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,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>
);
}