penpot/frontend/src/app/main/ui/workspace/context_menu.cljs

965 lines
39 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.context-menu
"A workspace specific context menu (mouse right click)."
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.transit :as t]
[app.common.types.component :as ctk]
[app.common.types.container :as ctn]
[app.common.types.page :as ctp]
[app.common.types.shape.layout :as ctl]
[app.config :as cf]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.shortcuts :as scd]
[app.main.data.workspace :as dw]
[app.main.data.workspace.guides :as dwg]
[app.main.data.workspace.interactions :as dwi]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.shortcuts :as sc]
[app.main.data.workspace.variants :as dwv]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.context :as ctx]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.workspace.sidebar.assets.common :as cmm]
[app.util.clipboard :as clipboard]
[app.util.dom :as dom]
[app.util.i18n :refer [tr] :as i18n]
[app.util.shape-icon :as usi]
[app.util.timers :as timers]
[beicon.v2.core :as rx]
[okulary.core :as l]
[rumext.v2 :as mf]))
(def menu-ref
(l/derived :context-menu refs/workspace-local))
(defn- prevent-default
[event]
(dom/prevent-default event)
(dom/stop-propagation event))
(mf/defc menu-entry*
{::mf/private true}
[{:keys [title shortcut on-click on-pointer-enter on-pointer-leave
on-unmount children is-selected icon disabled value]}]
(let [submenu-ref (mf/use-ref nil)
hovering? (mf/use-ref false)
on-click'
(mf/use-fn
(mf/deps on-click)
(fn [event]
(st/emit! dw/hide-context-menu)
(when on-click (on-click event))))
on-pointer-enter
(mf/use-fn
(fn []
(mf/set-ref-val! hovering? true)
(let [submenu-node (mf/ref-val submenu-ref)]
(when (some? submenu-node)
(dom/set-css-property! submenu-node "display" "block")))
(when on-pointer-enter (on-pointer-enter))))
on-pointer-leave
(mf/use-fn
(fn []
(mf/set-ref-val! hovering? false)
(let [submenu-node (mf/ref-val submenu-ref)]
(when (some? submenu-node)
(timers/schedule
200
#(when-not (mf/ref-val hovering?)
(dom/set-css-property! submenu-node "display" "none")))))
(when on-pointer-leave (on-pointer-leave))))
set-dom-node
(mf/use-fn
(fn [dom]
(let [submenu-node (mf/ref-val submenu-ref)]
(when (and (some? dom) (some? submenu-node))
(dom/set-css-property! submenu-node "top" (str (.-offsetTop dom) "px"))))))]
(mf/use-effect
(mf/deps on-unmount)
(constantly on-unmount))
(if icon
[:li {:class (stl/css :icon-menu-item)
:disabled disabled
:data-value value
:ref set-dom-node
:on-click on-click'
:on-pointer-enter on-pointer-enter
:on-pointer-leave on-pointer-leave}
[:span
{:class (stl/css :icon-wrapper)}
(if is-selected [:span {:class (stl/css :selected-icon)}
[:> icon* {:icon-id i/tick :size "s"}]]
[:span {:class (stl/css :selected-icon)}])
[:span {:class (stl/css :shape-icon)}
[:> icon* {:icon-id icon :size "s"}]]]
[:span {:class (stl/css :title)} title]]
[:li {:class (stl/css :context-menu-item)
:disabled disabled
:ref set-dom-node
:data-value value
:on-click on-click'
:on-pointer-enter on-pointer-enter
:on-pointer-leave on-pointer-leave}
[:span {:class (stl/css :title)} title]
(when shortcut
[:span {:class (stl/css :shortcut)}
(for [[idx sc] (d/enumerate (scd/split-sc shortcut))]
[:span {:key (dm/str shortcut "-" idx)
:class (stl/css :shortcut-key)} sc])])
(when (> (count children) 1)
[:span {:class (stl/css :submenu-icon)}
[:> icon* {:icon-id i/arrow :size "s"}]])
(when (> (count children) 1)
[:ul {:class (stl/css :workspace-context-submenu)
:ref submenu-ref
:style {:display "none" :left 250}
:on-context-menu prevent-default}
children])])))
(mf/defc menu-separator*
{::mf/private true}
[]
[:li {:class (stl/css :separator)}])
(mf/defc context-menu-edit*
{::mf/private true}
[{:keys [shapes]}]
(let [multiple? (> (count shapes) 1)
do-copy #(st/emit! (dw/copy-selected))
do-copy-link #(st/emit! (dw/copy-link-to-clipboard))
do-cut #(st/emit! (dw/copy-selected)
(dw/delete-selected))
do-paste #(st/emit! (dw/paste-from-clipboard))
do-duplicate #(st/emit! (dwv/duplicate-or-add-variant))
enabled-paste-props* (mf/use-state false)
handle-copy-css
(mf/use-callback #(st/emit! (dw/copy-selected-css)))
handle-copy-css-nested
(mf/use-callback #(st/emit! (dw/copy-selected-css-nested)))
handle-copy-props
(mf/use-callback #(st/emit! (dw/copy-selected-props)))
handle-paste-props
(mf/use-callback #(st/emit! (dw/paste-selected-props)))
handle-copy-svg
(mf/use-callback #(st/emit! (dw/copy-selected-svg)))
handle-copy-text
(mf/use-callback #(st/emit! (dw/copy-selected-text)))
handle-copy-as-image
(mf/use-callback #(st/emit! (dw/copy-as-image)))
handle-hover-copy-paste
(mf/use-callback
(fn []
(->> (clipboard/from-navigator)
(rx/mapcat #(.text %))
(rx/take 1)
(rx/subs!
(fn [data]
(try
(let [pdata (t/decode-str data)]
(reset! enabled-paste-props*
(and (dw/paste-data-valid? pdata)
(= :copied-props (:type pdata)))))
(catch :default _
(reset! enabled-paste-props* false))))
(fn []
(reset! enabled-paste-props* false))))))]
[:*
[:> menu-entry* {:title (tr "workspace.shape.menu.copy")
:shortcut (sc/get-tooltip :copy)
:on-click do-copy}]
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-link")
:shortcut (sc/get-tooltip :copy-link)
:on-click do-copy-link}]
[:> menu-entry* {:title (tr "workspace.shape.menu.cut")
:shortcut (sc/get-tooltip :cut)
:on-click do-cut}]
[:> menu-entry* {:title (tr "workspace.shape.menu.paste")
:shortcut (sc/get-tooltip :paste)
:on-click do-paste}]
[:> menu-entry* {:title (tr "workspace.shape.menu.duplicate")
:shortcut (sc/get-tooltip :duplicate)
:on-click do-duplicate}]
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-paste-as")
:on-pointer-enter (when (cf/check-browser? :chrome) handle-hover-copy-paste)}
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-css")
:on-click handle-copy-css}]
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-css-nested")
:on-click handle-copy-css-nested}]
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-svg")
:on-click handle-copy-svg}]
(when (some cfh/frame-shape? shapes)
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-as-image")
:disabled multiple?
:on-click handle-copy-as-image}])
[:> menu-separator* {}]
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-text")
:on-click handle-copy-text}]
[:> menu-entry* {:title (tr "workspace.shape.menu.copy-props")
:shortcut (sc/get-tooltip :copy-props)
:disabled multiple?
:on-click handle-copy-props}]
[:> menu-entry* {:title (tr "workspace.shape.menu.paste-props")
:shortcut (sc/get-tooltip :paste-props)
:disabled (and (cf/check-browser? :chrome) (not @enabled-paste-props*))
:on-click handle-paste-props}]]
[:> menu-separator* {}]]))
(mf/defc context-menu-layer-position*
{::mf/private true}
[{:keys [shapes]}]
(let [do-bring-forward (mf/use-fn #(st/emit! (dw/vertical-order-selected :up)))
do-bring-to-front (mf/use-fn #(st/emit! (dw/vertical-order-selected :top)))
do-send-backward (mf/use-fn #(st/emit! (dw/vertical-order-selected :down)))
do-send-to-back (mf/use-fn #(st/emit! (dw/vertical-order-selected :bottom)))
select-shapes (fn [id] #(st/emit! (dws/select-shape id)))
on-pointer-enter (fn [id] #(st/emit! (dw/highlight-shape id)))
on-pointer-leave (fn [id] #(st/emit! (dw/dehighlight-shape id)))
on-unmount (fn [id] #(st/emit! (dw/dehighlight-shape id)))
;; NOTE: we use deref instead of mf/deref on objects because
;; we really don't want rerender on object changes
hover-ids (deref refs/current-hover-ids)
objects (deref refs/workspace-page-objects)
hover-objs (into [] (keep (d/getf objects)) hover-ids)]
[:*
(when (> (count hover-objs) 1)
[:> menu-entry* {:title (tr "workspace.shape.menu.select-layer")}
(for [object hover-objs]
[:> menu-entry* {:title (:name object)
:key (dm/str (:id object))
:is-selected (some #(= object %) shapes)
:on-click (select-shapes (:id object))
:on-pointer-enter (on-pointer-enter (:id object))
:on-pointer-leave (on-pointer-leave (:id object))
:on-unmount (on-unmount (:id object))
:icon (usi/get-shape-icon object)}])])
[:> menu-entry* {:title (tr "workspace.shape.menu.forward")
:shortcut (sc/get-tooltip :bring-forward)
:on-click do-bring-forward}]
[:> menu-entry* {:title (tr "workspace.shape.menu.front")
:shortcut (sc/get-tooltip :bring-front)
:on-click do-bring-to-front}]
[:> menu-entry* {:title (tr "workspace.shape.menu.backward")
:shortcut (sc/get-tooltip :bring-backward)
:on-click do-send-backward}]
[:> menu-entry* {:title (tr "workspace.shape.menu.back")
:shortcut (sc/get-tooltip :bring-back)
:on-click do-send-to-back}]
[:> menu-separator* {}]]))
(mf/defc context-menu-flip*
{::mf/private true}
[]
(let [do-flip-vertical #(st/emit! (dw/flip-vertical-selected))
do-flip-horizontal #(st/emit! (dw/flip-horizontal-selected))]
[:*
[:> menu-entry* {:title (tr "workspace.shape.menu.flip-vertical")
:shortcut (sc/get-tooltip :flip-vertical)
:on-click do-flip-vertical}]
[:> menu-entry* {:title (tr "workspace.shape.menu.flip-horizontal")
:shortcut (sc/get-tooltip :flip-horizontal)
:on-click do-flip-horizontal}]
[:> menu-separator* {}]]))
(mf/defc context-menu-thumbnail*
{::mf/private true}
[{:keys [shapes]}]
(let [single? (= (count shapes) 1)
has-frame? (some cfh/frame-shape? shapes)
do-toggle-thumbnail #(st/emit! (dw/toggle-file-thumbnail-selected))]
(when (and single? has-frame?)
[:*
(if (every? :use-for-thumbnail shapes)
[:> menu-entry* {:title (tr "workspace.shape.menu.thumbnail-remove")
:on-click do-toggle-thumbnail}]
[:> menu-entry* {:title (tr "workspace.shape.menu.thumbnail-set")
:shortcut (sc/get-tooltip :thumbnail-set)
:on-click do-toggle-thumbnail}])
[:> menu-separator* {}]])))
(mf/defc context-menu-rename*
{::mf/private true}
[{:keys [shapes]}]
(let [do-rename #(st/emit! (dw/start-rename-selected))]
(when (= (count shapes) 1)
[:*
[:> menu-separator* {}]
[:> menu-entry* {:title (tr "workspace.shape.menu.rename")
:shortcut (sc/get-tooltip :rename)
:on-click do-rename}]])))
(mf/defc context-menu-group*
{::mf/private true}
[{:keys [shapes]}]
(let [multiple? (> (count shapes) 1)
single? (= (count shapes) 1)
objects (deref refs/workspace-page-objects)
any-in-copy? (some #(ctn/has-any-copy-parent? objects %) shapes)
any-is-variant? (some ctk/is-variant? shapes)
;; components can't be ungrouped
has-frame? (->> shapes (d/seek #(and (cfh/frame-shape? %) (not (ctk/instance-head? %)) (not (ctk/is-variant-container? %)))))
has-group? (->> shapes (d/seek #(and (cfh/group-shape? %) (not (ctk/instance-head? %)) (not (ctk/is-variant-container? %)))))
has-bool? (->> shapes (d/seek cfh/bool-shape?))
has-mask? (->> shapes (d/seek :masked-group))
is-group? (and single? has-group?)
is-bool? (and single? has-bool?)
do-create-group #(st/emit! (dw/group-selected))
do-remove-group #(st/emit! (dw/ungroup-selected))
do-mask-group #(st/emit! (dw/mask-group))
do-unmask-group #(st/emit! (dw/unmask-group))
do-create-artboard-from-selection
#(st/emit! (dwsh/create-artboard-from-selection))]
[:*
(when (not (or any-in-copy? any-is-variant?))
[:*
(when (or has-bool? has-group? has-mask? has-frame?)
[:> menu-entry* {:title (tr "workspace.shape.menu.ungroup")
:shortcut (sc/get-tooltip :ungroup)
:on-click do-remove-group}])
[:> menu-entry* {:title (tr "workspace.shape.menu.group")
:shortcut (sc/get-tooltip :group)
:on-click do-create-group}]
(when (or multiple? (and is-group? (not has-mask?)) is-bool?)
[:> menu-entry* {:title (tr "workspace.shape.menu.mask")
:shortcut (sc/get-tooltip :mask)
:on-click do-mask-group}])
(when has-mask?
[:> menu-entry* {:title (tr "workspace.shape.menu.unmask")
:shortcut (sc/get-tooltip :unmask)
:on-click do-unmask-group}])
[:> menu-entry* {:title (tr "workspace.shape.menu.create-artboard-from-selection")
:shortcut (sc/get-tooltip :artboard-selection)
:on-click do-create-artboard-from-selection}]
[:> menu-separator* {}]])]))
(mf/defc context-focus-mode-menu*
{::mf/private true}
[]
(let [focus (mf/deref refs/workspace-focus-selected)
do-toggle-focus-mode #(st/emit! (dw/toggle-focus-mode))]
[:> menu-entry* {:title (if (empty? focus)
(tr "workspace.focus.focus-on")
(tr "workspace.focus.focus-off"))
:shortcut (sc/get-tooltip :toggle-focus-mode)
:on-click do-toggle-focus-mode}]))
(mf/defc context-menu-path*
{::mf/private true}
[{:keys [shapes objects disable-flatten disable-booleans]}]
(let [multiple? (> (count shapes) 1)
single? (= (count shapes) 1)
has-group? (->> shapes (d/seek cfh/group-shape?))
has-bool? (->> shapes (d/seek cfh/bool-shape?))
has-frame? (->> shapes (d/seek cfh/frame-shape?))
has-path? (->> shapes (d/seek cfh/path-shape?))
is-group? (and single? has-group?)
is-bool? (and single? has-bool?)
is-frame? (and single? has-frame?)
has-strokes? (or (->> shapes (d/seek #(seq (:strokes %))))
(when objects
(->> shapes
(d/seek
(fn [shape]
(->> (cfh/get-children-ids objects (:id shape))
(d/seek #(seq (:strokes (get objects %))))))))))
do-start-editing (fn [] (timers/schedule #(st/emit! (dw/start-editing-selected))))
do-transform-to-path #(st/emit! (dw/convert-selected-to-path))
do-strokes-to-path #(st/emit! (dw/convert-selected-strokes-to-path))
make-do-bool
(fn [bool-type]
#(cond
multiple?
(st/emit! (dw/create-bool bool-type))
is-group?
(st/emit! (dw/group-to-bool (-> shapes first :id) bool-type))
is-bool?
(st/emit! (dw/change-bool-type (-> shapes first :id) bool-type))))]
[:*
(when (and single? (not is-frame?))
[:> menu-entry* {:title (tr "workspace.shape.menu.edit")
:shortcut (sc/get-tooltip :start-editing)
:on-click do-start-editing}])
(when-not (or disable-flatten has-frame? has-path?)
[:> menu-entry* {:title (tr "workspace.shape.menu.flatten")
:on-click do-transform-to-path}])
(when (and has-strokes?
(features/active-feature? @st/state "render-wasm/v1")
(contains? cf/flags :stroke-path))
[:> menu-entry* {:title (tr "workspace.shape.menu.stroke-to-path")
:on-click do-strokes-to-path}])
(when (and (not has-frame?)
(not disable-booleans)
(or multiple? (and single? (or is-group? is-bool?))))
[:> menu-entry* {:title (tr "workspace.shape.menu.path")}
[:> menu-entry* {:title (tr "workspace.shape.menu.union")
:shortcut (sc/get-tooltip :bool-union)
:on-click (make-do-bool :union)}]
[:> menu-entry* {:title (tr "workspace.shape.menu.difference")
:shortcut (sc/get-tooltip :bool-difference)
:on-click (make-do-bool :difference)}]
[:> menu-entry* {:title (tr "workspace.shape.menu.intersection")
:shortcut (sc/get-tooltip :bool-intersection)
:on-click (make-do-bool :intersection)}]
[:> menu-entry* {:title (tr "workspace.shape.menu.exclude")
:shortcut (sc/get-tooltip :bool-exclude)
:on-click (make-do-bool :exclude)}]
(when (and single? is-bool? (not disable-flatten))
[:*
[:> menu-separator* {}]
[:> menu-entry* {:title (tr "workspace.shape.menu.flatten")
:on-click do-transform-to-path}]])])]))
(mf/defc context-menu-layer-options*
{::mf/private true}
[{:keys [shapes]}]
(let [ids (mapv :id shapes)
do-show-shape #(st/emit! (dw/update-shape-flags ids {:hidden false}))
do-hide-shape #(st/emit! (dw/update-shape-flags ids {:hidden true}))
do-lock-shape #(st/emit! (dw/update-shape-flags ids {:blocked true}))
do-unlock-shape #(st/emit! (dw/update-shape-flags ids {:blocked false}))]
[:*
(if (every? :hidden shapes)
[:> menu-entry* {:title (tr "workspace.shape.menu.show")
:shortcut (sc/get-tooltip :toggle-visibility)
:on-click do-show-shape}]
[:> menu-entry* {:title (tr "workspace.shape.menu.hide")
:shortcut (sc/get-tooltip :toggle-visibility)
:on-click do-hide-shape}])
(if (every? :blocked shapes)
[:> menu-entry* {:title (tr "workspace.shape.menu.unlock")
:shortcut (sc/get-tooltip :toggle-lock)
:on-click do-unlock-shape}]
[:> menu-entry* {:title (tr "workspace.shape.menu.lock")
:shortcut (sc/get-tooltip :toggle-lock)
:on-click do-lock-shape}])]))
(mf/defc context-menu-prototype*
{::mf/private true}
[{:keys [shapes]}]
(let [flows (mf/deref refs/workspace-page-flows)
options-mode (mf/deref refs/options-mode-global)
do-add-flow #(st/emit! (dwi/add-flow-selected-frame))
do-remove-flow #(st/emit! (dwi/remove-flow (:id %)))
prototype? (= options-mode :prototype)
single? (= (count shapes) 1)
has-frame? (d/seek cfh/frame-shape? shapes)
is-frame? (and single? has-frame?)]
(when (and prototype? is-frame?)
(if-let [flow (ctp/get-frame-flow flows (-> shapes first :id))]
[:> menu-entry* {:title (tr "workspace.shape.menu.delete-flow-start")
:on-click (do-remove-flow flow)}]
[:> menu-entry* {:title (tr "workspace.shape.menu.flow-start")
:on-click do-add-flow}]))))
(mf/defc context-menu-layout*
{::mf/private true}
[{:keys [shapes]}]
(let [single? (= (count shapes) 1)
objects (deref refs/workspace-page-objects)
any-in-copy? (some true? (map #(ctn/has-any-copy-parent? objects %) shapes))
has-flex?
(and single? (every? ctl/flex-layout? shapes))
has-grid?
(and single? (every? ctl/grid-layout? shapes))
any-is-variant? (some ctk/is-variant? shapes)
on-add-layout
(mf/use-fn
(fn [event]
(let [type (-> (dom/get-current-target event)
(dom/get-data "value")
(keyword))]
(st/emit! (with-meta (dwsl/create-layout type)
{::ev/origin "workspace:context-menu"})))))
on-remove-layout
(mf/use-fn
(mf/deps shapes)
(fn [_event]
(let [ids (map :id shapes)]
(st/emit! (dwsl/remove-layout ids)))))]
[:*
(when (not any-in-copy?)
(if (or ^boolean has-flex?
^boolean has-grid?)
[:div
[:> menu-separator* {}]
(if has-flex?
[:> menu-entry* {:title (tr "workspace.shape.menu.remove-flex")
:shortcut (sc/get-tooltip :toggle-layout-flex)
:on-click on-remove-layout}]
[:> menu-entry* {:title (tr "workspace.shape.menu.remove-grid")
:shortcut (sc/get-tooltip :toggle-layout-grid)
:on-click on-remove-layout}])]
(when (or single? (not any-is-variant?))
[:div
[:> menu-separator* {}]
[:> menu-entry* {:title (tr "workspace.shape.menu.add-flex")
:shortcut (sc/get-tooltip :toggle-layout-flex)
:value "flex"
:on-click on-add-layout}]
[:> menu-entry* {:title (tr "workspace.shape.menu.add-grid")
:shortcut (sc/get-tooltip :toggle-layout-grid)
:value "grid"
:on-click on-add-layout}]])))]))
(mf/defc context-menu-component*
{:mf/private true}
[{:keys [shapes]}]
(let [single? (= (count shapes) 1)
objects (deref refs/workspace-page-objects)
can-make-component (every? true? (map #(ctn/valid-shape-for-component? objects %) shapes))
components-menu-entries (cmm/generate-components-menu-entries shapes)
variant-container? (and single? (ctk/is-variant-container? (first shapes)))
all-main? (every? ctk/main-instance? shapes)
any-variant? (some ctk/is-variant? shapes)
do-add-component (mf/use-fn #(st/emit! (dwl/add-component)))
do-add-multiple-components (mf/use-fn #(st/emit! (dwl/add-multiple-components)))
do-combine-as-variants (mf/use-fn #(st/emit!
(dwv/combine-selected-as-variants {:trigger "workspace:context-menu-component"})))
do-add-variant (mf/use-fn
(mf/deps shapes)
#(st/emit!
(ev/event {::ev/name "add-new-variant" ::ev/origin "workspace:context-menu-component"})
(dwv/add-new-variant (:id (first shapes)))))]
[:*
(when can-make-component ;; We don't want to change the structure of component copies
[:*
[:> menu-separator* {}]
[:> menu-entry* {:title (tr "workspace.shape.menu.create-component")
:shortcut (sc/get-tooltip :create-component-variant)
:on-click do-add-component}]
(when (not single?)
[:> menu-entry* {:title (tr "workspace.shape.menu.create-multiple-components")
:on-click do-add-multiple-components}])])
(when (seq components-menu-entries)
[:*
[:> menu-separator*]
(for [entry (filter some? components-menu-entries)]
[:> menu-entry* {:key (:title entry)
:title (:title entry)
:shortcut (when (contains? entry :shortcut)
(sc/get-tooltip (:shortcut entry)))
:on-click (:action entry)}])])
(when variant-container?
[:*
[:> menu-separator*]
[:> menu-entry* {:title (tr "workspace.shape.menu.add-variant")
:shortcut (sc/get-tooltip :create-component-variant)
:on-click do-add-variant}]])
(when (and (not single?) all-main? (not any-variant?))
[:*
[:> menu-separator*]
[:> menu-entry* {:title (tr "workspace.shape.menu.combine-as-variants")
:on-click do-combine-as-variants}]])]))
(mf/defc context-menu-guides*
{::mf/props :obj
::mf/private true}
[{:keys [shapes]}]
(let [frame-ids (into #{} (comp (filter cfh/frame-shape?) d/xf:map-id) shapes)
guides (mf/deref refs/workspace-page-guides)
has-guides? (some #(contains? frame-ids (:frame-id %)) (vals guides))
do-remove-guides
(mf/use-fn
(mf/deps frame-ids)
#(st/emit! (dwg/remove-frame-guides frame-ids)))]
(when (and (seq frame-ids) has-guides?)
[:*
[:> menu-separator* {}]
[:> menu-entry* {:title (tr "workspace.shape.menu.clear-guides")
:on-click do-remove-guides}]])))
(mf/defc context-menu-delete*
{::mf/private true}
[]
(let [do-delete #(st/emit! (dw/delete-selected))]
[:*
[:> menu-separator* {}]
[:> menu-entry* {:title (tr "workspace.shape.menu.delete")
:shortcut (sc/get-tooltip :delete)
:on-click do-delete}]]))
(mf/defc shape-context-menu*
{::mf/wrap [mf/memo]
::mf/private true}
[{:keys [mdata]}]
(let [{:keys [disable-booleans disable-flatten]} mdata
objects (deref refs/workspace-page-objects)
shape-ids (mf/deref refs/selected-shapes)
shapes (map (d/getf objects) shape-ids)
is-not-variant-container? (->> shapes (d/seek #(not (ctk/is-variant-container? %))))
props (mf/props
{:shapes shapes
:objects objects
:disable-booleans disable-booleans
:disable-flatten disable-flatten})]
(when-not (empty? shapes)
[:*
[:> context-menu-edit* props]
[:> context-menu-layer-position* props]
(when is-not-variant-container?
[:> context-menu-flip* props])
[:> context-menu-thumbnail* props]
[:> context-menu-rename* props]
[:> context-menu-group* props]
[:> context-focus-mode-menu* props]
[:> context-menu-path* props]
[:> context-menu-layer-options* props]
[:> context-menu-prototype* props]
(when is-not-variant-container?
[:> context-menu-layout* props])
[:> context-menu-component* props]
[:> context-menu-guides* props]
[:> context-menu-delete* props]])))
(mf/defc page-item-context-menu*
{::mf/private true}
[{:keys [mdata]}]
(let [page (:page mdata)
deletable? (:deletable? mdata)
id (:id page)
delete-fn #(st/emit! (dw/delete-page id))
do-delete #(st/emit! (modal/show
{:type :confirm
:title (tr "modals.delete-page.title")
:message (tr "modals.delete-page.body")
:on-accept delete-fn}))
do-duplicate #(st/emit!
(dw/duplicate-page id)
(ev/event {::ev/name "duplicate-page"}))
do-rename #(st/emit! (dw/start-rename-page-item id))]
[:*
(when deletable?
[:> menu-entry* {:title (tr "workspace.assets.delete")
:on-click do-delete}])
[:> menu-entry* {:title (tr "workspace.assets.rename")
:on-click do-rename}]
[:> menu-entry* {:title (tr "workspace.assets.duplicate")
:on-click do-duplicate}]]))
(mf/defc viewport-context-menu*
[]
(let [focus (mf/deref refs/workspace-focus-selected)
read-only? (mf/use-ctx ctx/workspace-read-only?)
do-paste #(st/emit! (dw/paste-from-clipboard))
do-hide-ui #(st/emit! (-> (dw/toggle-layout-flag :hide-ui)
(vary-meta assoc ::ev/origin "workspace-context-menu")))
do-toggle-focus-mode #(st/emit! (dw/toggle-focus-mode))]
[:*
(when-not ^boolean read-only?
[:> menu-entry* {:title (tr "workspace.shape.menu.paste")
:shortcut (sc/get-tooltip :paste)
:on-click do-paste}])
[:> menu-entry* {:title (tr "workspace.shape.menu.hide-ui")
:shortcut (sc/get-tooltip :hide-ui)
:on-click do-hide-ui}]
(when (d/not-empty? focus)
[:> menu-entry* {:title (tr "workspace.focus.focus-off")
:shortcut (sc/get-tooltip :toggle-focus-mode)
:on-click do-toggle-focus-mode}])]))
(mf/defc grid-track-context-menu*
{::mf/private true}
[{:keys [mdata]}]
(let [{:keys [type index grid-id]} mdata
do-delete-track
(mf/use-fn
(mf/deps grid-id type index)
(fn []
(st/emit! (dwsl/remove-layout-track [grid-id] type index))))
do-add-track-before
(mf/use-fn
(mf/deps grid-id type index)
(fn []
(st/emit! (dwsl/add-layout-track [grid-id] type ctl/default-track-value index))))
do-add-track-after
(mf/use-fn
(mf/deps grid-id type index)
(fn []
(st/emit! (dwsl/add-layout-track [grid-id] type ctl/default-track-value (inc index)))))
do-duplicate-track
(mf/use-fn
(mf/deps grid-id type index)
(fn []
(st/emit! (dwsl/duplicate-layout-track [grid-id] type index))))
do-delete-track-shapes
(mf/use-fn
(mf/deps grid-id type index)
(fn []
(st/emit! (dwsl/remove-layout-track [grid-id] type index {:with-shapes? true}))))]
(if (= type :column)
[:*
[:> menu-entry* {:title (tr "workspace.context-menu.grid-track.column.duplicate") :on-click do-duplicate-track}]
[:> menu-entry* {:title (tr "workspace.context-menu.grid-track.column.add-before") :on-click do-add-track-before}]
[:> menu-entry* {:title (tr "workspace.context-menu.grid-track.column.add-after") :on-click do-add-track-after}]
[:> menu-entry* {:title (tr "workspace.context-menu.grid-track.column.delete") :on-click do-delete-track}]
[:> menu-entry* {:title (tr "workspace.context-menu.grid-track.column.delete-shapes") :on-click do-delete-track-shapes}]]
[:*
[:> menu-entry* {:title (tr "workspace.context-menu.grid-track.row.duplicate") :on-click do-duplicate-track}]
[:> menu-entry* {:title (tr "workspace.context-menu.grid-track.row.add-before") :on-click do-add-track-before}]
[:> menu-entry* {:title (tr "workspace.context-menu.grid-track.row.add-after") :on-click do-add-track-after}]
[:> menu-entry* {:title (tr "workspace.context-menu.grid-track.row.delete") :on-click do-delete-track}]
[:> menu-entry* {:title (tr "workspace.context-menu.grid-track.row.delete-shapes") :on-click do-delete-track-shapes}]])))
(mf/defc grid-cells-context-menu*
{::mf/private true}
[{:keys [mdata]}]
(let [{:keys [grid cells]} mdata
grid-id (:id grid)
single? (= (count cells) 1)
can-merge?
(mf/use-memo
(mf/deps cells)
#(ctl/valid-area-cells? cells))
can-copy-rows?
(mf/use-memo
(mf/deps grid cells)
#(dwsl/complete-rows? grid cells))
can-copy-columns?
(mf/use-memo
(mf/deps grid cells)
#(dwsl/complete-columns? grid cells))
grid-edition-ref
(mf/use-memo
(mf/deps grid-id)
#(refs/workspace-grid-edition-id grid-id))
grid-edition (mf/deref grid-edition-ref)
has-copied-tracks? (some? (:copied-tracks grid-edition))
do-merge-cells
(mf/use-fn
(mf/deps grid-id cells)
(fn []
(st/emit! (dwsl/merge-cells grid-id (map :id cells)))))
do-create-board
(mf/use-fn
(mf/deps grid-id cells)
(fn []
(st/emit! (dwsl/create-cell-board grid-id (map :id cells)))))
do-copy-rows
(mf/use-fn
(mf/deps grid-id)
(fn []
(st/emit! (dwsl/copy-grid-tracks grid-id :row))))
do-copy-columns
(mf/use-fn
(mf/deps grid-id)
(fn []
(st/emit! (dwsl/copy-grid-tracks grid-id :column))))
do-paste-tracks
(mf/use-fn
(mf/deps grid-id)
(fn []
(st/emit! (dwsl/paste-grid-tracks grid-id))))]
[:*
(when (not single?)
[:> menu-entry* {:title (tr "workspace.context-menu.grid-cells.merge")
:on-click do-merge-cells
:disabled (not can-merge?)}])
(when single?
[:> menu-entry* {:title (tr "workspace.context-menu.grid-cells.area")
:on-click do-merge-cells}])
[:> menu-entry* {:title (tr "workspace.context-menu.grid-cells.create-board")
:on-click do-create-board
:disabled (and (not single?) (not can-merge?))}]
[:> menu-entry* {:title (tr "workspace.context-menu.grid-cells.copy-rows")
:on-click do-copy-rows
:disabled (not can-copy-rows?)}]
[:> menu-entry* {:title (tr "workspace.context-menu.grid-cells.copy-columns")
:on-click do-copy-columns
:disabled (not can-copy-columns?)}]
[:> menu-entry* {:title (tr "workspace.context-menu.grid-cells.paste-tracks")
:on-click do-paste-tracks
:disabled (not has-copied-tracks?)}]]))
(def guide-color-presets
["#ff3277" "#4dabf7" "#51cf66" "#fcc419" "#ff922b" "#cc5de8" "#ffffff" "#868e96"])
(mf/defc guide-color-context-menu*
{::mf/props :obj
::mf/private true}
[{:keys [mdata]}]
(let [{:keys [guide]} mdata
guide-id (:id guide)
current-color (or (:color guide) (first guide-color-presets))
do-set-color
(mf/use-fn
(mf/deps guide-id)
(fn [event]
(let [color (dom/get-data (dom/get-current-target event) "color")]
(st/emit! dw/hide-context-menu
(dwg/update-guide-color guide-id color)))))
do-remove-guide
(mf/use-fn
(mf/deps guide)
(fn []
(st/emit! dw/hide-context-menu
(dwg/remove-guide guide))))]
[:*
[:li {:class (stl/css :context-menu-item :guide-color-label)}
[:span {:class (stl/css :title)}
(tr "workspace.context-menu.guides.change-color")]]
[:li {:class (stl/css :guide-color-swatches)}
(for [color guide-color-presets]
[:span {:key color
:class (stl/css-case
:guide-color-swatch true
:selected (= color current-color))
:data-color color
:on-click do-set-color
:title color
:style {:background-color color}}])]
[:> menu-separator* {}]
[:> menu-entry* {:title (tr "workspace.context-menu.guides.remove")
:on-click do-remove-guide}]]))
;; FIXME: optimize because it is rendered always
(mf/defc context-menu*
[]
(let [mdata (mf/deref menu-ref)
top (- (get-in mdata [:position :y]) 20)
left (get-in mdata [:position :x])
dropdown-ref (mf/use-ref)
read-only? (mf/use-ctx ctx/workspace-read-only?)]
(mf/with-effect [mdata]
(when-let [dropdown (mf/ref-val dropdown-ref)]
(let [bounding-rect (dom/get-bounding-rect dropdown)
window-size (dom/get-window-size)
delta-x (max (- (+ (:right bounding-rect) 250) (:width window-size)) 0)
delta-y (max (- (:bottom bounding-rect) (:height window-size)) 0)
new-style (str "top: " (- top delta-y) "px; "
"left: " (- left delta-x) "px;")]
(when (or (> delta-x 0) (> delta-y 0))
(.setAttribute ^js dropdown "style" new-style)))))
[:& dropdown {:show (boolean mdata)
:on-close #(st/emit! dw/hide-context-menu)}
[:div {:class (stl/css :workspace-context-menu)
:ref dropdown-ref
:style {:top top :left left}
:data-testid "context-menu"
:on-context-menu prevent-default}
[:ul {:class (stl/css :context-list)}
(if ^boolean read-only?
[:> viewport-context-menu* {:mdata mdata}]
(case (:kind mdata)
:shape [:> shape-context-menu* {:mdata mdata}]
:page [:> page-item-context-menu* {:mdata mdata}]
:grid-track [:> grid-track-context-menu* {:mdata mdata}]
:grid-cells [:> grid-cells-context-menu* {:mdata mdata}]
:guide [:> guide-color-context-menu* {:mdata mdata}]
[:> viewport-context-menu* {:mdata mdata}]))]]]))