🐛 Fix loss of swap slot when copying to clipboard (#12434)

* 🐛 Fix loss of swap slot when copying to clipboard

* 🔧 Add test and make test helpers run same code as real functions

* 🔧 Changes after review
This commit is contained in:
Andrés Moya 2026-10-21 10:35:40 +02:00 committed by GitHub
parent 028547e19b
commit 3863e92993
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 208 additions and 80 deletions

View File

@ -1259,23 +1259,22 @@
(defn get-ref-chain-until-target-ref
"Returns a vector with the shape ref chain until target-ref, including itself"
[container libraries shape target-ref]
(loop [chain [shape]
current shape]
(if (= current target-ref)
(loop [chain [shape]
current shape
container container]
(if (= (:id current) target-ref)
chain
(if-let [ref (find-ref-shape nil container libraries current :with-context? true)]
(recur (conj chain ref) ref)
(recur (conj chain ref) ref (:container (meta ref)))
chain))))
(defn get-touched-from-ref-chain-until-target-ref
"Returns a set with the :touched of all the items on the shape
ref chain until target-ref, including itself"
[container libraries shape target-ref]
(let [chain (get-ref-chain-until-target-ref container libraries shape target-ref)
more-touched (->> chain
(map :touched)
(remove nil?)
(apply set/union)
(remove ctk/swap-slot?)
set)]
(set/union (or (:touched shape) #{}) more-touched)))
[container libraries shape target-ref & {:keys [remove-slots?] :or {remove-slots? true}}]
(let [chain (get-ref-chain-until-target-ref container libraries shape target-ref)
more-touched (cond->> chain
:always (map :touched)
:always (reduce set/union #{})
remove-slots? (remove ctk/swap-slot?))]
(set/union (or (:touched shape) #{}) (set more-touched))))

View File

@ -67,6 +67,43 @@
[potok.v2.core :as ptk]
[promesa.core :as p]))
(defn- advance-shape
[file libraries page level-delta head-id objects shape]
(let [new-shape-ref (ctf/advance-shape-ref file page libraries shape level-delta {:include-deleted? true})
container (ctn/make-container page :page)
;; The head becomes a root copy, which cannot carry a swap slot, so
;; strip inherited slots only for it. Nested sub-heads keep them so
;; they still pair with the children of the new near main.
head? (= (:id shape) head-id)
new-touched (ctf/get-touched-from-ref-chain-until-target-ref
container libraries shape new-shape-ref :remove-slots? head?)]
(cond-> objects
(and (some? new-shape-ref) (not= new-shape-ref (:shape-ref shape)))
(update (:id shape) assoc :shape-ref new-shape-ref :touched new-touched))))
(defn- advance-copy
[file libraries page objects shape]
(if (and (ctk/instance-head? shape) (not (ctk/main-instance? shape)))
(let [level-delta (if (nil? (ctk/get-swap-slot shape))
(ctn/get-nesting-level-delta (:objects page) shape uuid/zero)
0)]
(if (pos? level-delta)
(reduce (partial advance-shape file libraries page level-delta (:id shape))
objects
(cfh/get-children-with-self objects (:id shape)))
objects))
objects))
(defn advance-copies
"When copying an instance that is nested inside another one, we need to
advance the shape refs to one or more levels of remote mains."
[file libraries page selected data]
(let [heads (mapcat #(ctn/get-child-heads (:objects data) %) selected)]
(update data :objects
#(reduce (partial advance-copy file libraries page)
%
heads))))
(defn copy-selected
[]
(letfn [(sort-selected [state data]
@ -148,39 +185,13 @@
(let [frame (get objects parent-frame-id)]
(gsh/translate-to-frame shape frame))))
;; When copying an instance that is nested inside another one, we need to
;; advance the shape refs to one or more levels of remote mains.
(advance-copies [state selected data]
(let [file (dsh/lookup-file state)
libraries (:files state)
;; FIXME
page (dsh/lookup-page state)
heads (mapcat #(ctn/get-child-heads (:objects data) %) selected)]
(update data :objects
#(reduce (partial advance-copy file libraries page)
%
heads))))
(advance-copy [file libraries page objects shape]
(if (and (ctk/instance-head? shape) (not (ctk/main-instance? shape)))
(let [level-delta (if (nil? (ctk/get-swap-slot shape))
(ctn/get-nesting-level-delta (:objects page) shape uuid/zero)
0)]
(if (pos? level-delta)
(reduce (partial advance-shape file libraries page level-delta)
objects
(cfh/get-children-with-self objects (:id shape)))
objects))
objects))
(advance-shape [file libraries page level-delta objects shape]
(let [new-shape-ref (ctf/advance-shape-ref file page libraries shape level-delta {:include-deleted? true})
container (ctn/make-container page :page)
new-touched (ctf/get-touched-from-ref-chain-until-target-ref container libraries shape new-shape-ref)]
(cond-> objects
(and (some? new-shape-ref) (not= new-shape-ref (:shape-ref shape)))
(-> (assoc-in [(:id shape) :shape-ref] new-shape-ref)
(assoc-in [(:id shape) :touched] new-touched)))))
(advance-copies-from-state [state selected data]
(advance-copies (dsh/lookup-file state)
(:files state)
;; FIXME
(dsh/lookup-page state)
selected
data))
(on-copy-error [error]
(js/console.error "clipboard blocked:" error)
@ -232,7 +243,7 @@
(rx/merge-map (partial prepare-object objects frame-id))
(rx/reduce (partial collect-data state) initial)
(rx/map (partial sort-selected state))
(rx/map (partial advance-copies state selected))
(rx/map (partial advance-copies-from-state state selected))
(rx/map #(t/encode-str % {:type :json-verbose}))
(rx/map #(wapi/create-blob % "text/plain"))
(rx/subs! resolve reject))))]
@ -247,7 +258,7 @@
(rx/merge-map (partial prepare-object objects frame-id))
(rx/reduce (partial collect-data state) initial)
(rx/map (partial sort-selected state))
(rx/map (partial advance-copies state selected))
(rx/map (partial advance-copies-from-state state selected))
(rx/map #(t/encode-str % {:type :json-verbose}))
(rx/map clipboard/to-clipboard)
(rx/catch on-copy-error)

View File

@ -14,13 +14,11 @@
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh]
[app.common.logic.libraries :as cll]
[app.common.types.component :as ctk]
[app.common.types.container :as ctn]
[app.common.types.file :as ctf]
[app.common.types.shape :as cts]
[app.common.types.shape-tree :as ctst]
[app.common.uuid :as uuid]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.clipboard :as dwc]
[app.main.data.workspace.groups :as dwg]
[app.main.data.workspace.layout :as layout]))
@ -225,34 +223,7 @@
(if (= parent-frame-id uuid/zero)
shape
(let [frame (get objects parent-frame-id)]
(gsh/translate-to-frame shape frame))))
;; When copying an instance that is nested inside another one, we need to
;; advance the shape refs to one or more levels of remote mains.
(advance-copies [data]
(let [heads (mapcat #(ctn/get-child-heads (:objects data) %) selected)]
(update data :objects
#(reduce (partial advance-copy file libraries page)
%
heads))))
(advance-copy [file libraries page objects shape]
(if (and (ctk/instance-head? shape) (not (ctk/main-instance? shape)))
(let [level-delta (if (nil? (ctk/get-swap-slot shape))
(ctn/get-nesting-level-delta (:objects page) shape uuid/zero)
0)]
(if (pos? level-delta)
(reduce (partial advance-shape file libraries page level-delta)
objects
(cfh/get-children-with-self objects (:id shape)))
objects))
objects))
(advance-shape [file libraries page level-delta objects shape]
(let [new-shape-ref (ctf/advance-shape-ref file page libraries shape level-delta {:include-deleted? true})]
(cond-> objects
(and (some? new-shape-ref) (not= new-shape-ref (:shape-ref shape)))
(assoc-in [(:id shape) :shape-ref] new-shape-ref))))]
(gsh/translate-to-frame shape frame))))]
(let [file-id (:id file)
frame-id (cfh/common-parent-frame objects selected)
@ -273,4 +244,4 @@
(map (partial prepare-object objects frame-id))
(reduce collect-data initial)
sort-selected
advance-copies))))
(dwc/advance-copies file libraries page selected)))))

