diff --git a/common/src/app/common/types/shape/layout.cljc b/common/src/app/common/types/shape/layout.cljc index 384029a688..79aa661755 100644 --- a/common/src/app/common/types/shape/layout.cljc +++ b/common/src/app/common/types/shape/layout.cljc @@ -874,6 +874,42 @@ (duplicate-cells :column index (inc index) ids-map) (assign-cells objects)))) +(defn duplicate-row-at + "Duplicate source row and insert the copy at target-index (0-indexed). + Like `duplicate-row` but inserts at an arbitrary position. + Note: after add-grid-row, if target <= source the source cells shift + by +1, so we must adjust the from-index for duplicate-cells." + [shape objects source-index target-index ids-map] + (let [value (dm/get-in shape [:layout-grid-rows source-index]) + ;; After inserting at target-index, cells at rows >= (inc target-index) + ;; get shifted +1. If target <= source, the source row shifts. + adjusted-source (if (<= target-index source-index) + (inc source-index) + source-index)] + (-> shape + (remove-cell-areas-after :row source-index) + (add-grid-row value target-index) + (duplicate-cells :row adjusted-source target-index ids-map) + (assign-cells objects)))) + +(defn duplicate-column-at + "Duplicate source column and insert the copy at target-index (0-indexed). + Like `duplicate-column` but inserts at an arbitrary position. + Note: after add-grid-column, if target <= source the source cells shift + by +1, so we must adjust the from-index for duplicate-cells." + [shape objects source-index target-index ids-map] + (let [value (dm/get-in shape [:layout-grid-columns source-index]) + ;; After inserting at target-index, cells at columns >= (inc target-index) + ;; get shifted +1. If target <= source, the source column shifts. + adjusted-source (if (<= target-index source-index) + (inc source-index) + source-index)] + (-> shape + (remove-cell-areas-after :column source-index) + (add-grid-column value target-index) + (duplicate-cells :column adjusted-source target-index ids-map) + (assign-cells objects)))) + (defn make-remove-cell [attr span-attr track-num] (fn [[_ cell]] diff --git a/frontend/src/app/main/data/workspace/shape_layout.cljs b/frontend/src/app/main/data/workspace/shape_layout.cljs index 163195f11f..bbff39663a 100644 --- a/frontend/src/app/main/data/workspace/shape_layout.cljs +++ b/frontend/src/app/main/data/workspace/shape_layout.cljs @@ -787,3 +787,135 @@ (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) + (cll/generate-duplicate-changes objects page all-shapes (gpt/point 0 0) libraries library-data file-id) + (cll/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)))))))) diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs index 1fedf00e64..ca426f6e9e 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/context_menu.cljs @@ -779,6 +779,7 @@ [{:keys [mdata]}] (let [{:keys [grid cells]} mdata + grid-id (:id grid) single? (= (count cells) 1) can-merge? @@ -786,17 +787,53 @@ (mf/deps cells) #(ctl/valid-area-cells? cells)) + can-copy-rows? + (mf/use-memo + (mf/deps grid cells) + #(dwsl/complete-rows? grid cells)) + + can-copy-columns? + (mf/use-memo + (mf/deps grid cells) + #(dwsl/complete-columns? grid cells)) + + grid-edition-ref + (mf/use-memo + (mf/deps grid-id) + #(refs/workspace-grid-edition-id grid-id)) + + grid-edition (mf/deref grid-edition-ref) + has-copied-tracks? (some? (:copied-tracks grid-edition)) + do-merge-cells (mf/use-fn - (mf/deps grid cells) + (mf/deps grid-id cells) (fn [] - (st/emit! (dwsl/merge-cells (:id grid) (map :id cells))))) + (st/emit! (dwsl/merge-cells grid-id (map :id cells))))) do-create-board (mf/use-fn - (mf/deps grid cells) + (mf/deps grid-id cells) (fn [] - (st/emit! (dwsl/create-cell-board (:id grid) (map :id cells)))))] + (st/emit! (dwsl/create-cell-board grid-id (map :id cells))))) + + do-copy-rows + (mf/use-fn + (mf/deps grid-id) + (fn [] + (st/emit! (dwsl/copy-grid-tracks grid-id :row)))) + + do-copy-columns + (mf/use-fn + (mf/deps grid-id) + (fn [] + (st/emit! (dwsl/copy-grid-tracks grid-id :column)))) + + do-paste-tracks + (mf/use-fn + (mf/deps grid-id) + (fn [] + (st/emit! (dwsl/paste-grid-tracks grid-id))))] [:* (when (not single?) [:> menu-entry* {:title (tr "workspace.context-menu.grid-cells.merge") @@ -809,7 +846,19 @@ [:> menu-entry* {:title (tr "workspace.context-menu.grid-cells.create-board") :on-click do-create-board - :disabled (and (not single?) (not can-merge?))}]])) + :disabled (and (not single?) (not can-merge?))}] + + [:> menu-entry* {:title (tr "workspace.context-menu.grid-cells.copy-rows") + :on-click do-copy-rows + :disabled (not can-copy-rows?)}] + + [:> menu-entry* {:title (tr "workspace.context-menu.grid-cells.copy-columns") + :on-click do-copy-columns + :disabled (not can-copy-columns?)}] + + [:> menu-entry* {:title (tr "workspace.context-menu.grid-cells.paste-tracks") + :on-click do-paste-tracks + :disabled (not has-copied-tracks?)}]])) ;; FIXME: optimize because it is rendered always diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 1d30cbb441..eeff80eeed 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -5589,6 +5589,15 @@ msgstr "Create board" msgid "workspace.context-menu.grid-cells.merge" msgstr "Merge cells" +msgid "workspace.context-menu.grid-cells.copy-rows" +msgstr "Copy rows" + +msgid "workspace.context-menu.grid-cells.copy-columns" +msgstr "Copy columns" + +msgid "workspace.context-menu.grid-cells.paste-tracks" +msgstr "Paste" + #: src/app/main/ui/workspace/context_menu.cljs:754 msgid "workspace.context-menu.grid-track.column.add-after" msgstr "Add 1 column to the right"