add speech bubble example (#2362)

Adds an example for making a custom shape with handles

### Change Type

- [x] `documentation` — Changes to the documentation only[^2]

### Release Notes

- Add an example for making a custom shape with handles, this one is a
speech bubble with a movable tail.
This commit is contained in:
Taha 2023-12-20 14:40:23 +00:00 committed by GitHub
parent 6549ab70e2
commit 1c954db90e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 546 additions and 0 deletions

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="-10 -10 250 250" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0, 0L200, 0,200, 130,119.99, 130,99.9, 212.75,79.99, 130,0, 130Z" stroke="black" stroke-width="15"/>
</svg>

After

Width:  |  Height:  |  Size: 219 B

View file

@ -0,0 +1,23 @@
import { Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
import { SpeechBubbleTool } from './SpeechBubble/SpeechBubbleTool'
import { SpeechBubbleUtil } from './SpeechBubble/SpeechBubbleUtil'
import { customAssetUrls, uiOverrides } from './SpeechBubble/ui-overrides'
import './customhandles.css'
const shapeUtils = [SpeechBubbleUtil]
const tools = [SpeechBubbleTool]
export default function CustomShapeWithHandles() {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw
shapeUtils={shapeUtils}
tools={tools}
overrides={uiOverrides}
assetUrls={customAssetUrls}
persistenceKey="whatever"
/>
</div>
)
}

View file

@ -0,0 +1,7 @@
import { BaseBoxShapeTool } from '@tldraw/tldraw'
export class SpeechBubbleTool extends BaseBoxShapeTool {
static override id = 'speech-bubble'
static override initial = 'idle'
override shapeType = 'speech-bubble'
}

View file

@ -0,0 +1,215 @@
import {
DefaultColorStyle,
DefaultSizeStyle,
Geometry2d,
Polygon2d,
ShapeUtil,
T,
TLBaseShape,
TLDefaultColorStyle,
TLDefaultSizeStyle,
TLHandle,
TLOnBeforeUpdateHandler,
TLOnHandleChangeHandler,
TLOnResizeHandler,
Vec2d,
deepCopy,
getDefaultColorTheme,
resizeBox,
structuredClone,
} from '@tldraw/tldraw'
import { STROKE_SIZES } from '@tldraw/tldraw/src/lib/shapes/shared/default-shape-constants'
import { getHandleIntersectionPoint, getSpeechBubbleGeometry } from './helpers'
export type SpeechBubbleShape = TLBaseShape<
'speech-bubble',
{
w: number
h: number
size: TLDefaultSizeStyle
color: TLDefaultColorStyle
handles: {
handle: TLHandle
}
}
>
export const handleValidator = () => true
export const getHandleinShapeSpace = (shape: SpeechBubbleShape): SpeechBubbleShape => {
const newShape = deepCopy(shape)
newShape.props.handles.handle.x = newShape.props.handles.handle.x * newShape.props.w
newShape.props.handles.handle.y = newShape.props.handles.handle.y * newShape.props.h
return newShape
}
export const getHandlesInHandleSpace = (shape: SpeechBubbleShape): SpeechBubbleShape => {
const newShape = deepCopy(shape)
newShape.props.handles.handle.x = newShape.props.handles.handle.x / newShape.props.w
newShape.props.handles.handle.y = newShape.props.handles.handle.y / newShape.props.h
return newShape
}
export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
static override type = 'speech-bubble' as const
static override props = {
w: T.number,
h: T.number,
size: DefaultSizeStyle,
color: DefaultColorStyle,
handles: {
//TODO: Actually validate this
validate: handleValidator,
handle: { validate: handleValidator },
},
}
override isAspectRatioLocked = (_shape: SpeechBubbleShape) => false
override canResize = (_shape: SpeechBubbleShape) => true
override canBind = (_shape: SpeechBubbleShape) => true
getDefaultProps(): SpeechBubbleShape['props'] {
return {
w: 200,
h: 130,
color: 'black',
size: 'm',
handles: {
handle: {
id: 'handle1',
type: 'vertex',
canBind: true,
canSnap: true,
index: 'a1',
x: 0.5,
y: 1.5,
},
},
}
}
getGeometry(shape: SpeechBubbleShape): Geometry2d {
const newShape = getHandleinShapeSpace(shape)
const speechBubbleGeometry = getSpeechBubbleGeometry(newShape)
const body = new Polygon2d({
points: speechBubbleGeometry,
isFilled: true,
})
return body
}
override getHandles(shape: SpeechBubbleShape) {
const handles = getHandleinShapeSpace(shape).props.handles
const handlesArray = Object.values(handles)
return handlesArray
}
override onBeforeUpdate: TLOnBeforeUpdateHandler<SpeechBubbleShape> | undefined = (
_: SpeechBubbleShape,
next: SpeechBubbleShape
) => {
const shape = getHandleinShapeSpace(next)
const { originalIntersection: intersection, insideShape } = getHandleIntersectionPoint({
w: shape.props.w,
h: shape.props.h,
handle: shape.props.handles.handle,
})
if (!intersection) throw new Error('No intersection')
const intersectionVector = new Vec2d(intersection.x, intersection.y)
const handleVector = new Vec2d(shape.props.handles.handle.x, shape.props.handles.handle.y)
const topLeft = new Vec2d(0, 0)
const bottomRight = new Vec2d(shape.props.w, shape.props.h)
const center = new Vec2d(shape.props.w / 2, shape.props.h / 2)
const MIN_DISTANCE = topLeft.dist(bottomRight) / 5
const MAX_DISTANCE = topLeft.dist(bottomRight) / 1.5
const distanceToIntersection = handleVector.dist(intersectionVector)
const angle = Math.atan2(handleVector.y - center.y, handleVector.x - center.x)
let newPoint = handleVector
if (insideShape) {
const direction = Vec2d.FromAngle(angle, MIN_DISTANCE)
newPoint = intersectionVector.add(direction)
shape.props.handles.handle.x = newPoint.x
shape.props.handles.handle.y = newPoint.y
return getHandlesInHandleSpace(shape)
}
if (distanceToIntersection <= MIN_DISTANCE) {
const direction = Vec2d.FromAngle(angle, MIN_DISTANCE)
newPoint = intersectionVector.add(direction)
}
if (distanceToIntersection >= MAX_DISTANCE) {
const direction = Vec2d.FromAngle(angle, MAX_DISTANCE)
newPoint = intersectionVector.add(direction)
}
shape.props.handles.handle.x = newPoint.x
shape.props.handles.handle.y = newPoint.y
return getHandlesInHandleSpace(shape)
}
override onHandleChange: TLOnHandleChangeHandler<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'] = {
...next.props.handles['handle'],
x: newHandle.x,
y: newHandle.y,
}
return next
}
component(shape: SpeechBubbleShape) {
const theme = getDefaultColorTheme({
isDarkMode: this.editor.user.getIsDarkMode(),
})
const newShape = getHandleinShapeSpace(shape)
const geometry = getSpeechBubbleGeometry(newShape)
const pathData = 'M' + geometry[0] + 'L' + geometry.slice(1) + 'Z'
return (
<>
<svg className="tl-svg-container">
<path
d={pathData}
strokeWidth={STROKE_SIZES[shape.props.size]}
stroke={theme[shape.props.color].solid}
fill={'none'}
/>
</svg>
</>
)
}
indicator(shape: SpeechBubbleShape) {
const newShape = getHandleinShapeSpace(shape)
const geometry = getSpeechBubbleGeometry(newShape)
const pathData = 'M' + geometry[0] + 'L' + geometry.slice(1) + 'Z'
return <path d={pathData} />
}
override onResize: TLOnResizeHandler<SpeechBubbleShape> = (shape, info) => {
const resized = resizeBox(shape, info)
const next = structuredClone(info.initialShape)
next.x = resized.x
next.y = resized.y
next.props.w = resized.props.w
next.props.h = resized.props.h
return next
}
}

