mirror of
https://github.com/penpot/penpot.git
synced 2026-11-08 19:16:56 +00:00
Hand applied transitions to the component watcher only after the workspace records their undo entry. Keep the handoff synchronous with the captured prior file data. This preserves transaction order when nesting triggers several component syncs. Fix F137. The minimal replay failed both undo and redo before the fix; its 4 checks now pass. Existing sync scenarios pass their behavior and undo checks; 60 golden digests need review for the accumulated Phase 4 fixes. Common benchmark ratios against Phase 1e: main 10/100/1000 0.46/0.52/0.53; levels 0.47; chain 0.51; library 0.51; many 0.49; switch 0.61. This handoff changes no common benchmark code. AI-assisted-by: GPT-6
1748 lines
70 KiB
Clojure
1748 lines
70 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 SUBSIDIARY SL
|
|
|
|
(ns app.main.data.workspace.libraries
|
|
(:require
|
|
[app.common.data :as d]
|
|
[app.common.data.macros :as dm]
|
|
[app.common.files.changes :as ch]
|
|
[app.common.files.changes-builder :as pcb]
|
|
[app.common.files.helpers :as cfh]
|
|
[app.common.files.shapes-helpers :as cfsh]
|
|
[app.common.geom.point :as gpt]
|
|
[app.common.logging :as log]
|
|
[app.common.logic.duplicate :as cld]
|
|
[app.common.logic.instances :as cli]
|
|
[app.common.logic.libraries :as cll]
|
|
[app.common.logic.shapes :as cls]
|
|
[app.common.logic.tokens :as clo]
|
|
[app.common.logic.variants :as clv]
|
|
[app.common.path-names :as cpn]
|
|
[app.common.time :as ct]
|
|
[app.common.types.color :as ctc]
|
|
[app.common.types.component :as ctk]
|
|
[app.common.types.components-list :as ctkl]
|
|
[app.common.types.container :as ctn]
|
|
[app.common.types.file :as ctf]
|
|
[app.common.types.library :as ctl]
|
|
[app.common.types.text :as cttx]
|
|
[app.common.types.typography :as ctt]
|
|
[app.common.uuid :as uuid]
|
|
[app.config :as cf]
|
|
[app.main.data.changes :as dch]
|
|
[app.main.data.comments :as dc]
|
|
[app.main.data.common :as dcm]
|
|
[app.main.data.event :as ev]
|
|
[app.main.data.helpers :as dsh]
|
|
[app.main.data.modal :as modal]
|
|
[app.main.data.notifications :as ntf]
|
|
[app.main.data.workspace :as-alias dw]
|
|
[app.main.data.workspace.groups :as dwg]
|
|
[app.main.data.workspace.notifications :as-alias dwn]
|
|
[app.main.data.workspace.pages :as-alias dwpg]
|
|
[app.main.data.workspace.reflow :as wrf]
|
|
[app.main.data.workspace.selection :as dws]
|
|
[app.main.data.workspace.shapes :as dwsh]
|
|
[app.main.data.workspace.specialized-panel :as dwsp]
|
|
[app.main.data.workspace.thumbnails :as dwt]
|
|
[app.main.data.workspace.thumbnails-wasm :as dwt.wasm]
|
|
[app.main.data.workspace.tokens.propagation :as dwtp]
|
|
[app.main.data.workspace.transforms :as dwtr]
|
|
[app.main.data.workspace.undo :as dwu]
|
|
[app.main.data.workspace.wasm-text :as dwwt]
|
|
[app.main.data.workspace.zoom :as dwz]
|
|
[app.main.features :as features]
|
|
[app.main.features.pointer-map :as fpmap]
|
|
[app.main.fonts :as fonts]
|
|
[app.main.repo :as rp]
|
|
[app.main.router :as rt]
|
|
[app.main.store :as st]
|
|
[app.util.color :as uc]
|
|
[app.util.i18n :refer [tr]]
|
|
[beicon.v2.core :as rx]
|
|
[cuerdas.core :as str]
|
|
[potok.v2.core :as ptk]))
|
|
|
|
;; Change this to :info :debug or :trace to debug this module, or :warn to reset to default
|
|
(log/set-level! :warn)
|
|
|
|
(defn- debug-pretty-file
|
|
[file-id state]
|
|
(if (= file-id (:current-file-id state))
|
|
"<local>"
|
|
(str "<" (get-in state [:files file-id :name]) ">")))
|
|
|
|
(defn- log-changes
|
|
[changes file]
|
|
(let [extract-change
|
|
(fn [change]
|
|
(let [shape (when (:id change)
|
|
(cond
|
|
(:page-id change)
|
|
(get-in file [:pages-index
|
|
(:page-id change)
|
|
:objects
|
|
(:id change)])
|
|
(:component-id change)
|
|
(get-in file [:components
|
|
(:component-id change)
|
|
:objects
|
|
(:id change)])
|
|
:else nil))
|
|
|
|
prefix (if (:component-id change) "[C] " "[P] ")
|
|
|
|
extract (cond-> {:type (:type change)
|
|
:raw-change change}
|
|
shape
|
|
(assoc :shape (str prefix (:name shape))
|
|
:shape-id (str (:id shape)))
|
|
(:obj change)
|
|
(assoc :obj (:name (:obj change))
|
|
:obj-id (:id (:obj change)))
|
|
(:operations change)
|
|
(assoc :operations (:operations change)))]
|
|
extract))]
|
|
(map extract-change changes)))
|
|
|
|
(declare sync-file)
|
|
|
|
(defn extract-path-if-missing
|
|
[item]
|
|
(let [[path name] (cpn/split-group-name (:name item))]
|
|
(if (and
|
|
(= (:name item) name)
|
|
(contains? item :path))
|
|
item
|
|
(assoc item :path path :name name))))
|
|
|
|
(defn add-color
|
|
([color]
|
|
(add-color color nil))
|
|
|
|
([color {:keys [rename?] :or {rename? true}}]
|
|
(let [color (-> color
|
|
(update :id #(or % (uuid/next)))
|
|
(assoc :name (or (get-in color [:image :name])
|
|
(:color color)
|
|
(uc/gradient-type->string (get-in color [:gradient :type]))))
|
|
(d/without-nils)
|
|
(ctc/check-library-color))]
|
|
|
|
(ptk/reify ::add-color
|
|
ev/Event
|
|
(-data [_] color)
|
|
|
|
ptk/WatchEvent
|
|
(watch [it _ _]
|
|
(let [changes (-> (pcb/empty-changes it)
|
|
(pcb/add-color color))]
|
|
(rx/of
|
|
(when rename?
|
|
(fn [state] (assoc-in state [:workspace-local :color-for-rename] (:id color))))
|
|
(dch/commit-changes changes))))))))
|
|
|
|
(def clear-color-for-rename
|
|
(ptk/reify ::clear-color-for-rename
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(assoc-in state [:workspace-local :color-for-rename] nil))))
|
|
|
|
(defn- update-color*
|
|
[it state color file-id]
|
|
(let [data (dsh/lookup-file-data state)
|
|
[path name] (cpn/split-group-name (:name color))
|
|
color (assoc color :path path :name name)
|
|
changes (-> (pcb/empty-changes it)
|
|
(pcb/with-library-data data)
|
|
(pcb/update-color color))
|
|
undo-id (js/Symbol)]
|
|
(rx/of (dwu/start-undo-transaction undo-id)
|
|
(dch/commit-changes changes)
|
|
(sync-file (:id data) file-id :colors (:id color))
|
|
(dwu/commit-undo-transaction undo-id))))
|
|
|
|
(defn update-color
|
|
[color file-id]
|
|
(assert (uuid? file-id) "expected a uuid instance for `file-id`")
|
|
|
|
(let [color (-> (d/without-nils color)
|
|
(ctc/check-library-color))]
|
|
(ptk/reify ::update-color
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(update-color* it state color file-id)))))
|
|
|
|
(defn update-color-data
|
|
"Update color data without affecting the path location"
|
|
[color file-id]
|
|
(assert (uuid? file-id) "expected a uuid instance for `file-id`")
|
|
|
|
(let [color (-> (d/without-nils color)
|
|
(ctc/check-library-color))]
|
|
|
|
(ptk/reify ::update-color-data
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(let [color (assoc color :name (dm/str (:path color) "/" (:name color)))]
|
|
(update-color* it state color file-id))))))
|
|
|
|
;; FIXME: revisit why file-id is passed on the event
|
|
(defn rename-color
|
|
[file-id id new-name]
|
|
|
|
(assert (uuid? id) "expected valid uuid instance for `id`")
|
|
(assert (uuid? file-id) "expected a uuid instance for `file-id`")
|
|
(assert (string? new-name) "expected a string instance for `new-name`")
|
|
|
|
(ptk/reify ::rename-color
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(let [new-name (str/trim new-name)]
|
|
(if (str/empty? new-name)
|
|
(rx/empty)
|
|
(let [data (dsh/lookup-file-data state)
|
|
color (-> (ctl/get-color data id)
|
|
(assoc :name new-name)
|
|
(d/without-nils)
|
|
(ctc/check-library-color))]
|
|
(update-color* it state color file-id)))))))
|
|
|
|
(defn delete-color
|
|
[{:keys [id] :as params}]
|
|
(assert (uuid? id) "expected valid uuid instance for `id`")
|
|
|
|
(ptk/reify ::delete-color
|
|
ev/Event
|
|
(-data [_] {:id id})
|
|
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(let [data (dsh/lookup-file-data state)
|
|
changes (-> (pcb/empty-changes it)
|
|
(pcb/with-library-data data)
|
|
(pcb/delete-color id))]
|
|
(rx/of (dch/commit-changes changes))))))
|
|
|
|
(defn duplicate-color
|
|
[file-id color-id]
|
|
(assert (uuid? file-id) "expected valid uuid for `file-id`")
|
|
(assert (uuid? color-id) "expected valid uuid for `color-id`")
|
|
|
|
(ptk/reify ::duplicate-color
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(let [data (dsh/lookup-file-data state)
|
|
color (ctl/get-color data color-id)
|
|
new-color (-> color
|
|
(assoc :id (uuid/next))
|
|
(d/without-nils)
|
|
(ctc/check-library-color))
|
|
changes (-> (pcb/empty-changes it)
|
|
(pcb/add-color new-color))]
|
|
(rx/of (dch/commit-changes changes))))))
|
|
|
|
(defn add-typography
|
|
([typography] (add-typography typography true))
|
|
([typography edit?]
|
|
(let [typography (-> (update typography :id #(or % (uuid/next)))
|
|
(ctt/check-typography))]
|
|
(ptk/reify ::add-typography
|
|
ev/Event
|
|
(-data [_] typography)
|
|
|
|
ptk/WatchEvent
|
|
(watch [it _ _]
|
|
(let [changes (-> (pcb/empty-changes it)
|
|
(pcb/add-typography typography))]
|
|
(rx/of (dch/commit-changes changes)
|
|
#(cond-> %
|
|
edit?
|
|
(assoc-in [:workspace-global :edit-typography] (:id typography))))))))))
|
|
|
|
(defn- do-update-tipography
|
|
[it state typography file-id]
|
|
(let [data (dsh/lookup-file-data state)
|
|
typography (extract-path-if-missing typography)
|
|
changes (-> (pcb/empty-changes it)
|
|
(pcb/with-library-data data)
|
|
(pcb/update-typography typography))
|
|
undo-id (js/Symbol)]
|
|
(rx/of (dwu/start-undo-transaction undo-id)
|
|
(dch/commit-changes changes)
|
|
(sync-file (:current-file-id state) file-id :typographies (:id typography))
|
|
(dwu/commit-undo-transaction undo-id))))
|
|
|
|
(defn update-typography
|
|
[typography file-id]
|
|
(assert (uuid? file-id) "expected valid uuid for `file-id`")
|
|
(let [typography (ctt/check-typography typography)]
|
|
(ptk/reify ::update-typography
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(do-update-tipography it state typography file-id)))))
|
|
|
|
(defn rename-typography
|
|
[file-id id new-name]
|
|
(dm/assert! (uuid? file-id))
|
|
(dm/assert! (uuid? id))
|
|
(dm/assert! (string? new-name))
|
|
(ptk/reify ::rename-typography
|
|
ev/Event
|
|
(-data [_] {:id id :name new-name})
|
|
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(when (and (some? new-name) (not= "" new-name))
|
|
(let [data (dsh/lookup-file-data state)
|
|
[path name] (cpn/split-group-name new-name)
|
|
object (get-in data [:typographies id])
|
|
new-object (assoc object :path path :name name)]
|
|
(do-update-tipography it state new-object file-id))))))
|
|
|
|
(defn delete-typography
|
|
[id]
|
|
(dm/assert! (uuid? id))
|
|
(ptk/reify ::delete-typography
|
|
ev/Event
|
|
(-data [_] {:id id})
|
|
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(let [data (dsh/lookup-file-data state)
|
|
changes (-> (pcb/empty-changes it)
|
|
(pcb/with-library-data data)
|
|
(pcb/delete-typography id))]
|
|
(rx/of (dch/commit-changes changes))))))
|
|
|
|
(defn duplicate-typography
|
|
[file-id typography-id]
|
|
(assert (uuid? file-id) "expected valid uuid for `file-id`")
|
|
(assert (uuid? typography-id) "expected valid uuid for `typography-id`")
|
|
|
|
(ptk/reify ::duplicate-typography
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(let [data (dsh/lookup-file-data state)
|
|
typography (get-in data [:typographies typography-id])
|
|
new-typography (-> typography
|
|
(assoc :id (uuid/next))
|
|
(ctt/check-typography))
|
|
changes (-> (pcb/empty-changes it)
|
|
(pcb/add-typography new-typography))]
|
|
(rx/of (dch/commit-changes changes))))))
|
|
|
|
(defn- add-component2
|
|
"This is the second step of the component creation."
|
|
([selected]
|
|
(add-component2 nil selected))
|
|
([id-ref selected]
|
|
(ptk/reify ::add-component2
|
|
ev/Event
|
|
(-data [_]
|
|
{::ev/name "add-component"
|
|
:shapes (count selected)})
|
|
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(let [file-id (:current-file-id state)
|
|
page-id (:current-page-id state)
|
|
objects (dsh/lookup-page-objects state file-id page-id)
|
|
shapes (dwg/shapes-for-grouping objects selected)
|
|
parents (into #{} (map :parent-id) shapes)]
|
|
(when-not (empty? shapes)
|
|
(let [[root component-id changes]
|
|
(cli/generate-add-component (pcb/empty-changes it) shapes objects page-id file-id
|
|
cfsh/prepare-create-artboard-from-selection)]
|
|
(when id-ref
|
|
(reset! id-ref component-id))
|
|
(when-not (empty? (:redo-changes changes))
|
|
(rx/concat
|
|
(rx/of (dch/commit-changes changes)
|
|
(dws/select-shapes (d/ordered-set (:id root)))
|
|
(ptk/data-event :layout/update {:ids parents}))
|
|
|
|
;; When activated the wasm rendering we need to recreate its thumbnail on creation
|
|
(if (features/active-feature? state "render-wasm/v1")
|
|
(rx/of (dwt.wasm/render-thumbnail file-id page-id (:id root))
|
|
(dwt.wasm/persist-thumbnail file-id page-id (:id root)))
|
|
(rx/empty)))))))))))
|
|
|
|
(defn add-component
|
|
"Add a new component to current file library, from the currently selected shapes.
|
|
This operation is made in two steps, first one for calculate the
|
|
shapes that will be part of the component and the second one with
|
|
the component creation."
|
|
([]
|
|
(add-component nil nil))
|
|
|
|
([id-ref ids]
|
|
(ptk/reify ::add-component
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(let [objects (dsh/lookup-page-objects state)
|
|
selected (->> (d/nilv ids (dsh/lookup-selected state))
|
|
(cfh/clean-loops objects))
|
|
selected-objects (map #(get objects %) selected)
|
|
;; We don't want to change the structure of component copies
|
|
can-make-component (every? #(ctn/valid-shape-for-component? objects %) selected-objects)]
|
|
|
|
(when can-make-component
|
|
(rx/of (-> (add-component2 id-ref selected)
|
|
(with-meta (meta it))))))))))
|
|
|
|
(defn add-multiple-components
|
|
"Add several new components to current file library, from the currently selected shapes."
|
|
[]
|
|
(ptk/reify ::add-multiple-components
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [objects (dsh/lookup-page-objects state)
|
|
selected (->> (dsh/lookup-selected state)
|
|
(cfh/clean-loops objects))
|
|
selected-objects (map #(get objects %) selected)
|
|
;; We don't want to change the structure of component copies
|
|
can-make-component (every? #(ctn/valid-shape-for-component? objects %) selected-objects)
|
|
added-components (map (fn [id]
|
|
(with-meta (add-component2 [id])
|
|
{:multiple true}))
|
|
selected)
|
|
undo-id (js/Symbol)]
|
|
(when can-make-component
|
|
(rx/concat
|
|
(rx/of (dwu/start-undo-transaction undo-id))
|
|
(rx/from added-components)
|
|
(rx/of (dwu/commit-undo-transaction undo-id))))))))
|
|
|
|
(defn rename-component
|
|
"Rename the component with the given id, in the current file library."
|
|
[id new-name]
|
|
(dm/assert!
|
|
"expected an uuid instance"
|
|
(uuid? id))
|
|
|
|
(dm/assert!
|
|
"expected string for new-name"
|
|
(string? new-name))
|
|
|
|
(ptk/reify ::rename-component
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(let [new-name (str/trim new-name)]
|
|
(if (str/empty? new-name)
|
|
(rx/empty)
|
|
(let [data (dsh/lookup-file-data state)
|
|
changes (-> (pcb/empty-changes it)
|
|
(cll/generate-rename-component id new-name data))]
|
|
(rx/of (dch/commit-changes changes))))))))
|
|
|
|
(defn rename-component-and-main-instance
|
|
[component-id name]
|
|
(ptk/reify ::rename-component-and-main-instance
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [clean-name (cpn/normalize-path-name name)
|
|
data (dsh/lookup-file-data state)
|
|
component (dm/get-in data [:components component-id])]
|
|
|
|
(when (and (some? clean-name) component)
|
|
(let [shape-id (:main-instance-id component)
|
|
page-id (:main-instance-page component)]
|
|
|
|
(rx/concat
|
|
(rx/of (rename-component component-id clean-name))
|
|
|
|
(when (and shape-id page-id)
|
|
(rx/of (dwsh/update-shapes [shape-id] #(assoc % :name clean-name) {:page-id page-id :stack-undo? true}))))))))))
|
|
|
|
(defn duplicate-component
|
|
"Create a new component copied from the one with the given id."
|
|
([library-id component-id]
|
|
(duplicate-component library-id component-id (uuid/next)))
|
|
([library-id component-id new-component-id]
|
|
(ptk/reify ::duplicate-component
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(let [libraries (dsh/lookup-libraries state)
|
|
library (get libraries library-id)
|
|
|
|
[main-instance changes]
|
|
(-> (pcb/empty-changes it nil)
|
|
(cld/generate-duplicate-component library component-id new-component-id))]
|
|
(rx/of
|
|
(ptk/data-event :layout/update {:page-id (:main-instance-page (get-in library [:data :components component-id]))
|
|
:ids [(:id main-instance)]})
|
|
(dch/commit-changes changes)))))))
|
|
|
|
(defn delete-component
|
|
"Delete the component with the given id, from the current file library."
|
|
[{:keys [id]}]
|
|
(dm/assert!
|
|
"expected valid uuid for `id`"
|
|
(uuid? id))
|
|
|
|
(ptk/reify ::delete-component
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(let [file-id (:current-file-id state)
|
|
fdata (dsh/lookup-file-data state file-id)
|
|
component (ctkl/get-component fdata id)
|
|
page-id (:main-instance-page component)
|
|
root-id (:main-instance-id component)
|
|
|
|
page (dsh/get-page fdata page-id)
|
|
objects (:objects page)
|
|
|
|
undo-group (uuid/next)
|
|
undo-id (js/Symbol)
|
|
|
|
[all-parents changes]
|
|
(-> (pcb/empty-changes it page-id)
|
|
;; Deleting main root triggers component delete
|
|
(cls/generate-delete-shapes fdata page objects #{root-id} {:undo-group undo-group
|
|
:undo-id undo-id}))]
|
|
(rx/of
|
|
(dwu/start-undo-transaction undo-id)
|
|
(dwt/clear-thumbnail (:current-file-id state) page-id root-id "component")
|
|
(dc/detach-comment-thread #{root-id})
|
|
(dch/commit-changes changes)
|
|
(ptk/data-event :layout/update {:ids all-parents :undo-group undo-group})
|
|
(dwu/commit-undo-transaction undo-id))))))
|
|
|
|
(defn restore-component
|
|
"Restore a deleted component, with the given id, in the given file library."
|
|
[library-id component-id]
|
|
(dm/assert! (uuid? library-id))
|
|
(dm/assert! (uuid? component-id))
|
|
(ptk/reify ::restore-component
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(let [current-file-id (:current-file-id state)
|
|
local? (= current-file-id library-id)
|
|
|
|
ldata (dsh/lookup-file-data state library-id)
|
|
component (get-in ldata [:components component-id])
|
|
comp-page-id (:main-instance-page component)
|
|
comp-page (dsh/get-page ldata comp-page-id)
|
|
objects (:objects comp-page)
|
|
|
|
changes (-> (pcb/empty-changes it)
|
|
(cli/generate-restore-component ldata component-id library-id comp-page objects))
|
|
|
|
page-id
|
|
(->> changes :redo-changes (keep :page-id) first)
|
|
|
|
frames
|
|
(->> changes :redo-changes (keep :frame-id))]
|
|
|
|
(rx/of (dch/commit-changes changes)
|
|
(when local?
|
|
(ptk/data-event :layout/update {:page-id page-id :ids frames})))))))
|
|
|
|
(defn restore-components
|
|
"Restore multiple deleted component definded by a map with the component id as key and the component library as value"
|
|
[components-data]
|
|
(dm/assert! (map? components-data))
|
|
(ptk/reify ::restore-components
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
(let [undo-id (js/Symbol)]
|
|
(rx/concat
|
|
(rx/of (dwu/start-undo-transaction undo-id))
|
|
(rx/map #(restore-component (val %) (key %)) (rx/from components-data))
|
|
(rx/of (dwu/commit-undo-transaction undo-id)))))))
|
|
|
|
(defn instantiate-component
|
|
"Create a new shape in the current page, from the component with the given id
|
|
in the given file library. Then selects the newly created instance."
|
|
([file-id component-id position]
|
|
(instantiate-component file-id component-id position nil))
|
|
([file-id component-id position {:keys [start-move? initial-point id-ref origin]}]
|
|
(dm/assert! (uuid? file-id))
|
|
(dm/assert! (uuid? component-id))
|
|
(dm/assert! (gpt/point? position))
|
|
(ptk/reify ::instantiate-component
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(let [page (dsh/lookup-page state)
|
|
libraries (dsh/lookup-libraries state)
|
|
|
|
objects (:objects page)
|
|
changes (-> (pcb/empty-changes it (:id page))
|
|
(pcb/with-objects objects))
|
|
|
|
current-file-id (:current-file-id state)
|
|
|
|
[new-shape changes]
|
|
(cli/generate-instantiate-component changes
|
|
objects
|
|
file-id
|
|
component-id
|
|
position
|
|
page
|
|
libraries)
|
|
component (ctn/get-component-from-shape new-shape libraries)
|
|
|
|
undo-id (js/Symbol)]
|
|
|
|
(when id-ref
|
|
(reset! id-ref (:id new-shape)))
|
|
|
|
(rx/of (ev/event
|
|
(-> {::ev/name "use-library-component"
|
|
::ev/origin origin
|
|
:external-library (not= file-id current-file-id)
|
|
:is-variant (ctk/is-variant? component)}
|
|
(merge (meta it))))
|
|
(dwu/start-undo-transaction undo-id)
|
|
(dch/commit-changes changes)
|
|
(dwtp/propagate-workspace-tokens) ;; Make the new instance get the token values from the current file, not from the component's library
|
|
(ptk/data-event :layout/update {:ids [(:id new-shape)]})
|
|
(dws/select-shapes (d/ordered-set (:id new-shape)))
|
|
(when start-move?
|
|
(dwtr/start-move initial-point #{(:id new-shape)}))
|
|
(dwu/commit-undo-transaction undo-id)))))))
|
|
|
|
(defn detach-component
|
|
"Remove all references to components in the shape with the given id,
|
|
and all its children, at the current page."
|
|
[id]
|
|
(dm/assert! (uuid? id))
|
|
(ptk/reify ::detach-component
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(let [page-id (:current-page-id state)
|
|
file-id (:current-file-id state)
|
|
objects (dsh/lookup-page-objects state page-id)
|
|
|
|
fdata (dsh/lookup-file-data state file-id)
|
|
libraries (dsh/lookup-libraries state)]
|
|
|
|
(when (ctn/detachable? objects (get objects id))
|
|
(rx/of (dch/commit-changes (-> (pcb/empty-changes it)
|
|
(cli/generate-detach-component id fdata page-id libraries)))))))))
|
|
|
|
(defn detach-components
|
|
"Remove all references to components in the shapes with the given ids"
|
|
[ids]
|
|
(dm/assert! (seq ids))
|
|
(ptk/reify ::detach-components
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
(let [undo-id (js/Symbol)]
|
|
(rx/concat
|
|
(rx/of (dwu/start-undo-transaction undo-id))
|
|
(rx/map #(detach-component %) (rx/from ids))
|
|
(rx/of (dwu/commit-undo-transaction undo-id)))))))
|
|
|
|
(def detach-selected-components
|
|
(ptk/reify ::detach-selected-components
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(let [page-id (:current-page-id state)
|
|
file-id (:current-file-id state)
|
|
|
|
;; FIXME: revisit, inefficient access
|
|
objects (dsh/lookup-page-objects state page-id)
|
|
|
|
libraries (dsh/lookup-libraries state)
|
|
fdata (dsh/lookup-file-data state file-id)
|
|
|
|
selected (->> state
|
|
(dsh/lookup-selected)
|
|
(cfh/clean-loops objects))
|
|
|
|
selected-objects (map #(get objects %) selected)
|
|
copies (filter ctk/in-component-copy? selected-objects)
|
|
can-detach? (and (seq copies)
|
|
(every? #(not (ctn/has-any-copy-parent? objects %)) selected-objects))
|
|
|
|
changes (when can-detach?
|
|
(reduce
|
|
(fn [changes id]
|
|
(cli/generate-detach-component changes id fdata page-id libraries))
|
|
(pcb/empty-changes it)
|
|
selected))]
|
|
|
|
(rx/of (when can-detach?
|
|
(dch/commit-changes changes)))))))
|
|
|
|
(defn go-to-component-file
|
|
[file-id component update-layout?]
|
|
|
|
(assert (uuid? file-id) "expected an uuid for `file-id`")
|
|
(assert (ctk/check-component component) "expected a valid component")
|
|
|
|
(ptk/reify ::nav-to-component-file
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [params (-> (rt/get-params state)
|
|
(assoc :file-id file-id)
|
|
(assoc :page-id (:main-instance-page component))
|
|
(assoc :component-id (:id component))
|
|
(assoc :update-layout update-layout?))]
|
|
(rx/of (rt/nav :workspace params ::rt/new-window true))))))
|
|
|
|
(defn go-to-page-then
|
|
"Open the page `page-id` and, once it is initialized, emit the events of
|
|
`(f)`."
|
|
[stream page-id f]
|
|
(rx/merge
|
|
(->> stream
|
|
(rx/filter (ptk/type? ::dwpg/initialize-page))
|
|
(rx/take 1)
|
|
(rx/observe-on :async)
|
|
(rx/mapcat (fn [_] (f))))
|
|
(rx/of (dcm/go-to-workspace :page-id page-id))))
|
|
|
|
(defn go-to-local-component
|
|
;; id is the id of the component to go
|
|
;; additional-ids are ids of additional components on the same page
|
|
;; that will be selected and zoomed along the main one
|
|
;; update-layout? indicates if it should send a :layout/update event
|
|
;; for the parents of the component
|
|
[& {:keys [id additional-ids update-layout? retries] :or {retries 0} :as options}]
|
|
(ptk/reify ::go-to-local-component
|
|
ptk/WatchEvent
|
|
(watch [_ state stream]
|
|
(let [current-page-id (:current-page-id state)
|
|
data (dsh/lookup-file-data state)
|
|
objects (dsh/lookup-page-objects state current-page-id)
|
|
|
|
select-and-zoom
|
|
(fn [ids]
|
|
(let [parent-ids (when update-layout?
|
|
(keep #(-> (get objects %) :parent-id) ids))]
|
|
|
|
(if (and update-layout? (empty? parent-ids) (< retries 8))
|
|
;; The objects are not loaded yet, wait and try again
|
|
(->> (rx/of (go-to-local-component (assoc options :retries (inc retries))))
|
|
(rx/delay 250))
|
|
(rx/concat
|
|
(rx/of (dws/select-shapes ids)
|
|
dwz/zoom-to-selected-shape)
|
|
(when update-layout?
|
|
(rx/of (ptk/data-event :layout/update {:ids parent-ids})))))))
|
|
|
|
get-main-instance-id
|
|
(fn [id page-id]
|
|
(let [component (dm/get-in data [:components id])]
|
|
(when (= (:main-instance-page component) page-id)
|
|
(:main-instance-id component))))]
|
|
|
|
(when-let [component (dm/get-in data [:components id])]
|
|
(let [page-id (:main-instance-page component)
|
|
shape-id (:main-instance-id component)
|
|
additional-shape-ids (keep #(get-main-instance-id % page-id)
|
|
additional-ids)
|
|
ids (into (d/ordered-set shape-id) additional-shape-ids)]
|
|
(when (some? page-id)
|
|
(if (= page-id current-page-id)
|
|
(select-and-zoom ids)
|
|
(go-to-page-then stream page-id #(select-and-zoom ids))))))))))
|
|
|
|
(defn library-thumbnails-fetched
|
|
[thumbnails]
|
|
(ptk/reify ::library-thumbnails-fetched
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(update state :thumbnails merge
|
|
(d/update-vals thumbnails (fn [uri] {:uri uri :rendered-at nil}))))))
|
|
|
|
(defn fetch-library-thumbnails
|
|
[library-id]
|
|
(ptk/reify ::fetch-library-thumbnails
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
(->> (rp/cmd! :get-file-object-thumbnails {:file-id library-id :tag "component"})
|
|
(rx/map library-thumbnails-fetched)))))
|
|
|
|
(defn ext-library-changed
|
|
[library-id modified-at revn changes]
|
|
|
|
(assert (uuid? library-id)
|
|
"expected valid uuid for library-id")
|
|
|
|
(assert (ch/check-changes changes)
|
|
"expected valid changes vector")
|
|
|
|
(ptk/reify ::ext-library-changed
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(-> state
|
|
(update-in [:files library-id]
|
|
assoc :modified-at modified-at :revn revn)
|
|
(d/update-in-when [:files library-id :data]
|
|
ch/process-changes changes)))
|
|
|
|
ptk/WatchEvent
|
|
(watch [_ _ stream]
|
|
(let [stopper-s (rx/filter (ptk/type? ::ext-library-changed) stream)]
|
|
(->>
|
|
(rx/merge
|
|
(->> (rx/of library-id)
|
|
(rx/delay 5000)
|
|
(rx/map fetch-library-thumbnails))
|
|
(when (ch/tokens-lib-changed? changes)
|
|
(rx/of (dwtp/propagate-workspace-tokens)))
|
|
(when (ch/notifiable-token-change-occured? changes)
|
|
(rx/of (ntf/info (tr "workspace.tokens.notifications.source-sets-or-themes-updated")))))
|
|
|
|
(rx/take-until stopper-s))))))
|
|
|
|
(defn reset-component
|
|
"Cancels all modifications in the shape with the given id, and all its children, in
|
|
the current page. Set all attributes equal to the ones in the linked component,
|
|
and untouched."
|
|
[id]
|
|
(dm/assert! (uuid? id))
|
|
(ptk/reify ::reset-component
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(log/info :msg "RESET-COMPONENT of shape" :id (str id))
|
|
(let [libraries (:files state)
|
|
page-id (:current-page-id state)
|
|
|
|
file (dsh/lookup-file state)
|
|
data (:data file)
|
|
|
|
container (ctn/get-container data :page page-id)
|
|
undo-id (js/Symbol)
|
|
|
|
changes
|
|
(-> (pcb/empty-changes it)
|
|
(cll/generate-reset-component file libraries container id))]
|
|
|
|
(log/debug :msg "RESET-COMPONENT finished" :js/rchanges (log-changes
|
|
(:redo-changes changes)
|
|
file))
|
|
(rx/of (dwu/start-undo-transaction undo-id)
|
|
(dch/commit-changes changes)
|
|
(dwu/commit-undo-transaction undo-id))))))
|
|
|
|
(defn reset-components
|
|
"Cancels all modifications in the shapes with the given ids"
|
|
[ids]
|
|
(dm/assert! (seq ids))
|
|
(ptk/reify ::reset-components
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
(let [undo-id (js/Symbol)]
|
|
(rx/concat
|
|
(rx/of (dwu/start-undo-transaction undo-id))
|
|
(rx/map #(reset-component %) (rx/from ids))
|
|
(rx/of (dwu/commit-undo-transaction undo-id)))))))
|
|
|
|
(defn update-component
|
|
"Modify the component linked to the shape with the given id, in the
|
|
current page, so that all attributes of its shapes are equal to the
|
|
shape and its children. Also set all attributes of the shape
|
|
untouched.
|
|
|
|
NOTE: It's possible that the component to update is defined in an
|
|
external library file, so this function may cause to modify a file
|
|
different of that the one we are currently editing."
|
|
[id]
|
|
(dm/assert! (uuid? id))
|
|
(ptk/reify ::update-component
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(log/info :msg "UPDATE-COMPONENT of shape" :id (str id))
|
|
(let [page-id (:current-page-id state)
|
|
|
|
libraries (dsh/lookup-libraries state)
|
|
file (dsh/lookup-file state)
|
|
fdata (:data file)
|
|
|
|
container (ctn/get-container fdata :page page-id)
|
|
shape (ctn/get-shape container id)]
|
|
|
|
(when (ctk/instance-head? shape)
|
|
(let [[local-changes nonlocal-changes]
|
|
(-> (pcb/empty-changes it)
|
|
(pcb/with-container container)
|
|
(cll/generate-sync-shape-inverse libraries container id)
|
|
(cll/split-local-changes))]
|
|
|
|
(log/debug :msg "UPDATE-COMPONENT finished"
|
|
:js/local-changes (log-changes
|
|
(:redo-changes local-changes)
|
|
fdata)
|
|
:js/nonlocal-changes (log-changes
|
|
(:redo-changes nonlocal-changes)
|
|
fdata))
|
|
|
|
(rx/of
|
|
(when (seq (:redo-changes local-changes))
|
|
(dch/commit-changes (assoc local-changes
|
|
:file-id (:id file))))
|
|
(when (seq (:redo-changes nonlocal-changes))
|
|
(dch/commit-changes (assoc nonlocal-changes
|
|
:file-id (:component-file shape)))))))))))
|
|
|
|
(defn- update-component-thumbnail-sync
|
|
[state component-id file-id tag]
|
|
(let [data (dsh/lookup-file-data state file-id)
|
|
component (ctkl/get-component data component-id)
|
|
page-id (:main-instance-page component)
|
|
root-id (:main-instance-id component)]
|
|
(if (and (= tag "component")
|
|
(features/active-feature? state "render-wasm/v1"))
|
|
;; WASM: render immediately, UI only — server persist happens
|
|
;; on the debounced path (update-component-thumbnail)
|
|
(dwt.wasm/render-thumbnail file-id page-id root-id)
|
|
(dwt/update-thumbnail file-id page-id root-id tag "update-component-thumbnail-sync"))))
|
|
|
|
(defn- render-component-thumbnail-event
|
|
[[component-id file-id]]
|
|
(let [file-id (or file-id (:current-file-id @st/state))]
|
|
(update-component-thumbnail-sync
|
|
@st/state component-id file-id "component")))
|
|
|
|
(defn- component-sync-events
|
|
"The events that sync the copies of the component `component-id` of the
|
|
library `file-id`: in the current file, and in the library when it is
|
|
another file."
|
|
[state component-id file-id undo-group]
|
|
(let [current-file-id (:current-file-id state)]
|
|
[(sync-file current-file-id file-id :components component-id undo-group)
|
|
(when (not= current-file-id file-id)
|
|
(sync-file file-id file-id :components component-id undo-group))]))
|
|
|
|
(defn update-component-sync
|
|
"Update the main of the copy `shape-id` from the copy (see
|
|
`update-component`), in one undo entry with the sync of its copies."
|
|
[shape-id file-id]
|
|
(ptk/reify ::update-component-sync
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [page (dsh/lookup-page state)
|
|
shape (ctn/get-shape page shape-id)
|
|
component-id (:component-id shape)
|
|
undo-id (js/Symbol)]
|
|
(apply rx/of
|
|
(concat
|
|
[(dwu/start-undo-transaction undo-id)
|
|
(update-component shape-id)
|
|
|
|
;; These calls are necessary for properly sync thumbnails
|
|
;; when a main component does not live in the same page.
|
|
(update-component-thumbnail-sync state component-id file-id "frame")
|
|
(update-component-thumbnail-sync state component-id file-id "component")]
|
|
(component-sync-events state component-id file-id nil)
|
|
[(dwu/commit-undo-transaction undo-id)]))))))
|
|
|
|
(defn update-component-thumbnail
|
|
"Update the thumbnail of the component with the given id, in the
|
|
current file and in the imported libraries.
|
|
For WASM, re-renders and persists to the server in one step.
|
|
For SVG, update-thumbnail already handles both render + persist."
|
|
[component-id file-id]
|
|
(ptk/reify ::update-component-thumbnail
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(if (features/active-feature? state "render-wasm/v1")
|
|
(let [data (dsh/lookup-file-data state file-id)
|
|
component (ctkl/get-component data component-id)
|
|
page-id (:main-instance-page component)
|
|
root-id (:main-instance-id component)]
|
|
(rx/of (dwt.wasm/render-thumbnail file-id page-id root-id :persist? true)))
|
|
(rx/of (update-component-thumbnail-sync state component-id file-id "component"))))))
|
|
|
|
(defn- closest-font-variant
|
|
"The id of the variant of the family of the text node `node` closest to its
|
|
weight and style, when its variant is not one of the family's."
|
|
[node]
|
|
(when-let [font (fonts/get-font-data (:font-id node))]
|
|
(when-not (d/seek #(= (:id %) (:font-variant-id node)) (:variants font))
|
|
(:id (fonts/find-closest-variant font (:font-weight node) (:font-style node))))))
|
|
|
|
(defn fix-font-variants
|
|
"`changes` with each text content they set given variants of its own
|
|
families: a sync or a variant switch can take a copy text's family from
|
|
one side and its weight from the other (`mem:common/component-sync-contract`)."
|
|
[changes]
|
|
(let [fix-operation
|
|
(fn [{:keys [type attr val] :as operation}]
|
|
(if (and (= type :set) (= attr :content) (cttx/is-root-node? val))
|
|
(update operation :val cttx/update-font-variants closest-font-variant)
|
|
operation))]
|
|
(update changes :redo-changes
|
|
(fn [redo-changes]
|
|
(mapv (fn [change]
|
|
(cond-> change
|
|
(= :mod-obj (:type change))
|
|
(update :operations #(mapv fix-operation %))))
|
|
redo-changes)))))
|
|
|
|
(defn component-swap
|
|
"Swaps a component with another one"
|
|
[shape file-id id-new-component keep-touched?]
|
|
(dm/assert! (uuid? id-new-component))
|
|
(dm/assert! (uuid? file-id))
|
|
(ptk/reify ::component-swap
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
;; First delete shapes so we have space in the layout otherwise we can have problems
|
|
;; in the grid creating new rows/columns to make space
|
|
(let [libraries (dsh/lookup-libraries state)
|
|
page (dsh/lookup-page state)
|
|
ldata (dsh/lookup-file-data state file-id)
|
|
|
|
undo-id (js/Symbol)
|
|
undo-group (uuid/next)
|
|
|
|
[new-shape changes update-layout-ids]
|
|
(-> (pcb/empty-changes it (:id page))
|
|
(pcb/set-undo-group undo-group)
|
|
(clv/generate-component-swap-in-place page libraries ldata shape
|
|
id-new-component keep-touched?))
|
|
|
|
updated-objects (pcb/get-objects changes)
|
|
new-children-ids (cfh/get-children-ids-with-self updated-objects (:id new-shape))
|
|
new-text-ids (->> new-children-ids
|
|
(keep (fn [id]
|
|
(when-let [child (get updated-objects id)]
|
|
(when (and (cfh/text-shape? child)
|
|
(not= :fixed (:grow-type child)))
|
|
id))))
|
|
(vec))
|
|
|
|
update-layout-ids (concat update-layout-ids new-children-ids)]
|
|
(rx/of
|
|
(dwu/start-undo-transaction undo-id)
|
|
(dch/commit-changes (fix-font-variants changes))
|
|
(dwtp/propagate-workspace-tokens) ;; Make the new instance get the token values from the current file, not from the component's library
|
|
(when (and (features/active-feature? state "render-wasm/v1")
|
|
(seq new-text-ids))
|
|
(dwwt/resize-wasm-text-all new-text-ids {:skip-component-sync? true}))
|
|
(ptk/data-event :layout/update {:ids update-layout-ids :undo-group undo-group})
|
|
(dwu/commit-undo-transaction undo-id)
|
|
(dws/select-shape (:id new-shape) false))))))
|
|
|
|
(defn component-multi-swap
|
|
"Swaps several components with another one"
|
|
[shapes file-id id-new-component]
|
|
(dm/assert! (seq shapes))
|
|
(dm/assert! (uuid? id-new-component))
|
|
(dm/assert! (uuid? file-id))
|
|
(ptk/reify ::component-multi-swap
|
|
ev/Event
|
|
(-data [_]
|
|
{::ev/name "component-swap"})
|
|
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [undo-id (js/Symbol)]
|
|
(log/info :msg "COMPONENT-SWAP"
|
|
:file (debug-pretty-file file-id state)
|
|
:id-new-component id-new-component
|
|
:undo-id undo-id)
|
|
(rx/concat
|
|
(rx/of (dwu/start-undo-transaction undo-id))
|
|
(rx/map #(component-swap % file-id id-new-component false) (rx/from shapes))
|
|
(rx/of (dwu/commit-undo-transaction undo-id))
|
|
(rx/of (dwsp/open-specialized-panel :component-swap)))))))
|
|
|
|
(def valid-asset-types
|
|
#{:colors :components :typographies})
|
|
|
|
(defn- sync-file-pending-ids
|
|
[file-id changes]
|
|
;; Track the file and every changed page object.
|
|
(into #{file-id}
|
|
(comp
|
|
(filter :page-id)
|
|
(keep :id)
|
|
(remove uuid/zero?))
|
|
(:redo-changes changes)))
|
|
|
|
(defn set-updating-library
|
|
[updating?]
|
|
(ptk/reify ::set-updating-library
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(if updating?
|
|
(assoc state :updating-library true)
|
|
(dissoc state :updating-library)))))
|
|
|
|
(defn- synced-text-ids
|
|
"The texts of the current page that grow with their content and that
|
|
`changes` write: their size comes from their main, so the WASM renderer
|
|
measures them again (the SVG renderer does when it draws them)."
|
|
[state changes]
|
|
(when (features/active-feature? state "render-wasm/v1")
|
|
(let [page-id (:current-page-id state)
|
|
objects (dsh/lookup-page-objects state page-id)]
|
|
(into []
|
|
(comp (filter #(and (= :mod-obj (:type %)) (= page-id (:page-id %))))
|
|
(map :id)
|
|
(distinct)
|
|
(filter #(let [shape (get objects %)]
|
|
(and (cfh/text-shape? shape)
|
|
(not= :fixed (:grow-type shape))))))
|
|
(:redo-changes changes)))))
|
|
|
|
(defn- sync-file-frontend-events
|
|
[file-id changes updated-frames text-ids undo-group]
|
|
(rx/concat
|
|
(rx/of (set-updating-library false)
|
|
(ntf/hide {:tag :sync-dialog}))
|
|
(when (seq (:redo-changes changes))
|
|
(rx/of (dch/commit-changes (assoc changes :file-id file-id))))
|
|
(when (seq text-ids)
|
|
(rx/of (dwwt/resize-wasm-text-all text-ids {:skip-component-sync? true
|
|
:undo-group undo-group})))
|
|
(when-not (empty? updated-frames)
|
|
(let [frames-by-page (group-by :page-id updated-frames)]
|
|
(rx/merge
|
|
;; Emit one layout/update event for each page.
|
|
(->> frames-by-page
|
|
(map (fn [[page-id frames]]
|
|
(ptk/data-event :layout/update
|
|
{:page-id page-id
|
|
:ids (map :id frames)
|
|
:undo-group undo-group})))
|
|
(rx/from))
|
|
(->> (rx/from updated-frames)
|
|
(rx/mapcat
|
|
(fn [shape]
|
|
(rx/of
|
|
(dwt/clear-thumbnail file-id (:page-id shape) (:id shape) "frame")
|
|
(when-not (= (:frame-id shape) uuid/zero)
|
|
(dwt/clear-thumbnail file-id (:page-id shape) (:frame-id shape) "frame")))))))))))
|
|
|
|
(defn sync-file
|
|
"Synchronize the given file from the given library. Walk through all
|
|
shapes in all pages in the file that use some color, typography or
|
|
component of the library, and copy the new values to the shapes. Do
|
|
it also for shapes inside components of the local file library.
|
|
|
|
If it's known that only one asset has changed, you can give its
|
|
type and id, and only shapes that use it will be synced, thus avoiding
|
|
a lot of unneeded checks."
|
|
([file-id library-id]
|
|
(sync-file file-id library-id nil nil))
|
|
([file-id library-id asset-type asset-id]
|
|
(sync-file file-id library-id asset-type asset-id nil))
|
|
([file-id library-id asset-type asset-id undo-group]
|
|
(dm/assert! (uuid? file-id))
|
|
(dm/assert! (uuid? library-id))
|
|
(dm/assert! (or (nil? asset-type)
|
|
(contains? valid-asset-types asset-type)))
|
|
(dm/assert! (or (nil? asset-id)
|
|
(uuid? asset-id)))
|
|
(ptk/reify ::sync-file
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(if (and (not= library-id (:current-file-id state))
|
|
(nil? asset-id))
|
|
(d/update-in-when state [:files library-id] assoc :synced-at (ct/now))
|
|
state))
|
|
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(when (and (some? file-id) (some? library-id)) ; Prevent race conditions while navigating out of the file
|
|
(log/info :msg "SYNC-FILE"
|
|
:file (debug-pretty-file file-id state)
|
|
:library (debug-pretty-file library-id state)
|
|
:asset-type asset-type
|
|
:asset-id asset-id
|
|
:undo-group undo-group)
|
|
(let [ldata (dsh/lookup-file-data state file-id)
|
|
libraries (dsh/lookup-libraries state)
|
|
current-file-id (:current-file-id state)
|
|
|
|
;; the components of the file the sync changes are synced
|
|
;; too, until none is left, in one commit
|
|
{:keys [changes component-ids]}
|
|
(cll/generate-sync-file-cascade (pcb/empty-changes it)
|
|
undo-group
|
|
asset-type
|
|
file-id
|
|
asset-id
|
|
library-id
|
|
libraries
|
|
current-file-id)
|
|
|
|
changes (fix-font-variants changes)
|
|
|
|
find-frames (fn [change]
|
|
(->> (ch/frames-changed ldata change)
|
|
(map #(assoc %1 :page-id (:page-id change)))))
|
|
|
|
updated-frames (->> changes
|
|
:redo-changes
|
|
(mapcat find-frames)
|
|
distinct)
|
|
|
|
pending-ids (sync-file-pending-ids file-id changes)
|
|
|
|
text-ids (when (= file-id current-file-id)
|
|
(synced-text-ids state changes))
|
|
|
|
;; the texts measured again join the undo of the sync
|
|
undo-group (or undo-group
|
|
(when (seq text-ids) (uuid/next)))
|
|
changes (pcb/set-undo-group changes undo-group)
|
|
|
|
frontend-sync
|
|
(sync-file-frontend-events
|
|
file-id changes updated-frames text-ids undo-group)]
|
|
|
|
(log/debug :msg "SYNC-FILE finished" :js/rchanges (log-changes
|
|
(:redo-changes changes)
|
|
ldata))
|
|
(rx/concat
|
|
;; Keep the sync pending until its layout work starts.
|
|
(wrf/with-pending :sync-file pending-ids frontend-sync)
|
|
|
|
;; the copies in the current file of the library components the
|
|
;; sync changed follow them
|
|
(when (not= file-id current-file-id)
|
|
(->> component-ids
|
|
(map #(sync-file current-file-id file-id :components % undo-group))
|
|
(rx/from)))
|
|
|
|
(when (not= file-id library-id)
|
|
;; When we have just updated the library file, give some time for the
|
|
;; update to finish, before marking this file as synced.
|
|
;; TODO: look for a more precise way of syncing this.
|
|
;; Maybe by using the stream (second argument passed to watch)
|
|
;; to wait for the corresponding changes-committed and then proceed
|
|
;; with the :update-file-library-sync-status mutation.
|
|
(rx/concat (rx/timer 3000)
|
|
(rp/cmd! :update-file-library-sync-status
|
|
{:file-id file-id
|
|
:library-id library-id}))))))))))
|
|
|
|
|
|
;; FIXME: the data should be set on the backend for clock consistency
|
|
|
|
|
|
(def ignore-sync
|
|
"Mark the file as ignore syncs. All library changes before this moment will not
|
|
ber notified to sync."
|
|
(ptk/reify ::ignore-sync
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(let [file-id (:current-file-id state)]
|
|
(assoc-in state [:files file-id :ignore-sync-until] (ct/now))))
|
|
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [file-id (:current-file-id state)]
|
|
(->> (rp/cmd! :ignore-file-library-sync-status
|
|
{:file-id file-id
|
|
:date (ct/now)})
|
|
(rx/ignore))))))
|
|
|
|
(defn assets-need-sync
|
|
"Get a lazy sequence of all the assets of each type in the library that have
|
|
been modified after the last sync of the library. The sync date may be
|
|
overriden by providing a ignore-until parameter."
|
|
([library file-data]
|
|
(assets-need-sync library file-data nil))
|
|
([library file-data ignore-until]
|
|
(when (not= (:id library) (:id file-data))
|
|
(let [sync-date (max (:synced-at library) (or ignore-until 0))]
|
|
(when (> (:modified-at library) sync-date)
|
|
(ctf/used-assets-changed-since file-data library sync-date))))))
|
|
|
|
(defn update-libraries
|
|
"Sync the file `file-id` from each library of `library-ids`, then
|
|
propagate the file's tokens, so that its copies take the token values
|
|
of the file's token status, not the library's."
|
|
[file-id library-ids]
|
|
(ptk/reify ::update-libraries
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
(rx/concat
|
|
(rx/from (map #(sync-file file-id %) library-ids))
|
|
(rx/of (dwtp/propagate-workspace-tokens))))))
|
|
|
|
(defn notify-sync-file
|
|
"Notify the user that there are updates in the libraries used by the
|
|
current file, and ask if he wants to update them now."
|
|
[]
|
|
(ptk/reify ::notify-sync-file
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [file-id (:current-file-id state)
|
|
file (dsh/lookup-file state file-id)
|
|
file-data (get file :data)
|
|
ignore-until (get file :ignore-sync-until)
|
|
permissions (:permissions state)
|
|
|
|
libraries-need-sync
|
|
(->> (vals (get state :files))
|
|
(filter #(= (:library-of %) file-id))
|
|
(filter #(seq (assets-need-sync % file-data ignore-until))))]
|
|
|
|
(if-not (and (:can-edit permissions)
|
|
(seq libraries-need-sync))
|
|
;; Fast path: no libraries need sync based on timestamps
|
|
(rx/empty)
|
|
|
|
;; Defer the expensive change generation check to avoid blocking the UI.
|
|
;; For files with many libraries, this prevents stuttering/freezing.
|
|
(->> (rx/timer 0)
|
|
(rx/map (fn [_]
|
|
;; This runs asynchronously on the next tick.
|
|
;; Filter libraries to only those that would produce actual sync changes.
|
|
(let [libraries (dsh/lookup-libraries state)]
|
|
(filterv (fn [library]
|
|
(seq (:redo-changes
|
|
(cll/generate-sync-file-changes
|
|
(pcb/empty-changes)
|
|
nil
|
|
nil
|
|
file-id
|
|
nil
|
|
(:id library)
|
|
libraries
|
|
file-id
|
|
true))))
|
|
libraries-need-sync))))
|
|
(rx/filter seq)
|
|
(rx/map (fn [libraries-with-changes]
|
|
(let [do-more-info
|
|
#(modal/show! :libraries-dialog {:starting-tab "updates" :file-id file-id})
|
|
|
|
do-update
|
|
#(st/emit! (update-libraries file-id (map :id libraries-with-changes))
|
|
(ntf/hide))
|
|
|
|
do-dismiss
|
|
#(st/emit! ignore-sync (ntf/hide))]
|
|
|
|
(ntf/dialog
|
|
:content (tr "workspace.updates.there-are-updates")
|
|
:controls :inline-actions
|
|
:links [{:label (tr "workspace.updates.more-info")
|
|
:callback do-more-info}]
|
|
:cancel {:label (tr "workspace.updates.dismiss")
|
|
:callback do-dismiss}
|
|
:accept {:label (tr "workspace.updates.update")
|
|
:callback do-update}
|
|
:tag :sync-dialog))))))))))
|
|
|
|
(defn touch-component
|
|
"Update the modified-at attribute of the component to now"
|
|
[id]
|
|
(dm/assert!
|
|
"expected valid uuid for `id`"
|
|
(uuid? id))
|
|
(ptk/reify ::touch-component
|
|
cljs.core/IDeref
|
|
(-deref [_] [id])
|
|
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(let [data (dsh/lookup-file-data state)
|
|
changes (-> (pcb/empty-changes it)
|
|
(pcb/with-library-data data)
|
|
(pcb/update-component id #(assoc % :modified-at (ct/now))))]
|
|
(rx/of (dch/commit-changes {:origin it
|
|
:redo-changes (:redo-changes changes)
|
|
:undo-changes []
|
|
:save-undo? false}))))))
|
|
|
|
(defn component-changed
|
|
"Notify that the component with the given id has changed: mark it modified
|
|
and sync its copies, in the current file and in its library, in one undo
|
|
entry. The watcher also updates its thumbnails."
|
|
[component-id file-id undo-group]
|
|
(ptk/reify ::component-changed
|
|
cljs.core/IDeref
|
|
(-deref [_] [component-id file-id])
|
|
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [undo-id (js/Symbol)]
|
|
(apply rx/of
|
|
(concat
|
|
[(touch-component component-id)
|
|
(dwu/start-undo-transaction undo-id)]
|
|
(component-sync-events state component-id file-id undo-group)
|
|
[(dwu/commit-undo-transaction undo-id)]))))))
|
|
|
|
(defn- moves-whole-mains?
|
|
"Whether the `changes` of a translation commit, applied on the file
|
|
`data`, move only shapes outside mains, or whole mains: every shape they
|
|
move inside a main moves with the main's root."
|
|
[data changes]
|
|
(every? (fn [[page-id changes]]
|
|
(let [objects (dm/get-in data [:pages-index page-id :objects])
|
|
ids (into #{} (map :id) changes)]
|
|
(every? (fn [id]
|
|
(let [root (ctn/get-component-shape objects (get objects id) {:allow-main? true})]
|
|
(or (not (ctk/main-instance? root))
|
|
(contains? ids (:id root)))))
|
|
ids)))
|
|
(group-by :page-id (filter #(= :mod-obj (:type %)) changes))))
|
|
|
|
(defn watch-component-changes
|
|
"Watch the state for changes that affect to any main instance. If a change is detected will call
|
|
component-changed, so changes are immediately propagated to the component and copies."
|
|
[]
|
|
(ptk/reify ::watch-component-changes
|
|
ptk/WatchEvent
|
|
(watch [_ state stream]
|
|
(let [stopper-s
|
|
(->> stream
|
|
(rx/map ptk/type)
|
|
(rx/filter (fn [event-type]
|
|
(or (= ::dwpg/finalize-page event-type)
|
|
(= ::watch-component-changes event-type)))))
|
|
;; Barriers cover detection and the derived work of each commit.
|
|
;; Every local commit opens one, so a wait covers the watcher's
|
|
;; decision for any commit: sync, only touch, or drop.
|
|
pending-sync-barriers* (atom #{})
|
|
|
|
start-sync-barrier
|
|
(fn [{:keys [commit old-data]}]
|
|
(let [file-id (:file-id commit)
|
|
task (when (uuid? file-id)
|
|
(wrf/start! :sync-file [file-id]))]
|
|
(when task
|
|
(swap! pending-sync-barriers* conj task))
|
|
[commit task old-data]))
|
|
|
|
finish-sync-barrier!
|
|
(fn [task]
|
|
(when task
|
|
(wrf/finish! task)
|
|
(swap! pending-sync-barriers* disj task)))
|
|
|
|
local-commits-s
|
|
(->> stream
|
|
(rx/filter dch/changes-recorded?)
|
|
(rx/map deref)
|
|
(rx/filter #(= :local (get-in % [:commit :source])))
|
|
;; Keep waits pending while component changes are checked.
|
|
(rx/map start-sync-barrier)
|
|
(rx/share))
|
|
|
|
;; A translation commit that moves whole mains changes no
|
|
;; component; one that moves a shape inside a main does. Derived /
|
|
;; corrective commits (font-load selrect fix, position-data regen)
|
|
;; are not user component edits.
|
|
dropped-commit?
|
|
(fn [[{:keys [translation? skip-component-sync? changes]} _ old-data]]
|
|
(or skip-component-sync?
|
|
(and translation?
|
|
(moves-whole-mains? old-data changes))))
|
|
|
|
dropped-commits-s
|
|
(->> local-commits-s
|
|
(rx/filter dropped-commit?)
|
|
(rx/tap (comp finish-sync-barrier! second))
|
|
(rx/ignore))
|
|
|
|
commits-s
|
|
(->> local-commits-s
|
|
(rx/filter (complement dropped-commit?))
|
|
(rx/share))
|
|
|
|
get-component-changes
|
|
(fn [old-data {:keys [file-id changes save-undo? undo-group]}]
|
|
(let [changed-components
|
|
(when (and old-data
|
|
(= file-id (:current-file-id state) (:id old-data)))
|
|
(into #{}
|
|
(mapcat (partial ch/components-changed old-data))
|
|
changes))]
|
|
(cond
|
|
(empty? changed-components)
|
|
nil
|
|
|
|
save-undo?
|
|
(do
|
|
(log/info :hint "detected component changes"
|
|
:ids (map str changed-components)
|
|
:undo-group undo-group)
|
|
(map #(vector ::component-changed % (:id old-data) undo-group)
|
|
changed-components))
|
|
|
|
:else
|
|
;; Undos only bump :modified-at.
|
|
(map #(vector ::touch-component %) changed-components))))
|
|
|
|
;; Sync each transition before a later user commit can replace its
|
|
;; state or separate its sync from its undo group.
|
|
get-component-events
|
|
(fn [event old-data]
|
|
(->> (get-component-changes old-data event)
|
|
(map (fn [[type component-id file-id undo-group]]
|
|
(if (= type ::component-changed)
|
|
(component-changed component-id file-id undo-group)
|
|
(touch-component component-id))))
|
|
(rx/from)))
|
|
|
|
component-events-s
|
|
(->> commits-s
|
|
;; Barrier completion is async; it must not queue later
|
|
;; commits behind the previous transition.
|
|
(rx/merge-map
|
|
(fn [[event task old-data]]
|
|
(->> (rx/concat
|
|
(get-component-events event old-data)
|
|
;; Keep the completion barrier through the current
|
|
;; event turn, including derived layout work.
|
|
(->> (rx/of nil) (rx/observe-on :async) (rx/ignore)))
|
|
(rx/finalize #(finish-sync-barrier! task)))))
|
|
;; Close barriers left behind when the page shuts down.
|
|
(rx/finalize #(wrf/finish-tasks! @pending-sync-barriers*))
|
|
(rx/share))
|
|
|
|
notifier-s
|
|
(->> component-events-s
|
|
(rx/debounce 5000)
|
|
(rx/tap #(log/trc :hint "buffer initialized")))]
|
|
|
|
(->> (rx/merge
|
|
dropped-commits-s
|
|
component-events-s
|
|
|
|
;; the flag (or WASM) turns on the thumbnails of components,
|
|
;; not their sync
|
|
(if (or (contains? cf/flags :component-thumbnails)
|
|
(features/active-feature? state "render-wasm/v1"))
|
|
(rx/merge
|
|
|
|
;; WASM only: render the thumbnail on every component
|
|
;; change so single edits (fill, etc.) update instantly.
|
|
;; Non-WASM persists on every render, so it stays on the
|
|
;; debounced path below to avoid per-edit backend posts.
|
|
(if (features/active-feature? state "render-wasm/v1")
|
|
(->> component-events-s
|
|
(rx/filter (ptk/type? ::component-changed))
|
|
(rx/map deref)
|
|
(rx/map render-component-thumbnail-event))
|
|
(rx/empty))
|
|
|
|
;; Persist to the server in batches, 5s after the user
|
|
;; goes idle.
|
|
(->> component-events-s
|
|
(rx/filter (ptk/type? ::component-changed))
|
|
(rx/map deref)
|
|
(rx/buffer-until notifier-s)
|
|
(rx/mapcat #(into #{} %))
|
|
(rx/map (fn [[component-id file-id]]
|
|
(update-component-thumbnail component-id file-id))))
|
|
|
|
;; Undo/redo emit touch-component instead.
|
|
(->> component-events-s
|
|
(rx/filter (ptk/type? ::touch-component))
|
|
(rx/map deref)
|
|
(rx/map render-component-thumbnail-event)))
|
|
(rx/empty)))
|
|
|
|
(rx/take-until stopper-s))))))
|
|
|
|
(defn sync-tokens-status-with-lib
|
|
[]
|
|
(ptk/reify ::sync-tokens-status-with-lib
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [tokens-lib (dsh/lookup-tokens-lib state)
|
|
tokens-status (dsh/lookup-tokens-status state)]
|
|
(when (and tokens-lib tokens-status)
|
|
(let [data (dsh/lookup-file-data state)
|
|
changes (-> (pcb/empty-changes)
|
|
(pcb/with-library-data data)
|
|
(clo/generate-sync-tokens-status-with-lib tokens-status tokens-lib))]
|
|
(rx/of (dch/commit-changes changes))))))))
|
|
|
|
(defn watch-token-changes
|
|
"Watch the state for changes that affect the tokens library. If a change is detected,
|
|
launches a sync-tokens-status event so the tokens-status is kept in sync with the library."
|
|
[]
|
|
(ptk/reify ::watch-token-changes
|
|
ptk/WatchEvent
|
|
(watch [_ _ stream]
|
|
(let [stopper-s
|
|
(->> stream
|
|
(rx/map ptk/type)
|
|
(rx/filter (fn [event-type]
|
|
(or (= ::dwpg/finalize-page event-type)
|
|
(= ::watch-token-changes event-type)))))
|
|
|
|
changes-s
|
|
(->> stream
|
|
(rx/filter dch/commit?)
|
|
(rx/map deref)
|
|
(rx/filter #(= :local (:source %)))
|
|
(rx/observe-on :async))]
|
|
|
|
(->> changes-s
|
|
(rx/filter (comp ch/tokens-lib-changed? :changes))
|
|
(rx/debounce 5000)
|
|
(rx/map (fn [_] (sync-tokens-status-with-lib)))
|
|
(rx/take-until stopper-s))))))
|
|
|
|
(defn watch-library-changes
|
|
"Starts the watchers that propagate edits of the file's library while a page
|
|
is open: component changes to the copies, token changes to the tokens
|
|
status. They stop when the page is finalized."
|
|
[]
|
|
(ptk/reify ::watch-library-changes
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
(rx/of (watch-component-changes)
|
|
(watch-token-changes)))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; Backend interactions
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defn set-file-shared
|
|
[id is-shared]
|
|
{:pre [(uuid? id) (boolean? is-shared)]}
|
|
(ptk/reify ::set-file-shared
|
|
ev/Event
|
|
(-data [_]
|
|
{::ev/origin "workspace"
|
|
:id id
|
|
:shared is-shared})
|
|
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(update-in state [:files id] assoc :is-shared is-shared))
|
|
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [params {:id id :is-shared is-shared}]
|
|
(rx/concat
|
|
(->> (rp/cmd! :set-file-shared params)
|
|
(rx/ignore))
|
|
(when is-shared
|
|
(let [has-variants? (->> (dsh/lookup-file-data state)
|
|
:components
|
|
vals
|
|
(some ctk/is-variant?))]
|
|
(if has-variants?
|
|
(rx/of (ev/event {::ev/name "set-file-variants-shared" ::ev/origin "workspace"}))
|
|
(rx/empty)))))))))
|
|
|
|
;; --- Link and unlink Files
|
|
|
|
(defn libraries-fetched
|
|
[file-id libraries]
|
|
(ptk/reify ::libraries-fetched
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(update state :files merge
|
|
(->> libraries
|
|
(map #(assoc % :library-of file-id))
|
|
(d/index-by :id))))))
|
|
|
|
|
|
(defn- load-library-file
|
|
[file-id library-id]
|
|
(ptk/reify ::load-library-file
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [features (get state :features)]
|
|
(rx/merge
|
|
(->> (rp/cmd! :get-file {:id library-id :features features})
|
|
(rx/merge-map fpmap/resolve-file)
|
|
(rx/mapcat (fn [file]
|
|
(rx/of
|
|
(libraries-fetched file-id [file])))))
|
|
(->> (rp/cmd! :get-file-object-thumbnails {:file-id library-id :tag "component"})
|
|
(rx/map (fn [thumbnails]
|
|
(fn [state]
|
|
(update state :thumbnails merge
|
|
(d/update-vals thumbnails (fn [uri] {:uri uri :rendered-at nil}))))))))))))
|
|
|
|
(defn link-file-to-library
|
|
[file-id library-id]
|
|
(ptk/reify ::link-file-to-library
|
|
ev/Event
|
|
(-data [_]
|
|
{::ev/name "link-file-to-library"
|
|
:file-id file-id
|
|
:library-id library-id})
|
|
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [libraries (:shared-files state)
|
|
library (get libraries library-id)
|
|
variants-count (-> library :library-summary :variants count)
|
|
|
|
loaded-libraries (->> (dsh/lookup-libraries state)
|
|
(remove (fn [[_ lib]]
|
|
(or (nil? (:data lib))
|
|
(empty? (:data lib)))))
|
|
(map first)
|
|
set)]
|
|
(rx/concat
|
|
(->> (rp/cmd! :link-file-to-library {:file-id file-id :library-id library-id})
|
|
(rx/merge-map (fn [libraries-to-load]
|
|
(as-> libraries-to-load $
|
|
(remove loaded-libraries $)
|
|
(conj $ library-id)
|
|
(map #(load-library-file file-id %) $))))
|
|
(rx/catch (fn [cause]
|
|
(let [error (ex-data cause)]
|
|
(if (= (:code error) :circular-library-reference)
|
|
(rx/of (ntf/error (tr "errors.circular-library-reference")))
|
|
(rx/throw cause))))))
|
|
(rx/of (ptk/reify ::link-file-to-library-finished))
|
|
(when (pos? variants-count)
|
|
(->> (rp/cmd! :get-library-usage {:file-id library-id})
|
|
(rx/map (fn [library-usage]
|
|
(ev/event {::ev/name "link-file-to-library-variants"
|
|
:file-id file-id
|
|
:library-id library-id
|
|
:variants-count variants-count
|
|
:library-used-in (:used-in library-usage)}))))))))))
|
|
|
|
(defn cleanup-unlinked-libraries
|
|
"Remove libraries from state that are no longer linked to the given file.
|
|
This is used after unlinking a library to clean up transitive dependencies."
|
|
[file-id libraries]
|
|
(ptk/reify ::cleanup-unlinked-libraries
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(let [linked-ids (into #{} (map :id) libraries)]
|
|
(update state :files
|
|
(fn [files]
|
|
(reduce-kv
|
|
(fn [acc id file]
|
|
(if (and (= (:library-of file) file-id)
|
|
(not (contains? linked-ids id))
|
|
(not= id file-id))
|
|
(dissoc acc id)
|
|
acc))
|
|
files
|
|
files)))))))
|
|
|
|
|
|
(defn unlink-file-from-library
|
|
[file-id library-id]
|
|
(ptk/reify ::detach-library
|
|
ev/Event
|
|
(-data [_]
|
|
{::ev/name "detach-library"
|
|
:file-id file-id
|
|
:library-id library-id})
|
|
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(update state :files dissoc library-id))
|
|
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
;; Unlink the library, then fetch the current list of linked libraries
|
|
;; and remove any that are no longer linked (e.g., transitive dependencies)
|
|
(->> (rp/cmd! :unlink-file-from-library {:file-id file-id :library-id library-id})
|
|
(rx/mapcat (fn [_]
|
|
(rp/cmd! :get-file-libraries {:file-id file-id})))
|
|
(rx/map (partial cleanup-unlinked-libraries file-id))))))
|