From 0feb738b1b6cf00331fe07431e4690199d222ae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 15 May 2026 23:46:37 -0700 Subject: [PATCH 1/2] feat(studio): add Timing section to inspector Design panel Adds Start, End, and Duration fields to the Design panel when the selected element has data-start/data-duration attributes. Editing any field commits via the attribute patch pipeline (same as timeline edits) and refreshes the preview. End is computed from start+duration and writing End adjusts duration accordingly. --- .../src/components/StudioRightPanel.tsx | 2 + .../src/components/editor/PropertyPanel.tsx | 70 ++++++++++++++++++- .../studio/src/contexts/DomEditContext.tsx | 3 + .../studio/src/hooks/useDomEditCommits.ts | 2 + .../studio/src/hooks/useDomEditSession.ts | 2 + .../studio/src/hooks/useDomEditTextCommits.ts | 33 +++++++++ 6 files changed, 111 insertions(+), 1 deletion(-) 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 && ( { + 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, From f6202d34af512e4abe9487d5ed0b4bcc5fb98fff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 15 May 2026 23:56:49 -0700 Subject: [PATCH 2/2] fix(studio): preserve bare text nodes in mixed-content elements collectDomEditTextFields only captured child HTML elements, ignoring bare text nodes. For elements like:
If you're turning 65 soon...
only the was collected as a text field. When commitDomTextFields serialized back, "If you're " and " soon..." were lost. Now walks childNodes and creates text-node fields for bare text nodes alongside child element fields. serializeDomEditTextFields emits bare text for text-node fields, preserving the complete mixed content. --- .../src/components/editor/domEditingLayers.ts | 44 ++++++++++++++++--- .../src/components/editor/domEditingTypes.ts | 2 +- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/packages/studio/src/components/editor/domEditingLayers.ts b/packages/studio/src/components/editor/domEditingLayers.ts index b79d8e102..97711583d 100644 --- a/packages/studio/src/components/editor/domEditingLayers.ts +++ b/packages/studio/src/components/editor/domEditingLayers.ts @@ -73,10 +73,41 @@ function buildTextField( } export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] { - const childFields = Array.from(el.children).filter(isHtmlElement).filter(isEditableTextLeaf); - if (childFields.length > 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 {