Custom Tools DX + screenshot example (#2198)

This PR adds a custom tool example, the `Screenshot Tool`.

It demonstrates how a user can create a custom tool together with custom
tool UI.

### Change Type

- [x] `minor` — New feature

### Test Plan

1. Use the screenshot example

### Release Notes

- adds ScreenshotTool custom tool example
- improvements and new exports related to copying and exporting images /
files
- loosens up types around icons and translations
- moving `StateNode.isActive` into an atom
- adding `Editor.path`
This commit is contained in:
Steve Ruiz 2023-11-15 18:06:02 +00:00 committed by GitHub
parent d683cc0943
commit 14e8d19a71
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
116 changed files with 2559 additions and 1519 deletions

View file

@ -1,249 +1,234 @@
export {} import test, { Page, expect } from '@playwright/test'
import { Editor, TLShapeId, TLShapePartial } from '@tldraw/tldraw'
import assert from 'assert'
import { rename, writeFile } from 'fs/promises'
import { setupPage } from '../shared-e2e'
// import test, { Page, expect } from '@playwright/test' declare const editor: Editor
// import { Editor, TLShapeId, TLShapePartial } from '@tldraw/tldraw'
// import assert from 'assert'
// import { rename, writeFile } from 'fs/promises'
// import { setupPage } from '../shared-e2e'
// declare const editor: Editor test.describe('Export snapshots', () => {
const snapshots = {
'Exports geo text with leading line breaks': [
{
id: 'shape:testShape' as TLShapeId,
type: 'geo',
props: {
w: 100,
h: 30,
text: '\n\n\n\n\n\ntext',
},
},
],
'Exports geo text with trailing line breaks': [
{
id: 'shape:testShape' as TLShapeId,
type: 'geo',
props: {
w: 100,
h: 30,
text: 'text\n\n\n\n\n\n',
},
},
],
} as Record<string, TLShapePartial[]>
// test.describe('Export snapshots', () => { for (const fill of ['none', 'semi', 'solid', 'pattern']) {
// const snapshots = { snapshots[`geo fill=${fill}`] = [
// 'Exports geo text with leading line breaks': [ {
// { id: 'shape:testShape' as TLShapeId,
// id: 'shape:testShape' as TLShapeId, type: 'geo',
// type: 'geo', props: {
// props: { fill,
// w: 100, color: 'green',
// h: 30, w: 100,
// text: '\n\n\n\n\n\ntext', h: 100,
// }, },
// }, },
// ], ]
// 'Exports geo text with trailing line breaks': [
// {
// id: 'shape:testShape' as TLShapeId,
// type: 'geo',
// props: {
// w: 100,
// h: 30,
// text: 'text\n\n\n\n\n\n',
// },
// },
// ],
// } as Record<string, TLShapePartial[]>
// for (const fill of ['none', 'semi', 'solid', 'pattern']) { snapshots[`arrow fill=${fill}`] = [
// snapshots[`geo fill=${fill}`] = [ {
// { id: 'shape:testShape' as TLShapeId,
// id: 'shape:testShape' as TLShapeId, type: 'arrow',
// type: 'geo', props: {
// props: { color: 'light-green',
// fill, fill: fill,
// color: 'green', arrowheadStart: 'square',
// w: 100, arrowheadEnd: 'dot',
// h: 100, start: { type: 'point', x: 0, y: 0 },
// }, end: { type: 'point', x: 100, y: 100 },
// }, bend: 20,
// ] },
},
]
// snapshots[`arrow fill=${fill}`] = [ snapshots[`draw fill=${fill}`] = [
// { {
// id: 'shape:testShape' as TLShapeId, id: 'shape:testShape' as TLShapeId,
// type: 'arrow', type: 'draw',
// props: { props: {
// color: 'light-green', color: 'light-violet',
// fill: fill, fill: fill,
// arrowheadStart: 'square', segments: [
// arrowheadEnd: 'dot', {
// start: { type: 'point', x: 0, y: 0 }, type: 'straight',
// end: { type: 'point', x: 100, y: 100 }, points: [{ x: 0, y: 0 }],
// bend: 20, },
// }, {
// }, type: 'straight',
// ] points: [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
],
},
{
type: 'straight',
points: [
{ x: 100, y: 0 },
{ x: 0, y: 100 },
],
},
{
type: 'straight',
points: [
{ x: 0, y: 100 },
{ x: 100, y: 100 },
],
},
{
type: 'straight',
points: [
{ x: 100, y: 100 },
{ x: 0, y: 0 },
],
},
],
isClosed: true,
isComplete: true,
},
},
]
}
// snapshots[`draw fill=${fill}`] = [ for (const font of ['draw', 'sans', 'serif', 'mono']) {
// { snapshots[`geo font=${font}`] = [
// id: 'shape:testShape' as TLShapeId, {
// type: 'draw', id: 'shape:testShape' as TLShapeId,
// props: { type: 'geo',
// color: 'light-violet', props: {
// fill: fill, text: 'test',
// segments: [ color: 'blue',
// { font,
// type: 'straight', w: 100,
// points: [{ x: 0, y: 0 }], h: 100,
// }, },
// { },
// type: 'straight', ]
// points: [
// { x: 0, y: 0 },
// { x: 100, y: 0 },
// ],
// },
// {
// type: 'straight',
// points: [
// { x: 100, y: 0 },
// { x: 0, y: 100 },
// ],
// },
// {
// type: 'straight',
// points: [
// { x: 0, y: 100 },
// { x: 100, y: 100 },
// ],
// },
// {
// type: 'straight',
// points: [
// { x: 100, y: 100 },
// { x: 0, y: 0 },
// ],
// },
// ],
// isClosed: true,
// isComplete: true,
// },
// },
// ]
// }
// for (const font of ['draw', 'sans', 'serif', 'mono']) { snapshots[`arrow font=${font}`] = [
// snapshots[`geo font=${font}`] = [ {
// { id: 'shape:testShape' as TLShapeId,
// id: 'shape:testShape' as TLShapeId, type: 'arrow',
// type: 'geo', props: {
// props: { color: 'blue',
// text: 'test', fill: 'solid',
// color: 'blue', arrowheadStart: 'square',
// font, arrowheadEnd: 'arrow',
// w: 100, font,
// h: 100, start: { type: 'point', x: 0, y: 0 },
// }, end: { type: 'point', x: 100, y: 100 },
// }, bend: 20,
// ] text: 'test',
},
},
]
// snapshots[`arrow font=${font}`] = [ snapshots[`arrow font=${font}`] = [
// { {
// id: 'shape:testShape' as TLShapeId, id: 'shape:testShape' as TLShapeId,
// type: 'arrow', type: 'arrow',
// props: { props: {
// color: 'blue', color: 'blue',
// fill: 'solid', fill: 'solid',
// arrowheadStart: 'square', arrowheadStart: 'square',
// arrowheadEnd: 'arrow', arrowheadEnd: 'arrow',
// font, font,
// start: { type: 'point', x: 0, y: 0 }, start: { type: 'point', x: 0, y: 0 },
// end: { type: 'point', x: 100, y: 100 }, end: { type: 'point', x: 100, y: 100 },
// bend: 20, bend: 20,
// text: 'test', text: 'test',
// }, },
// }, },
// ] ]
// snapshots[`arrow font=${font}`] = [ snapshots[`note font=${font}`] = [
// { {
// id: 'shape:testShape' as TLShapeId, id: 'shape:testShape' as TLShapeId,
// type: 'arrow', type: 'note',
// props: { props: {
// color: 'blue', color: 'violet',
// fill: 'solid', font,
// arrowheadStart: 'square', text: 'test',
// arrowheadEnd: 'arrow', },
// font, },
// start: { type: 'point', x: 0, y: 0 }, ]
// end: { type: 'point', x: 100, y: 100 },
// bend: 20,
// text: 'test',
// },
// },
// ]
// snapshots[`note font=${font}`] = [ snapshots[`text font=${font}`] = [
// { {
// id: 'shape:testShape' as TLShapeId, id: 'shape:testShape' as TLShapeId,
// type: 'note', type: 'text',
// props: { props: {
// color: 'violet', color: 'red',
// font, font,
// text: 'test', text: 'test',
// }, },
// }, },
// ] ]
}
// snapshots[`text font=${font}`] = [ const snapshotsToTest = Object.entries(snapshots)
// { const filteredSnapshots = snapshotsToTest // maybe we filter these down, there are a lot of them
// id: 'shape:testShape' as TLShapeId,
// type: 'text',
// props: {
// color: 'red',
// font,
// text: 'test',
// },
// },
// ]
// }
// for (const [name, shapes] of Object.entries(snapshots)) { for (const [name, shapes] of filteredSnapshots) {
// test(`Exports with ${name}`, async ({ browser }) => { test(`Exports with ${name} in dark mode`, async ({ browser }) => {
// const page = await browser.newPage() const page = await browser.newPage()
// await setupPage(page) await setupPage(page)
// await page.evaluate((shapes) => { await page.evaluate((shapes) => {
// editor editor.user.updateUserPreferences({ isDarkMode: true })
// .updateInstanceState({ exportBackground: false }) editor
// .selectAll() .updateInstanceState({ exportBackground: false })
// .deleteShapes(editor.selectedShapeIds) .selectAll()
// .createShapes(shapes) .deleteShapes(editor.selectedShapeIds)
// }, shapes as any) .createShapes(shapes)
}, shapes as any)
// await snapshotTest(page) await snapshotTest(page)
// }) })
// } }
// for (const [name, shapes] of Object.entries(snapshots)) { async function snapshotTest(page: Page) {
// test(`Exports with ${name} in dark mode`, async ({ browser }) => { page.waitForEvent('download').then(async (download) => {
// const page = await browser.newPage() const path = (await download.path()) as string
// await setupPage(page) assert(path)
// await page.evaluate((shapes) => { await rename(path, path + '.svg')
// editor.user.updateUserPreferences({ isDarkMode: true }) await writeFile(
// editor path + '.html',
// .updateInstanceState({ exportBackground: false }) `
// .selectAll() <!DOCTYPE html>
// .deleteShapes(editor.selectedShapeIds) <meta charset="utf-8" />
// .createShapes(shapes) <meta name="viewport" content="width=device-width, initial-scale=1" />
// }, shapes as any) <img src="${path}.svg" />
`,
'utf-8'
)
// await snapshotTest(page) await page.goto(`file://${path}.html`)
// }) const clip = await page.$eval('img', (img) => img.getBoundingClientRect())
// } await expect(page).toHaveScreenshot({
// }) omitBackground: true,
clip,
// async function snapshotTest(page: Page) { })
// page.waitForEvent('download').then(async (download) => { })
// const path = (await download.path()) as string await page.evaluate(() => (window as any)['tldraw-export']())
// assert(path) }
// await rename(path, path + '.svg') })
// await writeFile(
// path + '.html',
// `
// <!DOCTYPE html>
// <meta charset="utf-8" />
// <meta name="viewport" content="width=device-width, initial-scale=1" />
// <img src="${path}.svg" />
// `,
// 'utf-8'
// )
// await page.goto(`file://${path}.html`)
// const clip = await page.$eval('img', (img) => img.getBoundingClientRect())
// await expect(page).toHaveScreenshot({
// omitBackground: true,
// clip,
// })
// })
// await page.evaluate(() => (window as any)['tldraw-export']())
// }

View file

@ -0,0 +1,5 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 15C20 17.7614 17.7614 20 15 20C12.2386 20 10 17.7614 10 15C10 12.2386 12.2386 10 15 10C17.7614 10 20 12.2386 20 15Z" stroke="black" stroke-width="2"/>
<rect x="21" y="5" width="4" height="4" rx="2" fill="black"/>
<path d="M5 3H25C26.1046 3 27 3.89543 27 5V25C27 26.1046 26.1046 27 25 27H5C3.89543 27 3 26.1046 3 25V5C3 3.89543 3.89543 3 5 3Z" stroke="black" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 493 B

View file

