Skip to content
86 changes: 39 additions & 47 deletions packages/studio/src/components/editor/LayersPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,17 @@ import {
findMatchingTimelineElementId,
resolveTimelineSelectionSeekTime,
} from "../../utils/studioHelpers";
import { Layers } from "../../icons/SystemIcons";
import {
Film,
Folder,
ImageIcon,
Layers,
Music,
Square,
Type,
Vector,
} from "../../icons/SystemIcons";
import { isCompositionHost, layerKindOf } from "./layerKind";
import { useLayerDrag, isLayerDraggable, type LayerReorderEvent } from "./useLayerDrag";
import { getVisibleLayers, sortLayersByZIndex } from "./layersPanelSort";
import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
Expand All @@ -34,38 +44,18 @@ import { useLayerRevealOverride } from "./useLayerRevealOverride";
// Rows this panel renders before it stops. A display budget, not a document limit.
const LAYERS_PANEL_MAX_ROWS = 80;

const TAG_ICONS: Record<string, string> = {
video: "Vi",
audio: "Au",
img: "Im",
svg: "Sv",
canvas: "Cn",
div: "Di",
section: "Se",
span: "Sp",
p: "P",
h1: "H1",
h2: "H2",
h3: "H3",
h4: "H4",
h5: "H5",
h6: "H6",
a: "A",
button: "Bt",
ul: "Ul",
ol: "Ol",
li: "Li",
style: "St",
template: "Te",
};

function getTagBadge(tagName: string): string {
return TAG_ICONS[tagName] ?? tagName.slice(0, 2).toUpperCase();
}
const [CARET, ICON, GAP] = [16, 20, 6];
const CHILD_INDENT = CARET + GAP + ICON + GAP;

function isCompositionHost(el: HTMLElement): boolean {
return el.hasAttribute("data-composition-src") || el.hasAttribute("data-composition-file");
}
const KIND_ICON = {
image: ImageIcon,
video: Film,
audio: Music,
vector: Vector,
group: Folder,
text: Type,
shape: Square,
} as const;

