mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
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:
co-authored by
bflatastic
parent
915728e9c7
commit
e3f48033f9
@@ -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}
|
||||
|
||||
-77
@@ -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]);
|
||||
};
|
||||
+93
@@ -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,
|
||||
]);
|
||||
};
|
||||
-74
@@ -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]);
|
||||
};
|
||||
+3
-9
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+13
-13
@@ -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();
|
||||
|
||||
-35
@@ -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);
|
||||
}
|
||||
}
|
||||
-32
@@ -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);
|
||||
}
|
||||
}
|
||||
+31
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+8
-23
@@ -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
|
||||
|
||||
@@ -213,6 +213,9 @@
|
||||
{
|
||||
"path": "./libs/components/moodboard"
|
||||
},
|
||||
{
|
||||
"path": "./libs/components/keybinds"
|
||||
},
|
||||
{
|
||||
"path": "./libs/omni-gen"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user