Compare commits

...
13 Commits
Author SHA1 Message Date
Maze Winther 43be7af1f3 chore: remove file 2026-05-02 02:57:23 +02:00
Maze WintherandCursor 94ae05fa6b refactor: collapse mask definition registry to a single keyed registry
The freeform mask was assembled on every getMaskDefinition("freeform") call,
re-spreading params and injecting a wrong SquareIcon placeholder, with a type
cast to suppress the structural mismatch. Root cause: MasksRegistry was keyed
on BuiltinMaskType, so freeform had no entry.

- Re-key MasksRegistry from BuiltinMaskType to MaskType
- Register freeform alongside builtins in registerDefaultMasks, with
  PenToolAddIcon as its menu icon
- getMaskDefinition and getMaskDefinitionsForMenu become plain registry
  lookups with no branching, no manual spreading, no cast
- Rename RegisteredBuiltinMaskDefinition to RegisteredMaskDefinition and
  tighten computeParamUpdate return type to Partial<BaseMaskParams>

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-02 01:33:32 +02:00
Maze WintherandCursor 67731d3071 refactor: remove dead tangent handle variant
The { kind: "tangent" } MaskHandleId variant was never constructed — neither
getFreeformDisplayHandles nor any other code ever produced a tangent handle
object. The in/out control-arm branches in computeFreeformParamUpdate, the
tangent case in maskHandleIdKey, and the CUSTOM_MASK_TANGENT_SIZE rendering
path in mask-handles.tsx were all unreachable. Removed all four dead paths
and removed "tangent" from MaskHandleKind.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-02 01:33:20 +02:00
Maze WintherandCursor 26020a08d2 fix: address review findings in masks substrate
- Remove dead ternary in getPreferredEdges (always returned undefined; the
  all-false object was never reached by Object.values().some(Boolean))
- Document why drawWithFeather descriptors zero the feather field in the
  compositor (analytical renderer handles softness itself; zeroing prevents
  a second JFA feather pass)
- Remove legacyType from V30→V31 migration (not consumed anywhere; a
  down-migration can map type:"freeform" back to "custom" directly)
- Add V30→V31 test coverage: skip-guard branches, freeform passthrough,
  elements without a masks array
