@hyperframes/studio 0.2.0 → 0.2.2-alpha.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/LICENSE +190 -21
- package/dist/assets/index-BT9D8I7B.css +1 -0
- package/dist/assets/index-DA_l-VKo.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/App.tsx +213 -8
- package/src/captions/components/CaptionAnimationPanel.tsx +269 -0
- package/src/captions/components/CaptionOverlay.tsx +622 -0
- package/src/captions/components/CaptionPropertyPanel.tsx +275 -0
- package/src/captions/components/CaptionTimeline.tsx +187 -0
- package/src/captions/components/shared.tsx +26 -0
- package/src/captions/generator.test.ts +279 -0
- package/src/captions/generator.ts +376 -0
- package/src/captions/hooks/useCaptionSync.ts +168 -0
- package/src/captions/index.ts +10 -0
- package/src/captions/parser.test.ts +377 -0
- package/src/captions/parser.ts +314 -0
- package/src/captions/store.ts +272 -0
- package/src/captions/types.ts +207 -0
- package/src/components/nle/NLELayout.tsx +1 -1
- package/dist/assets/index-Bkp9HQbo.css +0 -1
- package/dist/assets/index-DfhSlTti.js +0 -93
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
import { memo, useState, useCallback, useRef } from "react";
|
|
2
|
+
import { useCaptionStore } from "../store";
|
|
3
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
|
+
|
|
5
|
+
interface CaptionOverlayProps {
|
|
6
|
+
iframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface WordBox {
|
|
10
|
+
segmentId: string;
|
|
11
|
+
groupId: string;
|
|
12
|
+
groupIndex: number;
|
|
13
|
+
wordIndex: number;
|
|
14
|
+
x: number;
|
|
15
|
+
y: number;
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readWordBoxes(
|
|
21
|
+
iframe: HTMLIFrameElement,
|
|
22
|
+
model: {
|
|
23
|
+
groupOrder: string[];
|
|
24
|
+
groups: Map<string, { segmentIds: string[] }>;
|
|
25
|
+
},
|
|
26
|
+
overlayEl: HTMLElement,
|
|
27
|
+
): WordBox[] {
|
|
28
|
+
let doc: Document | null = null;
|
|
29
|
+
let win: Window | null = null;
|
|
30
|
+
try {
|
|
31
|
+
doc = iframe.contentDocument;
|
|
32
|
+
win = iframe.contentWindow;
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
if (!doc || !win) return [];
|
|
37
|
+
|
|
38
|
+
const iframeDisplayRect = iframe.getBoundingClientRect();
|
|
39
|
+
const overlayRect = overlayEl.getBoundingClientRect();
|
|
40
|
+
// The iframe renders at native resolution (e.g. 1920x1080) but is
|
|
41
|
+
// CSS-scaled to fit the viewport. getBoundingClientRect() on elements
|
|
42
|
+
// inside the iframe returns coordinates in the iframe's native space.
|
|
43
|
+
// Multiply by cssScale to convert to parent window coordinates.
|
|
44
|
+
const nativeW = parseFloat(iframe.style.width) || iframeDisplayRect.width;
|
|
45
|
+
const cssScale = iframeDisplayRect.width / nativeW;
|
|
46
|
+
const offsetX = iframeDisplayRect.left - overlayRect.left;
|
|
47
|
+
const offsetY = iframeDisplayRect.top - overlayRect.top;
|
|
48
|
+
|
|
49
|
+
const groupEls = doc.querySelectorAll<HTMLElement>(".caption-group");
|
|
50
|
+
const boxes: WordBox[] = [];
|
|
51
|
+
|
|
52
|
+
for (let gi = 0; gi < model.groupOrder.length; gi++) {
|
|
53
|
+
const groupId = model.groupOrder[gi];
|
|
54
|
+
const group = model.groups.get(groupId);
|
|
55
|
+
if (!group) continue;
|
|
56
|
+
const groupEl = groupEls[gi] as HTMLElement | undefined;
|
|
57
|
+
if (!groupEl) continue;
|
|
58
|
+
const computed = win.getComputedStyle(groupEl);
|
|
59
|
+
if (parseFloat(computed.opacity) <= 0.01 || computed.visibility === "hidden") continue;
|
|
60
|
+
// Find word elements — handles both per-word spans (generator output)
|
|
61
|
+
// and grouped text nodes (existing caption templates that use
|
|
62
|
+
// el.textContent = line.text instead of individual word spans).
|
|
63
|
+
const resolvedWordEls: HTMLElement[] = [];
|
|
64
|
+
for (const child of groupEl.children) {
|
|
65
|
+
const c = child as HTMLElement;
|
|
66
|
+
if (c.dataset.captionWrapper === "true") {
|
|
67
|
+
const inner = c.querySelector<HTMLElement>(":scope > span");
|
|
68
|
+
if (inner) resolvedWordEls.push(inner);
|
|
69
|
+
} else if (c.tagName === "SPAN") {
|
|
70
|
+
resolvedWordEls.push(c);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Fallback: if no word spans found but group has text content,
|
|
74
|
+
// the template uses grouped text. Wrap each word in a span so
|
|
75
|
+
// the overlay can target them individually.
|
|
76
|
+
if (resolvedWordEls.length === 0 && groupEl.textContent?.trim()) {
|
|
77
|
+
const textNode = groupEl.childNodes[0];
|
|
78
|
+
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
|
79
|
+
const words = (textNode.textContent || "").split(/\s+/).filter(Boolean);
|
|
80
|
+
const frag = doc.createDocumentFragment();
|
|
81
|
+
for (const word of words) {
|
|
82
|
+
const span = doc.createElement("span");
|
|
83
|
+
span.textContent = word + " ";
|
|
84
|
+
span.style.display = "inline";
|
|
85
|
+
frag.appendChild(span);
|
|
86
|
+
resolvedWordEls.push(span);
|
|
87
|
+
}
|
|
88
|
+
groupEl.replaceChild(frag, textNode);
|
|
89
|
+
} else {
|
|
90
|
+
// Single span child with all text (e.g. vignelli template)
|
|
91
|
+
const singleSpan = groupEl.querySelector<HTMLElement>(":scope > span");
|
|
92
|
+
if (singleSpan && singleSpan.textContent?.trim()) {
|
|
93
|
+
const words = singleSpan.textContent.split(/\s+/).filter(Boolean);
|
|
94
|
+
const frag = doc.createDocumentFragment();
|
|
95
|
+
for (const word of words) {
|
|
96
|
+
const span = doc.createElement("span");
|
|
97
|
+
span.textContent = word + " ";
|
|
98
|
+
span.style.display = "inline";
|
|
99
|
+
frag.appendChild(span);
|
|
100
|
+
resolvedWordEls.push(span);
|
|
101
|
+
}
|
|
102
|
+
singleSpan.replaceWith(frag);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (let wi = 0; wi < group.segmentIds.length; wi++) {
|
|
107
|
+
const segId = group.segmentIds[wi];
|
|
108
|
+
const wordEl = resolvedWordEls[wi] as HTMLElement | undefined;
|
|
109
|
+
if (!wordEl) continue;
|
|
110
|
+
const rect = wordEl.getBoundingClientRect();
|
|
111
|
+
boxes.push({
|
|
112
|
+
segmentId: segId,
|
|
113
|
+
groupId,
|
|
114
|
+
groupIndex: gi,
|
|
115
|
+
wordIndex: wi,
|
|
116
|
+
x: rect.left * cssScale + offsetX,
|
|
117
|
+
y: rect.top * cssScale + offsetY,
|
|
118
|
+
width: rect.width * cssScale,
|
|
119
|
+
height: rect.height * cssScale,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return boxes;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function getWordEl(
|
|
127
|
+
iframe: HTMLIFrameElement,
|
|
128
|
+
groupIndex: number,
|
|
129
|
+
wordIndex: number,
|
|
130
|
+
): HTMLElement | null {
|
|
131
|
+
let doc: Document | null = null;
|
|
132
|
+
try {
|
|
133
|
+
doc = iframe.contentDocument;
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (!doc) return null;
|
|
138
|
+
const groupEl = doc.querySelectorAll<HTMLElement>(".caption-group")[groupIndex];
|
|
139
|
+
if (!groupEl) return null;
|
|
140
|
+
// Find word spans — they may be direct children or inside wrapper spans.
|
|
141
|
+
// Word spans have class "word" or an id starting with "w".
|
|
142
|
+
// Wrappers have data-caption-wrapper="true".
|
|
143
|
+
const wordEls: HTMLElement[] = [];
|
|
144
|
+
for (const child of groupEl.children) {
|
|
145
|
+
const el = child as HTMLElement;
|
|
146
|
+
if (el.dataset.captionWrapper === "true") {
|
|
147
|
+
// Wrapped word — get the inner span
|
|
148
|
+
const inner = el.querySelector<HTMLElement>(":scope > span");
|
|
149
|
+
if (inner) wordEls.push(inner);
|
|
150
|
+
} else if (el.tagName === "SPAN") {
|
|
151
|
+
wordEls.push(el);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return wordEls[wordIndex] ?? null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Read GSAP's internal transform state for an element.
|
|
159
|
+
* GSAP stores transforms in its own cache, not in el.style.transform.
|
|
160
|
+
*/
|
|
161
|
+
function readGsapTransform(
|
|
162
|
+
el: HTMLElement,
|
|
163
|
+
iframeWin: Window,
|
|
164
|
+
): { x: number; y: number; scale: number; rotation: number } {
|
|
165
|
+
const gsap = (
|
|
166
|
+
iframeWin as unknown as { gsap?: { getProperty?: (el: HTMLElement, prop: string) => number } }
|
|
167
|
+
).gsap;
|
|
168
|
+
if (gsap && gsap.getProperty) {
|
|
169
|
+
return {
|
|
170
|
+
x: gsap.getProperty(el, "x") || 0,
|
|
171
|
+
y: gsap.getProperty(el, "y") || 0,
|
|
172
|
+
scale: gsap.getProperty(el, "scale") || 1,
|
|
173
|
+
rotation: gsap.getProperty(el, "rotation") || 0,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
// Fallback: parse from style
|
|
177
|
+
const t = el.style.transform || "";
|
|
178
|
+
const scaleMatch = t.match(/scale\(([^)]+)\)/);
|
|
179
|
+
const rotMatch = t.match(/rotate\(([^)]+)deg\)/);
|
|
180
|
+
const txyMatch = t.match(/translate\(([^,]+)px,\s*([^)]+)px\)/);
|
|
181
|
+
return {
|
|
182
|
+
x: txyMatch ? parseFloat(txyMatch[1]) : 0,
|
|
183
|
+
y: txyMatch ? parseFloat(txyMatch[2]) : 0,
|
|
184
|
+
scale: scaleMatch ? parseFloat(scaleMatch[1]) : 1,
|
|
185
|
+
rotation: rotMatch ? parseFloat(rotMatch[1]) : 0,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Get or create an inline-block wrapper span around a word element.
|
|
191
|
+
* Transforms are applied to the wrapper so the word's GSAP animations are preserved.
|
|
192
|
+
*/
|
|
193
|
+
function getOrCreateWrapper(el: HTMLElement): HTMLElement {
|
|
194
|
+
// If el IS a wrapper, return it
|
|
195
|
+
if (el.dataset.captionWrapper === "true") return el;
|
|
196
|
+
// If el's parent is a wrapper, return the parent
|
|
197
|
+
const parent = el.parentElement;
|
|
198
|
+
if (parent && parent.dataset.captionWrapper === "true") return parent;
|
|
199
|
+
// Create new wrapper
|
|
200
|
+
const doc = el.ownerDocument;
|
|
201
|
+
const wrapper = doc.createElement("span");
|
|
202
|
+
wrapper.style.display = "inline-block";
|
|
203
|
+
wrapper.dataset.captionWrapper = "true";
|
|
204
|
+
el.parentNode?.insertBefore(wrapper, el);
|
|
205
|
+
wrapper.appendChild(el);
|
|
206
|
+
return wrapper;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Write transform values to a wrapper span around the word element.
|
|
211
|
+
* The word keeps its GSAP animations; the wrapper handles editor transforms.
|
|
212
|
+
*/
|
|
213
|
+
function writeTransform(
|
|
214
|
+
el: HTMLElement,
|
|
215
|
+
iframeWin: Window,
|
|
216
|
+
x: number,
|
|
217
|
+
y: number,
|
|
218
|
+
scale: number,
|
|
219
|
+
rotation: number,
|
|
220
|
+
) {
|
|
221
|
+
const wrapper = getOrCreateWrapper(el);
|
|
222
|
+
const gsap = (
|
|
223
|
+
iframeWin as unknown as {
|
|
224
|
+
gsap?: { set?: (el: HTMLElement, props: Record<string, number>) => void };
|
|
225
|
+
}
|
|
226
|
+
).gsap;
|
|
227
|
+
if (gsap && gsap.set) {
|
|
228
|
+
gsap.set(wrapper, { x, y, scale, rotation });
|
|
229
|
+
} else {
|
|
230
|
+
wrapper.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${rotation.toFixed(1)}deg) scale(${scale.toFixed(3)})`;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Sync canvas state back to the Zustand store so the property panel reflects it.
|
|
235
|
+
* Only writes non-default values to avoid creating spurious overrides. */
|
|
236
|
+
function syncToStore(segmentId: string, el: HTMLElement, iframeWin: Window) {
|
|
237
|
+
const wrapper = getOrCreateWrapper(el);
|
|
238
|
+
const { x, y, scale, rotation } = readGsapTransform(wrapper, iframeWin);
|
|
239
|
+
const style: Record<string, number> = {};
|
|
240
|
+
if (Math.abs(x) > 0.5) style.x = x;
|
|
241
|
+
if (Math.abs(y) > 0.5) style.y = y;
|
|
242
|
+
if (Math.abs(scale - 1) > 0.001) {
|
|
243
|
+
style.scaleX = scale;
|
|
244
|
+
style.scaleY = scale;
|
|
245
|
+
}
|
|
246
|
+
if (Math.abs(rotation) > 0.1) style.rotation = rotation;
|
|
247
|
+
if (Object.keys(style).length > 0) {
|
|
248
|
+
useCaptionStore.getState().updateSegmentStyle(segmentId, style);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const HANDLE = 8;
|
|
253
|
+
const ROTATION_OFFSET = 20; // px above the selection box
|
|
254
|
+
|
|
255
|
+
export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: CaptionOverlayProps) {
|
|
256
|
+
const isEditMode = useCaptionStore((s) => s.isEditMode);
|
|
257
|
+
const model = useCaptionStore((s) => s.model);
|
|
258
|
+
const selectedSegmentIds = useCaptionStore((s) => s.selectedSegmentIds);
|
|
259
|
+
const selectSegment = useCaptionStore((s) => s.selectSegment);
|
|
260
|
+
const clearSelection = useCaptionStore((s) => s.clearSelection);
|
|
261
|
+
|
|
262
|
+
const [wordBoxes, setWordBoxes] = useState<WordBox[]>([]);
|
|
263
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
264
|
+
const modelRef = useRef(model);
|
|
265
|
+
modelRef.current = model;
|
|
266
|
+
|
|
267
|
+
// Interaction mode — only one active at a time
|
|
268
|
+
const interactionRef = useRef<
|
|
269
|
+
| {
|
|
270
|
+
type: "move";
|
|
271
|
+
wordEl: HTMLElement;
|
|
272
|
+
segmentId: string;
|
|
273
|
+
startMX: number;
|
|
274
|
+
startMY: number;
|
|
275
|
+
origTX: number;
|
|
276
|
+
origTY: number;
|
|
277
|
+
origScale: number;
|
|
278
|
+
origRotation: number;
|
|
279
|
+
}
|
|
280
|
+
| {
|
|
281
|
+
type: "scale";
|
|
282
|
+
wordEl: HTMLElement;
|
|
283
|
+
segmentId: string;
|
|
284
|
+
startMX: number;
|
|
285
|
+
startDxFromCenter: number;
|
|
286
|
+
origTX: number;
|
|
287
|
+
origTY: number;
|
|
288
|
+
origScale: number;
|
|
289
|
+
origRotation: number;
|
|
290
|
+
}
|
|
291
|
+
| {
|
|
292
|
+
type: "rotate";
|
|
293
|
+
wordEl: HTMLElement;
|
|
294
|
+
segmentId: string;
|
|
295
|
+
startMX: number;
|
|
296
|
+
origTX: number;
|
|
297
|
+
origTY: number;
|
|
298
|
+
origRotation: number;
|
|
299
|
+
origScale: number;
|
|
300
|
+
}
|
|
301
|
+
| null
|
|
302
|
+
>(null);
|
|
303
|
+
|
|
304
|
+
useMountEffect(() => {
|
|
305
|
+
if (!isEditMode) return;
|
|
306
|
+
let prevBoxes: WordBox[] = [];
|
|
307
|
+
const tick = () => {
|
|
308
|
+
const iframe = iframeRef.current;
|
|
309
|
+
const m = modelRef.current;
|
|
310
|
+
const overlay = overlayRef.current;
|
|
311
|
+
if (!iframe || !m || !overlay) return;
|
|
312
|
+
const next = readWordBoxes(iframe, m, overlay);
|
|
313
|
+
// Skip state update if nothing changed (avoids re-render every 66ms)
|
|
314
|
+
if (
|
|
315
|
+
next.length === prevBoxes.length &&
|
|
316
|
+
next.every(
|
|
317
|
+
(b, i) => Math.abs(b.x - prevBoxes[i].x) < 0.5 && Math.abs(b.y - prevBoxes[i].y) < 0.5,
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
return;
|
|
321
|
+
prevBoxes = next;
|
|
322
|
+
setWordBoxes(next);
|
|
323
|
+
};
|
|
324
|
+
const id = setInterval(tick, 66);
|
|
325
|
+
tick();
|
|
326
|
+
|
|
327
|
+
// Arrow key nudge for selected words
|
|
328
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
329
|
+
const { selectedSegmentIds: sel, model: m } = useCaptionStore.getState();
|
|
330
|
+
if (sel.size === 0 || !m) return;
|
|
331
|
+
const arrow = e.key;
|
|
332
|
+
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(arrow)) return;
|
|
333
|
+
|
|
334
|
+
e.preventDefault();
|
|
335
|
+
const step = e.shiftKey ? 10 : 1;
|
|
336
|
+
const dx = arrow === "ArrowLeft" ? -step : arrow === "ArrowRight" ? step : 0;
|
|
337
|
+
const dy = arrow === "ArrowUp" ? -step : arrow === "ArrowDown" ? step : 0;
|
|
338
|
+
|
|
339
|
+
const iframe = iframeRef.current;
|
|
340
|
+
const win = iframe?.contentWindow;
|
|
341
|
+
if (!iframe || !win) return;
|
|
342
|
+
|
|
343
|
+
for (const segId of sel) {
|
|
344
|
+
// Find group/word index for this segment
|
|
345
|
+
for (let gi = 0; gi < m.groupOrder.length; gi++) {
|
|
346
|
+
const group = m.groups.get(m.groupOrder[gi]);
|
|
347
|
+
if (!group) continue;
|
|
348
|
+
const wi = group.segmentIds.indexOf(segId);
|
|
349
|
+
if (wi < 0) continue;
|
|
350
|
+
const wordEl = getWordEl(iframe, gi, wi);
|
|
351
|
+
if (!wordEl) continue;
|
|
352
|
+
const wrapper = getOrCreateWrapper(wordEl);
|
|
353
|
+
const state = readGsapTransform(wrapper, win);
|
|
354
|
+
writeTransform(wordEl, win, state.x + dx, state.y + dy, state.scale, state.rotation);
|
|
355
|
+
syncToStore(segId, wordEl, win);
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
362
|
+
return () => {
|
|
363
|
+
clearInterval(id);
|
|
364
|
+
window.removeEventListener("keydown", handleKeyDown);
|
|
365
|
+
};
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const getCssScale = useCallback(() => {
|
|
369
|
+
const iframe = iframeRef.current;
|
|
370
|
+
if (!iframe) return 1;
|
|
371
|
+
const rect = iframe.getBoundingClientRect();
|
|
372
|
+
const nativeW = parseFloat(iframe.style.width) || rect.width;
|
|
373
|
+
return rect.width / nativeW;
|
|
374
|
+
}, [iframeRef]);
|
|
375
|
+
|
|
376
|
+
// --- Move ---
|
|
377
|
+
const startMove = useCallback(
|
|
378
|
+
(groupIndex: number, wordIndex: number, segmentId: string, e: React.PointerEvent) => {
|
|
379
|
+
e.stopPropagation();
|
|
380
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
381
|
+
const iframe = iframeRef.current;
|
|
382
|
+
if (!iframe) return;
|
|
383
|
+
const wordEl = getWordEl(iframe, groupIndex, wordIndex);
|
|
384
|
+
const win = iframe.contentWindow;
|
|
385
|
+
if (!wordEl || !win) return;
|
|
386
|
+
const state = readGsapTransform(getOrCreateWrapper(wordEl), win);
|
|
387
|
+
interactionRef.current = {
|
|
388
|
+
type: "move",
|
|
389
|
+
wordEl,
|
|
390
|
+
segmentId,
|
|
391
|
+
startMX: e.clientX,
|
|
392
|
+
startMY: e.clientY,
|
|
393
|
+
origTX: state.x,
|
|
394
|
+
origTY: state.y,
|
|
395
|
+
origScale: state.scale,
|
|
396
|
+
origRotation: state.rotation,
|
|
397
|
+
};
|
|
398
|
+
},
|
|
399
|
+
[iframeRef],
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
// --- Scale ---
|
|
403
|
+
const startScale = useCallback(
|
|
404
|
+
(groupIndex: number, wordIndex: number, segmentId: string, e: React.PointerEvent) => {
|
|
405
|
+
e.stopPropagation();
|
|
406
|
+
e.preventDefault();
|
|
407
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
408
|
+
const iframe = iframeRef.current;
|
|
409
|
+
if (!iframe) return;
|
|
410
|
+
const wordEl = getWordEl(iframe, groupIndex, wordIndex);
|
|
411
|
+
const win = iframe.contentWindow;
|
|
412
|
+
if (!wordEl || !win) return;
|
|
413
|
+
const rect = wordEl.getBoundingClientRect();
|
|
414
|
+
const cssScale = getCssScale();
|
|
415
|
+
const boxCenterX =
|
|
416
|
+
rect.left * cssScale +
|
|
417
|
+
(iframeRef.current?.getBoundingClientRect().left ?? 0) +
|
|
418
|
+
(rect.width * cssScale) / 2;
|
|
419
|
+
const state = readGsapTransform(getOrCreateWrapper(wordEl), win);
|
|
420
|
+
interactionRef.current = {
|
|
421
|
+
type: "scale",
|
|
422
|
+
wordEl,
|
|
423
|
+
segmentId,
|
|
424
|
+
startMX: e.clientX,
|
|
425
|
+
startDxFromCenter: e.clientX - boxCenterX,
|
|
426
|
+
origTX: state.x,
|
|
427
|
+
origTY: state.y,
|
|
428
|
+
origScale: state.scale,
|
|
429
|
+
origRotation: state.rotation,
|
|
430
|
+
};
|
|
431
|
+
},
|
|
432
|
+
[iframeRef, getCssScale],
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
// --- Rotate ---
|
|
436
|
+
const startRotate = useCallback(
|
|
437
|
+
(box: WordBox, e: React.PointerEvent) => {
|
|
438
|
+
e.stopPropagation();
|
|
439
|
+
e.preventDefault();
|
|
440
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
441
|
+
const iframe = iframeRef.current;
|
|
442
|
+
if (!iframe) return;
|
|
443
|
+
const wordEl = getWordEl(iframe, box.groupIndex, box.wordIndex);
|
|
444
|
+
const win = iframe.contentWindow;
|
|
445
|
+
if (!wordEl || !win) return;
|
|
446
|
+
const state = readGsapTransform(getOrCreateWrapper(wordEl), win);
|
|
447
|
+
interactionRef.current = {
|
|
448
|
+
type: "rotate",
|
|
449
|
+
wordEl,
|
|
450
|
+
segmentId: box.segmentId,
|
|
451
|
+
startMX: e.clientX,
|
|
452
|
+
origTX: state.x,
|
|
453
|
+
origTY: state.y,
|
|
454
|
+
origRotation: state.rotation,
|
|
455
|
+
origScale: state.scale,
|
|
456
|
+
};
|
|
457
|
+
},
|
|
458
|
+
[iframeRef],
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
/** Get iframe contentWindow, needed for gsap calls */
|
|
462
|
+
const getIframeWin = useCallback((): Window | null => {
|
|
463
|
+
try {
|
|
464
|
+
return iframeRef.current?.contentWindow ?? null;
|
|
465
|
+
} catch {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
}, [iframeRef]);
|
|
469
|
+
|
|
470
|
+
// --- Unified pointer move ---
|
|
471
|
+
const handlePointerMove = useCallback(
|
|
472
|
+
(e: React.PointerEvent) => {
|
|
473
|
+
const i = interactionRef.current;
|
|
474
|
+
if (!i) return;
|
|
475
|
+
const win = getIframeWin();
|
|
476
|
+
if (!win) return;
|
|
477
|
+
|
|
478
|
+
if (i.type === "move") {
|
|
479
|
+
const cssScale = getCssScale();
|
|
480
|
+
const dx = (e.clientX - i.startMX) / cssScale;
|
|
481
|
+
const dy = (e.clientY - i.startMY) / cssScale;
|
|
482
|
+
writeTransform(i.wordEl, win, i.origTX + dx, i.origTY + dy, i.origScale, i.origRotation);
|
|
483
|
+
} else if (i.type === "scale") {
|
|
484
|
+
// Use distance from box center so dragging outward from ANY corner
|
|
485
|
+
// increases scale (not just right-side handles).
|
|
486
|
+
const cx = i.startMX - i.startDxFromCenter;
|
|
487
|
+
const startDist = Math.abs(i.startDxFromCenter);
|
|
488
|
+
const currentDist = Math.abs(e.clientX - cx);
|
|
489
|
+
const factor = startDist > 5 ? currentDist / startDist : 1;
|
|
490
|
+
const newScale = Math.max(0.1, i.origScale * factor);
|
|
491
|
+
writeTransform(i.wordEl, win, i.origTX, i.origTY, newScale, i.origRotation);
|
|
492
|
+
} else if (i.type === "rotate") {
|
|
493
|
+
// Horizontal drag maps to rotation: right = clockwise, left = counter-clockwise.
|
|
494
|
+
// 200px of horizontal movement = 90 degrees.
|
|
495
|
+
const dx = e.clientX - i.startMX;
|
|
496
|
+
const delta = (dx / 200) * 90;
|
|
497
|
+
writeTransform(i.wordEl, win, i.origTX, i.origTY, i.origScale, i.origRotation + delta);
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
[getCssScale, getIframeWin],
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
// --- Unified pointer up — sync back to store ---
|
|
504
|
+
const handlePointerUp = useCallback(() => {
|
|
505
|
+
const i = interactionRef.current;
|
|
506
|
+
if (i) {
|
|
507
|
+
const win = getIframeWin();
|
|
508
|
+
if (win) syncToStore(i.segmentId, i.wordEl, win);
|
|
509
|
+
interactionRef.current = null;
|
|
510
|
+
}
|
|
511
|
+
}, [getIframeWin]);
|
|
512
|
+
|
|
513
|
+
const handleBackgroundClick = useCallback(
|
|
514
|
+
(e: React.MouseEvent) => {
|
|
515
|
+
if (e.target === e.currentTarget) clearSelection();
|
|
516
|
+
},
|
|
517
|
+
[clearSelection],
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
if (!isEditMode) return null;
|
|
521
|
+
|
|
522
|
+
return (
|
|
523
|
+
<div
|
|
524
|
+
ref={overlayRef}
|
|
525
|
+
className="absolute inset-0 z-50"
|
|
526
|
+
style={{ pointerEvents: "auto" }}
|
|
527
|
+
onClick={handleBackgroundClick}
|
|
528
|
+
onPointerMove={handlePointerMove}
|
|
529
|
+
onPointerUp={handlePointerUp}
|
|
530
|
+
onLostPointerCapture={handlePointerUp}
|
|
531
|
+
>
|
|
532
|
+
{wordBoxes.map((box) => {
|
|
533
|
+
const isSelected = selectedSegmentIds.has(box.segmentId);
|
|
534
|
+
return (
|
|
535
|
+
<div
|
|
536
|
+
key={box.segmentId}
|
|
537
|
+
className={[
|
|
538
|
+
"absolute",
|
|
539
|
+
isSelected ? "ring-2 ring-studio-accent" : "hover:ring-1 hover:ring-white/30",
|
|
540
|
+
].join(" ")}
|
|
541
|
+
style={{
|
|
542
|
+
left: box.x,
|
|
543
|
+
top: box.y,
|
|
544
|
+
width: box.width,
|
|
545
|
+
height: box.height,
|
|
546
|
+
cursor: isSelected ? "move" : "pointer",
|
|
547
|
+
touchAction: "none",
|
|
548
|
+
borderRadius: 2,
|
|
549
|
+
}}
|
|
550
|
+
onClick={(e) => {
|
|
551
|
+
e.stopPropagation();
|
|
552
|
+
selectSegment(box.segmentId, e.shiftKey);
|
|
553
|
+
}}
|
|
554
|
+
onPointerDown={(e) => {
|
|
555
|
+
if (isSelected) startMove(box.groupIndex, box.wordIndex, box.segmentId, e);
|
|
556
|
+
}}
|
|
557
|
+
>
|
|
558
|
+
{isSelected && (
|
|
559
|
+
<>
|
|
560
|
+
{/* Rotation handle — circle above the box */}
|
|
561
|
+
<div
|
|
562
|
+
style={{
|
|
563
|
+
position: "absolute",
|
|
564
|
+
left: "50%",
|
|
565
|
+
top: -ROTATION_OFFSET - HANDLE,
|
|
566
|
+
marginLeft: -HANDLE / 2,
|
|
567
|
+
width: HANDLE,
|
|
568
|
+
height: HANDLE,
|
|
569
|
+
borderRadius: "50%",
|
|
570
|
+
backgroundColor: "var(--hf-accent, #3CE6AC)",
|
|
571
|
+
border: "1px solid rgba(0,0,0,0.5)",
|
|
572
|
+
cursor: "grab",
|
|
573
|
+
touchAction: "none",
|
|
574
|
+
}}
|
|
575
|
+
onPointerDown={(e) => startRotate(box, e)}
|
|
576
|
+
/>
|
|
577
|
+
{/* Line from box to rotation handle */}
|
|
578
|
+
<div
|
|
579
|
+
style={{
|
|
580
|
+
position: "absolute",
|
|
581
|
+
left: "50%",
|
|
582
|
+
top: -ROTATION_OFFSET,
|
|
583
|
+
width: 1,
|
|
584
|
+
height: ROTATION_OFFSET,
|
|
585
|
+
marginLeft: -0.5,
|
|
586
|
+
backgroundColor: "var(--hf-accent, #3CE6AC)",
|
|
587
|
+
opacity: 0.5,
|
|
588
|
+
pointerEvents: "none",
|
|
589
|
+
}}
|
|
590
|
+
/>
|
|
591
|
+
{/* Scale handles — four corners */}
|
|
592
|
+
{[
|
|
593
|
+
{ right: -HANDLE / 2, bottom: -HANDLE / 2, cursor: "nwse-resize" },
|
|
594
|
+
{ left: -HANDLE / 2, top: -HANDLE / 2, cursor: "nwse-resize" },
|
|
595
|
+
{ right: -HANDLE / 2, top: -HANDLE / 2, cursor: "nesw-resize" },
|
|
596
|
+
{ left: -HANDLE / 2, bottom: -HANDLE / 2, cursor: "nesw-resize" },
|
|
597
|
+
].map((pos, idx) => (
|
|
598
|
+
<div
|
|
599
|
+
key={idx}
|
|
600
|
+
style={{
|
|
601
|
+
position: "absolute",
|
|
602
|
+
...pos,
|
|
603
|
+
width: HANDLE,
|
|
604
|
+
height: HANDLE,
|
|
605
|
+
backgroundColor: "var(--hf-accent, #3CE6AC)",
|
|
606
|
+
border: "1px solid rgba(0,0,0,0.5)",
|
|
607
|
+
borderRadius: 2,
|
|
608
|
+
touchAction: "none",
|
|
609
|
+
}}
|
|
610
|
+
onPointerDown={(e) =>
|
|
611
|
+
startScale(box.groupIndex, box.wordIndex, box.segmentId, e)
|
|
612
|
+
}
|
|
613
|
+
/>
|
|
614
|
+
))}
|
|
615
|
+
</>
|
|
616
|
+
)}
|
|
617
|
+
</div>
|
|
618
|
+
);
|
|
619
|
+
})}
|
|
620
|
+
</div>
|
|
621
|
+
);
|
|
622
|
+
});
|