@hyperframes/studio 0.6.6 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/assets/{hyperframes-player-T-ME1rqL.js → hyperframes-player-D0Yi3xMP.js} +2 -2
  2. package/dist/assets/index-BSe0Kibk.js +115 -0
  3. package/dist/assets/{index-Bne9FFeo.css → index-Ckqo37Co.css} +1 -1
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +59 -41
  7. package/src/components/StudioGlobalDragOverlay.tsx +26 -0
  8. package/src/components/StudioLeftSidebar.tsx +16 -2
  9. package/src/components/StudioRightPanel.tsx +15 -4
  10. package/src/components/editor/DomEditOverlay.test.ts +1 -0
  11. package/src/components/editor/DomEditOverlay.tsx +2 -1
  12. package/src/components/editor/MotionPanel.tsx +8 -8
  13. package/src/components/editor/PropertyPanel.tsx +27 -36
  14. package/src/components/editor/SourceEditor.tsx +14 -0
  15. package/src/components/editor/domEditingElement.ts +1 -0
  16. package/src/components/editor/manualEdits.test.ts +39 -466
  17. package/src/components/editor/manualEdits.ts +8 -168
  18. package/src/components/editor/manualEditsDom.ts +417 -1
  19. package/src/components/editor/manualEditsParsing.ts +2 -240
  20. package/src/components/editor/manualEditsTypes.ts +1 -40
  21. package/src/components/editor/studioMotion.ts +96 -0
  22. package/src/components/editor/studioMotionOps.test.ts +445 -0
  23. package/src/components/editor/studioMotionOps.ts +78 -4
  24. package/src/components/editor/useDomEditOverlayGestures.ts +25 -8
  25. package/src/components/nle/NLEPreview.tsx +1 -1
  26. package/src/components/renders/RenderQueue.tsx +20 -6
  27. package/src/components/renders/renderSettings.ts +38 -0
  28. package/src/components/renders/useRenderQueue.ts +11 -1
  29. package/src/components/sidebar/CompositionsTab.tsx +52 -4
  30. package/src/components/sidebar/LeftSidebar.tsx +6 -0
  31. package/src/contexts/DomEditContext.tsx +3 -0
  32. package/src/contexts/FileManagerContext.tsx +9 -0
  33. package/src/hooks/useAppHotkeys.ts +1 -4
  34. package/src/hooks/useDomEditCommits.ts +126 -109
  35. package/src/hooks/useDomEditSession.ts +30 -41
  36. package/src/hooks/useFileManager.ts +52 -1
  37. package/src/hooks/useManifestPersistence.ts +72 -386
  38. package/src/hooks/usePanelLayout.ts +10 -3
  39. package/src/hooks/usePreviewInteraction.ts +7 -1
  40. package/src/hooks/useStudioUrlState.ts +188 -0
  41. package/src/player/components/Player.tsx +27 -4
  42. package/src/player/components/PlayerControls.test.ts +17 -0
  43. package/src/player/components/PlayerControls.tsx +90 -2
  44. package/src/player/components/useTimelineRangeSelection.ts +30 -3
  45. package/src/player/hooks/usePlaybackKeyboard.test.ts +174 -0
  46. package/src/player/hooks/usePlaybackKeyboard.ts +18 -15
  47. package/src/player/hooks/useTimelinePlayer.seek.test.ts +329 -0
  48. package/src/player/hooks/useTimelinePlayer.ts +76 -18
  49. package/src/player/hooks/useTimelineSyncCallbacks.ts +10 -4
  50. package/src/player/lib/playbackAdapter.test.ts +50 -0
  51. package/src/player/lib/playbackAdapter.ts +2 -2
  52. package/src/player/lib/playbackTypes.ts +1 -1
  53. package/src/player/lib/timelineDOM.ts +4 -2
  54. package/src/player/lib/timelineIframeHelpers.ts +63 -7
  55. package/src/player/store/playerStore.test.ts +105 -1
  56. package/src/player/store/playerStore.ts +12 -1
  57. package/src/utils/projectRouting.test.ts +15 -0
  58. package/src/utils/projectRouting.ts +46 -9
  59. package/src/utils/sourcePatcher.test.ts +285 -0
  60. package/src/utils/sourcePatcher.ts +76 -20
  61. package/src/utils/studioPreviewHelpers.test.ts +56 -0
  62. package/src/utils/studioPreviewHelpers.ts +51 -13
  63. package/src/utils/studioUiPreferences.test.ts +3 -0
  64. package/src/utils/studioUiPreferences.ts +4 -0
  65. package/src/utils/studioUrlState.test.ts +249 -0
  66. package/src/utils/studioUrlState.ts +135 -0
  67. package/dist/assets/index-DYqqzECY.js +0 -117
