🔧 Improve text editor selection and tab conversion

This commit is contained in:
Elena Torro 2026-08-04 12:06:05 +02:00
parent 6e843faba3
commit f66d588943
7 changed files with 114 additions and 19 deletions

View File

@ -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")))

View File

@ -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

View File

@ -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)]

View File

@ -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}))))))

View File

@ -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);
}
}

View File

@ -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

View File

@ -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;
}