Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion apps/docs/content/releases/next.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,71 @@ status: published
last_version: v4.5.6
---

This release adds shape attribution with a new `TLUserStore` provider and extensible user records, clipboard hooks for intercepting copy, cut, and paste, custom record types to the store, a new `@tldraw/mermaid` package for converting Mermaid diagrams to native shapes, WebSocket hibernation support for tlsync, a new `@tldraw/editor-controller` package for scripting and automation, RTL language support in the UI, cross-window embedding support, arbitrary iframe embed pasting, and smarter export trimming. It also includes various other improvements and bug fixes.
This release adds a first-class theme system with display values for customizing default shapes, shape attribution with a new `TLUserStore` provider and extensible user records, clipboard hooks for intercepting copy, cut, and paste, custom record types to the store, a new `@tldraw/mermaid` package for converting Mermaid diagrams to native shapes, WebSocket hibernation support for tlsync, a new `@tldraw/editor-controller` package for scripting and automation, RTL language support in the UI, cross-window embedding support, arbitrary iframe embed pasting, and smarter export trimming. It also includes various other improvements and bug fixes.

## What's new

### 💥 Theme system with display values ([#8410](https://github.com/tldraw/tldraw/pull/8410))

A new first-class theme system replaces the previous approach where colors were hardcoded and resolved inline. Themes are now named, registered objects that shape utils consume via a structured display values pipeline.

Register custom themes via `TLThemes` module augmentation for type-safe IDs, add or remove palette colors via `TLThemeDefaultColors` and `TLRemovedDefaultThemeColors`, and pass themes to the editor via the `themes` and `initialTheme` props:

```tsx
<Tldraw
themes={{ corporate: myCorporateTheme }}
initialTheme="corporate"
/>
```

Each default shape util defines `getDefaultDisplayValues` to resolve visual properties (colors, stroke widths, font sizes) from the current theme and color mode. Override display values for a default shape with `getCustomDisplayValues`:

```tsx
const MyDrawShapeUtil = DrawShapeUtil.configure({
getCustomDisplayValues(_editor, _shape, _theme, _colorMode) {
return { strokeWidth: 10 }
},
})
```

New editor methods include `getCurrentTheme()`, `setCurrentTheme()`, `getThemes()`, `updateTheme()`, `updateThemes()`, and `getColorMode()`.

<details>
<summary>Migration guide</summary>

The `inferDarkMode` prop has been renamed to `colorScheme` and changed from `boolean` to `'light' | 'dark' | 'system'`:

```tsx
// Before
<Tldraw inferDarkMode />

// After
<Tldraw colorScheme="system" />
```

`useIsDarkMode()` has been renamed to `useColorMode()` and returns `'dark' | 'light'` instead of `boolean`.

`getDefaultColorTheme()` and `DefaultColorThemePalette` have been removed. Use `editor.getCurrentTheme().colors[colorMode]` instead:

```tsx
// Before
const theme = getDefaultColorTheme({ isDarkMode })

// After
const theme = editor.getCurrentTheme()
const colors = theme.colors[editor.getColorMode()]
```

`useDefaultColorTheme()` has been removed. Use `editor.getCurrentTheme()` and `useColorMode()` instead.

`FONT_FAMILIES`, `FONT_SIZES`, `LABEL_FONT_SIZES`, `STROKE_SIZES`, `TEXT_PROPS`, and `ARROW_LABEL_FONT_SIZES` have been removed — these are now resolved via display values.

`SvgExportContext.themeId` has been renamed to `SvgExportContext.colorMode` and changed from `string` to `'light' | 'dark'`.

`getColorValue()` now takes `TLThemeColors` as its first argument instead of `TLDefaultColorTheme`.

</details>

### Custom record types ([#8213](https://github.com/tldraw/tldraw/pull/8213))

