@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.
@@ -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
+ });