/**
* A trailing-rAF + cooldown throttle: `invoke` runs `run` at most once per
Expand Down Expand Up @@ -452,7 +442,7 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
<button
type="button"
onClick={() => setActiveGroupElement(null)}
className="flex w-full items-center gap-1.5 px-2 py-1 text-left text-[11px] text-panel-text-3 hover:bg-panel-hover/40 hover:text-panel-text-1"
className="mx-1 flex w-[calc(100%-0.5rem)] items-center gap-1.5 rounded-md px-2 py-1 text-left text-[11px] text-panel-text-3 hover:bg-panel-hover/40 hover:text-panel-text-1"
>
<span aria-hidden="true">←</span>
<span className="truncate">
Expand All @@ -467,6 +457,14 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
const isCollapsed = collapsed[layer.key] ?? false;
const hasChildren = layer.childCount > 0;
const isCompHost = isCompositionHost(layer.element);
const kind = layerKindOf(layer.element, hasChildren);
const KindIcon = KIND_ICON[kind];
const kindName = kind[0].toUpperCase() + kind.slice(1);
const hint = draggable
? layer.element.hasAttribute("data-hf-group")
? "Double-click to enter group"
: undefined
: "This layer can't be reordered";

return (
<div
Expand All @@ -484,21 +482,15 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
handleSelectLayer(layer);
}
}}
className={`group flex w-full items-center gap-1.5 px-2 py-1 text-left transition-colors ${
className={`group mx-1 flex items-center gap-1.5 rounded-md px-2 py-1 text-left transition-colors ${
isDragged
? "opacity-40"
: selected
? "bg-panel-accent/14 text-accent-ink"
: "text-panel-text-2 hover:bg-panel-hover/40 hover:text-panel-text-1"
} ${dragKey ? "cursor-grabbing" : "cursor-pointer"}`}
style={{ paddingLeft: 8 + layer.depth * 16 }}
title={
draggable
? layer.element.hasAttribute("data-hf-group")
? "Double-click to enter group"
: undefined
: "This layer can't be reordered"
}
style={{ paddingLeft: 8 + layer.depth * CHILD_INDENT }}
title={hint}
>
{hasChildren ? (
<button
Expand All @@ -518,19 +510,19 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
<path d="M2 1l4 3-4 3z" />
</svg>
</button>
) : (
<span className="w-4 shrink-0" />
)}
) : null}
<span
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded text-[8px] font-bold uppercase ${
data-layer-kind={kind}
title={hint ? `${kindName} · ${hint}` : kindName}
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded ${
selected
? "bg-panel-accent/18 text-accent-ink"
: isCompHost
? "bg-on text-accent-ink"
: "bg-panel-hover text-panel-text-4"
}`}
>
{getTagBadge(layer.tagName)}
<KindIcon size={12} />
</span>
<span className="min-w-0 flex-1 truncate text-[11px]">{layer.label}</span>
{hasChildren && (
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/components/editor/domEditInlineText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function canEditElementTextInline(element: HTMLElement | null): boolean {
return (element.textContent ?? "").trim().length > 0;
}

function hasOnlyFormattingChildren(element: HTMLElement): boolean {
export function hasOnlyFormattingChildren(element: HTMLElement): boolean {
for (const child of Array.from(element.children)) {
if (!isRichTextFormattingTag(child.tagName)) return false;
if (!isHtmlFromAnyWindow(child)) return false;
Expand Down
38 changes: 38 additions & 0 deletions packages/studio/src/components/editor/layerKind.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { layerKindOf } from "./layerKind";

const el = (html: string) => {
const host = document.createElement("div");
host.innerHTML = html;
return host.firstElementChild!;
};

describe("layerKindOf", () => {
it.each([
['<img src="a.png">', "image"],
['<picture><img src="a.png"></picture>', "image"],
['<video src="a.mp4"></video>', "video"],
['<audio src="a.mp3"></audio>', "audio"],
['<svg viewBox="0 0 10 10"><path d="M0 0h10"/></svg>', "vector"],
['<div data-hf-group="Intro"><div></div></div>', "group"],
['<div data-composition-src="intro.html"></div>', "group"],
['<div data-composition-file="intro.html"></div>', "group"],
["<h1>Title</h1>", "text"],
["<div>Plain words</div>", "text"],
["<p>Rich <b>bold</b> and <em>soft</em> words</p>", "text"],
['<div style="background:red"></div>', "shape"],
["<div><img></div>", "shape"],
["<div>Caption<img></div>", "shape"],
["<p>H<sub>2</sub>O</p>", "shape"],
["<div> </div>", "shape"],
["<canvas></canvas>", "shape"],
])("%s is %s", (html, kind) => {
expect(layerKindOf(el(html), false)).toBe(kind);
});
});

it("a row with layer children is a group unless it is text", () => {
expect(layerKindOf(el("<div><div></div></div>"), true)).toBe("group");
expect(layerKindOf(el('<p>Hi <span class="x">there</span></p>'), true)).toBe("text");
});
19 changes: 19 additions & 0 deletions packages/studio/src/components/editor/layerKind.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { hasOnlyFormattingChildren } from "./domEditInlineText";

export type LayerKind = "image" | "video" | "audio" | "vector" | "group" | "text" | "shape";

export function isCompositionHost(el: Element): boolean {
return el.hasAttribute("data-composition-src") || el.hasAttribute("data-composition-file");
}

/** What a Layers row tells a person an element is: its media tag, a group, text, or else a shape. */
export function layerKindOf(el: Element, hasChildren: boolean): LayerKind {
const tag = el.tagName.toLowerCase();
if (tag === "img" || tag === "picture") return "image";
if (tag === "video") return "video";
if (tag === "audio") return "audio";
if (tag === "svg") return "vector";
if (el.hasAttribute("data-hf-group") || isCompositionHost(el)) return "group";
if (hasOnlyFormattingChildren(el as HTMLElement) && el.textContent?.trim()) return "text";
return hasChildren ? "group" : "shape";
}
6 changes: 6 additions & 0 deletions packages/studio/src/icons/SystemIcons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ import {
Link as PhLink,
Eyedropper as PhEyedropper,
Trash as PhTrash,
Image as PhImage,
BezierCurve,
FolderSimple,
} from "@phosphor-icons/react";
import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";

