🐛 Sync content from the restored position after a swap back

A swap back relinks to its near position and clears the swap marker.
Sync through the enclosing copy that owns those refs so its content
overrides reach the restored head and children. Keep carried overrides
and unrelated sibling overrides intact.

Portable regression: three failures before the fix, then all assertions
pass, including exact undo. Focused JVM and golden checks: 183 tests,
2143 assertions. The original and newly shrunk library programs pass.
Live WASM verifies plain and variant swaps, later propagation, carried
opacity and an unrelated sibling override. Common/frontend lint and fmt pass.

Component benchmark ratios: mains 0.61/0.80/0.85; levels 0.71;
chain 0.73; library 0.52; many shapes 0.53; variant switch 0.62.
All worlds pass the unchanged baseline gate.

AI-assisted-by: GPT-6
This commit is contained in:
alonso.torres 2026-10-24 14:51:22 +02:00
parent fbc0b7fd93
commit 60f063a775
3 changed files with 39 additions and 1 deletions

View File

@ -30,7 +30,7 @@ Intended semantics of copies, `:touched` and component sync. This is the spec: w
- Instantiate / nest a copy: the new subtree has no touched groups.
- Duplicate a copy: the new subtree keeps the source's values and touched; if its nesting level changes, touched is inherited as in detach. Paste and move out of a copy follow the same rule.
- Swap without keep-touched: the new subtree keeps `swap-keep-attrs` values; the new head keeps the old shape's id. A retained rotation or flip that differs from the new component's expected placement becomes a `:geometry-group` override so later sync preserves it, including orientation inherited from the old component. The swap authors that choice. Size-only overrides clear. Orientation inherited from the position source or
the parent stays untouched, including references into deleted mains (F154). Other content overrides clear. Inside a copy it keeps the old `:shape-ref` and gets `:swap-group` (none when the new component is the near main's); inside a main it is a plain nested copy.
the parent stays untouched, including references into deleted mains (F154). Other content overrides clear. Inside a copy it keeps the old `:shape-ref` and gets `:swap-group` (none when the new component is the near main's); inside a main it is a plain nested copy. Swapping back produces a clean copy of the near position, including its content overrides, without authoring new groups (F176). Sync through the enclosing copy that owns the new refs; direct sync from the chosen component skips those indirect refs.
- Variant switch with keep-touched: an attr is carried from the old copy only when its group is touched and the old and new mains hold the same value for it. Resulting touched = groups of carried attrs (+ `:swap-group` when the head is inside a copy). The root also keeps a retained rotation or flip as a geometry override when it differs from the new expected placement, including orientation supplied by the old component (F146, F175). Placement still supplied by an enclosing copy remains inherited when no root geometry override was carried. A value equal to the new main is not carried and not touched. Texts: the same per text group (letters carried only when both mains have the same letters; a style group when both mains agree on it once placed on each other's letters).
- Delete component: copies keep their data. The deleted main cannot be edited, so it starts no sync, but changes from components nested in it still pass through: sync updates its stored shapes (`:objects` of the deleted component), its copies, and the components that nest it. Restore brings back an up-to-date main.
- Undo/redo: restores the exact prior state, `:touched` and `:remote-synced` included, of the op and the sync it caused together (same undo group, one step). Undo never needs a re-sync.

View File

@ -453,6 +453,23 @@
(when-let [parent (get objects parent-id)]
(or (d/index-of (:shapes parent) shape-id) 0)))
(defn- sync-restored-position
"A swap back is an ordinary copy of its position, including that
position's content overrides. Sync through the head that owns its ref."
[changes page libraries shape-id]
(let [objects (pcb/get-objects changes)
shape (get objects shape-id)
parent (get objects (:parent-id shape))
container (ctn/make-container (assoc page :objects objects) :page)]
(if (and (:shape-ref parent) (not (ctk/swapped? shape)))
(let [component (ctf/find-ref-component nil container libraries shape)
head (d/seek #(= (:id component) (:component-id %))
(ctn/get-parent-copy-heads objects parent))]
(if head
(cll/generate-sync-shape-direct changes nil libraries container (:id head) false)
changes))
changes)))
(defn generate-component-swap-in-place
"Swap the copy head `shape` of `page` for a copy of the component
`component-id` of library data `ldata`. The new head takes the old one's
@ -482,6 +499,8 @@
(ctf/find-ref-shape nil page libraries shape
:include-deleted? true :with-context? true))) []])
changes (sync-restored-position changes page libraries (:id new-shape))
;; Replacement keeps the head id and its cell. Cleanup during the
;; temporary deletion must not change the parent's placement data.
changes (cond-> changes

View File

@ -966,3 +966,22 @@
(t/is (= (:fills main-child) (:fills (ths/get-shape-by-id full (:id main-child)))))
(t/is (= (thn/normalize-file (tm/file edited))
(thn/normalize-file (tm/file (r/step updated (n/undo))))))))
(t/deftest swapping-back-inherits-the-new-positions-content-overrides
(let [initial (tm/make-situation (setup))
child-id (first (:shapes (tm/resolve-shape initial :card-star)))
edited (-> initial
(r/step (n/change-property :card-star :fills "#333333")) r/settle
(r/step (n/change-property (constantly child-id) :fills "#444444")) r/settle)
swapped (-> edited (r/step (n/swap-shape :copy-star :star)) r/settle)
head (tm/resolve-shape swapped :copy-star)
child (tm/shape-by-id swapped (first (:shapes head)))
file (tm/file swapped)
full (:file (r/sync-cascade file {} (keys (get-in file [:data :components]))))]
(t/is (= "#333333" (get-in head [:fills 0 :fill-color])))
(t/is (= "#444444" (get-in child [:fills 0 :fill-color])))
(t/is (empty? (:touched head)))
(t/is (empty? (:touched child)))
(t/is (= (:fills head) (:fills (ths/get-shape-by-id full (:id head)))))
(t/is (= (thn/normalize-file (tm/file edited))
(thn/normalize-file (tm/file (r/step swapped (n/undo))))))))