ShapeUtil.getInterpolatedProps (#4162)

This PR adds a `getInterpolatedProps` method to the `ShapeUtil` class.
It is used in `Editor.animateShapes` to allow shapes to lerp values that
the editor doesn't specifically know about.

![Kapture 2024-07-13 at 09 12
48](https://github.com/user-attachments/assets/f9711aa0-278b-4a26-84d3-4b6bbe610b81)

### Change type

- [x] `feature`
- [x] `api`

### Test plan

1. Animate a shape's props.

```ts

const shape = editor.getOnlySelectedShape()

setInterval(() => {
	editor.animateShape(
		{
			...shape,
			x: Math.random() * 500,
			y: Math.random() * 200,
			props: { w: 200 + Math.random() * 200, h: 200 + Math.random() * 200 },
		},
		{ animation: { duration: 500 } }
	)
}, 1000)
```

- [ ] Unit tests (Could be done!)

### Release notes

- SDK: adds `ShapeUtil.getInterpolatedProps` so that shapes can better
participate in animations.

---------

Co-authored-by: alex <alex@dytry.ch>
This commit is contained in:
Steve Ruiz 2024-07-15 15:04:22 +01:00 committed by GitHub
parent 7ba4040e84
commit f4ceb581dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 44 additions and 24 deletions

View file

@ -175,6 +175,8 @@ export abstract class BaseBoxShapeUtil<Shape extends TLBaseBoxShape> extends Sha
// (undocumented) // (undocumented)
getHandleSnapGeometry(shape: Shape): HandleSnapGeometry; getHandleSnapGeometry(shape: Shape): HandleSnapGeometry;
// (undocumented) // (undocumented)
getInterpolatedProps(startShape: Shape, endShape: Shape, t: number): Shape['props'];
// (undocumented)
onResize: TLOnResizeHandler<any>; onResize: TLOnResizeHandler<any>;
} }
@ -2044,6 +2046,7 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
abstract getGeometry(shape: Shape): Geometry2d; abstract getGeometry(shape: Shape): Geometry2d;
getHandles?(shape: Shape): TLHandle[]; getHandles?(shape: Shape): TLHandle[];
getHandleSnapGeometry(shape: Shape): HandleSnapGeometry; getHandleSnapGeometry(shape: Shape): HandleSnapGeometry;
getInterpolatedProps?(startShape: Shape, endShape: Shape, progress: number): Shape['props'];
hideResizeHandles: TLShapeUtilFlag<Shape>; hideResizeHandles: TLShapeUtilFlag<Shape>;
hideRotateHandle: TLShapeUtilFlag<Shape>; hideRotateHandle: TLShapeUtilFlag<Shape>;
hideSelectionBoundsBg: TLShapeUtilFlag<Shape>; hideSelectionBoundsBg: TLShapeUtilFlag<Shape>;

View file

