convert.tsx
1 import { type ComponentProps, type InputEventHandler, type ClipboardEventHandler, useState, useEffectEvent, useEffect, useRef } from 'react' 2 import { getErrorMessage } from 'react-error-boundary' 3 4 import { type Radix, num2str, str2num, allowedCharaters, createRadix } from '#/radixes.ts' 5 import type { UpdateValue } from '#/app.tsx' 6 import { sanitizeInput } from '#/common.ts' 7 import { getCharsForTooltip } from './table.tsx' 8 9 10 const BIG_INT_0 = 0n 11 const BIG_INT_1 = 1n 12 13 export default function Convert({ radixes, value, updateValue }: { 14 radixes: Radix[], 15 value: bigint, 16 updateValue: UpdateValue, 17 }) { 18 const plusButtonRef = useRef<HTMLButtonElement>(null) 19 const deleteButtonRef = useRef<HTMLButtonElement>(null) 20 const minusButtonRef = useRef<HTMLButtonElement>(null) 21 22 const keyDown = useEffectEvent((e: KeyboardEvent) => { 23 switch (e.key) { 24 case 'Backspace': 25 case 'Delete': 26 deleteButtonRef.current?.focus() 27 updateValue(BIG_INT_0) 28 break 29 case '+': 30 case '=': 31 plusButtonRef.current?.focus() 32 updateValue(value + BIG_INT_1) 33 break 34 case '-': 35 case '_': 36 minusButtonRef.current?.focus() 37 updateValue(value - BIG_INT_1) 38 break 39 } 40 }) 41 42 useEffect(() => { 43 document.addEventListener('keydown', keyDown) 44 return () => { document.removeEventListener('keydown', keyDown) } 45 }, []) 46 47 return ( 48 <main className="flex flex-col text-[clamp(1.3rem,2.3vw,2.1rem)] mx-[clamp(0.5rem,1.5vw,2rem)]"> 49 <div className="flex relative lg:left-32 max-w-fit gap-1"> 50 <span className="tooltip tooltip-top" data-tip="Increment"> 51 <button className="btn btn-circle btn-sm md:btn-xs lg:btn-sm" ref={plusButtonRef} type="button" onClick={() => { updateValue(value + BIG_INT_1) }}>+</button> 52 </span> 53 <span className="tooltip tooltip-top" data-tip="Reset"> 54 <button className="btn btn-circle btn-sm md:btn-xs lg:btn-sm" ref={deleteButtonRef} type="button" onClick={() => { updateValue(BIG_INT_0) }}>␡</button> 55 </span> 56 <span className="tooltip tooltip-top" data-tip="Decrement"> 57 <button className="btn btn-circle btn-sm md:btn-xs lg:btn-sm" ref={minusButtonRef} type="button" onClick={() => { updateValue(value - BIG_INT_1) }}>-</button> 58 </span> 59 </div>{ radixes.map((radix, index) => 60 <div key={radix.name}> 61 <span className="hidden lg:inline-block text-center w-32"> 62 <span className="tooltip tooltip-right whitespace-pre before:content-[attr(data-tip)] before:max-w-200" data-tip={getCharsForTooltip(radix)}> 63 <span className="badge badge-neutral badge-outline badge-lg align-middle">{radix.name}</span> 64 </span> 65 </span> 66 <span className="hidden md:inline-flex gap-1"> 67 <div className="tooltip tooltip-top" data-tip="Filling shift left"> 68 <button className="btn btn-circle btn-xs lg:btn-sm inline-block align-middle" type="button" onClick={() => { updateValue(filling_shl(value, radix), radix) }}>⋘</button> 69 </div> 70 <div className="tooltip tooltip-top" data-tip="Shift left"> 71 <button className="btn btn-circle btn-xs lg:btn-sm inline-block align-middle" disabled={value === BIG_INT_0 || radix.system === 'bijective' || radix.system === 'sum'} type="button" onClick={() => { updateValue(shl(value, radix), radix) }}>≪</button> 72 </div> 73 <div className="tooltip tooltip-top" data-tip="Shift right"> 74 <button className="btn btn-circle btn-xs lg:btn-sm inline-block align-middle" disabled={value === BIG_INT_0} type="button" onClick={() => { updateValue(shr(value, radix), radix) }}>≫</button> 75 </div> 76 </span> 77 <span> = </span> 78 <NumberLine value={value} radix={radix} radixIndex={index} numRadixes={radixes.length} updateValue={updateValue}/> 79 </div>)} 80 </main> 81 ) 82 } 83 84 function NumberLine({ value, radix, radixIndex, numRadixes, updateValue }: ComponentProps<'div'> & { 85 value: bigint, 86 radix: Radix, 87 radixIndex: number, 88 numRadixes: number, 89 updateValue: UpdateValue, 90 }) { 91 const [ strVal, setStrVal ] = useState(num2str(value, radix)) 92 const [ editing, setEditing ] = useState(false) 93 const [ error, setError ] = useState<unknown>() 94 const [ errorLevel, setErrorLevel ] = useState<'error' | 'warning'>('error') 95 const ref = useRef<HTMLSpanElement>(null) 96 97 const updateError = (error: unknown, errorLvl: typeof errorLevel) => { 98 setError(error) 99 setErrorLevel(errorLvl) 100 setTimeout(() => { setError(undefined) }, 10_000) 101 } 102 103 const setCaretPosition = (position: number) => { 104 setTimeout(() => { if (ref.current) getSelection()?.setPosition(ref.current.childNodes[0], position) }, 0) 105 } 106 107 const handleInput: InputEventHandler<HTMLSpanElement> = e => { 108 e.stopPropagation() 109 110 const s = e.currentTarget.textContent.toUpperCase() 111 if (s === '') { 112 setStrVal('') 113 updateValue(BIG_INT_0) 114 return 115 } 116 117 let position = getCaretPosition() 118 try { 119 const n = str2num(s, radix) 120 setStrVal(s) 121 updateValue(n, radix) 122 setError(undefined) 123 } catch (error) { 124 updateError(error, 'error') 125 e.currentTarget.textContent = strVal 126 position -= 1 127 } 128 setCaretPosition(position) 129 } 130 131 const handlePaste: ClipboardEventHandler<HTMLSpanElement> = e => { 132 e.preventDefault() 133 134 const [ input, rest ] = sanitizeInput(e.clipboardData.getData('text'), radix) 135 if (rest) { 136 updateError(`Non-Base characters "${rest}" has been filtered out. ${allowedCharaters(radix)}`, 'warning') 137 } 138 139 const position = getCaretPosition() 140 const range = getSelection()?.getRangeAt(0) 141 const newV = range?.startContainer === ref.current ? input : Array.from(strVal).toSpliced(position, range ? range.endOffset - range.startOffset : 0, input).join('') 142 143 try { 144 updateValue(str2num(newV, radix), radix) 145 setStrVal(newV) 146 setCaretPosition(position + input.length) 147 } catch (error) { 148 updateError(error, 'error') 149 } 150 } 151 152 useEffect(() => { if (!editing) setStrVal(num2str(value, radix)) }, [ editing, value, radix ]) 153 154 return ( 155 <> 156 <span 157 className={`font-mono font-medium break-all outline-none${error ? ` tooltip tooltip-open tooltip-${errorLevel}` : ''}`} 158 data-tip={getErrorMessage(error) ?? 'Unknown error'} 159 role="textbox" 160 tabIndex={0} 161 contentEditable 162 suppressContentEditableWarning 163 spellCheck={false} 164 onKeyDown={e => { if (e.key === 'Escape' || e.key === 'Enter') { e.currentTarget.blur() } else e.stopPropagation() }} 165 onInput={handleInput} 166 onPaste={handlePaste} 167 onDoubleClick={() => { if (ref.current) getSelection()?.selectAllChildren(ref.current) }} 168 onFocus={() => { setEditing(true) }} 169 onBlur={() => { setEditing(false); setError(undefined); setStrVal(num2str(value, radix)) }} 170 style={{ color: `hsl(${radixIndex / numRadixes * 300} 80% 40%)` }} 171 ref={ref} 172 > 173 {strVal} 174 </span> 175 <sub className="lg:hidden align-middle text-[0.6rem]">{radix.name}</sub> 176 <span className="text-[0.5em]"> 177 <span> 178 <span> #{strVal.length} </span> 179 </span>{ getDigitSumArray(value, radix).map(([ sum, system ]) => 180 <span key={`${system}-${sum}`}> 181 <span className="whitespace-nowrap">∑</span> 182 <span>=</span> 183 <span className="font-mono font-medium">{sum}</span> 184 <sub className="text-nowrap">{system}</sub> 185 </span>)} 186 </span> 187 </> 188 ) 189 } 190 191 function getDigitSumArray(number: bigint, radix: Radix): [string, string][] { 192 let num = num2str(number, radix) 193 194 let neg = false 195 if (num.startsWith('-')) { 196 neg = true 197 num = num.slice(1) 198 } 199 200 let n = Iterator.from(num).reduce((a, v) => a + str2num(v, radix), 0n) 201 if (neg) n = -n 202 203 num = num2str(n, radix) 204 205 if (radix.system === 'standard' && radix.radix === 10n) { 206 return (num.length === 1 || neg && num.length === 2) ? [[ num, radix.name ]] : [[ num, radix.name ], ...getDigitSumArray(n, radix) ] 207 } 208 209 return [[ num, radix.name ], ...getDigitSumArray(n, createRadix(10, 'standard')) ] 210 } 211 212 const getCaretPosition = () => getSelection()?.getRangeAt(0).startOffset ?? 0 213 214 function filling_shl(value: bigint, radix: Radix): bigint { 215 return value ? value > 0 ? value * radix.radix + 1n : value * radix.radix - 1n : 1n 216 } 217 218 function shl(value: bigint, radix: Radix): bigint { 219 return value * radix.radix 220 } 221 222 function shr(value: bigint, radix: Radix): bigint { 223 return radix.system === 'sum' ? str2num(num2str(value, radix).slice(1), radix) : str2num(num2str(value, radix).slice(0, -1), radix) 224 }