@ -1,7 +1,7 @@
import { Tldraw, TLEditorComponents } from '@tldraw/tldraw' import { Tldraw, TLEditorComponents } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css' import '@tldraw/tldraw/tldraw.css'
const components: Partial<TLEditorComponents> = { const components: TLEditorComponents = {
Brush: function MyBrush({ brush }) { Brush: function MyBrush({ brush }) {
return ( return (
<svg className="tl-overlays__item"> <svg className="tl-overlays__item">

View file

@ -8,7 +8,7 @@ export const uiOverrides: TLUiOverrides = {
tools.card = { tools.card = {
id: 'card', id: 'card',
icon: 'color', icon: 'color',
label: 'Card' as any, label: 'Card',
kbd: 'c', kbd: 'c',
readonlyOk: false, readonlyOk: false,
onSelect: () => { onSelect: () => {

View file

@ -72,7 +72,7 @@ const MyComponentInFront = track(() => {
) )
}) })
const components: Partial<TLEditorComponents> = { const components: TLEditorComponents = {
OnTheCanvas: MyComponent, OnTheCanvas: MyComponent,
InFrontOfTheCanvas: MyComponentInFront, InFrontOfTheCanvas: MyComponentInFront,
SnapLine: null, SnapLine: null,

View file

@ -0,0 +1,50 @@
import { StateNode, TLCancelEvent, TLInterruptEvent } from '@tldraw/tldraw'
import { ScreenshotDragging } from './childStates/Dragging'
import { ScreenshotIdle } from './childStates/Idle'
import { ScreenshotPointing } from './childStates/Pointing'
// There's a guide at the bottom of this file!
export class ScreenshotTool extends StateNode {
// [1]
static override id = 'screenshot'
static override initial = 'idle'
static override children = () => [ScreenshotIdle, ScreenshotPointing, ScreenshotDragging]
// [2]
override onEnter = () => {
this.editor.setCursor({ type: 'cross', rotation: 0 })
}
override onExit = () => {
this.editor.setCursor({ type: 'default', rotation: 0 })
}
// [3]
override onInterrupt: TLInterruptEvent = () => {
this.complete()
}
override onCancel: TLCancelEvent = () => {
this.complete()
}
private complete() {
this.parent.transition('select', {})
}
}
/*
This file contains our screenshot tool. The tool is a StateNode with the `id` "screenshot".
[1]
It has three child state nodes, ScreenshotIdle, ScreenshotPointing, and ScreenshotDragging.
Its initial state is `idle`.
[2]
When the screenshot tool is entered, we set the cursor to a crosshair. When it is exited, we
set the cursor back to the default cursor.
[3]
When the screenshot tool is interrupted or cancelled, we transition back to the select tool.
*/

View file

@ -0,0 +1,123 @@
import { Box2d, StateNode, atom, copyAs, exportAs } from '@tldraw/tldraw'
// There's a guide at the bottom of this file!
export class ScreenshotDragging extends StateNode {
static override id = 'dragging'
// [1]
screenshotBox = atom('screenshot brush', new Box2d())
// [2]
override onEnter = () => {
this.update()
}
override onPointerMove = () => {
this.update()
}
override onKeyDown = () => {
this.update()
}
override onKeyUp = () => {
this.update()
}
private update() {
const {
inputs: { shiftKey, altKey, originPagePoint, currentPagePoint },
} = this.editor
const box = Box2d.FromPoints([originPagePoint, currentPagePoint])
if (shiftKey) {
if (box.w > box.h * (16 / 9)) {
box.h = box.w * (9 / 16)
} else {
box.w = box.h * (16 / 9)
}
if (currentPagePoint.x < originPagePoint.x) {
box.x = originPagePoint.x - box.w
}
if (currentPagePoint.y < originPagePoint.y) {
box.y = originPagePoint.y - box.h
}
}
if (altKey) {
box.w *= 2
box.h *= 2
box.x = originPagePoint.x - box.w / 2
box.y = originPagePoint.y - box.h / 2
}
this.screenshotBox.set(box)
}
// [3]
override onPointerUp = () => {
const { editor } = this
const box = this.screenshotBox.value
// get all shapes contained by or intersecting the box
const shapes = editor.currentPageShapes.filter((s) => {
const pageBounds = editor.getShapeMaskedPageBounds(s)
if (!pageBounds) return false
return box.includes(pageBounds)
})
if (shapes.length) {
if (editor.inputs.ctrlKey) {
// Copy the shapes to the clipboard
copyAs(
editor,
shapes.map((s) => s.id),
'png',
{ bounds: box, background: editor.instanceState.exportBackground }
)
} else {
// Export the shapes as a png
exportAs(
editor,
shapes.map((s) => s.id),
'png',
{ bounds: box, background: editor.instanceState.exportBackground }
)
}
}
this.editor.setCurrentTool('select')
}
// [4]
override onCancel = () => {
this.editor.setCurrentTool('select')
}
}
/*
[1]
This state has a reactive property (an Atom) called "screenshotBox". This is the box
that the user is drawing on the screen as they drag their pointer. We use an Atom here
so that our UI can subscribe to this property using `useValue` (see the ScreenshotBox
component in ScreenshotToolExample).
[2]
When the user enters this state, or when they move their pointer, we update the
screenshotBox property to be drawn between the place where the user started pointing
and the place where their pointer is now. If the user is holding Shift, then we modify
the dimensions of this box so that it is in a 16:9 aspect ratio.
[3]
When the user makes a pointer up and stops dragging, we export the shapes contained by
the screenshot box as a png. If the user is holding the ctrl key, we copy the shapes
to the clipboard instead.
[4]
When the user cancels (esc key) or makes a pointer up event, we transition back to the
select tool.
*/

View file

@ -0,0 +1,17 @@
import { StateNode, TLEventHandlers } from '@tldraw/tldraw'
// There's a guide at the bottom of this file!
export class ScreenshotIdle extends StateNode {
static override id = 'idle'
// [1]
override onPointerDown: TLEventHandlers['onPointerUp'] = () => {
this.parent.transition('pointing')
}
}
/*
[1]
When we the user makes a pointer down event, we transition to the pointing state.
*/

View file

@ -0,0 +1,38 @@
import { StateNode, TLEventHandlers } from '@tldraw/tldraw'
// There's a guide at the bottom of this file!
export class ScreenshotPointing extends StateNode {
static override id = 'pointing'
// [1]
override onPointerMove: TLEventHandlers['onPointerUp'] = () => {
if (this.editor.inputs.isDragging) {
this.parent.transition('dragging')
}
}
// [2]
override onPointerUp: TLEventHandlers['onPointerUp'] = () => {
this.complete()
}
override onCancel: TLEventHandlers['onCancel'] = () => {
this.complete()
}
private complete() {
this.parent.transition('idle')
}
}
/*
[1]
When the user makes a pointer move event, we check if they are dragging. If they are,
we transition to the dragging state. If they are not yet dragging, we stay in this state.
[2]
When the user cancelles or makes a pointer up event (while this state is still active,
so after the user has started pointing but before they've moved their pointer far enough
to start dragging), then we transition back to the idle state.
*/

View file

@ -0,0 +1,157 @@
import {
Box2d,
TLEditorComponents,
TLUiAssetUrlOverrides,
TLUiOverrides,
Tldraw,
toolbarItem,
useEditor,
useValue,
} from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
import { ScreenshotTool } from './ScreenshotTool/ScreenshotTool'
import { ScreenshotDragging } from './ScreenshotTool/childStates/Dragging'
// There's a guide at the bottom of this file!
// [1]
const customTools = [ScreenshotTool]
// [2]
const customUiOverrides: TLUiOverrides = {
tools: (editor, tools) => {
return {
...tools,
screenshot: {
id: 'screenshot',
label: 'Screenshot',
readonlyOk: false,
icon: 'tool-screenshot',
kbd: 'j',
onSelect() {
editor.setCurrentTool('screenshot')
},
},
}
},
toolbar: (_editor, toolbarItems, { tools }) => {
toolbarItems.splice(4, 0, toolbarItem(tools.screenshot))
return toolbarItems
},
}
// [3]
const customAssetUrls: TLUiAssetUrlOverrides = {
icons: {
'tool-screenshot': '/tool-screenshot.svg',
},
}
// [4]
function ScreenshotBox() {
const editor = useEditor()
const screenshotBrush = useValue(
'screenshot brush',
() => {
// Check whether the screenshot tool (and its dragging state) is active
if (editor.getPath() !== 'screenshot.dragging') return null
// Get screenshot.dragging state node
const draggingState = editor.getStateDescendant<ScreenshotDragging>('screenshot.dragging')!
// Get the box from the screenshot.dragging state node
const box = draggingState.screenshotBox.get()
// The box is in "page space", i.e. panned and zoomed with the canvas, but we
// want to show it in front of the canvas, so we'll need to convert it to
// "page space", i.e. uneffected by scale, and relative to the tldraw
// page's top left corner.
const { zoomLevel } = editor
const { x, y } = editor.pageToScreen({ x: box.x, y: box.y })
return new Box2d(x, y, box.w * zoomLevel, box.h * zoomLevel)
},
[editor]
)
if (!screenshotBrush) return null
return (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
transform: `translate(${screenshotBrush.x}px, ${screenshotBrush.y}px)`,
width: screenshotBrush.w,
height: screenshotBrush.h,
border: '1px solid var(--color-text-0)',
zIndex: 999,
}}
/>
)
}
const customComponents: TLEditorComponents = {
InFrontOfTheCanvas: () => {
return <ScreenshotBox />
},
}
// [5]
export default function ScreenshotToolExample() {
return (
<div className="tldraw__editor">
<Tldraw
persistenceKey="tldraw_screenshot_example"
tools={customTools}
overrides={customUiOverrides}
assetUrls={customAssetUrls}
components={customComponents}
/>
</div>
)
}
/*
Introduction:
This example shows how to create a custom tool. In tldraw, tools are parts of the
tldraw state chart. While the most common use for tools is creating shapes, you can
use tools to create other types of interactions too! In this example, we create a
"screenshot tool" that lets the user draw a box on the canvas. When the user finishes
drawing their box, we'll export (or copy) a screenshot of that area.
[1]
Our custom tool is a class that extends the StateNode class. See the ScreenshotTool
files for more about the too. We define an array (outside of any React component)
to hold the custom tools. We'll pass this into the Tldraw component's `tools` prop.
[2]
Here we add our custom tool to the toolbar. We do this by providing a custom
toolbar override to the Tldraw component. This override is a function that takes
the current editor, the default toolbar items, and the default tools. It returns
the new toolbar items. We use the toolbarItem helper to create a new toolbar item
for our custom tool. We then splice it into the toolbar items array at the 4th index.
This puts it after the eraser tool. We'll pass our overrides object into the
Tldraw component's `overrides` prop.
[3]
Our toolbar item is using a custom icon, so we need to provide the asset url for it.
We do this by providing a custom assetUrls object to the Tldraw component.
This object is a map of icon ids to their urls. The icon ids are the same as the
icon prop on the toolbar item. We'll pass our assetUrls object into the Tldraw
component's `assetUrls` prop.
[4]
We want to show a box on the canvas when the screenshot tool is active. We do this
by providing an override to the InFrontOfTheCanvas component. This component will be shown
in front of the canvas but behind any other UI elements, such as menus and the toolbar.
We'll pass our components object into the Tldraw component's `components` prop.
[5]
Finally we pass all of our customizations into the Tldraw component. It's important
that the customizations are defined outside of the React component, otherwise they
will cause the Tldraw component to see them as new values on every render, which may
produce unexpected results.
*/

View file

@ -30,6 +30,7 @@ import MultipleExample from './examples/MultipleExample'
import OnTheCanvasExample from './examples/OnTheCanvas' import OnTheCanvasExample from './examples/OnTheCanvas'
import PersistenceExample from './examples/PersistenceExample' import PersistenceExample from './examples/PersistenceExample'
import ReadOnlyExample from './examples/ReadOnlyExample' import ReadOnlyExample from './examples/ReadOnlyExample'
import ScreenshotToolExample from './examples/ScreenshotToolExample/ScreenshotToolExample'
import ScrollExample from './examples/ScrollExample' import ScrollExample from './examples/ScrollExample'
import ShapeMetaExample from './examples/ShapeMetaExample' import ShapeMetaExample from './examples/ShapeMetaExample'
import SnapshotExample from './examples/SnapshotExample/SnapshotExample' import SnapshotExample from './examples/SnapshotExample/SnapshotExample'
@ -116,6 +117,11 @@ export const allExamples: Example[] = [
path: 'custom-ui', path: 'custom-ui',
element: <CustomUiExample />, element: <CustomUiExample />,
}, },
{
title: 'Custom Tool (Screenshot)',
path: 'screenshot-tool',
element: <ScreenshotToolExample />,
},
{ {
title: 'Hide UI', title: 'Hide UI',
path: 'hide-ui', path: 'hide-ui',

View file

@ -58,6 +58,7 @@ const menuOverrides = {
schema.forEach((item) => { schema.forEach((item) => {
if (item.id === 'menu' && item.type === 'group') { if (item.id === 'menu' && item.type === 'group') {
item.children = item.children.filter((menuItem) => { item.children = item.children.filter((menuItem) => {
if (!menuItem) return false
if (menuItem.id === 'file' && menuItem.type === 'submenu') { if (menuItem.id === 'file' && menuItem.type === 'submenu') {
return false return false
} }

View file

@ -275,6 +275,9 @@ export class Box2d {
zeroFix(): this; zeroFix(): this;
} }
// @public (undocumented)
export type BoxLike = Box2d | Box2dModel;
// @internal (undocumented) // @internal (undocumented)
export const CAMERA_SLIDE_FRICTION = 0.09; export const CAMERA_SLIDE_FRICTION = 0.09;
@ -625,7 +628,7 @@ export class Editor extends EventEmitter<TLEventMap> {
// @deprecated (undocumented) // @deprecated (undocumented)
get currentPageState(): TLInstancePageState; get currentPageState(): TLInstancePageState;
// @deprecated (undocumented) // @deprecated (undocumented)
get currentTool(): StateNode | undefined; get currentTool(): StateNode;
// @deprecated (undocumented) // @deprecated (undocumented)
get currentToolId(): string; get currentToolId(): string;
deleteAssets(assets: TLAsset[] | TLAssetId[]): this; deleteAssets(assets: TLAsset[] | TLAssetId[]): this;
@ -701,7 +704,7 @@ export class Editor extends EventEmitter<TLEventMap> {
getCurrentPageShapes(): TLShape[]; getCurrentPageShapes(): TLShape[];
getCurrentPageShapesSorted(): TLShape[]; getCurrentPageShapesSorted(): TLShape[];
getCurrentPageState(): TLInstancePageState; getCurrentPageState(): TLInstancePageState;
getCurrentTool(): StateNode | undefined; getCurrentTool(): StateNode;
getCurrentToolId(): string; getCurrentToolId(): string;
getDocumentSettings(): TLDocument; getDocumentSettings(): TLDocument;
getDroppingOverShape(point: VecLike, droppingShapes?: TLShape[]): TLUnknownShape | undefined; getDroppingOverShape(point: VecLike, droppingShapes?: TLShape[]): TLUnknownShape | undefined;
@ -783,16 +786,10 @@ export class Editor extends EventEmitter<TLEventMap> {
getSharedOpacity(): SharedStyle<number>; getSharedOpacity(): SharedStyle<number>;
getSharedStyles(): ReadonlySharedStyleMap; getSharedStyles(): ReadonlySharedStyleMap;
getSortedChildIdsForParent(parent: TLPage | TLParentId | TLShape): TLShapeId[]; getSortedChildIdsForParent(parent: TLPage | TLParentId | TLShape): TLShapeId[];
getStateDescendant(path: string): StateNode | undefined; getStateDescendant<T extends StateNode>(path: string): T | undefined;
// @internal (undocumented) // @internal (undocumented)
getStyleForNextShape<T>(style: StyleProp<T>): T; getStyleForNextShape<T>(style: StyleProp<T>): T;
getSvg(shapes: TLShape[] | TLShapeId[], opts?: Partial<{ getSvg(shapes: TLShape[] | TLShapeId[], opts?: Partial<TLSvgOptions>): Promise<SVGSVGElement | undefined>;
scale: number;
background: boolean;
padding: number;
darkMode?: boolean | undefined;
preserveAspectRatio: React.SVGAttributes<SVGSVGElement>['preserveAspectRatio'];
}>): Promise<SVGSVGElement | undefined>;
getViewportPageBounds(): Box2d; getViewportPageBounds(): Box2d;
getViewportPageCenter(): Vec2d; getViewportPageCenter(): Vec2d;
getViewportScreenBounds(): Box2d; getViewportScreenBounds(): Box2d;
@ -2128,7 +2125,7 @@ export interface TldrawEditorBaseProps {
autoFocus?: boolean; autoFocus?: boolean;
children?: any; children?: any;
className?: string; className?: string;
components?: Partial<TLEditorComponents>; components?: TLEditorComponents;
inferDarkMode?: boolean; inferDarkMode?: boolean;
initialState?: string; initialState?: string;
onMount?: TLOnMountHandler; onMount?: TLOnMountHandler;
@ -2150,13 +2147,9 @@ export type TldrawEditorProps = TldrawEditorBaseProps & ({
}); });
// @public (undocumented) // @public (undocumented)
export type TLEditorComponents = { export type TLEditorComponents = Partial<{
[K in keyof BaseEditorComponents]: BaseEditorComponents[K] | null; [K in keyof BaseEditorComponents]: BaseEditorComponents[K] | null;
} & { } & ErrorComponents>;
ErrorFallback: TLErrorFallbackComponent;
ShapeErrorFallback: TLShapeErrorFallbackComponent;
ShapeIndicatorErrorFallback: TLShapeIndicatorErrorFallbackComponent;
};
// @public (undocumented) // @public (undocumented)
export interface TLEditorOptions { export interface TLEditorOptions {
@ -2682,6 +2675,16 @@ export type TLStoreWithStatus = {
// @public (undocumented) // @public (undocumented)
export type TLSvgDefsComponent = React.ComponentType; export type TLSvgDefsComponent = React.ComponentType;
// @public (undocumented)
export type TLSvgOptions = {
bounds: Box2d;
scale: number;
background: boolean;
padding: number;
darkMode?: boolean;
preserveAspectRatio: React.SVGAttributes<SVGSVGElement>['preserveAspectRatio'];
};
// @public (undocumented) // @public (undocumented)
export type TLTickEvent = (elapsed: number) => void; export type TLTickEvent = (elapsed: number) => void;

View file

@ -3770,6 +3770,42 @@
], ],
"implementsTokenRanges": [] "implementsTokenRanges": []
}, },
{
"kind": "TypeAlias",
"canonicalReference": "@tldraw/editor!BoxLike:type",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export type BoxLike = "
},
{
"kind": "Reference",
"text": "Box2d",
"canonicalReference": "@tldraw/editor!Box2d:class"
},
{
"kind": "Content",
"text": " | "
},
{
"kind": "Reference",
"text": "Box2dModel",
"canonicalReference": "@tldraw/tlschema!Box2dModel:interface"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/editor/src/lib/primitives/Box2d.ts",
"releaseTag": "Public",
"name": "BoxLike",
"typeTokenRange": {
"startIndex": 1,
"endIndex": 4
}
},
{ {
"kind": "Function", "kind": "Function",
"canonicalReference": "@tldraw/editor!canonicalizeRotation:function(1)", "canonicalReference": "@tldraw/editor!canonicalizeRotation:function(1)",
@ -8256,10 +8292,6 @@
"text": "StateNode", "text": "StateNode",
"canonicalReference": "@tldraw/editor!StateNode:class" "canonicalReference": "@tldraw/editor!StateNode:class"
}, },
{
"kind": "Content",
"text": " | undefined"
},
{ {
"kind": "Content", "kind": "Content",
"text": ";" "text": ";"
@ -8271,7 +8303,7 @@
"name": "currentTool", "name": "currentTool",
"propertyTypeTokenRange": { "propertyTypeTokenRange": {
"startIndex": 1, "startIndex": 1,
"endIndex": 3 "endIndex": 2
}, },
"isStatic": false, "isStatic": false,
"isProtected": false, "isProtected": false,
@ -10447,10 +10479,6 @@
"text": "StateNode", "text": "StateNode",
"canonicalReference": "@tldraw/editor!StateNode:class" "canonicalReference": "@tldraw/editor!StateNode:class"
}, },
{
"kind": "Content",
"text": " | undefined"
},
{ {
"kind": "Content", "kind": "Content",
"text": ";" "text": ";"
@ -10459,7 +10487,7 @@
"isStatic": false, "isStatic": false,
"returnTypeTokenRange": { "returnTypeTokenRange": {
"startIndex": 1, "startIndex": 1,
"endIndex": 3 "endIndex": 2
}, },
"releaseTag": "Public", "releaseTag": "Public",
"isProtected": false, "isProtected": false,
@ -13658,7 +13686,16 @@
"excerptTokens": [ "excerptTokens": [
{ {
"kind": "Content", "kind": "Content",
"text": "getStateDescendant(path: " "text": "getStateDescendant<T extends "
},
{
"kind": "Reference",
"text": "StateNode",
"canonicalReference": "@tldraw/editor!StateNode:class"
},
{
"kind": "Content",
"text": ">(path: "
}, },
{ {
"kind": "Content", "kind": "Content",
@ -13668,24 +13705,32 @@
"kind": "Content", "kind": "Content",
"text": "): " "text": "): "
}, },
{
"kind": "Reference",
"text": "StateNode",
"canonicalReference": "@tldraw/editor!StateNode:class"
},
{ {
"kind": "Content", "kind": "Content",
"text": " | undefined" "text": "T | undefined"
}, },
{ {
"kind": "Content", "kind": "Content",
"text": ";" "text": ";"
} }
], ],
"typeParameters": [
{
"typeParameterName": "T",
"constraintTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"defaultTypeTokenRange": {
"startIndex": 0,
"endIndex": 0
}
}
],
"isStatic": false, "isStatic": false,
"returnTypeTokenRange": { "returnTypeTokenRange": {
"startIndex": 3, "startIndex": 5,
"endIndex": 5 "endIndex": 6
}, },
"releaseTag": "Public", "releaseTag": "Public",
"isProtected": false, "isProtected": false,
@ -13694,8 +13739,8 @@
{ {
"parameterName": "path", "parameterName": "path",
"parameterTypeTokenRange": { "parameterTypeTokenRange": {
"startIndex": 1, "startIndex": 3,
"endIndex": 2 "endIndex": 4
}, },
"isOptional": false "isOptional": false
} }
@ -13740,27 +13785,18 @@
"text": "Partial", "text": "Partial",
"canonicalReference": "!Partial:type" "canonicalReference": "!Partial:type"
}, },
{
"kind": "Content",
"text": "<{\n scale: number;\n background: boolean;\n padding: number;\n darkMode?: boolean | undefined;\n preserveAspectRatio: "
},
{
"kind": "Reference",
"text": "React.SVGAttributes",
"canonicalReference": "@types/react!React.SVGAttributes:interface"
},
{ {
"kind": "Content", "kind": "Content",
"text": "<" "text": "<"
}, },
{ {
"kind": "Reference", "kind": "Reference",
"text": "SVGSVGElement", "text": "TLSvgOptions",
"canonicalReference": "!SVGSVGElement:interface" "canonicalReference": "@tldraw/editor!TLSvgOptions:type"
}, },
{ {
"kind": "Content", "kind": "Content",
"text": ">['preserveAspectRatio'];\n }>" "text": ">"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -13791,8 +13827,8 @@
], ],
"isStatic": false, "isStatic": false,
"returnTypeTokenRange": { "returnTypeTokenRange": {
"startIndex": 13, "startIndex": 11,
"endIndex": 17 "endIndex": 15
}, },
"releaseTag": "Public", "releaseTag": "Public",
"isProtected": false, "isProtected": false,
@ -13810,7 +13846,7 @@
"parameterName": "opts", "parameterName": "opts",
"parameterTypeTokenRange": { "parameterTypeTokenRange": {
"startIndex": 6, "startIndex": 6,
"endIndex": 12 "endIndex": 10
}, },
"isOptional": true "isOptional": true
} }
@ -37739,24 +37775,11 @@
"kind": "Content", "kind": "Content",
"text": "components?: " "text": "components?: "
}, },
{
"kind": "Reference",
"text": "Partial",
"canonicalReference": "!Partial:type"
},
{
"kind": "Content",
"text": "<"
},
{ {
"kind": "Reference", "kind": "Reference",
"text": "TLEditorComponents", "text": "TLEditorComponents",
"canonicalReference": "@tldraw/editor!TLEditorComponents:type" "canonicalReference": "@tldraw/editor!TLEditorComponents:type"
}, },
{
"kind": "Content",
"text": ">"
},
{ {
"kind": "Content", "kind": "Content",
"text": ";" "text": ";"
@ -37768,7 +37791,7 @@
"name": "components", "name": "components",
"propertyTypeTokenRange": { "propertyTypeTokenRange": {
"startIndex": 1, "startIndex": 1,
"endIndex": 5 "endIndex": 2
} }
}, },
{ {
@ -38050,9 +38073,14 @@
"kind": "Content", "kind": "Content",
"text": "export type TLEditorComponents = " "text": "export type TLEditorComponents = "
}, },
{
"kind": "Reference",
"text": "Partial",
"canonicalReference": "!Partial:type"
},
{ {
"kind": "Content", "kind": "Content",
"text": "{\n [K in keyof " "text": "<{\n [K in keyof "
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -38070,34 +38098,16 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "[K] | null;\n} & {\n ErrorFallback: " "text": "[K] | null;\n} & "
}, },
{ {
"kind": "Reference", "kind": "Reference",
"text": "TLErrorFallbackComponent", "text": "ErrorComponents",
"canonicalReference": "@tldraw/editor!~TLErrorFallbackComponent:type" "canonicalReference": "@tldraw/editor!~ErrorComponents:type"
}, },
{ {
"kind": "Content", "kind": "Content",
"text": ";\n ShapeErrorFallback: " "text": ">"
},
{
"kind": "Reference",
"text": "TLShapeErrorFallbackComponent",
"canonicalReference": "@tldraw/editor!~TLShapeErrorFallbackComponent:type"
},
{
"kind": "Content",
"text": ";\n ShapeIndicatorErrorFallback: "
},
{
"kind": "Reference",
"text": "TLShapeIndicatorErrorFallbackComponent",
"canonicalReference": "@tldraw/editor!~TLShapeIndicatorErrorFallbackComponent:type"
},
{
"kind": "Content",
"text": ";\n}"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -38109,7 +38119,7 @@
"name": "TLEditorComponents", "name": "TLEditorComponents",
"typeTokenRange": { "typeTokenRange": {
"startIndex": 1, "startIndex": 1,
"endIndex": 12 "endIndex": 9
} }
}, },
{ {
@ -42979,6 +42989,59 @@
"endIndex": 2 "endIndex": 2
} }
}, },
{
"kind": "TypeAlias",
"canonicalReference": "@tldraw/editor!TLSvgOptions:type",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export type TLSvgOptions = "
},
{
"kind": "Content",
"text": "{\n bounds: "
},
{
"kind": "Reference",
"text": "Box2d",
"canonicalReference": "@tldraw/editor!Box2d:class"
},
{
"kind": "Content",
"text": ";\n scale: number;\n background: boolean;\n padding: number;\n darkMode?: boolean;\n preserveAspectRatio: "
},
{
"kind": "Reference",
"text": "React.SVGAttributes",
"canonicalReference": "@types/react!React.SVGAttributes:interface"
},
{
"kind": "Content",
"text": "<"
},
{
"kind": "Reference",
"text": "SVGSVGElement",
"canonicalReference": "!SVGSVGElement:interface"
},
{
"kind": "Content",
"text": ">['preserveAspectRatio'];\n}"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/editor/src/lib/editor/types/misc-types.ts",
"releaseTag": "Public",
"name": "TLSvgOptions",
"typeTokenRange": {
"startIndex": 1,
"endIndex": 8
}
},
{ {
"kind": "TypeAlias", "kind": "TypeAlias",
"canonicalReference": "@tldraw/editor!TLTickEvent:type", "canonicalReference": "@tldraw/editor!TLTickEvent:type",

View file

@ -326,6 +326,11 @@ input,
fill: var(--color-brush-fill); fill: var(--color-brush-fill);
} }
.tl-screenshot-brush {
stroke: var(--color-text-0);
fill: none;
}
/* -------------------- Scribble -------------------- */ /* -------------------- Scribble -------------------- */
.tl-scribble { .tl-scribble {
@ -1558,7 +1563,6 @@ it from receiving any pointer events or affecting the cursor. */
overflow: auto; overflow: auto;
font-size: 12px; font-size: 12px;
max-height: 320px; max-height: 320px;
} }
.tl-error-boundary__content button { .tl-error-boundary__content button {

View file

@ -241,7 +241,7 @@ export {
type TLHistoryEntry, type TLHistoryEntry,
type TLHistoryMark, type TLHistoryMark,
} from './lib/editor/types/history-types' } from './lib/editor/types/history-types'
export { type RequiredKeys } from './lib/editor/types/misc-types' export { type RequiredKeys, type TLSvgOptions } from './lib/editor/types/misc-types'
export { type TLResizeHandle, type TLSelectionHandle } from './lib/editor/types/selection-types' export { type TLResizeHandle, type TLSelectionHandle } from './lib/editor/types/selection-types'
export { useContainer } from './lib/hooks/useContainer' export { useContainer } from './lib/hooks/useContainer'
export { getCursor } from './lib/hooks/useCursor' export { getCursor } from './lib/hooks/useCursor'
@ -260,6 +260,7 @@ export {
Box2d, Box2d,
ROTATE_CORNER_TO_SELECTION_CORNER, ROTATE_CORNER_TO_SELECTION_CORNER,
rotateSelectionHandle, rotateSelectionHandle,
type BoxLike,
type RotateCorner, type RotateCorner,
type SelectionCorner, type SelectionCorner,
type SelectionEdge, type SelectionEdge,

View file

@ -86,7 +86,7 @@ export interface TldrawEditorBaseProps {
/** /**
* Overrides for the editor's components, such as handles, collaborator cursors, etc. * Overrides for the editor's components, such as handles, collaborator cursors, etc.
*/ */
components?: Partial<TLEditorComponents> components?: TLEditorComponents
/** /**
* Called when the editor has mounted. * Called when the editor has mounted.

View file

@ -184,7 +184,7 @@ function ZoomBrushWrapper() {
if (!(ZoomBrush && zoomBrush)) return null if (!(ZoomBrush && zoomBrush)) return null
return <ZoomBrush className="tl-user-brush" brush={zoomBrush} /> return <ZoomBrush className="tl-user-brush tl-zoom-brush" brush={zoomBrush} />
} }
function SnapLinesWrapper() { function SnapLinesWrapper() {

View file

@ -12,7 +12,7 @@ export type TLBrushComponent = ComponentType<{
}> }>
/** @public */ /** @public */
export const DefaultBrush: TLBrushComponent = ({ brush, color, opacity }) => { export const DefaultBrush: TLBrushComponent = ({ brush, color, opacity, className }) => {
const rSvg = useRef<SVGSVGElement>(null) const rSvg = useRef<SVGSVGElement>(null)
useTransform(rSvg, brush.x, brush.y) useTransform(rSvg, brush.x, brush.y)
@ -27,7 +27,7 @@ export const DefaultBrush: TLBrushComponent = ({ brush, color, opacity }) => {
<rect width={w} height={h} fill="none" stroke={color} opacity={0.1} /> <rect width={w} height={h} fill="none" stroke={color} opacity={0.1} />
</g> </g>
) : ( ) : (
<rect className="tl-brush tl-brush__default" width={w} height={h} /> <rect className={`tl-brush tl-brush__default ${className}`} width={w} height={h} />
)} )}
</svg> </svg>
) )

View file

@ -129,7 +129,7 @@ import {
} from './types/event-types' } from './types/event-types'
import { TLExternalAssetContent, TLExternalContent } from './types/external-content' import { TLExternalAssetContent, TLExternalContent } from './types/external-content'
import { TLCommandHistoryOptions } from './types/history-types' import { TLCommandHistoryOptions } from './types/history-types'
import { OptionalKeys, RequiredKeys } from './types/misc-types' import { OptionalKeys, RequiredKeys, TLSvgOptions } from './types/misc-types'
import { TLResizeHandle } from './types/selection-types' import { TLResizeHandle } from './types/selection-types'
/** @public */ /** @public */
@ -1127,13 +1127,14 @@ export class Editor extends EventEmitter<TLEventMap> {
this.root.transition(id, info) this.root.transition(id, info)
return this return this
} }
/** /**
* The current selected tool. * The current selected tool.
* *
* @public * @public
*/ */
@computed getCurrentTool(): StateNode | undefined { @computed getCurrentTool(): StateNode {
return this.root.getCurrent() return this.root.getCurrent()!
} }
/** /**
@ -1175,17 +1176,17 @@ export class Editor extends EventEmitter<TLEventMap> {
* *
* @public * @public
*/ */
getStateDescendant(path: string): StateNode | undefined { getStateDescendant<T extends StateNode>(path: string): T | undefined {
const ids = path.split('.').reverse() const ids = path.split('.').reverse()
let state = this.root as StateNode let state = this.root as StateNode
while (ids.length > 0) { while (ids.length > 0) {
const id = ids.pop() const id = ids.pop()
if (!id) return state if (!id) return state as T
const childState = state.children?.[id] const childState = state.children?.[id]
if (!childState) return undefined if (!childState) return undefined
state = childState state = childState
} }
return state return state as T
} }
/* ---------------- Document Settings --------------- */ /* ---------------- Document Settings --------------- */
@ -7550,6 +7551,9 @@ export class Editor extends EventEmitter<TLEventMap> {
// Otherwise, just return an empty map. // Otherwise, just return an empty map.
const currentTool = this.root.getCurrent()! const currentTool = this.root.getCurrent()!
const styles = new SharedStyleMap() const styles = new SharedStyleMap()
if (!currentTool) return styles
if (currentTool.shapeType) { if (currentTool.shapeType) {
for (const style of this.styleProps[currentTool.shapeType].keys()) { for (const style of this.styleProps[currentTool.shapeType].keys()) {
styles.applyValue(style, this.getStyleForNextShape(style)) styles.applyValue(style, this.getStyleForNextShape(style))
@ -8370,16 +8374,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* *
* @public * @public
*/ */
async getSvg( async getSvg(shapes: TLShapeId[] | TLShape[], opts = {} as Partial<TLSvgOptions>) {
shapes: TLShapeId[] | TLShape[],
opts = {} as Partial<{
scale: number
background: boolean
padding: number
darkMode?: boolean
preserveAspectRatio: React.SVGAttributes<SVGSVGElement>['preserveAspectRatio']
}>
) {
const ids = const ids =
typeof shapes[0] === 'string' typeof shapes[0] === 'string'
? (shapes as TLShapeId[]) ? (shapes as TLShapeId[])
@ -8406,6 +8401,9 @@ export class Editor extends EventEmitter<TLEventMap> {
// --- Common bounding box of all shapes // --- Common bounding box of all shapes
let bbox = null let bbox = null
if (opts.bounds) {
bbox = opts.bounds
} else {
for (const { maskedPageBounds } of renderingShapes) { for (const { maskedPageBounds } of renderingShapes) {
if (!maskedPageBounds) continue if (!maskedPageBounds) continue
if (bbox) { if (bbox) {
@ -8414,6 +8412,7 @@ export class Editor extends EventEmitter<TLEventMap> {
bbox = maskedPageBounds.clone() bbox = maskedPageBounds.clone()
} }
} }
}
// no unmasked shapes to export // no unmasked shapes to export
if (!bbox) return if (!bbox) return

View file

@ -12,12 +12,10 @@ export class RootState extends StateNode {
case 'KeyZ': { case 'KeyZ': {
if (!(info.shiftKey || info.ctrlKey)) { if (!(info.shiftKey || info.ctrlKey)) {
const currentTool = this.getCurrent() const currentTool = this.getCurrent()
if (currentTool && currentTool.getCurrent()?.id === 'idle') { if (currentTool && currentTool.getCurrent()?.id === 'idle' && this.children!['zoom']) {
if (this.children!['zoom']) {
this.editor.setCurrentTool('zoom', { ...info, onInteractionEnd: currentTool.id }) this.editor.setCurrentTool('zoom', { ...info, onInteractionEnd: currentTool.id })
} }
} }
}
break break
} }
} }

View file

@ -1,4 +1,16 @@
import { Box2d } from '../../primitives/Box2d'
/** @public */ /** @public */
export type RequiredKeys<T, K extends keyof T> = Partial<Omit<T, K>> & Pick<T, K> export type RequiredKeys<T, K extends keyof T> = Partial<Omit<T, K>> & Pick<T, K>
/** @public */ /** @public */
export type OptionalKeys<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>> export type OptionalKeys<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
/** @public */
export type TLSvgOptions = {
bounds: Box2d
scale: number
background: boolean
padding: number
darkMode?: boolean
preserveAspectRatio: React.SVGAttributes<SVGSVGElement>['preserveAspectRatio']
}

View file

@ -55,7 +55,7 @@ export function useDocumentEvents() {
if ( if (
e.altKey && e.altKey &&
// todo: When should we allow the alt key to be used? Perhaps states should declare which keys matter to them? // todo: When should we allow the alt key to be used? Perhaps states should declare which keys matter to them?
(editor.isIn('zoom') || !editor.root.getPath().endsWith('.idle')) && (editor.isIn('zoom') || !editor.getPath().endsWith('.idle')) &&
!isFocusingInput() !isFocusingInput()
) { ) {
// On windows the alt key opens the menu bar. // On windows the alt key opens the menu bar.

View file

@ -74,19 +74,24 @@ export interface BaseEditorComponents {
InFrontOfTheCanvas: TLInFrontOfTheCanvas InFrontOfTheCanvas: TLInFrontOfTheCanvas
} }
/** @public */ // These will always have defaults
export type TLEditorComponents = { type ErrorComponents = {
[K in keyof BaseEditorComponents]: BaseEditorComponents[K] | null
} & {
ErrorFallback: TLErrorFallbackComponent ErrorFallback: TLErrorFallbackComponent
ShapeErrorFallback: TLShapeErrorFallbackComponent ShapeErrorFallback: TLShapeErrorFallbackComponent
ShapeIndicatorErrorFallback: TLShapeIndicatorErrorFallbackComponent ShapeIndicatorErrorFallback: TLShapeIndicatorErrorFallbackComponent
} }
const EditorComponentsContext = createContext({} as TLEditorComponents) /** @public */
export type TLEditorComponents = Partial<
{
[K in keyof BaseEditorComponents]: BaseEditorComponents[K] | null
} & ErrorComponents
>
const EditorComponentsContext = createContext({} as TLEditorComponents & ErrorComponents)
type ComponentsContextProviderProps = { type ComponentsContextProviderProps = {
overrides?: Partial<TLEditorComponents> overrides?: TLEditorComponents
children: any children: any
} }
@ -99,6 +104,7 @@ export function EditorComponentsProvider({ overrides, children }: ComponentsCont
SvgDefs: DefaultSvgDefs, SvgDefs: DefaultSvgDefs,
Brush: DefaultBrush, Brush: DefaultBrush,
ZoomBrush: DefaultBrush, ZoomBrush: DefaultBrush,
ScreenshotBrush: DefaultBrush,
CollaboratorBrush: DefaultBrush, CollaboratorBrush: DefaultBrush,
Cursor: DefaultCursor, Cursor: DefaultCursor,
CollaboratorCursor: DefaultCursor, CollaboratorCursor: DefaultCursor,

View file

@ -2,6 +2,9 @@ import { Box2dModel } from '@tldraw/tlschema'
import { Vec2d, VecLike } from './Vec2d' import { Vec2d, VecLike } from './Vec2d'
import { PI, PI2, toPrecision } from './utils' import { PI, PI2, toPrecision } from './utils'
/** @public */
export type BoxLike = Box2dModel | Box2d
/** @public */ /** @public */
export type SelectionEdge = 'top' | 'right' | 'bottom' | 'left' export type SelectionEdge = 'top' | 'right' | 'bottom' | 'left'

View file

@ -99,6 +99,7 @@ import { TLShapeUtilCanvasSvgDef } from '@tldraw/editor';
import { TLShapeUtilFlag } from '@tldraw/editor'; import { TLShapeUtilFlag } from '@tldraw/editor';
import { TLStore } from '@tldraw/editor'; import { TLStore } from '@tldraw/editor';
import { TLStoreWithStatus } from '@tldraw/editor'; import { TLStoreWithStatus } from '@tldraw/editor';
import { TLSvgOptions } from '@tldraw/editor';
import { TLTextShape } from '@tldraw/editor'; import { TLTextShape } from '@tldraw/editor';
import { TLUnknownShape } from '@tldraw/editor'; import { TLUnknownShape } from '@tldraw/editor';
import { TLVideoShape } from '@tldraw/editor'; import { TLVideoShape } from '@tldraw/editor';
@ -286,6 +287,9 @@ export const ContextMenu: ({ children }: {
children: any; children: any;
}) => JSX.Element; }) => JSX.Element;
// @public
export function copyAs(editor: Editor, ids: TLShapeId[], format?: TLCopyType, opts?: Partial<TLSvgOptions>): Promise<void>;
// @public (undocumented) // @public (undocumented)
export const DEFAULT_ACCEPTED_IMG_TYPE: string[]; export const DEFAULT_ACCEPTED_IMG_TYPE: string[];
@ -461,8 +465,11 @@ export type EventsProviderProps = {
children: any; children: any;
}; };
// @public
export function exportAs(editor: Editor, ids: TLShapeId[], format?: TLExportType, opts?: Partial<TLSvgOptions>): Promise<void>;
// @public (undocumented) // @public (undocumented)
export function findMenuItem(menu: TLUiMenuSchema, path: string[]): TLUiMenuChild; export function findMenuItem(menu: TLUiMenuSchema, path: string[]): TLUiCustomMenuItem | TLUiMenuGroup | TLUiMenuItem | TLUiSubMenu<string>;
// @public (undocumented) // @public (undocumented)
function Footer({ className, children }: { function Footer({ className, children }: {
@ -906,7 +913,7 @@ export function menuCustom(id: string, opts?: Partial<{
}; };
// @public (undocumented) // @public (undocumented)
export function menuGroup(id: string, ...children: (false | null | TLUiMenuChild)[]): null | TLUiMenuGroup; export function menuGroup(id: string, ...children: (false | TLUiMenuChild)[]): null | TLUiMenuGroup;
// @public (undocumented) // @public (undocumented)
export function menuItem(actionItem: TLUiActionItem | TLUiToolItem, opts?: Partial<{ export function menuItem(actionItem: TLUiActionItem | TLUiToolItem, opts?: Partial<{
@ -915,7 +922,7 @@ export function menuItem(actionItem: TLUiActionItem | TLUiToolItem, opts?: Parti
}>): TLUiMenuItem; }>): TLUiMenuItem;
// @public (undocumented) // @public (undocumented)
export function menuSubmenu(id: string, label: TLUiTranslationKey, ...children: (false | null | TLUiMenuChild)[]): null | TLUiSubMenu; export function menuSubmenu(id: string, label: Exclude<string, TLUiTranslationKey> | TLUiTranslationKey, ...children: (false | TLUiMenuChild)[]): null | TLUiSubMenu;
// @public (undocumented) // @public (undocumented)
export class NoteShapeTool extends StateNode { export class NoteShapeTool extends StateNode {
@ -1020,7 +1027,7 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
export function OfflineIndicator(): JSX.Element; export function OfflineIndicator(): JSX.Element;
// @internal (undocumented) // @internal (undocumented)
export function parseAndLoadDocument(editor: Editor, document: string, msg: (id: TLUiTranslationKey) => string, addToast: TLUiToastsContextType['addToast'], onV1FileLoad?: () => void, forceDarkMode?: boolean): Promise<void>; export function parseAndLoadDocument(editor: Editor, document: string, msg: (id: Exclude<string, TLUiTranslationKey> | TLUiTranslationKey) => string, addToast: TLUiToastsContextType['addToast'], onV1FileLoad?: () => void, forceDarkMode?: boolean): Promise<void>;
// @public (undocumented) // @public (undocumented)
export function parseTldrawJsonFile({ json, schema, }: { export function parseTldrawJsonFile({ json, schema, }: {
@ -1081,7 +1088,7 @@ function SubContent({ alignOffset, sideOffset, children, }: {
// @public (undocumented) // @public (undocumented)
function SubTrigger({ label, 'data-testid': testId, 'data-direction': dataDirection, }: { function SubTrigger({ label, 'data-testid': testId, 'data-direction': dataDirection, }: {
label: TLUiTranslationKey; label: Exclude<string, TLUiTranslationKey> | TLUiTranslationKey;
'data-testid'?: string; 'data-testid'?: string;
'data-direction'?: 'left' | 'right'; 'data-direction'?: 'left' | 'right';
}): JSX.Element; }): JSX.Element;
@ -1211,17 +1218,7 @@ function Title({ className, children }: {
}): JSX.Element; }): JSX.Element;
// @public (undocumented) // @public (undocumented)
export function Tldraw(props: TldrawEditorBaseProps & ({ export function Tldraw(props: TldrawProps): JSX.Element;
store: TLStore | TLStoreWithStatus;
} | {
store?: undefined;
persistenceKey?: string;
sessionId?: string;
defaultName?: string;
snapshot?: StoreSnapshot<TLRecord>;
}) & TldrawUiProps & Partial<TLExternalContentProps> & {
assetUrls?: RecursivePartial<TLEditorAssetUrls>;
}): JSX.Element;
// @public (undocumented) // @public (undocumented)
export const TLDRAW_FILE_EXTENSION: ".tldr"; export const TLDRAW_FILE_EXTENSION: ".tldr";
@ -1257,6 +1254,17 @@ export const TldrawHandles: TLHandlesComponent;
// @public (undocumented) // @public (undocumented)
export const TldrawHoveredShapeIndicator: TLHoveredShapeIndicatorComponent; export const TldrawHoveredShapeIndicator: TLHoveredShapeIndicatorComponent;
// @public (undocumented)
export type TldrawProps = TldrawEditorBaseProps & ({
store: TLStore | TLStoreWithStatus;
} | {
store?: undefined;
persistenceKey?: string;
sessionId?: string;
defaultName?: string;
snapshot?: StoreSnapshot<TLRecord>;
}) & TldrawUiProps & Partial<TLExternalContentProps>;
// @public (undocumented) // @public (undocumented)
export const TldrawScribble: TLScribbleComponent; export const TldrawScribble: TLScribbleComponent;
@ -1271,6 +1279,7 @@ export const TldrawUi: React_2.NamedExoticComponent<TldrawUiProps>;
// @public // @public
export interface TldrawUiBaseProps { export interface TldrawUiBaseProps {
assetUrls?: TLUiAssetUrlOverrides;
children?: ReactNode; children?: ReactNode;
hideUi?: boolean; hideUi?: boolean;
renderDebugMenuItems?: () => React_2.ReactNode; renderDebugMenuItems?: () => React_2.ReactNode;
@ -1295,27 +1304,27 @@ export interface TldrawUiContextProviderProps {
export type TldrawUiProps = TldrawUiBaseProps & TldrawUiContextProviderProps; export type TldrawUiProps = TldrawUiBaseProps & TldrawUiContextProviderProps;
// @public (undocumented) // @public (undocumented)
export interface TLUiActionItem { export interface TLUiActionItem<TransationKey extends string = string, IconType extends string = string> {
// (undocumented) // (undocumented)
checkbox?: boolean; checkbox?: boolean;
// (undocumented) // (undocumented)
contextMenuLabel?: TLUiTranslationKey; contextMenuLabel?: TransationKey;
// (undocumented) // (undocumented)
icon?: TLUiIconType; icon?: IconType;
// (undocumented) // (undocumented)
id: string; id: string;
// (undocumented) // (undocumented)
kbd?: string; kbd?: string;
// (undocumented) // (undocumented)
label?: TLUiTranslationKey; label?: TransationKey;
// (undocumented) // (undocumented)
menuLabel?: TLUiTranslationKey; menuLabel?: TransationKey;
// (undocumented) // (undocumented)
onSelect: (source: TLUiEventSource) => Promise<void> | void; onSelect: (source: TLUiEventSource) => Promise<void> | void;
// (undocumented) // (undocumented)
readonlyOk: boolean; readonlyOk: boolean;
// (undocumented) // (undocumented)
shortcutsLabel?: TLUiTranslationKey; shortcutsLabel?: TransationKey;
// (undocumented) // (undocumented)
title?: string; title?: string;
} }
@ -1326,14 +1335,17 @@ export type TLUiActionsContextType = Record<string, TLUiActionItem>;
// @public (undocumented) // @public (undocumented)
export type TLUiActionsMenuSchemaContextType = TLUiMenuSchema; export type TLUiActionsMenuSchemaContextType = TLUiMenuSchema;
// @public (undocumented)
export type TLUiAssetUrlOverrides = RecursivePartial<TLUiAssetUrls>;
// @public (undocumented) // @public (undocumented)
export interface TLUiButtonProps extends React_3.HTMLAttributes<HTMLButtonElement> { export interface TLUiButtonProps extends React_3.HTMLAttributes<HTMLButtonElement> {
// (undocumented) // (undocumented)
disabled?: boolean; disabled?: boolean;
// (undocumented) // (undocumented)
icon?: TLUiIconType; icon?: Exclude<string, TLUiIconType> | TLUiIconType;
// (undocumented) // (undocumented)
iconLeft?: TLUiIconType; iconLeft?: Exclude<string, TLUiIconType> | TLUiIconType;
// (undocumented) // (undocumented)
invertIcon?: boolean; invertIcon?: boolean;
// (undocumented) // (undocumented)
@ -1341,7 +1353,7 @@ export interface TLUiButtonProps extends React_3.HTMLAttributes<HTMLButtonElemen
// (undocumented) // (undocumented)
kbd?: string; kbd?: string;
// (undocumented) // (undocumented)
label?: TLUiTranslationKey; label?: Exclude<string, TLUiTranslationKey> | TLUiTranslationKey;
// (undocumented) // (undocumented)
loading?: boolean; loading?: boolean;
// (undocumented) // (undocumented)
@ -1419,7 +1431,7 @@ export interface TLUiIconProps extends React.HTMLProps<HTMLDivElement> {
// (undocumented) // (undocumented)
crossOrigin?: 'anonymous' | 'use-credentials'; crossOrigin?: 'anonymous' | 'use-credentials';
// (undocumented) // (undocumented)
icon: TLUiIconType; icon: Exclude<string, TLUiIconType> | TLUiIconType;
// (undocumented) // (undocumented)
invertIcon?: boolean; invertIcon?: boolean;
// (undocumented) // (undocumented)
@ -1444,11 +1456,11 @@ export interface TLUiInputProps {
// (undocumented) // (undocumented)
disabled?: boolean; disabled?: boolean;
// (undocumented) // (undocumented)
icon?: TLUiIconType; icon?: Exclude<string, TLUiIconType> | TLUiIconType;
// (undocumented) // (undocumented)
iconLeft?: TLUiIconType; iconLeft?: Exclude<string, TLUiIconType> | TLUiIconType;
// (undocumented) // (undocumented)
label?: TLUiTranslationKey; label?: Exclude<string, TLUiTranslationKey> | TLUiTranslationKey;
// (undocumented) // (undocumented)
onBlur?: (value: string) => void; onBlur?: (value: string) => void;
// (undocumented) // (undocumented)
@ -1477,7 +1489,7 @@ export type TLUiKeyboardShortcutsSchemaProviderProps = {
}; };
// @public (undocumented) // @public (undocumented)
export type TLUiMenuChild = TLUiCustomMenuItem | TLUiMenuGroup | TLUiMenuItem | TLUiSubMenu; export type TLUiMenuChild<TranslationKey extends string = string> = null | TLUiCustomMenuItem | TLUiMenuGroup | TLUiMenuItem | TLUiSubMenu<TranslationKey>;
// @public (undocumented) // @public (undocumented)
export type TLUiMenuGroup = { export type TLUiMenuGroup = {
@ -1518,32 +1530,23 @@ export type TLUiMenuSchemaProviderProps = {
}; };
// @public (undocumented) // @public (undocumented)
export interface TLUiOverrides { export type TLUiOverrides = Partial<{
// (undocumented) actionsMenu: WithDefaultHelpers<NonNullable<ActionsMenuSchemaProviderProps['overrides']>>;
actions?: WithDefaultHelpers<NonNullable<ActionsProviderProps['overrides']>>; actions: WithDefaultHelpers<NonNullable<ActionsProviderProps['overrides']>>;
// (undocumented) contextMenu: WithDefaultHelpers<NonNullable<TLUiContextMenuSchemaProviderProps['overrides']>>;
actionsMenu?: WithDefaultHelpers<NonNullable<ActionsMenuSchemaProviderProps['overrides']>>; helpMenu: WithDefaultHelpers<NonNullable<TLUiHelpMenuSchemaProviderProps['overrides']>>;
// (undocumented) menu: WithDefaultHelpers<NonNullable<TLUiMenuSchemaProviderProps['overrides']>>;
contextMenu?: WithDefaultHelpers<NonNullable<TLUiContextMenuSchemaProviderProps['overrides']>>; toolbar: WithDefaultHelpers<NonNullable<TLUiToolbarSchemaProviderProps['overrides']>>;
// (undocumented) keyboardShortcutsMenu: WithDefaultHelpers<NonNullable<TLUiKeyboardShortcutsSchemaProviderProps['overrides']>>;
helpMenu?: WithDefaultHelpers<NonNullable<TLUiHelpMenuSchemaProviderProps['overrides']>>; tools: WithDefaultHelpers<NonNullable<TLUiToolsProviderProps['overrides']>>;
// (undocumented) translations: TLUiTranslationProviderProps['overrides'];
keyboardShortcutsMenu?: WithDefaultHelpers<NonNullable<TLUiKeyboardShortcutsSchemaProviderProps['overrides']>>; }>;
// (undocumented)
menu?: WithDefaultHelpers<NonNullable<TLUiMenuSchemaProviderProps['overrides']>>;
// (undocumented)
toolbar?: WithDefaultHelpers<NonNullable<TLUiToolbarSchemaProviderProps['overrides']>>;
// (undocumented)
tools?: WithDefaultHelpers<NonNullable<TLUiToolsProviderProps['overrides']>>;
// (undocumented)
translations?: TLUiTranslationProviderProps['overrides'];
}
// @public (undocumented) // @public (undocumented)
export type TLUiSubMenu = { export type TLUiSubMenu<TranslationKey extends string = string> = {
id: string; id: string;
type: 'submenu'; type: 'submenu';
label: TLUiTranslationKey; label: TranslationKey;
disabled: boolean; disabled: boolean;
readonlyOk: boolean; readonlyOk: boolean;
children: TLUiMenuChild[]; children: TLUiMenuChild[];
@ -1599,15 +1602,15 @@ export type TLUiToolbarItem = {
export type TLUiToolbarSchemaContextType = TLUiToolbarItem[]; export type TLUiToolbarSchemaContextType = TLUiToolbarItem[];
// @public (undocumented) // @public (undocumented)
export interface TLUiToolItem { export interface TLUiToolItem<TranslationKey extends string = string, IconType extends string = string> {
// (undocumented) // (undocumented)
icon: TLUiIconType; icon: IconType;
// (undocumented) // (undocumented)
id: string; id: string;
// (undocumented) // (undocumented)
kbd?: string; kbd?: string;
// (undocumented) // (undocumented)
label: TLUiTranslationKey; label: TranslationKey;
// (undocumented) // (undocumented)
meta?: { meta?: {
[key: string]: any; [key: string]: any;
@ -1617,7 +1620,7 @@ export interface TLUiToolItem {
// (undocumented) // (undocumented)
readonlyOk: boolean; readonlyOk: boolean;
// (undocumented) // (undocumented)
shortcutsLabel?: TLUiTranslationKey; shortcutsLabel?: TranslationKey;
} }
// @public (undocumented) // @public (undocumented)
@ -1681,7 +1684,7 @@ export function useCanUndo(): boolean;
export function useContextMenuSchema(): TLUiMenuSchema; export function useContextMenuSchema(): TLUiMenuSchema;
// @public (undocumented) // @public (undocumented)
export function useCopyAs(): (ids?: TLShapeId[], format?: TLCopyType) => void; export function useCopyAs(): (ids: TLShapeId[], format?: TLCopyType) => void;
// @public (undocumented) // @public (undocumented)
export function useDefaultHelpers(): { export function useDefaultHelpers(): {
@ -1696,7 +1699,7 @@ export function useDefaultHelpers(): {
clearDialogs: () => void; clearDialogs: () => void;
removeDialog: (id: string) => string; removeDialog: (id: string) => string;
updateDialog: (id: string, newDialogData: Partial<TLUiDialog>) => string; updateDialog: (id: string, newDialogData: Partial<TLUiDialog>) => string;
msg: (id: TLUiTranslationKey) => string; msg: (id: string) => string;
isMobile: boolean; isMobile: boolean;
}; };
@ -1704,7 +1707,7 @@ export function useDefaultHelpers(): {
export function useDialogs(): TLUiDialogsContextType; export function useDialogs(): TLUiDialogsContextType;
// @public (undocumented) // @public (undocumented)
export function useExportAs(): (ids?: TLShapeId[], format?: TLExportType) => Promise<void>; export function useExportAs(): (ids: TLShapeId[], format?: TLExportType) => void;
// @public (undocumented) // @public (undocumented)
export function useHelpMenuSchema(): TLUiMenuSchema; export function useHelpMenuSchema(): TLUiMenuSchema;
@ -1747,7 +1750,7 @@ export function useToolbarSchema(): TLUiToolbarSchemaContextType;
export function useTools(): TLUiToolsContextType; export function useTools(): TLUiToolsContextType;
// @public // @public
export function useTranslation(): (id: TLUiTranslationKey) => string; export function useTranslation(): (id: Exclude<string, TLUiTranslationKey> | string) => string;
// @public (undocumented) // @public (undocumented)
export function useUiEvents(): TLUiEventContextType; export function useUiEvents(): TLUiEventContextType;

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
// eslint-disable-next-line local/no-export-star // eslint-disable-next-line local/no-export-star
export * from '@tldraw/editor' export * from '@tldraw/editor'
export { Tldraw } from './lib/Tldraw' export { Tldraw, type TldrawProps } from './lib/Tldraw'
export { TldrawCropHandles, type TldrawCropHandlesProps } from './lib/canvas/TldrawCropHandles' export { TldrawCropHandles, type TldrawCropHandlesProps } from './lib/canvas/TldrawCropHandles'
export { TldrawHandles } from './lib/canvas/TldrawHandles' export { TldrawHandles } from './lib/canvas/TldrawHandles'
export { TldrawHoveredShapeIndicator } from './lib/canvas/TldrawHoveredShapeIndicator' export { TldrawHoveredShapeIndicator } from './lib/canvas/TldrawHoveredShapeIndicator'
@ -43,7 +43,7 @@ export {
TldrawUiContextProvider, TldrawUiContextProvider,
type TldrawUiContextProviderProps, type TldrawUiContextProviderProps,
} from './lib/ui/TldrawUiContextProvider' } from './lib/ui/TldrawUiContextProvider'
export { setDefaultUiAssetUrls } from './lib/ui/assetUrls' export { setDefaultUiAssetUrls, type TLUiAssetUrlOverrides } from './lib/ui/assetUrls'
export { ContextMenu, type TLUiContextMenuProps } from './lib/ui/components/ContextMenu' export { ContextMenu, type TLUiContextMenuProps } from './lib/ui/components/ContextMenu'
export { OfflineIndicator } from './lib/ui/components/OfflineIndicator/OfflineIndicator' export { OfflineIndicator } from './lib/ui/components/OfflineIndicator/OfflineIndicator'
export { Spinner } from './lib/ui/components/Spinner' export { Spinner } from './lib/ui/components/Spinner'
@ -142,16 +142,22 @@ export {
} from './lib/ui/hooks/useTranslation/useTranslation' } from './lib/ui/hooks/useTranslation/useTranslation'
export { type TLUiIconType } from './lib/ui/icon-types' export { type TLUiIconType } from './lib/ui/icon-types'
export { useDefaultHelpers, type TLUiOverrides } from './lib/ui/overrides' export { useDefaultHelpers, type TLUiOverrides } from './lib/ui/overrides'
export { setDefaultEditorAssetUrls } from './lib/utils/assetUrls'
export { export {
DEFAULT_ACCEPTED_IMG_TYPE, DEFAULT_ACCEPTED_IMG_TYPE,
DEFAULT_ACCEPTED_VID_TYPE, DEFAULT_ACCEPTED_VID_TYPE,
containBoxSize, containBoxSize,
getResizedImageDataUrl, getResizedImageDataUrl,
isGifAnimated, isGifAnimated,
} from './lib/utils/assets' } from './lib/utils/assets/assets'
export { buildFromV1Document, type LegacyTldrawDocument } from './lib/utils/buildFromV1Document' export { getEmbedInfo } from './lib/utils/embeds/embeds'
export { getEmbedInfo } from './lib/utils/embeds' export { copyAs } from './lib/utils/export/copyAs'
export { exportAs } from './lib/utils/export/exportAs'
export { setDefaultEditorAssetUrls } from './lib/utils/static-assets/assetUrls'
export { truncateStringWithEllipsis } from './lib/utils/text/text'
export {
buildFromV1Document,
type LegacyTldrawDocument,
} from './lib/utils/tldr/buildFromV1Document'
export { export {
TLDRAW_FILE_EXTENSION, TLDRAW_FILE_EXTENSION,
parseAndLoadDocument, parseAndLoadDocument,
@ -159,8 +165,7 @@ export {
serializeTldrawJson, serializeTldrawJson,
serializeTldrawJsonBlob, serializeTldrawJsonBlob,
type TldrawFile, type TldrawFile,
} from './lib/utils/file' } from './lib/utils/tldr/file'
export { truncateStringWithEllipsis } from './lib/utils/text'
export { Dialog, DropdownMenu } export { Dialog, DropdownMenu }
import * as Dialog from './lib/ui/components/primitives/Dialog' import * as Dialog from './lib/ui/components/primitives/Dialog'
import * as DropdownMenu from './lib/ui/components/primitives/DropdownMenu' import * as DropdownMenu from './lib/ui/components/primitives/DropdownMenu'

View file

@ -3,7 +3,6 @@ import {
Editor, Editor,
ErrorScreen, ErrorScreen,
LoadingScreen, LoadingScreen,
RecursivePartial,
StoreSnapshot, StoreSnapshot,
TLOnMountHandler, TLOnMountHandler,
TLRecord, TLRecord,
@ -31,12 +30,11 @@ import { registerDefaultSideEffects } from './defaultSideEffects'
import { defaultTools } from './defaultTools' import { defaultTools } from './defaultTools'
import { TldrawUi, TldrawUiProps } from './ui/TldrawUi' import { TldrawUi, TldrawUiProps } from './ui/TldrawUi'
import { ContextMenu } from './ui/components/ContextMenu' import { ContextMenu } from './ui/components/ContextMenu'
import { TLEditorAssetUrls, useDefaultEditorAssetsWithOverrides } from './utils/assetUrls' import { usePreloadAssets } from './ui/hooks/usePreloadAssets'
import { usePreloadAssets } from './utils/usePreloadAssets' import { useDefaultEditorAssetsWithOverrides } from './utils/static-assets/assetUrls'
/** @public */ /** @public */
export function Tldraw( export type TldrawProps = TldrawEditorBaseProps &
props: TldrawEditorBaseProps &
( (
| { | {
store: TLStore | TLStoreWithStatus store: TLStore | TLStoreWithStatus
@ -53,13 +51,10 @@ export function Tldraw(
} }
) & ) &
TldrawUiProps & TldrawUiProps &
Partial<TLExternalContentProps> & { Partial<TLExternalContentProps>
/**
* Urls for the editor to find fonts and other assets. /** @public */
*/ export function Tldraw(props: TldrawProps) {
assetUrls?: RecursivePartial<TLEditorAssetUrls>
}
) {
const { const {
children, children,
maxImageDimension, maxImageDimension,

View file

@ -17,9 +17,9 @@ import {
getHashForString, getHashForString,
} from '@tldraw/editor' } from '@tldraw/editor'
import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from './shapes/shared/default-shape-constants' import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from './shapes/shared/default-shape-constants'
import { containBoxSize, getResizedImageDataUrl, isGifAnimated } from './utils/assets' import { containBoxSize, getResizedImageDataUrl, isGifAnimated } from './utils/assets/assets'
import { getEmbedInfo } from './utils/embeds' import { getEmbedInfo } from './utils/embeds/embeds'
import { cleanupText, isRightToLeftLanguage, truncateStringWithEllipsis } from './utils/text' import { cleanupText, isRightToLeftLanguage, truncateStringWithEllipsis } from './utils/text/text'
/** @public */ /** @public */
export type TLExternalContentProps = { export type TLExternalContentProps = {

View file

@ -26,7 +26,7 @@ export class Idle extends StateNode {
) { ) {
this.editor.setCurrentTool('select') this.editor.setCurrentTool('select')
this.editor.setEditingShape(onlySelectedShape.id) this.editor.setEditingShape(onlySelectedShape.id)
this.editor.root.getCurrent()!.transition('editing_shape', { this.editor.root.getCurrent()?.transition('editing_shape', {
...info, ...info,
target: 'shape', target: 'shape',
shape: onlySelectedShape, shape: onlySelectedShape,

View file

@ -16,9 +16,9 @@ import {
stopEventPropagation, stopEventPropagation,
toDomPrecision, toDomPrecision,
} from '@tldraw/editor' } from '@tldraw/editor'
import { getRotatedBoxShadow } from '../../utils/rotated-box-shadow' import { truncateStringWithEllipsis } from '../../utils/text/text'
import { truncateStringWithEllipsis } from '../../utils/text'
import { HyperlinkButton } from '../shared/HyperlinkButton' import { HyperlinkButton } from '../shared/HyperlinkButton'
import { getRotatedBoxShadow } from '../shared/rotated-box-shadow'
/** @public */ /** @public */
export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> { export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {

View file

@ -15,9 +15,9 @@ import {
useValue, useValue,
} from '@tldraw/editor' } from '@tldraw/editor'
import { useMemo } from 'react' import { useMemo } from 'react'
import { getEmbedInfo, getEmbedInfoUnsafely } from '../../utils/embeds' import { getEmbedInfo, getEmbedInfoUnsafely } from '../../utils/embeds/embeds'
import { getRotatedBoxShadow } from '../../utils/rotated-box-shadow'
import { resizeBox } from '../shared/resizeBox' import { resizeBox } from '../shared/resizeBox'
import { getRotatedBoxShadow } from '../shared/rotated-box-shadow'
const getSandboxPermissions = (permissions: TLEmbedShapePermissions) => { const getSandboxPermissions = (permissions: TLEmbedShapePermissions) => {
return Object.entries(permissions) return Object.entries(permissions)

View file

@ -23,7 +23,7 @@ export class Idle extends StateNode {
) { ) {
this.editor.setCurrentTool('select') this.editor.setCurrentTool('select')
this.editor.setEditingShape(onlySelectedShape.id) this.editor.setEditingShape(onlySelectedShape.id)
this.editor.root.getCurrent()!.transition('editing_shape', { this.editor.root.getCurrent()?.transition('editing_shape', {
...info, ...info,
target: 'shape', target: 'shape',
shape: onlySelectedShape, shape: onlySelectedShape,

View file

@ -1,6 +1,6 @@
import { Vec2d } from '@tldraw/editor' import { Vec2d } from '@tldraw/editor'
export const ROTATING_BOX_SHADOWS = [ const ROTATING_BOX_SHADOWS = [
{ {
offsetX: 0, offsetX: 0,
offsetY: 2, offsetY: 2,

View file

@ -32,7 +32,7 @@ export class Idle extends StateNode {
) { ) {
this.editor.setCurrentTool('select') this.editor.setCurrentTool('select')
this.editor.setEditingShape(onlySelectedShape.id) this.editor.setEditingShape(onlySelectedShape.id)
this.editor.root.getCurrent()!.transition('editing_shape', { this.editor.root.getCurrent()?.transition('editing_shape', {
...info, ...info,
target: 'shape', target: 'shape',
shape: onlySelectedShape, shape: onlySelectedShape,

View file

@ -1,7 +1,7 @@
import { StateNode } from '@tldraw/editor' import { StateNode } from '@tldraw/editor'
import { Erasing } from './children/Erasing' import { Erasing } from './childStates/Erasing'
import { Idle } from './children/Idle' import { Idle } from './childStates/Idle'
import { Pointing } from './children/Pointing' import { Pointing } from './childStates/Pointing'
/** @public */ /** @public */
export class EraserTool extends StateNode { export class EraserTool extends StateNode {

View file

@ -1,7 +1,7 @@
import { EASINGS, StateNode, TLClickEvent } from '@tldraw/editor' import { EASINGS, StateNode, TLClickEvent } from '@tldraw/editor'
import { Dragging } from './children/Dragging' import { Dragging } from './childStates/Dragging'
import { Idle } from './children/Idle' import { Idle } from './childStates/Idle'
import { Pointing } from './children/Pointing' import { Pointing } from './childStates/Pointing'
/** @public */ /** @public */
export class HandTool extends StateNode { export class HandTool extends StateNode {

View file

@ -1,6 +1,6 @@
import { StateNode } from '@tldraw/editor' import { StateNode } from '@tldraw/editor'
import { Idle } from './children/Idle' import { Idle } from './childStates/Idle'
import { Lasering } from './children/Lasering' import { Lasering } from './childStates/Lasering'
/** @public */ /** @public */
export class LaserTool extends StateNode { export class LaserTool extends StateNode {

View file

@ -1,21 +1,21 @@
import { StateNode } from '@tldraw/editor' import { StateNode } from '@tldraw/editor'
import { Brushing } from './children/Brushing' import { Brushing } from './childStates/Brushing'
import { Crop } from './children/Crop/Crop' import { Crop } from './childStates/Crop/Crop'
import { Cropping } from './children/Cropping' import { Cropping } from './childStates/Cropping'
import { DraggingHandle } from './children/DraggingHandle' import { DraggingHandle } from './childStates/DraggingHandle'
import { EditingShape } from './children/EditingShape' import { EditingShape } from './childStates/EditingShape'
import { Idle } from './children/Idle' import { Idle } from './childStates/Idle'
import { PointingCanvas } from './children/PointingCanvas' import { PointingCanvas } from './childStates/PointingCanvas'
import { PointingCropHandle } from './children/PointingCropHandle' import { PointingCropHandle } from './childStates/PointingCropHandle'
import { PointingHandle } from './children/PointingHandle' import { PointingHandle } from './childStates/PointingHandle'
import { PointingResizeHandle } from './children/PointingResizeHandle' import { PointingResizeHandle } from './childStates/PointingResizeHandle'
import { PointingRotateHandle } from './children/PointingRotateHandle' import { PointingRotateHandle } from './childStates/PointingRotateHandle'
import { PointingSelection } from './children/PointingSelection' import { PointingSelection } from './childStates/PointingSelection'
import { PointingShape } from './children/PointingShape' import { PointingShape } from './childStates/PointingShape'
import { Resizing } from './children/Resizing' import { Resizing } from './childStates/Resizing'
import { Rotating } from './children/Rotating' import { Rotating } from './childStates/Rotating'
import { ScribbleBrushing } from './children/ScribbleBrushing' import { ScribbleBrushing } from './childStates/ScribbleBrushing'
import { Translating } from './children/Translating' import { Translating } from './childStates/Translating'
/** @public */ /** @public */
export class SelectTool extends StateNode { export class SelectTool extends StateNode {

View file

@ -1,7 +1,7 @@
import { StateNode, TLInterruptEvent, TLKeyboardEvent, TLPointerEventInfo } from '@tldraw/editor' import { StateNode, TLInterruptEvent, TLKeyboardEvent, TLPointerEventInfo } from '@tldraw/editor'
import { Idle } from './children/Idle' import { Idle } from './childStates/Idle'
import { Pointing } from './children/Pointing' import { Pointing } from './childStates/Pointing'
import { ZoomBrushing } from './children/ZoomBrushing' import { ZoomBrushing } from './childStates/ZoomBrushing'
/** @public */ /** @public */
export class ZoomTool extends StateNode { export class ZoomTool extends StateNode {

View file

@ -3,6 +3,7 @@ import { useEditor, useValue } from '@tldraw/editor'
import classNames from 'classnames' import classNames from 'classnames'
import React, { ReactNode } from 'react' import React, { ReactNode } from 'react'
import { TldrawUiContextProvider, TldrawUiContextProviderProps } from './TldrawUiContextProvider' import { TldrawUiContextProvider, TldrawUiContextProviderProps } from './TldrawUiContextProvider'
import { TLUiAssetUrlOverrides } from './assetUrls'
import { BackToContent } from './components/BackToContent' import { BackToContent } from './components/BackToContent'
import { DebugPanel } from './components/DebugPanel' import { DebugPanel } from './components/DebugPanel'
import { Dialogs } from './components/Dialogs' import { Dialogs } from './components/Dialogs'
@ -61,6 +62,9 @@ export interface TldrawUiBaseProps {
* Additional items to add to the debug menu (will be deprecated) * Additional items to add to the debug menu (will be deprecated)
*/ */
renderDebugMenuItems?: () => React.ReactNode renderDebugMenuItems?: () => React.ReactNode
/** Asset URL override. */
assetUrls?: TLUiAssetUrlOverrides
} }
/** /**

View file

@ -1,14 +1,17 @@
import { EMBED_DEFINITIONS, LANGUAGES, RecursivePartial } from '@tldraw/editor' import { EMBED_DEFINITIONS, LANGUAGES, RecursivePartial } from '@tldraw/editor'
import { version } from '../ui/version' import { version } from '../ui/version'
import { TLEditorAssetUrls, defaultEditorAssetUrls } from '../utils/assetUrls' import { TLEditorAssetUrls, defaultEditorAssetUrls } from '../utils/static-assets/assetUrls'
import { TLUiIconType, iconTypes } from './icon-types' import { TLUiIconType, iconTypes } from './icon-types'
export type TLUiAssetUrls = TLEditorAssetUrls & { export type TLUiAssetUrls = TLEditorAssetUrls & {
icons: Record<TLUiIconType, string> icons: Record<TLUiIconType | Exclude<string, TLUiIconType>, string>
translations: Record<(typeof LANGUAGES)[number]['locale'], string> translations: Record<(typeof LANGUAGES)[number]['locale'], string>
embedIcons: Record<(typeof EMBED_DEFINITIONS)[number]['type'], string> embedIcons: Record<(typeof EMBED_DEFINITIONS)[number]['type'], string>
} }
/** @public */
export type TLUiAssetUrlOverrides = RecursivePartial<TLUiAssetUrls>
export let defaultUiAssetUrls: TLUiAssetUrls = { export let defaultUiAssetUrls: TLUiAssetUrls = {
...defaultEditorAssetUrls, ...defaultEditorAssetUrls,
icons: Object.fromEntries( icons: Object.fromEntries(

View file

@ -16,6 +16,7 @@ export const ActionsMenu = memo(function ActionsMenu() {
const isReadonly = useReadonly() const isReadonly = useReadonly()
function getActionMenuItem(item: TLUiMenuChild) { function getActionMenuItem(item: TLUiMenuChild) {
if (!item) return null
if (isReadonly && !item.readonlyOk) return null if (isReadonly && !item.readonlyOk) return null
switch (item.type) { switch (item.type) {

View file

@ -7,7 +7,9 @@ import { useBreakpoint } from '../hooks/useBreakpoint'
import { useContextMenuSchema } from '../hooks/useContextMenuSchema' import { useContextMenuSchema } from '../hooks/useContextMenuSchema'
import { useMenuIsOpen } from '../hooks/useMenuIsOpen' import { useMenuIsOpen } from '../hooks/useMenuIsOpen'
import { useReadonly } from '../hooks/useReadonly' import { useReadonly } from '../hooks/useReadonly'
import { TLUiTranslationKey } from '../hooks/useTranslation/TLUiTranslationKey'
import { useTranslation } from '../hooks/useTranslation/useTranslation' import { useTranslation } from '../hooks/useTranslation/useTranslation'
import { TLUiIconType } from '../icon-types'
import { MoveToPageMenu } from './MoveToPageMenu' import { MoveToPageMenu } from './MoveToPageMenu'
import { Button } from './primitives/Button' import { Button } from './primitives/Button'
import { Icon } from './primitives/Icon' import { Icon } from './primitives/Icon'
@ -116,6 +118,7 @@ const ContextMenuContent = forwardRef(function ContextMenuContent() {
parent: TLUiMenuChild | null, parent: TLUiMenuChild | null,
depth: number depth: number
) { ) {
if (!item) return null
if (isReadonly && !item.readonlyOk) return null if (isReadonly && !item.readonlyOk) return null
switch (item.type) { switch (item.type) {
@ -147,7 +150,7 @@ const ContextMenuContent = forwardRef(function ContextMenuContent() {
<_ContextMenu.SubTrigger dir="ltr" disabled={item.disabled} asChild> <_ContextMenu.SubTrigger dir="ltr" disabled={item.disabled} asChild>
<Button <Button
type="menu" type="menu"
label={item.label} label={item.label as TLUiTranslationKey}
data-testid={`menu-item.${item.id}`} data-testid={`menu-item.${item.id}`}
icon="chevron-right" icon="chevron-right"
/> />
@ -165,7 +168,7 @@ const ContextMenuContent = forwardRef(function ContextMenuContent() {
const { id, checkbox, contextMenuLabel, label, onSelect, kbd, icon } = item.actionItem const { id, checkbox, contextMenuLabel, label, onSelect, kbd, icon } = item.actionItem
const labelToUse = contextMenuLabel ?? label const labelToUse = contextMenuLabel ?? label
const labelStr = labelToUse ? msg(labelToUse) : undefined const labelStr = labelToUse ? msg(labelToUse as TLUiTranslationKey) : undefined
if (checkbox) { if (checkbox) {
// Item is in a checkbox group // Item is in a checkbox group
@ -199,9 +202,9 @@ const ContextMenuContent = forwardRef(function ContextMenuContent() {
type="menu" type="menu"
data-testid={`menu-item.${id}`} data-testid={`menu-item.${id}`}
kbd={kbd} kbd={kbd}
label={labelToUse} label={labelToUse as TLUiTranslationKey}
disabled={item.disabled} disabled={item.disabled}
iconLeft={breakpoint < 3 && depth > 2 ? icon : undefined} iconLeft={breakpoint < 3 && depth > 2 ? (icon as TLUiIconType) : undefined}
onClick={() => { onClick={() => {
if (disableClicks) { if (disableClicks) {
setDisableClicks(false) setDisableClicks(false)

View file

@ -65,7 +65,7 @@ export const DebugPanel = React.memo(function DebugPanel({
const CurrentState = track(function CurrentState() { const CurrentState = track(function CurrentState() {
const editor = useEditor() const editor = useEditor()
return <div className="tlui-debug-panel__current-state">{editor.root.getPath()}</div> return <div className="tlui-debug-panel__current-state">{editor.getPath()}</div>
}) })
const ShapeCount = function ShapeCount() { const ShapeCount = function ShapeCount() {

View file

@ -1,6 +1,6 @@
import { EMBED_DEFINITIONS, EmbedDefinition, track, useEditor } from '@tldraw/editor' import { EMBED_DEFINITIONS, EmbedDefinition, track, useEditor } from '@tldraw/editor'
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { TLEmbedResult, getEmbedInfo } from '../../utils/embeds' import { TLEmbedResult, getEmbedInfo } from '../../utils/embeds/embeds'
import { useAssetUrls } from '../hooks/useAssetUrls' import { useAssetUrls } from '../hooks/useAssetUrls'
import { TLUiDialogProps } from '../hooks/useDialogsProvider' import { TLUiDialogProps } from '../hooks/useDialogsProvider'
import { untranslated, useTranslation } from '../hooks/useTranslation/useTranslation' import { untranslated, useTranslation } from '../hooks/useTranslation/useTranslation'

View file

@ -13,8 +13,8 @@ import { Button } from './primitives/Button'
import * as M from './primitives/DropdownMenu' import * as M from './primitives/DropdownMenu'
interface HelpMenuLink { interface HelpMenuLink {
label: TLUiTranslationKey label: TLUiTranslationKey | Exclude<string, TLUiTranslationKey>
icon: TLUiIconType icon: TLUiIconType | Exclude<string, TLUiIconType>
url: string url: string
} }
@ -64,6 +64,7 @@ function HelpMenuContent() {
const isReadonly = useReadonly() const isReadonly = useReadonly()
function getHelpMenuItem(item: TLUiMenuChild) { function getHelpMenuItem(item: TLUiMenuChild) {
if (!item) return null
if (isReadonly && !item.readonlyOk) return null if (isReadonly && !item.readonlyOk) return null
switch (item.type) { switch (item.type) {

View file

@ -12,6 +12,7 @@ export const KeyboardShortcutsDialog = () => {
const shortcutsItems = useKeyboardShortcutsSchema() const shortcutsItems = useKeyboardShortcutsSchema()
function getKeyboardShortcutItem(item: TLUiMenuChild) { function getKeyboardShortcutItem(item: TLUiMenuChild) {
if (!item) return null
if (isReadonly && !item.readonlyOk) return null if (isReadonly && !item.readonlyOk) return null
switch (item.type) { switch (item.type) {
@ -23,7 +24,7 @@ export const KeyboardShortcutsDialog = () => {
</h2> </h2>
<div className="tlui-shortcuts-dialog__group__content"> <div className="tlui-shortcuts-dialog__group__content">
{item.children {item.children
.filter((item) => item.type === 'item' && item.actionItem.kbd) .filter((item) => item && item.type === 'item' && item.actionItem.kbd)
.map(getKeyboardShortcutItem)} .map(getKeyboardShortcutItem)}
</div> </div>
</div> </div>

View file

@ -45,6 +45,7 @@ function MenuContent() {
parent: TLUiMenuChild | null, parent: TLUiMenuChild | null,
depth: number depth: number
) { ) {
if (!item) return null
switch (item.type) { switch (item.type) {
case 'custom': { case 'custom': {
if (isReadonly && !item.readonlyOk) return null if (isReadonly && !item.readonlyOk) return null

View file

@ -11,9 +11,9 @@ import { StyleValuesForUi } from './styles'
interface DoubleDropdownPickerProps<T extends string> { interface DoubleDropdownPickerProps<T extends string> {
uiTypeA: string uiTypeA: string
uiTypeB: string uiTypeB: string
label: TLUiTranslationKey label: TLUiTranslationKey | Exclude<string, TLUiTranslationKey>
labelA: TLUiTranslationKey labelA: TLUiTranslationKey | Exclude<string, TLUiTranslationKey>
labelB: TLUiTranslationKey labelB: TLUiTranslationKey | Exclude<string, TLUiTranslationKey>
itemsA: StyleValuesForUi<T> itemsA: StyleValuesForUi<T>
itemsB: StyleValuesForUi<T> itemsB: StyleValuesForUi<T>
styleA: StyleProp<T> styleA: StyleProp<T>

View file

@ -10,7 +10,7 @@ import { StyleValuesForUi } from './styles'
interface DropdownPickerProps<T extends string> { interface DropdownPickerProps<T extends string> {
id: string id: string
label?: TLUiTranslationKey label?: TLUiTranslationKey | Exclude<string, TLUiTranslationKey>
uiType: string uiType: string
style: StyleProp<T> style: StyleProp<T>
value: SharedStyle<T> value: SharedStyle<T>

View file

@ -11,10 +11,10 @@ import { Kbd } from './Kbd'
export interface TLUiButtonProps extends React.HTMLAttributes<HTMLButtonElement> { export interface TLUiButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
loading?: boolean // TODO: loading spinner loading?: boolean // TODO: loading spinner
disabled?: boolean disabled?: boolean
label?: TLUiTranslationKey label?: TLUiTranslationKey | Exclude<string, TLUiTranslationKey>
icon?: TLUiIconType icon?: TLUiIconType | Exclude<string, TLUiIconType>
spinner?: boolean spinner?: boolean
iconLeft?: TLUiIconType iconLeft?: TLUiIconType | Exclude<string, TLUiIconType>
smallIcon?: boolean smallIcon?: boolean
kbd?: string kbd?: string
isChecked?: boolean isChecked?: boolean

View file

@ -96,7 +96,7 @@ export function SubTrigger({
'data-testid': testId, 'data-testid': testId,
'data-direction': dataDirection, 'data-direction': dataDirection,
}: { }: {
label: TLUiTranslationKey label: TLUiTranslationKey | Exclude<string, TLUiTranslationKey>
'data-testid'?: string 'data-testid'?: string
'data-direction'?: 'left' | 'right' 'data-direction'?: 'left' | 'right'
}) { }) {

View file

@ -5,7 +5,7 @@ import { TLUiIconType } from '../../icon-types'
/** @public */ /** @public */
export interface TLUiIconProps extends React.HTMLProps<HTMLDivElement> { export interface TLUiIconProps extends React.HTMLProps<HTMLDivElement> {
icon: TLUiIconType icon: TLUiIconType | Exclude<string, TLUiIconType>
small?: boolean small?: boolean
color?: string color?: string
children?: undefined children?: undefined
@ -23,17 +23,21 @@ export const Icon = memo(function Icon({
...props ...props
}: TLUiIconProps) { }: TLUiIconProps) {
const assetUrls = useAssetUrls() const assetUrls = useAssetUrls()
const asset = assetUrls.icons[icon] const asset = assetUrls.icons[icon as TLUiIconType] ?? assetUrls.icons['question-mark-circle']
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
useLayoutEffect(() => { useLayoutEffect(() => {
if (!asset) {
console.error(`Icon not found: ${icon}. Add it to the assetUrls.icons object.`)
}
if (ref?.current) { if (ref?.current) {
// HACK: Fix for <https://linear.app/tldraw/issue/TLD-1700/dragging-around-with-the-handtool-makes-lots-of-requests-for-icons> // HACK: Fix for <https://linear.app/tldraw/issue/TLD-1700/dragging-around-with-the-handtool-makes-lots-of-requests-for-icons>
// It seems that passing `WebkitMask` to react will cause a render on each call, no idea why... but this appears to be the fix. // It seems that passing `WebkitMask` to react will cause a render on each call, no idea why... but this appears to be the fix.
// @ts-ignore // @ts-ignore
ref.current.style.webkitMask = `url(${asset}) center 100% / 100% no-repeat` ref.current.style.webkitMask = `url(${asset}) center 100% / 100% no-repeat`
} }
}, [ref, asset]) }, [ref, asset, icon])
return ( return (
<div <div

View file

@ -9,9 +9,9 @@ import { Icon } from './Icon'
/** @public */ /** @public */
export interface TLUiInputProps { export interface TLUiInputProps {
disabled?: boolean disabled?: boolean
label?: TLUiTranslationKey label?: TLUiTranslationKey | Exclude<string, TLUiTranslationKey>
icon?: TLUiIconType icon?: TLUiIconType | Exclude<string, TLUiIconType>
iconLeft?: TLUiIconType iconLeft?: TLUiIconType | Exclude<string, TLUiIconType>
autofocus?: boolean autofocus?: boolean
autoselect?: boolean autoselect?: boolean
children?: any children?: any

View file

@ -13,7 +13,12 @@ import { TLUiToolItem } from './useTools'
import { TLUiTranslationKey } from './useTranslation/TLUiTranslationKey' import { TLUiTranslationKey } from './useTranslation/TLUiTranslationKey'
/** @public */ /** @public */
export type TLUiMenuChild = TLUiMenuItem | TLUiSubMenu | TLUiMenuGroup | TLUiCustomMenuItem export type TLUiMenuChild<TranslationKey extends string = string> =
| TLUiMenuItem
| TLUiSubMenu<TranslationKey>
| TLUiMenuGroup
| TLUiCustomMenuItem
| null
/** @public */ /** @public */
export type TLUiCustomMenuItem = { export type TLUiCustomMenuItem = {
@ -44,10 +49,10 @@ export type TLUiMenuGroup = {
} }
/** @public */ /** @public */
export type TLUiSubMenu = { export type TLUiSubMenu<TranslationKey extends string = string> = {
id: string id: string
type: 'submenu' type: 'submenu'
label: TLUiTranslationKey label: TranslationKey
disabled: boolean disabled: boolean
readonlyOk: boolean readonlyOk: boolean
children: TLUiMenuChild[] children: TLUiMenuChild[]
@ -64,7 +69,7 @@ export function compactMenuItems<T>(arr: T[]): Exclude<T, null | false | undefin
/** @public */ /** @public */
export function menuGroup( export function menuGroup(
id: string, id: string,
...children: (TLUiMenuChild | null | false)[] ...children: (TLUiMenuChild | false)[]
): TLUiMenuGroup | null { ): TLUiMenuGroup | null {
const childItems = compactMenuItems(children) const childItems = compactMenuItems(children)
@ -83,8 +88,8 @@ export function menuGroup(
/** @public */ /** @public */
export function menuSubmenu( export function menuSubmenu(
id: string, id: string,
label: TLUiTranslationKey, label: TLUiTranslationKey | Exclude<string, TLUiTranslationKey>,
...children: (TLUiMenuChild | null | false)[] ...children: (TLUiMenuChild | false)[]
): TLUiSubMenu | null { ): TLUiSubMenu | null {
const childItems = compactMenuItems(children) const childItems = compactMenuItems(children)
if (childItems.length === 0) return null if (childItems.length === 0) return null
@ -216,14 +221,11 @@ export function findMenuItem(menu: TLUiMenuSchema, path: string[]) {
return item return item
} }
function _findMenuItem( function _findMenuItem(menu: TLUiMenuSchema | TLUiMenuChild[], path: string[]): TLUiMenuChild {
menu: TLUiMenuSchema | TLUiMenuChild[],
path: string[]
): TLUiMenuChild | null {
const [next, ...rest] = path const [next, ...rest] = path
if (!next) return null if (!next) return null
const item = menu.find((item) => item.id === next) const item = menu.find((item) => item?.id === next)
if (!item) return null if (!item) return null
switch (item.type) { switch (item.type) {

View file

@ -17,7 +17,7 @@ import {
useEditor, useEditor,
} from '@tldraw/editor' } from '@tldraw/editor'
import * as React from 'react' import * as React from 'react'
import { getEmbedInfo } from '../../utils/embeds' import { getEmbedInfo } from '../../utils/embeds/embeds'
import { EditLinkDialog } from '../components/EditLinkDialog' import { EditLinkDialog } from '../components/EditLinkDialog'
import { EmbedDialog } from '../components/EmbedDialog' import { EmbedDialog } from '../components/EmbedDialog'
import { TLUiIconType } from '../icon-types' import { TLUiIconType } from '../icon-types'
@ -32,15 +32,18 @@ import { useToasts } from './useToastsProvider'
import { TLUiTranslationKey } from './useTranslation/TLUiTranslationKey' import { TLUiTranslationKey } from './useTranslation/TLUiTranslationKey'
/** @public */ /** @public */
export interface TLUiActionItem { export interface TLUiActionItem<
icon?: TLUiIconType TransationKey extends string = string,
IconType extends string = string
> {
icon?: IconType
id: string id: string
kbd?: string kbd?: string
title?: string title?: string
label?: TLUiTranslationKey label?: TransationKey
menuLabel?: TLUiTranslationKey menuLabel?: TransationKey
shortcutsLabel?: TLUiTranslationKey shortcutsLabel?: TransationKey
contextMenuLabel?: TLUiTranslationKey contextMenuLabel?: TransationKey
readonlyOk: boolean readonlyOk: boolean
checkbox?: boolean checkbox?: boolean
onSelect: (source: TLUiEventSource) => Promise<void> | void onSelect: (source: TLUiEventSource) => Promise<void> | void
@ -98,7 +101,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
return editor.getSelectedShapeIds().length > 0 return editor.getSelectedShapeIds().length > 0
} }
const actions = makeActions([ const actionItems: TLUiActionItem<TLUiTranslationKey, TLUiIconType>[] = [
{ {
id: 'edit-link', id: 'edit-link',
label: 'action.edit-link', label: 'action.edit-link',
@ -1094,7 +1097,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
editor.toggleLock(editor.getSelectedShapeIds()) editor.toggleLock(editor.getSelectedShapeIds())
}, },
}, },
]) ]
const actions = makeActions(actionItems)
if (overrides) { if (overrides) {
return overrides(editor, actions, undefined) return overrides(editor, actions, undefined)
@ -1102,9 +1107,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
return actions return actions
}, [ }, [
editor,
trackEvent, trackEvent,
overrides, overrides,
editor,
addDialog, addDialog,
insertMedia, insertMedia,
exportAs, exportAs,

View file

@ -1,6 +1,6 @@
import { Editor, TLShapeId, useEditor } from '@tldraw/editor' import { TLShapeId, useEditor } from '@tldraw/editor'
import { useCallback } from 'react' import { useCallback } from 'react'
import { TLCopyType, getSvgAsImage, getSvgAsString } from '../../utils/export' import { TLCopyType, copyAs } from '../../utils/export/copyAs'
import { useToasts } from './useToastsProvider' import { useToasts } from './useToastsProvider'
import { useTranslation } from './useTranslation/useTranslation' import { useTranslation } from './useTranslation/useTranslation'
@ -11,140 +11,16 @@ export function useCopyAs() {
const msg = useTranslation() const msg = useTranslation()
return useCallback( return useCallback(
// it's important that this function itself isn't async - we need to (ids: TLShapeId[], format: TLCopyType = 'svg') => {
// create the relevant `ClipboardItem`s synchronously to make sure copyAs(editor, ids, format).catch(() => {
// safari knows that the user _wants_ to copy:
// https://bugs.webkit.org/show_bug.cgi?id=222262
//
// this is fine for navigator.clipboard.write, but for fallbacks it's a
// little awkward.
function copyAs(ids: TLShapeId[] = editor.getSelectedShapeIds(), format: TLCopyType = 'svg') {
if (ids.length === 0) {
ids = [...editor.currentPageShapeIds]
}
if (ids.length === 0) {
return
}
switch (format) {
case 'svg': {
if (window.navigator.clipboard) {
if (window.navigator.clipboard.write) {
window.navigator.clipboard.write([
new ClipboardItem({
'text/plain': getExportedSvgBlob(editor, ids),
}),
])
} else {
fallbackWriteTextAsync(async () =>
getSvgAsString(await getExportSvgElement(editor, ids))
)
}
}
break
}
case 'jpeg':
case 'png': {
const mimeType = format === 'jpeg' ? 'image/jpeg' : 'image/png'
const blobPromise = getExportedImageBlob(editor, ids, format).then((blob) => {
if (blob) {
if (window.navigator.clipboard) {
return blob
}
throw new Error('Copy not supported')
} else {
addToast({ addToast({
id: 'copy-fail', id: 'copy-fail',
icon: 'warning-triangle', icon: 'warning-triangle',
title: msg('toast.error.copy-fail.title'), title: msg('toast.error.copy-fail.title'),
description: msg('toast.error.copy-fail.desc'), description: msg('toast.error.copy-fail.desc'),
}) })
throw new Error('Copy not possible')
}
}) })
window.navigator.clipboard
.write([
new ClipboardItem({
// Note: This needs to use the promise based approach for safari/ios to not bail on a permissions error.
[mimeType]: blobPromise,
}),
])
.catch((err: any) => {
// Firefox will fail with the above if `dom.events.asyncClipboard.clipboardItem` is enabled.
// See <https://github.com/tldraw/tldraw/issues/1325>
if (!err.toString().match(/^TypeError: DOMString not supported/)) {
console.error(err)
}
blobPromise.then((blob) => {
window.navigator.clipboard.write([
new ClipboardItem({
// Note: This needs to use the promise based approach for safari/ios to not bail on a permissions error.
[mimeType]: blob,
}),
])
})
})
break
}
case 'json': {
const data = editor.getContentFromCurrentPage(ids)
if (window.navigator.clipboard) {
const jsonStr = JSON.stringify(data)
if (window.navigator.clipboard.write) {
window.navigator.clipboard.write([
new ClipboardItem({
'text/plain': new Blob([jsonStr], { type: 'text/plain' }),
}),
])
} else {
fallbackWriteTextAsync(async () => jsonStr)
}
}
break
}
default:
throw new Error(`Copy type ${format} not supported.`)
}
}, },
[editor, addToast, msg] [editor, addToast, msg]
) )
} }
async function getExportSvgElement(editor: Editor, ids: TLShapeId[]) {
const svg = await editor.getSvg(ids, {
scale: 1,
background: editor.getInstanceState().exportBackground,
})
if (!svg) throw new Error('Could not construct SVG.')
return svg
}
async function getExportedSvgBlob(editor: Editor, ids: TLShapeId[]) {
return new Blob([getSvgAsString(await getExportSvgElement(editor, ids))], {
type: 'text/plain',
})
}
async function getExportedImageBlob(editor: Editor, ids: TLShapeId[], format: 'png' | 'jpeg') {
return await getSvgAsImage(await getExportSvgElement(editor, ids), editor.environment.isSafari, {
type: format,
quality: 1,
scale: 2,
})
}
async function fallbackWriteTextAsync(getText: () => Promise<string>) {
if (!(navigator && navigator.clipboard)) return
navigator.clipboard.writeText(await getText())
}

View file

@ -1,11 +1,6 @@
import { TLFrameShape, TLShapeId, useEditor } from '@tldraw/editor' import { TLShapeId, useEditor } from '@tldraw/editor'
import { useCallback } from 'react' import { useCallback } from 'react'
import { import { TLExportType, exportAs } from '../../utils/export/exportAs'
TLExportType,
downloadDataURLAsFile,
getSvgAsDataUrl,
getSvgAsImage,
} from '../../utils/export'
import { useToasts } from './useToastsProvider' import { useToasts } from './useToastsProvider'
import { useTranslation } from './useTranslation/useTranslation' import { useTranslation } from './useTranslation/useTranslation'
@ -16,97 +11,20 @@ export function useExportAs() {
const msg = useTranslation() const msg = useTranslation()
return useCallback( return useCallback(
async function exportAs( (ids: TLShapeId[], format: TLExportType = 'png') => {
ids: TLShapeId[] = editor.getSelectedShapeIds(), exportAs(editor, ids, format, {
format: TLExportType = 'png'
) {
if (ids.length === 0) {
ids = [...editor.currentPageShapeIds]
}
if (ids.length === 0) {
return
}
const svg = await editor.getSvg(ids, {
scale: 1, scale: 1,
background: editor.getInstanceState().exportBackground, background: editor.instanceState.exportBackground,
}) }).catch((e) => {
console.error(e.message)
if (!svg) throw new Error('Could not construct SVG.')
let name = 'shapes' + getTimestamp()
if (ids.length === 1) {
const first = editor.getShape(ids[0])!
if (editor.isShapeOfType<TLFrameShape>(first, 'frame')) {
name = first.props.name ?? 'frame'
} else {
name = first.id.replace(/:/, '_')
}
}
switch (format) {
case 'svg': {
const dataURL = await getSvgAsDataUrl(svg)
downloadDataURLAsFile(dataURL, `${name}.svg`)
return
}
case 'webp':
case 'png': {
const image = await getSvgAsImage(svg, editor.environment.isSafari, {
type: format,
quality: 1,
scale: 2,
})
if (!image) {
addToast({ addToast({
id: 'export-fail', id: 'export-fail',
// icon: 'error', // icon: 'error',
title: msg('toast.error.export-fail.title'), title: msg('toast.error.export-fail.title'),
description: msg('toast.error.export-fail.desc'), description: msg('toast.error.export-fail.desc'),
}) })
return })
}
const dataURL = URL.createObjectURL(image)
downloadDataURLAsFile(dataURL, `${name}.${format}`)
URL.revokeObjectURL(dataURL)
return
}
case 'json': {
const data = editor.getContentFromCurrentPage(ids)
const dataURL = URL.createObjectURL(
new Blob([JSON.stringify(data, null, 4)], { type: 'application/json' })
)
downloadDataURLAsFile(dataURL, `${name || 'shapes'}.json`)
URL.revokeObjectURL(dataURL)
return
}
default:
throw new Error(`Export type ${format} not supported.`)
}
}, },
[editor, addToast, msg] [editor, addToast, msg]
) )
} }
function getTimestamp() {
const now = new Date()
const year = String(now.getFullYear()).slice(2)
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
const hours = String(now.getHours()).padStart(2, '0')
const minutes = String(now.getMinutes()).padStart(2, '0')
const seconds = String(now.getSeconds()).padStart(2, '0')
return ` at ${year}-${month}-${day} ${hours}.${minutes}.${seconds}`
}

View file

@ -1,6 +1,6 @@
import { Editor, TLBookmarkShape, TLEmbedShape, useEditor, useValue } from '@tldraw/editor' import { Editor, TLBookmarkShape, TLEmbedShape, useEditor, useValue } from '@tldraw/editor'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { getEmbedInfo } from '../../utils/embeds' import { getEmbedInfo } from '../../utils/embeds/embeds'
import { import {
TLUiMenuSchema, TLUiMenuSchema,
compactMenuItems, compactMenuItems,
@ -111,7 +111,7 @@ export function TLUiMenuSchemaProvider({ overrides, children }: TLUiMenuSchemaPr
) )
const menuSchema = useMemo<TLUiMenuSchema>(() => { const menuSchema = useMemo<TLUiMenuSchema>(() => {
const menuSchema = compactMenuItems([ const menuSchema: TLUiMenuSchema = compactMenuItems([
menuGroup( menuGroup(
'menu', 'menu',
menuSubmenu( menuSubmenu(

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { TLEditorAssetUrls } from './assetUrls' import { TLEditorAssetUrls } from '../../utils/static-assets/assetUrls'
export type TLTypeFace = { export type TLTypeFace = {
url: string url: string

View file

@ -69,9 +69,9 @@ export function ToolbarSchemaProvider({ overrides, children }: TLUiToolbarSchema
toolbarItem(tools['arrow-up']), toolbarItem(tools['arrow-up']),
toolbarItem(tools['arrow-down']), toolbarItem(tools['arrow-down']),
toolbarItem(tools['arrow-right']), toolbarItem(tools['arrow-right']),
toolbarItem(tools.frame),
toolbarItem(tools.line), toolbarItem(tools.line),
toolbarItem(tools.highlight), toolbarItem(tools.highlight),
toolbarItem(tools.frame),
toolbarItem(tools.laser), toolbarItem(tools.laser),
]) ])

View file

@ -8,11 +8,14 @@ import { useInsertMedia } from './useInsertMedia'
import { TLUiTranslationKey } from './useTranslation/TLUiTranslationKey' import { TLUiTranslationKey } from './useTranslation/TLUiTranslationKey'
/** @public */ /** @public */
export interface TLUiToolItem { export interface TLUiToolItem<
TranslationKey extends string = string,
IconType extends string = string
> {
id: string id: string
label: TLUiTranslationKey label: TranslationKey
shortcutsLabel?: TLUiTranslationKey shortcutsLabel?: TranslationKey
icon: TLUiIconType icon: IconType
onSelect: (source: TLUiEventSource) => void onSelect: (source: TLUiEventSource) => void
kbd?: string kbd?: string
readonlyOk: boolean readonlyOk: boolean
@ -46,7 +49,7 @@ export function ToolsProvider({ overrides, children }: TLUiToolsProviderProps) {
const insertMedia = useInsertMedia() const insertMedia = useInsertMedia()
const tools = React.useMemo<TLUiToolsContextType>(() => { const tools = React.useMemo<TLUiToolsContextType>(() => {
const toolsArray: TLUiToolItem[] = [ const toolsArray: TLUiToolItem<TLUiTranslationKey, TLUiIconType>[] = [
{ {
id: 'select', id: 'select',
label: 'tool.select', label: 'tool.select',

View file

@ -105,8 +105,8 @@ export const TranslationProvider = track(function TranslationProvider({
export function useTranslation() { export function useTranslation() {
const translation = useCurrentTranslation() const translation = useCurrentTranslation()
return React.useCallback( return React.useCallback(
function msg(id: TLUiTranslationKey) { function msg(id: Exclude<string, TLUiTranslationKey> | string) {
return translation.messages[id] ?? id return translation.messages[id as TLUiTranslationKey] ?? id
}, },
[translation] [translation]
) )

View file

@ -58,31 +58,31 @@ type WithDefaultHelpers<T extends TLUiOverride<any, any>> = T extends TLUiOverri
: never : never
/** @public */ /** @public */
export interface TLUiOverrides { export type TLUiOverrides = Partial<{
actionsMenu?: WithDefaultHelpers<NonNullable<ActionsMenuSchemaProviderProps['overrides']>> actionsMenu: WithDefaultHelpers<NonNullable<ActionsMenuSchemaProviderProps['overrides']>>
actions?: WithDefaultHelpers<NonNullable<ActionsProviderProps['overrides']>> actions: WithDefaultHelpers<NonNullable<ActionsProviderProps['overrides']>>
contextMenu?: WithDefaultHelpers<NonNullable<TLUiContextMenuSchemaProviderProps['overrides']>> contextMenu: WithDefaultHelpers<NonNullable<TLUiContextMenuSchemaProviderProps['overrides']>>
helpMenu?: WithDefaultHelpers<NonNullable<TLUiHelpMenuSchemaProviderProps['overrides']>> helpMenu: WithDefaultHelpers<NonNullable<TLUiHelpMenuSchemaProviderProps['overrides']>>
menu?: WithDefaultHelpers<NonNullable<TLUiMenuSchemaProviderProps['overrides']>> menu: WithDefaultHelpers<NonNullable<TLUiMenuSchemaProviderProps['overrides']>>
toolbar?: WithDefaultHelpers<NonNullable<TLUiToolbarSchemaProviderProps['overrides']>> toolbar: WithDefaultHelpers<NonNullable<TLUiToolbarSchemaProviderProps['overrides']>>
keyboardShortcutsMenu?: WithDefaultHelpers< keyboardShortcutsMenu: WithDefaultHelpers<
NonNullable<TLUiKeyboardShortcutsSchemaProviderProps['overrides']> NonNullable<TLUiKeyboardShortcutsSchemaProviderProps['overrides']>
> >
tools?: WithDefaultHelpers<NonNullable<TLUiToolsProviderProps['overrides']>> tools: WithDefaultHelpers<NonNullable<TLUiToolsProviderProps['overrides']>>
translations?: TLUiTranslationProviderProps['overrides'] translations: TLUiTranslationProviderProps['overrides']
} }>
export interface TLUiOverridesWithoutDefaults { export type TLUiOverridesWithoutDefaults = Partial<{
actionsMenu?: ActionsMenuSchemaProviderProps['overrides'] actionsMenu: ActionsMenuSchemaProviderProps['overrides']
actions?: ActionsProviderProps['overrides'] actions: ActionsProviderProps['overrides']
contextMenu?: TLUiContextMenuSchemaProviderProps['overrides'] contextMenu: TLUiContextMenuSchemaProviderProps['overrides']
helpMenu?: TLUiHelpMenuSchemaProviderProps['overrides'] helpMenu: TLUiHelpMenuSchemaProviderProps['overrides']
menu?: TLUiMenuSchemaProviderProps['overrides'] menu: TLUiMenuSchemaProviderProps['overrides']
toolbar?: TLUiToolbarSchemaProviderProps['overrides'] toolbar: TLUiToolbarSchemaProviderProps['overrides']
keyboardShortcutsMenu?: TLUiKeyboardShortcutsSchemaProviderProps['overrides'] keyboardShortcutsMenu: TLUiKeyboardShortcutsSchemaProviderProps['overrides']
tools?: TLUiToolsProviderProps['overrides'] tools: TLUiToolsProviderProps['overrides']
translations?: TLUiTranslationProviderProps['overrides'] translations: TLUiTranslationProviderProps['overrides']
} }>
export function mergeOverrides( export function mergeOverrides(
overrides: TLUiOverrides[], overrides: TLUiOverrides[],

View file

@ -1,6 +1,6 @@
import downscale from 'downscale' import downscale from 'downscale'
import { getBrowserCanvasMaxSize } from '../shapes/shared/getBrowserCanvasMaxSize' import { getBrowserCanvasMaxSize } from '../shapes/shared/getBrowserCanvasMaxSize'
import { isAnimated } from './is-gif-animated' import { isAnimated } from './assets/is-gif-animated'
type BoxWidthHeight = { type BoxWidthHeight = {
w: number w: number

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