Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions src/components/KeyboardShortcutPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"use client";

import { useEffect } from "react";
import { X } from "lucide-react";

interface ShortcutPanelProps {
open: boolean;
onClose: () => void;
}

const shortcuts = [
{ keys: ["Cmd/Ctrl", "+", "Enter"], action: "Start export" },
{ keys: ["Space"], action: "Play / pause preview" },
{ keys: ["?"], action: "Toggle this shortcut panel" },
{ keys: ["Escape"], action: "Close overlay / cancel" },
{ keys: ["M"], action: "Toggle audio mute" },
];

function ShortcutRow({ keys, action }: { keys: string[]; action: string }) {
return (
<li className="flex items-center justify-between gap-4 rounded-xl border border-[var(--border)] bg-[var(--bg)] px-3 py-2.5">
<span className="text-sm text-[var(--muted)]">{action}</span>
<div className="flex items-center gap-1.5 shrink-0">
{keys.map((key) => (
<kbd
key={key}
className="inline-flex items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 py-1 text-[11px] font-mono text-[var(--muted)] shadow-sm"
>
{key}
</kbd>
))}
</div>
</li>
);
}

export default function KeyboardShortcutPanel({ open, onClose }: ShortcutPanelProps) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};

const previousOverflow = document.body.style.overflow;

document.body.style.overflow = "hidden";
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = previousOverflow;
};
}, [open, onClose]);

if (!open) return null;

return (
<div className="fixed inset-0 z-50 flex items-end justify-center p-3 sm:items-center sm:justify-end sm:p-4">
<div
className="absolute inset-0 bg-black/55 backdrop-blur-sm"
onClick={onClose}
aria-hidden
/>
<div
id="keyboard-shortcut-panel"
role="dialog"
aria-modal="true"
aria-labelledby="shortcut-panel-title"
className="relative w-full max-w-xl overflow-hidden rounded-t-3xl border border-[var(--border)] bg-[var(--surface)] shadow-2xl sm:max-w-lg sm:rounded-3xl"
>
<div className="flex items-start justify-between border-b border-[var(--border)] px-5 py-4 sm:px-6">
<div>
<p className="text-[10px] font-heading font-bold uppercase tracking-[0.35em] text-[var(--muted)]">Quick help</p>
<h3 id="shortcut-panel-title" className="mt-1 text-lg font-heading font-bold text-[var(--text)]">
Keyboard Shortcuts
</h3>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close shortcuts"
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--bg)] text-[var(--muted)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text)]"
>
<X size={14} />
</button>
</div>

<div className="px-5 py-4 sm:px-6 sm:py-5">
<p className="text-sm text-[var(--muted)]">
Keep the editor moving without hunting for controls.
</p>

<ul className="mt-4 space-y-2">
{shortcuts.map(({ keys, action }) => (
<ShortcutRow key={action} keys={keys} action={action} />
))}
</ul>
</div>
</div>
</div>
);
}
124 changes: 28 additions & 96 deletions src/components/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ import ExportSettings from "./ExportSettings";
import ExportOverlay from "./ExportOverlay";
import DownloadResult from "./DownloadResult";
import ImageOverlay from "./ImageOverlay"
import KeyboardShortcutPanel from "./KeyboardShortcutPanel";

import { cn } from "@/lib/utils";
import {
Layers, Crop, Scissors, RotateCw, Volume2,
SlidersHorizontal, Zap, AlertTriangle, Github, Copy
SlidersHorizontal, Zap, AlertTriangle, Copy, HelpCircle
} from "lucide-react";
import OnboardingTour from "./OnboardingTour";
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
Expand Down Expand Up @@ -50,94 +51,6 @@ function Section({ icon, title, children, delay = 0 }: SectionProps) {
);
}

/** Inline keyboard hint badge. */
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd className="inline-flex items-center justify-center min-w-[1.5rem] px-1.5 py-0.5 rounded border border-[var(--border)] bg-[var(--bg)] text-[10px] font-mono text-[var(--muted)] leading-none">
{children}
</kbd>
);
}

