From d82038a570320ab4e23471b2d18c88b0c8110f7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Fri, 4 Sep 2026 13:43:23 +0200 Subject: [PATCH] :bug: Fix paragraph span restyle on line change (#11503) * :bug: Fix paragraph span restyle on line change * :bug: Fix text shape boundaries on resize --- frontend/src/app/render_wasm/text_editor.cljs | 75 ++++++++++++------- render-wasm/src/shapes/text.rs | 31 +++++++- render-wasm/src/wasm/text_editor.rs | 13 +++- 3 files changed, 85 insertions(+), 34 deletions(-) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 9e73ff4ce8..2f60004eac 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -565,41 +565,54 @@ [] (reset! pending-caret-styles {})) -(defn- merge-exported-texts-into-content - "Merge exported span texts back into the existing content tree. +(defn merge-exported-texts-into-content + "Merge exported spans back into the existing content tree. - The WASM editor may split or merge paragraphs (Enter / Backspace at - paragraph boundary), so the exported structure can differ from the - original. When extra paragraphs or spans appear we clone styling from - the nearest existing sibling; when fewer appear we truncate. + The WASM editor may split or merge paragraphs (Enter / Backspace at a + paragraph boundary, paste of several lines), so the exported structure can + differ from the original one, and a positional merge would leave the text of + one paragraph wearing the styling of another. Every exported span carries the + position it had in the tree we last exchanged with WASM (`:p`/`:s`), so the + styling is taken from there; a span WASM never saw falls back to its position + and then to the last existing span. - exported-texts vector of vectors [[\"span1\" \"span2\"] [\"p2s1\"]] - content existing Penpot content map (root -> paragraph-set -> …)" - [content exported-texts] + exported vector of paragraphs, each a vector of `{:p 0 :s 0 :t \"text\"}` + content existing Penpot content map (root -> paragraph-set -> …)" + [content exported] (let [para-set (first (get content :children)) orig-paras (get para-set :children) - num-orig (count orig-paras) last-orig-para (when (seq orig-paras) (last orig-paras)) template-span (when last-orig-para (-> last-orig-para :children last)) + + styling-para + (fn [para-idx spans] + (or (get orig-paras (get (first spans) :p)) + (get orig-paras para-idx) + last-orig-para)) + + styling-span + (fn [orig-para span-idx {:keys [p s]}] + (or (get-in orig-paras [p :children s]) + (get-in orig-para [:children span-idx]) + (-> orig-para :children last) + template-span)) + new-paras - (mapv (fn [para-idx exported-span-texts] - (let [orig-para (if (< para-idx num-orig) - (nth orig-paras para-idx) - (dissoc last-orig-para :children)) - orig-spans (get orig-para :children) - num-orig-spans (count orig-spans) - last-orig-span (when (seq orig-spans) (last orig-spans))] + (mapv (fn [para-idx spans] + (let [orig-para (styling-para para-idx spans)] (assoc orig-para :children - (mapv (fn [span-idx new-text] - (let [orig-span (if (< span-idx num-orig-spans) - (nth orig-spans span-idx) - (or last-orig-span template-span))] - (assoc orig-span :text new-text))) - (range (count exported-span-texts)) - exported-span-texts)))) - (range (count exported-texts)) - exported-texts) + (if (seq spans) + (mapv (fn [span-idx span] + (-> (styling-span orig-para span-idx span) + (assoc :text (get span :t)))) + (range (count spans)) + spans) + ;; A paragraph with no spans is dropped on the way + ;; back to WASM (and fails the content schema). + [(assoc (or template-span {}) :text "")])))) + (range (count exported)) + exported) new-para-set (assoc para-set :children new-paras)] (assoc content :children [new-para-set]))) @@ -629,9 +642,9 @@ [] (when (and (wasm/ready?) (text-editor-has-focus?)) (let [shape-id (text-editor-get-active-shape-id) - new-texts (text-editor-export-content)] + new-texts (when shape-id (text-editor-export-content))] (when (and shape-id new-texts) - (let [texts-clj (js->clj new-texts) + (let [texts-clj (js->clj new-texts :keywordize-keys true) ;; A brand-new empty text shape (single click) has no cached ;; content yet, so fall back to a default template so the first ;; keystrokes are synced back to the shape instead of dropped. @@ -670,7 +683,11 @@ span-end (+ pos span-len) ol-start (max pos sel-start) ol-end (min span-end sel-end) - has-overlap? (< ol-start ol-end)] + ;; An empty span has no range to overlap, but an empty + ;; line inside the selection still has to be restyled. + has-overlap? (or (< ol-start ol-end) + (and (zero? span-len) + (<= sel-start pos sel-end)))] (if (not has-overlap?) (recur (rest spans) span-end (conj acc span)) (let [before (when (> ol-start pos) diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 7cbf859ab4..f0f2fa9f1e 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -485,11 +485,19 @@ impl TextContent { seen } - pub fn add_paragraph(&mut self, paragraph: Paragraph) { + pub fn add_paragraph(&mut self, mut paragraph: Paragraph) { + let index = self.paragraphs.len() as u32; + paragraph.set_span_positions(index); self.paragraphs.push(paragraph); self.content_version = self.content_version.wrapping_add(1); } + pub fn reset_span_positions(&mut self) { + for (index, paragraph) in self.paragraphs.iter_mut().enumerate() { + paragraph.set_span_positions(index as u32); + } + } + pub fn paragraphs(&self) -> &[Paragraph] { &self.paragraphs } @@ -598,7 +606,11 @@ impl TextContent { return self.content_rect(selrect, valign); } - let tight = if !self.layout.paragraphs.is_empty() { + let layout_matches_container = self + .layout_width + .is_some_and(|w| w.ceil() == self.get_width(selrect.width()).ceil()); + + let tight = if !self.layout.paragraphs.is_empty() && layout_matches_container { self.rect_from_paragraphs(selrect, valign) } else { let mut text_content = self.clone(); @@ -1276,6 +1288,12 @@ impl Paragraph { &mut self.children } + fn set_span_positions(&mut self, index: u32) { + for (span_index, span) in self.children.iter_mut().enumerate() { + span.set_position(index, span_index as u32); + } + } + fn char_count(&self) -> usize { self.children .iter() @@ -1441,6 +1459,8 @@ pub struct TextSpan { pub text_transform: Option, pub text_direction: TextDirection, pub fills: Vec, + pub paragraph_position: u32, + pub span_position: u32, } impl TextSpan { @@ -1470,6 +1490,8 @@ impl TextSpan { font_weight, font_variant_id, fills, + paragraph_position: u32::MAX, + span_position: u32::MAX, } } @@ -1477,6 +1499,11 @@ impl TextSpan { self.text = text; } + pub fn set_position(&mut self, paragraph: u32, span: u32) { + self.paragraph_position = paragraph; + self.span_position = span; + } + pub fn to_style( &self, content_bounds: &Rect, diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 5db04aab98..08e5c9bf54 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -880,11 +880,11 @@ pub extern "C" fn text_editor_export_content() -> *mut u8 { return std::ptr::null_mut(); }; - let Some(shape) = state.shapes.get(&shape_id) else { + let Some(shape) = state.shapes.get_mut(&shape_id) else { return std::ptr::null_mut(); }; - let Type::Text(text_content) = &shape.shape_type else { + let Type::Text(text_content) = &mut shape.shape_type else { return std::ptr::null_mut(); }; @@ -899,12 +899,19 @@ pub extern "C" fn text_editor_export_content() -> *mut u8 { .replace('\n', "\\n") .replace('\r', "\\r") .replace('\t', "\\t"); - span_parts.push(format!("\"{}\"", escaped_text)); + span_parts.push(format!( + "{{\"p\":{},\"s\":{},\"t\":\"{}\"}}", + span.paragraph_position, span.span_position, escaped_text + )); } json_parts.push(format!("[{}]", span_parts.join(","))); } let json = format!("[{}]", json_parts.join(",")); + // The host rebuilds its content tree out of this JSON, so the current + // positions are what the next call has to report against. + text_content.reset_span_positions(); + let mut bytes = json.into_bytes(); bytes.push(0); crate::mem::write_bytes(bytes)