Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions packages/studio/src/components/editor/LayersPanel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// @vitest-environment happy-dom

import { describe, expect, it } from "vitest";
import { Window } from "happy-dom";
import type { DomEditLayerItem } from "./domEditingTypes";
import { sortLayersByZIndex } from "./LayersPanel";
import { isLayerDraggable } from "./useLayerDrag";

function makeLayer(
overrides: Partial<DomEditLayerItem> & { zIndex?: string; locked?: boolean },
): DomEditLayerItem {
const win = new Window();
const doc = win.document;
const parent = doc.createElement("div") as unknown as HTMLElement;
if (overrides.locked) {
(parent as unknown as Element).setAttribute("data-timeline-locked", "true");
}
const el = doc.createElement("div") as unknown as HTMLElement;
parent.appendChild(el as unknown as Node);
if (overrides.zIndex != null) {
(el as unknown as { style: { zIndex: string } }).style.zIndex = overrides.zIndex;
}
if (overrides.id) {
(el as unknown as Element).setAttribute("id", overrides.id);
}
return {
key: overrides.key ?? `layer-${Math.random()}`,
element: el,
label: overrides.label ?? "div",
tagName: overrides.tagName ?? "div",
depth: overrides.depth ?? 0,
childCount: overrides.childCount ?? 0,
id: overrides.id,
selector: overrides.selector,
selectorIndex: overrides.selectorIndex,
sourceFile: overrides.sourceFile ?? "index.html",
};
}

describe("sortLayersByZIndex", () => {
it("sorts siblings by z-index descending", () => {
const a = makeLayer({ key: "a", zIndex: "1", depth: 0 });
const b = makeLayer({ key: "b", zIndex: "3", depth: 0 });
const c = makeLayer({ key: "c", zIndex: "2", depth: 0 });

const sorted = sortLayersByZIndex([a, b, c]);
expect(sorted.map((l) => l.key)).toEqual(["b", "c", "a"]);
});

it("preserves DOM order (reversed) for siblings with auto z-index", () => {
const a = makeLayer({ key: "a", depth: 0 });
const b = makeLayer({ key: "b", depth: 0 });
const c = makeLayer({ key: "c", depth: 0 });

const sorted = sortLayersByZIndex([a, b, c]);
expect(sorted.map((l) => l.key)).toEqual(["c", "b", "a"]);
});

it("sorts explicit z-index above auto, auto elements maintain reversed DOM order", () => {
const a = makeLayer({ key: "a", depth: 0 });
const b = makeLayer({ key: "b", zIndex: "5", depth: 0 });
const c = makeLayer({ key: "c", depth: 0 });

const sorted = sortLayersByZIndex([a, b, c]);
expect(sorted.map((l) => l.key)).toEqual(["b", "c", "a"]);
});

it("sorts children independently of their parent's siblings", () => {
const parent1 = makeLayer({ key: "p1", zIndex: "1", depth: 0, childCount: 2 });
const child1a = makeLayer({ key: "c1a", zIndex: "3", depth: 1 });
const child1b = makeLayer({ key: "c1b", zIndex: "1", depth: 1 });
const parent2 = makeLayer({ key: "p2", zIndex: "2", depth: 0, childCount: 1 });
const child2a = makeLayer({ key: "c2a", zIndex: "1", depth: 1 });

const sorted = sortLayersByZIndex([parent1, child1a, child1b, parent2, child2a]);
expect(sorted.map((l) => l.key)).toEqual(["p2", "c2a", "p1", "c1a", "c1b"]);
});

it("handles single-element groups without crash", () => {
const single = makeLayer({ key: "only", zIndex: "5", depth: 0 });
const sorted = sortLayersByZIndex([single]);
expect(sorted).toEqual([single]);
});

it("returns empty array for empty input", () => {
expect(sortLayersByZIndex([])).toEqual([]);
});

it("handles duplicate z-index values with reverse DOM order tiebreak", () => {
const a = makeLayer({ key: "a", zIndex: "2", depth: 0 });
const b = makeLayer({ key: "b", zIndex: "1", depth: 0 });
const c = makeLayer({ key: "c", zIndex: "2", depth: 0 });

const sorted = sortLayersByZIndex([a, b, c]);
expect(sorted.map((l) => l.key)).toEqual(["c", "a", "b"]);
});

it("preserves deeply nested structure with sorting at each level", () => {
const root = makeLayer({ key: "root", depth: 0, childCount: 2 });
const a = makeLayer({ key: "a", zIndex: "1", depth: 1, childCount: 2 });
const a1 = makeLayer({ key: "a1", zIndex: "10", depth: 2 });
const a2 = makeLayer({ key: "a2", zIndex: "20", depth: 2 });
const b = makeLayer({ key: "b", zIndex: "2", depth: 1 });

const sorted = sortLayersByZIndex([root, a, a1, a2, b]);
expect(sorted.map((l) => l.key)).toEqual(["root", "b", "a", "a2", "a1"]);
});
});

