mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
🐛 Preserve scaled content styles through component sync
Store cumulative content scale as an optional shape attribute, default 1. Sync scales the main's styles by the copy/main ratio separately from constraint resizing. Keep descendants untouched and allow later main style edits to propagate (F109). Both renderer modifier paths use this field. Cover a later sync, repeated scaling, main radius edits, reset and its undo. The original stroke regression failed before the fix. Common JVM/JS and frontend suites, backend and exporter tests, lint and format checks pass. Historical scale gestures cannot be reconstructed from old file data. Benchmark vs Phase 1e (lower quantile, ms): world baseline now ratio main-10 1.790 0.993 0.55 main-100 15.415 9.038 0.59 main-1000 153.086 90.145 0.59 three-levels 35.215 18.989 0.54 chain 68.931 35.045 0.51 library-500 83.083 85.830 1.03 many-shapes 4.837 3.344 0.69 variant-100 32.052 33.586 1.05 All worlds pass the 10% regression threshold. AI-assisted-by: GPT-6
This commit is contained in:
parent
c0e814421f
commit
e2740f3be7
@ -43,6 +43,13 @@ Masters are not normally touched through `set-shape-attr`, but touched flags can
|
||||
With `:main-instance? true` (library absorption), nested copies keep their
|
||||
`:touched` and `:remote-synced`; they remain copies inside the new main.
|
||||
|
||||
`:content-scale` is an optional positive shape number (absent means 1),
|
||||
accumulated by scale-content transforms on both renderers. It belongs to
|
||||
`:geometry-group`, survives clone/serialization, and leaves the data when
|
||||
the accumulated scale returns to 1. Sync derives styles from the ratio of
|
||||
the copy's scale to its main's, separately from constraint resizing.
|
||||
Old files default to 1; historical scale gestures cannot be reconstructed.
|
||||
|
||||
`duplicate-component` in `app.common.logic.duplicate` creates a new component master by cloning existing component shapes, setting component metadata, and applying a position delta. It does not have the same clean-copy semantics as `make-component-instance`, so inherited attrs on the source can matter.
|
||||
|
||||
When a bug depends on touched state, identify which cloning path produced the shape before changing sync logic.
|
||||
@ -53,4 +60,4 @@ A variant container is a frame with `:is-variant-container true`. Its children a
|
||||
|
||||
Predicates are broad: `ctk/is-variant?` checks `:variant-id` and applies to both variant master shapes and component rows; `ctk/is-variant-container?` checks the container shape flag.
|
||||
|
||||
Moving/dropping a shape into a variant container through the move-to-frame path can auto-convert it into a variant via `generate-make-shapes-variant`, which may duplicate the underlying component. Treat drag/drop into variant containers as a component/variant operation, not a plain reparent.
|
||||
Moving/dropping a shape into a variant container through the move-to-frame path can auto-convert it into a variant via `generate-make-shapes-variant`, which may duplicate the underlying component. Treat drag/drop into variant containers as a component/variant operation, not a plain reparent.
|
||||
|
||||
@ -46,6 +46,11 @@ Intended semantics of copies, `:touched` and component sync. This is the spec: w
|
||||
## Geometry ownership
|
||||
|
||||
- Copy root: position is its own; rotation/flip belong to it only when its `:geometry-group` is touched. Resizing it touches the root only.
|
||||
- Content scaling is distinct from constraint resize. `:content-scale`
|
||||
stores the accumulated scale (default 1); expected placement scales
|
||||
styles by copy/main scale before syncing. Descendants remain untouched;
|
||||
later main style edits still reach them at that scale. Reset restores
|
||||
the near main's scale together with its geometry and styles.
|
||||
- Children of a resized or transformed shape: derived (expected geometry), untouched, and still follow later main edits. Code: a transform touches only the shapes the user transformed (`clp/calculate-ignore-tree` takes their ids; the frontend keeps them in `:workspace-modifiers-ids`); sync places the main subtree of a copy shape with its own geometry like that shape before walking its children.
|
||||
- Layout-owned (never touched; sync writes, then reflow): x/y of non-absolute children of flex/grid frames; width/height on axes sized fill, or auto on a layout frame.
|
||||
- Grow-owned (never touched; sync writes, then re-measure on SVG and WASM): width+height of auto-width text, height of auto-height text. Every editor and resize path writes the measured size as derived; on WASM `sync-file` re-measures the auto-grow texts of the current page it wrote (texts on other pages keep the synced size until measured).
|
||||
|
||||
@ -327,7 +327,8 @@
|
||||
(defn- close-geometry?
|
||||
[shape other]
|
||||
(and (gsh/close-attrs? :selrect (:selrect shape) (:selrect other))
|
||||
(gsh/close-attrs? :points (:points shape) (:points other))))
|
||||
(gsh/close-attrs? :points (:points shape) (:points other))
|
||||
(mth/close? (:content-scale shape 1) (:content-scale other 1))))
|
||||
|
||||
(defn- placed-like
|
||||
"The main `objects` with the subtree of `shape-main` placed like `placed`,
|
||||
@ -335,7 +336,8 @@
|
||||
children following through their constraints and layouts, then turned
|
||||
and moved with it as one piece."
|
||||
[objects shape-main placed]
|
||||
(let [width (-> placed :selrect :width)
|
||||
(let [scale (/ (:content-scale placed 1) (:content-scale shape-main 1))
|
||||
width (-> placed :selrect :width)
|
||||
height (-> placed :selrect :height)
|
||||
objects (if (and (mth/close? width (-> shape-main :selrect :width))
|
||||
(mth/close? height (-> shape-main :selrect :height)))
|
||||
@ -348,6 +350,10 @@
|
||||
objects
|
||||
modif-tree)))
|
||||
ids (cfh/get-children-ids-with-self objects (:id shape-main))
|
||||
objects (if (mth/close? scale 1)
|
||||
objects
|
||||
(let [modifiers (ctm/scale-content (ctm/empty) scale)]
|
||||
(reduce #(update %1 %2 gsh/transform-shape modifiers) objects ids)))
|
||||
resized (get objects (:id shape-main))
|
||||
turn (orient-like resized placed (grc/rect->center (:selrect resized)))
|
||||
objects (cond-> objects
|
||||
@ -383,6 +389,7 @@
|
||||
(and (some? placed) (not (close-geometry? placed shape-main)))
|
||||
(update :objects placed-like shape-main placed))
|
||||
ctx (assoc ctx :main-container main-container)
|
||||
shape-main (get-in main-container [:objects (:id shape-main)])
|
||||
children-inst (vec (ctn/get-direct-children container shape-inst))
|
||||
children-main (vec (ctn/get-direct-children main-container shape-main))]
|
||||
(cond-> (-> changes
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
"The attributes a transform can change, so an update after it compares
|
||||
only these."
|
||||
#{:selrect
|
||||
:content-scale
|
||||
:points
|
||||
:x
|
||||
:y
|
||||
|
||||
@ -67,6 +67,7 @@
|
||||
:r4 :radius-group
|
||||
:type :geometry-group
|
||||
:selrect :geometry-group
|
||||
:content-scale :geometry-group
|
||||
:points :geometry-group
|
||||
:locked :geometry-group
|
||||
:proportion :geometry-group
|
||||
|
||||
@ -675,8 +675,15 @@
|
||||
(defn- apply-scale-content
|
||||
[shape value]
|
||||
;; Scale can only be positive
|
||||
(let [value (mth/abs value)]
|
||||
(let [value (mth/abs value)
|
||||
scale (* (:content-scale shape 1) value)]
|
||||
(cond-> shape
|
||||
(mth/close? scale 1)
|
||||
(dissoc :content-scale)
|
||||
|
||||
(not (mth/close? scale 1))
|
||||
(assoc :content-scale scale)
|
||||
|
||||
(cfh/text-shape? shape)
|
||||
(update-text-content scale-text-content value)
|
||||
|
||||
|
||||
@ -195,6 +195,7 @@
|
||||
[:main-instance {:optional true} :boolean]
|
||||
[:remote-synced {:optional true} :boolean]
|
||||
[:shape-ref {:optional true} ::sm/uuid]
|
||||
[:content-scale {:optional true} ::sm/positive-safe-number]
|
||||
[:touched {:optional true} [:maybe [:set :keyword]]]
|
||||
[:blocked {:optional true} :boolean]
|
||||
[:collapsed {:optional true} :boolean]
|
||||
@ -510,7 +511,7 @@
|
||||
;; Valid attributes for keeping on a switch
|
||||
(def ^:private allowed-shape-attrs
|
||||
#{:page-id :component-id :component-file :component-root :main-instance
|
||||
:remote-synced :shape-ref :touched :blocked :collapsed :locked
|
||||
:remote-synced :shape-ref :content-scale :touched :blocked :collapsed :locked
|
||||
:hidden :masked-group :fills :proportion :proportion-lock :constraints-h
|
||||
:constraints-v :fixed-scroll :r1 :r2 :r3 :r4 :rotation :opacity :grids :exports
|
||||
:strokes :blend-mode :interactions :shadow :blur :background-blur :grow-type :applied-tokens
|
||||
|
||||
@ -20,7 +20,9 @@
|
||||
[app.common.test-helpers.compositions :as tho]
|
||||
[app.common.test-helpers.files :as thf]
|
||||
[app.common.test-helpers.ids-map :as thi]
|
||||
[app.common.test-helpers.normalize :as thn]
|
||||
[app.common.test-helpers.shapes :as ths]
|
||||
[app.common.types.container :as ctn]
|
||||
[app.common.types.modifiers :as ctm]
|
||||
[clojure.test :as t]))
|
||||
|
||||
@ -108,7 +110,33 @@
|
||||
:stroke-style :solid}])
|
||||
(thc/instantiate-component :component1 :copy2-root :children-labels [:copy2-child]))
|
||||
resized (resize file :copy2-root 200 200 :scale-content 2)
|
||||
child (ths/get-shape resized :copy2-child)]
|
||||
child (ths/get-shape resized :copy2-child)
|
||||
synced (edit-and-sync resized)]
|
||||
(t/is (mth/close? 4 (-> child :strokes first :stroke-width)))
|
||||
(t/is (nil? (:touched child)))))
|
||||
(t/is (nil? (:touched child)))
|
||||
(t/is (mth/close? 4 (-> (ths/get-shape synced :copy2-child) :strokes first :stroke-width)))
|
||||
(t/is (nil? (:touched (ths/get-shape synced :copy2-child))))))
|
||||
|
||||
(t/deftest content-scale-survives-repeated-sync-and-reset-undo
|
||||
(let [file (-> (component-with-child :scale)
|
||||
(ths/update-shape :main-child :r1 5)
|
||||
(thc/instantiate-component :component1 :copy2-root :children-labels [:copy2-child]))
|
||||
scaled (-> file
|
||||
(resize :copy2-root 200 200 :scale-content 2)
|
||||
(resize :copy2-root 300 300 :scale-content 1.5))
|
||||
synced (edit-and-sync scaled)
|
||||
changed (-> synced
|
||||
(ths/update-shape :main-child :r1 7)
|
||||
(edit-and-sync))
|
||||
reset-changes (cll/generate-reset-component
|
||||
(pcb/empty-changes) changed {(:id changed) changed}
|
||||
(ctn/make-container (thf/current-page changed) :page) (thi/id :copy2-root))
|
||||
reset (thf/apply-changes changed reset-changes)
|
||||
undone (thf/apply-changes reset {:redo-changes (:undo-changes reset-changes)})]
|
||||
(t/is (mth/close? 3 (:content-scale (ths/get-shape changed :copy2-root))))
|
||||
(t/is (mth/close? 21 (:r1 (ths/get-shape changed :copy2-child))))
|
||||
(t/is (nil? (:touched (ths/get-shape changed :copy2-child))))
|
||||
(t/is (mth/close? 7 (:r1 (ths/get-shape reset :copy2-child))))
|
||||
(t/is (nil? (:content-scale (ths/get-shape reset :copy2-root))))
|
||||
(t/is (= (thn/normalize-file changed) (thn/normalize-file undone)))))
|
||||
|
||||
|
||||
@ -146,6 +146,7 @@
|
||||
(add! :component-root)
|
||||
(add! :main-instance)
|
||||
(add! :shape-ref)
|
||||
(add! :content-scale)
|
||||
(add! :touched touched->str))))
|
||||
|
||||
(defn prefix-keys [m]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user