UNPKG

@hyperbrowser/agent

Version:

Hyperbrowsers Web Agent

998 lines (967 loc) 33.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.dispatchCDPAction = dispatchCDPAction; const bounding_box_1 = require("../cdp/bounding-box"); const domEnabledSessions = new WeakSet(); const runtimeEnabledSessions = new WeakSet(); const inputEnabledSessions = new WeakSet(); const FILL_ELEMENT_SCRIPT = ` function(rawValue) { try { const element = this; if (!element) { return { status: "error", reason: "Element missing" }; } const doc = element.ownerDocument || document; const win = doc.defaultView || window; const value = rawValue == null ? "" : String(rawValue); const dispatchEvents = () => { try { element.dispatchEvent(new win.Event("input", { bubbles: true })); element.dispatchEvent(new win.Event("change", { bubbles: true })); } catch {} }; const setUsingDescriptor = (target, prop, val) => { const proto = target.constructor?.prototype; const descriptor = (proto && Object.getOwnPropertyDescriptor(proto, prop)) || Object.getOwnPropertyDescriptor(win.HTMLElement.prototype, prop) || Object.getOwnPropertyDescriptor(win.HTMLInputElement?.prototype || {}, prop) || Object.getOwnPropertyDescriptor(win.HTMLTextAreaElement?.prototype || {}, prop); if (descriptor && descriptor.set) { descriptor.set.call(target, val); return true; } try { target[prop] = val; return true; } catch { return false; } }; if (element instanceof win.HTMLInputElement) { const type = (element.type || "").toLowerCase(); const directSetTypes = [ "color", "date", "datetime-local", "month", "range", "time", "week", "checkbox", "radio", "file", "hidden", ]; if (directSetTypes.includes(type)) { if (type === "checkbox" || type === "radio") { const normalized = value.trim().toLowerCase(); element.checked = normalized === "true" || normalized === "1" || normalized === "on" || normalized === "checked"; } else { setUsingDescriptor(element, "value", value); } dispatchEvents(); return { status: "done" }; } const typeInputTypes = [ "", "email", "number", "password", "search", "tel", "text", "url", ]; if (typeInputTypes.includes(type)) { return { status: "needsinput", value }; } setUsingDescriptor(element, "value", value); dispatchEvents(); return { status: "done" }; } if (element instanceof win.HTMLTextAreaElement) { return { status: "needsinput", value }; } if (element.isContentEditable) { element.textContent = value; dispatchEvents(); return { status: "done" }; } if (setUsingDescriptor(element, "value", value)) { dispatchEvents(); return { status: "done" }; } return { status: "needsinput", value }; } catch (error) { return { status: "error", reason: error?.message || "Failed to fill element" }; } } `; const PREPARE_FOR_TYPING_SCRIPT = ` function() { try { const element = this; if (!element || !element.isConnected) return false; const doc = element.ownerDocument || document; const win = doc.defaultView || window; try { if (typeof element.focus === "function") { element.focus(); } } catch {} if ( element instanceof win.HTMLInputElement || element instanceof win.HTMLTextAreaElement ) { try { if (typeof element.select === "function") { element.select(); return true; } } catch {} try { const length = (element.value || "").length; if (typeof element.setSelectionRange === "function") { element.setSelectionRange(0, length); return true; } } catch {} return true; } if (element.isContentEditable) { const selection = doc.getSelection?.(); const range = doc.createRange?.(); if (selection && range) { try { range.selectNodeContents(element); selection.removeAllRanges(); selection.addRange(range); } catch {} } return true; } return false; } catch { return false; } } `; function ensureActionContext(ctx) { if (!ctx || !ctx.element) { throw new Error("[CDP][Interactions] Action context missing element handle"); } } async function dispatchCDPAction(method, args, ctx) { ensureActionContext(ctx); switch (method) { case "click": await clickElement(ctx, args[0]); return; case "doubleClick": await clickElement(ctx, Object.assign({}, args[0] ?? {}, { clickCount: 2 })); return; case "hover": await hoverElement(ctx); return; case "type": await typeText(ctx, args[0] ?? "", args[1]); return; case "fill": await fillElement(ctx, args[0] ?? "", args[1]); return; case "press": await pressKey(ctx, args[0] ?? "Enter", args[1]); return; case "check": await setChecked(ctx, true); return; case "uncheck": await setChecked(ctx, false); return; case "selectOptionFromDropdown": await selectOption(ctx, { value: args[0] ?? "", }); return; case "scrollToElement": { await scrollElementIntoView(ctx); return; } case "scrollToPercentage": { const targetArg = args[0]; const options = typeof targetArg === "object" && !Array.isArray(targetArg) ? targetArg : { target: targetArg }; await scrollToPosition(ctx, options); return; } case "scrollTo": { const targetArg = args[0]; if (targetArg == null) { await scrollElementIntoView(ctx); } else { const options = typeof targetArg === "object" && !Array.isArray(targetArg) ? targetArg : { target: targetArg }; await scrollToPosition(ctx, options); } return; } case "nextChunk": await scrollByChunk(ctx, "nextChunk"); return; case "prevChunk": await scrollByChunk(ctx, "prevChunk"); return; default: throw new Error(`[CDP][Interactions] Unsupported action method: ${method}`); } } async function clickElement(ctx, options) { const { element } = ctx; const session = element.session; const button = options?.button ?? "left"; const clickCount = options?.clickCount ?? 1; await scrollIntoViewIfNeeded(ctx); const box = await getEffectiveBoundingBox(ctx); if (!box) { throw new Error("[CDP][Interactions] Unable to determine element bounding box"); } const x = box.x + box.width / 2; const y = box.y + box.height / 2; await ensureInputEnabled(session); await session.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "none", }); for (let i = 0; i < clickCount; i++) { await session.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount, }); await session.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount, }); if (options?.delayMs) { await delay(options.delayMs); } } } async function hoverElement(ctx) { const { element } = ctx; const session = element.session; const box = await getEffectiveBoundingBox(ctx); if (!box) { throw new Error("[CDP][Interactions] Unable to determine element bounding box"); } await ensureInputEnabled(session); await session.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: box.x + box.width / 2, y: box.y + box.height / 2, button: "none", }); } async function typeText(ctx, text, options) { if (!text) { return; } const { element } = ctx; const session = element.session; await focusElement(ctx); await ensureInputEnabled(session); await session.send("Input.insertText", { text }); if (options?.commitEnter) { await pressKey(ctx, "Enter"); } if (options?.delayMs) { await delay(options.delayMs); } } async function fillElement(ctx, value, options) { const { element } = ctx; const session = element.session; const objectId = await ensureObjectHandle(element); await ensureRuntimeEnabled(session); const fillResponse = await session.send("Runtime.callFunctionOn", { objectId, functionDeclaration: FILL_ELEMENT_SCRIPT, arguments: [{ value }], returnByValue: true, }); const fillResult = (fillResponse.result?.value ?? {}); if (fillResult.status === "error") { throw new Error(`Failed to fill element: ${fillResult.reason ?? "unknown error"}`); } if (fillResult.status === "needsinput") { const textToType = fillResult.value ?? value ?? ""; await session .send("Runtime.callFunctionOn", { objectId, functionDeclaration: PREPARE_FOR_TYPING_SCRIPT, returnByValue: true, }) .catch(() => { }); await focusElement(ctx); await ensureInputEnabled(session); if (textToType.length === 0) { await session.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8, nativeVirtualKeyCode: 8, }); await session.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8, nativeVirtualKeyCode: 8, }); } else { await session.send("Input.insertText", { text: textToType, }); } } if (options?.commitChange) { await session.send("Runtime.callFunctionOn", { objectId, functionDeclaration: ` function() { if (typeof this.blur === "function") { this.blur(); } } `, }); } } async function pressKey(ctx, key, options) { const { element } = ctx; const session = element.session; await focusElement(ctx); await ensureInputEnabled(session); const keyDef = getKeyEventData(key); await session.send("Input.dispatchKeyEvent", { type: "keyDown", key: keyDef.key, text: keyDef.text, code: keyDef.code, windowsVirtualKeyCode: keyDef.windowsVirtualKeyCode, nativeVirtualKeyCode: keyDef.nativeVirtualKeyCode, }); await session.send("Input.dispatchKeyEvent", { type: "keyUp", key: keyDef.key, text: keyDef.text, code: keyDef.code, windowsVirtualKeyCode: keyDef.windowsVirtualKeyCode, nativeVirtualKeyCode: keyDef.nativeVirtualKeyCode, }); if (options?.delayMs) { await delay(options.delayMs); } } async function setChecked(ctx, checked) { const { element } = ctx; const session = element.session; const objectId = await ensureObjectHandle(element); await ensureRuntimeEnabled(session); const result = await session.send("Runtime.callFunctionOn", { objectId, functionDeclaration: ` function(shouldCheck) { if (!this) return { status: "error", reason: "Element missing" }; // 1. Native Checkbox if (this.tagName === "INPUT" && this.type === "checkbox") { if (this.checked === shouldCheck) { return { status: "noop" }; } // Try native JS click first - this fires proper events and handles most frameworks this.click(); // Verify if it worked if (this.checked !== shouldCheck) { // Click failed (maybe preventDefault or detached), force property this.checked = shouldCheck; this.dispatchEvent(new Event("input", { bubbles: true })); this.dispatchEvent(new Event("change", { bubbles: true })); } return { status: "done" }; } // 2. ARIA Checkbox (role=checkbox/switch/etc) const role = this.getAttribute("role"); if (role === "checkbox" || role === "switch" || role === "menuitemcheckbox") { const ariaChecked = this.getAttribute("aria-checked"); const isChecked = ariaChecked === "true"; if (isChecked === shouldCheck) { return { status: "noop" }; } // Need to click to toggle return { status: "needs_click" }; } // 3. Fallback: Check specific 'checked' property existence (e.g. Web Components) if ("checked" in this) { if (this.checked === shouldCheck) { return { status: "noop" }; } this.checked = shouldCheck; this.dispatchEvent(new Event("input", { bubbles: true })); this.dispatchEvent(new Event("change", { bubbles: true })); return { status: "done" }; } // 4. Ambiguous element (e.g. label, div) - assume clicking toggles it return { status: "needs_click" }; } `, arguments: [{ value: checked }], returnByValue: true, }); const value = (result.result?.value ?? {}); if (value.status === "error") { throw new Error(`Failed to ${checked ? "check" : "uncheck"} element: ${value.reason || "unknown error"}`); } if (value.status === "needs_click") { await clickElement(ctx); } } async function selectOption(ctx, options) { const { element } = ctx; const session = element.session; const objectId = await ensureObjectHandle(element); const value = options.value; await ensureRuntimeEnabled(session); const result = await session.send("Runtime.callFunctionOn", { objectId, functionDeclaration: ` function(rawValue) { if (!this || this.tagName?.toLowerCase() !== "select") { return { status: "notfound" }; } const target = rawValue == null ? "" : String(rawValue).trim(); const normalized = target.toLowerCase(); const options = Array.from(this.options || []); if (!options.length) { return { status: "notfound" }; } let byIndex = null; if (target && /^\\d+$/.test(target)) { const idx = Number(target); if (!Number.isNaN(idx) && idx >= 0 && idx < options.length) { byIndex = options[idx]; } } const match = byIndex || options.find((opt) => { if (!normalized) return false; const compare = (val) => (val || "").toString().trim().toLowerCase(); return ( compare(opt.value) === normalized || compare(opt.label) === normalized || compare(opt.textContent) === normalized || compare(opt.innerText) === normalized ); }) || options.find(Boolean); if (!match) { return { status: "notfound" }; } try { this.value = match.value; } catch { return { status: "notfound" }; } try { this.dispatchEvent(new Event("input", { bubbles: true })); this.dispatchEvent(new Event("change", { bubbles: true })); } catch {} return { status: "selected", value: match.value }; } `, arguments: [{ value }], returnByValue: true, }); const selection = (result.result?.value ?? {}); if (selection.status !== "selected") { throw new Error(`Failed to select "${value}" (no matching option)`); } } async function scrollToPosition(ctx, options) { const percent = normalizeScrollPercent(options.target ?? "50%"); const { element } = ctx; const session = element.session; const objectId = await ensureObjectHandle(element); await ensureRuntimeEnabled(session); const beforeMetrics = ctx.debug && objectId ? await captureScrollMetrics(session, objectId) : null; const intendedScrollTop = beforeMetrics !== null ? beforeMetrics.maxScroll * (percent / 100) : null; if (ctx.debug) { console.log(`[CDP][Interactions] scrollTo target=${options.target ?? "50%"} -> ${percent}% (backendNodeId=${element.backendNodeId})`); if (beforeMetrics) { logScrollMetrics("before", beforeMetrics, { intentTop: intendedScrollTop ?? undefined, }); } else { console.log(`[CDP][Interactions] scrollTo metrics unavailable before scroll (backendNodeId=${element.backendNodeId})`); } } const scrollResponse = await session.send("Runtime.callFunctionOn", { objectId, functionDeclaration: ` function(percent, behavior) { const pct = Math.max(0, Math.min(100, Number(percent))); const target = this; const doc = target.ownerDocument || document; const win = doc.defaultView || window; const isRoot = target === doc.documentElement || target === doc.body; const scrollContainer = isRoot ? (doc.scrollingElement || doc.documentElement || doc.body) : target; if (!scrollContainer) { return { status: "missing" }; } const maxScroll = Math.max( 0, Number(scrollContainer.scrollHeight || 0) - Number(scrollContainer.clientHeight || 0) ); const nextTop = maxScroll * (pct / 100); const waitForIdle = () => new Promise((resolve) => { const epsilon = 0.5; const requiredStableFrames = 4; const maxWaitMs = 2000; const raf = typeof win.requestAnimationFrame === "function" ? win.requestAnimationFrame.bind(win) : (cb) => win.setTimeout(cb, 16); const caf = typeof win.cancelAnimationFrame === "function" ? win.cancelAnimationFrame.bind(win) : win.clearTimeout.bind(win); let stableFrames = 0; let lastTop = scrollContainer.scrollTop; let rafHandle = null; const timeoutId = win.setTimeout(() => { if (rafHandle != null) { caf(rafHandle); } resolve({ status: "timeout", finalTop: scrollContainer.scrollTop, maxScroll, }); }, maxWaitMs); const step = () => { const currentTop = scrollContainer.scrollTop; if (Math.abs(currentTop - lastTop) <= epsilon) { stableFrames += 1; } else { stableFrames = 0; } lastTop = currentTop; if (stableFrames >= requiredStableFrames) { win.clearTimeout(timeoutId); if (rafHandle != null) { caf(rafHandle); } resolve({ status: "done", finalTop: currentTop, maxScroll, }); return; } rafHandle = raf(step); }; rafHandle = raf(step); }); scrollContainer.scrollTo({ top: nextTop, behavior: behavior === "instant" ? "auto" : "smooth", }); if (maxScroll === 0) { return { status: "noop", finalTop: scrollContainer.scrollTop, maxScroll, }; } return waitForIdle(); } `, arguments: [{ value: percent }, { value: options.behavior }], awaitPromise: true, returnByValue: true, }); const scrollResult = (scrollResponse.result?.value ?? null); if (ctx.debug) { const afterMetrics = objectId != null ? await captureScrollMetrics(session, objectId) : null; if (scrollResult) { const finalTop = typeof scrollResult.finalTop === "number" ? scrollResult.finalTop.toFixed(2) : "n/a"; const maxScrollVal = typeof scrollResult.maxScroll === "number" ? scrollResult.maxScroll.toFixed(2) : "n/a"; console.log(`[CDP][Interactions] scrollTo in-page wait status=${scrollResult.status ?? "unknown"} finalTop=${finalTop} maxScroll=${maxScrollVal}`); } if (afterMetrics) { logScrollMetrics("after", afterMetrics, { previousTop: beforeMetrics?.scrollTop, }); } else { console.log(`[CDP][Interactions] scrollTo metrics unavailable after scroll (backendNodeId=${element.backendNodeId})`); } } } async function scrollElementIntoView(ctx) { const { element } = ctx; const session = element.session; try { await session.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: element.backendNodeId, }); } catch { const objectId = await ensureObjectHandle(element); await ensureRuntimeEnabled(session); await session.send("Runtime.callFunctionOn", { objectId, functionDeclaration: ` function() { if (typeof this.scrollIntoView === "function") { this.scrollIntoView({ behavior: "auto", block: "center" }); } } `, }); } await waitForScrollSettlement(session, element.backendNodeId); } async function scrollByChunk(ctx, direction) { const { element } = ctx; const session = element.session; const objectId = await ensureObjectHandle(element); const box = await getEffectiveBoundingBox(ctx); const delta = box ? box.height : 400; const sign = direction === "nextChunk" ? 1 : -1; await ensureRuntimeEnabled(session); await session.send("Runtime.callFunctionOn", { objectId, functionDeclaration: ` function(amount) { const target = this; const isRoot = target === document.documentElement || target === document.body; const scrollContainer = isRoot ? (document.scrollingElement || document.documentElement) : target; if (!scrollContainer) return; scrollContainer.scrollBy({ top: amount, left: 0, behavior: "smooth" }); } `, arguments: [{ value: delta * sign }], }); await waitForScrollSettlement(session, element.backendNodeId); } async function focusElement(ctx) { const { element } = ctx; const session = element.session; const objectId = await ensureObjectHandle(element); await ensureRuntimeEnabled(session); await session.send("Runtime.callFunctionOn", { objectId, functionDeclaration: ` function() { if (typeof this.focus === "function") { this.focus(); } } `, }); } async function scrollIntoViewIfNeeded(ctx) { const { element } = ctx; const session = element.session; const backendNodeId = element.backendNodeId; await ensureDomEnabled(session); try { await session.send("DOM.scrollIntoViewIfNeeded", { backendNodeId }); } catch (primaryError) { // Try JavaScript fallback try { const objectId = await ensureObjectHandle(element); await ensureRuntimeEnabled(session); await session.send("Runtime.callFunctionOn", { objectId, functionDeclaration: ` function() { if (typeof this.scrollIntoView === "function") { this.scrollIntoView({ block: "center", inline: "center", behavior: "auto" }); } } `, }); } catch (fallbackError) { // Re-throw with context about both failures throw new Error(`[CDP][Interactions] Failed to scroll element into view. ` + `Primary method failed: ${primaryError instanceof Error ? primaryError.message : String(primaryError)}. ` + `Fallback also failed: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`); } } } async function getEffectiveBoundingBox(ctx) { if (ctx.boundingBox) { return ctx.boundingBox; } if (ctx.getBoundingBox) { const cached = await ctx.getBoundingBox(); if (cached) { ctx.boundingBox = cached; return cached; } } const box = await (0, bounding_box_1.getBoundingBox)({ session: ctx.element.session, backendNodeId: ctx.element.backendNodeId, xpath: ctx.element.xpath, preferScript: ctx.preferScriptBoundingBox, }); if (box) { ctx.boundingBox = box; } return box; } async function ensureDomEnabled(session) { if (domEnabledSessions.has(session)) return; try { await session.send("DOM.enable"); } catch { // best-effort } domEnabledSessions.add(session); } async function ensureRuntimeEnabled(session) { if (runtimeEnabledSessions.has(session)) return; try { await session.send("Runtime.enable"); } catch { // best-effort } runtimeEnabledSessions.add(session); } async function ensureInputEnabled(session) { if (inputEnabledSessions.has(session)) return; try { await session.send("Input.enable"); } catch { // Input.enable is optional; ignore failures } inputEnabledSessions.add(session); } async function ensureObjectHandle(element) { if (element.objectId) { return element.objectId; } const response = (await element.session.send("DOM.resolveNode", { backendNodeId: element.backendNodeId, })); const objectId = response.object?.objectId; if (!objectId) { throw new Error("[CDP][Interactions] Failed to resolve element handle"); } element.objectId = objectId; return objectId; } async function waitForScrollSettlement(session, backendNodeId) { await ensureDomEnabled(session); const start = Date.now(); const timeoutMs = 400; let lastPosition = null; while (Date.now() - start < timeoutMs) { try { const { model } = await session.send("DOM.getBoxModel", { backendNodeId }); if (!model) break; const newPosition = { x: model.content[0], y: model.content[1], }; if (lastPosition && Math.abs(newPosition.x - lastPosition.x) < 1 && Math.abs(newPosition.y - lastPosition.y) < 1) { break; } lastPosition = newPosition; await delay(50); } catch { break; } } } async function captureScrollMetrics(session, objectId) { await ensureRuntimeEnabled(session); const response = await session.send("Runtime.callFunctionOn", { objectId, functionDeclaration: ` function() { try { const target = this; const doc = target.ownerDocument || document; const isRootTarget = target === doc.documentElement || target === doc.body; const scrollContainer = isRootTarget ? (doc.scrollingElement || doc.documentElement || doc.body) : target; if (!scrollContainer) { return null; } const scrollTop = Number(scrollContainer.scrollTop) || 0; const scrollHeight = Number(scrollContainer.scrollHeight) || 0; const clientHeight = Number(scrollContainer.clientHeight) || 0; const maxScroll = Math.max(0, scrollHeight - clientHeight); return { targetTagName: target.tagName || null, containerTagName: scrollContainer.tagName || null, isRootTarget, scrollTop, clientHeight, scrollHeight, maxScroll, }; } catch (error) { return null; } } `, returnByValue: true, }); return (response.result?.value ?? null); } function logScrollMetrics(phase, metrics, extras) { const parts = [ `[CDP][Interactions] scrollTo metrics (${phase}) target=${metrics.targetTagName ?? "unknown"} container=${metrics.containerTagName ?? "unknown"}`, `scrollTop=${formatScrollNumber(metrics.scrollTop)}`, `maxScroll=${formatScrollNumber(metrics.maxScroll)}`, `clientHeight=${formatScrollNumber(metrics.clientHeight)}`, `scrollHeight=${formatScrollNumber(metrics.scrollHeight)}`, ]; if (typeof extras?.intentTop === "number") { parts.push(`intentTop=${formatScrollNumber(extras.intentTop)}`); } if (typeof extras?.previousTop === "number") { parts.push(`delta=${formatScrollNumber(metrics.scrollTop - extras.previousTop)}`); } console.log(parts.join(" ")); } function formatScrollNumber(value) { if (!Number.isFinite(value)) { return "NaN"; } return value.toFixed(2); } function normalizeScrollPercent(target) { if (typeof target === "number") { return clamp(target, 0, 100); } const text = target.trim(); if (text.endsWith("%")) { const parsed = Number.parseFloat(text.slice(0, -1)); return clamp(Number.isNaN(parsed) ? 50 : parsed, 0, 100); } const num = Number.parseFloat(text); return clamp(Number.isNaN(num) ? 50 : num, 0, 100); } function getKeyEventData(inputKey) { const key = (inputKey ?? "").toString(); const lower = key.toLowerCase(); const mapping = { enter: { key: "Enter", code: "Enter", keyCode: 13 }, tab: { key: "Tab", code: "Tab", keyCode: 9 }, escape: { key: "Escape", code: "Escape", keyCode: 27 }, esc: { key: "Escape", code: "Escape", keyCode: 27 }, space: { key: " ", code: "Space", keyCode: 32, text: " " }, backspace: { key: "Backspace", code: "Backspace", keyCode: 8 }, delete: { key: "Delete", code: "Delete", keyCode: 46 }, arrowup: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 }, arrowdown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }, arrowleft: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 }, arrowright: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 }, }; if (mapping[lower]) { const entry = mapping[lower]; return { key: entry.key, code: entry.code, text: entry.text, windowsVirtualKeyCode: entry.keyCode, nativeVirtualKeyCode: entry.keyCode, }; } if (key.length === 1) { const char = key; const upper = char.toUpperCase(); const isLetter = upper >= "A" && upper <= "Z"; const isDigit = char >= "0" && char <= "9"; const code = isLetter ? `Key${upper}` : isDigit ? `Digit${char}` : `Key${upper}`; const keyCode = isDigit ? char.charCodeAt(0) : upper.charCodeAt(0); return { key: char, code, text: char, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode, }; } return { key, code: key, windowsVirtualKeyCode: 0, nativeVirtualKeyCode: 0, }; } function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function delay(ms) { if (!ms || ms <= 0) { return Promise.resolve(); } return new Promise((resolve) => setTimeout(resolve, ms)); }