speech bubble handle -> tail (#2975)

Handle's days are numbered, and in the line shape we've moved away from
storing `TLHandle` directly. This diff updates the speech bubble shape
to rename the 'handle' prop to 'tail' and make it just be the
coordinates. The handle props are derived at runtime.

### Change Type

- [x] `documentation` — Changes to the documentation only[^2]
This commit is contained in:
alex 2024-02-28 11:32:42 +00:00 committed by GitHub
parent 22af179983
commit 9f82e27214
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 74 additions and 83 deletions

View file

@ -13,13 +13,15 @@ import {
TLOnHandleDragHandler, TLOnHandleDragHandler,
TLOnResizeHandler, TLOnResizeHandler,
Vec, Vec,
VecModel,
ZERO_INDEX_KEY, ZERO_INDEX_KEY,
deepCopy, deepCopy,
getDefaultColorTheme, getDefaultColorTheme,
resizeBox, resizeBox,
structuredClone, structuredClone,
vecModelValidator,
} from '@tldraw/tldraw' } from '@tldraw/tldraw'
import { getHandleIntersectionPoint, getSpeechBubbleVertices } from './helpers' import { getSpeechBubbleVertices, getTailIntersectionPoint } from './helpers'
// Copied from tldraw/tldraw // Copied from tldraw/tldraw
export const STROKE_SIZES = { export const STROKE_SIZES = {
@ -39,14 +41,10 @@ export type SpeechBubbleShape = TLBaseShape<
h: number h: number
size: TLDefaultSizeStyle size: TLDefaultSizeStyle
color: TLDefaultColorStyle color: TLDefaultColorStyle
handles: { tail: VecModel
handle: TLHandle
}
} }
> >
export const handleValidator = () => true
export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> { export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
static override type = 'speech-bubble' as const static override type = 'speech-bubble' as const
@ -56,10 +54,7 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
h: T.number, h: T.number,
size: DefaultSizeStyle, size: DefaultSizeStyle,
color: DefaultColorStyle, color: DefaultColorStyle,
handles: { tail: vecModelValidator,
validate: handleValidator,
handle: { validate: handleValidator },
},
} }
override isAspectRatioLocked = (_shape: SpeechBubbleShape) => false override isAspectRatioLocked = (_shape: SpeechBubbleShape) => false
@ -75,17 +70,7 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
h: 130, h: 130,
color: 'black', color: 'black',
size: 'm', size: 'm',
handles: { tail: { x: 0.5, y: 1.5 },
handle: {
id: 'handle1',
type: 'vertex',
canBind: true,
canSnap: true,
index: ZERO_INDEX_KEY,
x: 0.5,
y: 1.5,
},
},
} }
} }
@ -98,68 +83,69 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
return body return body
} }
override getHandles(shape: SpeechBubbleShape) { // [4]
const { override getHandles(shape: SpeechBubbleShape): TLHandle[] {
handles: { handle }, const { tail, w, h } = shape.props
w,
h,
} = shape.props
return [ return [
{ {
...handle, id: 'tail',
// props.handles.handle coordinates are normalized type: 'vertex',
index: ZERO_INDEX_KEY,
// props.tail coordinates are normalized
// but here we need them in shape space // but here we need them in shape space
x: handle.x * w, x: tail.x * w,
y: handle.y * h, y: tail.y * h,
}, },
] ]
} }
// [4] override onHandleDrag: TLOnHandleDragHandler<SpeechBubbleShape> = (shape, { handle }) => {
return {
...shape,
props: {
tail: {
x: handle.x / shape.props.w,
y: handle.y / shape.props.h,
},
},
}
}
// [5]
override onBeforeUpdate: TLOnBeforeUpdateHandler<SpeechBubbleShape> | undefined = ( override onBeforeUpdate: TLOnBeforeUpdateHandler<SpeechBubbleShape> | undefined = (
_: SpeechBubbleShape, _: SpeechBubbleShape,
shape: SpeechBubbleShape shape: SpeechBubbleShape
) => { ) => {
const { w, h, handles } = shape.props const { w, h, tail } = shape.props
const { segmentsIntersection, insideShape } = getHandleIntersectionPoint(shape) const { segmentsIntersection, insideShape } = getTailIntersectionPoint(shape)
const slantedLength = Math.hypot(w, h) const slantedLength = Math.hypot(w, h)
const MIN_DISTANCE = slantedLength / 5 const MIN_DISTANCE = slantedLength / 5
const MAX_DISTANCE = slantedLength / 1.5 const MAX_DISTANCE = slantedLength / 1.5
const handleInShapeSpace = new Vec(handles.handle.x * w, handles.handle.y * h) const tailInShapeSpace = new Vec(tail.x * w, tail.y * h)
const distanceToIntersection = handleInShapeSpace.dist(segmentsIntersection) const distanceToIntersection = tailInShapeSpace.dist(segmentsIntersection)
const center = new Vec(w / 2, h / 2) const center = new Vec(w / 2, h / 2)
const vHandle = Vec.Sub(handleInShapeSpace, center).uni() const tailDirection = Vec.Sub(tailInShapeSpace, center).uni()
let newPoint = handleInShapeSpace let newPoint = tailInShapeSpace
if (insideShape) { if (insideShape) {
newPoint = Vec.Add(segmentsIntersection, vHandle.mul(MIN_DISTANCE)) newPoint = Vec.Add(segmentsIntersection, tailDirection.mul(MIN_DISTANCE))
} else { } else {
if (distanceToIntersection <= MIN_DISTANCE) { if (distanceToIntersection <= MIN_DISTANCE) {
newPoint = Vec.Add(segmentsIntersection, vHandle.mul(MIN_DISTANCE)) newPoint = Vec.Add(segmentsIntersection, tailDirection.mul(MIN_DISTANCE))
} else if (distanceToIntersection >= MAX_DISTANCE) { } else if (distanceToIntersection >= MAX_DISTANCE) {
newPoint = Vec.Add(segmentsIntersection, vHandle.mul(MAX_DISTANCE)) newPoint = Vec.Add(segmentsIntersection, tailDirection.mul(MAX_DISTANCE))
} }
} }
const next = deepCopy(shape) const next = deepCopy(shape)
next.props.handles.handle.x = newPoint.x / w next.props.tail.x = newPoint.x / w
next.props.handles.handle.y = newPoint.y / h next.props.tail.y = newPoint.y / h
return next
}
override onHandleDrag: TLOnHandleDragHandler<SpeechBubbleShape> = (_, { handle, initial }) => {
const newHandle = deepCopy(handle)
newHandle.x = newHandle.x / initial!.props.w
newHandle.y = newHandle.y / initial!.props.h
const next = deepCopy(initial!)
next.props.handles.handle = newHandle
return next return next
} }
@ -203,30 +189,35 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
} }
/* /*
Introduction: Introduction: This file contains our custom shape util. The shape util is a class that defines how
This file contains our custom shape util. The shape util is a class that defines how our shape behaves. our shape behaves. Most of the logic for how the speech bubble shape works is in the onBeforeUpdate
Most of the logic for how the speech bubble shape works is in the onBeforeUpdate handler [4]. Since this handler [5]. Since this shape has a handle, we need to do some special stuff to make sure it updates
shape has a handle, we need to do some special stuff to make sure it updates the way we want it to. the way we want it to.
[1] [1]
Here is where we define the shape's type. For the handle we can use the `TLHandle` type from @tldraw/tldraw. Here is where we define the shape's type. For the tail we can use the `VecModel` type from
@tldraw/tldraw.
[2] [2]
This is where we define the shape's props and a type validator for each key. tldraw exports a bunch of handy This is where we define the shape's props and a type validator for each key. tldraw exports a
validators for us to use. We can also define our own, at the moment our handle validator just returns true bunch of handy validators for us to use. Props you define here will determine which style options
though, because I like to live dangerously. Props you define here will determine which style options show show up in the style menu, e.g. we define 'size' and 'color' props, but we could add 'dash', 'fill'
up in the style menu, e.g. we define 'size' and 'color' props, but we could add 'dash', 'fill' or any other or any other of the default props.
of the default props.
[3] [3]
Here is where we set the default props for our shape, this will determine how the shape looks when we Here is where we set the default props for our shape, this will determine how the shape looks
click-create it. You'll notice we don't store the handle's absolute position though, instead we record its when we click-create it. You'll notice we don't store the tail's absolute position though, instead
relative position. This is because we can also drag-create shapes. If we store the handle's position absolutely we record its relative position. This is because we can also drag-create shapes. If we store the
it won't scale properly when drag-creating. Throughout the rest of the util we'll need to convert the tail's position absolutely it won't scale properly when drag-creating. Throughout the rest of the
handle's relative position to an absolute position and vice versa. util we'll need to convert the tail's relative position to an absolute position and vice versa.
[4] [4]
This is the last method that fires after a shape has been changed, we can use it to make sure the tail stays `getHandles` tells tldraw how to turn our shape into a list of handles that'll show up when it's
the right length and position. Check out helpers.tsx to get into some of the more specific geometry stuff. selected. We only have one handle, the tail, which simplifies things for us a bit. In
`onHandleDrag`, we tell tldraw how our shape should be updated when the handle is dragged.
[5]
This is the last method that fires after a shape has been changed, we can use it to make sure
the tail stays the right length and position. Check out helpers.tsx to get into some of the more
specific geometry stuff.
*/ */