/** Collapsible panel that lists all keyboard shortcuts. */
function KeyboardShortcutsPanel() {
const [open, setOpen] = useState(false);

const shortcuts: { keys: React.ReactNode[]; label: string }[] = [
{
keys: [
<Kbd key="ctrl">Ctrl</Kbd>,
<span key="plus1" className="text-[var(--muted)] text-xs">+</span>,
<Kbd key="shift">Shift</Kbd>,
<span key="plus2" className="text-[var(--muted)] text-xs">+</span>,
<Kbd key="e">E</Kbd>
],
label: "Export video",
},
{
keys: [<Kbd key="m">M</Kbd>],
label: "Toggle audio mute",
},
{
keys: [<Kbd key="r">R</Kbd>],
label: "Reset all settings",
},
{
keys: [<Kbd key="esc">Esc</Kbd>],
label: "Cancel export",
},
{
keys: [<Kbd key="1">1</Kbd>, <span key="dash" className="text-[var(--muted)] text-xs">–</span>, <Kbd key="9">9</Kbd>],
label: "Switch preset by index",
},
{
keys: [<Kbd key="question">?</Kbd>],
label: "Toggle this panel",
},
];

return (
<div className="rounded-xl border border-[var(--border)] bg-[var(--surface)] animate-fade-in overflow-hidden">
<button
type="button"
aria-expanded={open}
aria-controls="keyboard-shortcuts-list"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-[var(--border)] transition-colors duration-150"
>
<span className="text-[10px] font-heading font-bold uppercase tracking-widest text-[var(--muted)] flex items-center gap-2">
<Kbd>⌨</Kbd>
Keyboard Shortcuts
</span>
<svg
aria-hidden="true"
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
className={cn("text-[var(--muted)] transition-transform duration-200", open && "rotate-180")}
>
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>

{open && (
<ul
id="keyboard-shortcuts-list"
className="px-4 pb-3 space-y-2 border-t border-[var(--border)]"
>
{shortcuts.map(({ keys, label }) => (
<li key={label} className="flex items-center justify-between gap-3 pt-2">
<span className="text-xs text-[var(--muted)]">{label}</span>
<span className="flex items-center gap-1 shrink-0">{keys}</span>
</li>
))}
</ul>
)}
</div>
);
}

