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/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 "