From af6f479a2bcf0945421bf614afe1f05552e5a270 Mon Sep 17 00:00:00 2001 From: Hatton Date: Wed, 8 Jul 2026 15:32:01 -0600 Subject: [PATCH 01/19] KeymanWeb POC: hard-coded Thai keyboard in the edit view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proof of concept for embedding KeymanWeb in Bloom's page-editing view. How far this got (hard-coded to Thai, as far as I'm aware): - Focusing a .bloom-editable with lang="th" (or th-*) lazy-loads the KeymanWeb engine from the Keyman CDN on first use, activates the Thai Kedmanee keyboard, and shows the floating on-screen keyboard. - Physical keys are remapped to Kedmanee (d->ก, k->า, l->ส, etc.). - Non-Thai fields are left completely alone; the OSK is hidden again when focus leaves a Thai field, so the keyboard is active ONLY in Thai fields. - Verified live in an all-xmatter Thai/English test book: Thai remaps, English types literally, OSK shows/hides as expected, no console errors. Implementation: - New keymanWebIntegration.ts, called from the delegated focusin handler in bloomEditing.ts. attachType "manual"; the exact "thai_kedmanee" stub is registered and we wait for its code to load (HasLoaded) before binding, to avoid a first-focus race. root/resources/fonts are pointed at the CDN so the OSK font loads from there instead of 404ing against Bloom's server. - A save-time scrub in Cleanup() removes KeymanWeb's attach artifacts (keymanweb-font class, inputmode, dir="ltr", empty style) from editable divs. Known limitations / not done: - Hard-coded to Thai and to the Kedmanee keyboard id; no per-language configuration yet. - Loads from the Keyman CDN at runtime, so it needs internet in the edit view; a shipping version should vendor the engine + keyboard for offline. - The Cleanup() scrub only covers page-body divs. Xmatter content round- trips through bloomDataDiv, so KeymanWeb residue still persists in saved xmatter fields; that path needs its own scrub (likely C#-side). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bookEdit/js/bloomEditing.ts | 25 +++ .../bookEdit/js/keymanWebIntegration.ts | 151 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/BloomBrowserUI/bookEdit/js/keymanWebIntegration.ts diff --git a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts index 7e9f489870d1..d55e78eed6e1 100644 --- a/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts +++ b/src/BloomBrowserUI/bookEdit/js/bloomEditing.ts @@ -54,6 +54,7 @@ import { getToolboxBundleExports, } from "./workspaceFrames"; import { showInvisibles, hideInvisibles } from "./showInvisibles"; +import { attachKeymanWebIfNeeded } from "./keymanWebIntegration"; //promise may be needed to run tests with phantomjs //import promise = require('es6-promise'); @@ -168,6 +169,27 @@ function Cleanup() { TrimTrailingLineBreaksInDivs(this); }); + // Scrub KeymanWeb attach artifacts (see keymanWebIntegration.ts). The engine + // decorates every control it attaches to: it adds the "keymanweb-font" class, + // sets inputmode="none", forces dir="ltr", and can leave an empty style="". + // None of these belong in the saved book, so remove them here. This runs + // unconditionally (not only when Keyman loaded this session) so books already + // polluted by an earlier session get cleaned on their next save. + $("div.bloom-editable").each(function () { + $(this).removeClass("keymanweb-font"); + $(this).removeAttr("inputmode"); + // Bloom itself only ever sets dir="rtl" (via C# TranslationGroupManager), + // never "ltr", so a "ltr" value can only be KeymanWeb's; leave "rtl" alone. + if ($(this).attr("dir") === "ltr") { + $(this).removeAttr("dir"); + } + // Remove the empty style attribute Keyman can leave behind, but keep any + // real inline styles. + if ($(this).attr("style") === "") { + $(this).removeAttr("style"); + } + }); + cleanupImages(); cleanupOrigami(); cleanupNiceScroll(); @@ -957,6 +979,9 @@ export function SetupElements( editBox.closest(".bloom-userCannotModifyStyles").length === 0 ) { editor.AttachToBox(editBox.get(0)); + attachKeymanWebIfNeeded(editBox.get(0)!).catch((err) => + console.error("attachKeymanWebIfNeeded failed", err), + ); } }); diff --git a/src/BloomBrowserUI/bookEdit/js/keymanWebIntegration.ts b/src/BloomBrowserUI/bookEdit/js/keymanWebIntegration.ts new file mode 100644 index 000000000000..5d6ffca325bb --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/js/keymanWebIntegration.ts @@ -0,0 +1,151 @@ +// Loads the KeymanWeb engine from the Keyman CDN (once) and attaches the +// hard-coded Thai Kedmanee keyboard to Thai bloom-editable fields. +// Proof of concept. + +const kKeymanEngineVersion = "18.0.249"; // current stable, verified on CDN 2026-07-08 +// Base URL of the engine on the Keyman CDN. Used both to load keymanweb.js and +// as the engine "root": when we inject keymanweb.js dynamically, KeymanWeb cannot +// reliably infer its own base folder, so it resolves OSK resources (e.g. the +// on-screen-keyboard font osk/keymanweb-osk.ttf) relative to the page instead, +// which 404s against Bloom's server and raises a "Cannot Find File" dialog. +// Setting root explicitly points those resources back at the CDN. +const kKeymanEngineBaseUrl = `https://s.keyman.com/kmw/engine/${kKeymanEngineVersion}/`; + +// The specific keyboard this POC turns on for Thai fields. +const kKeyboardId = "thai_kedmanee"; +const kLanguageCode = "th"; +// KeymanWeb prefixes the id with "Keyboard_" for the entry it exposes in +// getKeyboards(); that is the name we match on when checking load state. +const kKeyboardInternalName = `Keyboard_${kKeyboardId}`; + +// Track attached elements in JS, NOT via a DOM attribute/class — the page DOM +// gets saved into the book, so we must not pollute it. +const attachedEditables = new WeakSet(); + +let keymanSetupPromise: Promise | undefined; + +// addKeyboards() only registers a keyboard STUB; the keyboard's actual code +// (its .js from the CDN) downloads lazily and is NOT present immediately after. +// Binding or activating the keyboard before that code has loaded makes +// KeymanWeb throw — verified empirically against the live engine: on a fresh +// load setKeyboardForControl throws "Cannot read properties of null (reading +// 'metadata')" and setActiveKeyboard can reject with "...keyboard script...may +// contain an error", because the loaded Keyboard object is still null. So we +// trigger the download and wait for the engine to mark the keyboard loaded +// before letting callers bind/activate it. (This is the real cause of the +// reported first-focus failure; the keyboard genuinely was not ready yet.) +const waitForThaiKeyboardLoaded = async (keyman: any): Promise => { + const isLoaded = () => + (keyman.getKeyboards() || []).some( + (k: any) => k.InternalName === kKeyboardInternalName && k.HasLoaded, + ); + // Activating the keyboard is what triggers its lazy code download. This call + // can itself reject while the code is not present yet (exactly the race we + // are guarding against), so ignore that rejection here; the poll below on the + // engine's own HasLoaded flag is our real completion signal. + keyman.setActiveKeyboard(kKeyboardId, kLanguageCode).catch(() => {}); + // Bounded poll (100ms x 50 = 5s). Empirically the keyboard loads in well + // under a second; we fail fast rather than silently continuing with an + // unusable keyboard if the CDN download never completes. + for (let attempt = 0; attempt < 50; attempt++) { + if (isLoaded()) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error( + `KeymanWeb keyboard ${kKeyboardId} did not finish loading within 5s`, + ); +}; + +// Injects the KeymanWeb engine