UNPKG

@uiwwsw/virtual-keyboard

Version:

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

1,309 lines (1,294 loc) 39.8 kB
// src/components/Input.tsx import { useState as useState2, useMemo, useCallback, useId, useImperativeHandle, useEffect as useEffect2, useRef } from "react"; // 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/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(({ children, css, tagName }) => { const [host, setHost] = useState(null); const [shadowRoot, setShadowRoot] = useState(null); const Component = tagName; useEffect(() => { if (host && !shadowRoot) { const shadow = host.attachShadow({ mode: "closed" }); setShadowRoot(shadow); } }, [host, shadowRoot]); return /* @__PURE__ */ jsxDEV(Component, { ref: setHost, 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/BlinkingCaret.tsx import { jsxDEV as jsxDEV2 } from "react/jsx-dev-runtime"; function BlinkingCaret() { return /* @__PURE__ */ jsxDEV2("span", { className: "blink", style: { margin: "0 -0.12em", position: "absolute" }, children: "|" }, undefined, false, undefined, this); } // 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; } // 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 jsxDEV3 } from "react/jsx-dev-runtime"; function VirtualInput({ value: controlledValue, defaultValue, placeholder, onChange, ...props }) { const id = useId(); const [internalValue, setInternalValue] = useState2(controlledValue ?? defaultValue ?? ""); const [caretIndex, setCaretIndex] = useState2(() => (controlledValue ?? defaultValue ?? "").length); const [selection, setSelection] = useState2({ start: null, end: null }); const divRef = useRef(null); const letters = useMemo(() => internalValue.split(""), [internalValue]); const updateValue = useCallback((newValue, newCaretIndex) => { if (controlledValue === undefined) { setInternalValue(newValue); } const fakeInput = { value: newValue }; const changeEvent = { target: fakeInput, currentTarget: fakeInput }; onChange?.(changeEvent); setCaretIndex(newCaretIndex); }, [controlledValue, onChange]); useEffect2(() => { if (controlledValue !== undefined && controlledValue !== internalValue) { setInternalValue(controlledValue); } }, [controlledValue, internalValue]); const { focusId, onFocus, onBlur, hangulMode, setHangulMode, isCompositionRef, inputRef, shift } = useVirtualInputContext(); const isFocused = focusId === id; const selectionStart = selection.start; const selectionEnd = selection.end; const hasSelection = useMemo(() => selectionStart !== null && selectionEnd !== null && selectionStart !== selectionEnd, [selectionStart, selectionEnd]); const clearSelection = useCallback(() => { setSelection({ start: null, end: null }); }, []); const deleteSelectedText = useCallback(() => { if (!hasSelection || selectionStart === null || selectionEnd === null) { return { newString: internalValue, finalCaretIndex: caretIndex }; } const [start, end] = [selectionStart, selectionEnd].sort((a, b) => a - b); const newString = internalValue.slice(0, start) + internalValue.slice(end); clearSelection(); return { newString, finalCaretIndex: start }; }, [ internalValue, caretIndex, hasSelection, selectionStart, selectionEnd, clearSelection ]); const handlePaste = useCallback((e) => { e.preventDefault(); const pastedText = e.clipboardData.getData("text/plain"); if (!pastedText) return; const { newString, finalCaretIndex } = hasSelection ? deleteSelectedText() : { newString: internalValue, finalCaretIndex: caretIndex }; const finalString = newString.slice(0, finalCaretIndex) + pastedText + newString.slice(finalCaretIndex); const newCaretPosition = finalCaretIndex + pastedText.length; updateValue(finalString, newCaretPosition); clearSelection(); }, [ internalValue, caretIndex, hasSelection, clearSelection, deleteSelectedText, updateValue ]); const handleKeyDown = useCallback((e) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") { e.preventDefault(); setSelection({ start: 0, end: letters.length }); return; } if (e.key === "HangulMode") { setHangulMode(!hangulMode); return; } if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") { if (hasSelection && selectionStart !== null && selectionEnd !== null) { e.preventDefault(); const [start, end] = [selectionStart, selectionEnd].sort((a, b) => a - b); const textToCopy = letters.slice(start, end).join(""); navigator.clipboard.writeText(textToCopy).catch((err) => { console.error("Failed to copy text: ", err); }); } return; } if ((e.ctrlKey || e.metaKey) && (e.key.toLowerCase() === "c" || e.key.toLowerCase() === "v")) return; switch (e.key) { case "Backspace": { isCompositionRef.current = false; if (hasSelection) { const { newString, finalCaretIndex } = deleteSelectedText(); updateValue(newString, finalCaretIndex); } else if (caretIndex > 0) { const newString = internalValue.slice(0, caretIndex - 1) + internalValue.slice(caretIndex); updateValue(newString, caretIndex - 1); } break; } case "Delete": { isCompositionRef.current = false; if (hasSelection) { const { newString, finalCaretIndex } = deleteSelectedText(); updateValue(newString, finalCaretIndex); } else if (caretIndex < letters.length) { const newString = internalValue.slice(0, caretIndex) + internalValue.slice(caretIndex + 1); updateValue(newString, caretIndex); } break; } case "ArrowLeft": { isCompositionRef.current = false; const newCaretIndex = Math.max(0, caretIndex - 1); if (e.shiftKey) { setSelection((prev) => ({ start: prev.start ?? caretIndex, end: newCaretIndex })); } else { clearSelection(); } setCaretIndex(newCaretIndex); break; } case "ArrowRight": { isCompositionRef.current = false; const newCaretIndex = Math.min(letters.length, caretIndex + 1); if (e.shiftKey) { setSelection((prev) => ({ start: prev.start ?? caretIndex, end: newCaretIndex })); } else { clearSelection(); } setCaretIndex(newCaretIndex); break; } case "Unidentified": case "Shift": case "Control": case "Alt": case "Meta": case "CapsLock": case "Enter": case "Tab": case "ArrowUp": case "ArrowDown": e.stopPropagation(); break; case "End": { const newCaretIndex = letters.length; if (e.shiftKey) { setSelection((prev) => ({ ...prev, end: newCaretIndex })); } setCaretIndex(newCaretIndex); break; } default: { const result = parseKeyInput(e, hangulMode); if (result.toggleHangulMode) { setHangulMode(!hangulMode); return; } if (!result.handled || !result.text) 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: internalValue, finalCaretIndex: caretIndex }; 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; break; } } }, [ letters, caretIndex, hasSelection, selectionStart, selectionEnd, clearSelection, deleteSelectedText, hangulMode, setHangulMode, isCompositionRef, shift, updateValue, internalValue ]); const handleFocus = useCallback(() => { onFocus(id); }, [onFocus, id]); const handleBlur = useCallback(() => { onBlur(); }, [onBlur]); const handleClickWrap = useCallback(() => { clearSelection(); setCaretIndex(letters.length); }, [clearSelection, letters.length]); const handleClickLetter = useCallback((index) => { clearSelection(); setCaretIndex(index); }, [clearSelection]); const isSelected = useCallback((index) => { if (!hasSelection || selectionStart === null || selectionEnd === null) return false; const [start, end] = [selectionStart, selectionEnd].sort((a, b) => a - b); return index >= start && index < end; }, [hasSelection, selectionStart, selectionEnd]); const handleClickLetterEvent = (e) => { e.stopPropagation(); handleClickLetter(Number(e.currentTarget.dataset.value)); }; useImperativeHandle(inputRef, () => { if (isFocused) { return { handleKeyDown }; } return inputRef.current; }, [isFocused, handleKeyDown, inputRef]); return /* @__PURE__ */ jsxDEV3(ShadowWrapper, { tagName: "virtual-input", css: ` .wrap { white-space: pre; position: relative; cursor: text; border-radius: 4px; user-select: none; } .wrap::after { content: " "; } .placeholder { position: absolute; left: 0; top: 0; color: #aaa; pointer-events: none; } .letter { position: relative; outline: none; } .letter.selected { background-color: #b4d5fe; } @keyframes blink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } } .blink { animation: blink 1s step-start infinite; } `, children: /* @__PURE__ */ jsxDEV3("div", { ...props, ref: divRef, className: "wrap", role: "textbox", "aria-placeholder": placeholder, tabIndex: 0, style: { minHeight: "1.5em", lineHeight: "1.5em", outline: "none", font: "inherit", width: "100%" }, onFocus: handleFocus, onBlur: handleBlur, onKeyDown: handleKeyDown, onClick: handleClickWrap, onPaste: handlePaste, children: [ !internalValue && placeholder && /* @__PURE__ */ jsxDEV3("span", { className: "placeholder", children: placeholder }, undefined, false, undefined, this), letters.map((char, i) => /* @__PURE__ */ jsxDEV3("span", { children: [ caretIndex === i && isFocused && !hasSelection && /* @__PURE__ */ jsxDEV3(BlinkingCaret, {}, undefined, false, undefined, this), /* @__PURE__ */ jsxDEV3("span", { role: "button", "data-value": i, onClick: handleClickLetterEvent, className: `letter ${isSelected(i) ? "selected" : ""}`, children: char }, undefined, false, undefined, this) ] }, `char-${char}-${i}`, true, undefined, this)), caretIndex === letters.length && isFocused && !hasSelection && /* @__PURE__ */ jsxDEV3(BlinkingCaret, {}, undefined, false, undefined, this) ] }, undefined, true, undefined, this) }, undefined, false, undefined, this); } // src/components/Provider.tsx import { useState as useState5, useRef as useRef2, useCallback as useCallback4, useEffect as useEffect5 } from "react"; // 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/components/Keypad.tsx import { useCallback as useCallback2 } from "react"; import { jsxDEV as jsxDEV4 } from "react/jsx-dev-runtime"; function VirtualKeypad({ layout, viewport }) { const { focusId, onBlur, onFocus, inputRef, shift, hangulMode, toggleShift, toggleKorean } = useVirtualInputContext(); const handleFocus = useCallback2(() => { if (!focusId) return; onFocus(focusId); }, [onFocus, focusId]); const getTransformedValue = useCallback2((cell) => { if (cell.type === "char") { if (hangulMode) { return convertQwertyToHangul(cell.value); } if (shift) { return cell.value.toUpperCase(); } return cell.value; } return cell.label ?? cell.value; }, [hangulMode, shift]); const insertCharacter = useCallback2((e) => { const target = e.target; if (!(target instanceof HTMLButtonElement)) return; const { value, dataset } = target; const type = dataset.type; 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, shift, toggleShift, toggleKorean, getTransformedValue]); if (!focusId || !isMobileAgent()) return null; return /* @__PURE__ */ jsxDEV4(ShadowWrapper, { tagName: "virtual-keypad", css: ` .keypad-container { display: flex; flex-direction: column; position: fixed; background-color: #f0f2f5; padding: calc(8px / var(--scale-factor)); box-sizing: border-box; gap: calc(8px / var(--scale-factor)); box-shadow: 0 calc(-2px / var(--scale-factor)) calc(10px / var(--scale-factor)) rgba(0, 0, 0, 0.1); user-select: none; touch-action: manipulation; } .keypad-row { display: flex; flex: 1; gap: calc(8px / var(--scale-factor)); } @keyframes key-pop { from { opacity: 0.9; transform: translate(-50%, 5px) scale(1); } to { opacity: 1; transform: translate(-50%, calc(-15px / var(--scale-factor))) scale(1.4); } } @keyframes key-press { from { transform: scale(1); } to { transform: scale(0.96); } } .keypad-button { position: relative; flex: 1; display: flex; justify-content: center; align-items: center; background-color: #ffffff; border: calc(1px / var(--scale-factor)) solid #ccc; border-radius: calc(8px / var(--scale-factor)); font-size: calc(18px / var(--scale-factor)); font-weight: 500; color: #333; cursor: pointer; box-shadow: 0 calc(2px / var(--scale-factor)) calc(2px / var(--scale-factor)) rgba(0, 0, 0, 0.05); } .keypad-button:active { animation: key-press 0.05s forwards; } .key-popup { display: none; position: absolute; left: 50%; bottom: 80%; min-width: 100%; padding: calc(8px / var(--scale-factor)) calc(12px / var(--scale-factor)); background-color: #f9fafb; color: #333; border-radius: calc(10px / var(--scale-factor)); box-shadow: 0 calc(-2px / var(--scale-factor)) calc(10px / var(--scale-factor)) rgba(0, 0, 0, 0.15); pointer-events: none; font-size: calc(26px / var(--scale-factor)); font-weight: 500; text-align: center; z-index: 10; line-height: 1.2; } .keypad-button:active .key-popup { display: block; animation: key-pop 0.1s ease-out forwards; } `, children: /* @__PURE__ */ jsxDEV4("div", { onFocus: handleFocus, onBlur, tabIndex: -1, className: "keypad-container", style: { left: viewport.offsetLeft, width: viewport.width, top: Math.round(viewport.offsetTop + viewport.height - 200 / viewport.scale), height: Math.round(200 / viewport.scale), "--scale-factor": viewport.scale }, children: layout?.map((row, i) => /* @__PURE__ */ jsxDEV4("div", { className: "keypad-row", children: row.map((cell, j) => /* @__PURE__ */ jsxDEV4("button", { type: "button", value: cell.value, "data-type": cell.type, onClick: insertCharacter, className: `keypad-button ${cell.type === "action" ? "action" : ""}`, children: [ getTransformedValue(cell), /* @__PURE__ */ jsxDEV4("div", { className: "key-popup", children: getTransformedValue(cell) }, undefined, false, undefined, this) ] }, `${i}-${j}`, true, undefined, this)) }, i, false, undefined, this)) }, undefined, false, undefined, this) }, undefined, false, undefined, this); } // src/hooks/useStorage.ts import { useState as useState3, useCallback as useCallback3, useEffect as useEffect3 } 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 = useCallback3((value) => { if (!isBrowser) { console.warn(`Tried to set localStorage key "${key}" in a non-browser environment.`); return; } try { const valueToStore = value instanceof Function ? value(storedValue) : value; setStoredValue(valueToStore); window.localStorage.setItem(key, JSON.stringify(valueToStore)); } catch (error) { console.error(`Error setting localStorage key "${key}":`, error); } }, [key, storedValue]); useEffect3(() => { if (!isBrowser) return; const handleStorageChange = (event) => { if (event.storageArea !== window.localStorage || event.key !== key) { return; } try { if (event.newValue) { if (JSON.stringify(storedValue) !== event.newValue) { setStoredValue(JSON.parse(event.newValue)); } } } catch (error) { console.error(`Error handling storage change for key "${key}":`, error); } }; window.addEventListener("storage", handleStorageChange); return () => window.removeEventListener("storage", handleStorageChange); }, [key, storedValue]); return [storedValue, setValue]; } // src/assets/qwerty.json var qwerty_default = [ [ { label: "q", value: "q", type: "char" }, { label: "w", value: "w", type: "char" }, { label: "e", value: "e", type: "char" }, { label: "r", value: "r", type: "char" }, { label: "t", value: "t", type: "char" }, { label: "y", value: "y", type: "char" }, { label: "u", value: "u", type: "char" }, { label: "i", value: "i", type: "char" }, { label: "o", value: "o", type: "char" }, { label: "p", value: "p", type: "char" } ], [ { label: "a", value: "a", type: "char" }, { label: "s", value: "s", type: "char" }, { label: "d", value: "d", type: "char" }, { label: "f", value: "f", type: "char" }, { label: "g", value: "g", type: "char" }, { label: "h", value: "h", type: "char" }, { label: "j", value: "j", type: "char" }, { label: "k", value: "k", type: "char" }, { label: "l", value: "l", type: "char" } ], [ { label: "⇧", value: "Shift", type: "action" }, { label: "z", value: "z", type: "char" }, { label: "x", value: "x", type: "char" }, { label: "c", value: "c", type: "char" }, { label: "v", value: "v", type: "char" }, { label: "b", value: "b", type: "char" }, { label: "n", value: "n", type: "char" }, { label: "m", value: "m", type: "char" }, { label: "⌫", value: "Backspace", type: "action" } ], [ { label: "␣", value: " ", width: 5, type: "action" }, { label: "⏎", value: ` `, type: "action" }, { label: "🌐", value: "HangulMode", type: "action" } ] ]; // src/hooks/useVisualViewport.ts import { useState as useState4, useEffect as useEffect4 } from "react"; var getVisualViewport = () => { if (typeof window !== "undefined" && window.visualViewport) { return { width: window.visualViewport.width, height: window.visualViewport.height, scale: window.visualViewport.scale, offsetLeft: window.visualViewport.offsetLeft, offsetTop: window.visualViewport.offsetTop }; } return { width: window.innerWidth, height: window.innerHeight, scale: 1, offsetLeft: 0, offsetTop: 0 }; }; var useVisualViewport = () => { const [viewport, setViewport] = useState4(getVisualViewport); useEffect4(() => { const handleResize = () => { setViewport(getVisualViewport()); }; if (typeof window !== "undefined" && window.visualViewport) { window.visualViewport.addEventListener("resize", handleResize); window.visualViewport.addEventListener("scroll", handleResize); return () => { window.visualViewport?.removeEventListener("resize", handleResize); window.visualViewport?.removeEventListener("scroll", handleResize); }; } window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, []); return viewport; }; // src/components/Provider.tsx import { jsxDEV as jsxDEV5 } from "react/jsx-dev-runtime"; function VirtualInputProvider({ children, layout = qwerty_default, defaultHangulMode = true }) { const inputRef = useRef2(null); const sti = useRef2(setTimeout(() => null, 0)); const [focusId, setFocusId] = useState5(); const [shift, setShift] = useState5(false); const [hangulMode, setHangulMode] = useStorage("virtual-keyboard-hangul-mode", defaultHangulMode); const viewport = useVisualViewport(); useEffect5(() => { if (focusId) { document.body.style.paddingBottom = `${Math.round(200 / viewport.scale)}px`; } else { document.body.style.paddingBottom = "0px"; } return () => { document.body.style.paddingBottom = "0px"; }; }, [focusId, viewport.scale]); const onFocus = (id) => { clearTimeout(sti.current); setFocusId(id); }; const onBlur = () => { sti.current = setTimeout(() => { setFocusId(undefined); isCompositionRef.current = false; }, 0); }; const toggleShift = useCallback4(() => { setShift((prev) => !prev); }, []); const toggleKorean = useCallback4(() => { setHangulMode((prev) => !prev); }, [setHangulMode]); const isCompositionRef = useRef2(undefined); return /* @__PURE__ */ jsxDEV5(VirtualInputContext.Provider, { value: { inputRef, isCompositionRef, onFocus, onBlur, focusId, setHangulMode, hangulMode, shift, toggleShift, toggleKorean }, children: [ children, /* @__PURE__ */ jsxDEV5(VirtualKeypad, { layout, viewport }, undefined, false, undefined, this) ] }, undefined, true, undefined, this); } export { VirtualInputProvider, VirtualInput };