Expand Down Expand Up @@ -75,3 +78,6 @@ export const Scissors = makeIcon(PhScissors);
export const Link = makeIcon(PhLink);
export const Eyedropper = makeIcon(PhEyedropper);
export const Trash = makeIcon(PhTrash);
export const ImageIcon = makeIcon(PhImage);
export const Vector = makeIcon(BezierCurve);
export const Folder = makeIcon(FolderSimple);
49 changes: 45 additions & 4 deletions packages/studio/src/layersPanelExport.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,17 @@ afterEach(() => {
});

/** The host's preview (title in front of bg, rows to match) and its edit session, mounted around the panel. */
async function mountPanel(wrap: (panel: ReactNode) => ReactNode = (panel) => panel) {
const TWO_BOXES =
'<div data-composition-id="main"><div id="bg" style="z-index:1"></div><div id="title" style="z-index:2"></div></div>';

async function mountPanel(
wrap: (panel: ReactNode) => ReactNode = (panel) => panel,
picture = TWO_BOXES,
) {
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
const doc = iframe.contentDocument!;
doc.body.innerHTML =
'<div data-composition-id="main"><div id="bg" style="z-index:1"></div><div id="title" style="z-index:2"></div></div>';
doc.body.innerHTML = picture;
usePlayerStore.setState({ elements: [clip("title", 0), clip("bg", 1)] });
const session = {
domEditSelection: null,
Expand Down Expand Up @@ -84,7 +89,7 @@ async function dragBackRowToFront() {
it("lists, selects and reorders the host's preview layers through the host's session", async () => {
const { doc, session, root } = await mountPanel();

expect(rowLabels()).toEqual(["DiTitle", "DiBg"]);
expect(rowLabels()).toEqual(["Title", "Bg"]);

const rows = document.querySelectorAll<HTMLElement>("[data-layer-index]");
await act(async () => rows[0]!.click());
Expand Down Expand Up @@ -113,3 +118,39 @@ it("mirrors the reorder into the timeline rows through the host's move handler",
expect(bgTrack()).not.toBe(1);
await act(async () => root.unmount());
});

it("puts a caret only on a group's row and starts each child's icon where its parent's label starts", async () => {
const { root } = await mountPanel(
undefined,
'<div data-composition-id="main"><h1 id="title" style="z-index:2">Hi</h1>' +
'<div id="intro" data-hf-group="Intro" style="z-index:1"><img id="logo">' +
'<div id="inner" data-hf-group="Inner"><img id="mark"></div></div>' +
'<div id="wrap" style="z-index:0"><div id="box"></div></div></div>',
);
const rows = [...document.querySelectorAll<HTMLElement>("[data-layer-index]")];
const byLabel = (label: string) => rows.find((row) => row.textContent?.startsWith(label))!;
const kind = (label: string) =>
byLabel(label).querySelector("[data-layer-kind]")?.getAttribute("data-layer-kind");
expect(["Title", "Intro", "Logo", "Inner", "Mark"].map(kind)).toEqual([
"text",
"group",
"image",
"group",
"image",
]);
expect([kind("Wrap"), kind("Box")]).toEqual(["group", "shape"]);
expect(byLabel("Wrap").firstElementChild?.getAttribute("aria-label")).toBe("Collapse children");
const icons = ["Title", "Intro", "Logo", "Box"].map(
(l) => byLabel(l).querySelector("[data-layer-kind]")!.innerHTML,
);
expect(new Set(icons).size).toBe(4);
for (const row of rows.filter((r) => r.title)) {
expect(row.querySelector<HTMLElement>("[data-layer-kind]")!.title).toContain(row.title);
}
expect(byLabel("Title").firstElementChild?.hasAttribute("data-layer-kind")).toBe(true);
expect(byLabel("Intro").firstElementChild?.getAttribute("aria-label")).toBe("Collapse children");
expect(
["Title", "Intro", "Logo", "Inner", "Mark"].map((l) => byLabel(l).style.paddingLeft),
).toEqual(["8px", "8px", "56px", "56px", "104px"]);
await act(async () => root.unmount());
});
Loading