Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 44 additions & 46 deletions client/src/playground/shape/ShapePlayground.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,67 +3,65 @@
import { useMagicCanvas } from '@canvas/index';
import colors from '@colors';
import { useAnimatedShapes } from '@shape/animation';
import type { Shape } from '@shape/types';
import { cross } from '@shapes/cross';
import Button from '@ui/core/button/Button.vue';

import { ref } from 'vue';

const { defineTimeline, shapes } = useAnimatedShapes();
const { defineTimeline, shapes, getAnimatedProp } = useAnimatedShapes();

const { play, stop, pause, resume } = defineTimeline({
forShapes: ['line'],
durationMs: 4000,
forShapes: ['circle'],
durationMs: 6000,
customInterpolations: {
fillGradient: {
value: (p) => [
{
color: 'red',
offset: 0,
},
{
stroke: {
value: (progress) => {
const r = getAnimatedProp('test', 'radius');
const numOfDashes = 5;
const lengthGapRatio = 40 / 22.832;
const circum = 2 * r * Math.PI;
const p = circum / numOfDashes;
const dashLength = (lengthGapRatio / (lengthGapRatio + 1)) * p;
const gapLength = (1 / (lengthGapRatio + 1)) * p;
return {
lineWidth: 10,
color: 'red',
offset: p < 0.5 ? p * 2 : 2 - p * 2,
},
{
color: 'black',
offset: p < 0.5 ? p * 2 : 2 - p * 2,
},
],
},
},
keyframes: [
{
progress: 0.5,
properties: {
end: { x: 50, y: 300 },
start: { x: 250, y: 0 },
lineWidth: 50,
textArea: (ta) => ({
textBlock: {
fontSize: ta.textBlock.fontSize + 12,
dash: {
pattern: [dashLength, gapLength],
offset: progress * circum,
},
}),
};
},
},
],
},
keyframes: [{ progress: 0.5, properties: { radius: 100 } }],
});

const paintedShapes = ref<Shape[]>([]);
const cir = shapes.circle({
id: 'test',
at: { x: 0, y: 0 },
radius: 50,
textArea: { textBlock: { content: '1' } },
});

paintedShapes.value.push(
shapes.line({
id: 'test',
start: { x: 0, y: 0 },
end: { x: 200, y: 200 },
textArea: { textBlock: { content: 'real' } },
fillColor: 'purple',
}),
);
const cir2 = shapes.circle({
id: 'test2',
at: { x: 200, y: 0 },
radius: 50,
stroke: {
lineWidth: 10,
color: 'red',
dash: {
pattern: [40, 22.832],
offset: 90,
},
},
textArea: { textBlock: { content: '2' } },
});

const magic = useMagicCanvas();
magic.draw.content.value = (ctx) =>
paintedShapes.value.forEach((i) => i.draw(ctx));
magic.draw.content.value = (ctx) => {
cir.draw(ctx);
cir2.draw(ctx);
};

magic.draw.backgroundPattern.value = (ctx, at) => {
cross({
Expand Down
139 changes: 97 additions & 42 deletions client/src/shapes/animation/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { shapeDefaults } from '@shape/defaults/shapes';
import type { UnionToIntersection } from 'ts-essentials';
import { getSchemaWithDefaults } from '@shape/defaults/shapes';
import type {
EverySchemaProp,
EverySchemaPropName,
SchemaId,
Shape,
Expand Down Expand Up @@ -61,13 +63,18 @@ export const useAnimatedShapes = () => {
const animations = activeAnimations.get(schemaId);
if (!animations || animations.length === 0) return;

let outputSchema = animations[0].schema;
let outputSchema = animations[0].schemaWithDefaults;

if (!outputSchema) {
console.warn('animation set without a schema. this should never happen!');
return;
}

const shapeName = schemaIdToShapeName.get(schemaId);
if (!shapeName) {
throw new Error('(Internal Error) Animation set without shape name mapping. this should never happen!');
}

for (const animation of animations) {
const timeline = timelineIdToTimeline.get(animation.timelineId);
if (!timeline) throw new Error('animation activated without a timeline!');
Expand All @@ -77,17 +84,9 @@ export const useAnimatedShapes = () => {
...animation,
};

const shapeName = schemaIdToShapeName.get(schemaId);
if (!shapeName) {
console.warn(
'animation set without shape name mapping. this should never happen!',
);
continue;
}

if (!animationWithTimeline.validShapes.has(shapeName)) {
console.warn('invalid shape name!');
continue;
const { validShapes, timelineId } = animationWithTimeline
if (!validShapes.has(shapeName)) {
throw new Error(`(Internal Error) Attempted to apply inappropriate animation to schema! Animation timeline ${timelineId} only works for shapes ${Array.from(validShapes.keys())} but schema ${schemaId} is of shape ${shapeName}.`);
}

// cleanup animation if expired
Expand Down Expand Up @@ -127,44 +126,44 @@ export const useAnimatedShapes = () => {
factory: ShapeFactory<T>,
shapeName: ShapeName,
): ShapeFactory<WithId<T>> =>
(schema) =>
new Proxy(factory(schema), {
get: (target, rawProp) => {
const prop = rawProp as keyof Shape;
if (!shapeProps.has(prop)) return target[prop];
(schema) =>
new Proxy(factory(schema), {
get: (target, rawProp) => {
const prop = rawProp as keyof Shape;
if (!shapeProps.has(prop)) return target[prop];

const animations = activeAnimations.get(schema.id);
const animations = activeAnimations.get(schema.id);

const defaultResolver:
| ((schema: LooseSchema) => LooseSchema)
| undefined = (shapeDefaults as any)?.[shapeName];
if (!defaultResolver)
throw new Error(`cant find defaults for ${shapeName}`);
const schemaWithDefaults = defaultResolver(schema);
const defaultResolver:
| ((schema: LooseSchema) => LooseSchema)
| undefined = (getSchemaWithDefaults as any)?.[shapeName];
if (!defaultResolver)
throw new Error(`cant find defaults for ${shapeName}`);
const schemaWithDefaults = defaultResolver(schema);

autoAnimate.captureSchemaState(schemaWithDefaults, shapeName);
autoAnimate.captureSchemaState(schemaWithDefaults, shapeName);

const targetMapSchema = autoAnimate.snapshotMap.get(schema.id);
if (targetMapSchema)
return factory(targetMapSchema as WithId<T>)[prop];
const targetMapSchema = autoAnimate.snapshotMap.get(schema.id);
if (targetMapSchema)
return factory(targetMapSchema as WithId<T>)[prop];

if (!animations || animations.length === 0) return target[prop];
if (!animations[0]?.schema) animations[0].schema = schemaWithDefaults;
if (!animations || animations.length === 0) return target[prop];
if (!animations[0]?.schemaWithDefaults) animations[0].schemaWithDefaults = schemaWithDefaults;

if (prop === 'startTextAreaEdit')
return console.warn(
'shapes with active animations cannot spawn text inputs',
);
if (prop === 'startTextAreaEdit')
return console.warn(
'shapes with active animations cannot spawn text inputs',
);

const hasShapeName = schemaIdToShapeName.get(schema.id);
if (!hasShapeName) schemaIdToShapeName.set(schema.id, shapeName);
const hasShapeName = schemaIdToShapeName.get(schema.id);
if (!hasShapeName) schemaIdToShapeName.set(schema.id, shapeName);

const animatedSchema = getAnimatedSchema(schema.id);
if (!animatedSchema) return target[prop];
const animatedSchema = getAnimatedSchema(schema.id);
if (!animatedSchema) return target[prop];

return factory(animatedSchema as WithId<T>)[prop];
},
});
return factory(animatedSchema as WithId<T>)[prop];
},
});

return {
shapes: {
Expand All @@ -184,6 +183,62 @@ export const useAnimatedShapes = () => {
defineTimeline,
autoAnimate: { captureFrame: autoAnimate.captureFrame },
getAnimatedSchema,
/**
* Get the animated value of a schema property currently being animated.
*
* Intended for use in imperative timelines where resolving one property's animated value
* depends on the animated value of another property. In these special cases, `getAnimatedSchema`
* would cause a circular dependency.
*
* WARNING: Calling this on a property that the imperative track itself resolves
* will crash your app!
*/
getAnimatedProp: <T extends EverySchemaPropName>(schemaId: string, inputPropName: T) => {
const animations = activeAnimations.get(schemaId);
if (!animations || animations.length === 0) {
throw new Error(`Schema with id ${schemaId} has no running animations`)
};

const { schemaWithDefaults } = animations[0]

if (!schemaWithDefaults) {
throw new Error('(Internal Error) Animation set without a schema. this should never happen!');
}

if (!(inputPropName in schemaWithDefaults)) {
throw new Error(`(User Error) Input prop name ${inputPropName} not a property on schema (${Object.keys(schemaWithDefaults)})`)
}

const shapeName = schemaIdToShapeName.get(schemaId);
if (!shapeName) {
throw new Error('(Internal Error) Animation set without shape name mapping. this should never happen!');
}

let propVal = schemaWithDefaults[inputPropName] as UnionToIntersection<EverySchemaProp>[T]

for (const animation of animations) {
const timeline = timelineIdToTimeline.get(animation.timelineId);
if (!timeline) throw new Error('(Internal Error) Animation activated without a timeline!');

const animationWithTimeline = {
...timeline,
...animation,
};

const { validShapes, timelineId } = animationWithTimeline
if (!validShapes.has(shapeName)) {
throw new Error(`(Internal Error) Attempted to apply inappropriate animation to schema! Animation timeline ${timelineId} only works for shapes ${Array.from(validShapes.keys())} but schema ${schemaId} is of shape ${shapeName}.`);
}

const { properties } = animationWithTimeline;
const progress = getAnimationProgress(animationWithTimeline);

const fn = properties[inputPropName as string]
propVal = fn(schemaWithDefaults, progress)
}

return propVal;
},
activeAnimations,
};
};
Expand Down
16 changes: 8 additions & 8 deletions client/src/shapes/animation/timeline/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,16 @@ export const compileTimeline = (timeline: Timeline<any>): CompiledTimeline => {
validShapes: new Set(timeline.forShapes),
};

const rawTimelineKeyframes = timeline?.keyframes ?? [];

const propsInTimeline = [
...new Set(
timeline.keyframes.map((kf) => Object.keys(kf.properties)).flat(),
rawTimelineKeyframes.map((kf) => Object.keys(kf.properties)).flat(),
),
] as EverySchemaPropName[];

const propToAnimationKeyframes = propsInTimeline.reduce((acc, prop) => {
const propInTimeline = timeline.keyframes
const propInTimeline = rawTimelineKeyframes
.map((kf): AnimationKeyframe<any> => {
const propVal = kf.properties[prop];
const isObj = isCustomInputObject(propVal);
Expand Down Expand Up @@ -200,10 +202,10 @@ export const compileTimeline = (timeline: Timeline<any>): CompiledTimeline => {
};
});

// @ts-expect-error could make TS happy, but would make this verbose unfortunately
return interpolation.fn(
keyframes,
getDefaultEasing(propName),
// @ts-expect-error could make TS happy, but would make this verbose unfortunately
rawPropVal,
)(progress);
};
Expand All @@ -213,15 +215,13 @@ export const compileTimeline = (timeline: Timeline<any>): CompiledTimeline => {
if (customInterpolations) {
const allCustomInterpolations = Object.entries(customInterpolations) as [
EverySchemaPropName,
ImperativeTrack<unknown>,
ImperativeTrack<any, any>,
][];
for (const [propName, interpolationOptions] of allCustomInterpolations) {
if (!interpolationOptions)
throw 'custom path received with no options. this should never happen!';
const { easing: easingRaw, value } = interpolationOptions;
const easing = easingRaw ?? getDefaultEasing(propName);
tl.properties[propName] = (_, progress) =>
value(easingOptionToFunction(easing)(progress));
const easingFn = easingOptionToFunction(easing);
tl.properties[propName] = (schemaWithDefaults, progress) => value(easingFn(progress), schemaWithDefaults);
}
}

Expand Down
Loading
Loading