feat(studio): add media properties panel for video/audio elements

Adds a new Media section to the Design panel that appears when a <video>
or <audio> element is selected. Controls include volume (slider),
playback rate, media start offset, loop/muted toggles, and for video:
object-fit, object-position, poster, and has-audio-track toggle.

Extends the source patcher with an "html-attribute" operation type for
native HTML attributes (loop, muted, poster) that don't use the data-
prefix. Adds coalesceKey to attribute commits so rapid slider/scrub
edits merge into a single undo entry.
This commit is contained in:
Miguel Ángel
2026-05-18 16:34:41 -04:00
parent 27efcd0f80
commit 96f9462e42
10 changed files with 362 additions and 2 deletions
@@ -56,6 +56,7 @@ export function StudioRightPanel({
clearDomSelection,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomHtmlAttributeCommit,
handleDomPathOffsetCommit,
handleDomBoxSizeCommit,
handleDomRotationCommit,
@@ -170,6 +171,7 @@ export function StudioRightPanel({
onClearSelection={clearDomSelection}
onSetStyle={handleDomStyleCommit}
onSetAttribute={handleDomAttributeCommit}
onSetHtmlAttribute={handleDomHtmlAttributeCommit}
onSetManualOffset={handleDomPathOffsetCommit}
onSetManualSize={handleDomBoxSizeCommit}
onSetManualRotation={handleDomRotationCommit}
@@ -16,6 +16,7 @@ import {
RESPONSIVE_GRID,
} from "./propertyPanelHelpers";
import { MetricField, Section } from "./propertyPanelPrimitives";
import { isMediaElement, MediaSection } from "./propertyPanelMediaSection";
import { TextSection, StyleSections } from "./propertyPanelSections";
// Re-export helpers that external consumers import from this module
@@ -40,6 +41,7 @@ interface PropertyPanelProps {
onClearSelection: () => void;
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>;
onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
@@ -189,6 +191,7 @@ export const PropertyPanel = memo(function PropertyPanel({
onClearSelection,
onSetStyle,
onSetAttribute,
onSetHtmlAttribute,
onSetManualOffset,
onSetManualSize,
onSetManualRotation,
@@ -389,6 +392,16 @@ export const PropertyPanel = memo(function PropertyPanel({
<TimingSection element={element} onSetAttribute={onSetAttribute} />
)}
{isMediaElement(element) && (
<MediaSection
element={element}
styles={styles}
onSetStyle={onSetStyle}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={onSetHtmlAttribute}
/>
)}
{showEditableSections && (
<StyleSections
projectId={projectId}
@@ -42,6 +42,8 @@ export const CURATED_STYLE_PROPERTIES = [
"backdrop-filter",
"z-index",
"transform",
"object-fit",
"object-position",
] as const;
export interface DomEditCapabilities {
@@ -0,0 +1,204 @@
import { Film, Music } from "../../icons/SystemIcons";
import type { DomEditSelection } from "./domEditing";
import {
formatNumericValue,
LABEL,
parseNumericValue,
RESPONSIVE_GRID,
} from "./propertyPanelHelpers";
import {
DetailField,
MetricField,
Section,
SegmentedControl,
SelectField,
SliderControl,
} from "./propertyPanelPrimitives";
const MEDIA_TAGS = new Set(["video", "audio"]);
export function isMediaElement(element: DomEditSelection): boolean {
return MEDIA_TAGS.has(element.tagName);
}
function formatTimingValue(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0.00s";
return `${seconds.toFixed(2)}s`;
}
function parseTimingValue(input: string): number | null {
const cleaned = input.replace(/s$/i, "").trim();
const parsed = Number.parseFloat(cleaned);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}
export function MediaSection({
element,
styles,
onSetStyle,
onSetAttribute,
onSetHtmlAttribute,
}: {
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>;
}) {
const isVideo = element.tagName === "video";
const el = element.element;
const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1;
const volumePercent = Math.round(volume * 100);
const mediaStart =
Number.parseFloat(
element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0",
) || 0;
const hasLoop = el.hasAttribute("loop");
const hasMuted = el.hasAttribute("muted");
const hasAudio = element.dataAttributes["has-audio"] === "true";
const playbackRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
const objectFit = styles["object-fit"] || "contain";
const objectPosition = styles["object-position"] || "center";
const poster = el.getAttribute("poster") ?? "";
const src = el.getAttribute("src") ?? "";
return (
<Section
title={isVideo ? "Video" : "Audio"}
icon={isVideo ? <Film size={15} /> : <Music size={15} />}
>
<div className="space-y-4">
{src && (
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-[0.12em] text-neutral-500">Source</div>
<div className="mt-1 truncate text-[11px] font-medium text-neutral-300" title={src}>
{src.split("/").pop() || src}
</div>
</div>
)}
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Volume</span>
<SliderControl
value={volumePercent}
min={0}
max={100}
step={1}
displayValue={`${volumePercent}%`}
formatDisplayValue={(next) => `${Math.round(next)}%`}
onCommit={(next) => {
void onSetAttribute("volume", formatNumericValue(next / 100));
}}
/>
</div>
<MetricField
label="Rate"
value={formatNumericValue(playbackRate)}
onCommit={(next) => {
const parsed = Number.parseFloat(next);
if (!Number.isFinite(parsed) || parsed < 0.1 || parsed > 5) return;
void onSetAttribute("playback-rate", formatNumericValue(parsed));
}}
/>
<MetricField
label="Media start"
value={formatTimingValue(mediaStart)}
onCommit={(next) => {
const parsed = parseTimingValue(next);
if (parsed == null) return;
void onSetAttribute("media-start", parsed.toFixed(2));
}}
/>
<div className={RESPONSIVE_GRID}>
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Loop</span>
<SegmentedControl
value={hasLoop ? "on" : "off"}
onChange={(next) => {
void onSetHtmlAttribute("loop", next === "on" ? "true" : null);
}}
options={[
{ label: "On", value: "on" },
{ label: "Off", value: "off" },
]}
/>
</div>
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Muted</span>
<SegmentedControl
value={hasMuted ? "on" : "off"}
onChange={(next) => {
void onSetHtmlAttribute("muted", next === "on" ? "true" : null);
}}
options={[
{ label: "On", value: "on" },
{ label: "Off", value: "off" },
]}
/>
</div>
</div>
{isVideo && (
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Has audio track</span>
<SegmentedControl
value={hasAudio ? "yes" : "no"}
onChange={(next) => {
if (next === "yes") {
void onSetAttribute("has-audio", "true");
void onSetHtmlAttribute("muted", null);
} else {
void onSetAttribute("has-audio", "");
void onSetHtmlAttribute("muted", "true");
}
}}
options={[
{ label: "Yes", value: "yes" },
{ label: "No", value: "no" },
]}
/>
</div>
)}
{isVideo && (
<>
<div className={RESPONSIVE_GRID}>
<SelectField
label="Fit"
value={objectFit}
onChange={(next) => {
void onSetStyle("object-fit", next);
}}
options={["contain", "cover", "fill", "none", "scale-down"]}
/>
<DetailField
label="Position"
value={objectPosition}
onCommit={(next) => {
void onSetStyle("object-position", next);
}}
/>
</div>
<DetailField
label="Poster"
value={poster}
onCommit={(next) => {
void onSetHtmlAttribute("poster", next || null);
}}
/>
</>
)}
</div>
</Section>
);
}
@@ -29,6 +29,7 @@ export function DomEditProvider({
clearDomSelection,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomHtmlAttributeCommit,
handleDomPathOffsetCommit,
handleDomGroupPathOffsetCommit,
handleDomBoxSizeCommit,
@@ -76,6 +77,7 @@ export function DomEditProvider({
clearDomSelection,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomHtmlAttributeCommit,
handleDomPathOffsetCommit,
handleDomGroupPathOffsetCommit,
handleDomBoxSizeCommit,
@@ -117,6 +119,7 @@ export function DomEditProvider({
clearDomSelection,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomHtmlAttributeCommit,
handleDomPathOffsetCommit,
handleDomGroupPathOffsetCommit,
handleDomBoxSizeCommit,
@@ -190,6 +190,7 @@ export function useDomEditCommits({
const {
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomHtmlAttributeCommit,
handleDomTextCommit,
commitDomTextFields,
handleDomTextFieldStyleCommit,
@@ -439,6 +440,7 @@ export function useDomEditCommits({
resolveImportedFontAsset,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomHtmlAttributeCommit,
handleDomTextCommit,
commitDomTextFields,
handleDomTextFieldStyleCommit,
@@ -194,6 +194,7 @@ export function useDomEditSession({
resolveImportedFontAsset,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomHtmlAttributeCommit,
handleDomTextCommit,
handleDomTextFieldStyleCommit,
handleDomAddTextField,
@@ -307,6 +308,7 @@ export function useDomEditSession({
clearDomSelection,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomHtmlAttributeCommit,
handleDomPathOffsetCommit,
handleDomGroupPathOffsetCommit,
handleDomBoxSizeCommit,
@@ -14,6 +14,7 @@ import {
buildDomEditStylePatchOperation,
buildDomEditTextPatchOperation,
findElementForSelection,
getDomEditTargetKey,
isTextEditableSelection,
serializeDomEditTextFields,
buildDefaultDomEditTextField,
@@ -125,7 +126,8 @@ export function useDomEditTextCommits({
const op: PatchOperation = { type: "attribute", property: attr, value };
try {
await persistDomEditOperations(domEditSelection, [op], {
label: "Edit timing",
label: `Edit ${attr.replace(/-/g, " ")}`,
coalesceKey: `attr:${attr}:${getDomEditTargetKey(domEditSelection)}`,
skipRefresh: false,
});
} catch (err) {
@@ -145,6 +147,45 @@ export function useDomEditTextCommits({
],
);
const handleDomHtmlAttributeCommit = useCallback(
async (attr: string, value: string | null) => {
if (!domEditSelection) return;
const iframe = previewIframeRef.current;
const doc = iframe?.contentDocument;
if (doc) {
const el = findElementForSelection(doc, domEditSelection, activeCompPath);
if (el) {
if (value === null || value === "" || value === "false") {
el.removeAttribute(attr);
} else {
el.setAttribute(attr, value);
}
}
}
const op: PatchOperation = { type: "html-attribute", property: attr, value };
try {
await persistDomEditOperations(domEditSelection, [op], {
label: `Edit ${attr}`,
coalesceKey: `html-attr:${attr}:${getDomEditTargetKey(domEditSelection)}`,
skipRefresh: false,
});
} catch (err) {
console.warn(
"[Studio] HTML attribute persist failed:",
err instanceof Error ? err.message : err,
);
}
refreshDomEditSelectionFromPreview(domEditSelection);
},
[
activeCompPath,
domEditSelection,
persistDomEditOperations,
refreshDomEditSelectionFromPreview,
previewIframeRef,
],
);
const handleDomTextCommit = useCallback(
async (value: string, fieldKey?: string) => {
if (!domEditSelection) return;
@@ -354,6 +395,7 @@ export function useDomEditTextCommits({
return {
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomHtmlAttributeCommit,
handleDomTextCommit,
commitDomTextFields,
handleDomTextFieldStyleCommit,
+90 -1
View File
@@ -87,7 +87,7 @@ function splitInlineStyleDeclarations(style: string): string[] {
}
export interface PatchOperation {
type: "inline-style" | "attribute" | "text-content";
type: "inline-style" | "attribute" | "text-content" | "html-attribute";
property: string;
value: string | null;
}
@@ -413,6 +413,91 @@ function findMatchingClosingTagIndex(html: string, tagName: string, contentStart
return -1;
}
const HTML_BOOLEAN_ATTRIBUTES = new Set([
"loop",
"muted",
"autoplay",
"playsinline",
"controls",
"default",
"defer",
"disabled",
"hidden",
"nomodule",
"open",
"readonly",
"required",
"reversed",
"selected",
]);
function patchHtmlAttributeInTag(
html: string,
tag: string,
attr: string,
value: string | null,
): string {
if (!tag) return html;
const isBoolean = HTML_BOOLEAN_ATTRIBUTES.has(attr);
if (isBoolean) {
const escapedAttr = escapeRegex(attr);
const hasBoolAttr = new RegExp(`(?:^|\\s)${escapedAttr}(?:\\s|=|$)`).test(tag);
if (value === null || value === "" || value === "false") {
if (!hasBoolAttr) return html;
const removePattern = new RegExp(`\\s+${escapedAttr}(?:=(["'])[^"']*\\1)?`);
const newTag = tag.replace(removePattern, "");
return html.replace(tag, newTag);
}
if (hasBoolAttr) return html;
const newTag = tag + ` ${attr}`;
return html.replace(tag, newTag);
}
const attrPattern = new RegExp(`\\b${escapeRegex(attr)}=(["'])([^"']*)\\1`);
if (value === null) {
if (!attrPattern.test(tag)) return html;
const removePattern = new RegExp(`\\s+${escapeRegex(attr)}=(["'])[^"']*\\1`);
const newTag = tag.replace(removePattern, "");
return html.replace(tag, newTag);
}
const escaped = escapeHtmlAttribute(value);
if (attrPattern.test(tag)) {
const newTag = tag.replace(attrPattern, `${attr}="${escaped}"`);
return html.replace(tag, newTag);
}
const newTag = tag + ` ${attr}="${escaped}"`;
return html.replace(tag, newTag);
}
function patchHtmlAttribute(
html: string,
elementId: string,
attr: string,
value: string | null,
): string {
const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
const match = idPattern.exec(html);
if (!match) return html;
return patchHtmlAttributeInTag(html, match[1], attr, value);
}
function patchHtmlAttributeByTarget(
html: string,
target: PatchTarget,
attr: string,
value: string | null,
): string {
const match = findTagByTarget(html, target);
if (!match) return html;
const newTag = patchHtmlAttributeInTag(match.tag, match.tag, attr, value);
return replaceTagAtMatch(html, match, newTag);
}
function patchTextContentByTarget(html: string, target: PatchTarget, value: string): string {
const match = findTagByTarget(html, target);
if (!match) return html;
@@ -436,6 +521,8 @@ export function applyPatch(html: string, elementId: string, op: PatchOperation):
return patchInlineStyle(html, elementId, op.property, op.value);
case "attribute":
return patchAttribute(html, elementId, op.property, op.value);
case "html-attribute":
return patchHtmlAttribute(html, elementId, op.property, op.value);
case "text-content":
return op.value !== null ? patchTextContent(html, elementId, op.value) : html;
default:
@@ -456,6 +543,8 @@ export function applyPatchByTarget(html: string, target: PatchTarget, op: PatchO
return patchInlineStyleByTarget(html, target, op.property, op.value);
case "attribute":
return patchAttributeByTarget(html, target, op.property, op.value);
case "html-attribute":
return patchHtmlAttributeByTarget(html, target, op.property, op.value);
case "text-content":
return op.value !== null ? patchTextContentByTarget(html, target, op.value) : html;
default: