diff --git a/apps/dotcom/package.json b/apps/dotcom/package.json
index c2cd34985..815710ae0 100644
--- a/apps/dotcom/package.json
+++ b/apps/dotcom/package.json
@@ -44,7 +44,7 @@
"@types/qrcode": "^1.5.0",
"@types/react": "^18.2.47",
"@typescript-eslint/utils": "^5.59.0",
- "@vitejs/plugin-react-swc": "^3.5.0",
+ "@vitejs/plugin-react-swc": "^3.6.0",
"dotenv": "^16.3.1",
"eslint": "^8.37.0",
"fast-glob": "^3.3.1",
diff --git a/apps/examples/package.json b/apps/examples/package.json
index 4fa778353..6c95141ab 100644
--- a/apps/examples/package.json
+++ b/apps/examples/package.json
@@ -50,7 +50,7 @@
},
"devDependencies": {
"@types/lodash": "^4.14.188",
- "@vitejs/plugin-react": "^4.2.0",
+ "@vitejs/plugin-react-swc": "^3.6.0",
"dotenv": "^16.3.1",
"remark": "^15.0.1",
"remark-frontmatter": "^5.0.0",
diff --git a/apps/examples/src/examples/camera-options/CameraOptionsExample.tsx b/apps/examples/src/examples/camera-options/CameraOptionsExample.tsx
index 91ab37139..91ef80ace 100644
--- a/apps/examples/src/examples/camera-options/CameraOptionsExample.tsx
+++ b/apps/examples/src/examples/camera-options/CameraOptionsExample.tsx
@@ -145,7 +145,7 @@ const CameraOptionsControlPanel = track(() => {
useEffect(() => {
if (!editor) return
editor.batch(() => {
- editor.setCameraOptions(cameraOptions, { immediate: true })
+ editor.setCameraOptions(cameraOptions)
editor.setCamera(editor.getCamera(), {
immediate: true,
})
diff --git a/apps/examples/src/examples/editor-focus/EditorFocusExample.tsx b/apps/examples/src/examples/editor-focus/EditorFocusExample.tsx
index 49b59c0f0..035d4a2a5 100644
--- a/apps/examples/src/examples/editor-focus/EditorFocusExample.tsx
+++ b/apps/examples/src/examples/editor-focus/EditorFocusExample.tsx
@@ -13,16 +13,26 @@ export default function EditorFocusExample() {
}, [focused])
return (
-
-
-
{
- setFocused(e.target.checked)
- }}
- />
-
+
{
+ const editor = rEditorRef.current
+ if (editor && editor.getInstanceState().isFocused) {
+ editor.updateInstanceState({ isFocused: false })
+ }
+ }}
+ >
+
The checkbox controls the editor's instanceState.isFocused
property.
@@ -39,6 +49,7 @@ export default function EditorFocusExample() {
}}
/>
+
)
}
diff --git a/apps/examples/src/examples/image-annotator/ImageAnnotationEditor.tsx b/apps/examples/src/examples/image-annotator/ImageAnnotationEditor.tsx
index 774e63474..854a4fe80 100644
--- a/apps/examples/src/examples/image-annotator/ImageAnnotationEditor.tsx
+++ b/apps/examples/src/examples/image-annotator/ImageAnnotationEditor.tsx
@@ -133,23 +133,21 @@ export function ImageAnnotationEditor({
* component hooks into camera updates to keep the camera constrained - try uploading a very long,
* thin image and seeing how the camera behaves.
*/
- editor.setCameraOptions(
- {
- constraints: {
- initialZoom: 'fit-max',
- baseZoom: 'default',
- bounds: { w: image.width, h: image.height, x: 0, y: 0 },
- padding: { x: 32, y: 64 },
- origin: { x: 0.5, y: 0.5 },
- behavior: 'contain',
- },
- zoomSteps: [1, 2, 4, 8],
- zoomSpeed: 1,
- panSpeed: 1,
- isLocked: false,
+ editor.setCameraOptions({
+ constraints: {
+ initialZoom: 'fit-max',
+ baseZoom: 'default',
+ bounds: { w: image.width, h: image.height, x: 0, y: 0 },
+ padding: { x: 32, y: 64 },
+ origin: { x: 0.5, y: 0.5 },
+ behavior: 'contain',
},
- { reset: true }
- )
+ zoomSteps: [1, 2, 4, 8],
+ zoomSpeed: 1,
+ panSpeed: 1,
+ isLocked: false,
+ })
+ editor.setCamera(editor.getCamera(), { reset: true })
}, [editor, imageShapeId, image])
return (
diff --git a/apps/examples/src/examples/pdf-editor/PdfEditor.tsx b/apps/examples/src/examples/pdf-editor/PdfEditor.tsx
index c30a465c6..359531478 100644
--- a/apps/examples/src/examples/pdf-editor/PdfEditor.tsx
+++ b/apps/examples/src/examples/pdf-editor/PdfEditor.tsx
@@ -117,20 +117,18 @@ export function PdfEditor({ pdf }: { pdf: Pdf }) {
)
function updateCameraBounds(isMobile: boolean) {
- editor.setCameraOptions(
- {
- ...DEFAULT_CAMERA_OPTIONS,
- constraints: {
- bounds: targetBounds,
- padding: { x: isMobile ? 16 : 164, y: 64 },
- origin: { x: 0.5, y: 0 },
- initialZoom: 'fit-x-100',
- baseZoom: 'default',
- behavior: 'contain',
- },
+ editor.setCameraOptions({
+ ...DEFAULT_CAMERA_OPTIONS,
+ constraints: {
+ bounds: targetBounds,
+ padding: { x: isMobile ? 16 : 164, y: 64 },
+ origin: { x: 0.5, y: 0 },
+ initialZoom: 'fit-x-100',
+ baseZoom: 'default',
+ behavior: 'contain',
},
- { reset: true }
- )
+ })
+ editor.setCamera(editor.getCamera(), { reset: true })
}
let isMobile = editor.getViewportScreenBounds().width < 840
diff --git a/apps/examples/src/examples/slideshow/README.md b/apps/examples/src/examples/slideshow/README.md
new file mode 100644
index 000000000..344316208
--- /dev/null
+++ b/apps/examples/src/examples/slideshow/README.md
@@ -0,0 +1,11 @@
+---
+title: Slideshow with Camera
+component: ./SlideShowExample.tsx
+category: use-cases
+---
+
+---
+
+The `Tldraw` component provides the tldraw editor as a regular React component. You can put this component anywhere in your React project. In this example, we make the component take up the height and width of the container.
+
+By default, the component does not persist between refreshes or sync locally between tabs. To keep your work after a refresh, check the [`persistenceKey`](/peristence-key) example.
diff --git a/apps/examples/src/examples/slideshow/SlideShowExample.tsx b/apps/examples/src/examples/slideshow/SlideShowExample.tsx
new file mode 100644
index 000000000..903911901
--- /dev/null
+++ b/apps/examples/src/examples/slideshow/SlideShowExample.tsx
@@ -0,0 +1,264 @@
+import { useEffect, useState } from 'react'
+import {
+ DEFAULT_CAMERA_OPTIONS,
+ Editor,
+ TLFrameShape,
+ Tldraw,
+ createShapeId,
+ stopEventPropagation,
+ transact,
+ useValue,
+} from 'tldraw'
+import 'tldraw/tldraw.css'
+import { SLIDE_MARGIN, SLIDE_SIZE, SlidesProvider, useSlides } from './SlidesManager'
+
+export default function SlideShowExample() {
+ return (
+
+
+
+
+
+ )
+}
+
+function InsideSlidesContext() {
+ const [editor, setEditor] = useState
(null)
+ const slides = useSlides()
+
+ const currentSlide = useValue('currentSlide', () => slides.getCurrentSlide(), [slides])
+
+ useEffect(() => {
+ if (!editor) return
+
+ const nextBounds = {
+ x: currentSlide.index * (SLIDE_SIZE.w + SLIDE_MARGIN),
+ y: 0,
+ w: SLIDE_SIZE.w,
+ h: SLIDE_SIZE.h,
+ }
+
+ editor.setCameraOptions({
+ ...DEFAULT_CAMERA_OPTIONS,
+ constraints: {
+ bounds: nextBounds,
+ behavior: 'contain',
+ initialZoom: 'fit-max',
+ baseZoom: 'fit-max',
+ origin: { x: 0.5, y: 0.5 },
+ padding: { x: 50, y: 50 },
+ },
+ })
+
+ editor.zoomToBounds(nextBounds, { force: true, animation: { duration: 500 } })
+ }, [editor, currentSlide])
+
+ const currentSlides = useValue('slides', () => slides.getCurrentSlides(), [slides])
+
+ useEffect(() => {
+ if (!editor) return
+
+ const ids = currentSlides.map((slide) => createShapeId(slide.id))
+
+ transact(() => {
+ for (let i = 0; i < currentSlides.length; i++) {
+ const shapeId = ids[i]
+ const slide = currentSlides[i]
+ const shape = editor.getShape(shapeId)
+ if (shape) {
+ if (shape.x === slide.index * (SLIDE_SIZE.w + SLIDE_MARGIN)) continue
+
+ // if name is still Slide and number, e.g Slide 1, update it. Use regex to test
+
+ const regex = /Slide \d+/
+ let name = (shape as TLFrameShape).props.name
+ if (regex.test((shape as TLFrameShape).props.name)) {
+ name = `Slide ${slide.index + 1}`
+ }
+
+ editor.updateShape({
+ id: shapeId,
+ type: 'frame',
+ x: slide.index * (SLIDE_SIZE.w + SLIDE_MARGIN),
+ props: {
+ name,
+ },
+ })
+ } else {
+ editor.createShape({
+ id: shapeId,
+ parentId: editor.getCurrentPageId(),
+ type: 'frame',
+ x: slide.index * (SLIDE_SIZE.w + SLIDE_MARGIN),
+ y: 0,
+ props: {
+ name: `Slide ${slide.index + 1}`,
+ w: SLIDE_SIZE.w,
+ h: SLIDE_SIZE.h,
+ },
+ })
+ }
+ }
+ })
+
+ const unsubs = [] as (() => void)[]
+
+ unsubs.push(
+ editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next) => {
+ if (
+ ids.includes(next.id) &&
+ (next as TLFrameShape).props.name === (prev as TLFrameShape).props.name
+ )
+ return prev
+ return next
+ })
+ )
+
+ unsubs.push(
+ editor.sideEffects.registerBeforeChangeHandler('instance_page_state', (prev, next) => {
+ next.selectedShapeIds = next.selectedShapeIds.filter((id) => !ids.includes(id))
+ if (next.hoveredShapeId && ids.includes(next.hoveredShapeId)) next.hoveredShapeId = null
+ return next
+ })
+ )
+
+ return () => {
+ unsubs.forEach((fn) => fn())
+ }
+ }, [currentSlides, editor])
+
+ const handleMount = (editor: Editor) => {
+ setEditor(editor)
+ }
+
+ return
+}
+
+function Slides() {
+ const slides = useSlides()
+ const currentSlides = useValue('slides', () => slides.getCurrentSlides(), [slides])
+ const lowestIndex = currentSlides[0].index
+ const highestIndex = currentSlides[currentSlides.length - 1].index
+
+ return (
+ <>
+ {/* {currentSlides.map((slide) => (
+ {
+ if (slide.id !== slides.getCurrentSlideId()) {
+ stopEventPropagation(e)
+ slides.setCurrentSlide(slide.id)
+ }
+ }}
+ />
+ ))} */}
+ {currentSlides.slice(0, -1).map((slide) => (
+
+ ))}
+
+
+ >
+ )
+}
+
+function SlideControls() {
+ const slides = useSlides()
+
+ return (
+ <>
+
+
+ >
+ )
+}
+
+const components = {
+ OnTheCanvas: Slides,
+ InFrontOfTheCanvas: SlideControls,
+}
diff --git a/apps/examples/src/examples/slideshow/SlidesManager.tsx b/apps/examples/src/examples/slideshow/SlidesManager.tsx
new file mode 100644
index 000000000..58166c561
--- /dev/null
+++ b/apps/examples/src/examples/slideshow/SlidesManager.tsx
@@ -0,0 +1,100 @@
+import { createContext, ReactNode, useContext, useState } from 'react'
+import { atom, computed, structuredClone, uniqueId } from 'tldraw'
+
+export const SLIDE_SIZE = { x: 0, y: 0, w: 1600, h: 900 }
+export const SLIDE_MARGIN = 100
+
+type Slide = {
+ id: string
+ index: number
+ name: string
+}
+
+class SlidesManager {
+ private _slides = atom
('slide', [
+ {
+ id: '1',
+ index: 0,
+ name: 'Slide 1',
+ },
+ {
+ id: '2',
+ index: 1,
+ name: 'Slide 2',
+ },
+ {
+ id: '3',
+ index: 2,
+ name: 'Slide 3',
+ },
+ ])
+
+ @computed getCurrentSlides() {
+ return this._slides.get().sort((a, b) => (a.index < b.index ? -1 : 1))
+ }
+
+ private _currentSlideId = atom('currentSlide', '1')
+
+ @computed getCurrentSlideId() {
+ return this._currentSlideId.get()
+ }
+
+ @computed getCurrentSlide() {
+ return this._slides.get().find((slide) => slide.id === this.getCurrentSlideId())!
+ }
+
+ setCurrentSlide(id: string) {
+ this._currentSlideId.set(id)
+ }
+
+ moveBy(delta: number) {
+ const slides = this.getCurrentSlides()
+ const currentIndex = slides.findIndex((slide) => slide.id === this.getCurrentSlideId())
+ const next = slides[currentIndex + delta]
+ if (!next) return
+ this._currentSlideId.set(next.id)
+ }
+
+ nextSlide() {
+ this.moveBy(1)
+ }
+
+ prevSlide() {
+ this.moveBy(-1)
+ }
+
+ newSlide(index: number) {
+ const slides = structuredClone(this.getCurrentSlides())
+
+ let bumping = false
+ for (const slide of slides) {
+ if (slide.index === index) {
+ bumping = true
+ }
+ if (bumping) {
+ slide.index++
+ }
+ }
+
+ const newSlide = {
+ id: uniqueId(),
+ index,
+ name: `Slide ${slides.length + 1}`,
+ }
+
+ this._slides.set([...slides, newSlide])
+
+ return newSlide
+ }
+}
+
+const slidesContext = createContext({} as SlidesManager)
+
+export const SlidesProvider = ({ children }: { children: ReactNode }) => {
+ const [slideManager] = useState(() => new SlidesManager())
+ return {children}
+}
+
+export function useSlides() {
+ return useContext(slidesContext)
+}
diff --git a/apps/examples/tsconfig.json b/apps/examples/tsconfig.json
index 95d93668c..4d943aa74 100644
--- a/apps/examples/tsconfig.json
+++ b/apps/examples/tsconfig.json
@@ -3,7 +3,8 @@
"include": ["src", "e2e", "./vite.config.ts", "**/*.json"],
"exclude": ["node_modules", "dist", "**/*.css", ".tsbuild*", "./scripts/legacy-translations"],
"compilerOptions": {
- "outDir": "./.tsbuild"
+ "outDir": "./.tsbuild",
+ "experimentalDecorators": true
},
"references": [
{
diff --git a/apps/examples/vite.config.ts b/apps/examples/vite.config.ts
index 648787582..9bdce6d8b 100644
--- a/apps/examples/vite.config.ts
+++ b/apps/examples/vite.config.ts
@@ -1,9 +1,9 @@
-import react from '@vitejs/plugin-react'
+import react from '@vitejs/plugin-react-swc'
import path from 'path'
import { PluginOption, defineConfig } from 'vite'
export default defineConfig({
- plugins: [react(), exampleReadmePlugin()],
+ plugins: [react({ tsDecorators: true }), exampleReadmePlugin()],
root: path.join(__dirname, 'src'),
publicDir: path.join(__dirname, 'public'),
build: {
diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md
index 58e6d8ce6..1367f1294 100644
--- a/packages/editor/api-report.md
+++ b/packages/editor/api-report.md
@@ -899,7 +899,7 @@ export class Editor extends EventEmitter {
sendBackward(shapes: TLShape[] | TLShapeId[]): this;
sendToBack(shapes: TLShape[] | TLShapeId[]): this;
setCamera(point: VecLike, opts?: TLCameraMoveOptions): this;
- setCameraOptions(options: Partial, opts?: TLCameraMoveOptions): this;
+ setCameraOptions(options: Partial): this;
setCroppingShape(shape: null | TLShape | TLShapeId): this;
setCurrentPage(page: TLPage | TLPageId): this;
setCurrentTool(id: string, info?: {}): this;
diff --git a/packages/editor/editor.css b/packages/editor/editor.css
index 23304a855..38418d761 100644
--- a/packages/editor/editor.css
+++ b/packages/editor/editor.css
@@ -28,6 +28,7 @@
--layer-canvas: 200;
--layer-shapes: 300;
--layer-overlays: 400;
+ --layer-in-front: 500;
--layer-following-indicator: 1000;
--layer-blocker: 10000;
@@ -265,7 +266,8 @@ input,
.tl-overlays {
position: absolute;
- inset: 0px;
+ top: 0px;
+ left: 0px;
height: 100%;
width: 100%;
contain: strict;
@@ -291,6 +293,17 @@ input,
pointer-events: none;
}
+.tl-front {
+ z-index: 600;
+ position: absolute;
+ inset: 0px;
+ height: 100%;
+ width: 100%;
+ overflow: clip;
+ content-visibility: auto;
+ touch-action: none;
+ pointer-events: none;
+}
/* ------------------- Background ------------------- */
.tl-background__wrapper {
diff --git a/packages/editor/src/lib/TldrawEditor.tsx b/packages/editor/src/lib/TldrawEditor.tsx
index 94a3d587a..a099118a6 100644
--- a/packages/editor/src/lib/TldrawEditor.tsx
+++ b/packages/editor/src/lib/TldrawEditor.tsx
@@ -367,7 +367,22 @@ function Layout({
useFocusEvents(autoFocus)
useOnMount(onMount)
- return <>{children}>
+ return (
+ <>
+ {children}
+
+ >
+ )
+}
+
+function InFrontOfTheCanvasWrapper() {
+ const { InFrontOfTheCanvas } = useEditorComponents()
+ if (!InFrontOfTheCanvas) return null
+ return (
+
+
+
+ )
}
function Crash({ crashingError }: { crashingError: unknown }): null {
diff --git a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx
index b1dc651c1..d73a556f4 100644
--- a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx
+++ b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx
@@ -169,7 +169,6 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
-