Skip to content

Commit f7d6734

Browse files
fix(studio): Layers rows show a kind icon, and only a group row has a caret (#5067)
* fix(studio): Layers rows show a kind icon, and only a group's row has a caret Rows show what an element is (image, video, audio, vector, group, text or shape) instead of the tag's first letters. The caret column sits only on rows with children, a group's children indent under its icon, and rows are inset with rounded corners so the selection reads centred. * style(studio): name the Layers indent and phrasing tags instead of commenting them * refactor(studio): Layers text and host checks reuse the editor's own rules * fix(studio): a Layers child's icon starts where its parent's label starts * refactor(studio): Layers text icon uses the inline editor's text check * fix(studio): a Layers row with children shows the group icon, and its icon keeps the row's hint * fix(studio): Layers text with styled spans keeps the Text icon
1 parent 39a693c commit f7d6734

6 files changed

Lines changed: 148 additions & 52 deletions

File tree

‎packages/studio/src/components/editor/LayersPanel.tsx‎

Lines changed: 39 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,17 @@ import {
2020
findMatchingTimelineElementId,
2121
resolveTimelineSelectionSeekTime,
2222
} from "../../utils/studioHelpers";
23-
import { Layers } from "../../icons/SystemIcons";
23+
import {
24+
Film,
25+
Folder,
26+
ImageIcon,
27+
Layers,
28+
Music,
29+
Square,
30+
Type,
31+
Vector,
32+
} from "../../icons/SystemIcons";
33+
import { isCompositionHost, layerKindOf } from "./layerKind";
2434
import { useLayerDrag, isLayerDraggable, type LayerReorderEvent } from "./useLayerDrag";
2535
import { getVisibleLayers, sortLayersByZIndex } from "./layersPanelSort";
2636
import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
@@ -34,38 +44,18 @@ import { useLayerRevealOverride } from "./useLayerRevealOverride";
3444
// Rows this panel renders before it stops. A display budget, not a document limit.
3545
const LAYERS_PANEL_MAX_ROWS = 80;
3646

37-
const TAG_ICONS: Record<string, string> = {
38-
video: "Vi",
39-
audio: "Au",
40-
img: "Im",
41-
svg: "Sv",
42-
canvas: "Cn",
43-
div: "Di",
44-
section: "Se",
45-
span: "Sp",
46-
p: "P",
47-
h1: "H1",
48-
h2: "H2",
49-
h3: "H3",
50-
h4: "H4",
51-
h5: "H5",
52-
h6: "H6",
53-
a: "A",
54-
button: "Bt",
55-
ul: "Ul",
56-
ol: "Ol",
57-
li: "Li",
58-
style: "St",
59-
template: "Te",
60-
};
61-
62-
function getTagBadge(tagName: string): string {
63-
return TAG_ICONS[tagName] ?? tagName.slice(0, 2).toUpperCase();
64-
}
47+
const [CARET, ICON, GAP] = [16, 20, 6];
48+
const CHILD_INDENT = CARET + GAP + ICON + GAP;
6549

66-
function isCompositionHost(el: HTMLElement): boolean {
67-
return el.hasAttribute("data-composition-src") || el.hasAttribute("data-composition-file");
68-
}
50+
const KIND_ICON = {
51+
image: ImageIcon,
52+
video: Film,
53+
audio: Music,
54+
vector: Vector,
55+
group: Folder,
56+
text: Type,
57+
shape: Square,
58+
} as const;
6959

7060
/**
7161
* A trailing-rAF + cooldown throttle: `invoke` runs `run` at most once per
@@ -452,7 +442,7 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
452442
<button
453443
type="button"
454444
onClick={() => setActiveGroupElement(null)}
455-
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"
445+
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"
456446
>
457447
<span aria-hidden="true">←</span>
458448
<span className="truncate">
@@ -467,6 +457,14 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
467457
const isCollapsed = collapsed[layer.key] ?? false;
468458
const hasChildren = layer.childCount > 0;
469459
const isCompHost = isCompositionHost(layer.element);
460+
const kind = layerKindOf(layer.element, hasChildren);
461+
const KindIcon = KIND_ICON[kind];
462+
const kindName = kind[0].toUpperCase() + kind.slice(1);
463+
const hint = draggable
464+
? layer.element.hasAttribute("data-hf-group")
465+
? "Double-click to enter group"
466+
: undefined
467+
: "This layer can't be reordered";
470468

471469
return (
472470
<div
@@ -484,21 +482,15 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
484482
handleSelectLayer(layer);
485483
}
486484
}}
487-
className={`group flex w-full items-center gap-1.5 px-2 py-1 text-left transition-colors ${
485+
className={`group mx-1 flex items-center gap-1.5 rounded-md px-2 py-1 text-left transition-colors ${
488486
isDragged
489487
? "opacity-40"
490488
: selected
491489
? "bg-panel-accent/14 text-accent-ink"
492490
: "text-panel-text-2 hover:bg-panel-hover/40 hover:text-panel-text-1"
493491
} ${dragKey ? "cursor-grabbing" : "cursor-pointer"}`}
494-
style={{ paddingLeft: 8 + layer.depth * 16 }}
495-
title={
496-
draggable
497-
? layer.element.hasAttribute("data-hf-group")
498-
? "Double-click to enter group"
499-
: undefined
500-
: "This layer can't be reordered"
501-
}
492+
style={{ paddingLeft: 8 + layer.depth * CHILD_INDENT }}
493+
title={hint}
502494
>
503495
{hasChildren ? (
504496
<button
@@ -518,19 +510,19 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
518510
<path d="M2 1l4 3-4 3z" />
519511
</svg>
520512
</button>
521-
) : (
522-
<span className="w-4 shrink-0" />
523-
)}
513+
) : null}
524514
<span
525-
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded text-[8px] font-bold uppercase ${
515+
data-layer-kind={kind}
516+
title={hint ? `${kindName} · ${hint}` : kindName}
517+
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded ${
526518
selected
527519
? "bg-panel-accent/18 text-accent-ink"
528520
: isCompHost
529521
? "bg-on text-accent-ink"
530522
: "bg-panel-hover text-panel-text-4"
531523
}`}
532524
>
533-
{getTagBadge(layer.tagName)}
525+
<KindIcon size={12} />
534526
</span>
535527
<span className="min-w-0 flex-1 truncate text-[11px]">{layer.label}</span>
536528
{hasChildren && (

‎packages/studio/src/components/editor/domEditInlineText.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export function canEditElementTextInline(element: HTMLElement | null): boolean {
5353
return (element.textContent ?? "").trim().length > 0;
5454
}
5555

56-
function hasOnlyFormattingChildren(element: HTMLElement): boolean {
56+
export function hasOnlyFormattingChildren(element: HTMLElement): boolean {
5757
for (const child of Array.from(element.children)) {
5858
if (!isRichTextFormattingTag(child.tagName)) return false;
5959
if (!isHtmlFromAnyWindow(child)) return false;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// @vitest-environment happy-dom
2+
import { describe, expect, it } from "vitest";
3+
import { layerKindOf } from "./layerKind";
4+
5+
const el = (html: string) => {
6+
const host = document.createElement("div");
7+
host.innerHTML = html;
8+
return host.firstElementChild!;
9+
};
10+
11+
describe("layerKindOf", () => {
12+
it.each([
13+
['<img src="a.png">', "image"],
14+
['<picture><img src="a.png"></picture>', "image"],
15+
['<video src="a.mp4"></video>', "video"],
16+
['<audio src="a.mp3"></audio>', "audio"],
17+
['<svg viewBox="0 0 10 10"><path d="M0 0h10"/></svg>', "vector"],
18+
['<div data-hf-group="Intro"><div></div></div>', "group"],
19+
['<div data-composition-src="intro.html"></div>', "group"],
20+
['<div data-composition-file="intro.html"></div>', "group"],
21+
["<h1>Title</h1>", "text"],
22+
["<div>Plain words</div>", "text"],
23+
["<p>Rich <b>bold</b> and <em>soft</em> words</p>", "text"],
24+
['<div style="background:red"></div>', "shape"],
25+
["<div><img></div>", "shape"],
26+
["<div>Caption<img></div>", "shape"],
27+
["<p>H<sub>2</sub>O</p>", "shape"],
28+
["<div> </div>", "shape"],
29+
["<canvas></canvas>", "shape"],
30+
])("%s is %s", (html, kind) => {
31+
expect(layerKindOf(el(html), false)).toBe(kind);
32+
});
33+
});
34+
35+
it("a row with layer children is a group unless it is text", () => {
36+
expect(layerKindOf(el("<div><div></div></div>"), true)).toBe("group");
37+
expect(layerKindOf(el('<p>Hi <span class="x">there</span></p>'), true)).toBe("text");
38+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { hasOnlyFormattingChildren } from "./domEditInlineText";
2+
3+
export type LayerKind = "image" | "video" | "audio" | "vector" | "group" | "text" | "shape";
4+
5+
export function isCompositionHost(el: Element): boolean {
6+
return el.hasAttribute("data-composition-src") || el.hasAttribute("data-composition-file");
7+
}
8+
9+
/** What a Layers row tells a person an element is: its media tag, a group, text, or else a shape. */
10+
export function layerKindOf(el: Element, hasChildren: boolean): LayerKind {
11+
const tag = el.tagName.toLowerCase();
12+
if (tag === "img" || tag === "picture") return "image";
13+
if (tag === "video") return "video";
14+
if (tag === "audio") return "audio";
15+
if (tag === "svg") return "vector";
16+
if (el.hasAttribute("data-hf-group") || isCompositionHost(el)) return "group";
17+
if (hasOnlyFormattingChildren(el as HTMLElement) && el.textContent?.trim()) return "text";
18+
return hasChildren ? "group" : "shape";
19+
}

‎packages/studio/src/icons/SystemIcons.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ import {
2424
Link as PhLink,
2525
Eyedropper as PhEyedropper,
2626
Trash as PhTrash,
27+
Image as PhImage,
28+
BezierCurve,
29+
FolderSimple,
2730
} from "@phosphor-icons/react";
2831
import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
2932

@@ -75,3 +78,6 @@ export const Scissors = makeIcon(PhScissors);
7578
export const Link = makeIcon(PhLink);
7679
export const Eyedropper = makeIcon(PhEyedropper);
7780
export const Trash = makeIcon(PhTrash);
81+
export const ImageIcon = makeIcon(PhImage);
82+
export const Vector = makeIcon(BezierCurve);
83+
export const Folder = makeIcon(FolderSimple);

‎packages/studio/src/layersPanelExport.test.tsx‎

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,17 @@ afterEach(() => {
2929
});
3030

3131
/** The host's preview (title in front of bg, rows to match) and its edit session, mounted around the panel. */
32-
async function mountPanel(wrap: (panel: ReactNode) => ReactNode = (panel) => panel) {
32+
const TWO_BOXES =
33+
'<div data-composition-id="main"><div id="bg" style="z-index:1"></div><div id="title" style="z-index:2"></div></div>';
34+
35+
async function mountPanel(
36+
wrap: (panel: ReactNode) => ReactNode = (panel) => panel,
37+
picture = TWO_BOXES,
38+
) {
3339
const iframe = document.createElement("iframe");
3440
document.body.appendChild(iframe);
3541
const doc = iframe.contentDocument!;
36-
doc.body.innerHTML =
37-
'<div data-composition-id="main"><div id="bg" style="z-index:1"></div><div id="title" style="z-index:2"></div></div>';
42+
doc.body.innerHTML = picture;
3843
usePlayerStore.setState({ elements: [clip("title", 0), clip("bg", 1)] });
3944
const session = {
4045
domEditSelection: null,
@@ -84,7 +89,7 @@ async function dragBackRowToFront() {
8489
it("lists, selects and reorders the host's preview layers through the host's session", async () => {
8590
const { doc, session, root } = await mountPanel();
8691

87-
expect(rowLabels()).toEqual(["DiTitle", "DiBg"]);
92+
expect(rowLabels()).toEqual(["Title", "Bg"]);
8893

8994
const rows = document.querySelectorAll<HTMLElement>("[data-layer-index]");
9095
await act(async () => rows[0]!.click());
@@ -113,3 +118,39 @@ it("mirrors the reorder into the timeline rows through the host's move handler",
113118
expect(bgTrack()).not.toBe(1);
114119
await act(async () => root.unmount());
115120
});
121+
122+
it("puts a caret only on a group's row and starts each child's icon where its parent's label starts", async () => {
123+
const { root } = await mountPanel(
124+
undefined,
125+
'<div data-composition-id="main"><h1 id="title" style="z-index:2">Hi</h1>' +
126+
'<div id="intro" data-hf-group="Intro" style="z-index:1"><img id="logo">' +
127+
'<div id="inner" data-hf-group="Inner"><img id="mark"></div></div>' +
128+
'<div id="wrap" style="z-index:0"><div id="box"></div></div></div>',
129+
);
130+
const rows = [...document.querySelectorAll<HTMLElement>("[data-layer-index]")];
131+
const byLabel = (label: string) => rows.find((row) => row.textContent?.startsWith(label))!;
132+
const kind = (label: string) =>
133+
byLabel(label).querySelector("[data-layer-kind]")?.getAttribute("data-layer-kind");
134+
expect(["Title", "Intro", "Logo", "Inner", "Mark"].map(kind)).toEqual([
135+
"text",
136+
"group",
137+
"image",
138+
"group",
139+
"image",
140+
]);
141+
expect([kind("Wrap"), kind("Box")]).toEqual(["group", "shape"]);
142+
expect(byLabel("Wrap").firstElementChild?.getAttribute("aria-label")).toBe("Collapse children");
143+
const icons = ["Title", "Intro", "Logo", "Box"].map(
144+
(l) => byLabel(l).querySelector("[data-layer-kind]")!.innerHTML,
145+
);
146+
expect(new Set(icons).size).toBe(4);
147+
for (const row of rows.filter((r) => r.title)) {
148+
expect(row.querySelector<HTMLElement>("[data-layer-kind]")!.title).toContain(row.title);
149+
}
150+
expect(byLabel("Title").firstElementChild?.hasAttribute("data-layer-kind")).toBe(true);
151+
expect(byLabel("Intro").firstElementChild?.getAttribute("aria-label")).toBe("Collapse children");
152+
expect(
153+
["Title", "Intro", "Logo", "Inner", "Mark"].map((l) => byLabel(l).style.paddingLeft),
154+
).toEqual(["8px", "8px", "56px", "56px", "104px"]);
155+
await act(async () => root.unmount());
156+
});

0 commit comments

Comments
 (0)