mirror of
https://github.com/penpot/penpot.git
synced 2026-09-04 02:58:56 +00:00
* ✨ 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>
382 lines
15 KiB
Clojure
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))))))))
|