feat: minor improvements

- allow to pass `SignalValue`s as properties
- support composite operation
- add a dispose callback for nodes
- add arcLerp for Rect and Vector
This commit is contained in:
aarthificial
2022-11-17 02:23:25 +01:00
committed by Jacob
parent 7c6e584aca
commit 403c7c27ad
18 changed files with 689 additions and 179 deletions

View File

@@ -1,14 +1,26 @@
import {Signal} from '@motion-canvas/core/lib/utils';
import {Signal, SignalValue} from '@motion-canvas/core/lib/utils';
import {computed, property} from '../decorators';
import {Layout, LayoutProps} from './Layout';
import {Rect as RectType, Vector2} from '@motion-canvas/core/lib/types';
import Color from 'colorjs.io';
import {drawImage} from '../utils';
import {Rect, RectProps} from './Rect';
export interface ImageProps extends LayoutProps {
src?: string;
export interface ImageProps extends RectProps {
src?: SignalValue<string>;
alpha?: SignalValue<number>;
smoothing?: SignalValue<boolean>;
}
export class Image extends Layout {
export class Image extends Rect {
@property()
public declare readonly src: Signal<string, this>;
@property(1)
public declare readonly alpha: Signal<number, this>;
@property(true)
public declare readonly smoothing: Signal<boolean, this>;
protected readonly image: HTMLImageElement;
public constructor(props: ImageProps) {
@@ -20,10 +32,22 @@ export class Image extends Layout {
}
protected override draw(context: CanvasRenderingContext2D) {
const {width, height} = this.computedSize();
context.drawImage(this.image, width / -2, height / -2, width, height);
super.draw(context);
this.drawShape(context);
if (this.clip()) {
context.clip(this.getPath());
}
const alpha = this.alpha();
if (alpha > 0) {
const rect = RectType.fromSizeCentered(this.computedSize());
context.save();
if (alpha < 1) {
context.globalAlpha *= alpha;
}
context.imageSmoothingEnabled = this.smoothing();
drawImage(context, this.image, rect);
context.restore();
}
this.drawChildren(context);
}
protected override updateLayout() {
@@ -36,6 +60,49 @@ export class Image extends Layout {
this.image.src = this.src();
}
@computed()
protected imageCanvas(): CanvasRenderingContext2D {
const canvas = document
.createElement('canvas')
.getContext('2d', {willReadFrequently: true});
if (!canvas) {
throw new Error('Could not create an image canvas');
}
return canvas;
}
@computed()
protected imageDrawnCanvas() {
this.applySrc();
const context = this.imageCanvas();
context.canvas.width = this.image.naturalWidth;
context.canvas.height = this.image.naturalHeight;
context.imageSmoothingEnabled = this.smoothing();
context.drawImage(this.image, 0, 0);
return context;
}
public getColorAtPoint(position: Vector2): any {
const context = this.imageDrawnCanvas();
const relativePosition = position.add(
this.computedSize().vector.scale(0.5),
);
const data = context.getImageData(
relativePosition.x,
relativePosition.y,
1,
1,
).data;
return new Color(
'sRGB',
[data[0] / 255, data[1] / 255, data[2] / 255],
data[3] / 255,
);
}
protected override collectAsyncResources(deps: Promise<any>[]) {
super.collectAsyncResources(deps);
this.applySrc();

View File

@@ -1,11 +1,13 @@
import {compound, computed, Property, property} from '../decorators';
import {
Vector2,
transformAngle,
Origin,
PossibleSpacing,
Rect,
Size,
PossibleSpacing,
Spacing,
transformAngle,
Vector2,
originToOffset,
} from '@motion-canvas/core/lib/types';
import {isReactive, Signal, SignalValue} from '@motion-canvas/core/lib/utils';
import {
@@ -32,58 +34,58 @@ export interface LayoutProps extends NodeProps {
layout?: LayoutMode;
tagName?: keyof HTMLElementTagNameMap;
width?: Length;
height?: Length;
maxWidth?: Length;
maxHeight?: Length;
minWidth?: Length;
minHeight?: Length;
ratio?: number;
width?: SignalValue<Length>;
height?: SignalValue<Length>;
maxWidth?: SignalValue<Length>;
maxHeight?: SignalValue<Length>;
minWidth?: SignalValue<Length>;
minHeight?: SignalValue<Length>;
ratio?: SignalValue<number>;
marginTop?: number;
marginBottom?: number;
marginLeft?: number;
marginRight?: number;
margin?: PossibleSpacing;
marginTop?: SignalValue<number>;
marginBottom?: SignalValue<number>;
marginLeft?: SignalValue<number>;
marginRight?: SignalValue<number>;
margin?: SignalValue<PossibleSpacing>;
paddingTop?: number;
paddingBottom?: number;
paddingLeft?: number;
paddingRight?: number;
padding?: PossibleSpacing;
paddingTop?: SignalValue<number>;
paddingBottom?: SignalValue<number>;
paddingLeft?: SignalValue<number>;
paddingRight?: SignalValue<number>;
padding?: SignalValue<PossibleSpacing>;
direction?: FlexDirection;
basis?: FlexBasis;
grow?: number;
shrink?: number;
wrap?: FlexWrap;
direction?: SignalValue<FlexDirection>;
basis?: SignalValue<FlexBasis>;
grow?: SignalValue<number>;
shrink?: SignalValue<number>;
wrap?: SignalValue<FlexWrap>;
justifyContent?: FlexJustify;
alignItems?: FlexAlign;
rowGap?: Length;
columnGap?: Length;
gap?: Length;
justifyContent?: SignalValue<FlexJustify>;
alignItems?: SignalValue<FlexAlign>;
rowGap?: SignalValue<Length>;
columnGap?: SignalValue<Length>;
gap?: SignalValue<Length>;
fontFamily?: string;
fontSize?: number;
fontStyle?: string;
fontWeight?: number;
lineHeight?: number;
letterSpacing?: number;
textWrap?: boolean;
fontFamily?: SignalValue<string>;
fontSize?: SignalValue<number>;
fontStyle?: SignalValue<string>;
fontWeight?: SignalValue<number>;
lineHeight?: SignalValue<number>;
letterSpacing?: SignalValue<number>;
textWrap?: SignalValue<boolean>;
x?: number;
y?: number;
position?: Vector2;
size?: Size;
rotation?: number;
offsetX?: number;
offsetY?: number;
offset?: Vector2;
scaleX?: number;
scaleY?: number;
scale?: Vector2;
overflow?: boolean;
x?: SignalValue<number>;
y?: SignalValue<number>;
position?: SignalValue<Vector2>;
size?: SignalValue<Size>;
rotation?: SignalValue<number>;
offsetX?: SignalValue<number>;
offsetY?: SignalValue<number>;
offset?: SignalValue<Vector2>;
scaleX?: SignalValue<number>;
scaleY?: SignalValue<number>;
scale?: SignalValue<Vector2>;
clip?: SignalValue<boolean>;
}
export class Layout extends Node {
@@ -389,9 +391,6 @@ export class Layout extends Node {
// TODO Implement setter
}
@property(false)
public declare readonly overflow: Signal<boolean, this>;
@compound(['x', 'y'], Vector2)
@property(undefined, Vector2.lerp, Vector2)
public declare readonly position: Signal<Vector2, this>;
@@ -428,6 +427,9 @@ export class Layout extends Node {
}
}
@property(false)
public declare readonly clip: Signal<boolean, this>;
public readonly element: HTMLElement;
public readonly styles: CSSStyleDeclaration;
@@ -495,6 +497,10 @@ export class Layout extends Node {
}
}
if (mode === 'root' && parentMode !== 'disabled') {
mode = 'enabled';
}
return mode;
}
@@ -565,7 +571,9 @@ export class Layout extends Node {
protected updateLayout() {
this.applyFont();
this.applyFlex();
this.syncDOM();
if (this.resolvedMode() !== 'disabled') {
this.syncDOM();
}
}
@computed()
@@ -597,6 +605,47 @@ export class Layout extends Node {
return new Rect(size.vector.scale(-0.5), size);
}
protected override draw(context: CanvasRenderingContext2D) {
if (this.clip()) {
const size = this.computedSize();
if (size.width === 0 || size.height === 0) {
return;
}
context.beginPath();
context.rect(size.width / -2, size.height / -2, size.width, size.height);
context.closePath();
context.clip();
}
this.drawChildren(context);
}
public getOriginDelta(origin: Origin) {
const size = this.computedSize().scale(0.5);
const offset = this.offset().mul(size.vector);
if (origin === Origin.Middle) {
return offset.flipped;
}
const newOffset = originToOffset(origin).mul(size.vector);
return newOffset.sub(offset);
}
/**
* Update the offset of this node and adjust the position to keep it in the
* same place.
*
* @param offset - The new offset.
*/
public moveOffset(offset: Vector2) {
const size = this.computedSize().scale(0.5);
const oldOffset = this.offset().mul(size.vector);
const newOffset = offset.mul(size.vector);
this.offset(offset);
this.position(this.position().add(newOffset).sub(oldOffset));
}
protected parseValue(value: number | string | null): string {
return value === null ? '' : value.toString();
}
@@ -617,7 +666,7 @@ export class Layout extends Node {
@computed()
protected applyFlex() {
const mode = this.layout();
const mode = this.resolvedMode();
this.element.style.position =
mode === 'disabled' || mode === 'root' ? 'absolute' : 'relative';
@@ -653,8 +702,8 @@ export class Layout extends Node {
this.element.style.flexGrow = '0';
this.element.style.flexShrink = '0';
} else {
this.element.style.flexGrow = this.parsePixels(this.grow());
this.element.style.flexShrink = this.parsePixels(this.shrink());
this.element.style.flexGrow = this.parseValue(this.grow());
this.element.style.flexShrink = this.parseValue(this.shrink());
}
}

View File

@@ -1,29 +1,38 @@
import {compound, computed, initialize, property} from '../decorators';
import {Vector2, Rect, transformScalar} from '@motion-canvas/core/lib/types';
import {createSignal, Reference, Signal} from '@motion-canvas/core/lib/utils';
import {
createSignal,
isReactive,
Reference,
Signal,
SignalValue,
} from '@motion-canvas/core/lib/utils';
import {ComponentChild, ComponentChildren} from './types';
import {Promisable} from '@motion-canvas/core/lib/threading';
import {TwoDView} from '../scenes';
import {TwoDView, use2DView} from '../scenes';
import {TimingFunction} from '@motion-canvas/core/lib/tweening';
import {threadable} from '@motion-canvas/core/lib/decorators';
export interface NodeProps {
ref?: Reference<Node>;
ref?: Reference<any>;
children?: ComponentChildren;
opacity?: number;
blur?: number;
brightness?: number;
contrast?: number;
grayscale?: number;
hue?: number;
invert?: number;
saturate?: number;
sepia?: number;
shadowColor?: string;
shadowBlur?: number;
shadowOffsetX?: number;
shadowOffsetY?: number;
shadowOffset?: Vector2;
cache?: boolean;
composite?: boolean;
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>;
shadowColor?: SignalValue<string>;
shadowBlur?: SignalValue<number>;
shadowOffsetX?: SignalValue<number>;
shadowOffsetY?: SignalValue<number>;
shadowOffset?: SignalValue<Vector2>;
cache?: SignalValue<boolean>;
composite?: SignalValue<boolean>;
compositeOperation?: SignalValue<GlobalCompositeOperation>;
}
export class Node implements Promisable<Node> {
@@ -35,6 +44,32 @@ export class Node implements Promisable<Node> {
@property(false)
public declare readonly composite: Signal<boolean, this>;
@property('source-over')
public declare readonly compositeOperation: Signal<
GlobalCompositeOperation,
this
>;
private readonly compositeOverride = createSignal(0);
@threadable()
protected *tweenCompositeOperation(
value: SignalValue<GlobalCompositeOperation>,
time: number,
timingFunction: TimingFunction,
) {
const nextValue = isReactive(value) ? value() : value;
if (nextValue === 'source-over') {
yield* this.compositeOverride(1, time, timingFunction);
this.compositeOverride(0);
this.compositeOperation(nextValue);
} else {
this.compositeOperation(nextValue);
this.compositeOverride(1);
yield* this.compositeOverride(0, time, timingFunction);
}
}
@property(1)
public declare readonly opacity: Signal<number, this>;
@@ -153,6 +188,7 @@ export class Node implements Promisable<Node> {
public constructor({children, ...rest}: NodeProps) {
initialize(this, {defaults: rest});
this.append(children);
use2DView()?.registerNode(this);
}
@computed()
@@ -229,9 +265,12 @@ export class Node implements Promisable<Node> {
@computed()
public compositeToLocal() {
const root = this.compositeRoot();
return root
? root.localToWorld().multiply(this.worldToLocal())
: new DOMMatrix();
if (root) {
const worldToLocal = this.worldToLocal();
worldToLocal.m44 = 1;
return root.localToWorld().multiply();
}
return new DOMMatrix();
}
@computed()
@@ -250,14 +289,14 @@ export class Node implements Promisable<Node> {
return this;
}
public remove() {
this.moveTo(null);
public remove(): this {
return this.moveTo(null);
}
protected moveTo(parent: Node | null) {
protected moveTo(parent: Node | null): this {
const current = this.parent();
if (current === parent) {
return;
return this;
}
if (current) {
@@ -269,6 +308,7 @@ export class Node implements Promisable<Node> {
}
this.parent(parent);
return this;
}
public removeChildren() {
@@ -277,6 +317,10 @@ export class Node implements Promisable<Node> {
}
}
public dispose() {
// do nothing
}
/**
* Whether this node should be cached or not.
*/
@@ -284,6 +328,7 @@ export class Node implements Promisable<Node> {
return (
this.cache() ||
this.opacity() < 1 ||
this.compositeOperation() !== 'source-over' ||
this.hasFilters() ||
this.hasShadow()
);
@@ -395,6 +440,7 @@ export class Node implements Promisable<Node> {
* @param context - The context using which the cache will be drawn.
*/
protected setupDrawFromCache(context: CanvasRenderingContext2D) {
context.globalCompositeOperation = this.compositeOperation();
context.globalAlpha = this.opacity();
if (this.hasFilters()) {
context.filter = this.filterString();
@@ -417,6 +463,10 @@ export class Node implements Promisable<Node> {
* @param context - The context to draw with.
*/
public render(context: CanvasRenderingContext2D) {
if (this.absoluteOpacity() <= 0) {
return;
}
context.save();
this.transformContext(context);
@@ -424,7 +474,15 @@ export class Node implements Promisable<Node> {
this.setupDrawFromCache(context);
const cacheContext = this.cachedCanvas();
const cacheRect = this.cacheRect();
const compositeOverride = this.compositeOverride();
context.drawImage(cacheContext.canvas, cacheRect.x, cacheRect.y);
if (compositeOverride > 0) {
context.save();
context.globalAlpha *= compositeOverride;
context.globalCompositeOperation = 'source-over';
context.drawImage(cacheContext.canvas, cacheRect.x, cacheRect.y);
context.restore();
}
} else {
this.draw(context);
}
@@ -443,6 +501,10 @@ export class Node implements Promisable<Node> {
* @param context - The context to draw with.
*/
protected draw(context: CanvasRenderingContext2D) {
this.drawChildren(context);
}
protected drawChildren(context: CanvasRenderingContext2D) {
for (const child of this.children()) {
child.render(context);
}

View File

@@ -1,9 +1,11 @@
import {Signal} from '@motion-canvas/core/lib/utils';
import {Signal, SignalValue} from '@motion-canvas/core/lib/utils';
import {Rect as RectType} from '@motion-canvas/core/lib/types';
import {Shape, ShapeProps} from './Shape';
import {property} from '../decorators';
import {drawRoundRect} from '../utils';
export interface RectProps extends ShapeProps {
radius?: number;
radius?: SignalValue<number>;
}
export class Rect extends Shape {
@@ -17,20 +19,22 @@ export class Rect extends Shape {
protected override getPath(): Path2D {
const path = new Path2D();
const radius = this.radius();
const {width, height} = this.size();
const x = width / -2;
const y = height / -2;
const rect = RectType.fromSizeCentered(this.size());
drawRoundRect(path, rect, radius);
if (radius > 0) {
const maxRadius = Math.min(height / 2, width / 2, radius);
path.moveTo(x + maxRadius, y);
path.arcTo(x + width, y, x + width, y + height, maxRadius);
path.arcTo(x + width, y + height, x, y + height, maxRadius);
path.arcTo(x, y + height, x, y, maxRadius);
path.arcTo(x, y, x + width, y, maxRadius);
} else {
path.rect(x, y, width, height);
}
return path;
}
protected override getCacheRect(): RectType {
return super.getCacheRect().expand(this.rippleSize());
}
protected override getRipplePath(): Path2D {
const path = new Path2D();
const rippleSize = this.rippleSize();
const radius = this.radius() + rippleSize;
const rect = RectType.fromSizeCentered(this.size()).expand(rippleSize);
drawRoundRect(path, rect, radius);
return path;
}

View File

@@ -1,20 +1,21 @@
import {Gradient, Pattern} from '../partials';
import {property} from '../decorators';
import {Signal} from '@motion-canvas/core/lib/utils';
import {CanvasStyle} from '../partials';
import {computed, property} from '../decorators';
import {createSignal, Signal, SignalValue} from '@motion-canvas/core/lib/utils';
import {Rect} from '@motion-canvas/core/lib/types';
import {Layout, LayoutProps} from './Layout';
export type CanvasStyle = null | string | Gradient | Pattern;
import {threadable} from '@motion-canvas/core/lib/decorators';
import {easeOutExpo, linear, map} from '@motion-canvas/core/lib/tweening';
import {parseCanvasStyle} from '../utils';
export interface ShapeProps extends LayoutProps {
fill?: CanvasStyle;
stroke?: CanvasStyle;
strokeFirst?: boolean;
lineWidth?: number;
lineJoin?: CanvasLineJoin;
lineCap?: CanvasLineCap;
lineDash?: number[];
lineDashOffset?: number;
fill?: SignalValue<CanvasStyle>;
stroke?: SignalValue<CanvasStyle>;
strokeFirst?: SignalValue<boolean>;
lineWidth?: SignalValue<number>;
lineJoin?: SignalValue<CanvasLineJoin>;
lineCap?: SignalValue<CanvasLineCap>;
lineDash?: SignalValue<number>[];
lineDashOffset?: SignalValue<number>;
}
export abstract class Shape extends Layout {
@@ -35,29 +36,20 @@ export abstract class Shape extends Layout {
@property(0)
public declare readonly lineDashOffset: Signal<number, this>;
protected readonly rippleStrength = createSignal<number, this>(0);
@computed()
protected rippleSize() {
return easeOutExpo(this.rippleStrength(), 0, 50);
}
public constructor(props: ShapeProps) {
super(props);
}
protected parseCanvasStyle(
style: CanvasStyle,
context: CanvasRenderingContext2D,
): string | CanvasGradient | CanvasPattern {
if (style === null) {
return '';
}
if (typeof style === 'string') {
return style;
}
if (style instanceof Gradient) {
return style.canvasGradient(context);
}
return style.canvasPattern(context) ?? '';
}
protected applyStyle(context: CanvasRenderingContext2D) {
context.fillStyle = this.parseCanvasStyle(this.fill(), context);
context.strokeStyle = this.parseCanvasStyle(this.stroke(), context);
context.fillStyle = parseCanvasStyle(this.fill(), context);
context.strokeStyle = parseCanvasStyle(this.stroke(), context);
context.lineWidth = this.lineWidth();
context.lineJoin = this.lineJoin();
context.lineCap = this.lineCap();
@@ -66,22 +58,28 @@ export abstract class Shape extends Layout {
}
protected override draw(context: CanvasRenderingContext2D) {
this.drawShape(context);
if (this.clip()) {
context.clip(this.getPath());
}
this.drawChildren(context);
}
protected drawShape(context: CanvasRenderingContext2D) {
const path = this.getPath();
const hasStroke = this.lineWidth() > 0 && this.stroke() !== null;
const hasFill = this.fill() !== null;
context.save();
this.applyStyle(context);
if (this.lineWidth() <= 0) {
context.fill(path);
} else if (this.strokeFirst()) {
context.stroke(path);
context.fill(path);
this.drawRipple(context);
if (this.strokeFirst()) {
hasStroke && context.stroke(path);
hasFill && context.fill(path);
} else {
context.fill(path);
context.stroke(path);
hasFill && context.fill(path);
hasStroke && context.stroke(path);
}
context.restore();
super.draw(context);
}
protected override getCacheRect(): Rect {
@@ -91,4 +89,26 @@ export abstract class Shape extends Layout {
protected getPath(): Path2D {
return new Path2D();
}
protected getRipplePath(): Path2D {
return new Path2D();
}
protected drawRipple(context: CanvasRenderingContext2D) {
const rippleStrength = this.rippleStrength();
if (rippleStrength > 0) {
const ripplePath = this.getRipplePath();
context.save();
context.globalAlpha *= map(0.54, 0, rippleStrength);
context.fill(ripplePath);
context.restore();
}
}
@threadable()
public *ripple(duration = 1) {
this.rippleStrength(0);
yield* this.rippleStrength(1, duration, linear);
this.rippleStrength(0);
}
}

View File

@@ -1,12 +1,12 @@
import {property} from '../decorators';
import {Signal} from '@motion-canvas/core/lib/utils';
import {Signal, SignalValue} from '@motion-canvas/core/lib/utils';
import {textLerp} from '@motion-canvas/core/lib/tweening';
import {Shape, ShapeProps} from './Shape';
import {Rect} from '@motion-canvas/core/lib/types';
export interface TextProps extends ShapeProps {
children?: string;
text?: string;
text?: SignalValue<string>;
}
export class Text extends Shape {
@@ -80,7 +80,7 @@ export class Text extends Shape {
context.measureText(text).fontBoundingBoxDescent;
if (this.lineWidth() <= 0) {
context.fillText(text, rect.x, rect.y + rect.height / 2);
context.fillText(text, rect.x, y);
} else if (this.strokeFirst()) {
context.strokeText(text, rect.x, y);
context.fillText(text, rect.x, y);
@@ -103,7 +103,7 @@ export class Text extends Shape {
this.element.appendChild(document.createTextNode(word.segment));
}
} else {
this.element.innerText = this.text();
this.element.innerHTML = this.text();
}
if (wrap && !Text.segmenter) {

View File

@@ -122,11 +122,19 @@ export function createProperty<
}
const from = getter();
return tween(duration, value => {
setter(
interpolationFunction(from, unwrap(newValue), timingFunction(value)),
);
});
return tween(
duration,
value => {
setter(
interpolationFunction(
from,
unwrap(newValue),
timingFunction(value),
),
);
},
() => setter(wrap(newValue)),
);
}
);

View File

@@ -1,3 +1,6 @@
import type {Gradient} from './Gradient';
import type {Pattern} from './Pattern';
export type FlexDirection = 'row' | 'row-reverse' | 'column' | 'column-reverse';
export type FlexWrap = 'nowrap' | 'wrap' | 'wrap-reverse';
@@ -32,3 +35,5 @@ export type ResolvedLayoutMode = 'disabled' | 'enabled' | 'root' | 'pop';
export type LayoutMode = ResolvedLayoutMode | null;
export type Length = number | `${number}%` | null;
export type CanvasStyle = null | string | Gradient | Pattern;

View File

@@ -4,6 +4,15 @@ import {
SceneRenderEvent,
} from '@motion-canvas/core/lib/scenes';
import {TwoDView} from './TwoDView';
import {useScene} from '@motion-canvas/core/lib/utils';
export function use2DView(): TwoDView | null {
const scene = useScene();
if (scene instanceof TwoDScene) {
return scene.getView();
}
return null;
}
export class TwoDScene extends GeneratorScene<TwoDView> {
private readonly view = new TwoDView();

View File

@@ -1,4 +1,4 @@
import {Layout} from '../components';
import {Layout, Node} from '../components';
export class TwoDView extends Layout {
public static frameID = 'motion-canvas-2d-frame';
@@ -23,6 +23,8 @@ export class TwoDView extends Layout {
this.document = frame.contentDocument ?? document;
}
private registeredNodes: Node[] = [];
public constructor() {
super({
// TODO Sync with the project size
@@ -42,6 +44,10 @@ export class TwoDView extends Layout {
public reset() {
this.removeChildren();
for (const node of this.registeredNodes) {
node.dispose();
}
this.registeredNodes = [];
this.element.innerText = '';
}
@@ -89,4 +95,8 @@ export class TwoDView extends Layout {
public override view(): TwoDView | null {
return this;
}
public registerNode(node: Node) {
this.registeredNodes.push(node);
}
}

View File

@@ -0,0 +1,84 @@
import {CanvasStyle, Gradient, Pattern} from '../partials';
import {Rect} from '@motion-canvas/core/lib/types';
export function parseCanvasStyle(
style: CanvasStyle,
context: CanvasRenderingContext2D,
): string | CanvasGradient | CanvasPattern {
if (style === null) {
return '';
}
if (typeof style === 'string') {
return style;
}
if (style instanceof Gradient) {
return style.canvasGradient(context);
}
if (style instanceof Pattern) {
return style.canvasPattern(context) ?? '';
}
return '';
}
export function drawRoundRect(
context: CanvasRenderingContext2D | Path2D,
rect: Rect,
radius: number,
) {
if (radius > 0) {
const maxRadius = Math.min(rect.width / 2, rect.height / 2, radius);
context.moveTo(rect.left + maxRadius, rect.top);
context.arcTo(rect.right, rect.top, rect.right, rect.bottom, maxRadius);
context.arcTo(rect.right, rect.bottom, rect.left, rect.bottom, maxRadius);
context.arcTo(rect.left, rect.bottom, rect.left, rect.top, maxRadius);
context.arcTo(rect.left, rect.top, rect.right, rect.top, maxRadius);
} else {
drawRect(context, rect);
}
}
export function drawRect(
context: CanvasRenderingContext2D | Path2D,
rect: Rect,
) {
context.rect(rect.x, rect.y, rect.width, rect.height);
}
export function fillRect(context: CanvasRenderingContext2D, rect: Rect) {
context.fillRect(rect.x, rect.y, rect.width, rect.height);
}
export function drawImage(
context: CanvasRenderingContext2D,
image: CanvasImageSource,
destination: Rect,
): void;
export function drawImage(
context: CanvasRenderingContext2D,
image: CanvasImageSource,
source: Rect,
destination: Rect,
): void;
export function drawImage(
context: CanvasRenderingContext2D,
image: CanvasImageSource,
first: Rect,
second?: Rect,
): void {
if (second) {
context.drawImage(
image,
first.x,
first.y,
first.width,
first.height,
second.x,
second.y,
second.width,
second.height,
);
} else {
context.drawImage(image, first.x, first.y, first.width, first.height);
}
}

View File

@@ -0,0 +1 @@
export * from './CanvasUtils';

View File

@@ -1,5 +1,5 @@
import Color from 'colorjs.io';
import type {Rect, Vector2, PossibleSpacing, Spacing, Size} from '../types';
import {Vector2} from '../types';
export interface InterpolationFunction<T, Rest extends unknown[] = unknown[]> {
(from: T, to: T, value: number, ...args: Rest): T;
@@ -189,3 +189,27 @@ export function clampRemap(
return clamp(fromOut, toOut, remappedValue);
}
export function arcLerp(
value: number,
reverse: boolean,
ratio: number,
): Vector2 {
let flip = reverse;
if (ratio > 1) {
ratio = 1 / ratio;
} else {
flip = !flip;
}
const normalized = flip ? Math.acos(1 - value) : Math.asin(value);
const radians = map(normalized, map(0, Math.PI / 2, value), ratio);
let xValue = Math.sin(radians);
let yValue = 1 - Math.cos(radians);
if (reverse) {
[xValue, yValue] = [yValue, xValue];
}
return new Vector2(xValue, yValue);
}

View File

@@ -1,5 +1,4 @@
import type {Vector2} from './Vector';
import type {Size} from './Size';
import {Vector2} from './Vector';
export enum Center {
Vertical = 1,
@@ -49,3 +48,36 @@ export function flipOrigin(
return origin;
}
/**
* Convert the given origin to a vector representing its offset.
*
* @example
* ```ts
* const bottomRight = originToOffset(Origin.TopRight);
* // bottomRight = {x: 1, y: -1}
* ```
*
* @param origin - The origin to convert.
*/
export function originToOffset(origin: Origin | Direction): Vector2 {
if (origin === Origin.Middle) {
return Vector2.zero;
}
let x = 0;
if (origin & Direction.Left) {
x = -1;
} else if (origin & Direction.Right) {
x = 1;
}
let y = 0;
if (origin & Direction.Top) {
y = -1;
} else if (origin & Direction.Bottom) {
y = 1;
}
return new Vector2(x, y);
}

View File

@@ -1,6 +1,6 @@
import {Vector2} from './Vector';
import {Size} from './Size';
import {map} from '../tweening';
import {arcLerp, map} from '../tweening';
export type SerializedRect = {
x: number;
@@ -21,15 +21,54 @@ export class Rect {
public width = 0;
public height = 0;
public static lerp(from: Rect, to: Rect, value: number): Rect {
public static lerp(
from: Rect,
to: Rect,
value: number | Vector2 | Rect,
): Rect {
let valueX;
let valueY;
let valueWidth;
let valueHeight;
if (typeof value === 'number') {
valueX = valueY = valueWidth = valueHeight = value;
} else if (value instanceof Vector2) {
valueX = valueWidth = value.x;
valueY = valueHeight = value.y;
} else {
valueX = value.x;
valueY = value.y;
valueWidth = value.width;
valueHeight = value.height;
}
return new Rect(
map(from.x, to.x, value),
map(from.y, to.y, value),
map(from.width, to.width, value),
map(from.height, to.height, value),
map(from.x, to.x, valueX),
map(from.y, to.y, valueY),
map(from.width, to.width, valueWidth),
map(from.height, to.height, valueHeight),
);
}
public static arcLerp(
from: Rect,
to: Rect,
value: number,
reverse = false,
ratio?: number,
) {
ratio ??=
(from.position.sub(to.position).ctg +
from.size.vector.sub(to.size.vector).ctg) /
2;
return Rect.lerp(from, to, arcLerp(value, reverse, ratio));
}
public static fromSizeCentered(size: Size): Rect {
return new Rect(-size.width / 2, -size.height / 2, size.width, size.height);
}
public static fromPoints(...points: Vector2[]): Rect {
let minX = Infinity;
let minY = Infinity;
@@ -88,6 +127,40 @@ export class Rect {
return new Size(this.width, this.height);
}
public get left() {
return this.x;
}
public set left(value: number) {
this.width = this.right - value;
this.x = value;
}
public get right() {
return this.x + this.width;
}
public set right(value: number) {
this.width = value - this.x;
}
public get top() {
return this.y;
}
public set top(value: number) {
this.width = this.bottom - value;
this.y = value;
}
public get bottom() {
return this.y + this.height;
}
public set bottom(value: number) {
this.height = value - this.y;
}
public get topLeft() {
return this.position;
}

View File

@@ -1,6 +1,6 @@
import {Size} from './Size';
import {Rect} from './Rect';
import {map} from '../tweening';
import {arcLerp, map} from '../tweening';
import {Direction, Origin} from './Origin';
export type SerializedVector2 = {
@@ -20,9 +20,35 @@ export class Vector2 {
public y = 0;
public static readonly zero = new Vector2();
public static readonly one = new Vector2(1, 1);
public static readonly right = new Vector2(1, 0);
public static readonly left = new Vector2(-1, 0);
public static readonly up = new Vector2(0, 1);
public static readonly down = new Vector2(0, -1);
public static lerp(from: Vector2, to: Vector2, value: number) {
return new Vector2(map(from.x, to.x, value), map(from.y, to.y, value));
public static lerp(from: Vector2, to: Vector2, value: number | Vector2) {
let valueX;
let valueY;
if (typeof value === 'number') {
valueX = valueY = value;
} else {
valueX = value.x;
valueY = value.y;
}
return new Vector2(map(from.x, to.x, valueX), map(from.y, to.y, valueY));
}
public static arcLerp(
from: Vector2,
to: Vector2,
value: number,
reverse?: boolean,
ratio?: number,
) {
ratio ??= from.sub(to).ctg;
return Vector2.lerp(from, to, arcLerp(value, reverse, ratio));
}
public static fromOrigin(origin: Origin | Direction) {
@@ -47,6 +73,10 @@ export class Vector2 {
return position;
}
public static fromScalar(value: number): Vector2 {
return new Vector2(value, value);
}
public static fromRadians(radians: number) {
return new Vector2(Math.cos(radians), Math.sin(radians));
}
@@ -59,10 +89,30 @@ export class Vector2 {
return Vector2.magnitude(this.x, this.y);
}
public get normalized(): Vector2 {
return this.scale(1 / Vector2.magnitude(this.x, this.y));
}
public get safe(): Vector2 {
return new Vector2(isNaN(this.x) ? 0 : this.x, isNaN(this.y) ? 0 : this.y);
}
public get flipped(): Vector2 {
return new Vector2(-this.x, -this.y);
}
public get perpendicular(): Vector2 {
return new Vector2(this.y, -this.x);
}
public get radians() {
return Math.atan2(this.y, this.x);
}
public get ctg(): number {
return this.x / this.y;
}
public constructor();
public constructor(from: PossibleVector2);
public constructor(x: number, y: number);
@@ -119,6 +169,14 @@ export class Vector2 {
return new Vector2(this.x + vector.x, this.y + vector.y);
}
public sub(vector: Vector2) {
return new Vector2(this.x - vector.x, this.y - vector.y);
}
public dot(vector: Vector2): number {
return this.x * vector.x + this.y * vector.y;
}
public addX(value: number) {
return new Vector2(this.x + value, this.y);
}

View File

@@ -154,15 +154,19 @@ export function createSignal<TValue, TReturn = void>(
}
const from = get();
return tween(duration, v => {
set(
interpolationFunction(
from,
isReactive(value) ? value() : value,
timingFunction(v),
),
);
});
return tween(
duration,
v => {
set(
interpolationFunction(
from,
isReactive(value) ? value() : value,
timingFunction(v),
),
);
},
() => set(value),
);
}
);

View File

@@ -1,5 +1,5 @@
export type Reference<TValue> = TValue extends (config: {
ref: infer TReference;
ref?: infer TReference;
}) => void
? TReference
: {value: TValue};