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:
parent
6549ab70e2
commit
1c954db90e
8 changed files with 546 additions and 0 deletions
3
apps/examples/public/speech-bubble.svg
Normal file
3
apps/examples/public/speech-bubble.svg
Normal 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 |
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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'
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
|
@ -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',
|
||||||
|
},
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
||||||
|
}
|
|
@ -18,6 +18,7 @@ import AssetPropsExample from './examples/AssetOptionsExample'
|
||||||
import CanvasEventsExample from './examples/CanvasEventsExample'
|
import CanvasEventsExample from './examples/CanvasEventsExample'
|
||||||
import CustomComponentsExample from './examples/CustomComponentsExample'
|
import CustomComponentsExample from './examples/CustomComponentsExample'
|
||||||
import CustomConfigExample from './examples/CustomConfigExample/CustomConfigExample'
|
import CustomConfigExample from './examples/CustomConfigExample/CustomConfigExample'
|
||||||
|
import CustomShapeWithHandles from './examples/CustomShapeWithHandles/CustomShapeWithHandles'
|
||||||
import CustomStylesExample from './examples/CustomStylesExample/CustomStylesExample'
|
import CustomStylesExample from './examples/CustomStylesExample/CustomStylesExample'
|
||||||
import CustomUiExample from './examples/CustomUiExample/CustomUiExample'
|
import CustomUiExample from './examples/CustomUiExample/CustomUiExample'
|
||||||
import ErrorBoundaryExample from './examples/ErrorBoundaryExample/ErrorBoundaryExample'
|
import ErrorBoundaryExample from './examples/ErrorBoundaryExample/ErrorBoundaryExample'
|
||||||
|
@ -203,6 +204,11 @@ export const allExamples: Example[] = [
|
||||||
path: 'external-content-sources',
|
path: 'external-content-sources',
|
||||||
element: <ExternalContentSourcesExample />,
|
element: <ExternalContentSourcesExample />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Custom Shape With Handles',
|
||||||
|
path: 'custom-shape-with-handles',
|
||||||
|
element: <CustomShapeWithHandles />,
|
||||||
|
}, // not listed
|
||||||
// not listed
|
// not listed
|
||||||
{
|
{
|
||||||
path: 'end-to-end',
|
path: 'end-to-end',
|
||||||
|
|
Loading…
Reference in a new issue