diff --git a/frontend/src/app/main/ui/inspect/attributes/layout_element.cljs b/frontend/src/app/main/ui/inspect/attributes/layout_element.cljs
index 2810e8f96d..87b3cb1d0c 100644
--- a/frontend/src/app/main/ui/inspect/attributes/layout_element.cljs
+++ b/frontend/src/app/main/ui/inspect/attributes/layout_element.cljs
@@ -61,7 +61,7 @@
only-flex?
"Flex element"
only-grid?
- "Flex element"
+ "Grid element"
:else
"Layout element")]
diff --git a/frontend/src/app/main/ui/inspect/code.cljs b/frontend/src/app/main/ui/inspect/code.cljs
index 8f7d6a8bb4..57f6ea10b8 100644
--- a/frontend/src/app/main/ui/inspect/code.cljs
+++ b/frontend/src/app/main/ui/inspect/code.cljs
@@ -87,12 +87,12 @@
value map))
(defn gen-all-code
- [style-code markup-code images-data]
+ [style-code markup-code images-data fonts-data]
(let [markup-code (cond-> markup-code
embed-images? (replace-map images-data))
style-code (cond-> style-code
- embed-images? (replace-map images-data))]
+ embed-images? (replace-map (merge images-data fonts-data)))]
(str/format page-template style-code markup-code)))
(mf/defc code*
@@ -101,11 +101,13 @@
markup-type* (mf/use-state "html")
fontfaces-css* (mf/use-state nil)
images-data* (mf/use-state nil)
+ fonts-data* (mf/use-state nil)
style-type (deref style-type*)
markup-type (deref markup-type*)
fontfaces-css (deref fontfaces-css*)
images-data (deref images-data*)
+ fonts-data (deref fonts-data*)
collapsed* (mf/use-state #{})
collapsed-css? (contains? @collapsed* :css)
@@ -199,9 +201,9 @@
handle-copy-all-code
(mf/use-fn
- (mf/deps style-code markup-code images-data)
+ (mf/deps style-code markup-code images-data fonts-data)
(fn []
- (clipboard/to-clipboard (gen-all-code style-code markup-code images-data))
+ (clipboard/to-clipboard (gen-all-code style-code markup-code images-data fonts-data))
(let [origin (if (= :workspace from)
"workspace"
"viewer")]
@@ -228,8 +230,8 @@
(conj collapsed panel-type)))))))
copy-css-fn
(mf/use-fn
- (mf/deps style-code images-data)
- #(replace-map style-code images-data))
+ (mf/deps style-code images-data fonts-data)
+ #(replace-map style-code (merge images-data fonts-data)))
copy-html-fn
(mf/use-fn
@@ -237,24 +239,41 @@
#(replace-map markup-code images-data))]
(mf/with-effect [fonts]
- (->> (rx/from fonts)
- (rx/merge-map fonts/fetch-font-css)
- (rx/reduce conj [])
- (rx/subs!
- (fn [result]
- (let [css (str/join "\n" result)]
- (reset! fontfaces-css* css))))))
+ (let [sub (->> (rx/from fonts)
+ (rx/merge-map fonts/fetch-font-css)
+ (rx/reduce conj [])
+ (rx/subs!
+ (fn [result]
+ (let [css (str/join "\n" result)]
+ (reset! fontfaces-css* css)))))]
+ #(rx/dispose! sub)))
+
+ ;; Resolve the font URLs to data URIs. The inspect view keeps the original
+ ;; URLs (more readable), but copying embeds the fonts so the styles render
+ ;; outside of Penpot, where the original URLs require auth/CORS.
+ (mf/with-effect [fontfaces-css]
+ (let [sub (->> (rx/from (fonts/extract-fontface-urls (or fontfaces-css "")))
+ (rx/merge-map
+ (fn [uri]
+ (->> (http/fetch-data-uri uri true)
+ (rx/catch (fn [_] (rx/of (hash-map uri uri)))))))
+ (rx/reduce conj {})
+ (rx/subs!
+ (fn [result]
+ (reset! fonts-data* result))))]
+ #(rx/dispose! sub)))
(mf/with-effect [images-urls]
- (->> (rx/from images-urls)
- (rx/merge-map
- (fn [[_ uri]]
- (->> (http/fetch-data-uri uri true)
- (rx/catch (fn [_] (rx/of (hash-map uri uri)))))))
- (rx/reduce conj {})
- (rx/subs!
- (fn [result]
- (reset! images-data* result)))))
+ (let [sub (->> (rx/from images-urls)
+ (rx/merge-map
+ (fn [[_ uri]]
+ (->> (http/fetch-data-uri uri true)
+ (rx/catch (fn [_] (rx/of (hash-map uri uri)))))))
+ (rx/reduce conj {})
+ (rx/subs!
+ (fn [result]
+ (reset! images-data* result))))]
+ #(rx/dispose! sub)))
[:div {:class (stl/css-case :element-options true
:viewer-code-block (= :viewer from))}
diff --git a/frontend/src/app/util/code_gen/markup_html.cljs b/frontend/src/app/util/code_gen/markup_html.cljs
index 890aae9ba4..896c024a00 100644
--- a/frontend/src/app/util/code_gen/markup_html.cljs
+++ b/frontend/src/app/util/code_gen/markup_html.cljs
@@ -38,7 +38,7 @@
indent))
(cfh/text-shape? shape)
- (let [text-shape-html (rds/renderToStaticMarkup (mf/element text/text-shape* #js {:shape shape :is-code true}))
+ (let [text-shape-html (rds/renderToStaticMarkup (mf/element text/text-shape* #js {:shape shape :isCode true}))
text-shape-html (str/replace text-shape-html #"style\s*=\s*[\"'][^\"']*[\"']" "")]
(dm/fmt "%
\n%\n%
"
indent
diff --git a/frontend/src/app/util/code_gen/style_css.cljs b/frontend/src/app/util/code_gen/style_css.cljs
index e2d6d36478..f2b8e76418 100644
--- a/frontend/src/app/util/code_gen/style_css.cljs
+++ b/frontend/src/app/util/code_gen/style_css.cljs
@@ -50,7 +50,10 @@ body {
(def shape-wrapper-css-properties
#{:flex-shrink
- :margin
+ :margin-block-start
+ :margin-block-end
+ :margin-inline-start
+ :margin-inline-end
:max-height
:min-height
:max-width
@@ -113,7 +116,6 @@ body {
;; Flex/grid self properties
:flex-shrink
- :margin
:margin-block-start
:margin-block-end
:margin-inline-start
diff --git a/frontend/src/app/util/code_gen/style_css_values.cljs b/frontend/src/app/util/code_gen/style_css_values.cljs
index 9bd9b787f3..9f53bea5ed 100644
--- a/frontend/src/app/util/code_gen/style_css_values.cljs
+++ b/frontend/src/app/util/code_gen/style_css_values.cljs
@@ -35,13 +35,25 @@
parent-value (dm/get-in parent [:selrect coord])
+ ;; In CSS an absolutely positioned element is placed relative to the
+ ;; parent's padding box (i.e. inside its border), but Penpot
+ ;; coordinates are relative to the border box. We discount the parent
+ ;; border width so the element keeps its position when the parent has
+ ;; a border.
+ parent-stroke (first (:strokes parent))
+ border-width (if (and (some? parent-stroke)
+ (not= :none (:stroke-style parent-stroke))
+ (not (cgc/svg-markup? parent)))
+ (d/nilv (:stroke-width parent-stroke) 0)
+ 0)
+
[selrect _ _]
(-> (:points shape)
(gsh/transform-points (gsh/shape->center parent) (:transform-inverse parent (gmt/matrix)))
(gsh/calculate-geometry))
shape-value (get selrect coord)]
- (- shape-value parent-value))))
+ (- shape-value parent-value border-width))))
(defn get-shape-size
[shape objects type]
@@ -62,8 +74,13 @@
(and (ctl/flex-layout-immediate-child? objects shape) (= sizing :fill))
nil
- (or (and (ctl/any-layout? shape) (= sizing :auto) (not (cgc/svg-markup? shape)))
- (and (ctl/grid-layout-immediate-child? objects shape) (= sizing :fill)))
+ ;; Grid fill children stretch to fill their cell (minus margins) via
+ ;; justify-self/align-self: stretch, so we avoid emitting an explicit
+ ;; 100% size that would overflow the track when a margin is present.
+ (and (ctl/grid-layout-immediate-child? objects shape) (= sizing :fill))
+ nil
+
+ (and (ctl/any-layout? shape) (= sizing :auto) (not (cgc/svg-markup? shape)))
sizing
(some? (:selrect shape))
@@ -412,8 +429,10 @@
(defn- get-margin
[{:keys [layout-item-margin] :as shape} objects]
-
- (when (ctl/any-layout-immediate-child? objects shape)
+ ;; Absolutely positioned children are out of the layout flow, so their
+ ;; margin must not be emitted.
+ (when (and (ctl/any-layout-immediate-child? objects shape)
+ (not (ctl/position-absolute? shape)))
(let [default-margin {:m1 0 :m2 0 :m3 0 :m4 0}
{:keys [m1 m2 m3 m4]} (merge default-margin layout-item-margin)]
(when (or (not= m1 0) (not= m2 0) (not= m3 0) (not= m4 0))
@@ -421,22 +440,22 @@
(defn- get-margin-block-start
[{:keys [layout-item-margin] :as shape} objects]
- (when (and (ctl/any-layout-immediate-child? objects shape) (:m1 layout-item-margin) (not= (:m1 layout-item-margin) 0))
+ (when (and (ctl/any-layout-immediate-child? objects shape) (not (ctl/position-absolute? shape)) (:m1 layout-item-margin) (not= (:m1 layout-item-margin) 0))
[(:m1 layout-item-margin)]))
(defn- get-margin-inline-end
[{:keys [layout-item-margin] :as shape} objects]
- (when (and (ctl/any-layout-immediate-child? objects shape) (:m2 layout-item-margin) (not= (:m2 layout-item-margin) 0))
+ (when (and (ctl/any-layout-immediate-child? objects shape) (not (ctl/position-absolute? shape)) (:m2 layout-item-margin) (not= (:m2 layout-item-margin) 0))
[(:m2 layout-item-margin)]))
(defn- get-margin-block-end
[{:keys [layout-item-margin] :as shape} objects]
- (when (and (ctl/any-layout-immediate-child? objects shape) (:m3 layout-item-margin) (not= (:m3 layout-item-margin) 0))
+ (when (and (ctl/any-layout-immediate-child? objects shape) (not (ctl/position-absolute? shape)) (:m3 layout-item-margin) (not= (:m3 layout-item-margin) 0))
[(:m3 layout-item-margin)]))
(defn- get-margin-inline-start
[{:keys [layout-item-margin] :as shape} objects]
- (when (and (ctl/any-layout-immediate-child? objects shape) (:m4 layout-item-margin) (not= (:m4 layout-item-margin) 0))
+ (when (and (ctl/any-layout-immediate-child? objects shape) (not (ctl/position-absolute? shape)) (:m4 layout-item-margin) (not= (:m4 layout-item-margin) 0))
[(:m4 layout-item-margin)]))
@@ -488,7 +507,10 @@
(let [parent (get objects (:parent-id shape))
cell (ctl/get-cell-by-shape-id parent (:id shape))
align-self (:align-self cell)]
- (when (not= align-self :auto) align-self))))
+ (cond
+ (not= align-self :auto) align-self
+ ;; Fill children rely on stretch to fill the cell minus their margins.
+ (= :fill (:layout-item-v-sizing shape)) :stretch))))
(defn- get-justify-self
[shape objects]
@@ -497,7 +519,10 @@
(let [parent (get objects (:parent-id shape))
cell (ctl/get-cell-by-shape-id parent (:id shape))
justify-self (:justify-self cell)]
- (when (not= justify-self :auto) justify-self))))
+ (cond
+ (not= justify-self :auto) justify-self
+ ;; Fill children rely on stretch to fill the cell minus their margins.
+ (= :fill (:layout-item-h-sizing shape)) :stretch))))
(defn- get-grid-auto-flow
[shape]
diff --git a/frontend/test/frontend_tests/code_gen_style_test.cljs b/frontend/test/frontend_tests/code_gen_style_test.cljs
new file mode 100644
index 0000000000..4f04dab929
--- /dev/null
+++ b/frontend/test/frontend_tests/code_gen_style_test.cljs
@@ -0,0 +1,196 @@
+;; 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 INC
+
+(ns frontend-tests.code-gen-style-test
+ "Regression tests for the inspect code-generation (HTML/CSS export).
+
+ Each test guards against a concrete bug found in the CSS/HTML generation
+ of layout children and text shapes."
+ (:require
+ [app.common.geom.matrix :as gmt]
+ [app.common.geom.point :as gpt]
+ [app.common.geom.rect :as grc]
+ [app.common.uuid :as uuid]
+ [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]))
+
+;; --- Builders ------------------------------------------------------------
+
+(defn- pts
+ "Rectangular point ring matching a x/y/w/h box."
+ [x y w h]
+ [(gpt/point x y)
+ (gpt/point (+ x w) y)
+ (gpt/point (+ x w) (+ y h))
+ (gpt/point x (+ y h))])
+
+(defn- frame
+ [id & {:as extra}]
+ (merge {:id id :name "Board" :type :frame
+ :parent-id uuid/zero :frame-id uuid/zero
+ :selrect (grc/make-rect 0 0 200 200)
+ :points (pts 0 0 200 200)}
+ extra))
+
+(defn- grid-frame
+ "A grid board with a single auto cell that holds `child-id`."
+ [id child-id & {:as extra}]
+ (let [cell-id (uuid/next)]
+ (merge (frame id)
+ {:name "Grid"
+ :layout :grid
+ :layout-grid-rows [{:type :flex :value 1}]
+ :layout-grid-columns [{:type :flex :value 1}]
+ :layout-grid-cells {cell-id {:id cell-id :row 1 :column 1
+ :row-span 1 :column-span 1
+ :position :auto
+ :align-self :auto :justify-self :auto
+ :shapes [child-id]}}}
+ extra)))
+
+(defn- child
+ [id parent-id & {:as extra}]
+ (merge {:id id :name "Child" :type :rect :parent-id parent-id
+ :selrect (grc/make-rect 0 0 50 50)
+ :points (pts 0 0 50 50)
+ :transform (gmt/matrix)}
+ extra))
+
+(defn- objects [& shapes]
+ (into {} (map (juxt :id identity)) shapes))
+
+(def ^:private sample-margin
+ {:m1 10 :m2 20 :m3 30 :m4 40})
+
+;; --- Margins on layout children -----------------------------------------
+
+(deftest grid-child-margins-use-logical-longhand
+ (testing "margins of grid children map to logical longhand properties"
+ (let [pid (uuid/next)
+ cid (uuid/next)
+ c (child cid pid
+ :layout-item-margin sample-margin
+ :layout-item-margin-type :multiple)
+ objs (objects (grid-frame pid cid) c)]
+ (is (= "10px" (css/get-css-value objs c :margin-block-start)))
+ (is (= "20px" (css/get-css-value objs c :margin-inline-end)))
+ (is (= "30px" (css/get-css-value objs c :margin-block-end)))
+ (is (= "40px" (css/get-css-value objs c :margin-inline-start))))))
+
+(deftest grid-child-css-has-no-redundant-margin-shorthand
+ (testing "the generated rule emits logical longhand margins, not a shorthand"
+ (let [pid (uuid/next)
+ cid (uuid/next)
+ c (child cid pid
+ :layout-item-margin sample-margin
+ :layout-item-margin-type :multiple)
+ out (css/get-shape-css-selector (objects (grid-frame pid cid) c) c)]
+ (is (str/includes? out "margin-block-start: 10px;"))
+ (is (not (re-find #"margin:" out))
+ "must not emit the `margin` shorthand alongside the longhand props"))))
+
+(deftest wrapped-layout-child-margin-only-on-wrapper
+ (testing "a rotated (wrapped) child keeps margins on the wrapper, not the inner element"
+ (let [pid (uuid/next)
+ cid (uuid/next)
+ c (child cid pid
+ :layout-item-margin {:m1 10 :m2 0 :m3 0 :m4 0}
+ :layout-item-margin-type :multiple
+ :transform (gmt/rotate-matrix 30))
+ out (css/get-shape-css-selector (objects (grid-frame pid cid) c) c)
+ ;; the inner element is the only rule carrying the transform matrix
+ inner (->> (str/split out "}")
+ (filter #(str/includes? % "transform:"))
+ (first))]
+ (is (str/includes? out "-wrapper {"))
+ (is (str/includes? out "margin-block-start: 10px;")
+ "margin is emitted (on the wrapper)")
+ (is (some? inner))
+ (is (not (str/includes? inner "margin"))
+ "the inner transformed element must not double the margin"))))
+
+(deftest absolute-positioned-child-has-no-margin
+ (testing "absolutely positioned layout children drop their margin"
+ (let [pid (uuid/next)
+ cid (uuid/next)
+ c (child cid pid
+ :layout-item-margin sample-margin
+ :layout-item-margin-type :multiple
+ :layout-item-absolute true)
+ objs (objects (grid-frame pid cid) c)]
+ (is (nil? (css/get-css-value objs c :margin)))
+ (is (nil? (css/get-css-value objs c :margin-block-start))))))
+
+;; --- Grid fill sizing ----------------------------------------------------
+
+(deftest grid-fill-child-stretches-instead-of-fixed-size
+ (testing "fill-sized grid children rely on stretch instead of width/height: 100%"
+ (let [pid (uuid/next)
+ cid (uuid/next)
+ c (child cid pid
+ :layout-item-h-sizing :fill
+ :layout-item-v-sizing :fill)
+ objs (objects (grid-frame pid cid) c)]
+ (is (nil? (css/get-css-value objs c :width))
+ "no explicit width: 100% that would overflow the cell with a margin")
+ (is (nil? (css/get-css-value objs c :height)))
+ (is (= "stretch" (css/get-css-value objs c :justify-self)))
+ (is (= "stretch" (css/get-css-value objs c :align-self))))))
+
+;; --- Absolute positioning vs parent border -------------------------------
+
+(deftest absolute-position-discounts-parent-border
+ (testing "absolute coords are measured from the padding box, so the parent border is discounted"
+ (let [pid (uuid/next)
+ cid (uuid/next)
+ parent (frame pid :strokes [{:stroke-width 80
+ :stroke-style :solid
+ :stroke-color "#000000"
+ :stroke-alignment :inner}])
+ c (child cid pid
+ :selrect (grc/make-rect 100 100 50 50)
+ :points (pts 100 100 50 50))
+ objs (objects parent c)]
+ ;; left/top = 100 (shape) - 0 (parent) - 80 (border) = 20
+ (is (= "20px" (css/get-css-value objs c :left)))
+ (is (= "20px" (css/get-css-value objs c :top))))))
+
+(deftest absolute-position-without-border-is-unchanged
+ (testing "without a parent border the absolute coords are the raw offset"
+ (let [pid (uuid/next)
+ cid (uuid/next)
+ c (child cid pid
+ :selrect (grc/make-rect 100 100 50 50)
+ :points (pts 100 100 50 50))
+ objs (objects (frame pid) c)]
+ (is (= "100px" (css/get-css-value objs c :left)))
+ (is (= "100px" (css/get-css-value objs c :top))))))
+
+;; --- Text node markup ----------------------------------------------------
+
+(def ^:private text-content
+ {:type "root"
+ :children [{:type "paragraph-set"
+ :children [{:type "paragraph"
+ :children [{:text "Hello"
+ :fills [{:fill-color "#000000" :fill-opacity 1}]}]}]}]})
+
+(deftest text-markup-emits-node-id-classes
+ (testing "generated text markup carries the $id classes the CSS rules target"
+ (let [tid (uuid/next)
+ text {:id tid :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 text-content}
+ markup (html/generate-markup (objects text) [text])]
+ (is (string? markup))
+ (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")))))
diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs
index a46a20f85e..f52364e2ed 100644
--- a/frontend/test/frontend_tests/runner.cljs
+++ b/frontend/test/frontend_tests/runner.cljs
@@ -5,6 +5,7 @@
[clojure.string :as str]
[clojure.tools.cli :refer [parse-opts]]
[frontend-tests.basic-shapes-test]
+ [frontend-tests.code-gen-style-test]
[frontend-tests.copy-as-svg-test]
[frontend-tests.data.nitrate-test]
[frontend-tests.data.repo-test]
@@ -57,6 +58,7 @@
(def test-namespaces
'[frontend-tests.basic-shapes-test
+ frontend-tests.code-gen-style-test
frontend-tests.copy-as-svg-test
frontend-tests.data.nitrate-test
frontend-tests.data.repo-test