You can now register custom record types in the tldraw store for persisting and synchronizing domain-specific data that doesn't fit into shapes, bindings, or assets. Custom records support scoping (document/session/presence), validation, migrations, and default properties.
Expand Down Expand Up @@ -138,6 +199,16 @@ New helpers `getOwnerDocument()` and `getOwnerWindow()` are exported from `@tldr

## API changes

- 💥 Remove `defaultColorNames`, `DefaultColorThemePalette`, `DefaultLabelColorStyle`, `TLDefaultColorTheme` type, and `getDefaultColorTheme()` from `@tldraw/tlschema`. Use `editor.getCurrentTheme().colors` instead. ([#8410](https://github.com/tldraw/tldraw/pull/8410))
- 💥 Remove `ARROW_LABEL_FONT_SIZES`, `FONT_FAMILIES`, `FONT_SIZES`, `LABEL_FONT_SIZES`, `STROKE_SIZES`, `TEXT_PROPS`, and `useDefaultColorTheme()` from `@tldraw/tldraw`. These are now resolved via display values. ([#8410](https://github.com/tldraw/tldraw/pull/8410))
- 💥 Rename `inferDarkMode` prop to `colorScheme` (`boolean` → `'light' | 'dark' | 'system'`). Rename `useIsDarkMode()` to `useColorMode()` (returns `'dark' | 'light'` instead of `boolean`). ([#8410](https://github.com/tldraw/tldraw/pull/8410))
- 💥 Rename `SvgExportContext.themeId` to `SvgExportContext.colorMode` (`string` → `'light' | 'dark'`). ([#8410](https://github.com/tldraw/tldraw/pull/8410))
- 💥 Change `getColorValue()` first argument from `TLDefaultColorTheme` to `TLThemeColors`. ([#8410](https://github.com/tldraw/tldraw/pull/8410))
- 💥 Change `PlainTextLabelProps` and `RichTextLabelProps`: `font` → `fontFamily`, `align` → `textAlign`, `fill` removed. ([#8410](https://github.com/tldraw/tldraw/pull/8410))
- Add `TLTheme`, `TLThemeId`, `TLThemes`, `TLThemeDefaultColors`, `TLThemeColors`, `TLRemovedDefaultThemeColors`, `ThemeManager`, `getDisplayValues()`, `getColorValue()`, and `DEFAULT_THEME` for the new theme system. ([#8410](https://github.com/tldraw/tldraw/pull/8410))
- Add `themes` and `initialTheme` props to `<Tldraw>` and `<TldrawEditor>`. ([#8410](https://github.com/tldraw/tldraw/pull/8410))
- Add `getCurrentTheme()`, `setCurrentTheme()`, `getThemes()`, `getTheme()`, `updateTheme()`, `updateThemes()`, and `getColorMode()` to the editor. ([#8410](https://github.com/tldraw/tldraw/pull/8410))
- Add `getDefaultDisplayValues` and `getCustomDisplayValues` to shape util options for theme-aware visual properties. ([#8410](https://github.com/tldraw/tldraw/pull/8410))
- Add support for pasting arbitrary `<iframe>` embed codes to create embed shapes from any service. ([#8306](https://github.com/tldraw/tldraw/pull/8306))
- Add `onBeforeCopyToClipboard`, `onBeforePasteFromClipboard`, and `onClipboardPasteRaw` hooks to `TldrawOptions` for intercepting clipboard operations. Add `TLClipboardWriteInfo` and `TLClipboardPasteRawInfo` types. Export `handleNativeOrMenuCopy` from `@tldraw/tldraw`. ([#8290](https://github.com/tldraw/tldraw/pull/8290))
- Add `CustomRecordInfo` interface, `createCustomRecordId()`, `createCustomRecordMigrationIds()`, `createCustomRecordMigrationSequence()`, `isCustomRecord()`, `isCustomRecordId()` for custom record types. `createTLSchema()` and `createTLStore()` now accept a `records` option. ([#8213](https://github.com/tldraw/tldraw/pull/8213))
Expand All @@ -159,6 +230,8 @@ New helpers `getOwnerDocument()` and `getOwnerWindow()` are exported from `@tldr
- Optimize geometry hot paths for hit testing: reduce allocations and function call overhead in `Vec`, `Edge2d`, `Circle2d`, `Arc2d`, `Polyline2d`, and intersection routines. Circle hit testing is up to 19x faster, polyline nearest-point is 6.8x faster. ([#8210](https://github.com/tldraw/tldraw/pull/8210))
- Exports now automatically trim to visual content bounds, capturing overflow like thick strokes and arrowheads without extra whitespace. ([#8202](https://github.com/tldraw/tldraw/pull/8202))
- Improve resize performance for multiple geo shapes with text labels by batching DOM measurements into a single pass per frame. ([#7949](https://github.com/tldraw/tldraw/pull/7949))
- Replace `@use-gesture/react` dependency with custom gesture handling, reducing bundle size and eliminating a stale dependency. ([#8392](https://github.com/tldraw/tldraw/pull/8392))
- Tighten iframe referrer policy for embeds to send only the origin instead of the full URL to third-party embed providers. ([#8412](https://github.com/tldraw/tldraw/pull/8412))
- Move the debug mode toggle into the preferences submenu. ([#8259](https://github.com/tldraw/tldraw/pull/8259))

## Bug fixes
Expand All @@ -176,3 +249,4 @@ New helpers `getOwnerDocument()` and `getOwnerWindow()` are exported from `@tldr
- Fix camera state getting permanently stuck at 'moving' when the editor is disposed mid-camera-transition, blocking all shape interactions. ([#8396](https://github.com/tldraw/tldraw/pull/8396))
- Fix missing sandbox attribute on GitHub Gist embeds. ([#8403](https://github.com/tldraw/tldraw/pull/8403))
- Restrict sandbox permissions for unknown/arbitrary embeds to mitigate security risks from untrusted content. ([#8404](https://github.com/tldraw/tldraw/pull/8404))
- Fix leaked camera animations, following subscriptions, and stale menu state when the editor is disposed during active operations. ([#8422](https://github.com/tldraw/tldraw/pull/8422))
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,4 @@
"purgecss": "^5.0.0",
"svgo": "^3.3.3"
}
}
}
50 changes: 50 additions & 0 deletions packages/tldraw/src/lib/ui/context/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
useMaybeEditor,
} from '@tldraw/editor'
import * as React from 'react'
import { defaultHandleExternalTextContent } from '../../defaultExternalContentHandlers'
import { createBookmarkFromUrl } from '../../shapes/bookmark/bookmarks'
import { downloadFile } from '../../utils/export/exportAs'
import { fitFrameToContent, removeFrame } from '../../utils/frames/frames'
Expand Down Expand Up @@ -1010,6 +1011,55 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
})
},
},
{
// Cmd+Option+V: paste at cursor (or center if paste-at-cursor pref is on)
id: 'paste-at-cursor',
label: 'action.paste',
kbd: '$?v',
onSelect(source) {
const pasteAtCursor = !editor.user.getIsPasteAtCursorMode()
const point = pasteAtCursor ? editor.inputs.getCurrentPagePoint() : undefined
navigator.clipboard
?.read()
.then((clipboardItems) => {
helpers.paste(clipboardItems, source, point)
})
.catch(() => {
helpers.addToast({
title: helpers.msg('action.paste-error-title'),
description: helpers.msg('action.paste-error-description'),
severity: 'error',
})
})
},
},
{
// Cmd+Shift+Option+V: paste plain text at cursor (or center if pref is on)
id: 'paste-plain-text-at-cursor',
label: 'action.paste',
kbd: '$!?v',
onSelect() {
const pasteAtCursor = !editor.user.getIsPasteAtCursorMode()
const point = pasteAtCursor
? editor.inputs.getCurrentPagePoint()
: editor.getViewportPageBounds().center
navigator.clipboard
?.readText()
.then((text) => {
if (text?.trim()) {
editor.markHistoryStoppingPoint('paste')
defaultHandleExternalTextContent(editor, { text, point })
}
})
.catch(() => {
helpers.addToast({
title: helpers.msg('action.paste-error-title'),
description: helpers.msg('action.paste-error-description'),
severity: 'error',
})
})
},
},
{
id: 'select-all',
label: 'action.select-all',
Expand Down
67 changes: 55 additions & 12 deletions packages/tldraw/src/lib/ui/hooks/useClipboardEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
FileHelpers,
TLClipboardWriteInfo,
TLExternalContentSource,
Vec,
VecLike,
activeElementShouldCaptureKeys,
assert,
Expand All @@ -18,11 +17,33 @@ import {
} from '@tldraw/editor'
import lz from 'lz-string'
import { useCallback, useEffect } from 'react'
import { defaultHandleExternalTextContent } from '../../defaultExternalContentHandlers'
import { TLDRAW_CUSTOM_PNG_MIME_TYPE, getCanonicalClipboardReadType } from '../../utils/clipboard'
import { TLUiEventSource, useUiEvents } from '../context/events'
import { pasteFiles } from './clipboard/pasteFiles'
import { pasteUrl } from './clipboard/pasteUrl'

/**
* Resolves paste modifier keys into plain-text and position behavior.
* Alt/Option inverts the paste-at-cursor user preference.
*
* @param isShift - Whether the Shift key is pressed (indicates plain text paste)
* @param isAlt - Whether the Alt/Option key is pressed (inverts paste position preference)
* @param pasteAtCursorPref - The user's preference for pasting at the cursor (true) or center (false)
*
* @internal
*/
export function resolvePasteModifiers(
isShift: boolean,
isAlt: boolean,
pasteAtCursorPref: boolean
) {
return {
isPlainText: isShift,
pasteAtCursor: isAlt ? !pasteAtCursorPref : pasteAtCursorPref,
}
}

// Expected paste mime types. The earlier in this array they appear, the higher preference we give
// them. For example, we prefer the `web image/png+tldraw` type to plain `image/png` as it does not
// strip some of the extra metadata we write into it.
Expand Down Expand Up @@ -877,6 +898,15 @@ export function useNativeClipboardEvents() {
}
}

// Track native modifier state from the most recent keydown. We use this
// instead of editor.inputs.getShiftKey() because the editor applies a
// 150ms delay on modifier release (to prevent physical race conditions
// with pointer events), which can cause false positives here.
let nativeShiftKey = false
const trackModifiers = (e: KeyboardEvent) => {
nativeShiftKey = e.shiftKey
}

const paste = (e: ClipboardEvent) => {
if (disablingMiddleClickPaste) {
editor.markEventAsHandled(e)
Expand All @@ -888,18 +918,27 @@ export function useNativeClipboardEvents() {
// input instead; e.g. when pasting text into a text shape's content
if (editor.getEditingShapeId() !== null || areShortcutsDisabled(editor)) return

// Where should the shapes go?
let point: Vec | undefined = undefined
let pasteAtCursor = false
// Cmd+Shift+V / Ctrl+Shift+V = paste as plain text (no formatting)
if (nativeShiftKey) {
const text = e.clipboardData?.getData('text/plain')
if (text?.trim()) {
const point = editor.user.getIsPasteAtCursorMode()
? editor.inputs.getCurrentPagePoint()
: editor.getViewportPageBounds().center
editor.markHistoryStoppingPoint('paste')
defaultHandleExternalTextContent(editor, { text, point })
}
preventDefault(e)
trackEvent('paste', { source: 'kbd' })
return
}

// | Shiftkey | Paste at cursor mode | Paste at point? |
// | N | N | N |
// | Y | N | Y |
// | N | Y | Y |
// | Y | Y | N |
if (editor.inputs.getShiftKey()) pasteAtCursor = true
if (editor.user.getIsPasteAtCursorMode()) pasteAtCursor = !pasteAtCursor
if (pasteAtCursor) point = editor.inputs.getCurrentPagePoint()
// Cmd+V: paste at center by default, or at cursor when the preference is on.
// (Cmd+Option+V and Cmd+Shift+Option+V are handled as actions in actions.tsx
// because the browser only fires paste events for Cmd+V and Cmd+Shift+V.)
const point = editor.user.getIsPasteAtCursorMode()
? editor.inputs.getCurrentPagePoint()
: undefined

if (
editor.options.onClipboardPasteRaw?.({
Expand Down Expand Up @@ -960,12 +999,16 @@ export function useNativeClipboardEvents() {
ownerDocument?.addEventListener('cut', cut)
ownerDocument?.addEventListener('paste', paste)
ownerDocument?.addEventListener('pointerup', pointerUpHandler)
ownerDocument?.addEventListener('keydown', trackModifiers, true)
ownerDocument?.addEventListener('keyup', trackModifiers, true)

return () => {
ownerDocument?.removeEventListener('copy', copy)
ownerDocument?.removeEventListener('cut', cut)
ownerDocument?.removeEventListener('paste', paste)
ownerDocument?.removeEventListener('pointerup', pointerUpHandler)
ownerDocument?.removeEventListener('keydown', trackModifiers, true)
ownerDocument?.removeEventListener('keyup', trackModifiers, true)
}
}, [editor, trackEvent, appIsFocused, ownerDocument])
}
97 changes: 97 additions & 0 deletions packages/tldraw/src/test/commands/clipboardCallbacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { vi } from 'vitest'
import {
handleNativeOrMenuCopy,
putPastedExternalContent,
resolvePasteModifiers,
} from '../../lib/ui/hooks/useClipboardEvents'
import { TestEditor } from '../TestEditor'

Expand Down Expand Up @@ -239,3 +240,99 @@ describe('handleNativeOrMenuCopy', () => {
expect(window.navigator.clipboard.write).not.toHaveBeenCalled()
})
})

describe('resolvePasteModifiers', () => {
it('cmd+v: regular paste, default position (pref off)', () => {
const result = resolvePasteModifiers(false, false, false)
expect(result).toEqual({ isPlainText: false, pasteAtCursor: false })
})

it('cmd+v: regular paste, default position (pref on)', () => {
const result = resolvePasteModifiers(false, false, true)
expect(result).toEqual({ isPlainText: false, pasteAtCursor: true })
})

it('cmd+shift+v: plain text, default position (pref off)', () => {
const result = resolvePasteModifiers(true, false, false)
expect(result).toEqual({ isPlainText: true, pasteAtCursor: false })
})

it('cmd+shift+v: plain text, default position (pref on)', () => {
const result = resolvePasteModifiers(true, false, true)
expect(result).toEqual({ isPlainText: true, pasteAtCursor: true })
})

it('cmd+option+v: regular paste, inverted position (pref off → cursor)', () => {
const result = resolvePasteModifiers(false, true, false)
expect(result).toEqual({ isPlainText: false, pasteAtCursor: true })
})

it('cmd+option+v: regular paste, inverted position (pref on → center)', () => {
const result = resolvePasteModifiers(false, true, true)
expect(result).toEqual({ isPlainText: false, pasteAtCursor: false })
})

it('cmd+shift+option+v: plain text, inverted position (pref off → cursor)', () => {
const result = resolvePasteModifiers(true, true, false)
expect(result).toEqual({ isPlainText: true, pasteAtCursor: true })
})

it('cmd+shift+option+v: plain text, inverted position (pref on → center)', () => {
const result = resolvePasteModifiers(true, true, true)
expect(result).toEqual({ isPlainText: true, pasteAtCursor: false })
})
})

describe('editor modifier delay vs native modifier state', () => {
beforeEach(() => {
vi.useFakeTimers()
editor = new TestEditor()
})

afterEach(() => {
vi.useRealTimers()
})

it('editor getShiftKey stays true for 150ms after shift is released (demonstrating the delay)', () => {
// Press shift
editor.dispatch({
type: 'keyboard',
name: 'key_down',
key: 'Shift',
shiftKey: true,
ctrlKey: false,
altKey: false,
metaKey: false,
accelKey: false,
code: 'ShiftLeft',
})
editor.forceTick()
expect(editor.inputs.getShiftKey()).toBe(true)

// Release shift
editor.dispatch({
type: 'keyboard',
name: 'key_up',
key: 'Shift',
shiftKey: false,
ctrlKey: false,
altKey: false,
metaKey: false,
accelKey: false,
code: 'ShiftLeft',
})
editor.forceTick()

// Editor still thinks shift is held due to 150ms delay
expect(editor.inputs.getShiftKey()).toBe(true)

// A native keydown event would report shiftKey=false immediately.
// This is the race condition: if cmd+v fires within this 150ms window,
// using editor.inputs.getShiftKey() would incorrectly return true,
// causing a plain text paste when the user wanted a regular paste.

// After 150ms, the editor finally catches up
vi.advanceTimersByTime(150)
expect(editor.inputs.getShiftKey()).toBe(false)
})
})
Loading