Dream 0c08dfb13d
Add the ability for save and restore selection state in undo/redo (#8652)
*  Capture selection state before changes are applied

Save current selection IDs in commit-changes so undo entries
can track what was selected before each action.

*  Save and restore selection state in undo/redo

Extend undo entry with selected-before and selected-after fields.
On undo, restore selection to what it was before the action.
On redo, restore selection to what it was after the action.
Handles single entries, stacked entries, accumulated transactions,
and undo groups.

Fixes #6007

* ♻️ Wire selected-before through workspace undo stream

Pass the captured selection state from commit data into
the undo entry so it is stored alongside changes.

* 🐛 Fix unmatched delimiter in changes.cljs

* 🐛 Pass selected-before through commit event to undo entry

selected-before was captured in commit-changes but dropped by the
commit function since it was missing from the destructuring and the
commit map. This caused restore-selection to receive nil on undo.

---------

Signed-off-by: eureka928 <meobius123@gmail.com>
Co-authored-by: Mihai <noreply@github.com>
2026-04-07 16:30:47 +02:00

382 lines
15 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 INC
(ns app.main.data.workspace.undo
"Undo management for the workspace.
There are **undo entries**, **undo transactions** and **undo groups**.
The undo stack has a maximum size of `MAX-UNDO-SIZE` (50) entries.
Undo entries:
- Consist of `:undo-changes`, `:redo-changes`, `:undo-group`, `:tags`
- Can be:
- `added`: pushes a new entry on top of the undo stack
- `stacked`: appends undo/redo changes to the last undo item under
`:workspace-undo :items`
- `accumulated`: extends the current transaction with new changes
Undo transactions:
- Are a way to incrementally merge incoming changes into a single
undo entry.
- Are identified by a unique id, typically a `(js/Symbol)`
- There is at most one current undo transaction.
- Have a timeout, defaulting to 20s
- Are _accumulated_ into the current transaction via
`accumulate-undo-entry`
Undo groups:
- Can contain multiple undo entries
- Undo entries in a groups must be consecutive
Undo tags:
- Known values: `:alt-duplication` (copy-and-move with mouse+alt)
"
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.changes :as cpc]
[app.common.logging :as log]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.types.shape.layout :as ctl]
[app.main.data.changes :as dch]
[app.main.data.common :as dcm]
[app.main.data.helpers :as dsh]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
;; Change this to :info :debug or :trace to debug this module
(log/set-level! :warn)
(def ^:private
discard-transaction-time-millis (* 20 1000))
(def ^:private
schema:undo-entry
[:map {:title "undo-entry"}
[:undo-changes [:vector cpc/schema:change]]
[:redo-changes [:vector cpc/schema:change]]
[:undo-group ::sm/uuid]
[:tags [:set :keyword]]
[:selected-before {:optional true} [:maybe [:set ::sm/uuid]]]
[:selected-after {:optional true} [:maybe [:set ::sm/uuid]]]])
(def check-undo-entry
(sm/check-fn schema:undo-entry))
(def MAX-UNDO-SIZE 50)
(defn- conj-undo-entry
[undo data]
(let [undo (conj undo data)
cnt (count undo)]
(if (> cnt MAX-UNDO-SIZE)
(subvec undo (- cnt MAX-UNDO-SIZE))
undo)))
(defn materialize-undo
"Updates the state to point to a specific index in the undo stack.
Used to materialize the undo stack when the user selects an entry
in the undo history."
[_changes index]
(ptk/reify ::materialize-undo
ptk/UpdateEvent
(update [_ state]
(update state :workspace-undo assoc :index index))))
(defn- add-undo-entry
[state entry]
(if (and entry
(not-empty (:undo-changes entry))
(not-empty (:redo-changes entry)))
(let [index (get-in state [:workspace-undo :index] -1)
items (get-in state [:workspace-undo :items] [])
items (->> items (take (inc index)) (into []))
items (conj-undo-entry items entry)]
(-> state
(update :workspace-undo assoc :items items
:index (min (inc index)
(dec MAX-UNDO-SIZE)))))
state))
(defn- stack-undo-entry
"Extends the current undo entry in the workspace with new changes if it
exists, or creates a new entry if it doesn't."
[state {:keys [undo-changes redo-changes selected-after] :as entry}]
(let [index (get-in state [:workspace-undo :index] -1)]
(if (>= index 0)
(update-in state [:workspace-undo :items index]
(fn [item]
(-> item
(update :undo-changes #(into undo-changes %))
(update :redo-changes #(into % redo-changes))
(assoc :selected-after selected-after))))
(add-undo-entry state entry))))
(defn- accumulate-undo-entry
"Extends the current undo transaction with new changes."
[state {:keys [undo-changes redo-changes undo-group tags selected-before selected-after]}]
(-> state
(update-in [:workspace-undo :transaction :undo-changes] #(into undo-changes %))
(update-in [:workspace-undo :transaction :redo-changes] #(into % redo-changes))
(cond-> (nil? (get-in state [:workspace-undo :transaction :undo-group]))
(assoc-in [:workspace-undo :transaction :undo-group] undo-group))
(cond-> (nil? (get-in state [:workspace-undo :transaction :selected-before]))
(assoc-in [:workspace-undo :transaction :selected-before] selected-before))
(assoc-in [:workspace-undo :transaction :selected-after] selected-after)
(assoc-in [:workspace-undo :transaction :tags] tags)))
(defn append-undo
"UpdateEvent to add an entry to the undo stack, or extend the current undo transaction
or last undo entry.
- If `stack?` is true, it will stack the entry on top of the current undo entry.
- If `stack?` is false, it will add a new entry to the undo stack.
- If there is an open transaction, it will accumulate the changes in that transaction."
[entry stack?]
(assert (check-undo-entry entry))
(assert (boolean? stack?))
(ptk/reify ::append-undo
ptk/UpdateEvent
(update [_ state]
(let [selected-after (dm/get-in state [:workspace-local :selected])
entry (assoc entry :selected-after selected-after)]
(cond
(and (get-in state [:workspace-undo :transaction])
(or (not stack?)
(d/not-empty? (get-in state [:workspace-undo :transaction :undo-changes]))
(d/not-empty? (get-in state [:workspace-undo :transaction :redo-changes]))))
(accumulate-undo-entry state entry)
stack?
(stack-undo-entry state entry)
:else
(add-undo-entry state entry))))))
(def empty-tx
{:undo-changes [] :redo-changes []})
(declare check-open-transactions)
(defn start-undo-transaction
"Start a transaction, so that changes in it are added together into a single undo entry."
[id & {:keys [timeout] :or {timeout discard-transaction-time-millis}}]
(ptk/reify ::start-undo-transaction
ptk/UpdateEvent
(update [_ state]
(log/info :hint "start-undo-transaction")
(update state :workspace-undo
(fn [undo-state]
(-> undo-state
(update :transaction #(d/nilv % empty-tx))
(update :transactions-pending assoc id (ct/now))))))
ptk/WatchEvent
(watch [_ _ stream]
(when (and timeout (pos? timeout))
(let [stoper (rx/filter (ptk/type? ::start-undo-transaction) stream)]
(->> (rx/of (check-open-transactions timeout))
;; Wait the configured time
(rx/delay timeout)
(rx/take-until stoper)))))))
(defn discard-undo-transaction
"Updates the state to discard any current and pending undo transaction."
[]
(ptk/reify ::discard-undo-transaction
ptk/UpdateEvent
(update [_ state]
(log/info :hint "discard-undo-transaction")
(update state :workspace-undo dissoc :transaction :transactions-pending))))
(defn- add-transaction-undo-entry
"Conditionally add an undo entry from the current transaction. That
only happens when no pending transactions are available and the
current transaction exists."
[state]
(let [undo-state (get state :workspace-undo)
current-tx (get undo-state :transaction)
pending-tx (get undo-state :transactions-pending)]
(if (and (some? current-tx)
(empty? pending-tx))
(-> state
(add-undo-entry current-tx)
(update :workspace-undo dissoc :transaction))
state)))
(defn commit-undo-transaction
[id]
(ptk/reify ::commit-undo-transaction
ptk/UpdateEvent
(update [_ state]
(log/info :hint "commit-undo-transaction")
(-> state
(update-in [:workspace-undo :transactions-pending] dissoc id)
(add-transaction-undo-entry)))))
(def reinitialize-undo
"Clears the undo stack, removing all entries and transactions."
(ptk/reify ::reset-undo
ptk/UpdateEvent
(update [_ state]
(assoc state :workspace-undo {}))))
(defn check-open-transactions
[timeout]
(ptk/reify ::check-open-transactions
ptk/WatchEvent
(watch [_ state _]
(log/info :hint "check-open-transactions" :timeout timeout)
(let [pending-ts (-> (dm/get-in state [:workspace-undo :transactions-pending])
(update-vals #(ct/diff-ms % (ct/now))))]
(->> pending-ts
(filter (fn [[_ ts]] (>= ts timeout)))
(rx/from)
(rx/tap #(js/console.warn (dm/str "FORCE COMMIT TRANSACTION AFTER " (second %) "MS")))
(rx/map first)
(rx/map commit-undo-transaction))))))
(defn- restore-selection
"Restores the selection state from an undo entry."
[selected-ids]
(ptk/reify ::restore-selection
ptk/UpdateEvent
(update [_ state]
(if (some? selected-ids)
(assoc-in state [:workspace-local :selected] selected-ids)
state))))
(defn undo-to-index
"Repeat undoing or redoing until dest-index is reached."
[dest-index]
(ptk/reify ::undo-to-index
ptk/WatchEvent
(watch [it state _]
(let [objects (dsh/lookup-page-objects state)
edition (get-in state [:workspace-local :edition])
drawing (get state :workspace-drawing)]
(when-not (and (or (some? edition) (some? (:object drawing)))
(not (ctl/grid-layout? objects edition)))
(let [undo (:workspace-undo state)
items (:items undo)
index (or (:index undo) (dec (count items)))]
(when (and (some? items)
(<= -1 dest-index (dec (count items))))
(let [changes (vec (apply concat
(cond
(< dest-index index)
(->> (subvec items (inc dest-index) (inc index))
(reverse)
(map :undo-changes))
(> dest-index index)
(->> (subvec items (inc index) (inc dest-index))
(map :redo-changes))
:else [])))]
(when (seq changes)
(rx/of (materialize-undo changes dest-index)
(dch/commit-changes {:redo-changes changes
:undo-changes []
:origin it
:save-undo? false})))))))))))
(declare ^:private assure-valid-current-page)
(def undo
"Undo the last action, or the last action in a group.
If there is an open transaction, it will undo to the last transaction
index."
(ptk/reify ::undo
ptk/WatchEvent
(watch [it state _]
(let [objects (dsh/lookup-page-objects state)
edition (get-in state [:workspace-local :edition])
drawing (get state :workspace-drawing)]
;; Editors handle their own undo's
(when (or (and (nil? edition) (nil? (:object drawing)))
(ctl/grid-layout? objects edition))
(let [undo (:workspace-undo state)
items (:items undo)
index (or (:index undo) (dec (count items)))]
(when-not (or (empty? items) (= index -1))
(let [item (get items index)
changes (:undo-changes item)
undo-group (:undo-group item)
find-first-group-idx
(fn [index]
(if (= (dm/get-in items [index :undo-group]) undo-group)
(recur (dec index))
(inc index)))
undo-group-index
(when undo-group
(find-first-group-idx index))]
(if undo-group
(let [first-item (get items undo-group-index)]
(rx/of (undo-to-index (dec undo-group-index))
(restore-selection (:selected-before first-item))))
(rx/of (materialize-undo changes (dec index))
(dch/commit-changes {:redo-changes changes
:undo-changes []
:save-undo? false
:origin it})
(restore-selection (:selected-before item))
(assure-valid-current-page)))))))))))
(def redo
(ptk/reify ::redo
ptk/WatchEvent
(watch [it state _]
(let [objects (dsh/lookup-page-objects state)
edition (get-in state [:workspace-local :edition])
drawing (get state :workspace-drawing)]
;; Editors handle their own undo's
(when (or (and (nil? edition) (nil? (:object drawing)))
(ctl/grid-layout? objects edition))
(let [undo (:workspace-undo state)
items (:items undo)
index (or (:index undo) (dec (count items)))]
(when-not (or (empty? items) (= index (dec (count items))))
(let [item (get items (inc index))
changes (:redo-changes item)
undo-group (:undo-group item)
find-last-group-idx (fn flgidx [index]
(let [item (get items index)]
(if (= (:undo-group item) undo-group)
(flgidx (inc index))
(dec index))))
redo-group-index (when undo-group
(find-last-group-idx (inc index)))]
(if undo-group
(let [last-item (get items redo-group-index)]
(rx/of (undo-to-index redo-group-index)
(restore-selection (:selected-after last-item))))
(rx/of (materialize-undo changes (inc index))
(dch/commit-changes {:redo-changes changes
:undo-changes []
:origin it
:save-undo? false})
(restore-selection (:selected-after item))))))))))))
(defn- assure-valid-current-page
[]
(ptk/reify ::assure-valid-current-page
ptk/WatchEvent
(watch [_ state _]
(let [page-id (:current-page-id state)
pages (-> (dsh/lookup-file-data state)
(get :pages))]
(if (contains? pages page-id)
(rx/empty)
(rx/of (dcm/go-to-workspace :page-id (first pages))))))))