From 83dca9775fd857b9bb887974ddc3d75bd55a2f03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Mon, 26 Oct 2026 17:53:21 +0100 Subject: [PATCH] :tada: Store list-style-type as a paragraph attribute The bullet list toggle now saves list-style-type ("none" or "disc") on the selected paragraphs and reads it back. A paragraph without the attr counts as "none", so a bullet paragraph next to an older one shows as mixed. WASM keeps the value on each paragraph: the paragraph bytes grow from 12 to 16, Enter keeps it on the new paragraph, and the v3 editor reports it in the current styles, so the sidebar shows it while editing too. Refs #12630 AI-assisted-by: claude-opus-5-5 --- common/src/app/common/attrs.cljc | 4 +- common/src/app/common/render_wasm/enums.clj | 1 + .../app/common/render_wasm/serializers.cljs | 10 +++ .../app/common/render_wasm/text_content.cljs | 10 ++- common/src/app/common/types/shape/attrs.cljc | 2 + common/src/app/common/types/text.cljc | 10 ++- common/test/common_tests/attrs_test.cljc | 24 ++++++- .../src/app/main/data/workspace/texts.cljs | 4 +- .../workspace/sidebar/options/menus/text.cljs | 2 + frontend/src/app/render_wasm/text_editor.cljs | 5 +- render-wasm/src/shapes/text.rs | 19 +++++ render-wasm/src/state/text_editor.rs | 71 ++++++++++++++++++- render-wasm/src/wasm/text.rs | 31 +++++++- render-wasm/src/wasm/text/helpers.rs | 19 ++++- render-wasm/src/wasm/text_editor.rs | 14 +++- 15 files changed, 213 insertions(+), 13 deletions(-) diff --git a/common/src/app/common/attrs.cljc b/common/src/app/common/attrs.cljc index 205b02ebd4..95e43d268c 100644 --- a/common/src/app/common/attrs.cljc +++ b/common/src/app/common/attrs.cljc @@ -128,5 +128,7 @@ (merge defaults (get-attrs-multi (->> (txt/node-seq txt/is-root-node? content)) root-attrs) - (get-attrs-multi (->> (txt/node-seq txt/is-paragraph-node? content)) paragraph-attrs) + (get-attrs-multi (->> (txt/node-seq txt/is-paragraph-node? content) + (map #(merge txt/default-list-attrs %))) + paragraph-attrs) (get-attrs-multi (->> (txt/node-seq txt/is-text-node? content)) text-node-attrs)))) diff --git a/common/src/app/common/render_wasm/enums.clj b/common/src/app/common/render_wasm/enums.clj index 0be29aacf9..0e73f76142 100644 --- a/common/src/app/common/render_wasm/enums.clj +++ b/common/src/app/common/render_wasm/enums.clj @@ -38,6 +38,7 @@ ["text-direction" "RawTextDirection"] ["text-decoration" "RawTextDecoration"] ["text-transform" "RawTextTransform"] + ["list-style-type" "RawListStyleType"] ["multiple-state" "MultipleState"] ["transform-entry-kind" "RawTransformEntryKind"] ["segment-data" "RawSegmentData"] diff --git a/common/src/app/common/render_wasm/serializers.cljs b/common/src/app/common/render_wasm/serializers.cljs index 02fe6632d2..b3db247e10 100644 --- a/common/src/app/common/render_wasm/serializers.cljs +++ b/common/src/app/common/render_wasm/serializers.cljs @@ -302,6 +302,16 @@ [text-direction] (untranslate "text-direction" text-direction "ltr")) +(defn translate-list-style-type + [list-style-type] + (let [values (unchecked-get wasm/serializers "list-style-type") + default (unchecked-get values "none")] + (d/nilv (unchecked-get values (d/name list-style-type)) default))) + +(defn untranslate-list-style-type + [list-style-type] + (untranslate "list-style-type" list-style-type "none")) + (defn translate-font-style [font-style] diff --git a/common/src/app/common/render_wasm/text_content.cljs b/common/src/app/common/render_wasm/text_content.cljs index 1ffbebb054..56946cba7b 100644 --- a/common/src/app/common/render_wasm/text_content.cljs +++ b/common/src/app/common/render_wasm/text_content.cljs @@ -24,7 +24,7 @@ [app.common.uuid :as uuid] [cuerdas.core :as str])) -(def ^:const PARAGRAPH-ATTR-U8-SIZE 12) +(def ^:const PARAGRAPH-ATTR-U8-SIZE 16) (def ^:const SPAN-ATTR-U8-SIZE 64) (def ^:const MAX-TEXT-FILLS types.fills.impl/MAX-FILLS) @@ -109,7 +109,8 @@ text-decoration (sr/translate-text-decoration (get paragraph :text-decoration)) text-transform (sr/translate-text-transform (get paragraph :text-transform)) line-height (serialize-line-height (get paragraph :line-height)) - letter-spacing (serialize-letter-spacing (get paragraph :letter-spacing))] + letter-spacing (serialize-letter-spacing (get paragraph :letter-spacing)) + list-style-type (sr/translate-list-style-type (get paragraph :list-style-type))] (-> offset (mem/write-u8 dview text-align) (mem/write-u8 dview text-direction) @@ -117,6 +118,11 @@ (mem/write-u8 dview text-transform) (mem/write-f32 dview line-height) (mem/write-f32 dview letter-spacing) + (mem/write-u8 dview list-style-type) + ;; padding + (mem/write-u8 dview 0) + (mem/write-u8 dview 0) + (mem/write-u8 dview 0) (mem/assert-written offset PARAGRAPH-ATTR-U8-SIZE)))) (defn- write-spans diff --git a/common/src/app/common/types/shape/attrs.cljc b/common/src/app/common/types/shape/attrs.cljc index eb8dc69193..400538033d 100644 --- a/common/src/app/common/types/shape/attrs.cljc +++ b/common/src/app/common/types/shape/attrs.cljc @@ -357,6 +357,8 @@ :text-direction + :list-style-type + :line-height :letter-spacing diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc index 4e53c9b753..7def038383 100644 --- a/common/src/app/common/types/text.cljc +++ b/common/src/app/common/types/text.cljc @@ -43,6 +43,9 @@ (def text-direction-attrs [:text-direction]) +(def text-list-attrs + [:list-style-type]) + (def text-spacing-attrs [:line-height :letter-spacing]) @@ -102,7 +105,8 @@ (def paragraph-attrs (d/concat-vec text-align-attrs - text-direction-attrs)) + text-direction-attrs + text-list-attrs)) (def text-node-attrs (d/concat-vec @@ -138,6 +142,10 @@ {:text-align "left" :text-direction "ltr"}) +(def default-list-attrs + "Older paragraphs have no list attrs; read them as no list." + {:list-style-type "none"}) + (def default-text-attrs {:typography-ref-file nil :typography-ref-id nil diff --git a/common/test/common_tests/attrs_test.cljc b/common/test/common_tests/attrs_test.cljc index 340a89e0da..8d06d1126b 100644 --- a/common/test/common_tests/attrs_test.cljc +++ b/common/test/common_tests/attrs_test.cljc @@ -148,4 +148,26 @@ {:x -153}] result (attrs/get-attrs-multi objs [:x])] (t/is (= {:x :multiple} result) - "Different positions should be :multiple")))) \ No newline at end of file + "Different positions should be :multiple")))) +(defn- text-shape-with-paragraphs + [& paragraphs] + {:content {:type "root" + :children [{:type "paragraph-set" + :children (mapv #(merge {:type "paragraph" + :children [{:text "item"}]} + %) + paragraphs)}]}}) + +(t/deftest get-text-attrs-multi-list-style-type + (t/testing "paragraphs with the same list style return it" + (let [shape (text-shape-with-paragraphs {:list-style-type "disc"} + {:list-style-type "disc"})] + (t/is (= "disc" (:list-style-type (attrs/get-text-attrs-multi shape {} [:list-style-type])))))) + + (t/testing "a paragraph without list style counts as no list" + (let [shape (text-shape-with-paragraphs {})] + (t/is (= "none" (:list-style-type (attrs/get-text-attrs-multi shape {} [:list-style-type])))))) + + (t/testing "a bullet paragraph next to one without list style is mixed" + (let [shape (text-shape-with-paragraphs {:list-style-type "disc"} {})] + (t/is (= :multiple (:list-style-type (attrs/get-text-attrs-multi shape {} [:list-style-type]))))))) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index bc077f92c4..35adc9eeb9 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -434,7 +434,9 @@ (-> (merge default-text-attrs node) (assoc :fills fills))) - node))))] + (cond->> node + (txt/is-paragraph-node? node) + (merge txt/default-list-attrs))))))] (attrs/get-attrs-multi nodes attrs))) (defn current-root-values diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs index 36c5c1204f..034d209ac1 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs @@ -310,6 +310,8 @@ (get n-values :grow-type)) (identical? (get o-values :letter-spacing) (get n-values :letter-spacing)) + (identical? (get o-values :list-style-type) + (get n-values :list-style-type)) (identical? (get o-values :line-height) (get n-values :line-height)) (identical? (get o-values :text-align) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index ebfabdb07d..10e6a4757d 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -24,7 +24,7 @@ (def multiple-state-multiple (sr/translate-multiple-state :multiple)) -(def ^:const TEXT_EDITOR_STYLES_METADATA_SIZE (* 31 4)) +(def ^:const TEXT_EDITOR_STYLES_METADATA_SIZE (* 33 4)) (def ^:const TEXT_EDITOR_STYLES_FILL_SOLID 0) (def ^:const TEXT_EDITOR_STYLES_FILL_LINEAR_GRADIENT 1) (def ^:const TEXT_EDITOR_STYLES_FILL_RADIAL_GRADIENT 2) @@ -325,6 +325,8 @@ font-weight-value (aget heap-i32 (+ u32-offset 24)) line-height-value (aget heap-f32 (+ u32-offset 29)) letter-spacing-value (aget heap-f32 (+ u32-offset 30)) + list-style-type-state (aget heap-u32 (+ u32-offset 31)) + list-style-type-value (aget heap-u32 (+ u32-offset 32)) font-id (fonts/uuid->font-id font-family-id-value) font-style-value (sr/untranslate-font-style (text-editor-get-style-property font-style-state font-style-raw-value)) font-variant-id-computed (text-editor-compute-font-variant-id font-id font-weight-value font-style-value) @@ -356,6 +358,7 @@ :text-direction (sr/untranslate-text-direction (text-editor-get-style-property text-direction-state text-direction-value)) :text-decoration (sr/untranslate-text-decoration (text-editor-get-style-property text-decoration-state text-decoration-value)) :text-transform (sr/untranslate-text-transform (text-editor-get-style-property text-transform-state text-transform-value)) + :list-style-type (sr/untranslate-list-style-type (text-editor-get-style-property list-style-type-state list-style-type-value)) ;; WASM reports size/weight as numbers, but the rest of Penpot (and the backend schema) expects strings. :line-height (let [height (text-editor-get-style-property line-height-state line-height-value)] (if (= height :multiple) height (str height))) diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index a8328be0d1..f139770095 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -1396,6 +1396,13 @@ pub enum TextTransform { Capitalize, } +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum ListStyleType { + #[default] + None, + Disc, +} + // FIXME: Rethink this type. We'll probably need to move the serialization to the // wasm module and store here meaningful model values (and/or skia type aliases) #[derive(Debug, PartialEq, Clone)] @@ -1406,6 +1413,7 @@ pub struct Paragraph { text_transform: Option, line_height: f32, letter_spacing: f32, + list_style_type: ListStyleType, children: Vec, } @@ -1418,6 +1426,7 @@ impl Default for Paragraph { text_transform: None, line_height: 1.0, letter_spacing: 0.0, + list_style_type: ListStyleType::None, children: vec![], } } @@ -1441,10 +1450,16 @@ impl Paragraph { text_transform, line_height, letter_spacing, + list_style_type: ListStyleType::None, children, } } + pub fn with_list_style_type(mut self, list_style_type: ListStyleType) -> Self { + self.list_style_type = list_style_type; + self + } + pub fn children(&self) -> &[TextSpan] { &self.children } @@ -1557,6 +1572,10 @@ impl Paragraph { self.text_transform } + pub fn list_style_type(&self) -> ListStyleType { + self.list_style_type + } + pub fn paragraph_to_style(&self) -> ParagraphStyle { let mut style = ParagraphStyle::default(); diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index aaec3e90fd..9857cb134f 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -3,7 +3,7 @@ use macros::ToJs; use crate::shapes::{ - Fill, FontStyle, TextAlign, TextContent, TextDecoration, TextDirection, + Fill, FontStyle, ListStyleType, TextAlign, TextContent, TextDecoration, TextDirection, TextPositionWithAffinity, TextTransform, VerticalAlign, }; use crate::uuid::Uuid; @@ -109,6 +109,7 @@ pub struct TextEditorStyles { pub text_direction: Multiple, // Multiple pub text_decoration: Multiple, pub text_transform: Multiple, + pub list_style_type: Multiple, // The font family is decomposed into its independent parts so the family // dropdown shows a single family even when the selection mixes variants of // the same family (e.g. regular + bold italic). The id identifies the @@ -232,6 +233,7 @@ impl TextEditorStyles { text_direction: Multiple::empty(), text_decoration: Multiple::empty(), text_transform: Multiple::empty(), + list_style_type: Multiple::empty(), font_family_id: Multiple::empty(), font_style: Multiple::empty(), font_size: Multiple::empty(), @@ -249,6 +251,7 @@ impl TextEditorStyles { self.text_direction.reset(); self.text_decoration.reset(); self.text_transform.reset(); + self.list_style_type.reset(); self.font_family_id.reset(); self.font_style.reset(); self.font_size.reset(); @@ -638,6 +641,9 @@ impl TextEditorState { self.current_styles .text_align .merge(Some(paragraph.text_align())); + self.current_styles + .list_style_type + .merge(Some(paragraph.list_style_type())); let mut char_pos = 0; for span in paragraph.children() { @@ -706,6 +712,9 @@ impl TextEditorState { self.current_styles .text_align .set_single(Some(current_paragraph.text_align())); + self.current_styles + .list_style_type + .set_single(Some(current_paragraph.list_style_type())); if let Some((text_span_index, _)) = current_text_span { if let Some(text_span) = current_paragraph.children().get(text_span_index) { self.current_styles @@ -1030,6 +1039,34 @@ mod tests { content } + fn list_content() -> TextContent { + let black = Fill::Solid(SolidColor(Color::BLACK)); + let mut content = + TextContent::new(Rect::from_xywh(0.0, 0.0, 200.0, 100.0), GrowType::Fixed); + content.add_paragraph( + Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.2, + 0.0, + vec![span("Bullet", 14.0, black.clone())], + ) + .with_list_style_type(ListStyleType::Disc), + ); + content.add_paragraph(Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.2, + 0.0, + vec![span("Plain", 14.0, black)], + )); + content + } + fn drain_events(editor: &mut TextEditorState) -> Vec { let mut events = Vec::new(); loop { @@ -1046,6 +1083,38 @@ mod tests { TextPositionWithAffinity::new_downstream_affinity(0, offset) } + #[test] + fn caret_styles_report_the_paragraph_list_style_type() { + let content = list_content(); + let mut editor = TextEditorState::new(); + + editor.place_caret_from_position( + &content, + &TextPositionWithAffinity::new_downstream_affinity(0, 2), + ); + assert_eq!( + editor.current_styles.list_style_type.value(), + &Some(ListStyleType::Disc) + ); + + editor.place_caret_from_position( + &content, + &TextPositionWithAffinity::new_downstream_affinity(1, 2), + ); + assert_eq!( + editor.current_styles.list_style_type.value(), + &Some(ListStyleType::None) + ); + } + + #[test] + fn selection_styles_mark_mixed_list_style_types_as_multiple() { + let content = list_content(); + let mut editor = TextEditorState::new(); + editor.select_all(&content); + assert!(editor.current_styles.list_style_type.is_multiple()); + } + #[test] fn set_caret_from_position_alone_does_not_emit_styles_changed() { let content = hello_world_content(); diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index c154afbf89..54e47defc4 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -4,7 +4,8 @@ use super::{fills::RawFillData, fonts::RawFontStyle}; use crate::mem::{self, SerializableResult}; use crate::shapes::{ - self, GrowType, Shape, TextAlign, TextDecoration, TextDirection, TextTransform, Type, + self, GrowType, ListStyleType, Shape, TextAlign, TextDecoration, TextDirection, TextTransform, + Type, }; use crate::utils::{uuid_from_u32, uuid_from_u32_quartet}; use crate::{with_current_shape, with_current_shape_mut, with_state}; @@ -94,6 +95,31 @@ impl From for Option { } } +#[derive(Debug, PartialEq, Clone, Copy, ToJs)] +#[repr(u8)] +pub enum RawListStyleType { + None = 0, + Disc = 1, +} + +impl From for ListStyleType { + fn from(value: RawListStyleType) -> Self { + match value { + RawListStyleType::None => ListStyleType::None, + RawListStyleType::Disc => ListStyleType::Disc, + } + } +} + +impl From for RawListStyleType { + fn from(value: ListStyleType) -> Self { + match value { + ListStyleType::None => RawListStyleType::None, + ListStyleType::Disc => RawListStyleType::Disc, + } + } +} + #[repr(C)] #[repr(align(4))] #[derive(Debug, Clone, Copy)] @@ -105,6 +131,8 @@ pub struct RawParagraphData { text_transform: RawTextTransform, line_height: f32, letter_spacing: f32, + list_style_type: RawListStyleType, + _padding: [u8; 3], } impl From<[u8; RAW_PARAGRAPH_DATA_SIZE]> for RawParagraphData { @@ -253,6 +281,7 @@ impl From for shapes::Paragraph { value.attrs.letter_spacing, spans, ) + .with_list_style_type(value.attrs.list_style_type.into()) } } diff --git a/render-wasm/src/wasm/text/helpers.rs b/render-wasm/src/wasm/text/helpers.rs index f9ccfebdd0..dba41e33c0 100644 --- a/render-wasm/src/wasm/text/helpers.rs +++ b/render-wasm/src/wasm/text/helpers.rs @@ -825,6 +825,7 @@ pub fn split_paragraph_at_cursor( let text_transform = para.text_transform(); let line_height = para.line_height(); let letter_spacing = para.letter_spacing(); + let list_style_type = para.list_style_type(); let para = &mut paragraphs[cursor.paragraph]; let children = para.children_mut(); @@ -846,7 +847,8 @@ pub fn split_paragraph_at_cursor( line_height, letter_spacing, new_para_children, - ); + ) + .with_list_style_type(list_style_type); paragraphs.insert(cursor.paragraph + 1, new_para); @@ -857,7 +859,7 @@ pub fn split_paragraph_at_cursor( mod tests { use super::*; use crate::math::Rect; - use crate::shapes::{FontFamily, FontStyle, GrowType, TextAlign, TextSpan}; + use crate::shapes::{FontFamily, FontStyle, GrowType, ListStyleType, TextAlign, TextSpan}; use crate::uuid::Uuid; fn span(text: &str) -> TextSpan { @@ -951,6 +953,19 @@ mod tests { TextPositionWithAffinity::new_downstream_affinity(paragraph, offset) } + #[test] + fn split_paragraph_keeps_the_list_style_type() { + let mut content = content(vec![ + paragraph(&["Hello world"]).with_list_style_type(ListStyleType::Disc) + ]); + assert!(split_paragraph_at_cursor(&mut content, &caret(0, 5))); + + let paragraphs = content.paragraphs(); + assert_eq!(paragraphs.len(), 2); + assert_eq!(paragraphs[0].list_style_type(), ListStyleType::Disc); + assert_eq!(paragraphs[1].list_style_type(), ListStyleType::Disc); + } + #[test] fn delete_char_before_deletes_a_whole_emoji() { for emoji in ["😀", "👍🏽", "👨\u{200d}👩\u{200d}👧", "🇪🇸"] { diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 9853876249..71d552f2bf 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -11,7 +11,8 @@ use crate::utils::uuid_to_u32_quartet; use crate::uuid::Uuid; use crate::wasm::fills::RawFillData; use crate::wasm::text::{ - helpers as text_helpers, RawTextAlign, RawTextDecoration, RawTextDirection, RawTextTransform, + helpers as text_helpers, RawListStyleType, RawTextAlign, RawTextDecoration, RawTextDirection, + RawTextTransform, }; use crate::with_state; use skia_safe::Color; @@ -741,6 +742,13 @@ pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { }) .unwrap_or(RawTextTransform::None as u32); + let list_style_type = styles + .list_style_type + .value() + .as_ref() + .map(|value| RawListStyleType::from(*value) as u32) + .unwrap_or(RawListStyleType::None as u32); + let font_family_id = styles .font_family_id .value() @@ -817,7 +825,9 @@ pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { bytes.extend_from_slice(&font_variant_id[3].to_le_bytes()); // 112 // 28 bytes.extend_from_slice(&line_height.to_le_bytes()); // 116 // 29 bytes.extend_from_slice(&letter_spacing.to_le_bytes()); // 120 // 30 - bytes.extend_from_slice(&fill_bytes); // 124 + bytes.extend_from_slice(&(*styles.list_style_type.state() as u32).to_le_bytes()); // 124 // 31 + bytes.extend_from_slice(&list_style_type.to_le_bytes()); // 128 // 32 + bytes.extend_from_slice(&fill_bytes); // 132 mem::write_bytes(bytes) })