@hyperframes/studio 0.8.108 → 0.8.109

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 (118) hide show
  1. package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-CJYtikAy.js} +1 -1
  2. package/dist/assets/{index-ncVJ5IVu.css → index-BQZBDqMs.css} +1 -1
  3. package/dist/assets/{index-CPe7d_wO.js → index-CMSgrT2F.js} +1 -1
  4. package/dist/assets/{index-C842tcVa.js → index-CYP6dMd7.js} +208 -206
  5. package/dist/assets/{index-C5dyUHwk.js → index-HTFVHUJ4.js} +1 -1
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
  9. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  10. package/dist/index.d.ts +44 -4
  11. package/dist/index.html +2 -2
  12. package/dist/index.js +7021 -5669
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/dist/styles.css +1 -1
  16. package/dist/ui.js +1 -1
  17. package/package.json +8 -8
  18. package/src/App.tsx +7 -11
  19. package/src/components/EditorShell.tsx +4 -0
  20. package/src/components/LinkedSelectionToggle.tsx +73 -0
  21. package/src/components/TimelineToolbar.test.tsx +31 -0
  22. package/src/components/TimelineToolbar.tsx +2 -0
  23. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  24. package/src/components/editor/InlineTextCaret.tsx +211 -0
  25. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  26. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  27. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  28. package/src/components/editor/domEditInlineText.ts +6 -4
  29. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  30. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  31. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  32. package/src/components/editor/mediaAudioEdits.test.ts +20 -4
  33. package/src/components/editor/mediaAudioEdits.ts +27 -14
  34. package/src/components/editor/mediaLinkEdits.test.ts +321 -0
  35. package/src/components/editor/mediaLinkEdits.ts +282 -0
  36. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
  37. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  38. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  39. package/src/components/editor/useVolumeAutomation.ts +29 -7
  40. package/src/components/nle/useTimelineEditCallbacks.ts +8 -0
  41. package/src/components/ui/Menu.test.tsx +3 -22
  42. package/src/components/ui/Menu.tsx +2 -2
  43. package/src/components/ui/Popover.tsx +2 -2
  44. package/src/components/ui/Select.tsx +2 -2
  45. package/src/components/ui/Tooltip.tsx +2 -2
  46. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  47. package/src/components/ui/valueControls.test.tsx +2 -21
  48. package/src/contexts/TimelineEditContext.tsx +3 -0
  49. package/src/hooks/appHotkeysDispatch.ts +2 -1
  50. package/src/hooks/linkShortcuts.test.ts +70 -0
  51. package/src/hooks/linkShortcuts.ts +41 -0
  52. package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
  53. package/src/hooks/timelineLinkEditPlan.ts +123 -0
  54. package/src/hooks/timelineMoveAdapter.ts +13 -0
  55. package/src/hooks/timelineTimingSync.test.ts +59 -0
  56. package/src/hooks/timelineTimingSync.ts +22 -3
  57. package/src/hooks/useAppHotkeys.ts +9 -0
  58. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  59. package/src/hooks/useGsapScriptCommits.ts +23 -3
  60. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  61. package/src/hooks/usePreviewPersistence.ts +15 -0
  62. package/src/hooks/useRazorSplit.test.tsx +48 -0
  63. package/src/hooks/useRazorSplit.ts +12 -1
  64. package/src/hooks/useRenderClipContent.test.ts +6 -4
  65. package/src/hooks/useRenderClipContent.ts +6 -3
  66. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  67. package/src/hooks/useTimelineEditGuard.ts +54 -0
  68. package/src/hooks/useTimelineEditing.ts +36 -55
  69. package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
  70. package/src/hooks/useTimelineLinkEditing.ts +153 -0
  71. package/src/index.ts +1 -0
  72. package/src/player/components/AudioWaveform.test.tsx +30 -3
  73. package/src/player/components/AudioWaveform.tsx +6 -7
  74. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  75. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  76. package/src/player/components/ClipContextMenu.tsx +115 -90
  77. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  78. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  79. package/src/player/components/TimelineClip.test.tsx +17 -0
  80. package/src/player/components/TimelineClip.tsx +6 -0
  81. package/src/player/components/audioClipLink.test.ts +228 -31
  82. package/src/player/components/audioClipLink.ts +72 -25
  83. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
  84. package/src/player/components/clipMenuAudioItems.test.tsx +52 -9
  85. package/src/player/components/clipMenuAudioItems.tsx +31 -23
  86. package/src/player/components/clipMenuDuck.ts +5 -0
  87. package/src/player/components/clipMenuLinkItems.test.ts +90 -0
  88. package/src/player/components/clipMenuLinkItems.tsx +121 -0
  89. package/src/player/components/clipMenuToolItems.tsx +1 -3
  90. package/src/player/components/clipSync.test.ts +67 -0
  91. package/src/player/components/clipSync.ts +80 -0
  92. package/src/player/components/linkLabelColor.test.ts +20 -0
  93. package/src/player/components/linkLabelColor.ts +24 -0
  94. package/src/player/components/timelineCallbacks.ts +11 -0
  95. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  96. package/src/player/components/timelineClipDragTypes.ts +2 -0
  97. package/src/player/components/timelineClipGestureHandlers.ts +4 -1
  98. package/src/player/components/timelineLinkSelection.ts +23 -0
  99. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  100. package/src/player/components/useTimelineClipDrag.ts +21 -0
  101. package/src/player/components/useTimelineProviderState.tsx +3 -0
  102. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  103. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  104. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  105. package/src/player/lib/timelineDOM.test.ts +31 -0
  106. package/src/player/lib/timelineDOM.ts +13 -0
  107. package/src/player/store/timelineElement.ts +12 -0
  108. package/src/styles/components.css +27 -11
  109. package/src/styles/theme.css +0 -1
  110. package/src/utils/gsapSoftReload.test.ts +238 -2
  111. package/src/utils/gsapSoftReload.ts +89 -74
  112. package/src/utils/gsapUndoRestore.test.ts +4 -3
  113. package/src/utils/gsapUndoRestore.ts +15 -0
  114. package/src/utils/linkedClipPreferences.ts +34 -0
  115. package/src/utils/softReloadTargets.ts +128 -0
  116. package/src/utils/studioUiPreferences.ts +8 -0
  117. package/dist/chunk-AOWVEVEU.js.map +0 -1
  118. package/dist/chunk-NCUBAVGK.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
2
- import { findAuthoredElement, parseSavedSource } from "./authoredSource";
2
+ import { findAuthoredElement } from "./authoredSource";
3
3
  import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
4
+ import { authoringFile, collectResetTargets, compositionFile, fileDocs } from "./softReloadTargets";
4
5
 
5
6
  type IframeWindow = Window & {
6
7
  __timelines?: Record<string, { kill?: () => void; pause?: () => void }>;
@@ -131,15 +132,28 @@ function verifyTimelinesPopulated(win: IframeWindow, targetKeys: string[]): bool
131
132
  return Object.keys(timelines).filter((k) => k !== "__proxied").length > 0;
132
133
  }
133
134
 
134
- // GSAP masks a folded CSS translate/rotate/scale with `none`; a fresh load has only what the file authors.
135
- function restoreAuthoredTransforms(
135
+ // GSAP masks a folded CSS translate/rotate/scale with `none`, and a tween writes what it animates
136
+ // inline; a fresh load has only what the file authors. Opacity has its own restore.
137
+ function restoreAuthoredStyle(
136
138
  style: CSSStyleDeclaration,
137
139
  authored: CSSStyleDeclaration | null,
140
+ tweened: Set<string>,
138
141
  ) {
139
142
  style.transform = authored?.transform ?? "";
140
143
  if (!authored) return;
141
- for (const prop of ["translate", "rotate", "scale"]) {
142
- style.setProperty(prop, authored.getPropertyValue(prop));
144
+ for (const prop of ["translate", "rotate", "scale", ...tweened]) {
145
+ if (prop === "opacity") continue;
146
+ style.setProperty(prop, authored.getPropertyValue(prop), authored.getPropertyPriority(prop));
147
+ }
148
+ }
149
+
150
+ function runSuppressed(win: IframeWindow, reload: () => void): boolean {
151
+ try {
152
+ if (win.__hfSuppressSceneMutations) win.__hfSuppressSceneMutations(reload);
153
+ else reload();
154
+ return true;
155
+ } catch {
156
+ return false;
143
157
  }
144
158
  }
145
159
 
@@ -196,6 +210,8 @@ export interface SoftReloadOptions {
196
210
  /** After-write file HTML — the primary source for the authored opacity and transform restore. */
197
211
  authoredHtml?: string;
198
212
  reparse?: Element[];
213
+ /** Other composition files a reset element is written in, by path; null when one could not be read. */
214
+ nestedFiles?: Map<string, string> | null;
199
215
  }
200
216
 
201
217
  /**
@@ -247,6 +263,12 @@ export function applySoftReloadFinalization(
247
263
  }
248
264
  }
249
265
 
266
+ function timelineKeys(scriptText: string): string[] {
267
+ return [...scriptText.matchAll(/__timelines\s*\[\s*["'`]([^"'`]+)["'`]\s*\]/g)]
268
+ .map((m) => m[1]!)
269
+ .filter((key) => key !== "__proxied");
270
+ }
271
+
250
272
  export function applySoftReload(
251
273
  iframe: HTMLIFrameElement | null,
252
274
  scriptText: string,
@@ -259,6 +281,8 @@ export function applySoftReload(
259
281
  const doc = iframe.contentDocument;
260
282
  if (!win || !doc) return "cannot-soft-reload";
261
283
  if (!win.gsap || !win.__hfForceTimelineRebind) return "cannot-soft-reload";
284
+ // Without the file an element is written in, only a full load restores it.
285
+ if (options.nestedFiles === null) return "cannot-soft-reload";
262
286
 
263
287
  // Which composition(s) does this script rebuild? A soft reload re-runs ONE
264
288
  // composition's GSAP script, which re-registers its own window.__timelines[key].
@@ -267,9 +291,7 @@ export function applySoftReload(
267
291
  // of the global timeline — so tearing down ALL of them (or the global timeline's
268
292
  // children) and re-running a single script wipes every OTHER composition,
269
293
  // reverting its edits. Scope the teardown to the keys THIS script re-registers.
270
- const targetKeys = [...scriptText.matchAll(/__timelines\s*\[\s*["'`]([^"'`]+)["'`]\s*\]/g)]
271
- .map((m) => m[1]!)
272
- .filter((key) => key !== "__proxied");
294
+ const targetKeys = timelineKeys(scriptText);
273
295
  if (targetKeys.length === 0) return "cannot-soft-reload"; // can't scope safely → full reload
274
296
  const gsapScripts = findGsapScriptElements(doc);
275
297
  if (gsapScripts.length === 0) return "cannot-soft-reload";
@@ -302,6 +324,7 @@ export function applySoftReload(
302
324
  // once the plugin loads; the alternative (returning false) would trigger a
303
325
  // full iframe reload that destroys the very WebGL context we're preserving.
304
326
  let deferredToAsync = false;
327
+ let builtDom = false;
305
328
 
306
329
  // Authored-opacity resolution for the restore loop below. Three-state:
307
330
  // "0.98" — the element's authored inline opacity
@@ -311,16 +334,10 @@ export function applySoftReload(
311
334
  // The just-written file (`authoredHtml`) is the current truth; the runtime's
312
335
  // parse-time stamp (data-hf-authored-opacity, installAuthoredOpacityCapture)
313
336
  // covers elements the file lookup can't resolve. Parsed lazily, at most once.
314
- let authoredDoc: Document | null | undefined;
337
+ const docFor = fileDocs(compositionFile(doc, targetKeys), authoredHtml, options.nestedFiles);
315
338
  const findAuthoredStyle = (el: HTMLElement): CSSStyleDeclaration | null => {
316
- if (authoredDoc === undefined) {
317
- try {
318
- authoredDoc = authoredHtml ? parseSavedSource(authoredHtml) : null;
319
- } catch {
320
- authoredDoc = null;
321
- }
322
- }
323
- const source = authoredDoc ? findAuthoredElement(authoredDoc, el) : null;
339
+ const fileDoc = docFor(authoringFile(el));
340
+ const source = fileDoc ? findAuthoredElement(fileDoc, el) : null;
324
341
  // The parsed file lives in this realm, so instanceof holds here, unlike for the iframe nodes below.
325
342
  return source instanceof HTMLElement || source instanceof SVGElement ? source.style : null;
326
343
  };
@@ -330,29 +347,14 @@ export function applySoftReload(
330
347
  // fallow-ignore-next-line complexity
331
348
  const doReload = () => {
332
349
  const timelines = win.__timelines;
333
- const allTargets: Element[] = [...reparse];
350
+ const targets = collectResetTargets(win, doc, targetKeys, reparse);
334
351
 
335
352
  // Kill ONLY the target composition's timeline(s) — leaving every other
336
353
  // composition's timeline (and its children on the global timeline) intact.
337
354
  if (timelines) {
338
355
  for (const key of targetKeys) {
339
- const tl = timelines[key] as
340
- | {
341
- kill?: () => void;
342
- clear?: () => void;
343
- getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
344
- }
345
- | undefined;
356
+ const tl = timelines[key] as { kill?: () => void; clear?: () => void } | undefined;
346
357
  if (!tl) continue;
347
- if (tl.getChildren) {
348
- try {
349
- for (const child of tl.getChildren(true)) {
350
- if (typeof child.targets === "function") {
351
- for (const t of child.targets()) allTargets.push(t);
352
- }
353
- }
354
- } catch {}
355
- }
356
358
  try {
357
359
  // kill() keeps the children, and the finalize seek renders this timeline until the rebind swaps it.
358
360
  tl.clear?.();
@@ -362,29 +364,11 @@ export function applySoftReload(
362
364
  }
363
365
  }
364
366
 
365
- // Also reset elements carrying a GSAP-applied inline `transform` that the
366
- // timeline-children sweep above missed — a dragged element whose position
367
- // was a standalone `gsap.set` (never a timeline child), or one whose
368
- // keyframes were just removed (no longer in any timeline). Their last
369
- // `gsap.set` transform is otherwise orphaned: the re-run won't re-set it
370
- // and the sweep above can't see it, so the element renders offset from its
371
- // source position (matching the overlay) until a full reload. The clear
372
- // below runs BEFORE the re-run, which re-applies the transform for any
373
- // element the new script still animates.
374
- const seenTargets = new Set<Element>(allTargets);
375
- for (const el of doc.querySelectorAll<HTMLElement>("[style*='transform']")) {
376
- // Gate on the GSAP cache (`_gsap`) so we only reset transforms GSAP owns —
377
- // never strip an authored, non-GSAP inline transform.
378
- if (el.style.transform && "_gsap" in el && !seenTargets.has(el)) {
379
- seenTargets.add(el);
380
- allTargets.push(el);
381
- }
382
- }
383
-
384
367
  // Reset GSAP's internal transform cache so from() tweens don't read stale
385
368
  // end values. `clearProps: "all"` is needed to flush the cache, but it also
386
369
  // nukes the element's CSS base (position, width, height, etc.) from the
387
- // HTML `style=""` attribute. Save → clear → restore → authored transform props.
370
+ // HTML `style=""` attribute. Save → clear → restore → what GSAP wrote, from the file.
371
+ const allTargets = [...targets.keys()];
388
372
  if (allTargets.length > 0 && win.gsap?.set) {
389
373
  const saved: Array<[HTMLElement, string]> = [];
390
374
  for (const el of allTargets) {
@@ -399,7 +383,7 @@ export function applySoftReload(
399
383
  for (const [el, css] of saved) {
400
384
  const s = el.style;
401
385
  s.cssText = css;
402
- restoreAuthoredTransforms(s, findAuthoredStyle(el));
386
+ restoreAuthoredStyle(s, findAuthoredStyle(el), targets.get(el)!);
403
387
  // The restored cssText carries RUNTIME opacity, not authored opacity:
404
388
  // a mid-flight tween's interpolated value, or the color-grading hide
405
389
  // (`opacity: 0 !important`). The re-run script's tweens re-initialize
@@ -430,7 +414,14 @@ export function applySoftReload(
430
414
  }
431
415
  const s = doc.createElement("script");
432
416
  s.textContent = `(function(){${scriptText}\n})();`;
417
+ const ownScriptNode = 1;
418
+ const before = doc.body.querySelectorAll("*").length + ownScriptNode;
433
419
  doc.body.appendChild(s);
420
+ // A script that builds DOM builds it again on every run; only a full load starts from the file.
421
+ // Not seen: text-only writes, nodes put in the head or a shadow root, nodes added
422
+ // later, and a builder that removes its old nodes first (which needs no full load).
423
+ builtDom = doc.body.querySelectorAll("*").length > before;
424
+ if (builtDom && deferredToAsync) onAsyncFailure?.();
434
425
  finalizeSoftReload(win, currentTime);
435
426
  };
436
427
 
@@ -479,23 +470,47 @@ export function applySoftReload(
479
470
  executeScript();
480
471
  };
481
472
 
482
- try {
483
- if (win.__hfSuppressSceneMutations) {
484
- win.__hfSuppressSceneMutations(doReload);
485
- } else {
486
- doReload();
487
- }
488
- // When MotionPath needs async loading, the script hasn't executed yet —
489
- // skip the __timelines check and report success optimistically (the script
490
- // WILL run on plugin load; onAsyncFailure covers the CDN-error case).
491
- if (deferredToAsync) return "applied";
492
- // The re-run executed. If the target keys read back, we're done; otherwise
493
- // it's the TRANSIENT empty-timeline window (live state is correct) — surfaced
494
- // as "verify-failed" so callers know NOT to escalate.
495
- return verifyTimelinesPopulated(win, targetKeys) ? "applied" : "verify-failed";
496
- } catch {
497
- // The synchronous re-run threw — the preview is now genuinely broken (target
498
- // timeline killed, script not re-registered). Escalate to a full reload.
499
- return "cannot-soft-reload";
500
- }
473
+ const run = () => runSuppressed(win, doReload);
474
+ // The synchronous re-run threw or built DOM a second time: the preview is now
475
+ // genuinely broken (no timeline, or duplicated nodes). Escalate to a full reload.
476
+ if (!run() || builtDom) return "cannot-soft-reload";
477
+ // When MotionPath needs async loading, the script hasn't executed yet —
478
+ // skip the __timelines check and report success optimistically (the script
479
+ // WILL run on plugin load; onAsyncFailure covers the CDN-error case).
480
+ if (deferredToAsync) return "applied";
481
+ // The re-run executed. If the target keys read back, we're done; otherwise
482
+ // it's the TRANSIENT empty-timeline window (live state is correct) — surfaced
483
+ // as "verify-failed" so callers know NOT to escalate.
484
+ return verifyTimelinesPopulated(win, targetKeys) ? "applied" : "verify-failed";
485
+ }
486
+
487
+ /** Other composition files for a soft reload: undefined when none are needed, null when a read failed. */
488
+ export type NestedFiles = Map<string, string> | null | undefined;
489
+
490
+ export async function settleNestedReads(
491
+ reads: Promise<Map<string, string>> | null,
492
+ ): Promise<NestedFiles> {
493
+ return reads ? reads.catch(() => null) : undefined;
494
+ }
495
+
496
+ /**
497
+ * Reads the composition files, other than the reloaded one, that the elements `scriptText`'s
498
+ * re-run resets are written in; null when there are none. Read them before the soft reload.
499
+ */
500
+ export function readNestedFiles(
501
+ iframe: HTMLIFrameElement | null,
502
+ scriptText: string,
503
+ readFile: (path: string) => Promise<string>,
504
+ ): Promise<Map<string, string>> | null {
505
+ const win = iframe?.contentWindow as IframeWindow | null;
506
+ const doc = iframe?.contentDocument;
507
+ if (!win || !doc) return null;
508
+ const keys = timelineKeys(scriptText);
509
+ const own = compositionFile(doc, keys);
510
+ const files = [...collectResetTargets(win, doc, keys).keys()].map(authoringFile);
511
+ const paths = [...new Set(files)].filter((file): file is string => file !== null && file !== own);
512
+ if (paths.length === 0) return null;
513
+ return Promise.all(paths.map((path) => readFile(path))).then(
514
+ (texts) => new Map(paths.map((path, i) => [path, texts[i]!])),
515
+ );
501
516
  }
@@ -170,9 +170,10 @@ describe("applyUndoRestoreToPreview", () => {
170
170
 
171
171
  describe("an undo that re-runs a changed script matches a fresh load of the restored file", () => {
172
172
  const script = (extra: string) => `window.__timelines["root"]=gsap.timeline();${extra}`;
173
+ const root = (body: string) => `<div data-composition-id="root">${body}</div>`;
173
174
  const undoScriptEdit = (live: string, authored: string, edit: string) => {
174
175
  const { iframe, contentWindow, doc } = buildLiveIframe(
175
- `${live}<script>${script(edit)}</script>`,
176
+ `${root(live)}<script>${script(edit)}</script>`,
176
177
  );
177
178
  const clearProps = (targets: HTMLElement[]) =>
178
179
  targets.forEach((t) => t.removeAttribute("style"));
@@ -180,8 +181,8 @@ describe("applyUndoRestoreToPreview", () => {
180
181
  for (const el of doc.querySelectorAll("[style*=transform]")) Object.assign(el, { _gsap: {} });
181
182
  const files = {
182
183
  [ROOT]: {
183
- previous: wrap(`${authored}<script>${script(edit)}</script>`),
184
- restored: wrap(`${authored}<script>${script("")}</script>`),
184
+ previous: wrap(`${root(authored)}<script>${script(edit)}</script>`),
185
+ restored: wrap(`${root(authored)}<script>${script("")}</script>`),
185
186
  },
186
187
  };
187
188
  expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn())).toBe("soft");
@@ -7,6 +7,7 @@ import {
7
7
  applySoftReloadFinalization,
8
8
  extractGsapScriptText,
9
9
  findGsapScriptElements,
10
+ readNestedFiles,
10
11
  } from "./gsapSoftReload";
11
12
  import { isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
12
13
  import { findAuthoredElement, parseSavedSource } from "./authoredSource";
@@ -324,6 +325,18 @@ export function showRestoreInPlace(
324
325
  return null;
325
326
  }
326
327
 
328
+ /** The other composition files a re-run of the restored script resets elements of; null when none. */
329
+ export function readUndoNestedFiles(
330
+ iframe: HTMLIFrameElement | null,
331
+ activeCompPath: string | null,
332
+ files: RestoreFiles | undefined,
333
+ readFile: (path: string) => Promise<string>,
334
+ ): Promise<Map<string, string>> | null {
335
+ const active = files?.[activeCompPath ?? "index.html"];
336
+ const script = active ? extractGsapScriptText(active.restored) : null;
337
+ return script ? readNestedFiles(iframe, script, readFile) : null;
338
+ }
339
+
327
340
  /**
328
341
  * Soft-apply an undo/redo restore to the live preview WITHOUT a full iframe
329
342
  * remount (which blanks the frame black and re-flashes the WebGL context). Eligible
@@ -358,6 +371,7 @@ export function applyUndoRestoreToPreview(
358
371
  files: RestoreFiles | undefined,
359
372
  currentTime: number,
360
373
  reload: () => void,
374
+ nestedFiles?: Map<string, string> | null,
361
375
  ): "soft" | "full" {
362
376
  // The master view carries a NULL activeCompPath but the root iframe shows
363
377
  // index.html — the codebase-wide convention (`activeCompPath || "index.html"`).
@@ -398,6 +412,7 @@ export function applyUndoRestoreToPreview(
398
412
  currentTimeOverride: currentTime,
399
413
  authoredHtml: active.restored,
400
414
  reparse,
415
+ nestedFiles,
401
416
  });
402
417
  if (result === "cannot-soft-reload") {
403
418
  reloadPreview();
@@ -0,0 +1,34 @@
1
+ import { create } from "zustand";
2
+ import { STUDIO_PREVIEW_FPS } from "../player/lib/time";
3
+ import { readStudioUiPreferences, writeStudioUiPreferences } from "./studioUiPreferences";
4
+
5
+ interface LinkedClipPreferences {
6
+ /** Premiere's Linked Selection: clicks and edits reach every link partner. */
7
+ linkedSelection: boolean;
8
+ setLinkedSelection: (enabled: boolean) => void;
9
+ /** Red offset badges on a video and audio from one source that drifted apart. */
10
+ syncIndicatorsVisible: boolean;
11
+ setSyncIndicatorsVisible: (visible: boolean) => void;
12
+ compositionFps: number;
13
+ setCompositionFps: (fps: number) => void;
14
+ }
15
+
16
+ export const useLinkedClipPreferences = create<LinkedClipPreferences>((set) => ({
17
+ linkedSelection: readStudioUiPreferences().linkedSelectionEnabled ?? true,
18
+ setLinkedSelection: (enabled) => {
19
+ writeStudioUiPreferences({ linkedSelectionEnabled: enabled });
20
+ set({ linkedSelection: enabled });
21
+ },
22
+ syncIndicatorsVisible: readStudioUiPreferences().syncIndicatorsVisible ?? true,
23
+ setSyncIndicatorsVisible: (visible) => {
24
+ writeStudioUiPreferences({ syncIndicatorsVisible: visible });
25
+ set({ syncIndicatorsVisible: visible });
26
+ },
27
+ compositionFps: STUDIO_PREVIEW_FPS,
28
+ setCompositionFps: (fps) => {
29
+ if (Number.isFinite(fps) && fps > 0) set({ compositionFps: fps });
30
+ },
31
+ }));
32
+
33
+ export const isLinkedSelectionOn = (): boolean =>
34
+ useLinkedClipPreferences.getState().linkedSelection;
@@ -0,0 +1,128 @@
1
+ // Which elements a GSAP soft reload resets, what their tweens wrote, and which file writes each.
2
+ import { parseSavedSource } from "./authoredSource";
3
+
4
+ type TweenLike = {
5
+ targets?: () => Element[];
6
+ vars?: Record<string, unknown>;
7
+ getChildren?: (deep: boolean) => TweenLike[];
8
+ };
9
+
10
+ const NESTED_VARS = new Set(["css", "startAt", "keyframes"]);
11
+
12
+ // The CSS names a tween's vars write; GSAP's own keys (ease, duration) are not CSS and restore as no-ops.
13
+ function tweenedProps(vars: unknown): string[] {
14
+ if (!vars || typeof vars !== "object") return [];
15
+ return Object.entries(vars).flatMap(([key, value]) => [
16
+ key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`),
17
+ ...(NESTED_VARS.has(key) || /^\d|%$/.test(key) ? tweenedProps(value) : []),
18
+ ]);
19
+ }
20
+
21
+ // A nested composition's timeline sits inside the root one but only its own script rebuilds it.
22
+ function ownTweens(tl: TweenLike, nested: Set<unknown>): TweenLike[] {
23
+ return (tl.getChildren?.(false) ?? []).flatMap((child) => {
24
+ if (nested.has(child)) return [];
25
+ return child.getChildren ? ownTweens(child, nested) : [child];
26
+ });
27
+ }
28
+
29
+ // The preview marks each inlined composition's host with its file; the host itself is the parent's.
30
+ export function authoringFile(el: Element): string | null {
31
+ return (
32
+ el.parentElement?.closest("[data-composition-file]")?.getAttribute("data-composition-file") ??
33
+ null
34
+ );
35
+ }
36
+
37
+ function addTweenTargets(tween: TweenLike, targets: Map<Element, Set<string>>): void {
38
+ const props = tweenedProps(tween.vars);
39
+ for (const el of tween.targets?.() ?? []) {
40
+ const seen = targets.get(el) ?? new Set<string>();
41
+ for (const prop of props) seen.add(prop);
42
+ targets.set(el, seen);
43
+ }
44
+ }
45
+
46
+ /** Each element the re-run rebuilds, with the CSS names its killed tweens wrote. */
47
+ export function collectResetTargets(
48
+ win: { __timelines?: Record<string, unknown> },
49
+ doc: Document,
50
+ targetKeys: string[],
51
+ reparse: Element[] = [],
52
+ ): Map<Element, Set<string>> {
53
+ // Elements the caller needs GSAP to parse again, with nothing tweened yet.
54
+ const targets = new Map<Element, Set<string>>(reparse.map((el) => [el, new Set<string>()]));
55
+ const timelines = (win.__timelines ?? {}) as Record<string, TweenLike | undefined>;
56
+ const others = new Set<unknown>(
57
+ Object.entries(timelines).flatMap(([key, tl]) => (targetKeys.includes(key) ? [] : [tl])),
58
+ );
59
+ for (const key of targetKeys) {
60
+ try {
61
+ for (const tween of ownTweens(timelines[key] ?? {}, others)) addTweenTargets(tween, targets);
62
+ } catch {}
63
+ }
64
+ sweepHeldElements(doc, targetKeys, others, targets);
65
+ return targets;
66
+ }
67
+
68
+ function tweenTargetsIn(timelines: Set<unknown>): Set<Element> {
69
+ const els = new Set<Element>();
70
+ for (const tl of timelines) {
71
+ try {
72
+ for (const tween of (tl as TweenLike | undefined)?.getChildren?.(true) ?? []) {
73
+ for (const el of tween.targets?.() ?? []) els.add(el);
74
+ }
75
+ } catch {}
76
+ }
77
+ return els;
78
+ }
79
+
80
+ function compositionRoot(doc: Document, key: string): Element | undefined {
81
+ return doc.querySelectorAll(`[data-composition-id="${CSS.escape(key)}"]`)[0];
82
+ }
83
+
84
+ export function compositionFile(doc: Document, targetKeys: string[]): string | null {
85
+ const root = targetKeys.map((key) => compositionRoot(doc, key)).find(Boolean);
86
+ return root?.closest("[data-composition-file]")?.getAttribute("data-composition-file") ?? null;
87
+ }
88
+
89
+ // A standalone gsap.set, or keyframes just removed, leaves GSAP state no timeline child shows.
90
+ // The nearest composition root says whose script set it; another timeline's targets stay its own.
91
+ function sweepHeldElements(
92
+ doc: Document,
93
+ targetKeys: string[],
94
+ others: Set<unknown>,
95
+ targets: Map<Element, Set<string>>,
96
+ ): void {
97
+ const elsewhere = tweenTargetsIn(others);
98
+ const roots = new Set<Element | null | undefined>(targetKeys.map((k) => compositionRoot(doc, k)));
99
+ roots.delete(undefined);
100
+ for (const root of roots) {
101
+ for (const el of root ? [root, ...root.querySelectorAll("*")] : []) {
102
+ if (targets.has(el) || elsewhere.has(el) || !("_gsap" in el)) continue;
103
+ if (el === root || roots.has(el.parentElement?.closest("[data-composition-id]")))
104
+ targets.set(el, new Set());
105
+ }
106
+ }
107
+ }
108
+
109
+ // Each file a reset element may be written in, parsed at most once per reload: the reloaded
110
+ // composition's own file is the one just written, any other comes from the caller.
111
+ export function fileDocs(
112
+ ownFile: string | null,
113
+ written: string | undefined,
114
+ nestedFiles: Map<string, string> | null | undefined,
115
+ ): (file: string | null) => Document | null {
116
+ const docs = new Map<string | null, Document | null>();
117
+ return (file) => {
118
+ if (!docs.has(file)) {
119
+ const text = file === ownFile ? written : nestedFiles?.get(file ?? "");
120
+ try {
121
+ docs.set(file, text ? parseSavedSource(text) : null);
122
+ } catch {
123
+ docs.set(file, null);
124
+ }
125
+ }
126
+ return docs.get(file) ?? null;
127
+ };
128
+ }
@@ -45,6 +45,8 @@ export interface StudioUiPreferences {
45
45
  theme?: StudioTheme;
46
46
  /** The dock's serialized panel tree; parsed by `parseDockLayout` on read. */
47
47
  dockLayout?: SerializedDockview;
48
+ linkedSelectionEnabled?: boolean;
49
+ syncIndicatorsVisible?: boolean;
48
50
  }
49
51
 
50
52
  const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences";
@@ -146,6 +148,12 @@ function readStorage(storage: Storage | null, key: string): StudioUiPreferences
146
148
  if (typeof parsed.agentToolsEnabled === "boolean") {
147
149
  preferences.agentToolsEnabled = parsed.agentToolsEnabled;
148
150
  }
151
+ if (typeof parsed.linkedSelectionEnabled === "boolean") {
152
+ preferences.linkedSelectionEnabled = parsed.linkedSelectionEnabled;
153
+ }
154
+ if (typeof parsed.syncIndicatorsVisible === "boolean") {
155
+ preferences.syncIndicatorsVisible = parsed.syncIndicatorsVisible;
156
+ }
149
157
  const dockLayout = parseDockLayout(parsed.dockLayout);
150
158
  if (dockLayout) preferences.dockLayout = dockLayout;
151
159
  return preferences;