diff --git a/.filesize-allowlist b/.filesize-allowlist index b4dbc51b4..895df1d18 100644 --- a/.filesize-allowlist +++ b/.filesize-allowlist @@ -3,3 +3,4 @@ packages/studio/src/hooks/useManifestPersistence.ts packages/studio/src/player/components/PlayerControls.tsx packages/studio/src/components/editor/manualEdits.test.ts packages/studio/src/components/editor/manualEditsDom.ts +packages/studio/src/utils/sourcePatcher.ts diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 80f07d8f3..b7d2c6810 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -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} diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index bd6d264e1..719062e29 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -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; onSetAttribute: (attr: string, value: string) => void | Promise; + onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise; 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({ )} + {isMediaElement(element) && ( + + )} + {showEditableSections && ( = 0 ? parsed : null; +} + +export function MediaSection({ + element, + styles, + onSetStyle, + onSetAttribute, + onSetHtmlAttribute, +}: { + element: DomEditSelection; + styles: Record; + onSetStyle: (prop: string, value: string) => void | Promise; + onSetAttribute: (attr: string, value: string) => void | Promise; + onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise; +}) { + 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 ( +
: } + > +
+ {src && ( +
+
Source
+
+ {src.split("/").pop() || src} +
+
+ )} + +
+ Volume + `${Math.round(next)}%`} + onCommit={(next) => { + void onSetAttribute("volume", formatNumericValue(next / 100)); + }} + /> +
+ + { + const parsed = Number.parseFloat(next); + if (!Number.isFinite(parsed) || parsed < 0.1 || parsed > 5) return; + void onSetAttribute("playback-rate", formatNumericValue(parsed)); + }} + /> + + { + const parsed = parseTimingValue(next); + if (parsed == null) return; + void onSetAttribute("media-start", parsed.toFixed(2)); + }} + /> + +
+
+ Loop + { + void onSetHtmlAttribute("loop", next === "on" ? "true" : null); + }} + options={[ + { label: "On", value: "on" }, + { label: "Off", value: "off" }, + ]} + /> +
+
+ Muted + { + void onSetHtmlAttribute("muted", next === "on" ? "true" : null); + }} + options={[ + { label: "On", value: "on" }, + { label: "Off", value: "off" }, + ]} + /> +
+
+ + {isVideo && ( +
+ Has audio track + { + 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" }, + ]} + /> +
+ )} + + {isVideo && ( + <> +
+ { + void onSetStyle("object-fit", next); + }} + options={["contain", "cover", "fill", "none", "scale-down"]} + /> + { + void onSetStyle("object-position", next); + }} + /> +
+ + { + void onSetHtmlAttribute("poster", next || null); + }} + /> + + )} +
+
+ ); +} diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index ad98777b8..5160b2610 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -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, diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index c0458e0b6..584ab3aab 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -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, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 46d29c393..ba0260e06 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -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, diff --git a/packages/studio/src/hooks/useDomEditTextCommits.ts b/packages/studio/src/hooks/useDomEditTextCommits.ts index 7b78b24d3..a6ea006ce 100644 --- a/packages/studio/src/hooks/useDomEditTextCommits.ts +++ b/packages/studio/src/hooks/useDomEditTextCommits.ts @@ -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, diff --git a/packages/studio/src/utils/sourcePatcher.ts b/packages/studio/src/utils/sourcePatcher.ts index ba142e2c8..0ef7fb7f1 100644 --- a/packages/studio/src/utils/sourcePatcher.ts +++ b/packages/studio/src/utils/sourcePatcher.ts @@ -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: