mirror of
https://github.com/penpot/penpot.git
synced 2026-09-08 13:09:22 +00:00
🐛 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:
parent
0533be100d
commit
d82038a570
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user