🎉 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
This commit is contained in:
Belén Albeza 2026-10-26 17:53:21 +01:00
parent ce5b22d38f
commit 83dca9775f
15 changed files with 213 additions and 13 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -357,6 +357,8 @@
:text-direction
:list-style-type
:line-height
:letter-spacing

View File

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

View File

@ -148,4 +148,26 @@
{:x -153}]
result (attrs/get-attrs-multi objs [:x])]
(t/is (= {:x :multiple} result)
"Different positions should be :multiple"))))
"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])))))))

View File

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

View File

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

View File

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

View File

@ -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<TextTransform>,
line_height: f32,
letter_spacing: f32,
list_style_type: ListStyleType,
children: Vec<TextSpan>,
}
@ -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();

View File

@ -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<TextDirection>, // Multiple
pub text_decoration: Multiple<TextDecoration>,
pub text_transform: Multiple<TextTransform>,
pub list_style_type: Multiple<ListStyleType>,
// 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<TextEditorEvent> {
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();

View File

@ -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<RawTextTransform> for Option<TextTransform> {
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
pub enum RawListStyleType {
None = 0,
Disc = 1,
}
impl From<RawListStyleType> for ListStyleType {
fn from(value: RawListStyleType) -> Self {
match value {
RawListStyleType::None => ListStyleType::None,
RawListStyleType::Disc => ListStyleType::Disc,
}
}
}
impl From<ListStyleType> 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<RawParagraph> for shapes::Paragraph {
value.attrs.letter_spacing,
spans,
)
.with_list_style_type(value.attrs.list_style_type.into())
}
}

View File

@ -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}👧", "🇪🇸"] {

View File

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