feat: wrapper-based caption overrides — preserve all GSAP animations

Instead of killing transform tweens, wrap each overridden word span in an
inline-block wrapper span. Editor transforms (position, scale, rotation)
are applied to the wrapper while the inner span keeps all GSAP entrance,
exit, and karaoke animations intact.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-04-01 23:37:11 -07:00
co-authored by Claude Opus 4.6
parent 7c0f825d72
commit b6bdce065e
2 changed files with 58 additions and 39 deletions
+32 -30
View File
@@ -1,18 +1,17 @@
/**
* Caption Overrides — applies per-word style overrides from a JSON data file.
*
* Matching strategy (in priority order):
* 1. `wordId` — matches by element ID (e.g. document.getElementById("w0"))
* 2. `wordIndex` — fallback, matches by DOM traversal order across .caption-group > span
* Strategy: wrap each overridden word span in an inline-block wrapper span,
* then apply transforms to the wrapper. The inner span keeps all its original
* GSAP animations (entrance, karaoke, exit) untouched. No tweens are killed.
*
* Using wordId is preferred because it's stable across grouping changes.
* wordIndex is supported for backwards compat with older override files.
* Matching (in priority order):
* 1. `wordId` — matches by element ID (document.getElementById)
* 2. `wordIndex` — fallback, DOM traversal order across .caption-group > span
*/
export interface CaptionOverride {
/** Stable word ID from transcript.json (e.g. "w0", "w42"). Preferred lookup key. */
wordId?: string;
/** Fallback: positional index across all .caption-group > span elements. */
wordIndex?: number;
x?: number;
y?: number;
@@ -27,7 +26,6 @@ export interface CaptionOverride {
interface GsapStatic {
set: (target: Element, vars: Record<string, unknown>) => void;
killTweensOf: (target: Element, props: string) => void;
}
export function applyCaptionOverrides(): void {
@@ -53,7 +51,6 @@ export function applyCaptionOverrides(): void {
}
for (const override of data) {
// Resolve element: prefer wordId, fall back to wordIndex
let el: Element | null = null;
if (override.wordId) {
el = document.getElementById(override.wordId);
@@ -61,32 +58,37 @@ export function applyCaptionOverrides(): void {
if (!el && override.wordIndex !== undefined) {
el = wordEls[override.wordIndex] ?? null;
}
if (!el) continue;
if (!el || !(el instanceof HTMLElement)) continue;
const props: Record<string, unknown> = {};
if (override.x !== undefined) props.x = override.x;
if (override.y !== undefined) props.y = override.y;
if (override.scale !== undefined) props.scale = override.scale;
if (override.rotation !== undefined) props.rotation = override.rotation;
if (override.color !== undefined) props.color = override.color;
if (override.opacity !== undefined) props.opacity = override.opacity;
if (override.fontSize !== undefined) props.fontSize = `${override.fontSize}px`;
if (override.fontWeight !== undefined) props.fontWeight = override.fontWeight;
if (override.fontFamily !== undefined) props.fontFamily = override.fontFamily;
// Split overrides into transform props (applied to wrapper)
// and style props (applied directly to the word span)
const transformProps: Record<string, unknown> = {};
const styleProps: Record<string, unknown> = {};
if (Object.keys(props).length === 0) continue;
if (override.x !== undefined) transformProps.x = override.x;
if (override.y !== undefined) transformProps.y = override.y;
if (override.scale !== undefined) transformProps.scale = override.scale;
if (override.rotation !== undefined) transformProps.rotation = override.rotation;
if (override.color !== undefined) styleProps.color = override.color;
if (override.opacity !== undefined) styleProps.opacity = override.opacity;
if (override.fontSize !== undefined) styleProps.fontSize = `${override.fontSize}px`;
if (override.fontWeight !== undefined) styleProps.fontWeight = override.fontWeight;
if (override.fontFamily !== undefined) styleProps.fontFamily = override.fontFamily;
// Kill conflicting transform tweens before applying overrides
const killProps: string[] = [];
if (props.x !== undefined) killProps.push("x");
if (props.y !== undefined) killProps.push("y");
if (props.scale !== undefined) killProps.push("scale");
if (props.rotation !== undefined) killProps.push("rotation");
if (killProps.length > 0) {
gsap.killTweensOf(el, killProps.join(","));
// Apply non-transform style props directly to the word (no conflict with GSAP tweens)
if (Object.keys(styleProps).length > 0) {
gsap.set(el, styleProps);
}
gsap.set(el, props);
// Wrap the word in an inline-block span and apply transforms to the wrapper.
// This preserves all GSAP entrance/exit/karaoke animations on the inner span.
if (Object.keys(transformProps).length > 0) {
const wrapper = document.createElement("span");
wrapper.style.display = "inline-block";
el.parentNode?.insertBefore(wrapper, el);
wrapper.appendChild(el);
gsap.set(wrapper, transformProps);
}
}
})
.catch(() => {});
@@ -108,22 +108,39 @@ function readGsapTransform(el: HTMLElement, iframeWin: Window): { x: number; y:
}
/**
* Write transform values using gsap.set() so GSAP tracks them properly.
* This prevents GSAP from overwriting our changes on next timeline tick.
* 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 {
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.
* 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(el, { x, y, scale, rotation });
gsap.set(wrapper, { x, y, scale, rotation });
} else {
// Fallback
el.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${rotation.toFixed(1)}deg) scale(${scale.toFixed(3)})`;
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 */
function syncToStore(segmentId: string, el: HTMLElement, iframeWin: Window) {
const { x, y, scale, rotation } = readGsapTransform(el, iframeWin);
const wrapper = getOrCreateWrapper(el);
const { x, y, scale, rotation } = readGsapTransform(wrapper, iframeWin);
useCaptionStore.getState().updateSegmentStyle(segmentId, {
x, y, rotation, scaleX: scale, scaleY: scale,
});
@@ -185,7 +202,7 @@ export const CaptionOverlay = memo(function CaptionOverlay({
const wordEl = getWordEl(iframe, groupIndex, wordIndex);
const win = iframe.contentWindow;
if (!wordEl || !win) return;
const state = readGsapTransform(wordEl, win);
const state = readGsapTransform(getOrCreateWrapper(wordEl), win);
interactionRef.current = {
type: "move", wordEl, segmentId,
startMX: e.clientX, startMY: e.clientY,
@@ -205,7 +222,7 @@ export const CaptionOverlay = memo(function CaptionOverlay({
const win = iframe.contentWindow;
if (!wordEl || !win) return;
const rect = wordEl.getBoundingClientRect();
const state = readGsapTransform(wordEl, win);
const state = readGsapTransform(getOrCreateWrapper(wordEl), win);
interactionRef.current = {
type: "scale", wordEl, segmentId,
startMX: e.clientX, startWidth: rect.width,
@@ -227,7 +244,7 @@ export const CaptionOverlay = memo(function CaptionOverlay({
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
const startAngle = Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI);
const state = readGsapTransform(wordEl, win);
const state = readGsapTransform(getOrCreateWrapper(wordEl), win);
interactionRef.current = {
type: "rotate", wordEl, segmentId: box.segmentId,
centerX: cx, centerY: cy,