mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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
This commit is contained in:
@@ -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<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",
|
||||
};
|
||||
const [CARET, ICON, GAP] = [16, 20, 6];
|
||||
const CHILD_INDENT = CARET + GAP + ICON + GAP;
|
||||
|
||||
function getTagBadge(tagName: string): string {
|
||||
return TAG_ICONS[tagName] ?? tagName.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
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
|
||||
<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">
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -518,11 +510,11 @@ 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
|
||||
@@ -530,7 +522,7 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
|
||||
: "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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user