Translation-aware modifier propagation and lazy parent walks

This commit is contained in:
Elena Torro 2026-05-05 15:35:00 +02:00
parent 9230091492
commit 97511ba6e5
7 changed files with 262 additions and 138 deletions

View File

@ -75,27 +75,28 @@
(reduce check-shape changes mod-obj-changes)))
(defn generate-update-shapes
[changes ids update-fn objects {:keys [attrs changed-sub-attr ignore-tree ignore-touched with-objects?]}]
(let [changes
(->> ids
(reduce
(fn [changes id]
(let [opts {:attrs attrs
:ignore-geometry? (get ignore-tree id)
:ignore-touched ignore-touched
:with-objects? with-objects?}]
(pcb/update-shapes changes [id] update-fn (d/without-nils opts))))
(cond-> changes
(some? objects) (pcb/with-objects objects))))
grid-ids
(->> ids (filter (partial ctl/grid-layout? objects)))
[changes ids update-fn objects {:keys [attrs changed-sub-attr ignore-tree ignore-touched with-objects? translation?]}]
(let [changes (reduce
(fn [changes id]
(let [opts {:attrs attrs
:ignore-geometry? (get ignore-tree id)
:ignore-touched ignore-touched
:with-objects? with-objects?}]
(pcb/update-shapes changes [id] update-fn (d/without-nils opts))))
(cond-> changes
(some? objects) (pcb/with-objects objects))
ids)
;; Translation doesn't shift children between grid cells, so
;; cell reassignment + child reorder are no-ops.
grid-ids (when-not translation?
(->> ids (filter (partial ctl/grid-layout? objects))))
changes (cond-> changes
(seq grid-ids)
(-> (pcb/update-shapes grid-ids ctl/assign-cell-positions {:with-objects? true})
(pcb/reorder-grid-children ids))
changes
(-> changes
(pcb/update-shapes grid-ids ctl/assign-cell-positions {:with-objects? true})
(pcb/reorder-grid-children ids)
(cond-> (not ignore-touched)
(generate-unapply-tokens objects changed-sub-attr)))]
(not ignore-touched)
(generate-unapply-tokens objects changed-sub-attr))]
changes))
(defn- generate-update-shape-flags

View File

@ -486,53 +486,78 @@
;; or inside its main component if it's in a copy.
comps-nesting-loop?)))
(defn parent-validation-cache
"Pre-computes the `children`-derived data for `find-valid-parent-and-frame-ids`.
Build once per gesture and pass as `cache`; values are delays so unused
branches stay unrealized."
[objects children libraries]
(let [children-ids (set (map :id children))
top-children (remove #(contains? children-ids (:parent-id %)) children)
all-main? (every? ctk/main-instance? top-children)
get-variant-id (fn [shape]
(when (:component-id shape)
(-> (get-component-from-shape shape libraries)
:variant-id)))
descendants (delay (mapcat #(cfh/get-children-with-self objects %) children-ids))
any-variant-container-descendant (delay (some ctk/is-variant-container? @descendants))
descendants-variant-ids-set (delay (->> @descendants
(map get-variant-id)
set))
any-main-descendant
(delay
(some
(fn [shape]
(some ctk/main-instance? (cfh/get-children-with-self objects (:id shape))))
children))]
{:top-children top-children
:all-main? all-main?
:descendants descendants
:any-variant-container-descendant any-variant-container-descendant
:descendants-variant-ids-set descendants-variant-ids-set
:any-main-descendant any-main-descendant}))
(defn find-valid-parent-and-frame-ids
"Navigate trough the ancestors until find one that is valid. Returns [ parent-id frame-id ]"
([parent-id objects children]
(find-valid-parent-and-frame-ids parent-id objects children false nil))
(find-valid-parent-and-frame-ids parent-id objects children false nil nil))
([parent-id objects children pasting? libraries]
(find-valid-parent-and-frame-ids parent-id objects children pasting? libraries nil))
([parent-id objects children pasting? libraries cache]
(letfn [(get-frame [pid]
(if (cfh/frame-shape? objects pid) pid (get-in objects [pid :frame-id])))]
;; `descendants`, variant-id set, etc. depend only on the moved shapes, not on the
;; candidate parent. Computing them once per drag (this fn is hot during move)
;; avoids O(depth * subtree) work when walking invalid ancestors — common with
;; variants and nested components.
(let [children-ids (into #{} (map :id) children)
top-children (remove #(contains? children-ids (:parent-id %)) children)
all-main? (every? ctk/main-instance? top-children)
get-variant-id
(fn [shape]
(when (:component-id shape)
(-> (get-component-from-shape shape libraries)
:variant-id)))
descendants (mapcat #(cfh/get-children-with-self objects %) children-ids)
any-variant-container-descendant (some ctk/is-variant-container? descendants)
descendants-variant-ids-set (into #{} (map get-variant-id) descendants)
;; Same as (some #(some ctk/main-instance? (cfh/get-children-with-self objects (:id %)))
;; children) but a single walk over `descendants`.
any-main-descendant (some ctk/main-instance? descendants)]
;; Predicates below are ordered so cheap parent/ascendant checks
;; short-circuit before the descendant delays are forced.
(let [{:keys [top-children all-main? any-variant-container-descendant
descendants-variant-ids-set any-main-descendant]}
(or cache (parent-validation-cache objects children libraries))]
(loop [parent-id parent-id]
(let [parent (get objects parent-id)
;; We can always move the children to the parent they already have.
;; But if we are pasting, those are new items, so it is considered a change
no-changes?
(and (every? #(= parent-id (:parent-id %)) top-children)
(not pasting?))
ascendants (cfh/get-parents-with-self objects parent-id)
any-main-ascendant (some ctk/main-instance? ascendants)
any-variant-container-ascendant (some ctk/is-variant-container? ascendants)]
(if (or no-changes?
(and (not (invalid-structure-for-component? objects parent children pasting? libraries))
;; If we are moving (not pasting) into a main component, no descendant can be main
(or pasting? (nil? any-main-descendant) (not (ctk/main-instance? parent)))
(or pasting? (not (ctk/main-instance? parent)) (nil? @any-main-descendant))
;; Don't allow variant-container inside variant container nor main
(or (not any-variant-container-descendant)
(and (not any-variant-container-ascendant) (not any-main-ascendant)))
(or (and (not any-variant-container-ascendant) (not any-main-ascendant))
(not @any-variant-container-descendant))
;; If the parent is a variant-container, all the items should be main
(or (not (ctk/is-variant-container? parent)) all-main?)
;; If we are pasting, the parent can't be a "brother" of any of the pasted items,
;; so not have the same variant-id of any descendant
(or (not pasting?)
(not (ctk/is-variant? parent))
(not (contains? descendants-variant-ids-set (:variant-id parent))))))
(not (contains? @descendants-variant-ids-set (:variant-id parent))))))
[parent-id (get-frame parent-id)]
(recur (:parent-id parent)))))))))

View File

@ -631,9 +631,56 @@
(def ^:private xf:map-key (map key))
(defn- expand-translation-entry
"Expand one translation-only geometry entry into [descendant-id matrix]
pairs covering the moved shape's full subtree (every descendant gets
the same matrix)."
[[id data] objects subtree-ids-by-id]
(let [m (:transform data)
sub (or (get subtree-ids-by-id id)
(cfh/get-children-ids-with-self objects id))]
(map (fn [sid] [sid m]) sub)))
(defn- expand-translation-modifiers
"Pure translation propagates as identity to descendants: every shape in
the subtree gets the same matrix. Builds the flat [id matrix] list
directly, skipping the WASM tree walk + FFI roundtrip used by
`propagate-modifiers` for the general (resize/rotate) case.
Only safe when pixel-snap is off: WASM applies pixel correction
per-shape (different scale/translation per descendant), which we
can't replicate cheaply on the CLJS side."
[geometry-entries objects subtree-ids-by-id]
(into []
(mapcat #(expand-translation-entry % objects subtree-ids-by-id))
geometry-entries))
(defn- translate-selrect
"Shift `selrect`'s center by (tx, ty). Width/height/transform are
invariant under pure translation, so only `:center` moves."
[selrect tx ty]
(update selrect :center
(fn [c] (gpt/point (+ (:x c) tx) (+ (:y c) ty)))))
(defn- cached-translation-selrect
"Translation-only fast path for the live selection rect. Avoids a WASM
`get-selection-rect` call per drag frame by caching the gesture-start
base: on the first emission, ask WASM and back out the current delta
to recover the base; on every later emission, shift the cached base
by the new (tx, ty)."
[ids ^js first-matrix cache]
(let [tx (.-e first-matrix)
ty (.-f first-matrix)]
(if-let [base @cache]
(translate-selrect base tx ty)
(let [computed (wasm.api/get-selection-rect ids)]
(vreset! cache (translate-selrect computed (- tx) (- ty)))
computed))))
#_:clj-kondo/ignore
(defn set-wasm-modifiers
[modif-tree & {:keys [ignore-constraints ignore-snap-pixel]
[modif-tree & {:keys [ignore-constraints ignore-snap-pixel
subtree-ids-by-id selection-rect-cache]
:or {ignore-constraints false ignore-snap-pixel false}
:as params}]
(ptk/reify ::set-wasm-modifiers
@ -658,14 +705,24 @@
wasm-props (:wasm-props state)
objects (dsh/lookup-page-objects state)
snap-pixel?
(and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))]
(and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))
translation?
(every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))]
(set-wasm-props! objects prev-wasm-props wasm-props)
(wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))
(when-not translation?
(wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)))
(let [geometry-entries (parse-geometry-modifiers modif-tree)
modifiers (wasm.api/propagate-modifiers geometry-entries snap-pixel?)]
modifiers
(if (and translation? (not snap-pixel?))
(expand-translation-modifiers geometry-entries objects subtree-ids-by-id)
(wasm.api/propagate-modifiers geometry-entries snap-pixel?))]
(wasm.api/set-modifiers modifiers)
(let [ids (into [] xf:map-key geometry-entries)
selrect (wasm.api/get-selection-rect ids)]
(let [ids (into [] xf:map-key geometry-entries)
selrect
(if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers))
(cached-translation-selrect ids (second (first modifiers)) selection-rect-cache)
(wasm.api/get-selection-rect ids))]
(rx/of (set-temporary-selrect selrect)
(set-temporary-modifiers modifiers))))))))
@ -694,92 +751,110 @@
#_:clj-kondo/ignore
(defn apply-wasm-modifiers
[modif-tree & {:keys [ignore-constraints ignore-snap-pixel snap-ignore-axis undo-transation?]
[modif-tree & {:keys [ignore-constraints ignore-snap-pixel snap-ignore-axis undo-transation?
subtree-ids-by-id]
:or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil undo-transation? true}
:as params}]
(ptk/reify ::apply-wasm-modifiesr
ptk/WatchEvent
(watch [_ state _]
(wasm.api/clean-modifiers)
(wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))
(let [translation?
(every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))]
(wasm.api/clean-modifiers)
(when-not translation?
(wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)))
;; Apply property changes (e.g. grow-type) to WASM shapes before
;; propagating geometry, so propagate_modifiers sees the updated state.
(doseq [[id {:keys [property value]}] (extract-property-changes modif-tree)]
(when (= property :grow-type)
(wasm.api/use-shape id)
(wasm.api/set-shape-grow-type value)))
;; Apply property changes (e.g. grow-type) to WASM shapes before
;; propagating geometry, so propagate_modifiers sees the updated state.
(doseq [[id {:keys [property value]}] (extract-property-changes modif-tree)]
(when (= property :grow-type)
(wasm.api/use-shape id)
(wasm.api/set-shape-grow-type value)))
(let [objects (dsh/lookup-page-objects state)
(let [objects (dsh/lookup-page-objects state)
geometry-entries
(parse-geometry-modifiers modif-tree)
geometry-entries
(parse-geometry-modifiers modif-tree)
snap-pixel?
(and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))
snap-pixel?
(and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))
transforms
(into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))
transforms
(if (and translation? (not snap-pixel?))
;; Mirror WASM `propagate_modifiers` in CLJS: splat the
;; translation matrix onto every descendant. Without
;; this step the commit would only touch the dragged
;; primaries and descendants would snap back to their
;; pre-drag positions on drop.
;;
;; Skipped when `snap-pixel?` is on: WASM applies
;; per-shape pixel correction (different scale/translate
;; per descendant) which we can't replicate cheaply on
;; the CLJS side.
(reduce
(fn [acc [id data]]
(let [t (:transform data)
subtree-ids
(or (get subtree-ids-by-id id)
(cfh/get-children-ids-with-self objects id))]
(reduce (fn [a sid] (assoc a sid t)) acc subtree-ids)))
{}
geometry-entries)
(into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?)))
;; Pure-translation gesture: every shape's modifier only
;; contains `:move` operations (no resize/rotate/scale and
;; no structural mutation)
translation?
(every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))
ignore-tree
(calculate-ignore-tree-wasm transforms objects)
ignore-tree
(calculate-ignore-tree-wasm transforms objects)
options
(-> params
(assoc :reg-objects? true)
(assoc :ignore-tree ignore-tree)
(assoc :translation? translation?)
;; Attributes that can change in the transform. This
;; way we don't have to check all the attributes
(assoc :attrs transform-attrs))
options
(-> params
(assoc :reg-objects? true)
(assoc :ignore-tree ignore-tree)
(assoc :translation? translation?)
;; Attributes that can change in the transform. This
;; way we don't have to check all the attributes
(assoc :attrs transform-attrs))
modif-tree
(propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state))
modif-tree
(propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state))
ids
(into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms))
ids
(into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms))
update-shape
(fn [shape]
(let [shape-id (dm/get-prop shape :id)
transform (get transforms shape-id)
modifiers (dm/get-in modif-tree [shape-id :modifiers])]
(-> shape
(gsh/apply-transform transform)
(ctm/apply-structure-modifiers modifiers))))
update-shape
(fn [shape]
(let [shape-id (dm/get-prop shape :id)
transform (get transforms shape-id)
modifiers (dm/get-in modif-tree [shape-id :modifiers])]
(-> shape
(gsh/apply-transform transform)
(ctm/apply-structure-modifiers modifiers))))
bool-ids
(into #{}
(comp
(mapcat (partial cfh/get-parents-with-self objects))
(filter cfh/bool-shape?)
(map :id))
ids)
bool-ids
(into #{}
(comp
(mapcat (partial cfh/get-parents-with-self objects))
(filter cfh/bool-shape?)
(map :id))
ids)
undo-id (js/Symbol)]
(rx/concat
(if undo-transation?
(rx/of (dwu/start-undo-transaction undo-id))
(rx/empty))
(rx/of
(clear-local-transform)
(ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms})
(ptk/event ::dwcm/move-frame-comment-threads transforms)
(dwsh/update-shapes ids update-shape options)
undo-id (js/Symbol)]
(rx/concat
(if undo-transation?
(rx/of (dwu/start-undo-transaction undo-id))
(rx/empty))
(rx/of
(clear-local-transform)
(ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms})
(ptk/event ::dwcm/move-frame-comment-threads transforms)
(dwsh/update-shapes ids update-shape options)
;; The update to the bool path needs to be in a different operation because it
;; needs to have the updated children info
(dwsh/update-shapes bool-ids path/update-bool-shape (assoc options :with-objects? true)))
;; The update to the bool path needs to be in a different operation because it
;; needs to have the updated children info
(dwsh/update-shapes bool-ids path/update-bool-shape (assoc options :with-objects? true)))
(if undo-transation?
(rx/of (dwu/commit-undo-transaction undo-id))
(rx/empty)))))))
(if undo-transation?
(rx/of (dwu/commit-undo-transaction undo-id))
(rx/empty))))))))
(def ^:private
xf-rotation-shape

View File

@ -28,6 +28,8 @@
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
;; If anything a translation can mutate is added here, drop the
;; `(when-not translation? …)` guard in `update-shapes` below.
(def ^:private update-layout-attr? #{:hidden})
(defn- add-undo-group
@ -180,8 +182,9 @@
(map :id))
update-layout-ids
(->> (into [] xf-update-layout ids)
(not-empty))
(when-not translation?
(->> (into [] xf-update-layout ids)
(not-empty)))
changes
(-> (pcb/empty-changes it page-id)
@ -194,7 +197,8 @@
:changed-sub-attr changed-sub-attr
:ignore-tree ignore-tree
:ignore-touched ignore-touched
:with-objects? with-objects?})
:with-objects? with-objects?
:translation? translation?})
(cond-> undo-group
(pcb/set-undo-group undo-group))
(pcb/set-translation? translation?))

