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..ef82b14ac 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -12,8 +12,7 @@ import { /** Motion data without targeting metadata. */ type StudioMotionData = Omit; -import { useCallback } from "react"; -import { resolveDomEditSelection, type DomEditLayerItem } from "./editor/domEditing"; + import { useStudioContext } from "../contexts/StudioContext"; import { usePanelLayoutContext } from "../contexts/PanelLayoutContext"; import { useFileManagerContext } from "../contexts/FileManagerContext"; @@ -56,6 +55,7 @@ export function StudioRightPanel({ clearDomSelection, handleDomStyleCommit, handleDomAttributeCommit, + handleDomHtmlAttributeCommit, handleDomPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, @@ -66,23 +66,10 @@ export function StudioRightPanel({ handleAskAgent, handleDomMotionCommit, handleDomMotionClear, - applyDomSelection, } = useDomEditContext(); - const { assets, fontAssets, handleImportFiles, handleImportFonts } = useFileManagerContext(); - - const isMasterView = !activeCompPath || activeCompPath === "index.html"; - const handleSelectLayer = useCallback( - (layer: DomEditLayerItem) => { - const selection = resolveDomEditSelection(layer.element, { - activeCompositionPath: activeCompPath, - isMasterView, - preferClipAncestor: false, - }); - if (selection) applyDomSelection(selection); - }, - [activeCompPath, isMasterView, applyDomSelection], - ); + const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts } = + useFileManagerContext(); const renderJobs = renderQueue.jobs as RenderJob[]; @@ -163,6 +150,7 @@ export function StudioRightPanel({ ) : designPanelActive ? ( 1 ? null : domEditSelection} multiSelectCount={domEditGroupSelections.length} @@ -170,6 +158,7 @@ export function StudioRightPanel({ onClearSelection={clearDomSelection} onSetStyle={handleDomStyleCommit} onSetAttribute={handleDomAttributeCommit} + onSetHtmlAttribute={handleDomHtmlAttributeCommit} onSetManualOffset={handleDomPathOffsetCommit} onSetManualSize={handleDomBoxSizeCommit} onSetManualRotation={handleDomRotationCommit} @@ -181,8 +170,6 @@ export function StudioRightPanel({ onImportAssets={handleImportFiles} fontAssets={fontAssets} onImportFonts={handleImportFonts} - activeCompositionPath={activeCompPath} - onSelectLayer={handleSelectLayer} /> ) : motionPanelActive ? ( 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; @@ -51,68 +49,6 @@ interface PropertyPanelProps { onImportAssets?: (files: FileList) => Promise; fontAssets?: ImportedFontAsset[]; onImportFonts?: (files: FileList | File[]) => Promise; - activeCompositionPath?: string | null; - onSelectLayer?: (layer: DomEditLayerItem) => void; -} - -/* ------------------------------------------------------------------ */ -/* LayerTree */ -/* ------------------------------------------------------------------ */ - -function LayerTree({ - element, - activeCompositionPath, - onSelectLayer, -}: { - element: DomEditSelection | null; - activeCompositionPath: string | null; - onSelectLayer: (layer: DomEditLayerItem) => void; -}) { - const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html"; - const layers = collectDomEditLayerItems(element?.element, { - activeCompositionPath, - isMasterView, - }); - if (layers.length <= 1) return null; - - const selectedKey = element ? getDomEditLayerKey(element) : null; - - return ( -
}> -
- {layers.map((layer) => { - const selected = layer.key === selectedKey; - return ( - - ); - })} -
-
- ); } /* ------------------------------------------------------------------ */ @@ -182,6 +118,7 @@ function TimingSection({ export const PropertyPanel = memo(function PropertyPanel({ projectId, + projectDir, assets, element, multiSelectCount = 0, @@ -189,6 +126,7 @@ export const PropertyPanel = memo(function PropertyPanel({ onClearSelection, onSetStyle, onSetAttribute, + onSetHtmlAttribute, onSetManualOffset, onSetManualSize, onSetManualRotation, @@ -200,8 +138,6 @@ export const PropertyPanel = memo(function PropertyPanel({ onImportAssets, fontAssets = [], onImportFonts, - activeCompositionPath = null, - onSelectLayer, }: PropertyPanelProps) { const styles = element?.computedStyles ?? EMPTY_STYLES; @@ -331,11 +267,18 @@ export const PropertyPanel = memo(function PropertyPanel({ onRemoveTextField={onRemoveTextField} /> - {onSelectLayer && ( - + )} + + {isMediaElement(element) && ( + )} @@ -385,10 +328,6 @@ export const PropertyPanel = memo(function PropertyPanel({ - {element.dataAttributes.start != null && ( - - )} - {showEditableSections && ( ; + 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 sourceDuration = + Number.parseFloat(element.dataAttributes["source-duration"] ?? "") || + (el as HTMLMediaElement).duration || + 0; + const mediaStartMax = Math.max(30, Math.ceil(sourceDuration || mediaStart + 10)); + + const srcAttr = el.getAttribute("src") ?? ""; + const [copied, setCopied] = useState(false); + + const absoluteSrc = + projectDir && srcAttr && !srcAttr.startsWith("http") ? `${projectDir}/${srcAttr}` : srcAttr; + + return ( +
: } + > +
+ {srcAttr && ( +
+
+
Source
+ +
+
+ {absoluteSrc} +
+
+ )} + +
+ Volume + `${Math.round(next)}%`} + onCommit={(next) => { + void onSetAttribute("volume", formatNumericValue(next / 100)); + }} + /> +
+ +
+ Playback rate + `${formatNumericValue(next / 100)}x`} + onCommit={(next) => { + void onSetAttribute("playback-rate", formatNumericValue(next / 100)); + }} + /> +
+ +
+ Media start + formatTimingValue(next / 100)} + onCommit={(next) => { + void onSetAttribute("media-start", (next / 100).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); + }} + options={[ + "center", + "top", + "bottom", + "left", + "right", + "left top", + "right top", + "left bottom", + "right bottom", + ]} + /> +
+ + )} +
+
+ ); +} 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/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 111ecc69a..31ebac4a7 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -57,7 +57,6 @@ export function useAppHotkeys({ handleTimelineElementDelete, handleDomEditElementDelete, domEditSelectionRef, - clearDomSelectionRef, editHistory, readOptionalProjectFile, readProjectFile, @@ -116,12 +115,10 @@ export function useAppHotkeys({ return; } if (result.ok && result.label) { - clearDomSelectionRef.current(); await syncHistoryPreviewAfterApply(result.paths); showToast(`Undid ${result.label}`, "info"); } }, [ - clearDomSelectionRef, editHistory, readHistoryProjectFile, showToast, @@ -141,12 +138,10 @@ export function useAppHotkeys({ return; } if (result.ok && result.label) { - clearDomSelectionRef.current(); await syncHistoryPreviewAfterApply(result.paths); showToast(`Redid ${result.label}`, "info"); } }, [ - clearDomSelectionRef, editHistory, readHistoryProjectFile, showToast, 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: diff --git a/packages/studio/vite.adapter.ts b/packages/studio/vite.adapter.ts index c61e3cf05..bbcb553ec 100644 --- a/packages/studio/vite.adapter.ts +++ b/packages/studio/vite.adapter.ts @@ -1,6 +1,6 @@ // Vite adapter that wires the shared Studio API to the local filesystem and build tools. -import { readFileSync, readdirSync, existsSync, writeFileSync } from "node:fs"; +import { readFileSync, readdirSync, existsSync, writeFileSync, realpathSync } from "node:fs"; import { join, relative, resolve, isAbsolute } from "node:path"; import type { ViteDevServer } from "vite"; import { @@ -132,7 +132,11 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi if (session.projectId) { projectDir = join(dataDir, session.projectId); if (existsSync(projectDir)) { - return { id: session.projectId, dir: projectDir, title: session.title }; + return { + id: session.projectId, + dir: realpathSync(projectDir), + title: session.title, + }; } } } catch { @@ -141,7 +145,7 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi } return null; } - return { id, dir: projectDir }; + return { id, dir: realpathSync(projectDir) }; }, async bundle(dir: string) {