mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
fix(ci): scope LOC check to studio, split useTimelinePlayer + hyperframes-player under 500 LOC (#750)
* refactor: split useTimelinePlayer.ts and hyperframes-player.ts into focused modules (<500 LOC each) * fix(ci): scope 500 LOC check to packages/studio, add allowlist for grandfathered files * feat(cli): Linux ARM64 support — auto-install Chromium on DGX Spark / GB10 / Jetson Chrome Headless Shell has no Linux ARM64 binary. On arm64 Linux: - Detects the platform automatically - Tries to auto-install system Chromium via apt-get (works on Ubuntu/Debian ARM) - Falls back to clear manual instructions with exact commands - 'hyperframes browser ensure' guides through the setup interactively - After setup, all render commands work without any flags * fix(ci): disable Windows Defender real-time monitoring to prevent EPERM builds Path exclusions are insufficient — Defender re-scans new files created during bun install before the exclusion takes effect. Disable real-time monitoring for the entire job duration instead (standard CI practice). * refactor(studio): split all files >500 LOC + extract useToast, delete allowlist All 11 large files split into focused modules under 500 LOC. App.tsx extracted toast logic into useToast hook (493 LOC now). .filesize-allowlist deleted — no longer needed. * fix: remove unused imports from split files, extract useToast from App.tsx App.tsx: 504 → 493 lines (toast logic extracted to useToast hook) timelineDOM.ts: remove unused imports from re-export pattern MotionPanel.tsx: remove unused clampStudioCustomEasePoints import studioMotionOps.ts: remove unused StudioGsapMotionDirection import * fix(ci): use Set-MpPreference to fully disable Windows Defender (both jobs) * fix(producer): use node --experimental-strip-types instead of tsx for build:fonts Eliminates the tsx binary dependency that Windows Defender locks during bun install, causing EPERM errors. Node 22.6+ strips TypeScript types natively with no external binary. * chore: remove .filesize-allowlist — App.tsx is now 493 lines (<500) * fix(ci): disable Windows Defender before checkout to prevent all EPERM races * fix(producer): skip build:fonts if fontData.generated.ts already exists The generated file is tracked in git, so CI doesn't need to regenerate it. This avoids @fontsource/inter node_modules access on Windows which triggers EPERM from Defender scanning during bun install.
This commit is contained in:
@@ -2,257 +2,31 @@ import { memo, useState, useCallback, useRef } from "react";
|
||||
import { useCaptionStore } from "../store";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { shouldHandleCaptionNudgeKey } from "../keyboard";
|
||||
import {
|
||||
readWordBoxes,
|
||||
getWordEl,
|
||||
readGsapTransform,
|
||||
getOrCreateWrapper,
|
||||
writeTransform,
|
||||
computeTransformStyle,
|
||||
type WordBox,
|
||||
} from "./CaptionOverlayUtils";
|
||||
|
||||
interface CaptionOverlayProps {
|
||||
iframeRef: React.RefObject<HTMLIFrameElement | null>;
|
||||
}
|
||||
|
||||
interface WordBox {
|
||||
segmentId: string;
|
||||
groupId: string;
|
||||
groupIndex: number;
|
||||
wordIndex: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
const HANDLE = 8;
|
||||
const ROTATION_OFFSET = 20; // px above the selection box
|
||||
|
||||
function readWordBoxes(
|
||||
iframe: HTMLIFrameElement,
|
||||
model: {
|
||||
groupOrder: string[];
|
||||
groups: Map<string, { segmentIds: string[] }>;
|
||||
},
|
||||
overlayEl: HTMLElement,
|
||||
): WordBox[] {
|
||||
let doc: Document | null = null;
|
||||
let win: Window | null = null;
|
||||
try {
|
||||
doc = iframe.contentDocument;
|
||||
win = iframe.contentWindow;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!doc || !win) return [];
|
||||
|
||||
const iframeDisplayRect = iframe.getBoundingClientRect();
|
||||
const overlayRect = overlayEl.getBoundingClientRect();
|
||||
// The iframe renders at native resolution (e.g. 1920x1080) but is
|
||||
// CSS-scaled to fit the viewport. getBoundingClientRect() on elements
|
||||
// inside the iframe returns coordinates in the iframe's native space.
|
||||
// Multiply by cssScale to convert to parent window coordinates.
|
||||
const nativeW = parseFloat(iframe.style.width) || iframeDisplayRect.width;
|
||||
const cssScale = iframeDisplayRect.width / nativeW;
|
||||
const offsetX = iframeDisplayRect.left - overlayRect.left;
|
||||
const offsetY = iframeDisplayRect.top - overlayRect.top;
|
||||
|
||||
const groupEls = doc.querySelectorAll<HTMLElement>(".caption-group");
|
||||
const boxes: WordBox[] = [];
|
||||
|
||||
for (let gi = 0; gi < model.groupOrder.length; gi++) {
|
||||
const groupId = model.groupOrder[gi];
|
||||
const group = model.groups.get(groupId);
|
||||
if (!group) continue;
|
||||
const groupEl = groupEls[gi] as HTMLElement | undefined;
|
||||
if (!groupEl) continue;
|
||||
const computed = win.getComputedStyle(groupEl);
|
||||
if (parseFloat(computed.opacity) <= 0.01 || computed.visibility === "hidden") continue;
|
||||
// Find word elements — handles both per-word spans (generator output)
|
||||
// and grouped text nodes (existing caption templates that use
|
||||
// el.textContent = line.text instead of individual word spans).
|
||||
const resolvedWordEls: HTMLElement[] = [];
|
||||
for (const child of groupEl.children) {
|
||||
const c = child as HTMLElement;
|
||||
if (c.dataset.captionWrapper === "true") {
|
||||
const inner = c.querySelector<HTMLElement>(":scope > span");
|
||||
if (inner) resolvedWordEls.push(inner);
|
||||
} else if (c.tagName === "SPAN") {
|
||||
resolvedWordEls.push(c);
|
||||
}
|
||||
}
|
||||
// Fallback: if no word spans found but group has text content,
|
||||
// the template uses grouped text. Wrap each word in a span so
|
||||
// the overlay can target them individually.
|
||||
if (resolvedWordEls.length === 0 && groupEl.textContent?.trim()) {
|
||||
const textNode = groupEl.childNodes[0];
|
||||
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||
const words = (textNode.textContent || "").split(/\s+/).filter(Boolean);
|
||||
const frag = doc.createDocumentFragment();
|
||||
for (const word of words) {
|
||||
const span = doc.createElement("span");
|
||||
span.textContent = word + " ";
|
||||
span.style.display = "inline";
|
||||
frag.appendChild(span);
|
||||
resolvedWordEls.push(span);
|
||||
}
|
||||
groupEl.replaceChild(frag, textNode);
|
||||
} else {
|
||||
// Single span child with all text (e.g. vignelli template)
|
||||
const singleSpan = groupEl.querySelector<HTMLElement>(":scope > span");
|
||||
if (singleSpan && singleSpan.textContent?.trim()) {
|
||||
const words = singleSpan.textContent.split(/\s+/).filter(Boolean);
|
||||
const frag = doc.createDocumentFragment();
|
||||
for (const word of words) {
|
||||
const span = doc.createElement("span");
|
||||
span.textContent = word + " ";
|
||||
span.style.display = "inline";
|
||||
frag.appendChild(span);
|
||||
resolvedWordEls.push(span);
|
||||
}
|
||||
singleSpan.replaceWith(frag);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let wi = 0; wi < group.segmentIds.length; wi++) {
|
||||
const segId = group.segmentIds[wi];
|
||||
const wordEl = resolvedWordEls[wi] as HTMLElement | undefined;
|
||||
if (!wordEl) continue;
|
||||
const rect = wordEl.getBoundingClientRect();
|
||||
boxes.push({
|
||||
segmentId: segId,
|
||||
groupId,
|
||||
groupIndex: gi,
|
||||
wordIndex: wi,
|
||||
x: rect.left * cssScale + offsetX,
|
||||
y: rect.top * cssScale + offsetY,
|
||||
width: rect.width * cssScale,
|
||||
height: rect.height * cssScale,
|
||||
});
|
||||
}
|
||||
}
|
||||
return boxes;
|
||||
}
|
||||
|
||||
function getWordEl(
|
||||
iframe: HTMLIFrameElement,
|
||||
groupIndex: number,
|
||||
wordIndex: number,
|
||||
): HTMLElement | null {
|
||||
let doc: Document | null = null;
|
||||
try {
|
||||
doc = iframe.contentDocument;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!doc) return null;
|
||||
const groupEl = doc.querySelectorAll<HTMLElement>(".caption-group")[groupIndex];
|
||||
if (!groupEl) return null;
|
||||
// Find word spans — they may be direct children or inside wrapper spans.
|
||||
// Word spans have class "word" or an id starting with "w".
|
||||
// Wrappers have data-caption-wrapper="true".
|
||||
const wordEls: HTMLElement[] = [];
|
||||
for (const child of groupEl.children) {
|
||||
const el = child as HTMLElement;
|
||||
if (el.dataset.captionWrapper === "true") {
|
||||
// Wrapped word — get the inner span
|
||||
const inner = el.querySelector<HTMLElement>(":scope > span");
|
||||
if (inner) wordEls.push(inner);
|
||||
} else if (el.tagName === "SPAN") {
|
||||
wordEls.push(el);
|
||||
}
|
||||
}
|
||||
return wordEls[wordIndex] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read GSAP's internal transform state for an element.
|
||||
* GSAP stores transforms in its own cache, not in el.style.transform.
|
||||
*/
|
||||
function readGsapTransform(
|
||||
el: HTMLElement,
|
||||
iframeWin: Window,
|
||||
): { x: number; y: number; scale: number; rotation: number } {
|
||||
const gsap = (
|
||||
iframeWin as unknown as { gsap?: { getProperty?: (el: HTMLElement, prop: string) => number } }
|
||||
).gsap;
|
||||
if (gsap && gsap.getProperty) {
|
||||
return {
|
||||
x: gsap.getProperty(el, "x") || 0,
|
||||
y: gsap.getProperty(el, "y") || 0,
|
||||
scale: gsap.getProperty(el, "scale") || 1,
|
||||
rotation: gsap.getProperty(el, "rotation") || 0,
|
||||
};
|
||||
}
|
||||
// Fallback: parse from style
|
||||
const t = el.style.transform || "";
|
||||
const scaleMatch = t.match(/scale\(([^)]+)\)/);
|
||||
const rotMatch = t.match(/rotate\(([^)]+)deg\)/);
|
||||
const txyMatch = t.match(/translate\(([^,]+)px,\s*([^)]+)px\)/);
|
||||
return {
|
||||
x: txyMatch ? parseFloat(txyMatch[1]) : 0,
|
||||
y: txyMatch ? parseFloat(txyMatch[2]) : 0,
|
||||
scale: scaleMatch ? parseFloat(scaleMatch[1]) : 1,
|
||||
rotation: rotMatch ? parseFloat(rotMatch[1]) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create an inline-block wrapper span around a word element.
|
||||
* Transforms are applied to the wrapper so the word's GSAP animations are preserved.
|
||||
*/
|
||||
function getOrCreateWrapper(el: HTMLElement): HTMLElement {
|
||||
// If el IS a wrapper, return it
|
||||
if (el.dataset.captionWrapper === "true") return el;
|
||||
// If el's parent is a wrapper, return the parent
|
||||
const parent = el.parentElement;
|
||||
if (parent && parent.dataset.captionWrapper === "true") return parent;
|
||||
// Create new wrapper
|
||||
const doc = el.ownerDocument;
|
||||
const wrapper = doc.createElement("span");
|
||||
wrapper.style.display = "inline-block";
|
||||
wrapper.dataset.captionWrapper = "true";
|
||||
el.parentNode?.insertBefore(wrapper, el);
|
||||
wrapper.appendChild(el);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write transform values to a wrapper span around the word element.
|
||||
* The word keeps its GSAP animations; the wrapper handles editor transforms.
|
||||
*/
|
||||
function writeTransform(
|
||||
el: HTMLElement,
|
||||
iframeWin: Window,
|
||||
x: number,
|
||||
y: number,
|
||||
scale: number,
|
||||
rotation: number,
|
||||
) {
|
||||
const wrapper = getOrCreateWrapper(el);
|
||||
const gsap = (
|
||||
iframeWin as unknown as {
|
||||
gsap?: { set?: (el: HTMLElement, props: Record<string, number>) => void };
|
||||
}
|
||||
).gsap;
|
||||
if (gsap && gsap.set) {
|
||||
gsap.set(wrapper, { x, y, scale, rotation });
|
||||
} else {
|
||||
wrapper.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${rotation.toFixed(1)}deg) scale(${scale.toFixed(3)})`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync canvas state back to the Zustand store so the property panel reflects it.
|
||||
* Only writes non-default values to avoid creating spurious overrides. */
|
||||
/** Sync canvas state back to the Zustand store so the property panel reflects it. */
|
||||
function syncToStore(segmentId: string, el: HTMLElement, iframeWin: Window) {
|
||||
const wrapper = getOrCreateWrapper(el);
|
||||
const { x, y, scale, rotation } = readGsapTransform(wrapper, iframeWin);
|
||||
const style: Record<string, number> = {};
|
||||
if (Math.abs(x) > 0.5) style.x = x;
|
||||
if (Math.abs(y) > 0.5) style.y = y;
|
||||
if (Math.abs(scale - 1) > 0.001) {
|
||||
style.scaleX = scale;
|
||||
style.scaleY = scale;
|
||||
}
|
||||
if (Math.abs(rotation) > 0.1) style.rotation = rotation;
|
||||
const style = computeTransformStyle(el, iframeWin);
|
||||
if (Object.keys(style).length > 0) {
|
||||
useCaptionStore.getState().updateSegmentStyle(segmentId, style);
|
||||
}
|
||||
}
|
||||
|
||||
const HANDLE = 8;
|
||||
const ROTATION_OFFSET = 20; // px above the selection box
|
||||
|
||||
export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: CaptionOverlayProps) {
|
||||
const isEditMode = useCaptionStore((s) => s.isEditMode);
|
||||
const model = useCaptionStore((s) => s.model);
|
||||
@@ -311,7 +85,6 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
|
||||
const overlay = overlayRef.current;
|
||||
if (!iframe || !m || !overlay) return;
|
||||
const next = readWordBoxes(iframe, m, overlay);
|
||||
// Skip state update if nothing changed (avoids re-render every 66ms)
|
||||
if (
|
||||
next.length === prevBoxes.length &&
|
||||
next.every(
|
||||
@@ -342,7 +115,6 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
|
||||
if (!iframe || !win) return;
|
||||
|
||||
for (const segId of sel) {
|
||||
// Find group/word index for this segment
|
||||
for (let gi = 0; gi < m.groupOrder.length; gi++) {
|
||||
const group = m.groups.get(m.groupOrder[gi]);
|
||||
if (!group) continue;
|
||||
@@ -459,7 +231,6 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
|
||||
[iframeRef],
|
||||
);
|
||||
|
||||
/** Get iframe contentWindow, needed for gsap calls */
|
||||
const getIframeWin = useCallback((): Window | null => {
|
||||
try {
|
||||
return iframeRef.current?.contentWindow ?? null;
|
||||
@@ -482,8 +253,6 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
|
||||
const dy = (e.clientY - i.startMY) / cssScale;
|
||||
writeTransform(i.wordEl, win, i.origTX + dx, i.origTY + dy, i.origScale, i.origRotation);
|
||||
} else if (i.type === "scale") {
|
||||
// Use distance from box center so dragging outward from ANY corner
|
||||
// increases scale (not just right-side handles).
|
||||
const cx = i.startMX - i.startDxFromCenter;
|
||||
const startDist = Math.abs(i.startDxFromCenter);
|
||||
const currentDist = Math.abs(e.clientX - cx);
|
||||
@@ -491,8 +260,6 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
|
||||
const newScale = Math.max(0.1, i.origScale * factor);
|
||||
writeTransform(i.wordEl, win, i.origTX, i.origTY, newScale, i.origRotation);
|
||||
} else if (i.type === "rotate") {
|
||||
// Horizontal drag maps to rotation: right = clockwise, left = counter-clockwise.
|
||||
// 200px of horizontal movement = 90 degrees.
|
||||
const dx = e.clientX - i.startMX;
|
||||
const delta = (dx / 200) * 90;
|
||||
writeTransform(i.wordEl, win, i.origTX, i.origTY, i.origScale, i.origRotation + delta);
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// DOM helpers for CaptionOverlay — word box reading, transform I/O, wrapper management
|
||||
|
||||
export interface WordBox {
|
||||
segmentId: string;
|
||||
groupId: string;
|
||||
groupIndex: number;
|
||||
wordIndex: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function readWordBoxes(
|
||||
iframe: HTMLIFrameElement,
|
||||
model: {
|
||||
groupOrder: string[];
|
||||
groups: Map<string, { segmentIds: string[] }>;
|
||||
},
|
||||
overlayEl: HTMLElement,
|
||||
): WordBox[] {
|
||||
let doc: Document | null = null;
|
||||
let win: Window | null = null;
|
||||
try {
|
||||
doc = iframe.contentDocument;
|
||||
win = iframe.contentWindow;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!doc || !win) return [];
|
||||
|
||||
const iframeDisplayRect = iframe.getBoundingClientRect();
|
||||
const overlayRect = overlayEl.getBoundingClientRect();
|
||||
const nativeW = parseFloat(iframe.style.width) || iframeDisplayRect.width;
|
||||
const cssScale = iframeDisplayRect.width / nativeW;
|
||||
const offsetX = iframeDisplayRect.left - overlayRect.left;
|
||||
const offsetY = iframeDisplayRect.top - overlayRect.top;
|
||||
|
||||
const groupEls = doc.querySelectorAll<HTMLElement>(".caption-group");
|
||||
const boxes: WordBox[] = [];
|
||||
|
||||
for (let gi = 0; gi < model.groupOrder.length; gi++) {
|
||||
const groupId = model.groupOrder[gi];
|
||||
const group = model.groups.get(groupId);
|
||||
if (!group) continue;
|
||||
const groupEl = groupEls[gi] as HTMLElement | undefined;
|
||||
if (!groupEl) continue;
|
||||
const computed = win.getComputedStyle(groupEl);
|
||||
if (parseFloat(computed.opacity) <= 0.01 || computed.visibility === "hidden") continue;
|
||||
const resolvedWordEls: HTMLElement[] = [];
|
||||
for (const child of groupEl.children) {
|
||||
const c = child as HTMLElement;
|
||||
if (c.dataset.captionWrapper === "true") {
|
||||
const inner = c.querySelector<HTMLElement>(":scope > span");
|
||||
if (inner) resolvedWordEls.push(inner);
|
||||
} else if (c.tagName === "SPAN") {
|
||||
resolvedWordEls.push(c);
|
||||
}
|
||||
}
|
||||
if (resolvedWordEls.length === 0 && groupEl.textContent?.trim()) {
|
||||
const textNode = groupEl.childNodes[0];
|
||||
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||
const words = (textNode.textContent || "").split(/\s+/).filter(Boolean);
|
||||
const frag = doc.createDocumentFragment();
|
||||
for (const word of words) {
|
||||
const span = doc.createElement("span");
|
||||
span.textContent = word + " ";
|
||||
span.style.display = "inline";
|
||||
frag.appendChild(span);
|
||||
resolvedWordEls.push(span);
|
||||
}
|
||||
groupEl.replaceChild(frag, textNode);
|
||||
} else {
|
||||
const singleSpan = groupEl.querySelector<HTMLElement>(":scope > span");
|
||||
if (singleSpan && singleSpan.textContent?.trim()) {
|
||||
const words = singleSpan.textContent.split(/\s+/).filter(Boolean);
|
||||
const frag = doc.createDocumentFragment();
|
||||
for (const word of words) {
|
||||
const span = doc.createElement("span");
|
||||
span.textContent = word + " ";
|
||||
span.style.display = "inline";
|
||||
frag.appendChild(span);
|
||||
resolvedWordEls.push(span);
|
||||
}
|
||||
singleSpan.replaceWith(frag);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let wi = 0; wi < group.segmentIds.length; wi++) {
|
||||
const segId = group.segmentIds[wi];
|
||||
const wordEl = resolvedWordEls[wi] as HTMLElement | undefined;
|
||||
if (!wordEl) continue;
|
||||
const rect = wordEl.getBoundingClientRect();
|
||||
boxes.push({
|
||||
segmentId: segId,
|
||||
groupId,
|
||||
groupIndex: gi,
|
||||
wordIndex: wi,
|
||||
x: rect.left * cssScale + offsetX,
|
||||
y: rect.top * cssScale + offsetY,
|
||||
width: rect.width * cssScale,
|
||||
height: rect.height * cssScale,
|
||||
});
|
||||
}
|
||||
}
|
||||
return boxes;
|
||||
}
|
||||
|
||||
export function getWordEl(
|
||||
iframe: HTMLIFrameElement,
|
||||
groupIndex: number,
|
||||
wordIndex: number,
|
||||
): HTMLElement | null {
|
||||
let doc: Document | null = null;
|
||||
try {
|
||||
doc = iframe.contentDocument;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!doc) return null;
|
||||
const groupEl = doc.querySelectorAll<HTMLElement>(".caption-group")[groupIndex];
|
||||
if (!groupEl) return null;
|
||||
const wordEls: HTMLElement[] = [];
|
||||
for (const child of groupEl.children) {
|
||||
const el = child as HTMLElement;
|
||||
if (el.dataset.captionWrapper === "true") {
|
||||
const inner = el.querySelector<HTMLElement>(":scope > span");
|
||||
if (inner) wordEls.push(inner);
|
||||
} else if (el.tagName === "SPAN") {
|
||||
wordEls.push(el);
|
||||
}
|
||||
}
|
||||
return wordEls[wordIndex] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read GSAP's internal transform state for an element.
|
||||
* GSAP stores transforms in its own cache, not in el.style.transform.
|
||||
*/
|
||||
export function readGsapTransform(
|
||||
el: HTMLElement,
|
||||
iframeWin: Window,
|
||||
): { x: number; y: number; scale: number; rotation: number } {
|
||||
const gsap = (
|
||||
iframeWin as unknown as { gsap?: { getProperty?: (el: HTMLElement, prop: string) => number } }
|
||||
).gsap;
|
||||
if (gsap && gsap.getProperty) {
|
||||
return {
|
||||
x: gsap.getProperty(el, "x") || 0,
|
||||
y: gsap.getProperty(el, "y") || 0,
|
||||
scale: gsap.getProperty(el, "scale") || 1,
|
||||
rotation: gsap.getProperty(el, "rotation") || 0,
|
||||
};
|
||||
}
|
||||
const t = el.style.transform || "";
|
||||
const scaleMatch = t.match(/scale\(([^)]+)\)/);
|
||||
const rotMatch = t.match(/rotate\(([^)]+)deg\)/);
|
||||
const txyMatch = t.match(/translate\(([^,]+)px,\s*([^)]+)px\)/);
|
||||
return {
|
||||
x: txyMatch ? parseFloat(txyMatch[1]) : 0,
|
||||
y: txyMatch ? parseFloat(txyMatch[2]) : 0,
|
||||
scale: scaleMatch ? parseFloat(scaleMatch[1]) : 1,
|
||||
rotation: rotMatch ? parseFloat(rotMatch[1]) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create an inline-block wrapper span around a word element.
|
||||
* Transforms are applied to the wrapper so the word's GSAP animations are preserved.
|
||||
*/
|
||||
export function getOrCreateWrapper(el: HTMLElement): HTMLElement {
|
||||
if (el.dataset.captionWrapper === "true") return el;
|
||||
const parent = el.parentElement;
|
||||
if (parent && parent.dataset.captionWrapper === "true") return parent;
|
||||
const doc = el.ownerDocument;
|
||||
const wrapper = doc.createElement("span");
|
||||
wrapper.style.display = "inline-block";
|
||||
wrapper.dataset.captionWrapper = "true";
|
||||
el.parentNode?.insertBefore(wrapper, el);
|
||||
wrapper.appendChild(el);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write transform values to a wrapper span around the word element.
|
||||
*/
|
||||
export function writeTransform(
|
||||
el: HTMLElement,
|
||||
iframeWin: Window,
|
||||
x: number,
|
||||
y: number,
|
||||
scale: number,
|
||||
rotation: number,
|
||||
) {
|
||||
const wrapper = getOrCreateWrapper(el);
|
||||
const gsap = (
|
||||
iframeWin as unknown as {
|
||||
gsap?: { set?: (el: HTMLElement, props: Record<string, number>) => void };
|
||||
}
|
||||
).gsap;
|
||||
if (gsap && gsap.set) {
|
||||
gsap.set(wrapper, { x, y, scale, rotation });
|
||||
} else {
|
||||
wrapper.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${rotation.toFixed(1)}deg) scale(${scale.toFixed(3)})`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute style deltas from the current wrapper transform — used by syncToStore in the overlay. */
|
||||
export function computeTransformStyle(el: HTMLElement, iframeWin: Window): Record<string, number> {
|
||||
const wrapper = getOrCreateWrapper(el);
|
||||
const { x, y, scale, rotation } = readGsapTransform(wrapper, iframeWin);
|
||||
const style: Record<string, number> = {};
|
||||
if (Math.abs(x) > 0.5) style.x = x;
|
||||
if (Math.abs(y) > 0.5) style.y = y;
|
||||
if (Math.abs(scale - 1) > 0.001) {
|
||||
style.scaleX = scale;
|
||||
style.scaleY = scale;
|
||||
}
|
||||
if (Math.abs(rotation) > 0.1) style.rotation = rotation;
|
||||
return style;
|
||||
}
|
||||
Reference in New Issue
Block a user