View File

@ -658,10 +658,6 @@
ptk/WatchEvent
(watch [_ state stream]
(let [prev-cell-data (volatile! nil)
;; Cache the resolved valid parent while hovering the same raw target frame.
;; `find-valid-parent-and-frame-ids` may walk many ancestors for variants/components,
;; and the result is stable during the gesture (objects/libraries are constant here).
find-valid-for-raw-cache (volatile! {:raw nil :pair nil})
page-id (:current-page-id state)
libraries (dsh/lookup-libraries state)
objects (dsh/lookup-page-objects state page-id)
@ -708,7 +704,21 @@
(rx/map #(array pos %)))))))]
(if (empty? shapes)
(rx/of (finish-transform))
(let [move-stream
;; Per-gesture caches: `shapes`/`objects`/`libraries` are
;; stable for the gesture, so build once and thread through.
(let [parent-validation-cache
(ctn/parent-validation-cache objects shapes libraries)
subtree-ids-by-id
(into {}
(map (fn [id]
[id (cfh/get-children-ids-with-self objects id)]))
ids)
selection-rect-cache
(volatile! nil)
move-stream
(->> position
;; We ask for the snap position but we continue even if the result is not available
(rx/with-latest-from snap-delta)
@ -722,14 +732,8 @@
(fn [[move-vector mod?]]
(let [position (gpt/add from-position move-vector)
exclude-frames (if mod? exclude-frames exclude-frames-siblings)
raw-target (ctst/top-nested-frame objects position exclude-frames)
cache @find-valid-for-raw-cache
[target-frame _]
(if (= raw-target (:raw cache))
(:pair cache)
(let [pair (ctn/find-valid-parent-and-frame-ids raw-target objects shapes false libraries)]
(vreset! find-valid-for-raw-cache {:raw raw-target :pair pair})
pair))
target-frame (ctst/top-nested-frame objects position exclude-frames)
[target-frame _] (ctn/find-valid-parent-and-frame-ids target-frame objects shapes false libraries parent-validation-cache)
flex-layout? (ctl/flex-layout? objects target-frame)
grid-layout? (ctl/grid-layout? objects target-frame)
drop-index (when flex-layout? (gslf/get-drop-index target-frame objects position))
@ -782,7 +786,10 @@
(rx/sample 16)
(rx/map
(fn [[modifiers snap-ignore-axis]]
(dwm/set-wasm-modifiers modifiers :snap-ignore-axis snap-ignore-axis))))
(dwm/set-wasm-modifiers modifiers
:snap-ignore-axis snap-ignore-axis
:subtree-ids-by-id subtree-ids-by-id
:selection-rect-cache selection-rect-cache))))
(->> move-stream
(rx/with-latest-from ms/mouse-position-alt)
@ -807,7 +814,8 @@
(dwu/start-undo-transaction undo-id)
(dwm/apply-wasm-modifiers modifiers
:snap-ignore-axis snap-ignore-axis
:undo-transation? false)
:undo-transation? false
:subtree-ids-by-id subtree-ids-by-id)
(move-shapes-to-frame ids target-frame drop-index drop-cell)
(finish-transform)
(dwu/commit-undo-transaction undo-id))))))))

View File

@ -75,6 +75,11 @@
(track! :set-structure-modifiers)
nil)
(defn- mock-set-modifiers
[_modifiers]
(track! :set-modifiers)
nil)
(defn- mock-set-shape-grow-type
[_grow-type]
(track! :set-shape-grow-type)
@ -141,6 +146,7 @@
{:clean-modifiers wasm.api/clean-modifiers
:set-structure-modifiers wasm.api/set-structure-modifiers
:propagate-modifiers wasm.api/propagate-modifiers
:set-modifiers wasm.api/set-modifiers
:set-shape-grow-type wasm.api/set-shape-grow-type
:set-shape-text-content wasm.api/set-shape-text-content
:set-shape-text-images wasm.api/set-shape-text-images
@ -152,6 +158,7 @@
(set! wasm.api/clean-modifiers mock-clean-modifiers)
(set! wasm.api/set-structure-modifiers mock-set-structure-modifiers)
(set! wasm.api/propagate-modifiers mock-propagate-modifiers)
(set! wasm.api/set-modifiers mock-set-modifiers)
(set! wasm.api/set-shape-grow-type mock-set-shape-grow-type)
(set! wasm.api/set-shape-text-content mock-set-shape-text-content)
(set! wasm.api/set-shape-text-images mock-set-shape-text-images)
@ -167,6 +174,7 @@
(set! wasm.api/clean-modifiers (:clean-modifiers orig))
(set! wasm.api/set-structure-modifiers (:set-structure-modifiers orig))
(set! wasm.api/propagate-modifiers (:propagate-modifiers orig))
(set! wasm.api/set-modifiers (:set-modifiers orig))
(set! wasm.api/set-shape-grow-type (:set-shape-grow-type orig))
(set! wasm.api/set-shape-text-content (:set-shape-text-content orig))
(set! wasm.api/set-shape-text-images (:set-shape-text-images orig))

View File

@ -56,10 +56,13 @@
;; guide has moved
(t/is (= (:position guide') 100))
;; WASM mocks were exercised
(t/is (pos? (thw/call-count :clean-modifiers)))
(t/is (pos? (thw/call-count :set-structure-modifiers)))
(t/is (pos? (thw/call-count :propagate-modifiers)))))))))))
;; WASM bridge was exercised. `dw/update-position`
;; routes through `apply-wasm-modifiers`, which for
;; translation-only updates calls only `clean-modifiers`
;; and computes the per-descendant transforms in CLJS
;; (skipping `set-structure-modifiers` and
;; `propagate-modifiers`).
(t/is (pos? (thw/call-count :clean-modifiers)))))))))))