chore: switch from biome to eslint + prettier; fix ton of lint issues
This commit is contained in:
parent
d6622dc6b3
commit
a9b9cf6bf5
|
|
@ -168,7 +168,7 @@ Working on `apps/desktop`? See [`apps/desktop/README.md`](../apps/desktop/README
|
||||||
2. Make your changes
|
2. Make your changes
|
||||||
3. Run the relevant checks for the area you touched:
|
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
|
- Desktop changes: run `./apps/desktop/script/setup` if your environment isn't set up yet
|
||||||
|
|
||||||
4. Commit your changes with a descriptive message
|
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
|
## Code Style
|
||||||
|
|
||||||
- We use Biome for code formatting and linting
|
- We use ESLint for linting and Prettier for formatting
|
||||||
- Run `bunx biome format --write .` from the `apps/web` directory to format code
|
- 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
|
- Run `bun run lint` from the `apps/web` directory to check for linting issues
|
||||||
- Follow the existing code patterns
|
- Follow the existing code patterns
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,344 +1,342 @@
|
||||||
---
|
---
|
||||||
applyTo: "**/*.{ts,tsx,js,jsx}"
|
applyTo: "**/*.{ts,tsx,js,jsx}"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project Context
|
# 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.
|
Strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects, enforced via ESLint (linting) and Prettier (formatting).
|
||||||
|
|
||||||
## Key Principles
|
## Key Principles
|
||||||
|
|
||||||
- Zero configuration required
|
- Maximum type safety
|
||||||
- Subsecond performance
|
- AI-friendly code generation
|
||||||
- Maximum type safety
|
|
||||||
- AI-friendly code generation
|
## Before Writing Code
|
||||||
|
|
||||||
## Before Writing Code
|
1. Analyze existing patterns in the codebase
|
||||||
|
2. Consider edge cases and error scenarios
|
||||||
1. Analyze existing patterns in the codebase
|
3. Follow the rules below strictly
|
||||||
2. Consider edge cases and error scenarios
|
4. Validate accessibility requirements
|
||||||
3. Follow the rules below strictly
|
|
||||||
4. Validate accessibility requirements
|
## Rules
|
||||||
|
|
||||||
## Rules
|
### Accessibility (a11y)
|
||||||
|
|
||||||
### Accessibility (a11y)
|
- Don't use `accessKey` attribute on any HTML element.
|
||||||
|
- Don't set `aria-hidden="true"` on focusable elements.
|
||||||
- Don't use `accessKey` attribute on any HTML element.
|
- Don't add ARIA roles, states, and properties to elements that don't support them.
|
||||||
- Don't set `aria-hidden="true"` on focusable elements.
|
- Don't use distracting elements like `<marquee>` or `<blink>`.
|
||||||
- Don't add ARIA roles, states, and properties to elements that don't support them.
|
- Only use the `scope` prop on `<th>` elements.
|
||||||
- Don't use distracting elements like `<marquee>` or `<blink>`.
|
- Don't assign non-interactive ARIA roles to interactive HTML elements.
|
||||||
- Only use the `scope` prop on `<th>` elements.
|
- Make sure label elements have text content and are associated with an input.
|
||||||
- Don't assign non-interactive ARIA roles to interactive HTML elements.
|
- Don't assign interactive ARIA roles to non-interactive HTML elements.
|
||||||
- Make sure label elements have text content and are associated with an input.
|
- Don't assign `tabIndex` to non-interactive HTML elements.
|
||||||
- Don't assign interactive ARIA roles to non-interactive HTML elements.
|
- Don't use positive integers for `tabIndex` property.
|
||||||
- Don't assign `tabIndex` to non-interactive HTML elements.
|
- Don't include "image", "picture", or "photo" in img alt prop.
|
||||||
- Don't use positive integers for `tabIndex` property.
|
- Don't use explicit role property that's the same as the implicit/default role.
|
||||||
- Don't include "image", "picture", or "photo" in img alt prop.
|
- Make static elements with click handlers use a valid role attribute.
|
||||||
- Don't use explicit role property that's the same as the implicit/default role.
|
- Always include a `title` element for SVG elements.
|
||||||
- Make static elements with click handlers use a valid role attribute.
|
- Give all elements requiring alt text meaningful information for screen readers.
|
||||||
- Always include a `title` element for SVG elements.
|
- Make sure anchors have content that's accessible to screen readers.
|
||||||
- Give all elements requiring alt text meaningful information for screen readers.
|
- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`.
|
||||||
- Make sure anchors have content that's accessible to screen readers.
|
- Include all required ARIA attributes for elements with ARIA roles.
|
||||||
- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`.
|
- Make sure ARIA properties are valid for the element's supported roles.
|
||||||
- Include all required ARIA attributes for elements with ARIA roles.
|
- Always include a `type` attribute for button elements.
|
||||||
- Make sure ARIA properties are valid for the element's supported roles.
|
- Make elements with interactive roles and handlers focusable.
|
||||||
- Always include a `type` attribute for button elements.
|
- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`).
|
||||||
- Make elements with interactive roles and handlers focusable.
|
- Always include a `lang` attribute on the html element.
|
||||||
- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`).
|
- Always include a `title` attribute for iframe elements.
|
||||||
- Always include a `lang` attribute on the html element.
|
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
|
||||||
- Always include a `title` attribute for iframe elements.
|
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
|
||||||
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
|
- Include caption tracks for audio and video elements.
|
||||||
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
|
- Use semantic elements instead of role attributes in JSX.
|
||||||
- Include caption tracks for audio and video elements.
|
- Make sure all anchors are valid and navigable.
|
||||||
- Use semantic elements instead of role attributes in JSX.
|
- Ensure all ARIA properties (`aria-*`) are valid.
|
||||||
- Make sure all anchors are valid and navigable.
|
- Use valid, non-abstract ARIA roles for elements with ARIA roles.
|
||||||
- Ensure all ARIA properties (`aria-*`) are valid.
|
- Use valid ARIA state and property values.
|
||||||
- Use valid, non-abstract ARIA roles for elements with ARIA roles.
|
- Use valid values for the `autocomplete` attribute on input elements.
|
||||||
- Use valid ARIA state and property values.
|
- Use correct ISO language/country codes for the `lang` attribute.
|
||||||
- Use valid values for the `autocomplete` attribute on input elements.
|
|
||||||
- Use correct ISO language/country codes for the `lang` attribute.
|
### Code Complexity and Quality
|
||||||
|
|
||||||
### Code Complexity and Quality
|
- Don't use consecutive spaces in regular expression literals.
|
||||||
|
- Don't use the `arguments` object.
|
||||||
- Don't use consecutive spaces in regular expression literals.
|
- Don't use primitive type aliases or misleading types.
|
||||||
- Don't use the `arguments` object.
|
- Don't use the comma operator.
|
||||||
- Don't use primitive type aliases or misleading types.
|
- Don't use empty type parameters in type aliases and interfaces.
|
||||||
- Don't use the comma operator.
|
- Don't write functions that exceed a given Cognitive Complexity score.
|
||||||
- Don't use empty type parameters in type aliases and interfaces.
|
- Don't nest describe() blocks too deeply in test files.
|
||||||
- Don't write functions that exceed a given Cognitive Complexity score.
|
- Don't use unnecessary boolean casts.
|
||||||
- Don't nest describe() blocks too deeply in test files.
|
- Don't use unnecessary callbacks with flatMap.
|
||||||
- Don't use unnecessary boolean casts.
|
- Use for...of statements instead of Array.forEach.
|
||||||
- Don't use unnecessary callbacks with flatMap.
|
- Don't create classes that only have static members (like a static namespace).
|
||||||
- Use for...of statements instead of Array.forEach.
|
- Don't use this and super in static contexts.
|
||||||
- Don't create classes that only have static members (like a static namespace).
|
- Don't use unnecessary catch clauses.
|
||||||
- Don't use this and super in static contexts.
|
- Don't use unnecessary constructors.
|
||||||
- Don't use unnecessary catch clauses.
|
- Don't use unnecessary continue statements.
|
||||||
- Don't use unnecessary constructors.
|
- Don't export empty modules that don't change anything.
|
||||||
- Don't use unnecessary continue statements.
|
- Don't use unnecessary escape sequences in regular expression literals.
|
||||||
- Don't export empty modules that don't change anything.
|
- Don't use unnecessary fragments.
|
||||||
- Don't use unnecessary escape sequences in regular expression literals.
|
- Don't use unnecessary labels.
|
||||||
- Don't use unnecessary fragments.
|
- Don't use unnecessary nested block statements.
|
||||||
- Don't use unnecessary labels.
|
- Don't rename imports, exports, and destructured assignments to the same name.
|
||||||
- Don't use unnecessary nested block statements.
|
- Don't use unnecessary string or template literal concatenation.
|
||||||
- Don't rename imports, exports, and destructured assignments to the same name.
|
- Don't use String.raw in template literals when there are no escape sequences.
|
||||||
- Don't use unnecessary string or template literal concatenation.
|
- Don't use useless case statements in switch statements.
|
||||||
- Don't use String.raw in template literals when there are no escape sequences.
|
- Don't use ternary operators when simpler alternatives exist.
|
||||||
- Don't use useless case statements in switch statements.
|
- Don't use useless `this` aliasing.
|
||||||
- Don't use ternary operators when simpler alternatives exist.
|
- Don't use any or unknown as type constraints.
|
||||||
- Don't use useless `this` aliasing.
|
- Don't initialize variables to undefined.
|
||||||
- Don't use any or unknown as type constraints.
|
- Don't use the void operators (they're not familiar).
|
||||||
- Don't initialize variables to undefined.
|
- Use arrow functions instead of function expressions.
|
||||||
- Don't use the void operators (they're not familiar).
|
- Use Date.now() to get milliseconds since the Unix Epoch.
|
||||||
- Use arrow functions instead of function expressions.
|
- Use .flatMap() instead of map().flat() when possible.
|
||||||
- Use Date.now() to get milliseconds since the Unix Epoch.
|
- Use literal property access instead of computed property access.
|
||||||
- Use .flatMap() instead of map().flat() when possible.
|
- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work.
|
||||||
- Use literal property access instead of computed property access.
|
- Use concise optional chaining instead of chained logical expressions.
|
||||||
- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work.
|
- Use regular expression literals instead of the RegExp constructor when possible.
|
||||||
- Use concise optional chaining instead of chained logical expressions.
|
- Don't use number literal object member names that aren't base 10 or use underscore separators.
|
||||||
- Use regular expression literals instead of the RegExp constructor when possible.
|
- Remove redundant terms from logical expressions.
|
||||||
- Don't use number literal object member names that aren't base 10 or use underscore separators.
|
- Use while loops instead of for loops when you don't need initializer and update expressions.
|
||||||
- Remove redundant terms from logical expressions.
|
- Don't pass children as props.
|
||||||
- Use while loops instead of for loops when you don't need initializer and update expressions.
|
- Don't reassign const variables.
|
||||||
- Don't pass children as props.
|
- Don't use constant expressions in conditions.
|
||||||
- Don't reassign const variables.
|
- Don't use `Math.min` and `Math.max` to clamp values when the result is constant.
|
||||||
- Don't use constant expressions in conditions.
|
- Don't return a value from a constructor.
|
||||||
- Don't use `Math.min` and `Math.max` to clamp values when the result is constant.
|
- Don't use empty character classes in regular expression literals.
|
||||||
- Don't return a value from a constructor.
|
- Don't use empty destructuring patterns.
|
||||||
- Don't use empty character classes in regular expression literals.
|
- Don't call global object properties as functions.
|
||||||
- Don't use empty destructuring patterns.
|
- Don't declare functions and vars that are accessible outside their block.
|
||||||
- Don't call global object properties as functions.
|
- Make sure builtins are correctly instantiated.
|
||||||
- Don't declare functions and vars that are accessible outside their block.
|
- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors.
|
||||||
- Make sure builtins are correctly instantiated.
|
- Don't use variables and function parameters before they're declared.
|
||||||
- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors.
|
- Don't use 8 and 9 escape sequences in string literals.
|
||||||
- Don't use variables and function parameters before they're declared.
|
- Don't use literal numbers that lose precision.
|
||||||
- Don't use 8 and 9 escape sequences in string literals.
|
|
||||||
- Don't use literal numbers that lose precision.
|
### React and JSX Best Practices
|
||||||
|
|
||||||
### React and JSX Best Practices
|
- Don't use the return value of React.render.
|
||||||
|
- Make sure all dependencies are correctly specified in React hooks.
|
||||||
- Don't use the return value of React.render.
|
- Make sure all React hooks are called from the top level of component functions.
|
||||||
- Make sure all dependencies are correctly specified in React hooks.
|
- Don't forget key props in iterators and collection literals.
|
||||||
- Make sure all React hooks are called from the top level of component functions.
|
- Don't destructure props inside JSX components in Solid projects.
|
||||||
- Don't forget key props in iterators and collection literals.
|
- Don't define React components inside other components.
|
||||||
- Don't destructure props inside JSX components in Solid projects.
|
- Don't use event handlers on non-interactive elements.
|
||||||
- Don't define React components inside other components.
|
- Don't assign to React component props.
|
||||||
- Don't use event handlers on non-interactive elements.
|
- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
|
||||||
- Don't assign to React component props.
|
- Don't use dangerous JSX props.
|
||||||
- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
|
- Don't use Array index in keys.
|
||||||
- Don't use dangerous JSX props.
|
- Don't insert comments as text nodes.
|
||||||
- Don't use Array index in keys.
|
- Don't assign JSX properties multiple times.
|
||||||
- Don't insert comments as text nodes.
|
- Don't add extra closing tags for components without children.
|
||||||
- Don't assign JSX properties multiple times.
|
- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
|
||||||
- Don't add extra closing tags for components without children.
|
- Watch out for possible "wrong" semicolons inside JSX elements.
|
||||||
- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
|
|
||||||
- Watch out for possible "wrong" semicolons inside JSX elements.
|
### Correctness and Safety
|
||||||
|
|
||||||
### Correctness and Safety
|
- Don't assign a value to itself.
|
||||||
|
- Don't return a value from a setter.
|
||||||
- Don't assign a value to itself.
|
- Don't compare expressions that modify string case with non-compliant values.
|
||||||
- Don't return a value from a setter.
|
- Don't use lexical declarations in switch clauses.
|
||||||
- Don't compare expressions that modify string case with non-compliant values.
|
- Don't use variables that haven't been declared in the document.
|
||||||
- Don't use lexical declarations in switch clauses.
|
- Don't write unreachable code.
|
||||||
- Don't use variables that haven't been declared in the document.
|
- 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 write unreachable code.
|
- Don't use control flow statements in finally blocks.
|
||||||
- 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 optional chaining where undefined values aren't allowed.
|
||||||
- Don't use control flow statements in finally blocks.
|
- Don't have unused function parameters.
|
||||||
- Don't use optional chaining where undefined values aren't allowed.
|
- Don't have unused imports.
|
||||||
- Don't have unused function parameters.
|
- Don't have unused labels.
|
||||||
- Don't have unused imports.
|
- Don't have unused private class members.
|
||||||
- Don't have unused labels.
|
- Don't have unused variables.
|
||||||
- Don't have unused private class members.
|
- Make sure void (self-closing) elements don't have children.
|
||||||
- Don't have unused variables.
|
- Don't return a value from a function with the return type 'void'
|
||||||
- Make sure void (self-closing) elements don't have children.
|
- Use isNaN() when checking for NaN.
|
||||||
- Don't return a value from a function with the return type 'void'
|
- Make sure "for" loop update clauses move the counter in the right direction.
|
||||||
- Use isNaN() when checking for NaN.
|
- Make sure typeof expressions are compared to valid values.
|
||||||
- Make sure "for" loop update clauses move the counter in the right direction.
|
- Make sure generator functions contain yield.
|
||||||
- Make sure typeof expressions are compared to valid values.
|
- Don't use await inside loops.
|
||||||
- Make sure generator functions contain yield.
|
- Don't use bitwise operators.
|
||||||
- Don't use await inside loops.
|
- Don't use expressions where the operation doesn't change the value.
|
||||||
- Don't use bitwise operators.
|
- Make sure Promise-like statements are handled appropriately.
|
||||||
- Don't use expressions where the operation doesn't change the value.
|
- Don't use **dirname and **filename in the global scope.
|
||||||
- Make sure Promise-like statements are handled appropriately.
|
- Prevent import cycles.
|
||||||
- Don't use **dirname and **filename in the global scope.
|
- Don't use configured elements.
|
||||||
- Prevent import cycles.
|
- Don't hardcode sensitive data like API keys and tokens.
|
||||||
- Don't use configured elements.
|
- Don't let variable declarations shadow variables from outer scopes.
|
||||||
- Don't hardcode sensitive data like API keys and tokens.
|
- Don't use the TypeScript directive @ts-ignore.
|
||||||
- Don't let variable declarations shadow variables from outer scopes.
|
- Prevent duplicate polyfills from Polyfill.io.
|
||||||
- Don't use the TypeScript directive @ts-ignore.
|
- Don't use useless backreferences in regular expressions that always match empty strings.
|
||||||
- Prevent duplicate polyfills from Polyfill.io.
|
- Don't use unnecessary escapes in string literals.
|
||||||
- Don't use useless backreferences in regular expressions that always match empty strings.
|
- Don't use useless undefined.
|
||||||
- Don't use unnecessary escapes in string literals.
|
- Make sure getters and setters for the same property are next to each other in class and object definitions.
|
||||||
- Don't use useless undefined.
|
- Make sure object literals are declared consistently (defaults to explicit definitions).
|
||||||
- Make sure getters and setters for the same property are next to each other in class and object definitions.
|
- Use static Response methods instead of new Response() constructor when possible.
|
||||||
- Make sure object literals are declared consistently (defaults to explicit definitions).
|
- Make sure switch-case statements are exhaustive.
|
||||||
- Use static Response methods instead of new Response() constructor when possible.
|
- Make sure the `preconnect` attribute is used when using Google Fonts.
|
||||||
- Make sure switch-case statements are exhaustive.
|
- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item.
|
||||||
- Make sure the `preconnect` attribute is used when using Google Fonts.
|
- Make sure iterable callbacks return consistent values.
|
||||||
- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item.
|
- Use `with { type: "json" }` for JSON module imports.
|
||||||
- Make sure iterable callbacks return consistent values.
|
- Use numeric separators in numeric literals.
|
||||||
- Use `with { type: "json" }` for JSON module imports.
|
- Use object spread instead of `Object.assign()` when constructing new objects.
|
||||||
- Use numeric separators in numeric literals.
|
- Always use the radix argument when using `parseInt()`.
|
||||||
- Use object spread instead of `Object.assign()` when constructing new objects.
|
- Make sure JSDoc comment lines start with a single asterisk, except for the first one.
|
||||||
- Always use the radix argument when using `parseInt()`.
|
- Include a description parameter for `Symbol()`.
|
||||||
- Make sure JSDoc comment lines start with a single asterisk, except for the first one.
|
- Don't use spread (`...`) syntax on accumulators.
|
||||||
- Include a description parameter for `Symbol()`.
|
- Don't use the `delete` operator.
|
||||||
- Don't use spread (`...`) syntax on accumulators.
|
- Don't access namespace imports dynamically.
|
||||||
- Don't use the `delete` operator.
|
- Don't use namespace imports.
|
||||||
- Don't access namespace imports dynamically.
|
- Declare regex literals at the top level.
|
||||||
- Don't use namespace imports.
|
- Don't use `target="_blank"` without `rel="noopener"`.
|
||||||
- Declare regex literals at the top level.
|
|
||||||
- Don't use `target="_blank"` without `rel="noopener"`.
|
### TypeScript Best Practices
|
||||||
|
|
||||||
### TypeScript Best Practices
|
- Don't use TypeScript enums.
|
||||||
|
- Don't export imported variables.
|
||||||
- Don't use TypeScript enums.
|
- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions.
|
||||||
- Don't export imported variables.
|
- Don't use TypeScript namespaces.
|
||||||
- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions.
|
- Don't use non-null assertions with the `!` postfix operator.
|
||||||
- Don't use TypeScript namespaces.
|
- Don't use parameter properties in class constructors.
|
||||||
- Don't use non-null assertions with the `!` postfix operator.
|
- Don't use user-defined types.
|
||||||
- Don't use parameter properties in class constructors.
|
- Use `as const` instead of literal types and type annotations.
|
||||||
- Don't use user-defined types.
|
- Use either `T[]` or `Array<T>` consistently.
|
||||||
- Use `as const` instead of literal types and type annotations.
|
- Initialize each enum member value explicitly.
|
||||||
- Use either `T[]` or `Array<T>` consistently.
|
- Use `export type` for types.
|
||||||
- Initialize each enum member value explicitly.
|
- Use `import type` for types.
|
||||||
- Use `export type` for types.
|
- Make sure all enum members are literal values.
|
||||||
- Use `import type` for types.
|
- Don't use TypeScript const enum.
|
||||||
- Make sure all enum members are literal values.
|
- Don't declare empty interfaces.
|
||||||
- Don't use TypeScript const enum.
|
- Don't let variables evolve into any type through reassignments.
|
||||||
- Don't declare empty interfaces.
|
- Don't use the any type.
|
||||||
- Don't let variables evolve into any type through reassignments.
|
- Don't misuse the non-null assertion operator (!) in TypeScript files.
|
||||||
- Don't use the any type.
|
- Don't use implicit any type on variable declarations.
|
||||||
- Don't misuse the non-null assertion operator (!) in TypeScript files.
|
- Don't merge interfaces and classes unsafely.
|
||||||
- Don't use implicit any type on variable declarations.
|
- Don't use overload signatures that aren't next to each other.
|
||||||
- Don't merge interfaces and classes unsafely.
|
- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces.
|
||||||
- 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
|
||||||
|
|
||||||
### Style and Consistency
|
- Don't use global `eval()`.
|
||||||
|
- Don't use callbacks in asynchronous tests and hooks.
|
||||||
- Don't use global `eval()`.
|
- Don't use negation in `if` statements that have `else` clauses.
|
||||||
- Don't use callbacks in asynchronous tests and hooks.
|
- Don't use nested ternary expressions.
|
||||||
- Don't use negation in `if` statements that have `else` clauses.
|
- Don't reassign function parameters.
|
||||||
- Don't use nested ternary expressions.
|
- This rule lets you specify global variable names you don't want to use in your application.
|
||||||
- Don't reassign function parameters.
|
- Don't use specified modules when loaded by import or require.
|
||||||
- This rule lets you specify global variable names you don't want to use in your application.
|
- Don't use constants whose value is the upper-case version of their name.
|
||||||
- Don't use specified modules when loaded by import or require.
|
- Use `String.slice()` instead of `String.substr()` and `String.substring()`.
|
||||||
- Don't use constants whose value is the upper-case version of their name.
|
- Don't use template literals if you don't need interpolation or special-character handling.
|
||||||
- Use `String.slice()` instead of `String.substr()` and `String.substring()`.
|
- Don't use `else` blocks when the `if` block breaks early.
|
||||||
- Don't use template literals if you don't need interpolation or special-character handling.
|
- Don't use yoda expressions.
|
||||||
- Don't use `else` blocks when the `if` block breaks early.
|
- Don't use Array constructors.
|
||||||
- Don't use yoda expressions.
|
- Use `at()` instead of integer index access.
|
||||||
- Don't use Array constructors.
|
- Follow curly brace conventions.
|
||||||
- Use `at()` instead of integer index access.
|
- Use `else if` instead of nested `if` statements in `else` clauses.
|
||||||
- Follow curly brace conventions.
|
- Use single `if` statements instead of nested `if` clauses.
|
||||||
- Use `else if` instead of nested `if` statements in `else` clauses.
|
- Use `new` for all builtins except `String`, `Number`, and `Boolean`.
|
||||||
- Use single `if` statements instead of nested `if` clauses.
|
- Use consistent accessibility modifiers on class properties and methods.
|
||||||
- Use `new` for all builtins except `String`, `Number`, and `Boolean`.
|
- Use `const` declarations for variables that are only assigned once.
|
||||||
- Use consistent accessibility modifiers on class properties and methods.
|
- Put default function parameters and optional function parameters last.
|
||||||
- Use `const` declarations for variables that are only assigned once.
|
- Include a `default` clause in switch statements.
|
||||||
- Put default function parameters and optional function parameters last.
|
- Use the `**` operator instead of `Math.pow`.
|
||||||
- Include a `default` clause in switch statements.
|
- Use `for-of` loops when you need the index to extract an item from the iterated array.
|
||||||
- Use the `**` operator instead of `Math.pow`.
|
- Use `node:assert/strict` over `node:assert`.
|
||||||
- Use `for-of` loops when you need the index to extract an item from the iterated array.
|
- Use the `node:` protocol for Node.js builtin modules.
|
||||||
- Use `node:assert/strict` over `node:assert`.
|
- Use Number properties instead of global ones.
|
||||||
- Use the `node:` protocol for Node.js builtin modules.
|
- Use assignment operator shorthand where possible.
|
||||||
- Use Number properties instead of global ones.
|
- Use function types instead of object types with call signatures.
|
||||||
- Use assignment operator shorthand where possible.
|
- Use template literals over string concatenation.
|
||||||
- Use function types instead of object types with call signatures.
|
- Use `new` when throwing an error.
|
||||||
- Use template literals over string concatenation.
|
- Don't throw non-Error values.
|
||||||
- Use `new` when throwing an error.
|
- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
|
||||||
- Don't throw non-Error values.
|
- Use standard constants instead of approximated literals.
|
||||||
- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
|
- Don't assign values in expressions.
|
||||||
- Use standard constants instead of approximated literals.
|
- Don't use async functions as Promise executors.
|
||||||
- Don't assign values in expressions.
|
- Don't reassign exceptions in catch clauses.
|
||||||
- Don't use async functions as Promise executors.
|
- Don't reassign class members.
|
||||||
- Don't reassign exceptions in catch clauses.
|
- Don't compare against -0.
|
||||||
- Don't reassign class members.
|
- Don't use labeled statements that aren't loops.
|
||||||
- Don't compare against -0.
|
- Don't use void type outside of generic or return types.
|
||||||
- Don't use labeled statements that aren't loops.
|
- Don't use console.
|
||||||
- Don't use void type outside of generic or return types.
|
- Don't use control characters and escape sequences that match control characters in regular expression literals.
|
||||||
- Don't use console.
|
- Don't use debugger.
|
||||||
- Don't use control characters and escape sequences that match control characters in regular expression literals.
|
- Don't assign directly to document.cookie.
|
||||||
- Don't use debugger.
|
- Use `===` and `!==`.
|
||||||
- Don't assign directly to document.cookie.
|
- Don't use duplicate case labels.
|
||||||
- Use `===` and `!==`.
|
- Don't use duplicate class members.
|
||||||
- Don't use duplicate case labels.
|
- Don't use duplicate conditions in if-else-if chains.
|
||||||
- Don't use duplicate class members.
|
- Don't use two keys with the same name inside objects.
|
||||||
- Don't use duplicate conditions in if-else-if chains.
|
- Don't use duplicate function parameter names.
|
||||||
- Don't use two keys with the same name inside objects.
|
- Don't have duplicate hooks in describe blocks.
|
||||||
- Don't use duplicate function parameter names.
|
- Don't use empty block statements and static blocks.
|
||||||
- Don't have duplicate hooks in describe blocks.
|
- Don't let switch clauses fall through.
|
||||||
- Don't use empty block statements and static blocks.
|
- Don't reassign function declarations.
|
||||||
- Don't let switch clauses fall through.
|
- Don't allow assignments to native objects and read-only global variables.
|
||||||
- Don't reassign function declarations.
|
- Use Number.isFinite instead of global isFinite.
|
||||||
- Don't allow assignments to native objects and read-only global variables.
|
- Use Number.isNaN instead of global isNaN.
|
||||||
- Use Number.isFinite instead of global isFinite.
|
- Don't assign to imported bindings.
|
||||||
- Use Number.isNaN instead of global isNaN.
|
- Don't use irregular whitespace characters.
|
||||||
- Don't assign to imported bindings.
|
- Don't use labels that share a name with a variable.
|
||||||
- Don't use irregular whitespace characters.
|
- Don't use characters made with multiple code points in character class syntax.
|
||||||
- Don't use labels that share a name with a variable.
|
- Make sure to use new and constructor properly.
|
||||||
- Don't use characters made with multiple code points in character class syntax.
|
- Don't use shorthand assign when the variable appears on both sides.
|
||||||
- Make sure to use new and constructor properly.
|
- Don't use octal escape sequences in string literals.
|
||||||
- Don't use shorthand assign when the variable appears on both sides.
|
- Don't use Object.prototype builtins directly.
|
||||||
- Don't use octal escape sequences in string literals.
|
- Don't redeclare variables, functions, classes, and types in the same scope.
|
||||||
- Don't use Object.prototype builtins directly.
|
- Don't have redundant "use strict".
|
||||||
- Don't redeclare variables, functions, classes, and types in the same scope.
|
- Don't compare things where both sides are exactly the same.
|
||||||
- Don't have redundant "use strict".
|
- Don't let identifiers shadow restricted names.
|
||||||
- Don't compare things where both sides are exactly the same.
|
- Don't use sparse arrays (arrays with holes).
|
||||||
- Don't let identifiers shadow restricted names.
|
- Don't use template literal placeholder syntax in regular strings.
|
||||||
- Don't use sparse arrays (arrays with holes).
|
- Don't use the then property.
|
||||||
- Don't use template literal placeholder syntax in regular strings.
|
- Don't use unsafe negation.
|
||||||
- Don't use the then property.
|
- Don't use var.
|
||||||
- Don't use unsafe negation.
|
- Don't use with statements in non-strict contexts.
|
||||||
- Don't use var.
|
- Make sure async functions actually use await.
|
||||||
- Don't use with statements in non-strict contexts.
|
- Make sure default clauses in switch statements come last.
|
||||||
- Make sure async functions actually use await.
|
- Make sure to pass a message value when creating a built-in error.
|
||||||
- Make sure default clauses in switch statements come last.
|
- Make sure get methods always return a value.
|
||||||
- Make sure to pass a message value when creating a built-in error.
|
- Use a recommended display strategy with Google Fonts.
|
||||||
- Make sure get methods always return a value.
|
- Make sure for-in loops include an if statement.
|
||||||
- Use a recommended display strategy with Google Fonts.
|
- Use Array.isArray() instead of instanceof Array.
|
||||||
- Make sure for-in loops include an if statement.
|
- Make sure to use the digits argument with Number#toFixed().
|
||||||
- Use Array.isArray() instead of instanceof Array.
|
- Make sure to use the "use strict" directive in script files.
|
||||||
- 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
|
||||||
|
|
||||||
### Next.js Specific Rules
|
- Don't use `<img>` elements in Next.js projects.
|
||||||
|
- Don't use `<head>` elements in Next.js projects.
|
||||||
- Don't use `<img>` elements in Next.js projects.
|
- Don't import next/document outside of pages/\_document.jsx in Next.js projects.
|
||||||
- Don't use `<head>` elements in Next.js projects.
|
- Don't use the next/head module in pages/\_document.js on 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
|
||||||
|
|
||||||
### Testing Best Practices
|
- Don't use export or module.exports in test files.
|
||||||
|
- Don't use focused tests.
|
||||||
- Don't use export or module.exports in test files.
|
- Make sure the assertion function, like expect, is placed inside an it() function call.
|
||||||
- Don't use focused tests.
|
- Don't use disabled tests.
|
||||||
- Make sure the assertion function, like expect, is placed inside an it() function call.
|
|
||||||
- Don't use disabled tests.
|
## Common Tasks
|
||||||
|
|
||||||
## Common Tasks
|
- `npx ultracite init` - Initialize Ultracite in your project
|
||||||
|
- `npx ultracite format` - Format and fix code automatically
|
||||||
- `npx ultracite init` - Initialize Ultracite in your project
|
- `npx ultracite lint` - Check for issues without fixing
|
||||||
- `npx ultracite format` - Format and fix code automatically
|
|
||||||
- `npx ultracite lint` - Check for issues without fixing
|
## Example: Error Handling
|
||||||
|
|
||||||
## Example: Error Handling
|
```typescript
|
||||||
|
// ✅ Good: Comprehensive error handling
|
||||||
```typescript
|
try {
|
||||||
// ✅ Good: Comprehensive error handling
|
const result = await fetchData();
|
||||||
try {
|
return { success: true, data: result };
|
||||||
const result = await fetchData();
|
} catch (error) {
|
||||||
return { success: true, data: result };
|
console.error("API call failed:", error);
|
||||||
} catch (error) {
|
return { success: false, error: error.message };
|
||||||
console.error("API call failed:", error);
|
}
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
// ❌ Bad: Swallowing errors
|
||||||
|
try {
|
||||||
// ❌ Bad: Swallowing errors
|
return await fetchData();
|
||||||
try {
|
} catch (e) {
|
||||||
return await fetchData();
|
console.log(e);
|
||||||
} catch (e) {
|
}
|
||||||
console.log(e);
|
```
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
target
|
||||||
|
rust/wasm/pkg
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"useTabs": true
|
||||||
|
}
|
||||||
|
|
@ -7,14 +7,7 @@
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.formatOnPaste": true,
|
"editor.formatOnPaste": true,
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.biome": "explicit",
|
"source.fixAll.eslint": "explicit"
|
||||||
"source.organizeImports.biome": "explicit"
|
|
||||||
},
|
},
|
||||||
"emmet.showExpandedAbbreviation": "never",
|
"emmet.showExpandedAbbreviation": "never"
|
||||||
"[typescriptreact]": {
|
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
|
||||||
},
|
|
||||||
"[typescript]": {
|
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
AGENTS.md
14
AGENTS.md
|
|
@ -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.
|
- 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.
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
||||||
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
|
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
|
||||||
"lint": "biome check src/",
|
"lint": "eslint src --ext .ts,.tsx",
|
||||||
"lint:fix": "biome check src/ --write",
|
"lint:fix": "eslint src --ext .ts,.tsx --fix",
|
||||||
"format": "biome format src/ --write",
|
"format": "prettier src --write",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
|
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,10 @@ export function ShortcutsDialog({
|
||||||
|
|
||||||
const keyString = getKeybindingString(e);
|
const keyString = getKeybindingString(e);
|
||||||
if (keyString) {
|
if (keyString) {
|
||||||
const conflict = validateKeybinding(
|
const conflict = validateKeybinding({
|
||||||
keyString,
|
key: keyString,
|
||||||
recordingShortcut.action,
|
action: recordingShortcut.action,
|
||||||
);
|
});
|
||||||
if (conflict) {
|
if (conflict) {
|
||||||
toast.error(
|
toast.error(
|
||||||
`Key "${keyString}" is already bound to "${conflict.existingAction}"`,
|
`Key "${keyString}" is already bound to "${conflict.existingAction}"`,
|
||||||
|
|
@ -68,7 +68,10 @@ export function ShortcutsDialog({
|
||||||
removeKeybinding(key);
|
removeKeybinding(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateKeybinding(keyString, recordingShortcut.action);
|
updateKeybinding({
|
||||||
|
key: keyString,
|
||||||
|
action: recordingShortcut.action,
|
||||||
|
});
|
||||||
|
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
setRecordingShortcut(null);
|
setRecordingShortcut(null);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
import type {
|
import type { ShortcutKey } from "@/actions/keybinding";
|
||||||
KeybindingConfig,
|
|
||||||
ShortcutKey,
|
|
||||||
} from "@/actions/keybinding";
|
|
||||||
import type { TActionWithOptionalArgs } from "./types";
|
import type { TActionWithOptionalArgs } from "./types";
|
||||||
|
|
||||||
export type TActionCategory =
|
export type TActionCategory =
|
||||||
|
|
@ -155,33 +152,36 @@ export const ACTIONS = {
|
||||||
|
|
||||||
export type TAction = keyof typeof ACTIONS;
|
export type TAction = keyof typeof ACTIONS;
|
||||||
|
|
||||||
const ACTION_DEFAULT_SHORTCUTS = {
|
const ACTION_DEFAULT_SHORTCUTS = [
|
||||||
"toggle-play": ["space", "k"],
|
["toggle-play", ["space", "k"]],
|
||||||
"seek-forward": ["l"],
|
["seek-forward", ["l"]],
|
||||||
"seek-backward": ["j"],
|
["seek-backward", ["j"]],
|
||||||
"frame-step-forward": ["right"],
|
["frame-step-forward", ["right"]],
|
||||||
"frame-step-backward": ["left"],
|
["frame-step-backward", ["left"]],
|
||||||
"jump-forward": ["shift+right"],
|
["jump-forward", ["shift+right"]],
|
||||||
"jump-backward": ["shift+left"],
|
["jump-backward", ["shift+left"]],
|
||||||
"goto-start": ["home", "enter"],
|
["goto-start", ["home", "enter"]],
|
||||||
"goto-end": ["end"],
|
["goto-end", ["end"]],
|
||||||
split: ["s"],
|
["split", ["s"]],
|
||||||
"split-left": ["q"],
|
["split-left", ["q"]],
|
||||||
"split-right": ["w"],
|
["split-right", ["w"]],
|
||||||
"delete-selected": ["backspace", "delete"],
|
["delete-selected", ["backspace", "delete"]],
|
||||||
"copy-selected": ["ctrl+c"],
|
["copy-selected", ["ctrl+c"]],
|
||||||
"paste-copied": ["ctrl+v"],
|
["paste-copied", ["ctrl+v"]],
|
||||||
"toggle-snapping": ["n"],
|
["toggle-snapping", ["n"]],
|
||||||
"select-all": ["ctrl+a"],
|
["select-all", ["ctrl+a"]],
|
||||||
"cancel-interaction": ["escape"],
|
["cancel-interaction", ["escape"]],
|
||||||
"duplicate-selected": ["ctrl+d"],
|
["duplicate-selected", ["ctrl+d"]],
|
||||||
undo: ["ctrl+z"],
|
["undo", ["ctrl+z"]],
|
||||||
redo: ["ctrl+shift+z", "ctrl+y"],
|
["redo", ["ctrl+shift+z", "ctrl+y"]],
|
||||||
} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>;
|
] as const satisfies ReadonlyArray<
|
||||||
|
readonly [TActionWithOptionalArgs, readonly ShortcutKey[]]
|
||||||
|
>;
|
||||||
|
|
||||||
const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial<
|
const ACTION_DEFAULT_SHORTCUTS_BY_ACTION = new Map<
|
||||||
Record<TAction, readonly ShortcutKey[]>
|
TAction,
|
||||||
> = ACTION_DEFAULT_SHORTCUTS;
|
readonly ShortcutKey[]
|
||||||
|
>(ACTION_DEFAULT_SHORTCUTS);
|
||||||
|
|
||||||
export function getActionDefinition({
|
export function getActionDefinition({
|
||||||
action,
|
action,
|
||||||
|
|
@ -190,18 +190,19 @@ export function getActionDefinition({
|
||||||
}): TActionDefinition {
|
}): TActionDefinition {
|
||||||
return {
|
return {
|
||||||
...ACTIONS[action],
|
...ACTIONS[action],
|
||||||
defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action],
|
defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION.get(action),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultShortcuts(): KeybindingConfig {
|
export function getDefaultShortcuts(): Map<
|
||||||
const shortcuts: KeybindingConfig = {};
|
ShortcutKey,
|
||||||
|
TActionWithOptionalArgs
|
||||||
|
> {
|
||||||
|
const shortcuts = new Map<ShortcutKey, TActionWithOptionalArgs>();
|
||||||
|
|
||||||
for (const [action, defaultShortcuts] of Object.entries(
|
for (const [action, defaultShortcuts] of ACTION_DEFAULT_SHORTCUTS) {
|
||||||
ACTION_DEFAULT_SHORTCUTS,
|
|
||||||
) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) {
|
|
||||||
for (const shortcut of defaultShortcuts) {
|
for (const shortcut of defaultShortcuts) {
|
||||||
shortcuts[shortcut] = action;
|
shortcuts.set(shortcut, action);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,79 +1,43 @@
|
||||||
import type { TActionWithOptionalArgs } from "./types";
|
import type { TActionWithOptionalArgs } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alt is also regarded as macOS OPTION (⌥) key
|
* 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!)
|
* Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!)
|
||||||
*/
|
*/
|
||||||
export type ModifierKeys =
|
export type ModifierKeys =
|
||||||
| "ctrl"
|
| "ctrl"
|
||||||
| "alt"
|
| "alt"
|
||||||
| "shift"
|
| "shift"
|
||||||
| "ctrl+shift"
|
| "ctrl+shift"
|
||||||
| "alt+shift"
|
| "alt+shift"
|
||||||
| "ctrl+alt"
|
| "ctrl+alt"
|
||||||
| "ctrl+alt+shift";
|
| "ctrl+alt+shift";
|
||||||
|
|
||||||
export type Key =
|
const KEYS = [
|
||||||
| "a"
|
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j",
|
||||||
| "b"
|
"k", "l", "m", "n", "o", "p", "q", "r", "s", "t",
|
||||||
| "c"
|
"u", "v", "w", "x", "y", "z",
|
||||||
| "d"
|
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
|
||||||
| "e"
|
"up", "down", "left", "right",
|
||||||
| "f"
|
"/", "?", ".",
|
||||||
| "g"
|
"enter", "tab", "space", "escape", "esc",
|
||||||
| "h"
|
"backspace", "delete", "home", "end",
|
||||||
| "i"
|
] as const;
|
||||||
| "j"
|
|
||||||
| "k"
|
export type Key = (typeof KEYS)[number];
|
||||||
| "l"
|
|
||||||
| "m"
|
const KEY_SET: ReadonlySet<string> = new Set(KEYS);
|
||||||
| "n"
|
|
||||||
| "o"
|
export function isKey(value: string): value is Key {
|
||||||
| "p"
|
return KEY_SET.has(value);
|
||||||
| "q"
|
}
|
||||||
| "r"
|
|
||||||
| "s"
|
export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`;
|
||||||
| "t"
|
// Singular keybindings (these will be disabled when an input-ish area has been focused)
|
||||||
| "u"
|
export type SingleCharacterShortcutKey = `${Key}`;
|
||||||
| "v"
|
|
||||||
| "w"
|
export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey;
|
||||||
| "x"
|
|
||||||
| "y"
|
export type KeybindingConfig = {
|
||||||
| "z"
|
[key in ShortcutKey]?: TActionWithOptionalArgs;
|
||||||
| "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;
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,15 @@ import type { TActionWithOptionalArgs } from "@/actions";
|
||||||
import { getDefaultShortcuts } from "@/actions";
|
import { getDefaultShortcuts } from "@/actions";
|
||||||
import { isTypableDOMElement } from "@/utils/browser";
|
import { isTypableDOMElement } from "@/utils/browser";
|
||||||
import { isAppleDevice } from "@/utils/platform";
|
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";
|
import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations";
|
||||||
|
|
||||||
const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
|
|
||||||
|
|
||||||
export interface KeybindingConflict {
|
export interface KeybindingConflict {
|
||||||
key: ShortcutKey;
|
key: ShortcutKey;
|
||||||
existingAction: TActionWithOptionalArgs;
|
existingAction: TActionWithOptionalArgs;
|
||||||
|
|
@ -18,38 +22,57 @@ export interface KeybindingConflict {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface KeybindingsState {
|
interface KeybindingsState {
|
||||||
keybindings: KeybindingConfig;
|
keybindings: Map<ShortcutKey, TActionWithOptionalArgs>;
|
||||||
isCustomized: boolean;
|
isCustomized: boolean;
|
||||||
overlayDepth: number;
|
overlayDepth: number;
|
||||||
openOverlayIds: string[];
|
openOverlayIds: string[];
|
||||||
isLoadingProject: boolean;
|
isLoadingProject: boolean;
|
||||||
isRecording: boolean;
|
isRecording: boolean;
|
||||||
|
|
||||||
updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void;
|
updateKeybinding: (params: {
|
||||||
|
key: ShortcutKey;
|
||||||
|
action: TActionWithOptionalArgs;
|
||||||
|
}) => void;
|
||||||
removeKeybinding: (key: ShortcutKey) => void;
|
removeKeybinding: (key: ShortcutKey) => void;
|
||||||
resetToDefaults: () => void;
|
resetToDefaults: () => void;
|
||||||
importKeybindings: (config: KeybindingConfig) => void;
|
importKeybindings: (config: KeybindingConfig) => void;
|
||||||
exportKeybindings: () => KeybindingConfig;
|
exportKeybindings: () => Record<string, TActionWithOptionalArgs>;
|
||||||
openOverlay: (overlayId: string) => void;
|
openOverlay: (overlayId: string) => void;
|
||||||
closeOverlay: (overlayId: string) => void;
|
closeOverlay: (overlayId: string) => void;
|
||||||
setLoadingProject: (loading: boolean) => void;
|
setLoadingProject: (loading: boolean) => void;
|
||||||
setIsRecording: (isRecording: boolean) => void;
|
setIsRecording: (isRecording: boolean) => void;
|
||||||
validateKeybinding: (
|
validateKeybinding: (params: {
|
||||||
key: ShortcutKey,
|
key: ShortcutKey;
|
||||||
action: TActionWithOptionalArgs,
|
action: TActionWithOptionalArgs;
|
||||||
) => KeybindingConflict | null;
|
}) => KeybindingConflict | null;
|
||||||
getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[];
|
getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[];
|
||||||
getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
|
getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PersistedState = {
|
||||||
|
keybindings: Record<string, TActionWithOptionalArgs>;
|
||||||
|
isCustomized: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
function isDOMElement(element: EventTarget | null): element is HTMLElement {
|
function isDOMElement(element: EventTarget | null): element is HTMLElement {
|
||||||
return element instanceof 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>()(
|
export const useKeybindingsStore = create<KeybindingsState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
keybindings: { ...defaultKeybindings },
|
keybindings: getDefaultShortcuts(),
|
||||||
isCustomized: false,
|
isCustomized: false,
|
||||||
overlayDepth: 0,
|
overlayDepth: 0,
|
||||||
openOverlayIds: [],
|
openOverlayIds: [],
|
||||||
|
|
@ -61,10 +84,9 @@ export const useKeybindingsStore = create<KeybindingsState>()(
|
||||||
const openOverlayIds = s.openOverlayIds.includes(overlayId)
|
const openOverlayIds = s.openOverlayIds.includes(overlayId)
|
||||||
? s.openOverlayIds
|
? s.openOverlayIds
|
||||||
: [...s.openOverlayIds, overlayId];
|
: [...s.openOverlayIds, overlayId];
|
||||||
const nextOverlayDepth = openOverlayIds.length;
|
|
||||||
return {
|
return {
|
||||||
openOverlayIds,
|
openOverlayIds,
|
||||||
overlayDepth: nextOverlayDepth,
|
overlayDepth: openOverlayIds.length,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
closeOverlay: (overlayId) =>
|
closeOverlay: (overlayId) =>
|
||||||
|
|
@ -72,35 +94,32 @@ export const useKeybindingsStore = create<KeybindingsState>()(
|
||||||
const openOverlayIds = s.openOverlayIds.filter(
|
const openOverlayIds = s.openOverlayIds.filter(
|
||||||
(id) => id !== overlayId,
|
(id) => id !== overlayId,
|
||||||
);
|
);
|
||||||
const nextOverlayDepth = openOverlayIds.length;
|
|
||||||
return {
|
return {
|
||||||
openOverlayIds,
|
openOverlayIds,
|
||||||
overlayDepth: nextOverlayDepth,
|
overlayDepth: openOverlayIds.length,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
setLoadingProject: (loading) => {
|
setLoadingProject: (loading) => {
|
||||||
set({ isLoadingProject: loading });
|
set({ isLoadingProject: loading });
|
||||||
},
|
},
|
||||||
|
|
||||||
updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => {
|
updateKeybinding: ({ key, action }) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newKeybindings = { ...state.keybindings };
|
const next = new Map(state.keybindings);
|
||||||
newKeybindings[key] = action;
|
next.set(key, action);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
keybindings: newKeybindings,
|
keybindings: next,
|
||||||
isCustomized: true,
|
isCustomized: true,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
removeKeybinding: (key: ShortcutKey) => {
|
removeKeybinding: (key) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newKeybindings = { ...state.keybindings };
|
const next = new Map(state.keybindings);
|
||||||
delete newKeybindings[key];
|
next.delete(key);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
keybindings: newKeybindings,
|
keybindings: next,
|
||||||
isCustomized: true,
|
isCustomized: true,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -108,34 +127,35 @@ export const useKeybindingsStore = create<KeybindingsState>()(
|
||||||
|
|
||||||
resetToDefaults: () => {
|
resetToDefaults: () => {
|
||||||
set({
|
set({
|
||||||
keybindings: { ...defaultKeybindings },
|
keybindings: getDefaultShortcuts(),
|
||||||
isCustomized: false,
|
isCustomized: false,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
importKeybindings: (config: KeybindingConfig) => {
|
importKeybindings: (config) => {
|
||||||
for (const [key] of Object.entries(config)) {
|
const next = new Map<ShortcutKey, TActionWithOptionalArgs>();
|
||||||
|
for (const [key, action] of Object.entries(config)) {
|
||||||
if (typeof key !== "string" || key.length === 0) {
|
if (typeof key !== "string" || key.length === 0) {
|
||||||
throw new Error(`Invalid key format: ${key}`);
|
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({
|
set({
|
||||||
keybindings: { ...config },
|
keybindings: next,
|
||||||
isCustomized: true,
|
isCustomized: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
exportKeybindings: () => {
|
exportKeybindings: () => {
|
||||||
return get().keybindings;
|
return Object.fromEntries(get().keybindings);
|
||||||
},
|
},
|
||||||
|
|
||||||
validateKeybinding: (
|
validateKeybinding: ({ key, action }) => {
|
||||||
key: ShortcutKey,
|
const existingAction = get().keybindings.get(key);
|
||||||
action: TActionWithOptionalArgs,
|
|
||||||
) => {
|
|
||||||
const { keybindings } = get();
|
|
||||||
const existingAction = keybindings[key];
|
|
||||||
|
|
||||||
if (existingAction && existingAction !== action) {
|
if (existingAction && existingAction !== action) {
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
|
|
@ -143,33 +163,45 @@ export const useKeybindingsStore = create<KeybindingsState>()(
|
||||||
newAction: action,
|
newAction: action,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
setIsRecording: (isRecording: boolean) => {
|
setIsRecording: (isRecording) => {
|
||||||
set({ isRecording });
|
set({ isRecording });
|
||||||
},
|
},
|
||||||
|
|
||||||
getKeybindingsForAction: (action: TActionWithOptionalArgs) => {
|
getKeybindingsForAction: (action) => {
|
||||||
const { keybindings } = get();
|
const result: ShortcutKey[] = [];
|
||||||
return Object.keys(keybindings).filter(
|
for (const [key, mapped] of get().keybindings) {
|
||||||
(key) => keybindings[key as ShortcutKey] === action,
|
if (mapped === action) result.push(key);
|
||||||
) as ShortcutKey[];
|
}
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
getKeybindingString: (ev: KeyboardEvent) => {
|
getKeybindingString: (ev) => generateKeybindingString(ev),
|
||||||
return generateKeybindingString(ev) as ShortcutKey | null;
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "opencut-keybindings",
|
name: "opencut-keybindings",
|
||||||
version: CURRENT_VERSION,
|
version: CURRENT_VERSION,
|
||||||
partialize: (state) => ({
|
partialize: (state): PersistedState => ({
|
||||||
keybindings: state.keybindings,
|
keybindings: Object.fromEntries(state.keybindings),
|
||||||
isCustomized: state.isCustomized,
|
isCustomized: state.isCustomized,
|
||||||
}),
|
}),
|
||||||
migrate: (persisted, version) =>
|
migrate: (persisted, version) =>
|
||||||
runMigrations({ state: persisted, fromVersion: 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 (
|
if (
|
||||||
modifierKey === "shift" &&
|
modifierKey === "shift" &&
|
||||||
isDOMElement(target) &&
|
isDOMElement(target) &&
|
||||||
isTypableDOMElement({ element: target as HTMLElement })
|
isTypableDOMElement({ element: target })
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${modifierKey}+${key}` as ShortcutKey;
|
return `${modifierKey}+${key}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (isDOMElement(target) && isTypableDOMElement({ element: target })) {
|
||||||
isDOMElement(target) &&
|
|
||||||
isTypableDOMElement({ element: target as HTMLElement })
|
|
||||||
)
|
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return `${key}` as ShortcutKey;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPressedKey(ev: KeyboardEvent): string | null {
|
function getPressedKey(ev: KeyboardEvent): Key | null {
|
||||||
const key = (ev.key ?? "").toLowerCase();
|
const raw = (ev.key ?? "").toLowerCase();
|
||||||
const code = ev.code ?? "";
|
const code = ev.code ?? "";
|
||||||
|
|
||||||
if (code === "Space" || key === " " || key === "spacebar" || key === "space")
|
if (code === "Space" || raw === " " || raw === "spacebar" || raw === "space")
|
||||||
return "space";
|
return "space";
|
||||||
|
|
||||||
if (key.startsWith("arrow")) return key.slice(5);
|
if (raw === "arrowup") return "up";
|
||||||
|
if (raw === "arrowdown") return "down";
|
||||||
if (key === "escape") return "escape";
|
if (raw === "arrowleft") return "left";
|
||||||
if (key === "tab") return "tab";
|
if (raw === "arrowright") return "right";
|
||||||
if (key === "home") return "home";
|
|
||||||
if (key === "end") return "end";
|
|
||||||
if (key === "delete") return "delete";
|
|
||||||
if (key === "backspace") return "backspace";
|
|
||||||
|
|
||||||
if (code.startsWith("Key")) {
|
if (code.startsWith("Key")) {
|
||||||
const letter = code.slice(3).toLowerCase();
|
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")) {
|
if (code.startsWith("Digit")) {
|
||||||
const digit = code.slice(5);
|
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 (isKey(raw)) return raw;
|
||||||
if (isDigit) return key;
|
|
||||||
|
|
||||||
if (key === "/" || key === "." || key === "enter") return key;
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActiveModifier(ev: KeyboardEvent): string | null {
|
function getActiveModifier(ev: KeyboardEvent): ModifierKeys | null {
|
||||||
const modifierKeys = {
|
const ctrl = isAppleDevice() ? ev.metaKey : ev.ctrlKey;
|
||||||
ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey,
|
const alt = ev.altKey;
|
||||||
alt: ev.altKey,
|
const shift = ev.shiftKey;
|
||||||
shift: ev.shiftKey,
|
|
||||||
};
|
|
||||||
|
|
||||||
const activeModifier = Object.keys(modifierKeys)
|
if (ctrl && alt && shift) return "ctrl+alt+shift";
|
||||||
.filter((key) => modifierKeys[key as keyof typeof modifierKeys])
|
if (ctrl && alt) return "ctrl+alt";
|
||||||
.join("+");
|
if (ctrl && shift) return "ctrl+shift";
|
||||||
|
if (alt && shift) return "alt+shift";
|
||||||
return activeModifier === "" ? null : activeModifier;
|
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 { getPersistedKeybindingsState } from "../persisted-state";
|
||||||
import type { TActionWithOptionalArgs } from "@/actions";
|
|
||||||
|
|
||||||
interface V2State {
|
|
||||||
keybindings: KeybindingConfig;
|
|
||||||
isCustomized: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function v2ToV3({ state }: { state: unknown }): unknown {
|
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> = {
|
const renames: Record<string, string> = {
|
||||||
"split-selected": "split",
|
"split-selected": "split",
|
||||||
|
|
@ -17,8 +12,9 @@ export function v2ToV3({ state }: { state: unknown }): unknown {
|
||||||
|
|
||||||
const migrated = { ...v2.keybindings };
|
const migrated = { ...v2.keybindings };
|
||||||
for (const [key, action] of Object.entries(migrated)) {
|
for (const [key, action] of Object.entries(migrated)) {
|
||||||
if (action && renames[action]) {
|
const renamedAction = action ? renames[action] : undefined;
|
||||||
migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs;
|
if (renamedAction) {
|
||||||
|
migrated[key] = renamedAction;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,8 @@
|
||||||
import type { TActionWithOptionalArgs } from "@/actions";
|
import { getPersistedKeybindingsState } from "../persisted-state";
|
||||||
import type { ShortcutKey } from "@/actions/keybinding";
|
|
||||||
import type { KeybindingConfig } from "@/actions/keybinding";
|
|
||||||
|
|
||||||
interface V3State {
|
|
||||||
keybindings: KeybindingConfig;
|
|
||||||
isCustomized: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function v3ToV4({ state }: { state: unknown }): unknown {
|
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> = {
|
const renames: Record<string, string> = {
|
||||||
"paste-selected": "paste-copied",
|
"paste-selected": "paste-copied",
|
||||||
|
|
@ -16,8 +10,9 @@ export function v3ToV4({ state }: { state: unknown }): unknown {
|
||||||
|
|
||||||
const migrated = { ...v3.keybindings };
|
const migrated = { ...v3.keybindings };
|
||||||
for (const [key, action] of Object.entries(migrated)) {
|
for (const [key, action] of Object.entries(migrated)) {
|
||||||
if (action && renames[action]) {
|
const renamedAction = action ? renames[action] : undefined;
|
||||||
migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs;
|
if (renamedAction) {
|
||||||
|
migrated[key] = renamedAction;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
import type { KeybindingConfig } from "@/actions/keybinding";
|
import { getPersistedKeybindingsState } from "../persisted-state";
|
||||||
|
|
||||||
interface V4State {
|
|
||||||
keybindings: KeybindingConfig;
|
|
||||||
isCustomized: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function v4ToV5({ state }: { state: unknown }): unknown {
|
export function v4ToV5({ state }: { state: unknown }): unknown {
|
||||||
const v4 = state as V4State;
|
const v4 = getPersistedKeybindingsState({ state });
|
||||||
|
if (!v4) return state;
|
||||||
const keybindings = { ...v4.keybindings };
|
const keybindings = { ...v4.keybindings };
|
||||||
|
|
||||||
if (!keybindings.escape) {
|
if (!keybindings.escape) {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
import type { KeybindingConfig } from "@/actions/keybinding";
|
import { getPersistedKeybindingsState } from "../persisted-state";
|
||||||
|
|
||||||
interface V5State {
|
|
||||||
keybindings: KeybindingConfig;
|
|
||||||
isCustomized: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function v5ToV6({ state }: { state: unknown }): unknown {
|
export function v5ToV6({ state }: { state: unknown }): unknown {
|
||||||
const v5 = state as V5State;
|
const v5 = getPersistedKeybindingsState({ state });
|
||||||
|
if (!v5) return state;
|
||||||
const keybindings = { ...v5.keybindings };
|
const keybindings = { ...v5.keybindings };
|
||||||
|
|
||||||
if (keybindings.escape === "deselect-all") {
|
if (keybindings.escape === "deselect-all") {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,12 @@
|
||||||
import type { KeybindingConfig } from "@/actions/keybinding";
|
import { getPersistedKeybindingsState } from "../persisted-state";
|
||||||
|
|
||||||
interface V6State {
|
|
||||||
keybindings: KeybindingConfig;
|
|
||||||
isCustomized: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function v6ToV7({ state }: { state: unknown }): unknown {
|
export function v6ToV7({ state }: { state: unknown }): unknown {
|
||||||
const v6 = state as V6State;
|
const v6 = getPersistedKeybindingsState({ state });
|
||||||
|
if (!v6) return state;
|
||||||
const keybindings = { ...v6.keybindings };
|
const keybindings = { ...v6.keybindings };
|
||||||
|
|
||||||
for (const key of Object.keys(keybindings) as Array<keyof KeybindingConfig>) {
|
for (const [key, action] of Object.entries(keybindings)) {
|
||||||
if (keybindings[key] === ("split-element" as never)) {
|
if (action === "split-element") {
|
||||||
keybindings[key] = "split";
|
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;
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import type {
|
||||||
type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void;
|
type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void;
|
||||||
const boundActions: Partial<Record<TAction, ActionHandler[]>> = {};
|
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>(
|
export function bindAction<A extends TAction>(
|
||||||
action: A,
|
action: A,
|
||||||
handler: TActionFunc<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>(
|
export function unbindAction<A extends TAction>(
|
||||||
action: A,
|
action: A,
|
||||||
handler: TActionFunc<A>,
|
handler: TActionFunc<A>,
|
||||||
|
|
@ -52,6 +54,7 @@ type InvokeActionFunc = {
|
||||||
): void;
|
): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line opencut/prefer-object-params -- dispatchers conventionally separate action, payload, and trigger.
|
||||||
export const invokeAction: InvokeActionFunc = <A extends TAction>(
|
export const invokeAction: InvokeActionFunc = <A extends TAction>(
|
||||||
action: A,
|
action: A,
|
||||||
args?: TArgOfAction<A>,
|
args?: TArgOfAction<A>,
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,44 @@
|
||||||
import type { MutableRefObject } from "react";
|
import type { MutableRefObject } from "react";
|
||||||
import type { TAction } from "./definitions";
|
import type { TAction } from "./definitions";
|
||||||
|
|
||||||
export type { TAction };
|
export type { TAction };
|
||||||
|
|
||||||
export type TActionArgsMap = {
|
export type TActionArgsMap = {
|
||||||
"seek-forward": { seconds: number } | undefined;
|
"seek-forward": { seconds: number } | undefined;
|
||||||
"seek-backward": { seconds: number } | undefined;
|
"seek-backward": { seconds: number } | undefined;
|
||||||
"jump-forward": { seconds: number } | undefined;
|
"jump-forward": { seconds: number } | undefined;
|
||||||
"jump-backward": { seconds: number } | undefined;
|
"jump-backward": { seconds: number } | undefined;
|
||||||
"remove-media-asset": { projectId: string; assetId: string };
|
"remove-media-asset": { projectId: string; assetId: string };
|
||||||
"remove-media-assets": { projectId: string; assetIds: string[] };
|
"remove-media-assets": { projectId: string; assetIds: string[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
type TKeysWithValueUndefined<T> = {
|
type TKeysWithValueUndefined<T> = {
|
||||||
[K in keyof T]: undefined extends T[K] ? K : never;
|
[K in keyof T]: undefined extends T[K] ? K : never;
|
||||||
}[keyof T];
|
}[keyof T];
|
||||||
|
|
||||||
export type TActionWithArgs = keyof TActionArgsMap;
|
export type TActionWithArgs = keyof TActionArgsMap;
|
||||||
|
|
||||||
export type TActionWithOptionalArgs =
|
export type TActionWithOptionalArgs =
|
||||||
| TActionWithNoArgs
|
| TActionWithNoArgs
|
||||||
| TKeysWithValueUndefined<TActionArgsMap>;
|
| TKeysWithValueUndefined<TActionArgsMap>;
|
||||||
|
|
||||||
export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>;
|
export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>;
|
||||||
|
|
||||||
export type TArgOfAction<A extends TAction> = A extends TActionWithArgs
|
export type TArgOfAction<A extends TAction> = A extends TActionWithArgs
|
||||||
? TActionArgsMap[A]
|
? TActionArgsMap[A]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
export type TActionFunc<A extends TAction> = A extends TActionWithArgs
|
export type TActionFunc<A extends TAction> = A extends TActionWithArgs
|
||||||
? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
|
? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
|
||||||
: (_?: undefined, trigger?: TInvocationTrigger) => void;
|
: (_?: undefined, trigger?: TInvocationTrigger) => void;
|
||||||
|
|
||||||
export type TInvocationTrigger = "keypress" | "mouseclick";
|
export type TInvocationTrigger = "keypress" | "mouseclick";
|
||||||
|
|
||||||
export type TBoundActionList = {
|
export type TBoundActionList = {
|
||||||
[A in TAction]?: Array<TActionFunc<A>>;
|
[A in TAction]?: Array<TActionFunc<A>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TActionHandlerOptions =
|
export type TActionHandlerOptions =
|
||||||
| MutableRefObject<boolean>
|
| MutableRefObject<boolean>
|
||||||
| boolean
|
| boolean
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import type {
|
||||||
} from "@/actions";
|
} from "@/actions";
|
||||||
import { bindAction, unbindAction } 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>(
|
export function useActionHandler<A extends TAction>(
|
||||||
action: A,
|
action: A,
|
||||||
handler: TActionFunc<A>,
|
handler: TActionFunc<A>,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTimelineStore } from "@/timeline/timeline-store";
|
import { useTimelineStore } from "@/timeline/timeline-store";
|
||||||
import { useActionHandler } from "@/actions/use-action-handler";
|
import { useActionHandler } from "@/actions/use-action-handler";
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
clearActiveScope,
|
clearActiveScope,
|
||||||
type ScopeEntry,
|
type ScopeEntry,
|
||||||
} from "@/selection/scope";
|
} from "@/selection/scope";
|
||||||
|
import { useCommittedRef } from "@/hooks/use-committed-ref";
|
||||||
|
|
||||||
export function useEditorActions() {
|
export function useEditorActions() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
|
|
@ -36,47 +37,34 @@ export function useEditorActions() {
|
||||||
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
|
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
|
||||||
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
|
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
|
||||||
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
|
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
|
||||||
const hasTimelineSelectionRef = useRef(false);
|
|
||||||
const clearTimelineSelectionRef = useRef(() => {});
|
|
||||||
const clearTimelineActiveSelectionRef = useRef(() => {});
|
|
||||||
const timelineScopeRef = useRef<ScopeEntry | null>(null);
|
|
||||||
const hasTimelineSelection =
|
const hasTimelineSelection =
|
||||||
selectedElements.length > 0 ||
|
selectedElements.length > 0 ||
|
||||||
selectedKeyframes.length > 0 ||
|
selectedKeyframes.length > 0 ||
|
||||||
selectedMaskPointSelection !== null;
|
selectedMaskPointSelection !== null;
|
||||||
|
const hasTimelineSelectionRef = useCommittedRef(hasTimelineSelection);
|
||||||
hasTimelineSelectionRef.current = hasTimelineSelection;
|
const clearTimelineSelectionRef = useCommittedRef(() => {
|
||||||
clearTimelineSelectionRef.current = () => {
|
|
||||||
editor.selection.clearSelection();
|
editor.selection.clearSelection();
|
||||||
};
|
});
|
||||||
clearTimelineActiveSelectionRef.current = () => {
|
const clearTimelineActiveSelectionRef = useCommittedRef(() => {
|
||||||
editor.selection.clearMostSpecificSelection();
|
editor.selection.clearMostSpecificSelection();
|
||||||
};
|
});
|
||||||
|
const [timelineScope] = useState<ScopeEntry>(() => ({
|
||||||
if (!timelineScopeRef.current) {
|
hasSelection: () => hasTimelineSelectionRef.current,
|
||||||
timelineScopeRef.current = {
|
clear: () => {
|
||||||
hasSelection: () => hasTimelineSelectionRef.current,
|
clearTimelineSelectionRef.current();
|
||||||
clear: () => {
|
},
|
||||||
clearTimelineSelectionRef.current();
|
clearActive: () => {
|
||||||
},
|
clearTimelineActiveSelectionRef.current();
|
||||||
clearActive: () => {
|
},
|
||||||
clearTimelineActiveSelectionRef.current();
|
}));
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasTimelineSelection) {
|
if (!hasTimelineSelection) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timelineScope = timelineScopeRef.current;
|
|
||||||
if (!timelineScope) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return activateScope({ entry: timelineScope });
|
return activateScope({ entry: timelineScope });
|
||||||
}, [hasTimelineSelection]);
|
}, [hasTimelineSelection, timelineScope]);
|
||||||
|
|
||||||
useActionHandler(
|
useActionHandler(
|
||||||
"toggle-play",
|
"toggle-play",
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ export function useKeybindingsListener() {
|
||||||
const isTextInput =
|
const isTextInput =
|
||||||
activeElement instanceof HTMLElement &&
|
activeElement instanceof HTMLElement &&
|
||||||
isTypableDOMElement({ element: activeElement });
|
isTypableDOMElement({ element: activeElement });
|
||||||
const boundAction = binding ? keybindings[binding] : undefined;
|
const boundAction = binding ? keybindings.get(binding) : undefined;
|
||||||
|
|
||||||
if (normalizedKey === "escape" && isTextInput) {
|
if (normalizedKey === "escape" && isTextInput) {
|
||||||
activeElement.blur();
|
activeElement.blur();
|
||||||
|
|
|
||||||
|
|
@ -39,27 +39,21 @@ export function useKeyboardShortcutsHelp() {
|
||||||
const { keybindings } = useKeybindingsStore();
|
const { keybindings } = useKeybindingsStore();
|
||||||
|
|
||||||
const shortcuts = useMemo(() => {
|
const shortcuts = useMemo(() => {
|
||||||
const result: KeyboardShortcut[] = [];
|
const actionToKeys = new Map<TActionWithOptionalArgs, string[]>();
|
||||||
const actionToKeys: Partial<Record<TActionWithOptionalArgs, string[]>> = {};
|
|
||||||
|
|
||||||
for (const [key, action] of Object.entries(keybindings) as Array<
|
for (const [key, action] of keybindings) {
|
||||||
[string, TActionWithOptionalArgs | undefined]
|
const existing = actionToKeys.get(action);
|
||||||
>) {
|
if (existing) {
|
||||||
if (action) {
|
existing.push(formatKey({ key }));
|
||||||
if (!actionToKeys[action]) {
|
} else {
|
||||||
actionToKeys[action] = [];
|
actionToKeys.set(action, [formatKey({ key })]);
|
||||||
}
|
|
||||||
actionToKeys[action].push(formatKey({ key }));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [action, keys] of Object.entries(actionToKeys) as Array<
|
const result: KeyboardShortcut[] = [];
|
||||||
[TActionWithOptionalArgs, string[]]
|
for (const [action, keys] of actionToKeys) {
|
||||||
>) {
|
|
||||||
const actionDef = ACTIONS[action];
|
const actionDef = ACTIONS[action];
|
||||||
if (!actionDef) {
|
if (!actionDef) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
result.push({
|
result.push({
|
||||||
id: action,
|
id: action,
|
||||||
keys,
|
keys,
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,11 @@ export interface AnimationPropertyDefinition {
|
||||||
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
|
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
|
||||||
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
|
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
|
||||||
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
|
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
|
||||||
setValue: ({
|
// Apply `value` to `element` for this property. Coerces the value through
|
||||||
|
// `coerceValue` and verifies element support; returns `element` unchanged
|
||||||
|
// if either fails. Cannot be bypassed — there is no kind-narrow `setValue`
|
||||||
|
// on the public surface, so callers can't apply an unvalidated value.
|
||||||
|
applyValue: ({
|
||||||
element,
|
element,
|
||||||
value,
|
value,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -94,7 +98,13 @@ function createNumberPropertyDefinition({
|
||||||
numericRange?: NumericSpec;
|
numericRange?: NumericSpec;
|
||||||
supportsElement: AnimationPropertyDefinition["supportsElement"];
|
supportsElement: AnimationPropertyDefinition["supportsElement"];
|
||||||
getValue: AnimationPropertyDefinition["getValue"];
|
getValue: AnimationPropertyDefinition["getValue"];
|
||||||
setValue: AnimationPropertyDefinition["setValue"];
|
setValue: ({
|
||||||
|
element,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
element: TimelineElement;
|
||||||
|
value: number;
|
||||||
|
}) => TimelineElement;
|
||||||
}): AnimationPropertyDefinition {
|
}): AnimationPropertyDefinition {
|
||||||
return {
|
return {
|
||||||
kind: "number",
|
kind: "number",
|
||||||
|
|
@ -102,12 +112,51 @@ function createNumberPropertyDefinition({
|
||||||
numericRanges: numericRange ? { value: numericRange } : undefined,
|
numericRanges: numericRange ? { value: numericRange } : undefined,
|
||||||
supportsElement,
|
supportsElement,
|
||||||
getValue,
|
getValue,
|
||||||
coerceValue: ({ value }) =>
|
coerceValue: ({ value }) => coerceNumberValue({ value, numericRange }),
|
||||||
coerceNumberValue({
|
applyValue: ({ element, value }) => {
|
||||||
value,
|
if (!supportsElement({ element })) {
|
||||||
numericRange,
|
return element;
|
||||||
}),
|
}
|
||||||
setValue,
|
const coerced = coerceNumberValue({ value, numericRange });
|
||||||
|
if (coerced === null) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
return setValue({ element, value: coerced });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createColorPropertyDefinition({
|
||||||
|
supportsElement,
|
||||||
|
getValue,
|
||||||
|
setValue,
|
||||||
|
}: {
|
||||||
|
supportsElement: AnimationPropertyDefinition["supportsElement"];
|
||||||
|
getValue: AnimationPropertyDefinition["getValue"];
|
||||||
|
setValue: ({
|
||||||
|
element,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
element: TimelineElement;
|
||||||
|
value: string;
|
||||||
|
}) => TimelineElement;
|
||||||
|
}): AnimationPropertyDefinition {
|
||||||
|
return {
|
||||||
|
kind: "color",
|
||||||
|
defaultInterpolation: "linear",
|
||||||
|
supportsElement,
|
||||||
|
getValue,
|
||||||
|
coerceValue: ({ value }) => coerceColorValue({ value }),
|
||||||
|
applyValue: ({ element, value }) => {
|
||||||
|
if (!supportsElement({ element })) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
const coerced = coerceColorValue({ value });
|
||||||
|
if (coerced === null) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
return setValue({ element, value: coerced });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,7 +175,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||||
...element,
|
...element,
|
||||||
transform: {
|
transform: {
|
||||||
...element.transform,
|
...element.transform,
|
||||||
position: { ...element.transform.position, x: value as number },
|
position: { ...element.transform.position, x: value },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
|
|
@ -142,7 +191,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||||
...element,
|
...element,
|
||||||
transform: {
|
transform: {
|
||||||
...element.transform,
|
...element.transform,
|
||||||
position: { ...element.transform.position, y: value as number },
|
position: { ...element.transform.position, y: value },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
|
|
@ -156,7 +205,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||||
isVisualElement(element)
|
isVisualElement(element)
|
||||||
? {
|
? {
|
||||||
...element,
|
...element,
|
||||||
transform: { ...element.transform, scaleX: value as number },
|
transform: { ...element.transform, scaleX: value },
|
||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
}),
|
}),
|
||||||
|
|
@ -169,7 +218,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||||
isVisualElement(element)
|
isVisualElement(element)
|
||||||
? {
|
? {
|
||||||
...element,
|
...element,
|
||||||
transform: { ...element.transform, scaleY: value as number },
|
transform: { ...element.transform, scaleY: value },
|
||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
}),
|
}),
|
||||||
|
|
@ -182,7 +231,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||||
isVisualElement(element)
|
isVisualElement(element)
|
||||||
? {
|
? {
|
||||||
...element,
|
...element,
|
||||||
transform: { ...element.transform, rotate: value as number },
|
transform: { ...element.transform, rotate: value },
|
||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
}),
|
}),
|
||||||
|
|
@ -192,9 +241,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||||
getValue: ({ element }) =>
|
getValue: ({ element }) =>
|
||||||
isVisualElement(element) ? element.opacity : null,
|
isVisualElement(element) ? element.opacity : null,
|
||||||
setValue: ({ element, value }) =>
|
setValue: ({ element, value }) =>
|
||||||
isVisualElement(element)
|
isVisualElement(element) ? { ...element, opacity: value } : element,
|
||||||
? { ...element, opacity: value as number }
|
|
||||||
: element,
|
|
||||||
}),
|
}),
|
||||||
volume: createNumberPropertyDefinition({
|
volume: createNumberPropertyDefinition({
|
||||||
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
|
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
|
||||||
|
|
@ -202,36 +249,26 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||||
getValue: ({ element }) =>
|
getValue: ({ element }) =>
|
||||||
canElementHaveAudio(element) ? element.volume ?? 0 : null,
|
canElementHaveAudio(element) ? element.volume ?? 0 : null,
|
||||||
setValue: ({ element, value }) =>
|
setValue: ({ element, value }) =>
|
||||||
canElementHaveAudio(element)
|
canElementHaveAudio(element) ? { ...element, volume: value } : element,
|
||||||
? { ...element, volume: value as number }
|
|
||||||
: element,
|
|
||||||
}),
|
}),
|
||||||
color: {
|
color: createColorPropertyDefinition({
|
||||||
kind: "color",
|
|
||||||
defaultInterpolation: "linear",
|
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
getValue: ({ element }) => (element.type === "text" ? element.color : null),
|
getValue: ({ element }) => (element.type === "text" ? element.color : null),
|
||||||
coerceValue: ({ value }) => coerceColorValue({ value }),
|
|
||||||
setValue: ({ element, value }) =>
|
setValue: ({ element, value }) =>
|
||||||
element.type === "text"
|
element.type === "text" ? { ...element, color: value } : element,
|
||||||
? { ...element, color: value as string }
|
}),
|
||||||
: element,
|
"background.color": createColorPropertyDefinition({
|
||||||
},
|
|
||||||
"background.color": {
|
|
||||||
kind: "color",
|
|
||||||
defaultInterpolation: "linear",
|
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
getValue: ({ element }) =>
|
getValue: ({ element }) =>
|
||||||
element.type === "text" ? element.background.color : null,
|
element.type === "text" ? element.background.color : null,
|
||||||
coerceValue: ({ value }) => coerceColorValue({ value }),
|
|
||||||
setValue: ({ element, value }) =>
|
setValue: ({ element, value }) =>
|
||||||
element.type === "text"
|
element.type === "text"
|
||||||
? {
|
? {
|
||||||
...element,
|
...element,
|
||||||
background: { ...element.background, color: value as string },
|
background: { ...element.background, color: value },
|
||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
},
|
}),
|
||||||
"background.paddingX": createNumberPropertyDefinition({
|
"background.paddingX": createNumberPropertyDefinition({
|
||||||
numericRange: { min: 0, step: 1 },
|
numericRange: { min: 0, step: 1 },
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
|
|
@ -243,7 +280,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||||
element.type === "text"
|
element.type === "text"
|
||||||
? {
|
? {
|
||||||
...element,
|
...element,
|
||||||
background: { ...element.background, paddingX: value as number },
|
background: { ...element.background, paddingX: value },
|
||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
}),
|
}),
|
||||||
|
|
@ -258,7 +295,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||||
element.type === "text"
|
element.type === "text"
|
||||||
? {
|
? {
|
||||||
...element,
|
...element,
|
||||||
background: { ...element.background, paddingY: value as number },
|
background: { ...element.background, paddingY: value },
|
||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
}),
|
}),
|
||||||
|
|
@ -273,7 +310,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||||
element.type === "text"
|
element.type === "text"
|
||||||
? {
|
? {
|
||||||
...element,
|
...element,
|
||||||
background: { ...element.background, offsetX: value as number },
|
background: { ...element.background, offsetX: value },
|
||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
}),
|
}),
|
||||||
|
|
@ -288,7 +325,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||||
element.type === "text"
|
element.type === "text"
|
||||||
? {
|
? {
|
||||||
...element,
|
...element,
|
||||||
background: { ...element.background, offsetY: value as number },
|
background: { ...element.background, offsetY: value },
|
||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
}),
|
}),
|
||||||
|
|
@ -307,7 +344,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||||
element.type === "text"
|
element.type === "text"
|
||||||
? {
|
? {
|
||||||
...element,
|
...element,
|
||||||
background: { ...element.background, cornerRadius: value as number },
|
background: { ...element.background, cornerRadius: value },
|
||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
}),
|
}),
|
||||||
|
|
@ -362,11 +399,7 @@ export function withElementBaseValueForProperty({
|
||||||
value: AnimationValue;
|
value: AnimationValue;
|
||||||
}): TimelineElement {
|
}): TimelineElement {
|
||||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||||
const coercedValue = definition.coerceValue({ value });
|
return definition.applyValue({ element, value });
|
||||||
if (coercedValue === null || !definition.supportsElement({ element })) {
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
return definition.setValue({ element, value: coercedValue });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultInterpolationForProperty({
|
export function getDefaultInterpolationForProperty({
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ export default function BrandPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<h2 className="font-semibold text-lg">What's not allowed</h2>
|
<h2 className="font-semibold text-lg">What's not allowed</h2>
|
||||||
<ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed">
|
<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.",
|
"Using OpenCut in the name of your product, service, or domain.",
|
||||||
|
|
|
||||||
|
|
@ -130,8 +130,14 @@ function EditorLayout() {
|
||||||
direction="vertical"
|
direction="vertical"
|
||||||
className="size-full gap-[0.18rem]"
|
className="size-full gap-[0.18rem]"
|
||||||
onLayout={(sizes) => {
|
onLayout={(sizes) => {
|
||||||
setPanel("mainContent", sizes[0] ?? panels.mainContent);
|
setPanel({
|
||||||
setPanel("timeline", sizes[1] ?? panels.timeline);
|
panel: "mainContent",
|
||||||
|
size: sizes[0] ?? panels.mainContent,
|
||||||
|
});
|
||||||
|
setPanel({
|
||||||
|
panel: "timeline",
|
||||||
|
size: sizes[1] ?? panels.timeline,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
|
|
@ -144,9 +150,12 @@ function EditorLayout() {
|
||||||
direction="horizontal"
|
direction="horizontal"
|
||||||
className="size-full gap-[0.19rem] px-3"
|
className="size-full gap-[0.19rem] px-3"
|
||||||
onLayout={(sizes) => {
|
onLayout={(sizes) => {
|
||||||
setPanel("tools", sizes[0] ?? panels.tools);
|
setPanel({ panel: "tools", size: sizes[0] ?? panels.tools });
|
||||||
setPanel("preview", sizes[1] ?? panels.preview);
|
setPanel({ panel: "preview", size: sizes[1] ?? panels.preview });
|
||||||
setPanel("properties", sizes[2] ?? panels.properties);
|
setPanel({
|
||||||
|
panel: "properties",
|
||||||
|
size: sizes[2] ?? panels.properties,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,10 @@ export default function PrivacyPage() {
|
||||||
content is tracked
|
content is tracked
|
||||||
</li>
|
</li>
|
||||||
<li>You can clear local data from your browser at any time</li>
|
<li>You can clear local data from your browser at any time</li>
|
||||||
<li>We don't sell or share your data with anyone (we don't even have it)</li>
|
<li>
|
||||||
|
We don't sell or share your data with anyone (we don't
|
||||||
|
even have it)
|
||||||
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p className="mt-4">
|
<p className="mt-4">
|
||||||
Questions? Email us at{" "}
|
Questions? Email us at{" "}
|
||||||
|
|
@ -172,7 +175,7 @@ export default function PrivacyPage() {
|
||||||
<a
|
<a
|
||||||
href={SOCIAL_LINKS.github}
|
href={SOCIAL_LINKS.github}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
GitHub
|
GitHub
|
||||||
|
|
@ -189,7 +192,7 @@ export default function PrivacyPage() {
|
||||||
<a
|
<a
|
||||||
href={`${SOCIAL_LINKS.github}/issues`}
|
href={`${SOCIAL_LINKS.github}/issues`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
GitHub repository
|
GitHub repository
|
||||||
|
|
@ -205,7 +208,7 @@ export default function PrivacyPage() {
|
||||||
<a
|
<a
|
||||||
href={SOCIAL_LINKS.x}
|
href={SOCIAL_LINKS.x}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
X (Twitter)
|
X (Twitter)
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,13 @@ export default function TermsPage() {
|
||||||
Free for personal and commercial use with no watermarks or
|
Free for personal and commercial use with no watermarks or
|
||||||
restrictions
|
restrictions
|
||||||
</li>
|
</li>
|
||||||
<li>You're responsible for how you use it - don't break the law</li>
|
|
||||||
<li>
|
<li>
|
||||||
Service provided "as is" - we can't guarantee perfect uptime
|
You're responsible for how you use it - don't break
|
||||||
|
the law
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Service provided "as is" - we can't guarantee
|
||||||
|
perfect uptime
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Open source means you can review our code and self-host if
|
Open source means you can review our code and self-host if
|
||||||
|
|
@ -109,8 +113,8 @@ export default function TermsPage() {
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
You're responsible for how you use OpenCut and the content you create.
|
You're responsible for how you use OpenCut and the content you
|
||||||
Don't use it for anything illegal in your jurisdiction.
|
create. Don't use it for anything illegal in your jurisdiction.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -127,8 +131,8 @@ export default function TermsPage() {
|
||||||
<h2 className="text-2xl font-semibold">Service</h2>
|
<h2 className="text-2xl font-semibold">Service</h2>
|
||||||
<p>
|
<p>
|
||||||
OpenCut does not currently require an account. The service is provided
|
OpenCut does not currently require an account. The service is provided
|
||||||
"as is" without warranties. While we strive for reliability, we can't
|
"as is" without warranties. While we strive for
|
||||||
guarantee uninterrupted service.
|
reliability, we can't guarantee uninterrupted service.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -146,7 +150,7 @@ export default function TermsPage() {
|
||||||
<a
|
<a
|
||||||
href={SOCIAL_LINKS.github}
|
href={SOCIAL_LINKS.github}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
GitHub
|
GitHub
|
||||||
|
|
@ -161,12 +165,12 @@ export default function TermsPage() {
|
||||||
OpenCut is provided free of charge. To the extent permitted by law:
|
OpenCut is provided free of charge. To the extent permitted by law:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc space-y-2 pl-6">
|
<ul className="list-disc space-y-2 pl-6">
|
||||||
<li>We're not liable for any loss of data or content</li>
|
<li>We're not liable for any loss of data or content</li>
|
||||||
<li>
|
<li>
|
||||||
Projects are stored in your browser and may be lost if you clear
|
Projects are stored in your browser and may be lost if you clear
|
||||||
browser data
|
browser data
|
||||||
</li>
|
</li>
|
||||||
<li>We're not responsible for how you use the service</li>
|
<li>We're not responsible for how you use the service</li>
|
||||||
<li>Our liability is limited to the maximum extent allowed by law</li>
|
<li>Our liability is limited to the maximum extent allowed by law</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
|
|
@ -180,7 +184,7 @@ export default function TermsPage() {
|
||||||
<h2 className="text-2xl font-semibold">Service Changes</h2>
|
<h2 className="text-2xl font-semibold">Service Changes</h2>
|
||||||
<p>We may update OpenCut and these terms:</p>
|
<p>We may update OpenCut and these terms:</p>
|
||||||
<ul className="list-disc space-y-2 pl-6">
|
<ul className="list-disc space-y-2 pl-6">
|
||||||
<li>We'll notify you of significant changes to these terms</li>
|
<li>We'll notify you of significant changes to these terms</li>
|
||||||
<li>Continued use means you accept any updates</li>
|
<li>Continued use means you accept any updates</li>
|
||||||
<li>You can always self-host an older version if you prefer</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>
|
<li>Major changes will be discussed with the community on GitHub</li>
|
||||||
|
|
@ -203,7 +207,7 @@ export default function TermsPage() {
|
||||||
<a
|
<a
|
||||||
href={`${SOCIAL_LINKS.github}/issues`}
|
href={`${SOCIAL_LINKS.github}/issues`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
GitHub repository
|
GitHub repository
|
||||||
|
|
@ -219,7 +223,7 @@ export default function TermsPage() {
|
||||||
<a
|
<a
|
||||||
href={SOCIAL_LINKS.x}
|
href={SOCIAL_LINKS.x}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
X (Twitter)
|
X (Twitter)
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export const ElementsClipboardHandler = {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
paste(entry, context) {
|
paste({ entry, context }) {
|
||||||
if (entry.items.length === 0) {
|
if (entry.items.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,5 +45,5 @@ export function buildPasteClipboardCommand({
|
||||||
const handler = clipboardHandlers[entry.type] as ClipboardHandler<
|
const handler = clipboardHandlers[entry.type] as ClipboardHandler<
|
||||||
typeof entry.type
|
typeof entry.type
|
||||||
>;
|
>;
|
||||||
return handler.paste(entry as never, context);
|
return handler.paste({ entry: entry as never, context });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,10 @@ export const KeyframesClipboardHandler = {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
paste(entry, { selectedElements, time }) {
|
paste({
|
||||||
|
entry,
|
||||||
|
context: { selectedElements, time },
|
||||||
|
}) {
|
||||||
const targetElement = selectedElements[0];
|
const targetElement = selectedElements[0];
|
||||||
if (!targetElement || entry.items.length === 0) {
|
if (!targetElement || entry.items.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -69,10 +69,10 @@ export interface ClipboardHandler<TType extends ClipboardEntryType> {
|
||||||
type: TType;
|
type: TType;
|
||||||
canCopy(context: CopyContext): boolean;
|
canCopy(context: CopyContext): boolean;
|
||||||
copy(context: CopyContext): ClipboardEntryByType[TType] | null;
|
copy(context: CopyContext): ClipboardEntryByType[TType] | null;
|
||||||
paste(
|
paste(args: {
|
||||||
entry: ClipboardEntryByType[TType],
|
entry: ClipboardEntryByType[TType];
|
||||||
context: PasteContext,
|
context: PasteContext;
|
||||||
): Command | null;
|
}): Command | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClipboardHandlerMap = {
|
export type ClipboardHandlerMap = {
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,22 @@ export class AddMediaAssetCommand extends Command {
|
||||||
private previousProjectFps: FrameRate | null = null;
|
private previousProjectFps: FrameRate | null = null;
|
||||||
private appliedProjectFps: FrameRate | null = null;
|
private appliedProjectFps: FrameRate | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor({
|
||||||
private projectId: string,
|
projectId,
|
||||||
private asset: Omit<MediaAsset, "id">,
|
asset,
|
||||||
) {
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
asset: Omit<MediaAsset, "id">;
|
||||||
|
}) {
|
||||||
super();
|
super();
|
||||||
|
this.projectId = projectId;
|
||||||
|
this.asset = asset;
|
||||||
this.assetId = generateUUID();
|
this.assetId = generateUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private projectId: string;
|
||||||
|
private asset: Omit<MediaAsset, "id">;
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedAssets = [...editor.media.getAssets()];
|
this.savedAssets = [...editor.media.getAssets()];
|
||||||
|
|
@ -117,7 +125,14 @@ export class AddMediaAssetCommand extends Command {
|
||||||
|
|
||||||
const activeProject = editor.project.getActiveOrNull();
|
const activeProject = editor.project.getActiveOrNull();
|
||||||
if (!activeProject) return;
|
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({
|
const highestRemainingVideoFps = getHighestImportedVideoFps({
|
||||||
mediaAssets: editor.media.getAssets(),
|
mediaAssets: editor.media.getAssets(),
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,21 @@ export class RemoveMediaAssetCommand extends Command {
|
||||||
private savedTracks: SceneTracks | null = null;
|
private savedTracks: SceneTracks | null = null;
|
||||||
private removedAsset: MediaAsset | null = null;
|
private removedAsset: MediaAsset | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor({
|
||||||
private projectId: string,
|
projectId,
|
||||||
private assetId: string,
|
assetId,
|
||||||
) {
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
assetId: string;
|
||||||
|
}) {
|
||||||
super();
|
super();
|
||||||
|
this.projectId = projectId;
|
||||||
|
this.assetId = assetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private projectId: string;
|
||||||
|
private assetId: string;
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const assets = editor.media.getAssets();
|
const assets = editor.media.getAssets();
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,21 @@ export class CreateSceneCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
private createdScene: TScene | null = null;
|
private createdScene: TScene | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor({
|
||||||
private name: string,
|
name,
|
||||||
private isMain: boolean = false,
|
isMain = false,
|
||||||
) {
|
}: {
|
||||||
|
name: string;
|
||||||
|
isMain?: boolean;
|
||||||
|
}) {
|
||||||
super();
|
super();
|
||||||
|
this.name = name;
|
||||||
|
this.isMain = isMain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private name: string;
|
||||||
|
private isMain: boolean;
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedScenes = [...editor.scenes.getScenes()];
|
this.savedScenes = [...editor.scenes.getScenes()];
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,21 @@ import type { MediaTime } from "@/wasm";
|
||||||
export class MoveBookmarkCommand extends Command {
|
export class MoveBookmarkCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor({
|
||||||
private fromTime: MediaTime,
|
fromTime,
|
||||||
private toTime: MediaTime,
|
toTime,
|
||||||
) {
|
}: {
|
||||||
|
fromTime: MediaTime;
|
||||||
|
toTime: MediaTime;
|
||||||
|
}) {
|
||||||
super();
|
super();
|
||||||
|
this.fromTime = fromTime;
|
||||||
|
this.toTime = toTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fromTime: MediaTime;
|
||||||
|
private toTime: MediaTime;
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const activeScene = editor.scenes.getActiveScene();
|
const activeScene = editor.scenes.getActiveScene();
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,11 @@ import {
|
||||||
getFrameTime,
|
getFrameTime,
|
||||||
removeBookmarkFromArray,
|
removeBookmarkFromArray,
|
||||||
} from "@/timeline/bookmarks/index";
|
} from "@/timeline/bookmarks/index";
|
||||||
import type { MediaTime } from "@/wasm";
|
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
||||||
|
|
||||||
export class RemoveBookmarkCommand extends Command {
|
export class RemoveBookmarkCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
private frameTime: MediaTime = 0 as MediaTime;
|
private frameTime: MediaTime = ZERO_MEDIA_TIME;
|
||||||
|
|
||||||
constructor(private time: MediaTime) {
|
constructor(private time: MediaTime) {
|
||||||
super();
|
super();
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,21 @@ export class RenameSceneCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
private previousName: string | null = null;
|
private previousName: string | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor({
|
||||||
private sceneId: string,
|
sceneId,
|
||||||
private newName: string,
|
newName,
|
||||||
) {
|
}: {
|
||||||
|
sceneId: string;
|
||||||
|
newName: string;
|
||||||
|
}) {
|
||||||
super();
|
super();
|
||||||
|
this.sceneId = sceneId;
|
||||||
|
this.newName = newName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sceneId: string;
|
||||||
|
private newName: string;
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const scenes = editor.scenes.getScenes();
|
const scenes = editor.scenes.getScenes();
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,11 @@ import {
|
||||||
getFrameTime,
|
getFrameTime,
|
||||||
toggleBookmarkInArray,
|
toggleBookmarkInArray,
|
||||||
} from "@/timeline/bookmarks/index";
|
} from "@/timeline/bookmarks/index";
|
||||||
import type { MediaTime } from "@/wasm";
|
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
||||||
|
|
||||||
export class ToggleBookmarkCommand extends Command {
|
export class ToggleBookmarkCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
private frameTime: MediaTime = 0 as MediaTime;
|
private frameTime: MediaTime = ZERO_MEDIA_TIME;
|
||||||
|
|
||||||
constructor(private time: MediaTime) {
|
constructor(private time: MediaTime) {
|
||||||
super();
|
super();
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,21 @@ import type { MediaTime } from "@/wasm";
|
||||||
export class UpdateBookmarkCommand extends Command {
|
export class UpdateBookmarkCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor({
|
||||||
private time: MediaTime,
|
time,
|
||||||
private updates: Partial<Omit<Bookmark, "time">>,
|
updates,
|
||||||
) {
|
}: {
|
||||||
|
time: MediaTime;
|
||||||
|
updates: Partial<Omit<Bookmark, "time">>;
|
||||||
|
}) {
|
||||||
super();
|
super();
|
||||||
|
this.time = time;
|
||||||
|
this.updates = updates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private time: MediaTime;
|
||||||
|
private updates: Partial<Omit<Bookmark, "time">>;
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
const activeScene = editor.scenes.getActiveScene();
|
const activeScene = editor.scenes.getActiveScene();
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,22 @@ export class AddTrackCommand extends Command {
|
||||||
private trackId: string;
|
private trackId: string;
|
||||||
private savedState: SceneTracks | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor({
|
||||||
private type: TrackType,
|
type,
|
||||||
private index?: number,
|
index,
|
||||||
) {
|
}: {
|
||||||
|
type: TrackType;
|
||||||
|
index?: number;
|
||||||
|
}) {
|
||||||
super();
|
super();
|
||||||
|
this.type = type;
|
||||||
|
this.index = index;
|
||||||
this.trackId = generateUUID();
|
this.trackId = generateUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private type: TrackType;
|
||||||
|
private index?: number;
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
const editor = EditorCore.getInstance();
|
const editor = EditorCore.getInstance();
|
||||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,21 @@ import type { SceneTracks } from "@/timeline";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
|
|
||||||
export class TracksSnapshotCommand extends Command {
|
export class TracksSnapshotCommand extends Command {
|
||||||
constructor(
|
constructor({
|
||||||
private before: SceneTracks,
|
before,
|
||||||
private after: SceneTracks,
|
after,
|
||||||
) {
|
}: {
|
||||||
|
before: SceneTracks;
|
||||||
|
after: SceneTracks;
|
||||||
|
}) {
|
||||||
super();
|
super();
|
||||||
|
this.before = before;
|
||||||
|
this.after = after;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private before: SceneTracks;
|
||||||
|
private after: SceneTracks;
|
||||||
|
|
||||||
execute(): CommandResult | undefined {
|
execute(): CommandResult | undefined {
|
||||||
EditorCore.getInstance().timeline.updateTracks(this.after);
|
EditorCore.getInstance().timeline.updateTracks(this.after);
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ interface AssetsPanelStore {
|
||||||
setMediaViewMode: (mode: MediaViewMode) => void;
|
setMediaViewMode: (mode: MediaViewMode) => void;
|
||||||
mediaSortBy: MediaSortKey;
|
mediaSortBy: MediaSortKey;
|
||||||
mediaSortOrder: MediaSortOrder;
|
mediaSortOrder: MediaSortOrder;
|
||||||
setMediaSort: (key: MediaSortKey, order: MediaSortOrder) => void;
|
setMediaSort: (args: { key: MediaSortKey; order: MediaSortOrder }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAssetsPanelStore = create<AssetsPanelStore>()(
|
export const useAssetsPanelStore = create<AssetsPanelStore>()(
|
||||||
|
|
@ -109,7 +109,7 @@ export const useAssetsPanelStore = create<AssetsPanelStore>()(
|
||||||
setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
|
setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
|
||||||
mediaSortBy: "name",
|
mediaSortBy: "name",
|
||||||
mediaSortOrder: "asc",
|
mediaSortOrder: "asc",
|
||||||
setMediaSort: (key, order) =>
|
setMediaSort: ({ key, order }) =>
|
||||||
set({ mediaSortBy: key, mediaSortOrder: order }),
|
set({ mediaSortBy: key, mediaSortOrder: order }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -139,9 +139,12 @@ export function MediaView() {
|
||||||
|
|
||||||
const handleSort = ({ key }: { key: MediaSortKey }) => {
|
const handleSort = ({ key }: { key: MediaSortKey }) => {
|
||||||
if (mediaSortBy === key) {
|
if (mediaSortBy === key) {
|
||||||
setMediaSort(key, mediaSortOrder === "asc" ? "desc" : "asc");
|
setMediaSort({
|
||||||
|
key,
|
||||||
|
order: mediaSortOrder === "asc" ? "desc" : "asc",
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setMediaSort(key, "asc");
|
setMediaSort({ key, order: "asc" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
|
import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -34,6 +34,10 @@ import type { TCanvasSize } from "@/project/types";
|
||||||
|
|
||||||
type SettingsView = "project-info" | "background";
|
type SettingsView = "project-info" | "background";
|
||||||
|
|
||||||
|
function isSettingsView(value: string): value is SettingsView {
|
||||||
|
return value === "project-info" || value === "background";
|
||||||
|
}
|
||||||
|
|
||||||
const PRESET_LABELS: Record<string, string> = {
|
const PRESET_LABELS: Record<string, string> = {
|
||||||
"1:1": "1:1",
|
"1:1": "1:1",
|
||||||
"16:9": "16:9",
|
"16:9": "16:9",
|
||||||
|
|
@ -73,23 +77,20 @@ function useCanvasDimensionDraft({
|
||||||
value: number;
|
value: number;
|
||||||
onCommit: (value: number) => void;
|
onCommit: (value: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const pendingValueRef = useRef(value);
|
const [pendingValue, setPendingValue] = useState(value);
|
||||||
const syncedValueRef = useRef(value);
|
|
||||||
|
|
||||||
if (syncedValueRef.current !== value) {
|
|
||||||
syncedValueRef.current = value;
|
|
||||||
pendingValueRef.current = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return usePropertyDraft({
|
return usePropertyDraft({
|
||||||
displayValue: formatCanvasDimension({ value }),
|
displayValue: formatCanvasDimension({ value }),
|
||||||
parse: (input) => parseCanvasDimension({ input }),
|
parse: (input) => parseCanvasDimension({ input }),
|
||||||
|
onStartEditing: () => {
|
||||||
|
setPendingValue(value);
|
||||||
|
},
|
||||||
onPreview: (nextValue) => {
|
onPreview: (nextValue) => {
|
||||||
pendingValueRef.current = nextValue;
|
setPendingValue(nextValue);
|
||||||
},
|
},
|
||||||
onCommit: () => {
|
onCommit: () => {
|
||||||
if (pendingValueRef.current !== value) {
|
if (pendingValue !== value) {
|
||||||
onCommit(pendingValueRef.current);
|
onCommit(pendingValue);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -212,7 +213,14 @@ export function SettingsView() {
|
||||||
contentClassName="px-0"
|
contentClassName="px-0"
|
||||||
scrollClassName="pt-0"
|
scrollClassName="pt-0"
|
||||||
actions={
|
actions={
|
||||||
<Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}>
|
<Tabs
|
||||||
|
value={view}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (isSettingsView(value)) {
|
||||||
|
setView(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="project-info">Project info</TabsTrigger>
|
<TabsTrigger value="project-info">Project info</TabsTrigger>
|
||||||
<TabsTrigger value="background">Background</TabsTrigger>
|
<TabsTrigger value="background">Background</TabsTrigger>
|
||||||
|
|
|
||||||
|
|
@ -1,96 +1,96 @@
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import {
|
import {
|
||||||
getKeyframeAtTime,
|
getKeyframeAtTime,
|
||||||
hasKeyframesForPath,
|
hasKeyframesForPath,
|
||||||
upsertElementKeyframe,
|
upsertElementKeyframe,
|
||||||
} from "@/animation";
|
} from "@/animation";
|
||||||
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
|
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
|
||||||
import type { TimelineElement } from "@/timeline";
|
import type { TimelineElement } from "@/timeline";
|
||||||
import type { MediaTime } from "@/wasm";
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export function useKeyframedColorProperty({
|
export function useKeyframedColorProperty({
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
animations,
|
animations,
|
||||||
propertyPath,
|
propertyPath,
|
||||||
localTime,
|
localTime,
|
||||||
isPlayheadWithinElementRange,
|
isPlayheadWithinElementRange,
|
||||||
resolvedColor,
|
resolvedColor,
|
||||||
buildBaseUpdates,
|
buildBaseUpdates,
|
||||||
}: {
|
}: {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: AnimationPropertyPath;
|
propertyPath: AnimationPropertyPath;
|
||||||
localTime: MediaTime;
|
localTime: MediaTime;
|
||||||
isPlayheadWithinElementRange: boolean;
|
isPlayheadWithinElementRange: boolean;
|
||||||
resolvedColor: string;
|
resolvedColor: string;
|
||||||
buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
|
buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
|
||||||
}) {
|
}) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
|
|
||||||
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
|
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
|
||||||
const keyframeAtTime = isPlayheadWithinElementRange
|
const keyframeAtTime = isPlayheadWithinElementRange
|
||||||
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
|
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
|
||||||
: null;
|
: null;
|
||||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||||
const shouldUseAnimatedChannel =
|
const shouldUseAnimatedChannel =
|
||||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||||
|
|
||||||
const onChange = ({ color }: { color: string }) => {
|
const onChange = ({ color }: { color: string }) => {
|
||||||
if (shouldUseAnimatedChannel) {
|
if (shouldUseAnimatedChannel) {
|
||||||
editor.timeline.previewElements({
|
editor.timeline.previewElements({
|
||||||
updates: [
|
updates: [
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
updates: {
|
updates: {
|
||||||
animations: upsertElementKeyframe({
|
animations: upsertElementKeyframe({
|
||||||
animations,
|
animations,
|
||||||
propertyPath,
|
propertyPath,
|
||||||
time: localTime,
|
time: localTime,
|
||||||
value: color,
|
value: color,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.timeline.previewElements({
|
editor.timeline.previewElements({
|
||||||
updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
|
updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onChangeEnd = () => editor.timeline.commitPreview();
|
const onChangeEnd = () => editor.timeline.commitPreview();
|
||||||
|
|
||||||
const toggleKeyframe = () => {
|
const toggleKeyframe = () => {
|
||||||
if (!isPlayheadWithinElementRange) {
|
if (!isPlayheadWithinElementRange) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keyframeIdAtTime) {
|
if (keyframeIdAtTime) {
|
||||||
editor.timeline.removeKeyframes({
|
editor.timeline.removeKeyframes({
|
||||||
keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
|
keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.timeline.upsertKeyframes({
|
editor.timeline.upsertKeyframes({
|
||||||
keyframes: [
|
keyframes: [
|
||||||
{ trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
|
{ trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isKeyframedAtTime,
|
isKeyframedAtTime,
|
||||||
hasAnimatedKeyframes,
|
hasAnimatedKeyframes,
|
||||||
keyframeIdAtTime,
|
keyframeIdAtTime,
|
||||||
onChange,
|
onChange,
|
||||||
onChangeEnd,
|
onChangeEnd,
|
||||||
toggleKeyframe,
|
toggleKeyframe,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,175 +1,175 @@
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import {
|
import {
|
||||||
getKeyframeAtTime,
|
getKeyframeAtTime,
|
||||||
hasKeyframesForPath,
|
hasKeyframesForPath,
|
||||||
upsertElementKeyframe,
|
upsertElementKeyframe,
|
||||||
} from "@/animation";
|
} from "@/animation";
|
||||||
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
|
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
|
||||||
import type { TimelineElement } from "@/timeline";
|
import type { TimelineElement } from "@/timeline";
|
||||||
import { snapToStep } from "@/utils/math";
|
import { snapToStep } from "@/utils/math";
|
||||||
import { usePropertyDraft } from "./use-property-draft";
|
import { usePropertyDraft } from "./use-property-draft";
|
||||||
import type { MediaTime } from "@/wasm";
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export function useKeyframedNumberProperty({
|
export function useKeyframedNumberProperty({
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
animations,
|
animations,
|
||||||
propertyPath,
|
propertyPath,
|
||||||
localTime,
|
localTime,
|
||||||
isPlayheadWithinElementRange,
|
isPlayheadWithinElementRange,
|
||||||
displayValue,
|
displayValue,
|
||||||
parse,
|
parse,
|
||||||
valueAtPlayhead,
|
valueAtPlayhead,
|
||||||
step,
|
step,
|
||||||
buildBaseUpdates,
|
buildBaseUpdates,
|
||||||
buildAdditionalKeyframes,
|
buildAdditionalKeyframes,
|
||||||
}: {
|
}: {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: AnimationPropertyPath;
|
propertyPath: AnimationPropertyPath;
|
||||||
localTime: MediaTime;
|
localTime: MediaTime;
|
||||||
isPlayheadWithinElementRange: boolean;
|
isPlayheadWithinElementRange: boolean;
|
||||||
displayValue: string;
|
displayValue: string;
|
||||||
parse: (input: string) => number | null;
|
parse: (input: string) => number | null;
|
||||||
valueAtPlayhead: number;
|
valueAtPlayhead: number;
|
||||||
step?: number;
|
step?: number;
|
||||||
buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
|
buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
|
||||||
buildAdditionalKeyframes?: ({
|
buildAdditionalKeyframes?: ({
|
||||||
value,
|
value,
|
||||||
}: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>;
|
}: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>;
|
||||||
}) {
|
}) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const snapValue = (value: number) =>
|
const snapValue = (value: number) =>
|
||||||
step != null ? snapToStep({ value, step }) : value;
|
step != null ? snapToStep({ value, step }) : value;
|
||||||
|
|
||||||
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
|
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
|
||||||
const keyframeAtTime = isPlayheadWithinElementRange
|
const keyframeAtTime = isPlayheadWithinElementRange
|
||||||
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
|
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
|
||||||
: null;
|
: null;
|
||||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||||
const shouldUseAnimatedChannel =
|
const shouldUseAnimatedChannel =
|
||||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||||
|
|
||||||
const previewValue = ({ value }: { value: number }) => {
|
const previewValue = ({ value }: { value: number }) => {
|
||||||
const nextValue = snapValue(value);
|
const nextValue = snapValue(value);
|
||||||
if (shouldUseAnimatedChannel) {
|
if (shouldUseAnimatedChannel) {
|
||||||
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
|
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
|
||||||
const updatedAnimations = [
|
const updatedAnimations = [
|
||||||
{ propertyPath, value: nextValue },
|
{ propertyPath, value: nextValue },
|
||||||
...additionalKeyframes,
|
...additionalKeyframes,
|
||||||
].reduce(
|
].reduce(
|
||||||
(currentAnimations, keyframe) =>
|
(currentAnimations, keyframe) =>
|
||||||
upsertElementKeyframe({
|
upsertElementKeyframe({
|
||||||
animations: currentAnimations,
|
animations: currentAnimations,
|
||||||
propertyPath: keyframe.propertyPath,
|
propertyPath: keyframe.propertyPath,
|
||||||
time: localTime,
|
time: localTime,
|
||||||
value: keyframe.value,
|
value: keyframe.value,
|
||||||
}),
|
}),
|
||||||
animations,
|
animations,
|
||||||
);
|
);
|
||||||
editor.timeline.previewElements({
|
editor.timeline.previewElements({
|
||||||
updates: [
|
updates: [
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
updates: { animations: updatedAnimations },
|
updates: { animations: updatedAnimations },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.timeline.previewElements({
|
editor.timeline.previewElements({
|
||||||
updates: [
|
updates: [
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
updates: buildBaseUpdates({ value: nextValue }),
|
updates: buildBaseUpdates({ value: nextValue }),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const propertyDraft = usePropertyDraft({
|
const propertyDraft = usePropertyDraft({
|
||||||
displayValue,
|
displayValue,
|
||||||
parse: (input) => {
|
parse: (input) => {
|
||||||
const parsedValue = parse(input);
|
const parsedValue = parse(input);
|
||||||
return parsedValue === null ? null : snapValue(parsedValue);
|
return parsedValue === null ? null : snapValue(parsedValue);
|
||||||
},
|
},
|
||||||
onPreview: (value) => previewValue({ value }),
|
onPreview: (value) => previewValue({ value }),
|
||||||
onCommit: () => editor.timeline.commitPreview(),
|
onCommit: () => editor.timeline.commitPreview(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleKeyframe = () => {
|
const toggleKeyframe = () => {
|
||||||
if (!isPlayheadWithinElementRange) {
|
if (!isPlayheadWithinElementRange) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keyframeIdAtTime) {
|
if (keyframeIdAtTime) {
|
||||||
editor.timeline.removeKeyframes({
|
editor.timeline.removeKeyframes({
|
||||||
keyframes: [
|
keyframes: [
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
propertyPath,
|
propertyPath,
|
||||||
keyframeId: keyframeIdAtTime,
|
keyframeId: keyframeIdAtTime,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.timeline.upsertKeyframes({
|
editor.timeline.upsertKeyframes({
|
||||||
keyframes: [
|
keyframes: [
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
propertyPath,
|
propertyPath,
|
||||||
time: localTime,
|
time: localTime,
|
||||||
value: snapValue(valueAtPlayhead),
|
value: snapValue(valueAtPlayhead),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const commitValue = ({ value }: { value: number }) => {
|
const commitValue = ({ value }: { value: number }) => {
|
||||||
const nextValue = snapValue(value);
|
const nextValue = snapValue(value);
|
||||||
if (shouldUseAnimatedChannel) {
|
if (shouldUseAnimatedChannel) {
|
||||||
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
|
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
|
||||||
editor.timeline.upsertKeyframes({
|
editor.timeline.upsertKeyframes({
|
||||||
keyframes: [
|
keyframes: [
|
||||||
{ trackId, elementId, propertyPath, time: localTime, value: nextValue },
|
{ trackId, elementId, propertyPath, time: localTime, value: nextValue },
|
||||||
...additionalKeyframes.map((keyframe) => ({
|
...additionalKeyframes.map((keyframe) => ({
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
propertyPath: keyframe.propertyPath,
|
propertyPath: keyframe.propertyPath,
|
||||||
time: localTime,
|
time: localTime,
|
||||||
value: keyframe.value,
|
value: keyframe.value,
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.timeline.updateElements({
|
editor.timeline.updateElements({
|
||||||
updates: [
|
updates: [
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
patch: buildBaseUpdates({ value: nextValue }),
|
patch: buildBaseUpdates({ value: nextValue }),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...propertyDraft,
|
...propertyDraft,
|
||||||
hasAnimatedKeyframes,
|
hasAnimatedKeyframes,
|
||||||
isKeyframedAtTime,
|
isKeyframedAtTime,
|
||||||
keyframeIdAtTime,
|
keyframeIdAtTime,
|
||||||
toggleKeyframe,
|
toggleKeyframe,
|
||||||
commitValue,
|
commitValue,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useReducer, useRef } from "react";
|
import { useState } from "react";
|
||||||
import { evaluateMathExpression } from "@/utils/math";
|
import { evaluateMathExpression } from "@/utils/math";
|
||||||
|
|
||||||
function looksLikeExpression({ input }: { input: string }): boolean {
|
function looksLikeExpression({ input }: { input: string }): boolean {
|
||||||
|
|
@ -14,59 +14,56 @@ export function usePropertyDraft<T>({
|
||||||
parse,
|
parse,
|
||||||
onPreview,
|
onPreview,
|
||||||
onCommit,
|
onCommit,
|
||||||
|
onStartEditing,
|
||||||
supportsExpressions = true,
|
supportsExpressions = true,
|
||||||
}: {
|
}: {
|
||||||
displayValue: string;
|
displayValue: string;
|
||||||
parse: (input: string) => T | null;
|
parse: (input: string) => T | null;
|
||||||
onPreview: (value: T) => void;
|
onPreview: (value: T) => void;
|
||||||
onCommit: () => void;
|
onCommit: () => void;
|
||||||
|
onStartEditing?: () => void;
|
||||||
supportsExpressions?: boolean;
|
supportsExpressions?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [, forceRender] = useReducer(
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
(renderVersion: number) => renderVersion + 1,
|
const [draft, setDraft] = useState("");
|
||||||
0,
|
|
||||||
);
|
|
||||||
const isEditing = useRef(false);
|
|
||||||
const draft = useRef("");
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
displayValue: isEditing.current ? draft.current : sourceDisplay,
|
displayValue: isEditing ? draft : sourceDisplay,
|
||||||
scrubTo: (value: number) => {
|
scrubTo: (value: number) => {
|
||||||
const parsed = parse(String(value));
|
const parsed = parse(String(value));
|
||||||
if (parsed !== null) onPreview(parsed);
|
if (parsed !== null) onPreview(parsed);
|
||||||
},
|
},
|
||||||
commitScrub: onCommit,
|
commitScrub: onCommit,
|
||||||
onFocus: () => {
|
onFocus: () => {
|
||||||
isEditing.current = true;
|
setIsEditing(true);
|
||||||
draft.current = sourceDisplay;
|
setDraft(sourceDisplay);
|
||||||
forceRender();
|
onStartEditing?.();
|
||||||
},
|
},
|
||||||
onChange: (
|
onChange: (
|
||||||
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
) => {
|
) => {
|
||||||
draft.current = event.target.value;
|
const nextDraft = event.target.value;
|
||||||
forceRender();
|
setDraft(nextDraft);
|
||||||
|
|
||||||
const parsed = parse(event.target.value);
|
const parsed = parse(nextDraft);
|
||||||
if (parsed !== null) {
|
if (parsed !== null) {
|
||||||
onPreview(parsed);
|
onPreview(parsed);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onBlur: () => {
|
onBlur: (
|
||||||
if (
|
event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
supportsExpressions &&
|
) => {
|
||||||
looksLikeExpression({ input: draft.current })
|
const nextDraft = event.target.value;
|
||||||
) {
|
if (supportsExpressions && looksLikeExpression({ input: nextDraft })) {
|
||||||
const evaluated = evaluateMathExpression({ input: draft.current });
|
const evaluated = evaluateMathExpression({ input: nextDraft });
|
||||||
if (evaluated !== null) {
|
if (evaluated !== null) {
|
||||||
const parsed = parse(String(evaluated));
|
const parsed = parse(String(evaluated));
|
||||||
if (parsed !== null) onPreview(parsed);
|
if (parsed !== null) onPreview(parsed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onCommit();
|
onCommit();
|
||||||
isEditing.current = false;
|
setIsEditing(false);
|
||||||
draft.current = "";
|
setDraft("");
|
||||||
forceRender();
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,12 @@ export function PropertiesPanel() {
|
||||||
<Button
|
<Button
|
||||||
variant={tab.id === activeTab.id ? "secondary" : "ghost"}
|
variant={tab.id === activeTab.id ? "secondary" : "ghost"}
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setActiveTab(element.type, tab.id)}
|
onClick={() =>
|
||||||
|
setActiveTab({
|
||||||
|
elementType: element.type,
|
||||||
|
tabId: tab.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
aria-label={tab.label}
|
aria-label={tab.label}
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0",
|
"shrink-0",
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,18 @@ import { create } from "zustand";
|
||||||
|
|
||||||
interface PropertiesState {
|
interface PropertiesState {
|
||||||
activeTabPerType: Record<string, string>;
|
activeTabPerType: Record<string, string>;
|
||||||
setActiveTab: (elementType: string, tabId: string) => void;
|
setActiveTab: (args: { elementType: string; tabId: string }) => void;
|
||||||
isTransformScaleLocked: boolean;
|
isTransformScaleLocked: boolean;
|
||||||
setTransformScaleLocked: (locked: boolean) => void;
|
setTransformScaleLocked: (args: { locked: boolean }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePropertiesStore = create<PropertiesState>()((set) => ({
|
export const usePropertiesStore = create<PropertiesState>()((set) => ({
|
||||||
activeTabPerType: {},
|
activeTabPerType: {},
|
||||||
setActiveTab: (elementType, tabId) =>
|
setActiveTab: ({ elementType, tabId }) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId },
|
activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId },
|
||||||
})),
|
})),
|
||||||
isTransformScaleLocked: false,
|
isTransformScaleLocked: false,
|
||||||
setTransformScaleLocked: (locked) => set({ isTransformScaleLocked: locked }),
|
setTransformScaleLocked: ({ locked }) =>
|
||||||
|
set({ isTransformScaleLocked: locked }),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export class EditorCore {
|
||||||
this.project = new ProjectManager(this);
|
this.project = new ProjectManager(this);
|
||||||
this.media = new MediaManager(this);
|
this.media = new MediaManager(this);
|
||||||
this.renderer = new RendererManager(this);
|
this.renderer = new RendererManager(this);
|
||||||
this.save = new SaveManager(this);
|
this.save = new SaveManager({ editor: this });
|
||||||
this.audio = new AudioManager(this);
|
this.audio = new AudioManager(this);
|
||||||
this.selection = new SelectionManager(this);
|
this.selection = new SelectionManager(this);
|
||||||
this.clipboard = new ClipboardManager(this);
|
this.clipboard = new ClipboardManager(this);
|
||||||
|
|
|
||||||
|
|
@ -68,9 +68,17 @@ export class MediaManager {
|
||||||
|
|
||||||
const command =
|
const command =
|
||||||
uniqueIds.length === 1
|
uniqueIds.length === 1
|
||||||
? new RemoveMediaAssetCommand(projectId, uniqueIds[0])
|
? new RemoveMediaAssetCommand({
|
||||||
|
projectId,
|
||||||
|
assetId: uniqueIds[0],
|
||||||
|
})
|
||||||
: new BatchCommand(
|
: new BatchCommand(
|
||||||
uniqueIds.map((id) => new RemoveMediaAssetCommand(projectId, id)),
|
uniqueIds.map((id) =>
|
||||||
|
new RemoveMediaAssetCommand({
|
||||||
|
projectId,
|
||||||
|
assetId: id,
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,18 @@ export class SaveManager {
|
||||||
private saveTimer: ReturnType<typeof setTimeout> | null = null;
|
private saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private unsubscribeHandlers: Array<() => void> = [];
|
private unsubscribeHandlers: Array<() => void> = [];
|
||||||
|
|
||||||
constructor(
|
constructor({
|
||||||
private editor: EditorCore,
|
editor,
|
||||||
{ debounceMs = 800 }: SaveManagerOptions = {},
|
debounceMs = 800,
|
||||||
) {
|
}: {
|
||||||
|
editor: EditorCore;
|
||||||
|
} & SaveManagerOptions) {
|
||||||
|
this.editor = editor;
|
||||||
this.debounceMs = debounceMs;
|
this.debounceMs = debounceMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private editor: EditorCore;
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.unsubscribeHandlers.length > 0) return;
|
if (this.unsubscribeHandlers.length > 0) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export class ScenesManager {
|
||||||
throw new Error("No active project");
|
throw new Error("No active project");
|
||||||
}
|
}
|
||||||
|
|
||||||
const command = new CreateSceneCommand(name, isMain);
|
const command = new CreateSceneCommand({ name, isMain });
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
return command.getSceneId();
|
return command.getSceneId();
|
||||||
}
|
}
|
||||||
|
|
@ -77,7 +77,10 @@ export class ScenesManager {
|
||||||
throw new Error("No active project");
|
throw new Error("No active project");
|
||||||
}
|
}
|
||||||
|
|
||||||
const command = new RenameSceneCommand(sceneId, name);
|
const command = new RenameSceneCommand({
|
||||||
|
sceneId,
|
||||||
|
newName: name,
|
||||||
|
});
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,7 +141,7 @@ export class ScenesManager {
|
||||||
time: MediaTime;
|
time: MediaTime;
|
||||||
updates: Partial<Omit<Bookmark, "time">>;
|
updates: Partial<Omit<Bookmark, "time">>;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const command = new UpdateBookmarkCommand(time, updates);
|
const command = new UpdateBookmarkCommand({ time, updates });
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,7 +152,7 @@ export class ScenesManager {
|
||||||
fromTime: MediaTime;
|
fromTime: MediaTime;
|
||||||
toTime: MediaTime;
|
toTime: MediaTime;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const command = new MoveBookmarkCommand(fromTime, toTime);
|
const command = new MoveBookmarkCommand({ fromTime, toTime });
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ export class TimelineManager {
|
||||||
constructor(private editor: EditorCore) {}
|
constructor(private editor: EditorCore) {}
|
||||||
|
|
||||||
addTrack({ type, index }: { type: TrackType; index?: number }): string {
|
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 });
|
this.editor.command.execute({ command });
|
||||||
return command.getTrackId();
|
return command.getTrackId();
|
||||||
}
|
}
|
||||||
|
|
@ -751,7 +751,10 @@ export class TimelineManager {
|
||||||
}
|
}
|
||||||
const afterTracks =
|
const afterTracks =
|
||||||
this.previewTracks ?? this.applyPreviewOverlay(committedTracks);
|
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.editor.command.push({ command });
|
||||||
this.previewOverlay.clear();
|
this.previewOverlay.clear();
|
||||||
this.previewTracks = null;
|
this.previewTracks = null;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export type PanelId = keyof PanelSizes;
|
||||||
|
|
||||||
interface PanelState {
|
interface PanelState {
|
||||||
panels: PanelSizes;
|
panels: PanelSizes;
|
||||||
setPanel: (panel: PanelId, size: number) => void;
|
setPanel: (args: { panel: PanelId; size: number }) => void;
|
||||||
setPanels: (sizes: Partial<PanelSizes>) => void;
|
setPanels: (sizes: Partial<PanelSizes>) => void;
|
||||||
resetPanels: () => void;
|
resetPanels: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -23,7 +23,7 @@ export const usePanelStore = create<PanelState>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
...PANEL_CONFIG,
|
...PANEL_CONFIG,
|
||||||
setPanel: (panel, size) =>
|
setPanel: ({ panel, size }) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
panels: {
|
panels: {
|
||||||
...state.panels,
|
...state.panels,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
|
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
|
||||||
import { EditorCore } from "@/core";
|
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 (Object.is(a, b)) return true;
|
||||||
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
||||||
if (a.length !== b.length) return false;
|
if (a.length !== b.length) return false;
|
||||||
|
|
@ -16,10 +24,7 @@ export function useEditor<T>(
|
||||||
selector?: (editor: EditorCore) => T,
|
selector?: (editor: EditorCore) => T,
|
||||||
): EditorCore | T {
|
): EditorCore | T {
|
||||||
const editor = useMemo(() => EditorCore.getInstance(), []);
|
const editor = useMemo(() => EditorCore.getInstance(), []);
|
||||||
const selectorRef = useRef(selector);
|
const snapshotCacheRef = useRef<T | typeof SNAPSHOT_UNSET>(SNAPSHOT_UNSET);
|
||||||
selectorRef.current = selector;
|
|
||||||
|
|
||||||
const snapshotCacheRef = useRef<unknown>(undefined);
|
|
||||||
|
|
||||||
const subscribeAll = useCallback(
|
const subscribeAll = useCallback(
|
||||||
(onChange: () => void) => {
|
(onChange: () => void) => {
|
||||||
|
|
@ -43,18 +48,29 @@ export function useEditor<T>(
|
||||||
[editor],
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getSnapshot = useCallback(() => {
|
const getSnapshot = useCallback((): EditorCore | T => {
|
||||||
const next = selectorRef.current ? selectorRef.current(editor) : editor;
|
if (!selector) {
|
||||||
if (isShallowEqual(snapshotCacheRef.current, next)) {
|
return editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = selector(editor);
|
||||||
|
if (
|
||||||
|
snapshotCacheRef.current !== SNAPSHOT_UNSET &&
|
||||||
|
isShallowEqual({
|
||||||
|
a: snapshotCacheRef.current,
|
||||||
|
b: next,
|
||||||
|
})
|
||||||
|
) {
|
||||||
return snapshotCacheRef.current;
|
return snapshotCacheRef.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshotCacheRef.current = next;
|
snapshotCacheRef.current = next;
|
||||||
return next;
|
return next;
|
||||||
}, [editor]);
|
}, [editor, selector]);
|
||||||
|
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
selector ? subscribeAll : subscribeNone,
|
selector ? subscribeAll : subscribeNone,
|
||||||
getSnapshot,
|
getSnapshot,
|
||||||
getSnapshot,
|
getSnapshot,
|
||||||
) as EditorCore | T;
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ export function registerDefaultEffects(): void {
|
||||||
if (effectsRegistry.has(definition.type)) {
|
if (effectsRegistry.has(definition.type)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
effectsRegistry.register(definition.type, definition);
|
effectsRegistry.register({
|
||||||
|
key: definition.type,
|
||||||
|
definition,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,13 @@ export function frameRateToFloat(rate: FrameRate): number {
|
||||||
return rate.numerator / rate.denominator;
|
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;
|
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 ARBITRARY_DENOMINATOR = 1_000_000;
|
||||||
const scaledNumerator = Math.round(fps * ARBITRARY_DENOMINATOR);
|
const scaledNumerator = Math.round(fps * ARBITRARY_DENOMINATOR);
|
||||||
const divisor = gcd(scaledNumerator, ARBITRARY_DENOMINATOR);
|
const divisor = gcd({
|
||||||
|
left: scaledNumerator,
|
||||||
|
right: ARBITRARY_DENOMINATOR,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
numerator: scaledNumerator / divisor,
|
numerator: scaledNumerator / divisor,
|
||||||
denominator: ARBITRARY_DENOMINATOR / 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 a = Math.abs(left);
|
||||||
let b = Math.abs(right);
|
let b = Math.abs(right);
|
||||||
while (b !== 0) {
|
while (b !== 0) {
|
||||||
|
|
|
||||||
|
|
@ -1,239 +1,239 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
||||||
import {
|
import {
|
||||||
useKeyframedParamProperty,
|
useKeyframedParamProperty,
|
||||||
type KeyframedParamPropertyResult,
|
type KeyframedParamPropertyResult,
|
||||||
} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
|
} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
|
||||||
import type { ParamDefinition, ParamValues } from "@/params";
|
import type { ParamDefinition, ParamValues } from "@/params";
|
||||||
import type { GraphicElement } from "@/timeline";
|
import type { GraphicElement } from "@/timeline";
|
||||||
import { graphicsRegistry, registerDefaultGraphics, resolveGraphicElementParamsAtTime } from "@/graphics";
|
import { graphicsRegistry, registerDefaultGraphics, resolveGraphicElementParamsAtTime } from "@/graphics";
|
||||||
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
|
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import {
|
import {
|
||||||
Section,
|
Section,
|
||||||
SectionContent,
|
SectionContent,
|
||||||
SectionFields,
|
SectionFields,
|
||||||
SectionHeader,
|
SectionHeader,
|
||||||
SectionTitle,
|
SectionTitle,
|
||||||
} from "@/components/section";
|
} from "@/components/section";
|
||||||
import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
|
import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
|
import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
import type { MediaTime } from "@/wasm";
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
registerDefaultGraphics();
|
registerDefaultGraphics();
|
||||||
|
|
||||||
const DEFAULT_STROKE_WIDTH = 2;
|
const DEFAULT_STROKE_WIDTH = 2;
|
||||||
|
|
||||||
export function GraphicTab({
|
export function GraphicTab({
|
||||||
element,
|
element,
|
||||||
trackId,
|
trackId,
|
||||||
}: {
|
}: {
|
||||||
element: GraphicElement;
|
element: GraphicElement;
|
||||||
trackId: string;
|
trackId: string;
|
||||||
}) {
|
}) {
|
||||||
const definition = graphicsRegistry.get(element.definitionId);
|
const definition = graphicsRegistry.get(element.definitionId);
|
||||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||||
startTime: element.startTime,
|
startTime: element.startTime,
|
||||||
duration: element.duration,
|
duration: element.duration,
|
||||||
});
|
});
|
||||||
const { renderElement } = useElementPreview({
|
const { renderElement } = useElementPreview({
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
fallback: element,
|
fallback: element,
|
||||||
});
|
});
|
||||||
|
|
||||||
const liveElement = renderElement as GraphicElement;
|
const liveElement = renderElement as GraphicElement;
|
||||||
const resolvedParams = resolveGraphicElementParamsAtTime({
|
const resolvedParams = resolveGraphicElementParamsAtTime({
|
||||||
element: liveElement,
|
element: liveElement,
|
||||||
localTime,
|
localTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
const shapeParams = definition.params.filter((p) => p.group !== "stroke");
|
const shapeParams = definition.params.filter((p) => p.group !== "stroke");
|
||||||
const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
|
const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<Section collapsible sectionKey={`${element.id}:graphic`}>
|
<Section collapsible sectionKey={`${element.id}:graphic`}>
|
||||||
<SectionHeader>
|
<SectionHeader>
|
||||||
<SectionTitle>{definition.name}</SectionTitle>
|
<SectionTitle>{definition.name}</SectionTitle>
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
<SectionFields>
|
<SectionFields>
|
||||||
{shapeParams.map((param) => (
|
{shapeParams.map((param) => (
|
||||||
<AnimatedGraphicParamField
|
<AnimatedGraphicParamField
|
||||||
key={param.key}
|
key={param.key}
|
||||||
param={param}
|
param={param}
|
||||||
trackId={trackId}
|
trackId={trackId}
|
||||||
element={liveElement}
|
element={liveElement}
|
||||||
localTime={localTime}
|
localTime={localTime}
|
||||||
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
|
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
|
||||||
resolvedParams={resolvedParams}
|
resolvedParams={resolvedParams}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SectionFields>
|
</SectionFields>
|
||||||
</SectionContent>
|
</SectionContent>
|
||||||
</Section>
|
</Section>
|
||||||
{hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
|
{hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StrokeSection({
|
function StrokeSection({
|
||||||
element,
|
element,
|
||||||
trackId,
|
trackId,
|
||||||
}: {
|
}: {
|
||||||
element: GraphicElement;
|
element: GraphicElement;
|
||||||
trackId: string;
|
trackId: string;
|
||||||
}) {
|
}) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const definition = graphicsRegistry.get(element.definitionId);
|
const definition = graphicsRegistry.get(element.definitionId);
|
||||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||||
startTime: element.startTime,
|
startTime: element.startTime,
|
||||||
duration: element.duration,
|
duration: element.duration,
|
||||||
});
|
});
|
||||||
const { renderElement } = useElementPreview({
|
const { renderElement } = useElementPreview({
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
fallback: element,
|
fallback: element,
|
||||||
});
|
});
|
||||||
|
|
||||||
const liveElement = renderElement as GraphicElement;
|
const liveElement = renderElement as GraphicElement;
|
||||||
const resolvedParams = resolveGraphicElementParamsAtTime({
|
const resolvedParams = resolveGraphicElementParamsAtTime({
|
||||||
element: liveElement,
|
element: liveElement,
|
||||||
localTime,
|
localTime,
|
||||||
});
|
});
|
||||||
const strokeParams = definition.params.filter((p) => p.group === "stroke");
|
const strokeParams = definition.params.filter((p) => p.group === "stroke");
|
||||||
const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
|
const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
|
||||||
const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
|
const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
|
||||||
|
|
||||||
const toggleStroke = () => {
|
const toggleStroke = () => {
|
||||||
if (isStrokeEnabled) {
|
if (isStrokeEnabled) {
|
||||||
lastStrokeWidth.current = Number(
|
lastStrokeWidth.current = Number(
|
||||||
element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
|
element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
|
||||||
);
|
);
|
||||||
editor.timeline.updateElements({
|
editor.timeline.updateElements({
|
||||||
updates: [
|
updates: [
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
patch: { params: { ...element.params, strokeWidth: 0 } },
|
patch: { params: { ...element.params, strokeWidth: 0 } },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
editor.timeline.updateElements({
|
editor.timeline.updateElements({
|
||||||
updates: [
|
updates: [
|
||||||
{
|
{
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
patch: {
|
patch: {
|
||||||
params: {
|
params: {
|
||||||
...element.params,
|
...element.params,
|
||||||
strokeWidth: lastStrokeWidth.current,
|
strokeWidth: lastStrokeWidth.current,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
collapsible
|
collapsible
|
||||||
defaultOpen={isStrokeEnabled}
|
defaultOpen={isStrokeEnabled}
|
||||||
sectionKey={`${element.id}:stroke`}
|
sectionKey={`${element.id}:stroke`}
|
||||||
>
|
>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
trailing={
|
trailing={
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
toggleStroke();
|
toggleStroke();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<HugeiconsIcon
|
<HugeiconsIcon
|
||||||
icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
|
icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SectionTitle>Stroke</SectionTitle>
|
<SectionTitle>Stroke</SectionTitle>
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
<SectionContent
|
<SectionContent
|
||||||
className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
|
className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
|
||||||
>
|
>
|
||||||
<SectionFields>
|
<SectionFields>
|
||||||
{strokeParams.map((param) => (
|
{strokeParams.map((param) => (
|
||||||
<AnimatedGraphicParamField
|
<AnimatedGraphicParamField
|
||||||
key={param.key}
|
key={param.key}
|
||||||
param={param}
|
param={param}
|
||||||
trackId={trackId}
|
trackId={trackId}
|
||||||
element={liveElement}
|
element={liveElement}
|
||||||
localTime={localTime}
|
localTime={localTime}
|
||||||
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
|
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
|
||||||
resolvedParams={resolvedParams}
|
resolvedParams={resolvedParams}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SectionFields>
|
</SectionFields>
|
||||||
</SectionContent>
|
</SectionContent>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AnimatedGraphicParamField({
|
function AnimatedGraphicParamField({
|
||||||
param,
|
param,
|
||||||
trackId,
|
trackId,
|
||||||
element,
|
element,
|
||||||
localTime,
|
localTime,
|
||||||
isPlayheadWithinElementRange,
|
isPlayheadWithinElementRange,
|
||||||
resolvedParams,
|
resolvedParams,
|
||||||
}: {
|
}: {
|
||||||
key?: string;
|
key?: string;
|
||||||
param: ParamDefinition;
|
param: ParamDefinition;
|
||||||
trackId: string;
|
trackId: string;
|
||||||
element: GraphicElement;
|
element: GraphicElement;
|
||||||
localTime: MediaTime;
|
localTime: MediaTime;
|
||||||
isPlayheadWithinElementRange: boolean;
|
isPlayheadWithinElementRange: boolean;
|
||||||
resolvedParams: ParamValues;
|
resolvedParams: ParamValues;
|
||||||
}) {
|
}) {
|
||||||
const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
|
const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
|
||||||
{
|
{
|
||||||
param,
|
param,
|
||||||
trackId,
|
trackId,
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
animations: element.animations,
|
animations: element.animations,
|
||||||
localTime,
|
localTime,
|
||||||
isPlayheadWithinElementRange,
|
isPlayheadWithinElementRange,
|
||||||
resolvedValue: resolvedParams[param.key] ?? param.default,
|
resolvedValue: resolvedParams[param.key] ?? param.default,
|
||||||
buildBaseUpdates: ({ value }) => ({
|
buildBaseUpdates: ({ value }) => ({
|
||||||
params: {
|
params: {
|
||||||
...element.params,
|
...element.params,
|
||||||
[param.key]: value,
|
[param.key]: value,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PropertyParamField
|
<PropertyParamField
|
||||||
param={param}
|
param={param}
|
||||||
value={resolvedParams[param.key] ?? param.default}
|
value={resolvedParams[param.key] ?? param.default}
|
||||||
onPreview={animatedParam.onPreview}
|
onPreview={animatedParam.onPreview}
|
||||||
onCommit={animatedParam.onCommit}
|
onCommit={animatedParam.onCommit}
|
||||||
keyframe={{
|
keyframe={{
|
||||||
isActive: animatedParam.isKeyframedAtTime,
|
isActive: animatedParam.isKeyframedAtTime,
|
||||||
isDisabled: !isPlayheadWithinElementRange,
|
isDisabled: !isPlayheadWithinElementRange,
|
||||||
onToggle: animatedParam.toggleKeyframe,
|
onToggle: animatedParam.toggleKeyframe,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,10 @@ export function registerDefaultGraphics(): void {
|
||||||
if (graphicsRegistry.has(definition.id)) {
|
if (graphicsRegistry.has(definition.id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
graphicsRegistry.register(definition.id, definition);
|
graphicsRegistry.register({
|
||||||
|
key: definition.id,
|
||||||
|
definition,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,149 +1,155 @@
|
||||||
import { resolveGraphicParamsAtTime } from "@/animation";
|
import { resolveGraphicParamsAtTime } from "@/animation";
|
||||||
import type { ElementAnimations } from "@/animation/types";
|
import type { ElementAnimations } from "@/animation/types";
|
||||||
import { buildDefaultParamValues } from "@/params/registry";
|
import { buildDefaultParamValues } from "@/params/registry";
|
||||||
import type { ParamValues } from "@/params";
|
import type { ParamValues } from "@/params";
|
||||||
import { graphicsRegistry } from "./registry";
|
import { graphicsRegistry } from "./registry";
|
||||||
import {
|
import {
|
||||||
registerDefaultGraphics,
|
registerDefaultGraphics,
|
||||||
ellipseGraphicDefinition,
|
ellipseGraphicDefinition,
|
||||||
polygonGraphicDefinition,
|
polygonGraphicDefinition,
|
||||||
rectangleGraphicDefinition,
|
rectangleGraphicDefinition,
|
||||||
starGraphicDefinition,
|
starGraphicDefinition,
|
||||||
} from "./definitions";
|
} from "./definitions";
|
||||||
import {
|
import {
|
||||||
DEFAULT_GRAPHIC_SOURCE_SIZE,
|
DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||||
type GraphicInstance,
|
type GraphicInstance,
|
||||||
type GraphicDefinition,
|
type GraphicDefinition,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const graphicPreviewUrlCache = new Map<string, string>();
|
const graphicPreviewUrlCache = new Map<string, string>();
|
||||||
|
|
||||||
const FALLBACK_CORNER_RADIUS_RATIO = 0.2;
|
const FALLBACK_CORNER_RADIUS_RATIO = 0.2;
|
||||||
const FALLBACK_FILL_OPACITY = 0.08;
|
const FALLBACK_FILL_OPACITY = 0.08;
|
||||||
const FALLBACK_MIN_FONT_SIZE = 12;
|
const FALLBACK_MIN_FONT_SIZE = 12;
|
||||||
const FALLBACK_FONT_SIZE_RATIO = 0.15;
|
const FALLBACK_FONT_SIZE_RATIO = 0.15;
|
||||||
|
|
||||||
function buildFallbackPreviewUrl({
|
function buildFallbackPreviewUrl({
|
||||||
name,
|
name,
|
||||||
size,
|
size,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
size: number;
|
size: number;
|
||||||
}): string {
|
}): string {
|
||||||
const svg = `
|
const svg = `
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
<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}" />
|
<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>
|
<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>
|
</svg>
|
||||||
`;
|
`;
|
||||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGraphicDefinition({
|
export function getGraphicDefinition({
|
||||||
definitionId,
|
definitionId,
|
||||||
}: {
|
}: {
|
||||||
definitionId: string;
|
definitionId: string;
|
||||||
}): GraphicDefinition {
|
}): GraphicDefinition {
|
||||||
registerDefaultGraphics();
|
registerDefaultGraphics();
|
||||||
return graphicsRegistry.get(definitionId);
|
return graphicsRegistry.get(definitionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDefaultGraphicInstance({
|
export function buildDefaultGraphicInstance({
|
||||||
definitionId,
|
definitionId,
|
||||||
}: {
|
}: {
|
||||||
definitionId: string;
|
definitionId: string;
|
||||||
}): GraphicInstance {
|
}): GraphicInstance {
|
||||||
const definition = getGraphicDefinition({ definitionId });
|
const definition = getGraphicDefinition({ definitionId });
|
||||||
return {
|
return {
|
||||||
definitionId,
|
definitionId,
|
||||||
params: buildDefaultParamValues(definition.params),
|
params: buildDefaultParamValues(definition.params),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveGraphicParams(
|
export function resolveGraphicParams({
|
||||||
definition: GraphicDefinition,
|
definition,
|
||||||
params?: ParamValues,
|
params,
|
||||||
): ParamValues {
|
}: {
|
||||||
return {
|
definition: GraphicDefinition;
|
||||||
...buildDefaultParamValues(definition.params),
|
params?: ParamValues;
|
||||||
...(params ?? {}),
|
}): ParamValues {
|
||||||
};
|
return {
|
||||||
}
|
...buildDefaultParamValues(definition.params),
|
||||||
|
...(params ?? {}),
|
||||||
export function resolveGraphicElementParamsAtTime({
|
};
|
||||||
element,
|
}
|
||||||
localTime,
|
|
||||||
}: {
|
export function resolveGraphicElementParamsAtTime({
|
||||||
element: {
|
element,
|
||||||
definitionId: string;
|
localTime,
|
||||||
params: ParamValues;
|
}: {
|
||||||
animations?: ElementAnimations;
|
element: {
|
||||||
};
|
definitionId: string;
|
||||||
localTime: number;
|
params: ParamValues;
|
||||||
}): ParamValues {
|
animations?: ElementAnimations;
|
||||||
const definition = getGraphicDefinition({
|
};
|
||||||
definitionId: element.definitionId,
|
localTime: number;
|
||||||
});
|
}): ParamValues {
|
||||||
return resolveGraphicParamsAtTime({
|
const definition = getGraphicDefinition({
|
||||||
params: resolveGraphicParams(definition, element.params),
|
definitionId: element.definitionId,
|
||||||
definitions: definition.params,
|
});
|
||||||
animations: element.animations,
|
return resolveGraphicParamsAtTime({
|
||||||
localTime,
|
params: resolveGraphicParams({
|
||||||
});
|
definition,
|
||||||
}
|
params: element.params,
|
||||||
|
}),
|
||||||
export function buildGraphicPreviewUrl({
|
definitions: definition.params,
|
||||||
definitionId,
|
animations: element.animations,
|
||||||
params,
|
localTime,
|
||||||
size = DEFAULT_GRAPHIC_SOURCE_SIZE,
|
});
|
||||||
}: {
|
}
|
||||||
definitionId: string;
|
|
||||||
params?: ParamValues;
|
export function buildGraphicPreviewUrl({
|
||||||
size?: number;
|
definitionId,
|
||||||
}): string {
|
params,
|
||||||
const definition = getGraphicDefinition({ definitionId });
|
size = DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||||
const resolvedParams = resolveGraphicParams(definition, params);
|
}: {
|
||||||
const cacheKey = JSON.stringify({ definitionId, resolvedParams, size });
|
definitionId: string;
|
||||||
const cachedUrl = graphicPreviewUrlCache.get(cacheKey);
|
params?: ParamValues;
|
||||||
if (cachedUrl) {
|
size?: number;
|
||||||
return cachedUrl;
|
}): string {
|
||||||
}
|
const definition = getGraphicDefinition({ definitionId });
|
||||||
|
const resolvedParams = resolveGraphicParams({ definition, params });
|
||||||
if (typeof document === "undefined") {
|
const cacheKey = JSON.stringify({ definitionId, resolvedParams, size });
|
||||||
return buildFallbackPreviewUrl({ name: definition.name, size });
|
const cachedUrl = graphicPreviewUrlCache.get(cacheKey);
|
||||||
}
|
if (cachedUrl) {
|
||||||
|
return cachedUrl;
|
||||||
const canvas = document.createElement("canvas");
|
}
|
||||||
canvas.width = size;
|
|
||||||
canvas.height = size;
|
if (typeof document === "undefined") {
|
||||||
const ctx = canvas.getContext("2d");
|
return buildFallbackPreviewUrl({ name: definition.name, size });
|
||||||
if (!ctx) {
|
}
|
||||||
return buildFallbackPreviewUrl({ name: definition.name, size });
|
|
||||||
}
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = size;
|
||||||
definition.render({
|
canvas.height = size;
|
||||||
ctx,
|
const ctx = canvas.getContext("2d");
|
||||||
params: resolvedParams,
|
if (!ctx) {
|
||||||
width: size,
|
return buildFallbackPreviewUrl({ name: definition.name, size });
|
||||||
height: size,
|
}
|
||||||
});
|
|
||||||
|
definition.render({
|
||||||
const previewUrl = canvas.toDataURL("image/png");
|
ctx,
|
||||||
graphicPreviewUrlCache.set(cacheKey, previewUrl);
|
params: resolvedParams,
|
||||||
return previewUrl;
|
width: size,
|
||||||
}
|
height: size,
|
||||||
|
});
|
||||||
export {
|
|
||||||
DEFAULT_GRAPHIC_SOURCE_SIZE,
|
const previewUrl = canvas.toDataURL("image/png");
|
||||||
ellipseGraphicDefinition,
|
graphicPreviewUrlCache.set(cacheKey, previewUrl);
|
||||||
graphicsRegistry,
|
return previewUrl;
|
||||||
polygonGraphicDefinition,
|
}
|
||||||
rectangleGraphicDefinition,
|
|
||||||
registerDefaultGraphics,
|
export {
|
||||||
starGraphicDefinition,
|
DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||||
};
|
ellipseGraphicDefinition,
|
||||||
export type {
|
graphicsRegistry,
|
||||||
GraphicDefinition,
|
polygonGraphicDefinition,
|
||||||
GraphicInstance,
|
rectangleGraphicDefinition,
|
||||||
GraphicRenderContext,
|
registerDefaultGraphics,
|
||||||
} from "./types";
|
starGraphicDefinition,
|
||||||
|
};
|
||||||
|
export type {
|
||||||
|
GraphicDefinition,
|
||||||
|
GraphicInstance,
|
||||||
|
GraphicRenderContext,
|
||||||
|
} from "./types";
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useCommittedRef } from "@/hooks/use-committed-ref";
|
||||||
|
|
||||||
type FocusLockCursor = "text" | "default" | "pointer" | "crosshair";
|
type FocusLockCursor = "text" | "default" | "pointer" | "crosshair";
|
||||||
|
|
||||||
|
|
@ -37,8 +38,7 @@ export function useFocusLock<T extends HTMLElement = HTMLElement>({
|
||||||
allowSelector?: string;
|
allowSelector?: string;
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<T>(null);
|
const containerRef = useRef<T>(null);
|
||||||
const onDismissRef = useRef(onDismiss);
|
const onDismissRef = useCommittedRef(onDismiss);
|
||||||
onDismissRef.current = onDismiss;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isActive) return;
|
if (!isActive) return;
|
||||||
|
|
@ -53,10 +53,13 @@ export function useFocusLock<T extends HTMLElement = HTMLElement>({
|
||||||
|
|
||||||
const handleOutsidePointerDown = (event: PointerEvent) => {
|
const handleOutsidePointerDown = (event: PointerEvent) => {
|
||||||
if (event.button !== 0) return;
|
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 =
|
||||||
const isAllowedTarget = allowSelector && target?.closest(allowSelector);
|
allowSelector &&
|
||||||
|
target instanceof Element &&
|
||||||
|
target.closest(allowSelector);
|
||||||
if (isAllowedTarget) return;
|
if (isAllowedTarget) return;
|
||||||
|
|
||||||
onDismissRef.current();
|
onDismissRef.current();
|
||||||
|
|
@ -73,7 +76,7 @@ export function useFocusLock<T extends HTMLElement = HTMLElement>({
|
||||||
container.removeAttribute(DATA_ATTR);
|
container.removeAttribute(DATA_ATTR);
|
||||||
focusLockStyle.remove();
|
focusLockStyle.remove();
|
||||||
};
|
};
|
||||||
}, [isActive, cursor, allowSelector]);
|
}, [isActive, cursor, allowSelector, onDismissRef]);
|
||||||
|
|
||||||
return { containerRef };
|
return { containerRef };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -330,13 +330,27 @@ function clampUnit(value: number): number {
|
||||||
return Math.min(1, Math.max(0, value));
|
return Math.min(1, Math.max(0, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDistanceSquared(a: CanvasPoint, b: CanvasPoint): number {
|
function getDistanceSquared({
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
}: {
|
||||||
|
a: CanvasPoint;
|
||||||
|
b: CanvasPoint;
|
||||||
|
}): number {
|
||||||
const dx = a.x - b.x;
|
const dx = a.x - b.x;
|
||||||
const dy = a.y - b.y;
|
const dy = a.y - b.y;
|
||||||
return dx * dx + dy * dy;
|
return dx * dx + dy * dy;
|
||||||
}
|
}
|
||||||
|
|
||||||
function lerpPoint(a: CanvasPoint, b: CanvasPoint, t: number): CanvasPoint {
|
function lerpPoint({
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
a: CanvasPoint;
|
||||||
|
b: CanvasPoint;
|
||||||
|
t: number;
|
||||||
|
}): CanvasPoint {
|
||||||
return {
|
return {
|
||||||
x: a.x + (b.x - a.x) * t,
|
x: a.x + (b.x - a.x) * t,
|
||||||
y: a.y + (b.y - a.y) * t,
|
y: a.y + (b.y - a.y) * t,
|
||||||
|
|
@ -483,7 +497,10 @@ export function findClosestPointOnCustomMaskSegment({
|
||||||
|
|
||||||
const sampleCount = 24;
|
const sampleCount = 24;
|
||||||
let bestT = 0;
|
let bestT = 0;
|
||||||
let bestDistanceSquared = getDistanceSquared(canvasPoint, segment.start);
|
let bestDistanceSquared = getDistanceSquared({
|
||||||
|
a: canvasPoint,
|
||||||
|
b: segment.start,
|
||||||
|
});
|
||||||
|
|
||||||
for (let step = 0; step <= sampleCount; step++) {
|
for (let step = 0; step <= sampleCount; step++) {
|
||||||
const t = step / sampleCount;
|
const t = step / sampleCount;
|
||||||
|
|
@ -494,7 +511,7 @@ export function findClosestPointOnCustomMaskSegment({
|
||||||
p3: segment.end,
|
p3: segment.end,
|
||||||
t,
|
t,
|
||||||
});
|
});
|
||||||
const distanceSquared = getDistanceSquared(canvasPoint, point);
|
const distanceSquared = getDistanceSquared({ a: canvasPoint, b: point });
|
||||||
if (distanceSquared < bestDistanceSquared) {
|
if (distanceSquared < bestDistanceSquared) {
|
||||||
bestDistanceSquared = distanceSquared;
|
bestDistanceSquared = distanceSquared;
|
||||||
bestT = t;
|
bestT = t;
|
||||||
|
|
@ -516,7 +533,10 @@ export function findClosestPointOnCustomMaskSegment({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const distanceSquared = getDistanceSquared(canvasPoint, candidate.point);
|
const distanceSquared = getDistanceSquared({
|
||||||
|
a: canvasPoint,
|
||||||
|
b: candidate.point,
|
||||||
|
});
|
||||||
if (distanceSquared < bestDistanceSquared) {
|
if (distanceSquared < bestDistanceSquared) {
|
||||||
bestDistanceSquared = distanceSquared;
|
bestDistanceSquared = distanceSquared;
|
||||||
bestT = candidate.t;
|
bestT = candidate.t;
|
||||||
|
|
@ -573,12 +593,12 @@ export function insertPointIntoCustomMaskSegment({
|
||||||
y: endPoint.y + endPoint.inY,
|
y: endPoint.y + endPoint.inY,
|
||||||
};
|
};
|
||||||
const p3 = { x: endPoint.x, y: endPoint.y };
|
const p3 = { x: endPoint.x, y: endPoint.y };
|
||||||
const p01 = lerpPoint(p0, p1, clampedT);
|
const p01 = lerpPoint({ a: p0, b: p1, t: clampedT });
|
||||||
const p12 = lerpPoint(p1, p2, clampedT);
|
const p12 = lerpPoint({ a: p1, b: p2, t: clampedT });
|
||||||
const p23 = lerpPoint(p2, p3, clampedT);
|
const p23 = lerpPoint({ a: p2, b: p3, t: clampedT });
|
||||||
const p012 = lerpPoint(p01, p12, clampedT);
|
const p012 = lerpPoint({ a: p01, b: p12, t: clampedT });
|
||||||
const p123 = lerpPoint(p12, p23, clampedT);
|
const p123 = lerpPoint({ a: p12, b: p23, t: clampedT });
|
||||||
const splitPoint = lerpPoint(p012, p123, clampedT);
|
const splitPoint = lerpPoint({ a: p012, b: p123, t: clampedT });
|
||||||
|
|
||||||
const nextPoints = [...points];
|
const nextPoints = [...points];
|
||||||
nextPoints[indices.startIndex] = {
|
nextPoints[indices.startIndex] = {
|
||||||
|
|
|
||||||
|
|
@ -53,10 +53,13 @@ function splitLineGeometry({
|
||||||
return { normalX, normalY, lineX, lineY };
|
return { normalX, normalY, lineX, lineY };
|
||||||
}
|
}
|
||||||
|
|
||||||
function pointsEqual(
|
function pointsEqual({
|
||||||
a: { x: number; y: number },
|
a,
|
||||||
b: { x: number; y: number },
|
b,
|
||||||
): boolean {
|
}: {
|
||||||
|
a: { x: number; y: number };
|
||||||
|
b: { x: number; y: number };
|
||||||
|
}): boolean {
|
||||||
return (
|
return (
|
||||||
Math.abs(a.x - b.x) <= INTERSECTION_EPSILON &&
|
Math.abs(a.x - b.x) <= INTERSECTION_EPSILON &&
|
||||||
Math.abs(a.y - b.y) <= INTERSECTION_EPSILON
|
Math.abs(a.y - b.y) <= INTERSECTION_EPSILON
|
||||||
|
|
@ -101,7 +104,7 @@ export function getSplitMaskStrokeSegment({
|
||||||
y2,
|
y2,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!hit || intersections.some((point) => pointsEqual(point, hit))) {
|
if (!hit || intersections.some((point) => pointsEqual({ a: point, b: hit }))) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,13 +310,18 @@ export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
|
||||||
[0, height, 0, 0],
|
[0, height, 0, 0],
|
||||||
];
|
];
|
||||||
|
|
||||||
const isInsideHalfPlane = (x: number, y: number) =>
|
const isInsideHalfPlane = ({
|
||||||
halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0;
|
x,
|
||||||
|
y,
|
||||||
|
}: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}) => halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0;
|
||||||
|
|
||||||
const vertices: [number, number][] = [];
|
const vertices: [number, number][] = [];
|
||||||
for (const [x1, y1, x2, y2] of edges) {
|
for (const [x1, y1, x2, y2] of edges) {
|
||||||
const isVertex1Inside = isInsideHalfPlane(x1, y1);
|
const isVertex1Inside = isInsideHalfPlane({ x: x1, y: y1 });
|
||||||
const isVertex2Inside = isInsideHalfPlane(x2, y2);
|
const isVertex2Inside = isInsideHalfPlane({ x: x2, y: y2 });
|
||||||
|
|
||||||
if (isVertex1Inside && isVertex2Inside) {
|
if (isVertex1Inside && isVertex2Inside) {
|
||||||
vertices.push([x2, y2]);
|
vertices.push([x2, y2]);
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,10 @@ export class MasksRegistry extends DefinitionRegistry<
|
||||||
},
|
},
|
||||||
icon,
|
icon,
|
||||||
};
|
};
|
||||||
this.register(definition.type, withBaseParams);
|
this.register({
|
||||||
|
key: definition.type,
|
||||||
|
definition: withBaseParams,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,10 +68,10 @@ export function usePasteMedia() {
|
||||||
const startTime = editor.playback.getCurrentTime();
|
const startTime = editor.playback.getCurrentTime();
|
||||||
|
|
||||||
for (const asset of processedAssets) {
|
for (const asset of processedAssets) {
|
||||||
const addMediaCmd = new AddMediaAssetCommand(
|
const addMediaCmd = new AddMediaAssetCommand({
|
||||||
activeProject.metadata.id,
|
projectId: activeProject.metadata.id,
|
||||||
asset,
|
asset,
|
||||||
);
|
});
|
||||||
const assetId = addMediaCmd.getAssetId();
|
const assetId = addMediaCmd.getAssetId();
|
||||||
const duration =
|
const duration =
|
||||||
asset.duration != null
|
asset.duration != null
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,50 @@
|
||||||
export type ParamValues = Record<string, number | string | boolean>;
|
export type ParamValues = Record<string, number | string | boolean>;
|
||||||
|
|
||||||
export type ParamGroup = "stroke";
|
export type ParamGroup = "stroke";
|
||||||
|
|
||||||
interface BaseParamDefinition<TKey extends string = string> {
|
interface BaseParamDefinition<TKey extends string = string> {
|
||||||
key: TKey;
|
key: TKey;
|
||||||
label: string;
|
label: string;
|
||||||
group?: ParamGroup;
|
group?: ParamGroup;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NumberParamDefinition<TKey extends string = string>
|
export interface NumberParamDefinition<TKey extends string = string>
|
||||||
extends BaseParamDefinition<TKey> {
|
extends BaseParamDefinition<TKey> {
|
||||||
type: "number";
|
type: "number";
|
||||||
default: number;
|
default: number;
|
||||||
min: number;
|
min: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
step: number;
|
step: number;
|
||||||
/** When set, min/max/step are in display space. display = stored * displayMultiplier. */
|
/** When set, min/max/step are in display space. display = stored * displayMultiplier. */
|
||||||
displayMultiplier?: number;
|
displayMultiplier?: number;
|
||||||
/** Show as percentage of max. min/max/step/default stay in stored space. */
|
/** Show as percentage of max. min/max/step/default stay in stored space. */
|
||||||
unit?: "percent";
|
unit?: "percent";
|
||||||
/** Short label shown as the scrub handle icon in the number field (e.g. "W", "R"). */
|
/** Short label shown as the scrub handle icon in the number field (e.g. "W", "R"). */
|
||||||
shortLabel?: string;
|
shortLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BooleanParamDefinition<TKey extends string = string>
|
export interface BooleanParamDefinition<TKey extends string = string>
|
||||||
extends BaseParamDefinition<TKey> {
|
extends BaseParamDefinition<TKey> {
|
||||||
type: "boolean";
|
type: "boolean";
|
||||||
default: boolean;
|
default: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ColorParamDefinition<TKey extends string = string>
|
export interface ColorParamDefinition<TKey extends string = string>
|
||||||
extends BaseParamDefinition<TKey> {
|
extends BaseParamDefinition<TKey> {
|
||||||
type: "color";
|
type: "color";
|
||||||
default: string;
|
default: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectParamDefinition<TKey extends string = string>
|
export interface SelectParamDefinition<TKey extends string = string>
|
||||||
extends BaseParamDefinition<TKey> {
|
extends BaseParamDefinition<TKey> {
|
||||||
type: "select";
|
type: "select";
|
||||||
default: string;
|
default: string;
|
||||||
options: Array<{ value: string; label: string }>;
|
options: Array<{ value: string; label: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ParamDefinition<TKey extends string = string> =
|
export type ParamDefinition<TKey extends string = string> =
|
||||||
| NumberParamDefinition<TKey>
|
| NumberParamDefinition<TKey>
|
||||||
| BooleanParamDefinition<TKey>
|
| BooleanParamDefinition<TKey>
|
||||||
| ColorParamDefinition<TKey>
|
| ColorParamDefinition<TKey>
|
||||||
| SelectParamDefinition<TKey>;
|
| SelectParamDefinition<TKey>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,46 @@
|
||||||
import type { ParamDefinition, ParamValues } from "@/params";
|
import type { ParamDefinition, ParamValues } from "@/params";
|
||||||
|
|
||||||
export function buildDefaultParamValues(
|
export function buildDefaultParamValues(
|
||||||
params: ParamDefinition[],
|
params: ParamDefinition[],
|
||||||
): ParamValues {
|
): ParamValues {
|
||||||
const values: ParamValues = {};
|
const values: ParamValues = {};
|
||||||
for (const param of params) {
|
for (const param of params) {
|
||||||
values[param.key] = param.default;
|
values[param.key] = param.default;
|
||||||
}
|
}
|
||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DefinitionRegistry<TKey extends string, TDefinition> {
|
export class DefinitionRegistry<TKey extends string, TDefinition> {
|
||||||
private definitions = new Map<TKey, TDefinition>();
|
private definitions = new Map<TKey, TDefinition>();
|
||||||
private entityName: string;
|
private entityName: string;
|
||||||
|
|
||||||
constructor(entityName: string) {
|
constructor(entityName: string) {
|
||||||
this.entityName = entityName;
|
this.entityName = entityName;
|
||||||
}
|
}
|
||||||
|
|
||||||
register(key: TKey, definition: TDefinition): void {
|
register({
|
||||||
this.definitions.set(key, definition);
|
key,
|
||||||
}
|
definition,
|
||||||
|
}: {
|
||||||
has(key: TKey): boolean {
|
key: TKey;
|
||||||
return this.definitions.has(key);
|
definition: TDefinition;
|
||||||
}
|
}): void {
|
||||||
|
this.definitions.set(key, definition);
|
||||||
get(key: TKey): TDefinition {
|
}
|
||||||
const def = this.definitions.get(key);
|
|
||||||
if (!def) {
|
has(key: TKey): boolean {
|
||||||
throw new Error(`Unknown ${this.entityName}: ${key}`);
|
return this.definitions.has(key);
|
||||||
}
|
}
|
||||||
return def;
|
|
||||||
}
|
get(key: TKey): TDefinition {
|
||||||
|
const def = this.definitions.get(key);
|
||||||
getAll(): TDefinition[] {
|
if (!def) {
|
||||||
return Array.from(this.definitions.values());
|
throw new Error(`Unknown ${this.entityName}: ${key}`);
|
||||||
}
|
}
|
||||||
}
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(): TDefinition[] {
|
||||||
|
return Array.from(this.definitions.values());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,12 @@ import { toast } from "sonner";
|
||||||
|
|
||||||
export function PreviewContextMenu({
|
export function PreviewContextMenu({
|
||||||
onToggleFullscreen,
|
onToggleFullscreen,
|
||||||
containerRef,
|
container,
|
||||||
overlayControls,
|
overlayControls,
|
||||||
onOverlayVisibilityChange,
|
onOverlayVisibilityChange,
|
||||||
}: {
|
}: {
|
||||||
onToggleFullscreen: () => void;
|
onToggleFullscreen: () => void;
|
||||||
containerRef: React.RefObject<HTMLElement | null>;
|
container: HTMLElement | null;
|
||||||
overlayControls: PreviewOverlayControl[];
|
overlayControls: PreviewOverlayControl[];
|
||||||
onOverlayVisibilityChange: (params: {
|
onOverlayVisibilityChange: (params: {
|
||||||
overlayId: string;
|
overlayId: string;
|
||||||
|
|
@ -51,7 +51,7 @@ export function PreviewContextMenu({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenuContent className="w-56" container={containerRef.current}>
|
<ContextMenuContent className="w-56" container={container}>
|
||||||
<ContextMenuItem onClick={viewport.fitToScreen} inset>
|
<ContextMenuItem onClick={viewport.fitToScreen} inset>
|
||||||
Fit to screen
|
Fit to screen
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import useDeepCompareEffect from "use-deep-compare-effect";
|
import useDeepCompareEffect from "use-deep-compare-effect";
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { useRafLoop } from "@/hooks/use-raf-loop";
|
import { useRafLoop } from "@/hooks/use-raf-loop";
|
||||||
|
|
@ -68,15 +68,20 @@ export function PreviewPanel({
|
||||||
}) => void;
|
}) => void;
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||||
const { toggleFullscreen } = useFullscreen({ containerRef });
|
const { toggleFullscreen } = useFullscreen({ containerRef });
|
||||||
|
const handleContainerRef = useCallback((node: HTMLDivElement | null) => {
|
||||||
|
containerRef.current = node;
|
||||||
|
setContainer(node);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={handleContainerRef}
|
||||||
className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border"
|
className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border"
|
||||||
>
|
>
|
||||||
<PreviewCanvas
|
<PreviewCanvas
|
||||||
containerRef={containerRef}
|
container={container}
|
||||||
onToggleFullscreen={toggleFullscreen}
|
onToggleFullscreen={toggleFullscreen}
|
||||||
overlayControls={overlayControls}
|
overlayControls={overlayControls}
|
||||||
overlayInstances={overlayInstances}
|
overlayInstances={overlayInstances}
|
||||||
|
|
@ -117,13 +122,13 @@ function RenderTreeController() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function PreviewCanvas({
|
function PreviewCanvas({
|
||||||
containerRef,
|
container,
|
||||||
onToggleFullscreen,
|
onToggleFullscreen,
|
||||||
overlayControls,
|
overlayControls,
|
||||||
overlayInstances,
|
overlayInstances,
|
||||||
onOverlayVisibilityChange,
|
onOverlayVisibilityChange,
|
||||||
}: {
|
}: {
|
||||||
containerRef: React.RefObject<HTMLElement | null>;
|
container: HTMLElement | null;
|
||||||
onToggleFullscreen: () => void;
|
onToggleFullscreen: () => void;
|
||||||
overlayControls: PreviewOverlayControl[];
|
overlayControls: PreviewOverlayControl[];
|
||||||
overlayInstances: PreviewOverlayInstance[];
|
overlayInstances: PreviewOverlayInstance[];
|
||||||
|
|
@ -149,6 +154,7 @@ function PreviewCanvas({
|
||||||
viewportRef,
|
viewportRef,
|
||||||
viewportWidth: viewportSize.width,
|
viewportWidth: viewportSize.width,
|
||||||
});
|
});
|
||||||
|
const { canPan, panByScreenDelta, scaleZoom } = viewport;
|
||||||
|
|
||||||
const renderer = useMemo(() => {
|
const renderer = useMemo(() => {
|
||||||
return new CanvasRenderer({
|
return new CanvasRenderer({
|
||||||
|
|
@ -240,7 +246,7 @@ function PreviewCanvas({
|
||||||
Math.min(Math.abs(pendingZoomDelta), 30);
|
Math.min(Math.abs(pendingZoomDelta), 30);
|
||||||
const zoomFactor = Math.exp(-cappedDelta / 300);
|
const zoomFactor = Math.exp(-cappedDelta / 300);
|
||||||
|
|
||||||
viewport.scaleZoom({ factor: zoomFactor });
|
scaleZoom({ factor: zoomFactor });
|
||||||
pendingZoomDelta = 0;
|
pendingZoomDelta = 0;
|
||||||
zoomRafId = null;
|
zoomRafId = null;
|
||||||
});
|
});
|
||||||
|
|
@ -249,7 +255,7 @@ function PreviewCanvas({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!viewport.canPan) {
|
if (!canPan) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -263,7 +269,7 @@ function PreviewCanvas({
|
||||||
|
|
||||||
if (panRafId === null) {
|
if (panRafId === null) {
|
||||||
panRafId = requestAnimationFrame(() => {
|
panRafId = requestAnimationFrame(() => {
|
||||||
viewport.panByScreenDelta({
|
panByScreenDelta({
|
||||||
deltaX: pendingPanDeltaX,
|
deltaX: pendingPanDeltaX,
|
||||||
deltaY: pendingPanDeltaY,
|
deltaY: pendingPanDeltaY,
|
||||||
});
|
});
|
||||||
|
|
@ -290,7 +296,7 @@ function PreviewCanvas({
|
||||||
cancelAnimationFrame(panRafId);
|
cancelAnimationFrame(panRafId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [viewport.canPan, viewport.panByScreenDelta, viewport.scaleZoom]);
|
}, [canPan, panByScreenDelta, scaleZoom]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PreviewViewportProvider value={viewport}>
|
<PreviewViewportProvider value={viewport}>
|
||||||
|
|
@ -329,7 +335,7 @@ function PreviewCanvas({
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<PreviewContextMenu
|
<PreviewContextMenu
|
||||||
onToggleFullscreen={onToggleFullscreen}
|
onToggleFullscreen={onToggleFullscreen}
|
||||||
containerRef={containerRef}
|
container={container}
|
||||||
overlayControls={overlayControls}
|
overlayControls={overlayControls}
|
||||||
onOverlayVisibilityChange={onOverlayVisibilityChange}
|
onOverlayVisibilityChange={onOverlayVisibilityChange}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useCommittedRef } from "@/hooks/use-committed-ref";
|
||||||
import {
|
import {
|
||||||
canvasToOverlay,
|
canvasToOverlay,
|
||||||
getDisplayScale,
|
getDisplayScale,
|
||||||
|
|
@ -219,8 +220,7 @@ export function usePreviewViewportState({
|
||||||
}));
|
}));
|
||||||
const [isPanning, setIsPanning] = useState(false);
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
const panSessionRef = useRef<PanSession | null>(null);
|
const panSessionRef = useRef<PanSession | null>(null);
|
||||||
const centerRef = useRef(center);
|
const centerRef = useCommittedRef(center);
|
||||||
centerRef.current = center;
|
|
||||||
|
|
||||||
const fitScale = useMemo(
|
const fitScale = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -409,10 +409,10 @@ export function usePreviewViewportState({
|
||||||
pointerId: event.pointerId,
|
pointerId: event.pointerId,
|
||||||
};
|
};
|
||||||
setIsPanning(true);
|
setIsPanning(true);
|
||||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[zoom],
|
[centerRef, zoom],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePanPointerMove = useCallback(
|
const handlePanPointerMove = useCallback(
|
||||||
|
|
@ -451,13 +451,9 @@ export function usePreviewViewportState({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(event.currentTarget as HTMLElement).hasPointerCapture(
|
event.currentTarget.hasPointerCapture(panSession.pointerId)
|
||||||
panSession.pointerId,
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
(event.currentTarget as HTMLElement).releasePointerCapture(
|
event.currentTarget.releasePointerCapture(panSession.pointerId);
|
||||||
panSession.pointerId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
panSessionRef.current = null;
|
panSessionRef.current = null;
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,6 @@ export function TextEditOverlay({
|
||||||
transformOrigin: "center center",
|
transformOrigin: "center center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */}
|
|
||||||
<div
|
<div
|
||||||
ref={divRef}
|
ref={divRef}
|
||||||
contentEditable
|
contentEditable
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useReducer, useRef } from "react";
|
import { useEffect, useReducer, useState } from "react";
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
|
import { useCommittedRef } from "@/hooks/use-committed-ref";
|
||||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||||
import { usePreviewViewport } from "@/preview/components/preview-viewport";
|
import { usePreviewViewport } from "@/preview/components/preview-viewport";
|
||||||
import type { SnapLine } from "@/preview/preview-snap";
|
import type { SnapLine } from "@/preview/preview-snap";
|
||||||
|
|
@ -59,17 +60,10 @@ export function usePreviewInteraction({
|
||||||
onSnapLinesChange,
|
onSnapLinesChange,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const depsRef = useCommittedRef(deps) as PreviewInteractionDepsRef;
|
||||||
const depsRef = useRef<PreviewInteractionDeps>(deps);
|
const [controller] = useState(
|
||||||
depsRef.current = deps;
|
() => new PreviewInteractionController({ depsRef }),
|
||||||
|
);
|
||||||
const controllerRef = useRef<PreviewInteractionController | null>(null);
|
|
||||||
if (!controllerRef.current) {
|
|
||||||
controllerRef.current = new PreviewInteractionController({
|
|
||||||
depsRef: depsRef as PreviewInteractionDepsRef,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const controller = controllerRef.current;
|
|
||||||
|
|
||||||
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||||
useEffect(
|
useEffect(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useEffect, useReducer, useRef } from "react";
|
import { useEffect, useReducer, useState } from "react";
|
||||||
import { usePreviewViewport } from "@/preview/components/preview-viewport";
|
import { usePreviewViewport } from "@/preview/components/preview-viewport";
|
||||||
import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction";
|
import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction";
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
|
import { useCommittedRef } from "@/hooks/use-committed-ref";
|
||||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||||
import { registerCanceller } from "@/editor/cancel-interaction";
|
import { registerCanceller } from "@/editor/cancel-interaction";
|
||||||
import {
|
import {
|
||||||
|
|
@ -48,15 +49,10 @@ export function useTransformHandles({
|
||||||
onSnapLinesChange,
|
onSnapLinesChange,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const depsRef = useCommittedRef(deps);
|
||||||
const depsRef = useRef<TransformHandleDeps>(deps);
|
const [controller] = useState(
|
||||||
depsRef.current = deps;
|
() => new TransformHandleController({ depsRef }),
|
||||||
|
);
|
||||||
const controllerRef = useRef<TransformHandleController | null>(null);
|
|
||||||
if (!controllerRef.current) {
|
|
||||||
controllerRef.current = new TransformHandleController({ depsRef });
|
|
||||||
}
|
|
||||||
const controller = controllerRef.current;
|
|
||||||
|
|
||||||
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||||
useEffect(() => controller.subscribe(rerender), [controller]);
|
useEffect(() => controller.subscribe(rerender), [controller]);
|
||||||
|
|
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
import { useEditor } from "@/editor/use-editor";
|
|
||||||
import { clamp } from "@/utils/math";
|
|
||||||
import { NumberField } from "@/components/ui/number-field";
|
|
||||||
import { OcCheckerboardIcon } from "@/components/icons";
|
|
||||||
import { Fragment, useRef } from "react";
|
|
||||||
import { useMenuPreview } from "@/editor/use-menu-preview";
|
|
||||||
import {
|
|
||||||
Section,
|
|
||||||
SectionContent,
|
|
||||||
SectionField,
|
|
||||||
SectionHeader,
|
|
||||||
SectionTitle,
|
|
||||||
} from "@/components/section";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectSeparator,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import type { BlendMode } from "@/rendering";
|
|
||||||
import type { ElementType } from "@/timeline";
|
|
||||||
import type { ElementAnimations } from "@/animation/types";
|
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
|
||||||
import { RainDropIcon } from "@hugeicons/core-free-icons";
|
|
||||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
|
||||||
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
|
|
||||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
|
||||||
import { resolveOpacityAtTime } from "@/animation/values";
|
|
||||||
import { DEFAULTS } from "@/timeline/defaults";
|
|
||||||
import { isPropertyAtDefault } from "./transform-tab";
|
|
||||||
import type { MediaTime } from "@/wasm";
|
|
||||||
|
|
||||||
type BlendingElement = {
|
|
||||||
id: string;
|
|
||||||
opacity: number;
|
|
||||||
type: ElementType;
|
|
||||||
blendMode?: BlendMode;
|
|
||||||
startTime: MediaTime;
|
|
||||||
duration: MediaTime;
|
|
||||||
animations?: ElementAnimations;
|
|
||||||
};
|
|
||||||
|
|
||||||
const BLEND_MODE_GROUPS: { value: BlendMode; label: string }[][] = [
|
|
||||||
[{ value: "normal", label: "Normal" }],
|
|
||||||
[
|
|
||||||
{ value: "darken", label: "Darken" },
|
|
||||||
{ value: "multiply", label: "Multiply" },
|
|
||||||
{ value: "color-burn", label: "Color Burn" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ value: "lighten", label: "Lighten" },
|
|
||||||
{ value: "screen", label: "Screen" },
|
|
||||||
{ value: "plus-lighter", label: "Plus Lighter" },
|
|
||||||
{ value: "color-dodge", label: "Color Dodge" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ value: "overlay", label: "Overlay" },
|
|
||||||
{ value: "soft-light", label: "Soft Light" },
|
|
||||||
{ value: "hard-light", label: "Hard Light" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ value: "difference", label: "Difference" },
|
|
||||||
{ value: "exclusion", label: "Exclusion" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ value: "hue", label: "Hue" },
|
|
||||||
{ value: "saturation", label: "Saturation" },
|
|
||||||
{ value: "color", label: "Color" },
|
|
||||||
{ value: "luminosity", label: "Luminosity" },
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
export function BlendingTab({
|
|
||||||
element,
|
|
||||||
trackId,
|
|
||||||
}: {
|
|
||||||
element: BlendingElement;
|
|
||||||
trackId: string;
|
|
||||||
}) {
|
|
||||||
const editor = useEditor();
|
|
||||||
const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
|
|
||||||
const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
|
|
||||||
const committedBlendModeRef = useRef(blendMode);
|
|
||||||
if (!isPreviewActive) {
|
|
||||||
committedBlendModeRef.current = blendMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
onPointerLeave,
|
|
||||||
onOpenChange: handleBlendModeOpenChange,
|
|
||||||
markCommitted,
|
|
||||||
} = useMenuPreview();
|
|
||||||
|
|
||||||
const previewBlendMode = ({ value }: { value: BlendMode }) =>
|
|
||||||
editor.timeline.previewElements({
|
|
||||||
updates: [
|
|
||||||
{ trackId, elementId: element.id, updates: { blendMode: value } },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const commitBlendMode = (value: BlendMode) => {
|
|
||||||
if (editor.timeline.isPreviewActive()) {
|
|
||||||
editor.timeline.commitPreview();
|
|
||||||
} else {
|
|
||||||
editor.timeline.updateElements({
|
|
||||||
updates: [
|
|
||||||
{
|
|
||||||
trackId,
|
|
||||||
elementId: element.id,
|
|
||||||
patch: { blendMode: value },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
markCommitted();
|
|
||||||
};
|
|
||||||
|
|
||||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
|
||||||
startTime: element.startTime,
|
|
||||||
duration: element.duration,
|
|
||||||
});
|
|
||||||
const resolvedOpacity = resolveOpacityAtTime({
|
|
||||||
baseOpacity: element.opacity,
|
|
||||||
animations: element.animations,
|
|
||||||
localTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
const opacity = useKeyframedNumberProperty({
|
|
||||||
trackId,
|
|
||||||
elementId: element.id,
|
|
||||||
animations: element.animations,
|
|
||||||
propertyPath: "opacity",
|
|
||||||
localTime,
|
|
||||||
isPlayheadWithinElementRange,
|
|
||||||
displayValue: Math.round(resolvedOpacity * 100).toString(),
|
|
||||||
parse: (input) => {
|
|
||||||
const parsed = parseFloat(input);
|
|
||||||
if (Number.isNaN(parsed)) return null;
|
|
||||||
return clamp({ value: parsed, min: 0, max: 100 }) / 100;
|
|
||||||
},
|
|
||||||
valueAtPlayhead: resolvedOpacity,
|
|
||||||
step: 0.01,
|
|
||||||
buildBaseUpdates: ({ value }) => ({ opacity: value }),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section collapsible sectionKey={`${element.id}:blending`}>
|
|
||||||
<SectionHeader>
|
|
||||||
<SectionTitle>Blending</SectionTitle>
|
|
||||||
</SectionHeader>
|
|
||||||
<SectionContent>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<SectionField
|
|
||||||
label="Opacity"
|
|
||||||
className="w-1/2"
|
|
||||||
beforeLabel={
|
|
||||||
<KeyframeToggle
|
|
||||||
isActive={opacity.isKeyframedAtTime}
|
|
||||||
isDisabled={!isPlayheadWithinElementRange}
|
|
||||||
title="Toggle opacity keyframe"
|
|
||||||
onToggle={opacity.toggleKeyframe}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<NumberField
|
|
||||||
className="w-full"
|
|
||||||
icon={
|
|
||||||
<OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
|
|
||||||
}
|
|
||||||
value={opacity.displayValue}
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
onFocus={opacity.onFocus}
|
|
||||||
onChange={opacity.onChange}
|
|
||||||
onBlur={opacity.onBlur}
|
|
||||||
onScrub={opacity.scrubTo}
|
|
||||||
onScrubEnd={opacity.commitScrub}
|
|
||||||
onReset={() =>
|
|
||||||
opacity.commitValue({ value: DEFAULTS.element.opacity })
|
|
||||||
}
|
|
||||||
isDefault={isPropertyAtDefault({
|
|
||||||
hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
|
|
||||||
isPlayheadWithinElementRange,
|
|
||||||
resolvedValue: resolvedOpacity,
|
|
||||||
staticValue: element.opacity,
|
|
||||||
defaultValue: DEFAULTS.element.opacity,
|
|
||||||
})}
|
|
||||||
dragSensitivity="slow"
|
|
||||||
/>
|
|
||||||
</SectionField>
|
|
||||||
<SectionField label="Blend mode" className="w-1/2">
|
|
||||||
<Select
|
|
||||||
value={committedBlendModeRef.current}
|
|
||||||
onOpenChange={handleBlendModeOpenChange}
|
|
||||||
onValueChange={commitBlendMode}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
icon={<HugeiconsIcon icon={RainDropIcon} />}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<SelectValue placeholder="Select blend mode" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="w-36" onPointerLeave={onPointerLeave}>
|
|
||||||
{BLEND_MODE_GROUPS.map((group, groupIndex) => (
|
|
||||||
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
|
|
||||||
{group.map((option) => (
|
|
||||||
<SelectItem
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
onPointerEnter={() =>
|
|
||||||
previewBlendMode({ value: option.value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
|
|
||||||
<SelectSeparator />
|
|
||||||
) : null}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</SectionField>
|
|
||||||
</div>
|
|
||||||
</SectionContent>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,462 +0,0 @@
|
||||||
import { NumberField } from "@/components/ui/number-field";
|
|
||||||
import { useEditor } from "@/editor/use-editor";
|
|
||||||
import { clamp, isNearlyEqual } from "@/utils/math";
|
|
||||||
import type { VisualElement } from "@/timeline";
|
|
||||||
import {
|
|
||||||
Section,
|
|
||||||
SectionContent,
|
|
||||||
SectionField,
|
|
||||||
SectionFields,
|
|
||||||
SectionHeader,
|
|
||||||
SectionTitle,
|
|
||||||
} from "@/components/section";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
|
||||||
import {
|
|
||||||
ArrowExpandIcon,
|
|
||||||
Link05Icon,
|
|
||||||
RotateClockwiseIcon,
|
|
||||||
} from "@hugeicons/core-free-icons";
|
|
||||||
import {
|
|
||||||
getGroupKeyframesAtTime,
|
|
||||||
hasGroupKeyframeAtTime,
|
|
||||||
} from "@/animation";
|
|
||||||
import { resolveTransformAtTime } from "@/rendering/animation-values";
|
|
||||||
import { DEFAULTS } from "@/timeline/defaults";
|
|
||||||
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
|
|
||||||
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
|
|
||||||
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
|
|
||||||
import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
|
|
||||||
|
|
||||||
export function parseNumericInput({ input }: { input: string }): number | null {
|
|
||||||
const parsed = parseFloat(input);
|
|
||||||
return Number.isNaN(parsed) ? null : parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPropertyAtDefault({
|
|
||||||
hasAnimatedKeyframes,
|
|
||||||
isPlayheadWithinElementRange,
|
|
||||||
resolvedValue,
|
|
||||||
staticValue,
|
|
||||||
defaultValue,
|
|
||||||
}: {
|
|
||||||
hasAnimatedKeyframes: boolean;
|
|
||||||
isPlayheadWithinElementRange: boolean;
|
|
||||||
resolvedValue: number;
|
|
||||||
staticValue: number;
|
|
||||||
defaultValue: number;
|
|
||||||
}): boolean {
|
|
||||||
if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
|
|
||||||
return isNearlyEqual({
|
|
||||||
leftValue: resolvedValue,
|
|
||||||
rightValue: defaultValue,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return staticValue === defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TransformTab({
|
|
||||||
element,
|
|
||||||
trackId,
|
|
||||||
}: {
|
|
||||||
element: VisualElement;
|
|
||||||
trackId: string;
|
|
||||||
}) {
|
|
||||||
const editor = useEditor();
|
|
||||||
const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked);
|
|
||||||
const setTransformScaleLocked = usePropertiesStore(
|
|
||||||
(s) => s.setTransformScaleLocked,
|
|
||||||
);
|
|
||||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
|
||||||
startTime: element.startTime,
|
|
||||||
duration: element.duration,
|
|
||||||
});
|
|
||||||
const resolvedTransform = resolveTransformAtTime({
|
|
||||||
baseTransform: element.transform,
|
|
||||||
animations: element.animations,
|
|
||||||
localTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
const positionX = useKeyframedNumberProperty({
|
|
||||||
trackId,
|
|
||||||
elementId: element.id,
|
|
||||||
animations: element.animations,
|
|
||||||
propertyPath: "transform.positionX",
|
|
||||||
localTime,
|
|
||||||
isPlayheadWithinElementRange,
|
|
||||||
displayValue: Math.round(resolvedTransform.position.x).toString(),
|
|
||||||
parse: (input) => parseNumericInput({ input }),
|
|
||||||
valueAtPlayhead: resolvedTransform.position.x,
|
|
||||||
step: 1,
|
|
||||||
buildBaseUpdates: ({ value }) => ({
|
|
||||||
transform: {
|
|
||||||
...element.transform,
|
|
||||||
position: { ...element.transform.position, x: value },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const positionY = useKeyframedNumberProperty({
|
|
||||||
trackId,
|
|
||||||
elementId: element.id,
|
|
||||||
animations: element.animations,
|
|
||||||
propertyPath: "transform.positionY",
|
|
||||||
localTime,
|
|
||||||
isPlayheadWithinElementRange,
|
|
||||||
displayValue: Math.round(resolvedTransform.position.y).toString(),
|
|
||||||
parse: (input) => parseNumericInput({ input }),
|
|
||||||
valueAtPlayhead: resolvedTransform.position.y,
|
|
||||||
step: 1,
|
|
||||||
buildBaseUpdates: ({ value }) => ({
|
|
||||||
transform: {
|
|
||||||
...element.transform,
|
|
||||||
position: { ...element.transform.position, y: value },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const parseScale = (input: string) => {
|
|
||||||
const parsed = parseNumericInput({ input });
|
|
||||||
if (parsed === null) return null;
|
|
||||||
return parsed / 100;
|
|
||||||
};
|
|
||||||
|
|
||||||
const scaleX = useKeyframedNumberProperty({
|
|
||||||
trackId,
|
|
||||||
elementId: element.id,
|
|
||||||
animations: element.animations,
|
|
||||||
propertyPath: "transform.scaleX",
|
|
||||||
localTime,
|
|
||||||
isPlayheadWithinElementRange,
|
|
||||||
displayValue: Math.round(resolvedTransform.scaleX * 100).toString(),
|
|
||||||
parse: parseScale,
|
|
||||||
valueAtPlayhead: resolvedTransform.scaleX,
|
|
||||||
step: 0.01,
|
|
||||||
buildBaseUpdates: ({ value }) => ({
|
|
||||||
transform: {
|
|
||||||
...element.transform,
|
|
||||||
scaleX: value,
|
|
||||||
...(isScaleLocked ? { scaleY: value } : {}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
buildAdditionalKeyframes: isScaleLocked
|
|
||||||
? ({ value }) => [{ propertyPath: "transform.scaleY", value }]
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const scaleY = useKeyframedNumberProperty({
|
|
||||||
trackId,
|
|
||||||
elementId: element.id,
|
|
||||||
animations: element.animations,
|
|
||||||
propertyPath: "transform.scaleY",
|
|
||||||
localTime,
|
|
||||||
isPlayheadWithinElementRange,
|
|
||||||
displayValue: Math.round(resolvedTransform.scaleY * 100).toString(),
|
|
||||||
parse: parseScale,
|
|
||||||
valueAtPlayhead: resolvedTransform.scaleY,
|
|
||||||
step: 0.01,
|
|
||||||
buildBaseUpdates: ({ value }) => ({
|
|
||||||
transform: {
|
|
||||||
...element.transform,
|
|
||||||
scaleY: value,
|
|
||||||
...(isScaleLocked ? { scaleX: value } : {}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
buildAdditionalKeyframes: isScaleLocked
|
|
||||||
? ({ value }) => [{ propertyPath: "transform.scaleX", value }]
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const scaleFieldPropsX = {
|
|
||||||
value: scaleX.displayValue,
|
|
||||||
onFocus: scaleX.onFocus,
|
|
||||||
onChange: scaleX.onChange,
|
|
||||||
onBlur: scaleX.onBlur,
|
|
||||||
dragSensitivity: "slow" as const,
|
|
||||||
onScrub: scaleX.scrubTo,
|
|
||||||
onScrubEnd: scaleX.commitScrub,
|
|
||||||
onReset: () =>
|
|
||||||
scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }),
|
|
||||||
isDefault: isPropertyAtDefault({
|
|
||||||
hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes,
|
|
||||||
isPlayheadWithinElementRange,
|
|
||||||
resolvedValue: resolvedTransform.scaleX,
|
|
||||||
staticValue: element.transform.scaleX,
|
|
||||||
defaultValue: DEFAULTS.element.transform.scaleX,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const scaleFieldPropsY = {
|
|
||||||
value: scaleY.displayValue,
|
|
||||||
onFocus: scaleY.onFocus,
|
|
||||||
onChange: scaleY.onChange,
|
|
||||||
onBlur: scaleY.onBlur,
|
|
||||||
dragSensitivity: "slow" as const,
|
|
||||||
onScrub: scaleY.scrubTo,
|
|
||||||
onScrubEnd: scaleY.commitScrub,
|
|
||||||
onReset: () =>
|
|
||||||
scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }),
|
|
||||||
isDefault: isPropertyAtDefault({
|
|
||||||
hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes,
|
|
||||||
isPlayheadWithinElementRange,
|
|
||||||
resolvedValue: resolvedTransform.scaleY,
|
|
||||||
staticValue: element.transform.scaleY,
|
|
||||||
defaultValue: DEFAULTS.element.transform.scaleY,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const rotation = useKeyframedNumberProperty({
|
|
||||||
trackId,
|
|
||||||
elementId: element.id,
|
|
||||||
animations: element.animations,
|
|
||||||
propertyPath: "transform.rotate",
|
|
||||||
localTime,
|
|
||||||
isPlayheadWithinElementRange,
|
|
||||||
displayValue: Math.round(resolvedTransform.rotate).toString(),
|
|
||||||
parse: (input) => {
|
|
||||||
const parsed = parseNumericInput({ input });
|
|
||||||
if (parsed === null) return null;
|
|
||||||
return clamp({ value: parsed, min: -360, max: 360 });
|
|
||||||
},
|
|
||||||
valueAtPlayhead: resolvedTransform.rotate,
|
|
||||||
step: 1,
|
|
||||||
buildBaseUpdates: ({ value }) => ({
|
|
||||||
transform: {
|
|
||||||
...element.transform,
|
|
||||||
rotate: value,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasScaleKeyframe = hasGroupKeyframeAtTime({
|
|
||||||
animations: element.animations,
|
|
||||||
group: "transform.scale",
|
|
||||||
time: localTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleScaleKeyframe = () => {
|
|
||||||
if (!isPlayheadWithinElementRange) return;
|
|
||||||
const existing = getGroupKeyframesAtTime({
|
|
||||||
animations: element.animations,
|
|
||||||
group: "transform.scale",
|
|
||||||
time: localTime,
|
|
||||||
});
|
|
||||||
if (existing.length > 0) {
|
|
||||||
editor.timeline.removeKeyframes({
|
|
||||||
keyframes: existing.map((ref) => ({
|
|
||||||
trackId,
|
|
||||||
elementId: element.id,
|
|
||||||
...ref,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
editor.timeline.upsertKeyframes({
|
|
||||||
keyframes: [
|
|
||||||
{
|
|
||||||
trackId,
|
|
||||||
elementId: element.id,
|
|
||||||
propertyPath: "transform.scaleX",
|
|
||||||
time: localTime,
|
|
||||||
value: resolvedTransform.scaleX,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
trackId,
|
|
||||||
elementId: element.id,
|
|
||||||
propertyPath: "transform.scaleY",
|
|
||||||
time: localTime,
|
|
||||||
value: resolvedTransform.scaleY,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const scaleLockButton = (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={isScaleLocked ? "secondary" : "ghost"}
|
|
||||||
size="icon"
|
|
||||||
aria-pressed={isScaleLocked}
|
|
||||||
onClick={() => setTransformScaleLocked(!isScaleLocked)}
|
|
||||||
>
|
|
||||||
<HugeiconsIcon icon={Link05Icon} />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section collapsible sectionKey={`${element.id}:transform`}>
|
|
||||||
<SectionHeader>
|
|
||||||
<SectionTitle>Transform</SectionTitle>
|
|
||||||
</SectionHeader>
|
|
||||||
<SectionContent>
|
|
||||||
<SectionFields>
|
|
||||||
<div className="flex items-end gap-2">
|
|
||||||
{isScaleLocked ? (
|
|
||||||
<>
|
|
||||||
<SectionField
|
|
||||||
label="Scale"
|
|
||||||
className="min-w-0 flex-1"
|
|
||||||
beforeLabel={
|
|
||||||
<KeyframeToggle
|
|
||||||
isActive={hasScaleKeyframe}
|
|
||||||
isDisabled={!isPlayheadWithinElementRange}
|
|
||||||
title="Toggle scale keyframe"
|
|
||||||
onToggle={toggleScaleKeyframe}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<NumberField
|
|
||||||
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
|
|
||||||
{...scaleFieldPropsX}
|
|
||||||
/>
|
|
||||||
</SectionField>
|
|
||||||
{scaleLockButton}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<SectionField
|
|
||||||
label="Width"
|
|
||||||
className="min-w-0 flex-1"
|
|
||||||
beforeLabel={
|
|
||||||
<KeyframeToggle
|
|
||||||
isActive={scaleX.isKeyframedAtTime}
|
|
||||||
isDisabled={!isPlayheadWithinElementRange}
|
|
||||||
title="Toggle width scale keyframe"
|
|
||||||
onToggle={scaleX.toggleKeyframe}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<NumberField icon="W" {...scaleFieldPropsX} />
|
|
||||||
</SectionField>
|
|
||||||
{scaleLockButton}
|
|
||||||
<SectionField
|
|
||||||
label="Height"
|
|
||||||
className="min-w-0 flex-1"
|
|
||||||
beforeLabel={
|
|
||||||
<KeyframeToggle
|
|
||||||
isActive={scaleY.isKeyframedAtTime}
|
|
||||||
isDisabled={!isPlayheadWithinElementRange}
|
|
||||||
title="Toggle height scale keyframe"
|
|
||||||
onToggle={scaleY.toggleKeyframe}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<NumberField icon="H" {...scaleFieldPropsY} />
|
|
||||||
</SectionField>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-end gap-2">
|
|
||||||
<SectionField
|
|
||||||
label="X"
|
|
||||||
className="min-w-0 flex-1"
|
|
||||||
beforeLabel={
|
|
||||||
<KeyframeToggle
|
|
||||||
isActive={positionX.isKeyframedAtTime}
|
|
||||||
isDisabled={!isPlayheadWithinElementRange}
|
|
||||||
title="Toggle X position keyframe"
|
|
||||||
onToggle={positionX.toggleKeyframe}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<NumberField
|
|
||||||
icon="X"
|
|
||||||
value={positionX.displayValue}
|
|
||||||
onFocus={positionX.onFocus}
|
|
||||||
onChange={positionX.onChange}
|
|
||||||
onBlur={positionX.onBlur}
|
|
||||||
onScrub={positionX.scrubTo}
|
|
||||||
onScrubEnd={positionX.commitScrub}
|
|
||||||
onReset={() =>
|
|
||||||
positionX.commitValue({
|
|
||||||
value: DEFAULTS.element.transform.position.x,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
isDefault={isPropertyAtDefault({
|
|
||||||
hasAnimatedKeyframes: positionX.hasAnimatedKeyframes,
|
|
||||||
isPlayheadWithinElementRange,
|
|
||||||
resolvedValue: resolvedTransform.position.x,
|
|
||||||
staticValue: element.transform.position.x,
|
|
||||||
defaultValue: DEFAULTS.element.transform.position.x,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</SectionField>
|
|
||||||
<SectionField
|
|
||||||
label="Y"
|
|
||||||
className="min-w-0 flex-1"
|
|
||||||
beforeLabel={
|
|
||||||
<KeyframeToggle
|
|
||||||
isActive={positionY.isKeyframedAtTime}
|
|
||||||
isDisabled={!isPlayheadWithinElementRange}
|
|
||||||
title="Toggle Y position keyframe"
|
|
||||||
onToggle={positionY.toggleKeyframe}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<NumberField
|
|
||||||
icon="Y"
|
|
||||||
value={positionY.displayValue}
|
|
||||||
onFocus={positionY.onFocus}
|
|
||||||
onChange={positionY.onChange}
|
|
||||||
onBlur={positionY.onBlur}
|
|
||||||
onScrub={positionY.scrubTo}
|
|
||||||
onScrubEnd={positionY.commitScrub}
|
|
||||||
onReset={() =>
|
|
||||||
positionY.commitValue({
|
|
||||||
value: DEFAULTS.element.transform.position.y,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
isDefault={isPropertyAtDefault({
|
|
||||||
hasAnimatedKeyframes: positionY.hasAnimatedKeyframes,
|
|
||||||
isPlayheadWithinElementRange,
|
|
||||||
resolvedValue: resolvedTransform.position.y,
|
|
||||||
staticValue: element.transform.position.y,
|
|
||||||
defaultValue: DEFAULTS.element.transform.position.y,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</SectionField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SectionField
|
|
||||||
label="Rotation"
|
|
||||||
beforeLabel={
|
|
||||||
<KeyframeToggle
|
|
||||||
isActive={rotation.isKeyframedAtTime}
|
|
||||||
isDisabled={!isPlayheadWithinElementRange}
|
|
||||||
title="Toggle rotation keyframe"
|
|
||||||
onToggle={rotation.toggleKeyframe}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<NumberField
|
|
||||||
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
|
|
||||||
className="flex-none"
|
|
||||||
value={rotation.displayValue}
|
|
||||||
onFocus={rotation.onFocus}
|
|
||||||
onChange={rotation.onChange}
|
|
||||||
onBlur={rotation.onBlur}
|
|
||||||
dragSensitivity="slow"
|
|
||||||
onScrub={rotation.scrubTo}
|
|
||||||
onScrubEnd={rotation.commitScrub}
|
|
||||||
onReset={() =>
|
|
||||||
rotation.commitValue({
|
|
||||||
value: DEFAULTS.element.transform.rotate,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
isDefault={isPropertyAtDefault({
|
|
||||||
hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
|
|
||||||
isPlayheadWithinElementRange,
|
|
||||||
resolvedValue: resolvedTransform.rotate,
|
|
||||||
staticValue: element.transform.rotate,
|
|
||||||
defaultValue: DEFAULTS.element.transform.rotate,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SectionField>
|
|
||||||
</SectionFields>
|
|
||||||
</SectionContent>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -4,15 +4,40 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type {
|
import type {
|
||||||
BoxSelectionSnapshot,
|
BoxSelectionSnapshot,
|
||||||
ResolveIntersections,
|
ResolveIntersections,
|
||||||
|
SelectionBoxBounds,
|
||||||
} from "@/selection/types";
|
} from "@/selection/types";
|
||||||
|
|
||||||
interface SelectionBoxState<TId> extends BoxSelectionSnapshot<TId> {
|
interface SelectionBoxState<TId> extends BoxSelectionSnapshot<TId> {
|
||||||
startPos: { x: number; y: number };
|
startPos: { x: number; y: number };
|
||||||
currentPos: { x: number; y: number };
|
currentPos: { x: number; y: number };
|
||||||
|
bounds: SelectionBoxBounds | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isAdditive: boolean;
|
isAdditive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSelectionBoxBounds({
|
||||||
|
container,
|
||||||
|
startPos,
|
||||||
|
currentPos,
|
||||||
|
}: {
|
||||||
|
container: HTMLElement;
|
||||||
|
startPos: { x: number; y: number };
|
||||||
|
currentPos: { x: number; y: number };
|
||||||
|
}): SelectionBoxBounds {
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const startX = startPos.x - containerRect.left;
|
||||||
|
const startY = startPos.y - containerRect.top;
|
||||||
|
const currentX = currentPos.x - containerRect.left;
|
||||||
|
const currentY = currentPos.y - containerRect.top;
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: Math.min(startX, currentX),
|
||||||
|
top: Math.min(startY, currentY),
|
||||||
|
width: Math.abs(currentX - startX),
|
||||||
|
height: Math.abs(currentY - startY),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useBoxSelect<TId>({
|
export function useBoxSelect<TId>({
|
||||||
containerRef,
|
containerRef,
|
||||||
resolveIntersections,
|
resolveIntersections,
|
||||||
|
|
@ -40,33 +65,43 @@ export function useBoxSelect<TId>({
|
||||||
const [selectionBox, setSelectionBox] =
|
const [selectionBox, setSelectionBox] =
|
||||||
useState<SelectionBoxState<TId> | null>(null);
|
useState<SelectionBoxState<TId> | null>(null);
|
||||||
const justFinishedSelectingRef = useRef(false);
|
const justFinishedSelectingRef = useRef(false);
|
||||||
const shouldStartSelectionCheck = shouldStartSelection ?? (() => true);
|
|
||||||
const getIsAdditiveSelectionCheck =
|
|
||||||
getIsAdditiveSelection ??
|
|
||||||
((event: React.MouseEvent<Element>) => event.ctrlKey || event.metaKey);
|
|
||||||
|
|
||||||
const handleMouseDown = useCallback(
|
const handleMouseDown = useCallback(
|
||||||
(event: React.MouseEvent<Element>) => {
|
(event: React.MouseEvent<Element>) => {
|
||||||
const canStartSelection = shouldStartSelectionCheck(event);
|
const canStartSelection = shouldStartSelection
|
||||||
|
? shouldStartSelection(event)
|
||||||
|
: true;
|
||||||
if (!isEnabled || event.button !== 0 || !canStartSelection) {
|
if (!isEnabled || event.button !== 0 || !canStartSelection) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startPos = { x: event.clientX, y: event.clientY };
|
||||||
|
const container = containerRef.current;
|
||||||
setSelectionBox({
|
setSelectionBox({
|
||||||
startPos: { x: event.clientX, y: event.clientY },
|
startPos,
|
||||||
currentPos: { x: event.clientX, y: event.clientY },
|
currentPos: startPos,
|
||||||
|
bounds: container
|
||||||
|
? getSelectionBoxBounds({
|
||||||
|
container,
|
||||||
|
startPos,
|
||||||
|
currentPos: startPos,
|
||||||
|
})
|
||||||
|
: null,
|
||||||
isActive: false,
|
isActive: false,
|
||||||
isAdditive: getIsAdditiveSelectionCheck(event),
|
isAdditive: getIsAdditiveSelection
|
||||||
|
? getIsAdditiveSelection(event)
|
||||||
|
: event.ctrlKey || event.metaKey,
|
||||||
initialSelectedIds: selectedIds,
|
initialSelectedIds: selectedIds,
|
||||||
initialAnchorId: anchorId,
|
initialAnchorId: anchorId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
anchorId,
|
anchorId,
|
||||||
getIsAdditiveSelectionCheck,
|
containerRef,
|
||||||
|
getIsAdditiveSelection,
|
||||||
isEnabled,
|
isEnabled,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
shouldStartSelectionCheck,
|
shouldStartSelection,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -98,11 +133,20 @@ export function useBoxSelect<TId>({
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
|
const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
|
||||||
|
const currentPos = { x: clientX, y: clientY };
|
||||||
const deltaX = Math.abs(clientX - selectionBox.startPos.x);
|
const deltaX = Math.abs(clientX - selectionBox.startPos.x);
|
||||||
const deltaY = Math.abs(clientY - selectionBox.startPos.y);
|
const deltaY = Math.abs(clientY - selectionBox.startPos.y);
|
||||||
|
const container = containerRef.current;
|
||||||
const nextSelectionBox = {
|
const nextSelectionBox = {
|
||||||
...selectionBox,
|
...selectionBox,
|
||||||
currentPos: { x: clientX, y: clientY },
|
currentPos,
|
||||||
|
bounds: container
|
||||||
|
? getSelectionBoxBounds({
|
||||||
|
container,
|
||||||
|
startPos: selectionBox.startPos,
|
||||||
|
currentPos,
|
||||||
|
})
|
||||||
|
: null,
|
||||||
isActive: deltaX > 5 || deltaY > 5 || selectionBox.isActive,
|
isActive: deltaX > 5 || deltaY > 5 || selectionBox.isActive,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -133,26 +177,26 @@ export function useBoxSelect<TId>({
|
||||||
window.removeEventListener("mousemove", handleMouseMove);
|
window.removeEventListener("mousemove", handleMouseMove);
|
||||||
window.removeEventListener("mouseup", handleMouseUp);
|
window.removeEventListener("mouseup", handleMouseUp);
|
||||||
};
|
};
|
||||||
}, [selectionBox, updateSelection]);
|
}, [containerRef, selectionBox, updateSelection]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectionBox) {
|
if (!selectionBox) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
const previousBodyUserSelect = document.body.style.userSelect;
|
const previousBodyUserSelect = document.body.style.userSelect;
|
||||||
const previousContainerUserSelect =
|
const previousContainerUserSelect = container?.style.userSelect ?? "";
|
||||||
containerRef.current?.style.userSelect ?? "";
|
|
||||||
|
|
||||||
document.body.style.userSelect = "none";
|
document.body.style.userSelect = "none";
|
||||||
if (containerRef.current) {
|
if (container) {
|
||||||
containerRef.current.style.userSelect = "none";
|
container.style.userSelect = "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.userSelect = previousBodyUserSelect;
|
document.body.style.userSelect = previousBodyUserSelect;
|
||||||
if (containerRef.current) {
|
if (container) {
|
||||||
containerRef.current.style.userSelect = previousContainerUserSelect;
|
container.style.userSelect = previousContainerUserSelect;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [containerRef, selectionBox]);
|
}, [containerRef, selectionBox]);
|
||||||
|
|
@ -162,7 +206,10 @@ export function useBoxSelect<TId>({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectionBox,
|
selectionBox:
|
||||||
|
selectionBox?.isActive && selectionBox.bounds
|
||||||
|
? { bounds: selectionBox.bounds }
|
||||||
|
: null,
|
||||||
handleMouseDown,
|
handleMouseDown,
|
||||||
isSelecting: selectionBox?.isActive ?? false,
|
isSelecting: selectionBox?.isActive ?? false,
|
||||||
shouldIgnoreClick,
|
shouldIgnoreClick,
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,25 @@
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useCommittedRef } from "@/hooks/use-committed-ref";
|
||||||
import { useSelectionContext } from "@/selection/context";
|
import { useSelectionContext } from "@/selection/context";
|
||||||
import { activateScope, type ScopeEntry } from "@/selection/scope";
|
import { activateScope, type ScopeEntry } from "@/selection/scope";
|
||||||
|
|
||||||
export function useSelectionScope() {
|
export function useSelectionScope() {
|
||||||
const { selectedIds, clearSelection } = useSelectionContext();
|
const { selectedIds, clearSelection } = useSelectionContext();
|
||||||
const hasSelectionRef = useRef(selectedIds.length > 0);
|
|
||||||
const clearSelectionRef = useRef(clearSelection);
|
|
||||||
const hasSelection = selectedIds.length > 0;
|
const hasSelection = selectedIds.length > 0;
|
||||||
|
const hasSelectionRef = useCommittedRef(hasSelection);
|
||||||
hasSelectionRef.current = hasSelection;
|
const clearSelectionRef = useCommittedRef(clearSelection);
|
||||||
clearSelectionRef.current = clearSelection;
|
const [entry] = useState<ScopeEntry>(() => ({
|
||||||
|
|
||||||
const entryRef = useRef<ScopeEntry>({
|
|
||||||
hasSelection: () => hasSelectionRef.current,
|
hasSelection: () => hasSelectionRef.current,
|
||||||
clear: () => {
|
clear: () => {
|
||||||
clearSelectionRef.current();
|
clearSelectionRef.current();
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasSelection) {
|
if (!hasSelection) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return activateScope({ entry: entryRef.current });
|
return activateScope({ entry });
|
||||||
}, [hasSelection]);
|
}, [entry, hasSelection]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,13 @@ import { SELECTABLE_ITEM_ATTRIBUTE } from "@/selection/attributes";
|
||||||
import type { SelectableItemProps } from "@/selection/types";
|
import type { SelectableItemProps } from "@/selection/types";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
|
|
||||||
function setForwardedRef<T>(ref: React.ForwardedRef<T>, value: T | null) {
|
function setForwardedRef<T>({
|
||||||
|
ref,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
ref: React.ForwardedRef<T>;
|
||||||
|
value: T | null;
|
||||||
|
}) {
|
||||||
if (typeof ref === "function") {
|
if (typeof ref === "function") {
|
||||||
ref(value);
|
ref(value);
|
||||||
return;
|
return;
|
||||||
|
|
@ -55,7 +61,7 @@ export const SelectableItem = forwardRef<HTMLDivElement, SelectableItemProps>(
|
||||||
const handleRef = useCallback(
|
const handleRef = useCallback(
|
||||||
(element: HTMLDivElement | null) => {
|
(element: HTMLDivElement | null) => {
|
||||||
registerItem(id, element);
|
registerItem(id, element);
|
||||||
setForwardedRef(forwardedRef, element);
|
setForwardedRef({ ref: forwardedRef, value: element });
|
||||||
},
|
},
|
||||||
[forwardedRef, id, registerItem],
|
[forwardedRef, id, registerItem],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -167,14 +167,15 @@ export function SelectableSurface({
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { selectionBox, handleMouseDown, shouldIgnoreClick } = useBoxSelect({
|
const { selectionBox, handleMouseDown, isSelecting, shouldIgnoreClick } =
|
||||||
containerRef,
|
useBoxSelect({
|
||||||
resolveIntersections,
|
containerRef,
|
||||||
selectedIds: selectionState.selectedIds,
|
resolveIntersections,
|
||||||
anchorId: selectionState.anchorId,
|
selectedIds: selectionState.selectedIds,
|
||||||
onSelectionChange: handleBoxSelectionChange,
|
anchorId: selectionState.anchorId,
|
||||||
shouldStartSelection,
|
onSelectionChange: handleBoxSelectionChange,
|
||||||
});
|
shouldStartSelection,
|
||||||
|
});
|
||||||
|
|
||||||
const handleBackgroundClick = useCallback(
|
const handleBackgroundClick = useCallback(
|
||||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
|
@ -236,7 +237,7 @@ export function SelectableSurface({
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [getItemElement, onRevealComplete, revealId]);
|
}, [getItemElement, onRevealComplete, revealId]);
|
||||||
|
|
||||||
const isBoxSelecting = selectionBox?.isActive ?? false;
|
const isBoxSelecting = isSelecting;
|
||||||
|
|
||||||
const contextValue = useMemo(() => {
|
const contextValue = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
|
|
@ -276,12 +277,7 @@ export function SelectableSurface({
|
||||||
onKeyDown={handleBackgroundKeyDown}
|
onKeyDown={handleBackgroundKeyDown}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<SelectionBox
|
<SelectionBox bounds={selectionBox?.bounds ?? null} />
|
||||||
startPos={selectionBox?.startPos ?? null}
|
|
||||||
currentPos={selectionBox?.currentPos ?? null}
|
|
||||||
containerRef={containerRef}
|
|
||||||
isActive={selectionBox?.isActive ?? false}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</SelectionContext.Provider>
|
</SelectionContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,22 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import type { SelectionBoxBounds } from "@/selection/types";
|
||||||
|
|
||||||
interface SelectionBoxProps {
|
interface SelectionBoxProps {
|
||||||
startPos: { x: number; y: number } | null;
|
bounds: SelectionBoxBounds | null;
|
||||||
currentPos: { x: number; y: number } | null;
|
|
||||||
containerRef: React.RefObject<HTMLElement | null>;
|
|
||||||
isActive: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectionBox({
|
export function SelectionBox({ bounds }: SelectionBoxProps) {
|
||||||
startPos,
|
if (!bounds) return null;
|
||||||
currentPos,
|
|
||||||
containerRef,
|
|
||||||
isActive,
|
|
||||||
}: SelectionBoxProps) {
|
|
||||||
const selectionBoxStyle = useMemo(() => {
|
|
||||||
if (!isActive || !startPos || !currentPos || !containerRef.current) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerRect = containerRef.current.getBoundingClientRect();
|
|
||||||
const startX = startPos.x - containerRect.left;
|
|
||||||
const startY = startPos.y - containerRect.top;
|
|
||||||
const currentX = currentPos.x - containerRect.left;
|
|
||||||
const currentY = currentPos.y - containerRect.top;
|
|
||||||
|
|
||||||
const left = Math.min(startX, currentX);
|
|
||||||
const top = Math.min(startY, currentY);
|
|
||||||
const width = Math.abs(currentX - startX);
|
|
||||||
const height = Math.abs(currentY - startY);
|
|
||||||
|
|
||||||
return {
|
|
||||||
left: `${left}px`,
|
|
||||||
top: `${top}px`,
|
|
||||||
width: `${width}px`,
|
|
||||||
height: `${height}px`,
|
|
||||||
};
|
|
||||||
}, [containerRef, currentPos, isActive, startPos]);
|
|
||||||
|
|
||||||
if (!selectionBoxStyle) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={selectionBoxStyle}
|
style={{
|
||||||
|
left: `${bounds.left}px`,
|
||||||
|
top: `${bounds.top}px`,
|
||||||
|
width: `${bounds.width}px`,
|
||||||
|
height: `${bounds.height}px`,
|
||||||
|
}}
|
||||||
className="border-foreground/50 bg-foreground/5 pointer-events-none absolute z-50 border"
|
className="border-foreground/50 bg-foreground/5 pointer-events-none absolute z-50 border"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,13 @@ export interface BoxSelectionSnapshot<TId = string> {
|
||||||
initialAnchorId: TId | null;
|
initialAnchorId: TId | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SelectionBoxBounds {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BoxSelectionChange<TId = string>
|
export interface BoxSelectionChange<TId = string>
|
||||||
extends BoxSelectionSnapshot<TId> {
|
extends BoxSelectionSnapshot<TId> {
|
||||||
intersectedIds: TId[];
|
intersectedIds: TId[];
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { FrameRate } from "opencut-wasm";
|
import type { FrameRate } from "opencut-wasm";
|
||||||
import type { AnyBaseNode } from "./nodes/base-node";
|
import type { AnyBaseNode } from "./nodes/base-node";
|
||||||
|
import { createCanvasSurface } from "./canvas-utils";
|
||||||
import { buildFrameDescriptor } from "./compositor/frame-descriptor";
|
import { buildFrameDescriptor } from "./compositor/frame-descriptor";
|
||||||
import { wasmCompositor } from "./compositor/wasm-compositor";
|
import { wasmCompositor } from "./compositor/wasm-compositor";
|
||||||
import { resolveRenderTree } from "./resolve";
|
import { resolveRenderTree } from "./resolve";
|
||||||
|
|
@ -16,8 +17,8 @@ export type CanvasRendererParams = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export class CanvasRenderer {
|
export class CanvasRenderer {
|
||||||
canvas: OffscreenCanvas | HTMLCanvasElement;
|
canvas: OffscreenCanvas;
|
||||||
context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D;
|
context: OffscreenCanvasRenderingContext2D;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
fps: FrameRate;
|
fps: FrameRate;
|
||||||
|
|
@ -27,22 +28,9 @@ export class CanvasRenderer {
|
||||||
this.height = height;
|
this.height = height;
|
||||||
this.fps = fps;
|
this.fps = fps;
|
||||||
|
|
||||||
try {
|
const surface = createCanvasSurface({ width, height });
|
||||||
this.canvas = new OffscreenCanvas(width, height);
|
this.canvas = surface.canvas;
|
||||||
} catch {
|
this.context = surface.context;
|
||||||
this.canvas = document.createElement("canvas");
|
|
||||||
this.canvas.width = width;
|
|
||||||
this.canvas.height = height;
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = this.canvas.getContext("2d");
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("Failed to get canvas context");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.context = context as
|
|
||||||
| OffscreenCanvasRenderingContext2D
|
|
||||||
| CanvasRenderingContext2D;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getOutputCanvas(): HTMLCanvasElement {
|
getOutputCanvas(): HTMLCanvasElement {
|
||||||
|
|
@ -57,20 +45,9 @@ export class CanvasRenderer {
|
||||||
this.width = width;
|
this.width = width;
|
||||||
this.height = height;
|
this.height = height;
|
||||||
|
|
||||||
if (this.canvas instanceof OffscreenCanvas) {
|
const surface = createCanvasSurface({ width, height });
|
||||||
this.canvas = new OffscreenCanvas(width, height);
|
this.canvas = surface.canvas;
|
||||||
} else {
|
this.context = surface.context;
|
||||||
this.canvas.width = width;
|
|
||||||
this.canvas.height = height;
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = this.canvas.getContext("2d");
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("Failed to get canvas context");
|
|
||||||
}
|
|
||||||
this.context = context as
|
|
||||||
| OffscreenCanvasRenderingContext2D
|
|
||||||
| CanvasRenderingContext2D;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async render({ node, time }: { node: AnyBaseNode; time: number }) {
|
async render({ node, time }: { node: AnyBaseNode; time: number }) {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
export function createOffscreenCanvas({
|
export function createCanvasSurface({
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
}: {
|
}: {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}): OffscreenCanvas | HTMLCanvasElement {
|
}): {
|
||||||
try {
|
canvas: OffscreenCanvas;
|
||||||
return new OffscreenCanvas(width, height);
|
context: OffscreenCanvasRenderingContext2D;
|
||||||
} catch {
|
} {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = new OffscreenCanvas(width, height);
|
||||||
canvas.width = width;
|
const context = canvas.getContext("2d");
|
||||||
canvas.height = height;
|
if (!context) {
|
||||||
return canvas;
|
throw new Error("Failed to create 2D rendering context");
|
||||||
}
|
}
|
||||||
|
return { canvas, context };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { masksRegistry } from "@/masks";
|
||||||
import { incrementCounter } from "@/diagnostics/render-perf";
|
import { incrementCounter } from "@/diagnostics/render-perf";
|
||||||
import type { AnyBaseNode } from "../nodes/base-node";
|
import type { AnyBaseNode } from "../nodes/base-node";
|
||||||
import type { CanvasRenderer } from "../canvas-renderer";
|
import type { CanvasRenderer } from "../canvas-renderer";
|
||||||
import { createOffscreenCanvas } from "../canvas-utils";
|
import { createCanvasSurface } from "../canvas-utils";
|
||||||
import { BlurBackgroundNode } from "../nodes/blur-background-node";
|
import { BlurBackgroundNode } from "../nodes/blur-background-node";
|
||||||
import { ColorNode } from "../nodes/color-node";
|
import { ColorNode } from "../nodes/color-node";
|
||||||
import { EffectLayerNode } from "../nodes/effect-layer-node";
|
import { EffectLayerNode } from "../nodes/effect-layer-node";
|
||||||
|
|
@ -414,15 +414,11 @@ function buildMaskArtifacts({
|
||||||
const { width: canvasWidth, height: canvasHeight } = renderer;
|
const { width: canvasWidth, height: canvasHeight } = renderer;
|
||||||
const maskContentHash = `mask:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:direct=${shouldRenderMaskDirectly}`;
|
const maskContentHash = `mask:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:direct=${shouldRenderMaskDirectly}`;
|
||||||
const drawMask: TextureCanvasDrawFn = (ctx) => {
|
const drawMask: TextureCanvasDrawFn = (ctx) => {
|
||||||
const elementMaskCanvas = createOffscreenCanvas({
|
const { canvas: elementMaskCanvas, context: elementMaskCtx } =
|
||||||
width: Math.round(transform.width),
|
createCanvasSurface({
|
||||||
height: Math.round(transform.height),
|
width: Math.round(transform.width),
|
||||||
});
|
height: Math.round(transform.height),
|
||||||
const elementMaskCtx = elementMaskCanvas.getContext("2d") as
|
});
|
||||||
| CanvasRenderingContext2D
|
|
||||||
| OffscreenCanvasRenderingContext2D
|
|
||||||
| null;
|
|
||||||
if (!elementMaskCtx) return;
|
|
||||||
|
|
||||||
if (shouldRenderMaskDirectly && definition.renderer.renderMask) {
|
if (shouldRenderMaskDirectly && definition.renderer.renderMask) {
|
||||||
definition.renderer.renderMask({
|
definition.renderer.renderMask({
|
||||||
|
|
@ -465,15 +461,10 @@ function buildMaskArtifacts({
|
||||||
const strokeTextureId = `${path}:mask-stroke`;
|
const strokeTextureId = `${path}:mask-stroke`;
|
||||||
const strokeContentHash = `stroke:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}`;
|
const strokeContentHash = `stroke:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}`;
|
||||||
const drawStroke: TextureCanvasDrawFn = (ctx) => {
|
const drawStroke: TextureCanvasDrawFn = (ctx) => {
|
||||||
const strokeCanvas = createOffscreenCanvas({
|
const { canvas: strokeCanvas, context: strokeCtx } = createCanvasSurface({
|
||||||
width: Math.round(transform.width),
|
width: Math.round(transform.width),
|
||||||
height: Math.round(transform.height),
|
height: Math.round(transform.height),
|
||||||
});
|
});
|
||||||
const strokeCtx = strokeCanvas.getContext("2d") as
|
|
||||||
| CanvasRenderingContext2D
|
|
||||||
| OffscreenCanvasRenderingContext2D
|
|
||||||
| null;
|
|
||||||
if (!strokeCtx) return;
|
|
||||||
|
|
||||||
if (definition.renderer.renderStroke) {
|
if (definition.renderer.renderStroke) {
|
||||||
definition.renderer.renderStroke({
|
definition.renderer.renderStroke({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createOffscreenCanvas } from "./canvas-utils";
|
import { createCanvasSurface } from "./canvas-utils";
|
||||||
import { effectsRegistry, resolveEffectPasses } from "@/effects";
|
import { effectsRegistry, resolveEffectPasses } from "@/effects";
|
||||||
import { buildDefaultParamValues } from "@/params/registry";
|
import { buildDefaultParamValues } from "@/params/registry";
|
||||||
import type { ParamValues } from "@/params";
|
import type { ParamValues } from "@/params";
|
||||||
|
|
@ -8,7 +8,7 @@ const PREVIEW_SIZE = 160;
|
||||||
const PREVIEW_IMAGE_PATH = "/effects/preview.jpg";
|
const PREVIEW_IMAGE_PATH = "/effects/preview.jpg";
|
||||||
|
|
||||||
class EffectPreviewService {
|
class EffectPreviewService {
|
||||||
private testSourceCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
|
private testSourceCanvas: OffscreenCanvas | null = null;
|
||||||
private previewImageElement: HTMLImageElement | null = null;
|
private previewImageElement: HTMLImageElement | null = null;
|
||||||
private onReadyCallbacks = new Set<() => void>();
|
private onReadyCallbacks = new Set<() => void>();
|
||||||
|
|
||||||
|
|
@ -98,7 +98,7 @@ class EffectPreviewService {
|
||||||
}: {
|
}: {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}): OffscreenCanvas | HTMLCanvasElement | null {
|
}): OffscreenCanvas | null {
|
||||||
const isImageReady =
|
const isImageReady =
|
||||||
this.previewImageElement?.complete &&
|
this.previewImageElement?.complete &&
|
||||||
(this.previewImageElement.naturalWidth ?? 0) > 0;
|
(this.previewImageElement.naturalWidth ?? 0) > 0;
|
||||||
|
|
@ -106,15 +106,8 @@ class EffectPreviewService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const canvas = createOffscreenCanvas({ width, height });
|
const { canvas, context } = createCanvasSurface({ width, height });
|
||||||
const ctx = canvas.getContext("2d") as
|
context.drawImage(this.previewImageElement, 0, 0, width, height);
|
||||||
| CanvasRenderingContext2D
|
|
||||||
| OffscreenCanvasRenderingContext2D
|
|
||||||
| null;
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error("failed to get 2d context for test source");
|
|
||||||
}
|
|
||||||
ctx.drawImage(this.previewImageElement, 0, 0, width, height);
|
|
||||||
return canvas;
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,7 +117,7 @@ class EffectPreviewService {
|
||||||
}: {
|
}: {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}): CanvasImageSource | null {
|
}): OffscreenCanvas | null {
|
||||||
if (
|
if (
|
||||||
!this.testSourceCanvas ||
|
!this.testSourceCanvas ||
|
||||||
this.testSourceCanvas.width !== width ||
|
this.testSourceCanvas.width !== width ||
|
||||||
|
|
@ -141,17 +134,17 @@ class EffectPreviewService {
|
||||||
height,
|
height,
|
||||||
passes,
|
passes,
|
||||||
}: {
|
}: {
|
||||||
source: CanvasImageSource;
|
source: OffscreenCanvas;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
passes: ReturnType<typeof resolveEffectPasses>;
|
passes: ReturnType<typeof resolveEffectPasses>;
|
||||||
}): OffscreenCanvas | HTMLCanvasElement {
|
}): OffscreenCanvas {
|
||||||
return gpuRenderer.applyEffect({
|
return gpuRenderer.applyEffect({
|
||||||
source,
|
source,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
passes,
|
passes,
|
||||||
}) as OffscreenCanvas | HTMLCanvasElement;
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,23 +34,17 @@ export const gpuRenderer = {
|
||||||
height,
|
height,
|
||||||
passes,
|
passes,
|
||||||
}: {
|
}: {
|
||||||
source: CanvasImageSource;
|
source: OffscreenCanvas;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
passes: EffectPass[];
|
passes: EffectPass[];
|
||||||
}): CanvasImageSource {
|
}): OffscreenCanvas {
|
||||||
if (passes.length === 0 || !gpuAvailable) {
|
if (passes.length === 0 || !gpuAvailable) {
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceCanvas = ensureOffscreenCanvas({
|
|
||||||
source,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
label: "effect source",
|
|
||||||
});
|
|
||||||
return applyEffectPasses({
|
return applyEffectPasses({
|
||||||
source: sourceCanvas,
|
source,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
passes: serializeEffectPasses(passes),
|
passes: serializeEffectPasses(passes),
|
||||||
|
|
@ -63,23 +57,17 @@ export const gpuRenderer = {
|
||||||
height,
|
height,
|
||||||
feather,
|
feather,
|
||||||
}: {
|
}: {
|
||||||
maskCanvas: CanvasImageSource;
|
maskCanvas: OffscreenCanvas;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
feather: number;
|
feather: number;
|
||||||
}): CanvasImageSource {
|
}): OffscreenCanvas {
|
||||||
if (!gpuAvailable) {
|
if (!gpuAvailable) {
|
||||||
return maskCanvas;
|
return maskCanvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceCanvas = ensureOffscreenCanvas({
|
|
||||||
source: maskCanvas,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
label: "mask source",
|
|
||||||
});
|
|
||||||
return applyMaskFeatherWasm({
|
return applyMaskFeatherWasm({
|
||||||
mask: sourceCanvas,
|
mask: maskCanvas,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
feather,
|
feather,
|
||||||
|
|
@ -87,35 +75,6 @@ export const gpuRenderer = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function ensureOffscreenCanvas({
|
|
||||||
source,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
label,
|
|
||||||
}: {
|
|
||||||
source: CanvasImageSource;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
label: string;
|
|
||||||
}): OffscreenCanvas {
|
|
||||||
if (source instanceof OffscreenCanvas) {
|
|
||||||
return source;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof OffscreenCanvas === "undefined") {
|
|
||||||
throw new Error(`OffscreenCanvas is required for the GPU ${label}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvas = new OffscreenCanvas(width, height);
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
if (!context) {
|
|
||||||
throw new Error(`Failed to get 2d context for the GPU ${label}`);
|
|
||||||
}
|
|
||||||
context.clearRect(0, 0, width, height);
|
|
||||||
context.drawImage(source, 0, 0, width, height);
|
|
||||||
return canvas;
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeEffectPasses(passes: EffectPass[]) {
|
function serializeEffectPasses(passes: EffectPass[]) {
|
||||||
return passes.map((pass) => ({
|
return passes.map((pass) => ({
|
||||||
shader: pass.shader,
|
shader: pass.shader,
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,15 @@ export function applyMaskFeather({
|
||||||
height,
|
height,
|
||||||
feather,
|
feather,
|
||||||
}: {
|
}: {
|
||||||
maskCanvas: CanvasImageSource;
|
maskCanvas: OffscreenCanvas;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
feather: number;
|
feather: number;
|
||||||
}): OffscreenCanvas | HTMLCanvasElement {
|
}): OffscreenCanvas {
|
||||||
return gpuRenderer.applyMaskFeather({
|
return gpuRenderer.applyMaskFeather({
|
||||||
maskCanvas,
|
maskCanvas,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
feather,
|
feather,
|
||||||
}) as OffscreenCanvas | HTMLCanvasElement;
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createOffscreenCanvas } from "../canvas-utils";
|
import { createCanvasSurface } from "../canvas-utils";
|
||||||
import {
|
import {
|
||||||
DEFAULT_GRAPHIC_SOURCE_SIZE,
|
DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||||
getGraphicDefinition,
|
getGraphicDefinition,
|
||||||
|
|
@ -25,7 +25,7 @@ export class GraphicNode extends VisualNode<
|
||||||
ResolvedGraphicNodeState
|
ResolvedGraphicNodeState
|
||||||
> {
|
> {
|
||||||
private cachedKey: string | null = null;
|
private cachedKey: string | null = null;
|
||||||
private cachedSource: OffscreenCanvas | HTMLCanvasElement | null = null;
|
private cachedSource: OffscreenCanvas | null = null;
|
||||||
|
|
||||||
constructor(params: GraphicNodeParams) {
|
constructor(params: GraphicNodeParams) {
|
||||||
super(params);
|
super(params);
|
||||||
|
|
@ -36,7 +36,7 @@ export class GraphicNode extends VisualNode<
|
||||||
resolvedParams,
|
resolvedParams,
|
||||||
}: {
|
}: {
|
||||||
resolvedParams: ParamValues;
|
resolvedParams: ParamValues;
|
||||||
}): OffscreenCanvas | HTMLCanvasElement | null {
|
}): OffscreenCanvas {
|
||||||
const definition = getGraphicDefinition({
|
const definition = getGraphicDefinition({
|
||||||
definitionId: this.params.definitionId,
|
definitionId: this.params.definitionId,
|
||||||
});
|
});
|
||||||
|
|
@ -48,20 +48,13 @@ export class GraphicNode extends VisualNode<
|
||||||
return this.cachedSource;
|
return this.cachedSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
const canvas = createOffscreenCanvas({
|
const { canvas, context } = createCanvasSurface({
|
||||||
width: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
width: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||||
height: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
height: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||||
});
|
});
|
||||||
const ctx = canvas.getContext("2d") as
|
|
||||||
| CanvasRenderingContext2D
|
|
||||||
| OffscreenCanvasRenderingContext2D
|
|
||||||
| null;
|
|
||||||
if (!ctx) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
definition.render({
|
definition.render({
|
||||||
ctx,
|
ctx: context,
|
||||||
params: resolvedParams,
|
params: resolvedParams,
|
||||||
width: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
width: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||||
height: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
height: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,13 @@ export interface CachedImageSource {
|
||||||
|
|
||||||
const imageSourceCache = new Map<string, Promise<CachedImageSource>>();
|
const imageSourceCache = new Map<string, Promise<CachedImageSource>>();
|
||||||
|
|
||||||
export function loadImageSource(
|
export function loadImageSource({
|
||||||
url: string,
|
url,
|
||||||
maxSourceSize?: number,
|
maxSourceSize,
|
||||||
): Promise<CachedImageSource> {
|
}: {
|
||||||
|
url: string;
|
||||||
|
maxSourceSize?: number;
|
||||||
|
}): Promise<CachedImageSource> {
|
||||||
const cacheKey = `${url}::${maxSourceSize ?? "full"}`;
|
const cacheKey = `${url}::${maxSourceSize ?? "full"}`;
|
||||||
|
|
||||||
const cached = imageSourceCache.get(cacheKey);
|
const cached = imageSourceCache.get(cacheKey);
|
||||||
|
|
|
||||||
|
|
@ -235,10 +235,10 @@ async function resolveImageNode({
|
||||||
node: ImageNode;
|
node: ImageNode;
|
||||||
context: ResolveContext;
|
context: ResolveContext;
|
||||||
}): Promise<ResolvedVisualSourceNodeState | null> {
|
}): Promise<ResolvedVisualSourceNodeState | null> {
|
||||||
const source = await loadImageSource(
|
const source = await loadImageSource({
|
||||||
node.params.url,
|
url: node.params.url,
|
||||||
node.params.maxSourceSize,
|
maxSourceSize: node.params.maxSourceSize,
|
||||||
);
|
});
|
||||||
const visualState = resolveVisualState({
|
const visualState = resolveVisualState({
|
||||||
params: node.params,
|
params: node.params,
|
||||||
context,
|
context,
|
||||||
|
|
@ -434,7 +434,7 @@ async function resolveBackdropSource({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = await loadImageSource(node.params.url);
|
const source = await loadImageSource({ url: node.params.url });
|
||||||
return {
|
return {
|
||||||
source: source.source,
|
source: source.source,
|
||||||
width: source.width,
|
width: source.width,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,15 @@ export class IndexedDBAdapter<T> implements StorageAdapter<T> {
|
||||||
private storeName: string;
|
private storeName: string;
|
||||||
private version: number;
|
private version: number;
|
||||||
|
|
||||||
constructor(dbName: string, storeName: string, version = 1) {
|
constructor({
|
||||||
|
dbName,
|
||||||
|
storeName,
|
||||||
|
version = 1,
|
||||||
|
}: {
|
||||||
|
dbName: string;
|
||||||
|
storeName: string;
|
||||||
|
version?: number;
|
||||||
|
}) {
|
||||||
this.dbName = dbName;
|
this.dbName = dbName;
|
||||||
this.storeName = storeName;
|
this.storeName = storeName;
|
||||||
this.version = version;
|
this.version = version;
|
||||||
|
|
@ -39,7 +47,13 @@ export class IndexedDBAdapter<T> implements StorageAdapter<T> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(key: string, value: T): Promise<void> {
|
async set({
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
key: string;
|
||||||
|
value: T;
|
||||||
|
}): Promise<void> {
|
||||||
const db = await this.getDB();
|
const db = await this.getDB();
|
||||||
const transaction = db.transaction([this.storeName], "readwrite");
|
const transaction = db.transaction([this.storeName], "readwrite");
|
||||||
const store = transaction.objectStore(this.storeName);
|
const store = transaction.objectStore(this.storeName);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
/**
|
||||||
|
* Type-safe accessors for navigating migration output.
|
||||||
|
*
|
||||||
|
* Migrations input/output `Record<string, unknown>` because they handle
|
||||||
|
* potentially malformed data from older versions. Tests need to inspect
|
||||||
|
* specific properties of the migrated result without using type assertions.
|
||||||
|
*
|
||||||
|
* These helpers narrow through type guards (Array.isArray + a record
|
||||||
|
* predicate), so the file contains no unsafe assertions despite turning
|
||||||
|
* unknown into concrete shapes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function describe(value: unknown): string {
|
||||||
|
if (value === null) return "null";
|
||||||
|
if (Array.isArray(value)) return "array";
|
||||||
|
return typeof value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asRecord(value: unknown): Record<string, unknown> {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new Error(`Expected record, got ${describe(value)}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asArray(value: unknown): unknown[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new Error(`Expected array, got ${describe(value)}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asRecordArray(value: unknown): Record<string, unknown>[] {
|
||||||
|
return asArray(value).map(asRecord);
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
v0ProjectWithMetadata,
|
v0ProjectWithMetadata,
|
||||||
v1Project,
|
v1Project,
|
||||||
} from "./fixtures";
|
} from "./fixtures";
|
||||||
|
import { asArray, asRecord, asRecordArray } from "./helpers";
|
||||||
|
|
||||||
describe("V0 to V1 Migration", () => {
|
describe("V0 to V1 Migration", () => {
|
||||||
const fixedDate = new Date("2024-06-01T12:00:00.000Z");
|
const fixedDate = new Date("2024-06-01T12:00:00.000Z");
|
||||||
|
|
@ -22,7 +23,7 @@ describe("V0 to V1 Migration", () => {
|
||||||
expect(result.skipped).toBe(false);
|
expect(result.skipped).toBe(false);
|
||||||
expect(result.project.version).toBe(1);
|
expect(result.project.version).toBe(1);
|
||||||
expect(Array.isArray(result.project.scenes)).toBe(true);
|
expect(Array.isArray(result.project.scenes)).toBe(true);
|
||||||
expect((result.project.scenes as unknown[]).length).toBe(1);
|
expect(asArray(result.project.scenes).length).toBe(1);
|
||||||
expect(result.project.currentSceneId).toBeDefined();
|
expect(result.project.currentSceneId).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -32,7 +33,7 @@ describe("V0 to V1 Migration", () => {
|
||||||
options: { now: fixedDate },
|
options: { now: fixedDate },
|
||||||
});
|
});
|
||||||
|
|
||||||
const scenes = result.project.scenes as Array<Record<string, unknown>>;
|
const scenes = asRecordArray(result.project.scenes);
|
||||||
const mainScene = scenes[0];
|
const mainScene = scenes[0];
|
||||||
|
|
||||||
expect(mainScene.isMain).toBe(true);
|
expect(mainScene.isMain).toBe(true);
|
||||||
|
|
@ -48,7 +49,7 @@ describe("V0 to V1 Migration", () => {
|
||||||
options: { now: fixedDate },
|
options: { now: fixedDate },
|
||||||
});
|
});
|
||||||
|
|
||||||
const metadata = result.project.metadata as Record<string, unknown>;
|
const metadata = asRecord(result.project.metadata);
|
||||||
expect(metadata.updatedAt).toBe(fixedDate.toISOString());
|
expect(metadata.updatedAt).toBe(fixedDate.toISOString());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
v1ProjectWithMultipleScenes,
|
v1ProjectWithMultipleScenes,
|
||||||
v2Project,
|
v2Project,
|
||||||
} from "./fixtures";
|
} from "./fixtures";
|
||||||
|
import { asRecord, asRecordArray } from "./helpers";
|
||||||
|
|
||||||
const DEFAULT_FPS = 30;
|
const DEFAULT_FPS = 30;
|
||||||
const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10;
|
const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10;
|
||||||
|
|
@ -25,7 +26,7 @@ describe("V1 to V2 Migration", () => {
|
||||||
expect(result.skipped).toBe(false);
|
expect(result.skipped).toBe(false);
|
||||||
expect(result.project.version).toBe(2);
|
expect(result.project.version).toBe(2);
|
||||||
|
|
||||||
const metadata = result.project.metadata as Record<string, unknown>;
|
const metadata = asRecord(result.project.metadata);
|
||||||
expect(metadata.id).toBe(v1Project.id);
|
expect(metadata.id).toBe(v1Project.id);
|
||||||
expect(metadata.name).toBe(v1Project.name);
|
expect(metadata.name).toBe(v1Project.name);
|
||||||
expect(typeof metadata.createdAt).toBe("string");
|
expect(typeof metadata.createdAt).toBe("string");
|
||||||
|
|
@ -35,7 +36,7 @@ describe("V1 to V2 Migration", () => {
|
||||||
test("creates settings object from flat properties", () => {
|
test("creates settings object from flat properties", () => {
|
||||||
const result = transformProjectV1ToV2({ project: v1Project });
|
const result = transformProjectV1ToV2({ project: v1Project });
|
||||||
|
|
||||||
const settings = result.project.settings as Record<string, unknown>;
|
const settings = asRecord(result.project.settings);
|
||||||
expect(settings.fps).toBe(v1Project.fps);
|
expect(settings.fps).toBe(v1Project.fps);
|
||||||
expect(settings.canvasSize).toEqual(v1Project.canvasSize);
|
expect(settings.canvasSize).toEqual(v1Project.canvasSize);
|
||||||
expect(settings.originalCanvasSize).toBe(null);
|
expect(settings.originalCanvasSize).toBe(null);
|
||||||
|
|
@ -44,8 +45,8 @@ describe("V1 to V2 Migration", () => {
|
||||||
test("converts color background correctly", () => {
|
test("converts color background correctly", () => {
|
||||||
const result = transformProjectV1ToV2({ project: v1Project });
|
const result = transformProjectV1ToV2({ project: v1Project });
|
||||||
|
|
||||||
const settings = result.project.settings as Record<string, unknown>;
|
const settings = asRecord(result.project.settings);
|
||||||
const background = settings.background as Record<string, unknown>;
|
const background = asRecord(settings.background);
|
||||||
expect(background.type).toBe("color");
|
expect(background.type).toBe("color");
|
||||||
expect(background.color).toBe(v1Project.backgroundColor);
|
expect(background.color).toBe(v1Project.backgroundColor);
|
||||||
});
|
});
|
||||||
|
|
@ -58,8 +59,8 @@ describe("V1 to V2 Migration", () => {
|
||||||
};
|
};
|
||||||
const result = transformProjectV1ToV2({ project: projectWithBlur });
|
const result = transformProjectV1ToV2({ project: projectWithBlur });
|
||||||
|
|
||||||
const settings = result.project.settings as Record<string, unknown>;
|
const settings = asRecord(result.project.settings);
|
||||||
const background = settings.background as Record<string, unknown>;
|
const background = asRecord(settings.background);
|
||||||
expect(background.type).toBe("blur");
|
expect(background.type).toBe("blur");
|
||||||
expect(background.blurIntensity).toBe(30);
|
expect(background.blurIntensity).toBe(30);
|
||||||
});
|
});
|
||||||
|
|
@ -67,7 +68,7 @@ describe("V1 to V2 Migration", () => {
|
||||||
test("applies legacy bookmarks to main scene", () => {
|
test("applies legacy bookmarks to main scene", () => {
|
||||||
const result = transformProjectV1ToV2({ project: v1Project });
|
const result = transformProjectV1ToV2({ project: v1Project });
|
||||||
|
|
||||||
const scenes = result.project.scenes as Array<Record<string, unknown>>;
|
const scenes = asRecordArray(result.project.scenes);
|
||||||
const mainScene = scenes.find((s) => s.isMain === true);
|
const mainScene = scenes.find((s) => s.isMain === true);
|
||||||
expect(mainScene?.bookmarks).toEqual(v1Project.bookmarks);
|
expect(mainScene?.bookmarks).toEqual(v1Project.bookmarks);
|
||||||
});
|
});
|
||||||
|
|
@ -77,7 +78,7 @@ describe("V1 to V2 Migration", () => {
|
||||||
project: v1ProjectWithMultipleScenes,
|
project: v1ProjectWithMultipleScenes,
|
||||||
});
|
});
|
||||||
|
|
||||||
const scenes = result.project.scenes as Array<Record<string, unknown>>;
|
const scenes = asRecordArray(result.project.scenes);
|
||||||
const introScene = scenes.find((s) => s.name === "Intro");
|
const introScene = scenes.find((s) => s.name === "Intro");
|
||||||
expect(introScene?.bookmarks).toEqual([1.0]);
|
expect(introScene?.bookmarks).toEqual([1.0]);
|
||||||
});
|
});
|
||||||
|
|
@ -102,7 +103,7 @@ describe("V1 to V2 Migration", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.skipped).toBe(false);
|
expect(result.skipped).toBe(false);
|
||||||
const settings = result.project.settings as Record<string, unknown>;
|
const settings = asRecord(result.project.settings);
|
||||||
expect(settings.fps).toBe(DEFAULT_FPS);
|
expect(settings.fps).toBe(DEFAULT_FPS);
|
||||||
expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE);
|
expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE);
|
||||||
});
|
});
|
||||||
|
|
@ -115,11 +116,11 @@ describe("V1 to V2 Migration", () => {
|
||||||
};
|
};
|
||||||
const result = transformProjectV1ToV2({ project: minimalProject });
|
const result = transformProjectV1ToV2({ project: minimalProject });
|
||||||
|
|
||||||
const settings = result.project.settings as Record<string, unknown>;
|
const settings = asRecord(result.project.settings);
|
||||||
expect(settings.fps).toBe(DEFAULT_FPS);
|
expect(settings.fps).toBe(DEFAULT_FPS);
|
||||||
expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE);
|
expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE);
|
||||||
|
|
||||||
const background = settings.background as Record<string, unknown>;
|
const background = asRecord(settings.background);
|
||||||
expect(background.type).toBe("color");
|
expect(background.type).toBe("color");
|
||||||
expect(background.color).toBe(DEFAULT_BACKGROUND_COLOR);
|
expect(background.color).toBe(DEFAULT_BACKGROUND_COLOR);
|
||||||
});
|
});
|
||||||
|
|
@ -135,8 +136,8 @@ describe("V1 to V2 Migration", () => {
|
||||||
project: projectWithBlurNoIntensity,
|
project: projectWithBlurNoIntensity,
|
||||||
});
|
});
|
||||||
|
|
||||||
const settings = result.project.settings as Record<string, unknown>;
|
const settings = asRecord(result.project.settings);
|
||||||
const background = settings.background as Record<string, unknown>;
|
const background = asRecord(settings.background);
|
||||||
expect(background.blurIntensity).toBe(DEFAULT_BACKGROUND_BLUR_INTENSITY);
|
expect(background.blurIntensity).toBe(DEFAULT_BACKGROUND_BLUR_INTENSITY);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -183,9 +184,9 @@ describe("V1 to V2 Migration", () => {
|
||||||
project: projectWithTracks,
|
project: projectWithTracks,
|
||||||
});
|
});
|
||||||
|
|
||||||
const scenes = result.project.scenes as Array<Record<string, unknown>>;
|
const scenes = asRecordArray(result.project.scenes);
|
||||||
const mainScene = scenes[0];
|
const mainScene = scenes[0];
|
||||||
const tracks = mainScene.tracks as Array<Record<string, unknown>>;
|
const tracks = asRecordArray(mainScene.tracks);
|
||||||
expect(tracks.length).toBe(1);
|
expect(tracks.length).toBe(1);
|
||||||
expect(tracks[0].name).toBe("Existing Track");
|
expect(tracks[0].name).toBe("Existing Track");
|
||||||
});
|
});
|
||||||
|
|
@ -240,9 +241,9 @@ describe("V1 to V2 Migration", () => {
|
||||||
context,
|
context,
|
||||||
});
|
});
|
||||||
|
|
||||||
const scenes = result.project.scenes as Array<Record<string, unknown>>;
|
const scenes = asRecordArray(result.project.scenes);
|
||||||
const mainScene = scenes[0];
|
const mainScene = scenes[0];
|
||||||
const tracks = mainScene.tracks as Array<Record<string, unknown>>;
|
const tracks = asRecordArray(mainScene.tracks);
|
||||||
expect(Array.isArray(tracks)).toBe(true);
|
expect(Array.isArray(tracks)).toBe(true);
|
||||||
expect(tracks).toHaveLength(1);
|
expect(tracks).toHaveLength(1);
|
||||||
expect(tracks[0].type).toBe("video");
|
expect(tracks[0].type).toBe("video");
|
||||||
|
|
@ -302,9 +303,9 @@ describe("V1 to V2 Migration", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.skipped).toBe(false);
|
expect(result.skipped).toBe(false);
|
||||||
const scenes = result.project.scenes as Array<Record<string, unknown>>;
|
const scenes = asRecordArray(result.project.scenes);
|
||||||
const tracks = scenes[0].tracks as Array<Record<string, unknown>>;
|
const tracks = asRecordArray(scenes[0].tracks);
|
||||||
const elements = tracks[0].elements as Array<Record<string, unknown>>;
|
const elements = asRecordArray(tracks[0].elements);
|
||||||
const textElement = elements[0];
|
const textElement = elements[0];
|
||||||
expect(textElement.opacity).toBe(0.5);
|
expect(textElement.opacity).toBe(0.5);
|
||||||
expect(textElement.transform).toEqual({
|
expect(textElement.transform).toEqual({
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { transformProjectV15ToV16 } from "../transformers/v15-to-v16";
|
import { transformProjectV15ToV16 } from "../transformers/v15-to-v16";
|
||||||
|
import { asRecordArray } from "./helpers";
|
||||||
|
|
||||||
describe("V15 to V16 Migration", () => {
|
describe("V15 to V16 Migration", () => {
|
||||||
test("renames sticker tracks to graphic tracks", () => {
|
test("renames sticker tracks to graphic tracks", () => {
|
||||||
|
|
@ -61,10 +62,10 @@ describe("V15 to V16 Migration", () => {
|
||||||
|
|
||||||
expect(result.skipped).toBe(false);
|
expect(result.skipped).toBe(false);
|
||||||
expect(result.project.version).toBe(16);
|
expect(result.project.version).toBe(16);
|
||||||
expect(
|
const firstTrack = asRecordArray(
|
||||||
(result.project.scenes as Array<{ tracks: Array<{ type: string }> }>)[0]
|
asRecordArray(result.project.scenes)[0].tracks,
|
||||||
.tracks[0].type,
|
)[0];
|
||||||
).toBe("graphic");
|
expect(firstTrack.type).toBe("graphic");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("skips projects already on v16", () => {
|
test("skips projects already on v16", () => {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue