Refactor and document speech bubble example (#2392)

Adds comments to speech bubble example in a similar vein to the
screenshot tool example. it also refactors the code to make it easier to
parse.

### Change Type

- [ ] `patch` — Bug fix
- [ ] `minor` — New feature
- [ ] `major` — Breaking change
- [ ] `dependencies` — Changes to package dependencies[^1]
- [x] `documentation` — Changes to the documentation only[^2]
- [ ] `tests` — Changes to any test code only[^2]
- [ ] `internal` — Any other changes that don't affect the published
package[^2]
- [ ] I don't know

[^1]: publishes a `patch` release, for devDependencies use `internal`
[^2]: will not publish a new version

### Test Plan

1. Add a step-by-step description of how to test your PR here.
2.

- [ ] Unit Tests
- [ ] End to end tests

### Release Notes

- Add annotations to the speech bubble example
- Refactor code for clarity

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
Taha 2024-01-03 10:29:59 +00:00 committed by GitHub
parent 16316ac2a0
commit afd5af1cb6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 257 additions and 231 deletions

View file

@ -5,9 +5,13 @@ import { SpeechBubbleUtil } from './SpeechBubble/SpeechBubbleUtil'
import { customAssetUrls, uiOverrides } from './SpeechBubble/ui-overrides'
import './customhandles.css'
// There's a guide at the bottom of this file!
// [1]
const shapeUtils = [SpeechBubbleUtil]
const tools = [SpeechBubbleTool]
// [2]
export default function CustomShapeWithHandles() {
return (
<div style={{ position: 'absolute', inset: 0 }}>
@ -21,3 +25,25 @@ export default function CustomShapeWithHandles() {
</div>
)
}
/*
Introduction:
This example shows how to create a custom shape using handles. You can use handles when you want
user interaction to alter the geometry of a shape. In this example, we create a speech bubble shape
with a handle on the tail so the user can alter its position and length. Most of the interesting stuff
is in SpeechBubbleUtil.tsx and helpers.tsx.
[1]
We define an array to hold the custom shape util and cusom tool. It's important to do this outside of
any React component so that this array doesn't get redefined on every render. We'll pass this into the
Tldraw component's `shapeUtils` and `tools` props.
Check out SpeechBubbleUtil.tsx and SpeechBubbleTool.tsx to see how we define the shape util and tool.
[2]
We pass the custom shape util and tool into the Tldraw component's `shapeUtils` and `tools` props.
We also pass in the custom ui overrides and asset urls to make sure our icons render where we want them to.
Check out ui-overrides.ts for more details.
*/

View file

@ -5,3 +5,11 @@ export class SpeechBubbleTool extends BaseBoxShapeTool {
static override initial = 'idle'
override shapeType = 'speech-bubble'
}
/*
This file contains our speech bubble tool. The tool is a StateNode with the `id` "screenshot".
We get a lot of functionality for free by extending the BaseBoxShapeTool. For an example of a tool
with more custom functionality, check out the screenshot-tool example.
*/

View file

@ -18,9 +18,19 @@ import {
resizeBox,
structuredClone,
} from '@tldraw/tldraw'
import { STROKE_SIZES } from '@tldraw/tldraw/src/lib/shapes/shared/default-shape-constants'
import { getHandleIntersectionPoint, getSpeechBubbleGeometry } from './helpers'
import { getHandleIntersectionPoint, getSpeechBubbleVertices } from './helpers'
// Copied from tldraw/tldraw
export const STROKE_SIZES = {
s: 2,
m: 3.5,
l: 5,
xl: 10,
}
// There's a guide at the bottom of this file!
// [1]
export type SpeechBubbleShape = TLBaseShape<
'speech-bubble',
{
@ -36,30 +46,16 @@ export type SpeechBubbleShape = TLBaseShape<
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
// [2]
static override props = {
w: T.number,
h: T.number,
size: DefaultSizeStyle,
color: DefaultColorStyle,
handles: {
//TODO: Actually validate this
validate: handleValidator,
handle: { validate: handleValidator },
},
@ -71,6 +67,7 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
override canBind = (_shape: SpeechBubbleShape) => true
// [3]
getDefaultProps(): SpeechBubbleShape['props'] {
return {
w: 200,
@ -92,8 +89,7 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
}
getGeometry(shape: SpeechBubbleShape): Geometry2d {
const newShape = getHandleinShapeSpace(shape)
const speechBubbleGeometry = getSpeechBubbleGeometry(newShape)
const speechBubbleGeometry = getSpeechBubbleVertices(shape)
const body = new Polygon2d({
points: speechBubbleGeometry,
isFilled: true,
@ -102,59 +98,59 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
}
override getHandles(shape: SpeechBubbleShape) {
const handles = getHandleinShapeSpace(shape).props.handles
const handlesArray = Object.values(handles)
const {
handles: { handle },
w,
h,
} = shape.props
return handlesArray
return [
{
...handle,
// props.handles.handle coordinates are normalized
// but here we need them in shape space
x: handle.x * w,
y: handle.y * h,
},
]
}
// [4]
override onBeforeUpdate: TLOnBeforeUpdateHandler<SpeechBubbleShape> | undefined = (
_: SpeechBubbleShape,
next: SpeechBubbleShape
shape: SpeechBubbleShape
) => {
const shape = getHandleinShapeSpace(next)
const { w, h, handles } = shape.props
const { originalIntersection: intersection, insideShape } = getHandleIntersectionPoint({
w: shape.props.w,
h: shape.props.h,
handle: shape.props.handles.handle,
})
const { segmentsIntersection, insideShape } = getHandleIntersectionPoint(shape)
if (!intersection) throw new Error('No intersection')
const slantedLength = Math.hypot(w, h)
const MIN_DISTANCE = slantedLength / 5
const MAX_DISTANCE = slantedLength / 1.5
const intersectionVector = new Vec2d(intersection.x, intersection.y)
const handleVector = new Vec2d(shape.props.handles.handle.x, shape.props.handles.handle.y)
const handleInShapeSpace = new Vec2d(handles.handle.x * w, handles.handle.y * h)
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 distanceToIntersection = handleInShapeSpace.dist(segmentsIntersection)
const center = new Vec2d(w / 2, h / 2)
const vHandle = Vec2d.Sub(handleInShapeSpace, center).uni()
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
let newPoint = handleInShapeSpace
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)
newPoint = Vec2d.Add(segmentsIntersection, vHandle.mul(MIN_DISTANCE))
} else {
if (distanceToIntersection <= MIN_DISTANCE) {
newPoint = Vec2d.Add(segmentsIntersection, vHandle.mul(MIN_DISTANCE))
} else if (distanceToIntersection >= MAX_DISTANCE) {
newPoint = Vec2d.Add(segmentsIntersection, vHandle.mul(MAX_DISTANCE))
}
}
shape.props.handles.handle.x = newPoint.x
shape.props.handles.handle.y = newPoint.y
return getHandlesInHandleSpace(shape)
const next = deepCopy(shape)
next.props.handles.handle.x = newPoint.x / w
next.props.handles.handle.y = newPoint.y / h
return next
}
override onHandleChange: TLOnHandleChangeHandler<SpeechBubbleShape> = (
@ -165,11 +161,7 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
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,
}
next.props.handles.handle = newHandle
return next
}
@ -178,9 +170,8 @@ export class SpeechBubbleUtil extends ShapeUtil<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'
const vertices = getSpeechBubbleVertices(shape)
const pathData = 'M' + vertices[0] + 'L' + vertices.slice(1) + 'Z'
return (
<>
@ -197,9 +188,8 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
}
indicator(shape: SpeechBubbleShape) {
const newShape = getHandleinShapeSpace(shape)
const geometry = getSpeechBubbleGeometry(newShape)
const pathData = 'M' + geometry[0] + 'L' + geometry.slice(1) + 'Z'
const vertices = getSpeechBubbleVertices(shape)
const pathData = 'M' + vertices[0] + 'L' + vertices.slice(1) + 'Z'
return <path d={pathData} />
}
@ -213,3 +203,32 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
return next
}
}
/*
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.
[1]
Here is where we define the shape's type. For the handle we can use the `TLHandle` 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 defauly 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.
[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.
*/

View file

@ -1,211 +1,157 @@
import { TLHandle, Vec2d, VecLike, lerp } from '@tldraw/tldraw'
import { Vec2d, VecLike, lerp, pointInPolygon } 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,
})
export const getSpeechBubbleVertices = (shape: SpeechBubbleShape): Vec2d[] => {
const { w, h, handles } = shape.props
const handle = shape.props.handles.handle
const handleInShapeSpace = new Vec2d(handles.handle.x * w, handles.handle.y * h)
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),
]
const [tl, tr, br, bl] = [new Vec2d(0, 0), new Vec2d(w, 0), new Vec2d(w, h), new Vec2d(0, h)]
if (!intersection) {
throw new Error('No intersection')
}
const offsetH = w / 10
const offsetV = h / 10
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),
]
}
const { adjustedIntersection, intersectionSegmentIndex } = getHandleIntersectionPoint(shape)
let modifiedSegments = [...initialSegments]
let vertices: Vec2d[]
// Inject the tail segments into the geometry of the shape
switch (line) {
switch (intersectionSegmentIndex) {
case 0:
modifiedSegments.splice(1, 0, ...createTailSegments('horizontal'))
// top
vertices = [
tl,
new Vec2d(adjustedIntersection.x - offsetH, adjustedIntersection.y),
new Vec2d(handleInShapeSpace.x, handleInShapeSpace.y),
new Vec2d(adjustedIntersection.x + offsetH, adjustedIntersection.y),
tr,
br,
bl,
]
break
case 1:
modifiedSegments.splice(2, 0, ...createTailSegments('vertical'))
// right
vertices = [
tl,
tr,
new Vec2d(adjustedIntersection.x, adjustedIntersection.y - offsetV),
new Vec2d(handleInShapeSpace.x, handleInShapeSpace.y),
new Vec2d(adjustedIntersection.x, adjustedIntersection.y + offsetV),
br,
bl,
]
break
case 2:
modifiedSegments.splice(3, 0, ...createTailSegments('horizontal'))
// bottom
vertices = [
tl,
tr,
br,
new Vec2d(adjustedIntersection.x + offsetH, adjustedIntersection.y),
new Vec2d(handleInShapeSpace.x, handleInShapeSpace.y),
new Vec2d(adjustedIntersection.x - offsetH, adjustedIntersection.y),
bl,
]
break
case 3:
modifiedSegments = [...modifiedSegments, ...createTailSegments('vertical')]
// left
vertices = [
tl,
tr,
br,
bl,
new Vec2d(adjustedIntersection.x, adjustedIntersection.y + offsetV),
new Vec2d(handleInShapeSpace.x, handleInShapeSpace.y),
new Vec2d(adjustedIntersection.x, adjustedIntersection.y - offsetV),
]
break
default:
// eslint-disable-next-line no-console
console.log('default')
throw Error("no intersection found, this shouldn't happen")
}
return modifiedSegments
return vertices
}
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)
export function getHandleIntersectionPoint(shape: SpeechBubbleShape) {
const { w, h, handles } = shape.props
const handleInShapeSpace = new Vec2d(handles.handle.x * w, handles.handle.y * h)
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 corners = [new Vec2d(0, 0), new Vec2d(w, 0), new Vec2d(w, h), new Vec2d(0, h)]
const segments = [
[corners[0], corners[1]],
[corners[1], corners[2]],
[corners[2], corners[3]],
[corners[3], corners[0]],
]
const result = checkIntersection(handleVec, center, box)
if (!result) return { intersection: null, offset: null, line: null }
const { result: intersection, line } = result
let segmentsIntersection: Vec2d | null = null
let intersectionSegment: Vec2d[] | null = null
// lines
/// 0
// _____________
// | |
// 3| | 1
// | |
// -------------
// 2
// If the point inside of the box's corners?
const insideShape = pointInPolygon(handleInShapeSpace, corners)
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) },
// 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
? Vec2d.Add(handleInShapeSpace, Vec2d.Sub(handleInShapeSpace, center).uni().mul(1000000))
: handleInShapeSpace
// Test each segment for an intersection
for (const segment of segments) {
segmentsIntersection = intersectLineSegmentLineSegment(
segment[0],
segment[1],
center,
pointToCheck
)
if (segmentsIntersection) {
intersectionSegment = segment
break
}
}
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,
if (!(segmentsIntersection && intersectionSegment)) {
throw Error("no intersection found, this shouldn't happen")
}
}
export const getAdjustedIntersectionPoint = ({
start,
end,
intersectionVec,
offset,
}: {
start: Vec2d
end: Vec2d
intersectionVec: Vec2d
offset: number
}): Vec2d | null => {
const [start, end] = intersectionSegment
const intersectionSegmentIndex = segments.indexOf(intersectionSegment)
// a normalised vector from start to end, so this can work in any direction
const unit = Vec2d.Sub(end, start).norm()
const unit = Vec2d.Sub(end, start).uni()
// Where is the intersection relative to the start?
const totalDistance = start.dist(end)
const distance = intersectionVec.dist(start)
const totalDistance = Vec2d.Dist(start, end)
const distance = Vec2d.Dist(segmentsIntersection, start)
// make it stick to the middle
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)
const offset = (segments.indexOf(intersectionSegment) % 2 === 0 ? w / 10 : h / 10) * 3
const constrained = mapRange(0, totalDistance, offset, totalDistance - offset, distance)
// combine the two
const interpolated = lerp(constrained, squared, 0.5)
return unit.mul(interpolated).add(start)
}
const adjustedIntersection = 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
// We need the adjusted intersection to draw the tail, but the original intersection
// for the onBeforeUpdate handler
return {
result: intersection.result,
line: intersection.line,
insideShape: true,
segmentsIntersection,
adjustedIntersection,
intersectionSegmentIndex,
insideShape,
}
}
// This function is copied from the tldraw codebase
export function intersectLineSegmentLineSegment(
a1: VecLike,
a2: VecLike,
b1: VecLike,
b2: VecLike
) {
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
@ -234,13 +180,13 @@ export function intersectLineSegmentLineSegment(
/**
* Inverse linear interpolation
*/
export function invLerp(a: number, b: number, v: number) {
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) {
function mapRange(a1: number, b1: number, a2: number, b2: number, s: number) {
return lerp(a2, b2, invLerp(a1, b1, s))
}

View file

@ -6,11 +6,13 @@ import {
toolbarItem,
} from '@tldraw/tldraw'
// There's a guide at the bottom of this file!
// [1]
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',
@ -34,8 +36,33 @@ export const uiOverrides: TLUiOverrides = {
},
}
// [2]
export const customAssetUrls: TLUiAssetUrlOverrides = {
icons: {
'speech-bubble': '/speech-bubble.svg',
},
}
/*
This file contains overrides for the Tldraw UI. These overrides are used to add your custom tools
to the toolbar and the keyboard shortcuts menu.
[1]
Here we add our custom tool to the toolbar. We do this by providing a custom
toolbar override to the Tldraw component. This override is a function that takes
the current editor, the default toolbar items, and the default tools. It returns
the new toolbar items. We use the toolbarItem helper to create a new toolbar item
for our custom tool. We then splice it into the toolbar items array at the 4th index.
This puts it after the eraser tool. We'll pass our overrides object into the
Tldraw component's `overrides` prop.
[2]
Our toolbar item is using a custom icon, so we need to provide the asset url for it.
We do this by providing a custom assetUrls object to the Tldraw component.
This object is a map of icon ids to their urls. The icon ids are the same as the
icon prop on the toolbar item. We'll pass our assetUrls object into the Tldraw
component's `assetUrls` prop.
*/