🐛 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:
alonso.torres 2026-10-22 14:36:25 +02:00
parent c0e814421f
commit e2740f3be7
9 changed files with 65 additions and 7 deletions

View File

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

View File

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

View File

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

View File

@ -27,6 +27,7 @@
"The attributes a transform can change, so an update after it compares
only these."
#{:selrect
:content-scale
:points
:x
:y

View File

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

View File

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

View File

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

View File

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

View File

@ -146,6 +146,7 @@
(add! :component-root)
(add! :main-instance)
(add! :shape-ref)
(add! :content-scale)
(add! :touched touched->str))))
(defn prefix-keys [m]