View file

@ -0,0 +1,246 @@
import { TLHandle, Vec2d, VecLike, lerp } from '@tldraw/tldraw'
import { SpeechBubbleShape } from './SpeechBubbleUtil'
export const getSpeechBubbleGeometry = (shape: SpeechBubbleShape): Vec2d[] => {
const {
adjustedIntersection: intersection,
offset,
line,
} = getHandleIntersectionPoint({
w: shape.props.w,
h: shape.props.h,
handle: shape.props.handles.handle,
})
const handle = shape.props.handles.handle
const initialSegments = [
new Vec2d(0, 0),
new Vec2d(shape.props.w, 0),
new Vec2d(shape.props.w, shape.props.h),
new Vec2d(0, shape.props.h),
]
if (!intersection) {
throw new Error('No intersection')
}
const createTailSegments = (orientation: 'horizontal' | 'vertical') => {
// Is it a horizontal or vertical line? Which line are we intersecting?
return orientation === 'horizontal'
? [
line === 0
? new Vec2d(intersection.x - offset.horizontal, intersection.y)
: new Vec2d(intersection.x + offset.horizontal, intersection.y),
new Vec2d(handle.x, handle.y),
line === 0
? new Vec2d(intersection.x + offset.horizontal, intersection.y)
: new Vec2d(intersection.x - offset.horizontal, intersection.y),
]
: [
line === 1
? new Vec2d(intersection.x, intersection.y - offset.vertical)
: new Vec2d(intersection.x, intersection.y + offset.vertical),
new Vec2d(handle.x, handle.y),
line === 1
? new Vec2d(intersection.x, intersection.y + offset.vertical)
: new Vec2d(intersection.x, intersection.y - offset.vertical),
]
}
let modifiedSegments = [...initialSegments]
// Inject the tail segments into the geometry of the shape
switch (line) {
case 0:
modifiedSegments.splice(1, 0, ...createTailSegments('horizontal'))
break
case 1:
modifiedSegments.splice(2, 0, ...createTailSegments('vertical'))
break
case 2:
modifiedSegments.splice(3, 0, ...createTailSegments('horizontal'))
break
case 3:
modifiedSegments = [...modifiedSegments, ...createTailSegments('vertical')]
break
default:
// eslint-disable-next-line no-console
console.log('default')
}
return modifiedSegments
}
export function getHandleIntersectionPoint({
w,
h,
handle,
}: {
w: number
h: number
handle: TLHandle
}) {
const offset = { horizontal: w / 10, vertical: h / 10 }
const handleVec = new Vec2d(handle.x, handle.y)
const center = new Vec2d(w / 2, h / 2)
const box = [new Vec2d(0, 0), new Vec2d(w, 0), new Vec2d(w, h), new Vec2d(0, h)]
const result = checkIntersection(handleVec, center, box)
if (!result) return { intersection: null, offset: null, line: null }
const { result: intersection, line } = result
// lines
/// 0
// _____________
// | |
// 3| | 1
// | |
// -------------
// 2
const intersectionVec = new Vec2d(intersection[0].x, intersection[0].y)
const lineCoordinates = {
0: { start: new Vec2d(0, 0), end: new Vec2d(w, 0) },
1: { start: new Vec2d(w, 0), end: new Vec2d(w, h) },
2: { start: new Vec2d(0, h), end: new Vec2d(w, h) },
3: { start: new Vec2d(0, 0), end: new Vec2d(0, h) },
}
const { start, end } = lineCoordinates[line]
const whichOffset = line === 0 || line === 2 ? offset.horizontal : offset.vertical
// let's make the intersection more likely to be in the middle and also stay away from the edges
const adjustedIntersection = getAdjustedIntersectionPoint({
start,
end,
intersectionVec,
offset: whichOffset,
})
// We need the adjusted intersection to draw the tail, but the original intersection
// for the onBeforeUpdate handler
return {
originalIntersection: intersectionVec,
adjustedIntersection: adjustedIntersection,
offset,
line,
insideShape: result.insideShape,
}
}
export const getAdjustedIntersectionPoint = ({
start,
end,
intersectionVec,
offset,
}: {
start: Vec2d
end: Vec2d
intersectionVec: Vec2d
offset: number
}): Vec2d | null => {
// a normalised vector from start to end, so this can work in any direction
const unit = Vec2d.Sub(end, start).norm()
// Where is the intersection relative to the start?
const totalDistance = start.dist(end)
const distance = intersectionVec.dist(start)
const middleRelative = mapRange(0, totalDistance, -1, 1, distance) // absolute -> -1 to 1
const squaredRelative = Math.abs(middleRelative) ** 2 * Math.sign(middleRelative) // square it and keep the sign
// make it stick to the middle
const squared = mapRange(-1, 1, 0, totalDistance, squaredRelative) // -1 to 1 -> absolute
//keep it away from the edges
const constrained = mapRange(0, totalDistance, offset * 3, totalDistance - offset * 3, distance)
// combine the two
const interpolated = lerp(constrained, squared, 0.5)
return unit.mul(interpolated).add(start)
}
// This works similarly to the function intersectLineSegmentPolygon in the tldraw codebase,
// but we want to return which line was intersected, and also call the function recursively
export function checkIntersection(
handle: Vec2d,
center: Vec2d,
points: Vec2d[]
): { result: VecLike[]; line: 0 | 1 | 2 | 3; insideShape?: boolean } | null {
const result: VecLike[] = []
let segmentIntersection: VecLike | null
for (let i = 1, n = points.length; i < n + 1; i++) {
segmentIntersection = intersectLineSegmentLineSegment(
handle,
center,
points[i - 1],
points[i % points.length]
)
if (segmentIntersection) {
result.push(segmentIntersection)
return { result, line: (i - 1) as 0 | 1 | 2 | 3 }
}
}
//We're inside the shape, look backwards to find the intersection
const angle = Math.atan2(handle.y - center.y, handle.x - center.x)
//the third point's coordinates are the same as the height and width of the shape
const direction = Vec2d.FromAngle(angle, Math.max(points[2].x, points[2].y))
const newPoint = handle.add(direction)
// Call this function again with the new point
const intersection = checkIntersection(newPoint, center, points)
if (!intersection) return null
return {
result: intersection.result,
line: intersection.line,
insideShape: true,
}
}
// This function is copied from the tldraw codebase
export function intersectLineSegmentLineSegment(
a1: VecLike,
a2: VecLike,
b1: VecLike,
b2: VecLike
) {
const ABx = a1.x - b1.x
const ABy = a1.y - b1.y
const BVx = b2.x - b1.x
const BVy = b2.y - b1.y
const AVx = a2.x - a1.x
const AVy = a2.y - a1.y
const ua_t = BVx * ABy - BVy * ABx
const ub_t = AVx * ABy - AVy * ABx
const u_b = BVy * AVx - BVx * AVy
if (ua_t === 0 || ub_t === 0) return null // coincident
if (u_b === 0) return null // parallel
if (u_b !== 0) {
const ua = ua_t / u_b
const ub = ub_t / u_b
if (0 <= ua && ua <= 1 && 0 <= ub && ub <= 1) {
return Vec2d.AddXY(a1, ua * AVx, ua * AVy)
}
}
return null // no intersection
}
/**
* Inverse linear interpolation
*/
export function invLerp(a: number, b: number, v: number) {
return (v - a) / (b - a)
}
/**
* Maps a value from one range to another.
* e.g. mapRange(10, 20, 50, 100, 15) => 75
*/
export function mapRange(a1: number, b1: number, a2: number, b2: number, s: number) {
return lerp(a2, b2, invLerp(a1, b1, s))
}

