diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index 3d8262ae00..cbb693837d 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -87,6 +87,41 @@ (or (.-isComposing native) (= 229 (.-keyCode event))))) +(defn- update-ime-caret! + "Move the hidden contenteditable capture surface onto the WASM caret so the + browser's IME candidate window opens next to the edited text. + + Must only be called while the editor is idle, never during or immediately + before an IME composition: real IMEs (ibus-mozc) abort and commit the + pending text on any mutation of the composing element, including style + writes on the keydown-229 that precedes compositionstart. The surface is + therefore kept on the caret at all times, updated after every + caret-affecting operation (click, arrows, typing, commit). + + For vertical text the surface gets `writing-mode: vertical-rl` and is + anchored by its right edge on the caret column, so the in-flight composition + — and the candidate window that tracks it — flows down the column. The + surface font-size is taken from the caret cell (line height horizontally, + column width vertically) so the browser caret advances at roughly the same + rate as the rendered preview text. + + The caret rectangle is in the shape's local selrect space; `origin` is the + overlay foreignObject's top-left in that same space." + [^js node origin] + (when (and (some? node) (some? origin)) + (when-let [{:keys [x y width height]} (text-editor/text-editor-get-cursor-rect)] + (let [style (.-style node)] + (if (:vertical? origin) + (do + (set! (.-writingMode style) "vertical-rl") + (set! (.-fontSize style) (dm/str width "px")) + (set! (.-left style) (dm/str (- (+ x width) (:x origin) (:width origin)) "px"))) + (do + (set! (.-writingMode style) "") + (set! (.-fontSize style) (dm/str height "px")) + (set! (.-left style) (dm/str (- x (:x origin)) "px")))) + (set! (.-top style) (dm/str (- y (:y origin)) "px")))))) + (mf/defc text-editor* "Contenteditable element positioned over the text shape to capture input events." [{:keys [shape]}] @@ -94,8 +129,18 @@ clip-id (dm/str "text-edition-clip" shape-id) + vertical? (jl/vertical-text-content? (:content shape)) + contenteditable-ref (mf/use-ref nil) + ;; Foreign object top-left in shape-local space, used to translate the + ;; WASM caret rectangle into an offset inside the capture surface. + origin-ref (mf/use-ref nil) + + ;; True between compositionstart and compositionend; guards the surface + ;; against any repositioning while the IME owns it. + composing-ref (mf/use-ref false) + fallback-fonts (wasm.api/fonts-from-text-content (:content shape) false) fallback-families (map (fn [font] (font-family-from-font-id (:font-id font))) fallback-fonts) @@ -126,9 +171,46 @@ y)] [(assoc selrect :y y :width overlay-width :height max-height) transform]) + schedule-ime-caret! + (mf/use-fn + (fn [] + ;; Wait two frames so the pending WASM render rebuilds the text + ;; layout the caret rect reads, then retry for a while: right after + ;; mount the rect can stay unavailable until the first full layout. + ;; Skipped when a composition began in the meantime — the surface + ;; must not move while the IME owns it. + (letfn [(attempt [tries] + (when-not (mf/ref-val composing-ref) + (when (and (nil? (update-ime-caret! + (mf/ref-val contenteditable-ref) + (mf/ref-val origin-ref))) + (pos? tries)) + (js/requestAnimationFrame #(attempt (dec tries))))))] + (js/requestAnimationFrame + (fn [] + (js/requestAnimationFrame #(attempt 30))))))) + + ;; Programmatic .focus() on the surface does not reliably fire + ;; focus/focusin in Chromium (contenteditable inside an SVG + ;; foreignObject): the node becomes document.activeElement but React's + ;; on-focus never dispatches, leaving the WASM editor unfocused and + ;; every caret-rect read null. So wherever we focus programmatically, + ;; the WASM focus is established explicitly instead of relying on the + ;; focus event. + focus-editor! + (mf/use-fn + (mf/deps shape-id) + (fn [] + (when-let [node (mf/ref-val contenteditable-ref)] + (when (not= (.-activeElement js/document) node) + (.focus node))) + (wasm.api/text-editor-focus shape-id) + (schedule-ime-caret!))) + on-composition-start (mf/use-fn (fn [_event] + (mf/set-ref-val! composing-ref true) (text-editor/text-editor-composition-start))) on-composition-update @@ -136,21 +218,32 @@ (fn [event] ;; IME cancel (e.g. Escape on Linux ibus-mozc) fires compositionupdate ;; with an empty string; that must reach WASM to clear the preview text. + ;; + ;; While the composition is in flight the browser owns the capture + ;; surface, so this handler must cause zero DOM mutation anywhere near + ;; it: no clearing, no style writes, and no store dispatch. A store + ;; sync re-renders this component (shape content/name/dimensions, + ;; foreignObject attributes, CSS vars), and any such churn makes a + ;; real IME commit the pending kana and restart (typing "ni" commits + ;; ん then い instead of composing に). The WASM editor keeps the + ;; preview in its own state and paints it via request-render; the + ;; store is synced once on compositionend. (let [data (.-data event)] (when (some? data) (text-editor/text-editor-composition-update data) - (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-composition")) - (reset-input-node (mf/ref-val contenteditable-ref))))) + (wasm.api/request-render "text-composition"))))) on-composition-end (mf/use-fn (fn [^js event] + (mf/set-ref-val! composing-ref false) (let [data (or (.-data event) "")] (text-editor/text-editor-composition-end data) (sync-wasm-text-editor-content!) (wasm.api/request-render "text-composition")) - (reset-input-node (mf/ref-val contenteditable-ref)))) + (reset-input-node (mf/ref-val contenteditable-ref)) + ;; Move the surface onto the caret, now past the committed text. + (schedule-ime-caret!))) on-paste (mf/use-fn @@ -162,7 +255,8 @@ (text-editor/text-editor-insert-text text) (sync-wasm-text-editor-content!) (wasm.api/request-render "text-paste")) - (reset-input-node (mf/ref-val contenteditable-ref))))) + (reset-input-node (mf/ref-val contenteditable-ref)) + (schedule-ime-caret!)))) on-copy (mf/use-fn @@ -185,11 +279,17 @@ (text-editor/text-editor-delete-backward) (sync-wasm-text-editor-content!) (wasm.api/request-render "text-cut")))) - (reset-input-node (mf/ref-val contenteditable-ref))))) + (reset-input-node (mf/ref-val contenteditable-ref)) + (schedule-ime-caret!)))) on-key-down (mf/use-fn (fn [^js event] + ;; IME keydowns (keyCode 229) must not touch the surface at all: by + ;; the time the 229 reaches the DOM the IME already owns the surface, + ;; and even a pre-compositionstart style write makes ibus abort and + ;; commit. The surface is already sitting on the caret from the last + ;; idle repositioning (see `update-ime-caret!`). (when (and (text-editor/text-editor-has-focus?) (not (composing-event? event))) (let [key (.-key event) @@ -281,7 +381,11 @@ (wasm.api/request-render "text-cursor-move")) ;; Let contenteditable handle text input via on-input - :else nil))))) + :else nil) + ;; Any handled key may have moved the caret; keep the surface on + ;; it so the next IME sequence anchors correctly. Plain character + ;; keys reschedule again from on-input after the insert. + (schedule-ime-caret!))))) on-input (mf/use-fn @@ -296,7 +400,8 @@ (text-editor/text-editor-insert-text data) (sync-wasm-text-editor-content!) (wasm.api/request-render "text-input")) - (reset-input-node (mf/ref-val contenteditable-ref)))))) + (reset-input-node (mf/ref-val contenteditable-ref)) + (schedule-ime-caret!))))) ;; Pointer/click handlers must request a render so caret and selection ;; updates are painted immediately. Without it the overlay only redraws @@ -305,6 +410,12 @@ on-pointer-down (mf/use-fn (fn [^js event] + ;; The capture surface is pointer-events:none (it sits on the caret, + ;; not under the pointer), so it must be focused programmatically. + ;; preventDefault stops the browser from moving focus to the body on + ;; mousedown, which would blur the surface right back. + (dom/prevent-default event) + (focus-editor!) (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] (wasm.api/text-editor-pointer-down off-pt) @@ -324,7 +435,8 @@ (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] (wasm.api/text-editor-pointer-up off-pt) - (wasm.api/request-render "text-pointer-up")))) + (wasm.api/request-render "text-pointer-up") + (schedule-ime-caret!)))) on-click (mf/use-fn @@ -332,7 +444,8 @@ (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] (wasm.api/text-editor-set-cursor-from-offset off-pt) - (wasm.api/request-render "text-set-cursor")))) + (wasm.api/request-render "text-set-cursor") + (schedule-ime-caret!)))) on-double-click (mf/use-fn @@ -340,12 +453,14 @@ (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] (wasm.api/text-editor-select-word-boundary off-pt) - (wasm.api/request-render "text-select-word")))) + (wasm.api/request-render "text-select-word") + (schedule-ime-caret!)))) on-focus (mf/use-fn (fn [^js _event] - (wasm.api/text-editor-focus shape-id))) + (wasm.api/text-editor-focus shape-id) + (schedule-ime-caret!))) on-blur (mf/use-fn @@ -365,13 +480,17 @@ "--editor-container-height" (dm/str height "px") "--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")}] + ;; Keep the caret-translation origin in sync with the current render. + (mf/set-ref-val! origin-ref {:x x :y y :width width :vertical? vertical?}) + ;; Focus contenteditable on mount (mf/use-effect (mf/deps contenteditable-ref) (fn [] - (when-let [node (mf/ref-val contenteditable-ref)] - ;; Focus and select all text on mount (this will trigger on-focus) - (.focus node) + (when (some? (mf/ref-val contenteditable-ref)) + ;; Focus and select all text on mount. `focus-editor!` establishes the + ;; WASM focus explicitly; select-all is a no-op without it. + (focus-editor!) (text-editor/text-editor-select-all) (wasm.api/request-render "text-editor-select-all-on-mount")) ;; On unmount (edition actually ended), finalize the editor content and @@ -410,7 +529,11 @@ :on-pointer-down on-pointer-down :on-pointer-move on-pointer-move :on-pointer-up on-pointer-up - :class (stl/css :text-editor) + ;; The hover cursor lives here: the capture surface below is + ;; pointer-events:none so it never receives hover itself. + :class (dm/str (cur/get-text (:rotation shape) vertical?) + " " + (stl/css :text-editor)) :style style} [:div {:ref contenteditable-ref @@ -427,7 +550,5 @@ :on-focus on-focus :on-blur on-blur :id "text-editor-wasm-input" - :class (dm/str (cur/get-text (:rotation shape) (jl/vertical-text-content? (:content shape))) - " " - (stl/css :text-editor-container)) + :class (stl/css :text-editor-container) :data-testid "text-editor-container"}]]]])) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss index 5659b0646e..394c906f7b 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss @@ -9,4 +9,8 @@ opacity: 0; overflow: hidden; white-space: pre; + // The surface is positioned on the caret while idle, so clicks must always + // hit the outer overlay (keeping offset coordinates stable); focus is given + // to the surface programmatically from the overlay's pointer-down. + pointer-events: none; } diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 3f99769390..b5bb94ae02 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -364,6 +364,25 @@ (mem/free) result))))) +(defn text-editor-get-cursor-rect + "Read the caret rectangle from the WASM editor. + + Coordinates are in the shape's local selrect space (the same space as the + text overlay's foreignObject). Returns a map with :x :y :width :height, or + nil when there is no active caret." + [] + (when wasm/context-initialized? + (let [ptr (h/call wasm/internal-module "_text_editor_get_cursor_rect")] + (when (and ptr (not (zero? ptr))) + (let [heap-f32 (mem/get-heap-f32) + offset (mem/->offset-32 ptr) + x (aget heap-f32 offset) + y (aget heap-f32 (+ offset 1)) + width (aget heap-f32 (+ offset 2)) + height (aget heap-f32 (+ offset 3))] + (mem/free) + {:x x :y y :width width :height height}))))) + (defn text-editor-encode-text-pre [text] (when (and (not (empty? text)) diff --git a/handoff-ime-bug.md b/handoff-ime-bug.md new file mode 100644 index 0000000000..f1a8f1d3ee --- /dev/null +++ b/handoff-ime-bug.md @@ -0,0 +1,256 @@ +# 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 to `handoff-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: + +1. composition must not be interrupted (the user must be able to compose kana + and convert to kanji) — **FIXED, user-confirmed with real fcitx5-mozc**; +2. 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: + +1. **Store dispatch in `on-composition-update`.** + `sync-wasm-text-editor-content!` dispatched + `dwt/v2-update-text-shape-content` per kana → React re-render of the editor + component mid-composition (shape content/name/dimensions, foreignObject + attrs, CSS vars). Removed: the WASM `text_editor_composition_update` + already writes the preview into WASM shape state and marks it touched, so + `request-render` alone paints the preview. The store is synced once on + `compositionend`. +2. **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-ref` guard, set in `on-composition-start`, cleared + in `on-composition-end`). No handler touches the surface or the store + between `compositionstart` and `compositionend`. +- `schedule-ime-caret!` waits two `requestAnimationFrame`s (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/top` at caret, + `font-size` = caret rect height; vertical → `writing-mode: vertical-rl`, + right-edge anchor `left = (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: none` on the surface** (`v3_editor.scss`): clicks always + land on the outer overlay div, so `offsetX/offsetY` remain 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 becomes `document.activeElement` but React's + `on-focus` never 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 calls + `wasm.api/text-editor-focus` + `schedule-ime-caret!` explicitly. Used by the + mount effect and `on-pointer-down` (which also `preventDefault`s so + mousedown cannot move focus to the body). + Note `text-editor/text-editor-focus` THROWS 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-rect` reads 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) + +1. **Horizontal caret rect / hit test broken for CJK-heavy content → popup + anchors at the shape origin.** With content `日本語ab`: + - `text_editor_get_cursor_rect` always returns the LINE-START x (rect x == + selrect.x) regardless of the real caret offset. In + `render-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 uses `get_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_offset` maps 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, and + `get_text_dimensions`/auto-width grow correctly, so some other layout is + fine. Compare how `layout.paragraphs` is (re)built after edits vs the + render path in `render/text.rs` / `render/text_editor.rs`. + - The vertical paths are fine (they recompute from + `text_vertical::layout_from_content` on demand). +2. **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. +3. Possible mount race: `text-editor-focus` throws 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 (make `focus-editor!` retry or make the throw a no-op+retry). +4. 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. +5. 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 in `on-composition-update`). +- `frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss` — + `pointer-events: none` on `.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 with + `fcitx5-remote -s keyboard-us-altgr-intl` (query with `-n`). +- Real keystrokes: `xdotool windowactivate --sync ` then + `xdotool 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 via + `for 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) and + `vime-test.sh` (vertical) — recreate from this description if gone. + +**Safety checks before sending synthetic input:** + +- Screen lock: `pgrep slock` (user uses slock) and + `DISPLAY=: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):** + +- `requestAnimationFrame` does 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-0 + `fit-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`. Patching `app.render_wasm.text_editor.foo` does NOT + intercept calls made through `app.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-586a95941bbe` now + `日本語ab日本語を知覚異常日本語…`, vertical + `f179b962-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) + +1. 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. +2. Fix the Rust horizontal caret-rect/hit-test for CJK content (bug 1): find + why `layout.paragraphs` yields empty caret-range rects / zero-width + positions for `日本語ab` while painting is correct; fix the + index-as-coordinate fallback while there. Add a Rust regression test with + CJK content asserting `get_cursor_rect` x monotonically increases with the + caret offset and `set_cursor_from_offset` round-trips. +3. Re-verify click-to-place-caret + surface positioning end-to-end in both + flows, then run `clj-kondo`/`cljfmt` (devenv) on the edited CLJS. +4. 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. diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 33f15ea954..d19e7c0b35 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -637,7 +637,10 @@ pub extern "C" fn text_editor_move_cursor( #[no_mangle] pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 { with_state!(state, { - if !get_text_editor_state().has_focus || !get_text_editor_state().cursor_visible { + // Report the caret geometry independent of the blink phase; whether the + // caret is currently painted is a separate `cursor_visible` concern + // handled by the overlay renderer. + if !get_text_editor_state().has_focus { return std::ptr::null_mut(); }