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
@@ -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,