/ src / components / convert.tsx
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  }