UNPKG

tracky-mouse

Version:

Add facial mouse accessibility to JavaScript applications

1,675 lines (1,620 loc) 179 kB
/* global jsfeat, Stats, clm, faceLandmarksDetection, OneEuroFilter */ const TrackyMouse = { dependenciesRoot: "./tracky-mouse", }; TrackyMouse.loadDependencies = function ({ statsJs = false } = {}) { TrackyMouse.dependenciesRoot = TrackyMouse.dependenciesRoot.replace(/\/+$/, ""); const loadScript = src => { return new Promise((resolve, reject) => { // This wouldn't wait for them to load // for (const script of document.scripts) { // if (script.src.includes(src)) { // resolve(); // return; // } // } const script = document.createElement('script'); script.type = 'text/javascript'; script.onload = resolve; script.onerror = reject; script.src = src; document.head.append(script); }); }; const scriptFiles = [ `${TrackyMouse.dependenciesRoot}/lib/no-eval.js`, // generated with eval-is-evil.html, this instruments clmtrackr.js so I don't need unsafe-eval in the CSP `${TrackyMouse.dependenciesRoot}/lib/clmtrackr.js`, `${TrackyMouse.dependenciesRoot}/lib/face_mesh/face_mesh.js`, `${TrackyMouse.dependenciesRoot}/lib/OneEuroFilter.js`, ]; // face-landmarks-detection.min.js depends on face_mesh.js // avoid sporadic "TypeError: o.Facemesh is not a constructor" by loading face-landmarks-detection after face_mesh.js // TODO: preload in parallel? const moreScriptFiles = [ `${TrackyMouse.dependenciesRoot}/lib/face-landmarks-detection.min.js`, ]; if (statsJs) { scriptFiles.push(`${TrackyMouse.dependenciesRoot}/lib/stats.js`); } return Promise.all(scriptFiles.map(loadScript)).then(() => { return Promise.all(moreScriptFiles.map(loadScript)); }); }; const isSelectorValid = ((dummyElement) => (selector) => { try { dummyElement.querySelector(selector); } catch { return false; } return true; })(document.createDocumentFragment()); const dwellClickers = []; let playSound = () => { console.log("audio module not loaded yet; can't play sound effect"); }; let initialAudioEnabled = false; let setAudioEnabled = (enabled) => { initialAudioEnabled = enabled; }; /** * @param {Object} config * @param {string} config.targets - a CSS selector for the elements to click. Anything else will be ignored (except as an occluder). * @param {(el: Element) => boolean} [config.shouldDrag] - a function that returns true if the element should be dragged rather than simply clicked. * @param {(el: Element) => boolean} [config.noCenter] - a function that returns true if the element should be clicked anywhere on the element, rather than always at the center. * @param {Array<{ * from: string | Element | ((el: Element) => boolean), // - an array of `{ from, to, withinMargin }` objects, which define rules for dynamically changing what is hovered/clicked when the mouse is over a different element. * to: string | Element | ((el: Element) => Element | null), // - the element to retarget from. Can be a CSS selector, an element, or a function taking the element under the mouse and returning whether it should be retargeted. * withinMargin?: number // - the element to retarget to. Can be a CSS selector for an element which is an ancestor or descendant of the `from` element, or an element, or a function taking the element under the mouse and returning an element to retarget to, or null to ignore the element. * }>} [config.retarget] - a number of pixels within which to consider the mouse over the `to` element. Default to infinity. * @param {(el1: Element, el2: Element) => boolean} [config.isEquivalentTarget] - a function that returns true if two elements should be considered part of the same control, i.e. if clicking either should do the same thing. Elements that are equal are always considered equivalent even if you return false. This option is used for preventing the system from detecting occluding elements as separate controls, and rejecting the click. (When an occlusion is detected, it flashes a red box.) * @param {(el: Element) => boolean} [config.dwellClickEvenIfPaused] - a function that returns true if the element should be clicked even while dwell clicking is otherwise paused. Use this for a dwell clicking toggle button, so it's possible to resume dwell clicking. With dwell clicking it's important to let users take a break, since otherwise you have to constantly move the cursor in order to not click on things! * @param {(el: Element) => boolean} [config.shouldClickThrough] - a function that returns true if the element should be totally ignored, allowing clicking on content behind it. Prefer `pointer-events: none` when possible, which will work for all input methods. Use this only if you need to differentiate input methods. Default: `(el) => el.matches(".tracky-mouse-click-through, .tracky-mouse-click-through *")` * @param {(args: {x: number, y: number, target: Element}) => void} config.click - a function to trigger a click on the given target element. * @param {() => void} [config.beforeDispatch] - a function to call before a pointer event is dispatched. For detecting un-trusted user gestures, outside of an event handler. * @param {() => void} [config.afterDispatch] - a function to call after a pointer event is dispatched. For detecting un-trusted user gestures, outside of an event handler. * @param {() => void} [config.beforePointerDownDispatch] - a function to call before a `pointerdown` event is dispatched. Likely to be merged with `config.beforeDispatch()` in the future. * @param {() => boolean} [config.isHeld] - a function that returns true if the next dwell should be a release (triggering `pointerup`). */ const initDwellClicking = (config) => { /** translation placeholder */ const t = (key, options = {}) => options.defaultValue ?? key; if (typeof config !== "object") { throw new Error(t("api.errors.configRequired", { defaultValue: "configuration object required for initDwellClicking" })); } if (config.targets === undefined) { throw new Error(t("api.errors.targetsRequired", { defaultValue: "config.targets is required (must be a CSS selector)" })); } if (typeof config.targets !== "string") { throw new Error(t("api.errors.targetsMustBeSelectorString", { defaultValue: "config.targets must be a string (a CSS selector)" })); } if (!isSelectorValid(config.targets)) { throw new Error(t("api.errors.targetsInvalidSelector", { defaultValue: "config.targets is not a valid CSS selector" })); } if (config.click === undefined) { throw new Error(t("api.errors.clickRequired", { defaultValue: "config.click is required" })); } if (typeof config.click !== "function") { throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.click")); } if (config.shouldDrag !== undefined && typeof config.shouldDrag !== "function") { throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.shouldDrag")); } if (config.noCenter !== undefined && typeof config.noCenter !== "function") { throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.noCenter")); } if (config.isEquivalentTarget !== undefined && typeof config.isEquivalentTarget !== "function") { throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.isEquivalentTarget")); } if (config.dwellClickEvenIfPaused !== undefined && typeof config.dwellClickEvenIfPaused !== "function") { throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.dwellClickEvenIfPaused")); } if (config.shouldClickThrough !== undefined && typeof config.shouldClickThrough !== "function") { throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.shouldClickThrough")); } if (config.beforeDispatch !== undefined && typeof config.beforeDispatch !== "function") { throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.beforeDispatch")); } if (config.afterDispatch !== undefined && typeof config.afterDispatch !== "function") { throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.afterDispatch")); } if (config.beforePointerDownDispatch !== undefined && typeof config.beforePointerDownDispatch !== "function") { throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.beforePointerDownDispatch")); } if (config.isHeld !== undefined && typeof config.isHeld !== "function") { throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.isHeld")); } if (config.retarget !== undefined) { if (!Array.isArray(config.retarget)) { throw new Error(t("api.errors.retargetMustBeArray", { defaultValue: "config.retarget must be an array of objects" })); } for (let i = 0; i < config.retarget.length; i++) { const rule = config.retarget[i]; if (typeof rule !== "object") { throw new Error(t("api.errors.retargetMustBeArray", { defaultValue: "config.retarget must be an array of objects" })); } if (rule.from === undefined) { throw new Error(t("api.errors.retargetFromRequired", { defaultValue: "config.retarget[%0].from is required" }).replace("%0", i)); } if (rule.to === undefined) { throw new Error(t("api.errors.retargetToRequired", { defaultValue: "config.retarget[%0].to is required (although can be null to ignore the element)" }).replace("%0", i)); } if (rule.withinMargin !== undefined && typeof rule.withinMargin !== "number") { throw new Error(t("api.errors.numberRequired", { defaultValue: "%0 must be a number" }).replace("%0", `config.retarget[${i}].withinMargin`)); } if (typeof rule.from !== "string" && typeof rule.from !== "function" && !(rule.from instanceof Element)) { throw new Error(t("api.errors.retargetFromInvalidType", { defaultValue: "config.retarget[%0].from must be a CSS selector string, an Element, or a function" }).replace("%0", i)); } if (typeof rule.to !== "string" && typeof rule.to !== "function" && !(rule.to instanceof Element) && rule.to !== null) { throw new Error(t("api.errors.retargetToInvalidType", { defaultValue: "config.retarget[%0].to must be a CSS selector string, an Element, a function, or null" }).replace("%0", i)); } if (typeof rule.from === "string" && !isSelectorValid(rule.from)) { throw new Error(t("api.errors.retargetFromInvalidSelector", { defaultValue: "config.retarget[%0].from is not a valid CSS selector" }).replace("%0", i)); } if (typeof rule.to === "string" && !isSelectorValid(rule.to)) { throw new Error(t("api.errors.retargetToInvalidSelector", { defaultValue: "config.retarget[%0].to is not a valid CSS selector" }).replace("%0", i)); } } } const shouldClickThrough = config.shouldClickThrough ?? ((el) => el.matches(".tracky-mouse-click-through, .tracky-mouse-click-through *")); // trackyMouseContainer.querySelector(".tracky-mouse-canvas").classList.add("inset-deep"); const circleRadiusMax = 50; // dwell indicator size in pixels const hoverTimespan = 500; // how long between the dwell indicator appearing and triggering a click const averagingWindowTimespan = 500; const inactiveAtStartupTimespan = 1500; // (should be at least averagingWindowTimespan, but more importantly enough to make it not awkward when enabling dwell clicking) const inactiveAfterReleaseTimespan = 1000; // after click or drag release (from dwell or otherwise) const inactiveAfterHoveredTimespan = 1000; // after dwell click indicator appears; does not control the time to finish that dwell click, only to click on something else after this is canceled (but it doesn't control that directly) const inactiveAfterInvalidTimespan = 1000; // after a dwell click is canceled due to an element popping up in front, or existing in front at the center of the other element const inactiveAfterFocusedTimespan = 1000; // after page becomes focused after being unfocused let recentPoints = []; let inactiveUntilTime = performance.now(); let paused = false; let hoverCandidate; let dwellDragging = null; const deactivateForAtLeast = (timespan) => { inactiveUntilTime = Math.max(inactiveUntilTime, performance.now() + timespan); }; deactivateForAtLeast(inactiveAtStartupTimespan); const halo = document.createElement("div"); halo.className = "tracky-mouse-hover-halo"; halo.style.display = "none"; document.body.appendChild(halo); const dwellIndicator = document.createElement("div"); dwellIndicator.className = "tracky-mouse-dwell-indicator"; dwellIndicator.style.width = `${circleRadiusMax}px`; dwellIndicator.style.height = `${circleRadiusMax}px`; dwellIndicator.style.display = "none"; document.body.appendChild(dwellIndicator); const onPointerMove = (e) => { recentPoints.push({ x: e.clientX, y: e.clientY, time: performance.now() }); }; const onPointerUpOrCancel = (_e) => { deactivateForAtLeast(inactiveAfterReleaseTimespan); dwellDragging = null; }; let pageFocused = document.visibilityState === "visible"; // guess/assumption let mouseInsidePage = true; // assumption const onFocus = () => { pageFocused = true; deactivateForAtLeast(inactiveAfterFocusedTimespan); }; const onBlur = () => { pageFocused = false; }; const onMouseLeavePage = () => { mouseInsidePage = false; }; const onMouseEnterPage = () => { mouseInsidePage = true; }; window.addEventListener("pointermove", onPointerMove); window.addEventListener("pointerup", onPointerUpOrCancel); window.addEventListener("pointercancel", onPointerUpOrCancel); window.addEventListener("focus", onFocus); window.addEventListener("blur", onBlur); document.addEventListener("mouseleave", onMouseLeavePage); document.addEventListener("mouseenter", onMouseEnterPage); const getHoverCandidate = (clientX, clientY) => { if (!pageFocused || !mouseInsidePage) return null; let target = document.elementFromPoint(clientX, clientY); if (!target) { return null; } if (shouldClickThrough(target)) { const elements = document.elementsFromPoint(clientX, clientY); target = elements.find(el => !shouldClickThrough(el)); if (!target) { return null; } } let hoverCandidate = { x: clientX, y: clientY, time: performance.now(), }; let retargeted = false; for (const { from, to, withinMargin = Infinity } of (config.retarget ?? [])) { if ( from instanceof Element ? from === target : typeof from === "function" ? from(target) : target.matches(from) ) { const toElement = (to instanceof Element || to === null) ? to : typeof to === "function" ? to(target) : (target.closest(to) || target.querySelector(to)); if (toElement === null) { return null; } else if (toElement) { const toRect = toElement.getBoundingClientRect(); if ( hoverCandidate.x > toRect.left - withinMargin && hoverCandidate.y > toRect.top - withinMargin && hoverCandidate.x < toRect.right + withinMargin && hoverCandidate.y < toRect.bottom + withinMargin ) { target = toElement; hoverCandidate.x = Math.min( toRect.right - 1, Math.max( toRect.left, hoverCandidate.x, ), ); hoverCandidate.y = Math.min( toRect.bottom - 1, Math.max( toRect.top, hoverCandidate.y, ), ); retargeted = true; } } } } if (!retargeted) { target = target.closest(config.targets); if (!target) { return null; } } if (!config.noCenter?.(target)) { // Nudge hover previews to the center of buttons and things const rect = target.getBoundingClientRect(); hoverCandidate.x = rect.left + rect.width / 2; hoverCandidate.y = rect.top + rect.height / 2; } hoverCandidate.target = target; return hoverCandidate; }; const getEventOptions = ({ x, y }) => { return { view: window, // needed for offsetX/Y calculation clientX: x, clientY: y, pointerId: 1234567890, pointerType: "mouse", isPrimary: true, bubbles: true, cancelable: true, }; }; const averagePoints = (points) => { const average = { x: 0, y: 0 }; for (const pointer of points) { average.x += pointer.x; average.y += pointer.y; } average.x /= points.length; average.y /= points.length; return average; }; const update = () => { const time = performance.now(); recentPoints = recentPoints.filter((pointRecord) => time < pointRecord.time + averagingWindowTimespan); if (recentPoints.length) { const latestPoint = recentPoints[recentPoints.length - 1]; recentPoints.push({ x: latestPoint.x, y: latestPoint.y, time }); const averagePoint = averagePoints(recentPoints); // debug // const canvasPoint = toCanvasCoords({clientX: averagePoint.x, clientY: averagePoint.y}); // ctx.fillStyle = "red"; // ctx.fillRect(canvasPoint.x, canvasPoint.y, 10, 10); const recentMovementAmount = Math.hypot(latestPoint.x - averagePoint.x, latestPoint.y - averagePoint.y); // Invalidate in case an element pops up in front of the element you're hovering over, e.g. a submenu // (that use case doesn't actually work in jspaint because the menu pops up before the hoverCandidate exists) // (TODO: disable hovering to open submenus in facial mouse mode in jspaint) // or an element occludes the center of an element you're hovering over, in which case it // could be confusing if it showed a dwell click indicator over a different element than it would click // (but TODO: just move the indicator off center in that case) if (hoverCandidate && !dwellDragging) { const apparentHoverCandidate = getHoverCandidate(hoverCandidate.x, hoverCandidate.y); const showOccluderIndicator = (occluder) => { const occluderIndicator = document.createElement("div"); const occluderRect = occluder.getBoundingClientRect(); const outlineWidth = 4; occluderIndicator.style.pointerEvents = "none"; occluderIndicator.style.zIndex = 1000001; occluderIndicator.style.display = "block"; occluderIndicator.style.position = "fixed"; occluderIndicator.style.left = `${occluderRect.left + outlineWidth}px`; occluderIndicator.style.top = `${occluderRect.top + outlineWidth}px`; occluderIndicator.style.width = `${occluderRect.width - outlineWidth * 2}px`; occluderIndicator.style.height = `${occluderRect.height - outlineWidth * 2}px`; occluderIndicator.style.outline = `${outlineWidth}px dashed red`; occluderIndicator.style.boxShadow = `0 0 ${outlineWidth}px ${outlineWidth}px maroon`; document.body.appendChild(occluderIndicator); setTimeout(() => { occluderIndicator.remove(); }, inactiveAfterInvalidTimespan * 0.5); }; if (apparentHoverCandidate) { if ( apparentHoverCandidate.target !== hoverCandidate.target && // !retargeted && !config.isEquivalentTarget?.( apparentHoverCandidate.target, hoverCandidate.target ) ) { hoverCandidate = null; deactivateForAtLeast(inactiveAfterInvalidTimespan); showOccluderIndicator(apparentHoverCandidate.target); } } else { // TODO: ignore .tracky-mouse-click-through elements here as well // TODO: distinguish occlusion vs moved element (i.e. element is no longer in the elementsFromPoint list) // for example for the archery targets in the demo on the website, which animate let occluder = document.elementFromPoint(hoverCandidate.x, hoverCandidate.y); hoverCandidate = null; deactivateForAtLeast(inactiveAfterInvalidTimespan); showOccluderIndicator(occluder || document.body); } } let circlePosition = latestPoint; let circleOpacity = 0; let circleRadius = 0; if (hoverCandidate) { circlePosition = hoverCandidate; circleOpacity = 0.4; circleRadius = (hoverCandidate.time - time + hoverTimespan) / hoverTimespan * circleRadiusMax; if (time > hoverCandidate.time + hoverTimespan) { if (config.isHeld?.() || dwellDragging) { config.beforeDispatch?.(); hoverCandidate.target.dispatchEvent(new PointerEvent("pointerup", Object.assign(getEventOptions(hoverCandidate), { button: 0, buttons: 0, }) )); config.afterDispatch?.(); playSound("clickRelease"); } else { config.beforePointerDownDispatch?.(); config.beforeDispatch?.(); hoverCandidate.target.dispatchEvent(new PointerEvent("pointerdown", Object.assign(getEventOptions(hoverCandidate), { button: 0, buttons: 1, }) )); config.afterDispatch?.(); if (config.shouldDrag?.(hoverCandidate.target)) { dwellDragging = hoverCandidate.target; playSound("clickPress"); } else { config.beforeDispatch?.(); hoverCandidate.target.dispatchEvent(new PointerEvent("pointerup", Object.assign(getEventOptions(hoverCandidate), { button: 0, buttons: 0, }) )); config.click(hoverCandidate); config.afterDispatch?.(); playSound("clickPress"); playSound("clickRelease", { delay: 0.03 }); // fully separating the sounds sounded worse } } hoverCandidate = null; deactivateForAtLeast(inactiveAfterHoveredTimespan); } } if (dwellDragging) { dwellIndicator.classList.add("tracky-mouse-for-release"); } else { dwellIndicator.classList.remove("tracky-mouse-for-release"); } dwellIndicator.style.display = ""; dwellIndicator.style.opacity = circleOpacity; dwellIndicator.style.transform = `scale(${circleRadius / circleRadiusMax})`; dwellIndicator.style.left = `${circlePosition.x - circleRadiusMax / 2}px`; dwellIndicator.style.top = `${circlePosition.y - circleRadiusMax / 2}px`; let haloTarget = dwellDragging || (hoverCandidate || getHoverCandidate(latestPoint.x, latestPoint.y) || {}).target; if (haloTarget && (!paused || config.dwellClickEvenIfPaused?.(haloTarget))) { let rect = haloTarget.getBoundingClientRect(); const computedStyle = getComputedStyle(haloTarget); let ancestor = haloTarget; let borderRadiusScale = 1; // for border radius mimicry, given parents with transform: scale() while (ancestor instanceof HTMLElement) { const ancestorComputedStyle = getComputedStyle(ancestor); if (ancestorComputedStyle.transform) { // Collect scale transforms const match = ancestorComputedStyle.transform.match(/(?:scale|matrix)\((\d+(?:\.\d+)?)/); if (match) { borderRadiusScale *= Number(match[1]); } } if (ancestorComputedStyle.overflow !== "visible") { // Clamp to visible region if in scrollable area // This lets you see the hover halo when scrolled to the middle of a large canvas const scrollAreaRect = ancestor.getBoundingClientRect(); rect = { left: Math.max(rect.left, scrollAreaRect.left), top: Math.max(rect.top, scrollAreaRect.top), right: Math.min(rect.right, scrollAreaRect.right), bottom: Math.min(rect.bottom, scrollAreaRect.bottom), }; rect.width = rect.right - rect.left; rect.height = rect.bottom - rect.top; } ancestor = ancestor.parentNode; } halo.style.display = "block"; halo.style.position = "fixed"; halo.style.left = `${rect.left}px`; halo.style.top = `${rect.top}px`; halo.style.width = `${rect.width}px`; halo.style.height = `${rect.height}px`; // shorthand properties might not work in all browsers (not tested) // this is so overkill... // Maybe instead of collecting scale transforms and applying them to the border radii specifically, // just collect transforms in general and apply them to the halo element? // But of course getBoundingClientRect() includes transforms... for (const prop of [ "borderTopRightRadius", "borderTopLeftRadius", "borderBottomRightRadius", "borderBottomLeftRadius", ]) { // Unfortunately, getComputedStyle can return percentages, probably other units, probably also "auto" if (computedStyle[prop].endsWith("px")) { halo.style[prop] = `${parseFloat(computedStyle[prop]) * borderRadiusScale}px`; } else { halo.style[prop] = computedStyle[prop]; } } } else { halo.style.display = "none"; } if (time < inactiveUntilTime) { return; } if (recentMovementAmount < 5) { if (!hoverCandidate) { hoverCandidate = { x: averagePoint.x, y: averagePoint.y, time: performance.now(), target: dwellDragging || null, }; if (!dwellDragging) { hoverCandidate = getHoverCandidate(hoverCandidate.x, hoverCandidate.y); } if (hoverCandidate && (paused && !config.dwellClickEvenIfPaused?.(hoverCandidate.target))) { hoverCandidate = null; } } } if (recentMovementAmount > 100) { if (dwellDragging) { config.beforeDispatch?.(); window.dispatchEvent(new PointerEvent("pointerup", Object.assign(getEventOptions(averagePoint), { button: 0, buttons: 0, }) )); config.afterDispatch?.(); config.afterReleaseDrag?.(); } } if (recentMovementAmount > 60) { hoverCandidate = null; } } }; let rafId; const animate = () => { rafId = requestAnimationFrame(animate); update(); }; rafId = requestAnimationFrame(animate); const dispose = () => { cancelAnimationFrame(rafId); halo.remove(); dwellIndicator.remove(); window.removeEventListener("pointermove", onPointerMove); window.removeEventListener("pointerup", onPointerUpOrCancel); window.removeEventListener("pointercancel", onPointerUpOrCancel); window.removeEventListener("focus", onFocus); window.removeEventListener("blur", onBlur); document.removeEventListener("mouseleave", onMouseLeavePage); document.removeEventListener("mouseenter", onMouseEnterPage); }; const dwellClicker = { get paused() { return paused; }, set paused(value) { paused = value; }, dispose, }; dwellClickers.push(dwellClicker); return dwellClicker; }; TrackyMouse.initDwellClicking = function (config) { return initDwellClicking(config); }; TrackyMouse.cleanupDwellClicking = function () { for (const dwellClicker of dwellClickers) { dwellClicker.dispose(); } }; TrackyMouse._initAudio = async function () { let module; try { // console.log("Loading audio support..."); module = await import("./audio.js"); } catch (e) { console.warn("Failed to load audio module, click sounds will be disabled:", e); } // console.log("Audio module loaded."); try { const { initAudio } = module; initAudio(); playSound = module.playSound; setAudioEnabled = module.setAudioEnabled; setAudioEnabled(initialAudioEnabled); // console.log("Audio is initially " + (initialAudioEnabled ? "enabled" : "disabled")); } catch (e) { console.warn("Failed to initialize audio support, click sounds will be disabled:", e); } return module; }; TrackyMouse._initInner = function (div, initOptions, reinit) { const { statsJs = false, // Unstable updateInputFeedback = window.electronAPI?.updateInputFeedback, // Unstable setMouseButtonState = window.electronAPI?.setMouseButtonState, // Unstable notifyToggleState = window.electronAPI?.notifyToggleState, // Unstable handleSettingsUpdate, // Unstable clickingModeSupported = false, // TODO: manage all of electronAPI similarly? well, setOptions is already a function in scope here, // and it's not like we want to expose all electronAPI as part of the public API necessarily // Could group things under an "unstable" object, or ideally, design nice APIs for everything. } = initOptions; /** @type {SleepSweep | null} */ let sleepSweep = null; TrackyMouse._initAudio().then((module) => { // _initAudio warns in the console and resolves to undefined if it fails to load audio support if (module) { const { SleepSweep } = module; sleepSweep = new SleepSweep(); } }); const isDesktopApp = !!window.electronAPI; let translations = {}; let locale = navigator.language || "en"; // Transform en-US to en, etc. // We don't support variants yet if (locale.includes("-")) { locale = locale.split("-")[0]; } const availableLanguages = [ // GENERATED by scripts/update-locales.js "ar", "ar-EG", "bg", "bn", "ca", "ce", "ceb", "cs", "da", "de", "el", "emoji", "en", "eo", "es", "eu", "fa", "fi", "fr", "gu", "ha", "he", "hi", "hr", "hu", "hy", "id", "it", "ja", "jv", "ko", "mr", "ms", "nan", "nb", "nl", "pa", "pl", "pt", "pt-BR", "ro", "ru", "sk", "sl", "sr", "sv", "sw", "ta", "te", "th", "tl", "tr", "tt", "uk", "ur", "uz", "vi", "war", "zh", "zh-simplified" // END GENERATED ]; // Fallback to a valid dropdown value for unsupported locales if (!availableLanguages.includes(locale)) { locale = "en"; } try { // Load settings early so that they can be used to define settings (among other things) // It's a bit hacky to load them twice but yeah // (Actually in the desktop app it's even more hacky because I // added code in electron-app.html to load the settings via the electron API // and populate localStorage so that this code will work) const settingsJSON = localStorage.getItem("tracky-mouse-settings"); if (settingsJSON) { locale = JSON.parse(settingsJSON)?.globalSettings?.language || locale; } if (locale !== "en") { // synchronous XHR baby! const request = new XMLHttpRequest(); request.open("GET", `${TrackyMouse.dependenciesRoot}/locales/${locale}/translation.json`, false); request.send(null); if (request.status === 200) { translations = JSON.parse(request.responseText); } else { console.warn(`Could not load translations for locale ${locale} (status ${request.status})`); } } } catch (e) { console.warn("Could not load translations for TrackyMouse UI:", e); } const rtlLanguages = ["ar", "he", "fa", "ur"]; // Right-to-left languages (current and future) const isRTL = rtlLanguages.includes(locale.split("-")[0]); const t = (key, options = {}) => translations[key] ?? options.defaultValue ?? key; // console.trace("Initializing UI with locale", locale); // language name mappings marked with * may not be ISO 639-1 // they may be ISO 639-3 or bespoke // spell-checker:disable const languageNames = { // "639-1": [["ISO language name"], ["Native name (endonym)"]], ab: [["Abkhazian"], ["Аҧсуа Бызшәа", "Аҧсшәа"]], aa: [["Afar"], ["Afaraf"]], af: [["Afrikaans"], ["Afrikaans"]], ak: [["Akan"], ["Akan"]], sq: [["Albanian"], ["Shqip"]], am: [["Amharic"], ["አማርኛ"]], ar: [["Arabic"], ["العربية"]], "ar-EG": [["Egyptian Arabic"], ["العربية المصرية"]],//* an: [["Aragonese"], ["Aragonés"]], hy: [["Armenian"], ["Հայերեն"]], as: [["Assamese"], ["অসমীয়া"]], av: [["Avaric"], ["Авар МацӀ", "МагӀарул МацӀ"]], ae: [["Avestan"], ["Avesta"]], ay: [["Aymara"], ["Aymar Aru"]], az: [["Azerbaijani"], ["Azərbaycan Dili"]], bm: [["Bambara"], ["Bamanankan"]], ba: [["Bashkir"], ["Башҡорт Теле"]], emoji: [["Emoji"], ["😃📝"]],//* eu: [["Basque"], ["Euskara", "Euskera"]], be: [["Belarusian"], ["Беларуская Мова"]], bn: [["Bengali"], ["বাংলা"]], bh: [["Bihari Languages"], ["भोजपुरी"]], bi: [["Bislama"], ["Bislama"]], bs: [["Bosnian"], ["Bosanski Jezik"]], br: [["Breton"], ["Brezhoneg"]], bg: [["Bulgarian"], ["Български Език"]], my: [["Burmese"], ["ဗမာစာ"]], ca: [["Catalan", "Valencian"], ["Català", "Valencià"]], ch: [["Chamorro"], ["Chamoru"]], ce: [["Chechen"], ["Нохчийн Мотт"]], ceb: [["Cebuano"], ["Bisayâ", "Binisayâ"]],//* ny: [["Chichewa", "Chewa", "Nyanja"], ["ChiCheŵa", "Chinyanja"]], // zh: [["Chinese"], ["中文", "Zhōngwén", "汉语", "漢語"]], // The ISO 639-1 code "zh" doesn't refer to Traditional Chinese specifically, // but we want to show the distinction between Chinese varieties in the Language menu, // so this is overly specific for now. // @TODO: do this cleaner by establishing a mapping between ISO codes (such as "zh") and default language IDs (such as "zh-traditional") zh: [["Traditional Chinese"], ["繁體中文", "傳統中文", "正體中文", "繁体中文"]], "zh-traditional": [["Traditional Chinese"], ["繁體中文", "傳統中文", "正體中文", "繁体中文"]], //* "zh-simplified": [["Simplified Chinese"], ["简体中文"]], //* cv: [["Chuvash"], ["Чӑваш Чӗлхи"]], kw: [["Cornish"], ["Kernewek"]], co: [["Corsican"], ["Corsu", "Lingua Corsa"]], cr: [["Cree"], ["ᓀᐦᐃᔭᐍᐏᐣ"]], hr: [["Croatian"], ["Hrvatski Jezik"]], cs: [["Czech"], ["Čeština", "Český Jazyk"]], da: [["Danish"], ["Dansk"]], dv: [["Divehi", "Dhivehi", "Maldivian"], ["ދިވެހި"]], nl: [["Dutch", "Flemish"], ["Nederlands", "Vlaams"]], dz: [["Dzongkha"], ["རྫོང་ཁ"]], en: [["English"], ["English"]], eo: [["Esperanto"], ["Esperanto"]], et: [["Estonian"], ["Eesti", "Eesti Keel"]], ee: [["Ewe"], ["Eʋegbe"]], fo: [["Faroese"], ["Føroyskt"]], fj: [["Fijian"], ["Vosa Vakaviti"]], fi: [["Finnish"], ["Suomi", "Suomen Kieli"]], fr: [["French"], ["Français", "Langue Française"]], ff: [["Fulah"], ["Fulfulde", "Pulaar", "Pular"]], gl: [["Galician"], ["Galego"]], ka: [["Georgian"], ["ქართული"]], de: [["German"], ["Deutsch"]], el: [["Greek"], ["Ελληνικά"]], gn: [["Guarani"], ["Avañe'ẽ"]], gu: [["Gujarati"], ["ગુજરાતી"]], ht: [["Haitian", "Haitian Creole"], ["Kreyòl Ayisyen"]], ha: [["Hausa"], ["هَوُسَ"]], he: [["Hebrew"], ["עברית"]], hz: [["Herero"], ["Otjiherero"]], hi: [["Hindi"], ["हिन्दी", "हिंदी"]], ho: [["Hiri Motu"], ["Hiri Motu"]], hu: [["Hungarian"], ["Magyar"]], ia: [["Interlingua"], ["Interlingua"]], id: [["Indonesian"], ["Bahasa Indonesia"]], ie: [["Interlingue", "Occidental"], ["Interlingue", "Occidental"]], ga: [["Irish"], ["Gaeilge"]], ig: [["Igbo"], ["Asụsụ Igbo"]], ik: [["Inupiaq"], ["Iñupiaq", "Iñupiatun"]], io: [["Ido"], ["Ido"]], is: [["Icelandic"], ["Íslenska"]], it: [["Italian"], ["Italiano"]], iu: [["Inuktitut"], ["ᐃᓄᒃᑎᑐᑦ"]], ja: [["Japanese"], ["日本語", "にほんご"]], jv: [["Javanese"], ["ꦧꦱꦗꦮ", "Basa Jawa"]], kl: [["Kalaallisut", "Greenlandic"], ["Kalaallisut", "Kalaallit Oqaasii"]], kn: [["Kannada"], ["ಕನ್ನಡ"]], kr: [["Kanuri"], ["Kanuri"]], ks: [["Kashmiri"], ["कश्मीरी", "كشميري‎"]], kk: [["Kazakh"], ["Қазақ Тілі"]], km: [["Central Khmer"], ["ខ្មែរ", "ខេមរភាសា", "ភាសាខ្មែរ"]], ki: [["Kikuyu", "Gikuyu"], ["Gĩkũyũ"]], rw: [["Kinyarwanda"], ["Ikinyarwanda"]], ky: [["Kirghiz", "Kyrgyz"], ["Кыргызча", "Кыргыз Тили"]], kv: [["Komi"], ["Коми Кыв"]], kg: [["Kongo"], ["Kikongo"]], ko: [["Korean"], ["한국어"]], ku: [["Kurdish"], ["Kurdî", "کوردی‎"]], kj: [["Kuanyama", "Kwanyama"], ["Kuanyama"]], la: [["Latin"], ["Latine", "Lingua Latina"]], lb: [["Luxembourgish", "Letzeburgesch"], ["Lëtzebuergesch"]], lg: [["Ganda"], ["Luganda"]], li: [["Limburgan", "Limburger", "Limburgish"], ["Limburgs"]], ln: [["Lingala"], ["Lingála"]], lo: [["Lao"], ["ພາສາລາວ"]], lt: [["Lithuanian"], ["Lietuvių Kalba"]], lu: [["Luba-Katanga"], ["Kiluba"]], lv: [["Latvian"], ["Latviešu Valoda"]], gv: [["Manx"], ["Gaelg", "Gailck"]], mk: [["Macedonian"], ["Македонски Јазик"]], mg: [["Malagasy"], ["Fiteny Malagasy"]], ms: [["Malay"], ["Bahasa Melayu", "بهاس ملايو‎"]], ml: [["Malayalam"], ["മലയാളം"]], mt: [["Maltese"], ["Malti"]], mi: [["Maori"], ["Te Reo Māori"]], mr: [["Marathi"], ["मराठी"]], mh: [["Marshallese"], ["Kajin M̧ajeļ"]], mn: [["Mongolian"], ["Монгол Хэл"]], na: [["Nauru"], ["Dorerin Naoero"]], nan: [["Minnan", "Taiwanese Hokkien"], ["閩南語", "闽南语", "Bàn-lâm-gú", "Bân-lâm-gí"]],//* (technically Hokkien is a branch of Minnan; also idk what names are preferred) nv: [["Navajo", "Navaho"], ["Diné Bizaad"]], nd: [["North Ndebele"], ["IsiNdebele"]], ne: [["Nepali"], ["नेपाली"]], ng: [["Ndonga"], ["Owambo"]], nb: [["Norwegian Bokmål"], ["Norsk Bokmål"]], nn: [["Norwegian Nynorsk"], ["Norsk Nynorsk"]], no: [["Norwegian"], ["Norsk"]], ii: [["Sichuan Yi", "Nuosu"], ["ꆈꌠ꒿", "Nuosuhxop"]], nr: [["South Ndebele"], ["IsiNdebele"]], oc: [["Occitan"], ["Occitan", "Lenga d'Òc"]], oj: [["Ojibwa"], ["ᐊᓂᔑᓈᐯᒧᐎᓐ"]], cu: [["Church Slavic", "Old Slavonic", "Church Slavonic", "Old Bulgarian", "Old Church Slavonic"], ["Ѩзыкъ Словѣньскъ"]], om: [["Oromo"], ["Afaan Oromoo"]], or: [["Oriya"], ["ଓଡ଼ିଆ"]], os: [["Ossetian", "Ossetic"], ["Ирон Æвзаг"]], pa: [["Punjabi", "Panjabi"], ["ਪੰਜਾਬੀ", "پنجابی‎"]], pi: [["Pali"], ["पालि", "पाळि"]], fa: [["Persian"], ["فارسی"]], pl: [["Polish"], ["Język Polski", "Polszczyzna"]], ps: [["Pashto", "Pushto"], ["پښتو"]], pt: [["Portuguese"], ["Português"]], "pt-BR": [["Brazilian Portuguese"], ["Português Brasileiro"]], "pt-PT": [["Portuguese (Portugal)"], ["Português De Portugal"]], qu: [["Quechua"], ["Runa Simi", "Kichwa"]], rm: [["Romansh"], ["Rumantsch Grischun"]], rn: [["Rundi"], ["Ikirundi"]], ro: [["Romanian", "Moldavian", "Moldovan"], ["Română"]], ru: [["Russian"], ["Русский"]], sa: [["Sanskrit"], ["संस्कृतम्"]], sc: [["Sardinian"], ["Sardu"]], sd: [["Sindhi"], ["सिन्धी", "سنڌي، سندھی‎"]], se: [["Northern Sami"], ["Davvisámegiella"]], sm: [["Samoan"], ["Gagana Fa'a Samoa"]], sg: [["Sango"], ["Yângâ Tî Sängö"]], sr: [["Serbian"], ["Српски Језик"]], gd: [["Gaelic", "Scottish Gaelic"], ["Gàidhlig"]], sn: [["Shona"], ["ChiShona"]], si: [["Sinhala", "Sinhalese"], ["සිංහල"]], sk: [["Slovak"], ["Slovenčina", "Slovenský Jazyk"]], sl: [["Slovenian"], ["Slovenski Jezik", "Slovenščina"]], so: [["Somali"], ["Soomaaliga", "Af Soomaali"]], st: [["Southern Sotho"], ["Sesotho"]], es: [["Spanish", "Castilian"], ["Español"]], su: [["Sundanese"], ["Basa Sunda"]], sw: [["Swahili"], ["Kiswahili"]], ss: [["Swati"], ["SiSwati"]], sv: [["Swedish"], ["Svenska"]], ta: [["Tamil"], ["தமிழ்"]], te: [["Telugu"], ["తెలుగు"]], tg: [["Tajik"], ["Тоҷикӣ", "Toçikī", "تاجیکی‎"]], th: [["Thai"], ["ไทย"]], ti: [["Tigrinya"], ["ትግርኛ"]], bo: [["Tibetan"], ["བོད་ཡིག"]], tk: [["Turkmen"], ["Türkmen", "Түркмен"]], tl: [["Tagalog"], ["Wikang Tagalog"]], tn: [["Tswana"], ["Setswana"]], to: [["Tonga"], ["Faka Tonga"]], tr: [["Turkish"], ["Türkçe"]], ts: [["Tsonga"], ["Xitsonga"]], tt: [["Tatar"], ["Татар Теле", "Tatar Tele"]], tw: [["Twi"], ["Twi"]], ty: [["Tahitian"], ["Reo Tahiti"]], ug: [["Uighur", "Uyghur"], ["ئۇيغۇرچە‎", "Uyghurche"]], uk: [["Ukrainian"], ["Українська"]], ur: [["Urdu"], ["اردو"]], uz: [["Uzbek"], ["Oʻzbek", "Ўзбек", "أۇزبېك‎"]], ve: [["Venda"], ["Tshivenḓa"]], vi: [["Vietnamese"], ["Tiếng Việt"]], vo: [["Volapük"], ["Volapük"]], wa: [["Walloon"], ["Walon"]], war: [["Waray"], ["Winaray"]],//* cy: [["Welsh"], ["Cymraeg"]], wo: [["Wolof"], ["Wollof"]], fy: [["Western Frisian"], ["Frysk"]], xh: [["Xhosa"], ["IsiXhosa"]], yi: [["Yiddish"], ["ייִדיש"]], yo: [["Yoruba"], ["Yorùbá"]], za: [["Zhuang", "Chuang"], ["Saɯ Cueŋƅ", "Saw Cuengh"]], zu: [["Zulu"], ["IsiZulu"]], }; let languageToDefaultRegion = { aa: "ET", ab: "GE", abr: "GH", ace: "ID", ach: "UG", ada: "GH", ady: "RU", ae: "IR", aeb: "TN", af: "ZA", agq: "CM", aho: "IN", ak: "GH", akk: "IQ", aln: "XK", alt: "RU", am: "ET", amo: "NG", aoz: "ID", apd: "TG", ar: "EG", arc: "IR", "arc-Nbat": "JO", "arc-Palm": "SY", arn: "CL", aro: "BO", arq: "DZ", ary: "MA", arz: "EG", as: "IN", asa: "TZ", ase: "US", ast: "ES", atj: "CA", av: "RU", awa: "IN", ay: "BO", az: "AZ", "az-Arab": "IR", ba: "RU", bal: "PK", ban: "ID", bap: "NP", bar: "AT", bas: "CM", bax: "CM", bbc: "ID", bbj: "CM", bci: "CI", be: "BY", bej: "SD", bem: "ZM", bew: "ID", bez: "TZ", bfd: "CM", bfq: "IN", bft: "PK", bfy: "IN", bg: "BG", bgc: "IN", bgn: "PK", bgx: "TR", bhb: "IN", bhi: "IN", bhk: "PH", bho: "IN", bi: "VU", bik: "PH", bin: "NG", bjj: "IN", bjn: "ID", bjt: "SN", bkm: "CM", bku: "PH", blt: "VN", bm: "ML", bmq: "ML", bn: "BD", bo: "CN", bpy: "IN", bqi: "IR", bqv: "CI", br: "FR", bra: "IN", brh: "PK", brx: "IN", bs: "BA", bsq: "LR", bss: "CM", bto: "PH", btv: "PK", bua: "RU", buc: "YT", bug: "ID", bum: "CM", bvb: "GQ", byn: "ER", byv: "CM", bze: "ML", ca: "ES", cch: "NG", ccp: "BD", ce: "RU", ceb: "PH", cgg: "UG", ch: "GU", chk: "FM", chm: "RU", cho: "US", chp: "CA", chr: "US", cja: "KH", cjm: "VN", ckb: "IQ", co: "FR", cop: "EG", cps: "PH", cr: "CA", crh: "UA", crj: "CA", crk: "CA", crl: "CA", crm: "CA", crs: "SC", cs: "CZ", csb: "PL", csw: "CA", ctd: "MM", cu: "RU", "cu-Glag": "BG", cv: "RU", cy: "GB", da: "DK", dak: "US", dar: "RU", dav: "KE", dcc: "IN", de: "DE", den: "CA", dgr: "CA", dje: "NE", dnj: "CI", doi: "IN", dsb: "DE", dtm: "ML", dtp: "MY", dty: "NP", dua: "CM", dv: "MV", dyo: "SN", dyu: "BF", dz: "BT", ebu: "KE", ee: "GH", efi: "NG", egl: "IT", egy: "EG", eky: "MM", el: "GR", en: "US", "en-Shaw": "GB", es: "ES", esu: "US", et: "EE", ett: "IT", eu: "ES", ewo: "CM", ext: "ES", fa: "IR", fan: "GQ", ff: "SN", "ff-Adlm": "GN", ffm: "ML", fi: "FI", fia: "SD", fil: "PH", fit: "SE", fj: "FJ", fo: "FO", fon: "BJ", fr: "FR", frc: "US", frp: "FR", frr: "DE", frs: "DE", fub: "CM", fud: "WF", fuf: "GN", fuq: "NE", fur: "IT", fuv: "NG", fvr: "SD", fy: "NL", ga: "IE", gaa: "GH", gag: "MD", gan: "CN", gay: "ID", gbm: "IN", gbz: "IR", gcr: "GF", gd: "GB", gez: "ET", ggn: "NP", gil: "KI", gjk: "PK", gju: "PK", gl: "ES", glk: "IR", gn: "PY", gom: "IN", gon: "IN", gor: "ID", gos: "NL", got: "UA", grc: "CY", "grc-Linb": "GR", grt: "IN", gsw: "CH", gu: "IN", gub: "BR", guc: "CO", gur: "GH", guz: "KE", gv: "IM", gvr: "NP", gwi: "CA", ha: "NG", hak: "CN", haw: "US", haz: "AF", he: "IL", hi: "IN", hif: "FJ", hil: "PH", hlu: "TR", hmd: "CN", hnd: "PK", hne: "IN", hnj: "LA", hnn: "PH", hno: "PK", ho: "PG", hoc: "IN", hoj: "IN", hr: "HR", hsb: "DE", hsn: "CN", ht: "HT", hu: "HU", hy: "AM", hz: "NA", ia: "FR", iba: "MY", ibb: "NG", id: "ID", ife: "TG", ig: "NG", ii: "CN", ik: "US", ikt: "CA", ilo: "PH", in: "ID", inh: "RU", is: "IS", it: "IT", iu: "CA", iw: "IL", izh: "RU", ja: "JP", jam: "JM", jgo: "CM", ji: "UA", jmc: "TZ", jml: "NP", jut: "DK", jv: "ID", jw: "ID", ka: "GE", kaa: "UZ", kab: "DZ", kac: "MM", kaj: "NG", kam: "KE", kao: "ML", kbd: "RU", kby: "NE", kcg: "NG", kck: "ZW", kde: "TZ", kdh: "TG", kdt: "TH", kea: "CV", ken: "CM", kfo: "CI", kfr: "IN", kfy: "IN", kg: "CD", kge: "ID", kgp: "BR", kha: "IN", khb: "CN", khn: "IN", khq: "ML", kht: "IN", khw: "PK", ki: "KE", kiu: "TR", kj: "NA", kjg: "LA", kk: "KZ", "kk-Arab": "CN", kkj: "CM", kl: "GL", kln: "KE", km: "KH", kmb: "AO", kn: "IN", knf: "SN", ko: "KR", koi: "RU", kok: "IN", kos: "FM", kpe: "LR", krc: "RU", kri: "SL", krj: "PH", krl: "RU", kru: "IN", ks: "IN", ksb: "TZ", ksf: "CM", ksh: "DE", ku: "TR", "ku-Arab": "IQ", kum: "RU", kv: "RU", kvr: "ID", kvx: "PK", kw: "GB", kxm: "TH", kxp: "PK", ky: "KG", "ky-Arab": "CN", "ky-Latn": "TR", la: "VA", lab: "GR", lad: "IL", lag: "TZ", lah: "PK", laj: "UG", lb: "LU", lbe: "RU", lbw: "ID", lcp: "CN", lep: "IN", lez: "RU", lg: "UG", li: "NL", lif: "NP", "lif-Limb": "IN", lij: "IT", lis: "CN", ljp: "ID", lki: "IR", lkt: "US", lmn: "IN", lmo: "IT", ln: "CD", lo: "LA", lol: "CD", loz: "ZM", lrc: "IR", lt: "LT", ltg: "LV", lu: "CD", lua: "CD", luo: "KE", luy: "KE", luz: "IR", lv: "LV", lwl: "TH", lzh: "CN", lzz: "TR", mad: "ID", maf: "CM", mag: "IN", mai: "IN", mak: "ID", man: "GM", "man-Nkoo": "GN", mas: "KE", maz: "MX", mdf: "RU", mdh: "PH", mdr: "ID", men: "SL", mer: "KE", mfa: "TH", mfe: "MU", mg: "MG", mgh: "MZ", mgo: "CM", mgp: "NP", mgy: "TZ", mh: "MH", mi: "NZ", min: "ID", mis: "IQ", mk: "MK", ml: "IN", mls: "SD", mn: "MN", "mn-Mong": "CN", mni: "IN", mnw: "MM", moe: "CA", moh: "CA", mos: "BF", mr: "IN", mrd: "NP", mrj: "RU", mro: "BD", ms: "MY", mt: "MT", mtr: "IN", mua: "CM", mus: "US", mvy: "PK", mwk: "ML", mwr: "IN", mwv: "ID", mxc: "ZW", my: "MM", myv: "RU", myx: "UG", myz: "IR", mzn: "IR", na: "NR", nan: "CN", nap: "IT", naq: "NA", nb: "NO", nch: "MX", nd: "ZW", ndc: "MZ", nds: "DE", ne: "NP", new: "NP", ng: "NA", ngl: "MZ", nhe: "MX", nhw: "MX", nij: "ID", niu: "NU", njo: "IN", nl: "NL", nmg: "CM", nn: "NO", nnh: "CM", no: "NO", nod: "TH", noe: "IN", non: "SE", nqo: "GN", nr: "ZA", nsk: "CA", nso: "ZA", nus: "SS", nv: "US", nxq: "CN", ny: "MW", nym: "TZ", nyn: "UG", nzi: "GH", oc: "FR", om: "ET", or: "IN", os: "GE", osa: "US", otk: "MN", pa: "IN", "pa-Arab": "PK", pag: "PH", pal: "IR", "pal-Phlp": "CN", pam: "PH", pap: "AW", pau: "PW", pcd: "FR", pcm: "NG", pdc: "US", pdt: "CA", peo: "IR", pfl: "DE", phn: "LB", pka: "IN", pko: "KE", pl: "PL", pms: "IT", pnt: "GR", pon: "FM", pra: "PK", prd: "IR", ps: "AF", pt: "PT", //"BR", puu: "GA", qu: "PE", quc: "GT", qug: "EC", raj: "IN", rcf: "RE", rej: "ID", rgn: "IT", ria: "IN", rif: "MA", rjs: "NP", rkt: "BD", rm: "CH", rmf: "FI", rmo: "CH", rmt: "IR", rmu: "SE", rn: "BI", rng: "MZ", ro: "RO", rob: "ID", rof: "TZ", rtm: "FJ", ru: "RU", rue: "UA", rug: "SB", rw: "RW", rwk: "TZ", ryu: "JP", sa: "IN", saf: "GH", sah: "RU", saq: "KE", sas: "ID", sat: "IN", sav: "SN", saz: "IN", sbp: "TZ", sc: "IT", sck: "IN", scn: "IT", sco: "GB", scs: "CA", sd: "PK", "sd-Deva": "IN", "sd-Khoj": "IN", "sd-Sind": "IN", sdc: "IT", sdh: "IR", se: "NO", sef: "CI", seh: "MZ", sei: "MX", ses: "ML", sg: "CF", sga: "IE", sgs: "LT", shi: "MA", shn: "MM", si: "LK", sid: "ET", sk: "SK", skr: "PK", sl: "SI", sli: "PL", sly: "ID", sm: "WS", sma: "SE", smj: "SE", smn: "FI", smp: "IL", sms: "FI", sn: "ZW", snk: "ML", so: "SO", sou: "TH", sq: "AL", sr: "RS", srb: "IN", srn: "SR", srr: "SN", srx: "IN", ss: "ZA", ssy: "ER", st: "ZA", stq: "DE", su: "ID", suk: "TZ", sus: "GN", sv: "SE", sw: "TZ", swb: "YT", swc: "CD", swg: "DE", swv: "IN", sxn: "ID", syl: "BD", syr: "IQ", szl: "PL", ta: "IN", taj: "NP", tbw: "PH", tcy: "IN", tdd: "CN", tdg: "NP", tdh: "NP", te: "IN", tem: "SL", teo: "UG", tet: "TL", tg: "TJ", "tg-Arab": "PK", th: "TH", thl: "NP", thq: "NP", thr: "NP", ti: "ET", tig: "ER", tiv: "NG", tk: "TM", tkl: "TK", tkr: "AZ", tkt: "NP", tl: "PH", tly: "AZ", tmh: "NE", tn: "ZA", to: "TO", tog: "MW", tpi: "PG", tr: "TR", tru: "TR", trv: "TW", ts: "ZA", tsd: "GR", tsf: "NP", tsg: "PH", tsj: "BT", tt: "RU", ttj: "UG", tts: "TH", ttt: "AZ", tum: "MW", tvl: "TV", twq: "NE", txg: "CN", ty: "PF", tyv: "RU", tzm: "MA", udm: "RU", ug: "CN", "ug-Cyrl": "KZ", uga: "SY", uk: "UA", uli: "FM", umb: "AO", und: "US", unr: "IN", "unr-Deva": "NP", unx: "IN", ur: "PK", uz: "UZ", "uz-Arab": "AF", vai: "LR", ve: "ZA", vec: "IT", vep: "RU", vi: "VN", vic: "SX", vls: "BE", vmf: "DE", vmw: "MZ", vot: "RU", vro: "EE", vun: "TZ", wa: "BE", wae: "CH", wal: "ET", war: "PH", wbp: "AU", wbq: "IN", wbr: "IN", wls: "WF", wni: "KM", wo: "SN", wtm: "IN", wuu: "CN", xav: "BR", xcr: "TR", xh: "ZA", xlc: "TR", xld: "TR", xmf: "GE", xmn: "CN", xmr: "SD", xna: "SA", xnr: "IN", xog: "UG", xpr: "IR", xsa: "YE", xsr: "NP", yao: "MZ", yap: "FM", yav: "CM", ybb: "CM", yo: "NG", yrl: "BR", yua: "MX", yue: "HK", "yue-Hans": "CN", za: "CN", zag: "SD", zdj: "KM", zea: "NL", zgh: "MA", zh: "CN", "zh-Bopo": "TW", "zh-Hanb": "TW", "zh-Hant": "TW", zlm: "TG", zmi: "MY", zu: "ZA", zza: "TR", }; function getLanguageFlagEmoji(locale) { if (locale === "emoji") { return "🏳️‍🌈"; } else if (locale === "eo") { // return "🏴🟩"; return "🟩"; // return `<svg viewBox="0 0 600 400" height="20"> // <path fill="#FFF" d="m0,0h202v202H0"/> // <path fill="#090" d="m0,200H200V0H600V400H0m58-243 41-126 41,126-107-78h133"/> // </svg>`; } let split = locale.toUpperCase().split(/-|_/); let lang = split.shift(); let code = split.pop(); if (!/^[A-Z]{2}$/.test(code)) { code = languageToDefaultRegion[lang.toLowerCase()]; } if (!code) { return ""; } const a = String.fromCodePoint(code.codePointAt(0) - 0x41 + 0x1F1E6); const b = String.fromCodePoint(code.codePointAt(1) - 0x41 + 0x1F1E6); return a + b; } let uiContainer = div || document.createElement("div"); uiContainer.classList.add("tracky-mouse-ui"); uiContainer.classList.toggle("tracky-mouse-rtl", isRTL); uiContainer.dir = isRTL ? "rtl" : "ltr"; uiContainer.innerHTML = ` <div class="tracky-mouse-controls"> <button class="tracky-mouse-start-stop-button" aria-pressed="false" aria-keyshortcuts="F9">${t("ui.startStopButton.start", { defaultValue: "Start" })}</button> </div> <div class="tracky-mouse-canvas-c