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/flags.cljc b/common/src/app/common/flags.cljc
index 202b41b1d1..6012c702bf 100644
--- a/common/src/app/common/flags.cljc
+++ b/common/src/app/common/flags.cljc
@@ -191,6 +191,8 @@
:available-viewer-wasm
:stroke-path
:stroke-per-side
+ ;; Enables bullet lists in the v3 (WASM) text editor.
+ :text-editor-wasm-v1-lists
:custom-shortcuts
:remote-media-processing
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 c98c00b4c4..a19fb47c47 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..fbeb80695c 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,15 @@
{: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"})
+
+(defn list-paragraph?
+ "True when a paragraph (or its merged style map) is a list item."
+ [node]
+ (= "disc" (:list-style-type node)))
+
(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/common/test/common_tests/types/text_test.cljc b/common/test/common_tests/types/text_test.cljc
index ace36f0c4d..e0027b7b16 100644
--- a/common/test/common_tests/types/text_test.cljc
+++ b/common/test/common_tests/types/text_test.cljc
@@ -368,3 +368,8 @@
(t/is (every? #(= % "14") original-font-sizes))
(t/is (every? #(= % "32") updated-font-sizes))))
+
+(t/deftest test-list-paragraph
+ (t/is (true? (cttx/list-paragraph? {:type "paragraph" :list-style-type "disc"})))
+ (t/is (false? (cttx/list-paragraph? {:type "paragraph" :list-style-type "none"})))
+ (t/is (false? (cttx/list-paragraph? {:type "paragraph"}))))
diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs
index 0464cee98b..ace273017c 100644
--- a/frontend/src/app/main/data/workspace/texts.cljs
+++ b/frontend/src/app/main/data/workspace/texts.cljs
@@ -437,7 +437,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/inspect/attributes/text.cljs b/frontend/src/app/main/ui/inspect/attributes/text.cljs
index e6434bd722..799f8e0db7 100644
--- a/frontend/src/app/main/ui/inspect/attributes/text.cljs
+++ b/frontend/src/app/main/ui/inspect/attributes/text.cljs
@@ -129,6 +129,14 @@
"uppercase" (tr "inspect.attributes.typography.text-transform.uppercase")
"unset" (tr "inspect.attributes.typography.text-transform.unset"))]]]])
+ (when (types.text/list-paragraph? style)
+ [:div {:class (stl/css :text-row)}
+ [:div {:class (stl/css :global/attr-label)} "List Style"]
+ [:div {:class (stl/css :global/attr-value)}
+ [:> copy-button* {:data (copy-style-data style :list-style-type)}
+ [:div {:class (stl/css :button-children)}
+ (tr "inspect.attributes.typography.list-style-type.disc")]]]])
+
[:> copy-button* {:data (str/trim text)
:class (stl/css :attributes-content-row)}
[:span {:class (stl/css :content)
diff --git a/frontend/src/app/main/ui/inspect/styles/panels/text.cljs b/frontend/src/app/main/ui/inspect/styles/panels/text.cljs
index f68c764913..a2491d78f7 100644
--- a/frontend/src/app/main/ui/inspect/styles/panels/text.cljs
+++ b/frontend/src/app/main/ui/inspect/styles/panels/text.cljs
@@ -180,6 +180,12 @@
:property (str "text-transform: " (:text-transform style) ";")
:copiable true}]))
+ (when (txt/list-paragraph? style)
+ [:> properties-row* {:term "List Style"
+ :detail (:list-style-type style)
+ :property (str "list-style-type: " (:list-style-type style) ";")
+ :copiable true}])
+
[:pre {:class (stl/css :text-content-wrapper)
:role "presentation"}
[:> property-detail-copiable* {:copied copied
diff --git a/frontend/src/app/main/ui/shapes/text/html_text.cljs b/frontend/src/app/main/ui/shapes/text/html_text.cljs
index 299ee49de7..7ffd80c38c 100644
--- a/frontend/src/app/main/ui/shapes/text/html_text.cljs
+++ b/frontend/src/app/main/ui/shapes/text/html_text.cljs
@@ -9,6 +9,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.text :as legacy.txt]
+ [app.common.types.text :as txt]
[app.main.ui.shapes.text.styles :as sts]
[rumext.v2 :as mf]))
@@ -39,17 +40,48 @@
[:div.paragraph-set {:style style :class class} children]))
(mf/defc render-paragraph*
- [{:keys [node children shape is-code]}]
+ [{:keys [node children shape is-code is-list-item]}]
(let [style (when-not is-code (sts/generate-paragraph-styles shape node))
class (when is-code (:$id node))
dir (:text-direction node "auto")]
- [:p.paragraph {:style style :dir dir :class class} children]))
+ (if is-list-item
+ [:li {:style style :dir dir :class class} children]
+ [:p.paragraph {:style style :dir dir :class class} children])))
+
+(declare render-node*)
+
+(defn- render-children
+ "Child elements of `parent`. In code mode, runs of list paragraphs are
+ grouped into a `
` of `- `."
+ [props parent is-code]
+ (let [render-child
+ (fn [[index child-node] is-list-item]
+ (mf/html
+ [:> render-node*
+ (mf/spread-props props
+ {:node child-node
+ :parent parent
+ :index index
+ :key index
+ :is-list-item is-list-item})]))
+
+ children (d/enumerate (:children parent))]
+ (if (and is-code (= "paragraph-set" (:type parent)))
+ (mapcat (fn [run]
+ (if (txt/list-paragraph? (second (first run)))
+ [(mf/html
+ [:ul {:key (dm/str "list-" (first (first run)))}
+ (for [child run]
+ (render-child child true))])]
+ (map #(render-child % false) run)))
+ (partition-by (comp txt/list-paragraph? second) children))
+ (map #(render-child % false) children))))
;; -- Text nodes
(mf/defc render-node*
{::mf/props :obj}
[{:keys [node is-code] :as props}]
- (let [{:keys [type text children] :as parent} node]
+ (let [{:keys [type text]} node]
(if (string? text)
[:> render-text* props]
(let [component (case type
@@ -59,14 +91,7 @@
nil)]
(when component
[:> component props
- (for [[index child-node] (d/enumerate children)]
- [:> render-node*
- (mf/spread-props props
- {:node child-node
- :parent parent
- :index index
- :key index
- :is-code is-code})])])))))
+ (render-children props node is-code)])))))
(mf/defc text-shape*
{::mf/forward-ref true}
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 61ee90bf07..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
@@ -52,6 +52,9 @@
Evaluated once at module load time; cf/flags is immutable after startup."
(contains? cf/flags :token-typography-row))
+(def ^:private text-lists-flag-enabled?
+ (contains? cf/flags :text-editor-wasm-v1-lists))
+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Sub-components
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -230,6 +233,37 @@
:allow-empty true
:options options}]]))
+(mf/defc list-options*
+ [{:keys [values on-change on-blur]}]
+ (let [list-style-type (:list-style-type values)
+ ;; Mixed selections leave no option selected
+ selected (if (= list-style-type :multiple)
+ nil
+ (d/nilv list-style-type "none"))
+ options
+ (mf/with-memo []
+ [{:value "none"
+ :id "list-style-none"
+ :label (tr "workspace.options.text-options.list-none")
+ :icon i/remove}
+ {:value "disc"
+ :id "list-style-disc"
+ :label (tr "workspace.options.text-options.list-bullets")
+ :icon i/view-as-list}])
+
+ handle-change
+ (mf/use-fn
+ (mf/deps on-change on-blur)
+ (fn [value]
+ (on-change {:list-style-type value})
+ (when (some? on-blur) (on-blur))))]
+
+ [:div {:class (stl/css :list-options)}
+ [:> radio-buttons* {:selected selected
+ :on-change handle-change
+ :name "list-options"
+ :options options}]]))
+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Helpers
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -276,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)
@@ -360,6 +396,10 @@
;; --- Helpers
multiple? (->> values vals (d/seek #(= % :multiple)))
+ ;; v3 can be toggled at runtime, so it is read on render
+ text-editor-v3? (features/use-feature "text-editor-wasm/v1")
+ text-lists-enabled? (and text-lists-flag-enabled? text-editor-v3?)
+
apply-token!
(mf/use-fn
(mf/deps ids typography-tokens)
@@ -596,4 +636,7 @@
[:div {:class (stl/css :text-decoration-options)}
[:> vertical-align* common-props]
[:> text-decoration-options* (mf/spread-props common-props {:token-applied current-token-name})]
- [:> text-direction-options* common-props]])])]))
+ [:> text-direction-options* common-props]])
+
+ (when (and more-options-open? text-lists-enabled?)
+ [:> list-options* common-props])])]))
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss
index 4b39b0663c..7d114ab03a 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss
@@ -59,7 +59,8 @@
.text-direction-options,
.vertical-align-options,
.grow-options,
-.text-decoration-options {
+.text-decoration-options,
+.list-options {
block-size: $sz-32;
}
diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs
index 851f08fbb8..322e12b59e 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/frontend/src/app/util/code_gen/style_css.cljs b/frontend/src/app/util/code_gen/style_css.cljs
index 2534404394..fa04b1e89c 100644
--- a/frontend/src/app/util/code_gen/style_css.cljs
+++ b/frontend/src/app/util/code_gen/style_css.cljs
@@ -13,12 +13,14 @@
[app.common.geom.shapes.bounds :as gsb]
[app.common.geom.shapes.points :as gpo]
[app.common.text :as legacy.txt]
+ [app.common.types.color :as cc]
[app.common.types.shape.layout :as ctl]
[app.common.types.text :as types.text]
[app.main.ui.shapes.text.styles :as sts]
[app.util.code-gen.common :as cgc]
[app.util.code-gen.style-css-formats :refer [format-value format-shadow->css]]
[app.util.code-gen.style-css-values :refer [get-value]]
+ [app.util.object :as obj]
[cuerdas.core :as str]))
;;
@@ -230,6 +232,11 @@ body {
[(str/kebab key) (unchecked-get properties key)])))
nil))
+(defn- list-paragraph?
+ [node]
+ (and (= "paragraph" (:type node))
+ (types.text/list-paragraph? node)))
+
(defn node->css
[shape shape-selector node]
(let [properties
@@ -241,7 +248,9 @@ body {
(sts/generate-paragraph-set-styles shape)
(:paragraph "paragraph")
- (sts/generate-paragraph-styles shape node)
+ (cond-> (sts/generate-paragraph-styles shape node)
+ (list-paragraph? node)
+ (obj/set! "listStyleType" (:list-style-type node)))
(sts/generate-text-styles shape node))]
(dm/fmt
@@ -249,15 +258,47 @@ body {
(dm/str shape-selector " ." (:$id node))
(format-js-styles properties nil))))
+(defn- marker-color
+ "The bullet takes its span's text color. A `::marker` can't paint a gradient,
+ so a gradient fill gives its first stop."
+ [shape span]
+ (if-let [gradient (or (-> span :fills first :fill-color-gradient)
+ (:fill-color-gradient span))]
+ (let [{:keys [color opacity]} (first (:stops gradient))
+ [r g b a] (cc/hex->rgba color opacity)]
+ (str/format "rgba(%s, %s, %s, %s)" r g b a))
+ (obj/get (sts/generate-text-styles shape span) "color")))
+
+(defn- list-marker-css
+ "Paragraphs set `font-size: 0`, so the bullet takes its size (and color) from
+ the item's first span, as it does on the canvas."
+ [shape shape-selector node]
+ (let [span (first (:children node))
+ font-size (d/nilv (:font-size span) (:font-size types.text/default-text-attrs))
+ color (marker-color shape span)]
+ (dm/fmt
+ ".% {\n%\n}"
+ (dm/str shape-selector " ." (:$id node) "::marker")
+ (format-js-styles (cond-> #js {:fontSize (dm/str font-size "px")}
+ (some? color) (obj/set! "color" color))
+ nil))))
+
(defn generate-text-css
[shape]
- (let [selector (cgc/shape->selector shape)]
- (->> shape
- :content
- (legacy.txt/index-content)
- (types.text/node-seq)
- (map #(node->css shape selector %))
- (str/join "\n"))))
+ (let [selector (cgc/shape->selector shape)
+ nodes (->> shape
+ :content
+ (legacy.txt/index-content)
+ (types.text/node-seq))
+ node-rules (mapcat (fn [node]
+ (cond-> [(node->css shape selector node)]
+ (list-paragraph? node)
+ (conj (list-marker-css shape selector node))))
+ nodes)
+ list-rules (when (some list-paragraph? nodes)
+ [(dm/fmt ".% ul {\n%\n}" selector
+ (format-js-styles #js {:margin 0 :padding 0} nil))])]
+ (str/join "\n" (concat node-rules list-rules))))
(defn get-shape-css-selector
([objects shape]
diff --git a/frontend/test/frontend_tests/code_gen_style_test.cljs b/frontend/test/frontend_tests/code_gen_style_test.cljs
index fb10dfef85..937df0b8ca 100644
--- a/frontend/test/frontend_tests/code_gen_style_test.cljs
+++ b/frontend/test/frontend_tests/code_gen_style_test.cljs
@@ -10,14 +10,17 @@
Each test guards against a concrete bug found in the CSS/HTML generation
of layout children and text shapes."
(:require
+ ["react-dom/server" :as rds]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.uuid :as uuid]
+ [app.main.ui.shapes.text.html-text :as html-text]
[app.util.code-gen.markup-html :as html]
[app.util.code-gen.style-css :as css]
[cljs.test :refer [deftest is testing] :include-macros true]
- [cuerdas.core :as str]))
+ [cuerdas.core :as str]
+ [rumext.v2 :as mf]))
;; --- Builders ------------------------------------------------------------
@@ -194,3 +197,110 @@
(is (str/includes? markup "root-0")
"the text nodes must expose their $id as a class for the CSS to apply")
(is (str/includes? markup "root-0-paragraph-set-0-paragraph-0")))))
+
+;; --- Text lists ----------------------------------------------------------
+
+(defn- list-paragraph
+ [text list-style-type]
+ (cond-> {:type "paragraph"
+ :children [{:text text
+ :font-size "20"
+ :fills [{:fill-color "#ff0000" :fill-opacity 1}]}]}
+ (some? list-style-type)
+ (assoc :list-style-type list-style-type)))
+
+(defn- text-shape-with
+ [paragraphs]
+ {:id (uuid/next) :name "Text" :type :text
+ :parent-id uuid/zero :frame-id uuid/zero
+ :selrect (grc/make-rect 0 0 100 20)
+ :points (pts 0 0 100 20)
+ :grow-type :fixed
+ :content {:type "root"
+ :children [{:type "paragraph-set" :children paragraphs}]}})
+
+(defn- block-tags
+ "The p/ul/li tags of `markup`, in order."
+ [markup]
+ (->> (re-seq #"<(/?)(p|ul|li)[ >]" markup)
+ (map (fn [[_ close tag]] (str close tag)))))
+
+(deftest text-markup-groups-list-paragraphs-into-lists
+ (let [text (text-shape-with [(list-paragraph "a" nil)
+ (list-paragraph "b" "disc")
+ (list-paragraph "c" "disc")
+ (list-paragraph "d" "none")
+ (list-paragraph "e" "disc")])
+ markup (html/generate-markup (objects text) [text])]
+ (is (= ["p" "/p" "ul" "li" "/li" "li" "/li" "/ul" "p" "/p" "ul" "li" "/li" "/ul"]
+ (block-tags markup)))
+ (testing "list items keep the paragraph class and direction"
+ (is (str/includes? markup "
- ")))))
+
+(deftest text-markup-without-lists-has-no-list-tags
+ (let [text (text-shape-with [(list-paragraph "a" nil) (list-paragraph "b" "none")])
+ markup (html/generate-markup (objects text) [text])]
+ (is (= ["p" "/p" "p" "/p"] (block-tags markup)))))
+
+(deftest text-css-styles-list-items
+ (let [text (text-shape-with [(list-paragraph "a" nil)
+ (list-paragraph "b" "disc")])
+ css (css/generate-text-css text)]
+ (testing "the list item rule sets the list style"
+ (is (re-find #"(?s)\.root-0-paragraph-set-0-paragraph-1 \{[^}]*list-style-type: disc;" css))
+ (is (not (re-find #"(?s)\.root-0-paragraph-set-0-paragraph-0 \{[^}]*list-style-type" css))))
+ (testing "the marker gets the first span's size and color"
+ (is (re-find #"(?s)\.root-0-paragraph-set-0-paragraph-1::marker \{[^}]*font-size: 20px;[^}]*color: rgba\(255, 0, 0, 1\);" css)))
+ (testing "the list drops the browser's spacing"
+ (is (= 1 (count (re-seq #" ul \{" css))))
+ (is (re-find #"(?s) ul \{\s*margin: 0;\s*padding: 0;\s*\}" css)))))
+
+(deftest text-css-without-lists-has-no-list-rules
+ (let [css (css/generate-text-css (text-shape-with [(list-paragraph "a" nil)]))]
+ (is (not (str/includes? css "list-style")))
+ (is (not (str/includes? css " ul {")))
+ (is (not (str/includes? css "::marker")))))
+
+(deftest rendered-text-keeps-paragraphs-for-lists
+ (testing "outside code mode list paragraphs still render as
"
+ (let [text (text-shape-with [(list-paragraph "b" "disc")])
+ markup (rds/renderToStaticMarkup
+ (mf/element html-text/text-shape* #js {:shape text :grow-type :fixed}))]
+ (is (= ["p" "/p"] (block-tags markup))))))
+
+(defn- marker-rule
+ "The ::marker rule generated for a single list item made of `span`."
+ [span]
+ (let [css (css/generate-text-css
+ (text-shape-with [{:type "paragraph"
+ :list-style-type "disc"
+ :children [span]}]))]
+ (re-find #"(?s)::marker \{[^}]*\}" css)))
+
+(deftest text-css-colors-the-marker-like-its-text
+ (testing "a half-opaque fill keeps its opacity"
+ (is (str/includes? (marker-rule {:text "a" :font-size "20"
+ :fills [{:fill-color "#ff0000" :fill-opacity 0.5}]})
+ "color: rgba(255, 0, 0, 0.5);")))
+ (testing "a gradient fill uses its first stop"
+ (is (str/includes? (marker-rule {:text "a" :font-size "20"
+ :fills [{:fill-color-gradient
+ {:type :linear
+ :start-x 0 :start-y 0 :end-x 1 :end-y 1 :width 1
+ :stops [{:color "#00ff00" :opacity 0.8 :offset 0}
+ {:color "#0000ff" :opacity 1 :offset 1}]}}]})
+ "color: rgba(0, 255, 0, 0.8);")))
+ (testing "a legacy span keeps its color on the node"
+ (is (str/includes? (marker-rule {:text "a" :font-size "20"
+ :fill-color "#0000ff" :fill-opacity 1})
+ "color: rgba(0, 0, 255, 1);")))
+ (testing "a span without a font size falls back to the default"
+ (is (str/includes? (marker-rule {:text "a"
+ :fills [{:fill-color "#ff0000" :fill-opacity 1}]})
+ "font-size: 14px;"))))
+
+(deftest text-markup-keeps-the-direction-of-list-items
+ (let [text (text-shape-with [(assoc (list-paragraph "a" "disc") :text-direction "rtl")])
+ markup (html/generate-markup (objects text) [text])]
+ (is (str/includes? markup "
- disc<"))))
+ (t/testing "computed tab"
+ (let [markup (render-panel attributes-text/text-panel* shapes)]
+ (t/is (str/includes? markup "List Style"))
+ (t/is (str/includes? markup "inspect.attributes.typography.list-style-type.disc")))))))
+
+(t/deftest text-panels-hide-the-list-style-without-a-list
+ (with-redefs [cf/flags (conj cf/flags :inspect-styles)
+ hooks/use-portal-container (mock/stub (fn [_] nil))]
+ (doseq [paragraph-attrs [{} {:list-style-type "none"}]
+ panel [styles-text/text-panel* attributes-text/text-panel*]]
+ (let [markup (render-panel panel [(text-shape paragraph-attrs)])]
+ (t/is (not (str/includes? markup "List Style")))))))
diff --git a/frontend/translations/en.po b/frontend/translations/en.po
index 185602dbb5..3bfa4d75c7 100644
--- a/frontend/translations/en.po
+++ b/frontend/translations/en.po
@@ -2408,7 +2408,10 @@ msgstr "Letter Spacing"
msgid "inspect.attributes.typography.line-height"
msgstr "Line Height"
-#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:638
+msgid "inspect.attributes.typography.list-style-type.disc"
+msgstr "Disc"
+
+#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:594
msgid "inspect.attributes.typography.mixed-font-family"
msgstr "Mixed Font Families"
@@ -8420,6 +8423,14 @@ msgstr "Letter Spacing"
msgid "workspace.options.text-options.line-height"
msgstr "Line height"
+#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:251
+msgid "workspace.options.text-options.list-bullets"
+msgstr "Bullet list"
+
+#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:247
+msgid "workspace.options.text-options.list-none"
+msgstr "No list"
+
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:212
msgid "workspace.options.text-options.strikethrough"
msgstr "Strikethrough (%s)"
diff --git a/frontend/translations/es.po b/frontend/translations/es.po
index 5871401d67..fa83708f7d 100644
--- a/frontend/translations/es.po
+++ b/frontend/translations/es.po
@@ -2458,7 +2458,10 @@ msgstr "Espaciado de letras"
msgid "inspect.attributes.typography.line-height"
msgstr "Interlineado"
-#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:638
+msgid "inspect.attributes.typography.list-style-type.disc"
+msgstr "Disco"
+
+#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:594
msgid "inspect.attributes.typography.mixed-font-family"
msgstr "Varias Familias tipográficas"
@@ -8497,6 +8500,14 @@ msgstr "Espaciado entre letras"
msgid "workspace.options.text-options.line-height"
msgstr "Altura de línea"
+#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:251
+msgid "workspace.options.text-options.list-bullets"
+msgstr "Lista con viñetas"
+
+#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:247
+msgid "workspace.options.text-options.list-none"
+msgstr "Sin lista"
+
#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:212
msgid "workspace.options.text-options.strikethrough"
msgstr "Tachado (%s)"
diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs
index a112752b6b..b8dfa4b785 100644
--- a/render-wasm/src/render/svg/fixtures.rs
+++ b/render-wasm/src/render/svg/fixtures.rs
@@ -7,9 +7,9 @@ use skia_safe as skia;
use crate::globals::TestRenderResourcesGuard;
use crate::render::{FontStore, RenderResources};
use crate::shapes::{
- make_corners, Fill, FontFamily, FontStyle, Frame, Group, GrowType, ImageFill, Paragraph, Path,
- Rect, Segment, SolidColor, Stroke, StrokeKind, StrokeStyle, TextAlign, TextContent,
- TextDirection, TextSpan, Type,
+ make_corners, Fill, FontFamily, FontStyle, Frame, Group, GrowType, ImageFill, ListStyleType,
+ Paragraph, Path, Rect, Segment, SolidColor, Stroke, StrokeKind, StrokeStyle, TextAlign,
+ TextContent, TextDirection, TextSpan, Type,
};
use crate::state::ShapesPool;
use crate::utils::uuid_from_u32_quartet;
@@ -384,6 +384,52 @@ pub(super) fn add_text_with_spans(
shape.set_shape_type(Type::Text(content));
}
+/// Fixed text with one solid-filled paragraph per `(text, list style)` entry.
+pub(super) fn add_text_with_list_paragraphs(
+ pool: &mut ShapesPool,
+ id: Uuid,
+ (l, t, r, b): (f32, f32, f32, f32),
+ font_size: f32,
+ fill: skia::Color,
+ paragraphs: Vec<(&str, ListStyleType)>,
+) {
+ let bounds = skia::Rect::from_ltrb(l, t, r, b);
+ let mut content = TextContent::new(bounds, GrowType::Fixed);
+ let line_height = 1.2;
+ for (text, list_style_type) in paragraphs {
+ let span = TextSpan::new(
+ text.to_string(),
+ FontFamily::new(Uuid::nil(), 400, FontStyle::Normal),
+ font_size,
+ line_height,
+ 0.0,
+ None,
+ None,
+ TextDirection::LTR,
+ 400,
+ Uuid::nil(),
+ vec![Fill::Solid(SolidColor(fill))],
+ );
+ content.add_paragraph(
+ Paragraph::new(
+ TextAlign::Left,
+ TextDirection::LTR,
+ None,
+ None,
+ line_height,
+ 0.0,
+ vec![span],
+ )
+ .with_list_style_type(list_style_type),
+ );
+ }
+
+ let shape = pool.add_shape(id);
+ shape.set_parent(Uuid::nil());
+ shape.set_selrect(l, t, r, b);
+ shape.set_shape_type(Type::Text(content));
+}
+
/// Adds a rectangle with a single stroke and no fill.
pub(super) fn add_stroked_rect(
pool: &mut ShapesPool,
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_list_bullets_before_list_paragraphs_only.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_list_bullets_before_list_paragraphs_only.snap
new file mode 100644
index 0000000000..d76f3044a5
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_list_bullets_before_list_paragraphs_only.snap
@@ -0,0 +1,24 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs
index 2212371f1e..96178a7c0a 100644
--- a/render-wasm/src/render/svg/tests.rs
+++ b/render-wasm/src/render/svg/tests.rs
@@ -2,8 +2,8 @@ use super::fixtures::*;
use crate::shapes::{
radius_to_sigma, AlignContent, AlignItems, BlendMode, Blur, BlurType, Fill, FlexDirection,
- ImageFill, ImageFillTransform, JustifyContent, JustifyItems, Shadow, ShadowStyle, SolidColor,
- StrokeCap, StrokeKind, WrapType,
+ ImageFill, ImageFillTransform, JustifyContent, JustifyItems, ListStyleType, Shadow,
+ ShadowStyle, SolidColor, StrokeCap, StrokeKind, WrapType,
};
use crate::state::ShapesPool;
use crate::uuid::Uuid;
@@ -2063,6 +2063,33 @@ fn exports_text_with_multiple_solid_fills() {
insta::assert_snapshot!(svg);
}
+#[test]
+fn exports_list_bullets_before_list_paragraphs_only() {
+ let mut pool = ShapesPool::new();
+ let id = uid(1);
+ add_text_with_list_paragraphs(
+ &mut pool,
+ id,
+ (40.0, 0.0, 300.0, 120.0),
+ 30.0,
+ skia::Color::RED,
+ vec![
+ ("Bullet", ListStyleType::Disc),
+ ("Plain", ListStyleType::None),
+ ],
+ );
+ pool.get_mut(&id)
+ .unwrap()
+ .add_stroke(solid_stroke(StrokeKind::Center, 2.0, skia::Color::BLUE));
+
+ let svg = render(&pool, id);
+ // One bullet, before "Bullet" only: a red fill and a blue stroke.
+ assert_eq!(svg.matches("(
+ skia_paragraphs: impl IntoIterator
- >,
+ text_paragraphs: &[TextParagraph],
+ x: f32,
+ top: f32,
+ layout_width: f32,
+) -> Vec {
+ let mut markers: Vec = Vec::new();
+ let mut y = top;
+ for (skia_paragraph, text_paragraph) in skia_paragraphs.into_iter().zip(text_paragraphs) {
+ let Some(skia_paragraph) = skia_paragraph else {
+ continue;
+ };
+ if let Some(marker) = list_marker(
+ skia_paragraph,
+ text_paragraph,
+ x,
+ y,
+ layout_width,
+ markers.last(),
+ ) {
+ markers.push(marker);
+ }
+ y += skia_paragraph.height();
+ }
+ markers
+}
+
+/// The bullet of a list paragraph, in shape coordinates. It hangs before the
+/// text box's start edge, level with the first line, a gap away that grows
+/// with the font size.
+///
+/// An empty paragraph has no style run, so it takes the paint of `previous`
+/// (the last list item painted in the same pass) and its own span's size.
+pub fn list_marker(
+ skia_paragraph: &skia::textlayout::Paragraph,
+ text_paragraph: &TextParagraph,
+ x: f32,
+ y_accum: f32,
+ layout_width: f32,
+ previous: Option<&ListMarker>,
+) -> Option {
+ if text_paragraph.list_style_type() == ListStyleType::None {
+ return None;
+ }
+
+ let line_metrics = skia_paragraph.get_line_metrics();
+ let line = line_metrics.first()?;
+ let style_metrics = line.get_style_metrics(line.start_index..line.end_index);
+ let (paint, font_size) = match style_metrics.first() {
+ Some((_, metrics)) => {
+ let mut paint = metrics.text_style.foreground();
+ paint.set_anti_alias(true);
+ (paint, metrics.text_style.font_size())
+ }
+ None => {
+ let font_size = text_paragraph.children().first()?.font_size;
+ (previous?.paint.clone(), font_size)
+ }
+ };
+
+ let radius = (font_size * LIST_MARKER_RADIUS_FACTOR).max(LIST_MARKER_MIN_RADIUS);
+ let offset = radius + font_size * LIST_MARKER_GAP_FACTOR;
+ let center_x = if text_paragraph.text_direction() == TextDirection::RTL {
+ x + layout_width + offset
+ } else {
+ x - offset
+ };
+ let baseline = y_accum + line.baseline as f32;
+
+ Some(ListMarker {
+ paint,
+ center: Point::new(center_x, baseline - font_size * LIST_MARKER_CENTER_FACTOR),
+ radius,
+ })
+}
+
fn draw_decoration_segment(canvas: &Canvas, deco: &TextDecorationSegment) {
let mut decoration_paint = deco.text_style.foreground();
decoration_paint.set_anti_alias(true);
@@ -1235,7 +1337,12 @@ fn calculate_decoration_metrics(
#[cfg(test)]
mod tests {
use super::*;
- use crate::shapes::{FontFamily, FontStyle, TextAlign, TextDirection, TextSpan, TextTransform};
+ use crate::globals::TestRenderResourcesGuard;
+ use crate::render::RenderResources;
+ use crate::shapes::{
+ Fill, FontFamily, FontStyle, GrowType, ListStyleType, SolidColor, TextAlign, TextDirection,
+ TextSpan, TextTransform, Type,
+ };
use crate::uuid::Uuid;
fn span(
@@ -1319,4 +1426,320 @@ mod tests {
assert!(decorated_span_ranges(¶).is_empty());
}
+
+ fn sized_span(text: &str, font_size: f32, fills: Vec) -> TextSpan {
+ TextSpan::new(
+ text.to_string(),
+ FontFamily::new(Uuid::nil(), 400, FontStyle::Normal),
+ font_size,
+ 1.2,
+ 0.0,
+ None,
+ None,
+ TextDirection::LTR,
+ 400,
+ Uuid::nil(),
+ fills,
+ )
+ }
+
+ fn list_paragraph(
+ text: &str,
+ font_size: f32,
+ direction: TextDirection,
+ list_style_type: ListStyleType,
+ ) -> TextParagraph {
+ TextParagraph::new(
+ TextAlign::Left,
+ direction,
+ None,
+ None,
+ 1.2,
+ 0.0,
+ vec![sized_span(text, font_size, vec![])],
+ )
+ .with_list_style_type(list_style_type)
+ }
+
+ fn filled_list_item(text: &str, font_size: f32, color: skia::Color) -> TextParagraph {
+ TextParagraph::new(
+ TextAlign::Left,
+ TextDirection::LTR,
+ None,
+ None,
+ 1.2,
+ 0.0,
+ vec![sized_span(
+ text,
+ font_size,
+ vec![Fill::Solid(SolidColor(color))],
+ )],
+ )
+ .with_list_style_type(ListStyleType::Disc)
+ }
+
+ /// A fixed text at (10, 20), `width` wide, laid out into paragraph layouts.
+ fn layout_paragraphs(paragraphs: Vec, width: f32) -> Vec {
+ let selrect = crate::math::Rect::from_xywh(10.0, 20.0, width, 200.0);
+ let mut content = TextContent::new(selrect, GrowType::Fixed);
+ for paragraph in paragraphs {
+ content.add_paragraph(paragraph);
+ }
+ let mut shape = Shape::new(Uuid::nil());
+ shape.set_shape_type(Type::Text(content.clone()));
+ shape.set_selrect(10.0, 20.0, 10.0 + width, 220.0);
+
+ let mut builders = content.paragraph_builder_group_from_text(None);
+ calculate_text_layout_data(&shape, &content, &mut builders, true).paragraphs
+ }
+
+ fn first_baseline(layout: &ParagraphLayout) -> f32 {
+ layout.y + layout.paragraph.get_line_metrics()[0].baseline as f32
+ }
+
+ #[test]
+ fn list_marker_hangs_before_the_first_line() {
+ let mut resources = RenderResources::try_new_headless().expect("headless resources");
+ let _guard = TestRenderResourcesGuard::install(&mut resources);
+ let layouts = layout_paragraphs(
+ vec![list_paragraph(
+ "item",
+ 20.0,
+ TextDirection::LTR,
+ ListStyleType::Disc,
+ )],
+ 300.0,
+ );
+
+ let marker = layouts[0]
+ .marker
+ .as_ref()
+ .expect("disc paragraph has a marker");
+ // Font size 20: radius 3.2, gap 6.6, center 6.4 above the baseline.
+ assert!((marker.center.x - (10.0 - 9.8)).abs() < 0.01, "{marker:?}");
+ assert!(
+ (marker.center.y - (first_baseline(&layouts[0]) - 6.4)).abs() < 0.01,
+ "{marker:?}"
+ );
+ assert!((marker.radius - 3.2).abs() < 0.01, "{marker:?}");
+ }
+
+ #[test]
+ fn rtl_list_marker_hangs_after_the_text_box() {
+ let mut resources = RenderResources::try_new_headless().expect("headless resources");
+ let _guard = TestRenderResourcesGuard::install(&mut resources);
+ let layouts = layout_paragraphs(
+ vec![list_paragraph(
+ "item",
+ 20.0,
+ TextDirection::RTL,
+ ListStyleType::Disc,
+ )],
+ 300.0,
+ );
+
+ let marker = layouts[0]
+ .marker
+ .as_ref()
+ .expect("disc paragraph has a marker");
+ assert!(
+ (marker.center.x - (10.0 + 300.0 + 9.8)).abs() < 0.01,
+ "{marker:?}"
+ );
+ }
+
+ #[test]
+ fn wrapped_list_paragraph_has_one_marker_on_its_first_line() {
+ let mut resources = RenderResources::try_new_headless().expect("headless resources");
+ let _guard = TestRenderResourcesGuard::install(&mut resources);
+ let layouts = layout_paragraphs(
+ vec![list_paragraph(
+ "one two three four five six",
+ 20.0,
+ TextDirection::LTR,
+ ListStyleType::Disc,
+ )],
+ 60.0,
+ );
+
+ assert!(layouts[0].paragraph.get_line_metrics().len() > 1);
+ let marker = layouts[0]
+ .marker
+ .as_ref()
+ .expect("disc paragraph has a marker");
+ assert!(
+ (marker.center.y - (first_baseline(&layouts[0]) - 6.4)).abs() < 0.01,
+ "{marker:?}"
+ );
+ }
+
+ #[test]
+ fn list_marker_keeps_a_gap_before_the_text_at_any_size() {
+ let mut resources = RenderResources::try_new_headless().expect("headless resources");
+ let _guard = TestRenderResourcesGuard::install(&mut resources);
+ for font_size in [4.0, 14.0, 72.0, 300.0] {
+ let layouts = layout_paragraphs(
+ vec![list_paragraph(
+ "item",
+ font_size,
+ TextDirection::LTR,
+ ListStyleType::Disc,
+ )],
+ 2000.0,
+ );
+
+ let marker = layouts[0]
+ .marker
+ .as_ref()
+ .expect("disc paragraph has a marker");
+ // The text starts at x = 10; the gap grows with the font size.
+ assert!(
+ marker.rect().right() <= 10.0 - font_size * 0.32,
+ "font size {font_size}: {marker:?}"
+ );
+ }
+ }
+
+ /// The color painted at `point` when `paragraph` is painted through the
+ /// cached layout of a 200x100 text at (40, 40).
+ fn cached_paint_color_at(
+ paragraph: TextParagraph,
+ point: impl Fn(&ParagraphLayout) -> Point,
+ ) -> skia::Color {
+ let selrect = crate::math::Rect::from_xywh(40.0, 40.0, 200.0, 100.0);
+ let mut content = TextContent::new(selrect, GrowType::Fixed);
+ content.add_paragraph(paragraph);
+ let mut shape = Shape::new(Uuid::nil());
+ shape.set_shape_type(Type::Text(content));
+ shape.set_selrect(40.0, 40.0, 240.0, 140.0);
+
+ let content = shape.get_text_content().clone();
+ let mut builders = content.paragraph_builder_group_from_text(None);
+ let layouts = calculate_text_layout_data(&shape, &content, &mut builders, true);
+ let at = point(&layouts.paragraphs[0]);
+
+ let mut surface = skia::surfaces::raster_n32_premul((300, 200)).expect("raster surface");
+ surface.canvas().clear(skia::Color::TRANSPARENT);
+ let painted =
+ try_paint_from_layout_cache(None, Some(surface.canvas()), &shape, None, false)
+ .expect("cached paint");
+ assert!(painted, "the layout cache should be usable");
+
+ let info = skia::ImageInfo::new(
+ (1, 1),
+ skia::ColorType::RGBA8888,
+ skia::AlphaType::Unpremul,
+ None,
+ );
+ let mut px = [0u8; 4];
+ assert!(surface.image_snapshot().read_pixels(
+ &info,
+ &mut px,
+ 4,
+ (at.x as i32, at.y as i32),
+ skia::image::CachingHint::Disallow
+ ));
+ skia::Color::from_argb(px[3], px[0], px[1], px[2])
+ }
+
+ #[test]
+ fn cached_paint_draws_the_list_marker() {
+ let mut resources = RenderResources::try_new_headless().expect("headless resources");
+ let _guard = TestRenderResourcesGuard::install(&mut resources);
+ let color =
+ cached_paint_color_at(filled_list_item("item", 30.0, skia::Color::RED), |layout| {
+ layout
+ .marker
+ .as_ref()
+ .expect("disc paragraph has a marker")
+ .center
+ });
+ assert_eq!(color, skia::Color::RED);
+ }
+
+ #[test]
+ fn cached_paint_draws_no_marker_for_plain_text() {
+ let mut resources = RenderResources::try_new_headless().expect("headless resources");
+ let _guard = TestRenderResourcesGuard::install(&mut resources);
+ // Where a 30px bullet would be: radius 4.8 plus a 9.9 gap before x = 40.
+ let paragraph = filled_list_item("item", 30.0, skia::Color::RED)
+ .with_list_style_type(ListStyleType::None);
+ let color = cached_paint_color_at(paragraph, |layout| {
+ Point::new(40.0 - 14.7, first_baseline(layout) - 9.6)
+ });
+ assert_eq!(color.a(), 0, "{color:?}");
+ }
+
+ #[test]
+ fn plain_paragraph_has_no_marker() {
+ let mut resources = RenderResources::try_new_headless().expect("headless resources");
+ let _guard = TestRenderResourcesGuard::install(&mut resources);
+ let layouts = layout_paragraphs(
+ vec![list_paragraph(
+ "item",
+ 20.0,
+ TextDirection::LTR,
+ ListStyleType::None,
+ )],
+ 300.0,
+ );
+
+ assert!(layouts[0].marker.is_none());
+ }
+
+ #[test]
+ fn small_text_marker_keeps_a_minimum_radius() {
+ let mut resources = RenderResources::try_new_headless().expect("headless resources");
+ let _guard = TestRenderResourcesGuard::install(&mut resources);
+ let layouts = layout_paragraphs(
+ vec![list_paragraph(
+ "item",
+ 4.0,
+ TextDirection::LTR,
+ ListStyleType::Disc,
+ )],
+ 300.0,
+ );
+
+ let marker = layouts[0]
+ .marker
+ .as_ref()
+ .expect("disc paragraph has a marker");
+ assert!((marker.radius - 1.5).abs() < 0.01, "{marker:?}");
+ }
+
+ #[test]
+ fn empty_list_paragraph_borrows_the_previous_item_paint() {
+ let mut resources = RenderResources::try_new_headless().expect("headless resources");
+ let _guard = TestRenderResourcesGuard::install(&mut resources);
+ let layouts = layout_paragraphs(
+ vec![
+ filled_list_item("item", 20.0, skia::Color::RED),
+ filled_list_item("", 10.0, skia::Color::BLUE),
+ ],
+ 300.0,
+ );
+
+ let empty = layouts[1].marker.as_ref().expect("empty item has a marker");
+ // Its own size, the previous item's paint.
+ assert!((empty.radius - 1.6).abs() < 0.01, "{empty:?}");
+ assert_eq!(empty.paint.color(), skia::Color::RED);
+ }
+
+ #[test]
+ fn empty_first_list_paragraph_has_no_marker() {
+ let mut resources = RenderResources::try_new_headless().expect("headless resources");
+ let _guard = TestRenderResourcesGuard::install(&mut resources);
+ let layouts = layout_paragraphs(
+ vec![list_paragraph(
+ "",
+ 20.0,
+ TextDirection::LTR,
+ ListStyleType::Disc,
+ )],
+ 300.0,
+ );
+
+ assert!(layouts[0].marker.is_none());
+ }
}
diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs
index a8328be0d1..d3a15eb34c 100644
--- a/render-wasm/src/shapes/text.rs
+++ b/render-wasm/src/shapes/text.rs
@@ -1,4 +1,4 @@
-use crate::render::text::decoration_segments;
+use crate::render::text::{decoration_segments, list_marker, list_markers};
use crate::{
math::{Bounds, Matrix, Rect},
render::{default_font, DEFAULT_EMOJI_FONT},
@@ -309,6 +309,34 @@ impl TextContentLayout {
}
}
+// List bullet geometry, in font sizes, as Chrome draws a CSS disc marker
+// (measured with Source Sans Pro).
+pub const LIST_MARKER_RADIUS_FACTOR: f32 = 0.16;
+pub const LIST_MARKER_MIN_RADIUS: f32 = 1.5;
+/// Space between the bullet and the text box's start edge.
+pub const LIST_MARKER_GAP_FACTOR: f32 = 0.33;
+/// How far above the baseline the bullet's center sits.
+pub const LIST_MARKER_CENTER_FACTOR: f32 = 0.32;
+
+/// A list bullet, painted with the style of the first run on its line.
+#[derive(Debug, Clone)]
+pub struct ListMarker {
+ pub paint: skia::Paint,
+ pub center: Point,
+ pub radius: f32,
+}
+
+impl ListMarker {
+ pub fn rect(&self) -> Rect {
+ Rect::new(
+ self.center.x - self.radius,
+ self.center.y - self.radius,
+ self.center.x + self.radius,
+ self.center.y + self.radius,
+ )
+ }
+}
+
#[derive(Debug, Clone)]
pub struct TextDecorationSegment {
#[allow(dead_code)]
@@ -626,11 +654,40 @@ impl TextContent {
}
}
- if has_lines {
- Some(Rect::from_ltrb(min_x, min_y, max_x, max_y))
- } else {
- None
+ if !has_lines {
+ return None;
}
+
+ let mut rect = Rect::from_ltrb(min_x, min_y, max_x, max_y);
+ if let Some(markers) = self.list_markers_rect(selrect, valign) {
+ rect.join(markers);
+ }
+ Some(rect)
+ }
+
+ /// Union of the list bullets, which hang outside the text box.
+ fn list_markers_rect(&self, selrect: &Rect, valign: VerticalAlign) -> Option {
+ let paragraphs = &self.layout.paragraphs;
+ let total_height: f32 = paragraphs
+ .iter()
+ .filter_map(|group| group.first())
+ .map(|p| p.height())
+ .sum();
+ let top = selrect.y() + vertical_align_offset(selrect.height(), total_height, valign);
+
+ list_markers(
+ paragraphs.iter().map(|group| group.first()),
+ self.paragraphs(),
+ selrect.x(),
+ top,
+ self.get_width(selrect.width()),
+ )
+ .iter()
+ .map(ListMarker::rect)
+ .reduce(|mut rect, marker_rect| {
+ rect.join(marker_rect);
+ rect
+ })
}
fn compute_and_cache_extrect(
@@ -643,7 +700,11 @@ impl TextContent {
// (line.left) reflect alignment within that huge width and are
// unusable for tight bounds. Fall back to content_rect.
if self.grow_type() == GrowType::AutoWidth {
- return self.content_rect(selrect, valign);
+ let mut rect = self.content_rect(selrect, valign);
+ if let Some(markers) = self.list_markers_rect(selrect, valign) {
+ rect.join(markers);
+ }
+ return rect;
}
let layout_matches_container = self
@@ -1396,6 +1457,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 +1474,7 @@ pub struct Paragraph {
text_transform: Option,
line_height: f32,
letter_spacing: f32,
+ list_style_type: ListStyleType,
children: Vec,
}
@@ -1418,6 +1487,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 +1511,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 +1633,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();
@@ -1857,6 +1937,7 @@ pub struct ParagraphLayout {
pub x: f32,
pub y: f32,
pub decorations: Vec,
+ pub marker: Option,
}
#[derive(Debug)]
@@ -1920,20 +2001,39 @@ pub fn calculate_text_layout_data(
vertical_align_offset(selrect_height, total_text_height, shape.vertical_align());
let mut paragraph_layouts: Vec = Vec::new();
let mut y_accum = base_y + vertical_offset;
+ // Last list marker of each builder, so an empty item borrows its pass' paint.
+ let mut previous_markers: Vec