Skip to content

feat(studio): draggable layer reorder with z-index persistence#1216

Merged
miguel-heygen merged 5 commits into
mainfrom
feat/layers-panel-drag-reorder
Jun 6, 2026
Merged

feat(studio): draggable layer reorder with z-index persistence#1216
miguel-heygen merged 5 commits into
mainfrom
feat/layers-panel-drag-reorder

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

Summary

  • Layers panel now sorts siblings by computed z-index (descending) to reflect visual stacking order
  • Users can drag layer rows to reorder them within a sibling group — on drop, sequential z-index values are assigned and persisted via the existing inline-style patch pipeline
  • Single preview reload and coalesced undo entry per reorder operation

Changes

  • sortLayersByZIndex: recursive sibling-group sort by computed z-index with DOM-order tiebreaker
  • useLayerDrag: pointer-capture drag gesture with 4px threshold, insertion indicator line, depth-constrained sibling reorder
  • handleDomZIndexReorderCommit: batch z-index commit through commitPositionPatchToHtml with shared coalesceKey
  • 7 unit tests covering sort edge cases (descending, auto/explicit mix, nested independence)

Test plan

  • Open a composition with multiple sibling elements in the layers panel
  • Drag a layer row up or down — insertion indicator line should appear
  • Drop at new position — z-index values should update, preview reloads once
  • Verify PropertyPanel z-index field reflects new values
  • Undo (Cmd+Z) should revert the entire reorder as one operation
  • Layers with no selector/id should not be draggable
  • Cross-depth drag (child to uncle level) should be prevented

…tence

Layers panel now sorts siblings by computed z-index (descending) to
reflect visual stacking order. Users can drag layer rows to reorder
them within a sibling group — on drop, sequential z-index values are
assigned and persisted via the existing inline-style patch pipeline
with a single preview reload.

- sortLayersByZIndex: recursive sibling-group sort by computed z-index
- useLayerDrag: pointer-capture drag gesture with 4px threshold,
  insertion indicator line, and depth-constrained sibling reorder
- handleDomZIndexReorderCommit: batch z-index commit with coalesced
  undo entry and single skipRefresh=false on the final patch
@miguel-heygen miguel-heygen force-pushed the feat/layers-panel-drag-reorder branch from 1c7ef5e to 581ba85 Compare June 5, 2026 22:19
- Guard drag initiation against locked compositions by checking
  data-timeline-locked ancestors in isLayerDraggable
- Show not-allowed cursor and reduced opacity on non-draggable layer rows
- Fire toast when attempting to drag a layer with no same-depth siblings
- Preserve z-index spacing on reorder by redistributing existing values
  instead of flattening to sequential integers
- Auto-set position:relative on unpositioned elements when z-index is
  applied so the stacking order actually takes visual effect
- Add tests for isLayerDraggable (anonymous, id, selector, locked, free)
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 5, 2026

Fallow audit report

Found 17 findings.

