diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index b553a8d19..80f07d8f3 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -55,6 +55,7 @@ export function StudioRightPanel({ copiedAgentPrompt, clearDomSelection, handleDomStyleCommit, + handleDomAttributeCommit, handleDomPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, @@ -168,6 +169,7 @@ export function StudioRightPanel({ copiedAgentPrompt={copiedAgentPrompt} onClearSelection={clearDomSelection} onSetStyle={handleDomStyleCommit} + onSetAttribute={handleDomAttributeCommit} 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 73947bf61..db8d2fea1 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -1,5 +1,5 @@ import { memo } from "react"; -import { Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons"; +import { Clock, Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons"; import { collectDomEditLayerItems, getDomEditLayerKey, @@ -39,6 +39,7 @@ interface PropertyPanelProps { copiedAgentPrompt: boolean; onClearSelection: () => void; onSetStyle: (prop: string, value: string) => void | Promise; + onSetAttribute: (attr: string, value: string) => 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; @@ -114,6 +115,68 @@ function LayerTree({ ); } +/* ------------------------------------------------------------------ */ +/* TimingSection */ +/* ------------------------------------------------------------------ */ + +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; +} + +function TimingSection({ + element, + onSetAttribute, +}: { + element: DomEditSelection; + onSetAttribute: (attr: string, value: string) => void | Promise; +}) { + const start = Number.parseFloat(element.dataAttributes.start ?? "0") || 0; + const duration = Number.parseFloat(element.dataAttributes.duration ?? "0") || 0; + const end = start + duration; + + const commitStart = (nextValue: string) => { + const parsed = parseTimingValue(nextValue); + if (parsed == null) return; + void onSetAttribute("start", parsed.toFixed(2)); + }; + + const commitDuration = (nextValue: string) => { + const parsed = parseTimingValue(nextValue); + if (parsed == null || parsed <= 0) return; + void onSetAttribute("duration", parsed.toFixed(2)); + }; + + const commitEnd = (nextValue: string) => { + const parsed = parseTimingValue(nextValue); + if (parsed == null || parsed <= start) return; + void onSetAttribute("duration", (parsed - start).toFixed(2)); + }; + + return ( +
}> +
+ + +
+
+ +
+
+ ); +} + /* ------------------------------------------------------------------ */ /* PropertyPanel */ /* ------------------------------------------------------------------ */ @@ -126,6 +189,7 @@ export const PropertyPanel = memo(function PropertyPanel({ copiedAgentPrompt, onClearSelection, onSetStyle, + onSetAttribute, onSetManualOffset, onSetManualSize, onSetManualRotation, @@ -322,6 +386,10 @@ export const PropertyPanel = memo(function PropertyPanel({ + {element.dataAttributes.start != null && ( + + )} + {showEditableSections && ( 0) { - return childFields.map((child, index) => - buildTextField(child, index, childFields.length, "child"), + const childElements = Array.from(el.children).filter(isHtmlElement).filter(isEditableTextLeaf); + + if (childElements.length > 0) { + const hasMixedContent = Array.from(el.childNodes).some( + (node) => node.nodeType === 3 && node.textContent?.trim(), + ); + + if (hasMixedContent) { + const fields: DomEditTextField[] = []; + let childIdx = 0; + for (const node of el.childNodes) { + if (node.nodeType === 3) { + const text = node.textContent ?? ""; + if (!text.trim()) continue; + fields.push({ + key: `text-node:${childIdx}`, + label: `Text ${childIdx + 1}`, + value: text, + tagName: "#text", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "text-node", + }); + childIdx++; + } else if (isHtmlElement(node) && isEditableTextLeaf(node)) { + fields.push(buildTextField(node, childIdx, childElements.length, "child")); + childIdx++; + } + } + return fields; + } + + return childElements.map((child, index) => + buildTextField(child, index, childElements.length, "child"), ); } @@ -99,8 +130,11 @@ function serializeTextFieldStyle(field: DomEditTextField): string { export function serializeDomEditTextFields(fields: DomEditTextField[]): string { return fields - .filter((field) => field.source === "child") + .filter((field) => field.source === "child" || field.source === "text-node") .map((field) => { + if (field.source === "text-node") { + return escapeHtmlText(field.value); + } const attrs = [ ...field.attributes.filter((attribute) => attribute.name !== "data-hf-text-key"), { name: "data-hf-text-key", value: field.key }, diff --git a/packages/studio/src/components/editor/domEditingTypes.ts b/packages/studio/src/components/editor/domEditingTypes.ts index e139395cb..da7b36e49 100644 --- a/packages/studio/src/components/editor/domEditingTypes.ts +++ b/packages/studio/src/components/editor/domEditingTypes.ts @@ -65,7 +65,7 @@ export interface DomEditTextField { attributes: Array<{ name: string; value: string }>; inlineStyles: Record; computedStyles: Record; - source: "self" | "child"; + source: "self" | "child" | "text-node"; } export interface DomEditSelection extends PatchTarget { diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index d6aba5d6a..ad98777b8 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -28,6 +28,7 @@ export function DomEditProvider({ applyDomSelection, clearDomSelection, handleDomStyleCommit, + handleDomAttributeCommit, handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, @@ -74,6 +75,7 @@ export function DomEditProvider({ applyDomSelection, clearDomSelection, handleDomStyleCommit, + handleDomAttributeCommit, handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, @@ -114,6 +116,7 @@ export function DomEditProvider({ applyDomSelection, clearDomSelection, handleDomStyleCommit, + handleDomAttributeCommit, handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 8c838b66c..c0458e0b6 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -189,6 +189,7 @@ export function useDomEditCommits({ const { handleDomStyleCommit, + handleDomAttributeCommit, handleDomTextCommit, commitDomTextFields, handleDomTextFieldStyleCommit, @@ -437,6 +438,7 @@ export function useDomEditCommits({ return { resolveImportedFontAsset, handleDomStyleCommit, + handleDomAttributeCommit, handleDomTextCommit, commitDomTextFields, handleDomTextFieldStyleCommit, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 61e37553c..46d29c393 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -193,6 +193,7 @@ export function useDomEditSession({ const { resolveImportedFontAsset, handleDomStyleCommit, + handleDomAttributeCommit, handleDomTextCommit, handleDomTextFieldStyleCommit, handleDomAddTextField, @@ -305,6 +306,7 @@ export function useDomEditSession({ applyDomSelection, clearDomSelection, handleDomStyleCommit, + handleDomAttributeCommit, handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, diff --git a/packages/studio/src/hooks/useDomEditTextCommits.ts b/packages/studio/src/hooks/useDomEditTextCommits.ts index 429ddbaaa..7b78b24d3 100644 --- a/packages/studio/src/hooks/useDomEditTextCommits.ts +++ b/packages/studio/src/hooks/useDomEditTextCommits.ts @@ -113,6 +113,38 @@ export function useDomEditTextCommits({ ], ); + const handleDomAttributeCommit = useCallback( + async (attr: string, value: string) => { + if (!domEditSelection) return; + const iframe = previewIframeRef.current; + const doc = iframe?.contentDocument; + if (doc) { + const el = findElementForSelection(doc, domEditSelection, activeCompPath); + if (el) el.setAttribute(`data-${attr}`, value); + } + const op: PatchOperation = { type: "attribute", property: attr, value }; + try { + await persistDomEditOperations(domEditSelection, [op], { + label: "Edit timing", + skipRefresh: false, + }); + } catch (err) { + console.warn( + "[Studio] 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; @@ -321,6 +353,7 @@ export function useDomEditTextCommits({ return { handleDomStyleCommit, + handleDomAttributeCommit, handleDomTextCommit, commitDomTextFields, handleDomTextFieldStyleCommit,