mirror of
https://github.com/penpot/penpot.git
synced 2026-10-01 08:16:15 +00:00
tmp
This commit is contained in:
parent
21d6050b7f
commit
24466897db
@ -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"}]]]]))
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
256
handoff-ime-bug.md
Normal file
256
handoff-ime-bug.md
Normal file
@ -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 <WIN>` 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.
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user