mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
The panel does not trap focus and leaves the rest of the editor operable, so aria-modal would tell assistive tech the whole app is inert while it is open. role=dialog plus aria-controls and Escape is the correct non-modal disclosure shape.
329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
import { useState, useCallback, useId, memo } from "react";
|
|
import { formatTime, frameToSeconds } from "../lib/time";
|
|
import { Tooltip } from "../../components/ui";
|
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
|
|
const SHORTCUT_SECTIONS = [
|
|
{
|
|
title: "Playback",
|
|
hints: [
|
|
{ key: "Space", label: "Play / Pause" },
|
|
{ key: "J", label: "Play backward" },
|
|
{ key: "K", label: "Stop" },
|
|
{ key: "L", label: "Play forward" },
|
|
{ key: "M", label: "Toggle mute" },
|
|
{ key: "⇧L", label: "Toggle loop" },
|
|
{ key: "←/→", label: "Step 1 frame" },
|
|
{ key: "⇧←/⇧→", label: "Step 10 frames" },
|
|
{ key: "F", label: "Toggle fullscreen" },
|
|
],
|
|
},
|
|
{
|
|
title: "Keyframes",
|
|
hints: [
|
|
{ key: "K", label: "Add keyframe at playhead" },
|
|
{ key: "Del", label: "Delete selected keyframe" },
|
|
{ key: "H", label: "Toggle hold / bezier" },
|
|
{ key: "U", label: "Expand / collapse properties" },
|
|
{ key: "R", label: "Record gesture" },
|
|
],
|
|
},
|
|
{
|
|
title: "Editing",
|
|
hints: [
|
|
{ key: "⌘Z", label: "Undo" },
|
|
{ key: "⌘⇧Z", label: "Redo" },
|
|
{ key: "⌘C", label: "Copy element" },
|
|
{ key: "⌘V", label: "Paste element" },
|
|
{ key: "⌘X", label: "Cut element" },
|
|
{ key: "S", label: "Split clip at playhead" },
|
|
{ key: "⌘G", label: "Group elements" },
|
|
{ key: "⌘⇧G", label: "Ungroup" },
|
|
{ key: "Del", label: "Delete selected element" },
|
|
],
|
|
},
|
|
{
|
|
title: "Gesture recording modifiers",
|
|
hints: [
|
|
{ key: "Drag", label: "Record x / y position" },
|
|
{ key: "Scroll", label: "Record z depth" },
|
|
{ key: "⇧ Drag", label: "Record rotationX / rotationY" },
|
|
{ key: "⌥ Drag", label: "Record rotation" },
|
|
{ key: "⌘ Drag↕", label: "Record opacity" },
|
|
{ key: "⌘ Scroll", label: "Record scale" },
|
|
],
|
|
},
|
|
{
|
|
title: "Canvas",
|
|
hints: [
|
|
{ key: "Drag", label: "Move element / add keyframe" },
|
|
{ key: "⌥ Drag", label: "Move entire animation path" },
|
|
{ key: "⇧ Drag", label: "Uniform resize" },
|
|
],
|
|
},
|
|
{
|
|
title: "Crop",
|
|
hints: [
|
|
{ key: "Drag edge", label: "Crop a side" },
|
|
{ key: "Drag center", label: "Reposition the crop" },
|
|
],
|
|
},
|
|
{
|
|
title: "Panels",
|
|
hints: [
|
|
{ key: "⌘1", label: "Compositions tab" },
|
|
{ key: "⌘2", label: "Assets tab" },
|
|
],
|
|
},
|
|
{
|
|
title: "Work area",
|
|
hints: [
|
|
{ key: "I", label: "Set in-point" },
|
|
{ key: "⇧I", label: "Clear in-point" },
|
|
{ key: "O", label: "Set out-point" },
|
|
{ key: "⇧O", label: "Clear out-point" },
|
|
{ key: "A", label: "Jump to in-point" },
|
|
{ key: "E", label: "Jump to out-point" },
|
|
],
|
|
},
|
|
] as const;
|
|
|
|
interface ShortcutsPanelProps {
|
|
disabled: boolean;
|
|
duration: number;
|
|
inPoint: number | null;
|
|
outPoint: number | null;
|
|
setInPoint: (v: number | null) => void;
|
|
setOutPoint: (v: number | null) => void;
|
|
onSeek: (time: number) => void;
|
|
}
|
|
|
|
export const ShortcutsPanel = memo(function ShortcutsPanel({
|
|
disabled,
|
|
duration,
|
|
inPoint,
|
|
outPoint,
|
|
setInPoint,
|
|
setOutPoint,
|
|
onSeek,
|
|
}: ShortcutsPanelProps) {
|
|
const [showShortcuts, setShowShortcuts] = useState(false);
|
|
const [jumpFrame, setJumpFrame] = useState("");
|
|
const shortcutsPanelId = useId();
|
|
const closeShortcuts = useCallback(() => setShowShortcuts(false), []);
|
|
const shortcutsPanelRef = useContextMenuDismiss(closeShortcuts);
|
|
|
|
const commitJumpFrame = useCallback(() => {
|
|
if (disabled) return;
|
|
const frame = Number.parseInt(jumpFrame, 10);
|
|
if (!Number.isFinite(frame) || duration <= 0) return;
|
|
onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame))));
|
|
}, [disabled, duration, jumpFrame, onSeek]);
|
|
|
|
const handleJumpSubmit = useCallback(
|
|
(e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
commitJumpFrame();
|
|
},
|
|
[commitJumpFrame],
|
|
);
|
|
|
|
const handleJumpKeyDown = useCallback(
|
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key !== "Enter") return;
|
|
e.preventDefault();
|
|
commitJumpFrame();
|
|
},
|
|
[commitJumpFrame],
|
|
);
|
|
|
|
return (
|
|
<div ref={shortcutsPanelRef} className="relative flex-shrink-0">
|
|
<Tooltip label="Shortcuts and tools">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowShortcuts((v) => !v)}
|
|
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
|
|
showShortcuts ? "text-neutral-200" : "text-neutral-600 hover:text-neutral-300"
|
|
}`}
|
|
aria-label="Shortcuts and tools"
|
|
aria-expanded={showShortcuts}
|
|
aria-controls={shortcutsPanelId}
|
|
>
|
|
<svg
|
|
width="11"
|
|
height="11"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.75"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<rect x="2" y="4" width="20" height="16" rx="2" />
|
|
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8" />
|
|
</svg>
|
|
</button>
|
|
</Tooltip>
|
|
{showShortcuts && (
|
|
<div
|
|
id={shortcutsPanelId}
|
|
role="dialog"
|
|
// Deliberately NOT aria-modal. This is a non-modal disclosure: focus is
|
|
// not trapped and the rest of the editor stays operable, so claiming
|
|
// modality would make assistive tech treat the whole app as inert.
|
|
className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto"
|
|
style={{
|
|
background: "#161618",
|
|
border: "1px solid rgba(255,255,255,0.08)",
|
|
maxHeight: "min(280px, calc(100vh - 80px))",
|
|
}}
|
|
>
|
|
<div className="px-3 pt-3 pb-2.5">
|
|
<p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
|
|
Jump to frame
|
|
</p>
|
|
<form onSubmit={handleJumpSubmit} className="flex items-center gap-1.5">
|
|
<input
|
|
value={jumpFrame}
|
|
onChange={(e) => setJumpFrame(e.target.value)}
|
|
disabled={disabled}
|
|
inputMode="numeric"
|
|
pattern="[0-9]*"
|
|
aria-label="Jump to frame"
|
|
placeholder="frame number"
|
|
className="h-6 flex-1 rounded border border-neutral-700 bg-neutral-900 px-2 text-[10px] font-mono tabular-nums text-neutral-200 outline-none transition-colors placeholder:text-neutral-600 focus:border-studio-accent/60"
|
|
onKeyDown={handleJumpKeyDown}
|
|
onBlur={commitJumpFrame}
|
|
/>
|
|
<Tooltip label="Jump to frame">
|
|
<button
|
|
type="submit"
|
|
disabled={disabled}
|
|
className="h-6 px-2 rounded border border-neutral-700 text-[10px] text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800 disabled:opacity-40"
|
|
>
|
|
Go
|
|
</button>
|
|
</Tooltip>
|
|
</form>
|
|
</div>
|
|
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }} />
|
|
<div className="px-3 pt-2.5 pb-2">
|
|
<p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
|
|
Work area
|
|
</p>
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[20px] text-center"
|
|
style={{ background: "rgba(255,255,255,0.05)" }}
|
|
>
|
|
I
|
|
</span>
|
|
<span className="text-[10px] text-neutral-400">In-point</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
{inPoint !== null ? (
|
|
<>
|
|
<span className="font-mono text-[10px] text-neutral-300">
|
|
{formatTime(inPoint)}
|
|
</span>
|
|
<Tooltip label="Clear in-point">
|
|
<button
|
|
type="button"
|
|
onClick={() => setInPoint(null)}
|
|
className="w-4 h-4 flex items-center justify-center rounded text-neutral-500 hover:text-neutral-200 transition-colors"
|
|
aria-label="Clear in-point"
|
|
>
|
|
<svg
|
|
width="8"
|
|
height="8"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.5"
|
|
>
|
|
<path d="M18 6L6 18M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</Tooltip>
|
|
</>
|
|
) : (
|
|
<span className="text-[10px] text-neutral-600">—</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[20px] text-center"
|
|
style={{ background: "rgba(255,255,255,0.05)" }}
|
|
>
|
|
O
|
|
</span>
|
|
<span className="text-[10px] text-neutral-400">Out-point</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
{outPoint !== null ? (
|
|
<>
|
|
<span className="font-mono text-[10px] text-neutral-300">
|
|
{formatTime(outPoint)}
|
|
</span>
|
|
<Tooltip label="Clear out-point">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOutPoint(null)}
|
|
className="w-4 h-4 flex items-center justify-center rounded text-neutral-500 hover:text-neutral-200 transition-colors"
|
|
aria-label="Clear out-point"
|
|
>
|
|
<svg
|
|
width="8"
|
|
height="8"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.5"
|
|
>
|
|
<path d="M18 6L6 18M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</Tooltip>
|
|
</>
|
|
) : (
|
|
<span className="text-[10px] text-neutral-600">—</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }} />
|
|
<div className="px-3 pt-2.5 pb-3 flex flex-col gap-3">
|
|
{SHORTCUT_SECTIONS.map((section) => (
|
|
<div key={section.title}>
|
|
<p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
|
|
{section.title}
|
|
</p>
|
|
<div className="flex flex-col gap-1">
|
|
{section.hints.map((hint) => (
|
|
<div key={hint.key} className="flex items-center gap-3">
|
|
<span
|
|
className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[36px] text-center"
|
|
style={{ background: "rgba(255,255,255,0.05)" }}
|
|
>
|
|
{hint.key}
|
|
</span>
|
|
<span className="text-[10px] text-neutral-400">{hint.label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|