diff --git a/backend/src/app/rpc/commands/files_snapshot.clj b/backend/src/app/rpc/commands/files_snapshot.clj index 658ab92870..7736b66cd9 100644 --- a/backend/src/app/rpc/commands/files_snapshot.clj +++ b/backend/src/app/rpc/commands/files_snapshot.clj @@ -70,6 +70,7 @@ (assoc :version (:version snapshot)) (assoc :features (:features snapshot)) (assoc :revn (:revn snapshot)) + (assoc :vern (rand-int 100000)) (assoc :permissions perms)))))) (def ^:private schema:create-file-snapshot diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 403375d54d..92760a71a6 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -231,22 +231,6 @@ (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 diff --git a/frontend/src/app/main/data/workspace/versions.cljs b/frontend/src/app/main/data/workspace/versions.cljs index 9067483be3..50f8cf9f6b 100644 --- a/frontend/src/app/main/data/workspace/versions.cljs +++ b/frontend/src/app/main/data/workspace/versions.cljs @@ -13,13 +13,16 @@ [app.common.time :as ct] [app.common.uuid :as uuid] [app.main.data.event :as ev] + [app.main.data.helpers :as dsh] [app.main.data.notifications :as ntf] [app.main.data.persistence :as dwp] [app.main.data.workspace :as dw] [app.main.data.workspace.pages :as dwpg] [app.main.data.workspace.thumbnails :as th] + [app.main.features :as features] [app.main.refs :as refs] [app.main.repo :as rp] + [app.util.i18n :refer [tr]] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) @@ -125,32 +128,6 @@ (rx/take 1) (rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id snapshot-id})))) -(defn restore-version - [id origin] - (assert (uuid? id) "expected valid uuid for `id`") - (ptk/reify ::restore-version - ptk/WatchEvent - (watch [_ state _] - (let [file-id (:current-file-id state) - team-id (:current-team-id state) - event-name (case origin - :version "restore-pin-version" - :snapshot "restore-autosave" - :plugin "restore-version-plugin")] - - (rx/concat - (rx/of ::dwp/force-persist - (dw/remove-layout-flag :document-history)) - - (->> (wait-for-persistence file-id id) - (rx/map #(initialize-version))) - - (if event-name - (rx/of (ev/event {::ev/name event-name - :file-id file-id - :team-id team-id})) - (rx/empty))))))) - (defn delete-version [id] (assert (uuid? id) "expected valid uuid for `id`") @@ -196,73 +173,145 @@ (->> (rp/cmd! :unlock-file-snapshot {:id id}) (rx/map fetch-versions))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; RESTORE VERSION EVENTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- restore-version + [id] + (assert (uuid? id) "expected valid uuid for `id`") + (ptk/reify ::restore-version + ptk/WatchEvent + (watch [_ state _] + (let [file-id (:current-file-id state)] + (rx/concat + (rx/of ::dwp/force-persist + (dw/remove-layout-flag :document-history)) + + (->> (wait-for-persistence file-id id) + (rx/map #(initialize-version)))))))) + +(defn enter-restore + [id] + (assert (uuid? id) "expected valid uuid for `id`") + (ptk/reify ::restore-version + ptk/WatchEvent + (watch [_ state _] + (let [output-s (rx/subject)] + (rx/merge + output-s + (rx/of (ntf/dialog + :content (tr "workspace.versions.restore-warning") + :controls :inline-actions + :cancel {:label (tr "workspace.updates.dismiss") + :callback #(do + (rx/push! output-s (ntf/hide :tag :restore-dialog)) + (rx/end! output-s))} + :accept {:label (tr "labels.restore") + :callback #(do + (rx/push! output-s (restore-version id)) + (rx/end! output-s))} + :tag :restore-dialog))))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; 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 +(defn- apply-snapshot + "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" + [{:keys [id] :as snapshot}] + (ptk/reify ::apply-snapshot-data ptk/UpdateEvent (update [_ state] - (-> state - (update :workspace-versions assoc :preview-id id) - (update :workspace-global assoc :read-only? true))) + (update state :files assoc id snapshot)))) + +(defn exit-preview + "Exit from preview mode and reload the live file data" + [] + (ptk/reify ::exit-preview + ptk/UpdateEvent + (update [_ state] + (let [backup (dm/get-in state [:workspace-versions :backup])] + (-> state + (update :workspace-versions dissoc :backup) + (update :workspace-global dissoc :read-only? :preview-id) + (update :files assoc (:id backup) backup)))) + + ptk/WatchEvent + (watch [_ state _] + (let [team-id (:current-team-id state) + file-id (:current-file-id state) + page-id (:current-page-id state)] + + (rx/of (dwpg/initialize-page file-id page-id)))))) + +(defn enter-preview + "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 and enter on the preview mode" + [id] + (assert (uuid? id) "expected valid uuid for `id`") + + (ptk/reify ::enter-preview + ptk/UpdateEvent + (update [_ state] + (let [file (dsh/lookup-file state)] + (-> state + (update :workspace-versions assoc :backup file) + (update :workspace-global assoc :read-only? true :preview-id id)))) 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?))))))))))) + team-id (:current-team-id state) + features (features/get-enabled-features state team-id) + snapshot (->> (dm/get-in state [:workspace-versions :data]) + (d/seek #(= id (:id %)))) + label (or (:label snapshot) + (tr "workspace.versions.preview.unnamed")) + output-s (rx/subject)] + (rx/merge + output-s -(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)))) + (rx/of (ntf/dialog + :content (tr "workspace.versions.preview-banner-title" label) + :controls :inline-actions + :cancel {:label (tr "labels.exit") + :callback #(do + (rx/push! output-s (ntf/hide)) + (rx/push! output-s (exit-preview)) + (rx/end! output-s))} + :accept {:label (tr "labels.restore") + :callback #(do + (rx/push! output-s (ntf/hide)) + (rx/push! output-s (restore-version id)) + (rx/end! output-s))} + :tag :preview-dialog)) - 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)))))) + (->> (rp/cmd! :get-file-snapshot + {:file-id file-id + :id id + :features features}) + (rx/mapcat + (fn [snapshot] + (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. + (apply-snapshot snapshot) + ;; Re-initialize the page to rebuild its search index + ;; and page-local state with the new snapshot + ;; objects. + (dwpg/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 (exit-preview)))))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; PLUGINS SPECIFIC EVENTS @@ -320,9 +369,8 @@ (watch [_ state _] (let [team-id (:current-team-id state)] (rx/concat - (rx/of (ev/event {::ev/name "restore-version-plugin" - :file-id file-id - :team-id team-id}) + (rx/of (ev/event {::ev/name "restore-version" + ::ev/origin "plugins"}) ::dwp/force-persist) (->> (wait-for-persistence file-id id) diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index 620e1922f1..07aa0540c8 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -134,54 +134,6 @@ :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] @@ -319,7 +271,6 @@ :touch-action "none" :position "relative"}} [:> context-menu*] - [:> preview-banner*] (when (and file-loaded? page-id) [:> workspace-inner* {:page-id page-id diff --git a/frontend/src/app/main/ui/workspace.scss b/frontend/src/app/main/ui/workspace.scss index b0cceb2c87..5cd617bab4 100644 --- a/frontend/src/app/main/ui/workspace.scss +++ b/frontend/src/app/main/ui/workspace.scss @@ -54,53 +54,3 @@ 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); - } -} diff --git a/frontend/src/app/main/ui/workspace/sidebar/versions.cljs b/frontend/src/app/main/ui/workspace/sidebar/versions.cljs index e1cc1f454f..48ab103561 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/versions.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/versions.cljs @@ -11,7 +11,7 @@ [app.common.time :as ct] [app.common.uuid :as uuid] [app.config :as cfg] - [app.main.data.notifications :as ntf] + [app.main.data.event :as ev] [app.main.data.workspace.versions :as dwv] [app.main.refs :as refs] [app.main.store :as st] @@ -77,17 +77,6 @@ (assoc item :index index))) (reverse))) -(defn- open-restore-version-dialog - [origin 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! (dwv/restore-version id origin))} - :tag :restore-dialog))) - (mf/defc version-entry* {::mf/private true} [{:keys [entry current-profile on-preview on-restore on-delete on-rename on-lock on-unlock on-edit on-cancel-edit is-editing]}] @@ -352,22 +341,34 @@ on-preview-version (mf/use-fn (fn [id] - (st/emit! (dwv/preview-version id)))) + (st/emit! (dwv/enter-preview id) + (ev/event {::ev/name "preview-version" + ::ev/origin "workspace:sidebar" + :type "pinned-version"})))) on-preview-snapshot (mf/use-fn (fn [id _event] - (st/emit! (dwv/preview-version id)))) + (st/emit! (dwv/enter-preview id) + (ev/event {::ev/name "preview-version" + ::ev/origin "workspace:sidebar" + :type "autosaved-version"})))) on-restore-version (mf/use-fn (fn [id _event] - (open-restore-version-dialog :version id))) + (st/emit! (dwv/enter-restore id) + (ev/event {::ev/name "restore-version" + ::ev/origin "workspace:sidebar" + :type "pinned-version"})))) on-restore-snapshot (mf/use-fn (fn [id _event] - (open-restore-version-dialog :snapshot id))) + (st/emit! (dwv/enter-restore id) + (ev/event {::ev/name "restore-version" + ::ev/origin "workspace:sidebar" + :type "autosaved-version"})))) on-delete-version (mf/use-fn diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 78ac223082..f4480ddd7f 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -97,6 +97,7 @@ {:keys [options-mode tooltip + preview-id show-distances? picking-color?]} wglobal @@ -313,23 +314,28 @@ (hooks/setup-shortcuts path-editing? path-drawing? text-editing? grid-editing?) (hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox) - [:div {:class (stl/css :viewport) :style #js {"--zoom" zoom} :data-testid "viewport"} - (when (:can-edit permissions) - (if read-only? - [:> view-only-bar* {}] - [:* - (when-not hide-ui? - [:> top-toolbar* {:layout layout}]) + [:div {:class (stl/css :viewport) :style {"--zoom" zoom} :data-testid "viewport"} + (cond + (some? preview-id) + nil - (when (and ^boolean path-editing? - ^boolean single-select?) - [:> path-edition-bar* {:shape editing-shape - :edit-path-state edit-path-state - :layout layout}]) + (and read-only? (:can-edit permissions)) + [:> view-only-bar* {}] - (when (and ^boolean grid-editing? - ^boolean single-select?) - [:> grid-edition-bar* {:shape editing-shape}])])) + :else + [:* + (when-not hide-ui? + [:> top-toolbar* {:layout layout}]) + + (when (and ^boolean path-editing? + ^boolean single-select?) + [:> path-edition-bar* {:shape editing-shape + :edit-path-state edit-path-state + :layout layout}]) + + (when (and ^boolean grid-editing? + ^boolean single-select?) + [:> grid-edition-bar* {:shape editing-shape}])]) [:div {:class (stl/css :viewport-overlays)} ;; The behaviour inside a foreign object is a bit different that in plain HTML so we wrap diff --git a/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs b/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs index b9f4f69cb4..2fca82347f 100644 --- a/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs @@ -20,14 +20,12 @@ ;; branch. (mf/defc view-only-bar* - {::mf/private true} [] - (let [handle-close-view-mode + (let [on-close (mf/use-fn - (fn [] - (st/emit! :interrupt - (dw/set-options-mode :design) - (dwc/set-workspace-read-only false))))] + #(st/emit! :interrupt + (dw/set-options-mode :design) + (dwc/set-workspace-read-only false)))] [:div {:class (stl/css :viewport-actions)} [:div {:class (stl/css :viewport-actions-container)} [:div {:class (stl/css :viewport-actions-title)} @@ -35,7 +33,7 @@ {:tag-name "span" :content (tr "workspace.top-bar.view-only")}]] [:button {:class (stl/css :done-btn) - :on-click handle-close-view-mode} + :on-click on-close} (tr "workspace.top-bar.read-only.done")]]])) (mf/defc path-edition-bar* diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index b95962c4c5..01a931b91f 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -98,6 +98,7 @@ {:keys [options-mode tooltip show-distances? + preview-id picking-color?]} wglobal @@ -454,22 +455,28 @@ (hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox) [:div {:class (stl/css :viewport) :style #js {"--zoom" zoom} :data-testid "viewport"} - (when (:can-edit permissions) - (if read-only? - [:> view-only-bar* {}] - [:* - (when-not hide-ui? - [:> top-toolbar* {:layout layout}]) - (when (and ^boolean path-editing? - ^boolean single-select?) - [:> path-edition-bar* {:shape editing-shape - :edit-path-state edit-path-state - :layout layout}]) + (cond + (some? preview-id) + nil - (when (and ^boolean grid-editing? - ^boolean single-select?) - [:> grid-edition-bar* {:shape editing-shape}])])) + (and read-only? (:can-edit permissions)) + [:> view-only-bar* {}] + + :else + [:* + (when-not hide-ui? + [:> top-toolbar* {:layout layout}]) + + (when (and ^boolean path-editing? + ^boolean single-select?) + [:> path-edition-bar* {:shape editing-shape + :edit-path-state edit-path-state + :layout layout}]) + + (when (and ^boolean grid-editing? + ^boolean single-select?) + [:> grid-edition-bar* {:shape editing-shape}])]) [:div {:class (stl/css :viewport-overlays)} (when show-comments? diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 794f57f21a..335bb9594f 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -2960,6 +2960,9 @@ msgstr "Resend invitation" msgid "labels.restore" msgstr "Restore" +msgid "labels.exit" +msgstr "Exit" + #: src/app/main/ui/components/progress.cljs:80, src/app/main/ui/static.cljs:299, src/app/main/ui/static.cljs:308, src/app/main/ui/static.cljs:419 msgid "labels.retry" msgstr "Retry" @@ -8878,11 +8881,8 @@ 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-banner-title" +msgstr "Previewing version: %s" msgid "workspace.versions.preview.unnamed" msgstr "Unnamed version" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 9314ec02d2..b7d95f1f17 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -2907,6 +2907,9 @@ msgstr "Reenviar invitacion" msgid "labels.restore" msgstr "Restaurar" +msgid "labels.exit" +msgstr "Salir" + #: src/app/main/ui/components/progress.cljs:80, src/app/main/ui/static.cljs:299, src/app/main/ui/static.cljs:308, src/app/main/ui/static.cljs:419 msgid "labels.retry" msgstr "Reintentar" @@ -8726,6 +8729,9 @@ msgstr "Versiones de %s" msgid "workspace.versions.loading" msgstr "Cargando..." +msgid "workspace.versions.preview-banner-title" +msgstr "Previsualizando version: %s" + #: src/app/main/ui/workspace/sidebar/versions.cljs:83 msgid "workspace.versions.restore-warning" msgstr "¿Quieres restaurar esta versión?"