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 72ac024eed..3d8262ae00 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 @@ -12,6 +12,7 @@ [app.common.types.text :as txt] [app.common.types.text.japanese-layout :as jl] [app.main.data.helpers :as dsh] + [app.main.data.workspace.edition :as dwe] [app.main.data.workspace.texts :as dwt] [app.main.refs :as refs] [app.main.store :as st] @@ -195,12 +196,14 @@ ctrl? (or (.-ctrlKey event) (.-metaKey event)) shift? (.-shiftKey event)] (cond - ;; Escape: finalize and stop + ;; Escape: end edition. Clearing the edition mode unmounts this + ;; component, whose unmount effect finalizes and disposes the + ;; WASM editor. We must not rely on blurring the capture node, + ;; because on-blur no longer finalizes or blurs the WASM editor. (= key "Escape") (do (dom/prevent-default event) - (when-let [node (mf/ref-val contenteditable-ref)] - (.blur node))) + (st/emit! :interrupt (dwe/clear-edition-mode))) ;; Ctrl+A: select all (key is "a" or "A" depending on platform) (and ctrl? (= (str/lower key) "a")) @@ -295,40 +298,49 @@ (wasm.api/request-render "text-input")) (reset-input-node (mf/ref-val contenteditable-ref)))))) + ;; Pointer/click handlers must request a render so caret and selection + ;; updates are painted immediately. Without it the overlay only redraws + ;; on the caret-blink tick (every ~250ms), so dragging a selection or + ;; clicking to place the caret lags and appears to flicker. on-pointer-down (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] - (wasm.api/text-editor-pointer-down off-pt)))) + (wasm.api/text-editor-pointer-down off-pt) + (wasm.api/request-render "text-pointer-down")))) on-pointer-move (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] - (wasm.api/text-editor-pointer-move off-pt)))) + (wasm.api/text-editor-pointer-move off-pt) + (wasm.api/request-render "text-pointer-move")))) on-pointer-up (mf/use-fn (fn [^js event] (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/text-editor-pointer-up off-pt) + (wasm.api/request-render "text-pointer-up")))) on-click (mf/use-fn (fn [^js event] (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/text-editor-set-cursor-from-offset off-pt) + (wasm.api/request-render "text-set-cursor")))) on-double-click (mf/use-fn (fn [^js event] (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/text-editor-select-word-boundary off-pt) + (wasm.api/request-render "text-select-word")))) on-focus (mf/use-fn @@ -338,8 +350,15 @@ on-blur (mf/use-fn (fn [^js _event] - (sync-wasm-text-editor-content! {:finalize? true}) - (wasm.api/text-editor-blur))) + ;; A blur is not an exit: focusing a sidebar control (e.g. the + ;; Japanese writing-mode buttons) blurs this capture surface while + ;; the editor stays open and is refocused shortly after. Only sync + ;; the current content here; do not finalize and do not blur the WASM + ;; editor. Finalizing on every blur commits a transient empty text and + ;; deletes the shape (closing the editor). The real finalize runs on + ;; unmount, when edition actually ends, and needs the WASM editor to + ;; still hold focus so it can read the content back. + (sync-wasm-text-editor-content!))) style #js {:pointerEvents "all" "--editor-container-width" (dm/str width "px") @@ -355,11 +374,13 @@ (.focus node) (text-editor/text-editor-select-all) (wasm.api/request-render "text-editor-select-all-on-mount")) - ;; On unmount, finalize the editor content and then dispose the WASM editor. - ;; We finalize on unmount instead of relying on the browser blur event, because - ;; it was not being reliable (timing issues, Firefox issues…) + ;; On unmount (edition actually ended), finalize the editor content and + ;; then blur and dispose the WASM editor. We finalize here instead of on + ;; the browser blur event, which is both unreliable (timing/Firefox + ;; issues) and fires for transient blurs like clicking a sidebar control. (fn [] - (on-blur) + (sync-wasm-text-editor-content! {:finalize? true}) + (wasm.api/text-editor-blur) (text-editor/text-editor-dispose) (wasm.api/request-render "text-editor-dispose")))) diff --git a/frontend/src/app/util/text/ui.cljs b/frontend/src/app/util/text/ui.cljs index 8138126221..658e112d70 100644 --- a/frontend/src/app/util/text/ui.cljs +++ b/frontend/src/app/util/text/ui.cljs @@ -36,8 +36,18 @@ [] (dom/query "[data-itype=\"editor\"]")) +(defn v3-get-text-editor-content + [] + (dom/get-element "text-editor-wasm-input")) + (defn get-text-editor-content [] - (if (features/active-feature? @st/state "text-editor/v2") + (cond + (features/active-feature? @st/state "text-editor-wasm/v1") + (v3-get-text-editor-content) + + (features/active-feature? @st/state "text-editor/v2") (v2-get-text-editor-content) + + :else (v1-get-text-editor-content))) diff --git a/render-wasm/src/shapes/text_vertical.rs b/render-wasm/src/shapes/text_vertical.rs index 96a97988b6..73da4ed006 100644 --- a/render-wasm/src/shapes/text_vertical.rs +++ b/render-wasm/src/shapes/text_vertical.rs @@ -2078,10 +2078,25 @@ pub fn layout_vertical( } else { span.font_size }; - // Flow down the column by the true vertical - // advance when the font has `vmtx`, else the - // horizontal advance (exact for full-width CJK). + // The character in this cluster; used to size the + // cell and, later, to classify it. + let ch = segment.text[(cluster as usize)..].chars().next(); + // Flow down the column by the true vertical advance + // when the font has `vmtx`. Without it, fall back to + // the horizontal advance, which is exact for + // full-width CJK. A character that is upright only + // because `text-orientation: upright` forced it + // (e.g. a Latin letter) is far narrower than an em + // and would pack together and collide with the + // following character, so floor it to a full em, + // matching the browser's upright behavior. // Letter-spacing widens the gap after each cluster. + let horizontal_fallback = if ch.is_some_and(|c| !is_upright_char(c)) + { + h_advance.max(span.font_size) + } else { + h_advance + }; let extent = vmetrics .as_ref() .map(|vm| { @@ -2091,7 +2106,7 @@ pub fn layout_vertical( .sum::() }) .filter(|v| *v > 0.0) - .unwrap_or(h_advance); + .unwrap_or(horizontal_fallback); // vpal: the font's advance delta tightens the // cell and its placement delta lifts the drawn // ink to keep it inside. The tightened extent @@ -2110,7 +2125,6 @@ pub fn layout_vertical( vpal_delta.map(|(_, shift)| shift).unwrap_or(0.0); let start = piece_base + utf8_to_utf16(cluster as usize); let end = piece_base + utf8_to_utf16(next_cluster_utf8); - let ch = segment.text[(cluster as usize)..].chars().next(); let minimum_oikomi_extent = minimum_oikomi_extent( ch, extent, @@ -4383,6 +4397,37 @@ mod tests { )); } + #[test] + fn upright_narrow_latin_reserves_a_full_em_without_vmtx() { + // Under text-orientation: upright, Latin letters are set upright. The + // Latin test face carries no `vmtx`, so the flow advance must fall back + // to a synthesized em instead of the (much narrower) horizontal advance; + // otherwise the letters pack together and collide with the following + // character. + let em = 20.0; + let mut content = make_content(&["ab"], 1000.0); + content.paragraphs_mut()[0].children_mut()[0].text_orientation = TextOrientation::Upright; + let layout = layout_with(&test_provider(), &content); + assert_eq!(layout.cells.len(), 2, "one upright cell per Latin letter"); + for (index, cell) in layout.cells.iter().enumerate() { + assert!( + matches!(cell.kind, CellKind::Upright { .. }), + "cell {index} should be an upright Latin letter" + ); + assert!( + (cell.extent - em).abs() < 0.5, + "upright Latin cell {index} reserves a full em, got {}", + cell.extent + ); + } + // The letters advance a full em down the column, so they never overlap. + assert!( + layout.cells[1].top - layout.cells[0].top >= em - 0.5, + "successive upright letters are one em apart, got {}", + layout.cells[1].top - layout.cells[0].top + ); + } + #[test] fn segments_mixed_text() { let segments = segment_by_orientation("縦書きABC123です", TextOrientation::Mixed); diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 22815a3d9e..0e8352c258 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -438,9 +438,13 @@ impl TextEditorState { let Some(_last_text_span) = last_paragraph.children().last() else { return false; }; + // Selection offsets are counted in characters, not UTF-8 bytes; + // using byte length overshoots for multi-byte (e.g. Japanese) text and + // leaves the trailing characters outside the selection, so a + // select-all followed by typing fails to replace them. let mut offset = 0; for span in last_paragraph.children() { - offset += span.text.len(); + offset += span.text.chars().count(); } self.extend_selection_from_position(&TextPositionWithAffinity::new( PositionWithAffinity { @@ -535,11 +539,16 @@ impl TextEditorState { pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) { self.selection.set_caret(*position); + // Show the caret immediately at the new position instead of waiting for + // the next blink phase (mirrors the keyboard navigation path). + self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); } pub fn extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) { self.selection.extend_to(*position); + // Keep the focus caret solid while the selection is being dragged. + self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); } diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index a21230a5d6..33f15ea954 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -368,11 +368,16 @@ pub extern "C" fn text_editor_composition_end() -> Result<()> { .get_selection(&get_text_editor_state().selection); text_helpers::delete_selection_range(text_content, &selection); - let cursor = get_text_editor_state().selection.focus; + // Insert at the start of the replaced range (the pre-composition + // selection or the previous preview), not at a possibly stale + // selection focus, and advance the caret past the committed text. + let cursor = selection.start(); if let Some(new_cursor) = text_helpers::insert_text_with_newlines(text_content, &cursor, &text) { get_text_editor_state().selection.set_caret(new_cursor); + } else { + get_text_editor_state().selection.set_caret(cursor); } text_content.layout.paragraphs.clear(); @@ -424,7 +429,14 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> { .get_selection(&get_text_editor_state().selection); text_helpers::delete_selection_range(text_content, &selection); - let cursor = get_text_editor_state().selection.focus; + // Collapse to the start of the replaced range before inserting the new + // preview. The next update measures the previous preview as + // [focus, focus + previous_len], so the caret must stay at the START of + // the preview; leaving a stale pre-composition selection focus makes + // each update append instead of replace, doubling the text and blocking + // further edits. + let cursor = selection.start(); + get_text_editor_state().selection.set_caret(cursor); text_helpers::insert_text_with_newlines(text_content, &cursor, &text); text_content.layout.paragraphs.clear();