View file

@ -0,0 +1,41 @@
import {
TLUiAssetUrlOverrides,
TLUiMenuGroup,
TLUiOverrides,
menuItem,
toolbarItem,
} from '@tldraw/tldraw'
export const uiOverrides: TLUiOverrides = {
tools(editor, tools) {
tools.speech = {
id: 'speech-bubble',
//get rid of typescript error?
icon: 'speech-bubble',
label: 'Speech Bubble',
kbd: 's',
readonlyOk: false,
onSelect: () => {
editor.setCurrentTool('speech-bubble')
},
}
return tools
},
toolbar(_app, toolbar, { tools }) {
toolbar.splice(4, 0, toolbarItem(tools.speech))
return toolbar
},
keyboardShortcutsMenu(_app, keyboardShortcutsMenu, { tools }) {
const toolsGroup = keyboardShortcutsMenu.find(
(group) => group.id === 'shortcuts-dialog.tools'
) as TLUiMenuGroup
toolsGroup.children.push(menuItem(tools.speech))
return keyboardShortcutsMenu
},
}
export const customAssetUrls: TLUiAssetUrlOverrides = {
icons: {
'speech-bubble': '/speech-bubble.svg',
},
}

View file

@ -0,0 +1,5 @@
/* Resize handles are normally on top, but We're going to give shape handles priority */
.tl-user-handles {
z-index: 101;
}

View file

@ -18,6 +18,7 @@ import AssetPropsExample from './examples/AssetOptionsExample'
import CanvasEventsExample from './examples/CanvasEventsExample'
import CustomComponentsExample from './examples/CustomComponentsExample'
import CustomConfigExample from './examples/CustomConfigExample/CustomConfigExample'
import CustomShapeWithHandles from './examples/CustomShapeWithHandles/CustomShapeWithHandles'
import CustomStylesExample from './examples/CustomStylesExample/CustomStylesExample'
import CustomUiExample from './examples/CustomUiExample/CustomUiExample'
import ErrorBoundaryExample from './examples/ErrorBoundaryExample/ErrorBoundaryExample'
@ -203,6 +204,11 @@ export const allExamples: Example[] = [
path: 'external-content-sources',
element: <ExternalContentSourcesExample />,
},
{
title: 'Custom Shape With Handles',
path: 'custom-shape-with-handles',
element: <CustomShapeWithHandles />,
}, // not listed
// not listed
{
path: 'end-to-end',