mirror of
https://github.com/motion-canvas/motion-canvas.git
synced 2026-01-12 07:18:01 -05:00
feat: circular mask for surfaces
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
threads,
|
||||
ThreadsJoin,
|
||||
ThreadsCancel,
|
||||
showCircle,
|
||||
} from './animations';
|
||||
|
||||
Konva.autoDrawEnabled = false;
|
||||
@@ -117,6 +118,7 @@ export class Project extends Stage {
|
||||
public tween = tween;
|
||||
public moveNode = move;
|
||||
public showTop = showTop;
|
||||
public showCircle = showCircle;
|
||||
public surfaceTransition = surfaceTransition;
|
||||
public showSurface = showSurface;
|
||||
public sequence = sequence;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {Node} from 'konva/lib/Node';
|
||||
import {Project} from '../Project';
|
||||
import {Surface} from "../components/Surface";
|
||||
import {TimeTween} from "./TimeTween";
|
||||
import {Spacing} from "../types";
|
||||
import {Surface} from '../components/Surface';
|
||||
import {TimeTween} from './TimeTween';
|
||||
import {Origin, originPosition, Spacing} from '../types';
|
||||
import {chain} from '../flow';
|
||||
import {Vector2d} from 'konva/lib/types';
|
||||
|
||||
export function showTop(this: Project, node: Node): [Generator, Generator] {
|
||||
const to = node.offsetY();
|
||||
@@ -31,19 +33,43 @@ export function showSurface(this: Project, surface: Surface): Generator {
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
surface.setOverride(true);
|
||||
|
||||
surface.setMargin(0);
|
||||
surface.setMask(fromMask)
|
||||
surface.setMask(fromMask);
|
||||
|
||||
return this.tween(0.5, value => {
|
||||
surface.setMask(
|
||||
{
|
||||
...toMask,
|
||||
width: value.easeInOutCubic(fromMask.width, toMask.width),
|
||||
height: value.easeInOutCubic(fromMask.height, toMask.height),
|
||||
}
|
||||
)
|
||||
surface.setMargin(value.spacing(marginFrom, margin, value.easeInOutCubic()));
|
||||
surface.setMask({
|
||||
...toMask,
|
||||
width: value.easeInOutCubic(fromMask.width, toMask.width),
|
||||
height: value.easeInOutCubic(fromMask.height, toMask.height),
|
||||
});
|
||||
surface.setMargin(
|
||||
value.spacing(marginFrom, margin, value.easeInOutCubic()),
|
||||
);
|
||||
surface.opacity(TimeTween.clampRemap(0.3, 1, 0, 1, value.value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function showCircle(
|
||||
this: Project,
|
||||
surface: Surface,
|
||||
origin?: Origin | Vector2d,
|
||||
): Generator {
|
||||
const position = typeof origin === 'object' ? origin : originPosition(origin ?? surface.getOrigin());
|
||||
const mask = surface.getAbsoluteCircleMask({
|
||||
circleMask: {
|
||||
...position,
|
||||
radius: 1,
|
||||
},
|
||||
});
|
||||
surface.setCircleMask(mask);
|
||||
const target = mask.radius;
|
||||
mask.radius = 0;
|
||||
|
||||
return chain(
|
||||
this.tween(target / 2000, value => {
|
||||
mask.radius = value.easeInOutCubic(0, target);
|
||||
}),
|
||||
() => surface.setCircleMask(null),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ export function surfaceTransition(this: Project, fromSurfaceOriginal: Surface) {
|
||||
.zIndex(fromSurfaceOriginal.zIndex());
|
||||
|
||||
fromSurfaceOriginal.hide();
|
||||
fromSurface.setOverride(true);
|
||||
const from = fromSurfaceOriginal.getMask();
|
||||
|
||||
const project = this;
|
||||
@@ -45,7 +44,6 @@ export function surfaceTransition(this: Project, fromSurfaceOriginal: Surface) {
|
||||
if (value.value > 1 / 3) {
|
||||
if (check) {
|
||||
target.show();
|
||||
target.setOverride(true);
|
||||
fromSurface.destroy();
|
||||
}
|
||||
|
||||
@@ -85,7 +83,7 @@ export function surfaceTransition(this: Project, fromSurfaceOriginal: Surface) {
|
||||
}
|
||||
});
|
||||
|
||||
target.setOverride(false);
|
||||
target.setMask(null);
|
||||
target.show();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import {Size, Origin, Direction, PossibleSpacing, Spacing} from '../types';
|
||||
import {
|
||||
Size,
|
||||
Origin,
|
||||
PossibleSpacing,
|
||||
Spacing,
|
||||
originPosition,
|
||||
} from '../types';
|
||||
import {Node} from 'konva/lib/Node';
|
||||
import {LayoutGroup} from 'MC/components/LayoutGroup';
|
||||
import {LayoutShape} from 'MC/components/LayoutShape';
|
||||
import {IRect, Vector2d} from 'konva/lib/types';
|
||||
import {Shape} from "konva/lib/Shape";
|
||||
import {Group} from "konva/lib/Group";
|
||||
import {Shape} from 'konva/lib/Shape';
|
||||
import {Group} from 'konva/lib/Group';
|
||||
|
||||
export const LAYOUT_CHANGE_EVENT = 'layoutChange';
|
||||
|
||||
@@ -51,27 +57,7 @@ export function isInsideLayout(node: Node) {
|
||||
}
|
||||
|
||||
export function getOriginOffset(size: Size, origin: Origin): Vector2d {
|
||||
const width = size.width / 2;
|
||||
const height = size.height / 2;
|
||||
const offset: Vector2d = {x: 0, y: 0};
|
||||
|
||||
if (origin === Origin.Middle) {
|
||||
return offset;
|
||||
}
|
||||
|
||||
if (origin & Direction.Left) {
|
||||
offset.x = -width;
|
||||
} else if (origin & Direction.Right) {
|
||||
offset.x = width;
|
||||
}
|
||||
|
||||
if (origin & Direction.Top) {
|
||||
offset.y = -height;
|
||||
} else if (origin & Direction.Bottom) {
|
||||
offset.y = height;
|
||||
}
|
||||
|
||||
return offset;
|
||||
return originPosition(origin, size.width / 2, size.height / 2);
|
||||
}
|
||||
|
||||
export function getOriginDelta(size: Size, from: Origin, to: Origin) {
|
||||
|
||||
@@ -6,8 +6,15 @@ import {parseColor} from 'mix-color';
|
||||
import {Project} from '../Project';
|
||||
import {LayoutGroup} from './LayoutGroup';
|
||||
import {Origin, Size} from '../types';
|
||||
import {getOriginDelta, getOriginOffset, isLayoutNode, LayoutAttrs, LayoutNode} from './ILayoutNode';
|
||||
import {CanvasHelper} from "../helpers";
|
||||
import {
|
||||
getOriginDelta,
|
||||
getOriginOffset,
|
||||
isLayoutNode,
|
||||
LayoutAttrs,
|
||||
LayoutNode,
|
||||
} from './ILayoutNode';
|
||||
import {CanvasHelper} from '../helpers';
|
||||
import {Context} from 'konva/lib/Context';
|
||||
|
||||
export type LayoutData = LayoutAttrs & Size;
|
||||
export interface SurfaceMask {
|
||||
@@ -17,16 +24,23 @@ export interface SurfaceMask {
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface CircleMask {
|
||||
radius: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface SurfaceConfig extends ContainerConfig {
|
||||
origin?: Origin;
|
||||
circleMask?: CircleMask;
|
||||
}
|
||||
|
||||
export class Surface extends LayoutGroup {
|
||||
private box: Rect;
|
||||
private ripple: Rect;
|
||||
private child: LayoutNode;
|
||||
private override: boolean = false;
|
||||
private mask: SurfaceMask = null;
|
||||
private surfaceMask: SurfaceMask | null = null;
|
||||
private circleMask: CircleMask | null = null;
|
||||
private layoutData: LayoutData;
|
||||
|
||||
public constructor(config?: SurfaceConfig) {
|
||||
@@ -50,16 +64,20 @@ export class Surface extends LayoutGroup {
|
||||
);
|
||||
}
|
||||
|
||||
getLayoutSize(): Size {
|
||||
return this.override && this.mask
|
||||
? {
|
||||
width: this.mask?.width ?? 0,
|
||||
height: this.mask?.height ?? 0,
|
||||
}
|
||||
: {
|
||||
width: this.layoutData?.width ?? 0,
|
||||
height: this.layoutData?.height ?? 0,
|
||||
};
|
||||
public setCircleMask(value: CircleMask | null): this {
|
||||
this.circleMask = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public getCircleMask(): CircleMask | null {
|
||||
return this.circleMask ?? null;
|
||||
}
|
||||
|
||||
getLayoutSize(custom?: SurfaceConfig): Size {
|
||||
return {
|
||||
width: this.surfaceMask?.width ?? this.layoutData?.width ?? 0,
|
||||
height: this.surfaceMask?.height ?? this.layoutData?.height ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
add(...children: (Shape | Group)[]): this {
|
||||
@@ -94,7 +112,7 @@ export class Surface extends LayoutGroup {
|
||||
}
|
||||
|
||||
public *doRipple() {
|
||||
if (this.override) return;
|
||||
if (this.surfaceMask) return;
|
||||
const opaque = parseColor(this.layoutData.color);
|
||||
this.ripple.show();
|
||||
this.ripple
|
||||
@@ -131,12 +149,12 @@ export class Surface extends LayoutGroup {
|
||||
return this.child;
|
||||
}
|
||||
|
||||
public setOverride(value: boolean) {
|
||||
this.override = value;
|
||||
this.clipFunc(value ? this.drawMask : null);
|
||||
if (!value) {
|
||||
public setMask(data: SurfaceMask) {
|
||||
if (data === null) {
|
||||
this.surfaceMask = null;
|
||||
this.handleLayoutChange();
|
||||
} else {
|
||||
return;
|
||||
} else if (this.surfaceMask === null) {
|
||||
this.box
|
||||
.offsetX(5000)
|
||||
.offsetY(5000)
|
||||
@@ -144,9 +162,7 @@ export class Surface extends LayoutGroup {
|
||||
.width(10000)
|
||||
.height(100000);
|
||||
}
|
||||
}
|
||||
|
||||
public setMask(data: SurfaceMask) {
|
||||
const newOffset = getOriginOffset(data, this.getOrigin());
|
||||
const contentSize = this.child.getLayoutSize();
|
||||
const contentMargin = this.child.getMargin();
|
||||
@@ -155,7 +171,7 @@ export class Surface extends LayoutGroup {
|
||||
data.width / (contentSize.width + contentMargin.x),
|
||||
);
|
||||
|
||||
this.mask = data;
|
||||
this.surfaceMask = data;
|
||||
this.offset(newOffset);
|
||||
this.child.scaleX(scale);
|
||||
this.child.scaleY(scale);
|
||||
@@ -171,7 +187,7 @@ export class Surface extends LayoutGroup {
|
||||
}
|
||||
|
||||
protected handleLayoutChange() {
|
||||
if (this.override) return;
|
||||
if (this.surfaceMask) return;
|
||||
|
||||
this.layoutData ??= {
|
||||
origin: Origin.Middle,
|
||||
@@ -224,17 +240,55 @@ export class Surface extends LayoutGroup {
|
||||
}
|
||||
}
|
||||
|
||||
private drawMask(ctx: CanvasRenderingContext2D) {
|
||||
const offset = this.offsetY();
|
||||
const newOffset = getOriginOffset(this.mask, this.getOrigin());
|
||||
private drawMask(ctx: Context) {
|
||||
if (this.surfaceMask) {
|
||||
const offset = this.offsetY();
|
||||
const newOffset = getOriginOffset(this.surfaceMask, this.getOrigin());
|
||||
CanvasHelper.roundRect(
|
||||
ctx,
|
||||
-this.surfaceMask.width / 2,
|
||||
-this.surfaceMask.height / 2 + offset - newOffset.y,
|
||||
this.surfaceMask.width,
|
||||
this.surfaceMask.height,
|
||||
this.surfaceMask.radius,
|
||||
);
|
||||
if (this.circleMask) {
|
||||
ctx._context.clip(this.drawCircleMask(new Path2D()));
|
||||
}
|
||||
} else {
|
||||
this.drawCircleMask(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
CanvasHelper.roundRect(
|
||||
ctx,
|
||||
-this.mask.width / 2,
|
||||
-this.mask.height / 2 + offset - newOffset.y,
|
||||
this.mask.width,
|
||||
this.mask.height,
|
||||
this.mask.radius,
|
||||
private drawCircleMask<T extends Context | Path2D>(ctx: T): T {
|
||||
const mask = this.circleMask;
|
||||
ctx.arc(mask.x, mask.y, mask.radius, 0, Math.PI * 2, false);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
public getClipFunc() {
|
||||
return this.circleMask || this.surfaceMask ? this.drawMask : null;
|
||||
}
|
||||
|
||||
public getAbsoluteCircleMask(custom?: SurfaceConfig): CircleMask {
|
||||
const mask = custom?.circleMask ?? this.circleMask ?? null;
|
||||
if (mask === null) return null;
|
||||
const size = this.getLayoutSize(custom);
|
||||
const position = {
|
||||
x: size.width * mask.x / 2,
|
||||
y: size.height * mask.y / 2,
|
||||
};
|
||||
const farthestEdge = {
|
||||
x: Math.abs(position.x) + size.width / 2,
|
||||
y: Math.abs(position.y) + size.height / 2,
|
||||
};
|
||||
const distance = Math.sqrt(
|
||||
farthestEdge.x * farthestEdge.x + farthestEdge.y * farthestEdge.y,
|
||||
);
|
||||
|
||||
return {
|
||||
...position,
|
||||
radius: distance * mask.radius,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,25 +35,18 @@ class CanvasPool implements Pool<HTMLCanvasElement> {
|
||||
}
|
||||
}
|
||||
|
||||
const canvasPool2D = new CanvasPool();
|
||||
const canvasPool3D = new CanvasPool();
|
||||
|
||||
export class ThreeView extends LayoutShape {
|
||||
private readonly threeCanvas: HTMLCanvasElement;
|
||||
private readonly copyCanvas: HTMLCanvasElement;
|
||||
private readonly renderer: THREE.WebGLRenderer;
|
||||
private readonly context: WebGLRenderingContext;
|
||||
private readonly copyContext: CanvasRenderingContext2D;
|
||||
|
||||
private copyData: ImageData;
|
||||
private pixels: Uint8ClampedArray;
|
||||
private renderedFrames: number = 0;
|
||||
|
||||
public constructor(config?: ThreeViewConfig) {
|
||||
super(config);
|
||||
this.threeCanvas = canvasPool3D.borrow();
|
||||
this.copyCanvas = canvasPool2D.borrow();
|
||||
this.copyContext = this.copyCanvas.getContext('2d');
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
canvas: this.threeCanvas,
|
||||
@@ -141,7 +134,6 @@ export class ThreeView extends LayoutShape {
|
||||
|
||||
destroy(): this {
|
||||
this.renderer.dispose();
|
||||
canvasPool2D.dispose(this.copyCanvas);
|
||||
canvasPool3D.dispose(this.threeCanvas);
|
||||
|
||||
return super.destroy();
|
||||
@@ -169,10 +161,6 @@ export class ThreeView extends LayoutShape {
|
||||
size.width *= this.getQuality();
|
||||
size.height *= this.getQuality();
|
||||
this.renderer.setSize(size.width, size.height);
|
||||
this.copyCanvas.width = size.width;
|
||||
this.copyCanvas.height = size.height;
|
||||
this.copyData = this.copyContext.createImageData(size.width, size.height);
|
||||
this.pixels = new Uint8ClampedArray(size.width * size.height * 4);
|
||||
}
|
||||
|
||||
getLayoutSize(): Size {
|
||||
@@ -182,52 +170,38 @@ export class ThreeView extends LayoutShape {
|
||||
_sceneFunc(context: Context) {
|
||||
const scale = this.getQuality();
|
||||
const size = this.getCanvasSize();
|
||||
size.width *= scale;
|
||||
size.height *= scale;
|
||||
|
||||
if (this.renderedFrames < 1) {
|
||||
this.renderedFrames = this.getSkipFrames();
|
||||
this.renderer.render(this.getScene(), this.getCamera());
|
||||
this.context.readPixels(
|
||||
0,
|
||||
0,
|
||||
size.width,
|
||||
size.height,
|
||||
this.context.RGBA,
|
||||
this.context.UNSIGNED_BYTE,
|
||||
this.pixels,
|
||||
);
|
||||
|
||||
this.copyData.data.set(this.pixels);
|
||||
this.copyContext.putImageData(this.copyData, 0, 0);
|
||||
} else {
|
||||
this.renderedFrames--;
|
||||
}
|
||||
|
||||
context.save();
|
||||
context._context.save();
|
||||
context._context.imageSmoothingEnabled = false;
|
||||
context.scale(1 / scale, 1 / -scale);
|
||||
|
||||
CanvasHelper.roundRect(
|
||||
context._context,
|
||||
size.width / -2,
|
||||
size.height / -2,
|
||||
size.width,
|
||||
size.height,
|
||||
this.getRadius(),
|
||||
context._context.clip(
|
||||
CanvasHelper.roundRectPath(
|
||||
new Path2D(),
|
||||
size.width / -2,
|
||||
size.height / -2,
|
||||
size.width,
|
||||
size.height,
|
||||
this.getRadius(),
|
||||
),
|
||||
);
|
||||
context.clip();
|
||||
context.drawImage(
|
||||
this.copyCanvas,
|
||||
context._context.drawImage(
|
||||
this.threeCanvas,
|
||||
0,
|
||||
0,
|
||||
size.width,
|
||||
size.height,
|
||||
size.width * scale,
|
||||
size.height * scale,
|
||||
size.width / -2,
|
||||
size.height / -2,
|
||||
size.width,
|
||||
size.height,
|
||||
);
|
||||
context.restore();
|
||||
context._context.restore();
|
||||
}
|
||||
}
|
||||
|
||||
13
src/flow/chain.ts
Normal file
13
src/flow/chain.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
function isGenerator(value: any): value is Generator {
|
||||
return Symbol.iterator in value;
|
||||
}
|
||||
|
||||
export function* chain(...args: (Generator | Function)[]) {
|
||||
for (const generator of args) {
|
||||
if (isGenerator(generator)) {
|
||||
yield* generator;
|
||||
} else {
|
||||
generator();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './all';
|
||||
export * from './any';
|
||||
export * from './chain';
|
||||
export * from './loop';
|
||||
|
||||
@@ -1,22 +1,39 @@
|
||||
import {SceneContext} from 'konva/lib/Context';
|
||||
import {Context} from 'konva/lib/Context';
|
||||
|
||||
export namespace CanvasHelper {
|
||||
export function roundRect(
|
||||
ctx: CanvasRenderingContext2D | SceneContext,
|
||||
export function roundRect<T extends CanvasRenderingContext2D | Context>(
|
||||
ctx: T,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
) {
|
||||
): T {
|
||||
if (width < 2 * radius) radius = width / 2;
|
||||
if (height < 2 * radius) radius = height / 2;
|
||||
ctx.beginPath();
|
||||
roundRectPath(ctx, x, y, width, height, radius);
|
||||
ctx.closePath();
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function roundRectPath<
|
||||
T extends CanvasRenderingContext2D | Context | Path2D,
|
||||
>(
|
||||
ctx: T,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
): T {
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.arcTo(x + width, y, x + width, y + height, radius);
|
||||
ctx.arcTo(x + width, y + height, x, y + height, radius);
|
||||
ctx.arcTo(x, y + height, x, y, radius);
|
||||
ctx.arcTo(x, y, x + width, y, radius);
|
||||
ctx.closePath();
|
||||
|
||||
return ctx;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import {Vector2d} from "konva/lib/types";
|
||||
|
||||
export enum Center {
|
||||
Vertical = 1,
|
||||
Horizontal = 2,
|
||||
@@ -46,3 +48,25 @@ export function flipOrigin(
|
||||
|
||||
return origin;
|
||||
}
|
||||
|
||||
export function originPosition(origin: Origin, width = 1 , height = 1): Vector2d {
|
||||
const position: Vector2d = {x: 0, y: 0};
|
||||
|
||||
if (origin === Origin.Middle) {
|
||||
return position;
|
||||
}
|
||||
|
||||
if (origin & Direction.Left) {
|
||||
position.x = -width;
|
||||
} else if (origin & Direction.Right) {
|
||||
position.x = width;
|
||||
}
|
||||
|
||||
if (origin & Direction.Top) {
|
||||
position.y = -height;
|
||||
} else if (origin & Direction.Bottom) {
|
||||
position.y = height;
|
||||
}
|
||||
|
||||
return position;
|
||||
}
|
||||
Reference in New Issue
Block a user