@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.
- package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
- package/dist/assets/index-D1JDq7Gg.css +1 -0
- package/dist/assets/index-hYc4aP7M.js +117 -0
- package/dist/favicon.svg +14 -0
- package/dist/index.html +3 -2
- package/package.json +9 -9
- package/src/App.tsx +421 -4303
- package/src/captions/components/CaptionOverlay.tsx +13 -246
- package/src/captions/components/CaptionOverlayUtils.ts +221 -0
- package/src/components/AskAgentModal.tsx +120 -0
- package/src/components/StudioHeader.tsx +133 -0
- package/src/components/StudioLeftSidebar.tsx +125 -0
- package/src/components/StudioPreviewArea.tsx +167 -0
- package/src/components/StudioRightPanel.tsx +198 -0
- package/src/components/TimelineToolbar.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +88 -993
- package/src/components/editor/EaseCurveEditor.tsx +221 -0
- package/src/components/editor/FileTree.tsx +13 -621
- package/src/components/editor/FileTreeIcons.tsx +128 -0
- package/src/components/editor/FileTreeNodes.tsx +496 -0
- package/src/components/editor/MotionPanel.tsx +16 -390
- package/src/components/editor/MotionPanelFields.tsx +185 -0
- package/src/components/editor/PropertyPanel.test.ts +0 -49
- package/src/components/editor/PropertyPanel.tsx +132 -2763
- package/src/components/editor/domEditOverlayGeometry.ts +211 -0
- package/src/components/editor/domEditOverlayGestures.ts +138 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
- package/src/components/editor/domEditing.ts +44 -1117
- package/src/components/editor/domEditingAgentPrompt.ts +97 -0
- package/src/components/editor/domEditingDom.ts +266 -0
- package/src/components/editor/domEditingElement.ts +329 -0
- package/src/components/editor/domEditingLayers.ts +460 -0
- package/src/components/editor/domEditingTypes.ts +125 -0
- package/src/components/editor/manualEditingAvailability.test.ts +2 -2
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +84 -1049
- package/src/components/editor/manualEditsDom.ts +436 -0
- package/src/components/editor/manualEditsParsing.ts +280 -0
- package/src/components/editor/manualEditsSnapshot.ts +333 -0
- package/src/components/editor/manualEditsTypes.ts +141 -0
- package/src/components/editor/propertyPanelColor.tsx +371 -0
- package/src/components/editor/propertyPanelFill.tsx +421 -0
- package/src/components/editor/propertyPanelFont.tsx +455 -0
- package/src/components/editor/propertyPanelHelpers.ts +401 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
- package/src/components/editor/propertyPanelSections.tsx +453 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
- package/src/components/editor/studioMotion.ts +47 -434
- package/src/components/editor/studioMotionOps.ts +299 -0
- package/src/components/editor/studioMotionTypes.ts +168 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
- package/src/components/editor/useDomEditOverlayRects.ts +207 -0
- package/src/components/nle/NLELayout.tsx +68 -155
- package/src/components/nle/NLEPreview.tsx +3 -0
- package/src/components/nle/useCompositionStack.ts +126 -0
- package/src/components/renders/RenderQueue.tsx +102 -31
- package/src/components/renders/useRenderQueue.ts +8 -2
- package/src/components/sidebar/LeftSidebar.tsx +186 -186
- package/src/contexts/DomEditContext.tsx +137 -0
- package/src/contexts/FileManagerContext.tsx +110 -0
- package/src/contexts/PanelLayoutContext.tsx +68 -0
- package/src/contexts/StudioContext.tsx +135 -0
- package/src/hooks/useAppHotkeys.ts +326 -0
- package/src/hooks/useAskAgentModal.ts +162 -0
- package/src/hooks/useCaptionDetection.ts +132 -0
- package/src/hooks/useCompositionDimensions.ts +25 -0
- package/src/hooks/useConsoleErrorCapture.ts +60 -0
- package/src/hooks/useDomEditCommits.ts +437 -0
- package/src/hooks/useDomEditSession.ts +342 -0
- package/src/hooks/useDomEditTextCommits.ts +330 -0
- package/src/hooks/useDomSelection.ts +398 -0
- package/src/hooks/useFileManager.ts +431 -0
- package/src/hooks/useFrameCapture.ts +77 -0
- package/src/hooks/useLintModal.ts +35 -0
- package/src/hooks/useManifestPersistence.ts +492 -0
- package/src/hooks/usePanelLayout.ts +68 -0
- package/src/hooks/usePreviewInteraction.ts +153 -0
- package/src/hooks/useRenderClipContent.ts +124 -0
- package/src/hooks/useTimelineEditing.ts +472 -0
- package/src/hooks/useToast.ts +20 -0
- package/src/player/components/Player.tsx +33 -2
- package/src/player/components/Timeline.test.ts +0 -8
- package/src/player/components/Timeline.tsx +196 -1518
- package/src/player/components/TimelineCanvas.tsx +434 -0
- package/src/player/components/TimelineClip.tsx +9 -244
- package/src/player/components/TimelineEmptyState.tsx +102 -0
- package/src/player/components/TimelineRuler.tsx +90 -0
- package/src/player/components/timelineIcons.tsx +49 -0
- package/src/player/components/timelineLayout.ts +215 -0
- package/src/player/components/timelineUtils.ts +211 -0
- package/src/player/components/useTimelineClipDrag.ts +388 -0
- package/src/player/components/useTimelinePlayhead.ts +200 -0
- package/src/player/components/useTimelineRangeSelection.ts +135 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
- package/src/player/hooks/useTimelinePlayer.ts +105 -1371
- package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
- package/src/player/lib/playbackAdapter.ts +145 -0
- package/src/player/lib/playbackShortcuts.ts +68 -0
- package/src/player/lib/playbackTypes.ts +60 -0
- package/src/player/lib/timelineDOM.ts +373 -0
- package/src/player/lib/timelineElementHelpers.ts +303 -0
- package/src/player/lib/timelineIframeHelpers.ts +269 -0
- package/src/utils/domEditHelpers.ts +50 -0
- package/src/utils/studioFontHelpers.ts +83 -0
- package/src/utils/studioHelpers.ts +214 -0
- package/src/utils/studioPreviewHelpers.ts +185 -0
- package/src/utils/timelineDiscovery.ts +1 -1
- package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
- package/dist/assets/index-14zH9lqh.css +0 -1
- package/dist/assets/index-DYCiFGWQ.js +0 -108
- 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
|
-
|
|
11
|
-
|
|
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
|
|
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
|
+
}
|