Duplication (11)
Severity Rule Location Description
minor fallow/code-duplication packages/studio/src/components/editor/LayersPanel.test.ts:43 Code clone group 1 (5 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/editor/LayersPanel.test.ts:44 Code clone group 2 (7 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/editor/LayersPanel.test.ts:52 Code clone group 3 (5 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/editor/LayersPanel.test.ts:61 Code clone group 3 (5 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/editor/LayersPanel.test.ts:62 Code clone group 2 (7 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/editor/LayersPanel.test.ts:91 Code clone group 1 (5 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/contexts/DomEditContext.tsx:16 Code clone group 4 (61 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/contexts/DomEditContext.tsx:38 Code clone group 5 (40 lines, 3 instances)
minor fallow/code-duplication packages/studio/src/contexts/DomEditContext.tsx:85 Code clone group 4 (61 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/contexts/DomEditContext.tsx:107 Code clone group 5 (40 lines, 3 instances)
minor fallow/code-duplication packages/studio/src/hooks/useDomEditSession.ts:558 Code clone group 5 (40 lines, 3 instances)
Health (6)
Severity Rule Location Description
minor fallow/high-crap-score packages/studio/src/components/editor/LayersPanel.test.ts:9 'makeLayer' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
minor fallow/high-crap-score packages/studio/src/components/editor/LayersPanel.tsx:135 'seekToLayer' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
minor fallow/high-crap-score packages/studio/src/components/editor/LayersPanel.tsx:265 '<arrow>' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)
major fallow/high-crap-score packages/studio/src/hooks/useDomEditCommits.ts:42 'isElementGsapTargeted' has CRAP score 63.6 (threshold: 30.0, cyclomatic 15)
critical fallow/high-crap-score packages/studio/src/hooks/useDomEditCommits.ts:166 'persistDomEditOperations' has CRAP score 238.6 (threshold: 30.0, cyclomatic 31)
major fallow/high-crap-score packages/studio/src/hooks/useDomEditCommits.ts:457 'handleDomEditElementDelete' has CRAP score 79.4 (threshold: 30.0, cyclomatic 17)

Generated by fallow.

Copy link
Copy Markdown

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary: drag-to-reorder on the Layers panel. sortLayersByZIndex flips sibling chunks to descending computed z-index; useLayerDrag runs a pointer-capture gesture with a 4px threshold and an insertion-line indicator; handleDomZIndexReorderCommit writes the new z-index values onto siblings as inline styles, auto-promotes position: staticrelative where needed, and batches the writes through commitPositionPatchToHtml with a shared coalesceKey so the undo stack treats the whole reorder as one entry. PR body is precise and the test plan is the right shape.

One real bug in the reorder math (concern #1), one layout-side-effect to think about (concern #2), CI is red on two checks, and a few smaller things. No blockers — concern #1 is fixable cheaply and would normally be a blocker, but since it silently reverts to status quo on reload (no data loss, no visible regression to layers that aren't z-tied), I'm tagging it as a concern with a sharp fix path rather than a blocker. Your call.

Concerns

  • Tied z-index reorders silently persist nothing (LayersPanel.tsx:194–218). The handleReorder math handles the allSame case but falls through on partial ties. Walk-through: siblings with computed z [3, 2, 2, 1], user drags the second 2 above the first 2. existingValues = [3, 2, 2, 1]sorted = [3, 2, 2, 1]allSame = falsezValues = sorted = [3, 2, 2, 1]. The reordered positions get assigned [3, 2, 2, 1] again — both tied elements stay at z=2. CSS resolves ties by DOM order (unchanged by this PR), so paint order is identical to before; and on next iframe reload sortLayersByZIndex's tiebreaker (b.domIndex - a.domIndex) restores the original panel order, so the drag visually vanishes too. Fix: when any duplicates exist in sorted (not just all-same), generate fresh sequential ints (reordered.map((_, i) => reordered.length - i)), or interleave new ints between non-tied neighbors so only the tied subrange churns. A single unit test ([2, 1, 2] drag last to front → assert resulting z-values give a stable strict order) would have caught it; same test would lock the chosen fix.
  • position: staticposition: relative auto-promote is a hidden layout-context change (useDomEditCommits.ts:551–556). Necessary for z-index to take effect on static elements — totally pragmatic — but relative also makes the element a containing block for absolute descendants. If a static element has position: absolute children that were positioning relative to a further-up ancestor, this PR silently captures them on first reorder. Most studio elements probably aren't static (the panel-edit defaults push everything to relative/absolute), but worth either gating the promote on element.querySelector(":scope > [style*='position: absolute'], :scope > [style*='position:absolute']") returning null, or just adding a Test-plan line for "static element with abs children, reorder, abs children stay where they were."
  • Two CI checks red:
    • File size checkuseDomEditSession.ts is 602 / 600 lines (the new handleDomZIndexReorderCommit destructure plus the useGsapSelectionHandlers import nudges it over). The extraction of GSAP handlers into useGsapSelectionHandlers.ts is a great refactor and almost gets you there; another small extraction (e.g. pulling the handleGsapAware* adapters into their own hook) closes the gap without touching the bot of the new feature.
    • Fallow audit — the bot comment lists the new findings: a couple of test-file dup groups (low-stakes) and a couple of new high-CRAP-score callouts on LayersPanel.tsx (seekToLayer, the row-render arrow). Mostly minor; the seekToLayer cyclomatic 12 is the result of the matched-id-fallback walk that's been carried forward unchanged. Worth either suppressing or splitting the row render's inline className ternary mess.
  • Visual feedback during cross-depth drag is misleading. Depth-constrained reorder is the right call (PR body item: "Cross-depth drag (child to uncle level) should be prevented"), but the implementation just leaves the insertion line anchored to the original sibling band even when the pointer is hovering over a different parent group entirely. Users will read that as "the drag is broken." Either swap the cursor to not-allowed once clientY falls outside [siblingRects[0].top, last.bottom], or fade the indicator. Cheap UX win for a feature that's hard to teach otherwise.

Nits

  • No keyboard-driven reorder. The row already has role="button" + tabIndex={0} + Enter/Space for select. An ArrowUp / ArrowDown with a modifier (Option-Up / Option-Down would mirror Figma) that calls handleReorder({ fromIndex, toIndex }) directly would close the a11y gap on drag-and-drop, which is famously the worst pattern for AT users. The reorder math is already factored out — wiring it would be ~10 lines. (nit)
  • Touch / pen path not in the test plan. Pointer events make it work for free in theory; in practice the 4px threshold + scroll-container's touch-action (which I didn't see set on the scroll container) often conflicts with vertical-scroll gestures on tablet. Worth a quick iPad pass given Studio occasionally gets used there, and consider touch-action: pan-y on the row so a touch-drag doesn't accidentally scroll. (nit)
  • Optimistic DOM write before patch confirms (useDomEditCommits.ts:547). entry.element.style.zIndex = ... runs before commitPositionPatchToHtml returns. If the patch fails (server 500, file-read race, etc.) the iframe DOM is one step ahead of source. The reorder will revert on next preview reload, but mid-session the user sees stale state. Error is toasted, so not silent — minor. (nit)
  • Test coverage gap on the reorder math. sortLayersByZIndex and isLayerDraggable are nicely covered. The handleReorder math (the location of concern #1 above) is untested; findSiblingIndices, computeInsertionPos, computeInsertionLineY are also pure functions ripe for a few cases each. Pure-function units would cover ~80% of the bug surface here. (nit)
  • findSiblingIndices (useLayerDrag.ts:48–68) could simplify the start-walk to for (let i = layerIndex - 1; i >= 0; i--) if (visibleLayers[i].depth < depth) { start = i + 1; break; } — same logic, no start--; check; start++; break shuffle. (style nit)
  • useDomEditSession.ts refactor is bundled into a feature PR. The extraction of GSAP handlers into useGsapSelectionHandlers is clean and orthogonal to drag-reorder. Splitting that into its own PR ahead of this one would make git blame on the feature work cleaner, and would isolate the file-size-check fix. Not a blocker; just a "next time" thought. (nit)
  • isLayerDraggable's ancestor walk uses parentElement, which stops cleanly at the iframe document root. Worth a one-line comment noting that's deliberate (so a future reader doesn't "fix" it by walking parentNode and accidentally reach into the host page). (nit)
  • getElementZIndex swallows everything inside try/catch and falls back to 0. The 0 collapses to "auto / no-z" semantics, but if getComputedStyle throws because the element is detached (which is the case on cross-iframe gotchas), you can't distinguish "deliberately 0" from "couldn't read." Doesn't matter for sorting today; worth a null return + caller fallback if the codepath ever feeds something stricter. (nit)

Questions

  • Was the auto-promote from staticrelative discussed with the team? It's a sensible default for the z-index reorder to take effect, but it's a layout-context change. If the answer is "yes, all studio-managed elements are non-static already and this is just a safety net," consider a debug console.warn so the case is visible if it ever fires.
  • The tied-z behaviour (concern #1) — intentional ("don't churn already-tied z-indices, let DOM order decide") or oversight? If intentional, lock it with a test + a comment explaining the no-op semantics so the next person debugging "my drag didn't stick" has a pointer.
  • Multi-select reorder — explicitly out of scope for this PR, or coming next? The current selection model is single, so this is a future-question; if multi-drag is on the roadmap, the entries shape in handleDomZIndexReorderCommit is already roughly the right primitive to extend.
  • Coalesce-key shape z-reorder:${entries.map((e) => e.id ?? e.selector ?? "el").join(":")} — for two rapid-fire reorders on the same sibling group, this collapses into a single undo entry even if the user did them as two distinct intents. Is the coalesce window time-bounded somewhere upstream, or is it purely key-based? (If purely key-based, rapid double-drags could be unundo-able as separate steps.)

What I didn't verify

  • The commitPositionPatchToHtmleditHistory.recordEdit pipeline's exact coalesce semantics — trusted the PR body's "coalesced undo entry per reorder" claim.
  • The setPointerCapture release on pointercancel (e.g., OS gesture interruption, iframe reload mid-drag). The try/catch around releasePointerCapture is defensively shaped.
  • Whether the iframe load event firing mid-drag (HMR, post-commit reload, etc.) cleanly cancels the gesture. The dragRef is only cleared on pointerup / pointercancel.
  • Live behaviour with data-timeline-locked ancestors inside nested compositions — only the direct-ancestor walk was traced.
  • Touch / pen behaviour on tablet (no setup).
  • Manual test-plan items all unchecked — assumed Miguel will run them locally pre-merge; not blocking on it.
  • The useGsapSelectionHandlers extraction in useDomEditSession.ts — read the call-site only; assumed mechanical hoist.

Review by Rames D Jusso

- Fall back to sequential z-index when any duplicates exist in the
  sibling set, not just when all values are identical — fixes silent
  no-op reorder when tied values preserve DOM-order stacking
- Trim useDomEditSession.ts from 602 to 600 lines (CI file-size gate)
Copy link
Copy Markdown

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 2 follow-up on a8be5baf.

The headline bug is closed cleanly:

  • Tied-z fix (LayersPanel.tsx:200–206) — allSamehasDupes check across the sorted array. Any adjacent duplicate triggers full sequential renumbering (reordered.map((_, i) => reordered.length - i)); strictly-monotonic z sets keep the existing-values reuse path (so reorders inside [10, 5, 1] still preserve the value structure). Walked the cases:
    • [3, 2, 2, 1]hasDupes=truezValues=[4, 3, 2, 1] (formerly-tied pair gets distinct values, drag persists) ✓
    • [3, 3, 3, 3]hasDupes=truezValues=[4, 3, 2, 1] (matches the old all-same branch) ✓
    • [3, 2, 1]hasDupes=falsezValues=[3, 2, 1] (preserves the user's gap structure) ✓
    • [5, 5]hasDupes=truezValues=[2, 1] (two-element tie is broken) ✓
  • File size trim (useDomEditSession.ts:538–544) — removed two blank lines around Refs / Callbacks comment dividers, brings the file from 602 → 600. Pragmatic, gets CI green; longer-term you'll probably want to extract another small chunk if that file keeps growing. Not blocking.

One tiny follow-up that would normally be a "do it": the tied-z fix is the kind of thing a [2, 1, 2]drag last to front unit test (against handleReorder or a hoisted pure helper) would lock against future regressions. The PR doesn't add one. Not a blocker — the visual inspection is straightforward and the existing sortLayersByZIndex suite would catch the symptom on next reload — but if this code gets touched again, the next reader has nothing pinning the semantics. Worth ~5 lines of test.

Round-1 items intentionally deferred (no objection):

  • position: staticrelative auto-promote — not touched, fine; if it ever bites in practice we can scope-gate later. The CSS-y "I want z-index to apply" reading is correct.
  • Cross-depth-drag indicator UX — also not touched, fine for v1.
  • Keyboard-driven reorder, touch test plan, optimistic-DOM ordering, the smaller nits — all carry forward as future polish.

CI is mid-flight at review time; File size check is already SUCCESS (the round-1 red), Fallow audit is re-running on a8be5baf. Assuming both land green, looks good to me.

Review by Rames D Jusso

Cover the [2, 1, 2] case where tied z-index values fall back to
reverse DOM order — locks the hasDupes fix against regressions.
@miguel-heygen miguel-heygen merged commit 7a0cb08 into main Jun 6, 2026
44 of 45 checks passed
@miguel-heygen miguel-heygen deleted the feat/layers-panel-drag-reorder branch June 6, 2026 00:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants