snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

View File

@@ -0,0 +1,325 @@
import { act, screen } from '@testing-library/react'
import { BaseBoxShapeUtil, Editor, StateNode, TLStateNodeConstructor } from '@tldraw/editor'
import { useState } from 'react'
import {
renderTldrawComponent,
renderTldrawComponentWithEditor,
} from '../test/testutils/renderTldrawComponent'
import { Tldraw } from './Tldraw'
describe('<Tldraw />', () => {
it('Renders without crashing', async () => {
await renderTldrawComponent(
<Tldraw>
<div data-testid="canvas-1" />
</Tldraw>,
{ waitForPatterns: true }
)
await screen.findByTestId('canvas-1')
})
it('Doesnt cause re-render loops with unstable shape utils + tools', async () => {
function TestComponent() {
const [_, setEditor] = useState<Editor | null>(null)
return (
<Tldraw onMount={setEditor} shapeUtils={[]} tools={[]}>
<div data-testid="canvas-1" />
</Tldraw>
)
}
await renderTldrawComponent(<TestComponent />, { waitForPatterns: true })
await screen.findByTestId('canvas-1')
})
it('Doesnt cause re-render loops when shape utils change', async () => {
class FakeShapeUtil1 extends BaseBoxShapeUtil<any> {
static override type = 'fake' as const
override getDefaultProps() {
throw new Error('Method not implemented.')
}
override component(_: any) {
throw new Error('Method not implemented.')
}
override getIndicatorPath(_: any): undefined {
throw new Error('Method not implemented.')
}
}
class FakeShapeUtil2 extends BaseBoxShapeUtil<any> {
static override type = 'fake' as const
override getDefaultProps() {
throw new Error('Method not implemented.')
}
override component(_: any) {
throw new Error('Method not implemented.')
}
override getIndicatorPath(_: any): undefined {
throw new Error('Method not implemented.')
}
}
const rendered = await renderTldrawComponent(
<Tldraw shapeUtils={[FakeShapeUtil1]}>
<div data-testid="canvas-1" />
</Tldraw>,
{ waitForPatterns: false }
)
await screen.findByTestId('canvas-1')
await act(async () => {
rendered.rerender(
<Tldraw shapeUtils={[FakeShapeUtil2]}>
<div data-testid="canvas-2" />
</Tldraw>
)
})
await screen.findByTestId('canvas-2')
})
it('correctly merges custom tools with default tools, allowing custom tools to override defaults', async () => {
// Create a custom tool that overrides a default tool
class CustomSelectTool extends StateNode {
static override id = 'select' // This should override the default select tool
static override initial = 'idle'
static override children(): TLStateNodeConstructor[] {
return [CustomIdleState]
}
}
class CustomIdleState extends StateNode {
static override id = 'idle'
}
// Create a custom tool that doesn't conflict with defaults
class CustomTool extends StateNode {
static override id = 'custom-tool'
static override initial = 'idle'
static override children(): TLStateNodeConstructor[] {
return [CustomToolIdleState]
}
}
class CustomToolIdleState extends StateNode {
static override id = 'idle'
}
let editor: Editor
await renderTldrawComponent(
<Tldraw
tools={[CustomSelectTool, CustomTool]}
onMount={(e) => {
editor = e
}}
/>,
{ waitForPatterns: false }
)
// Verify that the custom select tool overrides the default select tool
expect(editor!.root.children!['select']).toBeInstanceOf(CustomSelectTool)
// Verify that the custom tool is also available
expect(editor!.root.children!['custom-tool']).toBeInstanceOf(CustomTool)
// Verify that other default tools are still available
expect(editor!.root.children!['eraser']).toBeDefined()
expect(editor!.root.children!['hand']).toBeDefined()
expect(editor!.root.children!['zoom']).toBeDefined()
})
it('keyboard shortcuts work when hideUi is true', async () => {
const { editor } = await renderTldrawComponentWithEditor(
(onMount) => <Tldraw hideUi onMount={onMount} />,
{ waitForPatterns: false }
)
// Focus the editor so keyboard shortcuts are active
await act(async () => {
editor.focus()
})
// Start on select tool
expect(editor.getCurrentToolId()).toBe('select')
await act(async () => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', bubbles: true })
)
document.body.dispatchEvent(
new KeyboardEvent('keyup', { key: 'd', code: 'KeyD', bubbles: true })
)
})
// Should now be on draw tool
expect(editor.getCurrentToolId()).toBe('draw')
await act(async () => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'h', code: 'KeyH', bubbles: true })
)
document.body.dispatchEvent(
new KeyboardEvent('keyup', { key: 'h', code: 'KeyH', bubbles: true })
)
})
// Should now be on hand tool
expect(editor.getCurrentToolId()).toBe('hand')
})
it('matches typed-character shortcuts on alternative Latin layouts (Dvorak)', async () => {
const { editor } = await renderTldrawComponentWithEditor(
(onMount) => <Tldraw hideUi onMount={onMount} />,
{ waitForPatterns: false }
)
await act(async () => {
editor.focus()
})
// Dvorak's 'd' lives at the physical position QWERTY calls 'KeyH'. The user typed 'd';
// matching by typed character (event.key) selects the draw tool regardless of physical position.
await act(async () => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'd', code: 'KeyH', bubbles: true })
)
document.body.dispatchEvent(
new KeyboardEvent('keyup', { key: 'd', code: 'KeyH', bubbles: true })
)
})
expect(editor.getCurrentToolId()).toBe('draw')
// Dvorak's 'e' lives at the physical position QWERTY calls 'KeyD'.
await act(async () => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'e', code: 'KeyD', bubbles: true })
)
document.body.dispatchEvent(
new KeyboardEvent('keyup', { key: 'e', code: 'KeyD', bubbles: true })
)
})
expect(editor.getCurrentToolId()).toBe('eraser')
})
it('falls back to physical key for non-Latin layouts (Cyrillic)', async () => {
const { editor } = await renderTldrawComponentWithEditor(
(onMount) => <Tldraw hideUi onMount={onMount} />,
{ waitForPatterns: false }
)
await act(async () => {
editor.focus()
})
// Cyrillic users pressing the physical KeyA position type 'ф'. Since the typed character
// has no ASCII shortcut, we fall back to event.code's US-QWERTY equivalent ('a') — which
// activates the arrow tool just as it would on a QWERTY keyboard.
await act(async () => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ф', code: 'KeyA', bubbles: true })
)
document.body.dispatchEvent(
new KeyboardEvent('keyup', { key: 'ф', code: 'KeyA', bubbles: true })
)
})
expect(editor.getCurrentToolId()).toBe('arrow')
})
it('matches modifier shortcuts (cmd/ctrl) regardless of layout', async () => {
const { editor } = await renderTldrawComponentWithEditor(
(onMount) => <Tldraw hideUi onMount={onMount} />,
{ waitForPatterns: false }
)
await act(async () => {
editor.focus()
})
await act(async () => {
editor.createShape({ id: 'shape:test' as any, type: 'geo', x: 0, y: 0 })
})
expect(editor.getCurrentPageShapeIds().size).toBe(1)
await act(async () => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'z',
code: 'KeyZ',
metaKey: true,
bubbles: true,
})
)
document.body.dispatchEvent(
new KeyboardEvent('keyup', {
key: 'z',
code: 'KeyZ',
metaKey: true,
bubbles: true,
})
)
})
// cmd+z should undo the shape creation (kbd is 'cmd+z,ctrl+z')
expect(editor.getCurrentPageShapeIds().size).toBe(0)
})
it('matches the comma key for the temporary zoom shortcut on Dvorak', async () => {
const { editor } = await renderTldrawComponentWithEditor(
(onMount) => <Tldraw hideUi onMount={onMount} />,
{ waitForPatterns: false }
)
await act(async () => {
editor.focus()
})
// Dvorak's comma is at the physical KeyW position. With layout-independent matching by
// event.code (the v4 hotkeys-js bug) the library would read this as 'w' and the comma
// zoom would never engage. We match by typed character so the registered ',' shortcut
// fires correctly.
await act(async () => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: ',', code: 'KeyW', bubbles: true })
)
})
expect(editor.inputs.keys.has('Comma')).toBe(true)
await act(async () => {
document.body.dispatchEvent(
new KeyboardEvent('keyup', { key: ',', code: 'KeyW', bubbles: true })
)
})
expect(editor.inputs.keys.has('Comma')).toBe(false)
})
it('does not fire shortcuts when typing into a textarea', async () => {
const { editor } = await renderTldrawComponentWithEditor(
(onMount) => <Tldraw hideUi onMount={onMount} />,
{ waitForPatterns: false }
)
await act(async () => {
editor.focus()
})
expect(editor.getCurrentToolId()).toBe('select')
const textarea = document.createElement('textarea')
document.body.appendChild(textarea)
try {
await act(async () => {
textarea.dispatchEvent(
new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', bubbles: true })
)
textarea.dispatchEvent(
new KeyboardEvent('keyup', { key: 'd', code: 'KeyD', bubbles: true })
)
})
// Should remain on select tool — the shortcut is filtered out for textarea targets.
expect(editor.getCurrentToolId()).toBe('select')
} finally {
textarea.remove()
}
})
})

View File

@@ -0,0 +1,385 @@
import {
DEFAULT_SUPPORTED_IMAGE_TYPES,
DEFAULT_SUPPORT_VIDEO_TYPES,
TLEditorComponents,
TLOnMountHandler,
TLTextOptions,
TldrawEditor,
TldrawEditorBaseProps,
TldrawEditorStoreProps,
defaultUserPreferences,
mergeArraysAndReplaceDefaults,
useEditor,
useEditorComponents,
useOnMount,
useShallowArrayIdentity,
useShallowObjectIdentity,
} from '@tldraw/editor'
import { TLAnyAssetUtilConstructor } from '@tldraw/editor'
import { useMemo } from 'react'
import { ImageAssetUtil } from './assets/ImageAssetUtil'
import { VideoAssetUtil } from './assets/VideoAssetUtil'
import { defaultAssetUtils } from './defaultAssetUtils'
import { defaultBindingUtils } from './defaultBindingUtils'
import { TLEmbedDefinition } from './defaultEmbedDefinitions'
import {
TLExternalContentProps,
registerDefaultExternalContentHandlers,
} from './defaultExternalContentHandlers'
import { defaultOverlayUtils } from './defaultOverlayUtils'
import { defaultShapeTools } from './defaultShapeTools'
import { defaultShapeUtils } from './defaultShapeUtils'
import { registerDefaultSideEffects } from './defaultSideEffects'
import { defaultTools } from './defaultTools'
import { EmbedShapeUtil } from './shapes/embed/EmbedShapeUtil'
import { allDefaultFontFaces } from './shapes/shared/defaultFonts'
import { TLUiAssetUrlOverrides, useDefaultUiAssetUrlsWithOverrides } from './ui/assetUrls'
import { LoadingScreen } from './ui/components/LoadingScreen'
import { Spinner } from './ui/components/Spinner'
import { AssetUrlsProvider } from './ui/context/asset-urls'
import { TLUiComponents, useTldrawUiComponents } from './ui/context/components'
import { useUiEvents } from './ui/context/events'
import { useToasts } from './ui/context/toasts'
import {
TldrawUiTranslationProvider,
useTranslation,
} from './ui/hooks/useTranslation/useTranslation'
import { useMergedTranslationOverrides } from './ui/overrides'
import { TldrawUi, TldrawUiInFrontOfTheCanvas, TldrawUiProps } from './ui/TldrawUi'
import { useDefaultEditorAssetsWithOverrides } from './utils/static-assets/assetUrls'
import { defaultAddFontsFromNode, tipTapDefaultExtensions } from './utils/text/richText'
/**
* Override the default react components used by the editor and UI. Set components to null to
* disable them entirely.
*
* @example
* ```tsx
* import {Tldraw, TLComponents} from 'tldraw'
*
* const components: TLComponents = {
* Scribble: MyCustomScribble,
* }
*
* export function MyApp() {
* return <Tldraw components={components} />
* }
* ```
*
*
* @public
*/
export interface TLComponents extends TLEditorComponents, TLUiComponents {}
/** @public */
export interface TldrawBaseProps
extends TldrawUiProps, TldrawEditorBaseProps, TLExternalContentProps {
/** Urls for custom assets.
*
* ⚠︎ Important! This must be memoized (with useMemo) or defined outside of any React component.
*/
assetUrls?: TLUiAssetUrlOverrides
/** Overrides for tldraw's components.
*
* ⚠︎ Important! This must be memoized (with useMemo) or defined outside of any React component.
*/
components?: TLComponents
/** Custom definitions for tldraw's embeds.
*
* ⚠︎ Important! This must be memoized (with useMemo) or defined outside of any React component.
*
* @deprecated Use `EmbedShapeUtil.configure({ embedDefinitions: embeds })` instead.
*/
embeds?: TLEmbedDefinition[]
/**
* Text options for the editor.
*
* @deprecated Use `options.text` instead. This prop will be removed in a future release.
*/
textOptions?: TLTextOptions
/**
* The locale to use for the editor's UI. When set, this takes priority over
* both the browser's language preferences (`navigator.languages`) and the
* user's locale preference (e.g. via
* `editor.user.updateUserPreferences({ locale: '...' })`), giving the
* application explicit control over the displayed language.
*
* @example
* ```tsx
* <Tldraw locale="fr" />
* ```
*/
locale?: string
}
/** @public */
export type TldrawProps = TldrawBaseProps & TldrawEditorStoreProps
const allDefaultTools = [...defaultTools, ...defaultShapeTools]
function configureDefaultAssetUtils(
assetUtils: readonly TLAnyAssetUtilConstructor[],
overrides: Pick<
TLExternalContentProps,
'maxImageDimension' | 'acceptedImageMimeTypes' | 'acceptedVideoMimeTypes'
>
): readonly TLAnyAssetUtilConstructor[] {
const { maxImageDimension, acceptedImageMimeTypes, acceptedVideoMimeTypes } = overrides
const needsImageConfig = maxImageDimension !== undefined || acceptedImageMimeTypes !== undefined
const needsVideoConfig = acceptedVideoMimeTypes !== undefined
if (!needsImageConfig && !needsVideoConfig) return assetUtils
return assetUtils.map((util) => {
if (needsImageConfig && util.type === 'image') {
return (util as typeof ImageAssetUtil).configure({
...(maxImageDimension !== undefined && { maxDimension: maxImageDimension }),
...(acceptedImageMimeTypes !== undefined && { supportedMimeTypes: acceptedImageMimeTypes }),
})
}
if (needsVideoConfig && util.type === 'video') {
return (util as typeof VideoAssetUtil).configure({
...(acceptedVideoMimeTypes !== undefined && { supportedMimeTypes: acceptedVideoMimeTypes }),
})
}
return util
})
}
/** @public @react */
export function Tldraw(props: TldrawProps) {
const {
children,
maxImageDimension,
maxAssetSize,
acceptedImageMimeTypes,
acceptedVideoMimeTypes,
onMount,
components = {},
shapeUtils = [],
bindingUtils = [],
assetUtils = [],
overlayUtils = [],
tools = [],
// needs to be here for backwards compatibility
// eslint-disable-next-line @typescript-eslint/no-deprecated
embeds,
options,
locale,
// needs to be here for backwards compatibility with TldrawEditor
// eslint-disable-next-line @typescript-eslint/no-deprecated
textOptions: _textOptions,
...rest
} = props
const _components = useShallowObjectIdentity(components)
const CustomInFrontOfTheCanvas = components?.InFrontOfTheCanvas
const InFrontOfTheCanvas = useMemo(() => {
if (rest.hideUi) return CustomInFrontOfTheCanvas ?? null
if (!CustomInFrontOfTheCanvas) return TldrawUiInFrontOfTheCanvas
return () => (
<>
<TldrawUiInFrontOfTheCanvas />
<CustomInFrontOfTheCanvas />
</>
)
}, [rest.hideUi, CustomInFrontOfTheCanvas])
const componentsWithDefault = useMemo(
() => ({
Spinner,
LoadingScreen,
..._components,
InFrontOfTheCanvas,
}),
[_components, InFrontOfTheCanvas]
)
const _shapeUtils = useShallowArrayIdentity(shapeUtils)
const shapeUtilsWithDefaults = useMemo(
() => mergeArraysAndReplaceDefaults('type', _shapeUtils, defaultShapeUtils),
[_shapeUtils]
)
const _bindingUtils = useShallowArrayIdentity(bindingUtils)
const bindingUtilsWithDefaults = useMemo(
() => mergeArraysAndReplaceDefaults('type', _bindingUtils, defaultBindingUtils),
[_bindingUtils]
)
const _assetUtils = useShallowArrayIdentity(assetUtils)
const assetUtilsWithDefaults = useMemo(
() =>
configureDefaultAssetUtils(
mergeArraysAndReplaceDefaults('type', _assetUtils, defaultAssetUtils),
{ maxImageDimension, acceptedImageMimeTypes, acceptedVideoMimeTypes }
),
[_assetUtils, maxImageDimension, acceptedImageMimeTypes, acceptedVideoMimeTypes]
)
const _overlayUtils = useShallowArrayIdentity(overlayUtils)
const overlayUtilsWithDefaults = useMemo(
() => mergeArraysAndReplaceDefaults('type', _overlayUtils, defaultOverlayUtils),
[_overlayUtils]
)
const _tools = useShallowArrayIdentity(tools)
const toolsWithDefaults = useMemo(
() => mergeArraysAndReplaceDefaults('id', _tools, allDefaultTools),
[_tools]
)
const _imageMimeTypes = useShallowArrayIdentity(
acceptedImageMimeTypes ?? DEFAULT_SUPPORTED_IMAGE_TYPES
)
const _videoMimeTypes = useShallowArrayIdentity(
acceptedVideoMimeTypes ?? DEFAULT_SUPPORT_VIDEO_TYPES
)
// Merge deprecated textOptions prop with options.textOptions
// options.textOptions takes precedence over the deprecated textOptions prop
const _mergedTextOptions = options?.text ?? _textOptions
const textOptionsWithDefaults = useMemo((): TLTextOptions => {
return {
addFontsFromNode: defaultAddFontsFromNode,
..._mergedTextOptions,
tipTapConfig: {
extensions: tipTapDefaultExtensions,
..._mergedTextOptions?.tipTapConfig,
},
}
}, [_mergedTextOptions])
const optionsWithDefaults = useMemo(
() => ({
...options,
text: textOptionsWithDefaults,
}),
[options, textOptionsWithDefaults]
)
const mediaMimeTypes = useMemo(
() => [..._imageMimeTypes, ..._videoMimeTypes],
[_imageMimeTypes, _videoMimeTypes]
)
const assets = useDefaultEditorAssetsWithOverrides(rest.assetUrls)
const embedShapeUtil = shapeUtilsWithDefaults.find((util) => util.type === 'embed')
if (embedShapeUtil && embeds) {
// eslint-disable-next-line @typescript-eslint/no-deprecated
EmbedShapeUtil.setEmbedDefinitions(embeds)
}
return (
// We provide an extra higher layer of asset+translations providers here so that
// loading UI (which is rendered outside of TldrawUi) may be translated.
// Ideally we would refactor to hoist all the UI context providers we can up here. Maybe later.
<AssetUrlsProvider assetUrls={useDefaultUiAssetUrlsWithOverrides(rest.assetUrls)}>
<TldrawUiTranslationProvider
overrides={useMergedTranslationOverrides(rest.overrides)}
// If the locale prop is provided, then use that and assume it to be controlled
locale={locale ?? rest.user?.userPreferences.get().locale ?? defaultUserPreferences.locale}
>
<TldrawEditor
initialState="select"
{...rest}
components={componentsWithDefault}
shapeUtils={shapeUtilsWithDefaults}
bindingUtils={bindingUtilsWithDefaults}
assetUtils={assetUtilsWithDefaults}
overlayUtils={overlayUtilsWithDefaults}
tools={toolsWithDefaults}
options={optionsWithDefaults}
assetUrls={assets}
>
<TldrawUi {...rest} components={componentsWithDefault} mediaMimeTypes={mediaMimeTypes}>
<InsideOfEditorAndUiContext
maxImageDimension={maxImageDimension}
maxAssetSize={maxAssetSize}
acceptedImageMimeTypes={_imageMimeTypes}
acceptedVideoMimeTypes={_videoMimeTypes}
onMount={onMount}
/>
{children}
</TldrawUi>
</TldrawEditor>
</TldrawUiTranslationProvider>
</AssetUrlsProvider>
)
}
// We put these hooks into a component here so that they can run inside of the context provided by TldrawEditor and TldrawUi.
function InsideOfEditorAndUiContext({
maxImageDimension,
maxAssetSize,
acceptedImageMimeTypes,
acceptedVideoMimeTypes,
onMount,
}: TLExternalContentProps & {
onMount?: TLOnMountHandler
}) {
const editor = useEditor()
const toasts = useToasts()
const msg = useTranslation()
const trackEvent = useUiEvents()
useOnMount(() => {
const unsubs: (void | (() => void) | undefined)[] = []
unsubs.push(registerDefaultSideEffects(editor))
// now that the editor has mounted (and presumably pre-loaded the fonts actually in use in
// the document), we want to preload the other default font faces in the background. These
// won't be directly used, but mean that when adding text the user can switch between fonts
// quickly, without having to wait for them to load in.
editor.fonts.requestFonts(allDefaultFontFaces)
// Also preload any custom font faces defined in themes
const themes = editor.getThemes()
for (const theme of Object.values(themes)) {
for (const key of Object.keys(theme.fonts)) {
const font = theme.fonts[key as keyof typeof theme.fonts]
if (font.faces?.length) {
editor.fonts.requestFonts(font.faces)
}
}
}
editor.once('edit', () => trackEvent('edit', { source: 'unknown' }))
// for content handling, first we register the default handlers...
registerDefaultExternalContentHandlers(editor, {
maxImageDimension,
maxAssetSize,
acceptedImageMimeTypes,
acceptedVideoMimeTypes,
toasts,
msg,
})
// ...then we call the store's on mount which may override them...
unsubs.push(editor.store.props.onMount(editor))
// ...then we run the user's onMount prop, which may override things again.
unsubs.push(onMount?.(editor))
return () => {
unsubs.forEach((fn) => fn?.())
}
})
const { Canvas } = useEditorComponents()
const { ContextMenu } = useTldrawUiComponents()
if (ContextMenu) {
// should wrap canvas
return <ContextMenu />
}
if (Canvas) {
return <Canvas />
}
return null
}

View File

@@ -0,0 +1,237 @@
import {
Editor,
TLAnyBindingUtilConstructor,
TLAnyShapeUtilConstructor,
TLAssetStore,
TLEditorSnapshot,
TLImageExportOptions,
TLPageId,
TLStoreSnapshot,
TLTextOptions,
TldrawOptions,
mergeArraysAndReplaceDefaults,
useShallowArrayIdentity,
useTLStore,
} from '@tldraw/editor'
import { memo, useEffect, useLayoutEffect, useMemo, useState } from 'react'
import { defaultBindingUtils } from './defaultBindingUtils'
import { defaultShapeUtils } from './defaultShapeUtils'
import { TLUiAssetUrlOverrides } from './ui/assetUrls'
import { useDefaultEditorAssetsWithOverrides } from './utils/static-assets/assetUrls'
import { defaultAddFontsFromNode, tipTapDefaultExtensions } from './utils/text/richText'
/** @public */
export interface TldrawImageProps extends TLImageExportOptions {
/**
* The snapshot to display.
*/
snapshot: Partial<TLEditorSnapshot> | TLStoreSnapshot
/**
* The image format to use. Defaults to 'svg'.
*/
format?: 'svg' | 'png'
/**
* The page to display. Defaults to the first page.
*/
pageId?: TLPageId
/**
* Additional shape utils to use.
*/
shapeUtils?: readonly TLAnyShapeUtilConstructor[]
/**
* Additional binding utils to use.
*/
bindingUtils?: readonly TLAnyBindingUtilConstructor[]
/**
* The license key.
*/
licenseKey?: string
/**
* Asset URL overrides.
*/
assetUrls?: TLUiAssetUrlOverrides
/**
* The asset store to use for resolving the snapshot's assets, matching the `assets` prop on
* {@link Tldraw}. Without one, assets that aren't stored inline (as data URLs) can't be
* resolved and won't appear in the image.
*/
assets?: TLAssetStore
/**
* Options for the editor.
*/
options?: Partial<TldrawOptions>
/**
* Text options for the editor.
*
* @deprecated Use `options.text` instead. This prop will be removed in a future release.
*/
textOptions?: TLTextOptions
}
const defaultOptions: Partial<TldrawOptions> = {
text: {
tipTapConfig: {
extensions: tipTapDefaultExtensions,
},
addFontsFromNode: defaultAddFontsFromNode,
},
}
/**
* A rendered SVG image of a Tldraw snapshot.
*
* @example
* ```tsx
* <TldrawImage
* snapshot={snapshot}
* pageId={pageId}
* background={false}
* darkMode={true}
* bounds={new Box(0,0,600,400)}
* scale={1}
* />
* ```
*
* @public
* @react
*/
export const TldrawImage = memo(function TldrawImage(props: TldrawImageProps) {
const [url, setUrl] = useState<string | null>(null)
const [container, setContainer] = useState<HTMLDivElement | null>(null)
const _shapeUtils = useShallowArrayIdentity(props.shapeUtils ?? [])
const shapeUtilsWithDefaults = useMemo(
() => mergeArraysAndReplaceDefaults('type', _shapeUtils, defaultShapeUtils),
[_shapeUtils]
)
const _bindingUtils = useShallowArrayIdentity(props.bindingUtils ?? [])
const bindingUtilsWithDefaults = useMemo(
() => mergeArraysAndReplaceDefaults('type', _bindingUtils, defaultBindingUtils),
[_bindingUtils]
)
const store = useTLStore({
snapshot: props.snapshot,
shapeUtils: shapeUtilsWithDefaults,
assets: props.assets,
})
const {
pageId,
bounds,
scale,
pixelRatio,
background,
padding,
darkMode,
preserveAspectRatio,
format = 'svg',
licenseKey,
assetUrls,
options: _options,
// eslint-disable-next-line @typescript-eslint/no-deprecated
textOptions: _textOptions,
} = props
const options = useMemo(
() => ({
...defaultOptions,
..._options,
text: {
...defaultOptions.text,
...(_options?.text ?? _textOptions),
},
}),
[_options, _textOptions]
)
const assetUrlsWithOverrides = useDefaultEditorAssetsWithOverrides(assetUrls)
useLayoutEffect(() => {
if (!container) return
if (!store) return
let isCancelled = false
const tempElm = container.ownerDocument.createElement('div')
container.appendChild(tempElm)
container.classList.add('tl-container', 'tl-theme__light')
const editor = new Editor({
store,
shapeUtils: shapeUtilsWithDefaults,
bindingUtils: bindingUtilsWithDefaults,
tools: [],
getContainer: () => tempElm,
licenseKey,
fontAssetUrls: assetUrlsWithOverrides.fonts,
options,
})
if (pageId) editor.setCurrentPage(pageId)
const shapeIds = editor.getCurrentPageShapeIds()
async function setSvg() {
// toImage waits for the required fonts to load (so text is measured correctly) before
// it renders, so we don't need to do it here.
const imageResult = await editor.toImage([...shapeIds], {
bounds,
scale,
background,
padding,
darkMode,
preserveAspectRatio,
format,
})
if (!imageResult || isCancelled) return
const url = URL.createObjectURL(imageResult.blob)
setUrl(url)
editor.dispose()
}
setSvg()
return () => {
isCancelled = true
}
}, [
format,
container,
store,
shapeUtilsWithDefaults,
bindingUtilsWithDefaults,
pageId,
bounds,
scale,
background,
padding,
darkMode,
preserveAspectRatio,
licenseKey,
pixelRatio,
assetUrlsWithOverrides,
options,
])
useEffect(() => {
return () => {
if (url) URL.revokeObjectURL(url)
}
}, [url])
return (
<div ref={setContainer} style={{ position: 'relative', width: '100%', height: '100%' }}>
{url && (
<img
src={url}
referrerPolicy="strict-origin-when-cross-origin"
style={{ width: '100%', height: '100%' }}
/>
)}
</div>
)
})

View File

@@ -0,0 +1,23 @@
import {
AssetUtil,
TLBookmarkAsset,
bookmarkAssetMigrations,
bookmarkAssetProps,
} from '@tldraw/editor'
/** @public */
export class BookmarkAssetUtil extends AssetUtil<TLBookmarkAsset> {
static override type = 'bookmark' as const
static override props = bookmarkAssetProps
static override migrations = bookmarkAssetMigrations
override getDefaultProps(): TLBookmarkAsset['props'] {
return {
title: '',
description: '',
image: '',
favicon: '',
src: null,
}
}
}

View File

@@ -0,0 +1,116 @@
import { AssetRecordType, MediaHelpers } from '@tldraw/editor'
import { describe, expect, it, vi } from 'vitest'
import { TestEditor } from '../../test/TestEditor'
import { defaultAssetUtils } from '../defaultAssetUtils'
import { ImageAssetUtil } from './ImageAssetUtil'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor({ assetUtils: defaultAssetUtils })
})
afterEach(() => {
editor?.dispose()
})
describe('ImageAssetUtil', () => {
describe('getAssetFromFile', () => {
it('should include pixelRatio when image has non-1 pixelRatio', async () => {
vi.spyOn(MediaHelpers, 'getImageSize').mockResolvedValue({
w: 100,
h: 200,
pixelRatio: 2,
})
vi.spyOn(MediaHelpers, 'isAnimated').mockResolvedValue(false)
const file = new File(['test'], 'test.png', { type: 'image/png' })
const assetId = AssetRecordType.createId('test')
const util = editor.getAssetUtil('image') as ImageAssetUtil
const asset = await util.getAssetFromFile(file, assetId)
expect(asset).not.toBeNull()
expect(asset!.props.pixelRatio).toBe(2)
expect(asset!.props.w).toBe(100)
expect(asset!.props.h).toBe(200)
})
it('should not include pixelRatio when image has pixelRatio of 1', async () => {
vi.spyOn(MediaHelpers, 'getImageSize').mockResolvedValue({
w: 100,
h: 200,
pixelRatio: 1,
})
vi.spyOn(MediaHelpers, 'isAnimated').mockResolvedValue(false)
const file = new File(['test'], 'test.png', { type: 'image/png' })
const assetId = AssetRecordType.createId('test')
const util = editor.getAssetUtil('image') as ImageAssetUtil
const asset = await util.getAssetFromFile(file, assetId)
expect(asset).not.toBeNull()
expect(asset!.props.pixelRatio).toBeUndefined()
})
it('should pass doc to MediaHelpers.getImageSize for iframe compatibility', async () => {
const getImageSizeSpy = vi
.spyOn(MediaHelpers, 'getImageSize')
.mockResolvedValue({ w: 100, h: 200, pixelRatio: 1 })
vi.spyOn(MediaHelpers, 'isAnimated').mockResolvedValue(false)
const file = new File(['test'], 'test.png', { type: 'image/png' })
const assetId = AssetRecordType.createId('test')
const util = editor.getAssetUtil('image') as ImageAssetUtil
await util.getAssetFromFile(file, assetId)
expect(getImageSizeSpy).toHaveBeenCalledWith(file, expect.any(Document))
})
it('should set correct asset properties from file', async () => {
vi.spyOn(MediaHelpers, 'getImageSize').mockResolvedValue({
w: 800,
h: 600,
pixelRatio: 1,
})
vi.spyOn(MediaHelpers, 'isAnimated').mockResolvedValue(true)
const file = new File(['test-content'], 'animated.gif', { type: 'image/gif' })
const assetId = AssetRecordType.createId('test2')
const util = editor.getAssetUtil('image') as ImageAssetUtil
const asset = await util.getAssetFromFile(file, assetId)
expect(asset).toMatchObject({
id: assetId,
type: 'image',
typeName: 'asset',
props: {
name: 'animated.gif',
src: '',
w: 800,
h: 600,
mimeType: 'image/gif',
isAnimated: true,
},
})
})
it('should constrain dimensions to maxDimension for static images', async () => {
vi.spyOn(MediaHelpers, 'getImageSize').mockResolvedValue({
w: 8000,
h: 4000,
pixelRatio: 1,
})
vi.spyOn(MediaHelpers, 'isAnimated').mockResolvedValue(false)
const file = new File(['test'], 'large.png', { type: 'image/png' })
const assetId = AssetRecordType.createId('test3')
const util = editor.getAssetUtil('image') as ImageAssetUtil
const asset = await util.getAssetFromFile(file, assetId)
expect(asset).not.toBeNull()
// Input 8000x4000 should be constrained to 5000x2500
expect(asset!.props.w).toBe(5000)
expect(asset!.props.h).toBe(2500)
})
})
})

View File

@@ -0,0 +1,77 @@
import {
AssetUtil,
DEFAULT_SUPPORTED_IMAGE_TYPES,
MediaHelpers,
TLAssetId,
TLImageAsset,
imageAssetMigrations,
imageAssetProps,
} from '@tldraw/editor'
import { DEFAULT_MAX_IMAGE_DIMENSION } from '../defaultExternalContentHandlers'
import { containBoxSize } from '../utils/assets/assets'
/** @public */
export class ImageAssetUtil extends AssetUtil<TLImageAsset> {
static override type = 'image' as const
static override props = imageAssetProps
static override migrations = imageAssetMigrations
override options: {
maxDimension: number
supportedMimeTypes: readonly string[] | null
} = {
maxDimension: DEFAULT_MAX_IMAGE_DIMENSION,
supportedMimeTypes: null,
}
override getDefaultProps(): TLImageAsset['props'] {
return {
w: 0,
h: 0,
name: '',
isAnimated: false,
mimeType: null,
src: null,
}
}
override getSupportedMimeTypes(): readonly string[] {
return this.options.supportedMimeTypes ?? DEFAULT_SUPPORTED_IMAGE_TYPES
}
override async getAssetFromFile(file: File, assetId: TLAssetId): Promise<TLImageAsset | null> {
const doc = this.editor.getContainerDocument()
const size = await MediaHelpers.getImageSize(file, doc)
const isAnimated = await MediaHelpers.isAnimated(file)
const pixelRatio = 'pixelRatio' in size && size.pixelRatio !== 1 ? size.pixelRatio : undefined
const assetInfo: TLImageAsset = {
id: assetId,
type: 'image',
typeName: 'asset',
props: {
name: file.name,
src: '',
w: size.w,
h: size.h,
fileSize: file.size,
mimeType: file.type,
isAnimated,
...(pixelRatio ? { pixelRatio } : undefined),
},
meta: {},
}
const maxDimension = this.options.maxDimension
if (maxDimension && isFinite(maxDimension)) {
const originalSize = { w: assetInfo.props.w, h: assetInfo.props.h }
const resizedSize = containBoxSize(originalSize, { w: maxDimension, h: maxDimension })
if (originalSize !== resizedSize && MediaHelpers.isStaticImageType(file.type)) {
assetInfo.props.w = resizedSize.w
assetInfo.props.h = resizedSize.h
}
}
return assetInfo
}
}

View File

@@ -0,0 +1,64 @@
import {
AssetUtil,
DEFAULT_SUPPORT_VIDEO_TYPES,
MediaHelpers,
TLAssetId,
TLVideoAsset,
videoAssetMigrations,
videoAssetProps,
} from '@tldraw/editor'
/** @public */
export class VideoAssetUtil extends AssetUtil<TLVideoAsset> {
static override type = 'video' as const
static override props = videoAssetProps
static override migrations = videoAssetMigrations
override options: {
supportedMimeTypes: readonly string[] | null
} = {
supportedMimeTypes: null,
}
override getDefaultProps(): TLVideoAsset['props'] {
return {
w: 0,
h: 0,
name: '',
isAnimated: true,
mimeType: null,
src: null,
}
}
override getSupportedMimeTypes(): readonly string[] {
return this.options.supportedMimeTypes ?? DEFAULT_SUPPORT_VIDEO_TYPES
}
override async getAssetFromFile(file: File, assetId: TLAssetId): Promise<TLVideoAsset | null> {
let fileType = file.type
if (fileType === 'video/quicktime') {
// hack to make .mov videos work
fileType = 'video/mp4'
}
const doc = this.editor.getContainerDocument()
const size = await MediaHelpers.getVideoSize(file, doc)
return {
id: assetId,
type: 'video',
typeName: 'asset',
props: {
name: file.name,
src: '',
w: size.w,
h: size.h,
fileSize: file.size,
mimeType: fileType,
isAnimated: true,
},
meta: {},
}
}
}

View File

@@ -0,0 +1,406 @@
import {
BindingOnChangeOptions,
BindingOnCreateOptions,
BindingOnShapeChangeOptions,
BindingOnShapeIsolateOptions,
BindingUtil,
Editor,
Geometry2dFilters,
IndexKey,
TLArrowBinding,
TLArrowBindingProps,
TLArrowShape,
TLParentId,
TLShape,
TLShapeId,
TLShapePartial,
Vec,
approximately,
arrowBindingMigrations,
arrowBindingProps,
getIndexAbove,
getIndexBetween,
intersectLineSegmentCircle,
} from '@tldraw/editor'
import { getArrowInfo } from '../../shapes/arrow/getArrowInfo'
import { getArrowBindings, removeArrowBinding } from '../../shapes/arrow/shared'
/**
* @public
*/
export class ArrowBindingUtil extends BindingUtil<TLArrowBinding> {
static override type = 'arrow'
static override props = arrowBindingProps
static override migrations = arrowBindingMigrations
override getDefaultProps(): Partial<TLArrowBindingProps> {
return {
isPrecise: false,
isExact: false,
normalizedAnchor: { x: 0.5, y: 0.5 },
snap: 'none',
}
}
// when the binding itself changes
override onAfterCreate({ binding }: BindingOnCreateOptions<TLArrowBinding>): void {
const arrow = this.editor.getShape(binding.fromId) as TLArrowShape | undefined
if (!arrow) return
arrowDidUpdate(this.editor, arrow)
}
// when the binding itself changes
override onAfterChange({ bindingAfter }: BindingOnChangeOptions<TLArrowBinding>): void {
const arrow = this.editor.getShape(bindingAfter.fromId) as TLArrowShape | undefined
if (!arrow) return
arrowDidUpdate(this.editor, arrow)
}
// when the arrow itself changes
override onAfterChangeFromShape({
shapeBefore,
shapeAfter,
reason,
}: BindingOnShapeChangeOptions<TLArrowBinding>): void {
// When translating arrows together with their bound shapes, only x/y changes.
// In this case, bindings remain valid and no reparenting is needed.
// This is a significant performance optimization when moving many bound shapes.
if (
reason !== 'ancestry' &&
shapeBefore.parentId === shapeAfter.parentId &&
shapeBefore.index === shapeAfter.index
) {
return
}
arrowDidUpdate(this.editor, shapeAfter as TLArrowShape)
}
// when the shape an arrow is bound to changes
override onAfterChangeToShape({
binding,
shapeBefore,
shapeAfter,
reason,
}: BindingOnShapeChangeOptions<TLArrowBinding>): void {
// When a bound geo shape's geo type changes (e.g. rectangle to triangle) its outline can move
// out from under a precise anchor, leaving the arrow floating off the shape. Re-snap the anchor
// to the new geometry.
if (
binding.props.isPrecise &&
shapeBefore.type === 'geo' &&
shapeAfter.type === 'geo' &&
shapeBefore.props.geo !== shapeAfter.props.geo &&
this.editor.isIn('select.idle') &&
!this.editor.isReplayingHistory()
) {
updateBindingAnchorIfOutsideShape(this.editor, binding, shapeAfter)
}
if (
reason !== 'ancestry' &&
shapeBefore.parentId === shapeAfter.parentId &&
shapeBefore.index === shapeAfter.index
) {
return
}
reparentArrow(this.editor, binding.fromId)
}
// when the arrow is isolated we need to update its (x,y) positions
override onBeforeIsolateFromShape({
binding,
}: BindingOnShapeIsolateOptions<TLArrowBinding>): void {
// during undo/redo the history diff already contains the arrow's correct
// state, so adjusting the terminal here would corrupt the replay
if (this.editor.isReplayingHistory()) return
const arrow = this.editor.getShape<TLArrowShape>(binding.fromId)
if (!arrow) return
updateArrowTerminal({
editor: this.editor,
arrow,
terminal: binding.props.terminal,
})
}
}
/**
* A precise arrow binding stores its anchor as a normalized point (0–1) within the bound shape's
* geometry bounds. If the shape's geometry changes (for example, when its geo type changes from a
* rectangle to a triangle) the anchor can end up outside the new outline, making the arrow appear to
* float off the shape. When that happens, move the anchor to the nearest point on the new geometry.
*/
function updateBindingAnchorIfOutsideShape(
editor: Editor,
binding: TLArrowBinding,
boundShape: TLShape
) {
const geometry = editor.getShapeGeometry(boundShape)
// "Inside" only makes sense for closed geometry (e.g. an open line has no interior to fall out
// of), so leave those bindings alone.
if (!geometry.isClosed) return
const { point, size } = geometry.bounds
if (size.x === 0 || size.y === 0) return
// The anchor's current position in the bound shape's local geometry space.
const anchorPoint = Vec.Add(point, Vec.MulV(binding.props.normalizedAnchor, size))
// If the anchor still lands on or inside the shape, there's nothing to do.
if (geometry.hitTestPoint(anchorPoint, 0, true)) return
// Otherwise re-snap it to the closest point on the new outline and store it back as a normalized
// anchor.
const nearest = geometry.nearestPoint(anchorPoint, Geometry2dFilters.EXCLUDE_LABELS)
editor.updateBinding({
id: binding.id,
type: binding.type,
props: {
normalizedAnchor: {
x: (nearest.x - point.x) / size.x,
y: (nearest.y - point.y) / size.y,
},
},
})
}
function reparentArrow(editor: Editor, arrowId: TLShapeId) {
const arrow = editor.getShape<TLArrowShape>(arrowId)
if (!arrow) return
const bindings = getArrowBindings(editor, arrow)
const { start, end } = bindings
const startShape = start ? editor.getShape(start.toId) : undefined
const endShape = end ? editor.getShape(end.toId) : undefined
const parentPageId = editor.getAncestorPageId(arrow)
if (!parentPageId) return
let nextParentId: TLParentId
if (startShape && endShape) {
// If arrow has two bindings, parent it to the closest common ancestor of the
// bound shapes. When one bound shape is a frame-like ancestor-or-self of the
// other, use that frame-like shape itself instead of its parent.
nextParentId =
getCommonFrameLikeBindingAncestor(editor, startShape, endShape) ??
editor.findCommonAncestor([startShape, endShape]) ??
parentPageId
} else if (startShape || endShape) {
const bindingParentId = (startShape || endShape)?.parentId
// If the arrow and the shape that it is bound to have the same parent, then keep that parent
if (bindingParentId && bindingParentId === arrow.parentId) {
nextParentId = arrow.parentId
} else {
// if arrow has one binding, keep arrow on its own page
nextParentId = parentPageId
}
} else {
return
}
if (nextParentId && nextParentId !== arrow.parentId) {
editor.reparentShapes([arrowId], nextParentId)
}
const reparentedArrow = editor.getShape<TLArrowShape>(arrowId)
if (!reparentedArrow) throw Error('no reparented arrow')
const startSibling = editor.getShapeNearestSibling(reparentedArrow, startShape)
const endSibling = editor.getShapeNearestSibling(reparentedArrow, endShape)
let highestSibling: TLShape | undefined
if (startSibling && endSibling) {
highestSibling = startSibling.index > endSibling.index ? startSibling : endSibling
} else if (startSibling && !endSibling) {
highestSibling = startSibling
} else if (endSibling && !startSibling) {
highestSibling = endSibling
} else {
return
}
let finalIndex: IndexKey
const higherSiblings = editor
.getSortedChildIdsForParent(highestSibling.parentId)
.map((id) => editor.getShape(id)!)
.filter((sibling) => sibling.index > highestSibling!.index)
if (higherSiblings.length) {
// there are siblings above the highest bound sibling, we need to
// insert between them.
// if the next sibling is also a bound arrow though, we can end up
// all fighting for the same indexes. so lets find the next
// non-arrow sibling...
const nextHighestNonArrowSibling = higherSiblings.find((sibling) => sibling.type !== 'arrow')
if (
// ...then, if we're above the last shape we want to be above...
reparentedArrow.index > highestSibling.index &&
// ...but below the next non-arrow sibling...
(!nextHighestNonArrowSibling || reparentedArrow.index < nextHighestNonArrowSibling.index)
) {
// ...then we're already in the right place. no need to update!
return
}
// otherwise, we need to find the index between the highest sibling
// we want to be above, and the next highest sibling we want to be
// below:
finalIndex = getIndexBetween(highestSibling.index, higherSiblings[0].index)
} else {
// if there are no siblings above us, we can just get the next index:
finalIndex = getIndexAbove(highestSibling.index)
}
if (finalIndex !== reparentedArrow.index) {
editor.updateShapes([{ id: arrowId, type: 'arrow', index: finalIndex }])
}
}
function getCommonFrameLikeBindingAncestor(
editor: Editor,
startShape: TLShape,
endShape: TLShape
): TLShapeId | undefined {
let ancestor: TLShape | undefined
if (startShape.id === endShape.id) {
ancestor = startShape
} else if (editor.hasAncestor(startShape, endShape.id)) {
ancestor = endShape
} else if (editor.hasAncestor(endShape, startShape.id)) {
ancestor = startShape
}
return ancestor && editor.isShapeFrameLike(ancestor) ? ancestor.id : undefined
}
function arrowDidUpdate(editor: Editor, arrow: TLArrowShape) {
const bindings = getArrowBindings(editor, arrow)
// if the shape is an arrow and its bound shape is on another page
// or was deleted, unbind it
for (const handle of ['start', 'end'] as const) {
const binding = bindings[handle]
if (!binding) continue
const boundShape = editor.getShape(binding.toId)
const isShapeInSamePageAsArrow =
editor.getAncestorPageId(arrow) === editor.getAncestorPageId(boundShape)
if (!boundShape || !isShapeInSamePageAsArrow) {
updateArrowTerminal({ editor, arrow, terminal: handle, unbind: true })
}
}
// always check the arrow parents
reparentArrow(editor, arrow.id)
}
/** @internal */
export function updateArrowTerminal({
editor,
arrow,
terminal,
unbind = false,
useHandle = false,
}: {
editor: Editor
arrow: TLArrowShape
terminal: 'start' | 'end'
unbind?: boolean
useHandle?: boolean
}) {
const info = getArrowInfo(editor, arrow)
if (!info) {
throw new Error('expected arrow info')
}
const startPoint = getValidTerminalPoint(
useHandle ? info.start.handle : info.start.point,
arrow.props.start
)
const endPoint = getValidTerminalPoint(
useHandle ? info.end.handle : info.end.point,
arrow.props.end
)
const point = terminal === 'start' ? startPoint : endPoint
const update = {
id: arrow.id,
type: 'arrow',
props: {
[terminal]: { x: point.x, y: point.y },
bend: arrow.props.bend,
},
} satisfies TLShapePartial<TLArrowShape>
// fix up the bend:
if (info.type === 'arc') {
// find the new start/end points of the resulting arrow
const newStart =
terminal === 'start'
? startPoint
: getValidTerminalPoint(info.start.handle, arrow.props.start)
const newEnd =
terminal === 'end' ? endPoint : getValidTerminalPoint(info.end.handle, arrow.props.end)
const newMidPoint = Vec.Med(newStart, newEnd)
const arrowDirection = Vec.Sub(newStart, newEnd)
if (approximately(Vec.Len2(arrowDirection), 0)) {
editor.updateShape(update)
if (unbind) {
removeArrowBinding(editor, arrow, terminal)
}
return
}
// intersect a line segment perpendicular to the new arrow with the old arrow arc to
// find the new mid-point
const lineSegment = arrowDirection
.per()
.uni()
.mul(info.handleArc.radius * 2 * Math.sign(arrow.props.bend))
const targetPoint = Vec.Add(newMidPoint, lineSegment)
if (
!Vec.IsFinite(info.handleArc.center) ||
!Number.isFinite(info.handleArc.radius) ||
!Vec.IsFinite(targetPoint)
) {
editor.updateShape(update)
if (unbind) {
removeArrowBinding(editor, arrow, terminal)
}
return
}
// find the intersections with the old arrow arc:
const intersections = intersectLineSegmentCircle(
info.handleArc.center,
targetPoint,
info.handleArc.center,
info.handleArc.radius
)
if (intersections?.length) {
const intersection = intersections.reduce((closest, candidate) =>
Vec.Dist2(candidate, targetPoint) < Vec.Dist2(closest, targetPoint) ? candidate : closest
)
const bend = Vec.Dist(newMidPoint, intersection) * Math.sign(arrow.props.bend)
// use `approximately` to avoid endless update loops
if (!approximately(bend, update.props.bend)) {
update.props.bend = bend
}
}
}
editor.updateShape(update)
if (unbind) {
removeArrowBinding(editor, arrow, terminal)
}
}
function getValidTerminalPoint(
point: { x: number; y: number },
fallback: { x: number; y: number }
) {
return Vec.From(Vec.IsFinite(point) ? point : fallback)
}

View File

@@ -0,0 +1,6 @@
import { BookmarkAssetUtil } from './assets/BookmarkAssetUtil'
import { ImageAssetUtil } from './assets/ImageAssetUtil'
import { VideoAssetUtil } from './assets/VideoAssetUtil'
/** @public */
export const defaultAssetUtils = [ImageAssetUtil, VideoAssetUtil, BookmarkAssetUtil] as const

View File

@@ -0,0 +1,4 @@
import { ArrowBindingUtil } from './bindings/arrow/ArrowBindingUtil'
/** @public */
export const defaultBindingUtils = [ArrowBindingUtil] as const

View File

@@ -0,0 +1,788 @@
import { safeParseUrl } from '@tldraw/editor'
// Only allow multiplayer embeds. If we add additional routes later for example '/help' this won't match
const TLDRAW_APP_RE = /(^\/[f|p|r|ro|s|v]\/[^/]+\/?$)/
/** @public */
export const DEFAULT_EMBED_DEFINITIONS = [
{
type: 'tldraw',
title: 'tldraw',
hostnames: ['beta.tldraw.com', 'tldraw.com', 'localhost:3000'],
minWidth: 300,
minHeight: 300,
width: 720,
height: 500,
doesResize: true,
overridePermissions: {
'allow-top-navigation': true,
},
toEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.pathname.match(TLDRAW_APP_RE)) {
// Add the "clean=true" search param to the URL to hide the sidebar
urlObj.searchParams.append('embed', 'true')
return url
}
return
},
fromEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.pathname.match(TLDRAW_APP_RE)) {
// Add the "clean=true" search param to the URL to hide the sidebar
urlObj.searchParams.delete('embed')
return url
}
return
},
embedOnPaste: false,
},
{
type: 'figma',
title: 'Figma',
hostnames: ['figma.com'],
width: 720,
height: 500,
doesResize: true,
toEmbedUrl: (url) => {
if (
!!url.match(
// eslint-disable-next-line no-useless-escape
/https:\/\/([\w\.-]+\.)?figma.com\/(file|proto|design)\/([0-9a-zA-Z]{22,128})(?:\/.*)?$/
) &&
!url.includes('figma.com/embed')
) {
return `https://www.figma.com/embed?embed_host=share&url=${url}`
}
return
},
fromEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.pathname.match(/^\/embed\/?$/)) {
const outUrl = urlObj.searchParams.get('url')
if (outUrl) {
return outUrl
}
}
return
},
embedOnPaste: true,
},
{
type: 'canva',
title: 'Canva',
hostnames: ['canva.com'],
width: 720,
height: 500,
doesResize: true,
toEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (
urlObj &&
urlObj.pathname.match(/^\/design\/([^/]+)\/.+/) &&
!urlObj.searchParams.has('embed')
) {
urlObj.searchParams.set('embed', '')
return urlObj.href
}
return
},
fromEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (
urlObj &&
urlObj.pathname.match(/^\/design\/([^/]+)\/.+/) &&
urlObj.searchParams.has('embed')
) {
urlObj.searchParams.delete('embed')
return urlObj.href
}
return
},
embedOnPaste: true,
},
{
type: 'google_maps',
title: 'Google Maps',
hostnames: ['google.*'],
width: 720,
height: 500,
doesResize: true,
overridePermissions: {
'allow-presentation': true,
},
toEmbedUrl: (url, config?: GoogleMapsEmbedConfig) => {
if (url.includes('/maps/embed?')) {
return url
} else if (url.includes('/maps/')) {
const match = url.match(/@(.*?),(.*?),(.*?)(z|m)/)
let result: string
if (match) {
const [, lat, lng, zoomOrMeters, mapTypeSymbol] = match
const mapType = mapTypeSymbol === 'z' ? 'roadmap' : 'satellite'
// Note: This meters to zoom equation is a rough approximation and not canonical.
const z =
mapType === 'roadmap'
? zoomOrMeters
: -Math.log2(parseInt(zoomOrMeters) / 14772321) / 0.8
const host = new URL(url).host.replace('www.', '')
// TODO: remove the process.env fallback once all consumers pass `apiKey` via `embedConfig`.
const apiKey = config?.apiKey ?? process.env.NEXT_PUBLIC_GC_API_KEY
result = `https://${host}/maps/embed/v1/view?key=${apiKey}&center=${lat},${lng}&zoom=${z}&maptype=${mapType}`
} else {
result = ''
}
return result
}
return
},
fromEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (!urlObj) return
const matches = urlObj.pathname.match(/^\/maps\/embed\/v1\/view\/?$/)
if (matches && urlObj.searchParams.has('center') && urlObj.searchParams.get('zoom')) {
const zoom = urlObj.searchParams.get('zoom') ?? '12'
const mapType = urlObj.searchParams.get('maptype') ?? 'roadmap'
// Note: This zoom to meters equation is a rough approximation and not canonical.
const zoomOrMeters =
mapType === 'roadmap' ? zoom : 14772321 * Math.pow(2, parseInt(zoom) * -0.8)
const [lat, lon] = urlObj.searchParams.get('center')!.split(',')
return `https://www.google.com/maps/@${lat},${lon},${zoomOrMeters}${mapType === 'roadmap' ? 'z' : 'm'}`
}
return
},
embedOnPaste: true,
},
{
type: 'val_town',
title: 'Val Town',
hostnames: ['val.town'],
minWidth: 260,
minHeight: 100,
width: 720,
height: 500,
doesResize: true,
toEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
// e.g. extract "steveruizok/mathFact" from https://www.val.town/v/steveruizok/mathFact
const matches = urlObj && urlObj.pathname.match(/\/v\/(.+)\/?/)
if (matches) {
return `https://www.val.town/embed/${matches[1]}`
}
return
},
fromEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
// e.g. extract "steveruizok/mathFact" from https://www.val.town/v/steveruizok/mathFact
const matches = urlObj && urlObj.pathname.match(/\/embed\/(.+)\/?/)
if (matches) {
return `https://www.val.town/v/${matches[1]}`
}
return
},
embedOnPaste: true,
},
{
type: 'codesandbox',
title: 'CodeSandbox',
hostnames: ['codesandbox.io'],
minWidth: 300,
minHeight: 300,
width: 720,
height: 500,
doesResize: true,
toEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
const matches = urlObj && urlObj.pathname.match(/\/s\/([^/]+)\/?/)
if (matches) {
return `https://codesandbox.io/embed/${matches[1]}`
}
return
},
fromEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
const matches = urlObj && urlObj.pathname.match(/\/embed\/([^/]+)\/?/)
if (matches) {
return `https://codesandbox.io/s/${matches[1]}`
}
return
},
embedOnPaste: true,
},
{
type: 'codepen',
title: 'Codepen',
hostnames: ['codepen.io'],
minWidth: 300,
minHeight: 300,
width: 520,
height: 400,
doesResize: true,
toEmbedUrl: (url) => {
const CODEPEN_URL_REGEXP = /https:\/\/codepen.io\/([^/]+)\/pen\/([^/]+)/
const matches = url.match(CODEPEN_URL_REGEXP)
if (matches) {
const [_, user, id] = matches
return `https://codepen.io/${user}/embed/${id}`
}
return
},
fromEmbedUrl: (url) => {
const CODEPEN_EMBED_REGEXP = /https:\/\/codepen.io\/([^/]+)\/embed\/([^/]+)/
const matches = url.match(CODEPEN_EMBED_REGEXP)
if (matches) {
const [_, user, id] = matches
return `https://codepen.io/${user}/pen/${id}`
}
return
},
embedOnPaste: true,
},
{
type: 'scratch',
title: 'Scratch',
hostnames: ['scratch.mit.edu'],
width: 520,
height: 400,
doesResize: false,
embedOnPaste: true,
toEmbedUrl: (url) => {
const SCRATCH_URL_REGEXP = /https?:\/\/scratch.mit.edu\/projects\/([^/]+)/
const matches = url.match(SCRATCH_URL_REGEXP)
if (matches) {
const [_, id] = matches
return `https://scratch.mit.edu/projects/embed/${id}`
}
return
},
fromEmbedUrl: (url) => {
const SCRATCH_EMBED_REGEXP = /https:\/\/scratch.mit.edu\/projects\/embed\/([^/]+)/
const matches = url.match(SCRATCH_EMBED_REGEXP)
if (matches) {
const [_, id] = matches
return `https://scratch.mit.edu/projects/${id}`
}
return
},
},
{
type: 'youtube',
title: 'YouTube',
hostnames: ['*.youtube.com', 'youtube.com', 'youtu.be'],
width: 800,
height: 450,
doesResize: true,
overridePermissions: {
'allow-presentation': true,
'allow-popups-to-escape-sandbox': true,
},
isAspectRatioLocked: true,
embedOnPaste: true,
// Not opted into `sizeToContentAspectRatio`: YouTube advertises the same 16:9 thumbnail
// dimensions for every video (it pads vertical videos into a 16:9 thumbnail), so unfurled
// dimensions can never reveal a non-16:9 ratio — the correction would always be a no-op.
toEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (!urlObj) return
const hostname = urlObj.hostname.replace(/^www./, '')
if (hostname === 'youtu.be') {
const videoId = urlObj.pathname.split('/').filter(Boolean)[0]
const searchParams = new URLSearchParams(urlObj.search)
const timeStart = searchParams.get('t')
if (timeStart) {
searchParams.set('start', timeStart)
searchParams.delete('t')
}
const search = searchParams.toString() ? '?' + searchParams.toString() : ''
return `https://www.youtube.com/embed/${videoId}${search}`
} else if (
(hostname === 'youtube.com' || hostname === 'm.youtube.com') &&
urlObj.pathname.match(/^\/watch/)
) {
const videoId = urlObj.searchParams.get('v')
const searchParams = new URLSearchParams(urlObj.search)
searchParams.delete('v')
const timeStart = searchParams.get('t')
if (timeStart) {
searchParams.set('start', timeStart)
searchParams.delete('t')
}
const search = searchParams.toString() ? '?' + searchParams.toString() : ''
return `https://www.youtube.com/embed/${videoId}${search}`
}
return
},
fromEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (!urlObj) return
const hostname = urlObj.hostname.replace(/^www./, '')
if (hostname === 'youtube.com') {
const matches = urlObj.pathname.match(/^\/embed\/([^/]+)\/?/)
if (matches) {
const params = new URLSearchParams(urlObj.search)
params.set('v', matches?.[1] ?? '')
const timeStart = params.get('start')
if (timeStart) {
params.set('t', timeStart)
params.delete('start')
}
return `https://www.youtube.com/watch?${params.toString()}`
}
}
return
},
},
{
type: 'google_calendar',
title: 'Google Calendar',
hostnames: ['calendar.google.*'],
width: 720,
height: 500,
minWidth: 460,
minHeight: 360,
doesResize: true,
instructionLink: 'https://support.google.com/calendar/answer/41207?hl=en',
overridePermissions: {
'allow-popups-to-escape-sandbox': true,
},
embedOnPaste: true,
toEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
const cidQs = urlObj?.searchParams.get('cid')
if (urlObj?.pathname.match(/\/calendar\/u\/0/) && cidQs) {
urlObj.pathname = '/calendar/embed'
const keys = Array.from(urlObj.searchParams.keys())
for (const key of keys) {
urlObj.searchParams.delete(key)
}
urlObj.searchParams.set('src', cidQs)
return urlObj.href
}
return
},
fromEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
const srcQs = urlObj?.searchParams.get('src')
if (urlObj?.pathname.match(/\/calendar\/embed/) && srcQs) {
urlObj.pathname = '/calendar/u/0'
const keys = Array.from(urlObj.searchParams.keys())
for (const key of keys) {
urlObj.searchParams.delete(key)
}
urlObj.searchParams.set('cid', srcQs)
return urlObj.href
}
return
},
},
{
type: 'google_slides',
title: 'Google Slides',
hostnames: ['docs.google.*'],
width: 720,
height: 500,
minWidth: 460,
minHeight: 360,
doesResize: true,
overridePermissions: {
'allow-popups-to-escape-sandbox': true,
},
embedOnPaste: true,
toEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (urlObj?.pathname.match(/^\/presentation/) && urlObj?.pathname.match(/\/pub\/?$/)) {
urlObj.pathname = urlObj.pathname.replace(/\/pub$/, '/embed')
const keys = Array.from(urlObj.searchParams.keys())
for (const key of keys) {
urlObj.searchParams.delete(key)
}
return urlObj.href
}
return
},
fromEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (urlObj?.pathname.match(/^\/presentation/) && urlObj?.pathname.match(/\/embed\/?$/)) {
urlObj.pathname = urlObj.pathname.replace(/\/embed$/, '/pub')
const keys = Array.from(urlObj.searchParams.keys())
for (const key of keys) {
urlObj.searchParams.delete(key)
}
return urlObj.href
}
return
},
},
{
type: 'github_gist',
title: 'GitHub Gist',
hostnames: ['gist.github.com'],
width: 720,
height: 500,
doesResize: true,
embedOnPaste: true,
// Security warning:
// Gists allow adding .json extensions to the URL which return JSONP.
// Furthermore, the JSONP can include callbacks that execute arbitrary JavaScript.
// It _is_ sandboxed by the iframe but we still want to disable it nonetheless.
// We restrict the id to only allow hexdecimal characters to prevent this.
// Read more:
// https://github.com/bhaveshk90/Content-Security-Policy-CSP-Bypass-Techniques
// https://github.com/renniepak/CSPBypass
toEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.pathname.match(/\/([^/]+)\/([0-9a-f]+)$/)) {
if (!url.split('/').pop()) return
return url
}
return
},
fromEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.pathname.match(/\/([^/]+)\/([0-9a-f]+)$/)) {
if (!url.split('/').pop()) return
return url
}
return
},
},
{
type: 'replit',
title: 'Replit',
hostnames: ['replit.com'],
width: 720,
height: 500,
doesResize: true,
embedOnPaste: true,
toEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.pathname.match(/\/@([^/]+)\/([^/]+)/)) {
urlObj.searchParams.append('embed', 'true')
return urlObj.href
}
return
},
fromEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (
urlObj &&
urlObj.pathname.match(/\/@([^/]+)\/([^/]+)/) &&
urlObj.searchParams.has('embed')
) {
urlObj.searchParams.delete('embed')
return urlObj.href
}
return
},
},
{
type: 'felt',
title: 'Felt',
hostnames: ['felt.com'],
width: 720,
height: 500,
doesResize: true,
embedOnPaste: true,
toEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.pathname.match(/^\/map\//)) {
return urlObj.origin + '/embed' + urlObj.pathname
}
return
},
fromEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.pathname.match(/^\/embed\/map\//)) {
urlObj.pathname = urlObj.pathname.replace(/^\/embed/, '')
return urlObj.href
}
return
},
},
{
type: 'spotify',
title: 'Spotify',
hostnames: ['open.spotify.com'],
width: 720,
height: 500,
minHeight: 500,
overrideOutlineRadius: 12,
doesResize: true,
embedOnPaste: true,
toEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.pathname.match(/^\/(artist|album)\//)) {
return urlObj.origin + '/embed' + urlObj.pathname
}
return
},
fromEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.pathname.match(/^\/embed\/(artist|album)\//)) {
return urlObj.origin + urlObj.pathname.replace(/^\/embed/, '')
}
return
},
},
{
type: 'vimeo',
title: 'Vimeo',
hostnames: ['vimeo.com', 'player.vimeo.com'],
width: 640,
height: 360,
doesResize: true,
isAspectRatioLocked: true,
embedOnPaste: true,
sizeToContentAspectRatio: true,
toEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.hostname === 'vimeo.com') {
if (urlObj.pathname.match(/^\/[0-9]+/)) {
return (
'https://player.vimeo.com/video/' + urlObj.pathname.split('/')[1] + '?title=0&byline=0'
)
}
}
return
},
fromEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.hostname === 'player.vimeo.com') {
const matches = urlObj.pathname.match(/^\/video\/([^/]+)\/?$/)
if (matches) {
return 'https://vimeo.com/' + matches[1]
}
}
return
},
},
{
type: 'observable',
title: 'Observable',
hostnames: ['observablehq.com'],
width: 720,
height: 500,
doesResize: true,
isAspectRatioLocked: false,
backgroundColor: '#fff',
embedOnPaste: true,
toEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.pathname.match(/^\/@([^/]+)\/([^/]+)\/?$/)) {
return `${urlObj.origin}/embed${urlObj.pathname}?cell=*`
}
if (urlObj && urlObj.pathname.match(/^\/d\/([^/]+)\/?$/)) {
const pathName = urlObj.pathname.replace(/^\/d/, '')
return `${urlObj.origin}/embed${pathName}?cell=*`
}
return
},
fromEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (urlObj && urlObj.pathname.match(/^\/embed\/@([^/]+)\/([^/]+)\/?$/)) {
return `${urlObj.origin}${urlObj.pathname.replace('/embed', '')}#cell-*`
}
if (urlObj && urlObj.pathname.match(/^\/embed\/([^/]+)\/?$/)) {
return `${urlObj.origin}${urlObj.pathname.replace('/embed', '/d')}#cell-*`
}
return
},
},
{
type: 'desmos',
title: 'Desmos',
hostnames: ['desmos.com'],
width: 700,
height: 450,
doesResize: true,
embedOnPaste: true,
toEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (
urlObj &&
urlObj.hostname === 'www.desmos.com' &&
urlObj.pathname.match(/^\/calculator\/([^/]+)\/?$/) &&
urlObj.search === '' &&
urlObj.hash === ''
) {
return `${url}?embed`
}
return
},
fromEmbedUrl: (url) => {
const urlObj = safeParseUrl(url)
if (
urlObj &&
urlObj.hostname === 'www.desmos.com' &&
urlObj.pathname.match(/^\/calculator\/([^/]+)\/?$/) &&
urlObj.search === '?embed' &&
urlObj.hash === ''
) {
return url.replace('?embed', '')
}
return
},
},
] as const satisfies readonly EmbedDefinition[]
/**
* Permissions with note inline from
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox
*
* @public
*/
export const embedShapePermissionDefaults = {
// ========================================================================================
// Disabled permissions
// ========================================================================================
// [MDN] Experimental: Allows for downloads to occur without a gesture from the user.
// [REASON] Disabled because otherwise the <iframe/> can trick the user on behalf of us to perform an action.
'allow-downloads-without-user-activation': false,
// [MDN] Allows for downloads to occur with a gesture from the user.
// [REASON] Disabled because otherwise the <iframe/> can trick the user on behalf of us to perform an action.
'allow-downloads': false,
// [MDN] Lets the resource open modal windows.
// [REASON] The <iframe/> could 'window.prompt("Enter your tldraw password")'.
'allow-modals': false,
// [MDN] Lets the resource lock the screen orientation.
// [REASON] Would interfere with the tldraw interface.
'allow-orientation-lock': false,
// [MDN] Lets the resource use the Pointer Lock API.
// [REASON] Maybe we should allow this for games embeds (scratch/codepen/codesandbox).
'allow-pointer-lock': false,
// [MDN] Allows popups (such as window.open(), target="_blank", or showModalDialog()). If this keyword is not used, the popup will silently fail to open.
// [REASON] We want to allow embeds to link back to their original sites (e.g. YouTube).
'allow-popups': true,
// [MDN] Lets the sandboxed document open new windows without those windows inheriting the sandboxing. For example, this can safely sandbox an advertisement without forcing the same restrictions upon the page the ad links to.
// [REASON] We shouldn't allow popups as an embed could pretend to be us by opening a mocked version of tldraw. This is very unobvious when it is performed as an action within our app.
'allow-popups-to-escape-sandbox': false,
// [MDN] Lets the resource start a presentation session.
// [REASON] Prevents embed from navigating away from tldraw and pretending to be us.
'allow-presentation': false,
// [MDN] Experimental: Lets the resource request access to the parent's storage capabilities with the Storage Access API.
// [REASON] We don't want anyone else to access our storage.
'allow-storage-access-by-user-activation': false,
// [MDN] Lets the resource navigate the top-level browsing context (the one named _top).
// [REASON] Prevents embed from navigating away from tldraw and pretending to be us.
'allow-top-navigation': false,
// [MDN] Lets the resource navigate the top-level browsing context, but only if initiated by a user gesture.
// [REASON] Prevents embed from navigating away from tldraw and pretending to be us.
'allow-top-navigation-by-user-activation': false,
// ========================================================================================
// Enabled permissions
// ========================================================================================
// [MDN] Lets the resource run scripts (but not create popup windows).
'allow-scripts': true,
// [MDN] If this token is not used, the resource is treated as being from a special origin that always fails the same-origin policy (potentially preventing access to data storage/cookies and some JavaScript APIs).
'allow-same-origin': true,
// [MDN] Allows the resource to submit forms. If this keyword is not used, form submission is blocked.
'allow-forms': true,
} as const
/** @public */
export type TLEmbedShapePermissions = { [K in keyof typeof embedShapePermissionDefaults]?: boolean }
/**
* Overrides for unknown/arbitrary embeds that aren't in the curated embed definitions list.
* These restrict the most dangerous sandbox permissions to mitigate:
* - Same-origin sandbox escape (allow-scripts + allow-same-origin lets the iframe remove its own sandbox)
* - Phishing via embedded forms
* - Popup-based social engineering
*
* @public
*/
export const unknownEmbedShapePermissionOverrides: TLEmbedShapePermissions = {
'allow-same-origin': false,
'allow-forms': false,
'allow-popups': false,
}
/**
* Configuration for the default Google Maps embed. Provide an `apiKey` through
* {@link EmbedShapeUtil}'s `embedConfig` option to render Google Maps embeds.
*
* @public
*/
export interface GoogleMapsEmbedConfig {
readonly apiKey?: string
}
/**
* Per-embed configuration for the default embed definitions, keyed by embed type.
* Passed to an embed definition's `toEmbedUrl` when building its embed URL.
*
* @public
*/
export interface DefaultEmbedConfig {
readonly google_maps?: GoogleMapsEmbedConfig
}
/** @public */
export interface EmbedDefinition<Config = never> {
readonly type: string
readonly title: string
readonly hostnames: readonly string[]
readonly minWidth?: number
readonly minHeight?: number
readonly width: number
readonly height: number
readonly doesResize: boolean
readonly isAspectRatioLocked?: boolean
readonly overridePermissions?: TLEmbedShapePermissions
readonly instructionLink?: string
readonly backgroundColor?: string
readonly embedOnPaste?: boolean
readonly canEditWhileLocked?: boolean
// TODO: FIXME this is ugly be required because some embeds have their own border radius for example spotify embeds
readonly overrideOutlineRadius?: number
/**
* When true, the embed shape corrects its size after creation to match the real aspect ratio of
* its content, so fixed-aspect media (like video) isn't letterboxed inside the default box. The
* dimensions are resolved from the URL's OpenGraph metadata via the editor's url asset handler
* (the same "unfurl" path used for bookmarks), so it degrades gracefully when no metadata is
* available.
*/
readonly sizeToContentAspectRatio?: boolean
// eslint-disable-next-line tldraw/method-signature-style
readonly toEmbedUrl: (url: string, config?: Config) => string | undefined
// eslint-disable-next-line tldraw/method-signature-style
readonly fromEmbedUrl: (url: string) => string | undefined
}
/** @public */
export interface CustomEmbedDefinition<Config = never> extends EmbedDefinition<Config> {
readonly icon: string
}
/** @public */
export type TLEmbedDefinition = EmbedDefinition<any> | CustomEmbedDefinition<any>
/** @public */
export type DefaultEmbedDefinitionType = (typeof DEFAULT_EMBED_DEFINITIONS)[number]['type']
const DEFAULT_EMBED_DEFINITION_TYPES = DEFAULT_EMBED_DEFINITIONS.map(
(def) => def.type
) as DefaultEmbedDefinitionType[]
/** @public */
export function isDefaultEmbedDefinitionType(type: string): type is DefaultEmbedDefinitionType {
return DEFAULT_EMBED_DEFINITION_TYPES.includes(type as DefaultEmbedDefinitionType)
}
/** @public */
export function isCustomEmbedDefinition(
def: EmbedDefinition | CustomEmbedDefinition
): def is CustomEmbedDefinition {
return 'icon' in def
}

View File

@@ -0,0 +1,328 @@
import {
AssetUtil,
createShapeId,
DEFAULT_SUPPORT_VIDEO_TYPES,
DEFAULT_SUPPORTED_IMAGE_TYPES,
T,
TLAsset,
TLAssetId,
TLGeoShape,
} from '@tldraw/editor'
import { TestEditor } from '../test/TestEditor'
import { defaultAssetUtils } from './defaultAssetUtils'
import {
defaultHandleExternalTextContent,
defaultHandleExternalUrlContent,
notifyIfFileNotAllowed,
registerDefaultExternalContentHandlers,
} from './defaultExternalContentHandlers'
// A custom asset type used only in this test. We avoid declaring it via
// `TLGlobalAssetPropsMap` module augmentation because that would leak into
// other files in the same TS project and corrupt the global asset union.
class TestPdfAssetUtil extends AssetUtil<TLAsset> {
static override type = 'test-pdf' as any
static override props = {
name: T.string,
mimeType: T.string,
}
override getDefaultProps() {
return { name: '', mimeType: '' } as any
}
override getSupportedMimeTypes() {
return ['application/pdf', 'text/csv']
}
override async getAssetFromFile(file: File, assetId: TLAssetId) {
return {
id: assetId,
type: 'test-pdf',
typeName: 'asset',
props: { name: file.name, mimeType: file.type },
meta: {},
} as any
}
}
function makeOpts(overrides: Partial<Parameters<typeof notifyIfFileNotAllowed>[2]> = {}) {
const addToast = vi.fn().mockReturnValue('toast-id')
return {
addToast,
opts: {
toasts: {
addToast,
removeToast: vi.fn().mockReturnValue('toast-id'),
clearToasts: vi.fn(),
toasts: { get: () => [] } as any,
},
msg: ((key: string) => key) as any,
...overrides,
},
}
}
describe('notifyIfFileNotAllowed', () => {
let editor: TestEditor
afterEach(() => {
editor?.dispose()
})
it('accepts files whose MIME type is supported by a registered asset util', () => {
editor = new TestEditor({ assetUtils: defaultAssetUtils })
const file = new File(['x'], 'a.png', { type: 'image/png' })
const { opts, addToast } = makeOpts()
expect(notifyIfFileNotAllowed(editor, file, opts)).toBe(true)
expect(addToast).not.toHaveBeenCalled()
})
it('accepts files supported by a custom AssetUtil even when default MIME lists are forwarded', () => {
// Regression: <Tldraw> forwards DEFAULT_SUPPORTED_IMAGE_TYPES /
// DEFAULT_SUPPORT_VIDEO_TYPES into this function. The previous logic
// short-circuited to checking only those lists when either was provided,
// which silently rejected custom MIME types (e.g. application/pdf)
// registered via assetUtils. The check must OR the asset-util branch with
// the explicit-list branch so custom asset types still work in the
// default <Tldraw> flow. This test mimics that flow exactly.
editor = new TestEditor({
assetUtils: [...defaultAssetUtils, TestPdfAssetUtil] as any,
})
const file = new File(['x'], 'doc.pdf', { type: 'application/pdf' })
const { opts, addToast } = makeOpts()
expect(
notifyIfFileNotAllowed(editor, file, {
...opts,
acceptedImageMimeTypes: DEFAULT_SUPPORTED_IMAGE_TYPES,
acceptedVideoMimeTypes: DEFAULT_SUPPORT_VIDEO_TYPES,
})
).toBe(true)
expect(addToast).not.toHaveBeenCalled()
})
it('accepts files supported by a custom AssetUtil when no explicit MIME lists are provided', () => {
editor = new TestEditor({
assetUtils: [...defaultAssetUtils, TestPdfAssetUtil] as any,
})
const file = new File(['x'], 'doc.pdf', { type: 'application/pdf' })
const { opts, addToast } = makeOpts()
expect(notifyIfFileNotAllowed(editor, file, opts)).toBe(true)
expect(addToast).not.toHaveBeenCalled()
})
it('rejects files with no matching asset util and no explicit list match', () => {
editor = new TestEditor({ assetUtils: defaultAssetUtils })
const file = new File(['x'], 'doc.pdf', { type: 'application/pdf' })
const { opts, addToast } = makeOpts()
expect(notifyIfFileNotAllowed(editor, file, opts)).toBe(false)
expect(addToast).toHaveBeenCalledWith(
expect.objectContaining({
title: 'assets.files.type-not-allowed',
severity: 'error',
})
)
})
it('falls back to explicit acceptedImageMimeTypes for direct callers', () => {
// Direct callers of notifyIfFileNotAllowed (e.g. templates) may pass
// explicit lists without configuring asset utils. In that case the
// explicit allow-list is honored.
editor = new TestEditor({ assetUtils: [] })
const file = new File(['x'], 'a.png', { type: 'image/png' })
const { opts: optsAllowed, addToast: addToastAllowed } = makeOpts()
const { opts: optsRejected, addToast: addToastRejected } = makeOpts()
expect(
notifyIfFileNotAllowed(editor, file, {
...optsAllowed,
acceptedImageMimeTypes: ['image/png'],
})
).toBe(true)
expect(addToastAllowed).not.toHaveBeenCalled()
expect(
notifyIfFileNotAllowed(editor, file, {
...optsRejected,
acceptedImageMimeTypes: ['image/jpeg'],
})
).toBe(false)
expect(addToastRejected).toHaveBeenCalledWith(
expect.objectContaining({ title: 'assets.files.type-not-allowed' })
)
})
it('shows an upload-failed toast when a file has no MIME type', () => {
editor = new TestEditor({ assetUtils: defaultAssetUtils })
// Force a registered util to claim the empty type so the type-not-allowed
// branch is skipped and we exercise the no-MIME-type guard below it.
vi.spyOn(editor, 'getAssetUtilForMimeType').mockReturnValue(editor.getAssetUtil('image') as any)
const file = new File(['x'], 'noext', { type: '' })
const { opts, addToast } = makeOpts()
expect(notifyIfFileNotAllowed(editor, file, opts)).toBe(false)
expect(addToast).toHaveBeenCalledWith(
expect.objectContaining({ title: 'assets.files.upload-failed' })
)
})
it('rejects files larger than maxAssetSize', () => {
editor = new TestEditor({ assetUtils: defaultAssetUtils })
const file = new File([new Uint8Array(11 * 1024 * 1024)], 'big.png', {
type: 'image/png',
})
const { opts, addToast } = makeOpts()
expect(notifyIfFileNotAllowed(editor, file, opts)).toBe(false)
expect(addToast).toHaveBeenCalledWith(
expect.objectContaining({ title: 'assets.files.size-too-big' })
)
})
})
describe('defaultHandleExternalUrlContent', () => {
let editor: TestEditor
afterEach(() => {
editor?.dispose()
})
it('creates a bookmark for a valid http(s) url', async () => {
editor = new TestEditor()
const { opts, addToast } = makeOpts()
await defaultHandleExternalUrlContent(
editor,
{ url: 'https://example.com', point: { x: 0, y: 0 } },
opts
)
const shapes = editor.getCurrentPageShapes()
expect(shapes.length).toBe(1)
expect(shapes[0].type).toBe('bookmark')
expect(addToast).not.toHaveBeenCalled()
})
it('selects the created bookmark', async () => {
editor = new TestEditor()
const { opts } = makeOpts()
await defaultHandleExternalUrlContent(
editor,
{ url: 'https://example.com', point: { x: 0, y: 0 } },
opts
)
const [bookmark] = editor.getCurrentPageShapes()
expect(bookmark.type).toBe('bookmark')
expect(editor.getSelectedShapeIds()).toEqual([bookmark.id])
})
it('sets the url on the shape named by shapeId instead of creating a bookmark', async () => {
editor = new TestEditor()
const { opts } = makeOpts()
const id = createShapeId()
editor.createShape<TLGeoShape>({ id, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } })
await defaultHandleExternalUrlContent(
editor,
{ url: 'https://example.com/', point: { x: 0, y: 0 }, shapeId: id },
opts
)
expect(editor.getCurrentPageShapes()).toEqual([
expect.objectContaining({
id,
props: expect.objectContaining({ url: 'https://example.com/' }),
}),
])
expect(editor.getSelectedShapeIds()).toEqual([id])
})
it('sets the url on a shape rather than embedding an embeddable url', async () => {
editor = new TestEditor()
const { opts } = makeOpts()
const id = createShapeId()
const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
editor.createShape<TLGeoShape>({ id, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } })
await defaultHandleExternalUrlContent(editor, { url, point: { x: 0, y: 0 }, shapeId: id }, opts)
expect(editor.getCurrentPageShapes()).toEqual([
expect.objectContaining({ id, props: expect.objectContaining({ url }) }),
])
})
it('creates a bookmark when shapeId names a shape that cannot hold a link', async () => {
editor = new TestEditor()
const { opts } = makeOpts()
const id = createShapeId()
editor.createShape({ id, type: 'frame', x: 0, y: 0 })
await defaultHandleExternalUrlContent(
editor,
{ url: 'https://example.com/', point: { x: 0, y: 0 }, shapeId: id },
opts
)
expect(editor.getCurrentPageShapes().map((s) => s.type)).toEqual(['frame', 'bookmark'])
})
it('shows a toast and logs the url for a url with an invalid protocol', async () => {
// Regression test for #8097: dragging content from a browser tab in Chrome
// with an ad blocker active supplies a DataTransfer url rewritten to
// `about:blank#blocked`. That protocol is not valid for a bookmark shape's
// `url` prop (T.linkUrl), so creating one threw a ValidationError and
// crashed the editor. We now surface a toast to the user and warn with the
// offending url for local debugging instead, and never throw or create a
// shape.
editor = new TestEditor()
const { opts, addToast } = makeOpts()
registerDefaultExternalContentHandlers(editor, opts)
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
await expect(
defaultHandleExternalUrlContent(
editor,
{ url: 'about:blank#blocked', point: { x: 0, y: 0 } },
opts
)
).resolves.not.toThrow()
expect(editor.getCurrentPageShapes()).toEqual([])
expect(addToast).toHaveBeenCalledWith(
expect.objectContaining({ title: 'assets.url.failed', severity: 'error' })
)
expect(consoleWarn).toHaveBeenCalledWith(expect.stringContaining('about:blank#blocked'))
consoleWarn.mockRestore()
})
})
describe('defaultHandleExternalTextContent', () => {
let editor: TestEditor
afterEach(() => {
editor?.dispose()
})
it('selects the created text shape', async () => {
editor = new TestEditor()
await defaultHandleExternalTextContent(editor, { text: 'hello', point: { x: 0, y: 0 } })
const [text] = editor.getCurrentPageShapes()
expect(text.type).toBe('text')
expect(editor.getSelectedShapeIds()).toEqual([text.id])
})
it('does not select anything when the page is full and no shape is created', async () => {
editor = new TestEditor({ options: { maxShapesPerPage: 0 } })
await defaultHandleExternalTextContent(editor, { text: 'hello', point: { x: 0, y: 0 } })
expect(editor.getCurrentPageShapes()).toEqual([])
expect(editor.getSelectedShapeIds()).toEqual([])
})
})

View File

@@ -0,0 +1,970 @@
import {
AssetRecordType,
Editor,
T,
TLAsset,
TLAssetId,
TLBookmarkAsset,
TLBookmarkShape,
TLContent,
TLFileExternalAsset,
TLFileReplaceExternalContent,
TLImageAsset,
TLImageShape,
TLShapeId,
TLShapePartial,
TLTextShape,
TLTextShapeProps,
TLUrlExternalAsset,
TLVideoAsset,
Vec,
VecLike,
assert,
createShapeId,
fetch,
getHashForBuffer,
getHashForString,
maybeSnapToGrid,
toRichText,
} from '@tldraw/editor'
import { EmbedDefinition } from './defaultEmbedDefinitions'
import { createBookmarkFromUrl } from './shapes/bookmark/bookmarks'
import { EmbedShapeUtil } from './shapes/embed/EmbedShapeUtil'
import { getCroppedImageDataForReplacedImage } from './shapes/shared/crop'
import { FONT_SIZES, TEXT_PROPS, getFontFamily } from './shapes/shared/default-shape-constants'
import { TLUiToastsContextType } from './ui/context/toasts'
import { useTranslation } from './ui/hooks/useTranslation/useTranslation'
import { putExcalidrawContent } from './utils/excalidraw/putExcalidrawContent'
import { isShapeWithLink } from './utils/shapes/shapes'
import { renderHtmlFromRichTextForMeasurement, renderRichTextFromHTML } from './utils/text/richText'
import { cleanupText, isRightToLeftLanguage } from './utils/text/text'
/**
* 5000px
* @public
*/
export const DEFAULT_MAX_IMAGE_DIMENSION = 5000
/**
* 10mb
* @public
*/
export const DEFAULT_MAX_ASSET_SIZE = 10 * 1024 * 1024
/** @public */
export interface TLExternalContentProps {
/**
* The maximum dimension (width or height) of an image. Images larger than this will be rescaled
* to fit. Defaults to infinity.
*/
maxImageDimension?: number
/**
* The maximum size (in bytes) of an asset. Assets larger than this will be rejected. Defaults
* to 10mb (10 * 1024 * 1024).
*/
maxAssetSize?: number
/**
* The mime types of images that are allowed to be handled. When passed to
* the `Tldraw` component, this also reconfigures the default `ImageAssetUtil`
* to only accept files matching these types. If you only want to accept a
* subset of image types and want to additionally block videos, pass
* `acceptedVideoMimeTypes={[]}`. A file is accepted if its MIME type is in
* this list, in `acceptedVideoMimeTypes`, or if any registered asset util
* accepts it.
*/
acceptedImageMimeTypes?: readonly string[]
/**
* The mime types of videos that are allowed to be handled. When passed to
* the `Tldraw` component, this also reconfigures the default `VideoAssetUtil`
* to only accept files matching these types. A file is accepted if its MIME
* type is in this list, in `acceptedImageMimeTypes`, or if any registered
* asset util accepts it.
*/
acceptedVideoMimeTypes?: readonly string[]
}
/** @public */
export interface TLDefaultExternalContentHandlerOpts extends TLExternalContentProps {
toasts: TLUiToastsContextType
msg: ReturnType<typeof useTranslation>
}
/** @public */
export function registerDefaultExternalContentHandlers(
editor: Editor,
options: TLDefaultExternalContentHandlerOpts
) {
// files -> asset
editor.registerExternalAssetHandler('file', async (externalAsset) => {
return defaultHandleExternalFileAsset(editor, externalAsset, options)
})
// urls -> bookmark asset
editor.registerExternalAssetHandler('url', async (externalAsset) => {
return defaultHandleExternalUrlAsset(editor, externalAsset, options)
})
// svg text
editor.registerExternalContentHandler('svg-text', async (externalContent) => {
return defaultHandleExternalSvgTextContent(editor, externalContent)
})
// embeds
editor.registerExternalContentHandler<'embed', EmbedDefinition>('embed', (externalContent) => {
return defaultHandleExternalEmbedContent(editor, externalContent)
})
// files
editor.registerExternalContentHandler('files', async (externalContent) => {
return defaultHandleExternalFileContent(editor, externalContent, options)
})
// file-replace -> asset
editor.registerExternalContentHandler('file-replace', async (externalContent) => {
return defaultHandleExternalFileReplaceContent(editor, externalContent, options)
})
// text
editor.registerExternalContentHandler('text', async (externalContent) => {
return defaultHandleExternalTextContent(editor, externalContent)
})
// url
editor.registerExternalContentHandler('url', async (externalContent) => {
return defaultHandleExternalUrlContent(editor, externalContent, options)
})
// tldraw
editor.registerExternalContentHandler('tldraw', async (externalContent) => {
return defaultHandleExternalTldrawContent(editor, externalContent)
})
// excalidraw
editor.registerExternalContentHandler('excalidraw', async (externalContent) => {
return defaultHandleExternalExcalidrawContent(editor, externalContent)
})
}
/** @public */
export async function defaultHandleExternalFileAsset(
editor: Editor,
{ file, assetId }: TLFileExternalAsset,
options: TLDefaultExternalContentHandlerOpts
) {
const isSuccess = notifyIfFileNotAllowed(editor, file, options)
if (!isSuccess) assert(false, 'File checks failed')
const sanitizedFile = await maybeSanitizeSvgFile(file)
if (!sanitizedFile) assert(false, 'SVG file contained no safe content')
const assetInfo = await getAssetInfo(editor, sanitizedFile, assetId)
if (!assetInfo) assert(false, `No asset util found for MIME type "${file.type}"`)
const result = await editor.uploadAsset(assetInfo, sanitizedFile)
assetInfo.props.src = result.src
if (result.meta) assetInfo.meta = { ...assetInfo.meta, ...result.meta }
return AssetRecordType.create(assetInfo)
}
/** @public */
export async function defaultHandleExternalFileReplaceContent(
editor: Editor,
{ file, shapeId }: TLFileReplaceExternalContent,
options: TLDefaultExternalContentHandlerOpts
) {
const isSuccess = notifyIfFileNotAllowed(editor, file, options)
if (!isSuccess) assert(false, 'File checks failed')
const sanitizedFile = await maybeSanitizeSvgFile(file)
if (!sanitizedFile) return
const shape = editor.getShape(shapeId)
if (!shape) assert(false, 'Shape not found')
const hash = getHashForBuffer(await sanitizedFile.arrayBuffer())
const assetId = AssetRecordType.createId(hash)
editor.createTemporaryAssetPreview(assetId, sanitizedFile)
const assetInfoPartial = (await getAssetInfo(editor, sanitizedFile, assetId)) as
| TLImageAsset
| TLVideoAsset
| null
if (!assetInfoPartial) return
editor.createAssets([assetInfoPartial])
// And update the shape
if (shape.type === 'image') {
const imageShape = shape as TLImageShape
const currentCrop = imageShape.props.crop
// Calculate new dimensions that preserve the current visual size of the cropped area
let newWidth = assetInfoPartial.props.w
let newHeight = assetInfoPartial.props.h
let newX = imageShape.x
let newY = imageShape.y
let finalCrop = currentCrop
if (currentCrop) {
// Use the dedicated function to calculate the new crop and dimensions
const result = getCroppedImageDataForReplacedImage(
imageShape,
assetInfoPartial.props.w,
assetInfoPartial.props.h
)
finalCrop = result.crop
newWidth = result.w
newHeight = result.h
newX = result.x
newY = result.y
}
editor.updateShapes([
{
id: imageShape.id,
type: imageShape.type,
props: {
assetId: assetId,
crop: finalCrop,
w: newWidth,
h: newHeight,
},
x: newX,
y: newY,
},
])
} else if (shape.type === 'video') {
editor.updateShapes([
{
id: shape.id,
type: shape.type,
props: {
assetId: assetId,
w: assetInfoPartial.props.w,
h: assetInfoPartial.props.h,
},
},
])
}
const asset = (await editor.getAssetForExternalContent({
type: 'file',
file: sanitizedFile,
assetId,
})) as TLAsset
editor.updateAssets([{ ...asset, id: assetId }])
return asset
}
/** @public */
export async function defaultHandleExternalUrlAsset(
editor: Editor,
{ url }: TLUrlExternalAsset,
{ toasts, msg }: TLDefaultExternalContentHandlerOpts
): Promise<TLBookmarkAsset> {
let meta: { image: string; favicon: string; title: string; description: string }
try {
const resp = await fetch(url, {
method: 'GET',
mode: 'no-cors',
})
const html = await resp.text()
const doc = new DOMParser().parseFromString(html, 'text/html')
meta = {
image: doc.head.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '',
favicon:
doc.head.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href') ??
doc.head.querySelector('link[rel="icon"]')?.getAttribute('href') ??
'',
title: doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ?? url,
description:
doc.head.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? '',
}
if (meta.image && !meta.image.startsWith('http')) {
meta.image = new URL(meta.image, url).href
}
if (meta.favicon && !meta.favicon.startsWith('http')) {
meta.favicon = new URL(meta.favicon, url).href
}
} catch (error) {
console.error(error)
toasts.addToast({
title: msg('assets.url.failed'),
severity: 'error',
})
meta = { image: '', favicon: '', title: '', description: '' }
}
// Create the bookmark asset from the meta
return {
id: AssetRecordType.createId(getHashForString(url)),
typeName: 'asset',
type: 'bookmark',
props: {
src: url,
description: meta.description,
image: meta.image,
favicon: meta.favicon,
title: meta.title,
},
meta: {},
} as TLBookmarkAsset
}
/** @public */
export async function defaultHandleExternalSvgTextContent(
editor: Editor,
{ point, text }: { point?: VecLike; text: string }
) {
const { sanitizeSvg } = await import('./utils/svg/sanitizeSvg')
text = sanitizeSvg(text)
if (!text) return
const position =
point ??
(editor.inputs.getShiftKey()
? editor.inputs.getCurrentPagePoint()
: editor.getViewportPageBounds().center)
const svg = new DOMParser().parseFromString(text, 'image/svg+xml').querySelector('svg')
if (!svg) {
throw new Error('No <svg/> element present')
}
let width = parseFloat(svg.getAttribute('width') || '0')
let height = parseFloat(svg.getAttribute('height') || '0')
if (!(width && height)) {
const doc = editor.getContainerDocument()
doc.body.appendChild(svg)
const box = svg.getBoundingClientRect()
doc.body.removeChild(svg)
width = box.width
height = box.height
}
const asset = await editor.getAssetForExternalContent({
type: 'file',
file: new File([text], 'asset.svg', { type: 'image/svg+xml' }),
})
if (!asset) throw Error('Could not create an asset')
createShapesForAssets(editor, [asset], position)
}
/** @public */
export function defaultHandleExternalEmbedContent<T>(
editor: Editor,
{ point, url, embed }: { point?: VecLike; url: string; embed: T }
) {
const position =
point ??
(editor.inputs.getShiftKey()
? editor.inputs.getCurrentPagePoint()
: editor.getViewportPageBounds().center)
const { width, height } = embed as { width: number; height: number }
const id = createShapeId()
const newPoint = maybeSnapToGrid(
new Vec(position.x - (width || 450) / 2, position.y - (height || 450) / 2),
editor
)
const shapePartial: TLShapePartial = {
id,
type: 'embed',
x: newPoint.x,
y: newPoint.y,
props: {
w: width,
h: height,
url,
},
}
if (editor.canCreateShape(shapePartial)) {
editor.createShape(shapePartial).select(id)
}
}
/** @public */
export async function defaultHandleExternalFileContent(
editor: Editor,
{ point, files }: { point?: VecLike; files: File[] },
options: TLDefaultExternalContentHandlerOpts
) {
const { toasts, msg } = options
if (files.length > editor.options.maxFilesAtOnce) {
toasts.addToast({ title: msg('assets.files.amount-too-many'), severity: 'error' })
return
}
const position =
point ??
(editor.inputs.getShiftKey()
? editor.inputs.getCurrentPagePoint()
: editor.getViewportPageBounds().center)
const pagePoint = new Vec(position.x, position.y)
const assetPartials: TLAsset[] = []
const assetsToUpdate: {
asset: TLAsset
file: File
}[] = []
for (const file of files) {
const isSuccess = notifyIfFileNotAllowed(editor, file, options)
if (!isSuccess) continue
const sanitizedFile = await maybeSanitizeSvgFile(file)
if (!sanitizedFile) {
toasts.addToast({
title: msg('assets.files.upload-failed'),
severity: 'error',
})
continue
}
// An undecodable image rejects here; uncaught, it aborts the whole drop,
// including files that already succeeded, with no toast (#10438).
let assetInfo: TLAsset | null
try {
assetInfo = await getAssetInfo(editor, sanitizedFile)
} catch (error) {
toasts.addToast({
title: msg('assets.files.upload-failed'),
severity: 'error',
})
console.error(error)
continue
}
if (!assetInfo) continue
if (assetInfo.type === 'image') {
editor.createTemporaryAssetPreview(assetInfo.id, sanitizedFile)
}
assetPartials.push(assetInfo)
assetsToUpdate.push({ asset: assetInfo, file: sanitizedFile })
}
Promise.allSettled(
assetsToUpdate.map(async (assetAndFile) => {
try {
const newAsset = await editor.getAssetForExternalContent({
type: 'file',
file: assetAndFile.file,
})
if (!newAsset) {
throw Error('Could not create an asset')
}
// Save the new asset under the old asset's id
editor.updateAssets([{ ...newAsset, id: assetAndFile.asset.id }])
} catch (error) {
toasts.addToast({
title: msg('assets.files.upload-failed'),
severity: 'error',
})
console.error(error)
editor.deleteAssets([assetAndFile.asset.id])
return
}
})
)
createShapesForAssets(editor, assetPartials, pagePoint)
}
/** @public */
export async function defaultHandleExternalTextContent(
editor: Editor,
{ point, text, html }: { point?: VecLike; text: string; html?: string }
) {
const p =
point ??
(editor.inputs.getShiftKey()
? editor.inputs.getCurrentPagePoint()
: editor.getViewportPageBounds().center)
const defaultProps = editor.getShapeUtil<TLTextShape>('text').getDefaultProps()
const cleanedUpPlaintext = cleanupText(text)
const richTextToPaste = html
? renderRichTextFromHTML(editor, html)
: toRichText(cleanedUpPlaintext)
// todo: discuss
// If we have one shape with rich text selected, update the shape's text.
// const onlySelectedShape = editor.getOnlySelectedShape()
// if (onlySelectedShape && 'richText' in onlySelectedShape.props) {
// editor.updateShapes([
// {
// id: onlySelectedShape.id,
// type: onlySelectedShape.type,
// props: {
// richText: richTextToPaste,
// },
// },
// ])
// return
// }
// Measure the text with default values
let w: number
let h: number
let autoSize: boolean
let align = 'middle' as TLTextShapeProps['textAlign']
const htmlToMeasure = renderHtmlFromRichTextForMeasurement(editor, richTextToPaste)
const isMultiLine = richTextToPaste.content.length > 1
// check whether the text contains the most common characters in RTL languages
const isRtl = isRightToLeftLanguage(cleanedUpPlaintext)
if (isMultiLine) {
align = isMultiLine ? (isRtl ? 'end' : 'start') : 'middle'
}
const theme = editor.getCurrentTheme()
const rawSize = editor.textMeasure.measureHtml(htmlToMeasure, {
...TEXT_PROPS,
lineHeight: theme.lineHeight,
fontFamily: getFontFamily(theme, defaultProps.font),
fontSize: theme.fontSize * FONT_SIZES[defaultProps.size],
maxWidth: null,
})
const minWidth = Math.min(
isMultiLine ? editor.getViewportPageBounds().width * 0.9 : 920,
Math.max(200, editor.getViewportPageBounds().width * 0.9)
)
if (rawSize.w > minWidth) {
const shrunkSize = editor.textMeasure.measureHtml(htmlToMeasure, {
...TEXT_PROPS,
lineHeight: theme.lineHeight,
fontFamily: getFontFamily(theme, defaultProps.font),
fontSize: theme.fontSize * FONT_SIZES[defaultProps.size],
maxWidth: minWidth,
})
w = shrunkSize.w
h = shrunkSize.h
autoSize = false
align = isRtl ? 'end' : 'start'
} else {
// autosize is fine
w = Math.max(rawSize.w, 10)
h = Math.max(rawSize.h, 10)
autoSize = true
}
if (p.y - h / 2 < editor.getViewportPageBounds().minY + 40) {
p.y = editor.getViewportPageBounds().minY + 40 + h / 2
}
const newPoint = maybeSnapToGrid(new Vec(p.x - w / 2, p.y - h / 2), editor)
const shapeId = createShapeId()
editor.run(() => {
// Allow this to trigger the max shapes reached alert
editor.createShapes([
{
id: shapeId,
type: 'text',
x: newPoint.x,
y: newPoint.y,
props: {
richText: richTextToPaste,
// if the text has more than one line, align it to the left
textAlign: align,
autoSize,
w,
},
},
])
// createShapes silently creates nothing when the page is full, so only
// select the shape if it actually exists
if (editor.getShape(shapeId)) {
editor.select(shapeId)
}
})
}
/** @public */
export async function defaultHandleExternalUrlContent(
editor: Editor,
{ point, url, shapeId }: { point?: VecLike; url: string; shapeId?: TLShapeId },
{ toasts, msg }: TLDefaultExternalContentHandlerOpts
) {
// Bookmark shapes validate their `url` prop with T.linkUrl, so a url we can't
// turn into a bookmark would throw a ValidationError and crash the editor
// (#8097) — e.g. Chrome with an ad blocker active rewrites dragged content
// urls to `about:blank#blocked`. This is a known, handled condition rather
// than a bug, so we tell the user with a toast and warn (not error) with the
// offending url as a local debugging breadcrumb — deliberately not reported
// to error tracking, where it would just be non-actionable noise.
if (!T.linkUrl.isValid(url)) {
console.warn(`Could not create a bookmark from an invalid url: ${JSON.stringify(url)}`)
toasts.addToast({
title: msg('assets.url.failed'),
severity: 'error',
})
return
}
// A url aimed at a shape decorates that shape: no bookmark, and no embed even when the url is
// one we could embed, since the user aimed it at something that already exists.
if (shapeId) {
const shape = editor.getShape(shapeId)
if (isShapeWithLink(shape)) {
editor.updateShapes([{ id: shape.id, type: shape.type, props: { url } }])
editor.select(shape.id)
return
}
}
// try to paste as an embed first
const embedUtil = editor.getShapeUtil('embed') as EmbedShapeUtil | undefined
const embedInfo = embedUtil?.getEmbedDefinition(url)
if (embedInfo && embedInfo.definition.embedOnPaste !== false) {
return editor.putExternalContent({
type: 'embed',
url: embedInfo.url,
point,
embed: embedInfo.definition,
})
}
const position =
point ??
(editor.inputs.getShiftKey()
? editor.inputs.getCurrentPagePoint()
: editor.getViewportPageBounds().center)
// Use the new function to create the bookmark
const result = await createBookmarkFromUrl(editor, { url, center: position })
if (!result.ok) {
toasts.addToast({
title: msg('assets.url.failed'),
severity: 'error',
})
return
}
editor.select(result.value.id)
}
/** @public */
export async function defaultHandleExternalTldrawContent(
editor: Editor,
{ point, content }: { point?: VecLike; content: TLContent }
) {
editor.run(() => {
const selectionBoundsBefore = editor.getSelectionPageBounds()
editor.markHistoryStoppingPoint('paste')
// Unlock any locked root shapes on paste
for (const shape of content.shapes) {
if (content.rootShapeIds.includes(shape.id)) {
shape.isLocked = false
}
}
// While the user is mid-interaction (dragging a handle, translating,
// resizing, or rotating), selecting the pasted content would steal the
// selection from the shape being manipulated and interrupt the
// interaction — e.g. an arrow's in-progress binding hint disappears
// until the drag ends. Leave the selection alone in those cases.
const isMidInteraction = editor.isInAny(
'select.dragging_handle',
'select.translating',
'select.resizing',
'select.rotating'
)
editor.putContentOntoCurrentPage(content, {
point: point,
select: !isMidInteraction,
})
const selectedBoundsAfter = editor.getSelectionPageBounds()
if (
// When mid-interaction we don't select the pasted content, so the
// selection is unchanged and the before/after bounds are identical —
// the overlap check would always pass. The selection flash below
// signals that the newly-selected pasted content landed on the old
// selection, which is meaningless when we didn't change the
// selection, so skip it.
!isMidInteraction &&
selectionBoundsBefore &&
selectedBoundsAfter &&
selectionBoundsBefore?.collides(selectedBoundsAfter)
) {
// Creates a 'puff' to show content has been pasted
editor.updateInstanceState({ isChangingStyle: true })
editor.timers.setTimeout(() => {
editor.updateInstanceState({ isChangingStyle: false })
}, 150)
}
})
}
/** @public */
export async function defaultHandleExternalExcalidrawContent(
editor: Editor,
{ point, content }: { point?: VecLike; content: any }
) {
editor.run(() => {
putExcalidrawContent(editor, content, point)
})
}
/**
* A helper function for an external content handler. It creates bookmarks,
* images or video shapes corresponding to the type of assets provided.
*
* @param editor - The editor instance
*
* @param assets - An array of asset Ids
*
* @param position - the position at which to create the shapes
*
* @public
*/
export async function createShapesForAssets(
editor: Editor,
assets: TLAsset[],
position: VecLike
): Promise<TLShapeId[]> {
if (!assets.length) return []
const currentPoint = Vec.From(position)
const partials: TLShapePartial[] = []
for (let i = 0; i < assets.length; i++) {
const asset = assets[i]
const shapeUtil = editor.getShapeUtilForAssetType(asset.type)
const partial = shapeUtil?.createShapeForAsset?.(asset, currentPoint) ?? null
if (partial) {
partials.push(partial)
if ('w' in asset.props && typeof asset.props.w === 'number') {
currentPoint.x += asset.props.w
}
}
}
editor.run(() => {
// Create any assets
const assetsToCreate = assets.filter((asset) => !editor.getAsset(asset.id))
editor.store.atomic(() => {
if (editor.canCreateShapes(partials)) {
if (assetsToCreate.length) {
editor.createAssets(assetsToCreate)
}
// Create the shapes
editor.createShapes(partials).select(...partials.map((p) => p.id))
// Re-position shapes so that the center of the group is at the provided point
centerSelectionAroundPoint(editor, position)
}
})
})
return partials.map((p) => p.id)
}
/**
* Repositions selected shapes do that the center of the group is
* at the provided position
*
* @param editor - The editor instance
*
* @param position - the point to center the shapes around
*
* @public
*/
export function centerSelectionAroundPoint(editor: Editor, position: VecLike) {
// Re-position shapes so that the center of the group is at the provided point
const viewportPageBounds = editor.getViewportPageBounds()
let selectionPageBounds = editor.getSelectionPageBounds()
if (selectionPageBounds) {
const offset = selectionPageBounds!.center.sub(position)
editor.updateShapes(
editor.getSelectedShapes().map((shape) => {
const localRotation = editor.getShapeParentTransform(shape).decompose().rotation
const localDelta = Vec.Rot(offset, -localRotation)
return {
id: shape.id,
type: shape.type,
x: shape.x! - localDelta.x,
y: shape.y! - localDelta.y,
}
})
)
}
selectionPageBounds = editor.getSelectionPageBounds()
// align selection with the grid if necessary
if (selectionPageBounds && editor.getInstanceState().isGridMode) {
const gridSize = editor.getDocumentSettings().gridSize
const topLeft = new Vec(selectionPageBounds.minX, selectionPageBounds.minY)
const gridSnappedPoint = topLeft.clone().snapToGrid(gridSize)
const delta = Vec.Sub(topLeft, gridSnappedPoint)
editor.updateShapes(
editor.getSelectedShapes().map((shape) => {
const newPoint = { x: shape.x! - delta.x, y: shape.y! - delta.y }
return {
id: shape.id,
type: shape.type,
x: newPoint.x,
y: newPoint.y,
}
})
)
}
// Zoom out to fit the shapes, if necessary
selectionPageBounds = editor.getSelectionPageBounds()
if (selectionPageBounds && !viewportPageBounds.contains(selectionPageBounds)) {
editor.zoomToSelection({ animation: { duration: editor.options.animationMediumMs } })
}
}
/** @public */
export function createEmptyBookmarkShape(
editor: Editor,
url: string,
position: VecLike
): TLBookmarkShape {
const partial: TLShapePartial = {
id: createShapeId(),
type: 'bookmark',
x: position.x - 150,
y: position.y - 160,
opacity: 1,
props: {
assetId: null,
url,
},
}
editor.run(() => {
// Allow this to trigger the max shapes reached alert
editor.createShape(partial)
if (!editor.getShape(partial.id)) return
editor.select(partial.id)
centerSelectionAroundPoint(editor, position)
})
return editor.getShape(partial.id) as TLBookmarkShape
}
async function maybeSanitizeSvgFile(file: File): Promise<File | null> {
if (file.type !== 'image/svg+xml') return file
try {
const text = await file.text()
const { sanitizeSvg } = await import('./utils/svg/sanitizeSvg')
const sanitized = sanitizeSvg(text)
if (!sanitized) return null
return new File([sanitized], file.name, { type: file.type, lastModified: file.lastModified })
} catch {
return null
}
}
/**
* Checks if a file is allowed to be uploaded. If it is not, it will show a toast explaining why to the user.
*
* @param editor - The editor instance
* @param file - The file to check
* @param options - The options for the external content handler
* @returns True if the file is allowed, false otherwise
* @public
*/
export function notifyIfFileNotAllowed(
editor: Editor,
file: File,
options: TLDefaultExternalContentHandlerOpts
) {
const {
maxAssetSize = DEFAULT_MAX_ASSET_SIZE,
acceptedImageMimeTypes,
acceptedVideoMimeTypes,
toasts,
msg,
} = options
// Allow if any registered asset util accepts the MIME type, or if it matches
// an explicit allow-list. The asset-util branch lets custom AssetUtils
// (e.g. for PDFs) extend the default media handling — without it, files
// registered via `assetUtils` would be rejected because <Tldraw> forwards
// `DEFAULT_SUPPORTED_IMAGE_TYPES` / `DEFAULT_SUPPORT_VIDEO_TYPES` here, and
// custom MIME types aren't in those lists. The explicit-list branch supports
// callers that pass these props directly to restrict allowed types.
const isFileTypeAllowed =
!!editor.getAssetUtilForMimeType(file.type) ||
(acceptedImageMimeTypes?.includes(file.type) ?? false) ||
(acceptedVideoMimeTypes?.includes(file.type) ?? false)
if (!isFileTypeAllowed) {
toasts.addToast({
title: msg('assets.files.type-not-allowed'),
severity: 'error',
})
return false
}
if (file.size > maxAssetSize) {
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 bytes'
const units = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB']
const base = 1024
const unitIndex = Math.floor(Math.log(bytes) / Math.log(base))
const value = bytes / Math.pow(base, unitIndex)
const formatted = value % 1 === 0 ? value.toString() : value.toFixed(1)
return `${formatted} ${units[unitIndex]}`
}
toasts.addToast({
title: msg('assets.files.size-too-big'),
description: msg('assets.files.maximum-size').replace('{size}', formatBytes(maxAssetSize)),
severity: 'error',
})
return false
}
// Use mime type instead of file ext, this is because
// window.navigator.clipboard does not preserve file names
// of copied files.
if (!file.type) {
toasts.addToast({
title: msg('assets.files.upload-failed'),
severity: 'error',
})
console.error('No mime type')
return false
}
return true
}
/** @public */
export async function getAssetInfo(editor: Editor, file: File, assetId?: TLAssetId) {
if (!assetId) {
const hash = getHashForBuffer(await file.arrayBuffer())
assetId = AssetRecordType.createId(hash)
}
const assetUtil = editor.getAssetUtilForMimeType(file.type)
if (!assetUtil) return null
return await assetUtil.getAssetFromFile(file, assetId)
}

View File

@@ -0,0 +1,30 @@
import { ArrowBindingHintOverlayUtil } from './overlays/ArrowBindingHintOverlayUtil'
import { ArrowHintOverlayUtil } from './overlays/ArrowHintOverlayUtil'
import { BrushOverlayUtil } from './overlays/BrushOverlayUtil'
import { CollaboratorBrushOverlayUtil } from './overlays/CollaboratorBrushOverlayUtil'
import { CollaboratorHintOverlayUtil } from './overlays/CollaboratorHintOverlayUtil'
import { CollaboratorScribbleOverlayUtil } from './overlays/CollaboratorScribbleOverlayUtil'
import { CollaboratorShapeIndicatorOverlayUtil } from './overlays/CollaboratorShapeIndicatorOverlayUtil'
import { ScribbleOverlayUtil } from './overlays/ScribbleOverlayUtil'
import { SelectionForegroundOverlayUtil } from './overlays/SelectionForegroundOverlayUtil'
import { ShapeHandleOverlayUtil } from './overlays/ShapeHandleOverlayUtil'
import { ShapeIndicatorOverlayUtil } from './overlays/ShapeIndicatorOverlayUtil'
import { SnapIndicatorOverlayUtil } from './overlays/SnapIndicatorOverlayUtil'
import { ZoomBrushOverlayUtil } from './overlays/ZoomBrushOverlayUtil'
/** @public */
export const defaultOverlayUtils = [
ArrowBindingHintOverlayUtil,
ArrowHintOverlayUtil,
BrushOverlayUtil,
CollaboratorBrushOverlayUtil,
CollaboratorHintOverlayUtil,
CollaboratorScribbleOverlayUtil,
CollaboratorShapeIndicatorOverlayUtil,
ScribbleOverlayUtil,
SelectionForegroundOverlayUtil,
ShapeHandleOverlayUtil,
ShapeIndicatorOverlayUtil,
SnapIndicatorOverlayUtil,
ZoomBrushOverlayUtil,
] as const

View File

@@ -0,0 +1,20 @@
import { ArrowShapeTool } from './shapes/arrow/ArrowShapeTool'
import { DrawShapeTool } from './shapes/draw/DrawShapeTool'
import { FrameShapeTool } from './shapes/frame/FrameShapeTool'
import { GeoShapeTool } from './shapes/geo/GeoShapeTool'
import { HighlightShapeTool } from './shapes/highlight/HighlightShapeTool'
import { LineShapeTool } from './shapes/line/LineShapeTool'
import { NoteShapeTool } from './shapes/note/NoteShapeTool'
import { TextShapeTool } from './shapes/text/TextShapeTool'
/** @public */
export const defaultShapeTools = [
TextShapeTool,
DrawShapeTool,
GeoShapeTool,
NoteShapeTool,
LineShapeTool,
FrameShapeTool,
ArrowShapeTool,
HighlightShapeTool,
] as const

View File

@@ -0,0 +1,28 @@
import { ArrowShapeUtil } from './shapes/arrow/ArrowShapeUtil'
import { BookmarkShapeUtil } from './shapes/bookmark/BookmarkShapeUtil'
import { DrawShapeUtil } from './shapes/draw/DrawShapeUtil'
import { EmbedShapeUtil } from './shapes/embed/EmbedShapeUtil'
import { FrameShapeUtil } from './shapes/frame/FrameShapeUtil'
import { GeoShapeUtil } from './shapes/geo/GeoShapeUtil'
import { HighlightShapeUtil } from './shapes/highlight/HighlightShapeUtil'
import { ImageShapeUtil } from './shapes/image/ImageShapeUtil'
import { LineShapeUtil } from './shapes/line/LineShapeUtil'
import { NoteShapeUtil } from './shapes/note/NoteShapeUtil'
import { TextShapeUtil } from './shapes/text/TextShapeUtil'
import { VideoShapeUtil } from './shapes/video/VideoShapeUtil'
/** @public */
export const defaultShapeUtils = [
TextShapeUtil,
BookmarkShapeUtil,
DrawShapeUtil,
GeoShapeUtil,
NoteShapeUtil,
LineShapeUtil,
FrameShapeUtil,
ArrowShapeUtil,
HighlightShapeUtil,
EmbedShapeUtil,
ImageShapeUtil,
VideoShapeUtil,
] as const

View File

@@ -0,0 +1,99 @@
import { Editor, TLEmbedShape } from '@tldraw/editor'
import { EmbedShapeUtil } from './shapes/embed/EmbedShapeUtil'
import {
cancelUpdateHoveredShapeId,
updateHoveredShapeId,
} from './tools/selection-logic/updateHoveredShapeId'
/** @public */
export function registerDefaultSideEffects(editor: Editor) {
const resolveEmbedAspectRatio = (shape: TLEmbedShape) => {
// Resolving the embed's real aspect ratio is a document mutation, so it belongs here as a
// store side effect rather than in the shape's render component — that way it runs once per
// create/url-change and never during rendering (e.g. SVG export).
;(editor.getShapeUtil('embed') as EmbedShapeUtil).resolveAspectRatio(shape)
}
const unsub = editor.sideEffects.register({
shape: {
afterCreate: (shape, source) => {
// Only for local user actions: remote peers and document loads already carry the
// resolved size, so we don't need to re-fetch.
if (source !== 'user') return
if (editor.isShapeOfType<TLEmbedShape>(shape, 'embed')) {
resolveEmbedAspectRatio(shape)
}
},
afterChange: (prev, next, source) => {
if (source !== 'user') return
if (
editor.isShapeOfType<TLEmbedShape>(next, 'embed') &&
(prev as TLEmbedShape).props.url !== next.props.url
) {
resolveEmbedAspectRatio(next)
}
},
},
instance: {
afterChange: (prev, next) => {
if (prev.cameraState !== next.cameraState && next.cameraState === 'idle') {
updateHoveredShapeId(editor)
}
},
},
instance_page_state: {
afterChange: (prev, next) => {
if (prev.croppingShapeId !== next.croppingShapeId) {
const isInCroppingState = editor.isIn('select.crop')
if (!prev.croppingShapeId && next.croppingShapeId) {
if (!isInCroppingState) {
editor.setCurrentTool('select.crop.idle')
}
} else if (prev.croppingShapeId && !next.croppingShapeId) {
if (isInCroppingState) {
editor.setCurrentTool('select.idle')
}
}
}
if (prev.editingShapeId !== next.editingShapeId) {
if (!prev.editingShapeId && next.editingShapeId) {
if (!editor.isIn('select.editing_shape')) {
// Here's where we handle the special tool locking case for text
// If tool lock is enabled, and we just finished editing a text
// shape and are setting that shape as the new editing shape,
// then create the shape with a flag that will let it know to
// go back to the text tool once the edit is complete.
const shape = editor.getEditingShape()
if (
shape &&
shape.type === 'text' &&
editor.isInAny('text.pointing', 'select.resizing') &&
editor.getInstanceState().isToolLocked
) {
editor.setCurrentTool('select.editing_shape', {
target: 'shape',
shape: shape,
isCreatingTextWhileToolLocked: true,
})
} else {
editor.setCurrentTool('select.editing_shape', {
target: 'shape',
shape: shape,
})
}
}
} else if (prev.editingShapeId && !next.editingShapeId) {
if (editor.isIn('select.editing_shape')) {
editor.setCurrentTool('select.idle')
}
}
}
},
},
})
return () => {
unsub()
cancelUpdateHoveredShapeId(editor)
}
}

View File

@@ -0,0 +1,8 @@
import { EraserTool } from './tools/EraserTool/EraserTool'
import { HandTool } from './tools/HandTool/HandTool'
import { LaserTool } from './tools/LaserTool/LaserTool'
import { SelectTool } from './tools/SelectTool/SelectTool'
import { ZoomTool } from './tools/ZoomTool/ZoomTool'
/** @public */
export const defaultTools = [EraserTool, HandTool, LaserTool, ZoomTool, SelectTool] as const

View File

@@ -0,0 +1,258 @@
import {
getPerfectDashProps,
OverlayUtil,
PI2,
TLArrowShape,
TLOverlay,
TLShapeId,
Vec,
} from '@tldraw/editor'
import { TLArrowInfo } from '../shapes/arrow/arrow-types'
import { getArrowInfo } from '../shapes/arrow/getArrowInfo'
import { getArrowBindings } from '../shapes/arrow/shared'
/** @public */
export interface TLArrowBindingHintOverlay extends TLOverlay {
props: {
arrowId: TLShapeId
}
}
/**
* Overlay util for the dashed binding hint shown on bound arrows. Draws stubs
* along the arrow's handle path, from each bound endpoint's snapped body
* position to the user's intended (handle) position, with a precision marker
* at the handle.
*
* @public
*/
export class ArrowBindingHintOverlayUtil extends OverlayUtil<TLArrowBindingHintOverlay> {
static override type = 'arrow_binding_hint'
override options = {
zIndex: 150,
strokeWidth: 2,
opacity: 0.16,
dashLengthRatio: 2.5,
dotRadius: 4,
crossSize: 6,
dashedMinZoom: 0.2,
}
override isActive(): boolean {
const editor = this.editor
if (editor.getIsReadonly()) return false
const id = editor.getOnlySelectedShapeId()
if (!id) return false
const shape = editor.getShape(id)
if (!shape || shape.type !== 'arrow') return false
if (
!editor.isInAny(
'select.idle',
'select.pointing_handle',
'select.dragging_handle',
'select.translating',
'arrow.dragging'
)
) {
return false
}
const bindings = getArrowBindings(editor, shape as TLArrowShape)
return Boolean(bindings.start || bindings.end)
}
override getOverlays(): TLArrowBindingHintOverlay[] {
const id = this.editor.getOnlySelectedShapeId()
if (!id) return []
return [
{
id: 'arrow_binding_hint',
type: 'arrow_binding_hint',
props: { arrowId: id },
},
]
}
override render(ctx: CanvasRenderingContext2D, overlays: TLArrowBindingHintOverlay[]): void {
const overlay = overlays[0]
if (!overlay) return
const editor = this.editor
const shape = editor.getShape(overlay.props.arrowId) as TLArrowShape | undefined
if (!shape) return
const info = getArrowInfo(editor, shape)
if (!info?.isValid) return
const bindings = getArrowBindings(editor, shape)
if (!bindings.start && !bindings.end) return
const pageTransform = editor.getShapePageTransform(shape)
if (!pageTransform) return
const zoom = editor.getZoomLevel()
const colors = editor.getCurrentTheme().colors[editor.getColorMode()]
const strokeWidth = this.options.strokeWidth / zoom
ctx.save()
ctx.transform(
pageTransform.a,
pageTransform.b,
pageTransform.c,
pageTransform.d,
pageTransform.e,
pageTransform.f
)
ctx.strokeStyle = colors.text
ctx.fillStyle = colors.text
ctx.lineWidth = strokeWidth
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.globalAlpha = this.options.opacity
if (bindings.start) {
this.drawEndpoint(
ctx,
info,
'start',
bindings.start.props.isExact,
bindings.start.props.isPrecise,
strokeWidth,
zoom
)
}
if (bindings.end) {
this.drawEndpoint(
ctx,
info,
'end',
bindings.end.props.isExact,
bindings.end.props.isPrecise,
strokeWidth,
zoom
)
}
ctx.restore()
}
private drawEndpoint(
ctx: CanvasRenderingContext2D,
info: TLArrowInfo,
side: 'start' | 'end',
isExact: boolean,
isPrecise: boolean,
strokeWidth: number,
zoom: number
) {
const handle = info[side].handle
const point = info[side].point
const dist = Vec.Dist(handle, point)
// Stop the dashed stub at the marker's outer edge so it doesn't pass
// through the dot/cross. The cross's corners (leg endpoints) sit at
// half the diagonal of its bounding square, not half its side.
const markerRadius =
(isPrecise ? (this.options.crossSize / 2) * Math.SQRT2 : this.options.dotRadius) / zoom
if (dist > markerRadius + 0.5) {
ctx.save()
ctx.beginPath()
let pathLength: number
if (info.type === 'arc') {
// Render along the body arc so the stub sits on the same circle as
// the visible arrow body; project handle radially onto that circle.
const { center, radius, sweepFlag } = info.bodyArc
const pointAngle = Math.atan2(point.y - center.y, point.x - center.x)
const handleAngle = Math.atan2(handle.y - center.y, handle.x - center.x)
const anticlockwise = !sweepFlag
const sign = (sweepFlag ? 1 : -1) * (side === 'start' ? 1 : -1)
const trimmedHandleAngle = handleAngle + sign * (markerRadius / radius)
// Handle and point are close points on the same circle, so the
// stub follows the short arc between them. Use the shortest signed
// angular distance to get the arc length, then trim the marker.
let handleToPointSweep = pointAngle - handleAngle
if (handleToPointSweep > Math.PI) handleToPointSweep -= PI2
else if (handleToPointSweep < -Math.PI) handleToPointSweep += PI2
pathLength = Math.max(0, radius * Math.abs(handleToPointSweep) - markerRadius)
if (side === 'start') {
ctx.arc(center.x, center.y, radius, trimmedHandleAngle, pointAngle, anticlockwise)
} else {
ctx.arc(center.x, center.y, radius, pointAngle, trimmedHandleAngle, anticlockwise)
}
} else {
pathLength = dist - markerRadius
const t = markerRadius / dist
const trimmedHandle = {
x: handle.x + (point.x - handle.x) * t,
y: handle.y + (point.y - handle.y) * t,
}
ctx.moveTo(trimmedHandle.x, trimmedHandle.y)
ctx.lineTo(point.x, point.y)
}
// Below the dash threshold the dashes get too small to read, so
// fall back to a solid stub.
if (zoom >= this.options.dashedMinZoom) {
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(pathLength, strokeWidth, {
style: 'dashed',
end: 'skip',
start: 'skip',
})
if (strokeDasharray !== 'none') {
const [dashLength, gapLength] = strokeDasharray.split(' ').map(Number)
ctx.setLineDash([dashLength, gapLength])
ctx.lineDashOffset = Number(strokeDashoffset)
}
}
ctx.stroke()
ctx.restore()
}
if (isExact) return
const markerAngle = dist > 0.5 ? this.getMarkerAngle(info, side, handle, point) : 0
if (isPrecise) {
const half = this.options.crossSize / zoom / 2
ctx.save()
ctx.translate(handle.x, handle.y)
ctx.rotate(markerAngle)
ctx.beginPath()
ctx.moveTo(-half, -half)
ctx.lineTo(half, half)
ctx.moveTo(-half, half)
ctx.lineTo(half, -half)
ctx.stroke()
ctx.restore()
} else {
const radius = this.options.dotRadius / zoom
ctx.beginPath()
ctx.arc(handle.x, handle.y, radius, 0, PI2)
ctx.stroke()
}
}
/** Tangent direction at the handle, oriented toward the body. */
private getMarkerAngle(
info: TLArrowInfo,
side: 'start' | 'end',
handle: { x: number; y: number },
point: { x: number; y: number }
): number {
if (info.type === 'arc') {
const { center, sweepFlag } = info.bodyArc
const radial = Math.atan2(handle.y - center.y, handle.x - center.x)
// tangent perpendicular to radial; sign chosen to point toward the body
const sign = (sweepFlag ? 1 : -1) * (side === 'start' ? 1 : -1)
return radial + (sign * Math.PI) / 2
}
return Math.atan2(point.y - handle.y, point.x - handle.x)
}
}

View File

@@ -0,0 +1,215 @@
import { OverlayUtil, TLOverlay, TLShape, TLShapeId, createComputedCache } from '@tldraw/editor'
import type { Editor } from '@tldraw/editor'
import { TLIndicatorPath } from '@tldraw/editor'
import { getArrowTargetState } from '../shapes/arrow/arrowTargetState'
import { DraggingHandle } from '../tools/SelectTool/childStates/DraggingHandle'
import { PointingHandle } from '../tools/SelectTool/childStates/PointingHandle'
/** @public */
export interface TLArrowHintOverlay extends TLOverlay {
props: {
targetId: TLShapeId
handles: {
top: { x: number; y: number; isEnabled: boolean }
bottom: { x: number; y: number; isEnabled: boolean }
left: { x: number; y: number; isEnabled: boolean }
right: { x: number; y: number; isEnabled: boolean }
}
anchorX: number
anchorY: number
snap: string
isExact: boolean
isPrecise: boolean
arrowKind: string
showEdgeHints: boolean
}
}
const indicatorPathCache = createComputedCache(
'arrowHintIndicatorPath',
(editor: Editor, shape: TLShape) => {
const util = editor.getShapeUtil(shape)
return util.getIndicatorPath(shape)
},
{
areRecordsEqual(a, b) {
return a.props === b.props
},
}
)
/**
* Overlay util for arrow target hints (target shape indicator + edge snap circles).
*
* @public
*/
export class ArrowHintOverlayUtil extends OverlayUtil<TLArrowHintOverlay> {
static override type = 'arrow_hint'
override options = {
zIndex: 1000,
lineWidth: 1.5,
edgeRadius: 8,
edgePointRadius: 12,
handleRadius: 4,
}
override isActive(): boolean {
const editor = this.editor
if (editor.isInAny('arrow.idle', 'arrow.pointing')) return true
if (editor.isIn('select.pointing_handle')) {
const node: PointingHandle = editor.getStateDescendant('select.pointing_handle')!
if (
node.info.shape.type === 'arrow' &&
(node.info.handle.id === 'start' || node.info.handle.id === 'end')
) {
return true
}
}
if (editor.isIn('select.dragging_handle')) {
const node: DraggingHandle = editor.getStateDescendant('select.dragging_handle')!
if (
node.info.shape.type === 'arrow' &&
(node.info.handle.id === 'start' || node.info.handle.id === 'end')
) {
return true
}
}
return false
}
override getOverlays(): TLArrowHintOverlay[] {
const targetInfo = getArrowTargetState(this.editor)
if (!targetInfo) return []
const { handlesInPageSpace, snap, anchorInPageSpace, arrowKind, isExact, isPrecise } =
targetInfo
const showEdgeHints = !isExact && arrowKind === 'elbow'
return [
{
id: 'arrow_hint',
type: 'arrow_hint',
props: {
targetId: targetInfo.target.id,
handles: {
top: {
x: handlesInPageSpace.top.point.x,
y: handlesInPageSpace.top.point.y,
isEnabled: handlesInPageSpace.top.isEnabled,
},
bottom: {
x: handlesInPageSpace.bottom.point.x,
y: handlesInPageSpace.bottom.point.y,
isEnabled: handlesInPageSpace.bottom.isEnabled,
},
left: {
x: handlesInPageSpace.left.point.x,
y: handlesInPageSpace.left.point.y,
isEnabled: handlesInPageSpace.left.isEnabled,
},
right: {
x: handlesInPageSpace.right.point.x,
y: handlesInPageSpace.right.point.y,
isEnabled: handlesInPageSpace.right.isEnabled,
},
},
anchorX: anchorInPageSpace.x,
anchorY: anchorInPageSpace.y,
snap,
isExact,
isPrecise,
arrowKind,
showEdgeHints,
},
},
]
}
override render(ctx: CanvasRenderingContext2D, overlays: TLArrowHintOverlay[]): void {
const overlay = overlays[0]
if (!overlay) return
const editor = this.editor
const zoom = editor.getZoomLevel()
const colors = editor.getCurrentTheme().colors[editor.getColorMode()]
const { targetId, handles, anchorX, anchorY, snap, showEdgeHints } = overlay.props
// Draw the target shape indicator
const shape = editor.getShape(targetId)
if (shape) {
const pageTransform = editor.getShapePageTransform(shape)
const indicatorPath = indicatorPathCache.get(editor, targetId)
if (pageTransform && indicatorPath) {
ctx.save()
ctx.transform(
pageTransform.a,
pageTransform.b,
pageTransform.c,
pageTransform.d,
pageTransform.e,
pageTransform.f
)
ctx.strokeStyle = colors.selectionStroke
ctx.lineWidth = this.options.lineWidth / zoom
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
this._renderIndicatorPath(ctx, indicatorPath)
ctx.restore()
}
}
if (!showEdgeHints) return
// Draw the anchor snap circle
if (snap === 'edge' || snap === 'edge-point') {
const snapRadius =
(snap === 'edge-point' ? this.options.edgePointRadius : this.options.edgeRadius) / zoom
ctx.beginPath()
ctx.arc(anchorX, anchorY, snapRadius, 0, Math.PI * 2)
ctx.fillStyle = colors.selectionFill
ctx.fill()
}
// Draw edge handle circles using a single fill+stroke for all handles
const handleRadius = this.options.handleRadius / zoom
ctx.fillStyle = colors.selectedContrast
ctx.strokeStyle = colors.selectionStroke
ctx.lineWidth = this.options.lineWidth / zoom
ctx.beginPath()
for (const handle of Object.values(handles)) {
if (!handle.isEnabled) continue
// Separate subpath for each circle
ctx.moveTo(handle.x + handleRadius, handle.y)
ctx.arc(handle.x, handle.y, handleRadius, 0, Math.PI * 2)
}
ctx.fill()
ctx.stroke()
}
/** @internal */
private _renderIndicatorPath(ctx: CanvasRenderingContext2D, indicatorPath: TLIndicatorPath) {
if (indicatorPath instanceof Path2D) {
ctx.stroke(indicatorPath)
} else {
const { path, clipPath, additionalPaths } = indicatorPath
if (clipPath) {
ctx.save()
ctx.clip(clipPath, 'evenodd')
ctx.stroke(path)
ctx.restore()
} else {
ctx.stroke(path)
}
if (additionalPaths) {
for (const additionalPath of additionalPaths) {
ctx.stroke(additionalPath)
}
}
}
}
}

View File

@@ -0,0 +1,108 @@
import {
getOverlayDisplayValues,
OverlayOptionsWithDisplayValues,
OverlayUtil,
TLOverlay,
} from '@tldraw/editor'
/** @public */
export interface TLBrushOverlay extends TLOverlay {
props: {
x: number
y: number
w: number
h: number
}
}
/** @public */
export interface BrushOverlayUtilDisplayValues {
fillColor: string
strokeColor: string
lineWidth: number
}
/** @public */
export interface BrushOverlayUtilOptions extends OverlayOptionsWithDisplayValues<
TLBrushOverlay,
BrushOverlayUtilDisplayValues
> {
zIndex: number
}
/**
* Overlay util for the selection brush rectangle.
*
* @public
*/
export class BrushOverlayUtil extends OverlayUtil<TLBrushOverlay> {
static override type = 'brush'
override options: BrushOverlayUtilOptions = {
zIndex: 300,
getDefaultDisplayValues(editor, _overlay, theme, colorMode): BrushOverlayUtilDisplayValues {
const colors = theme.colors[colorMode]
return {
fillColor: colors.brushFill,
strokeColor: colors.brushStroke,
lineWidth: 1,
}
},
getCustomDisplayValues(): Partial<BrushOverlayUtilDisplayValues> {
return {}
},
}
override isActive(): boolean {
return this.editor.getInstanceState().brush !== null
}
override getOverlays(): TLBrushOverlay[] {
const brush = this.editor.getInstanceState().brush
if (!brush) return []
return [
{
id: 'brush',
type: 'brush',
props: {
x: brush.x,
y: brush.y,
w: Math.max(1, brush.w),
h: Math.max(1, brush.h),
},
},
]
}
override render(ctx: CanvasRenderingContext2D, overlays: TLBrushOverlay[]): void {
const overlay = overlays[0]
if (!overlay) return
const { x, y, w, h } = overlay.props
const zoom = this.editor.getZoomLevel()
const dv = getOverlayDisplayValues(this, overlay)
// Use fillRect / strokeRect to avoid path construction overhead
ctx.fillStyle = dv.fillColor
ctx.fillRect(x, y, w, h)
ctx.lineWidth = dv.lineWidth / zoom
ctx.strokeStyle = dv.strokeColor
ctx.strokeRect(x, y, w, h)
}
override renderMinimap(
ctx: CanvasRenderingContext2D,
overlays: TLBrushOverlay[],
zoom: number
): void {
const overlay = overlays[0]
if (!overlay) return
const { x, y, w, h } = overlay.props
const dv = getOverlayDisplayValues(this, overlay)
ctx.fillStyle = dv.fillColor
ctx.fillRect(x, y, w, h)
ctx.lineWidth = dv.lineWidth / zoom
ctx.strokeStyle = dv.strokeColor
ctx.strokeRect(x, y, w, h)
}
}

View File

@@ -0,0 +1,80 @@
import { OverlayUtil, TLOverlay } from '@tldraw/editor'
/** @public */
export interface TLCollaboratorBrushOverlay extends TLOverlay {
props: {
x: number
y: number
w: number
h: number
color: string
}
}
/**
* Overlay util for collaborator selection brushes.
*
* @public
*/
export class CollaboratorBrushOverlayUtil extends OverlayUtil<TLCollaboratorBrushOverlay> {
static override type = 'collaborator_brush'
override options = { zIndex: 700, lineWidth: 1 }
override isActive(): boolean {
return this.editor.getVisibleCollaboratorsOnCurrentPage().some((c) => c.brush !== null)
}
override getOverlays(): TLCollaboratorBrushOverlay[] {
const overlays: TLCollaboratorBrushOverlay[] = []
for (const presence of this.editor.getVisibleCollaboratorsOnCurrentPage()) {
const { brush, color, userId } = presence
if (!brush) continue
overlays.push({
id: `collaborator_brush:${userId}`,
type: 'collaborator_brush',
props: {
x: brush.x,
y: brush.y,
w: Math.max(1, brush.w),
h: Math.max(1, brush.h),
color,
},
})
}
return overlays
}
override render(ctx: CanvasRenderingContext2D, overlays: TLCollaboratorBrushOverlay[]): void {
const zoom = this.editor.getZoomLevel()
for (const overlay of overlays) {
const { x, y, w, h, color } = overlay.props
// Match the old SVG path: brush fill at 0.75 modulated by a parent
// opacity of 0.1 gave an effective fill alpha of 0.075.
ctx.globalAlpha = 0.075
ctx.fillStyle = color
ctx.fillRect(x, y, w, h)
// Stroke at 0.1 alpha matches the old parent opacity.
ctx.globalAlpha = 0.1
ctx.strokeStyle = color
ctx.lineWidth = this.options.lineWidth / zoom
ctx.strokeRect(x, y, w, h)
ctx.globalAlpha = 1
}
}
override renderMinimap(
ctx: CanvasRenderingContext2D,
overlays: TLCollaboratorBrushOverlay[]
): void {
for (const overlay of overlays) {
const { x, y, w, h, color } = overlay.props
ctx.globalAlpha = 0.75
ctx.fillStyle = color
ctx.fillRect(x, y, w, h)
}
ctx.globalAlpha = 1
}
}

View File

@@ -0,0 +1,312 @@
// This file implements the deprecated canvas cursor overlay, so it references its own deprecated types.
// oxlint-disable typescript/no-deprecated
import { OverlayUtil, PI2, TLOverlay } from '@tldraw/editor'
/**
* @public
* @deprecated Collaborator cursors are rendered as DOM elements rather than drawn to the overlay
* canvas. Use `TLCursorProps`, the props of the `CollaboratorCursor` editor component.
*/
export interface TLCollaboratorCursorOverlay extends TLOverlay {
props: {
x: number
y: number
color: string
name: string | null
chatMessage: string
}
}
// Lazy-initialized Path2D objects for the cursor arrow shape (deferred to avoid jsdom issues in tests).
// Head and tail are separate Path2D objects per layer so each is filled in its own draw call,
// mirroring the original SVG's two-<path> layout per group.
let _shadowHead: Path2D | null = null
let _shadowTail: Path2D | null = null
let _whiteHead: Path2D | null = null
let _whiteTail: Path2D | null = null
let _fillHead: Path2D | null = null
let _fillTail: Path2D | null = null
function getCursorPaths() {
if (!_shadowHead) {
// Shadow layer (original SVG translate(-11,-11) pre-applied):
_shadowHead = new Path2D('M 1 13.4219 V -2.593 L 12.591 9.026 H 5.81 L 5.399 9.15 Z')
_shadowTail = new Path2D('M 10.0595 13.9498 L 6.3715 15.4978 L 2.4965 6.2803 L 6.1845 4.7323 Z')
// White outline layer (original SVG translate(-12,-12) pre-applied):
_whiteHead = new Path2D('M 0 12.4219 V -3.593 L 11.591 8.026 H 4.81 L 4.399 8.15 Z')
_whiteTail = new Path2D('M 9.0595 12.9498 L 5.3715 14.4978 L 1.4965 5.2803 L 5.1845 3.7323 Z')
// Colored fill layer (original SVG translate(-12,-12) pre-applied):
_fillHead = new Path2D('M 1 -1.186 V 10.002 L 3.969 7.136 L 4.397 6.997 H 9.165 Z')
_fillTail = new Path2D('M 7.751 12.4155 L 5.907 13.1895 L 2.807 5.8155 L 4.648 5.0405 Z')
}
return {
shadowHead: _shadowHead,
shadowTail: _shadowTail!,
whiteHead: _whiteHead!,
whiteTail: _whiteTail!,
fillHead: _fillHead!,
fillTail: _fillTail!,
}
}
const TRUNCATE_CACHE_MAX = 200
const DEFAULT_LABEL_FONT_FAMILY = "'tldraw_sans', sans-serif"
function getLabelFontFamily(editorContainer: HTMLElement, editorWindow: Window): string {
const fontFamily = editorWindow
.getComputedStyle(editorContainer)
.getPropertyValue('--tl-font-sans')
.trim()
return fontFamily && !fontFamily.includes('var(') ? fontFamily : DEFAULT_LABEL_FONT_FAMILY
}
/**
* Overlay util for collaborator cursors (arrow + name tag + chat message).
*
* @public
* @deprecated Collaborator cursors are no longer drawn to the overlay canvas, so this util is no
* longer part of `defaultOverlayUtils`. They render as DOM elements instead, customizable through
* the `CollaboratorCursor` editor component. To keep drawing cursors to the canvas, pass this util
* (or a subclass of it) to `overlayUtils` and set `components={{ CollaboratorCursor: null }}` so the
* DOM layer doesn't draw them a second time.
*/
export class CollaboratorCursorOverlayUtil extends OverlayUtil<TLCollaboratorCursorOverlay> {
static override type = 'collaborator_cursor'
override options = { zIndex: 1100, fontSize: 12, nameMaxWidth: 120, chatMaxWidth: 200 }
// Cache truncated text results to avoid repeated measureText loops.
// Key format: `${maxWidth}|${text}` with an upper bound on cache size.
// Per-editor so multiple <Tldraw /> instances on one page don't trample
// each other's entries.
private _truncateCache = new Map<string, string>()
override isActive(): boolean {
return this.editor.getVisibleCollaboratorsOnCurrentPage().some((presence) => !!presence.cursor)
}
override getOverlays(): TLCollaboratorCursorOverlay[] {
const overlays: TLCollaboratorCursorOverlay[] = []
// Visibility (activity state, following, highlighting) is handled by the
// editor. The main-canvas viewport cull lives in `render` so off-screen
// cursors still show on the minimap via `renderMinimap`.
for (const presence of this.editor.getVisibleCollaboratorsOnCurrentPage()) {
const { cursor, color, userName, chatMessage, userId } = presence
if (!cursor) continue
overlays.push({
id: `collaborator_cursor:${userId}`,
type: 'collaborator_cursor',
props: {
x: cursor.x,
y: cursor.y,
color,
name: userName !== 'New User' ? userName : null,
chatMessage: chatMessage ?? '',
},
})
}
return overlays
}
override render(ctx: CanvasRenderingContext2D, overlays: TLCollaboratorCursorOverlay[]): void {
const zoom = this.editor.getZoomLevel()
const scale = 1 / zoom
const viewport = this.editor.getViewportPageBounds()
const viewportMarginX = 12 / zoom
const viewportMarginY = 16 / zoom
const labelFontFamily = getLabelFontFamily(
this.editor.getContainer(),
this.editor.getContainerWindow()
)
let paths: ReturnType<typeof getCursorPaths> | null = null
for (const overlay of overlays) {
const { x, y, color, name, chatMessage } = overlay.props
// Cull cursors outside the main viewport (with a small margin for
// the cursor glyph). Off-screen cursors still show on the minimap
// via `renderMinimap`.
if (
x < viewport.minX - viewportMarginX ||
y < viewport.minY - viewportMarginY ||
x > viewport.maxX - viewportMarginX ||
y > viewport.maxY - viewportMarginY
) {
continue
}
ctx.save()
ctx.translate(x, y)
ctx.scale(scale, scale)
// Draw cursor arrow
paths ??= getCursorPaths()
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.2)'
ctx.fill(paths.shadowHead)
ctx.fill(paths.shadowTail)
// White outline
ctx.fillStyle = '#ffffff'
ctx.fill(paths.whiteHead)
ctx.fill(paths.whiteTail)
// Colored fill
ctx.fillStyle = color
ctx.fill(paths.fillHead)
ctx.fill(paths.fillTail)
// Draw name tag / chat
if (chatMessage) {
if (name) {
this._drawNameTitle(ctx, name, color, labelFontFamily)
}
this._drawChatBubble(ctx, chatMessage, color, labelFontFamily)
} else if (name) {
this._drawNameTag(ctx, name, color, labelFontFamily)
}
ctx.restore()
}
}
/** Name tag (no chat) - colored background with white text */
private _drawNameTag(
ctx: CanvasRenderingContext2D,
name: string,
color: string,
fontFamily: string
) {
const { fontSize, nameMaxWidth } = this.options
ctx.font = `${fontSize}px ${fontFamily}`
const text = this._truncateText(ctx, name, nameMaxWidth)
const metrics = ctx.measureText(text)
const textWidth = Math.min(metrics.width, nameMaxWidth)
const px = 6
const py = 3
const x = 13
const y = 16
const h = fontSize + py * 2
const w = textWidth + px * 2
// Background
ctx.fillStyle = color
ctx.beginPath()
ctx.roundRect(x, y, w, h, 4)
ctx.fill()
// Text
ctx.fillStyle = '#ffffff'
ctx.textBaseline = 'top'
ctx.fillText(text, x + px, y + py)
}
/** Name title (when chat is present) - text with shadow, no background */
private _drawNameTitle(
ctx: CanvasRenderingContext2D,
name: string,
color: string,
fontFamily: string
) {
const { fontSize, nameMaxWidth } = this.options
ctx.font = `${fontSize}px ${fontFamily}`
const text = this._truncateText(ctx, name, nameMaxWidth)
const x = 13
const y = -2
// Text outline (simulate text-shadow)
ctx.strokeStyle = '#ffffff'
ctx.lineWidth = 3
ctx.lineJoin = 'round'
ctx.textBaseline = 'top'
ctx.strokeText(text, x, y)
// Text fill
ctx.fillStyle = color
ctx.fillText(text, x, y)
}
/** Chat bubble - colored background with white text */
private _drawChatBubble(
ctx: CanvasRenderingContext2D,
chatMessage: string,
color: string,
fontFamily: string
) {
const { fontSize, chatMaxWidth } = this.options
ctx.font = `${fontSize}px ${fontFamily}`
const text = this._truncateText(ctx, chatMessage, chatMaxWidth)
const metrics = ctx.measureText(text)
const textWidth = Math.min(metrics.width, chatMaxWidth)
const px = 6
const py = 3
const x = 13
const y = 16
const h = fontSize + py * 2
const w = textWidth + px * 2
// Background
ctx.fillStyle = color
ctx.beginPath()
ctx.roundRect(x, y, w, h, 4)
ctx.fill()
// Text
ctx.fillStyle = '#ffffff'
ctx.textBaseline = 'top'
ctx.fillText(text, x + px, y + py)
}
override renderMinimap(
ctx: CanvasRenderingContext2D,
overlays: TLCollaboratorCursorOverlay[],
zoom: number
): void {
// Small filled dot per collaborator, clamped inside the minimap's
// page bounds is left to the caller — we just render at the cursor
// page-position and let the viewport rounded-rect indicate framing.
const radius = 3 / zoom
for (const overlay of overlays) {
const { x, y, color } = overlay.props
ctx.beginPath()
ctx.arc(x, y, radius, 0, PI2)
ctx.fillStyle = color
ctx.fill()
}
}
private _truncateText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string {
const key = `${ctx.font}|${maxWidth}|${text}`
const cached = this._truncateCache.get(key)
if (cached !== undefined) return cached
if (ctx.measureText(text).width <= maxWidth) {
return this._setTruncatedTextCache(key, text)
}
const ellipsis = '…'
let low = 0
let high = text.length
while (low < high) {
const mid = Math.ceil((low + high) / 2)
if (ctx.measureText(text.slice(0, mid) + ellipsis).width <= maxWidth) {
low = mid
} else {
high = mid - 1
}
}
return this._setTruncatedTextCache(key, text.slice(0, low) + ellipsis)
}
private _setTruncatedTextCache(key: string, result: string): string {
if (this._truncateCache.size >= TRUNCATE_CACHE_MAX) this._truncateCache.clear()
this._truncateCache.set(key, result)
return result
}
}

View File

@@ -0,0 +1,96 @@
import { OverlayUtil, TLOverlay, Vec, clamp, isCursorInViewport } from '@tldraw/editor'
/** @public */
export interface TLCollaboratorHintOverlay extends TLOverlay {
props: {
/** Clamped point on viewport edge, in page coordinates */
x: number
y: number
/** Rotation angle pointing toward the collaborator's actual cursor */
rotation: number
color: string
}
}
// Lazy-init a Path2D for the hint arrow shape to avoid per-frame path building
let _arrowPath: Path2D | null = null
function getArrowPath() {
if (!_arrowPath) {
// Matches the path drawn via: M -2,-5 2,0 -2,5 Z
_arrowPath = new Path2D('M -2 -5 L 2 0 L -2 5 Z')
}
return _arrowPath!
}
/**
* Overlay util for off-screen collaborator cursor hints.
* Shows a small directional arrow at the viewport edge pointing toward the collaborator.
*
* @public
*/
export class CollaboratorHintOverlayUtil extends OverlayUtil<TLCollaboratorHintOverlay> {
static override type = 'collaborator_hint'
override options = { zIndex: 900, lineWidth: 3, viewportPadding: 5 }
override isActive(): boolean {
const viewport = this.editor.getViewportPageBounds()
const zoom = this.editor.getZoomLevel()
return this.editor.getVisibleCollaboratorsOnCurrentPage().some((presence) => {
const { cursor } = presence
if (!cursor) return false
return !isCursorInViewport(cursor, viewport, zoom)
})
}
override getOverlays(): TLCollaboratorHintOverlay[] {
const overlays: TLCollaboratorHintOverlay[] = []
const viewport = this.editor.getViewportPageBounds()
const zoom = this.editor.getZoomLevel()
for (const presence of this.editor.getVisibleCollaboratorsOnCurrentPage()) {
const { cursor, color, userId } = presence
if (!cursor) continue
if (isCursorInViewport(cursor, viewport, zoom)) continue
const pad = this.options.viewportPadding / zoom
const x = clamp(cursor.x, viewport.minX + pad, viewport.maxX - pad)
const y = clamp(cursor.y, viewport.minY + pad, viewport.maxY - pad)
const rotation = Vec.Angle(viewport.center, cursor)
overlays.push({
id: `collaborator_hint:${userId}`,
type: 'collaborator_hint',
props: { x, y, rotation, color },
})
}
return overlays
}
override render(ctx: CanvasRenderingContext2D, overlays: TLCollaboratorHintOverlay[]): void {
const zoom = this.editor.getZoomLevel()
const scale = 1 / zoom
for (const overlay of overlays) {
const { x, y, rotation, color } = overlay.props
ctx.save()
ctx.translate(x, y)
ctx.scale(scale, scale)
ctx.rotate(rotation)
const path = getArrowPath()
// Outline stroke (white background for contrast)
ctx.lineWidth = this.options.lineWidth
// Canvas doesn't support CSS vars in strokeStyle; use white
ctx.strokeStyle = '#ffffff'
ctx.stroke(path)
// Fill with collaborator color
ctx.fillStyle = color
ctx.fill(path)
ctx.restore()
}
}
}

View File

@@ -0,0 +1,122 @@
import { EASINGS, OverlayUtil, TLOverlay, TLScribble, getSvgPathFromPoints } from '@tldraw/editor'
import { getStroke } from '../shapes/shared/freehand/getStroke'
/** @public */
export interface TLCollaboratorScribbleOverlay extends TLOverlay {
props: {
scribble: TLScribble
color: string
}
}
// Cache Path2D results for collaborator scribbles similarly to local scribbles
interface CollaboratorScribbleCacheEntry {
len: number
lastX: number
lastY: number
zoom: number
size: number
taper: boolean
state: TLCollaboratorScribbleOverlay['props']['scribble']['state']
path: Path2D
}
/**
* Overlay util for collaborator scribble strokes (eraser, lasso, etc.).
*
* @public
*/
export class CollaboratorScribbleOverlayUtil extends OverlayUtil<TLCollaboratorScribbleOverlay> {
static override type = 'collaborator_scribble'
override options = { zIndex: 800, streamline: 0.32, cacheSize: 500 }
// String-keyed (not a WeakMap) because the cache key is a logical identity
// — `${overlay.id}` derived from `scribble.id` — not the scribble object.
// Tldraw's store replaces record objects on every update, so a WeakMap
// keyed on the `TLScribble` instance would cache-miss every frame. Lifetime
// is bounded by the Util instance (so, by the editor) plus the `cacheSize`
// cap in `render` below.
private _collabScribblePathCache = new Map<string, CollaboratorScribbleCacheEntry>()
override isActive(): boolean {
return this.editor.getVisibleCollaboratorsOnCurrentPage().some((c) => c.scribbles.length > 0)
}
override getOverlays(): TLCollaboratorScribbleOverlay[] {
const overlays: TLCollaboratorScribbleOverlay[] = []
for (const presence of this.editor.getVisibleCollaboratorsOnCurrentPage()) {
const { scribbles, color, userId } = presence
for (const scribble of scribbles) {
overlays.push({
id: `collaborator_scribble:${userId}:${scribble.id}`,
type: 'collaborator_scribble',
props: { scribble, color },
})
}
}
return overlays
}
override render(ctx: CanvasRenderingContext2D, overlays: TLCollaboratorScribbleOverlay[]): void {
const zoom = this.editor.getZoomLevel()
for (const overlay of overlays) {
const { scribble, color } = overlay.props
const ptsLen = scribble.points.length
if (!ptsLen) continue
const last = scribble.points[ptsLen - 1]
const cacheKey = overlay.id
const cached = this._collabScribblePathCache.get(cacheKey)
let path: Path2D
if (
cached &&
cached.len === ptsLen &&
cached.lastX === last.x &&
cached.lastY === last.y &&
cached.zoom === zoom &&
cached.size === scribble.size &&
cached.taper === scribble.taper &&
cached.state === scribble.state
) {
path = cached.path
} else {
const stroke = getStroke(scribble.points, {
size: scribble.size / zoom,
start: { taper: scribble.taper, easing: EASINGS.linear },
last: scribble.state === 'complete' || scribble.state === 'stopping',
simulatePressure: false,
streamline: this.options.streamline,
})
let d: string
if (stroke.length < 4) {
const r = scribble.size / zoom / 2
const { x, y } = last
d = `M ${x - r},${y} a ${r},${r} 0 1,0 ${r * 2},0 a ${r},${r} 0 1,0 ${-r * 2},0`
} else {
d = getSvgPathFromPoints(stroke)
}
path = new Path2D(d)
this._collabScribblePathCache.set(cacheKey, {
len: ptsLen,
lastX: last.x,
lastY: last.y,
zoom,
size: scribble.size,
taper: scribble.taper,
state: scribble.state,
path,
})
if (this._collabScribblePathCache.size > this.options.cacheSize)
this._collabScribblePathCache.clear()
}
ctx.fillStyle = color
ctx.globalAlpha = scribble.color === 'laser' ? 0.5 : 0.1
ctx.fill(path)
ctx.globalAlpha = 1
}
}
}

View File

@@ -0,0 +1,85 @@
import { OverlayUtil, strokeShapeIndicators, TLOverlay, TLShapeId } from '@tldraw/editor'
/** @public */
export interface TLCollaboratorShapeIndicatorOverlay extends TLOverlay {
props: {
// One entry per peer, batching the shape IDs they have selected so the
// renderer can issue a single stroke call per collaborator.
indicators: Array<{ color: string; shapeIds: TLShapeId[] }>
}
}
/**
* Overlay util for remote collaborators' shape selection indicators.
*
* Renders a per-peer outline around each shape another user has selected,
* using the peer's color. Drawn under the local `ShapeIndicatorOverlayUtil`
* (lower z-index) so the local user's selection always appears on top.
*
* Non-interactive: contributes no hit-test geometry.
*
* @public
*/
export class CollaboratorShapeIndicatorOverlayUtil extends OverlayUtil<TLCollaboratorShapeIndicatorOverlay> {
static override type = 'collaborator_shape_indicator'
override options = { zIndex: 49, lineWidth: 1.5, alpha: 0.7 }
override isActive(): boolean {
return this.editor
.getVisibleCollaboratorsOnCurrentPage()
.some((presence) => presence.selectedShapeIds.length > 0)
}
override getOverlays(): TLCollaboratorShapeIndicatorOverlay[] {
const editor = this.editor
const selectedPresences = editor
.getVisibleCollaboratorsOnCurrentPage()
.filter((presence) => presence.selectedShapeIds.length > 0)
if (selectedPresences.length === 0) return []
const renderingShapeIds = new Set(editor.getRenderingShapes().map((s) => s.id))
const indicators: TLCollaboratorShapeIndicatorOverlay['props']['indicators'] = []
for (const presence of selectedPresences) {
const visibleShapeIds = presence.selectedShapeIds.filter(
(id) => renderingShapeIds.has(id) && !editor.isShapeHidden(id)
)
if (visibleShapeIds.length === 0) continue
indicators.push({ color: presence.color, shapeIds: visibleShapeIds })
}
if (indicators.length === 0) return []
return [
{
id: 'collaborator_shape_indicator',
type: 'collaborator_shape_indicator',
props: { indicators },
},
]
}
override render(
ctx: CanvasRenderingContext2D,
overlays: TLCollaboratorShapeIndicatorOverlay[]
): void {
const overlay = overlays[0]
if (!overlay) return
const editor = this.editor
const zoom = editor.getZoomLevel()
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.lineWidth = this.options.lineWidth / zoom
ctx.globalAlpha = this.options.alpha
// One stroke call per collaborator.
for (const { color, shapeIds } of overlay.props.indicators) {
ctx.strokeStyle = color
strokeShapeIndicators(editor, ctx, shapeIds)
}
ctx.globalAlpha = 1
}
}

View File

@@ -0,0 +1,143 @@
import {
EASINGS,
OverlayUtil,
TLCanvasUiColor,
TLOverlay,
TLScribble,
TLThemeColors,
getSvgPathFromPoints,
} from '@tldraw/editor'
import { getStroke } from '../shapes/shared/freehand/getStroke'
/** @public */
export interface TLScribbleOverlay extends TLOverlay {
props: {
scribble: TLScribble
}
}
// Cache Path2D results for scribbles when inputs have not changed.
// Keyed by scribble id; invalidated when points length/last point/zoom/size/taper/state change.
interface ScribbleCacheEntry {
len: number
lastX: number
lastY: number
zoom: number
size: number
taper: boolean
state: TLScribble['state']
path: Path2D
}
/**
* Overlay util for scribble strokes (eraser, lasso selection, etc.).
*
* @public
*/
export class ScribbleOverlayUtil extends OverlayUtil<TLScribbleOverlay> {
static override type = 'scribble'
override options = { zIndex: 600, streamline: 0.32, cacheSize: 500 }
// Per-editor cache so multiple <Tldraw /> instances on one page don't
// trample each other's entries. String-keyed (not a WeakMap) because the
// cache key is a logical identity — `scribble.id` — not the scribble
// object. Tldraw's store replaces record objects on every update, so a
// WeakMap keyed on the `TLScribble` instance would cache-miss every frame.
// Lifetime is bounded by the Util instance plus the `cacheSize` cap below.
private _scribblePathCache = new Map<string, ScribbleCacheEntry>()
override isActive(): boolean {
return this.editor.getInstanceState().scribbles.length > 0
}
override getOverlays(): TLScribbleOverlay[] {
return this.editor.getInstanceState().scribbles.map((scribble) => ({
id: `scribble:${scribble.id}`,
type: 'scribble',
props: { scribble },
}))
}
override render(ctx: CanvasRenderingContext2D, overlays: TLScribbleOverlay[]): void {
const zoom = this.editor.getZoomLevel()
const colors = this.editor.getCurrentTheme().colors[this.editor.getColorMode()]
for (const overlay of overlays) {
const { scribble } = overlay.props
const ptsLen = scribble.points.length
if (!ptsLen) continue
const last = scribble.points[ptsLen - 1]
const cached = this._scribblePathCache.get(scribble.id)
let path: Path2D
if (
cached &&
cached.len === ptsLen &&
cached.lastX === last.x &&
cached.lastY === last.y &&
cached.zoom === zoom &&
cached.size === scribble.size &&
cached.taper === scribble.taper &&
cached.state === scribble.state
) {
path = cached.path
} else {
const stroke = getStroke(scribble.points, {
size: scribble.size / zoom,
start: { taper: scribble.taper, easing: EASINGS.linear },
last: scribble.state === 'complete' || scribble.state === 'stopping',
simulatePressure: false,
streamline: this.options.streamline,
})
let d: string
if (stroke.length < 4) {
const r = scribble.size / zoom / 2
const { x, y } = last
d = `M ${x - r},${y} a ${r},${r} 0 1,0 ${r * 2},0 a ${r},${r} 0 1,0 ${-r * 2},0`
} else {
d = getSvgPathFromPoints(stroke)
}
path = new Path2D(d)
this._scribblePathCache.set(scribble.id, {
len: ptsLen,
lastX: last.x,
lastY: last.y,
zoom,
size: scribble.size,
taper: scribble.taper,
state: scribble.state,
path,
})
if (this._scribblePathCache.size > this.options.cacheSize) this._scribblePathCache.clear()
}
ctx.fillStyle = resolveCanvasUiColor(colors, scribble.color)
ctx.globalAlpha = scribble.opacity
ctx.fill(path)
ctx.globalAlpha = 1
}
}
}
/** @internal */
function resolveCanvasUiColor(colors: TLThemeColors, color: TLCanvasUiColor): string {
switch (color) {
case 'accent':
case 'selection-stroke':
return colors.selectionStroke
case 'selection-fill':
return colors.selectionFill
case 'white':
return colors.selectedContrast
case 'black':
return colors.text
case 'laser':
return colors.laser
case 'muted-1':
return colors.brushFill
default:
return colors.text
}
}

View File

@@ -0,0 +1,700 @@
import {
Box,
Geometry2d,
HALF_PI,
Mat,
OverlayUtil,
Polygon2d,
Rectangle2d,
RotateCorner,
SelectionCorner,
SelectionEdge,
TLCursorType,
TLOverlay,
TLSelectionHandle,
TLShape,
Vec,
} from '@tldraw/editor'
const SQUARE_ROOT_PI = Math.sqrt(Math.PI)
const ROTATE_CURSORS: Partial<Record<RotateCorner, TLCursorType>> = {
top_left_rotate: 'nwse-rotate',
top_right_rotate: 'nesw-rotate',
bottom_left_rotate: 'swne-rotate',
bottom_right_rotate: 'senw-rotate',
}
const RESIZE_CURSORS: Partial<Record<TLSelectionHandle, TLCursorType>> = {
top_left: 'nwse-resize',
top_right: 'nesw-resize',
bottom_right: 'nwse-resize',
bottom_left: 'nesw-resize',
top: 'ns-resize',
bottom: 'ns-resize',
left: 'ew-resize',
right: 'ew-resize',
}
/** @public */
export interface TLSelectionForegroundOverlay extends TLOverlay {
props: {
overlayType: 'resize_handle' | 'rotate_handle' | 'mobile_rotate'
handle: TLSelectionHandle | RotateCorner
}
}
interface SelectionState {
bounds: Box
onlyShape: TLShape | null
isCoarsePointer: boolean
zoom: number
rotation: number
width: number
height: number
/** Visual side length of the rendered resize-handle squares. */
handleSize: number
/** Base half-extent of the interactive hit target for handles. */
hitTargetSize: number
/** Hit target half-extent along the x axis (narrower for small selections). */
hitTargetSizeX: number
/** Hit target half-extent along the y axis (narrower for small selections). */
hitTargetSizeY: number
/** Offset from the unexpanded bounds to the expanded selection outline. */
expandOffsetX: number
expandOffsetY: number
isSmallX: boolean
shouldDisplayControls: boolean
shouldDisplayBox: boolean
showCropHandles: boolean
showResizeHandles: boolean
showCornerRotateHandles: boolean
showMobileRotateHandle: boolean
showHandles: boolean
hideAlternateCornerHandles: boolean
hideAlternateCropHandles: boolean
showOnlyOneHandle: boolean
}
interface SelectionForegroundColors {
strokeColor: string
bgColor: string
}
/**
* Overlay util for selection foreground handles (resize corners/edges, rotate corners, mobile rotate).
* Each interactive element of the selection foreground becomes its own overlay instance.
*
* @public
*/
export class SelectionForegroundOverlayUtil extends OverlayUtil<TLSelectionForegroundOverlay> {
static override type = 'selection_foreground'
override options = { zIndex: 100, lineWidth: 1.5 }
override isActive(): boolean {
if (!this.editor.getSelectionRotatedPageBounds()) return false
return this.editor.isInAny(
'select.idle',
'select.brushing',
'select.scribble_brushing',
'select.pointing_canvas',
'select.pointing_selection',
'select.pointing_shape',
'select.pointing_resize_handle',
'select.pointing_rotate_handle',
'select.resizing',
'select.crop.idle',
'select.crop.pointing_crop',
'select.crop.pointing_crop_handle'
)
}
override getOverlays(): TLSelectionForegroundOverlay[] {
const state = this._computeSelectionState()
if (!state) return []
const overlays: TLSelectionForegroundOverlay[] = []
this._collectResizeCornerOverlays(state, overlays)
this._collectResizeEdgeOverlays(state, overlays)
this._collectRotateOverlays(state, overlays)
return overlays
}
override getGeometry(overlay: TLSelectionForegroundOverlay): Geometry2d | null {
const state = this._computeSelectionState()
if (!state) return null
const transform = Mat.Compose(
Mat.Translate(state.bounds.x, state.bounds.y),
Mat.Rotate(state.rotation),
Mat.Translate(state.expandOffsetX, state.expandOffsetY)
)
const { overlayType, handle } = overlay.props
switch (overlayType) {
case 'resize_handle':
return this._getResizeHandleGeometry(handle as TLSelectionHandle, state, transform)
case 'rotate_handle':
return this._getRotateHandleGeometry(handle as RotateCorner, state, transform)
case 'mobile_rotate':
return this._getMobileRotateGeometry(state, transform)
default:
return null
}
}
override render(ctx: CanvasRenderingContext2D, _overlays: TLSelectionForegroundOverlay[]): void {
const state = this._computeSelectionState()
if (!state) return
// Transform to local selection space; each render helper assumes this is active.
ctx.save()
ctx.translate(state.bounds.x, state.bounds.y)
ctx.rotate(state.rotation)
ctx.translate(state.expandOffsetX, state.expandOffsetY)
const colors = this._getThemeColors()
this._renderSelectionBox(ctx, state, colors)
this._renderResizeCorners(ctx, state, colors)
this._renderCropHandles(ctx, state, colors)
this._renderMobileRotateHandle(ctx, state, colors)
this._renderTextResizeHandles(ctx, state, colors)
ctx.restore()
}
override getCursor(overlay: TLSelectionForegroundOverlay): TLCursorType | undefined {
const { overlayType, handle } = overlay.props
switch (overlayType) {
case 'rotate_handle':
return ROTATE_CURSORS[handle as RotateCorner]
case 'mobile_rotate':
return 'grab'
case 'resize_handle':
return RESIZE_CURSORS[handle as TLSelectionHandle]
default:
return undefined
}
}
// --- Overlay collection ---
private _collectResizeCornerOverlays(
state: SelectionState,
overlays: TLSelectionForegroundOverlay[]
) {
if (!state.showHandles) return
overlays.push(this._makeOverlay('resize_handle', 'top_left'))
if (!state.hideAlternateCornerHandles) {
overlays.push(this._makeOverlay('resize_handle', 'top_right'))
overlays.push(this._makeOverlay('resize_handle', 'bottom_left'))
}
if (!state.showOnlyOneHandle || state.showCropHandles) {
overlays.push(this._makeOverlay('resize_handle', 'bottom_right'))
}
}
private _collectResizeEdgeOverlays(
state: SelectionState,
overlays: TLSelectionForegroundOverlay[]
) {
if (!state.showHandles) return
const {
showCropHandles,
hideAlternateCropHandles,
hideAlternateCornerHandles,
showOnlyOneHandle,
isCoarsePointer,
onlyShape,
} = state
const hideVerticalEdgeTargets = showCropHandles
? hideAlternateCropHandles
: hideAlternateCornerHandles || showOnlyOneHandle || isCoarsePointer
const isMobileAndTextShape = isCoarsePointer && !!onlyShape && onlyShape.type === 'text'
const hideHorizontalEdgeTargets = showCropHandles
? hideAlternateCropHandles
: hideVerticalEdgeTargets && !isMobileAndTextShape
if (!hideVerticalEdgeTargets) {
overlays.push(this._makeOverlay('resize_handle', 'top'))
overlays.push(this._makeOverlay('resize_handle', 'bottom'))
}
if (!hideHorizontalEdgeTargets) {
overlays.push(this._makeOverlay('resize_handle', 'right'))
overlays.push(this._makeOverlay('resize_handle', 'left'))
}
}
private _collectRotateOverlays(state: SelectionState, overlays: TLSelectionForegroundOverlay[]) {
if (state.showCornerRotateHandles) {
overlays.push(this._makeOverlay('rotate_handle', 'top_left_rotate'))
overlays.push(this._makeOverlay('rotate_handle', 'top_right_rotate'))
overlays.push(this._makeOverlay('rotate_handle', 'bottom_left_rotate'))
overlays.push(this._makeOverlay('rotate_handle', 'bottom_right_rotate'))
}
if (state.showMobileRotateHandle) {
overlays.push(this._makeOverlay('mobile_rotate', 'mobile_rotate'))
}
}
// --- Geometry builders ---
private _getResizeHandleGeometry(
handle: TLSelectionHandle,
state: SelectionState,
transform: Mat
): Geometry2d {
if (handle === 'top' || handle === 'bottom' || handle === 'left' || handle === 'right') {
const rect = this._getEdgeLocalRect(handle, state)
return new Polygon2d({
points: this._localRectToPoints(rect.x, rect.y, rect.w, rect.h).map((p) =>
Mat.applyToPoint(transform, p)
),
isFilled: true,
})
}
const cornerPoint = this._getCornerLocalPoint(
handle as SelectionCorner,
state.width,
state.height
)
const cornerHitHalfSize = Math.max(state.hitTargetSizeX, state.hitTargetSizeY) * 1.5
return new Polygon2d({
points: this._localRectToPoints(
cornerPoint.x - cornerHitHalfSize,
cornerPoint.y - cornerHitHalfSize,
cornerHitHalfSize * 2,
cornerHitHalfSize * 2
).map((p) => Mat.applyToPoint(transform, p)),
isFilled: true,
})
}
private _getRotateHandleGeometry(
handle: RotateCorner,
state: SelectionState,
transform: Mat
): Geometry2d {
const cornerSize = Math.max(state.hitTargetSizeX, state.hitTargetSizeY) * 1.62
const center = this._getRotateHandleLocalCenter(handle, state.width, state.height, cornerSize)
const radius = cornerSize
return new Rectangle2d({
x: center.x - radius,
y: center.y - radius,
width: radius * 2,
height: radius * 2,
isFilled: true,
}).transform(transform)
}
private _getMobileRotateGeometry(state: SelectionState, transform: Mat): Geometry2d {
const bgRadius = Math.max(14 * (1 / state.zoom), 20 / Math.max(1, state.zoom))
const { cx, cy } = this._getMobileRotateCenter(state)
return new Polygon2d({
points: this._localRectToPoints(cx - bgRadius, cy - bgRadius, bgRadius * 2, bgRadius * 2).map(
(p) => Mat.applyToPoint(transform, p)
),
isFilled: true,
})
}
// --- Rendering (all helpers assume ctx is in local selection space) ---
private _renderSelectionBox(
ctx: CanvasRenderingContext2D,
state: SelectionState,
colors: SelectionForegroundColors
) {
if (!state.shouldDisplayBox) return
ctx.strokeStyle = colors.strokeColor
ctx.lineWidth = this.options.lineWidth / state.zoom
ctx.strokeRect(0, 0, state.width, state.height)
}
private _renderResizeCorners(
ctx: CanvasRenderingContext2D,
state: SelectionState,
colors: SelectionForegroundColors
) {
if (!state.showResizeHandles) return
const { handleSize, width, height, hideAlternateCornerHandles, showOnlyOneHandle, zoom } = state
ctx.fillStyle = colors.bgColor
ctx.strokeStyle = colors.strokeColor
ctx.lineWidth = this.options.lineWidth / zoom
const drawCorner = (x: number, y: number, hidden: boolean) => {
if (hidden) return
ctx.fillRect(x - handleSize / 2, y - handleSize / 2, handleSize, handleSize)
ctx.strokeRect(x - handleSize / 2, y - handleSize / 2, handleSize, handleSize)
}
drawCorner(0, 0, false) // top-left always shown
drawCorner(width, 0, hideAlternateCornerHandles) // top-right
drawCorner(width, height, showOnlyOneHandle) // bottom-right
drawCorner(0, height, hideAlternateCornerHandles) // bottom-left
}
private _renderCropHandles(
ctx: CanvasRenderingContext2D,
state: SelectionState,
colors: SelectionForegroundColors
) {
if (!state.showCropHandles) return
const { handleSize, width, height, hideAlternateCropHandles } = state
const cropStrokeWidth = handleSize / 3
const offset = cropStrokeWidth / 2
ctx.beginPath()
ctx.strokeStyle = colors.strokeColor
ctx.lineWidth = cropStrokeWidth
ctx.lineCap = 'butt'
ctx.lineJoin = 'miter'
// top_left corner (always shown)
ctx.moveTo(-offset, handleSize)
ctx.lineTo(-offset, -offset)
ctx.lineTo(handleSize, -offset)
// bottom_right corner (always shown)
ctx.moveTo(width + offset, height - handleSize)
ctx.lineTo(width + offset, height + offset)
ctx.lineTo(width - handleSize, height + offset)
if (!hideAlternateCropHandles) {
// top_right corner
ctx.moveTo(width - handleSize, -offset)
ctx.lineTo(width + offset, -offset)
ctx.lineTo(width + offset, handleSize)
// bottom_left corner
ctx.moveTo(handleSize, height + offset)
ctx.lineTo(-offset, height + offset)
ctx.lineTo(-offset, height - handleSize)
// top edge
ctx.moveTo(width / 2 - handleSize, -offset)
ctx.lineTo(width / 2 + handleSize, -offset)
// right edge
ctx.moveTo(width + offset, height / 2 - handleSize)
ctx.lineTo(width + offset, height / 2 + handleSize)
// bottom edge
ctx.moveTo(width / 2 - handleSize, height + offset)
ctx.lineTo(width / 2 + handleSize, height + offset)
// left edge
ctx.moveTo(-offset, height / 2 - handleSize)
ctx.lineTo(-offset, height / 2 + handleSize)
}
ctx.stroke()
}
private _renderMobileRotateHandle(
ctx: CanvasRenderingContext2D,
state: SelectionState,
colors: SelectionForegroundColors
) {
if (!state.showMobileRotateHandle) return
const { cx, cy } = this._getMobileRotateCenter(state)
const fgRadius = state.handleSize / SQUARE_ROOT_PI
ctx.fillStyle = colors.bgColor
ctx.strokeStyle = colors.strokeColor
ctx.lineWidth = this.options.lineWidth / state.zoom
ctx.beginPath()
ctx.arc(cx, cy, fgRadius, 0, Math.PI * 2)
ctx.fill()
ctx.stroke()
}
private _renderTextResizeHandles(
ctx: CanvasRenderingContext2D,
state: SelectionState,
colors: SelectionForegroundColors
) {
const {
shouldDisplayControls,
isCoarsePointer,
onlyShape,
zoom,
width,
height,
handleSize,
hitTargetSizeY,
} = state
if (!shouldDisplayControls || !isCoarsePointer || !onlyShape || onlyShape.type !== 'text') {
return
}
const textHandleHeight = Math.min(24 / zoom, height - hitTargetSizeY * 3)
if (textHandleHeight * zoom < 4) return
const hw = handleSize / 2
const r = handleSize / 4
ctx.fillStyle = colors.strokeColor
ctx.beginPath()
ctx.roundRect(0 - hw / 2, height / 2 - textHandleHeight / 2, hw, textHandleHeight, r)
ctx.fill()
ctx.beginPath()
ctx.roundRect(width - hw / 2, height / 2 - textHandleHeight / 2, hw, textHandleHeight, r)
ctx.fill()
}
// --- Shared helpers ---
/**
* Single source of truth for the derived state the selection foreground needs.
* Called from `getOverlays()`, `getGeometry()`, and `render()` so their visibility
* predicates can't drift. Returns `null` when no selection UI should appear at all
* (nothing selected, or the only selected shape is hidden).
*/
private _computeSelectionState(): SelectionState | null {
const editor = this.editor
const bounds = editor.getSelectionRotatedPageBounds()
if (!bounds) return null
const onlyShape = editor.getOnlySelectedShape()
if (onlyShape && editor.isShapeHidden(onlyShape)) return null
const onlyShapeUtil = onlyShape ? editor.getShapeUtil(onlyShape) : null
const isLockedShape = !!(onlyShape && editor.isShapeOrAncestorLocked(onlyShape))
const expandOutlineBy =
onlyShape && onlyShapeUtil ? onlyShapeUtil.expandSelectionOutlinePx(onlyShape) : 0
const expandedBounds =
expandOutlineBy instanceof Box
? bounds.clone().expand(expandOutlineBy).zeroFix()
: bounds.clone().expandBy(expandOutlineBy).zeroFix()
const instanceState = editor.getInstanceState()
const isCoarsePointer = instanceState.isCoarsePointer
const isChangingStyle = instanceState.isChangingStyle
const isReadonly = editor.getIsReadonly()
const zoom = editor.getZoomLevel()
const rotation = editor.getSelectionRotation()
const width = expandedBounds.width
const height = expandedBounds.height
const handleSize = 8 / zoom
const isTinyX = width < handleSize * 2
const isTinyY = height < handleSize * 2
const isSmallX = width < handleSize * 4
const isSmallY = height < handleSize * 4
const isSmallCropX = width < handleSize * 5
const isSmallCropY = height < handleSize * 5
const mobileHandleMultiplier = isCoarsePointer ? 1.75 : 1
const hitTargetSize = (6 / zoom) * mobileHandleMultiplier
const hitTargetSizeX =
(isSmallX ? hitTargetSize / 2 : hitTargetSize) * (mobileHandleMultiplier * 0.75)
const hitTargetSizeY =
(isSmallY ? hitTargetSize / 2 : hitTargetSize) * (mobileHandleMultiplier * 0.75)
const expandOffsetX = expandedBounds.x - bounds.x
const expandOffsetY = expandedBounds.y - bounds.y
const shouldDisplayControls =
editor.isInAny(
'select.idle',
'select.pointing_selection',
'select.pointing_shape',
'select.pointing_resize_handle',
'select.pointing_rotate_handle',
'select.crop.idle'
) &&
!isChangingStyle &&
!isReadonly
const showCropHandles =
editor.isInAny(
'select.crop.idle',
'select.crop.pointing_crop',
'select.crop.pointing_crop_handle'
) && !isReadonly
const canOnlyShapeResize =
onlyShape && onlyShapeUtil
? onlyShapeUtil.canResize(onlyShape) && !onlyShapeUtil.hideResizeHandles(onlyShape)
: true
const hideOnlyShapeRotateHandle =
onlyShape && onlyShapeUtil ? onlyShapeUtil.hideRotateHandle(onlyShape) : false
const hideOnlyShapeSelectionBounds =
onlyShape && onlyShapeUtil ? onlyShapeUtil.hideSelectionBoundsFg(onlyShape) : false
const showResizeHandles =
shouldDisplayControls && !isLockedShape && !showCropHandles && canOnlyShapeResize
const showCornerRotateHandles =
shouldDisplayControls &&
!isLockedShape &&
!isCoarsePointer &&
!(isTinyX || isTinyY) &&
!hideOnlyShapeRotateHandle
const showMobileRotateHandle =
shouldDisplayControls &&
!isLockedShape &&
isCoarsePointer &&
(!isSmallX || !isSmallY) &&
!hideOnlyShapeRotateHandle
const hideAlternateCornerHandles = isTinyX || isTinyY
const hideAlternateCropHandles = isSmallCropX || isSmallCropY
const showOnlyOneHandle = isTinyX && isTinyY
const showHandles = showResizeHandles || showCropHandles
const showSelectionBounds = !hideOnlyShapeSelectionBounds && !isChangingStyle
const shouldDisplayBox =
(showSelectionBounds &&
editor.isInAny(
'select.idle',
'select.brushing',
'select.scribble_brushing',
'select.pointing_canvas',
'select.pointing_selection',
'select.pointing_shape',
'select.crop.idle',
'select.crop.pointing_crop',
'select.crop.pointing_crop_handle',
'select.pointing_resize_handle',
'select.pointing_rotate_handle'
)) ||
(showSelectionBounds &&
editor.isIn('select.resizing') &&
!!(onlyShape && editor.isShapeOfType(onlyShape, 'text')))
return {
bounds,
onlyShape,
isCoarsePointer,
zoom,
rotation,
width,
height,
handleSize,
hitTargetSize,
hitTargetSizeX,
hitTargetSizeY,
expandOffsetX,
expandOffsetY,
isSmallX,
shouldDisplayControls,
shouldDisplayBox,
showCropHandles,
showResizeHandles,
showCornerRotateHandles,
showMobileRotateHandle,
showHandles,
hideAlternateCornerHandles,
hideAlternateCropHandles,
showOnlyOneHandle,
}
}
private _getMobileRotateCenter(state: SelectionState): { cx: number; cy: number } {
const { width, height, hitTargetSize, isSmallX, onlyShape, rotation } = state
const editor = this.editor
const isMediaShape =
!!onlyShape &&
(editor.isShapeOfType(onlyShape, 'image') || editor.isShapeOfType(onlyShape, 'video'))
const isShapeTooCloseToContextualToolbar = rotation / HALF_PI > 1.6 && rotation / HALF_PI < 2.4
const cx = isSmallX ? -hitTargetSize * 1.5 : width / 2
const cy = isSmallX
? height / 2
: isMediaShape && !isShapeTooCloseToContextualToolbar
? height + hitTargetSize * 1.5
: -hitTargetSize * 1.5
return { cx, cy }
}
private _getThemeColors(): SelectionForegroundColors {
const editor = this.editor
const themeColors = editor.getCurrentTheme().colors[editor.getColorMode()]
return {
strokeColor: themeColors.selectionStroke,
bgColor: themeColors.background,
}
}
private _makeOverlay(
overlayType: TLSelectionForegroundOverlay['props']['overlayType'],
handle: TLSelectionHandle | RotateCorner
): TLSelectionForegroundOverlay {
return {
id: `selection_fg:${handle}`,
type: 'selection_foreground',
props: { overlayType, handle },
}
}
private _getEdgeLocalRect(
edge: SelectionEdge,
state: SelectionState
): { x: number; y: number; w: number; h: number } {
const { width, height, hitTargetSizeX, hitTargetSizeY } = state
switch (edge) {
case 'top':
return { x: 0, y: -hitTargetSizeY, w: width, h: hitTargetSizeY * 2 }
case 'right':
return { x: width - hitTargetSizeX, y: 0, w: hitTargetSizeX * 2, h: height }
case 'bottom':
return { x: 0, y: height - hitTargetSizeY, w: width, h: hitTargetSizeY * 2 }
case 'left':
return { x: -hitTargetSizeX, y: 0, w: hitTargetSizeX * 2, h: height }
}
}
private _getRotateHandleLocalCenter(
corner: RotateCorner,
width: number,
height: number,
cornerSize: number
): Vec {
// Centered on the outside corner of the resize handle square
// (the corner furthest from the selection interior)
switch (corner) {
case 'top_left_rotate':
return new Vec(-cornerSize, -cornerSize)
case 'top_right_rotate':
return new Vec(width + cornerSize, -cornerSize)
case 'bottom_left_rotate':
return new Vec(-cornerSize, height + cornerSize)
case 'bottom_right_rotate':
return new Vec(width + cornerSize, height + cornerSize)
default:
return new Vec(0, 0)
}
}
private _getCornerLocalPoint(corner: SelectionCorner, width: number, height: number): Vec {
switch (corner) {
case 'top_left':
return new Vec(0, 0)
case 'top_right':
return new Vec(width, 0)
case 'bottom_right':
return new Vec(width, height)
case 'bottom_left':
return new Vec(0, height)
}
}
private _localRectToPoints(x: number, y: number, w: number, h: number): Vec[] {
return [new Vec(x, y), new Vec(x + w, y), new Vec(x + w, y + h), new Vec(x, y + h)]
}
}

View File

@@ -0,0 +1,211 @@
import {
Circle2d,
Geometry2d,
Mat,
OverlayUtil,
SIDES,
TLCursorType,
TLHandle,
TLOverlay,
TLShapeId,
Vec,
} from '@tldraw/editor'
/** @public */
export interface TLShapeHandleOverlay extends TLOverlay {
props: {
shapeId: TLShapeId
handle: TLHandle
}
}
/**
* Overlay util for shape handles (arrow endpoints, line vertices, etc.).
*
* @public
*/
export class ShapeHandleOverlayUtil extends OverlayUtil<TLShapeHandleOverlay> {
static override type = 'shape_handle'
override options = { zIndex: 200, lineWidth: 1.5 }
override isActive(): boolean {
const editor = this.editor
if (editor.getIsReadonly() || editor.getInstanceState().isChangingStyle) return false
const onlySelectedShape = editor.getOnlySelectedShape()
if (!onlySelectedShape) return false
const handles = editor.getShapeHandles(onlySelectedShape)
if (!handles) return false
// Not shown in select.pointing_shape: hiding handles on pointer down
// matches the arrow binding hint, which also waits for pointer up.
if (editor.isInAny('select.idle', 'select.pointing_handle')) {
return true
}
if (editor.isIn('select.editing_shape')) {
return editor.isShapeOfType(onlySelectedShape, 'note')
}
return false
}
override getOverlays(): TLShapeHandleOverlay[] {
const editor = this.editor
const onlySelectedShape = editor.getOnlySelectedShape()
if (!onlySelectedShape) return []
const handles = editor.getShapeHandles(onlySelectedShape)
if (!handles) return []
if (editor.isShapeHidden(onlySelectedShape)) return []
const zoom = editor.getZoomLevel()
const isCoarse = editor.getInstanceState().isCoarsePointer
const minDist =
((isCoarse ? editor.options.coarseHandleRadius : editor.options.handleRadius) / zoom) * 2
const vertexHandles = handles.filter((handle) => handle.type === 'vertex')
const vertexHandlesForHitTest: TLHandle[] = []
const otherHandlesForHitTest: TLHandle[] = []
// Vertex handles come first so they win hit-testing against overlapping
// virtual/create handles (e.g., a line's midpoint create handle that
// coincides with its endpoint vertices when a segment is very short).
// `render` iterates this array in reverse so the painted order puts
// vertex handles on top visually, matching the main branch's paint
// order where vertex handles were sorted last.
for (const handle of handles) {
if (
handle.type === 'virtual' &&
vertexHandles.some((vertexHandle) => Vec.Dist(handle, vertexHandle) < minDist)
) {
continue
}
if (handle.type === 'vertex') {
vertexHandlesForHitTest.push(handle)
} else {
otherHandlesForHitTest.push(handle)
}
}
return vertexHandlesForHitTest.concat(otherHandlesForHitTest).map((handle) => ({
id: `handle:${onlySelectedShape.id}:${handle.id}`,
type: 'shape_handle',
props: {
shapeId: onlySelectedShape.id,
handle,
},
}))
}
override getGeometry(overlay: TLShapeHandleOverlay): Geometry2d | null {
const editor = this.editor
const { shapeId, handle } = overlay.props
const transform = editor.getShapePageTransform(shapeId)
if (!transform) return null
const zoom = editor.getZoomLevel()
const isCoarse = editor.getInstanceState().isCoarsePointer
const radius =
(isCoarse ? editor.options.coarseHandleRadius : editor.options.handleRadius) / zoom
// Transform handle position to page space
const pagePoint = Mat.applyToPoint(transform, { x: handle.x, y: handle.y })
return new Circle2d({
x: pagePoint.x - radius,
y: pagePoint.y - radius,
radius,
isFilled: true,
})
}
override getCursor(_overlay: TLShapeHandleOverlay): TLCursorType | undefined {
return 'grab' as TLCursorType
}
override render(ctx: CanvasRenderingContext2D, overlays: TLShapeHandleOverlay[]): void {
if (overlays.length === 0) return
const editor = this.editor
const shapeId = overlays[0].props.shapeId
const transform = editor.getShapePageTransform(shapeId)
if (!transform) return
const zoom = editor.getZoomLevel()
const isCoarse = editor.getInstanceState().isCoarsePointer
const themeColors = editor.getCurrentTheme().colors[editor.getColorMode()]
const fgColor = themeColors.selectedContrast
const strokeColor = themeColors.selectionStroke
const bgFill = themeColors.selectionFill
const hoveredOverlayId = editor.overlays.getHoveredOverlayId()
const bgRadius =
(isCoarse ? editor.options.coarseHandleRadius : editor.options.handleRadius) / zoom
ctx.save()
ctx.transform(transform.a, transform.b, transform.c, transform.d, transform.e, transform.f)
ctx.strokeStyle = strokeColor
ctx.lineWidth = this.options.lineWidth / zoom
// Iterate in reverse: vertex handles come first in the array (for hit-
// test priority) but should paint last so they visually sit on top of
// overlapping virtual/create handles.
for (let i = overlays.length - 1; i >= 0; i--) {
const overlay = overlays[i]
const { handle } = overlay.props
const isHovered = overlay.id === hoveredOverlayId
// 'create' handles are invisible on fine pointers and revealed on
// hover (matches the old .tl-handle__create opacity rules).
if (handle.type === 'create' && !isCoarse && !isHovered) continue
// Hover halo — matches the old .tl-handle__bg:hover fill rule.
if (isHovered) {
ctx.fillStyle = bgFill
ctx.beginPath()
ctx.arc(handle.x, handle.y, bgRadius, 0, Math.PI * 2)
ctx.fill()
}
if (handle.type === 'clone') {
// Note clone handles render as a side-facing half circle.
const fr = 3 / zoom
const sideIndex = SIDES.indexOf(handle.id as (typeof SIDES)[number])
const rotation = (-Math.PI / 2) * (1 - sideIndex)
ctx.save()
ctx.translate(handle.x, handle.y)
ctx.rotate(rotation)
ctx.fillStyle = strokeColor
ctx.beginPath()
ctx.moveTo(0, -fr)
ctx.arc(0, 0, fr, -Math.PI / 2, Math.PI / 2)
ctx.closePath()
ctx.fill()
ctx.restore()
continue
}
// Match the old DefaultHandle sizing: create handles on coarse
// pointers are slightly smaller (3px) since they're always visible;
// on fine pointers they render at 4px when revealed by hover so
// they match the vertex handles. Clamp the zoom divisor at 0.25 so
// the visible circle stops growing past 25% zoom and visually
// shrinks as the user zooms further out — the hit-area halo above
// still tracks the full zoom so handles remain grabbable.
const fr = (handle.type === 'create' && isCoarse ? 3 : 4) / Math.max(zoom, 0.25)
ctx.fillStyle = fgColor
ctx.beginPath()
ctx.arc(handle.x, handle.y, fr, 0, Math.PI * 2)
ctx.fill()
ctx.stroke()
}
ctx.restore()
}
}

View File

@@ -0,0 +1,133 @@
import { OverlayUtil, strokeShapeIndicators, TLOverlay } from '@tldraw/editor'
import { computed } from '@tldraw/state'
import { TLShapeId } from '@tldraw/tlschema'
/** @public */
export interface TLShapeIndicatorOverlay extends TLOverlay {
props: {
idsToDisplay: TLShapeId[]
hintingShapeIds: TLShapeId[]
}
}
interface RelevantInstanceFlags {
isChangingStyle: boolean
isHoveringCanvas: boolean | null
isCoarsePointer: boolean
}
/**
* Overlay util for shape indicators — the selection / hover / hint outlines drawn
* under the selection foreground. Paints local indicators in the theme's
* selection color.
*
* Remote collaborator selection indicators are drawn by a separate overlay util
* (e.g. `CollaboratorShapeIndicatorOverlayUtil` from `tldraw`) that runs at a
* lower z-index so peer selections appear under the local indicators.
*
* Non-interactive: contributes no hit-test geometry.
*
* @public
*/
export class ShapeIndicatorOverlayUtil extends OverlayUtil<TLShapeIndicatorOverlay> {
static override type = 'shape_indicator'
override options = { zIndex: 50, lineWidth: 1.5, hintedLineWidth: 2.5 }
// Narrow projection of instance state. Reading the full record would
// re-fire getOverlays on every cursor move / brush update; gating on these
// three booleans means we only re-fire when one of them actually flips.
private _instanceFlags$ = computed<RelevantInstanceFlags>(
'shape indicator instance flags',
() => {
const i = this.editor.getInstanceState()
return {
isChangingStyle: i.isChangingStyle,
isHoveringCanvas: i.isHoveringCanvas,
isCoarsePointer: i.isCoarsePointer,
}
},
{
isEqual: (a, b) =>
a.isChangingStyle === b.isChangingStyle &&
a.isHoveringCanvas === b.isHoveringCanvas &&
a.isCoarsePointer === b.isCoarsePointer,
}
)
override isActive(): boolean {
return true
}
override getOverlays(): TLShapeIndicatorOverlay[] {
const editor = this.editor
const renderingShapeIds = new Set(editor.getRenderingShapes().map((s) => s.id))
// Local selected / hovered indicators.
const idsToDisplay: TLShapeId[] = []
const { isChangingStyle, isHoveringCanvas, isCoarsePointer } = this._instanceFlags$.get()
const isIdleOrEditing = editor.isInAny('select.idle', 'select.editing_shape')
const isInSelectState = editor.isInAny(
'select.brushing',
'select.scribble_brushing',
'select.pointing_shape',
'select.pointing_selection',
'select.pointing_handle',
'select.pointing_resize_handle',
'select.pointing_rotate_handle'
)
if (!isChangingStyle && (isIdleOrEditing || isInSelectState)) {
for (const id of editor.getSelectedShapeIds()) {
if (renderingShapeIds.has(id)) idsToDisplay.push(id)
}
if (isIdleOrEditing && isHoveringCanvas && !isCoarsePointer) {
const hovered = editor.getHoveredShapeId()
if (hovered && renderingShapeIds.has(hovered) && !idsToDisplay.includes(hovered)) {
idsToDisplay.push(hovered)
}
}
}
// Hinted shapes (drawn thicker). Already deduped at write time in
// `updateHintingShapeIds`, so no need to dedupe again here.
const hintingShapeIds: TLShapeId[] = []
for (const id of editor.getHintingShapeIds()) {
if (renderingShapeIds.has(id)) hintingShapeIds.push(id)
}
if (idsToDisplay.length === 0 && hintingShapeIds.length === 0) {
return []
}
return [
{
id: 'shape_indicator',
type: 'shape_indicator',
props: { idsToDisplay, hintingShapeIds },
},
]
}
override render(ctx: CanvasRenderingContext2D, overlays: TLShapeIndicatorOverlay[]): void {
const overlay = overlays[0]
if (!overlay) return
const editor = this.editor
const zoom = editor.getZoomLevel()
const { idsToDisplay, hintingShapeIds } = overlay.props
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
// Local selected / hovered indicators — one stroke call for the whole batch.
ctx.strokeStyle = editor.getCurrentTheme().colors[editor.getColorMode()].selectionStroke
ctx.lineWidth = this.options.lineWidth / zoom
strokeShapeIndicators(editor, ctx, idsToDisplay)
// Hinted shapes — thicker stroke, one call for the whole batch.
if (hintingShapeIds.length > 0) {
ctx.lineWidth = this.options.hintedLineWidth / zoom
strokeShapeIndicators(editor, ctx, hintingShapeIds)
}
}
}

View File

@@ -0,0 +1,195 @@
import {
GapsSnapIndicator,
OverlayUtil,
PointsSnapIndicator,
SnapIndicator,
TLOverlay,
rangeIntersection,
} from '@tldraw/editor'
/** @public */
export interface TLSnapIndicatorOverlay extends TLOverlay {
props: {
line: SnapIndicator
}
}
/**
* Overlay util for snap alignment indicators (point snap lines and gap indicators).
*
* @public
*/
export class SnapIndicatorOverlayUtil extends OverlayUtil<TLSnapIndicatorOverlay> {
static override type = 'snap_indicator'
override options = { zIndex: 500, lineWidth: 1 }
override isActive(): boolean {
return this.editor.snaps.getIndicators().length > 0
}
override getOverlays(): TLSnapIndicatorOverlay[] {
return this.editor.snaps.getIndicators().map((line) => ({
id: `snap:${line.id}`,
type: 'snap_indicator',
props: { line },
}))
}
override render(ctx: CanvasRenderingContext2D, overlays: TLSnapIndicatorOverlay[]): void {
const zoom = this.editor.getZoomLevel()
const snapColor = this.editor.getCurrentTheme().colors[this.editor.getColorMode()].snap
for (const overlay of overlays) {
const { line } = overlay.props
if (line.type === 'points') {
this._renderPoints(ctx, line, zoom, snapColor)
} else if (line.type === 'gaps') {
this._renderGaps(ctx, line, zoom, snapColor)
}
}
}
private _renderPoints(
ctx: CanvasRenderingContext2D,
indicator: PointsSnapIndicator,
zoom: number,
color: string
): void {
const { points } = indicator
if (points.length === 0) return
const l = 2.5 / zoom
let minX = Infinity
let maxX = -Infinity
let minY = Infinity
let maxY = -Infinity
for (const point of points) {
if (point.x < minX) minX = point.x
if (point.x > maxX) maxX = point.x
if (point.y < minY) minY = point.y
if (point.y > maxY) maxY = point.y
}
let useNWtoSEdirection = false
for (const point of points) {
if (point.x === minX && point.y === minY) {
useNWtoSEdirection = true
break
}
}
let firstX: number, firstY: number, secondX: number, secondY: number
if (useNWtoSEdirection) {
firstX = minX
firstY = minY
secondX = maxX
secondY = maxY
} else {
firstX = minX
firstY = maxY
secondX = maxX
secondY = minY
}
ctx.strokeStyle = color
ctx.lineWidth = this.options.lineWidth / zoom
// Main snap line
ctx.beginPath()
ctx.moveTo(firstX, firstY)
ctx.lineTo(secondX, secondY)
ctx.stroke()
// Batch-draw crosses for all points in a single stroke
ctx.beginPath()
for (const p of points) {
ctx.moveTo(p.x - l, p.y - l)
ctx.lineTo(p.x + l, p.y + l)
ctx.moveTo(p.x - l, p.y + l)
ctx.lineTo(p.x + l, p.y - l)
}
ctx.stroke()
}
private _renderGaps(
ctx: CanvasRenderingContext2D,
indicator: GapsSnapIndicator,
zoom: number,
color: string
): void {
const { gaps, direction } = indicator
if (gaps.length === 0) return
const l = 3.5 / zoom
const tickLength = 2 * l
const horizontal = direction === 'horizontal'
let edgeIntersection: number[] = [-Infinity, +Infinity]
let nextEdgeIntersection: number[] | null = null
for (const gap of gaps) {
nextEdgeIntersection = rangeIntersection(
edgeIntersection[0],
edgeIntersection[1],
horizontal ? gap.startEdge[0].y : gap.startEdge[0].x,
horizontal ? gap.startEdge[1].y : gap.startEdge[1].x
)
if (nextEdgeIntersection) {
edgeIntersection = nextEdgeIntersection
} else {
continue
}
nextEdgeIntersection = rangeIntersection(
edgeIntersection[0],
edgeIntersection[1],
horizontal ? gap.endEdge[0].y : gap.endEdge[0].x,
horizontal ? gap.endEdge[1].y : gap.endEdge[1].x
)
if (nextEdgeIntersection) {
edgeIntersection = nextEdgeIntersection
} else {
continue
}
}
const midPoint = (edgeIntersection[0] + edgeIntersection[1]) / 2
ctx.strokeStyle = color
ctx.lineWidth = this.options.lineWidth / zoom
// Batch all gap ticks/lines into a single path, then stroke once
ctx.beginPath()
for (const { startEdge, endEdge } of gaps) {
if (horizontal) {
// Start edge tick
ctx.moveTo(startEdge[0].x, midPoint - tickLength)
ctx.lineTo(startEdge[1].x, midPoint + tickLength)
// End edge tick
ctx.moveTo(endEdge[0].x, midPoint - tickLength)
ctx.lineTo(endEdge[1].x, midPoint + tickLength)
// Joining line
ctx.moveTo(startEdge[0].x, midPoint)
ctx.lineTo(endEdge[0].x, midPoint)
// Center tick
const cx = (startEdge[0].x + endEdge[0].x) / 2
ctx.moveTo(cx, midPoint - l)
ctx.lineTo(cx, midPoint + l)
} else {
// Start edge tick
ctx.moveTo(midPoint - tickLength, startEdge[0].y)
ctx.lineTo(midPoint + tickLength, startEdge[1].y)
// End edge tick
ctx.moveTo(midPoint - tickLength, endEdge[0].y)
ctx.lineTo(midPoint + tickLength, endEdge[1].y)
// Joining line
ctx.moveTo(midPoint, startEdge[0].y)
ctx.lineTo(midPoint, endEdge[0].y)
// Center tick
const cy = (startEdge[0].y + endEdge[0].y) / 2
ctx.moveTo(midPoint - l, cy)
ctx.lineTo(midPoint + l, cy)
}
}
ctx.stroke()
}
}

View File

@@ -0,0 +1,75 @@
import { OverlayUtil, TLOverlay } from '@tldraw/editor'
/** @public */
export interface TLZoomBrushOverlay extends TLOverlay {
props: {
x: number
y: number
w: number
h: number
}
}
/**
* Overlay util for the zoom brush rectangle.
*
* @public
*/
export class ZoomBrushOverlayUtil extends OverlayUtil<TLZoomBrushOverlay> {
static override type = 'zoom_brush'
override options = { zIndex: 400, lineWidth: 1 }
override isActive(): boolean {
return this.editor.getInstanceState().zoomBrush !== null
}
override getOverlays(): TLZoomBrushOverlay[] {
const brush = this.editor.getInstanceState().zoomBrush
if (!brush) return []
return [
{
id: 'zoom_brush',
type: 'zoom_brush',
props: {
x: brush.x,
y: brush.y,
w: Math.max(1, brush.w),
h: Math.max(1, brush.h),
},
},
]
}
override render(ctx: CanvasRenderingContext2D, overlays: TLZoomBrushOverlay[]): void {
const overlay = overlays[0]
if (!overlay) return
const { x, y, w, h } = overlay.props
const zoom = this.editor.getZoomLevel()
const colors = this.editor.getCurrentTheme().colors[this.editor.getColorMode()]
// Use fillRect / strokeRect to avoid path construction overhead
ctx.fillStyle = colors.brushFill
ctx.fillRect(x, y, w, h)
ctx.lineWidth = this.options.lineWidth / zoom
ctx.strokeStyle = colors.brushStroke
ctx.strokeRect(x, y, w, h)
}
override renderMinimap(
ctx: CanvasRenderingContext2D,
overlays: TLZoomBrushOverlay[],
zoom: number
): void {
const overlay = overlays[0]
if (!overlay) return
const { x, y, w, h } = overlay.props
const colors = this.editor.getCurrentTheme().colors[this.editor.getColorMode()]
ctx.fillStyle = colors.brushFill
ctx.fillRect(x, y, w, h)
ctx.lineWidth = this.options.lineWidth / zoom
ctx.strokeStyle = colors.brushStroke
ctx.strokeRect(x, y, w, h)
}
}

View File

@@ -0,0 +1,73 @@
import { exhaustiveSwitchError, TLArrowShape } from '@tldraw/editor'
import { PathBuilder, PathBuilderOpts } from '../shared/PathBuilder'
import { TLArrowInfo } from './arrow-types'
import { getRouteHandlePath } from './elbow/getElbowArrowInfo'
export function getArrowBodyPathBuilder(info: TLArrowInfo): PathBuilder {
switch (info.type) {
case 'straight':
return new PathBuilder()
.moveTo(info.start.point.x, info.start.point.y, { offset: 0, roundness: 0 })
.lineTo(info.end.point.x, info.end.point.y, { offset: 0, roundness: 0 })
case 'arc':
return new PathBuilder()
.moveTo(info.start.point.x, info.start.point.y, { offset: 0, roundness: 0 })
.circularArcTo(
info.bodyArc.radius,
!!info.bodyArc.largeArcFlag,
!!info.bodyArc.sweepFlag,
info.end.point.x,
info.end.point.y,
{ offset: 0, roundness: 0 }
)
case 'elbow': {
const path = new PathBuilder()
path.moveTo(info.start.point.x, info.start.point.y, {
offset: 0,
})
for (let i = 1; i < info.route.points.length; i++) {
const point = info.route.points[i]
if (info.route.skipPointsWhenDrawing.has(point)) {
continue
}
path.lineTo(point.x, point.y, {
offset: i === info.route.points.length - 1 ? 0 : undefined,
})
}
return path
}
default:
exhaustiveSwitchError(info, 'type')
}
}
export function getArrowBodyPath(shape: TLArrowShape, info: TLArrowInfo, opts: PathBuilderOpts) {
return getArrowBodyPathBuilder(info).toSvg(opts)
}
export function getArrowHandlePath(info: TLArrowInfo, opts: PathBuilderOpts) {
switch (info.type) {
case 'straight':
return new PathBuilder()
.moveTo(info.start.handle.x, info.start.handle.y)
.lineTo(info.end.handle.x, info.end.handle.y)
.toSvg(opts)
case 'arc':
return new PathBuilder()
.moveTo(info.start.handle.x, info.start.handle.y)
.circularArcTo(
info.handleArc.radius,
!!info.handleArc.largeArcFlag,
!!info.handleArc.sweepFlag,
info.end.handle.x,
info.end.handle.y
)
.toSvg(opts)
case 'elbow': {
const handleRoute = getRouteHandlePath(info.elbow, info.route)
return PathBuilder.lineThroughPoints(handleRoute.points).toSvg(opts)
}
default:
exhaustiveSwitchError(info, 'type')
}
}

View File

@@ -0,0 +1,305 @@
import { TLArrowShape, createShapeId } from '@tldraw/editor'
import { vi } from 'vitest'
import { TestEditor } from '../../../test/TestEditor'
import { ArrowShapeUtil } from './ArrowShapeUtil'
import { updateArrowTargetState } from './arrowTargetState'
import { getArrowBindings } from './shared'
let editor: TestEditor
const ids = {
box1: createShapeId('box1'),
box2: createShapeId('box2'),
arrow1: createShapeId('arrow1'),
}
vi.useFakeTimers()
window.requestAnimationFrame = function requestAnimationFrame(cb) {
return setTimeout(cb, 1000 / 60)
}
window.cancelAnimationFrame = function cancelAnimationFrame(id) {
clearTimeout(id)
}
function arrow(id = ids.arrow1) {
return editor.getShape(id) as TLArrowShape
}
function _bindings(id = ids.arrow1) {
return getArrowBindings(editor, arrow(id))
}
beforeEach(() => {
editor = new TestEditor()
editor
.selectAll()
.deleteShapes(editor.getSelectedShapeIds())
.createShapes([
{ id: ids.box1, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } },
{ id: ids.box2, type: 'geo', x: 300, y: 300, props: { w: 100, h: 100 } },
])
})
describe('ArrowShapeOptions', () => {
describe('Default options', () => {
it('should have correct default shouldBeExact behavior (alt key)', () => {
const util = editor.getShapeUtil<ArrowShapeUtil>('arrow')
// Test without alt key, not precise
editor.inputs.setAltKey(false)
expect(util.options.shouldBeExact(editor, false)).toBe(false)
// Test without alt key, precise
editor.inputs.setAltKey(false)
expect(util.options.shouldBeExact(editor, true)).toBe(false)
// Test with alt key, not precise
editor.inputs.setAltKey(true)
expect(util.options.shouldBeExact(editor, false)).toBe(true)
// Test with alt key, precise
editor.inputs.setAltKey(true)
expect(util.options.shouldBeExact(editor, true)).toBe(true)
})
it('should have correct default shouldIgnoreTargets behavior (ctrl key)', () => {
const util = editor.getShapeUtil<ArrowShapeUtil>('arrow')
// Test without ctrl key
editor.inputs.setCtrlKey(false)
expect(util.options.shouldIgnoreTargets(editor)).toBe(false)
// Test with ctrl key
editor.inputs.setCtrlKey(true)
expect(util.options.shouldIgnoreTargets(editor)).toBe(true)
})
})
describe('shouldIgnoreTargets option', () => {
it('should work with updateArrowTargetState function', () => {
// Test that updateArrowTargetState returns null when shouldIgnoreTargets is true
editor.inputs.setCtrlKey(true)
const targetState = updateArrowTargetState({
editor,
pointInPageSpace: { x: 150, y: 150 }, // Center of box1
arrow: undefined,
isPrecise: false,
currentBinding: undefined,
oppositeBinding: undefined,
})
expect(targetState).toBeNull()
})
it('should return valid target state when shouldIgnoreTargets is false', () => {
editor.inputs.setCtrlKey(false)
const targetState = updateArrowTargetState({
editor,
pointInPageSpace: { x: 150, y: 150 }, // Center of box1
arrow: undefined,
isPrecise: false,
currentBinding: undefined,
oppositeBinding: undefined,
})
expect(targetState).toBeDefined()
expect(targetState?.target.id).toBe(ids.box1)
})
})
describe('shouldBeExact option', () => {
it('should affect arrow targeting behavior when true', () => {
editor.inputs.setAltKey(true) // shouldBeExact = true
editor.inputs.setCtrlKey(false) // shouldIgnoreTargets = false
const targetState = updateArrowTargetState({
editor,
pointInPageSpace: { x: 150, y: 150 }, // Center of box1
arrow: undefined,
isPrecise: true,
currentBinding: undefined,
oppositeBinding: undefined,
})
expect(targetState).toBeDefined()
expect(targetState?.isExact).toBe(true)
})
it('should affect arrow targeting behavior when false', () => {
editor.inputs.setAltKey(false) // shouldBeExact = false
editor.inputs.setCtrlKey(false) // shouldIgnoreTargets = false
const targetState = updateArrowTargetState({
editor,
pointInPageSpace: { x: 150, y: 150 }, // Center of box1
arrow: undefined,
isPrecise: true,
currentBinding: undefined,
oppositeBinding: undefined,
})
expect(targetState).toBeDefined()
expect(targetState?.isExact).toBe(false)
})
})
describe('Custom options', () => {
it('should allow customizing shouldBeExact behavior', () => {
// Create a custom ArrowShapeUtil with different shouldBeExact behavior
const baseUtil = editor.getShapeUtil<ArrowShapeUtil>('arrow')
class CustomArrowShapeUtil extends ArrowShapeUtil {
override options = {
...baseUtil.options,
shouldBeExact: (editor: any, _isPrecise: boolean) => editor.inputs.getShiftKey(), // Use shift instead of alt
}
}
const customUtil = new CustomArrowShapeUtil(editor)
// Test with shift key
editor.inputs.setShiftKey(true)
editor.inputs.setAltKey(false)
expect(customUtil.options.shouldBeExact(editor, false)).toBe(true)
expect(customUtil.options.shouldBeExact(editor, true)).toBe(true)
// Test without shift key
editor.inputs.setShiftKey(false)
editor.inputs.setAltKey(true) // Alt key should not matter for custom implementation
expect(customUtil.options.shouldBeExact(editor, false)).toBe(false)
expect(customUtil.options.shouldBeExact(editor, true)).toBe(false)
})
it('should allow customizing shouldIgnoreTargets behavior', () => {
// Create a custom ArrowShapeUtil with different shouldIgnoreTargets behavior
const baseUtil = editor.getShapeUtil<ArrowShapeUtil>('arrow')
class CustomArrowShapeUtil extends ArrowShapeUtil {
override options = {
...baseUtil.options,
shouldIgnoreTargets: (editor: any) => editor.inputs.getShiftKey(), // Use shift instead of ctrl
}
}
const customUtil = new CustomArrowShapeUtil(editor)
// Test with shift key
editor.inputs.setShiftKey(true)
editor.inputs.setCtrlKey(false)
expect(customUtil.options.shouldIgnoreTargets(editor)).toBe(true)
// Test without shift key
editor.inputs.setShiftKey(false)
editor.inputs.setCtrlKey(true) // Ctrl key should not matter for custom implementation
expect(customUtil.options.shouldIgnoreTargets(editor)).toBe(false)
})
it('should allow completely custom logic for options', () => {
// Create a custom ArrowShapeUtil with complex custom logic
const baseUtil = editor.getShapeUtil<ArrowShapeUtil>('arrow')
class CustomArrowShapeUtil extends ArrowShapeUtil {
override options = {
...baseUtil.options,
shouldBeExact: (editor: any, isPrecise: boolean) => {
// Custom logic: exact when both alt and shift are pressed, and only if precise
return editor.inputs.getAltKey() && editor.inputs.getShiftKey() && isPrecise
},
shouldIgnoreTargets: (editor: any) => {
// Custom logic: ignore targets when any modifier key is pressed
return (
editor.inputs.getAltKey() || editor.inputs.getCtrlKey() || editor.inputs.getShiftKey()
)
},
}
}
const customUtil = new CustomArrowShapeUtil(editor)
// Test shouldBeExact with both keys and precise
editor.inputs.setAltKey(true)
editor.inputs.setShiftKey(true)
expect(customUtil.options.shouldBeExact(editor, true)).toBe(true)
// Test shouldBeExact with both keys but not precise
editor.inputs.setAltKey(true)
editor.inputs.setShiftKey(true)
expect(customUtil.options.shouldBeExact(editor, false)).toBe(false)
// Test shouldBeExact with only one key
editor.inputs.setAltKey(true)
editor.inputs.setShiftKey(false)
expect(customUtil.options.shouldBeExact(editor, true)).toBe(false)
// Test shouldIgnoreTargets with any key
editor.inputs.setAltKey(false)
editor.inputs.setCtrlKey(false)
editor.inputs.setShiftKey(true)
expect(customUtil.options.shouldIgnoreTargets(editor)).toBe(true)
// Test shouldIgnoreTargets with no keys
editor.inputs.setAltKey(false)
editor.inputs.setCtrlKey(false)
editor.inputs.setShiftKey(false)
expect(customUtil.options.shouldIgnoreTargets(editor)).toBe(false)
})
})
describe('Integration with arrow tool states', () => {
it('should allow custom shouldBeExact logic based on isPrecise - example from arrow precise-exact', () => {
// This replicates the logic from the arrows-precise-exact example
const baseUtil = editor.getShapeUtil<ArrowShapeUtil>('arrow')
class ExampleArrowShapeUtil extends ArrowShapeUtil {
override options = {
...baseUtil.options,
shouldBeExact: (_editor: any, isPrecise: boolean) => isPrecise,
}
}
// Replace the util temporarily for testing
const customUtil = new ExampleArrowShapeUtil(editor)
const originalShouldBeExact = baseUtil.options.shouldBeExact
baseUtil.options.shouldBeExact = customUtil.options.shouldBeExact
try {
editor.setCurrentTool('arrow')
editor.inputs.setCtrlKey(false) // Allow binding
// Set up fast pointer velocity to ensure precise remains false
editor.inputs.setPointerVelocity({ x: 2, y: 2, len: () => 2.8 } as any)
const targetState = updateArrowTargetState({
editor,
pointInPageSpace: { x: 150, y: 150 },
arrow: undefined,
isPrecise: true, // Input precise
currentBinding: undefined,
oppositeBinding: undefined,
})
// With the custom logic, precise arrows should be exact
expect(targetState?.isExact).toBe(true)
expect(targetState?.isPrecise).toBe(true)
// Test with non-precise movement (and fast velocity to avoid auto-precise)
const nonPreciseTargetState = updateArrowTargetState({
editor,
pointInPageSpace: { x: 150, y: 150 },
arrow: undefined,
isPrecise: false, // Not precise
currentBinding: undefined,
oppositeBinding: undefined,
})
// Non-precise arrows should not be exact with this custom logic,
// but they might still become precise due to internal logic
// The key test is that shouldBeExact gets the final computed precise value
expect(nonPreciseTargetState).toBeDefined()
} finally {
// Restore original function
baseUtil.options.shouldBeExact = originalShouldBeExact
}
})
})
})

View File

@@ -0,0 +1,807 @@
import { IndexKey, TLArrowShape, TLShapeId, Vec, createShapeId } from '@tldraw/editor'
import { vi } from 'vitest'
import { TestEditor } from '../../../test/TestEditor'
import { defaultShapeUtils } from '../../defaultShapeUtils'
import { ArrowShapeUtil } from './ArrowShapeUtil'
import { getArrowTargetState } from './arrowTargetState'
import { getArrowBindings } from './shared'
let editor: TestEditor
global.requestAnimationFrame = function requestAnimationFrame(cb) {
return setTimeout(cb, 1000 / 60)
}
global.cancelAnimationFrame = function cancelAnimationFrame(id) {
clearTimeout(id)
}
vi.useFakeTimers()
const ids = {
box1: createShapeId('box1'),
box2: createShapeId('box2'),
box3: createShapeId('box3'),
arrow1: createShapeId('arrow1'),
}
function bindings(id: TLShapeId) {
return getArrowBindings(editor, editor.getShape(id) as TLArrowShape)
}
function init(opts?: ConstructorParameters<typeof TestEditor>[0]) {
editor = new TestEditor(opts)
editor
.selectAll()
.deleteShapes(editor.getSelectedShapeIds())
.createShapes([
{ id: ids.box1, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } },
{ id: ids.box2, type: 'geo', x: 300, y: 300, props: { w: 100, h: 100 } },
{ id: ids.box3, type: 'geo', x: 350, y: 350, props: { w: 50, h: 50 } }, // overlapping box2, but smaller!
])
}
beforeEach(init)
it('enters the arrow state', () => {
editor.setCurrentTool('arrow')
expect(editor.getCurrentToolId()).toBe('arrow')
editor.expectToBeIn('arrow.idle')
})
describe('When in the idle state', () => {
it('enters the pointing state and creates a shape on pointer down', () => {
const shapesBefore = editor.getCurrentPageShapes().length
editor.setCurrentTool('arrow').pointerDown(0, 0)
const shapesAfter = editor.getCurrentPageShapes().length
expect(shapesAfter).toBe(shapesBefore + 1)
editor.expectToBeIn('arrow.pointing')
})
it('returns to select on cancel', () => {
editor.setCurrentTool('arrow')
editor.cancel()
editor.expectToBeIn('select.idle')
})
})
describe('When in the pointing state', () => {
it('cancels on pointer up', () => {
const shapesBefore = editor.getCurrentPageShapes().length
editor.setCurrentTool('arrow').pointerDown(0, 0).pointerUp(0, 0)
const shapesAfter = editor.getCurrentPageShapes().length
expect(shapesAfter).toBe(shapesBefore)
expect(editor.getHintingShapeIds().length).toBe(0)
editor.expectToBeIn('arrow.idle')
})
it('bails on cancel', () => {
const shapesBefore = editor.getCurrentPageShapes().length
editor.setCurrentTool('arrow').pointerDown(0, 0).cancel()
const shapesAfter = editor.getCurrentPageShapes().length
expect(shapesAfter).toBe(shapesBefore)
expect(editor.getHintingShapeIds().length).toBe(0)
editor.expectToBeIn('arrow.idle')
})
it('enters the dragging state on pointer move', () => {
editor.setCurrentTool('arrow').pointerDown(0, 0).pointerMove(10, 10)
editor.expectToBeIn('select.dragging_handle')
})
it('removes the pressed arrow when switching tools before dragging', () => {
const shapesBefore = editor.getCurrentPageShapes().length
editor.setCurrentTool('arrow').pointerDown(0, 0)
expect(editor.getCurrentPageShapes().length).toBe(shapesBefore + 1)
editor.setCurrentTool('geo')
expect(editor.getCurrentPageShapes().length).toBe(shapesBefore)
editor.expectToBeIn('geo.idle')
})
it('removes the pressed arrow when switching to the select tool before dragging', () => {
const shapesBefore = editor.getCurrentPageShapes().length
editor.setCurrentTool('arrow').pointerDown(0, 0)
editor.setCurrentTool('select')
expect(editor.getCurrentPageShapes().length).toBe(shapesBefore)
editor.expectToBeIn('select.idle')
})
})
// This could be moved to dragging_handle
describe('When dragging the arrow', () => {
it('updates the arrow on pointer move', () => {
editor.setCurrentTool('arrow').pointerDown(0, 0).pointerMove(10, 10)
const arrow = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
editor.expectShapeToMatch(arrow, {
id: arrow.id,
type: 'arrow',
x: 0,
y: 0,
props: {
start: { x: 0, y: 0 },
end: { x: 10, y: 10 },
},
})
expect(bindings(arrow.id)).toMatchObject({ start: undefined, end: undefined })
editor.expectToBeIn('select.dragging_handle')
})
it('returns to select.idle, keeping shape, on pointer up', () => {
const shapesBefore = editor.getCurrentPageShapes().length
editor.setCurrentTool('arrow').pointerDown(0, 0).pointerMove(10, 10).pointerUp(10, 10)
const shapesAfter = editor.getCurrentPageShapes().length
expect(shapesAfter).toBe(shapesBefore + 1)
expect(editor.getHintingShapeIds().length).toBe(0)
editor.expectToBeIn('select.idle')
})
it('returns to arrow.idle, keeping shape, on pointer up when tool lock is active', () => {
editor.updateInstanceState({ isToolLocked: true })
const shapesBefore = editor.getCurrentPageShapes().length
editor.setCurrentTool('arrow').pointerDown(0, 0).pointerMove(10, 10).pointerUp(10, 10)
const shapesAfter = editor.getCurrentPageShapes().length
expect(shapesAfter).toBe(shapesBefore + 1)
expect(editor.getHintingShapeIds().length).toBe(0)
editor.expectToBeIn('arrow.idle')
})
it('bails on cancel', () => {
const shapesBefore = editor.getCurrentPageShapes().length
editor.setCurrentTool('arrow').pointerDown(0, 0).pointerMove(10, 10).cancel()
const shapesAfter = editor.getCurrentPageShapes().length
expect(shapesAfter).toBe(shapesBefore)
editor.expectToBeIn('arrow.idle')
})
})
describe('When double-clicking an arrow handle', () => {
function createArrow() {
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
editor.createShapes([
{
id: ids.arrow1,
type: 'arrow',
x: 0,
y: 0,
props: {
start: { x: 0, y: 0 },
end: { x: 100, y: 100 },
arrowheadEnd: 'arrow',
},
},
])
editor.select(ids.arrow1)
return editor.getShape(ids.arrow1) as TLArrowShape
}
function getEndHandle() {
return { id: 'end', type: 'vertex' as const, index: 'a0' as IndexKey, x: 100, y: 100 }
}
function getEndHandleInfo() {
return {
target: 'handle' as const,
shape: editor.getShape(ids.arrow1) as TLArrowShape,
handle: getEndHandle(),
}
}
beforeEach(() => {
editor._transformPointerDownSpy.mockRestore()
editor._transformPointerUpSpy.mockRestore()
})
it('toggles the arrowhead on the second pointer up', () => {
createArrow()
editor.pointerDown(100, 100, getEndHandleInfo()).pointerUp(100, 100, getEndHandleInfo())
expect((editor.getShape(ids.arrow1) as TLArrowShape).props.arrowheadEnd).toBe('arrow')
editor.pointerDown(100, 100, getEndHandleInfo())
expect((editor.getShape(ids.arrow1) as TLArrowShape).props.arrowheadEnd).toBe('arrow')
editor.expectToBeIn('select.pointing_handle')
editor.pointerUp(100, 100, getEndHandleInfo())
expect((editor.getShape(ids.arrow1) as TLArrowShape).props.arrowheadEnd).toBe('none')
})
it('starts a drag instead of toggling when the second click moves', () => {
createArrow()
editor.pointerDown(100, 100, getEndHandleInfo()).pointerUp(100, 100, getEndHandleInfo())
editor.pointerDown(100, 100, getEndHandleInfo())
expect((editor.getShape(ids.arrow1) as TLArrowShape).props.arrowheadEnd).toBe('arrow')
editor.pointerMove(140, 140)
editor.expectToBeIn('select.dragging_handle')
expect((editor.getShape(ids.arrow1) as TLArrowShape).props.arrowheadEnd).toBe('arrow')
})
})
describe('When pointing a start shape', () => {
it('binds to the top shape', () => {
editor.setCurrentTool('arrow').pointerDown(375, 375)
// Set hinting ids when moving away
expect(getArrowTargetState(editor)).not.toBeNull()
// Fake some velocity
editor.inputs.setPointerVelocity(new Vec(1, 1))
editor.pointerMove(375, 500)
// Clear hinting ids when moving away
expect(getArrowTargetState(editor)).toBeNull()
const arrow = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
editor.expectShapeToMatch(arrow, {
id: arrow.id,
type: 'arrow',
x: 375,
y: 375,
props: {
start: { x: 0, y: 0 },
end: { x: 0, y: 125 },
},
})
expect(bindings(arrow.id)).toMatchObject({
start: {
toId: ids.box3,
props: {
isExact: false,
normalizedAnchor: { x: 0.5, y: 0.5 }, // center!
isPrecise: false,
},
},
end: undefined,
})
editor.pointerUp()
// Clear hinting ids on pointer up
expect(editor.getHintingShapeIds().length).toBe(0)
})
})
describe('When pointing an end shape', () => {
it('binds to the top shape', () => {
editor.setCurrentTool('arrow')
editor.pointerDown(0, 0)
expect(editor.getHintingShapeIds().length).toBe(0)
// Fake some velocity
editor.inputs.setPointerVelocity(new Vec(1, 1))
// Move onto shape
editor.pointerMove(375, 375)
// Set hinting id when pointing the shape
expect(getArrowTargetState(editor)).not.toBeNull()
const arrow = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
editor.expectShapeToMatch(arrow, {
id: arrow.id,
type: 'arrow',
x: 0,
y: 0,
props: {
start: { x: 0, y: 0 },
},
})
expect(bindings(arrow.id)).toMatchObject({
start: undefined,
end: {
toId: ids.box3,
props: {
isExact: false,
normalizedAnchor: { x: 0.5, y: 0.5 }, // center!
isPrecise: false,
},
},
})
// Clear hinting ids on pointer up
editor.pointerUp()
expect(getArrowTargetState(editor)).toBeNull()
})
it('unbinds and rebinds', () => {
editor.setCurrentTool('arrow').pointerDown(0, 0)
editor.inputs.setPointerVelocity(new Vec(1, 1))
editor.pointerMove(375, 375)
let arrow = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
expect(getArrowTargetState(editor)).not.toBeNull()
expect(bindings(arrow.id)).toMatchObject({
start: undefined,
end: {
toId: ids.box3,
props: {
isExact: false,
normalizedAnchor: { x: 0.5, y: 0.5 },
isPrecise: false,
},
},
})
vi.advanceTimersByTime(1000)
arrow = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
expect(bindings(arrow.id)).toMatchObject({
start: undefined,
end: {
toId: ids.box3,
props: {
isExact: false,
normalizedAnchor: { x: 0.5, y: 0.5 },
isPrecise: true,
},
},
})
editor.pointerMove(375, 0)
expect(getArrowTargetState(editor)).toBeNull()
arrow = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
editor.expectShapeToMatch(arrow, {
id: arrow.id,
type: 'arrow',
x: 0,
y: 0,
props: {
start: { x: 0, y: 0 },
end: { x: 375, y: 0 },
},
})
expect(bindings(arrow.id)).toMatchObject({
start: undefined,
end: undefined,
})
// Build up some velocity
editor.inputs.setPointerVelocity(new Vec(1, 1))
editor.pointerMove(325, 325)
expect(getArrowTargetState(editor)).not.toBeNull()
arrow = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
editor.expectShapeToMatch(arrow, {
id: arrow.id,
type: 'arrow',
x: 0,
y: 0,
props: {
start: { x: 0, y: 0 },
},
})
expect(bindings(arrow.id)).toMatchObject({
start: undefined,
end: {
toId: ids.box2,
props: {
isExact: false,
normalizedAnchor: { x: 0.5, y: 0.5 }, // center!
isPrecise: false,
},
},
})
// Give time for the velocity to die down
vi.advanceTimersByTime(1000)
arrow = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
expect(bindings(arrow.id)).toMatchObject({
start: undefined,
end: {
toId: ids.box2,
props: {
isExact: false,
normalizedAnchor: { x: 0.25, y: 0.25 }, // precise!
isPrecise: true,
},
},
})
editor.pointerUp()
expect(getArrowTargetState(editor)).toBeNull()
})
it('respects shouldIgnoreTargets option when ctrl key is held', () => {
editor.setCurrentTool('arrow')
// Test without ctrl key - should bind normally
editor.pointerDown(0, 0)
editor.pointerMove(375, 375) // Move to box3
let arrow = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
expect(bindings(arrow.id)).toMatchObject({
end: {
toId: ids.box3,
},
})
editor.pointerUp()
editor.setCurrentTool('arrow')
// Test with ctrl key - should not bind
editor.keyDown('Control')
editor.pointerDown(0, 0)
editor.pointerMove(375, 375) // Move to box3
arrow = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
expect(bindings(arrow.id)).toMatchObject({
end: undefined,
})
editor.pointerUp()
editor.keyUp('Control')
})
it('respects shouldBeExact option when alt key is held', () => {
editor.setCurrentTool('arrow')
// Test without alt key - should not be exact
editor.pointerDown(0, 0)
editor.pointerMove(375, 375) // Move to box3
let arrow = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
expect(bindings(arrow.id)).toMatchObject({
end: {
toId: ids.box3,
props: {
isExact: false,
},
},
})
editor.pointerUp()
editor.setCurrentTool('arrow')
// Test with alt key - should be exact
editor.keyDown('Alt')
editor.pointerDown(0, 0)
editor.pointerMove(375, 375) // Move to box3
arrow = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
expect(bindings(arrow.id)).toMatchObject({
end: {
toId: ids.box3,
props: {
isExact: true,
},
},
})
editor.pointerUp()
editor.keyUp('Alt')
})
it('begins imprecise when moving quickly', () => {
editor.setCurrentTool('arrow').pointerDown(0, 0)
editor.inputs.setPointerVelocity(new Vec(1, 1))
editor.pointerMove(370, 370)
const arrow = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
expect(getArrowTargetState(editor)).not.toBeNull()
expect(bindings(arrow.id)).toMatchObject({
start: undefined,
end: {
toId: ids.box3,
props: {
isExact: false,
normalizedAnchor: { x: 0.5, y: 0.5 },
isPrecise: false,
},
},
})
})
it('begins precise when moving slowly', () => {
editor.setCurrentTool('arrow').pointerDown(0, 0)
let arrow = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
expect(bindings(arrow.id)).toMatchObject({
start: undefined,
end: undefined,
})
expect(getArrowTargetState(editor)).toBeNull()
editor.inputs.setPointerVelocity(new Vec(0.001, 0.001))
editor.pointerMove(375, 375)
arrow = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
expect(getArrowTargetState(editor)).not.toBeNull()
editor.expectShapeToMatch(arrow, {
id: arrow.id,
type: 'arrow',
x: 0,
y: 0,
})
expect(bindings(arrow.id)).toMatchObject({
start: undefined,
end: {
toId: ids.box3,
props: {
isExact: false,
normalizedAnchor: { x: 0.5, y: 0.5 },
isPrecise: true,
},
},
})
})
})
describe('reparenting issue', () => {
it('Correctly sets index when reparenting', () => {
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
// Create an arrow!
editor.setCurrentTool('arrow')
editor.pointerDown(0, 0)
editor.pointerMove(100, 100)
editor.pointerUp()
const arrowId = editor.getCurrentPageShapesSorted()[0].id
// Now create three shapes
editor.createShapes([
{ id: ids.box1, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } },
{ id: ids.box2, type: 'geo', x: 300, y: 300, props: { w: 100, h: 100 } },
{ id: ids.box3, type: 'geo', x: 350, y: 350, props: { w: 90, h: 90 } }, // overlapping box2
])
editor.expectShapeToMatch({ id: ids.box1, index: 'a2' as IndexKey })
editor.expectShapeToMatch({ id: ids.box2, index: 'a3' as IndexKey })
editor.expectShapeToMatch({ id: ids.box3, index: 'a4' as IndexKey })
editor.select(arrowId)
editor.pointerDown(100, 100, {
target: 'handle',
handle: { id: 'end', type: 'vertex', index: 'a0' as IndexKey, x: 100, y: 100 },
shape: editor.getShape(arrowId)!,
})
editor.expectToBeIn('select.pointing_handle')
editor.pointerMove(320, 320) // over box 2
editor.expectToBeIn('select.dragging_handle')
editor.expectShapeToMatch({
id: arrowId,
index: 'a3V' as IndexKey,
}) // between box 2 (a3) and 3 (a4)
expect(bindings(arrowId)).toMatchObject({ end: { toId: ids.box2 } })
expect(editor.getShapeAtPoint({ x: 350, y: 350 }, { hitInside: true })).toMatchObject({
id: ids.box3,
})
editor.pointerMove(350, 350) // over box 3 and box 2, but box 3 is smaller
editor.expectShapeToMatch({
id: arrowId,
index: 'a5' as IndexKey,
}) // above box 3 (a4)
expect(bindings(arrowId)).toMatchObject({ end: { toId: ids.box3 } })
editor.pointerMove(150, 150) // over box 1
editor.expectShapeToMatch({ id: arrowId, index: 'a2V' as IndexKey }) // between box 1 (a2) and box 3 (a3)
editor.pointerMove(-100, -100) // over the page
editor.expectShapeToMatch({ id: arrowId, index: 'a2V' as IndexKey }) // no change needed, keep whatever we had before
// todo: should the arrow go back to where it was before?
})
it('Correctly sets index when reparenting with multiple arrows', () => {
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
// create two rectangles:
editor.createShapes([
{ id: ids.box1, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } },
{ id: ids.box2, type: 'geo', x: 300, y: 300, props: { w: 100, h: 100 } },
])
// create some arrows:
const arrow1Id = createShapeId('arrow1')
const arrow2Id = createShapeId('arrow2')
editor.createShapes([
{
id: arrow1Id,
type: 'arrow',
x: 0,
y: 0,
props: { start: { x: 0, y: 0 }, end: { x: 100, y: 100 } },
},
{
id: arrow2Id,
type: 'arrow',
x: 0,
y: 0,
props: { start: { x: 0, y: 0 }, end: { x: 100, y: 100 } },
},
])
// bind arrows to boxes:
editor
.select(arrow1Id)
.pointerDown(100, 100, {
target: 'handle',
handle: { id: 'end', type: 'vertex', index: 'a0' as IndexKey, x: 100, y: 100 },
shape: editor.getShape(arrow1Id)!,
})
.pointerMove(120, 120)
.pointerUp()
.select(arrow2Id)
.pointerDown(100, 100, {
target: 'handle',
handle: { id: 'end', type: 'vertex', index: 'a0' as IndexKey, x: 100, y: 100 },
shape: editor.getShape(arrow2Id)!,
})
.pointerMove(150, 150)
const arrow1BoundIndex = editor.getShape(arrow1Id)!.index
const arrow2BoundIndex = editor.getShape(arrow2Id)!.index
expect(arrow1BoundIndex).toBe('a1V')
expect(arrow2BoundIndex).toBe('a1G')
// nudge everything around and make sure we all stay in the right order
editor.selectAll().nudgeShapes(editor.getSelectedShapeIds(), { x: -1, y: 0 })
expect(editor.getShape(arrow1Id)!.index).toBe('a1V')
expect(editor.getShape(arrow2Id)!.index).toBe('a1G')
})
})
describe('precision timeout configuration', () => {
it('uses a timeout when dragging arrow handles', () => {
// Create an arrow first
editor.setCurrentTool('arrow').pointerDown(0, 0)
// Use high velocity to avoid precise mode immediately
editor.inputs.setPointerVelocity(new Vec(1, 1))
editor.pointerMove(100, 100)
const arrow = editor.getCurrentPageShapes()[
editor.getCurrentPageShapes().length - 1
] as TLArrowShape
editor.expectToBeIn('select.dragging_handle')
expect(bindings(arrow.id)).toMatchObject({
end: {
toId: ids.box1,
props: {
isPrecise: false,
},
},
})
vi.advanceTimersByTime(1000)
expect(bindings(arrow.id)).toMatchObject({
end: {
toId: ids.box1,
props: {
isPrecise: true,
},
},
})
})
it('allows configuring the pointingPreciseTimeout', () => {
init({
shapeUtils: [
...defaultShapeUtils.map((s) =>
s.type === 'arrow' ? ArrowShapeUtil.configure({ pointingPreciseTimeout: 2000 }) : s
),
],
})
// Create an arrow first
editor.setCurrentTool('arrow').pointerDown(0, 0)
// Use high velocity to avoid precise mode immediately
editor.inputs.setPointerVelocity(new Vec(1, 1))
editor.pointerMove(100, 100)
const arrow = editor.getCurrentPageShapes()[
editor.getCurrentPageShapes().length - 1
] as TLArrowShape
editor.expectToBeIn('select.dragging_handle')
expect(bindings(arrow.id)).toMatchObject({
end: {
toId: ids.box1,
props: {
isPrecise: false,
},
},
})
vi.advanceTimersByTime(1000)
expect(bindings(arrow.id)).toMatchObject({
end: {
toId: ids.box1,
props: {
isPrecise: false,
},
},
})
vi.advanceTimersByTime(1000)
expect(bindings(arrow.id)).toMatchObject({
end: {
toId: ids.box1,
props: {
isPrecise: true,
},
},
})
})
})
describe('line bug', () => {
it('works as expected when binding to a straight line', () => {
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
expect(editor.getCurrentPageShapes().length).toBe(0)
editor
.setCurrentTool('line')
.keyDown('Shift')
.pointerMove(0, 0)
.pointerDown()
.pointerMove(0, 100)
.pointerUp()
.keyUp('Shift')
.setCurrentTool('arrow')
.keyDown('Shift')
.pointerMove(50, 50)
.pointerDown()
.pointerMove(0, 50)
.pointerUp()
.keyUp('Shift')
expect(editor.getCurrentPageShapes().length).toBe(2)
const bindings = getArrowBindings(editor, editor.getCurrentPageShapes()[1] as TLArrowShape)
expect(bindings.end).toBeDefined()
})
it('works as expected when binding to a straight horizontal line', () => {
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
expect(editor.getCurrentPageShapes().length).toBe(0)
editor
.setCurrentTool('line')
.pointerMove(0, 0)
.pointerDown()
.pointerMove(0, 100)
.pointerUp()
.setCurrentTool('arrow')
.pointerMove(50, 50)
.pointerDown()
.pointerMove(0, 50)
.pointerUp()
expect(editor.getCurrentPageShapes().length).toBe(2)
const bindings = getArrowBindings(editor, editor.getCurrentPageShapes()[1] as TLArrowShape)
expect(bindings.end).toBeDefined()
})
})

View File

@@ -0,0 +1,14 @@
import { StateNode, TLStateNodeConstructor } from '@tldraw/editor'
import { Idle } from './toolStates/Idle'
import { Pointing } from './toolStates/Pointing'
/** @public */
export class ArrowShapeTool extends StateNode {
static override id = 'arrow'
static override initial = 'idle'
static override children(): TLStateNodeConstructor[] {
return [Idle, Pointing]
}
override shapeType = 'arrow'
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,178 @@
import {
Editor,
TLArrowShape,
TLArrowShapeArrowheadStyle,
TLDefaultSizeStyle,
VecLike,
} from '@tldraw/editor'
import { ShapeOptionsWithDisplayValues } from '../shared/getDisplayValues'
import { ElbowArrowInfo, ElbowArrowRoute } from './elbow/definitions'
import { TLArrowBindings } from './shared'
/** @public */
export interface ArrowShapeUtilDisplayValues {
strokeColor: string
strokeWidth: number
fillColor: string
patternFillFallbackColor: string
labelColor: string
labelFontFamily: string
labelFontSize: number
labelLineHeight: number
labelPadding: number
labelBorderRadius: number
}
/**
* Options for the arrow shape.
*
* @example
* ```tsx
* const shapeUtils = [
* ArrowShapeUtil.configure({ arcArrowCenterSnapDistance: 0 }),
* ]
*
* function MyApp() {
* return <Tldraw shapeUtils={shapeUtils} />
* }
* ```
*
* @public
*/
export interface ArrowShapeOptions extends ShapeOptionsWithDisplayValues<
TLArrowShape,
ArrowShapeUtilDisplayValues
> {
/**
* How far should elbow arrows expand from the shapes they're targeting?
*/
readonly expandElbowLegLength: Record<TLDefaultSizeStyle, number>
/**
* The minimum length of an elbow arrow's leg.
*/
readonly minElbowLegLength: Record<TLDefaultSizeStyle, number>
/**
* The minimum distance, in screen pixels, between two handles on an elbow arrow. If two handles
* would be closer than this distance, they're both hidden.
*/
readonly minElbowHandleDistance: number
/**
* The distance, in screen pixels, at which we snap to the center of a target shape when drawing
* an arc arrow. Set to 0 to disable.
*/
readonly arcArrowCenterSnapDistance: number
/**
* The distance, in screen pixels, at which we snap to the center of a target shape when drawing
* an elbow arrow. Set to 0 to disable.
*/
readonly elbowArrowCenterSnapDistance: number
/**
* The distance, in screen pixels, at which we snap to the edge of a target shape when drawing
* an elbow arrow. Set to 0 to disable.
*/
readonly elbowArrowEdgeSnapDistance: number
/**
* The distance, in screen pixels, at which we snap to one of the 4 directional points (top,
* right, bottom, left) of a target shape when drawing an elbow arrow. Set to 0 to disable.
*/
readonly elbowArrowPointSnapDistance: number
/**
* The distance, in screen pixels, at which we snap to the axis passing through the center of a
* shape when drawing an elbow arrow. Set to 0 to disable.
*/
readonly elbowArrowAxisSnapDistance: number
/**
* The distance, in screen pixels, at which we snap an arrow label to center of the arrow when
* dragging it. Set to 0 to disable.
*/
readonly labelCenterSnapDistance: number
/**
* The distance, in screen pixels, at which we snap an elbow arrow midpoint handle to the
* midpoint between two shapes. Set to 0 to disable.
*/
readonly elbowMidpointSnapDistance: number
/**
* The minimum length, in screen pixels, of an elbow arrows midpoint segment before we show the
* handle for dragging that segment.
*/
readonly elbowMinSegmentLengthToShowMidpointHandle: number
/**
* When hovering over a shape using the arrow tool, how long should we wait before we assume the
* user is targeting precisely instead of imprecisely.
*/
readonly hoverPreciseTimeout: number
/**
* When pointing at a shape using the arrow tool or dragging an arrow terminal handle, how long
* should we wait before we assume the user is targeting precisely instead of imprecisely.
*/
readonly pointingPreciseTimeout: number
/**
* When creating an arrow, should it stop exactly at the pointer, or should
* it stop at the edge of the target shape.
*/
shouldBeExact(editor: Editor, isPrecise: boolean): boolean
/**
* When creating an arrow, should it bind to the target shape.
*/
shouldIgnoreTargets(editor: Editor): boolean
/** Whether to show the outline of the arrow shape's label (using the same color as the canvas). This helps with overlapping shapes. It does not show up on Safari, where text outline is a performance issues. */
readonly showTextOutline: boolean
}
/** @public */
export interface TLArrowPoint {
handle: VecLike
point: VecLike
arrowhead: TLArrowShapeArrowheadStyle
}
/** @public */
export interface TLArcInfo {
center: VecLike
radius: number
size: number
length: number
largeArcFlag: number
sweepFlag: number
}
/** @public */
export interface TLArcArrowInfo {
bindings: TLArrowBindings
type: 'arc'
start: TLArrowPoint
end: TLArrowPoint
middle: VecLike
handleArc: TLArcInfo
bodyArc: TLArcInfo
isValid: boolean
}
/** @public */
export interface TLStraightArrowInfo {
bindings: TLArrowBindings
type: 'straight'
start: TLArrowPoint
end: TLArrowPoint
middle: VecLike
isValid: boolean
length: number
}
/** @public */
export interface TLElbowArrowInfo {
type: 'elbow'
bindings: TLArrowBindings
start: TLArrowPoint
end: TLArrowPoint
elbow: ElbowArrowInfo
route: ElbowArrowRoute
isValid: boolean
}
/** @public */
export type TLArrowInfo = TLArcArrowInfo | TLStraightArrowInfo | TLElbowArrowInfo

View File

@@ -0,0 +1,324 @@
import {
Arc2d,
Box,
Circle2d,
Edge2d,
Editor,
Geometry2d,
Group2d,
Polygon2d,
Polyline2d,
TLArrowShape,
TLShape,
TLTheme,
Vec,
VecLike,
clamp,
createComputedCache,
exhaustiveSwitchError,
getChangedKeys,
pointInPolygon,
toRichText,
} from '@tldraw/editor'
import { isEmptyRichText, renderHtmlFromRichTextForMeasurement } from '../../utils/text/richText'
import { LABEL_TO_ARROW_PADDING, STROKE_SIZES, TEXT_PROPS } from '../shared/default-shape-constants'
import { getDisplayValues } from '../shared/getDisplayValues'
import type { ArrowShapeUtilDisplayValues } from './arrow-types'
import { TLArrowInfo } from './arrow-types'
import { getArrowInfo } from './getArrowInfo'
function getArrowDisplayValues(editor: Editor, shape: TLArrowShape): ArrowShapeUtilDisplayValues {
// We cast here because editor.getShapeUtil('arrow') returns ShapeUtil<TLArrowShape>
// with generic options, but the actual instance has ArrowShapeOptions with display values.
return getDisplayValues(editor.getShapeUtil('arrow') as any, shape)
}
export function getArrowBodyGeometry(editor: Editor, shape: TLArrowShape) {
const info = getArrowInfo(editor, shape)!
switch (info.type) {
case 'straight':
return new Edge2d({
start: Vec.From(info.start.point),
end: Vec.From(info.end.point),
})
case 'arc':
return new Arc2d({
center: Vec.Cast(info.handleArc.center),
start: Vec.Cast(info.start.point),
end: Vec.Cast(info.end.point),
sweepFlag: info.bodyArc.sweepFlag,
largeArcFlag: info.bodyArc.largeArcFlag,
})
case 'elbow':
return new Polyline2d({ points: info.route.points })
default:
exhaustiveSwitchError(info, 'type')
}
}
const labelSizeCache = createComputedCache(
'arrow label size',
(editor: Editor, shape: TLArrowShape) => {
editor.fonts.trackFontsForShape(shape)
let width = 0
let height = 0
const bodyGeom = getArrowBodyGeometry(editor, shape)
// We use 'i' as a default label to measure against as a minimum width.
const isEmpty = isEmptyRichText(shape.props.richText)
const html = renderHtmlFromRichTextForMeasurement(
editor,
isEmpty ? toRichText('i') : shape.props.richText
)
const bodyBounds = bodyGeom.bounds
const dv = getArrowDisplayValues(editor, shape)
const fontSize = dv.labelFontSize * shape.props.scale
// First we measure the text with no constraints
const { w, h } = editor.textMeasure.measureHtml(html, {
...TEXT_PROPS,
lineHeight: dv.labelLineHeight,
fontFamily: dv.labelFontFamily,
fontSize,
maxWidth: null,
})
width = w
height = h
let shouldSquish = false
// If the text is wider than the body, we need to squish it
const info = getArrowInfo(editor, shape)!
const labelToArrowPadding = getLabelToArrowPadding(shape, editor.getCurrentTheme())
const margin =
info.type === 'elbow'
? Math.max(info.elbow.A.arrowheadOffset + labelToArrowPadding, 32) +
Math.max(info.elbow.B.arrowheadOffset + labelToArrowPadding, 32)
: 64
if (bodyBounds.width > bodyBounds.height) {
width = Math.max(Math.min(w, margin), Math.min(bodyBounds.width - margin, w))
shouldSquish = true
} else if (width > 16 * fontSize) {
width = 16 * fontSize
shouldSquish = true
}
if (shouldSquish) {
const { w: squishedWidth, h: squishedHeight } = editor.textMeasure.measureHtml(html, {
...TEXT_PROPS,
lineHeight: dv.labelLineHeight,
fontFamily: dv.labelFontFamily,
fontSize,
maxWidth: width,
})
width = squishedWidth
height = squishedHeight
}
return new Vec(width, height).addScalar(dv.labelPadding * 2 * shape.props.scale)
},
{
areRecordsEqual: (a, b) => {
if (a.props === b.props) return true
// If the only thing that has changed is the label position, we can skip recalculating the size
const changedKeys = getChangedKeys(a.props, b.props)
return changedKeys.length === 1 && changedKeys[0] === 'labelPosition'
},
}
)
function getArrowLabelSize(editor: Editor, shape: TLArrowShape) {
return labelSizeCache.get(editor, shape.id) ?? new Vec(0, 0)
}
function getLabelToArrowPadding(shape: TLArrowShape, theme: TLTheme) {
const strokeWidth = theme.strokeWidth * STROKE_SIZES[shape.props.size]
const smallStrokeWidth = theme.strokeWidth * STROKE_SIZES.s
const xlStrokeWidth = theme.strokeWidth * STROKE_SIZES.xl
const labelToArrowPadding =
(LABEL_TO_ARROW_PADDING +
(strokeWidth - smallStrokeWidth) * 2 +
(strokeWidth >= xlStrokeWidth ? 20 : 0)) *
shape.props.scale
return labelToArrowPadding
}
/**
* Return the range of possible label positions for an arrow. The full possible range is 0 to 1, but
* as the label itself takes up space the usable range is smaller.
*/
function getArrowLabelRange(editor: Editor, shape: TLArrowShape, info: TLArrowInfo) {
const bodyGeom = getArrowBodyGeometry(editor, shape)
const dbgPoints: VecLike[] = []
const dbg: Geometry2d[] = [new Group2d({ children: [bodyGeom], debugColor: 'lime' })]
const labelSize = getArrowLabelSize(editor, shape)
const labelToArrowPadding = getLabelToArrowPadding(shape, editor.getCurrentTheme())
const paddingRelative = labelToArrowPadding / bodyGeom.length
// we can calculate the range by sticking the center of the label at the very start/end of the
// arrow, and seeing where the label intersects with the arrow. Then, if we move the label's
// center to that point, that'll be the start/end of the range.
let startBox, endBox
if (info.type === 'elbow') {
// for elbow arrows, because they have multiple segments but are always axis-aligned, we can use
// an expanded box. This helps keep the box from partially covering the first segment when it's
// very small.
dbgPoints.push(info.start.point, info.end.point)
startBox = Box.FromCenter(info.start.point, labelSize).expandBy(labelToArrowPadding)
endBox = Box.FromCenter(info.end.point, labelSize).expandBy(labelToArrowPadding)
} else {
// for other arrows, we move along the arrow by the padding amount to find the start/end points
const startPoint = bodyGeom.interpolateAlongEdge(paddingRelative)
const endPoint = bodyGeom.interpolateAlongEdge(1 - paddingRelative)
dbgPoints.push(startPoint, endPoint)
startBox = Box.FromCenter(startPoint, labelSize)
endBox = Box.FromCenter(endPoint, labelSize)
}
const startIntersections = bodyGeom.intersectPolygon(startBox.corners)
const endIntersections = bodyGeom.intersectPolygon(endBox.corners)
const startConstrained = furthest(info.start.point, startIntersections)
const endConstrained = furthest(info.end.point, endIntersections)
let startRelative = startConstrained ? bodyGeom.uninterpolateAlongEdge(startConstrained) : 0.5
let endRelative = endConstrained ? bodyGeom.uninterpolateAlongEdge(endConstrained) : 0.5
if (startRelative > endRelative) {
startRelative = 0.5
endRelative = 0.5
}
for (const pt of [...startIntersections, ...endIntersections, ...dbgPoints]) {
dbg.push(
new Circle2d({
x: pt.x - 3,
y: pt.y - 3,
radius: 3,
isFilled: false,
debugColor: 'magenta',
ignore: true,
})
)
}
dbg.push(
new Polygon2d({
points: startBox.corners,
debugColor: 'lime',
isFilled: false,
ignore: true,
}),
new Polygon2d({
points: endBox.corners,
debugColor: 'lime',
isFilled: false,
ignore: true,
})
)
return { start: startRelative, end: endRelative, dbg }
}
interface ArrowheadInfo {
hasStartBinding: boolean
hasEndBinding: boolean
hasStartArrowhead: boolean
hasEndArrowhead: boolean
}
export function getArrowLabelPosition(editor: Editor, shape: TLArrowShape, isEditing: boolean) {
if (!isEditing && isEmptyRichText(shape.props.richText)) {
// Short-circuit for empty labels.
const bodyGeom = getArrowBodyGeometry(editor, shape)
const labelCenter = bodyGeom.interpolateAlongEdge(0.5)
return { box: Box.FromCenter(labelCenter, new Vec(0, 0)), debugGeom: [] }
}
const debugGeom: Geometry2d[] = []
const info = getArrowInfo(editor, shape)!
const arrowheadInfo: ArrowheadInfo = {
hasStartBinding: !!info.bindings.start,
hasEndBinding: !!info.bindings.end,
hasStartArrowhead: info.start.arrowhead !== 'none',
hasEndArrowhead: info.end.arrowhead !== 'none',
}
const range = getArrowLabelRange(editor, shape, info)
if (range.dbg) debugGeom.push(...range.dbg)
const clampedPosition = getClampedPosition(shape, range, arrowheadInfo)
const bodyGeom = getArrowBodyGeometry(editor, shape)
const labelCenter = bodyGeom.interpolateAlongEdge(clampedPosition)
const labelSize = getArrowLabelSize(editor, shape)
return { box: Box.FromCenter(labelCenter, labelSize), debugGeom }
}
function getClampedPosition(
shape: TLArrowShape,
range: { start: number; end: number },
arrowheadInfo: ArrowheadInfo
) {
const { hasEndArrowhead, hasEndBinding, hasStartBinding, hasStartArrowhead } = arrowheadInfo
const clampedPosition = clamp(
shape.props.labelPosition,
hasStartArrowhead || hasStartBinding ? range.start : 0,
hasEndArrowhead || hasEndBinding ? range.end : 1
)
return clampedPosition
}
function furthest(from: VecLike, candidates: VecLike[]): VecLike | null {
let furthest: VecLike | null = null
let furthestDist = -Infinity
for (const candidate of candidates) {
const dist = Vec.Dist2(from, candidate)
if (dist > furthestDist) {
furthest = candidate
furthestDist = dist
}
}
return furthest
}
export function getArrowLabelDefaultPosition(editor: Editor, shape: TLArrowShape) {
const info = getArrowInfo(editor, shape)!
switch (info.type) {
case 'straight':
case 'arc':
return 0.5
case 'elbow': {
const midpointHandle = info.route.midpointHandle
const bodyGeom = getArrowBodyGeometry(editor, shape)
if (midpointHandle && bodyGeom) {
return bodyGeom.uninterpolateAlongEdge(midpointHandle.point)
}
return 0.5
}
default:
exhaustiveSwitchError(info, 'type')
}
}
/** @internal */
export function isOverArrowLabel(editor: Editor, shape: TLShape) {
if (!editor.isShapeOfType(shape, 'arrow')) return false
const pointInShapeSpace = editor.getPointInShapeSpace(shape, editor.inputs.getCurrentPagePoint())
// How should we handle multiple labels? Do shapes ever have multiple labels?
const labelGeometry = editor.getShapeGeometry<Group2d>(shape).children[1]
// Knowing what we know about arrows... if the shape has no text in its label,
// then the label geometry should not be there.
return labelGeometry && pointInPolygon(pointInShapeSpace, labelGeometry.vertices)
}

View File

@@ -0,0 +1,398 @@
import {
ArrowShapeKindStyle,
atom,
Atom,
Box,
clamp,
Editor,
ElbowArrowSnap,
Geometry2dFilters,
invLerp,
mapObjectMapValues,
objectMapEntries,
objectMapKeys,
TLArrowBinding,
TLArrowShape,
TLArrowShapeKind,
TLShape,
Vec,
VecLike,
WeakCache,
} from '@tldraw/editor'
import { ArrowShapeUtil } from './ArrowShapeUtil'
import {
ElbowArrowAxes,
ElbowArrowSide,
ElbowArrowSideAxes,
ElbowArrowSideDeltas,
} from './elbow/definitions'
/**
* Options passed to {@link updateArrowTargetState}.
*
* @public
*/
export interface UpdateArrowTargetStateOpts {
editor: Editor
pointInPageSpace: VecLike
arrow: TLArrowShape | undefined
isPrecise: boolean
currentBinding: TLArrowBinding | undefined
/** The binding from the opposite end of the arrow, if one exists. */
oppositeBinding: TLArrowBinding | undefined
}
/**
* State representing what we're pointing to when drawing or updating an arrow. You can get this
* state using {@link getArrowTargetState}, and update it as part of an arrow interaction with
* {@link updateArrowTargetState} or {@link clearArrowTargetState}.
*
* @public
*/
export interface ArrowTargetState {
target: TLShape
arrowKind: TLArrowShapeKind
handlesInPageSpace: {
top: { point: VecLike; isEnabled: boolean }
bottom: { point: VecLike; isEnabled: boolean }
left: { point: VecLike; isEnabled: boolean }
right: { point: VecLike; isEnabled: boolean }
}
isExact: boolean
isPrecise: boolean
centerInPageSpace: VecLike
anchorInPageSpace: VecLike
snap: ElbowArrowSnap
normalizedAnchor: VecLike
}
const arrowTargetStore = new WeakCache<Editor, Atom<ArrowTargetState | null>>()
function getArrowTargetAtom(editor: Editor) {
return arrowTargetStore.get(editor, () => atom('arrowTarget', null))
}
/**
* Get the current arrow target state for an editor. See {@link ArrowTargetState} for more
* information.
*
* @public
*/
export function getArrowTargetState(editor: Editor) {
return getArrowTargetAtom(editor).get()
}
/**
* Clear the current arrow target state for an editor. See {@link ArrowTargetState} for more
* information.
*
* @public
*/
export function clearArrowTargetState(editor: Editor) {
getArrowTargetAtom(editor).set(null)
}
/**
* Update the current arrow target state for an editor. See {@link ArrowTargetState} for more
* information.
*
* @public
*/
export function updateArrowTargetState({
editor,
pointInPageSpace,
arrow,
isPrecise,
currentBinding,
oppositeBinding,
}: UpdateArrowTargetStateOpts): ArrowTargetState | null {
const util = editor.getShapeUtil<ArrowShapeUtil>('arrow')
// no target picking when ctrl is held:
if (util.options.shouldIgnoreTargets(editor)) {
getArrowTargetAtom(editor).set(null)
return null
}
const arrowKind = arrow ? arrow.props.kind : editor.getStyleForNextShape(ArrowShapeKindStyle)
const target = editor.getShapeAtPoint(pointInPageSpace, {
hitInside: true,
hitFrameInside: true,
margin: arrowKind === 'elbow' ? 8 : [8, 0],
filter: (targetShape) => {
return (
!targetShape.isLocked &&
editor.canBindShapes({
fromShape: arrow ?? targetFilterFallback,
toShape: targetShape,
binding: 'arrow',
})
)
},
})
if (!target) {
getArrowTargetAtom(editor).set(null)
return null
}
const targetGeometryInTargetSpace = editor.getShapeGeometry(target)
const targetBoundsInTargetSpace = Box.ZeroFix(targetGeometryInTargetSpace.bounds)
const targetCenterInTargetSpace = targetGeometryInTargetSpace.center
const targetTransform = editor.getShapePageTransform(target)
const pointInTargetSpace = editor.getPointInShapeSpace(target, pointInPageSpace)
const castDistance = Math.max(
targetGeometryInTargetSpace.bounds.width,
targetGeometryInTargetSpace.bounds.height
)
const handlesInPageSpace = mapObjectMapValues(ElbowArrowSideDeltas, (side, delta) => {
const axis = ElbowArrowAxes[ElbowArrowSideAxes[side]]
const farPoint = Vec.Mul(delta, castDistance).add(targetCenterInTargetSpace)
let isEnabled = false
let handlePointInTargetSpace: VecLike = axis.v(
targetBoundsInTargetSpace[side],
targetBoundsInTargetSpace[axis.crossMid]
)
let furthestDistance = 0
const intersections = targetGeometryInTargetSpace.intersectLineSegment(
targetCenterInTargetSpace,
farPoint,
Geometry2dFilters.EXCLUDE_NON_STANDARD
)
for (const intersection of intersections) {
const distance = Vec.Dist2(intersection, targetCenterInTargetSpace)
if (distance > furthestDistance) {
furthestDistance = distance
handlePointInTargetSpace = intersection
isEnabled = targetGeometryInTargetSpace.isClosed
}
}
const handlePointInPageSpace = targetTransform.applyToPoint(handlePointInTargetSpace)
return { point: handlePointInPageSpace, isEnabled, far: targetTransform.applyToPoint(farPoint) }
})
const zoomLevel = editor.getZoomLevel()
const minDistScaled = util.options.minElbowHandleDistance / zoomLevel
const targetCenterInPageSpace = targetTransform.applyToPoint(targetCenterInTargetSpace)
for (const side of objectMapKeys(handlesInPageSpace)) {
const handle = handlesInPageSpace[side]
if (Vec.DistMin(handle.point, targetCenterInPageSpace, minDistScaled)) {
handle.isEnabled = false
}
}
let precise = isPrecise
if (!precise) {
// If we're switching to a new bound shape, then precise only if moving slowly
if (!currentBinding || (currentBinding && target.id !== currentBinding.toId)) {
precise = editor.inputs.getPointerVelocity().len() < 0.5
}
}
if (!isPrecise) {
if (!targetGeometryInTargetSpace.isClosed) {
precise = true
}
// Double check that we're not going to be doing an imprecise snap on
// the same shape twice, as this would result in a zero length line
if (oppositeBinding && target.id === oppositeBinding.toId && oppositeBinding.props.isPrecise) {
precise = true
}
}
const isExact = util.options.shouldBeExact(editor, precise)
if (isExact) precise = true
const shouldSnapCenter = !isExact && precise && targetGeometryInTargetSpace.isClosed
// const shouldSnapEdges = !isExact && (precise || !targetGeometryInTargetSpace.isClosed)
const shouldSnapEdges =
!isExact && ((precise && arrowKind === 'elbow') || !targetGeometryInTargetSpace.isClosed)
const shouldSnapEdgePoints =
!isExact && precise && arrowKind === 'elbow' && targetGeometryInTargetSpace.isClosed
const shouldSnapNone = precise && (targetGeometryInTargetSpace.isClosed || isExact)
const shouldSnapCenterAxis =
!isExact && precise && arrowKind === 'elbow' && targetGeometryInTargetSpace.isClosed
// we run through all the snapping options from least to most specific:
let snap: ElbowArrowSnap = 'none'
let anchorInPageSpace: VecLike = pointInPageSpace
if (!shouldSnapNone) {
snap = 'center'
anchorInPageSpace = targetCenterInPageSpace
}
if (shouldSnapEdges) {
const snapDistance = shouldSnapNone
? calculateSnapDistance(
editor,
targetBoundsInTargetSpace,
util.options.elbowArrowEdgeSnapDistance
)
: Infinity
const nearestPointOnEdgeInTargetSpace = targetGeometryInTargetSpace.nearestPoint(
pointInTargetSpace,
{
includeLabels: false,
includeInternal: false,
}
)
const nearestPointOnEdgeInPageSpace = targetTransform.applyToPoint(
nearestPointOnEdgeInTargetSpace
)
const distance = Vec.Dist(nearestPointOnEdgeInPageSpace, pointInPageSpace)
if (distance < snapDistance) {
snap = 'edge'
anchorInPageSpace = nearestPointOnEdgeInPageSpace
}
}
if (shouldSnapCenterAxis) {
const snapDistance = calculateSnapDistance(
editor,
targetBoundsInTargetSpace,
util.options.elbowArrowAxisSnapDistance
)
const distanceFromXAxis = Vec.DistanceToLineSegment(
handlesInPageSpace.left.far,
handlesInPageSpace.right.far,
pointInPageSpace
)
const distanceFromYAxis = Vec.DistanceToLineSegment(
handlesInPageSpace.top.far,
handlesInPageSpace.bottom.far,
pointInPageSpace
)
const snapAxis =
distanceFromXAxis < distanceFromYAxis && distanceFromXAxis < snapDistance
? 'x'
: distanceFromYAxis < snapDistance
? 'y'
: null
if (snapAxis) {
const axis = ElbowArrowAxes[snapAxis]
const loDist2 = Vec.Dist2(handlesInPageSpace[axis.loEdge].far, pointInPageSpace)
const hiDist2 = Vec.Dist2(handlesInPageSpace[axis.hiEdge].far, pointInPageSpace)
const side = loDist2 < hiDist2 ? axis.loEdge : axis.hiEdge
if (handlesInPageSpace[side].isEnabled) {
snap = 'edge-point'
anchorInPageSpace = handlesInPageSpace[side].point
}
}
}
if (shouldSnapEdgePoints) {
const snapDistance = calculateSnapDistance(
editor,
targetBoundsInTargetSpace,
util.options.elbowArrowPointSnapDistance
)
let closestSide: ElbowArrowSide | null = null
let closestDistance = Infinity
for (const [side, handle] of objectMapEntries(handlesInPageSpace)) {
if (!handle.isEnabled) continue
const distance = Vec.Dist(handle.point, pointInPageSpace)
if (distance < snapDistance && distance < closestDistance) {
closestDistance = distance
closestSide = side
}
}
if (closestSide) {
snap = 'edge-point'
anchorInPageSpace = handlesInPageSpace[closestSide].point
}
}
if (shouldSnapCenter) {
const snapDistance = calculateSnapDistance(
editor,
targetBoundsInTargetSpace,
arrowKind === 'elbow'
? util.options.elbowArrowCenterSnapDistance
: util.options.arcArrowCenterSnapDistance
)
if (Vec.Dist(pointInTargetSpace, targetBoundsInTargetSpace.center) < snapDistance) {
snap = 'center'
anchorInPageSpace = targetCenterInPageSpace
}
}
const snapPointInTargetSpace = editor.getPointInShapeSpace(target, anchorInPageSpace)
const normalizedAnchor = {
x: invLerp(
targetBoundsInTargetSpace.minX,
targetBoundsInTargetSpace.maxX,
snapPointInTargetSpace.x
),
y: invLerp(
targetBoundsInTargetSpace.minY,
targetBoundsInTargetSpace.maxY,
snapPointInTargetSpace.y
),
}
const result: ArrowTargetState = {
target,
arrowKind,
handlesInPageSpace,
centerInPageSpace: targetCenterInPageSpace,
anchorInPageSpace,
isExact,
isPrecise: precise,
snap,
normalizedAnchor,
}
getArrowTargetAtom(editor).set(result)
return result
}
const targetFilterFallback = { type: 'arrow' as const }
/**
* Funky math but we want the snap distance to be 4 at the minimum and either 16 or 15% of the
* smaller dimension of the target shape, whichever is smaller
*/
function calculateSnapDistance(
editor: Editor,
targetBoundsInTargetSpace: Box,
idealSnapDistance: number
) {
return (
clamp(
Math.min(targetBoundsInTargetSpace.width, targetBoundsInTargetSpace.height) * 0.15,
4,
idealSnapDistance
) / editor.getZoomLevel()
)
}

View File

@@ -0,0 +1,163 @@
import {
HALF_PI,
PI,
Vec,
VecLike,
clamp,
exhaustiveSwitchError,
intersectCircleCircle,
} from '@tldraw/editor'
import { TLArrowInfo } from './arrow-types'
interface TLArrowPointsInfo {
point: VecLike
int: VecLike
}
function getArrowPoints(
info: TLArrowInfo,
side: 'start' | 'end',
strokeWidth: number
): TLArrowPointsInfo {
const point = side === 'end' ? info.end.point : info.start.point
let int: VecLike
switch (info.type) {
case 'straight': {
const opposite = side === 'end' ? info.start.point : info.end.point
const compareLength = Vec.Dist(opposite, point)
const length = clamp(compareLength / 5, strokeWidth, strokeWidth * 3)
int = Vec.Nudge(point, opposite, length)
break
}
case 'arc': {
const compareLength = Math.abs(info.bodyArc.length)
const length = clamp(compareLength / 5, strokeWidth, strokeWidth * 3)
const intersections = intersectCircleCircle(
point,
length,
info.handleArc.center,
info.handleArc.radius
)
int =
side === 'end'
? info.handleArc.sweepFlag
? intersections[0]
: intersections[1]
: info.handleArc.sweepFlag
? intersections[1]
: intersections[0]
break
}
case 'elbow': {
const previousPoint =
side === 'end' ? info.route.points[info.route.points.length - 2] : info.route.points[1]
const previousSegmentLength = Vec.ManhattanDist(previousPoint, point)
const length = clamp(previousSegmentLength / 2, strokeWidth, strokeWidth * 3)
int = previousPoint ? Vec.Nudge(point, previousPoint, length) : point
break
}
default:
exhaustiveSwitchError(info, 'type')
}
if (Vec.IsNaN(int)) {
int = point
}
return { point, int }
}
function getArrowhead({ point, int }: TLArrowPointsInfo) {
const PL = Vec.RotWith(int, point, PI / 6)
const PR = Vec.RotWith(int, point, -PI / 6)
return `M ${PL.x} ${PL.y} L ${point.x} ${point.y} L ${PR.x} ${PR.y}`
}
function getTriangleHead({ point, int }: TLArrowPointsInfo) {
const PL = Vec.RotWith(int, point, PI / 6)
const PR = Vec.RotWith(int, point, -PI / 6)
return `M ${PL.x} ${PL.y} L ${PR.x} ${PR.y} L ${point.x} ${point.y} Z`
}
function getInvertedTriangleHead({ point, int }: TLArrowPointsInfo) {
const d = Vec.Sub(int, point).div(2)
const PL = Vec.Add(point, Vec.Rot(d, HALF_PI))
const PR = Vec.Sub(point, Vec.Rot(d, HALF_PI))
return `M ${PL.x} ${PL.y} L ${int.x} ${int.y} L ${PR.x} ${PR.y} Z`
}
function getDotHead({ point, int }: TLArrowPointsInfo) {
const A = Vec.Lrp(point, int, 0.45)
const r = Vec.Dist(A, point)
return `M ${A.x - r},${A.y}
a ${r},${r} 0 1,0 ${r * 2},0
a ${r},${r} 0 1,0 -${r * 2},0 `
}
function getDiamondHead({ point, int }: TLArrowPointsInfo) {
const PB = Vec.Lrp(point, int, 0.75)
const PL = Vec.RotWith(PB, point, PI / 4)
const PR = Vec.RotWith(PB, point, -PI / 4)
const PQ = Vec.Lrp(PL, PR, 0.5)
PQ.add(Vec.Sub(PQ, point))
return `M ${PQ.x} ${PQ.y} L ${PR.x} ${PR.y} ${point.x} ${point.y} L ${PL.x} ${PL.y} Z`
}
function getSquareHead({ int, point }: TLArrowPointsInfo) {
const PB = Vec.Lrp(point, int, 0.85)
const d = Vec.Sub(PB, point).div(2)
const PL1 = Vec.Add(point, Vec.Rot(d, HALF_PI))
const PR1 = Vec.Sub(point, Vec.Rot(d, HALF_PI))
const PL2 = Vec.Add(PB, Vec.Rot(d, HALF_PI))
const PR2 = Vec.Sub(PB, Vec.Rot(d, HALF_PI))
return `M ${PL1.x} ${PL1.y} L ${PL2.x} ${PL2.y} L ${PR2.x} ${PR2.y} L ${PR1.x} ${PR1.y} Z`
}
function getBarHead({ int, point }: TLArrowPointsInfo) {
const d = Vec.Sub(int, point).div(2)
const PL = Vec.Add(point, Vec.Rot(d, HALF_PI))
const PR = Vec.Sub(point, Vec.Rot(d, HALF_PI))
return `M ${PL.x} ${PL.y} L ${PR.x} ${PR.y}`
}
/** @public */
export function getArrowheadPathForType(
info: TLArrowInfo,
side: 'start' | 'end',
strokeWidth: number
): string | undefined {
const type = side === 'end' ? info.end.arrowhead : info.start.arrowhead
if (type === 'none') return
const points = getArrowPoints(info, side, strokeWidth)
if (!points) return
switch (type) {
case 'bar':
return getBarHead(points)
case 'square':
return getSquareHead(points)
case 'diamond':
return getDiamondHead(points)
case 'dot':
return getDotHead(points)
case 'inverted':
return getInvertedTriangleHead(points)
case 'arrow':
return getArrowhead(points)
case 'triangle':
return getTriangleHead(points)
}
return ''
}

View File

@@ -0,0 +1,484 @@
import {
Editor,
Mat,
PI,
PI2,
TLArrowShape,
Vec,
VecLike,
centerOfCircleFromThreePoints,
clockwiseAngleDist,
counterClockwiseAngleDist,
isSafeFloat,
} from '@tldraw/editor'
import { STROKE_SIZES } from '../shared/default-shape-constants'
import { TLArcInfo, TLArrowInfo } from './arrow-types'
import {
BOUND_ARROW_OFFSET,
MIN_ARROW_LENGTH,
TLArrowBindings,
WAY_TOO_BIG_ARROW_BEND_FACTOR,
clampArrowTerminalToMask,
getArrowTerminalsInArrowSpace,
getBoundShapeInfoForTerminal,
getBoundShapeRelationships,
} from './shared'
import { getStraightArrowInfo } from './straight-arrow'
export function getCurvedArrowInfo(
editor: Editor,
shape: TLArrowShape,
bindings: TLArrowBindings,
arrowStrokeWidth?: number
): TLArrowInfo {
const { arrowheadEnd, arrowheadStart } = shape.props
const bend = shape.props.bend
if (
Math.abs(bend) >
Math.abs(shape.props.bend * (WAY_TOO_BIG_ARROW_BEND_FACTOR * shape.props.scale))
) {
return getStraightArrowInfo(editor, shape, bindings, arrowStrokeWidth)
}
const terminalsInArrowSpace = getArrowTerminalsInArrowSpace(editor, shape, bindings)
const med = Vec.Med(terminalsInArrowSpace.start, terminalsInArrowSpace.end) // point between start and end
const distance = Vec.Sub(terminalsInArrowSpace.end, terminalsInArrowSpace.start)
// Check for divide-by-zero before we call uni()
const u = Vec.Len(distance) ? distance.uni() : Vec.From(distance) // unit vector between start and end
const middle = Vec.Add(med, u.per().mul(-bend)) // middle handle
const theme = editor.getCurrentTheme()
const arrowSW = arrowStrokeWidth ?? theme.strokeWidth * STROKE_SIZES[shape.props.size]
const startShapeInfo = getBoundShapeInfoForTerminal(editor, shape, 'start')
const endShapeInfo = getBoundShapeInfoForTerminal(editor, shape, 'end')
// The positions of the body of the arrow, which may be different
// than the arrow's start / end points if the arrow is bound to shapes
const a = terminalsInArrowSpace.start.clone()
const b = terminalsInArrowSpace.end.clone()
const c = middle.clone()
if (Vec.Equals(a, b)) {
return {
bindings,
type: 'straight',
start: {
handle: a,
point: a,
arrowhead: shape.props.arrowheadStart,
},
end: {
handle: b,
point: b,
arrowhead: shape.props.arrowheadEnd,
},
middle: c,
isValid: false,
length: 0,
}
}
const isClockwise = shape.props.bend < 0
const distFn = isClockwise ? clockwiseAngleDist : counterClockwiseAngleDist
const handleArc = getArcInfo(a, b, c)
const handle_aCA = Vec.Angle(handleArc.center, a)
const handle_aCB = Vec.Angle(handleArc.center, b)
const handle_dAB = distFn(handle_aCA, handle_aCB)
if (
handleArc.length === 0 ||
handleArc.size === 0 ||
!isSafeFloat(handleArc.length) ||
!isSafeFloat(handleArc.size)
) {
return getStraightArrowInfo(editor, shape, bindings, arrowStrokeWidth)
}
const tempA = a.clone()
const tempB = b.clone()
const tempC = c.clone()
const arrowPageTransform = editor.getShapePageTransform(shape)!
let offsetA = 0
let offsetB = 0
let minLength = MIN_ARROW_LENGTH * shape.props.scale
if (startShapeInfo && !startShapeInfo.isExact) {
const startInPageSpace = Mat.applyToPoint(arrowPageTransform, tempA)
const centerInPageSpace = Mat.applyToPoint(arrowPageTransform, handleArc.center)
const endInPageSpace = Mat.applyToPoint(arrowPageTransform, tempB)
const inverseTransform = Mat.Inverse(startShapeInfo.transform)
const startInStartShapeLocalSpace = Mat.applyToPoint(inverseTransform, startInPageSpace)
const centerInStartShapeLocalSpace = Mat.applyToPoint(inverseTransform, centerInPageSpace)
const endInStartShapeLocalSpace = Mat.applyToPoint(inverseTransform, endInPageSpace)
const { isClosed } = startShapeInfo
let point: VecLike | undefined
let intersections = Array.from(
startShapeInfo.geometry.intersectCircle(centerInStartShapeLocalSpace, handleArc.radius, {
includeLabels: false,
includeInternal: false,
})
)
if (intersections.length) {
const angleToStart = centerInStartShapeLocalSpace.angle(startInStartShapeLocalSpace)
const angleToEnd = centerInStartShapeLocalSpace.angle(endInStartShapeLocalSpace)
const dAB = distFn(angleToStart, angleToEnd)
// Filter out any intersections that aren't in the arc
intersections = intersections.filter(
(pt) => distFn(angleToStart, centerInStartShapeLocalSpace.angle(pt)) <= dAB
)
const targetDist = dAB * 0.25
intersections.sort(
isClosed
? (p0, p1) =>
Math.abs(distFn(angleToStart, centerInStartShapeLocalSpace.angle(p0)) - targetDist) <
Math.abs(distFn(angleToStart, centerInStartShapeLocalSpace.angle(p1)) - targetDist)
? -1
: 1
: (p0, p1) =>
distFn(angleToStart, centerInStartShapeLocalSpace.angle(p0)) <
distFn(angleToStart, centerInStartShapeLocalSpace.angle(p1))
? -1
: 1
)
point = intersections[0]
}
if (!point) {
if (isClosed) {
const nearestPoint = startShapeInfo.geometry.nearestPoint(startInStartShapeLocalSpace, {
includeInternal: false,
includeLabels: false,
})
if (Vec.DistMin(nearestPoint, startInStartShapeLocalSpace, 1)) {
point = nearestPoint
}
} else {
point = startInStartShapeLocalSpace
}
}
if (point) {
tempA.setTo(
editor.getPointInShapeSpace(shape, Mat.applyToPoint(startShapeInfo.transform, point))
)
startShapeInfo.didIntersect = true
if (arrowheadStart !== 'none') {
const strokeOffset =
arrowSW / 2 +
('size' in startShapeInfo.shape.props
? (theme.strokeWidth * (STROKE_SIZES[startShapeInfo.shape.props.size] ?? 0)) / 2
: 0)
offsetA = (BOUND_ARROW_OFFSET + strokeOffset) * shape.props.scale
minLength += strokeOffset * shape.props.scale
}
}
}
if (endShapeInfo && !endShapeInfo.isExact) {
// get points in shape's coordinates?
const startInPageSpace = Mat.applyToPoint(arrowPageTransform, tempA)
const endInPageSpace = Mat.applyToPoint(arrowPageTransform, tempB)
const centerInPageSpace = Mat.applyToPoint(arrowPageTransform, handleArc.center)
const inverseTransform = Mat.Inverse(endShapeInfo.transform)
const startInEndShapeLocalSpace = Mat.applyToPoint(inverseTransform, startInPageSpace)
const centerInEndShapeLocalSpace = Mat.applyToPoint(inverseTransform, centerInPageSpace)
const endInEndShapeLocalSpace = Mat.applyToPoint(inverseTransform, endInPageSpace)
const isClosed = endShapeInfo.isClosed
let point: VecLike | undefined
let intersections = Array.from(
endShapeInfo.geometry.intersectCircle(centerInEndShapeLocalSpace, handleArc.radius, {
includeLabels: false,
includeInternal: false,
})
)
if (intersections.length) {
const angleToStart = centerInEndShapeLocalSpace.angle(startInEndShapeLocalSpace)
const angleToEnd = centerInEndShapeLocalSpace.angle(endInEndShapeLocalSpace)
const dAB = distFn(angleToStart, angleToEnd)
const targetDist = dAB * 0.75
// or simplified...
intersections = intersections.filter(
(pt) => distFn(angleToStart, centerInEndShapeLocalSpace.angle(pt)) <= dAB
)
intersections.sort(
isClosed
? (p0, p1) =>
Math.abs(distFn(angleToStart, centerInEndShapeLocalSpace.angle(p0)) - targetDist) <
Math.abs(distFn(angleToStart, centerInEndShapeLocalSpace.angle(p1)) - targetDist)
? -1
: 1
: (p0, p1) =>
distFn(angleToStart, centerInEndShapeLocalSpace.angle(p0)) <
distFn(angleToStart, centerInEndShapeLocalSpace.angle(p1))
? -1
: 1
)
point = intersections[0]
}
if (!point) {
if (isClosed) {
const nearestPoint = endShapeInfo.geometry.nearestPoint(endInEndShapeLocalSpace, {
includeInternal: false,
includeLabels: false,
})
if (Vec.DistMin(nearestPoint, endInEndShapeLocalSpace, 1)) {
point = nearestPoint
}
} else {
point = endInEndShapeLocalSpace
}
}
if (point) {
// Set b to target local point -> page point -> shape local point
tempB.setTo(
editor.getPointInShapeSpace(shape, Mat.applyToPoint(endShapeInfo.transform, point))
)
endShapeInfo.didIntersect = true
if (arrowheadEnd !== 'none') {
const strokeOffset =
arrowSW / 2 +
('size' in endShapeInfo.shape.props
? (theme.strokeWidth * (STROKE_SIZES[endShapeInfo.shape.props.size] ?? 0)) / 2
: 0)
offsetB = (BOUND_ARROW_OFFSET + strokeOffset) * shape.props.scale
minLength += strokeOffset * shape.props.scale
}
}
}
// Clamp terminals to mask boundaries if the bound shape is clipped (e.g. by a frame)
clampArrowTerminalToMask(editor, tempA, a, arrowPageTransform, startShapeInfo)
clampArrowTerminalToMask(editor, tempB, b, arrowPageTransform, endShapeInfo)
// Apply arrowhead offsets
let aCA = Vec.Angle(handleArc.center, tempA) // angle center -> a
let aCB = Vec.Angle(handleArc.center, tempB) // angle center -> b
let dAB = distFn(aCA, aCB) // angle distance between a and b
let lAB = dAB * handleArc.radius // length of arc between a and b
// Try the offsets first, then check whether the distance between the points is too small;
// if it is, flip the offsets and expand them. We need to do this using temporary points
// so that we can apply them both in a balanced way.
const tA = tempA.clone()
const tB = tempB.clone()
if (offsetA !== 0) {
tA.setTo(handleArc.center).add(
Vec.FromAngle(aCA + dAB * ((offsetA / lAB) * (isClockwise ? 1 : -1))).mul(handleArc.radius)
)
}
if (offsetB !== 0) {
tB.setTo(handleArc.center).add(
Vec.FromAngle(aCB + dAB * ((offsetB / lAB) * (isClockwise ? -1 : 1))).mul(handleArc.radius)
)
}
if (Vec.DistMin(tA, tB, minLength)) {
if (offsetA !== 0 && offsetB !== 0) {
offsetA *= -1.5
offsetB *= -1.5
} else if (offsetA !== 0) {
offsetA *= -2
} else if (offsetB !== 0) {
offsetB *= -2
} else {
// noop
}
// if we're using negative offsets, we need to make sure that the body arc doesn't end up
// larger than the handle arc or things will get weird:
const minOffsetA = 0.1 - distFn(handle_aCA, aCA) * handleArc.radius
const minOffsetB = 0.1 - distFn(aCB, handle_aCB) * handleArc.radius
offsetA = Math.max(offsetA, minOffsetA)
offsetB = Math.max(offsetB, minOffsetB)
}
if (offsetA !== 0) {
tempA
.setTo(handleArc.center)
.add(
Vec.FromAngle(aCA + dAB * ((offsetA / lAB) * (isClockwise ? 1 : -1))).mul(handleArc.radius)
)
}
if (offsetB !== 0) {
tempB
.setTo(handleArc.center)
.add(
Vec.FromAngle(aCB + dAB * ((offsetB / lAB) * (isClockwise ? -1 : 1))).mul(handleArc.radius)
)
}
// Did we miss intersections? This happens when we have overlapping shapes.
if (startShapeInfo && endShapeInfo && !startShapeInfo.isExact && !endShapeInfo.isExact) {
aCA = Vec.Angle(handleArc.center, tempA) // angle center -> a
aCB = Vec.Angle(handleArc.center, tempB) // angle center -> b
dAB = distFn(aCA, aCB) // angle distance between a and b
lAB = dAB * handleArc.radius // length of arc between a and b
const relationship = getBoundShapeRelationships(
editor,
startShapeInfo.shape.id,
endShapeInfo.shape.id
)
if (relationship === 'double-bound' && lAB < 30) {
tempA.setTo(a)
tempB.setTo(b)
tempC.setTo(c)
} else if (relationship === 'safe') {
if (startShapeInfo && !startShapeInfo.didIntersect) {
tempA.setTo(a)
}
if (
(endShapeInfo && !endShapeInfo.didIntersect) ||
distFn(handle_aCA, aCA) > distFn(handle_aCA, aCB)
) {
tempB
.setTo(handleArc.center)
.add(
Vec.FromAngle(
aCA +
dAB *
(Math.min(0.9, (MIN_ARROW_LENGTH * shape.props.scale) / lAB) *
(isClockwise ? 1 : -1))
).mul(handleArc.radius)
)
}
}
}
placeCenterHandle(
handleArc.center,
handleArc.radius,
tempA,
tempB,
tempC,
handle_dAB,
isClockwise
)
if (tempA.equals(tempB)) {
tempA.setTo(tempC.clone().addXY(1, 1))
tempB.setTo(tempC.clone().subXY(1, 1))
}
a.setTo(tempA)
b.setTo(tempB)
c.setTo(tempC)
const bodyArc = getArcInfo(a, b, c)
return {
bindings,
type: 'arc',
start: {
point: a,
handle: terminalsInArrowSpace.start,
arrowhead: shape.props.arrowheadStart,
},
end: {
point: b,
handle: terminalsInArrowSpace.end,
arrowhead: shape.props.arrowheadEnd,
},
middle: c,
handleArc,
bodyArc,
isValid: bodyArc.length !== 0 && isFinite(bodyArc.center.x) && isFinite(bodyArc.center.y),
}
}
/**
* Get info about an arc formed by three points.
*
* @param a - The start of the arc
* @param b - The end of the arc
* @param c - A point on the arc
*/
function getArcInfo(a: VecLike, b: VecLike, c: VecLike): TLArcInfo {
// find a circle from the three points
const center = centerOfCircleFromThreePoints(a, b, c) ?? Vec.Med(a, b)
const radius = Vec.Dist(center, a)
// Whether to draw the arc clockwise or counter-clockwise (are the points clockwise?)
const sweepFlag = +Vec.Clockwise(a, c, b)
// The base angle of the arc in radians
const ab = ((a.y - b.y) ** 2 + (a.x - b.x) ** 2) ** 0.5
const bc = ((b.y - c.y) ** 2 + (b.x - c.x) ** 2) ** 0.5
const ca = ((c.y - a.y) ** 2 + (c.x - a.x) ** 2) ** 0.5
const theta = Math.acos((bc * bc + ca * ca - ab * ab) / (2 * bc * ca)) * 2
// Whether to draw the long arc or short arc
const largeArcFlag = +(PI > theta)
// The size of the arc to draw in radians
const size = (PI2 - theta) * (sweepFlag ? 1 : -1)
// The length of the arc to draw in distance units
const length = size * radius
return {
center,
radius,
size,
length,
largeArcFlag,
sweepFlag,
}
}
function placeCenterHandle(
center: VecLike,
radius: number,
tempA: Vec,
tempB: Vec,
tempC: Vec,
originalArcLength: number,
isClockwise: boolean
) {
const aCA = Vec.Angle(center, tempA) // angle center -> a
const aCB = Vec.Angle(center, tempB) // angle center -> b
let dAB = clockwiseAngleDist(aCA, aCB) // angle distance between a and b
if (!isClockwise) dAB = PI2 - dAB
tempC.setTo(center).add(Vec.FromAngle(aCA + dAB * (0.5 * (isClockwise ? 1 : -1))).mul(radius))
if (dAB > originalArcLength) {
tempC.rotWith(center, PI)
const t = tempB.clone()
tempB.setTo(tempA)
tempA.setTo(t)
}
}

View File

@@ -0,0 +1,215 @@
import { Box, TLArrowShape, useEditor, useValue, VecLike } from '@tldraw/editor'
import { SVGProps } from 'react'
import { getArrowBindings } from '../shared'
import { ElbowArrowEdge } from './definitions'
import { getElbowArrowInfo } from './getElbowArrowInfo'
export function ElbowArrowDebug({ arrow }: { arrow: TLArrowShape }) {
const editor = useEditor()
const info = useValue(
'elbow arrow grid',
() => {
try {
const info = getElbowArrowInfo(
editor,
editor.getShape(arrow.id)!,
getArrowBindings(editor, arrow)
)
return info
} catch (err) {
console.error(err)
return undefined
}
},
[editor, arrow.id]
)
if (!info) return null
const fullBox = Box.Common([info.A.original, info.B.original]).expandBy(50)
const label = info.route?.name ?? ''
const midPoint = info.route?.midpointHandle
return (
<>
{info.midX !== null && (
<DebugLine
a={{ x: info.midX, y: fullBox.minY }}
b={{ x: info.midX, y: fullBox.maxY }}
stroke="red"
/>
)}
{info.midY !== null && (
<DebugLine
a={{ x: fullBox.minX, y: info.midY }}
b={{ x: fullBox.maxX, y: info.midY }}
stroke="blue"
/>
)}
{midPoint?.axis === 'x' && info.midXRange && (
<DebugLine
a={{ x: info.midXRange.lo, y: midPoint.point.y }}
b={{ x: info.midXRange.hi, y: midPoint.point.y }}
stroke="red"
strokeDasharray={'0 2'}
/>
)}
{midPoint?.axis === 'y' && info.midYRange && (
<DebugLine
a={{ x: midPoint.point.x, y: info.midYRange.lo }}
b={{ x: midPoint.point.x, y: info.midYRange.hi }}
stroke="blue"
strokeDasharray={'0 2'}
/>
)}
<DebugBox box={info.A.original} stroke="orange" />
<DebugBox box={info.A.expanded} stroke="orange" strokeWidth={0.5} />
<DebugBox
box={info.A.original.clone().expandBy(info.options.minElbowLegLength)}
stroke="orange"
strokeWidth={0.5}
/>
<DebugBox box={info.B.original} stroke="lightskyblue" />
<DebugBox box={info.B.expanded} stroke="lightskyblue" strokeWidth={0.5} />
<DebugBox
box={info.B.original.clone().expandBy(info.options.minElbowLegLength)}
stroke="lightskyblue"
strokeWidth={0.5}
/>
<DebugEdge edge={info.A.edges.top} axis="x" stroke="orange" />
<DebugEdge edge={info.B.edges.top} axis="x" stroke="lightskyblue" />
<DebugEdge edge={info.A.edges.right} axis="y" stroke="orange" />
<DebugEdge edge={info.B.edges.right} axis="y" stroke="lightskyblue" />
<DebugEdge edge={info.A.edges.bottom} axis="x" stroke="orange" />
<DebugEdge edge={info.B.edges.bottom} axis="x" stroke="lightskyblue" />
<DebugEdge edge={info.A.edges.left} axis="y" stroke="orange" />
<DebugEdge edge={info.B.edges.left} axis="y" stroke="lightskyblue" />
{info.route && <DebugRoute route={info.route.points} strokeWidth={10} />}
<text
x={fullBox.minX + 5}
y={fullBox.minY - 3}
fontSize={10}
fill="black"
stroke="var(--tl-color-background)"
strokeWidth={2}
paintOrder="stroke"
>
{label}
</text>
<text
x={info.A.expanded.x}
y={info.A.expanded.y}
fontSize={10}
fill="black"
stroke="var(--tl-color-background)"
strokeWidth={2}
paintOrder="stroke"
>
A{info.route && `, ${info.route.aEdgePicking}`}
{info.A.isPoint && `, point`}
</text>
<text
x={info.B.expanded.x}
y={info.B.expanded.y}
fontSize={10}
fill="black"
stroke="var(--tl-color-background)"
strokeWidth={2}
paintOrder="stroke"
>
B{info.route && `, ${info.route.bEdgePicking}`}
{info.B.isPoint && `, point`}
</text>
</>
)
}
function DebugLine({ a, b, ...props }: { a: VecLike; b: VecLike } & SVGProps<SVGLineElement>) {
return (
<line
fill="none"
strokeWidth={1}
strokeDasharray="4,4"
stroke="green"
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
{...props}
/>
)
}
function DebugRoute({ route, ...props }: { route: VecLike[] } & SVGProps<SVGPolylineElement>) {
return (
<polyline
fill="none"
stroke="darkorchid"
strokeWidth={3}
opacity={0.5}
points={route.map((r) => `${r.x},${r.y}`).join(' ')}
{...props}
/>
)
}
function DebugEdge({
edge,
axis,
...props
}: {
edge: ElbowArrowEdge | null
axis: 'x' | 'y'
} & Omit<SVGProps<SVGLineElement>, 'scale'>) {
if (!edge || edge.expanded === null) return null
const vec = (vec: VecLike) => (axis === 'x' ? { x: vec.y, y: vec.x } : vec)
return (
<g>
<DebugLine
a={vec({ x: edge.expanded, y: edge.cross.min })}
b={vec({ x: edge.expanded, y: edge.cross.max })}
strokeDasharray="0"
strokeWidth={1.5}
{...props}
/>
<DebugLine
a={vec({ x: edge.expanded - 4, y: edge.cross.min })}
b={vec({ x: edge.expanded + 4, y: edge.cross.min })}
strokeDasharray="0"
strokeWidth={1.5}
{...props}
/>
<DebugLine
a={vec({ x: edge.expanded - 4, y: edge.cross.max })}
b={vec({ x: edge.expanded + 4, y: edge.cross.max })}
strokeDasharray="0"
strokeWidth={1.5}
{...props}
/>
</g>
)
}
function DebugBox({ box, ...props }: { box: Box } & SVGProps<SVGRectElement>) {
return (
<rect
x={box.minX}
y={box.minY}
width={box.width}
height={box.height}
strokeDasharray="4,4"
strokeWidth={1}
fill="none"
{...props}
/>
)
}

View File

@@ -0,0 +1,350 @@
import { Box, ElbowArrowSnap, Geometry2d, TLShapeId, Vec, VecLike, VecModel } from '@tldraw/editor'
/**
* The side of a box that an elbow arrow could enter/exit from.
* @public
*/
export type ElbowArrowSide = 'top' | 'right' | 'bottom' | 'left'
/**
* The reason a particular side of a shape was chosen for an elbow arrow to enter / exit. Used only
* for debugging information.
*
* - `manual`: The side was chosen because the user indicated this was the desired side
* - `auto`: The side was chosen automatically based on heuristics for nice-looking arrows
* - `fallback`: We couldn't draw a route to the edge the user indicated, so fell back to our
* heuristics
*
* @internal
*/
export type ElbowArrowSideReason = 'manual' | 'auto' | 'fallback'
/**
* A route for an elbow arrow.
*
* @public
*/
export interface ElbowArrowRoute {
/**
* The name of the route - this is used only for debugging.
* @internal
*/
name: string
/** The vertices of the route. Draw a line through them to see the route. */
points: Vec[]
/** The total distance of the route, in arrow-space pixels. */
distance: number
/**
* Why did we pick edge A?
* @internal
*/
aEdgePicking: ElbowArrowSideReason
/**
* Why did we pick edge B?
* @internal
*/
bEdgePicking: ElbowArrowSideReason
/**
* Some points on the line are there for more informative than display reasons - e.g. where the
* midpoint handle is. If we draw these in our "draw" style, the line will look weird. We still
* need them for some of the calculations we do, but we want to skip them specifically when
* rendering the arrow.
*/
skipPointsWhenDrawing: Set<Vec>
/**
* The midpoint handle of the route, if any.
*/
midpointHandle: ElbowArrowMidpointHandle | null
}
/**
* Part of an {@link ElbowArrowRoute} that describes a handle for dragging the midpoint line, a line
* roughly halfway between the two shapes.
* @public
*/
export interface ElbowArrowMidpointHandle {
axis: 'x' | 'y'
/** The start point of the segment in the route that the handle is on. */
segmentStart: VecLike
/** The end point of the segment in the route that the handle is on. */
segmentEnd: VecLike
/** The position of the handle, in arrow-space. */
point: VecLike
}
export const ElbowArrowSides = ['right', 'bottom', 'left', 'top'] as const
/**
* Extracted from {@link ArrowShapeOptions}. Options for one specific arrow.
* @public
*/
export interface ElbowArrowOptions {
expandElbowLegLength: number
minElbowLegLength: number
elbowMidpoint: number
}
/**
* Vectors that point out of each side of a box.
*/
export const ElbowArrowSideDeltas = {
top: { x: 0, y: -1 },
right: { x: 1, y: 0 },
bottom: { x: 0, y: 1 },
left: { x: -1, y: 0 },
} as const satisfies Record<ElbowArrowSide, VecModel>
/**
* The axis along when each side of a box lies.
*/
export const ElbowArrowSideAxes = {
left: 'x',
right: 'x',
top: 'y',
bottom: 'y',
} as const satisfies Record<ElbowArrowSide, 'x' | 'y'>
/**
* The opposite of each side of a box.
*/
export const ElbowArrowSideOpposites = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
} as const satisfies Record<ElbowArrowSide, ElbowArrowSide>
export const ElbowArrowAxes = {
x: {
v: (x: number, y: number) => new Vec(x, y),
loEdge: 'left',
hiEdge: 'right',
crossMid: 'midY',
gap: 'gapX',
midRange: 'midXRange',
self: 'x',
cross: 'y',
size: 'width',
},
y: {
v: (y: number, x: number) => new Vec(x, y),
loEdge: 'top',
hiEdge: 'bottom',
crossMid: 'midX',
gap: 'gapY',
midRange: 'midYRange',
self: 'y',
cross: 'x',
size: 'height',
},
} as const
export type ElbowArrowAxis = (typeof ElbowArrowAxes)[keyof typeof ElbowArrowAxes]
export type ElbowArrowSideWithAxis = ElbowArrowSide | 'x' | 'y'
/** @public */
export interface ElbowArrowBoxes {
/** The starting bounding box */
A: Box
/** The ending bounding box */
B: Box
/** The common bounding box of A and B */
common: Box
}
/** @public */
export interface ElbowArrowRange {
min: number
max: number
}
/**
* An edge on a box.
* @public
*/
export interface ElbowArrowEdge {
/**
* The co-ordinate of the edge. An x-coordinate if left/right, a y-coordinate if top/bottom.
*/
value: number
/**
* The co-ordinate of the edge, expanded by {@link ArrowShapeOptions.expandElbowLegLength}. May
* be null if the target is a point.
*/
expanded: number | null
/**
* The usable range of the edge along its cross-axis. Y-coordinates if left/right, x-coordinated
* if top/bottom.
*/
cross: ElbowArrowRange
/**
* The point of the target along the edge, constrained to within {@link ElbowArrowEdge.cross}.
*/
crossTarget: number
/**
* Whether the cross-axis range is shrunk from the original range to make space for the other shape.
*/
isPartial: boolean
}
/**
* The usable range of the edges of a box. Each edge might be null if the edge is not usable for
* entry/exit.
* @public
*/
export interface ElbowArrowBoxEdges {
top: ElbowArrowEdge | null
right: ElbowArrowEdge | null
bottom: ElbowArrowEdge | null
left: ElbowArrowEdge | null
}
/**
* @public
*/
export interface ElbowArrowBox {
/** The original bounding box */
original: Box
/**
* The bounding box, expanded by {@link ArrowShapeOptions.expandElbowLegLength}.
*/
expanded: Box
}
/**
* @public
*/
export interface ElbowArrowTargetBox extends ElbowArrowBox {
/** What specific point in the box are we aiming for? */
target: Vec
/**
* If true, the arrow should end at `target`. If false, the arrow should end at the edge of the
* shape, pointing at `target`.
*/
isExact: boolean
/**
* How far away from this box should the arrow terminate to leave space for the arrowhead?
*/
arrowheadOffset: number
/**
* The minimum length of the segment of the arrow that actually reaches the target - and has the
* arrowhead on it.
*/
minEndSegmentLength: number
/**
* The usable edges of the box.
*/
edges: ElbowArrowBoxEdges
/**
* The geometry of the bound shape, in arrow space.
*/
geometry: Geometry2d | null
/**
* Are we treating this target as a single point in space rather than a bounding box?
*/
isPoint: boolean
}
/** @public */
export interface ElbowArrowInfoWithoutRoute {
/**
* The options used for this elbow arrow
*/
options: ElbowArrowOptions
/**
* If false, A is the start shape and B is the end shape. If true, A is the end shape and B is
* the start shape.
*/
swapOrder: boolean
/**
* One of the two shapes we're drawing an arrow between. Could be either the start or end
* depending on `swapOrder`.
*/
A: ElbowArrowTargetBox
/**
* The other shape we're drawing an arrow between. Could be either the start or end
* depending on `swapOrder`.
*/
B: ElbowArrowTargetBox
/**
* The common bounding box of A and B.
*/
common: ElbowArrowBox
/**
* The gap between the right edge of A and the left edge of B.
*/
gapX: number
/**
* The gap between the bottom edge of A and the top edge of B.
*/
gapY: number
/**
* The X coordinate of the middle line between the two boxes. If the boxes are too close or
* overlap, this may be null.
*/
midX: number | null
/**
* The Y coordinate of the middle line between the two boxes. If the boxes are too close or
* overlap, this may be null.
*/
midY: number | null
}
/** @public */
export interface ElbowArrowInfo extends ElbowArrowInfoWithoutRoute {
/**
* The route of the arrow.
*/
route: ElbowArrowRoute | null
midXRange: { lo: number; hi: number } | null
midYRange: { lo: number; hi: number } | null
}
export interface ElbowArrowTerminal {
/**
* The id of the shape we're binding to, if any.
*/
targetShapeId: TLShapeId | null
/**
* The side of the box that the arrow should enter from.
*/
side: ElbowArrowSideWithAxis | null
/**
* The bounding box of the shape. May have width/height of 0 if the shape is a point.
*/
bounds: Box
/**
* The geometry of the we're binding to, if it exists.
*/
geometry: Geometry2d | null
/**
* The target point of the arrow.
*/
target: Vec
/**
* How far away from the target should the arrow terminate to leave space for the arrowhead?
*/
arrowheadOffset: number
/**
* The minimum length of the segment of the arrow that actually reaches the target - and has the
* arrowhead on it.
*/
minEndSegmentLength: number
/**
* Whether the target is an exact point. within a shape's geometry.
*/
isExact: boolean
/**
* Whether the target is a point, rather than a bounding box
*/
isPoint: boolean
/**
* How did this binding get snapped to the target shape?
*/
snap: ElbowArrowSnap
}

View File

@@ -0,0 +1,129 @@
import {
computed,
Computed,
Editor,
TLArrowShape,
TLShapeId,
Vec,
VecLike,
WeakCache,
} from '@tldraw/editor'
import { getArrowBindings } from '../shared'
/**
* A snap line for an elbow arrow segment.
*
* This should already belong to ElbowArrowSnapLines establishing an angle of the line.
*/
interface ElbowArrowSnapLine {
/** The id of the shape that the snap line starts from. */
startBoundShapeId: TLShapeId | undefined
/** The id of the shape that the snap line ends at. */
endBoundShapeId: TLShapeId | undefined
/** The perpendicular distance from the snap line to the origin. */
perpDistance: number
}
/**
* A map from an angle (0-π) to a set of snap lines. Snap lines are stored in page space. They're
* modelled as an angle (the angle of the line itself) and a perpendicular signed distance from the
* page origin. Each line is effectively infinite in length, but modelling them in this way makes it
* pretty efficient for us to query for relevant snap lines.
*/
type ElbowArrowSnapLines = Map<number, Set<ElbowArrowSnapLine>>
const snapLinesStore = new WeakCache<Editor, Computed<ElbowArrowSnapLines>>()
export function getElbowArrowSnapLines(editor: Editor) {
return snapLinesStore
.get(editor, (editor) => {
const currentSelectedArrowShape = computed('current selected arrow shape', () => {
const shape = editor.getOnlySelectedShape()
if (!shape || !editor.isShapeOfType(shape, 'arrow')) return null
return shape.id
})
const unselectedArrowShapeIds = editor.store.query.ids('shape', () => {
const activeArrowShapeId = currentSelectedArrowShape.get()
if (!activeArrowShapeId) return { type: { eq: 'arrow' } } as const
return {
type: { eq: 'arrow' },
id: { neq: activeArrowShapeId },
} as const
})
return computed('elbow arrow snap lines', () => {
// the result is a map from angle (0-π), to a set of single-axis co-ordinates. For
// example, if a line from (0, 1) to (1, 1) is found (ie a horizontal line at y-coord 1),
// we'll add an entry to the map with the key 0 (horizontal), and the set containing 1.
const result = new Map<number, Set<ElbowArrowSnapLine>>()
const currentPageShapeIds = editor.getCurrentPageShapeIds()
const viewportBounds = editor.getViewportPageBounds()
for (const id of unselectedArrowShapeIds.get()) {
if (!currentPageShapeIds.has(id)) continue
const shape = editor.getShape<TLArrowShape>(id)
if (shape?.type !== 'arrow') continue
const shapeBounds = editor.getShapePageBounds(id)
if (!shapeBounds || !viewportBounds.includes(shapeBounds)) continue
const bindings = getArrowBindings(editor, shape)
const pageTransform = editor.getShapePageTransform(id)
if (!pageTransform) continue
const geometry = editor.getShapeGeometry(id)
const pageVertices = pageTransform.applyToPoints(geometry.vertices)
for (let i = 1; i < pageVertices.length; i++) {
const prev = pageVertices[i - 1]
const curr = pageVertices[i]
let angle = Vec.Angle(prev, curr)
// we don't care if the angle is going "up" or "down" - so we only care
// about the 0-π range
if (angle < 0) angle += Math.PI
let set = result.get(angle)
if (!set) {
set = new Set()
result.set(angle, set)
}
const perpDistance = perpDistanceToLineAngle(prev, angle)
set.add({
perpDistance,
startBoundShapeId: bindings.start?.toId,
endBoundShapeId: bindings.end?.toId,
})
}
}
return result
})
})
.get()
}
/**
* Return the signed distance from the origin to a point on a line of angle `lineAngle` that passes
* through the point `pointOnLine`.
*/
export function perpDistanceToLineAngle(pointOnLine: VecLike, lineAngle: number): number {
// The perpendicular unit vector to the line direction
const perpDir = Vec.FromAngle(lineAngle).per()
// Project the point onto the perpendicular vector
return Vec.Dpr(pointOnLine, perpDir)
}
/**
* Return the signed distance from the origin to the line segment defined by `A` and `B`.
*/
export function perpDistanceToLine(A: VecLike, B: VecLike): number {
return perpDistanceToLineAngle(A, Vec.Angle(A, B))
}

View File

@@ -0,0 +1,80 @@
import { createShapeId, TLArrowShape } from '@tldraw/editor'
import { TestEditor } from '../../../../test/TestEditor'
import { createOrUpdateArrowBinding, getArrowBindings } from '../shared'
import { getElbowArrowInfo } from './getElbowArrowInfo'
let editor: TestEditor
const ids = {
box1: createShapeId('box1'),
box2: createShapeId('box2'),
arrow1: createShapeId('arrow1'),
}
beforeEach(() => {
editor = new TestEditor()
})
describe('Elbow arrow with scale', () => {
it.each([1, 5, 10, 20])('creates valid orthogonal routes with scale=%i', (scale) => {
editor.createShapes([
{ id: ids.box1, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } },
{ id: ids.box2, type: 'geo', x: 250, y: 100, props: { w: 100, h: 100 } },
{
id: ids.arrow1,
type: 'arrow',
x: 0,
y: 0,
props: {
start: { x: 0, y: 0 },
end: { x: 0, y: 0 },
kind: 'elbow',
scale,
size: 's',
},
},
])
createOrUpdateArrowBinding(editor, ids.arrow1, ids.box1, {
terminal: 'start',
isExact: false,
isPrecise: false,
normalizedAnchor: { x: 0.5, y: 0.5 },
snap: 'none',
})
createOrUpdateArrowBinding(editor, ids.arrow1, ids.box2, {
terminal: 'end',
isExact: false,
isPrecise: false,
normalizedAnchor: { x: 0.5, y: 0.5 },
snap: 'none',
})
const arrow = editor.getShape(ids.arrow1)! as TLArrowShape
const bindings = getArrowBindings(editor, arrow)
const info = getElbowArrowInfo(editor, arrow, bindings)
expect(info).toBeDefined()
expect(arrow.props.kind).toBe('elbow')
expect(info.route).toBeDefined()
expect(info.route!.points.length).toBeGreaterThanOrEqual(3)
// No duplicate consecutive points
for (let i = 0; i < info.route!.points.length - 1; i++) {
const p1 = info.route!.points[i]
const p2 = info.route!.points[i + 1]
const isDuplicate = Math.abs(p1.x - p2.x) < 0.01 && Math.abs(p1.y - p2.y) < 0.01
expect(isDuplicate).toBe(false)
}
// All segments orthogonal
for (let i = 0; i < info.route!.points.length - 1; i++) {
const p1 = info.route!.points[i]
const p2 = info.route!.points[i + 1]
const isHorizontal = Math.abs(p1.y - p2.y) < 0.01
const isVertical = Math.abs(p1.x - p2.x) < 0.01
expect(isHorizontal || isVertical).toBe(true)
}
})
})

View File

@@ -0,0 +1,901 @@
import {
approximately,
assert,
Box,
Editor,
exhaustiveSwitchError,
Geometry2dFilters,
lerp,
Mat,
TLArrowBinding,
TLArrowBindingProps,
TLArrowShape,
TLShapeId,
Vec,
VecLike,
VecModel,
} from '@tldraw/editor'
import { STROKE_SIZES } from '../../shared/default-shape-constants'
import { ArrowShapeUtil } from '../ArrowShapeUtil'
import { BOUND_ARROW_OFFSET, TLArrowBindings } from '../shared'
import {
ElbowArrowAxes,
ElbowArrowBox,
ElbowArrowBoxEdges,
ElbowArrowEdge,
ElbowArrowInfo,
ElbowArrowInfoWithoutRoute,
ElbowArrowOptions,
ElbowArrowRoute,
ElbowArrowSide,
ElbowArrowSideWithAxis,
ElbowArrowTargetBox,
ElbowArrowTerminal,
} from './definitions'
import { createRange, expandRange, isWithinRange, rangeSize, subtractRange } from './range'
import { ElbowArrowWorkingInfo } from './routes/ElbowArrowWorkingInfo'
import {
routeArrowWithAutoEdgePicking,
routeArrowWithManualEdgePicking,
routeArrowWithPartialEdgePicking,
} from './routes/routeArrowWithAutoEdgePicking'
export function getElbowArrowInfo(
editor: Editor,
arrow: TLArrowShape,
bindings: TLArrowBindings,
arrowStrokeWidth?: number
): ElbowArrowInfo {
const shapeOptions = editor.getShapeUtil<ArrowShapeUtil>(arrow.type).options
const options: ElbowArrowOptions = {
elbowMidpoint: arrow.props.elbowMidPoint,
expandElbowLegLength: shapeOptions.expandElbowLegLength[arrow.props.size] * arrow.props.scale,
minElbowLegLength: shapeOptions.minElbowLegLength[arrow.props.size] * arrow.props.scale,
}
// Before we can do anything else, we need to find the start and end terminals of the arrow.
// These contain the binding info, geometry, bounds, etc.
let startTerminal = getElbowArrowTerminalInfo(
editor,
arrow,
bindings.start,
arrow.props.start,
arrowStrokeWidth
)
let endTerminal = getElbowArrowTerminalInfo(
editor,
arrow,
bindings.end,
arrow.props.end,
arrowStrokeWidth
)
// unclosed paths are weird - we handle them outside of the initial terminal info.
startTerminal = adjustTerminalForUnclosedPathIfNeeded(startTerminal, endTerminal, options)
endTerminal = adjustTerminalForUnclosedPathIfNeeded(endTerminal, startTerminal, options)
// Ther terminal might include a "side" if the user has explicitly indicated what side the arrow
// should come from. There are two terminals, and two cases for each terminal (explicit side or
// not), for a total for 4 cases to handle. In order to keep things a bit simpler though, we
// only handle 3 cases: if start no side and end has a side, we flip them around. From here on
// out, we use A and B to refer to the terminals as they may be swapped.
const swapOrder = !!(!startTerminal.side && endTerminal.side)
let { aTerminal, bTerminal } = swapOrder
? { aTerminal: endTerminal, bTerminal: startTerminal }
: { aTerminal: startTerminal, bTerminal: endTerminal }
// We model each edge that an arrow might enter/exit from separately. If an edge is blocked,
// `getUsableEdge` might return null.
let edgesA = {
top: getUsableEdge(aTerminal, bTerminal, 'top', options),
right: getUsableEdge(aTerminal, bTerminal, 'right', options),
bottom: getUsableEdge(aTerminal, bTerminal, 'bottom', options),
left: getUsableEdge(aTerminal, bTerminal, 'left', options),
}
let edgesB = {
top: getUsableEdge(bTerminal, aTerminal, 'top', options),
right: getUsableEdge(bTerminal, aTerminal, 'right', options),
bottom: getUsableEdge(bTerminal, aTerminal, 'bottom', options),
left: getUsableEdge(bTerminal, aTerminal, 'left', options),
}
// We we don't have a usable edge because it's blocked, we can convert some of the terminals to
// points. Point terminals have less strict edge routing rules, but don't look as good
// generally. For example, the arrow might go through the shape instead of around.
const aIsUsable = hasUsableEdge(edgesA, aTerminal.side)
const bIsUsable = hasUsableEdge(edgesB, bTerminal.side)
let needsNewEdges = false
if (!aIsUsable || !bIsUsable) {
needsNewEdges = true
if (!aIsUsable) {
bTerminal = convertTerminalToPoint(bTerminal)
}
if (!bIsUsable) {
aTerminal = convertTerminalToPoint(aTerminal)
}
if (bTerminal.bounds.containsPoint(aTerminal.target, options.expandElbowLegLength)) {
bTerminal = convertTerminalToPoint(bTerminal)
}
if (aTerminal.bounds.containsPoint(bTerminal.target, options.expandElbowLegLength)) {
aTerminal = convertTerminalToPoint(aTerminal)
}
}
if (needsNewEdges) {
edgesA = {
top: getUsableEdge(aTerminal, bTerminal, 'top', options),
right: getUsableEdge(aTerminal, bTerminal, 'right', options),
bottom: getUsableEdge(aTerminal, bTerminal, 'bottom', options),
left: getUsableEdge(aTerminal, bTerminal, 'left', options),
}
edgesB = {
top: getUsableEdge(bTerminal, aTerminal, 'top', options),
right: getUsableEdge(bTerminal, aTerminal, 'right', options),
bottom: getUsableEdge(bTerminal, aTerminal, 'bottom', options),
left: getUsableEdge(bTerminal, aTerminal, 'left', options),
}
}
// We expand the bounds of the terminals so we can route arrows around them without the arrows
// being too close to the shapes. Always a separate Box from `bounds`: ElbowArrowWorkingInfo
// transforms `original` and `expanded` in place, so sharing one object would transform a point
// terminal twice (a no-op for flips, a 180° turn for rotations) and corrupt the route.
const expandedA = aTerminal.isPoint
? aTerminal.bounds.clone()
: aTerminal.bounds.clone().expandBy(options.expandElbowLegLength)
const expandedB = bTerminal.isPoint
? bTerminal.bounds.clone()
: bTerminal.bounds.clone().expandBy(options.expandElbowLegLength)
const common: ElbowArrowBox = {
original: Box.Common([aTerminal.bounds, bTerminal.bounds]),
expanded: Box.Common([expandedA, expandedB]),
}
// Calculate the gaps between the two terminals. If gap is positive, B is to the right of A. If
// it's negative, the opposite is true. If it's 0, there's no gap between the shapes in that
// dimension.
let gapX = bTerminal.bounds.minX - aTerminal.bounds.maxX
if (gapX < 0) {
gapX = aTerminal.bounds.minX - bTerminal.bounds.maxX
if (gapX < 0) {
gapX = 0
}
gapX = -gapX
}
let gapY = bTerminal.bounds.minY - aTerminal.bounds.maxY
if (gapY < 0) {
gapY = aTerminal.bounds.minY - bTerminal.bounds.maxY
if (gapY < 0) {
gapY = 0
}
gapY = -gapY
}
// The midpoint of the gap is a useful point to route arrows through, but the user can also drag
// it to choose a new midpoint. First, we calculate some constraints we'll need to keep in mind
// when figuring out the midpoint...
const aMinLength = aTerminal.minEndSegmentLength * 3
const bMinLength = bTerminal.minEndSegmentLength * 3
const minLegDistanceNeeded =
(aTerminal.isPoint ? aMinLength : options.minElbowLegLength) +
(bTerminal.isPoint ? bMinLength : options.minElbowLegLength)
// ...then, the possible range of the midpoint. This is also used when dragging the midpoint.
let mxRange: null | { a: number; b: number } = null
if (gapX > minLegDistanceNeeded) {
mxRange = {
a: aTerminal.isPoint ? aTerminal.bounds.maxX + aMinLength : expandedA.maxX,
b: bTerminal.isPoint ? bTerminal.bounds.minX - bMinLength : expandedB.minX,
}
} else if (gapX < -minLegDistanceNeeded) {
mxRange = {
a: aTerminal.isPoint ? aTerminal.bounds.minX - aMinLength : expandedA.minX,
b: bTerminal.isPoint ? bTerminal.bounds.maxX + bMinLength : expandedB.maxX,
}
}
let myRange: null | { a: number; b: number } = null
if (gapY > minLegDistanceNeeded) {
myRange = {
a: aTerminal.isPoint ? aTerminal.bounds.maxY + aMinLength : expandedA.maxY,
b: bTerminal.isPoint ? bTerminal.bounds.minY - bMinLength : expandedB.minY,
}
} else if (gapY < -minLegDistanceNeeded) {
myRange = {
a: aTerminal.isPoint ? aTerminal.bounds.minY - aMinLength : expandedA.minY,
b: bTerminal.isPoint ? bTerminal.bounds.maxY + bMinLength : expandedB.maxY,
}
}
// and finally we take the range and the midpoint prop and calculate the actual position of the
// midpoint. Note that the midpoint and midpoint range can be null if the gap is too small for a
// midpoint line.
const midpoint = swapOrder ? 1 - options.elbowMidpoint : options.elbowMidpoint
const mx = mxRange ? lerp(mxRange.a, mxRange.b, midpoint) : null
const my = myRange ? lerp(myRange.a, myRange.b, midpoint) : null
// The info without route is given to the route-finding functions to route between the two
// terminals.
const info: ElbowArrowInfoWithoutRoute = {
options,
swapOrder,
A: {
isPoint: aTerminal.isPoint,
target: aTerminal.target,
isExact: aTerminal.isExact,
arrowheadOffset: aTerminal.arrowheadOffset,
minEndSegmentLength: aTerminal.minEndSegmentLength,
original: aTerminal.bounds,
expanded: expandedA,
edges: edgesA,
geometry: aTerminal.geometry,
},
B: {
isPoint: bTerminal.isPoint,
target: bTerminal.target,
isExact: bTerminal.isExact,
arrowheadOffset: bTerminal.arrowheadOffset,
minEndSegmentLength: bTerminal.minEndSegmentLength,
original: bTerminal.bounds,
expanded: expandedB,
edges: edgesB,
geometry: bTerminal.geometry,
},
common,
gapX,
gapY,
midX: mx,
midY: my,
}
// We wrap the info in a working info object that lets us mutate and reset it as needed.
const workingInfo = new ElbowArrowWorkingInfo(info)
// Figure out the final sides to use for each terminal.
const aSide = getSideToUse(aTerminal, bTerminal, info.A.edges)
const bSide = getSideToUse(bTerminal, aTerminal, info.B.edges)
// try to find a route with the specification we have:
let route
if (aSide && bSide) {
route = routeArrowWithManualEdgePicking(workingInfo, aSide, bSide)
} else if (aSide && !bSide) {
route = routeArrowWithPartialEdgePicking(workingInfo, aSide)
}
if (!route) {
route = routeArrowWithAutoEdgePicking(workingInfo, aSide || bSide ? 'fallback' : 'auto')
}
if (route) {
// If we found a route, we need to fix it up. The route will only go to the bounding box of
// the shape, so we need to cast the final segments into the actual geometry of the shape.
castPathSegmentIntoGeometry('first', info.A, info.B, route)
castPathSegmentIntoGeometry('last', info.B, info.A, route)
// If we have tiny L-shaped arrows, the arrowheads look super janky. We fix those up by just
// drawing a straight line instead.
fixTinyEndNubs(route, aTerminal, bTerminal)
// If we swapped the order way back of the start of things, we need to reverse the route so
// it flows start -> end instead of A -> B.
if (swapOrder) route.points.reverse()
}
return {
...info,
route,
midXRange: mxRange
? swapOrder
? { lo: mxRange.b, hi: mxRange.a }
: { lo: mxRange.a, hi: mxRange.b }
: null,
midYRange: myRange
? swapOrder
? { lo: myRange.b, hi: myRange.a }
: { lo: myRange.a, hi: myRange.b }
: null,
}
}
/**
* Take the route from `getElbowArrowInfo` (which represents the visible body of the arrow) and
* convert it into a path we can use to show that paths to the handles, which may extend further
* into the target shape geometries.
* @returns
*/
export function getRouteHandlePath(info: ElbowArrowInfo, route: ElbowArrowRoute): ElbowArrowRoute {
const startTarget = info.swapOrder ? info.B.target : info.A.target
const endTarget = info.swapOrder ? info.A.target : info.B.target
const firstSegmentLength = Vec.ManhattanDist(route.points[0], route.points[1])
const lastSegmentLength = Vec.ManhattanDist(
route.points[route.points.length - 2],
route.points[route.points.length - 1]
)
const newFirstSegmentLength = Vec.ManhattanDist(startTarget, route.points[1])
const newLastSegmentLength = Vec.ManhattanDist(route.points[route.points.length - 2], endTarget)
const firstSegmentLengthChange = firstSegmentLength - newFirstSegmentLength
const lastSegmentLengthChange = lastSegmentLength - newLastSegmentLength
const newPoints = [startTarget, ...route.points, endTarget]
return {
name: route.name,
distance: route.distance + firstSegmentLengthChange + lastSegmentLengthChange,
points: newPoints.filter((p) => !route.skipPointsWhenDrawing.has(p)),
aEdgePicking: route.aEdgePicking,
bEdgePicking: route.bEdgePicking,
skipPointsWhenDrawing: route.skipPointsWhenDrawing,
midpointHandle: route.midpointHandle,
}
}
/**
* Take a normalizes anchor and return the side we think it's closest to.
*/
export function getEdgeFromNormalizedAnchor(normalizedAnchor: VecLike) {
if (approximately(normalizedAnchor.x, 0.5) && approximately(normalizedAnchor.y, 0.5)) {
return null
}
if (
Math.abs(normalizedAnchor.x - 0.5) >
// slightly bias towards x arrows to prevent flickering when the anchor is right on the line
// between the two directions
Math.abs(normalizedAnchor.y - 0.5) - 0.0001
) {
return normalizedAnchor.x < 0.5 ? 'left' : 'right'
}
return normalizedAnchor.y < 0.5 ? 'top' : 'bottom'
}
function getElbowArrowTerminalInfo(
editor: Editor,
arrow: TLArrowShape,
binding: TLArrowBinding | undefined,
point: VecModel,
arrowStrokeWidth?: number
): ElbowArrowTerminal {
const theme = editor.getCurrentTheme()
const arrowSW = arrowStrokeWidth ?? theme.strokeWidth * STROKE_SIZES[arrow.props.size]
const arrowStrokeSize = (arrowSW * arrow.props.scale) / 2
const minEndSegmentLength = arrowStrokeSize * 3
if (binding) {
const target = editor.getShape(binding.toId)
const geometry = getBindingGeometryInArrowSpace(editor, arrow, binding.toId, binding.props)
if (geometry && target) {
let arrowheadOffset = 0
const arrowheadProp = binding.props.terminal === 'start' ? 'arrowheadStart' : 'arrowheadEnd'
if (arrow.props[arrowheadProp] !== 'none') {
const targetScale = 'scale' in target.props ? target.props.scale : 1
const targetStrokeSize =
'size' in target.props
? (theme.strokeWidth * (STROKE_SIZES[target.props.size] ?? 0) * targetScale) / 2
: 0
arrowheadOffset =
arrowStrokeSize + targetStrokeSize + BOUND_ARROW_OFFSET * arrow.props.scale
}
let side: ElbowArrowSideWithAxis | null = null
const targetPoint = geometry.target
if (binding.props.isPrecise) {
side = getEdgeFromNormalizedAnchor(
Vec.RotWith(
binding.props.normalizedAnchor,
{ x: 0.5, y: 0.5 },
geometry.shapeToArrowTransform.rotation()
)
)
}
return {
targetShapeId: binding.toId,
isPoint: false,
isExact: binding.props.isExact,
bounds: geometry.bounds,
geometry: geometry.geometry,
target: targetPoint,
arrowheadOffset,
minEndSegmentLength,
side,
snap: binding.props.snap,
}
}
}
return {
targetShapeId: null,
bounds: Box.FromCenter(point, { x: 0, y: 0 }),
geometry: null,
isExact: false,
isPoint: true,
target: Vec.From(point),
arrowheadOffset: 0,
minEndSegmentLength,
side: null,
snap: 'none',
}
}
function getBindingGeometryInArrowSpace(
editor: Editor,
arrow: TLArrowShape,
targetId: TLShapeId,
bindingProps: TLArrowBindingProps
) {
const hasArrowhead =
bindingProps.terminal === 'start'
? arrow.props.arrowheadStart !== 'none'
: arrow.props.arrowheadEnd !== 'none'
const targetGeometryInTargetSpace = editor.getShapeGeometry(
targetId,
hasArrowhead ? undefined : { context: '@tldraw/arrow-without-arrowhead' }
)
if (!targetGeometryInTargetSpace) {
return null
}
const arrowTransform = editor.getShapePageTransform(arrow.id)
const shapeTransform = editor.getShapePageTransform(targetId)
const shapeToArrowTransform = arrowTransform.clone().invert().multiply(shapeTransform)
const targetGeometryInArrowSpace = targetGeometryInTargetSpace.transform(shapeToArrowTransform)
const center = { x: 0.5, y: 0.5 }
const normalizedAnchor = bindingProps.isPrecise ? bindingProps.normalizedAnchor : center
const targetInShapeSpace = {
x: lerp(
targetGeometryInTargetSpace.bounds.minX,
targetGeometryInTargetSpace.bounds.maxX,
normalizedAnchor.x
),
y: lerp(
targetGeometryInTargetSpace.bounds.minY,
targetGeometryInTargetSpace.bounds.maxY,
normalizedAnchor.y
),
}
const centerInShapeSpace = {
x: lerp(
targetGeometryInTargetSpace.bounds.minX,
targetGeometryInTargetSpace.bounds.maxX,
center.x
),
y: lerp(
targetGeometryInTargetSpace.bounds.minY,
targetGeometryInTargetSpace.bounds.maxY,
center.y
),
}
const targetInArrowSpace = Mat.applyToPoint(shapeToArrowTransform, targetInShapeSpace)
const centerInArrowSpace = Mat.applyToPoint(shapeToArrowTransform, centerInShapeSpace)
return {
bounds: targetGeometryInArrowSpace.bounds,
geometry: targetGeometryInArrowSpace,
target: targetInArrowSpace,
center: centerInArrowSpace,
shapeToArrowTransform,
}
}
const sideProps = {
top: {
expand: -1,
main: 'minY',
opposite: 'maxY',
crossMid: 'midX',
crossMin: 'minX',
crossMax: 'maxX',
bRangeExpand: 'max',
crossAxis: 'x',
},
bottom: {
expand: 1,
main: 'maxY',
opposite: 'minY',
crossMid: 'midX',
crossMin: 'minX',
crossMax: 'maxX',
bRangeExpand: 'min',
crossAxis: 'x',
},
left: {
expand: -1,
main: 'minX',
opposite: 'maxX',
crossMid: 'midY',
crossMin: 'minY',
crossMax: 'maxY',
bRangeExpand: 'max',
crossAxis: 'y',
},
right: {
expand: 1,
main: 'maxX',
opposite: 'minX',
crossMid: 'midY',
crossMin: 'minY',
crossMax: 'maxY',
bRangeExpand: 'min',
crossAxis: 'y',
},
} as const
export function getUsableEdge(
a: ElbowArrowTerminal,
b: ElbowArrowTerminal,
side: 'top' | 'right' | 'bottom' | 'left',
options: ElbowArrowOptions
): ElbowArrowEdge | null {
const props = sideProps[side]
// if a shape is bound to itself, by default we'd end up routing the arrow _within_ the shape -
// as if it were a point-to-point arrow. if one of the bindings is specifically to the edge
// though, we route it externally instead.
const isSelfBoundAndShouldRouteExternal =
a.targetShapeId === b.targetShapeId &&
a.targetShapeId !== null &&
(a.snap === 'edge' || a.snap === 'edge-point') &&
(b.snap === 'edge' || b.snap === 'edge-point')
const aValue = a.bounds[props.main]
const aExpanded = a.isPoint ? null : aValue + props.expand * options.expandElbowLegLength
const originalACrossRange = createRange(a.bounds[props.crossMin], a.bounds[props.crossMax])
let aCrossRange = originalACrossRange
// this edge is too small to be useful:
if (!aCrossRange) {
return null
}
assert(originalACrossRange)
const bRange = createRange(b.bounds[props.main], b.bounds[props.opposite])
if (!b.isPoint) {
bRange[props.bRangeExpand] -= options.minElbowLegLength * 2 * props.expand
}
const bCrossRange = expandRange(
createRange(b.bounds[props.crossMin], b.bounds[props.crossMax]),
options.expandElbowLegLength
)
assert(bRange && bCrossRange)
let isPartial = false
if (
isWithinRange(aValue, bRange) &&
!a.isPoint &&
!b.isPoint &&
!isSelfBoundAndShouldRouteExternal
) {
const subtracted = subtractRange(aCrossRange, bCrossRange)
switch (subtracted.length) {
case 0:
return null
case 1:
isPartial = subtracted[0] !== aCrossRange
aCrossRange = subtracted[0]
break
case 2:
isPartial = true
aCrossRange =
rangeSize(subtracted[0]) > rangeSize(subtracted[1]) ? subtracted[0] : subtracted[1]
break
default:
exhaustiveSwitchError(subtracted)
}
}
if (!isWithinRange(a.target[props.crossAxis], aCrossRange)) {
return null
}
const crossTarget = a.target[props.crossAxis]
return {
value: aValue,
expanded: aExpanded,
cross: aCrossRange,
crossTarget,
isPartial,
}
}
function hasUsableEdge(edges: ElbowArrowBoxEdges, side: ElbowArrowSideWithAxis | null) {
if (side === null) {
return !!(edges.bottom || edges.left || edges.right || edges.top)
}
if (side === 'x') {
return !!edges.left || !!edges.right
}
if (side === 'y') {
return !!edges.top || !!edges.bottom
}
return !!edges[side]
}
function getSideToUse(
binding: ElbowArrowTerminal,
other: ElbowArrowTerminal,
edges: ElbowArrowBoxEdges | null
): ElbowArrowSide | null {
switch (binding.side) {
case null:
return null
case 'x':
if (binding.bounds.center.x > other.bounds.center.x && edges?.left) {
return 'left'
} else if (edges?.right) {
return 'right'
}
return null
case 'y':
if (binding.bounds.center.y > other.bounds.center.y && edges?.top) {
return 'top'
} else if (edges?.bottom) {
return 'bottom'
}
return null
default:
return binding.side
}
}
function convertTerminalToPoint(terminal: ElbowArrowTerminal): ElbowArrowTerminal {
if (terminal.isPoint) return terminal
let side: ElbowArrowSideWithAxis | null = null
let arrowheadOffset = 0
if (terminal.snap === 'edge' || terminal.snap === 'edge-point') {
arrowheadOffset = terminal.arrowheadOffset
if (terminal.side === 'x' || terminal.side === 'left' || terminal.side === 'right') {
side = 'x'
}
if (terminal.side === 'y' || terminal.side === 'top' || terminal.side === 'bottom') {
side = 'y'
}
}
return {
targetShapeId: terminal.targetShapeId,
side,
bounds: new Box(terminal.target.x, terminal.target.y, 0, 0),
geometry: terminal.geometry,
target: terminal.target,
arrowheadOffset,
minEndSegmentLength: terminal.minEndSegmentLength,
isExact: terminal.isExact,
isPoint: true,
snap: terminal.snap,
}
}
/**
* Make sure the first path segments goes fully into the target, and doesn't just point to its
* bounding box. This modifies the route in-place.
*/
function castPathSegmentIntoGeometry(
segment: 'first' | 'last',
target: ElbowArrowTargetBox,
other: ElbowArrowTargetBox,
route: ElbowArrowRoute
) {
if (!target.geometry) return
const point1 = segment === 'first' ? route.points[0] : route.points[route.points.length - 1]
const point2 = segment === 'first' ? route.points[1] : route.points[route.points.length - 2]
const pointToFindClosestIntersectionTo = target.geometry.isClosed ? point2 : target.target
const initialDistance = Vec.ManhattanDist(point1, pointToFindClosestIntersectionTo)
let nearestIntersectionToPoint2: VecLike | null = null
let nearestDistanceToPoint2 = Infinity
if (target.isExact) {
nearestIntersectionToPoint2 = target.target
} else if (target.geometry) {
const intersections = target.geometry.intersectLineSegment(point2, target.target, {
includeLabels: false,
includeInternal: false,
})
if (
target.geometry.hitTestPoint(
target.target,
Math.max(1, target.arrowheadOffset),
true,
Geometry2dFilters.EXCLUDE_NON_STANDARD
)
) {
intersections.push(target.target)
}
for (const intersection of intersections) {
const point2Distance = Vec.ManhattanDist(pointToFindClosestIntersectionTo, intersection)
if (point2Distance < nearestDistanceToPoint2) {
nearestDistanceToPoint2 = point2Distance
nearestIntersectionToPoint2 = intersection
}
}
}
if (nearestIntersectionToPoint2) {
let offset = target.arrowheadOffset
const currentFinalSegmentLength = Vec.ManhattanDist(point2, nearestIntersectionToPoint2)
const minLength = target.arrowheadOffset * 2
if (currentFinalSegmentLength < minLength) {
const targetLength = minLength - target.arrowheadOffset
offset = currentFinalSegmentLength - targetLength
}
if (offset < target.minEndSegmentLength) {
if (target.geometry.bounds.containsPoint(other.target)) {
offset = Math.max(0, offset)
} else {
offset = -target.arrowheadOffset
}
}
let nudgedPoint = nearestIntersectionToPoint2
let shouldAddExtraPointForNudge = false
if (!target.isExact && offset !== 0) {
const nudged = Vec.Nudge(nearestIntersectionToPoint2, point2, offset)
nudgedPoint = nudged
if (
offset < 0 &&
!target.geometry.hitTestPoint(nudged, 0, true, Geometry2dFilters.EXCLUDE_NON_STANDARD)
) {
// point has been nudged _out_ of the shape so lets not actually apply the nudge
nudgedPoint = nearestIntersectionToPoint2
} else {
if (offset < 0) {
shouldAddExtraPointForNudge = true
}
nudgedPoint = nudged
}
}
const newDistance = Vec.ManhattanDist(point2, nudgedPoint)
route.distance += newDistance - initialDistance
point1.x = nudgedPoint.x
point1.y = nudgedPoint.y
if (shouldAddExtraPointForNudge) {
const midPoint = Vec.Lrp(point2, point1, 0.5)
route.skipPointsWhenDrawing.add(midPoint)
route.points.splice(segment === 'first' ? 1 : route.points.length - 1, 0, midPoint)
}
}
}
function fixTinyEndNubs(
route: ElbowArrowRoute,
aTerminal: ElbowArrowTerminal,
bTerminal: ElbowArrowTerminal
) {
if (!route) return
if (route.points.length >= 3) {
const a = route.points[0]
const b = route.points[1]
const firstSegmentLength = Vec.ManhattanDist(a, b)
if (firstSegmentLength < aTerminal.minEndSegmentLength) {
route.points.splice(1, 1)
if (route.points.length >= 3) {
const matchAxis = approximately(a.x, b.x) ? 'y' : 'x'
route.points[1][matchAxis] = a[matchAxis]
}
}
}
if (route.points.length >= 3) {
const a = route.points[route.points.length - 1]
const b = route.points[route.points.length - 2]
const lastSegmentLength = Vec.ManhattanDist(a, b)
if (lastSegmentLength < bTerminal.minEndSegmentLength) {
route.points.splice(route.points.length - 2, 1)
if (route.points.length >= 3) {
const matchAxis = approximately(a.x, b.x) ? 'y' : 'x'
route.points[route.points.length - 2][matchAxis] = a[matchAxis]
}
}
}
}
function adjustTerminalForUnclosedPathIfNeeded(
terminal: ElbowArrowTerminal,
otherTerminal: ElbowArrowTerminal,
options: ElbowArrowOptions
): ElbowArrowTerminal {
if (!terminal.geometry || terminal.geometry.isClosed) return terminal
const normalizedPointAlongPath = terminal.geometry.uninterpolateAlongEdge(
terminal.target,
Geometry2dFilters.EXCLUDE_NON_STANDARD
)
const prev = terminal.geometry.interpolateAlongEdge(
normalizedPointAlongPath - 0.01 / terminal.geometry.length
)
const next = terminal.geometry.interpolateAlongEdge(
normalizedPointAlongPath + 0.01 / terminal.geometry.length
)
const normal = next.sub(prev).per().uni()
const axis = Math.abs(normal.x) > Math.abs(normal.y) ? ElbowArrowAxes.x : ElbowArrowAxes.y
if (terminal.geometry.bounds.containsPoint(otherTerminal.target, options.expandElbowLegLength)) {
terminal.side = axis.self
return convertTerminalToPoint(terminal)
}
const min = axis.v(
terminal.target[axis.self] - terminal.bounds[axis.size] * 2,
terminal.target[axis.cross]
)
const max = axis.v(
terminal.target[axis.self] + terminal.bounds[axis.size] * 2,
terminal.target[axis.cross]
)
let furthestIntersectionTowardsMin: VecLike | null = null
let furthestIntersectionTowardsMinDistance = 0
let furthestIntersectionTowardsMax: VecLike | null = null
let furthestIntersectionTowardsMaxDistance = 0
let side: ElbowArrowSideWithAxis = axis.self
for (const intersection of terminal.geometry.intersectLineSegment(
min,
max,
Geometry2dFilters.EXCLUDE_NON_STANDARD
)) {
if (Math.abs(intersection[axis.self] - terminal.target[axis.self]) < 1) {
continue
}
if (intersection[axis.self] < terminal.target[axis.self]) {
if (
Vec.ManhattanDist(intersection, terminal.target) > furthestIntersectionTowardsMinDistance
) {
furthestIntersectionTowardsMinDistance = Vec.ManhattanDist(intersection, terminal.target)
furthestIntersectionTowardsMin = intersection
}
} else {
if (
Vec.ManhattanDist(intersection, terminal.target) > furthestIntersectionTowardsMaxDistance
) {
furthestIntersectionTowardsMaxDistance = Vec.ManhattanDist(intersection, terminal.target)
furthestIntersectionTowardsMax = intersection
}
}
}
if (furthestIntersectionTowardsMin && furthestIntersectionTowardsMax) {
if (furthestIntersectionTowardsMinDistance > furthestIntersectionTowardsMaxDistance) {
side = axis.hiEdge
} else {
side = axis.loEdge
}
} else if (furthestIntersectionTowardsMin && !furthestIntersectionTowardsMax) {
side = axis.hiEdge
} else if (!furthestIntersectionTowardsMin && furthestIntersectionTowardsMax) {
side = axis.loEdge
}
terminal.side = side
return terminal
}

View File

@@ -0,0 +1,81 @@
import { assert, clamp } from '@tldraw/editor'
export interface Range {
min: number
max: number
}
export function expandRange(range: Range, amount: number) {
const newRange: Range = {
min: range.min - amount,
max: range.max + amount,
}
if (newRange.min > newRange.max) {
return null
}
return newRange
}
export function clampToRange(value: number, range: Range) {
return clamp(value, range.min, range.max)
}
/**
* Subtract the range b from the range a. If b is completely inside a, return the two ranges of a
* that are outside of b. If b contains a, return []. Otherwise, return the range of a that is
* outside of b.
*/
export function subtractRange(a: Range, b: Range): [] | [Range] | [Range, Range] {
assert(a.min <= a.max && b.min <= b.max)
// if b is completely inside a:
if (a.min <= b.min && b.max <= a.max) {
return [
{ min: a.min, max: b.min },
{ min: b.max, max: a.max },
]
}
// if b is completely outside of a:
if (b.max <= a.min || b.min >= a.max) {
return [a]
}
// if b fully contains a:
if (b.min <= a.min && a.max <= b.max) {
return []
}
// if b overlaps a on the low side:
if (isWithinRange(a.min, b)) {
return [{ min: b.max, max: a.max }]
}
// if b overlaps a on the high side:
if (isWithinRange(a.max, b)) {
return [{ min: a.min, max: b.min }]
}
// unreachable (?)
return []
}
export function createRange(a: number, b: number) {
return { min: Math.min(a, b), max: Math.max(a, b) }
}
export function doRangesOverlap(a: Range, b: Range) {
return a.min <= b.max && a.max >= b.min
}
export function isWithinRange(value: number, range: Range) {
return value >= range.min && value <= range.max
}
export function rangeSize(range: Range) {
return range.max - range.min
}
export function rangeCenter(range: Range) {
return (range.min + range.max) / 2
}

View File

@@ -0,0 +1,93 @@
import { assert, Vec, VecLike } from '@tldraw/editor'
import { ElbowArrowMidpointHandle, ElbowArrowRoute } from '../definitions'
import { ElbowArrowWorkingInfo } from './ElbowArrowWorkingInfo'
const MIN_DISTANCE = 0.01
export class ElbowArrowRouteBuilder {
points: Vec[] = []
constructor(
private readonly info: ElbowArrowWorkingInfo,
public readonly name: string
) {}
add(x: number, y: number): this {
this.points.push(this.info.vec(x, y))
return this
}
private _midpointHandle: ElbowArrowMidpointHandle | null = null
midpointHandle(axis: 'x' | 'y'): this {
assert(this._midpointHandle === null, 'midX/midY called multiple times')
const point = Vec.Lrp(
this.points[this.points.length - 2],
this.points[this.points.length - 1],
0.5
)
this._midpointHandle = {
axis: this.info.transform.transpose ? (axis === 'x' ? 'y' : 'x') : axis,
point,
segmentStart: this.points[this.points.length - 2].clone(),
segmentEnd: this.points[this.points.length - 1].clone(),
}
return this
}
build(): ElbowArrowRoute {
const finalPoints = []
for (let i = 0; i < this.points.length; i++) {
const p0 = this.points[i]
const p1 = finalPoints[finalPoints.length - 1]
const p2 = finalPoints[finalPoints.length - 2]
if (!p1 || !p2) {
finalPoints.push(p0)
} else {
const d1x = Math.abs(p0.x - p1.x)
const d1y = Math.abs(p0.y - p1.y)
const d2x = Math.abs(p0.x - p2.x)
const d2y = Math.abs(p0.y - p2.y)
if (d1x < MIN_DISTANCE && d1y < MIN_DISTANCE) {
// this point is basically in the same place as the last one, so ignore it
} else if (d1x < MIN_DISTANCE && d2x < MIN_DISTANCE) {
// this coord is extending the same vertical line as the last two, so update the
// last point to this one.
p1.y = p0.y
} else if (d1y < MIN_DISTANCE && d2y < MIN_DISTANCE) {
// this coord is extending the same horizontal line as the last two, so update the
// last point to this one.
p1.x = p0.x
} else {
// this coord is changing direction, so add it to the points
finalPoints.push(p0)
}
}
}
return {
name: this.name,
points: finalPoints,
distance: measureRouteManhattanDistance(finalPoints),
aEdgePicking: 'manual',
bEdgePicking: 'manual',
skipPointsWhenDrawing: new Set(),
midpointHandle: this._midpointHandle,
}
}
}
function measureRouteManhattanDistance(path: VecLike[]): number {
let distance = 0
for (let i = 0; i < path.length - 1; i++) {
const start = path[i]
const end = path[i + 1]
distance += Math.abs(end.x - start.x) + Math.abs(end.y - start.y)
}
return distance
}

View File

@@ -0,0 +1,44 @@
import {
debugElbowArrowTransform,
ElbowArrowTransform,
transformElbowArrowTransform,
} from './ElbowArrowWorkingInfo'
describe('ElbowArrowTransform', () => {
describe('debugElbowArrowTransform', () => {
it.each([
['Identity'],
['Rotate90'],
['Rotate180'],
['Rotate270'],
['FlipX'],
['FlipY'],
] as const)('%s', (a) => {
expect(debugElbowArrowTransform(ElbowArrowTransform[a])).toEqual(a)
})
})
describe('transformElbowArrowTransform', () => {
it.each([
['Identity', 'Identity', 'Identity'],
['Rotate90', 'Identity', 'Rotate90'],
['Rotate180', 'Identity', 'Rotate180'],
['Rotate270', 'Identity', 'Rotate270'],
['FlipX', 'Identity', 'FlipX'],
['FlipY', 'Identity', 'FlipY'],
['Identity', 'Rotate90', 'Rotate90'],
['Identity', 'Rotate180', 'Rotate180'],
['Identity', 'Rotate270', 'Rotate270'],
['Identity', 'FlipX', 'FlipX'],
['Identity', 'FlipY', 'FlipY'],
] as const)(`%s + %s = %s`, (a, b, expected) => {
expect(
debugElbowArrowTransform(
transformElbowArrowTransform(ElbowArrowTransform[a], ElbowArrowTransform[b])
)
).toEqual(debugElbowArrowTransform(ElbowArrowTransform[expected]))
})
})
})

View File

@@ -0,0 +1,233 @@
import { Box, Vec, VecLike } from '@tldraw/editor'
import {
ElbowArrowBoxEdges,
ElbowArrowEdge,
ElbowArrowInfoWithoutRoute,
ElbowArrowOptions,
} from '../definitions'
/**
* A transform that can be applied when working on elbow arrows. This only models flipping x/y or
* transposing x/y (for 90 degree rotations).
*/
export interface ElbowArrowTransform {
readonly x: 1 | -1
readonly y: 1 | -1
readonly transpose: boolean
}
function flipEdgeCrossInPlace(edge: ElbowArrowEdge | null) {
if (!edge) return
const tmp = edge.cross.min
edge.cross.min = -edge.cross.max
edge.cross.max = -tmp
edge.crossTarget = -edge.crossTarget
}
function flipEdgeValueInPlace(edge: ElbowArrowEdge | null) {
if (!edge) return
edge.value = -edge.value
edge.expanded = edge.expanded === null ? null : -edge.expanded
}
export const ElbowArrowTransform = {
Identity: { x: 1, y: 1, transpose: false } as const,
Rotate90: { x: -1, y: 1, transpose: true } as const,
Rotate180: { x: -1, y: -1, transpose: false } as const,
Rotate270: { x: 1, y: -1, transpose: true } as const,
FlipX: { x: -1, y: 1, transpose: false } as const,
FlipY: { x: 1, y: -1, transpose: false } as const,
}
function invertElbowArrowTransform(transform: ElbowArrowTransform): ElbowArrowTransform {
if (transform.transpose) {
return {
x: transform.y,
y: transform.x,
transpose: true,
}
}
return transform
}
export function transformElbowArrowTransform(a: ElbowArrowTransform, b: ElbowArrowTransform) {
// apply b to a:
const next = { ...a }
if (b.transpose) {
swap(next, 'x', 'y')
next.transpose = !next.transpose
}
if (b.x === -1) {
next.x = -next.x as 1 | -1
}
if (b.y === -1) {
next.y = -next.y as 1 | -1
}
return next
}
function swap<const A extends string, const B extends string>(
object: { [key in A | B]: any },
a: A,
b: B
) {
const temp = object[a]
object[a] = object[b]
object[b] = temp
}
function transformVecInPlace(transform: ElbowArrowTransform, point: VecLike) {
point.x = transform.x * point.x
point.y = transform.y * point.y
if (transform.transpose) {
swap(point, 'x', 'y')
}
}
function transformBoxInPlace(transform: ElbowArrowTransform, box: Box) {
if (transform.x === -1) {
box.x = -(box.x + box.width)
}
if (transform.y === -1) {
box.y = -(box.y + box.height)
}
if (transform.transpose) {
swap(box, 'x', 'y')
swap(box, 'width', 'height')
}
}
function transformEdgesInPlace(transform: ElbowArrowTransform, edges: ElbowArrowBoxEdges) {
if (transform.x === -1) {
swap(edges, 'left', 'right')
flipEdgeCrossInPlace(edges.top)
flipEdgeCrossInPlace(edges.bottom)
flipEdgeValueInPlace(edges.left)
flipEdgeValueInPlace(edges.right)
}
if (transform.y === -1) {
swap(edges, 'top', 'bottom')
flipEdgeCrossInPlace(edges.left)
flipEdgeCrossInPlace(edges.right)
flipEdgeValueInPlace(edges.top)
flipEdgeValueInPlace(edges.bottom)
}
if (transform.transpose) {
swap(edges, 'left', 'top')
swap(edges, 'right', 'bottom')
}
}
export function debugElbowArrowTransform(transform: ElbowArrowTransform) {
switch (
`${transform.transpose ? 't' : ''}${transform.x === -1 ? 'x' : ''}${transform.y === -1 ? 'y' : ''}`
) {
case '':
return 'Identity'
case 't':
return 'Transpose'
case 'x':
return 'FlipX'
case 'y':
return 'FlipY'
case 'tx':
return 'Rotate90'
case 'ty':
return 'Rotate270'
case 'xy':
return 'Rotate180'
case 'txy':
return 'spooky (transpose + flip both)'
default:
throw new Error('Unknown transform')
}
}
export interface ElbowArrowWorkingBox {
original: Box
expanded: Box
edges: ElbowArrowBoxEdges
isPoint: boolean
}
export class ElbowArrowWorkingInfo {
options: ElbowArrowOptions
A: ElbowArrowWorkingBox
B: ElbowArrowWorkingBox
common: {
original: Box
expanded: Box
}
gapX: number
gapY: number
midX: number | null
midY: number | null
bias: Vec
constructor(info: ElbowArrowInfoWithoutRoute) {
this.options = info.options
this.A = info.A
this.B = info.B
this.common = info.common
this.midX = info.midX
this.midY = info.midY
this.gapX = info.gapX
this.gapY = info.gapY
// prefer down/right when routing arrows
this.bias = new Vec(1, 1)
}
transform: ElbowArrowTransform = ElbowArrowTransform.Identity
inverse: ElbowArrowTransform = ElbowArrowTransform.Identity
apply(transform: ElbowArrowTransform) {
this.transform = transformElbowArrowTransform(transform, this.transform)
this.inverse = invertElbowArrowTransform(this.transform)
transformBoxInPlace(transform, this.A.original)
transformBoxInPlace(transform, this.B.original)
transformBoxInPlace(transform, this.common.original)
transformBoxInPlace(transform, this.A.expanded)
transformBoxInPlace(transform, this.B.expanded)
transformBoxInPlace(transform, this.common.expanded)
transformEdgesInPlace(transform, this.A.edges)
transformEdgesInPlace(transform, this.B.edges)
transformVecInPlace(transform, this.bias)
if (transform.x === -1) {
this.gapX = -this.gapX
this.midX = this.midX === null ? null : -this.midX
}
if (transform.y === -1) {
this.gapY = -this.gapY
this.midY = this.midY === null ? null : -this.midY
}
if (transform.transpose) {
let temp = this.midX
this.midX = this.midY
this.midY = temp
temp = this.gapX
this.gapX = this.gapY
this.gapY = temp
}
}
reset() {
this.apply(this.inverse)
}
vec(x: number, y: number) {
const point = new Vec(x, y)
transformVecInPlace(this.inverse, point)
return point
}
}

View File

@@ -0,0 +1,403 @@
import { ElbowArrowRoute, ElbowArrowSide } from '../definitions'
import { ElbowArrowRouteBuilder } from './ElbowArrowRouteBuilder'
import { ElbowArrowTransform, ElbowArrowWorkingInfo } from './ElbowArrowWorkingInfo'
/**
* Draw one of these arrows:
*
* ```
* 1: 2: 3: 4: 5:
* ┌───┐ ┌───┐ ┌───┐ ┌───────┐ ┌───────┐ ┌───┐
* │ A ├─┐ │ A ├─┐ │ A ├───┐ │ ┌───┐ │ │ ┌───┐ │ │ A ├─┐
* └───┘ │ ┌───┐ └───┘ │ └───┘ │ │ │ A ├─┘ └─► B │ │ └───┘ │
* └─► B │ ┌────┘ ┌───┐ │ │ └───┘ └───┘ └───────┘
* └───┘ │ ┌───┐ ┌►│ B │ │ │ ┌───┐
* └─► B │ │ └───┘ │ └───► B │
* └───┘ └───────┘ └───┘
* ```
*/
export function routeRightToLeft(info: ElbowArrowWorkingInfo): ElbowArrowRoute | null {
const aEdge = info.A.edges.right
const bEdge = info.B.edges.left
// we can't draw this arrow if we don't have the proper edge we want:
if (!aEdge || !bEdge) return null
// just so we get to handle less edge cases, flip the boxes if A is below B:
if (aEdge.crossTarget > bEdge.crossTarget) {
info.apply(ElbowArrowTransform.FlipY)
}
if (info.gapX > 0 && info.midX !== null) {
// Arrow 1:
return new ElbowArrowRouteBuilder(info, 'to left 1')
.add(aEdge.value, aEdge.crossTarget)
.add(info.midX, aEdge.crossTarget)
.add(info.midX, bEdge.crossTarget)
.midpointHandle('x')
.add(bEdge.value, bEdge.crossTarget)
.build()
}
if (aEdge.expanded === null || bEdge.expanded === null) return null
if (info.midY !== null) {
// Arrow 2:
return new ElbowArrowRouteBuilder(info, 'to left 2')
.add(aEdge.value, aEdge.crossTarget)
.add(aEdge.expanded, aEdge.crossTarget)
.add(aEdge.expanded, info.midY)
.add(bEdge.expanded, info.midY)
.midpointHandle('y')
.add(bEdge.expanded, bEdge.crossTarget)
.add(bEdge.value, bEdge.crossTarget)
.build()
}
const arrow3Distance =
Math.abs(aEdge.value - info.common.expanded.right) +
Math.abs(aEdge.crossTarget - info.common.expanded.bottom) +
Math.abs(info.common.expanded.right - bEdge.expanded) +
Math.abs(info.common.expanded.bottom - bEdge.crossTarget) +
info.options.expandElbowLegLength +
6 // 6 points in this arrow
const arrow4Distance =
info.options.expandElbowLegLength +
Math.abs(aEdge.crossTarget - info.common.expanded.top) +
Math.abs(aEdge.expanded - info.common.expanded.left) +
Math.abs(info.common.expanded.top - bEdge.crossTarget) +
Math.abs(info.common.expanded.left - bEdge.value) +
// 6 points in this arrow, plus bias towards down/right:
6 +
info.bias.y
const arrow5Distance =
info.gapX < 0 && info.midX !== null
? info.options.expandElbowLegLength +
Math.abs(aEdge.crossTarget - info.A.expanded.bottom) +
info.common.expanded.width +
Math.abs(info.A.expanded.bottom - info.B.expanded.top) +
Math.abs(info.B.expanded.top - bEdge.crossTarget) +
info.options.expandElbowLegLength +
// 8 points in this arrow
8
: Infinity
if (arrow3Distance < arrow4Distance && arrow3Distance < arrow5Distance) {
// Arrow 3:
return new ElbowArrowRouteBuilder(info, 'to left 3')
.add(aEdge.value, aEdge.crossTarget)
.add(info.common.expanded.right, aEdge.crossTarget)
.add(info.common.expanded.right, info.common.expanded.bottom)
.add(bEdge.expanded, info.common.expanded.bottom)
.add(bEdge.expanded, bEdge.crossTarget)
.add(bEdge.value, bEdge.crossTarget)
.build()
}
if (arrow4Distance < arrow5Distance) {
// Arrow 4:
return new ElbowArrowRouteBuilder(info, 'to left 4')
.add(aEdge.value, aEdge.crossTarget)
.add(aEdge.expanded, aEdge.crossTarget)
.add(aEdge.expanded, info.common.expanded.top)
.add(info.common.expanded.left, info.common.expanded.top)
.add(info.common.expanded.left, bEdge.crossTarget)
.add(bEdge.value, bEdge.crossTarget)
.build()
}
if (info.midX !== null) {
// Arrow 5:
return new ElbowArrowRouteBuilder(info, 'to left 5')
.add(aEdge.value, aEdge.crossTarget)
.add(aEdge.expanded, aEdge.crossTarget)
.add(aEdge.expanded, info.A.expanded.bottom)
.add(info.midX, info.A.expanded.bottom)
.add(info.midX, info.B.expanded.top)
.midpointHandle('y')
.add(bEdge.expanded, info.B.expanded.top)
.add(bEdge.expanded, bEdge.crossTarget)
.add(bEdge.value, bEdge.crossTarget)
.build()
}
return null
}
/**
* Draw one of these arrows:
* ```
* 1: 2: 3:
* ┌───┐ ┌───┐ ┌───┐
* │ A ├─────┐ ┌───┐ │ ┌─▼─┐ │ A ├─┐
* └───┘ │ │ A ├─┘ │ B │ └───┘ │
* ┌─▼─┐ └───┘ └───┘ ┌───┘
* │ B │ ┌─▼─┐
* └───┘ │ B │
* └───┘
* 4: 5: 6:
* ┌───┐ ┌───┐ ┌───┐ ┌───┐
* ┌─▼─┐ │ │ ┌─▼─┐ ┌─▼─┐ │ │ A ├─┐
* │ B │ │ │ │ B │ │ B │ │ └───┘ │
* └───┘ │ │ └───┘ └───┘ └───────┘
* ┌───┐ │ └───┐
* │ A ├─┘ ┌───┐ │
* └───┘ │ A ├─┘
* └───┘
* ```
*/
export function routeRightToTop(info: ElbowArrowWorkingInfo): ElbowArrowRoute | null {
const aEdge = info.A.edges.right
const bEdge = info.B.edges.top
if (!aEdge || !bEdge) return null
if (
(aEdge.crossTarget < (bEdge.expanded ?? bEdge.value) &&
bEdge.crossTarget > (aEdge.expanded ?? aEdge.value)) ||
(info.A.isPoint && info.B.expanded.containsPoint(info.A.original.center))
) {
// Arrow 1:
return new ElbowArrowRouteBuilder(info, 'to top 1')
.add(aEdge.value, aEdge.crossTarget)
.add(bEdge.crossTarget, aEdge.crossTarget)
.add(bEdge.crossTarget, bEdge.value)
.build()
}
if (info.gapX > 0 && info.midX !== null && bEdge.expanded !== null) {
// Arrow 2:
return new ElbowArrowRouteBuilder(info, 'to top 2')
.add(aEdge.value, aEdge.crossTarget)
.add(info.midX, aEdge.crossTarget)
.add(info.midX, bEdge.expanded)
.midpointHandle('x')
.add(bEdge.crossTarget, bEdge.expanded)
.add(bEdge.crossTarget, bEdge.value)
.build()
}
if (
info.gapY > 0 &&
aEdge.expanded !== null &&
bEdge.crossTarget < aEdge.expanded &&
info.midY !== null
) {
// Arrow 3:
return new ElbowArrowRouteBuilder(info, 'to top 3')
.add(aEdge.value, aEdge.crossTarget)
.add(aEdge.expanded, aEdge.crossTarget)
.add(aEdge.expanded, info.midY)
.add(bEdge.crossTarget, info.midY)
.midpointHandle('y')
.add(bEdge.crossTarget, bEdge.value)
.build()
}
const arrow4Length =
Math.abs(aEdge.value - info.common.expanded.right) +
Math.abs(aEdge.crossTarget - info.common.expanded.top) +
Math.abs(bEdge.crossTarget - info.common.expanded.right) +
Math.abs(bEdge.value - info.common.expanded.top)
const arrow5Length =
aEdge.expanded !== null && info.midY !== null && bEdge.expanded !== null
? Math.abs(aEdge.value - aEdge.expanded) +
Math.abs(info.B.expanded.left - aEdge.expanded) +
Math.abs(info.B.expanded.left - bEdge.crossTarget) +
Math.abs(aEdge.crossTarget - info.B.expanded.top) +
Math.abs(bEdge.value - bEdge.expanded)
: Infinity
const arrow6Length =
aEdge.expanded !== null && info.midX !== null && bEdge.expanded !== null
? Math.abs(aEdge.value - info.common.expanded.right) +
Math.abs(aEdge.crossTarget - info.A.expanded.bottom) +
Math.abs(aEdge.expanded - bEdge.crossTarget) +
Math.abs(info.A.expanded.bottom - bEdge.expanded) +
Math.abs(bEdge.expanded - bEdge.value)
: Infinity
if (arrow4Length < arrow5Length && arrow4Length < arrow6Length) {
// Arrow 4:
return new ElbowArrowRouteBuilder(info, 'to top 4')
.add(aEdge.value, aEdge.crossTarget)
.add(info.common.expanded.right, aEdge.crossTarget)
.add(info.common.expanded.right, info.common.expanded.top)
.add(bEdge.crossTarget, info.common.expanded.top)
.add(bEdge.crossTarget, bEdge.value)
.build()
}
if (
bEdge.expanded !== null &&
aEdge.expanded !== null &&
info.midY !== null &&
arrow5Length < arrow6Length
) {
// Arrow 5:
return new ElbowArrowRouteBuilder(info, 'to top 5')
.add(aEdge.value, aEdge.crossTarget)
.add(aEdge.expanded, aEdge.crossTarget)
.add(aEdge.expanded, info.midY)
.add(info.B.expanded.left, info.midY)
.midpointHandle('y')
.add(info.B.expanded.left, bEdge.expanded)
.add(bEdge.crossTarget, bEdge.expanded)
.add(bEdge.crossTarget, bEdge.value)
.build()
}
if (bEdge.expanded !== null && aEdge.expanded !== null && info.midX !== null) {
// Arrow 6:
return new ElbowArrowRouteBuilder(info, 'to top 6')
.add(aEdge.value, aEdge.crossTarget)
.add(aEdge.expanded, aEdge.crossTarget)
.add(aEdge.expanded, info.A.expanded.bottom)
.add(info.midX, info.A.expanded.bottom)
.add(info.midX, bEdge.expanded)
.midpointHandle('x')
.add(bEdge.crossTarget, bEdge.expanded)
.add(bEdge.crossTarget, bEdge.value)
.build()
}
return null
}
/**
* See `routeRightToTop`.
*/
export function routeRightToBottom(info: ElbowArrowWorkingInfo): ElbowArrowRoute | null {
info.apply(ElbowArrowTransform.FlipY)
return routeRightToTop(info)
}
/**
* Arrows may be mirrored - Y flipped
* ```
* 1: 2: 3:
* ┌───┐ ┌───┐ ┌───────┐ ┌───┐
* │ A ├─┐ │ A ├─┘ ┌───┐ │ ┌───┐ │ A ├─┐
* └───┘ │ └───┘ │ B ◄─┘ │ B ◄─┐ └───┘ │
* ┌───┐ │ └───┘ └───┘ └───────┘
* │ B ◄─┘
* └───┘
* ```
*/
export function routeRightToRight(info: ElbowArrowWorkingInfo): ElbowArrowRoute | null {
const aEdge = info.A.edges.right
const bEdge = info.B.edges.right
if (!aEdge || !bEdge) return null
if (
(info.gapX <= 0 ||
aEdge.crossTarget > info.B.expanded.bottom ||
aEdge.crossTarget < info.B.expanded.top) &&
(bEdge.value > info.A.original.left ||
bEdge.crossTarget > info.A.expanded.bottom ||
bEdge.crossTarget < info.A.expanded.top)
) {
// Arrow 1
return new ElbowArrowRouteBuilder(info, 'to right 1')
.add(aEdge.value, aEdge.crossTarget)
.add(info.common.expanded.right, aEdge.crossTarget)
.add(info.common.expanded.right, bEdge.crossTarget)
.add(bEdge.value, bEdge.crossTarget)
.build()
}
if (info.midX === null) return null
if (bEdge.expanded !== null && info.gapX >= 0) {
const viaBottomLength =
Math.abs(bEdge.crossTarget - info.B.expanded.bottom) +
Math.abs(aEdge.crossTarget - info.B.expanded.bottom)
const viaTopLength =
Math.abs(bEdge.crossTarget - info.B.expanded.top) +
Math.abs(aEdge.crossTarget - info.B.expanded.top)
const topOrBottom = viaBottomLength < viaTopLength ? 'bottom' : 'top'
// Arrow 2:
return new ElbowArrowRouteBuilder(info, `to right 2 via ${topOrBottom}`)
.add(aEdge.value, aEdge.crossTarget)
.add(info.midX, aEdge.crossTarget)
.add(info.midX, info.B.expanded[topOrBottom])
.midpointHandle('x')
.add(bEdge.expanded, info.B.expanded[topOrBottom])
.add(bEdge.expanded, bEdge.crossTarget)
.add(bEdge.value, bEdge.crossTarget)
.build()
}
if (aEdge.expanded !== null && info.gapX <= 0) {
const viaBottomLength =
Math.abs(bEdge.crossTarget - info.A.expanded.bottom) +
Math.abs(aEdge.crossTarget - info.A.expanded.bottom)
const viaTopLength =
Math.abs(bEdge.crossTarget - info.A.expanded.top) +
Math.abs(aEdge.crossTarget - info.A.expanded.top)
const topOrBottom = viaBottomLength < viaTopLength ? 'bottom' : 'top'
// Arrow 3:
return new ElbowArrowRouteBuilder(info, `to right 3 via ${topOrBottom}`)
.add(aEdge.value, aEdge.crossTarget)
.add(aEdge.expanded, aEdge.crossTarget)
.add(aEdge.expanded, info.A.expanded[topOrBottom])
.add(info.midX, info.A.expanded[topOrBottom])
.add(info.midX, bEdge.crossTarget)
.midpointHandle('x')
.add(bEdge.value, bEdge.crossTarget)
.build()
}
return null
}
const routes = {
top: {
top: [ElbowArrowTransform.Rotate270, routeRightToRight],
left: [ElbowArrowTransform.Rotate270, routeRightToTop],
bottom: [ElbowArrowTransform.Rotate270, routeRightToLeft],
right: [ElbowArrowTransform.Rotate270, routeRightToBottom],
},
right: {
top: [ElbowArrowTransform.Identity, routeRightToTop],
right: [ElbowArrowTransform.Identity, routeRightToRight],
bottom: [ElbowArrowTransform.Identity, routeRightToBottom],
left: [ElbowArrowTransform.Identity, routeRightToLeft],
},
bottom: {
top: [ElbowArrowTransform.Rotate90, routeRightToLeft],
left: [ElbowArrowTransform.Rotate90, routeRightToBottom],
bottom: [ElbowArrowTransform.Rotate90, routeRightToRight],
right: [ElbowArrowTransform.Rotate90, routeRightToTop],
},
left: {
top: [ElbowArrowTransform.Rotate180, routeRightToBottom],
left: [ElbowArrowTransform.Rotate180, routeRightToRight],
bottom: [ElbowArrowTransform.Rotate180, routeRightToTop],
right: [ElbowArrowTransform.Rotate180, routeRightToLeft],
},
} satisfies Record<
ElbowArrowSide,
Record<
ElbowArrowSide,
[ElbowArrowTransform, (info: ElbowArrowWorkingInfo) => ElbowArrowRoute | null]
>
>
export function tryRouteArrow(
info: ElbowArrowWorkingInfo,
aEdge: ElbowArrowSide,
bEdge: ElbowArrowSide
): ElbowArrowRoute | null {
const [transform, routeFn] = routes[aEdge][bEdge]
info.apply(transform)
const route = routeFn(info)
info.reset()
return route
}

View File

@@ -0,0 +1,241 @@
import { exhaustiveSwitchError } from '@tldraw/editor'
import {
ElbowArrowRoute,
ElbowArrowSide,
ElbowArrowSideOpposites,
ElbowArrowSideReason,
ElbowArrowSides,
} from '../definitions'
import { tryRouteArrow } from './elbowArrowRoutes'
import { ElbowArrowWorkingInfo } from './ElbowArrowWorkingInfo'
export function routeArrowWithAutoEdgePicking(
info: ElbowArrowWorkingInfo,
reason: ElbowArrowSideReason
): ElbowArrowRoute | null {
let idealRoute = null
if (
// +1 to bias us towards the x-axis. without this, we get flicker as we move an arrow locket
// to 45 deg (as gapx/gapy are almost equal and the result depends on floating point
// precision)
Math.abs(info.gapX) + 1 > Math.abs(info.gapY) &&
info.midX !== null
) {
if (info.gapX > 0) {
idealRoute = tryRouteArrow(info, 'right', 'left')
} else {
idealRoute = tryRouteArrow(info, 'left', 'right')
}
} else {
const aRight = info.A.edges.right
const aLeft = info.A.edges.left
const bTop = info.B.edges.top
const bBottom = info.B.edges.bottom
if (info.A.isPoint && info.B.isPoint) {
if (info.gapY > 0) {
idealRoute = tryRouteArrow(info, 'bottom', 'top')
} else {
idealRoute = tryRouteArrow(info, 'top', 'bottom')
}
} else if (
aRight &&
bTop &&
(aRight.expanded ?? aRight.value) <= bTop.crossTarget &&
aRight.crossTarget <= (bTop.expanded ?? bTop.value)
) {
idealRoute = tryRouteArrow(info, 'right', 'top')
} else if (
aRight &&
bBottom &&
(aRight.expanded ?? aRight.value) <= bBottom.crossTarget &&
aRight.crossTarget >= (bBottom.expanded ?? bBottom.value)
) {
idealRoute = tryRouteArrow(info, 'right', 'bottom')
} else if (
aLeft &&
bTop &&
(aLeft.expanded ?? aLeft.value) >= bTop.crossTarget &&
aLeft.crossTarget <= (bTop.expanded ?? bTop.value)
) {
idealRoute = tryRouteArrow(info, 'left', 'top')
} else if (
aLeft &&
bBottom &&
(aLeft.expanded ?? aLeft.value) >= bBottom.crossTarget &&
aLeft.crossTarget >= (bBottom.expanded ?? bBottom.value)
) {
idealRoute = tryRouteArrow(info, 'left', 'bottom')
} else if (info.gapY > 0 && info.midY !== null) {
idealRoute = tryRouteArrow(info, 'bottom', 'top')
} else if (info.gapY < 0 && info.midY !== null) {
idealRoute = tryRouteArrow(info, 'top', 'bottom')
}
}
if (idealRoute) {
idealRoute.aEdgePicking = reason
idealRoute.bEdgePicking = reason
return idealRoute
}
const aAvailableSide = ElbowArrowSides.filter((side) => info.A.edges[side])
const bAvailableSides = ElbowArrowSides.filter((side) => info.B.edges[side])
const nonPartialRouteCandidates = aAvailableSide.flatMap((aSide) =>
bAvailableSides.map((bSide) => [aSide, bSide, reason, reason] as const)
)
return pickBest(info, nonPartialRouteCandidates)
}
export function routeArrowWithPartialEdgePicking(
info: ElbowArrowWorkingInfo,
aSide: ElbowArrowSide
) {
let idealRoute = null
const aRight = info.A.edges.right
const aLeft = info.A.edges.left
const bTop = info.B.edges.top
const bBottom = info.B.edges.bottom
switch (aSide) {
case 'right':
if (info.gapX > 0 && info.gapX > Math.abs(info.gapY) && info.midX !== null) {
idealRoute = tryRouteArrow(info, 'right', 'left')
} else if (
aRight &&
bTop &&
(aRight.expanded ?? aRight.value) <= bTop.crossTarget &&
aRight.crossTarget <= (bTop.expanded ?? bTop.value)
) {
idealRoute = tryRouteArrow(info, 'right', 'top')
} else if (
aRight &&
bBottom &&
(aRight.expanded ?? aRight.value) <= bBottom.crossTarget &&
aRight.crossTarget >= (bBottom.expanded ?? bBottom.value)
) {
idealRoute = tryRouteArrow(info, 'right', 'bottom')
}
break
case 'left':
if (info.gapX < 0 && Math.abs(info.gapX) > Math.abs(info.gapY) && info.midX !== null) {
idealRoute = tryRouteArrow(info, 'left', 'right')
} else if (
aLeft &&
bTop &&
(aLeft.expanded ?? aLeft.value) >= bTop.crossTarget &&
aLeft.crossTarget <= (bTop.expanded ?? bTop.value)
) {
idealRoute = tryRouteArrow(info, 'left', 'top')
} else if (
aLeft &&
bBottom &&
(aLeft.expanded ?? aLeft.value) >= bBottom.crossTarget &&
aLeft.crossTarget >= (bBottom.expanded ?? bBottom.value)
) {
idealRoute = tryRouteArrow(info, 'left', 'bottom')
}
break
case 'top':
case 'bottom':
// top and bottom are handled by the pickShortest approach below - it automatically
// picks the path we would pick with heuristics anyway.
break
default:
exhaustiveSwitchError(aSide)
}
if (idealRoute) {
idealRoute.aEdgePicking = 'manual'
idealRoute.bEdgePicking = 'auto'
return idealRoute
}
switch (aSide) {
case 'top':
return pickBest(info, [
['top', 'bottom', 'manual', 'auto'],
['top', 'right', 'manual', 'auto'],
['top', 'left', 'manual', 'auto'],
['top', 'top', 'manual', 'auto'],
])
case 'bottom':
return pickBest(info, [
['bottom', 'top', 'manual', 'auto'],
['bottom', 'right', 'manual', 'auto'],
['bottom', 'left', 'manual', 'auto'],
['bottom', 'bottom', 'manual', 'auto'],
])
case 'left':
return pickBest(info, [
['left', 'right', 'manual', 'auto'],
['left', 'bottom', 'manual', 'auto'],
['left', 'left', 'manual', 'auto'],
['left', 'top', 'manual', 'auto'],
])
case 'right':
return pickBest(info, [
['right', 'left', 'manual', 'auto'],
['right', 'bottom', 'manual', 'auto'],
['right', 'right', 'manual', 'auto'],
['right', 'top', 'manual', 'auto'],
])
}
}
export function routeArrowWithManualEdgePicking(
info: ElbowArrowWorkingInfo,
aSide: ElbowArrowSide,
bSide: ElbowArrowSide
) {
const route = tryRouteArrow(info, aSide, bSide)
if (route) return route
if (info.A.isPoint && info.B.isPoint) {
return pickBest(info, [
[ElbowArrowSideOpposites[aSide], ElbowArrowSideOpposites[bSide], 'manual', 'manual'],
[aSide, ElbowArrowSideOpposites[bSide], 'manual', 'auto'],
[ElbowArrowSideOpposites[aSide], bSide, 'auto', 'manual'],
])
} else if (info.A.isPoint) {
return tryRouteArrow(info, ElbowArrowSideOpposites[aSide], bSide)
} else if (info.B.isPoint) {
return tryRouteArrow(info, aSide, ElbowArrowSideOpposites[bSide])
}
return null
}
function pickBest(
info: ElbowArrowWorkingInfo,
edges: ReadonlyArray<
readonly [ElbowArrowSide, ElbowArrowSide, ElbowArrowSideReason, ElbowArrowSideReason]
>
) {
let bestRoute: ElbowArrowRoute | null = null
let bestCornerCount = Infinity
let bestDistance = Infinity
let distanceBias = 0
for (const [aSide, bSide, aEdgePicking, bEdgePicking] of edges) {
distanceBias += 1
const route = tryRouteArrow(info, aSide, bSide)
if (route) {
route.aEdgePicking = aEdgePicking
route.bEdgePicking = bEdgePicking
if (route.points.length < bestCornerCount) {
bestCornerCount = route.points.length
bestDistance = route.distance
bestRoute = route
} else if (
route.points.length === bestCornerCount &&
route.distance + distanceBias < bestDistance
) {
bestDistance = route.distance
bestRoute = route
}
}
}
return bestRoute
}

View File

@@ -0,0 +1,69 @@
import {
Editor,
isEqualAllowingForFloatingPointErrors,
TLArrowShape,
TLShapeId,
} from '@tldraw/editor'
import { createComputedCache } from '@tldraw/store'
import { STROKE_SIZES } from '../shared/default-shape-constants'
import { getDisplayValues } from '../shared/getDisplayValues'
import { TLArrowInfo } from './arrow-types'
import { getCurvedArrowInfo } from './curved-arrow'
import { getElbowArrowInfo } from './elbow/getElbowArrowInfo'
import { getArrowBindings, getIsArrowStraight } from './shared'
import { getStraightArrowInfo } from './straight-arrow'
const arrowInfoCache = createComputedCache<Editor, TLArrowInfo, TLArrowShape>(
'arrow info',
(editor: Editor, shape: TLArrowShape): TLArrowInfo => {
const bindings = getArrowBindings(editor, shape)
const util = editor.getShapeUtil(shape)
// A replacement arrow util may not carry display values; fall back to the theme width.
const sw =
'getDefaultDisplayValues' in util.options && 'getCustomDisplayValues' in util.options
? (getDisplayValues(util as any, shape) as { strokeWidth: number }).strokeWidth
: editor.getCurrentTheme().strokeWidth * STROKE_SIZES[shape.props.size]
if (shape.props.kind === 'elbow') {
const elbowInfo = getElbowArrowInfo(editor, shape, bindings, sw)
if (!elbowInfo?.route) return getStraightArrowInfo(editor, shape, bindings, sw)
const start = elbowInfo.swapOrder ? elbowInfo.B : elbowInfo.A
const end = elbowInfo.swapOrder ? elbowInfo.A : elbowInfo.B
return {
type: 'elbow',
bindings,
start: {
handle: start.target,
point: elbowInfo.route.points[0],
arrowhead: shape.props.arrowheadStart,
},
end: {
handle: end.target,
point: elbowInfo.route.points[elbowInfo.route.points.length - 1],
arrowhead: shape.props.arrowheadEnd,
},
elbow: elbowInfo,
route: elbowInfo.route,
isValid: true,
}
}
if (getIsArrowStraight(shape)) {
return getStraightArrowInfo(editor, shape, bindings, sw)
} else {
return getCurvedArrowInfo(editor, shape, bindings, sw)
}
},
{
areRecordsEqual: (a, b) => a.props === b.props,
areResultsEqual: isEqualAllowingForFloatingPointErrors,
}
)
/** @public */
export function getArrowInfo(editor: Editor, shape: TLArrowShape | TLShapeId) {
const id = typeof shape === 'string' ? shape : shape.id
return arrowInfoCache.get(editor, id)
}

View File

@@ -0,0 +1,302 @@
import {
Editor,
Geometry2d,
intersectLineSegmentPolygon,
Mat,
MatModel,
pointInPolygon,
TLArrowBinding,
TLArrowBindingProps,
TLArrowShape,
TLShape,
TLShapeId,
Vec,
} from '@tldraw/editor'
import { createComputedCache } from '@tldraw/store'
const MIN_ARROW_BEND = 8
// Keep anchors off exact edges/corners to avoid degenerate arrow intersections.
const NORMALIZED_ANCHOR_EPSILON = 1e-3
function clampNormalizedAnchor(anchor: { x: number; y: number }) {
const clamp = (v: number) =>
Math.max(NORMALIZED_ANCHOR_EPSILON, Math.min(1 - NORMALIZED_ANCHOR_EPSILON, v))
return { x: clamp(anchor.x), y: clamp(anchor.y) }
}
export function getIsArrowStraight(shape: TLArrowShape) {
if (shape.props.kind !== 'arc') return false
return Math.abs(shape.props.bend) < MIN_ARROW_BEND * shape.props.scale // snap to +-8px
}
export interface BoundShapeInfo<T extends TLShape = TLShape> {
shape: T
didIntersect: boolean
isExact: boolean
isClosed: boolean
transform: Mat
geometry: Geometry2d
}
export function getBoundShapeInfoForTerminal(
editor: Editor,
arrow: TLArrowShape,
terminalName: 'start' | 'end'
): BoundShapeInfo | undefined {
const binding = editor
.getBindingsFromShape(arrow, 'arrow')
.find((b) => b.props.terminal === terminalName)
if (!binding) return
const boundShape = editor.getShape(binding.toId)!
if (!boundShape) return
const transform = editor.getShapePageTransform(boundShape)!
const hasArrowhead =
terminalName === 'start'
? arrow.props.arrowheadStart !== 'none'
: arrow.props.arrowheadEnd !== 'none'
const geometry = editor.getShapeGeometry(
boundShape,
hasArrowhead ? undefined : { context: '@tldraw/arrow-without-arrowhead' }
)
return {
shape: boundShape,
transform,
isClosed: geometry.isClosed,
isExact: binding.props.isExact,
didIntersect: false,
geometry,
}
}
export function getArrowTerminalInArrowSpace(
editor: Editor,
arrowPageTransform: Mat,
binding: TLArrowBinding,
forceImprecise: boolean
) {
const boundShape = editor.getShape(binding.toId)
if (!boundShape) {
// this can happen in multiplayer contexts where the shape is being deleted
return new Vec(0, 0)
} else {
// Find the actual local point of the normalized terminal on
// the bound shape and transform it to page space, then transform
// it to arrow space
const { point, size } = editor.getShapeGeometry(boundShape).bounds
// If the parent is the bound shape, then it's always treated as precise.
const shouldUsePreciseAnchor = binding.props.isPrecise || forceImprecise
const normalizedAnchor = shouldUsePreciseAnchor
? clampNormalizedAnchor(binding.props.normalizedAnchor)
: { x: 0.5, y: 0.5 }
const shapePoint = Vec.Add(point, Vec.MulV(normalizedAnchor, size))
const pagePoint = Mat.applyToPoint(editor.getShapePageTransform(boundShape)!, shapePoint)
const arrowPoint = Mat.applyToPoint(Mat.Inverse(arrowPageTransform), pagePoint)
return arrowPoint
}
}
/** @public */
export interface TLArrowBindings {
start: TLArrowBinding | undefined
end: TLArrowBinding | undefined
}
const arrowBindingsCache = createComputedCache(
'arrow bindings',
(editor: Editor, arrow: TLArrowShape) => {
const bindings = editor.getBindingsFromShape(arrow.id, 'arrow')
return {
start: bindings.find((b) => b.props.terminal === 'start'),
end: bindings.find((b) => b.props.terminal === 'end'),
}
},
{
// we only look at the arrow IDs:
areRecordsEqual: (a, b) => a.id === b.id,
// the records should stay the same:
areResultsEqual: (a, b) => a.start === b.start && a.end === b.end,
}
)
/** @public */
export function getArrowBindings(editor: Editor, shape: TLArrowShape): TLArrowBindings {
return arrowBindingsCache.get(editor, shape.id)!
}
/** @public */
export function getArrowTerminalsInArrowSpace(
editor: Editor,
shape: TLArrowShape,
bindings: TLArrowBindings
) {
const arrowPageTransform = editor.getShapePageTransform(shape)!
const boundShapeRelationships = getBoundShapeRelationships(
editor,
bindings.start?.toId,
bindings.end?.toId
)
const start = bindings.start
? getArrowTerminalInArrowSpace(
editor,
arrowPageTransform,
bindings.start,
boundShapeRelationships === 'double-bound' ||
boundShapeRelationships === 'start-contains-end'
)
: Vec.From(shape.props.start)
const end = bindings.end
? getArrowTerminalInArrowSpace(
editor,
arrowPageTransform,
bindings.end,
boundShapeRelationships === 'double-bound' ||
boundShapeRelationships === 'end-contains-start'
)
: Vec.From(shape.props.end)
return { start, end }
}
/**
* Create or update the arrow binding for a particular arrow terminal. Will clear up if needed.
* @internal
*/
export function createOrUpdateArrowBinding(
editor: Editor,
arrow: TLArrowShape | TLShapeId,
target: TLShape | TLShapeId,
props: TLArrowBindingProps
) {
const arrowId = typeof arrow === 'string' ? arrow : arrow.id
const targetId = typeof target === 'string' ? target : target.id
const existingMany = editor
.getBindingsFromShape(arrowId, 'arrow')
.filter((b) => b.props.terminal === props.terminal)
// if we've somehow ended up with too many bindings, delete the extras
if (existingMany.length > 1) {
editor.deleteBindings(existingMany.slice(1))
}
const existing = existingMany[0]
if (existing) {
editor.updateBinding({
...existing,
toId: targetId,
props,
})
} else {
editor.createBinding({
type: 'arrow',
fromId: arrowId,
toId: targetId,
props,
})
}
}
/**
* Remove any arrow bindings for a particular terminal.
* @internal
*/
export function removeArrowBinding(editor: Editor, arrow: TLArrowShape, terminal: 'start' | 'end') {
const existing = editor
.getBindingsFromShape(arrow, 'arrow')
.filter((b) => b.props.terminal === terminal)
editor.deleteBindings(existing)
}
/** @internal */
export const MIN_ARROW_LENGTH = 10
/** @internal */
export const BOUND_ARROW_OFFSET = 10
/** @internal */
export const WAY_TOO_BIG_ARROW_BEND_FACTOR = 10
/**
* Get the relationships for an arrow that has two bound shape terminals.
* If the arrow has only one bound shape, then it is always "safe" to apply
* standard offsets and precision behavior. If the shape is bound to the same
* shape on both ends, then that is an exception. If one of the shape's
* terminals is bound to a shape that contains / is contained by the shape that
* is bound to the other terminal, then that is also an exception.
*
* @param editor - the editor instance
* @param startShapeId - the bound shape from the arrow's start
* @param endShapeId - the bound shape from the arrow's end
*
* @internal */
export function getBoundShapeRelationships(
editor: Editor,
startShapeId?: TLShapeId,
endShapeId?: TLShapeId
) {
if (!startShapeId || !endShapeId) return 'safe'
if (startShapeId === endShapeId) return 'double-bound'
const startBounds = editor.getShapePageBounds(startShapeId)
const endBounds = editor.getShapePageBounds(endShapeId)
if (startBounds && endBounds) {
if (startBounds.contains(endBounds)) return 'start-contains-end'
if (endBounds.contains(startBounds)) return 'end-contains-start'
}
return 'safe'
}
/**
* If the arrow terminal point falls outside the bound shape's mask (e.g. when a shape
* extends beyond a frame boundary and is clipped), clamp the terminal to the mask boundary.
* Uses the binding anchor point (inside the shape/frame) as the ray origin, since the
* arrow endpoint may be entirely outside the mask.
*
* @internal
*/
export function clampArrowTerminalToMask(
editor: Editor,
point: Vec,
terminalHandle: Vec,
arrowPageTransform: MatModel,
targetShapeInfo?: BoundShapeInfo
) {
if (!targetShapeInfo) return
const mask = editor.getShapeMask(targetShapeInfo.shape.id)
if (!mask) return
const pagePoint = Mat.applyToPoint(arrowPageTransform, point)
if (pointInPolygon(pagePoint, mask)) return
// The point is outside the mask (clipped). Cast a ray from the binding
// anchor (which is inside the shape, and typically inside the frame) through
// the intersection point on the shape boundary to find where it crosses the mask.
// We extend the line slightly past the anchor in case the anchor sits exactly
// on the mask boundary.
const pageAnchor = Mat.applyToPoint(arrowPageTransform, terminalHandle)
const direction = Vec.Sub(pageAnchor, pagePoint).uni()
const extendedAnchor = Vec.Add(pageAnchor, Vec.Mul(direction, 1))
const intersections = intersectLineSegmentPolygon(extendedAnchor, pagePoint, mask)
if (!intersections || intersections.length === 0) return
// Pick the intersection closest to the original point (nearest frame edge to the shape)
let closest = intersections[0]
let closestDist = Vec.Dist2(closest, pagePoint)
for (let i = 1; i < intersections.length; i++) {
const dist = Vec.Dist2(intersections[i], pagePoint)
if (dist < closestDist) {
closest = intersections[i]
closestDist = dist
}
}
const arrowPoint = Mat.applyToPoint(Mat.Inverse(arrowPageTransform), closest)
point.setTo(arrowPoint)
}

View File

@@ -0,0 +1,284 @@
import { Editor, Mat, MatModel, TLArrowShape, Vec, VecLike } from '@tldraw/editor'
import { STROKE_SIZES } from '../shared/default-shape-constants'
import { TLArrowInfo } from './arrow-types'
import {
BOUND_ARROW_OFFSET,
BoundShapeInfo,
MIN_ARROW_LENGTH,
TLArrowBindings,
clampArrowTerminalToMask,
getArrowTerminalsInArrowSpace,
getBoundShapeInfoForTerminal,
getBoundShapeRelationships,
} from './shared'
export function getStraightArrowInfo(
editor: Editor,
shape: TLArrowShape,
bindings: TLArrowBindings,
arrowStrokeWidth?: number
): TLArrowInfo {
const { arrowheadStart, arrowheadEnd } = shape.props
const terminalsInArrowSpace = getArrowTerminalsInArrowSpace(editor, shape, bindings)
const a = terminalsInArrowSpace.start.clone()
const b = terminalsInArrowSpace.end.clone()
const c = Vec.Med(a, b)
if (Vec.Equals(a, b)) {
return {
bindings,
type: 'straight',
start: {
handle: a,
point: a,
arrowhead: shape.props.arrowheadStart,
},
end: {
handle: b,
point: b,
arrowhead: shape.props.arrowheadEnd,
},
middle: c,
isValid: false,
length: 0,
}
}
const uAB = Vec.Sub(b, a).uni()
// Update the arrowhead points using intersections with the bound shapes, if any.
const startShapeInfo = getBoundShapeInfoForTerminal(editor, shape, 'start')
const endShapeInfo = getBoundShapeInfoForTerminal(editor, shape, 'end')
const arrowPageTransform = editor.getShapePageTransform(shape)!
// Update the position of the arrowhead's end point
updateArrowheadPointWithBoundShape(
b, // <-- will be mutated
terminalsInArrowSpace.start,
arrowPageTransform,
endShapeInfo
)
// Then update the position of the arrowhead's end point
updateArrowheadPointWithBoundShape(
a, // <-- will be mutated
terminalsInArrowSpace.end,
arrowPageTransform,
startShapeInfo
)
// Clamp terminals to mask boundaries if the bound shape is clipped (e.g. by a frame)
clampArrowTerminalToMask(editor, b, terminalsInArrowSpace.end, arrowPageTransform, endShapeInfo)
clampArrowTerminalToMask(
editor,
a,
terminalsInArrowSpace.start,
arrowPageTransform,
startShapeInfo
)
let offsetA = 0
let offsetB = 0
let strokeOffsetA = 0
let strokeOffsetB = 0
let minLength = MIN_ARROW_LENGTH * shape.props.scale
const theme = editor.getCurrentTheme()
const arrowSW = arrowStrokeWidth ?? theme.strokeWidth * STROKE_SIZES[shape.props.size]
const isSelfIntersection =
startShapeInfo && endShapeInfo && startShapeInfo.shape === endShapeInfo.shape
const relationship =
startShapeInfo && endShapeInfo
? getBoundShapeRelationships(editor, startShapeInfo.shape.id, endShapeInfo.shape.id)
: 'safe'
if (
relationship === 'safe' &&
startShapeInfo &&
endShapeInfo &&
!isSelfIntersection &&
!startShapeInfo.isExact &&
!endShapeInfo.isExact
) {
if (endShapeInfo.didIntersect && !startShapeInfo.didIntersect) {
// ...and if only the end shape intersected, then make it
// a short arrow ending at the end shape intersection.
if (startShapeInfo.isClosed) {
a.setTo(b.clone().add(uAB.clone().mul(MIN_ARROW_LENGTH * shape.props.scale)))
}
} else if (!endShapeInfo.didIntersect) {
// ...and if only the end shape intersected, or if neither
// shape intersected, then make it a short arrow starting
// at the start shape intersection.
if (endShapeInfo.isClosed) {
b.setTo(a.clone().sub(uAB.clone().mul(MIN_ARROW_LENGTH * shape.props.scale)))
}
}
}
const distance = Vec.Sub(b, a)
// Check for divide-by-zero before we call uni()
const u = Vec.Len(distance) ? distance.uni() : Vec.From(distance)
const didFlip = !Vec.Equals(u, uAB)
// If the arrow is bound non-exact to a start shape and the
// start point has an arrowhead, then offset the start point
if (!isSelfIntersection) {
if (
relationship !== 'start-contains-end' &&
startShapeInfo &&
arrowheadStart !== 'none' &&
!startShapeInfo.isExact
) {
strokeOffsetA =
arrowSW / 2 +
('size' in startShapeInfo.shape.props
? (theme.strokeWidth * (STROKE_SIZES[startShapeInfo.shape.props.size] ?? 0)) / 2
: 0)
offsetA = (BOUND_ARROW_OFFSET + strokeOffsetA) * shape.props.scale
minLength += strokeOffsetA * shape.props.scale
}
// If the arrow is bound non-exact to an end shape and the
// end point has an arrowhead offset the end point
if (
relationship !== 'end-contains-start' &&
endShapeInfo &&
arrowheadEnd !== 'none' &&
!endShapeInfo.isExact
) {
strokeOffsetB =
arrowSW / 2 +
('size' in endShapeInfo.shape.props
? (theme.strokeWidth * (STROKE_SIZES[endShapeInfo.shape.props.size] ?? 0)) / 2
: 0)
offsetB = (BOUND_ARROW_OFFSET + strokeOffsetB) * shape.props.scale
minLength += strokeOffsetB * shape.props.scale
}
}
// Adjust offsets if the length of the arrow is too small
const tA = a.clone().add(u.clone().mul(offsetA * (didFlip ? -1 : 1)))
const tB = b.clone().sub(u.clone().mul(offsetB * (didFlip ? -1 : 1)))
if (Vec.DistMin(tA, tB, minLength)) {
if (offsetA !== 0 && offsetB !== 0) {
// both bound + offset
offsetA *= -1.5
offsetB *= -1.5
} else if (offsetA !== 0) {
// start bound + offset
offsetA *= -1
} else if (offsetB !== 0) {
// end bound + offset
offsetB *= -1
} else {
// noop, its just a really short arrow
}
}
a.add(u.clone().mul(offsetA * (didFlip ? -1 : 1)))
b.sub(u.clone().mul(offsetB * (didFlip ? -1 : 1)))
// If the handles flipped their order, then set the center handle
// to the midpoint of the terminals (rather than the midpoint of the
// arrow body); otherwise, it may not be "between" the other terminals.
if (didFlip) {
if (startShapeInfo && endShapeInfo) {
// If we have two bound shapes...then make the arrow a short arrow from
// the start point towards where the end point should be.
b.setTo(Vec.Add(a, u.clone().mul(-MIN_ARROW_LENGTH * shape.props.scale)))
}
c.setTo(Vec.Med(terminalsInArrowSpace.start, terminalsInArrowSpace.end))
} else {
c.setTo(Vec.Med(a, b))
}
const length = Vec.Dist(a, b)
return {
bindings,
type: 'straight',
start: {
handle: terminalsInArrowSpace.start,
point: a,
arrowhead: shape.props.arrowheadStart,
},
end: {
handle: terminalsInArrowSpace.end,
point: b,
arrowhead: shape.props.arrowheadEnd,
},
middle: c,
isValid: length > 0,
length,
}
}
/** Get an intersection point from A -> B with bound shape (target) from shape (arrow). */
function updateArrowheadPointWithBoundShape(
point: Vec,
opposite: Vec,
arrowPageTransform: MatModel,
targetShapeInfo?: BoundShapeInfo
) {
if (targetShapeInfo === undefined) {
// No bound shape? The arrowhead point will be at the arrow terminal.
return
}
if (targetShapeInfo.isExact) {
// Exact type binding? The arrowhead point will be at the arrow terminal.
return
}
// From and To in page space
const pageFrom = Mat.applyToPoint(arrowPageTransform, opposite)
const pageTo = Mat.applyToPoint(arrowPageTransform, point)
// From and To in local space of the target shape
const targetFrom = Mat.applyToPoint(Mat.Inverse(targetShapeInfo.transform), pageFrom)
const targetTo = Mat.applyToPoint(Mat.Inverse(targetShapeInfo.transform), pageTo)
const intersection = Array.from(
targetShapeInfo.geometry.intersectLineSegment(targetFrom, targetTo, {
includeLabels: false,
includeInternal: false,
})
)
let targetInt: VecLike | undefined
if (intersection.length) {
targetInt =
intersection.sort((p1, p2) => Vec.Dist2(p1, targetFrom) - Vec.Dist2(p2, targetFrom))[0] ??
(targetShapeInfo.isClosed ? undefined : targetTo)
}
if (targetInt === undefined) {
// No intersection? The arrowhead point will be at the arrow terminal.
// if we _almost_ hit the target, just put the arrowhead at the target.
targetInt = targetShapeInfo.geometry.nearestPoint(targetTo, {
includeLabels: false,
includeInternal: false,
})
if (!Vec.DistMin(targetInt, targetTo, 1)) {
return
}
}
const pageInt = Mat.applyToPoint(targetShapeInfo.transform, targetInt)
const arrowInt = Mat.applyToPoint(Mat.Inverse(arrowPageTransform), pageInt)
point.setTo(arrowInt)
targetShapeInfo.didIntersect = true
}

View File

@@ -0,0 +1,80 @@
import { StateNode, TLKeyboardEventInfo, TLPointerEventInfo, TLShapeId } from '@tldraw/editor'
import { startEditingShape } from '../../../tools/SelectTool/selectHelpers'
import { ArrowShapeUtil } from '../ArrowShapeUtil'
import { clearArrowTargetState, updateArrowTargetState } from '../arrowTargetState'
export class Idle extends StateNode {
static override id = 'idle'
isPrecise = false
isPreciseTimerId: number | null = null
preciseTargetId: TLShapeId | null = null
override onPointerMove() {
this.update()
}
override onPointerDown(info: TLPointerEventInfo) {
this.parent.transition('pointing', { ...info, isPrecise: this.isPrecise })
}
override onEnter() {
this.editor.setCursor({ type: 'cross', rotation: 0 })
this.update()
}
override onCancel() {
this.editor.setCurrentTool('select')
}
override onExit() {
clearArrowTargetState(this.editor)
if (this.isPreciseTimerId !== null) {
this.editor.timers.clearTimeout(this.isPreciseTimerId)
}
}
override onKeyDown() {
this.update()
}
override onKeyUp(info: TLKeyboardEventInfo) {
this.update()
if (info.key === 'Enter') {
const onlySelectedShape = this.editor.getOnlySelectedShape()
if (!this.editor.canEditShape(onlySelectedShape)) return
startEditingShape(this.editor, onlySelectedShape, { selectAll: true })
}
}
update() {
const arrowUtil = this.editor.getShapeUtil<ArrowShapeUtil>('arrow')
const targetState = updateArrowTargetState({
editor: this.editor,
pointInPageSpace: this.editor.inputs.getCurrentPagePoint(),
arrow: undefined,
isPrecise: this.isPrecise,
currentBinding: undefined,
oppositeBinding: undefined,
})
if (targetState && targetState.target.id !== this.preciseTargetId) {
if (this.isPreciseTimerId !== null) {
this.editor.timers.clearTimeout(this.isPreciseTimerId)
}
this.preciseTargetId = targetState.target.id
this.isPreciseTimerId = this.editor.timers.setTimeout(() => {
this.isPrecise = true
this.update()
}, arrowUtil.options.hoverPreciseTimeout)
} else if (!targetState && this.preciseTargetId) {
this.isPrecise = false
this.preciseTargetId = null
if (this.isPreciseTimerId !== null) {
this.editor.timers.clearTimeout(this.isPreciseTimerId)
}
}
}
}

View File

@@ -0,0 +1,206 @@
import { StateNode, TLArrowShape, createShapeId, maybeSnapToGrid } from '@tldraw/editor'
import { ArrowShapeUtil } from '../ArrowShapeUtil'
import { clearArrowTargetState, updateArrowTargetState } from '../arrowTargetState'
export class Pointing extends StateNode {
static override id = 'pointing'
shape?: TLArrowShape
isPrecise = false
isPreciseTimerId: number | null = null
markId = ''
override onEnter(info: { isPrecise?: boolean }) {
this.markId = ''
this.isPrecise = !!info.isPrecise
const targetState = updateArrowTargetState({
editor: this.editor,
pointInPageSpace: this.editor.inputs.getCurrentPagePoint(),
arrow: undefined,
isPrecise: this.isPrecise,
currentBinding: undefined,
oppositeBinding: undefined,
})
if (!targetState) {
this.createArrowShape()
if (!this.shape) {
this.cancel()
return
}
}
this.startPreciseTimeout()
}
override onExit() {
// The press already created the arrow, so leaving without a drag (pointer up, cancel, or a
// tool switch) would otherwise strand a zero-length stub on the canvas
if (this.shape) {
this.editor.bailToMark(this.markId)
this.shape = undefined
}
clearArrowTargetState(this.editor)
this.clearPreciseTimeout()
}
override onPointerMove() {
if (this.editor.inputs.getIsDragging()) {
if (!this.shape) {
this.createArrowShape()
}
if (!this.shape) {
this.cancel()
return
}
this.updateArrowShapeEndHandle()
// Hand the arrow off before exiting so onExit does not bail it away
const shape = this.shape
this.shape = undefined
this.editor.setCurrentTool('select.dragging_handle', {
shape,
handle: { id: 'end', type: 'vertex', index: 'a3', x: 0, y: 0 },
isCreating: true,
creatingMarkId: this.markId || undefined,
onInteractionEnd: 'arrow',
})
}
}
override onPointerUp() {
this.cancel()
}
override onLongPress() {
// On a touch (coarse pointer) long-press, cancel the pending shape so it leaves nothing behind.
if (this.editor.getInstanceState().isCoarsePointer) this.cancel()
}
override onCancel() {
this.cancel()
}
override onComplete() {
this.cancel()
}
override onInterrupt() {
this.cancel()
}
cancel() {
this.parent.transition('idle')
}
createArrowShape() {
const originPagePoint = this.editor.inputs.getOriginPagePoint()
const id = createShapeId()
this.markId = this.editor.markHistoryStoppingPoint(`creating_arrow:${id}`)
const newPoint = maybeSnapToGrid(originPagePoint, this.editor)
this.editor.createShape({
id,
type: 'arrow',
x: newPoint.x,
y: newPoint.y,
props: {
scale: this.editor.getResizeScaleFactor(),
},
})
const shape = this.editor.getShape<TLArrowShape>(id)
if (!shape) return
const handles = this.editor.getShapeHandles(shape)
if (!handles) throw Error(`expected handles for arrow`)
const util = this.editor.getShapeUtil<TLArrowShape>('arrow')
const initial = this.shape
const startHandle = handles.find((h) => h.id === 'start')!
const change = util.onHandleDrag?.(shape, {
handle: { ...startHandle, x: 0, y: 0 },
isPrecise: true,
isCreatingShape: true,
initial: initial,
})
if (change) {
this.editor.updateShapes([change])
}
// Cache the current shape after those changes
this.shape = this.editor.getShape(id)
this.editor.select(id)
}
updateArrowShapeEndHandle() {
const shape = this.shape
if (!shape) throw Error(`expected shape`)
const handles = this.editor.getShapeHandles(shape)
if (!handles) throw Error(`expected handles for arrow`)
// start update
{
const util = this.editor.getShapeUtil<TLArrowShape>('arrow')
const initial = this.shape
const startHandle = handles.find((h) => h.id === 'start')!
const change = util.onHandleDrag?.(shape, {
handle: { ...startHandle, x: 0, y: 0 },
isPrecise: this.isPrecise,
isCreatingShape: true,
initial: initial,
})
if (change) {
this.editor.updateShapes([change])
}
}
// end update
{
const util = this.editor.getShapeUtil<TLArrowShape>('arrow')
const initial = this.shape
const point = this.editor.getPointInShapeSpace(
shape,
this.editor.inputs.getCurrentPagePoint()
)
const endHandle = handles.find((h) => h.id === 'end')!
const change = util.onHandleDrag?.(this.editor.getShape(shape)!, {
handle: { ...endHandle, x: point.x, y: point.y },
isPrecise: this.isPrecise,
isCreatingShape: true,
initial: initial,
})
if (change) {
this.editor.updateShapes([change])
}
}
// Cache the current shape after those changes
this.shape = this.editor.getShape(shape.id)
}
private startPreciseTimeout() {
const arrowUtil = this.editor.getShapeUtil<ArrowShapeUtil>('arrow')
this.isPreciseTimerId = this.editor.timers.setTimeout(() => {
if (!this.getIsActive()) return
this.isPrecise = true
}, arrowUtil.options.pointingPreciseTimeout)
}
private clearPreciseTimeout() {
if (this.isPreciseTimerId !== null) {
this.editor.timers.clearTimeout(this.isPreciseTimerId)
}
}
}

View File

@@ -0,0 +1,258 @@
// oxlint-disable typescript/no-empty-object-type
import {
BaseBoxShapeUtil,
HTMLContainer,
Rectangle2d,
T,
TLAssetId,
TLBookmarkAsset,
TLBookmarkShape,
TLBookmarkShapeProps,
bookmarkShapeMigrations,
bookmarkShapeProps,
lerp,
tlenv,
useEditor,
useSvgExportContext,
} from '@tldraw/editor'
import classNames from 'classnames'
import { PointerEventHandler, useCallback, useState } from 'react'
import { convertCommonTitleHTMLEntities } from '../../utils/text/text'
import type { ShapeOptionsWithDisplayValues } from '../shared/getDisplayValues'
import { HyperlinkButton } from '../shared/HyperlinkButton'
import { LINK_ICON } from '../shared/icons-editor'
import { getRotatedBoxShadow } from '../shared/rotated-box-shadow'
import {
BOOKMARK_HEIGHT,
BOOKMARK_WIDTH,
getBookmarkShapeHeight,
getHumanReadableAddress,
getResolvedBookmarkAssetId,
setBookmarkHeight,
resolveBookmarkAssetForUrlChange,
} from './bookmarks'
/** @public */
export type BookmarkShapeUtilDisplayValues = object
/** @public */
export interface BookmarkShapeOptions extends ShapeOptionsWithDisplayValues<
TLBookmarkShape,
BookmarkShapeUtilDisplayValues
> {}
/** @public */
export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
static override type = 'bookmark' as const
static override props = bookmarkShapeProps
static override migrations = bookmarkShapeMigrations
override options: BookmarkShapeOptions = {
getDefaultDisplayValues(): BookmarkShapeUtilDisplayValues {
return {}
},
getCustomDisplayValues(): Partial<BookmarkShapeUtilDisplayValues> {
return {}
},
}
override canResize(shape: TLBookmarkShape) {
return false
}
override hideSelectionBoundsFg(shape: TLBookmarkShape) {
return true
}
override getText(shape: TLBookmarkShape) {
return shape.props.url
}
override getAriaDescriptor(shape: TLBookmarkShape) {
const asset = (
shape.props.assetId ? this.editor.getAsset(shape.props.assetId) : null
) as TLBookmarkAsset | null
if (!asset?.props.title) return undefined
return (
convertCommonTitleHTMLEntities(asset.props.title) +
(asset.props.description ? ', ' + asset.props.description : '')
)
}
override getDefaultProps(): TLBookmarkShape['props'] {
return {
url: '',
w: BOOKMARK_WIDTH,
h: BOOKMARK_HEIGHT,
assetId: null,
}
}
override getGeometry(shape: TLBookmarkShape) {
return new Rectangle2d({
width: shape.props.w,
height: getBookmarkShapeHeight(this.editor, shape),
isFilled: true,
})
}
override component(shape: TLBookmarkShape) {
const { assetId, url } = shape.props
const h = getBookmarkShapeHeight(this.editor, shape)
const rotation = this.editor.getShapePageTransform(shape)!.rotation()
return <BookmarkShapeComponent assetId={assetId} url={url} h={h} rotation={rotation} />
}
override getIndicatorPath(shape: TLBookmarkShape): Path2D {
const path = new Path2D()
path.rect(0, 0, shape.props.w, getBookmarkShapeHeight(this.editor, shape))
return path
}
override onBeforeCreate(next: TLBookmarkShape) {
return setBookmarkHeight(this.editor, next)
}
override onBeforeUpdate(prev: TLBookmarkShape, shape: TLBookmarkShape) {
let next = shape
if (prev.props.url !== shape.props.url) {
if (!T.linkUrl.isValid(shape.props.url)) {
return { ...shape, props: { ...shape.props, url: prev.props.url } }
}
next = resolveBookmarkAssetForUrlChange(this.editor, shape)
}
if (prev.props.assetId !== next.props.assetId) {
return setBookmarkHeight(this.editor, next)
}
return next === shape ? undefined : next
}
override getInterpolatedProps(
startShape: TLBookmarkShape,
endShape: TLBookmarkShape,
t: number
): TLBookmarkShapeProps {
return {
...(t > 0.5 ? endShape.props : startShape.props),
w: lerp(startShape.props.w, endShape.props.w, t),
h: lerp(startShape.props.h, endShape.props.h, t),
}
}
}
export function BookmarkShapeComponent({
assetId,
rotation,
url,
h,
showImageContainer = true,
}: {
assetId: TLAssetId | null
rotation: number
h: number
url: string
showImageContainer?: boolean
}) {
const editor = useEditor()
const resolvedAssetId = getResolvedBookmarkAssetId(editor, assetId, url)
const asset = resolvedAssetId ? (editor.getAsset(resolvedAssetId) as TLBookmarkAsset) : null
const isSafariExport = !!useSvgExportContext() && tlenv.isSafari
const address = getHumanReadableAddress(url)
const [isFaviconValid, setIsFaviconValid] = useState(true)
const onFaviconError = () => setIsFaviconValid(false)
const markAsHandledOnShiftKey = useCallback<PointerEventHandler>(
(e) => {
if (!editor.inputs.getShiftKey()) editor.markEventAsHandled(e)
},
[editor]
)
return (
<HTMLContainer>
<div
className={classNames(
'tl-bookmark__container',
isSafariExport && 'tl-bookmark__container--safariExport'
)}
style={{
boxShadow: isSafariExport ? undefined : getRotatedBoxShadow(rotation),
maxHeight: h,
}}
>
{showImageContainer && (!asset || asset.props.image) && (
<div className="tl-bookmark__image_container">
{asset ? (
<img
className="tl-bookmark__image"
draggable={false}
referrerPolicy="strict-origin-when-cross-origin"
src={asset?.props.image}
alt={asset?.props.title || ''}
/>
) : (
<div className="tl-bookmark__placeholder" />
)}
{asset?.props.image && <HyperlinkButton url={url} />}
</div>
)}
<div className="tl-bookmark__copy_container">
{asset?.props.title ? (
<a
className="tl-bookmark__link"
href={url || ''}
target="_blank"
rel="noopener noreferrer"
draggable={false}
onPointerDown={markAsHandledOnShiftKey}
onPointerUp={markAsHandledOnShiftKey}
>
<h2 className="tl-bookmark__heading">
{convertCommonTitleHTMLEntities(asset.props.title)}
</h2>
</a>
) : null}
{asset?.props.description && asset?.props.image ? (
<p className="tl-bookmark__description">{asset.props.description}</p>
) : null}
<a
className="tl-bookmark__link"
href={url || ''}
target="_blank"
rel="noopener noreferrer"
draggable={false}
onPointerDown={markAsHandledOnShiftKey}
onPointerUp={markAsHandledOnShiftKey}
>
{isFaviconValid && asset?.props.favicon ? (
<img
className="tl-bookmark__favicon"
src={asset?.props.favicon}
referrerPolicy="strict-origin-when-cross-origin"
onError={onFaviconError}
alt={`favicon of ${address}`}
/>
) : (
<div
className="tl-hyperlink__icon"
style={{
mask: `url("${LINK_ICON}") center 100% / 100% no-repeat`,
WebkitMask: `url("${LINK_ICON}") center 100% / 100% no-repeat`,
}}
/>
)}
<span>{address}</span>
</a>
</div>
</div>
</HTMLContainer>
)
}

View File

@@ -0,0 +1,267 @@
import {
AssetRecordType,
Editor,
Result,
TLAssetId,
TLBookmarkAsset,
TLBookmarkShape,
TLShapeId,
TLShapePartial,
createShapeId,
debounce,
getHashForString,
} from '@tldraw/editor'
export const BOOKMARK_WIDTH = 300
export const BOOKMARK_HEIGHT = 320
export const BOOKMARK_JUST_URL_HEIGHT = 46
const SHORT_BOOKMARK_HEIGHT = 101
export function getBookmarkHeight(editor: Editor, assetId?: TLAssetId | null) {
const asset = (assetId ? editor.getAsset(assetId) : null) as TLBookmarkAsset | null
if (asset) {
if (!asset.props.image) {
if (!asset.props.title) {
return BOOKMARK_JUST_URL_HEIGHT
} else {
return SHORT_BOOKMARK_HEIGHT
}
}
}
return BOOKMARK_HEIGHT
}
export function setBookmarkHeight(editor: Editor, shape: TLBookmarkShape) {
return {
...shape,
props: { ...shape.props, h: getBookmarkHeight(editor, shape.props.assetId) },
}
}
/** @internal */
export function getHumanReadableAddress(url: string) {
try {
const objUrl = new URL(url)
// we want the hostname without any www
return objUrl.hostname.replace(/^www\./, '')
} catch {
return url
}
}
/**
* Resolve the asset for a bookmark whose url just changed: the existing asset for the new url
* if there is one, otherwise `null` while a new one is fetched in the background.
*
* Must not write to the store: this runs inside `onBeforeUpdate`, where a nested `updateShapes`
* is overwritten by the outer put and the old asset would stick.
*/
export function resolveBookmarkAssetForUrlChange(
editor: Editor,
shape: TLBookmarkShape
): TLBookmarkShape {
const { url } = shape.props
// Derive the asset id from the URL
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url))
if (editor.getAsset(assetId)) {
if (shape.props.assetId === assetId) return shape
return { ...shape, props: { ...shape.props, assetId } }
}
// No asset for this URL yet: drop the stale reference and fetch one asynchronously
createBookmarkAssetOnUrlChange(editor, shape)
if (shape.props.assetId === null) return shape
return { ...shape, props: { ...shape.props, assetId: null } }
}
/**
* Resolve the asset id to render for a bookmark. Bookmark assets are keyed
* deterministically by URL, so if the shape has no `assetId` but an asset for
* its URL already exists in the store, use that.
*
* This keeps a bookmark from staying stuck on its placeholder after the shape
* is recreated outside of the hydration flow — e.g. on redo, where the
* placeholder is restored with a null `assetId` but its hydrated asset (created
* with `history: 'ignore'`) still lives in the store.
*/
export function getResolvedBookmarkAssetId(
editor: Editor,
assetId: TLAssetId | null,
url: string
): TLAssetId | null {
if (assetId) return assetId
if (!url) return null
const derivedId: TLAssetId = AssetRecordType.createId(getHashForString(url))
return editor.getAsset(derivedId) ? derivedId : null
}
/**
* The effective height to render and measure a bookmark at.
*
* Normally this is the stored `props.h`. But when the shape's `assetId` resolves
* to a different asset than it stores — e.g. a placeholder restored on redo with
* a null `assetId` whose asset already exists in the store — `props.h` is stale
* (it still holds the placeholder height). In that case recompute the height
* from the resolved asset so rendering, the indicator, and the geometry/selection
* bounds all stay in sync.
*/
export function getBookmarkShapeHeight(editor: Editor, shape: TLBookmarkShape): number {
const resolvedAssetId = getResolvedBookmarkAssetId(editor, shape.props.assetId, shape.props.url)
if (resolvedAssetId === shape.props.assetId) return shape.props.h
return getBookmarkHeight(editor, resolvedAssetId)
}
async function _createBookmarkAssetOnUrlChange(editor: Editor, shape: TLBookmarkShape) {
if (editor.isDisposed) return
const { url } = shape.props
// Create the asset using the external content manager's createAssetFromUrl method.
// This may be overwritten by the user (for example, we overwrite it on tldraw.com)
const asset = await editor.getAssetForExternalContent({ type: 'url', url })
if (!asset) {
// No asset? Just leave the bookmark as a null assetId.
return
}
editor.run(() => {
// Create the new asset
editor.createAssets([asset])
// And update the shape
editor.updateShapes([
{
id: shape.id,
type: shape.type,
props: { assetId: asset.id },
},
])
})
}
const bookmarkDebouncers = new WeakMap<
Editor,
ReturnType<typeof debounce<[Editor, TLBookmarkShape], void>>
>()
function createBookmarkAssetOnUrlChange(editor: Editor, shape: TLBookmarkShape) {
let fn = bookmarkDebouncers.get(editor)
if (!fn) {
fn = debounce(_createBookmarkAssetOnUrlChange, 500)
bookmarkDebouncers.set(editor, fn)
}
fn(editor, shape)
}
/**
* Creates a bookmark shape from a URL.
*
* The shape is created immediately as a placeholder so the user gets visible
* feedback at the paste location, and the bookmark metadata (title, description,
* image, favicon) is fetched in the background. Once metadata resolves, the
* shape is patched with the resulting asset. If the fetch fails, the shape is
* left as a URL-only bookmark.
*
* @returns A Result containing the created bookmark shape or an error
* @public
*/
export async function createBookmarkFromUrl(
editor: Editor,
{
url,
center = editor.getViewportPageBounds().center,
}: {
url: string
center?: { x: number; y: number }
}
): Promise<Result<TLBookmarkShape, string>> {
try {
// If we already have a bookmark asset for this URL (e.g. another bookmark
// shape was created from the same URL earlier), use it immediately rather
// than re-fetching.
const expectedAssetId: TLAssetId = AssetRecordType.createId(getHashForString(url))
const existingAsset = editor.getAsset(expectedAssetId) as TLBookmarkAsset | null
const shapeId = createShapeId()
const shapePartial: TLShapePartial<TLBookmarkShape> = {
id: shapeId,
type: 'bookmark',
x: center.x - BOOKMARK_WIDTH / 2,
y: center.y - BOOKMARK_HEIGHT / 2,
rotation: 0,
opacity: 1,
props: {
url,
assetId: existingAsset?.id ?? null,
w: BOOKMARK_WIDTH,
h: getBookmarkHeight(editor, existingAsset?.id),
},
}
editor.run(() => {
editor.createShapes([shapePartial])
})
const createdShape = editor.getShape<TLBookmarkShape>(shapeId)
if (!createdShape) {
return Result.err('Failed to create bookmark shape')
}
// If we already had the asset, we're done — no metadata fetch needed.
if (existingAsset) {
return Result.ok(createdShape)
}
// Otherwise kick off the metadata fetch in the background. The shape is
// already visible as a placeholder; once the asset resolves we'll patch
// the shape with its assetId.
void hydrateBookmarkShape(editor, shapeId, url)
return Result.ok(createdShape)
} catch (error) {
return Result.err(error instanceof Error ? error.message : 'Failed to create bookmark')
}
}
async function hydrateBookmarkShape(editor: Editor, shapeId: TLShapeId, url: string) {
let asset
try {
asset = await editor.getAssetForExternalContent({ type: 'url', url })
} catch (error) {
console.error(error)
return
}
if (editor.isDisposed) return
if (!asset) return
// The shape may have been deleted (e.g. via undo), had its URL changed, or
// had its asset replaced before the fetch resolved. In any of those cases we
// don't want to attach metadata for the old URL.
const shape = editor.getShape<TLBookmarkShape>(shapeId)
if (!shape) return
if (shape.props.url !== url) return
if (shape.props.assetId) return
editor.run(
() => {
if (!editor.getAsset(asset.id)) {
editor.createAssets([asset])
}
editor.updateShapes([
{
id: shapeId,
type: 'bookmark',
props: { assetId: asset.id },
},
])
},
{ history: 'ignore' }
)
}

View File

@@ -0,0 +1,277 @@
import { TLDrawShape } from '@tldraw/editor'
import { TestEditor } from '../../../test/TestEditor'
import { Drawing } from './toolStates/Drawing'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
})
afterEach(() => {
editor?.dispose()
})
function getDrawingState(): Drawing {
return editor.root.children!['draw'].children!['drawing'] as Drawing
}
describe('When in the idle state', () => {
it('Returns to select on cancel', () => {
editor.setCurrentTool('draw')
editor.expectToBeIn('draw.idle')
editor.cancel()
editor.expectToBeIn('select.idle')
})
it('Enters the drawing state on pointer down', () => {
editor.setCurrentTool('draw')
editor.pointerDown(50, 50)
editor.expectToBeIn('draw.drawing')
})
})
describe('When in the drawing state', () => {
it('Returns to idle on cancel', () => {
editor.setCurrentTool('draw')
editor.pointerDown(50, 50)
editor.cancel()
editor.expectToBeIn('draw.idle')
})
it('Returns to idle on complete', () => {
editor.setCurrentTool('draw')
editor.pointerDown(50, 50)
editor.pointerUp(50, 50)
editor.expectToBeIn('draw.idle')
editor.pointerDown(50, 50)
editor.pointerMove(55, 55)
editor.pointerMove(60, 60)
editor.pointerUp(60, 60)
editor.expectToBeIn('draw.idle')
})
})
describe('zoomOnEnter', () => {
it('Captures the zoom level when entering the drawing state', () => {
editor.setCamera({ x: 0, y: 0, z: 2 })
editor.setCurrentTool('draw')
editor.pointerDown(50, 50)
const drawing = getDrawingState()
expect(drawing.zoomOnEnter).toBe(2)
})
it('Uses the zoom level at entry, not the current zoom level', () => {
editor.setCamera({ x: 0, y: 0, z: 3 })
editor.setCurrentTool('draw')
editor.pointerDown(50, 50)
const drawing = getDrawingState()
expect(drawing.zoomOnEnter).toBe(3)
// Change zoom mid-stroke
editor.setCamera({ x: 0, y: 0, z: 1 })
editor.pointerMove(60, 60)
// zoomOnEnter should still reflect the zoom at entry
expect(drawing.zoomOnEnter).toBe(3)
})
it('Defaults to 1 when zoom is at default level', () => {
editor.setCurrentTool('draw')
editor.pointerDown(50, 50)
const drawing = getDrawingState()
expect(drawing.zoomOnEnter).toBe(1)
})
})
describe('Close threshold with zoom', () => {
function drawNearlyClosedShape(gap: number) {
// Draw a shape that loops back near the start point
// Start at origin, go right, down, left, then almost back to start
editor.setCurrentTool('draw')
editor.pointerDown(100, 100)
editor.pointerMove(200, 100)
editor.pointerMove(200, 200)
editor.pointerMove(100, 200)
// Come back near the start with the specified gap
editor.pointerMove(100 + gap, 100)
editor.pointerUp()
const shapes = editor.getCurrentPageShapes()
return shapes[shapes.length - 1] as TLDrawShape
}
it('Closes a shape when the endpoint is near the start at zoom=1', () => {
const shape = drawNearlyClosedShape(2)
expect(shape.props.isClosed).toBe(true)
})
it('Closes a small shape with densely sampled points', () => {
// Match the point density of the reported shape that exposed the length calculation bug.
const samples = 476
const radius = 20
const center = { x: 100, y: 100 }
editor.setCurrentTool('draw')
editor.pointerDown(center.x + radius, center.y)
for (let i = 1; i <= samples; i++) {
const angle = (i / samples) * Math.PI * 2
editor.pointerMove(center.x + Math.cos(angle) * radius, center.y + Math.sin(angle) * radius)
}
editor.pointerUp()
const shapes = editor.getCurrentPageShapes() as TLDrawShape[]
expect(shapes).toHaveLength(1)
expect(shapes[0].props.isClosed).toBe(true)
})
it('Does not close a shape when the endpoint is far from the start', () => {
const shape = drawNearlyClosedShape(50)
expect(shape.props.isClosed).toBe(false)
})
it('Has a larger close threshold at low zoom levels', () => {
// threshold = 6 + 2*sqrt(strokeWidth*0.8) + 100/(1+(zoom/0.18)^3)
// At zoom=0.1: threshold ≈ 95 page units
// At zoom=1: threshold ≈ 10 page units
// Note: screen gap * (1/zoom) = page gap
// At zoom=1, screen gap of 15px = 15 page units > threshold ~10 → NOT closed
editor.setCamera({ x: 0, y: 0, z: 1 })
const shapeAtZoom1 = drawNearlyClosedShape(15)
expect(shapeAtZoom1.props.isClosed).toBe(false)
// At zoom=0.1, screen gap of 5px = 50 page units < threshold ~95 → CLOSED
// The larger page-space threshold at low zoom makes closing easier
editor.setCamera({ x: 0, y: 0, z: 0.1 })
const shapeAtLowZoom = drawNearlyClosedShape(5)
expect(shapeAtLowZoom.props.isClosed).toBe(true)
})
it('Has a small close threshold at high zoom levels', () => {
// At zoom=5: threshold ≈ 9.4 page units
// Screen gap of 60px = 12 page units > threshold → NOT closed
editor.setCamera({ x: 0, y: 0, z: 5 })
const shape = drawNearlyClosedShape(60)
expect(shape.props.isClosed).toBe(false)
})
it('Uses a different threshold formula when dynamic resize mode is enabled', () => {
editor.user.updateUserPreferences({ isDynamicSizeMode: true })
// In dynamic resize mode, threshold = (strokeWidth + 2) * scale
// where scale = 1/zoom. At zoom=0.1, scale=10, strokeWidth(m)=3.5
// threshold = (3.5+2)*10 = 55 page units
// Screen gap of 3px at zoom=0.1 = 30 page units < 55 → CLOSED
editor.setCamera({ x: 0, y: 0, z: 0.1 })
const shapeAtLowZoom = drawNearlyClosedShape(3)
expect(shapeAtLowZoom.props.isClosed).toBe(true)
})
it('Closes on the straight segment that returns to the start, not one update later', () => {
const shift = { shiftKey: true }
editor.setCurrentTool('draw')
editor.pointerDown(100, 100, shift)
editor.pointerMove(200, 100, shift)
editor.pointerUp(200, 100, shift)
// shift-click extends the previous line with a new straight segment
editor.pointerDown(200, 100, shift)
editor.pointerMove(150, 180, shift)
editor.pointerUp(150, 180, shift)
editor.pointerDown(150, 180, shift)
editor.pointerMove(100, 100, shift)
const shapes = editor.getCurrentPageShapes() as TLDrawShape[]
expect(shapes).toHaveLength(1)
expect(shapes[0].props.isClosed).toBe(true)
})
it('Closes when the first straight segment after a free segment returns to the start', () => {
editor.setCurrentTool('draw')
editor.pointerDown(100, 100)
editor.pointerMove(200, 100)
editor.pointerMove(150, 180)
editor.pointerMove(150, 140)
expect((editor.getCurrentPageShapes()[0] as TLDrawShape).props.isClosed).toBe(false)
// pressing shift starts a straight segment from here; the move that commits it lands on the start
editor.keyDown('Shift')
editor.pointerMove(100, 100, { shiftKey: true })
const shapes = editor.getCurrentPageShapes() as TLDrawShape[]
expect(shapes).toHaveLength(1)
expect(shapes[0].props.segments.map((s) => s.type)).toEqual(['free', 'straight'])
expect(shapes[0].props.isClosed).toBe(true)
})
it('Closes when the final straight segment pushes a short stroke over the minimum length', () => {
// size m stroke width is 3.5, so a stroke needs > 14 page units of length to close
editor.setCurrentTool('draw')
editor.pointerDown(100, 100)
editor.pointerMove(108, 100)
expect((editor.getCurrentPageShapes()[0] as TLDrawShape).props.isClosed).toBe(false)
editor.keyDown('Shift')
editor.pointerMove(100, 100, { shiftKey: true })
const shape = editor.getCurrentPageShapes()[0] as TLDrawShape
expect(shape.props.segments.map((s) => s.type)).toEqual(['free', 'straight'])
expect(shape.props.isClosed).toBe(true)
})
it('Does not close highlight shapes regardless of zoom', () => {
editor.setCamera({ x: 0, y: 0, z: 0.1 })
editor.setCurrentTool('highlight')
editor.pointerDown(100, 100)
editor.pointerMove(200, 100)
editor.pointerMove(200, 200)
editor.pointerMove(100, 200)
editor.pointerMove(102, 100)
editor.pointerUp()
const shapes = editor.getCurrentPageShapes()
const shape = shapes[shapes.length - 1]
// Highlight shapes don't have isClosed
expect((shape as any).props.isClosed).toBeUndefined()
})
})
describe('Line length with straight segments', () => {
// size m stroke width is 3.5, so a stroke needs > 14 page units of length to close
it('Counts a dragged straight segment once, not once per pointer move', () => {
const shift = { shiftKey: true }
editor.setCurrentTool('draw')
editor.pointerDown(100, 100)
editor.pointerMove(102, 100)
editor.keyDown('Shift')
for (let x = 103; x <= 120; x++) editor.pointerMove(x, 100, shift)
// 2 free + 18 straight
expect(getDrawingState().currentLineLength).toBeCloseTo(20)
// drag the straight segment back onto the start: 2 free + 2 straight
for (let x = 119; x >= 100; x--) editor.pointerMove(x, 100, shift)
expect(getDrawingState().currentLineLength).toBeCloseTo(4)
editor.pointerUp(100, 100, shift)
expect((editor.getCurrentPageShapes()[0] as TLDrawShape).props.isClosed).toBe(false)
})
it('Counts a dragged shift-click segment once, not once per pointer move', () => {
const shift = { shiftKey: true }
editor.setCurrentTool('draw')
editor.pointerDown(100, 100).pointerUp(100, 100)
editor.pointerDown(105, 100, shift)
for (let x = 106; x <= 120; x++) editor.pointerMove(x, 100, shift)
for (let x = 119; x >= 105; x--) editor.pointerMove(x, 100, shift)
editor.pointerUp(105, 100, shift)
expect(getDrawingState().currentLineLength).toBeCloseTo(5)
// shift-click back onto the start
editor.pointerDown(100, 100, shift).pointerUp(100, 100, shift)
expect(getDrawingState().currentLineLength).toBeCloseTo(10)
expect((editor.getCurrentPageShapes()[0] as TLDrawShape).props.isClosed).toBe(false)
})
})

View File

@@ -0,0 +1,21 @@
import { StateNode, TLStateNodeConstructor } from '@tldraw/editor'
import { Drawing } from './toolStates/Drawing'
import { Idle } from './toolStates/Idle'
/** @public */
export class DrawShapeTool extends StateNode {
static override id = 'draw'
static override initial = 'idle'
static override isLockable = false
static override useCoalescedEvents = true
static override children(): TLStateNodeConstructor[] {
return [Idle, Drawing]
}
override shapeType = 'draw'
override onExit() {
const drawingState = this.children!['drawing'] as Drawing
drawingState.initialShape = undefined
}
}

View File

@@ -0,0 +1,212 @@
import { Circle2d, createShapeId, TLDrawShape } from '@tldraw/editor'
import { TestEditor } from '../../../test/TestEditor'
import { createDrawSegments, pointsToBase64 } from '../../utils/test-helpers'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
})
afterEach(() => {
editor?.dispose()
})
describe('DrawShapeUtil dot detection', () => {
const shapeId = createShapeId('test-draw')
function createDrawShape(segments: TLDrawShape['props']['segments']): TLDrawShape {
editor.createShapes([
{
id: shapeId,
type: 'draw',
props: { segments },
},
])
return editor.getShape(shapeId) as TLDrawShape
}
describe('empty segments', () => {
it('treats a shape created from its default props as a dot', () => {
editor.createShape({ id: shapeId, type: 'draw' })
const shape = editor.getShape<TLDrawShape>(shapeId)!
expect(shape.props.segments).toEqual([])
expect(editor.getShapeGeometry(shape)).toBeInstanceOf(Circle2d)
expect(editor.getShapeAtPoint({ x: 0, y: 0 })?.id).toBe(shapeId)
})
it('treats a single segment with an empty path as a dot', () => {
const shape = createDrawShape([{ type: 'free', path: '' }])
expect(editor.getShapeGeometry(shape)).toBeInstanceOf(Circle2d)
expect(editor.getShapeAtPoint({ x: 0, y: 0 })?.id).toBe(shapeId)
})
})
describe('getIsDot behavior via hideResizeHandles', () => {
it('treats a shape with one segment and zero points as a dot', () => {
const shape = createDrawShape([{ type: 'free', path: '' }])
const util = editor.getShapeUtil('draw')
expect(util.hideResizeHandles(shape)).toBe(true)
})
it('treats a shape with one segment and one point as a dot', () => {
const shape = createDrawShape(createDrawSegments([[{ x: 0, y: 0, z: 0.5 }]]))
const util = editor.getShapeUtil('draw')
expect(util.hideResizeHandles(shape)).toBe(true)
})
it('treats a shape with one segment and two points as NOT a dot', () => {
const shape = createDrawShape(
createDrawSegments([
[
{ x: 0, y: 0, z: 0.5 },
{ x: 10, y: 10, z: 0.5 },
],
])
)
const util = editor.getShapeUtil('draw')
expect(util.hideResizeHandles(shape)).toBe(false)
})
it('treats a shape with one segment and many points as NOT a dot', () => {
const shape = createDrawShape(
createDrawSegments([
[
{ x: 0, y: 0, z: 0.5 },
{ x: 10, y: 10, z: 0.5 },
{ x: 20, y: 5, z: 0.5 },
{ x: 30, y: 15, z: 0.5 },
],
])
)
const util = editor.getShapeUtil('draw')
expect(util.hideResizeHandles(shape)).toBe(false)
})
it('treats a shape with multiple segments as NOT a dot', () => {
const shape = createDrawShape(
createDrawSegments([[{ x: 0, y: 0, z: 0.5 }], [{ x: 10, y: 10, z: 0.5 }]])
)
const util = editor.getShapeUtil('draw')
expect(util.hideResizeHandles(shape)).toBe(false)
})
})
describe('hideRotateHandle mirrors hideResizeHandles for dots', () => {
it('hides rotate handle for dots', () => {
const shape = createDrawShape(createDrawSegments([[{ x: 0, y: 0, z: 0.5 }]]))
const util = editor.getShapeUtil('draw')
expect(util.hideRotateHandle(shape)).toBe(true)
})
it('shows rotate handle for non-dots', () => {
const shape = createDrawShape(
createDrawSegments([
[
{ x: 0, y: 0, z: 0.5 },
{ x: 10, y: 10, z: 0.5 },
],
])
)
const util = editor.getShapeUtil('draw')
expect(util.hideRotateHandle(shape)).toBe(false)
})
})
describe('hideSelectionBoundsFg mirrors hideResizeHandles for dots', () => {
it('hides selection bounds for dots', () => {
const shape = createDrawShape(createDrawSegments([[{ x: 0, y: 0, z: 0.5 }]]))
const util = editor.getShapeUtil('draw')
expect(util.hideSelectionBoundsFg(shape)).toBe(true)
})
it('shows selection bounds for non-dots', () => {
const shape = createDrawShape(
createDrawSegments([
[
{ x: 0, y: 0, z: 0.5 },
{ x: 10, y: 10, z: 0.5 },
],
])
)
const util = editor.getShapeUtil('draw')
expect(util.hideSelectionBoundsFg(shape)).toBe(false)
})
})
describe('onResize prevents zero scale', () => {
it('is a no-op when resized to zero width', () => {
const shape = createDrawShape(
createDrawSegments([
[
{ x: 0, y: 0, z: 0.5 },
{ x: 50, y: 50, z: 0.5 },
],
])
)
editor.resizeShape(shape.id, { x: 0, y: 1 })
const resized = editor.getShape<TLDrawShape>(shapeId)!
expect(resized.props.scaleX).toBe(1)
expect(resized.props.scaleY).toBe(1)
})
it('is a no-op when resized to zero height', () => {
const shape = createDrawShape(
createDrawSegments([
[
{ x: 0, y: 0, z: 0.5 },
{ x: 50, y: 50, z: 0.5 },
],
])
)
editor.resizeShape(shape.id, { x: 1, y: 0 })
const resized = editor.getShape<TLDrawShape>(shapeId)!
expect(resized.props.scaleX).toBe(1)
expect(resized.props.scaleY).toBe(1)
})
it('preserves sign when resizing with negative scale (flipping)', () => {
const shape = createDrawShape(
createDrawSegments([
[
{ x: 0, y: 0, z: 0.5 },
{ x: 50, y: 50, z: 0.5 },
],
])
)
editor.resizeShape(shape.id, { x: -1, y: -1 })
const resized = editor.getShape<TLDrawShape>(shapeId)!
expect(resized.props.scaleX).toBe(-1)
expect(resized.props.scaleY).toBe(-1)
})
})
describe('base64 encoding boundary conditions', () => {
it('correctly handles the boundary at exactly 24 base64 characters (2 points)', () => {
// First point is 16 base64 chars (Float32), delta points are 8 chars each (Float16)
// 2 points = 24 characters, which should NOT be a dot
const twoPointsBase64 = pointsToBase64([
{ x: 0, y: 0, z: 0.5 },
{ x: 1, y: 1, z: 0.5 },
])
expect(twoPointsBase64.length).toBe(24)
const shape = createDrawShape([{ type: 'free', path: twoPointsBase64 }])
const util = editor.getShapeUtil('draw')
expect(util.hideResizeHandles(shape)).toBe(false)
})
it('correctly handles the boundary at exactly 16 base64 characters (1 point)', () => {
// 1 point = 16 characters (Float32 first point), which should be a dot
const onePointBase64 = pointsToBase64([{ x: 0, y: 0, z: 0.5 }])
expect(onePointBase64.length).toBe(16)
const shape = createDrawShape([{ type: 'free', path: onePointBase64 }])
const util = editor.getShapeUtil('draw')
expect(util.hideResizeHandles(shape)).toBe(true)
})
})
})

View File

@@ -0,0 +1,404 @@
import {
Box,
Circle2d,
Polygon2d,
Polyline2d,
SVGContainer,
ShapeUtil,
SvgExportContext,
TLDrawShape,
TLDrawShapeProps,
TLResizeInfo,
TLShapeUtilCanvasSvgDef,
VecLike,
b64Vecs,
drawShapeMigrations,
drawShapeProps,
getColorValue,
last,
lerp,
rng,
useEditor,
useValue,
} from '@tldraw/editor'
import { STROKE_SIZES } from '../shared/default-shape-constants'
import { DEFAULT_FILL_COLOR_NAMES } from '../shared/defaultFills'
import { getFillDefForCanvas, getFillDefForExport } from '../shared/defaultStyleDefs'
import { getStrokePoints } from '../shared/freehand/getStrokePoints'
import { getSvgPathFromStrokePoints } from '../shared/freehand/svg'
import { svgInk } from '../shared/freehand/svgInk'
import { ShapeOptionsWithDisplayValues, getDisplayValues } from '../shared/getDisplayValues'
import { interpolateSegments } from '../shared/interpolate-props'
import { PatternFill } from '../shared/PatternFill'
import {
getDrawShapeStrokeDashArray,
getFreehandOptions,
getPointsFromDrawSegments,
} from './getPath'
/** @public */
export interface DrawShapeUtilDisplayValues {
strokeColor: string
strokeWidth: number
fillColor: string
patternFillFallbackColor: string
}
/** @public */
export interface DrawShapeOptions extends ShapeOptionsWithDisplayValues<
TLDrawShape,
DrawShapeUtilDisplayValues
> {
/**
* The maximum number of points in a line before the draw tool will begin a new shape.
* A higher number will lead to poor performance while drawing very long lines.
*/
readonly maxPointsPerShape: number
}
/** @public */
export class DrawShapeUtil extends ShapeUtil<TLDrawShape> {
static override type = 'draw' as const
static override props = drawShapeProps
static override migrations = drawShapeMigrations
override options: DrawShapeOptions = {
maxPointsPerShape: 600,
getDefaultDisplayValues(_editor, shape, theme, colorMode): DrawShapeUtilDisplayValues {
const { color, fill, size } = shape.props
const colors = theme.colors[colorMode]
return {
strokeColor: getColorValue(colors, color, 'solid'),
strokeWidth: theme.strokeWidth * STROKE_SIZES[size],
fillColor:
fill === 'none'
? 'transparent'
: fill === 'semi'
? colors.solid
: getColorValue(colors, color, DEFAULT_FILL_COLOR_NAMES[fill]),
patternFillFallbackColor: getColorValue(colors, color, 'semi'),
}
},
getCustomDisplayValues(): Partial<DrawShapeUtilDisplayValues> {
return {}
},
}
override hideResizeHandles(shape: TLDrawShape) {
return getIsDot(shape)
}
override hideRotateHandle(shape: TLDrawShape) {
return getIsDot(shape)
}
override hideSelectionBoundsFg(shape: TLDrawShape) {
return getIsDot(shape)
}
override getDefaultProps(): TLDrawShape['props'] {
return {
segments: [],
color: 'black',
fill: 'none',
dash: 'draw',
size: 'm',
isComplete: false,
isClosed: false,
isPen: false,
scale: 1,
scaleX: 1,
scaleY: 1,
}
}
getGeometry(shape: TLDrawShape) {
const points = getPointsFromDrawSegments(
shape.props.segments,
shape.props.scaleX,
shape.props.scaleY
)
const sw = (getDisplayValues(this, shape).strokeWidth + 1) * shape.props.scale
// No decoded points (the default props have no segments): a dot at the origin, so geometry
// lookups don't throw on a shape created from its defaults.
if (points.length === 0) {
return new Circle2d({ x: -sw, y: -sw, radius: sw, isFilled: true })
}
// A dot
if (shape.props.segments.length === 1) {
const box = Box.FromPoints(points)
if (box.width < sw * 2 && box.height < sw * 2) {
return new Circle2d({
x: -sw,
y: -sw,
radius: sw,
isFilled: true,
})
}
}
const strokePoints = getStrokePoints(
points,
getFreehandOptions(shape.props, sw, shape.props.isPen, true)
).map((p) => p.point)
// A closed draw stroke
if (shape.props.isClosed && strokePoints.length > 2) {
return new Polygon2d({
points: strokePoints,
isFilled: shape.props.fill !== 'none',
})
}
if (strokePoints.length === 1) {
return new Circle2d({
x: -sw,
y: -sw,
radius: sw,
isFilled: true,
})
}
// An open draw stroke
return new Polyline2d({
points: strokePoints,
})
}
component(shape: TLDrawShape) {
const dv = getDisplayValues(this, shape)
return (
<SVGContainer>
<DrawShapeSvg
shape={shape}
strokeColor={dv.strokeColor}
strokeWidth={dv.strokeWidth}
fillColor={dv.fillColor}
patternFillFallbackColor={dv.patternFillFallbackColor}
/>
</SVGContainer>
)
}
override getIndicatorPath(shape: TLDrawShape): Path2D {
const allPointsFromSegments = getPointsFromDrawSegments(
shape.props.segments,
shape.props.scaleX,
shape.props.scaleY
)
let sw = (getDisplayValues(this, shape).strokeWidth + 1) * shape.props.scale
const zoomLevel = this.editor.getEfficientZoomLevel()
const forceSolid = zoomLevel < 0.5 && zoomLevel < 1.5 / sw
if (
!forceSolid &&
!shape.props.isPen &&
shape.props.dash === 'draw' &&
allPointsFromSegments.length === 1
) {
sw += rng(shape.id)() * (sw / 6)
}
const showAsComplete = shape.props.isComplete || last(shape.props.segments)?.type === 'straight'
const options = getFreehandOptions(shape.props, sw, showAsComplete, true)
const strokePoints = getStrokePoints(allPointsFromSegments, options)
const solidStrokePath =
strokePoints.length > 1
? getSvgPathFromStrokePoints(strokePoints, shape.props.isClosed)
: getDot(allPointsFromSegments[0] ?? { x: 0, y: 0 }, sw)
return new Path2D(solidStrokePath)
}
override toSvg(shape: TLDrawShape, ctx: SvgExportContext) {
ctx.addExportDef(getFillDefForExport(shape.props.fill))
const dv = getDisplayValues(this, shape, ctx.colorMode)
const scaleFactor = 1 / shape.props.scale
return (
<g transform={`scale(${scaleFactor})`}>
<DrawShapeSvg
shape={shape}
zoomOverride={1}
strokeColor={dv.strokeColor}
strokeWidth={dv.strokeWidth}
fillColor={dv.fillColor}
patternFillFallbackColor={dv.patternFillFallbackColor}
/>
</g>
)
}
override getCanvasSvgDefs(): TLShapeUtilCanvasSvgDef[] {
return [getFillDefForCanvas()]
}
override onResize(shape: TLDrawShape, info: TLResizeInfo<TLDrawShape>) {
const newScaleX = info.scaleX * shape.props.scaleX
const newScaleY = info.scaleY * shape.props.scaleY
if (newScaleX === 0 || newScaleY === 0) return
return {
props: {
scaleX: newScaleX,
scaleY: newScaleY,
},
}
}
override expandSelectionOutlinePx(shape: TLDrawShape): number {
const multiplier = shape.props.dash === 'draw' ? 1.6 : 1
return ((getDisplayValues(this, shape).strokeWidth * multiplier) / 2) * shape.props.scale
}
override getInterpolatedProps(
startShape: TLDrawShape,
endShape: TLDrawShape,
t: number
): TLDrawShapeProps {
return {
...(t > 0.5 ? endShape.props : startShape.props),
segments: interpolateSegments(startShape.props.segments, endShape.props.segments, t),
scale: lerp(startShape.props.scale, endShape.props.scale, t),
}
}
}
function getDot(point: VecLike, sw: number) {
const r = (sw + 1) * 0.5
return `M ${point.x} ${point.y} m -${r}, 0 a ${r},${r} 0 1,0 ${r * 2},0 a ${r},${r} 0 1,0 -${
r * 2
},0`
}
function getIsDot(shape: TLDrawShape) {
// A dot is a single-point segment. isSinglePoint knows the per-encoding length
// (and takes the segment's dim), so this stays correct for both 2D and 3D paths.
// A bare length threshold can't: a 2-point 2D path is the same length as a 1-point
// 3D path, so only the dim disambiguates them.
const segment = shape.props.segments[0]
return shape.props.segments.length === 1 && b64Vecs.isSinglePoint(segment.path, segment.dim)
}
function DrawShapeSvg({
shape,
zoomOverride,
strokeColor,
strokeWidth: baseStrokeWidth,
fillColor,
patternFillFallbackColor,
}: {
shape: TLDrawShape
zoomOverride?: number
strokeColor: string
strokeWidth: number
fillColor: string
patternFillFallbackColor: string
}) {
const editor = useEditor()
const allPointsFromSegments = getPointsFromDrawSegments(
shape.props.segments,
shape.props.scaleX,
shape.props.scaleY
)
const showAsComplete = shape.props.isComplete || last(shape.props.segments)?.type === 'straight'
let sw = (baseStrokeWidth + 1) * shape.props.scale
const forceSolid = useValue(
'force solid',
() => {
const zoomLevel = zoomOverride ?? editor.getEfficientZoomLevel()
return zoomLevel < 0.5 && zoomLevel < 1.5 / sw
},
[editor, sw, zoomOverride]
)
const dotAdjustment = useValue(
'dot adjustment',
() => {
const zoomLevel = zoomOverride ?? editor.getEfficientZoomLevel()
// If we're zoomed way out (10%), then we need to make the dotted line go to 9 instead 0.1
// Chrome doesn't render anything otherwise.
return zoomLevel < 0.2 ? 9 : 0.1
},
[editor, zoomOverride]
)
if (
!forceSolid &&
!shape.props.isPen &&
shape.props.dash === 'draw' &&
allPointsFromSegments.length === 1
) {
sw += rng(shape.id)() * (sw / 6)
}
const options = getFreehandOptions(shape.props, sw, showAsComplete, forceSolid)
if (!forceSolid && shape.props.dash === 'draw') {
return (
<>
{shape.props.isClosed &&
shape.props.fill !== 'none' &&
allPointsFromSegments.length > 1 &&
(shape.props.fill === 'pattern' ? (
<PatternFill
d={getSvgPathFromStrokePoints(
getStrokePoints(allPointsFromSegments, options),
shape.props.isClosed
)}
fillColor={fillColor}
patternFillFallbackColor={patternFillFallbackColor}
scale={shape.props.scale}
/>
) : (
<path
fill={fillColor}
d={getSvgPathFromStrokePoints(
getStrokePoints(allPointsFromSegments, options),
shape.props.isClosed
)}
/>
))}
<path d={svgInk(allPointsFromSegments, options)} strokeLinecap="round" fill={strokeColor} />
</>
)
}
const strokePoints = getStrokePoints(allPointsFromSegments, options)
const isDot = strokePoints.length < 2
const fill = isDot || shape.props.isClosed ? shape.props.fill : 'none'
const solidStrokePath = isDot
? getDot(allPointsFromSegments[0] ?? { x: 0, y: 0 }, 0)
: getSvgPathFromStrokePoints(strokePoints, shape.props.isClosed && fill !== 'none')
return (
<>
{isDot || !shape.props.isClosed || fill === 'none' ? null : fill === 'pattern' ? (
<PatternFill
d={solidStrokePath}
fillColor={fillColor}
patternFillFallbackColor={patternFillFallbackColor}
scale={shape.props.scale}
/>
) : (
<path fill={fillColor} d={solidStrokePath} />
)}
{shape.props.dash !== 'none' && (
<path
d={solidStrokePath}
strokeLinecap="round"
fill={isDot ? strokeColor : 'none'}
stroke={strokeColor}
strokeWidth={sw}
strokeDasharray={isDot ? 'none' : getDrawShapeStrokeDashArray(shape, sw, dotAdjustment)}
strokeDashoffset="0"
/>
)}
</>
)
}

View File

@@ -0,0 +1,167 @@
import {
EASINGS,
PI,
SIN,
TLDefaultDashStyle,
TLDrawShape,
TLDrawShapeSegment,
Vec,
b64Vecs,
modulate,
} from '@tldraw/editor'
import { StrokeOptions } from '../shared/freehand/types'
const PEN_EASING = (t: number) => t * 0.65 + SIN((t * PI) / 2) * 0.35
const simulatePressureSettings = (strokeWidth: number): StrokeOptions => {
return {
size: strokeWidth,
thinning: 0.5,
streamline: modulate(strokeWidth, [9, 16], [0.64, 0.74], true), // 0.62 + ((1 + strokeWidth) / 8) * 0.06,
smoothing: 0.62,
easing: EASINGS.easeOutSine,
simulatePressure: true,
}
}
const realPressureSettings = (strokeWidth: number): StrokeOptions => {
return {
size: 1 + strokeWidth * 1.2,
thinning: 0.62,
streamline: 0.62,
smoothing: 0.62,
simulatePressure: false,
easing: PEN_EASING,
}
}
const solidSettings = (strokeWidth: number): StrokeOptions => {
return {
size: strokeWidth,
thinning: 0,
streamline: modulate(strokeWidth, [9, 16], [0.64, 0.74], true), // 0.62 + ((1 + strokeWidth) / 8) * 0.06,
smoothing: 0.62,
simulatePressure: false,
easing: EASINGS.linear,
}
}
const solidRealPressureSettings = (strokeWidth: number): StrokeOptions => {
return {
size: strokeWidth,
thinning: 0,
streamline: 0.62,
smoothing: 0.62,
simulatePressure: false,
easing: EASINGS.linear,
}
}
export function getHighlightFreehandSettings({
strokeWidth,
showAsComplete,
}: {
strokeWidth: number
showAsComplete: boolean
}): StrokeOptions {
return {
size: 1 + strokeWidth,
thinning: 0,
streamline: 0.5,
smoothing: 0.5,
simulatePressure: false,
easing: EASINGS.easeOutSine,
last: showAsComplete,
}
}
export function getFreehandOptions(
shapeProps: { dash: TLDefaultDashStyle; isPen: boolean; isComplete: boolean },
strokeWidth: number,
forceComplete: boolean,
forceSolid: boolean
): StrokeOptions {
const last = shapeProps.isComplete || forceComplete
if (forceSolid) {
if (shapeProps.isPen) {
return { ...solidRealPressureSettings(strokeWidth), last }
} else {
return { ...solidSettings(strokeWidth), last }
}
}
if (shapeProps.dash === 'draw') {
if (shapeProps.isPen) {
return { ...realPressureSettings(strokeWidth), last }
} else {
return { ...simulatePressureSettings(strokeWidth), last }
}
}
return { ...solidSettings(strokeWidth), last }
}
/** @public */
export function getPointsFromDrawSegment(
segment: TLDrawShapeSegment,
scaleX: number,
scaleY: number,
points: Vec[] = []
) {
const _points = b64Vecs.decodePoints(segment.path, segment.dim)
// Apply scale factors (used for lazy resize and flipping)
if (scaleX !== 1 || scaleY !== 1) {
for (const point of _points) {
point.x *= scaleX
point.y *= scaleY
}
}
if (segment.type === 'free' || _points.length < 2) {
points.push(..._points.map(Vec.From))
} else {
// A B <interpolated points> D
const A = Vec.From(_points[0])
const D = Vec.From(_points[1])
const dist = Vec.Dist(D, A)
if (dist === 0) {
points.push(A)
} else {
const uni = Vec.Tan(D, A)
const nudgeDist = Math.min(1, Math.floor(dist / 4))
const B = Vec.Add(A, Vec.Mul(uni, nudgeDist))
const C = Vec.Add(D, Vec.Mul(uni, -nudgeDist))
const interpolatedPointsCount = Math.max(4, Math.floor(dist / 16))
points.push(A, ...Vec.PointsBetween(B, C, interpolatedPointsCount, EASINGS.easeInOutCubic), D)
}
}
return points
}
/** @public */
export function getPointsFromDrawSegments(segments: TLDrawShapeSegment[], scaleX = 1, scaleY = 1) {
const points: Vec[] = []
for (const segment of segments) {
getPointsFromDrawSegment(segment, scaleX, scaleY, points)
}
return points
}
export function getDrawShapeStrokeDashArray(
shape: TLDrawShape,
strokeWidth: number,
dotAdjustment: number
) {
return {
draw: 'none',
solid: `none`,
dotted: `${dotAdjustment} ${strokeWidth * 2}`,
dashed: `${strokeWidth * 2} ${strokeWidth * 2}`,
none: `none`,
}[shape.props.dash]
}

View File

@@ -0,0 +1,792 @@
import {
Mat,
StateNode,
TLDefaultSizeStyle,
TLDrawShape,
TLDrawShapeSegment,
TLHighlightShape,
TLKeyboardEventInfo,
TLPointerEventInfo,
TLShapePartial,
DIM_2D,
Vec,
VecModel,
b64Vecs,
createShapeId,
last,
snapAngle,
structuredClone,
toFixed,
uniqueId,
} from '@tldraw/editor'
import { HighlightShapeUtil } from '../../highlight/HighlightShapeUtil'
import { STROKE_SIZES } from '../../shared/default-shape-constants'
import { getDisplayValues } from '../../shared/getDisplayValues'
import { DrawShapeUtil } from '../DrawShapeUtil'
type DrawableShape = TLDrawShape | TLHighlightShape
export class Drawing extends StateNode {
static override id = 'drawing'
static override trackPerformance = true
info = {} as TLPointerEventInfo
initialShape?: DrawableShape
override shapeType = this.parent.id === 'highlight' ? ('highlight' as const) : ('draw' as const)
util = this.editor.getShapeUtil(this.shapeType) as DrawShapeUtil | HighlightShapeUtil
isPen = false
isPenOrStylus = false
// Encoding dimension for segments created during this stroke. `2` drops the
// constant pressure value (non-pressure devices), `undefined` keeps full 3D.
// Decided once in startShape from the input device — see the note there.
segmentDim: 2 | undefined = undefined
segmentMode = 'free' as 'free' | 'straight' | 'starting_straight' | 'starting_free'
didJustShiftClickToExtendPreviousShapeLine = false
pagePointWhereCurrentSegmentChanged = {} as Vec
pagePointWhereNextSegmentChanged = null as Vec | null
lastRecordedPoint = {} as Vec
mergeNextPoint = false
currentLineLength = 0
zoomOnEnter = 1
// Cache for current segment's points to avoid repeated b64 decode/encode
currentSegmentPoints: Vec[] = []
markId = null as null | string
override onEnter(info: TLPointerEventInfo) {
this.markId = null
this.info = info
this.lastRecordedPoint = this.editor.inputs.getCurrentPagePoint().clone()
this.zoomOnEnter = this.editor.getZoomLevel()
this.startShape()
}
override onPointerMove() {
const { inputs } = this.editor
const isPen = inputs.getIsPen()
if (this.isPen && !isPen) {
// The user made a palm gesture before starting a pen gesture;
// ideally we'd start the new shape here but we could also just bail
// as the next interaction will work correctly
if (this.markId) {
this.editor.bailToMark(this.markId)
this.startShape()
return
}
}
if (this.isPenOrStylus) {
// Don't update the shape if we haven't moved far enough from the last time we recorded a point
const currentPagePoint = inputs.getCurrentPagePoint()
if (Vec.Dist(currentPagePoint, this.lastRecordedPoint) >= 1 / this.zoomOnEnter) {
this.lastRecordedPoint = currentPagePoint.clone()
this.mergeNextPoint = false
} else {
this.mergeNextPoint = true
}
} else {
this.mergeNextPoint = false
}
this.updateDrawingShape()
}
override onKeyDown(info: TLKeyboardEventInfo) {
if (info.key === 'Shift') {
switch (this.segmentMode) {
case 'free': {
// We've just entered straight mode, go to straight mode
this.segmentMode = 'starting_straight'
this.pagePointWhereNextSegmentChanged = this.editor.inputs.getCurrentPagePoint().clone()
break
}
case 'starting_free': {
this.segmentMode = 'starting_straight'
}
}
}
this.updateDrawingShape()
}
override onKeyUp(info: TLKeyboardEventInfo) {
if (info.key === 'Shift') {
this.editor.snaps.clearIndicators()
switch (this.segmentMode) {
case 'straight': {
// We've just exited straight mode, go back to free mode
this.segmentMode = 'starting_free'
this.pagePointWhereNextSegmentChanged = this.editor.inputs.getCurrentPagePoint().clone()
break
}
case 'starting_straight': {
// Entered from 'starting_free' the last segment is still straight. A straight
// segment renders only its first two points, so writing free points into it
// freezes the stroke: go back to 'starting_free' instead of 'free'.
const shape =
this.initialShape && this.editor.getShape<DrawableShape>(this.initialShape.id)
if (shape && last(shape.props.segments)?.type === 'straight') {
this.segmentMode = 'starting_free'
} else {
this.pagePointWhereNextSegmentChanged = null
this.segmentMode = 'free'
}
break
}
}
}
this.updateDrawingShape()
}
override onExit() {
this.editor.snaps.clearIndicators()
this.pagePointWhereCurrentSegmentChanged = this.editor.inputs.getCurrentPagePoint().clone()
}
canClose() {
return this.shapeType !== 'highlight'
}
getIsClosed(
segments: TLDrawShapeSegment[],
size: TLDefaultSizeStyle,
scale: number,
strokeWidth?: number
) {
if (!this.canClose()) return false
if (strokeWidth === undefined) {
const theme = this.editor.getCurrentTheme()
strokeWidth = theme.strokeWidth * STROKE_SIZES[size]
}
const firstPoint = b64Vecs.decodeFirstPoint(segments[0].path, segments[0].dim)
const lastSegment = segments[segments.length - 1]
const lastPoint = b64Vecs.decodeLastPoint(lastSegment.path, lastSegment.dim)
const isDynamicResizingEnabled = this.editor.user.getIsDynamicResizeMode()
const threshold = isDynamicResizingEnabled // when dynamic resizing is enabled scale is 1/zoom, so the threshold should not scale directly with zoom at all
? (strokeWidth + 2) * scale // +2 keeps tiny strokes from being too hard to close
: // 6 is a base floor, 2 is stroke influence, 0.8 tempers width growth
6 +
2 * Math.sqrt(strokeWidth * 0.8) +
// 100 is low-zoom boost, 0.18 is the zoom knee, 3 controls falloff steepness
100 / (1 + Math.pow(this.zoomOnEnter / 0.18, 3))
return (
firstPoint !== null &&
lastPoint !== null &&
this.currentLineLength > strokeWidth * 4 * scale &&
Vec.DistMin(firstPoint, lastPoint, threshold)
)
}
/**
* Build a segment from points, encoding `path` at this stroke's `segmentDim` and
* attaching `dim: 2` only when z was dropped. 3D segments omit `dim` so they
* serialize byte-identically to pre-#8879 data.
*/
private makeSegment(type: TLDrawShapeSegment['type'], points: VecModel[]): TLDrawShapeSegment {
const path = b64Vecs.encodePoints(points, this.segmentDim)
return this.segmentDim === DIM_2D ? { type, path, dim: DIM_2D } : { type, path }
}
private startShape() {
const inputs = this.editor.inputs
const originPagePoint = inputs.getOriginPagePoint()
const isPen = inputs.getIsPen()
this.markId = this.editor.markHistoryStoppingPoint('draw start')
// If the pressure is weird, then it's probably a stylus reporting as a mouse
// We treat pen/stylus inputs differently in the drawing tool, so we need to
// have our own value for this. The inputs.isPen is only if the input is a regular
// pen, like an iPad pen, which needs to trigger "pen mode" in order to avoid
// accidental palm touches. We don't have to worry about that with styluses though.
const { z = 0.5 } = this.info.point
this.isPen = isPen
// if z === 0 on the initial point, treat this pen as a mouse because it's likely a broken pen
// or a broken OS.
this.isPenOrStylus = (isPen && z !== 0) || (z > 0 && z < 0.5) || (z > 0.5 && z < 1)
// Decide the encoding dimension once, here, from the input device — not by
// scanning point data (content-driven). Device-driven keeps the encoded bytes
// deterministic and is O(1) at stroke start. Non-pressure devices report a
// constant z (0 / 0.5 / 1) that we normalize to 0.5 and omit entirely (2D);
// pen/stylus input keeps the full 3D encoding.
this.segmentDim = this.isPenOrStylus ? undefined : DIM_2D
const pressure = this.isPenOrStylus ? z * 1.25 : 0.5
this.segmentMode = this.editor.inputs.getShiftKey() ? 'straight' : 'free'
this.didJustShiftClickToExtendPreviousShapeLine = false
this.lastRecordedPoint = originPagePoint.clone()
if (this.initialShape) {
const shape = this.editor.getShape<DrawableShape>(this.initialShape.id)
// The remembered shape survives a page change; extending it from another
// page would silently append a segment to content the user can't see (#10400)
if (shape && this.segmentMode === 'straight' && this.editor.isShapeInPage(shape)) {
// Connect dots
this.didJustShiftClickToExtendPreviousShapeLine = true
const prevSegment = last(shape.props.segments)
if (!prevSegment) throw Error('Expected a previous segment!')
const prevPoint = b64Vecs.decodeLastPoint(prevSegment.path, prevSegment.dim)
if (!prevPoint) throw Error('Expected a previous point!')
const { x, y } = this.editor.getPointInShapeSpace(shape, originPagePoint).toFixed()
const newSegment = this.makeSegment(this.segmentMode, [
{ x: prevPoint.x, y: prevPoint.y, z: +pressure.toFixed(2) },
{ x, y, z: +pressure.toFixed(2) },
])
// Convert prevPoint to page space
const prevPointPageSpace = Mat.applyToPoint(
this.editor.getShapePageTransform(shape.id)!,
prevPoint
)
this.pagePointWhereCurrentSegmentChanged = prevPointPageSpace
this.pagePointWhereNextSegmentChanged = null
const segments = [...shape.props.segments, newSegment]
const dvStrokeWidth = (getDisplayValues(this.util as any, shape) as { strokeWidth: number })
.strokeWidth
if (this.currentLineLength < dvStrokeWidth * 4) {
this.currentLineLength = this.getLineLength(segments)
}
const shapePartial: TLShapePartial<DrawableShape> = {
id: shape.id,
type: this.shapeType,
props: {
segments,
},
}
if (this.canClose()) {
;(shapePartial as TLShapePartial<TLDrawShape>).props!.isClosed = this.getIsClosed(
segments,
shape.props.size,
shape.props.scale
)
}
this.editor.updateShapes([shapePartial])
return
}
}
// Create a new shape
this.pagePointWhereCurrentSegmentChanged = originPagePoint.clone()
const id = createShapeId()
// Initialize the segment points cache
const initialPoint = new Vec(0, 0, +pressure.toFixed(2))
this.currentSegmentPoints = [initialPoint]
// Allow this to trigger the max shapes reached alert
this.editor.createShape({
id,
type: this.shapeType,
x: originPagePoint.x,
y: originPagePoint.y,
props: {
isPen: this.isPenOrStylus,
scale: this.editor.getResizeScaleFactor(),
segments: [this.makeSegment(this.segmentMode, [initialPoint])],
},
})
const shape = this.editor.getShape<DrawableShape>(id)
if (!shape) {
this.cancel()
return
}
this.currentLineLength = 0
this.initialShape = this.editor.getShape<DrawableShape>(id)
}
private updateDrawingShape() {
const { initialShape } = this
const { inputs } = this.editor
if (!initialShape) return
const {
id,
props: { size, scale },
} = initialShape
const shape = this.editor.getShape<DrawableShape>(id)!
if (!shape) return
const { segments } = shape.props
const currentPagePoint = inputs.getCurrentPagePoint()
const { x, y, z } = this.editor.getPointInShapeSpace(shape, currentPagePoint).toFixed()
const pressure = this.isPenOrStylus ? +(currentPagePoint.z! * 1.25).toFixed(2) : 0.5
const newPoint = { x, y, z: pressure }
switch (this.segmentMode) {
case 'starting_straight': {
const { pagePointWhereNextSegmentChanged } = this
if (pagePointWhereNextSegmentChanged === null) {
throw Error('We should have a point where the segment changed')
}
// page-space distance against a screen-space threshold, so scale by the zoom
const zoom = this.editor.getZoomLevel()
const hasMovedFarEnough =
Vec.Dist2(pagePointWhereNextSegmentChanged, inputs.getCurrentPagePoint()) * zoom * zoom >
this.editor.options.dragDistanceSquared
// Find the distance from where the pointer was when shift was released and
// where it is now; if it's far enough away, then update the page point where
// the current segment changed (to match the pagepoint where next segment changed)
// and set the pagepoint where next segment changed to null.
if (hasMovedFarEnough) {
this.pagePointWhereCurrentSegmentChanged = this.pagePointWhereNextSegmentChanged!.clone()
this.pagePointWhereNextSegmentChanged = null
// Set the new mode
this.segmentMode = 'straight'
const prevSegment = last(segments)
if (!prevSegment) throw Error('Expected a previous segment!')
const prevLastPoint = b64Vecs.decodeLastPoint(prevSegment.path, prevSegment.dim)
if (!prevLastPoint) throw Error('Expected a previous last point!')
let newSegment: TLDrawShapeSegment
const newLastPoint = this.editor
.getPointInShapeSpace(shape, this.pagePointWhereCurrentSegmentChanged)
.toFixed()
.toJson()
if (prevSegment.type === 'straight') {
this.currentLineLength += Vec.Dist(prevLastPoint, newLastPoint)
newSegment = this.makeSegment('straight', [prevLastPoint, newLastPoint])
const transform = this.editor.getShapePageTransform(shape)!
this.pagePointWhereCurrentSegmentChanged = Mat.applyToPoint(transform, prevLastPoint)
} else {
this.currentLineLength += Vec.Dist(newLastPoint, newPoint)
newSegment = this.makeSegment('straight', [newLastPoint, newPoint])
}
const nextSegments = [...segments, newSegment]
const shapePartial: TLShapePartial<DrawableShape> = {
id,
type: this.shapeType,
props: {
segments: nextSegments,
},
}
if (this.canClose()) {
;(shapePartial as TLShapePartial<TLDrawShape>).props!.isClosed = this.getIsClosed(
nextSegments,
size,
scale
)
}
this.editor.updateShapes([shapePartial])
}
break
}
case 'starting_free': {
const { pagePointWhereNextSegmentChanged } = this
if (pagePointWhereNextSegmentChanged === null) {
throw Error('We should have a point where the segment changed')
}
// page-space distance against a screen-space threshold, so scale by the zoom
const zoom = this.editor.getZoomLevel()
const hasMovedFarEnough =
Vec.Dist2(pagePointWhereNextSegmentChanged, inputs.getCurrentPagePoint()) * zoom * zoom >
this.editor.options.dragDistanceSquared
// Find the distance from where the pointer was when shift was released and
// where it is now; if it's far enough away, then update the page point where
// the current segment changed (to match the pagepoint where next segment changed)
// and set the pagepoint where next segment changed to null.
if (hasMovedFarEnough) {
this.pagePointWhereCurrentSegmentChanged = this.pagePointWhereNextSegmentChanged!.clone()
this.pagePointWhereNextSegmentChanged = null
// Set the new mode
this.segmentMode = 'free'
const newSegments = segments.slice()
const prevStraightSegment = newSegments[newSegments.length - 1]
const prevPoint = b64Vecs.decodeLastPoint(
prevStraightSegment.path,
prevStraightSegment.dim
)
if (!prevPoint) {
throw Error('No previous point!')
}
// Create the new free segment and interpolate the points between where the last line
// ended and where the pointer is now
const interpolatedPoints = Vec.PointsBetween(prevPoint, newPoint, 6).map(
(p) => new Vec(toFixed(p.x), toFixed(p.y), toFixed(p.z))
)
// Initialize cache for the new free segment
this.currentSegmentPoints = interpolatedPoints
const newFreeSegment = this.makeSegment('free', interpolatedPoints)
const finalSegments = [...newSegments, newFreeSegment]
const dvStrokeWidth = (
getDisplayValues(this.util as any, shape) as { strokeWidth: number }
).strokeWidth
if (this.currentLineLength < dvStrokeWidth * 4) {
this.currentLineLength = this.getLineLength(finalSegments)
}
const shapePartial: TLShapePartial<DrawableShape> = {
id,
type: this.shapeType,
props: {
segments: finalSegments,
},
}
if (this.canClose()) {
;(shapePartial as TLShapePartial<TLDrawShape>).props!.isClosed = this.getIsClosed(
finalSegments,
size,
scale
)
}
this.editor.updateShapes([shapePartial])
}
break
}
case 'straight': {
const newSegments = segments.slice()
const newSegment = newSegments[newSegments.length - 1]
const { pagePointWhereCurrentSegmentChanged } = this
const inputs = this.editor.inputs
const ctrlKey = inputs.getCtrlKey()
const currentPagePoint = inputs.getCurrentPagePoint()
if (!pagePointWhereCurrentSegmentChanged)
throw Error('We should have a point where the segment changed')
let pagePoint: VecModel
let shouldSnapToAngle = false
if (this.didJustShiftClickToExtendPreviousShapeLine) {
if (this.editor.inputs.getIsDragging()) {
// If we've just shift clicked to extend a line, only snap once we've started dragging
shouldSnapToAngle = !ctrlKey
this.didJustShiftClickToExtendPreviousShapeLine = false
} else {
// noop
}
} else {
// If we're not shift clicking to extend a line, but we're holding shift, then we should snap
shouldSnapToAngle = !ctrlKey // don't snap angle while snapping line
}
let newPoint = this.editor.getPointInShapeSpace(shape, currentPagePoint).toFixed().toJson()
let didSnap = false
let snapSegment: TLDrawShapeSegment | undefined = undefined
const shouldSnap = this.editor.user.getIsSnapMode() ? !ctrlKey : ctrlKey
if (shouldSnap) {
if (newSegments.length > 2) {
let nearestPoint: VecModel | undefined = undefined
let minDistance = 8 / this.zoomOnEnter
// Don't try to snap to the last two segments
for (let i = 0, n = segments.length - 2; i < n; i++) {
const segment = segments[i]
if (!segment) break
if (segment.type === 'free') continue
const first = b64Vecs.decodeFirstPoint(segment.path, segment.dim)
const lastPoint = b64Vecs.decodeLastPoint(segment.path, segment.dim)
if (!(first && lastPoint)) continue
// Snap to the nearest point on the segment, if it's closer than the previous snapped point
const nearestPointOnSegment = Vec.NearestPointOnLineSegment(
first,
lastPoint,
newPoint
)
if (Vec.DistMin(nearestPointOnSegment, newPoint, minDistance)) {
nearestPoint = nearestPointOnSegment.toFixed().toJson()
minDistance = Vec.Dist(nearestPointOnSegment, newPoint)
snapSegment = segment
break
}
}
if (nearestPoint) {
didSnap = true
newPoint = nearestPoint
}
}
}
if (didSnap && snapSegment) {
const transform = this.editor.getShapePageTransform(shape)!
const first = b64Vecs.decodeFirstPoint(snapSegment.path, snapSegment.dim)
const lastPoint = b64Vecs.decodeLastPoint(snapSegment.path, snapSegment.dim)
if (!first || !lastPoint) throw Error('Expected a last point!')
const A = Mat.applyToPoint(transform, first)
const B = Mat.applyToPoint(transform, lastPoint)
const snappedPoint = Mat.applyToPoint(transform, newPoint)
this.editor.snaps.setIndicators([
{
id: uniqueId(),
type: 'points',
points: [A, snappedPoint, B],
},
])
} else {
this.editor.snaps.clearIndicators()
if (shouldSnapToAngle) {
// Snap line angle to nearest 15 degrees
const currentAngle = Vec.Angle(pagePointWhereCurrentSegmentChanged, currentPagePoint)
const snappedAngle = snapAngle(currentAngle, 24)
const angleDiff = snappedAngle - currentAngle
pagePoint = Vec.RotWith(
currentPagePoint,
pagePointWhereCurrentSegmentChanged,
angleDiff
)
} else {
pagePoint = currentPagePoint.clone()
}
newPoint = this.editor.getPointInShapeSpace(shape, pagePoint).toFixed().toJson()
}
// If the previous segment is a one point free shape and is the first segment of the line,
// then the user just did a click-and-immediately-press-shift to create a new straight line
// without continuing the previous line. In this case, we want to remove the previous segment.
// A straight segment is just [first, last]; swap its previous length for the new one
// (adding the whole length on every move would let tiny strokes close as if long).
const firstPoint = b64Vecs.decodeFirstPoint(newSegment.path, newSegment.dim)!
const prevLastPoint = b64Vecs.decodeLastPoint(newSegment.path, newSegment.dim)!
this.currentLineLength +=
Vec.Dist(firstPoint, Vec.From(newPoint)) - Vec.Dist(firstPoint, prevLastPoint)
newSegments[newSegments.length - 1] = {
...newSegment,
type: 'straight',
path: b64Vecs.encodePoints([firstPoint, Vec.From(newPoint)], newSegment.dim),
}
const shapePartial: TLShapePartial<DrawableShape> = {
id,
type: this.shapeType,
props: {
segments: newSegments,
},
}
if (this.canClose()) {
;(shapePartial as TLShapePartial<TLDrawShape>).props!.isClosed = this.getIsClosed(
newSegments,
size,
scale
)
}
this.editor.updateShapes([shapePartial])
break
}
case 'free': {
// Use cached points instead of decoding from b64 on every update
const cachedPoints = this.currentSegmentPoints
if (cachedPoints.length && this.mergeNextPoint) {
const lastPoint = cachedPoints[cachedPoints.length - 1]
lastPoint.x = newPoint.x
lastPoint.y = newPoint.y
lastPoint.z = lastPoint.z ? Math.max(lastPoint.z, newPoint.z) : newPoint.z
// Note: we could recompute the line length here, but it's not really necessary
// this.currentLineLength = this.getLineLength(newSegments)
} else {
this.currentLineLength += cachedPoints.length
? Vec.Dist(cachedPoints[cachedPoints.length - 1], newPoint)
: 0
cachedPoints.push(new Vec(newPoint.x, newPoint.y, newPoint.z))
}
const newSegments = segments.slice()
const newSegment = newSegments[newSegments.length - 1]
newSegments[newSegments.length - 1] = {
...newSegment,
path: b64Vecs.encodePoints(cachedPoints, newSegment.dim),
}
const dvStrokeWidth = (getDisplayValues(this.util as any, shape) as { strokeWidth: number })
.strokeWidth
if (this.currentLineLength < dvStrokeWidth * 4) {
this.currentLineLength = this.getLineLength(newSegments)
}
const shapePartial: TLShapePartial<DrawableShape> = {
id,
type: this.shapeType,
props: {
segments: newSegments,
},
}
if (this.canClose()) {
;(shapePartial as TLShapePartial<TLDrawShape>).props!.isClosed = this.getIsClosed(
newSegments,
size,
scale
)
}
this.editor.updateShapes([shapePartial])
// Set a maximum length for the lines array; after 200 points, complete the line.
if (cachedPoints.length > this.util.options.maxPointsPerShape) {
this.editor.updateShapes([{ id, type: this.shapeType, props: { isComplete: true } }])
const newShapeId = createShapeId()
const props = this.editor.getShape<DrawableShape>(id)!.props
if (!this.editor.canCreateShapes([newShapeId])) return this.cancel()
const currentPagePoint = inputs.getCurrentPagePoint()
// Reset cache for the new shape's segment
const initialPoint = new Vec(0, 0, this.isPenOrStylus ? +(z! * 1.25).toFixed(2) : 0.5)
this.currentSegmentPoints = [initialPoint]
this.editor.createShape({
id: newShapeId,
type: this.shapeType,
x: toFixed(currentPagePoint.x),
y: toFixed(currentPagePoint.y),
props: {
isPen: this.isPenOrStylus,
scale: props.scale,
segments: [this.makeSegment('free', [initialPoint])],
},
})
const shape = this.editor.getShape<DrawableShape>(newShapeId)
if (!shape) {
// This would only happen if the page is full and no more shapes can be created. The bug would manifest as a crash when we try to clone the shape.
// todo: handle this type of thing better
return this.cancel()
}
this.initialShape = structuredClone(shape)
this.mergeNextPoint = false
this.lastRecordedPoint = currentPagePoint.clone()
this.currentLineLength = 0
}
break
}
}
}
private getLineLength(segments: TLDrawShapeSegment[]) {
let length = 0
for (let j = 0; j < segments.length; j++) {
const points = b64Vecs.decodePoints(segments[j].path, segments[j].dim)
for (let i = 0; i < points.length - 1; i++) {
length += Vec.Dist(points[i], points[i + 1])
}
}
return length
}
override onPointerUp() {
this.complete()
}
override onCancel() {
this.cancel()
}
override onComplete() {
this.complete()
}
override onInterrupt() {
if (this.editor.inputs.getIsDragging()) {
return
}
if (this.markId) {
this.editor.bailToMark(this.markId)
}
this.cancel()
}
complete() {
const { initialShape } = this
if (!initialShape) return
this.editor.updateShapes([
{ id: initialShape.id, type: initialShape.type, props: { isComplete: true } },
])
this.parent.transition('idle')
}
cancel() {
this.parent.transition('idle', this.info)
}
}

View File

@@ -0,0 +1,22 @@
import { StateNode, TLPointerEventInfo } from '@tldraw/editor'
export class Idle extends StateNode {
static override id = 'idle'
override onPointerDown(info: TLPointerEventInfo) {
if (info.accelKey && !info.shiftKey) {
this.editor.setCurrentTool('eraser.pointing', { ...info, onInteractionEnd: this.parent.id })
return
}
this.parent.transition('drawing', info)
}
override onEnter() {
this.editor.setCursor({ type: 'cross', rotation: 0 })
}
override onCancel() {
this.editor.setCurrentTool('select')
}
}

View File

@@ -0,0 +1,470 @@
/* eslint-disable react-hooks/rules-of-hooks */
import {
BaseBoxShapeUtil,
HTMLContainer,
TLEmbedShape,
TLEmbedShapeProps,
TLResizeInfo,
embedShapeMigrations,
embedShapeProps,
lerp,
resizeBox,
toDomPrecision,
useColorMode,
useIsEditing,
useSvgExportContext,
useValue,
} from '@tldraw/editor'
import {
DEFAULT_EMBED_DEFINITIONS,
DefaultEmbedConfig,
TLEmbedDefinition,
TLEmbedShapePermissions,
embedShapePermissionDefaults,
unknownEmbedShapePermissionOverrides,
} from '../../defaultEmbedDefinitions'
import { TLEmbedResult, getCorrectedEmbedSize, getEmbedInfo } from '../../utils/embeds/embeds'
import { BookmarkShapeComponent } from '../bookmark/BookmarkShapeUtil'
import { ShapeOptionsWithDisplayValues, getDisplayValues } from '../shared/getDisplayValues'
import { getRotatedBoxShadow } from '../shared/rotated-box-shadow'
/** @public */
export interface EmbedShapeUtilDisplayValues {
showShadow: boolean
}
/** @public */
export interface EmbedShapeOptions extends ShapeOptionsWithDisplayValues<
TLEmbedShape,
EmbedShapeUtilDisplayValues
> {
/** The embed definitions to use for this shape util. */
readonly embedDefinitions: readonly TLEmbedDefinition[]
/**
* Per-embed configuration, keyed by embed type. Passed to each definition's `toEmbedUrl` when
* building its embed URL — for example, an API key for the default Google Maps embed:
*
* ```ts
* EmbedShapeUtil.configure({ embedConfig: { google_maps: { apiKey: '...' } } })
* ```
*/
readonly embedConfig?: DefaultEmbedConfig & Record<string, unknown>
}
const getSandboxPermissions = (permissions: TLEmbedShapePermissions) => {
return Object.entries(permissions)
.filter(([_perm, isEnabled]) => isEnabled)
.map(([perm]) => perm)
.join(' ')
}
/** @public */
export class EmbedShapeUtil extends BaseBoxShapeUtil<TLEmbedShape> {
static override type = 'embed' as const
static override props = embedShapeProps
static override migrations = embedShapeMigrations
override options: EmbedShapeOptions = {
embedDefinitions: DEFAULT_EMBED_DEFINITIONS,
embedConfig: {},
getDefaultDisplayValues(): EmbedShapeUtilDisplayValues {
return {
showShadow: true,
}
},
getCustomDisplayValues(): Partial<EmbedShapeUtilDisplayValues> {
return {}
},
}
override canEditWhileLocked(shape: TLEmbedShape) {
const result = this.getEmbedDefinition(shape.props.url)
if (!result) return true
return result.definition.canEditWhileLocked ?? true
}
private static legacyEmbedDefinitions: readonly TLEmbedDefinition[] | null = null
/** @deprecated - Use `EmbedShapeUtil.configure({ embedDefinitions: [...] })` instead. */
static setEmbedDefinitions(embedDefinitions: readonly TLEmbedDefinition[]) {
EmbedShapeUtil.legacyEmbedDefinitions = embedDefinitions
}
private getEmbedDefs(): readonly TLEmbedDefinition[] {
return EmbedShapeUtil.legacyEmbedDefinitions ?? this.options.embedDefinitions
}
getEmbedDefinitions(): readonly TLEmbedDefinition[] {
return this.getEmbedDefs()
}
getEmbedDefinition(url: string): TLEmbedResult {
return getEmbedInfo(this.getEmbedDefs(), url, this.options.embedConfig)
}
/**
* Resolve the embed's true aspect ratio (for definitions with `sizeToContentAspectRatio`) and
* correct the shape's size to match. The dimensions come from the URL's OpenGraph metadata via
* the editor's url asset handler (the same "unfurl" path bookmarks use). The correction is
* applied without adding to the undo history.
*/
async resolveAspectRatio(shape: TLEmbedShape) {
const { url } = shape.props
const definition = this.getEmbedDefinition(url)?.definition
if (!definition?.sizeToContentAspectRatio || !url) return
const resolvedRatio = await this.getContentAspectRatio(url)
// The editor may have been torn down while the metadata was in flight.
if (this.editor.isDisposed) return
const latest = this.editor.getShape<TLEmbedShape>(shape.id)
// Bail if the shape is gone or its url changed while we were awaiting: a later run for the
// new url will handle it, and we must not apply this url's ratio to a different video.
if (!latest || latest.props.url !== url) return
const corrected = getCorrectedEmbedSize({
w: latest.props.w,
h: latest.props.h,
resolvedRatio,
})
if (!corrected) return
// `onResize` enforces a per-axis minimum (inflated by the aspect ratio when locked), so a very
// tall/narrow target could have one axis clamped while the other isn't — that would break the
// ratio and re-letterboxes the very content we're correcting. Scale the whole target up so it
// clears both floors, which preserves the ratio at the cost of a slightly larger box only in
// the extreme (still far smaller than leaving one axis un-corrected).
const { minWidth, minHeight } = this.getResizeMinBounds(latest)
const clearFloor = Math.max(1, minWidth / corrected.w, minHeight / corrected.h)
const targetW = corrected.w * clearFloor
const targetH = corrected.h * clearFloor
// Let the editor resize the shape: by default it scales around the shape's page-space center
// and along its own rotation axis, so the center stays fixed even if the embed was rotated
// before the ratio resolved. No need to compute x/y by hand.
//
// We pass `isAspectRatioLocked: false` because embeds like Vimeo lock their aspect ratio,
// which would otherwise make the editor merge our non-uniform scale into a single uniform
// factor — leaving the wrong ratio and defeating the whole correction.
this.editor.run(
() => {
this.editor.resizeShape(
latest.id,
{
x: targetW / latest.props.w,
y: targetH / latest.props.h,
},
{ isAspectRatioLocked: false }
)
},
{ history: 'ignore' }
)
}
/**
* Resolve the content's aspect ratio from the URL's OpenGraph metadata, fetched through the
* editor's url asset handler (the "unfurl" service). Returns `undefined` if no usable dimensions
* are available (e.g. no unfurl service is configured, or the page reports no image size).
*/
private async getContentAspectRatio(url: string): Promise<number | undefined> {
try {
const asset = await this.editor.getAssetForExternalContent({ type: 'url', url })
if (!asset || asset.type !== 'bookmark') return undefined
const width = asset.meta.imageWidth
const height = asset.meta.imageHeight
if (typeof width === 'number' && typeof height === 'number' && width > 0 && height > 0) {
return width / height
}
} catch {
// Resolving the aspect ratio is best-effort: never let a metadata fetch failure surface.
}
return undefined
}
override getText(shape: TLEmbedShape) {
return shape.props.url
}
override getAriaDescriptor(shape: TLEmbedShape) {
const embedInfo = this.getEmbedDefinition(shape.props.url)
return embedInfo?.definition.title
}
override hideSelectionBoundsFg(shape: TLEmbedShape) {
return !this.canResize(shape)
}
override canEdit(shape: TLEmbedShape) {
return true
}
override canResize(shape: TLEmbedShape) {
return this.getEmbedDefinition(shape.props.url)?.definition?.doesResize ?? true
}
override canEditInReadonly(shape: TLEmbedShape) {
return true
}
override getDefaultProps(): TLEmbedShape['props'] {
return {
w: 300,
h: 300,
url: '',
}
}
override getGeometry(shape: TLEmbedShape) {
return super.getGeometry(shape)
}
override isAspectRatioLocked(shape: TLEmbedShape) {
const embedInfo = this.getEmbedDefinition(shape.props.url)
return embedInfo?.definition.isAspectRatioLocked ?? false
}
/**
* The minimum width/height a resize of `shape` is allowed to reach. When the embed's aspect ratio
* is locked, the longer axis's minimum is inflated by the current aspect ratio so that shrinking
* to the floor keeps the shorter axis at or above the base minimum.
*/
private getResizeMinBounds(shape: TLEmbedShape) {
const embedInfo = this.getEmbedDefinition(shape.props.url)
let minWidth = embedInfo?.definition.minWidth ?? 200
let minHeight = embedInfo?.definition.minHeight ?? 200
if (this.isAspectRatioLocked(shape)) {
// Neither the width nor the height can be less than the base minimum.
const aspectRatio = shape.props.w / shape.props.h
if (aspectRatio > 1) {
// Landscape
minWidth *= aspectRatio
} else {
// Portrait
minHeight /= aspectRatio
}
}
return { minWidth, minHeight }
}
override onResize(shape: TLEmbedShape, info: TLResizeInfo<TLEmbedShape>) {
return resizeBox(shape, info, this.getResizeMinBounds(shape))
}
override component(shape: TLEmbedShape) {
const svgExport = useSvgExportContext()
const { w, h, url } = shape.props
const isEditing = useIsEditing(shape.id)
const colorMode = useColorMode()
const dv = getDisplayValues(this, shape, colorMode)
const embedInfo = this.getEmbedDefinition(url)
const isHoveringWhileEditingSameShape = useValue(
'is hovering',
() => {
const { editingShapeId, hoveredShapeId } = this.editor.getCurrentPageState()
if (editingShapeId && hoveredShapeId !== editingShapeId) {
const editingShape = this.editor.getShape(editingShapeId)
if (editingShape && this.editor.isShapeOfType(editingShape, 'embed')) {
return true
}
}
return false
},
[]
)
const pageRotation = this.editor.getShapePageTransform(shape)!.rotation()
if (svgExport) {
// for SVG exports, we show a blank embed
return (
<HTMLContainer className="tl-embed-container" id={shape.id}>
<div
className="tl-embed"
style={{
border: 0,
boxShadow: dv.showShadow ? getRotatedBoxShadow(pageRotation) : 'none',
borderRadius: embedInfo?.definition.overrideOutlineRadius ?? 8,
background: embedInfo?.definition.backgroundColor ?? 'var(--tl-color-background)',
width: w,
height: h,
}}
/>
</HTMLContainer>
)
}
const isInteractive = isEditing || isHoveringWhileEditingSameShape
// Prevent nested embedding of tldraw
const isIframe =
typeof window !== 'undefined' && (window !== window.top || window.self !== window.parent)
if (isIframe && embedInfo?.definition.type === 'tldraw') return null
const sandbox = getSandboxPermissions({
...embedShapePermissionDefaults,
...(embedInfo
? (embedInfo.definition.overridePermissions ?? {})
: unknownEmbedShapePermissionOverrides),
})
if (embedInfo?.definition.type === 'github_gist') {
const idFromGistUrl = embedInfo.url.split('/').pop()
if (!idFromGistUrl) throw Error('No gist id!')
// Gist embeds use srcDoc, so we must disable allow-same-origin. Otherwise
// the embedded script shares the parent's origin and can escape the sandbox.
const gistSandbox = getSandboxPermissions({
...embedShapePermissionDefaults,
...(embedInfo?.definition?.overridePermissions ?? {}),
'allow-same-origin': false,
})
return (
<HTMLContainer className="tl-embed-container" id={shape.id}>
<Gist
id={idFromGistUrl}
sandbox={gistSandbox}
width={toDomPrecision(w)!}
height={toDomPrecision(h)!}
isInteractive={isInteractive}
pageRotation={pageRotation}
showShadow={dv.showShadow}
/>
</HTMLContainer>
)
}
const iframeSrc = embedInfo?.embedUrl ?? url
return (
<HTMLContainer className="tl-embed-container" id={shape.id}>
{iframeSrc ? (
<iframe
className="tl-embed"
sandbox={sandbox}
src={iframeSrc}
width={toDomPrecision(w)}
height={toDomPrecision(h)}
draggable={false}
// eslint-disable-next-line @typescript-eslint/no-deprecated
frameBorder="0"
referrerPolicy="strict-origin-when-cross-origin"
tabIndex={isEditing ? 0 : -1}
allowFullScreen
style={{
border: 0,
// Fill the container exactly. Relying on the fixed pixel width/height causes
// the flex-centered iframe to be rounded independently from its box at
// fractional sizes/zooms, leaving a 1-2px background sliver on one edge.
width: '100%',
height: '100%',
pointerEvents: isInteractive ? 'auto' : 'none',
// Fix for safari <https://stackoverflow.com/a/49150908>
zIndex: isInteractive ? '' : '-1',
boxShadow: dv.showShadow ? getRotatedBoxShadow(pageRotation) : 'none',
borderRadius: embedInfo?.definition?.overrideOutlineRadius ?? 8,
background: embedInfo?.definition?.backgroundColor,
}}
/>
) : (
<BookmarkShapeComponent
url={url}
h={h}
rotation={pageRotation}
assetId={null}
showImageContainer={false}
/>
)}
</HTMLContainer>
)
}
override getIndicatorPath(shape: TLEmbedShape): Path2D {
const path = new Path2D()
path.rect(0, 0, shape.props.w, shape.props.h)
return path
}
override getInterpolatedProps(
startShape: TLEmbedShape,
endShape: TLEmbedShape,
t: number
): TLEmbedShapeProps {
return {
...(t > 0.5 ? endShape.props : startShape.props),
w: lerp(startShape.props.w, endShape.props.w, t),
h: lerp(startShape.props.h, endShape.props.h, t),
}
}
}
function Gist({
id,
sandbox,
isInteractive,
width,
height,
style,
pageRotation,
showShadow,
}: {
id: string
sandbox: string
isInteractive: boolean
width: number
height: number
pageRotation: number
showShadow: boolean
style?: React.CSSProperties
}) {
// Security warning:
// Gists allow adding .json extensions to the URL which return JSONP.
// Furthermore, the JSONP can include callbacks that execute arbitrary JavaScript.
// It _is_ sandboxed by the iframe but we still want to disable it nonetheless.
// We restrict the id to only allow hexdecimal characters to prevent this.
// Read more:
// https://github.com/bhaveshk90/Content-Security-Policy-CSP-Bypass-Techniques
// https://github.com/renniepak/CSPBypass
if (!id.match(/^[0-9a-f]+$/)) throw Error('No gist id!')
return (
<iframe
className="tl-embed"
sandbox={sandbox}
draggable={false}
width={toDomPrecision(width)}
height={toDomPrecision(height)}
// eslint-disable-next-line @typescript-eslint/no-deprecated
frameBorder="0"
// eslint-disable-next-line @typescript-eslint/no-deprecated
scrolling="no"
referrerPolicy="strict-origin-when-cross-origin"
tabIndex={isInteractive ? 0 : -1}
style={{
...style,
pointerEvents: isInteractive ? 'all' : 'none',
// Fix for safari <https://stackoverflow.com/a/49150908>
zIndex: isInteractive ? '' : '-1',
boxShadow: showShadow ? getRotatedBoxShadow(pageRotation) : 'none',
}}
srcDoc={`
<html>
<head>
<base target="_blank">
</head>
<body>
<script src=${`https://gist.github.com/${id}.js`}></script>
<style type="text/css">
* { margin: 0px; }
table { height: 100%; background-color: red; }
.gist { background-color: none; height: 100%; }
.gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
</style>
</body>
</html>`}
/>
)
}

View File

@@ -0,0 +1,186 @@
import { TestEditor } from '../../../test/TestEditor'
import { FrameShapeTool } from './FrameShapeTool'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
})
afterEach(() => {
editor?.dispose()
})
describe(FrameShapeTool, () => {
it('Creates frame shapes on click-and-drag, supports undo and redo', () => {
expect(editor.getCurrentPageShapes().length).toBe(0)
editor.setCurrentTool('frame')
editor.pointerDown(50, 50)
editor.pointerMove(100, 100)
editor.pointerUp(100, 100)
expect(editor.getCurrentPageShapes().length).toBe(1)
expect(editor.getCurrentPageShapes()[0]?.type).toBe('frame')
expect(editor.getSelectedShapeIds()[0]).toBe(editor.getCurrentPageShapes()[0]?.id)
editor.undo()
expect(editor.getCurrentPageShapes().length).toBe(0)
editor.redo()
expect(editor.getCurrentPageShapes().length).toBe(1)
})
it('Creates frame shapes on click, supports undo and redo', () => {
expect(editor.getCurrentPageShapes().length).toBe(0)
editor.setCurrentTool('frame')
editor.pointerDown(50, 50)
editor.pointerUp(50, 50)
expect(editor.getCurrentPageShapes().length).toBe(1)
expect(editor.getCurrentPageShapes()[0]?.type).toBe('frame')
expect(editor.getSelectedShapeIds()[0]).toBe(editor.getCurrentPageShapes()[0]?.id)
editor.undo()
expect(editor.getCurrentPageShapes().length).toBe(0)
editor.redo()
expect(editor.getCurrentPageShapes().length).toBe(1)
})
})
describe('When selecting the tool', () => {
it('selects the tool and enters the idle state', () => {
editor.setCurrentTool('frame')
editor.expectToBeIn('frame.idle')
})
})
describe('When in the idle state', () => {
it('Enters pointing state on pointer down', () => {
editor.setCurrentTool('frame')
editor.pointerDown(100, 100)
editor.expectToBeIn('frame.pointing')
})
it('Switches back to select tool on cancel', () => {
editor.setCurrentTool('frame')
editor.cancel()
editor.expectToBeIn('select.idle')
})
it('Does nothing on interrupt', () => {
editor.setCurrentTool('frame')
editor.interrupt()
editor.expectToBeIn('frame.idle')
})
})
describe('When in the pointing state', () => {
it('Switches back to idle on cancel', () => {
editor.setCurrentTool('frame')
editor.pointerDown(50, 50)
editor.expectToBeIn('frame.pointing')
editor.cancel()
editor.expectToBeIn('frame.idle')
})
it('Enters the select.resizing state on drag start', () => {
editor.setCurrentTool('frame')
editor.pointerDown(50, 50)
editor.pointerMove(51, 51) // not far enough!
editor.expectToBeIn('frame.pointing')
editor.pointerMove(55, 55)
editor.expectToBeIn('select.resizing')
})
it('Enters the select.resizing state on pointer move', () => {
editor.setCurrentTool('frame')
editor.pointerDown(50, 50)
editor.cancel()
editor.expectToBeIn('frame.idle')
})
it('Returns to the frame state on cancel', () => {
editor.setCurrentTool('frame')
editor.pointerDown(50, 50)
editor.pointerMove(100, 100)
editor.cancel()
editor.expectToBeIn('frame.idle')
})
it('Creates a frame and returns to select tool on pointer up', () => {
expect(editor.getCurrentPageShapes().length).toBe(0)
editor.setCurrentTool('frame')
editor.pointerDown(50, 50)
editor.pointerUp(50, 50)
editor.expectToBeIn('select.idle')
expect(editor.getCurrentPageShapes().length).toBe(1)
})
it('Creates a frame and returns to frame.idle on pointer up if tool lock is enabled', () => {
editor.updateInstanceState({ isToolLocked: true })
expect(editor.getCurrentPageShapes().length).toBe(0)
editor.setCurrentTool('frame')
editor.pointerDown(50, 50)
editor.pointerUp(50, 50)
editor.expectToBeIn('frame.idle')
expect(editor.getCurrentPageShapes().length).toBe(1)
})
it('Leaves nothing behind on complete before the pointer is released', () => {
editor.setCurrentTool('frame')
editor.pointerDown(50, 50)
editor.expectToBeIn('frame.pointing')
editor.complete()
editor.expectToBeIn('frame.idle')
expect(editor.getCurrentPageShapes().length).toBe(0)
})
it('Does not create a frame when undo is pressed mid-press', () => {
editor.setCurrentTool('frame')
editor.pointerDown(50, 50)
editor.undo()
editor.expectToBeIn('frame.idle')
expect(editor.getCurrentPageShapes().length).toBe(0)
})
})
describe('When in the resizing state', () => {
it('Returns to frame on cancel', () => {
editor.setCurrentTool('frame')
editor.pointerDown(50, 50)
editor.pointerMove(55, 55)
editor.expectToBeIn('select.resizing')
editor.cancel()
editor.expectToBeIn('frame.idle')
})
it('Returns to select.idle on complete', () => {
editor.setCurrentTool('frame')
editor.pointerDown(50, 50)
editor.pointerMove(100, 100)
editor.pointerUp(100, 100)
editor.expectToBeIn('select.idle')
})
it('Returns to frame.idle on complete if tool lock is enabled', () => {
editor.updateInstanceState({ isToolLocked: true })
editor.setCurrentTool('frame')
editor.pointerDown(50, 50)
editor.pointerMove(100, 100)
editor.pointerUp(100, 100)
editor.expectToBeIn('frame.idle')
})
})
it('Returns to the idle state on interrupt', () => {
editor.setCurrentTool('frame')
editor.pointerDown(50, 50)
editor.interrupt()
editor.expectToBeIn('frame.idle')
})

View File

@@ -0,0 +1,66 @@
import { BaseBoxShapeTool, Editor, TLShape, TLShapeId } from '@tldraw/editor'
/** @public */
export class FrameShapeTool extends BaseBoxShapeTool {
static override id = 'frame'
static override initial = 'idle'
override shapeType = 'frame' as const
override onCreate(shape: TLShape | null): void {
if (!shape) return
this.editor.reparentShapes(getEnclosedShapeIds(this.editor, shape), shape.id)
if (this.editor.getInstanceState().isToolLocked) {
this.editor.setCurrentTool('frame')
} else {
this.editor.setCurrentTool('select.idle')
}
}
}
/**
* Get the ids of the sibling shapes that a frame would enclose at its current page bounds.
*
* @internal
*/
export function getEnclosedShapeIds(editor: Editor, shape: TLShape): TLShapeId[] {
const bounds = editor.getShapePageBounds(shape)
if (!bounds) return []
const enclosedShapeIds: TLShapeId[] = []
const ancestorIds = editor.getShapeAncestors(shape).map((shape) => shape.id)
editor.getSortedChildIdsForParent(shape.parentId).map((siblingShapeId) => {
const siblingShape = editor.getShape(siblingShapeId)
if (!siblingShape) return
// We don't want to frame the frame itself
if (siblingShape.id === shape.id) return
if (siblingShape.isLocked) return
const pageShapeBounds = editor.getShapePageBounds(siblingShape)
if (!pageShapeBounds) return
// Frame shape encloses page shape
if (bounds.contains(pageShapeBounds)) {
if (canEnclose(siblingShape, ancestorIds, shape)) {
enclosedShapeIds.push(siblingShape.id)
}
}
})
return enclosedShapeIds
}
/** @internal */
function canEnclose(shape: TLShape, ancestorIds: TLShapeId[], frame: TLShape): boolean {
// We don't want to pull in shapes that are ancestors of the frame (can create a cycle)
if (ancestorIds.includes(shape.id)) {
return false
}
// We only want to pull in shapes that are siblings of the frame
if (shape.parentId === frame.parentId) {
return true
}
return false
}

View File

@@ -0,0 +1,417 @@
import {
BaseFrameLikeShapeUtil,
DefaultColorStyle,
Geometry2d,
Group2d,
Rectangle2d,
SVGContainer,
SvgExportContext,
TLClickEventInfo,
TLEditStartInfo,
TLFrameShape,
TLFrameShapeProps,
TLShapePartial,
TLShapeUtilConstructor,
Vec,
clamp,
compact,
frameShapeMigrations,
frameShapeProps,
getColorValue,
lerp,
useColorMode,
useValue,
} from '@tldraw/editor'
import classNames from 'classnames'
import { fitFrameToContent, getFrameChildrenBounds } from '../../utils/frames/frames'
import {
TLCreateTextJsxFromSpansOpts,
createTextJsxFromSpans,
} from '../shared/createTextJsxFromSpans'
import { ShapeOptionsWithDisplayValues, getDisplayValues } from '../shared/getDisplayValues'
import { FrameHeading } from './components/FrameHeading'
import {
defaultEmptyAs,
getFrameHeadingOpts,
getFrameHeadingSide,
getFrameHeadingSize,
getFrameHeadingTranslation,
} from './frameHelpers'
// Some of these values are repeated in CSS and need to match
const FRAME_HEADING_EXTRA_WIDTH = 12
const FRAME_HEADING_MIN_WIDTH = 32
const FRAME_HEADING_NOCOLORS_OFFSET_X = -7
const FRAME_HEADING_OFFSET_Y = 4
/** @public */
export interface FrameShapeUtilDisplayValues {
fillColor: string
strokeColor: string
showColorsFillColor: string
showColorsStrokeColor: string
headingFillColor: string
headingStrokeColor: string
headingTextColor: string
showColorsHeadingFillColor: string
showColorsHeadingStrokeColor: string
showColorsHeadingTextColor: string
}
/** @public */
export interface FrameShapeOptions extends ShapeOptionsWithDisplayValues<
TLFrameShape,
FrameShapeUtilDisplayValues
> {
/**
* When true, the frame will display colors for the shape's headings and background.
*/
showColors: boolean
/**
* When true, the frame will resize its children when the frame itself is resized.
*/
resizeChildren: boolean
}
/** @public */
export class FrameShapeUtil extends BaseFrameLikeShapeUtil<TLFrameShape> {
static override type = 'frame' as const
static override props = frameShapeProps
static override migrations = frameShapeMigrations
override options: FrameShapeOptions = {
showColors: false,
resizeChildren: false,
getDefaultDisplayValues(_editor, shape, theme, colorMode): FrameShapeUtilDisplayValues {
const { color } = shape.props
const colors = theme.colors[colorMode]
return {
fillColor: getColorValue(colors, 'black', 'frameFill'),
strokeColor: getColorValue(colors, 'black', 'frameStroke'),
showColorsFillColor: getColorValue(colors, color, 'frameFill'),
showColorsStrokeColor: getColorValue(colors, color, 'frameStroke'),
headingFillColor: colors.negativeSpace,
headingStrokeColor: colors.negativeSpace,
headingTextColor: getColorValue(colors, 'black', 'frameText'),
showColorsHeadingFillColor: getColorValue(colors, color, 'frameHeadingFill'),
showColorsHeadingStrokeColor: getColorValue(colors, color, 'frameHeadingStroke'),
showColorsHeadingTextColor: getColorValue(colors, color, 'frameText'),
}
},
getCustomDisplayValues(): Partial<FrameShapeUtilDisplayValues> {
return {}
},
}
// evil crimes :)
// By default, showColors is off. Because they use style props, which are picked up
// automatically, we don't have DefaultColorStyle in the props in the schema by default.
// Instead, when someone calls .configure to turn the option on, we manually add in the color
// style here so it plays nicely with the other editor APIs.
static override configure<T extends TLShapeUtilConstructor<any, any>>(
this: T,
options: T extends new (...args: any[]) => { options: infer Options } ? Partial<Options> : never
): T {
const withOptions = super.configure.call(this, options) as T
if ((options as any).showColors) {
;(withOptions as any).props = { ...withOptions.props, color: DefaultColorStyle }
}
return withOptions
}
override canEdit(shape: TLFrameShape, info: TLEditStartInfo) {
return info.type === 'click-header' || info.type === 'unknown'
}
override canResize(shape: TLFrameShape) {
return true
}
override canResizeChildren(shape: TLFrameShape) {
return this.options.resizeChildren
}
override isExportBoundsContainer(): boolean {
return true
}
override getDefaultProps(): TLFrameShape['props'] {
return { w: 160 * 2, h: 90 * 2, name: '', color: 'black' }
}
override getAriaDescriptor(shape: TLFrameShape) {
return shape.props.name
}
override getGeometry(shape: TLFrameShape): Geometry2d {
const { editor } = this
const z = editor.getEfficientZoomLevel()
// Which dimension measures the top edge after rotation?
const labelSide = getFrameHeadingSide(editor, shape)
const isVertical = labelSide % 2 === 1
const rotatedTopEdgeWidth = isVertical ? shape.props.h : shape.props.w
// Get the size of the heading (max width equal to the rotatedTopEdgeWidth)
const opts = getFrameHeadingOpts(rotatedTopEdgeWidth, false)
const headingSize = getFrameHeadingSize(editor, shape, opts)
// If NOT showing frame colors, we need to offset the label
// to the left so that the title is in line with the shape edge
// and add that extra width to the right side of the label
const isShowingFrameColors = this.options.showColors
// Scale everything into **screen space**
const extraWidth = FRAME_HEADING_EXTRA_WIDTH / z
const minWidth = FRAME_HEADING_MIN_WIDTH / z
const maxWidth = rotatedTopEdgeWidth + (isShowingFrameColors ? 1 : extraWidth)
const labelWidth = headingSize.w / z
const labelHeight = headingSize.h / z
const clampedLabelWidth = clamp(labelWidth + extraWidth, minWidth, maxWidth)
const offsetX = (isShowingFrameColors ? -1 : FRAME_HEADING_NOCOLORS_OFFSET_X) / z
const offsetY = FRAME_HEADING_OFFSET_Y / z
// In page space
const width = isVertical ? labelHeight : clampedLabelWidth
const height = isVertical ? clampedLabelWidth : labelHeight
// Calculate label position based on side. The position needs to always appear
// at the top left of the shape, regardless of rotation. The label must be
// between a minimum and maximum. The minimum is arbitrary; the maximum is the
// width of the edge of the frame where the label will be shown.
let x: number, y: number
switch (labelSide) {
case 0: {
// top
x = offsetX
y = -(labelHeight + offsetY)
break
}
case 1: {
// right
x = -(labelHeight + offsetY)
y = shape.props.h - (offsetX + clampedLabelWidth)
break
}
case 2: {
// bottom
x = shape.props.w - (offsetX + clampedLabelWidth)
y = shape.props.h + offsetY
break
}
case 3: {
// left
x = shape.props.w + offsetY
y = offsetX
break
}
}
return new Group2d({
children: [
new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: false,
}),
new Rectangle2d({
x,
y,
width,
height,
isFilled: true,
isLabel: true,
excludeFromShapeBounds: true,
}),
],
})
}
override getText(shape: TLFrameShape): string | undefined {
return shape.props.name
}
override component(shape: TLFrameShape) {
// eslint-disable-next-line react-hooks/rules-of-hooks
const colorMode = useColorMode()
const dv = getDisplayValues(this, shape, colorMode)
// eslint-disable-next-line react-hooks/rules-of-hooks
const isCreating = useValue(
'is creating this shape',
() => {
const resizingState = this.editor.getStateDescendant('select.resizing')
if (!resizingState) return false
if (!resizingState.getIsActive()) return false
const info = (resizingState as typeof resizingState & { info: { isCreating: boolean } })
?.info
if (!info) return false
return info.isCreating && this.editor.getOnlySelectedShapeId() === shape.id
},
[shape.id]
)
const showFrameColors = this.options.showColors
return (
<>
<SVGContainer>
<rect
className={classNames('tl-frame__body', { 'tl-frame__creating': isCreating })}
fill={showFrameColors ? dv.showColorsFillColor : dv.fillColor}
stroke={showFrameColors ? dv.showColorsStrokeColor : dv.strokeColor}
style={{
width: `calc(${shape.props.w}px + 1px / var(--tl-zoom))`,
height: `calc(${shape.props.h}px + 1px / var(--tl-zoom))`,
transform: `translate(calc(-0.5px / var(--tl-zoom)), calc(-0.5px / var(--tl-zoom)))`,
}}
/>
</SVGContainer>
{isCreating ? null : (
<FrameHeading
id={shape.id}
name={shape.props.name}
fill={showFrameColors ? dv.showColorsHeadingFillColor : dv.headingFillColor}
stroke={showFrameColors ? dv.showColorsHeadingStrokeColor : dv.headingStrokeColor}
color={showFrameColors ? dv.showColorsHeadingTextColor : dv.headingTextColor}
width={shape.props.w}
height={shape.props.h}
offsetX={showFrameColors ? -1 : -7}
showColors={this.options.showColors}
/>
)}
</>
)
}
override toSvg(shape: TLFrameShape, ctx: SvgExportContext) {
const dv = getDisplayValues(this, shape, ctx.colorMode)
// rotate right 45 deg
const labelSide = getFrameHeadingSide(this.editor, shape)
const isVertical = labelSide % 2 === 1
const rotatedTopEdgeWidth = isVertical ? shape.props.h : shape.props.w
const labelTranslate = getFrameHeadingTranslation(shape, labelSide, true)
// Truncate with ellipsis
const opts: TLCreateTextJsxFromSpansOpts = getFrameHeadingOpts(rotatedTopEdgeWidth - 12, true)
const frameTitle = defaultEmptyAs(shape.props.name, 'Frame') + String.fromCharCode(8203)
const labelBounds = getFrameHeadingSize(this.editor, shape, opts)
const spans = this.editor.textMeasure.measureTextSpans(frameTitle, opts)
const text = createTextJsxFromSpans(this.editor, spans, opts)
const showFrameColors = this.options.showColors
const frameHeadingStroke = showFrameColors
? dv.showColorsHeadingStrokeColor
: dv.headingStrokeColor
const frameHeadingFill = showFrameColors ? dv.showColorsHeadingFillColor : dv.headingFillColor
const frameHeadingText = showFrameColors ? dv.showColorsHeadingTextColor : dv.headingTextColor
return (
<>
<rect
width={shape.props.w}
height={shape.props.h}
fill={showFrameColors ? dv.showColorsFillColor : dv.fillColor}
stroke={showFrameColors ? dv.showColorsStrokeColor : dv.strokeColor}
strokeWidth={1}
x={0}
rx={0}
ry={0}
/>
<g fill={frameHeadingText} transform={labelTranslate}>
<rect
x={labelBounds.x - (showFrameColors ? 0 : 6)}
y={labelBounds.y - 6}
width={Math.min(rotatedTopEdgeWidth, labelBounds.width + 12)}
height={labelBounds.height}
fill={frameHeadingFill}
stroke={frameHeadingStroke}
rx={4}
ry={4}
/>
<g transform={`translate(${showFrameColors ? 8 : 0}, 4)`}>{text}</g>
</g>
</>
)
}
override getIndicatorPath(shape: TLFrameShape): Path2D {
const path = new Path2D()
path.rect(0, 0, shape.props.w, shape.props.h)
return path
}
override getInterpolatedProps(
startShape: TLFrameShape,
endShape: TLFrameShape,
t: number
): TLFrameShapeProps {
return {
...(t > 0.5 ? endShape.props : startShape.props),
w: lerp(startShape.props.w, endShape.props.w, t),
h: lerp(startShape.props.h, endShape.props.h, t),
}
}
override onDoubleClickEdge(shape: TLFrameShape, info: TLClickEventInfo) {
if (info.target !== 'selection') return
const { handle } = info
// If handle is missing, we can't determine which edge was clicked
if (!handle) return
const isHorizontalEdge = handle === 'left' || handle === 'right'
const isVerticalEdge = handle === 'top' || handle === 'bottom'
const childIds = this.editor.getSortedChildIdsForParent(shape.id)
const children = compact(childIds.map((id) => this.editor.getShape(id)))
if (!children.length) return
const { dx, dy, w, h } = getFrameChildrenBounds(children, this.editor, { padding: 10 })
this.editor.run(() => {
const changes: TLShapePartial[] = childIds.map((childId) => {
const childShape = this.editor.getShape(childId)!
return {
id: childShape.id,
type: childShape.type,
x: isHorizontalEdge ? childShape.x + dx : childShape.x,
y: isVerticalEdge ? childShape.y + dy : childShape.y,
}
})
this.editor.updateShapes(changes)
})
// The children were shifted by (dx, dy) in frame space; move the frame the opposite way
// (in its parent's space) so they keep their canvas positions, as fitFrameToContent does.
const diff = new Vec(isHorizontalEdge ? dx : 0, isVerticalEdge ? dy : 0).rot(shape.rotation)
return {
id: shape.id,
type: shape.type,
x: shape.x - diff.x,
y: shape.y - diff.y,
props: {
w: isHorizontalEdge ? w : shape.props.w,
h: isVerticalEdge ? h : shape.props.h,
},
}
}
override onDoubleClickCorner(shape: TLFrameShape) {
fitFrameToContent(this.editor, shape.id, { padding: 10 })
return {
id: shape.id,
type: shape.type,
}
}
}

View File

@@ -0,0 +1,80 @@
import { TLFrameShape, TLShapeId, useEditor, useIsEditing, useValue } from '@tldraw/editor'
import { memo, useEffect, useRef } from 'react'
import { getFrameHeadingSide, getFrameHeadingTranslation } from '../frameHelpers'
import { FrameLabelInput } from './FrameLabelInput'
export const FrameHeading = memo(function FrameHeading({
id,
name,
width,
height,
fill,
stroke,
color,
offsetX,
showColors,
}: {
id: TLShapeId
name: string
width: number
height: number
fill: string
stroke: string
color: string
offsetX: number
showColors: boolean
}) {
const editor = useEditor()
const { side, translation } = useValue(
'shape rotation',
() => {
const shape = editor.getShape<TLFrameShape>(id)
if (!shape) {
// meh
return {
side: 0,
translation: 'translate(0, 0)',
}
}
const labelSide = getFrameHeadingSide(editor, shape)
return {
side: labelSide,
translation: getFrameHeadingTranslation(shape, labelSide, false),
}
},
[editor, offsetX, id]
)
const rInput = useRef<HTMLInputElement>(null)
const isEditing = useIsEditing(id)
useEffect(() => {
const el = rInput.current
if (el && isEditing) {
// On iOS, we must focus here
el.focus()
el.select()
}
}, [rInput, isEditing])
return (
<div
className="tl-frame-heading"
style={{
overflow: isEditing ? 'visible' : 'hidden',
maxWidth: `calc(var(--tl-zoom) * ${
side === 0 || side === 2 ? Math.ceil(width) : Math.ceil(height)
}px + ${showColors ? '0px' : 'var(--tl-frame-offset-width)'})`,
bottom: '100%',
transform: `${translation} scale(min(var(--tl-scale), 3.5)) translateX(${offsetX}px)`,
}}
>
<div
className="tl-frame-heading-hit-area"
style={{ color, backgroundColor: fill, boxShadow: `inset 0px 0px 0px 1px ${stroke}` }}
>
<FrameLabelInput ref={rInput} id={id} name={name} isEditing={isEditing} />
</div>
</div>
)
})

View File

@@ -0,0 +1,114 @@
import { TLFrameShape, TLShapeId, useEditor, useValue } from '@tldraw/editor'
import { forwardRef, useCallback, useEffect, useRef } from 'react'
import { PORTRAIT_BREAKPOINT } from '../../../ui/constants'
import { useBreakpoint } from '../../../ui/context/breakpoints'
import { useTranslation } from '../../../ui/hooks/useTranslation/useTranslation'
import { defaultEmptyAs } from '../frameHelpers'
export const FrameLabelInput = forwardRef<
HTMLInputElement,
{ id: TLShapeId; name: string; isEditing: boolean }
>(({ id, name, isEditing }, ref) => {
const editor = useEditor()
const breakpoint = useBreakpoint()
const isCoarsePointer = useValue(
'isCoarsePointer',
() => editor.getInstanceState().isCoarsePointer,
[editor]
)
const shouldUseWindowPrompt = breakpoint < PORTRAIT_BREAKPOINT.TABLET_SM && isCoarsePointer
const promptOpen = useRef<boolean>(false)
const msg = useTranslation()
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if (isEditing) editor.markEventAsHandled(e)
},
[editor, isEditing]
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
// need to prevent the enter keydown making it's way up to the Idle state
// and sending us back into edit mode
editor.markEventAsHandled(e)
e.currentTarget.blur()
editor.setEditingShape(null)
}
},
[editor]
)
const renameFrame = useCallback(
(value: string) => {
const shape = editor.getShape<TLFrameShape>(id)
if (!shape) return
const name = shape.props.name
if (name === value) return
editor.updateShapes([
{
id,
type: 'frame',
props: { name: value },
},
])
},
[id, editor]
)
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
renameFrame(e.currentTarget.value)
},
[renameFrame]
)
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
renameFrame(e.currentTarget.value)
},
[renameFrame]
)
/* Mobile rename uses window.prompt */
useEffect(() => {
if (!isEditing) {
promptOpen.current = false
return
}
if (isEditing && shouldUseWindowPrompt && !promptOpen.current) {
promptOpen.current = true
const shape = editor.getShape<TLFrameShape>(id)
const currentName = shape?.props.name ?? ''
const newName = window.prompt(msg('action.rename'), currentName)
promptOpen.current = false
if (newName !== null) renameFrame(newName)
editor.setEditingShape(null)
}
}, [isEditing, shouldUseWindowPrompt, id, msg, renameFrame, editor])
return (
<div
className={`tl-frame-label ${isEditing && !shouldUseWindowPrompt ? 'tl-frame-label__editing' : ''}`}
>
<input
className="tl-frame-name-input"
ref={ref}
disabled={!isEditing || shouldUseWindowPrompt}
readOnly={!isEditing || shouldUseWindowPrompt}
style={{ display: isEditing ? undefined : 'none' }}
value={name}
autoFocus={!shouldUseWindowPrompt}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onChange={handleChange}
onPointerDown={handlePointerDown}
draggable={false}
/>
{defaultEmptyAs(name, 'Frame') + String.fromCharCode(8203)}
</div>
)
})

View File

@@ -0,0 +1,121 @@
import {
Box,
Editor,
TLFrameShape,
canonicalizeRotation,
last,
toDomPrecision,
} from '@tldraw/editor'
import { TLCreateTextJsxFromSpansOpts } from '../shared/createTextJsxFromSpans'
export function defaultEmptyAs(str: string, dflt: string) {
if (str.match(/^\s*$/)) {
return dflt
}
return str
}
export function getFrameHeadingSide(editor: Editor, shape: TLFrameShape): 0 | 1 | 2 | 3 {
const pageRotation = canonicalizeRotation(editor.getShapePageTransform(shape.id)!.rotation())
const offsetRotation = pageRotation + Math.PI / 4
const scaledRotation = (offsetRotation * (2 / Math.PI) + 4) % 4
return Math.floor(scaledRotation) as 0 | 1 | 2 | 3
}
/**
* We use a weak map here to prevent re-measuring the text width of frames that haven't changed their names.
* It's only really important for performance reasons while zooming in and out. The measured text size is
* independent of the zoom level, so we can cache the expensive part (measurement) and apply those changes
* using the zoom level.
*/
const measurementWeakmap = new WeakMap()
/**
* Get the frame heading info (size and text) for a frame shape.
*
* @param editor The editor instance.
* @param shape The frame shape.
* @param opts The text measurement options.
*
* @returns The frame heading's size (as a Box) and JSX text spans.
*/
export function getFrameHeadingSize(
editor: Editor,
shape: TLFrameShape,
opts: TLCreateTextJsxFromSpansOpts
) {
if (process.env.NODE_ENV === 'test') {
// can't really measure text in tests
return new Box(0, -opts.height, shape.props.w, opts.height)
}
let width = measurementWeakmap.get(shape.props)
if (!width) {
const frameTitle = defaultEmptyAs(shape.props.name, 'Frame') + String.fromCharCode(8203)
const spans = editor.textMeasure.measureTextSpans(frameTitle, opts)
const firstSpan = spans[0]
const lastSpan = last(spans)
if (!firstSpan || !lastSpan) {
// measureTextSpans can return no spans when the text can't be laid
// out yet — e.g. the measurement element isn't attached/visible
// during the initial editor mount, or every grapheme lacks a layout
// rect. Fall back to a zero-width heading and don't cache it, so the
// heading is remeasured once measurement is possible.
return new Box(0, -opts.height, 0, opts.height)
}
width = lastSpan.box.w + lastSpan.box.x - firstSpan.box.x
measurementWeakmap.set(shape.props, width)
}
return new Box(0, -opts.height, width, opts.height)
}
export function getFrameHeadingOpts(width: number, isSvg: boolean): TLCreateTextJsxFromSpansOpts {
return {
fontSize: 12,
fontFamily: isSvg ? 'Arial' : 'Inter, sans-serif',
textAlign: 'start' as const,
width: width,
height: 24, // --frame-height
padding: 0,
lineHeight: 1,
fontStyle: 'normal',
fontWeight: 'normal',
overflow: 'truncate-ellipsis' as const,
verticalTextAlign: 'middle' as const,
offsetY: -(32 + 2), // --frame-minimum-height + (border width * 2)
offsetX: 0,
}
}
export function getFrameHeadingTranslation(
shape: TLFrameShape,
side: 0 | 1 | 2 | 3,
isSvg: boolean
) {
const u = isSvg ? '' : 'px'
const r = isSvg ? '' : 'deg'
let labelTranslate: string
switch (side) {
case 0: // top
labelTranslate = ``
break
case 3: // right
labelTranslate = `translate(${toDomPrecision(shape.props.w)}${u}, 0${u}) rotate(90${r})`
break
case 2: // bottom
labelTranslate = `translate(${toDomPrecision(shape.props.w)}${u}, ${toDomPrecision(
shape.props.h
)}${u}) rotate(180${r})`
break
case 1: // left
labelTranslate = `translate(0${u}, ${toDomPrecision(shape.props.h)}${u}) rotate(270${r})`
break
default:
throw Error('labelSide out of bounds')
}
return labelTranslate
}

View File

@@ -0,0 +1,55 @@
import { TLGeoShape } from '@tldraw/editor'
import { PatternFill } from '../shared/PatternFill'
import { GeoTypeDefinition, getGeoShapePath } from './getGeoShapePath'
export function GeoShapeBody({
shape,
shouldScale,
forceSolid,
strokeColor,
strokeWidth: unscaledStrokeWidth,
fillColor,
patternFillFallbackColor,
customGeoTypes,
}: {
shape: TLGeoShape
shouldScale: boolean
forceSolid: boolean
strokeColor: string
strokeWidth: number
fillColor: string
patternFillFallbackColor: string
customGeoTypes?: Record<string, GeoTypeDefinition>
}) {
const scaleToUse = shouldScale ? shape.props.scale : 1
const strokeWidth = unscaledStrokeWidth * scaleToUse
const { dash, fill } = shape.props
const path = getGeoShapePath(shape, unscaledStrokeWidth, customGeoTypes)
const fillPath =
dash === 'draw' && !forceSolid
? path.toDrawD({ strokeWidth, randomSeed: shape.id, passes: 1, offset: 0, onlyFilled: true })
: path.toD({ onlyFilled: true })
return (
<>
{fill === 'none' ? null : fill === 'pattern' ? (
<PatternFill
d={fillPath}
fillColor={fillColor}
patternFillFallbackColor={patternFillFallbackColor}
scale={scaleToUse}
/>
) : (
<path fill={fillColor} d={fillPath} />
)}
{path.toSvg({
style: dash,
strokeWidth,
forceSolid,
randomSeed: shape.id,
props: { fill: 'none', stroke: strokeColor },
})}
</>
)
}

View File

@@ -0,0 +1,268 @@
import { TestEditor } from '../../../test/TestEditor'
import { GeoShapeTool } from './GeoShapeTool'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
})
afterEach(() => {
editor?.dispose()
})
describe(GeoShapeTool, () => {
it('Creates geo shapes on click-and-drag, supports undo and redo', () => {
expect(editor.getCurrentPageShapes().length).toBe(0)
editor.setCurrentTool('geo')
editor.pointerDown(50, 50)
editor.pointerMove(100, 100)
editor.pointerUp()
expect(editor.getCurrentPageShapes().length).toBe(1)
expect(editor.getCurrentPageShapes()[0]?.type).toBe('geo')
expect(editor.getSelectedShapeIds()[0]).toBe(editor.getCurrentPageShapes()[0]?.id)
editor.undo()
expect(editor.getCurrentPageShapes().length).toBe(0)
editor.redo()
expect(editor.getCurrentPageShapes().length).toBe(1)
})
it('Creates geo shapes on click, supports undo and redo', () => {
expect(editor.getCurrentPageShapes().length).toBe(0)
editor.setCurrentTool('geo')
editor.pointerDown(50, 50)
editor.pointerUp(50, 50)
expect(editor.getCurrentPageShapes().length).toBe(1)
expect(editor.getCurrentPageShapes()[0]?.type).toBe('geo')
expect(editor.getSelectedShapeIds()[0]).toBe(editor.getCurrentPageShapes()[0]?.id)
editor.undo()
expect(editor.getCurrentPageShapes().length).toBe(0)
editor.redo()
expect(editor.getCurrentPageShapes().length).toBe(1)
})
it('Creates geo shapes when scaled', () => {
editor.setCurrentTool('geo')
editor.pointerMove(50, 50)
editor.pointerDown()
editor.pointerMove(100, 100)
editor.pointerUp()
expect(editor.getLastCreatedShape()).toMatchObject({
props: {
w: 50,
h: 50,
scale: 1,
},
})
editor.user.updateUserPreferences({ isDynamicSizeMode: true })
editor.zoomIn() // 1 -> 2
editor.setCurrentTool('geo')
editor.pointerMove(50, 50)
editor.pointerDown()
editor.pointerMove(100, 100)
editor.pointerUp()
expect(editor.getLastCreatedShape()).toMatchObject({
props: {
w: 25,
h: 25,
scale: 0.5,
},
})
editor.zoomOut().zoomOut() // 2 -> 1 -> .5
editor.setCurrentTool('geo')
editor.pointerMove(50, 50)
editor.pointerDown()
editor.pointerMove(100, 100)
editor.pointerUp()
expect(editor.getLastCreatedShape()).toMatchObject({
props: {
w: 100,
h: 100,
scale: 2,
},
})
})
})
describe('When selecting the tool', () => {
it('selects the tool and enters the idle state', () => {
editor.setCurrentTool('geo')
editor.expectToBeIn('geo.idle')
})
})
describe('When in the idle state', () => {
it('Enters pointing state on pointer down', () => {
editor.setCurrentTool('geo')
editor.pointerDown(100, 100)
editor.expectToBeIn('geo.pointing')
})
it('Switches back to select tool on cancel', () => {
editor.setCurrentTool('geo')
editor.cancel()
editor.expectToBeIn('select.idle')
})
it('Does nothing on interrupt', () => {
editor.setCurrentTool('geo')
editor.interrupt()
editor.expectToBeIn('geo.idle')
})
it('Enters edit shape state on "Enter" key up when we have one geo shape', () => {
editor.setCurrentTool('geo')
editor.pointerDown(50, 50)
editor.pointerMove(100, 100)
editor.pointerUp(100, 100)
editor.keyPress('Enter')
editor.expectToBeIn('select.editing_shape')
})
it('Does not enter edit shape state on "Enter" key up when multiple geo shapes are selected', () => {
editor.setCurrentTool('geo')
editor.pointerDown(50, 50)
editor.pointerMove(100, 100)
editor.pointerUp(100, 100)
editor.setCurrentTool('geo')
editor.pointerDown(150, 150)
editor.pointerMove(200, 200)
editor.pointerUp(200, 200)
expect(editor.getCurrentPageShapes().length).toBe(2)
editor.selectAll()
expect(editor.getSelectedShapes().length).toBe(2)
editor.keyUp('Enter')
editor.expectToBeIn('select.idle')
})
})
describe('When in the pointing state', () => {
it('Switches back to idle on cancel', () => {
editor.setCurrentTool('geo')
editor.pointerDown(50, 50)
editor.expectToBeIn('geo.pointing')
editor.cancel()
editor.expectToBeIn('geo.idle')
})
it('Enters the select.resizing state on drag start', () => {
editor.setCurrentTool('geo')
editor.pointerDown(50, 50)
editor.pointerMove(51, 51) // not far enough!
editor.expectToBeIn('geo.pointing')
editor.pointerMove(55, 55)
editor.expectToBeIn('select.resizing')
})
it('Enters the select.resizing state on pointer move', () => {
editor.setCurrentTool('geo')
editor.pointerDown(50, 50)
editor.cancel()
editor.expectToBeIn('geo.idle')
})
it('Returns to the idle state on interrupt', () => {
editor.setCurrentTool('geo')
editor.pointerDown(50, 50)
editor.interrupt()
editor.expectToBeIn('geo.idle')
})
it('Creates a geo and returns to select tool on pointer up', () => {
expect(editor.getCurrentPageShapes().length).toBe(0)
editor.setCurrentTool('geo')
editor.pointerDown(50, 50)
editor.pointerUp(50, 50)
editor.expectToBeIn('select.idle')
expect(editor.getCurrentPageShapes().length).toBe(1)
})
it('Creates a geo and returns to geo.idle on pointer up if tool lock is enabled', () => {
editor.updateInstanceState({ isToolLocked: true })
expect(editor.getCurrentPageShapes().length).toBe(0)
editor.setCurrentTool('geo')
editor.pointerDown(50, 50)
editor.pointerUp(50, 50)
editor.expectToBeIn('geo.idle')
expect(editor.getCurrentPageShapes().length).toBe(1)
})
it('Leaves nothing behind on complete before the pointer is released', () => {
editor.setCurrentTool('geo')
editor.pointerDown(50, 50)
editor.expectToBeIn('geo.pointing')
editor.complete()
editor.expectToBeIn('geo.idle')
expect(editor.getCurrentPageShapes().length).toBe(0)
})
it('Does not create a geo when undo is pressed mid-press', () => {
editor.setCurrentTool('geo')
editor.pointerDown(50, 50)
editor.undo()
editor.expectToBeIn('geo.idle')
expect(editor.getCurrentPageShapes().length).toBe(0)
})
})
describe('When in the resizing state while creating a geo shape', () => {
it('Returns to geo on cancel', () => {
editor.setCurrentTool('geo')
editor.pointerDown(50, 50)
editor.pointerMove(55, 55)
editor.expectToBeIn('select.resizing')
editor.cancel()
editor.expectToBeIn('geo.idle')
})
it('Returns to select.idle on complete', () => {
editor.setCurrentTool('geo')
editor.pointerDown(50, 50)
editor.pointerMove(100, 100)
editor.pointerUp(100, 100)
editor.expectToBeIn('select.idle')
})
it('Removes the pending geo shape when another tool is selected mid-drag', () => {
editor.setCurrentTool('geo')
editor.pointerDown(50, 50)
editor.pointerMove(100, 100)
editor.expectToBeIn('select.resizing')
expect(editor.getCurrentPageShapes().length).toBe(1)
editor.setCurrentTool('draw')
editor.expectToBeIn('draw.idle')
expect(editor.getCurrentPageShapes().length).toBe(0)
})
it('Returns to geo.idle on complete if tool lock is enabled', () => {
editor.updateInstanceState({ isToolLocked: true })
editor.setCurrentTool('geo')
editor.pointerDown(50, 50)
editor.pointerMove(100, 100)
editor.pointerUp(100, 100)
editor.expectToBeIn('geo.idle')
})
})

View File

@@ -0,0 +1,13 @@
import { StateNode, TLStateNodeConstructor } from '@tldraw/editor'
import { Idle } from './toolStates/Idle'
import { Pointing } from './toolStates/Pointing'
/** @public */
export class GeoShapeTool extends StateNode {
static override id = 'geo'
static override initial = 'idle'
static override children(): TLStateNodeConstructor[] {
return [Idle, Pointing]
}
override shapeType = 'geo'
}

View File

@@ -0,0 +1,418 @@
import {
GeoShapeGeoStyle,
Group2d,
IndexKey,
TLGeoShape,
TLShapeId,
createShapeId,
toRichText,
} from '@tldraw/editor'
import { vi } from 'vitest'
import { TestEditor } from '../../../test/TestEditor'
import { PathBuilder } from '../shared/PathBuilder'
import { GeoShapeUtil } from './GeoShapeUtil'
let editor: TestEditor
let ids: Record<string, TLShapeId>
beforeEach(() => {
editor = new TestEditor()
})
afterEach(() => {
editor?.dispose()
})
describe('Handle snapping', () => {
beforeEach(() => {
ids = {
geo: createShapeId('geo'),
line: createShapeId('line'),
}
editor.createShapes([
{ id: ids.geo, type: 'geo', x: 0, y: 0, props: { geo: 'rectangle', w: 100, h: 100 } },
{
id: ids.line,
type: 'line',
x: 0,
y: 0,
props: {
points: {
a1: { id: 'a1', index: 'a1' as IndexKey, x: 200, y: 0 },
a2: { id: 'a2', index: 'a2' as IndexKey, x: 200, y: 100 },
},
},
},
])
})
const geoShape = () => {
const shape = editor.getShape(ids.geo)!
assert(editor.isShapeOfType(shape, 'geo'))
return shape
}
const lineShape = () => {
const shape = editor.getShape(ids.line)!
assert(editor.isShapeOfType(shape, 'line'))
return shape
}
const lineHandles = () => editor.getShapeUtil('line').getHandles!(lineShape())!
function startDraggingHandle() {
editor
.select(ids.line)
.pointerDown(200, 0, { target: 'handle', shape: lineShape(), handle: lineHandles()[0] })
}
test('handles snap to the edges of the shape', () => {
startDraggingHandle()
editor.pointerMove(50, 5, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(lineHandles()[0]).toMatchObject({ x: 50, y: 0 })
})
test('handles snap to the corner of the shape', () => {
startDraggingHandle()
editor.pointerMove(0, 5, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(lineHandles()[0]).toMatchObject({ x: 0, y: 0 })
})
test('handles snap to the center of the shape', () => {
startDraggingHandle()
editor.pointerMove(51, 45, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(lineHandles()[0]).toMatchObject({ x: 50, y: 50 })
})
test('does not snap to the label of the shape', () => {
startDraggingHandle()
const geometry = editor.getShapeUtil('geo').getGeometry(geoShape()) as Group2d
const label = geometry.children.find((c) => c.isLabel)!
const labelVertex = label.vertices[0]
editor.pointerMove(labelVertex.x + 2, labelVertex.y + 2, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(0)
expect(lineHandles()[0]).toMatchObject({ x: labelVertex.x + 2, y: labelVertex.y + 2 })
})
})
describe('Resizing geo shapes with labels', () => {
const geoId = createShapeId('geo')
function getGeo() {
return editor.getShape<TLGeoShape>(geoId)!
}
test('can be resized narrower than the initial label width', () => {
// Create a wide shape with a short label
editor.createShapes([
{
id: geoId,
type: 'geo',
props: { w: 300, h: 100, richText: toRichText('Hi'), geo: 'rectangle' },
},
])
const initialW = getGeo().props.w
// Resize to half width
editor.resizeShape(geoId, { x: 0.5, y: 1 })
// Should be narrower than initial — not locked to the initial label measurement width
const afterW = getGeo().props.w
expect(afterW).toBeLessThan(initialW)
})
test('height grows to accommodate wrapped text when resized narrower', () => {
// Create a shape where the label fits without wrapping.
// "Hello World" (11 chars) at medium size has raw text width = 121,
// so min label width = 153. At initial w=300 the text is one line.
editor.createShapes([
{
id: geoId,
type: 'geo',
props: { w: 300, h: 100, richText: toRichText('Hello World'), geo: 'rectangle' },
},
])
// Resize to a narrow width AND short height — text must wrap at the narrower width,
// and the height should grow to fit the taller wrapped text.
editor.resizeShape(geoId, { x: 0.2, y: 0.3 })
const geo = getGeo()
// The height should be greater than the naive 100 * 0.3 = 30 target,
// because the text wraps at the narrower width and needs more vertical space.
expect(geo.props.h).toBeGreaterThan(30)
})
test('width is constrained to at least the minimum label width', () => {
// "Hello World" has a minimum label width of 153 (text raw width 121 + padding 32)
editor.createShapes([
{
id: geoId,
type: 'geo',
props: { w: 300, h: 100, richText: toRichText('Hello World'), geo: 'rectangle' },
},
])
// Try to resize to something very small
editor.resizeShape(geoId, { x: 0.1, y: 1 })
const geo = getGeo()
// The width should be at least MIN_SIZE_WITH_LABEL (51) — the text's minimum width
// is actually larger (153), so the shape should be constrained to the label width
expect(geo.props.w).toBeGreaterThanOrEqual(51)
// And specifically, it should be at least the label's minimum width
expect(geo.props.w).toBeGreaterThan(100)
})
test('shape without label can be resized freely', () => {
editor.createShapes([
{
id: geoId,
type: 'geo',
props: { w: 300, h: 200, geo: 'rectangle' },
},
])
editor.resizeShape(geoId, { x: 0.1, y: 0.1 })
const geo = getGeo()
expect(geo.props.w).toBeLessThan(50)
expect(geo.props.h).toBeLessThan(50)
})
describe.each([1, 2, 0.5])('when the label stops the shrink at scale %s', (scale) => {
const w = 300 * scale
const h = 100 * scale
beforeEach(() => {
editor.createShapes([
{
id: geoId,
type: 'geo',
props: { w, h, scale, richText: toRichText('Hello World'), geo: 'rectangle' },
},
])
editor.select(geoId)
})
function getEdges() {
const { minX, minY, maxX, maxY } = editor.getShapePageBounds(geoId)!
return { minX, minY, maxX, maxY }
}
test('dragging the top-left handle keeps the bottom-right corner fixed', () => {
const before = getEdges()
editor
.pointerDown(0, 0, { target: 'selection', handle: 'top_left' })
.pointerMove(w - 10 * scale, h - 2 * scale)
.pointerUp()
const after = getEdges()
// the label must have stopped the shrink, or the corner check proves nothing
expect(after.maxX - after.minX).toBeGreaterThan(10 * scale)
expect(after.maxY - after.minY).toBeGreaterThan(2 * scale)
expect({ maxX: after.maxX, maxY: after.maxY }).toEqual({
maxX: before.maxX,
maxY: before.maxY,
})
})
test('flipping over the right edge with the left handle keeps that edge fixed', () => {
const before = getEdges()
editor
.pointerDown(0, h / 2, { target: 'selection', handle: 'left' })
.pointerMove(w + 10 * scale, h / 2)
.pointerUp()
expect(getGeo().props.flipX).toBe(true)
expect(getEdges().minX).toBe(before.maxX)
})
})
})
describe('Geo shapes with programmatically-authored empty rich text', () => {
const geoId = createShapeId('geo')
function getGeo() {
return editor.getShape<TLGeoShape>(geoId)!
}
// An empty paragraph can be encoded two equally-valid ways. The interactive
// editor emits the first (no `content` key); programmatic authoring (snapshot
// load, agents emitting tldraw JSON) commonly emits the second (`content: []`).
// Both mean "no label", so neither should grow the shape to fit a phantom line.
const emptyForms = [
{
label: 'paragraph with no content key (toRichText output)',
richText: toRichText(''),
},
{
label: 'paragraph with empty content array',
richText: {
type: 'doc',
content: [{ type: 'paragraph', attrs: { dir: 'auto' }, content: [] }],
} as TLGeoShape['props']['richText'],
},
{
label: 'doc with empty content array',
richText: { type: 'doc', content: [] } as TLGeoShape['props']['richText'],
},
]
for (const { label, richText } of emptyForms) {
test(`does not grow height for an empty label: ${label}`, () => {
editor.createShapes([
{
id: geoId,
type: 'geo',
props: { w: 110, h: 24, geo: 'rectangle', size: 's', richText },
},
])
const geo = getGeo()
expect(geo.props.h).toBe(24)
expect(geo.props.growY).toBe(0)
})
}
})
describe('Flipping geo shapes', () => {
const geoId = createShapeId('geo')
function getGeo() {
return editor.getShape<TLGeoShape>(geoId)!
}
beforeEach(() => {
editor.createShapes([
{ id: geoId, type: 'geo', x: 0, y: 0, props: { geo: 'arrow-left', w: 100, h: 80 } },
])
})
test('flip command toggles flipX / flipY like the image shape', () => {
expect(getGeo().props.flipX).toBe(false)
editor.flipShapes([geoId], 'horizontal')
expect(getGeo().props).toMatchObject({ flipX: true, flipY: false })
editor.flipShapes([geoId], 'vertical')
expect(getGeo().props).toMatchObject({ flipX: true, flipY: true })
// flipping again on the same axis restores the original state
editor.flipShapes([geoId], 'horizontal')
expect(getGeo().props).toMatchObject({ flipX: false, flipY: true })
})
test('flipping a lone geo shape keeps its position and size', () => {
const before = getGeo()
editor.flipShapes([geoId], 'horizontal')
const after = getGeo()
expect(after).toMatchObject({
x: before.x,
y: before.y,
props: { w: before.props.w, h: before.props.h, flipX: true },
})
})
test('mirrors the geometry when flipped, keeping the bounds unchanged', () => {
const util = editor.getShapeUtil('geo')
const outlineBefore = (util.getGeometry(getGeo()) as Group2d).children[0]
const vertsBefore = outlineBefore.vertices.map((v) => ({ x: v.x, y: v.y }))
editor.updateShape({ id: geoId, type: 'geo', props: { flipX: true } })
const outlineAfter = (util.getGeometry(getGeo()) as Group2d).children[0]
// bounds are unchanged: we mirror geometry within the shape's box, not its w/h
expect(outlineAfter.bounds.width).toBeCloseTo(outlineBefore.bounds.width)
expect(outlineAfter.bounds.height).toBeCloseTo(outlineBefore.bounds.height)
// every vertex is mirrored around the horizontal center (w = 100)
const vertsAfter = outlineAfter.vertices
expect(vertsAfter).toHaveLength(vertsBefore.length)
for (const v of vertsBefore) {
const mirrored = { x: 100 - v.x, y: v.y }
expect(
vertsAfter.some(
(a) => Math.abs(a.x - mirrored.x) < 0.01 && Math.abs(a.y - mirrored.y) < 0.01
)
).toBe(true)
}
})
})
describe('GeoShapeUtil.configure with customGeoTypes', () => {
// Snapshot the built-in geo values so we can clean up any custom keys added
// during these tests. `GeoShapeUtil.configure({ customGeoTypes })` mutates
// `GeoShapeGeoStyle.values` globally via `addValues`, so without cleanup the
// state would leak between tests in this describe block.
const builtinGeoValues = [...GeoShapeGeoStyle.values]
afterEach(() => {
const toRemove = GeoShapeGeoStyle.values.filter((v) => !builtinGeoValues.includes(v))
if (toRemove.length > 0) {
GeoShapeGeoStyle.removeValues(...toRemove)
}
})
const validDef = {
getPath: (w: number, h: number) =>
new PathBuilder()
.moveTo(0, 0, { geometry: { isFilled: true } })
.lineTo(w, 0)
.lineTo(w, h)
.lineTo(0, h)
.close(),
snapType: 'polygon' as const,
icon: 'geo-rectangle',
defaultSize: { w: 999, h: 999 },
}
function getConfiguredOptions(customGeoTypes: Record<string, typeof validDef>) {
const Configured = GeoShapeUtil.configure({ customGeoTypes })
const localEditor = new TestEditor({ shapeUtils: [Configured] })
try {
const util = localEditor.getShapeUtil('geo') as GeoShapeUtil
return util.options.customGeoTypes
} finally {
localEditor.dispose()
}
}
test('keeps non-colliding entries in options.customGeoTypes', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
expect(getConfiguredOptions({ 'my-shape': validDef })).toEqual({ 'my-shape': validDef })
expect(warn).not.toHaveBeenCalled()
} finally {
warn.mockRestore()
}
})
test('strips colliding keys from options so runtime lookups do not see them', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
expect(getConfiguredOptions({ rectangle: validDef, 'my-shape': validDef })).toEqual({
'my-shape': validDef,
})
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/customGeoTypes key "rectangle"/))
} finally {
warn.mockRestore()
}
})
test('reusing the same custom key across configure() calls still keeps the entry', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
expect(getConfiguredOptions({ 'my-shape': validDef })).toEqual({ 'my-shape': validDef })
// A second configure() call with the same key should not treat it as a
// collision with built-ins, so the entry is preserved.
expect(getConfiguredOptions({ 'my-shape': validDef })).toEqual({ 'my-shape': validDef })
expect(warn).not.toHaveBeenCalled()
} finally {
warn.mockRestore()
}
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,719 @@
import {
centerOfCircleFromThreePoints,
clamp,
getPointOnCircle,
getPolygonVertices,
HALF_PI,
PI,
PI2,
rng,
TLDefaultDashStyle,
TLDefaultSizeStyle,
TLGeoShape,
Vec,
VecModel,
WeakCache,
} from '@tldraw/editor'
import { getFlipMatrix } from '../shared/flip'
import { PathBuilder } from '../shared/PathBuilder'
/**
* Defines the behavior for a geo shape type. Every built-in geo type is
* registered through this same interface (see {@link defaultGeoTypeDefinitions}),
* and consumers can register additional types via
* {@link @tldraw/tldraw#GeoShapeUtil.configure | GeoShapeUtil.configure()}.
*
* @public
*/
export interface GeoTypeDefinition {
/**
* Generate the path geometry for this geo type.
*
* @param w - The width of the shape (already clamped to min 1)
* @param h - The height of the shape (already clamped to min 1, includes growY)
* @param shape - The full geo shape record, for access to props like id, dash, fill, etc.
* @param strokeWidth - The scaled stroke width (strokeWidth * scale)
*/
getPath(w: number, h: number, shape: TLGeoShape, strokeWidth: number): PathBuilder
/** Snap behavior: 'polygon' snaps to vertices + center, 'blobby' snaps to center only. */
snapType: 'polygon' | 'blobby'
/** Default creation size when clicking (not dragging). Defaults to 200x200. */
defaultSize?: { w: number; h: number }
/** Icon name for the style panel geo picker. */
icon: string
/**
* Optional double-click handler. Return an object with partial props to update the shape,
* or void to do nothing.
*/
onDoubleClick?(shape: TLGeoShape): { props: Partial<TLGeoShape['props']> } | void
}
/**
* Built-in geo type definitions keyed by their `geo` prop value. Every default
* geo type (rectangle, ellipse, cloud, etc.) is registered here. The same
* registry powers path generation, handle snapping, the style panel picker,
* and creation defaults — so custom types added through
* {@link @tldraw/tldraw#GeoShapeUtil.configure | GeoShapeUtil.configure()} get
* the same treatment as the built-ins.
*
* The key order here defines the visual order of items in the geo style panel
* picker.
*
* @public
*/
export const defaultGeoTypeDefinitions = {
rectangle: {
snapType: 'polygon',
icon: 'geo-rectangle',
getPath(w, h, shape) {
const isFilled = shape.props.fill !== 'none'
return new PathBuilder()
.moveTo(0, 0, { geometry: { isFilled } })
.lineTo(w, 0)
.lineTo(w, h)
.lineTo(0, h)
.close()
},
},
ellipse: {
snapType: 'blobby',
icon: 'geo-ellipse',
getPath(w, h, shape) {
const isFilled = shape.props.fill !== 'none'
const cx = w / 2
const cy = h / 2
return new PathBuilder()
.moveTo(0, cy, { geometry: { isFilled } })
.arcTo(cx, cy, false, true, 0, w, cy)
.arcTo(cx, cy, false, true, 0, 0, cy)
.close()
},
},
triangle: {
snapType: 'polygon',
icon: 'geo-triangle',
getPath(w, h, shape) {
const isFilled = shape.props.fill !== 'none'
const cx = w / 2
return new PathBuilder()
.moveTo(cx, 0, { geometry: { isFilled } })
.lineTo(w, h)
.lineTo(0, h)
.close()
},
},
diamond: {
snapType: 'polygon',
icon: 'geo-diamond',
getPath(w, h, shape) {
const isFilled = shape.props.fill !== 'none'
const cx = w / 2
const cy = h / 2
return new PathBuilder()
.moveTo(cx, 0, { geometry: { isFilled } })
.lineTo(w, cy)
.lineTo(cx, h)
.lineTo(0, cy)
.close()
},
},
star: {
snapType: 'polygon',
icon: 'geo-star',
defaultSize: { w: 200, h: 190 },
getPath(w, h, shape) {
const isFilled = shape.props.fill !== 'none'
return getStarPath(w, h, isFilled)
},
},
pentagon: {
snapType: 'polygon',
icon: 'geo-pentagon',
getPath(w, h, shape) {
const isFilled = shape.props.fill !== 'none'
return PathBuilder.lineThroughPoints(getPolygonVertices(w, h, 5), {
geometry: { isFilled },
}).close()
},
},
hexagon: {
snapType: 'polygon',
icon: 'geo-hexagon',
getPath(w, h, shape) {
const isFilled = shape.props.fill !== 'none'
return PathBuilder.lineThroughPoints(getPolygonVertices(w, h, 6), {
geometry: { isFilled },
}).close()
},
},
octagon: {
snapType: 'polygon',
icon: 'geo-octagon',
getPath(w, h, shape) {
const isFilled = shape.props.fill !== 'none'
return PathBuilder.lineThroughPoints(getPolygonVertices(w, h, 8), {
geometry: { isFilled },
}).close()
},
},
rhombus: {
snapType: 'polygon',
icon: 'geo-rhombus',
getPath(w, h, shape) {
const isFilled = shape.props.fill !== 'none'
const offset = Math.min(w * 0.38, h * 0.38)
return new PathBuilder()
.moveTo(offset, 0, { geometry: { isFilled } })
.lineTo(w, 0)
.lineTo(w - offset, h)
.lineTo(0, h)
.close()
},
},
'rhombus-2': {
snapType: 'polygon',
icon: 'geo-rhombus-2',
getPath(w, h, shape) {
const isFilled = shape.props.fill !== 'none'
const offset = Math.min(w * 0.38, h * 0.38)
return new PathBuilder()
.moveTo(0, 0, { geometry: { isFilled } })
.lineTo(w - offset, 0)
.lineTo(w, h)
.lineTo(offset, h)
.close()
},
},
oval: {
snapType: 'blobby',
icon: 'geo-oval',
getPath(w, h, shape) {
const isFilled = shape.props.fill !== 'none'
return getStadiumPath(w, h, isFilled)
},
},
trapezoid: {
snapType: 'polygon',
icon: 'geo-trapezoid',
getPath(w, h, shape) {
const isFilled = shape.props.fill !== 'none'
const offset = Math.min(w * 0.38, h * 0.38)
return new PathBuilder()
.moveTo(offset, 0, { geometry: { isFilled } })
.lineTo(w - offset, 0)
.lineTo(w, h)
.lineTo(0, h)
.close()
},
},
'arrow-left': {
snapType: 'polygon',
icon: 'geo-arrow-left',
getPath(w, h, shape) {
const isFilled = shape.props.fill !== 'none'
const ox = Math.min(w, h) * 0.38
const oy = h * 0.16
return new PathBuilder()
.moveTo(ox, 0, { geometry: { isFilled } })
.lineTo(ox, oy)
.lineTo(w, oy)
.lineTo(w, h - oy)
.lineTo(ox, h - oy)
.lineTo(ox, h)
.lineTo(0, h / 2)
.close()
},
},
'arrow-up': {
snapType: 'polygon',
icon: 'geo-arrow-up',
getPath(w, h, shape) {
const isFilled = shape.props.fill !== 'none'
const ox = w * 0.16
const oy = Math.min(w, h) * 0.38
return new PathBuilder()
.moveTo(w / 2, 0, { geometry: { isFilled } })
.lineTo(w, oy)
.lineTo(w - ox, oy)
.lineTo(w - ox, h)
.lineTo(ox, h)
.lineTo(ox, oy)
.lineTo(0, oy)
.close()
},
},
'arrow-down': {
snapType: 'polygon',
icon: 'geo-arrow-down',
getPath(w, h, shape) {
const isFilled = shape.props.fill !== 'none'
const ox = w * 0.16
const oy = Math.min(w, h) * 0.38
return new PathBuilder()
.moveTo(ox, 0, { geometry: { isFilled } })
.lineTo(w - ox, 0)
.lineTo(w - ox, h - oy)
.lineTo(w, h - oy)
.lineTo(w / 2, h)
.lineTo(0, h - oy)
.lineTo(ox, h - oy)
.close()
},
},
'arrow-right': {
snapType: 'polygon',
icon: 'geo-arrow-right',
getPath(w, h, shape) {
const isFilled = shape.props.fill !== 'none'
const ox = Math.min(w, h) * 0.38
const oy = h * 0.16
return new PathBuilder()
.moveTo(0, oy, { geometry: { isFilled } })
.lineTo(w - ox, oy)
.lineTo(w - ox, 0)
.lineTo(w, h / 2)
.lineTo(w - ox, h)
.lineTo(w - ox, h - oy)
.lineTo(0, h - oy)
.close()
},
},
cloud: {
snapType: 'blobby',
icon: 'geo-cloud',
defaultSize: { w: 300, h: 180 },
getPath(w, h, shape) {
const isFilled = shape.props.fill !== 'none'
return getCloudPath(w, h, shape.id, shape.props.size, shape.props.scale, isFilled)
},
},
'x-box': {
snapType: 'polygon',
icon: 'geo-x-box',
getPath(w, h, shape, strokeWidth) {
const isFilled = shape.props.fill !== 'none'
return getXBoxPath(w, h, strokeWidth, shape.props.dash, isFilled)
},
},
'check-box': {
snapType: 'polygon',
icon: 'geo-check-box',
getPath(w, h, shape) {
const isFilled = shape.props.fill !== 'none'
const size = Math.min(w, h) * 0.82
const ox = (w - size) / 2
const oy = (h - size) / 2
return new PathBuilder()
.moveTo(0, 0, { geometry: { isFilled } })
.lineTo(w, 0)
.lineTo(w, h)
.lineTo(0, h)
.close()
.moveTo(clamp(ox + size * 0.25, 0, w), clamp(oy + size * 0.52, 0, h), {
geometry: { isInternal: true, isFilled: false },
offset: 0,
})
.lineTo(clamp(ox + size * 0.45, 0, w), clamp(oy + size * 0.82, 0, h))
.lineTo(clamp(ox + size * 0.82, 0, w), clamp(oy + size * 0.22, 0, h), { offset: 0 })
},
},
heart: {
snapType: 'blobby',
icon: 'geo-heart',
getPath(w, h, shape) {
const isFilled = shape.props.fill !== 'none'
const cx = w / 2
const o = w / 4
const k = h / 4
return new PathBuilder()
.moveTo(cx, h, { geometry: { isFilled } })
.cubicBezierTo(0, k * 1.2, o * 1.5, k * 3, 0, k * 2.5)
.cubicBezierTo(cx, k * 0.9, 0, -k * 0.32, o * 1.85, -k * 0.32)
.cubicBezierTo(w, k * 1.2, o * 2.15, -k * 0.32, w, -k * 0.32)
.cubicBezierTo(cx, h, w, k * 2.5, o * 2.5, k * 3)
.close()
},
},
} as const satisfies Record<string, GeoTypeDefinition>
/**
* Look up a geo type definition by name, checking custom types first then
* falling back to the built-in registry.
*
* @public
*/
export function getGeoTypeDefinition(
name: string,
customGeoTypes?: Record<string, GeoTypeDefinition>
): GeoTypeDefinition | undefined {
return (
customGeoTypes?.[name] ?? (defaultGeoTypeDefinitions as Record<string, GeoTypeDefinition>)[name]
)
}
const pathCache = new WeakCache<TLGeoShape, PathBuilder>()
export function getGeoShapePath(
shape: TLGeoShape,
strokeWidth: number,
customGeoTypes?: Record<string, GeoTypeDefinition>
) {
// Cache is keyed on shape only. For x-box, strokeWidth affects the diagonal
// inset, but theme changes are rare enough that stale cache entries are acceptable.
return pathCache.get(shape, (s) => _getGeoPath(s, strokeWidth, customGeoTypes))
}
function _getGeoPath(
shape: TLGeoShape,
strokeWidth: number,
customGeoTypes?: Record<string, GeoTypeDefinition>
) {
const w = Math.max(1, shape.props.w)
const h = Math.max(1, shape.props.h + shape.props.growY)
const sw = strokeWidth * shape.props.scale
const def = getGeoTypeDefinition(shape.props.geo, customGeoTypes)
if (!def) {
throw new Error(`Unknown geo type: ${shape.props.geo}`)
}
const path = def.getPath(w, h, shape, sw)
// Mirror the geometry when the shape is flipped. We flip the path itself (rather than only the
// rendered output) so that geometry, hit-testing, the selection indicator, and SVG export all
// stay consistent. The label lives in its own container and is intentionally left unflipped so
// text stays upright and readable.
const { flipX, flipY } = shape.props
if (flipX || flipY) {
return path.transform(getFlipMatrix({ flipX, flipY }, w, h))
}
return path
}
function getXBoxPath(
w: number,
h: number,
sw: number,
dash: TLDefaultDashStyle,
isFilled: boolean
) {
const cx = w / 2
const cy = h / 2
const path = new PathBuilder()
.moveTo(0, 0, { geometry: { isFilled } })
.lineTo(w, 0)
.lineTo(w, h)
.lineTo(0, h)
.close()
if (dash === 'none') {
return path
}
if (dash === 'dashed' || dash === 'dotted') {
return path
.moveTo(0, 0, {
geometry: { isInternal: true, isFilled: false },
dashStart: 'skip',
dashEnd: 'outset',
})
.lineTo(cx, cy)
.moveTo(w, h, {
geometry: { isInternal: true, isFilled: false },
dashStart: 'skip',
dashEnd: 'outset',
})
.lineTo(cx, cy)
.moveTo(0, h, {
geometry: { isInternal: true, isFilled: false },
dashStart: 'skip',
dashEnd: 'outset',
})
.lineTo(cx, cy)
.moveTo(w, 0, {
geometry: { isInternal: true, isFilled: false },
dashStart: 'skip',
dashEnd: 'outset',
})
.lineTo(cx, cy)
}
const inset = dash === 'draw' ? 0.62 : 0
path
.moveTo(clamp(sw * inset, 0, w), clamp(sw * inset, 0, h), {
geometry: { isInternal: true, isFilled: false },
})
.lineTo(clamp(w - sw * inset, 0, w), clamp(h - sw * inset, 0, h))
.moveTo(clamp(w - sw * inset, 0, w), clamp(sw * inset, 0, h), {
geometry: { isInternal: true, isFilled: false },
})
.lineTo(clamp(sw * inset, 0, w), clamp(h - sw * inset, 0, h))
return path
}
function getStadiumPath(w: number, h: number, isFilled: boolean) {
// stadium:
if (h > w) {
const r = w / 2
return new PathBuilder()
.moveTo(0, r, { geometry: { isFilled } })
.arcTo(r, r, false, true, 0, w, r)
.lineTo(w, h - r)
.arcTo(r, r, false, true, 0, 0, h - r)
.close()
}
const r = h / 2
return new PathBuilder()
.moveTo(r, h, { geometry: { isFilled } })
.arcTo(r, r, false, true, 0, r, 0)
.lineTo(w - r, 0)
.arcTo(r, r, false, true, 0, w - r, h)
.close()
}
function getStarPath(w: number, h: number, isFilled: boolean) {
// Most of this code is to offset the center, a 5 point star
// will need to be moved downward because from its center [0,0]
// it will have a bigger minY than maxY. This is because it'll
// have 2 points at the bottom.
const sides = 5
const step = PI2 / sides / 2
const rightMostIndex = Math.floor(sides / 4) * 2
const leftMostIndex = sides * 2 - rightMostIndex
const topMostIndex = 0
const bottomMostIndex = Math.floor(sides / 2) * 2
const maxX = (Math.cos(-HALF_PI + rightMostIndex * step) * w) / 2
const minX = (Math.cos(-HALF_PI + leftMostIndex * step) * w) / 2
const minY = (Math.sin(-HALF_PI + topMostIndex * step) * h) / 2
const maxY = (Math.sin(-HALF_PI + bottomMostIndex * step) * h) / 2
const diffX = w - Math.abs(maxX - minX)
const diffY = h - Math.abs(maxY - minY)
const offsetX = w / 2 + minX - (w / 2 - maxX)
const offsetY = h / 2 + minY - (h / 2 - maxY)
const ratio = 1
const cx = (w - offsetX) / 2
const cy = (h - offsetY) / 2
const ox = (w + diffX) / 2
const oy = (h + diffY) / 2
const ix = (ox * ratio) / 2
const iy = (oy * ratio) / 2
return PathBuilder.lineThroughPoints(
Array.from(Array(sides * 2), (_, i) => {
const theta = -HALF_PI + i * step
return new Vec(
cx + (i % 2 ? ix : ox) * Math.cos(theta),
cy + (i % 2 ? iy : oy) * Math.sin(theta)
)
}),
{ geometry: { isFilled } }
).close()
}
/* ---------------------- Cloud --------------------- */
function getOvalPerimeter(h: number, w: number) {
if (h > w) return (PI * (w / 2) + (h - w)) * 2
else return (PI * (h / 2) + (w - h)) * 2
}
type PillSection =
| {
type: 'straight'
start: VecModel
delta: VecModel
}
| {
type: 'arc'
center: VecModel
startAngle: number
}
function getPillPoints(width: number, height: number, numPoints: number) {
const radius = Math.min(width, height) / 2
const longSide = Math.max(width, height) - radius * 2
const circumference = Math.PI * (radius * 2) + 2 * longSide
const spacing = circumference / numPoints
const sections: PillSection[] =
width > height
? [
{
type: 'straight',
start: new Vec(radius, 0),
delta: new Vec(1, 0),
},
{
type: 'arc',
center: new Vec(width - radius, radius),
startAngle: -PI / 2,
},
{
type: 'straight',
start: new Vec(width - radius, height),
delta: new Vec(-1, 0),
},
{
type: 'arc',
center: new Vec(radius, radius),
startAngle: PI / 2,
},
]
: [
{
type: 'straight',
start: new Vec(width, radius),
delta: new Vec(0, 1),
},
{
type: 'arc',
center: new Vec(radius, height - radius),
startAngle: 0,
},
{
type: 'straight',
start: new Vec(0, height - radius),
delta: new Vec(0, -1),
},
{
type: 'arc',
center: new Vec(radius, radius),
startAngle: PI,
},
]
let sectionOffset = 0
const points: Vec[] = []
for (let i = 0; i < numPoints; i++) {
const section = sections[0]
if (section.type === 'straight') {
points.push(Vec.Add(section.start, Vec.Mul(section.delta, sectionOffset)))
} else {
points.push(
getPointOnCircle(section.center, radius, section.startAngle + sectionOffset / radius)
)
}
sectionOffset += spacing
let sectionLength = section.type === 'straight' ? longSide : PI * radius
while (sectionOffset > sectionLength) {
sectionOffset -= sectionLength
sections.push(sections.shift()!)
sectionLength = sections[0].type === 'straight' ? longSide : PI * radius
}
}
return points
}
const SIZES: Record<TLDefaultSizeStyle, number> = {
s: 50,
m: 70,
l: 100,
xl: 130,
}
const BUMP_PROTRUSION = 0.2
function getCloudPath(
width: number,
height: number,
seed: string,
size: TLDefaultSizeStyle,
scale: number,
isFilled: boolean
) {
const path = new PathBuilder()
const getRandom = rng(seed)
const pillCircumference = getOvalPerimeter(width, height)
const numBumps = Math.max(
Math.ceil(pillCircumference / SIZES[size]),
6,
Math.ceil(pillCircumference / Math.min(width, height))
)
const targetBumpProtrusion = (pillCircumference / numBumps) * BUMP_PROTRUSION
// if the aspect ratio is high, innerWidth should be smaller
const innerWidth = Math.max(width - targetBumpProtrusion * 2, 1)
const innerHeight = Math.max(height - targetBumpProtrusion * 2, 1)
const innerCircumference = getOvalPerimeter(innerWidth, innerHeight)
const distanceBetweenPointsOnPerimeter = innerCircumference / numBumps
const paddingX = (width - innerWidth) / 2
const paddingY = (height - innerHeight) / 2
const bumpPoints = getPillPoints(innerWidth, innerHeight, numBumps).map((p) => {
return p.addXY(paddingX, paddingY)
})
const maxWiggleX = width < 20 ? 0 : targetBumpProtrusion * 0.3
const maxWiggleY = height < 20 ? 0 : targetBumpProtrusion * 0.3
// wiggle the points from either end so that the bumps 'pop'
// in at the bottom-right and the top-left looks relatively stable
// note: it's important that we don't mutate here! these points are also the bump points
const wiggledPoints = bumpPoints.slice(0)
for (let i = 0; i < Math.floor(numBumps / 2); i++) {
wiggledPoints[i] = Vec.AddXY(
wiggledPoints[i],
getRandom() * maxWiggleX * scale,
getRandom() * maxWiggleY * scale
)
wiggledPoints[numBumps - i - 1] = Vec.AddXY(
wiggledPoints[numBumps - i - 1],
getRandom() * maxWiggleX * scale,
getRandom() * maxWiggleY * scale
)
}
for (let i = 0; i < wiggledPoints.length; i++) {
const j = i === wiggledPoints.length - 1 ? 0 : i + 1
const leftWigglePoint = wiggledPoints[i]
const rightWigglePoint = wiggledPoints[j]
const leftPoint = bumpPoints[i]
const rightPoint = bumpPoints[j]
// when the points are on the curvy part of a pill, there is a natural arc that we need to extends past
// otherwise it looks like the bumps get less bumpy on the curvy parts
const distanceBetweenOriginalPoints = Vec.Dist(leftPoint, rightPoint)
const curvatureOffset = distanceBetweenPointsOnPerimeter - distanceBetweenOriginalPoints
const distanceBetweenWigglePoints = Vec.Dist(leftWigglePoint, rightWigglePoint)
const relativeSize = distanceBetweenWigglePoints / distanceBetweenOriginalPoints
const finalDistance = (Math.max(paddingX, paddingY) + curvatureOffset) * relativeSize
const arcPoint = Vec.Lrp(leftPoint, rightPoint, 0.5).add(
Vec.Sub(rightPoint, leftPoint).uni().per().mul(finalDistance)
)
if (arcPoint.x < 0) {
arcPoint.x = 0
} else if (arcPoint.x > width) {
arcPoint.x = width
}
if (arcPoint.y < 0) {
arcPoint.y = 0
} else if (arcPoint.y > height) {
arcPoint.y = height
}
const center = centerOfCircleFromThreePoints(leftWigglePoint, rightWigglePoint, arcPoint)
const radius = Vec.Dist(
center ? center : Vec.Average([leftWigglePoint, rightWigglePoint]),
leftWigglePoint
)
if (i === 0) {
path.moveTo(leftWigglePoint.x, leftWigglePoint.y, { geometry: { isFilled } })
}
path.circularArcTo(radius, false, true, rightWigglePoint.x, rightWigglePoint.y)
}
return path.close()
}

View File

@@ -0,0 +1,27 @@
import { StateNode, TLKeyboardEventInfo, TLPointerEventInfo } from '@tldraw/editor'
import { startEditingShape } from '../../../tools/SelectTool/selectHelpers'
export class Idle extends StateNode {
static override id = 'idle'
override onPointerDown(info: TLPointerEventInfo) {
this.parent.transition('pointing', info)
}
override onEnter() {
this.editor.setCursor({ type: 'cross', rotation: 0 })
}
override onKeyUp(info: TLKeyboardEventInfo) {
const { editor } = this
if (info.key === 'Enter') {
const onlySelectedShape = editor.getOnlySelectedShape()
if (!editor.canEditShape(onlySelectedShape)) return
startEditingShape(editor, onlySelectedShape, { selectAll: true })
}
}
override onCancel() {
this.editor.setCurrentTool('select')
}
}

View File

@@ -0,0 +1,146 @@
import {
GeoShapeGeoStyle,
StateNode,
TLGeoShape,
TLPointerEventInfo,
Vec,
createShapeId,
maybeSnapToGrid,
} from '@tldraw/editor'
import { GeoShapeUtil } from '../GeoShapeUtil'
import { getGeoTypeDefinition } from '../getGeoShapePath'
export class Pointing extends StateNode {
static override id = 'pointing'
override onPointerUp() {
this.complete()
}
override onLongPress() {
// On a touch (coarse pointer) long-press, cancel the pending shape so it leaves nothing behind.
if (this.editor.getInstanceState().isCoarsePointer) this.cancel()
}
override onPointerMove(info: TLPointerEventInfo) {
if (this.editor.inputs.getIsDragging()) {
const originPagePoint = this.editor.inputs.getOriginPagePoint()
const id = createShapeId()
const creatingMarkId = this.editor.markHistoryStoppingPoint(`creating_geo:${id}`)
const newPoint = maybeSnapToGrid(originPagePoint, this.editor)
this.editor
.createShapes([
{
id,
type: 'geo',
x: newPoint.x,
y: newPoint.y,
props: {
w: 1,
h: 1,
geo: this.editor.getStyleForNextShape(GeoShapeGeoStyle),
scale: this.editor.getResizeScaleFactor(),
},
},
])
.select(id)
const shape = this.editor.getShape(id)
if (!shape) {
this.cancel()
return
}
this.editor.setCurrentTool('select.resizing', {
...info,
target: 'selection',
handle: 'bottom_right',
isCreating: true,
creatingMarkId,
creationCursorOffset: { x: 1, y: 1 },
onInteractionEnd: 'geo',
})
}
}
override onCancel() {
this.cancel()
}
override onComplete() {
// Not a release: see BaseBoxShapeTool's Pointing state for why this cancels.
this.cancel()
}
override onInterrupt() {
this.cancel()
}
private complete() {
const originPagePoint = this.editor.inputs.getOriginPagePoint()
const id = createShapeId()
this.editor.markHistoryStoppingPoint(`creating_geo:${id}`)
const scale = this.editor.getResizeScaleFactor()
const geo = this.editor.getStyleForNextShape(GeoShapeGeoStyle)
const geoShapeUtil = this.editor.getShapeUtil<GeoShapeUtil>('geo')
const def = getGeoTypeDefinition(geo, geoShapeUtil.options.customGeoTypes)
const size = def?.defaultSize ?? { w: 200, h: 200 }
this.editor.createShapes([
{
id,
type: 'geo',
x: originPagePoint.x,
y: originPagePoint.y,
props: {
geo: this.editor.getStyleForNextShape(GeoShapeGeoStyle),
scale,
...size,
},
},
])
const shape = this.editor.getShape<TLGeoShape>(id)!
if (!shape) {
this.cancel()
return
}
const { w, h } = shape.props
const delta = new Vec(w / 2, h / 2).mul(scale)
const parentTransform = this.editor.getShapeParentTransform(shape)
if (parentTransform) delta.rot(-parentTransform.rotation())
const newPoint = maybeSnapToGrid(new Vec(shape.x - delta.x, shape.y - delta.y), this.editor)
this.editor.select(id)
this.editor.updateShape({
id: shape.id,
type: 'geo',
x: newPoint.x,
y: newPoint.y,
props: {
geo: this.editor.getStyleForNextShape(GeoShapeGeoStyle),
w: w * scale,
h: h * scale,
},
})
if (this.editor.getInstanceState().isToolLocked) {
this.parent.transition('idle')
} else {
this.editor.setCurrentTool('select', {})
}
}
private cancel() {
// we should not have created any shapes yet, so no need to bail
this.parent.transition('idle')
}
}

View File

@@ -0,0 +1,3 @@
describe('Highlight shape tool', () => {
it.todo('tests the highlight shape tool')
})

View File

@@ -0,0 +1,21 @@
// shared custody
import { StateNode, TLStateNodeConstructor } from '@tldraw/editor'
import { Drawing } from '../draw/toolStates/Drawing'
import { Idle } from '../draw/toolStates/Idle'
/** @public */
export class HighlightShapeTool extends StateNode {
static override id = 'highlight'
static override initial = 'idle'
static override useCoalescedEvents = true
static override children(): TLStateNodeConstructor[] {
return [Idle, Drawing]
}
static override isLockable = false
override shapeType = 'highlight'
override onExit() {
const drawingState = this.children!['drawing'] as Drawing
drawingState.initialShape = undefined
}
}

View File

@@ -0,0 +1,188 @@
import { Circle2d, createShapeId, TLHighlightShape } from '@tldraw/editor'
import { TestEditor } from '../../../test/TestEditor'
import { createDrawSegments, pointsToBase64 } from '../../utils/test-helpers'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
})
afterEach(() => {
editor?.dispose()
})
describe('HighlightShapeUtil dot detection', () => {
const shapeId = createShapeId('test-highlight')
function createHighlightShape(segments: TLHighlightShape['props']['segments']): TLHighlightShape {
editor.createShapes([
{
id: shapeId,
type: 'highlight',
props: { segments },
},
])
return editor.getShape(shapeId) as TLHighlightShape
}
describe('empty segments', () => {
it('treats a shape created from its default props as a dot', () => {
editor.createShape({ id: shapeId, type: 'highlight' })
const shape = editor.getShape<TLHighlightShape>(shapeId)!
expect(shape.props.segments).toEqual([])
expect(editor.getShapeGeometry(shape)).toBeInstanceOf(Circle2d)
expect(editor.getShapeAtPoint({ x: 0, y: 0 })?.id).toBe(shapeId)
})
})
describe('getIsDot behavior via hideResizeHandles', () => {
it('treats a shape with one segment and zero points as a dot', () => {
const shape = createHighlightShape([{ type: 'free', path: '' }])
const util = editor.getShapeUtil('highlight')
expect(util.hideResizeHandles(shape)).toBe(true)
})
it('treats a shape with one segment and one point as a dot', () => {
const shape = createHighlightShape(createDrawSegments([[{ x: 0, y: 0, z: 0.5 }]]))
const util = editor.getShapeUtil('highlight')
expect(util.hideResizeHandles(shape)).toBe(true)
})
it('treats a shape with one segment and two points as NOT a dot', () => {
const shape = createHighlightShape(
createDrawSegments([
[
{ x: 0, y: 0, z: 0.5 },
{ x: 10, y: 10, z: 0.5 },
],
])
)
const util = editor.getShapeUtil('highlight')
expect(util.hideResizeHandles(shape)).toBe(false)
})
it('treats a shape with one segment and many points as NOT a dot', () => {
const shape = createHighlightShape(
createDrawSegments([
[
{ x: 0, y: 0, z: 0.5 },
{ x: 10, y: 10, z: 0.5 },
{ x: 20, y: 5, z: 0.5 },
{ x: 30, y: 15, z: 0.5 },
],
])
)
const util = editor.getShapeUtil('highlight')
expect(util.hideResizeHandles(shape)).toBe(false)
})
it('treats a shape with multiple segments as NOT a dot', () => {
const shape = createHighlightShape(
createDrawSegments([[{ x: 0, y: 0, z: 0.5 }], [{ x: 10, y: 10, z: 0.5 }]])
)
const util = editor.getShapeUtil('highlight')
expect(util.hideResizeHandles(shape)).toBe(false)
})
})
describe('hideRotateHandle mirrors hideResizeHandles for dots', () => {
it('hides rotate handle for dots', () => {
const shape = createHighlightShape(createDrawSegments([[{ x: 0, y: 0, z: 0.5 }]]))
const util = editor.getShapeUtil('highlight')
expect(util.hideRotateHandle(shape)).toBe(true)
})
it('shows rotate handle for non-dots', () => {
const shape = createHighlightShape(
createDrawSegments([
[
{ x: 0, y: 0, z: 0.5 },
{ x: 10, y: 10, z: 0.5 },
],
])
)
const util = editor.getShapeUtil('highlight')
expect(util.hideRotateHandle(shape)).toBe(false)
})
})
describe('hideSelectionBoundsFg mirrors hideResizeHandles for dots', () => {
it('hides selection bounds for dots', () => {
const shape = createHighlightShape(createDrawSegments([[{ x: 0, y: 0, z: 0.5 }]]))
const util = editor.getShapeUtil('highlight')
expect(util.hideSelectionBoundsFg(shape)).toBe(true)
})
it('shows selection bounds for non-dots', () => {
const shape = createHighlightShape(
createDrawSegments([
[
{ x: 0, y: 0, z: 0.5 },
{ x: 10, y: 10, z: 0.5 },
],
])
)
const util = editor.getShapeUtil('highlight')
expect(util.hideSelectionBoundsFg(shape)).toBe(false)
})
})
describe('base64 encoding boundary conditions', () => {
it('correctly handles the boundary at exactly 24 base64 characters (2 points)', () => {
// First point is 16 base64 chars (Float32), delta points are 8 chars each (Float16)
// 2 points = 24 characters, which should NOT be a dot
const twoPointsBase64 = pointsToBase64([
{ x: 0, y: 0, z: 0.5 },
{ x: 1, y: 1, z: 0.5 },
])
expect(twoPointsBase64.length).toBe(24)
const shape = createHighlightShape([{ type: 'free', path: twoPointsBase64 }])
const util = editor.getShapeUtil('highlight')
expect(util.hideResizeHandles(shape)).toBe(false)
})
it('correctly handles the boundary at exactly 16 base64 characters (1 point)', () => {
// 1 point = 16 characters (Float32 first point), which should be a dot
const onePointBase64 = pointsToBase64([{ x: 0, y: 0, z: 0.5 }])
expect(onePointBase64.length).toBe(16)
const shape = createHighlightShape([{ type: 'free', path: onePointBase64 }])
const util = editor.getShapeUtil('highlight')
expect(util.hideResizeHandles(shape)).toBe(true)
})
})
})
describe('HighlightShapeUtil getInterpolatedProps', () => {
const segments = createDrawSegments([
[
{ x: 0, y: 0, z: 0.5 },
{ x: 10, y: 10, z: 0.5 },
],
])
function createHighlightShape(id: string, props: Partial<TLHighlightShape['props']>) {
const shapeId = createShapeId(id)
editor.createShapes([{ id: shapeId, type: 'highlight', props: { segments, ...props } }])
return editor.getShape(shapeId) as TLHighlightShape
}
it('takes discrete props from the nearer shape', () => {
const start = createHighlightShape('start', { color: 'red', size: 's', scale: 1 })
const end = createHighlightShape('end', { color: 'blue', size: 'xl', scale: 3 })
const util = editor.getShapeUtil('highlight')
expect(util.getInterpolatedProps!(start, end, 0.25)).toMatchObject({
color: 'red',
size: 's',
scale: 1.5,
})
expect(util.getInterpolatedProps!(start, end, 0.75)).toMatchObject({
color: 'blue',
size: 'xl',
scale: 2.5,
})
})
})

View File

@@ -0,0 +1,357 @@
/* eslint-disable react-hooks/rules-of-hooks */
import {
Circle2d,
Editor,
Polygon2d,
SVGContainer,
ShapeUtil,
SvgExportContext,
TLHighlightShape,
TLHighlightShapeProps,
TLResizeInfo,
VecLike,
b64Vecs,
debugFlags,
getColorValue,
highlightShapeMigrations,
highlightShapeProps,
last,
lerp,
rng,
tlenvReactive,
useColorMode,
useValue,
} from '@tldraw/editor'
import { getHighlightFreehandSettings, getPointsFromDrawSegments } from '../draw/getPath'
import { FONT_SIZES } from '../shared/default-shape-constants'
import { getStroke } from '../shared/freehand/getStroke'
import { getStrokePoints } from '../shared/freehand/getStrokePoints'
import { getSvgPathFromStrokePoints } from '../shared/freehand/svg'
import type { ShapeOptionsWithDisplayValues } from '../shared/getDisplayValues'
import { getDisplayValues } from '../shared/getDisplayValues'
import { interpolateSegments } from '../shared/interpolate-props'
/** @public */
export interface HighlightShapeUtilDisplayValues {
strokeColor: string
strokeWidth: number
underlayOpacity: number
overlayOpacity: number
}
/** @public */
export interface HighlightShapeOptions extends ShapeOptionsWithDisplayValues<
TLHighlightShape,
HighlightShapeUtilDisplayValues
> {
/**
* The maximum number of points in a line before the draw tool will begin a new shape.
* A higher number will lead to poor performance while drawing very long lines.
*/
readonly maxPointsPerShape: number
}
/** @public */
export class HighlightShapeUtil extends ShapeUtil<TLHighlightShape> {
static override type = 'highlight' as const
static override props = highlightShapeProps
static override migrations = highlightShapeMigrations
override options: HighlightShapeOptions = {
maxPointsPerShape: 600,
getDefaultDisplayValues(_editor, shape, theme, colorMode): HighlightShapeUtilDisplayValues {
const { color, size } = shape.props
const colors = theme.colors[colorMode]
const useP3 = !debugFlags.forceSrgb.get() && tlenvReactive.get().supportsP3ColorSpace
const strokeColor = useP3
? getColorValue(colors, color, 'highlightP3')
: getColorValue(colors, color, 'highlightSrgb')
return {
strokeColor,
strokeWidth: theme.fontSize * FONT_SIZES[size] * 1.12,
underlayOpacity: 0.82,
overlayOpacity: 0.35,
}
},
getCustomDisplayValues(): Partial<HighlightShapeUtilDisplayValues> {
return {}
},
}
override hideResizeHandles(shape: TLHighlightShape) {
return getIsDot(shape)
}
override hideRotateHandle(shape: TLHighlightShape) {
return getIsDot(shape)
}
override hideSelectionBoundsFg(shape: TLHighlightShape) {
return getIsDot(shape)
}
override getDefaultProps(): TLHighlightShape['props'] {
return {
segments: [],
color: 'black',
size: 'm',
isComplete: false,
isPen: false,
scale: 1,
scaleX: 1,
scaleY: 1,
}
}
getGeometry(shape: TLHighlightShape) {
const dv = getDisplayValues(this, shape)
const strokeWidth = dv.strokeWidth * shape.props.scale
// No segments yet (the default props): treat it as a dot so geometry lookups don't throw.
if (getIsDot(shape) || shape.props.segments.length === 0) {
return new Circle2d({
x: -strokeWidth / 2,
y: -strokeWidth / 2,
radius: strokeWidth / 2,
isFilled: true,
})
}
const allPointsFromSegments = getPointsFromDrawSegments(
shape.props.segments,
shape.props.scaleX,
shape.props.scaleY
)
const showAsComplete = shape.props.isComplete || last(shape.props.segments)?.type === 'straight'
const opts = getHighlightFreehandSettings({ strokeWidth, showAsComplete })
return new Polygon2d({
points: getStroke(allPointsFromSegments, opts),
isFilled: true,
})
}
component(shape: TLHighlightShape) {
const dv = getDisplayValues(this, shape)
const sw = dv.strokeWidth * shape.props.scale
const forceSolid = useHighlightForceSolid(this.editor, sw)
return (
<SVGContainer>
<HighlightRenderer
shape={shape}
forceSolid={forceSolid}
strokeWidth={sw}
opacity={dv.overlayOpacity}
strokeColor={dv.strokeColor}
/>
</SVGContainer>
)
}
override backgroundComponent(shape: TLHighlightShape) {
const colorMode = useColorMode()
const dv = getDisplayValues(this, shape, colorMode)
const sw = dv.strokeWidth * shape.props.scale
const forceSolid = useHighlightForceSolid(this.editor, sw)
return (
<SVGContainer>
<HighlightRenderer
shape={shape}
forceSolid={forceSolid}
strokeWidth={sw}
opacity={dv.underlayOpacity}
strokeColor={dv.strokeColor}
/>
</SVGContainer>
)
}
override getIndicatorPath(shape: TLHighlightShape): Path2D {
const dv = getDisplayValues(this, shape)
const strokeWidth = dv.strokeWidth * shape.props.scale
const zoomLevel = this.editor.getEfficientZoomLevel()
const forceSolid = strokeWidth / zoomLevel < 1.5
const { strokePoints, sw } = getHighlightStrokePoints(shape, strokeWidth, forceSolid)
const allPointsFromSegments = getPointsFromDrawSegments(
shape.props.segments,
shape.props.scaleX,
shape.props.scaleY
)
let strokePath
if (strokePoints.length < 2) {
strokePath = getIndicatorDot(allPointsFromSegments[0] ?? { x: 0, y: 0 }, sw)
} else {
strokePath = getSvgPathFromStrokePoints(strokePoints, false)
}
return new Path2D(strokePath)
}
override toSvg(shape: TLHighlightShape, ctx: SvgExportContext) {
const dv = getDisplayValues(this, shape, ctx.colorMode)
const strokeWidth = dv.strokeWidth * shape.props.scale
const forceSolid = strokeWidth < 1.5
const scaleFactor = 1 / shape.props.scale
return (
<g transform={`scale(${scaleFactor})`}>
<HighlightRenderer
forceSolid={forceSolid}
strokeWidth={strokeWidth}
shape={shape}
opacity={dv.overlayOpacity}
strokeColor={dv.strokeColor}
/>
</g>
)
}
override toBackgroundSvg(shape: TLHighlightShape, ctx: SvgExportContext) {
const dv = getDisplayValues(this, shape, ctx.colorMode)
const strokeWidth = dv.strokeWidth * shape.props.scale
const forceSolid = strokeWidth < 1.5
const scaleFactor = 1 / shape.props.scale
return (
<g transform={`scale(${scaleFactor})`}>
<HighlightRenderer
forceSolid={forceSolid}
strokeWidth={strokeWidth}
shape={shape}
opacity={dv.underlayOpacity}
strokeColor={dv.strokeColor}
/>
</g>
)
}
override onResize(shape: TLHighlightShape, info: TLResizeInfo<TLHighlightShape>) {
const newScaleX = info.scaleX * shape.props.scaleX
const newScaleY = info.scaleY * shape.props.scaleY
if (newScaleX === 0 || newScaleY === 0) return
return {
props: {
scaleX: newScaleX,
scaleY: newScaleY,
},
}
}
override getInterpolatedProps(
startShape: TLHighlightShape,
endShape: TLHighlightShape,
t: number
): TLHighlightShapeProps {
return {
...(t > 0.5 ? endShape.props : startShape.props),
segments: interpolateSegments(startShape.props.segments, endShape.props.segments, t),
scale: lerp(startShape.props.scale, endShape.props.scale, t),
}
}
}
function getShapeDot(point: VecLike) {
const r = 0.1
return `M ${point.x} ${point.y} m -${r}, 0 a ${r},${r} 0 1,0 ${r * 2},0 a ${r},${r} 0 1,0 -${
r * 2
},0`
}
function getIndicatorDot(point: VecLike, sw: number) {
const r = sw / 2
return `M ${point.x} ${point.y} m -${r}, 0 a ${r},${r} 0 1,0 ${r * 2},0 a ${r},${r} 0 1,0 -${
r * 2
},0`
}
function getHighlightStrokePoints(
shape: TLHighlightShape,
strokeWidth: number,
forceSolid: boolean
) {
const allPointsFromSegments = getPointsFromDrawSegments(
shape.props.segments,
shape.props.scaleX,
shape.props.scaleY
)
const showAsComplete = shape.props.isComplete || last(shape.props.segments)?.type === 'straight'
let sw = strokeWidth
if (!forceSolid && !shape.props.isPen && allPointsFromSegments.length === 1) {
sw += rng(shape.id)() * (strokeWidth / 6)
}
const options = getHighlightFreehandSettings({
strokeWidth: sw,
showAsComplete,
})
const strokePoints = getStrokePoints(allPointsFromSegments, options)
return { strokePoints, sw }
}
function getIsDot(shape: TLHighlightShape) {
// A dot is a single-point segment. isSinglePoint knows the per-encoding length
// (and takes the segment's dim), so this stays correct for both 2D and 3D paths.
const segment = shape.props.segments[0]
return shape.props.segments.length === 1 && b64Vecs.isSinglePoint(segment.path, segment.dim)
}
function HighlightRenderer({
strokeWidth,
forceSolid,
shape,
opacity,
strokeColor,
}: {
strokeWidth: number
forceSolid: boolean
shape: TLHighlightShape
opacity: number
strokeColor: string
}) {
const allPointsFromSegments = getPointsFromDrawSegments(
shape.props.segments,
shape.props.scaleX,
shape.props.scaleY
)
let sw = strokeWidth
if (!forceSolid && !shape.props.isPen && allPointsFromSegments.length === 1) {
sw += rng(shape.id)() * (sw / 6)
}
const options = getHighlightFreehandSettings({
strokeWidth: sw,
showAsComplete: shape.props.isComplete || last(shape.props.segments)?.type === 'straight',
})
const strokePoints = getStrokePoints(allPointsFromSegments, options)
const solidStrokePath =
strokePoints.length > 1
? getSvgPathFromStrokePoints(strokePoints, false)
: getShapeDot(allPointsFromSegments[0] ?? { x: 0, y: 0 })
return (
<path
d={solidStrokePath}
strokeLinecap="round"
fill="none"
pointerEvents="all"
stroke={strokeColor}
strokeWidth={sw}
opacity={opacity}
/>
)
}
function useHighlightForceSolid(editor: Editor, scaledStrokeWidth: number) {
return useValue(
'forceSolid',
() => {
const zoomLevel = editor.getEfficientZoomLevel()
return scaledStrokeWidth / zoomLevel < 1.5
},
[editor, scaledStrokeWidth]
)
}

View File

@@ -0,0 +1,168 @@
import { Image, LruCache, VecLike } from '@tldraw/editor'
/** Mime types of image formats that support transparency / alpha channel. */
export const TRANSPARENT_IMAGE_MIMETYPES: readonly string[] = [
'image/png',
'image/webp',
'image/gif',
'image/avif',
]
/** Alpha channel data for an image, downsampled for efficient hit testing. */
export interface AlphaData {
width: number
height: number
/** Row-major alpha values (0–255) */
alphas: Uint8Array
}
/** Shared config for image geometries that support alpha hit testing. */
export interface ImageAlphaGeometryConfig {
alphaDataGetter(): AlphaData | null
crop: { topLeft: { x: number; y: number }; bottomRight: { x: number; y: number } } | null
flipX: boolean
flipY: boolean
}
/**
* Map a point in shape space to normalized [0,1] image coordinates, accounting for crop and flip.
* @internal
*/
function mapToImageCoords(
config: ImageAlphaGeometryConfig,
point: VecLike,
bounds: { minX: number; minY: number; w: number; h: number }
): { nx: number; ny: number } {
// Normalize point to [0,1] within the shape bounds, clamped for edge-margin hits
let nx = Math.max(0, Math.min(1, (point.x - bounds.minX) / bounds.w))
let ny = Math.max(0, Math.min(1, (point.y - bounds.minY) / bounds.h))
// Map from cropped shape space to full image space
if (config.crop) {
const { topLeft, bottomRight } = config.crop
nx = topLeft.x + nx * (bottomRight.x - topLeft.x)
ny = topLeft.y + ny * (bottomRight.y - topLeft.y)
}
// Account for flips
if (config.flipX) nx = 1 - nx
if (config.flipY) ny = 1 - ny
return { nx, ny }
}
/** Returns true if the point maps to a transparent pixel. Returns false if alpha data isn't loaded yet. */
export function isImagePointTransparent(
config: ImageAlphaGeometryConfig,
point: VecLike,
bounds: { minX: number; minY: number; w: number; h: number }
): boolean {
const data = config.alphaDataGetter()
if (!data) return false
const { nx, ny } = mapToImageCoords(config, point, bounds)
return isPointTransparent(data, nx, ny)
}
const MAX_SIZE = 256
const alphaCache = new LruCache<string, AlphaData>(100)
const pending = new Set<string>()
let offscreenCanvas: OffscreenCanvas | null = null
function getOffscreenCanvas(w: number, h: number): OffscreenCanvas {
if (!offscreenCanvas) {
offscreenCanvas = new OffscreenCanvas(w, h)
} else {
offscreenCanvas.width = w
offscreenCanvas.height = h
}
return offscreenCanvas
}
function extractAlphas(ctx: OffscreenCanvasRenderingContext2D, w: number, h: number): Uint8Array {
const imageData = ctx.getImageData(0, 0, w, h)
const pixels = new Uint32Array(imageData.data.buffer)
const alphas = new Uint8Array(w * h)
for (let i = 0; i < alphas.length; i++) {
alphas[i] = pixels[i] >>> 24
}
return alphas
}
/**
* Start loading alpha data for a given image URL. No-op if already loaded or loading.
*
* @param url - The URL to fetch the image from (may be a resolved/optimized CDN URL).
* @param cacheKey - The key to store/lookup the alpha data under. Defaults to `url`.
* Pass `asset.props.src` here so that `getAlphaData(asset.props.src)` in getGeometry
* finds data that was preloaded from a resolved URL.
*/
export function preloadAlphaData(url: string, cacheKey?: string): void {
if (typeof OffscreenCanvas === 'undefined') return
const key = cacheKey ?? url
if (alphaCache.has(key) || pending.has(key)) return
pending.add(key)
const img = Image()
img.crossOrigin = 'anonymous'
img.onload = async () => {
pending.delete(key)
const { width: origW, height: origH } = img
if (origW === 0 || origH === 0) return
const scale = Math.min(1, MAX_SIZE / Math.max(origW, origH))
const w = Math.max(1, Math.round(origW * scale))
const h = Math.max(1, Math.round(origH * scale))
let bitmap: ImageBitmap | null = null
try {
// Resize off the main thread via createImageBitmap
bitmap = await createImageBitmap(img, {
resizeWidth: w,
resizeHeight: h,
resizeQuality: 'low',
})
} catch {
// Fallback handled below
}
// Canvas operations are synchronous from here — no interleaving from
// concurrent preloads that could resize the shared OffscreenCanvas.
const canvas = getOffscreenCanvas(w, h)
const ctx = canvas.getContext('2d')
if (!ctx) return
if (bitmap) {
ctx.drawImage(bitmap, 0, 0)
bitmap.close()
} else {
ctx.drawImage(img, 0, 0, w, h)
}
alphaCache.set(key, { width: w, height: h, alphas: extractAlphas(ctx, w, h) })
}
img.onerror = () => {
pending.delete(key)
}
img.src = url
}
/** Get cached alpha data for a URL, or null if not yet loaded. */
export function getAlphaData(src: string): AlphaData | null {
return alphaCache.get(src) ?? null
}
/**
* Check whether a point in normalized [0,1] coordinates falls on a transparent pixel.
* Returns true if the pixel's alpha is below the threshold.
*/
export function isPointTransparent(
data: AlphaData,
nx: number,
ny: number,
threshold = 10
): boolean {
const ix = Math.min(Math.floor(nx * data.width), data.width - 1)
const iy = Math.min(Math.floor(ny * data.height), data.height - 1)
return data.alphas[iy * data.width + ix] < threshold
}

View File

@@ -0,0 +1,61 @@
import { Ellipse2d, Geometry2dOptions, Rectangle2d, VecLike } from '@tldraw/editor'
import { ImageAlphaGeometryConfig, isImagePointTransparent } from './ImageAlphaCache'
function extractAlphaConfig(config: ImageAlphaGeometryConfig): ImageAlphaGeometryConfig {
return {
alphaDataGetter: config.alphaDataGetter,
crop: config.crop,
flipX: config.flipX,
flipY: config.flipY,
}
}
/** @internal */
export class ImageRectangle2d extends Rectangle2d {
private alphaConfig: ImageAlphaGeometryConfig
constructor(
config: Omit<Geometry2dOptions, 'isClosed'> & {
x?: number
y?: number
width: number
height: number
} & ImageAlphaGeometryConfig
) {
super(config)
this.alphaConfig = extractAlphaConfig(config)
}
override hitTestPoint(point: VecLike, margin = 0, hitInside = false): boolean {
if (!super.hitTestPoint(point, margin, hitInside)) return false
return !isImagePointTransparent(this.alphaConfig, point, this.bounds)
}
override ignoreHit(point: VecLike): boolean {
return isImagePointTransparent(this.alphaConfig, point, this.bounds)
}
}
/** @internal */
export class ImageEllipse2d extends Ellipse2d {
private alphaConfig: ImageAlphaGeometryConfig
constructor(
config: Omit<Geometry2dOptions, 'isClosed'> & {
width: number
height: number
} & ImageAlphaGeometryConfig
) {
super(config)
this.alphaConfig = extractAlphaConfig(config)
}
override hitTestPoint(point: VecLike, margin = 0, hitInside = false): boolean {
if (!super.hitTestPoint(point, margin, hitInside)) return false
return !isImagePointTransparent(this.alphaConfig, point, this.bounds)
}
override ignoreHit(point: VecLike): boolean {
return isImagePointTransparent(this.alphaConfig, point, this.bounds)
}
}

View File

@@ -0,0 +1,655 @@
// oxlint-disable typescript/no-empty-object-type
import {
BaseBoxShapeUtil,
Editor,
Ellipse2d,
FileHelpers,
Geometry2d,
HTMLContainer,
Image,
MediaHelpers,
Rectangle2d,
SvgExportContext,
TLAsset,
TLAssetId,
TLImageAsset,
TLImageShape,
TLImageShapeProps,
TLResizeInfo,
TLShapePartial,
Vec,
VecModel,
WeakCache,
createShapeId,
fetch,
getGlobalDocument,
imageShapeMigrations,
imageShapeProps,
lerp,
modulate,
resizeBox,
structuredClone,
useEditor,
useUniqueSafeId,
useValue,
} from '@tldraw/editor'
import classNames from 'classnames'
import { memo, useEffect, useState } from 'react'
import { BrokenAssetIcon } from '../shared/BrokenAssetIcon'
import { getUncroppedSize } from '../shared/crop'
import { getFlipForResize } from '../shared/flip'
import type { ShapeOptionsWithDisplayValues } from '../shared/getDisplayValues'
import { HyperlinkButton } from '../shared/HyperlinkButton'
import { useImageOrVideoAsset } from '../shared/useImageOrVideoAsset'
import { usePrefersReducedMotion } from '../shared/usePrefersReducedMotion'
import { TRANSPARENT_IMAGE_MIMETYPES, getAlphaData, preloadAlphaData } from './ImageAlphaCache'
import { ImageEllipse2d, ImageRectangle2d } from './ImageAlphaGeometry'
async function getDataURIFromURL(url: string): Promise<string> {
const response = await fetch(url)
const blob = await response.blob()
return FileHelpers.blobToDataUrl(blob)
}
const imageSvgExportCache = new WeakCache<TLAsset, Promise<string | null>>()
/** @public */
export interface ImageShapeUtilDisplayValues {}
/** @public */
export interface ImageShapeOptions extends ShapeOptionsWithDisplayValues<
TLImageShape,
ImageShapeUtilDisplayValues
> {}
/** @public */
export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
static override type = 'image' as const
static override props = imageShapeProps
static override migrations = imageShapeMigrations
static override handledAssetTypes = ['image'] as const
override options: ImageShapeOptions = {
getDefaultDisplayValues(): ImageShapeUtilDisplayValues {
return {}
},
getCustomDisplayValues(): Partial<ImageShapeUtilDisplayValues> {
return {}
},
}
override isAspectRatioLocked(shape: TLImageShape) {
return true
}
override canCrop(shape: TLImageShape) {
return true
}
override isExportBoundsContainer(): boolean {
return true
}
override getDefaultProps(): TLImageShape['props'] {
return {
w: 100,
h: 100,
assetId: null,
playing: true,
url: '',
crop: null,
flipX: false,
flipY: false,
altText: '',
}
}
override createShapeForAsset(asset: TLAsset, position: VecModel): TLShapePartial | null {
const imageAsset = asset as TLImageAsset
return {
id: createShapeId(),
type: 'image',
x: position.x,
y: position.y,
opacity: 1,
props: {
assetId: imageAsset.id,
w: imageAsset.props.w,
h: imageAsset.props.h,
},
}
}
override getGeometry(shape: TLImageShape): Geometry2d {
const asset = shape.props.assetId ? this.editor.getAsset(shape.props.assetId) : null
const mimeType = asset && 'mimeType' in asset.props ? asset.props.mimeType : null
const supportsTransparency = mimeType != null && TRANSPARENT_IMAGE_MIMETYPES.includes(mimeType)
const assetSrc = asset && 'src' in asset.props ? asset.props.src : null
if (shape.props.crop?.isCircle) {
if (supportsTransparency && assetSrc) {
const src = assetSrc
return new ImageEllipse2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
alphaDataGetter: () => getAlphaData(src),
crop: shape.props.crop,
flipX: shape.props.flipX,
flipY: shape.props.flipY,
})
}
return new Ellipse2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
})
}
if (supportsTransparency && assetSrc) {
const src = assetSrc
return new ImageRectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
alphaDataGetter: () => getAlphaData(src),
crop: shape.props.crop,
flipX: shape.props.flipX,
flipY: shape.props.flipY,
})
}
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
})
}
override getAriaDescriptor(shape: TLImageShape) {
return shape.props.altText
}
override onResize(shape: TLImageShape, info: TLResizeInfo<TLImageShape>) {
let resized: TLImageShape = resizeBox(shape, info)
const { scaleX, scaleY } = info
resized = {
...resized,
props: {
...resized.props,
...getFlipForResize(info.initialShape.props, info),
},
}
if (!shape.props.crop) return resized
// Mirror the crop whenever the shape is flipped along an axis. This happens both when
// using the flip command and when dragging a resize handle (including a group's handle)
// past the opposite edge. We can't check for an exact scale of -1 here because a group
// flip resizes its children by an arbitrary negative scale, not just -1.
const flipCropHorizontally = scaleX < 0
const flipCropVertically = scaleY < 0
const { topLeft, bottomRight } = shape.props.crop
resized.props.crop = {
topLeft: {
x: flipCropHorizontally ? 1 - bottomRight.x : topLeft.x,
y: flipCropVertically ? 1 - bottomRight.y : topLeft.y,
},
bottomRight: {
x: flipCropHorizontally ? 1 - topLeft.x : bottomRight.x,
y: flipCropVertically ? 1 - topLeft.y : bottomRight.y,
},
isCircle: shape.props.crop.isCircle,
}
return resized
}
component(shape: TLImageShape) {
return <ImageShape shape={shape} />
}
override getIndicatorPath(shape: TLImageShape): Path2D | undefined {
if (this.editor.getCroppingShapeId() === shape.id) return undefined
const path = new Path2D()
if (shape.props.crop?.isCircle) {
const cx = shape.props.w / 2
const cy = shape.props.h / 2
path.ellipse(cx, cy, cx, cy, 0, 0, Math.PI * 2)
} else {
path.rect(0, 0, shape.props.w, shape.props.h)
}
return path
}
override async toSvg(shape: TLImageShape, ctx: SvgExportContext) {
const props = shape.props
if (!props.assetId) return null
const asset = this.editor.getAsset(props.assetId)
if (!asset) return null
const { w } = getUncroppedSize(shape.props, props.crop)
const promise = imageSvgExportCache.get(asset, async () => {
let src = await ctx.resolveAssetUrl(asset.id, w)
if (!src) return null
if (
src.startsWith('blob:') ||
src.startsWith('http') ||
src.startsWith('/') ||
src.startsWith('./')
) {
// If it's a remote image, we need to fetch it and convert it to a data URI
src = (await getDataURIFromURL(src)) || ''
}
// If it's animated then we need to get the first frame
if (getIsAnimated(this.editor, asset.id)) {
const { promise } = getFirstFrameOfAnimatedImage(src)
src = await promise
}
return src
})
const src = await promise.catch((error) => {
// A failure (e.g. a CORS-blocked fetch) must not stay memoized, or every later export of
// this asset fails too. Only evict our own entry: a concurrent export may have started a
// fresh one in the meantime. Skip the image rather than failing the whole export.
if (imageSvgExportCache.items.get(asset) === promise) imageSvgExportCache.items.delete(asset)
console.error(`Could not export image ${asset.id}`, error)
return null
})
if (!src) return null
return <SvgImage shape={shape} src={src} />
}
override onDoubleClickEdge(shape: TLImageShape) {
const props = shape.props
if (!props) return
if (this.editor.getCroppingShapeId() !== shape.id) {
return
}
const crop = structuredClone(props.crop) || {
topLeft: { x: 0, y: 0 },
bottomRight: { x: 1, y: 1 },
}
// The true asset dimensions
const { w, h } = getUncroppedSize(shape.props, crop)
const pointDelta = new Vec(crop.topLeft.x * w, crop.topLeft.y * h).rot(shape.rotation)
const partial: TLShapePartial<TLImageShape> = {
id: shape.id,
type: shape.type,
x: shape.x - pointDelta.x,
y: shape.y - pointDelta.y,
props: {
crop: {
topLeft: { x: 0, y: 0 },
bottomRight: { x: 1, y: 1 },
},
w,
h,
},
}
this.editor.updateShapes([partial])
}
override getInterpolatedProps(
startShape: TLImageShape,
endShape: TLImageShape,
t: number
): TLImageShapeProps {
function interpolateCrop(
startShape: TLImageShape,
endShape: TLImageShape
): TLImageShapeProps['crop'] {
if (startShape.props.crop === null && endShape.props.crop === null) return null
const startTL = startShape.props.crop?.topLeft || { x: 0, y: 0 }
const startBR = startShape.props.crop?.bottomRight || { x: 1, y: 1 }
const endTL = endShape.props.crop?.topLeft || { x: 0, y: 0 }
const endBR = endShape.props.crop?.bottomRight || { x: 1, y: 1 }
return {
topLeft: { x: lerp(startTL.x, endTL.x, t), y: lerp(startTL.y, endTL.y, t) },
bottomRight: { x: lerp(startBR.x, endBR.x, t), y: lerp(startBR.y, endBR.y, t) },
}
}
return {
...(t > 0.5 ? endShape.props : startShape.props),
w: lerp(startShape.props.w, endShape.props.w, t),
h: lerp(startShape.props.h, endShape.props.h, t),
crop: interpolateCrop(startShape, endShape),
}
}
}
const ImageShape = memo(function ImageShape({ shape }: { shape: TLImageShape }) {
const editor = useEditor()
const { w } = getUncroppedSize(shape.props, shape.props.crop)
const { asset, url } = useImageOrVideoAsset({
shapeId: shape.id,
assetId: shape.props.assetId,
width: w,
})
const prefersReducedMotion = usePrefersReducedMotion()
const [staticFrameSrc, setStaticFrameSrc] = useState('')
const [loadedUrl, setLoadedUrl] = useState<null | string>(null)
const isAnimated = asset && getIsAnimated(editor, asset.id)
useEffect(() => {
if (url && isAnimated) {
const { promise, cancel } = getFirstFrameOfAnimatedImage(url)
promise.then(
(dataUrl) => {
setStaticFrameSrc(dataUrl)
setLoadedUrl(url)
},
() => {
// couldn't decode a first frame; keep showing the animated source
}
)
return () => {
cancel()
}
}
return undefined
}, [editor, isAnimated, prefersReducedMotion, url])
const mimeType = asset && 'mimeType' in asset.props ? asset.props.mimeType : null
const supportsTransparency = mimeType != null && TRANSPARENT_IMAGE_MIMETYPES.includes(mimeType)
const assetSrc = asset && 'src' in asset.props ? asset.props.src : null
useEffect(() => {
if (url && supportsTransparency) {
// Cache under asset.props.src so getGeometry (which only has the asset
// record) can look up the data even when the resolved URL differs.
preloadAlphaData(url, assetSrc ?? undefined)
}
}, [url, supportsTransparency, assetSrc])
const showCropPreview = useValue(
'show crop preview',
() =>
shape.id === editor.getOnlySelectedShapeId() &&
editor.getCroppingShapeId() === shape.id &&
editor.isIn('select.crop'),
[editor, shape.id]
)
// We only want to reduce motion for mimeTypes that have motion
const reduceMotion =
prefersReducedMotion && (asset?.props.mimeType?.includes('video') || isAnimated)
const containerStyle = getCroppedContainerStyle(shape)
const nextSrc = url === loadedUrl ? null : url
const loadedSrc = reduceMotion ? staticFrameSrc : loadedUrl
// This logic path is for when it's broken/missing asset.
if (!url && !asset?.props.src) {
return (
<HTMLContainer
id={shape.id}
style={{
overflow: 'hidden',
width: shape.props.w,
height: shape.props.h,
color: 'var(--tl-color-text-3)',
backgroundColor: 'var(--tl-color-low)',
border: '1px solid var(--tl-color-low-border)',
}}
>
<div
className={classNames('tl-image-container', asset && 'tl-image-container-loading')}
style={containerStyle}
>
{asset ? null : <BrokenAssetIcon />}
</div>
{'url' in shape.props && shape.props.url && <HyperlinkButton url={shape.props.url} />}
</HTMLContainer>
)
}
// We don't set crossOrigin for non-animated images because for Cloudflare we don't currently
// have that set up.
const crossOrigin = isAnimated ? 'anonymous' : undefined
return (
<>
{showCropPreview && loadedSrc && (
<div style={containerStyle}>
<img
className="tl-image"
style={{ ...getFlipStyle(shape), opacity: 0.1 }}
crossOrigin={crossOrigin}
src={loadedSrc}
referrerPolicy="strict-origin-when-cross-origin"
draggable={false}
alt=""
/>
</div>
)}
<HTMLContainer
id={shape.id}
style={{
overflow: 'hidden',
width: shape.props.w,
height: shape.props.h,
borderRadius: shape.props.crop?.isCircle ? '50%' : undefined,
}}
>
<div className={classNames('tl-image-container')} style={containerStyle}>
{/* We have two images: the currently loaded image, and the next image that
we're waiting to load. we keep the loaded image mounted while we're waiting
for the next one by storing the loaded URL in state. We use `key` props with
the src of the image so that when the next image is ready, the previous one will
be unmounted and the next will be shown with the browser having to remount a
fresh image and decoded it again from the cache. */}
{loadedSrc && (
<img
key={loadedSrc}
className="tl-image"
style={getFlipStyle(shape)}
crossOrigin={crossOrigin}
src={loadedSrc}
referrerPolicy="strict-origin-when-cross-origin"
draggable={false}
alt={shape.props.altText}
/>
)}
{nextSrc && (
<img
key={nextSrc}
className="tl-image"
style={getFlipStyle(shape)}
crossOrigin={crossOrigin}
src={nextSrc}
referrerPolicy="strict-origin-when-cross-origin"
draggable={false}
alt={shape.props.altText}
onLoad={() => setLoadedUrl(nextSrc)}
/>
)}
</div>
{shape.props.url && <HyperlinkButton url={shape.props.url} />}
</HTMLContainer>
</>
)
})
function getIsAnimated(editor: Editor, assetId: TLAssetId) {
const asset = assetId ? editor.getAsset(assetId) : undefined
if (!asset) return false
return (
('mimeType' in asset.props && MediaHelpers.isAnimatedImageType(asset?.props.mimeType)) ||
('isAnimated' in asset.props && asset.props.isAnimated)
)
}
/**
* When an image is cropped we need to translate the image to show the portion withing the cropped
* area. We do this by translating the image by the negative of the top left corner of the crop
* area.
*
* @param shape - Shape The image shape for which to get the container style
* @returns - Styles to apply to the image container
*/
function getCroppedContainerStyle(shape: TLImageShape) {
const crop = shape.props.crop
const topLeft = crop?.topLeft
if (!topLeft) {
return {
width: shape.props.w,
height: shape.props.h,
}
}
const { w, h } = getUncroppedSize(shape.props, crop)
const offsetX = -topLeft.x * w
const offsetY = -topLeft.y * h
return {
transform: `translate(${offsetX}px, ${offsetY}px)`,
width: w,
height: h,
}
}
function getFlipStyle(shape: TLImageShape, size?: { width: number; height: number }) {
const { flipX, flipY, crop } = shape.props
if (!flipX && !flipY) return undefined
let cropOffsetX
let cropOffsetY
if (crop) {
// We have to do all this extra math because of the whole transform origin around 0,0
// instead of center in SVG-land, ugh.
const { w, h } = getUncroppedSize(shape.props, crop)
// Find the resulting w/h of the crop in normalized (0-1) coordinates
const cropWidth = crop.bottomRight.x - crop.topLeft.x
const cropHeight = crop.bottomRight.y - crop.topLeft.y
// Map from the normalized crop coordinate space to shape pixel space
cropOffsetX = modulate(crop.topLeft.x, [0, 1 - cropWidth], [0, w - shape.props.w])
cropOffsetY = modulate(crop.topLeft.y, [0, 1 - cropHeight], [0, h - shape.props.h])
}
const scale = `scale(${flipX ? -1 : 1}, ${flipY ? -1 : 1})`
const translate = size
? `translate(${(flipX ? size.width : 0) - (cropOffsetX ? cropOffsetX : 0)}px,
${(flipY ? size.height : 0) - (cropOffsetY ? cropOffsetY : 0)}px)`
: ''
return {
transform: `${translate} ${scale}`,
// in SVG, flipping around the center doesn't work so we use explicit width/height
transformOrigin: size ? '0 0' : 'center center',
}
}
function SvgImage({ shape, src }: { shape: TLImageShape; src: string }) {
const cropClipId = useUniqueSafeId()
const containerStyle = getCroppedContainerStyle(shape)
const crop = shape.props.crop
if (containerStyle.transform && crop) {
const { transform: cropTransform, width, height } = containerStyle
const croppedWidth = (crop.bottomRight.x - crop.topLeft.x) * width
const croppedHeight = (crop.bottomRight.y - crop.topLeft.y) * height
const points = [
new Vec(0, 0),
new Vec(croppedWidth, 0),
new Vec(croppedWidth, croppedHeight),
new Vec(0, croppedHeight),
]
const flip = getFlipStyle(shape, { width, height })
return (
<>
<defs>
<clipPath id={cropClipId}>
{crop.isCircle ? (
<ellipse
cx={croppedWidth / 2}
cy={croppedHeight / 2}
rx={croppedWidth / 2}
ry={croppedHeight / 2}
/>
) : (
<polygon points={points.map((p) => `${p.x},${p.y}`).join(' ')} />
)}
</clipPath>
</defs>
<g clipPath={`url(#${cropClipId})`}>
<image
href={src}
width={width}
height={height}
aria-label={shape.props.altText}
style={flip ? { ...flip } : { transform: cropTransform }}
/>
</g>
</>
)
} else {
return (
<image
href={src}
width={shape.props.w}
height={shape.props.h}
aria-label={shape.props.altText}
style={getFlipStyle(shape, { width: shape.props.w, height: shape.props.h })}
/>
)
}
}
function getFirstFrameOfAnimatedImage(url: string) {
let cancelled = false
// Must settle on failure too: an image that never loads would otherwise leave the
// export awaiting this promise forever.
const promise = new Promise<string>((resolve, reject) => {
const image = Image()
image.onload = () => {
if (cancelled) return
const canvas = getGlobalDocument().createElement('canvas')
canvas.width = image.width
canvas.height = image.height
const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error('Could not get a 2d canvas context'))
return
}
ctx.drawImage(image, 0, 0)
resolve(canvas.toDataURL())
}
image.onerror = () => {
if (cancelled) return
reject(new Error(`Could not load image: ${url}`))
}
image.crossOrigin = 'anonymous'
image.src = url
})
return { promise, cancel: () => (cancelled = true) }
}

View File

@@ -0,0 +1,293 @@
import { PageRecordType, assert } from '@tldraw/editor'
import { TestEditor } from '../../../test/TestEditor'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
})
it('enters the line state', () => {
editor.setCurrentTool('line')
expect(editor.getCurrentToolId()).toBe('line')
editor.expectToBeIn('line.idle')
})
describe('When in the idle state', () => {
it('enters the pointing state and creates a shape on pointer down', () => {
const shapesBefore = editor.getCurrentPageShapes().length
editor.setCurrentTool('line').pointerDown(0, 0, { target: 'canvas' })
const shapesAfter = editor.getCurrentPageShapes().length
expect(shapesAfter).toBe(shapesBefore + 1)
editor.expectToBeIn('line.pointing')
})
it('returns to select on cancel', () => {
editor.setCurrentTool('line')
editor.cancel()
editor.expectToBeIn('select.idle')
})
})
describe('When in the pointing state', () => {
it('createes on pointer up', () => {
const shapesBefore = editor.getCurrentPageShapes().length
editor.setCurrentTool('line').pointerDown(0, 0, { target: 'canvas' }).pointerUp(0, 0)
const shapesAfter = editor.getCurrentPageShapes().length
expect(shapesAfter).toBe(shapesBefore + 1)
expect(editor.getHintingShapeIds().length).toBe(0)
editor.expectToBeIn('line.idle')
})
it('bails on cancel', () => {
const shapesBefore = editor.getCurrentPageShapes().length
editor.setCurrentTool('line').pointerDown(0, 0, { target: 'canvas' }).cancel()
const shapesAfter = editor.getCurrentPageShapes().length
expect(shapesAfter).toBe(shapesBefore)
expect(editor.getHintingShapeIds().length).toBe(0)
editor.expectToBeIn('line.idle')
})
it('enters the dragging state on pointer move', () => {
editor.setCurrentTool('line').pointerDown(0, 0, { target: 'canvas' }).pointerMove(10, 10)
editor.expectToBeIn('select.dragging_handle')
})
})
// This could be moved to dragging_handle
describe('When dragging the line', () => {
it('updates the line on pointer move', () => {
editor.setCurrentTool('line').pointerDown(0, 0, { target: 'canvas' }).pointerMove(10, 10)
const line = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
editor.expectShapeToMatch(line, {
id: line.id,
type: 'line',
x: 0,
y: 0,
props: {
points: {
a1: { id: 'a1', index: 'a1', x: 0, y: 0 },
a2: { id: 'a2', index: 'a2', x: 10, y: 10 },
},
},
})
editor.expectToBeIn('select.dragging_handle')
})
it('returns to select.idle, keeping shape, on pointer up', () => {
const shapesBefore = editor.getCurrentPageShapes().length
editor
.setCurrentTool('line')
.pointerDown(0, 0, { target: 'canvas' })
.pointerMove(10, 10)
.pointerUp(10, 10)
const shapesAfter = editor.getCurrentPageShapes().length
expect(shapesAfter).toBe(shapesBefore + 1)
expect(editor.getHintingShapeIds().length).toBe(0)
editor.expectToBeIn('select.idle')
})
it('returns to line.idle, keeping shape, on pointer up if tool lock is enabled', () => {
editor.updateInstanceState({ isToolLocked: true })
const shapesBefore = editor.getCurrentPageShapes().length
editor
.setCurrentTool('line')
.pointerDown(0, 0, { target: 'canvas' })
.pointerMove(10, 10)
.pointerUp(10, 10)
const shapesAfter = editor.getCurrentPageShapes().length
expect(shapesAfter).toBe(shapesBefore + 1)
expect(editor.getHintingShapeIds().length).toBe(0)
editor.expectToBeIn('line.idle')
})
it('bails on cancel', () => {
const shapesBefore = editor.getCurrentPageShapes().length
editor
.setCurrentTool('line')
.pointerDown(0, 0, { target: 'canvas' })
.pointerMove(10, 10)
.cancel()
const shapesAfter = editor.getCurrentPageShapes().length
expect(shapesAfter).toBe(shapesBefore)
editor.expectToBeIn('line.idle')
})
})
describe('When extending the line with the shift-key in tool-lock mode', () => {
it('extends a line by joining-the-dots', () => {
editor.updateInstanceState({ isToolLocked: true })
editor
.setCurrentTool('line')
.pointerDown(0, 0, { target: 'canvas' })
.pointerMove(10, 10)
.pointerUp(10, 10)
.keyDown('Shift')
.pointerDown(20, 10, { target: 'canvas' })
.pointerUp(20, 10)
const line = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
assert(editor.isShapeOfType(line, 'line'))
expect(Object.keys(line.props.points).length).toBe(3)
})
it('extends a line after a click by shift-click dragging', () => {
editor.updateInstanceState({ isToolLocked: true })
editor
.setCurrentTool('line')
.pointerDown(0, 0, { target: 'canvas' })
.pointerUp(0, 0)
.keyDown('Shift')
.pointerDown(20, 10, { target: 'canvas' })
.pointerMove(30, 10)
.pointerUp(30, 10)
const line = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
assert(editor.isShapeOfType(line, 'line'))
expect(Object.keys(line.props.points).length).toBe(2)
})
it('extends a line by shift-click dragging', () => {
editor.updateInstanceState({ isToolLocked: true })
editor
.setCurrentTool('line')
.pointerDown(0, 0, { target: 'canvas' })
.pointerMove(10, 10)
.pointerUp(10, 10)
.keyDown('Shift')
.pointerDown(20, 10, { target: 'canvas' })
.pointerMove(30, 10)
.pointerUp(30, 10)
const line = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
assert(editor.isShapeOfType(line, 'line'))
expect(Object.keys(line.props.points).length).toBe(3)
})
it('extends a line by shift-clicking even after canceling a pointerdown', () => {
editor.updateInstanceState({ isToolLocked: true })
editor
.setCurrentTool('line')
.pointerDown(0, 0, { target: 'canvas' })
.pointerMove(10, 10)
.pointerUp(10, 10)
.keyDown('Shift')
.pointerDown(20, 10, { target: 'canvas' })
.cancel()
.pointerDown(20, 10, { target: 'canvas' })
.pointerMove(30, 10)
.pointerUp(30, 10)
const line = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
assert(editor.isShapeOfType(line, 'line'))
expect(Object.keys(line.props.points).length).toBe(3)
})
it('extends a line by shift-clicking even after canceling a pointermove', () => {
editor.updateInstanceState({ isToolLocked: true })
editor
.setCurrentTool('line')
.pointerDown(0, 0, { target: 'canvas' })
.pointerMove(10, 10)
.pointerUp(10, 10)
.keyDown('Shift')
.pointerDown(20, 10, { target: 'canvas' })
.pointerUp(20, 10)
.pointerMove(30, 10)
.cancel()
.pointerDown(30, 10, { target: 'canvas' })
.pointerMove(40, 10)
.pointerUp(40, 10)
const line = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
assert(editor.isShapeOfType(line, 'line'))
expect(Object.keys(line.props.points).length).toBe(3)
})
it('does not extend a line on another page after changing page', () => {
// regression for #10400: shift-clicking after a page change added a point
// to the line on the old page instead of starting a new line
editor.updateInstanceState({ isToolLocked: true })
editor
.setCurrentTool('line')
.pointerDown(0, 0, { target: 'canvas' })
.pointerMove(10, 10)
.pointerUp(10, 10)
const lineOnPage1 = editor.getCurrentPageShapes()[0]
assert(editor.isShapeOfType(lineOnPage1, 'line'))
expect(Object.keys(lineOnPage1.props.points).length).toBe(2)
const page2Id = PageRecordType.createId()
editor.createPage({ id: page2Id, name: 'Page 2' })
editor.setCurrentPage(page2Id)
editor.keyDown('Shift').pointerDown(20, 10, { target: 'canvas' }).pointerUp(20, 10)
expect(editor.getCurrentPageShapes()).toHaveLength(1)
const lineOnPage2 = editor.getCurrentPageShapes()[0]
assert(editor.isShapeOfType(lineOnPage2, 'line'))
expect(lineOnPage2.id).not.toBe(lineOnPage1.id)
expect(editor.isShapeInPage(lineOnPage2, page2Id)).toBe(true)
// The line on the first page is untouched
expect(editor.getShape(lineOnPage1.id)).toEqual(lineOnPage1)
})
})
describe('minimum bend distance for shift-clicked handles is measured in screen space', () => {
// regression for #7661: minimum bend distance for shift-clicked handles is measured in screen space
// Creates a two-point line whose end handle sits at screen point (200, 200), the
// camera being static so the handle's screen position matches the pointer-up location.
function createTwoPointLine() {
editor
.setCurrentTool('line')
.pointerDown(100, 100, { target: 'canvas' })
.pointerMove(200, 200)
.pointerUp(200, 200)
}
beforeEach(() => {
editor.updateInstanceState({ isToolLocked: true })
})
it('adds a point when the click is more than 2 screen pixels away while zoomed in', () => {
editor.setCamera({ x: 0, y: 0, z: 4 })
createTwoPointLine()
// 4 screen pixels from the end handle: past the 2px screen threshold, but only 1
// page unit — under the old page-space threshold this would have merged.
editor.keyDown('Shift').pointerDown(204, 200, { target: 'canvas' }).pointerUp(204, 200)
const line = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
assert(editor.isShapeOfType(line, 'line'))
expect(Object.keys(line.props.points).length).toBe(3)
})
it('merges the click when within 2 screen pixels while zoomed out', () => {
editor.setCamera({ x: 0, y: 0, z: 0.25 })
createTwoPointLine()
// 1 screen pixel from the end handle: within the 2px screen threshold, but 4 page
// units — under the old page-space threshold this would have added a point.
editor.keyDown('Shift').pointerDown(201, 200, { target: 'canvas' }).pointerUp(201, 200)
const line = editor.getCurrentPageShapes()[editor.getCurrentPageShapes().length - 1]
assert(editor.isShapeOfType(line, 'line'))
expect(Object.keys(line.props.points).length).toBe(2)
})
})
describe('tool lock bug', () => {
it('works as expected when tool lock is on but shift is off', () => {
expect(editor.getCurrentPageShapes().length).toBe(0)
editor
.updateInstanceState({ isToolLocked: true })
.setCurrentTool('line')
.pointerDown(0, 0)
.pointerMove(10, 10)
.pointerUp(10, 10)
.pointerDown(100, 100)
.pointerMove(110, 110)
.pointerUp(100, 100)
.pointerUp(120, 110)
expect(editor.getCurrentPageShapes().length).toBe(2)
})
})

View File

@@ -0,0 +1,14 @@
import { StateNode, TLStateNodeConstructor } from '@tldraw/editor'
import { Idle } from './toolStates/Idle'
import { Pointing } from './toolStates/Pointing'
/** @public */
export class LineShapeTool extends StateNode {
static override id = 'line'
static override initial = 'idle'
static override children(): TLStateNodeConstructor[] {
return [Idle, Pointing]
}
override shapeType = 'line'
}

View File

@@ -0,0 +1,531 @@
import {
IndexKey,
TLGeoShape,
TLLineShape,
createShapeId,
mockUniqueId,
sortByIndex,
structuredClone,
} from '@tldraw/editor'
import { vi } from 'vitest'
import { TestEditor } from '../../../test/TestEditor'
let nextId = 0
mockUniqueId(() => 'id' + nextId++)
let editor: TestEditor
const id = createShapeId('line1')
vi.useFakeTimers()
beforeEach(() => {
editor = new TestEditor()
editor
.selectAll()
.deleteShapes(editor.getSelectedShapeIds())
.createShapes([
{
id: id,
type: 'line',
x: 150,
y: 150,
props: {
points: {
a1: { id: 'a1', index: 'a1' as IndexKey, x: 0, y: 0 },
a2: { id: 'a2', index: 'a2' as IndexKey, x: 100, y: 100 },
},
},
},
])
})
const getShape = () => editor.getShape<TLLineShape>(id)!
const getHandles = () => editor.getShapeHandles<TLLineShape>(id)!
describe('Translating', () => {
it('updates the line', () => {
editor.select(id)
editor.pointerDown(25, 25, { target: 'shape', shape: getShape() })
editor.pointerMove(50, 50) // Move shape by 25, 25
editor.expectShapeToMatch<TLLineShape>({
id: id,
x: 175,
y: 175,
})
})
it('updates the line when rotated', () => {
editor.select(id)
const shape = getShape()
editor.updateShape({ ...shape, rotation: Math.PI / 2 })
editor.pointerDown(250, 250, { target: 'shape', shape: shape })
editor.pointerMove(300, 400) // Move shape by 50, 150
editor.expectShapeToMatch<TLLineShape>({
id: id,
x: 200,
y: 300,
})
})
})
describe('Mid-point handles', () => {
it('create new handle', () => {
editor.select(id)
editor.pointerDown(200, 200, {
target: 'handle',
shape: getShape(),
handle: getHandles()[1],
})
editor.pointerMove(349, 349).pointerMove(350, 350) // Move handle by 150, 150
editor.pointerUp()
editor.expectShapeToMatch({
id: id,
props: {
points: {
a1: { id: 'a1', index: 'a1', x: 0, y: 0 },
a1V: { id: 'a1V', index: 'a1V', x: 200, y: 200 },
a2: { id: 'a2', index: 'a2', x: 100, y: 100 },
},
},
})
})
it('allows snapping with mid-point handles', () => {
editor.createShapes([
{ id: createShapeId(), type: 'geo', x: 200, y: 200, props: { w: 100, h: 100 } },
])
editor.select(id)
editor
.pointerDown(200, 200, {
target: 'handle',
shape: getShape(),
handle: getHandles()[1],
})
.pointerMove(198, 230, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(editor.getShapeHandles(id)).toHaveLength(5) // 3 real + 2
const points = editor.getShape<TLLineShape>(id)!.props.points
expect(points).toStrictEqual({
a1: { id: 'a1', index: 'a1', x: 0, y: 0 },
a1V: { id: 'a1V', index: 'a1V', x: 50, y: 80 },
a2: { id: 'a2', index: 'a2', x: 100, y: 100 },
})
})
it('allows snapping with created mid-point handles', () => {
editor.createShapes([
{ id: createShapeId(), type: 'geo', x: 200, y: 200, props: { w: 100, h: 100 } },
])
// 2 actual points, plus 1 mid-points:
expect(getHandles()).toHaveLength(3)
// use a mid-point handle to create a new handle
editor
.select(id)
.pointerDown(200, 200, {
target: 'handle',
shape: getShape(),
handle: getHandles().sort(sortByIndex)[1]!,
})
.pointerMove(230, 200)
.pointerMove(240, 200)
.pointerMove(200, 200)
.pointerUp()
// 3 actual points, plus 2 mid-points:
expect(getHandles()).toHaveLength(5)
// now, try dragging the newly created handle. it should still snap:
editor
.pointerDown(200, 200, {
target: 'handle',
shape: getShape(),
handle: getHandles().sort(sortByIndex)[2],
})
.pointerMove(198, 230, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(editor.getShapeHandles(id)).toHaveLength(5) // 3 real + 2
const points = editor.getShape<TLLineShape>(id)!.props.points
expect(points).toStrictEqual({
a1: { id: 'a1', index: 'a1', x: 0, y: 0 },
a1V: { id: 'a1V', index: 'a1V', x: 50, y: 80 },
a2: { id: 'a2', index: 'a2', x: 100, y: 100 },
})
})
})
describe('Snapping', () => {
beforeEach(() => {
editor.updateShape({
id: id,
type: 'line',
props: {
points: {
a1: { id: 'a1', index: 'a1' as IndexKey, x: 0, y: 0 },
a2: { id: 'a2', index: 'a2' as IndexKey, x: 100, y: 0 },
a3: { id: 'a3', index: 'a3' as IndexKey, x: 100, y: 100 },
a4: { id: 'a4', index: 'a4' as IndexKey, x: 0, y: 100 },
},
},
})
})
it('snaps endpoints to itself', () => {
editor.select(id)
editor
.pointerDown(0, 0, { target: 'handle', shape: getShape(), handle: getHandles()[0] })
.pointerMove(50, 95, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
editor.expectShapeToMatch({
id: id,
props: {
points: {
a1: { id: 'a1', index: 'a1', x: 50, y: 100 },
a2: { id: 'a2', index: 'a2', x: 100, y: 0 },
a3: { id: 'a3', index: 'a3', x: 100, y: 100 },
a4: { id: 'a4', index: 'a4', x: 0, y: 100 },
},
},
})
})
it('snaps endpoints to its vertices', () => {
editor.select(id)
editor
.pointerDown(0, 0, { target: 'handle', shape: getShape(), handle: getHandles()[0] })
.pointerMove(3, 95, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
editor.expectShapeToMatch({
id: id,
props: {
points: {
a1: { id: 'a1', index: 'a1', x: 0, y: 100 },
a2: { id: 'a2', index: 'a2', x: 100, y: 0 },
a3: { id: 'a3', index: 'a3', x: 100, y: 100 },
a4: { id: 'a4', index: 'a4', x: 0, y: 100 },
},
},
})
})
it("doesn't snap to the segment of the current handle", () => {
editor.select(id)
editor
.pointerDown(0, 0, { target: 'handle', shape: getShape(), handle: getHandles()[0] })
.pointerMove(5, 2, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(0)
editor.expectShapeToMatch({
id: id,
props: {
points: {
a1: { id: 'a1', index: 'a1', x: 5, y: 2 },
a2: { id: 'a2', index: 'a2', x: 100, y: 0 },
a3: { id: 'a3', index: 'a3', x: 100, y: 100 },
a4: { id: 'a4', index: 'a4', x: 0, y: 100 },
},
},
})
})
it('snaps to vertices on other line shapes', () => {
editor.createShapes([
{
id: createShapeId(),
type: 'line',
x: 150,
y: 150,
props: {
points: {
a1: { id: 'a1', index: 'a1' as IndexKey, x: 200, y: 0 },
a2: { id: 'a2', index: 'a2' as IndexKey, x: 300, y: 0 },
},
},
},
])
editor.select(id)
const handle = getHandles()[0]
editor
.pointerDown(handle.x, handle.y, { target: 'handle', shape: getShape(), handle })
.pointerMove(205, 1, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
editor.expectShapeToMatch({
id: id,
props: {
points: {
a1: { id: 'a1', index: 'a1', x: 200, y: 0 },
a2: { id: 'a2', index: 'a2', x: 100, y: 0 },
a3: { id: 'a3', index: 'a3', x: 100, y: 100 },
a4: { id: 'a4', index: 'a4', x: 0, y: 100 },
},
},
})
})
})
describe('Misc', () => {
it('preserves handle positions on spline type change', () => {
editor.select(id)
const shape = getShape()
const prevPoints = structuredClone(shape.props.points)
editor.updateShapes([
{
...shape,
props: {
spline: 'cubic',
},
},
])
editor.expectShapeToMatch<TLLineShape>({
id,
props: {
spline: 'cubic',
points: prevPoints,
},
})
})
it('resizes', () => {
editor.select(id)
editor
.pointerDown(150, 0, { target: 'selection', handle: 'bottom' })
.pointerMove(150, 600) // Resize shape by 0, 600
.expectToBeIn('select.resizing')
expect(editor.getShape(id)!).toMatchSnapshot('line shape after resize')
})
it('nudges', () => {
editor.select(id)
editor.nudgeShapes(editor.getSelectedShapeIds(), { x: 1, y: 0 })
editor.expectShapeToMatch<TLLineShape>({
id: id,
x: 151,
y: 150,
})
editor.nudgeShapes(editor.getSelectedShapeIds(), { x: 0, y: 10 })
editor.expectShapeToMatch<TLLineShape>({
id: id,
x: 151,
y: 160,
})
})
it('align', () => {
const boxID = createShapeId('box1')
editor.createShapes([{ id: boxID, type: 'geo', x: 500, y: 150, props: { w: 100, h: 50 } }])
const box = editor.getShape<TLGeoShape>(boxID)!
const line = getShape()
editor.select(boxID, id)
expect(editor.getShapePageBounds(box)!.maxX).not.toEqual(editor.getShapePageBounds(line)!.maxX)
editor.alignShapes(editor.getSelectedShapeIds(), 'right')
vi.advanceTimersByTime(1000)
expect(editor.getShapePageBounds(box)!.maxX).toEqual(editor.getShapePageBounds(line)!.maxX)
expect(editor.getShapePageBounds(box)!.maxY).not.toEqual(editor.getShapePageBounds(line)!.maxY)
editor.alignShapes(editor.getSelectedShapeIds(), 'bottom')
vi.advanceTimersByTime(1000)
expect(editor.getShapePageBounds(box)!.maxY).toEqual(editor.getShapePageBounds(line)!.maxY)
})
it('duplicates', () => {
editor.select(id)
editor.keyDown('Alt').pointerDown(25, 25, { target: 'shape', shape: getShape() })
editor.pointerMove(50, 50) // Move shape by 25, 25
editor.pointerUp().keyUp('Alt')
expect(Array.from(editor.getCurrentPageShapeIds().values()).length).toEqual(2)
})
it('duplicates a zero-length line without mutating the source', () => {
// onBeforeCreate nudges on create, so collapse the points afterwards
editor.updateShapes([
{
id,
type: 'line',
props: {
points: {
a1: { id: 'a1', index: 'a1' as IndexKey, x: 5, y: 5 },
a2: { id: 'a2', index: 'a2' as IndexKey, x: 5, y: 5 },
},
},
},
])
const before = structuredClone(getShape().props.points)
expect(() => editor.duplicateShapes([id])).not.toThrow()
expect(getShape().props.points).toEqual(before)
const duplicate = editor.getCurrentPageShapes().find((s) => s.id !== id) as TLLineShape
expect(duplicate.props.points.a2).toMatchObject({ x: 5.1, y: 5.1 })
})
it('deletes', () => {
editor.select(id)
editor.keyDown('Alt').pointerDown(25, 25, { target: 'shape', shape: getShape() })
editor.pointerMove(50, 50) // Move shape by 25, 25
editor.pointerUp().keyUp('Alt')
let ids = Array.from(editor.getCurrentPageShapeIds().values())
expect(ids.length).toEqual(2)
const duplicate = ids.filter((i) => i !== id)[0]
editor.select(duplicate)
editor.deleteShapes(editor.getSelectedShapeIds())
ids = Array.from(editor.getCurrentPageShapeIds().values())
expect(ids.length).toEqual(1)
expect(ids[0]).toEqual(id)
})
})
describe('Line points: id-mapped object with a decoupled index', () => {
// `points` is an "id-mapped object": each entry's key === its `id`. The `index`
// is a separate fractional index used only for ordering. A vertex handle's id is
// the point's id (not its index), so a handle round-trips to the right point even
// when key !== index. Malformed data (two points sharing an index) is tolerated on
// the read path rather than crashing. See #9267.
it('drags a point in place on a line whose keys differ from their indices', () => {
// valid: key === id ('a'/'b'), but the index is decoupled ('a1'/'a2')
const id = createShapeId('line-divergent')
editor.createShapes([
{
id,
type: 'line',
x: 100,
y: 100,
props: {
points: {
a: { id: 'a', index: 'a1' as IndexKey, x: 0, y: 0 },
b: { id: 'b', index: 'a2' as IndexKey, x: 100, y: 0 },
},
},
},
])
editor.select(id)
// the endpoint handle's id is the point's id ('b'), not its index ('a2')
const endHandle = getHandlesFor(id).find((h) => h.id === 'b')!
const inParent = editor.getShapePageTransform(id)!.applyToPoint(endHandle)
editor
.pointerMove(inParent.x, inParent.y)
.pointerDown(inParent.x, inParent.y, {
target: 'handle',
shape: editor.getShape(id),
handle: endHandle,
})
.pointerMove(inParent.x + 100, inParent.y)
.pointerUp()
// the dragged point updated in place — still two points, keyed as before
expect(editor.getShape<TLLineShape>(id)!.props.points).toStrictEqual({
a: { id: 'a', index: 'a1', x: 0, y: 0 },
b: { id: 'b', index: 'a2', x: 200, y: 0 },
})
expect(() => editor.getShapeHandles(id)).not.toThrow()
})
it('tolerates a line with duplicate indices on read without crashing', () => {
// malformed: points 'b' and 'a2' share index 'a2'. The store keeps it as-is;
// linePointsToArray dedupes on read so getHandles never throws "a2 >= a2".
const id = createShapeId('line-dup-index')
editor.createShapes([
{
id,
type: 'line',
x: 100,
y: 100,
props: {
points: {
a: { id: 'a', index: 'a1' as IndexKey, x: 0, y: 0 },
b: { id: 'b', index: 'a2' as IndexKey, x: 100, y: 0 },
a2: { id: 'a2', index: 'a2' as IndexKey, x: 200, y: 0 },
},
},
},
])
// stored as-is (no repair) — all three points are still there
expect(Object.keys(editor.getShape<TLLineShape>(id)!.props.points)).toHaveLength(3)
// but reading handles does not throw; the duplicate index is collapsed on read
expect(() => editor.getShapeHandles(id)).not.toThrow()
const vertexHandles = getHandlesFor(id).filter((h) => h.type === 'vertex')
expect(vertexHandles).toHaveLength(2)
})
it('keeps every point while animating to a line with more points (#9397)', () => {
// getInterpolatedProps used to clone extra start points with duplicate indices, which
// linePointsToArray collapsed, so the line lost points until the animation ended.
const id = createShapeId('line-animate')
editor.createShapes([
{
id,
type: 'line',
x: 0,
y: 0,
props: {
points: {
a1: { id: 'a1', index: 'a1' as IndexKey, x: 0, y: 0 },
a2: { id: 'a2', index: 'a2' as IndexKey, x: 100, y: 0 },
},
},
},
])
editor.animateShape(
{
id,
type: 'line',
props: {
points: {
a1: { id: 'a1', index: 'a1' as IndexKey, x: 0, y: 0 },
a2: { id: 'a2', index: 'a2' as IndexKey, x: 50, y: 0 },
a3: { id: 'a3', index: 'a3' as IndexKey, x: 100, y: 0 },
},
},
},
{ animation: { duration: 200 } }
)
for (let i = 0; i < 12; i++) {
editor.emit('tick', 16)
expect(getHandlesFor(id).filter((h) => h.type === 'vertex')).toHaveLength(3)
}
})
})
function getHandlesFor(shapeId: TLLineShape['id']) {
return editor.getShapeHandles<TLLineShape>(shapeId)!
}

View File

@@ -0,0 +1,424 @@
// oxlint-disable typescript/no-empty-object-type
import {
Group2d,
HandleSnapGeometry,
SVGContainer,
ShapeUtil,
SvgExportContext,
TLHandle,
TLHandleDragInfo,
TLLineShape,
TLLineShapePoint,
TLResizeInfo,
Vec,
WeakCache,
ZERO_INDEX_KEY,
assert,
getColorValue,
getIndexAbove,
getIndexBetween,
getIndices,
lerp,
lineShapeMigrations,
lineShapeProps,
mapObjectMapValues,
maybeSnapToGrid,
sortByIndex,
useColorMode,
} from '@tldraw/editor'
import { STROKE_SIZES } from '../shared/default-shape-constants'
import { ShapeOptionsWithDisplayValues, getDisplayValues } from '../shared/getDisplayValues'
import { PathBuilder, PathBuilderGeometry2d } from '../shared/PathBuilder'
const handlesCache = new WeakCache<TLLineShape['props'], TLHandle[]>()
/** @public */
export interface LineShapeUtilDisplayValues {
strokeColor: string
strokeWidth: number
}
/** @public */
export interface LineShapeOptions extends ShapeOptionsWithDisplayValues<
TLLineShape,
LineShapeUtilDisplayValues
> {}
/** @public */
export class LineShapeUtil extends ShapeUtil<TLLineShape> {
static override type = 'line' as const
static override props = lineShapeProps
static override migrations = lineShapeMigrations
override options: LineShapeOptions = {
getDefaultDisplayValues(_editor, shape, theme, colorMode): LineShapeUtilDisplayValues {
const { color, size } = shape.props
return {
strokeColor: getColorValue(theme.colors[colorMode], color, 'solid'),
strokeWidth: theme.strokeWidth * STROKE_SIZES[size],
}
},
getCustomDisplayValues(): Partial<LineShapeUtilDisplayValues> {
return {}
},
}
override hideResizeHandles(shape: TLLineShape) {
return true
}
override hideRotateHandle(shape: TLLineShape) {
return true
}
override hideSelectionBoundsFg(shape: TLLineShape) {
return true
}
override hideSelectionBoundsBg(shape: TLLineShape) {
return true
}
override hideInMinimap() {
return true
}
override getDefaultProps(): TLLineShape['props'] {
const [start, end] = getIndices(2)
return {
dash: 'draw',
size: 'm',
color: 'black',
spline: 'line',
points: {
[start]: { id: start, index: start, x: 0, y: 0 },
[end]: { id: end, index: end, x: 0.1, y: 0.1 },
},
scale: 1,
}
}
getGeometry(shape: TLLineShape) {
// todo: should we have min size?
const geometry = getPathForLineShape(shape).toGeometry()
assert(geometry instanceof PathBuilderGeometry2d)
return geometry
}
override getHandles(shape: TLLineShape) {
return handlesCache.get(shape.props, () => {
const spline = this.getGeometry(shape)
const points = linePointsToArray(shape)
const results: TLHandle[] = points.map((point) => ({
...point,
// A vertex handle's id is the point's stable id (its map key), not its
// index. The index is only ever used for ordering (sortByIndex) and for
// computing the create-handle positions below.
id: point.id,
type: 'vertex',
canSnap: true,
}))
for (let i = 0; i < points.length - 1; i++) {
const index = getIndexBetween(points[i].index, points[i + 1].index)
const segment = spline.getSegments()[i]
const point = segment.interpolateAlongEdge(0.5)
results.push({
id: index,
type: 'create',
index,
x: point.x,
y: point.y,
canSnap: true,
})
}
return results.sort(sortByIndex)
})
}
// Events
override onResize(shape: TLLineShape, info: TLResizeInfo<TLLineShape>) {
const { scaleX, scaleY } = info
return {
props: {
points: mapObjectMapValues(shape.props.points, (_, { id, index, x, y }) => ({
id,
index,
x: x * scaleX,
y: y * scaleY,
})),
},
}
}
override onBeforeCreate(next: TLLineShape): void | TLLineShape {
const {
props: { points },
} = next
const pointKeys = Object.keys(points)
if (pointKeys.length < 2) {
return
}
const firstPoint = points[pointKeys[0]]
const allSame = pointKeys.every((key) => {
const point = points[key]
return point.x === firstPoint.x && point.y === firstPoint.y
})
if (allSame) {
// Copy rather than mutate: createShapes spreads the partial's props shallowly, so
// `points` is the caller's object — on duplicate, the frozen source record's.
const lastKey = pointKeys[pointKeys.length - 1]
return {
...next,
props: {
...next.props,
points: {
...points,
[lastKey]: {
...points[lastKey],
x: points[lastKey].x + 0.1,
y: points[lastKey].y + 0.1,
},
},
},
}
}
return
}
override onHandleDrag(shape: TLLineShape, { handle }: TLHandleDragInfo<TLLineShape>) {
const newPoint = maybeSnapToGrid(new Vec(handle.x, handle.y), this.editor)
// handle.id is the point's key (== its id), so we update the point in place.
return {
...shape,
props: {
...shape.props,
points: {
...shape.props.points,
[handle.id]: { id: handle.id, index: handle.index, x: newPoint.x, y: newPoint.y },
},
},
}
}
override onHandleDragStart(shape: TLLineShape, { handle }: TLHandleDragInfo<TLLineShape>) {
// For line shapes, if we're dragging a "create" handle, then
// create a new vertex handle at that point; and make this handle
// the handle that we're dragging.
if (handle.type === 'create') {
return {
...shape,
props: {
...shape.props,
points: {
...shape.props.points,
[handle.index]: { id: handle.index, index: handle.index, x: handle.x, y: handle.y },
},
},
}
}
return
}
component(shape: TLLineShape) {
// eslint-disable-next-line react-hooks/rules-of-hooks
const colorMode = useColorMode()
const dv = getDisplayValues(this, shape, colorMode)
return (
<SVGContainer style={{ minWidth: 50, minHeight: 50 }}>
<LineShapeSvg shape={shape} strokeColor={dv.strokeColor} strokeWidth={dv.strokeWidth} />
</SVGContainer>
)
}
override getIndicatorPath(shape: TLLineShape): Path2D {
const strokeWidth = getDisplayValues(this, shape).strokeWidth * shape.props.scale
const path = getPathForLineShape(shape)
const { dash } = shape.props
return path.toPath2D({
style: dash === 'draw' ? 'draw' : 'solid',
strokeWidth: 1,
passes: 1,
randomSeed: shape.id,
offset: 0,
roundness: strokeWidth * 2,
})
}
override toSvg(shape: TLLineShape, ctx: SvgExportContext) {
const dv = getDisplayValues(this, shape, ctx.colorMode)
return (
<LineShapeSvg
shouldScale
shape={shape}
strokeColor={dv.strokeColor}
strokeWidth={dv.strokeWidth}
/>
)
}
override getHandleSnapGeometry(shape: TLLineShape): HandleSnapGeometry {
const points = linePointsToArray(shape)
return {
points,
getSelfSnapPoints: (handle) => {
const index = this.getHandles(shape)
.filter((h) => h.type === 'vertex')
.findIndex((h) => h.id === handle.id)!
// We want to skip the current and adjacent handles
return points.filter((_, i) => Math.abs(i - index) > 1).map(Vec.From)
},
getSelfSnapOutline: (handle) => {
// We want to skip the segments that include the handle, so
// find the index of the handle that shares the same index property
// as the initial dragging handle; this catches a quirk of create handles
const index = this.getHandles(shape)
.filter((h) => h.type === 'vertex')
.findIndex((h) => h.id === handle.id)!
// Get all the outline segments from the shape that don't include the handle
const segments = this.getGeometry(shape)
.getSegments()
.filter((_, i) => i !== index - 1 && i !== index)
if (!segments.length) return null
return new Group2d({ children: segments })
},
}
}
override getInterpolatedProps(
startShape: TLLineShape,
endShape: TLLineShape,
t: number
): TLLineShape['props'] {
const startPoints = linePointsToArray(startShape)
const endPoints = linePointsToArray(endShape)
const pointsToUseStart: TLLineShapePoint[] = []
const pointsToUseEnd: TLLineShapePoint[] = []
let index = ZERO_INDEX_KEY
if (startPoints.length > endPoints.length) {
// we'll need to expand points
for (let i = 0; i < startPoints.length; i++) {
pointsToUseStart[i] = { ...startPoints[i] }
if (endPoints[i] === undefined) {
pointsToUseEnd[i] = { ...endPoints[endPoints.length - 1], id: index }
} else {
pointsToUseEnd[i] = { ...endPoints[i], id: index }
}
index = getIndexAbove(index)
}
} else if (endPoints.length > startPoints.length) {
// we'll need to converge points. Clones of the last start point would share its index
// and get dropped by linePointsToArray, so hand out fresh indices to every start point.
for (let i = 0; i < endPoints.length; i++) {
pointsToUseEnd[i] = { ...endPoints[i] }
if (startPoints[i] === undefined) {
pointsToUseStart[i] = {
...startPoints[startPoints.length - 1],
id: index,
index,
}
} else {
pointsToUseStart[i] = { ...startPoints[i], id: index, index }
}
index = getIndexAbove(index)
}
} else {
// noop, easy
for (let i = 0; i < endPoints.length; i++) {
pointsToUseStart[i] = startPoints[i]
pointsToUseEnd[i] = endPoints[i]
}
}
return {
...(t > 0.5 ? endShape.props : startShape.props),
points: Object.fromEntries(
pointsToUseStart.map((point, i) => {
const endPoint = pointsToUseEnd[i]
return [
point.id,
{
...point,
x: lerp(point.x, endPoint.x, t),
y: lerp(point.y, endPoint.y, t),
},
]
})
),
scale: lerp(startShape.props.scale, endShape.props.scale, t),
}
}
}
function linePointsToArray(shape: TLLineShape) {
const sorted = Object.values(shape.props.points).sort(sortByIndex)
// Tolerate malformed data where two points share an index: keep only the first
// point at each index, so getHandles' getIndexBetween never sees equal adjacent
// indices (which would throw "a2 >= a2"). A no-op for well-formed lines.
return sorted.filter((point, i) => i === 0 || point.index !== sorted[i - 1].index)
}
const pathCache = new WeakCache<TLLineShape, PathBuilder>()
function getPathForLineShape(shape: TLLineShape): PathBuilder {
return pathCache.get(shape, () => {
const points = linePointsToArray(shape).map(Vec.From)
// A line needs two points to have a path. onBeforeCreate lets 0/1-point lines through
// (they can also arrive via updateShape or a file), and without this every geometry
// lookup on the page would throw — getShapeAtPoint included.
if (points.length === 0) points.push(new Vec(0, 0))
if (points.length === 1) points.push(points[0].clone().addXY(0.1, 0.1))
switch (shape.props.spline) {
case 'cubic': {
return PathBuilder.cubicSplineThroughPoints(points, { endOffsets: 0 })
}
case 'line': {
return PathBuilder.lineThroughPoints(points, { endOffsets: 0 })
}
}
})
}
function LineShapeSvg({
shape,
shouldScale = false,
forceSolid = false,
strokeColor,
strokeWidth: baseStrokeWidth,
}: {
shape: TLLineShape
shouldScale?: boolean
forceSolid?: boolean
strokeColor: string
strokeWidth: number
}) {
const path = getPathForLineShape(shape)
const { dash } = shape.props
const scaleFactor = 1 / shape.props.scale
const scale = shouldScale ? scaleFactor : 1
const strokeWidth = baseStrokeWidth * shape.props.scale
return path.toSvg({
style: dash,
strokeWidth,
forceSolid,
randomSeed: shape.id,
props: {
transform: `scale(${scale})`,
stroke: strokeColor,
fill: 'none',
},
})
}

View File

@@ -0,0 +1,38 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Misc > resizes > line shape after resize 1`] = `
{
"id": "shape:line1",
"index": "a1",
"isLocked": false,
"meta": {},
"opacity": 1,
"parentId": "page:page",
"props": {
"color": "black",
"dash": "draw",
"points": {
"a1": {
"id": "a1",
"index": "a1",
"x": 0,
"y": 0,
},
"a2": {
"id": "a2",
"index": "a2",
"x": 100,
"y": 700,
},
},
"scale": 1,
"size": "m",
"spline": "line",
},
"rotation": 0,
"type": "line",
"typeName": "shape",
"x": 150,
"y": 150,
}
`;

View File

@@ -0,0 +1,20 @@
import { StateNode, TLShapeId } from '@tldraw/editor'
export class Idle extends StateNode {
static override id = 'idle'
private shapeId = '' as TLShapeId
override onEnter(info: { shapeId: TLShapeId }) {
this.shapeId = info.shapeId
this.editor.setCursor({ type: 'cross', rotation: 0 })
}
override onPointerDown() {
this.parent.transition('pointing', { shapeId: this.shapeId })
}
override onCancel() {
this.editor.setCurrentTool('select')
}
}

View File

@@ -0,0 +1,172 @@
import {
StateNode,
TLLineShape,
TLShapeId,
Vec,
createShapeId,
getIndexAbove,
last,
maybeSnapToGrid,
sortByIndex,
structuredClone,
} from '@tldraw/editor'
// Minimum distance, in screen pixels, between two shift-clicked handles before we merge them
const MINIMUM_DISTANCE_BETWEEN_SHIFT_CLICKED_HANDLES = 2
export class Pointing extends StateNode {
static override id = 'pointing'
shape = {} as TLLineShape
markId: string | undefined
override onEnter(info: { shapeId?: TLShapeId }) {
const { inputs } = this.editor
const currentPagePoint = inputs.getCurrentPagePoint()
this.markId = undefined
// Previously created line shape that we might be extending
const shape = info.shapeId && this.editor.getShape<TLLineShape>(info.shapeId)
// The shape id carried through idle survives a page change; extending it from
// another page would silently add a point to content the user can't see (#10400)
if (shape && inputs.getShiftKey() && this.editor.isShapeInPage(shape)) {
// Extending a previous shape
this.markId = this.editor.markHistoryStoppingPoint(`creating_line:${shape.id}`)
this.shape = shape
const handles = this.editor.getShapeHandles(this.shape)
if (!handles) return
const vertexHandles = handles.filter((h) => h.type === 'vertex').sort(sortByIndex)
const endHandle = vertexHandles[vertexHandles.length - 1]
const prevEndHandle = vertexHandles[vertexHandles.length - 2]
// nudge the point slightly to avoid zero-length lines
const nudgedPoint = this.editor
.getPointInShapeSpace(this.shape, currentPagePoint)
.addXY(0.1, 0.1)
const nextPoint = maybeSnapToGrid(nudgedPoint, this.editor)
const points = structuredClone(this.shape.props.points)
// compare in screen space
const minDistance =
MINIMUM_DISTANCE_BETWEEN_SHIFT_CLICKED_HANDLES / this.editor.getZoomLevel()
if (
Vec.DistMin(endHandle, prevEndHandle, minDistance) ||
Vec.DistMin(nextPoint, endHandle, minDistance)
) {
// Don't add a new point if the distance between the last two points is too small
points[endHandle.id] = {
id: endHandle.id,
index: endHandle.index,
x: nextPoint.x,
y: nextPoint.y,
}
} else {
// Add a new point
const nextIndex = getIndexAbove(endHandle.index)
points[nextIndex] = {
id: nextIndex,
index: nextIndex,
x: nextPoint.x,
y: nextPoint.y,
}
}
this.editor.updateShapes([
{
id: this.shape.id,
type: this.shape.type,
props: {
points,
},
},
])
} else {
const id = createShapeId()
this.markId = this.editor.markHistoryStoppingPoint(`creating_line:${id}`)
const newPoint = maybeSnapToGrid(currentPagePoint, this.editor)
this.editor.createShapes([
{
id,
type: 'line',
x: newPoint.x,
y: newPoint.y,
props: {
scale: this.editor.getResizeScaleFactor(),
},
},
])
if (!this.editor.getShape(id)) {
this.cancel()
return
}
this.editor.select(id)
this.shape = this.editor.getShape(id)!
}
}
override onPointerMove() {
if (!this.shape) return
if (this.editor.inputs.getIsDragging()) {
const handles = this.editor.getShapeHandles(this.shape)
if (!handles) {
if (this.markId) this.editor.bailToMark(this.markId)
throw Error('No handles found')
}
const lastHandle = last(handles)!
this.editor.setCurrentTool('select.dragging_handle', {
shape: this.shape,
isCreating: true,
creatingMarkId: this.markId,
// remove the offset that we added to the handle when we created it
handle: { ...lastHandle, x: lastHandle.x - 0.1, y: lastHandle.y - 0.1 },
onInteractionEnd: 'line',
})
}
}
override onPointerUp() {
this.complete()
}
override onLongPress() {
// On a touch (coarse pointer) long-press, cancel the pending shape so it leaves nothing behind.
if (this.editor.getInstanceState().isCoarsePointer) this.cancel()
}
override onCancel() {
this.cancel()
}
override onComplete() {
this.complete()
}
override onInterrupt() {
this.parent.transition('idle')
if (this.markId) this.editor.bailToMark(this.markId)
this.editor.snaps.clearIndicators()
}
complete() {
this.parent.transition('idle', { shapeId: this.shape.id })
this.editor.snaps.clearIndicators()
}
cancel() {
if (this.markId) this.editor.bailToMark(this.markId)
this.parent.transition('idle', { shapeId: this.shape.id })
this.editor.snaps.clearIndicators()
}
}

View File

@@ -0,0 +1,84 @@
import { TLNoteShape } from '@tldraw/editor'
import { ComponentType } from 'react'
import { TldrawUiTooltip } from '../../ui/components/primitives/TldrawUiTooltip'
/** @public */
export interface TLNoteShapeAttributionProps {
/** The note shape the attribution belongs to. */
shape: TLNoteShape
/** The full display name of the user who last edited the note's text. */
name: string
/** The first name (the part shown in the badge). */
firstName: string
/** The label color to use for the badge. */
color: string
/** The note's scale. Only meaningful for the `'canvas'` variant. */
scale: number
/**
* Where the attribution is being rendered. The `'canvas'` variant renders the interactive badge
* (with a tooltip); the `'export'` variant renders static markup suitable for an SVG/PNG export.
*/
variant: 'canvas' | 'export'
}
/**
* The default attribution badge shown in the corner of a note shape — the display name of the user
* who last edited the note's text.
*
* Override it (or hide it) with the note shape util's `AttributionComponent` option:
*
* ```tsx
* // hide the attribution badge
* <Tldraw shapeUtils={[NoteShapeUtil.configure({ AttributionComponent: null })]} />
*
* // render your own
* <Tldraw shapeUtils={[NoteShapeUtil.configure({ AttributionComponent: (props) => <MyBadge {...props} /> })]} />
* ```
*
* @public @react
*/
export function DefaultNoteShapeAttribution({
name,
firstName,
color,
scale,
variant,
}: TLNoteShapeAttributionProps) {
if (variant === 'export') {
return (
<div
className="tl-note__attribution"
style={{
fontSize: 11,
color,
opacity: 0.6,
}}
>
{firstName}
</div>
)
}
return (
<TldrawUiTooltip content={name} side="bottom">
<div
className="tl-note__attribution"
style={{
['--note-attribution-scale' as string]: scale,
fontSize: 11 * scale,
color,
opacity: 0.6,
}}
>
{firstName}
</div>
</TldrawUiTooltip>
)
}
/**
* The component used to render a note shape's attribution badge, or `null` to hide it.
*
* @public
*/
export type TLNoteShapeAttributionComponent = ComponentType<TLNoteShapeAttributionProps> | null

View File

@@ -0,0 +1,339 @@
import { TLNoteShape } from '@tldraw/editor'
import { TestEditor } from '../../../test/TestEditor'
import { NoteShapeTool } from './NoteShapeTool'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor({ options: { adjacentShapeMargin: 20 } })
})
afterEach(() => {
editor?.dispose()
})
describe(NoteShapeTool, () => {
it('Creates note shapes on click-and-drag, supports undo and redo', () => {
expect(editor.getCurrentPageShapes().length).toBe(0)
editor.setCurrentTool('note')
editor.pointerDown(50, 50)
editor.pointerMove(100, 100)
editor.pointerUp(100, 100)
expect(editor.getCurrentPageShapes().length).toBe(1)
expect(editor.getCurrentPageShapes()[0]?.type).toBe('note')
expect(editor.getSelectedShapeIds()[0]).toBe(editor.getCurrentPageShapes()[0]?.id)
editor.cancel() // leave edit mode
editor.undo()
expect(editor.getCurrentPageShapes().length).toBe(0)
editor.redo()
expect(editor.getCurrentPageShapes().length).toBe(1)
})
it('Creates note shapes on click, supports undo and redo', () => {
expect(editor.getCurrentPageShapes().length).toBe(0)
editor.setCurrentTool('note')
editor.pointerDown(50, 50)
editor.pointerUp(50, 50)
expect(editor.getCurrentPageShapes().length).toBe(1)
expect(editor.getCurrentPageShapes()[0]?.type).toBe('note')
expect(editor.getSelectedShapeIds()[0]).toBe(editor.getCurrentPageShapes()[0]?.id)
editor.undo()
expect(editor.getCurrentPageShapes().length).toBe(0)
editor.redo()
expect(editor.getCurrentPageShapes().length).toBe(1)
})
})
describe('When selecting the tool', () => {
it('selects the tool and enters the idle state', () => {
editor.setCurrentTool('note')
editor.expectToBeIn('note.idle')
})
})
describe('When in the idle state', () => {
it('Enters pointing state on pointer down', () => {
editor.setCurrentTool('note')
editor.pointerDown(100, 100)
editor.expectToBeIn('note.pointing')
})
it('Switches back to select tool on cancel', () => {
editor.setCurrentTool('note')
editor.cancel()
editor.expectToBeIn('select.idle')
})
it('Does nothing on interrupt', () => {
editor.setCurrentTool('note')
editor.interrupt()
editor.expectToBeIn('note.idle')
})
})
describe('When in the pointing state', () => {
it('Switches back to idle on cancel', () => {
editor.setCurrentTool('note')
editor.pointerDown(50, 50)
editor.expectToBeIn('note.pointing')
editor.cancel()
editor.expectToBeIn('note.idle')
})
it('Enters the select.translating state on drag start', () => {
editor.setCurrentTool('note')
editor.pointerDown(50, 50)
editor.pointerMove(51, 51) // not far enough!
editor.expectToBeIn('note.pointing')
editor.pointerMove(55, 55)
editor.expectToBeIn('select.translating')
})
it('Returns to the note tool on cancel from translating', () => {
editor.setCurrentTool('note')
editor.pointerDown(50, 50)
editor.pointerMove(55, 55)
editor.cancel()
editor.expectToBeIn('note.idle')
})
it('Returns to the note tool on complete from translating when tool lock is enabled', () => {
editor.updateInstanceState({ isToolLocked: true })
editor.setCurrentTool('note')
editor.pointerDown(50, 50)
editor.pointerMove(55, 55)
editor.pointerUp()
editor.expectToBeIn('note.idle')
})
it('Returns to the idle state on interrupt', () => {
editor.setCurrentTool('note')
editor.pointerDown(50, 50)
editor.interrupt()
editor.expectToBeIn('note.idle')
})
it('Creates a note and begins editing on pointer up', () => {
expect(editor.getCurrentPageShapes().length).toBe(0)
editor.setCurrentTool('note')
editor.pointerDown(50, 50)
editor.pointerUp(50, 50)
editor.expectToBeIn('select.editing_shape')
expect(editor.getCurrentPageShapes().length).toBe(1)
})
it('Creates a note and returns to note.idle on pointer up if tool lock is enabled', () => {
editor.updateInstanceState({ isToolLocked: true })
expect(editor.getCurrentPageShapes().length).toBe(0)
editor.setCurrentTool('note')
editor.pointerDown(50, 50)
editor.pointerUp(50, 50)
editor.expectToBeIn('note.idle')
expect(editor.getCurrentPageShapes().length).toBe(1)
})
it('Leaves nothing behind on complete before the pointer is released', () => {
editor.setCurrentTool('note')
editor.pointerDown(50, 50)
editor.expectToBeIn('note.pointing')
editor.complete()
editor.expectToBeIn('note.idle')
expect(editor.getCurrentPageShapes().length).toBe(0)
})
it('Does not create a note when undo is pressed mid-press', () => {
editor.setCurrentTool('note')
editor.pointerDown(50, 50)
editor.undo()
editor.expectToBeIn('note.idle')
expect(editor.getCurrentPageShapes().length).toBe(0)
})
it('Leaves nothing behind on complete before the pointer is released on a coarse pointer', () => {
editor.updateInstanceState({ isCoarsePointer: true })
editor.setCurrentTool('note')
editor.pointerDown(50, 50)
editor.expectToBeIn('note.pointing')
editor.complete()
editor.expectToBeIn('note.idle')
expect(editor.getCurrentPageShapes().length).toBe(0)
})
})
describe('Adjacent note position helpers (sticky pits)', () => {
it('Creates a new sticky note outside of a sticky pit', () => {
editor.createShape({ type: 'note', x: 0, y: 0 })
for (const pit of [
{ x: 100, y: -120 },
{ x: 320, y: 100 },
{ x: 100, y: 320 },
{ x: -120, y: 100 },
]) {
const OFFSET_DISTANCE = 8
editor
.setCurrentTool('note')
.pointerMove(pit.x + OFFSET_DISTANCE, pit.y + OFFSET_DISTANCE) // too far from the pit
.click()
.expectShapeToMatch({
...editor.getLastCreatedShape(),
x: pit.x + OFFSET_DISTANCE - 100,
y: pit.y + OFFSET_DISTANCE - 100,
})
}
})
it('Creates a new sticky note in a sticky pit', () => {
editor.createShape({ type: 'note', x: 0, y: 0 })
for (const pit of [
{ x: 100, y: -120 },
{ x: 320, y: 100 },
{ x: 100, y: 320 },
{ x: -120, y: 100 },
]) {
const OFFSET_DISTANCE = 7 // close enough to the pit to fall into it
editor
.setCurrentTool('note')
.pointerMove(pit.x + OFFSET_DISTANCE, pit.y + OFFSET_DISTANCE)
.click()
.expectShapeToMatch({
...editor.getLastCreatedShape(),
x: pit.x - 100,
y: pit.y - 100,
})
}
})
it('Falls into a sticky pit when empty', () => {
editor
.createShape({ type: 'note', x: 0, y: 0 })
.setCurrentTool('note')
.pointerMove(324, 104)
.click()
.expectShapeToMatch({
...editor.getLastCreatedShape(),
// in da pit
x: 220,
y: 0,
})
})
it('Falls into a pit beside the note the tool just created when tool lock is on', () => {
editor.updateInstanceState({ isToolLocked: true })
editor.setCurrentTool('note').pointerMove(100, 100).click()
const first = editor.getLastCreatedShape()
expect(first).toMatchObject({ type: 'note', x: 0, y: 0 })
expect(editor.getSelectedShapeIds()).toEqual([first.id])
editor.pointerMove(324, 104).click()
const second = editor.getLastCreatedShape()
expect(second.id).not.toBe(first.id)
editor.expectShapeToMatch({
...second,
// in the pit to the right of the first note
x: 220,
y: 0,
})
})
it('Does not create a new sticky note in a sticky pit if a note is already there', () => {
editor
.createShape({ type: 'note', x: 0, y: 0 })
.createShape({ type: 'note', x: 330, y: 8 }) // make a shape kinda there already!
.setCurrentTool('note')
.pointerMove(300, 104)
.click()
.expectShapeToMatch({
...editor.getLastCreatedShape(),
// outta da pit
x: 200,
y: 4,
})
})
it('Does not fall into pits around rotated notes', () => {
editor.createShape({ type: 'note', x: 0, y: 0, rotation: 0.0000001 })
for (const pit of [
{ x: 100, y: -120 },
{ x: 320, y: 100 },
{ x: 100, y: 320 },
{ x: -120, y: 100 },
]) {
const OFFSET_DISTANCE = 7 // close enough to the pit to fall into it (if it weren't rotated)
editor
.setCurrentTool('note')
.pointerMove(pit.x + OFFSET_DISTANCE, pit.y + OFFSET_DISTANCE)
.click()
.expectShapeToMatch({
...editor.getLastCreatedShape(),
x: pit.x + OFFSET_DISTANCE - 100,
y: pit.y + OFFSET_DISTANCE - 100,
})
}
})
it('Falls into correct pit below notes with growY', () => {
editor.createShape({ type: 'note', x: 0, y: 0 }).updateShape({
...editor.getLastCreatedShape<TLNoteShape>(),
props: { growY: 100 },
})
// Misses the pit below the note because the note has growY
// instead of being at 100, 320, it's at 100, 320 + 100 = 320
editor
.setCurrentTool('note')
.pointerMove(100, 324)
.click()
.expectShapeToMatch({
...editor.getLastCreatedShape(),
x: 0,
y: 224,
})
.undo()
// Let's get it in that pit
editor
.setCurrentTool('note')
.pointerMove(100, 424)
.click()
.expectShapeToMatch({
...editor.getLastCreatedShape(),
x: 0,
y: 320,
})
.undo()
})
it('Prefers snapping to adjacent note position over the grid', () => {
editor.updateInstanceState({ isGridMode: true })
editor.createShape({ type: 'note', x: 2, y: 0 })
const pit = { x: 322, y: 100 }
editor
.setCurrentTool('note')
.pointerDown(0, 0)
.pointerMove(pit.x, pit.y)
.pointerUp()
.expectShapeToMatch({
...editor.getLastCreatedShape(),
x: pit.x - 100,
y: pit.y - 100,
})
expect(editor.getLastCreatedShape().x).not.toBe(220)
})
})

View File

@@ -0,0 +1,13 @@
import { StateNode, TLStateNodeConstructor } from '@tldraw/editor'
import { Idle } from './toolStates/Idle'
import { Pointing } from './toolStates/Pointing'
/** @public */
export class NoteShapeTool extends StateNode {
static override id = 'note'
static override initial = 'idle'
static override children(): TLStateNodeConstructor[] {
return [Idle, Pointing]
}
override shapeType = 'note'
}

Some files were not shown because too many files have changed in this diff Show More