mirror of
https://github.com/penpot/penpot.git
synced 2026-10-31 06:50:33 +00:00
Merge remote-tracking branch 'origin/staging' into develop
This commit is contained in:
commit
94836cb8c8
@ -1419,38 +1419,46 @@
|
||||
WHERE file_id = ANY(?::uuid[])")
|
||||
|
||||
(def ^:private sql:restore-storage-objects
|
||||
"WITH ids AS (SELECT unnest(?::uuid[]) AS file_id)
|
||||
|
||||
UPDATE storage_object
|
||||
;; Collect referenced storage ids first (UUID set), then join by PK.
|
||||
;; Casting `storage_object.id` to text (or OR-ing that with `id IN`) forces
|
||||
;; a scan of deleted rows; cast the JSON side instead.
|
||||
"WITH ids AS (SELECT unnest(?::uuid[]) AS file_id),
|
||||
refs AS (
|
||||
SELECT fmo.media_id AS id
|
||||
FROM file_media_object AS fmo
|
||||
JOIN ids ON (ids.file_id = fmo.file_id)
|
||||
WHERE fmo.deleted_at IS NULL
|
||||
AND fmo.media_id IS NOT NULL
|
||||
UNION
|
||||
SELECT fmo.thumbnail_id
|
||||
FROM file_media_object AS fmo
|
||||
JOIN ids ON (ids.file_id = fmo.file_id)
|
||||
WHERE fmo.deleted_at IS NULL
|
||||
AND fmo.thumbnail_id IS NOT NULL
|
||||
UNION
|
||||
SELECT ft.media_id
|
||||
FROM file_thumbnail AS ft
|
||||
JOIN ids ON (ids.file_id = ft.file_id)
|
||||
WHERE ft.deleted_at IS NULL
|
||||
AND ft.media_id IS NOT NULL
|
||||
UNION
|
||||
SELECT fot.media_id
|
||||
FROM file_tagged_object_thumbnail AS fot
|
||||
JOIN ids ON (ids.file_id = fot.file_id)
|
||||
WHERE fot.deleted_at IS NULL
|
||||
AND fot.media_id IS NOT NULL
|
||||
UNION
|
||||
SELECT (fd.metadata->>'storage-ref-id')::uuid
|
||||
FROM file_data AS fd
|
||||
JOIN ids ON (ids.file_id = fd.file_id)
|
||||
WHERE fd.deleted_at IS NULL
|
||||
AND (fd.metadata->>'storage-ref-id') IS NOT NULL
|
||||
)
|
||||
UPDATE storage_object AS so
|
||||
SET deleted_at = null
|
||||
WHERE deleted_at IS NOT NULL
|
||||
AND (id IN (SELECT media_id
|
||||
FROM file_media_object AS fmo
|
||||
JOIN ids ON (ids.file_id = fmo.file_id)
|
||||
WHERE fmo.deleted_at IS NULL
|
||||
AND fmo.media_id IS NOT NULL
|
||||
UNION
|
||||
SELECT thumbnail_id
|
||||
FROM file_media_object AS fmo
|
||||
JOIN ids ON (ids.file_id = fmo.file_id)
|
||||
WHERE fmo.deleted_at IS NULL
|
||||
AND fmo.thumbnail_id IS NOT NULL
|
||||
UNION
|
||||
SELECT ft.media_id
|
||||
FROM file_thumbnail AS ft
|
||||
JOIN ids ON (ids.file_id = ft.file_id)
|
||||
WHERE ft.deleted_at IS NULL
|
||||
AND ft.media_id IS NOT NULL
|
||||
UNION
|
||||
SELECT fot.media_id
|
||||
FROM file_tagged_object_thumbnail AS fot
|
||||
JOIN ids ON (ids.file_id = fot.file_id)
|
||||
WHERE fot.deleted_at IS NULL
|
||||
AND fot.media_id IS NOT NULL)
|
||||
OR id::text IN (SELECT fd.metadata->>'storage-ref-id'
|
||||
FROM file_data AS fd
|
||||
JOIN ids ON (ids.file_id = fd.file_id)
|
||||
WHERE fd.deleted_at IS NULL))")
|
||||
FROM refs
|
||||
WHERE so.id = refs.id
|
||||
AND so.deleted_at IS NOT NULL")
|
||||
|
||||
(defn- restore-files
|
||||
[conn file-ids]
|
||||
|
||||
@ -235,7 +235,7 @@
|
||||
(ctm/move-modifiers (displacement center-before center-after before-side-vector after-side-vector))))
|
||||
|
||||
(defmethod constraint-modifier :default [_ _ _ _ _ _]
|
||||
[])
|
||||
(ctm/empty))
|
||||
|
||||
(def const->type+axis
|
||||
{:left :start
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.files.variant :as cfv]
|
||||
[app.common.geom.matrix :as gmt]
|
||||
[app.common.geom.modifiers :as gm]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.geom.rect :as grc]
|
||||
[app.common.geom.shapes :as gsh]
|
||||
@ -27,6 +28,7 @@
|
||||
[app.common.types.container :as ctn]
|
||||
[app.common.types.file :as ctf]
|
||||
[app.common.types.library :as ctl]
|
||||
[app.common.types.modifiers :as ctm]
|
||||
[app.common.types.page :as ctp]
|
||||
[app.common.types.pages-list :as ctpl]
|
||||
[app.common.types.path.segment :as segment]
|
||||
@ -577,15 +579,19 @@
|
||||
(container-log :debug (:id container) :msg "Sync page in local file" :page-id (:id container))
|
||||
(container-log :debug (:id container) :msg "Sync component in local library" :component-id (:id container)))
|
||||
|
||||
(let [linked-shapes (->> (vals (:objects container))
|
||||
(filter #(uses-assets? asset-type asset-id % library-id)))]
|
||||
(let [objects (:objects container)
|
||||
;; Deepest first, so a copy places its swapped children after their own sync
|
||||
linked-shapes (->> (vals objects)
|
||||
(filter #(uses-assets? asset-type asset-id % library-id))
|
||||
(sort-by #(- (count (cfh/get-parent-ids objects (:id %))))))]
|
||||
(loop [shapes (seq linked-shapes)
|
||||
changes (-> changes
|
||||
(pcb/with-container container)
|
||||
(pcb/with-objects (:objects container)))]
|
||||
(pcb/with-objects objects))]
|
||||
(if-let [shape (first shapes)]
|
||||
(recur (next shapes)
|
||||
(let [objects' (pcb/get-objects changes)
|
||||
(let [changes (pcb/apply-changes-local changes)
|
||||
objects' (pcb/get-objects changes)
|
||||
shape' (get objects' (:id shape))]
|
||||
;; The shape could have been deleted in previous changes, if this nested component
|
||||
;; comes from components-v1 era or if there has been some error with the swap slot.
|
||||
@ -594,7 +600,7 @@
|
||||
(generate-sync-shape asset-type
|
||||
changes
|
||||
library-id
|
||||
container
|
||||
(assoc container :objects objects')
|
||||
shape'
|
||||
libraries
|
||||
current-file-id)
|
||||
@ -832,7 +838,8 @@
|
||||
root-inst
|
||||
root-main
|
||||
reset?
|
||||
initial-root?)
|
||||
initial-root?
|
||||
nil)
|
||||
|
||||
;; If the component is not found, because the master component has been
|
||||
;; deleted or the library unlinked, do nothing.
|
||||
@ -866,8 +873,88 @@
|
||||
(recur shape-inst' next-data component')
|
||||
nil))))))
|
||||
|
||||
(defn- geometry-sync-delta
|
||||
"Offset the sync applies to the top-left of `dest-shape` when it copies the
|
||||
geometry of `origin-shape`."
|
||||
[dest-shape origin-shape dest-root origin-root]
|
||||
(gpt/subtract (grc/rect->point (:selrect (reposition-shape origin-shape origin-root dest-root)))
|
||||
(grc/rect->point (:selrect dest-shape))))
|
||||
|
||||
(defn- follow-parent-modifiers
|
||||
"Modifiers that make `child` follow the change of its parent from
|
||||
`parent-before` to `parent-after`, through the child constraints."
|
||||
[child parent-before parent-after]
|
||||
(let [sr-before (:selrect parent-before)
|
||||
sr-after (:selrect parent-after)
|
||||
modifiers (-> (ctm/empty)
|
||||
(ctm/resize (gpt/point (/ (:width sr-after) (max 0.01 (:width sr-before)))
|
||||
(/ (:height sr-after) (max 0.01 (:height sr-before))))
|
||||
(first (:points parent-before))
|
||||
(:transform parent-before)
|
||||
(:transform-inverse parent-before))
|
||||
(ctm/move (gpt/subtract (first (:points parent-after))
|
||||
(first (:points parent-before)))))]
|
||||
;; A move doesn't need the constraints
|
||||
(if (or (ctm/empty? modifiers) (ctm/only-move? modifiers))
|
||||
modifiers
|
||||
(gsh/calc-child-modifiers parent-before child modifiers false
|
||||
(:points child)
|
||||
(:points parent-before)
|
||||
(delay (:points parent-after))))))
|
||||
|
||||
(defn- follow-parent-modif-tree
|
||||
"Modifiers that make `child` and its children follow the change of the
|
||||
parent of `child` from `parent-before` to `parent-after`, each one through
|
||||
its own constraints."
|
||||
[objects child parent-before parent-after]
|
||||
(let [modifiers (follow-parent-modifiers child parent-before parent-after)]
|
||||
(if (ctm/empty? modifiers)
|
||||
{}
|
||||
(gm/set-objects-modifiers {(:id child) {:modifiers modifiers}}
|
||||
(select-keys objects (cfh/get-children-ids-with-self objects (:id child)))))))
|
||||
|
||||
(defn- resize-with-parent
|
||||
"Resizes `shape` the way its constraints do when its parent changes from
|
||||
`parent-before` to `parent-after`, keeping its top-left."
|
||||
[shape parent-before parent-after]
|
||||
(let [modifiers (follow-parent-modifiers shape parent-before parent-after)]
|
||||
(if (ctm/empty? modifiers)
|
||||
shape
|
||||
(let [shape' (gsh/transform-shape shape modifiers)]
|
||||
(gsh/move shape' (gpt/subtract (grc/rect->point (:selrect shape))
|
||||
(grc/rect->point (:selrect shape'))))))))
|
||||
|
||||
(defn- update-geometry-untouched
|
||||
"Applies `update-fn` to the shapes without marking them touched."
|
||||
[changes ids update-fn container]
|
||||
(pcb/concat-changes
|
||||
changes
|
||||
(-> (pcb/empty-changes)
|
||||
(pcb/with-container container)
|
||||
(pcb/with-objects (:objects container))
|
||||
(pcb/update-shapes ids update-fn {:ignore-touched true}))))
|
||||
|
||||
(defn generate-follow-parent
|
||||
"Makes `child` and its children follow the change of the parent of `child`
|
||||
from `parent-before` to `parent-after`, each one through its own
|
||||
constraints, without marking them touched. `delta-fn`, if given, receives
|
||||
`child` after that and returns the offset to move the subtree by."
|
||||
[changes container child parent-before parent-after delta-fn]
|
||||
(let [objects (:objects container)
|
||||
modif-tree (follow-parent-modif-tree objects child parent-before parent-after)
|
||||
follow #(gsh/transform-shape % (dm/get-in modif-tree [(:id %) :modifiers]))
|
||||
delta (if delta-fn (delta-fn (follow child)) (gpt/point 0 0))]
|
||||
(cond-> changes
|
||||
(or (seq modif-tree) (not (gpt/almost-zero? delta)))
|
||||
(update-geometry-untouched (cfh/get-children-ids-with-self objects (:id child))
|
||||
#(gsh/move (follow %) delta)
|
||||
container))))
|
||||
|
||||
(defn- generate-sync-shape-direct-recursive
|
||||
[changes container shape-inst component library file libraries shape-main root-inst root-main reset? initial-root?]
|
||||
"Syncs `shape-inst` and its children from `shape-main`. `parent-change` is
|
||||
the geometry of the parent of `shape-inst` before and after the sync, or
|
||||
nil."
|
||||
[changes container shape-inst component library file libraries shape-main root-inst root-main reset? initial-root? parent-change]
|
||||
(shape-log :debug (:id shape-inst) container
|
||||
:msg "Sync shape direct recursive"
|
||||
:shape-inst (str (:name shape-inst) " " (pretty-uuid (:id shape-inst)))
|
||||
@ -879,6 +966,19 @@
|
||||
(let [omit-touched? (not reset?)
|
||||
clear-remote-synced? (and initial-root? reset?)
|
||||
set-remote-synced? (and (not initial-root?) reset?)
|
||||
geometry-kept? (and omit-touched?
|
||||
(ctk/touched-group? shape-inst :geometry-group))
|
||||
main-geometry (reposition-shape shape-main root-main root-inst)
|
||||
follow-modifiers (when (and geometry-kept? (some? parent-change))
|
||||
(let [[parent-before parent-after] parent-change]
|
||||
(follow-parent-modifiers shape-inst parent-before parent-after)))
|
||||
shape-after (cond
|
||||
(not geometry-kept?) main-geometry
|
||||
(some? follow-modifiers) (gsh/transform-shape shape-inst follow-modifiers)
|
||||
:else shape-inst)
|
||||
;; Offset between where the sync leaves shape-inst and where the main puts it
|
||||
main-gap (gpt/subtract (grc/rect->point (:selrect shape-after))
|
||||
(grc/rect->point (:selrect main-geometry)))
|
||||
changes
|
||||
(cond-> changes
|
||||
:always
|
||||
@ -889,6 +989,11 @@
|
||||
container
|
||||
omit-touched?)
|
||||
|
||||
(and (some? follow-modifiers) (not (ctm/empty? follow-modifiers)))
|
||||
(update-geometry-untouched [(:id shape-inst)]
|
||||
#(gsh/transform-shape % follow-modifiers)
|
||||
container)
|
||||
|
||||
(ctsl/flex-layout? shape-main)
|
||||
(update-flex-child-copy-attrs shape-main
|
||||
shape-inst
|
||||
@ -962,15 +1067,20 @@
|
||||
root-inst
|
||||
root-main
|
||||
reset?
|
||||
initial-root?))
|
||||
initial-root?
|
||||
[shape-inst shape-after]))
|
||||
|
||||
swapped (fn [changes child-inst child-main]
|
||||
(shape-log :trace (:id child-inst) container
|
||||
:msg "Match slot"
|
||||
:child-inst (str (:name child-inst) " " (pretty-uuid (:id child-inst)))
|
||||
:child-main (str (:name child-main) " " (pretty-uuid (:id child-main))))
|
||||
;; For now we don't make any sync here.
|
||||
changes)
|
||||
;; A swapped child follows its parent and, if the user didn't move it,
|
||||
;; goes to the place of the slot's occupant in the main
|
||||
(generate-follow-parent changes container child-inst shape-inst shape-after
|
||||
(when-not (ctk/touched-group? child-inst :geometry-group)
|
||||
#(gpt/add (geometry-sync-delta % child-main root-inst root-main)
|
||||
main-gap))))
|
||||
|
||||
moved (fn [changes child-inst child-main]
|
||||
(shape-log :trace (:id child-inst) container
|
||||
@ -2214,7 +2324,10 @@
|
||||
[changes current-shape previous-shape current-root prev-root origin-ref-shape container]
|
||||
(let [;; We need to sync only the position relative to the origin of the component.
|
||||
;; (see update-attrs for a full explanation)
|
||||
previous-shape (reposition-shape previous-shape prev-root current-root)
|
||||
previous-shape (-> previous-shape
|
||||
(resize-with-parent (get (:objects container) (:parent-id previous-shape))
|
||||
(get (pcb/get-objects changes) (:parent-id current-shape)))
|
||||
(reposition-shape prev-root current-root))
|
||||
touched (get previous-shape :touched #{})
|
||||
text-auto? (and (cfh/text-shape? current-shape)
|
||||
(contains? #{:auto-height :auto-width} (:grow-type current-shape)))]
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
[app.common.files.changes-builder :as pcb]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.files.variant :as cfv]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.geom.rect :as grc]
|
||||
[app.common.logic.libraries :as cll]
|
||||
[app.common.logic.shapes :as cls]
|
||||
[app.common.logic.variant-properties :as clvp]
|
||||
@ -72,7 +74,10 @@
|
||||
copy that was swapped (orig-swapped-child), and its related shape on the new copy
|
||||
(related-shape-in-new), move the orig-swapped-child into the parent of
|
||||
related-shape-in-new, fix its swap-slot if needed, and then delete
|
||||
related-shape-in-new"
|
||||
related-shape-in-new.
|
||||
|
||||
orig-swapped-child follows its parent through its constraints and, if the
|
||||
user didn't move it, takes the place of related-shape-in-new."
|
||||
[changes related-shape-in-new orig-swapped-child ldata page swap-ref-id]
|
||||
(let [;; Before to the swap, temporary move the previous
|
||||
;; shape to the root panel to avoid problems when
|
||||
@ -83,15 +88,28 @@
|
||||
(pcb/change-parent uuid/zero [orig-swapped-child] 0 {:allow-altering-copies true}))
|
||||
|
||||
objects (pcb/get-objects changes)
|
||||
page-objects (:objects page)
|
||||
prev-swap-slot (ctk/get-swap-slot orig-swapped-child)
|
||||
current-parent (get objects (:parent-id related-shape-in-new))
|
||||
pos (d/index-of (:shapes current-parent) (:id related-shape-in-new))]
|
||||
pos (d/index-of (:shapes current-parent) (:id related-shape-in-new))
|
||||
|
||||
orig-child (get page-objects (:id orig-swapped-child))
|
||||
;; If the user didn't move it, it goes to the place of related-shape-in-new
|
||||
delta-fn (when-not (ctk/touched-group? orig-child :geometry-group)
|
||||
#(gpt/subtract (grc/rect->point (:selrect related-shape-in-new))
|
||||
(grc/rect->point (:selrect %))))]
|
||||
|
||||
(-> (pcb/concat-changes before-changes changes)
|
||||
|
||||
;; Move the previous shape to the new parent
|
||||
(pcb/change-parent (:parent-id related-shape-in-new) [orig-swapped-child] pos {:allow-altering-copies true})
|
||||
|
||||
(cll/generate-follow-parent (ctn/make-container page :page)
|
||||
orig-child
|
||||
(get page-objects (:parent-id orig-child))
|
||||
current-parent
|
||||
delta-fn)
|
||||
|
||||
;; We need to update the swap slot only when it pointed
|
||||
;; to the swap-ref-id. Oterwise this is a swapped item
|
||||
;; inside a nested copy, so we need to keep it.
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
[app.common.files.changes-builder :as pcb]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.geom.shapes :as gsh]
|
||||
[app.common.logic.libraries :as cll]
|
||||
[app.common.logic.shapes :as cls]
|
||||
[app.common.logic.variants :as clv]
|
||||
@ -443,6 +444,33 @@
|
||||
:propagate-fn propagate-fn
|
||||
:library library)))
|
||||
|
||||
(defn change-shape
|
||||
"Applies `update-fn` to the shape identified by shape-tag. With
|
||||
`ignore-touched` the change is not an override, as when a layout changes it."
|
||||
[file shape-tag update-fn & {:keys [ignore-touched]}]
|
||||
(let [page (thf/current-page file)]
|
||||
(thf/apply-changes
|
||||
file
|
||||
(cls/generate-update-shapes (pcb/empty-changes nil (:id page))
|
||||
#{(thi/id shape-tag)}
|
||||
update-fn
|
||||
(:objects page)
|
||||
{:ignore-touched ignore-touched}))))
|
||||
|
||||
(defn move-shape-tree
|
||||
"Moves the shape identified by shape-tag and its children by `delta`. With
|
||||
`ignore-touched` the move is not an override, as when a layout moves it."
|
||||
[file shape-tag delta & {:keys [ignore-touched]}]
|
||||
(let [page (thf/current-page file)
|
||||
objects (:objects page)]
|
||||
(thf/apply-changes
|
||||
file
|
||||
(cls/generate-update-shapes (pcb/empty-changes nil (:id page))
|
||||
(cfh/get-children-ids-with-self objects (thi/id shape-tag))
|
||||
#(gsh/move % delta)
|
||||
objects
|
||||
{:ignore-touched ignore-touched}))))
|
||||
|
||||
(defn update-color
|
||||
"Update the first fill color for the shape identified by shape-tag"
|
||||
[file shape-tag color & {:keys [page-label propagate-fn]}]
|
||||
|
||||
@ -7,21 +7,11 @@
|
||||
(ns common-tests.geom-shapes-constraints-test
|
||||
(:require
|
||||
[app.common.geom.shapes.constraints :as gsc]
|
||||
[app.common.types.modifiers :as ctm]
|
||||
[clojure.test :as t]))
|
||||
|
||||
;; ---- constraint-modifier :default ----
|
||||
|
||||
(t/deftest constraint-modifier-default-returns-empty-vector
|
||||
(t/testing ":default method accepts 6 args and returns an empty vector"
|
||||
;; Before the fix the :default method only accepted 5 positional args
|
||||
;; (plus the dispatch value), so calling it with 6 args would throw an
|
||||
;; arity error. After the fix it takes [_ _ _ _ _ _] and returns [].
|
||||
(let [result (gsc/constraint-modifier :unknown-constraint-type
|
||||
:x nil nil nil nil)]
|
||||
(t/is (vector? result))
|
||||
(t/is (empty? result))))
|
||||
|
||||
(t/testing ":default method returns [] for :scale-like unknown type on :y axis"
|
||||
(let [result (gsc/constraint-modifier :some-other-unknown
|
||||
:y nil nil nil nil)]
|
||||
(t/is (= [] result)))))
|
||||
(t/deftest constraint-modifier-default-returns-empty-modifiers
|
||||
(t/is (= (ctm/empty)
|
||||
(gsc/constraint-modifier :unknown-constraint-type :x nil nil nil nil))))
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.geom.shapes :as gsh]
|
||||
[app.common.logic.libraries :as cll]
|
||||
[app.common.logic.shapes :as cls]
|
||||
[app.common.math :as mth]
|
||||
[app.common.test-helpers.components :as thc]
|
||||
[app.common.test-helpers.compositions :as tho]
|
||||
@ -19,6 +18,7 @@
|
||||
[app.common.test-helpers.shapes :as ths]
|
||||
[app.common.types.component :as ctk]
|
||||
[app.common.types.container :as ctn]
|
||||
[app.common.types.modifiers :as ctm]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/use-fixtures :each thi/test-fixture)
|
||||
@ -39,17 +39,6 @@
|
||||
(ths/add-interaction :main-child :main-popup)
|
||||
(ths/add-interaction :copy-child :copy-popup)))
|
||||
|
||||
(defn- update-shape
|
||||
[file label update-fn]
|
||||
(let [page (thf/current-page file)]
|
||||
(thf/apply-changes
|
||||
file
|
||||
(cls/generate-update-shapes (pcb/empty-changes nil (:id page))
|
||||
#{(thi/id label)}
|
||||
update-fn
|
||||
(:objects page)
|
||||
{}))))
|
||||
|
||||
(defn- destinations
|
||||
[shape]
|
||||
(mapv :destination (:interactions shape)))
|
||||
@ -60,8 +49,8 @@
|
||||
margin {:m1 5 :m2 5 :m3 5 :m4 5}
|
||||
|
||||
;; ==== Action
|
||||
updated-file (update-shape file :main-child
|
||||
#(assoc % :hidden true :layout-item-margin margin))
|
||||
updated-file (tho/change-shape file :main-child
|
||||
#(assoc % :hidden true :layout-item-margin margin))
|
||||
changes (cll/generate-sync-file-changes (pcb/empty-changes)
|
||||
nil
|
||||
:components
|
||||
@ -85,7 +74,7 @@
|
||||
file (setup-file)
|
||||
|
||||
;; ==== Action
|
||||
updated-file (update-shape file :copy-child #(assoc % :hidden true))
|
||||
updated-file (tho/change-shape file :copy-child #(assoc % :hidden true))
|
||||
page (thf/current-page updated-file)
|
||||
container (ctn/make-container page :page)
|
||||
changes (-> (pcb/empty-changes)
|
||||
@ -144,7 +133,7 @@
|
||||
|
||||
;; ==== Action
|
||||
library' (-> library
|
||||
(update-shape :main1-child #(gsh/move % (gpt/point 1 0)))
|
||||
(tho/change-shape :main1-child #(gsh/move % (gpt/point 1 0)))
|
||||
(tho/propagate-component-changes :component1))
|
||||
file' (thf/apply-changes file (sync-file-from-library file library'))
|
||||
|
||||
@ -190,7 +179,7 @@
|
||||
:layout :flex
|
||||
:layout-flex-dir :row})
|
||||
(thc/instantiate-component :icon-a :nested-head {:parent-label :board-root})
|
||||
(update-shape :nested-head #(assoc % :layout-item-absolute true))
|
||||
(tho/change-shape :nested-head #(assoc % :layout-item-absolute true))
|
||||
(thc/make-component :board :board-root)))
|
||||
|
||||
(t/deftest test-sync-library-keeps-swapped-nested-absolute
|
||||
@ -205,8 +194,8 @@
|
||||
swapped (ths/get-shape file :copy-nested-head)
|
||||
|
||||
;; ==== Action
|
||||
library' (update-shape library :board-root
|
||||
#(assoc % :fills (ths/sample-fills-color :fill-color "#fabada")))
|
||||
library' (tho/change-shape library :board-root
|
||||
#(assoc % :fills (ths/sample-fills-color :fill-color "#fabada")))
|
||||
file' (thf/apply-changes file (sync-file-from-library file library'))
|
||||
swapped' (ths/get-shape file' :copy-nested-head)]
|
||||
|
||||
@ -229,8 +218,8 @@
|
||||
file (swap-to-library-component file library :copy-nested-head :icon-b)
|
||||
|
||||
;; ==== Action
|
||||
library' (update-shape library :nested-head
|
||||
#(assoc % :layout-item-absolute false))
|
||||
library' (tho/change-shape library :nested-head
|
||||
#(assoc % :layout-item-absolute false))
|
||||
file' (thf/apply-changes file (sync-file-from-library file library'))
|
||||
swapped' (ths/get-shape file' :copy-nested-head)]
|
||||
|
||||
@ -250,7 +239,7 @@
|
||||
:layout-flex-dir :row})
|
||||
(thc/instantiate-component :icon-a :nested-head {:parent-label :board-root})
|
||||
(ths/add-sample-shape :sibling :type :rect :name "Sibling" :parent-label :board-root)
|
||||
(update-shape :nested-head #(assoc % :layout-item-absolute true))
|
||||
(tho/change-shape :nested-head #(assoc % :layout-item-absolute true))
|
||||
(thc/make-component :board :board-root))
|
||||
file (-> (thf/sample-file :file)
|
||||
(thc/instantiate-component :board
|
||||
@ -260,8 +249,8 @@
|
||||
file (swap-to-library-component file library :copy-nested-head :icon-b)
|
||||
|
||||
;; ==== Action
|
||||
library' (update-shape library :sibling
|
||||
#(assoc % :layout-item-margin margin))
|
||||
library' (tho/change-shape library :sibling
|
||||
#(assoc % :layout-item-margin margin))
|
||||
file' (thf/apply-changes file (sync-file-from-library file library'))
|
||||
sibling' (ths/get-shape file' :copy-sibling)
|
||||
swapped' (ths/get-shape file' :copy-nested-head)]
|
||||
@ -270,3 +259,212 @@
|
||||
(t/is (= margin (:layout-item-margin sibling')))
|
||||
(t/is (true? (:layout-item-absolute swapped')))
|
||||
(t/is (some? (ctk/get-swap-slot swapped')))))
|
||||
|
||||
(defn- setup-library-with-icon-slot
|
||||
"Library: icon A, icon B, and a flex board whose `:slot` frame holds an
|
||||
instance of icon A. The slot has a flex layout and the icon is absolute;
|
||||
with `:layout? false` the slot has no layout and the icon is a plain child."
|
||||
[& {:keys [layout?] :or {layout? true}}]
|
||||
(-> (thf/sample-file :library)
|
||||
(tho/add-simple-component :icon-a :icon-a-root :icon-a-child)
|
||||
(tho/add-simple-component :icon-b :icon-b-root :icon-b-child)
|
||||
(tho/add-frame :board-root {:name "Board"
|
||||
:layout :flex
|
||||
:layout-flex-dir :row})
|
||||
(tho/add-frame :slot (cond-> {:name "Slot" :parent-label :board-root}
|
||||
layout? (assoc :layout :flex :layout-flex-dir :row)))
|
||||
(thc/instantiate-component :icon-a :nested-head {:parent-label :slot})
|
||||
(cond-> layout? (tho/change-shape :nested-head #(assoc % :layout-item-absolute true)))
|
||||
(thc/make-component :board :board-root)))
|
||||
|
||||
(defn- instantiate-board
|
||||
"File with a copy of the library board, labelling its slot and nested icon."
|
||||
[library]
|
||||
(let [file (-> (thf/sample-file :file)
|
||||
(thc/instantiate-component :board
|
||||
:copy-board
|
||||
:library library
|
||||
:children-labels [:copy-slot]))
|
||||
slot (ths/get-shape file :copy-slot)]
|
||||
(thi/set-id! :copy-nested-head (first (:shapes slot)))
|
||||
file))
|
||||
|
||||
(defn- offset-in-slot
|
||||
[file slot-label icon-label]
|
||||
(let [slot (ths/get-shape file slot-label)
|
||||
icon (ths/get-shape file icon-label)]
|
||||
(gpt/point (- (:x icon) (:x slot))
|
||||
(- (:y icon) (:y slot)))))
|
||||
|
||||
(defn- sync-after-unrelated-change
|
||||
[file library root-label]
|
||||
(let [library' (tho/change-shape library root-label
|
||||
#(assoc % :fills (ths/sample-fills-color :fill-color "#fabada")))]
|
||||
(thf/apply-changes file (sync-file-from-library file library'))))
|
||||
|
||||
(t/deftest test-sync-places-swapped-child-in-moved-slot
|
||||
(let [;; ==== Setup
|
||||
library (setup-library-with-icon-slot)
|
||||
file (-> (instantiate-board library)
|
||||
(swap-to-library-component library :copy-nested-head :icon-b)
|
||||
;; The layout moves the slot, and a previous sync moved the icon away
|
||||
(tho/move-shape-tree :copy-slot (gpt/point 800 0) :ignore-touched true)
|
||||
(tho/move-shape-tree :copy-nested-head (gpt/point 800 0) :ignore-touched true))
|
||||
|
||||
;; ==== Action
|
||||
file' (sync-after-unrelated-change file library :board-root)]
|
||||
|
||||
;; ==== Check
|
||||
(t/is (gpt/close? (offset-in-slot library :slot :nested-head)
|
||||
(offset-in-slot file' :copy-slot :copy-nested-head)))
|
||||
(t/is (true? (:layout-item-absolute (ths/get-shape file' :copy-nested-head))))))
|
||||
|
||||
(t/deftest test-sync-places-swapped-child-in-moved-slot-without-layout
|
||||
(let [;; ==== Setup
|
||||
library (setup-library-with-icon-slot :layout? false)
|
||||
file (-> (instantiate-board library)
|
||||
(swap-to-library-component library :copy-nested-head :icon-b)
|
||||
;; The layout moves the slot, and a previous sync moved the icon away
|
||||
(tho/move-shape-tree :copy-slot (gpt/point 800 0) :ignore-touched true)
|
||||
(tho/move-shape-tree :copy-nested-head (gpt/point 800 0) :ignore-touched true))
|
||||
|
||||
;; ==== Action
|
||||
file' (sync-after-unrelated-change file library :board-root)]
|
||||
|
||||
;; ==== Check
|
||||
(t/is (gpt/close? (offset-in-slot library :slot :nested-head)
|
||||
(offset-in-slot file' :copy-slot :copy-nested-head)))))
|
||||
|
||||
(t/deftest test-sync-keeps-moved-child-in-moved-slot
|
||||
(let [;; ==== Setup
|
||||
library (setup-library-with-icon-slot)
|
||||
file (-> (instantiate-board library)
|
||||
(tho/move-shape-tree :copy-nested-head (gpt/point 5 0))
|
||||
(tho/move-shape-tree :copy-slot (gpt/point 800 0) :ignore-touched true))
|
||||
before (offset-in-slot file :copy-slot :copy-nested-head)
|
||||
|
||||
;; ==== Action
|
||||
file' (sync-after-unrelated-change file library :board-root)]
|
||||
|
||||
;; ==== Check
|
||||
(t/is (gpt/close? before (offset-in-slot file' :copy-slot :copy-nested-head)))))
|
||||
|
||||
(t/deftest test-sync-keeps-user-moved-swapped-child-in-moved-slot
|
||||
(let [;; ==== Setup
|
||||
library (setup-library-with-icon-slot)
|
||||
file (-> (instantiate-board library)
|
||||
(swap-to-library-component library :copy-nested-head :icon-b)
|
||||
(tho/move-shape-tree :copy-nested-head (gpt/point 5 0))
|
||||
(tho/move-shape-tree :copy-slot (gpt/point 800 0) :ignore-touched true))
|
||||
before (offset-in-slot file :copy-slot :copy-nested-head)
|
||||
|
||||
;; ==== Action
|
||||
file' (sync-after-unrelated-change file library :board-root)]
|
||||
|
||||
;; ==== Check
|
||||
(t/is (gpt/close? before (offset-in-slot file' :copy-slot :copy-nested-head)))))
|
||||
|
||||
(defn- with-shapes-first
|
||||
"Puts the shapes with the given labels first in the page objects, in that
|
||||
order. The sync visits the shapes of a page in this order."
|
||||
[file labels]
|
||||
(let [page-id (:id (thf/current-page file))
|
||||
ids (mapv thi/id labels)]
|
||||
(update-in file [:data :pages-index page-id :objects]
|
||||
(fn [objects]
|
||||
(apply array-map (concat (mapcat #(vector % (get objects %)) ids)
|
||||
(mapcat identity (apply dissoc objects ids))))))))
|
||||
|
||||
(t/deftest test-sync-places-swapped-child-where-main-slot-occupant-is
|
||||
(let [;; ==== Setup
|
||||
library (setup-library-with-icon-slot)
|
||||
file (-> (instantiate-board library)
|
||||
(swap-to-library-component library :copy-nested-head :icon-b)
|
||||
;; The copy comes before its swapped icon, which the sync also syncs on its own
|
||||
(with-shapes-first [:copy-board :copy-nested-head]))
|
||||
|
||||
;; ==== Action
|
||||
library' (-> library
|
||||
(tho/move-shape-tree :nested-head (gpt/point 4 4))
|
||||
(tho/change-shape :icon-b-root #(gsh/transform-shape % (ctm/change-size % 32 nil))))
|
||||
file' (thf/apply-changes file (sync-file-from-library file library'))
|
||||
|
||||
;; ==== Get
|
||||
icon' (ths/get-shape file' :copy-nested-head)]
|
||||
|
||||
;; ==== Check
|
||||
(t/is (gpt/close? (offset-in-slot library' :slot :nested-head)
|
||||
(offset-in-slot file' :copy-slot :copy-nested-head)))
|
||||
(t/is (mth/close? (:width (ths/get-shape library' :icon-b-root)) (:width icon')))
|
||||
(t/is (mth/close? (:x icon') (-> icon' :selrect :x)))))
|
||||
|
||||
(defn- setup-library-with-root-icon
|
||||
"Library: icon A, and a 300px wide flex card whose `:close` child, placed
|
||||
directly in the root, is an absolute instance of icon A with the given
|
||||
horizontal constraint."
|
||||
[constraints-h]
|
||||
(-> (thf/sample-file :library)
|
||||
(tho/add-simple-component :icon-a :icon-a-root :icon-a-child
|
||||
:root-params {:width 24 :height 24}
|
||||
:child-params {:width 24 :height 24})
|
||||
(tho/add-simple-component :icon-b :icon-b-root :icon-b-child
|
||||
:root-params {:width 24 :height 24}
|
||||
:child-params {:width 24 :height 24})
|
||||
(tho/add-frame :card-root {:name "Card"
|
||||
:layout :flex
|
||||
:layout-flex-dir :row
|
||||
:width 300
|
||||
:height 48})
|
||||
(thc/instantiate-component :icon-a :close {:parent-label :card-root})
|
||||
(tho/change-shape :close #(assoc % :layout-item-absolute true :constraints-h constraints-h))
|
||||
(thc/make-component :card :card-root)))
|
||||
|
||||
(defn- instantiate-filled-card
|
||||
"File with a copy of the card, widened by 800px and with its close icon
|
||||
moved by `icon-shift`, the way the layout does when the copy fills a wider
|
||||
parent."
|
||||
[library icon-shift]
|
||||
(-> (thf/sample-file :file)
|
||||
(thc/instantiate-component :card
|
||||
:copy-card
|
||||
:library library
|
||||
:children-labels [:copy-close])
|
||||
(tho/change-shape :copy-card
|
||||
#(gsh/transform-shape % (ctm/change-size % (+ (:width %) 800) nil))
|
||||
:ignore-touched true)
|
||||
(tho/move-shape-tree :copy-close (gpt/point icon-shift 0) :ignore-touched true)))
|
||||
|
||||
(t/deftest test-sync-moved-root-child-follows-root-through-right-constraint
|
||||
(let [;; ==== Setup
|
||||
library (setup-library-with-root-icon :right)
|
||||
file (-> (instantiate-filled-card library 800)
|
||||
(tho/move-shape-tree :copy-close (gpt/point 5 0)))
|
||||
|
||||
;; ==== Action
|
||||
file' (sync-after-unrelated-change file library :card-root)]
|
||||
|
||||
;; ==== Check
|
||||
(t/is (gpt/close? (gpt/add (offset-in-slot library :card-root :close) (gpt/point 5 0))
|
||||
(offset-in-slot file' :copy-card :copy-close)))))
|
||||
|
||||
(t/deftest test-sync-scales-swapped-root-child-back-with-root
|
||||
(let [;; ==== Setup
|
||||
library (setup-library-with-root-icon :scale)
|
||||
file (-> (instantiate-filled-card library 0)
|
||||
(swap-to-library-component library :copy-close :icon-b)
|
||||
;; The swap drops the constraints; set them again so this path runs
|
||||
(tho/change-shape :copy-close #(assoc % :constraints-h :scale)))
|
||||
|
||||
;; ==== Action
|
||||
file' (sync-after-unrelated-change file library :card-root)
|
||||
|
||||
;; ==== Get
|
||||
scale (/ (:width (ths/get-shape file' :copy-card))
|
||||
(:width (ths/get-shape file :copy-card)))
|
||||
inner #(ths/get-shape-by-id % (first (:shapes (ths/get-shape % :copy-close))))]
|
||||
|
||||
;; ==== Check
|
||||
(t/is (mth/close? (* scale (:width (ths/get-shape file :copy-close)))
|
||||
(:width (ths/get-shape file' :copy-close))))
|
||||
;; The icon content follows the icon through its own (left) constraint
|
||||
(t/is (mth/close? (:width (inner file)) (:width (inner file'))))))
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
[app.common.geom.shapes :as gsh]
|
||||
[app.common.logic.libraries :as cll]
|
||||
[app.common.logic.shapes :as cls]
|
||||
[app.common.math :as mth]
|
||||
[app.common.test-helpers.components :as thc]
|
||||
[app.common.test-helpers.compositions :as tho]
|
||||
[app.common.test-helpers.files :as thf]
|
||||
@ -19,6 +20,7 @@
|
||||
[app.common.test-helpers.shapes :as ths]
|
||||
[app.common.test-helpers.variants :as thv]
|
||||
[app.common.types.component :as ctk]
|
||||
[app.common.types.modifiers :as ctm]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/use-fixtures :each thi/test-fixture)
|
||||
@ -3181,3 +3183,145 @@
|
||||
(t/is (= (get-in synced-t font-size-path-0) "30"))
|
||||
(t/is (contains? (:touched synced-t) :content-group))
|
||||
(t/is (contains? (:touched synced-t) :text-content-text))))))
|
||||
|
||||
;; ============================================================
|
||||
;; SWAPPED CHILD POSITION
|
||||
;; ============================================================
|
||||
|
||||
(defn- setup-variant-with-swapped-icon
|
||||
"Variants whose roots hold an icon A copy, placed 8px lower in the second
|
||||
variant, and a copy of the first variant with its icon swapped to icon B."
|
||||
[]
|
||||
(-> (thf/sample-file :file1)
|
||||
(tho/add-simple-component :icon-a :icon-a-root :icon-a-child)
|
||||
(tho/add-simple-component :icon-b :icon-b-root :icon-b-child)
|
||||
(thv/add-variant-with-copy :v01 :c01 :m01 :c02 :m02 :m01-icon :m02-icon :icon-a)
|
||||
(tho/move-shape-tree :m02-icon (gpt/point 0 8))
|
||||
(thc/instantiate-component :c01 :copy01 :children-labels [:copy-icon])
|
||||
(thc/component-swap :copy-icon :icon-b :copy-icon-b)))
|
||||
|
||||
(defn- offset-in-root
|
||||
[file root-label child-label]
|
||||
(let [root (ths/get-shape file root-label)
|
||||
child (ths/get-shape file child-label)]
|
||||
(gpt/point (- (:x child) (:x root))
|
||||
(- (:y child) (:y root)))))
|
||||
|
||||
(t/deftest test-switch-places-swapped-child-where-new-variant-occupant-is
|
||||
(let [;; ==== Setup
|
||||
file (setup-variant-with-swapped-icon)
|
||||
|
||||
;; ==== Action
|
||||
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02
|
||||
:keep-touched? true})]
|
||||
|
||||
;; ==== Check
|
||||
(t/is (gpt/close? (offset-in-root file :m02 :m02-icon)
|
||||
(offset-in-root file' :copy02 :copy-icon-b)))))
|
||||
|
||||
(defn- resize-width
|
||||
[shape width]
|
||||
(gsh/transform-shape shape (ctm/change-size shape width nil)))
|
||||
|
||||
(defn- setup-card-copy
|
||||
"Variants of a 300x48 flex card whose `:m01-icon` / `:m02-icon` is an
|
||||
absolute icon A copy placed directly in the root at x 270, with the given
|
||||
horizontal constraint. A copy of the first variant fills 800px more, the
|
||||
way the layout grows it."
|
||||
[constraints-h]
|
||||
(let [place-icon (fn [file root-label icon-label]
|
||||
(let [root (ths/get-shape file root-label)
|
||||
icon (ths/get-shape file icon-label)]
|
||||
(-> file
|
||||
(tho/move-shape-tree icon-label (gpt/point (- (+ (:x root) 270) (:x icon))
|
||||
(- (+ (:y root) 12) (:y icon))))
|
||||
(tho/change-shape icon-label #(assoc % :layout-item-absolute true
|
||||
:constraints-h constraints-h)))))
|
||||
card (fn [file root-label]
|
||||
(tho/change-shape file root-label #(-> %
|
||||
(resize-width 300)
|
||||
(gsh/transform-shape (ctm/change-size % nil 48))
|
||||
(assoc :layout :flex :layout-flex-dir :row))))]
|
||||
(-> (thf/sample-file :file1)
|
||||
(tho/add-simple-component :icon-a :icon-a-root :icon-a-child
|
||||
:root-params {:width 24 :height 24}
|
||||
:child-params {:width 24 :height 24})
|
||||
(tho/add-simple-component :icon-b :icon-b-root :icon-b-child
|
||||
:root-params {:width 24 :height 24}
|
||||
:child-params {:width 24 :height 24})
|
||||
(thv/add-variant-with-copy :v01 :c01 :m01 :c02 :m02 :m01-icon :m02-icon :icon-a)
|
||||
(card :m01)
|
||||
(card :m02)
|
||||
(place-icon :m01 :m01-icon)
|
||||
(place-icon :m02 :m02-icon)
|
||||
(thc/instantiate-component :c01 :copy01 :children-labels [:copy-icon])
|
||||
(tho/change-shape :copy01 #(resize-width % 1100) :ignore-touched true)
|
||||
(tho/move-shape-tree :copy-icon (gpt/point (case constraints-h :right 800 0) 0) :ignore-touched true))))
|
||||
|
||||
(defn- setup-card-variant
|
||||
"Same as `setup-card-copy`, with the icon of the copy swapped to icon B."
|
||||
[constraints-h]
|
||||
(-> (setup-card-copy constraints-h)
|
||||
(thc/component-swap :copy-icon :icon-b :copy-icon-b)
|
||||
;; The swap drops the constraints; set them again so this path runs
|
||||
(tho/change-shape :copy-icon-b #(assoc % :constraints-h constraints-h))))
|
||||
|
||||
(defn- distance-to-right-edge
|
||||
[file root-label child-label]
|
||||
(let [root (ths/get-shape file root-label)
|
||||
child (ths/get-shape file child-label)]
|
||||
(- (+ (:x root) (:width root)) (:x child))))
|
||||
|
||||
(t/deftest test-switch-moved-swapped-child-follows-right-constraint
|
||||
(let [;; ==== Setup
|
||||
file (-> (setup-card-variant :right)
|
||||
(tho/move-shape-tree :copy-icon-b (gpt/point 5 0)))
|
||||
before (distance-to-right-edge file :copy01 :copy-icon-b)
|
||||
|
||||
;; ==== Action
|
||||
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02
|
||||
:keep-touched? true})]
|
||||
|
||||
;; ==== Check
|
||||
(t/is (mth/close? before (distance-to-right-edge file' :copy02 :copy-icon-b)))))
|
||||
|
||||
(t/deftest test-switch-scales-swapped-child-back-with-root
|
||||
(let [;; ==== Setup
|
||||
file (setup-card-variant :scale)
|
||||
width (:width (ths/get-shape file :copy-icon-b))
|
||||
root-w (:width (ths/get-shape file :copy01))
|
||||
|
||||
;; ==== Action
|
||||
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02
|
||||
:keep-touched? true})
|
||||
scale (/ (:width (ths/get-shape file' :copy02)) root-w)
|
||||
inner #(ths/get-shape-by-id % (first (:shapes (ths/get-shape % :copy-icon-b))))]
|
||||
|
||||
;; ==== Check
|
||||
(t/is (mth/close? (* width scale)
|
||||
(:width (ths/get-shape file' :copy-icon-b))))
|
||||
;; The icon content follows the icon through its own (left) constraint
|
||||
(t/is (mth/close? (:width (inner file)) (:width (inner file'))))))
|
||||
|
||||
(t/deftest test-switch-scales-moved-child-back-with-root
|
||||
(let [;; ==== Setup
|
||||
card (setup-card-copy :scale)
|
||||
grow (/ (:width (ths/get-shape card :copy01))
|
||||
(:width (ths/get-shape card :m01)))
|
||||
file (-> card
|
||||
;; The layout scales the icon with the copy
|
||||
(tho/change-shape :copy-icon #(resize-width % (* grow (:width %))) :ignore-touched true)
|
||||
(tho/move-shape-tree :copy-icon (gpt/point 5 0)))
|
||||
width (:width (ths/get-shape file :copy-icon))
|
||||
root-w (:width (ths/get-shape file :copy01))
|
||||
|
||||
;; ==== Action
|
||||
file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02
|
||||
:keep-touched? true})
|
||||
|
||||
;; ==== Get
|
||||
copy02' (ths/get-shape file' :copy02)
|
||||
icon' (get-in (thf/current-page file') [:objects (-> copy02' :shapes first)])]
|
||||
|
||||
;; ==== Check
|
||||
(t/is (mth/close? (* width (/ (:width copy02') root-w)) (:width icon')))))
|
||||
|
||||
@ -508,30 +508,37 @@
|
||||
:title "Align self end"
|
||||
:id "align-self-end"}]])
|
||||
|
||||
;; A multiple selection gives nil for missing attrs and :multiple for mixed ones
|
||||
(def ^:private schema:number-value
|
||||
[:maybe [:or :float :int [:= :multiple]]])
|
||||
|
||||
(def ^:private schema:layout-item-props-schema
|
||||
[:map
|
||||
[:layout-item-margin
|
||||
{:optional true}
|
||||
[:map
|
||||
[:m1 {:optional true} [:or :float :int]]
|
||||
[:m2 {:optional true} [:or :float :int]]
|
||||
[:m3 {:optional true} [:or :float :int]]
|
||||
[:m4 {:optional true} [:or :float :int]]]]
|
||||
[:maybe
|
||||
[:or
|
||||
[:= :multiple]
|
||||
[:map
|
||||
[:m1 {:optional true} schema:number-value]
|
||||
[:m2 {:optional true} schema:number-value]
|
||||
[:m3 {:optional true} schema:number-value]
|
||||
[:m4 {:optional true} schema:number-value]]]]]
|
||||
|
||||
[:layout-item-margin-type {:optional true} :keyword]
|
||||
[:layout-item-margin-type {:optional true} [:maybe :keyword]]
|
||||
|
||||
[:layout-item-h-sizing {:optional true} :keyword]
|
||||
[:layout-item-v-sizing {:optional true} :keyword]
|
||||
[:layout-item-h-sizing {:optional true} [:maybe :keyword]]
|
||||
[:layout-item-v-sizing {:optional true} [:maybe :keyword]]
|
||||
|
||||
[:layout-item-min-w {:optional true} [:or :float :int]]
|
||||
[:layout-item-max-w {:optional true} [:or :float :int]]
|
||||
[:layout-item-min-h {:optional true} [:or :float :int]]
|
||||
[:layout-item-max-h {:optional true} [:or :float :int]]])
|
||||
[:layout-item-min-w {:optional true} schema:number-value]
|
||||
[:layout-item-max-w {:optional true} schema:number-value]
|
||||
[:layout-item-min-h {:optional true} schema:number-value]
|
||||
[:layout-item-max-h {:optional true} schema:number-value]])
|
||||
|
||||
(def ^:private schema:layout-size-constraints
|
||||
[:map
|
||||
[:values schema:layout-item-props-schema]
|
||||
[:applied-tokens [:maybe [:map-of :keyword :string]]]
|
||||
[:applied-tokens [:maybe [:map-of :keyword [:maybe [:or :string [:= :multiple]]]]]]
|
||||
[:ids [::sm/vec ::sm/uuid]]])
|
||||
|
||||
(mf/defc layout-size-constraints*
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use skia_safe::{self as skia, Canvas, Paint, RRect};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::shapes::{
|
||||
circle_segments_local, merge_fills, radius_to_sigma, rect_segments_local, stroke_to_path,
|
||||
BlurType, Fill, Frame, Path, Rect, Shape, Stroke, StrokeKind, StrokeStyle, Type,
|
||||
BlurType, Fill, Frame, Path, Rect, Shape, SolidColor, Stroke, StrokeKind, StrokeStyle, Type,
|
||||
};
|
||||
use crate::state::ShapesPoolRef;
|
||||
use crate::uuid::Uuid;
|
||||
@ -29,6 +31,10 @@ pub(super) struct VectorRenderer<'a> {
|
||||
/// techniques that `SkSVGDevice` drops: draw fills individually and emit
|
||||
/// solid Inner/Outer strokes as filled outlines.
|
||||
compose_fills: bool,
|
||||
/// Set when an enclosing layer already applies the shape's layer blur.
|
||||
blur_in_layer: bool,
|
||||
/// Set inside a container's drop-shadow pass: text glyphs paint opaque.
|
||||
silhouette: bool,
|
||||
}
|
||||
|
||||
impl<'a> VectorRenderer<'a> {
|
||||
@ -43,6 +49,8 @@ impl<'a> VectorRenderer<'a> {
|
||||
shared,
|
||||
scale,
|
||||
compose_fills,
|
||||
blur_in_layer: false,
|
||||
silhouette: false,
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,9 +58,10 @@ impl<'a> VectorRenderer<'a> {
|
||||
///
|
||||
/// SVG export (`compose_fills == false`) returns `None`: `SkSVGDevice` drops
|
||||
/// paint image-filters (the shape would vanish). Layer blur is re-emitted as
|
||||
/// a native SVG `<filter>` wrapper instead.
|
||||
/// a native SVG `<filter>` wrapper instead. Also `None` inside a layer
|
||||
/// that already blurs, or the shape would blur twice.
|
||||
fn layer_blur_filter(&self, shape: &Shape) -> Option<skia::ImageFilter> {
|
||||
if !self.compose_fills {
|
||||
if !self.compose_fills || self.blur_in_layer {
|
||||
return None;
|
||||
}
|
||||
shape.image_filter(1.)
|
||||
@ -121,7 +130,7 @@ impl ShapeRenderer for VectorRenderer<'_> {
|
||||
fn draw_drop_shadows(&mut self, shape: &Shape) -> Result<()> {
|
||||
let layer_bounds = shape.layer_bounds();
|
||||
for shadow in shape.drop_shadows_visible() {
|
||||
if let Some(filter) = shadow.get_drop_shadow_filter() {
|
||||
if let Some(filter) = shadow.get_drop_shadow_blur_filter() {
|
||||
let mut paint = Paint::default();
|
||||
paint.set_image_filter(filter);
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default()
|
||||
@ -131,6 +140,12 @@ impl ShapeRenderer for VectorRenderer<'_> {
|
||||
let mut fill_paint = Paint::default();
|
||||
fill_paint.set_anti_alias(true);
|
||||
fill_paint.set_color(skia::Color::BLACK);
|
||||
// Spread as a mitered outline: Skia's dilate filter runs as
|
||||
// SkSL per pixel on CPU canvases, which takes minutes.
|
||||
if shadow.spread > 0.0 {
|
||||
fill_paint.set_style(skia::PaintStyle::StrokeAndFill);
|
||||
fill_paint.set_stroke_width(2.0 * shadow.spread);
|
||||
}
|
||||
draw_shape_geometry(self.canvas, shape, &fill_paint);
|
||||
self.canvas.restore();
|
||||
}
|
||||
@ -182,13 +197,17 @@ impl ShapeRenderer for VectorRenderer<'_> {
|
||||
};
|
||||
|
||||
let text_content = text_content.new_bounds(shape.selrect());
|
||||
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
|
||||
let mut paragraph_builders =
|
||||
text_content.paragraph_builder_group_from_text(Some(self.silhouette));
|
||||
let blur_filter = self.layer_blur_filter(shape);
|
||||
|
||||
// Text drop shadows: one filter layer per shadow over fill + stroke
|
||||
// silhouettes (mirrors GPU `render_text_shadows`).
|
||||
let drop_shadows = shape.drop_shadow_paints();
|
||||
if !drop_shadows.is_empty() {
|
||||
// silhouettes (mirrors GPU `render_text_shadows`). Spread grows the
|
||||
// glyphs through strokes (`spread_shape`).
|
||||
let layer_bounds = shape.layer_bounds();
|
||||
for shadow in shape.drop_shadows_visible() {
|
||||
let spread = spread_shape(shape, shadow.spread);
|
||||
let shape: &Shape = &spread;
|
||||
let shadow_stroke_outset = Stroke::max_bounds_width(shape.visible_strokes(), false);
|
||||
let mut shadow_paragraphs = text_content.paragraph_builder_group_from_text(Some(true));
|
||||
let mut stroke_shadow_groups: Vec<(StrokeKind, _)> = shape
|
||||
@ -208,67 +227,66 @@ impl ShapeRenderer for VectorRenderer<'_> {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let layer_bounds = shape.layer_bounds();
|
||||
for shadow_paint in &drop_shadows {
|
||||
self.canvas.save_layer(
|
||||
&skia::canvas::SaveLayerRec::default()
|
||||
.bounds(&layer_bounds)
|
||||
.paint(shadow_paint),
|
||||
);
|
||||
let mut shadow_paint = Paint::default();
|
||||
shadow_paint.set_image_filter(shadow.get_drop_shadow_blur_filter());
|
||||
self.canvas.save_layer(
|
||||
&skia::canvas::SaveLayerRec::default()
|
||||
.bounds(&layer_bounds)
|
||||
.paint(&shadow_paint),
|
||||
);
|
||||
|
||||
text::render_overlay_emoji(
|
||||
self.canvas,
|
||||
shape,
|
||||
&mut shadow_paragraphs,
|
||||
None,
|
||||
blur_filter.as_ref(),
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
text::render_overlay_emoji(
|
||||
self.canvas,
|
||||
shape,
|
||||
&mut shadow_paragraphs,
|
||||
None,
|
||||
blur_filter.as_ref(),
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
|
||||
for (kind, stroke_paragraphs) in &mut stroke_shadow_groups {
|
||||
if *kind == StrokeKind::Inner {
|
||||
// Inner stroke masked by the glyph fill (outset 0 here).
|
||||
let mut fill_builders =
|
||||
text_content.paragraph_builder_group_from_text(Some(true));
|
||||
text::render_inner_stroke(
|
||||
None,
|
||||
Some(self.canvas),
|
||||
shape,
|
||||
stroke_paragraphs,
|
||||
&mut fill_builders,
|
||||
None,
|
||||
blur_filter.as_ref(),
|
||||
0.0,
|
||||
None,
|
||||
)?;
|
||||
} else if *kind == StrokeKind::Outer {
|
||||
text::render_outer_stroke(
|
||||
None,
|
||||
Some(self.canvas),
|
||||
shape,
|
||||
stroke_paragraphs,
|
||||
None,
|
||||
blur_filter.as_ref(),
|
||||
0.0,
|
||||
None,
|
||||
)?;
|
||||
} else {
|
||||
text::render_with_bounds_outset_overlay_emoji(
|
||||
self.canvas,
|
||||
shape,
|
||||
stroke_paragraphs,
|
||||
None,
|
||||
blur_filter.as_ref(),
|
||||
shadow_stroke_outset,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
for (kind, stroke_paragraphs) in &mut stroke_shadow_groups {
|
||||
if *kind == StrokeKind::Inner {
|
||||
// Inner stroke masked by the glyph fill (outset 0 here).
|
||||
let mut fill_builders =
|
||||
text_content.paragraph_builder_group_from_text(Some(true));
|
||||
text::render_inner_stroke(
|
||||
None,
|
||||
Some(self.canvas),
|
||||
shape,
|
||||
stroke_paragraphs,
|
||||
&mut fill_builders,
|
||||
None,
|
||||
blur_filter.as_ref(),
|
||||
0.0,
|
||||
None,
|
||||
)?;
|
||||
} else if *kind == StrokeKind::Outer {
|
||||
text::render_outer_stroke(
|
||||
None,
|
||||
Some(self.canvas),
|
||||
shape,
|
||||
stroke_paragraphs,
|
||||
None,
|
||||
blur_filter.as_ref(),
|
||||
0.0,
|
||||
None,
|
||||
)?;
|
||||
} else {
|
||||
text::render_with_bounds_outset_overlay_emoji(
|
||||
self.canvas,
|
||||
shape,
|
||||
stroke_paragraphs,
|
||||
None,
|
||||
blur_filter.as_ref(),
|
||||
shadow_stroke_outset,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
|
||||
self.canvas.restore();
|
||||
}
|
||||
|
||||
self.canvas.restore();
|
||||
}
|
||||
|
||||
text::render_overlay_emoji(
|
||||
@ -427,6 +445,9 @@ struct TreeOpts<'a> {
|
||||
root_fills: &'a [Fill],
|
||||
/// Nearest group's fills, painted by fill-less leaves (GPU `nested_fills`).
|
||||
inherited_fills: &'a [Fill],
|
||||
/// Set inside a container's drop-shadow pass: the spread to grow every
|
||||
/// shape by.
|
||||
silhouette: Option<f32>,
|
||||
}
|
||||
|
||||
impl<'a> TreeOpts<'a> {
|
||||
@ -447,6 +468,7 @@ impl<'a> TreeOpts<'a> {
|
||||
skip: self.skip,
|
||||
root_fills: self.root_fills,
|
||||
inherited_fills,
|
||||
silhouette: self.silhouette,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -500,6 +522,7 @@ fn render_tree_dispatch(
|
||||
skip: None,
|
||||
root_fills: &root_fills,
|
||||
inherited_fills: &root_fills,
|
||||
silhouette: None,
|
||||
};
|
||||
render_tree_inner(shared, canvas, id, tree, scale, &opts)
|
||||
}
|
||||
@ -525,6 +548,10 @@ fn render_tree_inner(
|
||||
if element.hidden {
|
||||
return Ok(());
|
||||
}
|
||||
let silhouette = opts
|
||||
.silhouette
|
||||
.map(|spread| silhouette_shape(element, spread));
|
||||
let element: &Shape = silhouette.as_deref().unwrap_or(element);
|
||||
|
||||
// Background blur samples already-drawn content behind the shape, so it must
|
||||
// run before the shape (and its subtree) paints. SVGRaw is excluded,
|
||||
@ -561,7 +588,8 @@ fn render_tree_inner(
|
||||
} else {
|
||||
element.fills.as_slice()
|
||||
};
|
||||
render_leaf(shared, canvas, element, fills, scale)?;
|
||||
let silhouette = opts.silhouette.is_some();
|
||||
render_leaf(shared, canvas, element, fills, scale, silhouette)?;
|
||||
}
|
||||
}
|
||||
|
||||
@ -696,6 +724,7 @@ fn render_background_blur_image(
|
||||
skip: Some(&shape.id),
|
||||
root_fills: opts.root_fills,
|
||||
inherited_fills: opts.root_fills,
|
||||
silhouette: None,
|
||||
};
|
||||
render_tree_inner(shared, oc, opts.root, tree, scale, &sub)?;
|
||||
}
|
||||
@ -952,6 +981,8 @@ fn render_frame(
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, true);
|
||||
// The opacity/blend layer above carries the frame's layer blur.
|
||||
renderer.blur_in_layer = needs_layer;
|
||||
renderer.draw_fills(element, &element.fills)?;
|
||||
renderer.draw_fill_inner_shadows(element)?;
|
||||
canvas.restore();
|
||||
@ -996,7 +1027,7 @@ fn render_container_drop_shadows(
|
||||
) -> Result<()> {
|
||||
let subtree_bounds = element.extrect(tree, scale);
|
||||
for shadow in element.drop_shadows_visible() {
|
||||
let Some(filter) = shadow.get_drop_shadow_filter() else {
|
||||
let Some(filter) = shadow.get_drop_shadow_blur_filter() else {
|
||||
continue;
|
||||
};
|
||||
let mut paint = Paint::default();
|
||||
@ -1007,13 +1038,22 @@ fn render_container_drop_shadows(
|
||||
.paint(&paint),
|
||||
);
|
||||
|
||||
// Spread grows every silhouette shape instead of dilating the layer.
|
||||
let spread = opts.silhouette.unwrap_or(0.0) + shadow.spread.max(0.0);
|
||||
if draw_fills && !element.fills.is_empty() {
|
||||
let mut background = Cow::Borrowed(element);
|
||||
if spread > 0.0 {
|
||||
background.to_mut().selrect.outset((spread, spread));
|
||||
}
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, true);
|
||||
renderer.draw_fills(element, &element.fills)?;
|
||||
renderer.draw_fills(&background, &[Fill::Solid(SolidColor(skia::Color::BLACK))])?;
|
||||
}
|
||||
|
||||
let children = children_paint_order(tree, element);
|
||||
let child_opts = opts.for_children_of(element);
|
||||
let child_opts = TreeOpts {
|
||||
silhouette: Some(spread),
|
||||
..opts.for_children_of(element)
|
||||
};
|
||||
if element.is_masked_group() {
|
||||
render_masked_children(shared, canvas, element, &children, tree, scale, &child_opts)?;
|
||||
} else {
|
||||
@ -1027,6 +1067,41 @@ fn render_container_drop_shadows(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `shape` grown by a drop-shadow `spread` for its silhouette, through its own
|
||||
/// strokes and joins (as the GPU does for paths). Empty frames and containers
|
||||
/// have no outline of their own to grow.
|
||||
fn spread_shape(shape: &Shape, spread: f32) -> Cow<'_, Shape> {
|
||||
let outline = match shape.shape_type {
|
||||
Type::Group(_) | Type::SVGRaw(_) => false,
|
||||
Type::Frame(_) => !shape.fills.is_empty() || !shape.strokes.is_empty(),
|
||||
_ => true,
|
||||
};
|
||||
if spread <= 0.0 || !outline {
|
||||
return Cow::Borrowed(shape);
|
||||
}
|
||||
let mut shape = shape.clone();
|
||||
shape.apply_shadow_spread(spread);
|
||||
Cow::Owned(shape)
|
||||
}
|
||||
|
||||
/// `shape` as drawn in a container's drop-shadow pass: grown by `spread` and
|
||||
/// solid black, since fill and stroke opacity don't affect the shadow (GPU).
|
||||
fn silhouette_shape(shape: &Shape, spread: f32) -> Cow<'_, Shape> {
|
||||
let mut shape = spread_shape(shape, spread);
|
||||
if shape.has_fills() || !shape.strokes.is_empty() {
|
||||
let black = Fill::Solid(SolidColor(skia::Color::BLACK));
|
||||
let shape = shape.to_mut();
|
||||
if shape.has_fills() {
|
||||
shape.clear_fills();
|
||||
shape.add_fill(black.clone());
|
||||
}
|
||||
for stroke in shape.strokes.iter_mut() {
|
||||
stroke.fill = black.clone();
|
||||
}
|
||||
}
|
||||
shape
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Leaf shapes (Rect, Circle, Path, Bool, Text, SVGRaw)
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -1037,6 +1112,7 @@ fn render_leaf(
|
||||
element: &Shape,
|
||||
fills: &[Fill],
|
||||
scale: f32,
|
||||
silhouette: bool,
|
||||
) -> Result<()> {
|
||||
let needs_layer = element.needs_layer();
|
||||
|
||||
@ -1065,6 +1141,8 @@ fn render_leaf(
|
||||
} else {
|
||||
false
|
||||
};
|
||||
renderer.blur_in_layer = blur_layer;
|
||||
renderer.silhouette = silhouette;
|
||||
|
||||
renderer.draw_drop_shadows(element)?;
|
||||
render_leaf_content(&mut renderer, element, fills)?;
|
||||
|
||||
@ -2187,20 +2187,6 @@ impl Shape {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn drop_shadow_paints(&self) -> Vec<skia_safe::Paint> {
|
||||
let drop_shadows: Vec<&Shadow> = self.drop_shadows_visible().collect();
|
||||
|
||||
drop_shadows
|
||||
.into_iter()
|
||||
.map(|shadow| {
|
||||
let mut paint = skia_safe::Paint::default();
|
||||
let filter = shadow.get_drop_shadow_filter();
|
||||
paint.set_image_filter(filter);
|
||||
paint
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn inner_shadow_paints(&self) -> Vec<skia_safe::Paint> {
|
||||
let inner_shadows: Vec<&Shadow> = self.inner_shadows_visible().collect();
|
||||
|
||||
|
||||
@ -87,25 +87,30 @@ impl Shadow {
|
||||
}
|
||||
|
||||
pub fn get_drop_shadow_filter(&self) -> Option<ImageFilter> {
|
||||
let filter = self.get_drop_shadow_blur_filter()?;
|
||||
|
||||
if self.spread > 0. {
|
||||
return image_filters::dilate((self.spread, self.spread), filter, None);
|
||||
}
|
||||
|
||||
Some(filter)
|
||||
}
|
||||
|
||||
/// [`Self::get_drop_shadow_filter`] without the spread dilate.
|
||||
pub fn get_drop_shadow_blur_filter(&self) -> Option<ImageFilter> {
|
||||
if self.style == ShadowStyle::Inner {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sigma = radius_to_sigma(self.blur);
|
||||
let mut filter = image_filters::drop_shadow_only(
|
||||
image_filters::drop_shadow_only(
|
||||
(self.offset.0, self.offset.1),
|
||||
(sigma, sigma),
|
||||
self.color,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
if self.spread > 0. {
|
||||
filter = image_filters::dilate((self.spread, self.spread), filter, None);
|
||||
}
|
||||
|
||||
filter
|
||||
)
|
||||
}
|
||||
|
||||
/// Same shadow as [`Self::get_drop_shadow_filter`], for a `merge` input.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user