mirror of
https://github.com/motion-canvas/motion-canvas.git
synced 2026-01-12 15:28:03 -05:00
feat: filter reordering (#119)
This commit is contained in:
@@ -11,6 +11,8 @@ import {
|
||||
Vector2Property,
|
||||
vector2Property,
|
||||
wrapper,
|
||||
FiltersProperty,
|
||||
filtersProperty,
|
||||
} from '../decorators';
|
||||
import {
|
||||
Vector2,
|
||||
@@ -35,6 +37,7 @@ import {TimingFunction} from '@motion-canvas/core/lib/tweening';
|
||||
import {threadable} from '@motion-canvas/core/lib/decorators';
|
||||
import {drawLine} from '../utils';
|
||||
import type {View2D} from './View2D';
|
||||
import {Filter} from '../partials';
|
||||
|
||||
export interface NodeProps {
|
||||
ref?: Reference<any>;
|
||||
@@ -50,14 +53,7 @@ export interface NodeProps {
|
||||
scale?: SignalValue<PossibleVector2>;
|
||||
|
||||
opacity?: SignalValue<number>;
|
||||
blur?: SignalValue<number>;
|
||||
brightness?: SignalValue<number>;
|
||||
contrast?: SignalValue<number>;
|
||||
grayscale?: SignalValue<number>;
|
||||
hue?: SignalValue<number>;
|
||||
invert?: SignalValue<number>;
|
||||
saturate?: SignalValue<number>;
|
||||
sepia?: SignalValue<number>;
|
||||
filters?: SignalValue<Filter[]>;
|
||||
shadowColor?: SignalValue<PossibleColor>;
|
||||
shadowBlur?: SignalValue<number>;
|
||||
shadowOffsetX?: SignalValue<number>;
|
||||
@@ -195,37 +191,8 @@ export class Node implements Promisable<Node> {
|
||||
return (this.parent()?.absoluteOpacity() ?? 1) * this.opacity();
|
||||
}
|
||||
|
||||
@initial(0)
|
||||
@property()
|
||||
public declare readonly blur: Signal<number, this>;
|
||||
|
||||
@initial(1)
|
||||
@property()
|
||||
public declare readonly brightness: Signal<number, this>;
|
||||
|
||||
@initial(1)
|
||||
@property()
|
||||
public declare readonly contrast: Signal<number, this>;
|
||||
|
||||
@initial(0)
|
||||
@property()
|
||||
public declare readonly grayscale: Signal<number, this>;
|
||||
|
||||
@initial(0)
|
||||
@property()
|
||||
public declare readonly hue: Signal<number, this>;
|
||||
|
||||
@initial(0)
|
||||
@property()
|
||||
public declare readonly invert: Signal<number, this>;
|
||||
|
||||
@initial(1)
|
||||
@property()
|
||||
public declare readonly saturate: Signal<number, this>;
|
||||
|
||||
@initial(0)
|
||||
@property()
|
||||
public declare readonly sepia: Signal<number, this>;
|
||||
@filtersProperty()
|
||||
public declare readonly filters: FiltersProperty;
|
||||
|
||||
@initial('#0000')
|
||||
@colorProperty()
|
||||
@@ -239,17 +206,8 @@ export class Node implements Promisable<Node> {
|
||||
public declare readonly shadowOffset: Vector2Property<this>;
|
||||
|
||||
@computed()
|
||||
protected hasFilters() {
|
||||
return (
|
||||
this.blur() !== 0 ||
|
||||
this.brightness() !== 1 ||
|
||||
this.contrast() !== 1 ||
|
||||
this.grayscale() !== 0 ||
|
||||
this.hue() !== 0 ||
|
||||
this.invert() !== 0 ||
|
||||
this.saturate() !== 1 ||
|
||||
this.sepia() !== 0
|
||||
);
|
||||
protected hasFilters(): boolean {
|
||||
return !!this.filters().find(filter => filter.isActive());
|
||||
}
|
||||
|
||||
@computed()
|
||||
@@ -265,38 +223,11 @@ export class Node implements Promisable<Node> {
|
||||
@computed()
|
||||
protected filterString(): string {
|
||||
let filters = '';
|
||||
|
||||
const invert = this.invert();
|
||||
if (invert !== 0) {
|
||||
filters += ` invert(${invert * 100}%)`;
|
||||
}
|
||||
const sepia = this.sepia();
|
||||
if (sepia !== 0) {
|
||||
filters += ` sepia(${sepia * 100}%)`;
|
||||
}
|
||||
const brightness = this.brightness();
|
||||
if (brightness !== 1) {
|
||||
filters += ` brightness(${brightness * 100}%)`;
|
||||
}
|
||||
const contrast = this.contrast();
|
||||
if (contrast !== 1) {
|
||||
filters += ` contrast(${contrast * 100}%)`;
|
||||
}
|
||||
const grayscale = this.grayscale();
|
||||
if (grayscale !== 0) {
|
||||
filters += ` grayscale(${grayscale * 100}%)`;
|
||||
}
|
||||
const hue = this.hue();
|
||||
if (hue !== 0) {
|
||||
filters += ` hue-rotate(${hue}deg)`;
|
||||
}
|
||||
const saturate = this.saturate();
|
||||
if (saturate !== 1) {
|
||||
filters += ` saturate(${saturate * 100}%)`;
|
||||
}
|
||||
const blur = this.blur();
|
||||
if (blur !== 0) {
|
||||
filters += ` blur(${transformScalar(blur, this.compositeToWorld())}px)`;
|
||||
const matrix = this.compositeToWorld();
|
||||
for (const filter of this.filters()) {
|
||||
if (filter.isActive()) {
|
||||
filters += ' ' + filter.serialize(matrix);
|
||||
}
|
||||
}
|
||||
|
||||
return filters;
|
||||
@@ -694,7 +625,9 @@ export class Node implements Promisable<Node> {
|
||||
const shadowOffset = this.shadowOffset().transform(matrix);
|
||||
const shadowBlur = transformScalar(this.shadowBlur(), matrix);
|
||||
|
||||
const result = this.cacheRect().expand(this.blur() * 2 + shadowBlur);
|
||||
const result = this.cacheRect().expand(
|
||||
this.filters.blur() * 2 + shadowBlur,
|
||||
);
|
||||
|
||||
if (shadowOffset.x < 0) {
|
||||
result.x += shadowOffset.x;
|
||||
|
||||
185
packages/2d/src/decorators/filtersProperty.ts
Normal file
185
packages/2d/src/decorators/filtersProperty.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import {getPropertyMetaOrCreate, PropertyOwner} from './property';
|
||||
import {Filter, FilterName, FILTERS} from '../partials';
|
||||
import {
|
||||
createSignal,
|
||||
isReactive,
|
||||
Signal,
|
||||
SignalGenerator,
|
||||
SignalValue,
|
||||
} from '@motion-canvas/core/lib/utils';
|
||||
import {easeInOutCubic, TimingFunction} from '@motion-canvas/core/lib/tweening';
|
||||
import {ThreadGenerator} from '@motion-canvas/core/lib/threading';
|
||||
import {decorate, threadable} from '@motion-canvas/core/lib/decorators';
|
||||
import {all} from '@motion-canvas/core/lib/flow';
|
||||
import {addInitializer} from './initializers';
|
||||
|
||||
export type FiltersProperty = Signal<Filter[]> & {
|
||||
[K in FilterName]: Signal<number>;
|
||||
};
|
||||
|
||||
export function createFiltersProperty<
|
||||
TNode extends PropertyOwner<Filter[], Filter[]>,
|
||||
TProperty extends string & keyof TNode,
|
||||
>(
|
||||
node: TNode,
|
||||
property: TProperty,
|
||||
initial: SignalValue<Filter[]> = [],
|
||||
): FiltersProperty {
|
||||
const signal = createSignal(initial, undefined, node);
|
||||
|
||||
const handler = <Signal<Filter[], TNode>>(
|
||||
function (
|
||||
newValue?: SignalValue<Filter[]>,
|
||||
duration?: number,
|
||||
timingFunction: TimingFunction = easeInOutCubic,
|
||||
) {
|
||||
if (newValue === undefined) {
|
||||
return signal();
|
||||
}
|
||||
|
||||
if (duration === undefined) {
|
||||
return signal(newValue);
|
||||
}
|
||||
|
||||
return makeAnimate(timingFunction)(newValue, duration);
|
||||
}
|
||||
);
|
||||
|
||||
function makeAnimate(
|
||||
defaultTimingFunction: TimingFunction,
|
||||
before?: ThreadGenerator,
|
||||
) {
|
||||
function animate(
|
||||
value: SignalValue<Filter[]>,
|
||||
duration: number,
|
||||
timingFunction = defaultTimingFunction,
|
||||
) {
|
||||
const task = <SignalGenerator<Filter[]>>(
|
||||
makeTask(value, duration, timingFunction, before)
|
||||
);
|
||||
task.to = makeAnimate(timingFunction, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
return animate;
|
||||
}
|
||||
|
||||
decorate(<any>makeTask, threadable());
|
||||
function* makeTask(
|
||||
value: SignalValue<Filter[]>,
|
||||
duration: number,
|
||||
timingFunction: TimingFunction,
|
||||
before?: ThreadGenerator,
|
||||
) {
|
||||
if (before) {
|
||||
yield* before;
|
||||
}
|
||||
|
||||
const from = signal();
|
||||
const to = isReactive(value) ? value() : value;
|
||||
|
||||
if (areFiltersCompatible(from, to)) {
|
||||
yield* all(
|
||||
...from.map((filter, i) =>
|
||||
filter.value(to[i].value(), duration, timingFunction),
|
||||
),
|
||||
);
|
||||
signal(to);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const filter of to) {
|
||||
filter.value(filter.default);
|
||||
}
|
||||
|
||||
const toValues = to.map(filter => filter.value.raw());
|
||||
const partialDuration =
|
||||
from.length > 0 && to.length > 0 ? duration / 2 : duration;
|
||||
if (from.length > 0) {
|
||||
yield* all(
|
||||
...from.map(filter =>
|
||||
filter.value(filter.default, partialDuration, timingFunction),
|
||||
),
|
||||
);
|
||||
}
|
||||
signal(to);
|
||||
if (to.length > 0) {
|
||||
yield* all(
|
||||
...to.map((filter, index) =>
|
||||
filter.value(toValues[index], partialDuration, timingFunction),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(handler, 'reset', {
|
||||
configurable: true,
|
||||
value: signal.reset,
|
||||
});
|
||||
|
||||
Object.defineProperty(handler, 'save', {
|
||||
configurable: true,
|
||||
value: signal.save,
|
||||
});
|
||||
|
||||
Object.defineProperty(handler, 'raw', {
|
||||
value: signal.raw,
|
||||
});
|
||||
|
||||
for (const filter in FILTERS) {
|
||||
const props = FILTERS[filter];
|
||||
Object.defineProperty(handler, filter, {
|
||||
value: (
|
||||
newValue?: SignalValue<number>,
|
||||
duration?: number,
|
||||
timingFunction: TimingFunction = easeInOutCubic,
|
||||
) => {
|
||||
if (newValue === undefined) {
|
||||
return (
|
||||
signal()?.find(filter => filter.name === props.name) ??
|
||||
props.default
|
||||
);
|
||||
}
|
||||
|
||||
let instance = signal()?.find(filter => filter.name === props.name);
|
||||
if (!instance) {
|
||||
instance = new Filter(props);
|
||||
signal([...signal(), instance]);
|
||||
}
|
||||
|
||||
if (duration === undefined) {
|
||||
instance.value(newValue);
|
||||
return node;
|
||||
}
|
||||
|
||||
return instance.value(newValue, duration, timingFunction);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return <FiltersProperty>(<unknown>handler);
|
||||
}
|
||||
|
||||
export function filtersProperty<T>(): PropertyDecorator {
|
||||
return (target: any, key) => {
|
||||
const meta = getPropertyMetaOrCreate<T>(target, key);
|
||||
addInitializer(target, (instance: any, context: any) => {
|
||||
instance[key] = createFiltersProperty(
|
||||
instance,
|
||||
<string>key,
|
||||
context.defaults[key] ?? meta.default,
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function areFiltersCompatible(a: Filter[], b: Filter[]) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i].name !== b[i].name) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export * from './canvasStyleProperty';
|
||||
export * from './colorProperty';
|
||||
export * from './compound';
|
||||
export * from './computed';
|
||||
export * from './filtersProperty';
|
||||
export * from './initializers';
|
||||
export * from './property';
|
||||
export * from './vector2Property';
|
||||
|
||||
181
packages/2d/src/partials/Filter.ts
Normal file
181
packages/2d/src/partials/Filter.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import {createSignal, Signal, SignalValue} from '@motion-canvas/core/lib/utils';
|
||||
import {map} from '@motion-canvas/core/lib/tweening';
|
||||
import {transformScalar} from '@motion-canvas/core/lib/types';
|
||||
|
||||
/**
|
||||
* All possible CSS filter names.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export type FilterName =
|
||||
| 'invert'
|
||||
| 'sepia'
|
||||
| 'grayscale'
|
||||
| 'brightness'
|
||||
| 'contrast'
|
||||
| 'saturate'
|
||||
| 'hue'
|
||||
| 'blur';
|
||||
|
||||
/**
|
||||
* Definitions of all possible CSS filters.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const FILTERS: Record<string, Partial<FilterProps>> = {
|
||||
invert: {
|
||||
name: 'invert',
|
||||
},
|
||||
sepia: {
|
||||
name: 'sepia',
|
||||
},
|
||||
grayscale: {
|
||||
name: 'grayscale',
|
||||
},
|
||||
brightness: {
|
||||
name: 'brightness',
|
||||
default: 1,
|
||||
},
|
||||
contrast: {
|
||||
name: 'contrast',
|
||||
default: 1,
|
||||
},
|
||||
saturate: {
|
||||
name: 'saturate',
|
||||
default: 1,
|
||||
},
|
||||
hue: {
|
||||
name: 'hue-rotate',
|
||||
unit: 'deg',
|
||||
scale: 1,
|
||||
},
|
||||
blur: {
|
||||
name: 'blur',
|
||||
transform: true,
|
||||
unit: 'px',
|
||||
scale: 1,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A unified abstraction for all CSS filters.
|
||||
*/
|
||||
export interface FilterProps {
|
||||
name: string;
|
||||
value: SignalValue<number>;
|
||||
unit: string;
|
||||
scale: number;
|
||||
transform: boolean;
|
||||
default: number;
|
||||
}
|
||||
|
||||
export class Filter {
|
||||
public get name() {
|
||||
return this.props.name;
|
||||
}
|
||||
|
||||
public get default() {
|
||||
return this.props.default;
|
||||
}
|
||||
|
||||
public readonly value: Signal<number, Filter>;
|
||||
private readonly props: FilterProps;
|
||||
|
||||
public constructor(props: Partial<FilterProps>) {
|
||||
this.props = {
|
||||
name: 'invert',
|
||||
default: 0,
|
||||
unit: '%',
|
||||
scale: 100,
|
||||
transform: false,
|
||||
...props,
|
||||
value: props.value ?? props.default ?? 0,
|
||||
};
|
||||
this.value = createSignal(this.props.value, map, this);
|
||||
}
|
||||
|
||||
public isActive() {
|
||||
return this.value() !== this.props.default;
|
||||
}
|
||||
|
||||
public serialize(matrix: DOMMatrix): string {
|
||||
let value = this.value();
|
||||
if (this.props.transform) {
|
||||
value = transformScalar(value, matrix);
|
||||
}
|
||||
|
||||
return `${this.props.name}(${value * this.props.scale}${this.props.unit})`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an {@link https://developer.mozilla.org/en-US/docs/Web/CSS/filter-function/invert | invert} filter.
|
||||
*
|
||||
* @param value - The value of the filter.
|
||||
*/
|
||||
export function invert(value?: SignalValue<number>) {
|
||||
return new Filter({...FILTERS.invert, value});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link https://developer.mozilla.org/en-US/docs/Web/CSS/filter-function/sepia | sepia} filter.
|
||||
*
|
||||
* @param value - The value of the filter.
|
||||
*/
|
||||
export function sepia(value?: SignalValue<number>) {
|
||||
return new Filter({...FILTERS.sepia, value});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link https://developer.mozilla.org/en-US/docs/Web/CSS/filter-function/grayscale | grayscale} filter.
|
||||
*
|
||||
* @param value - The value of the filter.
|
||||
*/
|
||||
export function grayscale(value?: SignalValue<number>) {
|
||||
return new Filter({...FILTERS.grayscale, value});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link https://developer.mozilla.org/en-US/docs/Web/CSS/filter-function/brightness | brightness} filter.
|
||||
*
|
||||
* @param value - The value of the filter.
|
||||
*/
|
||||
export function brightness(value?: SignalValue<number>) {
|
||||
return new Filter({...FILTERS.brightness, value});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link https://developer.mozilla.org/en-US/docs/Web/CSS/filter-function/contrast | contrast} filter.
|
||||
*
|
||||
* @param value - The value of the filter.
|
||||
*/
|
||||
export function contrast(value?: SignalValue<number>) {
|
||||
return new Filter({...FILTERS.contrast, value});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link https://developer.mozilla.org/en-US/docs/Web/CSS/filter-function/saturate | saturate} filter.
|
||||
*
|
||||
* @param value - The value of the filter.
|
||||
*/
|
||||
export function saturate(value?: SignalValue<number>) {
|
||||
return new Filter({...FILTERS.saturate, value});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link https://developer.mozilla.org/en-US/docs/Web/CSS/filter-function/hue-rotate | hue} filter.
|
||||
*
|
||||
* @param value - The value of the filter in degrees.
|
||||
*/
|
||||
export function hue(value?: SignalValue<number>) {
|
||||
return new Filter({...FILTERS.hue, value});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link https://developer.mozilla.org/en-US/docs/Web/CSS/filter-function/blur | blur} filter.
|
||||
*
|
||||
* @param value - The value of the filter in pixels.
|
||||
*/
|
||||
export function blur(value?: SignalValue<number>) {
|
||||
return new Filter({...FILTERS.blur, value});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './Filter';
|
||||
export * from './Gradient';
|
||||
export * from './Pattern';
|
||||
export * from './types';
|
||||
|
||||
@@ -28,24 +28,31 @@ const classes: Record<ListType, string> = {
|
||||
export default function TokenList({
|
||||
children,
|
||||
type,
|
||||
separator,
|
||||
separator = Separator.Comma,
|
||||
}: {
|
||||
children: ReactNode[];
|
||||
children: ReactNode | ReactNode[];
|
||||
type?: ListType;
|
||||
separator?: Separator;
|
||||
}) {
|
||||
return (
|
||||
<span className={clsx(styles.list, classes[type ?? ListType.None])}>
|
||||
<span className={styles.elements}>
|
||||
{children.flatMap((child, index) => (
|
||||
<span
|
||||
data-separator={separator ?? Separator.Comma}
|
||||
key={index}
|
||||
className={styles.element}
|
||||
>
|
||||
{child}
|
||||
</span>
|
||||
))}
|
||||
<span
|
||||
className={clsx(
|
||||
styles.elements,
|
||||
separator !== Separator.Comma && styles.left,
|
||||
)}
|
||||
>
|
||||
{(Array.isArray(children) ? children : [children]).flatMap(
|
||||
(child, index) => (
|
||||
<span
|
||||
data-separator={separator}
|
||||
key={index}
|
||||
className={styles.element}
|
||||
>
|
||||
{child}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -3,18 +3,26 @@
|
||||
.elements {
|
||||
}
|
||||
|
||||
.element:not(:last-child)::after {
|
||||
.elements:not(.wrap) > .element:not(:last-child)::after {
|
||||
content: attr(data-separator);
|
||||
}
|
||||
.elements.wrap > .element::after {
|
||||
.elements.wrap:not(.left) > .element::after {
|
||||
content: attr(data-separator) '\a';
|
||||
}
|
||||
.elements.wrap::before {
|
||||
.elements.wrap.left > .element::before {
|
||||
content: '\a' attr(data-separator);
|
||||
}
|
||||
|
||||
.elements.wrap:not(.left)::before {
|
||||
content: '\a';
|
||||
}
|
||||
.elements.wrap::after {
|
||||
.elements.wrap:not(.left)::after {
|
||||
content: '';
|
||||
}
|
||||
.elements.wrap.left::after {
|
||||
content: '\a';
|
||||
}
|
||||
|
||||
.wrap .elements.wrap::after {
|
||||
padding-left: 2ch;
|
||||
}
|
||||
|
||||
22
packages/docs/src/components/Api/Type/MappedType.tsx
Normal file
22
packages/docs/src/components/Api/Type/MappedType.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
import type {JSONOutput} from 'typedoc';
|
||||
import Type from '@site/src/components/Api/Type';
|
||||
import Token from '@site/src/components/Api/Code/Token';
|
||||
import TokenList, {ListType} from '@site/src/components/Api/Code/TokenList';
|
||||
|
||||
export default function MappedType({type}: {type: JSONOutput.MappedType}) {
|
||||
// TODO Figure out what `type.asserts` does.
|
||||
return (
|
||||
<>
|
||||
<TokenList type={ListType.Curly}>
|
||||
<>
|
||||
[<Token type="class">{type.parameter}</Token>
|
||||
<Token type="keyword"> in </Token>
|
||||
<Type type={type.parameterType} />
|
||||
]: <Type type={type.templateType} />
|
||||
</>
|
||||
</TokenList>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import ConditionalType from '@site/src/components/Api/Type/ConditionalType';
|
||||
import InferredType from '@site/src/components/Api/Type/InferredType';
|
||||
import IndexedAccessType from '@site/src/components/Api/Type/IndexedAccessType';
|
||||
import TypeOperatorType from '@site/src/components/Api/Type/TypeOperatorType';
|
||||
import MappedType from '@site/src/components/Api/Type/MappedType';
|
||||
import type {JSONOutput} from 'typedoc';
|
||||
|
||||
export interface CodeTypeProps {
|
||||
@@ -58,7 +59,7 @@ export default function CodeType(props: CodeTypeProps) {
|
||||
case 'inferred':
|
||||
return InferredType;
|
||||
case 'mapped':
|
||||
break;
|
||||
return MappedType;
|
||||
case 'template-literal':
|
||||
return TemplateLiteralType;
|
||||
case 'indexedAccess':
|
||||
|
||||
Reference in New Issue
Block a user