@hyperframes/studio 0.6.0-alpha.9 → 0.6.1

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 (111) hide show
  1. package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
  2. package/dist/assets/index-D1JDq7Gg.css +1 -0
  3. package/dist/assets/index-hYc4aP7M.js +117 -0
  4. package/dist/favicon.svg +14 -0
  5. package/dist/index.html +3 -2
  6. package/package.json +9 -9
  7. package/src/App.tsx +421 -4303
  8. package/src/captions/components/CaptionOverlay.tsx +13 -246
  9. package/src/captions/components/CaptionOverlayUtils.ts +221 -0
  10. package/src/components/AskAgentModal.tsx +120 -0
  11. package/src/components/StudioHeader.tsx +133 -0
  12. package/src/components/StudioLeftSidebar.tsx +125 -0
  13. package/src/components/StudioPreviewArea.tsx +167 -0
  14. package/src/components/StudioRightPanel.tsx +198 -0
  15. package/src/components/TimelineToolbar.tsx +89 -0
  16. package/src/components/editor/DomEditOverlay.tsx +88 -993
  17. package/src/components/editor/EaseCurveEditor.tsx +221 -0
  18. package/src/components/editor/FileTree.tsx +13 -621
  19. package/src/components/editor/FileTreeIcons.tsx +128 -0
  20. package/src/components/editor/FileTreeNodes.tsx +496 -0
  21. package/src/components/editor/MotionPanel.tsx +16 -390
  22. package/src/components/editor/MotionPanelFields.tsx +185 -0
  23. package/src/components/editor/PropertyPanel.test.ts +0 -49
  24. package/src/components/editor/PropertyPanel.tsx +132 -2763
  25. package/src/components/editor/domEditOverlayGeometry.ts +211 -0
  26. package/src/components/editor/domEditOverlayGestures.ts +138 -0
  27. package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
  28. package/src/components/editor/domEditing.ts +44 -1117
  29. package/src/components/editor/domEditingAgentPrompt.ts +97 -0
  30. package/src/components/editor/domEditingDom.ts +266 -0
  31. package/src/components/editor/domEditingElement.ts +329 -0
  32. package/src/components/editor/domEditingLayers.ts +460 -0
  33. package/src/components/editor/domEditingTypes.ts +125 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +2 -2
  35. package/src/components/editor/manualEditingAvailability.ts +1 -1
  36. package/src/components/editor/manualEdits.ts +84 -1049
  37. package/src/components/editor/manualEditsDom.ts +436 -0
  38. package/src/components/editor/manualEditsParsing.ts +280 -0
  39. package/src/components/editor/manualEditsSnapshot.ts +333 -0
  40. package/src/components/editor/manualEditsTypes.ts +141 -0
  41. package/src/components/editor/propertyPanelColor.tsx +371 -0
  42. package/src/components/editor/propertyPanelFill.tsx +421 -0
  43. package/src/components/editor/propertyPanelFont.tsx +455 -0
  44. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  45. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  46. package/src/components/editor/propertyPanelSections.tsx +453 -0
  47. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  48. package/src/components/editor/studioMotion.ts +47 -434
  49. package/src/components/editor/studioMotionOps.ts +299 -0
  50. package/src/components/editor/studioMotionTypes.ts +168 -0
  51. package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
  52. package/src/components/editor/useDomEditOverlayRects.ts +207 -0
  53. package/src/components/nle/NLELayout.tsx +68 -155
  54. package/src/components/nle/NLEPreview.tsx +3 -0
  55. package/src/components/nle/useCompositionStack.ts +126 -0
  56. package/src/components/renders/RenderQueue.tsx +102 -31
  57. package/src/components/renders/useRenderQueue.ts +8 -2
  58. package/src/components/sidebar/LeftSidebar.tsx +186 -186
  59. package/src/contexts/DomEditContext.tsx +137 -0
  60. package/src/contexts/FileManagerContext.tsx +110 -0
  61. package/src/contexts/PanelLayoutContext.tsx +68 -0
  62. package/src/contexts/StudioContext.tsx +135 -0
  63. package/src/hooks/useAppHotkeys.ts +326 -0
  64. package/src/hooks/useAskAgentModal.ts +162 -0
  65. package/src/hooks/useCaptionDetection.ts +132 -0
  66. package/src/hooks/useCompositionDimensions.ts +25 -0
  67. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  68. package/src/hooks/useDomEditCommits.ts +437 -0
  69. package/src/hooks/useDomEditSession.ts +342 -0
  70. package/src/hooks/useDomEditTextCommits.ts +330 -0
  71. package/src/hooks/useDomSelection.ts +398 -0
  72. package/src/hooks/useFileManager.ts +431 -0
  73. package/src/hooks/useFrameCapture.ts +77 -0
  74. package/src/hooks/useLintModal.ts +35 -0
  75. package/src/hooks/useManifestPersistence.ts +492 -0
  76. package/src/hooks/usePanelLayout.ts +68 -0
  77. package/src/hooks/usePreviewInteraction.ts +153 -0
  78. package/src/hooks/useRenderClipContent.ts +124 -0
  79. package/src/hooks/useTimelineEditing.ts +472 -0
  80. package/src/hooks/useToast.ts +20 -0
  81. package/src/player/components/Player.tsx +33 -2
  82. package/src/player/components/Timeline.test.ts +0 -8
  83. package/src/player/components/Timeline.tsx +196 -1518
  84. package/src/player/components/TimelineCanvas.tsx +434 -0
  85. package/src/player/components/TimelineClip.tsx +9 -244
  86. package/src/player/components/TimelineEmptyState.tsx +102 -0
  87. package/src/player/components/TimelineRuler.tsx +90 -0
  88. package/src/player/components/timelineIcons.tsx +49 -0
  89. package/src/player/components/timelineLayout.ts +215 -0
  90. package/src/player/components/timelineUtils.ts +211 -0
  91. package/src/player/components/useTimelineClipDrag.ts +388 -0
  92. package/src/player/components/useTimelinePlayhead.ts +200 -0
  93. package/src/player/components/useTimelineRangeSelection.ts +135 -0
  94. package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
  95. package/src/player/hooks/useTimelinePlayer.ts +105 -1371
  96. package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
  97. package/src/player/lib/playbackAdapter.ts +145 -0
  98. package/src/player/lib/playbackShortcuts.ts +68 -0
  99. package/src/player/lib/playbackTypes.ts +60 -0
  100. package/src/player/lib/timelineDOM.ts +373 -0
  101. package/src/player/lib/timelineElementHelpers.ts +303 -0
  102. package/src/player/lib/timelineIframeHelpers.ts +269 -0
  103. package/src/utils/domEditHelpers.ts +50 -0
  104. package/src/utils/studioFontHelpers.ts +83 -0
  105. package/src/utils/studioHelpers.ts +214 -0
  106. package/src/utils/studioPreviewHelpers.ts +185 -0
  107. package/src/utils/timelineDiscovery.ts +1 -1
  108. package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
  109. package/dist/assets/index-14zH9lqh.css +0 -1
  110. package/dist/assets/index-DYCiFGWQ.js +0 -108
  111. package/src/player/components/TimelineClip.test.ts +0 -92
