mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 22:10:34 +00:00
✨ 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:
parent
28068f0503
commit
6a83babc76
@ -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}
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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\""))))
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user