feat(studio): add FlatMediaSection scaffold + source/copy row

This commit is contained in:
Vance Ingalls
2026-07-14 15:50:44 -07:00
parent 14d8de7492
commit 0e0cb4846d
2 changed files with 207 additions and 0 deletions
@@ -0,0 +1,82 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FlatMediaSection } from "./propertyPanelFlatMediaSection";
import type { DomEditSelection } from "./domEditing";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
});
function makeVideoElement(overrides: Partial<DomEditSelection> = {}): DomEditSelection {
const el = document.createElement("video");
el.setAttribute("src", "assets/intro-loop.mp4");
return {
element: el,
id: "s1-bg",
selector: "#s1-bg",
label: "S1 Background",
tagName: "video",
sourceFile: "index.html",
compositionPath: "index.html",
isCompositionHost: false,
isInsideLockedComposition: false,
boundingBox: { x: 0, y: 0, width: 1920, height: 1080 },
textContent: "",
dataAttributes: {},
inlineStyles: {},
computedStyles: {},
textFields: [],
capabilities: {
canSelect: true,
canEditStyles: true,
canCrop: true,
canMove: true,
canResize: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
},
...overrides,
} as DomEditSelection;
}
function renderSection(overrides: Partial<DomEditSelection> = {}) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const element = makeVideoElement(overrides);
act(() => {
root.render(
<FlatMediaSection
projectDir={null}
element={element}
styles={{}}
onSetStyle={vi.fn()}
onSetAttribute={vi.fn()}
onSetHtmlAttribute={vi.fn()}
/>,
);
});
return { host, root };
}
describe("FlatMediaSection — source row", () => {
it("renders the source path and copies it to clipboard on click", () => {
Object.defineProperty(navigator, "clipboard", {
value: { writeText: vi.fn().mockResolvedValue(undefined) },
configurable: true,
});
const { host, root } = renderSection();
expect(host.textContent).toContain("assets/intro-loop.mp4");
const copyButton = host.querySelector<HTMLButtonElement>('[data-flat-media-copy="true"]');
expect(copyButton).not.toBeNull();
act(() => copyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("assets/intro-loop.mp4");
act(() => root.unmount());
});
});
@@ -0,0 +1,125 @@
import { useEffect, useState } from "react";
import { Check, ClipboardList } from "../../icons/SystemIcons";
import type { DomEditSelection } from "./domEditing";
import {
type BackgroundRemovalProgress,
type BackgroundRemovalResult,
stripQueryAndHash,
} from "./propertyPanelHelpers";
export function FlatMediaSection({
projectDir,
element,
// oxlint-disable-next-line no-unused-vars -- wired into the Fit/Position rows in Task 6
styles,
// oxlint-disable-next-line no-unused-vars -- wired into the Fit/Position rows in Task 6
onSetStyle,
onSetAttribute,
onSetHtmlAttribute,
onRemoveBackground,
}: {
projectDir: string | null;
element: DomEditSelection;
styles: Record<string, string>;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
onRemoveBackground?: (
inputPath: string,
options: {
createBackgroundPlate?: boolean;
quality?: "fast" | "balanced" | "best";
onProgress?: (progress: BackgroundRemovalProgress) => void;
},
) => Promise<BackgroundRemovalResult>;
}) {
const isVideo = element.tagName === "video";
// oxlint-disable-next-line no-unused-vars -- wired into the Volume/Rate/Muted gate in Task 4
const isAudio = element.tagName === "audio";
const isImage = element.tagName === "img";
const isVisualMedia = isVideo || isImage;
const el = element.element;
const srcAttr = el.getAttribute("src") ?? "";
const [copied, setCopied] = useState(false);
const [removeBusy, setRemoveBusy] = useState(false);
// oxlint-disable-next-line no-unused-vars -- rendered by the progress bar added in Task 3
const [removeProgress, setRemoveProgress] = useState<BackgroundRemovalProgress | null>(null);
const [createPlate, setCreatePlate] = useState(false);
// oxlint-disable-next-line no-unused-vars -- wired into the Quality FlatSelectRow in Task 3
const [quality, setQuality] = useState<"fast" | "balanced" | "best">("balanced");
const absoluteSrc =
projectDir && srcAttr && !srcAttr.startsWith("http") ? `${projectDir}/${srcAttr}` : srcAttr;
const projectSrc =
srcAttr && !/^(?:https?:|data:|blob:)/i.test(srcAttr)
? stripQueryAndHash(srcAttr.startsWith("./") ? srcAttr.slice(2) : srcAttr)
: "";
// oxlint-disable-next-line no-unused-vars -- gates the Remove BG button added in Task 3
const canRemoveBackground = Boolean(onRemoveBackground && isVisualMedia && projectSrc);
useEffect(() => {
setRemoveProgress(null);
setCreatePlate(false);
}, [srcAttr]);
const applyCutoutResult = async (result: BackgroundRemovalResult) => {
await onSetHtmlAttribute("src", result.outputPath);
if (isVideo) {
await onSetAttribute("has-audio", "");
await onSetHtmlAttribute("muted", "true");
}
};
// oxlint-disable-next-line no-unused-vars -- called by the Remove BG button added in Task 3
const runBackgroundRemoval = async () => {
if (!onRemoveBackground || !projectSrc || removeBusy) return;
setRemoveBusy(true);
setRemoveProgress({ status: "processing", progress: 0, stage: "Preparing" });
try {
const result = await onRemoveBackground(projectSrc, {
createBackgroundPlate: isVideo && createPlate,
quality,
onProgress: setRemoveProgress,
});
await applyCutoutResult(result);
setRemoveProgress({ status: "complete", progress: 100, stage: "Applied cutout", ...result });
} catch (error) {
setRemoveProgress({
status: "failed",
progress: 0,
stage: "Failed",
error: error instanceof Error ? error.message : String(error),
});
} finally {
setRemoveBusy(false);
}
};
return (
<div className="space-y-1.5">
<div className="flex min-h-8 items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2">
<span className="h-5 w-8 flex-shrink-0 rounded-[3px] bg-panel-surface" />
<span className="min-w-0 truncate font-mono text-[11px] text-panel-text-0">
{srcAttr}
</span>
</span>
<button
type="button"
data-flat-media-copy="true"
onClick={() => {
void navigator.clipboard.writeText(absoluteSrc).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
}}
className="flex flex-shrink-0 items-center gap-1 text-[10px] text-panel-text-3 hover:text-panel-text-1"
>
{copied ? <Check size={11} /> : <ClipboardList size={11} />}
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
);
}