From dae26e72b23a5907d173177a72e1f96bd5483934 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 7 Jul 2026 22:05:15 -0400 Subject: [PATCH] refactor(studio): extract shared layerOrdering module from LayersPanel --- .../src/components/editor/LayersPanel.tsx | 24 +----- .../src/player/lib/layerOrdering.test.ts | 77 +++++++++++++++++ .../studio/src/player/lib/layerOrdering.ts | 85 +++++++++++++++++++ 3 files changed, 164 insertions(+), 22 deletions(-) create mode 100644 packages/studio/src/player/lib/layerOrdering.test.ts create mode 100644 packages/studio/src/player/lib/layerOrdering.ts diff --git a/packages/studio/src/components/editor/LayersPanel.tsx b/packages/studio/src/components/editor/LayersPanel.tsx index 15ffedca1..915b81c6a 100644 --- a/packages/studio/src/components/editor/LayersPanel.tsx +++ b/packages/studio/src/components/editor/LayersPanel.tsx @@ -14,6 +14,7 @@ import { } from "../../utils/studioHelpers"; import { Layers } from "../../icons/SystemIcons"; import { useLayerDrag, isLayerDraggable, type LayerReorderEvent } from "./useLayerDrag"; +import { computeReorderZValues, getElementZIndex } from "../../player/lib/layerOrdering"; const TAG_ICONS: Record = { video: "Vi", @@ -228,9 +229,7 @@ export const LayersPanel = memo(function LayersPanel() { reordered.splice(toIndex, 0, moved); const existingValues = siblingLayers.map((l) => getElementZIndex(l.element)); - const sorted = [...existingValues].sort((a, b) => b - a); - const hasDupes = sorted.some((v, i) => i > 0 && v === sorted[i - 1]); - const zValues = hasDupes ? reordered.map((_, i) => reordered.length - i) : sorted; + const zValues = computeReorderZValues(existingValues, fromIndex, toIndex); const entries = reordered.map((layer, i) => ({ element: layer.element, @@ -388,25 +387,6 @@ export const LayersPanel = memo(function LayersPanel() { // ── Pure helpers ────────────────────────────────────────────────────── -// fallow-ignore-next-line complexity -function getElementZIndex(element: HTMLElement): number { - try { - const inline = element.style?.zIndex; - if (inline && inline !== "auto") { - const parsed = parseInt(inline, 10); - if (Number.isFinite(parsed)) return parsed; - } - const win = element.ownerDocument?.defaultView; - if (!win) return 0; - const value = win.getComputedStyle(element).zIndex; - if (value === "auto" || value === "") return 0; - const parsed = parseInt(value, 10); - return Number.isFinite(parsed) ? parsed : 0; - } catch { - return 0; - } -} - // fallow-ignore-next-line complexity export function sortLayersByZIndex(layers: DomEditLayerItem[]): DomEditLayerItem[] { if (layers.length <= 1) return layers; diff --git a/packages/studio/src/player/lib/layerOrdering.test.ts b/packages/studio/src/player/lib/layerOrdering.test.ts new file mode 100644 index 000000000..cb5f035a3 --- /dev/null +++ b/packages/studio/src/player/lib/layerOrdering.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from "vitest"; +import { computeReorderZValues, getElementZIndex, resolveContextOrder } from "./layerOrdering"; + +function makeElement(zIndex?: string): HTMLElement { + const element = document.createElement("div"); + if (zIndex != null) element.style.zIndex = zIndex; + document.body.appendChild(element); + return element; +} + +describe("getElementZIndex", () => { + it("returns inline z-index when present", () => { + expect(getElementZIndex(makeElement("7"))).toBe(7); + }); + + it("falls back to computed style when inline z-index is not usable", () => { + const element = makeElement(); + element.className = "computed-z"; + const style = document.createElement("style"); + style.textContent = ".computed-z { position: relative; z-index: 12; }"; + document.head.appendChild(style); + + expect(getElementZIndex(element)).toBe(12); + }); + + it("returns zero for auto or missing z-index", () => { + expect(getElementZIndex(makeElement())).toBe(0); + expect(getElementZIndex(makeElement("auto"))).toBe(0); + }); +}); + +describe("computeReorderZValues", () => { + it("preserves distinct existing z-index values and remaps them onto the new order", () => { + expect(computeReorderZValues([1, 8, 3], 0, 2)).toEqual([8, 3, 1]); + }); + + it("renumbers an all-tied group to descending contiguous z-index values", () => { + expect(computeReorderZValues([0, 0, 0], 2, 0)).toEqual([3, 2, 1]); + }); + + it("renumbers the whole group when any existing z-index values are tied", () => { + expect(computeReorderZValues([5, 5, 1], 2, 0)).toEqual([3, 2, 1]); + }); +}); + +describe("resolveContextOrder", () => { + it("sorts a flat sibling group by z-index descending then original order", () => { + const ordered = resolveContextOrder([ + { id: "a", zIndex: 2, parentCompositionId: null, compositionAncestors: ["root"] }, + { id: "b", zIndex: 5, parentCompositionId: null, compositionAncestors: ["root"] }, + { id: "c", zIndex: 5, parentCompositionId: null, compositionAncestors: ["root"] }, + ]); + + expect(ordered.map((item) => item.id)).toEqual(["b", "c", "a"]); + }); + + it("keeps distinct stacking contexts from interleaving", () => { + const ordered = resolveContextOrder([ + { id: "root-low", zIndex: 1, parentCompositionId: null, compositionAncestors: ["root"] }, + { + id: "nested-high", + zIndex: 100, + parentCompositionId: "scene", + compositionAncestors: ["root", "scene"], + }, + { id: "root-top", zIndex: 2, parentCompositionId: null, compositionAncestors: ["root"] }, + ]); + + expect(ordered.map((item) => item.id)).toEqual(["root-top", "root-low", "nested-high"]); + }); + + it("returns an empty list for empty input", () => { + expect(resolveContextOrder([])).toEqual([]); + }); +}); diff --git a/packages/studio/src/player/lib/layerOrdering.ts b/packages/studio/src/player/lib/layerOrdering.ts new file mode 100644 index 000000000..f016b11bc --- /dev/null +++ b/packages/studio/src/player/lib/layerOrdering.ts @@ -0,0 +1,85 @@ +export interface StackingContextDescriptor { + parentCompositionId: string | null; + compositionAncestors: readonly string[]; + stackingContextId?: string | null; +} + +export interface ContextOrderItem extends StackingContextDescriptor { + zIndex: number; +} + +// fallow-ignore-next-line complexity +export function getElementZIndex(element: HTMLElement): number { + try { + const inline = element.style?.zIndex; + if (inline && inline !== "auto") { + const parsed = parseInt(inline, 10); + if (Number.isFinite(parsed)) return parsed; + } + const win = element.ownerDocument?.defaultView; + if (!win) return 0; + const value = win.getComputedStyle(element).zIndex; + if (value === "auto" || value === "") return 0; + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : 0; + } catch { + return 0; + } +} + +export function computeReorderZValues( + existingValues: readonly number[], + fromIndex: number, + toIndex: number, +): number[] { + const reordered = [...existingValues]; + const [moved] = reordered.splice(fromIndex, 1); + reordered.splice(toIndex, 0, moved); + + const sorted = [...existingValues].sort((a, b) => b - a); + const hasDupes = sorted.some((v, i) => i > 0 && v === sorted[i - 1]); + return hasDupes ? reordered.map((_, i) => reordered.length - i) : sorted; +} + +// Exported in a later unit when the timeline consumes it; internal-only for now. +function resolveStackingContextKey(item: StackingContextDescriptor): string { + return item.stackingContextId ?? item.parentCompositionId ?? item.compositionAncestors[0] ?? ""; +} + +function resolveStackingContextDepth(item: StackingContextDescriptor): number { + const contextKey = resolveStackingContextKey(item); + if (!contextKey) return 0; + const index = item.compositionAncestors.indexOf(contextKey); + return index >= 0 ? index : 0; +} + +export function resolveContextOrder(items: readonly T[]): T[] { + if (items.length === 0) return []; + + const groups = new Map< + string, + { firstIndex: number; depth: number; entries: Array<{ item: T; index: number }> } + >(); + + items.forEach((item, index) => { + const key = resolveStackingContextKey(item); + const group = groups.get(key); + if (group) { + group.entries.push({ item, index }); + return; + } + groups.set(key, { + firstIndex: index, + depth: resolveStackingContextDepth(item), + entries: [{ item, index }], + }); + }); + + return [...groups.values()] + .sort((a, b) => a.depth - b.depth || a.firstIndex - b.firstIndex) + .flatMap((group) => + group.entries + .sort((a, b) => b.item.zIndex - a.item.zIndex || a.index - b.index) + .map((entry) => entry.item), + ); +}