Skip to content

Commit 6bb2e2d

Browse files
authored
Merge pull request #531 from Yonava/yva/animated-stroke
feat(shapes/Animation): get animated props for imperative tracks and general API improvements
2 parents 0045c90 + 239eb62 commit 6bb2e2d

11 files changed

Lines changed: 214 additions & 179 deletions

File tree

client/src/playground/shape/ShapePlayground.vue

Lines changed: 44 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,67 +3,65 @@
33
import { useMagicCanvas } from '@canvas/index';
44
import colors from '@colors';
55
import { useAnimatedShapes } from '@shape/animation';
6-
import type { Shape } from '@shape/types';
76
import { cross } from '@shapes/cross';
87
import Button from '@ui/core/button/Button.vue';
98
10-
import { ref } from 'vue';
11-
12-
const { defineTimeline, shapes } = useAnimatedShapes();
9+
const { defineTimeline, shapes, getAnimatedProp } = useAnimatedShapes();
1310
1411
const { play, stop, pause, resume } = defineTimeline({
15-
forShapes: ['line'],
16-
durationMs: 4000,
12+
forShapes: ['circle'],
13+
durationMs: 6000,
1714
customInterpolations: {
18-
fillGradient: {
19-
value: (p) => [
20-
{
21-
color: 'red',
22-
offset: 0,
23-
},
24-
{
15+
stroke: {
16+
value: (progress) => {
17+
const r = getAnimatedProp('test', 'radius');
18+
const numOfDashes = 5;
19+
const lengthGapRatio = 40 / 22.832;
20+
const circum = 2 * r * Math.PI;
21+
const p = circum / numOfDashes;
22+
const dashLength = (lengthGapRatio / (lengthGapRatio + 1)) * p;
23+
const gapLength = (1 / (lengthGapRatio + 1)) * p;
24+
return {
25+
lineWidth: 10,
2526
color: 'red',
26-
offset: p < 0.5 ? p * 2 : 2 - p * 2,
27-
},
28-
{
29-
color: 'black',
30-
offset: p < 0.5 ? p * 2 : 2 - p * 2,
31-
},
32-
],
33-
},
34-
},
35-
keyframes: [
36-
{
37-
progress: 0.5,
38-
properties: {
39-
end: { x: 50, y: 300 },
40-
start: { x: 250, y: 0 },
41-
lineWidth: 50,
42-
textArea: (ta) => ({
43-
textBlock: {
44-
fontSize: ta.textBlock.fontSize + 12,
27+
dash: {
28+
pattern: [dashLength, gapLength],
29+
offset: progress * circum,
4530
},
46-
}),
31+
};
4732
},
4833
},
49-
],
34+
},
35+
keyframes: [{ progress: 0.5, properties: { radius: 100 } }],
5036
});
5137
52-
const paintedShapes = ref<Shape[]>([]);
38+
const cir = shapes.circle({
39+
id: 'test',
40+
at: { x: 0, y: 0 },
41+
radius: 50,
42+
textArea: { textBlock: { content: '1' } },
43+
});
5344
54-
paintedShapes.value.push(
55-
shapes.line({
56-
id: 'test',
57-
start: { x: 0, y: 0 },
58-
end: { x: 200, y: 200 },
59-
textArea: { textBlock: { content: 'real' } },
60-
fillColor: 'purple',
61-
}),
62-
);
45+
const cir2 = shapes.circle({
46+
id: 'test2',
47+
at: { x: 200, y: 0 },
48+
radius: 50,
49+
stroke: {
50+
lineWidth: 10,
51+
color: 'red',
52+
dash: {
53+
pattern: [40, 22.832],
54+
offset: 90,
55+
},
56+
},
57+
textArea: { textBlock: { content: '2' } },
58+
});
6359
6460
const magic = useMagicCanvas();
65-
magic.draw.content.value = (ctx) =>
66-
paintedShapes.value.forEach((i) => i.draw(ctx));
61+
magic.draw.content.value = (ctx) => {
62+
cir.draw(ctx);
63+
cir2.draw(ctx);
64+
};
6765
6866
magic.draw.backgroundPattern.value = (ctx, at) => {
6967
cross({

client/src/shapes/animation/index.ts

Lines changed: 97 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { shapeDefaults } from '@shape/defaults/shapes';
1+
import type { UnionToIntersection } from 'ts-essentials';
2+
import { getSchemaWithDefaults } from '@shape/defaults/shapes';
23
import type {
4+
EverySchemaProp,
35
EverySchemaPropName,
46
SchemaId,
57
Shape,
@@ -61,13 +63,18 @@ export const useAnimatedShapes = () => {
6163
const animations = activeAnimations.get(schemaId);
6264
if (!animations || animations.length === 0) return;
6365

64-
let outputSchema = animations[0].schema;
66+
let outputSchema = animations[0].schemaWithDefaults;
6567

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

73+
const shapeName = schemaIdToShapeName.get(schemaId);
74+
if (!shapeName) {
75+
throw new Error('(Internal Error) Animation set without shape name mapping. this should never happen!');
76+
}
77+
7178
for (const animation of animations) {
7279
const timeline = timelineIdToTimeline.get(animation.timelineId);
7380
if (!timeline) throw new Error('animation activated without a timeline!');
@@ -77,17 +84,9 @@ export const useAnimatedShapes = () => {
7784
...animation,
7885
};
7986

80-
const shapeName = schemaIdToShapeName.get(schemaId);
81-
if (!shapeName) {
82-
console.warn(
83-
'animation set without shape name mapping. this should never happen!',
84-
);
85-
continue;
86-
}
87-
88-
if (!animationWithTimeline.validShapes.has(shapeName)) {
89-
console.warn('invalid shape name!');
90-
continue;
87+
const { validShapes, timelineId } = animationWithTimeline
88+
if (!validShapes.has(shapeName)) {
89+
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}.`);
9190
}
9291

9392
// cleanup animation if expired
@@ -127,44 +126,44 @@ export const useAnimatedShapes = () => {
127126
factory: ShapeFactory<T>,
128127
shapeName: ShapeName,
129128
): ShapeFactory<WithId<T>> =>
130-
(schema) =>
131-
new Proxy(factory(schema), {
132-
get: (target, rawProp) => {
133-
const prop = rawProp as keyof Shape;
134-
if (!shapeProps.has(prop)) return target[prop];
129+
(schema) =>
130+
new Proxy(factory(schema), {
131+
get: (target, rawProp) => {
132+
const prop = rawProp as keyof Shape;
133+
if (!shapeProps.has(prop)) return target[prop];
135134

136-
const animations = activeAnimations.get(schema.id);
135+
const animations = activeAnimations.get(schema.id);
137136

138-
const defaultResolver:
139-
| ((schema: LooseSchema) => LooseSchema)
140-
| undefined = (shapeDefaults as any)?.[shapeName];
141-
if (!defaultResolver)
142-
throw new Error(`cant find defaults for ${shapeName}`);
143-
const schemaWithDefaults = defaultResolver(schema);
137+
const defaultResolver:
138+
| ((schema: LooseSchema) => LooseSchema)
139+
| undefined = (getSchemaWithDefaults as any)?.[shapeName];
140+
if (!defaultResolver)
141+
throw new Error(`cant find defaults for ${shapeName}`);
142+
const schemaWithDefaults = defaultResolver(schema);
144143

145-
autoAnimate.captureSchemaState(schemaWithDefaults, shapeName);
144+
autoAnimate.captureSchemaState(schemaWithDefaults, shapeName);
146145

147-
const targetMapSchema = autoAnimate.snapshotMap.get(schema.id);
148-
if (targetMapSchema)
149-
return factory(targetMapSchema as WithId<T>)[prop];
146+
const targetMapSchema = autoAnimate.snapshotMap.get(schema.id);
147+
if (targetMapSchema)
148+
return factory(targetMapSchema as WithId<T>)[prop];
150149

151-
if (!animations || animations.length === 0) return target[prop];
152-
if (!animations[0]?.schema) animations[0].schema = schemaWithDefaults;
150+
if (!animations || animations.length === 0) return target[prop];
151+
if (!animations[0]?.schemaWithDefaults) animations[0].schemaWithDefaults = schemaWithDefaults;
153152

154-
if (prop === 'startTextAreaEdit')
155-
return console.warn(
156-
'shapes with active animations cannot spawn text inputs',
157-
);
153+
if (prop === 'startTextAreaEdit')
154+
return console.warn(
155+
'shapes with active animations cannot spawn text inputs',
156+
);
158157

159-
const hasShapeName = schemaIdToShapeName.get(schema.id);
160-
if (!hasShapeName) schemaIdToShapeName.set(schema.id, shapeName);
158+
const hasShapeName = schemaIdToShapeName.get(schema.id);
159+
if (!hasShapeName) schemaIdToShapeName.set(schema.id, shapeName);
161160

162-
const animatedSchema = getAnimatedSchema(schema.id);
163-
if (!animatedSchema) return target[prop];
161+
const animatedSchema = getAnimatedSchema(schema.id);
162+
if (!animatedSchema) return target[prop];
164163

165-
return factory(animatedSchema as WithId<T>)[prop];
166-
},
167-
});
164+
return factory(animatedSchema as WithId<T>)[prop];
165+
},
166+
});
168167

169168
return {
170169
shapes: {
@@ -184,6 +183,62 @@ export const useAnimatedShapes = () => {
184183
defineTimeline,
185184
autoAnimate: { captureFrame: autoAnimate.captureFrame },
186185
getAnimatedSchema,
186+
/**
187+
* Get the animated value of a schema property currently being animated.
188+
*
189+
* Intended for use in imperative timelines where resolving one property's animated value
190+
* depends on the animated value of another property. In these special cases, `getAnimatedSchema`
191+
* would cause a circular dependency.
192+
*
193+
* WARNING: Calling this on a property that the imperative track itself resolves
194+
* will crash your app!
195+
*/
196+
getAnimatedProp: <T extends EverySchemaPropName>(schemaId: string, inputPropName: T) => {
197+
const animations = activeAnimations.get(schemaId);
198+
if (!animations || animations.length === 0) {
199+
throw new Error(`Schema with id ${schemaId} has no running animations`)
200+
};
201+
202+
const { schemaWithDefaults } = animations[0]
203+
204+
if (!schemaWithDefaults) {
205+
throw new Error('(Internal Error) Animation set without a schema. this should never happen!');
206+
}
207+
208+
if (!(inputPropName in schemaWithDefaults)) {
209+
throw new Error(`(User Error) Input prop name ${inputPropName} not a property on schema (${Object.keys(schemaWithDefaults)})`)
210+
}
211+
212+
const shapeName = schemaIdToShapeName.get(schemaId);
213+
if (!shapeName) {
214+
throw new Error('(Internal Error) Animation set without shape name mapping. this should never happen!');
215+
}
216+
217+
let propVal = schemaWithDefaults[inputPropName] as UnionToIntersection<EverySchemaProp>[T]
218+
219+
for (const animation of animations) {
220+
const timeline = timelineIdToTimeline.get(animation.timelineId);
221+
if (!timeline) throw new Error('(Internal Error) Animation activated without a timeline!');
222+
223+
const animationWithTimeline = {
224+
...timeline,
225+
...animation,
226+
};
227+
228+
const { validShapes, timelineId } = animationWithTimeline
229+
if (!validShapes.has(shapeName)) {
230+
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}.`);
231+
}
232+
233+
const { properties } = animationWithTimeline;
234+
const progress = getAnimationProgress(animationWithTimeline);
235+
236+
const fn = properties[inputPropName as string]
237+
propVal = fn(schemaWithDefaults, progress)
238+
}
239+
240+
return propVal;
241+
},
187242
activeAnimations,
188243
};
189244
};

client/src/shapes/animation/timeline/compile.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,14 +95,16 @@ export const compileTimeline = (timeline: Timeline<any>): CompiledTimeline => {
9595
validShapes: new Set(timeline.forShapes),
9696
};
9797

98+
const rawTimelineKeyframes = timeline?.keyframes ?? [];
99+
98100
const propsInTimeline = [
99101
...new Set(
100-
timeline.keyframes.map((kf) => Object.keys(kf.properties)).flat(),
102+
rawTimelineKeyframes.map((kf) => Object.keys(kf.properties)).flat(),
101103
),
102104
] as EverySchemaPropName[];
103105

104106
const propToAnimationKeyframes = propsInTimeline.reduce((acc, prop) => {
105-
const propInTimeline = timeline.keyframes
107+
const propInTimeline = rawTimelineKeyframes
106108
.map((kf): AnimationKeyframe<any> => {
107109
const propVal = kf.properties[prop];
108110
const isObj = isCustomInputObject(propVal);
@@ -200,10 +202,10 @@ export const compileTimeline = (timeline: Timeline<any>): CompiledTimeline => {
200202
};
201203
});
202204

203-
// @ts-expect-error could make TS happy, but would make this verbose unfortunately
204205
return interpolation.fn(
205206
keyframes,
206207
getDefaultEasing(propName),
208+
// @ts-expect-error could make TS happy, but would make this verbose unfortunately
207209
rawPropVal,
208210
)(progress);
209211
};
@@ -213,15 +215,13 @@ export const compileTimeline = (timeline: Timeline<any>): CompiledTimeline => {
213215
if (customInterpolations) {
214216
const allCustomInterpolations = Object.entries(customInterpolations) as [
215217
EverySchemaPropName,
216-
ImperativeTrack<unknown>,
218+
ImperativeTrack<any, any>,
217219
][];
218220
for (const [propName, interpolationOptions] of allCustomInterpolations) {
219-
if (!interpolationOptions)
220-
throw 'custom path received with no options. this should never happen!';
221221
const { easing: easingRaw, value } = interpolationOptions;
222222
const easing = easingRaw ?? getDefaultEasing(propName);
223-
tl.properties[propName] = (_, progress) =>
224-
value(easingOptionToFunction(easing)(progress));
223+
const easingFn = easingOptionToFunction(easing);
224+
tl.properties[propName] = (schemaWithDefaults, progress) => value(easingFn(progress), schemaWithDefaults);
225225
}
226226
}
227227

0 commit comments

Comments
 (0)