Add read-only preview mode for saved versions

This commit is contained in:
wdeveloper16 2026-04-14 07:11:35 +02:00 committed by Andrey Antukh
parent d43d1f431f
commit e83efadc61
9 changed files with 291 additions and 9 deletions

View File

@ -112,8 +112,9 @@
THEN (c.deleted_at IS NULL OR c.deleted_at >= ?::timestamptz)
END"))
(defn- get-snapshot
"Get snapshot with decoded data"
(defn get-snapshot-data
"Get a fully decoded snapshot for read-only preview or restoration.
Returns the snapshot map with decoded :data field."
[cfg file-id snapshot-id]
(let [now (ct/now)]
(->> (db/get-with-sql cfg [sql:get-snapshot file-id snapshot-id now]
@ -326,7 +327,7 @@
(sto/resolve cfg {::db/reuse-conn true})
snapshot
(get-snapshot cfg file-id snapshot-id)]
(get-snapshot-data cfg file-id snapshot-id)]
(when-not snapshot
(ex/raise :type :not-found

View File

@ -8,6 +8,7 @@
(:require
[app.binfile.common :as bfc]
[app.common.exceptions :as ex]
[app.common.features :as-alias cfeat]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.db :as db]
@ -35,6 +36,42 @@
(files/check-read-permissions! conn profile-id file-id)
(fsnap/get-visible-snapshots conn file-id))))
;; --- COMMAND QUERY: get-file-snapshot
(def ^:private schema:get-file-snapshot
[:map {:title "get-file-snapshot"}
[:file-id ::sm/uuid]
[:id ::sm/uuid]
[:features {:optional true} ::cfeat/features]])
(sv/defmethod ::get-file-snapshot
"Retrieve a file bundle with data from a specific snapshot for
read-only preview. Does not modify any database state."
{::doc/added "2.16"
::sm/params schema:get-file-snapshot
::sm/result files/schema:file-with-permissions
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id id] :as params}]
(let [perms (bfc/get-file-permissions conn profile-id file-id)]
(files/check-read-permissions! perms)
(let [snapshot (fsnap/get-snapshot-data cfg file-id id)]
(when-not snapshot
(ex/raise :type :not-found
:code :snapshot-not-found
:hint "unable to find snapshot with the provided id"
:snapshot-id id
:file-id file-id))
;; Load current file metadata only (no data decoding) then overlay
;; the snapshot data so the client receives the same shape as a
;; normal get-file response but with historical page/object content.
(let [base-file (bfc/get-file cfg file-id :load-data? false)]
(-> base-file
(assoc :data (:data snapshot))
(assoc :version (:version snapshot))
(assoc :features (:features snapshot))
(assoc :revn (:revn snapshot))
(assoc :permissions perms))))))
(def ^:private schema:create-file-snapshot
[:map
[:file-id ::sm/uuid]

View File

@ -121,8 +121,10 @@
:features features}
permissions (:permissions state)]
;; Prevent commit changes by a team member without edition permission
(when (:can-edit permissions)
;; Prevent saving changes when in version preview (read-only) mode
;; or when the user does not have edition permission.
(when (and (:can-edit permissions)
(not (get-in state [:workspace-global :read-only?])))
(->> (rp/cmd! :update-file params)
(rx/mapcat (fn [{:keys [revn lagged] :as response}]
(log/debug :hint "changes persisted" :commit-id (dm/str commit-id) :lagged (count lagged))

View File

@ -231,6 +231,22 @@
(assoc :thumbnails thumbnails)
(update :files assoc file-id file)))))
(defn apply-snapshot-data
"Swap the file data in app state with the provided snapshot-file
response. Used by the version preview feature to show historical
file content without modifying the database.
Also sets workspace-file-version-id to snapshot-id so the WASM
viewport detects the change and reloads shapes into its internal
buffer (it only re-initialises when this value changes)."
[file-id snapshot-id snapshot-file]
(ptk/reify ::apply-snapshot-data
ptk/UpdateEvent
(update [_ state]
(-> state
(update :files assoc file-id snapshot-file)
(assoc :workspace-file-version-id snapshot-id)))))
(defn zoom-to-frame
[]
(ptk/reify ::zoom-to-frame
@ -508,7 +524,8 @@
:workspace-persistence
:workspace-presence
:workspace-tokens
:workspace-undo)
:workspace-undo
:workspace-versions)
(update :workspace-global dissoc :read-only?)
(assoc-in [:workspace-global :options-mode] :design)
(update :files d/update-vals #(dissoc % :data))))

View File

@ -8,8 +8,10 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.logging :as log]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.main.data.event :as ev]
[app.main.data.notifications :as ntf]
[app.main.data.persistence :as dwp]
@ -24,7 +26,8 @@
(defonce default-state
{:status :loading
:data nil
:editing nil})
:editing nil
:preview-id nil})
(declare fetch-versions)
@ -193,6 +196,74 @@
(->> (rp/cmd! :unlock-file-snapshot {:id id})
(rx/map fetch-versions)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PREVIEW VERSION EVENTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn preview-version
"Load a snapshot into the workspace for read-only preview without
modifying any database state. Sets a read-only flag so no changes
are persisted while previewing."
[id]
(assert (uuid? id) "expected valid uuid for `id`")
(ptk/reify ::preview-version
ptk/UpdateEvent
(update [_ state]
(-> state
(update :workspace-versions assoc :preview-id id)
(update :workspace-global assoc :read-only? true)))
ptk/WatchEvent
(watch [_ state _]
(let [file-id (:current-file-id state)
page-id (:current-page-id state)
features (get-in state [:files file-id :features])]
(->> (rp/cmd! :get-file-snapshot
{:file-id file-id
:id id
:features features})
(rx/mapcat
(fn [snapshot-file]
(rx/of
;; Swap the file data in state with snapshot content.
;; Passing id sets workspace-file-version-id, which
;; causes the WASM viewport to reload its shape buffer.
(dw/apply-snapshot-data file-id id snapshot-file)
;; Re-initialize the page to rebuild its search index
;; and page-local state with the new snapshot objects.
(dw/initialize-page file-id page-id))))
(rx/catch (fn [err]
;; On error roll back the read-only flag so the
;; user is not stuck in a broken preview state.
(log/error :hint "failed to load snapshot" :cause err :file-id file-id :snapshot-id id)
(rx/of (update-versions-state {:preview-id nil})
(ptk/reify ::clear-preview-read-only
ptk/UpdateEvent
(update [_ state]
(update state :workspace-global dissoc :read-only?)))))))))))
(defn exit-preview
"Exit version preview mode and reload the live file data."
[]
(ptk/reify ::exit-preview
ptk/UpdateEvent
(update [_ state]
(-> state
(update :workspace-versions dissoc :preview-id)
(update :workspace-global dissoc :read-only?)
;; A fresh UUID triggers the WASM viewport to reload its shape
;; buffer with the live file objects once initialize-workspace
;; completes.
(assoc :workspace-file-version-id (uuid/next))))
ptk/WatchEvent
(watch [_ state _]
(let [team-id (:current-team-id state)
file-id (:current-file-id state)]
;; Full workspace re-init reloads the live file from the server,
;; clearing all snapshot data and restoring normal edit mode.
(rx/of (dw/initialize-workspace team-id file-id))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PLUGINS SPECIFIC EVENTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -7,12 +7,15 @@
(ns app.main.ui.workspace
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.main.data.common :as dcm]
[app.main.data.helpers :as dsh]
[app.main.data.notifications :as ntf]
[app.main.data.persistence :as dps]
[app.main.data.plugins :as dpl]
[app.main.data.workspace :as dw]
[app.main.data.workspace.versions :as dwv]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.router :as-alias rt]
@ -131,6 +134,54 @@
:overlay true
:file-loading true}])
(def ^:private workspace-versions-ref
(l/derived :workspace-versions st/state))
(mf/defc preview-banner*
"Banner shown at the top of the workspace when the user is previewing
a saved version. Provides Exit and Restore actions."
{::mf/private true}
[]
(let [versions-state (mf/deref workspace-versions-ref)
preview-id (:preview-id versions-state)
preview-entry (when preview-id
(d/seek #(= (:id %) preview-id) (:data versions-state)))
preview-label (or (:label preview-entry)
(tr "workspace.versions.preview.unnamed"))
on-exit
(mf/use-fn
(fn []
(st/emit! (dwv/exit-preview))))
on-restore
(mf/use-fn
(mf/deps preview-id)
(fn []
(when preview-id
(st/emit!
(ntf/dialog
:content (tr "workspace.versions.restore-warning")
:controls :inline-actions
:cancel {:label (tr "workspace.updates.dismiss")
:callback #(st/emit! (ntf/hide))}
:accept {:label (tr "labels.restore")
:callback #(st/emit! (ntf/hide)
(dwv/exit-preview)
(dwv/restore-version preview-id :version))}
:tag :restore-dialog)))))]
(when preview-id
[:div {:class (stl/css :preview-banner)}
[:span {:class (stl/css :preview-banner-label)}
(tr "workspace.versions.preview.banner-label" preview-label)]
[:button {:class (stl/css :preview-banner-exit-btn)
:on-click on-exit}
(tr "workspace.versions.preview.exit")]
[:button {:class (stl/css :preview-banner-restore-btn)
:on-click on-restore}
(tr "labels.restore")]])))
(defn- make-team-ref
[team-id]
(l/derived (fn [state]
@ -268,6 +319,7 @@
:touch-action "none"
:position "relative"}}
[:> context-menu*]
[:> preview-banner*]
(when (and file-loaded? page-id)
[:> workspace-inner*
{:page-id page-id

View File

@ -54,3 +54,53 @@
grid-template-columns: deprecated.$s-20 1fr;
flex: 1;
}
.preview-banner {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
z-index: var(--z-index-modal);
background: var(--color-accent-primary);
color: var(--color-background-primary);
display: flex;
align-items: center;
gap: deprecated.$s-12;
padding: deprecated.$s-8 deprecated.$s-16;
border-radius: 0 0 deprecated.$s-8 deprecated.$s-8;
white-space: nowrap;
box-shadow: 0 deprecated.$s-4 deprecated.$s-16 rgb(0 0 0 / 0.25);
}
.preview-banner-label {
font-size: deprecated.$fs-12;
font-weight: 600;
}
.preview-banner-exit-btn,
.preview-banner-restore-btn {
border: none;
border-radius: deprecated.$s-4;
cursor: pointer;
padding: deprecated.$s-4 deprecated.$s-8;
font-size: deprecated.$fs-11;
}
.preview-banner-exit-btn {
background: rgb(255 255 255 / 0.2);
color: inherit;
&:hover {
background: rgb(255 255 255 / 0.35);
}
}
.preview-banner-restore-btn {
background: rgb(255 255 255 / 0.9);
color: var(--color-accent-primary);
font-weight: 600;
&:hover {
background: rgb(255 255 255);
}
}

View File

@ -90,7 +90,7 @@
(mf/defc version-entry*
{::mf/private true}
[{:keys [entry current-profile on-restore on-delete on-rename on-lock on-unlock on-edit on-cancel-edit is-editing]}]
[{:keys [entry current-profile on-preview on-restore on-delete on-rename on-lock on-unlock on-edit on-cancel-edit is-editing]}]
(let [show-menu? (mf/use-state false)
profiles (mf/deref refs/profiles)
@ -108,6 +108,13 @@
(fn [event]
(on-edit (:id entry) event)))
on-preview
(mf/use-fn
(mf/deps entry on-preview)
(fn []
(when (fn? on-preview)
(on-preview (:id entry)))))
on-restore
(mf/use-fn
(mf/deps entry on-restore)
@ -191,6 +198,11 @@
:on-click on-edit}
(tr "labels.rename")])
[:li {:class (stl/css :menu-option)
:role "button"
:on-click on-preview}
(tr "workspace.versions.button.preview")]
[:li {:class (stl/css :menu-option)
:role "button"
:on-click on-restore}
@ -216,7 +228,7 @@
(tr "labels.delete")])])]]))
(mf/defc snapshot-entry*
[{:keys [entry on-pin-snapshot on-restore-snapshot]}]
[{:keys [entry on-pin-snapshot on-restore-snapshot on-preview-snapshot]}]
(let [open-menu* (mf/use-state nil)
entry-ref (mf/use-ref nil)
@ -243,6 +255,17 @@
(when (fn? on-restore-snapshot)
(on-restore-snapshot id event)))))
on-preview-snapshot
(mf/use-fn
(mf/deps on-preview-snapshot)
(fn [event]
(let [node (dom/get-current-target event)
id (-> node
(dom/get-data "id")
(uuid/parse))]
(when (fn? on-preview-snapshot)
(on-preview-snapshot id event)))))
on-open-snapshot-menu
(mf/use-fn
(mf/deps entry)
@ -266,6 +289,11 @@
:on-close #(reset! open-menu* nil)}
[:ul {:class (stl/css :version-options-dropdown)
:style {"--offset" (dm/str (:offset @open-menu*) "px")}}
[:li {:class (stl/css :menu-option)
:role "button"
:data-id (dm/str (:snapshot @open-menu*))
:on-click on-preview-snapshot}
(tr "workspace.versions.button.preview")]
[:li {:class (stl/css :menu-option)
:role "button"
:data-id (dm/str (:snapshot @open-menu*))
@ -321,6 +349,16 @@
(fn [id label]
(st/emit! (dwv/rename-version id label))))
on-preview-version
(mf/use-fn
(fn [id]
(st/emit! (dwv/preview-version id))))
on-preview-snapshot
(mf/use-fn
(fn [id _event]
(st/emit! (dwv/preview-version id))))
on-restore-version
(mf/use-fn
(fn [id _event]
@ -415,6 +453,7 @@
:on-edit on-edit-version
:on-cancel-edit on-cancel-version-edition
:on-rename on-rename-version
:on-preview on-preview-version
:on-restore on-restore-version
:on-delete on-delete-version
:on-lock on-lock-version
@ -423,6 +462,7 @@
:snapshot
[:> snapshot-entry* {:key (:index entry)
:entry entry
:on-preview-snapshot on-preview-snapshot
:on-restore-snapshot on-restore-snapshot
:on-pin-snapshot on-pin-version}]

View File

@ -8831,6 +8831,9 @@ msgstr "Autosaved %s"
msgid "workspace.versions.button.pin"
msgstr "Pin version"
msgid "workspace.versions.button.preview"
msgstr "Preview version"
#: src/app/main/ui/workspace/sidebar/versions.cljs:273
msgid "workspace.versions.button.restore"
msgstr "Restore version"
@ -8875,6 +8878,15 @@ msgstr "This version is locked by %s and cannot be modified"
msgid "workspace.versions.locked-by-you"
msgstr "This version is locked by you"
msgid "workspace.versions.preview.banner-label"
msgstr "Previewing: %s"
msgid "workspace.versions.preview.exit"
msgstr "Exit preview"
msgid "workspace.versions.preview.unnamed"
msgstr "Unnamed version"
#: src/app/main/ui/workspace/sidebar/versions.cljs:83
msgid "workspace.versions.restore-warning"
msgstr "Do you want to restore this version?"