penpot/frontend/src/app/main/data/workspace/shape_layout.cljs
alonso.torres fe91c9a4a0 ✨ Build variant actions and update main changes in common
Several actions built their changes in frontend events, step by step,
each step its own commit, so the pure runner could not run them and
one undo could revert half an action.

- Renaming a variant member in the layers panel sent three events,
  each in its own undo transaction: update the properties from the
  typed formula, remove the properties no member gives a value, and
  set or clear the member's error. One undo reverted only the last.
  clvp/generate-rename-member builds the three steps in one changes
  (with generate-update-properties and
  generate-remove-empty-properties), and dwv/rename-member commits it
  once with the same analytics events.
  update-properties-names-and-values goes; remove-empty-properties and
  update-error stay for the design tab.
- Transforming a component into a variant sent about fourteen events,
  and combining components as variants that transform and eight more.
  clv/generate-transform-in-variant and
  clv/generate-combine-as-variants build those steps as one changes,
  from the generators common already had, and the events commit them
  once. The flex layout initializer moves to common as
  cls/layout-initializer. Combine still sizes the board with two
  frontend dimension updates inside its undo transaction: the
  frontend applies them through modifiers, which common has no step
  for.
- update-component split the changes of an update main (the copy's,
  marked :local-change?, from the main's, which may belong to a
  library) with two transducers; cll/split-local-changes does it. Its
  undo-group argument, never passed, goes, and the main's library is
  the copy head's :component-file, which it looked up through the
  file data only to read its id back.
- dwv/valid-variant-switch? read the libraries from the state and
  repeated the lookup of clv/find-switch-target. clv/valid-switch?
  takes the libraries and asks find-switch-target; the plugin's
  switchVariant and the frontend test call it.

Behavior changes:
- One undo after a rename of a member, a transform or a combine
  reverts all of it. After a transform, it reverted only the sync of
  the copies' names (F6 in case AT); its pins go.
- The transform no longer sends the "create-board" analytics event of
  the board it creates around the main.

In the composable suites, renaming a member in the layers panel,
removing the empty properties and setting a member's error are
variant property edits both runners run, and case AW joins the JVM
set. Its rename node drives the real layers-panel rename, which
removes the empty properties too, so the step that removed them goes.
The golden masters are recorded again for AW (the JVM one gains the
case, the frontend one loses that step) and for AT (its final states
no longer list the F6 undo failures).

AI-assisted-by: claude-opus-5-5

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 00:35:55 +02:00

