@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
JavaScript
// 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 =