UNPKG

@uiwwsw/virtual-keyboard

Version:

**A revolutionary virtual keyboard solution for React that solves the Korean `composition` issue.**

1,589 lines (1,577 loc) 59.1 kB
// src/components/Input.tsx import { useCallback, useEffect as useEffect2, useMemo, useRef, useState as useState2, useImperativeHandle, useId } from "react"; // src/components/ShadowWrapper.tsx import { useEffect, useState, memo } from "react"; import ReactDOM from "react-dom"; import { jsxDEV, Fragment } from "react/jsx-dev-runtime"; var ShadowWrapper = memo((props) => { const [host, setHost] = useState(null); const [shadowRoot, setShadowRoot] = useState(null); const { tagName, css, children, hostRef, ...rest } = props; const Component = tagName; useEffect(() => { if (host && !shadowRoot) { const shadow = host.attachShadow({ mode: "open" }); setShadowRoot(shadow); } if (hostRef) { hostRef.current = host; } }, [host, shadowRoot, hostRef]); return /* @__PURE__ */ jsxDEV(Component, { ref: setHost, ...rest, children: shadowRoot && ReactDOM.createPortal(/* @__PURE__ */ jsxDEV(Fragment, { children: [ css && /* @__PURE__ */ jsxDEV("style", { children: css }, undefined, false, undefined, this), children ] }, undefined, true, undefined, this), shadowRoot) }, undefined, false, undefined, this); }); ShadowWrapper.displayName = "ShadowWrapper"; // src/components/Context.tsx import { createContext, useContext } from "react"; var VirtualInputContext = createContext(null); function useVirtualInputContext() { const context = useContext(VirtualInputContext); if (!context) { throw new Error("Input must be used within an <VirtualInputProvider>. Wrap your app with <VirtualInputProvider>."); } return context; } // node_modules/es-hangul/dist/index.mjs function excludeLastElement(array) { const lastElement = array[array.length - 1]; return [array.slice(0, -1), lastElement != null ? lastElement : ""]; } function joinString(...args) { return args.join(""); } function isBlank(character) { return /^\s$/.test(character); } function assert(condition, errorMessage) { if (condition === false) { throw new Error(errorMessage != null ? errorMessage : "Invalid condition"); } } function hasValueInReadOnlyStringList(list, value) { return list.some((item) => item === value); } function hasProperty(obj, key) { return Object.prototype.hasOwnProperty.call(obj, key); } var _JASO_HANGUL_NFD = [..."각힣".normalize("NFD")].map((char) => char.charCodeAt(0)); var COMPLETE_HANGUL_START_CHARCODE = "가".charCodeAt(0); var COMPLETE_HANGUL_END_CHARCODE = "힣".charCodeAt(0); var NUMBER_OF_JONGSEONG = 28; var NUMBER_OF_JUNGSEONG = 21; var DISASSEMBLED_CONSONANTS_BY_CONSONANT = { "": "", ㄱ: "ㄱ", ㄲ: "ㄲ", ㄳ: "ㄱㅅ", ㄴ: "ㄴ", ㄵ: "ㄴㅈ", ㄶ: "ㄴㅎ", ㄷ: "ㄷ", ㄸ: "ㄸ", ㄹ: "ㄹ", ㄺ: "ㄹㄱ", ㄻ: "ㄹㅁ", ㄼ: "ㄹㅂ", ㄽ: "ㄹㅅ", ㄾ: "ㄹㅌ", ㄿ: "ㄹㅍ", ㅀ: "ㄹㅎ", ㅁ: "ㅁ", ㅂ: "ㅂ", ㅃ: "ㅃ", ㅄ: "ㅂㅅ", ㅅ: "ㅅ", ㅆ: "ㅆ", ㅇ: "ㅇ", ㅈ: "ㅈ", ㅉ: "ㅉ", ㅊ: "ㅊ", ㅋ: "ㅋ", ㅌ: "ㅌ", ㅍ: "ㅍ", ㅎ: "ㅎ" }; var DISASSEMBLED_VOWELS_BY_VOWEL = { ㅏ: "ㅏ", ㅐ: "ㅐ", ㅑ: "ㅑ", ㅒ: "ㅒ", ㅓ: "ㅓ", ㅔ: "ㅔ", ㅕ: "ㅕ", ㅖ: "ㅖ", ㅗ: "ㅗ", ㅘ: "ㅗㅏ", ㅙ: "ㅗㅐ", ㅚ: "ㅗㅣ", ㅛ: "ㅛ", ㅜ: "ㅜ", ㅝ: "ㅜㅓ", ㅞ: "ㅜㅔ", ㅟ: "ㅜㅣ", ㅠ: "ㅠ", ㅡ: "ㅡ", ㅢ: "ㅡㅣ", ㅣ: "ㅣ" }; var CHOSEONGS = [ "ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅉ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ" ]; var JUNSEONGS = Object.values(DISASSEMBLED_VOWELS_BY_VOWEL); var JONGSEONGS = [ "", "ㄱ", "ㄲ", "ㄳ", "ㄴ", "ㄵ", "ㄶ", "ㄷ", "ㄹ", "ㄺ", "ㄻ", "ㄼ", "ㄽ", "ㄾ", "ㄿ", "ㅀ", "ㅁ", "ㅂ", "ㅄ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ" ].map((consonant) => DISASSEMBLED_CONSONANTS_BY_CONSONANT[consonant]); var HANGUL_DIGITS = [ "", "만", "억", "조", "경", "해", "자", "양", "구", "간", "정", "재", "극", "항하사", "아승기", "나유타", "불가사의", "무량대수", "겁", "업" ]; var HANGUL_DIGITS_MAX = HANGUL_DIGITS.length * 4; function disassembleCompleteCharacter(letter) { const charCode = letter.charCodeAt(0); const isCompleteHangul = COMPLETE_HANGUL_START_CHARCODE <= charCode && charCode <= COMPLETE_HANGUL_END_CHARCODE; if (!isCompleteHangul) { return; } const hangulCode = charCode - COMPLETE_HANGUL_START_CHARCODE; const jongseongIndex = hangulCode % NUMBER_OF_JONGSEONG; const jungseongIndex = (hangulCode - jongseongIndex) / NUMBER_OF_JONGSEONG % NUMBER_OF_JUNGSEONG; const choseongIndex = Math.floor((hangulCode - jongseongIndex) / NUMBER_OF_JONGSEONG / NUMBER_OF_JUNGSEONG); return { choseong: CHOSEONGS[choseongIndex], jungseong: JUNSEONGS[jungseongIndex], jongseong: JONGSEONGS[jongseongIndex] }; } function disassembleToGroups(str) { const result = []; for (const letter of str) { const disassembledComplete = disassembleCompleteCharacter(letter); if (disassembledComplete != null) { result.push([ ...disassembledComplete.choseong, ...disassembledComplete.jungseong, ...disassembledComplete.jongseong ]); continue; } if (hasProperty(DISASSEMBLED_CONSONANTS_BY_CONSONANT, letter)) { const disassembledConsonant = DISASSEMBLED_CONSONANTS_BY_CONSONANT[letter]; result.push([...disassembledConsonant]); continue; } if (hasProperty(DISASSEMBLED_VOWELS_BY_VOWEL, letter)) { const disassembledVowel = DISASSEMBLED_VOWELS_BY_VOWEL[letter]; result.push([...disassembledVowel]); continue; } result.push([letter]); } return result; } function disassemble(str) { return disassembleToGroups(str).reduce((hanguls, disassembleds) => `${hanguls}${disassembleds.join("")}`, ""); } function canBeChoseong(character) { return hasValueInReadOnlyStringList(CHOSEONGS, character); } function canBeJongseong(character) { return hasValueInReadOnlyStringList(JONGSEONGS, character); } function canBeJungseong(character) { if (!character) { return false; } if (character in DISASSEMBLED_VOWELS_BY_VOWEL) { return true; } return hasValueInReadOnlyStringList(JUNSEONGS, character); } function combineVowels(vowel1, vowel2) { var _a, _b; return (_b = (_a = Object.entries(DISASSEMBLED_VOWELS_BY_VOWEL).find(([, value]) => value === `${vowel1}${vowel2}`)) == null ? undefined : _a[0]) != null ? _b : `${vowel1}${vowel2}`; } function combineCharacter(choseong, jungseong, jongseong = "") { if (canBeChoseong(choseong) === false || canBeJungseong(jungseong) === false || canBeJongseong(jongseong) === false) { throw new Error(`Invalid hangul Characters: ${choseong}, ${jungseong}, ${jongseong}`); } const numOfJungseongs = JUNSEONGS.length; const numOfJongseongs = JONGSEONGS.length; const choseongIndex = CHOSEONGS.indexOf(choseong); const jungseongIndex = JUNSEONGS.indexOf(jungseong); const jongseongIndex = JONGSEONGS.indexOf(jongseong); const choseongOfTargetConsonant = choseongIndex * numOfJungseongs * numOfJongseongs; const choseongOfTargetVowel = jungseongIndex * numOfJongseongs; const unicode = COMPLETE_HANGUL_START_CHARCODE + choseongOfTargetConsonant + choseongOfTargetVowel + jongseongIndex; return String.fromCharCode(unicode); } function hasBatchim(str, options) { const lastChar = str[str.length - 1]; if (lastChar == null) { return false; } const charCode = lastChar.charCodeAt(0); const isNotCompleteHangul = charCode < COMPLETE_HANGUL_START_CHARCODE || charCode > COMPLETE_HANGUL_END_CHARCODE; if (isNotCompleteHangul) { return false; } const batchimCode = (charCode - COMPLETE_HANGUL_START_CHARCODE) % NUMBER_OF_JONGSEONG; const batchimLength = JONGSEONGS[batchimCode].length; switch (options == null ? undefined : options.only) { case "single": { return batchimLength === 1; } case "double": { return batchimLength === 2; } default: { return batchimCode > 0; } } } function removeLastCharacter(words) { const lastCharacter = words[words.length - 1]; if (lastCharacter == null) { return ""; } const result = (() => { const disassembleLastCharacter = disassembleToGroups(lastCharacter); const [lastCharacterWithoutLastAlphabet] = excludeLastElement(disassembleLastCharacter[0]); if (lastCharacterWithoutLastAlphabet.length <= 3) { const [first, middle, last] = lastCharacterWithoutLastAlphabet; if (middle != null) { return canBeJungseong(last) ? combineCharacter(first, `${middle}${last}`) : combineCharacter(first, middle, last); } return first; } else { const [first, firstJungseong, secondJungseong, firstJongseong] = lastCharacterWithoutLastAlphabet; return combineCharacter(first, `${firstJungseong}${secondJungseong}`, firstJongseong); } })(); return [words.substring(0, words.length - 1), result].join(""); } function isHangulCharacter(character) { return /^[가-힣]$/.test(character); } function isHangulAlphabet(character) { return /^[ㄱ-ㅣ]$/.test(character); } function binaryAssembleAlphabets(source, nextCharacter) { if (canBeJungseong(`${source}${nextCharacter}`)) { return combineVowels(source, nextCharacter); } const isConsonantSource = canBeJungseong(source) === false; if (isConsonantSource && canBeJungseong(nextCharacter)) { return combineCharacter(source, nextCharacter); } return joinString(source, nextCharacter); } function linkHangulCharacters(source, nextCharacter) { const sourceJamo = disassembleToGroups(source)[0]; const [, lastJamo] = excludeLastElement(sourceJamo); return joinString(removeLastCharacter(source), combineCharacter(lastJamo, nextCharacter)); } function binaryAssembleCharacters(source, nextCharacter) { assert(isHangulCharacter(source) || isHangulAlphabet(source), `Invalid source character: ${source}. Source must be one character.`); assert(isHangulAlphabet(nextCharacter), `Invalid next character: ${nextCharacter}. Next character must be one of the choseong, jungseong, or jongseong.`); const sourceJamos = disassembleToGroups(source)[0]; const isSingleCharacter = sourceJamos.length === 1; if (isSingleCharacter) { const sourceCharacter = sourceJamos[0]; return binaryAssembleAlphabets(sourceCharacter, nextCharacter); } const [restJamos, lastJamo] = excludeLastElement(sourceJamos); const secondaryLastJamo = excludeLastElement(restJamos)[1]; const needLinking = canBeChoseong(lastJamo) && canBeJungseong(nextCharacter); if (needLinking) { return linkHangulCharacters(source, nextCharacter); } const fixConsonant = curriedCombineCharacter; const combineJungseong = fixConsonant(restJamos[0]); if (canBeJungseong(`${lastJamo}${nextCharacter}`)) { return combineJungseong(`${lastJamo}${nextCharacter}`)(); } if (canBeJungseong(`${secondaryLastJamo}${lastJamo}`) && canBeJongseong(nextCharacter)) { return combineJungseong(`${secondaryLastJamo}${lastJamo}`)(nextCharacter); } if (canBeJungseong(lastJamo) && canBeJongseong(nextCharacter)) { return combineJungseong(lastJamo)(nextCharacter); } const fixVowel = combineJungseong; const combineJongseong = fixVowel(canBeJungseong(`${restJamos[1]}${restJamos[2]}`) ? `${restJamos[1]}${restJamos[2]}` : restJamos[1]); const lastConsonant = lastJamo; if (hasBatchim(source, { only: "single" }) && canBeJongseong(`${lastConsonant}${nextCharacter}`)) { return combineJongseong(`${lastConsonant}${nextCharacter}`); } return joinString(source, nextCharacter); } function binaryAssemble(source, nextCharacter) { const [rest, lastCharacter] = excludeLastElement(source.split("")); const needJoinString = isBlank(lastCharacter) || isBlank(nextCharacter); return joinString(...rest, needJoinString ? joinString(lastCharacter, nextCharacter) : binaryAssembleCharacters(lastCharacter, nextCharacter)); } var curriedCombineCharacter = (choseong) => (jungseong) => (jongseong = "") => combineCharacter(choseong, jungseong, jongseong); function assemble(fragments) { const disassembled = disassemble(fragments.join("")).split(""); return disassembled.reduce(binaryAssemble); } var JASO_HANGUL_NFD = { START_CHOSEONG: _JASO_HANGUL_NFD[0], START_JUNGSEONG: _JASO_HANGUL_NFD[1], START_JONGSEONG: _JASO_HANGUL_NFD[2], END_CHOSEONG: _JASO_HANGUL_NFD[3], END_JUNGSEONG: _JASO_HANGUL_NFD[4], END_JONGSEONG: _JASO_HANGUL_NFD[5] }; var EXTRACT_CHOSEONG_REGEX = new RegExp(`[^\\u${JASO_HANGUL_NFD.START_CHOSEONG.toString(16)}-\\u${JASO_HANGUL_NFD.END_CHOSEONG.toString(16)}ㄱ-ㅎ\\s]+`, "ug"); var CHOOSE_NFD_CHOSEONG_REGEX = new RegExp(`[\\u${JASO_HANGUL_NFD.START_CHOSEONG.toString(16)}-\\u${JASO_HANGUL_NFD.END_CHOSEONG.toString(16)}]`, "g"); var 로__ = ["으로/로", "으로서/로서", "으로써/로써", "으로부터/로부터"]; function josa(word, josa2) { if (word.length === 0) { return word; } return word + josaPicker(word, josa2); } josa.pick = josaPicker; function josaPicker(word, josa2) { var _a; if (word.length === 0) { return josa2.split("/")[0]; } const has_ = hasBatchim(word); let index = has_ ? 0 : 1; const is_ = has_ && ((_a = disassembleCompleteCharacter(word[word.length - 1])) == null ? undefined : _a.jongseong) === "ㄹ"; const isCaseOf_ = has_ && is_ && 로__.includes(josa2); if (josa2 === "와/과" || isCaseOf_) { index = index === 0 ? 1 : 0; } const isEndsWith_ = word[word.length - 1] === "이"; if (josa2 === "이에요/예요" && isEndsWith_) { index = 1; } return josa2.split("/")[index]; } var QWERTY_KEYBOARD_MAP = { q: "ㅂ", Q: "ㅃ", w: "ㅈ", W: "ㅉ", e: "ㄷ", E: "ㄸ", r: "ㄱ", R: "ㄲ", t: "ㅅ", T: "ㅆ", y: "ㅛ", Y: "ㅛ", u: "ㅕ", U: "ㅕ", i: "ㅑ", I: "ㅑ", o: "ㅐ", O: "ㅒ", p: "ㅔ", P: "ㅖ", a: "ㅁ", A: "ㅁ", s: "ㄴ", S: "ㄴ", d: "ㅇ", D: "ㅇ", f: "ㄹ", F: "ㄹ", g: "ㅎ", G: "ㅎ", h: "ㅗ", H: "ㅗ", j: "ㅓ", J: "ㅓ", k: "ㅏ", K: "ㅏ", l: "ㅣ", L: "ㅣ", z: "ㅋ", Z: "ㅋ", x: "ㅌ", X: "ㅌ", c: "ㅊ", C: "ㅊ", v: "ㅍ", V: "ㅍ", b: "ㅠ", B: "ㅠ", n: "ㅜ", N: "ㅜ", m: "ㅡ", M: "ㅡ" }; function convertQwertyToAlphabet(word) { return word.split("").map((inputText) => hasProperty(QWERTY_KEYBOARD_MAP, inputText) ? QWERTY_KEYBOARD_MAP[inputText] : inputText).join(""); } function convertQwertyToHangul(word) { if (!word) { return ""; } return assemble([...convertQwertyToAlphabet(word)]); } // src/utils/isHangul.ts function isHangul(char) { return /^[\u1100-\u11FF\u3130-\u318F\uAC00-\uD7AF]$/.test(char); } // src/utils/parseKeyInput.ts function parseKeyInput(e, hangulMode) { const isMac = navigator.userAgent.includes("Mac"); if (!isMac && e.key === "HangulMode") { return { handled: true, toggleHangulMode: true }; } const ignoredKeys = new Set([ "Shift", "Control", "Alt", "Meta", "CapsLock", "Enter", "Tab", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Backspace", "Delete", "Unidentified" ]); if (ignoredKeys.has(e.key)) { return { handled: false }; } if (isMac) { if (isHangul(e.key)) { return { handled: true, text: e.key, composing: true }; } else { return { handled: true, text: e.key, composing: false }; } } if (hangulMode && e.key.length === 1) { return { handled: true, text: isHangul(e.key) ? e.key : convertQwertyToHangul(e.key), composing: true }; } return { handled: true, text: e.key, composing: false }; } // src/components/Input.tsx import { jsxDEV as jsxDEV2 } from "react/jsx-dev-runtime"; function VirtualInput({ value: controlledValue, defaultValue, placeholder, onChange, ...props }) { const id = useId(); const canvasRef = useRef(null); const containerRef = useRef(null); const [internalValue, setInternalValue] = useState2(controlledValue ?? defaultValue ?? ""); const [caretIndex, setCaretIndex] = useState2(() => (controlledValue ?? defaultValue ?? "").length); const internalValueRef = useRef(internalValue); const caretIndexRef = useRef(caretIndex); const [selection, setSelection] = useState2({ start: null, end: null }); const [showCursor, setShowCursor] = useState2(true); const showCursorRef = useRef(showCursor); useEffect2(() => { showCursorRef.current = showCursor; }, [showCursor]); const longPressTimerRef = useRef(null); const [showCopyFeedback, setShowCopyFeedback] = useState2(false); const pointerStartedRef = useRef(false); const pointerStartPositionRef = useRef(null); const charPositionsRef = useRef([]); const scrollXRef = useRef(0); useEffect2(() => { charPositionsRef.current = []; }, [internalValue]); const { focusId, onFocus, onBlur, hangulMode, setHangulMode, isCompositionRef, inputRef, shift } = useVirtualInputContext(); const isFocused = focusId === id; useEffect2(() => { if (controlledValue !== undefined && controlledValue !== internalValue) { setInternalValue(controlledValue); internalValueRef.current = controlledValue; } }, [controlledValue, internalValue]); useEffect2(() => { if (!isFocused) return; const interval = setInterval(() => { setShowCursor((prev) => !prev); }, 530); return () => clearInterval(interval); }, [isFocused]); useEffect2(() => { setShowCursor(true); }, [caretIndex, internalValue]); const hasSelection = useMemo(() => selection.start !== null && selection.end !== null && selection.start !== selection.end, [selection]); const clearSelection = useCallback(() => { setSelection({ start: null, end: null }); }, []); const deleteSelectedText = useCallback(() => { const currentVal = internalValueRef.current; if (!hasSelection || selection.start === null || selection.end === null) { return { newString: currentVal, finalCaretIndex: caretIndexRef.current }; } const [start, end] = [selection.start, selection.end].sort((a, b) => a - b); const newString = currentVal.slice(0, start) + currentVal.slice(end); clearSelection(); return { newString, finalCaretIndex: start }; }, [ hasSelection, selection, clearSelection ]); const draw = useCallback(() => { const canvas = canvasRef.current; const container = containerRef.current; if (!canvas || !container) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const dpr = window.devicePixelRatio || 1; const rect = container.getBoundingClientRect(); if (canvas.width !== rect.width * dpr || canvas.height !== rect.height * dpr) { canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.scale(dpr, dpr); charPositionsRef.current = []; } const width = rect.width; const height = rect.height; ctx.clearRect(0, 0, width, height); const computedStyle = window.getComputedStyle(container); const font = `${computedStyle.fontStyle} ${computedStyle.fontVariant} ${computedStyle.fontWeight} ${computedStyle.fontSize} ${computedStyle.fontFamily}`; ctx.font = font; ctx.textBaseline = "middle"; const fontSize = parseFloat(computedStyle.fontSize); const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0; const paddingRight = parseFloat(computedStyle.paddingRight) || 0; const borderLeft = parseFloat(computedStyle.borderLeftWidth) || 0; const borderRight = parseFloat(computedStyle.borderRightWidth) || 0; const color = computedStyle.color || "black"; const caretColor = (computedStyle.caretColor !== "auto" ? computedStyle.caretColor : color) || "black"; const y = height / 2; const x = borderLeft + paddingLeft; const text = internalValueRef.current; const cIndex = caretIndexRef.current; const isCursorVisible = showCursorRef.current; if (!text && placeholder) { ctx.fillStyle = "#aaa"; ctx.fillText(placeholder, x, y); if (isFocused && isCursorVisible) { ctx.beginPath(); ctx.moveTo(x, y - fontSize / 2); ctx.lineTo(x, y + fontSize / 2); ctx.strokeStyle = caretColor; ctx.lineWidth = 1.5; ctx.stroke(); } return; } ctx.fillStyle = color; if (charPositionsRef.current.length !== text.length + 1) { const positions = [x]; for (let i = 0;i < text.length; i++) { const w = ctx.measureText(text.slice(0, i + 1)).width; positions.push(x + w); } charPositionsRef.current = positions; } const charX = charPositionsRef.current; const caretPos = charX[cIndex] ?? x; const viewLeft = paddingLeft; const viewRight = width - paddingRight - borderLeft - borderRight; const safety = 2; const totalTextWidth = (charX[charX.length - 1] ?? x) - x; const availableWidth = viewRight - viewLeft; if (totalTextWidth < availableWidth) { scrollXRef.current = 0; } else { let s = scrollXRef.current; if (caretPos - s > viewRight - safety) { s = caretPos - viewRight + safety; } if (caretPos - s < viewLeft + safety) { s = caretPos - viewLeft - safety; } const contentEnd = charX[charX.length - 1] ?? x; const maxScroll = Math.max(0, contentEnd - viewRight + safety); s = Math.max(0, Math.min(s, maxScroll)); scrollXRef.current = s; } ctx.save(); ctx.translate(-scrollXRef.current, 0); if (hasSelection && selection.start !== null && selection.end !== null) { const [start, end] = [selection.start, selection.end].sort((a, b) => a - b); const startX = charX[start] ?? x; const endX = charX[end] ?? charX[charX.length - 1]; ctx.fillStyle = "#b4d5fe"; ctx.fillRect(startX, 0, endX - startX, height); ctx.fillStyle = color; } ctx.fillText(text, x, y); if (isFocused && isCursorVisible && !hasSelection) { const caretX = charX[cIndex] ?? x; ctx.beginPath(); ctx.moveTo(caretX, y - fontSize / 2); ctx.lineTo(caretX, y + fontSize / 2); ctx.strokeStyle = caretColor; ctx.lineWidth = 1.5; ctx.stroke(); } if (showCopyFeedback) { ctx.save(); ctx.translate(scrollXRef.current, 0); ctx.fillStyle = "rgba(0, 0, 0, 0.7)"; const msg = "Copied!"; const msgWidth = ctx.measureText(msg).width + 20; const msgHeight = fontSize + 10; const cx = width / 2; const cy = height / 2; const r = 6; const rw = msgWidth; const rh = msgHeight; const rx = cx - rw / 2; const ry = cy - rh / 2; ctx.beginPath(); ctx.fillStyle = "rgba(30, 41, 59, 0.9)"; ctx.roundRect(rx, ry, rw, rh, r); ctx.fill(); ctx.fillStyle = "#ffffff"; ctx.font = `bold ${fontSize * 0.8}px system-ui`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(msg, cx, cy); ctx.restore(); } ctx.restore(); }, [isFocused, placeholder, hasSelection, selection, showCopyFeedback]); useEffect2(() => { let animationFrameId; const loop = () => { draw(); animationFrameId = requestAnimationFrame(loop); }; loop(); return () => cancelAnimationFrame(animationFrameId); }, [draw]); const updateValue = useCallback((newValue, newCaretIndex) => { internalValueRef.current = newValue; caretIndexRef.current = newCaretIndex; if (controlledValue === undefined) { setInternalValue(newValue); } const fakeInput = { value: newValue }; const changeEvent = { target: fakeInput, currentTarget: fakeInput }; onChange?.(changeEvent); setCaretIndex(newCaretIndex); }, [controlledValue, onChange]); const getCharIndexFromX = useCallback((clientX) => { const canvas = canvasRef.current; if (!canvas) return 0; const rect = canvas.getBoundingClientRect(); const x = clientX - rect.left + scrollXRef.current; if (charPositionsRef.current.length === internalValue.length + 1) { const charX = charPositionsRef.current; for (let i = 0;i < charX.length - 1; i++) { const center = (charX[i] + charX[i + 1]) / 2; if (x < center) return i; } return internalValue.length; } const ctx = canvas.getContext("2d"); if (!ctx) return 0; const computedStyle = window.getComputedStyle(containerRef.current); ctx.font = `${computedStyle.fontSize} ${computedStyle.fontFamily}`; let currentX = 0; for (let i = 0;i < internalValue.length; i++) { const w = ctx.measureText(internalValue[i]).width; if (x < currentX + w / 2) return i; currentX += w; } return internalValue.length; }, [internalValue]); const handleCanvasClick = useCallback((e) => { if (showCopyFeedback) return; const index = getCharIndexFromX(e.clientX); caretIndexRef.current = index; setCaretIndex(index); clearSelection(); onFocus(id); }, [getCharIndexFromX, clearSelection, onFocus, id, showCopyFeedback]); const handleCopy = useCallback((e) => { e.preventDefault(); const currentVal = internalValueRef.current; const sel = selection; if (sel.start !== null && sel.end !== null && sel.start !== sel.end) { const [start, end] = [sel.start, sel.end].sort((a, b) => a - b); const textToCopy = currentVal.slice(start, end); e.clipboardData.setData("text/plain", textToCopy); if (navigator.vibrate) navigator.vibrate(50); setShowCopyFeedback(true); setTimeout(() => setShowCopyFeedback(false), 1500); } }, [selection]); const handleCut = useCallback((e) => { e.preventDefault(); const currentVal = internalValueRef.current; if (hasSelection && selection.start !== null && selection.end !== null) { const [start, end] = [selection.start, selection.end].sort((a, b) => a - b); const textToCopy = currentVal.slice(start, end); e.clipboardData.setData("text/plain", textToCopy); if (navigator.vibrate) navigator.vibrate(50); setShowCopyFeedback(true); setTimeout(() => setShowCopyFeedback(false), 1500); const { newString, finalCaretIndex } = deleteSelectedText(); updateValue(newString, finalCaretIndex); } }, [hasSelection, selection, deleteSelectedText, updateValue]); const handlePaste = useCallback((e) => { e.preventDefault(); const text = e.clipboardData.getData("text/plain"); if (!text) return; const currentVal = internalValueRef.current; const currentCaret = caretIndexRef.current; const { newString, finalCaretIndex } = hasSelection ? deleteSelectedText() : { newString: currentVal, finalCaretIndex: currentCaret }; const finalString = newString.slice(0, finalCaretIndex) + text + newString.slice(finalCaretIndex); updateValue(finalString, finalCaretIndex + text.length); }, [hasSelection, deleteSelectedText, updateValue]); const cancelLongPress = useCallback(() => { if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); longPressTimerRef.current = null; } pointerStartedRef.current = false; pointerStartPositionRef.current = null; }, []); const handlePointerDown = useCallback((e) => { if (e.button !== 0) return; cancelLongPress(); pointerStartedRef.current = true; pointerStartPositionRef.current = { x: e.clientX, y: e.clientY }; longPressTimerRef.current = window.setTimeout(async () => { if (!pointerStartedRef.current) return; pointerStartedRef.current = false; try { if (internalValueRef.current) { await navigator.clipboard.writeText(internalValueRef.current); if (navigator.vibrate) navigator.vibrate(50); setShowCopyFeedback(true); setTimeout(() => setShowCopyFeedback(false), 1500); } } catch (err) { console.error("Failed to copy", err); } }, 600); }, [cancelLongPress]); const handlePointerMove = useCallback((e) => { if (!pointerStartedRef.current || !pointerStartPositionRef.current) return; const dx = Math.abs(e.clientX - pointerStartPositionRef.current.x); const dy = Math.abs(e.clientY - pointerStartPositionRef.current.y); if (dx > 10 || dy > 10) cancelLongPress(); }, [cancelLongPress]); const handlePointerUp = useCallback(() => { cancelLongPress(); }, [cancelLongPress]); const handleKeyDown = useCallback((e) => { const currentVal = internalValueRef.current; const currentCaret = caretIndexRef.current; if (e.key === "HangulMode") { setHangulMode(!hangulMode); return; } const isCmd = e.ctrlKey || e.metaKey; const isHome = e.key === "ArrowUp" || isCmd && e.key === "ArrowLeft" || e.key === "Home"; const isEnd = e.key === "ArrowDown" || isCmd && e.key === "ArrowRight" || e.key === "End"; if (isHome) { e.preventDefault(); const newIndex = 0; if (e.shiftKey) { setSelection((prev) => ({ start: prev.start ?? currentCaret, end: newIndex })); } else { clearSelection(); } caretIndexRef.current = newIndex; setCaretIndex(newIndex); return; } if (isEnd) { e.preventDefault(); const newIndex = currentVal.length; if (e.shiftKey) { setSelection((prev) => ({ start: prev.start ?? currentCaret, end: newIndex })); } else { clearSelection(); } caretIndexRef.current = newIndex; setCaretIndex(newIndex); return; } if (e.ctrlKey || e.metaKey) { const key = e.key.toLowerCase(); if (key === "a") { e.preventDefault(); setSelection({ start: 0, end: currentVal.length }); caretIndexRef.current = currentVal.length; setCaretIndex(currentVal.length); return; } } if (e.shiftKey && (e.key === "ArrowLeft" || e.key === "ArrowRight")) { const newIndex = e.key === "ArrowLeft" ? Math.max(0, currentCaret - 1) : Math.min(currentVal.length, currentCaret + 1); setSelection((prev) => ({ start: prev.start ?? currentCaret, end: newIndex })); caretIndexRef.current = newIndex; setCaretIndex(newIndex); return; } switch (e.key) { case "ArrowLeft": { const newIndex = Math.max(0, currentCaret - 1); caretIndexRef.current = newIndex; setCaretIndex(newIndex); clearSelection(); return; } case "ArrowRight": { const newIndex = Math.min(currentVal.length, currentCaret + 1); caretIndexRef.current = newIndex; setCaretIndex(newIndex); clearSelection(); return; } case "Backspace": { isCompositionRef.current = false; if (hasSelection) { const { newString: newString2, finalCaretIndex: finalCaretIndex2 } = deleteSelectedText(); updateValue(newString2, finalCaretIndex2); } else if (currentCaret > 0) { const newString2 = currentVal.slice(0, currentCaret - 1) + currentVal.slice(currentCaret); updateValue(newString2, currentCaret - 1); } return; } } const result = parseKeyInput(e, hangulMode); if (result.toggleHangulMode) { setHangulMode(!hangulMode); return; } if (!result.handled || !result.text) return; if (e.ctrlKey || e.metaKey || e.altKey) return; const { composing } = result; let { text } = result; if (shift && text.length === 1 && text.match(/[a-z]/i)) { text = text.toUpperCase(); } const { newString, finalCaretIndex } = hasSelection ? deleteSelectedText() : { newString: currentVal, finalCaretIndex: currentCaret }; const prevChar = newString[finalCaretIndex - 1]; if (composing && isHangul(prevChar) && isCompositionRef.current) { const combined = assemble([prevChar, text]); const finalString = newString.slice(0, finalCaretIndex - 1) + combined + newString.slice(finalCaretIndex); updateValue(finalString, finalCaretIndex - 1 + combined.length); } else { const finalString = newString.slice(0, finalCaretIndex) + text + newString.slice(finalCaretIndex); updateValue(finalString, finalCaretIndex + text.length); } isCompositionRef.current = composing; }, [hangulMode, setHangulMode, isCompositionRef, shift, updateValue, hasSelection, deleteSelectedText, clearSelection]); useImperativeHandle(inputRef, () => { if (isFocused) { return { handleKeyDown, scrollIntoView: () => { containerRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" }); } }; } return inputRef.current; }, [isFocused, handleKeyDown, inputRef]); return /* @__PURE__ */ jsxDEV2(ShadowWrapper, { tagName: "virtual-input-canvas", hostRef: containerRef, id, className: props.className, role: "textbox", tabIndex: 0, ...props, style: { display: "inline-block", minHeight: "1.5em", height: "1.5em", outline: "none", font: "inherit", width: "100%", position: "relative", cursor: "text", ...props.style }, onFocus: () => onFocus(id, containerRef.current), onBlur: (e) => onBlur(e), onKeyDown: handleKeyDown, onContextMenu: (e) => e.preventDefault(), onCopy: handleCopy, onCut: handleCut, onPaste: handlePaste, onClick: handleCanvasClick, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerLeave: handlePointerUp, onPointerCancel: handlePointerUp, "data-virtual-input": "true", css: ` :host { display: inline-block; position: relative; } canvas { width: 100%; height: 100%; display: block; } `, children: /* @__PURE__ */ jsxDEV2("canvas", { ref: canvasRef, style: { width: "100%", height: "100%", display: "block" } }, undefined, false, undefined, this) }, undefined, false, undefined, this); } // src/components/Provider.tsx import { useState as useState6, useRef as useRef5, useCallback as useCallback6, useEffect as useEffect8 } from "react"; // src/components/Keypad.tsx import { useRef as useRef4, useEffect as useEffect4, useCallback as useCallback4 } from "react"; import { createPortal } from "react-dom"; // src/utils/isMobileAgent.ts function isMobileAgent(userAgent = navigator.userAgent) { const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i; return mobileRegex.test(userAgent); } // src/hooks/useKeypadLayout.ts import { useCallback as useCallback2, useRef as useRef2 } from "react"; function useKeypadLayout({ layout, viewport, hangulMode, shift }) { const keyBoundsRef = useRef2([]); const getTransformedValue = useCallback2((cell) => { if (cell.type === "char") { const isConvertibleHangulSource = hangulMode && /[a-zA-Z]/.test(cell.value) && cell.value.length === 1; if (isConvertibleHangulSource) { return convertQwertyToHangul(cell.value); } if (shift) { return cell.value.toUpperCase(); } return cell.value; } return cell.label ?? cell.value; }, [hangulMode, shift]); const calculateLayout = useCallback2(() => { if (!layout) return []; const scale = viewport.scale; const compactRatio = Math.max(0, Math.min(1, (viewport.width - 260) / 140)); const padding = (2 + (10 - 2) * compactRatio) / scale; const gap = (2 + (8 - 2) * compactRatio) / scale; const totalHeight = 200 / scale; const width = viewport.width; const height = totalHeight; const contentW = width - padding * 2; const contentH = height - padding * 2; const bounds = []; const rowCount = layout.length; const rowHeight = (contentH - (rowCount - 1) * gap) / rowCount; let currentY = padding; layout.forEach((row, rowIndex) => { const rowWidth = contentW; const totalFlexGrow = row.reduce((acc, cell) => acc + (cell.width || 1), 0); const totalGapW = (row.length - 1) * gap; const availableW = rowWidth - totalGapW; const unitW = availableW / totalFlexGrow; let currentX = padding; row.forEach((cell, colIndex) => { const flex = cell.width || 1; const cellW = flex * unitW; bounds.push({ x: currentX, y: currentY, w: cellW, h: rowHeight, value: cell.value, type: cell.type, label: getTransformedValue(cell), rowIndex, colIndex, isAction: cell.type === "action" }); currentX += cellW + gap; }); currentY += rowHeight + gap; }); return bounds; }, [layout, viewport, getTransformedValue]); return { keyBoundsRef, calculateLayout, getTransformedValue }; } // src/hooks/useKeypadInteraction.ts import { useCallback as useCallback3, useRef as useRef3, useMemo as useMemo2, useEffect as useEffect3 } from "react"; function useKeypadInteraction({ inputRef, toggleShift, toggleKorean, shift, getTransformedValue, keyBoundsRef }) { const activePresses = useRef3(new Map); const repeatableKeys = useMemo2(() => new Set(["char", "Backspace", "Delete", "Space", "ArrowLeft", "ArrowRight"]), []); const dispatchKeyEvent = useCallback3((value, type) => { if (typeof navigator !== "undefined" && "vibrate" in navigator) { navigator.vibrate?.(10); } if (type === "action") { if (value === "Shift") { toggleShift(); return; } if (value === "HangulMode") { toggleKorean(); return; } } const event = new KeyboardEvent("keydown", { key: getTransformedValue({ value, type }), code: `Key${value.toUpperCase()}`, bubbles: true, cancelable: true, shiftKey: shift }); inputRef.current?.handleKeyDown(event); inputRef.current?.scrollIntoView(); }, [getTransformedValue, inputRef, shift, toggleKorean, toggleShift]); const clearRepeat = useCallback3((press) => { if (!press?.repeatTimeout) return; window.clearTimeout(press.repeatTimeout); }, []); const startRepeat = useCallback3((press) => { const canRepeat = press.type && repeatableKeys.has(press.type) || repeatableKeys.has(press.value); if (!canRepeat) return; const firstDelay = 500; const repeatInterval = 60; const tick = () => { const storedPress = activePresses.current.get(press.pointerId); if (!storedPress) return; dispatchKeyEvent(storedPress.value, storedPress.type); storedPress.repeatTimeout = window.setTimeout(tick, repeatInterval); activePresses.current.set(press.pointerId, storedPress); }; press.repeatTimeout = window.setTimeout(tick, firstDelay); activePresses.current.set(press.pointerId, press); }, [dispatchKeyEvent, repeatableKeys]); const handlePointerDown = useCallback3((e) => { e.preventDefault(); const canvas = e.currentTarget; if (!canvas) return; canvas.setPointerCapture(e.pointerId); activePresses.current.forEach((press) => { clearRepeat(press); }); const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const hitKey = keyBoundsRef.current.find((k) => x >= k.x && x <= k.x + k.w && y >= k.y && y <= k.y + k.h); if (hitKey) { const keyIndex = hitKey.rowIndex * 100 + hitKey.colIndex; dispatchKeyEvent(hitKey.value, hitKey.type); const press = { pointerId: e.pointerId, value: hitKey.value, type: hitKey.type, keyIndex }; activePresses.current.set(e.pointerId, press); startRepeat(press); } else { activePresses.current.set(e.pointerId, { pointerId: e.pointerId, value: "", keyIndex: -1 }); } }, [dispatchKeyEvent, startRepeat, keyBoundsRef]); const handlePointerMove = useCallback3((e) => { e.preventDefault(); const press = activePresses.current.get(e.pointerId); if (!press) return; const canvas = e.currentTarget; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const hitKey = keyBoundsRef.current.find((k) => x >= k.x && x <= k.x + k.w && y >= k.y && y <= k.y + k.h); if (hitKey) { const newKeyIndex = hitKey.rowIndex * 100 + hitKey.colIndex; if (newKeyIndex !== press.keyIndex) { clearRepeat(press); dispatchKeyEvent(hitKey.value, hitKey.type); const newPress = { pointerId: e.pointerId, value: hitKey.value, type: hitKey.type, keyIndex: newKeyIndex }; activePresses.current.set(e.pointerId, newPress); startRepeat(newPress); } } else { if (press.keyIndex !== -1) { clearRepeat(press); activePresses.current.set(e.pointerId, { pointerId: e.pointerId, value: "", keyIndex: -1 }); } } }, [clearRepeat, dispatchKeyEvent, startRepeat, keyBoundsRef]); const handlePointerUp = useCallback3((e) => { e.preventDefault(); const press = activePresses.current.get(e.pointerId); if (press) { clearRepeat(press); activePresses.current.delete(e.pointerId); } }, [clearRepeat]); useEffect3(() => { return () => { activePresses.current.forEach((press) => { if (press.repeatTimeout) { window.clearTimeout(press.repeatTimeout); } }); activePresses.current.clear(); }; }, []); const handlePointerCancel = useCallback3((e) => { handlePointerUp(e); }, [handlePointerUp]); return { activePresses, handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel }; } // src/components/Keypad.tsx import { jsxDEV as jsxDEV3 } from "react/jsx-dev-runtime"; function VirtualKeypad({ layout, viewport }) { const { focusId, onBlur, onFocus, inputRef, shift, hangulMode, toggleShift, toggleKorean, theme } = useVirtualInputContext(); const canvasRef = useRef4(null); const containerRef = useRef4(null); const { keyBoundsRef, calculateLayout, getTransformedValue } = useKeypadLayout({ layout, viewport, hangulMode, shift }); const { activePresses, handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = useKeypadInteraction({ inputRef, toggleShift, toggleKorean, shift, getTransformedValue, keyBoundsRef }); const drawKey = (ctx, key, isPressed, isActiveModifier, scale, currentTheme) => { const r = 10 / scale; const { x, y, w, h } = key; const colors = currentTheme === "dark" ? { shadow: "#0f172a", keyFace: isPressed ? "#334155" : "#1e293b", text: "#f8fafc", actionFace: isPressed ? "#475569" : "#334155", activeModFace: isPressed ? "#f8fafc" : "#e2e8f0", activeModText: "#0f172a" } : { shadow: "#cbd5e1", keyFace: isPressed ? "#f1f5f9" : "#ffffff", text: "#1e293b", actionFace: isPressed ? "#e2e8f0" : "#f1f5f9", activeModFace: isPressed ? "#bfdbfe" : "#dbeafe", activeModText: "#1d4ed8" }; let faceColor = colors.keyFace; let textColor = colors.text; if (key.isAction) { faceColor = colors.actionFace; } if (isActiveModifier) { faceColor = colors.activeModFace; textColor = colors.activeModText; } const depth = 4 / scale; const pressOffset = isPressed ? depth * 0.6 : 0; ctx.fillStyle = colors.shadow; roundRect(ctx, x, y + depth, w, h, r); ctx.fill(); ctx.fillStyle = faceColor; roundRect(ctx, x, y + pressOffset, w, h, r); ctx.fill(); ctx.textAlign = "center"; ctx.textBaseline = "middle"; let fontSize = 20 / scale; if (key.label.length > 1) fontSize = 16 / scale; const fontFamily = currentTheme === "dark" ? "Inter, system-ui, sans-serif" : "Inter, system-ui, sans-serif"; ctx.font = `500 ${fontSize}px "${fontFamily}"`; ctx.fillStyle = textColor; ctx.fillText(key.label, x + w / 2, y + h / 2 + pressOffset); }; const roundRect = (ctx, x, y, w, h, r) => { if (w < 2 * r) r = w / 2; if (h < 2 * r) r = h / 2; ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); }; const draw = useCallback4(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d", { alpha: false }); if (!ctx) return; const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); if (Math.abs(canvas.width - rect.width * dpr) > 1 || Math.abs(canvas.height - rect.height * dpr) > 1) { canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.scale(dpr, dpr); keyBoundsRef.current = calculateLayout(); } if (keyBoundsRef.current.length === 0) { keyBoundsRef.current = calculateLayout(); } const width = rect.width; const height = rect.height; const bgColors = { dark: "#0f172a", light: "#e2e8f0" }; ctx.fillStyle = bgColors[theme] || bgColors.light; ctx.fillRect(0, 0, width, height); const scale = viewport.scale; keyBoundsRef.current.forEach((key) => { let isPressed = false; for (const press of activePresses.current.values()) { const keyIndex = key.rowIndex * 100 + key.colIndex; if (press.keyIndex === keyIndex) { isPressed = true; break; } } const isActiveModifier = key.value === "Shift" && shift || key.value === "HangulMode" && hangulMode; drawKey(ctx, key, isPressed, isActiveModifier, scale, theme); }); }, [calculateLayout, hangulMode, shift, viewport.scale, activePresses, keyBoundsRef, theme]); useEffect4(() => { keyBoundsRef.current = []; }, [hangulMode, shift, keyBoundsRef]); useEffect4(() => { const handleGlobalPointerDown = (e) => { if (!containerRef.current || !focusId) return; const path = e.composedPath(); if (path.includes(containerRef.current)) { return; } const isInput = path.some((node) => { return node instanceof Element && node.getAttribute("data-virtual-input") === "true"; }); if (isInput) return; onBlur(true); }; window.addEventListener("pointerdown", handleGlobalPointerDown, { capture: true }); return () => { window.removeEventListener("pointerdown", handleGlobalPointerDown, { capture: true }); }; }, [focusId, onBlur]); useEffect4(() => { let handle; const loop = () => { draw(); handle = requestAnimationFrame(loop); }; loop(); return () => cancelAnimationFrame(handle); }, [draw]); if (!focusId || !isMobileAgent()) return null; return createPortal(/* @__PURE__ */ jsxDEV3(ShadowWrapper, { tagName: "virtual-keypad-canvas", hostRef: containerRef, style: { position: "fixed", bottom: 0, left: 0, right: 0, margin: "0 auto", width: viewport.width, height: Math.round(200 / viewport.scale), backgroundColor: theme === "dark" ? "#0f172a" : "#e2e8f0", borderRadius: `calc(18px / ${viewport.scale}) calc(18px / ${viewport.scale}) 0 0`, boxShadow: `0 calc(-6px / ${viewport.scale}) calc(30px / ${viewport.scale}) rgba(15, 23, 42, 0.2)`, zIndex: 9999, overflow: "hidden", userSelect: "none", touchAction: "none", "--scale-factor": viewport.scale, "--keypad-bg": theme === "dark" ? "#0f172a" : "#e2e8f0" }, onFocus: () => { if (focusId) onFocus(focusId); }, onBlur, onContextMenu: (e) => e.preventDefault(), onPointerDown: (e) => e.preventDefault(), onClickCapture: (e) => { e.preventDefault(); e.stopPropagation(); }, css: ` :host { display: block; } canvas { display: block; width: 100%; height: 100%; touch-action: none; } `, children: /* @__PURE__ */ jsxDEV3("canvas", { ref: canvasRef, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerLeave: handlePointerUp, onPointerCancel: handlePointerCancel, style: { width: "100%", height: "100%", display: "block" } }, undefined, false, undefined, this) }, undefined, false, undefined, this), document.body); } // src/hooks/useStorage.ts import { useState as useState3, useCallback as useCallback5, useEffect as useEffect5 } from "react"; var isBrowser = typeof window !== "undefined"; function useStorage(key, initialValue) { const [storedValue, setStoredValue] = useState3(() => { if (!isBrowser) { return initialValue instanceof Function ? initialValue() : initialValue; } try { const item = window.localStorage.getItem(key); if (item) { return JSON.parse(item); } else { const valueToStore = initialValue instanceof Function ? initialValue() : initialValue; window.localStorage.setItem(key, JSON.stringify(valueToStore)); return valueToStore; } } catch (error) { console.error(`Error reading localStorage key "${key}":`, error); return initialValue instanceof Function ? initialValue() : initialValue; } }); const setValue =