Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Alejandro Alonso 2026-10-30 13:22:09 +01:00
commit 94836cb8c8
12 changed files with 762 additions and 187 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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')))))

View File

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

View File

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

View File

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

View File

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