Skip to content

Commit

Permalink
feat: support decoding
Browse files Browse the repository at this point in the history
  • Loading branch information
Keith-CY committed Mar 29, 2024
1 parent e025a1a commit 4710e4c
Show file tree
Hide file tree
Showing 10 changed files with 405 additions and 14 deletions.
2 changes: 2 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ module.exports = {
'no-redeclare': 'off',
'@typescript-eslint/no-redeclare': 'error',
'jsx-a11y/label-has-associated-control': 'off',
'jsx-a11y/no-static-element-interactions': 'off',
'jsx-a11y/no-noninteractive-element-interactions': 'off',
},
env: {
jest: true,
Expand Down
24 changes: 14 additions & 10 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Toast from './components/Toast'
import { isMainnet } from './utils/chain'
import { DASQueryContextProvider } from './hooks/useDASAccount'
import { getPrimaryColor, getSecondaryColor } from './constants/common'
import Decoder from './components/Decoder'

const appStyle = {
width: '100vw',
Expand All @@ -25,16 +26,19 @@ const App = () => {
)

return (
<ThemeProvider theme={theme}>
<div style={appStyle} data-net={isMainnet() ? 'mainnet' : 'testnet'}>
<QueryClientProvider client={queryClient}>
<DASQueryContextProvider>
<Routers />
<Toast />
</DASQueryContextProvider>
</QueryClientProvider>
</div>
</ThemeProvider>
<>
<ThemeProvider theme={theme}>
<div style={appStyle} data-net={isMainnet() ? 'mainnet' : 'testnet'}>
<QueryClientProvider client={queryClient}>
<DASQueryContextProvider>
<Routers />
<Toast />
</DASQueryContextProvider>
</QueryClientProvider>
</div>
</ThemeProvider>
<Decoder />
</>
)
}

Expand Down
227 changes: 227 additions & 0 deletions src/components/Decoder/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { addressToScript } from '@nervosnetwork/ckb-sdk-utils'
import BigNumber from 'bignumber.js'
import { useState, useRef, useEffect, useMemo } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { hexToUtf8 } from '../../utils/string'
import { useSetToast } from '../Toast'
import { ReactComponent as CopyIcon } from '../../assets/copy_icon.svg'
import styles from './styles.module.scss'

enum DecodeMethod {
LittleEndian = 'little-endian',
HexNumber = 'hex-number',
Utf8 = 'utf-8',
Address = 'address',
TOKEN_INFO = 'token-info',
XUDT_DATA = 'xudt-data',
JSON = 'json',
}

const WIDTH = 500

const prefixHex = (str: string) => {
let s = str.toLowerCase()
if (s.startsWith('ckb') || s.startsWith('ckt')) return s
if (s.startsWith('0x')) return s
if (s.startsWith('x')) {
s = s.slice(1)
}
if (s.length % 2 === 1) {
s += '0'
}
return `0x${s}`
}

const leToNum = (v: string) => {
const bytes = v.slice(2).match(/\w{2}/g)
if (!bytes) return ''
const val = `0x${bytes.reverse().join('')}`
if (Number.isNaN(+val)) {
throw new Error('Invalid little-endian')
}
return new BigNumber(val).toFormat()
}

const hexToTokenInfo = (v: string) => {
const decimal = v.slice(0, 4)
const nameLen = `0x${v.slice(4, 6)}`
const name = `0x${v.slice(6, 6 + +nameLen * 2)}`
const symbolLen = `0x${v.slice(6 + +nameLen * 2, 6 + +nameLen * 2 + 2)}`
const symbol = `0x${v.slice(6 + +nameLen * 2 + 2, 6 + +nameLen * 2 + 2 + +symbolLen * 2)}`
return {
name: hexToUtf8(name),
symbol: hexToUtf8(symbol),
decimal: +decimal,
}
}

const hexToXudtData = (v: string) => {
const amount = v.slice(0, 34)
const res: Partial<Record<'amount' | 'data', string>> = {
amount: leToNum(amount),
}
const data = v.slice(34)
if (data) {
res.data = data
}
return res
}

const Decoder = () => {
const [selection, setSelection] = useState<{
text: string
position: Record<'x' | 'y', number>
index: number
} | null>(null)
const [decodeMethod, setDecodeMethod] = useState<DecodeMethod>(DecodeMethod.LittleEndian)
const timerRef = useRef<NodeJS.Timer | null>(null)

const { t } = useTranslation()

const setToast = useSetToast()

useEffect(() => {
const handleSelectChange = () => {
if (timerRef.current) clearTimeout(timerRef.current)

timerRef.current = setTimeout(() => {
const selection = window.getSelection()
if (!selection || selection.isCollapsed) {
setSelection(null)
setDecodeMethod(DecodeMethod.LittleEndian)
return
}
const selectionText = selection.toString().trim()
const elm = selection.anchorNode?.parentElement
if (!selectionText || !elm) return
if (!elm.closest('[data-is-decodable=true]')) {
return
}

const range = selection.getRangeAt(0)
const rect = range.getBoundingClientRect()
let x = rect.left + window.pageXOffset
if (rect.width > WIDTH) {
x = rect.right - WIDTH
} else if (rect.right + WIDTH > window.innerWidth) {
x = window.innerWidth - WIDTH - 20
}
setSelection({
text: selectionText,
position: {
x,
y: rect.bottom + window.pageYOffset + 4,
},
index: range.startOffset,
})
}, 16)
}
window.document.addEventListener('selectionchange', handleSelectChange)
return () => {
window.document.removeEventListener('selectionchange', handleSelectChange)
}
}, [])

const handleDecodeMethodChange = (e: React.SyntheticEvent<HTMLUListElement>) => {
e.stopPropagation()
e.preventDefault()
const elm = e.target

if (elm instanceof HTMLLIElement) {
const { value } = elm.dataset
if (!value) return
setDecodeMethod(value as DecodeMethod)
}
}

const handleCopy = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
e.preventDefault()
const { detail } = e.currentTarget.dataset
if (!detail) return
navigator.clipboard.writeText(detail).then(() => {
setToast({ message: t('common.copied') })
})
}

const decoded = useMemo(() => {
if (!selection?.text) return ''

const v = prefixHex(selection.text)

try {
switch (decodeMethod) {
case DecodeMethod.Utf8: {
if (Number.isNaN(+v)) {
throw new Error('Invalid hex string')
}
return hexToUtf8(v)
}
case DecodeMethod.HexNumber: {
if (Number.isNaN(+v)) {
throw new Error('Invalid hex number')
}
return new BigNumber(v).toFormat()
}
case DecodeMethod.TOKEN_INFO: {
return JSON.stringify(hexToTokenInfo(v), null, 2)
}
case DecodeMethod.XUDT_DATA: {
return JSON.stringify(hexToXudtData(v), null, 2)
}
case DecodeMethod.LittleEndian: {
return leToNum(v)
}
case DecodeMethod.Address: {
return JSON.stringify(addressToScript(v), null, 2)
}
case DecodeMethod.JSON: {
const raw = JSON.parse(v)
if (!raw) return ''
return JSON.stringify(raw, null, 2)
}
default: {
throw new Error('Invalid decode method')
}
}
} catch (e) {
return t(`decoder.fail-to-decode`, { decode: t(`decoder.${decodeMethod}`) })
}
}, [decodeMethod, selection, t])

if (!selection) return null
return createPortal(
<div
className={styles.container}
style={{ maxWidth: WIDTH, left: selection.position.x, top: selection.position.y }}
data-role="decoder"
>
<div className={styles.head}>
<span>{t('decoder.view-data-as')}</span>
<div className={styles.select}>
<label>{t(`decoder.${decodeMethod}`)}</label>
<ul onClick={handleDecodeMethodChange} onKeyUp={handleDecodeMethodChange}>
{Object.values(DecodeMethod).map(m => (
<li key={m} data-value={m} data-is-active={m === decodeMethod}>
{t(`decoder.${m}`)}
</li>
))}
</ul>
</div>
</div>
<div className={styles.body}>
<pre>{decoded}</pre>
<button type="button" className={styles.copy} onClick={handleCopy} data-detail={decoded}>
<CopyIcon />
</button>
<div className={styles.count}>
{t('decoder.select-x-from-y', { x: selection.text.length, y: selection.index })}
</div>
</div>
</div>,
document.body,
)
}

