mirror of
https://github.com/penpot/penpot.git
synced 2025-02-10 00:58:26 -05:00
Merge pull request #2201 from penpot/hiru-undelete-components
🎉 Allow to restore deleted components
This commit is contained in:
commit
11018581ed
26 changed files with 469 additions and 133 deletions
|
@ -750,6 +750,10 @@
|
|||
(uuid? (:typography-ref-file form))
|
||||
(update :typography-ref-file lookup-index)
|
||||
|
||||
;; This covers the component instance links
|
||||
(uuid? (:component-file form))
|
||||
(update :component-file lookup-index)
|
||||
|
||||
;; This covers the shadows and grids (they have directly
|
||||
;; the :file-id prop)
|
||||
(uuid? (:file-id form))
|
||||
|
|
|
@ -9,12 +9,16 @@
|
|||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.geom.matrix :as gmt]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.geom.shapes :as gsh]
|
||||
[app.common.pages.changes :as ch]
|
||||
[app.common.pages.changes-spec :as pcs]
|
||||
[app.common.spec :as us]
|
||||
[app.common.types.components-list :as ctkl]
|
||||
[app.common.types.container :as ctn]
|
||||
[app.common.types.file :as ctf]
|
||||
[app.common.types.page :as ctp]
|
||||
[app.common.types.pages-list :as ctpl]
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.uuid :as uuid]
|
||||
[cuerdas.core :as str]))
|
||||
|
@ -516,10 +520,17 @@
|
|||
[file data]
|
||||
|
||||
(let [selrect cts/empty-selrect
|
||||
name (:name data)
|
||||
path (:path data)
|
||||
name (:name data)
|
||||
path (:path data)
|
||||
main-instance-id (:main-instance-id data)
|
||||
main-instance-page (:main-instance-page data)
|
||||
obj (-> (cts/make-minimal-group nil selrect name)
|
||||
(merge data)
|
||||
(dissoc :path
|
||||
:main-instance-id
|
||||
:main-instance-page
|
||||
:main-instance-x
|
||||
:main-instance-y)
|
||||
(check-name file :group)
|
||||
(d/without-nils))]
|
||||
(-> file
|
||||
|
@ -528,6 +539,8 @@
|
|||
:id (:id obj)
|
||||
:name name
|
||||
:path path
|
||||
:main-instance-id main-instance-id
|
||||
:main-instance-page main-instance-page
|
||||
:shapes [obj]})
|
||||
|
||||
(assoc :last-id (:id obj))
|
||||
|
@ -546,7 +559,8 @@
|
|||
(commit-change
|
||||
file
|
||||
{:type :del-component
|
||||
:id component-id})
|
||||
:id component-id
|
||||
:skip-undelete? true})
|
||||
|
||||
(:masked-group? component)
|
||||
(let [mask (first children)]
|
||||
|
@ -586,6 +600,42 @@
|
|||
(dissoc :current-component-id)
|
||||
(update :parent-stack pop))))
|
||||
|
||||
(defn finish-deleted-component
|
||||
[component-id page-id main-instance-x main-instance-y file]
|
||||
(let [file (assoc file :current-component-id component-id)
|
||||
page (ctpl/get-page (:data file) page-id)
|
||||
component (ctkl/get-component (:data file) component-id)
|
||||
main-instance-id (:main-instance-id component)
|
||||
|
||||
; To obtain a deleted component, we first create the component
|
||||
; and the main instance in the workspace, and then delete them.
|
||||
[_ shapes]
|
||||
(ctn/make-component-instance page
|
||||
component
|
||||
(:id file)
|
||||
(gpt/point main-instance-x
|
||||
main-instance-y)
|
||||
{:main-instance? true
|
||||
:force-id main-instance-id})]
|
||||
(as-> file $
|
||||
(reduce #(commit-change %1
|
||||
{:type :add-obj
|
||||
:id (:id %2)
|
||||
:page-id (:id page)
|
||||
:parent-id (:parent-id %2)
|
||||
:frame-id (:frame-id %2)
|
||||
:obj %2})
|
||||
$
|
||||
shapes)
|
||||
(commit-change $ {:type :del-component
|
||||
:id component-id})
|
||||
(reduce #(commit-change %1 {:type :del-obj
|
||||
:page-id page-id
|
||||
:id (:id %2)})
|
||||
$
|
||||
shapes)
|
||||
(dissoc $ :current-component-id))))
|
||||
|
||||
(defn delete-object
|
||||
[file id]
|
||||
(let [page-id (:current-page-id file)]
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
[app.common.types.components-list :as ctkl]
|
||||
[app.common.types.container :as ctn]
|
||||
[app.common.types.colors-list :as ctcl]
|
||||
[app.common.types.file :as ctf]
|
||||
[app.common.types.page :as ctp]
|
||||
[app.common.types.pages-list :as ctpl]
|
||||
[app.common.types.shape :as cts]
|
||||
|
@ -302,9 +303,7 @@
|
|||
|
||||
(defmethod process-change :del-page
|
||||
[data {:keys [id]}]
|
||||
(-> data
|
||||
(update :pages (fn [pages] (filterv #(not= % id) pages)))
|
||||
(update :pages-index dissoc id)))
|
||||
(ctpl/delete-page data id))
|
||||
|
||||
(defmethod process-change :mov-page
|
||||
[data {:keys [id index]}]
|
||||
|
@ -320,7 +319,7 @@
|
|||
|
||||
(defmethod process-change :del-color
|
||||
[data {:keys [id]}]
|
||||
(update data :colors dissoc id))
|
||||
(ctcl/delete-color data id))
|
||||
|
||||
(defmethod process-change :add-recent-color
|
||||
[data {:keys [color]}]
|
||||
|
@ -371,8 +370,16 @@
|
|||
(assoc :objects objects))))
|
||||
|
||||
(defmethod process-change :del-component
|
||||
[data {:keys [id skip-undelete?]}]
|
||||
(ctf/delete-component data id skip-undelete?))
|
||||
|
||||
(defmethod process-change :restore-component
|
||||
[data {:keys [id]}]
|
||||
(d/dissoc-in data [:components id]))
|
||||
(ctf/restore-component data id))
|
||||
|
||||
(defmethod process-change :purge-component
|
||||
[data {:keys [id]}]
|
||||
(ctf/purge-component data id))
|
||||
|
||||
;; -- Typography
|
||||
|
||||
|
@ -386,7 +393,7 @@
|
|||
|
||||
(defmethod process-change :del-typography
|
||||
[data {:keys [id]}]
|
||||
(update data :typographies dissoc id))
|
||||
(ctyl/delete-typography data id))
|
||||
|
||||
;; === Operations
|
||||
|
||||
|
|
|
@ -617,18 +617,34 @@
|
|||
changes)))
|
||||
|
||||
(defn delete-component
|
||||
[changes id]
|
||||
[changes id components-v2]
|
||||
(assert-library changes)
|
||||
(let [library-data (::library-data (meta changes))
|
||||
prev-component (get-in library-data [:components id])]
|
||||
(-> changes
|
||||
(update :redo-changes conj {:type :del-component
|
||||
:id id})
|
||||
(update :undo-changes d/preconj {:type :add-component
|
||||
:id id
|
||||
:name (:name prev-component)
|
||||
:path (:path prev-component)
|
||||
:main-instance-id (:main-instance-id prev-component)
|
||||
:main-instance-page (:main-instance-page prev-component)
|
||||
:shapes (vals (:objects prev-component))}))))
|
||||
(update :undo-changes
|
||||
(fn [undo-changes]
|
||||
(cond-> undo-changes
|
||||
components-v2
|
||||
(d/preconj {:type :purge-component
|
||||
:id id})
|
||||
|
||||
:always
|
||||
(d/preconj {:type :add-component
|
||||
:id id
|
||||
:name (:name prev-component)
|
||||
:path (:path prev-component)
|
||||
:main-instance-id (:main-instance-id prev-component)
|
||||
:main-instance-page (:main-instance-page prev-component)
|
||||
:shapes (vals (:objects prev-component))})))))))
|
||||
|
||||
(defn restore-component
|
||||
[changes id]
|
||||
(assert-library changes)
|
||||
(-> changes
|
||||
(update :redo-changes conj {:type :restore-component
|
||||
:id id})
|
||||
(update :undo-changes d/preconj {:type :del-component
|
||||
:id id})))
|
||||
|
|
|
@ -159,7 +159,16 @@
|
|||
(s/keys :req-un [::id]
|
||||
:opt-un [::name :internal.changes.add-component/shapes]))
|
||||
|
||||
(s/def :internal.changes.del-component/skip-undelete? boolean?)
|
||||
|
||||
(defmethod change-spec :del-component [_]
|
||||
(s/keys :req-un [::id]
|
||||
:opt-un [:internal.changes.del-component/skip-undelete?]))
|
||||
|
||||
(defmethod change-spec :restore-component [_]
|
||||
(s/keys :req-un [::id]))
|
||||
|
||||
(defmethod change-spec :purge-component [_]
|
||||
(s/keys :req-un [::id]))
|
||||
|
||||
(defmethod change-spec :add-typography [_]
|
||||
|
|
|
@ -22,3 +22,7 @@
|
|||
[file-data color-id f]
|
||||
(update-in file-data [:colors color-id] f))
|
||||
|
||||
(defn delete-color
|
||||
[file-data color-id]
|
||||
(update file-data :colors dissoc color-id))
|
||||
|
||||
|
|
|
@ -35,3 +35,7 @@
|
|||
[file-data component-id f]
|
||||
(update-in file-data [:components component-id] f))
|
||||
|
||||
(defn delete-component
|
||||
[file-data component-id]
|
||||
(update file-data :components dissoc component-id))
|
||||
|
||||
|
|
|
@ -108,53 +108,59 @@
|
|||
"Clone the shapes of the component, generating new names and ids, and linking
|
||||
each new shape to the corresponding one of the component. Place the new instance
|
||||
coordinates in the given position."
|
||||
[container component component-file-id position main-instance?]
|
||||
(let [component-shape (get-shape component (:id component))
|
||||
([container component component-file-id position]
|
||||
(make-component-instance container component component-file-id position {}))
|
||||
|
||||
orig-pos (gpt/point (:x component-shape) (:y component-shape))
|
||||
delta (gpt/subtract position orig-pos)
|
||||
([container component component-file-id position
|
||||
{:keys [main-instance? force-id] :or {main-instance? false force-id nil}}]
|
||||
(let [component-shape (get-shape component (:id component))
|
||||
|
||||
objects (:objects container)
|
||||
unames (volatile! (ctst/retrieve-used-names objects))
|
||||
orig-pos (gpt/point (:x component-shape) (:y component-shape))
|
||||
delta (gpt/subtract position orig-pos)
|
||||
|
||||
frame-id (ctst/frame-id-by-position objects (gpt/add orig-pos delta))
|
||||
objects (:objects container)
|
||||
unames (volatile! (ctst/retrieve-used-names objects))
|
||||
|
||||
update-new-shape
|
||||
(fn [new-shape original-shape]
|
||||
(let [new-name (ctst/generate-unique-name @unames (:name new-shape))]
|
||||
frame-id (ctst/frame-id-by-position objects (gpt/add orig-pos delta))
|
||||
|
||||
(when (nil? (:parent-id original-shape))
|
||||
(vswap! unames conj new-name))
|
||||
update-new-shape
|
||||
(fn [new-shape original-shape]
|
||||
(let [new-name (ctst/generate-unique-name @unames (:name new-shape))]
|
||||
|
||||
(cond-> new-shape
|
||||
true
|
||||
(as-> $
|
||||
(gsh/move $ delta)
|
||||
(assoc $ :frame-id frame-id)
|
||||
(assoc $ :parent-id
|
||||
(or (:parent-id $) (:frame-id $)))
|
||||
(dissoc $ :touched))
|
||||
(when (nil? (:parent-id original-shape))
|
||||
(vswap! unames conj new-name))
|
||||
|
||||
(nil? (:shape-ref original-shape))
|
||||
(assoc :shape-ref (:id original-shape))
|
||||
(cond-> new-shape
|
||||
true
|
||||
(as-> $
|
||||
(gsh/move $ delta)
|
||||
(assoc $ :frame-id frame-id)
|
||||
(assoc $ :parent-id
|
||||
(or (:parent-id $) (:frame-id $)))
|
||||
(dissoc $ :touched))
|
||||
|
||||
(nil? (:parent-id original-shape))
|
||||
(assoc :component-id (:id original-shape)
|
||||
:component-file component-file-id
|
||||
:component-root? true
|
||||
:name new-name)
|
||||
(nil? (:shape-ref original-shape))
|
||||
(assoc :shape-ref (:id original-shape))
|
||||
|
||||
(and (nil? (:parent-id original-shape)) main-instance?)
|
||||
(assoc :main-instance? true)
|
||||
(nil? (:parent-id original-shape))
|
||||
(assoc :component-id (:id original-shape)
|
||||
:component-file component-file-id
|
||||
:component-root? true
|
||||
:name new-name)
|
||||
|
||||
(some? (:parent-id original-shape))
|
||||
(dissoc :component-root?))))
|
||||
(and (nil? (:parent-id original-shape)) main-instance?)
|
||||
(assoc :main-instance? true)
|
||||
|
||||
[new-shape new-shapes _]
|
||||
(ctst/clone-object component-shape
|
||||
nil
|
||||
(get component :objects)
|
||||
update-new-shape)]
|
||||
(some? (:parent-id original-shape))
|
||||
(dissoc :component-root?))))
|
||||
|
||||
[new-shape new-shapes _]
|
||||
(ctst/clone-object component-shape
|
||||
nil
|
||||
(get component :objects)
|
||||
update-new-shape
|
||||
(fn [object _] object)
|
||||
force-id)]
|
||||
|
||||
[new-shape new-shapes])))
|
||||
|
||||
[new-shape new-shapes]))
|
||||
|
||||
|
|
|
@ -120,6 +120,57 @@
|
|||
|
||||
;; Asset helpers
|
||||
|
||||
(defn delete-component
|
||||
"Delete a component and store it to be able to be recovered later.
|
||||
|
||||
Remember also the position of the main instance."
|
||||
([file-data component-id]
|
||||
(delete-component file-data component-id false))
|
||||
|
||||
([file-data component-id skip-undelete?]
|
||||
(let [components-v2 (get-in file-data [:options :components-v2])
|
||||
|
||||
add-to-deleted-components
|
||||
(fn [file-data]
|
||||
(let [component (ctkl/get-component file-data component-id)]
|
||||
(if (some? component)
|
||||
(let [page (ctpl/get-page file-data (:main-instance-page component))
|
||||
main-instance (ctn/get-shape page (:main-instance-id component))
|
||||
component (assoc component
|
||||
:main-instance-x (:x main-instance) ; An instance root is always a group,
|
||||
:main-instance-y (:y main-instance))] ; so it will have :x and :y
|
||||
(when (nil? main-instance)
|
||||
(throw (ex-info "Cannot delete the main instance before the component" {:component-id component-id})))
|
||||
(assoc-in file-data [:deleted-components component-id] component))
|
||||
file-data)))]
|
||||
|
||||
(cond-> file-data
|
||||
(and components-v2 (not skip-undelete?))
|
||||
(add-to-deleted-components)
|
||||
|
||||
:always
|
||||
(ctkl/delete-component component-id)))))
|
||||
|
||||
(defn get-deleted-component
|
||||
"Retrieve a component that has been deleted but still is in the safe store."
|
||||
[file-data component-id]
|
||||
(get-in file-data [:deleted-components component-id]))
|
||||
|
||||
(defn restore-component
|
||||
"Recover a deleted component and put it again in place."
|
||||
[file-data component-id]
|
||||
(let [component (-> (get-in file-data [:deleted-components component-id])
|
||||
(dissoc :main-instance-x :main-instance-y))]
|
||||
(cond-> file-data
|
||||
(some? component)
|
||||
(-> (assoc-in [:components component-id] component)
|
||||
(d/dissoc-in [:deleted-components component-id])))))
|
||||
|
||||
(defn purge-component
|
||||
"Remove permanently a component."
|
||||
[file-data component-id]
|
||||
(d/dissoc-in file-data [:deleted-components component-id]))
|
||||
|
||||
(defmulti uses-asset?
|
||||
"Checks if a shape uses the given asset."
|
||||
(fn [asset-type _ _ _] asset-type))
|
||||
|
@ -185,7 +236,7 @@
|
|||
|
||||
(defn migrate-to-components-v2
|
||||
"If there is any component in the file library, add a new 'Library backup' and generate
|
||||
main instances for all components there. Mark the file with the :comonents-v2 option."
|
||||
main instances for all components there. Mark the file with the :components-v2 option."
|
||||
[file-data]
|
||||
(let [components (ctkl/components-seq file-data)]
|
||||
(if (or (empty? components)
|
||||
|
@ -205,7 +256,7 @@
|
|||
component
|
||||
(:id file-data)
|
||||
position
|
||||
true)
|
||||
{:main-instance? true})
|
||||
|
||||
add-shapes
|
||||
(fn [page]
|
||||
|
@ -269,7 +320,7 @@
|
|||
component
|
||||
(:id file-data)
|
||||
position
|
||||
true)
|
||||
{:main-instance? true})
|
||||
|
||||
; Add all shapes of the main instance to the library page
|
||||
add-main-instance-shapes
|
||||
|
|
|
@ -37,3 +37,9 @@
|
|||
[file-data page-id f]
|
||||
(update-in file-data [:pages-index page-id] f))
|
||||
|
||||
(defn delete-page
|
||||
[file-data page-id]
|
||||
(-> file-data
|
||||
(update :pages (fn [pages] (filterv #(not= % page-id) pages)))
|
||||
(update :pages-index dissoc page-id)))
|
||||
|
||||
|
|
|
@ -284,10 +284,13 @@
|
|||
the order of the children of each parent."
|
||||
|
||||
([object parent-id objects update-new-object]
|
||||
(clone-object object parent-id objects update-new-object (fn [object _] object)))
|
||||
(clone-object object parent-id objects update-new-object (fn [object _] object) nil))
|
||||
|
||||
([object parent-id objects update-new-object update-original-object]
|
||||
(let [new-id (uuid/next)]
|
||||
(clone-object object parent-id objects update-new-object update-original-object nil))
|
||||
|
||||
([object parent-id objects update-new-object update-original-object force-id]
|
||||
(let [new-id (or force-id (uuid/next))]
|
||||
(loop [child-ids (seq (:shapes object))
|
||||
new-direct-children []
|
||||
new-children []
|
||||
|
|
|
@ -22,3 +22,7 @@
|
|||
[file-data typography-id f]
|
||||
(update-in file-data [:typographies typography-id] f))
|
||||
|
||||
(defn delete-typography
|
||||
[file-data typography-id]
|
||||
(update file-data :typographies dissoc typography-id))
|
||||
|
||||
|
|
|
@ -97,8 +97,7 @@
|
|||
(ctn/make-component-instance (ctpl/get-page file-data page-id)
|
||||
(ctkl/get-component (:data library) component-id)
|
||||
(:id library)
|
||||
(gpt/point 0 0)
|
||||
false)]
|
||||
(gpt/point 0 0))]
|
||||
|
||||
(swap! idmap assoc label (:id instance-shape))
|
||||
(-> file-data
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
[app.common.types.color :as ctc]
|
||||
[app.common.types.container :as ctn]
|
||||
[app.common.types.file :as ctf]
|
||||
[app.common.types.pages-list :as ctpl]
|
||||
[app.common.types.shape-tree :as ctst]
|
||||
[app.common.types.typography :as ctt]
|
||||
[app.common.uuid :as uuid]
|
||||
|
@ -393,13 +394,50 @@
|
|||
(ptk/reify ::delete-component
|
||||
ptk/WatchEvent
|
||||
(watch [it state _]
|
||||
(let [data (get state :workspace-data)
|
||||
(let [data (get state :workspace-data)
|
||||
components-v2 (features/active-feature? state :components-v2)
|
||||
changes (-> (pcb/empty-changes it)
|
||||
(pcb/with-library-data data)
|
||||
(pcb/delete-component id))]
|
||||
(pcb/delete-component id components-v2))]
|
||||
|
||||
(rx/of (dch/commit-changes changes))))))
|
||||
|
||||
(defn restore-component
|
||||
"Restore a deleted component, with the given id, on the current file library."
|
||||
[id]
|
||||
(us/assert ::us/uuid id)
|
||||
(ptk/reify ::restore-component
|
||||
ptk/WatchEvent
|
||||
(watch [it state _]
|
||||
(let [data (get state :workspace-data)
|
||||
component (ctf/get-deleted-component data id)
|
||||
page (ctpl/get-page data (:main-instance-page component))
|
||||
|
||||
; Make a new main instance, with the same id of the original
|
||||
[_main-instance shapes]
|
||||
(ctn/make-component-instance page
|
||||
component
|
||||
(:id data)
|
||||
(gpt/point (:main-instance-x component)
|
||||
(:main-instance-y component))
|
||||
{:main-instance? true
|
||||
:force-id (:main-instance-id component)})
|
||||
|
||||
changes (-> (pcb/empty-changes it)
|
||||
(pcb/with-library-data data)
|
||||
(pcb/with-page page))
|
||||
|
||||
changes (reduce #(pcb/add-object %1 %2 {:ignore-touched true})
|
||||
changes
|
||||
shapes)
|
||||
|
||||
; restore-component change needs to be done after add main instance
|
||||
; because when undo changes, the orden is inverse
|
||||
changes (pcb/restore-component changes id)]
|
||||
|
||||
(rx/of (dch/commit-changes changes))))))
|
||||
|
||||
|
||||
(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."
|
||||
|
|
|
@ -118,8 +118,7 @@
|
|||
:name (:name new-component-shape)
|
||||
:objects (d/index-by :id new-component-shapes)}
|
||||
(:component-file main-instance-shape)
|
||||
position
|
||||
false))]
|
||||
position))]
|
||||
|
||||
[new-component-shape new-component-shapes
|
||||
new-instance-shape new-instance-shapes]))
|
||||
|
@ -130,7 +129,7 @@
|
|||
(let [component (cph/get-component libraries file-id component-id)
|
||||
|
||||
[new-shape new-shapes]
|
||||
(ctn/make-component-instance page component file-id position false)
|
||||
(ctn/make-component-instance page component file-id position)
|
||||
|
||||
changes (reduce #(pcb/add-object %1 %2 {:ignore-touched true})
|
||||
(pcb/empty-changes it (:id page))
|
||||
|
|
|
@ -233,7 +233,16 @@
|
|||
(pcb/with-page page)
|
||||
(pcb/with-objects objects)
|
||||
(pcb/with-library-data file)
|
||||
(pcb/set-page-option :guides guides)
|
||||
(pcb/set-page-option :guides guides))
|
||||
|
||||
changes (reduce (fn [changes component-id]
|
||||
;; It's important to delete the component before the main instance, because we
|
||||
;; need to store the instance position if we want to restore it later.
|
||||
(pcb/delete-component changes component-id components-v2))
|
||||
changes
|
||||
components-to-delete)
|
||||
|
||||
changes (-> changes
|
||||
(pcb/remove-objects all-children)
|
||||
(pcb/remove-objects ids)
|
||||
(pcb/remove-objects empty-parents)
|
||||
|
@ -252,12 +261,7 @@
|
|||
(cond-> (seq starting-flows)
|
||||
(pcb/update-page-option :flows (fn [flows]
|
||||
(->> (map :id starting-flows)
|
||||
(reduce ctp/remove-flow flows))))))
|
||||
|
||||
changes (reduce (fn [changes component-id]
|
||||
(pcb/delete-component changes component-id))
|
||||
changes
|
||||
components-to-delete)]
|
||||
(reduce ctp/remove-flow flows))))))]
|
||||
|
||||
(rx/of (dc/detach-comment-thread ids)
|
||||
(dwsl/update-layout-positions all-parents)
|
||||
|
|
|
@ -220,7 +220,7 @@
|
|||
:fill "none"}
|
||||
|
||||
(when include-metadata?
|
||||
[:& export/export-page {:options (:options data)}])
|
||||
[:& export/export-page {:id (:id data) :options (:options data)}])
|
||||
|
||||
(let [shapes (->> shapes
|
||||
(remove cph/frame-shape?)
|
||||
|
@ -393,6 +393,11 @@
|
|||
object (get objects id)
|
||||
selrect (:selrect object)
|
||||
|
||||
main-instance-id (:main-instance-id data)
|
||||
main-instance-page (:main-instance-page data)
|
||||
main-instance-x (:main-instance-x data)
|
||||
main-instance-y (:main-instance-y data)
|
||||
|
||||
vbox
|
||||
(format-viewbox
|
||||
{:width (:width selrect)
|
||||
|
@ -403,7 +408,13 @@
|
|||
(mf/deps objects)
|
||||
(fn [] (group-wrapper-factory objects)))]
|
||||
|
||||
[:> "symbol" #js {:id (str id) :viewBox vbox "penpot:path" path}
|
||||
[:> "symbol" #js {:id (str id)
|
||||
:viewBox vbox
|
||||
"penpot:path" path
|
||||
"penpot:main-instance-id" main-instance-id
|
||||
"penpot:main-instance-page" main-instance-page
|
||||
"penpot:main-instance-x" main-instance-x
|
||||
"penpot:main-instance-y" main-instance-y}
|
||||
[:title name]
|
||||
[:> shape-container {:shape object}
|
||||
[:& group-wrapper {:shape object :view-box vbox}]]]))
|
||||
|
@ -414,7 +425,8 @@
|
|||
(let [data (obj/get props "data")
|
||||
children (obj/get props "children")
|
||||
render-embed? (obj/get props "render-embed?")
|
||||
include-metadata? (obj/get props "include-metadata?")]
|
||||
include-metadata? (obj/get props "include-metadata?")
|
||||
source (keyword (obj/get props "source" "components"))]
|
||||
[:& (mf/provider embed/context) {:value render-embed?}
|
||||
[:& (mf/provider export/include-metadata-ctx) {:value include-metadata?}
|
||||
[:svg {:version "1.1"
|
||||
|
@ -424,7 +436,7 @@
|
|||
:style {:display (when-not (some? children) "none")}
|
||||
:fill "none"}
|
||||
[:defs
|
||||
(for [[id data] (:components data)]
|
||||
(for [[id data] (source data)]
|
||||
[:& component-symbol {:id id :key (dm/str id) :data data}])]
|
||||
|
||||
children]]]))
|
||||
|
@ -482,9 +494,9 @@
|
|||
(rds/renderToStaticMarkup elem)))))))
|
||||
|
||||
(defn render-components
|
||||
[data]
|
||||
[data source]
|
||||
(let [;; Join all components objects into a single map
|
||||
objects (->> (:components data)
|
||||
objects (->> (source data)
|
||||
(vals)
|
||||
(map :objects)
|
||||
(reduce conj))]
|
||||
|
@ -498,5 +510,6 @@
|
|||
(rx/map
|
||||
(fn [data]
|
||||
(let [elem (mf/element components-sprite-svg
|
||||
#js {:data data :render-embed? true :include-metadata? true})]
|
||||
#js {:data data :render-embed? true :include-metadata? true
|
||||
:source (name source)})]
|
||||
(rds/renderToStaticMarkup elem))))))))
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
[app.main.data.events :as ev]
|
||||
[app.main.data.messages :as msg]
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.features :as features]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.file-uploader :refer [file-uploader]]
|
||||
[app.main.ui.icons :as i]
|
||||
|
@ -247,6 +248,8 @@
|
|||
:files (->> files
|
||||
(mapv #(assoc % :status :analyzing)))})
|
||||
|
||||
components-v2 (features/use-feature :components-v2)
|
||||
|
||||
analyze-import
|
||||
(mf/use-callback
|
||||
(fn [files]
|
||||
|
@ -268,6 +271,7 @@
|
|||
:num-files (count files)}))
|
||||
(->> (uw/ask-many!
|
||||
{:cmd :import-files
|
||||
:components-v2 components-v2
|
||||
:project-id project-id
|
||||
:files files})
|
||||
(rx/subs
|
||||
|
|
|
@ -54,7 +54,8 @@
|
|||
([props attr trfn]
|
||||
(let [val (get shape attr)
|
||||
val (if (keyword? val) (d/name val) val)
|
||||
ns-attr (str "penpot:" (-> attr d/name))]
|
||||
ns-attr (-> (str "penpot:" (-> attr d/name))
|
||||
(str/strip-suffix "?"))]
|
||||
(cond-> props
|
||||
(some? val)
|
||||
(obj/set! ns-attr (trfn val)))))))
|
||||
|
@ -136,7 +137,8 @@
|
|||
(add! :typography-ref-file)
|
||||
(add! :component-file)
|
||||
(add! :component-id)
|
||||
(add! :component-root)
|
||||
(add! :component-root?)
|
||||
(add! :main-instance?)
|
||||
(add! :shape-ref))))
|
||||
|
||||
(defn prefix-keys [m]
|
||||
|
@ -177,11 +179,11 @@
|
|||
:axis (d/name axis)}])])
|
||||
|
||||
(mf/defc export-page
|
||||
[{:keys [options]}]
|
||||
[{:keys [id options]}]
|
||||
(let [saved-grids (get options :saved-grids)
|
||||
flows (get options :flows)
|
||||
guides (get options :guides)]
|
||||
[:> "penpot:page" #js {}
|
||||
[:> "penpot:page" #js {:id id}
|
||||
(when (d/not-empty? saved-grids)
|
||||
(let [parse-grid (fn [[type params]] {:type type :params params})
|
||||
grids (->> saved-grids (mapv parse-grid))]
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.pages.helpers :as cph]
|
||||
[app.common.types.components-list :as ctkl]
|
||||
[app.common.types.page :as ctp]
|
||||
[app.main.data.events :as ev]
|
||||
[app.main.data.modal :as modal]
|
||||
|
@ -18,6 +19,7 @@
|
|||
[app.main.data.workspace.libraries :as dwl]
|
||||
[app.main.data.workspace.selection :as dws]
|
||||
[app.main.data.workspace.shortcuts :as sc]
|
||||
[app.main.features :as features]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.dropdown :refer [dropdown]]
|
||||
|
@ -373,9 +375,19 @@
|
|||
component-file (-> shapes first :component-file)
|
||||
component-shapes (filter #(contains? % :component-id) shapes)
|
||||
|
||||
components-v2 (features/use-feature :components-v2)
|
||||
|
||||
current-file-id (mf/use-ctx ctx/current-file-id)
|
||||
local-component? (= component-file current-file-id)
|
||||
|
||||
local-library (when local-component?
|
||||
;; Not needed to subscribe to changes because it's not expected
|
||||
;; to change while context menu is open
|
||||
(deref refs/workspace-local-library))
|
||||
|
||||
main-component (when local-component?
|
||||
(ctkl/get-component local-library (:component-id (first shapes))))
|
||||
|
||||
do-add-component #(st/emit! (dwl/add-component))
|
||||
do-detach-component #(st/emit! (dwl/detach-component shape-id))
|
||||
do-detach-component-in-bulk #(st/emit! dwl/detach-selected-components)
|
||||
|
@ -384,6 +396,7 @@
|
|||
do-navigate-component-file #(st/emit! (dwl/nav-to-component-file component-file))
|
||||
do-update-component #(st/emit! (dwl/update-component-sync shape-id component-file))
|
||||
do-update-component-in-bulk #(st/emit! (dwl/update-component-in-bulk component-shapes component-file))
|
||||
do-restore-component #(st/emit! (dwl/restore-component component-id))
|
||||
|
||||
do-update-remote-component
|
||||
#(st/emit! (modal/show
|
||||
|
@ -436,11 +449,14 @@
|
|||
|
||||
|
||||
(if local-component?
|
||||
[:*
|
||||
[:& menu-entry {:title (tr "workspace.shape.menu.update-main")
|
||||
:on-click do-update-component}]
|
||||
[:& menu-entry {:title (tr "workspace.shape.menu.show-main")
|
||||
:on-click do-show-component}]]
|
||||
(if (and (nil? main-component) components-v2)
|
||||
[:& menu-entry {:title (tr "workspace.shape.menu.restore-main")
|
||||
:on-click do-restore-component}]
|
||||
[:*
|
||||
[:& menu-entry {:title (tr "workspace.shape.menu.update-main")
|
||||
:on-click do-update-component}]
|
||||
[:& menu-entry {:title (tr "workspace.shape.menu.show-main")
|
||||
:on-click do-show-component}]])
|
||||
|
||||
[:*
|
||||
[:& menu-entry {:title (tr "workspace.shape.menu.go-main")
|
||||
|
|
|
@ -6,9 +6,11 @@
|
|||
|
||||
(ns app.main.ui.workspace.sidebar.options.menus.component
|
||||
(:require
|
||||
[app.common.types.components-list :as ctkl]
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.data.workspace :as dw]
|
||||
[app.main.data.workspace.libraries :as dwl]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.context-menu :refer [context-menu]]
|
||||
[app.main.ui.context :as ctx]
|
||||
|
@ -34,6 +36,15 @@
|
|||
(:main-instance? values)
|
||||
true)
|
||||
|
||||
local-component? (= library-id current-file-id)
|
||||
local-library (when local-component?
|
||||
;; Not needed to subscribe to changes because it's not expected
|
||||
;; to change while context menu is open
|
||||
(deref refs/workspace-local-library))
|
||||
|
||||
main-component (when local-component?
|
||||
(ctkl/get-component local-library component-id))
|
||||
|
||||
on-menu-click
|
||||
(mf/use-callback
|
||||
(fn [event]
|
||||
|
@ -54,6 +65,9 @@
|
|||
do-update-component
|
||||
#(st/emit! (dwl/update-component-sync id library-id))
|
||||
|
||||
do-restore-component
|
||||
#(st/emit! (dwl/restore-component component-id))
|
||||
|
||||
do-update-remote-component
|
||||
#(st/emit! (modal/show
|
||||
{:type :confirm
|
||||
|
@ -85,11 +99,13 @@
|
|||
;; app/main/ui/workspace/context_menu.cljs
|
||||
[:& context-menu {:on-close on-menu-close
|
||||
:show (:menu-open @local)
|
||||
:options (if (= library-id current-file-id)
|
||||
[[(tr "workspace.shape.menu.detach-instance") do-detach-component]
|
||||
[(tr "workspace.shape.menu.reset-overrides") do-reset-component]
|
||||
[(tr "workspace.shape.menu.update-main") do-update-component]
|
||||
[(tr "workspace.shape.menu.show-main") do-show-component]]
|
||||
:options (if local-component?
|
||||
(if (and (nil? main-component) components-v2)
|
||||
[[(tr "workspace.shape.menu.restore-main") do-restore-component]]
|
||||
[[(tr "workspace.shape.menu.detach-instance") do-detach-component]
|
||||
[(tr "workspace.shape.menu.reset-overrides") do-reset-component]
|
||||
[(tr "workspace.shape.menu.update-main") do-update-component]
|
||||
[(tr "workspace.shape.menu.show-main") do-show-component]])
|
||||
|
||||
[[(tr "workspace.shape.menu.detach-instance") do-detach-component]
|
||||
[(tr "workspace.shape.menu.reset-overrides") do-reset-component]
|
||||
|
|
|
@ -400,7 +400,8 @@
|
|||
component-id (get-meta node :component-id uuid/uuid)
|
||||
component-file (get-meta node :component-file uuid/uuid)
|
||||
shape-ref (get-meta node :shape-ref uuid/uuid)
|
||||
component-root? (get-meta node :component-root str->bool)]
|
||||
component-root? (get-meta node :component-root str->bool)
|
||||
main-instance? (get-meta node :main-instance str->bool)]
|
||||
|
||||
(cond-> props
|
||||
(some? stroke-color-ref-id)
|
||||
|
@ -414,6 +415,9 @@
|
|||
component-root?
|
||||
(assoc :component-root? component-root?)
|
||||
|
||||
main-instance?
|
||||
(assoc :main-instance? main-instance?)
|
||||
|
||||
(some? shape-ref)
|
||||
(assoc :shape-ref shape-ref))))
|
||||
|
||||
|
|
|
@ -40,17 +40,18 @@
|
|||
(reduce format-page {}))]
|
||||
(-> manifest
|
||||
(assoc (str (:id file))
|
||||
{:name name
|
||||
:shared is-shared
|
||||
:pages pages
|
||||
:pagesIndex index
|
||||
:version current-version
|
||||
:libraries (->> (:libraries file) (into #{}) (mapv str))
|
||||
:exportType (d/name export-type)
|
||||
:hasComponents (d/not-empty? (get-in file [:data :components]))
|
||||
:hasMedia (d/not-empty? (get-in file [:data :media]))
|
||||
:hasColors (d/not-empty? (get-in file [:data :colors]))
|
||||
:hasTypographies (d/not-empty? (get-in file [:data :typographies]))}))))]
|
||||
{:name name
|
||||
:shared is-shared
|
||||
:pages pages
|
||||
:pagesIndex index
|
||||
:version current-version
|
||||
:libraries (->> (:libraries file) (into #{}) (mapv str))
|
||||
:exportType (d/name export-type)
|
||||
:hasComponents (d/not-empty? (get-in file [:data :components]))
|
||||
:hasDeletedComponents (d/not-empty? (get-in file [:data :deleted-components]))
|
||||
:hasMedia (d/not-empty? (get-in file [:data :media]))
|
||||
:hasColors (d/not-empty? (get-in file [:data :colors]))
|
||||
:hasTypographies (d/not-empty? (get-in file [:data :typographies]))}))))]
|
||||
(let [manifest {:teamId (str team-id)
|
||||
:fileId (str file-id)
|
||||
:files (->> (vals files) (reduce format-file {}))}]
|
||||
|
@ -146,9 +147,14 @@
|
|||
|
||||
(defn parse-library-components
|
||||
[file]
|
||||
(->> (r/render-components (:data file))
|
||||
(->> (r/render-components (:data file) :components)
|
||||
(rx/map #(vector (str (:id file) "/components.svg") %))))
|
||||
|
||||
(defn parse-deleted-components
|
||||
[file]
|
||||
(->> (r/render-components (:data file) :deleted-components)
|
||||
(rx/map #(vector (str (:id file) "/deleted-components.svg") %))))
|
||||
|
||||
(defn fetch-file-with-libraries [file-id components-v2]
|
||||
(->> (rx/zip (rp/query :file {:id file-id :components-v2 components-v2})
|
||||
(rp/query :file-libraries {:file-id file-id}))
|
||||
|
@ -426,6 +432,12 @@
|
|||
(rx/filter #(d/not-empty? (get-in % [:data :components])))
|
||||
(rx/flat-map parse-library-components))
|
||||
|
||||
deleted-components-stream
|
||||
(->> files-stream
|
||||
(rx/flat-map vals)
|
||||
(rx/filter #(d/not-empty? (get-in % [:data :deleted-components])))
|
||||
(rx/flat-map parse-deleted-components))
|
||||
|
||||
pages-stream
|
||||
(->> render-stream
|
||||
(rx/map collect-page))]
|
||||
|
@ -441,6 +453,7 @@
|
|||
manifest-stream
|
||||
pages-stream
|
||||
components-stream
|
||||
deleted-components-stream
|
||||
media-stream
|
||||
colors-stream
|
||||
typographies-stream)
|
||||
|
|
|
@ -46,14 +46,15 @@
|
|||
([context type id media]
|
||||
(let [file-id (:file-id context)
|
||||
path (case type
|
||||
:manifest (str "manifest.json")
|
||||
:page (str file-id "/" id ".svg")
|
||||
:colors (str file-id "/colors.json")
|
||||
:typographies (str file-id "/typographies.json")
|
||||
:media-list (str file-id "/media.json")
|
||||
:media (let [ext (cm/mtype->extension (:mtype media))]
|
||||
(str/concat file-id "/media/" id ext))
|
||||
:components (str file-id "/components.svg"))
|
||||
:manifest (str "manifest.json")
|
||||
:page (str file-id "/" id ".svg")
|
||||
:colors (str file-id "/colors.json")
|
||||
:typographies (str file-id "/typographies.json")
|
||||
:media-list (str file-id "/media.json")
|
||||
:media (let [ext (cm/mtype->extension (:mtype media))]
|
||||
(str/concat file-id "/media/" id ext))
|
||||
:components (str file-id "/components.svg")
|
||||
:deleted-components (str file-id "/deleted-components.svg"))
|
||||
|
||||
parse-svg? (and (not= type :media) (str/ends-with? path "svg"))
|
||||
parse-json? (and (not= type :media) (str/ends-with? path "json"))
|
||||
|
@ -125,7 +126,7 @@
|
|||
|
||||
(defn create-file
|
||||
"Create a new file on the back-end"
|
||||
[context]
|
||||
[context components-v2]
|
||||
(let [resolve (:resolve context)
|
||||
file-id (resolve (:file-id context))]
|
||||
(rp/mutation :create-temp-file
|
||||
|
@ -133,7 +134,9 @@
|
|||
:name (:name context)
|
||||
:is-shared (:shared context)
|
||||
:project-id (:project-id context)
|
||||
:data (-> ctf/empty-file-data (assoc :id file-id))})))
|
||||
:data (-> ctf/empty-file-data
|
||||
(assoc :id file-id)
|
||||
(assoc-in [:options :components-v2] components-v2))})))
|
||||
|
||||
(defn link-file-libraries
|
||||
"Create a new file on the back-end"
|
||||
|
@ -380,18 +383,22 @@
|
|||
(rx/map (comp fb/close-page setup-interactions))))))))
|
||||
|
||||
(defn import-component [context file node]
|
||||
(let [resolve (:resolve context)
|
||||
content (cip/find-node node :g)
|
||||
file-id (:id file)
|
||||
old-id (cip/get-id node)
|
||||
id (resolve old-id)
|
||||
path (get-in node [:attrs :penpot:path] "")
|
||||
data (-> (cip/parse-data :group content)
|
||||
(assoc :path path)
|
||||
(assoc :id id))
|
||||
(let [resolve (:resolve context)
|
||||
content (cip/find-node node :g)
|
||||
file-id (:id file)
|
||||
old-id (cip/get-id node)
|
||||
id (resolve old-id)
|
||||
path (get-in node [:attrs :penpot:path] "")
|
||||
main-instance-id (resolve (uuid (get-in node [:attrs :penpot:main-instance-id] "")))
|
||||
main-instance-page (resolve (uuid (get-in node [:attrs :penpot:main-instance-page] "")))
|
||||
data (-> (cip/parse-data :group content)
|
||||
(assoc :path path)
|
||||
(assoc :id id)
|
||||
(assoc :main-instance-id main-instance-id)
|
||||
(assoc :main-instance-page main-instance-page))
|
||||
|
||||
file (-> file (fb/start-component data))
|
||||
children (cip/node-seq node)]
|
||||
file (-> file (fb/start-component data))
|
||||
children (cip/node-seq node)]
|
||||
|
||||
(->> (rx/from children)
|
||||
(rx/filter cip/shape?)
|
||||
|
@ -401,6 +408,43 @@
|
|||
(rx/reduce (partial process-import-node context) file)
|
||||
(rx/map fb/finish-component))))
|
||||
|
||||
(defn import-deleted-component [context file node]
|
||||
(let [resolve (:resolve context)
|
||||
content (cip/find-node node :g)
|
||||
file-id (:id file)
|
||||
old-id (cip/get-id node)
|
||||
id (resolve old-id)
|
||||
path (get-in node [:attrs :penpot:path] "")
|
||||
main-instance-id (resolve (uuid (get-in node [:attrs :penpot:main-instance-id] "")))
|
||||
main-instance-page (resolve (uuid (get-in node [:attrs :penpot:main-instance-page] "")))
|
||||
main-instance-x (get-in node [:attrs :penpot:main-instance-x] "")
|
||||
main-instance-y (get-in node [:attrs :penpot:main-instance-y] "")
|
||||
|
||||
data (-> (cip/parse-data :group content)
|
||||
(assoc :path path)
|
||||
(assoc :id id)
|
||||
(assoc :main-instance-id main-instance-id)
|
||||
(assoc :main-instance-page main-instance-page)
|
||||
(assoc :main-instance-x main-instance-x)
|
||||
(assoc :main-instance-y main-instance-y))
|
||||
|
||||
file (-> file (fb/start-component data))
|
||||
component-id (:current-component-id file)
|
||||
children (cip/node-seq node)]
|
||||
|
||||
(->> (rx/from children)
|
||||
(rx/filter cip/shape?)
|
||||
(rx/skip 1)
|
||||
(rx/skip-last 1)
|
||||
(rx/mapcat (partial resolve-media context file-id))
|
||||
(rx/reduce (partial process-import-node context) file)
|
||||
(rx/map fb/finish-component)
|
||||
(rx/map (partial fb/finish-deleted-component
|
||||
component-id
|
||||
main-instance-page
|
||||
main-instance-x
|
||||
main-instance-y)))))
|
||||
|
||||
(defn process-pages
|
||||
[context file]
|
||||
(let [index (:pages-index context)
|
||||
|
@ -486,6 +530,18 @@
|
|||
(rx/concat-reduce (partial import-component context) file)))
|
||||
(rx/of file)))
|
||||
|
||||
(defn process-deleted-components
|
||||
[context file]
|
||||
(if (:has-deleted-components context)
|
||||
(let [split-components
|
||||
(fn [content] (->> (cip/node-seq content)
|
||||
(filter #(= :symbol (:tag %)))))]
|
||||
|
||||
(->> (get-file context :deleted-components)
|
||||
(rx/flat-map split-components)
|
||||
(rx/concat-reduce (partial import-deleted-component context) file)))
|
||||
(rx/of file)))
|
||||
|
||||
(defn process-file
|
||||
[context file]
|
||||
|
||||
|
@ -502,18 +558,20 @@
|
|||
(rx/flat-map (partial process-library-media context))
|
||||
(rx/tap #(progress! context :process-components))
|
||||
(rx/flat-map (partial process-library-components context))
|
||||
(rx/tap #(progress! context :process-deleted-components))
|
||||
(rx/flat-map (partial process-deleted-components context))
|
||||
(rx/flat-map (partial send-changes context))
|
||||
(rx/tap #(rx/end! progress-str)))]))
|
||||
|
||||
(defn create-files
|
||||
[context files]
|
||||
[context files components-v2]
|
||||
|
||||
(let [data (group-by :file-id files)]
|
||||
(rx/concat
|
||||
(->> (rx/from files)
|
||||
(rx/map #(merge context %))
|
||||
(rx/flat-map (fn [context]
|
||||
(->> (create-file context)
|
||||
(->> (create-file context components-v2)
|
||||
(rx/map #(vector % (first (get data (:file-id context)))))))))
|
||||
|
||||
(->> (rx/from files)
|
||||
|
@ -564,7 +622,7 @@
|
|||
(rx/catch #(rx/of {:uri (:uri file) :error (.-message %)}))))))))
|
||||
|
||||
(defmethod impl/handler :import-files
|
||||
[{:keys [project-id files]}]
|
||||
[{:keys [project-id files components-v2]}]
|
||||
|
||||
(let [context {:project-id project-id
|
||||
:resolve (resolve-factory)}
|
||||
|
@ -572,7 +630,7 @@
|
|||
binary-files (filter #(= "application/octet-stream" (:type %)) files)]
|
||||
|
||||
(->> (rx/merge
|
||||
(->> (create-files context zip-files)
|
||||
(->> (create-files context zip-files components-v2)
|
||||
(rx/flat-map
|
||||
(fn [[file data]]
|
||||
(->> (uz/load-from-url (:uri data))
|
||||
|
|
|
@ -4303,6 +4303,9 @@ msgstr "Update main components"
|
|||
msgid "workspace.shape.menu.update-main"
|
||||
msgstr "Update main component"
|
||||
|
||||
msgid "workspace.shape.menu.restore-main"
|
||||
msgstr "Restore main component"
|
||||
|
||||
#: src/app/main/ui/workspace/left_toolbar.cljs
|
||||
msgid "workspace.sidebar.history"
|
||||
msgstr "History (%s)"
|
||||
|
|
|
@ -4498,6 +4498,9 @@ msgstr "Actualizar componentes"
|
|||
msgid "workspace.shape.menu.update-main"
|
||||
msgstr "Actualizar componente principal"
|
||||
|
||||
msgid "workspace.shape.menu.restore-main"
|
||||
msgstr "Restaurar componente principal"
|
||||
|
||||
#: src/app/main/ui/workspace/left_toolbar.cljs
|
||||
msgid "workspace.sidebar.history"
|
||||
msgstr "Historial (%s)"
|
||||
|
|
Loading…
Add table
Reference in a new issue