@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
JavaScript
// 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
};