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