14 KiB
Handoff — v3 editor IME input (candidate window placement + premature commit)
Context for continuing the IME work on branch
poc-japanese-text. Rewritten 2026-07-19 after the premature-commit fix was confirmed with the real IME. Companion tohandoff-japanese-text.md(the overall feature handoff); this file covers only the in-flight IME editor work.
Problem statement
When typing Japanese with an IME in the v3 (WASM) text editor:
- composition must not be interrupted (the user must be able to compose kana and convert to kanji) — FIXED, user-confirmed with real fcitx5-mozc;
- the IME candidate/suggestion popup must appear next to the current input point, in both horizontal and vertical layouts — partially working; remaining work below.
The user's environment is Linux/X11 + Chromium + fcitx5 with mozc
(fcitx5 -d + /usr/lib/mozc/mozc_server; the previous handoff said
ibus-mozc — that was wrong).
The two root causes found for premature commit
Real IMEs abort the composition and commit the pending kana on ANY DOM disturbance around the composing element. Two distinct triggers were found and removed:
- Store dispatch in
on-composition-update.sync-wasm-text-editor-content!dispatcheddwt/v2-update-text-shape-contentper kana → React re-render of the editor component mid-composition (shape content/name/dimensions, foreignObject attrs, CSS vars). Removed: the WASMtext_editor_composition_updatealready writes the preview into WASM shape state and marks it touched, sorequest-renderalone paints the preview. The store is synced once oncompositionend. - Style writes on the keydown-229 that precedes
compositionstart. The old design repositioned the capture surface on the first keydown of an IME sequence (keyCode 229 && !isComposing), believing that was "before the composition". It is not safe: by the time keydown-229 reaches the DOM, the IME has already established its composition context, and the style write still aborts real mozc (CDP tolerates it — see Verification below). The user confirmed the abort persisted after fix 1 alone; it stopped after the redesign in fix 2.
Current design (working tree)
All in frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +
v3_editor.scss:
- Idle positioning: the capture surface (
#text-editor-wasm-input) sits ON the WASM caret at all times.schedule-ime-caret!runs after every caret-affecting operation (click, pointer-up, double-click, handled keydown, on-input, paste, cut, composition end, focus, mount) — never during a composition (composing-refguard, set inon-composition-start, cleared inon-composition-end). No handler touches the surface or the store betweencompositionstartandcompositionend. schedule-ime-caret!waits tworequestAnimationFrames (so the pending WASM render can rebuild the layout the caret rect reads) and then retries up to 30 frames because the rect can be unavailable right after mount. Gotcha: rAF does not fire in occluded/background windows (see Tooling).update-ime-caret!(unchanged semantics): horizontal →left/topat caret,font-size= caret rect height; vertical →writing-mode: vertical-rl, right-edge anchorleft = (x+width) - origin.x - origin.width,font-size= caret rect width. Returns nil when it could not position (no node/origin/rect) — the scheduler retries on nil.pointer-events: noneon the surface (v3_editor.scss): clicks always land on the outer overlay div, sooffsetX/offsetYremain in the shape's local space regardless of where the surface currently sits. The hover text cursor class moved to the outer div.focus-editor!: programmatic.focus()on the surface does NOT reliably fire focus/focusin in Chromium (contenteditable inside SVG foreignObject): the node becomesdocument.activeElementbut React'son-focusnever dispatches, so the WASM editor stayed unfocused and every caret-rect read returned null (this made the vertical popup anchor to the default full-size surface with no writing-mode — the "jumping" popup).focus-editor!therefore focuses the node AND callswasm.api/text-editor-focus+schedule-ime-caret!explicitly. Used by the mount effect andon-pointer-down(which alsopreventDefaults so mousedown cannot move focus to the body). Notetext-editor/text-editor-focusTHROWS when the shape id is not in WASM state — a possible mount race to keep in mind.on-composition-end: WASM commit + store sync +reset-input-node+schedule-ime-caret!(surface moves to the caret past the committed text). There is no full-size "reset" anymore; the surface stays caret-positioned for the whole editing session.
Supporting changes (unchanged from before, already built):
frontend/src/app/render_wasm/text_editor.cljs:text-editor-get-cursor-rectreads 4 f32 (x/y/w/h) from_text_editor_get_cursor_rect. Coordinates are ABSOLUTE page coords (selrect origin included);update-ime-caret!subtracts the foreignObject origin (origin-ref, kept fresh each render, includes the valign-adjusted y).render-wasm/src/wasm/text_editor.rs: cursor rect independent of the caret blink phase (null only when the editor lacks focus). The WASM artifact with this change is built and loaded in the ws1 devenv.
Verification status
Horizontal — real IME, PASSED (2026-07-19)
Automated real-IME run (see Tooling): typed nihongo, Space-converted,
Return-committed through actual fcitx5-mozc:
- ONE composition
n→に→にほ→にほんご→日本語, no aborts, no fragment commits; committed text landed in the shape; store synced at compositionend; no crash. - Surface style byte-identical through the whole composition; a MutationObserver over the entire foreignObject subtree recorded ZERO attribute/childList mutations between compositionstart and compositionend.
- The fcitx candidate window tracked the preedit (window x advanced 866→967 at constant y, matching the DOM caret anchor at the end of the preedit).
- The user separately confirmed typing works with their own keyboard.
Vertical — NOT yet verified with the real IME
The vertical real-IME run was voided: the user's screen was locked (slock) so
the synthetic keystrokes never reached the browser (all-blue screenshots, no
composition events, text unchanged). Rerun vime-test.sh when the screen is
unlocked. Under CDP the vertical mechanics passed earlier (single
composition, style byte-identical, commit lands, caret advances down the
column), but CDP proves mechanics only.
Remaining known bugs (the actual work left)
- Horizontal caret rect / hit test broken for CJK-heavy content → popup
anchors at the shape origin. With content
日本語ab:text_editor_get_cursor_rectalways returns the LINE-START x (rect x == selrect.x) regardless of the real caret offset. Inrender-wasm/src/wasm/text_editor.rs(get_cursor_rect, ~line 1103):get_rects_for_range(char_pos..char_pos, Tight, Tight)comes back empty and the fallback usesget_glyph_position_at_coordinate((0.0,0.0)).position as f32— a glyph INDEX used as an X COORDINATE. That is a bug regardless of the root cause of the empty rects.text_editor_set_cursor_from_offsetmaps EVERY (x,y) to offset 0 for the same shape (verified with direct WASM calls bypassing all CLJS changes, x swept 1..200, y swept 0..40; valign top). So click-to-place-caret is broken on that content too.- Both symptoms suggest the layout the hit-test paths read
(
text_content.layout.paragraphs) differs from what is painted — possibly laid out without the Japanese fallback fonts (zero-width glyphs collapse every x to 0). The canvas paints correctly, andget_text_dimensions/auto-width grow correctly, so some other layout is fine. Compare howlayout.paragraphsis (re)built after edits vs the render path inrender/text.rs/render/text_editor.rs. - The vertical paths are fine (they recompute from
text_vertical::layout_from_contenton demand).
- Vertical popup verification pending (screen lock, above). After the
focus fix the mount-time WASM focus is established and the caret rect for
vertical returns correct values (measured: end-of-text caret
x=3728.6/y=1278/w=16.8/h=1 on the test shape), so the surface should now
get
writing-mode: vertical-rl+ caret position before the first composition. Needs the on-screen run to confirm popup placement. - Possible mount race:
text-editor-focusthrows if the WASM shape is not yet in state when the mount effect runs. Not reproduced conclusively, but one entry attempt showed WASM focus false with no error until a manual call at +300ms succeeded. If editor entry ever silently loses WASM focus again, look here (makefocus-editor!retry or make the throw a no-op+retry). - Compositions that start WITHOUT keydown (voice input, on-screen keyboards) rely on the last idle position — now always correct by design (the surface is always on the caret), which retires the old concern.
- v2 editor untouched; macOS/Safari behavior unknown.
Files touched (uncommitted, on poc-japanese-text)
frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs— the design above (update-ime-caret!idle model,schedule-ime-caret!retries,composing-ref,focus-editor!,pointer-events-aware pointer handlers, no store sync inon-composition-update).frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss—pointer-events: noneon.text-editor-container.frontend/src/app/render_wasm/text_editor.cljs—text-editor-get-cursor-rect.render-wasm/src/wasm/text_editor.rs— cursor rect independent of blink phase.
Lint/format for the CLJS file not yet run this round (clj-kondo/cljfmt
live in the devenv container, not on the host PATH).
Tooling — REAL-IME automated testing (new, important)
CDP (Input.imeSetComposition) bypasses the OS input method entirely: it
cannot reproduce IME aborts and cannot show the candidate popup (an OS window
fcitx draws outside the browser — Playwright screenshots never contain it).
The real pipeline is scriptable because the Playwright Chrome runs headed on
the user's X11 display (DISPLAY=:0):
- Switch engine:
fcitx5-remote -s mozc… restore withfcitx5-remote -s keyboard-us-altgr-intl(query with-n). - Real keystrokes:
xdotool windowactivate --sync <WIN>thenxdotool type --delay 250 "nihongo",xdotool key space,key Return. XTEST events go through fcitx/mozc exactly like hardware input. - The Chrome window: find by exact title, e.g.
xdotool search --name "Japanese text - Penpot"(was id 60817432; do NOT head -1 a loose pattern). - Popup observation: full-screen
scrot -o file.png+ geometry viafor w in $(xdotool search --class fcitx); do xdotool getwindowgeometry --shell $w; done. Geometry persists for unmapped windows — corroborate with the screenshot. - Page-side instrumentation (before typing): composition-event log capturing
node.getAttribute('style')and the DOM caret anchor (getSelection().getRangeAt(0).getBoundingClientRect()) per event, plus a MutationObserver over the foreignObject subtree asserting zero attribute/childList mutations mid-composition. - Scripts saved in the session scratchpad as
ime-test.sh(horizontal) andvime-test.sh(vertical) — recreate from this description if gone.
Safety checks before sending synthetic input:
- Screen lock:
pgrep slock(user uses slock) andDISPLAY=:0 xset q | grep Monitor(Monitor off = user away). If locked, DO NOT type — keystrokes land in the password prompt. - The user's fcitx layout must be restored afterwards
(
keyboard-us-altgr-intl).
Other environment gotchas (all hit this session):
requestAnimationFramedoes not fire while the Chrome window is occluded —schedule-ime-caret!verification MUST be done with the window visible (activate it first). Store-side checks are unaffected.- After
location.reload()the editor may not mount:(reset! app.render-wasm.api/page-transition? false)then re-enter edition. - Programmatic edition entry:
select_shape(uuid)→zoom.fit_to_shapes([uuid])(NOT the arity-0fit-to-shapes; Shift+2 via CDP keyboard is unreliable) →start_editing_selected(). - Munged names used a lot:
app.render_wasm.text_editor.text_editor_get_cursor_rect,..._has_focus_QMARK_,app.main.data.workspace.zoom.fit_to_shapes,cljs.core.clj__GT_js. Patchingapp.render_wasm.text_editor.foodoes NOT intercept calls made throughapp.render_wasm.api.foo(the api def captured the original fn object) — patch the api var too. - The test shapes contain residue text from aborted/committed test runs:
horizontal
0121fd5b-f9b4-8060-8008-586a95941bbenow日本語ab日本語を知覚異常日本語…, verticalf179b962-b099-8026-8008-5641a910fe2e日本語をしますかアリアがとうごいます…. Content is junk by design; keep using them. - File "Japanese text" (Drafts), page
showcase,https://localhost:13449/#/workspace?team-id=9aa629b4-0900-8061-8008-3ac96936247d&file-id=f8554703-a493-807a-8008-454b071d651d&page-id=f8554703-a493-807a-8008-454b071d8665.
Suggested next steps (in order)
- Rerun the vertical real-IME test with the screen unlocked
(
vime-test.sh); verify the popup follows the caret down the column and commits cleanly. Also re-verify horizontal once more after any changes. - Fix the Rust horizontal caret-rect/hit-test for CJK content (bug 1): find
why
layout.paragraphsyields empty caret-range rects / zero-width positions for日本語abwhile painting is correct; fix the index-as-coordinate fallback while there. Add a Rust regression test with CJK content assertingget_cursor_rectx monotonically increases with the caret offset andset_cursor_from_offsetround-trips. - Re-verify click-to-place-caret + surface positioning end-to-end in both
flows, then run
clj-kondo/cljfmt(devenv) on the edited CLJS. - Human confirmation from the user in both flows (type, convert, Esc-cancel, two consecutive compositions, click into text mid-composition-free).
See auto-memory v3-editor-repl-verification for the distilled recipes and
handoff-japanese-text.md for build/test commands and branch state.