export default Decoder
121 changes: 121 additions & 0 deletions src/components/Decoder/styles.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
.container {
min-width: 300px;
background: #fff;
position: absolute;
user-select: none;
border-radius: 4px;
box-shadow: 0 2px 10px 0 #eee;
z-index: 999;

.head {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #eee;
gap: 1rem;
padding: 0.5rem 1rem;
z-index: 1;
}

.select {
position: relative;
background: #fff;
width: 150px;

label {
display: flex;
justify-content: space-between;
gap: 0.5rem;
align-items: center;
padding: 0.5rem calc(1.5rem - 2px);
cursor: pointer;

&::after {
content: '';
display: block;
border: 4px solid transparent;
border-left: 4px solid currentcolor;
border-top: 4px solid currentcolor;
transform: translateY(-2px) rotate(-135deg);
}
}

&:hover {
label {
&::after {
transform: translateY(2px) rotate(45deg);
}
}

ul {
display: block;
}
}

ul {
display: none;
background: #fff;
width: 100%;
white-space: nowrap;
position: absolute;
list-style: none;
box-shadow: 0 2px 10px 0 #eee;
padding: 0.5rem;
border-radius: 4px;
}

li {
cursor: pointer;
border-radius: 4px;
padding: 0.5rem 1rem;

&:hover {
background: #f9f9f9;
}

&[data-is-active='true'] {
font-weight: 900;
}
}
}

.body {
padding: 1rem;
position: relative;

pre {
background: #f9f9f9;
border-radius: 4px;
margin: 0;
padding: 1.5rem 0.5rem 0.5rem;
word-break: break-all;
white-space: break-spaces;
font-family: Roboto, Lato, sans-serif, Montserrat, 'PingFang SC', -apple-system;
}

.count {
text-align: right;
font-size: 0.8em;
padding-top: 4px;
color: #999;
}

.copy {
z-index: 0;
position: absolute;
border: none;
background: none;
appearance: none;
right: 1.5rem;
top: 1.5rem;
cursor: pointer;
color: #999;

svg {
width: 1rem;
height: 1rem;
}
}
}
}
Loading

0 comments on commit 4710e4c

Please sign in to comment.