View File

@ -5,6 +5,7 @@
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.comp-remove-swap-slots-test
(:require
[app.common.files.validate :as cfv]
[app.common.test-helpers.components :as cthc]
[app.common.test-helpers.compositions :as ctho]
[app.common.test-helpers.files :as cthf]
@ -870,3 +871,149 @@
(t/is (some? copied-blue1'))
(t/is (nil? (ctk/get-swap-slot copied-blue1')))))))))
(t/deftest test-keep-inherited-swap-slot-copy-paste-nested-copy-to-root
(t/async
done
(let [;; ==== Setup
;; {:frame-red} [:name frame-red] # [Component :red]
;; {:frame-blue} [:name frame-blue] # [Component :blue]
;; {:frame-icon} [:name frame-icon] # [Component :icon]
;; :red-copy-icon [:name frame-red] @--> frame-red
;; {:frame-title} [:name frame-title] # [Component :title]
;; :icon-copy-title [:name frame-icon] @--> frame-icon
;; :blue-copy-title [:name frame-blue, :swap-slot-label :red-copy-icon] @--> frame-blue
;; :title-copy [:name frame-title] #--> frame-title
;; :icon-copy-in-title-copy [:name frame-icon] @--> :icon-copy-title
;; :blue-copy-in-title-copy [:name frame-blue] @--> :blue-copy-title
file (-> (cthf/sample-file :file1)
(ctho/add-frame :frame-red :name "frame-red")
(cthc/make-component :red :frame-red)
(ctho/add-frame :frame-blue :name "frame-blue")
(cthc/make-component :blue :frame-blue)
(ctho/add-frame :frame-icon :name "frame-icon")
(cthc/make-component :icon :frame-icon)
(cthc/instantiate-component :red :red-copy-icon :parent-label :frame-icon)
(ctho/add-frame :frame-title :name "frame-title")
(cthc/make-component :title :frame-title)
(cthc/instantiate-component :icon :icon-copy-title :parent-label :frame-title
:children-labels [:red-copy-in-icon-copy-title])
(cthc/component-swap :red-copy-in-icon-copy-title :blue :blue-copy-title)
(cthc/instantiate-component :title :title-copy
:children-labels [:icon-copy-in-title-copy
:blue-copy-in-title-copy]))
store (ths/setup-store file)
;; ==== Action
page (cthf/current-page file)
icon (cths/get-shape file :icon-copy-in-title-copy)
features #{"components/v2"}
version 47
pdata (thp/simulate-copy-shape #{(:id icon)} (:objects page) {(:id file) file} page file features version)
events
[(dws/select-shape uuid/zero)
(dw/paste-shapes pdata)]]
(ths/run-store
store done events
(fn [new-state]
(let [;; ==== Get
file' (ths/get-file-from-state new-state)
page' (cthf/current-page file')
icon' (cths/get-shape file' :icon-copy-in-title-copy)
blue' (cths/get-shape file' :blue-copy-in-title-copy)
copied-icon' (find-copied-shape icon' page' uuid/zero)
copied-blue' (find-copied-shape blue' page' (:id copied-icon'))
errors (try
(cfv/validate-file! file' {(:id file') file'})
nil
(catch :default cause
(ex-data cause)))]
;; ==== Check
;; the copied icon now refers to the icon main, where blue
;; does not exist, so the copied blue must keep the swap slot
;; inherited from :blue-copy-title
(t/is (some? copied-icon'))
(t/is (= (:shape-ref copied-icon') (cthi/id :frame-icon)))
(t/is (some? copied-blue'))
(t/is (= (ctk/get-swap-slot copied-blue') (cthi/id :red-copy-icon)))
;; the file must be valid after the paste
(t/is (nil? errors))))))))
(t/deftest test-keep-touched-copy-paste-nested-copy-to-root
(t/async
done
(let [;; ==== Setup
;; Add a nested copy inside a copy of :green, and override its fill
;; so that the copy has a touched attribute.
file (-> (setup-file)
(cthc/instantiate-component :green :green-copy-2
:parent-label :frame-b2
:children-labels [:red-copy-in-green-copy-2])
(cths/update-shape :red-copy-in-green-copy-2
:fills (cths/sample-fills-color :fill-color "#ff0000")))
store (ths/setup-store file)
;; ==== Action
page (cthf/current-page file)
red (cths/get-shape file :red-copy-in-green-copy-2)
features #{"components/v2"}
version 47
pdata (thp/simulate-copy-shape #{(:id red)} (:objects page) {(:id file) file} page file features version)
events
[(dws/select-shape uuid/zero)
(dw/paste-shapes pdata)]]
(ths/run-store
store done events
(fn [new-state]
(let [;; ==== Get
file' (ths/get-file-from-state new-state)
page' (cthf/current-page file')
red' (cths/get-shape file' :red-copy-in-green-copy-2)
copied-red' (find-copied-shape red' page' uuid/zero)]
;; ==== Check
;; the touched fill override must be preserved in the pasted copy
(t/is (some? copied-red'))
(t/is (contains? (:touched copied-red') :fill-group))
(t/is (= (:touched red') (:touched copied-red')))))))))
(t/deftest test-remove-swap-slot-copy-paste-nested-swapped-copy-to-root
(t/async
done
(let [;; ==== Setup (sample file as is)
file (setup-file)
store (ths/setup-store file)
;; ==== Action
page (cthf/current-page file)
blue (cths/get-shape file :blue-copy-in-green-copy)
features #{"components/v2"}
version 47
pdata (thp/simulate-copy-shape #{(:id blue)} (:objects page) {(:id file) file} page file features version)
events
[(dws/select-shape uuid/zero)
(dw/paste-shapes pdata)]]
(ths/run-store
store done events
(fn [new-state]
(let [;; ==== Get
file' (ths/get-file-from-state new-state)
page' (cthf/current-page file')
blue' (cths/get-shape file' :blue-copy-in-green-copy)
copied-blue' (find-copied-shape blue' page' uuid/zero)]
;; ==== Check
;; the copied blue has no swap-id
(t/is (some? copied-blue'))
(t/is (nil? (ctk/get-swap-slot copied-blue')))))))))