From f66d588943931729db3159a25536054dd2c9530f Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Tue, 4 Aug 2026 12:06:05 +0200 Subject: [PATCH] :wrench: Improve text editor selection and tab conversion --- .../src/app/main/data/workspace/texts.cljs | 38 ++++++++++++++---- .../ui/workspace/shapes/text/v3_editor.cljs | 13 +++++- frontend/src/app/render_wasm/api.cljs | 8 ++++ frontend/src/app/render_wasm/text_editor.cljs | 26 ++++++++++++ render-wasm/src/render/text.rs | 6 +-- render-wasm/src/shapes/text.rs | 40 ++++++++++++++++--- render-wasm/src/wasm/text_editor.rs | 2 +- 7 files changed, 114 insertions(+), 19 deletions(-) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 3fe9161d97..113561a506 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -975,7 +975,14 @@ (watch [_ state stream] (let [text-editor-instance (:workspace-editor state) objects (dsh/lookup-page-objects state) - text-ids (resolve-text-ids objects id)] + text-ids (resolve-text-ids objects id) + + wasm-editing? + (and (features/active-feature? state "text-editor-wasm/v1") + (= id (wasm.api/text-editor-get-active-shape-id))) + + wasm-editing-selection? + (and wasm-editing? (wasm.api/text-editor-has-selection?))] (if (and (features/active-feature? state "text-editor/v2") (some? text-editor-instance)) (rx/empty) @@ -985,15 +992,30 @@ (rx/of (update-root-attrs {:id id :attrs attrs})) (rx/empty))) - (let [attrs (select-keys attrs txt/paragraph-attrs)] - (if-not (empty? attrs) - (rx/of (update-paragraph-attrs {:id id :attrs attrs})) - (rx/empty))) + ;; `:line-height` is stored on both the paragraph and its spans, and + ;; the renderer takes the larger of the two. + (let [pattrs (if wasm-editing-selection? + (conj txt/paragraph-attrs :line-height) + txt/paragraph-attrs) + attrs (select-keys attrs pattrs) + result (when (and (seq attrs) wasm-editing?) + (wasm.api/apply-paragraph-attrs-to-selection attrs))] + (cond + (empty? attrs) + (rx/empty) + + (some? result) + (rx/of (v2-update-text-shape-content + (:shape-id result) (:content result) + :update-name? true)) + + :else + (rx/of (update-paragraph-attrs {:id id :attrs attrs})))) (let [attrs (select-keys attrs txt/text-node-attrs)] - (if-not (empty? attrs) - (rx/of (update-text-attrs {:id id :attrs attrs})) - (rx/empty))) + (if (or (empty? attrs) wasm-editing-selection?) + (rx/empty) + (rx/of (update-text-attrs {:id id :attrs attrs})))) (when (and (features/active-feature? state "text-editor/v2") (not (features/active-feature? state "text-editor-wasm/v1"))) 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 fb65be820e..c2b456c83c 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 @@ -191,7 +191,7 @@ (fn [^js event] (when (text-editor/text-editor-has-focus?) (dom/prevent-default event) - (when (text-editor/text-editor-get-selection) + (when (text-editor/text-editor-has-selection?) (let [text (text-editor/text-editor-export-selection)] (.setData (.-clipboardData event) "text/plain" text)))))) @@ -200,7 +200,7 @@ (fn [^js event] (when (text-editor/text-editor-has-focus?) (dom/prevent-default event) - (when (text-editor/text-editor-get-selection) + (when (text-editor/text-editor-has-selection?) (let [text (text-editor/text-editor-export-selection)] (.setData (.-clipboardData event) "text/plain" (or text "")) (when (and text (seq text)) @@ -256,6 +256,15 @@ (sync-wasm-text-editor-content!) (wasm.api/request-render-preserving-target "text-delete-forward")) + ;; Shift+Tab falls through to the browser, so the keyboard can + ;; still leave the editor. + (and (= key "Tab") (not shift?)) + (do + (dom/prevent-default event) + (text-editor/text-editor-insert-text "\t") + (sync-wasm-text-editor-content!) + (wasm.api/request-render-preserving-target "text-tab")) + ;; Insert (= key "Insert") (do diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index aa86d52701..585c406bcc 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -710,6 +710,14 @@ (request-render "apply-styles-to-selection") result)) +(defn apply-paragraph-attrs-to-selection + "Apply paragraph attrs to the paragraphs the editor selection touches. + Returns {:shape-id :content} for saving." + [attrs] + (let [result (text-editor/apply-paragraph-attrs-to-selection attrs use-shape set-shape-text-content)] + (request-render "apply-paragraph-attrs-to-selection") + result)) + (defn set-parent-id [id] (let [buffer (uuid/get-u32 id)] diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 1794c1ae22..57151606cd 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -756,3 +756,29 @@ :content new-content} with-fills? (assoc :fills (selection-fills new-content normalized-selection))))))))))) + +(defn apply-paragraph-attrs-to-selection + "Apply paragraph level attrs (text-align, text-direction) to the whole + paragraphs the editor selection touches; a collapsed caret means just the one + it sits in." + [attrs use-shape-fn set-shape-text-content-fn] + (when (wasm/ready?) + (let [shape-id (text-editor-get-active-shape-id) + selection (text-editor-get-selection)] + (when (and shape-id selection) + (when-let [content (get-cached-content shape-id)] + (let [{:keys [start-para end-para]} (normalize-selection selection) + paragraph-set (first (:children content)) + new-paragraphs (into [] + (map-indexed (fn [idx para] + (if (<= start-para idx end-para) + (merge para attrs) + para))) + (:children paragraph-set)) + new-content (assoc content :children + [(assoc paragraph-set :children new-paragraphs)])] + (update-cached-content! shape-id new-content) + (use-shape-fn shape-id) + (set-shape-text-content-fn shape-id new-content) + {:shape-id shape-id + :content new-content})))))) diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index c176084688..bf069d2b1c 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -3,8 +3,8 @@ use crate::{ error::Result, math::Rect, shapes::{ - calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, ParagraphLayout, Stroke, - StrokeKind, TextContent, + add_text_with_tabs, calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, + ParagraphLayout, Stroke, StrokeKind, TextContent, }, utils::{get_fallback_fonts, get_font_collection}, }; @@ -55,7 +55,7 @@ pub fn stroke_paragraph_builder_group_from_text( paragraph.line_height(), ); builder.push_style(&stroke_style); - builder.add_text(&text); + add_text_with_tabs(builder, &text, span.font_size); } } diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index d5506850c0..47befc08f3 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -14,7 +14,10 @@ use skia_safe::{ textlayout::Affinity, textlayout::ParagraphBuilder, textlayout::ParagraphStyle, + textlayout::PlaceholderAlignment, + textlayout::PlaceholderStyle, textlayout::PositionWithAffinity, + textlayout::TextBaseline, Contains, }; @@ -725,7 +728,7 @@ impl TextContent { has_text = true; } builder.push_style(&text_style); - builder.add_text(&text); + add_text_with_tabs(&mut builder, &text, span.font_size); } if !has_text { builder.add_text(" "); @@ -759,7 +762,7 @@ impl TextContent { has_text = true; } builder.push_style(&text_style); - builder.add_text(&text); + add_text_with_tabs(&mut builder, &text, span.font_size); } if !has_text { builder.add_text(" "); @@ -1213,7 +1216,7 @@ impl Paragraph { style.set_height(self.line_height); style.set_text_align(self.text_align); style.set_text_direction(self.text_direction); - style.set_replace_tab_characters(true); + style.set_replace_tab_characters(false); style.set_apply_rounding_hack(true); style.set_text_height_behavior(skia::textlayout::TextHeightBehavior::All); style @@ -1249,12 +1252,30 @@ fn capitalize_words(text: &str) -> String { result } -/// Filter control characters below U+0020, preserving line breaks. +/// Add `text`, pushing every '\t' as a one em wide placeholder. +pub fn add_text_with_tabs(builder: &mut ParagraphBuilder, text: &str, font_size: f32) { + let tab = PlaceholderStyle::new( + font_size, + 0.0, + PlaceholderAlignment::Baseline, + TextBaseline::Alphabetic, + 0.0, + ); + + for (index, segment) in text.split('\t').enumerate() { + if index > 0 { + builder.add_placeholder(&tab); + } + builder.add_text(segment); + } +} + +/// Filter control characters below U+0020, preserving tabs and line breaks. /// Browser-dependent: Firefox drops them, others replace with space. fn process_ignored_chars(text: &str, browser: u8) -> String { text.chars() .filter_map(|c| { - if c == '\n' || c == '\r' || c == '\u{2028}' || c == '\u{2029}' { + if c == '\t' || c == '\n' || c == '\r' || c == '\u{2028}' || c == '\u{2029}' { return Some(c); } if c < '\u{0020}' { @@ -1742,6 +1763,15 @@ mod tests { assert_eq!(process_ignored_chars("hello\rworld", 0), "hello\rworld"); } + #[test] + fn process_ignored_chars_preserves_tabs() { + assert_eq!(process_ignored_chars("hello\tworld", 0), "hello\tworld"); + assert_eq!( + process_ignored_chars("hello\tworld", Browser::Firefox as u8), + "hello\tworld" + ); + } + #[test] fn process_ignored_chars_replaces_control_chars_chrome() { // U+0001 (SOH) should become space in non-Firefox diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index af37164af9..97f9c8ab10 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -1061,7 +1061,7 @@ pub extern "C" fn text_editor_export_selection() -> *mut u8 { #[no_mangle] pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> bool { with_state!(state, { - if !get_text_editor_state().selection.is_selection() { + if get_text_editor_state().active_shape_id.is_none() { return false; }