describe("isLayerDraggable", () => {
it("returns false for layers without id or selector", () => {
const layer = makeLayer({ key: "anon" });
expect(isLayerDraggable(layer)).toBe(false);
});

it("returns true for layers with an id", () => {
const layer = makeLayer({ key: "with-id", id: "my-el" });
expect(isLayerDraggable(layer)).toBe(true);
});

it("returns true for layers with a selector", () => {
const layer = makeLayer({ key: "with-sel", selector: ".my-class" });
expect(isLayerDraggable(layer)).toBe(true);
});

it("returns false for layers inside a locked composition", () => {
const layer = makeLayer({ key: "locked", id: "locked-el", locked: true });
expect(isLayerDraggable(layer)).toBe(false);
});

it("returns true for layers with id and no locked ancestor", () => {
const layer = makeLayer({ key: "free", id: "free-el" });
expect(isLayerDraggable(layer)).toBe(true);
});
});
166 changes: 151 additions & 15 deletions packages/studio/src/components/editor/LayersPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
resolveTimelineSelectionSeekTime,
} from "../../utils/studioHelpers";
import { Layers } from "../../icons/SystemIcons";
import { useLayerDrag, isLayerDraggable, type LayerReorderEvent } from "./useLayerDrag";

const TAG_ICONS: Record<string, string> = {
video: "Vi",
Expand Down Expand Up @@ -51,6 +52,7 @@ interface CollapsedState {
[key: string]: boolean;
}

// fallow-ignore-next-line complexity
export const LayersPanel = memo(function LayersPanel() {
const {
previewIframeRef,
Expand All @@ -59,12 +61,19 @@ export const LayersPanel = memo(function LayersPanel() {
compositionLoading,
timelineElements,
currentTime,
showToast,
} = useStudioContext();
const { domEditSelection, applyDomSelection, updateDomEditHoverSelection } = useDomEditContext();
const {
domEditSelection,
applyDomSelection,
updateDomEditHoverSelection,
handleDomZIndexReorderCommit,
} = useDomEditContext();

const [layers, setLayers] = useState<DomEditLayerItem[]>([]);
const [collapsed, setCollapsed] = useState<CollapsedState>({});
const prevDocVersionRef = useRef(0);
const scrollContainerRef = useRef<HTMLDivElement>(null);

const isMasterView = !activeCompPath || activeCompPath === "index.html";

Expand All @@ -87,7 +96,7 @@ export const LayersPanel = memo(function LayersPanel() {
activeCompositionPath: activeCompPath,
isMasterView,
});
setLayers(items);
setLayers(sortLayersByZIndex(items));
}, [previewIframeRef, activeCompPath, isMasterView]);

useEffect(() => {
Expand Down Expand Up @@ -119,7 +128,6 @@ export const LayersPanel = memo(function LayersPanel() {
isMasterView,
preferClipAncestor: false,
}),
// LayersPanel has no projectId; probe is skipped when projectId is absent
[activeCompPath, isMasterView],
);

Expand All @@ -130,8 +138,6 @@ export const LayersPanel = memo(function LayersPanel() {

let matchedId = findMatchingTimelineElementId(selection, timelineElements);

// No direct match — walk up DOM ancestors to find the nearest element
// that has a timeline entry (e.g. a child of scene1 seeks to scene1.start)
if (!matchedId) {
const sourceFile = selection.sourceFile ?? "index.html";
let ancestor = layer.element.parentElement;
Expand Down Expand Up @@ -185,10 +191,52 @@ export const LayersPanel = memo(function LayersPanel() {
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);

const selectedKey = domEditSelection ? getDomEditLayerKey(domEditSelection) : null;
const handleReorder = useCallback(
(event: LayerReorderEvent) => {
const { siblingLayers, fromIndex, toIndex } = event;
const reordered = [...siblingLayers];
const [moved] = reordered.splice(fromIndex, 1);
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 entries = reordered.map((layer, i) => ({
element: layer.element,
zIndex: zValues[i],
id: layer.id,
selector: layer.selector,
selectorIndex: layer.selectorIndex,
sourceFile: layer.sourceFile,
}));

handleDomZIndexReorderCommit(entries);
},
[handleDomZIndexReorderCommit],
);

const selectedKey = domEditSelection ? getDomEditLayerKey(domEditSelection) : null;
const visibleLayers = getVisibleLayers(layers, collapsed);

const handleSingleSibling = useCallback(() => {
showToast("Only one layer at this level", "info");
}, [showToast]);

const {
dragKey,
insertionLineY,
handleRowPointerDown,
handleContainerPointerMove,
handleContainerPointerUp,
} = useLayerDrag({
visibleLayers,
scrollContainerRef,
onReorder: handleReorder,
onSingleSibling: handleSingleSibling,
});

if (layers.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center bg-neutral-900 px-6 text-center">
Expand All @@ -207,31 +255,43 @@ export const LayersPanel = memo(function LayersPanel() {
<div className="border-b border-white/10 px-3 py-2 text-[11px] text-neutral-500">
{layers.length} layer{layers.length === 1 ? "" : "s"}
</div>
<div className="min-h-0 flex-1 overflow-y-auto py-1">
{visibleLayers.map((layer) => {
<div
ref={scrollContainerRef}
className="relative min-h-0 flex-1 overflow-y-auto py-1"
onPointerMove={handleContainerPointerMove}
onPointerUp={handleContainerPointerUp}
onPointerCancel={handleContainerPointerUp}
>
{visibleLayers.map((layer, index) => {
const selected = layer.key === selectedKey;
const isDragged = layer.key === dragKey;
const draggable = isLayerDraggable(layer);
const isCollapsed = collapsed[layer.key] ?? false;
const hasChildren = layer.childCount > 0;
const isCompHost = isCompositionHost(layer.element);

return (
<div
key={layer.key}
data-layer-index={index}
role="button"
tabIndex={0}
onClick={() => handleSelectLayer(layer)}
onPointerEnter={() => handleLayerHover(layer)}
onClick={() => !dragKey && handleSelectLayer(layer)}
onPointerDown={(e) => handleRowPointerDown(index, e)}
onPointerEnter={() => !dragKey && handleLayerHover(layer)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelectLayer(layer);
}
}}
className={`group flex w-full cursor-pointer items-center gap-1.5 px-2 py-1 text-left transition-colors ${
selected
? "bg-studio-accent/14 text-studio-accent"
: "text-neutral-300 hover:bg-white/[0.04] hover:text-neutral-100"
}`}
className={`group flex w-full items-center gap-1.5 px-2 py-1 text-left transition-colors ${
isDragged
? "opacity-40"
: selected
? "bg-studio-accent/14 text-studio-accent"
: "text-neutral-300 hover:bg-white/[0.04] hover:text-neutral-100"
} ${dragKey ? "cursor-grabbing" : draggable ? "cursor-pointer" : "cursor-not-allowed opacity-50"}`}
style={{ paddingLeft: 8 + layer.depth * 16 }}
>
{hasChildren ? (
Expand Down Expand Up @@ -271,11 +331,87 @@ export const LayersPanel = memo(function LayersPanel() {
</div>
);
})}
{insertionLineY != null && (
<div
className="pointer-events-none absolute left-2 right-2 h-0.5 bg-studio-accent"
style={{ top: insertionLineY }}
/>
)}
</div>
</div>
);
});

// ── 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;

const minDepth = layers[0].depth;
for (let i = 1; i < layers.length; i++) {
if (layers[i].depth < minDepth) return layers;
}

const chunks: Array<{ root: DomEditLayerItem; children: DomEditLayerItem[]; domIndex: number }> =
[];

for (let i = 0; i < layers.length; i++) {
if (layers[i].depth === minDepth) {
const children: DomEditLayerItem[] = [];
let j = i + 1;
while (j < layers.length && layers[j].depth > minDepth) {
children.push(layers[j]);
j++;
}
chunks.push({ root: layers[i], children, domIndex: chunks.length });
}
}

if (chunks.length <= 1) {
if (chunks.length === 1 && chunks[0].children.length > 0) {
const sorted = sortLayersByZIndex(chunks[0].children);
return [chunks[0].root, ...sorted];
}
return layers;
}

chunks.sort((a, b) => {
const zA = getElementZIndex(a.root.element);
const zB = getElementZIndex(b.root.element);
if (zA !== zB) return zB - zA;
return b.domIndex - a.domIndex;
});

const result: DomEditLayerItem[] = [];
for (const chunk of chunks) {
result.push(chunk.root);
if (chunk.children.length > 0) {
result.push(...sortLayersByZIndex(chunk.children));
}
}
return result;
}

function getVisibleLayers(
layers: DomEditLayerItem[],
collapsed: CollapsedState,
Expand Down
Loading
Loading