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
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:
1
packages/tldraw/LICENSE.md
Normal file
1
packages/tldraw/LICENSE.md
Normal file
@@ -0,0 +1 @@
|
||||
This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)
|
||||
79
packages/tldraw/README.md
Normal file
79
packages/tldraw/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
<div alt style="text-align: center; transform: scale(.5);">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tldraw/tldraw/raw/main/assets/github-hero-dark.png" />
|
||||
<img alt="tldraw" src="https://github.com/tldraw/tldraw/raw/main/assets/github-hero-light.png" />
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
# tldraw SDK
|
||||
|
||||
tldraw is an infinite canvas SDK for React developers. You can use the tldraw SDK to add a drop-in whiteboard to your project, customize its appearance, extend its functionality, or use its components and APIs to compose other canvas experiences.
|
||||
|
||||
Learn more at our docs site: [tldraw.dev](https://tldraw.dev).
|
||||
|
||||
## Installation
|
||||
|
||||
Install the `tldraw` package.
|
||||
|
||||
```bash
|
||||
npm i tldraw
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { Tldraw } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
export default function () {
|
||||
return <Tldraw />
|
||||
}
|
||||
```
|
||||
|
||||
Visit [tldraw.dev](https://tldraw.dev) to learn more.
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation for the most recent release can be found on [tldraw.dev/docs](https://tldraw.dev/docs), including [reference docs](https://tldraw.dev/reference/editor/Editor). Our release notes can be found [here](https://tldraw.dev/releases).
|
||||
|
||||
For more agent-friendly docs, see our [LLMs.txt](https://tldraw.dev/llms.txt).
|
||||
|
||||
From 5.1.x onward, this package also includes a `DOCS.md` file with the appropriate docs-site content and a generated `RELEASE_NOTES.md` file with versioned release notes. If you or your coding agent need release notes for your installed version specifically, check these files alongside the source in your `node_modules` folder.
|
||||
|
||||
## Who's using tldraw
|
||||
|
||||
The tldraw SDK powers canvas experiences in products from [Google](https://about.google), [Shopify](https://www.shopify.com), [BlackRock](https://www.blackrock.com), [Autodesk](https://blogs.autodesk.com/forma/2024/06/20/forma-board-collaborative-tool-for-architects/), [ClickUp](https://tldraw.dev/blog/clickup), [Replit](https://docs.replit.com/replitai/canvas), [Google Stitch](https://stitch.withgoogle.com), [Luma](https://lumalabs.ai/dream-machine), [Runway](https://runwayml.com), [Padlet](https://tldraw.dev/blog/padlet), [Mobbin](https://tldraw.dev/blog/mobbin), [Jam](https://tldraw.dev/blog/jam), [Craft](https://support.craft.do/en/write-and-edit/whiteboards), [Honeycomb](https://www.honeycomb.io/platform/canvas), [SchoolAI](https://schoolai.com/products/powerups), [Brisk](https://www.briskteaching.com/post/boost-whiteboard), and many more. See our [showcase](https://tldraw.dev/showcase) for case studies.
|
||||
|
||||
## Package development
|
||||
|
||||
To start the local development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This will run our [examples app](https://github.com/tldraw/tldraw/tree/main/apps/examples) locally at http://localhost:5420. Changes to packages will be reflected live on the examples app.
|
||||
|
||||
## License
|
||||
|
||||
This project is part of the tldraw SDK. It is provided under the [tldraw SDK license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
|
||||
|
||||
## Trademarks
|
||||
|
||||
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
|
||||
|
||||
## Distributions
|
||||
|
||||
You can find tldraw on npm [here](https://www.npmjs.com/package/@tldraw/tldraw?activeTab=versions).
|
||||
|
||||
## Contribution
|
||||
|
||||
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
|
||||
|
||||
## Community
|
||||
|
||||
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
|
||||
|
||||
## Contact
|
||||
|
||||
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
|
||||
4
packages/tldraw/api-extractor.json
Normal file
4
packages/tldraw/api-extractor.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
|
||||
"extends": "../../internal/config/api-extractor.json"
|
||||
}
|
||||
103
packages/tldraw/package.json
Normal file
103
packages/tldraw/package.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"name": "tldraw",
|
||||
"description": "A tiny little drawing editor.",
|
||||
"version": "5.4.2",
|
||||
"author": {
|
||||
"name": "tldraw Inc.",
|
||||
"email": "hello@tldraw.com"
|
||||
},
|
||||
"homepage": "https://tldraw.dev",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"tldraw_product": {
|
||||
"stableId": "tldraw:sdk-core",
|
||||
"name": "SDK",
|
||||
"type": "product",
|
||||
"premium": false
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tldraw/tldraw"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/tldraw/tldraw/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"tldraw",
|
||||
"sdk",
|
||||
"drawing",
|
||||
"app",
|
||||
"development",
|
||||
"whiteboard",
|
||||
"canvas",
|
||||
"infinite"
|
||||
],
|
||||
"/* NOTE */": "These `main` and `types` fields are rewritten by the build script. They are not the actual values we publish",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./.tsbuild/index.d.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./src/test/test-jsx": "./src/test/test-jsx.tsx",
|
||||
"./*.css": "./*.css"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test-ci": "yarn run -T vitest run --passWithNoTests",
|
||||
"test": "yarn run -T vitest --passWithNoTests",
|
||||
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
|
||||
"predev": "node ./scripts/copy-css-files.mjs",
|
||||
"dev": "chokidar '../{editor/editor,tldraw/src/lib/ui}.css' -c 'node ./scripts/copy-css-files.mjs'",
|
||||
"prebuild": "node ./scripts/copy-css-files.mjs",
|
||||
"build": "yarn run -T tsx ../../internal/scripts/build-package.ts",
|
||||
"build-api": "yarn run -T tsx ../../internal/scripts/build-api.ts",
|
||||
"prepack": "yarn run -T tsx ../../internal/scripts/prepack.ts",
|
||||
"postpack": "../../internal/scripts/postpack.sh",
|
||||
"pack-tarball": "yarn pack",
|
||||
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
|
||||
},
|
||||
"files": [
|
||||
"tldraw.css"
|
||||
],
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^3.12.1",
|
||||
"@tiptap/extension-code": "^3.12.1",
|
||||
"@tiptap/extension-highlight": "^3.12.1",
|
||||
"@tiptap/extension-list": "^3.12.1",
|
||||
"@tiptap/extension-typography": "3.13.0",
|
||||
"@tiptap/pm": "^3.12.1",
|
||||
"@tiptap/react": "^3.12.1",
|
||||
"@tiptap/starter-kit": "^3.12.1",
|
||||
"@tldraw/driver": "workspace:*",
|
||||
"@tldraw/editor": "workspace:*",
|
||||
"@tldraw/state": "workspace:*",
|
||||
"@tldraw/store": "workspace:*",
|
||||
"@tldraw/tlschema": "workspace:*",
|
||||
"classnames": "^2.5.1",
|
||||
"idb": "^7.1.1",
|
||||
"lz-string": "^1.5.0",
|
||||
"radix-ui": "^1.4.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0 || ^19.2.1",
|
||||
"react-dom": "^18.2.0 || ^19.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@peculiar/webcrypto": "^1.5.0",
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/classnames": "^2.3.4",
|
||||
"@types/lz-string": "^1.5.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"chokidar-cli": "^3.0.0",
|
||||
"fake-indexeddb": "^4.0.2",
|
||||
"jest-diff": "^30.4.1",
|
||||
"lazyrepo": "0.0.0-alpha.27",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"vitest": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
}
|
||||
27
packages/tldraw/scripts/copy-css-files.mjs
Normal file
27
packages/tldraw/scripts/copy-css-files.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const packageDir = join(__dirname, '..')
|
||||
|
||||
let combinedContent = [
|
||||
join(packageDir, '..', 'editor', 'editor.css'),
|
||||
join(packageDir, 'src', 'lib', 'ui.css'),
|
||||
].reduce(
|
||||
(acc, path) => {
|
||||
const content = readFileSync(path, 'utf8')
|
||||
acc += content + '\n'
|
||||
return acc
|
||||
},
|
||||
`/* THIS CSS FILE IS GENERATED! DO NOT EDIT. OR EDIT. I'M A COMMENT NOT A COP */
|
||||
/* This file is created by the copy-css-files.mjs script in packages/tldraw. */
|
||||
/* It combines @tldraw/editor's editor.css and tldraw's ui.css */
|
||||
|
||||
`
|
||||
)
|
||||
|
||||
const destination = join(packageDir, 'tldraw.css')
|
||||
writeFileSync(destination, combinedContent)
|
||||
194
packages/tldraw/setupVitest.js
Normal file
194
packages/tldraw/setupVitest.js
Normal file
@@ -0,0 +1,194 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// Mock the breakpoints context to provide a default breakpoint value in tests
|
||||
// This prevents errors when components use useBreakpoint() without a BreakpointProvider
|
||||
vi.mock('./src/lib/ui/context/breakpoints', async () => {
|
||||
const actual = await vi.importActual('./src/lib/ui/context/breakpoints')
|
||||
return {
|
||||
...actual,
|
||||
useBreakpoint: () => 7, // PORTRAIT_BREAKPOINT.DESKTOP
|
||||
}
|
||||
})
|
||||
|
||||
// Mock the translation hooks to provide default translation functions in tests
|
||||
// This prevents errors when components use useTranslation() without a TldrawUiTranslationProvider
|
||||
vi.mock('./src/lib/ui/hooks/useTranslation/useTranslation', async () => {
|
||||
const actual = await vi.importActual('./src/lib/ui/hooks/useTranslation/useTranslation')
|
||||
return {
|
||||
...actual,
|
||||
useCurrentTranslation: () => ({
|
||||
locale: 'en',
|
||||
label: 'English',
|
||||
dir: 'ltr',
|
||||
messages: {},
|
||||
}),
|
||||
useTranslation: () => (key) => key || '', // Return the key itself as the translation
|
||||
}
|
||||
})
|
||||
|
||||
// Vitest setup file for tldraw package
|
||||
// Converted from setupTests.js to provide the same polyfills and global setup
|
||||
|
||||
// IndexedDB polyfill for browser storage testing
|
||||
require('fake-indexeddb/auto')
|
||||
|
||||
// ResizeObserver polyfill for DOM observation testing
|
||||
global.ResizeObserver = require('resize-observer-polyfill')
|
||||
|
||||
// Crypto polyfill for cryptographic operations
|
||||
global.crypto ??= new (require('@peculiar/webcrypto').Crypto)()
|
||||
|
||||
// FontFace API polyfill for font testing
|
||||
global.FontFace = class FontFace {
|
||||
load() {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
// Document fonts API mock
|
||||
document.fonts = {
|
||||
add: () => {},
|
||||
delete: () => {},
|
||||
forEach: () => {},
|
||||
[Symbol.iterator]: () => [][Symbol.iterator](),
|
||||
}
|
||||
|
||||
// Window.matchMedia polyfill for media queries
|
||||
// Use a conditional approach to avoid conflicts with jsdom
|
||||
if (!window.matchMedia || typeof window.matchMedia !== 'function') {
|
||||
window.matchMedia = (query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => {},
|
||||
})
|
||||
}
|
||||
|
||||
// URL.createObjectURL polyfill for blob handling
|
||||
Object.defineProperty(global.URL, 'createObjectURL', {
|
||||
writable: true,
|
||||
value: () => 'mock-object-url',
|
||||
})
|
||||
|
||||
// Extract version from package.json (same as original setup)
|
||||
const { version } = require('./package.json')
|
||||
|
||||
// Window fetch mock for network requests - handles translation loading
|
||||
window.fetch = async (input, init) => {
|
||||
if (input === `https://cdn.tldraw.com/${version}/translations/en.json`) {
|
||||
const { DEFAULT_TRANSLATION } =
|
||||
await import('./src/lib/ui/hooks/useTranslation/defaultTranslation.ts')
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => DEFAULT_TRANSLATION,
|
||||
}
|
||||
}
|
||||
|
||||
if (input === '/icons/icon/icon-names.json') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [],
|
||||
}
|
||||
}
|
||||
|
||||
// Handle font requests (tldraw_draw, etc.) - return mock font data
|
||||
if (
|
||||
typeof input === 'string' &&
|
||||
(input.includes('tldraw_draw') ||
|
||||
input.includes('.woff') ||
|
||||
input.includes('.ttf') ||
|
||||
input.includes('font'))
|
||||
) {
|
||||
return {
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
blob: async () => new Blob([''], { type: 'font/woff2' }),
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled request: ${input}`)
|
||||
}
|
||||
|
||||
// Image API polyfills for jsdom - add decode method to HTMLImageElement prototype
|
||||
if (typeof HTMLImageElement !== 'undefined') {
|
||||
if (!HTMLImageElement.prototype.decode) {
|
||||
HTMLImageElement.prototype.decode = function () {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// window.getComputedStyle polyfill to prevent jsdom warnings
|
||||
// Override jsdom's implementation with a silent one
|
||||
Object.defineProperty(window, 'getComputedStyle', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: function (element, pseudoElt) {
|
||||
// Create a mock CSSStyleDeclaration
|
||||
const style = {
|
||||
getPropertyValue: function (property) {
|
||||
// Handle common properties that tldraw might need
|
||||
switch (property) {
|
||||
case 'display':
|
||||
return 'block'
|
||||
case 'position':
|
||||
return 'static'
|
||||
case 'width':
|
||||
return '0px'
|
||||
case 'height':
|
||||
return '0px'
|
||||
case 'margin':
|
||||
return '0px'
|
||||
case 'padding':
|
||||
return '0px'
|
||||
case 'border':
|
||||
return '0px'
|
||||
case 'font-family':
|
||||
return 'serif'
|
||||
case 'font-size':
|
||||
return '16px'
|
||||
case 'line-height':
|
||||
return 'normal'
|
||||
case 'color':
|
||||
return 'rgb(0, 0, 0)'
|
||||
case 'background-color':
|
||||
return 'rgba(0, 0, 0, 0)'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
},
|
||||
setProperty: function (property, value) {
|
||||
this[property] = value
|
||||
},
|
||||
removeProperty: function (property) {
|
||||
delete this[property]
|
||||
return ''
|
||||
},
|
||||
}
|
||||
|
||||
// Add common CSS properties
|
||||
const commonProps = [
|
||||
'display',
|
||||
'position',
|
||||
'width',
|
||||
'height',
|
||||
'margin',
|
||||
'padding',
|
||||
'border',
|
||||
'font-family',
|
||||
'font-size',
|
||||
'line-height',
|
||||
'color',
|
||||
'background-color',
|
||||
]
|
||||
commonProps.forEach((prop) => {
|
||||
style[prop] = style.getPropertyValue(prop)
|
||||
})
|
||||
|
||||
return style
|
||||
},
|
||||
})
|
||||
920
packages/tldraw/src/index.ts
Normal file
920
packages/tldraw/src/index.ts
Normal file
@@ -0,0 +1,920 @@
|
||||
/// <reference types="react" />
|
||||
|
||||
import { registerTldrawLibraryVersion } from '@tldraw/editor'
|
||||
export { getPointsFromDrawSegment, getPointsFromDrawSegments } from './lib/shapes/draw/getPath'
|
||||
export {
|
||||
PathBuilder,
|
||||
PathBuilderGeometry2d,
|
||||
type BasePathBuilderOpts,
|
||||
type CubicBezierToPathBuilderCommand,
|
||||
type DashedPathBuilderOpts,
|
||||
type DrawPathBuilderDOpts,
|
||||
type DrawPathBuilderOpts,
|
||||
type NonePathBuilderOpts,
|
||||
type LineToPathBuilderCommand,
|
||||
type MoveToPathBuilderCommand,
|
||||
type PathBuilderCommand,
|
||||
type PathBuilderCommandBase,
|
||||
type PathBuilderCommandInfo,
|
||||
type PathBuilderCommandOpts,
|
||||
type PathBuilderLineOpts,
|
||||
type PathBuilderOpts,
|
||||
type PathBuilderToDOpts,
|
||||
type SolidPathBuilderOpts,
|
||||
} from './lib/shapes/shared/PathBuilder'
|
||||
export { usePrefersReducedMotion } from './lib/shapes/shared/usePrefersReducedMotion'
|
||||
export { startEditingShapeWithRichText } from './lib/tools/SelectTool/selectHelpers'
|
||||
export { DefaultA11yAnnouncer, useSelectedShapesAnnouncer } from './lib/ui/components/A11y'
|
||||
export { AccessibilityMenu } from './lib/ui/components/AccessibilityMenu'
|
||||
export { ColorSchemeMenu } from './lib/ui/components/ColorSchemeMenu'
|
||||
export { DefaultFollowingIndicator } from './lib/ui/components/DefaultFollowingIndicator'
|
||||
export { DefaultDialogs } from './lib/ui/components/Dialogs'
|
||||
export {
|
||||
TldrawUiColumn,
|
||||
TldrawUiGrid,
|
||||
TldrawUiOrientationProvider,
|
||||
TldrawUiRow,
|
||||
useTldrawUiOrientation,
|
||||
type TldrawUiOrientationContext,
|
||||
type TldrawUiOrientationProviderProps,
|
||||
type TLUiLayoutProps,
|
||||
} from './lib/ui/components/primitives/layout'
|
||||
export {
|
||||
TldrawUiMenuActionCheckboxItem,
|
||||
type TLUiMenuActionCheckboxItemProps,
|
||||
} from './lib/ui/components/primitives/menus/TldrawUiMenuActionCheckboxItem'
|
||||
export {
|
||||
TldrawUiMenuActionItem,
|
||||
type TLUiMenuActionItemProps,
|
||||
} from './lib/ui/components/primitives/menus/TldrawUiMenuActionItem'
|
||||
export {
|
||||
TldrawUiMenuToolItem,
|
||||
type TLUiMenuToolItemProps,
|
||||
} from './lib/ui/components/primitives/menus/TldrawUiMenuToolItem'
|
||||
export { DefaultToasts } from './lib/ui/components/Toasts'
|
||||
export { TldrawUiTranslationProvider } from './lib/ui/hooks/useTranslation/useTranslation'
|
||||
// eslint-disable-next-line tldraw/no-export-star
|
||||
export * from '@tldraw/editor'
|
||||
export { BookmarkAssetUtil } from './lib/assets/BookmarkAssetUtil'
|
||||
export { ImageAssetUtil } from './lib/assets/ImageAssetUtil'
|
||||
export { VideoAssetUtil } from './lib/assets/VideoAssetUtil'
|
||||
export {
|
||||
ArrowBindingHintOverlayUtil,
|
||||
type TLArrowBindingHintOverlay,
|
||||
} from './lib/overlays/ArrowBindingHintOverlayUtil'
|
||||
export { ArrowBindingUtil } from './lib/bindings/arrow/ArrowBindingUtil'
|
||||
export { ArrowHintOverlayUtil, type TLArrowHintOverlay } from './lib/overlays/ArrowHintOverlayUtil'
|
||||
export {
|
||||
BrushOverlayUtil,
|
||||
type BrushOverlayUtilDisplayValues,
|
||||
type BrushOverlayUtilOptions,
|
||||
type TLBrushOverlay,
|
||||
} from './lib/overlays/BrushOverlayUtil'
|
||||
export { ZoomBrushOverlayUtil, type TLZoomBrushOverlay } from './lib/overlays/ZoomBrushOverlayUtil'
|
||||
export { ScribbleOverlayUtil, type TLScribbleOverlay } from './lib/overlays/ScribbleOverlayUtil'
|
||||
export {
|
||||
CollaboratorHintOverlayUtil,
|
||||
type TLCollaboratorHintOverlay,
|
||||
} from './lib/overlays/CollaboratorHintOverlayUtil'
|
||||
// Deprecated, but still exported so apps that customized canvas-drawn cursors keep working.
|
||||
// oxlint-disable-next-line typescript/no-deprecated
|
||||
export { CollaboratorCursorOverlayUtil } from './lib/overlays/CollaboratorCursorOverlayUtil'
|
||||
// oxlint-disable-next-line typescript/no-deprecated
|
||||
export type { TLCollaboratorCursorOverlay } from './lib/overlays/CollaboratorCursorOverlayUtil'
|
||||
export {
|
||||
CollaboratorBrushOverlayUtil,
|
||||
type TLCollaboratorBrushOverlay,
|
||||
} from './lib/overlays/CollaboratorBrushOverlayUtil'
|
||||
export {
|
||||
CollaboratorScribbleOverlayUtil,
|
||||
type TLCollaboratorScribbleOverlay,
|
||||
} from './lib/overlays/CollaboratorScribbleOverlayUtil'
|
||||
export {
|
||||
CollaboratorShapeIndicatorOverlayUtil,
|
||||
type TLCollaboratorShapeIndicatorOverlay,
|
||||
} from './lib/overlays/CollaboratorShapeIndicatorOverlayUtil'
|
||||
export {
|
||||
ShapeHandleOverlayUtil,
|
||||
type TLShapeHandleOverlay,
|
||||
} from './lib/overlays/ShapeHandleOverlayUtil'
|
||||
export {
|
||||
ShapeIndicatorOverlayUtil,
|
||||
type TLShapeIndicatorOverlay,
|
||||
} from './lib/overlays/ShapeIndicatorOverlayUtil'
|
||||
export {
|
||||
SelectionForegroundOverlayUtil,
|
||||
type TLSelectionForegroundOverlay,
|
||||
} from './lib/overlays/SelectionForegroundOverlayUtil'
|
||||
export {
|
||||
SnapIndicatorOverlayUtil,
|
||||
type TLSnapIndicatorOverlay,
|
||||
} from './lib/overlays/SnapIndicatorOverlayUtil'
|
||||
export { defaultAssetUtils } from './lib/defaultAssetUtils'
|
||||
export { defaultBindingUtils } from './lib/defaultBindingUtils'
|
||||
export { defaultOverlayUtils } from './lib/defaultOverlayUtils'
|
||||
export {
|
||||
DEFAULT_EMBED_DEFINITIONS,
|
||||
embedShapePermissionDefaults,
|
||||
unknownEmbedShapePermissionOverrides,
|
||||
type CustomEmbedDefinition,
|
||||
type DefaultEmbedConfig,
|
||||
type DefaultEmbedDefinitionType,
|
||||
type EmbedDefinition,
|
||||
type GoogleMapsEmbedConfig,
|
||||
type TLEmbedDefinition,
|
||||
type TLEmbedShapePermissions,
|
||||
} from './lib/defaultEmbedDefinitions'
|
||||
export {
|
||||
centerSelectionAroundPoint,
|
||||
createEmptyBookmarkShape,
|
||||
createShapesForAssets,
|
||||
DEFAULT_MAX_ASSET_SIZE,
|
||||
DEFAULT_MAX_IMAGE_DIMENSION,
|
||||
defaultHandleExternalEmbedContent,
|
||||
defaultHandleExternalExcalidrawContent,
|
||||
defaultHandleExternalFileAsset,
|
||||
defaultHandleExternalFileContent,
|
||||
defaultHandleExternalFileReplaceContent,
|
||||
defaultHandleExternalSvgTextContent,
|
||||
defaultHandleExternalTextContent,
|
||||
defaultHandleExternalTldrawContent,
|
||||
defaultHandleExternalUrlAsset,
|
||||
defaultHandleExternalUrlContent,
|
||||
getAssetInfo,
|
||||
notifyIfFileNotAllowed,
|
||||
registerDefaultExternalContentHandlers,
|
||||
type TLDefaultExternalContentHandlerOpts,
|
||||
type TLExternalContentProps,
|
||||
} from './lib/defaultExternalContentHandlers'
|
||||
export { defaultShapeTools } from './lib/defaultShapeTools'
|
||||
export { defaultShapeUtils } from './lib/defaultShapeUtils'
|
||||
export { registerDefaultSideEffects } from './lib/defaultSideEffects'
|
||||
export { defaultTools } from './lib/defaultTools'
|
||||
export {
|
||||
type ArrowShapeOptions,
|
||||
type ArrowShapeUtilDisplayValues,
|
||||
type TLArcArrowInfo,
|
||||
type TLArcInfo,
|
||||
type TLArrowInfo,
|
||||
type TLArrowPoint,
|
||||
type TLElbowArrowInfo,
|
||||
type TLStraightArrowInfo,
|
||||
} from './lib/shapes/arrow/arrow-types'
|
||||
export { ArrowShapeTool } from './lib/shapes/arrow/ArrowShapeTool'
|
||||
export { ArrowShapeUtil } from './lib/shapes/arrow/ArrowShapeUtil'
|
||||
export {
|
||||
clearArrowTargetState,
|
||||
getArrowTargetState,
|
||||
updateArrowTargetState,
|
||||
type ArrowTargetState,
|
||||
type UpdateArrowTargetStateOpts,
|
||||
} from './lib/shapes/arrow/arrowTargetState'
|
||||
export {
|
||||
type ElbowArrowBox,
|
||||
type ElbowArrowBoxEdges,
|
||||
type ElbowArrowBoxes,
|
||||
type ElbowArrowEdge,
|
||||
type ElbowArrowInfo,
|
||||
type ElbowArrowInfoWithoutRoute,
|
||||
type ElbowArrowMidpointHandle,
|
||||
type ElbowArrowOptions,
|
||||
type ElbowArrowRange,
|
||||
type ElbowArrowRoute,
|
||||
type ElbowArrowSide,
|
||||
type ElbowArrowSideReason,
|
||||
type ElbowArrowTargetBox,
|
||||
} from './lib/shapes/arrow/elbow/definitions'
|
||||
export { getArrowInfo } from './lib/shapes/arrow/getArrowInfo'
|
||||
export {
|
||||
getArrowBindings,
|
||||
getArrowTerminalsInArrowSpace,
|
||||
type TLArrowBindings,
|
||||
} from './lib/shapes/arrow/shared'
|
||||
export { createBookmarkFromUrl } from './lib/shapes/bookmark/bookmarks'
|
||||
export {
|
||||
BookmarkShapeUtil,
|
||||
type BookmarkShapeOptions,
|
||||
type BookmarkShapeUtilDisplayValues,
|
||||
} from './lib/shapes/bookmark/BookmarkShapeUtil'
|
||||
export { DrawShapeTool } from './lib/shapes/draw/DrawShapeTool'
|
||||
export {
|
||||
DrawShapeUtil,
|
||||
type DrawShapeOptions,
|
||||
type DrawShapeUtilDisplayValues,
|
||||
} from './lib/shapes/draw/DrawShapeUtil'
|
||||
export {
|
||||
EmbedShapeUtil,
|
||||
type EmbedShapeOptions,
|
||||
type EmbedShapeUtilDisplayValues,
|
||||
} from './lib/shapes/embed/EmbedShapeUtil'
|
||||
export { FrameShapeTool } from './lib/shapes/frame/FrameShapeTool'
|
||||
export {
|
||||
FrameShapeUtil,
|
||||
type FrameShapeOptions,
|
||||
type FrameShapeUtilDisplayValues,
|
||||
} from './lib/shapes/frame/FrameShapeUtil'
|
||||
export { GeoShapeTool } from './lib/shapes/geo/GeoShapeTool'
|
||||
export {
|
||||
GeoShapeUtil,
|
||||
type GeoShapeOptions,
|
||||
type GeoShapeUtilDisplayValues,
|
||||
} from './lib/shapes/geo/GeoShapeUtil'
|
||||
export {
|
||||
defaultGeoTypeDefinitions,
|
||||
getGeoTypeDefinition,
|
||||
type GeoTypeDefinition,
|
||||
} from './lib/shapes/geo/getGeoShapePath'
|
||||
export { HighlightShapeTool } from './lib/shapes/highlight/HighlightShapeTool'
|
||||
export {
|
||||
HighlightShapeUtil,
|
||||
type HighlightShapeOptions,
|
||||
type HighlightShapeUtilDisplayValues,
|
||||
} from './lib/shapes/highlight/HighlightShapeUtil'
|
||||
export {
|
||||
ImageShapeUtil,
|
||||
type ImageShapeOptions,
|
||||
type ImageShapeUtilDisplayValues,
|
||||
} from './lib/shapes/image/ImageShapeUtil'
|
||||
export { LineShapeTool } from './lib/shapes/line/LineShapeTool'
|
||||
export {
|
||||
LineShapeUtil,
|
||||
type LineShapeOptions,
|
||||
type LineShapeUtilDisplayValues,
|
||||
} from './lib/shapes/line/LineShapeUtil'
|
||||
export {
|
||||
DefaultNoteShapeAttribution,
|
||||
type TLNoteShapeAttributionComponent,
|
||||
type TLNoteShapeAttributionProps,
|
||||
} from './lib/shapes/note/DefaultNoteShapeAttribution'
|
||||
export { NoteShapeTool } from './lib/shapes/note/NoteShapeTool'
|
||||
export {
|
||||
NoteShapeUtil,
|
||||
type NoteShapeOptions,
|
||||
type NoteShapeUtilDisplayValues,
|
||||
} from './lib/shapes/note/NoteShapeUtil'
|
||||
export {
|
||||
ASPECT_RATIO_OPTIONS,
|
||||
ASPECT_RATIO_TO_VALUE,
|
||||
getCropBox,
|
||||
getDefaultCrop,
|
||||
getUncroppedSize,
|
||||
type ASPECT_RATIO_OPTION,
|
||||
type CropBoxOptions,
|
||||
} from './lib/shapes/shared/crop'
|
||||
export { getFontFamily } from './lib/shapes/shared/default-shape-constants'
|
||||
export {
|
||||
allDefaultFontFaces,
|
||||
DefaultFontFaces,
|
||||
type TLDefaultFont,
|
||||
type TLDefaultFonts,
|
||||
} from './lib/shapes/shared/defaultFonts'
|
||||
export { getStroke } from './lib/shapes/shared/freehand/getStroke'
|
||||
export { getStrokeOutlinePoints } from './lib/shapes/shared/freehand/getStrokeOutlinePoints'
|
||||
export { getStrokePoints } from './lib/shapes/shared/freehand/getStrokePoints'
|
||||
export { getSvgPathFromStrokePoints } from './lib/shapes/shared/freehand/svg'
|
||||
export { type StrokeOptions, type StrokePoint } from './lib/shapes/shared/freehand/types'
|
||||
export {
|
||||
getDisplayValues,
|
||||
type ShapeOptionsWithDisplayValues,
|
||||
} from './lib/shapes/shared/getDisplayValues'
|
||||
export { PlainTextLabel, type PlainTextLabelProps } from './lib/shapes/shared/PlainTextLabel'
|
||||
export {
|
||||
RichTextLabel,
|
||||
RichTextSVG,
|
||||
type RichTextLabelProps,
|
||||
type RichTextSVGProps,
|
||||
} from './lib/shapes/shared/RichTextLabel'
|
||||
export { useEditablePlainText } from './lib/shapes/shared/useEditablePlainText'
|
||||
export { useEditableRichText } from './lib/shapes/shared/useEditableRichText'
|
||||
export {
|
||||
useImageOrVideoAsset,
|
||||
type UseImageOrVideoAssetOptions,
|
||||
} from './lib/shapes/shared/useImageOrVideoAsset'
|
||||
export { PlainTextArea } from './lib/shapes/text/PlainTextArea'
|
||||
export { RichTextArea, type TextAreaProps } from './lib/shapes/text/RichTextArea'
|
||||
export { TextShapeTool } from './lib/shapes/text/TextShapeTool'
|
||||
export {
|
||||
TextShapeUtil,
|
||||
type TextShapeOptions,
|
||||
type TextShapeUtilDisplayValues,
|
||||
} from './lib/shapes/text/TextShapeUtil'
|
||||
export {
|
||||
VideoShapeUtil,
|
||||
type VideoShapeOptions,
|
||||
type VideoShapeUtilDisplayValues,
|
||||
} from './lib/shapes/video/VideoShapeUtil'
|
||||
export { getColorStyleItems, getFontStyleItems, type StyleValuesForUi } from './lib/styles'
|
||||
export { Tldraw, type TLComponents, type TldrawBaseProps, type TldrawProps } from './lib/Tldraw'
|
||||
export { TldrawImage, type TldrawImageProps } from './lib/TldrawImage'
|
||||
export { EraserTool } from './lib/tools/EraserTool/EraserTool'
|
||||
export { HandTool } from './lib/tools/HandTool/HandTool'
|
||||
export { LaserTool } from './lib/tools/LaserTool/LaserTool'
|
||||
export { getHitShapeOnCanvasPointerDown } from './lib/tools/selection-logic/getHitShapeOnCanvasPointerDown'
|
||||
export { SelectTool } from './lib/tools/SelectTool/SelectTool'
|
||||
export { ZoomTool } from './lib/tools/ZoomTool/ZoomTool'
|
||||
export {
|
||||
setDefaultUiAssetUrls,
|
||||
type TLUiAssetUrlOverrides,
|
||||
type TLUiAssetUrls,
|
||||
} from './lib/ui/assetUrls'
|
||||
export {
|
||||
DefaultActionsMenu,
|
||||
type TLUiActionsMenuProps,
|
||||
} from './lib/ui/components/ActionsMenu/DefaultActionsMenu'
|
||||
export {
|
||||
AlignMenuItems,
|
||||
DefaultActionsMenuContent,
|
||||
DistributeMenuItems,
|
||||
GroupOrUngroupMenuItem,
|
||||
ReorderMenuItems,
|
||||
RotateCWMenuItem,
|
||||
StackMenuItems,
|
||||
ZoomOrRotateMenuItem,
|
||||
} from './lib/ui/components/ActionsMenu/DefaultActionsMenuContent'
|
||||
export {
|
||||
DefaultContextMenu as ContextMenu,
|
||||
DefaultContextMenu,
|
||||
type TLUiContextMenuProps,
|
||||
} from './lib/ui/components/ContextMenu/DefaultContextMenu'
|
||||
export { DefaultContextMenuContent } from './lib/ui/components/ContextMenu/DefaultContextMenuContent'
|
||||
export {
|
||||
DefaultDebugMenu,
|
||||
type TLUiDebugMenuProps,
|
||||
} from './lib/ui/components/DebugMenu/DefaultDebugMenu'
|
||||
export {
|
||||
DebugFlags,
|
||||
DefaultDebugMenuContent,
|
||||
ExampleDialog,
|
||||
FeatureFlags,
|
||||
type CustomDebugFlags,
|
||||
type DebugFlagsProps,
|
||||
type ExampleDialogProps,
|
||||
type FeatureFlagsProps,
|
||||
} from './lib/ui/components/DebugMenu/DefaultDebugMenuContent'
|
||||
export { DefaultMenuPanel } from './lib/ui/components/DefaultMenuPanel'
|
||||
export {
|
||||
DefaultHelperButtons,
|
||||
type TLUiHelperButtonsProps,
|
||||
} from './lib/ui/components/HelperButtons/DefaultHelperButtons'
|
||||
export { DefaultHelperButtonsContent } from './lib/ui/components/HelperButtons/DefaultHelperButtonsContent'
|
||||
export {
|
||||
DefaultHelpMenu,
|
||||
type TLUiHelpMenuProps,
|
||||
} from './lib/ui/components/HelpMenu/DefaultHelpMenu'
|
||||
export {
|
||||
DefaultHelpMenuContent,
|
||||
KeyboardShortcutsMenuItem,
|
||||
} from './lib/ui/components/HelpMenu/DefaultHelpMenuContent'
|
||||
export {
|
||||
DefaultKeyboardShortcutsDialog,
|
||||
type TLUiKeyboardShortcutsDialogProps,
|
||||
} from './lib/ui/components/KeyboardShortcutsDialog/DefaultKeyboardShortcutsDialog'
|
||||
export { DefaultKeyboardShortcutsDialogContent } from './lib/ui/components/KeyboardShortcutsDialog/DefaultKeyboardShortcutsDialogContent'
|
||||
export { LanguageMenu } from './lib/ui/components/LanguageMenu'
|
||||
export { InputModeMenu } from './lib/ui/components/InputModeMenu'
|
||||
export {
|
||||
DefaultMainMenu,
|
||||
type TLUiMainMenuProps,
|
||||
} from './lib/ui/components/MainMenu/DefaultMainMenu'
|
||||
export {
|
||||
DefaultMainMenuContent,
|
||||
EditSubmenu,
|
||||
ExportFileContentSubMenu,
|
||||
ExtrasGroup,
|
||||
LockGroup,
|
||||
MiscMenuGroup,
|
||||
PreferencesGroup,
|
||||
UndoRedoGroup,
|
||||
ViewSubmenu,
|
||||
} from './lib/ui/components/MainMenu/DefaultMainMenuContent'
|
||||
export {
|
||||
ArrangeMenuSubmenu,
|
||||
ClipboardMenuGroup,
|
||||
ConversionsMenuGroup,
|
||||
ConvertToBookmarkMenuItem,
|
||||
ConvertToEmbedMenuItem,
|
||||
CopyAsMenuGroup,
|
||||
CopyMenuItem,
|
||||
CursorChatItem,
|
||||
CutMenuItem,
|
||||
DeleteMenuItem,
|
||||
DuplicateMenuItem,
|
||||
EditLinkMenuItem,
|
||||
EditMenuSubmenu,
|
||||
ExportAsMenuGroup,
|
||||
FitFrameToContentMenuItem,
|
||||
GroupMenuItem,
|
||||
MoveToPageMenu,
|
||||
PasteMenuItem,
|
||||
PrintItem,
|
||||
RemoveFrameMenuItem,
|
||||
ReorderMenuSubmenu,
|
||||
SelectAllMenuItem,
|
||||
ToggleAutoSizeMenuItem,
|
||||
ToggleDebugModeItem,
|
||||
ToggleDynamicSizeModeItem,
|
||||
ToggleEdgeScrollingItem,
|
||||
ToggleEnhancedA11yModeItem,
|
||||
ToggleFocusModeItem,
|
||||
ToggleGridItem,
|
||||
ToggleInvertZoomItem,
|
||||
ToggleKeyboardShortcutsItem,
|
||||
ToggleLockMenuItem,
|
||||
TogglePasteAtCursorItem,
|
||||
ToggleReduceMotionItem,
|
||||
ToggleSnapModeItem,
|
||||
ToggleToolLockItem,
|
||||
ToggleTransparentBgMenuItem,
|
||||
ToggleWrapModeItem,
|
||||
UngroupMenuItem,
|
||||
UnlockAllMenuItem,
|
||||
ZoomTo100MenuItem,
|
||||
ZoomToFitMenuItem,
|
||||
ZoomToSelectionMenuItem,
|
||||
} from './lib/ui/components/menu-items'
|
||||
export { DefaultMinimap } from './lib/ui/components/Minimap/DefaultMinimap'
|
||||
export { MobileStylePanel } from './lib/ui/components/MobileStylePanel'
|
||||
export { DefaultNavigationPanel } from './lib/ui/components/NavigationPanel/DefaultNavigationPanel'
|
||||
export { OfflineIndicator } from './lib/ui/components/OfflineIndicator/OfflineIndicator'
|
||||
export { DefaultPageMenu } from './lib/ui/components/PageMenu/DefaultPageMenu'
|
||||
export { PageItemInput, type PageItemInputProps } from './lib/ui/components/PageMenu/PageItemInput'
|
||||
export {
|
||||
PageItemSubmenu,
|
||||
type PageItemSubmenuProps,
|
||||
} from './lib/ui/components/PageMenu/PageItemSubmenu'
|
||||
export {
|
||||
TldrawUiButton,
|
||||
type TLUiButtonProps,
|
||||
} from './lib/ui/components/primitives/Button/TldrawUiButton'
|
||||
export {
|
||||
TldrawUiButtonCheck,
|
||||
type TLUiButtonCheckProps,
|
||||
} from './lib/ui/components/primitives/Button/TldrawUiButtonCheck'
|
||||
export {
|
||||
TldrawUiButtonIcon,
|
||||
type TLUiButtonIconProps,
|
||||
} from './lib/ui/components/primitives/Button/TldrawUiButtonIcon'
|
||||
export {
|
||||
TldrawUiButtonLabel,
|
||||
type TLUiButtonLabelProps,
|
||||
} from './lib/ui/components/primitives/Button/TldrawUiButtonLabel'
|
||||
export {
|
||||
TldrawUiMenuCheckboxItem,
|
||||
type TLUiMenuCheckboxItemProps,
|
||||
} from './lib/ui/components/primitives/menus/TldrawUiMenuCheckboxItem'
|
||||
export {
|
||||
TldrawUiMenuContextProvider,
|
||||
type TLUiMenuContextProviderProps,
|
||||
type TLUiMenuContextType,
|
||||
} from './lib/ui/components/primitives/menus/TldrawUiMenuContext'
|
||||
export {
|
||||
TldrawUiMenuGroup,
|
||||
type TLUiMenuGroupProps,
|
||||
} from './lib/ui/components/primitives/menus/TldrawUiMenuGroup'
|
||||
export {
|
||||
TldrawUiMenuItem,
|
||||
type TLUiMenuItemProps,
|
||||
} from './lib/ui/components/primitives/menus/TldrawUiMenuItem'
|
||||
export {
|
||||
TldrawUiMenuSubmenu,
|
||||
type TLUiMenuSubmenuProps,
|
||||
} from './lib/ui/components/primitives/menus/TldrawUiMenuSubmenu'
|
||||
export {
|
||||
TldrawUiContextualToolbar,
|
||||
type TLUiContextualToolbarProps,
|
||||
} from './lib/ui/components/primitives/TldrawUiContextualToolbar'
|
||||
export {
|
||||
TldrawUiDialogBody,
|
||||
TldrawUiDialogCloseButton,
|
||||
TldrawUiDialogFooter,
|
||||
TldrawUiDialogHeader,
|
||||
TldrawUiDialogTitle,
|
||||
type TLUiDialogBodyProps,
|
||||
type TLUiDialogFooterProps,
|
||||
type TLUiDialogHeaderProps,
|
||||
type TLUiDialogTitleProps,
|
||||
} from './lib/ui/components/primitives/TldrawUiDialog'
|
||||
export {
|
||||
TldrawUiDropdownMenuCheckboxItem,
|
||||
TldrawUiDropdownMenuContent,
|
||||
TldrawUiDropdownMenuGroup,
|
||||
TldrawUiDropdownMenuIndicator,
|
||||
TldrawUiDropdownMenuItem,
|
||||
TldrawUiDropdownMenuRoot,
|
||||
TldrawUiDropdownMenuSub,
|
||||
TldrawUiDropdownMenuSubContent,
|
||||
TldrawUiDropdownMenuSubTrigger,
|
||||
TldrawUiDropdownMenuTrigger,
|
||||
type TLUiDropdownMenuCheckboxItemProps,
|
||||
type TLUiDropdownMenuContentProps,
|
||||
type TLUiDropdownMenuGroupProps,
|
||||
type TLUiDropdownMenuItemProps,
|
||||
type TLUiDropdownMenuRootProps,
|
||||
type TLUiDropdownMenuSubContentProps,
|
||||
type TLUiDropdownMenuSubProps,
|
||||
type TLUiDropdownMenuSubTriggerProps,
|
||||
type TLUiDropdownMenuTriggerProps,
|
||||
} from './lib/ui/components/primitives/TldrawUiDropdownMenu'
|
||||
export {
|
||||
TldrawUiIcon,
|
||||
type TLUiIconJsx,
|
||||
type TLUiIconProps,
|
||||
} from './lib/ui/components/primitives/TldrawUiIcon'
|
||||
export { TldrawUiInput, type TLUiInputProps } from './lib/ui/components/primitives/TldrawUiInput'
|
||||
export { TldrawUiKbd, type TLUiKbdProps } from './lib/ui/components/primitives/TldrawUiKbd'
|
||||
export {
|
||||
TldrawUiPopover,
|
||||
TldrawUiPopoverContent,
|
||||
TldrawUiPopoverTrigger,
|
||||
type TLUiPopoverContentProps,
|
||||
type TLUiPopoverProps,
|
||||
type TLUiPopoverTriggerProps,
|
||||
} from './lib/ui/components/primitives/TldrawUiPopover'
|
||||
export {
|
||||
TldrawUiSelect,
|
||||
TldrawUiSelectContent,
|
||||
TldrawUiSelectItem,
|
||||
TldrawUiSelectTrigger,
|
||||
TldrawUiSelectValue,
|
||||
type TLUiSelectContentProps,
|
||||
type TLUiSelectItemProps,
|
||||
type TLUiSelectProps,
|
||||
type TLUiSelectTriggerProps,
|
||||
type TLUiSelectValueProps,
|
||||
} from './lib/ui/components/primitives/TldrawUiSelect'
|
||||
export { TldrawUiSlider, type TLUiSliderProps } from './lib/ui/components/primitives/TldrawUiSlider'
|
||||
export {
|
||||
TldrawUiToolbar,
|
||||
TldrawUiToolbarButton,
|
||||
TldrawUiToolbarToggleGroup,
|
||||
TldrawUiToolbarToggleItem,
|
||||
type TLUiToolbarButtonProps,
|
||||
type TLUiToolbarProps,
|
||||
type TLUiToolbarToggleGroupProps,
|
||||
type TLUiToolbarToggleItemProps,
|
||||
} from './lib/ui/components/primitives/TldrawUiToolbar'
|
||||
export {
|
||||
hideAllTooltips,
|
||||
TldrawUiTooltip,
|
||||
TldrawUiTooltipProvider,
|
||||
type TldrawUiTooltipProps,
|
||||
type TldrawUiTooltipProviderProps,
|
||||
} from './lib/ui/components/primitives/TldrawUiTooltip'
|
||||
export {
|
||||
DefaultQuickActions,
|
||||
type TLUiQuickActionsProps,
|
||||
} from './lib/ui/components/QuickActions/DefaultQuickActions'
|
||||
export { DefaultQuickActionsContent } from './lib/ui/components/QuickActions/DefaultQuickActionsContent'
|
||||
export {
|
||||
DefaultPeopleMenu,
|
||||
// legacy
|
||||
DefaultPeopleMenu as PeopleMenu,
|
||||
type DefaultPeopleMenuProps,
|
||||
type DefaultPeopleMenuProps as PeopleMenuProps,
|
||||
} from './lib/ui/components/SharePanel/DefaultPeopleMenu'
|
||||
export {
|
||||
DefaultPeopleMenuAvatar,
|
||||
type TLUiPeopleMenuAvatarProps,
|
||||
} from './lib/ui/components/SharePanel/DefaultPeopleMenuAvatar'
|
||||
export {
|
||||
DefaultPeopleMenuContent,
|
||||
type DefaultPeopleMenuContentProps,
|
||||
} from './lib/ui/components/SharePanel/DefaultPeopleMenuContent'
|
||||
export {
|
||||
DefaultPeopleMenuFacePile,
|
||||
type TLUiPeopleMenuFacePileProps,
|
||||
} from './lib/ui/components/SharePanel/DefaultPeopleMenuFacePile'
|
||||
export {
|
||||
DefaultPeopleMenuItem,
|
||||
type TLUiPeopleMenuItemProps,
|
||||
} from './lib/ui/components/SharePanel/DefaultPeopleMenuItem'
|
||||
export { DefaultSharePanel } from './lib/ui/components/SharePanel/DefaultSharePanel'
|
||||
export { DefaultUserPresenceEditor } from './lib/ui/components/SharePanel/DefaultUserPresenceEditor'
|
||||
export { Spinner } from './lib/ui/components/Spinner'
|
||||
export {
|
||||
DefaultStylePanel,
|
||||
type TLUiStylePanelProps,
|
||||
} from './lib/ui/components/StylePanel/DefaultStylePanel'
|
||||
export {
|
||||
DefaultStylePanelContent,
|
||||
StylePanelArrowheadPicker,
|
||||
StylePanelArrowKindPicker,
|
||||
StylePanelColorPicker,
|
||||
StylePanelDashPicker,
|
||||
StylePanelFillPicker,
|
||||
StylePanelFontPicker,
|
||||
StylePanelGeoShapePicker,
|
||||
StylePanelLabelAlignPicker,
|
||||
StylePanelOpacityPicker,
|
||||
StylePanelSection,
|
||||
StylePanelSizePicker,
|
||||
StylePanelSplinePicker,
|
||||
StylePanelTextAlignPicker,
|
||||
type StylePanelSectionProps,
|
||||
} from './lib/ui/components/StylePanel/DefaultStylePanelContent'
|
||||
export {
|
||||
StylePanelButtonPicker,
|
||||
StylePanelButtonPickerInline,
|
||||
type StylePanelButtonPickerProps,
|
||||
} from './lib/ui/components/StylePanel/StylePanelButtonPicker'
|
||||
export {
|
||||
StylePanelContextProvider,
|
||||
useStylePanelContext,
|
||||
type StylePanelContext,
|
||||
type StylePanelContextProviderProps,
|
||||
} from './lib/ui/components/StylePanel/StylePanelContext'
|
||||
export {
|
||||
StylePanelDoubleDropdownPicker,
|
||||
StylePanelDoubleDropdownPickerInline,
|
||||
type StylePanelDoubleDropdownPickerProps,
|
||||
} from './lib/ui/components/StylePanel/StylePanelDoubleDropdownPicker'
|
||||
export {
|
||||
StylePanelDropdownPicker,
|
||||
StylePanelDropdownPickerInline,
|
||||
type StylePanelDropdownPickerProps,
|
||||
} from './lib/ui/components/StylePanel/StylePanelDropdownPicker'
|
||||
export {
|
||||
StylePanelSubheading,
|
||||
type StylePanelSubheadingProps,
|
||||
} from './lib/ui/components/StylePanel/StylePanelSubheading'
|
||||
export {
|
||||
DefaultImageToolbar,
|
||||
type TLUiImageToolbarProps,
|
||||
} from './lib/ui/components/Toolbar/DefaultImageToolbar'
|
||||
export {
|
||||
DefaultImageToolbarContent,
|
||||
type DefaultImageToolbarContentProps,
|
||||
} from './lib/ui/components/Toolbar/DefaultImageToolbarContent'
|
||||
export {
|
||||
DefaultRichTextToolbar,
|
||||
type TLUiRichTextToolbarProps,
|
||||
} from './lib/ui/components/Toolbar/DefaultRichTextToolbar'
|
||||
export {
|
||||
DefaultRichTextToolbarContent,
|
||||
type DefaultRichTextToolbarContentProps,
|
||||
} from './lib/ui/components/Toolbar/DefaultRichTextToolbarContent'
|
||||
export {
|
||||
DefaultToolbar,
|
||||
type DefaultToolbarProps,
|
||||
} from './lib/ui/components/Toolbar/DefaultToolbar'
|
||||
export {
|
||||
ArrowDownToolbarItem,
|
||||
ArrowLeftToolbarItem,
|
||||
ArrowRightToolbarItem,
|
||||
ArrowToolbarItem,
|
||||
ArrowUpToolbarItem,
|
||||
AssetToolbarItem,
|
||||
CheckBoxToolbarItem,
|
||||
CloudToolbarItem,
|
||||
CommentToolbarItem,
|
||||
DefaultToolbarContent,
|
||||
DiamondToolbarItem,
|
||||
DrawToolbarItem,
|
||||
EllipseToolbarItem,
|
||||
EraserToolbarItem,
|
||||
FrameToolbarItem,
|
||||
HandToolbarItem,
|
||||
HeartToolbarItem,
|
||||
HexagonToolbarItem,
|
||||
HighlightToolbarItem,
|
||||
LaserToolbarItem,
|
||||
LineToolbarItem,
|
||||
NoteToolbarItem,
|
||||
OvalToolbarItem,
|
||||
RectangleToolbarItem,
|
||||
RhombusToolbarItem,
|
||||
SelectToolbarItem,
|
||||
StarToolbarItem,
|
||||
TextToolbarItem,
|
||||
ToolbarItem,
|
||||
TrapezoidToolbarItem,
|
||||
TriangleToolbarItem,
|
||||
useIsToolSelected,
|
||||
XBoxToolbarItem,
|
||||
type ToolbarItemProps,
|
||||
} from './lib/ui/components/Toolbar/DefaultToolbarContent'
|
||||
export {
|
||||
DefaultVideoToolbar,
|
||||
type TLUiVideoToolbarProps,
|
||||
} from './lib/ui/components/Toolbar/DefaultVideoToolbar'
|
||||
export {
|
||||
DefaultVideoToolbarContent,
|
||||
type DefaultVideoToolbarContentProps,
|
||||
} from './lib/ui/components/Toolbar/DefaultVideoToolbarContent'
|
||||
export {
|
||||
OverflowingToolbar,
|
||||
type OverflowingToolbarProps,
|
||||
} from './lib/ui/components/Toolbar/OverflowingToolbar'
|
||||
export {
|
||||
ToggleToolLockedButton,
|
||||
type ToggleToolLockedButtonProps,
|
||||
} from './lib/ui/components/Toolbar/ToggleToolLockedButton'
|
||||
export {
|
||||
CenteredTopPanelContainer,
|
||||
type CenteredTopPanelContainerProps,
|
||||
} from './lib/ui/components/TopPanel/CenteredTopPanelContainer'
|
||||
export {
|
||||
DefaultZoomMenu,
|
||||
type TLUiZoomMenuProps,
|
||||
} from './lib/ui/components/ZoomMenu/DefaultZoomMenu'
|
||||
export { DefaultZoomMenuContent } from './lib/ui/components/ZoomMenu/DefaultZoomMenuContent'
|
||||
export { PORTRAIT_BREAKPOINT } from './lib/ui/constants'
|
||||
export {
|
||||
TldrawUiA11yProvider,
|
||||
useA11y,
|
||||
type A11yPriority,
|
||||
type A11yProviderProps,
|
||||
type TLUiA11y,
|
||||
type TLUiA11yContextType,
|
||||
} from './lib/ui/context/a11y'
|
||||
export {
|
||||
unwrapLabel,
|
||||
useActions,
|
||||
type ActionsProviderProps,
|
||||
type TLUiActionItem,
|
||||
type TLUiActionsContextType,
|
||||
} from './lib/ui/context/actions'
|
||||
export {
|
||||
AssetUrlsProvider,
|
||||
useAssetUrls,
|
||||
type AssetUrlsProviderProps,
|
||||
} from './lib/ui/context/asset-urls'
|
||||
export {
|
||||
BreakPointProvider,
|
||||
useBreakpoint,
|
||||
type BreakPointProviderProps,
|
||||
} from './lib/ui/context/breakpoints'
|
||||
export {
|
||||
TldrawUiComponentsProvider,
|
||||
useTldrawUiComponents,
|
||||
type TLUiComponents,
|
||||
type TLUiComponentsProviderProps,
|
||||
} from './lib/ui/context/components'
|
||||
export {
|
||||
TldrawUiDialogsProvider,
|
||||
useDialogs,
|
||||
type TLUiDialog,
|
||||
type TLUiDialogProps,
|
||||
type TLUiDialogsContextType,
|
||||
type TLUiDialogsProviderProps,
|
||||
} from './lib/ui/context/dialogs'
|
||||
export {
|
||||
TldrawUiEventsProvider,
|
||||
useUiEvents,
|
||||
type EventsProviderProps,
|
||||
type TLUiEventContextType,
|
||||
type TLUiEventData,
|
||||
type TLUiEventHandler,
|
||||
type TLUiEventMap,
|
||||
type TLUiEventSource,
|
||||
} from './lib/ui/context/events'
|
||||
export {
|
||||
TldrawUiContextProvider,
|
||||
type TLUiContextProviderProps,
|
||||
} from './lib/ui/context/TldrawUiContextProvider'
|
||||
export {
|
||||
TldrawUiToastsProvider,
|
||||
useToasts,
|
||||
type AlertSeverity,
|
||||
type TLUiToast,
|
||||
type TLUiToastAction,
|
||||
type TLUiToastsContextType,
|
||||
type TLUiToastsProviderProps,
|
||||
} from './lib/ui/context/toasts'
|
||||
export {
|
||||
useCanApplySelectionAction,
|
||||
useCanRedo,
|
||||
useCanUndo,
|
||||
useUnlockedSelectedShapesCount,
|
||||
} from './lib/ui/hooks/menu-hooks'
|
||||
export {
|
||||
handleNativeOrMenuCopy,
|
||||
useMenuClipboardEvents,
|
||||
useNativeClipboardEvents,
|
||||
} from './lib/ui/hooks/useClipboardEvents'
|
||||
export {
|
||||
useCollaborationStatus,
|
||||
useShowCollaborationUi,
|
||||
} from './lib/ui/hooks/useCollaborationStatus'
|
||||
export { useCopyAs } from './lib/ui/hooks/useCopyAs'
|
||||
export { useExportAs } from './lib/ui/hooks/useExportAs'
|
||||
export { useKeyboardShortcuts } from './lib/ui/hooks/useKeyboardShortcuts'
|
||||
export { useLocalStorageState } from './lib/ui/hooks/useLocalStorageState'
|
||||
export { useMenuIsOpen } from './lib/ui/hooks/useMenuIsOpen'
|
||||
export { useReadonly } from './lib/ui/hooks/useReadonly'
|
||||
export { useRelevantStyles } from './lib/ui/hooks/useRelevantStyles'
|
||||
export {
|
||||
onDragFromToolbarToCreateShape,
|
||||
useTools,
|
||||
type OnDragFromToolbarToCreateShapesOpts,
|
||||
type TLUiToolItem,
|
||||
type TLUiToolsContextType,
|
||||
type TLUiToolsProviderProps,
|
||||
} from './lib/ui/hooks/useTools'
|
||||
export { type TLUiTranslationKey } from './lib/ui/hooks/useTranslation/TLUiTranslationKey'
|
||||
export { RTL_LANGUAGES, type TLUiTranslation } from './lib/ui/hooks/useTranslation/translations'
|
||||
export {
|
||||
useCurrentTranslation,
|
||||
useDirection,
|
||||
useMaybeCurrentTranslation,
|
||||
useTranslation,
|
||||
type TLUiTranslationContextType,
|
||||
type TLUiTranslationProviderProps,
|
||||
} from './lib/ui/hooks/useTranslation/useTranslation'
|
||||
export { iconTypes, type TLUiIconType } from './lib/ui/icon-types'
|
||||
export { useDefaultHelpers, type TLUiOverrideHelpers, type TLUiOverrides } from './lib/ui/overrides'
|
||||
export { TldrawUi, TldrawUiInFrontOfTheCanvas, type TldrawUiProps } from './lib/ui/TldrawUi'
|
||||
export { containBoxSize, downsizeImage, type BoxWidthHeight } from './lib/utils/assets/assets'
|
||||
export { preloadFont, type TLTypeFace } from './lib/utils/assets/preload-font'
|
||||
export { getEmbedInfo, type TLEmbedResult } from './lib/utils/embeds/embeds'
|
||||
export { putExcalidrawContent } from './lib/utils/excalidraw/putExcalidrawContent'
|
||||
export { copyAs, type CopyAsOptions, type TLCopyType } from './lib/utils/export/copyAs'
|
||||
export { downloadFile, exportAs, type ExportAsOptions } from './lib/utils/export/exportAs'
|
||||
export { fitFrameToContent, removeFrame } from './lib/utils/frames/frames'
|
||||
export {
|
||||
defaultEditorAssetUrls,
|
||||
setDefaultEditorAssetUrls,
|
||||
type TLEditorAssetUrls,
|
||||
} from './lib/utils/static-assets/assetUrls'
|
||||
export { sanitizeSvg } from './lib/utils/svg/sanitizeSvg'
|
||||
export {
|
||||
defaultAddFontsFromNode,
|
||||
getTipTapDefaultExtensions,
|
||||
KeyboardShiftEnterTweakExtension,
|
||||
renderHtmlFromRichText,
|
||||
renderHtmlFromRichTextForMeasurement,
|
||||
renderHtmlFromRichTextWithExtensions,
|
||||
renderPlaintextFromRichText,
|
||||
renderRichTextFromHTML,
|
||||
tipTapDefaultExtensions,
|
||||
} from './lib/utils/text/richText'
|
||||
export { truncateStringWithEllipsis } from './lib/utils/text/text'
|
||||
export {
|
||||
Extension,
|
||||
getSchema,
|
||||
Mark,
|
||||
mergeAttributes,
|
||||
Node,
|
||||
TaskItem,
|
||||
TaskList,
|
||||
type Extensions,
|
||||
type JSONContent,
|
||||
type TaskItemOptions,
|
||||
type TaskListOptions,
|
||||
} from './lib/utils/text/tiptap'
|
||||
export {
|
||||
defaultWrappingPairs,
|
||||
WrapSelectionExtension,
|
||||
type WrapSelectionOptions,
|
||||
} from './lib/utils/text/wrapSelection'
|
||||
export {
|
||||
buildFromV1Document,
|
||||
TLV1AlignStyle,
|
||||
TLV1AssetType,
|
||||
TLV1ColorStyle,
|
||||
TLV1DashStyle,
|
||||
TLV1Decoration,
|
||||
TLV1FontStyle,
|
||||
TLV1ShapeType,
|
||||
TLV1SizeStyle,
|
||||
type TLV1ArrowBinding,
|
||||
type TLV1ArrowShape,
|
||||
type TLV1Asset,
|
||||
type TLV1BaseAsset,
|
||||
type TLV1BaseBinding,
|
||||
type TLV1BaseShape,
|
||||
type TLV1Binding,
|
||||
type TLV1Bounds,
|
||||
type TLV1Document,
|
||||
type TLV1DrawShape,
|
||||
type TLV1EllipseShape,
|
||||
type TLV1GroupShape,
|
||||
type TLV1Handle,
|
||||
type TLV1ImageAsset,
|
||||
type TLV1ImageShape,
|
||||
type TLV1Page,
|
||||
type TLV1PageState,
|
||||
type TLV1RectangleShape,
|
||||
type TLV1Shape,
|
||||
type TLV1ShapeStyles,
|
||||
type TLV1StickyShape,
|
||||
type TLV1TextShape,
|
||||
type TLV1TriangleShape,
|
||||
type TLV1VideoAsset,
|
||||
type TLV1VideoShape,
|
||||
} from './lib/utils/tldr/buildFromV1Document'
|
||||
export {
|
||||
parseAndLoadDocument,
|
||||
parseTldrawJsonFile,
|
||||
serializeTldrawJson,
|
||||
serializeTldrawJsonBlob,
|
||||
TLDRAW_FILE_EXTENSION,
|
||||
type TldrawFile,
|
||||
type TldrawFileParseError,
|
||||
} from './lib/utils/tldr/file'
|
||||
|
||||
registerTldrawLibraryVersion(
|
||||
(globalThis as any).TLDRAW_LIBRARY_NAME,
|
||||
(globalThis as any).TLDRAW_LIBRARY_VERSION,
|
||||
(globalThis as any).TLDRAW_LIBRARY_MODULES
|
||||
)
|
||||
325
packages/tldraw/src/lib/Tldraw.test.tsx
Normal file
325
packages/tldraw/src/lib/Tldraw.test.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
385
packages/tldraw/src/lib/Tldraw.tsx
Normal file
385
packages/tldraw/src/lib/Tldraw.tsx
Normal 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
|
||||
}
|
||||
237
packages/tldraw/src/lib/TldrawImage.tsx
Normal file
237
packages/tldraw/src/lib/TldrawImage.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
23
packages/tldraw/src/lib/assets/BookmarkAssetUtil.ts
Normal file
23
packages/tldraw/src/lib/assets/BookmarkAssetUtil.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
116
packages/tldraw/src/lib/assets/ImageAssetUtil.test.ts
Normal file
116
packages/tldraw/src/lib/assets/ImageAssetUtil.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
77
packages/tldraw/src/lib/assets/ImageAssetUtil.ts
Normal file
77
packages/tldraw/src/lib/assets/ImageAssetUtil.ts
Normal 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
|
||||
}
|
||||
}
|
||||
64
packages/tldraw/src/lib/assets/VideoAssetUtil.ts
Normal file
64
packages/tldraw/src/lib/assets/VideoAssetUtil.ts
Normal 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: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
406
packages/tldraw/src/lib/bindings/arrow/ArrowBindingUtil.ts
Normal file
406
packages/tldraw/src/lib/bindings/arrow/ArrowBindingUtil.ts
Normal 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)
|
||||
}
|
||||
6
packages/tldraw/src/lib/defaultAssetUtils.ts
Normal file
6
packages/tldraw/src/lib/defaultAssetUtils.ts
Normal 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
|
||||
4
packages/tldraw/src/lib/defaultBindingUtils.ts
Normal file
4
packages/tldraw/src/lib/defaultBindingUtils.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { ArrowBindingUtil } from './bindings/arrow/ArrowBindingUtil'
|
||||
|
||||
/** @public */
|
||||
export const defaultBindingUtils = [ArrowBindingUtil] as const
|
||||
788
packages/tldraw/src/lib/defaultEmbedDefinitions.ts
Normal file
788
packages/tldraw/src/lib/defaultEmbedDefinitions.ts
Normal 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}¢er=${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
|
||||
}
|
||||
328
packages/tldraw/src/lib/defaultExternalContentHandlers.test.ts
Normal file
328
packages/tldraw/src/lib/defaultExternalContentHandlers.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
970
packages/tldraw/src/lib/defaultExternalContentHandlers.ts
Normal file
970
packages/tldraw/src/lib/defaultExternalContentHandlers.ts
Normal 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)
|
||||
}
|
||||
30
packages/tldraw/src/lib/defaultOverlayUtils.ts
Normal file
30
packages/tldraw/src/lib/defaultOverlayUtils.ts
Normal 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
|
||||
20
packages/tldraw/src/lib/defaultShapeTools.ts
Normal file
20
packages/tldraw/src/lib/defaultShapeTools.ts
Normal 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
|
||||
28
packages/tldraw/src/lib/defaultShapeUtils.ts
Normal file
28
packages/tldraw/src/lib/defaultShapeUtils.ts
Normal 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
|
||||
99
packages/tldraw/src/lib/defaultSideEffects.ts
Normal file
99
packages/tldraw/src/lib/defaultSideEffects.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
8
packages/tldraw/src/lib/defaultTools.ts
Normal file
8
packages/tldraw/src/lib/defaultTools.ts
Normal 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
|
||||
258
packages/tldraw/src/lib/overlays/ArrowBindingHintOverlayUtil.ts
Normal file
258
packages/tldraw/src/lib/overlays/ArrowBindingHintOverlayUtil.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
215
packages/tldraw/src/lib/overlays/ArrowHintOverlayUtil.ts
Normal file
215
packages/tldraw/src/lib/overlays/ArrowHintOverlayUtil.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
108
packages/tldraw/src/lib/overlays/BrushOverlayUtil.ts
Normal file
108
packages/tldraw/src/lib/overlays/BrushOverlayUtil.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
143
packages/tldraw/src/lib/overlays/ScribbleOverlayUtil.ts
Normal file
143
packages/tldraw/src/lib/overlays/ScribbleOverlayUtil.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
}
|
||||
}
|
||||
211
packages/tldraw/src/lib/overlays/ShapeHandleOverlayUtil.ts
Normal file
211
packages/tldraw/src/lib/overlays/ShapeHandleOverlayUtil.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
133
packages/tldraw/src/lib/overlays/ShapeIndicatorOverlayUtil.ts
Normal file
133
packages/tldraw/src/lib/overlays/ShapeIndicatorOverlayUtil.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
195
packages/tldraw/src/lib/overlays/SnapIndicatorOverlayUtil.ts
Normal file
195
packages/tldraw/src/lib/overlays/SnapIndicatorOverlayUtil.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
75
packages/tldraw/src/lib/overlays/ZoomBrushOverlayUtil.ts
Normal file
75
packages/tldraw/src/lib/overlays/ZoomBrushOverlayUtil.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
73
packages/tldraw/src/lib/shapes/arrow/ArrowPath.tsx
Normal file
73
packages/tldraw/src/lib/shapes/arrow/ArrowPath.tsx
Normal 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')
|
||||
}
|
||||
}
|
||||
305
packages/tldraw/src/lib/shapes/arrow/ArrowShapeOptions.test.ts
Normal file
305
packages/tldraw/src/lib/shapes/arrow/ArrowShapeOptions.test.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
807
packages/tldraw/src/lib/shapes/arrow/ArrowShapeTool.test.ts
Normal file
807
packages/tldraw/src/lib/shapes/arrow/ArrowShapeTool.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
14
packages/tldraw/src/lib/shapes/arrow/ArrowShapeTool.ts
Normal file
14
packages/tldraw/src/lib/shapes/arrow/ArrowShapeTool.ts
Normal 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'
|
||||
}
|
||||
1135
packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.test.ts
Normal file
1135
packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1281
packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx
Normal file
1281
packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx
Normal file
File diff suppressed because it is too large
Load Diff
178
packages/tldraw/src/lib/shapes/arrow/arrow-types.ts
Normal file
178
packages/tldraw/src/lib/shapes/arrow/arrow-types.ts
Normal 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
|
||||
324
packages/tldraw/src/lib/shapes/arrow/arrowLabel.ts
Normal file
324
packages/tldraw/src/lib/shapes/arrow/arrowLabel.ts
Normal 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)
|
||||
}
|
||||
398
packages/tldraw/src/lib/shapes/arrow/arrowTargetState.ts
Normal file
398
packages/tldraw/src/lib/shapes/arrow/arrowTargetState.ts
Normal 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()
|
||||
)
|
||||
}
|
||||
163
packages/tldraw/src/lib/shapes/arrow/arrowheads.ts
Normal file
163
packages/tldraw/src/lib/shapes/arrow/arrowheads.ts
Normal 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 ''
|
||||
}
|
||||
484
packages/tldraw/src/lib/shapes/arrow/curved-arrow.ts
Normal file
484
packages/tldraw/src/lib/shapes/arrow/curved-arrow.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
215
packages/tldraw/src/lib/shapes/arrow/elbow/ElbowArrowDebug.tsx
Normal file
215
packages/tldraw/src/lib/shapes/arrow/elbow/ElbowArrowDebug.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
350
packages/tldraw/src/lib/shapes/arrow/elbow/definitions.ts
Normal file
350
packages/tldraw/src/lib/shapes/arrow/elbow/definitions.ts
Normal 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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
901
packages/tldraw/src/lib/shapes/arrow/elbow/getElbowArrowInfo.tsx
Normal file
901
packages/tldraw/src/lib/shapes/arrow/elbow/getElbowArrowInfo.tsx
Normal 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
|
||||
}
|
||||
81
packages/tldraw/src/lib/shapes/arrow/elbow/range.tsx
Normal file
81
packages/tldraw/src/lib/shapes/arrow/elbow/range.tsx
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
69
packages/tldraw/src/lib/shapes/arrow/getArrowInfo.ts
Normal file
69
packages/tldraw/src/lib/shapes/arrow/getArrowInfo.ts
Normal 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)
|
||||
}
|
||||
302
packages/tldraw/src/lib/shapes/arrow/shared.ts
Normal file
302
packages/tldraw/src/lib/shapes/arrow/shared.ts
Normal 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)
|
||||
}
|
||||
284
packages/tldraw/src/lib/shapes/arrow/straight-arrow.ts
Normal file
284
packages/tldraw/src/lib/shapes/arrow/straight-arrow.ts
Normal 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
|
||||
}
|
||||
80
packages/tldraw/src/lib/shapes/arrow/toolStates/Idle.tsx
Normal file
80
packages/tldraw/src/lib/shapes/arrow/toolStates/Idle.tsx
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
206
packages/tldraw/src/lib/shapes/arrow/toolStates/Pointing.tsx
Normal file
206
packages/tldraw/src/lib/shapes/arrow/toolStates/Pointing.tsx
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
258
packages/tldraw/src/lib/shapes/bookmark/BookmarkShapeUtil.tsx
Normal file
258
packages/tldraw/src/lib/shapes/bookmark/BookmarkShapeUtil.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
267
packages/tldraw/src/lib/shapes/bookmark/bookmarks.ts
Normal file
267
packages/tldraw/src/lib/shapes/bookmark/bookmarks.ts
Normal 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' }
|
||||
)
|
||||
}
|
||||
277
packages/tldraw/src/lib/shapes/draw/DrawShapeTool.test.ts
Normal file
277
packages/tldraw/src/lib/shapes/draw/DrawShapeTool.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
21
packages/tldraw/src/lib/shapes/draw/DrawShapeTool.ts
Normal file
21
packages/tldraw/src/lib/shapes/draw/DrawShapeTool.ts
Normal 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
|
||||
}
|
||||
}
|
||||
212
packages/tldraw/src/lib/shapes/draw/DrawShapeUtil.test.ts
Normal file
212
packages/tldraw/src/lib/shapes/draw/DrawShapeUtil.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
404
packages/tldraw/src/lib/shapes/draw/DrawShapeUtil.tsx
Normal file
404
packages/tldraw/src/lib/shapes/draw/DrawShapeUtil.tsx
Normal 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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
167
packages/tldraw/src/lib/shapes/draw/getPath.ts
Normal file
167
packages/tldraw/src/lib/shapes/draw/getPath.ts
Normal 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]
|
||||
}
|
||||
792
packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts
Normal file
792
packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
22
packages/tldraw/src/lib/shapes/draw/toolStates/Idle.ts
Normal file
22
packages/tldraw/src/lib/shapes/draw/toolStates/Idle.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
470
packages/tldraw/src/lib/shapes/embed/EmbedShapeUtil.tsx
Normal file
470
packages/tldraw/src/lib/shapes/embed/EmbedShapeUtil.tsx
Normal 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>`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
186
packages/tldraw/src/lib/shapes/frame/FrameShapeTool.test.ts
Normal file
186
packages/tldraw/src/lib/shapes/frame/FrameShapeTool.test.ts
Normal 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')
|
||||
})
|
||||
66
packages/tldraw/src/lib/shapes/frame/FrameShapeTool.ts
Normal file
66
packages/tldraw/src/lib/shapes/frame/FrameShapeTool.ts
Normal 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
|
||||
}
|
||||
417
packages/tldraw/src/lib/shapes/frame/FrameShapeUtil.tsx
Normal file
417
packages/tldraw/src/lib/shapes/frame/FrameShapeUtil.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
121
packages/tldraw/src/lib/shapes/frame/frameHelpers.ts
Normal file
121
packages/tldraw/src/lib/shapes/frame/frameHelpers.ts
Normal 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
|
||||
}
|
||||
55
packages/tldraw/src/lib/shapes/geo/GeoShapeBody.tsx
Normal file
55
packages/tldraw/src/lib/shapes/geo/GeoShapeBody.tsx
Normal 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 },
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
268
packages/tldraw/src/lib/shapes/geo/GeoShapeTool.test.ts
Normal file
268
packages/tldraw/src/lib/shapes/geo/GeoShapeTool.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
13
packages/tldraw/src/lib/shapes/geo/GeoShapeTool.ts
Normal file
13
packages/tldraw/src/lib/shapes/geo/GeoShapeTool.ts
Normal 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'
|
||||
}
|
||||
418
packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.test.tsx
Normal file
418
packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.test.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
1004
packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx
Normal file
1004
packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx
Normal file
File diff suppressed because it is too large
Load Diff
719
packages/tldraw/src/lib/shapes/geo/getGeoShapePath.ts
Normal file
719
packages/tldraw/src/lib/shapes/geo/getGeoShapePath.ts
Normal 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()
|
||||
}
|
||||
27
packages/tldraw/src/lib/shapes/geo/toolStates/Idle.ts
Normal file
27
packages/tldraw/src/lib/shapes/geo/toolStates/Idle.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
146
packages/tldraw/src/lib/shapes/geo/toolStates/Pointing.ts
Normal file
146
packages/tldraw/src/lib/shapes/geo/toolStates/Pointing.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
describe('Highlight shape tool', () => {
|
||||
it.todo('tests the highlight shape tool')
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
357
packages/tldraw/src/lib/shapes/highlight/HighlightShapeUtil.tsx
Normal file
357
packages/tldraw/src/lib/shapes/highlight/HighlightShapeUtil.tsx
Normal 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]
|
||||
)
|
||||
}
|
||||
168
packages/tldraw/src/lib/shapes/image/ImageAlphaCache.ts
Normal file
168
packages/tldraw/src/lib/shapes/image/ImageAlphaCache.ts
Normal 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
|
||||
}
|
||||
61
packages/tldraw/src/lib/shapes/image/ImageAlphaGeometry.ts
Normal file
61
packages/tldraw/src/lib/shapes/image/ImageAlphaGeometry.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
655
packages/tldraw/src/lib/shapes/image/ImageShapeUtil.tsx
Normal file
655
packages/tldraw/src/lib/shapes/image/ImageShapeUtil.tsx
Normal 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) }
|
||||
}
|
||||
293
packages/tldraw/src/lib/shapes/line/LineShapeTool.test.ts
Normal file
293
packages/tldraw/src/lib/shapes/line/LineShapeTool.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
14
packages/tldraw/src/lib/shapes/line/LineShapeTool.ts
Normal file
14
packages/tldraw/src/lib/shapes/line/LineShapeTool.ts
Normal 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'
|
||||
}
|
||||
531
packages/tldraw/src/lib/shapes/line/LineShapeUtil.test.tsx
Normal file
531
packages/tldraw/src/lib/shapes/line/LineShapeUtil.test.tsx
Normal 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)!
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user