Keybind settings: New menu and revamped keybind support (#1699)

## Summary

Adds a unified, remappable keyboard-shortcut system
(`@storyteller/keybinds`) across the webapp and desktop app, replacing
the scattered hardcoded `keydown` listeners in the 3D editor, 2D editor,
and moodboard with one registry, two presets (**Gamer** = today's
defaults, **Blender-like**), a Settings → Keybinds pane, and a hold-Ctrl
/ pinnable cheatsheet on every editor surface.

This PR was approved in June and then sat behind ~195 commits on `main`
(timeline, build/record modes, lucide migration). It has been merged
with current `main` and extended to cover the features that landed
since. **Nothing changes by default** — the Gamer preset reproduces
every existing key exactly.

## Architecture

- **Registry of `ActionDef`s** keyed by surface-namespaced ids
(`pagescene.timeline.playPause`), bound to physical `event.code`.
Resolution: user override → preset delta → base bindings. Persisted in
the `artcraft-keybinds` zustand store.
- **Five surfaces**: `global`, `pagescene` (3D), `pagedraw` (2D),
`moodboard` (canvas), `moodboard-grid` (board library / inspector /
presentation).
- **Declared availability** — `ActionDef.when(ctx)` gates decide whether
a binding is live for the current editor state (`sceneMode`, `encoding`,
`timelineExpanded`, `timelineSelection`, …). An unavailable binding
falls through *without consuming the event*, which is what lets Space /
arrows / Delete / Escape be shared by context-exclusive actions. A
sampled-context checker (`actionsCoAvailable`) proves the exclusivity in
tests and drives the conflict UI in Settings.
- **Global surface is a pure trigger layer**: features register behavior
with `useGlobalAction(id, handler)`; a window capture-phase dispatcher
fires above the modal key-swallow, and an unregistered binding is inert
per host. First inhabitant: Ctrl/Cmd+B sidebar. This is the intended
home for the upcoming privacy-blur toggle (one `ActionDef` + one binding
+ one `useGlobalAction` call).
- Cross-surface conflict detection (global ↔ everything, both
directions); preset-integrity tests guard every preset against
same-surface collisions.
- **Video editor keeps its own keybinds system** — deliberate non-goal.

## What's in it

**3D editor (pagescene)**
- Timeline actions: Space play/pause, ←/→ frame step or keyframe/strip
nudge, Home/End, `I` add keyframe (Blender parity), Shift+T expand,
Delete/Backspace on a valid timeline selection (exact complement of
scene-object delete).
- Record mode: Tab toggles build/record, `C` capture still, `R` record
video (bare letters — Firefox eats Ctrl+Shift+S/E), Escape cancels an
in-flight encode (overlay Cancel button too). Record mode is immutable:
all scene-mutating actions gate off, Escape no longer drops out of the
render cam.
- **Per-edit timeline undo** — every timeline edit is one Ctrl+Z step
(`TimelineEditAction` with whole-timeline snapshots; drags and the
easing popover coalesce into single steps). Save now only moves the
Cancel baseline; Cancel is itself undoable.
- The old "Shortcuts" modal is replaced by the live cheatsheet, pinnable
from the same button.
- Dead code removed: `MouseControls.onkeydown`, `OnboardingHelper`, the
hardcoded `Help` view, three unused pagedraw hotkey hooks.

**2D editor (pagedraw)**
- Tool hotkeys 1–5 with live key badges on the toolbar icons; the shape
key cycles rectangle → circle → triangle (submenu-aware).
- Ctrl+= / Ctrl+- (and numpad) resize the active tool. The eraser now
has its own size (was sharing the brush's) and its own slider popout;
also fixes the eraser button never highlighting.

**Moodboard**
- Canvas keys unchanged (now registry-backed), plus Ctrl+0 reset
viewport.
- Grid: rate 0–5 / `P` pick / Delete / Escape, inspector and
presentation ←/→/Space/Esc — all rebindable. Escape now closes the
inspector; arrows no longer navigate while typing in the tag field.

**Settings & cheatsheet**
- Settings → Keybinds: preset picker, name/key search, per-action rebind
with conflict warnings, per-section reset, and a bottom "danger zone"
reset-all with a two-step confirm (2 s lockout + visible 5 s
auto-disarm).
- Cheatsheet: hold Ctrl (⌘) 3 s to peek; **General → "Keep shortcut
cheatsheet open"** makes it stick until Esc / click-outside; the 3D
"Shortcuts" button pins it.

## Testing

- `@storyteller/keybinds` unit tests (29): resolution layering,
overrides, per-surface/global reset, conflict detection incl.
global↔surface, matcher semantics, and preset integrity across all
presets.
- Builds green: `keybinds`, `ui-pagescene`, `ui-pagedraw`,
`ui-moodboard`, `ui-settings-modal`, `artcraft-webapp`, `artcraft`
(desktop).
- Manual QA pass completed on the webapp for every area above (settings
pane, cheatsheets, Blender modal transforms, timeline/record keys,
pagedraw toolbar, global surface, reset UX, pinned cheatsheet, timeline
undo, Escape contexts).

## Notes for review

- The keybinds store is unversioned-migration-free on purpose — the PR
was never merged, so there's no persisted shape to migrate from.
- Space-hold pan (moodboard) and held WASD camera movement stay as
gestures, resolved from the same store but outside the discrete-action
dispatch.

---------

Co-authored-by: bflatastic <bombayapirat@gmail.com>
This commit is contained in:
Tanish Manku
2026-08-25 00:34:26 -07:00
committed by GitHub
co-authored by bflatastic
parent 915728e9c7
commit e3f48033f9
89 changed files with 4611 additions and 1461 deletions
@@ -8,6 +8,7 @@ import {
useNavigationType,
} from "react-router-dom";
import { LoaderCircleIcon } from "lucide-react";
import { useGlobalKeybinds } from "@storyteller/keybinds";
import Home from "../pages/home";
import Media from "../pages/media";
import { ToastContainer } from "../components/toast/toast";
@@ -134,6 +135,11 @@ function ProtectedLayout() {
}
export function App() {
// App-shell mount of the global keybind dispatcher (window capture phase,
// so global actions like Ctrl+B keep working over open modals). Features
// register their behavior via useGlobalAction — see @storyteller/keybinds.
useGlobalKeybinds();
return (
<>
<ScrollToTop />
@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { Modal } from "@storyteller/ui-modal";
import { KeyIcon, SettingsIcon, UserIcon } from "lucide-react";
import { KeyIcon, KeyboardIcon, SettingsIcon, UserIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Switch } from "@storyteller/ui-switch";
import { KeybindsSettings, useKeybindsStore } from "@storyteller/keybinds";
import { useModelPickerStyleStore } from "@storyteller/ui-popover";
import { twMerge } from "tailwind-merge";
import { useEnterToGenerateStore } from "../../lib/enter-to-generate-store";
@@ -11,7 +12,7 @@ import { useSession } from "../../lib/session";
import { AccountSection } from "./AccountSection";
import { ApiKeySection } from "./ApiKeySection";
type Tab = "general" | "account" | "apiKeys";
type Tab = "general" | "keybinds" | "account" | "apiKeys";
interface SettingsModalProps {
isOpen: boolean;
@@ -20,6 +21,7 @@ interface SettingsModalProps {
const TABS: { id: Tab; label: string; icon: typeof SettingsIcon }[] = [
{ id: "general", label: "General", icon: SettingsIcon },
{ id: "keybinds", label: "Keybinds", icon: KeyboardIcon },
{ id: "account", label: "Account", icon: UserIcon },
{ id: "apiKeys", label: "API Keys", icon: KeyIcon },
];
@@ -37,10 +39,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
<Modal
isOpen={isOpen}
onClose={onClose}
className="max-w-3xl"
className="max-w-4xl"
childPadding={false}
>
<div className="h-[100dvh] sm:h-[560px]">
<div className="h-[100dvh] sm:h-[600px]">
<div className="flex h-full flex-col sm:grid sm:grid-cols-12 sm:gap-3">
<div className="relative shrink-0 border-b border-ui-panel-border p-4 sm:col-span-4 sm:border-b-0 sm:p-3 sm:pt-2 sm:after:absolute sm:after:right-0 sm:after:top-0 sm:after:h-full sm:after:w-px sm:after:bg-ui-panel-border">
<div className="hidden items-center gap-2.5 py-0.5 sm:flex">
@@ -66,12 +68,17 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
</div>
</div>
<div className="relative flex min-h-0 flex-1 flex-col overflow-y-auto sm:col-span-8 sm:h-full">
<div className="relative flex min-h-0 flex-1 flex-col overflow-y-auto sm:col-span-8 sm:h-full pl-0.5">
<div className="w-full border-b border-ui-panel-border px-4 py-2.5 sm:px-0">
<h2 className="text-[18px] font-semibold">{activeLabel}</h2>
</div>
<div className="h-full p-4 text-sm sm:p-3 sm:ps-0">
{tab === "general" && <GeneralPanel />}
{tab === "keybinds" && (
<div className="pb-4">
<KeybindsSettings />
</div>
)}
{tab === "account" && <AccountPanel />}
{tab === "apiKeys" && <ApiKeysPanel />}
</div>
@@ -89,6 +96,8 @@ function GeneralPanel() {
const setLightboxSound = useLightboxSoundStore((s) => s.setSoundEnabled);
const modelPickerStyle = useModelPickerStyleStore((s) => s.style);
const setModelPickerStyle = useModelPickerStyleStore((s) => s.setStyle);
const cheatsheetSticky = useKeybindsStore((s) => s.cheatsheetSticky);
const setCheatsheetSticky = useKeybindsStore((s) => s.setCheatsheetSticky);
return (
<div className="space-y-4 text-base-fg">
@@ -136,6 +145,23 @@ function GeneralPanel() {
offClassName="bg-white/20"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">Keep shortcut cheatsheet open</p>
<p className="text-xs opacity-70">
In the editors, holding Ctrl (⌘ on Mac) alone for a few seconds
shows a cheatsheet of the keyboard shortcuts. When on, it stays on
screen after you release the key until you press Esc or click
outside it. When off (default), it disappears as soon as you let
go.
</p>
</div>
<Switch
enabled={cheatsheetSticky}
setEnabled={setCheatsheetSticky}
offClassName="bg-white/20"
/>
</div>
</div>
);
}
@@ -2,6 +2,7 @@ import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { VariantProps, cva } from "class-variance-authority";
import { PanelLeft } from "lucide-react";
import { useGlobalAction } from "@storyteller/keybinds";
import { cn } from "./utils";
import { useIsMobile } from "./use-mobile";
@@ -24,7 +25,6 @@ const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
@@ -88,19 +88,10 @@ const SidebarProvider = React.forwardRef<
return isMobile ? setOpenMobile((o) => !o) : setOpen((o) => !o);
}, [isMobile, setOpen, setOpenMobile]);
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// The Ctrl/Cmd+B binding lives in the shared keybinds registry
// (global.ui.toggleSidebar, remappable in Settings → Keybinds); this
// only registers the behavior while the provider is mounted.
useGlobalAction("global.ui.toggleSidebar", toggleSidebar);
const state = open ? "expanded" : "collapsed";
@@ -79,6 +79,9 @@
{
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/keybinds/tsconfig.lib.json"
},
{
"path": "../../libs/components/switch/tsconfig.lib.json"
},
@@ -44,6 +44,9 @@
{
"path": "../../libs/components/gallery-modal"
},
{
"path": "../../libs/components/keybinds"
},
{
"path": "../../libs/components/switch"
},
@@ -0,0 +1,12 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
@@ -0,0 +1,33 @@
# @storyteller/keybinds
Unified, customizable keyboard shortcuts for Artcraft. A single registry of
remappable actions, switchable **presets** (Gamer / Blender-like), per-user
overrides with conflict detection, and persistence — plus the UI primitives
(`<Kbd>`, key-capture input) used by the Settings pane and the cheatsheet.
The video editor keeps its own separate keybind system and is intentionally not
covered here (Settings links out to it).
## Concepts
- **Action** — a remappable operation (`registry.ts`), id-namespaced by surface
(`pagescene.transform.translate`).
- **Binding** — `{ code, ctrl?, shift?, alt? }` keyed by physical `event.code`
so WASD survives non-QWERTY layouts.
- **Preset** — a named set of 3D binding deltas over the Gamer base
(`presets.ts`). 2D/moodboard are preset-independent.
- **Store** (`keybinds-store.ts`) — zustand + persist; resolves
override → preset → base.
## Usage
```ts
import { useResolvedKeybinds } from "@storyteller/keybinds";
const { matchAction, forAction, slotBindings } = useResolvedKeybinds();
// in a keydown handler:
const action = matchAction(e, "pagescene");
if (action === "pagescene.transform.translate") setGizmoMode("translate");
```
Engine modules that aren't React can read `useKeybindsStore.getState().resolveBindings(id)`.
@@ -0,0 +1,17 @@
{
"name": "@storyteller/keybinds",
"version": "0.0.1",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"development": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
}
}
@@ -0,0 +1,22 @@
export * from "./lib/types";
export * from "./lib/registry";
export * from "./lib/presets";
export * from "./lib/matcher";
export * from "./lib/format";
export { useKeybindsStore } from "./lib/keybinds-store";
export {
registerGlobalAction,
useGlobalAction,
useGlobalKeybinds,
} from "./lib/global-actions";
export { useResolvedKeybinds } from "./lib/useResolvedKeybinds";
export type { ResolvedKeybinds } from "./lib/useResolvedKeybinds";
export { useKeybindCapture } from "./lib/useKeybindCapture";
export { Kbd, KbdBindings } from "./lib/components/Kbd";
export { KeybindCaptureInput } from "./lib/components/KeybindCaptureInput";
export { KeybindsSettings } from "./lib/settings/KeybindsSettings";
export { Cheatsheet } from "./lib/cheatsheet/Cheatsheet";
export {
useCheatsheetVisibility,
useCheatsheetPin,
} from "./lib/cheatsheet/useCheatsheetVisibility";
@@ -0,0 +1,104 @@
import { useMemo } from "react";
import { twMerge } from "tailwind-merge";
import { useResolvedKeybinds } from "../useResolvedKeybinds";
import { useCheatsheetPin } from "./useCheatsheetVisibility";
import { ACTIONS_BY_SURFACE } from "../registry";
import { KbdBindings } from "../components/Kbd";
import { ActionDef, KeyGroup, Surface } from "../types";
const GROUP_ORDER: KeyGroup[] = [
"Camera",
"Tools",
"Transform",
"Selection",
"Edit",
"View",
"Timeline",
"Record",
"History",
"Rating",
"Navigation",
];
// Translucent overlay listing a surface's important shortcuts, resolved live
// from the keybinds store (so it reflects the active preset and any
// overrides). Shown while the user holds Ctrl/Cmd alone for 3s, or pinned
// open via useCheatsheetPin (then Esc / click outside dismisses; the panel
// becomes interactive so it can scroll). Render it inside a positioned
// (relative) container — it fills `inset-0`.
export function Cheatsheet({
surface,
visible,
}: {
surface: Surface;
visible: boolean;
}) {
const { forAction, selectedPreset } = useResolvedKeybinds();
const pinned = useCheatsheetPin((s) => s.pinned);
const byGroup = useMemo(() => {
const map = new Map<KeyGroup, ActionDef[]>();
for (const a of ACTIONS_BY_SURFACE[surface]) {
if (!a.important) continue;
const arr = map.get(a.group) ?? [];
arr.push(a);
map.set(a.group, arr);
}
return map;
}, [surface]);
if (!visible) return null;
return (
<div
aria-hidden={!pinned}
className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center"
>
<div
data-keybinds-cheatsheet
className={twMerge(
"max-h-[80%] w-[min(680px,90%)] overflow-auto rounded-xl border border-white/15 bg-black/70 p-6 text-white/90 shadow-2xl backdrop-blur-sm",
pinned && "pointer-events-auto",
)}
>
<div className="mb-3 flex items-center justify-between">
<span className="text-xs uppercase tracking-widest text-white/50">
Keyboard shortcuts
</span>
<span className="text-xs capitalize text-white/40">
{selectedPreset} preset
</span>
</div>
<div className="grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2">
{GROUP_ORDER.map((group) => {
const rows = byGroup.get(group);
if (!rows?.length) return null;
return (
<div key={group}>
<div className="mb-2 text-xs font-semibold uppercase tracking-wider text-white/60">
{group}
</div>
<ul className="flex flex-col gap-1.5">
{rows.map((a) => (
<li
key={a.id}
className="flex items-center justify-between gap-3 text-sm"
>
<span className="text-white/80">{a.label}</span>
<KbdBindings bindings={forAction(a.id)} />
</li>
))}
</ul>
</div>
);
})}
</div>
{pinned && (
<div className="mt-5 border-t border-white/10 pt-3 text-center text-xs text-white/40">
Press Esc or click outside to close
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,140 @@
import { useEffect, useState } from "react";
import { create } from "zustand";
import { useKeybindsStore } from "../keybinds-store";
const HOLD_MS = 3000;
// Session-only pin state, in a shared store (not hook-local) so chrome outside
// the surface that renders the overlay — e.g. the 3D editor's "Shortcuts"
// button — can toggle it. Pinned = the cheatsheet stays up until Esc, a click
// outside it, or the toggle again dismiss it.
interface CheatsheetPinState {
pinned: boolean;
setPinned: (v: boolean) => void;
togglePinned: () => void;
}
export const useCheatsheetPin = create<CheatsheetPinState>((set) => ({
pinned: false,
setPinned: (pinned) => set({ pinned }),
togglePinned: () => set((s) => ({ pinned: !s.pinned })),
}));
// Elements that opt out of the pinned overlay's click-outside dismissal:
// the overlay panel itself, and any button that toggles the pin (otherwise
// the toggle's own pointerdown would unpin first and the click would re-pin,
// making the button appear to do nothing).
const DISMISS_EXEMPT =
"[data-keybinds-cheatsheet], [data-cheatsheet-toggle]";
// Visibility for the cheatsheet overlay. Two paths compose:
// - Hold-to-peek: holding Ctrl (Cmd on Mac) ALONE for HOLD_MS shows it; any
// other keydown cancels (a real Ctrl+C never trips it). On release it
// hides — unless the `cheatsheetSticky` preference is on, in which case
// the peek converts into a pin.
// - Pin (useCheatsheetPin): stays visible until Esc, a click outside the
// panel, or the pin toggle. Lifted from the moodboard's
// useShortcutCheatsheet so every editor surface shares one implementation.
export const useCheatsheetVisibility = (): boolean => {
const [held, setHeld] = useState(false);
const pinned = useCheatsheetPin((s) => s.pinned);
// Hold-to-peek.
useEffect(() => {
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const modKey = isMac ? "Meta" : "Control";
let timer: ReturnType<typeof setTimeout> | null = null;
let modHeld = false;
const clearTimer = () => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
};
const hide = () => setHeld((v) => (v ? false : v));
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === modKey) {
if (modHeld) return; // ignore auto-repeat
modHeld = true;
clearTimer();
timer = setTimeout(() => {
timer = null;
setHeld(true);
}, HOLD_MS);
return;
}
// Any non-modifier press while held means a real combo — cancel.
clearTimer();
hide();
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === modKey) {
modHeld = false;
clearTimer();
// Sticky preference: releasing the modifier hands the peek over to
// the pin instead of hiding, so it stays until Esc / click outside.
setHeld((v) => {
if (v && useKeybindsStore.getState().cheatsheetSticky) {
useCheatsheetPin.getState().setPinned(true);
}
return false;
});
}
};
const onBlur = () => {
modHeld = false;
clearTimer();
hide();
};
document.addEventListener("keydown", onKeyDown);
document.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", onBlur);
return () => {
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", onBlur);
clearTimer();
};
}, []);
// Pinned dismissal: Esc (captured before the surface's own Escape actions)
// or a pointerdown outside the panel/toggle.
useEffect(() => {
if (!pinned) return;
const unpin = () => useCheatsheetPin.getState().setPinned(false);
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
unpin();
}
};
const onPointerDown = (e: PointerEvent) => {
const target = e.target as Element | null;
if (target?.closest?.(DISMISS_EXEMPT)) return;
unpin();
};
window.addEventListener("keydown", onKeyDown, { capture: true });
window.addEventListener("pointerdown", onPointerDown, { capture: true });
return () => {
window.removeEventListener("keydown", onKeyDown, { capture: true });
window.removeEventListener("pointerdown", onPointerDown, {
capture: true,
});
};
}, [pinned]);
// Leaving the surface (route change/unmount) drops the pin — the next
// editor shouldn't open with a cheatsheet the user pinned somewhere else.
useEffect(() => () => useCheatsheetPin.getState().setPinned(false), []);
return held || pinned;
};
@@ -0,0 +1,37 @@
import { Binding } from "../types";
import { formatBindingTokens } from "../format";
// A single key-cap cluster, e.g. ⌘ ⇧ Z. The app lacked any <kbd> primitive;
// this is the shared one used by the cheatsheet and settings rows.
export function Kbd({ binding }: { binding: Binding }) {
return (
<span className="inline-flex items-center gap-0.5">
{formatBindingTokens(binding).map((token, i) => (
<kbd
key={i}
className="inline-flex min-w-[1.4em] items-center justify-center rounded border border-white/15 bg-white/10 px-1.5 py-0.5 text-[11px] font-medium leading-none text-base-fg/90"
>
{token}
</kbd>
))}
</span>
);
}
// A list of alternative bindings for one action, e.g. "Del / ⌫". Renders a muted
// "Unbound" when empty.
export function KbdBindings({ bindings }: { bindings: Binding[] }) {
if (!bindings.length) {
return <span className="text-[11px] italic text-base-fg/40">Unbound</span>;
}
return (
<span className="inline-flex items-center gap-1.5">
{bindings.map((b, i) => (
<span key={i} className="inline-flex items-center gap-1.5">
{i > 0 && <span className="text-[11px] text-base-fg/40">/</span>}
<Kbd binding={b} />
</span>
))}
</span>
);
}
@@ -0,0 +1,55 @@
import { useState } from "react";
import { twMerge } from "tailwind-merge";
import { Binding } from "../types";
import { useKeybindCapture } from "../useKeybindCapture";
import { KbdBindings } from "./Kbd";
// A click-to-record control showing the action's current binding(s). Clicking
// arms capture ("Press a key…"); the next key press is reported via onCapture.
// Dependency-free (plain elements) so the foundation lib needs no UI-lib refs.
export function KeybindCaptureInput({
bindings,
onCapture,
accent,
className,
}: {
bindings: Binding[];
onCapture: (binding: Binding) => void;
/** Tint the resting state to mark a customized (overridden) binding. */
accent?: boolean;
className?: string;
}) {
const [capturing, setCapturing] = useState(false);
useKeybindCapture({
active: capturing,
onCapture: (b) => {
setCapturing(false);
onCapture(b);
},
onCancel: () => setCapturing(false),
});
return (
<button
type="button"
onClick={() => setCapturing((v) => !v)}
aria-label="Change keybinding"
className={twMerge(
"inline-flex min-h-8 min-w-[7rem] items-center justify-center rounded-md border px-2 py-1 text-sm transition-colors",
capturing
? "border-primary/80 bg-primary/10 text-primary animate-pulse"
: accent
? "border-primary/40 bg-primary/[0.06] hover:bg-primary/10"
: "border-ui-controls-border bg-ui-controls hover:bg-ui-controls/80",
className,
)}
>
{capturing ? (
<span className="text-[12px] font-medium">Press a key… (Esc to cancel)</span>
) : (
<KbdBindings bindings={bindings} />
)}
</button>
);
}
@@ -0,0 +1,54 @@
import { Binding } from "./types";
// Human-readable rendering of a binding. macOS uses symbol glyphs (⌘⇧⌥);
// other platforms use word modifiers joined with "+".
const isMac =
typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
// event.code → display token.
const CODE_LABELS: Record<string, string> = {
Space: "Space",
Escape: "Esc",
Delete: "Del",
Backspace: "⌫",
Enter: "Enter",
Tab: "Tab",
Home: "Home",
End: "End",
ArrowUp: "↑",
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
Backquote: "`",
Backslash: "\\",
Minus: "-",
Equal: "=",
NumpadAdd: "+",
NumpadSubtract: "-",
};
export function formatBindingTokens(binding: Binding): string[] {
const tokens: string[] = [];
if (binding.ctrl) tokens.push(isMac ? "⌘" : "Ctrl");
if (binding.shift) tokens.push(isMac ? "⇧" : "Shift");
if (binding.alt) tokens.push(isMac ? "⌥" : "Alt");
tokens.push(codeLabel(binding.code));
return tokens;
}
export function formatBinding(binding: Binding): string {
return formatBindingTokens(binding).join(isMac ? "" : "+");
}
export function formatBindings(bindings: Binding[]): string {
if (!bindings.length) return "Unbound";
return bindings.map(formatBinding).join(" / ");
}
function codeLabel(code: string): string {
if (CODE_LABELS[code]) return CODE_LABELS[code];
if (code.startsWith("Key")) return code.slice(3);
if (code.startsWith("Digit")) return code.slice(5);
return code;
}
@@ -0,0 +1,65 @@
import { useEffect } from "react";
import { useResolvedKeybinds } from "./useResolvedKeybinds";
import { ACTIONS } from "./registry";
import { isEventFromEditableElement } from "./matcher";
import type { ActionId } from "./types";
// The "global" surface is a TRIGGER layer, not a behavior layer. Features own
// their app-level behavior (sidebar open state, a future privacy-blur store,
// …) and register it here by action id; the global keybind dispatcher merely
// invokes whatever is registered when the bound key fires. Consequences:
//
// - A binding with no registered handler is inert — hosts that lack a
// feature simply never register it (the desktop app has no webapp
// sidebar, so Ctrl+B does nothing there).
// - Adding a global shortcut for a new feature = one ActionDef + one
// BASE_BINDINGS entry in this lib, plus a useGlobalAction call wherever
// the feature's state lives. No keyboard code in the feature itself.
const handlers = new Map<ActionId, () => void>();
/** Register the behavior for a global action. Returns an unregister fn.
* Last registration wins; unregistering only removes your own handler. */
export function registerGlobalAction(
id: ActionId,
handler: () => void,
): () => void {
handlers.set(id, handler);
return () => {
if (handlers.get(id) === handler) handlers.delete(id);
};
}
/** React helper: register `handler` for `id` while the component is mounted.
* Pass a stable (useCallback) handler to avoid re-registration churn. */
export function useGlobalAction(id: ActionId, handler: () => void): void {
useEffect(() => registerGlobalAction(id, handler), [id, handler]);
}
/** The global dispatcher. Mount ONCE at the app shell.
*
* Listens on `window` in the CAPTURE phase deliberately: the modal system
* swallows keydowns at document level while a modal is open, and window
* capture runs before that — so global actions (sidebar, a future privacy
* blur) keep working over open modals. A matched-and-handled event is
* stopped so per-surface listeners never double-consume it. */
export function useGlobalKeybinds(): void {
const { matchAction } = useResolvedKeybinds();
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
const id = matchAction(e, "global");
if (!id) return;
const def = ACTIONS[id];
if (!def) return;
if (!def.allowInEditable && isEventFromEditableElement(e)) return;
const run = handlers.get(id);
if (!run) return; // no feature registered on this host — stay inert
if (def.preventDefault) e.preventDefault();
e.stopPropagation();
run();
};
window.addEventListener("keydown", onKeyDown, true);
return () => window.removeEventListener("keydown", onKeyDown, true);
}, [matchAction]);
}
@@ -0,0 +1,189 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useKeybindsStore } from "./keybinds-store";
import {
bindingFromEvent,
bindingMatchesEvent,
bindingsEqual,
} from "./matcher";
import { BASE_BINDINGS, PRESETS } from "./presets";
import { ACTIONS } from "./registry";
import { ActionId, PresetId } from "./types";
const reset = () =>
useKeybindsStore.setState({ selectedPreset: "gamer", overrides: {} });
const store = () => useKeybindsStore.getState();
describe("keybinds-store", () => {
beforeEach(reset);
describe("resolution", () => {
it("gamer preset reproduces BASE bindings", () => {
expect(store().resolveBindings("pagescene.transform.translate")).toEqual(
BASE_BINDINGS["pagescene.transform.translate"],
);
expect(store().resolveBindings("pagescene.camera.forward")).toEqual([
{ code: "KeyW" },
]);
});
it("blender preset applies its deltas but leaves base untouched", () => {
store().setPreset("blender");
// Blender adds a modal "grab" on G and remaps scale to S.
expect(store().resolveBindings("pagescene.transform.grab")).toEqual([
{ code: "KeyG" },
]);
expect(store().resolveBindings("pagescene.transform.scale")).toEqual([
{ code: "KeyS" },
]);
// Translate is NOT a blender delta — grab (G) is its own action, so
// translate-mode stays on its BASE key (T).
expect(store().resolveBindings("pagescene.transform.translate")).toEqual([
{ code: "KeyT" },
]);
// Camera moves off the letters onto numpad + top-row digits so they don't
// collide with the transform keys (S etc.).
expect(store().resolveBindings("pagescene.camera.forward")).toEqual([
{ code: "Numpad8" },
{ code: "Digit8" },
]);
// Look is NOT a blender delta → falls through to BASE (arrows).
expect(store().resolveBindings("pagescene.camera.pitchUp")).toEqual([
{ code: "ArrowUp" },
]);
});
it("user override takes precedence over the preset", () => {
store().setPreset("blender");
store().setBinding("pagescene.transform.translate", [{ code: "KeyM" }]);
expect(store().resolveBindings("pagescene.transform.translate")).toEqual([
{ code: "KeyM" },
]);
});
});
describe("reset", () => {
it("resetAction drops the override back to the preset value", () => {
store().setBinding("pagescene.transform.translate", [{ code: "KeyM" }]);
store().resetAction("pagescene.transform.translate");
expect(store().resolveBindings("pagescene.transform.translate")).toEqual([
{ code: "KeyT" },
]);
});
it("resetSurface clears only that surface's overrides", () => {
store().setBinding("pagescene.transform.translate", [{ code: "KeyM" }]);
store().setBinding("pagedraw.tools.brush", [{ code: "KeyN" }]);
store().resetSurface("pagescene");
expect(store().overrides["pagescene.transform.translate"]).toBeUndefined();
expect(store().overrides["pagedraw.tools.brush"]).toEqual([
{ code: "KeyN" },
]);
});
it("resetAll clears overrides but keeps the preset", () => {
store().setPreset("blender");
store().setBinding("pagescene.transform.rotate", [{ code: "KeyM" }]);
store().resetAll();
expect(store().selectedPreset).toBe("blender");
expect(store().overrides).toEqual({});
});
it("resetToPresetDefault clears overrides and returns to gamer", () => {
store().setPreset("blender");
store().setBinding("pagescene.transform.rotate", [{ code: "KeyM" }]);
store().resetToPresetDefault();
expect(store().selectedPreset).toBe("gamer");
expect(store().overrides).toEqual({});
});
});
describe("conflicts", () => {
it("detects a same-surface binding already in use", () => {
// KeyR is rotate in the gamer preset; binding it elsewhere conflicts.
const conflicts = store().findConflicts("pagescene.transform.scale", {
code: "KeyR",
});
expect(conflicts).toContain("pagescene.transform.rotate");
});
it("does not report a conflict for an unused key", () => {
expect(
store().findConflicts("pagescene.transform.scale", { code: "KeyM" }),
).toEqual([]);
});
it("global bindings conflict with every surface, both directions", () => {
// A per-surface action stealing the global sidebar chord conflicts…
expect(
store().findConflicts("pagescene.transform.scale", {
code: "KeyB",
ctrl: true,
}),
).toContain("global.ui.toggleSidebar");
// …and a global action landing on a per-surface key conflicts back.
expect(
store().findConflicts("global.ui.toggleSidebar", { code: "KeyR" }),
).toContain("pagescene.transform.rotate");
});
});
describe("preset integrity", () => {
// Guards against ships like blender's old WASD-vs-scale (S) clash: no two
// actions on the same surface may resolve to the same binding in any preset.
const presetIds = Object.keys(PRESETS) as PresetId[];
const actionIds = Object.keys(ACTIONS) as ActionId[];
it.each(presetIds)("'%s' preset has no same-surface conflicts", (preset) => {
store().setPreset(preset);
const collisions: string[] = [];
for (const id of actionIds) {
for (const binding of store().resolveBindings(id)) {
for (const other of store().findConflicts(id, binding)) {
collisions.push(`${id} <-> ${other} on ${JSON.stringify(binding)}`);
}
}
}
expect(collisions).toEqual([]);
});
});
});
describe("matcher", () => {
it("treats ctrl and meta interchangeably", () => {
const binding = { code: "KeyZ", ctrl: true };
const ctrlEvent = new KeyboardEvent("keydown", { code: "KeyZ", ctrlKey: true });
const metaEvent = new KeyboardEvent("keydown", { code: "KeyZ", metaKey: true });
expect(bindingMatchesEvent(binding, ctrlEvent)).toBe(true);
expect(bindingMatchesEvent(binding, metaEvent)).toBe(true);
});
it("requires exact modifier match", () => {
const binding = { code: "KeyZ", ctrl: true };
const shiftedEvent = new KeyboardEvent("keydown", {
code: "KeyZ",
ctrlKey: true,
shiftKey: true,
});
expect(bindingMatchesEvent(binding, shiftedEvent)).toBe(false);
});
it("matches by physical code, not produced character", () => {
const binding = { code: "KeyW" };
const event = new KeyboardEvent("keydown", { code: "KeyW", key: "z" });
expect(bindingMatchesEvent(binding, event)).toBe(true);
});
it("bindingFromEvent ignores bare modifier presses", () => {
expect(
bindingFromEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" })),
).toBeNull();
});
it("bindingFromEvent captures modifiers + code", () => {
const b = bindingFromEvent(
new KeyboardEvent("keydown", { code: "KeyD", ctrlKey: true }),
);
expect(b && bindingsEqual(b, { code: "KeyD", ctrl: true })).toBe(true);
});
});
@@ -0,0 +1,122 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { ActionId, Binding, PresetId, Surface } from "./types";
import { BASE_BINDINGS, DEFAULT_PRESET, PRESETS } from "./presets";
import { ACTIONS, actionsCoAvailable } from "./registry";
import { bindingsEqual } from "./matcher";
// Persisted, app-wide keybinds state. Resolution layers, highest priority first:
// 1. user override for the action
// 2. selected preset's delta for the action (3D-only)
// 3. BASE_BINDINGS (the "Gamer" default)
// Modeled on the video-editor's keybindings-store (zustand + persist), but keyed
// by stable action ids with a preset+override layering instead of a flat map.
interface KeybindsState {
selectedPreset: PresetId;
overrides: Record<ActionId, Binding[]>;
isRecording: boolean;
/** Hold-to-peek cheatsheet stays open after the modifier is released,
* until Esc or a click outside dismisses it. */
cheatsheetSticky: boolean;
setPreset: (preset: PresetId) => void;
setCheatsheetSticky: (v: boolean) => void;
setBinding: (id: ActionId, bindings: Binding[]) => void;
resetAction: (id: ActionId) => void; // drop override → back to preset/base
resetSurface: (surface: Surface) => void; // clear one surface's overrides
resetAll: () => void; // clear all overrides, keep preset
resetToPresetDefault: () => void; // clear overrides AND return to default preset
setIsRecording: (v: boolean) => void;
resolveBindings: (id: ActionId) => Binding[];
/** Action ids in the SAME surface already bound to `candidate`. */
findConflicts: (id: ActionId, candidate: Binding) => ActionId[];
}
function resolveFrom(
overrides: Record<ActionId, Binding[]>,
preset: PresetId,
id: ActionId,
): Binding[] {
return overrides[id] ?? PRESETS[preset].bindings[id] ?? BASE_BINDINGS[id] ?? [];
}
export const useKeybindsStore = create<KeybindsState>()(
persist(
(set, get) => ({
selectedPreset: DEFAULT_PRESET,
overrides: {},
isRecording: false,
cheatsheetSticky: false,
setPreset: (preset) => set({ selectedPreset: preset }),
setCheatsheetSticky: (cheatsheetSticky) => set({ cheatsheetSticky }),
setBinding: (id, bindings) =>
set((s) => ({ overrides: { ...s.overrides, [id]: bindings } })),
resetAction: (id) =>
set((s) => {
const next = { ...s.overrides };
delete next[id];
return { overrides: next };
}),
resetSurface: (surface) =>
set((s) => ({
overrides: Object.fromEntries(
Object.entries(s.overrides).filter(
([id]) => ACTIONS[id as ActionId]?.surface !== surface,
),
) as Record<ActionId, Binding[]>,
})),
resetAll: () => set({ overrides: {} }),
resetToPresetDefault: () =>
set({ overrides: {}, selectedPreset: DEFAULT_PRESET }),
setIsRecording: (isRecording) => set({ isRecording }),
resolveBindings: (id) => {
const { overrides, selectedPreset } = get();
return resolveFrom(overrides, selectedPreset, id);
},
findConflicts: (id, candidate) => {
const action = ACTIONS[id];
if (!action) return [];
const { overrides, selectedPreset } = get();
const conflicts: ActionId[] = [];
for (const other of Object.values(ACTIONS)) {
if (other.id === id) continue;
// Same-surface bindings conflict; global bindings fire on EVERY
// surface, so they conflict across the board in both directions.
const sameSurface = other.surface === action.surface;
const involvesGlobal =
action.surface === "global" || other.surface === "global";
if (!sameSurface && !involvesGlobal) continue;
// Context-exclusive actions (their `when` gates never hold at the
// same time) may share a key on purpose — not a conflict.
if (!actionsCoAvailable(action, other)) continue;
const bindings = resolveFrom(overrides, selectedPreset, other.id);
if (bindings.some((bd) => bindingsEqual(bd, candidate))) {
conflicts.push(other.id);
}
}
return conflicts;
},
}),
{
name: "artcraft-keybinds",
version: 1,
partialize: (s) => ({
selectedPreset: s.selectedPreset,
overrides: s.overrides,
cheatsheetSticky: s.cheatsheetSticky,
}),
},
),
);
@@ -0,0 +1,65 @@
import { Binding } from "./types";
// Event matching + capture. Ctrl and Meta (⌘) are treated interchangeably so a
// binding authored with `ctrl` also fires for Cmd on macOS — the same rule the
// 3D keymap already used.
export function bindingMatchesEvent(binding: Binding, e: KeyboardEvent): boolean {
if (binding.code !== e.code) return false;
const ctrlOrMeta = e.ctrlKey || e.metaKey;
if (!!binding.ctrl !== ctrlOrMeta) return false;
if (!!binding.shift !== e.shiftKey) return false;
if (!!binding.alt !== e.altKey) return false;
return true;
}
const MODIFIER_CODES = new Set([
"ShiftLeft",
"ShiftRight",
"ControlLeft",
"ControlRight",
"AltLeft",
"AltRight",
"MetaLeft",
"MetaRight",
]);
/** Build a Binding from a key press, for the rebind capture UI. Returns null
* for a bare modifier press (wait for the real key). */
export function bindingFromEvent(e: KeyboardEvent): Binding | null {
if (!e.code || MODIFIER_CODES.has(e.code)) return null;
return {
code: e.code,
ctrl: e.ctrlKey || e.metaKey,
shift: e.shiftKey,
alt: e.altKey,
};
}
export function bindingsEqual(a: Binding, b: Binding): boolean {
return (
a.code === b.code &&
!!a.ctrl === !!b.ctrl &&
!!a.shift === !!b.shift &&
!!a.alt === !!b.alt
);
}
/** Stable string key for a binding — for conflict maps and React keys. */
export function bindingKey(b: Binding): string {
return [b.ctrl ? "ctrl" : "", b.shift ? "shift" : "", b.alt ? "alt" : "", b.code]
.filter(Boolean)
.join("+");
}
/** The shared "is the user typing in a field?" guard, previously duplicated
* across viewport / pagedraw / moodboard listeners. */
export function isEventFromEditableElement(e: KeyboardEvent): boolean {
const t = e.target as HTMLElement | null;
if (!t) return false;
const tag = t.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
if (t.isContentEditable) return true;
if (t.getAttribute?.("role") === "textbox") return true;
return false;
}
@@ -0,0 +1,155 @@
import { ActionId, Binding, Preset, PresetId } from "./types";
// BASE_BINDINGS is the "Gamer" scheme — it MUST reproduce the app's current
// hardcoded bindings exactly so adopting the system changes nothing by default.
// Presets list only the actions that DIFFER from BASE (3D-only deltas); every
// other action falls through to BASE regardless of selected preset, so 2D and
// moodboard are unaffected by preset choice.
const b = (code: string, mods: Omit<Binding, "code"> = {}): Binding => ({ code, ...mods });
export const BASE_BINDINGS: Record<ActionId, Binding[]> = {
// Global — app-wide trigger layer, preset-independent.
"global.ui.toggleSidebar": [b("KeyB", { ctrl: true })],
// Camera (held) — WASD + QE + arrows, matching cameraMath.ts today.
"pagescene.camera.forward": [b("KeyW")],
"pagescene.camera.back": [b("KeyS")],
"pagescene.camera.left": [b("KeyA")],
"pagescene.camera.right": [b("KeyD")],
"pagescene.camera.up": [b("KeyE")],
"pagescene.camera.down": [b("KeyQ")],
"pagescene.camera.pitchUp": [b("ArrowUp")],
"pagescene.camera.pitchDown": [b("ArrowDown")],
"pagescene.camera.yawLeft": [b("ArrowLeft")],
"pagescene.camera.yawRight": [b("ArrowRight")],
// Transform / view / selection / edit — matching keymap.ts today.
"pagescene.transform.translate": [b("KeyT")],
"pagescene.transform.rotate": [b("KeyR")],
"pagescene.transform.scale": [b("KeyG")],
"pagescene.transform.toggleSpace": [b("KeyX")],
"pagescene.transform.poseFK": [b("KeyK")],
"pagescene.view.focus": [b("KeyF")],
"pagescene.view.assetMenu": [b("KeyB")],
"pagescene.view.toggleCameraView": [b("Space")],
"pagescene.view.toggleStats": [b("Backquote")],
"pagescene.selection.clearOrExit": [b("Escape")],
"pagescene.selection.deselectAll": [b("KeyA", { ctrl: true, alt: true })],
"pagescene.edit.delete": [b("Delete"), b("Backspace")],
"pagescene.edit.duplicate": [b("KeyD", { ctrl: true })],
"pagescene.edit.toggleSnapping": [b("KeyN")],
"pagescene.view.toggleGrid": [b("KeyH")],
"pagescene.edit.undo": [b("KeyZ", { ctrl: true })],
"pagescene.edit.redo": [b("KeyZ", { ctrl: true, shift: true }), b("KeyY", { ctrl: true })],
"pagescene.edit.copy": [b("KeyC", { ctrl: true })],
"pagescene.edit.paste": [b("KeyV", { ctrl: true })],
// Timeline (context-gated; Space/arrows are shared with camera actions via
// mutually exclusive `when` predicates — see the registry).
"pagescene.timeline.playPause": [b("Space")],
"pagescene.timeline.stepBack": [b("ArrowLeft")],
"pagescene.timeline.stepForward": [b("ArrowRight")],
"pagescene.timeline.nudgeLeft": [b("ArrowLeft")],
"pagescene.timeline.nudgeRight": [b("ArrowRight")],
"pagescene.timeline.goToStart": [b("Home")],
"pagescene.timeline.goToEnd": [b("End")],
"pagescene.timeline.addKeyframe": [b("KeyI")], // Blender parity
"pagescene.timeline.deleteSelected": [b("Delete"), b("Backspace")],
"pagescene.timeline.toggleExpanded": [b("KeyT", { shift: true })],
// Record. Capture/record are bare letters on purpose: they're gated to
// record mode (where the build-mode letters are dead, so no conflicts) and
// browser-reserved chords like Ctrl+Shift+S/E (Firefox screenshot / dev
// tools) can't be preventDefault'ed away.
"pagescene.record.toggleMode": [b("Tab")],
"pagescene.record.captureStill": [b("KeyC")],
"pagescene.record.recordVideo": [b("KeyR")],
"pagescene.record.cancelEncode": [b("Escape")],
// PageDraw (2D) — preset-independent.
"pagedraw.tools.select": [b("Digit1")],
"pagedraw.tools.shape": [b("Digit2")],
"pagedraw.tools.brush": [b("Digit3")],
"pagedraw.tools.mask": [b("Digit4")],
"pagedraw.tools.eraser": [b("Digit5")],
// MS-Paint-style size keys; numpad variants included.
"pagedraw.tools.brushSizeUp": [
b("Equal", { ctrl: true }),
b("NumpadAdd", { ctrl: true }),
],
"pagedraw.tools.brushSizeDown": [
b("Minus", { ctrl: true }),
b("NumpadSubtract", { ctrl: true }),
],
"pagedraw.edit.delete": [b("Delete"), b("Backspace")],
"pagedraw.edit.copy": [b("KeyC", { ctrl: true })],
"pagedraw.edit.paste": [b("KeyV", { ctrl: true })],
"pagedraw.history.undo": [b("KeyZ", { ctrl: true })],
"pagedraw.history.redo": [b("KeyZ", { ctrl: true, shift: true }), b("KeyY", { ctrl: true })],
// Moodboard — preset-independent (Figma-style).
"moodboard.tools.select": [b("KeyV")],
"moodboard.tools.lasso": [b("KeyL")],
"moodboard.tools.text": [b("KeyT")],
"moodboard.selection.selectAll": [b("KeyA", { ctrl: true })],
"moodboard.selection.clear": [b("Escape")],
"moodboard.edit.delete": [b("Delete"), b("Backspace")],
"moodboard.edit.group": [b("KeyG", { ctrl: true })],
"moodboard.edit.ungroup": [b("KeyG", { ctrl: true, shift: true })],
"moodboard.view.fitToContent": [b("Digit1", { shift: true })],
"moodboard.history.undo": [b("KeyZ", { ctrl: true })],
"moodboard.history.redo": [b("KeyZ", { ctrl: true, shift: true }), b("KeyY", { ctrl: true })],
"moodboard.view.resetViewport": [b("Digit0", { ctrl: true })],
// Moodboard grid — Lightroom triage row + viewer navigation.
"moodboard-grid.selection.clearOrClose": [b("Escape")],
"moodboard-grid.edit.delete": [b("Delete"), b("Backspace")],
"moodboard-grid.rate.pick": [b("KeyP")],
"moodboard-grid.rate.0": [b("Digit0"), b("Numpad0")],
"moodboard-grid.rate.1": [b("Digit1"), b("Numpad1")],
"moodboard-grid.rate.2": [b("Digit2"), b("Numpad2")],
"moodboard-grid.rate.3": [b("Digit3"), b("Numpad3")],
"moodboard-grid.rate.4": [b("Digit4"), b("Numpad4")],
"moodboard-grid.rate.5": [b("Digit5"), b("Numpad5")],
"moodboard-grid.nav.prev": [b("ArrowLeft")],
"moodboard-grid.nav.next": [b("ArrowRight"), b("Space")],
};
export const PRESETS: Record<PresetId, Preset> = {
gamer: {
id: "gamer",
label: "Gamer",
description:
"Game-style free camera — WASD to move, Q/E down/up, arrows to look. The current Artcraft default.",
bindings: {}, // BASE is the gamer scheme.
},
blender: {
id: "blender",
label: "Blender-like",
description:
"Familiar to Blender users — G/R/S for grab/rotate/scale and X/Y/Z axis locking during a transform.",
bindings: {
// Camera moves off WASD/QE onto the numpad (Blender's navigation cluster),
// with the top-row digits as the laptop-friendly "Emulate Numpad" mirror.
// This frees the letter keys for transform ops below — without it, scale (S)
// would collide with camera-back (S). Look stays on the arrow keys (BASE).
"pagescene.camera.forward": [b("Numpad8"), b("Digit8")],
"pagescene.camera.back": [b("Numpad2"), b("Digit2")],
"pagescene.camera.left": [b("Numpad4"), b("Digit4")],
"pagescene.camera.right": [b("Numpad6"), b("Digit6")],
"pagescene.camera.up": [b("Numpad9"), b("Digit9")],
"pagescene.camera.down": [b("Numpad3"), b("Digit3")],
// G is Blender's modal "grab" (move with the mouse + X/Y/Z axis lock),
// not a gizmo-mode toggle — so translate-mode stays on its BASE key (T).
"pagescene.transform.grab": [b("KeyG")],
"pagescene.transform.scale": [b("KeyS")],
// R (rotate) is the same in both schemes.
"pagescene.transform.toggleSpace": [b("Backslash")], // X freed for axis-lock.
"pagescene.edit.duplicate": [b("KeyD", { shift: true })],
"pagescene.selection.deselectAll": [b("KeyA", { alt: true })],
},
},
};
export const DEFAULT_PRESET: PresetId = "gamer";
@@ -0,0 +1,341 @@
import { ActionDef, ActionId, KeybindContext, Surface } from "./types";
// The registry is the single source of truth for every remappable action.
// Presets and overrides supply bindings keyed by these ids; engine hooks resolve
// handlers by id. Video-editor keeps its own separate system and is NOT here.
//
// Grows per phase: this set covers the 3D PageScene's existing operations.
// New 3D binds (duplicate, select-all, grid/snapping, modal axis-lock) and the
// 2D/moodboard surfaces are added as their wiring lands.
// Record mode is immutable: every action that edits the scene, moves the
// camera intentionally, or opens build chrome is gated to build mode. View
// toggles that merely change what the viewport shows (camera view, grid,
// stats) stay live in record — the encode always renders through the render
// camera regardless of the viewport view.
const inBuild = (ctx: KeybindContext) =>
ctx.sceneMode !== "record" && !ctx.encoding;
// Camera-look arrows yield to the timeline while it's open (arrows mean
// frame stepping / keyframe nudging there); camera MOVE keys stay live so
// shots can still be framed with the timeline expanded. useFreeCam enforces
// these same rules imperatively — keep the two in sync.
const cameraMove = inBuild;
const cameraLook = (ctx: KeybindContext) =>
inBuild(ctx) && !ctx.timelineExpanded;
// Timeline transport is live wherever playback exists: the expanded editor
// in build mode, and all of record mode (read-only playback bar).
const inPlayback = (ctx: KeybindContext) =>
!ctx.encoding && (!!ctx.timelineExpanded || ctx.sceneMode === "record");
const defs: ActionDef[] = [
// ── Global (app-wide) ─────────────────────────────────────────────────────
// Global keybinds are a TRIGGER layer only: the behavior belongs to the
// feature that owns it, which registers a handler by id via
// registerGlobalAction / useGlobalAction (global-actions.ts). A binding
// with no registered handler is inert on that host — so e.g. the desktop
// app, which has no webapp sidebar, just never registers it. A future
// privacy-blur toggle follows the same recipe: def + default binding here,
// and the store that owns the blur state registers its toggle handler
// (likely with allowInEditable so it works mid-typing).
act("global.ui.toggleSidebar", "Toggle sidebar", "View", {
important: true,
preventDefault: true,
allowInEditable: true, // modifier chord; keeps working while typing
}),
// ── PageScene: camera (continuous, held) ──────────────────────────────────
cam("pagescene.camera.forward", "Camera forward", cameraMove),
cam("pagescene.camera.back", "Camera back", cameraMove),
cam("pagescene.camera.left", "Camera left", cameraMove),
cam("pagescene.camera.right", "Camera right", cameraMove),
cam("pagescene.camera.up", "Camera up", cameraMove),
cam("pagescene.camera.down", "Camera down", cameraMove),
cam("pagescene.camera.pitchUp", "Look up", cameraLook),
cam("pagescene.camera.pitchDown", "Look down", cameraLook),
cam("pagescene.camera.yawLeft", "Look left", cameraLook),
cam("pagescene.camera.yawRight", "Look right", cameraLook),
// ── PageScene: transform ──────────────────────────────────────────────────
act("pagescene.transform.grab", "Grab / move (modal)", "Transform", { important: true, when: inBuild }),
act("pagescene.transform.translate", "Move (translate)", "Transform", { important: true, when: inBuild }),
act("pagescene.transform.rotate", "Rotate", "Transform", { important: true, when: inBuild }),
act("pagescene.transform.scale", "Scale", "Transform", { important: true, when: inBuild }),
act("pagescene.transform.toggleSpace", "Toggle local / world", "Transform", { when: inBuild }),
act("pagescene.transform.poseFK", "Toggle pose (FK)", "Transform", { when: inBuild }),
// ── PageScene: view ───────────────────────────────────────────────────────
act("pagescene.view.focus", "Focus selection", "View", { important: true, when: inBuild }),
act("pagescene.view.assetMenu", "Open asset menu", "View", { important: true, when: inBuild }),
act("pagescene.view.toggleCameraView", "Toggle camera view", "View", {
important: true,
preventDefault: true,
// Space belongs to timeline playback whenever playback UI is up (the
// expanded editor, or all of record mode) — the camera toggle takes the
// complement. In record mode, view peeking is double-click only.
when: (ctx) => !inPlayback(ctx) && !ctx.encoding,
}),
act("pagescene.view.toggleStats", "Toggle perf stats", "View"),
// ── PageScene: selection ──────────────────────────────────────────────────
// Build-only: its first branch leaves camera view, which in record mode
// would break the render-cam lock (record forces camera view). Mid-encode
// Escape belongs to record.cancelEncode instead.
act("pagescene.selection.clearOrExit", "Clear selection / exit pose", "Selection", {
important: true,
when: inBuild,
}),
act("pagescene.selection.deselectAll", "Deselect all", "Selection", { when: inBuild }),
// ── PageScene: edit ───────────────────────────────────────────────────────
// Scene-object delete owns Del/Backspace EXCEPT when the expanded timeline
// holds a valid keyframe/strip selection — then timeline.deleteSelected
// takes the key. The two predicates are exact complements; consumption
// order is decided here, not by listener phase or registration order.
act("pagescene.edit.delete", "Delete selected", "Edit", {
important: true,
when: (ctx) =>
inBuild(ctx) && !(ctx.timelineExpanded && ctx.timelineSelection),
}),
act("pagescene.edit.duplicate", "Duplicate selected", "Edit", { important: true, when: inBuild }),
act("pagescene.edit.toggleSnapping", "Toggle grid snapping", "Edit", { when: inBuild }),
act("pagescene.view.toggleGrid", "Toggle grid", "View"),
act("pagescene.edit.undo", "Undo", "History", { important: true, preventDefault: true, when: inBuild }),
act("pagescene.edit.redo", "Redo", "History", { important: true, preventDefault: true, when: inBuild }),
act("pagescene.edit.copy", "Copy", "Edit", { preventDefault: true, when: inBuild }),
act("pagescene.edit.paste", "Paste", "Edit", { preventDefault: true, when: inBuild }),
// ── PageScene: timeline ───────────────────────────────────────────────────
// Transport works wherever playback exists (expanded editor, record mode).
// Arrows are context-split: a valid keyframe/strip selection makes them
// NUDGE the selection; otherwise they STEP the playhead — complements, so
// sharing ←/→ is deliberate, not a conflict.
act("pagescene.timeline.playPause", "Play / pause", "Timeline", {
important: true,
preventDefault: true,
when: inPlayback,
}),
act("pagescene.timeline.stepBack", "Step one frame back", "Timeline", {
preventDefault: true,
when: (ctx) => inPlayback(ctx) && !ctx.timelineSelection,
}),
act("pagescene.timeline.stepForward", "Step one frame forward", "Timeline", {
preventDefault: true,
when: (ctx) => inPlayback(ctx) && !ctx.timelineSelection,
}),
act("pagescene.timeline.nudgeLeft", "Nudge selection back", "Timeline", {
preventDefault: true,
when: (ctx) =>
inBuild(ctx) && !!ctx.timelineExpanded && !!ctx.timelineSelection,
}),
act("pagescene.timeline.nudgeRight", "Nudge selection forward", "Timeline", {
preventDefault: true,
when: (ctx) =>
inBuild(ctx) && !!ctx.timelineExpanded && !!ctx.timelineSelection,
}),
act("pagescene.timeline.goToStart", "Go to start", "Timeline", {
preventDefault: true,
when: inPlayback,
}),
act("pagescene.timeline.goToEnd", "Go to end", "Timeline", {
preventDefault: true,
when: inPlayback,
}),
act("pagescene.timeline.addKeyframe", "Add keyframe (selected object)", "Timeline", {
important: true,
when: (ctx) => inBuild(ctx) && !!ctx.timelineExpanded,
}),
// Exact complement of pagescene.edit.delete (see its note above).
act("pagescene.timeline.deleteSelected", "Delete keyframe / clip", "Timeline", {
preventDefault: true,
when: (ctx) =>
inBuild(ctx) && !!ctx.timelineExpanded && !!ctx.timelineSelection,
}),
act("pagescene.timeline.toggleExpanded", "Expand / collapse timeline", "Timeline", {
when: inBuild,
}),
// ── PageScene: record ─────────────────────────────────────────────────────
act("pagescene.record.toggleMode", "Toggle build / record mode", "Record", {
important: true,
preventDefault: true,
when: (ctx) => !ctx.encoding,
}),
act("pagescene.record.captureStill", "Capture still", "Record", {
preventDefault: true,
when: (ctx) => ctx.sceneMode === "record" && !ctx.encoding,
}),
act("pagescene.record.recordVideo", "Record timeline to video", "Record", {
preventDefault: true,
when: (ctx) => ctx.sceneMode === "record" && !ctx.encoding,
}),
// Live ONLY mid-encode — the one action allowed through while rendering.
act("pagescene.record.cancelEncode", "Cancel render", "Record", {
when: (ctx) => !!ctx.encoding,
}),
// ── PageDraw (2D editor) ──────────────────────────────────────────────────
// Tool switching mirrors the SideToolbar's visual order; the toolbar shows
// each tool's resolved key as a corner badge. Size up/down adjusts whichever
// size the active tool uses (brush/eraser share one, mask has its own).
act("pagedraw.tools.select", "Select tool", "Tools", { important: true }),
// Not a single tool — first press activates, pressing again cycles the
// rectangle/circle/triangle submenu (Adobe/Figma same-key convention).
act("pagedraw.tools.shape", "Shape tool (cycle shapes)", "Tools", {
important: true,
}),
act("pagedraw.tools.brush", "Brush tool", "Tools", { important: true }),
act("pagedraw.tools.mask", "Mask tool", "Tools", { important: true }),
act("pagedraw.tools.eraser", "Eraser tool", "Tools", { important: true }),
act("pagedraw.tools.brushSizeUp", "Increase brush size", "Tools", {
preventDefault: true, // Ctrl+= is browser zoom
}),
act("pagedraw.tools.brushSizeDown", "Decrease brush size", "Tools", {
preventDefault: true, // Ctrl+- is browser zoom
}),
act("pagedraw.edit.delete", "Delete selected", "Edit", { important: true }),
act("pagedraw.edit.copy", "Copy", "Edit", { important: true, preventDefault: true }),
act("pagedraw.edit.paste", "Paste", "Edit", { important: true, preventDefault: true }),
act("pagedraw.history.undo", "Undo", "History", { important: true, preventDefault: true }),
act("pagedraw.history.redo", "Redo", "History", { important: true, preventDefault: true }),
// ── Moodboard ─────────────────────────────────────────────────────────────
act("moodboard.tools.select", "Select tool", "Tools", { important: true }),
act("moodboard.tools.lasso", "Lasso tool", "Tools", { important: true }),
act("moodboard.tools.text", "Text tool", "Tools", { important: true }),
act("moodboard.selection.selectAll", "Select all", "Selection", {
important: true,
preventDefault: true,
}),
act("moodboard.selection.clear", "Clear selection", "Selection", { important: true }),
act("moodboard.edit.delete", "Delete selection", "Edit", { important: true }),
act("moodboard.edit.group", "Group selection", "Edit", {
important: true,
preventDefault: true,
}),
act("moodboard.edit.ungroup", "Ungroup selection", "Edit", {
important: true,
preventDefault: true,
}),
act("moodboard.view.fitToContent", "Zoom to fit", "View", {
important: true,
preventDefault: true,
}),
act("moodboard.history.undo", "Undo", "History", { important: true, preventDefault: true }),
act("moodboard.history.redo", "Redo", "History", { important: true, preventDefault: true }),
act("moodboard.view.resetViewport", "Reset zoom & pan", "View", {
important: true,
preventDefault: true,
}),
// ── Moodboard grid (board library, item inspector, presentation) ─────────
// Lightroom-style triage on the grid selection, plus prev/next that the
// inspector and the presentation deck both answer to. Escape is one
// contextual action: clear the grid selection, or close whichever viewer
// is open. The grid's own arrow-key roving focus is a11y navigation, not a
// shortcut — nav.prev/next are ignored (not consumed) when no viewer is up.
act("moodboard-grid.selection.clearOrClose", "Clear selection / close viewer", "Selection", {
important: true,
}),
act("moodboard-grid.edit.delete", "Delete selected items", "Edit", { important: true }),
act("moodboard-grid.rate.pick", "Pick (rate 5)", "Rating", { important: true }),
act("moodboard-grid.rate.0", "Rate 0 (clear rating)", "Rating", { important: true }),
act("moodboard-grid.rate.1", "Rate 1", "Rating"),
act("moodboard-grid.rate.2", "Rate 2", "Rating"),
act("moodboard-grid.rate.3", "Rate 3", "Rating"),
act("moodboard-grid.rate.4", "Rate 4", "Rating"),
act("moodboard-grid.rate.5", "Rate 5", "Rating", { important: true }),
act("moodboard-grid.nav.prev", "Previous item (viewer / presentation)", "Navigation", {
important: true,
}),
act("moodboard-grid.nav.next", "Next item (viewer / presentation)", "Navigation", {
important: true,
}),
];
export const ACTIONS: Record<ActionId, ActionDef> = Object.fromEntries(
defs.map((d) => [d.id, d]),
);
export const ACTIONS_BY_SURFACE: Record<Surface, ActionDef[]> = {
global: defs.filter((d) => d.surface === "global"),
pagescene: defs.filter((d) => d.surface === "pagescene"),
pagedraw: defs.filter((d) => d.surface === "pagedraw"),
moodboard: defs.filter((d) => d.surface === "moodboard"),
"moodboard-grid": defs.filter((d) => d.surface === "moodboard-grid"),
};
export function getAction(id: ActionId): ActionDef | undefined {
return ACTIONS[id];
}
// ── availability ─────────────────────────────────────────────────────────────
/** The dispatch-time availability rule: `when` has full control; without one,
* an action is available unless an encode is running. */
export function actionAvailable(def: ActionDef, ctx: KeybindContext): boolean {
return def.when ? def.when(ctx) : !ctx.encoding;
}
// Representative context grid for co-availability checks. Covers every field
// the registry's `when` gates read — extend it when a new KeybindContext
// field gains a gate, or exclusivity checks will silently miss it.
const SAMPLE_CONTEXTS: KeybindContext[] = (["build", "record"] as const).flatMap(
(sceneMode) =>
[false, true].flatMap((encoding) =>
[false, true].flatMap((timelineExpanded) =>
[false, true].map((timelineSelection) => ({
sceneMode,
encoding,
timelineExpanded,
timelineSelection,
})),
),
),
);
/** Whether two actions can ever be live in the same context. Actions that are
* context-exclusive (e.g. cancel-render vs clear-selection, both Escape) may
* deliberately share a binding — the dispatcher picks whichever is available. */
export function actionsCoAvailable(a: ActionDef, b: ActionDef): boolean {
return SAMPLE_CONTEXTS.some(
(ctx) => actionAvailable(a, ctx) && actionAvailable(b, ctx),
);
}
// ── builders ─────────────────────────────────────────────────────────────────
function act(
id: ActionId,
label: string,
group: ActionDef["group"],
extra: Partial<ActionDef> = {},
): ActionDef {
return { id, label, group, surface: surfaceOf(id), ...extra };
}
function cam(
id: ActionId,
label: string,
when?: (ctx: KeybindContext) => boolean,
): ActionDef {
return {
id,
label,
group: "Camera",
surface: "pagescene",
continuous: true,
important: true,
when,
};
}
function surfaceOf(id: ActionId): Surface {
const head = id.split(".")[0];
if (head === "global") return "global";
if (head === "pagedraw") return "pagedraw";
if (head === "moodboard-grid") return "moodboard-grid";
if (head === "moodboard") return "moodboard";
return "pagescene";
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { actionMatchesQuery, groupActions } from "./search";
import { ActionDef, Binding } from "../types";
const action = (
label: string,
group: ActionDef["group"] = "Transform",
): ActionDef => ({
id: `pagescene.transform.${label}`,
label,
group,
surface: "pagescene",
});
describe("actionMatchesQuery", () => {
describe("by name", () => {
it("matches a case-insensitive label substring", () => {
expect(actionMatchesQuery(action("Rotate"), [], "rot")).toBe(true);
expect(actionMatchesQuery(action("Rotate"), [], "ROTA")).toBe(true);
});
it("matches the group name", () => {
expect(actionMatchesQuery(action("Scale", "Transform"), [], "transform")).toBe(
true,
);
});
it("requires every whitespace-separated term", () => {
const a = action("Camera up", "Camera");
expect(actionMatchesQuery(a, [], "camera up")).toBe(true);
expect(actionMatchesQuery(a, [], "camera down")).toBe(false);
});
it("returns true for an empty query", () => {
expect(actionMatchesQuery(action("Anything"), [], " ")).toBe(true);
});
});
describe("by key", () => {
const ctrlZ: Binding = { code: "KeyZ", ctrl: true };
const shiftD: Binding = { code: "KeyD", shift: true };
it("matches a bare letter against a binding", () => {
expect(actionMatchesQuery(action("Scale"), [{ code: "KeyS" }], "s")).toBe(true);
});
it("matches a modifier combo written with +", () => {
expect(actionMatchesQuery(action("Undo"), [ctrlZ], "ctrl+z")).toBe(true);
});
it("matches cross-platform modifier synonyms (cmd → ctrl)", () => {
expect(actionMatchesQuery(action("Undo"), [ctrlZ], "cmd z")).toBe(true);
});
it("matches a shifted letter contiguously", () => {
expect(actionMatchesQuery(action("Duplicate"), [shiftD], "shiftd")).toBe(true);
});
it("matches named keys by synonym", () => {
expect(
actionMatchesQuery(action("Clear"), [{ code: "Escape" }], "esc"),
).toBe(true);
expect(
actionMatchesQuery(action("Up"), [{ code: "ArrowUp" }], "arrow"),
).toBe(true);
});
it("matches numpad and its digit mirror", () => {
const camFwd = action("Camera forward", "Camera");
expect(actionMatchesQuery(camFwd, [{ code: "Numpad8" }], "numpad8")).toBe(true);
expect(actionMatchesQuery(camFwd, [{ code: "Numpad8" }], "num8")).toBe(true);
});
it("does not match an unrelated key", () => {
expect(actionMatchesQuery(action("Scale"), [{ code: "KeyS" }], "q")).toBe(
false,
);
});
});
});
describe("groupActions", () => {
it("buckets actions by group preserving first-seen order", () => {
const actions = [
action("a", "Camera"),
action("b", "Transform"),
action("c", "Camera"),
];
const grouped = groupActions(actions);
expect(grouped.map((g) => g.group)).toEqual(["Camera", "Transform"]);
expect(grouped[0].actions.map((a) => a.label)).toEqual(["a", "c"]);
expect(grouped[1].actions.map((a) => a.label)).toEqual(["b"]);
});
});
@@ -0,0 +1,84 @@
import { formatBindingTokens } from "../format";
import { ActionDef, Binding, KeyGroup } from "../types";
// Free-text matching for the Keybinds settings search box. A query matches an
// action by NAME or by KEY: the haystack folds in the label, the group, every
// rendered glyph of each binding (⌘ ⇧ ↑ …), and cross-platform word synonyms
// (so "ctrl" matches a ⌘ binding on macOS, "option" matches Alt, etc.).
export function actionMatchesQuery(
action: ActionDef,
bindings: Binding[],
query: string,
): boolean {
const q = query.trim().toLowerCase();
if (!q) return true;
const haystack = `${action.label} ${action.group} ${bindings
.map(bindingSearchText)
.join(" ")}`.toLowerCase();
// A compact pass strips spaces/`+` so "ctrl+z" / "⇧d" match contiguously…
const compactHay = haystack.replace(/[\s+]/g, "");
const compactQ = q.replace(/[\s+]/g, "");
if (compactQ && compactHay.includes(compactQ)) return true;
// …otherwise every whitespace-separated term must appear ("camera up").
return q
.split(/\s+/)
.filter(Boolean)
.every((term) => haystack.includes(term));
}
export function groupActions(
actions: ActionDef[],
): { group: KeyGroup; actions: ActionDef[] }[] {
const out: { group: KeyGroup; actions: ActionDef[] }[] = [];
const index = new Map<KeyGroup, number>();
for (const action of actions) {
if (!index.has(action.group)) {
index.set(action.group, out.length);
out.push({ group: action.group, actions: [] });
}
out[index.get(action.group)!].actions.push(action);
}
return out;
}
function bindingSearchText(b: Binding): string {
const parts: string[] = [];
if (b.ctrl) parts.push("ctrl", "cmd", "command", "meta", "control");
if (b.shift) parts.push("shift");
if (b.alt) parts.push("alt", "option", "opt");
parts.push(codeToWords(b.code));
parts.push(...formatBindingTokens(b)); // displayed glyphs/words (⌘ ⇧ ↑ …)
return parts.join(" ");
}
const CODE_SYNONYMS: Record<string, string> = {
Space: "space spacebar",
Escape: "esc escape",
Delete: "del delete",
Backspace: "backspace delete",
Enter: "enter return",
Tab: "tab",
Home: "home",
End: "end",
ArrowUp: "up arrow",
ArrowDown: "down arrow",
ArrowLeft: "left arrow",
ArrowRight: "right arrow",
Backquote: "backtick grave tilde",
Backslash: "backslash",
Minus: "minus dash",
Equal: "equal plus",
};
function codeToWords(code: string): string {
const base = code.toLowerCase();
if (CODE_SYNONYMS[code]) return `${base} ${CODE_SYNONYMS[code]}`;
if (code.startsWith("Key")) return `${base} ${code.slice(3).toLowerCase()}`;
if (code.startsWith("Digit")) return `${base} ${code.slice(5)}`;
if (code.startsWith("Numpad")) {
const n = code.slice(6).toLowerCase();
return `${base} num${n} numpad ${n}`;
}
return base;
}
@@ -0,0 +1,95 @@
// Core vocabulary for the unified keybinds system.
//
// A `Binding` is stored by physical `event.code` (e.g. "KeyW") rather than the
// produced character, so WASD-style layouts survive AZERTY/Dvorak and so the
// representation matches the 3D viewport's existing keymap. Each remappable
// operation is an `ActionDef` in the registry; presets and per-user overrides
// supply the `Binding[]` for each action id.
export interface Binding {
/** Physical key, matching KeyboardEvent.code (e.g. "KeyW", "Space", "Delete"). */
code: string;
ctrl?: boolean; // Ctrl on Windows/Linux, Cmd on macOS (treated interchangeably).
shift?: boolean;
alt?: boolean;
}
// "moodboard" is the freeform canvas; "moodboard-grid" is the board library
// (grid + item inspector + presentation deck) — a different view of the same
// page, never active at the same time, so the two surfaces never conflict.
export type Surface =
| "global"
| "pagescene"
| "pagedraw"
| "moodboard"
| "moodboard-grid";
export type KeyGroup =
| "Camera"
| "Transform"
| "Selection"
| "Edit"
| "View"
| "Tools"
| "Timeline"
| "Record"
| "History"
| "Navigation"
| "Rating";
export type ActionId = string; // surface-namespaced, e.g. "pagescene.transform.translate"
/** Editor state a surface hands to `ActionDef.when` availability gates. All
* fields are optional — each surface supplies the ones it has. Adding a field
* here (rather than ad-hoc checks in handlers) keeps availability visible to
* the settings/cheatsheet UIs and to future surfaces. */
export interface KeybindContext {
/** 3D top-level mode; "record" is immutable playback/output. */
sceneMode?: "build" | "record";
/** A timeline→video encode is running (progress overlay up). */
encoding?: boolean;
/** The expanded timeline editor is open. */
timelineExpanded?: boolean;
/** A VALID timeline selection (keyframe or clip strip) exists — the
* supplier must verify the selected id still resolves, so a stale
* selection lets Delete fall through to the scene-object action. */
timelineSelection?: boolean;
/** A Blender-style modal transform owns input. */
modalTransformActive?: boolean;
}
export interface ActionDef {
id: ActionId;
label: string;
group: KeyGroup;
surface: Surface;
/** Held key that drives continuous motion (camera). Must stay single-key,
* no-modifier; the capture UI enforces this. */
continuous?: boolean;
/** Surfaced in the quick cheatsheet overlay. */
important?: boolean;
/** preventDefault + stopPropagation when matched (browser-conflict keys). */
preventDefault?: boolean;
/** Only active inside a Blender-style modal transform (axis lock). */
modalOnly?: boolean;
/** Fire even while an input/textarea/contenteditable has focus. For global
* modifier chords (Ctrl+B) and panic keys (a future privacy blur) that
* must never be dead just because the user was typing. */
allowInEditable?: boolean;
/** Availability gate, evaluated at dispatch time. Omitted = the default
* rule "available unless an encode is running". Supplying `when` takes
* FULL control of availability — re-check `ctx.encoding` yourself unless
* the action is deliberately live mid-encode (e.g. cancel-encode). An
* unavailable binding is inert: the event falls through untouched. */
when?: (ctx: KeybindContext) => boolean;
}
export type PresetId = "gamer" | "blender";
export interface Preset {
id: PresetId;
label: string;
description: string;
/** Bindings that DIFFER from BASE for this preset (3D-only deltas). */
bindings: Partial<Record<ActionId, Binding[]>>;
}
@@ -0,0 +1,30 @@
import { useEffect } from "react";
import { bindingFromEvent } from "./matcher";
import { Binding } from "./types";
// While `active`, captures the next key press as a Binding (capture phase, so it
// beats app shortcuts). Escape cancels. A bare modifier press is ignored until a
// real key arrives.
export function useKeybindCapture(opts: {
active: boolean;
onCapture: (binding: Binding) => void;
onCancel?: () => void;
}): void {
const { active, onCapture, onCancel } = opts;
useEffect(() => {
if (!active) return;
const handler = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.code === "Escape") {
onCancel?.();
return;
}
const binding = bindingFromEvent(e);
if (binding) onCapture(binding);
};
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [active, onCapture, onCancel]);
}
@@ -0,0 +1,48 @@
import { useMemo } from "react";
import { useKeybindsStore } from "./keybinds-store";
import { ACTIONS_BY_SURFACE } from "./registry";
import { BASE_BINDINGS, PRESETS } from "./presets";
import { bindingMatchesEvent } from "./matcher";
import { ActionId, Binding, PresetId, Surface } from "./types";
export interface ResolvedKeybinds {
selectedPreset: PresetId;
/** Current bindings for an action (override → preset → base). */
forAction: (id: ActionId) => Binding[];
/** First action on `surface` whose binding matches the event, or null. */
matchAction: (e: KeyboardEvent, surface: Surface) => ActionId | null;
/** Map of every action on `surface` to its resolved bindings. */
slotBindings: (surface: Surface) => Record<ActionId, Binding[]>;
}
// Subscribes to preset + overrides and returns memoized resolvers. The returned
// object's identity changes whenever bindings change, so engine hooks that list
// it in a dependency array re-attach with the new bindings.
export function useResolvedKeybinds(): ResolvedKeybinds {
const selectedPreset = useKeybindsStore((s) => s.selectedPreset);
const overrides = useKeybindsStore((s) => s.overrides);
return useMemo(() => {
const forAction = (id: ActionId): Binding[] =>
overrides[id] ?? PRESETS[selectedPreset].bindings[id] ?? BASE_BINDINGS[id] ?? [];
const matchAction = (e: KeyboardEvent, surface: Surface): ActionId | null => {
for (const action of ACTIONS_BY_SURFACE[surface]) {
if (forAction(action.id).some((bd) => bindingMatchesEvent(bd, e))) {
return action.id;
}
}
return null;
};
const slotBindings = (surface: Surface): Record<ActionId, Binding[]> => {
const out: Record<ActionId, Binding[]> = {};
for (const action of ACTIONS_BY_SURFACE[surface]) {
out[action.id] = forAction(action.id);
}
return out;
};
return { selectedPreset, forAction, matchAction, slotBindings };
}, [selectedPreset, overrides]);
}
@@ -0,0 +1,13 @@
{
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json"
}
@@ -0,0 +1,46 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"types": [
"node",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts",
"vite/client"
],
"rootDir": "src",
"jsx": "react-jsx",
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
},
"exclude": [
"out-tsc",
"dist",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.ts",
"src/**/*.tsx"
],
"references": []
}
@@ -0,0 +1,34 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./out-tsc/vitest",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
],
"jsx": "react-jsx"
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -0,0 +1,40 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import * as path from 'path';
import { isExternal } from '../../shared-vite-config';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/components/keybinds',
plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
build: {
outDir: './dist',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
entry: 'src/index.ts',
name: '@storyteller/keybinds',
fileName: 'index',
formats: ['es' as const]
},
rollupOptions: {
external: isExternal
},
},
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: './test-output/vitest/coverage',
provider: 'v8' as const,
}
},
}));
@@ -2,6 +2,7 @@ import { ReactNode, useState } from "react";
import { CheckIcon, CloudUploadIcon, Grid3x3Icon, LoaderCircleIcon, PlayIcon, SquareDashedIcon, TriangleAlertIcon } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Cheatsheet, useCheatsheetVisibility } from "@storyteller/keybinds";
import { BoardGridView } from "./grid/BoardGridView";
import { PresentationView } from "./grid/PresentationView";
import { useBoardLibraryStore } from "./boards/BoardLibraryStore";
@@ -38,6 +39,8 @@ export const MoodboardWorkspace = ({ adapter, topBarEndSlot }: Props) => {
// Presentation is a transient overlay, not a persisted view mode — so a
// reload never reopens straight into a slideshow.
const [presenting, setPresenting] = useState(false);
// Hold-Ctrl / pinned cheatsheet, listing whichever view is active.
const cheatsheetVisible = useCheatsheetVisibility();
const presentItems = board
? board.itemOrder.map((id) => board.items[id]).filter((it) => Boolean(it))
@@ -64,6 +67,10 @@ export const MoodboardWorkspace = ({ adapter, topBarEndSlot }: Props) => {
onSave={syncEnabled ? saveNow : undefined}
endSlot={topBarEndSlot}
/>
<Cheatsheet
surface={viewMode === "grid" ? "moodboard-grid" : "moodboard"}
visible={cheatsheetVisible}
/>
{presenting && (
<PresentationView
items={presentItems}
@@ -1,17 +1,13 @@
import { useEffect, useRef } from "react";
import { useRef } from "react";
import Konva from "konva";
import { MoodboardToolbar } from "./MoodboardToolbar";
import { MoodboardStage } from "./MoodboardStage";
import { TextEditOverlay } from "./TextEditOverlay";
import { useMoodboardStore } from "./MoodboardStore";
import { useUndoRedo } from "./interactions/useUndoRedo";
import { usePasteHandler } from "./interactions/usePasteHandler";
import { useGalleryDropEvent } from "./interactions/useGalleryDropEvent";
import { useKeyboardShortcuts } from "./interactions/useKeyboardShortcuts";
import { useShortcutCheatsheet } from "./interactions/useShortcutCheatsheet";
import { useMoodboardKeybinds } from "./interactions/useMoodboardKeybinds";
import { useMoodboardImageEntry } from "./useMoodboardImageEntry";
import { RecenterIndicator } from "./overlays/RecenterIndicator";
import { ShortcutCheatsheet } from "./overlays/ShortcutCheatsheet";
import type { MoodboardAdapter } from "../adapter";
// import { EmptyMoodboardCTA } from "./EmptyMoodboardCTA";
@@ -22,40 +18,18 @@ interface Props {
export const Moodboard = ({ adapter }: Props) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const stageRef = useRef<Konva.Stage | null>(null);
const deleteSelected = useMoodboardStore((s) => s.deleteSelected);
const setSelection = useMoodboardStore((s) => s.setSelection);
const editingTextId = useMoodboardStore((s) => s.transient.editingTextId);
// const isEmpty = useMoodboardStore((s) => s.rootOrder.length === 0);
useUndoRedo(true);
usePasteHandler(adapter, true, stageRef);
useGalleryDropEvent(true, stageRef);
useKeyboardShortcuts(true);
const cheatsheetVisible = useShortcutCheatsheet();
// All discrete moodboard shortcuts (tools, selection, group/ungroup, delete,
// fit, undo/redo) now resolve from the unified keybinds store.
useMoodboardKeybinds(true);
const { triggerUpload, triggerGallery, modals } = useMoodboardImageEntry(
adapter,
stageRef,
);
// Delete / Backspace removes the current selection. Skip when typing in
// an input or while a text node is in edit mode.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target && /input|textarea/i.test(target.tagName)) return;
if (target && target.isContentEditable) return;
if (editingTextId) return;
if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
deleteSelected();
} else if (e.key === "Escape") {
setSelection([]);
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [deleteSelected, setSelection, editingTextId]);
return (
<div
ref={containerRef}
@@ -64,7 +38,6 @@ export const Moodboard = ({ adapter }: Props) => {
<MoodboardStage containerRef={containerRef} stageRef={stageRef} />
<TextEditOverlay containerRef={containerRef} />
<RecenterIndicator />
<ShortcutCheatsheet visible={cheatsheetVisible} />
{/* {isEmpty && (
<EmptyMoodboardCTA
onUploadClick={triggerUpload}
@@ -1,77 +0,0 @@
import { useEffect } from "react";
import { useMoodboardStore } from "../MoodboardStore";
const isEditableTarget = (target: EventTarget | null): boolean => {
const el = target as HTMLElement | null;
if (!el) return false;
if (/input|textarea/i.test(el.tagName)) return true;
if (el.isContentEditable) return true;
return false;
};
// Figma-style shortcuts that aren't already wired elsewhere.
// Cmd+Z / Cmd+Shift+Z live in useUndoRedo; Cmd+0 + Space-pan live in
// useViewportControls; Delete / Escape live in Moodboard.tsx.
export const useKeyboardShortcuts = (active: boolean) => {
const setTool = useMoodboardStore((s) => s.setTool);
const selectAll = useMoodboardStore((s) => s.selectAll);
const group = useMoodboardStore((s) => s.group);
const ungroup = useMoodboardStore((s) => s.ungroup);
const fitToContent = useMoodboardStore((s) => s.fitToContent);
useEffect(() => {
if (!active) return undefined;
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const handler = (e: KeyboardEvent) => {
if (isEditableTarget(e.target)) return;
// Skip while typing in the text-edit overlay (covered by isEditableTarget
// too, but belt-and-suspenders in case the overlay loses target focus).
if (useMoodboardStore.getState().transient.editingTextId) return;
const mod = isMac ? e.metaKey : e.ctrlKey;
const k = e.key.toLowerCase();
// Modifier combos
if (mod && !e.shiftKey && k === "a") {
e.preventDefault();
selectAll();
return;
}
if (mod && !e.shiftKey && k === "g") {
e.preventDefault();
group();
return;
}
if (mod && e.shiftKey && k === "g") {
e.preventDefault();
ungroup();
return;
}
// Shift-only combos
if (e.shiftKey && !mod && !e.altKey && k === "1") {
e.preventDefault();
fitToContent();
return;
}
// Plain (no modifiers) tool switches — skip if any modifier is held
// so Cmd+V (paste) etc. never triggers the Text tool.
if (mod || e.shiftKey || e.altKey) return;
if (k === "v") {
e.preventDefault();
setTool("select");
} else if (k === "l") {
e.preventDefault();
setTool("lasso");
} else if (k === "t") {
e.preventDefault();
setTool("text");
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [active, setTool, selectAll, group, ungroup, fitToContent]);
};
@@ -0,0 +1,93 @@
import { useEffect } from "react";
import {
isEventFromEditableElement,
useResolvedKeybinds,
} from "@storyteller/keybinds";
import { useMoodboardStore } from "../MoodboardStore";
// Unified moodboard keyboard handling, resolved from the shared keybinds store
// so Settings → Keybinds can rebind these. Replaces the previous trio of
// hardcoded hooks (useKeyboardShortcuts / useUndoRedo + the inline delete/escape
// handler in Moodboard.tsx). Pan/zoom remain mouse/space gestures in
// useViewportControls; clipboard paste stays in usePasteHandler.
export const useMoodboardKeybinds = (active: boolean) => {
const setTool = useMoodboardStore((s) => s.setTool);
const selectAll = useMoodboardStore((s) => s.selectAll);
const setSelection = useMoodboardStore((s) => s.setSelection);
const group = useMoodboardStore((s) => s.group);
const ungroup = useMoodboardStore((s) => s.ungroup);
const fitToContent = useMoodboardStore((s) => s.fitToContent);
const resetViewport = useMoodboardStore((s) => s.resetViewport);
const deleteSelected = useMoodboardStore((s) => s.deleteSelected);
const undo = useMoodboardStore((s) => s.undo);
const redo = useMoodboardStore((s) => s.redo);
const { matchAction } = useResolvedKeybinds();
useEffect(() => {
if (!active) return undefined;
const handler = (e: KeyboardEvent) => {
if (isEventFromEditableElement(e)) return;
if (useMoodboardStore.getState().transient.editingTextId) return;
const action = matchAction(e, "moodboard");
if (!action) return;
e.preventDefault();
switch (action) {
case "moodboard.tools.select":
setTool("select");
break;
case "moodboard.tools.lasso":
setTool("lasso");
break;
case "moodboard.tools.text":
setTool("text");
break;
case "moodboard.selection.selectAll":
selectAll();
break;
case "moodboard.selection.clear":
setSelection([]);
break;
case "moodboard.edit.delete":
deleteSelected();
break;
case "moodboard.edit.group":
group();
break;
case "moodboard.edit.ungroup":
ungroup();
break;
case "moodboard.view.fitToContent":
fitToContent();
break;
case "moodboard.view.resetViewport":
resetViewport();
break;
case "moodboard.history.undo":
undo();
break;
case "moodboard.history.redo":
redo();
break;
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [
active,
matchAction,
setTool,
selectAll,
setSelection,
group,
ungroup,
fitToContent,
resetViewport,
deleteSelected,
undo,
redo,
]);
};
@@ -1,74 +0,0 @@
import { useEffect, useState } from "react";
const HOLD_MS = 3000;
// Shows the cheatsheet when the user holds Ctrl (or Cmd on Mac) alone for
// HOLD_MS with no other key pressed. Any other keydown cancels the timer,
// so a real `Ctrl+C` / `Cmd+X` never trips it. Releasing the modifier hides
// the cheatsheet immediately.
export const useShortcutCheatsheet = (): boolean => {
const [visible, setVisible] = useState(false);
useEffect(() => {
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const modKey = isMac ? "Meta" : "Control";
let timer: ReturnType<typeof setTimeout> | null = null;
let modHeld = false;
const clearTimer = () => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
};
const hide = () => {
setVisible((v) => (v ? false : v));
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === modKey) {
// Ignore auto-repeat keydown while already held.
if (modHeld) return;
modHeld = true;
clearTimer();
timer = setTimeout(() => {
timer = null;
setVisible(true);
}, HOLD_MS);
return;
}
// Any non-modifier key press while the modifier is held cancels the
// planned cheatsheet — the user is running a real combo.
clearTimer();
hide();
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === modKey) {
modHeld = false;
clearTimer();
hide();
}
};
// Losing focus while holding the key should also cancel.
const onBlur = () => {
modHeld = false;
clearTimer();
hide();
};
document.addEventListener("keydown", onKeyDown);
document.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", onBlur);
return () => {
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", onBlur);
clearTimer();
};
}, []);
return visible;
};
@@ -1,37 +0,0 @@
import { useEffect } from "react";
import { useMoodboardStore } from "../MoodboardStore";
// Standard editor hotkeys:
// - Cmd+Z (Mac) / Ctrl+Z (Win) → undo
// - Cmd+Shift+Z (Mac), Ctrl+Y or Ctrl+Shift+Z (Win) → redo
export const useUndoRedo = (active: boolean) => {
const undo = useMoodboardStore((s) => s.undo);
const redo = useMoodboardStore((s) => s.redo);
useEffect(() => {
if (!active) return undefined;
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target && /input|textarea/i.test(target.tagName)) return;
if (target && target.isContentEditable) return;
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const mod = isMac ? e.metaKey : e.ctrlKey;
if (!mod) return;
const k = e.key.toLowerCase();
if (k === "z" && !e.shiftKey) {
e.preventDefault();
undo();
} else if (
(isMac && k === "z" && e.shiftKey) ||
(!isMac && k === "y") ||
(!isMac && k === "z" && e.shiftKey)
) {
e.preventDefault();
redo();
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [active, undo, redo]);
};
@@ -24,7 +24,6 @@ export const useViewportControls = (
) => {
const setZoom = useMoodboardStore((s) => s.setZoom);
const setPan = useMoodboardStore((s) => s.setPan);
const resetViewport = useMoodboardStore((s) => s.resetViewport);
const setIsPanning = useMoodboardStore((s) => s.setIsPanning);
// Track spacebar so any subsequent mousedown becomes a pan, not a select.
@@ -143,13 +142,8 @@ export const useViewportControls = (
// Prevent page from scrolling on spacebar.
e.preventDefault();
}
// Cmd/Ctrl+0 → reset zoom + pan.
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const mod = isMac ? e.metaKey : e.ctrlKey;
if (mod && e.key === "0") {
e.preventDefault();
resetViewport();
}
// Reset zoom + pan (Ctrl+0 by default) is a registry action handled in
// useMoodboardKeybinds; only the held-Space pan gesture lives here.
};
const handleKeyUp = (e: KeyboardEvent) => {
@@ -177,5 +171,5 @@ export const useViewportControls = (
document.removeEventListener("keyup", handleKeyUp);
container.style.cursor = "";
};
}, [stageRef, setPan, resetViewport, setIsPanning]);
}, [stageRef, setPan, setIsPanning]);
};
@@ -1,76 +0,0 @@
import { SHORTCUTS, ShortcutRow } from "./shortcuts";
interface Props {
visible: boolean;
}
const SECTION_ORDER: ShortcutRow["section"][] = [
"Tools",
"Selection",
"Editing",
"Viewport",
"History",
];
// Translucent, non-interactive overlay that lists all keyboard shortcuts.
// Shown while the user holds Ctrl/Cmd alone for 3 s (see
// `useShortcutCheatsheet.ts`). `pointer-events: none` so it never steals
// focus — the user can keep panning / zooming / typing while it's visible.
export const ShortcutCheatsheet = ({ visible }: Props) => {
if (!visible) return null;
// Group rows by section, preserving declaration order within each.
const bySection = new Map<ShortcutRow["section"], ShortcutRow[]>();
for (const row of SHORTCUTS) {
const arr = bySection.get(row.section) ?? [];
arr.push(row);
bySection.set(row.section, arr);
}
return (
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center"
>
<div className="max-h-[80%] w-[min(640px,90%)] overflow-auto rounded-xl border border-white/15 bg-black/70 p-6 text-white/90 shadow-2xl backdrop-blur-sm">
<div className="mb-3 text-xs uppercase tracking-widest text-white/50">
Keyboard shortcuts
</div>
<div className="grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2">
{SECTION_ORDER.map((section) => {
const rows = bySection.get(section);
if (!rows) return null;
return (
<div key={section}>
<div className="mb-2 text-xs font-semibold uppercase tracking-wider text-white/60">
{section}
</div>
<ul className="flex flex-col gap-1.5">
{rows.map((row) => (
<li
key={row.label}
className="flex items-center justify-between gap-3 text-sm"
>
<span className="text-white/80">{row.label}</span>
<span className="flex items-center gap-1">
{row.keys.map((k, i) => (
<Key key={`${row.label}-${i}`} label={k} />
))}
</span>
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
</div>
);
};
const Key = ({ label }: { label: string }) => (
<kbd className="rounded border border-white/20 bg-white/10 px-1.5 py-0.5 font-mono text-[11px] text-white/90">
{label}
</kbd>
);
@@ -16,6 +16,10 @@ import { BoardGridToolbar } from "./BoardGridToolbar";
import { BoardEmptyState } from "./BoardEmptyState";
import { SelectionBar } from "./SelectionBar";
import { ItemInspector } from "./ItemInspector";
import {
isEventFromEditableElement,
useResolvedKeybinds,
} from "@storyteller/keybinds";
interface Props {
active: boolean;
@@ -151,33 +155,38 @@ export const BoardGridView = ({ active, adapter }: Props) => {
assignItemsToSection(board.id, Array.from(selectedItemIds), sectionId);
};
// Grid keyboard: delete / escape + Lightroom-style triage (0-5 rate, P pick)
// on the current selection. Inert while the inspector owns input. Delete is
// bound to Delete/Backspace only — `x` is deliberately NOT an alias, since the
// board has no undo and `x` sits among the number-key triage row, where an
// accidental press would silently destroy work.
// Grid keyboard: escape / delete + Lightroom-style triage (rate 0-5, P
// pick) on the current selection, resolved from the "moodboard-grid"
// keybinds surface so Settings → Keybinds can rebind them. Inert while the
// inspector owns input (it answers the same surface itself). nav.prev/next
// are deliberately NOT consumed here: with no viewer open the arrow keys
// belong to the cards' roving focus (a11y), not a shortcut. Delete has no
// single-letter alias on purpose — the board has no undo, and a stray press
// in the number-key triage row would silently destroy work.
const { matchAction } = useResolvedKeybinds();
useEffect(() => {
if (!active || openId) return undefined;
const handler = (e: KeyboardEvent) => {
const t = e.target as HTMLElement | null;
if (t && (/input|textarea/i.test(t.tagName) || t.isContentEditable)) return;
if (isEventFromEditableElement(e)) return;
const action = matchAction(e, "moodboard-grid");
if (!action) return;
const ids = Array.from(selectedItemIds);
if (e.key === "Escape") {
if (action === "moodboard-grid.selection.clearOrClose") {
clearSelection();
return;
}
if (ids.length === 0) return;
if (e.key === "Delete" || e.key === "Backspace") {
if (action === "moodboard-grid.edit.delete") {
e.preventDefault();
deleteSelected();
} else if (e.key === "p") {
} else if (action === "moodboard-grid.rate.pick") {
e.preventDefault();
if (board) rateItems(board.id, ids, 5);
} else if (/^[0-5]$/.test(e.key)) {
} else if (action.startsWith("moodboard-grid.rate.")) {
e.preventDefault();
if (board) rateItems(board.id, ids, Number(e.key));
if (board) rateItems(board.id, ids, Number(action.slice(-1)));
}
};
document.addEventListener("keydown", handler);
@@ -190,6 +199,7 @@ export const BoardGridView = ({ active, adapter }: Props) => {
deleteSelected,
clearSelection,
rateItems,
matchAction,
]);
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
@@ -8,6 +8,10 @@ import { BoardItem } from "../boards/boardTypes";
import { hostnameOf } from "../boards/linkMeta";
import { extractPalette } from "../boards/palette";
import { Favicon } from "./Favicon";
import {
isEventFromEditableElement,
useResolvedKeybinds,
} from "@storyteller/keybinds";
interface Props {
item: BoardItem;
@@ -53,14 +57,22 @@ export const ItemInspector = ({
const [palette, setPalette] = useState<string[]>([]);
const [tagDraft, setTagDraft] = useState("");
// Viewer keys resolve from the "moodboard-grid" surface (rebindable):
// prev/next item, Escape closes. Inert while typing in the tag field.
const { matchAction } = useResolvedKeybinds();
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "ArrowLeft" && hasPrev) onPrev();
else if (e.key === "ArrowRight" && hasNext) onNext();
if (isEventFromEditableElement(e)) return;
const action = matchAction(e, "moodboard-grid");
if (action === "moodboard-grid.nav.prev" && hasPrev) onPrev();
else if (action === "moodboard-grid.nav.next" && hasNext) onNext();
else if (action === "moodboard-grid.selection.clearOrClose") onClose();
else return;
e.preventDefault();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onPrev, onNext, hasPrev, hasNext]);
}, [matchAction, onPrev, onNext, onClose, hasPrev, hasNext]);
// Pull a palette for images, keyed on the image src — NOT the whole item, so
// editing tags/rating (which produce a new item object) doesn't re-decode the
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { BoardItem } from "../boards/boardTypes";
import { useResolvedKeybinds } from "@storyteller/keybinds";
interface Props {
items: BoardItem[];
@@ -9,22 +10,27 @@ interface Props {
}
// Distraction-free, full-bleed review deck. One item at a time, keyboard-driven
// (←/→ navigate, Esc exits). For client/team review and sharing-by-screen.
// via the "moodboard-grid" keybinds surface (by default ←/→ or Space navigate,
// Esc exits). For client/team review and sharing-by-screen.
export const PresentationView = ({ items, onClose }: Props) => {
const [index, setIndex] = useState(0);
const count = items.length;
const { matchAction } = useResolvedKeybinds();
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
else if (e.key === "ArrowLeft")
const action = matchAction(e, "moodboard-grid");
if (action === "moodboard-grid.selection.clearOrClose") onClose();
else if (action === "moodboard-grid.nav.prev")
setIndex((i) => (i - 1 + count) % Math.max(count, 1));
else if (e.key === "ArrowRight" || e.key === " ")
else if (action === "moodboard-grid.nav.next")
setIndex((i) => (i + 1) % Math.max(count, 1));
else return;
e.preventDefault();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose, count]);
}, [matchAction, onClose, count]);
const item = items[Math.min(index, count - 1)];
@@ -17,6 +17,9 @@
{
"path": "../button-icon-select"
},
{
"path": "../keybinds"
},
{
"path": "../../icons"
},
@@ -40,6 +40,9 @@
{
"path": "../button-icon-select/tsconfig.lib.json"
},
{
"path": "../keybinds/tsconfig.lib.json"
},
{
"path": "../../icons/tsconfig.lib.json"
}
@@ -19,9 +19,6 @@ export type { BaseSelectorImage, ImageBundle, DragState } from "./lib/types";
export { Node } from "./lib/Node";
// Hooks (used by PageEdit)
export { useCopyPasteHotkeys } from "./lib/hooks/useCopyPasteHotkeys";
export { useDeleteHotkeys } from "./lib/hooks/useDeleteHotkeys";
export { useUndoRedoHotkeys } from "./lib/hooks/useUndoRedoHotkeys";
export { useGlobalMouseUp } from "./lib/hooks/useGlobalMouseUp";
export { useStageCentering } from "./lib/hooks/useCenteredStage";
export { useRightPanelLayoutManagement } from "./lib/hooks/useRightPanelLayoutManagement";
@@ -16,9 +16,8 @@ import {
type SceneState,
useSceneStore,
} from "./stores/SceneState";
import { useUndoRedoHotkeys } from "./hooks/useUndoRedoHotkeys";
import { useDeleteHotkeys } from "./hooks/useDeleteHotkeys";
import { useCopyPasteHotkeys } from "./hooks/useCopyPasteHotkeys";
import { usePagedrawKeybinds } from "./hooks/usePagedrawKeybinds";
import { Cheatsheet, useCheatsheetVisibility } from "@storyteller/keybinds";
import Konva from "konva";
import { ContextMenuContainer } from "./components/ui/ContextMenu";
import InpaintToolBar from "./components/ui/InpaintToolBar";
@@ -54,6 +53,9 @@ import type { PageDrawAdapter } from "./adapter";
const PAGE_ID: ModelPage = ModelPage.Canvas2D;
// Order the shape hotkey cycles through (matches the submenu's visual order).
const SHAPE_CYCLE = ["rectangle", "circle", "triangle"] as const;
export const DecodeBase64ToImage = async (
base64String: string,
): Promise<ImageBitmap> => {
@@ -309,6 +311,7 @@ const PageDraw = ({
fillColor: state.fillColor,
brushColor: state.brushColor,
brushSize: state.brushSize,
eraserSize: state.eraserSize,
inpaintOperation: state.inpaintOperation,
inpaintBrushSize: state.inpaintBrushSize,
setInpaintOperation: state.setInpaintOperation,
@@ -363,6 +366,7 @@ const PageDraw = ({
fillColor,
brushColor,
brushSize,
eraserSize,
inpaintOperation,
inpaintBrushSize,
setInpaintOperation,
@@ -428,12 +432,81 @@ const PageDraw = ({
const supportsMaskedInpainting =
selectedImageModel?.usesInpaintingMask ?? false;
useDeleteHotkeys({ onDelete: deleteSelectedItems });
useUndoRedoHotkeys({ undo, redo });
useCopyPasteHotkeys({
// Keyboard tool switching mirrors the SideToolbar buttons; size up/down
// adjusts whichever size the active tool draws with (brush, eraser, and the
// mask tool each own their size). Handlers read the store via getState so
// they stay referentially stable.
// The shape tool isn't a single tool — it has a rectangle/circle/triangle
// submenu. The hotkey uses press-again-to-cycle (Adobe/Figma convention):
// first press activates with the current shape, further presses cycle.
const handleShapeToolKey = useCallback(() => {
const scene = useSceneStore.getState();
if (scene.activeTool === "shape") {
const current = scene.currentShape ?? "rectangle";
const index = SHAPE_CYCLE.indexOf(current);
scene.setCurrentShape(SHAPE_CYCLE[(index + 1) % SHAPE_CYCLE.length]);
return;
}
scene.selectNode(null);
if (!scene.currentShape) scene.setCurrentShape("rectangle");
scene.setActiveTool("shape");
}, []);
const handleMaskToolKey = useCallback(() => {
if (!supportsMaskedInpainting) return;
useSceneStore.getState().setActiveTool("inpaint");
}, [supportsMaskedInpainting]);
const adjustBrushSize = useCallback((direction: 1 | -1) => {
const scene = useSceneStore.getState();
const step = 2 * direction;
if (scene.activeTool === "inpaint") {
scene.setInpaintBrushSize(
Math.min(100, Math.max(1, scene.inpaintBrushSize + step)),
);
} else if (scene.activeTool === "eraser") {
scene.setEraserSize(Math.min(64, Math.max(1, scene.eraserSize + step)));
} else if (scene.activeTool === "draw") {
scene.setBrushSize(Math.min(64, Math.max(1, scene.brushSize + step)));
}
}, []);
const handleBrushSizeUp = useCallback(
() => adjustBrushSize(1),
[adjustBrushSize],
);
const handleBrushSizeDown = useCallback(
() => adjustBrushSize(-1),
[adjustBrushSize],
);
const handleSelectToolKey = useCallback(
() => useSceneStore.getState().setActiveTool("select"),
[],
);
const handleBrushToolKey = useCallback(
() => useSceneStore.getState().setActiveTool("draw"),
[],
);
const handleEraserToolKey = useCallback(
() => useSceneStore.getState().setActiveTool("eraser"),
[],
);
usePagedrawKeybinds({
undo,
redo,
onCopy: copySelectedItems,
onPaste: pasteItems,
onDelete: deleteSelectedItems,
onSelectTool: handleSelectToolKey,
onShapeTool: handleShapeToolKey,
onBrushTool: handleBrushToolKey,
onMaskTool: handleMaskToolKey,
onEraserTool: handleEraserToolKey,
onBrushSizeUp: handleBrushSizeUp,
onBrushSizeDown: handleBrushSizeDown,
});
const cheatsheetVisible = useCheatsheetVisibility();
// Read the inpaint mask off the Konva layer and encode it on a worker thread.
// The Konva readback itself runs on main (Konva is DOM-bound), but everything
@@ -1238,6 +1311,7 @@ const PageDraw = ({
/>
)}
<div className="relative z-0">
<Cheatsheet surface="pagedraw" visible={cheatsheetVisible} />
<ContextMenuContainer
onAction={(e, action) => {
if (action === "contextMenu") {
@@ -1264,6 +1338,7 @@ const PageDraw = ({
activeTool={activeTool}
brushColor={brushColor}
brushSize={brushSize}
eraserSize={eraserSize}
inpaintOperation={inpaintOperation}
inpaintBrushSize={inpaintBrushSize}
onSelectionChange={setIsSelecting}
@@ -43,6 +43,7 @@ export type MiraiProps = {
| "inpaint";
brushColor?: string;
brushSize?: number;
eraserSize?: number;
inpaintOperation?: "add" | "minus";
inpaintBrushSize?: number;
onSelectionChange?: (isSelecting: boolean) => void;
@@ -69,6 +70,7 @@ export const PaintSurface = ({
activeTool = "select",
brushColor = "#000000",
brushSize = 5,
eraserSize = 16,
inpaintOperation = "add",
inpaintBrushSize = 30,
onSelectionChange,
@@ -374,9 +376,11 @@ export const PaintSurface = ({
: InpaintingColor;
const strokeWidth =
activeTool === "inpaint"
? inpaintBrushSize / stage.scaleX()
: brushSize / stage.scaleX();
(activeTool === "inpaint"
? inpaintBrushSize
: activeTool === "eraser"
? eraserSize
: brushSize) / stage.scaleX();
const newLineNode: LineNode = {
id: lineId,
@@ -996,7 +1000,13 @@ export const PaintSurface = ({
cursorNode.visible(true);
cursorNode.position(cursorPositionRef.current);
cursorNode.radius((activeTool === "inpaint" ? inpaintBrushSize : brushSize) / 2);
cursorNode.radius(
(activeTool === "inpaint"
? inpaintBrushSize
: activeTool === "eraser"
? eraserSize
: brushSize) / 2,
);
if (activeTool === "draw") {
cursorNode.fill(brushColor);
@@ -1021,7 +1031,7 @@ export const PaintSurface = ({
}
cursorLayer.batchDraw();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cursorVisibleLocal, activeTool, brushColor, brushSize, inpaintBrushSize, inpaintOperation]);
}, [cursorVisibleLocal, activeTool, brushColor, brushSize, eraserSize, inpaintBrushSize, inpaintOperation]);
const renderNode = useCallback(
(node: Node | LineNode) => {
@@ -5,6 +5,11 @@ import "../../pagedraw.css";
import { HsvaColorPicker, HsvaColor } from "react-colorful";
import { hsvaToHex } from "@uiw/color-convert";
import SliderWithIndicator from "./SliderWithIndicator";
import {
formatBinding,
useResolvedKeybinds,
type ActionId,
} from "@storyteller/keybinds";
import { ActiveTool, useSceneStore } from "../../stores/SceneState";
import { Node } from "../../Node";
import { Tooltip } from "@storyteller/ui-tooltip";
@@ -16,6 +21,16 @@ import {
const shapeIconBtn =
"flex h-9 w-9 items-center justify-center rounded-lg transition-colors hover:bg-white/10";
// Toolbar entry → registry action whose resolved key renders as the button's
// corner badge (updates live when the user remaps in Settings → Keybinds).
const TOOL_ACTION_IDS: Record<string, ActionId> = {
select: "pagedraw.tools.select",
"add-shape": "pagedraw.tools.shape",
draw: "pagedraw.tools.brush",
inpaint: "pagedraw.tools.mask",
erase: "pagedraw.tools.eraser",
};
// Debounce function
function useDebounced<T extends (...args: A) => void, A extends unknown[]>(
fn: T,
@@ -52,8 +67,15 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
className = "",
}) => {
const store = useSceneStore();
const { forAction } = useResolvedKeybinds();
const [open, setOpen] = useState<string | null>(null);
const [brushSize, setBrushSize] = useState(16);
// Canonical brush/eraser size lives in the store (PaintSurface draws with
// it, the Ctrl+± keybinds adjust it) — the slider reads/writes it directly
// so keyboard changes show up here too.
const brushSize = store.brushSize;
const setBrushSize = store.setBrushSize;
const eraserSize = store.eraserSize;
const setEraserSize = store.setEraserSize;
const [brushHsva, setBrushHsva] = useState<HsvaColor>({
h: 120,
s: 100,
@@ -192,6 +214,20 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
const BgPopout = makePicker(bgHsva, setBgHsva, sendBg);
const ShapePopout = makePicker(shapeHsva, setShapeHsva, sendShapeColor);
// Size-only popout — the eraser has no color, just its own size, kept
// separate from the brush so switching tools doesn't clobber either.
const EraserPopout = (
<div className="glass relative w-56 rounded-2xl p-4 shadow-lg">
<p className="mb-2 text-sm font-medium text-white">Eraser Size</p>
<SliderWithIndicator
value={eraserSize}
onChange={setEraserSize}
min={1}
max={64}
/>
</div>
);
// Tools
const tools = [
{
@@ -218,19 +254,24 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
},
popout: (
<div className="flex items-center gap-1.5 rounded-full px-1.5 py-1.5 shadow-lg">
{[SquareIcon, CircleIcon, TriangleIcon].map((ShapeIcon, i) => (
<button
key={i}
className={shapeIconBtn}
onClick={() => {
const shapes = ["rectangle", "circle", "triangle"] as const;
onActivateShapeTool(shapes[i]);
setOpen(null);
}}
>
<ShapeIcon className="h-5 w-5 text-white" />
</button>
))}
{[SquareIcon, CircleIcon, TriangleIcon].map((ShapeIcon, i) => {
const shapes = ["rectangle", "circle", "triangle"] as const;
const isCurrent = currentShape === shapes[i];
return (
<button
key={i}
className={`${shapeIconBtn} ${
isCurrent ? "bg-primary/30 ring-1 ring-primary" : ""
}`}
onClick={() => {
onActivateShapeTool(shapes[i]);
setOpen(null);
}}
>
<ShapeIcon className="h-5 w-5 text-white" />
</button>
);
})}
</div>
),
},
@@ -268,6 +309,7 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
onClick: () => {
store.setActiveTool("eraser");
},
popout: EraserPopout,
},
{ id: "separator-3", type: "separator" },
{
@@ -374,9 +416,20 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
}
const { id, icon, onClick, popout, label } = tool;
// Two item ids don't match their ActiveTool value 1:1.
const active =
id === activeToolId ||
(id === "add-shape" && activeToolId === "shape");
(id === "add-shape" && activeToolId === "shape") ||
(id === "erase" && activeToolId === "eraser");
// Resolved shortcut badge in the button corner (switchable tools only).
const toolActionId = TOOL_ACTION_IDS[id];
const toolBinding = toolActionId ? forAction(toolActionId)[0] : undefined;
const keyBadge = toolBinding ? (
<span className="pointer-events-none absolute bottom-0 right-0 min-w-[13px] rounded-[4px] bg-black/60 px-0.5 text-center text-[9px] font-medium leading-[13px] text-white/80">
{formatBinding(toolBinding)}
</span>
) : null;
const btnStyle = active
? "bg-primary/30 border-2 !border-primary text-white"
: (id === "inpaint" && !supportsMaskTool)
@@ -409,7 +462,12 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
);
}
const tooltipLabel = (id === "inpaint" && !supportsMaskTool) ? label + " (Model unsupported)" : label;
const tooltipLabel =
id === "inpaint" && !supportsMaskTool
? label + " (Model unsupported)"
: id === "add-shape" && toolBinding
? `${label} — ${formatBinding(toolBinding)} cycles shapes`
: label;
return (
<div key={id} className="relative">
@@ -453,6 +511,7 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
className={`${baseBtn} ${btnStyle}`}
>
{displayIcon}
{keyBadge}
</button>
) : (
<button
@@ -463,6 +522,7 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
disabled={id === "inpaint" && !supportsMaskTool}
>
{displayIcon}
{keyBadge}
</button>
)}
</Tooltip>
@@ -502,6 +562,7 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
className={`${baseBtn} ${btnStyle}`}
>
{displayIcon}
{keyBadge}
</button>
) : (
<button
@@ -511,6 +572,7 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
className={`${baseBtn} ${btnStyle}`}
>
{displayIcon}
{keyBadge}
</button>
)}
</>
@@ -1,56 +0,0 @@
import { useEffect } from "react";
interface UseCopyPasteHotkeysProps {
onCopy: () => void;
onPaste: () => void;
// Optional: to disable hotkeys when an input is focused
disableWhenInputFocused?: boolean;
}
export const useCopyPasteHotkeys = ({
onCopy,
onPaste,
disableWhenInputFocused = true,
}: UseCopyPasteHotkeysProps): void => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (disableWhenInputFocused) {
const target = event.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return; // Ignore if typing in an input, textarea, etc.
}
}
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const isC = event.key.toLowerCase() === "c";
const isV = event.key.toLowerCase() === "v";
// Copy: Cmd+C (Mac) or Ctrl+C (Windows/Linux)
const isCopy =
(isMac ? event.metaKey : event.ctrlKey) && isC && !event.shiftKey;
// Paste: Cmd+V (Mac) or Ctrl+V (Windows/Linux)
const isPaste =
(isMac ? event.metaKey : event.ctrlKey) && isV && !event.shiftKey;
if (isCopy) {
event.preventDefault();
console.log("Copy action triggered");
onCopy();
} else if (isPaste) {
event.preventDefault();
console.log("Paste action triggered");
onPaste();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [onCopy, onPaste, disableWhenInputFocused]);
};
@@ -1,51 +0,0 @@
import { useEffect, useCallback } from "react";
interface DeleteHotkeysProps {
onDelete: () => void;
target?: Document | HTMLElement;
}
export const useDeleteHotkeys = ({
onDelete,
target = document,
}: DeleteHotkeysProps): void => {
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// Check if the event target is an input field or contentEditable element
const target = event.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable ||
target.getAttribute("role") === "textbox"
) {
return; // Don't prevent default for input fields
}
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const isDelete =
event.key === "Delete" ||
event.key === "Backspace" ||
(isMac && event.metaKey && event.key === "Backspace");
if (isDelete) {
event.preventDefault();
console.log("Delete action triggered");
onDelete();
}
},
[onDelete],
);
useEffect(() => {
const currentTarget = target || document;
currentTarget.addEventListener("keydown", handleKeyDown as EventListener);
return () => {
currentTarget.removeEventListener(
"keydown",
handleKeyDown as EventListener,
);
};
}, [handleKeyDown, target]);
};
@@ -0,0 +1,104 @@
import { useEffect } from "react";
import {
isEventFromEditableElement,
useResolvedKeybinds,
} from "@storyteller/keybinds";
// Unified 2D-editor keyboard handling, resolved from the shared keybinds store
// (so Settings → Keybinds can rebind these). Replaces the three ad-hoc hooks
// (useUndoRedoHotkeys / useCopyPasteHotkeys / useDeleteHotkeys).
export interface PagedrawKeybindHandlers {
undo: () => void;
redo: () => void;
onCopy: () => void;
onPaste: () => void;
onDelete: () => void;
onSelectTool: () => void;
onShapeTool: () => void;
onBrushTool: () => void;
onMaskTool: () => void;
onEraserTool: () => void;
onBrushSizeUp: () => void;
onBrushSizeDown: () => void;
}
export const usePagedrawKeybinds = (handlers: PagedrawKeybindHandlers): void => {
const { matchAction } = useResolvedKeybinds();
const {
undo,
redo,
onCopy,
onPaste,
onDelete,
onSelectTool,
onShapeTool,
onBrushTool,
onMaskTool,
onEraserTool,
onBrushSizeUp,
onBrushSizeDown,
} = handlers;
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (isEventFromEditableElement(e)) return;
const action = matchAction(e, "pagedraw");
if (!action) return;
e.preventDefault();
switch (action) {
case "pagedraw.history.undo":
undo();
break;
case "pagedraw.history.redo":
redo();
break;
case "pagedraw.edit.copy":
onCopy();
break;
case "pagedraw.edit.paste":
onPaste();
break;
case "pagedraw.edit.delete":
onDelete();
break;
case "pagedraw.tools.select":
onSelectTool();
break;
case "pagedraw.tools.shape":
onShapeTool();
break;
case "pagedraw.tools.brush":
onBrushTool();
break;
case "pagedraw.tools.mask":
onMaskTool();
break;
case "pagedraw.tools.eraser":
onEraserTool();
break;
case "pagedraw.tools.brushSizeUp":
onBrushSizeUp();
break;
case "pagedraw.tools.brushSizeDown":
onBrushSizeDown();
break;
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [
matchAction,
undo,
redo,
onCopy,
onPaste,
onDelete,
onSelectTool,
onShapeTool,
onBrushTool,
onMaskTool,
onEraserTool,
onBrushSizeUp,
onBrushSizeDown,
]);
};
@@ -1,53 +0,0 @@
import { useEffect } from "react";
interface UndoRedoHotkeysProps {
undo: () => void;
redo: () => void;
target?: Document | HTMLElement; // Optional target, defaults to document
}
export const useUndoRedoHotkeys = ({
undo,
redo,
target = document,
}: UndoRedoHotkeysProps): void => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const isZ = event.key.toLowerCase() === "z";
const isY = event.key.toLowerCase() === "y";
const isUndo =
(isMac ? event.metaKey : event.ctrlKey) && isZ && !event.shiftKey;
// Redo conditions:
// 1. Mac: Cmd + Shift + Z
// 2. Windows/Linux: Ctrl + Y
// 3. Windows/Linux (alternative): Ctrl + Shift + Z
const isRedoMac = isMac && event.metaKey && event.shiftKey && isZ;
const isRedoWinY = !isMac && event.ctrlKey && isY;
const isRedoWinShiftZ = !isMac && event.ctrlKey && event.shiftKey && isZ;
if (isUndo) {
event.preventDefault();
console.log("Undo action triggered");
undo();
} else if (isRedoMac || isRedoWinY || isRedoWinShiftZ) {
event.preventDefault();
console.log("Redo action triggered");
redo();
}
};
const currentTarget = target || document;
// Ensure 'keydown' is used as some systems might not fire 'keypress' for Ctrl/Cmd combinations
currentTarget.addEventListener("keydown", handleKeyDown as EventListener);
return () => {
currentTarget.removeEventListener(
"keydown",
handleKeyDown as EventListener,
);
};
}, [undo, redo, target]); // Re-run effect if callbacks or target change
};
@@ -124,6 +124,10 @@ export interface SceneState {
activeTool: ActiveTool;
brushColor: string;
brushSize: number;
// Eraser keeps its own size (like inpaintBrushSize) — erasing strokes are
// typically much bigger than brush strokes, so the sizes don't shadow each
// other across tool switches.
eraserSize: number;
brushOpacity: number;
fillColor: string;
currentShape: "rectangle" | "circle" | "triangle" | null;
@@ -249,6 +253,7 @@ export interface SceneState {
setBrushColor: (color: string) => void;
setBrushOpacity: (opacity: number) => void;
setBrushSize: (size: number) => void;
setEraserSize: (size: number) => void;
setFillColor: (color: string) => void;
// Inpaint actions
@@ -336,6 +341,7 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
activeTool: "select",
brushColor: "#000000",
brushSize: 5,
eraserSize: 16,
brushOpacity: 1,
fillColor: "white",
currentShape: null,
@@ -1031,6 +1037,7 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
setActiveTool: (tool: ActiveTool) => set({ activeTool: tool }),
setBrushColor: (color: string) => set({ brushColor: color }),
setBrushSize: (size: number) => set({ brushSize: size }),
setEraserSize: (size: number) => set({ eraserSize: size }),
setFillColor: (color: string) => set({ fillColor: color }),
setBrushOpacity: (opacity: number) => set({ brushOpacity: opacity }),
@@ -1107,6 +1114,7 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
inpaintLineNodes: JSON.parse(JSON.stringify(state.inpaintLineNodes)),
brushColor: state.brushColor,
brushSize: state.brushSize,
eraserSize: state.eraserSize,
fillColor: state.fillColor,
aspectRatioType: state.aspectRatioType,
version: "2.0",
@@ -1172,6 +1180,7 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
selectedNodeIds: [],
brushColor: sceneData.brushColor || "#000000",
brushSize: sceneData.brushSize || 5,
eraserSize: sceneData.eraserSize || 16,
fillColor: sceneData.fillColor || "white",
aspectRatioType: sceneData.aspectRatioType || AspectRatioType.NONE,
});
@@ -26,6 +26,9 @@
{
"path": "../../model-list"
},
{
"path": "../keybinds"
},
{
"path": "../../common"
},
@@ -68,6 +68,9 @@
{
"path": "../../model-list/tsconfig.lib.json"
},
{
"path": "../keybinds/tsconfig.lib.json"
},
{
"path": "../../common/tsconfig.lib.json"
},
+39 -3
View File
@@ -12,6 +12,35 @@ components that host these canvases** (`SceneContainer`, `EditorCanvas`, `Previe
for mode/chrome gating — hide them with CSS (`hidden`) instead. (Regression fixed: record mode
had gated `PreviewEngineCamera` behind `!isRecord`, wiping the scene on record→build toggle.)
## Keybinds (unified registry)
Every viewport shortcut is a remappable action in `@storyteller/keybinds`
(`frontend/libs/components/keybinds`): the registry owns *which key* (preset +
user overrides, Settings → Keybinds), `engine/keymap.ts` owns *what it does*
(action id → handler), and `useViewportKeyboard` dispatches with a per-event
`KeybindContext` (sceneMode / encoding / timelineExpanded / timelineSelection /
modalTransformActive). Rules:
- **Availability is declared, not implied.** An `ActionDef.when(ctx)` gate
decides if a binding is live; an unavailable binding falls through without
consuming the event. Default rule: available unless encoding. Two actions may
share a key ONLY if their gates are mutually exclusive (`actionsCoAvailable`
enforces this in tests and in the settings conflict UI). Examples: Space =
timeline play/pause in playback contexts, camera-view toggle otherwise;
Del/Backspace = valid timeline selection → keyframe/strip delete, else scene
object; Escape = cancel-render mid-encode, clear-selection otherwise.
- **Do NOT add raw keydown listeners for editor operations** — register an
action + handler + `when` gate instead. Capture-phase component listeners
(the old TimelineEditor delete path) are exactly the pattern this replaced.
- **Record mode** gates all scene-mutating actions off (`inBuild`); the encode
is abortable (overlay Cancel / Escape via `encodeCancelSignal`), and
Capture/Record always `enterCameraView()` first so output comes from the
render camera even after peeking at the scene view.
- Camera movement (held WASD/QE/arrows) stays in `useFreeCam`, which resolves
its codes from the same store; its imperative guards (ctrl-chords,
timeline-expanded arrows, record lock) must stay in sync with the registry's
`cameraMove`/`cameraLook` gates.
## Behavior notes (fixes)
- **Record mode is fully immutable.** The viewport lock (`CameraController.locked`, set by the
@@ -129,11 +158,18 @@ auto-import; audio in recordings; `snapShotOfCurrentFrame` render-camera reconci
`loadFromJson` calls `recreateCameraObject()` (→ `Scene._create_camera_obj()`, now idempotent)
AFTER `CamerasReplacedEvent`, so the frustum lands at the restored transform. This also fixes the
standalone "load loses the render camera" bug.
- **Undo/redo**: timeline is **transactional** — `Save` is one undo step (`SaveTimelineAction`;
`loadTimeline` re-seeks + emits on undo/redo); `Cancel` reverts to last saved (not history).
- **Undo/redo (PER-EDIT)**: every user-facing timeline edit is ONE undo step via
`TimelineEditAction` — whole-timeline `{live, saved}` before/after snapshots, recorded through
`TimelineController.recordEdit` (discrete edits) or `beginTimelineEdit`/`commitTimelineEdit`
(continuous gestures: keyframe/strip drags coalesce at pointer-up, a Motion-popover session
commits on close). Raw dispatchers (`moveKeyframe`, `resizeClipLane`, `setKeyframeEasing`,
`setClipTransitionEasing`) are deliberately UNRECORDED live-previews — never call them for a
discrete edit without wrapping. `Save` now only moves the Cancel baseline (its undo restores
the old baseline without jumping live state); `Cancel` still reverts to last saved and is
itself undoable. `autoKeyIfTracked` bypasses recording on purpose (it rides the gizmo's
`TransformAction`). The old `SaveTimelineAction`/`RemoveClipLaneAction` classes are retired.
Render-camera gizmo moves were already undoable (`TransformAction` on `::CAM::`, no special-casing);
because save reads the live `cam_obj`, a save after an undo persists the undone position.
Per-edit timeline undo remains deferred.
### Implementation status (branch `feature/scene-builder`)
@@ -188,7 +188,7 @@ export { getSceneGenerationMetaData } from "./lib/sceneMetadata";
// innerHeight when no host adapter is available.
//
// Host-coupled comps (AssetMenu, ControlsTopButtons, Controls3D,
// EditorLoadingBar, OnboardingHelper) stay in the artcraft host
// EditorLoadingBar) stay in the artcraft host
// until their dependencies (UploadModal3D, MediaFilesApi,
// signalScene, addToast, loadingBarData/IsShowing signals, etc.)
// are either surfaced via adapter slots or replaced with
@@ -210,16 +210,7 @@ export { PreviewBox } from "./lib/comps/PreviewBox";
export type { PreviewBoxProps } from "./lib/comps/PreviewBox";
export { PreviewEngineCamera } from "./lib/comps/PreviewEngineCamera";
export { PreviewImages } from "./lib/comps/PreviewImages";
export { OnboardingHelper } from "./lib/comps/OnboardingHelper";
export { EditorLoadingBar } from "./lib/comps/EditorLoadingBar";
export {
Help,
Key,
KeyGroup,
Mouse,
Plus,
ShortcutsGroup,
} from "./lib/comps/ControlsTopButtons/Help/Help";
// Viewport-size hook (also useful to host code that wants the same
// reactivity contract the lib comps use).
@@ -189,6 +189,9 @@ interface PageSceneState {
ignoreKeyDelete: boolean;
hotkeyStatus: HotkeyStatus;
isPromptBoxFocused: boolean;
// True while a Blender-style modal transform (grab/move) is in progress, so
// free-cam and the normal keymap stand down and let the modal own input.
modalTransformActive: boolean;
// timeline (mirrors TimelineController; see engine/editor/TimelineController.ts)
timelineExists: boolean;
@@ -222,6 +225,10 @@ interface PageSceneState {
// record output
producedArtifact: ProducedArtifact | null;
recordingProgress: RecordingProgress | null;
// Mutable abort flag for the in-flight encode (recordTimeline polls
// `cancelled` between frames). Held here so the Escape keybind and the
// overlay's Cancel button can reach it; null when no encode is running.
encodeCancelSignal: { cancelled: boolean } | null;
// layout / panels
assetModalVisible: boolean;
@@ -341,11 +348,15 @@ interface PageSceneState {
setProducedArtifact: (artifact: ProducedArtifact | null) => void;
clearProducedArtifact: () => void;
setRecordingProgress: (progress: RecordingProgress | null) => void;
setEncodeCancelSignal: (signal: { cancelled: boolean } | null) => void;
// Flip the in-flight encode's abort flag (no-op when nothing is encoding).
requestEncodeCancel: () => void;
toggleStats: () => void;
setIgnoreKeyDelete: (ignore: boolean) => void;
disableHotkeyInput: (level: DomLevels) => void;
enableHotkeyInput: (level: DomLevels) => void;
setIsPromptBoxFocused: (focused: boolean) => void;
setModalTransformActive: (active: boolean) => void;
// layout
setAssetModalVisible: (visible: boolean) => void;
@@ -442,9 +453,11 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
timelineFocusSelected: false,
producedArtifact: null,
recordingProgress: null,
encodeCancelSignal: null,
ignoreKeyDelete: false,
hotkeyStatus: { disabled: false, disabledBy: DomLevels.NONE },
isPromptBoxFocused: false,
modalTransformActive: false,
assetModalVisible: false,
animationsModalVisible: false,
@@ -562,6 +575,11 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
return { producedArtifact: null };
}),
setRecordingProgress: (progress) => set({ recordingProgress: progress }),
setEncodeCancelSignal: (signal) => set({ encodeCancelSignal: signal }),
requestEncodeCancel: () => {
const signal = get().encodeCancelSignal;
if (signal) signal.cancelled = true;
},
toggleStats: () => set((s) => ({ statsVisible: !s.statsVisible })),
setIgnoreKeyDelete: (ignore) => set({ ignoreKeyDelete: ignore }),
disableHotkeyInput: (level) => {
@@ -581,6 +599,7 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
}
},
setIsPromptBoxFocused: (focused) => set({ isPromptBoxFocused: focused }),
setModalTransformActive: (active) => set({ modalTransformActive: active }),
// layout actions
setAssetModalVisible: (visible) => set({ assetModalVisible: visible }),
@@ -38,6 +38,7 @@ import {
useCostBreakdownModalStore,
} from "@storyteller/ui-pricing-modal";
import { LoadingDots } from "@storyteller/ui-loading";
import { Cheatsheet, useCheatsheetVisibility } from "@storyteller/keybinds";
import type { PopoverItem } from "@storyteller/ui-popover";
import { v4 as uuidv4 } from "uuid";
@@ -50,7 +51,6 @@ import { SceneModePill } from "./comps/SceneModePill";
import { ControlsTopButtons } from "./comps/ControlsTopButtons";
import { EditorCanvas } from "./comps/EngineCanvases";
import { FocalLengthDisplay } from "./comps/FocalLengthDisplay/FocalLengthDisplay";
import { OnboardingHelper } from "./comps/OnboardingHelper";
import { PerfStatsOverlay } from "./comps/PerfStatsOverlay";
import { EntranceDebugPanel } from "./comps/EntranceDebugPanel";
import { Outliner } from "./comps/Outliner";
@@ -284,6 +284,7 @@ export const Stage3DBody = ({
useFreeCam(editorCanvas, editor);
useViewportPointer(editorCanvas, editor);
useViewportKeyboard(editor);
const cheatsheetVisible = useCheatsheetVisibility();
const handleCameraSelect = (selectedItem: PopoverItem) => {
const selectedCamera = cameras.find(
@@ -473,6 +474,8 @@ export const Stage3DBody = ({
<EditorCanvas />
</SceneContainer>
<Cheatsheet surface="pagescene" visible={cheatsheetVisible} />
<PerfStatsOverlay />
{import.meta.env.DEV && <EntranceDebugPanel />}
<FocalLengthDisplay />
@@ -0,0 +1,76 @@
// Capture (still) / Record (timeline → video) output actions. Shared by the
// RecordControls buttons and the keybind handlers (registry ids
// pagescene.record.captureStill / pagescene.record.recordVideo), so both
// entry points get the same busy-guarding and camera handling.
import type Editor from "../engine/editor";
import { recordTimeline } from "../engine/recording/TimelineRecorder";
import { usePageSceneStore } from "../PageSceneStore";
// Snapshot the composed frame → hand to the review modal, which previews it
// immediately and auto-uploads to the gallery (with status) → app Lightbox.
export function captureStill(editor: Editor): void {
const store = usePageSceneStore.getState();
if (store.recordingProgress !== null) return;
// Output always comes from the render camera. Record mode lets the user
// peek at the scene view, so re-enter camera view (idempotent) first.
editor.cameraController.enterCameraView();
store.setRecordingProgress({ phase: "capturing", pct: 0 });
// Defer so the overlay paints before the (sync) snapshot work.
requestAnimationFrame(() => {
const s = usePageSceneStore.getState();
try {
const snap = editor.snapShotOfCurrentFrame(false);
if (snap) {
s.setProducedArtifact({
kind: "image",
blob: snap.file,
objectUrl: URL.createObjectURL(snap.file),
fileName: snap.file.name,
mimeType: "image/png",
aspectRatio: s.cameraAspectRatio,
});
}
} finally {
s.setRecordingProgress(null);
}
});
}
// Encode the timeline; the produced clip opens the review modal for a manual
// upload (videos are large, so we don't auto-upload). Abortable mid-encode
// via the store's encodeCancelSignal (overlay Cancel button, Escape keybind).
export async function recordVideo(editor: Editor): Promise<void> {
const store = usePageSceneStore.getState();
if (store.recordingProgress !== null || !store.timelineExists) return;
// See captureStill — the encode reads the viewport camera, so it must be
// sitting at the render camera.
editor.cameraController.enterCameraView();
store.setRecordingProgress({ phase: "encoding", pct: 0 });
const cancelSignal = { cancelled: false };
store.setEncodeCancelSignal(cancelSignal);
try {
const result = await recordTimeline(editor, {
signal: cancelSignal,
onProgress: (pct) =>
usePageSceneStore
.getState()
.setRecordingProgress({ phase: "encoding", pct }),
});
if (result) {
const s = usePageSceneStore.getState();
s.setProducedArtifact({
kind: "video",
blob: result.blob,
objectUrl: URL.createObjectURL(result.blob),
fileName: result.fileName,
mimeType: result.mimeType,
aspectRatio: s.cameraAspectRatio,
});
}
} finally {
const s = usePageSceneStore.getState();
s.setEncodeCancelSignal(null);
s.setRecordingProgress(null);
}
}
@@ -2,10 +2,17 @@
// forward to editor.timelineController (mirrors the other files in this
// folder). Pure-UI state (expanded, selected keyframe) is set directly on
// the store by components and has no dispatcher here.
//
// Undo: every DISCRETE edit here records one undo step via
// TimelineController.recordEdit. Continuous gestures (keyframe/strip drags,
// the easing popover) intentionally call the raw move/resize/easing
// dispatchers per event and coalesce into a single step through
// beginTimelineEdit / commitTimelineEdit at the gesture boundaries.
import type Editor from "../engine/editor";
import type { EasingSpec } from "../engine/timeline/types";
import { RemoveClipLaneAction } from "../engine/editor/actions/RemoveClipLaneAction";
import type { TimelineUndoSnapshot } from "../engine/editor/TimelineController";
import { DEFAULT_EASING } from "../engine/timeline/types";
import { usePageSceneStore } from "../PageSceneStore";
// The clip source only needs an id + label — both a full MediaItem (drawer
@@ -38,7 +45,24 @@ export function seekTimeline(editor: Editor, time: number): void {
}
export function setTimelineDuration(editor: Editor, seconds: number): void {
editor.timelineController.setDuration(seconds);
editor.timelineController.recordEdit("Set Timeline Duration", () =>
editor.timelineController.setDuration(seconds),
);
}
// Gesture-boundary hooks for continuous edits: capture at pointer-down /
// popover-open, commit at pointer-up / popover-close so the whole gesture
// is ONE undo step (an unchanged timeline records nothing).
export function beginTimelineEdit(editor: Editor): TimelineUndoSnapshot {
return editor.timelineController.snapshotForUndo();
}
export function commitTimelineEdit(
editor: Editor,
label: string,
before: TimelineUndoSnapshot,
): void {
editor.timelineController.recordEditSince(label, before);
}
export function addKeyframe(
@@ -46,13 +70,20 @@ export function addKeyframe(
objectUuid: string,
atTime?: number,
): void {
editor.timelineController.addKeyframe(objectUuid, atTime);
editor.timelineController.recordEdit("Add Keyframe", () =>
editor.timelineController.addKeyframe(objectUuid, atTime),
);
}
export function deleteKeyframe(editor: Editor, keyframeId: string): void {
editor.timelineController.deleteKeyframe(keyframeId);
editor.timelineController.recordEdit("Delete Keyframe", () =>
editor.timelineController.deleteKeyframe(keyframeId),
);
}
// Raw (unrecorded) — drag live-preview. Discrete callers wrap it: drags
// commit via commitTimelineEdit at pointer-up, keyboard nudges via
// nudgeKeyframe below.
export function moveKeyframe(
editor: Editor,
keyframeId: string,
@@ -61,6 +92,18 @@ export function moveKeyframe(
editor.timelineController.moveKeyframe(keyframeId, time);
}
export function nudgeKeyframe(
editor: Editor,
keyframeId: string,
time: number,
): void {
editor.timelineController.recordEdit("Move Keyframe", () =>
editor.timelineController.moveKeyframe(keyframeId, time),
);
}
// Raw (unrecorded) — the Motion popover fires this per curve-drag event;
// TimelineEditor coalesces one popover session into one undo step.
export function setKeyframeEasing(
editor: Editor,
keyframeId: string,
@@ -91,14 +134,16 @@ export function addClipToCharacter(
): string | null {
if (!item.media_id) return null;
const startTime = atTime ?? editor.timelineController.getPlayhead();
const laneId = editor.timelineController.addClipLane(characterUuid, {
sourceMediaId: item.media_id,
name: item.name ?? "Animation",
startTime,
duration: DEFAULT_CLIP_DURATION,
loop: true,
autoDuration: true,
});
const laneId = editor.timelineController.recordEdit("Add Animation Clip", () =>
editor.timelineController.addClipLane(characterUuid, {
sourceMediaId: item.media_id,
name: item.name ?? "Animation",
startTime,
duration: DEFAULT_CLIP_DURATION,
loop: true,
autoDuration: true,
}),
);
if (laneId) {
const store = usePageSceneStore.getState();
store.setTimelineRevealObjectUuid(characterUuid);
@@ -123,17 +168,21 @@ export function addBakedClipToObject(
);
const clip = object?.animations?.[clipIndex];
if (!clip) return null;
return editor.timelineController.addClipLane(objectUuid, {
sourceMediaId: "",
bakedClipIndex: clipIndex,
name: clip.name || `Clip ${clipIndex + 1}`,
startTime: atTime,
duration: Math.min(clip.duration || DEFAULT_CLIP_DURATION, DEFAULT_CLIP_DURATION),
// Loop by default, same as library strips (see addClipToCharacter).
loop: true,
});
return editor.timelineController.recordEdit("Add Animation Clip", () =>
editor.timelineController.addClipLane(objectUuid, {
sourceMediaId: "",
bakedClipIndex: clipIndex,
name: clip.name || `Clip ${clipIndex + 1}`,
startTime: atTime,
duration: Math.min(clip.duration || DEFAULT_CLIP_DURATION, DEFAULT_CLIP_DURATION),
// Loop by default, same as library strips (see addClipToCharacter).
loop: true,
}),
);
}
// Raw (unrecorded) — strip-drag live-preview; the drag commits one undo step
// via commitTimelineEdit at pointer-up. Keyboard nudges use nudgeClipLane.
export function moveClipLane(
editor: Editor,
laneId: string,
@@ -142,6 +191,17 @@ export function moveClipLane(
editor.timelineController.moveClipLane(laneId, startTime);
}
export function nudgeClipLane(
editor: Editor,
laneId: string,
startTime: number,
): void {
editor.timelineController.recordEdit("Move Animation Clip", () =>
editor.timelineController.moveClipLane(laneId, startTime),
);
}
// Raw (unrecorded) — trim-drag live-preview, committed at pointer-up.
export function resizeClipLane(
editor: Editor,
laneId: string,
@@ -155,11 +215,14 @@ export function setClipLoop(
laneId: string,
loop: boolean,
): void {
editor.timelineController.setClipLoop(laneId, loop);
editor.timelineController.recordEdit("Toggle Clip Loop", () =>
editor.timelineController.setClipLoop(laneId, loop),
);
}
// Set (or clear, with null) the opt-in pose transition from `laneId`'s strip
// into the next strip on its row.
// Raw (unrecorded) — the Motion popover fires this per curve-drag event;
// TimelineEditor coalesces one popover session (including "Remove
// transition") into one undo step.
export function setClipTransitionEasing(
editor: Editor,
laneId: string,
@@ -168,24 +231,25 @@ export function setClipTransitionEasing(
editor.timelineController.setClipTransitionEasing(laneId, easing);
}
// Open a gap after `laneId`'s strip for a transition to play in, when its
// boundary with the next strip is flush/too tight. Returns false when the
// row is too packed to make room (callers toast).
export function ensureClipTransitionGap(
editor: Editor,
laneId: string,
): boolean {
return editor.timelineController.ensureTransitionGap(laneId);
// Enable the pose transition out of `laneId`'s strip: open a gap for it to
// play in when the boundary is flush (a too-packed row returns false and
// mutates nothing — callers toast) and set the default curve. One undo step.
export function enableClipTransition(editor: Editor, laneId: string): boolean {
const before = editor.timelineController.snapshotForUndo();
if (!editor.timelineController.ensureTransitionGap(laneId)) return false;
editor.timelineController.setClipTransitionEasing(laneId, DEFAULT_EASING);
editor.timelineController.recordEditSince("Enable Transition", before);
return true;
}
// Individually undoable (RemoveClipLaneAction) — both the strip's × button
// and Del/Backspace route through here.
// Individually undoable — both the strip's × button and Del/Backspace route
// through here. removeClipLane also mirrors the removal into the Cancel
// baseline (see TimelineController), and the snapshot captures both worlds.
export function removeClipLane(editor: Editor, laneId: string): void {
const lane = editor.timelineController.getClipLane(laneId);
if (!lane) return;
const action = new RemoveClipLaneAction(editor.timelineController, lane);
action.apply();
editor.history.record(action);
if (!editor.timelineController.getClipLane(laneId)) return;
editor.timelineController.recordEdit("Remove Animation Clip", () =>
editor.timelineController.removeClipLane(laneId),
);
}
export function saveTimeline(editor: Editor): void {
@@ -3,7 +3,7 @@ import { FileIcon, KeyboardIcon, SquareCheckIcon, SquareIcon } from "lucide-reac
import { ButtonDropdown } from "@storyteller/ui-button-dropdown";
import { Input } from "@storyteller/ui-input";
import { Button } from "@storyteller/ui-button";
import { Modal } from "@storyteller/ui-modal";
import { useCheatsheetPin } from "@storyteller/keybinds";
import { twMerge } from "tailwind-merge";
import { EngineContext } from "../../contexts/EngineContext/EngineContext";
@@ -14,13 +14,13 @@ import {
import { DEFAULT_CAMERA_ASPECT_RATIO, ToastTypes } from "../../enums";
import { getSceneGenerationMetaData } from "../../sceneMetadata";
import { LoadUserScenes } from "./LoadUserScenes";
import { Help } from "./Help/Help";
const isNumberString = (s: string): boolean => /^\d+$/.test(s);
export const ControlsTopButtons = () => {
const editor = useContext(EngineContext);
const [shortcutsIsShowing, setShortcutsIsShowing] = useState(false);
const cheatsheetPinned = useCheatsheetPin((s) => s.pinned);
const toggleCheatsheet = useCheatsheetPin((s) => s.togglePinned);
const sceneMeta = usePageSceneStore((s) => s.sceneMeta);
const currentUserToken = usePageSceneStore((s) => s.currentUserToken);
@@ -336,7 +336,6 @@ export const ControlsTopButtons = () => {
{
disabled: !currentUserToken || !sceneMeta.token,
label: "Save scene as copy",
description: "Ctrl+Shift+S",
onDialogOpen: bumpCopyCountInTitle,
dialogProps: saveAsCopyDialogProps,
},
@@ -391,23 +390,24 @@ export const ControlsTopButtons = () => {
Outliner
</Button>
{/* Pins the live keybinds cheatsheet (rendered by Stage3DBody) open
until Esc / click outside / this button again. data-cheatsheet-toggle
exempts it from the overlay's click-outside dismissal so the second
click reads as "close", not "close then reopen". */}
<Button
icon={KeyboardIcon}
variant="secondary"
className="shadow-xl"
onClick={() => setShortcutsIsShowing(true)}
iconClassName={twMerge(
"text-[16px]",
cheatsheetPinned ? "text-white" : "text-white/20",
)}
onClick={toggleCheatsheet}
data-cheatsheet-toggle
>
Shortcuts
</Button>
</div>
<Modal
isOpen={shortcutsIsShowing}
onClose={() => setShortcutsIsShowing(false)}
title="Shortcuts"
className="h-[500px] max-w-4xl"
>
<Help />
</Modal>
</div>
);
};
@@ -1,202 +0,0 @@
import { Label } from "@storyteller/ui-label";
export const ShortcutsGroup = (props: {
label: string;
children?: React.ReactNode;
}) => (
<div className="flex flex-col gap-1">
<Label className="text-md font-semibold" {...props}>
{props.label}
</Label>
<div className="relative flex flex-col gap-2.5">
{props.children}
<div className="absolute top-0 h-full w-0.5 bg-white/15" />
</div>
</div>
);
export const Shortcut = (props: {
label: string;
children: React.ReactNode;
}) => (
<div className="flex items-center gap-2 ps-4">
<span className="text-md w-[280px] font-medium opacity-80">
{props.label}
</span>
{props.children}
</div>
);
export const Key = (props: { button: string }) => (
<div className="flex h-[30px] w-auto min-w-[30px] items-center justify-center rounded-md border-b-2 border-[#9E9EA6] bg-white px-2 text-center text-sm font-bold text-ui-background">
{props.button}
</div>
);
export const KeyGroup = (props: { children: React.ReactNode }) => (
<div className="flex items-center gap-1">{props.children}</div>
);
// Inline SVG instead of /resources/icons/mouse_*.png. The PNGs only
// ship with the Tauri host's public dir, so the webapp host showed a
// broken-image placeholder. Inlining keeps the lib self-contained and
// renders identically across both apps.
export const Mouse = (props: {
button: "left" | "middle" | "right";
label?: string;
}) => (
<div className="flex items-center gap-2.5">
<MouseIcon active={props.button} />
<span className="text-sm font-normal opacity-60">{props.label}</span>
</div>
);
const MouseIcon = ({ active }: { active: "left" | "middle" | "right" }) => (
<svg
viewBox="0 0 20 28"
aria-hidden
className="h-7 w-auto text-white"
>
<rect
x="1.25"
y="1.25"
width="17.5"
height="25.5"
rx="8.75"
ry="11"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
opacity="0.85"
/>
{active === "left" && (
<path
d="M9 1.75 V12 H1.5 V10 A8.5 8.5 0 0 1 9 1.75 Z"
fill="currentColor"
/>
)}
{active === "right" && (
<path
d="M11 1.75 V12 H18.5 V10 A8.5 8.5 0 0 0 11 1.75 Z"
fill="currentColor"
/>
)}
<rect
x="9"
y="4"
width="2"
height="5"
rx="1"
fill="currentColor"
opacity={active === "middle" ? "1" : "0.5"}
/>
<line
x1="1.5"
y1="12"
x2="18.5"
y2="12"
stroke="currentColor"
strokeWidth="0.75"
opacity="0.4"
/>
</svg>
);
export const Plus = () => <div className="text-xl font-medium">+</div>;
const ShortcutsView = () => (
<div className="grid select-none grid-cols-2 gap-12 ps-4 mt-6">
<div className="flex flex-col gap-8">
<ShortcutsGroup label="Navigation">
<Shortcut label="Orbit View">
<KeyGroup>
<Mouse button="left" label="(Hold)" />
</KeyGroup>
</Shortcut>
<Shortcut label="Forward Backward">
<KeyGroup>
<Key button="W" />
<Key button="S" />
</KeyGroup>
</Shortcut>
<Shortcut label="Left Right">
<KeyGroup>
<Key button="A" />
<Key button="D" />
</KeyGroup>
</Shortcut>
<Shortcut label="Up Down">
<KeyGroup>
<Key button="E" />
<Key button="Q" />
</KeyGroup>
</Shortcut>
<Shortcut label="Speed Boost">
<KeyGroup>
<Key button="Shift" />
<span className="ml-1.5 text-sm font-normal opacity-60">(Hold)</span>
</KeyGroup>
</Shortcut>
<Shortcut label="Slow Movement">
<KeyGroup>
<Key button="Alt" />
<span className="ml-1.5 text-sm font-normal opacity-60">(Hold)</span>
</KeyGroup>
</Shortcut>
</ShortcutsGroup>
<ShortcutsGroup label="Interaction">
<Shortcut label="Select Object">
<Mouse button="left" label="(Click)" />
</Shortcut>
<Shortcut label="Delete Selection">
<Key button="Del" />
</Shortcut>
</ShortcutsGroup>
</div>
<div className="flex flex-col gap-8">
<ShortcutsGroup label="Shortcuts">
<Shortcut label="Transform">
<Key button="T" />
</Shortcut>
<Shortcut label="Rotate">
<Key button="R" />
</Shortcut>
<Shortcut label="Scale">
<Key button="G" />
</Shortcut>
<Shortcut label="Focus">
<Key button="F" />
</Shortcut>
<Shortcut label="Copy">
<Key button="Ctrl" />
<Plus></Plus>
<Key button="C" />
</Shortcut>
<Shortcut label="Paste">
<Key button="Ctrl" />
<Plus></Plus>
<Key button="Shift" />
<Plus></Plus>
<Key button="V" />
</Shortcut>
<Shortcut label="Open 3D Asset Modal">
<Key button="B" />
</Shortcut>
</ShortcutsGroup>
</div>
</div>
);
export const Help = () => {
return (
<div className="flex flex-col h-full">
<div className="flex-1">
<ShortcutsView />
</div>
</div>
);
};
@@ -1 +0,0 @@
export { Help } from "./Help";
@@ -1,106 +0,0 @@
import { useState, useEffect } from "react";
import { Transition } from "@headlessui/react";
import { Key, KeyGroup, Mouse, Plus } from "./ControlsTopButtons/Help/Help";
import { CloseButton } from "@storyteller/ui-close-button";
const STORAGE_KEY = "onboarding_helper_dismissed";
const EXPIRATION_DAYS = 3;
export const OnboardingHelper = () => {
const [actionPerformed, setActionPerformed] = useState(false);
const [keyPressed, setKeyPressed] = useState(false);
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
// Check if the helper was dismissed
/*
const dismissedData = localStorage.getItem(STORAGE_KEY);
if (dismissedData) {
const { timestamp } = JSON.parse(dismissedData);
const expirationTime = timestamp + EXPIRATION_DAYS * 24 * 60 * 60 * 1000; // 3 days in milliseconds
if (Date.now() < expirationTime) {
setIsVisible(false);
} else {
// Clear expired data
localStorage.removeItem(STORAGE_KEY);
}
}
*/
}, []);
const handleClose = () => {
setIsVisible(false);
// Store dismissal timestamp
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
timestamp: Date.now(),
}),
);
};
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (["W", "A", "S", "D"].includes(event.key.toUpperCase())) {
setTimeout(() => setKeyPressed(true), 200);
}
};
const handleAction = () => {
setTimeout(() => setActionPerformed(true), 200);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("mousedown", handleAction);
window.addEventListener("dragstart", handleAction);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("mousedown", handleAction);
window.removeEventListener("dragstart", handleAction);
};
}, []);
return (
<Transition
show={isVisible && !actionPerformed && !keyPressed}
enter="transition-opacity duration-500"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-500"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="glass pointer-events-none relative mx-auto flex w-fit flex-col items-center justify-center gap-1 rounded-xl border-2 border-brand-primary px-3 pr-4 py-2 mb-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="scale-75 origin-center -mx-3">
<KeyGroup>
<Key button="W" />
<Key button="A" />
<Key button="S" />
<Key button="D" />
</KeyGroup>
</div>
<p className="text-sm font-semibold">Move</p>
</div>
<div className="h-4 w-[1px] bg-white/20" />
<div className="flex items-center gap-2">
<div className="scale-75 origin-center -mx-1">
<Mouse button="left" />
</div>
<p className="text-sm font-semibold">Look</p>
</div>
</div>
<div className="pointer-events-auto absolute -top-2 -right-2">
<CloseButton
onClick={handleClose}
className="h-5 w-5 bg-black/50 hover:bg-black/80 rounded-full border border-white/10"
/>
</div>
</div>
</Transition>
);
};
export default OnboardingHelper;
@@ -3,73 +3,22 @@ import { CameraIcon, CircleIcon } from "lucide-react";
import { Tooltip } from "@storyteller/ui-tooltip";
import { EngineContext } from "../../contexts/EngineContext";
import { usePageSceneStore } from "../../PageSceneStore";
import { recordTimeline } from "../../engine/recording/TimelineRecorder";
import { captureStill, recordVideo } from "../../actions/recordOutput";
import { TimelineBar } from "../Timeline";
// Record-mode controls: a read-only playback bar (when a timeline exists) plus
// Capture (still) and Record (timeline→video).
// Capture (still) and Record (timeline→video). The actual output logic lives
// in actions/recordOutput.ts, shared with the capture/record keybinds.
// Capture → auto-upload to gallery → open the app Lightbox on the token.
// Record → produce the clip → hand to the video review modal (manual upload,
// since videos are large) via producedArtifact.
export const RecordControls = () => {
const editor = useContext(EngineContext);
const timelineExists = usePageSceneStore((s) => s.timelineExists);
const aspectRatio = usePageSceneStore((s) => s.cameraAspectRatio);
const recordingProgress = usePageSceneStore((s) => s.recordingProgress);
const setProducedArtifact = usePageSceneStore((s) => s.setProducedArtifact);
const setRecordingProgress = usePageSceneStore((s) => s.setRecordingProgress);
const busy = recordingProgress !== null;
// Snapshot the composed frame → hand to the review modal, which previews it
// immediately and auto-uploads to the gallery (with status) → app Lightbox.
const handleCapture = () => {
if (!editor || busy) return;
setRecordingProgress({ phase: "capturing", pct: 0 });
// Defer so the overlay paints before the (sync) snapshot work.
requestAnimationFrame(() => {
try {
const snap = editor.snapShotOfCurrentFrame(false);
if (snap) {
setProducedArtifact({
kind: "image",
blob: snap.file,
objectUrl: URL.createObjectURL(snap.file),
fileName: snap.file.name,
mimeType: "image/png",
aspectRatio,
});
}
} finally {
setRecordingProgress(null);
}
});
};
// Encode the timeline; the produced clip opens the review modal for a manual
// upload (videos are large, so we don't auto-upload).
const handleRecord = async () => {
if (!editor || busy || !timelineExists) return;
setRecordingProgress({ phase: "encoding", pct: 0 });
try {
const result = await recordTimeline(editor, {
onProgress: (pct) => setRecordingProgress({ phase: "encoding", pct }),
});
if (result) {
setProducedArtifact({
kind: "video",
blob: result.blob,
objectUrl: URL.createObjectURL(result.blob),
fileName: result.fileName,
mimeType: result.mimeType,
aspectRatio,
});
}
} finally {
setRecordingProgress(null);
}
};
return (
<div className="absolute bottom-6 left-1/2 z-30 flex -translate-x-1/2 flex-col items-center gap-3">
{timelineExists && (
@@ -80,7 +29,7 @@ export const RecordControls = () => {
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleCapture}
onClick={() => editor && captureStill(editor)}
disabled={busy}
className="glass glass-no-hover flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium text-base-fg shadow-xl hover:bg-white/10 disabled:opacity-50"
>
@@ -98,7 +47,7 @@ export const RecordControls = () => {
>
<button
type="button"
onClick={handleRecord}
onClick={() => editor && void recordVideo(editor)}
disabled={busy || !timelineExists}
className="flex items-center gap-2 rounded-full bg-red px-5 py-2.5 text-sm font-semibold text-white shadow-xl transition-transform hover:scale-[1.03] disabled:opacity-50 disabled:hover:scale-100"
>
@@ -3,8 +3,12 @@ import { usePageSceneStore } from "../../PageSceneStore";
// Opaque full-viewport cover shown while a Capture/Record is in flight. It
// hides the 3D scene (freeing GPU during frame encoding) and shows progress.
// Video encodes are abortable: the Cancel button (and the Escape keybind)
// flip the store's encodeCancelSignal, which recordTimeline polls per frame.
export const RenderOverlay = () => {
const progress = usePageSceneStore((s) => s.recordingProgress);
const cancellable = usePageSceneStore((s) => s.encodeCancelSignal !== null);
const requestEncodeCancel = usePageSceneStore((s) => s.requestEncodeCancel);
const pct = Math.round((progress?.pct ?? 0) * 100);
const message =
@@ -15,12 +19,25 @@ export const RenderOverlay = () => {
: "Capturing frame…";
return (
<LoadingDots
className="absolute left-0 top-0 z-[60]"
isShowing={progress !== null}
type="bricks"
message={message}
/>
<>
<LoadingDots
className="absolute left-0 top-0 z-[60]"
isShowing={progress !== null}
type="bricks"
message={message}
/>
{progress?.phase === "encoding" && cancellable && (
<div className="absolute bottom-10 left-1/2 z-[61] -translate-x-1/2">
<button
type="button"
onClick={requestEncodeCancel}
className="glass glass-no-hover rounded-full px-5 py-2 text-sm font-medium text-base-fg shadow-xl hover:bg-white/10"
>
Cancel (Esc)
</button>
</div>
)}
</>
);
};
@@ -4,21 +4,20 @@ import { FilmIcon, PlusIcon, RepeatIcon, SplineIcon, XIcon } from "lucide-react"
import { EngineContext } from "../../contexts/EngineContext/EngineContext";
import {
addBakedClipToObject,
ensureClipTransitionGap,
beginTimelineEdit,
commitTimelineEdit,
enableClipTransition,
moveClipLane,
removeClipLane,
resizeClipLane,
setClipLoop,
setClipTransitionEasing,
} from "../../actions";
import type { TimelineUndoSnapshot } from "../../engine/editor/TimelineController";
import {
usePageSceneStore,
type ClipLane,
} from "../../PageSceneStore";
import {
DEFAULT_EASING,
DEFAULT_TIMELINE_FPS,
} from "../../engine/timeline/types";
import { DEFAULT_TIMELINE_FPS } from "../../engine/timeline/types";
import { ToastTypes } from "../../enums";
import { fractionToTime, quantizeToFrame, timeToFraction } from "./timelineUtils";
@@ -56,6 +55,9 @@ export const TimelineClipRow = ({
const editor = useContext(EngineContext);
const laneRef = useRef<HTMLDivElement>(null);
const drag = useRef<Drag | null>(null);
// Snapshot at drag start; the whole move/trim gesture commits as ONE undo
// step at pointer-up (the per-event dispatchers are raw live-previews).
const dragUndo = useRef<TimelineUndoSnapshot | null>(null);
// Baked-clip picker anchor (button rect at open time; null = closed). The
// popup PORTALS to document.body: rendered in place it would be clipped by
// the row list's overflow-y-auto ancestor — the top row's upward popup was
@@ -98,14 +100,14 @@ export const TimelineClipRow = ({
return;
}
if (!lane.strip.transitionEasing) {
if (!ensureClipTransitionGap(editor, lane.id)) {
// Gap + default curve as one undo step.
if (!enableClipTransition(editor, lane.id)) {
editor.adapter.showToast(
ToastTypes.WARNING,
"No room on this row to open a gap for the transition.",
);
return;
}
setClipTransitionEasing(editor, lane.id, DEFAULT_EASING);
}
store.setTimelineEasingKeyframe(null);
store.setTimelineEasingClipLane(lane.id);
@@ -148,7 +150,15 @@ export const TimelineClipRow = ({
};
const endDrag = () => {
if (drag.current && dragUndo.current && editor) {
commitTimelineEdit(
editor,
drag.current.kind === "move" ? "Move Animation Clip" : "Trim Animation Clip",
dragUndo.current,
);
}
drag.current = null;
dragUndo.current = null;
};
return (
@@ -264,6 +274,7 @@ export const TimelineClipRow = ({
kind: "move",
grabOffset: laneTimeFromEvent(e.clientX) - strip.startTime,
};
if (editor) dragUndo.current = beginTimelineEdit(editor);
}}
>
<button
@@ -317,6 +328,7 @@ export const TimelineClipRow = ({
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = { laneId: lane.id, kind: "resize" };
if (editor) dragUndo.current = beginTimelineEdit(editor);
}}
/>
</div>
@@ -5,16 +5,18 @@ import { Button } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
import { EngineContext } from "../../contexts/EngineContext/EngineContext";
import {
beginTimelineEdit,
cancelTimeline,
commitTimelineEdit,
deleteKeyframe,
pauseTimeline,
playTimeline,
removeClipLane,
saveTimeline,
seekTimeline,
setClipTransitionEasing,
setKeyframeEasing,
} from "../../actions";
import type { TimelineUndoSnapshot } from "../../engine/editor/TimelineController";
import { usePageSceneStore } from "../../PageSceneStore";
import {
DEFAULT_EASING,
@@ -102,6 +104,42 @@ export const TimelineEditor = () => {
// The Motion popover edits the easing of the segment BETWEEN two
// keyframes (stored on the left one) and anchors at the segment midpoint.
const easingKeyframeId = usePageSceneStore((s) => s.timelineEasingKeyframeId);
const easingClipLaneIdForUndo = usePageSceneStore(
(s) => s.timelineEasingClipLaneId,
);
// One popover session = ONE undo step. The popover's onChange fires per
// curve-drag event (raw setKeyframeEasing / setClipTransitionEasing);
// snapshot when a popover opens, commit when it closes or retargets —
// "Remove transition" rides this same session, so it isn't double-recorded.
const easingTarget = easingKeyframeId ?? easingClipLaneIdForUndo ?? null;
const easingUndo = useRef<{
target: string;
before: TimelineUndoSnapshot;
} | null>(null);
useEffect(() => {
if (!editor) return;
if (easingUndo.current && easingUndo.current.target !== easingTarget) {
commitTimelineEdit(editor, "Edit Easing", easingUndo.current.before);
easingUndo.current = null;
}
if (easingTarget && !easingUndo.current) {
easingUndo.current = {
target: easingTarget,
before: beginTimelineEdit(editor),
};
}
}, [easingTarget, editor]);
// Collapse/unmount with a popover open still commits the session.
useEffect(
() => () => {
if (easingUndo.current && editor) {
commitTimelineEdit(editor, "Edit Easing", easingUndo.current.before);
easingUndo.current = null;
}
},
[editor],
);
let easingAnchor: {
keyframe: (typeof tracks)[number]["keyframes"][number];
leftPercent: number;
@@ -150,40 +188,14 @@ export const TimelineEditor = () => {
};
})();
// Selected keyframe: Delete/Backspace removes it (capture phase, so the
// engine's document-level Delete — which deletes the selected scene
// object — never sees the key), and clicking anywhere that isn't a
// keyframe or the Delete button deselects it.
// Deleting the selected keyframe / clip strip via Del/Backspace is NOT
// handled here — it's the registry action pagescene.timeline.deleteSelected
// (see engine/keymap.ts), whose `when` gate is the exact complement of the
// scene-object delete's, so consumption order is decided declaratively
// rather than by listener phase. This component only owns click-away
// deselection.
useEffect(() => {
if (!selectedKeyframeId) return undefined;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Delete" && e.key !== "Backspace") return;
const target = e.target as HTMLElement | null;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target?.isContentEditable
) {
return;
}
// The selection can be stale (Cancel / undo of a Save replace the
// timeline wholesale). Never swallow the key for a target that no
// longer exists — clear the selection and let the engine's own
// Delete handler (selected scene object) see the event.
const keyframeExists = editor?.timelineController
.getTimeline()
?.tracks.some((t) =>
t.keyframes.some((k) => k.id === selectedKeyframeId),
);
if (!editor || !keyframeExists) {
usePageSceneStore.getState().setTimelineSelectedKeyframe(null);
return;
}
e.preventDefault();
e.stopPropagation();
deleteKeyframe(editor, selectedKeyframeId);
usePageSceneStore.getState().setTimelineSelectedKeyframe(null);
};
const onPointerDown = (e: PointerEvent) => {
const target = e.target as HTMLElement | null;
if (
@@ -194,59 +206,22 @@ export const TimelineEditor = () => {
}
usePageSceneStore.getState().setTimelineSelectedKeyframe(null);
};
document.addEventListener("keydown", onKeyDown, true);
document.addEventListener("pointerdown", onPointerDown, true);
return () => {
document.removeEventListener("keydown", onKeyDown, true);
return () =>
document.removeEventListener("pointerdown", onPointerDown, true);
};
}, [selectedKeyframeId, editor]);
}, [selectedKeyframeId]);
// Selected clip strip: Del/Backspace removes it (undoable via
// RemoveClipLaneAction), and clicking anything that isn't a strip
// deselects. Mirrors the keyframe selection handlers above; both keydown
// listeners can coexist since strip-selection clears keyframe-selection
// (a strip isn't [data-keyframe]) and vice versa — the guard below is
// belt-and-braces for the impossible both-selected case.
useEffect(() => {
if (!selectedClipLaneId) return undefined;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Delete" && e.key !== "Backspace") return;
const target = e.target as HTMLElement | null;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target?.isContentEditable
) {
return;
}
if (usePageSceneStore.getState().timelineSelectedKeyframeId) return;
// Same staleness rule as the keyframe handler above: a dead id must
// not eat the key meant for the engine's scene-object Delete.
if (
!editor ||
!editor.timelineController.getClipLane(selectedClipLaneId)
) {
usePageSceneStore.getState().setTimelineSelectedClipLane(null);
return;
}
e.preventDefault();
e.stopPropagation();
removeClipLane(editor, selectedClipLaneId);
usePageSceneStore.getState().setTimelineSelectedClipLane(null);
};
const onPointerDown = (e: PointerEvent) => {
const target = e.target as HTMLElement | null;
if (target?.closest("[data-clip-strip]")) return;
usePageSceneStore.getState().setTimelineSelectedClipLane(null);
};
document.addEventListener("keydown", onKeyDown, true);
document.addEventListener("pointerdown", onPointerDown, true);
return () => {
document.removeEventListener("keydown", onKeyDown, true);
return () =>
document.removeEventListener("pointerdown", onPointerDown, true);
};
}, [selectedClipLaneId, editor]);
}, [selectedClipLaneId]);
// Scroll the row list to the object whose clip was just added
// (addClipToCharacter sets the uuid alongside timelineExpanded). A store
@@ -1,7 +1,14 @@
import { useContext, useRef } from "react";
import { DynamicIcon } from "@storyteller/icons";
import { EngineContext } from "../../contexts/EngineContext/EngineContext";
import { addKeyframe, moveKeyframe, seekTimeline } from "../../actions";
import {
addKeyframe,
beginTimelineEdit,
commitTimelineEdit,
moveKeyframe,
seekTimeline,
} from "../../actions";
import type { TimelineUndoSnapshot } from "../../engine/editor/TimelineController";
import {
usePageSceneStore,
type OutlinerItem,
@@ -28,6 +35,9 @@ export const TimelineTrackRow = ({
const editor = useContext(EngineContext);
const laneRef = useRef<HTMLDivElement>(null);
const draggingId = useRef<string | null>(null);
// Snapshot at drag start; the whole drag commits as ONE undo step at
// pointer-up (moveKeyframe during the drag is a raw live-preview).
const dragUndo = useRef<TimelineUndoSnapshot | null>(null);
const selectedKeyframeId = usePageSceneStore(
(s) => s.timelineSelectedKeyframeId,
@@ -60,6 +70,14 @@ export const TimelineTrackRow = ({
if (editor) seekTimeline(editor, time);
};
const endDrag = () => {
if (draggingId.current && dragUndo.current && editor) {
commitTimelineEdit(editor, "Move Keyframe", dragUndo.current);
}
draggingId.current = null;
dragUndo.current = null;
};
return (
<div className="flex items-center gap-3 py-1">
{/* Keep this column exactly w-32: the ruler row and playhead overlay
@@ -81,12 +99,8 @@ export const TimelineTrackRow = ({
laneTimeFromEvent(e.clientX),
);
}}
onPointerUp={() => {
draggingId.current = null;
}}
onPointerLeave={() => {
draggingId.current = null;
}}
onPointerUp={endDrag}
onPointerLeave={endDrag}
>
{/* connecting line between the first and last keyframe */}
{sorted.length >= 2 && (
@@ -152,6 +166,7 @@ export const TimelineTrackRow = ({
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
draggingId.current = kf.id;
if (editor) dragUndo.current = beginTimelineEdit(editor);
// Selecting a keyframe never opens the Motion popover —
// that's the easing chip's job.
setEasingKeyframe(null);
@@ -132,33 +132,37 @@ export const createFreeCamControlState = (): FreeCamControlState => ({
smoothing: 0.2,
});
// Translate a code from a KeyboardEvent into the move-state slot it
// drives, if any. Returns the property name on HeldMoveKeys, or null.
// Maps the keybinds-registry camera action ids to the held-key state slot each
// drives. The actual key codes come from the resolved bindings (preset +
// overrides), built into a `code → slot` map by useFreeCam at bind time.
export const CAMERA_MOVE_ACTION_SLOTS: Record<string, keyof HeldMoveKeys> = {
"pagescene.camera.forward": "forward",
"pagescene.camera.back": "back",
"pagescene.camera.left": "left",
"pagescene.camera.right": "right",
"pagescene.camera.up": "up",
"pagescene.camera.down": "down",
};
export const CAMERA_ROTATE_ACTION_SLOTS: Record<string, keyof HeldRotateKeys> = {
"pagescene.camera.pitchUp": "pitchUp",
"pagescene.camera.pitchDown": "pitchDown",
"pagescene.camera.yawLeft": "yawLeft",
"pagescene.camera.yawRight": "yawRight",
};
// Translate a KeyboardEvent code into the move/rotate slot it drives via a
// resolved `code → slot` map, or null. (The map replaces the old hardcoded
// WASD/QE + arrow switches so camera keys are remappable.)
export const moveSlotForKeyCode = (
code: string,
): keyof HeldMoveKeys | null => {
switch (code) {
case "KeyW": return "forward";
case "KeyS": return "back";
case "KeyA": return "left";
case "KeyD": return "right";
case "KeyQ": return "down";
case "KeyE": return "up";
default: return null;
}
};
map: Record<string, keyof HeldMoveKeys>,
): keyof HeldMoveKeys | null => map[code] ?? null;
export const rotateSlotForKeyCode = (
code: string,
): keyof HeldRotateKeys | null => {
switch (code) {
case "ArrowUp": return "pitchUp";
case "ArrowDown": return "pitchDown";
case "ArrowLeft": return "yawLeft";
case "ArrowRight": return "yawRight";
default: return null;
}
};
map: Record<string, keyof HeldRotateKeys>,
): keyof HeldRotateKeys | null => map[code] ?? null;
// Per-frame integration step. Reads `state` (held keys + velocity),
// applies the resulting movement / rotation to `camera`, and returns
@@ -25,6 +25,8 @@ import { CharacterAnimationManager } from "./animation/CharacterAnimationManager
import { SkeletonHelperController } from "./editor/SkeletonHelperController";
import { DeleteAction } from "./editor/actions/DeleteAction";
import { TransformAction } from "./editor/actions/TransformAction";
import { CreateAction } from "./editor/actions/CreateAction";
import { ModalTransformController } from "./editor/ModalTransformController";
import Stats from "three/examples/jsm/libs/stats.module.js";
import { SparkRenderer } from "@sparkjsdev/spark";
@@ -125,6 +127,15 @@ class Editor {
// dragend. Null whenever no drag is in progress.
private activeTransform: TransformAction | null = null;
// Blender-style modal grab/move (keyboard axis-lock). Created up front; reads
// camera/renderer/selection at begin() time.
readonly modalTransform: ModalTransformController =
new ModalTransformController(this);
public beginModalTransform(mode: "translate") {
this.modalTransform.begin(mode);
}
// Forwarding getter — ControlPanelSceneObject reads `editor.selected`.
get selected(): THREE.Object3D | undefined {
return this.selection.selected;
@@ -953,6 +964,28 @@ class Editor {
this.selection.refreshOutliner();
}
// Copy + paste the current selection in one step, recording history so the
// duplicate can be undone as a single action. Bound to the Duplicate keybind.
public async duplicateSelected() {
await this.sceneManager?.copy();
const obj = await this.sceneManager?.paste();
if (!obj) return;
this.history.record(new CreateAction(this, obj));
this.selection.refreshOutliner();
}
// Toggle the floor grid. One-way write: emit on the bus → bridge updates the
// store and the gridSubscription re-syncs the THREE gridHelper.
public toggleGrid() {
const visible = usePageSceneStore.getState().gridVisible;
this.bus.emit(new GridVisibleChangedEvent(!visible));
}
// Toggle gizmo grid-snapping (continuous vs 0.01 increments).
public toggleSnapping() {
this.gizmo.toggleSnapping();
}
// Render the scene to the camera, this is called in the update.
async renderScene() {
const { render_camera, render_width, render_height } =
@@ -29,10 +29,17 @@ export type GizmoControllerDeps = {
setTransformSpace: (space: TransformSpace) => void;
};
// Snap increments applied when grid snapping is enabled (matches the values
// originally hardcoded in configure()).
const TRANSLATION_SNAP = 0.01;
const ROTATION_SNAP = 0.01;
const SCALE_SNAP = 0.01;
export class GizmoController {
control: TransformControls | undefined;
private deps: GizmoControllerDeps;
private snapping = true;
constructor(deps: GizmoControllerDeps) {
this.deps = deps;
@@ -47,9 +54,7 @@ export class GizmoController {
if (camera == undefined) return;
this.control = new TransformControls(camera, domElement);
this.control.space = "world";
this.control.setScaleSnap(0.01);
this.control.setTranslationSnap(0.01);
this.control.setRotationSnap(0.01);
this.applySnapping();
this.control.setSize(0.5);
this.control.addEventListener("change", callbacks.onChange);
this.control.addEventListener("dragging-changed", (event: any) => {
@@ -72,6 +77,28 @@ export class GizmoController {
this.control.space = space;
}
// Grid snapping. When off, transforms are continuous (snap = null).
setSnapping(enabled: boolean) {
this.snapping = enabled;
this.applySnapping();
}
toggleSnapping(): boolean {
this.setSnapping(!this.snapping);
return this.snapping;
}
isSnapping(): boolean {
return this.snapping;
}
private applySnapping() {
if (this.control == undefined) return;
this.control.setTranslationSnap(this.snapping ? TRANSLATION_SNAP : null);
this.control.setRotationSnap(this.snapping ? ROTATION_SNAP : null);
this.control.setScaleSnap(this.snapping ? SCALE_SNAP : null);
}
// Flip the store's transform-space preference and push the new value
// into the live gizmo. No-op when the gizmo is in scale mode (scale
// is always local).
@@ -0,0 +1,216 @@
import * as THREE from "three";
import type Editor from "../editor";
import { TransformAction } from "./actions/TransformAction";
import { usePageSceneStore } from "../../PageSceneStore";
// Blender-style modal transform. Press the grab key (G in the Blender preset) to
// start moving the selection with the mouse — no gizmo handle needed. While
// active:
// • move the mouse to drag on a camera-facing plane,
// • press X / Y / Z to constrain to that world axis (Shift+axis to EXCLUDE it),
// • left-click or Enter to confirm, Esc or right-click to cancel.
//
// v1 supports translate. The controller owns capture-phase document listeners
// while active and flips `modalTransformActive` in the store so free-cam and the
// normal keymap stand down. Confirm records a single TransformAction (undoable);
// cancel restores the start position and records nothing.
export class ModalTransformController {
private readonly editor: Editor;
private readonly raycaster = new THREE.Raycaster();
private readonly plane = new THREE.Plane();
private active = false;
private target: THREE.Object3D | null = null;
private action: TransformAction | null = null;
private readonly startPos = new THREE.Vector3();
private readonly startPoint = new THREE.Vector3();
private readonly lastPoint = new THREE.Vector3();
private hasStart = false;
private axis: "x" | "y" | "z" | null = null;
private exclude = false;
constructor(editor: Editor) {
this.editor = editor;
}
isActive(): boolean {
return this.active;
}
begin(_mode: "translate") {
if (this.active) return;
const target = this.editor.sceneManager?.selected_objects?.[0];
const camera = this.editor.cameraController.camera;
const dom = this.editor.renderer?.domElement;
if (!target || !camera || !dom) return;
this.target = target;
this.startPos.copy(target.position);
this.action = new TransformAction(this.editor, target.uuid);
// Drag plane: faces the camera, passes through the object.
const normal = new THREE.Vector3();
camera.getWorldDirection(normal);
this.plane.setFromNormalAndCoplanarPoint(normal, target.position);
this.axis = null;
this.exclude = false;
this.hasStart = false;
this.active = true;
this.editor.gizmo.detach();
usePageSceneStore.getState().setModalTransformActive(true);
document.addEventListener("pointermove", this.onPointerMove);
document.addEventListener("pointerdown", this.onPointerDown, true);
document.addEventListener("keydown", this.onKeyDown, true);
document.addEventListener("contextmenu", this.onContextMenu, true);
}
// ── pointer ────────────────────────────────────────────────────────────────
private onPointerMove = (e: PointerEvent) => {
const camera = this.editor.cameraController.camera;
if (!this.active || !this.target || !camera) return;
const point = this.projectCursor(e.clientX, e.clientY, camera);
if (!point) return;
this.lastPoint.copy(point);
// First move after begin establishes the reference point (delta = 0).
if (!this.hasStart) {
this.startPoint.copy(point);
this.hasStart = true;
return;
}
this.apply();
};
private onPointerDown = (e: PointerEvent) => {
if (!this.active) return;
e.preventDefault();
e.stopPropagation();
if (e.button === 2) this.cancel();
else this.commit();
};
private onContextMenu = (e: Event) => {
if (!this.active) return;
e.preventDefault();
e.stopPropagation();
};
// ── keyboard ─────────────────────────────────────────────────────────────
private onKeyDown = (e: KeyboardEvent) => {
if (!this.active) return;
switch (e.code) {
case "KeyX":
case "KeyY":
case "KeyZ": {
const axis = e.code === "KeyX" ? "x" : e.code === "KeyY" ? "y" : "z";
// Pressing the same axis again with no shift toggles the constraint off.
if (this.axis === axis && this.exclude === e.shiftKey) {
this.axis = null;
this.exclude = false;
} else {
this.axis = axis;
this.exclude = e.shiftKey;
}
e.preventDefault();
e.stopPropagation();
this.apply();
break;
}
case "Enter":
case "NumpadEnter":
e.preventDefault();
e.stopPropagation();
this.commit();
break;
case "Escape":
e.preventDefault();
e.stopPropagation();
this.cancel();
break;
}
};
// ── math ───────────────────────────────────────────────────────────────────
private apply() {
if (!this.target || !this.hasStart) return;
const delta = this.lastPoint.clone().sub(this.startPoint);
if (this.axis) {
const axisVec = this.axisVector(this.axis);
const along = delta.dot(axisVec);
if (this.exclude) {
// Lock to the plane of the other two axes: drop the axis component.
delta.addScaledVector(axisVec, -along);
} else {
// Constrain to the single axis.
delta.copy(axisVec).multiplyScalar(along);
}
}
this.target.position.copy(this.startPos).add(delta);
this.editor.renderScene();
}
private axisVector(axis: "x" | "y" | "z"): THREE.Vector3 {
if (axis === "x") return new THREE.Vector3(1, 0, 0);
if (axis === "y") return new THREE.Vector3(0, 1, 0);
return new THREE.Vector3(0, 0, 1);
}
private projectCursor(
clientX: number,
clientY: number,
camera: THREE.PerspectiveCamera,
): THREE.Vector3 | null {
const dom = this.editor.renderer?.domElement;
if (!dom) return null;
const rect = dom.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return null;
const ndc = new THREE.Vector2(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
);
this.raycaster.setFromCamera(ndc, camera);
const hit = new THREE.Vector3();
return this.raycaster.ray.intersectPlane(this.plane, hit) ? hit : null;
}
// ── lifecycle ────────────────────────────────────────────────────────────
private commit() {
if (this.action && this.action.commit()) {
this.editor.history.record(this.action);
}
this.editor.selection.updateSelectedUI();
this.cleanup();
}
private cancel() {
if (this.target) this.target.position.copy(this.startPos);
this.editor.renderScene();
this.cleanup();
}
private cleanup() {
this.active = false;
document.removeEventListener("pointermove", this.onPointerMove);
document.removeEventListener("pointerdown", this.onPointerDown, true);
document.removeEventListener("keydown", this.onKeyDown, true);
document.removeEventListener("contextmenu", this.onContextMenu, true);
usePageSceneStore.getState().setModalTransformActive(false);
// Re-attach the gizmo to the (still-selected) object.
const sel = this.editor.sceneManager?.selected_objects?.[0];
this.editor.gizmo.addToScene(this.editor.activeScene.scene);
if (sel) this.editor.gizmo.attach(sel);
this.target = null;
this.action = null;
this.hasStart = false;
this.axis = null;
this.exclude = false;
}
}
@@ -9,7 +9,7 @@
import * as THREE from "three";
import type Editor from "../editor";
import { snapshotTransform, writeTransform } from "./actions/snapshots";
import { SaveTimelineAction } from "./actions/SaveTimelineAction";
import { TimelineEditAction } from "./actions/TimelineEditAction";
import { sampleTrackAt } from "../timeline/interpolation";
import {
cloneTimeline,
@@ -29,6 +29,14 @@ import {
TimelinePlayheadEvent,
} from "../events/EngineEvent";
// Whole-timeline state pair for undo/redo: the live timeline plus the
// last-saved baseline (which Cancel reverts to). TimelineEditAction restores
// both, so undoing past a Save also restores the older baseline.
export interface TimelineUndoSnapshot {
live: TimelineData | null;
saved: TimelineData | null;
}
export class TimelineController {
private timeline: TimelineData | null = null;
private saved: TimelineData | null = null;
@@ -37,6 +45,48 @@ export class TimelineController {
constructor(private readonly editor: Editor) {}
// ─── per-edit undo ────────────────────────────────────────────────────
//
// Every user-facing timeline edit is one undo step (TimelineEditAction).
// Discrete edits wrap in recordEdit(); continuous gestures capture
// snapshotForUndo() at gesture start and call recordEditSince() at the
// end so the whole drag is a single step. Engine-internal mutations
// (autoKeyIfTracked riding a gizmo transform) bypass recording on purpose.
snapshotForUndo(): TimelineUndoSnapshot {
return {
live: this.timeline ? cloneTimeline(this.timeline) : null,
saved: this.saved ? cloneTimeline(this.saved) : null,
};
}
restoreSnapshot(snap: TimelineUndoSnapshot): void {
this.timeline = snap.live ? cloneTimeline(snap.live) : null;
this.saved = snap.saved ? cloneTimeline(snap.saved) : null;
if (this.timeline) {
this.playhead = Math.min(this.playhead, this.timeline.duration);
}
this.syncClipLanes();
this.evaluate();
this.emitChanged();
this.emitPlayhead();
}
recordEdit<T>(label: string, mutate: () => T): T {
const before = this.snapshotForUndo();
const result = mutate();
this.recordEditSince(label, before);
return result;
}
recordEditSince(label: string, before: TimelineUndoSnapshot): void {
const after = this.snapshotForUndo();
if (JSON.stringify(before) === JSON.stringify(after)) return;
this.editor.history.record(
new TimelineEditAction(this, label, before, after),
);
}
// ─── queries ──────────────────────────────────────────────────────────
exists(): boolean {
@@ -452,11 +502,10 @@ export class TimelineController {
this.emitChanged();
}
// Removal is SESSION-INDEPENDENT (it carries its own undo step via
// RemoveClipLaneAction), so it is mirrored into the `saved` snapshot too:
// otherwise Cancel would resurrect the deleted strip and the next Save
// would double-record the removal (its before-state still holding the
// strip costs the user a phantom undo step).
// Removal is SESSION-INDEPENDENT (its own undo step, recorded by the
// actions/timeline.ts wrapper), so it is mirrored into the `saved`
// snapshot too: otherwise Cancel would resurrect the deleted strip. The
// undo snapshot captures both worlds, so undo restores them exactly.
removeClipLane(laneId: string): void {
if (!this.timeline) return;
this.timeline.clipLanes = this.timeline.clipLanes.filter(
@@ -476,38 +525,6 @@ export class TimelineController {
return this.timeline?.clipLanes.find((l) => l.id === laneId) ?? null;
}
// The lane's entry in the `saved` snapshot (which may hold an older
// placement than the live one, or none if the strip was added after the
// last save). RemoveClipLaneAction captures it so undo can restore BOTH
// worlds exactly.
getSavedClipLane(laneId: string): ClipLane | null {
const lane = this.saved?.clipLanes.find((l) => l.id === laneId);
return lane ? JSON.parse(JSON.stringify(lane)) : null;
}
// Re-insert a previously removed lane verbatim (undo of a removal). The
// strip returns to its exact prior slot; in the rare case another clip was
// moved onto that slot after the delete, the strips can momentarily
// overlap — the overlap guard re-applies on the next add/move.
// `savedLane` (when the strip existed in the last-saved snapshot) undoes
// the removal's `saved` mirroring at its saved-time placement, so a later
// Cancel restores exactly the pre-removal saved state.
restoreClipLane(lane: ClipLane, savedLane?: ClipLane): void {
if (!this.timeline) return;
if (this.timeline.clipLanes.some((l) => l.id === lane.id)) return;
this.timeline.clipLanes.push(JSON.parse(JSON.stringify(lane)));
if (
savedLane &&
this.saved &&
!this.saved.clipLanes.some((l) => l.id === savedLane.id)
) {
this.saved.clipLanes.push(JSON.parse(JSON.stringify(savedLane)));
}
this.syncClipLanes();
this.evaluate();
this.emitChanged();
}
// All clip lanes for one character (stacked lanes, drop order).
clipLanesFor(objectUuid: string): ClipLane[] {
if (!this.timeline) return [];
@@ -515,21 +532,26 @@ export class TimelineController {
}
// ─── save / cancel ──────────────────────────────────────────────────────
//
// Individual edits carry their own undo steps (see recordEdit), so Save
// only moves the Cancel baseline — its undo restores the OLD baseline
// without jumping the live timeline (Ctrl+Z right after Save still undoes
// the last edit, not the whole session). Cancel is itself one undo step,
// so a mis-click can be undone.
save(): void {
if (!this.timeline) return;
const before = this.saved
? cloneTimeline(this.saved)
: cloneTimeline(this.timeline);
const action = new SaveTimelineAction(this, before);
this.saved = cloneTimeline(this.timeline);
if (action.commit()) this.editor.history.record(action);
this.recordEdit("Save Timeline", () => {
this.saved = cloneTimeline(this.timeline!);
});
this.emitChanged();
}
cancel(): void {
if (!this.saved) return;
this.timeline = cloneTimeline(this.saved);
this.recordEdit("Cancel Timeline Edits", () => {
this.timeline = cloneTimeline(this.saved!);
});
this.syncClipLanes();
this.evaluate();
this.emitChanged();
@@ -1,35 +0,0 @@
import type { UndoableAction } from "../HistoryManager";
import type { TimelineController } from "../TimelineController";
import type { ClipLane } from "../../timeline/types";
// Removing a clip strip (the strip's × or Del/Backspace) gets its own undo
// step — unlike other clip edits, which ride the timeline Save/Cancel
// session, a deletion is destructive enough to deserve immediate undo. To
// keep that session-independence honest, removeClipLane also strips the
// lane from the controller's `saved` snapshot (else Cancel would resurrect
// it and the next Save would double-record the removal); this action
// therefore captures BOTH the live lane and its saved-snapshot entry (which
// may differ in placement, or be absent for a never-saved strip) so undo
// restores both worlds exactly. Snapshots are deep-cloned at construction
// so later mutations can't corrupt them.
export class RemoveClipLaneAction implements UndoableAction {
readonly label = "Remove Animation Clip";
private readonly lane: ClipLane;
private readonly savedLane: ClipLane | null;
constructor(
private readonly controller: TimelineController,
lane: ClipLane,
) {
this.lane = JSON.parse(JSON.stringify(lane));
this.savedLane = controller.getSavedClipLane(lane.id);
}
apply(): void {
this.controller.removeClipLane(this.lane.id);
}
revert(): void {
this.controller.restoreClipLane(this.lane, this.savedLane ?? undefined);
}
}
@@ -1,32 +0,0 @@
import type { UndoableAction } from "../HistoryManager";
import type { TimelineController } from "../TimelineController";
import { cloneTimeline, type TimelineData } from "../../timeline/types";
// Records a timeline "Save": the whole timeline before vs. after the save.
// Mirrors TransformAction's before/commit/after shape. The controller
// constructs this with the pre-save (last-saved) timeline, calls commit()
// to capture the post-save timeline, and records it only if they differ.
export class SaveTimelineAction implements UndoableAction {
readonly label = "Save Timeline";
private after?: TimelineData;
constructor(
private readonly controller: TimelineController,
private readonly before: TimelineData,
) {}
commit(): boolean {
const current = this.controller.getTimeline();
if (!current) return false;
this.after = cloneTimeline(current);
return JSON.stringify(this.before) !== JSON.stringify(this.after);
}
apply(): void {
if (this.after) this.controller.loadTimeline(this.after);
}
revert(): void {
this.controller.loadTimeline(this.before);
}
}
@@ -0,0 +1,31 @@
import type { UndoableAction } from "../HistoryManager";
import type {
TimelineController,
TimelineUndoSnapshot,
} from "../TimelineController";
// One timeline edit as an undo step. Every mutation of the timeline —
// keyframe add/move/delete, clip placement, easing, duration, even Save and
// Cancel — records one of these with whole-timeline {live, saved} snapshots
// captured before and after. Undo/redo restore the pair wholesale, so any
// interleaving of edits, saves, and cancels replays coherently with no
// per-kind bookkeeping. Timelines are small (this codebase already clones
// one on every change event), which is what makes snapshotting affordable.
// Continuous gestures (drags, the easing popover) capture `before` at
// gesture start and record on commit, so a whole drag is a single step.
export class TimelineEditAction implements UndoableAction {
constructor(
private readonly controller: TimelineController,
readonly label: string,
private readonly before: TimelineUndoSnapshot,
private readonly after: TimelineUndoSnapshot,
) {}
apply(): void {
this.controller.restoreSnapshot(this.after);
}
revert(): void {
this.controller.restoreSnapshot(this.before);
}
}
@@ -12,48 +12,13 @@ import { FKHelper } from "./KinHelpers/FKHelper";
import { Euler } from "three";
import type { EngineEventBus } from "./events/EngineEventBus";
import {
AssetModalVisibilityChangedEvent,
InspectorPanelChangedEvent,
OutlinerSelectedItemChangedEvent,
PoseControlsVisibilityChangedEvent,
PoseModeChangedEvent,
SelectedModeChangedEvent,
TransformSpaceChangedEvent,
} from "./events/EngineEvent";
import type { PoseMode, TransformSpace } from "../PageSceneStore";
const EDITABLE_INPUT_TYPES = new Set([
"text",
"search",
"email",
"password",
"number",
"url",
"tel",
]);
const isEventFromEditableElement = (event: KeyboardEvent): boolean => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return false;
}
if (target instanceof HTMLInputElement) {
if (target.disabled || target.readOnly) {
return false;
}
const type = target.type?.toLowerCase() ?? "";
return type === "" || EDITABLE_INPUT_TYPES.has(type);
}
if (target instanceof HTMLTextAreaElement) {
return !(target.disabled || target.readOnly);
}
return target.isContentEditable;
};
export enum KinMode {
FK,
IK,
@@ -345,150 +310,10 @@ export class MouseControls {
return;
}
async onkeydown(event: KeyboardEvent) {
if (isEventFromEditableElement(event)) {
return;
}
if (this.deps.isHotkeyDisabled()) {
return;
} else if (event.key === "f" && this.selected && this.lockControls) {
this.focus();
return;
} else if (event.key === "Backspace" || event.key === "Delete") {
if (this.selected) {
this.selected.forEach((selected) => {
this.deleteObject(selected.uuid);
this.selected = [];
if (this.kinMode === KinMode.FK) {
this.toggleFKMode();
}
this.removeTransformControls();
this.deps.bus.emit(new PoseControlsVisibilityChangedEvent(false));
});
}
return;
} else if (event.key === "t") {
// transform
this.control?.setMode("translate");
const ts = this.deps.getTransformSpace();
if (this.control) this.control.space = ts;
this.deps.bus.emit(new SelectedModeChangedEvent("move"));
return;
} else if (event.key === "x") {
// toggle world/local space (blocked in scale mode)
if (this.control?.mode === "scale") return;
const next: TransformSpace =
this.deps.getTransformSpace() === "world" ? "local" : "world";
this.deps.bus.emit(new TransformSpaceChangedEvent(next));
if (this.control) this.control.space = next;
return;
} else if (event.key === "r" && !event.ctrlKey) {
// rotate
this.control?.setMode("rotate");
if (this.control) this.control.space = this.deps.getTransformSpace();
this.deps.bus.emit(new SelectedModeChangedEvent("rotate"));
return;
} else if (event.key === "g") {
// scale
this.control?.setMode("scale");
if (this.control) this.control.space = this.deps.getTransformSpace();
this.deps.bus.emit(new SelectedModeChangedEvent("scale"));
return;
} else if (event.key === "k") {
this.toggleFKMode();
return;
} else if (event.key === "b") {
// Open asset modal
this.deps.bus.emit(new AssetModalVisibilityChangedEvent(true, true));
return;
}
if ((event.ctrlKey || event.metaKey) && !this.isProcessing) {
const keyLower = event.key.toLowerCase();
if (keyLower === "z" && !event.shiftKey) {
// undo
event.preventDefault();
this.isProcessing = true;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (this.sceneManager as any)?.undo();
} finally {
this.isProcessing = false;
}
} else if (keyLower === "z" && event.shiftKey) {
// redo (Ctrl+Shift+Z / Cmd+Shift+Z)
event.preventDefault();
this.isProcessing = true;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (this.sceneManager as any)?.redo();
} finally {
this.isProcessing = false;
}
} else if (keyLower === "y") {
// redo (Ctrl+Y on Windows/Linux, Cmd+Y on macOS)
event.preventDefault();
this.isProcessing = true;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (this.sceneManager as any)?.redo();
} finally {
this.isProcessing = false;
}
} else if (keyLower === "c") {
// Copy
event.preventDefault();
event.stopPropagation();
this.isProcessing = true;
try {
await this.sceneManager?.copy();
} finally {
this.isProcessing = false;
}
} else if (keyLower === "v") {
// Paste
event.preventDefault();
event.stopPropagation();
this.isProcessing = true;
try {
await this.sceneManager?.paste();
} finally {
this.isProcessing = false;
}
}
}
// Prevent browser shortcuts for Alt combinations
if (
event.altKey &&
(event.key === "Alt" || event.key.toLowerCase() === "d")
) {
event.preventDefault();
event.stopPropagation();
}
if (this.cameraViewControls) {
if (event.shiftKey) {
this.cameraViewControls.movementSpeed = 3;
} else if (event.altKey) {
this.cameraViewControls.movementSpeed = 0.1;
} else {
this.cameraViewControls.movementSpeed = 0.75;
}
}
if (event.key === "Escape") {
if (this.deps.getPoseMode() === "pose") {
this.toggleFKMode();
return;
} else if (this.selected && this.selected.length > 0) {
this.removeTransformControls();
this.deps.bus.emit(new InspectorPanelChangedEvent(null));
this.deps.bus.emit(new PoseControlsVisibilityChangedEvent(false));
}
}
}
// NOTE: keyboard shortcuts are NOT handled here. The legacy onkeydown
// method (never attached to any listener) was removed — every viewport
// shortcut is dispatched by useViewportKeyboard against the
// @storyteller/keybinds registry via engine/keymap.ts.
handleMousePointerLock() {
if (this.isMouseClicked && this.lockControls) {
@@ -1,3 +1,11 @@
import {
ACTIONS,
useKeybindsStore,
type ActionId,
type Binding,
type KeybindContext,
type KeyGroup as KeybindsKeyGroup,
} from "@storyteller/keybinds";
import type Editor from "./editor";
import { CreateAction } from "./editor/actions/CreateAction";
import {
@@ -6,13 +14,26 @@ import {
SelectedModeChangedEvent,
TransformModeChangedEvent,
} from "./events/EngineEvent";
import type { PoseMode } from "../PageSceneStore";
import { usePageSceneStore, type PoseMode } from "../PageSceneStore";
import {
addKeyframe,
deleteKeyframe,
nudgeClipLane,
nudgeKeyframe,
pauseTimeline,
playTimeline,
removeClipLane,
seekTimeline,
} from "../actions/timeline";
import { captureStill, recordVideo } from "../actions/recordOutput";
// One declarative table for every viewport keyboard shortcut.
// useViewportKeyboard dispatches against this list; a future Ctrl-hold
// cheatsheet overlay can render the same data without duplication.
// One declarative table mapping viewport actions to their handlers. The actual
// key bindings now come from the unified @storyteller/keybinds registry (preset
// + per-user overrides), so this file owns *what each action does*, not *which
// key triggers it*. useViewportKeyboard dispatches against the resolved list;
// the cheatsheet overlay renders the same registry.
export type KeyGroup = "Transform" | "Selection" | "Edit" | "View";
export type KeyGroup = KeybindsKeyGroup;
export interface KeyBinding {
code: string; // matches event.code (e.g. "KeyT", "Backspace")
@@ -23,6 +44,9 @@ export interface KeyBinding {
// Whether the binding should preventDefault + stopPropagation when
// matched. Used for browser shortcut conflicts (Ctrl+Z, Ctrl+C, etc).
preventDefault?: boolean;
// Availability gate from the registry ActionDef. Absent = the default
// rule (available unless an encode is running).
when?: (ctx: KeybindContext) => boolean;
}
// The TransformControls gizmo uses "translate" / "rotate" / "scale";
@@ -68,6 +92,14 @@ const onEscape = (editor: Editor) => {
}
};
const deselectAll = (editor: Editor) => {
const mc = editor.mouse_controls;
if (!mc?.selected?.length) return;
mc.removeTransformControls();
mc.selected = [];
editor.bus.emit(new PoseControlsVisibilityChangedEvent(false));
};
const focusSelected = (editor: Editor) => {
if (
editor.mouse_controls?.selected?.length &&
@@ -108,47 +140,157 @@ const toggleStats = (editor: Editor) => {
editor.toggle_stats();
};
export const buildKeymap = (): KeyBinding[] => [
// Transform
{ code: "KeyT", label: "Translate", group: "Transform",
run: (e) => setGizmoMode(e, "translate", "move") },
{ code: "KeyR", label: "Rotate", group: "Transform",
run: (e) => setGizmoMode(e, "rotate", "rotate") },
{ code: "KeyG", label: "Scale", group: "Transform",
run: (e) => setGizmoMode(e, "scale", "scale") },
{ code: "KeyX", label: "Toggle local/world", group: "Transform",
run: (e) => e.gizmo.toggleTransformSpace() },
{ code: "KeyK", label: "Toggle pose (FK)", group: "Transform",
run: (e) => e.mouse_controls?.toggleFKMode() },
// ── timeline / record handlers ───────────────────────────────────────────────
// Availability (which contexts these fire in) lives on the registry defs;
// handlers only implement the action itself.
// Selection / view
{ code: "KeyF", label: "Focus selection", group: "View",
run: focusSelected },
{ code: "KeyB", label: "Open asset menu", group: "View",
run: openAssetModal },
{ code: "Space", label: "Toggle camera view", group: "View",
run: toggleCameraView, preventDefault: true },
{ code: "Escape", label: "Clear selection / exit pose", group: "Selection",
run: onEscape },
{ code: "Backquote", label: "Toggle perf stats", group: "View",
run: toggleStats },
const timelinePlayPause = (editor: Editor) => {
if (usePageSceneStore.getState().timelineIsPlaying) pauseTimeline(editor);
else playTimeline(editor);
};
// Edit
{ code: "Backspace", label: "Delete selected", group: "Edit",
run: deleteSelected },
{ code: "Delete", label: "Delete selected", group: "Edit",
run: deleteSelected },
{ code: "KeyZ", modifiers: { ctrl: true }, label: "Undo", group: "Edit",
run: undo, preventDefault: true },
{ code: "KeyZ", modifiers: { ctrl: true, shift: true }, label: "Redo",
group: "Edit", run: redo, preventDefault: true },
{ code: "KeyY", modifiers: { ctrl: true }, label: "Redo", group: "Edit",
run: redo, preventDefault: true },
{ code: "KeyC", modifiers: { ctrl: true }, label: "Copy", group: "Edit",
run: copy, preventDefault: true },
{ code: "KeyV", modifiers: { ctrl: true }, label: "Paste", group: "Edit",
run: paste, preventDefault: true },
];
const timelineStep = (editor: Editor, direction: 1 | -1) => {
const timeline = editor.timelineController.getTimeline();
if (!timeline) return;
const fps = timeline.fps || 30;
const next =
editor.timelineController.getPlayhead() + direction / fps;
seekTimeline(editor, Math.min(timeline.duration, Math.max(0, next)));
};
// Move the selected keyframe (or clip strip) one frame in time. The registry
// gates this on a valid selection; the id lookups below are just resolution,
// not availability checks.
const timelineNudge = (editor: Editor, direction: 1 | -1) => {
const store = usePageSceneStore.getState();
const timeline = editor.timelineController.getTimeline();
if (!timeline) return;
const delta = direction / (timeline.fps || 30);
const keyframeId = store.timelineSelectedKeyframeId;
if (keyframeId) {
for (const track of timeline.tracks) {
const keyframe = track.keyframes.find((k) => k.id === keyframeId);
if (keyframe) {
const time = Math.min(
timeline.duration,
Math.max(0, keyframe.time + delta),
);
nudgeKeyframe(editor, keyframeId, time);
return;
}
}
return;
}
const laneId = store.timelineSelectedClipLaneId;
if (!laneId) return;
const lane = editor.timelineController.getClipLane(laneId);
if (!lane) return;
nudgeClipLane(editor, laneId, Math.max(0, lane.strip.startTime + delta));
};
const timelineAddKeyframe = (editor: Editor) => {
const uuid = editor.mouse_controls?.selected?.[0]?.uuid;
if (!uuid) return;
addKeyframe(editor, uuid);
};
// Registry-gated complement of deleteSelected (scene object): fires only for
// a VALID keyframe/strip selection, so a stale id can never eat the key. The
// selection is re-resolved here in case it changed since the ctx snapshot.
const timelineDeleteSelected = (editor: Editor) => {
const store = usePageSceneStore.getState();
const keyframeId = store.timelineSelectedKeyframeId;
if (keyframeId) {
const exists = editor.timelineController
.getTimeline()
?.tracks.some((t) => t.keyframes.some((k) => k.id === keyframeId));
if (exists) deleteKeyframe(editor, keyframeId);
store.setTimelineSelectedKeyframe(null);
return;
}
const laneId = store.timelineSelectedClipLaneId;
if (!laneId) return;
if (editor.timelineController.getClipLane(laneId)) {
removeClipLane(editor, laneId);
}
store.setTimelineSelectedClipLane(null);
};
// Action id → handler. Bindings live in the keybinds registry; this maps each
// registered PageScene action to what it actually does.
const HANDLERS: Record<ActionId, (editor: Editor) => void | Promise<void>> = {
"pagescene.transform.grab": (e) => e.beginModalTransform("translate"),
"pagescene.transform.translate": (e) => setGizmoMode(e, "translate", "move"),
"pagescene.transform.rotate": (e) => setGizmoMode(e, "rotate", "rotate"),
"pagescene.transform.scale": (e) => setGizmoMode(e, "scale", "scale"),
"pagescene.transform.toggleSpace": (e) => e.gizmo.toggleTransformSpace(),
"pagescene.transform.poseFK": (e) => e.mouse_controls?.toggleFKMode(),
"pagescene.view.focus": focusSelected,
"pagescene.view.assetMenu": openAssetModal,
"pagescene.view.toggleCameraView": toggleCameraView,
"pagescene.view.toggleStats": toggleStats,
"pagescene.selection.clearOrExit": onEscape,
"pagescene.selection.deselectAll": deselectAll,
"pagescene.edit.delete": deleteSelected,
"pagescene.edit.duplicate": (e) => e.duplicateSelected(),
"pagescene.edit.toggleSnapping": (e) => e.toggleSnapping(),
"pagescene.view.toggleGrid": (e) => e.toggleGrid(),
"pagescene.edit.undo": undo,
"pagescene.edit.redo": redo,
"pagescene.edit.copy": copy,
"pagescene.edit.paste": paste,
"pagescene.timeline.playPause": timelinePlayPause,
"pagescene.timeline.stepBack": (e) => timelineStep(e, -1),
"pagescene.timeline.stepForward": (e) => timelineStep(e, 1),
"pagescene.timeline.nudgeLeft": (e) => timelineNudge(e, -1),
"pagescene.timeline.nudgeRight": (e) => timelineNudge(e, 1),
"pagescene.timeline.goToStart": (e) => seekTimeline(e, 0),
"pagescene.timeline.goToEnd": (e) => {
const timeline = e.timelineController.getTimeline();
if (timeline) seekTimeline(e, timeline.duration);
},
"pagescene.timeline.addKeyframe": timelineAddKeyframe,
"pagescene.timeline.deleteSelected": timelineDeleteSelected,
"pagescene.timeline.toggleExpanded": () => {
const store = usePageSceneStore.getState();
store.setTimelineExpanded(!store.timelineExpanded);
},
"pagescene.record.toggleMode": () => {
const store = usePageSceneStore.getState();
store.setSceneMode(store.sceneMode === "record" ? "build" : "record");
},
"pagescene.record.captureStill": captureStill,
"pagescene.record.recordVideo": recordVideo,
"pagescene.record.cancelEncode": () =>
usePageSceneStore.getState().requestEncodeCancel(),
};
// Expand the handler table into concrete KeyBindings using the resolved bindings
// for each action (one KeyBinding per bound key — so Delete+Backspace and the
// two Redo combos each stay live). `forAction` defaults to the store so non-React
// callers keep working; useViewportKeyboard passes a reactive resolver.
export const buildKeymap = (
forAction: (id: ActionId) => Binding[] = (id) =>
useKeybindsStore.getState().resolveBindings(id),
): KeyBinding[] => {
const out: KeyBinding[] = [];
for (const [id, run] of Object.entries(HANDLERS)) {
const def = ACTIONS[id];
if (!def) continue;
for (const binding of forAction(id)) {
out.push({
code: binding.code,
modifiers: { ctrl: binding.ctrl, shift: binding.shift, alt: binding.alt },
label: def.label,
group: def.group,
run,
preventDefault: def.preventDefault,
when: def.when,
});
}
}
return out;
};
const matches = (binding: KeyBinding, e: KeyboardEvent): boolean => {
if (binding.code !== e.code) return false;
@@ -161,13 +303,21 @@ const matches = (binding: KeyBinding, e: KeyboardEvent): boolean => {
return true;
};
// Availability: an action's `when` predicate has full control; without one,
// the default rule is "available unless an encode is running". An unavailable
// binding is skipped WITHOUT consuming the event, so the same key can serve
// different actions in different contexts (e.g. Space = camera toggle in
// build, playback in the timeline) and unrelated listeners still see the key.
export const dispatchBinding = (
bindings: KeyBinding[],
event: KeyboardEvent,
editor: Editor,
ctx: KeybindContext = {},
): boolean => {
for (const binding of bindings) {
if (!matches(binding, event)) continue;
const available = binding.when ? binding.when(ctx) : !ctx.encoding;
if (!available) continue;
if (binding.preventDefault) {
event.preventDefault();
event.stopPropagation();
@@ -1,6 +1,9 @@
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import { MathUtils } from "three";
import { useResolvedKeybinds } from "@storyteller/keybinds";
import {
CAMERA_MOVE_ACTION_SLOTS,
CAMERA_ROTATE_ACTION_SLOTS,
createFreeCamControlState,
emptyMoveKeys,
emptyRotateKeys,
@@ -9,6 +12,8 @@ import {
rotateSlotForKeyCode,
zoomFromWheel,
type FreeCamControlState,
type HeldMoveKeys,
type HeldRotateKeys,
} from "../engine/cameraMath";
import { usePageSceneStore } from "../PageSceneStore";
import type Editor from "../engine/editor";
@@ -61,13 +66,6 @@ const PRECISE_MOVEMENT_SPEED = BASE_MOVEMENT_SPEED / 10;
const isAltKey = (code: string) =>
code === "AltLeft" || code === "AltRight";
// WASD / QE movement codes. We only need this set for the Alt-modifier
// preventDefault path; non-modified WASD doesn't collide with browser
// shortcuts and shouldn't be intercepted.
const MOVE_KEY_CODES = new Set([
"KeyW", "KeyA", "KeyS", "KeyD", "KeyQ", "KeyE",
]);
// Pick the camera speed based on the modifier state on this event.
// Precise wins over boost — if both are somehow held the user is
// almost certainly reaching for fine control, and "go max-fast while
@@ -87,6 +85,23 @@ export const useFreeCam = (
null,
);
// Resolve camera bindings into `code → slot` lookups. Recomputed (new identity)
// only when the preset/overrides change, which re-attaches the key listeners.
const { slotBindings } = useResolvedKeybinds();
const { moveMap, rotateMap, moveCodes } = useMemo(() => {
const resolved = slotBindings("pagescene");
const moveMap: Record<string, keyof HeldMoveKeys> = {};
for (const [id, slot] of Object.entries(CAMERA_MOVE_ACTION_SLOTS)) {
for (const b of resolved[id] ?? []) moveMap[b.code] = slot;
}
const rotateMap: Record<string, keyof HeldRotateKeys> = {};
for (const [id, slot] of Object.entries(CAMERA_ROTATE_ACTION_SLOTS)) {
for (const b of resolved[id] ?? []) rotateMap[b.code] = slot;
}
// Movement codes only matter for the Alt-modifier preventDefault path.
return { moveMap, rotateMap, moveCodes: new Set(Object.keys(moveMap)) };
}, [slotBindings]);
// Hand the state to the editor so its render loop can integrate it.
useEffect(() => {
if (!editor) return undefined;
@@ -104,7 +119,11 @@ export const useFreeCam = (
const onKeyDown = (e: KeyboardEvent) => {
if (usePageSceneStore.getState().isPromptBoxFocused) return;
if (usePageSceneStore.getState().modalTransformActive) return;
if (isEventFromEditableElement(e)) return;
// Ctrl/Cmd chords are commands (undo, duplicate, copy…), not camera
// movement — don't let e.g. Ctrl+D both duplicate and strafe right.
if (e.ctrlKey || e.metaKey) return;
// Browser-shortcut conflicts for Alt-slow-mode:
// - Alt+D focuses the address bar in Chrome / Firefox / Edge.
// - Tapping Alt on its own shows the menu bar in Firefox / Edge
@@ -113,23 +132,29 @@ export const useFreeCam = (
// We block keydown for the Alt key itself and for Alt+WASD/QE so
// the precise-fly path doesn't accidentally yank focus away or
// pop the menu. Non-modified WASD is left alone.
if (isAltKey(e.code) || (e.altKey && MOVE_KEY_CODES.has(e.code))) {
if (isAltKey(e.code) || (e.altKey && moveCodes.has(e.code))) {
e.preventDefault();
}
state.movementSpeed = movementSpeedForEvent(e);
const moveSlot = moveSlotForKeyCode(e.code);
const moveSlot = moveSlotForKeyCode(e.code, moveMap);
if (moveSlot) state.moveKeys[moveSlot] = 1;
const rotateSlot = rotateSlotForKeyCode(e.code);
// Camera-look keys yield to the timeline while it's open (arrows mean
// frame stepping / nudging there) — mirrors the registry's cameraLook
// gate; keep the two in sync.
if (usePageSceneStore.getState().timelineExpanded) return;
const rotateSlot = rotateSlotForKeyCode(e.code, rotateMap);
if (rotateSlot) state.rotateKeys[rotateSlot] = 1;
};
const onKeyUp = (e: KeyboardEvent) => {
if (usePageSceneStore.getState().isPromptBoxFocused) return;
if (usePageSceneStore.getState().modalTransformActive) return;
if (isEventFromEditableElement(e)) return;
if (e.ctrlKey || e.metaKey) return;
state.movementSpeed = movementSpeedForEvent(e);
const moveSlot = moveSlotForKeyCode(e.code);
const moveSlot = moveSlotForKeyCode(e.code, moveMap);
if (moveSlot) state.moveKeys[moveSlot] = 0;
const rotateSlot = rotateSlotForKeyCode(e.code);
const rotateSlot = rotateSlotForKeyCode(e.code, rotateMap);
if (rotateSlot) state.rotateKeys[rotateSlot] = 0;
};
@@ -137,6 +162,8 @@ export const useFreeCam = (
// Record mode locks the viewport — no right-drag panning.
if (editor.cameraController.locked) return;
if (e.button !== 2) return;
// The modal transform owns the pointer (right-click cancels it).
if (usePageSceneStore.getState().modalTransformActive) return;
dragRef.current = { x: e.clientX, y: e.clientY, pointerId: e.pointerId };
state.velocity.set(0, 0, 0);
try {
@@ -221,5 +248,5 @@ export const useFreeCam = (
document.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", onBlur);
};
}, [canvas, editor]);
}, [canvas, editor, moveMap, rotateMap, moveCodes]);
};
@@ -1,4 +1,5 @@
import { useEffect, useMemo } from "react";
import { useResolvedKeybinds, type KeybindContext } from "@storyteller/keybinds";
import type Editor from "../engine/editor";
import { buildKeymap, dispatchBinding } from "../engine/keymap";
import { usePageSceneStore } from "../PageSceneStore";
@@ -41,15 +42,43 @@ const isEventFromEditableElement = (event: KeyboardEvent): boolean => {
// owned by useFreeCam since they're continuous motion, not one-shots.
export const useViewportKeyboard = (editor: Editor | null) => {
const bindings = useMemo(() => buildKeymap(), []);
// Resolve from the unified keybinds store; `forAction` identity changes when
// the preset or any override changes, rebuilding the keymap + re-binding.
const { forAction } = useResolvedKeybinds();
const bindings = useMemo(() => buildKeymap(forAction), [forAction]);
useEffect(() => {
if (!editor) return undefined;
const onKeyDown = (event: KeyboardEvent) => {
if (isEventFromEditableElement(event)) return;
if (usePageSceneStore.getState().hotkeyStatus.disabled) return;
dispatchBinding(bindings, event, editor);
const store = usePageSceneStore.getState();
if (store.hotkeyStatus.disabled) return;
// While a modal transform owns input (axis-lock keys, confirm/cancel),
// don't also fire normal viewport shortcuts.
if (store.modalTransformActive) return;
// Availability context for the registry's `when` gates. Read fresh per
// event so mode flips apply without re-binding the listener.
// `timelineSelection` must be a VALIDATED selection — a stale id (e.g.
// after Cancel / undo-of-Save replaced the timeline wholesale) reports
// false, so Delete falls through to the scene-object action.
const keyframeId = store.timelineSelectedKeyframeId;
const laneId = store.timelineSelectedClipLaneId;
const timelineSelection =
(keyframeId !== null &&
store.timelineTracks.some((t) =>
t.keyframes.some((k) => k.id === keyframeId),
)) ||
(laneId !== null &&
store.timelineClipLanes.some((l) => l.id === laneId));
const ctx: KeybindContext = {
sceneMode: store.sceneMode,
encoding: store.recordingProgress !== null,
timelineExpanded: store.timelineExpanded,
timelineSelection,
modalTransformActive: store.modalTransformActive,
};
dispatchBinding(bindings, event, editor, ctx);
};
document.addEventListener("keydown", onKeyDown);
@@ -2,6 +2,9 @@
"files": [],
"include": [],
"references": [
{
"path": "../keybinds"
},
{
"path": "../tab-selector"
},
@@ -50,6 +50,9 @@
"src/**/*.tsx"
],
"references": [
{
"path": "../keybinds/tsconfig.lib.json"
},
{
"path": "../tab-selector/tsconfig.lib.json"
},
@@ -14,6 +14,7 @@ import { DownloadDirectoryReveal } from "@storyteller/tauri-api";
import { FolderIcon, RotateCcwIcon, SearchIcon } from "lucide-react";
import { useEnterToGenerateStore } from "@storyteller/ui-promptbox";
import { useModelPickerStyleStore } from "@storyteller/ui-popover";
import { useKeybindsStore } from "@storyteller/keybinds";
import {
getAskLocationBeforeDownload,
setAskLocationBeforeDownload,
@@ -35,6 +36,9 @@ export const MiscSettingsPane = (args: MiscSettingsPaneProps) => {
const modelPickerStyle = useModelPickerStyleStore((s) => s.style);
const setModelPickerStyle = useModelPickerStyleStore((s) => s.setStyle);
const cheatsheetSticky = useKeybindsStore((s) => s.cheatsheetSticky);
const setCheatsheetSticky = useKeybindsStore((s) => s.setCheatsheetSticky);
const toggleAskLocationBeforeDownload = (enabled: boolean) => {
setAskLocationBeforeDownload(enabled);
setAskLocationBeforeDownloadState(enabled);
@@ -166,6 +170,21 @@ export const MiscSettingsPane = (args: MiscSettingsPaneProps) => {
setEnabled={(on) => setModelPickerStyle(on ? "grouped" : "flat")}
/>
</div>
<div className="flex flex-col gap-2 pt-3">
<div className="flex flex-col gap-0.5">
<Label htmlFor="cheatsheet-sticky">
Keep shortcut cheatsheet open
</Label>
<p className="text-xs opacity-70">
In the editors, holding Ctrl (⌘ on Mac) alone for a few seconds
shows a cheatsheet of the keyboard shortcuts. When on, it stays on
screen after you release the key until you press Esc or click
outside it. When off (default), it disappears as soon as you let
go.
</p>
</div>
<Switch enabled={cheatsheetSticky} setEnabled={setCheatsheetSticky} />
</div>
</div>
);
};
@@ -1,8 +1,9 @@
import { Modal } from "@storyteller/ui-modal";
import { useEffect, useState } from "react";
import { CreditCardIcon, FlaskConicalIcon, InfoIcon, KeyIcon, PaletteIcon, SettingsIcon, UserIcon, Volume2Icon } from "lucide-react";
import { CreditCardIcon, FlaskConicalIcon, InfoIcon, KeyIcon, KeyboardIcon, PaletteIcon, SettingsIcon, UserIcon, Volume2Icon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { twMerge } from "tailwind-merge";
import { KeybindsSettings } from "@storyteller/keybinds";
import { MiscSettingsPane } from "./panes/MiscSettingsPane";
import { AudioSettingsPane } from "./panes/AudioSettingsPane";
import { AccountSettingsPane } from "./panes/AccountSettings/AccountSettingsPane";
@@ -28,6 +29,7 @@ interface SettingsModalProps {
type SettingsSection =
| "general"
| "appearance"
| "keybinds"
| "accounts"
| "api_keys"
| "alerts"
@@ -77,6 +79,7 @@ export const SettingsModal = ({
icon: RouteIcon,
}, */
{ id: "appearance" as const, label: "Appearance", icon: PaletteIcon },
{ id: "keybinds" as const, label: "Keybinds", icon: KeyboardIcon },
{ id: "alerts" as const, label: "Alerts", icon: Volume2Icon },
{ id: "about" as const, label: "About", icon: InfoIcon },
//{ id: "video" as const, label: "Video", icon: VideoIcon },
@@ -90,6 +93,8 @@ export const SettingsModal = ({
switch (selectedSection) {
case "appearance":
return <AppearanceSettingsPane />;
case "keybinds":
return <KeybindsSettings />;
case "alerts":
return <AudioSettingsPane />;
case "general":
@@ -2,6 +2,9 @@
"files": [],
"include": [],
"references": [
{
"path": "../keybinds"
},
{
"path": "../../tauri-utils"
},
@@ -43,6 +43,9 @@
"src/**/*.tsx"
],
"references": [
{
"path": "../keybinds/tsconfig.lib.json"
},
{
"path": "../../tauri-utils/tsconfig.lib.json"
},
+8 -23
View File
@@ -602,6 +602,10 @@
"name": "@storyteller/ui-input",
"version": "0.0.1"
},
"libs/components/keybinds": {
"name": "@storyteller/keybinds",
"version": "0.0.1"
},
"libs/components/label": {
"name": "@storyteller/ui-label",
"version": "0.0.1"
@@ -2920,7 +2924,6 @@
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -2938,7 +2941,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -2956,7 +2958,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -2974,7 +2975,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -2992,7 +2992,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3010,7 +3009,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3028,7 +3026,6 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3046,7 +3043,6 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3064,7 +3060,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3082,7 +3077,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3100,7 +3094,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3118,7 +3111,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3136,7 +3128,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3154,7 +3145,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3172,7 +3162,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3190,7 +3179,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3208,7 +3196,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3242,7 +3229,6 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3276,7 +3262,6 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3310,7 +3295,6 @@
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3328,7 +3312,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3346,7 +3329,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3364,7 +3346,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=12"
}
@@ -10006,6 +9987,10 @@
"resolved": "libs/icons",
"link": true
},
"node_modules/@storyteller/keybinds": {
"resolved": "libs/components/keybinds",
"link": true
},
"node_modules/@storyteller/markdown-content": {
"resolved": "libs/markdown-content",
"link": true
+3
View File
@@ -213,6 +213,9 @@
{
"path": "./libs/components/moodboard"
},
{
"path": "./libs/components/keybinds"
},
{
"path": "./libs/omni-gen"
},