- Add snap tests for vertical-edge (bottom) and uniform-scale handle paths

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-02 01:28:19 +02:00
Maze Winther d1044796a6 docs: capture mask rust migration notes
Made-with: Cursor
2026-04-29 18:42:27 +02:00
Maze Winther 0bdfef71a5 refactor: split builtin and freeform masks
Made-with: Cursor
2026-04-29 18:41:15 +02:00
Maze Winther 1928a99581 refactor: type mask handle identifiers
Made-with: Cursor
2026-04-29 18:31:24 +02:00
Maze Winther 7b649de35d refactor: make mask renderer dispatch explicit
Made-with: Cursor
2026-04-29 18:27:21 +02:00
Maze Winther 67e3cd0292 refactor: split mask snap dispatch
Made-with: Cursor
2026-04-29 18:23:08 +02:00
Maze Winther c92c7bf36a refactor: animate params over time 2026-04-29 10:37:39 +02:00
Maze Winther d631c920cb chore: cleanup 2026-04-29 01:59:46 +02:00
Maze Winther 222ef055fc refactor: new parameters modulerefactor: unify element params 2026-04-29 01:28:50 +02:00
Maze Winther d7692b4c79 chore: switch from biome to eslint + prettier; fix ton of lint issues 2026-04-29 00:37:56 +02:00
241 changed files with 9184 additions and 8558 deletions
+3 -3
View File
@@ -168,7 +168,7 @@ Working on `apps/desktop`? See [`apps/desktop/README.md`](../apps/desktop/README
2. Make your changes
3. Run the relevant checks for the area you touched:
- Web changes: from `apps/web`, run `bun run lint` and `bunx biome format --write .`
- Web changes: from `apps/web`, run `bun run lint` and `bun run format`
- Desktop changes: run `./apps/desktop/script/setup` if your environment isn't set up yet
4. Commit your changes with a descriptive message
@@ -176,8 +176,8 @@ Working on `apps/desktop`? See [`apps/desktop/README.md`](../apps/desktop/README
## Code Style
- We use Biome for code formatting and linting
- Run `bunx biome format --write .` from the `apps/web` directory to format code
- We use ESLint for linting and Prettier for formatting
- Run `bun run format` from the `apps/web` directory to format code
- Run `bun run lint` from the `apps/web` directory to check for linting issues
- Follow the existing code patterns
+342 -344
View File
@@ -1,344 +1,342 @@
---
applyTo: "**/*.{ts,tsx,js,jsx}"
---
# Project Context
Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter.
## Key Principles
- Zero configuration required
- Subsecond performance
- Maximum type safety
- AI-friendly code generation
## Before Writing Code
1. Analyze existing patterns in the codebase
2. Consider edge cases and error scenarios
3. Follow the rules below strictly
4. Validate accessibility requirements
## Rules
### Accessibility (a11y)
- Don't use `accessKey` attribute on any HTML element.
- Don't set `aria-hidden="true"` on focusable elements.
- Don't add ARIA roles, states, and properties to elements that don't support them.
- Don't use distracting elements like `<marquee>` or `<blink>`.
- Only use the `scope` prop on `<th>` elements.
- Don't assign non-interactive ARIA roles to interactive HTML elements.
- Make sure label elements have text content and are associated with an input.
- Don't assign interactive ARIA roles to non-interactive HTML elements.
- Don't assign `tabIndex` to non-interactive HTML elements.
- Don't use positive integers for `tabIndex` property.
- Don't include "image", "picture", or "photo" in img alt prop.
- Don't use explicit role property that's the same as the implicit/default role.
- Make static elements with click handlers use a valid role attribute.
- Always include a `title` element for SVG elements.
- Give all elements requiring alt text meaningful information for screen readers.
- Make sure anchors have content that's accessible to screen readers.
- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`.
- Include all required ARIA attributes for elements with ARIA roles.
- Make sure ARIA properties are valid for the element's supported roles.
- Always include a `type` attribute for button elements.
- Make elements with interactive roles and handlers focusable.
- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`).
- Always include a `lang` attribute on the html element.
- Always include a `title` attribute for iframe elements.
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
- Include caption tracks for audio and video elements.
- Use semantic elements instead of role attributes in JSX.
- Make sure all anchors are valid and navigable.
- Ensure all ARIA properties (`aria-*`) are valid.
- Use valid, non-abstract ARIA roles for elements with ARIA roles.
- Use valid ARIA state and property values.
- Use valid values for the `autocomplete` attribute on input elements.
- Use correct ISO language/country codes for the `lang` attribute.
### Code Complexity and Quality
- Don't use consecutive spaces in regular expression literals.
- Don't use the `arguments` object.
- Don't use primitive type aliases or misleading types.
- Don't use the comma operator.
- Don't use empty type parameters in type aliases and interfaces.
- Don't write functions that exceed a given Cognitive Complexity score.
- Don't nest describe() blocks too deeply in test files.
- Don't use unnecessary boolean casts.
- Don't use unnecessary callbacks with flatMap.
- Use for...of statements instead of Array.forEach.
- Don't create classes that only have static members (like a static namespace).
- Don't use this and super in static contexts.
- Don't use unnecessary catch clauses.
- Don't use unnecessary constructors.
- Don't use unnecessary continue statements.
- Don't export empty modules that don't change anything.
- Don't use unnecessary escape sequences in regular expression literals.
- Don't use unnecessary fragments.
- Don't use unnecessary labels.
- Don't use unnecessary nested block statements.
- Don't rename imports, exports, and destructured assignments to the same name.
- Don't use unnecessary string or template literal concatenation.
- Don't use String.raw in template literals when there are no escape sequences.
- Don't use useless case statements in switch statements.
- Don't use ternary operators when simpler alternatives exist.
- Don't use useless `this` aliasing.
- Don't use any or unknown as type constraints.
- Don't initialize variables to undefined.
- Don't use the void operators (they're not familiar).
- Use arrow functions instead of function expressions.
- Use Date.now() to get milliseconds since the Unix Epoch.
- Use .flatMap() instead of map().flat() when possible.
- Use literal property access instead of computed property access.
- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work.
- Use concise optional chaining instead of chained logical expressions.
- Use regular expression literals instead of the RegExp constructor when possible.
- Don't use number literal object member names that aren't base 10 or use underscore separators.
- Remove redundant terms from logical expressions.
- Use while loops instead of for loops when you don't need initializer and update expressions.
- Don't pass children as props.
- Don't reassign const variables.
- Don't use constant expressions in conditions.
- Don't use `Math.min` and `Math.max` to clamp values when the result is constant.
- Don't return a value from a constructor.
- Don't use empty character classes in regular expression literals.
- Don't use empty destructuring patterns.
- Don't call global object properties as functions.
- Don't declare functions and vars that are accessible outside their block.
- Make sure builtins are correctly instantiated.
- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors.
- Don't use variables and function parameters before they're declared.
- Don't use 8 and 9 escape sequences in string literals.
- Don't use literal numbers that lose precision.
### React and JSX Best Practices
- Don't use the return value of React.render.
- Make sure all dependencies are correctly specified in React hooks.
- Make sure all React hooks are called from the top level of component functions.
- Don't forget key props in iterators and collection literals.
- Don't destructure props inside JSX components in Solid projects.
- Don't define React components inside other components.
- Don't use event handlers on non-interactive elements.
- Don't assign to React component props.
- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
- Don't use dangerous JSX props.
- Don't use Array index in keys.
- Don't insert comments as text nodes.
- Don't assign JSX properties multiple times.
- Don't add extra closing tags for components without children.
- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
- Watch out for possible "wrong" semicolons inside JSX elements.
### Correctness and Safety
- Don't assign a value to itself.
- Don't return a value from a setter.
- Don't compare expressions that modify string case with non-compliant values.
- Don't use lexical declarations in switch clauses.
- Don't use variables that haven't been declared in the document.
- Don't write unreachable code.
- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass.
- Don't use control flow statements in finally blocks.
- Don't use optional chaining where undefined values aren't allowed.
- Don't have unused function parameters.
- Don't have unused imports.
- Don't have unused labels.
- Don't have unused private class members.
- Don't have unused variables.
- Make sure void (self-closing) elements don't have children.
- Don't return a value from a function with the return type 'void'
- Use isNaN() when checking for NaN.
- Make sure "for" loop update clauses move the counter in the right direction.
- Make sure typeof expressions are compared to valid values.
- Make sure generator functions contain yield.
- Don't use await inside loops.
- Don't use bitwise operators.
- Don't use expressions where the operation doesn't change the value.
- Make sure Promise-like statements are handled appropriately.
- Don't use **dirname and **filename in the global scope.
- Prevent import cycles.
- Don't use configured elements.
- Don't hardcode sensitive data like API keys and tokens.
- Don't let variable declarations shadow variables from outer scopes.
- Don't use the TypeScript directive @ts-ignore.
- Prevent duplicate polyfills from Polyfill.io.
- Don't use useless backreferences in regular expressions that always match empty strings.
- Don't use unnecessary escapes in string literals.
- Don't use useless undefined.
- Make sure getters and setters for the same property are next to each other in class and object definitions.
- Make sure object literals are declared consistently (defaults to explicit definitions).
- Use static Response methods instead of new Response() constructor when possible.
- Make sure switch-case statements are exhaustive.
- Make sure the `preconnect` attribute is used when using Google Fonts.
- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item.
- Make sure iterable callbacks return consistent values.
- Use `with { type: "json" }` for JSON module imports.
- Use numeric separators in numeric literals.
- Use object spread instead of `Object.assign()` when constructing new objects.
- Always use the radix argument when using `parseInt()`.
- Make sure JSDoc comment lines start with a single asterisk, except for the first one.
- Include a description parameter for `Symbol()`.
- Don't use spread (`...`) syntax on accumulators.
- Don't use the `delete` operator.
- Don't access namespace imports dynamically.
- Don't use namespace imports.
- Declare regex literals at the top level.
- Don't use `target="_blank"` without `rel="noopener"`.
### TypeScript Best Practices
- Don't use TypeScript enums.
- Don't export imported variables.
- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions.
- Don't use TypeScript namespaces.
- Don't use non-null assertions with the `!` postfix operator.
- Don't use parameter properties in class constructors.
- Don't use user-defined types.
- Use `as const` instead of literal types and type annotations.
- Use either `T[]` or `Array<T>` consistently.
- Initialize each enum member value explicitly.
- Use `export type` for types.
- Use `import type` for types.
- Make sure all enum members are literal values.
- Don't use TypeScript const enum.
- Don't declare empty interfaces.
- Don't let variables evolve into any type through reassignments.
- Don't use the any type.
- Don't misuse the non-null assertion operator (!) in TypeScript files.
- Don't use implicit any type on variable declarations.
- Don't merge interfaces and classes unsafely.
- Don't use overload signatures that aren't next to each other.
- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces.
### Style and Consistency
- Don't use global `eval()`.
- Don't use callbacks in asynchronous tests and hooks.
- Don't use negation in `if` statements that have `else` clauses.
- Don't use nested ternary expressions.
- Don't reassign function parameters.
- This rule lets you specify global variable names you don't want to use in your application.
- Don't use specified modules when loaded by import or require.
- Don't use constants whose value is the upper-case version of their name.
- Use `String.slice()` instead of `String.substr()` and `String.substring()`.
- Don't use template literals if you don't need interpolation or special-character handling.
- Don't use `else` blocks when the `if` block breaks early.
- Don't use yoda expressions.
- Don't use Array constructors.
- Use `at()` instead of integer index access.
- Follow curly brace conventions.
- Use `else if` instead of nested `if` statements in `else` clauses.
- Use single `if` statements instead of nested `if` clauses.
- Use `new` for all builtins except `String`, `Number`, and `Boolean`.
- Use consistent accessibility modifiers on class properties and methods.
- Use `const` declarations for variables that are only assigned once.
- Put default function parameters and optional function parameters last.
- Include a `default` clause in switch statements.
- Use the `**` operator instead of `Math.pow`.
- Use `for-of` loops when you need the index to extract an item from the iterated array.
- Use `node:assert/strict` over `node:assert`.
- Use the `node:` protocol for Node.js builtin modules.
- Use Number properties instead of global ones.
- Use assignment operator shorthand where possible.
- Use function types instead of object types with call signatures.
- Use template literals over string concatenation.
- Use `new` when throwing an error.
- Don't throw non-Error values.
- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
- Use standard constants instead of approximated literals.
- Don't assign values in expressions.
- Don't use async functions as Promise executors.
- Don't reassign exceptions in catch clauses.
- Don't reassign class members.
- Don't compare against -0.
- Don't use labeled statements that aren't loops.
- Don't use void type outside of generic or return types.
- Don't use console.
- Don't use control characters and escape sequences that match control characters in regular expression literals.
- Don't use debugger.
- Don't assign directly to document.cookie.
- Use `===` and `!==`.
- Don't use duplicate case labels.
- Don't use duplicate class members.
- Don't use duplicate conditions in if-else-if chains.
- Don't use two keys with the same name inside objects.
- Don't use duplicate function parameter names.
- Don't have duplicate hooks in describe blocks.
- Don't use empty block statements and static blocks.
- Don't let switch clauses fall through.
- Don't reassign function declarations.
- Don't allow assignments to native objects and read-only global variables.
- Use Number.isFinite instead of global isFinite.
- Use Number.isNaN instead of global isNaN.
- Don't assign to imported bindings.
- Don't use irregular whitespace characters.
- Don't use labels that share a name with a variable.
- Don't use characters made with multiple code points in character class syntax.
- Make sure to use new and constructor properly.
- Don't use shorthand assign when the variable appears on both sides.
- Don't use octal escape sequences in string literals.
- Don't use Object.prototype builtins directly.
- Don't redeclare variables, functions, classes, and types in the same scope.
- Don't have redundant "use strict".
- Don't compare things where both sides are exactly the same.
- Don't let identifiers shadow restricted names.
- Don't use sparse arrays (arrays with holes).
- Don't use template literal placeholder syntax in regular strings.
- Don't use the then property.
- Don't use unsafe negation.
- Don't use var.
- Don't use with statements in non-strict contexts.
- Make sure async functions actually use await.
- Make sure default clauses in switch statements come last.
- Make sure to pass a message value when creating a built-in error.
- Make sure get methods always return a value.
- Use a recommended display strategy with Google Fonts.
- Make sure for-in loops include an if statement.
- Use Array.isArray() instead of instanceof Array.
- Make sure to use the digits argument with Number#toFixed().
- Make sure to use the "use strict" directive in script files.
### Next.js Specific Rules
- Don't use `<img>` elements in Next.js projects.
- Don't use `<head>` elements in Next.js projects.
- Don't import next/document outside of pages/\_document.jsx in Next.js projects.
- Don't use the next/head module in pages/\_document.js on Next.js projects.
### Testing Best Practices
- Don't use export or module.exports in test files.
- Don't use focused tests.
- Make sure the assertion function, like expect, is placed inside an it() function call.
- Don't use disabled tests.
## Common Tasks
- `npx ultracite init` - Initialize Ultracite in your project
- `npx ultracite format` - Format and fix code automatically
- `npx ultracite lint` - Check for issues without fixing
## Example: Error Handling
```typescript
// ✅ Good: Comprehensive error handling
try {
const result = await fetchData();
return { success: true, data: result };
} catch (error) {
console.error("API call failed:", error);
return { success: false, error: error.message };
}
// ❌ Bad: Swallowing errors
try {
return await fetchData();
} catch (e) {
console.log(e);
}
```
---
applyTo: "**/*.{ts,tsx,js,jsx}"
---
# Project Context
Strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects, enforced via ESLint (linting) and Prettier (formatting).
## Key Principles
- Maximum type safety
- AI-friendly code generation
## Before Writing Code
1. Analyze existing patterns in the codebase
2. Consider edge cases and error scenarios
3. Follow the rules below strictly
4. Validate accessibility requirements
## Rules
### Accessibility (a11y)
- Don't use `accessKey` attribute on any HTML element.
- Don't set `aria-hidden="true"` on focusable elements.
- Don't add ARIA roles, states, and properties to elements that don't support them.
- Don't use distracting elements like `<marquee>` or `<blink>`.
- Only use the `scope` prop on `<th>` elements.
- Don't assign non-interactive ARIA roles to interactive HTML elements.
- Make sure label elements have text content and are associated with an input.
- Don't assign interactive ARIA roles to non-interactive HTML elements.
- Don't assign `tabIndex` to non-interactive HTML elements.
- Don't use positive integers for `tabIndex` property.
- Don't include "image", "picture", or "photo" in img alt prop.
- Don't use explicit role property that's the same as the implicit/default role.
- Make static elements with click handlers use a valid role attribute.
- Always include a `title` element for SVG elements.
- Give all elements requiring alt text meaningful information for screen readers.
- Make sure anchors have content that's accessible to screen readers.
- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`.
- Include all required ARIA attributes for elements with ARIA roles.
- Make sure ARIA properties are valid for the element's supported roles.
- Always include a `type` attribute for button elements.
- Make elements with interactive roles and handlers focusable.
- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`).
- Always include a `lang` attribute on the html element.
- Always include a `title` attribute for iframe elements.
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
- Include caption tracks for audio and video elements.
- Use semantic elements instead of role attributes in JSX.
- Make sure all anchors are valid and navigable.
- Ensure all ARIA properties (`aria-*`) are valid.
- Use valid, non-abstract ARIA roles for elements with ARIA roles.
- Use valid ARIA state and property values.
- Use valid values for the `autocomplete` attribute on input elements.
- Use correct ISO language/country codes for the `lang` attribute.
### Code Complexity and Quality
- Don't use consecutive spaces in regular expression literals.
- Don't use the `arguments` object.
- Don't use primitive type aliases or misleading types.
- Don't use the comma operator.
- Don't use empty type parameters in type aliases and interfaces.
- Don't write functions that exceed a given Cognitive Complexity score.
- Don't nest describe() blocks too deeply in test files.
- Don't use unnecessary boolean casts.
- Don't use unnecessary callbacks with flatMap.
- Use for...of statements instead of Array.forEach.
- Don't create classes that only have static members (like a static namespace).
- Don't use this and super in static contexts.
- Don't use unnecessary catch clauses.
- Don't use unnecessary constructors.
- Don't use unnecessary continue statements.
- Don't export empty modules that don't change anything.
- Don't use unnecessary escape sequences in regular expression literals.
- Don't use unnecessary fragments.
- Don't use unnecessary labels.
- Don't use unnecessary nested block statements.
- Don't rename imports, exports, and destructured assignments to the same name.
- Don't use unnecessary string or template literal concatenation.
- Don't use String.raw in template literals when there are no escape sequences.
- Don't use useless case statements in switch statements.
- Don't use ternary operators when simpler alternatives exist.
- Don't use useless `this` aliasing.
- Don't use any or unknown as type constraints.
- Don't initialize variables to undefined.
- Don't use the void operators (they're not familiar).
- Use arrow functions instead of function expressions.
- Use Date.now() to get milliseconds since the Unix Epoch.
- Use .flatMap() instead of map().flat() when possible.
- Use literal property access instead of computed property access.
- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work.
- Use concise optional chaining instead of chained logical expressions.
- Use regular expression literals instead of the RegExp constructor when possible.
- Don't use number literal object member names that aren't base 10 or use underscore separators.
- Remove redundant terms from logical expressions.
- Use while loops instead of for loops when you don't need initializer and update expressions.
- Don't pass children as props.
- Don't reassign const variables.
- Don't use constant expressions in conditions.
- Don't use `Math.min` and `Math.max` to clamp values when the result is constant.
- Don't return a value from a constructor.
- Don't use empty character classes in regular expression literals.
- Don't use empty destructuring patterns.
- Don't call global object properties as functions.
- Don't declare functions and vars that are accessible outside their block.
- Make sure builtins are correctly instantiated.
- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors.
- Don't use variables and function parameters before they're declared.
- Don't use 8 and 9 escape sequences in string literals.
- Don't use literal numbers that lose precision.
### React and JSX Best Practices
- Don't use the return value of React.render.
- Make sure all dependencies are correctly specified in React hooks.
- Make sure all React hooks are called from the top level of component functions.
- Don't forget key props in iterators and collection literals.
- Don't destructure props inside JSX components in Solid projects.
- Don't define React components inside other components.
- Don't use event handlers on non-interactive elements.
- Don't assign to React component props.
- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
- Don't use dangerous JSX props.
- Don't use Array index in keys.
- Don't insert comments as text nodes.
- Don't assign JSX properties multiple times.
- Don't add extra closing tags for components without children.
- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
- Watch out for possible "wrong" semicolons inside JSX elements.
### Correctness and Safety
- Don't assign a value to itself.
- Don't return a value from a setter.
- Don't compare expressions that modify string case with non-compliant values.
- Don't use lexical declarations in switch clauses.
- Don't use variables that haven't been declared in the document.
- Don't write unreachable code.
- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass.
- Don't use control flow statements in finally blocks.
- Don't use optional chaining where undefined values aren't allowed.
- Don't have unused function parameters.
- Don't have unused imports.
- Don't have unused labels.
- Don't have unused private class members.
- Don't have unused variables.
- Make sure void (self-closing) elements don't have children.
- Don't return a value from a function with the return type 'void'
- Use isNaN() when checking for NaN.
- Make sure "for" loop update clauses move the counter in the right direction.
- Make sure typeof expressions are compared to valid values.
- Make sure generator functions contain yield.
- Don't use await inside loops.
- Don't use bitwise operators.
- Don't use expressions where the operation doesn't change the value.
- Make sure Promise-like statements are handled appropriately.
- Don't use **dirname and **filename in the global scope.
- Prevent import cycles.
- Don't use configured elements.
- Don't hardcode sensitive data like API keys and tokens.
- Don't let variable declarations shadow variables from outer scopes.
- Don't use the TypeScript directive @ts-ignore.
- Prevent duplicate polyfills from Polyfill.io.
- Don't use useless backreferences in regular expressions that always match empty strings.
- Don't use unnecessary escapes in string literals.
- Don't use useless undefined.
- Make sure getters and setters for the same property are next to each other in class and object definitions.
- Make sure object literals are declared consistently (defaults to explicit definitions).
- Use static Response methods instead of new Response() constructor when possible.
- Make sure switch-case statements are exhaustive.
- Make sure the `preconnect` attribute is used when using Google Fonts.
- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item.
- Make sure iterable callbacks return consistent values.
- Use `with { type: "json" }` for JSON module imports.
- Use numeric separators in numeric literals.
- Use object spread instead of `Object.assign()` when constructing new objects.
- Always use the radix argument when using `parseInt()`.
- Make sure JSDoc comment lines start with a single asterisk, except for the first one.
- Include a description parameter for `Symbol()`.
- Don't use spread (`...`) syntax on accumulators.
- Don't use the `delete` operator.
- Don't access namespace imports dynamically.
- Don't use namespace imports.
- Declare regex literals at the top level.
- Don't use `target="_blank"` without `rel="noopener"`.
### TypeScript Best Practices
- Don't use TypeScript enums.
- Don't export imported variables.
- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions.
- Don't use TypeScript namespaces.
- Don't use non-null assertions with the `!` postfix operator.
- Don't use parameter properties in class constructors.
- Don't use user-defined types.
- Use `as const` instead of literal types and type annotations.
- Use either `T[]` or `Array<T>` consistently.
- Initialize each enum member value explicitly.
- Use `export type` for types.
- Use `import type` for types.
- Make sure all enum members are literal values.
- Don't use TypeScript const enum.
- Don't declare empty interfaces.
- Don't let variables evolve into any type through reassignments.
- Don't use the any type.
- Don't misuse the non-null assertion operator (!) in TypeScript files.
- Don't use implicit any type on variable declarations.
- Don't merge interfaces and classes unsafely.
- Don't use overload signatures that aren't next to each other.
- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces.
### Style and Consistency
- Don't use global `eval()`.
- Don't use callbacks in asynchronous tests and hooks.
- Don't use negation in `if` statements that have `else` clauses.
- Don't use nested ternary expressions.
- Don't reassign function parameters.
- This rule lets you specify global variable names you don't want to use in your application.
- Don't use specified modules when loaded by import or require.
- Don't use constants whose value is the upper-case version of their name.
- Use `String.slice()` instead of `String.substr()` and `String.substring()`.
- Don't use template literals if you don't need interpolation or special-character handling.
- Don't use `else` blocks when the `if` block breaks early.
- Don't use yoda expressions.
- Don't use Array constructors.
- Use `at()` instead of integer index access.
- Follow curly brace conventions.
- Use `else if` instead of nested `if` statements in `else` clauses.
- Use single `if` statements instead of nested `if` clauses.
- Use `new` for all builtins except `String`, `Number`, and `Boolean`.
- Use consistent accessibility modifiers on class properties and methods.
- Use `const` declarations for variables that are only assigned once.
- Put default function parameters and optional function parameters last.
- Include a `default` clause in switch statements.
- Use the `**` operator instead of `Math.pow`.
- Use `for-of` loops when you need the index to extract an item from the iterated array.
- Use `node:assert/strict` over `node:assert`.
- Use the `node:` protocol for Node.js builtin modules.
- Use Number properties instead of global ones.
- Use assignment operator shorthand where possible.
- Use function types instead of object types with call signatures.
- Use template literals over string concatenation.
- Use `new` when throwing an error.
- Don't throw non-Error values.
- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
- Use standard constants instead of approximated literals.
- Don't assign values in expressions.
- Don't use async functions as Promise executors.
- Don't reassign exceptions in catch clauses.
- Don't reassign class members.
- Don't compare against -0.
- Don't use labeled statements that aren't loops.
- Don't use void type outside of generic or return types.
- Don't use console.
- Don't use control characters and escape sequences that match control characters in regular expression literals.
- Don't use debugger.
- Don't assign directly to document.cookie.
- Use `===` and `!==`.
- Don't use duplicate case labels.
- Don't use duplicate class members.
- Don't use duplicate conditions in if-else-if chains.
- Don't use two keys with the same name inside objects.
- Don't use duplicate function parameter names.
- Don't have duplicate hooks in describe blocks.
- Don't use empty block statements and static blocks.
- Don't let switch clauses fall through.
- Don't reassign function declarations.
- Don't allow assignments to native objects and read-only global variables.
- Use Number.isFinite instead of global isFinite.
- Use Number.isNaN instead of global isNaN.
- Don't assign to imported bindings.
- Don't use irregular whitespace characters.
- Don't use labels that share a name with a variable.
- Don't use characters made with multiple code points in character class syntax.
- Make sure to use new and constructor properly.
- Don't use shorthand assign when the variable appears on both sides.
- Don't use octal escape sequences in string literals.
- Don't use Object.prototype builtins directly.
- Don't redeclare variables, functions, classes, and types in the same scope.
- Don't have redundant "use strict".
- Don't compare things where both sides are exactly the same.
- Don't let identifiers shadow restricted names.
- Don't use sparse arrays (arrays with holes).
- Don't use template literal placeholder syntax in regular strings.
- Don't use the then property.
- Don't use unsafe negation.
- Don't use var.
- Don't use with statements in non-strict contexts.
- Make sure async functions actually use await.
- Make sure default clauses in switch statements come last.
- Make sure to pass a message value when creating a built-in error.
- Make sure get methods always return a value.
- Use a recommended display strategy with Google Fonts.
- Make sure for-in loops include an if statement.
- Use Array.isArray() instead of instanceof Array.
- Make sure to use the digits argument with Number#toFixed().
- Make sure to use the "use strict" directive in script files.
### Next.js Specific Rules
- Don't use `<img>` elements in Next.js projects.
- Don't use `<head>` elements in Next.js projects.
- Don't import next/document outside of pages/\_document.jsx in Next.js projects.
- Don't use the next/head module in pages/\_document.js on Next.js projects.
### Testing Best Practices
- Don't use export or module.exports in test files.
- Don't use focused tests.
- Make sure the assertion function, like expect, is placed inside an it() function call.
- Don't use disabled tests.
## Common Tasks
- `npx ultracite init` - Initialize Ultracite in your project
- `npx ultracite format` - Format and fix code automatically
- `npx ultracite lint` - Check for issues without fixing
## Example: Error Handling
```typescript
// ✅ Good: Comprehensive error handling
try {
const result = await fetchData();
return { success: true, data: result };
} catch (error) {
console.error("API call failed:", error);
return { success: false, error: error.message };
}
// ❌ Bad: Swallowing errors
try {
return await fetchData();
} catch (e) {
console.log(e);
}
```
+6
View File
@@ -0,0 +1,6 @@
.next
node_modules
dist
build
target
rust/wasm/pkg
+3
View File
@@ -0,0 +1,3 @@
{
"useTabs": true
}
+2 -9
View File
@@ -7,14 +7,7 @@
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
"source.fixAll.eslint": "explicit"
},
"emmet.showExpandedAbbreviation": "never",
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
"emmet.showExpandedAbbreviation": "never"
}
-14
View File
@@ -21,17 +21,3 @@ Each app is a frontend that calls into Rust. Logic is never duplicated between a
- Read components before using them. They may already apply classes, which affects what you need to pass and how to override them.
### TypeScript
Function signatures should make the call site readable and let the function evolve without breaking callers. Positional parameters fail both: `formatTime(30, 24)` hides which number is which, and adding, removing, or reordering an argument silently breaks every caller whose types happen to still line up. A single destructured object fixes both at once - each argument names itself at the call site, and the shape can grow without churn. So signatures default to one object parameter:
```tsx
// ❌ meaning depends on order; the shape can't evolve without touching every caller
function formatTime(seconds: number, fps: number) { ... }
// ✅ each argument names itself; fields can be added, reordered, or made optional freely
function formatTime({ seconds, fps }: { seconds: number; fps: number }) { ... }
```
The one real exception is type predicates (`element is VideoElement`) — the language requires a positional subject, so the reasoning above doesn't get to apply.
+22 -49
View File
@@ -1,27 +1,13 @@
<table width="100%">
<tr>
<td align="left" width="120">
<img src="apps/web/public/logos/opencut/icon.svg" alt="OpenCut Logo" width="100" />
</td>
<td align="right">
<h1>OpenCut</h1>
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
</td>
</tr>
</table>
| | |
| --- | ---------------------------------------------------------------------- |
| | OpenCutA free, open-source video editor for web, desktop, and mobile. |
## Sponsors
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
<a href="https://vercel.com/oss">
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
</a>
<a href="https://fal.ai">
<img alt="Powered by fal.ai" src="https://img.shields.io/badge/Powered%20by-fal.ai-000000?style=flat&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCAxMEwxMy4wOSAxNS43NEwxMiAyMkwxMC45MSAxNS43NEw0IDEwTDEwLjkxIDguMjZMMTIgMloiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" />
</a>
## Why?
- **Privacy**: Your videos stay on your device
@@ -47,29 +33,23 @@ Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss
### Setup
1. Fork and clone the repository
2. Copy the environment file:
```bash
```bash
# Unix/Linux/Mac
cp apps/web/.env.example apps/web/.env.local
# Windows PowerShell
Copy-Item apps/web/.env.example apps/web/.env.local
```
```
3. Start the database and Redis:
```bash
```bash
docker compose up -d db redis serverless-redis-http
```
```
4. Install dependencies and start the dev server:
```bash
```bash
bun install
bun dev:web
```
```
The application will be available at [http://localhost:3000](http://localhost:3000).
@@ -79,7 +59,7 @@ The `.env.example` has sensible defaults that match the Docker Compose config
Desktop is opt-in. If you're only working on the web app, skip this entirely.
If you want to get ready for `apps/desktop`, see [`apps/desktop/README.md`](apps/desktop/README.md). It's a two-step setup: Rust toolchain first, then desktop native dependencies.
If you want to get ready for `apps/desktop`, see `[apps/desktop/README.md](apps/desktop/README.md)`. It's a two-step setup: Rust toolchain first, then desktop native dependencies.
### Local WASM development
@@ -99,30 +79,23 @@ cargo install cargo-watch
```
1. Build the package once from the repo root:
```bash
```bash
bun run build:wasm
```
```
2. Register the generated package for linking:
```bash
```bash
cd rust/wasm/pkg
bun link
```
```
3. Link `apps/web` to the local package:
```bash
```bash
cd apps/web
bun link opencut-wasm
```
```
4. Rebuild on changes while you work:
```bash
```bash
bun dev:wasm
```
```
To switch `apps/web` back to the published package, run:
@@ -155,7 +128,7 @@ See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instruc
- Fork the repo and clone locally
- Follow the setup instructions in CONTRIBUTING.md
- Working on `apps/desktop`? See [`apps/desktop/README.md`](apps/desktop/README.md) for setup
- Working on `apps/desktop`? See `[apps/desktop/README.md](apps/desktop/README.md)` for setup
- Create a feature branch and submit a PR
## License
@@ -164,4 +137,4 @@ See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instruc
---
![Star History Chart](https://api.star-history.com/svg?repos=opencut-app/opencut&type=Date)
Star History Chart
+3 -3
View File
@@ -9,9 +9,9 @@
"start": "next start",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"lint": "biome check src/",
"lint:fix": "biome check src/ --write",
"format": "biome format src/ --write",
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix",
"format": "prettier src --write",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
@@ -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);
+38 -37
View File
@@ -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);
}
}
+43 -79
View File
@@ -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;
};
+109 -86
View File
@@ -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;
}
@@ -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/);
});
});
@@ -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;
}
}
@@ -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;
}
}
@@ -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) {
@@ -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") {
@@ -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";
}
}
@@ -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,
};
}
@@ -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;
}
+3
View File
@@ -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>,
+44 -44
View File
@@ -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;
@@ -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>,
+18 -30
View File
@@ -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",
@@ -317,7 +305,7 @@ export function useEditorActions() {
if (!selectedMaskPointSelection) {
return;
}
editor.timeline.deleteCustomMaskPoints({
editor.timeline.deleteFreeformPathMaskPoints({
trackId: selectedMaskPointSelection.trackId,
elementId: selectedMaskPointSelection.elementId,
maskId: selectedMaskPointSelection.maskId,
+1 -1
View File
@@ -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();
@@ -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,
@@ -1,15 +1,15 @@
import { describe, expect, test } from "bun:test";
import {
coerceAnimationParamValue,
getAnimationParamDefaultInterpolation,
getAnimationParamNumericRange,
getAnimationParamValueKind,
} from "@/animation/animated-params";
coerceParamValue,
getParamDefaultInterpolation,
getParamNumericRange,
getParamValueKind,
} from "@/params";
describe("animated params", () => {
test("snaps and clamps number params", () => {
expect(
coerceAnimationParamValue({
coerceParamValue({
param: {
key: "intensity",
label: "Intensity",
@@ -24,7 +24,7 @@ describe("animated params", () => {
).toBe(0.5);
expect(
coerceAnimationParamValue({
coerceParamValue({
param: {
key: "intensity",
label: "Intensity",
@@ -49,14 +49,14 @@ describe("animated params", () => {
max: 1,
step: 0.25,
};
expect(coerceAnimationParamValue({ param, value: Number.NaN })).toBeNull();
expect(coerceAnimationParamValue({ param, value: "0.5" })).toBeNull();
expect(coerceAnimationParamValue({ param, value: true })).toBeNull();
expect(coerceParamValue({ param, value: Number.NaN })).toBeNull();
expect(coerceParamValue({ param, value: "0.5" })).toBeNull();
expect(coerceParamValue({ param, value: true })).toBeNull();
});
test("passthrough with step <= 0 guard", () => {
expect(
coerceAnimationParamValue({
coerceParamValue({
param: {
key: "x",
label: "X",
@@ -81,13 +81,13 @@ describe("animated params", () => {
{ value: "multiply", label: "Multiply" },
],
};
expect(coerceAnimationParamValue({ param, value: "normal" })).toBe("normal");
expect(coerceAnimationParamValue({ param, value: "multiply" })).toBe("multiply");
expect(coerceParamValue({ param, value: "normal" })).toBe("normal");
expect(coerceParamValue({ param, value: "multiply" })).toBe("multiply");
});
test("rejects select values outside the allowed options", () => {
expect(
coerceAnimationParamValue({
coerceParamValue({
param: {
key: "blend",
label: "Blend",
@@ -111,9 +111,9 @@ describe("animated params", () => {
default: "normal",
options: [{ value: "normal", label: "Normal" }],
};
expect(coerceAnimationParamValue({ param, value: 42 })).toBeNull();
expect(coerceAnimationParamValue({ param, value: null })).toBeNull();
expect(coerceAnimationParamValue({ param, value: undefined })).toBeNull();
expect(coerceParamValue({ param, value: 42 })).toBeNull();
expect(coerceParamValue({ param, value: null })).toBeNull();
expect(coerceParamValue({ param, value: undefined })).toBeNull();
});
test("boolean params accept booleans and reject other types", () => {
@@ -123,10 +123,10 @@ describe("animated params", () => {
type: "boolean" as const,
default: true,
};
expect(coerceAnimationParamValue({ param, value: true })).toBe(true);
expect(coerceAnimationParamValue({ param, value: false })).toBe(false);
expect(coerceAnimationParamValue({ param, value: 1 })).toBeNull();
expect(coerceAnimationParamValue({ param, value: "true" })).toBeNull();
expect(coerceParamValue({ param, value: true })).toBe(true);
expect(coerceParamValue({ param, value: false })).toBe(false);
expect(coerceParamValue({ param, value: 1 })).toBeNull();
expect(coerceParamValue({ param, value: "true" })).toBeNull();
});
test("color params accept strings and reject other types", () => {
@@ -136,14 +136,14 @@ describe("animated params", () => {
type: "color" as const,
default: "#ffffff",
};
expect(coerceAnimationParamValue({ param, value: "#ff0000" })).toBe("#ff0000");
expect(coerceAnimationParamValue({ param, value: 0xff0000 })).toBeNull();
expect(coerceAnimationParamValue({ param, value: null })).toBeNull();
expect(coerceParamValue({ param, value: "#ff0000" })).toBe("#ff0000");
expect(coerceParamValue({ param, value: 0xff0000 })).toBeNull();
expect(coerceParamValue({ param, value: null })).toBeNull();
});
test("getAnimationParamValueKind maps param type to binding kind", () => {
expect(
getAnimationParamValueKind({
getParamValueKind({
param: {
key: "n",
label: "N",
@@ -155,17 +155,17 @@ describe("animated params", () => {
}),
).toBe("number");
expect(
getAnimationParamValueKind({
getParamValueKind({
param: { key: "c", label: "C", type: "color", default: "#fff" },
}),
).toBe("color");
expect(
getAnimationParamValueKind({
getParamValueKind({
param: { key: "b", label: "B", type: "boolean", default: false },
}),
).toBe("discrete");
expect(
getAnimationParamValueKind({
getParamValueKind({
param: {
key: "s",
label: "S",
@@ -179,7 +179,7 @@ describe("animated params", () => {
test("getAnimationParamDefaultInterpolation is linear for continuous, hold for discrete", () => {
expect(
getAnimationParamDefaultInterpolation({
getParamDefaultInterpolation({
param: {
key: "n",
label: "N",
@@ -191,17 +191,17 @@ describe("animated params", () => {
}),
).toBe("linear");
expect(
getAnimationParamDefaultInterpolation({
getParamDefaultInterpolation({
param: { key: "c", label: "C", type: "color", default: "#fff" },
}),
).toBe("linear");
expect(
getAnimationParamDefaultInterpolation({
getParamDefaultInterpolation({
param: { key: "b", label: "B", type: "boolean", default: false },
}),
).toBe("hold");
expect(
getAnimationParamDefaultInterpolation({
getParamDefaultInterpolation({
param: {
key: "s",
label: "S",
@@ -215,7 +215,7 @@ describe("animated params", () => {
test("getAnimationParamNumericRange returns spec for number params, undefined otherwise", () => {
expect(
getAnimationParamNumericRange({
getParamNumericRange({
param: {
key: "intensity",
label: "Intensity",
@@ -228,12 +228,12 @@ describe("animated params", () => {
}),
).toEqual({ min: 0, max: 1, step: 0.1 });
expect(
getAnimationParamNumericRange({
getParamNumericRange({
param: { key: "c", label: "C", type: "color", default: "#fff" },
}),
).toBeUndefined();
expect(
getAnimationParamNumericRange({
getParamNumericRange({
param: { key: "b", label: "B", type: "boolean", default: false },
}),
).toBeUndefined();
@@ -1,26 +0,0 @@
import { describe, expect, test } from "bun:test";
import {
composeAnimationValue,
createAnimationBinding,
} from "@/animation/binding-values";
describe("binding values", () => {
test("formats composed animated colors as hex", () => {
const binding = createAnimationBinding({
path: "color",
kind: "color",
});
expect(
composeAnimationValue({
binding,
componentValues: {
r: 1,
g: 0,
b: 0,
a: 1,
},
}),
).toBe("#ff0000");
});
});
-83
View File
@@ -1,83 +0,0 @@
import { snapToStep } from "@/utils/math";
import type { ParamDefinition } from "@/params";
import type { DynamicAnimationPathValue, NumericSpec } from "./types";
export function getAnimationParamValueKind({
param,
}: {
param: ParamDefinition;
}): "number" | "color" | "discrete" {
if (param.type === "number") {
return "number";
}
if (param.type === "color") {
return "color";
}
return "discrete";
}
export function getAnimationParamDefaultInterpolation({
param,
}: {
param: ParamDefinition;
}): "linear" | "hold" {
return param.type === "number" || param.type === "color" ? "linear" : "hold";
}
export function getAnimationParamNumericRange({
param,
}: {
param: ParamDefinition;
}): NumericSpec | undefined {
if (param.type !== "number") {
return undefined;
}
return {
min: param.min,
max: param.max,
step: param.step,
};
}
/**
* `coerceAnimationParamValue` accepts `unknown` rather than a narrow
* `DynamicAnimationPathValue` because it doubles as a runtime gate for
* untrusted inputs (persisted state, paste payloads, programmatic updates).
* The caller does not need to pre-validate; null means "this value cannot live
* on this param".
*/
export function coerceAnimationParamValue({
param,
value,
}: {
param: ParamDefinition;
value: unknown;
}): DynamicAnimationPathValue | null {
if (param.type === "number") {
if (typeof value !== "number" || Number.isNaN(value)) {
return null;
}
const steppedValue = snapToStep({ value, step: param.step });
const minValue = param.min;
const maxValue = param.max ?? Number.POSITIVE_INFINITY;
return Math.min(maxValue, Math.max(minValue, steppedValue));
}
if (param.type === "color") {
return typeof value === "string" ? value : null;
}
if (param.type === "boolean") {
return typeof value === "boolean" ? value : null;
}
if (typeof value !== "string") {
return null;
}
return param.options.some((option) => option.value === value) ? value : null;
}
-344
View File
@@ -1,344 +0,0 @@
import { converter, formatHex, formatHex8, parse } from "culori";
import type {
AnimationBindingComponent,
AnimationBindingOfKind,
AnimationBindingInstance,
AnimationBindingKind,
ColorAnimationBinding,
DiscreteAnimationBinding,
NumberAnimationBinding,
AnimationPath,
AnimationValue,
DiscreteValue,
Vector2AnimationBinding,
VectorValue,
} from "@/animation/types";
import { clamp } from "@/utils/math";
interface LinearRgba {
r: number;
g: number;
b: number;
a: number;
}
export type AnimationComponentValue = number | DiscreteValue;
const toRgb = converter("rgb");
function srgbToLinear({ value }: { value: number }): number {
return value <= 0.04045
? value / 12.92
: Math.pow((value + 0.055) / 1.055, 2.4);
}
function linearToSrgb({ value }: { value: number }): number {
const clamped = clamp({ value, min: 0, max: 1 });
return clamped <= 0.0031308
? clamped * 12.92
: 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function isVectorValue(value: unknown): value is VectorValue {
return isRecord(value) && typeof value.x === "number" && typeof value.y === "number";
}
export type EasingMode = "independent" | "shared";
/**
* Declares how easing curves apply to a binding's components.
* "shared" means all components always use the same curve (e.g. color — you never
* want to ease R independently from G/B/A). "independent" means each component
* can have its own curve.
*/
export function getEasingModeForKind(kind: AnimationBindingKind): EasingMode {
return kind === "color" ? "shared" : "independent";
}
export function getBindingComponentKeys({
kind,
}: {
kind: AnimationBindingKind;
}): string[] {
if (kind === "vector2") {
return ["x", "y"];
}
if (kind === "color") {
return ["r", "g", "b", "a"];
}
return ["value"];
}
export function buildBindingChannelId({
path,
componentKey,
}: {
path: AnimationPath;
componentKey: string;
}): string {
return `${path}:${componentKey}`;
}
function createBindingComponent<TKey extends string>({
path,
key,
}: {
path: AnimationPath;
key: TKey;
}): AnimationBindingComponent<TKey> {
return {
key,
channelId: buildBindingChannelId({ path, componentKey: key }),
};
}
function cloneBindingComponents<TKey extends string>({
components,
}: {
components: AnimationBindingComponent<TKey>[];
}): AnimationBindingComponent<TKey>[] {
return components.map((component) => ({ ...component }));
}
const animationBindingFactories = {
color: ({ path }: { path: AnimationPath }): ColorAnimationBinding => ({
path,
kind: "color",
colorSpace: "srgb-linear",
components: [
createBindingComponent({ path, key: "r" }),
createBindingComponent({ path, key: "g" }),
createBindingComponent({ path, key: "b" }),
createBindingComponent({ path, key: "a" }),
],
}),
vector2: ({ path }: { path: AnimationPath }): Vector2AnimationBinding => ({
path,
kind: "vector2",
components: [
createBindingComponent({ path, key: "x" }),
createBindingComponent({ path, key: "y" }),
],
}),
number: ({ path }: { path: AnimationPath }): NumberAnimationBinding => ({
path,
kind: "number",
components: [createBindingComponent({ path, key: "value" })],
}),
discrete: ({ path }: { path: AnimationPath }): DiscreteAnimationBinding => ({
path,
kind: "discrete",
components: [createBindingComponent({ path, key: "value" })],
}),
} satisfies {
[K in AnimationBindingKind]: ({
path,
}: {
path: AnimationPath;
}) => AnimationBindingOfKind<K>;
};
export function createAnimationBinding<TKind extends AnimationBindingKind>({
path,
kind,
}: {
path: AnimationPath;
kind: TKind;
}): AnimationBindingOfKind<TKind>;
export function createAnimationBinding({
path,
kind,
}: {
path: AnimationPath;
kind: AnimationBindingKind;
}): AnimationBindingInstance {
return animationBindingFactories[kind]({ path });
}
const animationBindingCloners = {
color: ({ binding }: { binding: ColorAnimationBinding }): ColorAnimationBinding => ({
...binding,
components: cloneBindingComponents({
components: binding.components,
}),
}),
vector2: ({
binding,
}: {
binding: Vector2AnimationBinding;
}): Vector2AnimationBinding => ({
...binding,
components: cloneBindingComponents({
components: binding.components,
}),
}),
number: ({
binding,
}: {
binding: NumberAnimationBinding;
}): NumberAnimationBinding => ({
...binding,
components: cloneBindingComponents({
components: binding.components,
}),
}),
discrete: ({
binding,
}: {
binding: DiscreteAnimationBinding;
}): DiscreteAnimationBinding => ({
...binding,
components: cloneBindingComponents({
components: binding.components,
}),
}),
} satisfies {
[K in AnimationBindingKind]: ({
binding,
}: {
binding: AnimationBindingOfKind<K>;
}) => AnimationBindingOfKind<K>;
};
export function cloneAnimationBinding<TKind extends AnimationBindingKind>({
binding,
}: {
binding: AnimationBindingOfKind<TKind>;
}): AnimationBindingOfKind<TKind>;
export function cloneAnimationBinding({
binding,
}: {
binding: AnimationBindingInstance;
}): AnimationBindingInstance {
switch (binding.kind) {
case "color":
return animationBindingCloners.color({ binding });
case "vector2":
return animationBindingCloners.vector2({ binding });
case "number":
return animationBindingCloners.number({ binding });
case "discrete":
return animationBindingCloners.discrete({ binding });
}
}
export function parseColorToLinearRgba({
color,
}: {
color: string;
}): LinearRgba | null {
const parsed = parse(color);
const rgb = parsed ? toRgb(parsed) : null;
if (!rgb) {
return null;
}
return {
r: srgbToLinear({ value: rgb.r ?? 0 }),
g: srgbToLinear({ value: rgb.g ?? 0 }),
b: srgbToLinear({ value: rgb.b ?? 0 }),
a: clamp({ value: rgb.alpha ?? 1, min: 0, max: 1 }),
};
}
export function formatLinearRgba({
color,
}: {
color: LinearRgba;
}): string {
const rgb = {
mode: "rgb",
r: linearToSrgb({ value: color.r }),
g: linearToSrgb({ value: color.g }),
b: linearToSrgb({ value: color.b }),
alpha: clamp({ value: color.a, min: 0, max: 1 }),
} as const;
return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb);
}
export function decomposeAnimationValue({
kind,
value,
}: {
kind: AnimationBindingKind;
value: AnimationValue;
}): Record<string, AnimationComponentValue> | null {
if (kind === "number") {
return typeof value === "number" ? { value } : null;
}
if (kind === "vector2") {
return isVectorValue(value) ? { x: value.x, y: value.y } : null;
}
if (kind === "color") {
if (typeof value !== "string") {
return null;
}
const parsed = parseColorToLinearRgba({ color: value });
if (!parsed) {
return null;
}
return {
r: parsed.r,
g: parsed.g,
b: parsed.b,
a: parsed.a,
};
}
return typeof value === "string" || typeof value === "boolean"
? { value }
: null;
}
export function composeAnimationValue({
binding,
componentValues,
}: {
binding: AnimationBindingInstance;
componentValues: Record<string, AnimationComponentValue | undefined>;
}): AnimationValue | null {
if (binding.kind === "number") {
const value = componentValues.value;
return typeof value === "number" ? value : null;
}
if (binding.kind === "vector2") {
const x = componentValues.x;
const y = componentValues.y;
return typeof x === "number" && typeof y === "number" ? { x, y } : null;
}
if (binding.kind === "color") {
const r = componentValues.r;
const g = componentValues.g;
const b = componentValues.b;
const a = componentValues.a;
if (
typeof r !== "number" ||
typeof g !== "number" ||
typeof b !== "number" ||
typeof a !== "number"
) {
return null;
}
return formatLinearRgba({
color: {
r,
g,
b,
a,
},
});
}
const value = componentValues.value;
return typeof value === "string" || typeof value === "boolean" ? value : null;
}
+57
View File
@@ -0,0 +1,57 @@
import type {
AnimationChannel,
ChannelData,
CompositeChannelData,
} from "@/animation/types";
const LEGACY_ANIMATION_STORAGE_KEYS = new Set(["bindings", "channels"]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function isLeafChannelData(
data: ChannelData | undefined,
): data is AnimationChannel {
return isRecord(data) && Array.isArray(data.keys);
}
export function isCompositeChannelData(
data: ChannelData | undefined,
): data is CompositeChannelData {
return isRecord(data) && !Array.isArray(data.keys);
}
export function getChannelsFromData({
data,
}: {
data: ChannelData | undefined;
}): AnimationChannel[] {
if (isLeafChannelData(data)) {
return [data];
}
if (!isCompositeChannelData(data)) {
return [];
}
return Object.values(data).filter(isLeafChannelData);
}
export function getChannelEntriesFromData({
data,
}: {
data: ChannelData | undefined;
}): Array<[string, AnimationChannel]> {
if (isLeafChannelData(data)) {
return [["value", data]];
}
if (!isCompositeChannelData(data)) {
return [];
}
return Object.entries(data).flatMap(([componentKey, channel]) =>
isLeafChannelData(channel) ? [[componentKey, channel]] : [],
);
}
export function isAnimationStorageKey({ key }: { key: string }): boolean {
return !LEGACY_ANIMATION_STORAGE_KEYS.has(key);
}
@@ -70,7 +70,7 @@ export function resolveEffectParamsAtTime({
for (const [paramKey, staticValue] of Object.entries(params)) {
const path = buildEffectParamPath({ effectId, paramKey });
resolved[paramKey] = animations?.bindings[path]
resolved[paramKey] = animations?.[path]
? resolveAnimationPathValueAtTime({
animations,
propertyPath: path,
+26 -11
View File
@@ -1,21 +1,35 @@
import type {
AnimationBindingInstance,
AnimationPath,
ElementAnimations,
ChannelData,
ScalarAnimationChannel,
ScalarGraphChannel,
ScalarGraphKeyframeContext,
} from "@/animation/types";
import type { ChannelEasingMode } from "@/params";
import { isCompositeChannelData, isLeafChannelData } from "./channel-data";
import { isScalarChannel } from "./interpolation";
export interface EditableScalarChannels {
binding: AnimationBindingInstance;
easingMode: ChannelEasingMode;
channels: ScalarGraphChannel[];
}
function isScalarAnimationChannel(
channel: ElementAnimations["channels"][string],
channel: ChannelData | undefined,
): channel is ScalarAnimationChannel {
return channel?.kind === "scalar";
return isLeafChannelData(channel) && isScalarChannel(channel);
}
function getEasingModeForChannelData({
data,
}: {
data: ChannelData | undefined;
}): ChannelEasingMode {
return isCompositeChannelData(data) &&
["r", "g", "b", "a"].every((componentKey) => componentKey in data)
? "shared"
: "independent";
}
export function getEditableScalarChannels({
@@ -25,13 +39,15 @@ export function getEditableScalarChannels({
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
}): EditableScalarChannels | null {
const binding = animations?.bindings[propertyPath];
if (!binding) {
const data = animations?.[propertyPath];
if (!data) {
return null;
}
const channels = binding.components.flatMap((component) => {
const channel = animations?.channels[component.channelId];
const channelEntries = isLeafChannelData(data)
? [["value", data] as const]
: Object.entries(data);
const channels = channelEntries.flatMap(([componentKey, channel]) => {
if (!isScalarAnimationChannel(channel)) {
return [];
}
@@ -39,14 +55,13 @@ export function getEditableScalarChannels({
return [
{
propertyPath,
componentKey: component.key,
channelId: component.channelId,
componentKey,
channel,
} satisfies ScalarGraphChannel,
];
});
return { binding, channels };
return { easingMode: getEasingModeForChannelData({ data }), channels };
}
export function getEditableScalarChannel({
@@ -49,7 +49,7 @@ export function resolveGraphicParamsAtTime({
for (const param of definitions) {
const path = buildGraphicParamPath({ paramKey: param.key });
if (!animations?.bindings[path]) {
if (!animations?.[path]) {
continue;
}
+1 -16
View File
@@ -16,7 +16,6 @@ export {
setChannel,
splitAnimationsAtTime,
updateScalarKeyframeCurve,
upsertElementKeyframe,
upsertPathKeyframe,
} from "./keyframes";
@@ -65,23 +64,9 @@ export {
type GroupKeyframeRef,
} from "./property-groups";
export {
type EasingMode,
getEasingModeForKind,
} from "./binding-values";
export {
isAnimationPath,
isAnimationPropertyPath,
} from "./path";
export {
coerceAnimationValueForProperty,
getAnimationPropertyDefinition,
getDefaultInterpolationForProperty,
getElementBaseValueForProperty,
supportsAnimationProperty,
type AnimationPropertyDefinition,
type NumericSpec,
withElementBaseValueForProperty,
} from "./property-registry";
export type { NumericSpec } from "./types";
+66 -25
View File
@@ -1,13 +1,14 @@
import type {
AnimationChannel,
AnimationInterpolation,
AnimationValue,
Channel,
DiscreteAnimationChannel,
DiscreteValue,
ScalarAnimationChannel,
ScalarAnimationKey,
ScalarSegmentType,
} from "@/animation/types";
import type { ParamValue } from "@/params";
import { mediaTime } from "@/wasm";
import {
getBezierPoint,
@@ -111,7 +112,7 @@ function normalizeScalarKey({
};
}
function normalizeScalarChannel({
export function normalizeScalarChannel({
channel,
}: {
channel: ScalarAnimationChannel;
@@ -154,17 +155,11 @@ function normalizeScalarChannel({
};
}
export function normalizeChannel<TChannel extends AnimationChannel>({
export function normalizeDiscreteChannel({
channel,
}: {
channel: TChannel;
}): TChannel {
if (channel.kind === "scalar") {
return normalizeScalarChannel({
channel,
}) as TChannel;
}
channel: DiscreteAnimationChannel;
}): DiscreteAnimationChannel {
return {
...channel,
keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) =>
@@ -173,7 +168,39 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
rightTime: rightKeyframe.time,
}),
),
} as TChannel;
};
}
export function isScalarChannel(channel: AnimationChannel): channel is ScalarAnimationChannel {
return (
"extrapolation" in channel ||
channel.keys.some((keyframe) => "segmentToNext" in keyframe)
);
}
export function normalizeChannel({
channel,
}: {
channel: ScalarAnimationChannel;
}): ScalarAnimationChannel;
export function normalizeChannel({
channel,
}: {
channel: DiscreteAnimationChannel;
}): DiscreteAnimationChannel;
export function normalizeChannel({
channel,
}: {
channel: AnimationChannel;
}): AnimationChannel;
export function normalizeChannel({
channel,
}: {
channel: AnimationChannel;
}): AnimationChannel {
return isScalarChannel(channel)
? normalizeScalarChannel({ channel })
: normalizeDiscreteChannel({ channel });
}
function extrapolateScalarEdge({
@@ -216,7 +243,7 @@ export function getScalarChannelValueAtTime({
time,
fallbackValue,
}: {
channel: ScalarAnimationChannel | undefined;
channel: Channel<number> | undefined;
time: number;
fallbackValue: number;
}): number {
@@ -224,9 +251,7 @@ export function getScalarChannelValueAtTime({
return fallbackValue;
}
const normalizedChannel = normalizeChannel({
channel,
});
const normalizedChannel = normalizeScalarChannel({ channel });
const firstKey = normalizedChannel.keys[0];
const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1];
if (!firstKey || !lastKey) {
@@ -328,7 +353,7 @@ export function getDiscreteChannelValueAtTime({
time,
fallbackValue,
}: {
channel: DiscreteAnimationChannel | undefined;
channel: Channel<DiscreteValue> | undefined;
time: number;
fallbackValue: DiscreteValue;
}): DiscreteValue {
@@ -336,9 +361,7 @@ export function getDiscreteChannelValueAtTime({
return fallbackValue;
}
const normalizedChannel = normalizeChannel({
channel,
});
const normalizedChannel = normalizeDiscreteChannel({ channel });
let currentValue = fallbackValue;
for (const key of normalizedChannel.keys) {
if (time < key.time) {
@@ -349,6 +372,24 @@ export function getDiscreteChannelValueAtTime({
return currentValue;
}
export function getChannelValueAtTime({
channel,
time,
fallbackValue,
}: {
channel: Channel<number> | undefined;
time: number;
fallbackValue: number;
}): number;
export function getChannelValueAtTime<TValue extends DiscreteValue>({
channel,
time,
fallbackValue,
}: {
channel: DiscreteAnimationChannel | undefined;
time: number;
fallbackValue: TValue;
}): TValue;
export function getChannelValueAtTime({
channel,
time,
@@ -356,14 +397,14 @@ export function getChannelValueAtTime({
}: {
channel: AnimationChannel | undefined;
time: number;
fallbackValue: AnimationValue;
}): AnimationValue {
fallbackValue: ParamValue;
}): ParamValue {
if (!channel || channel.keys.length === 0) {
return fallbackValue;
}
if (channel.kind === "scalar") {
return typeof fallbackValue === "number"
if (typeof fallbackValue === "number") {
return isScalarChannel(channel)
? getScalarChannelValueAtTime({
channel,
time,
@@ -377,7 +418,7 @@ export function getChannelValueAtTime({
}
return getDiscreteChannelValueAtTime({
channel,
channel: isScalarChannel(channel) ? undefined : channel,
time,
fallbackValue,
});
+123 -103
View File
@@ -1,75 +1,75 @@
import type {
AnimationBindingInstance,
AnimationChannel,
AnimationPath,
ChannelData,
ElementAnimations,
ElementKeyframe,
} from "@/animation/types";
import { formatLinearRgba } from "@/params";
import {
type AnimationComponentValue,
composeAnimationValue,
} from "./binding-values";
getChannelEntriesFromData,
isAnimationStorageKey,
} from "./channel-data";
import {
getChannelValueAtTime,
getScalarSegmentInterpolation,
isScalarChannel,
} from "./interpolation";
import { isAnimationPath } from "./path";
function getBindingFallbackValue({
function getChannelFallbackValue({
channel,
}: {
channel: ElementAnimations["channels"][string];
channel: AnimationChannel;
}) {
if (!channel || channel.keys.length === 0) {
return channel?.kind === "discrete" ? false : 0;
if (channel.keys.length === 0) {
return isScalarChannel(channel) ? 0 : false;
}
return channel.keys[0].value;
}
interface BindingKeyframeMatch {
interface ChannelKeyframeMatch {
componentKey: string;
componentIndex: number;
channel: AnimationChannel;
keyframe: AnimationChannel["keys"][number];
}
function getBindingKeyframeMatches({
animations,
binding,
function getChannelKeyframeMatches({
data,
}: {
animations: ElementAnimations;
binding: AnimationBindingInstance;
}): BindingKeyframeMatch[] {
return binding.components.flatMap((component, componentIndex) => {
const channel = animations.channels[component.channelId];
if (!channel || channel.keys.length === 0) {
return [];
}
data: ChannelData | undefined;
}): ChannelKeyframeMatch[] {
return getChannelEntriesFromData({ data }).flatMap(
([componentKey, channel], componentIndex) => {
if (channel.keys.length === 0) {
return [];
}
return channel.keys.map((keyframe) => ({
componentIndex,
channel,
keyframe,
}));
});
return channel.keys.map((keyframe) => ({
componentKey,
componentIndex,
channel,
keyframe,
}));
},
);
}
function getUniqueBindingKeyframeMatches({
animations,
binding,
function getUniqueChannelKeyframeMatches({
data,
}: {
animations: ElementAnimations;
binding: AnimationBindingInstance;
}): BindingKeyframeMatch[] {
const sortedMatches = getBindingKeyframeMatches({
animations,
binding,
data: ChannelData | undefined;
}): ChannelKeyframeMatch[] {
const sortedMatches = getChannelKeyframeMatches({
data,
}).sort(
(leftMatch, rightMatch) =>
leftMatch.keyframe.time - rightMatch.keyframe.time ||
leftMatch.componentIndex - rightMatch.componentIndex,
);
const uniqueMatches: BindingKeyframeMatch[] = [];
const uniqueMatches: ChannelKeyframeMatch[] = [];
for (const match of sortedMatches) {
const previousMatch = uniqueMatches[uniqueMatches.length - 1];
@@ -92,11 +92,11 @@ function getUniqueBindingKeyframeMatches({
return uniqueMatches;
}
function getPreferredBindingKeyframeMatch({
function getPreferredChannelKeyframeMatch({
matches,
}: {
matches: BindingKeyframeMatch[];
}): BindingKeyframeMatch | null {
matches: ChannelKeyframeMatch[];
}): ChannelKeyframeMatch | null {
return (
matches.find((match) => match.componentIndex === 0) ??
matches[0] ??
@@ -104,35 +104,67 @@ function getPreferredBindingKeyframeMatch({
);
}
function getComposedBindingValueAtTime({
animations,
binding,
function getChannelValue({
channel,
time,
}: {
animations: ElementAnimations;
binding: AnimationBindingInstance;
channel: AnimationChannel;
time: number;
}) {
const componentValues = Object.fromEntries(
binding.components.map((component) => {
const channel = animations.channels[component.channelId];
return [
component.key,
getChannelValueAtTime({
channel,
time,
fallbackValue: getBindingFallbackValue({ channel }),
}),
];
}),
) as Record<string, AnimationComponentValue | undefined>;
return composeAnimationValue({
binding,
componentValues,
const fallbackValue = getChannelFallbackValue({ channel });
if (typeof fallbackValue === "number") {
return getChannelValueAtTime({
channel: isScalarChannel(channel) ? channel : undefined,
time,
fallbackValue,
});
}
return getChannelValueAtTime({
channel: !isScalarChannel(channel) ? channel : undefined,
time,
fallbackValue,
});
}
function getComposedChannelDataValueAtTime({
data,
time,
}: {
data: ChannelData | undefined;
time: number;
}) {
const entries = getChannelEntriesFromData({ data });
if (entries.length === 0) {
return null;
}
if (entries.length === 1 && entries[0]?.[0] === "value") {
return getChannelValue({ channel: entries[0][1], time });
}
const componentValues = Object.fromEntries(
entries.map(([componentKey, channel]) => [
componentKey,
getChannelValue({ channel, time }),
]),
);
if (
typeof componentValues.r === "number" &&
typeof componentValues.g === "number" &&
typeof componentValues.b === "number" &&
typeof componentValues.a === "number"
) {
return formatLinearRgba({
color: {
r: componentValues.r,
g: componentValues.g,
b: componentValues.b,
a: componentValues.a,
},
});
}
return null;
}
function getKeyframeInterpolation({
channel,
keyframe,
@@ -140,25 +172,22 @@ function getKeyframeInterpolation({
channel: AnimationChannel;
keyframe: AnimationChannel["keys"][number];
}) {
return channel.kind === "scalar" && "segmentToNext" in keyframe
return isScalarChannel(channel) && "segmentToNext" in keyframe
? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext })
: "hold";
}
function toElementKeyframe({
animations,
binding,
data,
propertyPath,
keyframeMatch,
}: {
animations: ElementAnimations;
binding: AnimationBindingInstance;
data: ChannelData | undefined;
propertyPath: AnimationPath;
keyframeMatch: BindingKeyframeMatch;
keyframeMatch: ChannelKeyframeMatch;
}): ElementKeyframe | null {
const value = getComposedBindingValueAtTime({
animations,
binding,
const value = getComposedChannelDataValueAtTime({
data,
time: keyframeMatch.keyframe.time,
});
if (value === null) {
@@ -186,19 +215,19 @@ export function getElementKeyframes({
return [];
}
return Object.entries(animations.bindings).flatMap(
([propertyPath, binding]) => {
if (!binding || !isAnimationPath(propertyPath)) {
return Object.entries(animations).filter(([key]) =>
isAnimationStorageKey({ key }),
).flatMap(
([propertyPath, data]) => {
if (!data || !isAnimationPath(propertyPath)) {
return [];
}
return getUniqueBindingKeyframeMatches({
animations,
binding,
return getUniqueChannelKeyframeMatches({
data,
}).flatMap((keyframeMatch) => {
const keyframe = toElementKeyframe({
animations,
binding,
data,
propertyPath,
keyframeMatch,
});
@@ -219,13 +248,8 @@ export function hasKeyframesForPath({
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
}): boolean {
const binding = animations?.bindings[propertyPath];
if (!binding) {
return false;
}
return binding.components.some((component) =>
Boolean(animations?.channels[component.channelId]?.keys.length),
return getChannelEntriesFromData({ data: animations?.[propertyPath] }).some(
([, channel]) => channel.keys.length > 0,
);
}
@@ -238,24 +262,22 @@ export function getKeyframeAtTime({
propertyPath: AnimationPath;
time: number;
}): ElementKeyframe | null {
const binding = animations?.bindings[propertyPath];
if (!binding) {
const data = animations?.[propertyPath];
if (!data) {
return null;
}
const keyframeMatch = getPreferredBindingKeyframeMatch({
matches: getBindingKeyframeMatches({
animations,
binding,
}).filter(({ keyframe }) => keyframe.time === time),
const keyframeMatch = getPreferredChannelKeyframeMatch({
matches: getChannelKeyframeMatches({ data }).filter(
({ keyframe }) => keyframe.time === time,
),
});
if (!keyframeMatch) {
return null;
}
return toElementKeyframe({
animations,
binding,
data,
propertyPath,
keyframeMatch,
});
@@ -270,24 +292,22 @@ export function getKeyframeById({
propertyPath: AnimationPath;
keyframeId: string;
}): ElementKeyframe | null {
const binding = animations?.bindings[propertyPath];
if (!binding) {
const data = animations?.[propertyPath];
if (!data) {
return null;
}
const keyframeMatch = getPreferredBindingKeyframeMatch({
matches: getBindingKeyframeMatches({
animations,
binding,
}).filter(({ keyframe }) => keyframe.id === keyframeId),
const keyframeMatch = getPreferredChannelKeyframeMatch({
matches: getChannelKeyframeMatches({ data }).filter(
({ keyframe }) => keyframe.id === keyframeId,
),
});
if (!keyframeMatch) {
return null;
}
return toElementKeyframe({
animations,
binding,
data,
propertyPath,
keyframeMatch,
});
File diff suppressed because it is too large Load Diff
-390
View File
@@ -1,390 +0,0 @@
import type {
AnimationBindingKind,
AnimationInterpolation,
AnimationPropertyPath,
AnimationValue,
} from "@/animation/types";
import { parseColorToLinearRgba } from "./binding-values";
import type { TimelineElement } from "@/timeline";
import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
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";
import { snapToStep } from "@/utils/math";
export interface NumericSpec {
min?: number;
max?: number;
step?: number;
}
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 isAnimationPropertyPath(
propertyPath: string,
): propertyPath is AnimationPropertyPath {
return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
}
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 });
}
+112 -38
View File
@@ -1,15 +1,13 @@
import type {
AnimationPath,
AnimationValueForPath,
ElementAnimations,
} from "@/animation/types";
import {
type AnimationComponentValue,
composeAnimationValue,
decomposeAnimationValue,
} from "./binding-values";
import { formatLinearRgba, parseColorToLinearRgba } from "@/params";
import type { ParamValue } from "@/params";
import { isCompositeChannelData, isLeafChannelData } from "./channel-data";
import {
getChannelValueAtTime,
isScalarChannel,
} from "./interpolation";
export function getElementLocalTime({
@@ -33,47 +31,123 @@ export function getElementLocalTime({
return localTime;
}
export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
export function resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime,
fallbackValue,
}: {
animations: ElementAnimations | undefined;
propertyPath: TPath;
propertyPath: AnimationPath;
localTime: number;
fallbackValue: AnimationValueForPath<TPath>;
}): AnimationValueForPath<TPath> {
const binding = animations?.bindings[propertyPath];
if (!binding) {
fallbackValue: number;
}): number;
export function resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime,
fallbackValue,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
localTime: number;
fallbackValue: string;
}): string;
export function resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime,
fallbackValue,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
localTime: number;
fallbackValue: boolean;
}): boolean;
export function resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime,
fallbackValue,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
localTime: number;
fallbackValue: ParamValue;
}): ParamValue;
export function resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime,
fallbackValue,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
localTime: number;
fallbackValue: ParamValue;
}): ParamValue {
const data = animations?.[propertyPath];
if (!data) {
return fallbackValue;
}
if (isLeafChannelData(data)) {
if (typeof fallbackValue === "number") {
return getChannelValueAtTime({
channel: isScalarChannel(data) ? data : undefined,
time: localTime,
fallbackValue,
});
}
if (typeof fallbackValue === "string" || typeof fallbackValue === "boolean") {
return getChannelValueAtTime({
channel: !isScalarChannel(data) ? data : undefined,
time: localTime,
fallbackValue,
});
}
return fallbackValue;
}
if (!isCompositeChannelData(data)) {
return fallbackValue;
}
const fallbackComponents = decomposeAnimationValue({
kind: binding.kind,
value: fallbackValue,
if (
typeof fallbackValue !== "string" ||
!("r" in data) ||
!("g" in data) ||
!("b" in data) ||
!("a" in data)
) {
return fallbackValue;
}
const fallbackComponents = parseColorToLinearRgba({ color: fallbackValue });
if (fallbackComponents === null) {
return fallbackValue;
}
return formatLinearRgba({
color: {
r: getChannelValueAtTime({
channel: data.r && isScalarChannel(data.r) ? data.r : undefined,
time: localTime,
fallbackValue: fallbackComponents.r,
}),
g: getChannelValueAtTime({
channel: data.g && isScalarChannel(data.g) ? data.g : undefined,
time: localTime,
fallbackValue: fallbackComponents.g,
}),
b: getChannelValueAtTime({
channel: data.b && isScalarChannel(data.b) ? data.b : undefined,
time: localTime,
fallbackValue: fallbackComponents.b,
}),
a: getChannelValueAtTime({
channel: data.a && isScalarChannel(data.a) ? data.a : undefined,
time: localTime,
fallbackValue: fallbackComponents.a,
}),
},
});
if (!fallbackComponents) {
return fallbackValue;
}
const componentValues = Object.fromEntries(
binding.components.map((component) => {
const channel = animations?.channels[component.channelId];
return [
component.key,
getChannelValueAtTime({
channel,
time: localTime,
fallbackValue:
fallbackComponents[component.key] ??
(channel?.kind === "discrete" ? false : 0),
}),
];
}),
) as Record<string, AnimationComponentValue | undefined>;
return (composeAnimationValue({
binding,
componentValues,
}) ?? fallbackValue) as AnimationValueForPath<TPath>;
}
+33 -105
View File
@@ -1,5 +1,5 @@
import type { ParamValues } from "@/params";
import type { MediaTime } from "@/wasm";
import type { ParamValue } from "@/params";
export const ANIMATION_PROPERTY_PATHS = [
"transform.positionX",
@@ -21,10 +21,7 @@ export const ANIMATION_PROPERTY_PATHS = [
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
export type GraphicParamPath = `params.${string}`;
export type EffectParamPath = `effects.${string}.params.${string}`;
export type AnimationPath =
| AnimationPropertyPath
| GraphicParamPath
| EffectParamPath;
export type AnimationPath = string;
export const ANIMATION_PROPERTY_GROUPS = {
"transform.scale": ["transform.scaleX", "transform.scaleY"],
@@ -32,44 +29,21 @@ export const ANIMATION_PROPERTY_GROUPS = {
export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
export type VectorValue = { x: number; y: number };
export type DiscreteValue = boolean | string;
export type AnimationValue = number | string | boolean | VectorValue;
export interface AnimationPropertyValueMap {
"transform.positionX": number;
"transform.positionY": number;
"transform.scaleX": number;
"transform.scaleY": number;
"transform.rotate": number;
opacity: number;
volume: number;
color: string;
"background.color": string;
"background.paddingX": number;
"background.paddingY": number;
"background.offsetX": number;
"background.offsetY": number;
"background.cornerRadius": number;
}
export type DynamicAnimationPathValue = number | string | boolean;
export interface NumericSpec {
min?: number;
max?: number;
step?: number;
}
export type AnimationValueForPath<TPath extends AnimationPath> =
TPath extends AnimationPropertyPath
? AnimationPropertyValueMap[TPath]
: TPath extends GraphicParamPath | EffectParamPath
? DynamicAnimationPathValue
: never;
export type AnimationNumericPropertyPath = {
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never;
}[AnimationPropertyPath];
export type AnimationColorPropertyPath = {
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends string ? K : never;
}[AnimationPropertyPath];
export type AnimationColorPropertyPath = Extract<
AnimationPropertyPath,
"color" | "background.color"
>;
export type AnimationNumericPropertyPath = Exclude<
AnimationPropertyPath,
AnimationColorPropertyPath
>;
export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
export type DiscreteKeyframeInterpolation = "hold";
@@ -77,8 +51,6 @@ export type AnimationInterpolation =
| ContinuousKeyframeInterpolation
| DiscreteKeyframeInterpolation;
export type PrimitiveAnimationChannelKind = "scalar" | "discrete";
export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete";
export type ScalarSegmentType = "step" | "linear" | "bezier";
export type TangentMode = "auto" | "aligned" | "broken" | "flat";
export type ChannelExtrapolationMode = "hold" | "linear";
@@ -88,7 +60,7 @@ export interface CurveHandle {
dv: number;
}
interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> {
interface BaseAnimationKeyframe<TValue extends ParamValue> {
id: string;
time: MediaTime; // relative to element start time
value: TValue;
@@ -101,13 +73,16 @@ export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> {
tangentMode: TangentMode;
}
export interface DiscreteAnimationKey
extends BaseAnimationKeyframe<DiscreteValue> {}
export type DiscreteAnimationKey = BaseAnimationKeyframe<DiscreteValue>;
export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey;
export type Keyframe<TValue extends ParamValue = ParamValue> =
TValue extends number
? ScalarAnimationKey
: TValue extends DiscreteValue
? DiscreteAnimationKey
: never;
export interface ScalarAnimationChannel {
kind: "scalar";
export interface ScalarChannel {
keys: ScalarAnimationKey[];
extrapolation?: {
before: ChannelExtrapolationMode;
@@ -115,72 +90,26 @@ export interface ScalarAnimationChannel {
};
}
export interface DiscreteAnimationChannel {
kind: "discrete";
export interface DiscreteChannel {
keys: DiscreteAnimationKey[];
}
export type AnimationChannel =
| ScalarAnimationChannel
| DiscreteAnimationChannel;
export type Channel<TValue extends ParamValue = ParamValue> =
TValue extends number
? ScalarChannel
: TValue extends DiscreteValue
? DiscreteChannel
: never;
export type ElementAnimationChannelMap = Record<
string,
AnimationChannel | undefined
>;
export type ScalarAnimationChannel = Channel<number>;
export type DiscreteAnimationChannel = Channel<DiscreteValue>;
export type AnimationChannel = Channel;
export interface AnimationBindingComponent<TKey extends string = string> {
key: TKey;
channelId: string;
}
interface BaseAnimationBinding<
TKind extends AnimationBindingKind,
TComponentKey extends string,
> {
path: AnimationPath;
kind: TKind;
components: AnimationBindingComponent<TComponentKey>[];
}
export interface NumberAnimationBinding
extends BaseAnimationBinding<"number", "value"> {}
export interface Vector2AnimationBinding
extends BaseAnimationBinding<"vector2", "x" | "y"> {}
export interface ColorAnimationBinding
extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> {
colorSpace: "srgb-linear";
}
export interface DiscreteAnimationBinding
extends BaseAnimationBinding<"discrete", "value"> {}
export type AnimationBindingInstance =
| NumberAnimationBinding
| Vector2AnimationBinding
| ColorAnimationBinding
| DiscreteAnimationBinding;
export interface AnimationBindingByKind {
number: NumberAnimationBinding;
vector2: Vector2AnimationBinding;
color: ColorAnimationBinding;
discrete: DiscreteAnimationBinding;
}
export type AnimationBindingOfKind<TKind extends AnimationBindingKind> =
AnimationBindingByKind[TKind];
export type ElementAnimationBindingMap = Record<
string,
AnimationBindingInstance | undefined
>;
export type CompositeChannelData = Record<string, AnimationChannel | undefined>;
export type ChannelData = AnimationChannel | CompositeChannelData;
export interface ElementAnimations {
bindings: ElementAnimationBindingMap;
channels: ElementAnimationChannelMap;
[propertyPath: AnimationPath]: ChannelData | undefined;
}
export type NormalizedCubicBezier = [number, number, number, number];
@@ -188,7 +117,6 @@ export type NormalizedCubicBezier = [number, number, number, number];
export interface ScalarGraphChannelTarget {
propertyPath: AnimationPath;
componentKey: string;
channelId: string;
}
export interface ScalarGraphChannel extends ScalarGraphChannelTarget {
@@ -217,7 +145,7 @@ export interface ElementKeyframe {
propertyPath: AnimationPath;
id: string;
time: MediaTime;
value: AnimationValue;
value: ParamValue;
interpolation: AnimationInterpolation;
}
+1 -1
View File
@@ -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&apos;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.",
+14 -5
View File
@@ -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
+7 -4
View File
@@ -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&apos;t sell or share your data with anyone (we don&apos;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)
+16 -12
View File
@@ -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&apos;re responsible for how you use it - don&apos;t break
the law
</li>
<li>
Service provided &quot;as is&quot; - we can&apos;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&apos;re responsible for how you use OpenCut and the content you
create. Don&apos;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.
&quot;as is&quot; without warranties. While we strive for
reliability, we can&apos;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&apos;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&apos;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&apos;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)
+1 -1
View File
@@ -35,7 +35,7 @@ export const ElementsClipboardHandler = {
};
},
paste(entry, context) {
paste({ entry, context }) {
if (entry.items.length === 0) {
return null;
}
+1 -1
View File
@@ -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 });
}
+11 -7
View File
@@ -1,4 +1,6 @@
import { getKeyframeById } from "@/animation";
import { getChannelEntriesFromData } from "@/animation/channel-data";
import { isScalarChannel } from "@/animation/interpolation";
import type { SelectedKeyframeRef } from "@/animation/types";
import type { TimelineElement } from "@/timeline";
import { PasteKeyframesCommand } from "@/commands/timeline";
@@ -41,14 +43,13 @@ function getCurvePatches({
propertyPath: KeyframeClipboardItem["propertyPath"];
keyframeId: string;
}): KeyframeClipboardCurvePatch[] {
const binding = element.animations?.bindings[propertyPath];
if (!binding) {
const data = element.animations?.[propertyPath];
if (!data) {
return [];
}
return binding.components.flatMap((component) => {
const channel = element.animations?.channels[component.channelId];
if (channel?.kind !== "scalar") {
return getChannelEntriesFromData({ data }).flatMap(([componentKey, channel]) => {
if (!channel || !isScalarChannel(channel)) {
return [];
}
@@ -61,7 +62,7 @@ function getCurvePatches({
return [
{
componentKey: component.key,
componentKey,
patch: {
leftHandle: keyframe.leftHandle ?? null,
rightHandle: keyframe.rightHandle ?? null,
@@ -156,7 +157,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;
+6 -6
View File
@@ -2,10 +2,10 @@ import type { EditorCore } from "@/core";
import type {
AnimationInterpolation,
AnimationPath,
AnimationValue,
ScalarCurveKeyframePatch,
SelectedKeyframeRef,
} from "@/animation/types";
import type { ParamValue } from "@/params";
import type { Command } from "@/commands/base-command";
import type {
CreateTimelineElement,
@@ -28,7 +28,7 @@ export interface KeyframeClipboardCurvePatch {
export interface KeyframeClipboardItem {
propertyPath: AnimationPath;
timeOffset: MediaTime;
value: AnimationValue;
value: ParamValue;
interpolation: AnimationInterpolation;
curvePatches: KeyframeClipboardCurvePatch[];
}
@@ -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 = {
+20 -5
View File
@@ -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(),
@@ -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();
+12 -4
View File
@@ -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()];
+12 -4
View File
@@ -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();
@@ -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();
+12 -4
View File
@@ -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();
@@ -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();
+12 -4
View File
@@ -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();
@@ -52,8 +52,7 @@ function pasteKeyframesIntoElement({
value: item.value,
interpolation: item.interpolation,
keyframeId: generateUUID(),
kind: target.kind,
defaultInterpolation: target.defaultInterpolation,
channelLayout: target.channelLayout,
coerceValue: target.coerceValue,
});
const pastedKeyframe = getKeyframeAtTime({
@@ -189,7 +189,7 @@ export class InsertElementCommand extends Command {
}
}
if (element.type === "text" && !element.content) {
if (element.type === "text" && !element.params.content) {
console.error("Text element must have content");
return false;
}
@@ -5,7 +5,8 @@ import {
} from "@/animation";
import { Command, type CommandResult } from "@/commands/base-command";
import { updateElementInSceneTracks } from "@/timeline";
import type { AnimationPath, AnimationValue } from "@/animation/types";
import type { AnimationPath } from "@/animation/types";
import type { ParamValue } from "@/params";
import type { SceneTracks, TimelineElement } from "@/timeline";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
@@ -18,7 +19,7 @@ function removeKeyframeAndPersist({
element: TimelineElement;
propertyPath: AnimationPath;
keyframeId: string;
valueAtPlayhead: AnimationValue | null;
valueAtPlayhead: ParamValue | null;
}): TimelineElement {
const target = resolveAnimationTarget({ element, path: propertyPath });
if (!target) {
@@ -52,7 +53,7 @@ export class RemoveKeyframeCommand extends Command {
private readonly elementId: string;
private readonly propertyPath: AnimationPath;
private readonly keyframeId: string;
private readonly valueAtPlayhead: AnimationValue | null;
private readonly valueAtPlayhead: ParamValue | null;
constructor({
trackId,
@@ -65,7 +66,7 @@ export class RemoveKeyframeCommand extends Command {
elementId: string;
propertyPath: AnimationPath;
keyframeId: string;
valueAtPlayhead: AnimationValue | null;
valueAtPlayhead: ParamValue | null;
}) {
super();
this.trackId = trackId;
@@ -90,8 +90,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
value: this.value,
interpolation: this.interpolation,
keyframeId: this.keyframeId,
kind: target.kind,
defaultInterpolation: target.defaultInterpolation,
channelLayout: target.channelLayout,
coerceValue: target.coerceValue,
});
return { ...element, animations };
@@ -7,8 +7,8 @@ import { resolveAnimationTarget } from "@/timeline/animation-targets";
import type {
AnimationPath,
AnimationInterpolation,
AnimationValue,
} from "@/animation/types";
import type { ParamValue } from "@/params";
import {
type MediaTime,
maxMediaTime,
@@ -22,7 +22,7 @@ export class UpsertKeyframeCommand extends Command {
private readonly elementId: string;
private readonly propertyPath: AnimationPath;
private readonly time: MediaTime;
private readonly value: AnimationValue;
private readonly value: ParamValue;
private readonly interpolation: AnimationInterpolation | undefined;
private readonly keyframeId: string | undefined;
@@ -39,7 +39,7 @@ export class UpsertKeyframeCommand extends Command {
elementId: string;
propertyPath: AnimationPath;
time: MediaTime;
value: AnimationValue;
value: ParamValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
}) {
@@ -83,8 +83,7 @@ export class UpsertKeyframeCommand extends Command {
value: this.value,
interpolation: this.interpolation,
keyframeId: this.keyframeId,
kind: target.kind,
defaultInterpolation: target.defaultInterpolation,
channelLayout: target.channelLayout,
coerceValue: target.coerceValue,
}),
};
@@ -1,22 +1,22 @@
import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/commands/base-command";
import {
getCustomMaskClosedStateAfterPointRemoval,
removeCustomMaskPoints,
} from "@/masks/custom-path";
import type { CustomMask } from "@/masks/types";
getFreeformPathClosedStateAfterPointRemoval,
removeFreeformPathPoints,
} from "@/masks/freeform/path";
import type { FreeformPathMask } from "@/masks/types";
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
import type { MaskableElement, SceneTracks } from "@/timeline";
function deletePointsFromCustomMask({
function deletePointsFromFreeformPathMask({
mask,
pointIds,
}: {
mask: CustomMask;
mask: FreeformPathMask;
pointIds: string[];
}): CustomMask {
}): FreeformPathMask {
const points = mask.params.path;
const nextPoints = removeCustomMaskPoints({ points, pointIds });
const nextPoints = removeFreeformPathPoints({ points, pointIds });
if (nextPoints.length === points.length) {
return mask;
}
@@ -26,7 +26,7 @@ function deletePointsFromCustomMask({
params: {
...mask.params,
path: nextPoints,
closed: getCustomMaskClosedStateAfterPointRemoval({
closed: getFreeformPathClosedStateAfterPointRemoval({
wasClosed: mask.params.closed,
remainingPointCount: nextPoints.length,
}),
@@ -46,11 +46,11 @@ function deletePointsFromElementMask({
const currentMasks = element.masks ?? [];
let didDeletePoints = false;
const nextMasks = currentMasks.map((mask) => {
if (mask.id !== maskId || mask.type !== "custom") {
if (mask.id !== maskId || mask.type !== "freeform") {
return mask;
}
const nextMask = deletePointsFromCustomMask({
const nextMask = deletePointsFromFreeformPathMask({
mask,
pointIds,
});
@@ -64,7 +64,7 @@ function deletePointsFromElementMask({
};
}
export class DeleteCustomMaskPointsCommand extends Command {
export class DeleteFreeformPathMaskPointsCommand extends Command {
private savedState: SceneTracks | null = null;
private readonly trackId: string;
private readonly elementId: string;
@@ -100,8 +100,9 @@ export class DeleteCustomMaskPointsCommand extends Command {
elementId: this.elementId,
elementPredicate: isMaskableElement,
update: (element) => {
if (!isMaskableElement(element)) return element;
const result = deletePointsFromElementMask({
element: element as MaskableElement,
element,
maskId: this.maskId,
pointIds: this.pointIds,
});
@@ -1,4 +1,4 @@
export { DeleteCustomMaskPointsCommand } from "./delete-custom-mask-points";
export { InsertCustomMaskPointCommand } from "./insert-custom-mask-point";
export { DeleteFreeformPathMaskPointsCommand } from "./delete-custom-mask-points";
export { InsertFreeformPathMaskPointCommand } from "./insert-custom-mask-point";
export { RemoveMaskCommand } from "./remove-mask";
export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";
@@ -1,23 +1,23 @@
import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/commands/base-command";
import { insertPointOnCustomMaskSegment } from "@/masks/definitions/custom";
import { insertPointOnFreeformSegment } from "@/masks/freeform/definition";
import type { ElementBounds } from "@/preview/element-bounds";
import type { CustomMask } from "@/masks/types";
import type { FreeformPathMask } from "@/masks/types";
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
import type { MaskableElement, SceneTracks } from "@/timeline";
function insertPointIntoCustomMask({
function insertPointIntoFreeformPathMask({
mask,
segmentIndex,
canvasPoint,
bounds,
}: {
mask: CustomMask;
mask: FreeformPathMask;
segmentIndex: number;
canvasPoint: { x: number; y: number };
bounds: ElementBounds;
}): { mask: CustomMask; insertedPointId: string | null } {
const result = insertPointOnCustomMaskSegment({
}): { mask: FreeformPathMask; insertedPointId: string | null } {
const result = insertPointOnFreeformSegment({
params: mask.params,
segmentIndex,
canvasPoint,
@@ -61,11 +61,11 @@ function insertPointIntoElementMask({
let didInsertPoint = false;
const nextMasks = currentMasks.map((mask) => {
if (mask.id !== maskId || mask.type !== "custom") {
if (mask.id !== maskId || mask.type !== "freeform") {
return mask;
}
const result = insertPointIntoCustomMask({
const result = insertPointIntoFreeformPathMask({
mask,
segmentIndex,
canvasPoint,
@@ -85,7 +85,7 @@ function insertPointIntoElementMask({
};
}
export class InsertCustomMaskPointCommand extends Command {
export class InsertFreeformPathMaskPointCommand extends Command {
private savedState: SceneTracks | null = null;
private readonly trackId: string;
private readonly elementId: string;
@@ -130,8 +130,9 @@ export class InsertCustomMaskPointCommand extends Command {
elementId: this.elementId,
elementPredicate: isMaskableElement,
update: (element) => {
if (!isMaskableElement(element)) return element;
const result = insertPointIntoElementMask({
element: element as MaskableElement,
element,
maskId: this.maskId,
segmentIndex: this.segmentIndex,
canvasPoint: this.canvasPoint,
@@ -45,11 +45,13 @@ export class RemoveMaskCommand extends Command {
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isMaskableElement,
update: (element) =>
removeMaskFromElement({
element: element as MaskableElement,
update: (element) => {
if (!isMaskableElement(element)) return element;
return removeMaskFromElement({
element,
maskId: this.maskId,
}),
});
},
});
editor.timeline.updateTracks(updatedTracks);
@@ -56,11 +56,13 @@ export class ToggleMaskInvertedCommand extends Command {
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isMaskableElement,
update: (element) =>
toggleMaskInvertedOnElement({
element: element as MaskableElement,
update: (element) => {
if (!isMaskableElement(element)) return element;
return toggleMaskInvertedOnElement({
element,
maskId: this.maskId,
}),
});
},
});
editor.timeline.updateTracks(updatedTracks);
@@ -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;
@@ -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;
@@ -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 }),
}),
{
@@ -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" });
}
};
@@ -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>
@@ -0,0 +1,154 @@
"use client";
import { resolveAnimationPathValueAtTime } from "@/animation";
import { Section, SectionContent, SectionFields } from "@/components/section";
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
import { useKeyframedParamProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
import type { ParamValue, ParamValues } from "@/params";
import {
getElementParams,
readElementParamValue,
writeElementParamValue,
type ElementParamDefinition,
} from "@/params/registry";
import type { TimelineElement } from "@/timeline";
import type { MediaTime } from "@/wasm";
export function ElementParamsTab({
element,
trackId,
paramKeys,
sectionKey,
}: {
element: TimelineElement;
trackId: string;
paramKeys?: readonly string[];
sectionKey: string;
}) {
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
});
const params = getElementParams({ element }).filter(
(param) => !paramKeys || paramKeys.includes(param.key),
);
const baseValues = buildValues({ element, params });
return (
<Section sectionKey={`${element.id}:${sectionKey}`}>
<SectionContent className="pt-4">
<SectionFields>
{params
.filter((param) => isVisible({ param, values: baseValues }))
.map((param) => (
<ElementParamField
key={param.key}
element={element}
trackId={trackId}
param={param}
baseValue={baseValues[param.key] ?? param.default}
localTime={localTime}
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
/>
))}
</SectionFields>
</SectionContent>
</Section>
);
}
function ElementParamField({
element,
trackId,
param,
baseValue,
localTime,
isPlayheadWithinElementRange,
}: {
element: TimelineElement;
trackId: string;
param: ElementParamDefinition;
baseValue: ParamValue;
localTime: MediaTime;
isPlayheadWithinElementRange: boolean;
}) {
const resolvedValue = resolveAnimationPathValueAtTime({
animations: element.animations,
propertyPath: param.key,
localTime,
fallbackValue: baseValue,
});
const animatedParam = useKeyframedParamProperty({
param,
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: param.key,
localTime,
isPlayheadWithinElementRange,
resolvedValue,
buildBaseUpdates: ({ value }) =>
writeElementParamValue({ element, param, value }),
});
return (
<PropertyParamField
param={param}
value={resolvedValue}
onPreview={animatedParam.onPreview}
onCommit={animatedParam.onCommit}
keyframe={
param.keyframable === false
? undefined
: {
isActive: animatedParam.isKeyframedAtTime,
isDisabled: !isPlayheadWithinElementRange,
onToggle: animatedParam.toggleKeyframe,
}
}
/>
);
}
function buildValues({
element,
params,
}: {
element: TimelineElement;
params: readonly ElementParamDefinition[];
}): ParamValues {
const values: ParamValues = {};
for (const param of params) {
const value = readElementParamValue({ element, param });
if (value !== null) {
values[param.key] = value;
}
}
return values;
}
function isVisible({
param,
values,
}: {
param: ElementParamDefinition;
values: ParamValues;
}): boolean {
return (param.dependencies ?? []).every((dependency) =>
areParamValuesEqual({
left: values[dependency.param],
right: dependency.equals,
}),
);
}
function areParamValuesEqual({
left,
right,
}: {
left: ParamValue | undefined;
right: ParamValue;
}): boolean {
return left === right;
}
@@ -1,6 +1,10 @@
"use client";
import type { ParamDefinition, NumberParamDefinition } from "@/params";
import type {
ParamDefinition,
NumberParamDefinition,
ParamValue,
} from "@/params";
import {
formatNumberForDisplay,
getFractionDigitsForStep,
@@ -19,6 +23,7 @@ import {
} from "@/components/ui/select";
import { usePropertyDraft } from "../hooks/use-property-draft";
import { KeyframeToggle } from "./keyframe-toggle";
import { Textarea } from "@/components/ui/textarea";
export function PropertyParamField({
param,
@@ -28,8 +33,8 @@ export function PropertyParamField({
keyframe,
}: {
param: ParamDefinition;
value: number | string | boolean;
onPreview: (value: number | string | boolean) => void;
value: ParamValue;
onPreview: (value: ParamValue) => void;
onCommit: () => void;
keyframe?: {
isActive: boolean;
@@ -41,7 +46,7 @@ export function PropertyParamField({
<SectionField
label={param.label}
beforeLabel={
keyframe ? (
keyframe && param.keyframable !== false ? (
<KeyframeToggle
isActive={keyframe.isActive}
isDisabled={keyframe.isDisabled}
@@ -68,8 +73,8 @@ function ParamInput({
onCommit,
}: {
param: ParamDefinition;
value: number | string | boolean;
onPreview: (value: number | string | boolean) => void;
value: ParamValue;
onPreview: (value: ParamValue) => void;
onCommit: () => void;
}) {
if (param.type === "number") {
@@ -131,6 +136,27 @@ function ParamInput({
);
}
if (param.type === "text") {
return (
<Textarea
value={String(value)}
onChange={(event) => onPreview(event.currentTarget.value)}
onBlur={onCommit}
/>
);
}
if (param.type === "font") {
return (
<input
className="border-input bg-accent h-9 w-full rounded-md border px-3 text-sm outline-none"
value={String(value)}
onChange={(event) => onPreview(event.currentTarget.value)}
onBlur={onCommit}
/>
);
}
return null;
}
@@ -1,96 +0,0 @@
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,
};
}
@@ -1,175 +0,0 @@
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,
};
}
@@ -8,14 +8,14 @@ import {
upsertPathKeyframe,
} from "@/animation";
import type {
AnimationPath,
ElementAnimations,
} from "@/animation/types";
import {
coerceAnimationParamValue,
getAnimationParamDefaultInterpolation,
getAnimationParamValueKind,
} from "@/animation/animated-params";
import type { ParamDefinition } from "@/params";
coerceParamValue,
getParamChannelLayout,
type ParamDefinition,
} from "@/params";
import type { TimelineElement } from "@/timeline";
import type { MediaTime } from "@/wasm";
@@ -33,6 +33,7 @@ export function useKeyframedParamProperty({
trackId,
elementId,
animations,
propertyPath,
localTime,
isPlayheadWithinElementRange,
resolvedValue,
@@ -42,6 +43,7 @@ export function useKeyframedParamProperty({
trackId: string;
elementId: string;
animations: ElementAnimations | undefined;
propertyPath?: AnimationPath;
localTime: MediaTime;
isPlayheadWithinElementRange: boolean;
resolvedValue: number | string | boolean;
@@ -52,15 +54,16 @@ export function useKeyframedParamProperty({
}) => Partial<TimelineElement>;
}): KeyframedParamPropertyResult {
const editor = useEditor();
const propertyPath = buildGraphicParamPath({ paramKey: param.key });
const resolvedPropertyPath =
propertyPath ?? buildGraphicParamPath({ paramKey: param.key });
const hasAnimatedKeyframes = hasKeyframesForPath({
animations,
propertyPath,
propertyPath: resolvedPropertyPath,
});
const keyframeAtTime = isPlayheadWithinElementRange
? getKeyframeAtTime({
animations,
propertyPath,
propertyPath: resolvedPropertyPath,
time: localTime,
})
: null;
@@ -79,15 +82,12 @@ export function useKeyframedParamProperty({
updates: {
animations: upsertPathKeyframe({
animations,
propertyPath,
propertyPath: resolvedPropertyPath,
time: localTime,
value,
kind: getAnimationParamValueKind({ param }),
defaultInterpolation: getAnimationParamDefaultInterpolation({
param,
}),
channelLayout: getParamChannelLayout({ param }),
coerceValue: ({ value: nextValue }) =>
coerceAnimationParamValue({
coerceParamValue({
param,
value: nextValue,
}),
@@ -121,7 +121,7 @@ export function useKeyframedParamProperty({
{
trackId,
elementId,
propertyPath,
propertyPath: resolvedPropertyPath,
keyframeId: keyframeIdAtTime,
},
],
@@ -134,7 +134,7 @@ export function useKeyframedParamProperty({
{
trackId,
elementId,
propertyPath,
propertyPath: resolvedPropertyPath,
time: localTime,
value: resolvedValue,
},
@@ -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("");
},
};
}
@@ -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",
@@ -22,16 +22,43 @@ import {
MagicWand05Icon,
DashboardSpeed02Icon,
} from "@hugeicons/core-free-icons";
import { TransformTab } from "@/rendering/components/transform-tab";
import { BlendingTab } from "@/rendering/components/blending-tab";
import { AudioTab } from "@/timeline/components/audio-tab";
import { TextTab } from "@/text/components/text-tab";
import { ElementParamsTab } from "./components/element-params-tab";
import { ClipEffectsTab, StandaloneEffectTab } from "@/effects/components/effects-tab";
import { MasksTab } from "@/masks/components/masks-tab";
import { SpeedTab } from "@/speed/components/speed-tab";
import { GraphicTab } from "@/graphics/components/graphic-tab";
import { OcShapesIcon } from "@/components/icons";
const TRANSFORM_PARAM_KEYS = [
"transform.positionX",
"transform.positionY",
"transform.scaleX",
"transform.scaleY",
"transform.rotate",
] as const;
const BLENDING_PARAM_KEYS = ["opacity", "blendMode"] as const;
const AUDIO_PARAM_KEYS = ["volume", "muted"] as const;
const TEXT_PARAM_KEYS = [
"content",
"fontFamily",
"fontSize",
"color",
"textAlign",
"fontWeight",
"fontStyle",
"textDecoration",
"letterSpacing",
"lineHeight",
"background.enabled",
"background.color",
"background.cornerRadius",
"background.paddingX",
"background.paddingY",
"background.offsetX",
"background.offsetY",
] as const;
export type TabContentProps = {
trackId: string;
};
@@ -58,7 +85,12 @@ function buildTransformTab({
label: "Transform",
icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
content: ({ trackId }) => (
<TransformTab element={element} trackId={trackId} />
<ElementParamsTab
element={element}
trackId={trackId}
paramKeys={TRANSFORM_PARAM_KEYS}
sectionKey="transform"
/>
),
};
}
@@ -73,7 +105,12 @@ function buildBlendingTab({
label: "Blending",
icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
content: ({ trackId }) => (
<BlendingTab element={element} trackId={trackId} />
<ElementParamsTab
element={element}
trackId={trackId}
paramKeys={BLENDING_PARAM_KEYS}
sectionKey="blending"
/>
),
};
}
@@ -87,7 +124,14 @@ function buildAudioTab({
id: "audio",
label: "Audio",
icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
content: ({ trackId }) => (
<ElementParamsTab
element={element}
trackId={trackId}
paramKeys={AUDIO_PARAM_KEYS}
sectionKey="audio"
/>
),
};
}
@@ -137,7 +181,14 @@ function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
id: "text",
label: "Text",
icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
content: ({ trackId }) => (
<ElementParamsTab
element={element}
trackId={trackId}
paramKeys={TEXT_PARAM_KEYS}
sectionKey="text"
/>
),
};
}
@@ -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 }),
}));
+1 -1
View File
@@ -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);
+10 -2
View File
@@ -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 });
+9 -4
View File
@@ -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;
+7 -4
View File
@@ -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 });
}
+18 -17
View File
@@ -16,13 +16,13 @@ import {
canElementBeHidden,
canElementHaveAudio,
} from "@/timeline/element-utils";
import { isElementMuted } from "@/timeline/audio-state";
import type {
AnimationPath,
AnimationInterpolation,
AnimationValue,
AnimationValueForPath,
ScalarCurveKeyframePatch,
} from "@/animation/types";
import type { ParamValue } from "@/params";
import {
getElementLocalTime,
resolveAnimationPathValueAtTime,
@@ -46,8 +46,8 @@ import {
RetimeKeyframeCommand,
UpdateScalarKeyframeCurveCommand,
AddClipEffectCommand,
DeleteCustomMaskPointsCommand,
InsertCustomMaskPointCommand,
DeleteFreeformPathMaskPointsCommand,
InsertFreeformPathMaskPointCommand,
RemoveClipEffectCommand,
UpdateClipEffectParamsCommand,
ToggleClipEffectCommand,
@@ -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();
}
@@ -349,7 +349,7 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
deleteCustomMaskPoints({
deleteFreeformPathMaskPoints({
trackId,
elementId,
maskId,
@@ -363,7 +363,7 @@ export class TimelineManager {
if (pointIds.length === 0) {
return;
}
const command = new DeleteCustomMaskPointsCommand({
const command = new DeleteFreeformPathMaskPointsCommand({
trackId,
elementId,
maskId,
@@ -372,7 +372,7 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
insertCustomMaskPoint({
insertFreeformPathMaskPoint({
trackId,
elementId,
maskId,
@@ -387,7 +387,7 @@ export class TimelineManager {
canvasPoint: { x: number; y: number };
bounds: ElementBounds;
}): void {
const command = new InsertCustomMaskPointCommand({
const command = new InsertFreeformPathMaskPointCommand({
trackId,
elementId,
maskId,
@@ -486,7 +486,7 @@ export class TimelineManager {
elementId: string;
propertyPath: AnimationPath;
time: MediaTime;
value: AnimationValue;
value: ParamValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
}>;
@@ -537,7 +537,7 @@ export class TimelineManager {
// Pre-sample values at playhead for each (element, property) pair.
// This preserves "what you see is what you get" when all keyframes are deleted.
const playheadTime = this.editor.playback.getCurrentTime();
const valueAtPlayheadMap = new Map<string, AnimationValue | null>();
const valueAtPlayheadMap = new Map<string, ParamValue | null>();
for (const { trackId, elementId, propertyPath } of keyframes) {
const key = `${elementId}:${propertyPath}`;
@@ -558,9 +558,7 @@ export class TimelineManager {
});
const target = resolveAnimationTarget({ element, path: propertyPath });
const baseValue =
(target?.getBaseValue() as AnimationValueForPath<AnimationPath> | null) ??
null;
const baseValue = target?.getBaseValue() ?? null;
if (baseValue === null) {
valueAtPlayheadMap.set(key, null);
continue;
@@ -751,7 +749,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;
@@ -840,7 +841,7 @@ export class TimelineManager {
}): void {
const shouldMute = elements.some(({ trackId, elementId }) => {
const element = this.getElementByRef({ trackId, elementId });
return element && canElementHaveAudio(element) && !element.muted;
return element && canElementHaveAudio(element) && !isElementMuted({ element });
});
const nextUpdates = elements.flatMap(({ trackId, elementId }) => {
@@ -853,7 +854,7 @@ export class TimelineManager {
{
trackId,
elementId,
patch: { muted: shouldMute },
patch: { params: { muted: shouldMute } },
},
];
});
+2 -2
View File
@@ -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,
+26 -10
View File
@@ -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;
);
}
+4 -1
View File
@@ -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,
});
}
}
+12 -3
View File
@@ -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) {
+239 -239
View File
@@ -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,
}}
/>
);
}
+4 -1
View File
@@ -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,
});
}
}
+155 -149
View File
@@ -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";
+11
View File
@@ -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;
}
+9 -6
View File
@@ -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 };
}
+83 -47
View File
@@ -1,22 +1,22 @@
import { describe, expect, test } from "bun:test";
import {
findClosestPointOnCustomMaskSegment,
getCustomMaskClosedStateAfterPointRemoval,
insertPointIntoCustomMaskSegment,
removeCustomMaskPoints,
} from "@/masks/custom-path";
findClosestPointOnFreeformSegment,
getFreeformPathClosedStateAfterPointRemoval,
insertPointIntoFreeformSegment,
removeFreeformPathPoints,
} from "@/masks/freeform/path";
import {
appendPointToCustomMask,
customMaskDefinition,
insertPointOnCustomMaskSegment,
} from "@/masks/definitions/custom";
import { getSplitMaskStrokeSegment } from "@/masks/definitions/split";
import { textMaskDefinition } from "@/masks/definitions/text";
appendPointToFreeformPathMask,
freeformMaskDefinition,
insertPointOnFreeformSegment,
} from "@/masks/freeform/definition";
import { getSplitMaskStrokeSegment } from "@/masks/builtin/definitions/split";
import { textMaskDefinition } from "@/masks/builtin/definitions/text";
import { getMaskSnapGeometry } from "@/masks/geometry";
import { snapMaskInteraction } from "@/masks/snap";
import { snapBoxMaskInteraction, snapSplitMaskInteraction } from "@/masks/snap";
import type { ElementBounds } from "@/preview/element-bounds";
import type {
CustomMaskParams,
FreeformPathMaskParams,
RectangleMaskParams,
SplitMaskParams,
TextMaskParams,
@@ -100,9 +100,9 @@ function buildTextMaskParams(
};
}
function buildCustomMaskParams(
overrides: Partial<CustomMaskParams> = {},
): CustomMaskParams {
function buildFreeformPathMaskParams(
overrides: Partial<FreeformPathMaskParams> = {},
): FreeformPathMaskParams {
return {
feather: 0,
inverted: false,
@@ -150,10 +150,10 @@ function buildCustomMaskParams(
function sortSegment(
segment: [{ x: number; y: number }, { x: number; y: number }],
): [{ x: number; y: number }, { x: number; y: number }] {
return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [
{ x: number; y: number },
{ x: number; y: number },
];
const [first, second] = segment;
return first.x < second.x || (first.x === second.x && first.y <= second.y)
? [first, second]
: [second, first];
}
describe("mask geometry", () => {
@@ -230,8 +230,8 @@ describe("mask geometry", () => {
describe("mask snapping", () => {
test("snaps split mask movement using the shared position pipeline", () => {
const result = snapMaskInteraction({
handleId: "position",
const result = snapSplitMaskInteraction({
handleId: { kind: "position" },
startParams: buildSplitParams({
centerX: 0.03,
centerY: -0.04,
@@ -254,8 +254,8 @@ describe("mask snapping", () => {
});
test("snaps box mask movement against element center and edges", () => {
const result = snapMaskInteraction({
handleId: "position",
const result = snapBoxMaskInteraction({
handleId: { kind: "position" },
startParams: buildRectangleParams(),
proposedParams: buildRectangleParams({
centerX: 0.29,
@@ -275,8 +275,8 @@ describe("mask snapping", () => {
});
test("snaps mask rotation through the shared rotation path", () => {
const result = snapMaskInteraction({
handleId: "rotation",
const result = snapBoxMaskInteraction({
handleId: { kind: "rotation" },
startParams: buildRectangleParams(),
proposedParams: buildRectangleParams({
rotation: 88,
@@ -291,8 +291,8 @@ describe("mask snapping", () => {
});
test("snaps edge resize for box masks", () => {
const result = snapMaskInteraction({
handleId: "right",
const result = snapBoxMaskInteraction({
handleId: { kind: "edge", side: "right" },
startParams: buildRectangleParams(),
proposedParams: buildRectangleParams({
width: 0.98,
@@ -307,8 +307,8 @@ describe("mask snapping", () => {
});
test("snaps corner resize for box masks", () => {
const result = snapMaskInteraction({
handleId: "bottom-right",
const result = snapBoxMaskInteraction({
handleId: { kind: "corner", corner: { x: "right", y: "bottom" } },
startParams: buildRectangleParams(),
proposedParams: buildRectangleParams({
width: 0.99,
@@ -324,13 +324,49 @@ describe("mask snapping", () => {
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
});
test("snaps vertical edge resize for box masks", () => {
// bounds.height=100 → localCanvasSize.height/2=50 is the bottom snap target.
// height=0.2 → baseHeight=20 → aabbHalfH=10.
// At height=0.98 → proposedScaleY=4.9 → bottomEdge=0+10*4.9=49.
// |49-50|=1 < threshold(8) → snaps to height=0.2*5=1.0; line at position 50.
const result = snapBoxMaskInteraction({
handleId: { kind: "edge", side: "bottom" },
startParams: buildRectangleParams(),
proposedParams: buildRectangleParams({ height: 0.98 }),
bounds,
canvasSize,
snapThreshold,
});
expect(result.params.height).toBe(1);
expect(result.activeLines).toEqual([{ type: "horizontal", position: 50 }]);
});
test("snaps uniform scale handle for box masks", () => {
// bounds.width=200 → localCanvasSize.width/2=100 is the right snap target.
// width=0.4, scale=1 → aabbHalfW = (0.4*200)/2 * 1 = 40.
// At scale=2.48 → rightEdge=0+40*2.48=99.2; |99.2-100|=0.8 < threshold(8)
// → snaps to scale=1*(100/40)=2.5; line at position 100.
const result = snapBoxMaskInteraction({
handleId: { kind: "scale" },
startParams: buildRectangleParams({ scale: 1 }),
proposedParams: buildRectangleParams({ scale: 2.48 }),
bounds,
canvasSize,
snapThreshold,
});
expect(result.params.scale).toBe(2.5);
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
});
test("snaps text mask movement using intrinsic text bounds", () => {
const params = buildTextMaskParams({
centerX: 0.03,
centerY: -0.04,
});
const result = textMaskDefinition.interaction.snap?.({
handleId: "position",
handleId: { kind: "position" },
startParams: params,
proposedParams: params,
bounds,
@@ -347,12 +383,12 @@ describe("mask snapping", () => {
});
test("snaps custom mask movement using path geometry bounds", () => {
const params = buildCustomMaskParams({
const params = buildFreeformPathMaskParams({
centerX: 0.03,
centerY: -0.04,
});
const result = customMaskDefinition.interaction.snap?.({
handleId: "position",
const result = freeformMaskDefinition.interaction.snap?.({
handleId: { kind: "position" },
startParams: params,
proposedParams: params,
bounds,
@@ -381,11 +417,11 @@ describe("mask snapping", () => {
describe("custom mask creation", () => {
test("anchors the first point at the click position", () => {
const params = buildCustomMaskParams({
const params = buildFreeformPathMaskParams({
path: [],
closed: false,
});
const next = appendPointToCustomMask({
const next = appendPointToFreeformPathMask({
params,
canvasPoint: { x: bounds.cx + 20, y: bounds.cy - 10 },
bounds,
@@ -401,8 +437,8 @@ describe("custom mask creation", () => {
describe("custom mask point deletion", () => {
test("removes the selected points by id", () => {
const points = buildCustomMaskParams().path;
const nextPoints = removeCustomMaskPoints({
const points = buildFreeformPathMaskParams().path;
const nextPoints = removeFreeformPathPoints({
points,
pointIds: ["b"],
});
@@ -411,14 +447,14 @@ describe("custom mask point deletion", () => {
});
test("reopens a closed path once fewer than three points remain", () => {
const points = buildCustomMaskParams().path;
const nextPoints = removeCustomMaskPoints({
const points = buildFreeformPathMaskParams().path;
const nextPoints = removeFreeformPathPoints({
points,
pointIds: ["c"],
});
expect(
getCustomMaskClosedStateAfterPointRemoval({
getFreeformPathClosedStateAfterPointRemoval({
wasClosed: true,
remainingPointCount: nextPoints.length,
}),
@@ -428,9 +464,9 @@ describe("custom mask point deletion", () => {
describe("custom mask point insertion", () => {
test("finds the closest point on the clicked segment", () => {
const params = buildCustomMaskParams();
const params = buildFreeformPathMaskParams();
const points = params.path;
const closestPoint = findClosestPointOnCustomMaskSegment({
const closestPoint = findClosestPointOnFreeformSegment({
points,
segmentIndex: 0,
canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
@@ -449,8 +485,8 @@ describe("custom mask point insertion", () => {
});
test("splits a segment into two segments at the insertion point", () => {
const points = buildCustomMaskParams().path;
const nextPoints = insertPointIntoCustomMaskSegment({
const points = buildFreeformPathMaskParams().path;
const nextPoints = insertPointIntoFreeformSegment({
points,
segmentIndex: 0,
pointId: "new",
@@ -471,8 +507,8 @@ describe("custom mask point insertion", () => {
});
test("builds updated custom mask params for a clicked segment", () => {
const result = insertPointOnCustomMaskSegment({
params: buildCustomMaskParams(),
const result = insertPointOnFreeformSegment({
params: buildFreeformPathMaskParams(),
segmentIndex: 0,
canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
bounds,
@@ -16,7 +16,7 @@ import {
getBoxMaskHandlePositions,
getBoxMaskOverlays,
} from "@/masks/handle-positions";
import { snapMaskInteraction } from "@/masks/snap";
import { snapBoxMaskInteraction } from "@/masks/snap";
const PERCENTAGE_DISPLAY: Pick<
NumberParamDefinition,
@@ -198,7 +198,7 @@ export function buildBoxMaskInteraction({
};
},
snap(args) {
return snapMaskInteraction(args);
return snapBoxMaskInteraction(args);
},
};
}
@@ -210,20 +210,20 @@ export function computeBoxMaskParamUpdate({
deltaY,
bounds,
}: MaskParamUpdateArgs<RectangleMaskParams>): Partial<RectangleMaskParams> {
if (handleId === "position") {
if (handleId.kind === "position") {
return {
centerX: startParams.centerX + deltaX / bounds.width,
centerY: startParams.centerY + deltaY / bounds.height,
};
}
if (handleId === "rotation") {
if (handleId.kind === "rotation") {
const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
const newRotation = (startParams.rotation + currentAngle) % 360;
return { rotation: newRotation < 0 ? newRotation + 360 : newRotation };
}
if (handleId === "feather") {
if (handleId.kind === "feather") {
const angleRad = (startParams.rotation * Math.PI) / 180;
return computeFeatherUpdate({
startFeather: startParams.feather,
@@ -237,8 +237,11 @@ export function computeBoxMaskParamUpdate({
const halfWidth = startParams.width * bounds.width;
const halfHeight = startParams.height * bounds.height;
if (handleId === "right" || handleId === "left") {
const sign = handleId === "right" ? 1 : -1;
if (
handleId.kind === "edge" &&
(handleId.side === "right" || handleId.side === "left")
) {
const sign = handleId.side === "right" ? 1 : -1;
return {
width: Math.max(
MIN_MASK_DIMENSION,
@@ -247,8 +250,11 @@ export function computeBoxMaskParamUpdate({
};
}
if (handleId === "bottom" || handleId === "top") {
const sign = handleId === "bottom" ? 1 : -1;
if (
handleId.kind === "edge" &&
(handleId.side === "bottom" || handleId.side === "top")
) {
const sign = handleId.side === "bottom" ? 1 : -1;
return {
height: Math.max(
MIN_MASK_DIMENSION,
@@ -257,14 +263,9 @@ export function computeBoxMaskParamUpdate({
};
}
if (
handleId === "top-left" ||
handleId === "top-right" ||
handleId === "bottom-left" ||
handleId === "bottom-right"
) {
const signX = handleId.includes("right") ? 1 : -1;
const signY = handleId.includes("bottom") ? 1 : -1;
if (handleId.kind === "corner") {
const signX = handleId.corner.x === "right" ? 1 : -1;
const signY = handleId.corner.y === "bottom" ? 1 : -1;
const distance = Math.sqrt(
(signX * deltaX + halfWidth) ** 2 + (signY * deltaY + halfHeight) ** 2,
);
@@ -276,7 +277,7 @@ export function computeBoxMaskParamUpdate({
};
}
if (handleId === "scale") {
if (handleId.kind === "scale") {
const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2);
const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
const scale = originalDistance > 0 ? 1 + distance / originalDistance : 1;
@@ -10,7 +10,7 @@ import {
getDefaultBaseMaskParams,
getStrokeOffset,
rotatePoint,
} from "./box-like";
} from "../box-like";
function getDefaultCinematicBarsMaskParams({
elementSize,
@@ -71,7 +71,7 @@ function buildBandPath({
return path;
}
export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = {
export const cinematicBarsMaskDefinition: MaskDefinition<"cinematic-bars"> = {
type: "cinematic-bars",
name: "Cinematic Bars",
features: {
@@ -94,42 +94,51 @@ export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> =
},
computeParamUpdate: computeBoxMaskParamUpdate,
renderer: {
buildPath({ resolvedParams, width, height }) {
const params = resolvedParams as RectangleMaskParams;
const centerX = width / 2 + params.centerX * width;
const centerY = height / 2 + params.centerY * height;
const maskWidth = Math.max(params.width * width, width);
const maskHeight = Math.max(params.height, 0.01) * height;
const rotationRad = (params.rotation * Math.PI) / 180;
body: {
kind: "fillPath",
buildPath({ resolvedParams, width, height }) {
const params = resolvedParams;
const centerX = width / 2 + params.centerX * width;
const centerY = height / 2 + params.centerY * height;
const maskWidth = Math.max(params.width * width, width);
const maskHeight = Math.max(params.height, 0.01) * height;
const rotationRad = (params.rotation * Math.PI) / 180;
return buildBandPath({
centerX,
centerY,
halfWidth: maskWidth / 2,
halfHeight: maskHeight / 2,
rotationRad,
});
return buildBandPath({
centerX,
centerY,
halfWidth: maskWidth / 2,
halfHeight: maskHeight / 2,
rotationRad,
});
},
},
buildStrokePath({ resolvedParams, width, height }) {
const params = resolvedParams as RectangleMaskParams;
const centerX = width / 2 + params.centerX * width;
const centerY = height / 2 + params.centerY * height;
const rotationRad = (params.rotation * Math.PI) / 180;
const offset = getStrokeOffset({
strokeAlign: params.strokeAlign,
strokeWidth: params.strokeWidth,
});
stroke: {
kind: "strokeFromPath",
buildStrokePath({ resolvedParams, width, height }) {
const params = resolvedParams;
const centerX = width / 2 + params.centerX * width;
const centerY = height / 2 + params.centerY * height;
const rotationRad = (params.rotation * Math.PI) / 180;
const offset = getStrokeOffset({
strokeAlign: params.strokeAlign,
strokeWidth: params.strokeWidth,
});
return buildBandPath({
centerX,
centerY,
halfWidth: Math.max((Math.max(params.width * width, width) / 2) + offset, 1),
halfHeight: Math.max(
(Math.max(params.height, 0.01) * height) / 2 + offset,
1,
),
rotationRad,
});
return buildBandPath({
centerX,
centerY,
halfWidth: Math.max(
Math.max(params.width * width, width) / 2 + offset,
1,
),
halfHeight: Math.max(
(Math.max(params.height, 0.01) * height) / 2 + offset,
1,
),
rotationRad,
});
},
},
},
};
@@ -1,4 +1,4 @@
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
import type { MaskDefinition } from "@/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
@@ -7,7 +7,7 @@ import {
getDefaultSquareMaskParams,
getStrokeOffset,
rotatePoint,
} from "./box-like";
} from "../box-like";
function buildDiamondPath({
centerX,
@@ -45,7 +45,7 @@ function buildDiamondPath({
return path;
}
export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = {
export const diamondMaskDefinition: MaskDefinition<"diamond"> = {
type: "diamond",
name: "Diamond",
features: {
@@ -68,33 +68,39 @@ export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = {
},
computeParamUpdate: computeBoxMaskParamUpdate,
renderer: {
buildPath({ resolvedParams, width, height }) {
const params = resolvedParams as RectangleMaskParams;
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
getBoxLikeGeometry({ params, width, height });
return buildDiamondPath({
centerX,
centerY,
halfWidth: maskWidth / 2,
halfHeight: maskHeight / 2,
rotationRad,
});
body: {
kind: "fillPath",
buildPath({ resolvedParams, width, height }) {
const params = resolvedParams;
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
getBoxLikeGeometry({ params, width, height });
return buildDiamondPath({
centerX,
centerY,
halfWidth: maskWidth / 2,
halfHeight: maskHeight / 2,
rotationRad,
});
},
},
buildStrokePath({ resolvedParams, width, height }) {
const params = resolvedParams as RectangleMaskParams;
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
getBoxLikeGeometry({ params, width, height });
const offset = getStrokeOffset({
strokeAlign: params.strokeAlign,
strokeWidth: params.strokeWidth,
});
return buildDiamondPath({
centerX,
centerY,
halfWidth: Math.max(maskWidth / 2 + offset, 1),
halfHeight: Math.max(maskHeight / 2 + offset, 1),
rotationRad,
});
stroke: {
kind: "strokeFromPath",
buildStrokePath({ resolvedParams, width, height }) {
const params = resolvedParams;
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
getBoxLikeGeometry({ params, width, height });
const offset = getStrokeOffset({
strokeAlign: params.strokeAlign,
strokeWidth: params.strokeWidth,
});
return buildDiamondPath({
centerX,
centerY,
halfWidth: Math.max(maskWidth / 2 + offset, 1),
halfHeight: Math.max(maskHeight / 2 + offset, 1),
rotationRad,
});
},
},
},
};
@@ -0,0 +1,81 @@
import type { MaskDefinition } from "@/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
computeBoxMaskParamUpdate,
getBoxLikeGeometry,
getDefaultSquareMaskParams,
getStrokeOffset,
} from "../box-like";
export const ellipseMaskDefinition: MaskDefinition<"ellipse"> = {
type: "ellipse",
name: "Ellipse",
features: {
hasPosition: true,
hasRotation: true,
sizeMode: "width-height",
},
params: BOX_LIKE_MASK_PARAMS,
interaction: buildBoxMaskInteraction({
sizeMode: "width-height",
buildOverlayPath({ width, height }) {
const rx = Math.max((width - 1) / 2, 0);
const ry = Math.max((height - 1) / 2, 0);
const cx = width / 2;
const cy = height / 2;
return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`;
},
}),
buildDefault(context) {
return {
type: "ellipse",
params: getDefaultSquareMaskParams(context),
};
},
computeParamUpdate: computeBoxMaskParamUpdate,
renderer: {
body: {
kind: "fillPath",
buildPath({ resolvedParams, width, height }) {
const params = resolvedParams;
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
getBoxLikeGeometry({ params, width, height });
const path = new Path2D();
path.ellipse(
centerX,
centerY,
maskWidth / 2,
maskHeight / 2,
rotationRad,
0,
Math.PI * 2,
);
return path;
},
},
stroke: {
kind: "strokeFromPath",
buildStrokePath({ resolvedParams, width, height }) {
const params = resolvedParams;
const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
getBoxLikeGeometry({ params, width, height });
const offset = getStrokeOffset({
strokeAlign: params.strokeAlign,
strokeWidth: params.strokeWidth,
});
const path = new Path2D();
path.ellipse(
centerX,
centerY,
Math.max(1, maskWidth / 2 + offset),
Math.max(1, maskHeight / 2 + offset),
rotationRad,
0,
Math.PI * 2,
);
return path;
},
},
},
};

Some files were not shown because too many files have changed in this diff Show More