🎉 Add basic bullet lists to the v3 text editor (#12720)

* 🎉 Add UI for toggling bullet lists (under config flag)

* 🎉 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

* ✨ Draw bullets for list paragraphs

A paragraph with list-style-type "disc" now gets a solid round bullet,
sized as Chrome draws a CSS disc marker (measured with Source Sans Pro):
a radius of 0.16 font sizes, centered 0.32 font sizes above the first
baseline, with a gap of 0.33 font sizes before the text. The bullet
hangs outside the text box, before its start edge (after it for RTL),
like CSS list-style-position: outside, so the text layout, caret and
selection stay as they were.

The bullet takes the paint of its line's first style run, so fills,
strokes, shadows and masks apply to it as they do to the glyphs. An
empty item, which has no run, borrows the previous item's paint. The
cached paint, the text extrect and text-to-path share one walker over
the paragraphs, so they always agree: the extrect grows to hold the
bullets, like shadows and blur, and flattening a text keeps them.

Refs #12630

AI-assisted-by: claude-opus-5-5

* ✨ Show the list style in Inspect

Text paragraphs with list-style-type "disc" now show a "List Style" row
in the Inspect Styles and Computed views, after Text Transform. Its copy
action gives "list-style-type: disc;". Text without a list shows no row.
The list item check lives in common as list-paragraph?, so every place
that needs it shares one rule.

Refs #12630

AI-assisted-by: claude-opus-5-5

* ✨ Generate list HTML and CSS for bullet paragraphs

In the Inspect code, neighbouring paragraphs with list-style-type
"disc" become one <ul> with an <li> each, keeping the paragraph's class
and dir. Other paragraphs stay <p>. Only code mode groups them; text
rendering still uses <p>.

The CSS sets list-style-type on the items and resets the list's margin
and padding, so the browser hangs the bullet before the text as the
canvas does. Paragraph rules set font-size: 0, so each item also gets a
::marker rule with its first span's font size and text color. A
::marker can't paint a gradient, so a gradient fill gives its first
stop.

Refs #12630

AI-assisted-by: claude-opus-5-5
This commit is contained in:
Belén Albeza 2026-11-12 11:50:05 +01:00 committed by GitHub
parent 00e77a0f53
commit 86a8ac34cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1419 additions and 57 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

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

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

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

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

View File

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

View File

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

View File

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

View File

@ -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 `<ul>` of `<li>`."
[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}

View File

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

View File

@ -59,7 +59,8 @@
.text-direction-options,
.vertical-align-options,
.grow-options,
.text-decoration-options {
.text-decoration-options,
.list-options {
block-size: $sz-32;
}

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

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

View File

@ -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 "<li dir=\"auto\" class=\"root-0-paragraph-set-0-paragraph-1\">")))))
(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 <p>"
(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 "<li dir=\"rtl\""))))

View File

@ -154,6 +154,7 @@
[frontend-tests.ui.history-test]
[frontend-tests.ui.inspect-geometry-test]
[frontend-tests.ui.inspect-stroke-tokens-test]
[frontend-tests.ui.inspect-text-list-test]
[frontend-tests.ui.jobs-progress-test]
[frontend-tests.ui.layout-container-multiple-test]
[frontend-tests.ui.mcp-authorize-test]
@ -357,6 +358,7 @@
'frontend-tests.ui.history-test
'frontend-tests.ui.inspect-geometry-test
'frontend-tests.ui.inspect-stroke-tokens-test
'frontend-tests.ui.inspect-text-list-test
'frontend-tests.ui.jobs-progress-test
'frontend-tests.ui.layout-container-multiple-test
'frontend-tests.ui.measurements-test

View File

