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:
parent
16316ac2a0
commit
afd5af1cb6
5 changed files with 257 additions and 231 deletions
|
@ -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.
|
||||
|
||||
*/
|
||||
|
|
|
@ -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.
|
||||
|
||||
*/
|
||||
|
|
|
@ -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.
|
||||
|
||||
*/
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
||||
*/
|
||||
|
|
Loading…
Reference in a new issue