@ -6985,8 +6985,8 @@ export class Editor extends EventEmitter<TLEventMap> {
let t: number let t: number
interface ShapeAnimation { interface ShapeAnimation {
partial: TLShapePartial start: TLShape
values: { prop: string; from: number; to: number }[] end: TLShape
} }
const animations: ShapeAnimation[] = [] const animations: ShapeAnimation[] = []
@ -6996,27 +6996,18 @@ export class Editor extends EventEmitter<TLEventMap> {
partial = partials[i] partial = partials[i]
if (!partial) continue if (!partial) continue
result = {
partial,
values: [],
}
const shape = this.getShape(partial.id)! const shape = this.getShape(partial.id)!
if (!shape) continue if (!shape) continue
// We only support animations for certain props result = {
for (const key of ['x', 'y', 'rotation'] as const) { start: structuredClone(shape),
if (partial[key] !== undefined && shape[key] !== partial[key]) { end: applyPartialToRecordWithProps(structuredClone(shape), partial),
result.values.push({ prop: key, from: shape[key], to: partial[key] as number })
}
} }
animations.push(result) animations.push(result)
this.animatingShapes.set(shape.id, animationId) this.animatingShapes.set(shape.id, animationId)
} }
let value: ShapeAnimation
const handleTick = (elapsed: number) => { const handleTick = (elapsed: number) => {
remaining -= elapsed remaining -= elapsed
@ -7026,8 +7017,9 @@ export class Editor extends EventEmitter<TLEventMap> {
(p) => p && animatingShapes.get(p.id) === animationId (p) => p && animatingShapes.get(p.id) === animationId
) )
if (partialsToUpdate.length) { if (partialsToUpdate.length) {
// the regular update shapes also removes the shape from
// the animating shapes set
this.updateShapes(partialsToUpdate) this.updateShapes(partialsToUpdate)
// update shapes also removes the shape from animating shapes
} }
this.off('tick', handleTick) this.off('tick', handleTick)
@ -7042,22 +7034,22 @@ export class Editor extends EventEmitter<TLEventMap> {
let animationIdForShape: string | undefined let animationIdForShape: string | undefined
for (let i = 0, n = animations.length; i < n; i++) { for (let i = 0, n = animations.length; i < n; i++) {
value = animations[i] const { start, end } = animations[i]
// Is the animation for this shape still active? // Is the animation for this shape still active?
animationIdForShape = animatingShapes.get(value.partial.id) animationIdForShape = animatingShapes.get(start.id)
if (animationIdForShape !== animationId) continue if (animationIdForShape !== animationId) continue
// Create the update
updates.push({ updates.push({
id: value.partial.id, ...end,
type: value.partial.type, x: start.x + (end.x - start.x) * t,
...value.values.reduce((acc, { prop, from, to }) => { y: start.y + (end.y - start.y) * t,
acc[prop] = from + (to - from) * t rotation: start.rotation + (end.rotation - start.rotation) * t,
return acc props: this.getShapeUtil(end).getInterpolatedProps?.(start, end, t) ?? end.props,
}, {} as any),
}) })
} }
// The _updateShapes method does NOT remove the
// shapes from the animated shapes set
this._updateShapes(updates) this._updateShapes(updates)
} }

View file

@ -1,4 +1,5 @@
import { TLBaseShape } from '@tldraw/tlschema' import { TLBaseShape } from '@tldraw/tlschema'
import { lerp } from '@tldraw/utils'
import { Geometry2d } from '../../primitives/geometry/Geometry2d' import { Geometry2d } from '../../primitives/geometry/Geometry2d'
import { Rectangle2d } from '../../primitives/geometry/Rectangle2d' import { Rectangle2d } from '../../primitives/geometry/Rectangle2d'
import { HandleSnapGeometry } from '../managers/SnapManager/HandleSnaps' import { HandleSnapGeometry } from '../managers/SnapManager/HandleSnaps'
@ -27,4 +28,12 @@ export abstract class BaseBoxShapeUtil<Shape extends TLBaseBoxShape> extends Sha
points: this.getGeometry(shape).bounds.cornersAndCenter, points: this.getGeometry(shape).bounds.cornersAndCenter,
} }
} }
override getInterpolatedProps(startShape: Shape, endShape: Shape, t: number): Shape['props'] {
return {
...endShape.props,
w: lerp(startShape.props.w, endShape.props.w, t),
h: lerp(startShape.props.h, endShape.props.h, t),
}
}
} }

View file

@ -211,6 +211,22 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
*/ */
backgroundComponent?(shape: Shape): any backgroundComponent?(shape: Shape): any
/**
* Get the interpolated props for an animating shape. This is an optional method.
*
* @example
*
* ```ts
* util.getInterpolatedProps?.(startShape, endShape, t)
* ```
*
* @param startShape - The initial shape.
* @param endShape - The initial shape.
* @param progress - The normalized progress between zero (start) and 1 (end).
* @public
*/
getInterpolatedProps?(startShape: Shape, endShape: Shape, progress: number): Shape['props']
/** /**
* Get an array of handle models for the shape. This is an optional method. * Get an array of handle models for the shape. This is an optional method.
* *