diff --git a/client/src/playground/shape/ShapePlayground.vue b/client/src/playground/shape/ShapePlayground.vue index 947ce2cd..bcb01423 100644 --- a/client/src/playground/shape/ShapePlayground.vue +++ b/client/src/playground/shape/ShapePlayground.vue @@ -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([]); + 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({ diff --git a/client/src/shapes/animation/index.ts b/client/src/shapes/animation/index.ts index 357d5dde..b5cf0de3 100644 --- a/client/src/shapes/animation/index.ts +++ b/client/src/shapes/animation/index.ts @@ -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, @@ -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!'); @@ -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 @@ -127,44 +126,44 @@ export const useAnimatedShapes = () => { factory: ShapeFactory, shapeName: ShapeName, ): ShapeFactory> => - (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)[prop]; + const targetMapSchema = autoAnimate.snapshotMap.get(schema.id); + if (targetMapSchema) + return factory(targetMapSchema as WithId)[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)[prop]; - }, - }); + return factory(animatedSchema as WithId)[prop]; + }, + }); return { shapes: { @@ -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: (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[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, }; }; diff --git a/client/src/shapes/animation/timeline/compile.ts b/client/src/shapes/animation/timeline/compile.ts index 9f51c2d0..812dc643 100644 --- a/client/src/shapes/animation/timeline/compile.ts +++ b/client/src/shapes/animation/timeline/compile.ts @@ -95,14 +95,16 @@ export const compileTimeline = (timeline: Timeline): 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 => { const propVal = kf.properties[prop]; const isObj = isCustomInputObject(propVal); @@ -200,10 +202,10 @@ export const compileTimeline = (timeline: Timeline): 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); }; @@ -213,15 +215,13 @@ export const compileTimeline = (timeline: Timeline): CompiledTimeline => { if (customInterpolations) { const allCustomInterpolations = Object.entries(customInterpolations) as [ EverySchemaPropName, - ImperativeTrack, + ImperativeTrack, ][]; 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); } } diff --git a/client/src/shapes/animation/timeline/define.ts b/client/src/shapes/animation/timeline/define.ts index 72d18760..de5f466d 100644 --- a/client/src/shapes/animation/timeline/define.ts +++ b/client/src/shapes/animation/timeline/define.ts @@ -1,14 +1,14 @@ -import type { TextArea } from '@shape/text/types'; // @typescript-eslint/no-unused-vars reports unused even if referenced in jsdoc // eslint-disable-next-line import type { EverySchemaProp, ShapeNameToSchema, WithId } from '@shape/types'; import { generateId } from '@utils/id'; -import type { DeepPartial, DeepRequired } from 'ts-essentials'; +import type { DeepRequired } from 'ts-essentials'; import type { DeepReadonly } from 'vue'; import type { EasingOption } from '../easing'; import { type CompiledTimeline, compileTimeline } from './compile'; +import type { SchemaWithDefaults } from '@shape/defaults/shapes'; type ShapeTarget = { /** @@ -64,62 +64,37 @@ type TimelineControls = { dispose: () => void; }; -type SharedSchemaProps = - keyof ShapeNameToSchema[T] & - { - [K in keyof ShapeNameToSchema[T]]: T extends any - ? K extends keyof ShapeNameToSchema[T] - ? unknown - : never - : never; - }[keyof ShapeNameToSchema[T]]; - -type SchemaProps = Pick< - ShapeNameToSchema[T], - SharedSchemaProps ->; - -type InterceptedSchemaProps = { - textArea: DeepPartial