@@ -208,4 +208,289 @@ describe("applyPatchByTarget", () => {
208
208
  expect(patched).toContain(`<div class="headline clip" data-start="0"></div>`);
209
209
  expect(patched).toContain(`<div class="headline clip" data-start="2.5"></div>`);
210
210
  });
211
+
212
+ it("escapes JSON attribute values containing double-quotes and round-trips them", () => {
213
+ const html = `<div id="card" data-start="0"></div>`;
214
+ const motionJson = JSON.stringify({ preset: "fadeIn", start: 0, duration: 1.5 });
215
+
216
+ const patched = applyPatch(html, "card", {
217
+ type: "attribute",
218
+ property: "data-hf-studio-motion",
219
+ value: motionJson,
220
+ });
221
+
222
+ // The raw HTML must NOT contain unescaped quotes inside the attribute
223
+ expect(patched).not.toMatch(/data-hf-studio-motion="[^"]*"[^"]*"/);
224
+ // Entities should be present
225
+ expect(patched).toContain("&quot;");
226
+
227
+ // Reading the attribute back should return the original JSON
228
+ const readBack = readAttributeByTarget(patched, { id: "card" }, "data-hf-studio-motion");
229
+ expect(readBack).toBe(motionJson);
230
+ });
231
+
232
+ it("escapes and round-trips data-hf-studio-motion-original-transform with quotes", () => {
233
+ const html = `<div id="hero" data-start="0"></div>`;
234
+ const transform = `rotate(15deg) translate("50px", "100px")`;
235
+
236
+ const patched = applyPatchByTarget(
237
+ html,
238
+ { id: "hero" },
239
+ {
240
+ type: "attribute",
241
+ property: "data-hf-studio-motion-original-transform",
242
+ value: transform,
243
+ },
244
+ );
245
+
246
+ // No broken attribute boundary
247
+ expect(patched).not.toMatch(/data-hf-studio-motion-original-transform="[^"]*"[^"]*"/);
248
+
249
+ const readBack = readAttributeByTarget(
250
+ patched,
251
+ { id: "hero" },
252
+ "data-hf-studio-motion-original-transform",
253
+ );
254
+ expect(readBack).toBe(transform);
255
+ });
256
+
257
+ it("escapes ampersands and angle brackets in attribute values", () => {
258
+ const html = `<div id="el" data-start="0"></div>`;
259
+ const value = `a&b<c>d"e`;
260
+
261
+ const patched = applyPatch(html, "el", {
262
+ type: "attribute",
263
+ property: "data-custom",
264
+ value,
265
+ });
266
+
267
+ expect(patched).toContain("a&amp;b&lt;c&gt;d&quot;e");
268
+
269
+ const readBack = readAttributeByTarget(patched, { id: "el" }, "data-custom");
270
+ expect(readBack).toBe(value);
271
+ });
272
+
273
+ it("updates an already-escaped attribute value to a new escaped value", () => {
274
+ const html = `<div id="card" data-start="0"></div>`;
275
+ const first = JSON.stringify({ preset: "fadeIn" });
276
+ const second = JSON.stringify({ preset: "slideUp", easing: "ease-out" });
277
+
278
+ const patched1 = applyPatch(html, "card", {
279
+ type: "attribute",
280
+ property: "data-hf-studio-motion",
281
+ value: first,
282
+ });
283
+ const patched2 = applyPatch(patched1, "card", {
284
+ type: "attribute",
285
+ property: "data-hf-studio-motion",
286
+ value: second,
287
+ });
288
+
289
+ const readBack = readAttributeByTarget(patched2, { id: "card" }, "data-hf-studio-motion");
290
+ expect(readBack).toBe(second);
291
+ });
292
+ });
293
+
294
+ describe("motion attribute round-trip via sourcePatcher", () => {
295
+ it("round-trips data-hf-studio-motion JSON through patch and read", () => {
296
+ const html = `<div id="hero" style="position: absolute">Hero</div>`;
297
+ const motion = {
298
+ start: 0.5,
299
+ duration: 1,
300
+ ease: "power3.out",
301
+ from: { opacity: 0, y: 40 },
302
+ to: { opacity: 1, y: 0 },
303
+ };
304
+ const motionJson = JSON.stringify(motion);
305
+
306
+ const patched = applyPatchByTarget(
307
+ html,
308
+ { id: "hero" },
309
+ { type: "attribute", property: "data-hf-studio-motion", value: motionJson },
310
+ );
311
+
312
+ const readBack = readAttributeByTarget(patched, { id: "hero" }, "data-hf-studio-motion");
313
+ expect(readBack).toBeDefined();
314
+ expect(JSON.parse(readBack!)).toEqual(motion);
315
+ });
316
+
317
+ it("round-trips motion with customEase containing SVG path data", () => {
318
+ const html = `<div id="card" class="clip" data-start="0" data-duration="10">Card</div>`;
319
+ const motion = {
320
+ start: 0.25,
321
+ duration: 0.8,
322
+ ease: "studio-card-bounce",
323
+ customEase: { id: "studio-card-bounce", data: "M0,0 C0.18,0.9 0.32,1 1,1" },
324
+ from: { y: 44, autoAlpha: 0 },
325
+ to: { y: 0, autoAlpha: 1 },
326
+ };
327
+ const motionJson = JSON.stringify(motion);
328
+
329
+ const patched = applyPatchByTarget(
330
+ html,
331
+ { id: "card" },
332
+ { type: "attribute", property: "data-hf-studio-motion", value: motionJson },
333
+ );
334
+
335
+ const readBack = readAttributeByTarget(patched, { id: "card" }, "data-hf-studio-motion");
336
+ expect(readBack).toBeDefined();
337
+ expect(JSON.parse(readBack!)).toEqual(motion);
338
+ });
339
+
340
+ it("round-trips all four motion attributes (motion + three originals)", () => {
341
+ const html = `<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: visible">Hero</div>`;
342
+ const motion = {
343
+ start: 0,
344
+ duration: 0.6,
345
+ ease: "power2.out",
346
+ from: { autoAlpha: 0, y: 32 },
347
+ to: { autoAlpha: 1, y: 0 },
348
+ };
349
+
350
+ let result = html;
351
+ result = applyPatchByTarget(
352
+ result,
353
+ { id: "hero" },
354
+ { type: "attribute", property: "data-hf-studio-motion", value: JSON.stringify(motion) },
355
+ );
356
+ result = applyPatchByTarget(
357
+ result,
358
+ { id: "hero" },
359
+ {
360
+ type: "attribute",
361
+ property: "data-hf-studio-motion-original-transform",
362
+ value: "rotate(5deg)",
363
+ },
364
+ );
365
+ result = applyPatchByTarget(
366
+ result,
367
+ { id: "hero" },
368
+ { type: "attribute", property: "data-hf-studio-motion-original-opacity", value: "0.8" },
369
+ );
370
+ result = applyPatchByTarget(
371
+ result,
372
+ { id: "hero" },
373
+ {
374
+ type: "attribute",
375
+ property: "data-hf-studio-motion-original-visibility",
376
+ value: "visible",
377
+ },
378
+ );
379
+
380
+ expect(
381
+ JSON.parse(readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion")!),
382
+ ).toEqual(motion);
383
+ expect(
384
+ readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-transform"),
385
+ ).toBe("rotate(5deg)");
386
+ expect(
387
+ readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-opacity"),
388
+ ).toBe("0.8");
389
+ expect(
390
+ readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-visibility"),
391
+ ).toBe("visible");
392
+ });
393
+
394
+ it("removes all four motion attributes when clearing", () => {
395
+ const html = `<div id="hero" style="position: absolute">Hero</div>`;
396
+ const motion = {
397
+ start: 0,
398
+ duration: 1,
399
+ ease: "none",
400
+ from: { opacity: 0 },
401
+ to: { opacity: 1 },
402
+ };
403
+
404
+ let result = html;
405
+ result = applyPatchByTarget(
406
+ result,
407
+ { id: "hero" },
408
+ { type: "attribute", property: "data-hf-studio-motion", value: JSON.stringify(motion) },
409
+ );
410
+ result = applyPatchByTarget(
411
+ result,
412
+ { id: "hero" },
413
+ { type: "attribute", property: "data-hf-studio-motion-original-transform", value: "" },
414
+ );
415
+ result = applyPatchByTarget(
416
+ result,
417
+ { id: "hero" },
418
+ { type: "attribute", property: "data-hf-studio-motion-original-opacity", value: "1" },
419
+ );
420
+ result = applyPatchByTarget(
421
+ result,
422
+ { id: "hero" },
423
+ { type: "attribute", property: "data-hf-studio-motion-original-visibility", value: "" },
424
+ );
425
+
426
+ // Verify all four attributes exist
427
+ expect(readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion")).toBeDefined();
428
+ expect(
429
+ readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-transform"),
430
+ ).toBeDefined();
431
+ expect(
432
+ readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-opacity"),
433
+ ).toBeDefined();
434
+ expect(
435
+ readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-visibility"),
436
+ ).toBeDefined();
437
+
438
+ // Remove all four
439
+ result = applyPatchByTarget(
440
+ result,
441
+ { id: "hero" },
442
+ { type: "attribute", property: "data-hf-studio-motion", value: null },
443
+ );
444
+ result = applyPatchByTarget(
445
+ result,
446
+ { id: "hero" },
447
+ { type: "attribute", property: "data-hf-studio-motion-original-transform", value: null },
448
+ );
449
+ result = applyPatchByTarget(
450
+ result,
451
+ { id: "hero" },
452
+ { type: "attribute", property: "data-hf-studio-motion-original-opacity", value: null },
453
+ );
454
+ result = applyPatchByTarget(
455
+ result,
456
+ { id: "hero" },
457
+ { type: "attribute", property: "data-hf-studio-motion-original-visibility", value: null },
458
+ );
459
+
460
+ expect(readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion")).toBeUndefined();
461
+ expect(
462
+ readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-transform"),
463
+ ).toBeUndefined();
464
+ expect(
465
+ readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-opacity"),
466
+ ).toBeUndefined();
467
+ expect(
468
+ readAttributeByTarget(result, { id: "hero" }, "data-hf-studio-motion-original-visibility"),
469
+ ).toBeUndefined();
470
+ });
471
+
472
+ it("round-trips motion via selector when element has no id", () => {
473
+ const html = `<div class="headline clip" style="position: absolute">Title</div>`;
474
+ const motion = {
475
+ start: 0.3,
476
+ duration: 0.5,
477
+ ease: "sine.out",
478
+ from: { scale: 0.88, autoAlpha: 0 },
479
+ to: { scale: 1, autoAlpha: 1 },
480
+ };
481
+
482
+ const patched = applyPatchByTarget(
483
+ html,
484
+ { selector: ".headline" },
485
+ { type: "attribute", property: "data-hf-studio-motion", value: JSON.stringify(motion) },
486
+ );
487
+
488
+ const readBack = readAttributeByTarget(
489
+ patched,
490
+ { selector: ".headline" },
491
+ "data-hf-studio-motion",
492
+ );
493
+ expect(readBack).toBeDefined();
494
+ expect(JSON.parse(readBack!)).toEqual(motion);
495
+ });
211
496
  });
@@ -11,6 +11,24 @@ function escapeStyleAttributeValue(value: string, quote: string): string {
11
11
  return quote === '"' ? value.replace(/"/g, "&quot;") : value.replace(/'/g, "&#39;");
12
12
  }
13
13
 
14
+ /** Escape a string for safe use inside a double-quoted HTML attribute. */
15
+ function escapeHtmlAttribute(value: string): string {
16
+ return value
17
+ .replace(/&/g, "&amp;")
18
+ .replace(/"/g, "&quot;")
19
+ .replace(/</g, "&lt;")
20
+ .replace(/>/g, "&gt;");
21
+ }
22
+
23
+ /** Reverse escapeHtmlAttribute so callers get the original value. */
24
+ function unescapeHtmlAttribute(value: string): string {
25
+ return value
26
+ .replace(/&quot;/g, '"')
27
+ .replace(/&lt;/g, "<")
28
+ .replace(/&gt;/g, ">")
29
+ .replace(/&amp;/g, "&");
30
+ }
31
+
14
32
  function splitInlineStyleDeclarations(style: string): string[] {
15
33
  const declarations: string[] = [];
16
34
  let current = "";
@@ -71,7 +89,7 @@ function splitInlineStyleDeclarations(style: string): string[] {
71
89
  export interface PatchOperation {
72
90
  type: "inline-style" | "attribute" | "text-content";
73
91
  property: string;
74
- value: string;
92
+ value: string | null;
75
93
  }
76
94
 
77
95
  export interface PatchTarget {
@@ -133,7 +151,12 @@ export function resolveSourceFile(
133
151
  /**
134
152
  * Apply a style property change to an element's inline style in the HTML source.
135
153
  */
136
- function patchInlineStyle(html: string, elementId: string, prop: string, value: string): string {
154
+ function patchInlineStyle(
155
+ html: string,
156
+ elementId: string,
157
+ prop: string,
158
+ value: string | null,
159
+ ): string {
137
160
  // Find the element tag with this id
138
161
  const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
139
162
  const match = idPattern.exec(html);
@@ -143,7 +166,12 @@ function patchInlineStyle(html: string, elementId: string, prop: string, value:
143
166
  return patchInlineStyleInTag(html, tag, prop, value);
144
167
  }
145
168
 
146
- function patchInlineStyleInTag(html: string, tag: string, prop: string, value: string): string {
169
+ function patchInlineStyleInTag(
170
+ html: string,
171
+ tag: string,
172
+ prop: string,
173
+ value: string | null,
174
+ ): string {
147
175
  if (!tag) return html;
148
176
 
149
177
  // Check if there's an existing style attribute
@@ -160,16 +188,22 @@ function patchInlineStyleInTag(html: string, tag: string, prop: string, value: s
160
188
  const val = part.slice(colon + 1).trim();
161
189
  if (key) props.set(key, val);
162
190
  }
163
- // Update/add the property
164
- props.set(prop, value);
165
- // Rebuild style string
191
+ // Update/add or remove the property
192
+ if (value === null) {
193
+ props.delete(prop);
194
+ } else {
195
+ props.set(prop, value);
196
+ }
197
+ // Rebuild style string; keep style="" if empty (harmless)
166
198
  const newStyle = Array.from(props.entries())
167
199
  .map(([k, v]) => `${k}: ${escapeStyleAttributeValue(v, quote)}`)
168
200
  .join("; ");
169
201
  const newTag = tag.replace(styleMatch[0], `style=${quote}${newStyle}${quote}`);
170
202
  return html.replace(tag, newTag);
171
203
  } else {
172
- // No existing style — add one
204
+ // No existing style attribute
205
+ if (value === null) return html; // nothing to remove
206
+ // Add one
173
207
  const newTag =
174
208
  tag.replace(/>$/, "") + ` style="${prop}: ${escapeStyleAttributeValue(value, '"')}"`;
175
209
  return html.replace(tag, newTag);
@@ -180,7 +214,7 @@ function patchInlineStyleByTarget(
180
214
  html: string,
181
215
  target: PatchTarget,
182
216
  prop: string,
183
- value: string,
217
+ value: string | null,
184
218
  ): string {
185
219
  const match = findTagByTarget(html, target);
186
220
  if (!match) return html;
@@ -198,7 +232,7 @@ function replaceTagAtMatch(html: string, match: TagMatch, newTag: string): strin
198
232
  return `${html.slice(0, match.start)}${newTag}${html.slice(match.end)}`;
199
233
  }
200
234
 
201
- function findTagByTarget(html: string, target: PatchTarget): TagMatch | null {
235
+ export function findTagByTarget(html: string, target: PatchTarget): TagMatch | null {
202
236
  if (target.id) {
203
237
  const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(target.id)}\\2[^>]*)>`, "i");
204
238
  const match = idPattern.exec(html);
@@ -265,7 +299,7 @@ export function readAttributeByTarget(
265
299
 
266
300
  const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
267
301
  const valueMatch = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`).exec(match.tag);
268
- return valueMatch?.[2];
302
+ return valueMatch?.[2] != null ? unescapeHtmlAttribute(valueMatch[2]) : undefined;
269
303
  }
270
304
 
271
305
  export function readTagSnippetByTarget(html: string, target: PatchTarget): string | undefined {
@@ -277,43 +311,65 @@ function patchAttributeByTarget(
277
311
  html: string,
278
312
  target: PatchTarget,
279
313
  attr: string,
280
- value: string,
314
+ value: string | null,
281
315
  ): string {
282
316
  const match = findTagByTarget(html, target);
283
317
  if (!match) return html;
284
318
 
285
319
  const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
286
- const attrPattern = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`);
320
+ const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`);
287
321
  const tag = match.tag;
288
322
 
323
+ if (value === null) {
324
+ // Remove the attribute if present
325
+ if (!attrPattern.test(tag)) return html;
326
+ const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}=(["'])[^"']*\\1`);
327
+ const newTag = tag.replace(removePattern, "");
328
+ return replaceTagAtMatch(html, match, newTag);
329
+ }
330
+
331
+ const escaped = escapeHtmlAttribute(value);
289
332
  if (attrPattern.test(tag)) {
290
- const newTag = tag.replace(attrPattern, `${fullAttr}="${value}"`);
333
+ const newTag = tag.replace(attrPattern, `${fullAttr}="${escaped}"`);
291
334
  return replaceTagAtMatch(html, match, newTag);
292
335
  }
293
336
 
294
- const newTag = tag + ` ${fullAttr}="${value}"`;
337
+ const newTag = tag + ` ${fullAttr}="${escaped}"`;
295
338
  return replaceTagAtMatch(html, match, newTag);
296
339
  }
297
340
 
298
341
  /**
299
342
  * Apply an attribute change to an element in the HTML source.
300
343
  */
301
- function patchAttribute(html: string, elementId: string, attr: string, value: string): string {
344
+ function patchAttribute(
345
+ html: string,
346
+ elementId: string,
347
+ attr: string,
348
+ value: string | null,
349
+ ): string {
302
350
  const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
303
351
  const match = idPattern.exec(html);
304
352
  if (!match) return html;
305
353
 
306
354
  const tag = match[1];
307
355
  const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
308
- const attrPattern = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`);
356
+ const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`);
357
+
358
+ if (value === null) {
359
+ if (!attrPattern.test(tag)) return html;
360
+ const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}=(["'])[^"']*\\1`);
361
+ const newTag = tag.replace(removePattern, "");
362
+ return html.replace(tag, newTag);
363
+ }
309
364
 
365
+ const escaped = escapeHtmlAttribute(value);
310
366
  if (attrPattern.test(tag)) {
311
367
  // Update existing attribute
312
- const newTag = tag.replace(attrPattern, `${fullAttr}="${value}"`);
368
+ const newTag = tag.replace(attrPattern, `${fullAttr}="${escaped}"`);
313
369
  return html.replace(tag, newTag);
314
370
  } else {
315
371
  // Add new attribute
316
- const newTag = tag + ` ${fullAttr}="${value}"`;
372
+ const newTag = tag + ` ${fullAttr}="${escaped}"`;
317
373
  return html.replace(tag, newTag);
318
374
  }
319
375
  }
@@ -381,7 +437,7 @@ export function applyPatch(html: string, elementId: string, op: PatchOperation):
381
437
  case "attribute":
382
438
  return patchAttribute(html, elementId, op.property, op.value);
383
439
  case "text-content":
384
- return patchTextContent(html, elementId, op.value);
440
+ return op.value !== null ? patchTextContent(html, elementId, op.value) : html;
385
441
  default:
386
442
  return html;
387
443
  }
@@ -401,7 +457,7 @@ export function applyPatchByTarget(html: string, target: PatchTarget, op: PatchO
401
457
  case "attribute":
402
458
  return patchAttributeByTarget(html, target, op.property, op.value);
403
459
  case "text-content":
404
- return patchTextContentByTarget(html, target, op.value);
460
+ return op.value !== null ? patchTextContentByTarget(html, target, op.value) : html;
405
461
  default:
406
462
  return html;
407
463
  }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { pauseStudioPreviewPlayback } from "./studioPreviewHelpers";
3
+
4
+ describe("pauseStudioPreviewPlayback", () => {
5
+ it("pauses through __player without pausing sibling timelines directly", () => {
6
+ const playerPause = vi.fn();
7
+ const timelinePause = vi.fn();
8
+ const siblingPause = vi.fn();
9
+
10
+ const iframe = {
11
+ contentWindow: {
12
+ __player: {
13
+ getTime: () => 4.25,
14
+ pause: playerPause,
15
+ },
16
+ __timeline: {
17
+ time: () => 4.25,
18
+ pause: timelinePause,
19
+ },
20
+ __timelines: {
21
+ root: {
22
+ pause: siblingPause,
23
+ },
24
+ },
25
+ },
26
+ } as unknown as HTMLIFrameElement;
27
+
28
+ expect(pauseStudioPreviewPlayback(iframe)).toBe(4.25);
29
+ expect(playerPause).toHaveBeenCalledTimes(1);
30
+ expect(timelinePause).not.toHaveBeenCalled();
31
+ expect(siblingPause).not.toHaveBeenCalled();
32
+ });
33
+
34
+ it("falls back to pausing timelines directly when __player is unavailable", () => {
35
+ const timelinePause = vi.fn();
36
+ const siblingPause = vi.fn();
37
+
38
+ const iframe = {
39
+ contentWindow: {
40
+ __timeline: {
41
+ time: () => 2.5,
42
+ pause: timelinePause,
43
+ },
44
+ __timelines: {
45
+ root: {
46
+ pause: siblingPause,
47
+ },
48
+ },
49
+ },
50
+ } as unknown as HTMLIFrameElement;
51
+
52
+ expect(pauseStudioPreviewPlayback(iframe)).toBe(2.5);
53
+ expect(timelinePause).toHaveBeenCalledTimes(1);
54
+ expect(siblingPause).toHaveBeenCalledTimes(1);
55
+ });
56
+ });
@@ -1,5 +1,9 @@
1
1
  import type { DomEditViewport, DomEditSelection } from "../components/editor/domEditing";
2
2
  import { resolveVisualDomEditSelectionTarget } from "../components/editor/domEditing";
3
+ import {
4
+ getDomLayerPatchTarget,
5
+ isElementComputedVisible,
6
+ } from "../components/editor/domEditingElement";
3
7
  import { usePlayerStore, liveTime } from "../player";
4
8
  import { getEventTargetElement } from "./studioHelpers";
5
9
 
@@ -56,6 +60,28 @@ export function getPreviewLocalPointer(
56
60
  return resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
57
61
  }
58
62
 
63
+ const POINTER_EVENTS_OVERRIDE_ID = "__hf_studio_pointer_events_override__";
64
+
65
+ function forcePointerEventsAuto(doc: Document): HTMLStyleElement | null {
66
+ try {
67
+ const style = doc.createElement("style");
68
+ style.id = POINTER_EVENTS_OVERRIDE_ID;
69
+ style.textContent = "* { pointer-events: auto !important; }";
70
+ doc.head.appendChild(style);
71
+ return style;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function removePointerEventsOverride(style: HTMLStyleElement | null): void {
78
+ try {
79
+ style?.remove();
80
+ } catch {
81
+ // cross-origin or detached doc
82
+ }
83
+ }
84
+
59
85
  export function getPreviewTargetFromPointer(
60
86
  iframe: HTMLIFrameElement,
61
87
  clientX: number,
@@ -75,17 +101,25 @@ export function getPreviewTargetFromPointer(
75
101
  const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
76
102
  if (!localPointer) return null;
77
103
 
78
- if (typeof doc.elementsFromPoint === "function") {
79
- const visualTarget = resolveVisualDomEditSelectionTarget(
80
- doc.elementsFromPoint(localPointer.x, localPointer.y),
81
- {
82
- activeCompositionPath,
83
- },
84
- );
85
- if (visualTarget) return visualTarget;
86
- }
104
+ const overrideStyle = forcePointerEventsAuto(doc);
105
+ try {
106
+ if (typeof doc.elementsFromPoint === "function") {
107
+ const visualTarget = resolveVisualDomEditSelectionTarget(
108
+ doc.elementsFromPoint(localPointer.x, localPointer.y),
109
+ {
110
+ activeCompositionPath,
111
+ },
112
+ );
113
+ if (visualTarget) return visualTarget;
114
+ }
87
115
 
88
- return getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
116
+ const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
117
+ if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null;
118
+ if (!isElementComputedVisible(fallback)) return null;
119
+ return fallback;
120
+ } finally {
121
+ removePointerEventsOverride(overrideStyle);
122
+ }
89
123
  }
90
124
 
91
125
  export function buildRasterClickSelectionContext(
@@ -160,11 +194,15 @@ export function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): nu
160
194
  if (!win) return null;
161
195
 
162
196
  try {
163
- let pausedTime: number | null = null;
164
197
  const player = objectLike(Reflect.get(win, "__player"));
165
- pausedTime = readPlaybackTime(player, "getTime") ?? pausedTime;
166
- callPlaybackMethod(player, "pause");
198
+ const playerPausedTime = readPlaybackTime(player, "getTime");
199
+ const playerPause = player ? Reflect.get(player, "pause") : null;
200
+ if (typeof playerPause === "function") {
201
+ callPlaybackMethod(player, "pause");
202
+ return playerPausedTime;
203
+ }
167
204
 
205
+ let pausedTime: number | null = null;
168
206
  const timeline = objectLike(Reflect.get(win, "__timeline"));
169
207
  pausedTime = pausedTime ?? readPlaybackTime(timeline, "time");
170
208
  callPlaybackMethod(timeline, "pause");
@@ -21,11 +21,13 @@ describe("studio UI preferences", () => {
21
21
 
22
22
  writeStudioUiPreferences({ timelineVisible: false }, storage);
23
23
  writeStudioUiPreferences({ playbackRate: 1.5 }, storage);
24
+ writeStudioUiPreferences({ audioMuted: true }, storage);
24
25
  writeStudioUiPreferences({ previewZoom: { zoomPercent: 160, panX: -20, panY: 12 } }, storage);
25
26
 
26
27
  expect(readStudioUiPreferences(storage)).toEqual({
27
28
  timelineVisible: false,
28
29
  playbackRate: 1.5,
30
+ audioMuted: true,
29
31
  previewZoom: { zoomPercent: 160, panX: -20, panY: 12 },
30
32
  });
31
33
  });
@@ -38,6 +40,7 @@ describe("studio UI preferences", () => {
38
40
  leftCollapsed: "yes",
39
41
  timelineVisible: true,
40
42
  playbackRate: Number.NaN,
43
+ audioMuted: "false",
41
44
  previewZoom: { zoomPercent: 150, panX: 0, panY: "bad" },
42
45
  }),
43
46
  );
@@ -8,6 +8,7 @@ export interface StudioUiPreferences {
8
8
  leftCollapsed?: boolean;
9
9
  timelineVisible?: boolean;
10
10
  playbackRate?: number;
11
+ audioMuted?: boolean;
11
12
  previewZoom?: StoredPreviewZoomState;
12
13
  }
13
14
 
@@ -44,6 +45,9 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
44
45
  if (typeof parsed.playbackRate === "number" && Number.isFinite(parsed.playbackRate)) {
45
46
  preferences.playbackRate = parsed.playbackRate;
46
47
  }
48
+ if (typeof parsed.audioMuted === "boolean") {
49
+ preferences.audioMuted = parsed.audioMuted;
50
+ }
47
51
  if (isRecord(parsed.previewZoom)) {
48
52
  const { zoomPercent, panX, panY } = parsed.previewZoom;
49
53
  if (