View file

@ -2,16 +2,16 @@ import { Vec, VecLike, lerp, pointInPolygon } from '@tldraw/tldraw'
import { SpeechBubbleShape } from './SpeechBubbleUtil' import { SpeechBubbleShape } from './SpeechBubbleUtil'
export const getSpeechBubbleVertices = (shape: SpeechBubbleShape): Vec[] => { export const getSpeechBubbleVertices = (shape: SpeechBubbleShape): Vec[] => {
const { w, h, handles } = shape.props const { w, h, tail } = shape.props
const handleInShapeSpace = new Vec(handles.handle.x * w, handles.handle.y * h) const tailInShapeSpace = new Vec(tail.x * w, tail.y * h)
const [tl, tr, br, bl] = [new Vec(0, 0), new Vec(w, 0), new Vec(w, h), new Vec(0, h)] const [tl, tr, br, bl] = [new Vec(0, 0), new Vec(w, 0), new Vec(w, h), new Vec(0, h)]
const offsetH = w / 10 const offsetH = w / 10
const offsetV = h / 10 const offsetV = h / 10
const { adjustedIntersection, intersectionSegmentIndex } = getHandleIntersectionPoint(shape) const { adjustedIntersection, intersectionSegmentIndex } = getTailIntersectionPoint(shape)
let vertices: Vec[] let vertices: Vec[]
@ -22,7 +22,7 @@ export const getSpeechBubbleVertices = (shape: SpeechBubbleShape): Vec[] => {
vertices = [ vertices = [
tl, tl,
new Vec(adjustedIntersection.x - offsetH, adjustedIntersection.y), new Vec(adjustedIntersection.x - offsetH, adjustedIntersection.y),
new Vec(handleInShapeSpace.x, handleInShapeSpace.y), new Vec(tailInShapeSpace.x, tailInShapeSpace.y),
new Vec(adjustedIntersection.x + offsetH, adjustedIntersection.y), new Vec(adjustedIntersection.x + offsetH, adjustedIntersection.y),
tr, tr,
br, br,
@ -35,7 +35,7 @@ export const getSpeechBubbleVertices = (shape: SpeechBubbleShape): Vec[] => {
tl, tl,
tr, tr,
new Vec(adjustedIntersection.x, adjustedIntersection.y - offsetV), new Vec(adjustedIntersection.x, adjustedIntersection.y - offsetV),
new Vec(handleInShapeSpace.x, handleInShapeSpace.y), new Vec(tailInShapeSpace.x, tailInShapeSpace.y),
new Vec(adjustedIntersection.x, adjustedIntersection.y + offsetV), new Vec(adjustedIntersection.x, adjustedIntersection.y + offsetV),
br, br,
bl, bl,
@ -48,7 +48,7 @@ export const getSpeechBubbleVertices = (shape: SpeechBubbleShape): Vec[] => {
tr, tr,
br, br,
new Vec(adjustedIntersection.x + offsetH, adjustedIntersection.y), new Vec(adjustedIntersection.x + offsetH, adjustedIntersection.y),
new Vec(handleInShapeSpace.x, handleInShapeSpace.y), new Vec(tailInShapeSpace.x, tailInShapeSpace.y),
new Vec(adjustedIntersection.x - offsetH, adjustedIntersection.y), new Vec(adjustedIntersection.x - offsetH, adjustedIntersection.y),
bl, bl,
] ]
@ -61,7 +61,7 @@ export const getSpeechBubbleVertices = (shape: SpeechBubbleShape): Vec[] => {
br, br,
bl, bl,
new Vec(adjustedIntersection.x, adjustedIntersection.y + offsetV), new Vec(adjustedIntersection.x, adjustedIntersection.y + offsetV),
new Vec(handleInShapeSpace.x, handleInShapeSpace.y), new Vec(tailInShapeSpace.x, tailInShapeSpace.y),
new Vec(adjustedIntersection.x, adjustedIntersection.y - offsetV), new Vec(adjustedIntersection.x, adjustedIntersection.y - offsetV),
] ]
break break
@ -72,9 +72,9 @@ export const getSpeechBubbleVertices = (shape: SpeechBubbleShape): Vec[] => {
return vertices return vertices
} }
export function getHandleIntersectionPoint(shape: SpeechBubbleShape) { export function getTailIntersectionPoint(shape: SpeechBubbleShape) {
const { w, h, handles } = shape.props const { w, h, tail } = shape.props
const handleInShapeSpace = new Vec(handles.handle.x * w, handles.handle.y * h) const tailInShapeSpace = new Vec(tail.x * w, tail.y * h)
const center = new Vec(w / 2, h / 2) const center = new Vec(w / 2, h / 2)
const corners = [new Vec(0, 0), new Vec(w, 0), new Vec(w, h), new Vec(0, h)] const corners = [new Vec(0, 0), new Vec(w, 0), new Vec(w, h), new Vec(0, h)]
@ -89,13 +89,13 @@ export function getHandleIntersectionPoint(shape: SpeechBubbleShape) {
let intersectionSegment: Vec[] | null = null let intersectionSegment: Vec[] | null = null
// If the point inside of the box's corners? // If the point inside of the box's corners?
const insideShape = pointInPolygon(handleInShapeSpace, corners) const insideShape = pointInPolygon(tailInShapeSpace, corners)
// We want to be sure we get an intersection, so if the point is // We want to be sure we get an intersection, so if the point is
// inside the shape, push it away from the center by a big distance // inside the shape, push it away from the center by a big distance
const pointToCheck = insideShape const pointToCheck = insideShape
? Vec.Add(handleInShapeSpace, Vec.Sub(handleInShapeSpace, center).uni().mul(1000000)) ? Vec.Add(tailInShapeSpace, Vec.Sub(tailInShapeSpace, center).uni().mul(1000000))
: handleInShapeSpace : tailInShapeSpace
// Test each segment for an intersection // Test each segment for an intersection
for (const segment of segments) { for (const segment of segments) {