diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-29 00:37:56 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-05-02 03:27:23 +0200 |
| commit | a9b9cf6bf50f08723896659634f2e6afc17bcd42 (patch) | |
| tree | 9175b49ff21f4326872ac9e541d2765cc7994ebc /apps/web/src | |
| parent | d6622dc6b3523a5658cd512b44f15f7a66fd540c (diff) | |
| download | opencut-a9b9cf6bf50f08723896659634f2e6afc17bcd42.tar.gz | |
chore: switch from biome to eslint + prettier; fix ton of lint issues
Diffstat (limited to 'apps/web/src')
145 files changed, 3092 insertions, 4475 deletions
diff --git a/apps/web/src/actions/components/shortcuts-dialog.tsx b/apps/web/src/actions/components/shortcuts-dialog.tsx index cd9c0242..7f7d8a87 100644 --- a/apps/web/src/actions/components/shortcuts-dialog.tsx +++ b/apps/web/src/actions/components/shortcuts-dialog.tsx @@ -51,10 +51,10 @@ export function ShortcutsDialog({ const keyString = getKeybindingString(e);
if (keyString) {
- const conflict = validateKeybinding(
- keyString,
- recordingShortcut.action,
- );
+ const conflict = validateKeybinding({
+ key: keyString,
+ action: recordingShortcut.action,
+ });
if (conflict) {
toast.error(
`Key "${keyString}" is already bound to "${conflict.existingAction}"`,
@@ -68,7 +68,10 @@ export function ShortcutsDialog({ removeKeybinding(key);
}
- updateKeybinding(keyString, recordingShortcut.action);
+ updateKeybinding({
+ key: keyString,
+ action: recordingShortcut.action,
+ });
setIsRecording(false);
setRecordingShortcut(null);
diff --git a/apps/web/src/actions/definitions.ts b/apps/web/src/actions/definitions.ts index 6f0ba700..986510ec 100644 --- a/apps/web/src/actions/definitions.ts +++ b/apps/web/src/actions/definitions.ts @@ -1,7 +1,4 @@ -import type { - KeybindingConfig, - ShortcutKey, -} from "@/actions/keybinding"; +import type { ShortcutKey } from "@/actions/keybinding"; import type { TActionWithOptionalArgs } from "./types"; export type TActionCategory = @@ -155,33 +152,36 @@ export const ACTIONS = { export type TAction = keyof typeof ACTIONS; -const ACTION_DEFAULT_SHORTCUTS = { - "toggle-play": ["space", "k"], - "seek-forward": ["l"], - "seek-backward": ["j"], - "frame-step-forward": ["right"], - "frame-step-backward": ["left"], - "jump-forward": ["shift+right"], - "jump-backward": ["shift+left"], - "goto-start": ["home", "enter"], - "goto-end": ["end"], - split: ["s"], - "split-left": ["q"], - "split-right": ["w"], - "delete-selected": ["backspace", "delete"], - "copy-selected": ["ctrl+c"], - "paste-copied": ["ctrl+v"], - "toggle-snapping": ["n"], - "select-all": ["ctrl+a"], - "cancel-interaction": ["escape"], - "duplicate-selected": ["ctrl+d"], - undo: ["ctrl+z"], - redo: ["ctrl+shift+z", "ctrl+y"], -} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>; +const ACTION_DEFAULT_SHORTCUTS = [ + ["toggle-play", ["space", "k"]], + ["seek-forward", ["l"]], + ["seek-backward", ["j"]], + ["frame-step-forward", ["right"]], + ["frame-step-backward", ["left"]], + ["jump-forward", ["shift+right"]], + ["jump-backward", ["shift+left"]], + ["goto-start", ["home", "enter"]], + ["goto-end", ["end"]], + ["split", ["s"]], + ["split-left", ["q"]], + ["split-right", ["w"]], + ["delete-selected", ["backspace", "delete"]], + ["copy-selected", ["ctrl+c"]], + ["paste-copied", ["ctrl+v"]], + ["toggle-snapping", ["n"]], + ["select-all", ["ctrl+a"]], + ["cancel-interaction", ["escape"]], + ["duplicate-selected", ["ctrl+d"]], + ["undo", ["ctrl+z"]], + ["redo", ["ctrl+shift+z", "ctrl+y"]], +] as const satisfies ReadonlyArray< + readonly [TActionWithOptionalArgs, readonly ShortcutKey[]] +>; -const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial< - Record<TAction, readonly ShortcutKey[]> -> = ACTION_DEFAULT_SHORTCUTS; +const ACTION_DEFAULT_SHORTCUTS_BY_ACTION = new Map< + TAction, + readonly ShortcutKey[] +>(ACTION_DEFAULT_SHORTCUTS); export function getActionDefinition({ action, @@ -190,18 +190,19 @@ export function getActionDefinition({ }): TActionDefinition { return { ...ACTIONS[action], - defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action], + defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION.get(action), }; } -export function getDefaultShortcuts(): KeybindingConfig { - const shortcuts: KeybindingConfig = {}; +export function getDefaultShortcuts(): Map< + ShortcutKey, + TActionWithOptionalArgs +> { + const shortcuts = new Map<ShortcutKey, TActionWithOptionalArgs>(); - for (const [action, defaultShortcuts] of Object.entries( - ACTION_DEFAULT_SHORTCUTS, - ) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) { + for (const [action, defaultShortcuts] of ACTION_DEFAULT_SHORTCUTS) { for (const shortcut of defaultShortcuts) { - shortcuts[shortcut] = action; + shortcuts.set(shortcut, action); } } diff --git a/apps/web/src/actions/keybinding.ts b/apps/web/src/actions/keybinding.ts index 65ef4538..aeec2fd6 100644 --- a/apps/web/src/actions/keybinding.ts +++ b/apps/web/src/actions/keybinding.ts @@ -1,79 +1,43 @@ -import type { TActionWithOptionalArgs } from "./types";
-
-/**
- * Alt is also regarded as macOS OPTION (⌥) key
- * Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!)
- */
-export type ModifierKeys =
- | "ctrl"
- | "alt"
- | "shift"
- | "ctrl+shift"
- | "alt+shift"
- | "ctrl+alt"
- | "ctrl+alt+shift";
-
-export type Key =
- | "a"
- | "b"
- | "c"
- | "d"
- | "e"
- | "f"
- | "g"
- | "h"
- | "i"
- | "j"
- | "k"
- | "l"
- | "m"
- | "n"
- | "o"
- | "p"
- | "q"
- | "r"
- | "s"
- | "t"
- | "u"
- | "v"
- | "w"
- | "x"
- | "y"
- | "z"
- | "0"
- | "1"
- | "2"
- | "3"
- | "4"
- | "5"
- | "6"
- | "7"
- | "8"
- | "9"
- | "up"
- | "down"
- | "left"
- | "right"
- | "/"
- | "?"
- | "."
- | "enter"
- | "tab"
- | "space"
- | "escape"
- | "esc"
- | "backspace"
- | "delete"
- | "home"
- | "end";
-/* eslint-enable */
-
-export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`;
-// Singular keybindings (these will be disabled when an input-ish area has been focused)
-export type SingleCharacterShortcutKey = `${Key}`;
-
-export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey;
-
-export type KeybindingConfig = {
- [key in ShortcutKey]?: TActionWithOptionalArgs;
-};
+import type { TActionWithOptionalArgs } from "./types"; + +/** + * Alt is also regarded as macOS OPTION (⌥) key + * Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!) + */ +export type ModifierKeys = + | "ctrl" + | "alt" + | "shift" + | "ctrl+shift" + | "alt+shift" + | "ctrl+alt" + | "ctrl+alt+shift"; + +const KEYS = [ + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", + "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", + "u", "v", "w", "x", "y", "z", + "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", + "up", "down", "left", "right", + "/", "?", ".", + "enter", "tab", "space", "escape", "esc", + "backspace", "delete", "home", "end", +] as const; + +export type Key = (typeof KEYS)[number]; + +const KEY_SET: ReadonlySet<string> = new Set(KEYS); + +export function isKey(value: string): value is Key { + return KEY_SET.has(value); +} + +export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`; +// Singular keybindings (these will be disabled when an input-ish area has been focused) +export type SingleCharacterShortcutKey = `${Key}`; + +export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey; + +export type KeybindingConfig = { + [key in ShortcutKey]?: TActionWithOptionalArgs; +}; diff --git a/apps/web/src/actions/keybindings-store.ts b/apps/web/src/actions/keybindings-store.ts index c0a1a5e2..8f344487 100644 --- a/apps/web/src/actions/keybindings-store.ts +++ b/apps/web/src/actions/keybindings-store.ts @@ -6,11 +6,15 @@ import type { TActionWithOptionalArgs } from "@/actions"; import { getDefaultShortcuts } from "@/actions"; import { isTypableDOMElement } from "@/utils/browser"; import { isAppleDevice } from "@/utils/platform"; -import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding"; +import type { + Key, + KeybindingConfig, + ModifierKeys, + ShortcutKey, +} from "@/actions/keybinding"; +import { isKey } from "@/actions/keybinding"; import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations"; -const defaultKeybindings: KeybindingConfig = getDefaultShortcuts(); - export interface KeybindingConflict { key: ShortcutKey; existingAction: TActionWithOptionalArgs; @@ -18,38 +22,57 @@ export interface KeybindingConflict { } interface KeybindingsState { - keybindings: KeybindingConfig; + keybindings: Map<ShortcutKey, TActionWithOptionalArgs>; isCustomized: boolean; overlayDepth: number; openOverlayIds: string[]; isLoadingProject: boolean; isRecording: boolean; - updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void; + updateKeybinding: (params: { + key: ShortcutKey; + action: TActionWithOptionalArgs; + }) => void; removeKeybinding: (key: ShortcutKey) => void; resetToDefaults: () => void; importKeybindings: (config: KeybindingConfig) => void; - exportKeybindings: () => KeybindingConfig; + exportKeybindings: () => Record<string, TActionWithOptionalArgs>; openOverlay: (overlayId: string) => void; closeOverlay: (overlayId: string) => void; setLoadingProject: (loading: boolean) => void; setIsRecording: (isRecording: boolean) => void; - validateKeybinding: ( - key: ShortcutKey, - action: TActionWithOptionalArgs, - ) => KeybindingConflict | null; + validateKeybinding: (params: { + key: ShortcutKey; + action: TActionWithOptionalArgs; + }) => KeybindingConflict | null; getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[]; getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; } +type PersistedState = { + keybindings: Record<string, TActionWithOptionalArgs>; + isCustomized: boolean; +}; + function isDOMElement(element: EventTarget | null): element is HTMLElement { return element instanceof HTMLElement; } +function isPersistedState(value: unknown): value is PersistedState { + if (!value || typeof value !== "object") return false; + if (!("keybindings" in value) || !("isCustomized" in value)) return false; + const { keybindings, isCustomized } = value; + return ( + typeof keybindings === "object" && + keybindings !== null && + typeof isCustomized === "boolean" + ); +} + export const useKeybindingsStore = create<KeybindingsState>()( persist( (set, get) => ({ - keybindings: { ...defaultKeybindings }, + keybindings: getDefaultShortcuts(), isCustomized: false, overlayDepth: 0, openOverlayIds: [], @@ -61,10 +84,9 @@ export const useKeybindingsStore = create<KeybindingsState>()( const openOverlayIds = s.openOverlayIds.includes(overlayId) ? s.openOverlayIds : [...s.openOverlayIds, overlayId]; - const nextOverlayDepth = openOverlayIds.length; return { openOverlayIds, - overlayDepth: nextOverlayDepth, + overlayDepth: openOverlayIds.length, }; }), closeOverlay: (overlayId) => @@ -72,35 +94,32 @@ export const useKeybindingsStore = create<KeybindingsState>()( const openOverlayIds = s.openOverlayIds.filter( (id) => id !== overlayId, ); - const nextOverlayDepth = openOverlayIds.length; return { openOverlayIds, - overlayDepth: nextOverlayDepth, + overlayDepth: openOverlayIds.length, }; }), setLoadingProject: (loading) => { set({ isLoadingProject: loading }); }, - updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => { + updateKeybinding: ({ key, action }) => { set((state) => { - const newKeybindings = { ...state.keybindings }; - newKeybindings[key] = action; - + const next = new Map(state.keybindings); + next.set(key, action); return { - keybindings: newKeybindings, + keybindings: next, isCustomized: true, }; }); }, - removeKeybinding: (key: ShortcutKey) => { + removeKeybinding: (key) => { set((state) => { - const newKeybindings = { ...state.keybindings }; - delete newKeybindings[key]; - + const next = new Map(state.keybindings); + next.delete(key); return { - keybindings: newKeybindings, + keybindings: next, isCustomized: true, }; }); @@ -108,34 +127,35 @@ export const useKeybindingsStore = create<KeybindingsState>()( resetToDefaults: () => { set({ - keybindings: { ...defaultKeybindings }, + keybindings: getDefaultShortcuts(), isCustomized: false, }); }, - importKeybindings: (config: KeybindingConfig) => { - for (const [key] of Object.entries(config)) { + importKeybindings: (config) => { + const next = new Map<ShortcutKey, TActionWithOptionalArgs>(); + for (const [key, action] of Object.entries(config)) { if (typeof key !== "string" || key.length === 0) { throw new Error(`Invalid key format: ${key}`); } + if (action !== undefined) { + // Public type's keys are `ShortcutKey`; trust the caller's typing. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + next.set(key as ShortcutKey, action); + } } set({ - keybindings: { ...config }, + keybindings: next, isCustomized: true, }); }, exportKeybindings: () => { - return get().keybindings; + return Object.fromEntries(get().keybindings); }, - validateKeybinding: ( - key: ShortcutKey, - action: TActionWithOptionalArgs, - ) => { - const { keybindings } = get(); - const existingAction = keybindings[key]; - + validateKeybinding: ({ key, action }) => { + const existingAction = get().keybindings.get(key); if (existingAction && existingAction !== action) { return { key, @@ -143,33 +163,45 @@ export const useKeybindingsStore = create<KeybindingsState>()( newAction: action, }; } - return null; }, - setIsRecording: (isRecording: boolean) => { + setIsRecording: (isRecording) => { set({ isRecording }); }, - getKeybindingsForAction: (action: TActionWithOptionalArgs) => { - const { keybindings } = get(); - return Object.keys(keybindings).filter( - (key) => keybindings[key as ShortcutKey] === action, - ) as ShortcutKey[]; + getKeybindingsForAction: (action) => { + const result: ShortcutKey[] = []; + for (const [key, mapped] of get().keybindings) { + if (mapped === action) result.push(key); + } + return result; }, - getKeybindingString: (ev: KeyboardEvent) => { - return generateKeybindingString(ev) as ShortcutKey | null; - }, + getKeybindingString: (ev) => generateKeybindingString(ev), }), { name: "opencut-keybindings", version: CURRENT_VERSION, - partialize: (state) => ({ - keybindings: state.keybindings, + partialize: (state): PersistedState => ({ + keybindings: Object.fromEntries(state.keybindings), isCustomized: state.isCustomized, }), migrate: (persisted, version) => runMigrations({ state: persisted, fromVersion: version }), + merge: (persisted, current) => { + if (!isPersistedState(persisted)) return current; + const entries = Object.entries(persisted.keybindings); + // Persistence boundary: keys are normalized by the migration chain. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const typedEntries = entries as Array< + [ShortcutKey, TActionWithOptionalArgs] + >; + return { + ...current, + keybindings: new Map(typedEntries), + isCustomized: persisted.isCustomized, + }; + }, }, ), ); @@ -184,68 +216,59 @@ function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null { if ( modifierKey === "shift" && isDOMElement(target) && - isTypableDOMElement({ element: target as HTMLElement }) + isTypableDOMElement({ element: target }) ) { return null; } - return `${modifierKey}+${key}` as ShortcutKey; + return `${modifierKey}+${key}`; } - if ( - isDOMElement(target) && - isTypableDOMElement({ element: target as HTMLElement }) - ) + if (isDOMElement(target) && isTypableDOMElement({ element: target })) { return null; + } - return `${key}` as ShortcutKey; + return key; } -function getPressedKey(ev: KeyboardEvent): string | null { - const key = (ev.key ?? "").toLowerCase(); +function getPressedKey(ev: KeyboardEvent): Key | null { + const raw = (ev.key ?? "").toLowerCase(); const code = ev.code ?? ""; - if (code === "Space" || key === " " || key === "spacebar" || key === "space") + if (code === "Space" || raw === " " || raw === "spacebar" || raw === "space") return "space"; - if (key.startsWith("arrow")) return key.slice(5); - - if (key === "escape") return "escape"; - if (key === "tab") return "tab"; - if (key === "home") return "home"; - if (key === "end") return "end"; - if (key === "delete") return "delete"; - if (key === "backspace") return "backspace"; + if (raw === "arrowup") return "up"; + if (raw === "arrowdown") return "down"; + if (raw === "arrowleft") return "left"; + if (raw === "arrowright") return "right"; if (code.startsWith("Key")) { const letter = code.slice(3).toLowerCase(); - if (letter.length === 1 && letter >= "a" && letter <= "z") return letter; + if (isKey(letter)) return letter; } - // Use physical key position for AZERTY and other non-QWERTY layouts + // Use physical key position for AZERTY and other non-QWERTY layouts. if (code.startsWith("Digit")) { const digit = code.slice(5); - if (digit.length === 1 && digit >= "0" && digit <= "9") return digit; + if (isKey(digit)) return digit; } - const isDigit = key.length === 1 && key >= "0" && key <= "9"; - if (isDigit) return key; - - if (key === "/" || key === "." || key === "enter") return key; - + if (isKey(raw)) return raw; return null; } -function getActiveModifier(ev: KeyboardEvent): string | null { - const modifierKeys = { - ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey, - alt: ev.altKey, - shift: ev.shiftKey, - }; +function getActiveModifier(ev: KeyboardEvent): ModifierKeys | null { + const ctrl = isAppleDevice() ? ev.metaKey : ev.ctrlKey; + const alt = ev.altKey; + const shift = ev.shiftKey; - const activeModifier = Object.keys(modifierKeys) - .filter((key) => modifierKeys[key as keyof typeof modifierKeys]) - .join("+"); - - return activeModifier === "" ? null : activeModifier; + if (ctrl && alt && shift) return "ctrl+alt+shift"; + if (ctrl && alt) return "ctrl+alt"; + if (ctrl && shift) return "ctrl+shift"; + if (alt && shift) return "alt+shift"; + if (ctrl) return "ctrl"; + if (alt) return "alt"; + if (shift) return "shift"; + return null; } diff --git a/apps/web/src/actions/keybindings/__tests__/persistence.test.ts b/apps/web/src/actions/keybindings/__tests__/persistence.test.ts new file mode 100644 index 00000000..bcccc6a4 --- /dev/null +++ b/apps/web/src/actions/keybindings/__tests__/persistence.test.ts @@ -0,0 +1,144 @@ +import { + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { + decodePersistedKeybindingsState, + migratePersistedKeybindingsState, + parseImportedKeybindings, + serializeKeybindingsState, +} from "../persistence"; + +describe("keybinding persistence", () => { + let warnSpy: ReturnType<typeof mock>; + let originalWarn: typeof console.warn; + + beforeEach(() => { + originalWarn = console.warn; + warnSpy = mock(() => {}); + console.warn = warnSpy; + }); + + afterEach(() => { + console.warn = originalWarn; + }); + + test("migrates legacy persisted keybindings before decoding them", () => { + const migrated = migratePersistedKeybindingsState({ + state: { + keybindings: { + s: "split-selected", + "ctrl+v": "paste-selected", + }, + isCustomized: true, + }, + fromVersion: 2, + }); + + const decoded = decodePersistedKeybindingsState({ state: migrated }); + expect(decoded).not.toBeNull(); + if (!decoded) throw new Error("Expected migrated keybindings to decode"); + + expect(decoded.isCustomized).toBe(true); + expect(decoded.keybindings.get("s")).toBe("split"); + expect(decoded.keybindings.get("ctrl+v")).toBe("paste-copied"); + expect(decoded.keybindings.get("escape")).toBe("cancel-interaction"); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test("filters invalid persisted entries at the boundary and warns", () => { + const decoded = decodePersistedKeybindingsState({ + state: { + keybindings: { + space: "toggle-play", + "shift+bogus": "toggle-play", + "ctrl+v": "not-an-action", + }, + isCustomized: false, + }, + }); + + expect(decoded).not.toBeNull(); + if (!decoded) throw new Error("Expected persisted keybindings to decode"); + + expect(Array.from(decoded.keybindings.entries())).toEqual([ + ["space", "toggle-play"], + ]); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + test("returns null and warns when persisted shape is unrecognizable", () => { + const decoded = decodePersistedKeybindingsState({ state: "garbage" }); + expect(decoded).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + test("round-trips actions that have no default shortcut", () => { + // `stop-playback` is a valid `TActionWithOptionalArgs` but is not in the + // defaults table; the validator must still accept it. + const serialized = serializeKeybindingsState({ + keybindings: new Map([ + ["space", "toggle-play"], + ["x", "stop-playback"], + ["b", "toggle-bookmark"], + ]), + isCustomized: true, + }); + + const decoded = decodePersistedKeybindingsState({ state: serialized }); + expect(decoded).not.toBeNull(); + if (!decoded) throw new Error("Expected round-tripped keybindings"); + + expect(decoded.keybindings.get("space")).toBe("toggle-play"); + expect(decoded.keybindings.get("x")).toBe("stop-playback"); + expect(decoded.keybindings.get("b")).toBe("toggle-bookmark"); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); + +describe("parseImportedKeybindings", () => { + test("accepts a valid configuration", () => { + const result = parseImportedKeybindings({ + config: { + space: "toggle-play", + x: "stop-playback", + }, + }); + + expect(result.get("space")).toBe("toggle-play"); + expect(result.get("x")).toBe("stop-playback"); + }); + + test("throws on non-object input", () => { + expect(() => parseImportedKeybindings({ config: null })).toThrow( + /JSON object/, + ); + expect(() => parseImportedKeybindings({ config: [] })).toThrow( + /JSON object/, + ); + }); + + test("throws on non-string action values", () => { + expect(() => + parseImportedKeybindings({ config: { space: 42 } }), + ).toThrow(/expected string/); + }); + + test("throws on invalid shortcut keys", () => { + expect(() => + parseImportedKeybindings({ + config: { "shift+bogus": "toggle-play" }, + }), + ).toThrow(/shift\+bogus/); + }); + + test("throws on invalid actions", () => { + expect(() => + parseImportedKeybindings({ config: { space: "not-an-action" } }), + ).toThrow(/not-an-action/); + }); +}); diff --git a/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts b/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts index 0171c5a5..81fd86a7 100644 --- a/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts +++ b/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts @@ -1,13 +1,8 @@ -import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding";
-import type { TActionWithOptionalArgs } from "@/actions";
-
-interface V2State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v2ToV3({ state }: { state: unknown }): unknown {
- const v2 = state as V2State;
+ const v2 = getPersistedKeybindingsState({ state });
+ if (!v2) return state;
const renames: Record<string, string> = {
"split-selected": "split",
@@ -17,8 +12,9 @@ export function v2ToV3({ state }: { state: unknown }): unknown { const migrated = { ...v2.keybindings };
for (const [key, action] of Object.entries(migrated)) {
- if (action && renames[action]) {
- migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs;
+ const renamedAction = action ? renames[action] : undefined;
+ if (renamedAction) {
+ migrated[key] = renamedAction;
}
}
diff --git a/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts b/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts index 8541071b..3724f61c 100644 --- a/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts +++ b/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts @@ -1,14 +1,8 @@ -import type { TActionWithOptionalArgs } from "@/actions";
-import type { ShortcutKey } from "@/actions/keybinding";
-import type { KeybindingConfig } from "@/actions/keybinding";
-
-interface V3State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v3ToV4({ state }: { state: unknown }): unknown {
- const v3 = state as V3State;
+ const v3 = getPersistedKeybindingsState({ state });
+ if (!v3) return state;
const renames: Record<string, string> = {
"paste-selected": "paste-copied",
@@ -16,8 +10,9 @@ export function v3ToV4({ state }: { state: unknown }): unknown { const migrated = { ...v3.keybindings };
for (const [key, action] of Object.entries(migrated)) {
- if (action && renames[action]) {
- migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs;
+ const renamedAction = action ? renames[action] : undefined;
+ if (renamedAction) {
+ migrated[key] = renamedAction;
}
}
diff --git a/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts b/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts index f6937c39..bb60b044 100644 --- a/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts +++ b/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts @@ -1,12 +1,8 @@ -import type { KeybindingConfig } from "@/actions/keybinding";
-
-interface V4State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v4ToV5({ state }: { state: unknown }): unknown {
- const v4 = state as V4State;
+ const v4 = getPersistedKeybindingsState({ state });
+ if (!v4) return state;
const keybindings = { ...v4.keybindings };
if (!keybindings.escape) {
diff --git a/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts b/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts index 09b44307..cb572aac 100644 --- a/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts +++ b/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts @@ -1,12 +1,8 @@ -import type { KeybindingConfig } from "@/actions/keybinding";
-
-interface V5State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v5ToV6({ state }: { state: unknown }): unknown {
- const v5 = state as V5State;
+ const v5 = getPersistedKeybindingsState({ state });
+ if (!v5) return state;
const keybindings = { ...v5.keybindings };
if (keybindings.escape === "deselect-all") {
diff --git a/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts b/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts index 46a99ac1..9c05db23 100644 --- a/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts +++ b/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts @@ -1,16 +1,12 @@ -import type { KeybindingConfig } from "@/actions/keybinding"; - -interface V6State { - keybindings: KeybindingConfig; - isCustomized: boolean; -} +import { getPersistedKeybindingsState } from "../persisted-state"; export function v6ToV7({ state }: { state: unknown }): unknown { - const v6 = state as V6State; + const v6 = getPersistedKeybindingsState({ state }); + if (!v6) return state; const keybindings = { ...v6.keybindings }; - for (const key of Object.keys(keybindings) as Array<keyof KeybindingConfig>) { - if (keybindings[key] === ("split-element" as never)) { + for (const [key, action] of Object.entries(keybindings)) { + if (action === "split-element") { keybindings[key] = "split"; } } diff --git a/apps/web/src/actions/keybindings/persisted-state.ts b/apps/web/src/actions/keybindings/persisted-state.ts new file mode 100644 index 00000000..57d83f6d --- /dev/null +++ b/apps/web/src/actions/keybindings/persisted-state.ts @@ -0,0 +1,37 @@ +export type PersistedKeybindingConfig = Record<string, string | undefined>; + +export interface PersistedKeybindingsState { + keybindings: PersistedKeybindingConfig; + isCustomized: boolean; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function getPersistedKeybindingsState({ + state, +}: { + state: unknown; +}): PersistedKeybindingsState | null { + if (!isRecord(state)) return null; + + const { keybindings, isCustomized } = state; + if (!isRecord(keybindings) || typeof isCustomized !== "boolean") { + return null; + } + + const normalizedKeybindings: PersistedKeybindingConfig = {}; + for (const [key, action] of Object.entries(keybindings)) { + if (action !== undefined && typeof action !== "string") { + return null; + } + + normalizedKeybindings[key] = action; + } + + return { + keybindings: normalizedKeybindings, + isCustomized, + }; +} diff --git a/apps/web/src/actions/keybindings/persistence.ts b/apps/web/src/actions/keybindings/persistence.ts new file mode 100644 index 00000000..ced44bd0 --- /dev/null +++ b/apps/web/src/actions/keybindings/persistence.ts @@ -0,0 +1,119 @@ +import type { ShortcutKey } from "@/actions/keybinding"; +import { isShortcutKey } from "@/actions/keybinding"; +import type { TActionWithOptionalArgs } from "@/actions"; +import { isActionWithOptionalArgs } from "@/actions"; +import { runMigrations } from "./migrations"; +import { + getPersistedKeybindingsState, + type PersistedKeybindingsState, +} from "./persisted-state"; + +export interface DecodedKeybindingsState { + keybindings: Map<ShortcutKey, TActionWithOptionalArgs>; + isCustomized: boolean; +} + +export function serializeKeybindingsState({ + keybindings, + isCustomized, +}: DecodedKeybindingsState): PersistedKeybindingsState { + return { + keybindings: Object.fromEntries(keybindings), + isCustomized, + }; +} + +export function migratePersistedKeybindingsState({ + state, + fromVersion, +}: { + state: unknown; + fromVersion: number; +}): unknown { + return runMigrations({ state, fromVersion }); +} + +/** + * Decode a persisted/migrated keybindings blob into the in-memory shape. + * + * Lossy by design: invalid entries are dropped and a warning is emitted, so the + * user falls back to (mostly) sensible defaults instead of a broken store. + * Returns `null` if the top-level shape is unrecognizable, in which case the + * caller should keep its current state. + */ +export function decodePersistedKeybindingsState({ + state, +}: { + state: unknown; +}): DecodedKeybindingsState | null { + const persisted = getPersistedKeybindingsState({ state }); + if (!persisted) { + console.warn( + "[keybindings] Persisted state has unexpected shape; keeping current keybindings.", + state, + ); + return null; + } + + const keybindings = new Map<ShortcutKey, TActionWithOptionalArgs>(); + const dropped: Array<{ key: string; action: string | undefined }> = []; + for (const [key, action] of Object.entries(persisted.keybindings)) { + if (action === undefined) continue; + if (!isShortcutKey(key) || !isActionWithOptionalArgs(action)) { + dropped.push({ key, action }); + continue; + } + + keybindings.set(key, action); + } + + if (dropped.length > 0) { + console.warn( + "[keybindings] Dropped invalid persisted entries:", + dropped, + ); + } + + return { + keybindings, + isCustomized: persisted.isCustomized, + }; +} + +/** + * Parse a user-supplied keybindings configuration (typically the output of + * `JSON.parse` on an imported file). + * + * Strict by design: throws on the first invalid entry so the caller can surface + * the failure to the user instead of silently producing a half-applied import. + * Accepts `unknown` because the input has already crossed a trust boundary. + */ +export function parseImportedKeybindings({ + config, +}: { + config: unknown; +}): Map<ShortcutKey, TActionWithOptionalArgs> { + if (typeof config !== "object" || config === null || Array.isArray(config)) { + throw new Error("Imported keybindings must be a JSON object"); + } + + const result = new Map<ShortcutKey, TActionWithOptionalArgs>(); + for (const [key, action] of Object.entries(config)) { + if (action === undefined) continue; + if (typeof action !== "string") { + throw new Error( + `Invalid action for "${key}": expected string, got ${typeof action}`, + ); + } + if (!isShortcutKey(key)) { + throw new Error(`Invalid shortcut key: ${JSON.stringify(key)}`); + } + if (!isActionWithOptionalArgs(action)) { + throw new Error( + `Invalid action for "${key}": ${JSON.stringify(action)}`, + ); + } + result.set(key, action); + } + return result; +} diff --git a/apps/web/src/actions/registry.ts b/apps/web/src/actions/registry.ts index e69b3b9f..ece14eaf 100644 --- a/apps/web/src/actions/registry.ts +++ b/apps/web/src/actions/registry.ts @@ -11,6 +11,7 @@ import type { type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void; const boundActions: Partial<Record<TAction, ActionHandler[]>> = {}; +// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler). export function bindAction<A extends TAction>( action: A, handler: TActionFunc<A>, @@ -24,6 +25,7 @@ export function bindAction<A extends TAction>( } } +// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler). export function unbindAction<A extends TAction>( action: A, handler: TActionFunc<A>, @@ -52,6 +54,7 @@ type InvokeActionFunc = { ): void; }; +// eslint-disable-next-line opencut/prefer-object-params -- dispatchers conventionally separate action, payload, and trigger. export const invokeAction: InvokeActionFunc = <A extends TAction>( action: A, args?: TArgOfAction<A>, diff --git a/apps/web/src/actions/types.ts b/apps/web/src/actions/types.ts index 41258c7b..38627b95 100644 --- a/apps/web/src/actions/types.ts +++ b/apps/web/src/actions/types.ts @@ -1,44 +1,44 @@ -import type { MutableRefObject } from "react";
-import type { TAction } from "./definitions";
-
-export type { TAction };
-
-export type TActionArgsMap = {
- "seek-forward": { seconds: number } | undefined;
- "seek-backward": { seconds: number } | undefined;
- "jump-forward": { seconds: number } | undefined;
- "jump-backward": { seconds: number } | undefined;
- "remove-media-asset": { projectId: string; assetId: string };
- "remove-media-assets": { projectId: string; assetIds: string[] };
-};
-
-type TKeysWithValueUndefined<T> = {
- [K in keyof T]: undefined extends T[K] ? K : never;
-}[keyof T];
-
-export type TActionWithArgs = keyof TActionArgsMap;
-
-export type TActionWithOptionalArgs =
- | TActionWithNoArgs
- | TKeysWithValueUndefined<TActionArgsMap>;
-
-export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>;
-
-export type TArgOfAction<A extends TAction> = A extends TActionWithArgs
- ? TActionArgsMap[A]
- : undefined;
-
-export type TActionFunc<A extends TAction> = A extends TActionWithArgs
- ? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
- : (_?: undefined, trigger?: TInvocationTrigger) => void;
-
-export type TInvocationTrigger = "keypress" | "mouseclick";
-
-export type TBoundActionList = {
- [A in TAction]?: Array<TActionFunc<A>>;
-};
-
-export type TActionHandlerOptions =
- | MutableRefObject<boolean>
- | boolean
- | undefined;
+import type { MutableRefObject } from "react"; +import type { TAction } from "./definitions"; + +export type { TAction }; + +export type TActionArgsMap = { + "seek-forward": { seconds: number } | undefined; + "seek-backward": { seconds: number } | undefined; + "jump-forward": { seconds: number } | undefined; + "jump-backward": { seconds: number } | undefined; + "remove-media-asset": { projectId: string; assetId: string }; + "remove-media-assets": { projectId: string; assetIds: string[] }; +}; + +type TKeysWithValueUndefined<T> = { + [K in keyof T]: undefined extends T[K] ? K : never; +}[keyof T]; + +export type TActionWithArgs = keyof TActionArgsMap; + +export type TActionWithOptionalArgs = + | TActionWithNoArgs + | TKeysWithValueUndefined<TActionArgsMap>; + +export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>; + +export type TArgOfAction<A extends TAction> = A extends TActionWithArgs + ? TActionArgsMap[A] + : undefined; + +export type TActionFunc<A extends TAction> = A extends TActionWithArgs + ? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void + : (_?: undefined, trigger?: TInvocationTrigger) => void; + +export type TInvocationTrigger = "keypress" | "mouseclick"; + +export type TBoundActionList = { + [A in TAction]?: Array<TActionFunc<A>>; +}; + +export type TActionHandlerOptions = + | MutableRefObject<boolean> + | boolean + | undefined; diff --git a/apps/web/src/actions/use-action-handler.ts b/apps/web/src/actions/use-action-handler.ts index a8bb0054..d4a37b50 100644 --- a/apps/web/src/actions/use-action-handler.ts +++ b/apps/web/src/actions/use-action-handler.ts @@ -8,6 +8,7 @@ import type { } from "@/actions"; import { bindAction, unbindAction } from "@/actions"; +// eslint-disable-next-line opencut/prefer-object-params -- action subscriptions read best as (action, handler, isActive). export function useActionHandler<A extends TAction>( action: A, handler: TActionFunc<A>, diff --git a/apps/web/src/actions/use-editor-actions.ts b/apps/web/src/actions/use-editor-actions.ts index 5e90a6a1..73300c6e 100644 --- a/apps/web/src/actions/use-editor-actions.ts +++ b/apps/web/src/actions/use-editor-actions.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useState } from "react"; import { useTimelineStore } from "@/timeline/timeline-store"; import { useActionHandler } from "@/actions/use-action-handler"; import { useEditor } from "@/editor/use-editor"; @@ -25,6 +25,7 @@ import { clearActiveScope, type ScopeEntry, } from "@/selection/scope"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; export function useEditorActions() { const editor = useEditor(); @@ -36,47 +37,34 @@ export function useEditorActions() { const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); - const hasTimelineSelectionRef = useRef(false); - const clearTimelineSelectionRef = useRef(() => {}); - const clearTimelineActiveSelectionRef = useRef(() => {}); - const timelineScopeRef = useRef<ScopeEntry | null>(null); const hasTimelineSelection = selectedElements.length > 0 || selectedKeyframes.length > 0 || selectedMaskPointSelection !== null; - - hasTimelineSelectionRef.current = hasTimelineSelection; - clearTimelineSelectionRef.current = () => { + const hasTimelineSelectionRef = useCommittedRef(hasTimelineSelection); + const clearTimelineSelectionRef = useCommittedRef(() => { editor.selection.clearSelection(); - }; - clearTimelineActiveSelectionRef.current = () => { + }); + const clearTimelineActiveSelectionRef = useCommittedRef(() => { editor.selection.clearMostSpecificSelection(); - }; - - if (!timelineScopeRef.current) { - timelineScopeRef.current = { - hasSelection: () => hasTimelineSelectionRef.current, - clear: () => { - clearTimelineSelectionRef.current(); - }, - clearActive: () => { - clearTimelineActiveSelectionRef.current(); - }, - }; - } + }); + const [timelineScope] = useState<ScopeEntry>(() => ({ + hasSelection: () => hasTimelineSelectionRef.current, + clear: () => { + clearTimelineSelectionRef.current(); + }, + clearActive: () => { + clearTimelineActiveSelectionRef.current(); + }, + })); useEffect(() => { if (!hasTimelineSelection) { return; } - const timelineScope = timelineScopeRef.current; - if (!timelineScope) { - return; - } - return activateScope({ entry: timelineScope }); - }, [hasTimelineSelection]); + }, [hasTimelineSelection, timelineScope]); useActionHandler( "toggle-play", diff --git a/apps/web/src/actions/use-keybindings.ts b/apps/web/src/actions/use-keybindings.ts index c129795c..e9d78982 100644 --- a/apps/web/src/actions/use-keybindings.ts +++ b/apps/web/src/actions/use-keybindings.ts @@ -33,7 +33,7 @@ export function useKeybindingsListener() { const isTextInput = activeElement instanceof HTMLElement && isTypableDOMElement({ element: activeElement }); - const boundAction = binding ? keybindings[binding] : undefined; + const boundAction = binding ? keybindings.get(binding) : undefined; if (normalizedKey === "escape" && isTextInput) { activeElement.blur(); diff --git a/apps/web/src/actions/use-keyboard-shortcuts-help.ts b/apps/web/src/actions/use-keyboard-shortcuts-help.ts index 7681e664..89a17887 100644 --- a/apps/web/src/actions/use-keyboard-shortcuts-help.ts +++ b/apps/web/src/actions/use-keyboard-shortcuts-help.ts @@ -39,27 +39,21 @@ export function useKeyboardShortcutsHelp() { const { keybindings } = useKeybindingsStore(); const shortcuts = useMemo(() => { - const result: KeyboardShortcut[] = []; - const actionToKeys: Partial<Record<TActionWithOptionalArgs, string[]>> = {}; + const actionToKeys = new Map<TActionWithOptionalArgs, string[]>(); - for (const [key, action] of Object.entries(keybindings) as Array< - [string, TActionWithOptionalArgs | undefined] - >) { - if (action) { - if (!actionToKeys[action]) { - actionToKeys[action] = []; - } - actionToKeys[action].push(formatKey({ key })); + for (const [key, action] of keybindings) { + const existing = actionToKeys.get(action); + if (existing) { + existing.push(formatKey({ key })); + } else { + actionToKeys.set(action, [formatKey({ key })]); } } - for (const [action, keys] of Object.entries(actionToKeys) as Array< - [TActionWithOptionalArgs, string[]] - >) { + const result: KeyboardShortcut[] = []; + for (const [action, keys] of actionToKeys) { const actionDef = ACTIONS[action]; - if (!actionDef) { - continue; - } + if (!actionDef) continue; result.push({ id: action, keys, diff --git a/apps/web/src/animation/property-registry.ts b/apps/web/src/animation/property-registry.ts index aacb9972..bca9d82e 100644 --- a/apps/web/src/animation/property-registry.ts +++ b/apps/web/src/animation/property-registry.ts @@ -32,7 +32,11 @@ export interface AnimationPropertyDefinition { supportsElement: ({ element }: { element: TimelineElement }) => boolean; getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; - setValue: ({ + // Apply `value` to `element` for this property. Coerces the value through + // `coerceValue` and verifies element support; returns `element` unchanged + // if either fails. Cannot be bypassed — there is no kind-narrow `setValue` + // on the public surface, so callers can't apply an unvalidated value. + applyValue: ({ element, value, }: { @@ -94,7 +98,13 @@ function createNumberPropertyDefinition({ numericRange?: NumericSpec; supportsElement: AnimationPropertyDefinition["supportsElement"]; getValue: AnimationPropertyDefinition["getValue"]; - setValue: AnimationPropertyDefinition["setValue"]; + setValue: ({ + element, + value, + }: { + element: TimelineElement; + value: number; + }) => TimelineElement; }): AnimationPropertyDefinition { return { kind: "number", @@ -102,12 +112,51 @@ function createNumberPropertyDefinition({ numericRanges: numericRange ? { value: numericRange } : undefined, supportsElement, getValue, - coerceValue: ({ value }) => - coerceNumberValue({ - value, - numericRange, - }), - setValue, + coerceValue: ({ value }) => coerceNumberValue({ value, numericRange }), + applyValue: ({ element, value }) => { + if (!supportsElement({ element })) { + return element; + } + const coerced = coerceNumberValue({ value, numericRange }); + if (coerced === null) { + return element; + } + return setValue({ element, value: coerced }); + }, + }; +} + +function createColorPropertyDefinition({ + supportsElement, + getValue, + setValue, +}: { + supportsElement: AnimationPropertyDefinition["supportsElement"]; + getValue: AnimationPropertyDefinition["getValue"]; + setValue: ({ + element, + value, + }: { + element: TimelineElement; + value: string; + }) => TimelineElement; +}): AnimationPropertyDefinition { + return { + kind: "color", + defaultInterpolation: "linear", + supportsElement, + getValue, + coerceValue: ({ value }) => coerceColorValue({ value }), + applyValue: ({ element, value }) => { + if (!supportsElement({ element })) { + return element; + } + const coerced = coerceColorValue({ value }); + if (coerced === null) { + return element; + } + return setValue({ element, value: coerced }); + }, }; } @@ -126,7 +175,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< ...element, transform: { ...element.transform, - position: { ...element.transform.position, x: value as number }, + position: { ...element.transform.position, x: value }, }, } : element, @@ -142,7 +191,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< ...element, transform: { ...element.transform, - position: { ...element.transform.position, y: value as number }, + position: { ...element.transform.position, y: value }, }, } : element, @@ -156,7 +205,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< isVisualElement(element) ? { ...element, - transform: { ...element.transform, scaleX: value as number }, + transform: { ...element.transform, scaleX: value }, } : element, }), @@ -169,7 +218,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< isVisualElement(element) ? { ...element, - transform: { ...element.transform, scaleY: value as number }, + transform: { ...element.transform, scaleY: value }, } : element, }), @@ -182,7 +231,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< isVisualElement(element) ? { ...element, - transform: { ...element.transform, rotate: value as number }, + transform: { ...element.transform, rotate: value }, } : element, }), @@ -192,9 +241,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< getValue: ({ element }) => isVisualElement(element) ? element.opacity : null, setValue: ({ element, value }) => - isVisualElement(element) - ? { ...element, opacity: value as number } - : element, + isVisualElement(element) ? { ...element, opacity: value } : element, }), volume: createNumberPropertyDefinition({ numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, @@ -202,36 +249,26 @@ const ANIMATION_PROPERTY_REGISTRY: Record< getValue: ({ element }) => canElementHaveAudio(element) ? element.volume ?? 0 : null, setValue: ({ element, value }) => - canElementHaveAudio(element) - ? { ...element, volume: value as number } - : element, + canElementHaveAudio(element) ? { ...element, volume: value } : element, }), - color: { - kind: "color", - defaultInterpolation: "linear", + color: createColorPropertyDefinition({ supportsElement: ({ element }) => element.type === "text", getValue: ({ element }) => (element.type === "text" ? element.color : null), - coerceValue: ({ value }) => coerceColorValue({ value }), setValue: ({ element, value }) => - element.type === "text" - ? { ...element, color: value as string } - : element, - }, - "background.color": { - kind: "color", - defaultInterpolation: "linear", + element.type === "text" ? { ...element, color: value } : element, + }), + "background.color": createColorPropertyDefinition({ supportsElement: ({ element }) => element.type === "text", getValue: ({ element }) => element.type === "text" ? element.background.color : null, - coerceValue: ({ value }) => coerceColorValue({ value }), setValue: ({ element, value }) => element.type === "text" ? { ...element, - background: { ...element.background, color: value as string }, + background: { ...element.background, color: value }, } : element, - }, + }), "background.paddingX": createNumberPropertyDefinition({ numericRange: { min: 0, step: 1 }, supportsElement: ({ element }) => element.type === "text", @@ -243,7 +280,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< element.type === "text" ? { ...element, - background: { ...element.background, paddingX: value as number }, + background: { ...element.background, paddingX: value }, } : element, }), @@ -258,7 +295,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< element.type === "text" ? { ...element, - background: { ...element.background, paddingY: value as number }, + background: { ...element.background, paddingY: value }, } : element, }), @@ -273,7 +310,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< element.type === "text" ? { ...element, - background: { ...element.background, offsetX: value as number }, + background: { ...element.background, offsetX: value }, } : element, }), @@ -288,7 +325,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< element.type === "text" ? { ...element, - background: { ...element.background, offsetY: value as number }, + background: { ...element.background, offsetY: value }, } : element, }), @@ -307,7 +344,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record< element.type === "text" ? { ...element, - background: { ...element.background, cornerRadius: value as number }, + background: { ...element.background, cornerRadius: value }, } : element, }), @@ -362,11 +399,7 @@ export function withElementBaseValueForProperty({ value: AnimationValue; }): TimelineElement { const definition = getAnimationPropertyDefinition({ propertyPath }); - const coercedValue = definition.coerceValue({ value }); - if (coercedValue === null || !definition.supportsElement({ element })) { - return element; - } - return definition.setValue({ element, value: coercedValue }); + return definition.applyValue({ element, value }); } export function getDefaultInterpolationForProperty({ diff --git a/apps/web/src/app/brand/page.tsx b/apps/web/src/app/brand/page.tsx index 94e3f3e1..9728cbc7 100644 --- a/apps/web/src/app/brand/page.tsx +++ b/apps/web/src/app/brand/page.tsx @@ -190,7 +190,7 @@ export default function BrandPage() { </div> <div className="flex flex-col gap-3"> - <h2 className="font-semibold text-lg">What's not allowed</h2> + <h2 className="font-semibold text-lg">What's not allowed</h2> <ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed"> {[ "Using OpenCut in the name of your product, service, or domain.", diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index b3d40a37..680dfff4 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -130,8 +130,14 @@ function EditorLayout() { direction="vertical" className="size-full gap-[0.18rem]" onLayout={(sizes) => { - setPanel("mainContent", sizes[0] ?? panels.mainContent); - setPanel("timeline", sizes[1] ?? panels.timeline); + setPanel({ + panel: "mainContent", + size: sizes[0] ?? panels.mainContent, + }); + setPanel({ + panel: "timeline", + size: sizes[1] ?? panels.timeline, + }); }} > <ResizablePanel @@ -144,9 +150,12 @@ function EditorLayout() { direction="horizontal" className="size-full gap-[0.19rem] px-3" onLayout={(sizes) => { - setPanel("tools", sizes[0] ?? panels.tools); - setPanel("preview", sizes[1] ?? panels.preview); - setPanel("properties", sizes[2] ?? panels.properties); + setPanel({ panel: "tools", size: sizes[0] ?? panels.tools }); + setPanel({ panel: "preview", size: sizes[1] ?? panels.preview }); + setPanel({ + panel: "properties", + size: sizes[2] ?? panels.properties, + }); }} > <ResizablePanel diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index 6362146e..17775147 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -57,7 +57,10 @@ export default function PrivacyPage() { content is tracked </li> <li>You can clear local data from your browser at any time</li> - <li>We don't sell or share your data with anyone (we don't even have it)</li> + <li> + We don't sell or share your data with anyone (we don't + even have it) + </li> </ol> <p className="mt-4"> Questions? Email us at{" "} @@ -172,7 +175,7 @@ export default function PrivacyPage() { <a href={SOCIAL_LINKS.github} target="_blank" - rel="noopener" + rel="noopener noreferrer" className="text-primary hover:underline" > GitHub @@ -189,7 +192,7 @@ export default function PrivacyPage() { <a href={`${SOCIAL_LINKS.github}/issues`} target="_blank" - rel="noopener" + rel="noopener noreferrer" className="text-primary hover:underline" > GitHub repository @@ -205,7 +208,7 @@ export default function PrivacyPage() { <a href={SOCIAL_LINKS.x} target="_blank" - rel="noopener" + rel="noopener noreferrer" className="text-primary hover:underline" > X (Twitter) diff --git a/apps/web/src/app/terms/page.tsx b/apps/web/src/app/terms/page.tsx index 0845892c..5f2e0420 100644 --- a/apps/web/src/app/terms/page.tsx +++ b/apps/web/src/app/terms/page.tsx @@ -51,9 +51,13 @@ export default function TermsPage() { Free for personal and commercial use with no watermarks or restrictions </li> - <li>You're responsible for how you use it - don't break the law</li> <li> - Service provided "as is" - we can't guarantee perfect uptime + You're responsible for how you use it - don't break + the law + </li> + <li> + Service provided "as is" - we can't guarantee + perfect uptime </li> <li> Open source means you can review our code and self-host if @@ -109,8 +113,8 @@ export default function TermsPage() { </li> </ul> <p> - You're responsible for how you use OpenCut and the content you create. - Don't use it for anything illegal in your jurisdiction. + You're responsible for how you use OpenCut and the content you + create. Don't use it for anything illegal in your jurisdiction. </p> </section> @@ -127,8 +131,8 @@ export default function TermsPage() { <h2 className="text-2xl font-semibold">Service</h2> <p> OpenCut does not currently require an account. The service is provided - "as is" without warranties. While we strive for reliability, we can't - guarantee uninterrupted service. + "as is" without warranties. While we strive for + reliability, we can't guarantee uninterrupted service. </p> </section> @@ -146,7 +150,7 @@ export default function TermsPage() { <a href={SOCIAL_LINKS.github} target="_blank" - rel="noopener" + rel="noopener noreferrer" className="text-primary hover:underline" > GitHub @@ -161,12 +165,12 @@ export default function TermsPage() { OpenCut is provided free of charge. To the extent permitted by law: </p> <ul className="list-disc space-y-2 pl-6"> - <li>We're not liable for any loss of data or content</li> + <li>We're not liable for any loss of data or content</li> <li> Projects are stored in your browser and may be lost if you clear browser data </li> - <li>We're not responsible for how you use the service</li> + <li>We're not responsible for how you use the service</li> <li>Our liability is limited to the maximum extent allowed by law</li> </ul> <p> @@ -180,7 +184,7 @@ export default function TermsPage() { <h2 className="text-2xl font-semibold">Service Changes</h2> <p>We may update OpenCut and these terms:</p> <ul className="list-disc space-y-2 pl-6"> - <li>We'll notify you of significant changes to these terms</li> + <li>We'll notify you of significant changes to these terms</li> <li>Continued use means you accept any updates</li> <li>You can always self-host an older version if you prefer</li> <li>Major changes will be discussed with the community on GitHub</li> @@ -203,7 +207,7 @@ export default function TermsPage() { <a href={`${SOCIAL_LINKS.github}/issues`} target="_blank" - rel="noopener" + rel="noopener noreferrer" className="text-primary hover:underline" > GitHub repository @@ -219,7 +223,7 @@ export default function TermsPage() { <a href={SOCIAL_LINKS.x} target="_blank" - rel="noopener" + rel="noopener noreferrer" className="text-primary hover:underline" > X (Twitter) diff --git a/apps/web/src/clipboard/handlers/elements.ts b/apps/web/src/clipboard/handlers/elements.ts index b2f5f2c3..2297b493 100644 --- a/apps/web/src/clipboard/handlers/elements.ts +++ b/apps/web/src/clipboard/handlers/elements.ts @@ -35,7 +35,7 @@ export const ElementsClipboardHandler = { }; }, - paste(entry, context) { + paste({ entry, context }) { if (entry.items.length === 0) { return null; } diff --git a/apps/web/src/clipboard/handlers/index.ts b/apps/web/src/clipboard/handlers/index.ts index 29d4d1d0..a042c3dd 100644 --- a/apps/web/src/clipboard/handlers/index.ts +++ b/apps/web/src/clipboard/handlers/index.ts @@ -45,5 +45,5 @@ export function buildPasteClipboardCommand({ const handler = clipboardHandlers[entry.type] as ClipboardHandler< typeof entry.type >; - return handler.paste(entry as never, context); + return handler.paste({ entry: entry as never, context }); } diff --git a/apps/web/src/clipboard/handlers/keyframes.ts b/apps/web/src/clipboard/handlers/keyframes.ts index cb0c0e65..06879e3e 100644 --- a/apps/web/src/clipboard/handlers/keyframes.ts +++ b/apps/web/src/clipboard/handlers/keyframes.ts @@ -156,7 +156,10 @@ export const KeyframesClipboardHandler = { }; }, - paste(entry, { selectedElements, time }) { + paste({ + entry, + context: { selectedElements, time }, + }) { const targetElement = selectedElements[0]; if (!targetElement || entry.items.length === 0) { return null; diff --git a/apps/web/src/clipboard/types.ts b/apps/web/src/clipboard/types.ts index 562f9ac7..1d469a30 100644 --- a/apps/web/src/clipboard/types.ts +++ b/apps/web/src/clipboard/types.ts @@ -69,10 +69,10 @@ export interface ClipboardHandler<TType extends ClipboardEntryType> { type: TType; canCopy(context: CopyContext): boolean; copy(context: CopyContext): ClipboardEntryByType[TType] | null; - paste( - entry: ClipboardEntryByType[TType], - context: PasteContext, - ): Command | null; + paste(args: { + entry: ClipboardEntryByType[TType]; + context: PasteContext; + }): Command | null; } export type ClipboardHandlerMap = { diff --git a/apps/web/src/commands/media/add-media-asset.ts b/apps/web/src/commands/media/add-media-asset.ts index d22241a2..629bc3ee 100644 --- a/apps/web/src/commands/media/add-media-asset.ts +++ b/apps/web/src/commands/media/add-media-asset.ts @@ -16,14 +16,22 @@ export class AddMediaAssetCommand extends Command { private previousProjectFps: FrameRate | null = null; private appliedProjectFps: FrameRate | null = null; - constructor( - private projectId: string, - private asset: Omit<MediaAsset, "id">, - ) { + constructor({ + projectId, + asset, + }: { + projectId: string; + asset: Omit<MediaAsset, "id">; + }) { super(); + this.projectId = projectId; + this.asset = asset; this.assetId = generateUUID(); } + private projectId: string; + private asset: Omit<MediaAsset, "id">; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedAssets = [...editor.media.getAssets()]; @@ -117,7 +125,14 @@ export class AddMediaAssetCommand extends Command { const activeProject = editor.project.getActiveOrNull(); if (!activeProject) return; - if (!this.appliedProjectFps || !frameRatesEqual(activeProject.settings.fps, this.appliedProjectFps)) return; + if ( + !this.appliedProjectFps || + !frameRatesEqual({ + a: activeProject.settings.fps, + b: this.appliedProjectFps, + }) + ) + return; const highestRemainingVideoFps = getHighestImportedVideoFps({ mediaAssets: editor.media.getAssets(), diff --git a/apps/web/src/commands/media/remove-media-asset.ts b/apps/web/src/commands/media/remove-media-asset.ts index 8da62181..38ff79fe 100644 --- a/apps/web/src/commands/media/remove-media-asset.ts +++ b/apps/web/src/commands/media/remove-media-asset.ts @@ -13,13 +13,21 @@ export class RemoveMediaAssetCommand extends Command { private savedTracks: SceneTracks | null = null; private removedAsset: MediaAsset | null = null; - constructor( - private projectId: string, - private assetId: string, - ) { + constructor({ + projectId, + assetId, + }: { + projectId: string; + assetId: string; + }) { super(); + this.projectId = projectId; + this.assetId = assetId; } + private projectId: string; + private assetId: string; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); const assets = editor.media.getAssets(); diff --git a/apps/web/src/commands/scene/create-scene.ts b/apps/web/src/commands/scene/create-scene.ts index b554fe90..3776467d 100644 --- a/apps/web/src/commands/scene/create-scene.ts +++ b/apps/web/src/commands/scene/create-scene.ts @@ -7,13 +7,21 @@ export class CreateSceneCommand extends Command { private savedScenes: TScene[] | null = null; private createdScene: TScene | null = null; - constructor( - private name: string, - private isMain: boolean = false, - ) { + constructor({ + name, + isMain = false, + }: { + name: string; + isMain?: boolean; + }) { super(); + this.name = name; + this.isMain = isMain; } + private name: string; + private isMain: boolean; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedScenes = [...editor.scenes.getScenes()]; diff --git a/apps/web/src/commands/scene/move-bookmark.ts b/apps/web/src/commands/scene/move-bookmark.ts index f3628b28..45b7bda5 100644 --- a/apps/web/src/commands/scene/move-bookmark.ts +++ b/apps/web/src/commands/scene/move-bookmark.ts @@ -8,13 +8,21 @@ import type { MediaTime } from "@/wasm"; export class MoveBookmarkCommand extends Command { private savedScenes: TScene[] | null = null; - constructor( - private fromTime: MediaTime, - private toTime: MediaTime, - ) { + constructor({ + fromTime, + toTime, + }: { + fromTime: MediaTime; + toTime: MediaTime; + }) { super(); + this.fromTime = fromTime; + this.toTime = toTime; } + private fromTime: MediaTime; + private toTime: MediaTime; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); const activeScene = editor.scenes.getActiveScene(); diff --git a/apps/web/src/commands/scene/remove-bookmark.ts b/apps/web/src/commands/scene/remove-bookmark.ts index bff5c035..95403e0e 100644 --- a/apps/web/src/commands/scene/remove-bookmark.ts +++ b/apps/web/src/commands/scene/remove-bookmark.ts @@ -6,11 +6,11 @@ import { getFrameTime, removeBookmarkFromArray, } from "@/timeline/bookmarks/index"; -import type { MediaTime } from "@/wasm"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export class RemoveBookmarkCommand extends Command { private savedScenes: TScene[] | null = null; - private frameTime: MediaTime = 0 as MediaTime; + private frameTime: MediaTime = ZERO_MEDIA_TIME; constructor(private time: MediaTime) { super(); diff --git a/apps/web/src/commands/scene/rename-scene.ts b/apps/web/src/commands/scene/rename-scene.ts index 19d6bfd8..8bc8724b 100644 --- a/apps/web/src/commands/scene/rename-scene.ts +++ b/apps/web/src/commands/scene/rename-scene.ts @@ -7,13 +7,21 @@ export class RenameSceneCommand extends Command { private savedScenes: TScene[] | null = null; private previousName: string | null = null; - constructor( - private sceneId: string, - private newName: string, - ) { + constructor({ + sceneId, + newName, + }: { + sceneId: string; + newName: string; + }) { super(); + this.sceneId = sceneId; + this.newName = newName; } + private sceneId: string; + private newName: string; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); const scenes = editor.scenes.getScenes(); diff --git a/apps/web/src/commands/scene/toggle-bookmark.ts b/apps/web/src/commands/scene/toggle-bookmark.ts index 9f318f80..31751be4 100644 --- a/apps/web/src/commands/scene/toggle-bookmark.ts +++ b/apps/web/src/commands/scene/toggle-bookmark.ts @@ -6,11 +6,11 @@ import { getFrameTime, toggleBookmarkInArray, } from "@/timeline/bookmarks/index"; -import type { MediaTime } from "@/wasm"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export class ToggleBookmarkCommand extends Command { private savedScenes: TScene[] | null = null; - private frameTime: MediaTime = 0 as MediaTime; + private frameTime: MediaTime = ZERO_MEDIA_TIME; constructor(private time: MediaTime) { super(); diff --git a/apps/web/src/commands/scene/update-bookmark.ts b/apps/web/src/commands/scene/update-bookmark.ts index 238bb3de..7208abc4 100644 --- a/apps/web/src/commands/scene/update-bookmark.ts +++ b/apps/web/src/commands/scene/update-bookmark.ts @@ -11,13 +11,21 @@ import type { MediaTime } from "@/wasm"; export class UpdateBookmarkCommand extends Command { private savedScenes: TScene[] | null = null; - constructor( - private time: MediaTime, - private updates: Partial<Omit<Bookmark, "time">>, - ) { + constructor({ + time, + updates, + }: { + time: MediaTime; + updates: Partial<Omit<Bookmark, "time">>; + }) { super(); + this.time = time; + this.updates = updates; } + private time: MediaTime; + private updates: Partial<Omit<Bookmark, "time">>; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); const activeScene = editor.scenes.getActiveScene(); diff --git a/apps/web/src/commands/timeline/track/add-track.ts b/apps/web/src/commands/timeline/track/add-track.ts index 6b58b687..90becf13 100644 --- a/apps/web/src/commands/timeline/track/add-track.ts +++ b/apps/web/src/commands/timeline/track/add-track.ts @@ -11,14 +11,22 @@ export class AddTrackCommand extends Command { private trackId: string; private savedState: SceneTracks | null = null; - constructor( - private type: TrackType, - private index?: number, - ) { + constructor({ + type, + index, + }: { + type: TrackType; + index?: number; + }) { super(); + this.type = type; + this.index = index; this.trackId = generateUUID(); } + private type: TrackType; + private index?: number; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedState = editor.scenes.getActiveScene().tracks; diff --git a/apps/web/src/commands/timeline/tracks-snapshot.ts b/apps/web/src/commands/timeline/tracks-snapshot.ts index 5b6d871a..8e55e602 100644 --- a/apps/web/src/commands/timeline/tracks-snapshot.ts +++ b/apps/web/src/commands/timeline/tracks-snapshot.ts @@ -3,13 +3,21 @@ import type { SceneTracks } from "@/timeline"; import { EditorCore } from "@/core"; export class TracksSnapshotCommand extends Command { - constructor( - private before: SceneTracks, - private after: SceneTracks, - ) { + constructor({ + before, + after, + }: { + before: SceneTracks; + after: SceneTracks; + }) { super(); + this.before = before; + this.after = after; } + private before: SceneTracks; + private after: SceneTracks; + execute(): CommandResult | undefined { EditorCore.getInstance().timeline.updateTracks(this.after); return undefined; diff --git a/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx b/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx index 1539d305..b412de7b 100644 --- a/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx +++ b/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx @@ -93,7 +93,7 @@ interface AssetsPanelStore { setMediaViewMode: (mode: MediaViewMode) => void; mediaSortBy: MediaSortKey; mediaSortOrder: MediaSortOrder; - setMediaSort: (key: MediaSortKey, order: MediaSortOrder) => void; + setMediaSort: (args: { key: MediaSortKey; order: MediaSortOrder }) => void; } export const useAssetsPanelStore = create<AssetsPanelStore>()( @@ -109,7 +109,7 @@ export const useAssetsPanelStore = create<AssetsPanelStore>()( setMediaViewMode: (mode) => set({ mediaViewMode: mode }), mediaSortBy: "name", mediaSortOrder: "asc", - setMediaSort: (key, order) => + setMediaSort: ({ key, order }) => set({ mediaSortBy: key, mediaSortOrder: order }), }), { diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx index c071fdc7..00cb015f 100644 --- a/apps/web/src/components/editor/panels/assets/views/assets.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -139,9 +139,12 @@ export function MediaView() { const handleSort = ({ key }: { key: MediaSortKey }) => { if (mediaSortBy === key) { - setMediaSort(key, mediaSortOrder === "asc" ? "desc" : "asc"); + setMediaSort({ + key, + order: mediaSortOrder === "asc" ? "desc" : "asc", + }); } else { - setMediaSort(key, "asc"); + setMediaSort({ key, order: "asc" }); } }; diff --git a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx index bf3a1c16..5edbeab3 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState } from "react"; +import { useState } from "react"; import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; import { Select, @@ -34,6 +34,10 @@ import type { TCanvasSize } from "@/project/types"; type SettingsView = "project-info" | "background"; +function isSettingsView(value: string): value is SettingsView { + return value === "project-info" || value === "background"; +} + const PRESET_LABELS: Record<string, string> = { "1:1": "1:1", "16:9": "16:9", @@ -73,23 +77,20 @@ function useCanvasDimensionDraft({ value: number; onCommit: (value: number) => void; }) { - const pendingValueRef = useRef(value); - const syncedValueRef = useRef(value); - - if (syncedValueRef.current !== value) { - syncedValueRef.current = value; - pendingValueRef.current = value; - } + const [pendingValue, setPendingValue] = useState(value); return usePropertyDraft({ displayValue: formatCanvasDimension({ value }), parse: (input) => parseCanvasDimension({ input }), + onStartEditing: () => { + setPendingValue(value); + }, onPreview: (nextValue) => { - pendingValueRef.current = nextValue; + setPendingValue(nextValue); }, onCommit: () => { - if (pendingValueRef.current !== value) { - onCommit(pendingValueRef.current); + if (pendingValue !== value) { + onCommit(pendingValue); } }, }); @@ -212,7 +213,14 @@ export function SettingsView() { contentClassName="px-0" scrollClassName="pt-0" actions={ - <Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}> + <Tabs + value={view} + onValueChange={(value) => { + if (isSettingsView(value)) { + setView(value); + } + }} + > <TabsList> <TabsTrigger value="project-info">Project info</TabsTrigger> <TabsTrigger value="background">Background</TabsTrigger> diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts index f6216208..91d29fd7 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts @@ -1,96 +1,96 @@ -import { useEditor } from "@/editor/use-editor";
-import {
- getKeyframeAtTime,
- hasKeyframesForPath,
- upsertElementKeyframe,
-} from "@/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
-import type { TimelineElement } from "@/timeline";
-import type { MediaTime } from "@/wasm";
-
-export function useKeyframedColorProperty({
- trackId,
- elementId,
- animations,
- propertyPath,
- localTime,
- isPlayheadWithinElementRange,
- resolvedColor,
- buildBaseUpdates,
-}: {
- trackId: string;
- elementId: string;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
- localTime: MediaTime;
- isPlayheadWithinElementRange: boolean;
- resolvedColor: string;
- buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
-}) {
- const editor = useEditor();
-
- const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
- const keyframeAtTime = isPlayheadWithinElementRange
- ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
- : null;
- const keyframeIdAtTime = keyframeAtTime?.id ?? null;
- const isKeyframedAtTime = keyframeAtTime !== null;
- const shouldUseAnimatedChannel =
- hasAnimatedKeyframes && isPlayheadWithinElementRange;
-
- const onChange = ({ color }: { color: string }) => {
- if (shouldUseAnimatedChannel) {
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: {
- animations: upsertElementKeyframe({
- animations,
- propertyPath,
- time: localTime,
- value: color,
- }),
- },
- },
- ],
- });
- return;
- }
-
- editor.timeline.previewElements({
- updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
- });
- };
-
- const onChangeEnd = () => editor.timeline.commitPreview();
-
- const toggleKeyframe = () => {
- if (!isPlayheadWithinElementRange) {
- return;
- }
-
- if (keyframeIdAtTime) {
- editor.timeline.removeKeyframes({
- keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
- });
- return;
- }
-
- editor.timeline.upsertKeyframes({
- keyframes: [
- { trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
- ],
- });
- };
-
- return {
- isKeyframedAtTime,
- hasAnimatedKeyframes,
- keyframeIdAtTime,
- onChange,
- onChangeEnd,
- toggleKeyframe,
- };
-}
+import { useEditor } from "@/editor/use-editor"; +import { + getKeyframeAtTime, + hasKeyframesForPath, + upsertElementKeyframe, +} from "@/animation"; +import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; +import type { TimelineElement } from "@/timeline"; +import type { MediaTime } from "@/wasm"; + +export function useKeyframedColorProperty({ + trackId, + elementId, + animations, + propertyPath, + localTime, + isPlayheadWithinElementRange, + resolvedColor, + buildBaseUpdates, +}: { + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: MediaTime; + isPlayheadWithinElementRange: boolean; + resolvedColor: string; + buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>; +}) { + const editor = useEditor(); + + const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const onChange = ({ color }: { color: string }) => { + if (shouldUseAnimatedChannel) { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + animations: upsertElementKeyframe({ + animations, + propertyPath, + time: localTime, + value: color, + }), + }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }], + }); + }; + + const onChangeEnd = () => editor.timeline.commitPreview(); + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { trackId, elementId, propertyPath, time: localTime, value: resolvedColor }, + ], + }); + }; + + return { + isKeyframedAtTime, + hasAnimatedKeyframes, + keyframeIdAtTime, + onChange, + onChangeEnd, + toggleKeyframe, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts index d52f7d97..610c2253 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts @@ -1,175 +1,175 @@ -import { useEditor } from "@/editor/use-editor";
-import {
- getKeyframeAtTime,
- hasKeyframesForPath,
- upsertElementKeyframe,
-} from "@/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
-import type { TimelineElement } from "@/timeline";
-import { snapToStep } from "@/utils/math";
-import { usePropertyDraft } from "./use-property-draft";
-import type { MediaTime } from "@/wasm";
-
-export function useKeyframedNumberProperty({
- trackId,
- elementId,
- animations,
- propertyPath,
- localTime,
- isPlayheadWithinElementRange,
- displayValue,
- parse,
- valueAtPlayhead,
- step,
- buildBaseUpdates,
- buildAdditionalKeyframes,
-}: {
- trackId: string;
- elementId: string;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
- localTime: MediaTime;
- isPlayheadWithinElementRange: boolean;
- displayValue: string;
- parse: (input: string) => number | null;
- valueAtPlayhead: number;
- step?: number;
- buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
- buildAdditionalKeyframes?: ({
- value,
- }: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>;
-}) {
- const editor = useEditor();
- const snapValue = (value: number) =>
- step != null ? snapToStep({ value, step }) : value;
-
- const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
- const keyframeAtTime = isPlayheadWithinElementRange
- ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
- : null;
- const keyframeIdAtTime = keyframeAtTime?.id ?? null;
- const isKeyframedAtTime = keyframeAtTime !== null;
- const shouldUseAnimatedChannel =
- hasAnimatedKeyframes && isPlayheadWithinElementRange;
-
- const previewValue = ({ value }: { value: number }) => {
- const nextValue = snapValue(value);
- if (shouldUseAnimatedChannel) {
- const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
- const updatedAnimations = [
- { propertyPath, value: nextValue },
- ...additionalKeyframes,
- ].reduce(
- (currentAnimations, keyframe) =>
- upsertElementKeyframe({
- animations: currentAnimations,
- propertyPath: keyframe.propertyPath,
- time: localTime,
- value: keyframe.value,
- }),
- animations,
- );
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: { animations: updatedAnimations },
- },
- ],
- });
- return;
- }
-
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: buildBaseUpdates({ value: nextValue }),
- },
- ],
- });
- };
-
- const propertyDraft = usePropertyDraft({
- displayValue,
- parse: (input) => {
- const parsedValue = parse(input);
- return parsedValue === null ? null : snapValue(parsedValue);
- },
- onPreview: (value) => previewValue({ value }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- const toggleKeyframe = () => {
- if (!isPlayheadWithinElementRange) {
- return;
- }
-
- if (keyframeIdAtTime) {
- editor.timeline.removeKeyframes({
- keyframes: [
- {
- trackId,
- elementId,
- propertyPath,
- keyframeId: keyframeIdAtTime,
- },
- ],
- });
- return;
- }
-
- editor.timeline.upsertKeyframes({
- keyframes: [
- {
- trackId,
- elementId,
- propertyPath,
- time: localTime,
- value: snapValue(valueAtPlayhead),
- },
- ],
- });
- };
-
- const commitValue = ({ value }: { value: number }) => {
- const nextValue = snapValue(value);
- if (shouldUseAnimatedChannel) {
- const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
- editor.timeline.upsertKeyframes({
- keyframes: [
- { trackId, elementId, propertyPath, time: localTime, value: nextValue },
- ...additionalKeyframes.map((keyframe) => ({
- trackId,
- elementId,
- propertyPath: keyframe.propertyPath,
- time: localTime,
- value: keyframe.value,
- })),
- ],
- });
- return;
- }
-
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId,
- patch: buildBaseUpdates({ value: nextValue }),
- },
- ],
- });
- };
-
- return {
- ...propertyDraft,
- hasAnimatedKeyframes,
- isKeyframedAtTime,
- keyframeIdAtTime,
- toggleKeyframe,
- commitValue,
- };
-}
+import { useEditor } from "@/editor/use-editor"; +import { + getKeyframeAtTime, + hasKeyframesForPath, + upsertElementKeyframe, +} from "@/animation"; +import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; +import type { TimelineElement } from "@/timeline"; +import { snapToStep } from "@/utils/math"; +import { usePropertyDraft } from "./use-property-draft"; +import type { MediaTime } from "@/wasm"; + +export function useKeyframedNumberProperty({ + trackId, + elementId, + animations, + propertyPath, + localTime, + isPlayheadWithinElementRange, + displayValue, + parse, + valueAtPlayhead, + step, + buildBaseUpdates, + buildAdditionalKeyframes, +}: { + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: MediaTime; + isPlayheadWithinElementRange: boolean; + displayValue: string; + parse: (input: string) => number | null; + valueAtPlayhead: number; + step?: number; + buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>; + buildAdditionalKeyframes?: ({ + value, + }: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>; +}) { + const editor = useEditor(); + const snapValue = (value: number) => + step != null ? snapToStep({ value, step }) : value; + + const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const previewValue = ({ value }: { value: number }) => { + const nextValue = snapValue(value); + if (shouldUseAnimatedChannel) { + const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? []; + const updatedAnimations = [ + { propertyPath, value: nextValue }, + ...additionalKeyframes, + ].reduce( + (currentAnimations, keyframe) => + upsertElementKeyframe({ + animations: currentAnimations, + propertyPath: keyframe.propertyPath, + time: localTime, + value: keyframe.value, + }), + animations, + ); + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { animations: updatedAnimations }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: buildBaseUpdates({ value: nextValue }), + }, + ], + }); + }; + + const propertyDraft = usePropertyDraft({ + displayValue, + parse: (input) => { + const parsedValue = parse(input); + return parsedValue === null ? null : snapValue(parsedValue); + }, + onPreview: (value) => previewValue({ value }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + keyframeId: keyframeIdAtTime, + }, + ], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value: snapValue(valueAtPlayhead), + }, + ], + }); + }; + + const commitValue = ({ value }: { value: number }) => { + const nextValue = snapValue(value); + if (shouldUseAnimatedChannel) { + const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? []; + editor.timeline.upsertKeyframes({ + keyframes: [ + { trackId, elementId, propertyPath, time: localTime, value: nextValue }, + ...additionalKeyframes.map((keyframe) => ({ + trackId, + elementId, + propertyPath: keyframe.propertyPath, + time: localTime, + value: keyframe.value, + })), + ], + }); + return; + } + + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId, + patch: buildBaseUpdates({ value: nextValue }), + }, + ], + }); + }; + + return { + ...propertyDraft, + hasAnimatedKeyframes, + isKeyframedAtTime, + keyframeIdAtTime, + toggleKeyframe, + commitValue, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts index 0e7ae6a7..7e8f6e40 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts @@ -1,4 +1,4 @@ -import { useReducer, useRef } from "react";
+import { useState } from "react";
import { evaluateMathExpression } from "@/utils/math";
function looksLikeExpression({ input }: { input: string }): boolean {
@@ -14,59 +14,56 @@ export function usePropertyDraft<T>({ parse,
onPreview,
onCommit,
+ onStartEditing,
supportsExpressions = true,
}: {
displayValue: string;
parse: (input: string) => T | null;
onPreview: (value: T) => void;
onCommit: () => void;
+ onStartEditing?: () => void;
supportsExpressions?: boolean;
}) {
- const [, forceRender] = useReducer(
- (renderVersion: number) => renderVersion + 1,
- 0,
- );
- const isEditing = useRef(false);
- const draft = useRef("");
+ const [isEditing, setIsEditing] = useState(false);
+ const [draft, setDraft] = useState("");
return {
- displayValue: isEditing.current ? draft.current : sourceDisplay,
+ displayValue: isEditing ? draft : sourceDisplay,
scrubTo: (value: number) => {
const parsed = parse(String(value));
if (parsed !== null) onPreview(parsed);
},
commitScrub: onCommit,
onFocus: () => {
- isEditing.current = true;
- draft.current = sourceDisplay;
- forceRender();
+ setIsEditing(true);
+ setDraft(sourceDisplay);
+ onStartEditing?.();
},
onChange: (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
- draft.current = event.target.value;
- forceRender();
+ const nextDraft = event.target.value;
+ setDraft(nextDraft);
- const parsed = parse(event.target.value);
+ const parsed = parse(nextDraft);
if (parsed !== null) {
onPreview(parsed);
}
},
- onBlur: () => {
- if (
- supportsExpressions &&
- looksLikeExpression({ input: draft.current })
- ) {
- const evaluated = evaluateMathExpression({ input: draft.current });
+ onBlur: (
+ event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>,
+ ) => {
+ const nextDraft = event.target.value;
+ if (supportsExpressions && looksLikeExpression({ input: nextDraft })) {
+ const evaluated = evaluateMathExpression({ input: nextDraft });
if (evaluated !== null) {
const parsed = parse(String(evaluated));
if (parsed !== null) onPreview(parsed);
}
}
onCommit();
- isEditing.current = false;
- draft.current = "";
- forceRender();
+ setIsEditing(false);
+ setDraft("");
},
};
}
diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index 52fa42b5..d946027d 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -71,7 +71,12 @@ export function PropertiesPanel() { <Button variant={tab.id === activeTab.id ? "secondary" : "ghost"} size="icon" - onClick={() => setActiveTab(element.type, tab.id)} + onClick={() => + setActiveTab({ + elementType: element.type, + tabId: tab.id, + }) + } aria-label={tab.label} className={cn( "shrink-0", diff --git a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts index bd57d4b0..f6304349 100644 --- a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts +++ b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts @@ -2,17 +2,18 @@ import { create } from "zustand"; interface PropertiesState {
activeTabPerType: Record<string, string>;
- setActiveTab: (elementType: string, tabId: string) => void;
+ setActiveTab: (args: { elementType: string; tabId: string }) => void;
isTransformScaleLocked: boolean;
- setTransformScaleLocked: (locked: boolean) => void;
+ setTransformScaleLocked: (args: { locked: boolean }) => void;
}
export const usePropertiesStore = create<PropertiesState>()((set) => ({
activeTabPerType: {},
- setActiveTab: (elementType, tabId) =>
+ setActiveTab: ({ elementType, tabId }) =>
set((state) => ({
activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId },
})),
isTransformScaleLocked: false,
- setTransformScaleLocked: (locked) => set({ isTransformScaleLocked: locked }),
+ setTransformScaleLocked: ({ locked }) =>
+ set({ isTransformScaleLocked: locked }),
}));
diff --git a/apps/web/src/core/index.ts b/apps/web/src/core/index.ts index 3e55e1d6..0977666f 100644 --- a/apps/web/src/core/index.ts +++ b/apps/web/src/core/index.ts @@ -39,7 +39,7 @@ export class EditorCore { this.project = new ProjectManager(this); this.media = new MediaManager(this); this.renderer = new RendererManager(this); - this.save = new SaveManager(this); + this.save = new SaveManager({ editor: this }); this.audio = new AudioManager(this); this.selection = new SelectionManager(this); this.clipboard = new ClipboardManager(this); diff --git a/apps/web/src/core/managers/media-manager.ts b/apps/web/src/core/managers/media-manager.ts index 553d1557..69da6e80 100644 --- a/apps/web/src/core/managers/media-manager.ts +++ b/apps/web/src/core/managers/media-manager.ts @@ -68,9 +68,17 @@ export class MediaManager { const command = uniqueIds.length === 1 - ? new RemoveMediaAssetCommand(projectId, uniqueIds[0]) + ? new RemoveMediaAssetCommand({ + projectId, + assetId: uniqueIds[0], + }) : new BatchCommand( - uniqueIds.map((id) => new RemoveMediaAssetCommand(projectId, id)), + uniqueIds.map((id) => + new RemoveMediaAssetCommand({ + projectId, + assetId: id, + }), + ), ); this.editor.command.execute({ command }); diff --git a/apps/web/src/core/managers/save-manager.ts b/apps/web/src/core/managers/save-manager.ts index b3c894d4..8cb2275a 100644 --- a/apps/web/src/core/managers/save-manager.ts +++ b/apps/web/src/core/managers/save-manager.ts @@ -12,13 +12,18 @@ export class SaveManager { private saveTimer: ReturnType<typeof setTimeout> | null = null;
private unsubscribeHandlers: Array<() => void> = [];
- constructor(
- private editor: EditorCore,
- { debounceMs = 800 }: SaveManagerOptions = {},
- ) {
+ constructor({
+ editor,
+ debounceMs = 800,
+ }: {
+ editor: EditorCore;
+ } & SaveManagerOptions) {
+ this.editor = editor;
this.debounceMs = debounceMs;
}
+ private editor: EditorCore;
+
start(): void {
if (this.unsubscribeHandlers.length > 0) return;
diff --git a/apps/web/src/core/managers/scenes-manager.ts b/apps/web/src/core/managers/scenes-manager.ts index c9558a6d..a4c364ce 100644 --- a/apps/web/src/core/managers/scenes-manager.ts +++ b/apps/web/src/core/managers/scenes-manager.ts @@ -41,7 +41,7 @@ export class ScenesManager { throw new Error("No active project"); } - const command = new CreateSceneCommand(name, isMain); + const command = new CreateSceneCommand({ name, isMain }); this.editor.command.execute({ command }); return command.getSceneId(); } @@ -77,7 +77,10 @@ export class ScenesManager { throw new Error("No active project"); } - const command = new RenameSceneCommand(sceneId, name); + const command = new RenameSceneCommand({ + sceneId, + newName: name, + }); this.editor.command.execute({ command }); } @@ -138,7 +141,7 @@ export class ScenesManager { time: MediaTime; updates: Partial<Omit<Bookmark, "time">>; }): Promise<void> { - const command = new UpdateBookmarkCommand(time, updates); + const command = new UpdateBookmarkCommand({ time, updates }); this.editor.command.execute({ command }); } @@ -149,7 +152,7 @@ export class ScenesManager { fromTime: MediaTime; toTime: MediaTime; }): Promise<void> { - const command = new MoveBookmarkCommand(fromTime, toTime); + const command = new MoveBookmarkCommand({ fromTime, toTime }); this.editor.command.execute({ command }); } diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index 8fbfdb08..91a4eeef 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -73,7 +73,7 @@ export class TimelineManager { constructor(private editor: EditorCore) {} addTrack({ type, index }: { type: TrackType; index?: number }): string { - const command = new AddTrackCommand(type, index); + const command = new AddTrackCommand({ type, index }); this.editor.command.execute({ command }); return command.getTrackId(); } @@ -751,7 +751,10 @@ export class TimelineManager { } const afterTracks = this.previewTracks ?? this.applyPreviewOverlay(committedTracks); - const command = new TracksSnapshotCommand(committedTracks, afterTracks); + const command = new TracksSnapshotCommand({ + before: committedTracks, + after: afterTracks, + }); this.editor.command.push({ command }); this.previewOverlay.clear(); this.previewTracks = null; diff --git a/apps/web/src/editor/panel-store.ts b/apps/web/src/editor/panel-store.ts index 2cb768a1..5a2bd0f4 100644 --- a/apps/web/src/editor/panel-store.ts +++ b/apps/web/src/editor/panel-store.ts @@ -14,7 +14,7 @@ export type PanelId = keyof PanelSizes; interface PanelState {
panels: PanelSizes;
- setPanel: (panel: PanelId, size: number) => void;
+ setPanel: (args: { panel: PanelId; size: number }) => void;
setPanels: (sizes: Partial<PanelSizes>) => void;
resetPanels: () => void;
}
@@ -23,7 +23,7 @@ export const usePanelStore = create<PanelState>()( persist(
(set) => ({
...PANEL_CONFIG,
- setPanel: (panel, size) =>
+ setPanel: ({ panel, size }) =>
set((state) => ({
panels: {
...state.panels,
diff --git a/apps/web/src/editor/use-editor.ts b/apps/web/src/editor/use-editor.ts index ce48e036..6380d470 100644 --- a/apps/web/src/editor/use-editor.ts +++ b/apps/web/src/editor/use-editor.ts @@ -1,7 +1,15 @@ import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; import { EditorCore } from "@/core"; -function isShallowEqual(a: unknown, b: unknown): boolean { +const SNAPSHOT_UNSET = Symbol("snapshotUnset"); + +function isShallowEqual({ + a, + b, +}: { + a: unknown; + b: unknown; +}): boolean { if (Object.is(a, b)) return true; if (!Array.isArray(a) || !Array.isArray(b)) return false; if (a.length !== b.length) return false; @@ -16,10 +24,7 @@ export function useEditor<T>( selector?: (editor: EditorCore) => T, ): EditorCore | T { const editor = useMemo(() => EditorCore.getInstance(), []); - const selectorRef = useRef(selector); - selectorRef.current = selector; - - const snapshotCacheRef = useRef<unknown>(undefined); + const snapshotCacheRef = useRef<T | typeof SNAPSHOT_UNSET>(SNAPSHOT_UNSET); const subscribeAll = useCallback( (onChange: () => void) => { @@ -43,18 +48,29 @@ export function useEditor<T>( [editor], ); - const getSnapshot = useCallback(() => { - const next = selectorRef.current ? selectorRef.current(editor) : editor; - if (isShallowEqual(snapshotCacheRef.current, next)) { + const getSnapshot = useCallback((): EditorCore | T => { + if (!selector) { + return editor; + } + + const next = selector(editor); + if ( + snapshotCacheRef.current !== SNAPSHOT_UNSET && + isShallowEqual({ + a: snapshotCacheRef.current, + b: next, + }) + ) { return snapshotCacheRef.current; } + snapshotCacheRef.current = next; return next; - }, [editor]); + }, [editor, selector]); return useSyncExternalStore( selector ? subscribeAll : subscribeNone, getSnapshot, getSnapshot, - ) as EditorCore | T; + ); } diff --git a/apps/web/src/effects/definitions/index.ts b/apps/web/src/effects/definitions/index.ts index edc8c5e2..511df042 100644 --- a/apps/web/src/effects/definitions/index.ts +++ b/apps/web/src/effects/definitions/index.ts @@ -8,6 +8,9 @@ export function registerDefaultEffects(): void { if (effectsRegistry.has(definition.type)) { continue; } - effectsRegistry.register(definition.type, definition); + effectsRegistry.register({ + key: definition.type, + definition, + }); } } diff --git a/apps/web/src/fps/utils.ts b/apps/web/src/fps/utils.ts index e30e263a..c2a57fbb 100644 --- a/apps/web/src/fps/utils.ts +++ b/apps/web/src/fps/utils.ts @@ -22,7 +22,13 @@ export function frameRateToFloat(rate: FrameRate): number { return rate.numerator / rate.denominator; } -export function frameRatesEqual(a: FrameRate, b: FrameRate): boolean { +export function frameRatesEqual({ + a, + b, +}: { + a: FrameRate; + b: FrameRate; +}): boolean { return a.numerator === b.numerator && a.denominator === b.denominator; } @@ -38,14 +44,17 @@ export function floatToFrameRate(fps: number): FrameRate { const ARBITRARY_DENOMINATOR = 1_000_000; const scaledNumerator = Math.round(fps * ARBITRARY_DENOMINATOR); - const divisor = gcd(scaledNumerator, ARBITRARY_DENOMINATOR); + const divisor = gcd({ + left: scaledNumerator, + right: ARBITRARY_DENOMINATOR, + }); return { numerator: scaledNumerator / divisor, denominator: ARBITRARY_DENOMINATOR / divisor, }; } -function gcd(left: number, right: number): number { +function gcd({ left, right }: { left: number; right: number }): number { let a = Math.abs(left); let b = Math.abs(right); while (b !== 0) { diff --git a/apps/web/src/graphics/components/graphic-tab.tsx b/apps/web/src/graphics/components/graphic-tab.tsx index 13cd7b84..31c4a86d 100644 --- a/apps/web/src/graphics/components/graphic-tab.tsx +++ b/apps/web/src/graphics/components/graphic-tab.tsx @@ -1,239 +1,239 @@ -"use client";
-
-import { useRef } from "react";
-import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
-import {
- useKeyframedParamProperty,
- type KeyframedParamPropertyResult,
-} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
-import type { ParamDefinition, ParamValues } from "@/params";
-import type { GraphicElement } from "@/timeline";
-import { graphicsRegistry, registerDefaultGraphics, resolveGraphicElementParamsAtTime } from "@/graphics";
-import { useElementPreview } from "@/timeline/hooks/use-element-preview";
-import { useEditor } from "@/editor/use-editor";
-import {
- Section,
- SectionContent,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
-import { Button } from "@/components/ui/button";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
-import { cn } from "@/utils/ui";
-import type { MediaTime } from "@/wasm";
-
-registerDefaultGraphics();
-
-const DEFAULT_STROKE_WIDTH = 2;
-
-export function GraphicTab({
- element,
- trackId,
-}: {
- element: GraphicElement;
- trackId: string;
-}) {
- const definition = graphicsRegistry.get(element.definitionId);
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const { renderElement } = useElementPreview({
- trackId,
- elementId: element.id,
- fallback: element,
- });
-
- const liveElement = renderElement as GraphicElement;
- const resolvedParams = resolveGraphicElementParamsAtTime({
- element: liveElement,
- localTime,
- });
-
- const shapeParams = definition.params.filter((p) => p.group !== "stroke");
- const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
-
- return (
- <div className="flex flex-col">
- <Section collapsible sectionKey={`${element.id}:graphic`}>
- <SectionHeader>
- <SectionTitle>{definition.name}</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- {shapeParams.map((param) => (
- <AnimatedGraphicParamField
- key={param.key}
- param={param}
- trackId={trackId}
- element={liveElement}
- localTime={localTime}
- isPlayheadWithinElementRange={isPlayheadWithinElementRange}
- resolvedParams={resolvedParams}
- />
- ))}
- </SectionFields>
- </SectionContent>
- </Section>
- {hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
- </div>
- );
-}
-
-function StrokeSection({
- element,
- trackId,
-}: {
- element: GraphicElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const definition = graphicsRegistry.get(element.definitionId);
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const { renderElement } = useElementPreview({
- trackId,
- elementId: element.id,
- fallback: element,
- });
-
- const liveElement = renderElement as GraphicElement;
- const resolvedParams = resolveGraphicElementParamsAtTime({
- element: liveElement,
- localTime,
- });
- const strokeParams = definition.params.filter((p) => p.group === "stroke");
- const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
- const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
-
- const toggleStroke = () => {
- if (isStrokeEnabled) {
- lastStrokeWidth.current = Number(
- element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
- );
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: { params: { ...element.params, strokeWidth: 0 } },
- },
- ],
- });
- } else {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: {
- params: {
- ...element.params,
- strokeWidth: lastStrokeWidth.current,
- },
- },
- },
- ],
- });
- }
- };
-
- return (
- <Section
- collapsible
- defaultOpen={isStrokeEnabled}
- sectionKey={`${element.id}:stroke`}
- >
- <SectionHeader
- trailing={
- <Button
- variant="ghost"
- size="icon"
- onClick={(event) => {
- event.stopPropagation();
- toggleStroke();
- }}
- >
- <HugeiconsIcon
- icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
- strokeWidth={1}
- />
- </Button>
- }
- >
- <SectionTitle>Stroke</SectionTitle>
- </SectionHeader>
- <SectionContent
- className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
- >
- <SectionFields>
- {strokeParams.map((param) => (
- <AnimatedGraphicParamField
- key={param.key}
- param={param}
- trackId={trackId}
- element={liveElement}
- localTime={localTime}
- isPlayheadWithinElementRange={isPlayheadWithinElementRange}
- resolvedParams={resolvedParams}
- />
- ))}
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
-
-function AnimatedGraphicParamField({
- param,
- trackId,
- element,
- localTime,
- isPlayheadWithinElementRange,
- resolvedParams,
-}: {
- key?: string;
- param: ParamDefinition;
- trackId: string;
- element: GraphicElement;
- localTime: MediaTime;
- isPlayheadWithinElementRange: boolean;
- resolvedParams: ParamValues;
-}) {
- const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
- {
- param,
- trackId,
- elementId: element.id,
- animations: element.animations,
- localTime,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedParams[param.key] ?? param.default,
- buildBaseUpdates: ({ value }) => ({
- params: {
- ...element.params,
- [param.key]: value,
- },
- }),
- },
- );
-
- return (
- <PropertyParamField
- param={param}
- value={resolvedParams[param.key] ?? param.default}
- onPreview={animatedParam.onPreview}
- onCommit={animatedParam.onCommit}
- keyframe={{
- isActive: animatedParam.isKeyframedAtTime,
- isDisabled: !isPlayheadWithinElementRange,
- onToggle: animatedParam.toggleKeyframe,
- }}
- />
- );
-}
+"use client"; + +import { useRef } from "react"; +import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; +import { + useKeyframedParamProperty, + type KeyframedParamPropertyResult, +} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property"; +import type { ParamDefinition, ParamValues } from "@/params"; +import type { GraphicElement } from "@/timeline"; +import { graphicsRegistry, registerDefaultGraphics, resolveGraphicElementParamsAtTime } from "@/graphics"; +import { useElementPreview } from "@/timeline/hooks/use-element-preview"; +import { useEditor } from "@/editor/use-editor"; +import { + Section, + SectionContent, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field"; +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; +import { cn } from "@/utils/ui"; +import type { MediaTime } from "@/wasm"; + +registerDefaultGraphics(); + +const DEFAULT_STROKE_WIDTH = 2; + +export function GraphicTab({ + element, + trackId, +}: { + element: GraphicElement; + trackId: string; +}) { + const definition = graphicsRegistry.get(element.definitionId); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const { renderElement } = useElementPreview({ + trackId, + elementId: element.id, + fallback: element, + }); + + const liveElement = renderElement as GraphicElement; + const resolvedParams = resolveGraphicElementParamsAtTime({ + element: liveElement, + localTime, + }); + + const shapeParams = definition.params.filter((p) => p.group !== "stroke"); + const hasStrokeParams = definition.params.some((p) => p.group === "stroke"); + + return ( + <div className="flex flex-col"> + <Section collapsible sectionKey={`${element.id}:graphic`}> + <SectionHeader> + <SectionTitle>{definition.name}</SectionTitle> + </SectionHeader> + <SectionContent> + <SectionFields> + {shapeParams.map((param) => ( + <AnimatedGraphicParamField + key={param.key} + param={param} + trackId={trackId} + element={liveElement} + localTime={localTime} + isPlayheadWithinElementRange={isPlayheadWithinElementRange} + resolvedParams={resolvedParams} + /> + ))} + </SectionFields> + </SectionContent> + </Section> + {hasStrokeParams && <StrokeSection element={element} trackId={trackId} />} + </div> + ); +} + +function StrokeSection({ + element, + trackId, +}: { + element: GraphicElement; + trackId: string; +}) { + const editor = useEditor(); + const definition = graphicsRegistry.get(element.definitionId); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const { renderElement } = useElementPreview({ + trackId, + elementId: element.id, + fallback: element, + }); + + const liveElement = renderElement as GraphicElement; + const resolvedParams = resolveGraphicElementParamsAtTime({ + element: liveElement, + localTime, + }); + const strokeParams = definition.params.filter((p) => p.group === "stroke"); + const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH); + const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0; + + const toggleStroke = () => { + if (isStrokeEnabled) { + lastStrokeWidth.current = Number( + element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH, + ); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + patch: { params: { ...element.params, strokeWidth: 0 } }, + }, + ], + }); + } else { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + patch: { + params: { + ...element.params, + strokeWidth: lastStrokeWidth.current, + }, + }, + }, + ], + }); + } + }; + + return ( + <Section + collapsible + defaultOpen={isStrokeEnabled} + sectionKey={`${element.id}:stroke`} + > + <SectionHeader + trailing={ + <Button + variant="ghost" + size="icon" + onClick={(event) => { + event.stopPropagation(); + toggleStroke(); + }} + > + <HugeiconsIcon + icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon} + strokeWidth={1} + /> + </Button> + } + > + <SectionTitle>Stroke</SectionTitle> + </SectionHeader> + <SectionContent + className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")} + > + <SectionFields> + {strokeParams.map((param) => ( + <AnimatedGraphicParamField + key={param.key} + param={param} + trackId={trackId} + element={liveElement} + localTime={localTime} + isPlayheadWithinElementRange={isPlayheadWithinElementRange} + resolvedParams={resolvedParams} + /> + ))} + </SectionFields> + </SectionContent> + </Section> + ); +} + +function AnimatedGraphicParamField({ + param, + trackId, + element, + localTime, + isPlayheadWithinElementRange, + resolvedParams, +}: { + key?: string; + param: ParamDefinition; + trackId: string; + element: GraphicElement; + localTime: MediaTime; + isPlayheadWithinElementRange: boolean; + resolvedParams: ParamValues; +}) { + const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty( + { + param, + trackId, + elementId: element.id, + animations: element.animations, + localTime, + isPlayheadWithinElementRange, + resolvedValue: resolvedParams[param.key] ?? param.default, + buildBaseUpdates: ({ value }) => ({ + params: { + ...element.params, + [param.key]: value, + }, + }), + }, + ); + + return ( + <PropertyParamField + param={param} + value={resolvedParams[param.key] ?? param.default} + onPreview={animatedParam.onPreview} + onCommit={animatedParam.onCommit} + keyframe={{ + isActive: animatedParam.isKeyframedAtTime, + isDisabled: !isPlayheadWithinElementRange, + onToggle: animatedParam.toggleKeyframe, + }} + /> + ); +} diff --git a/apps/web/src/graphics/definitions/index.ts b/apps/web/src/graphics/definitions/index.ts index 6fd9e446..846a9983 100644 --- a/apps/web/src/graphics/definitions/index.ts +++ b/apps/web/src/graphics/definitions/index.ts @@ -16,7 +16,10 @@ export function registerDefaultGraphics(): void { if (graphicsRegistry.has(definition.id)) {
continue;
}
- graphicsRegistry.register(definition.id, definition);
+ graphicsRegistry.register({
+ key: definition.id,
+ definition,
+ });
}
}
diff --git a/apps/web/src/graphics/index.ts b/apps/web/src/graphics/index.ts index 3f277d40..7b8d905e 100644 --- a/apps/web/src/graphics/index.ts +++ b/apps/web/src/graphics/index.ts @@ -1,149 +1,155 @@ -import { resolveGraphicParamsAtTime } from "@/animation";
-import type { ElementAnimations } from "@/animation/types";
-import { buildDefaultParamValues } from "@/params/registry";
-import type { ParamValues } from "@/params";
-import { graphicsRegistry } from "./registry";
-import {
- registerDefaultGraphics,
- ellipseGraphicDefinition,
- polygonGraphicDefinition,
- rectangleGraphicDefinition,
- starGraphicDefinition,
-} from "./definitions";
-import {
- DEFAULT_GRAPHIC_SOURCE_SIZE,
- type GraphicInstance,
- type GraphicDefinition,
-} from "./types";
-
-const graphicPreviewUrlCache = new Map<string, string>();
-
-const FALLBACK_CORNER_RADIUS_RATIO = 0.2;
-const FALLBACK_FILL_OPACITY = 0.08;
-const FALLBACK_MIN_FONT_SIZE = 12;
-const FALLBACK_FONT_SIZE_RATIO = 0.15;
-
-function buildFallbackPreviewUrl({
- name,
- size,
-}: {
- name: string;
- size: number;
-}): string {
- const svg = `
- <svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
- <rect width="${size}" height="${size}" rx="${size * FALLBACK_CORNER_RADIUS_RATIO}" fill="white" fill-opacity="${FALLBACK_FILL_OPACITY}" />
- <text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="white" font-size="${Math.max(FALLBACK_MIN_FONT_SIZE, size * FALLBACK_FONT_SIZE_RATIO)}" font-family="sans-serif">${name}</text>
- </svg>
- `;
- return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
-}
-
-export function getGraphicDefinition({
- definitionId,
-}: {
- definitionId: string;
-}): GraphicDefinition {
- registerDefaultGraphics();
- return graphicsRegistry.get(definitionId);
-}
-
-export function buildDefaultGraphicInstance({
- definitionId,
-}: {
- definitionId: string;
-}): GraphicInstance {
- const definition = getGraphicDefinition({ definitionId });
- return {
- definitionId,
- params: buildDefaultParamValues(definition.params),
- };
-}
-
-export function resolveGraphicParams(
- definition: GraphicDefinition,
- params?: ParamValues,
-): ParamValues {
- return {
- ...buildDefaultParamValues(definition.params),
- ...(params ?? {}),
- };
-}
-
-export function resolveGraphicElementParamsAtTime({
- element,
- localTime,
-}: {
- element: {
- definitionId: string;
- params: ParamValues;
- animations?: ElementAnimations;
- };
- localTime: number;
-}): ParamValues {
- const definition = getGraphicDefinition({
- definitionId: element.definitionId,
- });
- return resolveGraphicParamsAtTime({
- params: resolveGraphicParams(definition, element.params),
- definitions: definition.params,
- animations: element.animations,
- localTime,
- });
-}
-
-export function buildGraphicPreviewUrl({
- definitionId,
- params,
- size = DEFAULT_GRAPHIC_SOURCE_SIZE,
-}: {
- definitionId: string;
- params?: ParamValues;
- size?: number;
-}): string {
- const definition = getGraphicDefinition({ definitionId });
- const resolvedParams = resolveGraphicParams(definition, params);
- const cacheKey = JSON.stringify({ definitionId, resolvedParams, size });
- const cachedUrl = graphicPreviewUrlCache.get(cacheKey);
- if (cachedUrl) {
- return cachedUrl;
- }
-
- if (typeof document === "undefined") {
- return buildFallbackPreviewUrl({ name: definition.name, size });
- }
-
- const canvas = document.createElement("canvas");
- canvas.width = size;
- canvas.height = size;
- const ctx = canvas.getContext("2d");
- if (!ctx) {
- return buildFallbackPreviewUrl({ name: definition.name, size });
- }
-
- definition.render({
- ctx,
- params: resolvedParams,
- width: size,
- height: size,
- });
-
- const previewUrl = canvas.toDataURL("image/png");
- graphicPreviewUrlCache.set(cacheKey, previewUrl);
- return previewUrl;
-}
-
-export {
- DEFAULT_GRAPHIC_SOURCE_SIZE,
- ellipseGraphicDefinition,
- graphicsRegistry,
- polygonGraphicDefinition,
- rectangleGraphicDefinition,
- registerDefaultGraphics,
- starGraphicDefinition,
-};
-export type {
- GraphicDefinition,
- GraphicInstance,
- GraphicRenderContext,
-} from "./types";
+import { resolveGraphicParamsAtTime } from "@/animation"; +import type { ElementAnimations } from "@/animation/types"; +import { buildDefaultParamValues } from "@/params/registry"; +import type { ParamValues } from "@/params"; +import { graphicsRegistry } from "./registry"; +import { + registerDefaultGraphics, + ellipseGraphicDefinition, + polygonGraphicDefinition, + rectangleGraphicDefinition, + starGraphicDefinition, +} from "./definitions"; +import { + DEFAULT_GRAPHIC_SOURCE_SIZE, + type GraphicInstance, + type GraphicDefinition, +} from "./types"; + +const graphicPreviewUrlCache = new Map<string, string>(); + +const FALLBACK_CORNER_RADIUS_RATIO = 0.2; +const FALLBACK_FILL_OPACITY = 0.08; +const FALLBACK_MIN_FONT_SIZE = 12; +const FALLBACK_FONT_SIZE_RATIO = 0.15; + +function buildFallbackPreviewUrl({ + name, + size, +}: { + name: string; + size: number; +}): string { + const svg = ` + <svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"> + <rect width="${size}" height="${size}" rx="${size * FALLBACK_CORNER_RADIUS_RATIO}" fill="white" fill-opacity="${FALLBACK_FILL_OPACITY}" /> + <text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="white" font-size="${Math.max(FALLBACK_MIN_FONT_SIZE, size * FALLBACK_FONT_SIZE_RATIO)}" font-family="sans-serif">${name}</text> + </svg> + `; + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} + +export function getGraphicDefinition({ + definitionId, +}: { + definitionId: string; +}): GraphicDefinition { + registerDefaultGraphics(); + return graphicsRegistry.get(definitionId); +} + +export function buildDefaultGraphicInstance({ + definitionId, +}: { + definitionId: string; +}): GraphicInstance { + const definition = getGraphicDefinition({ definitionId }); + return { + definitionId, + params: buildDefaultParamValues(definition.params), + }; +} + +export function resolveGraphicParams({ + definition, + params, +}: { + definition: GraphicDefinition; + params?: ParamValues; +}): ParamValues { + return { + ...buildDefaultParamValues(definition.params), + ...(params ?? {}), + }; +} + +export function resolveGraphicElementParamsAtTime({ + element, + localTime, +}: { + element: { + definitionId: string; + params: ParamValues; + animations?: ElementAnimations; + }; + localTime: number; +}): ParamValues { + const definition = getGraphicDefinition({ + definitionId: element.definitionId, + }); + return resolveGraphicParamsAtTime({ + params: resolveGraphicParams({ + definition, + params: element.params, + }), + definitions: definition.params, + animations: element.animations, + localTime, + }); +} + +export function buildGraphicPreviewUrl({ + definitionId, + params, + size = DEFAULT_GRAPHIC_SOURCE_SIZE, +}: { + definitionId: string; + params?: ParamValues; + size?: number; +}): string { + const definition = getGraphicDefinition({ definitionId }); + const resolvedParams = resolveGraphicParams({ definition, params }); + const cacheKey = JSON.stringify({ definitionId, resolvedParams, size }); + const cachedUrl = graphicPreviewUrlCache.get(cacheKey); + if (cachedUrl) { + return cachedUrl; + } + + if (typeof document === "undefined") { + return buildFallbackPreviewUrl({ name: definition.name, size }); + } + + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d"); + if (!ctx) { + return buildFallbackPreviewUrl({ name: definition.name, size }); + } + + definition.render({ + ctx, + params: resolvedParams, + width: size, + height: size, + }); + + const previewUrl = canvas.toDataURL("image/png"); + graphicPreviewUrlCache.set(cacheKey, previewUrl); + return previewUrl; +} + +export { + DEFAULT_GRAPHIC_SOURCE_SIZE, + ellipseGraphicDefinition, + graphicsRegistry, + polygonGraphicDefinition, + rectangleGraphicDefinition, + registerDefaultGraphics, + starGraphicDefinition, +}; +export type { + GraphicDefinition, + GraphicInstance, + GraphicRenderContext, +} from "./types"; diff --git a/apps/web/src/hooks/use-committed-ref.ts b/apps/web/src/hooks/use-committed-ref.ts new file mode 100644 index 00000000..ff064985 --- /dev/null +++ b/apps/web/src/hooks/use-committed-ref.ts @@ -0,0 +1,11 @@ +import { useLayoutEffect, useRef } from "react"; + +export function useCommittedRef<T>(value: T) { + const ref = useRef(value); + + useLayoutEffect(() => { + ref.current = value; + }, [value]); + + return ref; +} diff --git a/apps/web/src/hooks/use-focus-lock.ts b/apps/web/src/hooks/use-focus-lock.ts index 236d2623..86092aec 100644 --- a/apps/web/src/hooks/use-focus-lock.ts +++ b/apps/web/src/hooks/use-focus-lock.ts @@ -1,4 +1,5 @@ import { useEffect, useRef } from "react";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
type FocusLockCursor = "text" | "default" | "pointer" | "crosshair";
@@ -37,8 +38,7 @@ export function useFocusLock<T extends HTMLElement = HTMLElement>({ allowSelector?: string;
}) {
const containerRef = useRef<T>(null);
- const onDismissRef = useRef(onDismiss);
- onDismissRef.current = onDismiss;
+ const onDismissRef = useCommittedRef(onDismiss);
useEffect(() => {
if (!isActive) return;
@@ -53,10 +53,13 @@ export function useFocusLock<T extends HTMLElement = HTMLElement>({ const handleOutsidePointerDown = (event: PointerEvent) => {
if (event.button !== 0) return;
- if (container.contains(event.target as Node)) return;
+ const target = event.target;
+ if (target instanceof Node && container.contains(target)) return;
- const target = event.target as Element | null;
- const isAllowedTarget = allowSelector && target?.closest(allowSelector);
+ const isAllowedTarget =
+ allowSelector &&
+ target instanceof Element &&
+ target.closest(allowSelector);
if (isAllowedTarget) return;
onDismissRef.current();
@@ -73,7 +76,7 @@ export function useFocusLock<T extends HTMLElement = HTMLElement>({ container.removeAttribute(DATA_ATTR);
focusLockStyle.remove();
};
- }, [isActive, cursor, allowSelector]);
+ }, [isActive, cursor, allowSelector, onDismissRef]);
return { containerRef };
}
diff --git a/apps/web/src/masks/custom-path.ts b/apps/web/src/masks/custom-path.ts index 0349df63..b35702f6 100644 --- a/apps/web/src/masks/custom-path.ts +++ b/apps/web/src/masks/custom-path.ts @@ -330,13 +330,27 @@ function clampUnit(value: number): number { return Math.min(1, Math.max(0, value)); } -function getDistanceSquared(a: CanvasPoint, b: CanvasPoint): number { +function getDistanceSquared({ + a, + b, +}: { + a: CanvasPoint; + b: CanvasPoint; +}): number { const dx = a.x - b.x; const dy = a.y - b.y; return dx * dx + dy * dy; } -function lerpPoint(a: CanvasPoint, b: CanvasPoint, t: number): CanvasPoint { +function lerpPoint({ + a, + b, + t, +}: { + a: CanvasPoint; + b: CanvasPoint; + t: number; +}): CanvasPoint { return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, @@ -483,7 +497,10 @@ export function findClosestPointOnCustomMaskSegment({ const sampleCount = 24; let bestT = 0; - let bestDistanceSquared = getDistanceSquared(canvasPoint, segment.start); + let bestDistanceSquared = getDistanceSquared({ + a: canvasPoint, + b: segment.start, + }); for (let step = 0; step <= sampleCount; step++) { const t = step / sampleCount; @@ -494,7 +511,7 @@ export function findClosestPointOnCustomMaskSegment({ p3: segment.end, t, }); - const distanceSquared = getDistanceSquared(canvasPoint, point); + const distanceSquared = getDistanceSquared({ a: canvasPoint, b: point }); if (distanceSquared < bestDistanceSquared) { bestDistanceSquared = distanceSquared; bestT = t; @@ -516,7 +533,10 @@ export function findClosestPointOnCustomMaskSegment({ }), })); for (const candidate of candidates) { - const distanceSquared = getDistanceSquared(canvasPoint, candidate.point); + const distanceSquared = getDistanceSquared({ + a: canvasPoint, + b: candidate.point, + }); if (distanceSquared < bestDistanceSquared) { bestDistanceSquared = distanceSquared; bestT = candidate.t; @@ -573,12 +593,12 @@ export function insertPointIntoCustomMaskSegment({ y: endPoint.y + endPoint.inY, }; const p3 = { x: endPoint.x, y: endPoint.y }; - const p01 = lerpPoint(p0, p1, clampedT); - const p12 = lerpPoint(p1, p2, clampedT); - const p23 = lerpPoint(p2, p3, clampedT); - const p012 = lerpPoint(p01, p12, clampedT); - const p123 = lerpPoint(p12, p23, clampedT); - const splitPoint = lerpPoint(p012, p123, clampedT); + const p01 = lerpPoint({ a: p0, b: p1, t: clampedT }); + const p12 = lerpPoint({ a: p1, b: p2, t: clampedT }); + const p23 = lerpPoint({ a: p2, b: p3, t: clampedT }); + const p012 = lerpPoint({ a: p01, b: p12, t: clampedT }); + const p123 = lerpPoint({ a: p12, b: p23, t: clampedT }); + const splitPoint = lerpPoint({ a: p012, b: p123, t: clampedT }); const nextPoints = [...points]; nextPoints[indices.startIndex] = { diff --git a/apps/web/src/masks/definitions/split.ts b/apps/web/src/masks/definitions/split.ts index d50a2571..d8fdc789 100644 --- a/apps/web/src/masks/definitions/split.ts +++ b/apps/web/src/masks/definitions/split.ts @@ -53,10 +53,13 @@ function splitLineGeometry({ return { normalX, normalY, lineX, lineY }; } -function pointsEqual( - a: { x: number; y: number }, - b: { x: number; y: number }, -): boolean { +function pointsEqual({ + a, + b, +}: { + a: { x: number; y: number }; + b: { x: number; y: number }; +}): boolean { return ( Math.abs(a.x - b.x) <= INTERSECTION_EPSILON && Math.abs(a.y - b.y) <= INTERSECTION_EPSILON @@ -101,7 +104,7 @@ export function getSplitMaskStrokeSegment({ y2, }); - if (!hit || intersections.some((point) => pointsEqual(point, hit))) { + if (!hit || intersections.some((point) => pointsEqual({ a: point, b: hit }))) { continue; } @@ -307,13 +310,18 @@ export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = { [0, height, 0, 0], ]; - const isInsideHalfPlane = (x: number, y: number) => - halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0; + const isInsideHalfPlane = ({ + x, + y, + }: { + x: number; + y: number; + }) => halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0; const vertices: [number, number][] = []; for (const [x1, y1, x2, y2] of edges) { - const isVertex1Inside = isInsideHalfPlane(x1, y1); - const isVertex2Inside = isInsideHalfPlane(x2, y2); + const isVertex1Inside = isInsideHalfPlane({ x: x1, y: y1 }); + const isVertex2Inside = isInsideHalfPlane({ x: x2, y: y2 }); if (isVertex1Inside && isVertex2Inside) { vertices.push([x2, y2]); diff --git a/apps/web/src/masks/registry.ts b/apps/web/src/masks/registry.ts index 00b276c8..0bcf1cf0 100644 --- a/apps/web/src/masks/registry.ts +++ b/apps/web/src/masks/registry.ts @@ -115,7 +115,10 @@ export class MasksRegistry extends DefinitionRegistry< }, icon, }; - this.register(definition.type, withBaseParams); + this.register({ + key: definition.type, + definition: withBaseParams, + }); } } diff --git a/apps/web/src/media/use-paste-media.ts b/apps/web/src/media/use-paste-media.ts index 387b2b9a..7dbd4078 100644 --- a/apps/web/src/media/use-paste-media.ts +++ b/apps/web/src/media/use-paste-media.ts @@ -68,10 +68,10 @@ export function usePasteMedia() { const startTime = editor.playback.getCurrentTime(); for (const asset of processedAssets) { - const addMediaCmd = new AddMediaAssetCommand( - activeProject.metadata.id, + const addMediaCmd = new AddMediaAssetCommand({ + projectId: activeProject.metadata.id, asset, - ); + }); const assetId = addMediaCmd.getAssetId(); const duration = asset.duration != null diff --git a/apps/web/src/params/index.ts b/apps/web/src/params/index.ts index 633bfbd0..23085407 100644 --- a/apps/web/src/params/index.ts +++ b/apps/web/src/params/index.ts @@ -1,50 +1,50 @@ -export type ParamValues = Record<string, number | string | boolean>;
-
-export type ParamGroup = "stroke";
-
-interface BaseParamDefinition<TKey extends string = string> {
- key: TKey;
- label: string;
- group?: ParamGroup;
-}
-
-export interface NumberParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "number";
- default: number;
- min: number;
- max?: number;
- step: number;
- /** When set, min/max/step are in display space. display = stored * displayMultiplier. */
- displayMultiplier?: number;
- /** Show as percentage of max. min/max/step/default stay in stored space. */
- unit?: "percent";
- /** Short label shown as the scrub handle icon in the number field (e.g. "W", "R"). */
- shortLabel?: string;
-}
-
-export interface BooleanParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "boolean";
- default: boolean;
-}
-
-export interface ColorParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "color";
- default: string;
-}
-
-export interface SelectParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "select";
- default: string;
- options: Array<{ value: string; label: string }>;
-}
-
-export type ParamDefinition<TKey extends string = string> =
- | NumberParamDefinition<TKey>
- | BooleanParamDefinition<TKey>
- | ColorParamDefinition<TKey>
- | SelectParamDefinition<TKey>;
-
+export type ParamValues = Record<string, number | string | boolean>; + +export type ParamGroup = "stroke"; + +interface BaseParamDefinition<TKey extends string = string> { + key: TKey; + label: string; + group?: ParamGroup; +} + +export interface NumberParamDefinition<TKey extends string = string> + extends BaseParamDefinition<TKey> { + type: "number"; + default: number; + min: number; + max?: number; + step: number; + /** When set, min/max/step are in display space. display = stored * displayMultiplier. */ + displayMultiplier?: number; + /** Show as percentage of max. min/max/step/default stay in stored space. */ + unit?: "percent"; + /** Short label shown as the scrub handle icon in the number field (e.g. "W", "R"). */ + shortLabel?: string; +} + +export interface BooleanParamDefinition<TKey extends string = string> + extends BaseParamDefinition<TKey> { + type: "boolean"; + default: boolean; +} + +export interface ColorParamDefinition<TKey extends string = string> + extends BaseParamDefinition<TKey> { + type: "color"; + default: string; +} + +export interface SelectParamDefinition<TKey extends string = string> + extends BaseParamDefinition<TKey> { + type: "select"; + default: string; + options: Array<{ value: string; label: string }>; +} + +export type ParamDefinition<TKey extends string = string> = + | NumberParamDefinition<TKey> + | BooleanParamDefinition<TKey> + | ColorParamDefinition<TKey> + | SelectParamDefinition<TKey>; + diff --git a/apps/web/src/params/registry.ts b/apps/web/src/params/registry.ts index d9fcc266..3ce483e4 100644 --- a/apps/web/src/params/registry.ts +++ b/apps/web/src/params/registry.ts @@ -1,40 +1,46 @@ -import type { ParamDefinition, ParamValues } from "@/params";
-
-export function buildDefaultParamValues(
- params: ParamDefinition[],
-): ParamValues {
- const values: ParamValues = {};
- for (const param of params) {
- values[param.key] = param.default;
- }
- return values;
-}
-
-export class DefinitionRegistry<TKey extends string, TDefinition> {
- private definitions = new Map<TKey, TDefinition>();
- private entityName: string;
-
- constructor(entityName: string) {
- this.entityName = entityName;
- }
-
- register(key: TKey, definition: TDefinition): void {
- this.definitions.set(key, definition);
- }
-
- has(key: TKey): boolean {
- return this.definitions.has(key);
- }
-
- get(key: TKey): TDefinition {
- const def = this.definitions.get(key);
- if (!def) {
- throw new Error(`Unknown ${this.entityName}: ${key}`);
- }
- return def;
- }
-
- getAll(): TDefinition[] {
- return Array.from(this.definitions.values());
- }
-}
+import type { ParamDefinition, ParamValues } from "@/params"; + +export function buildDefaultParamValues( + params: ParamDefinition[], +): ParamValues { + const values: ParamValues = {}; + for (const param of params) { + values[param.key] = param.default; + } + return values; +} + +export class DefinitionRegistry<TKey extends string, TDefinition> { + private definitions = new Map<TKey, TDefinition>(); + private entityName: string; + + constructor(entityName: string) { + this.entityName = entityName; + } + + register({ + key, + definition, + }: { + key: TKey; + definition: TDefinition; + }): void { + this.definitions.set(key, definition); + } + + has(key: TKey): boolean { + return this.definitions.has(key); + } + + get(key: TKey): TDefinition { + const def = this.definitions.get(key); + if (!def) { + throw new Error(`Unknown ${this.entityName}: ${key}`); + } + return def; + } + + getAll(): TDefinition[] { + return Array.from(this.definitions.values()); + } +} diff --git a/apps/web/src/preview/components/context-menu.tsx b/apps/web/src/preview/components/context-menu.tsx index fca55d71..a0f1bc5c 100644 --- a/apps/web/src/preview/components/context-menu.tsx +++ b/apps/web/src/preview/components/context-menu.tsx @@ -13,12 +13,12 @@ import { toast } from "sonner"; export function PreviewContextMenu({ onToggleFullscreen, - containerRef, + container, overlayControls, onOverlayVisibilityChange, }: { onToggleFullscreen: () => void; - containerRef: React.RefObject<HTMLElement | null>; + container: HTMLElement | null; overlayControls: PreviewOverlayControl[]; onOverlayVisibilityChange: (params: { overlayId: string; @@ -51,7 +51,7 @@ export function PreviewContextMenu({ }; return ( - <ContextMenuContent className="w-56" container={containerRef.current}> + <ContextMenuContent className="w-56" container={container}> <ContextMenuItem onClick={viewport.fitToScreen} inset> Fit to screen </ContextMenuItem> diff --git a/apps/web/src/preview/components/index.tsx b/apps/web/src/preview/components/index.tsx index e8f5c572..894eec78 100644 --- a/apps/web/src/preview/components/index.tsx +++ b/apps/web/src/preview/components/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import useDeepCompareEffect from "use-deep-compare-effect"; import { useEditor } from "@/editor/use-editor"; import { useRafLoop } from "@/hooks/use-raf-loop"; @@ -68,15 +68,20 @@ export function PreviewPanel({ }) => void; }) { const containerRef = useRef<HTMLDivElement>(null); + const [container, setContainer] = useState<HTMLDivElement | null>(null); const { toggleFullscreen } = useFullscreen({ containerRef }); + const handleContainerRef = useCallback((node: HTMLDivElement | null) => { + containerRef.current = node; + setContainer(node); + }, []); return ( <div - ref={containerRef} + ref={handleContainerRef} className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border" > <PreviewCanvas - containerRef={containerRef} + container={container} onToggleFullscreen={toggleFullscreen} overlayControls={overlayControls} overlayInstances={overlayInstances} @@ -117,13 +122,13 @@ function RenderTreeController() { } function PreviewCanvas({ - containerRef, + container, onToggleFullscreen, overlayControls, overlayInstances, onOverlayVisibilityChange, }: { - containerRef: React.RefObject<HTMLElement | null>; + container: HTMLElement | null; onToggleFullscreen: () => void; overlayControls: PreviewOverlayControl[]; overlayInstances: PreviewOverlayInstance[]; @@ -149,6 +154,7 @@ function PreviewCanvas({ viewportRef, viewportWidth: viewportSize.width, }); + const { canPan, panByScreenDelta, scaleZoom } = viewport; const renderer = useMemo(() => { return new CanvasRenderer({ @@ -240,7 +246,7 @@ function PreviewCanvas({ Math.min(Math.abs(pendingZoomDelta), 30); const zoomFactor = Math.exp(-cappedDelta / 300); - viewport.scaleZoom({ factor: zoomFactor }); + scaleZoom({ factor: zoomFactor }); pendingZoomDelta = 0; zoomRafId = null; }); @@ -249,7 +255,7 @@ function PreviewCanvas({ return; } - if (!viewport.canPan) { + if (!canPan) { return; } @@ -263,7 +269,7 @@ function PreviewCanvas({ if (panRafId === null) { panRafId = requestAnimationFrame(() => { - viewport.panByScreenDelta({ + panByScreenDelta({ deltaX: pendingPanDeltaX, deltaY: pendingPanDeltaY, }); @@ -290,7 +296,7 @@ function PreviewCanvas({ cancelAnimationFrame(panRafId); } }; - }, [viewport.canPan, viewport.panByScreenDelta, viewport.scaleZoom]); + }, [canPan, panByScreenDelta, scaleZoom]); return ( <PreviewViewportProvider value={viewport}> @@ -329,7 +335,7 @@ function PreviewCanvas({ </ContextMenuTrigger> <PreviewContextMenu onToggleFullscreen={onToggleFullscreen} - containerRef={containerRef} + container={container} overlayControls={overlayControls} onOverlayVisibilityChange={onOverlayVisibilityChange} /> diff --git a/apps/web/src/preview/components/preview-viewport.tsx b/apps/web/src/preview/components/preview-viewport.tsx index 6dc9f084..be261adc 100644 --- a/apps/web/src/preview/components/preview-viewport.tsx +++ b/apps/web/src/preview/components/preview-viewport.tsx @@ -9,6 +9,7 @@ import { useRef,
useState,
} from "react";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
import {
canvasToOverlay,
getDisplayScale,
@@ -219,8 +220,7 @@ export function usePreviewViewportState({ }));
const [isPanning, setIsPanning] = useState(false);
const panSessionRef = useRef<PanSession | null>(null);
- const centerRef = useRef(center);
- centerRef.current = center;
+ const centerRef = useCommittedRef(center);
const fitScale = useMemo(
() =>
@@ -409,10 +409,10 @@ export function usePreviewViewportState({ pointerId: event.pointerId,
};
setIsPanning(true);
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ event.currentTarget.setPointerCapture(event.pointerId);
return true;
},
- [zoom],
+ [centerRef, zoom],
);
const handlePanPointerMove = useCallback(
@@ -451,13 +451,9 @@ export function usePreviewViewportState({ }
if (
- (event.currentTarget as HTMLElement).hasPointerCapture(
- panSession.pointerId,
- )
+ event.currentTarget.hasPointerCapture(panSession.pointerId)
) {
- (event.currentTarget as HTMLElement).releasePointerCapture(
- panSession.pointerId,
- );
+ event.currentTarget.releasePointerCapture(panSession.pointerId);
}
panSessionRef.current = null;
diff --git a/apps/web/src/preview/components/text-edit-overlay.tsx b/apps/web/src/preview/components/text-edit-overlay.tsx index 1d599b3b..dd6364d6 100644 --- a/apps/web/src/preview/components/text-edit-overlay.tsx +++ b/apps/web/src/preview/components/text-edit-overlay.tsx @@ -120,7 +120,6 @@ export function TextEditOverlay({ transformOrigin: "center center", }} > - {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */} <div ref={divRef} contentEditable diff --git a/apps/web/src/preview/hooks/use-preview-interaction.ts b/apps/web/src/preview/hooks/use-preview-interaction.ts index d093dcf2..b707f1e6 100644 --- a/apps/web/src/preview/hooks/use-preview-interaction.ts +++ b/apps/web/src/preview/hooks/use-preview-interaction.ts @@ -1,5 +1,6 @@ -import { useEffect, useReducer, useRef } from "react"; +import { useEffect, useReducer, useState } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useShiftKey } from "@/hooks/use-shift-key"; import { usePreviewViewport } from "@/preview/components/preview-viewport"; import type { SnapLine } from "@/preview/preview-snap"; @@ -59,17 +60,10 @@ export function usePreviewInteraction({ onSnapLinesChange, }, }; - - const depsRef = useRef<PreviewInteractionDeps>(deps); - depsRef.current = deps; - - const controllerRef = useRef<PreviewInteractionController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new PreviewInteractionController({ - depsRef: depsRef as PreviewInteractionDepsRef, - }); - } - const controller = controllerRef.current; + const depsRef = useCommittedRef(deps) as PreviewInteractionDepsRef; + const [controller] = useState( + () => new PreviewInteractionController({ depsRef }), + ); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect( diff --git a/apps/web/src/preview/hooks/use-transform-handles.ts b/apps/web/src/preview/hooks/use-transform-handles.ts index f4b7e306..55d8d8f7 100644 --- a/apps/web/src/preview/hooks/use-transform-handles.ts +++ b/apps/web/src/preview/hooks/use-transform-handles.ts @@ -1,7 +1,8 @@ -import { useEffect, useReducer, useRef } from "react"; +import { useEffect, useReducer, useState } from "react"; import { usePreviewViewport } from "@/preview/components/preview-viewport"; import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useShiftKey } from "@/hooks/use-shift-key"; import { registerCanceller } from "@/editor/cancel-interaction"; import { @@ -48,15 +49,10 @@ export function useTransformHandles({ onSnapLinesChange, }, }; - - const depsRef = useRef<TransformHandleDeps>(deps); - depsRef.current = deps; - - const controllerRef = useRef<TransformHandleController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new TransformHandleController({ depsRef }); - } - const controller = controllerRef.current; + const depsRef = useCommittedRef(deps); + const [controller] = useState( + () => new TransformHandleController({ depsRef }), + ); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); diff --git a/apps/web/src/rendering/components/blending-tab.tsx b/apps/web/src/rendering/components/blending-tab.tsx index 52d2f051..e69de29b 100644 --- a/apps/web/src/rendering/components/blending-tab.tsx +++ b/apps/web/src/rendering/components/blending-tab.tsx @@ -1,231 +0,0 @@ -import { useEditor } from "@/editor/use-editor"; -import { clamp } from "@/utils/math"; -import { NumberField } from "@/components/ui/number-field"; -import { OcCheckerboardIcon } from "@/components/icons"; -import { Fragment, useRef } from "react"; -import { useMenuPreview } from "@/editor/use-menu-preview"; -import { - Section, - SectionContent, - SectionField, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { - Select, - SelectContent, - SelectItem, - SelectSeparator, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import type { BlendMode } from "@/rendering"; -import type { ElementType } from "@/timeline"; -import type { ElementAnimations } from "@/animation/types"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { RainDropIcon } from "@hugeicons/core-free-icons"; -import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; -import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; -import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; -import { resolveOpacityAtTime } from "@/animation/values"; -import { DEFAULTS } from "@/timeline/defaults"; -import { isPropertyAtDefault } from "./transform-tab"; -import type { MediaTime } from "@/wasm"; - -type BlendingElement = { - id: string; - opacity: number; - type: ElementType; - blendMode?: BlendMode; - startTime: MediaTime; - duration: MediaTime; - animations?: ElementAnimations; -}; - -const BLEND_MODE_GROUPS: { value: BlendMode; label: string }[][] = [ - [{ value: "normal", label: "Normal" }], - [ - { value: "darken", label: "Darken" }, - { value: "multiply", label: "Multiply" }, - { value: "color-burn", label: "Color Burn" }, - ], - [ - { value: "lighten", label: "Lighten" }, - { value: "screen", label: "Screen" }, - { value: "plus-lighter", label: "Plus Lighter" }, - { value: "color-dodge", label: "Color Dodge" }, - ], - [ - { value: "overlay", label: "Overlay" }, - { value: "soft-light", label: "Soft Light" }, - { value: "hard-light", label: "Hard Light" }, - ], - [ - { value: "difference", label: "Difference" }, - { value: "exclusion", label: "Exclusion" }, - ], - [ - { value: "hue", label: "Hue" }, - { value: "saturation", label: "Saturation" }, - { value: "color", label: "Color" }, - { value: "luminosity", label: "Luminosity" }, - ], -]; - -export function BlendingTab({ - element, - trackId, -}: { - element: BlendingElement; - trackId: string; -}) { - const editor = useEditor(); - const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive()); - const blendMode = element.blendMode ?? DEFAULTS.element.blendMode; - const committedBlendModeRef = useRef(blendMode); - if (!isPreviewActive) { - committedBlendModeRef.current = blendMode; - } - - const { - onPointerLeave, - onOpenChange: handleBlendModeOpenChange, - markCommitted, - } = useMenuPreview(); - - const previewBlendMode = ({ value }: { value: BlendMode }) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { blendMode: value } }, - ], - }); - - const commitBlendMode = (value: BlendMode) => { - if (editor.timeline.isPreviewActive()) { - editor.timeline.commitPreview(); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { blendMode: value }, - }, - ], - }); - } - markCommitted(); - }; - - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedOpacity = resolveOpacityAtTime({ - baseOpacity: element.opacity, - animations: element.animations, - localTime, - }); - - const opacity = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "opacity", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedOpacity * 100).toString(), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ value: parsed, min: 0, max: 100 }) / 100; - }, - valueAtPlayhead: resolvedOpacity, - step: 0.01, - buildBaseUpdates: ({ value }) => ({ opacity: value }), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:blending`}> - <SectionHeader> - <SectionTitle>Blending</SectionTitle> - </SectionHeader> - <SectionContent> - <div className="flex items-start gap-2"> - <SectionField - label="Opacity" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={opacity.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle opacity keyframe" - onToggle={opacity.toggleKeyframe} - /> - } - > - <NumberField - className="w-full" - icon={ - <OcCheckerboardIcon className="size-3.5 text-muted-foreground" /> - } - value={opacity.displayValue} - min={0} - max={100} - onFocus={opacity.onFocus} - onChange={opacity.onChange} - onBlur={opacity.onBlur} - onScrub={opacity.scrubTo} - onScrubEnd={opacity.commitScrub} - onReset={() => - opacity.commitValue({ value: DEFAULTS.element.opacity }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: opacity.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedOpacity, - staticValue: element.opacity, - defaultValue: DEFAULTS.element.opacity, - })} - dragSensitivity="slow" - /> - </SectionField> - <SectionField label="Blend mode" className="w-1/2"> - <Select - value={committedBlendModeRef.current} - onOpenChange={handleBlendModeOpenChange} - onValueChange={commitBlendMode} - > - <SelectTrigger - icon={<HugeiconsIcon icon={RainDropIcon} />} - className="w-full" - > - <SelectValue placeholder="Select blend mode" /> - </SelectTrigger> - <SelectContent className="w-36" onPointerLeave={onPointerLeave}> - {BLEND_MODE_GROUPS.map((group, groupIndex) => ( - <Fragment key={group[0]?.value ?? `group-${groupIndex}`}> - {group.map((option) => ( - <SelectItem - key={option.value} - value={option.value} - onPointerEnter={() => - previewBlendMode({ value: option.value }) - } - > - {option.label} - </SelectItem> - ))} - {groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( - <SelectSeparator /> - ) : null} - </Fragment> - ))} - </SelectContent> - </Select> - </SectionField> - </div> - </SectionContent> - </Section> - ); -} diff --git a/apps/web/src/rendering/components/transform-tab.tsx b/apps/web/src/rendering/components/transform-tab.tsx index f4d5a183..e69de29b 100644 --- a/apps/web/src/rendering/components/transform-tab.tsx +++ b/apps/web/src/rendering/components/transform-tab.tsx @@ -1,462 +0,0 @@ -import { NumberField } from "@/components/ui/number-field"; -import { useEditor } from "@/editor/use-editor"; -import { clamp, isNearlyEqual } from "@/utils/math"; -import type { VisualElement } from "@/timeline"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - ArrowExpandIcon, - Link05Icon, - RotateClockwiseIcon, -} from "@hugeicons/core-free-icons"; -import { - getGroupKeyframesAtTime, - hasGroupKeyframeAtTime, -} from "@/animation"; -import { resolveTransformAtTime } from "@/rendering/animation-values"; -import { DEFAULTS } from "@/timeline/defaults"; -import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; -import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; -import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; -import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store"; - -export function parseNumericInput({ input }: { input: string }): number | null { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : parsed; -} - -export function isPropertyAtDefault({ - hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue, - staticValue, - defaultValue, -}: { - hasAnimatedKeyframes: boolean; - isPlayheadWithinElementRange: boolean; - resolvedValue: number; - staticValue: number; - defaultValue: number; -}): boolean { - if (hasAnimatedKeyframes && isPlayheadWithinElementRange) { - return isNearlyEqual({ - leftValue: resolvedValue, - rightValue: defaultValue, - }); - } - - return staticValue === defaultValue; -} - -export function TransformTab({ - element, - trackId, -}: { - element: VisualElement; - trackId: string; -}) { - const editor = useEditor(); - const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked); - const setTransformScaleLocked = usePropertiesStore( - (s) => s.setTransformScaleLocked, - ); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const positionX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.positionX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.position.x).toString(), - parse: (input) => parseNumericInput({ input }), - valueAtPlayhead: resolvedTransform.position.x, - step: 1, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - position: { ...element.transform.position, x: value }, - }, - }), - }); - - const positionY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.positionY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.position.y).toString(), - parse: (input) => parseNumericInput({ input }), - valueAtPlayhead: resolvedTransform.position.y, - step: 1, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - position: { ...element.transform.position, y: value }, - }, - }), - }); - - const parseScale = (input: string) => { - const parsed = parseNumericInput({ input }); - if (parsed === null) return null; - return parsed / 100; - }; - - const scaleX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.scaleX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.scaleX * 100).toString(), - parse: parseScale, - valueAtPlayhead: resolvedTransform.scaleX, - step: 0.01, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - scaleX: value, - ...(isScaleLocked ? { scaleY: value } : {}), - }, - }), - buildAdditionalKeyframes: isScaleLocked - ? ({ value }) => [{ propertyPath: "transform.scaleY", value }] - : undefined, - }); - - const scaleY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.scaleY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.scaleY * 100).toString(), - parse: parseScale, - valueAtPlayhead: resolvedTransform.scaleY, - step: 0.01, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - scaleY: value, - ...(isScaleLocked ? { scaleX: value } : {}), - }, - }), - buildAdditionalKeyframes: isScaleLocked - ? ({ value }) => [{ propertyPath: "transform.scaleX", value }] - : undefined, - }); - - const scaleFieldPropsX = { - value: scaleX.displayValue, - onFocus: scaleX.onFocus, - onChange: scaleX.onChange, - onBlur: scaleX.onBlur, - dragSensitivity: "slow" as const, - onScrub: scaleX.scrubTo, - onScrubEnd: scaleX.commitScrub, - onReset: () => - scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }), - isDefault: isPropertyAtDefault({ - hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.scaleX, - staticValue: element.transform.scaleX, - defaultValue: DEFAULTS.element.transform.scaleX, - }), - }; - - const scaleFieldPropsY = { - value: scaleY.displayValue, - onFocus: scaleY.onFocus, - onChange: scaleY.onChange, - onBlur: scaleY.onBlur, - dragSensitivity: "slow" as const, - onScrub: scaleY.scrubTo, - onScrubEnd: scaleY.commitScrub, - onReset: () => - scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }), - isDefault: isPropertyAtDefault({ - hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.scaleY, - staticValue: element.transform.scaleY, - defaultValue: DEFAULTS.element.transform.scaleY, - }), - }; - - const rotation = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.rotate", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.rotate).toString(), - parse: (input) => { - const parsed = parseNumericInput({ input }); - if (parsed === null) return null; - return clamp({ value: parsed, min: -360, max: 360 }); - }, - valueAtPlayhead: resolvedTransform.rotate, - step: 1, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - rotate: value, - }, - }), - }); - - const hasScaleKeyframe = hasGroupKeyframeAtTime({ - animations: element.animations, - group: "transform.scale", - time: localTime, - }); - - const toggleScaleKeyframe = () => { - if (!isPlayheadWithinElementRange) return; - const existing = getGroupKeyframesAtTime({ - animations: element.animations, - group: "transform.scale", - time: localTime, - }); - if (existing.length > 0) { - editor.timeline.removeKeyframes({ - keyframes: existing.map((ref) => ({ - trackId, - elementId: element.id, - ...ref, - })), - }); - return; - } - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId: element.id, - propertyPath: "transform.scaleX", - time: localTime, - value: resolvedTransform.scaleX, - }, - { - trackId, - elementId: element.id, - propertyPath: "transform.scaleY", - time: localTime, - value: resolvedTransform.scaleY, - }, - ], - }); - }; - - const scaleLockButton = ( - <Button - type="button" - variant={isScaleLocked ? "secondary" : "ghost"} - size="icon" - aria-pressed={isScaleLocked} - onClick={() => setTransformScaleLocked(!isScaleLocked)} - > - <HugeiconsIcon icon={Link05Icon} /> - </Button> - ); - - return ( - <Section collapsible sectionKey={`${element.id}:transform`}> - <SectionHeader> - <SectionTitle>Transform</SectionTitle> - </SectionHeader> - <SectionContent> - <SectionFields> - <div className="flex items-end gap-2"> - {isScaleLocked ? ( - <> - <SectionField - label="Scale" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={hasScaleKeyframe} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle scale keyframe" - onToggle={toggleScaleKeyframe} - /> - } - > - <NumberField - icon={<HugeiconsIcon icon={ArrowExpandIcon} />} - {...scaleFieldPropsX} - /> - </SectionField> - {scaleLockButton} - </> - ) : ( - <> - <SectionField - label="Width" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={scaleX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle width scale keyframe" - onToggle={scaleX.toggleKeyframe} - /> - } - > - <NumberField icon="W" {...scaleFieldPropsX} /> - </SectionField> - {scaleLockButton} - <SectionField - label="Height" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={scaleY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle height scale keyframe" - onToggle={scaleY.toggleKeyframe} - /> - } - > - <NumberField icon="H" {...scaleFieldPropsY} /> - </SectionField> - </> - )} - </div> - <div className="flex items-end gap-2"> - <SectionField - label="X" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={positionX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle X position keyframe" - onToggle={positionX.toggleKeyframe} - /> - } - > - <NumberField - icon="X" - value={positionX.displayValue} - onFocus={positionX.onFocus} - onChange={positionX.onChange} - onBlur={positionX.onBlur} - onScrub={positionX.scrubTo} - onScrubEnd={positionX.commitScrub} - onReset={() => - positionX.commitValue({ - value: DEFAULTS.element.transform.position.x, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: positionX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.position.x, - staticValue: element.transform.position.x, - defaultValue: DEFAULTS.element.transform.position.x, - })} - /> - </SectionField> - <SectionField - label="Y" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={positionY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle Y position keyframe" - onToggle={positionY.toggleKeyframe} - /> - } - > - <NumberField - icon="Y" - value={positionY.displayValue} - onFocus={positionY.onFocus} - onChange={positionY.onChange} - onBlur={positionY.onBlur} - onScrub={positionY.scrubTo} - onScrubEnd={positionY.commitScrub} - onReset={() => - positionY.commitValue({ - value: DEFAULTS.element.transform.position.y, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: positionY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.position.y, - staticValue: element.transform.position.y, - defaultValue: DEFAULTS.element.transform.position.y, - })} - /> - </SectionField> - </div> - - <SectionField - label="Rotation" - beforeLabel={ - <KeyframeToggle - isActive={rotation.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle rotation keyframe" - onToggle={rotation.toggleKeyframe} - /> - } - > - <div className="flex items-center gap-2"> - <NumberField - icon={<HugeiconsIcon icon={RotateClockwiseIcon} />} - className="flex-none" - value={rotation.displayValue} - onFocus={rotation.onFocus} - onChange={rotation.onChange} - onBlur={rotation.onBlur} - dragSensitivity="slow" - onScrub={rotation.scrubTo} - onScrubEnd={rotation.commitScrub} - onReset={() => - rotation.commitValue({ - value: DEFAULTS.element.transform.rotate, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: rotation.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.rotate, - staticValue: element.transform.rotate, - defaultValue: DEFAULTS.element.transform.rotate, - })} - /> - </div> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - ); -} diff --git a/apps/web/src/selection/hooks/use-box-select.ts b/apps/web/src/selection/hooks/use-box-select.ts index 90a584b5..3bbff14e 100644 --- a/apps/web/src/selection/hooks/use-box-select.ts +++ b/apps/web/src/selection/hooks/use-box-select.ts @@ -4,15 +4,40 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { BoxSelectionSnapshot, ResolveIntersections, + SelectionBoxBounds, } from "@/selection/types"; interface SelectionBoxState<TId> extends BoxSelectionSnapshot<TId> { startPos: { x: number; y: number }; currentPos: { x: number; y: number }; + bounds: SelectionBoxBounds | null; isActive: boolean; isAdditive: boolean; } +function getSelectionBoxBounds({ + container, + startPos, + currentPos, +}: { + container: HTMLElement; + startPos: { x: number; y: number }; + currentPos: { x: number; y: number }; +}): SelectionBoxBounds { + const containerRect = container.getBoundingClientRect(); + const startX = startPos.x - containerRect.left; + const startY = startPos.y - containerRect.top; + const currentX = currentPos.x - containerRect.left; + const currentY = currentPos.y - containerRect.top; + + return { + left: Math.min(startX, currentX), + top: Math.min(startY, currentY), + width: Math.abs(currentX - startX), + height: Math.abs(currentY - startY), + }; +} + export function useBoxSelect<TId>({ containerRef, resolveIntersections, @@ -40,33 +65,43 @@ export function useBoxSelect<TId>({ const [selectionBox, setSelectionBox] = useState<SelectionBoxState<TId> | null>(null); const justFinishedSelectingRef = useRef(false); - const shouldStartSelectionCheck = shouldStartSelection ?? (() => true); - const getIsAdditiveSelectionCheck = - getIsAdditiveSelection ?? - ((event: React.MouseEvent<Element>) => event.ctrlKey || event.metaKey); const handleMouseDown = useCallback( (event: React.MouseEvent<Element>) => { - const canStartSelection = shouldStartSelectionCheck(event); + const canStartSelection = shouldStartSelection + ? shouldStartSelection(event) + : true; if (!isEnabled || event.button !== 0 || !canStartSelection) { return; } + const startPos = { x: event.clientX, y: event.clientY }; + const container = containerRef.current; setSelectionBox({ - startPos: { x: event.clientX, y: event.clientY }, - currentPos: { x: event.clientX, y: event.clientY }, + startPos, + currentPos: startPos, + bounds: container + ? getSelectionBoxBounds({ + container, + startPos, + currentPos: startPos, + }) + : null, isActive: false, - isAdditive: getIsAdditiveSelectionCheck(event), + isAdditive: getIsAdditiveSelection + ? getIsAdditiveSelection(event) + : event.ctrlKey || event.metaKey, initialSelectedIds: selectedIds, initialAnchorId: anchorId, }); }, [ anchorId, - getIsAdditiveSelectionCheck, + containerRef, + getIsAdditiveSelection, isEnabled, selectedIds, - shouldStartSelectionCheck, + shouldStartSelection, ], ); @@ -98,11 +133,20 @@ export function useBoxSelect<TId>({ } const handleMouseMove = ({ clientX, clientY }: MouseEvent) => { + const currentPos = { x: clientX, y: clientY }; const deltaX = Math.abs(clientX - selectionBox.startPos.x); const deltaY = Math.abs(clientY - selectionBox.startPos.y); + const container = containerRef.current; const nextSelectionBox = { ...selectionBox, - currentPos: { x: clientX, y: clientY }, + currentPos, + bounds: container + ? getSelectionBoxBounds({ + container, + startPos: selectionBox.startPos, + currentPos, + }) + : null, isActive: deltaX > 5 || deltaY > 5 || selectionBox.isActive, }; @@ -133,26 +177,26 @@ export function useBoxSelect<TId>({ window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); }; - }, [selectionBox, updateSelection]); + }, [containerRef, selectionBox, updateSelection]); useEffect(() => { if (!selectionBox) { return; } + const container = containerRef.current; const previousBodyUserSelect = document.body.style.userSelect; - const previousContainerUserSelect = - containerRef.current?.style.userSelect ?? ""; + const previousContainerUserSelect = container?.style.userSelect ?? ""; document.body.style.userSelect = "none"; - if (containerRef.current) { - containerRef.current.style.userSelect = "none"; + if (container) { + container.style.userSelect = "none"; } return () => { document.body.style.userSelect = previousBodyUserSelect; - if (containerRef.current) { - containerRef.current.style.userSelect = previousContainerUserSelect; + if (container) { + container.style.userSelect = previousContainerUserSelect; } }; }, [containerRef, selectionBox]); @@ -162,7 +206,10 @@ export function useBoxSelect<TId>({ }, []); return { - selectionBox, + selectionBox: + selectionBox?.isActive && selectionBox.bounds + ? { bounds: selectionBox.bounds } + : null, handleMouseDown, isSelecting: selectionBox?.isActive ?? false, shouldIgnoreClick, diff --git a/apps/web/src/selection/hooks/use-selection-scope.ts b/apps/web/src/selection/hooks/use-selection-scope.ts index ad4d2ce8..7fddab08 100644 --- a/apps/web/src/selection/hooks/use-selection-scope.ts +++ b/apps/web/src/selection/hooks/use-selection-scope.ts @@ -1,28 +1,25 @@ -import { useEffect, useRef } from "react";
+import { useEffect, useState } from "react";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
import { useSelectionContext } from "@/selection/context";
import { activateScope, type ScopeEntry } from "@/selection/scope";
export function useSelectionScope() {
const { selectedIds, clearSelection } = useSelectionContext();
- const hasSelectionRef = useRef(selectedIds.length > 0);
- const clearSelectionRef = useRef(clearSelection);
const hasSelection = selectedIds.length > 0;
-
- hasSelectionRef.current = hasSelection;
- clearSelectionRef.current = clearSelection;
-
- const entryRef = useRef<ScopeEntry>({
+ const hasSelectionRef = useCommittedRef(hasSelection);
+ const clearSelectionRef = useCommittedRef(clearSelection);
+ const [entry] = useState<ScopeEntry>(() => ({
hasSelection: () => hasSelectionRef.current,
clear: () => {
clearSelectionRef.current();
},
- });
+ }));
useEffect(() => {
if (!hasSelection) {
return;
}
- return activateScope({ entry: entryRef.current });
- }, [hasSelection]);
+ return activateScope({ entry });
+ }, [entry, hasSelection]);
}
diff --git a/apps/web/src/selection/selectable-item.tsx b/apps/web/src/selection/selectable-item.tsx index cc9e1dbf..9735c4a8 100644 --- a/apps/web/src/selection/selectable-item.tsx +++ b/apps/web/src/selection/selectable-item.tsx @@ -6,7 +6,13 @@ import { SELECTABLE_ITEM_ATTRIBUTE } from "@/selection/attributes"; import type { SelectableItemProps } from "@/selection/types";
import { cn } from "@/utils/ui";
-function setForwardedRef<T>(ref: React.ForwardedRef<T>, value: T | null) {
+function setForwardedRef<T>({
+ ref,
+ value,
+}: {
+ ref: React.ForwardedRef<T>;
+ value: T | null;
+}) {
if (typeof ref === "function") {
ref(value);
return;
@@ -55,7 +61,7 @@ export const SelectableItem = forwardRef<HTMLDivElement, SelectableItemProps>( const handleRef = useCallback(
(element: HTMLDivElement | null) => {
registerItem(id, element);
- setForwardedRef(forwardedRef, element);
+ setForwardedRef({ ref: forwardedRef, value: element });
},
[forwardedRef, id, registerItem],
);
diff --git a/apps/web/src/selection/selectable-surface.tsx b/apps/web/src/selection/selectable-surface.tsx index 24166daf..12682ba8 100644 --- a/apps/web/src/selection/selectable-surface.tsx +++ b/apps/web/src/selection/selectable-surface.tsx @@ -167,14 +167,15 @@ export function SelectableSurface({ [],
);
- const { selectionBox, handleMouseDown, shouldIgnoreClick } = useBoxSelect({
- containerRef,
- resolveIntersections,
- selectedIds: selectionState.selectedIds,
- anchorId: selectionState.anchorId,
- onSelectionChange: handleBoxSelectionChange,
- shouldStartSelection,
- });
+ const { selectionBox, handleMouseDown, isSelecting, shouldIgnoreClick } =
+ useBoxSelect({
+ containerRef,
+ resolveIntersections,
+ selectedIds: selectionState.selectedIds,
+ anchorId: selectionState.anchorId,
+ onSelectionChange: handleBoxSelectionChange,
+ shouldStartSelection,
+ });
const handleBackgroundClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
@@ -236,7 +237,7 @@ export function SelectableSurface({ return () => clearTimeout(timer);
}, [getItemElement, onRevealComplete, revealId]);
- const isBoxSelecting = selectionBox?.isActive ?? false;
+ const isBoxSelecting = isSelecting;
const contextValue = useMemo(() => {
return {
@@ -276,12 +277,7 @@ export function SelectableSurface({ onKeyDown={handleBackgroundKeyDown}
>
{children}
- <SelectionBox
- startPos={selectionBox?.startPos ?? null}
- currentPos={selectionBox?.currentPos ?? null}
- containerRef={containerRef}
- isActive={selectionBox?.isActive ?? false}
- />
+ <SelectionBox bounds={selectionBox?.bounds ?? null} />
</div>
</SelectionContext.Provider>
);
diff --git a/apps/web/src/selection/selection-box.tsx b/apps/web/src/selection/selection-box.tsx index e30f574d..2f2bf933 100644 --- a/apps/web/src/selection/selection-box.tsx +++ b/apps/web/src/selection/selection-box.tsx @@ -1,49 +1,22 @@ "use client";
-import { useMemo } from "react";
+import type { SelectionBoxBounds } from "@/selection/types";
interface SelectionBoxProps {
- startPos: { x: number; y: number } | null;
- currentPos: { x: number; y: number } | null;
- containerRef: React.RefObject<HTMLElement | null>;
- isActive: boolean;
+ bounds: SelectionBoxBounds | null;
}
-export function SelectionBox({
- startPos,
- currentPos,
- containerRef,
- isActive,
-}: SelectionBoxProps) {
- const selectionBoxStyle = useMemo(() => {
- if (!isActive || !startPos || !currentPos || !containerRef.current) {
- return null;
- }
-
- const containerRect = containerRef.current.getBoundingClientRect();
- const startX = startPos.x - containerRect.left;
- const startY = startPos.y - containerRect.top;
- const currentX = currentPos.x - containerRect.left;
- const currentY = currentPos.y - containerRect.top;
-
- const left = Math.min(startX, currentX);
- const top = Math.min(startY, currentY);
- const width = Math.abs(currentX - startX);
- const height = Math.abs(currentY - startY);
-
- return {
- left: `${left}px`,
- top: `${top}px`,
- width: `${width}px`,
- height: `${height}px`,
- };
- }, [containerRef, currentPos, isActive, startPos]);
-
- if (!selectionBoxStyle) return null;
+export function SelectionBox({ bounds }: SelectionBoxProps) {
+ if (!bounds) return null;
return (
<div
- style={selectionBoxStyle}
+ style={{
+ left: `${bounds.left}px`,
+ top: `${bounds.top}px`,
+ width: `${bounds.width}px`,
+ height: `${bounds.height}px`,
+ }}
className="border-foreground/50 bg-foreground/5 pointer-events-none absolute z-50 border"
/>
);
diff --git a/apps/web/src/selection/types.ts b/apps/web/src/selection/types.ts index 31520788..a797d6c0 100644 --- a/apps/web/src/selection/types.ts +++ b/apps/web/src/selection/types.ts @@ -8,6 +8,13 @@ export interface BoxSelectionSnapshot<TId = string> { initialAnchorId: TId | null;
}
+export interface SelectionBoxBounds {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+}
+
export interface BoxSelectionChange<TId = string>
extends BoxSelectionSnapshot<TId> {
intersectedIds: TId[];
diff --git a/apps/web/src/services/renderer/canvas-renderer.ts b/apps/web/src/services/renderer/canvas-renderer.ts index 4a002675..58294e9c 100644 --- a/apps/web/src/services/renderer/canvas-renderer.ts +++ b/apps/web/src/services/renderer/canvas-renderer.ts @@ -1,5 +1,6 @@ import type { FrameRate } from "opencut-wasm"; import type { AnyBaseNode } from "./nodes/base-node"; +import { createCanvasSurface } from "./canvas-utils"; import { buildFrameDescriptor } from "./compositor/frame-descriptor"; import { wasmCompositor } from "./compositor/wasm-compositor"; import { resolveRenderTree } from "./resolve"; @@ -16,8 +17,8 @@ export type CanvasRendererParams = { }; export class CanvasRenderer { - canvas: OffscreenCanvas | HTMLCanvasElement; - context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D; + canvas: OffscreenCanvas; + context: OffscreenCanvasRenderingContext2D; width: number; height: number; fps: FrameRate; @@ -27,22 +28,9 @@ export class CanvasRenderer { this.height = height; this.fps = fps; - try { - this.canvas = new OffscreenCanvas(width, height); - } catch { - this.canvas = document.createElement("canvas"); - this.canvas.width = width; - this.canvas.height = height; - } - - const context = this.canvas.getContext("2d"); - if (!context) { - throw new Error("Failed to get canvas context"); - } - - this.context = context as - | OffscreenCanvasRenderingContext2D - | CanvasRenderingContext2D; + const surface = createCanvasSurface({ width, height }); + this.canvas = surface.canvas; + this.context = surface.context; } getOutputCanvas(): HTMLCanvasElement { @@ -57,20 +45,9 @@ export class CanvasRenderer { this.width = width; this.height = height; - if (this.canvas instanceof OffscreenCanvas) { - this.canvas = new OffscreenCanvas(width, height); - } else { - this.canvas.width = width; - this.canvas.height = height; - } - - const context = this.canvas.getContext("2d"); - if (!context) { - throw new Error("Failed to get canvas context"); - } - this.context = context as - | OffscreenCanvasRenderingContext2D - | CanvasRenderingContext2D; + const surface = createCanvasSurface({ width, height }); + this.canvas = surface.canvas; + this.context = surface.context; } async render({ node, time }: { node: AnyBaseNode; time: number }) { diff --git a/apps/web/src/services/renderer/canvas-utils.ts b/apps/web/src/services/renderer/canvas-utils.ts index 093168e6..6981e6d5 100644 --- a/apps/web/src/services/renderer/canvas-utils.ts +++ b/apps/web/src/services/renderer/canvas-utils.ts @@ -1,16 +1,17 @@ -export function createOffscreenCanvas({ +export function createCanvasSurface({ width, height, }: { width: number; height: number; -}): OffscreenCanvas | HTMLCanvasElement { - try { - return new OffscreenCanvas(width, height); - } catch { - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - return canvas; +}): { + canvas: OffscreenCanvas; + context: OffscreenCanvasRenderingContext2D; +} { + const canvas = new OffscreenCanvas(width, height); + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Failed to create 2D rendering context"); } + return { canvas, context }; } diff --git a/apps/web/src/services/renderer/compositor/frame-descriptor.ts b/apps/web/src/services/renderer/compositor/frame-descriptor.ts index 190a0237..1bc316ef 100644 --- a/apps/web/src/services/renderer/compositor/frame-descriptor.ts +++ b/apps/web/src/services/renderer/compositor/frame-descriptor.ts @@ -3,7 +3,7 @@ import { masksRegistry } from "@/masks"; import { incrementCounter } from "@/diagnostics/render-perf"; import type { AnyBaseNode } from "../nodes/base-node"; import type { CanvasRenderer } from "../canvas-renderer"; -import { createOffscreenCanvas } from "../canvas-utils"; +import { createCanvasSurface } from "../canvas-utils"; import { BlurBackgroundNode } from "../nodes/blur-background-node"; import { ColorNode } from "../nodes/color-node"; import { EffectLayerNode } from "../nodes/effect-layer-node"; @@ -414,15 +414,11 @@ function buildMaskArtifacts({ const { width: canvasWidth, height: canvasHeight } = renderer; const maskContentHash = `mask:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:direct=${shouldRenderMaskDirectly}`; const drawMask: TextureCanvasDrawFn = (ctx) => { - const elementMaskCanvas = createOffscreenCanvas({ - width: Math.round(transform.width), - height: Math.round(transform.height), - }); - const elementMaskCtx = elementMaskCanvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!elementMaskCtx) return; + const { canvas: elementMaskCanvas, context: elementMaskCtx } = + createCanvasSurface({ + width: Math.round(transform.width), + height: Math.round(transform.height), + }); if (shouldRenderMaskDirectly && definition.renderer.renderMask) { definition.renderer.renderMask({ @@ -465,15 +461,10 @@ function buildMaskArtifacts({ const strokeTextureId = `${path}:mask-stroke`; const strokeContentHash = `stroke:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}`; const drawStroke: TextureCanvasDrawFn = (ctx) => { - const strokeCanvas = createOffscreenCanvas({ + const { canvas: strokeCanvas, context: strokeCtx } = createCanvasSurface({ width: Math.round(transform.width), height: Math.round(transform.height), }); - const strokeCtx = strokeCanvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!strokeCtx) return; if (definition.renderer.renderStroke) { definition.renderer.renderStroke({ diff --git a/apps/web/src/services/renderer/effect-preview.ts b/apps/web/src/services/renderer/effect-preview.ts index c4cacde0..cbfb1a31 100644 --- a/apps/web/src/services/renderer/effect-preview.ts +++ b/apps/web/src/services/renderer/effect-preview.ts @@ -1,4 +1,4 @@ -import { createOffscreenCanvas } from "./canvas-utils"; +import { createCanvasSurface } from "./canvas-utils"; import { effectsRegistry, resolveEffectPasses } from "@/effects"; import { buildDefaultParamValues } from "@/params/registry"; import type { ParamValues } from "@/params"; @@ -8,7 +8,7 @@ const PREVIEW_SIZE = 160; const PREVIEW_IMAGE_PATH = "/effects/preview.jpg"; class EffectPreviewService { - private testSourceCanvas: OffscreenCanvas | HTMLCanvasElement | null = null; + private testSourceCanvas: OffscreenCanvas | null = null; private previewImageElement: HTMLImageElement | null = null; private onReadyCallbacks = new Set<() => void>(); @@ -98,7 +98,7 @@ class EffectPreviewService { }: { width: number; height: number; - }): OffscreenCanvas | HTMLCanvasElement | null { + }): OffscreenCanvas | null { const isImageReady = this.previewImageElement?.complete && (this.previewImageElement.naturalWidth ?? 0) > 0; @@ -106,15 +106,8 @@ class EffectPreviewService { return null; } - const canvas = createOffscreenCanvas({ width, height }); - const ctx = canvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!ctx) { - throw new Error("failed to get 2d context for test source"); - } - ctx.drawImage(this.previewImageElement, 0, 0, width, height); + const { canvas, context } = createCanvasSurface({ width, height }); + context.drawImage(this.previewImageElement, 0, 0, width, height); return canvas; } @@ -124,7 +117,7 @@ class EffectPreviewService { }: { width: number; height: number; - }): CanvasImageSource | null { + }): OffscreenCanvas | null { if ( !this.testSourceCanvas || this.testSourceCanvas.width !== width || @@ -141,17 +134,17 @@ class EffectPreviewService { height, passes, }: { - source: CanvasImageSource; + source: OffscreenCanvas; width: number; height: number; passes: ReturnType<typeof resolveEffectPasses>; - }): OffscreenCanvas | HTMLCanvasElement { + }): OffscreenCanvas { return gpuRenderer.applyEffect({ source, width, height, passes, - }) as OffscreenCanvas | HTMLCanvasElement; + }); } } diff --git a/apps/web/src/services/renderer/gpu-renderer.ts b/apps/web/src/services/renderer/gpu-renderer.ts index c61b6b98..c1be3f1a 100644 --- a/apps/web/src/services/renderer/gpu-renderer.ts +++ b/apps/web/src/services/renderer/gpu-renderer.ts @@ -34,23 +34,17 @@ export const gpuRenderer = { height, passes, }: { - source: CanvasImageSource; + source: OffscreenCanvas; width: number; height: number; passes: EffectPass[]; - }): CanvasImageSource { + }): OffscreenCanvas { if (passes.length === 0 || !gpuAvailable) { return source; } - const sourceCanvas = ensureOffscreenCanvas({ - source, - width, - height, - label: "effect source", - }); return applyEffectPasses({ - source: sourceCanvas, + source, width, height, passes: serializeEffectPasses(passes), @@ -63,23 +57,17 @@ export const gpuRenderer = { height, feather, }: { - maskCanvas: CanvasImageSource; + maskCanvas: OffscreenCanvas; width: number; height: number; feather: number; - }): CanvasImageSource { + }): OffscreenCanvas { if (!gpuAvailable) { return maskCanvas; } - const sourceCanvas = ensureOffscreenCanvas({ - source: maskCanvas, - width, - height, - label: "mask source", - }); return applyMaskFeatherWasm({ - mask: sourceCanvas, + mask: maskCanvas, width, height, feather, @@ -87,35 +75,6 @@ export const gpuRenderer = { }, }; -function ensureOffscreenCanvas({ - source, - width, - height, - label, -}: { - source: CanvasImageSource; - width: number; - height: number; - label: string; -}): OffscreenCanvas { - if (source instanceof OffscreenCanvas) { - return source; - } - - if (typeof OffscreenCanvas === "undefined") { - throw new Error(`OffscreenCanvas is required for the GPU ${label}`); - } - - const canvas = new OffscreenCanvas(width, height); - const context = canvas.getContext("2d"); - if (!context) { - throw new Error(`Failed to get 2d context for the GPU ${label}`); - } - context.clearRect(0, 0, width, height); - context.drawImage(source, 0, 0, width, height); - return canvas; -} - function serializeEffectPasses(passes: EffectPass[]) { return passes.map((pass) => ({ shader: pass.shader, diff --git a/apps/web/src/services/renderer/mask-feather.ts b/apps/web/src/services/renderer/mask-feather.ts index 4ea7eb2b..dde4decc 100644 --- a/apps/web/src/services/renderer/mask-feather.ts +++ b/apps/web/src/services/renderer/mask-feather.ts @@ -6,15 +6,15 @@ export function applyMaskFeather({ height, feather, }: { - maskCanvas: CanvasImageSource; + maskCanvas: OffscreenCanvas; width: number; height: number; feather: number; -}): OffscreenCanvas | HTMLCanvasElement { +}): OffscreenCanvas { return gpuRenderer.applyMaskFeather({ maskCanvas, width, height, feather, - }) as OffscreenCanvas | HTMLCanvasElement; + }); } diff --git a/apps/web/src/services/renderer/nodes/graphic-node.ts b/apps/web/src/services/renderer/nodes/graphic-node.ts index 2d022510..798b56a0 100644 --- a/apps/web/src/services/renderer/nodes/graphic-node.ts +++ b/apps/web/src/services/renderer/nodes/graphic-node.ts @@ -1,4 +1,4 @@ -import { createOffscreenCanvas } from "../canvas-utils"; +import { createCanvasSurface } from "../canvas-utils"; import { DEFAULT_GRAPHIC_SOURCE_SIZE, getGraphicDefinition, @@ -25,7 +25,7 @@ export class GraphicNode extends VisualNode< ResolvedGraphicNodeState > { private cachedKey: string | null = null; - private cachedSource: OffscreenCanvas | HTMLCanvasElement | null = null; + private cachedSource: OffscreenCanvas | null = null; constructor(params: GraphicNodeParams) { super(params); @@ -36,7 +36,7 @@ export class GraphicNode extends VisualNode< resolvedParams, }: { resolvedParams: ParamValues; - }): OffscreenCanvas | HTMLCanvasElement | null { + }): OffscreenCanvas { const definition = getGraphicDefinition({ definitionId: this.params.definitionId, }); @@ -48,20 +48,13 @@ export class GraphicNode extends VisualNode< return this.cachedSource; } - const canvas = createOffscreenCanvas({ + const { canvas, context } = createCanvasSurface({ width: DEFAULT_GRAPHIC_SOURCE_SIZE, height: DEFAULT_GRAPHIC_SOURCE_SIZE, }); - const ctx = canvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!ctx) { - return null; - } definition.render({ - ctx, + ctx: context, params: resolvedParams, width: DEFAULT_GRAPHIC_SOURCE_SIZE, height: DEFAULT_GRAPHIC_SOURCE_SIZE, diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts index d8c56803..4f475a2d 100644 --- a/apps/web/src/services/renderer/nodes/image-node.ts +++ b/apps/web/src/services/renderer/nodes/image-node.ts @@ -17,10 +17,13 @@ export interface CachedImageSource { const imageSourceCache = new Map<string, Promise<CachedImageSource>>(); -export function loadImageSource( - url: string, - maxSourceSize?: number, -): Promise<CachedImageSource> { +export function loadImageSource({ + url, + maxSourceSize, +}: { + url: string; + maxSourceSize?: number; +}): Promise<CachedImageSource> { const cacheKey = `${url}::${maxSourceSize ?? "full"}`; const cached = imageSourceCache.get(cacheKey); diff --git a/apps/web/src/services/renderer/resolve.ts b/apps/web/src/services/renderer/resolve.ts index 36a6ecb5..4637df8d 100644 --- a/apps/web/src/services/renderer/resolve.ts +++ b/apps/web/src/services/renderer/resolve.ts @@ -235,10 +235,10 @@ async function resolveImageNode({ node: ImageNode; context: ResolveContext; }): Promise<ResolvedVisualSourceNodeState | null> { - const source = await loadImageSource( - node.params.url, - node.params.maxSourceSize, - ); + const source = await loadImageSource({ + url: node.params.url, + maxSourceSize: node.params.maxSourceSize, + }); const visualState = resolveVisualState({ params: node.params, context, @@ -434,7 +434,7 @@ async function resolveBackdropSource({ }; } - const source = await loadImageSource(node.params.url); + const source = await loadImageSource({ url: node.params.url }); return { source: source.source, width: source.width, diff --git a/apps/web/src/services/storage/indexeddb-adapter.ts b/apps/web/src/services/storage/indexeddb-adapter.ts index 2df4feaa..4a7498b6 100644 --- a/apps/web/src/services/storage/indexeddb-adapter.ts +++ b/apps/web/src/services/storage/indexeddb-adapter.ts @@ -5,7 +5,15 @@ export class IndexedDBAdapter<T> implements StorageAdapter<T> { private storeName: string; private version: number; - constructor(dbName: string, storeName: string, version = 1) { + constructor({ + dbName, + storeName, + version = 1, + }: { + dbName: string; + storeName: string; + version?: number; + }) { this.dbName = dbName; this.storeName = storeName; this.version = version; @@ -39,7 +47,13 @@ export class IndexedDBAdapter<T> implements StorageAdapter<T> { }); } - async set(key: string, value: T): Promise<void> { + async set({ + key, + value, + }: { + key: string; + value: T; + }): Promise<void> { const db = await this.getDB(); const transaction = db.transaction([this.storeName], "readwrite"); const store = transaction.objectStore(this.storeName); diff --git a/apps/web/src/services/storage/migrations/__tests__/helpers.ts b/apps/web/src/services/storage/migrations/__tests__/helpers.ts new file mode 100644 index 00000000..381cb935 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/helpers.ts @@ -0,0 +1,39 @@ +/** + * Type-safe accessors for navigating migration output. + * + * Migrations input/output `Record<string, unknown>` because they handle + * potentially malformed data from older versions. Tests need to inspect + * specific properties of the migrated result without using type assertions. + * + * These helpers narrow through type guards (Array.isArray + a record + * predicate), so the file contains no unsafe assertions despite turning + * unknown into concrete shapes. + */ + +function describe(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + return typeof value; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function asRecord(value: unknown): Record<string, unknown> { + if (!isRecord(value)) { + throw new Error(`Expected record, got ${describe(value)}`); + } + return value; +} + +export function asArray(value: unknown): unknown[] { + if (!Array.isArray(value)) { + throw new Error(`Expected array, got ${describe(value)}`); + } + return value; +} + +export function asRecordArray(value: unknown): Record<string, unknown>[] { + return asArray(value).map(asRecord); +} diff --git a/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts b/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts index 2cd67da8..73b64fed 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts @@ -8,6 +8,7 @@ import { v0ProjectWithMetadata, v1Project, } from "./fixtures"; +import { asArray, asRecord, asRecordArray } from "./helpers"; describe("V0 to V1 Migration", () => { const fixedDate = new Date("2024-06-01T12:00:00.000Z"); @@ -22,7 +23,7 @@ describe("V0 to V1 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(1); expect(Array.isArray(result.project.scenes)).toBe(true); - expect((result.project.scenes as unknown[]).length).toBe(1); + expect(asArray(result.project.scenes).length).toBe(1); expect(result.project.currentSceneId).toBeDefined(); }); @@ -32,7 +33,7 @@ describe("V0 to V1 Migration", () => { options: { now: fixedDate }, }); - const scenes = result.project.scenes as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); const mainScene = scenes[0]; expect(mainScene.isMain).toBe(true); @@ -48,7 +49,7 @@ describe("V0 to V1 Migration", () => { options: { now: fixedDate }, }); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.updatedAt).toBe(fixedDate.toISOString()); }); diff --git a/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts b/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts index 0840ac76..6670c479 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts @@ -11,6 +11,7 @@ import { v1ProjectWithMultipleScenes, v2Project, } from "./fixtures"; +import { asRecord, asRecordArray } from "./helpers"; const DEFAULT_FPS = 30; const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10; @@ -25,7 +26,7 @@ describe("V1 to V2 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(2); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.id).toBe(v1Project.id); expect(metadata.name).toBe(v1Project.name); expect(typeof metadata.createdAt).toBe("string"); @@ -35,7 +36,7 @@ describe("V1 to V2 Migration", () => { test("creates settings object from flat properties", () => { const result = transformProjectV1ToV2({ project: v1Project }); - const settings = result.project.settings as Record<string, unknown>; + const settings = asRecord(result.project.settings); expect(settings.fps).toBe(v1Project.fps); expect(settings.canvasSize).toEqual(v1Project.canvasSize); expect(settings.originalCanvasSize).toBe(null); @@ -44,8 +45,8 @@ describe("V1 to V2 Migration", () => { test("converts color background correctly", () => { const result = transformProjectV1ToV2({ project: v1Project }); - const settings = result.project.settings as Record<string, unknown>; - const background = settings.background as Record<string, unknown>; + const settings = asRecord(result.project.settings); + const background = asRecord(settings.background); expect(background.type).toBe("color"); expect(background.color).toBe(v1Project.backgroundColor); }); @@ -58,8 +59,8 @@ describe("V1 to V2 Migration", () => { }; const result = transformProjectV1ToV2({ project: projectWithBlur }); - const settings = result.project.settings as Record<string, unknown>; - const background = settings.background as Record<string, unknown>; + const settings = asRecord(result.project.settings); + const background = asRecord(settings.background); expect(background.type).toBe("blur"); expect(background.blurIntensity).toBe(30); }); @@ -67,7 +68,7 @@ describe("V1 to V2 Migration", () => { test("applies legacy bookmarks to main scene", () => { const result = transformProjectV1ToV2({ project: v1Project }); - const scenes = result.project.scenes as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); const mainScene = scenes.find((s) => s.isMain === true); expect(mainScene?.bookmarks).toEqual(v1Project.bookmarks); }); @@ -77,7 +78,7 @@ describe("V1 to V2 Migration", () => { project: v1ProjectWithMultipleScenes, }); - const scenes = result.project.scenes as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); const introScene = scenes.find((s) => s.name === "Intro"); expect(introScene?.bookmarks).toEqual([1.0]); }); @@ -102,7 +103,7 @@ describe("V1 to V2 Migration", () => { }); expect(result.skipped).toBe(false); - const settings = result.project.settings as Record<string, unknown>; + const settings = asRecord(result.project.settings); expect(settings.fps).toBe(DEFAULT_FPS); expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE); }); @@ -115,11 +116,11 @@ describe("V1 to V2 Migration", () => { }; const result = transformProjectV1ToV2({ project: minimalProject }); - const settings = result.project.settings as Record<string, unknown>; + const settings = asRecord(result.project.settings); expect(settings.fps).toBe(DEFAULT_FPS); expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE); - const background = settings.background as Record<string, unknown>; + const background = asRecord(settings.background); expect(background.type).toBe("color"); expect(background.color).toBe(DEFAULT_BACKGROUND_COLOR); }); @@ -135,8 +136,8 @@ describe("V1 to V2 Migration", () => { project: projectWithBlurNoIntensity, }); - const settings = result.project.settings as Record<string, unknown>; - const background = settings.background as Record<string, unknown>; + const settings = asRecord(result.project.settings); + const background = asRecord(settings.background); expect(background.blurIntensity).toBe(DEFAULT_BACKGROUND_BLUR_INTENSITY); }); @@ -183,9 +184,9 @@ describe("V1 to V2 Migration", () => { project: projectWithTracks, }); - const scenes = result.project.scenes as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); const mainScene = scenes[0]; - const tracks = mainScene.tracks as Array<Record<string, unknown>>; + const tracks = asRecordArray(mainScene.tracks); expect(tracks.length).toBe(1); expect(tracks[0].name).toBe("Existing Track"); }); @@ -240,9 +241,9 @@ describe("V1 to V2 Migration", () => { context, }); - const scenes = result.project.scenes as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); const mainScene = scenes[0]; - const tracks = mainScene.tracks as Array<Record<string, unknown>>; + const tracks = asRecordArray(mainScene.tracks); expect(Array.isArray(tracks)).toBe(true); expect(tracks).toHaveLength(1); expect(tracks[0].type).toBe("video"); @@ -302,9 +303,9 @@ describe("V1 to V2 Migration", () => { }); expect(result.skipped).toBe(false); - const scenes = result.project.scenes as Array<Record<string, unknown>>; - const tracks = scenes[0].tracks as Array<Record<string, unknown>>; - const elements = tracks[0].elements as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); + const tracks = asRecordArray(scenes[0].tracks); + const elements = asRecordArray(tracks[0].elements); const textElement = elements[0]; expect(textElement.opacity).toBe(0.5); expect(textElement.transform).toEqual({ diff --git a/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts b/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts index 122af2cb..1af445ac 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test";
import { transformProjectV15ToV16 } from "../transformers/v15-to-v16";
+import { asRecordArray } from "./helpers";
describe("V15 to V16 Migration", () => {
test("renames sticker tracks to graphic tracks", () => {
@@ -61,10 +62,10 @@ describe("V15 to V16 Migration", () => { expect(result.skipped).toBe(false);
expect(result.project.version).toBe(16);
- expect(
- (result.project.scenes as Array<{ tracks: Array<{ type: string }> }>)[0]
- .tracks[0].type,
- ).toBe("graphic");
+ const firstTrack = asRecordArray(
+ asRecordArray(result.project.scenes)[0].tracks,
+ )[0];
+ expect(firstTrack.type).toBe("graphic");
});
test("skips projects already on v16", () => {
diff --git a/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts b/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts index 352ca762..10a792ca 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test";
import { transformProjectV16ToV17 } from "../transformers/v16-to-v17";
+import { asRecord, asRecordArray } from "./helpers";
describe("V16 to V17 Migration", () => {
test("adds center stroke alignment to masks that do not have it", () => {
@@ -97,13 +98,13 @@ describe("V16 to V17 Migration", () => { expect(result.skipped).toBe(false);
expect(result.project.version).toBe(17);
- const migratedMasks = (
- ((result.project.scenes as Array<{ tracks: Array<{ elements: Array<{ masks: Array<{ params: Record<string, unknown> }> }> }> }>)[0]
- .tracks[0].elements[0].masks)
- );
+ const firstScene = asRecordArray(result.project.scenes)[0];
+ const firstTrack = asRecordArray(firstScene.tracks)[0];
+ const firstElement = asRecordArray(firstTrack.elements)[0];
+ const migratedMasks = asRecordArray(firstElement.masks);
- expect(migratedMasks[0].params.strokeAlign).toBe("center");
- expect(migratedMasks[1].params.strokeAlign).toBe("outside");
+ expect(asRecord(migratedMasks[0].params).strokeAlign).toBe("center");
+ expect(asRecord(migratedMasks[1].params).strokeAlign).toBe("outside");
});
test("skips projects already on v17", () => {
diff --git a/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts b/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts index 0f679b6d..0cb30f2b 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test";
import { transformProjectV18ToV19 } from "../transformers/v18-to-v19";
+import { asRecord } from "./helpers";
describe("V18 to V19 Migration", () => {
test("adds canvas size mode and empty remembered custom size defaults", () => {
@@ -26,15 +27,13 @@ describe("V18 to V19 Migration", () => { expect(result.skipped).toBe(false);
expect(result.project.version).toBe(19);
- expect(
- (result.project.settings as Record<string, unknown>).canvasSizeMode,
- ).toBe("preset");
- expect(
- (result.project.settings as Record<string, unknown>).lastCustomCanvasSize,
- ).toBeNull();
- expect(
- (result.project.settings as Record<string, unknown>).originalCanvasSize,
- ).toEqual({ width: 1920, height: 1080 });
+ const settings = asRecord(result.project.settings);
+ expect(settings.canvasSizeMode).toBe("preset");
+ expect(settings.lastCustomCanvasSize).toBeNull();
+ expect(settings.originalCanvasSize).toEqual({
+ width: 1920,
+ height: 1080,
+ });
});
test("skips projects already on v19", () => {
diff --git a/apps/web/src/services/storage/migrations/__tests__/v19-to-v20.test.ts b/apps/web/src/services/storage/migrations/__tests__/v19-to-v20.test.ts index 0e0cfbbe..b1bfe86f 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v19-to-v20.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v19-to-v20.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV19ToV20 } from "../transformers/v19-to-v20"; +import { asRecordArray } from "./helpers"; describe("V19 to V20 Migration", () => { test("backfills source audio enabled state on video elements", () => { @@ -82,19 +83,16 @@ describe("V19 to V20 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(20); - expect( - ((result.project.scenes as Array<Record<string, unknown>>)[0] - .tracks as Array<Record<string, unknown>>)[0].elements, - ).toEqual([ + const scene = asRecordArray(result.project.scenes)[0]; + const tracks = asRecordArray(scene.tracks); + expect(tracks[0].elements).toEqual([ expect.objectContaining({ id: "video-1", isSourceAudioEnabled: true, }), ]); expect( - (((result.project.scenes as Array<Record<string, unknown>>)[0] - .tracks as Array<Record<string, unknown>>)[1] - .elements as Array<Record<string, unknown>>)[0].isSourceAudioEnabled, + asRecordArray(tracks[1].elements)[0].isSourceAudioEnabled, ).toBeUndefined(); }); @@ -120,11 +118,10 @@ describe("V19 to V20 Migration", () => { }, }); - expect( - (((result.project.scenes as Array<Record<string, unknown>>)[0] - .tracks as Array<Record<string, unknown>>)[0] - .elements as Array<Record<string, unknown>>)[0].isSourceAudioEnabled, - ).toBe(false); + const scene = asRecordArray(result.project.scenes)[0]; + const track = asRecordArray(scene.tracks)[0]; + const element = asRecordArray(track.elements)[0]; + expect(element.isSourceAudioEnabled).toBe(false); }); test("skips projects already on v20", () => { diff --git a/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts b/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts index ba8b3bfb..d18b4dd4 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts @@ -8,6 +8,7 @@ import { v2ProjectWithBlurBackground, v3Project, } from "./fixtures"; +import { asRecord } from "./helpers"; describe("V2 to V3 Migration", () => { describe("transformProjectV2ToV3", () => { @@ -17,14 +18,14 @@ describe("V2 to V3 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(3); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(typeof metadata.duration).toBe("number"); }); test("calculates duration from scene tracks", () => { const result = transformProjectV2ToV3({ project: v2Project }); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); // v2Project has a video element with duration 15.5 and a text element at startTime 2 with duration 5 // Total duration should be max(15.5, 2+5) = 15.5 expect(metadata.duration).toBe(15.5); @@ -36,7 +37,7 @@ describe("V2 to V3 Migration", () => { }); expect(result.skipped).toBe(false); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); // v2ProjectWithBlurBackground has a video with duration 30 expect(metadata.duration).toBe(30); }); @@ -45,7 +46,7 @@ describe("V2 to V3 Migration", () => { const result = transformProjectV2ToV3({ project: v2ProjectEmptyScenes }); expect(result.skipped).toBe(false); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.duration).toBe(0); }); @@ -55,7 +56,7 @@ describe("V2 to V3 Migration", () => { }); expect(result.skipped).toBe(false); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.duration).toBe(0); }); @@ -91,7 +92,7 @@ describe("V2 to V3 Migration", () => { test("preserves existing metadata fields", () => { const result = transformProjectV2ToV3({ project: v2Project }); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.id).toBe(v2Project.metadata.id); expect(metadata.name).toBe(v2Project.metadata.name); expect(metadata.thumbnail).toBe(v2Project.metadata.thumbnail); @@ -122,7 +123,7 @@ describe("V2 to V3 Migration", () => { }); expect(result.skipped).toBe(false); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.duration).toBe(0); }); @@ -156,7 +157,7 @@ describe("V2 to V3 Migration", () => { }; const result = transformProjectV2ToV3({ project: multiSceneProject }); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); // Duration is from main scene only, not sum of all scenes expect(metadata.duration).toBe(10); }); diff --git a/apps/web/src/services/storage/migrations/__tests__/v20-to-v21.test.ts b/apps/web/src/services/storage/migrations/__tests__/v20-to-v21.test.ts index 3d77a775..2737bfc3 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v20-to-v21.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v20-to-v21.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV20ToV21 } from "../transformers/v20-to-v21"; +import { asRecord } from "./helpers"; const LEGACY_DEFAULT_BACKGROUND_BLUR_INTENSITY = 50; @@ -27,8 +28,8 @@ describe("V20 to V21 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(21); - const settings = result.project.settings as Record<string, unknown>; - const background = settings.background as Record<string, unknown>; + const settings = asRecord(result.project.settings); + const background = asRecord(settings.background); expect(background.blurIntensity).toBe(500); }); @@ -45,8 +46,8 @@ describe("V20 to V21 Migration", () => { }); expect(result.skipped).toBe(false); - const settings = result.project.settings as Record<string, unknown>; - const background = settings.background as Record<string, unknown>; + const settings = asRecord(result.project.settings); + const background = asRecord(settings.background); expect(background.blurIntensity).toBe( LEGACY_DEFAULT_BACKGROUND_BLUR_INTENSITY, ); @@ -65,7 +66,7 @@ describe("V20 to V21 Migration", () => { }); expect(result.skipped).toBe(false); - const settings = result.project.settings as Record<string, unknown>; + const settings = asRecord(result.project.settings); expect(settings.background).toEqual({ type: "color", color: "#000000" }); }); diff --git a/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts b/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts index 18b815b0..76d9c0e1 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV21ToV22 } from "../transformers/v21-to-v22"; +import { asRecord, asRecordArray } from "./helpers"; describe("V21 to V22 Migration", () => { test("migrates legacy animation channels to bindings and component channels", () => { @@ -77,12 +78,12 @@ describe("V21 to V22 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(22); - const scenes = result.project.scenes as Array<Record<string, unknown>>; - const tracks = scenes[0].tracks as Array<Record<string, unknown>>; - const elements = tracks[0].elements as Array<Record<string, unknown>>; - const animations = elements[0].animations as Record<string, unknown>; - const bindings = animations.bindings as Record<string, Record<string, unknown>>; - const channels = animations.channels as Record<string, Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); + const tracks = asRecordArray(scenes[0].tracks); + const elements = asRecordArray(tracks[0].elements); + const animations = asRecord(elements[0].animations); + const bindings = asRecord(animations.bindings); + const channels = asRecord(animations.channels); expect(bindings.opacity).toEqual({ path: "opacity", diff --git a/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts b/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts index 5b764b48..e229ecbc 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV22ToV23 } from "../transformers/v22-to-v23"; +import { asRecord, asRecordArray } from "./helpers"; describe("V22 to V23 Migration", () => { test("converts project time values from seconds to ticks and fps to a frame-rate object", () => { @@ -101,20 +102,17 @@ describe("V22 to V23 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(23); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.duration).toBe(1_860_000); - const settings = result.project.settings as Record<string, unknown>; + const settings = asRecord(result.project.settings); expect(settings.fps).toEqual({ numerator: 30_000, denominator: 1_001 }); - const timelineViewState = result.project.timelineViewState as Record< - string, - unknown - >; + const timelineViewState = asRecord(result.project.timelineViewState); expect(timelineViewState.playheadTime).toBe(150_000); expect(timelineViewState.scrollLeft).toBe(120); - const scenes = result.project.scenes as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); const scene = scenes[0]; expect(scene.bookmarks).toEqual([ { @@ -126,8 +124,8 @@ describe("V22 to V23 Migration", () => { { time: 540_000 }, ]); - const tracks = scene.tracks as Array<Record<string, unknown>>; - const elements = tracks[0].elements as Array<Record<string, unknown>>; + const tracks = asRecordArray(scene.tracks); + const elements = asRecordArray(tracks[0].elements); const element = elements[0]; expect(element.startTime).toBe(150_000); expect(element.duration).toBe(660_000); @@ -135,8 +133,8 @@ describe("V22 to V23 Migration", () => { expect(element.trimEnd).toBe(60_000); expect(element.sourceDuration).toBe(750_000); - const animations = element.animations as Record<string, unknown>; - const channels = animations.channels as Record<string, Record<string, unknown>>; + const animations = asRecord(element.animations); + const channels = asRecord(animations.channels); expect(channels["opacity:value"]).toEqual({ kind: "scalar", keys: [ diff --git a/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts b/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts index 730d958d..5e16e886 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV26ToV27 } from "../transformers/v26-to-v27"; +import { asRecord, asRecordArray } from "./helpers"; describe("V26 to V27 Migration", () => { test("converts custom mask paths from JSON strings to typed point arrays", () => { @@ -59,13 +60,13 @@ describe("V26 to V27 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(27); - const scenes = result.project.scenes as Array<Record<string, unknown>>; - const tracks = scenes[0].tracks as Record<string, unknown>; - const mainTrack = tracks.main as Record<string, unknown>; - const elements = mainTrack.elements as Array<Record<string, unknown>>; - const masks = elements[0].masks as Array<Record<string, unknown>>; - const customParams = masks[0].params as Record<string, unknown>; - const rectangleParams = masks[1].params as Record<string, unknown>; + const scenes = asRecordArray(result.project.scenes); + const tracks = asRecord(scenes[0].tracks); + const mainTrack = asRecord(tracks.main); + const elements = asRecordArray(mainTrack.elements); + const masks = asRecordArray(elements[0].masks); + const customParams = asRecord(masks[0].params); + const rectangleParams = asRecord(masks[1].params); expect(customParams.path).toEqual([ { diff --git a/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts b/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts index d467c8a1..cb09e07d 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV27ToV28 } from "../transformers/v27-to-v28"; +import { asRecord, asRecordArray } from "./helpers"; describe("V27 to V28 Migration", () => { test("rounds persisted media-time floats back to integer ticks", () => { @@ -96,18 +97,15 @@ describe("V27 to V28 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(28); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.duration).toBe(2_152_466); - const timelineViewState = result.project.timelineViewState as Record< - string, - unknown - >; + const timelineViewState = asRecord(result.project.timelineViewState); expect(timelineViewState.playheadTime).toBe(301_235); expect(timelineViewState.zoomLevel).toBe(1.25); expect(timelineViewState.scrollLeft).toBe(120); - const scenes = result.project.scenes as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); const scene = scenes[0]; expect(scene.bookmarks).toEqual([ { @@ -118,9 +116,9 @@ describe("V27 to V28 Migration", () => { }, ]); - const tracks = scene.tracks as Record<string, unknown>; - const mainTrack = tracks.main as Record<string, unknown>; - const elements = mainTrack.elements as Array<Record<string, unknown>>; + const tracks = asRecord(scene.tracks); + const mainTrack = asRecord(tracks.main); + const elements = asRecordArray(mainTrack.elements); const element = elements[0]; expect(element.startTime).toBe(300_000); expect(element.duration).toBe(2_152_466); @@ -128,9 +126,9 @@ describe("V27 to V28 Migration", () => { expect(element.trimEnd).toBe(15_001); expect(element.sourceDuration).toBe(2_197_468); - const animations = element.animations as Record<string, unknown>; - const channels = animations.channels as Record<string, Record<string, unknown>>; - const opacityChannel = channels.opacity; + const animations = asRecord(element.animations); + const channels = asRecord(animations.channels); + const opacityChannel = asRecord(channels.opacity); expect(opacityChannel.keys).toEqual([ { id: "key-1", @@ -204,26 +202,23 @@ describe("V27 to V28 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(28); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.duration).toBe(120_000); - const timelineViewState = result.project.timelineViewState as Record< - string, - unknown - >; + const timelineViewState = asRecord(result.project.timelineViewState); expect(timelineViewState).toEqual({ zoomLevel: 2, scrollLeft: 300, playheadTime: 30_000, }); - const scenes = result.project.scenes as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); const scene = scenes[0]; expect(scene.bookmarks).toEqual([{ time: 60_000, duration: 15_000 }]); - const tracks = scene.tracks as Record<string, unknown>; - const mainTrack = tracks.main as Record<string, unknown>; - const element = (mainTrack.elements as Array<Record<string, unknown>>)[0]; + const tracks = asRecord(scene.tracks); + const mainTrack = asRecord(tracks.main); + const element = asRecordArray(mainTrack.elements)[0]; expect(element.startTime).toBe(10_000); expect(element.duration).toBe(20_000); expect(element.trimStart).toBe(1_000); diff --git a/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts b/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts index 6c2d6597..e5ad7e34 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test";
import { transformProjectV3ToV4 } from "../transformers/v3-to-v4";
import { v3Project } from "./fixtures";
+import { asRecordArray } from "./helpers";
describe("V3 to V4 Migration", () => {
test("normalizes legacy text fontWeight values", () => {
@@ -54,15 +55,9 @@ describe("V3 to V4 Migration", () => { expect(result.skipped).toBe(false);
expect(result.project.version).toBe(4);
- const migratedScene = (
- result.project.scenes as Array<Record<string, unknown>>
- )[0];
- const migratedTrack = (
- migratedScene.tracks as Array<Record<string, unknown>>
- )[0];
- const migratedElement = (
- migratedTrack.elements as Array<Record<string, unknown>>
- )[0];
+ const migratedScene = asRecordArray(result.project.scenes)[0];
+ const migratedTrack = asRecordArray(migratedScene.tracks)[0];
+ const migratedElement = asRecordArray(migratedTrack.elements)[0];
expect(migratedElement.fontWeight).toBe("700");
});
@@ -104,15 +99,9 @@ describe("V3 to V4 Migration", () => { };
const result = transformProjectV3ToV4({ project: projectWithoutTextTrack });
- const migratedScene = (
- result.project.scenes as Array<Record<string, unknown>>
- )[0];
- const migratedTrack = (
- migratedScene.tracks as Array<Record<string, unknown>>
- )[0];
- const migratedElement = (
- migratedTrack.elements as Array<Record<string, unknown>>
- )[0];
+ const migratedScene = asRecordArray(result.project.scenes)[0];
+ const migratedTrack = asRecordArray(migratedScene.tracks)[0];
+ const migratedElement = asRecordArray(migratedTrack.elements)[0];
expect(migratedElement.iconName).toBe("mdi:home");
});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts b/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts index 95386137..16e9e7ca 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test";
import { transformProjectV4ToV5 } from "../transformers/v4-to-v5";
import { v3Project } from "./fixtures";
+import { asRecordArray } from "./helpers";
describe("V4 to V5 Migration", () => {
test("migrates sticker iconName to stickerId and removes legacy color", () => {
@@ -48,15 +49,9 @@ describe("V4 to V5 Migration", () => { expect(result.skipped).toBe(false);
expect(result.project.version).toBe(5);
- const migratedScene = (
- result.project.scenes as Array<Record<string, unknown>>
- )[0];
- const migratedTrack = (
- migratedScene.tracks as Array<Record<string, unknown>>
- )[0];
- const migratedElement = (
- migratedTrack.elements as Array<Record<string, unknown>>
- )[0];
+ const migratedScene = asRecordArray(result.project.scenes)[0];
+ const migratedTrack = asRecordArray(migratedScene.tracks)[0];
+ const migratedElement = asRecordArray(migratedTrack.elements)[0];
expect(migratedElement.stickerId).toBe("icons:mdi:home");
expect("iconName" in migratedElement).toBe(false);
@@ -101,15 +96,9 @@ describe("V4 to V5 Migration", () => { };
const result = transformProjectV4ToV5({ project: projectWithStickerId });
- const migratedScene = (
- result.project.scenes as Array<Record<string, unknown>>
- )[0];
- const migratedTrack = (
- migratedScene.tracks as Array<Record<string, unknown>>
- )[0];
- const migratedElement = (
- migratedTrack.elements as Array<Record<string, unknown>>
- )[0];
+ const migratedScene = asRecordArray(result.project.scenes)[0];
+ const migratedTrack = asRecordArray(migratedScene.tracks)[0];
+ const migratedElement = asRecordArray(migratedTrack.elements)[0];
expect(migratedElement.stickerId).toBe("flags:AD");
});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts index b05d1837..8fd267fd 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV5ToV6 } from "../transformers/v5-to-v6"; import { v5Project } from "./fixtures"; +import { asRecordArray } from "./helpers"; describe("V5 to V6 Migration", () => { test("converts number bookmarks to Bookmark objects", async () => { @@ -13,19 +14,13 @@ describe("V5 to V6 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(6); - const mainScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[0]; - expect(mainScene.bookmarks).toEqual([ + const scenes = asRecordArray(result.project.scenes); + expect(scenes[0].bookmarks).toEqual([ { time: 2.0 }, { time: 5.5 }, { time: 12.0 }, ]); - - const introScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[1]; - expect(introScene.bookmarks).toEqual([]); + expect(scenes[1].bookmarks).toEqual([]); }); test("skips projects that are already v6", () => { @@ -83,9 +78,7 @@ describe("V5 to V6 Migration", () => { }); expect(result.skipped).toBe(false); - const mainScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[0]; + const mainScene = asRecordArray(result.project.scenes)[0]; expect(mainScene.bookmarks).toEqual([ { time: 1, note: "Intro", color: "#ef4444" }, { time: 5.5, duration: 2 }, diff --git a/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts b/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts index aa79a6ea..ce8e9bd3 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test";
import { transformProjectV8ToV9 } from "../transformers/v8-to-v9";
+import { asRecord, asRecordArray } from "./helpers";
const v8ProjectWithText = {
id: "project-v8-text",
@@ -70,16 +71,16 @@ describe("V8 to V9 Migration", () => { expect(result.skipped).toBe(false);
expect(result.project.version).toBe(9);
- const track = (
- result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
- )[0].tracks[0];
- const elements = track.elements as Array<{ background: { enabled: boolean; color: string } }>;
+ const track = asRecordArray(asRecordArray(result.project.scenes)[0].tracks)[0];
+ const elements = asRecordArray(track.elements);
+ const background0 = asRecord(elements[0].background);
+ const background1 = asRecord(elements[1].background);
- expect(elements[0].background.enabled).toBe(true);
- expect(elements[0].background.color).toBe("#ff0000");
+ expect(background0.enabled).toBe(true);
+ expect(background0.color).toBe("#ff0000");
- expect(elements[1].background.enabled).toBe(false);
- expect(elements[1].background.color).toBe("transparent");
+ expect(background1.enabled).toBe(false);
+ expect(background1.color).toBe("transparent");
});
test("preserves existing background.enabled if already present", () => {
@@ -87,7 +88,7 @@ describe("V8 to V9 Migration", () => { ...v8ProjectWithText,
scenes: [
{
- ...(v8ProjectWithText.scenes as Record<string, unknown>[])[0],
+ ...asRecordArray(v8ProjectWithText.scenes)[0],
tracks: [
{
id: "track-text",
@@ -116,10 +117,9 @@ describe("V8 to V9 Migration", () => { const result = transformProjectV8ToV9({ project: projectWithEnabled });
expect(result.skipped).toBe(false);
- const elements = (
- result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
- )[0].tracks[0].elements as Array<{ background: { enabled: boolean } }>;
- expect(elements[0].background.enabled).toBe(false);
+ const track = asRecordArray(asRecordArray(result.project.scenes)[0].tracks)[0];
+ const elements = asRecordArray(track.elements);
+ expect(asRecord(elements[0].background).enabled).toBe(false);
});
test("skips non-text elements and tracks", () => {
diff --git a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts index 065a6b34..e9e23c4e 100644 --- a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts +++ b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts @@ -19,34 +19,6 @@ const EMPTY_V1_TO_V2_CONTEXT: V1ToV2Context = { mediaTypesById: {}, }; -interface LegacyMediaElement { - type: "media"; - mediaId: string; - muted?: boolean; - [key: string]: unknown; -} - -interface LegacyTextElement { - type: "text"; - x: number; - y: number; - rotation: number; - opacity: number; - [key: string]: unknown; -} - -interface LegacyAudioElement { - type: "audio"; - mediaId: string; - [key: string]: unknown; -} - -interface LegacyMediaTrack { - type: "media"; - elements: unknown[]; - [key: string]: unknown; -} - interface V2Transform { scale: number; position: { x: number; y: number }; @@ -315,7 +287,7 @@ function transformTracks({ const isMain = !isFirstVideoTrackFound; isFirstVideoTrackFound = true; const videoTrack = transformMediaTrack({ - track: track as LegacyMediaTrack, + track, context, isMain, }); @@ -343,7 +315,7 @@ function transformMediaTrack({ context, isMain, }: { - track: LegacyMediaTrack; + track: Record<string, unknown>; context: V1ToV2Context; isMain: boolean; }): V2VideoTrack { @@ -354,8 +326,7 @@ function transformMediaTrack({ return null; } - const mediaElement = element as LegacyMediaElement; - const mediaId = getStringValue({ value: mediaElement.mediaId }); + const mediaId = getStringValue({ value: element.mediaId }); if (!mediaId) { return null; } @@ -372,7 +343,7 @@ function transformMediaTrack({ rotate: 0, }; - const muted = mediaElement.muted === true; + const muted = element.muted === true; if (mediaType === "image") { const imageElement: V2ImageElement = { @@ -442,15 +413,14 @@ function transformTextTrack({ return null; } - const textElement = element as LegacyTextElement; - const x = getNumberValue({ value: textElement.x, fallback: 0 }); - const y = getNumberValue({ value: textElement.y, fallback: 0 }); + const x = getNumberValue({ value: element.x, fallback: 0 }); + const y = getNumberValue({ value: element.y, fallback: 0 }); const rotation = getNumberValue({ - value: textElement.rotation, + value: element.rotation, fallback: 0, }); const opacity = getNumberValue({ - value: textElement.opacity, + value: element.opacity, fallback: 1, }); @@ -464,23 +434,23 @@ function transformTextTrack({ id: getStringValue({ value: element.id, fallback: "" }), name: getStringValue({ value: element.name, fallback: "" }), type: "text", - content: getStringValue({ value: textElement.content, fallback: "" }), + content: getStringValue({ value: element.content, fallback: "" }), fontSize: getNumberValue({ - value: textElement.fontSize, + value: element.fontSize, fallback: 16, }), fontFamily: getStringValue({ - value: textElement.fontFamily, + value: element.fontFamily, fallback: "Arial", }), color: getStringValue({ - value: textElement.color, + value: element.color, fallback: "#000000", }), background: { enabled: false, color: getStringValue({ - value: textElement.backgroundColor, + value: element.backgroundColor, fallback: "transparent", }), cornerRadius: 0, @@ -489,22 +459,26 @@ function transformTextTrack({ offsetX: 0, offsetY: 0, }, - textAlign: (getStringValue({ - value: textElement.textAlign, + textAlign: parseEnum({ + value: element.textAlign, + allowed: ["left", "center", "right"] as const, fallback: "left", - }) || "left") as "left" | "center" | "right", - fontWeight: (getStringValue({ - value: textElement.fontWeight, + }), + fontWeight: parseEnum({ + value: element.fontWeight, + allowed: ["normal", "bold"] as const, fallback: "normal", - }) || "normal") as "normal" | "bold", - fontStyle: (getStringValue({ - value: textElement.fontStyle, + }), + fontStyle: parseEnum({ + value: element.fontStyle, + allowed: ["normal", "italic"] as const, fallback: "normal", - }) || "normal") as "normal" | "italic", - textDecoration: (getStringValue({ - value: textElement.textDecoration, + }), + textDecoration: parseEnum({ + value: element.textDecoration, + allowed: ["none", "underline", "line-through"] as const, fallback: "none", - }) || "none") as "none" | "underline" | "line-through", + }), hidden: false, transform, opacity, @@ -538,8 +512,7 @@ function transformAudioTrack({ return null; } - const audioElement = element as LegacyAudioElement; - const mediaId = getStringValue({ value: audioElement.mediaId }); + const mediaId = getStringValue({ value: element.mediaId }); if (!mediaId) { return null; } @@ -762,6 +735,23 @@ function getStringValue({ return fallback; } +function parseEnum<T extends string>({ + value, + allowed, + fallback, +}: { + value: unknown; + allowed: readonly T[]; + fallback: T; +}): T { + for (const candidate of allowed) { + if (value === candidate) { + return candidate; + } + } + return fallback; +} + function normalizeDateString({ value }: { value: unknown }): string { if (value instanceof Date) { return value.toISOString(); diff --git a/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts b/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts index 89dde38e..6e152adb 100644 --- a/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts +++ b/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts @@ -94,10 +94,7 @@ function migrateSplitMask({ mask }: { mask: unknown }): unknown { const x = (position - 0.5) * Math.cos(angleRad);
const y = (position - 0.5) * Math.sin(angleRad);
- const { position: _removed, ...restParams } = params as Record<
- string,
- unknown
- > & { position: unknown };
+ const { position: _removed, ...restParams } = params;
return {
...mask,
diff --git a/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts b/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts index 3b118ee7..4477d363 100644 --- a/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts +++ b/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts @@ -12,19 +12,18 @@ interface CustomMaskPathPoint { } function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint { - if (!value || typeof value !== "object") { + if (!isRecord(value)) { return false; } - const candidate = value as Record<string, unknown>; return ( - typeof candidate.id === "string" && - typeof candidate.x === "number" && - typeof candidate.y === "number" && - typeof candidate.inX === "number" && - typeof candidate.inY === "number" && - typeof candidate.outX === "number" && - typeof candidate.outY === "number" + typeof value.id === "string" && + typeof value.x === "number" && + typeof value.y === "number" && + typeof value.inX === "number" && + typeof value.inY === "number" && + typeof value.outX === "number" && + typeof value.outY === "number" ); } diff --git a/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts b/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts index 84d97fd8..7a28134b 100644 --- a/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts +++ b/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts @@ -1,10 +1,10 @@ import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
-const LEGACY_FONT_WEIGHT_MAP = {
- normal: "400",
- bold: "700",
-} as const;
+const LEGACY_FONT_WEIGHT_MAP = new Map<string, string>([
+ ["normal", "400"],
+ ["bold", "700"],
+]);
const VALID_NUMERIC_FONT_WEIGHTS = new Set([
"100",
@@ -168,10 +168,9 @@ function normalizeFontWeight({ value }: { value: unknown }): unknown { }
const normalized = value.trim().toLowerCase();
- if (normalized in LEGACY_FONT_WEIGHT_MAP) {
- return LEGACY_FONT_WEIGHT_MAP[
- normalized as keyof typeof LEGACY_FONT_WEIGHT_MAP
- ];
+ const mapped = LEGACY_FONT_WEIGHT_MAP.get(normalized);
+ if (mapped !== undefined) {
+ return mapped;
}
if (VALID_NUMERIC_FONT_WEIGHTS.has(normalized)) {
diff --git a/apps/web/src/services/storage/opfs-adapter.ts b/apps/web/src/services/storage/opfs-adapter.ts index 879223b5..220ee452 100644 --- a/apps/web/src/services/storage/opfs-adapter.ts +++ b/apps/web/src/services/storage/opfs-adapter.ts @@ -27,7 +27,13 @@ export class OPFSAdapter implements StorageAdapter<File> { } } - async set(key: string, file: File): Promise<void> { + async set({ + key, + value: file, + }: { + key: string; + value: File; + }): Promise<void> { const directory = await this.getDirectory(); const fileHandle = await directory.getFileHandle(key, { create: true }); const writable = await fileHandle.createWritable(); diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts index a388e807..e8fc26c0 100644 --- a/apps/web/src/services/storage/service.ts +++ b/apps/web/src/services/storage/service.ts @@ -65,17 +65,17 @@ class StorageService { version: 1, }; - this.projectsAdapter = new IndexedDBAdapter<SerializedProject>( - this.config.projectsDb, - "projects", - this.config.version, - ); + this.projectsAdapter = new IndexedDBAdapter<SerializedProject>({ + dbName: this.config.projectsDb, + storeName: "projects", + version: this.config.version, + }); - this.savedSoundsAdapter = new IndexedDBAdapter<SavedSoundsData>( - this.config.savedSoundsDb, - "saved-sounds", - this.config.version, - ); + this.savedSoundsAdapter = new IndexedDBAdapter<SavedSoundsData>({ + dbName: this.config.savedSoundsDb, + storeName: "saved-sounds", + version: this.config.version, + }); } private async ensureMigrations(): Promise<void> { @@ -91,11 +91,11 @@ class StorageService { } private getProjectMediaAdapters({ projectId }: { projectId: string }) { - const mediaMetadataAdapter = new IndexedDBAdapter<MediaAssetData>( - `${this.config.mediaDb}-${projectId}`, - "media-metadata", - this.config.version, - ); + const mediaMetadataAdapter = new IndexedDBAdapter<MediaAssetData>({ + dbName: `${this.config.mediaDb}-${projectId}`, + storeName: "media-metadata", + version: this.config.version, + }); const mediaAssetsAdapter = new OPFSAdapter(`media-files-${projectId}`); @@ -161,7 +161,10 @@ class StorageService { timelineViewState: project.timelineViewState, }; - await this.projectsAdapter.set(project.metadata.id, serializedProject); + await this.projectsAdapter.set({ + key: project.metadata.id, + value: serializedProject, + }); } async loadProject({ @@ -305,8 +308,14 @@ class StorageService { }; try { - await mediaAssetsAdapter.set(mediaAsset.id, mediaAsset.file); - await mediaMetadataAdapter.set(mediaAsset.id, metadata); + await mediaAssetsAdapter.set({ + key: mediaAsset.id, + value: mediaAsset.file, + }); + await mediaMetadataAdapter.set({ + key: mediaAsset.id, + value: metadata, + }); } catch (error) { try { await mediaAssetsAdapter.remove(mediaAsset.id); @@ -501,7 +510,10 @@ class StorageService { lastModified: new Date().toISOString(), }; - await this.savedSoundsAdapter.set("user-sounds", updatedData); + await this.savedSoundsAdapter.set({ + key: "user-sounds", + value: updatedData, + }); } catch (error) { console.error("Failed to save sound effect:", error); throw error; @@ -517,7 +529,10 @@ class StorageService { lastModified: new Date().toISOString(), }; - await this.savedSoundsAdapter.set("user-sounds", updatedData); + await this.savedSoundsAdapter.set({ + key: "user-sounds", + value: updatedData, + }); } catch (error) { console.error("Failed to remove saved sound:", error); throw error; diff --git a/apps/web/src/services/storage/types.ts b/apps/web/src/services/storage/types.ts index 58c14b49..f20b311a 100644 --- a/apps/web/src/services/storage/types.ts +++ b/apps/web/src/services/storage/types.ts @@ -8,7 +8,7 @@ import type { TScene } from "@/timeline"; export interface StorageAdapter<T> { get(key: string): Promise<T | null>; - set(key: string, value: T): Promise<void>; + set(args: { key: string; value: T }): Promise<void>; remove(key: string): Promise<void>; list(): Promise<string[]>; clear(): Promise<void>; diff --git a/apps/web/src/stickers/providers/index.ts b/apps/web/src/stickers/providers/index.ts index ca23963c..56b3f6cb 100644 --- a/apps/web/src/stickers/providers/index.ts +++ b/apps/web/src/stickers/providers/index.ts @@ -1,24 +1,24 @@ -import { stickersRegistry } from "../registry";
-import type { StickerProvider } from "@/stickers/types";
-import { flagsProvider } from "./flags";
-import { logosProvider } from "./logos";
-import { shapesProvider } from "./shapes";
-
-const defaultProviders: StickerProvider[] = [
- logosProvider,
- flagsProvider,
- shapesProvider,
-];
-
-export function registerDefaultStickerProviders({
- providersToRegister = defaultProviders,
-}: {
- providersToRegister?: StickerProvider[];
-} = {}): void {
- for (const provider of providersToRegister) {
- if (stickersRegistry.has(provider.id)) {
- continue;
- }
- stickersRegistry.register(provider.id, provider);
- }
-}
+import { stickersRegistry } from "../registry"; +import type { StickerProvider } from "@/stickers/types"; +import { flagsProvider } from "./flags"; +import { logosProvider } from "./logos"; +import { shapesProvider } from "./shapes"; + +const defaultProviders: StickerProvider[] = [ + logosProvider, + flagsProvider, + shapesProvider, +]; + +export function registerDefaultStickerProviders({ + providersToRegister = defaultProviders, +}: { + providersToRegister?: StickerProvider[]; +} = {}): void { + for (const provider of providersToRegister) { + if (stickersRegistry.has(provider.id)) { + continue; + } + stickersRegistry.register(provider.id, provider); + } +} diff --git a/apps/web/src/subtitles/components/assets-view.tsx b/apps/web/src/subtitles/components/assets-view.tsx index 7e2ddad9..84480fa4 100644 --- a/apps/web/src/subtitles/components/assets-view.tsx +++ b/apps/web/src/subtitles/components/assets-view.tsx @@ -64,6 +64,7 @@ const IDLE_STATE: ProcessingState = { warnings: [], }; +/* eslint-disable opencut/prefer-object-params -- React reducers must accept (state, action). */ function processingReducer( state: ProcessingState, action: ProcessingAction, @@ -80,6 +81,7 @@ function processingReducer( return { status: "idle", error: action.error, warnings: [] }; } } +/* eslint-enable opencut/prefer-object-params */ export function Captions() { const [selectedLanguage, setSelectedLanguage] = diff --git a/apps/web/src/subtitles/insert.ts b/apps/web/src/subtitles/insert.ts index 50d6c40b..2a71e181 100644 --- a/apps/web/src/subtitles/insert.ts +++ b/apps/web/src/subtitles/insert.ts @@ -18,7 +18,7 @@ export function insertCaptionChunksAsTextTrack({ return null; } - const addTrackCommand = new AddTrackCommand("text", 0); + const addTrackCommand = new AddTrackCommand({ type: "text", index: 0 }); const trackId = addTrackCommand.getTrackId(); const canvasSize = editor.project.getActive().settings.canvasSize; const insertCommands = captions.map( diff --git a/apps/web/src/text/components/assets-view.tsx b/apps/web/src/text/components/assets-view.tsx index ffabe3b8..82a8e46c 100644 --- a/apps/web/src/text/components/assets-view.tsx +++ b/apps/web/src/text/components/assets-view.tsx @@ -1,47 +1,47 @@ -import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
-import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
-import { useEditor } from "@/editor/use-editor";
-import { DEFAULTS } from "@/timeline/defaults";
-import { buildTextElement } from "@/timeline/element-utils";
-import type { MediaTime } from "@/wasm";
-
-export function TextView() {
- const editor = useEditor();
-
- const handleAddToTimeline = ({ currentTime }: { currentTime: MediaTime }) => {
- const activeScene = editor.scenes.getActiveScene();
- if (!activeScene) return;
-
- const element = buildTextElement({
- raw: DEFAULTS.text.element,
- startTime: currentTime,
- });
-
- editor.timeline.insertElement({
- element,
- placement: { mode: "auto" },
- });
- };
-
- return (
- <PanelView title="Text">
- <DraggableItem
- name="Default text"
- preview={
- <div className="bg-accent flex size-full items-center justify-center rounded">
- <span className="text-xs select-none">Default text</span>
- </div>
- }
- dragData={{
- id: "temp-text-id",
- type: DEFAULTS.text.element.type,
- name: DEFAULTS.text.element.name,
- content: DEFAULTS.text.element.content,
- }}
- aspectRatio={1}
- onAddToTimeline={handleAddToTimeline}
- shouldShowLabel={false}
- />
- </PanelView>
- );
-}
+import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; +import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; +import { useEditor } from "@/editor/use-editor"; +import { DEFAULTS } from "@/timeline/defaults"; +import { buildTextElement } from "@/timeline/element-utils"; +import type { MediaTime } from "@/wasm"; + +export function TextView() { + const editor = useEditor(); + + const handleAddToTimeline = ({ currentTime }: { currentTime: MediaTime }) => { + const activeScene = editor.scenes.getActiveScene(); + if (!activeScene) return; + + const element = buildTextElement({ + raw: DEFAULTS.text.element, + startTime: currentTime, + }); + + editor.timeline.insertElement({ + element, + placement: { mode: "auto" }, + }); + }; + + return ( + <PanelView title="Text"> + <DraggableItem + name="Default text" + preview={ + <div className="bg-accent flex size-full items-center justify-center rounded"> + <span className="text-xs select-none">Default text</span> + </div> + } + dragData={{ + id: "temp-text-id", + type: DEFAULTS.text.element.type, + name: DEFAULTS.text.element.name, + content: DEFAULTS.text.element.content, + }} + aspectRatio={1} + onAddToTimeline={handleAddToTimeline} + shouldShowLabel={false} + /> + </PanelView> + ); +} diff --git a/apps/web/src/text/components/text-tab.tsx b/apps/web/src/text/components/text-tab.tsx index 0f0b274a..e69de29b 100644 --- a/apps/web/src/text/components/text-tab.tsx +++ b/apps/web/src/text/components/text-tab.tsx @@ -1,767 +0,0 @@ -"use client"; - -import { Textarea } from "@/components/ui/textarea"; -import { FontPicker } from "@/components/ui/font-picker"; -import type { TextElement } from "@/timeline"; -import { NumberField } from "@/components/ui/number-field"; -import { useRef } from "react"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { ColorPicker } from "@/components/ui/color-picker"; -import { Button } from "@/components/ui/button"; -import { uppercase } from "@/utils/string"; -import { clamp, formatNumberForDisplay } from "@/utils/math"; -import { useEditor } from "@/editor/use-editor"; -import { CORNER_RADIUS_MAX, CORNER_RADIUS_MIN } from "@/text/background"; -import { - DEFAULT_TEXT_COLOR, - MAX_FONT_SIZE, - MIN_FONT_SIZE, -} from "@/text/typography"; -import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft"; -import { useKeyframedColorProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-color-property"; -import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; -import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; -import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; -import { isPropertyAtDefault } from "@/rendering/components/transform-tab"; -import { - resolveColorAtTime, - resolveNumberAtTime, -} from "@/animation/values"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - MinusSignIcon, - PlusSignIcon, - TextFontIcon, -} from "@hugeicons/core-free-icons"; -import { OcTextHeightIcon, OcTextWidthIcon } from "@/components/icons"; -import { DEFAULTS } from "@/timeline/defaults"; -import { cn } from "@/utils/ui"; - -export function TextTab({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - return ( - <div className="flex flex-col"> - <ContentSection element={element} trackId={trackId} /> - <TypographySection element={element} trackId={trackId} /> - <SpacingSection element={element} trackId={trackId} /> - <BackgroundSection element={element} trackId={trackId} /> - </div> - ); -} - -function ContentSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - - const content = usePropertyDraft({ - displayValue: element.content, - parse: (input) => input, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { content: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:content`}> - <SectionHeader> - <SectionTitle>Content</SectionTitle> - </SectionHeader> - <SectionContent> - <Textarea - placeholder="Name" - value={content.displayValue} - className="min-h-20" - onFocus={content.onFocus} - onChange={content.onChange} - onBlur={content.onBlur} - /> - </SectionContent> - </Section> - ); -} - -function TypographySection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedTextColor = resolveColorAtTime({ - baseColor: element.color, - animations: element.animations, - propertyPath: "color", - localTime, - }); - - const textColor = useKeyframedColorProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "color", - localTime, - isPlayheadWithinElementRange, - resolvedColor: resolvedTextColor, - buildBaseUpdates: ({ value }) => ({ color: value }), - }); - - const fontSize = usePropertyDraft({ - displayValue: element.fontSize.toString(), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ - value: Math.round(parsed), - min: MIN_FONT_SIZE, - max: MAX_FONT_SIZE, - }); - }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { fontSize: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:typography`}> - <SectionHeader> - <SectionTitle>Typography</SectionTitle> - </SectionHeader> - <SectionContent> - <SectionFields> - <SectionField label="Font"> - <FontPicker - defaultValue={element.fontFamily} - onValueChange={(value) => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { fontFamily: value }, - }, - ], - }) - } - /> - </SectionField> - <SectionField label="Size"> - <NumberField - value={fontSize.displayValue} - min={MIN_FONT_SIZE} - max={MAX_FONT_SIZE} - onFocus={fontSize.onFocus} - onChange={fontSize.onChange} - onBlur={fontSize.onBlur} - onScrub={fontSize.scrubTo} - onScrubEnd={fontSize.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { - fontSize: DEFAULTS.text.element.fontSize, - }, - }, - ], - }) - } - isDefault={element.fontSize === DEFAULTS.text.element.fontSize} - icon={<HugeiconsIcon icon={TextFontIcon} />} - /> - </SectionField> - <SectionField - label="Color" - beforeLabel={ - <KeyframeToggle - isActive={textColor.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle text color keyframe" - onToggle={textColor.toggleKeyframe} - /> - } - > - <ColorPicker - value={uppercase({ - string: resolvedTextColor.replace("#", ""), - })} - onChange={(color) => textColor.onChange({ color: `#${color}` })} - onChangeEnd={textColor.onChangeEnd} - /> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - ); -} - -function SpacingSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - - const letterSpacing = usePropertyDraft({ - displayValue: Math.round( - element.letterSpacing ?? DEFAULTS.text.letterSpacing, - ).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.round(parsed); - }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { letterSpacing: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const lineHeight = usePropertyDraft({ - displayValue: formatNumberForDisplay({ - value: element.lineHeight ?? DEFAULTS.text.lineHeight, - fractionDigits: 1, - }), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) - ? null - : Math.max(0.1, Math.round(parsed * 10) / 10); - }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { lineHeight: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:spacing`}> - <SectionHeader> - <SectionTitle>Spacing</SectionTitle> - </SectionHeader> - <SectionContent> - <div className="flex items-start gap-2"> - <SectionField label="Letter spacing" className="w-1/2"> - <NumberField - value={letterSpacing.displayValue} - onFocus={letterSpacing.onFocus} - onChange={letterSpacing.onChange} - onBlur={letterSpacing.onBlur} - onScrub={letterSpacing.scrubTo} - onScrubEnd={letterSpacing.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { letterSpacing: DEFAULTS.text.letterSpacing }, - }, - ], - }) - } - isDefault={ - (element.letterSpacing ?? DEFAULTS.text.letterSpacing) === - DEFAULTS.text.letterSpacing - } - icon={<OcTextWidthIcon size={14} />} - /> - </SectionField> - <SectionField label="Line height" className="w-1/2"> - <NumberField - value={lineHeight.displayValue} - onFocus={lineHeight.onFocus} - onChange={lineHeight.onChange} - onBlur={lineHeight.onBlur} - onScrub={lineHeight.scrubTo} - onScrubEnd={lineHeight.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { lineHeight: DEFAULTS.text.lineHeight }, - }, - ], - }) - } - isDefault={ - (element.lineHeight ?? DEFAULTS.text.lineHeight) === - DEFAULTS.text.lineHeight - } - icon={<OcTextHeightIcon size={14} />} - /> - </SectionField> - </div> - </SectionContent> - </Section> - ); -} - -function BackgroundSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - const lastSelectedColor = useRef(DEFAULT_TEXT_COLOR); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedBgColor = resolveColorAtTime({ - baseColor: element.background.color, - animations: element.animations, - propertyPath: "background.color", - localTime, - }); - - const bgColor = useKeyframedColorProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.color", - localTime, - isPlayheadWithinElementRange, - resolvedColor: resolvedBgColor, - buildBaseUpdates: ({ value }) => ({ - background: { ...element.background, color: value }, - }), - }); - - const bg = element.background; - - const resolvedPaddingX = resolveNumberAtTime({ - baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, - animations: element.animations, - propertyPath: "background.paddingX", - localTime, - }); - const resolvedPaddingY = resolveNumberAtTime({ - baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, - animations: element.animations, - propertyPath: "background.paddingY", - localTime, - }); - const resolvedOffsetX = resolveNumberAtTime({ - baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, - animations: element.animations, - propertyPath: "background.offsetX", - localTime, - }); - const resolvedOffsetY = resolveNumberAtTime({ - baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, - animations: element.animations, - propertyPath: "background.offsetY", - localTime, - }); - const resolvedCornerRadius = resolveNumberAtTime({ - baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, - animations: element.animations, - propertyPath: "background.cornerRadius", - localTime, - }); - - const paddingX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.paddingX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedPaddingX).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); - }, - valueAtPlayhead: resolvedPaddingX, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, paddingX: value }, - }), - }); - - const paddingY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.paddingY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedPaddingY).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); - }, - valueAtPlayhead: resolvedPaddingY, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, paddingY: value }, - }), - }); - - const offsetX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.offsetX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedOffsetX).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.round(parsed); - }, - valueAtPlayhead: resolvedOffsetX, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, offsetX: value }, - }), - }); - - const offsetY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.offsetY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedOffsetY).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.round(parsed); - }, - valueAtPlayhead: resolvedOffsetY, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, offsetY: value }, - }), - }); - - const cornerRadius = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.cornerRadius", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedCornerRadius).toString(), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ - value: Math.round(parsed), - min: CORNER_RADIUS_MIN, - max: CORNER_RADIUS_MAX, - }); - }, - valueAtPlayhead: resolvedCornerRadius, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, cornerRadius: value }, - }), - }); - - const toggleBackgroundEnabled = () => { - const enabled = !element.background.enabled; - const color = - enabled && element.background.color === "transparent" - ? lastSelectedColor.current - : element.background.color; - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { - background: { - ...element.background, - enabled, - color, - }, - }, - }, - ], - }); - }; - - return ( - <Section - collapsible - defaultOpen={element.background.enabled} - sectionKey={`${element.id}:background`} - > - <SectionHeader - trailing={ - <Button - variant="ghost" - size="icon" - onClick={(event) => { - event.stopPropagation(); - toggleBackgroundEnabled(); - }} - > - <HugeiconsIcon - icon={element.background.enabled ? MinusSignIcon : PlusSignIcon} - strokeWidth={1} - /> - </Button> - } - > - <SectionTitle>Background</SectionTitle> - </SectionHeader> - <SectionContent - className={cn( - !element.background.enabled && "pointer-events-none opacity-50", - )} - > - <SectionFields> - <SectionField - label="Color" - beforeLabel={ - <KeyframeToggle - isActive={bgColor.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle background color keyframe" - onToggle={bgColor.toggleKeyframe} - /> - } - > - <ColorPicker - value={ - !element.background.enabled || - element.background.color === "transparent" - ? lastSelectedColor.current.replace("#", "") - : resolvedBgColor.replace("#", "") - } - onChange={(color) => { - const hexColor = `#${color}`; - if (color !== "transparent") { - lastSelectedColor.current = hexColor; - } - bgColor.onChange({ color: hexColor }); - }} - onChangeEnd={bgColor.onChangeEnd} - /> - </SectionField> - <div className="flex items-start gap-2"> - <SectionField - label="Width" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={paddingX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle background width keyframe" - onToggle={paddingX.toggleKeyframe} - /> - } - > - <NumberField - icon="W" - value={paddingX.displayValue} - min={0} - onFocus={paddingX.onFocus} - onChange={paddingX.onChange} - onBlur={paddingX.onBlur} - onScrub={paddingX.scrubTo} - onScrubEnd={paddingX.commitScrub} - onReset={() => - paddingX.commitValue({ - value: DEFAULTS.text.background.paddingX, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedPaddingX, - staticValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, - defaultValue: DEFAULTS.text.background.paddingX, - })} - /> - </SectionField> - <SectionField - label="Height" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={paddingY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle background height keyframe" - onToggle={paddingY.toggleKeyframe} - /> - } - > - <NumberField - icon="H" - value={paddingY.displayValue} - min={0} - onFocus={paddingY.onFocus} - onChange={paddingY.onChange} - onBlur={paddingY.onBlur} - onScrub={paddingY.scrubTo} - onScrubEnd={paddingY.commitScrub} - onReset={() => - paddingY.commitValue({ - value: DEFAULTS.text.background.paddingY, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedPaddingY, - staticValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, - defaultValue: DEFAULTS.text.background.paddingY, - })} - /> - </SectionField> - </div> - <div className="flex items-start gap-2"> - <SectionField - label="X-offset" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={offsetX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle x-offset keyframe" - onToggle={offsetX.toggleKeyframe} - /> - } - > - <NumberField - icon="X" - value={offsetX.displayValue} - onFocus={offsetX.onFocus} - onChange={offsetX.onChange} - onBlur={offsetX.onBlur} - onScrub={offsetX.scrubTo} - onScrubEnd={offsetX.commitScrub} - onReset={() => - offsetX.commitValue({ - value: DEFAULTS.text.background.offsetX, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedOffsetX, - staticValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, - defaultValue: DEFAULTS.text.background.offsetX, - })} - /> - </SectionField> - <SectionField - label="Y-offset" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={offsetY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle y-offset keyframe" - onToggle={offsetY.toggleKeyframe} - /> - } - > - <NumberField - icon="Y" - value={offsetY.displayValue} - onFocus={offsetY.onFocus} - onChange={offsetY.onChange} - onBlur={offsetY.onBlur} - onScrub={offsetY.scrubTo} - onScrubEnd={offsetY.commitScrub} - onReset={() => - offsetY.commitValue({ - value: DEFAULTS.text.background.offsetY, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedOffsetY, - staticValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, - defaultValue: DEFAULTS.text.background.offsetY, - })} - /> - </SectionField> - </div> - <SectionField - label="Corner radius" - beforeLabel={ - <KeyframeToggle - isActive={cornerRadius.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle corner radius keyframe" - onToggle={cornerRadius.toggleKeyframe} - /> - } - > - <NumberField - icon="R" - value={cornerRadius.displayValue} - min={CORNER_RADIUS_MIN} - max={CORNER_RADIUS_MAX} - onFocus={cornerRadius.onFocus} - onChange={cornerRadius.onChange} - onBlur={cornerRadius.onBlur} - onScrub={cornerRadius.scrubTo} - onScrubEnd={cornerRadius.commitScrub} - onReset={() => - cornerRadius.commitValue({ value: CORNER_RADIUS_MIN }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedCornerRadius, - staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, - defaultValue: CORNER_RADIUS_MIN, - })} - /> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - ); -} diff --git a/apps/web/src/timeline/__tests__/update-pipeline.test.ts b/apps/web/src/timeline/__tests__/update-pipeline.test.ts index 94b32ced..e6d63bd6 100644 --- a/apps/web/src/timeline/__tests__/update-pipeline.test.ts +++ b/apps/web/src/timeline/__tests__/update-pipeline.test.ts @@ -1,66 +1,66 @@ -import { describe, expect, test } from "bun:test";
-import type { Transform } from "@/rendering";
-import type { SceneTracks, VideoElement } from "@/timeline";
-import { applyElementUpdate } from "@/timeline/update-pipeline";
-import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm";
-
-function buildTransform(): Transform {
- return {
- scaleX: 1,
- scaleY: 1,
- position: { x: 0, y: 0 },
- rotate: 0,
- };
-}
-
-function buildVideoElement(overrides: Partial<VideoElement> = {}): VideoElement {
- return {
- id: "video-1",
- type: "video",
- name: "Video 1",
- startTime: ZERO_MEDIA_TIME,
- duration: mediaTime({ ticks: 10 }),
- trimStart: ZERO_MEDIA_TIME,
- trimEnd: ZERO_MEDIA_TIME,
- mediaId: "media-1",
- transform: buildTransform(),
- opacity: 1,
- ...overrides,
- };
-}
-
-function buildTracks(element: VideoElement): SceneTracks {
- return {
- overlay: [],
- main: {
- id: "main-track",
- type: "video",
- name: "Main",
- muted: false,
- hidden: false,
- elements: [element],
- },
- audio: [],
- };
-}
-
-describe("applyElementUpdate", () => {
- test("rounds retimed durations back to integer media time", () => {
- const element = buildVideoElement();
- const tracks = buildTracks(element);
-
- const updatedElement = applyElementUpdate({
- element,
- patch: {
- retime: { rate: 1.5 },
- },
- context: {
- tracks,
- trackId: tracks.main.id,
- },
- });
-
- expect(updatedElement.duration).toBe(7);
- expect(Number.isInteger(updatedElement.duration)).toBe(true);
- });
-});
+import { describe, expect, test } from "bun:test"; +import type { Transform } from "@/rendering"; +import type { SceneTracks, VideoElement } from "@/timeline"; +import { applyElementUpdate } from "@/timeline/update-pipeline"; +import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm"; + +function buildTransform(): Transform { + return { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }; +} + +function buildVideoElement(overrides: Partial<VideoElement> = {}): VideoElement { + return { + id: "video-1", + type: "video", + name: "Video 1", + startTime: ZERO_MEDIA_TIME, + duration: mediaTime({ ticks: 10 }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + mediaId: "media-1", + transform: buildTransform(), + opacity: 1, + ...overrides, + }; +} + +function buildTracks(element: VideoElement): SceneTracks { + return { + overlay: [], + main: { + id: "main-track", + type: "video", + name: "Main", + muted: false, + hidden: false, + elements: [element], + }, + audio: [], + }; +} + +describe("applyElementUpdate", () => { + test("rounds retimed durations back to integer media time", () => { + const element = buildVideoElement(); + const tracks = buildTracks(element); + + const updatedElement = applyElementUpdate({ + element, + patch: { + retime: { rate: 1.5 }, + }, + context: { + tracks, + trackId: tracks.main.id, + }, + }); + + expect(updatedElement.duration).toBe(7); + expect(Number.isInteger(updatedElement.duration)).toBe(true); + }); +}); diff --git a/apps/web/src/timeline/animation-properties.ts b/apps/web/src/timeline/animation-properties.ts deleted file mode 100644 index 308f6cf0..00000000 --- a/apps/web/src/timeline/animation-properties.ts +++ /dev/null @@ -1,426 +0,0 @@ -import type { - AnimationBindingKind, - AnimationInterpolation, - AnimationPropertyPath, - AnimationValue, - ElementAnimations, - NumericSpec, -} from "@/animation/types"; -import { upsertPathKeyframe } from "@/animation"; -import { parseColorToLinearRgba } from "@/animation/binding-values"; -import type { MediaTime } from "@/wasm"; -import { isAnimationPropertyPath } from "@/animation/path"; -import { MIN_TRANSFORM_SCALE } from "@/animation/transform"; -import { snapToStep } from "@/utils/math"; -import type { TimelineElement } from "@/timeline"; -import { - CORNER_RADIUS_MAX, - CORNER_RADIUS_MIN, -} from "@/text/background"; -import { - canElementHaveAudio, - isVisualElement, -} from "@/timeline/element-utils"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; -import { DEFAULTS } from "@/timeline/defaults"; - -export interface AnimationPropertyDefinition { - kind: AnimationBindingKind; - defaultInterpolation: AnimationInterpolation; - numericRanges?: Partial<Record<string, NumericSpec>>; - supportsElement: ({ element }: { element: TimelineElement }) => boolean; - getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; - coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; - setValue: ({ - element, - value, - }: { - element: TimelineElement; - value: AnimationValue; - }) => TimelineElement; -} - -function applyNumericSpec({ - value, - numericRange, -}: { - value: number; - numericRange: NumericSpec | undefined; -}): number { - if (!numericRange) { - return value; - } - - const steppedValue = - numericRange.step != null - ? snapToStep({ value, step: numericRange.step }) - : value; - const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; - const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); -} - -function coerceNumberValue({ - value, - numericRange, -}: { - value: AnimationValue; - numericRange?: NumericSpec; -}): number | null { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - return applyNumericSpec({ value, numericRange }); -} - -function coerceColorValue({ - value, -}: { - value: AnimationValue; -}): string | null { - return typeof value === "string" && parseColorToLinearRgba({ color: value }) - ? value - : null; -} - -function createNumberPropertyDefinition({ - numericRange, - supportsElement, - getValue, - setValue, -}: { - numericRange?: NumericSpec; - supportsElement: AnimationPropertyDefinition["supportsElement"]; - getValue: AnimationPropertyDefinition["getValue"]; - setValue: AnimationPropertyDefinition["setValue"]; -}): AnimationPropertyDefinition { - return { - kind: "number", - defaultInterpolation: "linear", - numericRanges: numericRange ? { value: numericRange } : undefined, - supportsElement, - getValue, - coerceValue: ({ value }) => - coerceNumberValue({ - value, - numericRange, - }), - setValue, - }; -} - -const ANIMATION_PROPERTY_REGISTRY: Record< - AnimationPropertyPath, - AnimationPropertyDefinition -> = { - "transform.positionX": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position.x : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: { ...element.transform.position, x: value as number }, - }, - } - : element, - }), - "transform.positionY": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position.y : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: { ...element.transform.position, y: value as number }, - }, - } - : element, - }), - "transform.scaleX": createNumberPropertyDefinition({ - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleX : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleX: value as number }, - } - : element, - }), - "transform.scaleY": createNumberPropertyDefinition({ - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleY : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleY: value as number }, - } - : element, - }), - "transform.rotate": createNumberPropertyDefinition({ - numericRange: { min: -360, max: 360, step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.rotate : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, rotate: value as number }, - } - : element, - }), - opacity: createNumberPropertyDefinition({ - numericRange: { min: 0, max: 1, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.opacity : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { ...element, opacity: value as number } - : element, - }), - volume: createNumberPropertyDefinition({ - numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, - supportsElement: ({ element }) => canElementHaveAudio(element), - getValue: ({ element }) => - canElementHaveAudio(element) ? element.volume ?? 0 : null, - setValue: ({ element, value }) => - canElementHaveAudio(element) - ? { ...element, volume: value as number } - : element, - }), - color: { - kind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => (element.type === "text" ? element.color : null), - coerceValue: ({ value }) => coerceColorValue({ value }), - setValue: ({ element, value }) => - element.type === "text" - ? { ...element, color: value as string } - : element, - }, - "background.color": { - kind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" ? element.background.color : null, - coerceValue: ({ value }) => coerceColorValue({ value }), - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, color: value as string }, - } - : element, - }, - "background.paddingX": createNumberPropertyDefinition({ - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingX: value as number }, - } - : element, - }), - "background.paddingY": createNumberPropertyDefinition({ - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingY: value as number }, - } - : element, - }), - "background.offsetX": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetX: value as number }, - } - : element, - }), - "background.offsetY": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetY: value as number }, - } - : element, - }), - "background.cornerRadius": createNumberPropertyDefinition({ - numericRange: { - min: CORNER_RADIUS_MIN, - max: CORNER_RADIUS_MAX, - step: 1, - }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, cornerRadius: value as number }, - } - : element, - }), -}; - -export function getAnimationPropertyDefinition({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationPropertyDefinition { - return ANIMATION_PROPERTY_REGISTRY[propertyPath]; -} - -export function supportsAnimationProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): boolean { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.supportsElement({ element }); -} - -export function getElementBaseValueForProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): AnimationValue | null { - const definition = getAnimationPropertyDefinition({ propertyPath }); - if (!definition.supportsElement({ element })) { - return null; - } - - return definition.getValue({ element }); -} - -export function withElementBaseValueForProperty({ - element, - propertyPath, - value, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): TimelineElement { - const definition = getAnimationPropertyDefinition({ propertyPath }); - const coercedValue = definition.coerceValue({ value }); - if (coercedValue === null || !definition.supportsElement({ element })) { - return element; - } - - return definition.setValue({ element, value: coercedValue }); -} - -export function getDefaultInterpolationForProperty({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationInterpolation { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.defaultInterpolation; -} - -export function coerceAnimationValueForProperty({ - propertyPath, - value, -}: { - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): AnimationValue | null { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.coerceValue({ value }); -} - -export function upsertElementKeyframe({ - animations, - propertyPath, - time, - value, - interpolation, - keyframeId, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - time: MediaTime; - value: AnimationValue; - interpolation?: AnimationInterpolation; - keyframeId?: string; -}): ElementAnimations | undefined { - const coercedValue = coerceAnimationValueForProperty({ - propertyPath, - value, - }); - if (coercedValue === null) { - return animations; - } - - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return upsertPathKeyframe({ - animations, - propertyPath, - time, - value: coercedValue, - interpolation, - keyframeId, - kind: propertyDefinition.kind, - defaultInterpolation: propertyDefinition.defaultInterpolation, - coerceValue: ({ value: nextValue }) => - coerceAnimationValueForProperty({ - propertyPath, - value: nextValue, - }), - }); -} diff --git a/apps/web/src/timeline/animation-targets.ts b/apps/web/src/timeline/animation-targets.ts index f9d53fcc..fe683f92 100644 --- a/apps/web/src/timeline/animation-targets.ts +++ b/apps/web/src/timeline/animation-targets.ts @@ -28,7 +28,7 @@ import { getAnimationPropertyDefinition, getElementBaseValueForProperty, withElementBaseValueForProperty, -} from "./animation-properties"; +} from "@/animation"; export interface AnimationPathDescriptor { kind: AnimationBindingKind; diff --git a/apps/web/src/timeline/components/audio-volume-line.tsx b/apps/web/src/timeline/components/audio-volume-line.tsx index 1baac2bd..ac2fd32a 100644 --- a/apps/web/src/timeline/components/audio-volume-line.tsx +++ b/apps/web/src/timeline/components/audio-volume-line.tsx @@ -242,8 +242,7 @@ export function AudioVolumeLine({ )} style={{ top: lineTop }} /> - {/* biome-ignore lint/a11y/noStaticElementInteractions: timeline volume line is a pointer-only editing surface */} - {/* biome-ignore lint/a11y/useKeyWithClickEvents: timeline volume line is a pointer-only editing surface */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- custom drag widget for clip volume; pointer events drive the interaction, the click/mousedown handlers are propagation-stoppers. The a11y-correct long-term shape is <input type="range"> with a custom thumb. */} <div className="absolute inset-x-0 -translate-y-1/2 touch-none cursor-ns-resize pointer-events-auto" style={{ top: lineTop, height: `${HIT_AREA_HEIGHT_PX}px` }} diff --git a/apps/web/src/timeline/components/audio-waveform.tsx b/apps/web/src/timeline/components/audio-waveform.tsx index 7777bc09..87178f17 100644 --- a/apps/web/src/timeline/components/audio-waveform.tsx +++ b/apps/web/src/timeline/components/audio-waveform.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useResizeObserver } from "@/hooks/use-resize-observer"; import { TIMELINE_AUDIO_WAVEFORM_COLOR } from "./theme"; import { @@ -72,21 +73,19 @@ export function AudioWaveform({ const canvasRef = useRef<HTMLCanvasElement>(null); const containerRef = useRef<HTMLDivElement>(null); const summaryRef = useRef<SourceWaveformSummary | null>(null); - const gainSamplesRef = useRef<number[] | undefined>(gainSamples); - const pixelsPerSecondRef = useRef<number>(pixelsPerSecond); - const clipDurationSecRef = useRef<number>(clipDurationSec); - const retimeRef = useRef<RetimeConfig | undefined>(retime); - const sourceStartSecRef = useRef<number>(sourceStartSec); + const waveformConfigRef = useCommittedRef({ + gainSamples, + pixelsPerSecond, + clipDurationSec, + retime, + sourceStartSec, + color, + burnColor, + }); const scrollParentRef = useRef<HTMLElement | null>(null); const heightRef = useRef<number>(0); const lastRenderSignatureRef = useRef<string | null>(null); - gainSamplesRef.current = gainSamples; - pixelsPerSecondRef.current = pixelsPerSecond; - clipDurationSecRef.current = clipDurationSec; - retimeRef.current = retime; - sourceStartSecRef.current = sourceStartSec; - const clearCanvas = useCallback(() => { const canvas = canvasRef.current; if (!canvas) { @@ -142,13 +141,19 @@ export function AudioWaveform({ return; } + const { + gainSamples: gainSamplesValue, + pixelsPerSecond: pixelsPerSecondValue, + clipDurationSec: clipDurationSecValue, + retime: retimeValue, + sourceStartSec: sourceStartSecValue, + color: colorValue, + burnColor: burnColorValue, + } = waveformConfigRef.current; const dpr = window.devicePixelRatio || 1; const canvasW = Math.max(1, Math.ceil(visibleWidth * dpr)); const canvasH = Math.max(1, Math.round(height * dpr)); const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP)); - const pixelsPerSecondValue = pixelsPerSecondRef.current; - const clipDurationSecValue = clipDurationSecRef.current; - const samples = gainSamplesRef.current; const renderSignature = JSON.stringify({ elementWidth, clipLeft, @@ -159,16 +164,16 @@ export function AudioWaveform({ barCount, dpr, clipDurationSec: clipDurationSecValue, - sourceStartSec: sourceStartSecRef.current, + sourceStartSec: sourceStartSecValue, pixelsPerSecond: pixelsPerSecondValue, - retime: retimeRef.current ?? null, + retime: retimeValue ?? null, summarySourceKey: summary.sourceKey, summarySampleRate: summary.sampleRate, summaryTotalSamples: summary.totalSamples, summaryBucketSize: summary.bucketSize, - gainSamples: samples ?? null, - color, - burnColor, + gainSamples: gainSamplesValue ?? null, + color: colorValue, + burnColor: burnColorValue, }); if (lastRenderSignatureRef.current === renderSignature) { return; @@ -190,8 +195,8 @@ export function AudioWaveform({ barCount, pixelsPerSecond: pixelsPerSecondValue, clipDurationSec: clipDurationSecValue, - sourceStartSec: sourceStartSecRef.current, - retime: retimeRef.current, + sourceStartSec: sourceStartSecValue, + retime: retimeValue, sampleRate: summary.sampleRate, maxSampleExclusive: summary.totalSamples, barStepPx: BAR_STEP, @@ -218,9 +223,9 @@ export function AudioWaveform({ Math.min(clipDurationSecValue, barCenterPx / pixelsPerSecondValue), ); const gain = - samples != null + gainSamplesValue != null ? sampleGainAtClipTime({ - samples, + samples: gainSamplesValue, clipTimeSec: clipCenterSec, clipDurationSec: clipDurationSecValue, }) @@ -243,7 +248,7 @@ export function AudioWaveform({ const deviceTop = Math.round((height - barH) * backingScaleY); const deviceHeight = Math.max(1, clipBottom - deviceTop); - ctx.fillStyle = color; + ctx.fillStyle = colorValue; ctx.fillRect( deviceLeft, deviceTop, @@ -253,7 +258,7 @@ export function AudioWaveform({ if (outputAmplitude > 1) { const burnHeight = Math.max(1, Math.round(BAR_WIDTH * backingScaleY)); - ctx.fillStyle = burnColor; + ctx.fillStyle = burnColorValue; ctx.fillRect( deviceLeft, deviceTop, @@ -262,7 +267,7 @@ export function AudioWaveform({ ); } } - }, [burnColor, clearCanvas, color]); + }, [clearCanvas, waveformConfigRef]); useEffect(() => { let isCancelled = false; @@ -296,7 +301,6 @@ export function AudioWaveform({ }; }, [audioBuffer, audioUrl, clearCanvas, drawVisible, sourceFile, sourceKey]); - // biome-ignore lint/correctness/useExhaustiveDependencies: these props are mirrored into refs during render, but the effect must still re-run to redraw when they change. useLayoutEffect(() => { drawVisible(); }, [ @@ -306,6 +310,8 @@ export function AudioWaveform({ clipDurationSec, retime, sourceStartSec, + color, + burnColor, ]); useEffect(() => { diff --git a/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx b/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx index 7d77b985..ff4219c5 100644 --- a/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx +++ b/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx @@ -2,6 +2,7 @@ import { useRef, useState, type PointerEvent } from "react"; import { useShiftKey } from "@/hooks/use-shift-key"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { getBezierPoint } from "@/animation/bezier"; import type { NormalizedCubicBezier } from "@/animation/types"; import { cn } from "@/utils/ui"; @@ -87,9 +88,7 @@ export function BezierGraph({ const svgRef = useRef<SVGSVGElement>(null); const [activeHandle, setActiveHandle] = useState<BezierHandle | null>(null); const isShiftPressedRef = useShiftKey(); - const latestValueRef = useRef(value); - - latestValueRef.current = value; + const latestValueRef = useCommittedRef(value); function getPointerPosition({ event, diff --git a/apps/web/src/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx index 490ad1cc..8edbb6fc 100644 --- a/apps/web/src/timeline/components/index.tsx +++ b/apps/web/src/timeline/components/index.tsx @@ -29,6 +29,7 @@ import { useState, type ReactNode, } from "react"; +import { useContainerSize } from "@/hooks/use-container-size"; import type { MediaTime } from "@/wasm"; import type { ElementDragView, DropTarget } from "@/timeline"; import { TimelineTrackContent } from "./timeline-track"; @@ -80,6 +81,7 @@ import { useInitialScrollBottom } from "@/timeline/hooks/use-initial-scroll-bott import { useTimelineResize } from "@/timeline/hooks/use-timeline-resize"; import { useTimelineStore } from "@/timeline/timeline-store"; import { useEditor } from "@/editor/use-editor"; +import { useScrollPosition } from "@/timeline/hooks/use-scroll-position"; import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead"; import { DragLine } from "./drag-line"; import { invokeAction } from "@/actions"; @@ -148,15 +150,25 @@ export function Timeline() { const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( null, ); + const { width: tracksContainerWidth } = useContainerSize({ + containerRef: tracksContainerRef, + }); + const { height: timelineHeaderHeightValue } = useContainerSize({ + containerRef: timelineHeaderRef, + }); + const { viewportWidth: tracksViewportWidth } = useScrollPosition({ + scrollRef: tracksScrollRef, + }); const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { setCurrentSnapPoint(snapPoint); }, []); const timelineDuration = timeline.getTotalDuration() || 0; + const containerWidth = tracksContainerWidth || FALLBACK_CONTAINER_WIDTH; const minZoomLevel = getTimelineZoomMin({ duration: timelineDuration, - containerWidth: tracksContainerRef.current?.clientWidth, + containerWidth, }); const savedViewState = editor.project.getTimelineViewState(); @@ -358,8 +370,6 @@ export function Timeline() { }, }); - const containerWidth = - tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; const contentWidth = timelineTimeToPixels({ time: timelineDuration, zoomLevel, @@ -373,11 +383,8 @@ export function Timeline() { contentWidth + paddingPx, containerWidth, ); - const tracksViewportWidth = - tracksScrollRef.current?.clientWidth ?? - tracksContainerRef.current?.clientWidth ?? - containerWidth; - const hasHorizontalScrollbar = dynamicTimelineWidth > tracksViewportWidth; + const hasHorizontalScrollbar = + dynamicTimelineWidth > (tracksViewportWidth || containerWidth); useEdgeAutoScroll({ isActive: bookmarkDragState.isDragging, @@ -419,8 +426,7 @@ export function Timeline() { }); const timelineHeaderHeight = - (timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) + - TIMELINE_CONTENT_TOP_PADDING_PX || 0; + timelineHeaderHeightValue + TIMELINE_CONTENT_TOP_PADDING_PX; return ( <section @@ -450,10 +456,7 @@ export function Timeline() { ref={tracksContainerRef} > <SelectionBox - startPos={selectionBox?.startPos || null} - currentPos={selectionBox?.currentPos || null} - containerRef={tracksContainerRef} - isActive={selectionBox?.isActive || false} + bounds={selectionBox?.bounds ?? null} /> <DragLine dropTarget={dropTarget} @@ -509,8 +512,7 @@ export function Timeline() { className="flex min-h-full flex-col" style={{ width: `${dynamicTimelineWidth}px` }} > - {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system */} - {/* biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- spatial gesture surface (tracks container background); direct-target clicks here originate box-select or clear selection. Keyboard control is global timeline shortcuts. */} <div className="relative shrink-0" style={{ @@ -891,9 +893,10 @@ function TimelineGutter({ onMouseDown: (event: React.MouseEvent) => void; onClick: (event: React.MouseEvent) => void; }) { - // biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system - // biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system - return <div className="flex-1" onMouseDown={onMouseDown} onClick={onClick} />; + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- spatial gesture surface (empty space below tracks); clicks here clear selection. Keyboard control is global timeline shortcuts. + <div className="flex-1" onMouseDown={onMouseDown} onClick={onClick} /> + ); } function TrackIcon({ track }: { track: TimelineTrack }) { diff --git a/apps/web/src/timeline/components/timeline-element.tsx b/apps/web/src/timeline/components/timeline-element.tsx index cb102c71..be8409f3 100644 --- a/apps/web/src/timeline/components/timeline-element.tsx +++ b/apps/web/src/timeline/components/timeline-element.tsx @@ -31,6 +31,7 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; +import type { SelectionBoxBounds } from "@/selection/types"; import type { TimelineElement as TimelineElementType, TimelineTrack, @@ -51,7 +52,7 @@ import { getTimelinePixelsPerSecond } from "@/timeline"; import { buildWaveformSourceKey } from "@/media/waveform-summary"; import { addMediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm"; import { - getActionDefinition, + getActionDefaultShortcuts, type TAction, type TActionWithOptionalArgs, invokeAction, @@ -185,7 +186,7 @@ export function getKeyframeIndicators({ } export function getDisplayShortcut({ action }: { action: TAction }) { - const { defaultShortcuts } = getActionDefinition({ action }); + const defaultShortcuts = getActionDefaultShortcuts({ action }); if (!defaultShortcuts?.length) { return ""; } @@ -206,14 +207,14 @@ interface TimelineElementProps { track: TimelineTrack; side: "left" | "right"; }) => void; - onElementMouseDown: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; - onElementClick: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; + onElementMouseDown: (params: { + event: React.MouseEvent; + element: TimelineElementType; + }) => void; + onElementClick: (params: { + event: React.MouseEvent; + element: TimelineElementType; + }) => void; dragView: ElementDragView; isDropTarget?: boolean; } @@ -530,14 +531,14 @@ function ElementInner({ isExpanded: boolean; baseTrackHeight: number; expandedContent: React.ReactNode; - onElementClick: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; - onElementMouseDown: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; + onElementClick: (params: { + event: React.MouseEvent; + element: TimelineElementType; + }) => void; + onElementMouseDown: (params: { + event: React.MouseEvent; + element: TimelineElementType; + }) => void; onResizeStart: (params: { event: React.MouseEvent; element: TimelineElementType; @@ -578,8 +579,8 @@ function ElementInner({ type="button" tabIndex={-1} className="absolute inset-0 size-full flex flex-col" - onClick={(event) => onElementClick(event, element)} - onMouseDown={(event) => onElementMouseDown(event, element)} + onClick={(event) => onElementClick({ event, element })} + onMouseDown={(event) => onElementMouseDown({ event, element })} > <div className={cn( @@ -770,9 +771,7 @@ function ExpandedKeyframeLanes({ onLaneMouseDown: (event: React.MouseEvent) => void; onLaneClick: (event: React.MouseEvent) => void; selectionBox: { - startPos: { x: number; y: number }; - currentPos: { x: number; y: number }; - isActive: boolean; + bounds: SelectionBoxBounds; } | null; isBoxSelecting: boolean; onKeyframeClick: (params: { @@ -808,8 +807,7 @@ function ExpandedKeyframeLanes({ ); return ( - // biome-ignore lint/a11y/noStaticElementInteractions: expanded keyframe lanes are a pointer-only editing surface - // biome-ignore lint/a11y/useKeyWithClickEvents: expanded keyframe lanes are a pointer-only editing surface + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- spatial gesture surface (keyframe lanes); keyboard control over keyframes is via global timeline shortcuts, not per-element focus. <div ref={containerRef} className="relative flex flex-col" @@ -893,14 +891,7 @@ function ExpandedKeyframeLanes({ </div> ); })} - {selectionBox && ( - <SelectionBox - startPos={selectionBox.startPos} - currentPos={selectionBox.currentPos} - containerRef={containerRef} - isActive={selectionBox.isActive} - /> - )} + {selectionBox && <SelectionBox bounds={selectionBox.bounds} />} </div> ); } @@ -1073,7 +1064,7 @@ function EffectsButton({ editor.selection.setSelectedElements({ elements: [{ trackId: track.id, elementId: element.id }], }); - setActiveTab(element.type, "effects"); + setActiveTab({ elementType: element.type, tabId: "effects" }); }; return ( diff --git a/apps/web/src/timeline/components/timeline-playhead.tsx b/apps/web/src/timeline/components/timeline-playhead.tsx index 5b1e45ab..6d77789e 100644 --- a/apps/web/src/timeline/components/timeline-playhead.tsx +++ b/apps/web/src/timeline/components/timeline-playhead.tsx @@ -1,11 +1,13 @@ "use client"; import { useRef } from "react"; +import { useContainerSize } from "@/hooks/use-container-size"; import { getCenteredLineLeft, TIMELINE_INDICATOR_LINE_WIDTH_PX, timelineTimeToSnappedPixels, } from "@/timeline"; +import { useScrollPosition } from "@/timeline/hooks/use-scroll-position"; import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead"; import { addMediaTime, @@ -52,11 +54,14 @@ export function TimelinePlayhead({ tracksScrollRef, playheadRef, }); + const { height: timelineHeight } = useContainerSize({ containerRef: timelineRef }); + const { height: tracksHeight } = useContainerSize({ + containerRef: tracksScrollRef, + }); + const { scrollLeft } = useScrollPosition({ scrollRef: tracksScrollRef }); const timelineContainerHeight = - timelineRef.current?.clientHeight ?? - tracksScrollRef.current?.clientHeight ?? - 400; + timelineHeight || tracksHeight || 400; const totalHeight = Math.max( 0, timelineContainerHeight - @@ -68,7 +73,6 @@ export function TimelinePlayhead({ time: currentTime, zoomLevel, }); - const scrollLeft = tracksScrollRef.current?.scrollLeft ?? 0; const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition }) - scrollLeft; diff --git a/apps/web/src/timeline/components/timeline-ruler.tsx b/apps/web/src/timeline/components/timeline-ruler.tsx index e2a379ee..ccacdae3 100644 --- a/apps/web/src/timeline/components/timeline-ruler.tsx +++ b/apps/web/src/timeline/components/timeline-ruler.tsx @@ -1,4 +1,4 @@ -import { type JSX, useLayoutEffect, useRef } from "react"; +import { type JSX } from "react"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; import { mediaTimeToSeconds } from "opencut-wasm"; import { TICKS_PER_SECOND } from "@/wasm"; @@ -51,20 +51,9 @@ export function TimelineRuler({ scrollRef: tracksScrollRef, }); - /** - * widens the virtualization buffer during zoom transitions. - * useScrollPosition lags one frame behind the scroll adjustment - * that useLayoutEffect applies after a zoom change. - */ - const prevZoomRef = useRef(zoomLevel); - const isZoomTransition = zoomLevel !== prevZoomRef.current; - const bufferPx = isZoomTransition - ? Math.max(200, (scrollLeft + viewportWidth) * 0.15) - : 200; - - useLayoutEffect(() => { - prevZoomRef.current = zoomLevel; - }, [zoomLevel]); + // Keep extra buffer because zoom layout and scroll position can briefly + // settle on different frames. + const bufferPx = Math.max(200, (scrollLeft + viewportWidth) * 0.15); const visibleStartTimeSeconds = Math.max( 0, diff --git a/apps/web/src/timeline/components/timeline-track.tsx b/apps/web/src/timeline/components/timeline-track.tsx index 7608e6df..2603f511 100644 --- a/apps/web/src/timeline/components/timeline-track.tsx +++ b/apps/web/src/timeline/components/timeline-track.tsx @@ -62,7 +62,7 @@ export function TimelineTrackContent({ onTrackMouseDown?.(event); }} /> - {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- spatial gesture surface; the wrapping <button> handles keyboard track selection, this <div> only forwards background clicks for box-select / deselect. */} <div className="relative h-full min-w-full" style={{ zIndex: TIMELINE_LAYERS.trackContent }} @@ -96,10 +96,10 @@ export function TimelineTrackContent({ onResizeStart={({ event, element, side }) => onResizeStart({ event, element, track, side }) } - onElementMouseDown={(event, element) => + onElementMouseDown={({ event, element }) => onElementMouseDown({ event, element, track }) } - onElementClick={(event, element) => + onElementClick={({ event, element }) => onElementClick({ event, element, track }) } dragView={dragView} diff --git a/apps/web/src/timeline/controllers/drag-drop-controller.ts b/apps/web/src/timeline/controllers/drag-drop-controller.ts index 7fca2676..e0624a74 100644 --- a/apps/web/src/timeline/controllers/drag-drop-controller.ts +++ b/apps/web/src/timeline/controllers/drag-drop-controller.ts @@ -326,7 +326,10 @@ export class DragDropController { trackType: TrackType; }): void { if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex); + const addTrackCmd = new AddTrackCommand({ + type: trackType, + index: target.trackIndex, + }); this.config.executeCommand( new BatchCommand([ addTrackCmd, diff --git a/apps/web/src/timeline/controllers/element-interaction-controller.ts b/apps/web/src/timeline/controllers/element-interaction-controller.ts index d0d6426a..5afc3a93 100644 --- a/apps/web/src/timeline/controllers/element-interaction-controller.ts +++ b/apps/web/src/timeline/controllers/element-interaction-controller.ts @@ -124,20 +124,27 @@ const IDLE_VIEW: ElementDragView = { kind: "idle" }; // --- Pure helpers --- -function pixelToClickOffsetTime( - clientX: number, - elementRect: DOMRect, - zoomLevel: number, -): MediaTime { +function pixelToClickOffsetTime({ + clientX, + elementRect, + zoomLevel, +}: { + clientX: number; + elementRect: DOMRect; + zoomLevel: number; +}): MediaTime { const clickOffsetX = clientX - elementRect.left; const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); return mediaTime({ ticks: Math.round(seconds * TICKS_PER_SECOND) }); } -function verticalDirection( - startMouseY: number, - currentMouseY: number, -): "up" | "down" | null { +function verticalDirection({ + startMouseY, + currentMouseY, +}: { + startMouseY: number; + currentMouseY: number; +}): "up" | "down" | null { if (currentMouseY < startMouseY) return "up"; if (currentMouseY > startMouseY) return "down"; return null; @@ -147,7 +154,13 @@ function orderedTracks(sceneTracks: SceneTracks): TimelineTrack[] { return [...sceneTracks.overlay, sceneTracks.main, ...sceneTracks.audio]; } -function movedPastDragThreshold(current: Point, origin: Point): boolean { +function movedPastDragThreshold({ + current, + origin, +}: { + current: Point; + origin: Point; +}): boolean { return ( Math.abs(current.x - origin.x) > TIMELINE_DRAG_THRESHOLD_PX || Math.abs(current.y - origin.y) > TIMELINE_DRAG_THRESHOLD_PX @@ -374,11 +387,11 @@ export class ElementInteractionController { elementId: element.id, trackId: track.id, startElementTime: element.startTime, - clickOffsetTime: pixelToClickOffsetTime( - event.clientX, - event.currentTarget.getBoundingClientRect(), - this.deps.viewport.getZoomLevel(), - ), + clickOffsetTime: pixelToClickOffsetTime({ + clientX: event.clientX, + elementRect: event.currentTarget.getBoundingClientRect(), + zoomLevel: this.deps.viewport.getZoomLevel(), + }), selectedElements, }, }; @@ -437,10 +450,13 @@ export class ElementInteractionController { this.notify(); } - private snapResult( - frameSnappedTime: MediaTime, - group: MoveGroup, - ): { snappedTime: MediaTime; snapPoint: SnapPoint | null } { + private snapResult({ + frameSnappedTime, + group, + }: { + frameSnappedTime: MediaTime; + group: MoveGroup; + }): { snappedTime: MediaTime; snapPoint: SnapPoint | null } { const { snap, input, scene, viewport, playback } = this.deps; if (!snap.isEnabled() || input.isShiftHeld()) { @@ -487,7 +503,10 @@ export class ElementInteractionController { viewport, zoomLevel, snappedTime, - verticalDragDirection: verticalDirection(mousedown.origin.y, clientY), + verticalDragDirection: verticalDirection({ + startMouseY: mousedown.origin.y, + currentMouseY: clientY, + }), }); const nextGroupMoveResult = anchorDropTarget @@ -543,7 +562,12 @@ export class ElementInteractionController { clientY: number; scrollContainer: HTMLDivElement; }): void { - if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) { + if ( + !movedPastDragThreshold({ + current: { x: clientX, y: clientY }, + origin: mousedown.origin, + }) + ) { return; } @@ -568,10 +592,10 @@ export class ElementInteractionController { clickOffsetTime: mousedown.clickOffsetTime, fps, }); - const { snappedTime, snapPoint } = this.snapResult( + const { snappedTime, snapPoint } = this.snapResult({ frameSnappedTime, - moveGroup, - ); + group: moveGroup, + }); // Ensure the anchor is selected before we render the drag — covers the // case where the selection store hasn't committed the mousedown-time @@ -632,10 +656,10 @@ export class ElementInteractionController { clickOffsetTime: mousedown.clickOffsetTime, fps, }); - const { snappedTime, snapPoint } = this.snapResult( + const { snappedTime, snapPoint } = this.snapResult({ frameSnappedTime, - drag.moveGroup, - ); + group: drag.moveGroup, + }); drag.currentTime = snappedTime; drag.currentMouseX = clientX; @@ -666,7 +690,12 @@ export class ElementInteractionController { // If the drag returned within the click threshold of its origin, treat // this as a cancel rather than a commit — the user dragged then put the // element back. - if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) { + if ( + !movedPastDragThreshold({ + current: { x: clientX, y: clientY }, + origin: mousedown.origin, + }) + ) { this.lastGestureWasDrag = false; this.finishSession(); return; diff --git a/apps/web/src/timeline/controllers/resize-controller.ts b/apps/web/src/timeline/controllers/resize-controller.ts index e8cbaa80..a1098915 100644 --- a/apps/web/src/timeline/controllers/resize-controller.ts +++ b/apps/web/src/timeline/controllers/resize-controller.ts @@ -263,10 +263,13 @@ export class ResizeController { this.notify(); } - private snappedDelta( - session: ResizeSession, - rawDeltaTime: MediaTime, - ): MediaTime { + private snappedDelta({ + session, + rawDeltaTime, + }: { + session: ResizeSession; + rawDeltaTime: MediaTime; + }): MediaTime { const { snappingEnabled, isShiftHeld, zoomLevel } = this.config; if (!snappingEnabled || isShiftHeld()) { @@ -330,7 +333,7 @@ export class ResizeController { TICKS_PER_SECOND, ), }); - const deltaTime = this.snappedDelta(session, rawDeltaTime); + const deltaTime = this.snappedDelta({ session, rawDeltaTime }); const result = computeGroupResize({ members: session.members, side: session.side, diff --git a/apps/web/src/timeline/controllers/zoom-controller.ts b/apps/web/src/timeline/controllers/zoom-controller.ts index c5090e16..91147354 100644 --- a/apps/web/src/timeline/controllers/zoom-controller.ts +++ b/apps/web/src/timeline/controllers/zoom-controller.ts @@ -25,7 +25,13 @@ export interface ZoomConfigRef { readonly current: ZoomConfig; } -function clampZoom(zoomLevel: number, minZoom: number): number { +function clampZoom({ + zoomLevel, + minZoom, +}: { + zoomLevel: number; + minZoom: number; +}): number { return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, zoomLevel)); } @@ -49,7 +55,7 @@ export class ZoomController { const minZoom = this.config.minZoom; this.zoomLevelValue = deps.initialZoom !== undefined - ? clampZoom(deps.initialZoom, minZoom) + ? clampZoom({ zoomLevel: deps.initialZoom, minZoom }) : minZoom; this.previousZoom = this.zoomLevelValue; this.hasInitialized = deps.initialZoom !== undefined; @@ -89,7 +95,10 @@ export class ZoomController { typeof zoomLevelOrUpdater === "function" ? zoomLevelOrUpdater(this.zoomLevelValue) : zoomLevelOrUpdater; - const nextZoom = clampZoom(nextZoomRaw, this.config.minZoom); + const nextZoom = clampZoom({ + zoomLevel: nextZoomRaw, + minZoom: this.config.minZoom, + }); if (nextZoom === this.zoomLevelValue) return; this.zoomLevelValue = nextZoom; @@ -115,10 +124,16 @@ export class ZoomController { } } - reconcileInitialAndMinZoom(minZoom: number, initialZoom?: number): void { + reconcileInitialAndMinZoom({ + minZoom, + initialZoom, + }: { + minZoom: number; + initialZoom?: number; + }): void { if (initialZoom !== undefined && !this.hasInitialized) { this.hasInitialized = true; - this.setZoomLevel(clampZoom(initialZoom, minZoom)); + this.setZoomLevel(clampZoom({ zoomLevel: initialZoom, minZoom })); return; } diff --git a/apps/web/src/timeline/hooks/element/use-element-interaction.ts b/apps/web/src/timeline/hooks/element/use-element-interaction.ts index 26a21f85..220f3fec 100644 --- a/apps/web/src/timeline/hooks/element/use-element-interaction.ts +++ b/apps/web/src/timeline/hooks/element/use-element-interaction.ts @@ -1,5 +1,6 @@ -import { useEffect, useReducer, useRef, type RefObject } from "react"; +import { useEffect, useReducer, useState, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useShiftKey } from "@/hooks/use-shift-key"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { registerCanceller } from "@/editor/cancel-interaction"; @@ -63,17 +64,10 @@ export function useElementInteraction({ onChange: onSnapPointChange, }, }; - - const depsRef = useRef<ElementInteractionDeps>(deps); - depsRef.current = deps; - - const controllerRef = useRef<ElementInteractionController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new ElementInteractionController({ - depsRef: depsRef as ElementInteractionDepsRef, - }); - } - const controller = controllerRef.current; + const depsRef = useCommittedRef(deps) as ElementInteractionDepsRef; + const [controller] = useState( + () => new ElementInteractionController({ depsRef }), + ); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts index ea03cb5c..141bba8f 100644 --- a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts +++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts @@ -1,5 +1,6 @@ -import { useEffect, useReducer, useRef } from "react"; +import { useEffect, useReducer, useState } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useKeyframeSelection } from "./use-keyframe-selection"; import { registerCanceller } from "@/editor/cancel-interaction"; import { @@ -44,15 +45,10 @@ export function useKeyframeDrag({ seek: ({ time }) => editor.playback.seek({ time }), getTotalDuration: () => editor.timeline.getTotalDuration(), }; - - const configRef = useRef<KeyframeDragConfig>(config); - configRef.current = config; - - const controllerRef = useRef<KeyframeDragController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new KeyframeDragController({ configRef }); - } - const controller = controllerRef.current; + const configRef = useCommittedRef(config); + const [controller] = useState( + () => new KeyframeDragController({ configRef }), + ); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); diff --git a/apps/web/src/timeline/hooks/use-snap-indicator-position.ts b/apps/web/src/timeline/hooks/use-snap-indicator-position.ts index 2081b939..ac6d91eb 100644 --- a/apps/web/src/timeline/hooks/use-snap-indicator-position.ts +++ b/apps/web/src/timeline/hooks/use-snap-indicator-position.ts @@ -1,6 +1,7 @@ -import { useEffect, useState } from "react";
+import { useContainerSize } from "@/hooks/use-container-size";
import { timelineTimeToSnappedPixels } from "@/timeline";
import { TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX } from "@/timeline/components/layout";
+import { useScrollPosition } from "./use-scroll-position";
interface UseSnapIndicatorPositionParams {
snapPoint: { time: number } | null;
zoomLevel: number;
@@ -20,24 +21,9 @@ export function useSnapIndicatorPosition({ timelineRef,
tracksScrollRef,
}: UseSnapIndicatorPositionParams): SnapIndicatorPosition {
- const [scrollLeft, setScrollLeft] = useState(0);
-
- useEffect(() => {
- const tracksViewport = tracksScrollRef.current;
-
- if (!tracksViewport) return;
-
- const handleScroll = () => {
- setScrollLeft(tracksViewport.scrollLeft);
- };
-
- setScrollLeft(tracksViewport.scrollLeft);
-
- tracksViewport.addEventListener("scroll", handleScroll);
- return () => tracksViewport.removeEventListener("scroll", handleScroll);
- }, [tracksScrollRef]);
-
- const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
+ const { height: timelineHeight } = useContainerSize({ containerRef: timelineRef });
+ const { scrollLeft } = useScrollPosition({ scrollRef: tracksScrollRef });
+ const timelineContainerHeight = timelineHeight || 400;
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
const timelinePosition = timelineTimeToSnappedPixels({
diff --git a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts index 4ec2b80b..c405819e 100644 --- a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts +++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts @@ -1,5 +1,6 @@ -import { useEffect, useReducer, useRef, type RefObject } from "react"; +import { useEffect, useReducer, useState, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { DragDropController, type DragDropConfig, @@ -37,15 +38,8 @@ export function useTimelineDragDrop({ insertElement: (args) => editor.timeline.insertElement(args), addClipEffect: (args) => editor.timeline.addClipEffect(args), }; - - const configRef = useRef<DragDropConfig>(config); - configRef.current = config; - - const controllerRef = useRef<DragDropController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new DragDropController({ configRef }); - } - const controller = controllerRef.current; + const configRef = useCommittedRef(config); + const [controller] = useState(() => new DragDropController({ configRef })); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); diff --git a/apps/web/src/timeline/hooks/use-timeline-playhead.ts b/apps/web/src/timeline/hooks/use-timeline-playhead.ts index 15ef3575..9f10c4a5 100644 --- a/apps/web/src/timeline/hooks/use-timeline-playhead.ts +++ b/apps/web/src/timeline/hooks/use-timeline-playhead.ts @@ -1,5 +1,6 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useState } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useShiftKey } from "@/hooks/use-shift-key"; import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; import { timelineTimeToPixels } from "@/timeline"; @@ -54,14 +55,8 @@ export function useTimelinePlayhead({ }, }), }; - const configRef = useRef(config); - configRef.current = config; - - const ctrlRef = useRef<PlayheadController | null>(null); - if (!ctrlRef.current) { - ctrlRef.current = new PlayheadController({ configRef }); - } - const ctrl = ctrlRef.current; + const configRef = useCommittedRef(config); + const [ctrl] = useState(() => new PlayheadController({ configRef })); // Scroll → keep playhead position in sync with scroll offset. useEffect(() => { diff --git a/apps/web/src/timeline/hooks/use-timeline-resize.ts b/apps/web/src/timeline/hooks/use-timeline-resize.ts index 3579bd75..6bd0c606 100644 --- a/apps/web/src/timeline/hooks/use-timeline-resize.ts +++ b/apps/web/src/timeline/hooks/use-timeline-resize.ts @@ -1,5 +1,6 @@ -import { useEffect, useReducer, useRef } from "react"; +import { useEffect, useReducer, useState } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useShiftKey } from "@/hooks/use-shift-key"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { useTimelineStore } from "@/timeline/timeline-store"; @@ -55,15 +56,8 @@ export function useTimelineResize({ }), onSnapPointChange, }; - - const configRef = useRef(config); - configRef.current = config; - - const controllerRef = useRef<ResizeController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new ResizeController({ configRef }); - } - const controller = controllerRef.current; + const configRef = useCommittedRef(config); + const [controller] = useState(() => new ResizeController({ configRef })); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); diff --git a/apps/web/src/timeline/hooks/use-timeline-seek.ts b/apps/web/src/timeline/hooks/use-timeline-seek.ts index bf033dae..ea9b44a7 100644 --- a/apps/web/src/timeline/hooks/use-timeline-seek.ts +++ b/apps/web/src/timeline/hooks/use-timeline-seek.ts @@ -1,5 +1,6 @@ -import { useEffect, useRef, type RefObject } from "react"; +import { useEffect, useState, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { SeekController, type SeekConfig, @@ -50,15 +51,8 @@ export function useTimelineSeek({ }, }), }; - - const configRef = useRef(config); - configRef.current = config; - - const controllerRef = useRef<SeekController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new SeekController({ configRef }); - } - const controller = controllerRef.current; + const configRef = useCommittedRef(config); + const [controller] = useState(() => new SeekController({ configRef })); useEffect(() => () => controller.destroy(), [controller]); diff --git a/apps/web/src/timeline/hooks/use-timeline-zoom.ts b/apps/web/src/timeline/hooks/use-timeline-zoom.ts index 3020504f..60768963 100644 --- a/apps/web/src/timeline/hooks/use-timeline-zoom.ts +++ b/apps/web/src/timeline/hooks/use-timeline-zoom.ts @@ -4,9 +4,10 @@ import { useEffect, useLayoutEffect, useReducer, - useRef, + useState, } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { TIMELINE_ZOOM_MIN } from "@/timeline/scale"; import { ZoomController, @@ -57,21 +58,17 @@ export function useTimelineZoom({ }, }), }; - const configRef = useRef(config); - configRef.current = config; - - const controllerRef = useRef<ZoomController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new ZoomController({ configRef, initialZoom }); - } - const controller = controllerRef.current; + const configRef = useCommittedRef(config); + const [controller] = useState( + () => new ZoomController({ configRef, initialZoom }), + ); const zoomLevel = controller.zoomLevel; const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); useEffect(() => { - controller.reconcileInitialAndMinZoom(minZoom, initialZoom); + controller.reconcileInitialAndMinZoom({ minZoom, initialZoom }); }, [controller, minZoom, initialZoom]); useLayoutEffect(() => { diff --git a/apps/web/src/timeline/index.ts b/apps/web/src/timeline/index.ts index 3775e006..21944561 100644 --- a/apps/web/src/timeline/index.ts +++ b/apps/web/src/timeline/index.ts @@ -1,4 +1,4 @@ -import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; +import { addMediaTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; import type { SceneTracks } from "./types"; export * from "./types"; @@ -22,9 +22,10 @@ export function calculateTotalDuration({ let maxEnd: MediaTime = ZERO_MEDIA_TIME; for (const track of orderedTracks) { for (const element of track.elements) { - // `startTime + duration` is integer-by-construction (both `MediaTime`), - // so the cast re-establishes the brand without runtime work. - const elementEnd = (element.startTime + element.duration) as MediaTime; + const elementEnd = addMediaTime({ + a: element.startTime, + b: element.duration, + }); if (elementEnd > maxEnd) maxEnd = elementEnd; } } diff --git a/apps/web/src/timeline/types.ts b/apps/web/src/timeline/types.ts index 99df3128..e84a4f4e 100644 --- a/apps/web/src/timeline/types.ts +++ b/apps/web/src/timeline/types.ts @@ -1,332 +1,332 @@ -import type { ElementAnimations } from "@/animation/types";
-import type { Effect } from "@/effects/types";
-import type { Mask } from "@/masks/types";
-import type { ParamValues } from "@/params";
-import type { BlendMode, Transform } from "@/rendering";
-import type { MediaTime } from "@/wasm";
-
-export type ElementRef = {
- trackId: string;
- elementId: string;
-};
-
-export interface Bookmark {
- time: MediaTime;
- note?: string;
- color?: string;
- duration?: MediaTime;
-}
-
-export interface TScene {
- id: string;
- name: string;
- isMain: boolean;
- tracks: SceneTracks;
- bookmarks: Bookmark[];
- createdAt: Date;
- updatedAt: Date;
-}
-
-export type TrackType = "video" | "text" | "audio" | "graphic" | "effect";
-
-interface BaseTrack {
- id: string;
- name: string;
-}
-
-export interface VideoTrack extends BaseTrack {
- type: "video";
- elements: (VideoElement | ImageElement)[];
- muted: boolean;
- hidden: boolean;
-}
-
-export interface TextTrack extends BaseTrack {
- type: "text";
- elements: TextElement[];
- hidden: boolean;
-}
-
-export interface AudioTrack extends BaseTrack {
- type: "audio";
- elements: AudioElement[];
- muted: boolean;
-}
-
-export interface GraphicTrack extends BaseTrack {
- type: "graphic";
- elements: (StickerElement | GraphicElement)[];
- hidden: boolean;
-}
-
-export interface EffectTrack extends BaseTrack {
- type: "effect";
- elements: EffectElement[];
- hidden: boolean;
-}
-
-export type TimelineTrack =
- | VideoTrack
- | TextTrack
- | AudioTrack
- | GraphicTrack
- | EffectTrack;
-
-export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack;
-
-export interface SceneTracks {
- overlay: OverlayTrack[];
- main: VideoTrack;
- audio: AudioTrack[];
-}
-
-export interface RetimeConfig {
- rate: number;
- maintainPitch?: boolean;
-}
-
-interface BaseAudioElement extends BaseTimelineElement {
- type: "audio";
- volume: number;
- muted?: boolean;
- buffer?: AudioBuffer;
- retime?: RetimeConfig;
-}
-
-export interface UploadAudioElement extends BaseAudioElement {
- sourceType: "upload";
- mediaId: string;
-}
-
-export interface LibraryAudioElement extends BaseAudioElement {
- sourceType: "library";
- sourceUrl: string;
-}
-
-export type AudioElement = UploadAudioElement | LibraryAudioElement;
-
-interface BaseTimelineElement {
- id: string;
- name: string;
- duration: MediaTime;
- startTime: MediaTime;
- trimStart: MediaTime;
- trimEnd: MediaTime;
- sourceDuration?: MediaTime;
- animations?: ElementAnimations;
-}
-
-export interface VideoElement extends BaseTimelineElement {
- type: "video";
- mediaId: string;
- volume?: number;
- muted?: boolean;
- isSourceAudioEnabled?: boolean;
- hidden?: boolean;
- retime?: RetimeConfig;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
- masks?: Mask[];
-}
-
-export interface ImageElement extends BaseTimelineElement {
- type: "image";
- mediaId: string;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
- masks?: Mask[];
-}
-
-export interface TextBackground {
- enabled: boolean;
- color: string;
- cornerRadius?: number;
- paddingX?: number;
- paddingY?: number;
- offsetX?: number;
- offsetY?: number;
-}
-
-export interface TextElement extends BaseTimelineElement {
- type: "text";
- content: string;
- fontSize: number;
- fontFamily: string;
- color: string;
- background: TextBackground;
- textAlign: "left" | "center" | "right";
- fontWeight: "normal" | "bold";
- fontStyle: "normal" | "italic";
- textDecoration: "none" | "underline" | "line-through";
- letterSpacing?: number;
- lineHeight?: number;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
-}
-
-export interface StickerElement extends BaseTimelineElement {
- type: "sticker";
- stickerId: string;
- /** Natural dimensions of the sticker asset, stored at insert time. Used by renderer and preview bounds to avoid split-brain geometry. */
- intrinsicWidth?: number;
- intrinsicHeight?: number;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
-}
-
-export interface GraphicElement extends BaseTimelineElement {
- type: "graphic";
- definitionId: string;
- params: ParamValues;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
- masks?: Mask[];
-}
-
-export interface EffectElement extends BaseTimelineElement {
- type: "effect";
- effectType: string;
- params: ParamValues;
-}
-
-export type ElementUpdatePatch =
- | { transform: Transform }
- | { opacity: number }
- | { volume: number };
-
-export type TimelineElement =
- | AudioElement
- | VideoElement
- | ImageElement
- | TextElement
- | StickerElement
- | GraphicElement
- | EffectElement;
-
-export type ElementType = TimelineElement["type"];
-
-function elementTypes<T extends ElementType[]>(...types: T): T {
- return types;
-}
-
-export const MASKABLE_ELEMENT_TYPES = elementTypes("video", "image", "graphic");
-
-export type MaskableElement = Extract<
- TimelineElement,
- { type: (typeof MASKABLE_ELEMENT_TYPES)[number] }
->;
-
-export const RETIMABLE_ELEMENT_TYPES = elementTypes("video", "audio");
-
-export type RetimableElement = Extract<
- TimelineElement,
- { type: (typeof RETIMABLE_ELEMENT_TYPES)[number] }
->;
-
-export const VISUAL_ELEMENT_TYPES = elementTypes(
- "video",
- "image",
- "text",
- "sticker",
- "graphic",
-);
-
-export type VisualElement = Extract<
- TimelineElement,
- { type: (typeof VISUAL_ELEMENT_TYPES)[number] }
->;
-
-export type CreateUploadAudioElement = Omit<UploadAudioElement, "id">;
-export type CreateLibraryAudioElement = Omit<LibraryAudioElement, "id">;
-export type CreateAudioElement =
- | CreateUploadAudioElement
- | CreateLibraryAudioElement;
-export type CreateVideoElement = Omit<VideoElement, "id">;
-export type CreateImageElement = Omit<ImageElement, "id">;
-export type CreateTextElement = Omit<TextElement, "id">;
-export type CreateStickerElement = Omit<StickerElement, "id">;
-export type CreateGraphicElement = Omit<GraphicElement, "id">;
-export type CreateEffectElement = Omit<EffectElement, "id">;
-export type CreateTimelineElement =
- | CreateAudioElement
- | CreateVideoElement
- | CreateImageElement
- | CreateTextElement
- | CreateStickerElement
- | CreateGraphicElement
- | CreateEffectElement;
-
-export interface ElementDragState {
- isDragging: boolean;
- elementId: string | null;
- dragElementIds: string[];
- dragTimeOffsets: Record<string, MediaTime>;
- trackId: string | null;
- startMouseX: number;
- startMouseY: number;
- startElementTime: MediaTime;
- clickOffsetTime: MediaTime;
- currentTime: MediaTime;
- currentMouseY: number;
-}
-
-export type ElementDragView =
- | { readonly kind: "idle" }
- | {
- readonly kind: "dragging";
- readonly anchorElementId: string;
- readonly trackId: string;
- readonly memberTimeOffsets: ReadonlyMap<string, MediaTime>;
- readonly startMouseX: number;
- readonly startMouseY: number;
- readonly startElementTime: MediaTime;
- readonly clickOffsetTime: MediaTime;
- readonly currentTime: MediaTime;
- readonly currentMouseX: number;
- readonly currentMouseY: number;
- readonly dropTarget: DropTarget | null;
- };
-
-export interface DropTarget {
- trackIndex: number;
- isNewTrack: boolean;
- insertPosition: "above" | "below" | null;
- xPosition: MediaTime;
- targetElement: { elementId: string; trackId: string } | null;
-}
-
-export interface ComputeDropTargetParams {
- elementType: ElementType;
- mouseX: number;
- mouseY: number;
- tracks: SceneTracks;
- playheadTime: MediaTime;
- isExternalDrop: boolean;
- elementDuration: MediaTime;
- pixelsPerSecond: number;
- zoomLevel: number;
- verticalDragDirection?: "up" | "down" | null;
- startTimeOverride?: MediaTime;
- excludeElementId?: string;
- targetElementTypes?: string[];
-}
-
-export interface ClipboardItem {
- trackId: string;
- trackType: TrackType;
- element: CreateTimelineElement;
-}
+import type { ElementAnimations } from "@/animation/types"; +import type { Effect } from "@/effects/types"; +import type { Mask } from "@/masks/types"; +import type { ParamValues } from "@/params"; +import type { BlendMode, Transform } from "@/rendering"; +import type { MediaTime } from "@/wasm"; + +export type ElementRef = { + trackId: string; + elementId: string; +}; + +export interface Bookmark { + time: MediaTime; + note?: string; + color?: string; + duration?: MediaTime; +} + +export interface TScene { + id: string; + name: string; + isMain: boolean; + tracks: SceneTracks; + bookmarks: Bookmark[]; + createdAt: Date; + updatedAt: Date; +} + +export type TrackType = "video" | "text" | "audio" | "graphic" | "effect"; + +interface BaseTrack { + id: string; + name: string; +} + +export interface VideoTrack extends BaseTrack { + type: "video"; + elements: (VideoElement | ImageElement)[]; + muted: boolean; + hidden: boolean; +} + +export interface TextTrack extends BaseTrack { + type: "text"; + elements: TextElement[]; + hidden: boolean; +} + +export interface AudioTrack extends BaseTrack { + type: "audio"; + elements: AudioElement[]; + muted: boolean; +} + +export interface GraphicTrack extends BaseTrack { + type: "graphic"; + elements: (StickerElement | GraphicElement)[]; + hidden: boolean; +} + +export interface EffectTrack extends BaseTrack { + type: "effect"; + elements: EffectElement[]; + hidden: boolean; +} + +export type TimelineTrack = + | VideoTrack + | TextTrack + | AudioTrack + | GraphicTrack + | EffectTrack; + +export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack; + +export interface SceneTracks { + overlay: OverlayTrack[]; + main: VideoTrack; + audio: AudioTrack[]; +} + +export interface RetimeConfig { + rate: number; + maintainPitch?: boolean; +} + +interface BaseAudioElement extends BaseTimelineElement { + type: "audio"; + volume: number; + muted?: boolean; + buffer?: AudioBuffer; + retime?: RetimeConfig; +} + +export interface UploadAudioElement extends BaseAudioElement { + sourceType: "upload"; + mediaId: string; +} + +export interface LibraryAudioElement extends BaseAudioElement { + sourceType: "library"; + sourceUrl: string; +} + +export type AudioElement = UploadAudioElement | LibraryAudioElement; + +interface BaseTimelineElement { + id: string; + name: string; + duration: MediaTime; + startTime: MediaTime; + trimStart: MediaTime; + trimEnd: MediaTime; + sourceDuration?: MediaTime; + animations?: ElementAnimations; +} + +export interface VideoElement extends BaseTimelineElement { + type: "video"; + mediaId: string; + volume?: number; + muted?: boolean; + isSourceAudioEnabled?: boolean; + hidden?: boolean; + retime?: RetimeConfig; + transform: Transform; + opacity: number; + blendMode?: BlendMode; + effects?: Effect[]; + masks?: Mask[]; +} + +export interface ImageElement extends BaseTimelineElement { + type: "image"; + mediaId: string; + hidden?: boolean; + transform: Transform; + opacity: number; + blendMode?: BlendMode; + effects?: Effect[]; + masks?: Mask[]; +} + +export interface TextBackground { + enabled: boolean; + color: string; + cornerRadius?: number; + paddingX?: number; + paddingY?: number; + offsetX?: number; + offsetY?: number; +} + +export interface TextElement extends BaseTimelineElement { + type: "text"; + content: string; + fontSize: number; + fontFamily: string; + color: string; + background: TextBackground; + textAlign: "left" | "center" | "right"; + fontWeight: "normal" | "bold"; + fontStyle: "normal" | "italic"; + textDecoration: "none" | "underline" | "line-through"; + letterSpacing?: number; + lineHeight?: number; + hidden?: boolean; + transform: Transform; + opacity: number; + blendMode?: BlendMode; + effects?: Effect[]; +} + +export interface StickerElement extends BaseTimelineElement { + type: "sticker"; + stickerId: string; + /** Natural dimensions of the sticker asset, stored at insert time. Used by renderer and preview bounds to avoid split-brain geometry. */ + intrinsicWidth?: number; + intrinsicHeight?: number; + hidden?: boolean; + transform: Transform; + opacity: number; + blendMode?: BlendMode; + effects?: Effect[]; +} + +export interface GraphicElement extends BaseTimelineElement { + type: "graphic"; + definitionId: string; + params: ParamValues; + hidden?: boolean; + transform: Transform; + opacity: number; + blendMode?: BlendMode; + effects?: Effect[]; + masks?: Mask[]; +} + +export interface EffectElement extends BaseTimelineElement { + type: "effect"; + effectType: string; + params: ParamValues; +} + +export type ElementUpdatePatch = + | { transform: Transform } + | { opacity: number } + | { volume: number }; + +export type TimelineElement = + | AudioElement + | VideoElement + | ImageElement + | TextElement + | StickerElement + | GraphicElement + | EffectElement; + +export type ElementType = TimelineElement["type"]; + +function elementTypes<T extends ElementType[]>(...types: T): T { + return types; +} + +export const MASKABLE_ELEMENT_TYPES = elementTypes("video", "image", "graphic"); + +export type MaskableElement = Extract< + TimelineElement, + { type: (typeof MASKABLE_ELEMENT_TYPES)[number] } +>; + +export const RETIMABLE_ELEMENT_TYPES = elementTypes("video", "audio"); + +export type RetimableElement = Extract< + TimelineElement, + { type: (typeof RETIMABLE_ELEMENT_TYPES)[number] } +>; + +export const VISUAL_ELEMENT_TYPES = elementTypes( + "video", + "image", + "text", + "sticker", + "graphic", +); + +export type VisualElement = Extract< + TimelineElement, + { type: (typeof VISUAL_ELEMENT_TYPES)[number] } +>; + +export type CreateUploadAudioElement = Omit<UploadAudioElement, "id">; +export type CreateLibraryAudioElement = Omit<LibraryAudioElement, "id">; +export type CreateAudioElement = + | CreateUploadAudioElement + | CreateLibraryAudioElement; +export type CreateVideoElement = Omit<VideoElement, "id">; +export type CreateImageElement = Omit<ImageElement, "id">; +export type CreateTextElement = Omit<TextElement, "id">; +export type CreateStickerElement = Omit<StickerElement, "id">; +export type CreateGraphicElement = Omit<GraphicElement, "id">; +export type CreateEffectElement = Omit<EffectElement, "id">; +export type CreateTimelineElement = + | CreateAudioElement + | CreateVideoElement + | CreateImageElement + | CreateTextElement + | CreateStickerElement + | CreateGraphicElement + | CreateEffectElement; + +export interface ElementDragState { + isDragging: boolean; + elementId: string | null; + dragElementIds: string[]; + dragTimeOffsets: Record<string, MediaTime>; + trackId: string | null; + startMouseX: number; + startMouseY: number; + startElementTime: MediaTime; + clickOffsetTime: MediaTime; + currentTime: MediaTime; + currentMouseY: number; +} + +export type ElementDragView = + | { readonly kind: "idle" } + | { + readonly kind: "dragging"; + readonly anchorElementId: string; + readonly trackId: string; + readonly memberTimeOffsets: ReadonlyMap<string, MediaTime>; + readonly startMouseX: number; + readonly startMouseY: number; + readonly startElementTime: MediaTime; + readonly clickOffsetTime: MediaTime; + readonly currentTime: MediaTime; + readonly currentMouseX: number; + readonly currentMouseY: number; + readonly dropTarget: DropTarget | null; + }; + +export interface DropTarget { + trackIndex: number; + isNewTrack: boolean; + insertPosition: "above" | "below" | null; + xPosition: MediaTime; + targetElement: { elementId: string; trackId: string } | null; +} + +export interface ComputeDropTargetParams { + elementType: ElementType; + mouseX: number; + mouseY: number; + tracks: SceneTracks; + playheadTime: MediaTime; + isExternalDrop: boolean; + elementDuration: MediaTime; + pixelsPerSecond: number; + zoomLevel: number; + verticalDragDirection?: "up" | "down" | null; + startTimeOverride?: MediaTime; + excludeElementId?: string; + targetElementTypes?: string[]; +} + +export interface ClipboardItem { + trackId: string; + trackType: TrackType; + element: CreateTimelineElement; +} diff --git a/apps/web/src/utils/geometry.ts b/apps/web/src/utils/geometry.ts index 23fc4f46..b5c50584 100644 --- a/apps/web/src/utils/geometry.ts +++ b/apps/web/src/utils/geometry.ts @@ -5,8 +5,14 @@ export function dimensionToAspectRatio({ width: number; height: number; }): string { - const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b)); - const divisor = gcd(width, height); + const gcd = ({ + a, + b, + }: { + a: number; + b: number; + }): number => (b === 0 ? a : gcd({ a: b, b: a % b })); + const divisor = gcd({ a: width, b: height }); const aspectWidth = width / divisor; const aspectHeight = height / divisor; return `${aspectWidth}:${aspectHeight}`; diff --git a/apps/web/src/wasm/media-time.ts b/apps/web/src/wasm/media-time.ts index 82f5378d..0ec23cf3 100644 --- a/apps/web/src/wasm/media-time.ts +++ b/apps/web/src/wasm/media-time.ts @@ -26,20 +26,37 @@ export type MediaTime = number & { readonly __mediaTime: unique symbol }; export const TICKS_PER_SECOND = _TICKS_PER_SECOND(); -export const ZERO_MEDIA_TIME = 0 as MediaTime; +function isMediaTime(value: number): value is MediaTime { + return Number.isInteger(value); +} + +function requireMediaTime({ + value, + context, +}: { + value: number; + context: string; +}): MediaTime { + if (!isMediaTime(value)) { + throw new Error(`${context}: expected an integer tick count, got ${value}`); + } + return value; +} + +export const ZERO_MEDIA_TIME = requireMediaTime({ + value: 0, + context: "ZERO_MEDIA_TIME", +}); /** - * Construct a `MediaTime` from a known-integer tick count. Asserts in dev that - * the value is actually an integer; cast in release. Use `roundMediaTime` when - * the input may be fractional. + * Construct a `MediaTime` from a known-integer tick count. Use `roundMediaTime` + * when the input may be fractional. */ export function mediaTime({ ticks }: { ticks: number }): MediaTime { - if (process.env.NODE_ENV !== "production" && !Number.isInteger(ticks)) { - throw new Error( - `mediaTime() requires an integer tick count, got ${ticks}. Use roundMediaTime() for fractional values.`, - ); - } - return ticks as MediaTime; + return requireMediaTime({ + value: ticks, + context: "mediaTime()", + }); } /** @@ -52,9 +69,12 @@ export function mediaTime({ ticks }: { ticks: number }): MediaTime { export function roundMediaTime({ time }: { time: number }): MediaTime { const roundedMagnitude = Math.round(Math.abs(time)); if (roundedMagnitude === 0) { - return 0 as MediaTime; + return ZERO_MEDIA_TIME; } - return (time < 0 ? -roundedMagnitude : roundedMagnitude) as MediaTime; + return requireMediaTime({ + value: time < 0 ? -roundedMagnitude : roundedMagnitude, + context: "roundMediaTime()", + }); } export function mediaTimeFromSeconds({ @@ -68,7 +88,10 @@ export function mediaTimeFromSeconds({ `mediaTimeFromSeconds: rust returned undefined for seconds=${seconds}`, ); } - return result as MediaTime; + return requireMediaTime({ + value: result, + context: "mediaTimeFromSeconds()", + }); } export function mediaTimeToSeconds({ time }: { time: MediaTime }): number { @@ -76,9 +99,7 @@ export function mediaTimeToSeconds({ time }: { time: MediaTime }): number { } /** - * Sum `MediaTime` values. Inputs are integer ticks, so the sum is integer too; - * the cast is a no-op at runtime, only re-establishes the brand for the type - * system. + * Sum `MediaTime` values. Inputs are integer ticks, so the sum is integer too. */ export function addMediaTime({ a, @@ -87,7 +108,10 @@ export function addMediaTime({ a: MediaTime; b: MediaTime; }): MediaTime { - return (a + b) as MediaTime; + return requireMediaTime({ + value: a + b, + context: "addMediaTime()", + }); } export function subMediaTime({ @@ -97,7 +121,10 @@ export function subMediaTime({ a: MediaTime; b: MediaTime; }): MediaTime { - return (a - b) as MediaTime; + return requireMediaTime({ + value: a - b, + context: "subMediaTime()", + }); } export function maxMediaTime({ @@ -107,7 +134,7 @@ export function maxMediaTime({ a: MediaTime; b: MediaTime; }): MediaTime { - return (a > b ? a : b) as MediaTime; + return a > b ? a : b; } export function minMediaTime({ @@ -117,7 +144,7 @@ export function minMediaTime({ a: MediaTime; b: MediaTime; }): MediaTime { - return (a < b ? a : b) as MediaTime; + return a < b ? a : b; } export function clampMediaTime({ @@ -141,7 +168,10 @@ export function roundFrameTime({ time: MediaTime; fps: FrameRate; }): MediaTime { - return (_roundToFrame({ time, rate: fps }) ?? time) as MediaTime; + return requireMediaTime({ + value: _roundToFrame({ time, rate: fps }) ?? time, + context: "roundFrameTime()", + }); } export function roundFrameTicks({ @@ -163,7 +193,10 @@ export function snapSeekMediaTime({ duration: MediaTime; fps: FrameRate; }): MediaTime { - return (_snappedSeekTime({ time, duration, rate: fps }) ?? time) as MediaTime; + return requireMediaTime({ + value: _snappedSeekTime({ time, duration, rate: fps }) ?? time, + context: "snapSeekMediaTime()", + }); } export function lastFrameMediaTime({ @@ -173,7 +206,10 @@ export function lastFrameMediaTime({ duration: MediaTime; fps: FrameRate; }): MediaTime { - return (_lastFrameTime({ duration, rate: fps }) ?? duration) as MediaTime; + return requireMediaTime({ + value: _lastFrameTime({ duration, rate: fps }) ?? duration, + context: "lastFrameMediaTime()", + }); } export function parseMediaTimecode({ @@ -186,5 +222,11 @@ export function parseMediaTimecode({ fps: FrameRate; }): MediaTime | null { const parsedTime = _parseTimecode({ timeCode, format, rate: fps }); - return parsedTime == null ? null : (parsedTime as MediaTime); + if (parsedTime == null) { + return null; + } + return requireMediaTime({ + value: parsedTime, + context: "parseMediaTimecode()", + }); } |
