diff --git a/backend/src/app/migrations/clj/migration_0023.clj b/backend/src/app/migrations/clj/migration_0023.clj index 6e928028c6..b2027d2275 100644 --- a/backend/src/app/migrations/clj/migration_0023.clj +++ b/backend/src/app/migrations/clj/migration_0023.clj @@ -58,4 +58,3 @@ (when (nil? (:data file)) (migrate-file conn file))) (db/exec-one! conn ["drop table page cascade;"]))) - diff --git a/frontend/playwright/ui/pages/WorkspacePage.js b/frontend/playwright/ui/pages/WorkspacePage.js index a4624ce37c..92f9b9bef2 100644 --- a/frontend/playwright/ui/pages/WorkspacePage.js +++ b/frontend/playwright/ui/pages/WorkspacePage.js @@ -404,6 +404,8 @@ export class WorkspacePage extends BaseWebSocketPage { return content !== ""; }, { timeout: 1000 }); + await this.page.waitForTimeout(3000); + } /** @@ -417,7 +419,8 @@ export class WorkspacePage extends BaseWebSocketPage { await this.viewport.click({ button: "right" }); return this.page.getByText("Paste", { exact: true }).click(); } - return this.page.keyboard.press("ControlOrMeta+V"); + await this.page.keyboard.press("ControlOrMeta+V"); + await this.page.waitForTimeout(3000); } async panOnViewportAt(x, y, width, height) { diff --git a/frontend/playwright/ui/specs/variants.spec.js b/frontend/playwright/ui/specs/variants.spec.js index 9cbe4ca440..b5dc3d751d 100644 --- a/frontend/playwright/ui/specs/variants.spec.js +++ b/frontend/playwright/ui/specs/variants.spec.js @@ -383,24 +383,26 @@ test("User cut paste a component with path inside a variant", async ({ const variant = await findVariant(workspacePage, 0); - //Create a component + // Create a component await workspacePage.ellipseShapeButton.click(); await workspacePage.clickWithDragViewportAt(500, 500, 20, 20); await workspacePage.clickLeafLayer("Ellipse"); await workspacePage.page.keyboard.press("ControlOrMeta+k"); + await workspacePage.page.waitForTimeout(3000); - //Rename the component + // Rename the component await workspacePage.layers.getByText("Ellipse").dblclick(); await workspacePage.page .getByTestId("layer-item") .getByRole("textbox") .pressSequentially("button / hover"); await workspacePage.page.keyboard.press("Enter"); + await workspacePage.page.waitForTimeout(3000); - //Cut the component + // Cut the component await workspacePage.cut("keyboard"); - //Paste the component inside the variant + // Paste the component inside the variant await variant.container.click(); await workspacePage.paste("keyboard"); @@ -427,6 +429,7 @@ test("User drag and drop a component with path inside a variant", async ({ await workspacePage.clickWithDragViewportAt(500, 500, 20, 20); await workspacePage.clickLeafLayer("Ellipse"); await workspacePage.page.keyboard.press("ControlOrMeta+k"); + await workspacePage.page.waitForTimeout(3000); //Rename the component await workspacePage.layers.getByText("Ellipse").dblclick(); diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 50cdd0e1b6..359be1179f 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -183,9 +183,6 @@ [id] (l/derived #(contains? % id) selected-shapes)) -(def highlighted-shapes - (l/derived :highlighted workspace-local)) - (def export-in-progress? (l/derived :export-in-progress? export)) diff --git a/frontend/src/app/main/ui/inspect/left_sidebar.cljs b/frontend/src/app/main/ui/inspect/left_sidebar.cljs index 6f521decc5..e8ef179e92 100644 --- a/frontend/src/app/main/ui/inspect/left_sidebar.cljs +++ b/frontend/src/app/main/ui/inspect/left_sidebar.cljs @@ -12,7 +12,7 @@ [app.common.types.component :as ctk] [app.main.data.viewer :as dv] [app.main.store :as st] - [app.main.ui.workspace.sidebar.layer-item :refer [layer-item-inner]] + [app.main.ui.workspace.sidebar.layer-item :refer [layer-item-inner*]] [app.util.dom :as dom] [app.util.keyboard :as kbd] [okulary.core :as l] @@ -26,7 +26,6 @@ (mf/defc layer-item [{:keys [item selected objects depth component-child? hide-toggle?] :as props}] (let [id (:id item) - hidden? (:hidden item) selected? (contains? selected id) item-ref (mf/use-ref nil) depth (+ depth 1) @@ -68,18 +67,17 @@ (when (and (= (count selected) 1) selected?) (dom/scroll-into-view-if-needed! (mf/ref-val item-ref) true)))) - [:& layer-item-inner + [:> layer-item-inner* {:ref item-ref :item item :depth depth - :read-only? true - :highlighted? false - :selected? selected? - :component-tree? component-tree? - :hidden? hidden? - :filtered? false - :expanded? expanded? - :hide-toggle? hide-toggle? + :is-read-only true + :is-highlighted false + :is-selected selected? + :is-component-tree component-tree? + :is-filtered false + :is-expanded expanded? + :hide-toggle hide-toggle? :on-select-shape select-shape :on-toggle-collapse toggle-collapse} diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs index 550e072d07..4e1028dc7a 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs @@ -10,6 +10,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] + [app.common.math :as mth] [app.common.types.component :as ctk] [app.common.types.components-list :as ctkl] [app.common.types.container :as ctn] @@ -37,6 +38,8 @@ (defonce ^:private sidebar-hover-queue (atom {:enter #{} :leave #{}})) (defonce ^:private sidebar-hover-pending? (atom false)) +(def ^:const default-chunk-size 50) + (defn- schedule-sidebar-hover-flush [] (when (compare-and-set! sidebar-hover-pending? false true) (ts/raf @@ -48,12 +51,11 @@ (when (seq enter) (apply st/emit! (map dw/highlight-shape enter)))))))) -(mf/defc layer-item-inner - {::mf/wrap-props false} - [{:keys [item depth parent-size name-ref children ref style +(mf/defc layer-item-inner* + [{:keys [item depth parent-size name-ref children ref style rename-id ;; Flags - read-only? highlighted? selected? component-tree? - filtered? expanded? dnd-over? dnd-over-top? dnd-over-bot? hide-toggle? + is-read-only is-highlighted is-selected is-component-tree + is-filtered is-expanded dnd-over dnd-over-top dnd-over-bot hide-toggle ;; Callbacks on-select-shape on-context-menu on-pointer-enter on-pointer-leave on-zoom-to-selected on-toggle-collapse on-enable-drag on-disable-drag on-toggle-visibility on-toggle-blocking]}] @@ -64,7 +66,7 @@ hidden? (:hidden item) has-shapes? (-> item :shapes seq boolean) touched? (-> item :touched seq boolean) - parent-board? (and (cfh/frame-shape? item) + root-board? (and (cfh/frame-shape? item) (= uuid/zero (:parent-id item))) absolute? (ctl/item-absolute? item) is-variant? (ctk/is-variant? item) @@ -73,9 +75,11 @@ variant-name (when is-variant? (:variant-name item)) variant-error (when is-variant? (:variant-error item)) - data (deref refs/workspace-data) - component (ctkl/get-component data (:component-id item)) - variant-properties (:variant-properties component) + component-id (get item :component-id) + data (mf/deref refs/workspace-data) + variant-properties (-> (ctkl/get-component data component-id) + (get :variant-properties)) + icon-shape (usi/get-shape-icon item)] [:* @@ -88,27 +92,27 @@ :aria-checked selected? :class (stl/css-case :layer-row true - :highlight highlighted? + :highlight is-highlighted :component (ctk/instance-head? item) :masked (:masked-group item) - :selected selected? + :selected is-selected :type-frame (cfh/frame-shape? item) :type-bool (cfh/bool-shape? item) - :type-comp (or component-tree? is-variant-container?) + :type-comp (or is-component-tree is-variant-container?) :hidden hidden? - :dnd-over dnd-over? - :dnd-over-top dnd-over-top? - :dnd-over-bot dnd-over-bot? - :root-board parent-board?) + :dnd-over dnd-over + :dnd-over-top dnd-over-top + :dnd-over-bot dnd-over-bot + :root-board root-board?) :style style} [:span {:class (stl/css-case :tab-indentation true - :filtered filtered?) + :filtered is-filtered) :style {"--depth" depth}}] [:div {:class (stl/css-case :element-list-body true - :filtered filtered? - :selected selected? + :filtered is-filtered + :selected is-selected :icon-layer (= (:type item) :icon)) :style {"--depth" depth} :on-pointer-enter on-pointer-enter @@ -117,12 +121,12 @@ (if (< 0 (count (:shapes item))) [:div {:class (stl/css :button-content)} - (when (and (not hide-toggle?) (not filtered?)) + (when (and (not hide-toggle) (not is-filtered)) [:button {:class (stl/css-case :toggle-content true - :inverse expanded?) + :inverse is-expanded) :data-testid "toggle-content" - :aria-expanded expanded? + :aria-expanded is-expanded :on-click on-toggle-collapse} deprecated-icon/arrow]) @@ -133,7 +137,7 @@ [:> icon* {:icon-id icon-shape :size "s" :data-testid (str "icon-" icon-shape)}]]] [:div {:class (stl/css :button-content)} - (when (not ^boolean filtered?) + (when (not ^boolean is-filtered) [:span {:class (stl/css :toggle-content)}]) [:div {:class (stl/css :icon-shape) :on-double-click on-zoom-to-selected} @@ -142,25 +146,26 @@ [:> icon* {:icon-id icon-shape :size "s" :data-testid (str "icon-" icon-shape)}]]]) [:> layer-name* {:ref name-ref + :rename-id rename-id :shape-id id :shape-name name :is-shape-touched touched? - :disabled-double-click read-only? + :disabled-double-click is-read-only :on-start-edit on-disable-drag :on-stop-edit on-enable-drag :depth depth :is-blocked blocked? :parent-size parent-size - :is-selected selected? - :type-comp (or component-tree? is-variant-container?) + :is-selected is-selected + :type-comp (or is-component-tree is-variant-container?) :type-frame (cfh/frame-shape? item) :variant-id variant-id :variant-name variant-name :variant-properties variant-properties :variant-error variant-error - :component-id (:id component) + :component-id component-id :is-hidden hidden?}]] - (when (not read-only?) + (when (not ^boolean is-read-only) [:div {:class (stl/css-case :element-actions true :is-parent has-shapes? @@ -185,41 +190,86 @@ children])) -;; Memoized for performance -(mf/defc layer-item - {::mf/props :obj - ::mf/wrap [mf/memo]} - [{:keys [index item selected objects sortable? filtered? depth parent-size component-child? highlighted style render-children?] - :or {render-children? true}}] - (let [id (:id item) - blocked? (:blocked item) - hidden? (:hidden item) +(mf/defc layer-item* + {::mf/wrap [mf/memo]} + [{:keys [index item selected objects rename-id + is-sortable is-filtered depth is-component-child + highlighted style render-children parent-size] + :or {render-children true}}] + (let [id (get item :id) + blocked? (get item :blocked) + hidden? (get item :hidden) + + shapes (get item :shapes) + shapes (mf/with-memo [shapes objects] + (loop [counter 0 + shapes (seq shapes) + result (list)] + + (if-let [id (first shapes)] + (if-let [obj (get objects id)] + (do + ;; NOTE: this is a bit hacky, but reduces substantially + ;; the allocation; If we use enumeration, we allocate + ;; new sequence and add one iteration on each render, + ;; independently if objects are changed or not. If we + ;; store counter on metadata, we still need to create a + ;; new allocation for each shape; with this method we + ;; bypass this by mutating a private property on the + ;; object removing extra allocation and extra iteration + ;; on every request. + (unchecked-set obj "__$__counter" counter) + (recur (inc counter) + (rest shapes) + (conj result obj))) + (recur (inc counter) + (rest shapes) + result)) + + (-> result vec not-empty)))) drag-disabled* (mf/use-state false) drag-disabled? (deref drag-disabled*) - scroll-to-middle? (mf/use-var true) + scroll-middle-ref (mf/use-ref true) expanded-iref (mf/with-memo [id] - (-> (l/in [:expanded id]) - (l/derived refs/workspace-local))) - expanded? (mf/deref expanded-iref) + (l/derived #(dm/get-in % [:expanded id]) refs/workspace-local)) + is-expanded (mf/deref expanded-iref) - selected? (contains? selected id) - highlighted? (contains? highlighted id) + is-selected (contains? selected id) + is-highlighted (contains? highlighted id) container? (or (cfh/frame-shape? item) (cfh/group-shape? item)) - read-only? (mf/use-ctx ctx/workspace-read-only?) - parent-board? (and (cfh/frame-shape? item) + is-read-only (mf/use-ctx ctx/workspace-read-only?) + root-board? (and (cfh/frame-shape? item) (= uuid/zero (:parent-id item))) + name-node-ref (mf/use-ref) + + depth (+ depth 1) + + is-component-tree (or ^boolean is-component-child + ^boolean (ctk/instance-root? item) + ^boolean (ctk/instance-head? item)) + + enable-drag (mf/use-fn #(reset! drag-disabled* false)) + disable-drag (mf/use-fn #(reset! drag-disabled* true)) + + ;; Lazy loading of child elements via IntersectionObserver + children-count* (mf/use-state 0) + children-count (deref children-count*) + + lazy-ref (mf/use-ref nil) + observer-ref (mf/use-ref nil) + toggle-collapse (mf/use-fn - (mf/deps expanded?) + (mf/deps is-expanded) (fn [event] (dom/stop-propagation event) - (if (and expanded? (kbd/shift? event)) + (if (and is-expanded (kbd/shift? event)) (st/emit! (dwc/collapse-all)) (st/emit! (dwc/toggle-collapse id))))) @@ -244,13 +294,13 @@ select-shape (mf/use-fn - (mf/deps id filtered? objects) + (mf/deps id is-filtered objects) (fn [event] (dom/prevent-default event) - (reset! scroll-to-middle? false) + (mf/set-ref-val! scroll-middle-ref false) (cond (kbd/shift? event) - (if filtered? + (if is-filtered (st/emit! (dw/shift-select-shapes id objects)) (st/emit! (dw/shift-select-shapes id))) @@ -285,11 +335,11 @@ on-context-menu (mf/use-fn - (mf/deps item read-only?) + (mf/deps item is-read-only) (fn [event] (dom/prevent-default event) (dom/stop-propagation event) - (when-not read-only? + (when-not is-read-only (let [pos (dom/get-client-position event)] (st/emit! (dw/show-shape-context-menu {:position pos :shape item})))))) @@ -302,7 +352,7 @@ on-drop (mf/use-fn - (mf/deps id objects expanded? selected) + (mf/deps id objects is-expanded selected) (fn [side _data] (let [single? (= (count selected) 1) same? (and single? (= (first selected) id))] @@ -315,32 +365,34 @@ (= side :center) id - (and expanded? (= side :bot) (d/not-empty? (:shapes shape))) + (and is-expanded (= side :bot) (d/not-empty? (:shapes shape))) id :else (cfh/get-parent-id objects id)) - [parent-id _] (ctn/find-valid-parent-and-frame-ids parent-id objects (map #(get objects %) selected) false files) + [parent-id _] + (ctn/find-valid-parent-and-frame-ids parent-id objects (map #(get objects %) selected) false files) parent (get objects parent-id) current-index (d/index-of (:shapes parent) id) to-index (cond (= side :center) 0 - (and expanded? (= side :bot) (d/not-empty? (:shapes shape))) (count (:shapes parent)) + (and is-expanded (= side :bot) (d/not-empty? (:shapes shape))) (count (:shapes parent)) ;; target not found in parent (while lazy loading) (neg? current-index) nil (= side :top) (inc current-index) :else current-index)] + (when (some? to-index) (st/emit! (dw/relocate-selected-shapes parent-id to-index)))))))) on-hold (mf/use-fn - (mf/deps id expanded?) + (mf/deps id is-expanded) (fn [] - (when-not expanded? + (when-not is-expanded (st/emit! (dwc/toggle-collapse id))))) zoom-to-selected @@ -361,112 +413,114 @@ :data {:id (:id item) :index index :name (:name item)} - :draggable? (and - sortable? - (not read-only?) - (not (ctn/has-any-copy-parent? objects item)))) ;; We don't want to change the structure of component copies + ;; We don't want to change the structure of component copies + :draggable? (and ^boolean is-sortable + ^boolean (not is-read-only) + ^boolean (not (ctn/has-any-copy-parent? objects item))))] - ref (mf/use-ref) - depth (+ depth 1) - component-tree? (or component-child? (ctk/instance-root? item) (ctk/instance-head? item)) - - enable-drag (mf/use-fn #(reset! drag-disabled* false)) - disable-drag (mf/use-fn #(reset! drag-disabled* true)) - - ;; Lazy loading of child elements via IntersectionObserver - children-count* (mf/use-state 0) - children-count (deref children-count*) - lazy-ref (mf/use-ref nil) - observer-var (mf/use-var nil) - chunk-size 50] - - (mf/with-effect [selected? selected] + (mf/with-effect [is-selected selected] (let [single? (= (count selected) 1) - node (mf/ref-val ref) - scroll-node (dom/get-parent-with-data node "scroll-container") - parent-node (dom/get-parent-at node 2) - first-child-node (dom/get-first-child parent-node) + node (mf/ref-val name-node-ref) + scroll-node (dom/get-parent-with-data node "scroll-container") + parent-node (dom/get-parent-at node 2) + first-child-node (dom/get-first-child parent-node) + scroll-to-middle? (mf/ref-val scroll-middle-ref) subid - (when (and single? selected? @scroll-to-middle?) + (when (and ^boolean single? + ^boolean is-selected + ^boolean scroll-to-middle?) (ts/schedule 100 #(when (and node scroll-node) (let [scroll-distance-ratio (dom/get-scroll-distance-ratio node scroll-node) scroll-behavior (if (> scroll-distance-ratio 1) "instant" "smooth")] (dom/scroll-into-view-if-needed! first-child-node #js {:block "center" :behavior scroll-behavior :inline "start"}) - (reset! scroll-to-middle? true)))))] + (mf/set-ref-val! scroll-middle-ref true)))))] #(when (some? subid) (rx/dispose! subid)))) ;; Setup scroll-driven lazy loading when expanded ;; and ensures selected children are loaded immediately - (mf/with-effect [expanded? (:shapes item) selected] - (let [shapes-vec (:shapes item) - total (count shapes-vec)] - (if expanded? + (mf/with-effect [is-expanded shapes selected] + (let [total (count shapes)] + (if ^boolean is-expanded (let [;; Children are rendered in reverse order, so index 0 in render = last in shapes-vec ;; Find if any selected id is a direct child and get its render index selected-child-render-idx - (when (and (> total chunk-size) (seq selected)) - (let [shapes-reversed (vec (reverse shapes-vec))] - (some (fn [sel-id] - (let [idx (.indexOf shapes-reversed sel-id)] - (when (>= idx 0) idx))) - selected))) + (when (> total default-chunk-size) + (some (fn [sel-id] + (let [idx (.indexOf shapes sel-id)] + (when (>= idx 0) idx))) + selected)) + ;; Load at least enough to include the selected child plus extra ;; for context (so it can be centered in the scroll view) - min-count (if selected-child-render-idx - (+ selected-child-render-idx chunk-size) - chunk-size) - current @children-count* - new-count (min total (max current chunk-size min-count))] + min-count + (if selected-child-render-idx + (+ selected-child-render-idx default-chunk-size) + default-chunk-size) + + current-count + @children-count* + + new-count + (mth/min total (mth/max current-count default-chunk-size min-count))] + (reset! children-count* new-count)) - (reset! children-count* 0)))) + + (reset! children-count* 0)) + + (fn [] + (when-let [obs (mf/ref-val observer-ref)] + (.disconnect obs) + (mf/set-ref-val! obs nil))))) ;; Re-observe sentinel whenever children-count changes (sentinel moves) ;; and (shapes item) to reconnect observer after shape changes - (mf/with-effect [children-count expanded? (:shapes item)] - (let [total (count (:shapes item)) - node (mf/ref-val ref) - scroll-node (dom/get-parent-with-data node "scroll-container") - lazy-node (mf/ref-val lazy-ref)] + (mf/with-effect [children-count is-expanded shapes] + (let [total (count shapes) + name-node (mf/ref-val name-node-ref) + scroll-node (dom/get-parent-with-data name-node "scroll-container") + lazy-node (mf/ref-val lazy-ref)] + ;; Disconnect previous observer - (when-let [obs ^js @observer-var] + (when-let [obs (mf/ref-val observer-ref)] (.disconnect obs) - (reset! observer-var nil)) + (mf/set-ref-val! observer-ref nil)) + ;; Setup new observer if there are more children to load - (when (and expanded? - (< children-count total) - scroll-node - lazy-node) + (when (and ^boolean is-expanded + ^boolean (< children-count total) + ^boolean scroll-node + ^boolean lazy-node) (let [cb (fn [entries] - (when (and (seq entries) - (.-isIntersecting (first entries))) + (when (and (pos? (alength entries)) + (.-isIntersecting ^js (aget entries 0))) ;; Load next chunk when sentinel intersects - (let [current @children-count* - next-count (min total (+ current chunk-size))] + (let [next-count (mth/min total (+ children-count default-chunk-size))] (reset! children-count* next-count)))) observer (js/IntersectionObserver. cb #js {:root scroll-node})] (.observe observer lazy-node) - (reset! observer-var observer))))) + (mf/set-ref-val! observer-ref observer))))) - [:& layer-item-inner + [:> layer-item-inner* {:ref dref :item item :depth depth :parent-size parent-size - :name-ref ref - :read-only? read-only? - :highlighted? highlighted? - :selected? selected? - :component-tree? component-tree? - :filtered? filtered? - :expanded? expanded? - :dnd-over? (= (:over dprops) :center) - :dnd-over-top? (= (:over dprops) :top) - :dnd-over-bot? (= (:over dprops) :bot) + :name-ref name-node-ref + :rename-id rename-id + :is-read-only is-read-only + :is-highlighted is-highlighted + :is-selected is-selected + :is-component-tree is-component-tree + :is-filtered is-filtered + :is-expanded is-expanded + :dnd-over (= (:over dprops) :center) + :dnd-over-top (= (:over dprops) :top) + :dnd-over-bot (= (:over dprops) :bot) :on-select-shape select-shape :on-context-menu on-context-menu :on-pointer-enter on-pointer-enter @@ -479,29 +533,28 @@ :on-toggle-blocking toggle-blocking :style style} - (when (and render-children? - (:shapes item) - expanded?) + (when (and ^boolean render-children + ^boolean shapes + ^boolean is-expanded) [:div {:class (stl/css-case :element-children true - :parent-selected selected? - :sticky-children parent-board?) + :parent-selected is-selected + :sticky-children root-board?) :data-testid (dm/str "children-" id)} - (let [all-children (reverse (d/enumerate (:shapes item))) - visible (take children-count all-children)] - (for [[index id] visible] - (when-let [item (get objects id)] - [:& layer-item - {:item item - :highlighted highlighted - :selected selected - :index index - :objects objects - :key (dm/str id) - :sortable? sortable? - :depth depth - :parent-size parent-size - :component-child? component-tree?}]))) - (when (< children-count (count (:shapes item))) + (for [item (take children-count shapes)] + [:> layer-item* + {:item item + :rename-id rename-id + :highlighted highlighted + :selected selected + :index (unchecked-get item "__$__counter") + :objects objects + :key (dm/str (get item :id)) + :is-sortable is-sortable + :depth depth + :parent-size parent-size + :is-component-child is-component-tree}]) + + (when (< children-count (count shapes)) [:div {:ref lazy-ref :class (stl/css :lazy-load-sentinel)}])])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs index ffe019638d..2c8e78fc57 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs @@ -16,39 +16,35 @@ [app.util.dom :as dom] [app.util.keyboard :as kbd] [cuerdas.core :as str] - [okulary.core :as l] [rumext.v2 :as mf])) -(def ^:private space-for-icons 110) - -(def lens:shape-for-rename - (-> (l/in [:workspace-local :shape-for-rename]) - (l/derived st/state))) +(def ^:private ^:const space-for-icons 110) (mf/defc layer-name* - {::mf/wrap-props false - ::mf/forward-ref true} - [{:keys [shape-id shape-name is-shape-touched disabled-double-click + [{:keys [shape-id rename-id shape-name is-shape-touched disabled-double-click on-start-edit on-stop-edit depth parent-size is-selected type-comp type-frame component-id is-hidden is-blocked - variant-id variant-name variant-properties variant-error]} external-ref] + variant-id variant-name variant-properties variant-error ref]}] + (let [edition* (mf/use-state false) edition? (deref edition*) local-ref (mf/use-ref) - ref (d/nilv external-ref local-ref) + ref (d/nilv ref local-ref) - shape-for-rename (mf/deref lens:shape-for-rename) + shape-name + (if variant-id + (d/nilv variant-error variant-name) + shape-name) - shape-name (if variant-id - (d/nilv variant-error variant-name) - shape-name) + default-value + (mf/with-memo [variant-id variant-error variant-properties] + (if variant-id + (or variant-error (ctv/properties-map->formula variant-properties)) + shape-name)) - default-value (if variant-id - (or variant-error (ctv/properties-map->formula variant-properties)) - shape-name) - - has-path? (str/includes? shape-name "/") + has-path? + (str/includes? shape-name "/") start-edit (mf/use-fn @@ -85,10 +81,11 @@ (when (kbd/enter? event) (accept-edit)) (when (kbd/esc? event) (cancel-edit)))) - parent-size (dm/str (- parent-size space-for-icons) "px")] + parent-size + (dm/str (- parent-size space-for-icons) "px")] - (mf/with-effect [shape-for-rename edition? start-edit shape-id] - (when (and (= shape-for-rename shape-id) + (mf/with-effect [rename-id edition? start-edit shape-id] + (when (and (= rename-id shape-id) (not ^boolean edition?)) (start-edit))) @@ -110,21 +107,24 @@ :auto-focus true :id (dm/str "layer-name-" shape-id) :default-value (d/nilv default-value "")}] + [:* - [:span - {:class (stl/css-case - :element-name true - :left-ellipsis has-path? - :selected is-selected - :hidden is-hidden - :type-comp type-comp - :type-frame type-frame) - :id (dm/str "layer-name-" shape-id) - :style {"--depth" depth "--parent-size" parent-size} - :ref ref - :on-double-click start-edit} - (if (dbg/enabled? :show-ids) - (str (d/nilv shape-name "") " | " (str/slice (str shape-id) 24)) + [:span {:class (stl/css-case + :element-name true + :left-ellipsis has-path? + :selected is-selected + :hidden is-hidden + :type-comp type-comp + :type-frame type-frame) + :id (dm/str "layer-name-" shape-id) + :style {"--depth" depth "--parent-size" parent-size} + :ref ref + :on-double-click start-edit} + + (if ^boolean (dbg/enabled? :show-ids) + (dm/str (d/nilv shape-name "") " | " (str/slice (str shape-id) 24)) (d/nilv shape-name ""))] - (when (and (dbg/enabled? :show-touched) ^boolean is-shape-touched) + + (when (and ^boolean (dbg/enabled? :show-touched) + ^boolean is-shape-touched) [:span {:class (stl/css :element-name-touched)} "*"])]))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs index dabe7aae77..49433489c6 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs @@ -21,7 +21,7 @@ [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.hooks :as hooks] [app.main.ui.notifications.badge :refer [badge-notification]] - [app.main.ui.workspace.sidebar.layer-item :refer [layer-item]] + [app.main.ui.workspace.sidebar.layer-item :refer [layer-item*]] [app.util.dom :as dom] [app.util.globals :as globals] [app.util.i18n :as i18n :refer [tr]] @@ -31,92 +31,160 @@ [beicon.v2.core :as rx] [cuerdas.core :as str] [goog.events :as events] - [rumext.v2 :as mf]) - (:import - goog.events.EventType)) + [okulary.core :as l] + [rumext.v2 :as mf])) + +(def ^:private ref:highlighted-shapes + (l/derived (fn [local] + (-> local + (get :highlighted) + (not-empty))) + refs/workspace-local)) + +(def ^:private ref:shape-for-rename + (l/derived (l/key :shape-for-rename) refs/workspace-local)) + +(defn- use-selected-shapes + "A convencience hook wrapper for get selected shapes" + [] + (let [selected (mf/deref refs/selected-shapes)] + (hooks/use-equal-memo selected))) ;; 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/props :obj} +(mf/defc frame-wrapper* [{:keys [selected] :as props}] - (let [pending-selected (mf/use-var selected) - current-selected (mf/use-state selected) - props (mf/spread-object props {:selected @current-selected}) + (let [pending-selected-ref + (mf/use-ref selected) + + current-selected + (mf/use-state selected) + + props + (mf/spread-object props {:selected @current-selected}) set-selected - (mf/use-memo - (fn [] - (throttle-fn - 50 - #(when-let [pending-selected @pending-selected] - (reset! current-selected pending-selected)))))] + (mf/with-memo [] + (throttle-fn 50 #(when-let [pending-selected (mf/ref-val pending-selected-ref)] + (reset! current-selected pending-selected))))] (mf/with-effect [selected set-selected] - (reset! pending-selected selected) - (set-selected) + (mf/set-ref-val! pending-selected-ref selected) + (^function set-selected) (fn [] - (reset! pending-selected nil) - #(rx/dispose! set-selected))) + (mf/set-ref-val! pending-selected-ref nil) + (rx/dispose! set-selected))) - [:> layer-item props])) + [:> layer-item* props])) + +(mf/defc layers-tree* + {::mf/wrap [mf/memo]} + [{:keys [objects is-filtered parent-size] :as props}] + (let [selected (use-selected-shapes) + highlighted (mf/deref ref:highlighted-shapes) + root (get objects uuid/zero) + + rename-id (mf/deref ref:shape-for-rename) + + shapes (get root :shapes) + shapes (mf/with-memo [shapes objects] + (loop [counter 0 + shapes (seq shapes) + result (list)] + (if-let [id (first shapes)] + (if-let [obj (get objects id)] + (do + ;; NOTE: this is a bit hacky, but reduces substantially + ;; the allocation; If we use enumeration, we allocate + ;; new sequence and add one iteration on each render, + ;; independently if objects are changed or not. If we + ;; store counter on metadata, we still need to create a + ;; new allocation for each shape; with this method we + ;; bypass this by mutating a private property on the + ;; object removing extra allocation and extra iteration + ;; on every request. + (unchecked-set obj "__$__counter" counter) + (recur (inc counter) + (rest shapes) + (conj result obj))) + (recur (inc counter) + (rest shapes) + result)) + result)))] -(mf/defc layers-tree - {::mf/wrap [mf/memo #(mf/throttle % 200)] - ::mf/wrap-props false} - [{:keys [objects filtered? parent-size] :as props}] - (let [selected (mf/deref refs/selected-shapes) - selected (hooks/use-equal-memo selected) - highlighted (mf/deref refs/highlighted-shapes) - highlighted (hooks/use-equal-memo highlighted) - root (get objects uuid/zero)] [:div {:class (stl/css :element-list) :data-testid "layer-item"} [:> hooks/sortable-container* {} - (for [[index id] (reverse (d/enumerate (:shapes root)))] - (when-let [obj (get objects id)] - (if (cfh/frame-shape? obj) - [:& frame-wrapper - {:item obj - :selected selected - :highlighted highlighted - :index index - :objects objects - :key id - :sortable? true - :filtered? filtered? - :parent-size parent-size - :depth -1}] - [:& layer-item - {:item obj - :selected selected - :highlighted highlighted - :index index - :objects objects - :key id - :sortable? true - :filtered? filtered? - :depth -1 - :parent-size parent-size}])))]])) + (for [obj shapes] + (if (cfh/frame-shape? obj) + [:> frame-wrapper* + {:item obj + :rename-id rename-id + :selected selected + :highlighted highlighted + :index (unchecked-get obj "__$__counter") + :objects objects + :key (dm/str (get obj :id)) + :is-sortable true + :is-filtered is-filtered + :parent-size parent-size + :depth -1}] + [:> layer-item* + {:item obj + :rename-id rename-id + :selected selected + :highlighted highlighted + :index (unchecked-get obj "__$__counter") + :objects objects + :key (dm/str (get obj :id)) + :is-sortable true + :is-filtered is-filtered + :depth -1 + :parent-size parent-size}]))]])) -(mf/defc filters-tree - {::mf/wrap [mf/memo #(mf/throttle % 200)] - ::mf/wrap-props false} +(mf/defc layers-tree-wrapper* + {::mf/private true} + [{:keys [objects] :as props}] + ;; This is a performance sensitive componet, so we use lower-level primitives for + ;; reduce residual allocation for this specific case + (let [state-tmp (mf/useState objects) + objects' (aget state-tmp 0) + set-objects (aget state-tmp 1) + + subject-s (mf/with-memo [] + (rx/subject)) + changes-s (mf/with-memo [subject-s] + (->> subject-s + (rx/debounce 500))) + + props (mf/spread-props props {:objects objects'})] + + (mf/with-effect [objects subject-s] + (rx/push! subject-s objects)) + + (mf/with-effect [changes-s] + (let [sub (rx/subscribe changes-s set-objects)] + #(rx/dispose! sub))) + + [:> layers-tree* props])) + +(mf/defc filters-tree* + {::mf/wrap [mf/memo #(mf/throttle % 300)] + ::mf/private true} [{:keys [objects parent-size]}] - (let [selected (mf/deref refs/selected-shapes) - selected (hooks/use-equal-memo selected) - root (get objects uuid/zero)] + (let [selected (use-selected-shapes) + root (get objects uuid/zero)] [:ul {:class (stl/css :element-list)} (for [[index id] (d/enumerate (:shapes root))] (when-let [obj (get objects id)] - [:& layer-item + [:> layer-item* {:item obj :selected selected :index index :objects objects :key id - :sortable? false - :filtered? true + :is-sortable false + :is-filtered true :depth -1 :parent-size parent-size}]))])) @@ -132,6 +200,7 @@ keys (filter #(not= uuid/zero %)) vec)] + (update reparented-objects uuid/zero assoc :shapes reparented-shapes))) ;; --- Layers Toolbox @@ -277,9 +346,11 @@ (swap! state* update :num-items + 100))))] (mf/with-effect [] - (let [keys [(events/listen globals/document EventType.KEYDOWN on-key-down) - (events/listen globals/document EventType.CLICK hide-menu)]] - (fn [] (doseq [key keys] (events/unlistenByKey key))))) + (let [key1 (events/listen globals/document "keydown" on-key-down) + key2 (events/listen globals/document "click" hide-menu)] + (fn [] + (events/unlistenByKey key1) + (events/unlistenByKey key2)))) [filtered-objects handle-show-more @@ -464,6 +535,8 @@ {::mf/wrap [mf/memo]} [{:keys [size-parent]}] (let [page (mf/deref refs/workspace-page) + page-id (get page :id) + focus (mf/deref refs/workspace-focus-selected) objects (hooks/with-focus-objects (:objects page) focus) @@ -473,7 +546,8 @@ observer-var (mf/use-var nil) lazy-load-ref (mf/use-ref nil) - [filtered-objects show-more filter-component] (use-search page objects) + [filtered-objects show-more filter-component] + (use-search page objects) intersection-callback (fn [entries] @@ -519,25 +593,25 @@ [:div {:class (stl/css :tool-window-content) :data-scroll-container true :ref on-render-container} - [:& filters-tree {:objects filtered-objects - :key (dm/str (:id page)) - :parent-size size-parent}] + [:> filters-tree* {:objects filtered-objects + :key (dm/str page-id) + :parent-size size-parent}] [:div {:ref lazy-load-ref}]] [:div {:on-scroll on-scroll :class (stl/css :tool-window-content) :data-scroll-container true :style {:display (when (some? filtered-objects) "none")}} - [:& layers-tree {:objects filtered-objects - :key (dm/str (:id page)) - :filtered? true - :parent-size size-parent}]]] + [:> layers-tree-wrapper* {:objects filtered-objects + :key (dm/str page-id) + :is-filtered true + :parent-size size-parent}]]] [:div {:on-scroll on-scroll :class (stl/css :tool-window-content) :data-scroll-container true :style {:display (when (some? filtered-objects) "none")}} - [:& layers-tree {:objects objects - :key (dm/str (:id page)) - :filtered? false - :parent-size size-parent}]])])) + [:> layers-tree-wrapper* {:objects objects + :key (dm/str page-id) + :is-filtered false + :parent-size size-parent}]])]))