@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.
- package/dist/assets/{hyperframes-player-T-ME1rqL.js → hyperframes-player-D0Yi3xMP.js} +2 -2
- package/dist/assets/index-BSe0Kibk.js +115 -0
- package/dist/assets/{index-Bne9FFeo.css → index-Ckqo37Co.css} +1 -1
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +59 -41
- package/src/components/StudioGlobalDragOverlay.tsx +26 -0
- package/src/components/StudioLeftSidebar.tsx +16 -2
- package/src/components/StudioRightPanel.tsx +15 -4
- package/src/components/editor/DomEditOverlay.test.ts +1 -0
- package/src/components/editor/DomEditOverlay.tsx +2 -1
- package/src/components/editor/MotionPanel.tsx +8 -8
- package/src/components/editor/PropertyPanel.tsx +27 -36
- package/src/components/editor/SourceEditor.tsx +14 -0
- package/src/components/editor/domEditingElement.ts +1 -0
- package/src/components/editor/manualEdits.test.ts +39 -466
- package/src/components/editor/manualEdits.ts +8 -168
- package/src/components/editor/manualEditsDom.ts +417 -1
- package/src/components/editor/manualEditsParsing.ts +2 -240
- package/src/components/editor/manualEditsTypes.ts +1 -40
- package/src/components/editor/studioMotion.ts +96 -0
- package/src/components/editor/studioMotionOps.test.ts +445 -0
- package/src/components/editor/studioMotionOps.ts +78 -4
- package/src/components/editor/useDomEditOverlayGestures.ts +25 -8
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/renders/RenderQueue.tsx +20 -6
- package/src/components/renders/renderSettings.ts +38 -0
- package/src/components/renders/useRenderQueue.ts +11 -1
- package/src/components/sidebar/CompositionsTab.tsx +52 -4
- package/src/components/sidebar/LeftSidebar.tsx +6 -0
- package/src/contexts/DomEditContext.tsx +3 -0
- package/src/contexts/FileManagerContext.tsx +9 -0
- package/src/hooks/useAppHotkeys.ts +1 -4
- package/src/hooks/useDomEditCommits.ts +126 -109
- package/src/hooks/useDomEditSession.ts +30 -41
- package/src/hooks/useFileManager.ts +52 -1
- package/src/hooks/useManifestPersistence.ts +72 -386
- package/src/hooks/usePanelLayout.ts +10 -3
- package/src/hooks/usePreviewInteraction.ts +7 -1
- package/src/hooks/useStudioUrlState.ts +188 -0
- package/src/player/components/Player.tsx +27 -4
- package/src/player/components/PlayerControls.test.ts +17 -0
- package/src/player/components/PlayerControls.tsx +90 -2
- package/src/player/components/useTimelineRangeSelection.ts +30 -3
- package/src/player/hooks/usePlaybackKeyboard.test.ts +174 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +18 -15
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +329 -0
- package/src/player/hooks/useTimelinePlayer.ts +76 -18
- package/src/player/hooks/useTimelineSyncCallbacks.ts +10 -4
- package/src/player/lib/playbackAdapter.test.ts +50 -0
- package/src/player/lib/playbackAdapter.ts +2 -2
- package/src/player/lib/playbackTypes.ts +1 -1
- package/src/player/lib/timelineDOM.ts +4 -2
- package/src/player/lib/timelineIframeHelpers.ts +63 -7
- package/src/player/store/playerStore.test.ts +105 -1
- package/src/player/store/playerStore.ts +12 -1
- package/src/utils/projectRouting.test.ts +15 -0
- package/src/utils/projectRouting.ts +46 -9
- package/src/utils/sourcePatcher.test.ts +285 -0
- package/src/utils/sourcePatcher.ts +76 -20
- package/src/utils/studioPreviewHelpers.test.ts +56 -0
- package/src/utils/studioPreviewHelpers.ts +51 -13
- package/src/utils/studioUiPreferences.test.ts +3 -0
- package/src/utils/studioUiPreferences.ts +4 -0
- package/src/utils/studioUrlState.test.ts +249 -0
- package/src/utils/studioUrlState.ts +135 -0
- 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(""");
|
|
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&b<c>d"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, """) : value.replace(/'/g, "'");
|
|
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, "&")
|
|
18
|
+
.replace(/"/g, """)
|
|
19
|
+
.replace(/</g, "<")
|
|
20
|
+
.replace(/>/g, ">");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Reverse escapeHtmlAttribute so callers get the original value. */
|
|
24
|
+
function unescapeHtmlAttribute(value: string): string {
|
|
25
|
+
return value
|
|
26
|
+
.replace(/"/g, '"')
|
|
27
|
+
.replace(/</g, "<")
|
|
28
|
+
.replace(/>/g, ">")
|
|
29
|
+
.replace(/&/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(
|
|
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(
|
|
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
|
-
|
|
165
|
-
|
|
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
|
|
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}="${
|
|
333
|
+
const newTag = tag.replace(attrPattern, `${fullAttr}="${escaped}"`);
|
|
291
334
|
return replaceTagAtMatch(html, match, newTag);
|
|
292
335
|
}
|
|
293
336
|
|
|
294
|
-
const newTag = tag + ` ${fullAttr}="${
|
|
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(
|
|
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}="${
|
|
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}="${
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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 (
|