export default function VideoEditor() {
const {
file, duration, recipe, status, progress,
Expand All @@ -149,6 +62,7 @@ export default function VideoEditor() {
overlayPosition, setOverlayPosition,
overlaySize, setOverlaySize,
overlayOpacity, setOverlayOpacity,
isShortcutPanelOpen, closeShortcutPanel, toggleShortcutPanel,
recommendedPreset,
toggleSound,
} = useVideoEditor();
Expand Down Expand Up @@ -203,6 +117,7 @@ export default function VideoEditor() {
return (
<div className="min-h-screen relative flex flex-col" style={{ background: "var(--bg)" }}>
<ExportOverlay status={status} progress={progress} onCancel={cancelExport} />
<KeyboardShortcutPanel open={isShortcutPanelOpen} onClose={closeShortcutPanel} />
<OnboardingTour />

<div aria-live="polite" aria-atomic="true" className="sr-only">
Expand All @@ -213,7 +128,7 @@ export default function VideoEditor() {

<div className="max-w-6xl mx-auto px-4 py-8 pb-6 flex-1 w-full">

<header className="mb-10 flex items-end justify-between animate-fade-in">
<header className="mb-10 flex items-end justify-between gap-4 animate-fade-in">
<div
className="inline-block px-5 py-3 rounded-xl border border-[var(--border)] bg-[var(--surface)] shadow-sm border-l-4 border-l-film-600"
aria-label="Reframe — video editor"
Expand All @@ -225,9 +140,29 @@ export default function VideoEditor() {
Your video, any format
</p>
</div>
<div className="hidden sm:flex items-center gap-2 text-sm font-heading font-semibold uppercase tracking-widest text-[var(--muted)] pb-1">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 inline-block animate-pulse" />
No login. No ads. 100% private - your video never leaves your device.
<div className="flex flex-col items-end gap-3 pb-1">
<div className="hidden sm:flex items-center gap-2 text-sm font-heading font-semibold uppercase tracking-widest text-[var(--muted)] text-right">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 inline-block animate-pulse" />
No login. No ads. 100% private - your video never leaves your device.
</div>
<button
type="button"
onClick={toggleShortcutPanel}
aria-expanded={isShortcutPanelOpen}
aria-controls="keyboard-shortcut-panel"
aria-label={isShortcutPanelOpen ? "Close keyboard shortcuts" : "Open keyboard shortcuts"}
title="Keyboard shortcuts"
className={cn(
"inline-flex items-center gap-2 rounded-full border px-3 py-2 text-xs font-heading font-bold uppercase tracking-widest transition-colors",
isShortcutPanelOpen
? "border-film-600 bg-film-600 text-white shadow-sm"
: "border-[var(--border)] bg-[var(--surface)] text-[var(--muted)] hover:bg-[var(--border)] hover:text-[var(--text)]"
)}
>
<HelpCircle size={14} />
<span className="hidden sm:inline">Shortcuts</span>
<span className="sm:hidden">?</span>
</button>
</div>
</header>

Expand Down Expand Up @@ -475,9 +410,6 @@ export default function VideoEditor() {
</button>
</div>
</div>

<KeyboardShortcutsPanel />

<button
id="export-button"
type="button"
Expand Down
39 changes: 39 additions & 0 deletions src/hooks/useVideoEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,17 @@ function validateRecipe(recipe: EditRecipe, duration: number ): string | null {
);
}

function isEditableShortcutTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;

return (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable
);
}

export function useVideoEditor() {
const [file, setFile] = useState<File | null>(null);
const [duration, setDuration] = useState<number>(0);
Expand Down Expand Up @@ -147,6 +158,7 @@ export function useVideoEditor() {
const [overlayPosition, setOverlayPosition] = useState<OverlayPosition>("bottom-right");
const [overlaySize, setOverlaySize] = useState(150);
const [overlayOpacity, setOverlayOpacity] = useState(100);
const [isShortcutPanelOpen, setIsShortcutPanelOpen] = useState(false);

const updateRecipe = useCallback((patch: Partial<EditRecipe>) => {
setRecipe((prev) => {
Expand Down Expand Up @@ -495,14 +507,25 @@ export function useVideoEditor() {

useEffect(() => {
const handleKeydown = (e: KeyboardEvent) => {
if (isEditableShortcutTarget(e.target)) {
return;
}

if (
(e.ctrlKey || e.metaKey) &&
e.key === "Enter" &&
file &&
status !== "loading-engine" &&
status !== "exporting"
) {
e.preventDefault();
handleExport();
return;
}

if (e.key === "?" || (e.shiftKey && e.key === "/")) {
e.preventDefault();
setIsShortcutPanelOpen((open) => !open);
}
};

Expand Down Expand Up @@ -602,6 +625,18 @@ export function useVideoEditor() {
updateRecipe({ soundOnCompletion: !recipe.soundOnCompletion });
}, [recipe.soundOnCompletion, updateRecipe]);

const openShortcutPanel = useCallback(() => {
setIsShortcutPanelOpen(true);
}, []);

const closeShortcutPanel = useCallback(() => {
setIsShortcutPanelOpen(false);
}, []);

const toggleShortcutPanel = useCallback(() => {
setIsShortcutPanelOpen((open) => !open);
}, []);

return {
file,
duration,
Expand Down Expand Up @@ -635,6 +670,10 @@ export function useVideoEditor() {
setOverlaySize,
overlayOpacity,
setOverlayOpacity,
isShortcutPanelOpen,
openShortcutPanel,
closeShortcutPanel,
toggleShortcutPanel,
recommendedPreset,
toggleSound,
};
Expand Down