🐛 Fix paragraph span restyle on line change (#11503)

* 🐛 Fix paragraph span restyle on line change

* 🐛 Fix text shape boundaries on resize
This commit is contained in:
Elena Torró 2026-09-04 13:43:23 +02:00 committed by GitHub
parent 0533be100d
commit d82038a570
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 85 additions and 34 deletions

View File

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

View File

@ -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<TextTransform>,
pub text_direction: TextDirection,
pub fills: Vec<shapes::Fill>,
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,

View File

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