@ -0,0 +1,61 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.ui.inspect-text-list-test
(:require
["react-dom/server" :as rds]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.ui.hooks :as hooks]
[app.main.ui.inspect.attributes.text :as attributes-text]
[app.main.ui.inspect.styles.panels.text :as styles-text]
[cljs.test :as t :include-macros true]
[cuerdas.core :as str]
[frontend-tests.helpers.mock :as mock]
[rumext.v2 :as mf]))
(defn- text-shape
[paragraph-attrs]
{:id (uuid/next)
:type :text
:content {:type "root"
:children [{:type "paragraph-set"
:children [(merge {:type "paragraph"
:children [{:text "Item"
:font-id "sourcesanspro"
:font-family "sourcesanspro"
:font-size "14"}]}
paragraph-attrs)]}]}})
(defn- render-panel
[panel shapes]
(rds/renderToStaticMarkup
(mf/element panel
#js {:shapes shapes
:resolvedTokens {}
:colorSpace "hex"
:onFontShorthand identity})))
(t/deftest text-panels-show-the-list-style
(with-redefs [cf/flags (conj cf/flags :inspect-styles)
hooks/use-portal-container (mock/stub (fn [_] nil))]
(let [shapes [(text-shape {:list-style-type "disc"})]]
(t/testing "styles tab"
(let [markup (render-panel styles-text/text-panel* shapes)]
(t/is (str/includes? markup "List Style"))
(t/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")))))))

View File

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

View File

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

View File

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

View File

@ -0,0 +1,24 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="281.5" height="122" viewBox="0 0 281.5 122"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style></defs>
<text fill="red" transform="translate(-19.5 1)" font-size="30" font-family="Source Sans Pro" x="40, 57.636719, 73.955078, 81.601562, 89.248047, 103.69141" y="27">
Bullet
</text>
<ellipse fill="red" transform="translate(-19.5 1)" cx="25.299999" cy="17.540627" rx="4.7999992" ry="4.7999992"/>
<text fill="red" transform="translate(-19.5 1)" font-size="30" font-family="Source Sans Pro" x="40, 57.270508, 64.916992, 80.268555, 87.636719" y="63">
Plain
</text>
<defs/>
<text fill="none" stroke="blue" stroke-width="2" stroke-miterlimit="4" transform="translate(-19.5 1)" font-size="30" font-family="Source Sans Pro" x="40, 57.636719, 73.955078, 81.601562, 89.248047, 103.69141" y="27">
Bullet
</text>
<defs/>
<ellipse fill="none" stroke="blue" stroke-width="2" stroke-miterlimit="4" transform="translate(-19.5 1)" cx="25.299999" cy="17.540627" rx="4.7999992" ry="4.7999992"/>
<defs/>
<text fill="none" stroke="blue" stroke-width="2" stroke-miterlimit="4" transform="translate(-19.5 1)" font-size="30" font-family="Source Sans Pro" x="40, 57.270508, 64.916992, 80.268555, 87.636719" y="63">
Plain
</text>
</svg>

View File

@ -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("<ellipse").count(), 2, "{svg}");
assert!(svg.contains("<ellipse fill=\"red\""), "{svg}");
assert!(svg.contains("stroke=\"blue\""), "{svg}");
insta::assert_snapshot!(svg);
}
#[test]
fn exports_rect_with_solid_inner_stroke() {
let mut pool = ShapesPool::new();

View File

@ -1,11 +1,13 @@
use super::{filters, RenderState, Shape, SurfaceId, DEFAULT_EMOJI_FONT};
use crate::{
error::Result,
math::Rect,
math::{Point, Rect},
shapes::{
add_text_with_tabs, calculate_text_layout_data, set_paint_fill, vertical_align_offset,
Paragraph as TextParagraph, ParagraphBuilderGroup, ParagraphLayout, Stroke, StrokeKind,
TextContent, TextDecorationSegment,
ListMarker, ListStyleType, Paragraph as TextParagraph, ParagraphBuilderGroup,
ParagraphLayout, Stroke, StrokeKind, TextContent, TextDecorationSegment, TextDirection,
LIST_MARKER_CENTER_FACTOR, LIST_MARKER_GAP_FACTOR, LIST_MARKER_MIN_RADIUS,
LIST_MARKER_RADIUS_FACTOR,
},
utils::{get_fallback_fonts, get_font_collection},
};
@ -371,6 +373,7 @@ fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextC
let x = anchor.x;
let base_y = anchor.y;
let layout_width = text_content.get_width(selrect.width());
let paragraphs = &text_content.layout.paragraphs;
let draw_decorations = text_content.has_text_decorations();
@ -382,7 +385,8 @@ fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextC
let vertical_offset =
vertical_align_offset(selrect.height(), total_text_height, shape.vertical_align());
let mut y_accum = base_y + vertical_offset;
let top = base_y + vertical_offset;
let mut y_accum = top;
for (index, group) in paragraphs.iter().enumerate() {
let Some(paragraph) = group.first() else {
continue;
@ -398,6 +402,16 @@ fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextC
y_accum += paragraph.height();
}
for marker in list_markers(
paragraphs.iter().map(|group| group.first()),
text_content.paragraphs(),
x,
top,
layout_width,
) {
draw_list_marker(canvas, &marker);
}
if needs_translate {
canvas.restore();
}
@ -533,6 +547,10 @@ fn paint_text_with_emoji_overlay(
paint_emoji_overlay(canvas, para);
}
if let Some(marker) = &para.marker {
draw_list_marker(canvas, marker);
}
for deco in &para.decorations {
draw_decoration_segment(canvas, deco);
}
@ -1069,6 +1087,90 @@ pub fn render_outer_stroke(
)
}
fn draw_list_marker(canvas: &Canvas, marker: &ListMarker) {
canvas.draw_circle(marker.center, marker.radius, &marker.paint);
}
/// Bullets of paragraphs laid out one under another, from `top` down. `None`
/// stands for a paragraph with no laid-out Skia paragraph; it keeps the
/// paragraphs in step with `text_paragraphs`.
pub fn list_markers<'a>(
skia_paragraphs: impl IntoIterator<Item = Option<&'a skia::textlayout::Paragraph>>,
text_paragraphs: &[TextParagraph],
x: f32,
top: f32,
layout_width: f32,
) -> Vec<ListMarker> {
let mut markers: Vec<ListMarker> = 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<ListMarker> {
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(&para).is_empty());
}
fn sized_span(text: &str, font_size: f32, fills: Vec<Fill>) -> 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<TextParagraph>, width: f32) -> Vec<ParagraphLayout> {
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());
}
}

View File

@ -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<Rect> {
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<TextTransform>,
line_height: f32,
letter_spacing: f32,
list_style_type: ListStyleType,
children: Vec<TextSpan>,
}
@ -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<TextDecorationSegment>,
pub marker: Option<ListMarker>,
}
#[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<ParagraphLayout> = 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<Option<ListMarker>> = Vec::new();
for (i, group_paragraphs) in built_groups.into_iter().enumerate() {
// For each paragraph in the group (e.g., fill, stroke, etc.)
for skia_paragraph in group_paragraphs.into_iter() {
for (builder_index, skia_paragraph) in group_paragraphs.into_iter().enumerate() {
if previous_markers.len() <= builder_index {
previous_markers.resize(builder_index + 1, None);
}
let decorations = text_paragraphs
.get(i)
.map(|text_paragraph| {
decoration_segments(&skia_paragraph, text_paragraph, x, y_accum)
})
.unwrap_or_default();
let marker = text_paragraphs.get(i).and_then(|text_paragraph| {
list_marker(
&skia_paragraph,
text_paragraph,
x,
y_accum,
text_width,
previous_markers[builder_index].as_ref(),
)
});
if marker.is_some() {
previous_markers[builder_index] = marker.clone();
}
paragraph_layouts.push(ParagraphLayout {
paragraph: skia_paragraph,
x,
y: y_accum,
decorations,
marker,
});
}
y_accum += paragraph_heights[i];
@ -2404,6 +2504,119 @@ mod tests {
shape
}
/// A laid-out 200x100 text at the origin with one "hello" paragraph.
fn list_text_shape(
grow_type: GrowType,
direction: TextDirection,
list_style_type: ListStyleType,
) -> Shape {
let mut paragraph = test_paragraph(&["hello"]).with_list_style_type(list_style_type);
paragraph.text_direction = direction;
let mut content = TextContent::new(Rect::from_xywh(0.0, 0.0, 200.0, 100.0), grow_type);
content.add_paragraph(paragraph);
let mut shape = Shape::new(Uuid::nil());
shape.set_shape_type(shapes::Type::Text(content));
shape.set_selrect(0.0, 0.0, 200.0, 100.0);
shape
}
#[test]
fn extrect_holds_the_painted_marker_when_vertically_aligned() {
let mut resources = RenderResources::try_new_headless().expect("headless resources");
let _guard = TestRenderResourcesGuard::install(&mut resources);
for valign in [VerticalAlign::Center, VerticalAlign::Bottom] {
let mut shape =
list_text_shape(GrowType::Fixed, TextDirection::LTR, ListStyleType::Disc);
shape.set_vertical_align(valign);
let content = shape.get_text_content().clone();
let mut builders = content.paragraph_builder_group_from_text(None);
let layout = calculate_text_layout_data(&shape, &content, &mut builders, true);
let marker = layout.paragraphs[0]
.marker
.as_ref()
.expect("disc paragraph has a marker")
.rect();
let extrect = text_extrect(&shape);
assert!(
extrect.left() <= marker.left()
&& extrect.top() <= marker.top()
&& extrect.right() >= marker.right()
&& extrect.bottom() >= marker.bottom(),
"{valign:?}: {marker:?} outside {extrect:?}"
);
// With top alignment the marker would sit near y = 0.
assert!(marker.top() > 10.0, "{valign:?}: {marker:?}");
}
}
fn text_extrect(shape: &Shape) -> Rect {
shape
.get_text_content()
.calculate_bounds(shape, false)
.to_rect()
}
// Font size 14: a gap of 4.62 and a radius of 2.24.
const MARKER_REACH: f32 = 4.62 + 2.0 * 2.24;
#[test]
fn extrect_includes_a_list_marker_before_the_text() {
let mut resources = RenderResources::try_new_headless().expect("headless resources");
let _guard = TestRenderResourcesGuard::install(&mut resources);
for grow_type in [GrowType::Fixed, GrowType::AutoHeight, GrowType::AutoWidth] {
let shape = list_text_shape(grow_type, TextDirection::LTR, ListStyleType::Disc);
let extrect = text_extrect(&shape);
assert!(
extrect.left() <= -MARKER_REACH + 0.01,
"{grow_type:?}: {extrect:?}"
);
assert_eq!(shape.selrect().left(), 0.0);
}
}
#[test]
fn extrect_includes_an_rtl_list_marker_after_the_text() {
let mut resources = RenderResources::try_new_headless().expect("headless resources");
let _guard = TestRenderResourcesGuard::install(&mut resources);
let shape = list_text_shape(GrowType::Fixed, TextDirection::RTL, ListStyleType::Disc);
let extrect = text_extrect(&shape);
assert!(
extrect.right() >= 200.0 + MARKER_REACH - 0.01,
"{extrect:?}"
);
}
#[test]
fn extrect_of_plain_text_stays_inside_the_text_box() {
let mut resources = RenderResources::try_new_headless().expect("headless resources");
let _guard = TestRenderResourcesGuard::install(&mut resources);
let shape = list_text_shape(GrowType::Fixed, TextDirection::LTR, ListStyleType::None);
let extrect = text_extrect(&shape);
assert!(extrect.left() >= 0.0, "{extrect:?}");
}
#[test]
fn extrect_grows_when_bullets_are_turned_on() {
let mut resources = RenderResources::try_new_headless().expect("headless resources");
let _guard = TestRenderResourcesGuard::install(&mut resources);
let mut shape = list_text_shape(GrowType::Fixed, TextDirection::LTR, ListStyleType::None);
assert!(text_extrect(&shape).left() >= 0.0);
let selrect = shape.selrect;
if let shapes::Type::Text(content) = &mut shape.shape_type {
let paragraph = content.paragraphs_mut()[0]
.clone()
.with_list_style_type(ListStyleType::Disc);
content.paragraphs_mut()[0] = paragraph;
content.update_layout(selrect);
}
assert!(text_extrect(&shape).left() <= -MARKER_REACH + 0.01);
}
/// Whether resizing `shape` to `width`x`height`, with no content change,
/// rebuilt its cached Skia paragraphs.
fn resize_rebuilds_paragraphs(shape: &mut Shape, width: f32, height: f32) -> bool {

View File

@ -1,4 +1,4 @@
use crate::render::text::decoration_segments;
use crate::render::text::{decoration_segments, list_markers};
use crate::shapes::text::{vertical_align_offset, Paragraph, TextContent};
use crate::shapes::VerticalAlign;
use skia_safe::{
@ -29,10 +29,25 @@ impl TextPaths {
.collect();
let total_height: f32 = paragraphs.iter().map(|p| p.height()).sum();
let mut offset_y = self.bounds.y()
let top = self.bounds.y()
+ vertical_align_offset(self.bounds.height(), total_height, vertical_align);
let mut paths = Vec::new();
let mut paths: Vec<skia::Path> = list_markers(
paragraphs.iter().map(Some),
self.0.paragraphs(),
self.bounds.x(),
top,
layout_width,
)
.into_iter()
.map(|marker| {
let mut builder = skia::PathBuilder::new();
builder.add_circle(marker.center, marker.radius, None);
builder.detach()
})
.collect();
let mut offset_y = top;
for (paragraph, text_paragraph) in paragraphs.iter_mut().zip(self.0.paragraphs()) {
let origin = Point::new(self.bounds.x(), offset_y);
Self::collect_paragraph_paths(paragraph, text_paragraph, origin, &mut paths);
@ -86,3 +101,107 @@ impl Deref for TextPaths {
&self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::globals::TestRenderResourcesGuard;
use crate::math::Rect;
use crate::render::RenderResources;
use crate::shapes::{
FontFamily, FontStyle, GrowType, ListStyleType, TextAlign, TextDirection, TextSpan,
};
use crate::uuid::Uuid;
/// Paths of a 200x100 text at the origin, one 14px paragraph per entry.
fn paths_of_paragraphs(
paragraphs: Vec<(&str, TextDirection, ListStyleType)>,
) -> Vec<skia::Path> {
let mut content =
TextContent::new(Rect::from_xywh(0.0, 0.0, 200.0, 100.0), GrowType::Fixed);
for (text, direction, list_style_type) in paragraphs {
let span = TextSpan::new(
text.to_string(),
FontFamily::new(Uuid::nil(), 400, FontStyle::Normal),
14.0,
1.2,
0.0,
None,
None,
direction,
400,
Uuid::nil(),
vec![],
);
content.add_paragraph(
Paragraph::new(TextAlign::Left, direction, None, None, 1.2, 0.0, vec![span])
.with_list_style_type(list_style_type),
);
}
TextPaths::new(content).get_paths(VerticalAlign::Top)
}
fn paths_of(list_style_type: ListStyleType) -> Vec<skia::Path> {
paths_of_paragraphs(vec![("hello", TextDirection::LTR, list_style_type)])
}
/// Bounds of the paths that lie wholly outside the 200px-wide text box.
fn bullet_bounds(paths: Vec<skia::Path>) -> Vec<Rect> {
paths
.into_iter()
.map(|path| *path.bounds())
.filter(|bounds| bounds.right() < 0.0 || bounds.left() > 200.0)
.collect()
}
#[test]
fn list_paragraph_paths_include_the_bullet() {
let mut resources = RenderResources::try_new_headless().expect("headless resources");
let _guard = TestRenderResourcesGuard::install(&mut resources);
// Font size 14: the bullet is centered at x = -6.86 with radius 2.24.
let bullet = paths_of(ListStyleType::Disc)
.into_iter()
.map(|path| *path.bounds())
.find(|bounds| bounds.right() < 0.0)
.expect("a path before the text box");
assert!((bullet.center_x() + 6.86).abs() < 0.01, "{bullet:?}");
assert!((bullet.width() - 4.48).abs() < 0.01, "{bullet:?}");
}
#[test]
fn empty_list_item_paths_include_its_bullet() {
let mut resources = RenderResources::try_new_headless().expect("headless resources");
let _guard = TestRenderResourcesGuard::install(&mut resources);
let bullets = bullet_bounds(paths_of_paragraphs(vec![
("hello", TextDirection::LTR, ListStyleType::Disc),
("", TextDirection::LTR, ListStyleType::Disc),
]));
assert_eq!(bullets.len(), 2, "{bullets:?}");
}
#[test]
fn rtl_list_paragraph_paths_put_the_bullet_after_the_text_box() {
let mut resources = RenderResources::try_new_headless().expect("headless resources");
let _guard = TestRenderResourcesGuard::install(&mut resources);
let bullets = bullet_bounds(paths_of_paragraphs(vec![(
"hello",
TextDirection::RTL,
ListStyleType::Disc,
)]));
assert_eq!(bullets.len(), 1, "{bullets:?}");
// Font size 14: radius 2.24 plus a 4.62 gap past the right edge.
assert!(
(bullets[0].center_x() - (200.0 + 6.86)).abs() < 0.01,
"{bullets:?}"
);
}
#[test]
fn plain_paragraph_paths_have_no_bullet() {
let mut resources = RenderResources::try_new_headless().expect("headless resources");
let _guard = TestRenderResourcesGuard::install(&mut resources);
assert!(paths_of(ListStyleType::None)
.iter()
.all(|path| path.bounds().left() >= 0.0));
}
}

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

@ -815,6 +815,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();
@ -836,7 +837,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);
@ -847,7 +849,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 {
@ -941,6 +943,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)
})