@@ -2,257 +2,31 @@ import { memo, useState, useCallback, useRef } from "react";
2
2
  import { useCaptionStore } from "../store";
3
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
4
  import { shouldHandleCaptionNudgeKey } from "../keyboard";
5
+ import {
6
+ readWordBoxes,
7
+ getWordEl,
8
+ readGsapTransform,
9
+ getOrCreateWrapper,
10
+ writeTransform,
11
+ computeTransformStyle,
12
+ type WordBox,
13
+ } from "./CaptionOverlayUtils";
5
14
 
6
15
  interface CaptionOverlayProps {
7
16
  iframeRef: React.RefObject<HTMLIFrameElement | null>;
8
17
  }
9
18
 
10
- interface WordBox {
11
- segmentId: string;
12
- groupId: string;
13
- groupIndex: number;
14
- wordIndex: number;
15
- x: number;
16
- y: number;
17
- width: number;
18
- height: number;
19
- }
20
-
21
- function readWordBoxes(
22
- iframe: HTMLIFrameElement,
23
- model: {
24
- groupOrder: string[];
25
- groups: Map<string, { segmentIds: string[] }>;
26
- },
27
- overlayEl: HTMLElement,
28
- ): WordBox[] {
29
- let doc: Document | null = null;
30
- let win: Window | null = null;
31
- try {
32
- doc = iframe.contentDocument;
33
- win = iframe.contentWindow;
34
- } catch {
35
- return [];
36
- }
37
- if (!doc || !win) return [];
38
-
39
- const iframeDisplayRect = iframe.getBoundingClientRect();
40
- const overlayRect = overlayEl.getBoundingClientRect();
41
- // The iframe renders at native resolution (e.g. 1920x1080) but is
42
- // CSS-scaled to fit the viewport. getBoundingClientRect() on elements
43
- // inside the iframe returns coordinates in the iframe's native space.
44
- // Multiply by cssScale to convert to parent window coordinates.
45
- const nativeW = parseFloat(iframe.style.width) || iframeDisplayRect.width;
46
- const cssScale = iframeDisplayRect.width / nativeW;
47
- const offsetX = iframeDisplayRect.left - overlayRect.left;
48
- const offsetY = iframeDisplayRect.top - overlayRect.top;
49
-
50
- const groupEls = doc.querySelectorAll<HTMLElement>(".caption-group");
51
- const boxes: WordBox[] = [];
52
-
53
- for (let gi = 0; gi < model.groupOrder.length; gi++) {
54
- const groupId = model.groupOrder[gi];
55
- const group = model.groups.get(groupId);
56
- if (!group) continue;
57
- const groupEl = groupEls[gi] as HTMLElement | undefined;
58
- if (!groupEl) continue;
59
- const computed = win.getComputedStyle(groupEl);
60
- if (parseFloat(computed.opacity) <= 0.01 || computed.visibility === "hidden") continue;
61
- // Find word elements — handles both per-word spans (generator output)
62
- // and grouped text nodes (existing caption templates that use
63
- // el.textContent = line.text instead of individual word spans).
64
- const resolvedWordEls: HTMLElement[] = [];
65
- for (const child of groupEl.children) {
66
- const c = child as HTMLElement;
67
- if (c.dataset.captionWrapper === "true") {
68
- const inner = c.querySelector<HTMLElement>(":scope > span");
69
- if (inner) resolvedWordEls.push(inner);
70
- } else if (c.tagName === "SPAN") {
71
- resolvedWordEls.push(c);
72
- }
73
- }
74
- // Fallback: if no word spans found but group has text content,
75
- // the template uses grouped text. Wrap each word in a span so
76
- // the overlay can target them individually.
77
- if (resolvedWordEls.length === 0 && groupEl.textContent?.trim()) {
78
- const textNode = groupEl.childNodes[0];
79
- if (textNode && textNode.nodeType === Node.TEXT_NODE) {
80
- const words = (textNode.textContent || "").split(/\s+/).filter(Boolean);
81
- const frag = doc.createDocumentFragment();
82
- for (const word of words) {
83
- const span = doc.createElement("span");
84
- span.textContent = word + " ";
85
- span.style.display = "inline";
86
- frag.appendChild(span);
87
- resolvedWordEls.push(span);
88
- }
89
- groupEl.replaceChild(frag, textNode);
90
- } else {
91
- // Single span child with all text (e.g. vignelli template)
92
- const singleSpan = groupEl.querySelector<HTMLElement>(":scope > span");
93
- if (singleSpan && singleSpan.textContent?.trim()) {
94
- const words = singleSpan.textContent.split(/\s+/).filter(Boolean);
95
- const frag = doc.createDocumentFragment();
96
- for (const word of words) {
97
- const span = doc.createElement("span");
98
- span.textContent = word + " ";
99
- span.style.display = "inline";
100
- frag.appendChild(span);
101
- resolvedWordEls.push(span);
102
- }
103
- singleSpan.replaceWith(frag);
104
- }
105
- }
106
- }
107
- for (let wi = 0; wi < group.segmentIds.length; wi++) {
108
- const segId = group.segmentIds[wi];
109
- const wordEl = resolvedWordEls[wi] as HTMLElement | undefined;
110
- if (!wordEl) continue;
111
- const rect = wordEl.getBoundingClientRect();
112
- boxes.push({
113
- segmentId: segId,
114
- groupId,
115
- groupIndex: gi,
116
- wordIndex: wi,
117
- x: rect.left * cssScale + offsetX,
118
- y: rect.top * cssScale + offsetY,
119
- width: rect.width * cssScale,
120
- height: rect.height * cssScale,
121
- });
122
- }
123
- }
124
- return boxes;
125
- }
126
-
127
- function getWordEl(
128
- iframe: HTMLIFrameElement,
129
- groupIndex: number,
130
- wordIndex: number,
131
- ): HTMLElement | null {
132
- let doc: Document | null = null;
133
- try {
134
- doc = iframe.contentDocument;
135
- } catch {
136
- return null;
137
- }
138
- if (!doc) return null;
139
- const groupEl = doc.querySelectorAll<HTMLElement>(".caption-group")[groupIndex];
140
- if (!groupEl) return null;
141
- // Find word spans — they may be direct children or inside wrapper spans.
142
- // Word spans have class "word" or an id starting with "w".
143
- // Wrappers have data-caption-wrapper="true".
144
- const wordEls: HTMLElement[] = [];
145
- for (const child of groupEl.children) {
146
- const el = child as HTMLElement;
147
- if (el.dataset.captionWrapper === "true") {
148
- // Wrapped word — get the inner span
149
- const inner = el.querySelector<HTMLElement>(":scope > span");
150
- if (inner) wordEls.push(inner);
151
- } else if (el.tagName === "SPAN") {
152
- wordEls.push(el);
153
- }
154
- }
155
- return wordEls[wordIndex] ?? null;
156
- }
157
-
158
- /**
159
- * Read GSAP's internal transform state for an element.
160
- * GSAP stores transforms in its own cache, not in el.style.transform.
161
- */
162
- function readGsapTransform(
163
- el: HTMLElement,
164
- iframeWin: Window,
165
- ): { x: number; y: number; scale: number; rotation: number } {
166
- const gsap = (
167
- iframeWin as unknown as { gsap?: { getProperty?: (el: HTMLElement, prop: string) => number } }
168
- ).gsap;
169
- if (gsap && gsap.getProperty) {
170
- return {
171
- x: gsap.getProperty(el, "x") || 0,
172
- y: gsap.getProperty(el, "y") || 0,
173
- scale: gsap.getProperty(el, "scale") || 1,
174
- rotation: gsap.getProperty(el, "rotation") || 0,
175
- };
176
- }
177
- // Fallback: parse from style
178
- const t = el.style.transform || "";
179
- const scaleMatch = t.match(/scale\(([^)]+)\)/);
180
- const rotMatch = t.match(/rotate\(([^)]+)deg\)/);
181
- const txyMatch = t.match(/translate\(([^,]+)px,\s*([^)]+)px\)/);
182
- return {
183
- x: txyMatch ? parseFloat(txyMatch[1]) : 0,
184
- y: txyMatch ? parseFloat(txyMatch[2]) : 0,
185
- scale: scaleMatch ? parseFloat(scaleMatch[1]) : 1,
186
- rotation: rotMatch ? parseFloat(rotMatch[1]) : 0,
187
- };
188
- }
189
-
190
- /**
191
- * Get or create an inline-block wrapper span around a word element.
192
- * Transforms are applied to the wrapper so the word's GSAP animations are preserved.
193
- */
194
- function getOrCreateWrapper(el: HTMLElement): HTMLElement {
195
- // If el IS a wrapper, return it
196
- if (el.dataset.captionWrapper === "true") return el;
197
- // If el's parent is a wrapper, return the parent
198
- const parent = el.parentElement;
199
- if (parent && parent.dataset.captionWrapper === "true") return parent;
200
- // Create new wrapper
201
- const doc = el.ownerDocument;
202
- const wrapper = doc.createElement("span");
203
- wrapper.style.display = "inline-block";
204
- wrapper.dataset.captionWrapper = "true";
205
- el.parentNode?.insertBefore(wrapper, el);
206
- wrapper.appendChild(el);
207
- return wrapper;
208
- }
209
-
210
- /**
211
- * Write transform values to a wrapper span around the word element.
212
- * The word keeps its GSAP animations; the wrapper handles editor transforms.
213
- */
214
- function writeTransform(
215
- el: HTMLElement,
216
- iframeWin: Window,
217
- x: number,
218
- y: number,
219
- scale: number,
220
- rotation: number,
221
- ) {
222
- const wrapper = getOrCreateWrapper(el);
223
- const gsap = (
224
- iframeWin as unknown as {
225
- gsap?: { set?: (el: HTMLElement, props: Record<string, number>) => void };
226
- }
227
- ).gsap;
228
- if (gsap && gsap.set) {
229
- gsap.set(wrapper, { x, y, scale, rotation });
230
- } else {
231
- wrapper.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${rotation.toFixed(1)}deg) scale(${scale.toFixed(3)})`;
232
- }
233
- }
19
+ const HANDLE = 8;
20
+ const ROTATION_OFFSET = 20; // px above the selection box
234
21
 
235
- /** Sync canvas state back to the Zustand store so the property panel reflects it.
236
- * Only writes non-default values to avoid creating spurious overrides. */
22
+ /** Sync canvas state back to the Zustand store so the property panel reflects it. */
237
23
  function syncToStore(segmentId: string, el: HTMLElement, iframeWin: Window) {
238
- const wrapper = getOrCreateWrapper(el);
239
- const { x, y, scale, rotation } = readGsapTransform(wrapper, iframeWin);
240
- const style: Record<string, number> = {};
241
- if (Math.abs(x) > 0.5) style.x = x;
242
- if (Math.abs(y) > 0.5) style.y = y;
243
- if (Math.abs(scale - 1) > 0.001) {
244
- style.scaleX = scale;
245
- style.scaleY = scale;
246
- }
247
- if (Math.abs(rotation) > 0.1) style.rotation = rotation;
24
+ const style = computeTransformStyle(el, iframeWin);
248
25
  if (Object.keys(style).length > 0) {
249
26
  useCaptionStore.getState().updateSegmentStyle(segmentId, style);
250
27
  }
251
28
  }
252
29
 
253
- const HANDLE = 8;
254
- const ROTATION_OFFSET = 20; // px above the selection box
255
-
256
30
  export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: CaptionOverlayProps) {
257
31
  const isEditMode = useCaptionStore((s) => s.isEditMode);
258
32
  const model = useCaptionStore((s) => s.model);
@@ -311,7 +85,6 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
311
85
  const overlay = overlayRef.current;
312
86
  if (!iframe || !m || !overlay) return;
313
87
  const next = readWordBoxes(iframe, m, overlay);
314
- // Skip state update if nothing changed (avoids re-render every 66ms)
315
88
  if (
316
89
  next.length === prevBoxes.length &&
317
90
  next.every(
@@ -342,7 +115,6 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
342
115
  if (!iframe || !win) return;
343
116
 
344
117
  for (const segId of sel) {
345
- // Find group/word index for this segment
346
118
  for (let gi = 0; gi < m.groupOrder.length; gi++) {
347
119
  const group = m.groups.get(m.groupOrder[gi]);
348
120
  if (!group) continue;
@@ -459,7 +231,6 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
459
231
  [iframeRef],
460
232
  );
461
233
 
462
- /** Get iframe contentWindow, needed for gsap calls */
463
234
  const getIframeWin = useCallback((): Window | null => {
464
235
  try {
465
236
  return iframeRef.current?.contentWindow ?? null;
@@ -482,8 +253,6 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
482
253
  const dy = (e.clientY - i.startMY) / cssScale;
483
254
  writeTransform(i.wordEl, win, i.origTX + dx, i.origTY + dy, i.origScale, i.origRotation);
484
255
  } else if (i.type === "scale") {
485
- // Use distance from box center so dragging outward from ANY corner
486
- // increases scale (not just right-side handles).
487
256
  const cx = i.startMX - i.startDxFromCenter;
488
257
  const startDist = Math.abs(i.startDxFromCenter);
489
258
  const currentDist = Math.abs(e.clientX - cx);
@@ -491,8 +260,6 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
491
260
  const newScale = Math.max(0.1, i.origScale * factor);
492
261
  writeTransform(i.wordEl, win, i.origTX, i.origTY, newScale, i.origRotation);
493
262
  } else if (i.type === "rotate") {
494
- // Horizontal drag maps to rotation: right = clockwise, left = counter-clockwise.
495
- // 200px of horizontal movement = 90 degrees.
496
263
  const dx = e.clientX - i.startMX;
497
264
  const delta = (dx / 200) * 90;
498
265
  writeTransform(i.wordEl, win, i.origTX, i.origTY, i.origScale, i.origRotation + delta);
@@ -0,0 +1,221 @@
1
+ // DOM helpers for CaptionOverlay — word box reading, transform I/O, wrapper management
2
+
3
+ export interface WordBox {
4
+ segmentId: string;
5
+ groupId: string;
6
+ groupIndex: number;
7
+ wordIndex: number;
8
+ x: number;
9
+ y: number;
10
+ width: number;
11
+ height: number;
12
+ }
13
+
14
+ export function readWordBoxes(
15
+ iframe: HTMLIFrameElement,
16
+ model: {
17
+ groupOrder: string[];
18
+ groups: Map<string, { segmentIds: string[] }>;
19
+ },
20
+ overlayEl: HTMLElement,
21
+ ): WordBox[] {
22
+ let doc: Document | null = null;
23
+ let win: Window | null = null;
24
+ try {
25
+ doc = iframe.contentDocument;
26
+ win = iframe.contentWindow;
27
+ } catch {
28
+ return [];
29
+ }
30
+ if (!doc || !win) return [];
31
+
32
+ const iframeDisplayRect = iframe.getBoundingClientRect();
33
+ const overlayRect = overlayEl.getBoundingClientRect();
34
+ const nativeW = parseFloat(iframe.style.width) || iframeDisplayRect.width;
35
+ const cssScale = iframeDisplayRect.width / nativeW;
36
+ const offsetX = iframeDisplayRect.left - overlayRect.left;
37
+ const offsetY = iframeDisplayRect.top - overlayRect.top;
38
+
39
+ const groupEls = doc.querySelectorAll<HTMLElement>(".caption-group");
40
+ const boxes: WordBox[] = [];
41
+
42
+ for (let gi = 0; gi < model.groupOrder.length; gi++) {
43
+ const groupId = model.groupOrder[gi];
44
+ const group = model.groups.get(groupId);
45
+ if (!group) continue;
46
+ const groupEl = groupEls[gi] as HTMLElement | undefined;
47
+ if (!groupEl) continue;
48
+ const computed = win.getComputedStyle(groupEl);
49
+ if (parseFloat(computed.opacity) <= 0.01 || computed.visibility === "hidden") continue;
50
+ const resolvedWordEls: HTMLElement[] = [];
51
+ for (const child of groupEl.children) {
52
+ const c = child as HTMLElement;
53
+ if (c.dataset.captionWrapper === "true") {
54
+ const inner = c.querySelector<HTMLElement>(":scope > span");
55
+ if (inner) resolvedWordEls.push(inner);
56
+ } else if (c.tagName === "SPAN") {
57
+ resolvedWordEls.push(c);
58
+ }
59
+ }
60
+ if (resolvedWordEls.length === 0 && groupEl.textContent?.trim()) {
61
+ const textNode = groupEl.childNodes[0];
62
+ if (textNode && textNode.nodeType === Node.TEXT_NODE) {
63
+ const words = (textNode.textContent || "").split(/\s+/).filter(Boolean);
64
+ const frag = doc.createDocumentFragment();
65
+ for (const word of words) {
66
+ const span = doc.createElement("span");
67
+ span.textContent = word + " ";
68
+ span.style.display = "inline";
69
+ frag.appendChild(span);
70
+ resolvedWordEls.push(span);
71
+ }
72
+ groupEl.replaceChild(frag, textNode);
73
+ } else {
74
+ const singleSpan = groupEl.querySelector<HTMLElement>(":scope > span");
75
+ if (singleSpan && singleSpan.textContent?.trim()) {
76
+ const words = singleSpan.textContent.split(/\s+/).filter(Boolean);
77
+ const frag = doc.createDocumentFragment();
78
+ for (const word of words) {
79
+ const span = doc.createElement("span");
80
+ span.textContent = word + " ";
81
+ span.style.display = "inline";
82
+ frag.appendChild(span);
83
+ resolvedWordEls.push(span);
84
+ }
85
+ singleSpan.replaceWith(frag);
86
+ }
87
+ }
88
+ }
89
+ for (let wi = 0; wi < group.segmentIds.length; wi++) {
90
+ const segId = group.segmentIds[wi];
91
+ const wordEl = resolvedWordEls[wi] as HTMLElement | undefined;
92
+ if (!wordEl) continue;
93
+ const rect = wordEl.getBoundingClientRect();
94
+ boxes.push({
95
+ segmentId: segId,
96
+ groupId,
97
+ groupIndex: gi,
98
+ wordIndex: wi,
99
+ x: rect.left * cssScale + offsetX,
100
+ y: rect.top * cssScale + offsetY,
101
+ width: rect.width * cssScale,
102
+ height: rect.height * cssScale,
103
+ });
104
+ }
105
+ }
106
+ return boxes;
107
+ }
108
+
109
+ export function getWordEl(
110
+ iframe: HTMLIFrameElement,
111
+ groupIndex: number,
112
+ wordIndex: number,
113
+ ): HTMLElement | null {
114
+ let doc: Document | null = null;
115
+ try {
116
+ doc = iframe.contentDocument;
117
+ } catch {
118
+ return null;
119
+ }
120
+ if (!doc) return null;
121
+ const groupEl = doc.querySelectorAll<HTMLElement>(".caption-group")[groupIndex];
122
+ if (!groupEl) return null;
123
+ const wordEls: HTMLElement[] = [];
124
+ for (const child of groupEl.children) {
125
+ const el = child as HTMLElement;
126
+ if (el.dataset.captionWrapper === "true") {
127
+ const inner = el.querySelector<HTMLElement>(":scope > span");
128
+ if (inner) wordEls.push(inner);
129
+ } else if (el.tagName === "SPAN") {
130
+ wordEls.push(el);
131
+ }
132
+ }
133
+ return wordEls[wordIndex] ?? null;
134
+ }
135
+
136
+ /**
137
+ * Read GSAP's internal transform state for an element.
138
+ * GSAP stores transforms in its own cache, not in el.style.transform.
139
+ */
140
+ export function readGsapTransform(
141
+ el: HTMLElement,
142
+ iframeWin: Window,
143
+ ): { x: number; y: number; scale: number; rotation: number } {
144
+ const gsap = (
145
+ iframeWin as unknown as { gsap?: { getProperty?: (el: HTMLElement, prop: string) => number } }
146
+ ).gsap;
147
+ if (gsap && gsap.getProperty) {
148
+ return {
149
+ x: gsap.getProperty(el, "x") || 0,
150
+ y: gsap.getProperty(el, "y") || 0,
151
+ scale: gsap.getProperty(el, "scale") || 1,
152
+ rotation: gsap.getProperty(el, "rotation") || 0,
153
+ };
154
+ }
155
+ const t = el.style.transform || "";
156
+ const scaleMatch = t.match(/scale\(([^)]+)\)/);
157
+ const rotMatch = t.match(/rotate\(([^)]+)deg\)/);
158
+ const txyMatch = t.match(/translate\(([^,]+)px,\s*([^)]+)px\)/);
159
+ return {
160
+ x: txyMatch ? parseFloat(txyMatch[1]) : 0,
161
+ y: txyMatch ? parseFloat(txyMatch[2]) : 0,
162
+ scale: scaleMatch ? parseFloat(scaleMatch[1]) : 1,
163
+ rotation: rotMatch ? parseFloat(rotMatch[1]) : 0,
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Get or create an inline-block wrapper span around a word element.
169
+ * Transforms are applied to the wrapper so the word's GSAP animations are preserved.
170
+ */
171
+ export function getOrCreateWrapper(el: HTMLElement): HTMLElement {
172
+ if (el.dataset.captionWrapper === "true") return el;
173
+ const parent = el.parentElement;
174
+ if (parent && parent.dataset.captionWrapper === "true") return parent;
175
+ const doc = el.ownerDocument;
176
+ const wrapper = doc.createElement("span");
177
+ wrapper.style.display = "inline-block";
178
+ wrapper.dataset.captionWrapper = "true";
179
+ el.parentNode?.insertBefore(wrapper, el);
180
+ wrapper.appendChild(el);
181
+ return wrapper;
182
+ }
183
+
184
+ /**
185
+ * Write transform values to a wrapper span around the word element.
186
+ */
187
+ export function writeTransform(
188
+ el: HTMLElement,
189
+ iframeWin: Window,
190
+ x: number,
191
+ y: number,
192
+ scale: number,
193
+ rotation: number,
194
+ ) {
195
+ const wrapper = getOrCreateWrapper(el);
196
+ const gsap = (
197
+ iframeWin as unknown as {
198
+ gsap?: { set?: (el: HTMLElement, props: Record<string, number>) => void };
199
+ }
200
+ ).gsap;
201
+ if (gsap && gsap.set) {
202
+ gsap.set(wrapper, { x, y, scale, rotation });
203
+ } else {
204
+ wrapper.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${rotation.toFixed(1)}deg) scale(${scale.toFixed(3)})`;
205
+ }
206
+ }
207
+
208
+ /** Compute style deltas from the current wrapper transform — used by syncToStore in the overlay. */
209
+ export function computeTransformStyle(el: HTMLElement, iframeWin: Window): Record<string, number> {
210
+ const wrapper = getOrCreateWrapper(el);
211
+ const { x, y, scale, rotation } = readGsapTransform(wrapper, iframeWin);
212
+ const style: Record<string, number> = {};
213
+ if (Math.abs(x) > 0.5) style.x = x;
214
+ if (Math.abs(y) > 0.5) style.y = y;
215
+ if (Math.abs(scale - 1) > 0.001) {
216
+ style.scaleX = scale;
217
+ style.scaleY = scale;
218
+ }
219
+ if (Math.abs(rotation) > 0.1) style.rotation = rotation;
220
+ return style;
221
+ }
@@ -0,0 +1,120 @@
1
+ import { useState, useRef, type CSSProperties } from "react";
2
+ import { useMountEffect } from "../hooks/useMountEffect";
3
+ import { type AgentModalAnchorPoint, clampNumber } from "../utils/studioHelpers";
4
+
5
+ function getAgentModalPositionStyle(
6
+ anchorPoint: AgentModalAnchorPoint | null,
7
+ ): CSSProperties | undefined {
8
+ if (!anchorPoint || typeof window === "undefined") return undefined;
9
+
10
+ const modalWidth = 480;
11
+ const estimatedModalHeight = 270;
12
+ const margin = 16;
13
+ const left = clampNumber(
14
+ anchorPoint.x,
15
+ margin + modalWidth / 2,
16
+ window.innerWidth - margin - modalWidth / 2,
17
+ );
18
+ const top = clampNumber(
19
+ anchorPoint.y + 12,
20
+ margin,
21
+ window.innerHeight - margin - estimatedModalHeight,
22
+ );
23
+
24
+ return { left, top, transform: "translateX(-50%)" };
25
+ }
26
+
27
+ export function AskAgentModal({
28
+ selectionLabel,
29
+ anchorPoint = null,
30
+ onSubmit,
31
+ onClose,
32
+ }: {
33
+ selectionLabel: string;
34
+ anchorPoint?: AgentModalAnchorPoint | null;
35
+ onSubmit: (instruction: string) => void;
36
+ onClose: () => void;
37
+ }) {
38
+ const [value, setValue] = useState("");
39
+ const inputRef = useRef<HTMLTextAreaElement>(null);
40
+ const modalPositionStyle = getAgentModalPositionStyle(anchorPoint);
41
+
42
+ useMountEffect(() => {
43
+ requestAnimationFrame(() => inputRef.current?.focus());
44
+ });
45
+
46
+ const handleSubmit = () => {
47
+ if (!value.trim()) return;
48
+ onSubmit(value.trim());
49
+ };
50
+
51
+ return (
52
+ <div
53
+ className={
54
+ anchorPoint
55
+ ? "fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm"
56
+ : "fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
57
+ }
58
+ onClick={onClose}
59
+ >
60
+ <div
61
+ className={`w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl ${
62
+ anchorPoint ? "fixed" : ""
63
+ }`}
64
+ style={modalPositionStyle}
65
+ onClick={(e) => e.stopPropagation()}
66
+ >
67
+ <div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
68
+ <div>
69
+ <h3 className="text-sm font-medium text-neutral-200">Ask agent</h3>
70
+ <p className="text-xs text-neutral-500 mt-0.5">
71
+ {selectionLabel.length > 50 ? `${selectionLabel.slice(0, 49)}…` : selectionLabel}
72
+ </p>
73
+ </div>
74
+ <button
75
+ className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50"
76
+ onClick={onClose}
77
+ >
78
+ <svg
79
+ width="14"
80
+ height="14"
81
+ viewBox="0 0 24 24"
82
+ fill="none"
83
+ stroke="currentColor"
84
+ strokeWidth="2"
85
+ strokeLinecap="round"
86
+ >
87
+ <line x1="18" y1="6" x2="6" y2="18" />
88
+ <line x1="6" y1="6" x2="18" y2="18" />
89
+ </svg>
90
+ </button>
91
+ </div>
92
+ <div className="px-5 py-4">
93
+ <textarea
94
+ ref={inputRef}
95
+ className="w-full h-24 px-3 py-2 rounded-lg border border-neutral-800 bg-neutral-900/60 text-sm text-neutral-200 placeholder-neutral-600 resize-none focus:outline-none focus:border-studio-accent/60 focus:ring-1 focus:ring-studio-accent/30"
96
+ placeholder="Describe what you want to change…"
97
+ value={value}
98
+ onChange={(e) => setValue(e.target.value)}
99
+ onKeyDown={(e) => {
100
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmit();
101
+ if (e.key === "Escape") onClose();
102
+ }}
103
+ />
104
+ </div>
105
+ <div className="flex items-center justify-between px-5 py-3 border-t border-neutral-800/60">
106
+ <span className="text-[11px] text-neutral-600">
107
+ {navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+Enter to copy
108
+ </span>
109
+ <button
110
+ className="px-4 py-1.5 rounded-lg bg-studio-accent/90 text-xs font-medium text-neutral-950 hover:bg-studio-accent disabled:opacity-40 disabled:cursor-not-allowed"
111
+ disabled={!value.trim()}
112
+ onClick={handleSubmit}
113
+ >
114
+ Copy prompt
115
+ </button>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ );
120
+ }