Compare commits

..

5 Commits

Author SHA1 Message Date
Elena Torro
457a7b0890 🔧 Optimize sidebar performance for deeply nested shapes
- Batch hover highlights using RAF to avoid long tasks from rapid events
- Run parent expansion asynchronously to not block selection
- Lazy-load children in layer items using IntersectionObserver
- Clarify expand-all-parents logic with explicit bindings
2026-01-14 15:17:00 +01:00
Elena Torro
3521bd493e 🔧 Add lazy tree wrapper for page objects lookup 2026-01-14 13:49:32 +01:00
Elena Torro
68f5671eab 🔧 Always lookup over a set 2026-01-14 13:49:32 +01:00
Elena Torro
92976143bb 🔧 Add performance debugging logs 2026-01-14 13:49:32 +01:00
Elena Torro
fd675e0194 🔧 Lookup page objects only when value changes 2026-01-14 12:15:01 +01:00
27 changed files with 468 additions and 538 deletions

View File

@@ -13,6 +13,7 @@
- Remap references when renaming tokens [Taiga #10202](https://tree.taiga.io/project/penpot/us/10202)
- Tokens panel nested path view [Taiga #9966](https://tree.taiga.io/project/penpot/us/9966)
- Improve usability of lock and hide buttons in the layer panel. [Taiga #12916](https://tree.taiga.io/project/penpot/issue/12916)
- Optimize sidebar performance for deeply nested shapes [Taiga #13017](https://tree.taiga.io/project/penpot/task/13017)
### :bug: Bugs fixed

View File

@@ -526,20 +526,25 @@
ids))
(defn clean-loops
"Clean a list of ids from circular references."
"Clean a list of ids from circular references. Optimized fast-path for single selections."
[objects ids]
(let [parent-selected?
(fn [id]
(let [parents (get-parent-ids objects id)]
(some ids parents)))
(if (<= (count ids) 1)
;; For single selection, there can't be circularity; return as ordered-set.
(into (d/ordered-set) ids)
(let [ids-set (if (set? ids) ids (set ids))
parent-selected?
(fn [id]
;; Stop early as soon as we find any selected parent
(let [parents (get-parent-ids objects id)]
(some #(contains? ids-set %) parents)))
add-element
(fn [result id]
(cond-> result
(not (parent-selected? id))
(conj id)))]
add-element
(fn [result id]
(cond-> result
(not (parent-selected? id))
(conj id)))]
(reduce add-element (d/ordered-set) ids)))
(reduce add-element (d/ordered-set) ids))))
(defn- indexed-shapes
"Retrieves a vector with the indexes for each element in the layer

View File

@@ -50,7 +50,7 @@ const setupTokensFile = async (page, options = {}) => {
const {
file = "workspace/get-file-tokens.json",
fileFragment = "workspace/get-file-fragment-tokens.json",
flags = ["enable-feature-token-input"],
flags = [],
} = options;
const workspacePage = new WorkspacePage(page);
@@ -2242,56 +2242,6 @@ test.describe("Tokens: Apply token", () => {
).toBeVisible();
});
test("User applies border-radius token to a shape from sidebar", async ({ page }) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.layers.getByTestId("layer-row").nth(1).click();
// Open tokens sections on left sidebar
const tokensTabButton = page.getByRole("tab", { name: "Tokens" });
await tokensTabButton.click();
// Unfold border radius tokens
await page.getByRole("button", { name: "Border Radius 3" }).click();
await expect(
tokensSidebar.getByRole("button", { name: "borderRadius" }),
).toBeVisible();
await tokensSidebar.getByRole("button", { name: "borderRadius" }).click();
await expect(
tokensSidebar.getByRole("button", { name: "borderRadius.sm" }),
).toBeVisible();
// Apply border radius token from token panels
await tokensSidebar.getByRole("button", { name: "borderRadius.sm" }).click();
// Check if border radius sections is visible on right sidebar
const borderRadiusSection = page.getByRole("region", {name: "border-radius-section"});
await expect(borderRadiusSection).toBeVisible();
// Check if token pill is visible on design tab on right sidebar
const brTokenPillSM = borderRadiusSection.getByRole('button', { name: 'borderRadius.sm' });
await expect(brTokenPillSM).toBeVisible();
await brTokenPillSM.click();
// Change token from dropdown
const brTokenOptionXl = borderRadiusSection.getByLabel('borderRadius.xl')
await expect(brTokenOptionXl).toBeVisible();
await brTokenOptionXl.click();
await expect(brTokenPillSM).not.toBeVisible();
const brTokenPillXL = borderRadiusSection.getByRole('button', { name: 'borderRadius.xl' });
await expect(brTokenPillXL).toBeVisible();
// Detach token from design tab on right sidebar
const detachButton = borderRadiusSection.getByRole('button', { name: 'Detach token' });
await detachButton.click();
await expect(brTokenPillXL).not.toBeVisible();
});
test("User applies typography token to a text shape", async ({ page }) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTypographyTokensFile(page);
@@ -2467,13 +2417,12 @@ test.describe("Tokens: Apply token", () => {
const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill(newTokenTitle);
const referenceTabButton = tokensUpdateCreateModal.getByRole("button", {
name: "Use a reference",
});
const referenceTabButton =
tokensUpdateCreateModal.getByRole('button', { name: 'Use a reference' });
referenceTabButton.click();
const referenceField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Reference",
const referenceField = tokensUpdateCreateModal.getByRole('textbox', {
name: 'Reference'
});
await referenceField.fill("{Full}");
@@ -2833,18 +2782,14 @@ test.describe("Tokens: Remapping Feature", () => {
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
nameField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Name",
});
nameField = tokensUpdateCreateModal.getByRole("textbox", {name: "Name"});
await nameField.fill("derived-shadow");
const referenceToggle =
tokensUpdateCreateModal.getByTestId("reference-opt");
await referenceToggle.click();
const referenceField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Reference",
});
const referenceField = tokensUpdateCreateModal.getByRole("textbox", {name: "Reference"});
await referenceField.fill("{base-shadow}");
submitButton = tokensUpdateCreateModal.getByRole("button", {
@@ -2933,9 +2878,7 @@ test.describe("Tokens: Remapping Feature", () => {
tokensUpdateCreateModal.getByTestId("reference-opt");
await referenceToggle.click();
const referenceField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Reference",
});
const referenceField = tokensUpdateCreateModal.getByRole("textbox", {name: "Reference"});
await referenceField.fill("{primary-shadow}");
submitButton = tokensUpdateCreateModal.getByRole("button", {
@@ -3007,8 +2950,7 @@ test.describe("Tokens: Remapping Feature", () => {
// Verify the shape still has the shadow applied with the UPDATED color value
// Expand the shadow section to access the color field
const shadowSection =
workspacePage.rightSidebar.getByTestId("shadow-section");
const shadowSection = workspacePage.rightSidebar.getByTestId("shadow-section");
await expect(shadowSection).toBeVisible();
// Click to expand the shadow options (the menu button)
@@ -3066,18 +3008,14 @@ test.describe("Tokens: Remapping Feature", () => {
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
nameField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Name",
});
nameField = tokensUpdateCreateModal.getByRole("textbox", {name: "Name"});
await nameField.fill("body-text");
const referenceToggle =
tokensUpdateCreateModal.getByTestId("reference-opt");
await referenceToggle.click();
const referenceField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Reference",
});
const referenceField = tokensUpdateCreateModal.getByRole("textbox", {name: "Reference"})
await referenceField.fill("{base-text}");
submitButton = tokensUpdateCreateModal.getByRole("button", {
@@ -3158,18 +3096,14 @@ test.describe("Tokens: Remapping Feature", () => {
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
nameField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Name",
});
nameField = tokensUpdateCreateModal.getByRole("textbox", {name: "Name"});
await nameField.fill("paragraph-style");
const referenceToggle =
tokensUpdateCreateModal.getByTestId("reference-opt");
await referenceToggle.click();
const referenceField = tokensUpdateCreateModal.getByRole("textbox", {
name: "Reference",
});
const referenceField = tokensUpdateCreateModal.getByRole("textbox", {name: "Reference"});
await referenceField.fill("{body-style}");
submitButton = tokensUpdateCreateModal.getByRole("button", {

View File

@@ -61,6 +61,11 @@
;; Def micro-benchmark iterations
(def micro-benchmark-iterations 1e6)
;; Performance logs
(defonce ^:private longtask-observer* (atom nil))
(defonce ^:private stall-timer* (atom nil))
(defonce ^:private current-op* (atom nil))
;; --- CONTEXT
(defn- collect-context
@@ -464,3 +469,72 @@
(defn event
[props]
(ptk/data-event ::event props))
;; --- DEVTOOLS PERF LOGGING
(defn install-long-task-observer! []
(when (and (some? (.-PerformanceObserver js/window)) (nil? @longtask-observer*))
(let [observer (js/PerformanceObserver.
(fn [list _]
(doseq [entry (.getEntries list)]
(let [dur (.-duration entry)
start (.-startTime entry)
attrib (.-attribution entry)
attrib-count (when attrib (.-length attrib))
first-attrib (when (and attrib-count (> attrib-count 0)) (aget attrib 0))
attrib-name (when first-attrib (.-name first-attrib))
attrib-ctype (when first-attrib (.-containerType first-attrib))
attrib-cid (when first-attrib (.-containerId first-attrib))
attrib-csrc (when first-attrib (.-containerSrc first-attrib))]
(.warn js/console (str "[perf] long task " (Math/round dur) "ms at " (Math/round start) "ms"
(when first-attrib
(str " attrib:name=" attrib-name
" ctype=" attrib-ctype
" cid=" attrib-cid
" csrc=" attrib-csrc))))))))]
(.observe observer #js{:entryTypes #js["longtask"]})
(reset! longtask-observer* observer))))
(defn start-event-loop-stall-logger!
"Log event loop stalls by measuring setInterval drift.
interval-ms: base interval
threshold-ms: drift over which we report"
[interval-ms threshold-ms]
(when (nil? @stall-timer*)
(let [last (atom (.now js/performance))
id (js/setInterval
(fn []
(let [now (.now js/performance)
expected (+ @last interval-ms)
drift (- now expected)
current-op @current-op*
measures (.getEntriesByType js/performance "measure")
mlen (.-length measures)
last-measure (when (> mlen 0) (aget measures (dec mlen)))
meas-name (when last-measure (.-name last-measure))
meas-detail (when last-measure (.-detail last-measure))
meas-count (when meas-detail (unchecked-get meas-detail "count"))]
(reset! last now)
(when (> drift threshold-ms)
(.warn js/console
(str "[perf] event loop stall: " (Math/round drift) "ms"
(when current-op (str " op=" current-op))
(when meas-name (str " last=" meas-name))
(when meas-count (str " count=" meas-count)))))))
interval-ms)]
(reset! stall-timer* id))))
(defn init!
"Install perf observers in dev builds. Safe to call multiple times."
[]
(when ^boolean js/goog.DEBUG
(install-long-task-observer!)
(start-event-loop-stall-logger! 50 100)
;; Expose simple API on window for manual control in devtools
(let [api #js {:reset (fn []
(try
(.clearMarks js/performance)
(.clearMeasures js/performance)
(catch :default _ nil)))}]
(aset js/window "PenpotPerf" api))))

View File

@@ -14,6 +14,87 @@
[app.common.geom.shapes :as gsh]
[app.common.types.path :as path]))
;; =============================================================================
;; Lazy Tree Wrapper
;; =============================================================================
;; This wrapper provides O(1) lookup by building an index lazily.
;; The index is built once on first access and cached for subsequent lookups.
(defn- build-index
"Builds a flat index from a tree structure. Returns {id -> shape-with-parent-id}"
([root]
(build-index root nil (transient {})))
([shape parent-id acc]
(let [id (dm/get-prop shape :id)
children (or (dm/get-prop shape :children)
(dm/get-prop shape :shapes)
[])
child-ids (mapv (fn [c] (if (map? c) (dm/get-prop c :id) c)) children)
shape-normalized (-> shape
(assoc :parent-id parent-id)
(assoc :shapes child-ids)
(dissoc :children))
acc' (assoc! acc id shape-normalized)]
(reduce (fn [a child]
(if (map? child)
(build-index child id a)
a))
acc'
children))))
(deftype TreeObjectsLookup [tree ^:mutable cached-index]
ILookup
(-lookup [_ k]
(when (nil? cached-index)
(set! cached-index (persistent! (build-index tree))))
(get cached-index k))
(-lookup [_ k not-found]
(when (nil? cached-index)
(set! cached-index (persistent! (build-index tree))))
(get cached-index k not-found))
ISeqable
(-seq [_]
(when (nil? cached-index)
(set! cached-index (persistent! (build-index tree))))
(seq cached-index))
ICounted
(-count [_]
(when (nil? cached-index)
(set! cached-index (persistent! (build-index tree))))
(count cached-index))
IFn
(-invoke [this k]
(-lookup this k))
(-invoke [this k not-found]
(-lookup this k not-found))
IAssociative
(-contains-key? [_ k]
(when (nil? cached-index)
(set! cached-index (persistent! (build-index tree))))
(contains? cached-index k))
(-assoc [_ k v]
(when (nil? cached-index)
(set! cached-index (persistent! (build-index tree))))
(assoc cached-index k v))
IIterable
(-iterator [_]
(when (nil? cached-index)
(set! cached-index (persistent! (build-index tree))))
(-iterator cached-index)))
(defn- tree->lookup
"Wraps a tree structure in a lazy lookup that builds the index on first access.
Returns the tree as-is if it's already a flat map (no :id at root level)."
[tree]
(if (and (map? tree) (contains? tree :id))
(->TreeObjectsLookup tree nil)
tree))
(defn lookup-profile
([state]
(:profile state))
@@ -62,26 +143,46 @@
(:current-file-id state)
page-id))
([state file-id page-id]
(-> (lookup-page state file-id page-id)
(get :objects))))
(let [page (lookup-page state file-id page-id)
objects (:objects page)]
;; Use lazy tree wrapper - builds index on first access, then O(1) lookups
(tree->lookup objects))))
;; Small 1-slot memo to avoid recomputing the same selection normalization
;; many times in hot paths (watchers, derived refs).
(defonce ^:private last-process-selected
(atom {:objects nil :selected nil :omit-blocked? nil :result nil}))
(defn process-selected
([objects selected]
(process-selected objects selected nil))
([objects selected {:keys [omit-blocked?] :or {omit-blocked? false}}]
(let [selectable?
(fn [id]
(and (contains? objects id)
(or (not omit-blocked?)
(not (dm/get-in objects [id :blocked] false)))))
selected
(cfh/clean-loops objects selected)]
(into (d/ordered-set)
(filter selectable?)
selected))))
(let [{c-objects :objects
c-selected :selected
c-omit-blocked? :omit-blocked?
c-result :result} @last-process-selected]
(if (and (identical? c-objects objects)
(= c-selected selected)
(= c-omit-blocked? omit-blocked?))
c-result
(let [;; Remove circular selections (child when parent is selected)
selected-cleaned (cfh/clean-loops objects selected)
;; Fast path when not filtering blocked: only ensure id exists
selectable? (if omit-blocked?
(fn [id]
(and (contains? objects id)
(not (dm/get-in objects [id :blocked] false))))
(fn [id]
(contains? objects id)))
result (into (d/ordered-set)
(filter selectable?)
selected-cleaned)]
(reset! last-process-selected {:objects objects
:selected selected
:omit-blocked? omit-blocked?
:result result})
result)))))
(defn split-text-shapes
"Split text shapes from non-text shapes"

View File

@@ -347,6 +347,12 @@
(with-meta {:team-id team-id
:file-id file-id}))))))
;; Install dev perf observers once the workspace is ready
(->> stream
(rx/filter (ptk/type? ::workspace-initialized))
(rx/take 1)
(rx/map (fn [_] (ev/init!))))
(->> stream
(rx/filter (ptk/type? ::dps/persistence-notification))
(rx/take 1)

View File

@@ -18,13 +18,13 @@
ptk/UpdateEvent
(update [_ state]
(let [expand-fn (fn [expanded]
(merge expanded
(->> ids
(map #(cfh/get-parent-ids objects %))
flatten
(remove #(= % uuid/zero))
(map (fn [id] {id true}))
(into {}))))]
(let [parents-seqs (map (fn [x] (cfh/get-parent-ids objects x)) ids)
flat-parents (apply concat parents-seqs)
non-root-parents (remove #(= % uuid/zero) flat-parents)
distinct-parents (into #{} non-root-parents)]
(merge expanded
(into {}
(map (fn [id] {id true}) distinct-parents)))))]
(update-in state [:workspace-local :expanded] expand-fn)))))

View File

@@ -264,10 +264,13 @@
ptk/WatchEvent
(watch [_ state _]
(let [objects (dsh/lookup-page-objects state)]
(rx/of
(dwc/expand-all-parents ids objects)
::dwsp/interrupt)))))
(let [objects (dsh/lookup-page-objects state)
;; Schedule expanding parents asynchronously to avoid blocking
;; the event loop
expand-s (->> (rx/of (dwc/expand-all-parents ids objects))
(rx/observe-on :async))
interrupt-s (rx/of ::dwsp/interrupt)]
(rx/merge expand-s interrupt-s)))))
(defn select-all
[]

View File

@@ -633,43 +633,6 @@
:shape-ids shape-ids
:on-update-shape on-update-shape}))))))))
(defn toggle-border-radius-token
[{:keys [token attrs shape-ids expand-with-children]}]
(ptk/reify ::on-toggle-border-radius-token
ptk/WatchEvent
(watch [_ state _]
(let [objects (dsh/lookup-page-objects state)
shapes (into [] (keep (d/getf objects)) shape-ids)
shapes
(if expand-with-children
(into []
(mapcat (fn [shape]
(if (= (:type shape) :group)
(keep objects (:shapes shape))
[shape])))
shapes)
shapes)
{:keys [attributes all-attributes]}
(get token-properties (:type token))
unapply-tokens?
(cft/shapes-token-applied? token shapes (or attrs all-attributes attributes))
shape-ids (map :id shapes)]
(if unapply-tokens?
(rx/of
(unapply-token {:attributes (or attrs all-attributes attributes)
:token token
:shape-ids shape-ids}))
(rx/of
(apply-token {:attributes attrs
:token token
:shape-ids shape-ids
:on-update-shape update-shape-radius-for-corners})))))))
(defn apply-token-on-selected
[color-operations token]

View File

@@ -305,7 +305,7 @@
(l/derived #(dsh/lookup-shape % page-id shape-id) st/state =))
(def workspace-page-objects
(l/derived dsh/lookup-page-objects st/state))
(l/derived dsh/lookup-page-objects st/state identical?))
(def workspace-read-only?
(l/derived :read-only? workspace-global))

View File

@@ -183,7 +183,6 @@
[:map
[:id {:optional true} :string]
[:class {:optional true} :string]
[:inner-class {:optional true} :string]
[:value {:optional true} [:maybe [:or
:int
:float
@@ -210,8 +209,7 @@
(mf/defc numeric-input*
{::mf/schema schema:numeric-input}
[{:keys [id class value default placeholder
icon disabled inner-class
[{:keys [id class value default placeholder icon disabled
min max max-length step
is-selected-on-focus nillable
tokens applied-token empty-to-end
@@ -626,7 +624,6 @@
(mf/spread-props props {:ref ref
:type "text"
:id id
:class inner-class
:placeholder (if is-multiple?
(tr "labels.mixed-values")
placeholder)
@@ -647,7 +644,7 @@
:class (stl/css :icon)}]]))
:slot-end (when-not disabled
(when (some? tokens)
(mf/html [:> icon-button* {:variant "ghost"
(mf/html [:> icon-button* {:variant "action"
:icon i/tokens
:class (stl/css :invisible-button)
:aria-label (tr "ds.inputs.numeric-input.open-token-list-dropdown")
@@ -672,7 +669,6 @@
:on-token-key-down on-token-key-down
:disabled disabled
:on-blur on-blur
:class inner-class
:slot-start (when icon
(mf/html [:> tooltip*
{:content property

View File

@@ -33,17 +33,12 @@
}
.invisible-button {
position: absolute;
inset-inline-end: 0;
inset-block-start: 0;
opacity: var(--opacity-button);
background-color: var(--color-background-quaternary);
&:hover {
background-color: var(--color-background-quaternary);
--opacity-button: 1;
}
&:focus {
background-color: var(--color-background-quaternary);
--opacity-button: 1;
}
}

View File

@@ -26,7 +26,7 @@
[:map
[:id {:optional true} :string]
[:resolved-value {:optional true}
[:or :int :string :float]]
[:or :int :string]]
[:name {:optional true} :string]
[:icon {:optional true} schema:icon-list]
[:label {:optional true} :string]

View File

@@ -30,11 +30,11 @@
}
.left-align {
left: var(--dropdown-offset, 0);
left: 0;
}
.right-align {
right: var(--dropdown-offset, 0);
right: 0;
}
.option-separator {

View File

@@ -18,7 +18,7 @@
[:map
[:id {:optiona true} :string]
[:ref some?]
[:resolved {:optional true} [:or :int :string :float]]
[:resolved {:optional true} [:or :int :string]]
[:name {:optional true} :string]
[:on-click {:optional true} fn?]
[:selected {:optional true} :boolean]

View File

@@ -17,7 +17,7 @@
(def ^:private schema:input-field
[:map
[:class {:optional true} [:maybe :string]]
[:class {:optional true} :string]
[:aria-label {:optional true} [:maybe :string]]
[:id :string]
[:icon {:optional true}
@@ -44,10 +44,9 @@
tooltip-id (mf/use-id)
props (mf/spread-props props
{:class [class
(stl/css-case
:input true
:input-with-icon (some? icon))]
{:class (stl/css-case
:input true
:input-with-icon (some? icon))
:ref (or ref input-ref)
:aria-invalid (when (and has-hint
(= hint-type "error"))

View File

@@ -19,7 +19,6 @@
(def ^:private schema:token-field
[:map
[:class {:optional true} [:maybe :string]]
[:id {:optional true} [:maybe :string]]
[:label {:optional true} [:maybe :string]]
[:value :any]
@@ -33,7 +32,7 @@
(mf/defc token-field*
{::mf/schema schema:token-field}
[{:keys [id label value slot-start disabled class
[{:keys [id label value slot-start disabled
on-click on-token-key-down on-blur detach-token
token-wrapper-ref token-detach-btn-ref on-focus]}]
(let [set-active? (some? id)
@@ -49,11 +48,14 @@
(fn [event]
(when-not ^boolean disabled
(dom/prevent-default event)
(dom/focus! (mf/ref-val token-wrapper-ref)))))]
(dom/focus! (mf/ref-val token-wrapper-ref)))))
[:div {:class [class (stl/css-case :token-field true
:with-icon (some? slot-start)
:token-field-disabled disabled)]
class
(stl/css-case :token-field true
:with-icon (some? slot-start)
:token-field-disabled disabled)]
[:div {:class class
:on-click focus-wrapper
:disabled disabled
:on-key-down on-token-key-down
@@ -78,7 +80,7 @@
[:div {:class (stl/css :pill-dot)}])]]
(when-not ^boolean disabled
[:> icon-button* {:variant "ghost"
[:> icon-button* {:variant "action"
:class (stl/css :invisible-button)
:icon i/broken-link
:ref token-detach-btn-ref

View File

@@ -8,7 +8,6 @@
@use "ds/_sizes.scss" as *;
@use "ds/typography.scss" as t;
@use "ds/colors.scss" as *;
@use "ds/mixins.scss" as *;
.token-field {
--token-field-bg-color: var(--color-background-tertiary);
@@ -17,7 +16,9 @@
--token-field-outline-color: none;
--token-field-height: var(--sp-xxxl);
--token-field-margin: unset;
display: grid;
grid-template-columns: 1fr auto;
column-gap: var(--sp-xs);
align-items: center;
position: relative;
@@ -26,7 +27,6 @@
border-radius: $br-8;
padding: var(--sp-xs);
outline: $b-1 solid var(--token-field-outline-color);
position: relative;
&:hover {
--token-field-bg-color: var(--color-background-quaternary);
@@ -39,7 +39,7 @@
}
.with-icon {
grid-template-columns: auto 1fr;
grid-template-columns: auto 1fr auto;
}
.token-field-disabled {
@@ -57,17 +57,14 @@
--pill-bg-color: var(--color-background-tertiary);
--pill-fg-color: var(--color-token-foreground);
@include t.use-typography("code-font");
@include textEllipsis;
display: block;
block-size: var(--sp-xxl);
inline-size: fit-content;
height: var(--sp-xxl);
width: fit-content;
background: var(--pill-bg-color);
cursor: pointer;
border: $b-1 solid var(--pill-border-color);
color: var(--pill-fg-color);
border-radius: $br-6;
padding-inline: $sz-6;
max-inline-size: 100%;
&:hover {
--pill-bg-color: var(--color-token-background);
--pill-fg-color: var(--color-foreground-primary);
@@ -106,29 +103,24 @@
}
.pill-dot {
inline-size: $sz-6;
block-size: $sz-6;
width: $sz-6;
height: $sz-6;
outline: var(--sp-xxs) solid var(--color-background-primary);
border-radius: 50%;
background-color: var(--color-foreground-error);
margin-inline-start: var(--sp-xs);
margin-left: var(--sp-xs);
position: absolute;
inset-inline-end: 0;
inset-block-start: 0;
right: 0;
top: 0;
}
.invisible-button {
position: absolute;
inset-inline-end: 0;
inset-block-start: 0;
opacity: var(--opacity-button);
background-color: var(--color-background-quaternary);
&:hover {
background-color: var(--color-background-quaternary);
--opacity-button: 1;
}
&:focus {
background-color: var(--color-background-quaternary);
--opacity-button: 1;
}
}

View File

@@ -159,6 +159,4 @@ $arrow-side: 12px;
block-size: fit-content;
inline-size: fit-content;
line-height: 0;
display: grid;
max-width: 100%;
}

View File

@@ -33,9 +33,24 @@
[okulary.core :as l]
[rumext.v2 :as mf]))
;; Coalesce sidebar hover highlights to 1 frame to avoid long tasks
(defonce ^:private sidebar-hover-queue (atom {:enter #{} :leave #{}}))
(defonce ^:private sidebar-hover-pending? (atom false))
(defn- schedule-sidebar-hover-flush []
(when (compare-and-set! sidebar-hover-pending? false true)
(ts/raf
(fn []
(let [{:keys [enter leave]} (swap! sidebar-hover-queue (constantly {:enter #{} :leave #{}}))]
(reset! sidebar-hover-pending? false)
(when (seq leave)
(apply st/emit! (map dw/dehighlight-shape leave)))
(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
[{:keys [item depth parent-size name-ref children ref style
;; Flags
read-only? highlighted? selected? component-tree?
filtered? expanded? dnd-over? dnd-over-top? dnd-over-bot? hide-toggle?
@@ -82,7 +97,8 @@
:dnd-over dnd-over?
:dnd-over-top dnd-over-top?
:dnd-over-bot dnd-over-bot?
:root-board parent-board?)}
:root-board parent-board?)
:style style}
[:span {:class (stl/css-case
:tab-indentation true
:filtered filtered?)
@@ -165,10 +181,12 @@
children]))
;; Memoized for performance
(mf/defc layer-item
{::mf/props :obj
::mf/memo true}
[{:keys [index item selected objects sortable? filtered? depth parent-size component-child? highlighted]}]
::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)
@@ -245,13 +263,21 @@
(mf/use-fn
(mf/deps id)
(fn [_]
(st/emit! (dw/highlight-shape id))))
(swap! sidebar-hover-queue (fn [{:keys [enter leave] :as q}]
(-> q
(assoc :enter (conj enter id))
(assoc :leave (disj leave id)))))
(schedule-sidebar-hover-flush)))
on-pointer-leave
(mf/use-fn
(mf/deps id)
(fn [_]
(st/emit! (dw/dehighlight-shape id))))
(swap! sidebar-hover-queue (fn [{:keys [enter leave] :as q}]
(-> q
(assoc :enter (disj enter id))
(assoc :leave (conj leave id)))))
(schedule-sidebar-hover-flush)))
on-context-menu
(mf/use-fn
@@ -337,14 +363,18 @@
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))]
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]
(let [single? (= (count selected) 1)
node (mf/ref-val ref)
;; NOTE: Neither get-parent-at nor get-parent-with-selector
;; work if the component template changes, so we need to
;; seek for an alternate solution. Maybe use-context?
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)
@@ -362,6 +392,59 @@
#(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?
(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)))
;; 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)]
(reset! children-count* (min total (max chunk-size min-count))))
(reset! children-count* 0)))
(fn []
(when-let [obs ^js @observer-var]
(.disconnect obs)
(reset! observer-var nil))))
;; Re-observe sentinel whenever children-count changes (sentinel moves)
(mf/with-effect [children-count expanded?]
(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)]
;; Disconnect previous observer
(when-let [obs ^js @observer-var]
(.disconnect obs)
(reset! observer-var nil))
;; Setup new observer if there are more children to load
(when (and expanded?
(< children-count total)
scroll-node
lazy-node)
(let [cb (fn [entries]
(when (and (seq entries)
(.-isIntersecting (first entries)))
;; Load next chunk when sentinel intersects
(let [current @children-count*
next-count (min total (+ current chunk-size))]
(reset! children-count* next-count))))
observer (js/IntersectionObserver. cb #js {:root scroll-node})]
(.observe observer lazy-node)
(reset! observer-var observer)))))
[:& layer-item-inner
{:ref dref
:item item
@@ -386,24 +469,32 @@
:on-enable-drag enable-drag
:on-disable-drag disable-drag
:on-toggle-visibility toggle-visibility
:on-toggle-blocking toggle-blocking}
:on-toggle-blocking toggle-blocking
:style style}
(when (and (:shapes item) expanded?)
(when (and render-children?
(:shapes item)
expanded?)
[:div {:class (stl/css-case
:element-children true
:parent-selected selected?
:sticky-children parent-board?)
:data-testid (dm/str "children-" id)}
(for [[index id] (reverse (d/enumerate (:shapes item)))]
(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?}]))])]))
(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)))
[:div {:ref lazy-ref
:style {:min-height 1}}])])]))

View File

@@ -3,15 +3,10 @@
(:require
[app.common.data.macros :as dm]
[app.common.types.shape.radius :as ctsr]
[app.common.types.token :as tk]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.features :as features]
[app.main.store :as st]
[app.main.ui.components.numeric-input :as deprecated-input]
[app.main.ui.context :as muc]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.numeric-input :refer [numeric-input*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.hooks :as hooks]
[app.util.i18n :as i18n :refer [tr]]
@@ -26,17 +21,11 @@
(defn- check-border-radius-menu-props
[old-props new-props]
(let [old-values (unchecked-get old-props "values")
new-values (unchecked-get new-props "values")
old-applied-tokens (unchecked-get old-props "appliedTokens")
new-applied-tokens (unchecked-get new-props "appliedTokens")]
new-values (unchecked-get new-props "values")]
(and (identical? (unchecked-get old-props "class")
(unchecked-get new-props "class"))
(identical? (unchecked-get old-props "ids")
(unchecked-get new-props "ids"))
(identical? (unchecked-get old-props "shapes")
(unchecked-get new-props "shapes"))
(identical? old-applied-tokens
new-applied-tokens)
(identical? (get old-values :r1)
(get new-values :r1))
(identical? (get old-values :r2)
@@ -46,114 +35,13 @@
(identical? (get old-values :r4)
(get new-values :r4)))))
(mf/defc numeric-input-wrapper*
{::mf/private true}
[{:keys [values name applied-tokens align on-detach radius] :rest props}]
(let [tokens (mf/use-ctx muc/active-tokens-by-type)
tokens (mf/with-memo [tokens name]
(delay
(-> (deref tokens)
(select-keys (get tk/tokens-by-input name))
(not-empty))))
on-detach-attr
(mf/use-fn
(mf/deps on-detach name)
#(on-detach % name))
r1-value (get applied-tokens :r1)
all-token-equal? (and (seq applied-tokens) (all-equal? applied-tokens))
all-values-equal? (all-equal? values)
applied-token (cond
(not (seq applied-tokens))
nil
(and (= radius :all) (or (not all-values-equal?) (not all-token-equal?)))
:multiple
(and all-token-equal? all-values-equal? (= radius :all))
r1-value
:else
(get applied-tokens radius))
placeholder (if (= radius :all)
(cond
(or (not all-values-equal?)
(not all-token-equal?))
(tr "settings.multiple")
:else
"--")
(cond
(or (= :multiple (:applied-tokens values))
(= :multiple (get values name)))
(tr "settings.multiple")
:else
"--"))
props (mf/spread-props props
{:placeholder placeholder
:applied-token applied-token
:tokens (if (delay? tokens) @tokens tokens)
:align align
:on-detach on-detach-attr
:value values})]
[:> numeric-input* props]))
(mf/defc border-radius-menu*
{::mf/wrap [#(mf/memo' % check-border-radius-menu-props)]}
[{:keys [class ids values applied-tokens]}]
(let [token-numeric-inputs
(features/use-feature "tokens/numeric-input")
all-values-equal? (all-equal? values)
[{:keys [class ids values]}]
(let [all-equal? (all-equal? values)
radius-expanded* (mf/use-state false)
radius-expanded (deref radius-expanded*)
;; DETACH
on-detach-token
(mf/use-fn
(mf/deps ids)
(fn [token attr]
(st/emit! (dwta/unapply-token {:token (first token)
:attributes #{attr}
:shape-ids ids}))))
on-detach-all
(mf/use-fn
(mf/deps on-detach-token)
(fn [token]
(run! #(on-detach-token token %) [:r1 :r2 :r3 :r4])))
on-detach-r1
(mf/use-fn
(mf/deps on-detach-token)
(fn [token]
(on-detach-token token :r1)))
on-detach-r2
(mf/use-fn
(mf/deps on-detach-token)
(fn [token]
(on-detach-token token :r2)))
on-detach-r3
(mf/use-fn
(mf/deps on-detach-token)
(fn [token]
(on-detach-token token :r3)))
on-detach-r4
(mf/use-fn
(mf/deps on-detach-token)
(fn [token]
(on-detach-token token :r4)))
change-radius
(mf/use-fn
(mf/deps ids)
@@ -166,54 +54,31 @@
{:reg-objects? true
:attrs [:r1 :r2 :r3 :r4]})))
change-one-radius
(mf/use-fn
(mf/deps ids)
(fn [update-fn attr]
(dwsh/update-shapes ids
(fn [shape]
(if (ctsr/has-radius? shape)
(update-fn shape)
shape))
{:reg-objects? true
:attrs [attr]})))
toggle-radius-mode
(mf/use-fn
(mf/deps radius-expanded)
(fn []
(swap! radius-expanded* not)))
on-all-radius-change
(mf/use-fn
(mf/deps change-radius ids)
(fn [value]
(if (or (string? value) (number? value))
(st/emit!
(change-radius (fn [shape]
(ctsr/set-radius-to-all-corners shape value))))
(doseq [attr [:r1 :r2 :r3 :r4]]
(st/emit!
(dwta/toggle-token {:token (first value)
:attrs #{attr}
:shape-ids ids}))))))
on-single-radius-change
(mf/use-fn
(mf/deps change-one-radius ids)
(fn [value attr]
(if (or (string? value) (number? value))
(st/emit! (change-one-radius #(ctsr/set-radius-to-single-corner % attr value) attr))
(st/emit! (dwta/toggle-border-radius-token {:token (first value)
:attrs #{attr}
:shape-ids ids})))))
(mf/deps ids change-radius)
(fn [value]
(st/emit!
(change-radius (fn [shape]
(ctsr/set-radius-to-all-corners shape value))))))
on-radius-r1-change #(on-single-radius-change % :r1)
on-radius-r2-change #(on-single-radius-change % :r2)
on-radius-r3-change #(on-single-radius-change % :r3)
on-radius-r4-change #(on-single-radius-change % :r4)
on-radius-4-change
(mf/use-fn
(mf/deps ids change-radius)
(fn [value attr]
(st/emit! (change-radius #(ctsr/set-radius-to-single-corner % attr value)))))
on-radius-r1-change #(on-radius-4-change % :r1)
on-radius-r2-change #(on-radius-4-change % :r2)
on-radius-r3-change #(on-radius-4-change % :r3)
on-radius-r4-change #(on-radius-4-change % :r4)
expand-stream
(mf/with-memo []
@@ -227,139 +92,58 @@
(mf/with-effect [ids]
(reset! radius-expanded* false))
[:section {:class (dm/str class " " (stl/css :radius))
:aria-label "border-radius-section"}
[:div {:class (dm/str class " " (stl/css :radius))}
(if (not radius-expanded)
(if token-numeric-inputs
[:> numeric-input-wrapper*
{:on-change on-all-radius-change
:on-detach on-detach-all
:icon i/corner-radius
[:div {:class (stl/css :radius-1)
:title (tr "workspace.options.radius")}
[:> icon* {:icon-id i/corner-radius
:size "s"
:class (stl/css :icon)}]
[:> deprecated-input/numeric-input*
{:placeholder (cond
(not all-equal?)
(tr "settings.multiple")
(= :multiple (:r1 values))
(tr "settings.multiple")
:else
"--")
:min 0
:nillable true
:on-change on-single-radius-change
:value (if all-equal? (:r1 values) nil)}]]
[:div {:class (stl/css :radius-4)}
[:div {:class (stl/css :small-input)}
[:> deprecated-input/numeric-input*
{:placeholder "--"
:title (tr "workspace.options.radius-top-left")
:min 0
:name :border-radius
:nillable true
:property (tr "workspace.options.radius")
:class (stl/css :radius-wrapper)
:applied-tokens applied-tokens
:radius :all
:align :right
:values (if all-values-equal?
(if (nil? (:r1 values))
0
(:r1 values))
nil)}]
:on-change on-radius-r1-change
:value (:r1 values)}]]
[:div {:class (stl/css :radius-1)
:title (tr "workspace.options.radius")}
[:> icon* {:icon-id i/corner-radius
:size "s"
:class (stl/css :icon)}]
[:> deprecated-input/numeric-input*
{:placeholder (cond
(not all-values-equal?)
(tr "settings.multiple")
(= :multiple (:r1 values))
(tr "settings.multiple")
:else
"--")
:min 0
:nillable true
:on-change on-all-radius-change
:value (if all-values-equal?
(if (nil? (:r1 values))
0
(:r1 values))
nil)}]])
[:div {:class (stl/css :small-input)}
[:> deprecated-input/numeric-input*
{:placeholder "--"
:title (tr "workspace.options.radius-top-right")
:min 0
:on-change on-radius-r2-change
:value (:r2 values)}]]
(if token-numeric-inputs
[:div {:class (stl/css :radius-4)}
[:> numeric-input-wrapper*
{:on-change on-radius-r1-change
:on-detach on-detach-r1
:min 0
:name :border-radius
:property (tr "workspace.options.radius-top-left")
:applied-tokens applied-tokens
:radius :r1
:align :right
:class (stl/css :radius-wrapper :dropdown-offset)
:inner-class (stl/css :no-icon-input)
:values (:r1 values)}]
[:div {:class (stl/css :small-input)}
[:> deprecated-input/numeric-input*
{:placeholder "--"
:title (tr "workspace.options.radius-bottom-left")
:min 0
:on-change on-radius-r4-change
:value (:r4 values)}]]
[:> numeric-input-wrapper*
{:on-change on-radius-r2-change
:on-detach on-detach-r2
:min 0
:name :border-radius
:nillable true
:property (tr "workspace.options.radius-top-right")
:applied-tokens applied-tokens
:align :right
:class (stl/css :radius-wrapper)
:inner-class (stl/css :no-icon-input)
:radius :r2
:values (:r2 values)}]
[:> numeric-input-wrapper*
{:on-change on-radius-r4-change
:on-detach on-detach-r4
:min 0
:name :border-radius
:nillable true
:property (tr "workspace.options.radius-bottom-left")
:applied-tokens applied-tokens
:class (stl/css :radius-wrapper :dropdown-offset)
:inner-class (stl/css :no-icon-input)
:radius :r4
:align :right
:values (:r4 values)}]
[:> numeric-input-wrapper*
{:on-change on-radius-r3-change
:on-detach on-detach-r3
:min 0
:name :border-radius
:nillable true
:property (tr "workspace.options.radius-bottom-right")
:applied-tokens applied-tokens
:radius :r3
:align :right
:class (stl/css :radius-wrapper)
:inner-class (stl/css :no-icon-input)
:values (:r3 values)}]]
[:div {:class (stl/css :radius-4)}
[:div {:class (stl/css :small-input)}
[:> deprecated-input/numeric-input*
{:placeholder "--"
:title (tr "workspace.options.radius-top-left")
:min 0
:on-change on-radius-r1-change
:value (:r1 values)}]]
[:div {:class (stl/css :small-input)}
[:> deprecated-input/numeric-input*
{:placeholder "--"
:title (tr "workspace.options.radius-top-right")
:min 0
:on-change on-radius-r2-change
:value (:r2 values)}]]
[:div {:class (stl/css :small-input)}
[:> deprecated-input/numeric-input*
{:placeholder "--"
:title (tr "workspace.options.radius-bottom-left")
:min 0
:on-change on-radius-r4-change
:value (:r4 values)}]]
[:div {:class (stl/css :small-input)}
[:> deprecated-input/numeric-input*
{:placeholder "--"
:title (tr "workspace.options.radius-bottom-right")
:min 0
:on-change on-radius-r3-change
:value (:r3 values)}]]]))
[:div {:class (stl/css :small-input)}
[:> deprecated-input/numeric-input*
{:placeholder "--"
:title (tr "workspace.options.radius-bottom-right")
:min 0
:on-change on-radius-r3-change
:value (:r3 values)}]]])
[:> icon-button* {:variant "ghost"
:on-click toggle-radius-mode

View File

@@ -5,8 +5,6 @@
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/typography" as t;
@use "ds/_utils.scss" as *;
.radius {
display: grid;
@@ -16,7 +14,7 @@
.radius-1 {
@extend .input-element;
@include t.use-typography("body-small");
@include deprecated.bodySmallTypography;
}
.radius-4 {
@@ -27,27 +25,9 @@
.small-input {
@extend .input-element;
@include t.use-typography("body-small");
}
.selected {
border-color: var(--button-icon-border-color-selected);
background-color: var(--button-icon-background-color-selected);
color: var(--color-accent-primary);
@include deprecated.bodySmallTypography;
}
.icon {
margin-inline: var(--sp-xs);
}
.radius-wrapper {
--dropdown-width: var(--7-columns-dropdown-width);
}
.no-icon-input {
padding-inline-start: px2rem(6);
}
.dropdown-offset {
--dropdown-offset: #{px2rem(-65)};
margin-inline: deprecated.$s-4;
}

View File

@@ -78,7 +78,7 @@
(nil? (get values name)))
(tr "settings.multiple")
"--")
:class (stl/css :numeric-input-layout)
:class (stl/css :numeric-input-measures)
:applied-token (get applied-tokens name)
:tokens tokens
:align align

View File

@@ -358,6 +358,6 @@
align-items: center;
}
.numeric-input-layout {
.numeric-input-measures {
--dropdown-width: var(--7-columns-dropdown-width);
}

View File

@@ -600,7 +600,10 @@
[:> border-radius-menu* {:class (stl/css :border-radius)
:ids ids
:values values
:applied-tokens applied-tokens}])])
:applied-tokens applied-tokens
:shapes shapes
:shape shape}])])
(when (or (options :clip-content)
(options :show-in-viewer))
[:div {:class (stl/css :clip-show)}

View File

@@ -6,7 +6,6 @@
@use "refactor/common-refactor.scss" as deprecated;
@use "../../../sidebar/common/sidebar.scss" as sidebar;
@use "ds/_utils.scss" as *;
.element-set {
display: grid;
@@ -157,6 +156,7 @@
gap: deprecated.$s-4;
}
// TODO: Add a proper variable to this sizing
.numeric-input-measures {
--dropdown-width: var(--7-columns-dropdown-width);
}

View File

@@ -223,6 +223,7 @@
(cond
(= existing ::not-found) (assoc acc t-attr new-val)
(= existing new-val) acc
(nil? new-val) acc
:else (assoc acc t-attr :multiple))))
merge-shape-attr
@@ -236,8 +237,10 @@
(fn [acc shape-attrs applied-tokens]
"Merges token values across all shape attributes.
For each shape attribute, its corresponding token attributes are merged
into the accumulator."
(reduce #(merge-shape-attr %1 applied-tokens %2) acc shape-attrs))
into the accumulator. If applied tokens are empty, the accumulator is returned unchanged."
(if (seq applied-tokens)
(reduce #(merge-shape-attr %1 applied-tokens %2) acc shape-attrs)
acc))
extract-attrs
(fn [[ids values token-acc] {:keys [id type applied-tokens] :as shape}]