933 lines
37 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.main.data.workspace.shape-layout
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh]
[app.common.files.shapes-helpers :as cfsh]
[app.common.geom.point :as gpt]
[app.common.logic.duplicate :as cld]
[app.common.logic.shapes :as cls]
[app.common.types.color :as clr]
[app.common.types.component :as ctc]
[app.common.types.modifiers :as ctm]
[app.common.types.shape.layout :as ctl]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.event :as ev]
[app.main.data.helpers :as dsh]
[app.main.data.workspace :as-alias dw]
[app.main.data.workspace.colors :as cl]
[app.main.data.workspace.grid-layout.editor :as dwge]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.selection :as dwse]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.undo :as dwu]
[app.main.features :as features]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(def layout-keys
[:layout
:layout-flex-dir
:layout-gap-type
:layout-gap
:layout-align-items
:layout-justify-content
:layout-align-content
:layout-wrap-type
:layout-padding-type
:layout-padding
:layout-gap-type])
;; Never call this directly but through the data-event `:layout/update`
;; Otherwise a lot of cycle dependencies could be generated
(defn- update-layout-positions
[{:keys [page-id ids undo-group reflow-tasks]}]
(ptk/reify ::update-layout-positions
ptk/WatchEvent
(watch [_ state stream]
(let [page-id (or page-id (:current-page-id state))
objects (dsh/lookup-page-objects state page-id)
ids (->> ids (remove uuid/zero?) (filter #(contains? objects %)))
update-positions-stream
(if (d/not-empty? ids)
(let [modif-tree (dwm/create-modif-tree ids (ctm/reflow-modifiers))]
(if (features/active-feature? state "render-wasm/v1")
(rx/of (dwm/apply-wasm-modifiers modif-tree
:stack-undo? true
:undo-group undo-group
:ignore-touched true))
(rx/of (dwm/apply-modifiers {:page-id page-id
:modifiers modif-tree
:stack-undo? true
:ignore-touched true
:undo-group undo-group}))))
(rx/empty))
;; The apply events above are dispatched and applied synchronously,
;; except while a shape-update buffer is open (token propagation),
;; where the commit lands on `update-shapes-buffer-commit`.
drain-stream
(if (and (::dwsh/update-shapes-buffer state)
(d/not-empty? ids))
(->> stream
(rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit))
(rx/take 1)
(rx/take-until (rx/filter (ptk/type? ::dw/finalize-workspace) stream))
;; No events are derived from this
(rx/ignore))
(rx/empty))]
(cond->> (rx/concat update-positions-stream drain-stream)
(d/not-empty? reflow-tasks)
(rx/finalize #(wrf/finish-tasks! reflow-tasks)))))))
(defn- without-root-board
[ids]
(into [] (remove uuid/zero?) ids))
(defn initialize-shape-layout
[]
(ptk/reify ::initialize-shape-layout
ptk/WatchEvent
(watch [_ _ stream]
(let [stopper (rx/filter (ptk/type? ::finalize-shape-layout) stream)]
(->> stream
(rx/filter (ptk/type? :layout/update))
;; Keeps each update's page and the shapes that can be laid out;
;; the root lays out nothing, so an update with only it is dropped.
(rx/map (fn [event]
(-> (deref event)
(update :ids without-root-board))))
(rx/filter #(d/not-empty? (:ids %)))
(rx/map #(assoc % ::reflow-task (wrf/start! :layout (:ids %))))
(rx/buffer-time 100)
(rx/filter #(d/not-empty? %))
(rx/mapcat
(fn [updates]
(->> (group-by :page-id updates)
(map (fn [[page-id updates]]
(let [ids (into #{} (mapcat :ids) updates)]
(update-layout-positions {:page-id page-id
:ids ids
:reflow-tasks (mapv ::reflow-task updates)})))))))
(rx/take-until stopper)
;; On workspace teardown clear everything still pending.
(rx/finalize wrf/reset-pending!))))))
(defn finalize-shape-layout
[]
(ptk/data-event ::finalize-shape-layout))
(defn create-layout-from-id
[id type & {:keys [from-frame? calculate-params?] :or {from-frame? false calculate-params? true}}]
(dm/assert!
"expected uuid for `id`"
(uuid? id))
(ptk/reify ::create-layout-from-id
ptk/WatchEvent
(watch [_ state _]
(let [objects (dsh/lookup-page-objects state)
parent (get objects id)
undo-id (js/Symbol)
layout-initializer (cls/layout-initializer type from-frame? calculate-params?)]
(rx/of (dwu/start-undo-transaction undo-id)
(dwsh/update-shapes [id] layout-initializer {:with-objects? true})
(dwsh/update-shapes (dm/get-prop parent :shapes) #(dissoc % :constraints-h :constraints-v))
(ptk/data-event :layout/update {:ids [id]})
(dwu/commit-undo-transaction undo-id))))))
(defn create-layout-from-selection
[type]
(ptk/reify ::create-layout-from-selection
ptk/WatchEvent
(watch [_ state _]
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
selected (dsh/lookup-selected state)
selected-shapes (map (d/getf objects) selected)
single? (= (count selected-shapes) 1)
has-group? (->> selected-shapes (d/seek cfh/group-shape?))
is-group? (and single? has-group?)
has-mask? (->> selected-shapes (d/seek cfh/mask-shape?))
is-mask? (and single? has-mask?)
has-component? (some true? (map ctc/instance-root? selected-shapes))
is-component? (and single? has-component?)
has-variant? (some ctc/is-variant? selected-shapes)
has-layout? (and single? (ctl/any-layout? (first selected-shapes)))
new-shape-id (uuid/next)
undo-id (js/Symbol)]
(if has-variant?
(rx/empty)
(rx/concat
(rx/of (dwu/start-undo-transaction undo-id))
(cond
(and is-group? (not is-component?) (not is-mask?))
;; Create layout from a group:
;; When creating a layout from a group we remove the group and create the layout with its children
(let [parent-id (:parent-id (first selected-shapes))
shapes-ids (:shapes (first selected-shapes))
ordered-ids (into (d/ordered-set) shapes-ids)
group-index (cfh/get-index-replacement selected objects)]
(rx/of
(dwse/select-shapes ordered-ids)
(dwsh/create-artboard-from-selection new-shape-id parent-id group-index (:name (first selected-shapes)))
(cl/remove-all-fills [new-shape-id] {:color clr/black :opacity 1})
(create-layout-from-id new-shape-id type)
(dwsh/update-shapes [new-shape-id] #(assoc % :layout-item-h-sizing :auto :layout-item-v-sizing :auto))
(dwsh/update-shapes selected ctl/toggle-fix-if-auto)
(dwsh/delete-shapes page-id selected)
(ptk/data-event :layout/update {:ids [new-shape-id]})
(dwu/commit-undo-transaction undo-id)))
has-layout?
(rx/of
(create-layout-from-id (first selected) type)
(ptk/data-event :layout/update {:ids selected})
(dwu/commit-undo-transaction undo-id))
;; Create Layout from selection
:else
(rx/of
(dwsh/create-artboard-from-selection new-shape-id)
(cl/remove-all-fills [new-shape-id] {:color clr/black :opacity 1})
(create-layout-from-id new-shape-id type)
(dwsh/update-shapes [new-shape-id] #(assoc % :layout-item-h-sizing :auto :layout-item-v-sizing :auto))
(dwsh/update-shapes selected ctl/toggle-fix-if-auto)))
(rx/of (ptk/data-event :layout/update {:ids [new-shape-id]})
(dwu/commit-undo-transaction undo-id))))))))
(defn remove-layout
[ids]
(ptk/reify ::remove-shape-layout
ptk/WatchEvent
(watch [_ _ _]
(let [undo-id (js/Symbol)]
(rx/of
(dwu/start-undo-transaction undo-id)
(dwsh/update-shapes ids #(apply dissoc % layout-keys))
(ptk/data-event :layout/update {:ids ids})
(dwu/commit-undo-transaction undo-id))))))
(defn create-layout
[type]
(ptk/reify ::create-shape-layout
ev/Event
(-data [_]
{:layout (d/name type)})
ptk/WatchEvent
(watch [_ state _]
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
selected (dsh/lookup-selected state)
selected-shapes (map (d/getf objects) selected)
single? (= (count selected-shapes) 1)
is-frame? (= :frame (:type (first selected-shapes)))
has-layout? (ctl/any-layout? (first selected-shapes))
undo-id (js/Symbol)]
(rx/of
(dwu/start-undo-transaction undo-id)
(if (and single? is-frame? (not has-layout?))
(create-layout-from-id (first selected) type :from-frame? true)
(create-layout-from-selection type))
(dwu/commit-undo-transaction undo-id))))))
(defn toggle-layout
[type]
(ptk/reify ::toggle-shape-layout
ptk/WatchEvent
(watch [it state _]
(let [objects (dsh/lookup-page-objects state)
selected (dsh/lookup-selected state)
selected-shapes (map (d/getf objects) selected)
single? (= (count selected-shapes) 1)
has-layout? (and single?
(ctl/any-layout? objects (:id (first selected-shapes))))]
(when (not= 0 (count selected))
(let [event (if has-layout?
(remove-layout selected)
(create-layout type))]
(rx/of (with-meta event (meta it)))))))))
(defn change-grid-direction
"Set grid direction and reflow single-span auto items in one undo step."
[ids dir]
(ptk/reify ::change-grid-direction
ptk/WatchEvent
(watch [_ _ _]
(let [undo-id (js/Symbol)]
(rx/of (dwu/start-undo-transaction undo-id)
(dwsh/update-shapes
ids
(fn [shape]
(let [from-dir (d/nilv (:layout-grid-dir shape) :row)]
(cond
(not= :grid (:layout shape))
shape
(not (contains? #{:row :column} dir))
shape
(= from-dir dir)
(assoc shape :layout-grid-dir dir)
:else
(ctl/reflow-grid-auto-items-for-direction shape from-dir dir))))
;; Auto cells are already reflowed, so skip generic reassignment
;; and child reordering.
{:skip-grid-reassignment? true})
(ptk/data-event :layout/update {:ids ids})
(dwu/commit-undo-transaction undo-id))))))
(defn update-layout
([ids changes] (update-layout ids changes nil))
([ids changes options]
(ptk/reify ::update-layout
ptk/WatchEvent
(watch [_ _ _]
(let [undo-id (js/Symbol)
padding-attrs (-> (get changes :layout-padding)
keys
set)]
(rx/of (dwu/start-undo-transaction undo-id)
(dwsh/update-shapes ids (d/patch-object changes)
(cond-> options
(seq padding-attrs)
(assoc :changed-sub-attr padding-attrs)))
(ptk/data-event :layout/update {:ids ids})
(dwu/commit-undo-transaction undo-id)
(when (or (:layout-align-content changes) (:layout-justify-content changes))
(ev/event
{::ev/name "layout-change-alignment"}))
(when (or (:layout-padding changes) (:layout-gap changes))
(ev/event
{::ev/name "layout-change-margin"}))))))))
(defn add-layout-track
([ids type value]
(add-layout-track ids type value nil))
([ids type value index]
(assert (#{:row :column} type))
(ptk/reify ::add-layout-track
ptk/WatchEvent
(watch [_ _ _]
(let [undo-id (js/Symbol)]
(rx/of (dwu/start-undo-transaction undo-id)
(dwsh/update-shapes
ids
(fn [shape]
(case type
:row (ctl/add-grid-row shape value index)
:column (ctl/add-grid-column shape value index))))
(ptk/data-event :layout/update {:ids ids})
(dwu/commit-undo-transaction undo-id)))))))
(defn remove-layout-track
[ids type index & {:keys [with-shapes?] :or {with-shapes? false}}]
(assert (#{:row :column} type))
(ptk/reify ::remove-layout-track
ptk/WatchEvent
(watch [_ state _]
(let [objects (dsh/lookup-page-objects state)
undo-id (js/Symbol)
shapes-to-delete
(when with-shapes?
(->> ids
(mapcat
(fn [id]
(let [shape (get objects id)]
(if (= type :column)
(ctl/shapes-by-column shape index)
(ctl/shapes-by-row shape index)))))
(into #{})))]
(rx/of (dwu/start-undo-transaction undo-id)
(if shapes-to-delete
(dwsh/delete-shapes shapes-to-delete)
(rx/empty))
(dwsh/update-shapes
ids
(fn [shape objects]
(case type
:row (ctl/remove-grid-row shape index objects)
:column (ctl/remove-grid-column shape index objects)))
{:with-objects? true})
(ptk/data-event :layout/update {:ids ids})
(dwu/commit-undo-transaction undo-id))))))
(defn duplicate-layout-track
[ids type index]
(assert (#{:row :column} type))
(ptk/reify ::duplicate-layout-track
ptk/WatchEvent
(watch [it state _]
(let [file-id (:current-file-id state)
page (dsh/lookup-page state)
objects (:objects page)
libraries (dsh/lookup-libraries state)
library-data (dsh/lookup-file state file-id)
shape-id (first ids)
base-shape (get objects shape-id)
shapes-by-track
(if (= type :column)
(ctl/shapes-by-column base-shape index false)
(ctl/shapes-by-row base-shape index false))
;; Change to set in order to use auxiliary functions
selected (set shapes-by-track)
changes
(-> (pcb/empty-changes it)
(cld/generate-duplicate-changes objects page selected (gpt/point 0 0) libraries library-data file-id)
(cld/generate-duplicate-changes-update-indices objects selected))
;; Creates a map with shape-id => duplicated-shape-id
ids-map
(->> changes
:redo-changes
(filter #(= (:type %) :add-obj))
(filter #(selected (:old-id %)))
(map #(vector (:old-id %) (get-in % [:obj :id])))
(into {}))
changes
(-> changes
(pcb/update-shapes
ids
(fn [shape objects]
;; The duplication could have altered the grid so we restore the values, we'll calculate the good ones now
(let [shape (merge shape (select-keys base-shape [:layout-grid-cells :layout-grid-columns :layout-grid-rows]))]
(case type
:row (ctl/duplicate-row shape objects index ids-map)
:column (ctl/duplicate-column shape objects index ids-map))))
{:with-objects? true}))
undo-id (js/Symbol)]
(rx/of (dwu/start-undo-transaction undo-id)
(dch/commit-changes changes)
(ptk/data-event :layout/update {:ids ids})
(dwu/commit-undo-transaction undo-id))))))
(defn reorder-layout-track
[ids type from-index to-index move-content?]
(assert (#{:row :column} type))
(ptk/reify ::reorder-layout-track
ptk/WatchEvent
(watch [_ _ _]
(let [undo-id (js/Symbol)]
(rx/of (dwu/start-undo-transaction undo-id)
(dwsh/update-shapes
ids
(fn [shape]
(case type
:row (ctl/reorder-grid-row shape from-index to-index move-content?)
:column (ctl/reorder-grid-column shape from-index to-index move-content?))))
(ptk/data-event :layout/update {:ids ids})
(dwu/commit-undo-transaction undo-id))))))
(defn hover-layout-track
[ids type index hover?]
(assert (#{:row :column} type))
(ptk/reify ::hover-layout-track
ptk/UpdateEvent
(update [_ state]
(let [objects (dsh/lookup-page-objects state)
shape (get objects (first ids))
highlighted
(when hover?
(->> (if (= type :row)
(ctl/shapes-by-row shape index)
(ctl/shapes-by-column shape index))
(set)))]
(cond-> state
hover?
(update-in [:workspace-grid-edition (first ids) :hover-track] (fnil conj #{}) [type index])
(not hover?)
(update-in [:workspace-grid-edition (first ids) :hover-track] (fnil disj #{}) [type index])
:always
(assoc-in [:workspace-local :highlighted] highlighted))))))
(defn change-layout-track
[ids type index props]
(assert (#{:row :column} type))
(ptk/reify ::change-layout-track
ptk/WatchEvent
(watch [_ _ _]
(let [undo-id (js/Symbol)
property (case type
:row :layout-grid-rows
:column :layout-grid-columns)]
(rx/of (dwu/start-undo-transaction undo-id)
(dwsh/update-shapes
ids
(fn [shape]
(-> shape
(update-in [property index] merge props))))
(ptk/data-event :layout/update {:ids ids})
(dwu/commit-undo-transaction undo-id))))))
(defn fix-child-sizing
[objects parent-changes shape]
(let [parent (-> (cfh/get-parent objects (:id shape))
(d/deep-merge parent-changes))
auto-width? (ctl/auto-width? parent)
auto-height? (ctl/auto-height? parent)
col? (ctl/col? parent)
row? (ctl/row? parent)
all-children (->> parent
:shapes
(map (d/getf objects))
(remove ctl/position-absolute?))]
(cond-> shape
;; If the parent is hug width and the direction column
;; change to fixed when ALL children are fill
(and col? auto-width? (every? ctl/fill-width? all-children))
(assoc :layout-item-h-sizing :fix)
;; If the parent is hug height and the direction is column
;; change to fixed when ANY children is fill
(and col? auto-height? (ctl/fill-height? shape))
(assoc :layout-item-v-sizing :fix)
;; If the parent is hug width and the direction row
;; change to fixed when ANY children is fill
(and row? auto-width? (ctl/fill-width? shape))
(assoc :layout-item-h-sizing :fix)
;; If the parent is hug height and the direction row
;; change to fixed when ALL children are fill
(and row? auto-height? (every? ctl/fill-height? all-children))
(assoc :layout-item-v-sizing :fix))))
(defn fix-parent-sizing
[parent objects ids-set changes]
(let [auto-width? (ctl/auto-width? parent)
auto-height? (ctl/auto-height? parent)
col? (ctl/col? parent)
row? (ctl/row? parent)
all-children
(->> parent :shapes
(map (d/getf objects))
(map (fn [shape]
(if (contains? ids-set (:id shape))
(d/deep-merge shape changes)
shape))))]
(cond-> parent
;; Col layout and parent is hug-width if all children are fill-width
;; change parent to fixed
(and col? auto-width? (every? ctl/fill-width? all-children))
(assoc :layout-item-h-sizing :fix)
;; Col layout and parent is hug-height if any children is fill-height
;; change parent to fixed
(and col? auto-height? (some ctl/fill-height? all-children))
(assoc :layout-item-v-sizing :fix)
;; Row layout and parent is hug-width if any children is fill-width
;; change parent to fixed
(and row? auto-width? (some ctl/fill-width? all-children))
(assoc :layout-item-h-sizing :fix)
;; Row layout and parent is hug-height if all children are fill-height
;; change parent to fixed
(and row? auto-height? (every? ctl/fill-height? all-children))
(assoc :layout-item-v-sizing :fix))))
(defn update-layout-child
([ids attrs] (update-layout-child ids attrs nil))
([ids attrs options]
(ptk/reify ::update-layout-child
ptk/WatchEvent
(watch [_ state _]
(let [page-id (or (get options :page-id)
(get state :current-page-id))
objects (dsh/lookup-page-objects state page-id)
children-ids (->> ids (mapcat #(cfh/get-children-ids objects %)))
parent-ids (->> ids (map #(cfh/get-parent-id objects %)))
undo-id (js/Symbol)
margin-attrs (-> (get attrs :layout-item-margin)
keys
set)]
(rx/of (dwu/start-undo-transaction undo-id)
(dwsh/update-shapes ids (d/patch-object attrs)
(cond-> options
(seq margin-attrs)
(assoc :changed-sub-attr margin-attrs)))
(dwsh/update-shapes children-ids (partial fix-child-sizing objects attrs) options)
(dwsh/update-shapes
parent-ids
(fn [parent objects]
(-> parent
(fix-parent-sizing objects (set ids) attrs)
(cond-> (ctl/grid-layout? parent)
(ctl/assign-cells objects))))
(merge options {:with-objects? true}))
(ptk/data-event :layout/update {:ids ids})
(dwu/commit-undo-transaction undo-id)))))))
(defn update-grid-cells
[layout-id ids props]
(ptk/reify ::update-grid-cells
ptk/WatchEvent
(watch [_ _ _]
(let [undo-id (js/Symbol)]
(rx/of
(dwu/start-undo-transaction undo-id)
(dwsh/update-shapes
[layout-id]
(fn [shape]
(->> ids
(reduce
(fn [shape cell-id]
(d/update-in-when
shape
[:layout-grid-cells cell-id]
d/patch-object props))
shape))))
(ptk/data-event :layout/update {:ids [layout-id]})
(dwu/commit-undo-transaction undo-id))))))
(defn change-cells-mode
[layout-id ids mode]
(ptk/reify ::change-cells-mode
ptk/WatchEvent
(watch [_ _ _]
(let [undo-id (js/Symbol)]
(rx/of
(dwu/start-undo-transaction undo-id)
(dwsh/update-shapes
[layout-id]
(fn [shape objects]
(case mode
:auto
;; change the manual cells and move to auto
(->> ids
(reduce
(fn [shape cell-id]
(let [cell (get-in shape [:layout-grid-cells cell-id])]
(cond-> shape
(or (contains? #{:area :manual} (:position cell))
(> (:row-span cell) 1)
(> (:column-span cell) 1))
(-> (d/update-in-when [:layout-grid-cells cell-id] assoc :shapes [] :position :auto)
(d/update-in-when [:layout-grid-cells cell-id] dissoc :area-name)
(ctl/resize-cell-area (:row cell) (:column cell) (:row cell) (:column cell) 1 1)
(ctl/assign-cells objects)))))
shape))
:manual
(->> ids
(reduce
(fn [shape cell-id]
(let [cell (get-in shape [:layout-grid-cells cell-id])]
(cond-> shape
(contains? #{:area :auto} (:position cell))
(-> (d/assoc-in-when [:layout-grid-cells cell-id :position] :manual)
(d/update-in-when [:layout-grid-cells cell-id] dissoc :area-name)
(ctl/assign-cells objects)))))
shape))
:area
;; Create area with the selected cells
(let [{:keys [first-row first-column last-row last-column]}
(ctl/cells-coordinates (->> ids (map #(get-in shape [:layout-grid-cells %]))))
target-cell
(ctl/get-cell-by-position shape first-row first-column)
shape
(-> shape
(ctl/resize-cell-area
(:row target-cell) (:column target-cell)
first-row
first-column
(inc (- last-row first-row))
(inc (- last-column first-column)))
(ctl/assign-cells objects))]
(-> shape
(d/update-in-when [:layout-grid-cells (:id target-cell)] assoc :position :area)))))
{:with-objects? true})
(dwge/clean-selection layout-id)
(ptk/data-event :layout/update {:ids [layout-id]})
(dwu/commit-undo-transaction undo-id))))))
(defn merge-cells
[layout-id ids]
(ptk/reify ::merge-cells
ptk/WatchEvent
(watch [_ _ _]
(let [undo-id (js/Symbol)]
(rx/of
(dwu/start-undo-transaction undo-id)
(dwsh/update-shapes
[layout-id]
(fn [shape objects]
(let [cells (->> ids (map #(get-in shape [:layout-grid-cells %])))
{:keys [first-row first-column last-row last-column]}
(ctl/cells-coordinates cells)
target-cell
(ctl/get-cell-by-position shape first-row first-column)]
(-> shape
(ctl/resize-cell-area
(:row target-cell) (:column target-cell)
first-row
first-column
(inc (- last-row first-row))
(inc (- last-column first-column)))
(ctl/assign-cells objects))))
{:with-objects? true})
(dwge/clean-selection layout-id)
(ptk/data-event :layout/update {:ids [layout-id]})
(dwu/commit-undo-transaction undo-id))))))
(defn update-grid-cell-position
[layout-id cell-id props]
(ptk/reify ::update-grid-cell-position
ptk/WatchEvent
(watch [_ _ _]
(let [undo-id (js/Symbol)]
(rx/of
(dwu/start-undo-transaction undo-id)
(dwsh/update-shapes
[layout-id]
(fn [shape objects]
(let [prev-data (-> (dm/get-in shape [:layout-grid-cells cell-id])
(select-keys [:row :column :row-span :column-span]))
new-data (merge prev-data props)]
(-> shape
(ctl/resize-cell-area (:row prev-data) (:column prev-data)
(:row new-data) (:column new-data)
(:row-span new-data) (:column-span new-data))
(ctl/assign-cells objects))))
{:with-objects? true})
(ptk/data-event :layout/update {:ids [layout-id]})
(dwu/commit-undo-transaction undo-id))))))
(defn create-cell-board
[layout-id cell-ids]
(ptk/reify ::create-cell-board
ptk/WatchEvent
(watch [it state _]
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state)
frame-id (uuid/next)
undo-id (js/Symbol)
shape (get objects layout-id)
cells (->> cell-ids (map #(get-in shape [:layout-grid-cells %])))
selected (into #{} (mapcat :shapes) cells)
{:keys [first-row first-column last-row last-column]} (ctl/cells-coordinates cells)
target-cell (ctl/get-cell-by-position shape first-row first-column)
[_ changes]
(-> (pcb/empty-changes it page-id)
(pcb/with-objects objects)
(cond-> (d/not-empty? selected)
(cfsh/prepare-create-artboard-from-selection
frame-id layout-id objects selected 0 nil true (:id target-cell)))
(cond-> (empty? (seq selected))
(cfsh/prepare-create-empty-artboard
frame-id layout-id objects 0 nil true (:id target-cell))))
changes
(-> changes
(pcb/update-shapes
[frame-id]
(fn [shape]
(-> shape
(assoc :layout-item-h-sizing :fill)
(assoc :layout-item-v-sizing :fill))))
(pcb/update-shapes
[layout-id]
(fn [shape]
(let [new-row-span (inc (- last-row first-row))
new-col-span (inc (- last-column first-column))]
(-> shape
(ctl/resize-cell-area
(:row target-cell) (:column target-cell)
first-row first-column new-row-span new-col-span))))))]
(rx/of
(dwu/start-undo-transaction undo-id)
(dch/commit-changes changes)
(ptk/data-event :layout/update {:ids [layout-id]})
(dwu/commit-undo-transaction undo-id))))))
(defn complete-rows?
"Check if the selected cells cover complete row(s) — all columns must be included."
[grid cells]
(let [{:keys [first-column last-column]} (ctl/cells-coordinates cells)
num-columns (count (:layout-grid-columns grid))]
(and (= first-column 1)
(= last-column num-columns))))
(defn complete-columns?
"Check if the selected cells cover complete column(s) — all rows must be included."
[grid cells]
(let [{:keys [first-row last-row]} (ctl/cells-coordinates cells)
num-rows (count (:layout-grid-rows grid))]
(and (= first-row 1)
(= last-row num-rows))))
(defn copy-grid-tracks
"Store the selected track indices for later paste. Works for both
complete rows and complete columns."
[grid-id type]
(assert (#{:row :column} type))
(ptk/reify ::copy-grid-tracks
ptk/UpdateEvent
(update [_ state]
(let [objects (dsh/lookup-page-objects state)
grid (get objects grid-id)
selected (get-in state [:workspace-grid-edition grid-id :selected])
cells (->> selected (map #(get-in grid [:layout-grid-cells %])))
{:keys [first-row last-row first-column last-column]} (ctl/cells-coordinates cells)
;; Convert 1-indexed cell positions to 0-indexed track indices
track-indices (if (= type :row)
(vec (range (dec first-row) last-row))
(vec (range (dec first-column) last-column)))]
(assoc-in state [:workspace-grid-edition grid-id :copied-tracks]
{:track-indices track-indices
:type type
:grid-id grid-id})))))
(defn paste-grid-tracks
"Paste previously copied tracks at the end of the grid.
Each source track is duplicated and appended after the last
existing track. All operations are grouped in a single undo
transaction. Follows the same pattern as `duplicate-layout-track`."
[grid-id]
(ptk/reify ::paste-grid-tracks
ptk/WatchEvent
(watch [it state _]
(let [file-id (:current-file-id state)
page (dsh/lookup-page state)
objects (:objects page)
libraries (dsh/lookup-libraries state)
library-data (dsh/lookup-file state file-id)
grid (get objects grid-id)
copied (get-in state [:workspace-grid-edition grid-id :copied-tracks])
track-indices (:track-indices copied)
type (:type copied)
undo-id (js/Symbol)]
(when (and (seq track-indices) (some? type))
(let [shapes-by-track-fn
(if (= type :row)
ctl/shapes-by-row
ctl/shapes-by-column)
;; Collect shapes from all source tracks
all-shapes
(->> track-indices
(mapcat #(shapes-by-track-fn grid % false))
(set))
;; Generate duplication changes for all shapes at once
changes
(-> (pcb/empty-changes it)
(cld/generate-duplicate-changes objects page all-shapes (gpt/point 0 0) libraries library-data file-id)
(cld/generate-duplicate-changes-update-indices objects all-shapes))
;; Build ids-map: old-shape-id -> new-shape-id
ids-map
(->> changes
:redo-changes
(filter #(= (:type %) :add-obj))
(filter #(all-shapes (:old-id %)))
(map #(vector (:old-id %) (get-in % [:obj :id])))
(into {}))
duplicate-at-fn
(if (= type :row)
ctl/duplicate-row-at
ctl/duplicate-column-at)
tracks-prop
(if (= type :row)
:layout-grid-rows
:layout-grid-columns)
;; Sort source indices ascending — we'll append each
;; copy at the end in order, preserving the original
;; track ordering in the appended block.
sorted-indices (vec (sort track-indices))
changes
(-> changes
(pcb/update-shapes
[grid-id]
(fn [shape objects]
;; Restore grid structure (duplication may have altered it)
(let [shape (merge shape (select-keys grid [:layout-grid-cells :layout-grid-columns :layout-grid-rows]))]
;; Append each source track at the end.
;; Process in ascending order so the copies
;; appear in the same order as the originals.
;; Each insertion adds one track, so both the
;; target index and the source index (if it
;; comes after the target) shift by 1.
(reduce
(fn [s [offset src-idx]]
(let [;; Source tracks don't shift because we
;; append after them (target > source).
actual-src src-idx
;; Append at the end (which grows by
;; one with each iteration).
target-idx (+ (count (get grid tracks-prop)) offset)]
(duplicate-at-fn s objects actual-src target-idx ids-map)))
shape
(map-indexed vector sorted-indices))))
{:with-objects? true}))]
(rx/of (dwu/start-undo-transaction undo-id)
(dch/commit-changes changes)
(ptk/data-event :layout/update {:ids [grid-id]})
(dwu/commit-undo-transaction undo-id))))))))