mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
fix(studio): captions UX — mode exit, undo, autosave surfacing, honest gating (#1968)
Caption-editing fixes from the studio UX review. This surface held five of the thirteen criticals; the theme is that the editing UI shipped ahead of its apply/persist pipeline, so several controls mutated an in-memory model with no downstream effect, and the mode itself could never be exited. Mode trap: caption edit mode auto-activated on detection and had no exit — `setEditMode(false)` and `reset()` had zero call sites, so the caption overlay replaced normal element editing for the rest of the session, even after switching compositions. The store now resets on composition change (flushing the last debounced edit first), an "Editing captions · Exit" pill sits on the preview, and a re-enter button appears once dismissed. Honest gating of dead surfaces: the Animation tab (31 presets × duration/ease/stagger/intensity) edited state that was never applied to playback nor serialized — wiring it needs a CaptionOverride schema extension in packages/core plus a runtime engine, so the tab is now visibly disabled with an amber "isn't applied to playback or saved yet" notice instead of silently discarding work. Timing edge-drags moved a block that never changed playback and never saved; the handles are gone and the blocks remain as select/seek targets. Double-click split desynced the overlay↔DOM index mapping, so split is out until regeneration exists. Undo: store-level undo/redo (cap 50, 800ms coalescing by edit target) across all ten mutations, with ⌘Z/⇧⌘Z intercepted while caption mode is active and reapplied to the live iframe. Previously ⌘Z reverted an unrelated file edit while the bad caption drag persisted. Autosave: save failures, including non-2xx, raise a persistent "not saved — Retry" banner; the code's own comment called this a data-loss path and it was telemetry-only. Debounced saves flush on unmount instead of being discarded, `beforeunload` flushes and warns while pending, and corrupt overrides JSON is distinguished from a missing file. Input safety and a11y: arrow-key nudge no longer hijacks arrows inside form inputs; numeric fields commit finite values only (typing "-" used to inject NaN into gsap and persist null); "Mixed" shows on multi-select divergence; Escape cancels an in-flight drag and restores the pre-drag transform; ⌘A selects all; caption blocks are keyboard-selectable with a playhead line and click-to-seek (CaptionTimeline's `onSeek` prop existed but nothing passed it); 24px hit areas around the 8px handles; a hint when no boxes are visible; visible input focus styles; tablist semantics. Perf: the 66ms getBoundingClientRect polling loop is replaced with event-driven updates (player-store subscription, preview messages, ResizeObserver, rAF-coalesced); the interval now runs only during playback. Reconciled against main: StudioPreviewArea.tsx was deleted by the Studio revamp (#2291), so the mode pill, the sync-error banner and the re-enter button move to its successor, nle/PreviewOverlays.tsx, and the caption track's onSeek is wired in EditorShell. The per-keyframe onChangeKeyframeEase change that also lived in that file is dropped: main removed the prop, and #1967 now routes the diamond menu's ease action to the focused-ease-segment editor instead. Restacked onto main now that PRs 1962-1967 have squash-merged, so this carries only its own changes. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0d26072e6c
commit
254de3d1c4
@@ -61,7 +61,13 @@ const EASE_PRESETS = [
|
||||
"bounce.out",
|
||||
];
|
||||
|
||||
import { Section, Row, inputCls } from "./shared";
|
||||
import { Section, Row, inputCls, NumberField } from "./shared";
|
||||
|
||||
// Animation edits currently mutate only the in-memory model: they are not
|
||||
// applied to the preview and not serialized by buildOverrides, so tuning them
|
||||
// would be editing blind. Controls stay visible (so the capability is
|
||||
// discoverable) but disabled until the apply/persist pipeline exists.
|
||||
const ANIMATION_PIPELINE_WIRED = false;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Animation phase controls
|
||||
@@ -72,6 +78,7 @@ interface AnimationPhaseProps {
|
||||
presets: string[];
|
||||
animation: CaptionAnimation | null;
|
||||
showIntensity?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (update: Partial<CaptionAnimation>) => void;
|
||||
}
|
||||
|
||||
@@ -80,6 +87,7 @@ function AnimationPhase({
|
||||
presets,
|
||||
animation,
|
||||
showIntensity,
|
||||
disabled,
|
||||
onChange,
|
||||
}: AnimationPhaseProps) {
|
||||
const preset = animation?.preset ?? "none";
|
||||
@@ -93,6 +101,8 @@ function AnimationPhase({
|
||||
<Row label="Preset">
|
||||
<select
|
||||
value={preset}
|
||||
disabled={disabled}
|
||||
aria-label={`${label} preset`}
|
||||
onChange={(e) => onChange({ preset: e.target.value })}
|
||||
className={inputCls}
|
||||
>
|
||||
@@ -105,20 +115,22 @@ function AnimationPhase({
|
||||
</Row>
|
||||
|
||||
<Row label="Duration">
|
||||
<input
|
||||
type="number"
|
||||
<NumberField
|
||||
value={duration}
|
||||
step={0.05}
|
||||
min={0}
|
||||
max={2}
|
||||
onChange={(e) => onChange({ duration: Number(e.target.value) })}
|
||||
className={inputCls}
|
||||
disabled={disabled}
|
||||
ariaLabel={`${label} duration`}
|
||||
onCommit={(v) => onChange({ duration: v })}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row label="Ease">
|
||||
<select
|
||||
value={ease}
|
||||
disabled={disabled}
|
||||
aria-label={`${label} ease`}
|
||||
onChange={(e) => onChange({ ease: e.target.value })}
|
||||
className={inputCls}
|
||||
>
|
||||
@@ -131,14 +143,14 @@ function AnimationPhase({
|
||||
</Row>
|
||||
|
||||
<Row label="Stagger">
|
||||
<input
|
||||
type="number"
|
||||
<NumberField
|
||||
value={stagger}
|
||||
step={0.02}
|
||||
min={0}
|
||||
max={0.5}
|
||||
onChange={(e) => onChange({ stagger: Number(e.target.value) })}
|
||||
className={inputCls}
|
||||
disabled={disabled}
|
||||
ariaLabel={`${label} stagger`}
|
||||
onCommit={(v) => onChange({ stagger: v })}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
@@ -151,8 +163,13 @@ function AnimationPhase({
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={intensity}
|
||||
onChange={(e) => onChange({ intensity: Number(e.target.value) })}
|
||||
className="flex-1 accent-studio-accent"
|
||||
disabled={disabled}
|
||||
aria-label={`${label} intensity`}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (Number.isFinite(v)) onChange({ intensity: v });
|
||||
}}
|
||||
className="flex-1 accent-studio-accent disabled:opacity-40"
|
||||
/>
|
||||
<span className="text-2xs text-neutral-400 font-mono w-8 text-right flex-shrink-0">
|
||||
{intensity.toFixed(2)}
|
||||
@@ -222,19 +239,31 @@ export const CaptionAnimationPanel = memo(function CaptionAnimationPanel() {
|
||||
if (!group || !resolvedGroupId || !animation) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full px-4 text-center">
|
||||
<p className="text-xs text-neutral-500">Select a caption group to edit animations</p>
|
||||
<p className="text-xs text-neutral-500">Select a caption word to edit animations</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gated = !ANIMATION_PIPELINE_WIRED;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
{gated && (
|
||||
<div className="flex-shrink-0 mx-3 mt-2 px-2 py-1.5 rounded border border-amber-500/30 bg-amber-500/10">
|
||||
<p className="text-2xs text-amber-300/90 leading-snug">
|
||||
Animation editing isn't applied to playback or saved yet, so these controls are
|
||||
disabled.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2">
|
||||
<AnimationPhase
|
||||
label="Entrance"
|
||||
presets={ENTRANCE_PRESETS}
|
||||
animation={animation.entrance}
|
||||
disabled={gated}
|
||||
onChange={handleEntranceChange}
|
||||
/>
|
||||
|
||||
@@ -243,6 +272,7 @@ export const CaptionAnimationPanel = memo(function CaptionAnimationPanel() {
|
||||
presets={HIGHLIGHT_PRESETS}
|
||||
animation={animation.highlight}
|
||||
showIntensity
|
||||
disabled={gated}
|
||||
onChange={handleHighlightChange}
|
||||
/>
|
||||
|
||||
@@ -250,6 +280,7 @@ export const CaptionAnimationPanel = memo(function CaptionAnimationPanel() {
|
||||
label="Exit"
|
||||
presets={EXIT_PRESETS}
|
||||
animation={animation.exit}
|
||||
disabled={gated}
|
||||
onChange={handleExitChange}
|
||||
/>
|
||||
</div>
|
||||
@@ -259,7 +290,9 @@ export const CaptionAnimationPanel = memo(function CaptionAnimationPanel() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApplyToAll}
|
||||
className="w-full py-1.5 rounded border border-neutral-700 text-2xs text-neutral-300 hover:border-studio-accent/50 hover:text-studio-accent transition-colors"
|
||||
disabled={gated}
|
||||
title={gated ? "Disabled until animation editing is applied to playback" : undefined}
|
||||
className="w-full py-1.5 rounded border border-neutral-700 text-2xs text-neutral-300 hover:border-studio-accent/50 hover:text-studio-accent transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-neutral-700 disabled:hover:text-neutral-300"
|
||||
>
|
||||
Apply to all groups
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { memo, useState, useCallback, useRef } from "react";
|
||||
import { useCaptionStore } from "../store";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { shouldHandleCaptionNudgeKey } from "../keyboard";
|
||||
import { shouldHandleCaptionNudgeKey, isEditableEventTarget } from "../keyboard";
|
||||
import {
|
||||
readWordBoxes,
|
||||
getWordEl,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
getOrCreateWrapper,
|
||||
writeTransform,
|
||||
computeTransformStyle,
|
||||
registerCaptionIframe,
|
||||
type WordBox,
|
||||
} from "./CaptionOverlayUtils";
|
||||
|
||||
@@ -16,7 +18,8 @@ interface CaptionOverlayProps {
|
||||
iframeRef: React.RefObject<HTMLIFrameElement | null>;
|
||||
}
|
||||
|
||||
const HANDLE = 8;
|
||||
const HANDLE = 8; // visual size of a handle dot
|
||||
const HANDLE_HIT = 24; // pointer target size (invisible padding around the dot)
|
||||
const ROTATION_OFFSET = 20; // px above the selection box
|
||||
|
||||
/** Sync canvas state back to the Zustand store so the property panel reflects it. */
|
||||
@@ -38,6 +41,8 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const modelRef = useRef(model);
|
||||
modelRef.current = model;
|
||||
// Set by the mount effect; lets Escape handling cancel an in-flight drag.
|
||||
const cancelDragRef = useRef<(() => boolean) | null>(null);
|
||||
|
||||
// Interaction mode — only one active at a time
|
||||
const interactionRef = useRef<
|
||||
@@ -78,8 +83,14 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
|
||||
|
||||
useMountEffect(() => {
|
||||
if (!isEditMode) return;
|
||||
|
||||
// Let undo/redo (useAppHotkeys) reapply restored models to this iframe.
|
||||
const unregisterIframe = registerCaptionIframe(iframeRef);
|
||||
|
||||
let prevBoxes: WordBox[] = [];
|
||||
let rafId: number | null = null;
|
||||
const tick = () => {
|
||||
rafId = null;
|
||||
const iframe = iframeRef.current;
|
||||
const m = modelRef.current;
|
||||
const overlay = overlayRef.current;
|
||||
@@ -95,15 +106,98 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
|
||||
prevBoxes = next;
|
||||
setWordBoxes(next);
|
||||
};
|
||||
const id = setInterval(tick, 66);
|
||||
// Coalesce bursts of triggers (player store updates, resize) into one
|
||||
// layout read per frame — replaces the old unconditional 66ms polling.
|
||||
const scheduleTick = () => {
|
||||
if (rafId === null) rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
// Event sources: player state changes (seek/scrub/select), preview
|
||||
// messages, element resize, window resize.
|
||||
const unsubPlayer = usePlayerStore.subscribe(scheduleTick);
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
const data = e.data;
|
||||
if (data?.source === "hf-preview") scheduleTick();
|
||||
};
|
||||
window.addEventListener("message", handleMessage);
|
||||
window.addEventListener("resize", scheduleTick);
|
||||
const resizeObserver = new ResizeObserver(scheduleTick);
|
||||
if (iframeRef.current) resizeObserver.observe(iframeRef.current);
|
||||
if (overlayRef.current) resizeObserver.observe(overlayRef.current);
|
||||
|
||||
// During playback caption groups animate continuously — a light interval
|
||||
// is the only reliable driver, but it runs ONLY while playing.
|
||||
let playInterval: ReturnType<typeof setInterval> | null = null;
|
||||
const syncPlaybackInterval = () => {
|
||||
const playing = usePlayerStore.getState().isPlaying;
|
||||
if (playing && playInterval === null) {
|
||||
playInterval = setInterval(scheduleTick, 66);
|
||||
} else if (!playing && playInterval !== null) {
|
||||
clearInterval(playInterval);
|
||||
playInterval = null;
|
||||
}
|
||||
};
|
||||
const unsubPlayback = usePlayerStore.subscribe(syncPlaybackInterval);
|
||||
syncPlaybackInterval();
|
||||
tick();
|
||||
|
||||
// Arrow key nudge for selected words
|
||||
const getWin = (): Window | null => {
|
||||
try {
|
||||
return iframeRef.current?.contentWindow ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelActiveDrag = (): boolean => {
|
||||
const i = interactionRef.current;
|
||||
if (!i) return false;
|
||||
const win = getWin();
|
||||
if (win) {
|
||||
// Restore the pre-drag transform instead of committing the partial one.
|
||||
writeTransform(i.wordEl, win, i.origTX, i.origTY, i.origScale, i.origRotation);
|
||||
}
|
||||
interactionRef.current = null;
|
||||
return true;
|
||||
};
|
||||
cancelDragRef.current = cancelActiveDrag;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const { selectedSegmentIds: sel, model: m } = useCaptionStore.getState();
|
||||
const store = useCaptionStore.getState();
|
||||
const { selectedSegmentIds: sel, model: m } = store;
|
||||
|
||||
// Escape: cancel an in-flight drag first, else clear the selection.
|
||||
if (e.key === "Escape") {
|
||||
if (cancelActiveDrag()) {
|
||||
e.preventDefault();
|
||||
scheduleTick();
|
||||
return;
|
||||
}
|
||||
if (sel.size > 0) {
|
||||
e.preventDefault();
|
||||
store.clearSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ⌘A / Ctrl+A selects every caption word (unless typing in a field).
|
||||
if (
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
e.key.toLowerCase() === "a" &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey &&
|
||||
!isEditableEventTarget(e.target)
|
||||
) {
|
||||
e.preventDefault();
|
||||
store.selectAll();
|
||||
return;
|
||||
}
|
||||
|
||||
if (sel.size === 0 || !m) return;
|
||||
const arrow = e.key;
|
||||
if (!shouldHandleCaptionNudgeKey(e)) return;
|
||||
// Pass the event target so arrows inside inputs keep their native
|
||||
// behavior instead of nudging the canvas word.
|
||||
if (!shouldHandleCaptionNudgeKey(e, e.target)) return;
|
||||
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? 10 : 1;
|
||||
@@ -129,12 +223,21 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
|
||||
break;
|
||||
}
|
||||
}
|
||||
scheduleTick();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
unregisterIframe();
|
||||
unsubPlayer();
|
||||
unsubPlayback();
|
||||
if (playInterval !== null) clearInterval(playInterval);
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener("message", handleMessage);
|
||||
window.removeEventListener("resize", scheduleTick);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
cancelDragRef.current = null;
|
||||
};
|
||||
});
|
||||
|
||||
@@ -297,6 +400,13 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
|
||||
onPointerUp={handlePointerUp}
|
||||
onLostPointerCapture={handlePointerUp}
|
||||
>
|
||||
{wordBoxes.length === 0 && model && model.segments.size > 0 && (
|
||||
<div className="absolute inset-x-0 top-3 flex justify-center pointer-events-none">
|
||||
<span className="px-2.5 py-1 rounded-full bg-black/60 border border-neutral-700 text-2xs text-neutral-300">
|
||||
No captions visible at this frame — scrub to a caption, or select one in the track below
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{wordBoxes.map((box) => {
|
||||
const isSelected = selectedSegmentIds.has(box.segmentId);
|
||||
return (
|
||||
@@ -325,23 +435,33 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
|
||||
>
|
||||
{isSelected && (
|
||||
<>
|
||||
{/* Rotation handle — circle above the box */}
|
||||
{/* Rotation handle — 24px hit area around an 8px dot */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: -ROTATION_OFFSET - HANDLE,
|
||||
marginLeft: -HANDLE / 2,
|
||||
width: HANDLE,
|
||||
height: HANDLE,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--hf-accent, #3CE6AC)",
|
||||
border: "1px solid rgba(0,0,0,0.5)",
|
||||
top: -ROTATION_OFFSET - HANDLE / 2 - HANDLE_HIT / 2,
|
||||
marginLeft: -HANDLE_HIT / 2,
|
||||
width: HANDLE_HIT,
|
||||
height: HANDLE_HIT,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "grab",
|
||||
touchAction: "none",
|
||||
}}
|
||||
onPointerDown={(e) => startRotate(box, e)}
|
||||
/>
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: HANDLE,
|
||||
height: HANDLE,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--hf-accent, #3CE6AC)",
|
||||
border: "1px solid rgba(0,0,0,0.5)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Line from box to rotation handle */}
|
||||
<div
|
||||
style={{
|
||||
@@ -356,29 +476,52 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
{/* Scale handles — four corners */}
|
||||
{/* Scale handles — four corners, 24px hit areas around 8px dots */}
|
||||
{[
|
||||
{ right: -HANDLE / 2, bottom: -HANDLE / 2, cursor: "nwse-resize" },
|
||||
{ left: -HANDLE / 2, top: -HANDLE / 2, cursor: "nwse-resize" },
|
||||
{ right: -HANDLE / 2, top: -HANDLE / 2, cursor: "nesw-resize" },
|
||||
{ left: -HANDLE / 2, bottom: -HANDLE / 2, cursor: "nesw-resize" },
|
||||
].map((pos, idx) => (
|
||||
{
|
||||
corner: { right: -HANDLE_HIT / 2, bottom: -HANDLE_HIT / 2 },
|
||||
cursor: "nwse-resize",
|
||||
},
|
||||
{
|
||||
corner: { left: -HANDLE_HIT / 2, top: -HANDLE_HIT / 2 },
|
||||
cursor: "nwse-resize",
|
||||
},
|
||||
{
|
||||
corner: { right: -HANDLE_HIT / 2, top: -HANDLE_HIT / 2 },
|
||||
cursor: "nesw-resize",
|
||||
},
|
||||
{
|
||||
corner: { left: -HANDLE_HIT / 2, bottom: -HANDLE_HIT / 2 },
|
||||
cursor: "nesw-resize",
|
||||
},
|
||||
].map(({ corner, cursor }, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
position: "absolute",
|
||||
...pos,
|
||||
width: HANDLE,
|
||||
height: HANDLE,
|
||||
backgroundColor: "var(--hf-accent, #3CE6AC)",
|
||||
border: "1px solid rgba(0,0,0,0.5)",
|
||||
borderRadius: 2,
|
||||
...corner,
|
||||
width: HANDLE_HIT,
|
||||
height: HANDLE_HIT,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor,
|
||||
touchAction: "none",
|
||||
}}
|
||||
onPointerDown={(e) =>
|
||||
startScale(box.groupIndex, box.wordIndex, box.segmentId, e)
|
||||
}
|
||||
/>
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: HANDLE,
|
||||
height: HANDLE,
|
||||
backgroundColor: "var(--hf-accent, #3CE6AC)",
|
||||
border: "1px solid rgba(0,0,0,0.5)",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -205,6 +205,60 @@ export function writeTransform(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Iframe registry — lets non-component code (undo/redo in useAppHotkeys)
|
||||
// reapply a restored model's transforms to the live preview.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let registeredIframe: React.RefObject<HTMLIFrameElement | null> | null = null;
|
||||
|
||||
export function registerCaptionIframe(ref: React.RefObject<HTMLIFrameElement | null>): () => void {
|
||||
registeredIframe = ref;
|
||||
return () => {
|
||||
if (registeredIframe === ref) registeredIframe = null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the caption preview iframe is mounted AND visible. The caption
|
||||
* store's isEditMode stays true while the preview is merely hidden (e.g.
|
||||
* storyboard view), so hotkeys must not route ⌘Z to the caption stack unless
|
||||
* the user can actually see the captions the undo would change.
|
||||
*/
|
||||
export function isCaptionPreviewVisible(): boolean {
|
||||
const iframe = registeredIframe?.current;
|
||||
return Boolean(iframe?.isConnected && iframe.offsetParent !== null);
|
||||
}
|
||||
|
||||
/** Reapply every segment's transform from a (restored) model to the preview DOM. */
|
||||
export function applyCaptionModelToIframe(model: {
|
||||
groupOrder: string[];
|
||||
groups: Map<string, { segmentIds: string[] }>;
|
||||
segments: Map<string, { style: { x?: number; y?: number; scaleX?: number; rotation?: number } }>;
|
||||
}): void {
|
||||
const iframe = registeredIframe?.current;
|
||||
if (!iframe) return;
|
||||
let win: Window | null = null;
|
||||
try {
|
||||
win = iframe.contentWindow;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!win) return;
|
||||
for (let gi = 0; gi < model.groupOrder.length; gi++) {
|
||||
const group = model.groups.get(model.groupOrder[gi]);
|
||||
if (!group) continue;
|
||||
for (let wi = 0; wi < group.segmentIds.length; wi++) {
|
||||
const seg = model.segments.get(group.segmentIds[wi]);
|
||||
if (!seg) continue;
|
||||
const wordEl = getWordEl(iframe, gi, wi);
|
||||
if (!wordEl) continue;
|
||||
const s = seg.style;
|
||||
writeTransform(wordEl, win, s.x ?? 0, s.y ?? 0, s.scaleX ?? 1, s.rotation ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
@@ -2,7 +2,29 @@ import { memo, useCallback, useState } from "react";
|
||||
import { useCaptionStore } from "../store";
|
||||
import type { CaptionStyle } from "../types";
|
||||
import { CaptionAnimationPanel } from "./CaptionAnimationPanel";
|
||||
import { Section, Row, inputCls } from "./shared";
|
||||
import { Section, Row, NumberField } from "./shared";
|
||||
|
||||
/** True when the given style key differs across the selected segments. */
|
||||
function isMixedValue(
|
||||
model: { segments: Map<string, { style: Partial<CaptionStyle> }> } | null,
|
||||
selectedSegmentIds: Set<string>,
|
||||
key: "x" | "y" | "scaleX" | "rotation",
|
||||
fallback: number,
|
||||
): boolean {
|
||||
if (!model || selectedSegmentIds.size < 2) return false;
|
||||
let first: number | undefined;
|
||||
let seen = false;
|
||||
for (const segId of selectedSegmentIds) {
|
||||
const v = model.segments.get(segId)?.style[key] ?? fallback;
|
||||
if (!seen) {
|
||||
first = v;
|
||||
seen = true;
|
||||
} else if (v !== first) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
@@ -180,6 +202,11 @@ export const CaptionPropertyPanel = memo(function CaptionPropertyPanel({
|
||||
const rotation = effectiveStyle.rotation ?? 0;
|
||||
const scaleX = effectiveStyle.scaleX ?? 1;
|
||||
|
||||
const xMixed = isMixedValue(model, selectedSegmentIds, "x", 0);
|
||||
const yMixed = isMixedValue(model, selectedSegmentIds, "y", 0);
|
||||
const scaleMixed = isMixedValue(model, selectedSegmentIds, "scaleX", 1);
|
||||
const rotationMixed = isMixedValue(model, selectedSegmentIds, "rotation", 0);
|
||||
|
||||
// Count label
|
||||
const countLabel = selectedSegmentIds.size === 1 ? "1 word" : `${selectedSegmentIds.size} words`;
|
||||
|
||||
@@ -191,9 +218,11 @@ export const CaptionPropertyPanel = memo(function CaptionPropertyPanel({
|
||||
<span className="text-2xs text-neutral-500">{countLabel}</span>
|
||||
</div>
|
||||
{/* Tab switcher */}
|
||||
<div className="flex gap-1">
|
||||
<div className="flex gap-1" role="tablist" aria-label="Caption editing tabs">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === "style"}
|
||||
onClick={() => setActiveTab("style")}
|
||||
className={[
|
||||
"flex-1 py-0.5 rounded text-2xs font-medium transition-colors",
|
||||
@@ -206,6 +235,8 @@ export const CaptionPropertyPanel = memo(function CaptionPropertyPanel({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === "animation"}
|
||||
onClick={() => setActiveTab("animation")}
|
||||
className={[
|
||||
"flex-1 py-0.5 rounded text-2xs font-medium transition-colors",
|
||||
@@ -227,44 +258,39 @@ export const CaptionPropertyPanel = memo(function CaptionPropertyPanel({
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2">
|
||||
<Section label="Position">
|
||||
<Row label="X">
|
||||
<input
|
||||
type="number"
|
||||
<NumberField
|
||||
value={x}
|
||||
onChange={(e) => handleStyleChange({ x: Number(e.target.value) })}
|
||||
className={inputCls}
|
||||
mixed={xMixed}
|
||||
ariaLabel="X position"
|
||||
onCommit={(v) => handleStyleChange({ x: v })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Y">
|
||||
<input
|
||||
type="number"
|
||||
<NumberField
|
||||
value={y}
|
||||
onChange={(e) => handleStyleChange({ y: Number(e.target.value) })}
|
||||
className={inputCls}
|
||||
mixed={yMixed}
|
||||
ariaLabel="Y position"
|
||||
onCommit={(v) => handleStyleChange({ y: v })}
|
||||
/>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section label="Transform">
|
||||
<Row label="Scale">
|
||||
<input
|
||||
type="number"
|
||||
<NumberField
|
||||
value={scaleX}
|
||||
mixed={scaleMixed}
|
||||
step={0.1}
|
||||
onChange={(e) =>
|
||||
handleStyleChange({
|
||||
scaleX: Number(e.target.value),
|
||||
scaleY: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className={inputCls}
|
||||
ariaLabel="Scale"
|
||||
onCommit={(v) => handleStyleChange({ scaleX: v, scaleY: v })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Rotation">
|
||||
<input
|
||||
type="number"
|
||||
<NumberField
|
||||
value={rotation}
|
||||
onChange={(e) => handleStyleChange({ rotation: Number(e.target.value) })}
|
||||
className={inputCls}
|
||||
mixed={rotationMixed}
|
||||
ariaLabel="Rotation"
|
||||
onCommit={(v) => handleStyleChange({ rotation: v })}
|
||||
/>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useCallback, useRef } from "react";
|
||||
import { memo, useCallback } from "react";
|
||||
import { useCaptionStore } from "../store";
|
||||
import { usePlayerStore } from "../../player";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -18,6 +19,14 @@ const GROUP_COLORS = [
|
||||
"#C084FC",
|
||||
];
|
||||
|
||||
const TRACK_LEFT_PAD = 32;
|
||||
|
||||
// Timing edge-drag and double-click group split were removed deliberately:
|
||||
// segment timing and group structure only mutate the in-memory model — they
|
||||
// are never applied to playback nor serialized to caption-overrides.json, so
|
||||
// the UI was confirming edits that didn't exist. Restore them only alongside
|
||||
// a real apply/persist pipeline.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -27,14 +36,6 @@ interface CaptionTimelineProps {
|
||||
onSeek?: (time: number) => void;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
segId: string;
|
||||
edge: "start" | "end";
|
||||
originalStart: number;
|
||||
originalEnd: number;
|
||||
startX: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -46,50 +47,7 @@ export const CaptionTimeline = memo(function CaptionTimeline({
|
||||
const model = useCaptionStore((s) => s.model);
|
||||
const selectedSegmentIds = useCaptionStore((s) => s.selectedSegmentIds);
|
||||
const selectSegment = useCaptionStore((s) => s.selectSegment);
|
||||
const updateSegmentTiming = useCaptionStore((s) => s.updateSegmentTiming);
|
||||
const splitGroup = useCaptionStore((s) => s.splitGroup);
|
||||
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
|
||||
const handleEdgePointerDown = useCallback(
|
||||
(
|
||||
e: React.PointerEvent<HTMLDivElement>,
|
||||
segId: string,
|
||||
edge: "start" | "end",
|
||||
originalStart: number,
|
||||
originalEnd: number,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
dragRef.current = { segId, edge, originalStart, originalEnd, startX: e.clientX };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
|
||||
const delta = (e.clientX - drag.startX) / pixelsPerSecond;
|
||||
|
||||
if (drag.edge === "start") {
|
||||
const newStart = Math.max(0, drag.originalStart + delta);
|
||||
const clampedStart = Math.min(newStart, drag.originalEnd - 0.05);
|
||||
updateSegmentTiming(drag.segId, clampedStart, drag.originalEnd);
|
||||
} else {
|
||||
const newEnd = Math.max(drag.originalStart + 0.05, drag.originalEnd + delta);
|
||||
const clampedEnd = Math.max(0, newEnd);
|
||||
updateSegmentTiming(drag.segId, drag.originalStart, clampedEnd);
|
||||
}
|
||||
},
|
||||
[pixelsPerSecond, updateSegmentTiming],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
dragRef.current = null;
|
||||
}, []);
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
|
||||
const handleBlockClick = useCallback(
|
||||
(e: React.MouseEvent, segId: string) => {
|
||||
@@ -99,19 +57,22 @@ export const CaptionTimeline = memo(function CaptionTimeline({
|
||||
[selectSegment],
|
||||
);
|
||||
|
||||
const handleBlockDoubleClick = useCallback(
|
||||
(e: React.MouseEvent, groupId: string, segId: string) => {
|
||||
e.stopPropagation();
|
||||
splitGroup(groupId, segId);
|
||||
const handleBlockKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent, segId: string) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectSegment(segId, e.shiftKey);
|
||||
}
|
||||
},
|
||||
[splitGroup],
|
||||
[selectSegment],
|
||||
);
|
||||
|
||||
const handleTrackClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!onSeek) return;
|
||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||
const x = e.clientX - rect.left - 32;
|
||||
const x = e.clientX - rect.left - TRACK_LEFT_PAD;
|
||||
const time = Math.max(0, x / pixelsPerSecond);
|
||||
onSeek(time);
|
||||
},
|
||||
@@ -120,13 +81,12 @@ export const CaptionTimeline = memo(function CaptionTimeline({
|
||||
|
||||
if (!model) return null;
|
||||
|
||||
const playheadLeft = TRACK_LEFT_PAD + currentTime * pixelsPerSecond;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative select-none overflow-x-auto"
|
||||
style={{ height: 40, minWidth: "100%" }}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
onClick={handleTrackClick}
|
||||
>
|
||||
{model.groupOrder.map((groupId, groupIdx) => {
|
||||
@@ -138,14 +98,18 @@ export const CaptionTimeline = memo(function CaptionTimeline({
|
||||
const seg = model.segments.get(segId);
|
||||
if (!seg) return null;
|
||||
|
||||
const left = 32 + seg.start * pixelsPerSecond;
|
||||
const left = TRACK_LEFT_PAD + seg.start * pixelsPerSecond;
|
||||
const width = Math.max((seg.end - seg.start) * pixelsPerSecond, 4);
|
||||
const isSelected = selectedSegmentIds.has(segId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={segId}
|
||||
className={`absolute top-1 bottom-1 rounded flex items-center overflow-hidden cursor-pointer${
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Caption word "${seg.text}"`}
|
||||
aria-pressed={isSelected}
|
||||
className={`absolute top-1 bottom-1 rounded flex items-center overflow-hidden cursor-pointer focus-visible:ring-1 focus-visible:ring-white outline-none${
|
||||
isSelected ? " ring-1 ring-white/50 z-10" : ""
|
||||
}`}
|
||||
style={{
|
||||
@@ -155,15 +119,8 @@ export const CaptionTimeline = memo(function CaptionTimeline({
|
||||
zIndex: isSelected ? 10 : 1,
|
||||
}}
|
||||
onClick={(e) => handleBlockClick(e, segId)}
|
||||
onDoubleClick={(e) => handleBlockDoubleClick(e, groupId, segId)}
|
||||
onKeyDown={(e) => handleBlockKeyDown(e, segId)}
|
||||
>
|
||||
{/* Left edge drag handle */}
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 cursor-col-resize z-20"
|
||||
style={{ width: 6 }}
|
||||
onPointerDown={(e) => handleEdgePointerDown(e, segId, "start", seg.start, seg.end)}
|
||||
/>
|
||||
|
||||
{/* Text label */}
|
||||
<span
|
||||
className="flex-1 truncate px-2 pointer-events-none"
|
||||
@@ -171,17 +128,17 @@ export const CaptionTimeline = memo(function CaptionTimeline({
|
||||
>
|
||||
{seg.text}
|
||||
</span>
|
||||
|
||||
{/* Right edge drag handle */}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 cursor-col-resize z-20"
|
||||
style={{ width: 6 }}
|
||||
onPointerDown={(e) => handleEdgePointerDown(e, segId, "end", seg.start, seg.end)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})}
|
||||
|
||||
{/* Playhead — correlates blocks with the current frame */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-px bg-white/70 pointer-events-none z-20"
|
||||
style={{ left: playheadLeft }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export const inputCls =
|
||||
"w-full bg-neutral-900 border border-neutral-800 rounded px-1.5 py-0.5 text-2xs text-neutral-200 font-mono outline-none focus:border-neutral-600";
|
||||
"w-full bg-neutral-900 border border-neutral-800 rounded px-1.5 py-0.5 text-2xs text-neutral-200 font-mono outline-none focus:border-studio-accent disabled:opacity-40 disabled:cursor-not-allowed";
|
||||
|
||||
export function Section({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -24,3 +25,77 @@ export function Row({ label, children }: { label: string; children: React.ReactN
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NumberFieldProps {
|
||||
value: number | undefined;
|
||||
/** True when a multi-selection has differing values — shows "Mixed" until edited. */
|
||||
mixed?: boolean;
|
||||
step?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
onCommit: (value: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Numeric input that only commits finite parses. Typing "-" or clearing the
|
||||
* field keeps a local draft instead of committing NaN/0 into live transforms
|
||||
* and the persisted overrides file.
|
||||
*/
|
||||
export function NumberField({
|
||||
value,
|
||||
mixed,
|
||||
step,
|
||||
min,
|
||||
max,
|
||||
disabled,
|
||||
ariaLabel,
|
||||
onCommit,
|
||||
}: NumberFieldProps) {
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const focusedRef = useRef(false);
|
||||
|
||||
// External value changed while not editing — drop any stale draft.
|
||||
useEffect(() => {
|
||||
if (!focusedRef.current) setDraft(null);
|
||||
}, [value]);
|
||||
|
||||
const display = draft !== null ? draft : mixed ? "" : String(value ?? 0);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = e.target.value;
|
||||
setDraft(raw);
|
||||
const parsed = Number(raw);
|
||||
if (raw.trim() !== "" && Number.isFinite(parsed)) {
|
||||
// Native min/max only constrain spinner steps — typed values bypass
|
||||
// them, so clamp before committing to the model/overrides.
|
||||
let clamped = parsed;
|
||||
if (min !== undefined) clamped = Math.max(min, clamped);
|
||||
if (max !== undefined) clamped = Math.min(max, clamped);
|
||||
onCommit(clamped);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
className={inputCls}
|
||||
value={display}
|
||||
placeholder={mixed ? "Mixed" : undefined}
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
onChange={handleChange}
|
||||
onFocus={() => {
|
||||
focusedRef.current = true;
|
||||
}}
|
||||
onBlur={() => {
|
||||
focusedRef.current = false;
|
||||
setDraft(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,23 +68,49 @@ export function useCaptionSync(projectId: string | null) {
|
||||
// Flag to suppress auto-save during loadOverrides
|
||||
const suppressSaveRef = useRef(false);
|
||||
|
||||
// True while an edit is debounced or a PUT is in flight — guards tab close.
|
||||
const pendingRef = useRef(false);
|
||||
// Bumped on every new edit: a PUT's success may only clear the pending flag
|
||||
// when no newer edit re-armed the debounce while it was in flight.
|
||||
const editSeqRef = useRef(0);
|
||||
|
||||
const save = useCallback(() => {
|
||||
const state = useCaptionStore.getState();
|
||||
if (!state.model || !state.sourceFilePath || !state.isEditMode) return;
|
||||
// Note: deliberately no isEditMode guard — exiting caption mode (or any
|
||||
// path that fires the flush) must still write the last edits; the model
|
||||
// survives exit, and after a store reset it is null anyway.
|
||||
if (!state.model || !state.sourceFilePath) {
|
||||
pendingRef.current = false;
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
if (!pid) {
|
||||
pendingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const seqAtSave = editSeqRef.current;
|
||||
const overrides = buildOverrides(state.model);
|
||||
|
||||
fetch(`/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "text/plain", ...studioWriteHeaders() },
|
||||
body: JSON.stringify(overrides, null, 2),
|
||||
}).catch((error: unknown) => {
|
||||
// Caption auto-save is a data-loss path; surface failures via telemetry
|
||||
// so a silently-dropped edit isn't invisible (no console in studio).
|
||||
trackEvent("studio_caption_autosave_failed", { error: String(error) });
|
||||
});
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
// A newer edit may have re-armed the debounce while this PUT was in
|
||||
// flight — its beforeunload/unmount flush still needs pending=true.
|
||||
if (editSeqRef.current === seqAtSave) pendingRef.current = false;
|
||||
const s = useCaptionStore.getState();
|
||||
if (s.syncError) s.setSyncError(null);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
// Caption auto-save is a data-loss path: surface it to the user, not
|
||||
// just telemetry. pendingRef stays true so beforeunload still warns.
|
||||
trackEvent("studio_caption_autosave_failed", { error: String(error) });
|
||||
useCaptionStore.getState().setSyncError("Caption changes couldn't be saved");
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Auto-save on model changes with 800ms debounce
|
||||
@@ -102,12 +128,37 @@ export function useCaptionSync(projectId: string | null) {
|
||||
}
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
pendingRef.current = true;
|
||||
editSeqRef.current++;
|
||||
debounceRef.current = setTimeout(save, 800);
|
||||
});
|
||||
|
||||
// Warn before the tab closes while an edit is unsaved.
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (!pendingRef.current) return;
|
||||
// Flush best-effort, then let the browser show its confirm.
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
save();
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
|
||||
// Let the caption error banner retry the save directly.
|
||||
useCaptionStore.getState().setRetrySave(save);
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
// Flush instead of discarding — clearTimeout alone drops the final edits.
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
if (pendingRef.current) save();
|
||||
}
|
||||
useCaptionStore.getState().setRetrySave(null);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -117,16 +168,21 @@ export function useCaptionSync(projectId: string | null) {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
|
||||
let data: { content?: string };
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`,
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (!data.content) return;
|
||||
if (!res.ok) return; // no overrides file yet — normal
|
||||
data = await res.json();
|
||||
} catch {
|
||||
return; // network failure fetching an optional file — nothing to restore
|
||||
}
|
||||
if (!data.content) return;
|
||||
|
||||
try {
|
||||
const overrides: CaptionOverrideEntry[] = JSON.parse(data.content);
|
||||
if (!Array.isArray(overrides)) return;
|
||||
if (!Array.isArray(overrides)) throw new Error("not an array");
|
||||
|
||||
const model = state.model;
|
||||
const allSegIds: string[] = [];
|
||||
@@ -171,7 +227,10 @@ export function useCaptionSync(projectId: string | null) {
|
||||
suppressSaveRef.current = true;
|
||||
useCaptionStore.getState().setModel({ ...model, segments: newSegments });
|
||||
} catch {
|
||||
// No overrides file
|
||||
// File exists but is unreadable — previous edits would silently not load.
|
||||
useCaptionStore
|
||||
.getState()
|
||||
.setSyncError("caption-overrides.json is corrupt — earlier caption edits didn't load");
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldHandleCaptionNudgeKey } from "./keyboard";
|
||||
|
||||
@@ -35,4 +37,14 @@ describe("shouldHandleCaptionNudgeKey", () => {
|
||||
it("ignores non-arrow keys", () => {
|
||||
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("KeyL"))).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores arrows when the event target is an editable element", () => {
|
||||
const input = document.createElement("input");
|
||||
const textarea = document.createElement("textarea");
|
||||
const div = document.createElement("div");
|
||||
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowUp"), input)).toBe(false);
|
||||
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowUp"), textarea)).toBe(false);
|
||||
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowUp"), div)).toBe(true);
|
||||
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowUp"), null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { isEditableTarget } from "../utils/timelineDiscovery";
|
||||
|
||||
const CAPTION_NUDGE_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);
|
||||
|
||||
type CaptionNudgeKeyEvent = Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "key">;
|
||||
|
||||
export function shouldHandleCaptionNudgeKey(event: CaptionNudgeKeyEvent): boolean {
|
||||
export function shouldHandleCaptionNudgeKey(
|
||||
event: CaptionNudgeKeyEvent,
|
||||
target?: EventTarget | null,
|
||||
): boolean {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return false;
|
||||
if (target != null && isEditableTarget(target)) return false;
|
||||
return CAPTION_NUDGE_KEYS.has(event.key);
|
||||
}
|
||||
|
||||
// Single editable-target check for the caption surface — re-exported from the
|
||||
// shared timeline helper so the two never diverge (it also covers ARIA
|
||||
// textbox/searchbox/combobox roles and nested editors via closest()).
|
||||
export { isEditableTarget as isEditableEventTarget };
|
||||
|
||||
@@ -9,17 +9,36 @@ import {
|
||||
|
||||
let nextSplitId = 0;
|
||||
|
||||
const HISTORY_CAP = 50;
|
||||
/** Coalesce rapid same-target edits (typing, nudging) into one history entry. */
|
||||
const HISTORY_COALESCE_MS = 800;
|
||||
|
||||
interface CaptionState {
|
||||
isEditMode: boolean;
|
||||
/** User explicitly exited caption editing — suppresses auto re-activation. */
|
||||
dismissed: boolean;
|
||||
model: CaptionModel | null;
|
||||
selectedSegmentIds: Set<string>;
|
||||
selectedGroupId: string | null;
|
||||
sourceFilePath: string | null;
|
||||
/** Load/save failure surfaced to the user (null = healthy). */
|
||||
syncError: string | null;
|
||||
/** Registered by useCaptionSync so error banners can retry the save. */
|
||||
retrySave: (() => void) | null;
|
||||
|
||||
// Undo/redo (in-memory model snapshots)
|
||||
past: CaptionModel[];
|
||||
future: CaptionModel[];
|
||||
undo: () => CaptionModel | null;
|
||||
redo: () => CaptionModel | null;
|
||||
|
||||
// Basic
|
||||
setEditMode: (active: boolean) => void;
|
||||
setDismissed: (dismissed: boolean) => void;
|
||||
setModel: (model: CaptionModel | null) => void;
|
||||
setSourceFilePath: (path: string | null) => void;
|
||||
setSyncError: (error: string | null) => void;
|
||||
setRetrySave: (fn: (() => void) | null) => void;
|
||||
|
||||
// Selection
|
||||
selectSegment: (id: string, additive?: boolean) => void;
|
||||
@@ -53,19 +72,70 @@ interface CaptionState {
|
||||
|
||||
const initialState = {
|
||||
isEditMode: false,
|
||||
dismissed: false,
|
||||
model: null,
|
||||
selectedSegmentIds: new Set<string>(),
|
||||
selectedGroupId: null,
|
||||
sourceFilePath: null,
|
||||
syncError: null,
|
||||
retrySave: null,
|
||||
past: [] as CaptionModel[],
|
||||
future: [] as CaptionModel[],
|
||||
};
|
||||
|
||||
// Coalescing bookkeeping lives outside the store — it is not renderable state.
|
||||
let lastHistoryKey: string | null = null;
|
||||
let lastHistoryAt = 0;
|
||||
|
||||
/**
|
||||
* Snapshot the current model onto the undo stack before a mutation.
|
||||
* `key` identifies the edit target so rapid repeats (typing a value, arrow-key
|
||||
* nudging) coalesce into a single entry instead of one per keystroke.
|
||||
*/
|
||||
function pushHistory(
|
||||
state: Pick<CaptionState, "model" | "past">,
|
||||
key: string,
|
||||
): Partial<Pick<CaptionState, "past" | "future">> {
|
||||
if (!state.model) return {};
|
||||
const now = Date.now();
|
||||
if (lastHistoryKey === key && now - lastHistoryAt < HISTORY_COALESCE_MS) {
|
||||
lastHistoryAt = now;
|
||||
return { future: [] };
|
||||
}
|
||||
lastHistoryKey = key;
|
||||
lastHistoryAt = now;
|
||||
const past = [...state.past, state.model].slice(-HISTORY_CAP);
|
||||
return { past, future: [] };
|
||||
}
|
||||
|
||||
export const useCaptionStore = create<CaptionState>((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// Basic
|
||||
setEditMode: (active) => set({ isEditMode: active }),
|
||||
setDismissed: (dismissed) => set({ dismissed }),
|
||||
setModel: (model) => set({ model }),
|
||||
setSourceFilePath: (path) => set({ sourceFilePath: path }),
|
||||
setSyncError: (error) => set({ syncError: error }),
|
||||
setRetrySave: (fn) => set({ retrySave: fn }),
|
||||
|
||||
// Undo/redo
|
||||
undo: () => {
|
||||
const { model, past, future } = get();
|
||||
const prev = past[past.length - 1];
|
||||
if (!prev || !model) return null;
|
||||
lastHistoryKey = null;
|
||||
set({ model: prev, past: past.slice(0, -1), future: [...future, model] });
|
||||
return prev;
|
||||
},
|
||||
redo: () => {
|
||||
const { model, past, future } = get();
|
||||
const next = future[future.length - 1];
|
||||
if (!next || !model) return null;
|
||||
lastHistoryKey = null;
|
||||
set({ model: next, past: [...past, model].slice(-HISTORY_CAP), future: future.slice(0, -1) });
|
||||
return next;
|
||||
},
|
||||
|
||||
// Selection
|
||||
selectSegment: (id, additive = false) =>
|
||||
@@ -111,7 +181,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
|
||||
if (!segment) return {};
|
||||
const segments = new Map(state.model.segments);
|
||||
segments.set(segmentId, { ...segment, style: { ...segment.style, ...style } });
|
||||
return { model: { ...state.model, segments } };
|
||||
return {
|
||||
...pushHistory(state, `seg-style:${segmentId}`),
|
||||
model: { ...state.model, segments },
|
||||
};
|
||||
}),
|
||||
|
||||
updateSegmentText: (segmentId, text) =>
|
||||
@@ -121,7 +194,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
|
||||
if (!segment) return {};
|
||||
const segments = new Map(state.model.segments);
|
||||
segments.set(segmentId, { ...segment, text });
|
||||
return { model: { ...state.model, segments } };
|
||||
return {
|
||||
...pushHistory(state, `seg-text:${segmentId}`),
|
||||
model: { ...state.model, segments },
|
||||
};
|
||||
}),
|
||||
|
||||
updateSegmentTiming: (segmentId, start, end) =>
|
||||
@@ -131,7 +207,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
|
||||
if (!segment) return {};
|
||||
const segments = new Map(state.model.segments);
|
||||
segments.set(segmentId, { ...segment, start, end });
|
||||
return { model: { ...state.model, segments } };
|
||||
return {
|
||||
...pushHistory(state, `seg-timing:${segmentId}`),
|
||||
model: { ...state.model, segments },
|
||||
};
|
||||
}),
|
||||
|
||||
// Group mutations
|
||||
@@ -142,7 +221,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
|
||||
if (!group) return {};
|
||||
const groups = new Map(state.model.groups);
|
||||
groups.set(groupId, { ...group, style: { ...group.style, ...style } });
|
||||
return { model: { ...state.model, groups } };
|
||||
return {
|
||||
...pushHistory(state, `group-style:${groupId}`),
|
||||
model: { ...state.model, groups },
|
||||
};
|
||||
}),
|
||||
|
||||
updateGroupContainer: (groupId, container) =>
|
||||
@@ -155,7 +237,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
|
||||
...group,
|
||||
containerStyle: { ...group.containerStyle, ...container },
|
||||
});
|
||||
return { model: { ...state.model, groups } };
|
||||
return {
|
||||
...pushHistory(state, `group-container:${groupId}`),
|
||||
model: { ...state.model, groups },
|
||||
};
|
||||
}),
|
||||
|
||||
updateGroupAnimation: (groupId, phase, animation) =>
|
||||
@@ -173,7 +258,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
|
||||
...group,
|
||||
animation: { ...group.animation, [phase]: mergedPhase },
|
||||
});
|
||||
return { model: { ...state.model, groups } };
|
||||
return {
|
||||
...pushHistory(state, `group-anim:${groupId}:${phase}`),
|
||||
model: { ...state.model, groups },
|
||||
};
|
||||
}),
|
||||
|
||||
splitGroup: (groupId, atSegmentId) =>
|
||||
@@ -206,7 +294,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
|
||||
}
|
||||
});
|
||||
|
||||
return { model: { ...state.model, groups, segments, groupOrder } };
|
||||
return {
|
||||
...pushHistory(state, `split:${groupId}:${atSegmentId}:${nextSplitId}`),
|
||||
model: { ...state.model, groups, segments, groupOrder },
|
||||
};
|
||||
}),
|
||||
|
||||
mergeGroups: (groupId1, groupId2) =>
|
||||
@@ -236,7 +327,11 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
|
||||
// Clear selection if it referenced group2
|
||||
const selectedGroupId = state.selectedGroupId === groupId2 ? null : state.selectedGroupId;
|
||||
|
||||
return { model: { ...state.model, groups, segments, groupOrder }, selectedGroupId };
|
||||
return {
|
||||
...pushHistory(state, `merge:${groupId1}:${groupId2}`),
|
||||
model: { ...state.model, groups, segments, groupOrder },
|
||||
selectedGroupId,
|
||||
};
|
||||
}),
|
||||
|
||||
// Bulk
|
||||
@@ -250,7 +345,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
|
||||
segments.set(segmentId, { ...segment, style: { ...segment.style, ...style } });
|
||||
}
|
||||
}
|
||||
return { model: { ...state.model, segments } };
|
||||
return {
|
||||
...pushHistory(state, `sel-style:${[...state.selectedSegmentIds].join(",")}`),
|
||||
model: { ...state.model, segments },
|
||||
};
|
||||
}),
|
||||
|
||||
applyAnimationToAll: (animation) =>
|
||||
@@ -260,13 +358,21 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
|
||||
for (const [id, group] of groups) {
|
||||
groups.set(id, { ...group, animation });
|
||||
}
|
||||
return { model: { ...state.model, groups } };
|
||||
return {
|
||||
...pushHistory(state, "apply-anim-all"),
|
||||
model: { ...state.model, groups },
|
||||
};
|
||||
}),
|
||||
|
||||
// Reset
|
||||
reset: () =>
|
||||
set({
|
||||
// Reset — keeps retrySave (registered once by useCaptionSync for the app's lifetime)
|
||||
reset: () => {
|
||||
lastHistoryKey = null;
|
||||
set((state) => ({
|
||||
...initialState,
|
||||
retrySave: state.retrySave,
|
||||
selectedSegmentIds: new Set<string>(),
|
||||
}),
|
||||
past: [],
|
||||
future: [],
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user