refactor(studio): extract shared layerOrdering module from LayersPanel

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-08 23:46:24 -04:00
parent 0ac000181e
commit dae26e72b2
3 changed files with 164 additions and 22 deletions
@@ -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<string, string> = {
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;
@@ -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([]);
});
});
@@ -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<T extends ContextOrderItem>(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),
);
}