diff --git a/packages/studio/src/components/editor/LayersPanel.tsx b/packages/studio/src/components/editor/LayersPanel.tsx index fc98b41c9c2..2199a8ab19a 100644 --- a/packages/studio/src/components/editor/LayersPanel.tsx +++ b/packages/studio/src/components/editor/LayersPanel.tsx @@ -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"; @@ -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 = { - 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 @@ -452,7 +442,7 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa - ) : ( - - )} + ) : null} - {getTagBadge(layer.tagName)} + {layer.label} {hasChildren && ( diff --git a/packages/studio/src/components/editor/domEditInlineText.ts b/packages/studio/src/components/editor/domEditInlineText.ts index efc8c590488..cc5592d803b 100644 --- a/packages/studio/src/components/editor/domEditInlineText.ts +++ b/packages/studio/src/components/editor/domEditInlineText.ts @@ -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; diff --git a/packages/studio/src/components/editor/layerKind.test.ts b/packages/studio/src/components/editor/layerKind.test.ts new file mode 100644 index 00000000000..038ab331b44 --- /dev/null +++ b/packages/studio/src/components/editor/layerKind.test.ts @@ -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([ + ['', "image"], + ['', "image"], + ['', "video"], + ['', "audio"], + ['', "vector"], + ['
', "group"], + ['
', "group"], + ['
', "group"], + ["

Title

", "text"], + ["
Plain words
", "text"], + ["

Rich bold and soft words

", "text"], + ['
', "shape"], + ["
", "shape"], + ["
Caption
", "shape"], + ["

H2O

", "shape"], + ["
", "shape"], + ["", "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("
"), true)).toBe("group"); + expect(layerKindOf(el('

Hi there

'), true)).toBe("text"); +}); diff --git a/packages/studio/src/components/editor/layerKind.ts b/packages/studio/src/components/editor/layerKind.ts new file mode 100644 index 00000000000..c1f5c610639 --- /dev/null +++ b/packages/studio/src/components/editor/layerKind.ts @@ -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"; +} diff --git a/packages/studio/src/icons/SystemIcons.tsx b/packages/studio/src/icons/SystemIcons.tsx index b2915005e94..d74aed85f1b 100644 --- a/packages/studio/src/icons/SystemIcons.tsx +++ b/packages/studio/src/icons/SystemIcons.tsx @@ -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"; @@ -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); diff --git a/packages/studio/src/layersPanelExport.test.tsx b/packages/studio/src/layersPanelExport.test.tsx index 7e8378ea7f7..b8e65dc5a3e 100644 --- a/packages/studio/src/layersPanelExport.test.tsx +++ b/packages/studio/src/layersPanelExport.test.tsx @@ -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 = + '
'; + +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 = - '
'; + doc.body.innerHTML = picture; usePlayerStore.setState({ elements: [clip("title", 0), clip("bg", 1)] }); const session = { domEditSelection: null, @@ -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("[data-layer-index]"); await act(async () => rows[0]!.click()); @@ -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, + '

Hi

' + + '
' + + '
' + + '
', + ); + const rows = [...document.querySelectorAll("[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("[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()); +});