From 285b36c63c1dc52c02562624bc8964db3ff15a69 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Wed, 6 May 2026 11:08:05 +0100 Subject: [PATCH 01/11] examples: add dimensions hud overlay example (#8767) having fun with the new overlay utils, this adds a design tool like overlay that tells you the dimensions of your shapes https://github.com/user-attachments/assets/932bd100-8229-4e2b-9695-8a261b8a162d ### Change type - [ ] `bugfix` - [ ] `improvement` - [ ] `feature` - [ ] `api` - [x] `other` --- .../dimensions-hud/DimensionsHudExample.tsx | 39 ++++ .../DimensionsHudOverlayUtil.ts | 190 ++++++++++++++++++ .../editor-api/dimensions-hud/README.md | 12 ++ 3 files changed, 241 insertions(+) create mode 100644 apps/examples/src/examples/editor-api/dimensions-hud/DimensionsHudExample.tsx create mode 100644 apps/examples/src/examples/editor-api/dimensions-hud/DimensionsHudOverlayUtil.ts create mode 100644 apps/examples/src/examples/editor-api/dimensions-hud/README.md diff --git a/apps/examples/src/examples/editor-api/dimensions-hud/DimensionsHudExample.tsx b/apps/examples/src/examples/editor-api/dimensions-hud/DimensionsHudExample.tsx new file mode 100644 index 000000000000..6e789b9aad84 --- /dev/null +++ b/apps/examples/src/examples/editor-api/dimensions-hud/DimensionsHudExample.tsx @@ -0,0 +1,39 @@ +import { Tldraw, defaultOverlayUtils } from 'tldraw' +import 'tldraw/tldraw.css' +import { DimensionsHudOverlayUtil } from './DimensionsHudOverlayUtil' + +// There's a guide at the bottom of this file! + +// [1] +const overlayUtils = [...defaultOverlayUtils, DimensionsHudOverlayUtil] + +export default function DimensionsHudExample() { + return ( +
+ {/* [2] */} + +
+ ) +} + +/* +This example adds a non-interactive dimensions label that follows the current +selection. The label is implemented as a custom `OverlayUtil`, so it renders in +the editor's canvas overlay layer rather than in the React tree. + +`DimensionsHudOverlayUtil.ts` does three things: + +- `isActive()` subscribes to the current selection and skips work when nothing + is selected. +- `getOverlays()` derives the label's page-space position, dimensions, and + rotation from the selected shape or selection bounds. +- `render()` draws the pill with Canvas 2D APIs. + +[1] +Register the custom overlay util after `defaultOverlayUtils` so the built-in +overlays remain enabled. The util's `options.zIndex` controls where it appears +relative to the selection handles and other canvas overlays. + +[2] +Pass the overlay util array to the `overlayUtils` prop on ``. +*/ diff --git a/apps/examples/src/examples/editor-api/dimensions-hud/DimensionsHudOverlayUtil.ts b/apps/examples/src/examples/editor-api/dimensions-hud/DimensionsHudOverlayUtil.ts new file mode 100644 index 000000000000..1506a8130f93 --- /dev/null +++ b/apps/examples/src/examples/editor-api/dimensions-hud/DimensionsHudOverlayUtil.ts @@ -0,0 +1,190 @@ +import { Box, OverlayUtil, type TLOverlay, Vec } from 'tldraw' + +interface TLDimensionsHudOverlay extends TLOverlay { + props: { + x: number + y: number + w: number + h: number + rotation: number + } +} + +const LABEL_PADDING = 20 +const PI2 = Math.PI * 2 + +type LabelEdge = 'top' | 'right' | 'bottom' | 'left' + +// [1] +function getLabelEdge(rotation: number): LabelEdge { + const quarterTurn = Math.round(rotation / (Math.PI / 2)) + const normalizedQuarterTurn = ((quarterTurn % 4) + 4) % 4 + + switch (normalizedQuarterTurn) { + case 0: + return 'bottom' + case 1: + return 'right' + case 2: + return 'top' + default: + return 'left' + } +} + +// [2] +function getReadableRotation(rotation: number): number { + const normalizedRotation = ((rotation + Math.PI) % PI2) - Math.PI + return Math.abs(normalizedRotation) > Math.PI / 2 ? rotation + Math.PI : rotation +} + +// [3] +function getLabelPointForEdge(edge: LabelEdge, bounds: Box, padding: number): Vec { + switch (edge) { + case 'bottom': + return new Vec(bounds.midX, bounds.maxY + padding) + case 'right': + return new Vec(bounds.maxX + padding, bounds.midY) + case 'top': + return new Vec(bounds.midX, bounds.minY - padding) + case 'left': + return new Vec(bounds.minX - padding, bounds.midY) + } +} + +export class DimensionsHudOverlayUtil extends OverlayUtil { + static override type = 'dimensions-hud' + override options = { zIndex: 950 } + + override isActive(): boolean { + return this.editor.getSelectedShapeIds().length > 0 + } + + override getOverlays(): TLDimensionsHudOverlay[] { + const selectedShapeIds = this.editor.getSelectedShapeIds() + + if (selectedShapeIds.length === 0) return [] + + // [4] + if (selectedShapeIds.length > 1) { + const bounds = this.editor.getSelectionPageBounds() + if (!bounds) return [] + const zoom = this.editor.getZoomLevel() + + return [ + { + id: 'dimensions-hud', + type: 'dimensions-hud', + props: { + x: bounds.midX, + y: bounds.maxY + LABEL_PADDING / zoom, + w: Math.round(bounds.width), + h: Math.round(bounds.height), + rotation: 0, + }, + }, + ] + } + + const shape = this.editor.getShape(selectedShapeIds[0]) + if (!shape) return [] + + // [5] + const bounds = this.editor.getShapeGeometry(shape).bounds + const transform = this.editor.getShapePageTransform(shape) + const zoom = this.editor.getZoomLevel() + const rotation = transform.rotation() + + // [6] + const padding = LABEL_PADDING / zoom + + const labelEdge = getLabelEdge(rotation) + const localLabelPoint = getLabelPointForEdge(labelEdge, bounds, padding) + + const labelPoint = transform.applyToPoint(localLabelPoint) + const labelRotation = + labelEdge === 'left' || labelEdge === 'right' ? rotation + Math.PI / 2 : rotation + const readableLabelRotation = getReadableRotation(labelRotation) + + return [ + { + id: 'dimensions-hud', + type: 'dimensions-hud', + props: { + x: labelPoint.x, + y: labelPoint.y, + w: Math.round(bounds.width), + h: Math.round(bounds.height), + rotation: readableLabelRotation, + }, + }, + ] + } + + override render(ctx: CanvasRenderingContext2D, overlays: TLDimensionsHudOverlay[]): void { + const overlay = overlays[0] + if (!overlay) return + + const colors = this.editor.getCurrentTheme().colors[this.editor.getColorMode()] + + const { x, y, w, h, rotation } = overlay.props + const zoom = this.editor.getZoomLevel() + + ctx.save() + + // [7] + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.font = `${10 / zoom}px sans-serif` + + const dimensionsLabel = `${w} × ${h}` + const metrics = ctx.measureText(dimensionsLabel) + const textHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent + const textWidth = metrics.width + const pillPaddingX = 5 / zoom + const pillWidth = textWidth + pillPaddingX * 2 + const pillHeight = textHeight * 2 + + ctx.translate(x, y) + ctx.rotate(rotation) + + ctx.fillStyle = colors.selectionStroke + ctx.beginPath() + ctx.roundRect(-pillWidth / 2, -pillHeight / 2, pillWidth, pillHeight, pillHeight / 3.5) + ctx.fill() + + ctx.fillStyle = colors.selectedContrast + ctx.fillText(dimensionsLabel, 0, 0) + ctx.restore() + } +} + +/* +[1] +Pick the side of the selected shape that is closest to the bottom of the page. +This keeps the label near the user's expected resize readout position even when +the shape is rotated. + +[2] +Flip the label by half a turn when it would otherwise be upside down. + +[3] +Calculate the label point in the shape's local coordinate space. The shape's +page transform converts it to page space later. + +[4] +Multiple selections do not have a single rotation, so the label uses the +selection's page bounds and stays horizontal. + +[5] +Use shape geometry bounds for the width and height. These are local bounds, so +they represent the unrotated dimensions the user is editing. + +[6] +Overlay drawing happens in page space. Divide fixed screen-space values by the +zoom level so padding, text, and the pill size look consistent while zooming. + +[7] +Save and restore the canvas context around all drawing state because overlay +utils share the same Canvas 2D context. +*/ diff --git a/apps/examples/src/examples/editor-api/dimensions-hud/README.md b/apps/examples/src/examples/editor-api/dimensions-hud/README.md new file mode 100644 index 000000000000..21579baa0904 --- /dev/null +++ b/apps/examples/src/examples/editor-api/dimensions-hud/README.md @@ -0,0 +1,12 @@ +--- +title: Dimensions HUD +component: ./DimensionsHudExample.tsx +priority: 3 +keywords: [overlay, overlayutil, hud, dimensions, resize, bounds, gesture] +--- + +Show a live width × height pill for the current selection. + +--- + +A non-interactive HUD that piggybacks on the editor's existing selection and resize state. The overlay renders into the canvas in page space, so it follows pan and zoom for free without any `pageToScreen` plumbing. From b01f99f12d7e51550cb6853a9178c8a201ff717d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mime=20=C4=8Cuvalo?= Date: Wed, 6 May 2026 11:08:49 +0100 Subject: [PATCH 02/11] fix(editor): restore ctrl+click and Android long-press context menu (#8772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In order to fix two regressions introduced by #8501 (right-click and drag to pan the camera), this PR makes the canvas's `onContextMenu` handler more selective. The new handler was unconditionally calling `preventDefault` on every trusted `contextmenu` event, and Radix's `composeEventHandlers` skips its own handler when `event.defaultPrevented` is true. That broke the two context menu sources where the SDK relies on the browser-native `contextmenu` rather than synthesising one itself: - ctrl+click on macOS — the OS-translated `contextmenu` after a `pointerdown` with `button=0` (closes #8771) - long-press on Android (and other touch devices) — the `contextmenu` Android Chrome dispatches after the OS long-press timeout (closes #8770) Now we only suppress the native `contextmenu` when it follows a real right-click (`button === 2` with no ctrl modifier). That's the only case where our pointer handling has already decided what to do — either we'll dispatch a synthetic `contextmenu` from `onPointerUp` to open the menu at the release position, or we'll have panned and don't want a menu at all. Every other source (ctrl+click, two-finger trackpad tap on macOS reported as `button=2 + ctrlKey=true` by some Chromium builds, long-press on touch) flows through to Radix as before. ### Change type - [x] `bugfix` ### Test plan 1. On macOS, hold ctrl and click the canvas — the context menu opens at the click position. 2. On Android (or any touch device), long-press the canvas — the context menu opens after the long-press. 3. Right-click on empty canvas — the context menu still opens at the release position (existing behaviour). 4. Right-click + drag — the camera still pans without opening a menu (existing behaviour). 5. Two-finger trackpad tap on macOS — the context menu still opens (existing behaviour). - [x] Unit tests - [x] End to end tests ### Release notes - Fix the context menu not opening on ctrl+click on macOS and long-press on Android. ### Code changes | Section | LOC change | | --------- | ---------- | | Core code | +13 / -8 | Made with [Cursor](https://cursor.com) Co-authored-by: Cursor --- .../editor/src/lib/hooks/useCanvasEvents.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/editor/src/lib/hooks/useCanvasEvents.ts b/packages/editor/src/lib/hooks/useCanvasEvents.ts index 136237b81e25..24fb42650b54 100644 --- a/packages/editor/src/lib/hooks/useCanvasEvents.ts +++ b/packages/editor/src/lib/hooks/useCanvasEvents.ts @@ -169,14 +169,19 @@ export function useCanvasEvents() { // menu opens on press. if (!editor.options.rightClickPanning) return // Synthetic events — our own dispatch from onPointerUp, or tests using - // fireEvent.contextMenu — pass through so Radix can open the menu. The real - // browser contextmenu is always suppressed: right-click behavior has - // already been decided by our pointer handling (either we dispatched a - // synthetic to open the menu at the release position, or we panned and - // don't want a menu at all). - if (e.nativeEvent.isTrusted) { - preventDefault(e) - } + // fireEvent.contextMenu — pass through so Radix can open the menu. + if (!e.nativeEvent.isTrusted) return + // Only suppress the native browser contextmenu when it follows a real + // right-click (button=2 with no ctrl modifier). For those, our pointer + // handling has already decided what to do (either we'll dispatch a + // synthetic contextmenu on pointerup to open the menu at the release + // position, or we panned and don't want a menu at all). + // + // Other contextmenu sources must reach Radix so the menu opens: + // - ctrl+click on macOS (button=0, or button=2 with ctrlKey=true) + // - long-press on touch devices (button=0, pointerType=touch) + if (e.button !== 2 || e.ctrlKey) return + preventDefault(e) } return { From cf26b495516ce24ad8a932be4fbd5f6b5e2c6d8e Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Wed, 6 May 2026 11:38:31 +0100 Subject: [PATCH 03/11] refactor(editor): drop redundant tl-overlays wrapper (#8773) In order to clean up the canvas DOM after the OverlayUtil refactor (#8633), this PR drops the now-redundant `tl-overlays` wrapper around the canvas overlay element and folds the `tl-canvas__in-front` wrapper (with its event handlers) into `InFrontOfTheCanvasWrapper` so it isn't rendered when no `InFrontOfTheCanvas` component is registered. Since the OverlayUtil rewrite, `CanvasOverlays` only renders a single ``, and the wrapper's only meaningful contribution was the `--tl-layer-canvas-overlays` z-index. That now lives directly on `.tl-canvas-overlays`. The matching `.tl-overlays` and `.tl-overlays__item` rules are removed, along with `DefaultCursor.tsx`, which was the last consumer of `.tl-overlays__item` and has been orphaned (not exported, not rendered) since the EditorComponents `Cursor` slot was removed in #8633. ### Change type - [x] `other` ### Test plan 1. Run \`yarn dev\` and confirm canvas overlays still render and interact normally (selection, brush, snap lines, scribble, collaborator cursors). 2. Confirm an example that registers an \`InFrontOfTheCanvas\` component still renders inside the \`tl-canvas__in-front\` wrapper and receives pointer/touch events. 3. Confirm overlay stacking order is unchanged (overlays sit above shapes and HTML layers). ### Release notes - None. ### Code changes | Section | LOC change | | --------- | ---------- | | Core code | +21 / -98 | --- .../TlaEditor/TlaEditorTopLeftPanel.tsx | 4 +- packages/editor/editor.css | 21 +------ .../default-components/DefaultCanvas.tsx | 27 +++++---- .../default-components/DefaultCursor.tsx | 59 ------------------- .../MainMenu/DefaultMainMenuContent.tsx | 6 +- 5 files changed, 18 insertions(+), 99 deletions(-) delete mode 100644 packages/editor/src/lib/components/default-components/DefaultCursor.tsx diff --git a/apps/dotcom/client/src/tla/components/TlaEditor/TlaEditorTopLeftPanel.tsx b/apps/dotcom/client/src/tla/components/TlaEditor/TlaEditorTopLeftPanel.tsx index 2ab7ce7470d2..6879461f4ee5 100644 --- a/apps/dotcom/client/src/tla/components/TlaEditor/TlaEditorTopLeftPanel.tsx +++ b/apps/dotcom/client/src/tla/components/TlaEditor/TlaEditorTopLeftPanel.tsx @@ -444,6 +444,7 @@ function TlaPreferencesGroup() { + @@ -451,9 +452,6 @@ function TlaPreferencesGroup() { - - - diff --git a/packages/editor/editor.css b/packages/editor/editor.css index 98ad66801134..18eef884516e 100644 --- a/packages/editor/editor.css +++ b/packages/editor/editor.css @@ -317,26 +317,6 @@ input, z-index: var(--tl-layer-canvas-shapes); } -.tl-overlays { - position: absolute; - top: 0px; - left: 0px; - height: 100%; - width: 100%; - contain: strict; - pointer-events: none; - z-index: var(--tl-layer-canvas-overlays); -} - -.tl-overlays__item { - position: absolute; - top: 0px; - left: 0px; - overflow: visible; - pointer-events: none; - transform-origin: top left; -} - .tl-svg-context { position: absolute; top: 0px; @@ -963,6 +943,7 @@ input, position: absolute; inset: 0; pointer-events: none; + z-index: var(--tl-layer-canvas-overlays); } /* ---------------------- Shape --------------------- */ diff --git a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx index 01098d842fbc..415c827e7131 100644 --- a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx +++ b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx @@ -155,29 +155,30 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) { {SelectionBackground && } {hideShapes ? null : } -
- -
+ -
- -
+ ) } function InFrontOfTheCanvasWrapper() { + const editor = useEditor() const { InFrontOfTheCanvas } = useEditorComponents() if (!InFrontOfTheCanvas) return null - return + return ( +
+ +
+ ) } function GridWrapper() { diff --git a/packages/editor/src/lib/components/default-components/DefaultCursor.tsx b/packages/editor/src/lib/components/default-components/DefaultCursor.tsx deleted file mode 100644 index d1c68d55f38b..000000000000 --- a/packages/editor/src/lib/components/default-components/DefaultCursor.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { VecModel } from '@tldraw/tlschema' -import classNames from 'classnames' -import { memo, useRef } from 'react' -import { useSharedSafeId } from '../../hooks/useSafeId' -import { useTransform } from '../../hooks/useTransform' - -/** @public */ -export interface TLCursorProps { - userId: string - className?: string - point: VecModel | null - zoom: number - color?: string - name: string | null - chatMessage: string -} - -/** @public @react */ -export const DefaultCursor = memo(function DefaultCursor({ - className, - zoom, - point, - color, - name, - chatMessage, -}: TLCursorProps) { - const rCursor = useRef(null) - useTransform(rCursor, point?.x, point?.y, 1 / zoom) - - const cursorId = useSharedSafeId('cursor') - - if (!point) return null - - return ( -
- - {chatMessage ? ( - <> - {name && ( -
- {name} -
- )} -
- {chatMessage} -
- - ) : ( - name && ( -
- {name} -
- ) - )} -
- ) -}) diff --git a/packages/tldraw/src/lib/ui/components/MainMenu/DefaultMainMenuContent.tsx b/packages/tldraw/src/lib/ui/components/MainMenu/DefaultMainMenuContent.tsx index b931a114e8c8..b199317c284f 100644 --- a/packages/tldraw/src/lib/ui/components/MainMenu/DefaultMainMenuContent.tsx +++ b/packages/tldraw/src/lib/ui/components/MainMenu/DefaultMainMenuContent.tsx @@ -163,14 +163,12 @@ export function PreferencesGroup() { + - - - - + From 2d31e495fdfcfeb1ef4cb42a4ffac871b523164f Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Wed, 6 May 2026 11:39:14 +0100 Subject: [PATCH 04/11] docs: update README with missing starter kit and Node.js version (#8526) In order to keep the top-level README accurate against the current repo state, this PR adds the `Image pipeline` starter kit (which is already shipped via `npm create tldraw` in `packages/create-tldraw/src/templates.ts`) to the starter kits list, and notes the required Node.js version (`^20.0.0`, per the `engines` field in `package.json`) in the Local development section. ### Change type - [x] `other` ### Test plan - [ ] Unit tests - [ ] End to end tests ### Code changes | Section | LOC change | | ------------- | ---------- | | Documentation | +2 / -1 | Co-authored-by: Claude --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c821c8b594df..3a71ee96a2ab 100644 --- a/README.md +++ b/README.md @@ -69,12 +69,13 @@ npx create-tldraw@latest - **Agent** — AI agents that read, interpret, and modify canvas content - **Workflow** — drag-and-drop node builder for automation pipelines, visual programming, and no-code platforms - **Chat** — canvas-powered AI chat where users sketch, annotate, and mark up images alongside conversations +- **Image pipeline** — node-based builder for image generation pipelines - **Branching chat** — AI chat with visual branching, letting users explore and compare different conversation paths - **Shader** — WebGL shaders that respond to canvas interactions ## Local development -The development server runs the examples app at `localhost:5420`. Clone the repo, then enable [corepack](https://nodejs.org/api/corepack.html) for the correct yarn version: +The development server runs the examples app at `localhost:5420`. You'll need [Node.js](https://nodejs.org) `^20.0.0`. Clone the repo, then enable [corepack](https://nodejs.org/api/corepack.html) for the correct yarn version: ```bash npm i -g corepack From f9947dae5989ff5158892c2d24e179df7ec9ad24 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Wed, 6 May 2026 11:39:50 +0100 Subject: [PATCH 05/11] feat(tldraw): add locale prop for setting UI language (#8462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In order to let developers set the editor's UI language without workarounds, this PR adds a `locale` prop to the `` component. Closes #6321. Previously, setting the locale required configuring user preferences in `onMount` (via `useTldrawUser` or `editor.user.updateUserPreferences`), which caused a flash of the wrong language on first render. Now developers can pass it declaratively: ```tsx ``` The locale is a top-level prop rather than an option in `TldrawOptions`, keeping options focused on editor behavior configuration. The priority chain for locale resolution is: 1. User's explicit locale preference (set via `editor.user.updateUserPreferences`) — highest 2. `locale` prop on `` — new 3. `navigator.languages` default — existing behavior ### Change type - [x] `feature` ### Test plan 1. Render `` — UI should appear in French immediately with no flash of English 2. Render `` without locale — behavior unchanged, uses `navigator.languages` 3. Set `locale="fr"` then call `editor.user.updateUserPreferences({ locale: 'ja' })` — Japanese should take priority - [x] Unit tests (existing UserPreferencesManager tests pass) ### Release notes - Add `locale` prop to `` for setting the UI language declaratively via ``, eliminating the flash of wrong language on mount. ### API changes - Added `TldrawBaseProps.locale: string | undefined` for declarative locale configuration ### Code changes | Section | LOC change | | --------------- | ---------- | | Core code | +15 / -1 | | Automated files | +1 / -0 | --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../sdk-features/internationalization.mdx | 19 +++++++++++++++++++ packages/tldraw/api-report.api.md | 1 + packages/tldraw/src/lib/Tldraw.tsx | 17 ++++++++++++++++- 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/apps/docs/content/sdk-features/internationalization.mdx b/apps/docs/content/sdk-features/internationalization.mdx index b641f2e5d8f7..c18e4bcaafa6 100644 --- a/apps/docs/content/sdk-features/internationalization.mdx +++ b/apps/docs/content/sdk-features/internationalization.mdx @@ -25,6 +25,25 @@ Tldraw's UI supports 50 languages out of the box, including right-to-left langua The user's locale is stored in [user preferences](/sdk-features/user-preferences). By default, tldraw detects the browser's language and selects the closest match from supported languages. +The simplest way to set the locale is with the `locale` prop on the `Tldraw` component: + +```tsx +import { Tldraw } from 'tldraw' +import 'tldraw/tldraw.css' + +export default function App() { + return ( +
+ +
+ ) +} +``` + +The `locale` prop takes priority over the browser's language preferences but can still be overridden by the user's explicit locale preference (e.g. via `editor.user.updateUserPreferences`). + +You can also set the locale imperatively after the editor mounts: + ```tsx import { Tldraw } from 'tldraw' import 'tldraw/tldraw.css' diff --git a/packages/tldraw/api-report.api.md b/packages/tldraw/api-report.api.md index 9fa3e43aedfd..2f28c71aa7f1 100644 --- a/packages/tldraw/api-report.api.md +++ b/packages/tldraw/api-report.api.md @@ -4085,6 +4085,7 @@ export interface TldrawBaseProps extends TldrawUiProps, TldrawEditorBaseProps, T components?: TLComponents; // @deprecated embeds?: TLEmbedDefinition[]; + locale?: string; // @deprecated textOptions?: TLTextOptions; } diff --git a/packages/tldraw/src/lib/Tldraw.tsx b/packages/tldraw/src/lib/Tldraw.tsx index c3e6834ef1c0..ba70db3a7934 100644 --- a/packages/tldraw/src/lib/Tldraw.tsx +++ b/packages/tldraw/src/lib/Tldraw.tsx @@ -97,6 +97,19 @@ export interface TldrawBaseProps * @deprecated Use `options.text` instead. This prop will be removed in a future release. */ textOptions?: TLTextOptions + /** + * The locale to use for the editor's UI. When set, this takes priority over + * both the browser's language preferences (`navigator.languages`) and the + * user's locale preference (e.g. via + * `editor.user.updateUserPreferences({ locale: '...' })`), giving the + * application explicit control over the displayed language. + * + * @example + * ```tsx + * + * ``` + */ + locale?: string } /** @public */ @@ -151,6 +164,7 @@ export function Tldraw(props: TldrawProps) { // eslint-disable-next-line @typescript-eslint/no-deprecated embeds, options, + locale, // needs to be here for backwards compatibility with TldrawEditor // eslint-disable-next-line @typescript-eslint/no-deprecated textOptions: _textOptions, @@ -264,7 +278,8 @@ export function Tldraw(props: TldrawProps) { Date: Wed, 6 May 2026 12:40:02 +0200 Subject: [PATCH 06/11] docs(sync): describe SQLite as primary persistence in template docs (#8751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In order to reflect the current `sync-cloudflare` template implementation, this PR updates the template README and the sync docs to describe SQLite (via `DurableObjectSqliteSyncWrapper`) as the room-state persistence mechanism. R2 in the template is only used for uploaded assets (images, videos), not for room snapshots. Closes #8560. Note: this is about the self-hosted template — `apps/dotcom/sync-worker/` still writes R2 snapshots as a backup layer, but that's not what these docs describe. ### Change type - [x] `docs` ### Test plan 1. Read `templates/sync-cloudflare/README.md` and confirm the persistence and deployment sections match the template's actual behavior (SQLite for room state, R2 only for assets). 2. Read `apps/docs/content/docs/sync.mdx` Cloudflare-template section and confirm SQLite is described as the room-state storage. ### Release notes - Sync docs: clarify that the Cloudflare template persists room state to durable object SQLite storage; R2 is only used for assets. --- apps/docs/content/docs/sync.mdx | 4 ++-- templates/sync-cloudflare/README.md | 22 +++++++++++----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/docs/content/docs/sync.mdx b/apps/docs/content/docs/sync.mdx index 2857f54a1cda..05d279e6a4da 100644 --- a/apps/docs/content/docs/sync.mdx +++ b/apps/docs/content/docs/sync.mdx @@ -37,8 +37,8 @@ The best way to get started hosting your own backend is to clone and deploy [our It uses: -- [Durable Objects](https://developers.cloudflare.com/durable-objects/) to provide a unique WebSocket server per room. -- [R2](https://developers.cloudflare.com/r2/) to persist document snapshots and store large binary assets like images and videos. +- [Durable Objects](https://developers.cloudflare.com/durable-objects/) to provide a unique WebSocket server per room. Room state is persisted automatically to the durable object's built-in SQLite storage. +- [R2](https://developers.cloudflare.com/r2/) to store large binary assets like images and videos. There are some features that we have not provided and you might want to add yourself, such as authentication and authorization, rate limiting and size limiting for asset uploads, storing snapshots of documents over time for long-term history, and listing and searching for rooms. diff --git a/templates/sync-cloudflare/README.md b/templates/sync-cloudflare/README.md index 83b253ab53bc..c2fca79ed02b 100644 --- a/templates/sync-cloudflare/README.md +++ b/templates/sync-cloudflare/README.md @@ -7,8 +7,9 @@ This is a production-ready backend for [tldraw sync](https://tldraw.dev/docs/syn to be deployed to your own Cloudflare account. - Each whiteboard is synced via [WebSockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) to a [Cloudflare - Durable Object](https://developers.cloudflare.com/durable-objects/). -- Whiteboards and any uploaded images/videos are stored in a [Cloudflare + Durable Object](https://developers.cloudflare.com/durable-objects/), which persists room state in + its built-in SQLite storage. +- Uploaded images and videos are stored in a [Cloudflare R2](https://developers.cloudflare.com/r2/) bucket. - Although unrelated to tldraw sync, this server also includes a component to fetch link previews for URLs added to the canvas. @@ -25,14 +26,13 @@ When a user opens a room, they connect via Workers to a durable object. Each dur its own miniature server. There's only ever one for each room, and all the users of that room connect to it. When a user makes a change to the drawing, it's sent via a websocket connection to the durable object for that room. The durable object applies the change to its in-memory copy of the -document, and broadcasts the change via websockets to all other connected clients. On a regular -schedule, the durable object persists its contents to an R2 bucket. When the last client leaves the -room, the durable object will shut down. +document, and broadcasts the change via websockets to all other connected clients. Room state is +persisted automatically to the durable object's built-in SQLite storage, so it survives restarts +and hibernation. When the last client leaves the room, the durable object will shut down. Static assets like images and videos are too big to be synced via websockets and a durable object. -Instead, they're uploaded to workers which store them in the same R2 bucket as the rooms. When -they're downloaded, they're cached on cloudflare's edge network to reduce costs and make serving -them faster. +Instead, they're uploaded to workers which store them in an R2 bucket. When they're downloaded, +they're cached on cloudflare's edge network to reduce costs and make serving them faster. ## Development @@ -49,7 +49,7 @@ The backend worker is under [`worker`](./worker/), and is split across several f - **[`worker/TldrawDurableObject.ts`](./worker/TldrawDurableObject.ts):** the sync durable object. An instance of this is created for every active room. This exposes a [`TLSocketRoom`](https://tldraw.dev/reference/sync-core/TLSocketRoom) over websockets, and - periodically saves room data to R2. + persists room state to the durable object's built-in SQLite storage. - **[`worker/assetUploads.ts`](./worker/assetUploads.ts):** uploads, downloads, and caching for static assets like images and videos. - **[`worker/bookmarkUnfurling.ts`](./worker/bookmarkUnfurling.ts):** extract URL metadata for bookmark shapes. @@ -86,8 +86,8 @@ of these files to point at your new `wrangler dev` server. ## Deployment To deploy this example, you'll need to create a cloudflare account and create an R2 bucket to store -your data. Update `bucket_name = 'tldraw-content'` in [`wrangler.toml`](./wrangler.toml) with the -name of your new bucket. +uploaded images and videos. Update `bucket_name = 'tldraw-content'` in +[`wrangler.toml`](./wrangler.toml) with the name of your new bucket. To actually deploy the app, first create a production build using `yarn build`. Then, run `yarn wrangler deploy`. This will deploy the backend worker along with the frontend app to cloudflare. From 4eede505b0c944b288b8a0fd6598c0f89f76a56a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mitja=20Bezen=C5=A1ek?= Date: Wed, 6 May 2026 12:50:47 +0200 Subject: [PATCH 07/11] example: tower defense game showcasing OverlayUtil (#8762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In order to exercise the new `OverlayUtil` system end-to-end under animation load, this PR adds a tower-defense mini-game example that uses overlays for everything except the towers themselves. Towers are real geo shapes; the path, enemies, projectiles, range indicators, placement preview, hit explosions, and per-tower upgrade button are all `OverlayUtil` subclasses. Builds on #8633. The example covers most of the OverlayUtil surface: `isActive` reactivity, `getOverlays` driven by `@tldraw/state` atoms ticked from `editor.on('tick')`, `getGeometry` / `getOverlayAtPoint` hit testing, `getCursor` swaps, `onPointerDown` for placement and upgrades, custom zIndex layering, and shape↔overlay reactivity (towers as locked geo shapes feed projectile spawns and upgrade levels via `shape.meta`). ### How to play - Pick a tower from the toolbar (or press `1` / `2` / `3`), then click the canvas to place it. - Triangle = Archer (50g, fast, low damage) - Rectangle = Cannon (120g, slow, high damage) - Ellipse = Magic (80g, medium, AOE) - Hover a placed tower to see its range; click the `+` in its center to upgrade (cost scales 60% per level, max 4 levels). Upgrades change the shape's `fill` / `color` so the level is readable at a glance. - Click an enemy to chip-damage it for free. Enemies that reach the end cost a life. - `Esc` cancels placement, `Space` restarts. ### Overlays added by the example | Util | zIndex | Role | | --- | --- | --- | | `PathOverlayUtil` | 50 | Decorative enemy path | | `TowerRangeOverlayUtil` | 100 | Hover-driven range ring on placed towers | | `PlacementPreviewOverlayUtil` | 150 | Ghost shape + range circle at the cursor while a tower is armed; click-to-place via `onPointerDown` | | `EnemyOverlayUtil` | 200 | Animated enemies with HP bar; hit-test + click-to-damage | | `ExplosionOverlayUtil` | 230 | Fading ring on Magic AOE impact | | `ProjectileOverlayUtil` | 250 | Arrows / rocks / orbs in flight | | `UpgradeButtonOverlayUtil` | 260 | Per-tower upgrade `+` button; click via `onPointerDown` | ### Notes for reviewers - Placement clicks and upgrade clicks route through `OverlayUtil.onPointerDown` — no DOM intercept. Locked-shape clicks correctly fall through to overlays because `select.idle.onPointerDown` checks `getOverlayAtPoint` before the locked-shape skip. - Upgrade and restart paths wrap `editor.updateShape` / `editor.deleteShapes` in `editor.run(..., { ignoreShapeLock: true })` because the runtime silently skips locked shapes otherwise. - Per-tick shape walks (`UpgradeButtonOverlayUtil._candidates`, `TowerRangeOverlayUtil._hoveredTowers`) are memoised with `computed()` so a mousemove storm only re-runs the cheap pointer filter, not the bounds resolution pass. - Sounds are synthesized with the Web Audio API (no asset loading) — fire tones per projectile kind and a small upgrade chime. ### Change type - [x] `feature` ### Test plan 1. `yarn dev` and open `localhost:5420/use-cases/tower-defense`. 2. Place each tower type via the toolbar buttons and via `1` / `2` / `3` shortcuts. 3. Confirm the placement preview shows ghost shape + range while a tower is armed and disappears when you can't afford it. 4. Hover a placed tower → range ring; click the central `+` → tower's color/fill changes per level and the chime plays. 5. Magic projectile impact → fading explosion ring + AOE damage to nearby enemies. 6. Click an enemy → chip damage; hover an enemy → HP readout above its HP bar. 7. Let enemies through → lives drop, game-over state appears, restart via button or `Space` clears all shapes and resets gold/lives/score/wave timer. 8. Toggle dark/light mode → overlay strokes and fills update. - [x] Unit tests - [x] End to end tests ### Release notes - Add a tower-defense example under `examples/use-cases/tower-defense` showcasing the new `OverlayUtil` system. --- .../use-cases/tower-defense/README.md | 16 + .../tower-defense/TowerDefenseExample.tsx | 291 ++++++++++++++++++ .../use-cases/tower-defense/enemy-config.ts | 59 ++++ .../use-cases/tower-defense/game-loop.ts | 278 +++++++++++++++++ .../use-cases/tower-defense/game-state.ts | 107 +++++++ .../overlays/EnemyOverlayUtil.ts | 143 +++++++++ .../overlays/ExplosionOverlayUtil.ts | 62 ++++ .../tower-defense/overlays/PathOverlayUtil.ts | 47 +++ .../overlays/PlacementPreviewOverlayUtil.ts | 116 +++++++ .../overlays/ProjectileOverlayUtil.ts | 82 +++++ .../overlays/TowerRangeOverlayUtil.ts | 79 +++++ .../overlays/UpgradeButtonOverlayUtil.ts | 192 ++++++++++++ .../examples/use-cases/tower-defense/path.ts | 63 ++++ .../use-cases/tower-defense/sounds.ts | 92 ++++++ .../use-cases/tower-defense/tower-config.ts | 92 ++++++ .../use-cases/tower-defense/tower-defense.css | 131 ++++++++ 16 files changed, 1850 insertions(+) create mode 100644 apps/examples/src/examples/use-cases/tower-defense/README.md create mode 100644 apps/examples/src/examples/use-cases/tower-defense/TowerDefenseExample.tsx create mode 100644 apps/examples/src/examples/use-cases/tower-defense/enemy-config.ts create mode 100644 apps/examples/src/examples/use-cases/tower-defense/game-loop.ts create mode 100644 apps/examples/src/examples/use-cases/tower-defense/game-state.ts create mode 100644 apps/examples/src/examples/use-cases/tower-defense/overlays/EnemyOverlayUtil.ts create mode 100644 apps/examples/src/examples/use-cases/tower-defense/overlays/ExplosionOverlayUtil.ts create mode 100644 apps/examples/src/examples/use-cases/tower-defense/overlays/PathOverlayUtil.ts create mode 100644 apps/examples/src/examples/use-cases/tower-defense/overlays/PlacementPreviewOverlayUtil.ts create mode 100644 apps/examples/src/examples/use-cases/tower-defense/overlays/ProjectileOverlayUtil.ts create mode 100644 apps/examples/src/examples/use-cases/tower-defense/overlays/TowerRangeOverlayUtil.ts create mode 100644 apps/examples/src/examples/use-cases/tower-defense/overlays/UpgradeButtonOverlayUtil.ts create mode 100644 apps/examples/src/examples/use-cases/tower-defense/path.ts create mode 100644 apps/examples/src/examples/use-cases/tower-defense/sounds.ts create mode 100644 apps/examples/src/examples/use-cases/tower-defense/tower-config.ts create mode 100644 apps/examples/src/examples/use-cases/tower-defense/tower-defense.css diff --git a/apps/examples/src/examples/use-cases/tower-defense/README.md b/apps/examples/src/examples/use-cases/tower-defense/README.md new file mode 100644 index 000000000000..4d6cefa06d95 --- /dev/null +++ b/apps/examples/src/examples/use-cases/tower-defense/README.md @@ -0,0 +1,16 @@ +--- +title: Tower defense +component: ./TowerDefenseExample.tsx +priority: 5 +keywords: [overlay, overlayutil, canvas, animation, game, hit testing, raf, tick] +--- + +A tiny tower defense game built on the OverlayUtil system. + +--- + +Draw triangle, rectangle, or ellipse geo shapes onto the canvas to place towers — each geo type maps to a different tower with its own range, fire rate, damage, and projectile. Enemies follow a fixed path; click an enemy to deal damage manually. + +The game's path, enemies, projectiles, and tower range indicator all render through `OverlayUtil` subclasses. The game state lives in `@tldraw/state` atoms read inside `getOverlays()`, so when the game loop ticks the atoms, every overlay redraws reactively. Hit-testing for clicks on enemies uses `getGeometry()` and the built-in `editor.overlays.getOverlayAtPoint` path; `getCursor()` swaps to a crosshair on hover and `onPointerDown()` applies damage. + +The game loop is driven by the editor's `tick` event, which fires once per frame with the elapsed delta in milliseconds. Towers are real geo shapes that the side-effects layer locks on creation so they can't be moved during play. diff --git a/apps/examples/src/examples/use-cases/tower-defense/TowerDefenseExample.tsx b/apps/examples/src/examples/use-cases/tower-defense/TowerDefenseExample.tsx new file mode 100644 index 000000000000..c069dcc67ee3 --- /dev/null +++ b/apps/examples/src/examples/use-cases/tower-defense/TowerDefenseExample.tsx @@ -0,0 +1,291 @@ +import { useCallback, useEffect } from 'react' +import { + Box, + Editor, + TLAnyOverlayUtilConstructor, + TLUiComponents, + Tldraw, + defaultOverlayUtils, + useEditor, + useValue, +} from 'tldraw' +import 'tldraw/tldraw.css' +import { resetSpawnTimer, runGameTick } from './game-loop' +import { + enemies$, + gameOver$, + gold$, + lives$, + placingTower$, + projectiles$, + resetGameState, + score$, +} from './game-state' +import { EnemyOverlayUtil } from './overlays/EnemyOverlayUtil' +import { ExplosionOverlayUtil } from './overlays/ExplosionOverlayUtil' +import { PathOverlayUtil } from './overlays/PathOverlayUtil' +import { PlacementPreviewOverlayUtil } from './overlays/PlacementPreviewOverlayUtil' +import { ProjectileOverlayUtil } from './overlays/ProjectileOverlayUtil' +import { TowerRangeOverlayUtil } from './overlays/TowerRangeOverlayUtil' +import { UpgradeButtonOverlayUtil } from './overlays/UpgradeButtonOverlayUtil' +import { TOWER_GEOS, TOWER_STATS_BY_GEO, TowerGeo } from './tower-config' +import './tower-defense.css' + +const overlayUtils: TLAnyOverlayUtilConstructor[] = [ + ...defaultOverlayUtils, + PathOverlayUtil, + TowerRangeOverlayUtil, + PlacementPreviewOverlayUtil, + EnemyOverlayUtil, + ProjectileOverlayUtil, + ExplosionOverlayUtil, + UpgradeButtonOverlayUtil, +] + +function pickTower(geo: TowerGeo) { + const cost = TOWER_STATS_BY_GEO[geo].cost + // Refuse to arm a tower the player can't afford so the preview / placement + // flow only kicks in when a click would actually result in a tower. + if (gold$.get() < cost) return + placingTower$.set(geo) +} + +function pickSelect(editor: Editor) { + placingTower$.set(null) + editor.setCurrentTool('select') +} + +function restartGame(editor: Editor) { + resetGameState() + resetSpawnTimer() + placingTower$.set(null) + // Just nuke everything on the page — towers are the only thing the user + // can produce here, and starting fresh shouldn't leave any stragglers. + const allShapeIds = editor.getCurrentPageShapes().map((s) => s.id) + if (allShapeIds.length === 0) return + editor.run(() => editor.deleteShapes(allShapeIds), { ignoreShapeLock: true }) +} + +function GameRunner() { + const editor = useEditor() + + useEffect(() => { + const onTick = (elapsedMs: number) => { + // Clamp on tab-resume; tldraw emits a single big elapsed when the tab + // has been idle, which would otherwise teleport enemies. + const dt = Math.min(60, elapsedMs) + runGameTick(editor, dt) + } + editor.on('tick', onTick) + + // Keyboard: 1/2/3 arm a tower, Esc selects, Space restarts. + const onKeyDown = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey) return + const target = e.target as HTMLElement | null + if (target?.matches('input, textarea, [contenteditable="true"]')) return + if (e.key === '1') pickTower('triangle') + else if (e.key === '2') pickTower('rectangle') + else if (e.key === '3') pickTower('ellipse') + else if (e.key === 'Escape') pickSelect(editor) + else if (e.key === ' ') { + e.preventDefault() + restartGame(editor) + } + } + window.addEventListener('keydown', onKeyDown) + + return () => { + editor.off('tick', onTick) + window.removeEventListener('keydown', onKeyDown) + placingTower$.set(null) + } + }, [editor]) + + return null +} + +function GameToolbar() { + const editor = useEditor() + const placingGeo = useValue('placingGeo', () => placingTower$.get(), []) + const gold = useValue('gold', () => gold$.get(), []) + + const onPickSelect = useCallback(() => pickSelect(editor), [editor]) + + return ( +
+ + {TOWER_GEOS.map((geo, i) => { + const stats = TOWER_STATS_BY_GEO[geo] + const isActive = placingGeo === geo + const canAfford = gold >= stats.cost + const cls = + 'td-toolbar__btn' + (isActive ? ' is-active' : '') + (canAfford ? '' : ' is-disabled') + return ( + + ) + })} +
+ ) +} + +function SelectGlyph() { + return ( + + + + ) +} + +function TowerGlyph({ geo }: { geo: TowerGeo }) { + const stroke = 'currentColor' + if (geo === 'triangle') { + return ( + + + + ) + } + if (geo === 'rectangle') { + return ( + + + + ) + } + return ( + + + + ) +} + +function HUD() { + const editor = useEditor() + const score = useValue('score', () => score$.get(), []) + const gold = useValue('gold', () => gold$.get(), []) + const lives = useValue('lives', () => lives$.get(), []) + const enemyCount = useValue('enemies', () => enemies$.get().length, []) + const projectileCount = useValue('projectiles', () => projectiles$.get().length, []) + const gameOver = useValue('gameOver', () => gameOver$.get(), []) + + const onRestart = useCallback(() => restartGame(editor), [editor]) + + return ( +
+
+ + Gold {gold} + + + Score {score} + + + Lives {lives} + + + Enemies {enemyCount} + + + Shots {projectileCount} + + +
+
+ Pick a tower from the toolbar (or press 1/2/3), then click + the canvas to place it. Each tower costs gold; kill enemies to earn more. Magic blasts deal + area damage. Click an enemy to chip damage for free. Press space to restart. +
+ {gameOver &&
Game over — press Restart
} +
+ ) +} + +const components: Required = { + TopPanel: HUD, + Toolbar: GameToolbar, + ContextMenu: null, + ActionsMenu: null, + HelpMenu: null, + ZoomMenu: null, + MainMenu: null, + Minimap: null, + StylePanel: null, + PageMenu: null, + NavigationPanel: null, + KeyboardShortcutsDialog: null, + QuickActions: null, + HelperButtons: null, + DebugPanel: null, + DebugMenu: null, + SharePanel: null, + MenuPanel: null, + CursorChatBubble: null, + RichTextToolbar: null, + ImageToolbar: null, + VideoToolbar: null, + Dialogs: null, + Toasts: null, + A11y: null, + FollowingIndicator: null, + PeopleMenu: null, + PeopleMenuAvatar: null, + PeopleMenuItem: null, + PeopleMenuFacePile: null, + UserPresenceEditor: null, +} + +function onEditorMount(editor: Editor) { + resetGameState() + resetSpawnTimer() + editor.zoomToBounds(new Box(-300, 0, 1700, 700), { immediate: true }) +} + +// Disable the built-in double-click-to-create-text behavior — there's no text +// shape in this example and a stray double-click while clearing enemies would +// otherwise drop a text shape on the canvas. +const tldrawOptions = { createTextOnCanvasDoubleClick: false } + +export default function TowerDefenseExample() { + return ( +
+ + + +
+ ) +} diff --git a/apps/examples/src/examples/use-cases/tower-defense/enemy-config.ts b/apps/examples/src/examples/use-cases/tower-defense/enemy-config.ts new file mode 100644 index 000000000000..e0c84824dbb4 --- /dev/null +++ b/apps/examples/src/examples/use-cases/tower-defense/enemy-config.ts @@ -0,0 +1,59 @@ +// Enemy archetypes. Each carries its own multipliers and visual identity so +// later waves can mix tougher/faster targets and the overlay layer has more +// to render than uniform circles. + +export type EnemyType = 'grunt' | 'runner' | 'brute' + +export interface EnemyConfig { + label: string + hpMultiplier: number + speedMultiplier: number + radius: number + bountyMultiplier: number + bodyColor: string + ringColor: string +} + +export const ENEMY_CONFIG: Record = { + grunt: { + label: 'Grunt', + hpMultiplier: 1, + speedMultiplier: 1, + radius: 18, + bountyMultiplier: 1, + bodyColor: '#c84', + ringColor: '#000', + }, + runner: { + label: 'Runner', + hpMultiplier: 0.55, + speedMultiplier: 1.9, + radius: 13, + bountyMultiplier: 1.4, + bodyColor: '#3aa', + ringColor: '#055', + }, + brute: { + label: 'Brute', + hpMultiplier: 3.2, + speedMultiplier: 0.62, + radius: 26, + bountyMultiplier: 2.6, + bodyColor: '#735', + ringColor: '#220', + }, +} + +// Weighted spawn picker: the longer the game runs, the more often runners and +// brutes appear. Returns an enemy type. +export function pickEnemyType(elapsedMs: number): EnemyType { + const t = Math.min(1, elapsedMs / 60_000) + const wGrunt = Math.max(0.2, 1 - t) + const wRunner = 0.3 + 0.4 * t + const wBrute = 0.05 + 0.5 * t + const total = wGrunt + wRunner + wBrute + const r = Math.random() * total + if (r < wGrunt) return 'grunt' + if (r < wGrunt + wRunner) return 'runner' + return 'brute' +} diff --git a/apps/examples/src/examples/use-cases/tower-defense/game-loop.ts b/apps/examples/src/examples/use-cases/tower-defense/game-loop.ts new file mode 100644 index 000000000000..fe78c6de7082 --- /dev/null +++ b/apps/examples/src/examples/use-cases/tower-defense/game-loop.ts @@ -0,0 +1,278 @@ +import { Editor, TLGeoShape } from 'tldraw' +import { ENEMY_CONFIG, pickEnemyType } from './enemy-config' +import { + Enemy, + Explosion, + Projectile, + elapsedMs$, + enemies$, + explosions$, + gainBounty, + gameOver$, + lives$, + nextEnemyId, + nextExplosionId, + nextProjectileId, + projectiles$, + towerCooldowns, +} from './game-state' +import { PATH_LENGTH, getPositionAtDistance } from './path' +import { playFireSound } from './sounds' +import { getScaledStats, getTowerLevel, getTowerStats } from './tower-config' + +const SPAWN_BASE_INTERVAL_MS = 1400 +const SPAWN_RAMP_RATE = 0.00004 // shaves spawn interval over time +const SPAWN_INTERVAL_FLOOR_MS = 350 +const ENEMY_BASE_HP = 50 +const ENEMY_HP_RAMP = 0.025 // per second of game time +const ENEMY_BASE_SPEED = 65 // page units / sec +const ENEMY_BASE_BOUNTY = 5 +const PROJECTILE_MAX_AGE_MS = 4000 +const MAGIC_AOE_RADIUS = 60 +// Magic projectiles slow enemies they hit for this long, scaling speed by +// MAGIC_SLOW_FACTOR until the timer expires. +const MAGIC_SLOW_DURATION_MS = 1500 +const MAGIC_SLOW_FACTOR = 0.4 + +let lastSpawnAt = 0 + +export function resetSpawnTimer() { + lastSpawnAt = 0 +} + +export function runGameTick(editor: Editor, dtMs: number) { + if (gameOver$.get()) return + const now = elapsedMs$.get() + dtMs + elapsedMs$.set(now) + const dt = dtMs / 1000 + + maybeSpawn(now) + moveEnemies(dt, now) + fireTowers(editor, now) + moveProjectiles(dt, dtMs) + resolveHits() + tickExplosions(dtMs) + checkGameOver() +} + +const EXPLOSION_DURATION_MS = 320 + +function spawnExplosion(x: number, y: number, radius: number) { + const exp: Explosion = { + id: nextExplosionId(), + x, + y, + radius, + ageMs: 0, + maxAgeMs: EXPLOSION_DURATION_MS, + } + explosions$.update((list) => [...list, exp]) +} + +function tickExplosions(dtMs: number) { + const list = explosions$.get() + if (list.length === 0) return + const next: Explosion[] = [] + for (const e of list) { + const ageMs = e.ageMs + dtMs + if (ageMs >= e.maxAgeMs) continue + next.push({ ...e, ageMs }) + } + explosions$.set(next) +} + +function maybeSpawn(now: number) { + const interval = Math.max( + SPAWN_INTERVAL_FLOOR_MS, + SPAWN_BASE_INTERVAL_MS - now * SPAWN_RAMP_RATE * SPAWN_BASE_INTERVAL_MS + ) + if (now - lastSpawnAt < interval) return + lastSpawnAt = now + const type = pickEnemyType(now) + const cfg = ENEMY_CONFIG[type] + const hpScale = 1 + (now / 1000) * ENEMY_HP_RAMP + const maxHp = Math.round(ENEMY_BASE_HP * hpScale * cfg.hpMultiplier) + const bounty = Math.round((ENEMY_BASE_BOUNTY + maxHp / 10) * cfg.bountyMultiplier) + const enemy: Enemy = { + id: nextEnemyId(), + type, + distance: 0, + hp: maxHp, + maxHp, + speed: (ENEMY_BASE_SPEED + Math.random() * 25) * cfg.speedMultiplier, + radius: cfg.radius, + bounty, + slowedUntilMs: 0, + } + enemies$.update((list) => [...list, enemy]) +} + +function moveEnemies(dt: number, now: number) { + const enemies = enemies$.get() + if (enemies.length === 0) return + const next: Enemy[] = [] + let leaks = 0 + for (const e of enemies) { + const isSlowed = e.slowedUntilMs > now + const speed = isSlowed ? e.speed * MAGIC_SLOW_FACTOR : e.speed + const distance = e.distance + speed * dt + if (distance >= PATH_LENGTH) { + leaks++ + continue + } + next.push({ ...e, distance }) + } + if (leaks > 0) lives$.update((l) => Math.max(0, l - leaks)) + enemies$.set(next) +} + +function fireTowers(editor: Editor, now: number) { + const enemies = enemies$.get() + if (enemies.length === 0) return + const shapes = editor.getCurrentPageShapes() + for (const shape of shapes) { + if (shape.type !== 'geo') continue + const baseStats = getTowerStats((shape as TLGeoShape).props.geo) + if (!baseStats) continue + const stats = getScaledStats(baseStats, getTowerLevel(shape)) + const cooldown = towerCooldowns.get(shape.id) + if (cooldown && now - cooldown.lastFiredAt < stats.fireRateMs) continue + const bounds = editor.getShapePageBounds(shape.id) + if (!bounds) continue + const cx = bounds.center.x + const cy = bounds.center.y + // Pick the enemy closest to the end of the path within range. + let target: Enemy | null = null + let targetPos = { x: 0, y: 0 } + let bestProgress = -1 + for (const enemy of enemies) { + const pos = getPositionAtDistance(enemy.distance) + const dx = pos.x - cx + const dy = pos.y - cy + if (dx * dx + dy * dy > stats.range * stats.range) continue + if (enemy.distance > bestProgress) { + bestProgress = enemy.distance + target = enemy + targetPos = pos + } + } + if (!target) continue + const dx = targetPos.x - cx + const dy = targetPos.y - cy + const dist = Math.hypot(dx, dy) || 1 + const projectile: Projectile = { + id: nextProjectileId(), + x: cx, + y: cy, + vx: (dx / dist) * stats.projectileSpeed, + vy: (dy / dist) * stats.projectileSpeed, + damage: stats.damage, + kind: stats.projectileKind, + targetEnemyId: target.id, + ageMs: 0, + } + projectiles$.update((list) => [...list, projectile]) + towerCooldowns.set(shape.id, { lastFiredAt: now }) + playFireSound(stats.projectileKind) + } +} + +function moveProjectiles(dt: number, dtMs: number) { + const list = projectiles$.get() + if (list.length === 0) return + const next: Projectile[] = [] + for (const p of list) { + const ageMs = p.ageMs + dtMs + if (ageMs > PROJECTILE_MAX_AGE_MS) continue + next.push({ ...p, x: p.x + p.vx * dt, y: p.y + p.vy * dt, ageMs }) + } + projectiles$.set(next) +} + +function resolveHits() { + const projectiles = projectiles$.get() + if (projectiles.length === 0) return + const enemies = enemies$.get() + if (enemies.length === 0) { + // Projectiles whose targets vanished can keep flying until expiry. + return + } + const now = elapsedMs$.get() + const enemyById = new Map() + for (const e of enemies) enemyById.set(e.id, e) + + const survivingProjectiles: Projectile[] = [] + const damageByEnemyId = new Map() + const slowedEnemyIds = new Set() + for (const p of projectiles) { + // Use current position of the original target if still alive; otherwise + // look for any nearby enemy to make impacts feel responsive. + const target = enemyById.get(p.targetEnemyId) + let hit: Enemy | null = null + if (target) { + const pos = getPositionAtDistance(target.distance) + if (Math.hypot(p.x - pos.x, p.y - pos.y) <= target.radius) hit = target + } + if (!hit) { + for (const e of enemies) { + const pos = getPositionAtDistance(e.distance) + if (Math.hypot(p.x - pos.x, p.y - pos.y) <= e.radius) { + hit = e + break + } + } + } + if (hit) { + if (p.kind === 'orb') { + // Magic projectiles explode on impact: damage and slow every + // enemy in the AOE, plus a transient ring overlay. + spawnExplosion(p.x, p.y, MAGIC_AOE_RADIUS) + for (const e of enemies) { + const pos = getPositionAtDistance(e.distance) + if (Math.hypot(p.x - pos.x, p.y - pos.y) <= MAGIC_AOE_RADIUS + e.radius) { + damageByEnemyId.set(e.id, (damageByEnemyId.get(e.id) ?? 0) + p.damage) + slowedEnemyIds.add(e.id) + } + } + } else { + damageByEnemyId.set(hit.id, (damageByEnemyId.get(hit.id) ?? 0) + p.damage) + } + } else { + survivingProjectiles.push(p) + } + } + if (survivingProjectiles.length !== projectiles.length) { + projectiles$.set(survivingProjectiles) + } + if (damageByEnemyId.size === 0 && slowedEnemyIds.size === 0) return + + let bountyGained = 0 + const slowExpiry = now + MAGIC_SLOW_DURATION_MS + const nextEnemies: Enemy[] = [] + for (const e of enemies) { + const dmg = damageByEnemyId.get(e.id) ?? 0 + const slowed = slowedEnemyIds.has(e.id) + if (dmg === 0 && !slowed) { + nextEnemies.push(e) + continue + } + const hp = e.hp - dmg + if (hp <= 0) { + bountyGained += e.bounty + continue + } + nextEnemies.push({ + ...e, + hp, + // Slows refresh — multiple magic hits keep the effect alive rather + // than stacking duration. + slowedUntilMs: slowed ? Math.max(e.slowedUntilMs, slowExpiry) : e.slowedUntilMs, + }) + } + enemies$.set(nextEnemies) + if (bountyGained > 0) gainBounty(bountyGained) +} + +function checkGameOver() { + if (lives$.get() <= 0) gameOver$.set(true) +} diff --git a/apps/examples/src/examples/use-cases/tower-defense/game-state.ts b/apps/examples/src/examples/use-cases/tower-defense/game-state.ts new file mode 100644 index 000000000000..a9b97e97b49f --- /dev/null +++ b/apps/examples/src/examples/use-cases/tower-defense/game-state.ts @@ -0,0 +1,107 @@ +import { atom } from 'tldraw' +import { EnemyType } from './enemy-config' +import { ProjectileKind, TowerGeo } from './tower-config' + +export interface Enemy { + id: number + type: EnemyType + distance: number + hp: number + maxHp: number + speed: number + radius: number + bounty: number + // Absolute elapsedMs at which any active slow ends. 0 = not slowed. + slowedUntilMs: number +} + +export interface Projectile { + id: number + x: number + y: number + vx: number + vy: number + damage: number + kind: ProjectileKind + targetEnemyId: number + ageMs: number +} + +export interface TowerCooldown { + lastFiredAt: number +} + +export interface Explosion { + id: number + x: number + y: number + radius: number + ageMs: number + maxAgeMs: number +} + +export const STARTING_GOLD = 100 +export const STARTING_LIVES = 20 + +export const enemies$ = atom('enemies', []) +export const projectiles$ = atom('projectiles', []) +export const explosions$ = atom('explosions', []) +// Currently armed tower placement; non-null while the player has a tower +// "in hand" from the toolbar / keyboard. +export const placingTower$ = atom('placingTower', null) +export const score$ = atom('score', 0) +export const gold$ = atom('gold', STARTING_GOLD) +export const lives$ = atom('lives', STARTING_LIVES) +export const gameOver$ = atom('gameOver', false) +export const elapsedMs$ = atom('elapsedMs', 0) + +// Tower cooldowns are keyed by shape id. Not an atom — only the game loop reads +// and writes them, and they don't need to drive overlay reactivity. +export const towerCooldowns = new Map() + +let _nextEnemyId = 1 +export const nextEnemyId = () => _nextEnemyId++ +let _nextProjId = 1 +export const nextProjectileId = () => _nextProjId++ +let _nextExplosionId = 1 +export const nextExplosionId = () => _nextExplosionId++ + +export function resetGameState() { + enemies$.set([]) + projectiles$.set([]) + explosions$.set([]) + score$.set(0) + gold$.set(STARTING_GOLD) + lives$.set(STARTING_LIVES) + gameOver$.set(false) + elapsedMs$.set(0) + towerCooldowns.clear() + _nextEnemyId = 1 + _nextProjId = 1 + _nextExplosionId = 1 +} + +export function gainBounty(amount: number) { + score$.update((s) => s + amount) + gold$.update((g) => g + amount) +} + +export function applyDamage(enemyId: number, amount: number) { + const enemies = enemies$.get() + const next: Enemy[] = [] + let killed: Enemy | null = null + for (const e of enemies) { + if (e.id !== enemyId) { + next.push(e) + continue + } + const hp = e.hp - amount + if (hp <= 0) { + killed = e + } else { + next.push({ ...e, hp }) + } + } + if (killed) gainBounty(killed.bounty) + enemies$.set(next) +} diff --git a/apps/examples/src/examples/use-cases/tower-defense/overlays/EnemyOverlayUtil.ts b/apps/examples/src/examples/use-cases/tower-defense/overlays/EnemyOverlayUtil.ts new file mode 100644 index 000000000000..1a9dec2f6444 --- /dev/null +++ b/apps/examples/src/examples/use-cases/tower-defense/overlays/EnemyOverlayUtil.ts @@ -0,0 +1,143 @@ +import { Circle2d, Geometry2d, OverlayUtil, TLCursorType, TLOverlay } from 'tldraw' +import { ENEMY_CONFIG, EnemyType } from '../enemy-config' +import { applyDamage, elapsedMs$, enemies$ } from '../game-state' +import { getPositionAtDistance } from '../path' + +interface TLEnemyOverlay extends TLOverlay { + props: { + enemyId: number + type: EnemyType + x: number + y: number + hp: number + maxHp: number + radius: number + slowed: boolean + } +} + +const CLICK_DAMAGE = 5 + +export class EnemyOverlayUtil extends OverlayUtil { + static override type = 'td-enemy' + override options = { zIndex: 200 } + + override isActive(): boolean { + return enemies$.get().length > 0 + } + + override getOverlays(): TLEnemyOverlay[] { + const now = elapsedMs$.get() + return enemies$.get().map((e) => { + const pos = getPositionAtDistance(e.distance) + return { + id: `td-enemy:${e.id}`, + type: 'td-enemy', + props: { + enemyId: e.id, + type: e.type, + x: pos.x, + y: pos.y, + hp: e.hp, + maxHp: e.maxHp, + radius: e.radius, + slowed: e.slowedUntilMs > now, + }, + } + }) + } + + override getGeometry(overlay: TLEnemyOverlay): Geometry2d { + const { x, y, radius } = overlay.props + // Circle2d's x/y is the bounding-box top-left, so center = (x+radius, y+radius). + return new Circle2d({ x: x - radius, y: y - radius, radius, isFilled: true }) + } + + override getCursor(): TLCursorType { + return 'cross' + } + + override onPointerDown(overlay: TLEnemyOverlay): boolean { + applyDamage(overlay.props.enemyId, CLICK_DAMAGE) + // Returning truthy keeps the editor from starting a brush/select drag on + // what is effectively a game click. + return true + } + + override render(ctx: CanvasRenderingContext2D, overlays: TLEnemyOverlay[]): void { + const zoom = this.editor.getZoomLevel() + const isDark = this.editor.getColorMode() === 'dark' + const hoveredId = this.editor.overlays.getHoveredOverlayId() + for (const overlay of overlays) { + const { x, y, hp, maxHp, radius, type, slowed } = overlay.props + const cfg = ENEMY_CONFIG[type] + const t = Math.max(0, Math.min(1, hp / maxHp)) + + ctx.save() + ctx.beginPath() + ctx.arc(x, y, radius, 0, Math.PI * 2) + ctx.fillStyle = cfg.bodyColor + ctx.fill() + ctx.lineWidth = 2 / zoom + ctx.strokeStyle = cfg.ringColor + ctx.stroke() + // Inner ring shrinks/dims with HP so wounded enemies read at a glance. + ctx.beginPath() + ctx.arc(x, y, radius * 0.55, 0, Math.PI * 2) + ctx.fillStyle = `rgba(0, 0, 0, ${0.15 + 0.45 * (1 - t)})` + ctx.fill() + + // Frosty halo when slowed by Magic. + if (slowed) { + ctx.beginPath() + ctx.arc(x, y, radius + 4 / zoom, 0, Math.PI * 2) + ctx.fillStyle = 'rgba(140, 200, 255, 0.35)' + ctx.fill() + ctx.lineWidth = 1.5 / zoom + ctx.strokeStyle = 'rgba(180, 220, 255, 0.9)' + ctx.setLineDash([4 / zoom, 3 / zoom]) + ctx.stroke() + ctx.setLineDash([]) + } + + // HP bar above the enemy. + const barW = radius * 2 + const barH = 5 / zoom + const barX = x - radius + const barY = y - radius - barH - 4 / zoom + ctx.fillStyle = isDark ? 'rgba(60,60,80,0.85)' : 'rgba(220,220,230,0.9)' + ctx.fillRect(barX, barY, barW, barH) + ctx.fillStyle = '#3bce5a' + ctx.fillRect(barX, barY, barW * t, barH) + ctx.lineWidth = 1 / zoom + ctx.strokeStyle = isDark ? '#000' : '#444' + ctx.strokeRect(barX, barY, barW, barH) + + // HP readout above the bar when this enemy is hovered. + if (overlay.id === hoveredId) { + const text = `${cfg.label} · ${Math.max(0, Math.ceil(hp))} / ${maxHp}` + const fontPx = 12 / zoom + ctx.font = `600 ${fontPx}px sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'bottom' + const textY = barY - 4 / zoom + const metrics = ctx.measureText(text) + const padX = 6 / zoom + const padY = 3 / zoom + const boxW = metrics.width + padX * 2 + const boxH = fontPx + padY * 2 + const boxX = x - boxW / 2 + const boxY = textY - boxH + padY + ctx.fillStyle = isDark ? 'rgba(20,20,28,0.92)' : 'rgba(255,255,255,0.95)' + ctx.fillRect(boxX, boxY, boxW, boxH) + ctx.lineWidth = 1 / zoom + ctx.strokeStyle = isDark ? '#fff' : '#000' + ctx.strokeRect(boxX, boxY, boxW, boxH) + ctx.fillStyle = isDark ? '#fff' : '#000' + ctx.fillText(text, x, textY) + } + + ctx.restore() + } + } +} diff --git a/apps/examples/src/examples/use-cases/tower-defense/overlays/ExplosionOverlayUtil.ts b/apps/examples/src/examples/use-cases/tower-defense/overlays/ExplosionOverlayUtil.ts new file mode 100644 index 000000000000..28625f868d57 --- /dev/null +++ b/apps/examples/src/examples/use-cases/tower-defense/overlays/ExplosionOverlayUtil.ts @@ -0,0 +1,62 @@ +import { OverlayUtil, TLOverlay } from 'tldraw' +import { explosions$ } from '../game-state' + +interface TLExplosionOverlay extends TLOverlay { + props: { + x: number + y: number + radius: number + t: number // 0 = just spawned, 1 = about to expire + } +} + +export class ExplosionOverlayUtil extends OverlayUtil { + static override type = 'td-explosion' + override options = { zIndex: 230 } + + override isActive(): boolean { + return explosions$.get().length > 0 + } + + override getOverlays(): TLExplosionOverlay[] { + return explosions$.get().map((e) => ({ + id: `td-explosion:${e.id}`, + type: 'td-explosion', + props: { + x: e.x, + y: e.y, + radius: e.radius, + t: Math.min(1, e.ageMs / e.maxAgeMs), + }, + })) + } + + override render(ctx: CanvasRenderingContext2D, overlays: TLExplosionOverlay[]): void { + const zoom = this.editor.getZoomLevel() + ctx.save() + for (const overlay of overlays) { + const { x, y, radius, t } = overlay.props + // Expanding ring + fading filled core. + const ringR = radius * (0.4 + 0.6 * t) + const alpha = 1 - t + + ctx.globalAlpha = alpha * 0.45 + const grd = ctx.createRadialGradient(x, y, 1, x, y, radius) + grd.addColorStop(0, 'rgba(180, 220, 255, 1)') + grd.addColorStop(0.6, 'rgba(120, 160, 240, 0.5)') + grd.addColorStop(1, 'rgba(80, 120, 220, 0)') + ctx.fillStyle = grd + ctx.beginPath() + ctx.arc(x, y, radius, 0, Math.PI * 2) + ctx.fill() + + ctx.globalAlpha = alpha + ctx.lineWidth = 3 / zoom + ctx.strokeStyle = 'rgba(180, 220, 255, 1)' + ctx.beginPath() + ctx.arc(x, y, ringR, 0, Math.PI * 2) + ctx.stroke() + } + ctx.restore() + } +} diff --git a/apps/examples/src/examples/use-cases/tower-defense/overlays/PathOverlayUtil.ts b/apps/examples/src/examples/use-cases/tower-defense/overlays/PathOverlayUtil.ts new file mode 100644 index 000000000000..0e5c401195c9 --- /dev/null +++ b/apps/examples/src/examples/use-cases/tower-defense/overlays/PathOverlayUtil.ts @@ -0,0 +1,47 @@ +import { OverlayUtil, TLOverlay } from 'tldraw' +import { PATH } from '../path' + +interface TLPathOverlay extends TLOverlay { + props: Record +} + +export class PathOverlayUtil extends OverlayUtil { + static override type = 'td-path' + override options = { zIndex: 50 } + + override isActive(): boolean { + return true + } + + override getOverlays(): TLPathOverlay[] { + return [{ id: 'td-path:main', type: 'td-path', props: {} }] + } + + override render(ctx: CanvasRenderingContext2D): void { + const zoom = this.editor.getZoomLevel() + const isDark = this.editor.getColorMode() === 'dark' + + ctx.save() + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + + // Wide soft track + ctx.lineWidth = 38 + ctx.strokeStyle = isDark ? 'rgba(80, 80, 110, 0.45)' : 'rgba(160, 170, 200, 0.45)' + ctx.beginPath() + ctx.moveTo(PATH[0].x, PATH[0].y) + for (let i = 1; i < PATH.length; i++) ctx.lineTo(PATH[i].x, PATH[i].y) + ctx.stroke() + + // Center dashed line + ctx.lineWidth = 2 / zoom + ctx.setLineDash([10 / zoom, 10 / zoom]) + ctx.strokeStyle = isDark ? 'rgba(220, 220, 240, 0.7)' : 'rgba(60, 60, 80, 0.7)' + ctx.beginPath() + ctx.moveTo(PATH[0].x, PATH[0].y) + for (let i = 1; i < PATH.length; i++) ctx.lineTo(PATH[i].x, PATH[i].y) + ctx.stroke() + + ctx.restore() + } +} diff --git a/apps/examples/src/examples/use-cases/tower-defense/overlays/PlacementPreviewOverlayUtil.ts b/apps/examples/src/examples/use-cases/tower-defense/overlays/PlacementPreviewOverlayUtil.ts new file mode 100644 index 000000000000..fabdf9c6496f --- /dev/null +++ b/apps/examples/src/examples/use-cases/tower-defense/overlays/PlacementPreviewOverlayUtil.ts @@ -0,0 +1,116 @@ +import { Circle2d, Geometry2d, OverlayUtil, TLCursorType, TLOverlay, createShapeId } from 'tldraw' +import { gold$, placingTower$ } from '../game-state' +import { TOWER_PLACEMENT_SIZE, TOWER_STATS_BY_GEO, TowerGeo, getTowerStats } from '../tower-config' + +interface TLPlacementPreviewOverlay extends TLOverlay { + props: { + geo: TowerGeo + x: number + y: number + range: number + } +} + +export class PlacementPreviewOverlayUtil extends OverlayUtil { + static override type = 'td-placement-preview' + override options = { zIndex: 150 } + + override isActive(): boolean { + const placing = placingTower$.get() + if (!placing) return false + const stats = TOWER_STATS_BY_GEO[placing] + return gold$.get() >= stats.cost + } + + override getOverlays(): TLPlacementPreviewOverlay[] { + const geo = placingTower$.get() + if (!geo) return [] + const stats = getTowerStats(geo) + if (!stats) return [] + const { x, y } = this.editor.inputs.getCurrentPagePoint() + return [ + { + id: 'td-placement-preview:main', + type: 'td-placement-preview', + props: { geo, x, y, range: stats.range }, + }, + ] + } + + // The preview tracks the cursor, so a click at the cursor always lands inside + // this circle — that's how we route placement through the overlay system + // instead of bolting on a capture-phase canvas listener. + override getGeometry(overlay: TLPlacementPreviewOverlay): Geometry2d { + const { x, y } = overlay.props + const r = TOWER_PLACEMENT_SIZE / 2 + return new Circle2d({ x: x - r, y: y - r, radius: r, isFilled: true }) + } + + override getCursor(): TLCursorType { + return 'cross' + } + + override onPointerDown(overlay: TLPlacementPreviewOverlay): boolean { + const { geo, x, y } = overlay.props + const stats = TOWER_STATS_BY_GEO[geo] + if (gold$.get() < stats.cost) return true + this.editor.createShape({ + id: createShapeId(), + type: 'geo', + x: x - TOWER_PLACEMENT_SIZE / 2, + y: y - TOWER_PLACEMENT_SIZE / 2, + isLocked: true, + props: { geo, w: TOWER_PLACEMENT_SIZE, h: TOWER_PLACEMENT_SIZE }, + }) + gold$.update((g) => g - stats.cost) + placingTower$.set(null) + return true + } + + override render(ctx: CanvasRenderingContext2D, overlays: TLPlacementPreviewOverlay[]): void { + const overlay = overlays[0] + if (!overlay) return + const { geo, x, y, range } = overlay.props + const zoom = this.editor.getZoomLevel() + const colors = this.editor.getCurrentTheme().colors[this.editor.getColorMode()] + + ctx.save() + + // Range ring + soft fill. + ctx.lineWidth = 2 / zoom + ctx.setLineDash([8 / zoom, 6 / zoom]) + ctx.strokeStyle = colors.selectionStroke + ctx.fillStyle = colors.selectionFill + ctx.globalAlpha = 0.35 + ctx.beginPath() + ctx.arc(x, y, range, 0, Math.PI * 2) + ctx.fill() + ctx.globalAlpha = 1 + ctx.stroke() + ctx.setLineDash([]) + + // Ghost shape silhouette at the would-be placement. + const half = TOWER_PLACEMENT_SIZE / 2 + ctx.globalAlpha = 0.55 + ctx.lineWidth = 2 / zoom + ctx.strokeStyle = colors.selectionStroke + ctx.fillStyle = colors.selectionFill + + ctx.beginPath() + if (geo === 'rectangle') { + ctx.rect(x - half, y - half, TOWER_PLACEMENT_SIZE, TOWER_PLACEMENT_SIZE) + } else if (geo === 'ellipse') { + ctx.ellipse(x, y, half, half, 0, 0, Math.PI * 2) + } else { + // Triangle — match how the geo shape renders: top-center, bottom-left, bottom-right. + ctx.moveTo(x, y - half) + ctx.lineTo(x + half, y + half) + ctx.lineTo(x - half, y + half) + ctx.closePath() + } + ctx.fill() + ctx.stroke() + + ctx.restore() + } +} diff --git a/apps/examples/src/examples/use-cases/tower-defense/overlays/ProjectileOverlayUtil.ts b/apps/examples/src/examples/use-cases/tower-defense/overlays/ProjectileOverlayUtil.ts new file mode 100644 index 000000000000..5d83e407d9c2 --- /dev/null +++ b/apps/examples/src/examples/use-cases/tower-defense/overlays/ProjectileOverlayUtil.ts @@ -0,0 +1,82 @@ +import { OverlayUtil, TLOverlay } from 'tldraw' +import { projectiles$ } from '../game-state' +import { ProjectileKind } from '../tower-config' + +interface TLProjectileOverlay extends TLOverlay { + props: { + x: number + y: number + angle: number + kind: ProjectileKind + } +} + +export class ProjectileOverlayUtil extends OverlayUtil { + static override type = 'td-projectile' + override options = { zIndex: 250 } + + override isActive(): boolean { + return projectiles$.get().length > 0 + } + + override getOverlays(): TLProjectileOverlay[] { + return projectiles$.get().map((p) => ({ + id: `td-projectile:${p.id}`, + type: 'td-projectile', + props: { + x: p.x, + y: p.y, + angle: Math.atan2(p.vy, p.vx), + kind: p.kind, + }, + })) + } + + override render(ctx: CanvasRenderingContext2D, overlays: TLProjectileOverlay[]): void { + const zoom = this.editor.getZoomLevel() + const isDark = this.editor.getColorMode() === 'dark' + for (const overlay of overlays) { + const { x, y, angle, kind } = overlay.props + ctx.save() + ctx.translate(x, y) + ctx.rotate(angle) + switch (kind) { + case 'arrow': + ctx.lineWidth = 2 / zoom + ctx.strokeStyle = isDark ? '#ddd' : '#222' + ctx.beginPath() + ctx.moveTo(-10, 0) + ctx.lineTo(8, 0) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(8, 0) + ctx.lineTo(2, -3) + ctx.lineTo(2, 3) + ctx.closePath() + ctx.fillStyle = isDark ? '#ddd' : '#222' + ctx.fill() + break + case 'rock': + ctx.beginPath() + ctx.arc(0, 0, 6, 0, Math.PI * 2) + ctx.fillStyle = '#806040' + ctx.fill() + ctx.lineWidth = 1.5 / zoom + ctx.strokeStyle = '#3a2a1a' + ctx.stroke() + break + case 'orb': { + const grd = ctx.createRadialGradient(0, 0, 1, 0, 0, 8) + grd.addColorStop(0, 'rgba(180, 220, 255, 1)') + grd.addColorStop(1, 'rgba(80, 120, 220, 0.1)') + ctx.fillStyle = grd + ctx.beginPath() + ctx.arc(0, 0, 8, 0, Math.PI * 2) + ctx.fill() + break + } + } + ctx.restore() + } + } +} diff --git a/apps/examples/src/examples/use-cases/tower-defense/overlays/TowerRangeOverlayUtil.ts b/apps/examples/src/examples/use-cases/tower-defense/overlays/TowerRangeOverlayUtil.ts new file mode 100644 index 000000000000..980f60d7ebcc --- /dev/null +++ b/apps/examples/src/examples/use-cases/tower-defense/overlays/TowerRangeOverlayUtil.ts @@ -0,0 +1,79 @@ +import { computed, OverlayUtil, TLGeoShape, TLOverlay } from 'tldraw' +import { getScaledStats, getTowerLevel, getTowerStats } from '../tower-config' + +interface TLTowerRangeOverlay extends TLOverlay { + props: { + shapeId: string + cx: number + cy: number + range: number + } +} + +export class TowerRangeOverlayUtil extends OverlayUtil { + static override type = 'td-tower-range' + override options = { zIndex: 100 } + + // We deliberately don't rely on `editor.getHoveredShape()` here: default geo + // shapes have `fill: 'none'`, which makes their interiors transparent to + // hover hit-testing. Walking tower bounds directly means hovering anywhere + // inside a tower's bounding box highlights its range, regardless of fill. + // Memoised via computed so isActive() + getOverlays() share one walk per tick. + private _hoveredTowers = computed('td-tower-range:hovered', (): TLTowerRangeOverlay[] => { + const point = this.editor.inputs.getCurrentPagePoint() + const result: TLTowerRangeOverlay[] = [] + for (const shape of this.editor.getCurrentPageShapes()) { + if (shape.type !== 'geo') continue + const baseStats = getTowerStats((shape as TLGeoShape).props.geo) + if (!baseStats) continue + const stats = getScaledStats(baseStats, getTowerLevel(shape)) + const bounds = this.editor.getShapePageBounds(shape.id) + if (!bounds) continue + // Always preview the range while a tower is being placed (unlocked); + // once placed (locked), only show on hover. Filtering on isLocked + // avoids flicker as the in-progress shape's bounds chase the pointer. + const isPlacing = !shape.isLocked + if (!isPlacing && !bounds.containsPoint(point)) continue + result.push({ + id: `td-tower-range:${shape.id}`, + type: 'td-tower-range', + props: { + shapeId: shape.id, + cx: bounds.center.x, + cy: bounds.center.y, + range: stats.range, + }, + }) + } + return result + }) + + override isActive(): boolean { + return this._hoveredTowers.get().length > 0 + } + + override getOverlays(): TLTowerRangeOverlay[] { + return this._hoveredTowers.get() + } + + override render(ctx: CanvasRenderingContext2D, overlays: TLTowerRangeOverlay[]): void { + const zoom = this.editor.getZoomLevel() + const colors = this.editor.getCurrentTheme().colors[this.editor.getColorMode()] + + ctx.save() + ctx.lineWidth = 2 / zoom + ctx.setLineDash([8 / zoom, 6 / zoom]) + ctx.strokeStyle = colors.selectionStroke + ctx.fillStyle = colors.selectionFill + for (const overlay of overlays) { + const { cx, cy, range } = overlay.props + ctx.globalAlpha = 0.4 + ctx.beginPath() + ctx.arc(cx, cy, range, 0, Math.PI * 2) + ctx.fill() + ctx.globalAlpha = 1 + ctx.stroke() + } + ctx.restore() + } +} diff --git a/apps/examples/src/examples/use-cases/tower-defense/overlays/UpgradeButtonOverlayUtil.ts b/apps/examples/src/examples/use-cases/tower-defense/overlays/UpgradeButtonOverlayUtil.ts new file mode 100644 index 000000000000..0546d7808d1e --- /dev/null +++ b/apps/examples/src/examples/use-cases/tower-defense/overlays/UpgradeButtonOverlayUtil.ts @@ -0,0 +1,192 @@ +import { + Box, + Circle2d, + Geometry2d, + OverlayUtil, + TLCursorType, + TLGeoShape, + TLOverlay, + TLShapeId, + computed, +} from 'tldraw' +import { gold$ } from '../game-state' +import { playUpgradeSound } from '../sounds' +import { + MAX_TOWER_LEVEL, + getTowerLevel, + getTowerStats, + getUpgradeCost, + levelColor, +} from '../tower-config' + +interface TLUpgradeBtnOverlay extends TLOverlay { + props: { + shapeId: TLShapeId + x: number + y: number + radius: number + level: number + upgradeCost: number + canAfford: boolean + } +} + +const BTN_RADIUS = 14 + +interface UpgradeCandidate { + shapeId: TLShapeId + cx: number + cy: number + bounds: Box + level: number + upgradeCost: number +} + +export class UpgradeButtonOverlayUtil extends OverlayUtil { + static override type = 'td-upgrade-btn' + override options = { zIndex: 260 } + + // Walk the page shapes once per shape/bounds change. Filtering by pointer + // happens in getOverlays(), so a mousemove storm only re-runs the cheap + // pointer filter — not the full shape scan and bounds resolution. + private _candidates = computed('td-upgrade-btn:candidates', (): UpgradeCandidate[] => { + const result: UpgradeCandidate[] = [] + for (const shape of this.editor.getCurrentPageShapes()) { + if (shape.type !== 'geo' || !shape.isLocked) continue + const stats = getTowerStats((shape as TLGeoShape).props.geo) + if (!stats) continue + const level = getTowerLevel(shape) + if (level >= MAX_TOWER_LEVEL) continue + const bounds = this.editor.getShapePageBounds(shape.id) + if (!bounds) continue + result.push({ + shapeId: shape.id, + cx: bounds.center.x, + cy: bounds.center.y, + bounds, + level, + upgradeCost: getUpgradeCost(stats.cost, level), + }) + } + return result + }) + + override isActive(): boolean { + return this._candidates.get().length > 0 + } + + override getOverlays(): TLUpgradeBtnOverlay[] { + const candidates = this._candidates.get() + if (candidates.length === 0) return [] + const gold = gold$.get() + const point = this.editor.inputs.getCurrentPagePoint() + const result: TLUpgradeBtnOverlay[] = [] + for (const c of candidates) { + // Show only while the pointer is over the tower or the button itself — + // the button stays accessible after the cursor moves up onto it. + const overShape = c.bounds.containsPoint(point) + const dx = point.x - c.cx + const dy = point.y - c.cy + const overButton = dx * dx + dy * dy <= BTN_RADIUS * BTN_RADIUS + if (!overShape && !overButton) continue + result.push({ + id: `td-upgrade-btn:${c.shapeId}`, + type: 'td-upgrade-btn', + props: { + shapeId: c.shapeId, + x: c.cx, + y: c.cy, + radius: BTN_RADIUS, + level: c.level, + upgradeCost: c.upgradeCost, + canAfford: gold >= c.upgradeCost, + }, + }) + } + return result + } + + override getGeometry(overlay: TLUpgradeBtnOverlay): Geometry2d { + const { x, y, radius } = overlay.props + return new Circle2d({ x: x - radius, y: y - radius, radius, isFilled: true }) + } + + override getCursor(): TLCursorType { + return 'pointer' + } + + override onPointerDown(overlay: TLUpgradeBtnOverlay): boolean { + const { shapeId, upgradeCost, level, canAfford } = overlay.props + if (!canAfford) return true + const shape = this.editor.getShape(shapeId) + if (!shape) return true + const nextLevel = level + 1 + gold$.update((g) => g - upgradeCost) + // updateShape silently skips locked shapes unless run with ignoreShapeLock; + // without this the meta + props don't persist on placed (locked) towers. + this.editor.run( + () => { + this.editor.updateShape({ + id: shapeId, + type: 'geo', + meta: { ...shape.meta, towerLevel: nextLevel }, + props: { fill: 'solid', color: levelColor(nextLevel) }, + }) + }, + { ignoreShapeLock: true } + ) + playUpgradeSound() + return true + } + + override render(ctx: CanvasRenderingContext2D, overlays: TLUpgradeBtnOverlay[]): void { + const zoom = this.editor.getZoomLevel() + const isDark = this.editor.getColorMode() === 'dark' + for (const overlay of overlays) { + const { x, y, radius, level, upgradeCost, canAfford } = overlay.props + ctx.save() + ctx.beginPath() + ctx.arc(x, y, radius, 0, Math.PI * 2) + ctx.fillStyle = canAfford ? '#3aa56a' : isDark ? '#444' : '#aaa' + ctx.fill() + ctx.lineWidth = 2 / zoom + ctx.strokeStyle = isDark ? '#000' : '#222' + ctx.stroke() + + // Plus sign. + ctx.lineWidth = 3 / zoom + ctx.lineCap = 'round' + ctx.strokeStyle = '#fff' + const arm = radius * 0.5 + ctx.beginPath() + ctx.moveTo(x - arm, y) + ctx.lineTo(x + arm, y) + ctx.moveTo(x, y - arm) + ctx.lineTo(x, y + arm) + ctx.stroke() + + // Cost + level chip below the button. + const fontPx = 11 / zoom + ctx.font = `600 ${fontPx}px sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + const text = `L${level}→${level + 1} · ${upgradeCost}g` + const metrics = ctx.measureText(text) + const padX = 5 / zoom + const padY = 2 / zoom + const boxW = metrics.width + padX * 2 + const boxH = fontPx + padY * 2 + const boxX = x - boxW / 2 + const boxY = y + radius + 3 / zoom + ctx.fillStyle = isDark ? 'rgba(20,20,28,0.92)' : 'rgba(255,255,255,0.95)' + ctx.fillRect(boxX, boxY, boxW, boxH) + ctx.lineWidth = 1 / zoom + ctx.strokeStyle = isDark ? '#fff' : '#000' + ctx.strokeRect(boxX, boxY, boxW, boxH) + ctx.fillStyle = canAfford ? (isDark ? '#fff' : '#000') : isDark ? '#888' : '#888' + ctx.fillText(text, x, boxY + padY) + + ctx.restore() + } + } +} diff --git a/apps/examples/src/examples/use-cases/tower-defense/path.ts b/apps/examples/src/examples/use-cases/tower-defense/path.ts new file mode 100644 index 000000000000..9d1c335dffa7 --- /dev/null +++ b/apps/examples/src/examples/use-cases/tower-defense/path.ts @@ -0,0 +1,63 @@ +// Hardcoded enemy path. Page-space waypoints; enemies move along it from start +// to end. Kept simple so the OverlayUtil mechanics, not the routing, are the +// point of the example. + +export interface Waypoint { + x: number + y: number +} + +export const PATH: Waypoint[] = [ + { x: -200, y: 200 }, + { x: 200, y: 200 }, + { x: 200, y: 500 }, + { x: 600, y: 500 }, + { x: 600, y: 100 }, + { x: 1000, y: 100 }, + { x: 1000, y: 600 }, + { x: 1400, y: 600 }, +] + +const SEGMENT_LENGTHS: number[] = [] +let TOTAL_LENGTH = 0 +for (let i = 1; i < PATH.length; i++) { + const dx = PATH[i].x - PATH[i - 1].x + const dy = PATH[i].y - PATH[i - 1].y + const len = Math.hypot(dx, dy) + SEGMENT_LENGTHS.push(len) + TOTAL_LENGTH += len +} + +export const PATH_LENGTH = TOTAL_LENGTH + +export interface PointOnPath { + x: number + y: number + angle: number +} + +export function getPositionAtDistance(distance: number): PointOnPath { + if (distance <= 0) { + const a = PATH[0] + const b = PATH[1] + return { x: a.x, y: a.y, angle: Math.atan2(b.y - a.y, b.x - a.x) } + } + let remaining = distance + for (let i = 0; i < SEGMENT_LENGTHS.length; i++) { + const segLen = SEGMENT_LENGTHS[i] + if (remaining <= segLen) { + const a = PATH[i] + const b = PATH[i + 1] + const t = remaining / segLen + return { + x: a.x + (b.x - a.x) * t, + y: a.y + (b.y - a.y) * t, + angle: Math.atan2(b.y - a.y, b.x - a.x), + } + } + remaining -= segLen + } + const last = PATH[PATH.length - 1] + const prev = PATH[PATH.length - 2] + return { x: last.x, y: last.y, angle: Math.atan2(last.y - prev.y, last.x - prev.x) } +} diff --git a/apps/examples/src/examples/use-cases/tower-defense/sounds.ts b/apps/examples/src/examples/use-cases/tower-defense/sounds.ts new file mode 100644 index 000000000000..21ce52fc7d97 --- /dev/null +++ b/apps/examples/src/examples/use-cases/tower-defense/sounds.ts @@ -0,0 +1,92 @@ +import { ProjectileKind } from './tower-config' + +// We synthesize fire sounds with Web Audio API rather than loading assets so +// the example stays self-contained. The AudioContext is created lazily on the +// first call to side-step browser autoplay restrictions — by then the user has +// already clicked something to place a tower. + +let ctx: AudioContext | null = null +let lastPlayedAt = 0 +const MIN_INTERVAL_MS = 25 // hard cap to stop a wall of overlapping shots + +function getCtx(): AudioContext | null { + if (ctx) return ctx + try { + const Ctor = + window.AudioContext ?? + (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext + if (!Ctor) return null + ctx = new Ctor() + return ctx + } catch { + return null + } +} + +interface ToneSpec { + type: OscillatorType + startFreq: number + endFreq: number + durationMs: number + gain: number +} + +const TONES: Record = { + arrow: { type: 'sawtooth', startFreq: 760, endFreq: 480, durationMs: 90, gain: 0.07 }, + rock: { type: 'square', startFreq: 160, endFreq: 70, durationMs: 180, gain: 0.1 }, + orb: { type: 'sine', startFreq: 880, endFreq: 320, durationMs: 220, gain: 0.09 }, +} + +export function playUpgradeSound() { + const c = getCtx() + if (!c) return + if (c.state === 'suspended') c.resume().catch(() => {}) + + // Two quick ascending sine notes — a small "level up" chime. + const t0 = c.currentTime + const notes = [ + { freq: 660, start: 0, durMs: 120 }, + { freq: 990, start: 90, durMs: 180 }, + ] + for (const n of notes) { + const osc = c.createOscillator() + osc.type = 'sine' + const gain = c.createGain() + const startAt = t0 + n.start / 1000 + const stopAt = startAt + n.durMs / 1000 + osc.frequency.setValueAtTime(n.freq, startAt) + gain.gain.setValueAtTime(0.001, startAt) + gain.gain.exponentialRampToValueAtTime(0.12, startAt + 0.01) + gain.gain.exponentialRampToValueAtTime(0.0001, stopAt) + osc.connect(gain).connect(c.destination) + osc.start(startAt) + osc.stop(stopAt + 0.02) + } +} + +export function playFireSound(kind: ProjectileKind) { + const c = getCtx() + if (!c) return + if (c.state === 'suspended') c.resume().catch(() => {}) + + const now = performance.now() + if (now - lastPlayedAt < MIN_INTERVAL_MS) return + lastPlayedAt = now + + const spec = TONES[kind] + const t0 = c.currentTime + const t1 = t0 + spec.durationMs / 1000 + + const osc = c.createOscillator() + osc.type = spec.type + osc.frequency.setValueAtTime(spec.startFreq, t0) + osc.frequency.exponentialRampToValueAtTime(Math.max(20, spec.endFreq), t1) + + const gain = c.createGain() + gain.gain.setValueAtTime(spec.gain, t0) + gain.gain.exponentialRampToValueAtTime(0.0001, t1) + + osc.connect(gain).connect(c.destination) + osc.start(t0) + osc.stop(t1 + 0.02) +} diff --git a/apps/examples/src/examples/use-cases/tower-defense/tower-config.ts b/apps/examples/src/examples/use-cases/tower-defense/tower-config.ts new file mode 100644 index 000000000000..7750856de71e --- /dev/null +++ b/apps/examples/src/examples/use-cases/tower-defense/tower-config.ts @@ -0,0 +1,92 @@ +// Maps geo shape variants to tower stats. Only these three geo types act as +// towers; other geo shapes the user draws are ignored by the game loop so the +// editor stays usable. + +import { TLShape } from 'tldraw' + +export type ProjectileKind = 'arrow' | 'rock' | 'orb' + +export const MAX_TOWER_LEVEL = 4 + +export type TowerGeo = 'triangle' | 'rectangle' | 'ellipse' +export const TOWER_GEOS: TowerGeo[] = ['triangle', 'rectangle', 'ellipse'] +export const TOWER_PLACEMENT_SIZE = 60 + +export interface TowerStats { + label: string + range: number + fireRateMs: number + damage: number + projectileSpeed: number + projectileKind: ProjectileKind + cost: number +} + +// Cost is roughly proportional to overall strength (range × damage / fireRate). +// Starting gold (see game-state) is calibrated to afford one Archer immediately +// and earn the others through kills. +export const TOWER_STATS_BY_GEO: Record = { + triangle: { + label: 'Archer', + range: 220, + fireRateMs: 350, + damage: 8, + projectileSpeed: 700, + projectileKind: 'arrow', + cost: 50, + }, + rectangle: { + label: 'Cannon', + range: 160, + fireRateMs: 1100, + damage: 30, + projectileSpeed: 380, + projectileKind: 'rock', + cost: 120, + }, + ellipse: { + label: 'Magic', + range: 190, + fireRateMs: 600, + damage: 14, + projectileSpeed: 520, + projectileKind: 'orb', + cost: 80, + }, +} + +export function getTowerStats(geo: string | undefined): TowerStats | null { + if (!geo) return null + return TOWER_STATS_BY_GEO[geo] ?? null +} + +export function getTowerLevel(shape: TLShape): number { + const lvl = shape.meta?.towerLevel + return typeof lvl === 'number' && lvl >= 1 ? Math.min(MAX_TOWER_LEVEL, lvl) : 1 +} + +// Each upgrade adds 60% of base cost per level: L1→2 = 60%, L2→3 = 120%, L3→4 = 180%. +export function getUpgradeCost(baseCost: number, currentLevel: number): number { + if (currentLevel >= MAX_TOWER_LEVEL) return 0 + return Math.ceil(baseCost * 0.6 * currentLevel) +} + +// Visual cue for tower level — also makes the level readable at a glance. +export function levelColor(level: number): 'blue' | 'violet' | 'red' | 'orange' { + if (level >= 4) return 'red' + if (level === 3) return 'violet' + if (level === 2) return 'blue' + return 'orange' +} + +// Damage and range grow with level; fire rate shortens. Level 1 returns base. +export function getScaledStats(stats: TowerStats, level: number): TowerStats { + const k = Math.max(0, level - 1) + if (k === 0) return stats + return { + ...stats, + damage: Math.round(stats.damage * Math.pow(1.4, k)), + range: Math.round(stats.range * Math.pow(1.06, k)), + fireRateMs: Math.round(stats.fireRateMs * Math.pow(0.9, k)), + } +} diff --git a/apps/examples/src/examples/use-cases/tower-defense/tower-defense.css b/apps/examples/src/examples/use-cases/tower-defense/tower-defense.css new file mode 100644 index 000000000000..eed699fd153c --- /dev/null +++ b/apps/examples/src/examples/use-cases/tower-defense/tower-defense.css @@ -0,0 +1,131 @@ +.td-hud { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 12px; + background: var(--color-panel); + border-radius: 8px; + box-shadow: var(--shadow-2); + font-size: 12px; + color: var(--color-text); + max-width: 720px; + pointer-events: auto; +} + +.td-hud__row { + display: flex; + gap: 16px; + align-items: center; + flex-wrap: wrap; +} + +.td-hud__row strong { + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.td-hud__btn { + margin-left: auto; + padding: 4px 10px; + border: 1px solid var(--color-divider); + border-radius: 6px; + background: var(--color-low); + color: var(--color-text); + cursor: pointer; + font: inherit; +} + +.td-hud__btn:hover { + background: var(--color-hint); +} + +.td-hud__hint { + color: var(--color-text-1); + font-size: 11px; + opacity: 0.85; +} + +.td-hud__hint code, +.td-hud__hint kbd { + display: inline-block; + padding: 0 4px; + border-radius: 3px; + background: var(--color-low); + font: inherit; + font-size: 10px; +} + +.td-hud__gameover { + color: #d33; + font-weight: 600; +} + +.td-toolbar { + display: flex; + gap: 6px; + padding: 6px; + background: var(--color-panel); + border-radius: 10px; + box-shadow: var(--shadow-2); + pointer-events: auto; +} + +.td-toolbar__btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + min-width: 56px; + padding: 6px 8px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--color-text); + cursor: pointer; + font-size: 11px; + font: inherit; +} + +.td-toolbar__btn:hover { + background: var(--color-hint); +} + +.td-toolbar__btn.is-active { + border-color: var(--color-selected); + background: var(--color-selected-contrast); + color: var(--color-selected); +} + +.td-toolbar__btn.is-disabled, +.td-toolbar__btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.td-toolbar__btn.is-disabled:hover, +.td-toolbar__btn:disabled:hover { + background: transparent; +} + +.td-toolbar__label { + font-size: 10px; + letter-spacing: 0.02em; +} + +.td-toolbar__cost { + font-size: 10px; + color: var(--color-text-1); + font-variant-numeric: tabular-nums; +} + +.td-toolbar__cost kbd { + display: inline-block; + padding: 0 4px; + min-width: 14px; + text-align: center; + border: 1px solid var(--color-divider); + border-radius: 3px; + background: var(--color-low); + font: inherit; + font-size: 9px; +} From 012f1734850540a2667579ffd8133c1c229565ba Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Wed, 6 May 2026 12:01:51 +0100 Subject: [PATCH 08/11] docs(releases): add migration guides for breaking changes (#8776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In order to make the `tldraw-migrate` skill version-agnostic and stop duplicating migration recipes between the skill and the release notes, this PR adds explicit migration guide blocks for every breaking change in the v4.2, v4.3, and `next.mdx` release notes — and refactors the skill to drive off those blocks instead of carrying its own copies. Version-specific knowledge belongs next to the breaking change that introduced it. The skill stays small and stable; the release notes own the recipes. ### Release notes content - `apps/docs/content/releases/v4.2.0.mdx` — TipTap v3 section gets a migration block covering the dual-install diagnostic, default→named export changes, `TextStyleKit`/`FontFamily` reorganization, and transaction handler types. - `apps/docs/content/releases/v4.3.0.mdx` — Custom shape/binding pattern section gets a migration block covering `TLGlobalShapePropsMap`/`TLGlobalBindingPropsMap` augmentation, the rename ripple when shape names collide, `as const` on `static override type` / `static override shapeType`, and the heterogeneous `createShapes`/`updateShapes` cast. - `apps/docs/content/releases/next.mdx` — adds migration blocks for `ShapeUtil.indicator()` → `getIndicatorPath()`, `` options consolidation (`cameraOptions`/`textOptions`/`deepLinks` → `options` prop, `embeds` → `EmbedShapeUtil.configure`, `setDefaultEditorAssetUrls`/`setDefaultUiAssetUrls` demoted to `@internal`), and `useTldrawUser` removal. Several previously-unmarked breaking changes in the API list are now marked with `💥` and given inline replacement guidance. ### Skill changes - `skills/write-release-notes/SKILL.md` — new step requiring a migration recipe for every `💥` (block for featured sections; inline replacement for one-line API entries). Verify step now grep-checks that every `💥` has a recipe. - `skills/tldraw-migrate/SKILL.md` — major refactor: - Renames `${CLAUDE_SKILL_DIR}` → `${SKILL_DIR}` and probes common skill locations so the skill works under non-Claude agent setups. - Adds support for migrating to a target version other than `latest`. Pass a dist-tag (`canary`, `next`, `beta`) or a pre-release semver as the second argument. - When the target is a pre-release, auto-fetches `apps/docs/content/releases/next.mdx` from `main` so canary/next deltas are searchable alongside the stable changelog. - Step 4 fix patterns are now version-agnostic — they categorize errors by TS code and route to the correct migration block in the changelog. Each sub-step ends with a verify step that re-runs typecheck. - Quality audit now distinguishes typed `as` casts from `as const` and import-path `as`, and adds a module-augmentation audit (catches the anti-pattern of re-exposing `@internal` symbols). - `skills/tldraw-migrate/detect-target.mjs` (new) — resolves the migration target from the skill arguments. `latest` by default; supports dist-tags and pre-release semvers. ### Change type - [x] `other` (skill + docs) ### Test plan 1. Skim each `
Migration guide` block in `next.mdx`, `v4.2.0.mdx`, and `v4.3.0.mdx` for accuracy. 2. Verify every `💥` has a recipe: `grep -nE '💥' apps/docs/content/releases/{next,v4.2.0,v4.3.0}.mdx` and confirm each line has either a nearby migration block or an inline replacement. 3. Run the migrate skill on a v4.1 → 4.6.0-canary project: confirm the auto-fetch blocks resolve `${SKILL_DIR}` and that `references/tldraw-next.mdx` is pulled when the target is a pre-release. 4. Run with no second argument and confirm the target resolves to `latest` and `tldraw-next.mdx` is skipped. ### Release notes - Add migration guides for every breaking change in the v4.2 and v4.3 release notes. ### Code changes | Section | LOC change | | --------------- | ---------- | | Documentation | +184 / -4 | | Config/tooling | +195 / -60 | --- apps/docs/content/releases/next.mdx | 106 +++++++++++++- apps/docs/content/releases/v4.2.0.mdx | 35 ++++- apps/docs/content/releases/v4.3.0.mdx | 47 ++++++- skills/tldraw-migrate/SKILL.md | 179 ++++++++++++++++-------- skills/tldraw-migrate/detect-target.mjs | 34 +++++ skills/write-release-notes/SKILL.md | 42 +++++- 6 files changed, 379 insertions(+), 64 deletions(-) create mode 100644 skills/tldraw-migrate/detect-target.mjs diff --git a/apps/docs/content/releases/next.mdx b/apps/docs/content/releases/next.mdx index 0fb44195faf2..dd198da81364 100644 --- a/apps/docs/content/releases/next.mdx +++ b/apps/docs/content/releases/next.mdx @@ -122,7 +122,25 @@ The following slots have been removed from `TLEditorComponents`. Subclass the ma The corresponding `Default*` exports and `LiveCollaborators` have also been removed. -Customizing shape indicators — `ShapeIndicator`, `ShapeIndicators`, and `ShapeIndicatorErrorFallback` are gone. Indicators now render through each shape util's `getIndicatorPath()`. Override it to change how a shape's indicator is drawn. +**`ShapeUtil.indicator()` → `ShapeUtil.getIndicatorPath()`.** The shape util method that draws a shape's selection indicator no longer returns JSX. It now returns a `Path2D` (or a `TLIndicatorPath`) and is composited onto the canvas overlay layer. Every custom `ShapeUtil` that overrides `indicator` needs to migrate: + +```tsx +// Before +override indicator(shape: MyShape) { + return +} + +// After +override getIndicatorPath(shape: MyShape): Path2D { + const path = new Path2D() + path.rect(0, 0, shape.props.w, shape.props.h) + return path +} +``` + +For shapes whose indicator should match the shape's geometry, return `path` built from the same primitives. For placeholder shapes (e.g. an `ErrorShape`), return an empty `new Path2D()` and declare the return type explicitly so TypeScript doesn't infer `never`. + +The React component slots `ShapeIndicator`, `ShapeIndicators`, and `ShapeIndicatorErrorFallback` are also removed from `TLEditorComponents` — there is no replacement React slot, since indicators now render through `getIndicatorPath()`. Overlays cannot render React; they draw into a 2D canvas context. For React-based overlays, render an HTML layer above the canvas via a regular `TLEditorComponents` slot like `InFrontOfTheCanvas`. @@ -134,6 +152,57 @@ Overriding them (or the removed selectors `.tl-brush`, `.tl-scribble`, `.tl-snap
+### 💥 Tldraw component options + +Several `` props that previously lived at the top level have been consolidated under the `options` prop, and a few extension points moved off the component entirely. + +
+Migration guide + +**`cameraOptions`, `textOptions`, and `deepLinks` props moved into `options`:** + +```tsx +// Before + + +// After + +``` + +**`embeds` prop removed; configure embed definitions on `EmbedShapeUtil`:** + +```tsx +// Before + + +// After +import { EmbedShapeUtil } from 'tldraw' +const ConfiguredEmbedShapeUtil = EmbedShapeUtil.configure({ + embedDefinitions: [...DEFAULT_EMBED_DEFINITIONS, customEmbed], +}) + +``` + +This silently compiles in some setups (the prop is unknown but JSX won't always reject it), so this is the kind of change that the typecheck won't always catch — search the source for `embeds=` and migrate any matches. + +**`setDefaultEditorAssetUrls()` and `setDefaultUiAssetUrls()` are no longer part of the public API.** They still exist at runtime for now, but they're marked `@internal` and should not be relied on. Pass `assetUrls` to each `` instead: + +```tsx +// Before +import { setDefaultEditorAssetUrls, setDefaultUiAssetUrls } from 'tldraw' +setDefaultEditorAssetUrls(assetUrls) +setDefaultUiAssetUrls(assetUrls) + + + +// After + +``` + +Do not reach for module augmentation to re-expose the old globals — find each `` mount point and pass `assetUrls` directly. + +
+ ### 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. @@ -282,6 +351,33 @@ const schema = createTLSchema({ Note shapes now track and display a "first edited by" attribution label in the bottom-right corner, showing who first added text to the note. +
+Migration guide + +**`useTldrawUser` has been removed.** Previously it bundled user *preferences* (color, color scheme) and user *identity* (id, name) into a single object. These are now separate concerns: + +- **Preferences** (color, color scheme, snap mode, etc.) are managed via `TLUserPreferences`. Set them with the editor's user-preferences API directly. +- **Identity** for attribution comes from a `TLUserStore` provider passed to the new `users` prop on ``. + +```tsx +// Before +const user = useTldrawUser({ userPreferences, setUserPreferences }) + + +// After + currentUserSignal, + resolve: (userId) => resolvedUserSignal(userId), + }} + colorScheme={userPreferences.colorScheme} +/> +``` + +If you only need preferences (no custom identity), pass `colorScheme` directly and seed preferences imperatively after mount; if you only need identity, pass `users`. Most apps want both. + +
+ ### @tldraw/driver ([#7952](https://github.com/tldraw/tldraw/pull/7952)) A new `@tldraw/driver` package provides an imperative API for driving the tldraw editor programmatically. `Driver` wraps an `Editor` instance and exposes event dispatch, selection transforms, clipboard operations, and shape queries with fluent chaining. @@ -387,6 +483,14 @@ New APIs include `handleSocketResume()` for restoring sessions from snapshots, ` - 💥 Change `notifyIfFileNotAllowed` signature from `(file, options)` to `(editor, file, options)`. ([#8031](https://github.com/tldraw/tldraw/pull/8031)) - 💥 Change `getAssetInfo` signature from `(file, options, assetId?)` to `(editor, file, assetId?)` and return `TLAsset | null` instead of throwing. ([#8031](https://github.com/tldraw/tldraw/pull/8031)) - 💥 Move the `Cmd+Shift+C` / `Ctrl+Shift+C` shortcut from "Copy as SVG" to "Copy as PNG". ([#8532](https://github.com/tldraw/tldraw/pull/8532)) +- 💥 Replace `ShapeUtil.indicator()` (returned JSX) with `ShapeUtil.getIndicatorPath()` (returns `Path2D | TLIndicatorPath | undefined`). Indicators now render to the canvas overlay layer instead of as React elements. ([#8469](https://github.com/tldraw/tldraw/pull/8469)) +- 💥 Move `cameraOptions`, `textOptions`, and `deepLinks` from top-level `` props into the `options` prop (e.g. `options={{ camera, text, deepLinks }}`). +- 💥 Remove the `embeds` prop from ``. Configure embed definitions via `EmbedShapeUtil.configure({ embedDefinitions })` and pass the configured util through `shapeUtils`. +- 💥 Demote `setDefaultEditorAssetUrls()` and `setDefaultUiAssetUrls()` to `@internal`. Pass `assetUrls` to each `` instead. +- 💥 Remove `useTldrawUser`. Use the new `users` prop (`TLUserStore`) for identity and `TLUserPreferences` for preferences. +- 💥 Replace `TLDrawShapeSegment.points` with the helper `getPointsFromDrawSegment(segment, scaleX, scaleY)` so segment points respect the shape's current scale. +- 💥 Change `BindingUtil` hook params: `fromShapeType`/`toShapeType` are removed in favor of full `fromShape`/`toShape` records (read `fromShape.type` / `toShape.type` directly). +- 💥 Add `'middle-legacy'` (and other legacy values) to the `align` union resolved by `PlainTextLabel`/`RichTextLabel`. If your code maps `align` into `PlainTextLabel.textAlign`, narrow legacy values to one of `'start' | 'center' | 'end'` before passing them through. - 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 `` and ``. ([#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)) diff --git a/apps/docs/content/releases/v4.2.0.mdx b/apps/docs/content/releases/v4.2.0.mdx index 3ed6708c7585..283084b6f53c 100644 --- a/apps/docs/content/releases/v4.2.0.mdx +++ b/apps/docs/content/releases/v4.2.0.mdx @@ -19,10 +19,43 @@ This month's release includes many bug fixes and small API additions, along with ## What's new -### TipTap v3 ([#5717](https://github.com/tldraw/tldraw/pull/5717)) +### 💥 TipTap v3 ([#5717](https://github.com/tldraw/tldraw/pull/5717)) We've upgraded TipTap from v2 to v3. If you've done any customization to our standard TipTap kit, please refer to TipTap's guide [How to upgrade Tiptap v2 to v3](https://tiptap.dev/docs/guides/upgrade-tiptap-v2) for breaking changes you might experience. +
+Migration guide + +Starting in v4.2, tldraw bundles TipTap v3 as a transitive dependency. If your project pins `@tiptap/*` v2 packages directly, upgrade them to v3 *before* trying to fix any code-level errors — leaving v2 in place produces a dual install that surfaces as confusing type errors. + +**Dual-install symptom.** Deeply-nested errors like *"Property 'getCharacterCount' is missing in type 'Editor' but required in type 'Editor'"*, with the two `Editor` types resolving to different paths (one under `node_modules/@tiptap/core`, the other under `node_modules/@tldraw/editor/node_modules/@tiptap/core`), are not API changes — they are the v2/v3 dual install. The `Editor` classes are structurally similar but nominally different. Finishing the v3 upgrade collapses the duplicate and these errors disappear without code changes. + +**Code-level changes that affect tldraw users:** + +- **Default → named exports.** Several extensions changed from default to named exports: + + ```ts + // Before + import StarterKit from '@tiptap/starter-kit' + + // After + import { StarterKit } from '@tiptap/starter-kit' + ``` + +- **`TextStyle` → `TextStyleKit`, and `FontFamily` moved.** The `FontFamily` extension is no longer in `@tiptap/extension-font-family`; it's part of `TextStyleKit` in `@tiptap/extension-text-style`. The `setFontFamily` and `setFontSize` chain commands only exist on `TextStyleKit`, not on bare `TextStyle`. + +- **Transaction handler types.** Inline parameter type annotations on TipTap event handlers no longer work. Import the proper event-map type: + + ```ts + import { EditorEvents } from '@tiptap/core' + // ... + onTransaction: (props: EditorEvents['transaction']) => { /* ... */ } + ``` + +- **Custom chained commands** still register via `declare module '@tiptap/core'` augmentation in v3 — the augmentation target is the same. + +
+ ## API changes - Add `Editor.setTool`/`Editor.removeTool` for dynamically altering the editor's tool state chart. ([#6909](https://github.com/tldraw/tldraw/pull/6909)) ([#7134](https://github.com/tldraw/tldraw/pull/7134)) diff --git a/apps/docs/content/releases/v4.3.0.mdx b/apps/docs/content/releases/v4.3.0.mdx index d21c6e72266e..346c4fdb2c9d 100644 --- a/apps/docs/content/releases/v4.3.0.mdx +++ b/apps/docs/content/releases/v4.3.0.mdx @@ -13,7 +13,7 @@ This release introduces several significant changes: a new pattern for defining --- -### New pattern for defining custom shape/binding types (breaking change) ([#7091](https://github.com/tldraw/tldraw/pull/7091)) +### 💥 New pattern for defining custom shape/binding types ([#7091](https://github.com/tldraw/tldraw/pull/7091)) We've improved the developer experience of working with custom shape and binding types. There's now less boilerplate and fewer gotchas when using tldraw APIs in a type-safe manner. @@ -61,7 +61,50 @@ editor.createShape({ type: 'my-shape', props: { w: 100, h: 100, text: 'Hello' } editor.createShape({ type: 'my-shape', props: { w: 100, h: 100, text: 123 } }) ``` -The same pattern applies to custom bindings. See the [Custom Shapes Guide](https://tldraw.dev/docs/shapes#custom-shapes) and the [Pin Bindings example](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/pin-bindings) for details. +The same pattern applies to custom bindings — augment `TLGlobalBindingPropsMap` and use `TLBinding<'binding-name'>`: + +```ts +declare module 'tldraw' { + export interface TLGlobalBindingPropsMap { + 'my-binding': { offset: number } + } +} + +type MyBinding = TLBinding`<'my-binding'>` +``` + +See the [Custom Shapes Guide](https://tldraw.dev/docs/shapes#custom-shapes) and the [Pin Bindings example](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/shapes/tools/pin-bindings) for details. + +**After registering, remove the `TLBaseShape` import** — it's no longer used and will trip lint rules that flag unused imports. + +**Shape type names are global.** `TLGlobalShapePropsMap` is a single shared registry, so two custom shapes that previously used the same type name in different files now collide. Pick a unique name for each, and remember the rename ripples beyond the type alias: + +- `static override type = '...'` on the `ShapeUtil` subclass +- Every `editor.createShape({ type: '...' })` and `editor.updateShape({ type: '...' })` call site +- The `TLGlobalShapePropsMap` entry itself +- Any persisted snapshot or migration JSON that references the old name + +**Use `as const` on the static `type` field.** TypeScript may widen `static override type = 'my-shape'` to `string`, which fails the new constraint. Add `as const` to keep it a string literal: + +```ts +class MyShapeUtil extends ShapeUtil`` { + static override type = 'my-shape' as const + // ... +} +``` + +The same fix applies to `BaseBoxShapeTool` (and other tool) subclasses, which expose `static override shapeType = '...'`. If you see TS2416 on a tool's `shapeType`, add `as const` there too. The two fields are easy to confuse — check both. + +**Heterogeneous `createShapes`/`updateShapes` arrays may need a cast.** Because `TLShape` is now a discriminated union over the global props map, a mapped array of mixed shape types no longer narrows automatically. For homogeneous arrays where every item shares one shape type, `as const` on the `type` field in the mapped object literal is enough. For genuinely heterogeneous arrays, go straight to `as TLShapePartial[]` (or `as TLCreateShapePartial[]` for `createShapes`): + +```ts +// Heterogeneous update — the union doesn't narrow +editor.updateShapes( + shapes.map((s) => ({ id: s.id, type: s.type, x: s.x + 10 })) as TLShapePartial[] +) +``` + +Don't reach for `satisfies TLShapePartial` here — `TLShapePartial` is distributive over the `TLShape` union, so `satisfies` enforces a literal-per-variant constraint that mapped-over heterogeneous shapes can't meet. The cast is the right tool for this specific shape of code. diff --git a/skills/tldraw-migrate/SKILL.md b/skills/tldraw-migrate/SKILL.md index 307fd4562682..b1c7bbf580de 100644 --- a/skills/tldraw-migrate/SKILL.md +++ b/skills/tldraw-migrate/SKILL.md @@ -1,7 +1,7 @@ --- name: tldraw-migrate description: Migrate a project to a newer version of the tldraw SDK. Use when upgrading tldraw packages, fixing TypeScript errors after a tldraw upgrade, or when the user mentions tldraw migration. -argument-hint: '[previous-version]' +argument-hint: '[from-version] [target]' disable-model-invocation: true user-invocable: true --- @@ -10,16 +10,33 @@ user-invocable: true You are helping migrate a project to a newer version of the tldraw SDK. Follow this process carefully. -Previous version (auto-detected from git history; pass an explicit version as `/tldraw-migrate ` to override): !`node ${CLAUDE_SKILL_DIR}/detect-versions.mjs $ARGUMENTS` +Throughout this skill, `${SKILL_DIR}` refers to this skill's own directory (where `SKILL.md`, the helper `.mjs` scripts, and the cached `references/` folder live). The auto-fetch blocks below resolve it from the `SKILL_DIR` env var if set, otherwise probe the common skill locations (`.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, `skills/`). When you run shell commands later in the workflow that reference `${SKILL_DIR}`, substitute the same absolute path. + +**Arguments**: `/tldraw-migrate [from-version] [target]`. Both optional. `from-version` defaults to the previous tldraw version detected from git history. `target` defaults to `latest` (the latest stable release on npm); pass a dist-tag (`canary`, `next`, `beta`) or a pre-release semver (e.g. `4.6.0-canary.abc123`) to migrate to a pre-release. + +Resolved migration: !`SKILL_DIR="${SKILL_DIR:-$(for d in .claude/skills/tldraw-migrate .agents/skills/tldraw-migrate .cursor/skills/tldraw-migrate skills/tldraw-migrate; do [ -d "$d" ] && printf %s "$d" && break; done)}" && FROM=$(node "$SKILL_DIR/detect-versions.mjs" $ARGUMENTS) && TARGET=$(node "$SKILL_DIR/detect-target.mjs" $ARGUMENTS) && echo "from $FROM → target $TARGET"` ## Resources (auto-fetched on invocation) -!`mkdir -p ${CLAUDE_SKILL_DIR}/references && CHANGELOG=${CLAUDE_SKILL_DIR}/references/tldraw-releases.txt && PREV=$(node ${CLAUDE_SKILL_DIR}/detect-versions.mjs $ARGUMENTS) && curl --fail -sS https://tldraw.dev/llms-releases.txt | node ${CLAUDE_SKILL_DIR}/filter-changelog.mjs "$PREV" > "$CHANGELOG" && echo "Saved changelog (from $PREV) to $CHANGELOG ($(wc -l < "$CHANGELOG") lines)"` +!`SKILL_DIR="${SKILL_DIR:-$(for d in .claude/skills/tldraw-migrate .agents/skills/tldraw-migrate .cursor/skills/tldraw-migrate skills/tldraw-migrate; do [ -d "$d" ] && printf %s "$d" && break; done)}" && mkdir -p "$SKILL_DIR/references" && CHANGELOG="$SKILL_DIR/references/tldraw-releases.txt" && PREV=$(node "$SKILL_DIR/detect-versions.mjs" $ARGUMENTS) && [ -n "$PREV" ] && curl --fail -sS https://tldraw.dev/llms-releases.txt | node "$SKILL_DIR/filter-changelog.mjs" "$PREV" > "$CHANGELOG" && echo "Saved changelog (from $PREV) to $CHANGELOG ($(wc -l < "$CHANGELOG") lines)"` -!`DOCS=${CLAUDE_SKILL_DIR}/references/tldraw-full-docs.txt && if [ -s "$DOCS" ]; then echo "Using cached full docs at $DOCS ($(wc -l < "$DOCS") lines) — delete the file to refresh"; else curl --fail -sS https://tldraw.dev/llms-full.txt -o "$DOCS" && echo "Saved full docs to $DOCS ($(wc -l < "$DOCS") lines)"; fi` +!`SKILL_DIR="${SKILL_DIR:-$(for d in .claude/skills/tldraw-migrate .agents/skills/tldraw-migrate .cursor/skills/tldraw-migrate skills/tldraw-migrate; do [ -d "$d" ] && printf %s "$d" && break; done)}" && DOCS="$SKILL_DIR/references/tldraw-full-docs.txt" && if [ -s "$DOCS" ]; then echo "Using cached full docs at $DOCS ($(wc -l < "$DOCS") lines) — delete the file to refresh"; else curl --fail -sS https://tldraw.dev/llms-full.txt -o "$DOCS" && echo "Saved full docs to $DOCS ($(wc -l < "$DOCS") lines)"; fi` -- **[Filtered changelog](references/tldraw-releases.txt)** — release notes for versions between the previous version and now. Read this first to understand what changed. +!`SKILL_DIR="${SKILL_DIR:-$(for d in .claude/skills/tldraw-migrate .agents/skills/tldraw-migrate .cursor/skills/tldraw-migrate skills/tldraw-migrate; do [ -d "$d" ] && printf %s "$d" && break; done)}" && mkdir -p "$SKILL_DIR/references" && TARGET=$(node "$SKILL_DIR/detect-target.mjs" $ARGUMENTS) && case "$TARGET" in canary|next|beta|alpha|rc|*-canary*|*-next*|*-beta*|*-alpha*|*-rc*) NEXT="$SKILL_DIR/references/tldraw-next.mdx" && curl --fail -sS https://raw.githubusercontent.com/tldraw/tldraw/main/apps/docs/content/releases/next.mdx -o "$NEXT" && echo "Pre-release target ($TARGET) — saved next-release notes to $NEXT ($(wc -l < "$NEXT") lines)" ;; *) echo "Stable target ($TARGET) — skipping next-release notes" ;; esac` + +- **[Filtered changelog](references/tldraw-releases.txt)** — release notes for stable versions between the previous version and now. Each breaking change (marked `💥`) carries a `
Migration guide` block with a before/after recipe. **These migration blocks are the primary source for version-specific fixes** — this skill intentionally does not duplicate them. When you hit a TS error, grep for the relevant API name in this file and read its migration block. - **[Full docs](references/tldraw-full-docs.txt)** — complete tldraw SDK docs (~1.5MB). Do NOT read this upfront. Use Grep or Read with line ranges to search for specific topics as needed (e.g., custom shapes, TLTextOptions). +- **[Next-release notes](references/tldraw-next.mdx)** — *only present when the target is a pre-release.* The in-progress release notes (raw MDX from `main`) for the upcoming version. Same `
Migration guide` structure. The stable changelog won't cover canary/next deltas; this file is where you'll find them. + +**Searching for migration recipes:** + +```sh +# List every breaking change with a migration block +grep -nE '💥|Migration guide' ${SKILL_DIR}/references/tldraw-next.mdx + +# Find the migration recipe for a specific symbol +grep -n -B2 -A20 'getIndicatorPath\|TLUserStore\|EmbedShapeUtil' ${SKILL_DIR}/references/tldraw-next.mdx +``` ## Step 1: Understand the environment @@ -27,87 +44,100 @@ Before making any changes, scan the project to understand what you're working wi - **Package manager**: Check for lock files (`yarn.lock`, `pnpm-lock.yaml`, `bun.lockb`, `package-lock.json`). Use the corresponding tool throughout. - **tldraw packages**: `grep -E "tldraw|@tldraw" package.json` — which packages are installed, and at what versions? Note: not every project uses all tldraw packages. -- **Import style**: `grep -r "from '@tldraw/" src/ --include="*.ts" --include="*.tsx" -l | head -5` — does the project import from `'tldraw'`, `'@tldraw/editor'`, or both? This affects module augmentation targets. -- **TypeScript**: Check `package.json` for a `typecheck` or `tsc` script. Also check the TypeScript version — is it a direct dependency or does the project rely on a global install? +- **Source directory**: figure out where the project's TypeScript sources live (commonly `src/`, `app/`, or `lib/`; Next.js App Router projects don't have `src/`). Use this directory for every grep below — don't assume `src/`. +- **Import style**: `grep -r "from '@tldraw" --include="*.ts" --include="*.tsx" -l | head -5` — does the project import from `'tldraw'`, `'@tldraw/editor'`, or both? This affects module augmentation targets. +- **TypeScript**: Check `package.json` for a `typecheck` or `tsc` script. If neither exists, fall back to `npx tsc --noEmit` (TypeScript is usually a devDependency). Also check the TypeScript version. - **Build tool**: Check `package.json` scripts for the build command (vite, next, webpack, esbuild, etc.) - **Linter**: Check for eslint/biome config files (`.eslintrc*`, `eslint.config.*`, `biome.json`). A linter may help catch deprecations later. - **Monorepo**: Is `package.json` at the working directory root, or is this a nested package? Check for workspaces config. ## Step 2: Upgrade packages -Using the detected package manager, upgrade all tldraw packages that are already in the project's dependencies to the latest version. Don't add new packages the project doesn't already use. +Using the detected package manager, upgrade all tldraw packages that are already in the project's dependencies to the resolved target (printed in the "Resolved migration" line above). Don't add new packages the project doesn't already use. + +- For a stable target (`latest` or a stable semver): install at that tag/version, e.g. `yarn add tldraw@latest` or `npm install tldraw@4.5.10`. +- For a pre-release target (`canary`, `next`, `beta`, or a pre-release semver): install at that tag/version, e.g. `yarn add tldraw@canary`. If multiple `@tldraw/*` packages are listed, pin them all to the same target to avoid version skew. ## Step 3: Identify all TypeScript errors Run the project's typecheck command (from Step 1) and categorize the errors: -1. **Count errors by TS code** (e.g., TS2344, TS2786) to understand the distribution -2. **List unique files with errors** to understand the scope -3. **Identify error patterns** — common categories in tldraw upgrades: - - React types mismatch (TS2786 "cannot be used as JSX component") — usually means `@types/react` version doesn't match what tldraw bundles. To find the bundled version: with npm or yarn classic, check `node_modules/@tldraw/editor/node_modules/@types/react/package.json`; with pnpm or yarn berry, run the package manager's "why" command (e.g. `pnpm why @types/react`, `yarn why @types/react`) to see the resolved range. - - Custom shape type registration (TS2344 "does not satisfy constraint TLShape/TLBaseBoxShape") — tldraw v4.3+ requires module augmentation of `TLGlobalShapePropsMap` - - Custom binding type registration — same pattern with `TLGlobalBindingPropsMap` - - `BaseBoxShapeTool.shapeType` type mismatch (TS2416) — needs `as const` - - API renames/removals (TS2305, TS2724) — check changelog for specific migrations - - `createShapes`/`updateShapes` type widening (TS2345) — mapped arrays need `TLShapePartial` or `TLCreateShapePartial` casts +1. **Count errors by TS code** (e.g., TS2344, TS2786) to understand the distribution. +2. **List unique files with errors** to understand the scope. +3. **Identify error patterns.** The categories below are version-agnostic — they describe the *shape* of common errors. The *specific* fix for each rename, removal, or signature change lives in the release-notes migration blocks (see "Searching for migration recipes" above), not in this skill. + + - **TS2786** "cannot be used as JSX component" / "bigint not assignable to ReactNode": React types skew. `@types/react` (and usually `@types/react-dom`) don't match the version tldraw bundles. To find the bundled version: with npm or yarn classic, check `node_modules/@tldraw/editor/node_modules/@types/react/package.json`; with pnpm or yarn berry, run the package manager's "why" command (e.g. `pnpm why @types/react`). + - **TS2344** "does not satisfy constraint TLShape/TLBaseBoxShape" / **TS2416** `shapeType` mismatch: custom shape or binding type isn't registered for the new global props maps. Fix pattern in 4b. + - **TS2305** / **TS2724** "has no exported member" / "is not exported": API removed or renamed. Find the replacement in the matching `Migration guide` block in `tldraw-releases.txt` / `tldraw-next.mdx`. + - **TS2339** "property does not exist": API renamed or method signature changed. Same lookup. + - **TS2515** "non-abstract class does not implement inherited abstract member": a base class gained a required method. The error names the method — search the migration blocks for that method name. (Note: if your implementation only throws, TypeScript may infer `never`; add an explicit return type to satisfy the base class signature.) + - **TS2345** on `createShapes`/`updateShapes`: heterogeneous mapped arrays need `as TLShapePartial[]` / `as TLCreateShapePartial[]` casts. See 4e. + - **TS2345** with two structurally-similar `Editor` classes resolving to different paths under `node_modules/@tiptap/core` and `node_modules/@tldraw/editor/node_modules/@tiptap/core`: TipTap v2/v3 dual install. See 4d. ## Step 4: Fix errors in order of impact -Fix in this order (each fix eliminates many downstream errors): +Fix in this order (each fix eliminates many downstream errors). After each sub-step, re-run the project's typecheck and confirm that the error codes targeted by *that* sub-step are resolved (or at least decreasing). Ignore unrelated error codes — they belong to later sub-steps. This catches regressions early without blocking on errors you haven't gotten to yet. ### 4a. Fix React types -If you see TS2786 "bigint not assignable to ReactNode" errors, upgrade `@types/react` and `@types/react-dom` to match tldraw's bundled version. +If you see TS2786 "bigint not assignable to ReactNode" errors, upgrade `@types/react` AND `@types/react-dom` together to match tldraw's bundled version. Bumping only `@types/react` will leave a transitive dependency on the old `@types/react-dom` and the same errors will reappear from a different path (e.g. inside `TldrawUiToolbarButton`). + +**Verify**: re-run typecheck — TS2786 errors should be gone. ### 4b. Register custom shapes and bindings -For every `TLBaseShape<'name', Props>` in the codebase, add module augmentation. Use the import style detected in Step 1 as the module target: - -```ts -declare module 'tldraw' { - interface TLGlobalShapePropsMap { - 'shape-name': { - /* props */ - } - } -} -type MyShape = TLShape<'shape-name'> +If you see TS2344 ("does not satisfy constraint `TLShape`/`TLBaseBoxShape`") or TS2416 (`shapeType` mismatch) errors, the project is using the pre-v4.3 `TLBaseShape<'name', Props>` pattern and needs to migrate to `TLGlobalShapePropsMap` module augmentation. + +The full recipe — module augmentation for shapes and bindings, the rename ripple when shape names collide, `as const` on `static override type` / `static override shapeType`, and the heterogeneous `createShapes`/`updateShapes` cast guidance — lives in the v4.3 release notes migration block. Find it with: + +```sh +grep -n -B2 -A80 'TLGlobalShapePropsMap' ${SKILL_DIR}/references/tldraw-releases.txt ``` -Same pattern for bindings with `TLGlobalBindingPropsMap` and `TLBinding<'name'>`. +Apply that recipe across the project. Use the import style detected in Step 1 as the module-augmentation target (`declare module 'tldraw'` vs. `declare module '@tldraw/editor'`). -**IMPORTANT**: If multiple files use the same shape type name with different props, rename them to be unique — they all share the global type registry. +**Verify**: re-run typecheck — TS2344 and TS2416 errors should be gone. -Add `as const` to all `static override shapeType = '...'` properties. +### 4c. Fix API renames, removals, and abstract-method additions -### 4c. Fix API renames and removals +This is where the version-specific work happens, and it's driven entirely by the release-notes migration blocks. The skill does *not* enumerate which APIs changed — that's the changelog's job, and it would go stale on every release. -Cross-reference each TS2305/TS2724/TS2339 error with the changelog. Common patterns: +For each TS2305 / TS2724 / TS2339 / TS2515 error: -- Removed exports: find replacement by grepping the tldraw type definitions -- Renamed properties: check changelog for the new name -- Changed method signatures: check the current type definitions +1. Pull the symbol name out of the error. +2. Grep the migration blocks for it: `grep -n -B2 -A20 'SymbolName' ${SKILL_DIR}/references/tldraw-releases.txt ${SKILL_DIR}/references/tldraw-next.mdx`. +3. Apply the recipe shown in the matching `Migration guide` block. Migration blocks contain before/after code snippets; copy the structure. -To find tldraw type definitions, check which paths exist — the location varies by version: +If the symbol isn't in any migration block: -- `node_modules/tldraw/dist-cjs/index.d.ts` -- `node_modules/tldraw/dist/index.d.ts` -- `node_modules/@tldraw/editor/dist-cjs/index.d.ts` -- `node_modules/@tldraw/tlschema/dist-cjs/index.d.ts` +- **Demoted to `@internal`** (still exported at runtime, but missing from `.d.ts`): check whether a `
Migration guide` mentions it as part of a larger API. The right fix is almost always to switch to the public replacement, *not* to use module augmentation to re-expose the symbol. If you reach for `declare module 'tldraw' { export function X(): ... }`, stop — find the public replacement instead. +- **Truly unmentioned**: check the type defs in `node_modules/tldraw/dist-cjs/index.d.ts`, `node_modules/@tldraw/editor/dist-cjs/index.d.ts`, or `node_modules/@tldraw/tlschema/dist-cjs/index.d.ts` (the layout varies by version and package manager). If the symbol is genuinely gone with no listed replacement, treat the gap as a documentation bug worth flagging in your final report. + +For TS2515 (newly-required abstract method): if your implementation only throws, declare the return type explicitly so TypeScript doesn't infer `never` and the abstract-mismatch error doesn't linger. + +**Verify**: re-run typecheck — count TS2305, TS2724, TS2339, and TS2515 errors before and after. Each fix should knock out one error. If counts haven't dropped, you missed a migration block — re-grep before continuing. ### 4d. Fix TipTap imports if needed -If the project uses TipTap (`@tiptap/*` in dependencies or imports), the tldraw upgrade may require upgrading TipTap as well. Starting with tldraw v4.2, tldraw bundles TipTap v3 as a transitive dependency. If the project pins TipTap v2 packages directly, this will cause version conflicts and broken imports. **Upgrade the project's direct `@tiptap/*` dependencies to v3** to match what tldraw expects — leaving them on v2 will not work. +**Skip this entire sub-step if the project has no `@tiptap/*` dependencies or imports.** Confirm with `grep -E '@tiptap/' package.json` and a recursive grep for `from '@tiptap` in the source directory. If both come back empty, jump to 4e. -After upgrading, fix any breaking TipTap v2 → v3 changes: +If the project uses TipTap, your migration may need to cross the v2 → v3 cutover (introduced in tldraw v4.2). The full v2 → v3 recipe — dual-install diagnostic, default-to-named export changes, `TextStyle`/`TextStyleKit`/`FontFamily` reorganization, transaction-handler types — lives in the v4.2 release notes migration block. Find it with: -- **Default → named exports**: If a default import fails, switch to a named import: `import StarterKit from '@tiptap/starter-kit'` → `import { StarterKit } from '@tiptap/starter-kit'` -- **Renames**: If a named import fails, check the package's actual exports: `grep 'export' node_modules/@tiptap//dist/index.js | head` — some extensions were renamed in TipTap v3 (e.g., `TextStyle` → `TextStyleKit`). -- **Transaction handler types**: If TipTap event handler types break, import the proper types: `import { EditorEvents } from '@tiptap/core'` and type handlers as `(props: EditorEvents['transaction']) => void` rather than inline type annotations. +```sh +grep -n -B2 -A60 'TipTap v3' ${SKILL_DIR}/references/tldraw-releases.txt +``` + +Apply that recipe. The tldraw skill only adds two version-agnostic notes on top: + +> **Install ordering trap (any TipTap upgrade).** Running `npm install @tiptap/core@3 @tiptap/starter-kit@3 ...` against a project that already has v2 in `node_modules` will fail with `ERESOLVE`, because the v2 `starter-kit` declares `peer @tiptap/core@^2.7`. Either uninstall the v2 packages first (`npm uninstall @tiptap/core @tiptap/starter-kit ...`) or pass `--legacy-peer-deps`. + +> **Custom chained commands.** Whatever TipTap version, custom chain commands register via `declare module '@tiptap/core'` augmentation. This is a TipTap idiom, not a tldraw one — see TipTap's docs. + +**Verify**: re-run typecheck — TipTap import and type errors should be gone. ### 4e. Fix remaining type errors -- `createShapes`/`updateShapes` with `.map()`: The proper fix is to add `as const` to the `type` field in the mapped object literal so TypeScript narrows it to the string literal type. If that's not sufficient, use a `satisfies` annotation on the return value. Only as a last resort, cast the result to `TLCreateShapePartial` or `TLShapePartial`. +- `createShapes`/`updateShapes` with `.map()`: see the v4.3 migration block for the full recipe (`as const` on the `type` field for homogeneous arrays; `as TLShapePartial[]` / `as TLCreateShapePartial[]` for heterogeneous ones; *not* `satisfies TLShapePartial`). - TipTap extension commands not on `ChainedCommands`: use `declare module '@tiptap/core'` augmentation to register custom commands. - **General rule**: every `as` cast you add is tech debt. Before adding one, exhaust these alternatives in order: 1. `as const` on object literals to narrow string literal types @@ -116,15 +146,37 @@ After upgrading, fix any breaking TipTap v2 → v3 changes: 4. Module augmentation to teach TypeScript about your types 5. Only then, a targeted `as` cast with a comment explaining why it's needed +**Verify**: re-run typecheck — remaining errors should all be resolved. If any remain, re-categorize and route them back to 4a–4d as appropriate. + ## Step 5: Fix deprecations -After all type errors are resolved, find and fix deprecated API usage. These still compile but should be migrated. +After all type errors are resolved, find and fix `@deprecated` API usage. These still compile but should be migrated. + +1. **Find deprecated symbols.** The changelog is the most reliable starting point — tldraw's `.d.ts` files use multi-line JSDoc, so grepping the type defs is fragile (the declaration line is typically 2–5 lines after the `@deprecated` tag, not adjacent to it). + + Start here: -1. **Find deprecated symbols** — grep the tldraw type definitions for `@deprecated` annotations. Also search the changelog for "deprecated" in `${CLAUDE_SKILL_DIR}/references/tldraw-releases.txt`. + ```sh + grep -i 'deprecated' ${SKILL_DIR}/references/tldraw-releases.txt + ``` -2. **Check if a linter is configured** — look for eslint, biome, or other linting config in the project. If available, run the linter — it may flag deprecated usage automatically (e.g., eslint's `deprecation/deprecation` rule). If no linter is configured, skip to step 3. + When the target is a pre-release, also: `grep -i 'deprecated' ${SKILL_DIR}/references/tldraw-next.mdx`. -3. **Search the project source** for each deprecated symbol found in step 1. Replace with the recommended alternative from the `@deprecated` JSDoc comment or changelog. + To cross-check against the type defs (one package at a time — repeat for each `@tldraw/*` package the project imports from, and `tldraw` itself), use `-A` not `-B`: + + ```sh + grep -A5 '@deprecated' node_modules/@tldraw/editor/dist-cjs/index.d.ts \ + | grep -oE '\b(class|function|interface|const|type|let|var)\s+\w+' \ + | awk '{print $NF}' | sort -u + ``` + + This catches multi-line JSDoc but produces some false positives — treat the output as a candidate list, not an authoritative one. The changelog grep is what you should drive from. + +2. **Run the linter if configured** — eslint's `deprecation/deprecation` rule will flag deprecated imports automatically. If no linter is configured, skip this step. + +3. **Search the project source** for each deprecated symbol. Replace with the recommended alternative from the `@deprecated` JSDoc comment, the changelog entry, or the docs. + +4. **Sanity-check renames the typecheck didn't catch.** If the changelog or `tldraw-next.mdx` describes a rename and the project still imports the old name, TypeScript may resolve it through a wildcard re-export and miss it. Skim the changelog/next.mdx for "renamed" / "moved to" entries and grep the source for any old names you find. ## Step 6: Verify @@ -136,9 +188,18 @@ If errors remain, repeat the categorize-and-fix cycle. After all errors are resolved, do a quick audit: -1. **Count `as` casts before vs after**: Run `grep -rn ' as ' --include='*.ts' --include='*.tsx' src/` and compare against the same grep on the pre-migration code (use `git stash` or `git show HEAD:path`). **The migration should add no more than a small handful of new casts** (ideally zero). If you added more than ~5 new `as` casts across the entire migration, go back and fix them — you are almost certainly using the new API incorrectly. -2. **Review every cast you added**: For each new `as` cast, verify it's truly necessary by checking whether `as const`, `satisfies`, generic type parameters, or module augmentation could replace it. Remove or replace any that have a cleaner alternative. -3. **Verify no stubs or dead code**: If you stubbed out removed APIs (e.g., replaced a removed function with a no-op), make sure the calling code doesn't depend on the return value. If it does, find the proper replacement in the changelog or docs. +1. **Count typed `as` casts added by this migration.** A naive `grep ' as '` overcounts because it includes `as const` (the recommended narrowing pattern) and `as` in import paths. Drive off the diff and filter: + + ```sh + git diff -- '/**/*.ts' '/**/*.tsx' \ + | grep -E '^\+' | grep -v '^+++' \ + | grep -E '\bas\s+[A-Z]' | grep -v 'as const' + ``` + + **The migration should add no more than a small handful of new typed casts** (ideally zero, with `as TLShapePartial[]` / `as TLCreateShapePartial[]` for heterogeneous `createShapes`/`updateShapes` arrays as the main legitimate exception). If you added more than ~5 across the whole migration, go back and fix them — you are almost certainly using the new API incorrectly. +2. **Review every typed cast you added**: For each, verify it's truly necessary by checking whether `as const`, `satisfies`, generic type parameters, or module augmentation could replace it. Remove or replace any that have a cleaner alternative. +3. **Audit module augmentations.** Module augmentation is correct for `TLGlobalShapePropsMap` / `TLGlobalBindingPropsMap` registration and for adding genuinely-missing public types. It is **not** correct for re-exposing symbols that the SDK demoted to `@internal` — that's a workaround, not a fix. Find the public replacement instead (the migration block usually names it). +4. **Verify no stubs or dead code**: If you stubbed out removed APIs (e.g., replaced a removed function with a no-op), make sure the calling code doesn't depend on the return value. If it does, find the proper replacement in the migration blocks or docs. ## Quality checks @@ -146,10 +207,10 @@ After all errors are resolved, do a quick audit: - **Prefer TypeScript's narrowing features over casts.** Use `as const` for literal types, `satisfies` for type-checking without widening, generic parameters for call sites, and module augmentation for extending interfaces. These are the right tools for a migration — `as` casts are not. - Use parallel agents for fixing large batches of files with the same pattern. - **Don't just make errors go away — understand the new API.** When a method signature changes (e.g., new parameters added, property renamed to a richer type), read the changelog AND the current type definitions to understand _why_ it changed. A fix that compiles but passes hardcoded/dummy values where the new API expects real data is worse than a type error — it silently degrades behavior. For example, if a function gains new required parameters, check what shape props or editor state should feed those parameters rather than passing `0` or `1`. -- **When unsure about an API pattern**, grep the full docs (`${CLAUDE_SKILL_DIR}/references/tldraw-full-docs.txt`) for usage examples of that specific API. The docs contain code samples that show the canonical way to use each API. +- **When unsure about an API pattern**, grep the full docs (`${SKILL_DIR}/references/tldraw-full-docs.txt`) for usage examples of that specific API. The docs contain code samples that show the canonical way to use each API. ## Tips -- Read `${CLAUDE_SKILL_DIR}/references/tldraw-releases.txt` for the filtered changelog — this is the primary source for what changed -- Grep `${CLAUDE_SKILL_DIR}/references/tldraw-full-docs.txt` when you need docs on a specific API +- Read `${SKILL_DIR}/references/tldraw-releases.txt` for the filtered changelog — this is the primary source for what changed +- Grep `${SKILL_DIR}/references/tldraw-full-docs.txt` when you need docs on a specific API - When searching tldraw types, try multiple paths — the dist directory structure varies by version and package manager diff --git a/skills/tldraw-migrate/detect-target.mjs b/skills/tldraw-migrate/detect-target.mjs new file mode 100644 index 000000000000..fc84e2f0ec99 --- /dev/null +++ b/skills/tldraw-migrate/detect-target.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +/** + * Resolves the migration *target* (what version/tag to upgrade TO) from + * the skill's $ARGUMENTS, and writes it to stdout. + * + * Argument forms supported: + * /tldraw-migrate → "latest" + * /tldraw-migrate 4.4.0 → "latest" (single semver = from-version) + * /tldraw-migrate canary → "canary" (single tag/pre-release = target) + * /tldraw-migrate 4.4.0 canary → "canary" (two args: ) + * /tldraw-migrate 4.4.0 4.6.0-canary.x → "4.6.0-canary.x" + * + * Outputs just the resolved target with no trailing newline so it can be + * interpolated directly in shell. + */ + +const args = process.argv + .slice(2) + .map((a) => a.trim()) + .filter(Boolean) + +const STABLE_SEMVER = /^v?\d+\.\d+\.\d+$/ + +function emit(target) { + process.stdout.write(target) + process.exit(0) +} + +if (args.length === 0) emit('latest') +if (args.length >= 2) emit(args[args.length - 1]) + +// Single arg: stable semver = from-version (target defaults), anything else = target. +const only = args[0] +emit(STABLE_SEMVER.test(only) ? 'latest' : only) diff --git a/skills/write-release-notes/SKILL.md b/skills/write-release-notes/SKILL.md index 94b6bb6caba8..bf4b97faed24 100644 --- a/skills/write-release-notes/SKILL.md +++ b/skills/write-release-notes/SKILL.md @@ -82,7 +82,46 @@ Create `apps/docs/content/releases/vX.Y.0.mdx` following the style guide. 5. Add patch release sections if applicable 6. Add GitHub release links -### 6. Verify +### 6. Write a migration guide for every breaking change + +Every `💥` in the article needs a migration recipe. The `tldraw-migrate` skill drives off these recipes — it intentionally does not duplicate them in its own SKILL.md, because version-specific knowledge belongs next to the breaking change that introduced it. If the recipe is missing, agents and contributors performing the upgrade have to reverse-engineer it from type defs. + +There are two acceptable forms: + +**For breaking changes with their own featured section** (renames, replaced APIs, new patterns), add a `
Migration guide` block under the section. Include before/after code and call out any silent-compile traps (props the typecheck won't reject, signatures with optional new parameters, etc.): + +````mdx +### 💥 Custom themes with display values + +[Description of what changed and why] + +
+Migration guide + +`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()] +``` + +
+```` + +**For one-line `💥` entries in the API changes list**, the bullet itself must contain the recipe — name the replacement and any caveats inline: + +- ✅ `💥 Replace TLDrawShapeSegment.points with the helper getPointsFromDrawSegment(segment, scaleX, scaleY) so segment points respect the shape's current scale.` +- ❌ `💥 Remove TLDrawShapeSegment.points.` (no replacement → reader has to guess) + +A symbol that is removed without a replacement is a documentation bug — find the public alternative or, if there genuinely isn't one, say so explicitly so readers know to drop the call site rather than searching for a rename. + +**Special case — `@public` → `@internal` demotions:** these compile but disappear from public types. They are still breaking changes for consumers who imported the symbol. Treat them like a removal: mark with `💥`, name the public replacement, and explicitly tell readers *not* to reach for module augmentation to re-expose the demoted symbol. + +### 7. Verify Check that: @@ -90,6 +129,7 @@ Check that: - PR links are correct and formatted properly - Community contributors are credited - Breaking changes are marked with 💥 +- **Every `💥` has either a migration guide block or an inline replacement** (run `grep -nE '💥' apps/docs/content/releases/.mdx` and verify each bullet/section) - Sections are in the correct order ## References From febe9b9e3ddbc1eaa69f9c4994ccf1e77011c6a3 Mon Sep 17 00:00:00 2001 From: angrycaptain19 <53473467+angrycaptain19@users.noreply.github.com> Date: Wed, 6 May 2026 12:09:03 +0100 Subject: [PATCH 09/11] docs: fixes pre 5.0 (#8580) I went through all of our docs pages to check for things that seemed to be out of date with changes from 4.0 -> now and which will have changed by 5.0. Mainly changing a few examples where methods have changed, but also fixes a few example code blocks that caused errors ### Change type - [ ] `bugfix` - [ ] `improvement` - [ ] `feature` - [ ] `api` - [x] `other` --------- Co-authored-by: Claude Opus 4.6 (1M context) --- apps/docs/content/docs/handles.mdx | 24 ++- apps/docs/content/docs/sync.mdx | 64 +++++++ apps/docs/content/sdk-features/assets.mdx | 84 ++++++++- apps/docs/content/sdk-features/bindings.mdx | 4 +- apps/docs/content/sdk-features/camera.mdx | 2 +- apps/docs/content/sdk-features/clipboard.mdx | 6 + apps/docs/content/sdk-features/deep-links.mdx | 8 +- .../content/sdk-features/default-shapes.mdx | 9 +- apps/docs/content/sdk-features/draw-shape.mdx | 28 +-- .../docs/content/sdk-features/embed-shape.mdx | 14 +- .../docs/content/sdk-features/environment.mdx | 22 +++ .../docs/content/sdk-features/frame-shape.mdx | 160 ++++++++++++++++-- apps/docs/content/sdk-features/geo-shape.mdx | 80 +++++++-- apps/docs/content/sdk-features/handles.mdx | 24 ++- .../content/sdk-features/image-export.mdx | 18 +- .../sdk-features/internationalization.mdx | 10 +- apps/docs/content/sdk-features/note-shape.mdx | 7 + apps/docs/content/sdk-features/options.mdx | 58 +++++-- .../docs/content/sdk-features/performance.mdx | 24 +++ .../content/sdk-features/shape-transforms.mdx | 2 +- apps/docs/content/sdk-features/shapes.mdx | 4 +- apps/docs/content/sdk-features/store.mdx | 109 ++++++++++++ apps/docs/content/sdk-features/styles.mdx | 3 +- apps/docs/content/sdk-features/themes.mdx | 4 +- .../content/sdk-features/ui-components.mdx | 1 + .../docs/content/starter-kits/multiplayer.mdx | 55 ++++-- .../configuration/camera-options/README.md | 2 +- 27 files changed, 703 insertions(+), 123 deletions(-) diff --git a/apps/docs/content/docs/handles.mdx b/apps/docs/content/docs/handles.mdx index d605ed6f7d14..76043a91c445 100644 --- a/apps/docs/content/docs/handles.mdx +++ b/apps/docs/content/docs/handles.mdx @@ -168,7 +168,15 @@ The `HandleSnapGeometry` object has these properties: Here's a speech bubble shape with a draggable tail handle: ```tsx -import { Polygon2d, ShapeUtil, TLHandle, TLHandleDragInfo, TLShape, ZERO_INDEX_KEY } from 'tldraw' +import { + Polygon2d, + ShapeUtil, + TLHandle, + TLHandleDragInfo, + TLShape, + Vec, + ZERO_INDEX_KEY, +} from 'tldraw' const SPEECH_BUBBLE_TYPE = 'speech-bubble' @@ -191,13 +199,13 @@ class SpeechBubbleUtil extends ShapeUtil { const { w, h, tailX, tailY } = shape.props return new Polygon2d({ points: [ - { x: 0, y: 0 }, - { x: w, y: 0 }, - { x: w, y: h }, - { x: w * 0.7, y: h }, - { x: tailX, y: tailY }, - { x: w * 0.3, y: h }, - { x: 0, y: h }, + new Vec(0, 0), + new Vec(w, 0), + new Vec(w, h), + new Vec(w * 0.7, h), + new Vec(tailX, tailY), + new Vec(w * 0.3, h), + new Vec(0, h), ], isFilled: true, }) diff --git a/apps/docs/content/docs/sync.mdx b/apps/docs/content/docs/sync.mdx index 05d279e6a4da..25ca943c8c7b 100644 --- a/apps/docs/content/docs/sync.mdx +++ b/apps/docs/content/docs/sync.mdx @@ -181,6 +181,70 @@ const room = new TLSocketRoom({ storage }) `tablePrefix` option to avoid conflicts if you're sharing a database with other data. +### WebSocket hibernation + +Some serverless platforms let WebSocket connections survive while the surrounding object hibernates. [Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/best-practices/websockets/) is the most common example. The platform keeps the sockets open, but your in-memory state (including the `TLSocketRoom`) is gone when the object wakes back up. Every wake would force every client to reconnect from scratch. + +`TLSocketRoom` exposes three APIs for hibernation. The `onSessionSnapshot` callback fires when a session has had no message activity for about 5 seconds, so you can persist its state. [TLSocketRoom#getSessionSnapshot](?) returns that snapshot on demand. [TLSocketRoom#handleSocketResume](?) restores a session straight into `Connected` state when the object wakes back up. + +Here's the pattern in a Cloudflare Durable Object that uses the WebSocket Hibernation API: + +```tsx +import { + DurableObjectSqliteSyncWrapper, + type SessionStateSnapshot, + SQLiteSyncStorage, + TLSocketRoom, +} from '@tldraw/sync-core' +import { TLRecord } from '@tldraw/tlschema' +import { DurableObject } from 'cloudflare:workers' + +interface SocketAttachment { + sessionId: string + snapshot?: SessionStateSnapshot +} + +export class TldrawDurableObject extends DurableObject { + private room: TLSocketRoom | null = null + + private getOrCreateRoom() { + if (this.room) return this.room + + const sql = new DurableObjectSqliteSyncWrapper(this.ctx.storage) + const storage = new SQLiteSyncStorage({ sql }) + + this.room = new TLSocketRoom({ + storage, + // Cloudflare keeps WebSockets alive across hibernation, so let it manage timeouts. + clientTimeout: Infinity, + // Persist each session's snapshot to its WebSocket attachment when it goes idle. + onSessionSnapshot: (sessionId, snapshot) => { + const ws = this.sessionIdToWs.get(sessionId) + if (ws) ws.serializeAttachment({ sessionId, snapshot }) + }, + }) + + // Resume any sessions whose sockets survived hibernation. + for (const ws of this.ctx.getWebSockets()) { + const attachment = ws.deserializeAttachment() as SocketAttachment | null + if (attachment?.snapshot) { + this.room.handleSocketResume({ + sessionId: attachment.sessionId, + socket: ws, + snapshot: attachment.snapshot, + }) + } + } + + return this.room + } +} +``` + +Three pieces make this work. The `onSessionSnapshot` callback persists each session's state to its WebSocket's attachment so the snapshot is still around after hibernation. When the object wakes up with sockets still open, `handleSocketResume` replays each saved snapshot and the session lands straight in `Connected` state without the client noticing. Setting `clientTimeout: Infinity` disables the room's idle timer; hibernating platforms handle keep-alive themselves and would otherwise see the room disconnect perfectly fine clients. + +For non-hibernating environments (Node servers, long-lived processes), you don't need any of this. The in-memory `TLSocketRoom` outlives individual sockets, and the default `clientTimeout` keeps idle sessions tidy. + ### Asset storage Tldraw also needs a way to store and retrieve large binary assets like images and videos. diff --git a/apps/docs/content/sdk-features/assets.mdx b/apps/docs/content/sdk-features/assets.mdx index 84b80f6133fc..2035232e8971 100644 --- a/apps/docs/content/sdk-features/assets.mdx +++ b/apps/docs/content/sdk-features/assets.mdx @@ -288,9 +288,89 @@ const assetStore: TLAssetStore = { ### Custom asset types -You can define custom asset types by extending [TLBaseAsset](?). Create a validator with [createAssetValidator](?), then implement shapes that reference your custom assets. +Custom asset types let you store domain-specific media alongside images, videos, and bookmarks. Each asset type has a corresponding [AssetUtil](?) that defines type-specific behavior: which MIME types it accepts, how to derive an asset record from a dropped file, and what default props new instances start with. The built-in `ImageAssetUtil`, `VideoAssetUtil`, and `BookmarkAssetUtil` follow this pattern and live in `defaultAssetUtils`. -Custom asset types follow the same storage lifecycle as built-in types. Your upload, resolve, and remove handlers need to support them, and your custom shapes handle the rendering. +`AssetUtil` is the asset-side counterpart to `ShapeUtil`. You register one util per type on the editor at startup, and the editor calls its methods whenever a file enters the system. + +Register your asset's props on `TLGlobalAssetPropsMap` via TypeScript module augmentation, then implement an `AssetUtil` for it: + +```typescript +import { AssetUtil, TLAsset, TLAssetId } from 'tldraw' + +const AUDIO_TYPE = 'audio' + +declare module 'tldraw' { + export interface TLGlobalAssetPropsMap { + [AUDIO_TYPE]: { + src: string | null + mimeType: string | null + name: string + } + } +} + +type TLAudioAsset = TLAsset + +class AudioAssetUtil extends AssetUtil { + static override type = AUDIO_TYPE + + override getDefaultProps(): TLAudioAsset['props'] { + return { src: null, mimeType: null, name: '' } + } + + override getSupportedMimeTypes() { + return ['audio/mpeg', 'audio/wav', 'audio/ogg'] + } + + override async getAssetFromFile(file: File, assetId: TLAssetId): Promise { + return { + id: assetId, + typeName: 'asset', + type: AUDIO_TYPE, + props: { + src: null, // populated by the asset store after upload + mimeType: file.type, + name: file.name, + }, + meta: {}, + } + } +} +``` + +Pass the util to the `` component alongside whichever defaults you still want: + +```tsx +import { Tldraw, defaultAssetUtils } from 'tldraw' + +const assetUtils = [...defaultAssetUtils, AudioAssetUtil] + +export default function App() { + return +} +``` + +When a file is dropped or pasted, the editor finds the first registered util whose `getSupportedMimeTypes()` includes the file's MIME type and calls its `getAssetFromFile()`. The returned asset record then flows through your `TLAssetStore.upload` handler, which assigns the final `src`. Custom shape utils that render audio (or whatever else you registered) read the resolved URL through `editor.resolveAssetUrl()` like the built-in shapes do. + +### Configuring built-in asset utils + +Use [AssetUtil#configure](?) to tweak options on a built-in util without subclassing it. For example, lock image uploads down to PNG: + +```tsx +import { ImageAssetUtil, defaultAssetUtils, Tldraw } from 'tldraw' + +const PngOnlyImageAssetUtil = ImageAssetUtil.configure({ + supportedMimeTypes: ['image/png'], +}) + +const assetUtils = defaultAssetUtils.map((util) => + util === ImageAssetUtil ? PngOnlyImageAssetUtil : util +) + + +``` + +`ImageAssetUtil` and `VideoAssetUtil` both expose `maxDimension` and `supportedMimeTypes` options. ### Asset validation and migrations diff --git a/apps/docs/content/sdk-features/bindings.mdx b/apps/docs/content/sdk-features/bindings.mdx index 6a94c20aaeda..47afc9c0ad1a 100644 --- a/apps/docs/content/sdk-features/bindings.mdx +++ b/apps/docs/content/sdk-features/bindings.mdx @@ -142,9 +142,9 @@ Shapes control whether they accept bindings by implementing `canBind()` in their ```typescript class MyShapeUtil extends ShapeUtil { - canBind({ fromShapeType, toShapeType, bindingType }: TLShapeUtilCanBindOpts) { + canBind({ fromShape, toShape, bindingType }: TLShapeUtilCanBindOpts) { // Only allow arrow bindings where this shape is the target - return bindingType === 'arrow' && toShapeType === this.type + return bindingType === 'arrow' && toShape.type === this.type } } ``` diff --git a/apps/docs/content/sdk-features/camera.mdx b/apps/docs/content/sdk-features/camera.mdx index 0c4bcf9af697..bf4e6ed304ae 100644 --- a/apps/docs/content/sdk-features/camera.mdx +++ b/apps/docs/content/sdk-features/camera.mdx @@ -147,7 +147,7 @@ Zoom in or out. Both methods accept an optional screen point to zoom toward: ```typescript editor.zoomIn() editor.zoomOut() -editor.zoomIn(editor.inputs.currentScreenPoint, { animation: { duration: 200 } }) +editor.zoomIn(editor.inputs.getCurrentScreenPoint(), { animation: { duration: 200 } }) ``` ### Zoom to content diff --git a/apps/docs/content/sdk-features/clipboard.mdx b/apps/docs/content/sdk-features/clipboard.mdx index b7e501402ce0..49364734a37f 100644 --- a/apps/docs/content/sdk-features/clipboard.mdx +++ b/apps/docs/content/sdk-features/clipboard.mdx @@ -81,6 +81,12 @@ This embeds images and videos directly in the clipboard data rather than relying Cut combines copy and delete. The editor first copies the selected shapes to the clipboard, then deletes the originals. This order ensures the clipboard has the data before shapes disappear, preventing data loss if the copy fails. +### Plain text paste + +`Cmd+Shift+V` (or `Ctrl+Shift+V` on Windows and Linux) pastes the clipboard as plain text. HTML and rich formatting are stripped. This is the standard "paste without formatting" shortcut. It's handy when styled text from a browser or word processor would otherwise bring its fonts and colors onto the canvas with it. + +To extend or override this behavior, use the `onClipboardPasteRaw` hook on [TldrawOptions](?). It fires before tldraw parses the clipboard, so you can read the raw `ClipboardEvent` data yourself. Return `false` to short-circuit the default pipeline, or `void` to let it continue. + ## Content structure The [TLContent](?) type defines the clipboard payload: diff --git a/apps/docs/content/sdk-features/deep-links.mdx b/apps/docs/content/sdk-features/deep-links.mdx index 70de4b71df46..44791acfb422 100644 --- a/apps/docs/content/sdk-features/deep-links.mdx +++ b/apps/docs/content/sdk-features/deep-links.mdx @@ -19,7 +19,7 @@ notes: '' Deep links serialize editor state into URL-safe strings. They let users share links that open the editor at specific locations: individual shapes, viewport positions, or entire pages. -The simplest way to enable deep links is with the `deepLinks` prop: +The simplest way to enable deep links is with the `deepLinks` option: ```tsx import { Tldraw } from 'tldraw' @@ -28,7 +28,7 @@ import 'tldraw/tldraw.css' export default function App() { return (
- +
) } @@ -117,8 +117,8 @@ const unlisten = editor.registerDeepLinkListener({ unlisten() ``` -You can also enable this via the `deepLinks` prop on the Tldraw component instead of calling this method directly. +You can also enable this via the `deepLinks` option on the Tldraw component instead of calling this method directly. ## Related examples -- **[Deep links](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/configuration/deep-links)** - Demonstrates how to use the `deepLinks` prop to enable URL-based navigation and how to create, parse, and handle deep links manually using the editor methods. +- **[Deep links](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/configuration/deep-links)** - Demonstrates how to use the `deepLinks` option to enable URL-based navigation and how to create, parse, and handle deep links manually using the editor methods. diff --git a/apps/docs/content/sdk-features/default-shapes.mdx b/apps/docs/content/sdk-features/default-shapes.mdx index 7231940be247..5dc0f7b6c610 100644 --- a/apps/docs/content/sdk-features/default-shapes.mdx +++ b/apps/docs/content/sdk-features/default-shapes.mdx @@ -225,13 +225,14 @@ Configuration options: | ------------------- | -------- | ------- | ------------------------------------------------------------------------- | | `maxPointsPerShape` | `number` | `600` | Maximum points before starting a new shape. Same behavior as draw shapes. | -The highlight's `underlayOpacity` (default `0.82`) and `overlayOpacity` (default `0.35`) are display values that can be overridden via `getCustomDisplayValues`. +The highlight's `underlayOpacity` (default `0.82`) and `overlayOpacity` (default `0.35`) are display values that can be overridden via `getCustomDisplayValues`: ```tsx const ConfiguredHighlightUtil = HighlightShapeUtil.configure({ maxPointsPerShape: 800, - underlayOpacity: 0.7, - overlayOpacity: 0.4, + getCustomDisplayValues() { + return { underlayOpacity: 0.7, overlayOpacity: 0.4 } + }, }) ``` @@ -424,6 +425,8 @@ const ConfiguredFrameUtil = FrameShapeUtil.configure({ }) ``` +To build your own container shape that behaves like a frame, extend `BaseFrameLikeShapeUtil` rather than re-implementing every behavior on `FrameShapeUtil`. The base class provides defaults for clipping children, full-brush selection, blocking erasure from inside, and drag-and-drop reparenting — see [Shapes](/sdk-features/shapes#frames) for details and the [portal shapes example](/examples/shapes/tools/portal-shapes) for a working implementation. + ### Group The group shape logically combines multiple shapes without visual representation. Groups let you move and transform shapes together while preserving their relative positions. The group's geometry is computed as the union of all child shapes' geometries. Groups are created through the editor API rather than directly, and they delete themselves automatically when their last child is removed or ungrouped. diff --git a/apps/docs/content/sdk-features/draw-shape.mdx b/apps/docs/content/sdk-features/draw-shape.mdx index 794959698481..0057a4e1ca70 100644 --- a/apps/docs/content/sdk-features/draw-shape.mdx +++ b/apps/docs/content/sdk-features/draw-shape.mdx @@ -108,26 +108,26 @@ Access dynamic resize mode through [Editor#user](?): const isDynamic = editor.user.getIsDynamicResizeMode() // Enable dynamic resize mode -editor.user.updateUserPreferences({ isDynamicResizeMode: true }) +editor.user.updateUserPreferences({ isDynamicSizeMode: true }) ``` ## Shape properties Draw shapes store their path data in an efficient delta-encoded base64 format. The first point uses full Float32 precision (12 bytes), with subsequent points stored as Float16 deltas (6 bytes each). -| Property | Type | Description | -| ------------ | ---------------------- | ---------------------------------------------------------- | -| `color` | `TLDefaultColorStyle` | Stroke color | -| `fill` | `TLDefaultFillStyle` | Fill style (applies when `isClosed` is true) | -| `dash` | `TLDefaultDashStyle` | Stroke pattern: `draw`, `solid`, `dashed`, `dotted` | -| `size` | `TLDefaultSizeStyle` | Stroke width preset: `s`, `m`, `l`, `xl` | -| `segments` | `TLDrawShapeSegment[]` | Array of segments with `type` and base64-encoded `path` | -| `isComplete` | `boolean` | Whether the user has finished drawing this stroke | -| `isClosed` | `boolean` | Whether the path forms a closed shape | -| `isPen` | `boolean` | Whether drawn with a stylus (enables pressure-based width) | -| `scale` | `number` | Scale factor applied to the shape | -| `scaleX` | `number` | Horizontal scale factor for lazy resize | -| `scaleY` | `number` | Vertical scale factor for lazy resize | +| Property | Type | Description | +| ------------ | ---------------------- | ----------------------------------------------------------- | +| `color` | `TLDefaultColorStyle` | Stroke color | +| `fill` | `TLDefaultFillStyle` | Fill style (applies when `isClosed` is true) | +| `dash` | `TLDefaultDashStyle` | Stroke pattern: `draw`, `solid`, `dashed`, `dotted`, `none` | +| `size` | `TLDefaultSizeStyle` | Stroke width preset: `s`, `m`, `l`, `xl` | +| `segments` | `TLDrawShapeSegment[]` | Array of segments with `type` and base64-encoded `path` | +| `isComplete` | `boolean` | Whether the user has finished drawing this stroke | +| `isClosed` | `boolean` | Whether the path forms a closed shape | +| `isPen` | `boolean` | Whether drawn with a stylus (enables pressure-based width) | +| `scale` | `number` | Scale factor applied to the shape | +| `scaleX` | `number` | Horizontal scale factor for lazy resize | +| `scaleY` | `number` | Vertical scale factor for lazy resize | Each segment has a `type` of `'free'` or `'straight'` and a `path` containing the encoded point data with x, y, and z (pressure) values. diff --git a/apps/docs/content/sdk-features/embed-shape.mdx b/apps/docs/content/sdk-features/embed-shape.mdx index 9aa154692fee..02ea33547ba0 100644 --- a/apps/docs/content/sdk-features/embed-shape.mdx +++ b/apps/docs/content/sdk-features/embed-shape.mdx @@ -37,6 +37,12 @@ editor.createShape({ The embed system recognizes URLs from supported services and converts them to their embeddable equivalents. A YouTube watch URL becomes an embed URL automatically. +### Pasting iframe code + +You can also paste raw `