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 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',
|
||||
|
|
Loading…
Reference in a new issue