Skip to content

Commit cd0e6b0

Browse files
feat(studio): Timing inspector + fix mixed-content text editing (#896)
* feat(studio): add clipboard payload types and ID deduplication * feat(studio): add Ctrl+C/V/X copy/paste for timeline clips and DOM elements * fix(studio): use duck-typing for cross-frame element access in clipboard Elements from the preview iframe are from a different window context, so `el instanceof HTMLElement` always returns false. Use `"outerHTML" in el` instead to correctly detect elements across frame boundaries. * fix(studio): preserve playhead position after paste reloadPreview() used location.reload() which bypassed the NLELayout saveSeekPosition effect, causing the playhead to reset to 0:00 after paste. Switch to setRefreshKey which triggers the effect and restores the seek position after the iframe reloads. * fix(studio): paste DOM elements as siblings, not at composition root DOM element paste was inserting at the composition root, losing the parent context that provides CSS styles and positioning. Now stores the origin selector on copy and inserts the paste as a sibling immediately after the original element, preserving style inheritance. Falls back to root insertion if the selector can't be matched. * fix(studio): address review — deduplicateIds, native copy, altKey guard - deduplicateIds regex used \b which matched data-composition-id, data-clip-id, etc. Switch to lookbehind (?<=\s) so only standalone id="..." attributes are rewritten. Add test pinning this. - Ctrl+C no longer calls preventDefault() before confirming there's a selected element. Native browser copy (text selections outside inputs) is preserved when nothing is selected in the Studio. - Add !event.altKey guard on C/V/X to avoid intercepting Cmd+Alt+V (paste-as-plain-text) and similar OS gestures. - Remove no-op .replace(/"/g, '"') flagged by CodeQL. * fix(studio): address review round 2 — Cmd+X guard, data-start scope, revert drive-by - Cmd+X now pre-checks selection state before preventDefault, mirroring the Cmd+C fix. Native cut preserved when nothing is selected. - handleCut returns Promise<boolean> so the caller can gate on it. - data-start rewrite scoped to the outermost opening tag only, so nested clip timing is preserved on paste. - Removed system clipboard write (cross-tab paste unsupported, in-memory ref is the only read path). - Reverted the reloadPreview drive-by (setRefreshKey→location.reload); the perf branch (#895) handles this properly via refreshPlayer(). * perf(studio): use lightweight iframe.src reload instead of Player teardown Content refreshes (paste, move, resize, delete, asset drop) previously triggered setRefreshKey which changed the Player's React key, causing full web-component destruction + iframe teardown + crossfade animation + re-initialization of all event listeners and asset polling. Now NLELayout intercepts refreshKey changes and calls refreshPlayer() which just appends a cache-busting _t param to the iframe src. The Player web component stays alive, event listeners persist, and the reload is ~10x faster with no "waiting for media" flash. Key-based teardown is preserved for actual structural changes (project switch, composition drill-down via directUrl change). * perf(studio): skip asset-loading overlay on content refreshes The asset-loading overlay ("Preparing preview assets") polled for video/audio readyState on every iframe load, including content refreshes from paste/move/resize. On reloads the browser serves assets from cache so they resolve near-instantly — the overlay just created a disruptive flash. Now skips the polling on subsequent loads (loadCountRef > 1), only showing it on the initial cold load. * 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. * fix(studio): preserve bare text nodes in mixed-content elements collectDomEditTextFields only captured child HTML elements, ignoring bare text nodes. For elements like: <div class="headline">If you're <span>turning 65</span> soon...</div> only the <span> 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. * fix(studio): address #896 review — remove scrub from timing, add mixed-content test - Remove scrub from Timing fields: 1px = 1 second is too coarse. Scroll-wheel and direct typing still work with sub-second precision. - Add mixed-content text-node serialization test in a separate file (domEditingTextFields.test.ts) to avoid bloating the existing domEditing.test.ts past the filesize limit.
1 parent 83b3eba commit cd0e6b0

9 files changed

Lines changed: 210 additions & 7 deletions

File tree

packages/studio/src/components/StudioRightPanel.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export function StudioRightPanel({
5555
copiedAgentPrompt,
5656
clearDomSelection,
5757
handleDomStyleCommit,
58+
handleDomAttributeCommit,
5859
handleDomPathOffsetCommit,
5960
handleDomBoxSizeCommit,
6061
handleDomRotationCommit,
@@ -168,6 +169,7 @@ export function StudioRightPanel({
168169
copiedAgentPrompt={copiedAgentPrompt}
169170
onClearSelection={clearDomSelection}
170171
onSetStyle={handleDomStyleCommit}
172+
onSetAttribute={handleDomAttributeCommit}
171173
onSetManualOffset={handleDomPathOffsetCommit}
172174
onSetManualSize={handleDomBoxSizeCommit}
173175
onSetManualRotation={handleDomRotationCommit}

packages/studio/src/components/editor/PropertyPanel.tsx

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { memo } from "react";
2-
import { Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
2+
import { Clock, Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
33
import {
44
collectDomEditLayerItems,
55
getDomEditLayerKey,
@@ -39,6 +39,7 @@ interface PropertyPanelProps {
3939
copiedAgentPrompt: boolean;
4040
onClearSelection: () => void;
4141
onSetStyle: (prop: string, value: string) => void | Promise<void>;
42+
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
4243
onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
4344
onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
4445
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
@@ -114,6 +115,67 @@ function LayerTree({
114115
);
115116
}
116117

118+
/* ------------------------------------------------------------------ */
119+
/* TimingSection */
120+
/* ------------------------------------------------------------------ */
121+
122+
function formatTimingValue(seconds: number): string {
123+
if (!Number.isFinite(seconds) || seconds < 0) return "0.00s";
124+
return `${seconds.toFixed(2)}s`;
125+
}
126+
127+
function parseTimingValue(input: string): number | null {
128+
const cleaned = input.replace(/s$/i, "").trim();
129+
const parsed = Number.parseFloat(cleaned);
130+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
131+
}
132+
133+
function TimingSection({
134+
element,
135+
onSetAttribute,
136+
}: {
137+
element: DomEditSelection;
138+
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
139+
}) {
140+
const start = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
141+
const duration = Number.parseFloat(element.dataAttributes.duration ?? "0") || 0;
142+
const end = start + duration;
143+
144+
const commitStart = (nextValue: string) => {
145+
const parsed = parseTimingValue(nextValue);
146+
if (parsed == null) return;
147+
void onSetAttribute("start", parsed.toFixed(2));
148+
};
149+
150+
const commitDuration = (nextValue: string) => {
151+
const parsed = parseTimingValue(nextValue);
152+
if (parsed == null || parsed <= 0) return;
153+
void onSetAttribute("duration", parsed.toFixed(2));
154+
};
155+
156+
const commitEnd = (nextValue: string) => {
157+
const parsed = parseTimingValue(nextValue);
158+
if (parsed == null || parsed <= start) return;
159+
void onSetAttribute("duration", (parsed - start).toFixed(2));
160+
};
161+
162+
return (
163+
<Section title="Timing" icon={<Clock size={15} />}>
164+
<div className={RESPONSIVE_GRID}>
165+
<MetricField label="Start" value={formatTimingValue(start)} onCommit={commitStart} />
166+
<MetricField label="End" value={formatTimingValue(end)} onCommit={commitEnd} />
167+
</div>
168+
<div className="mt-3">
169+
<MetricField
170+
label="Duration"
171+
value={formatTimingValue(duration)}
172+
onCommit={commitDuration}
173+
/>
174+
</div>
175+
</Section>
176+
);
177+
}
178+
117179
/* ------------------------------------------------------------------ */
118180
/* PropertyPanel */
119181
/* ------------------------------------------------------------------ */
@@ -126,6 +188,7 @@ export const PropertyPanel = memo(function PropertyPanel({
126188
copiedAgentPrompt,
127189
onClearSelection,
128190
onSetStyle,
191+
onSetAttribute,
129192
onSetManualOffset,
130193
onSetManualSize,
131194
onSetManualRotation,
@@ -322,6 +385,10 @@ export const PropertyPanel = memo(function PropertyPanel({
322385
</div>
323386
</Section>
324387

388+
{element.dataAttributes.start != null && (
389+
<TimingSection element={element} onSetAttribute={onSetAttribute} />
390+
)}
391+
325392
{showEditableSections && (
326393
<StyleSections
327394
projectId={projectId}

packages/studio/src/components/editor/domEditingLayers.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,41 @@ function buildTextField(
7373
}
7474

7575
export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] {
76-
const childFields = Array.from(el.children).filter(isHtmlElement).filter(isEditableTextLeaf);
77-
if (childFields.length > 0) {
78-
return childFields.map((child, index) =>
79-
buildTextField(child, index, childFields.length, "child"),
76+
const childElements = Array.from(el.children).filter(isHtmlElement).filter(isEditableTextLeaf);
77+
78+
if (childElements.length > 0) {
79+
const hasMixedContent = Array.from(el.childNodes).some(
80+
(node) => node.nodeType === 3 && node.textContent?.trim(),
81+
);
82+
83+
if (hasMixedContent) {
84+
const fields: DomEditTextField[] = [];
85+
let childIdx = 0;
86+
for (const node of el.childNodes) {
87+
if (node.nodeType === 3) {
88+
const text = node.textContent ?? "";
89+
if (!text.trim()) continue;
90+
fields.push({
91+
key: `text-node:${childIdx}`,
92+
label: `Text ${childIdx + 1}`,
93+
value: text,
94+
tagName: "#text",
95+
attributes: [],
96+
inlineStyles: {},
97+
computedStyles: {},
98+
source: "text-node",
99+
});
100+
childIdx++;
101+
} else if (isHtmlElement(node) && isEditableTextLeaf(node)) {
102+
fields.push(buildTextField(node, childIdx, childElements.length, "child"));
103+
childIdx++;
104+
}
105+
}
106+
return fields;
107+
}
108+
109+
return childElements.map((child, index) =>
110+
buildTextField(child, index, childElements.length, "child"),
80111
);
81112
}
82113

@@ -99,8 +130,11 @@ function serializeTextFieldStyle(field: DomEditTextField): string {
99130

100131
export function serializeDomEditTextFields(fields: DomEditTextField[]): string {
101132
return fields
102-
.filter((field) => field.source === "child")
133+
.filter((field) => field.source === "child" || field.source === "text-node")
103134
.map((field) => {
135+
if (field.source === "text-node") {
136+
return escapeHtmlText(field.value);
137+
}
104138
const attrs = [
105139
...field.attributes.filter((attribute) => attribute.name !== "data-hf-text-key"),
106140
{ name: "data-hf-text-key", value: field.key },
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, expect, it } from "vitest";
2+
import { serializeDomEditTextFields } from "./domEditing";
3+
4+
describe("serializeDomEditTextFields — mixed content", () => {
5+
it("round-trips text-node + child element fields", () => {
6+
expect(
7+
serializeDomEditTextFields([
8+
{
9+
key: "text-node:0",
10+
label: "Text 1",
11+
value: "If you're ",
12+
tagName: "#text",
13+
attributes: [],
14+
inlineStyles: {},
15+
computedStyles: {},
16+
source: "text-node",
17+
},
18+
{
19+
key: "child:1:span",
20+
label: "Text 2",
21+
value: "turning 65",
22+
tagName: "span",
23+
attributes: [{ name: "class", value: "accent" }],
24+
inlineStyles: { color: "red" },
25+
computedStyles: {},
26+
source: "child",
27+
},
28+
{
29+
key: "text-node:2",
30+
label: "Text 3",
31+
value: " soon...",
32+
tagName: "#text",
33+
attributes: [],
34+
inlineStyles: {},
35+
computedStyles: {},
36+
source: "text-node",
37+
},
38+
]),
39+
).toBe(
40+
`If you're <span class="accent" data-hf-text-key="child:1:span" style="color: red">turning 65</span> soon...`,
41+
);
42+
});
43+
44+
it("escapes HTML entities in text-node values", () => {
45+
expect(
46+
serializeDomEditTextFields([
47+
{
48+
key: "text-node:0",
49+
label: "Text 1",
50+
value: "A < B & C > D",
51+
tagName: "#text",
52+
attributes: [],
53+
inlineStyles: {},
54+
computedStyles: {},
55+
source: "text-node",
56+
},
57+
]),
58+
).toBe("A &lt; B &amp; C &gt; D");
59+
});
60+
});

packages/studio/src/components/editor/domEditingTypes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export interface DomEditTextField {
6565
attributes: Array<{ name: string; value: string }>;
6666
inlineStyles: Record<string, string>;
6767
computedStyles: Record<string, string>;
68-
source: "self" | "child";
68+
source: "self" | "child" | "text-node";
6969
}
7070

7171
export interface DomEditSelection extends PatchTarget {

packages/studio/src/contexts/DomEditContext.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export function DomEditProvider({
2828
applyDomSelection,
2929
clearDomSelection,
3030
handleDomStyleCommit,
31+
handleDomAttributeCommit,
3132
handleDomPathOffsetCommit,
3233
handleDomGroupPathOffsetCommit,
3334
handleDomBoxSizeCommit,
@@ -74,6 +75,7 @@ export function DomEditProvider({
7475
applyDomSelection,
7576
clearDomSelection,
7677
handleDomStyleCommit,
78+
handleDomAttributeCommit,
7779
handleDomPathOffsetCommit,
7880
handleDomGroupPathOffsetCommit,
7981
handleDomBoxSizeCommit,
@@ -114,6 +116,7 @@ export function DomEditProvider({
114116
applyDomSelection,
115117
clearDomSelection,
116118
handleDomStyleCommit,
119+
handleDomAttributeCommit,
117120
handleDomPathOffsetCommit,
118121
handleDomGroupPathOffsetCommit,
119122
handleDomBoxSizeCommit,

packages/studio/src/hooks/useDomEditCommits.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ export function useDomEditCommits({
189189

190190
const {
191191
handleDomStyleCommit,
192+
handleDomAttributeCommit,
192193
handleDomTextCommit,
193194
commitDomTextFields,
194195
handleDomTextFieldStyleCommit,
@@ -437,6 +438,7 @@ export function useDomEditCommits({
437438
return {
438439
resolveImportedFontAsset,
439440
handleDomStyleCommit,
441+
handleDomAttributeCommit,
440442
handleDomTextCommit,
441443
commitDomTextFields,
442444
handleDomTextFieldStyleCommit,

packages/studio/src/hooks/useDomEditSession.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ export function useDomEditSession({
193193
const {
194194
resolveImportedFontAsset,
195195
handleDomStyleCommit,
196+
handleDomAttributeCommit,
196197
handleDomTextCommit,
197198
handleDomTextFieldStyleCommit,
198199
handleDomAddTextField,
@@ -305,6 +306,7 @@ export function useDomEditSession({
305306
applyDomSelection,
306307
clearDomSelection,
307308
handleDomStyleCommit,
309+
handleDomAttributeCommit,
308310
handleDomPathOffsetCommit,
309311
handleDomGroupPathOffsetCommit,
310312
handleDomBoxSizeCommit,

packages/studio/src/hooks/useDomEditTextCommits.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,38 @@ export function useDomEditTextCommits({
113113
],
114114
);
115115

116+
const handleDomAttributeCommit = useCallback(
117+
async (attr: string, value: string) => {
118+
if (!domEditSelection) return;
119+
const iframe = previewIframeRef.current;
120+
const doc = iframe?.contentDocument;
121+
if (doc) {
122+
const el = findElementForSelection(doc, domEditSelection, activeCompPath);
123+
if (el) el.setAttribute(`data-${attr}`, value);
124+
}
125+
const op: PatchOperation = { type: "attribute", property: attr, value };
126+
try {
127+
await persistDomEditOperations(domEditSelection, [op], {
128+
label: "Edit timing",
129+
skipRefresh: false,
130+
});
131+
} catch (err) {
132+
console.warn(
133+
"[Studio] Attribute persist failed:",
134+
err instanceof Error ? err.message : err,
135+
);
136+
}
137+
refreshDomEditSelectionFromPreview(domEditSelection);
138+
},
139+
[
140+
activeCompPath,
141+
domEditSelection,
142+
persistDomEditOperations,
143+
refreshDomEditSelectionFromPreview,
144+
previewIframeRef,
145+
],
146+
);
147+
116148
const handleDomTextCommit = useCallback(
117149
async (value: string, fieldKey?: string) => {
118150
if (!domEditSelection) return;
@@ -321,6 +353,7 @@ export function useDomEditTextCommits({
321353

322354
return {
323355
handleDomStyleCommit,
356+
handleDomAttributeCommit,
324357
handleDomTextCommit,
325358
commitDomTextFields,
326359
handleDomTextFieldStyleCommit,

0 commit comments

Comments
 (0)