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,
TLOnResizeHandler,
Vec,
VecModel,
ZERO_INDEX_KEY,
deepCopy,
getDefaultColorTheme,
resizeBox,
structuredClone,
vecModelValidator,
} from '@tldraw/tldraw'
import { getHandleIntersectionPoint, getSpeechBubbleVertices } from './helpers'
import { getSpeechBubbleVertices, getTailIntersectionPoint } from './helpers'
// Copied from tldraw/tldraw
export const STROKE_SIZES = {
@ -39,14 +41,10 @@ export type SpeechBubbleShape = TLBaseShape<
h: number
size: TLDefaultSizeStyle
color: TLDefaultColorStyle
handles: {
handle: TLHandle
}
tail: VecModel
}
>
export const handleValidator = () => true
export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
static override type = 'speech-bubble' as const
@ -56,10 +54,7 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
h: T.number,
size: DefaultSizeStyle,
color: DefaultColorStyle,
handles: {
validate: handleValidator,
handle: { validate: handleValidator },
},
tail: vecModelValidator,
}
override isAspectRatioLocked = (_shape: SpeechBubbleShape) => false
@ -75,17 +70,7 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
h: 130,
color: 'black',
size: 'm',
handles: {
handle: {
id: 'handle1',
type: 'vertex',
canBind: true,
canSnap: true,
index: ZERO_INDEX_KEY,
x: 0.5,
y: 1.5,
},
},
tail: { x: 0.5, y: 1.5 },
}
}
@ -98,68 +83,69 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
return body
}
override getHandles(shape: SpeechBubbleShape) {
const {
handles: { handle },
w,
h,
} = shape.props
// [4]
override getHandles(shape: SpeechBubbleShape): TLHandle[] {
const { tail, w, h } = shape.props
return [
{
...handle,
// props.handles.handle coordinates are normalized
id: 'tail',
type: 'vertex',
index: ZERO_INDEX_KEY,
// props.tail coordinates are normalized
// but here we need them in shape space
x: handle.x * w,
y: handle.y * h,
x: tail.x * w,
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 = (
_: 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 MIN_DISTANCE = slantedLength / 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 vHandle = Vec.Sub(handleInShapeSpace, center).uni()
const tailDirection = Vec.Sub(tailInShapeSpace, center).uni()
let newPoint = handleInShapeSpace
let newPoint = tailInShapeSpace
if (insideShape) {
newPoint = Vec.Add(segmentsIntersection, vHandle.mul(MIN_DISTANCE))
newPoint = Vec.Add(segmentsIntersection, tailDirection.mul(MIN_DISTANCE))
} else {
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) {
newPoint = Vec.Add(segmentsIntersection, vHandle.mul(MAX_DISTANCE))
newPoint = Vec.Add(segmentsIntersection, tailDirection.mul(MAX_DISTANCE))
}
}
const next = deepCopy(shape)
next.props.handles.handle.x = newPoint.x / w
next.props.handles.handle.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
next.props.tail.x = newPoint.x / w
next.props.tail.y = newPoint.y / h
return next
}
@ -203,30 +189,35 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
}
/*
Introduction:
This file contains our custom shape util. The shape util is a class that defines how our shape behaves.
Most of the logic for how the speech bubble shape works is in the onBeforeUpdate handler [4]. Since this
shape has a handle, we need to do some special stuff to make sure it updates the way we want it to.
Introduction: This file contains our custom shape util. The shape util is a class that defines how
our shape behaves. Most of the logic for how the speech bubble shape works is in the onBeforeUpdate
handler [5]. Since this shape has a handle, we need to do some special stuff to make sure it updates
the way we want it to.
[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]
This is where we define the shape's props and a type validator for each key. tldraw exports a bunch of handy
validators for us to use. We can also define our own, at the moment our handle validator just returns true
though, because I like to live dangerously. Props you define here will determine which style options show
up in the style menu, e.g. we define 'size' and 'color' props, but we could add 'dash', 'fill' or any other
of the default props.
This is where we define the shape's props and a type validator for each key. tldraw exports a
bunch of handy validators for us to use. Props you define here will determine which style options
show up in the style menu, e.g. we define 'size' and 'color' props, but we could add 'dash', 'fill'
or any other of the default props.
[3]
Here is where we set the default props for our shape, this will determine how the shape looks when we
click-create it. You'll notice we don't store the handle's absolute position though, instead we record its
relative position. This is because we can also drag-create shapes. If we store the handle's position absolutely
it won't scale properly when drag-creating. Throughout the rest of the util we'll need to convert the
handle's relative position to an absolute position and vice versa.
Here is where we set the default props for our shape, this will determine how the shape looks
when we click-create it. You'll notice we don't store the tail's absolute position though, instead
we record its relative position. This is because we can also drag-create shapes. If we store the
tail's position absolutely it won't scale properly when drag-creating. Throughout the rest of the
util we'll need to convert the tail's relative position to an absolute position and vice versa.
[4]
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.
`getHandles` tells tldraw how to turn our shape into a list of handles that'll show up when it's
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'
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 offsetH = w / 10
const offsetV = h / 10
const { adjustedIntersection, intersectionSegmentIndex } = getHandleIntersectionPoint(shape)
const { adjustedIntersection, intersectionSegmentIndex } = getTailIntersectionPoint(shape)
let vertices: Vec[]
@ -22,7 +22,7 @@ export const getSpeechBubbleVertices = (shape: SpeechBubbleShape): Vec[] => {
vertices = [
tl,
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),
tr,
br,
@ -35,7 +35,7 @@ export const getSpeechBubbleVertices = (shape: SpeechBubbleShape): Vec[] => {
tl,
tr,
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),
br,
bl,
@ -48,7 +48,7 @@ export const getSpeechBubbleVertices = (shape: SpeechBubbleShape): Vec[] => {
tr,
br,
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),
bl,
]
@ -61,7 +61,7 @@ export const getSpeechBubbleVertices = (shape: SpeechBubbleShape): Vec[] => {
br,
bl,
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),
]
break
@ -72,9 +72,9 @@ export const getSpeechBubbleVertices = (shape: SpeechBubbleShape): Vec[] => {
return vertices
}
export function getHandleIntersectionPoint(shape: SpeechBubbleShape) {
const { w, h, handles } = shape.props
const handleInShapeSpace = new Vec(handles.handle.x * w, handles.handle.y * h)
export function getTailIntersectionPoint(shape: SpeechBubbleShape) {
const { w, h, tail } = shape.props
const tailInShapeSpace = new Vec(tail.x * w, tail.y * h)
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)]
@ -89,13 +89,13 @@ export function getHandleIntersectionPoint(shape: SpeechBubbleShape) {
let intersectionSegment: Vec[] | null = null
// 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
// inside the shape, push it away from the center by a big distance
const pointToCheck = insideShape
? Vec.Add(handleInShapeSpace, Vec.Sub(handleInShapeSpace, center).uni().mul(1000000))
: handleInShapeSpace
? Vec.Add(tailInShapeSpace, Vec.Sub(tailInShapeSpace, center).uni().mul(1000000))
: tailInShapeSpace
// Test each segment for an intersection
for (const segment of segments) {