;; 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.sidebar.layers (:require-macros [app.main.style :refer [css]]) (:require [app.common.data :as d] [app.common.data.macros :as dm] [app.common.uuid :as uuid] [app.main.data.workspace :as dw] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.shape-icon-refactor :as sic] [app.main.ui.context :as ctx] [app.main.ui.hooks :as hooks] [app.main.ui.icons :as i] [app.main.ui.workspace.sidebar.layer-item :refer [layer-item]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [app.util.timers :as ts] [cuerdas.core :as str] [rumext.v2 :as mf])) ;; This components is a piece for sharding equality check between top ;; level frames and try to avoid rerender frames that are does not ;; affected by the selected set. (mf/defc frame-wrapper {::mf/wrap-props false ::mf/wrap [mf/memo #(mf/deferred % ts/idle-then-raf)]} [props] [:> layer-item props]) (mf/defc layers-tree {::mf/wrap [#(mf/memo % =) #(mf/throttle % 200)]} [{:keys [objects filtered? parent-size] :as props}] (let [selected (mf/deref refs/selected-shapes) selected (hooks/use-equal-memo selected) root (get objects uuid/zero) new-css-system (mf/use-ctx ctx/new-css-system)] [:ul {:class (if new-css-system (dom/classnames (css :element-list) true) (dom/classnames :element-list true))} [:& hooks/sortable-container {} (for [[index id] (reverse (d/enumerate (:shapes root)))] (when-let [obj (get objects id)] (if (= (:type obj) :frame) [:& frame-wrapper {:item obj :selected selected :index index :objects objects :key id :sortable? true :filtered? filtered? :parent-size parent-size :recieved-depth -1}] [:& layer-item {:item obj :selected selected :index index :objects objects :key id :sortable? true :filtered? filtered? :recieved-depth -1 :parent-size parent-size}])))]])) (mf/defc filters-tree {::mf/wrap [#(mf/memo % =) #(mf/throttle % 200)]} [{:keys [objects parent-size] :as props}] (let [selected (mf/deref refs/selected-shapes) selected (hooks/use-equal-memo selected) root (get objects uuid/zero) new-css-system (mf/use-ctx ctx/new-css-system)] [:ul {:class (if new-css-system (dom/classnames (css :element-list) true) (dom/classnames :element-list true))} (for [[index id] (d/enumerate (:shapes root))] (when-let [obj (get objects id)] [:& layer-item {:item obj :selected selected :index index :objects objects :key id :sortable? false :filtered? true :recieved-depth -1 :parent-size parent-size}]))])) (defn calc-reparented-objects [objects] (let [reparented-objects (d/mapm (fn [_ val] (assoc val :parent-id uuid/zero :shapes nil)) objects) reparented-shapes (->> reparented-objects keys (filter #(not= uuid/zero %)) vec)] (update reparented-objects uuid/zero assoc :shapes reparented-shapes))) ;; --- Layers Toolbox (defn use-search [page objects] (let [filter-state (mf/use-state {:show-search-box false :show-filters-menu false :search-text "" :active-filters #{} :num-items 100}) new-css-system (mf/use-ctx ctx/new-css-system) clear-search-text (mf/use-callback (fn [] (swap! filter-state assoc :search-text "" :num-items 100))) update-search-text (mf/use-callback (fn [event] (let [value (-> event dom/get-target dom/get-value)] (swap! filter-state assoc :search-text value :num-items 100)))) toggle-search (mf/use-callback (fn [event] (let [node (dom/get-current-target event)] (swap! filter-state assoc :search-text "") (swap! filter-state assoc :active-filters #{}) (swap! filter-state assoc :show-filters-menu false) (swap! filter-state assoc :num-items 100) (swap! filter-state update :show-search-box not) (dom/blur! node)))) toggle-filters (mf/use-callback (fn [] (swap! filter-state update :show-filters-menu not))) remove-filter (mf/use-callback (mf/deps @filter-state) (fn [key] (fn [_] (swap! filter-state update :active-filters disj key) (swap! filter-state assoc :num-items 100)))) add-filter (mf/use-callback (mf/deps @filter-state (:show-filters-menu @filter-state)) (fn [key] (fn [_] (swap! filter-state update :active-filters conj key) (swap! filter-state assoc :num-items 100) (toggle-filters)))) active? (and (:show-search-box @filter-state) (or (d/not-empty? (:search-text @filter-state)) (d/not-empty? (:active-filters @filter-state)))) search-and-filters (fn [[id shape]] (let [search (:search-text @filter-state) filters (:active-filters @filter-state) filters (cond-> filters (some #{:shape} filters) (conj :rect :circle :path :bool))] (or (= uuid/zero id) (and (or (str/includes? (str/lower (:name shape)) (str/lower search)) (str/includes? (dm/str (:id shape)) (str/lower search))) (or (empty? filters) (and (some #{:component} filters) (contains? shape :component-id)) (let [direct_filters (filter #{:frame :rect :circle :path :bool :image :text} filters)] (some #{(:type shape)} direct_filters)) (and (some #{:group} filters) (and (= :group (:type shape)) (not (contains? shape :component-id)) (or (not (contains? shape :masked-group?)) (false? (:masked-group? shape))))) (and (some #{:mask} filters) (true? (:masked-group? shape)))))))) filtered-objects-total (mf/use-memo (mf/deps objects active? @filter-state) #(when active? ;; filterv so count is constant time (filterv search-and-filters objects))) filtered-objects (mf/use-memo (mf/deps filtered-objects-total) #(when active? (calc-reparented-objects (into {} (take (:num-items @filter-state)) filtered-objects-total)))) handle-show-more (fn [] (when (<= (:num-items @filter-state) (count filtered-objects-total)) (swap! filter-state update :num-items + 100))) handle-key-down (mf/use-callback (fn [event] (let [enter? (kbd/enter? event) esc? (kbd/esc? event) input-node (dom/event->target event)] (when enter? (dom/blur! input-node)) (when esc? (dom/blur! input-node)))))] [filtered-objects handle-show-more (mf/html (if (:show-search-box @filter-state) [:* [:div {:class (if new-css-system (dom/classnames (css :tool-window-bar) true (css :search) true) (dom/classnames :tool-window-bar true :search true))} [:span {:class (if new-css-system (dom/classnames (css :search-box) true) (dom/classnames :search-box true))} [:button {:on-click toggle-filters :class (if new-css-system (dom/classnames :active active? (css :filter-button) true) (dom/classnames :active active? :filter true))} (if new-css-system i/filter-refactor i/icon-filter)] [:div {:class (dom/classnames (css :search-input-wrapper) new-css-system)} [:input {:on-change update-search-text :value (:search-text @filter-state) :auto-focus (:show-search-box @filter-state) :placeholder (tr "workspace.sidebar.layers.search") :on-key-down handle-key-down}] (when (not (= "" (:search-text @filter-state))) [:button {:class (if new-css-system (dom/classnames (css :clear) true) (dom/classnames :clear true)) :on-click clear-search-text} (if new-css-system i/delete-text-refactor i/exclude)])]] [:button {:class (dom/classnames (css :close-search) new-css-system) :on-click toggle-search} (if new-css-system i/close-refactor i/cross)]] [:div {:class (if new-css-system (dom/classnames (css :active-filters) true) (dom/classnames :active-filters true))} (for [f (:active-filters @filter-state)] (let [name (case f :frame (tr "workspace.sidebar.layers.frames") :group (tr "workspace.sidebar.layers.groups") :mask (tr "workspace.sidebar.layers.masks") :component (tr "workspace.sidebar.layers.components") :text (tr "workspace.sidebar.layers.texts") :image (tr "workspace.sidebar.layers.images") :shape (tr "workspace.sidebar.layers.shapes") (tr f))] (if new-css-system [:button {:class (dom/classnames (css :layer-filter) true) :on-click (remove-filter f)} [:span {:class (dom/classnames (css :layer-filter-icon) true)} [:& sic/element-icon-refactor-by-type {:type f :main-instance? (= f :component)}]] [:span {:class (dom/classnames (css :layer-filter-name) true)} name] [:span {:class (dom/classnames (css :layer-filter-close) true)} i/close-small-refactor]] [:span {:on-click (remove-filter f)} name i/cross])))] (when (:show-filters-menu @filter-state) (if new-css-system [:ul {:class (dom/classnames (css :filters-container) true)} [:li {:key "frames-filter-item" :class (dom/classnames (css :filter-menu-item) true (css :selected) (contains? (:active-filters @filter-state) :frame)) :on-click (add-filter :frame)} [:div {:class (dom/classnames (css :filter-menu-item-name-wrapper) true)} [:span {:class (dom/classnames (css :filter-menu-item-icon) true)} i/board-refactor] [:span {:class (dom/classnames (css :filter-menu-item-name) true)} (tr "workspace.sidebar.layers.frames")]] (when (contains? (:active-filters @filter-state) :frame) [:span {:class (dom/classnames (css :filter-menu-item-tick) true)} i/tick-refactor])] [:li {:key "groups-filter-item" :class (dom/classnames (css :filter-menu-item) true (css :selected) (contains? (:active-filters @filter-state) :group)) :on-click (add-filter :group)} [:div {:class (dom/classnames (css :filter-menu-item-name-wrapper) true)} [:span {:class (dom/classnames (css :filter-menu-item-icon) true)} i/group-refactor] [:span {:class (dom/classnames (css :filter-menu-item-name) true)} (tr "workspace.sidebar.layers.groups")]] (when (contains? (:active-filters @filter-state) :group) [:span {:class (dom/classnames (css :filter-menu-item-tick) true)} i/tick-refactor])] [:li {:key "masks-filter-item" :class (dom/classnames (css :filter-menu-item) true (css :selected) (contains? (:active-filters @filter-state) :mask)) :on-click (add-filter :mask)} [:div {:class (dom/classnames (css :filter-menu-item-name-wrapper) true)} [:span {:class (dom/classnames (css :filter-menu-item-icon) true)} i/mask-refactor] [:span {:class (dom/classnames (css :filter-menu-item-name) true)} (tr "workspace.sidebar.layers.masks")]] (when (contains? (:active-filters @filter-state) :mask) [:span {:class (dom/classnames (css :filter-menu-item-tick) true)} i/tick-refactor])] [:li {:key "components-filter-item" :class (dom/classnames (css :filter-menu-item) true (css :selected) (contains? (:active-filters @filter-state) :component)) :on-click (add-filter :component)} [:div {:class (dom/classnames (css :filter-menu-item-name-wrapper) true)} [:span {:class (dom/classnames (css :filter-menu-item-icon) true)} i/component-refactor] [:span {:class (dom/classnames (css :filter-menu-item-name) true)} (tr "workspace.sidebar.layers.components")]] (when (contains? (:active-filters @filter-state) :component) [:span {:class (dom/classnames (css :filter-menu-item-tick) true)} i/tick-refactor])] [:li {:key "texts-filter-item" :class (dom/classnames (css :filter-menu-item) true (css :selected) (contains? (:active-filters @filter-state) :text)) :on-click (add-filter :text)} [:div {:class (dom/classnames (css :filter-menu-item-name-wrapper) true)} [:span {:class (dom/classnames (css :filter-menu-item-icon) true)} i/text-refactor] [:span {:class (dom/classnames (css :filter-menu-item-name) true)} (tr "workspace.sidebar.layers.texts")]] (when (contains? (:active-filters @filter-state) :text) [:span {:class (dom/classnames (css :filter-menu-item-tick) true)} i/tick-refactor])] [:li {:key "images-filter-item" :class (dom/classnames (css :filter-menu-item) true (css :selected) (contains? (:active-filters @filter-state) :image)) :on-click (add-filter :image)} [:div {:class (dom/classnames (css :filter-menu-item-name-wrapper) true)} [:span {:class (dom/classnames (css :filter-menu-item-icon) true)} i/img-refactor] [:span {:class (dom/classnames (css :filter-menu-item-name) true)} (tr "workspace.sidebar.layers.images")]] (when (contains? (:active-filters @filter-state) :image) [:span {:class (dom/classnames (css :filter-menu-item-tick) true)} i/tick-refactor])] [:li {:key "shapes-filter-item" :class (dom/classnames (css :filter-menu-item) true (css :selected) (contains? (:active-filters @filter-state) :shape)) :on-click (add-filter :shape)} [:div {:class (dom/classnames (css :filter-menu-item-name-wrapper) true)} [:span {:class (dom/classnames (css :filter-menu-item-icon) true)} i/path-refactor] [:span {:class (dom/classnames (css :filter-menu-item-name) true)} (tr "workspace.sidebar.layers.shapes")]] (when (contains? (:active-filters @filter-state) :shape) [:span {:class (dom/classnames (css :filter-menu-item-tick) true)} i/tick-refactor])]] [:div.filters-container [:span {:on-click (add-filter :frame)} i/artboard (tr "workspace.sidebar.layers.frames")] [:span {:on-click (add-filter :group)} i/folder (tr "workspace.sidebar.layers.groups")] [:span {:on-click (add-filter :mask)} i/mask (tr "workspace.sidebar.layers.masks")] [:span {:on-click (add-filter :component)} i/component (tr "workspace.sidebar.layers.components")] [:span {:on-click (add-filter :text)} i/text (tr "workspace.sidebar.layers.texts")] [:span {:on-click (add-filter :image)} i/image (tr "workspace.sidebar.layers.images")] [:span {:on-click (add-filter :shape)} i/curve (tr "workspace.sidebar.layers.shapes")]]))] [:div {:class (if new-css-system (dom/classnames (css :tool-window-bar) true) (dom/classnames :tool-window-bar true))} [:span {:class (if new-css-system (dom/classnames (css :page-name) true) (dom/classnames :page-name true))} (:name page)] [:button {:class (if new-css-system (dom/classnames (css :icon-search) true) (dom/classnames :icon-search true)) :on-click toggle-search} (if new-css-system i/search-refactor i/search)]]))])) (mf/defc layers-toolbox {:wrap [mf/memo]} [{:keys [size-parent] :as props}] (let [page (mf/deref refs/workspace-page) focus (mf/deref refs/workspace-focus-selected) objects (hooks/with-focus-objects (:objects page) focus) title (when (= 1 (count focus)) (get-in objects [(first focus) :name])) new-css-system (mf/use-ctx ctx/new-css-system) observer-var (mf/use-var nil) lazy-load-ref (mf/use-ref nil) [filtered-objects show-more filter-component] (use-search page objects) intersection-callback (fn [entries] (when (and (.-isIntersecting (first entries)) (some? show-more)) (show-more))) on-render-container (fn [element] (let [options #js {:root element} lazy-el (mf/ref-val lazy-load-ref)] (cond (and (some? element) (not (some? @observer-var))) (let [observer (js/IntersectionObserver. intersection-callback options)] (.observe observer lazy-el) (reset! observer-var observer)) (and (nil? element) (some? @observer-var)) (do (.disconnect @observer-var) (reset! observer-var nil))))) on-scroll (fn [event] (let [children (dom/get-elements-by-class "sticky-children") length (.-length children)] (when (< 0 length) (let [target (dom/get-target event) target-top (:top (dom/get-bounding-rect target)) frames (dom/get-elements-by-class "root-board") last-hidden-frame (->> frames (filter #(<= (- (:top (dom/get-bounding-rect %)) target-top) 0)) last) frame-id (dom/get-attribute last-hidden-frame "id") last-hidden-children (->> children (filter #(< (- (:top (dom/get-bounding-rect %)) target-top) 0)) last) is-children-shown? (and last-hidden-children (> (- (:bottom (dom/get-bounding-rect last-hidden-children)) target-top) 0)) children-frame-id (dom/get-attribute last-hidden-children "data-id") ;; We want to check that root-board is out of view but its children are not. ;; only in that case we make root board sticky. sticky? (and last-hidden-frame is-children-shown? (= frame-id children-frame-id))] (doseq [frame frames] (dom/remove-class! frame "sticky")) (when sticky? (dom/add-class! last-hidden-frame "sticky"))))))] [:div#layers {:class (if new-css-system (dom/classnames (css :layers) true) (dom/classnames :tool-window true))} (if (d/not-empty? focus) [:div {:class (if new-css-system (dom/classnames (css :tool-window-bar) true) (dom/classnames :tool-window-bar true))} [:button {:class (if new-css-system (dom/classnames (css :focus-title) true) (dom/classnames :focus-title true)) :on-click #(st/emit! (dw/toggle-focus-mode))} [:span {:class (if new-css-system (dom/classnames (css :back-button) true) (dom/classnames :back-button true))} (if new-css-system i/arrow-refactor i/arrow-slide)] [:div {:class (if new-css-system (dom/classnames (css :focus-name) true) (dom/classnames :focus-name true))} (or title (tr "workspace.sidebar.layers"))] (if new-css-system [:div {:class (dom/classnames (css :focus-mode-tag-wrapper) true)} [:div {:class (dom/classnames (css :focus-mode-tag) true)} (tr "workspace.focus.focus-mode")]] [:div.focus-mode (tr "workspace.focus.focus-mode")])]] filter-component) (if (some? filtered-objects) [:* [:div {:class (if new-css-system (dom/classnames (css :tool-window-content) true) (dom/classnames :tool-window-content true)) :ref on-render-container :key "filters"} [:& filters-tree {:objects filtered-objects :key (dm/str (:id page)) :parent-size size-parent}] [:div.lazy {:ref lazy-load-ref :key "lazy-load" :style {:min-height 16}}]] [:div {:on-scroll on-scroll :class (if new-css-system (dom/classnames (css :tool-window-content) true) (dom/classnames :tool-window-content true)) :style {:display (when (some? filtered-objects) "none")}} [:& layers-tree {:objects filtered-objects :key (dm/str (:id page)) :filtered? true :parent-size size-parent}]]] [:div {:on-scroll on-scroll :class (if new-css-system (dom/classnames (css :tool-window-content) true) (dom/classnames :tool-window-content true)) :style {:display (when (some? filtered-objects) "none")}} [:& layers-tree {:objects objects :key (dm/str (:id page)) :filtered? false :parent-size size-parent}]])]))