* remove sponsorwall for main route

* Adds sponsorship link

* Remove all sponsorwall

* Fix sponsorship link appearance on dark mode

* Add heart icon

* Fix text bug

* Fix toolbar, hide resize handles on sticky

* Add eraser

* Update Kbd.tsx

* cleanup

* base zoom delta on event deltaMode

* Fix image in example

* Fix eraser icon

* eraser tool resets to previous tool

* Update EraseTool.spec.ts

* Improves support for locked shapes

* Update _document.tsx

* Update CHANGELOG.md

* Adds multiplayer menu, fix develop route in example

* Tighten up top panel padding

* Update top bar, bump packages

* refactor TLDrawState -> TLDrawApp, mutables, new tests

* Fix scaling bug, delete groups bug

* fix snapping

* add pressure to points

* Remove mutables, rename to tldraw (or Tldraw)

* Clean up types, add darkmode prop

* more renaming

* rename getShapeUtils to getShapeUtil

* Fix file names

* Fix last bugs related to renaming

* Update state to app in tests

* rename types to TD

* remove unused styles / rename styles

* slight update to panel

* Fix rogue radix perf issue

* Update ZoomMenu.tsx

* Consolidate style panel

* Fix text wrapping in text shape, improve action menu

* Fix props

* add indicators for tool lock

* fix calloits

* Add click to erase shapes

* Slightly improve loading screen

* Update PrimaryTools.tsx

* remove force consistent filenames from tsconfig

* Update useTldrawApp.tsx

* fix capitalization

* Update main.yml
This commit is contained in:
Steve Ruiz 2021-11-16 16:01:29 +00:00 committed by GitHub
parent b7570ae6a3
commit 0c5f8dda48
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
255 changed files with 7978 additions and 7197 deletions

View file

@ -17,4 +17,4 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
test-command: 'yarn test --ci --runInBand' test-command: 'yarn test --ci --runInBand --updateSnapshot'

2
.gitignore vendored
View file

@ -12,3 +12,5 @@ coverage
www/public/worker-* www/public/worker-*
www/public/sw.js www/public/sw.js
www/public/sw.js.map www/public/sw.js.map
.vercel

View file

@ -1,4 +1,4 @@
# Welcome to the TLDraw contributing guide <!-- omit in toc --> # Welcome to the tldraw contributing guide <!-- omit in toc -->
Thank you for investing your time in contributing to our project! Any contribution you make will be reflected in the @tldraw/tldraw package and at [tldraw.com](https://tldraw.com). Thank you for investing your time in contributing to our project! Any contribution you make will be reflected in the @tldraw/tldraw package and at [tldraw.com](https://tldraw.com).
@ -77,4 +77,4 @@ When you're finished with the changes, create a pull request, also known as a PR
Congratulations :tada::tada: The GitHub team thanks you :sparkles:. Congratulations :tada::tada: The GitHub team thanks you :sparkles:.
Once your PR is merged, your contributions will become part of the next TLDraw release, and will be visible in the [TLDraw app](https://tldraw.com). Once your PR is merged, your contributions will become part of the next tldraw release, and will be visible in the [tldraw app](https://tldraw.com).

View file

@ -4,13 +4,13 @@
# @tldraw/tldraw # @tldraw/tldraw
This package contains the [TLDraw](https://tldraw.com) editor as a React component named `<TLDraw>`. You can use this package to embed the editor in any React application. This package contains the [tldraw](https://tldraw.com) editor as a React component named `<Tldraw>`. You can use this package to embed the editor in any React application.
💕 Love this library? Consider [becoming a sponsor](https://github.com/sponsors/steveruizok?frequency=recurring&sponsor=steveruizok). 💕 Love this library? Consider [becoming a sponsor](https://github.com/sponsors/steveruizok?frequency=recurring&sponsor=steveruizok).
🙌 Questions? Join the [Discord channel](https://discord.gg/SBBEVCA4PG) or start a [discussion](https://github.com/tldraw/tldraw/discussions/new). 🙌 Questions? Join the [Discord channel](https://discord.gg/SBBEVCA4PG) or start a [discussion](https://github.com/tldraw/tldraw/discussions/new).
🎨 Want to build your own TLDraw-ish app instead? Try [@tldraw/core](https://github.com/tldraw/core). 🎨 Want to build your own tldraw-ish app instead? Try [@tldraw/core](https://github.com/tldraw/core).
## Installation ## Installation
@ -24,13 +24,13 @@ npm i @tldraw/tldraw
## Usage ## Usage
Import the `TLDraw` React component and use it in your app. Import the `tldraw` React component and use it in your app.
```tsx ```tsx
import { TLDraw } from '@tldraw/tldraw' import { Tldraw } from '@tldraw/tldraw'
function App() { function App() {
return <TLDraw /> return <Tldraw />
} }
``` ```
@ -39,58 +39,58 @@ function App() {
You can use the `id` to persist the state in a user's browser storage. You can use the `id` to persist the state in a user's browser storage.
```tsx ```tsx
import { TLDraw } from '@tldraw/tldraw' import { Tldraw } from '@tldraw/tldraw'
function App() { function App() {
return <TLDraw id="myState" /> return <Tldraw id="myState" />
} }
``` ```
### Controlling the Component through Props ### Controlling the Component through Props
You can control the `TLDraw` component through its props. You can control the `tldraw` component through its props.
```tsx ```tsx
import { TLDraw, TLDrawDocument } from '@tldraw/tldraw' import { Tldraw, TDDocument } from '@tldraw/tldraw'
function App() { function App() {
const myDocument: TLDrawDocument = {} const myDocument: TDDocument = {}
return <TLDraw document={document} /> return <Tldraw document={document} />
} }
``` ```
### Controlling the Component through the TLDrawState API ### Controlling the Component through the tldrawApp API
You can also control the `TLDraw` component imperatively through the `TLDrawState` API. You can also control the `tldraw` component imperatively through the `tldrawApp` API.
```tsx ```tsx
import { TLDraw, TLDrawState } from '@tldraw/tldraw' import { Tldraw, tldrawApp } from '@tldraw/tldraw'
function App() { function App() {
const handleMount = React.useCallback((state: TLDrawState) => { const handleMount = React.useCallback((state: tldrawApp) => {
state.selectAll() state.selectAll()
}, []) }, [])
return <TLDraw onMount={handleMount} /> return <Tldraw onMount={handleMount} />
} }
``` ```
Internally, the `TLDraw` component's user interface uses this API to make changes to the component's state. See the `TLDrawState` section of the [documentation](guides/documentation) for more on this API. Internally, the `tldraw` component's user interface uses this API to make changes to the component's state. See the `tldrawApp` section of the [documentation](guides/documentation) for more on this API.
### Responding to Changes ### Responding to Changes
You can respond to changes and user actions using the `onChange` callback. For more specific changes, you can also use the `onPatch`, `onCommand`, or `onPersist` callbacks. See the [documentation](guides/documentation) for more. You can respond to changes and user actions using the `onChange` callback. For more specific changes, you can also use the `onPatch`, `onCommand`, or `onPersist` callbacks. See the [documentation](guides/documentation) for more.
```tsx ```tsx
import { TLDraw, TLDrawState } from '@tldraw/tldraw' import { Tldraw, tldrawApp } from '@tldraw/tldraw'
function App() { function App() {
const handleChange = React.useCallback((state: TLDrawState, reason: string) => { const handleChange = React.useCallback((state: tldrawApp, reason: string) => {
// Do something with the change // Do something with the change
}, []) }, [])
return <TLDraw onMount={handleMount} /> return <Tldraw onMount={handleMount} />
} }
``` ```
@ -108,7 +108,7 @@ See the [development guide](/guides/development.md).
## Example ## Example
See the `example` folder for examples of how to use the `<TLDraw/>` component. See the `example` folder for examples of how to use the `<Tldraw/>` component.
## Community ## Community

View file

@ -1,6 +1,6 @@
# @tldraw/tldraw-electron # @tldraw/tldraw-electron
An electron wrapper for TLDraw. Not yet published. An electron wrapper for tldraw. Not yet published.
## Development ## Development

View file

@ -13,7 +13,7 @@ export async function createWindow() {
minHeight: 480, minHeight: 480,
minWidth: 600, minWidth: 600,
titleBarStyle: 'hidden', titleBarStyle: 'hidden',
title: 'TLDraw', title: 'Tldraw',
webPreferences: { webPreferences: {
nodeIntegration: true, nodeIntegration: true,
devTools: true, devTools: true,

View file

@ -1,7 +1,7 @@
import { contextBridge, ipcRenderer } from 'electron' import { contextBridge, ipcRenderer } from 'electron'
import type { Message, TLApi } from 'src/types' import type { Message, TldrawBridgeApi } from 'src/types'
const api: TLApi = { const api: TldrawBridgeApi = {
send: (channel: string, data: Message) => { send: (channel: string, data: Message) => {
ipcRenderer.send(channel, data) ipcRenderer.send(channel, data)
}, },
@ -10,6 +10,6 @@ const api: TLApi = {
}, },
} }
contextBridge?.exposeInMainWorld('TLApi', api) contextBridge?.exposeInMainWorld('TldrawBridgeApi', api)
export {} export {}

View file

@ -1,85 +1,86 @@
import * as React from 'react' import * as React from 'react'
import { TLDraw, TLDrawState } from '@tldraw/tldraw' import { Tldraw, TldrawApp } from '@tldraw/tldraw'
import type { IpcMainEvent, IpcMain, IpcRenderer } from 'electron' import type { Message, TldrawBridgeApi } from 'src/types'
import type { Message, TLApi } from 'src/types'
declare const window: Window & { TldrawBridgeApi: TldrawBridgeApi }
export default function App(): JSX.Element { export default function App(): JSX.Element {
const rTLDrawState = React.useRef<TLDrawState>() const rTldrawApp = React.useRef<TldrawApp>()
// When the editor mounts, save the state instance in a ref. // When the editor mounts, save the state instance in a ref.
const handleMount = React.useCallback((tldr: TLDrawState) => { const handleMount = React.useCallback((tldr: TldrawApp) => {
rTLDrawState.current = tldr rTldrawApp.current = tldr
}, []) }, [])
React.useEffect(() => { React.useEffect(() => {
function handleEvent(message: Message) { function handleEvent(message: Message) {
const state = rTLDrawState.current const app = rTldrawApp.current
if (!state) return if (!app) return
switch (message.type) { switch (message.type) {
case 'resetZoom': { case 'resetZoom': {
state.resetZoom() app.resetZoom()
break break
} }
case 'zoomIn': { case 'zoomIn': {
state.zoomIn() app.zoomIn()
break break
} }
case 'zoomOut': { case 'zoomOut': {
state.zoomOut() app.zoomOut()
break break
} }
case 'zoomToFit': { case 'zoomToFit': {
state.zoomToFit() app.zoomToFit()
break break
} }
case 'zoomToSelection': { case 'zoomToSelection': {
state.zoomToSelection() app.zoomToSelection()
break break
} }
case 'undo': { case 'undo': {
state.undo() app.undo()
break break
} }
case 'redo': { case 'redo': {
state.redo() app.redo()
break break
} }
case 'cut': { case 'cut': {
state.cut() app.cut()
break break
} }
case 'copy': { case 'copy': {
state.copy() app.copy()
break break
} }
case 'paste': { case 'paste': {
state.paste() app.paste()
break break
} }
case 'delete': { case 'delete': {
state.delete() app.delete()
break break
} }
case 'selectAll': { case 'selectAll': {
state.selectAll() app.selectAll()
break break
} }
case 'selectNone': { case 'selectNone': {
state.selectNone() app.selectNone()
break break
} }
} }
} }
const { send, on } = (window as unknown as Window & { TLApi: TLApi })['TLApi'] const { on } = window.TldrawBridgeApi
on('projectMsg', handleEvent) on('projectMsg', handleEvent)
}) })
return ( return (
<div className="tldraw"> <div className="tldraw">
<TLDraw id="electron" onMount={handleMount} autofocus showMenu={false} /> <Tldraw id="electron" onMount={handleMount} autofocus showMenu={false} />
</div> </div>
) )
} }

View file

View file

@ -13,7 +13,7 @@ export type Message =
| { type: 'selectAll' } | { type: 'selectAll' }
| { type: 'selectNone' } | { type: 'selectNone' }
export type TLApi = { export type TldrawBridgeApi = {
send: (channel: string, data: Message) => void send: (channel: string, data: Message) => void
on: (channel: string, cb: (message: Message) => void) => void on: (channel: string, cb: (message: Message) => void) => void
} }

View file

@ -35,5 +35,9 @@
"rimraf": "3.0.2", "rimraf": "3.0.2",
"typescript": "4.2.3" "typescript": "4.2.3"
}, },
"gitHead": "a7dac0f83ad998e205c2aab58182cb4ba4e099a6" "gitHead": "a7dac0f83ad998e205c2aab58182cb4ba4e099a6",
"dependencies": {
"@liveblocks/client": "^0.12.3",
"@liveblocks/react": "^0.12.3"
}
} }

View file

@ -1,17 +1,17 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react' import * as React from 'react'
import { ColorStyle, TLDraw, TLDrawShapeType, TLDrawState } from '@tldraw/tldraw' import { ColorStyle, Tldraw, TDShapeType, TldrawApp } from '@tldraw/tldraw'
export default function Imperative(): JSX.Element { export default function Imperative(): JSX.Element {
const rTLDrawState = React.useRef<TLDrawState>() const rTldrawApp = React.useRef<TldrawApp>()
const handleMount = React.useCallback((state: TLDrawState) => { const handleMount = React.useCallback((app: TldrawApp) => {
rTLDrawState.current = state rTldrawApp.current = app
state.createShapes( app.createShapes(
{ {
id: 'rect1', id: 'rect1',
type: TLDrawShapeType.Rectangle, type: TDShapeType.Rectangle,
name: 'Rectangle', name: 'Rectangle',
childIndex: 1, childIndex: 1,
point: [0, 0], point: [0, 0],
@ -20,7 +20,7 @@ export default function Imperative(): JSX.Element {
{ {
id: 'rect2', id: 'rect2',
name: 'Rectangle', name: 'Rectangle',
type: TLDrawShapeType.Rectangle, type: TDShapeType.Rectangle,
point: [200, 200], point: [200, 200],
size: [100, 100], size: [100, 100],
} }
@ -30,13 +30,13 @@ export default function Imperative(): JSX.Element {
React.useEffect(() => { React.useEffect(() => {
let i = 0 let i = 0
const interval = setInterval(() => { const interval = setInterval(() => {
const state = rTLDrawState.current! const app = rTldrawApp.current!
const rect1 = state.getShape('rect1') const rect1 = app.getShape('rect1')
if (!rect1) { if (!rect1) {
state.createShapes({ app.createShapes({
id: 'rect1', id: 'rect1',
type: TLDrawShapeType.Rectangle, type: TDShapeType.Rectangle,
name: 'Rectangle', name: 'Rectangle',
childIndex: 1, childIndex: 1,
point: [0, 0], point: [0, 0],
@ -47,7 +47,7 @@ export default function Imperative(): JSX.Element {
const color = i % 2 ? ColorStyle.Red : ColorStyle.Blue const color = i % 2 ? ColorStyle.Red : ColorStyle.Blue
state.patchShapes({ app.patchShapes({
id: 'rect1', id: 'rect1',
style: { style: {
...rect1.style, ...rect1.style,
@ -60,5 +60,5 @@ export default function Imperative(): JSX.Element {
return () => clearInterval(interval) return () => clearInterval(interval)
}, []) }, [])
return <TLDraw onMount={handleMount} /> return <Tldraw onMount={handleMount} />
} }

View file

@ -1,16 +1,20 @@
import * as React from 'react' import * as React from 'react'
import { TLDraw, TLDrawState, TLDrawShapeType, ColorStyle } from '@tldraw/tldraw' import { Tldraw, TldrawApp, TDShapeType, ColorStyle } from '@tldraw/tldraw'
declare const window: Window & { app: TldrawApp }
export default function Api(): JSX.Element { export default function Api(): JSX.Element {
const rTLDrawState = React.useRef<TLDrawState>() const rTldrawApp = React.useRef<TldrawApp>()
const handleMount = React.useCallback((state: TLDrawState) => { const handleMount = React.useCallback((app: TldrawApp) => {
rTLDrawState.current = state rTldrawApp.current = app
state window.app = app
app
.createShapes({ .createShapes({
id: 'rect1', id: 'rect1',
type: TLDrawShapeType.Rectangle, type: TDShapeType.Rectangle,
point: [100, 100], point: [100, 100],
size: [200, 200], size: [200, 200],
}) })
@ -24,7 +28,7 @@ export default function Api(): JSX.Element {
return ( return (
<div className="tldraw"> <div className="tldraw">
<TLDraw onMount={handleMount} /> <Tldraw onMount={handleMount} />
</div> </div>
) )
} }

View file

@ -19,9 +19,8 @@ import './styles.css'
export default function App(): JSX.Element { export default function App(): JSX.Element {
return ( return (
<main> <main>
<img className="hero" src="./card-repo.png" />
<Switch> <Switch>
<Route path="/basic"> <Route path="/develop">
<Develop /> <Develop />
</Route> </Route>
<Route path="/basic"> <Route path="/basic">
@ -64,9 +63,10 @@ export default function App(): JSX.Element {
<Multiplayer /> <Multiplayer />
</Route> </Route>
<Route path="/"> <Route path="/">
<img className="hero" src="./card-repo.png" />
<ul className="links"> <ul className="links">
<li> <li>
<Link to="/basic">Develop</Link> <Link to="/develop">Develop</Link>
</li> </li>
<hr /> <hr />
<li> <li>
@ -94,10 +94,10 @@ export default function App(): JSX.Element {
<Link to="/controlled">Controlled via Props</Link> <Link to="/controlled">Controlled via Props</Link>
</li> </li>
<li> <li>
<Link to="/api">Using the TLDrawState API</Link> <Link to="/api">Using the TldrawApp API</Link>
</li> </li>
<li> <li>
<Link to="/imperative">Controlled via TLDrawState API</Link> <Link to="/imperative">Controlled via TldrawApp API</Link>
</li> </li>
<li> <li>
<Link to="/changing-id">Changing ID</Link> <Link to="/changing-id">Changing ID</Link>

View file

@ -1,10 +1,10 @@
import * as React from 'react' import * as React from 'react'
import { TLDraw } from '@tldraw/tldraw' import { Tldraw } from '@tldraw/tldraw'
export default function Basic(): JSX.Element { export default function Basic(): JSX.Element {
return ( return (
<div className="tldraw"> <div className="tldraw">
<TLDraw /> <Tldraw />
</div> </div>
) )
} }

View file

@ -1,5 +1,5 @@
import * as React from 'react' import * as React from 'react'
import { TLDraw } from '@tldraw/tldraw' import { Tldraw } from '@tldraw/tldraw'
export default function ChangingId() { export default function ChangingId() {
const [id, setId] = React.useState('example') const [id, setId] = React.useState('example')
@ -10,5 +10,5 @@ export default function ChangingId() {
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
}, []) }, [])
return <TLDraw id={id} /> return <Tldraw id={id} />
} }

View file

@ -1,17 +1,17 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import * as React from 'react' import * as React from 'react'
import { TLDraw, TLDrawState, useFileSystem } from '@tldraw/tldraw' import { Tldraw, TldrawApp, useFileSystem } from '@tldraw/tldraw'
declare const window: Window & { state: TLDrawState } declare const window: Window & { app: TldrawApp }
export default function Develop(): JSX.Element { export default function Develop(): JSX.Element {
const rTLDrawState = React.useRef<TLDrawState>() const rTldrawApp = React.useRef<TldrawApp>()
const fileSystemEvents = useFileSystem() const fileSystemEvents = useFileSystem()
const handleMount = React.useCallback((state: TLDrawState) => { const handleMount = React.useCallback((app: TldrawApp) => {
window.state = state window.app = app
rTLDrawState.current = state rTldrawApp.current = app
}, []) }, [])
const handleSignOut = React.useCallback(() => { const handleSignOut = React.useCallback(() => {
@ -28,13 +28,14 @@ export default function Develop(): JSX.Element {
return ( return (
<div className="tldraw"> <div className="tldraw">
<TLDraw <Tldraw
id="develop" id="develop"
{...fileSystemEvents} {...fileSystemEvents}
onMount={handleMount} onMount={handleMount}
onSignIn={handleSignIn} onSignIn={handleSignIn}
onSignOut={handleSignOut} onSignOut={handleSignOut}
onPersist={handlePersist} onPersist={handlePersist}
showSponsorLink={true}
/> />
</div> </div>
) )

View file

@ -1,4 +1,4 @@
import { TLDraw } from '@tldraw/tldraw' import { Tldraw } from '@tldraw/tldraw'
import * as React from 'react' import * as React from 'react'
export default function Embedded(): JSX.Element { export default function Embedded(): JSX.Element {
@ -13,7 +13,7 @@ export default function Embedded(): JSX.Element {
marginBottom: '32px', marginBottom: '32px',
}} }}
> >
<TLDraw id="small5" /> <Tldraw id="small5" />
</div> </div>
<div <div
@ -24,7 +24,7 @@ export default function Embedded(): JSX.Element {
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
<TLDraw id="embedded" /> <Tldraw id="embedded" />
</div> </div>
</div> </div>
) )

View file

@ -1,5 +1,5 @@
import * as React from 'react' import * as React from 'react'
import { TLDraw, useFileSystem } from '@tldraw/tldraw' import { Tldraw, useFileSystem } from '@tldraw/tldraw'
export default function FileSystem(): JSX.Element { export default function FileSystem(): JSX.Element {
const fileSystemEvents = useFileSystem() const fileSystemEvents = useFileSystem()
@ -8,7 +8,7 @@ export default function FileSystem(): JSX.Element {
return ( return (
<div className="tldraw"> <div className="tldraw">
<TLDraw {...fileSystemEvents} /> <Tldraw {...fileSystemEvents} />
</div> </div>
) )
} }

View file

@ -1,8 +1,8 @@
import { TLDraw, TLDrawFile } from '@tldraw/tldraw' import { Tldraw, TDFile } from '@tldraw/tldraw'
import * as React from 'react' import * as React from 'react'
export default function LoadingFiles(): JSX.Element { export default function LoadingFiles(): JSX.Element {
const [file, setFile] = React.useState<TLDrawFile>() const [file, setFile] = React.useState<TDFile>()
React.useEffect(() => { React.useEffect(() => {
async function loadFile(): Promise<void> { async function loadFile(): Promise<void> {
@ -13,5 +13,5 @@ export default function LoadingFiles(): JSX.Element {
loadFile() loadFile()
}, []) }, [])
return <TLDraw document={file?.document} /> return <Tldraw document={file?.document} />
} }

View file

@ -1,12 +1,14 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as React from 'react' import * as React from 'react'
import { TLDraw, TLDrawState, TLDrawDocument, TLDrawUser } from '@tldraw/tldraw' import { Tldraw, TldrawApp, TDDocument, TDUser } from '@tldraw/tldraw'
import { createClient, Presence } from '@liveblocks/client' import { createClient, Presence } from '@liveblocks/client'
import { LiveblocksProvider, RoomProvider, useErrorListener, useObject } from '@liveblocks/react' import { LiveblocksProvider, RoomProvider, useErrorListener, useObject } from '@liveblocks/react'
import { Utils } from '@tldraw/core' import { Utils } from '@tldraw/core'
interface TLDrawUserPresence extends Presence { declare const window: Window & { app: TldrawApp }
user: TLDrawUser
interface TDUserPresence extends Presence {
user: TDUser
} }
const client = createClient({ const client = createClient({
@ -20,37 +22,34 @@ export function Multiplayer() {
return ( return (
<LiveblocksProvider client={client}> <LiveblocksProvider client={client}>
<RoomProvider id={roomId}> <RoomProvider id={roomId}>
<TLDrawWrapper /> <TldrawWrapper />
</RoomProvider> </RoomProvider>
</LiveblocksProvider> </LiveblocksProvider>
) )
} }
function TLDrawWrapper() { function TldrawWrapper() {
const [docId] = React.useState(() => Utils.uniqueId()) const [docId] = React.useState(() => Utils.uniqueId())
const [error, setError] = React.useState<Error>() const [error, setError] = React.useState<Error>()
const [state, setstate] = React.useState<TLDrawState>() const [app, setApp] = React.useState<TldrawApp>()
useErrorListener((err) => setError(err)) useErrorListener((err) => setError(err))
const doc = useObject<{ uuid: string; document: TLDrawDocument }>('doc', { const doc = useObject<{ uuid: string; document: TDDocument }>('doc', {
uuid: docId, uuid: docId,
document: { document: {
...TLDrawState.defaultDocument, ...TldrawApp.defaultDocument,
id: roomId, id: roomId,
}, },
}) })
// Put the state into the window, for debugging. // Put the state into the window, for debugging.
const handleMount = React.useCallback( const handleMount = React.useCallback(
(state: TLDrawState) => { (app: TldrawApp) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment window.app = app
// @ts-ignore setApp(app)
window.state = state
state.loadRoom(roomId)
setstate(state)
}, },
[roomId] [roomId]
) )
@ -60,17 +59,11 @@ function TLDrawWrapper() {
if (!room) return if (!room) return
if (!doc) return if (!doc) return
if (!state) return if (!app) return
if (!state.state.room) return
// Update the user's presence with the user from state
const { users, userId } = state.state.room
room.updatePresence({ id: userId, user: users[userId] })
// Subscribe to presence changes; when others change, update the state // Subscribe to presence changes; when others change, update the state
room.subscribe<TLDrawUserPresence>('others', (others) => { room.subscribe<TDUserPresence>('others', (others) => {
state.updateUsers( app.updateUsers(
others others
.toArray() .toArray()
.filter((other) => other.presence) .filter((other) => other.presence)
@ -81,23 +74,23 @@ function TLDrawWrapper() {
room.subscribe('event', (event) => { room.subscribe('event', (event) => {
if (event.event?.name === 'exit') { if (event.event?.name === 'exit') {
state.removeUser(event.event.userId) app.removeUser(event.event.userId)
} }
}) })
function handleDocumentUpdates() { function handleDocumentUpdates() {
if (!doc) return if (!doc) return
if (!state) return if (!app) return
if (!state.state.room) return if (!app.room) return
const docObject = doc.toObject() const docObject = doc.toObject()
// Only merge the change if it caused by someone else // Only merge the change if it caused by someone else
if (docObject.uuid !== docId) { if (docObject.uuid !== docId) {
state.mergeDocument(docObject.document) app.mergeDocument(docObject.document)
} else { } else {
state.updateUsers( app.updateUsers(
Object.values(state.state.room.users).map((user) => { Object.values(app.room.users).map((user) => {
return { return {
...user, ...user,
selectedIds: user.selectedIds, selectedIds: user.selectedIds,
@ -108,8 +101,8 @@ function TLDrawWrapper() {
} }
function handleExit() { function handleExit() {
if (!(state && state.state.room)) return if (!(app && app.room)) return
room?.broadcastEvent({ name: 'exit', userId: state.state.room.userId }) room?.broadcastEvent({ name: 'exit', userId: app.room.userId })
} }
window.addEventListener('beforeunload', handleExit) window.addEventListener('beforeunload', handleExit)
@ -121,26 +114,33 @@ function TLDrawWrapper() {
const newDocument = doc.toObject().document const newDocument = doc.toObject().document
if (newDocument) { if (newDocument) {
state.loadDocument(newDocument) app.loadDocument(newDocument)
app.loadRoom(roomId)
// Update the user's presence with the user from state
if (app.state.room) {
const { users, userId } = app.state.room
room.updatePresence({ id: userId, user: users[userId] })
}
} }
return () => { return () => {
window.removeEventListener('beforeunload', handleExit) window.removeEventListener('beforeunload', handleExit)
doc.unsubscribe(handleDocumentUpdates) doc.unsubscribe(handleDocumentUpdates)
} }
}, [doc, docId, state]) }, [doc, docId, app])
const handlePersist = React.useCallback( const handlePersist = React.useCallback(
(state: TLDrawState) => { (app: TldrawApp) => {
doc?.update({ uuid: docId, document: state.document }) doc?.update({ uuid: docId, document: app.document })
}, },
[docId, doc] [docId, doc]
) )
const handleUserChange = React.useCallback( const handleUserChange = React.useCallback(
(state: TLDrawState, user: TLDrawUser) => { (app: TldrawApp, user: TDUser) => {
const room = client.getRoom(roomId) const room = client.getRoom(roomId)
room?.updatePresence({ id: state.state.room?.userId, user }) room?.updatePresence({ id: app.room?.userId, user })
}, },
[client] [client]
) )
@ -151,7 +151,7 @@ function TLDrawWrapper() {
return ( return (
<div className="tldraw"> <div className="tldraw">
<TLDraw <Tldraw
onMount={handleMount} onMount={handleMount}
onPersist={handlePersist} onPersist={handlePersist}
onUserChange={handleUserChange} onUserChange={handleUserChange}

View file

@ -1,6 +1,6 @@
import { TLDraw } from '@tldraw/tldraw' import { Tldraw } from '@tldraw/tldraw'
import * as React from 'react' import * as React from 'react'
export default function NoSizeEmbedded(): JSX.Element { export default function NoSizeEmbedded(): JSX.Element {
return <TLDraw /> return <Tldraw />
} }

View file

@ -1,10 +1,10 @@
import * as React from 'react' import * as React from 'react'
import { TLDraw } from '@tldraw/tldraw' import { Tldraw } from '@tldraw/tldraw'
export default function Persisted(): JSX.Element { export default function Persisted(): JSX.Element {
return ( return (
<div className="tldraw"> <div className="tldraw">
<TLDraw id="tldraw-persisted-id" /> <Tldraw id="Tldraw-persisted-id" />
</div> </div>
) )
} }

View file

@ -1,18 +1,18 @@
import * as React from 'react' import * as React from 'react'
import { import {
TLDraw, Tldraw,
ColorStyle, ColorStyle,
DashStyle, DashStyle,
SizeStyle, SizeStyle,
TLDrawDocument, TDDocument,
TLDrawShapeType, TDShapeType,
TLDrawState, TldrawApp,
} from '@tldraw/tldraw' } from '@tldraw/tldraw'
export default function Controlled() { export default function Controlled() {
const rDocument = React.useRef<TLDrawDocument>({ const rDocument = React.useRef<TDDocument>({
name: 'New Document', name: 'New Document',
version: TLDrawState.version, version: TldrawApp.version,
id: 'doc', id: 'doc',
pages: { pages: {
page1: { page1: {
@ -20,7 +20,7 @@ export default function Controlled() {
shapes: { shapes: {
rect1: { rect1: {
id: 'rect1', id: 'rect1',
type: TLDrawShapeType.Rectangle, type: TDShapeType.Rectangle,
parentId: 'page1', parentId: 'page1',
name: 'Rectangle', name: 'Rectangle',
childIndex: 1, childIndex: 1,
@ -34,7 +34,7 @@ export default function Controlled() {
}, },
rect2: { rect2: {
id: 'rect2', id: 'rect2',
type: TLDrawShapeType.Rectangle, type: TDShapeType.Rectangle,
parentId: 'page1', parentId: 'page1',
name: 'Rectangle', name: 'Rectangle',
childIndex: 1, childIndex: 1,
@ -62,7 +62,7 @@ export default function Controlled() {
}, },
}) })
const [doc, setDoc] = React.useState<TLDrawDocument>(rDocument.current) const [doc, setDoc] = React.useState<TDDocument>(rDocument.current)
React.useEffect(() => { React.useEffect(() => {
let i = 0 let i = 0
@ -105,5 +105,5 @@ export default function Controlled() {
rDocument.current = state.document rDocument.current = state.document
}, []) }, [])
return <TLDraw document={doc} onChange={handleChange} /> return <Tldraw document={doc} onChange={handleChange} />
} }

View file

@ -1,8 +1,8 @@
import { TLDraw, TLDrawFile } from '@tldraw/tldraw' import { Tldraw, TDFile } from '@tldraw/tldraw'
import * as React from 'react' import * as React from 'react'
export default function ReadOnly(): JSX.Element { export default function ReadOnly(): JSX.Element {
const [file, setFile] = React.useState<TLDrawFile>() const [file, setFile] = React.useState<TDFile>()
React.useEffect(() => { React.useEffect(() => {
async function loadFile(): Promise<void> { async function loadFile(): Promise<void> {
@ -15,7 +15,7 @@ export default function ReadOnly(): JSX.Element {
return ( return (
<div className="tldraw"> <div className="tldraw">
<TLDraw readOnly document={file?.document} /> <Tldraw readOnly document={file?.document} />
</div> </div>
) )
} }

View file

@ -1,10 +1,10 @@
import * as React from 'react' import * as React from 'react'
import { TLDraw } from '@tldraw/tldraw' import { Tldraw } from '@tldraw/tldraw'
export default function UIOptions(): JSX.Element { export default function UIOptions(): JSX.Element {
return ( return (
<div className="tldraw"> <div className="tldraw">
<TLDraw <Tldraw
showUI={true} showUI={true}
showMenu={true} showMenu={true}
showPages={true} showPages={true}

View file

@ -8,7 +8,7 @@ From the root folder:
- Open `localhost:5420` to view the example project. - Open `localhost:5420` to view the example project.
**Note:** The multiplayer examples and endpoints currently require an API key from [Liveblocks](https://liveblocks.io/), however the storage services that are used in TLDraw are currently in alpha and (as of November 2021) not accessible to the general public. You won't be able to authenticate and run these parts of the project. **Note:** The multiplayer examples and endpoints currently require an API key from [Liveblocks](https://liveblocks.io/), however the storage services that are used in tldraw are currently in alpha and (as of November 2021) not accessible to the general public. You won't be able to authenticate and run these parts of the project.
Other scripts: Other scripts:

View file

@ -2,7 +2,7 @@
## Introduction ## Introduction
This file contains the documentatin for the `<TLDraw>` component as well as the data model that the component accepts. This file contains the documentatin for the `<Tldraw>` component as well as the data model that the component accepts.
In addition to the docs written below, this project also includes **generated documentation**. To view the generated docs: In addition to the docs written below, this project also includes **generated documentation**. To view the generated docs:
@ -10,38 +10,39 @@ In addition to the docs written below, this project also includes **generated do
2. Open the file at: 2. Open the file at:
``` ```
/packages/tldraw/docs/classes/TLDrawState.html /packages/tldraw/docs/classes/TldrawApp.html
``` ```
## `TLDraw` ## `tldraw`
The `TLDraw` React component is the [tldraw](https://tldraw.com) editor exported as a standalone component. You can control the editor through props, or through the `TLDrawState`'s imperative API. **All props are optional.** The `Tldraw` React component is the [tldraw](https://tldraw.com) editor exported as a standalone component. You can control the editor through props, or through the `TldrawApp`'s imperative API. **All props are optional.**
| Prop | Type | Description | | Prop | Type | Description |
| ----------------- | ---------------- | --------------------------------------------------------------------------------------------------------- | | ----------------- | ------------ | --------------------------------------------------------------------------------------------------------- |
| `id` | `string` | An id under which to persist the component's state. | | `id` | `string` | An id under which to persist the component's state. |
| `document` | `TLDrawDocument` | An initial [`TLDrawDocument`](#tldrawdocument) object. | | `document` | `TDDocument` | An initial [`TDDocument`](#TDDocument) object. |
| `currentPageId` | `string` | A current page id, referencing the `TLDrawDocument` object provided via the `document` prop. | | `currentPageId` | `string` | A current page id, referencing the `TDDocument` object provided via the `document` prop. |
| `autofocus` | `boolean` | Whether the editor should immediately receive focus. Defaults to true. | | `autofocus` | `boolean` | Whether the editor should immediately receive focus. Defaults to true. |
| `showMenu` | `boolean` | Whether to show the menu. | | `showMenu` | `boolean` | Whether to show the menu. |
| `showPages` | `boolean` | Whether to show the pages menu. | | `showPages` | `boolean` | Whether to show the pages menu. |
| `showStyles` | `boolean` | Whether to show the styles menu. | | `showStyles` | `boolean` | Whether to show the styles menu. |
| `showTools` | `boolean` | Whether to show the tools. | | `showTools` | `boolean` | Whether to show the tools. |
| `showUI` | `boolean` | Whether to show any UI other than the canvas. | | `showUI` | `boolean` | Whether to show any UI other than the canvas. |
| `onMount` | `Function` | Called when the editor first mounts, receiving the current `TLDrawState`. | | `showSponsorLink` | `boolean` | Whether to show a sponsor link. |
| `onPatch` | `Function` | Called when the state is updated via a patch. | | `onMount` | `Function` | Called when the editor first mounts, receiving the current `TldrawApp`. |
| `onCommand` | `Function` | Called when the state is updated via a command. | | `onPatch` | `Function` | Called when the state is updated via a patch. |
| `onPersist` | `Function` | Called when the state is persisted after an action. | | `onCommand` | `Function` | Called when the state is updated via a command. |
| `onChange` | `Function` | Called when the `TLDrawState` updates for any reason. | | `onPersist` | `Function` | Called when the state is persisted after an action. |
| `onUserChange` | `Function` | Called when the user's "presence" information changes. | | `onChange` | `Function` | Called when the `TldrawApp` updates for any reason. |
| `onUndo` | `Function` | Called when the `TLDrawState` updates after an undo. | | `onUserChange` | `Function` | Called when the user's "presence" information changes. |
| `onRedo` | `Function` | Called when the `TLDrawState` updates after a redo. | | `onUndo` | `Function` | Called when the `TldrawApp` updates after an undo. |
| `onSignIn` | `Function` | Called when the user selects Sign In from the menu. | | `onRedo` | `Function` | Called when the `TldrawApp` updates after a redo. |
| `onSignOut` | `Function` | Called when the user selects Sign Out from the menu. | | `onSignIn` | `Function` | Called when the user selects Sign In from the menu. |
| `onNewProject` | `Function` | Called when the user when the user creates a new project through the menu or through a keyboard shortcut. | | `onSignOut` | `Function` | Called when the user selects Sign Out from the menu. |
| `onSaveProject` | `Function` | Called when the user saves a project through the menu or through a keyboard shortcut. | | `onNewProject` | `Function` | Called when the user when the user creates a new project through the menu or through a keyboard shortcut. |
| `onSaveProjectAs` | `Function` | Called when the user saves a project as a new project through the menu or through a keyboard shortcut. | | `onSaveProject` | `Function` | Called when the user saves a project through the menu or through a keyboard shortcut. |
| `onOpenProject` | `Function` | Called when the user opens new project through the menu or through a keyboard shortcut. | | `onSaveProjectAs` | `Function` | Called when the user saves a project as a new project through the menu or through a keyboard shortcut. |
| `onOpenProject` | `Function` | Called when the user opens new project through the menu or through a keyboard shortcut. |
> **Note**: For help with the file-related callbacks, see `useFileSystem`. > **Note**: For help with the file-related callbacks, see `useFileSystem`.
@ -50,30 +51,30 @@ The `TLDraw` React component is the [tldraw](https://tldraw.com) editor exported
You can use the `useFileSystem` hook to get prepared callbacks for `onNewProject`, `onOpenProject`, `onSaveProject`, and `onSaveProjectAs`. These callbacks allow a user to save files via the [FileSystem](https://developer.mozilla.org/en-US/docs/Web/API/FileSystem) API. You can use the `useFileSystem` hook to get prepared callbacks for `onNewProject`, `onOpenProject`, `onSaveProject`, and `onSaveProjectAs`. These callbacks allow a user to save files via the [FileSystem](https://developer.mozilla.org/en-US/docs/Web/API/FileSystem) API.
```ts ```ts
import { TLDraw, useFileSystem } from '@tldraw/tldraw' import { Tldraw, useFileSystem } from '@tldraw/tldraw'
function App() { function App() {
const fileSystemEvents = useFileSystem() const fileSystemEvents = useFileSystem()
return <TLDraw {...fileSystemEvents} /> return <Tldraw {...fileSystemEvents} />
} }
``` ```
## `TLDrawDocument` ## `TDDocument`
You can initialize or control the `<TLDraw>` component via its `document` property. A `TLDrawDocument` is an object with three properties: You can initialize or control the `<Tldraw>` component via its `document` property. A `TDDocument` is an object with three properties:
- `id` - A unique ID for this document - `id` - A unique ID for this document
- `pages` - A table of `TLDrawPage` objects - `pages` - A table of `TDPage` objects
- `pageStates` - A table of `TLPageState` objects - `pageStates` - A table of `TLPageState` objects
- `version` - The document's version, used internally for migrations. - `version` - The document's version, used internally for migrations.
```ts ```ts
import { TLDrawDocument, TLDrawState } from '@tldraw/tldraw' import { TDDocument, TldrawApp } from '@tldraw/tldraw'
const myDocument: TLDrawDocument = { const myDocument: TDDocument = {
id: 'doc', id: 'doc',
version: TLDrawState.version, version: TldrawApp.version,
pages: { pages: {
page1: { page1: {
id: 'page1', id: 'page1',
@ -95,34 +96,34 @@ const myDocument: TLDrawDocument = {
} }
function App() { function App() {
return <TLDraw document={myDocument} /> return <Tldraw document={myDocument} />
} }
``` ```
**Tip:** TLDraw is built on [@tldraw/core](https://github.com/tldraw/core). The pages and pageStates in TLDraw are objects containing `TLPage` and `TLPageState` objects from the core library. For more about these types, check out the [@tldraw/core](https://github.com/tldraw/core) documentation. **Tip:** The pages and pageStates in tldraw are objects containing `TLPage` and `TLPageState` objects from the [@tldraw/core](https://github.com/tldraw/core) library.
**Important:** In the `pages` object, each `TLPage` object must be keyed under its `id` property. Likewise, each `TLPageState` object must be keyed under its `id`. In addition, each `TLPageState` object must have an `id` that matches its corresponding page. **Important:** In the `pages` object, each `TLPage` object must be keyed under its `id` property. Likewise, each `TLPageState` object must be keyed under its `id`. In addition, each `TLPageState` object must have an `id` that matches its corresponding page.
## Shapes ## Shapes
Your `TLPage` objects may include shapes: objects that fit one of the `TLDrawShape` interfaces listed below. All `TLDrawShapes` extends a common interface: Your `TLPage` objects may include shapes: objects that fit one of the `TldrawShape` interfaces listed below. All `TldrawShapes` extends a common interface:
| Property | Type | Description | | Property | Type | Description |
| --------------------- | ---------------- | --------------------------------------------------------------- | | --------------------- | ------------ | --------------------------------------------------------------- |
| `id` | `string` | A unique ID for the shape. | | `id` | `string` | A unique ID for the shape. |
| `name` | `string` | The shape's name. | | `name` | `string` | The shape's name. |
| `type` | `string` | The shape's type. | | `type` | `string` | The shape's type. |
| `parentId` | `string` | The ID of the shape's parent (a shape or its page). | | `parentId` | `string` | The ID of the shape's parent (a shape or its page). |
| `childIndex` | `number` | The shape's order within its parent's children, indexed from 1. | | `childIndex` | `number` | The shape's order within its parent's children, indexed from 1. |
| `point` | `number[]` | The `[x, y]` position of the shape. | | `point` | `number[]` | The `[x, y]` position of the shape. |
| `rotation` | `number[]` | (optional) The shape's rotation in radians. | | `rotation` | `number[]` | (optional) The shape's rotation in radians. |
| `children` | `string[]` | (optional) The shape's child shape ids. | | `children` | `string[]` | (optional) The shape's child shape ids. |
| `handles` | `TLDrawHandle{}` | (optional) A table of `TLHandle` objects. | | `handles` | `TDHandle{}` | (optional) A table of `TLHandle` objects. |
| `isLocked` | `boolean` | (optional) True if the shape is locked. | | `isLocked` | `boolean` | (optional) True if the shape is locked. |
| `isHidden` | `boolean` | (optional) True if the shape is hidden. | | `isHidden` | `boolean` | (optional) True if the shape is hidden. |
| `isEditing` | `boolean` | (optional) True if the shape is currently editing. | | `isEditing` | `boolean` | (optional) True if the shape is currently editing. |
| `isGenerated` | `boolean` | (optional) True if the shape is generated. | | `isGenerated` | `boolean` | (optional) True if the shape is generated. |
| `isAspectRatioLocked` | `boolean` | (optional) True if the shape's aspect ratio is locked. | | `isAspectRatioLocked` | `boolean` | (optional) True if the shape's aspect ratio is locked. |
> **Important:** In order for re-ordering to work, a shape's `childIndex` values _must_ start from 1, not 0. The page or parent shape's "bottom-most" child should have a `childIndex` of 1. > **Important:** In order for re-ordering to work, a shape's `childIndex` values _must_ start from 1, not 0. The page or parent shape's "bottom-most" child should have a `childIndex` of 1.
@ -197,26 +198,26 @@ A binding is a connection **from** one shape and **to** another shape. At the mo
| `distance` | `number` | The distance from the bound point. | | `distance` | `number` | The distance from the bound point. |
| `point` | `number[]` | A normalized point representing the bound point. | | `point` | `number[]` | A normalized point representing the bound point. |
## `TLDrawState` API ## `TldrawApp` API
You can change the `TLDraw` component's state through an imperative API called `TLDrawState`. To access this API, use the `onMount` callback, or any of the component's callback props, like `onPersist`. You can change the `tldraw` component's state through an imperative API called `TldrawApp`. To access this API, use the `onMount` callback, or any of the component's callback props, like `onPersist`.
```tsx ```tsx
import { TLDraw, TLDrawState } from '@tldraw/tldraw' import { Tldraw, TldrawApp } from '@tldraw/tldraw'
function App() { function App() {
const handleMount = React.useCallback((state: TLDrawState) => { const handleMount = React.useCallback((state: TldrawApp) => {
state.selectAll() state.selectAll()
}, []) }, [])
return <TLDraw onMount={handleMount} /> return <Tldraw onMount={handleMount} />
} }
``` ```
To view the full documentation of the `TLDrawState` API, generate the project's documentation by running `yarn docs` from the root folder, then open the file at: To view the full documentation of the `TldrawApp` API, generate the project's documentation by running `yarn docs` from the root folder, then open the file at:
``` ```
/packages/tldraw/docs/classes/TLDrawState.html /packages/tldraw/docs/classes/TldrawApp.html
``` ```
Here are some useful methods: Here are some useful methods:

View file

@ -5,7 +5,7 @@
"author": "@steveruizok", "author": "@steveruizok",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/tldraw/tldraw.git" "url": "https://github.com/tldraw/tldraw.git"
}, },
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [

View file

@ -1,3 +1,29 @@
## 1.0.0
Breaking
- Renames many props, components (e.g. `TLDrawState` to `TLDrawApp`)
Improvements
- Updates UI in toolbars, menus.
- Simplifies state and context.
- Adds and updtes tests.
- Renames TLDraw to tldraw throughout the app and documentation.
- Renames TLDrawState to TldrawApp, state to app.
- Improves action menu
- Improves dark colors
- Consolidates style menu
- Fixes performance bug with menus
- Fixes text formatting in Text shapes
New
- Adds shape menu
- Adds eraser tool (click or drag to erase)
- Adds `darkMode` prop for controlling dark mode UI.
- Double-click a tool icon to toggle "tool lock". This will prevent the app from returning to the select tool after creating a shape.
## 0.1.17 ## 0.1.17
- Fixes "shifting" bug with drawing tool. Finally! - Fixes "shifting" bug with drawing tool. Finally!
@ -61,7 +87,7 @@
- Extracted into its own repository (`tldraw/core`) and open sourced! :clap: - Extracted into its own repository (`tldraw/core`) and open sourced! :clap:
### TLDraw ### tldraw
- Updated with latest `@tldraw/core`, updated ShapeUtils API. - Updated with latest `@tldraw/core`, updated ShapeUtils API.
@ -71,31 +97,31 @@
- Major change to ShapeUtils API. - Major change to ShapeUtils API.
### TLDraw ### tldraw
- Rewrite utils with new API. - Rewrite utils with new API.
## 0.0.126 ## 0.0.126
### TLDraw ### tldraw
- Swap behavior of command and alt keys in arrow shapes. - Swap behavior of command and alt keys in arrow shapes.
## 0.0.125 ## 0.0.125
### TLDraw ### tldraw
- Bug fixes. - Bug fixes.
## 0.0.124 ## 0.0.124
### TLDraw ### tldraw
- Fix typings. - Fix typings.
## 0.0.123 ## 0.0.123
### TLDraw ### tldraw
- Adds bound shape controls. - Adds bound shape controls.
- Drag a bound shape control to translate shapes in that direction. - Drag a bound shape control to translate shapes in that direction.
@ -104,7 +130,7 @@
## 0.0.122 ## 0.0.122
### TLDraw ### tldraw
- Adds snapping for transforming shapes. - Adds snapping for transforming shapes.
@ -114,20 +140,20 @@
- Adds `snapLines`. - Adds `snapLines`.
### TLDraw ### tldraw
- Adds shape snapping while translating. Hold Command/Control to disable while dragging. - Adds shape snapping while translating. Hold Command/Control to disable while dragging.
## 0.0.120 ## 0.0.120
### TLDraw ### tldraw
- Improves rectangle rendering. - Improves rectangle rendering.
- Improves zoom to fit and zoom to selection. - Improves zoom to fit and zoom to selection.
## 0.0.119 ## 0.0.119
### TLDraw ### tldraw
- Fixes bug with bound arrows after undo. - Fixes bug with bound arrows after undo.
@ -137,7 +163,7 @@
- Improves multiplayer features. - Improves multiplayer features.
### TLDraw ### tldraw
- Fixes bugs in text, arrows, stickies. - Fixes bugs in text, arrows, stickies.
- Adds start binding for new arrows. - Adds start binding for new arrows.
@ -151,7 +177,7 @@
- Improves rendering on Safari. - Improves rendering on Safari.
### TLDraw ### tldraw
- Improves rendering on Safari. - Improves rendering on Safari.
- Minor bug fixes around selection. - Minor bug fixes around selection.
@ -164,32 +190,32 @@
- Adds [side cloning](https://github.com/tldraw/tldraw/pull/149). - Adds [side cloning](https://github.com/tldraw/tldraw/pull/149).
- Improves rendering. - Improves rendering.
### TLDraw ### tldraw
- Adds sticky note [side cloning](https://github.com/tldraw/tldraw/pull/149). - Adds sticky note [side cloning](https://github.com/tldraw/tldraw/pull/149).
## 0.0.114 ## 0.0.114
### TLDraw ### tldraw
- Improves fills for filled shapes. - Improves fills for filled shapes.
## 0.0.113 ## 0.0.113
### TLDraw ### tldraw
- Improves grouping and ungrouping. - Improves grouping and ungrouping.
## 0.0.112 ## 0.0.112
### TLDraw ### tldraw
- Fixes centering on embedded TLDraw components. - Fixes centering on embedded tldraw components.
- Removes expensive calls to window. - Removes expensive calls to window.
## 0.0.111 ## 0.0.111
### TLDraw ### tldraw
- Adjust stroke widths and sizes. - Adjust stroke widths and sizes.
- Fixes a bug on very small dashed shapes. - Fixes a bug on very small dashed shapes.
@ -200,7 +226,7 @@
- Adds `user` and `users` props (optional) for multiplayer cursors. This feature is very lightly implemented. - Adds `user` and `users` props (optional) for multiplayer cursors. This feature is very lightly implemented.
### TLDraw ### tldraw
- Adds multiplayer support. - Adds multiplayer support.
- Adds `showMenu` and `showPages` props. - Adds `showMenu` and `showPages` props.
@ -208,7 +234,7 @@
## 0.0.109 ## 0.0.109
### TLDraw ### tldraw
- Bumps perfect-freehand - Bumps perfect-freehand
- Fixes dots for small sized draw shapes - Fixes dots for small sized draw shapes
@ -217,6 +243,6 @@
- Adds CHANGELOG. Only 108 releases late! - Adds CHANGELOG. Only 108 releases late!
### TLDraw ### tldraw
- Fixes a bug with bounding boxes on arrows. - Fixes a bug with bounding boxes on arrows.

View file

@ -4,10 +4,10 @@
# @tldraw/tldraw # @tldraw/tldraw
This package contains the [TLDraw](https://tldraw.com) editor as a React component named `<TLDraw>`. You can use this package to embed the editor in any React application. This package contains the [tldraw](https://tldraw.com) editor as a React component named `<Tldraw>`. You can use this package to embed the editor in any React application.
🎨 Want to build your own TLDraw-ish app instead? Try [@tldraw/core](https://github.com/tldraw/core). 🎨 Want to build your own tldraw-ish app instead? Try [@tldraw/core](https://github.com/tldraw/core).
💕 Love this library? Consider [becoming a sponsor](https://github.com/sponsors/steveruizok?frequency=recurring&sponsor=steveruizok). 💕 Love this library? Consider [becoming a sponsor](https://github.com/sponsors/steveruizok?frequency=recurring&sponsor=steveruizok).
For documentation, see the [TLDraw](https://github.com/tldraw) repository. For documentation, see the [tldraw](https://github.com/tldraw) repository.

View file

@ -5,7 +5,7 @@
"author": "@steveruizok", "author": "@steveruizok",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/tldraw/tldraw.git" "url": "https://github.com/tldraw/tldraw.git"
}, },
"license": "MIT", "license": "MIT",
"keywords": [ "keywords": [
@ -59,4 +59,4 @@
"tsconfig-replace-paths": "^0.0.5" "tsconfig-replace-paths": "^0.0.5"
}, },
"gitHead": "083b36e167b6911927a6b58cbbb830b11b33f00a" "gitHead": "083b36e167b6911927a6b58cbbb830b11b33f00a"
} }

View file

@ -1,17 +1,17 @@
import * as React from 'react' import * as React from 'react'
import { render, waitFor } from '@testing-library/react' import { render, waitFor } from '@testing-library/react'
import { TLDraw } from './TLDraw' import { Tldraw } from './Tldraw'
describe('tldraw', () => { describe('Tldraw', () => {
test('mounts component and calls onMount', async () => { test('mounts component and calls onMount', async () => {
const onMount = jest.fn() const onMount = jest.fn()
render(<TLDraw onMount={onMount} />) render(<Tldraw onMount={onMount} />)
await waitFor(onMount) await waitFor(onMount)
}) })
test('mounts component and calls onMount when id is present', async () => { test('mounts component and calls onMount when id is present', async () => {
const onMount = jest.fn() const onMount = jest.fn()
render(<TLDraw id="someId" onMount={onMount} />) render(<Tldraw id="someId" onMount={onMount} />)
await waitFor(onMount) await waitFor(onMount)
}) })
}) })

View file

@ -2,15 +2,9 @@ import * as React from 'react'
import { IdProvider } from '@radix-ui/react-id' import { IdProvider } from '@radix-ui/react-id'
import { Renderer } from '@tldraw/core' import { Renderer } from '@tldraw/core'
import { styled, dark } from '~styles' import { styled, dark } from '~styles'
import { TLDrawSnapshot, TLDrawDocument, TLDrawStatus, TLDrawUser } from '~types' import { TDDocument, TDStatus, TDUser } from '~types'
import { TLDrawCallbacks, TLDrawState } from '~state' import { TldrawApp, TDCallbacks } from '~state'
import { import { TldrawContext, useStylesheet, useKeyboardShortcuts, useTldrawApp } from '~hooks'
TLDrawContext,
TLDrawContextType,
useStylesheet,
useKeyboardShortcuts,
useTLDrawContext,
} from '~hooks'
import { shapeUtils } from '~state/shapes' import { shapeUtils } from '~state/shapes'
import { ToolsPanel } from '~components/ToolsPanel' import { ToolsPanel } from '~components/ToolsPanel'
import { TopPanel } from '~components/TopPanel' import { TopPanel } from '~components/TopPanel'
@ -18,29 +12,7 @@ import { TLDR } from '~state/TLDR'
import { ContextMenu } from '~components/ContextMenu' import { ContextMenu } from '~components/ContextMenu'
import { FocusButton } from '~components/FocusButton/FocusButton' import { FocusButton } from '~components/FocusButton/FocusButton'
// Selectors export interface TldrawProps extends TDCallbacks {
const isInSelectSelector = (s: TLDrawSnapshot) => s.appState.activeTool === 'select'
const isHideBoundsShapeSelector = (s: TLDrawSnapshot) => {
const { shapes } = s.document.pages[s.appState.currentPageId]
const { selectedIds } = s.document.pageStates[s.appState.currentPageId]
return (
selectedIds.length === 1 &&
selectedIds.every((id) => TLDR.getShapeUtils(shapes[id].type).hideBounds)
)
}
const pageSelector = (s: TLDrawSnapshot) => s.document.pages[s.appState.currentPageId]
const snapLinesSelector = (s: TLDrawSnapshot) => s.appState.snapLines
const usersSelector = (s: TLDrawSnapshot) => s.room?.users
const pageStateSelector = (s: TLDrawSnapshot) => s.document.pageStates[s.appState.currentPageId]
const settingsSelector = (s: TLDrawSnapshot) => s.settings
export interface TLDrawProps extends TLDrawCallbacks {
/** /**
* (optional) If provided, the component will load / persist state under this key. * (optional) If provided, the component will load / persist state under this key.
*/ */
@ -49,7 +21,7 @@ export interface TLDrawProps extends TLDrawCallbacks {
/** /**
* (optional) The document to load or update from. * (optional) The document to load or update from.
*/ */
document?: TLDrawDocument document?: TDDocument
/** /**
* (optional) The current page id. * (optional) The current page id.
@ -86,6 +58,11 @@ export interface TLDrawProps extends TLDrawCallbacks {
*/ */
showTools?: boolean showTools?: boolean
/**
* (optional) Whether to show a sponsor link for Tldraw.
*/
showSponsorLink?: boolean
/** /**
* (optional) Whether to show the UI. * (optional) Whether to show the UI.
*/ */
@ -96,69 +73,81 @@ export interface TLDrawProps extends TLDrawCallbacks {
*/ */
readOnly?: boolean readOnly?: boolean
/**
* (optional) Whether to to show the app's dark mode UI.
*/
darkMode?: boolean
/** /**
* (optional) A callback to run when the component mounts. * (optional) A callback to run when the component mounts.
*/ */
onMount?: (state: TLDrawState) => void onMount?: (state: TldrawApp) => void
/** /**
* (optional) A callback to run when the user creates a new project through the menu or through a keyboard shortcut. * (optional) A callback to run when the user creates a new project through the menu or through a keyboard shortcut.
*/ */
onNewProject?: (state: TLDrawState, e?: KeyboardEvent) => void onNewProject?: (state: TldrawApp, e?: KeyboardEvent) => void
/** /**
* (optional) A callback to run when the user saves a project through the menu or through a keyboard shortcut. * (optional) A callback to run when the user saves a project through the menu or through a keyboard shortcut.
*/ */
onSaveProject?: (state: TLDrawState, e?: KeyboardEvent) => void onSaveProject?: (state: TldrawApp, e?: KeyboardEvent) => void
/** /**
* (optional) A callback to run when the user saves a project as a new project through the menu or through a keyboard shortcut. * (optional) A callback to run when the user saves a project as a new project through the menu or through a keyboard shortcut.
*/ */
onSaveProjectAs?: (state: TLDrawState, e?: KeyboardEvent) => void onSaveProjectAs?: (state: TldrawApp, e?: KeyboardEvent) => void
/** /**
* (optional) A callback to run when the user opens new project through the menu or through a keyboard shortcut. * (optional) A callback to run when the user opens new project through the menu or through a keyboard shortcut.
*/ */
onOpenProject?: (state: TLDrawState, e?: KeyboardEvent) => void onOpenProject?: (state: TldrawApp, e?: KeyboardEvent) => void
/** /**
* (optional) A callback to run when the user signs in via the menu. * (optional) A callback to run when the user signs in via the menu.
*/ */
onSignIn?: (state: TLDrawState) => void onSignIn?: (state: TldrawApp) => void
/** /**
* (optional) A callback to run when the user signs out via the menu. * (optional) A callback to run when the user signs out via the menu.
*/ */
onSignOut?: (state: TLDrawState) => void onSignOut?: (state: TldrawApp) => void
/** /**
* (optional) A callback to run when the user creates a new project. * (optional) A callback to run when the user creates a new project.
*/ */
onUserChange?: (state: TLDrawState, user: TLDrawUser) => void onUserChange?: (state: TldrawApp, user: TDUser) => void
/** /**
* (optional) A callback to run when the component's state changes. * (optional) A callback to run when the component's state changes.
*/ */
onChange?: (state: TLDrawState, reason?: string) => void onChange?: (state: TldrawApp, reason?: string) => void
/** /**
* (optional) A callback to run when the state is patched. * (optional) A callback to run when the state is patched.
*/ */
onPatch?: (state: TLDrawState, reason?: string) => void onPatch?: (state: TldrawApp, reason?: string) => void
/** /**
* (optional) A callback to run when the state is changed with a command. * (optional) A callback to run when the state is changed with a command.
*/ */
onCommand?: (state: TLDrawState, reason?: string) => void onCommand?: (state: TldrawApp, reason?: string) => void
/** /**
* (optional) A callback to run when the state is persisted. * (optional) A callback to run when the state is persisted.
*/ */
onPersist?: (state: TLDrawState) => void onPersist?: (state: TldrawApp) => void
/** /**
* (optional) A callback to run when the user undos. * (optional) A callback to run when the user undos.
*/ */
onUndo?: (state: TLDrawState) => void onUndo?: (state: TldrawApp) => void
/** /**
* (optional) A callback to run when the user redos. * (optional) A callback to run when the user redos.
*/ */
onRedo?: (state: TLDrawState) => void onRedo?: (state: TldrawApp) => void
} }
export function TLDraw({ export function Tldraw({
id, id,
document, document,
currentPageId, currentPageId,
darkMode = false,
autofocus = true, autofocus = true,
showMenu = true, showMenu = true,
showPages = true, showPages = true,
@ -167,6 +156,7 @@ export function TLDraw({
showStyles = true, showStyles = true,
showUI = true, showUI = true,
readOnly = false, readOnly = false,
showSponsorLink = false,
onMount, onMount,
onChange, onChange,
onUserChange, onUserChange,
@ -181,12 +171,13 @@ export function TLDraw({
onPersist, onPersist,
onPatch, onPatch,
onCommand, onCommand,
}: TLDrawProps) { }: TldrawProps) {
const [sId, setSId] = React.useState(id) const [sId, setSId] = React.useState(id)
const [state, setState] = React.useState( // Create a new app when the component mounts.
const [app, setApp] = React.useState(
() => () =>
new TLDrawState(id, { new TldrawApp(id, {
onMount, onMount,
onChange, onChange,
onUserChange, onUserChange,
@ -204,15 +195,11 @@ export function TLDraw({
}) })
) )
const [context, setContext] = React.useState<TLDrawContextType>(() => ({ // Create a new app if the `id` prop changes.
state,
useSelector: state.useStore,
}))
React.useEffect(() => { React.useEffect(() => {
if (id === sId) return if (id === sId) return
const newState = new TLDrawState(id, { const newApp = new TldrawApp(id, {
onMount, onMount,
onChange, onChange,
onUserChange, onUserChange,
@ -231,31 +218,42 @@ export function TLDraw({
setSId(id) setSId(id)
setContext((ctx) => ({ setApp(newApp)
...ctx,
state: newState,
useSelector: newState.useStore,
}))
setState(newState)
}, [sId, id]) }, [sId, id])
React.useEffect(() => { // Update the document if the `document` prop changes but the ids,
state.readOnly = readOnly // are the same, or else load a new document if the ids are different.
}, [state, readOnly])
React.useEffect(() => { React.useEffect(() => {
if (!document) return if (!document) return
if (document.id === state.document.id) { if (document.id === app.document.id) {
state.updateDocument(document) app.updateDocument(document)
} else { } else {
state.loadDocument(document) app.loadDocument(document)
} }
}, [document, state]) }, [document, app])
// Change the page when the `currentPageId` prop changes
React.useEffect(() => { React.useEffect(() => {
state.callbacks = { if (!currentPageId) return
app.changePage(currentPageId)
}, [currentPageId, app])
// Toggle the app's readOnly mode when the `readOnly` prop changes
React.useEffect(() => {
app.readOnly = readOnly
}, [app, readOnly])
// Toggle the app's readOnly mode when the `readOnly` prop changes
React.useEffect(() => {
if (darkMode && !app.settings.isDarkMode) {
// app.toggleDarkMode()
}
}, [app, darkMode])
// Update the app's callbacks when any callback changes.
React.useEffect(() => {
app.callbacks = {
onMount, onMount,
onChange, onChange,
onUserChange, onUserChange,
@ -272,7 +270,7 @@ export function TLDraw({
onPersist, onPersist,
} }
}, [ }, [
state, app,
onMount, onMount,
onChange, onChange,
onUserChange, onUserChange,
@ -291,12 +289,11 @@ export function TLDraw({
// Use the `key` to ensure that new selector hooks are made when the id changes // Use the `key` to ensure that new selector hooks are made when the id changes
return ( return (
<TLDrawContext.Provider value={context}> <TldrawContext.Provider value={app}>
<IdProvider> <IdProvider>
<InnerTLDraw <InnerTldraw
key={sId || 'tldraw'} key={sId || 'Tldraw'}
id={sId} id={sId}
currentPageId={currentPageId}
autofocus={autofocus} autofocus={autofocus}
showPages={showPages} showPages={showPages}
showMenu={showMenu} showMenu={showMenu}
@ -304,16 +301,16 @@ export function TLDraw({
showZoom={showZoom} showZoom={showZoom}
showTools={showTools} showTools={showTools}
showUI={showUI} showUI={showUI}
showSponsorLink={showSponsorLink}
readOnly={readOnly} readOnly={readOnly}
/> />
</IdProvider> </IdProvider>
</TLDrawContext.Provider> </TldrawContext.Provider>
) )
} }
interface InnerTLDrawProps { interface InnerTldrawProps {
id?: string id?: string
currentPageId?: string
autofocus: boolean autofocus: boolean
showPages: boolean showPages: boolean
showMenu: boolean showMenu: boolean
@ -321,44 +318,49 @@ interface InnerTLDrawProps {
showStyles: boolean showStyles: boolean
showUI: boolean showUI: boolean
showTools: boolean showTools: boolean
showSponsorLink: boolean
readOnly: boolean readOnly: boolean
} }
const InnerTLDraw = React.memo(function InnerTLDraw({ const InnerTldraw = React.memo(function InnerTldraw({
id, id,
currentPageId,
autofocus, autofocus,
showPages, showPages,
showMenu, showMenu,
showZoom, showZoom,
showStyles, showStyles,
showTools, showTools,
showSponsorLink,
readOnly, readOnly,
showUI, showUI,
}: InnerTLDrawProps) { }: InnerTldrawProps) {
const { state, useSelector } = useTLDrawContext() const app = useTldrawApp()
const rWrapper = React.useRef<HTMLDivElement>(null) const rWrapper = React.useRef<HTMLDivElement>(null)
const page = useSelector(pageSelector) const state = app.useStore()
const pageState = useSelector(pageStateSelector) const { document, settings, appState, room } = state
const snapLines = useSelector(snapLinesSelector) const isSelecting = state.appState.activeTool === 'select'
const users = useSelector(usersSelector) const page = document.pages[appState.currentPageId]
const pageState = document.pageStates[page.id]
const { selectedIds } = state.document.pageStates[page.id]
const settings = useSelector(settingsSelector) const isHideBoundsShape =
pageState.selectedIds.length === 1 &&
TLDR.getShapeUtil(page.shapes[selectedIds[0]].type).hideBounds
const isSelecting = useSelector(isInSelectSelector) const isHideResizeHandlesShape =
selectedIds.length === 1 &&
TLDR.getShapeUtil(page.shapes[selectedIds[0]].type).hideResizeHandles
const isHideBoundsShape = useSelector(isHideBoundsShapeSelector) const isInSession = app.session !== undefined
const isInSession = state.session !== undefined
// Hide bounds when not using the select tool, or when the only selected shape has handles // Hide bounds when not using the select tool, or when the only selected shape has handles
const hideBounds = const hideBounds =
(isInSession && state.session?.constructor.name !== 'BrushSession') || (isInSession && app.session?.constructor.name !== 'BrushSession') ||
!isSelecting || !isSelecting ||
isHideBoundsShape || isHideBoundsShape ||
!!pageState.editingId !!pageState.editingId
@ -368,10 +370,12 @@ const InnerTLDraw = React.memo(function InnerTLDraw({
// Hide indicators when not using the select tool, or when in session // Hide indicators when not using the select tool, or when in session
const hideIndicators = const hideIndicators =
(isInSession && state.appState.status !== TLDrawStatus.Brushing) || !isSelecting (isInSession && state.appState.status !== TDStatus.Brushing) || !isSelecting
// Custom rendering meta, with dark mode for shapes // Custom rendering meta, with dark mode for shapes
const meta = React.useMemo(() => ({ isDarkMode: settings.isDarkMode }), [settings.isDarkMode]) const meta = React.useMemo(() => {
return { isDarkMode: settings.isDarkMode }
}, [settings.isDarkMode])
// Custom theme, based on darkmode // Custom theme, based on darkmode
const theme = React.useMemo(() => { const theme = React.useMemo(() => {
@ -381,7 +385,7 @@ const InnerTLDraw = React.memo(function InnerTLDraw({
brushStroke: 'rgba(180, 180, 180, .25)', brushStroke: 'rgba(180, 180, 180, .25)',
selected: 'rgba(38, 150, 255, 1.000)', selected: 'rgba(38, 150, 255, 1.000)',
selectFill: 'rgba(38, 150, 255, 0.05)', selectFill: 'rgba(38, 150, 255, 0.05)',
background: '#343d45', background: '#212529',
foreground: '#49555f', foreground: '#49555f',
} }
} }
@ -389,11 +393,6 @@ const InnerTLDraw = React.memo(function InnerTLDraw({
return {} return {}
}, [settings.isDarkMode]) }, [settings.isDarkMode])
React.useEffect(() => {
if (!currentPageId) return
state.changePage(currentPageId)
}, [currentPageId, state])
// When the context menu is blurred, close the menu by sending pointer events // When the context menu is blurred, close the menu by sending pointer events
// to the context menu's ref. This is a hack around the fact that certain shapes // to the context menu's ref. This is a hack around the fact that certain shapes
// stop event propagation, which causes the menu to stay open even when blurred. // stop event propagation, which causes the menu to stay open even when blurred.
@ -415,72 +414,73 @@ const InnerTLDraw = React.memo(function InnerTLDraw({
shapeUtils={shapeUtils} shapeUtils={shapeUtils}
page={page} page={page}
pageState={pageState} pageState={pageState}
snapLines={snapLines} snapLines={appState.snapLines}
users={users} users={room?.users}
userId={state.state.room?.userId} userId={room?.userId}
theme={theme} theme={theme}
meta={meta} meta={meta}
hideBounds={hideBounds} hideBounds={hideBounds}
hideHandles={hideHandles} hideHandles={hideHandles}
hideResizeHandles={isHideResizeHandlesShape}
hideIndicators={hideIndicators} hideIndicators={hideIndicators}
hideBindingHandles={!settings.showBindingHandles} hideBindingHandles={!settings.showBindingHandles}
hideCloneHandles={!settings.showCloneHandles} hideCloneHandles={!settings.showCloneHandles}
hideRotateHandles={!settings.showRotateHandles} hideRotateHandles={!settings.showRotateHandles}
onPinchStart={state.onPinchStart} onPinchStart={app.onPinchStart}
onPinchEnd={state.onPinchEnd} onPinchEnd={app.onPinchEnd}
onPinch={state.onPinch} onPinch={app.onPinch}
onPan={state.onPan} onPan={app.onPan}
onZoom={state.onZoom} onZoom={app.onZoom}
onPointerDown={state.onPointerDown} onPointerDown={app.onPointerDown}
onPointerMove={state.onPointerMove} onPointerMove={app.onPointerMove}
onPointerUp={state.onPointerUp} onPointerUp={app.onPointerUp}
onPointCanvas={state.onPointCanvas} onPointCanvas={app.onPointCanvas}
onDoubleClickCanvas={state.onDoubleClickCanvas} onDoubleClickCanvas={app.onDoubleClickCanvas}
onRightPointCanvas={state.onRightPointCanvas} onRightPointCanvas={app.onRightPointCanvas}
onDragCanvas={state.onDragCanvas} onDragCanvas={app.onDragCanvas}
onReleaseCanvas={state.onReleaseCanvas} onReleaseCanvas={app.onReleaseCanvas}
onPointShape={state.onPointShape} onPointShape={app.onPointShape}
onDoubleClickShape={state.onDoubleClickShape} onDoubleClickShape={app.onDoubleClickShape}
onRightPointShape={state.onRightPointShape} onRightPointShape={app.onRightPointShape}
onDragShape={state.onDragShape} onDragShape={app.onDragShape}
onHoverShape={state.onHoverShape} onHoverShape={app.onHoverShape}
onUnhoverShape={state.onUnhoverShape} onUnhoverShape={app.onUnhoverShape}
onReleaseShape={state.onReleaseShape} onReleaseShape={app.onReleaseShape}
onPointBounds={state.onPointBounds} onPointBounds={app.onPointBounds}
onDoubleClickBounds={state.onDoubleClickBounds} onDoubleClickBounds={app.onDoubleClickBounds}
onRightPointBounds={state.onRightPointBounds} onRightPointBounds={app.onRightPointBounds}
onDragBounds={state.onDragBounds} onDragBounds={app.onDragBounds}
onHoverBounds={state.onHoverBounds} onHoverBounds={app.onHoverBounds}
onUnhoverBounds={state.onUnhoverBounds} onUnhoverBounds={app.onUnhoverBounds}
onReleaseBounds={state.onReleaseBounds} onReleaseBounds={app.onReleaseBounds}
onPointBoundsHandle={state.onPointBoundsHandle} onPointBoundsHandle={app.onPointBoundsHandle}
onDoubleClickBoundsHandle={state.onDoubleClickBoundsHandle} onDoubleClickBoundsHandle={app.onDoubleClickBoundsHandle}
onRightPointBoundsHandle={state.onRightPointBoundsHandle} onRightPointBoundsHandle={app.onRightPointBoundsHandle}
onDragBoundsHandle={state.onDragBoundsHandle} onDragBoundsHandle={app.onDragBoundsHandle}
onHoverBoundsHandle={state.onHoverBoundsHandle} onHoverBoundsHandle={app.onHoverBoundsHandle}
onUnhoverBoundsHandle={state.onUnhoverBoundsHandle} onUnhoverBoundsHandle={app.onUnhoverBoundsHandle}
onReleaseBoundsHandle={state.onReleaseBoundsHandle} onReleaseBoundsHandle={app.onReleaseBoundsHandle}
onPointHandle={state.onPointHandle} onPointHandle={app.onPointHandle}
onDoubleClickHandle={state.onDoubleClickHandle} onDoubleClickHandle={app.onDoubleClickHandle}
onRightPointHandle={state.onRightPointHandle} onRightPointHandle={app.onRightPointHandle}
onDragHandle={state.onDragHandle} onDragHandle={app.onDragHandle}
onHoverHandle={state.onHoverHandle} onHoverHandle={app.onHoverHandle}
onUnhoverHandle={state.onUnhoverHandle} onUnhoverHandle={app.onUnhoverHandle}
onReleaseHandle={state.onReleaseHandle} onReleaseHandle={app.onReleaseHandle}
onError={state.onError} onError={app.onError}
onRenderCountChange={state.onRenderCountChange} onRenderCountChange={app.onRenderCountChange}
onShapeChange={state.onShapeChange} onShapeChange={app.onShapeChange}
onShapeBlur={state.onShapeBlur} onShapeBlur={app.onShapeBlur}
onShapeClone={state.onShapeClone} onShapeClone={app.onShapeClone}
onBoundsChange={state.updateBounds} onBoundsChange={app.updateBounds}
onKeyDown={state.onKeyDown} onKeyDown={app.onKeyDown}
onKeyUp={state.onKeyUp} onKeyUp={app.onKeyUp}
/> />
</ContextMenu> </ContextMenu>
{showUI && ( {showUI && (
<StyledUI> <StyledUI>
{settings.isFocusMode ? ( {settings.isFocusMode ? (
<FocusButton onSelect={state.toggleFocusMode} /> <FocusButton onSelect={app.toggleFocusMode} />
) : ( ) : (
<> <>
<TopPanel <TopPanel
@ -489,6 +489,7 @@ const InnerTLDraw = React.memo(function InnerTLDraw({
showMenu={showMenu} showMenu={showMenu}
showStyles={showStyles} showStyles={showStyles}
showZoom={showZoom} showZoom={showZoom}
showSponsorLink={showSponsorLink}
/> />
<StyledSpacer /> <StyledSpacer />
{showTools && !readOnly && <ToolsPanel />} {showTools && !readOnly && <ToolsPanel />}
@ -539,6 +540,13 @@ const StyledLayout = styled('div', {
width: '100%', width: '100%',
zIndex: 1, zIndex: 1,
}, },
'& input, textarea, button, select, label, button': {
webkitTouchCallout: 'none',
webkitUserSelect: 'none',
'-webkit-tap-highlight-color': 'transparent',
'tap-highlight-color': 'transparent',
},
}) })
const StyledUI = styled('div', { const StyledUI = styled('div', {

View file

@ -2,9 +2,9 @@ import * as React from 'react'
import { ContextMenuItem } from '@radix-ui/react-context-menu' import { ContextMenuItem } from '@radix-ui/react-context-menu'
import { RowButton, RowButtonProps } from '~components/RowButton' import { RowButton, RowButtonProps } from '~components/RowButton'
export const CMRowButton = ({ onSelect, ...rest }: RowButtonProps) => { export const CMRowButton = ({ ...rest }: RowButtonProps) => {
return ( return (
<ContextMenuItem asChild onSelect={onSelect}> <ContextMenuItem asChild>
<RowButton {...rest} /> <RowButton {...rest} />
</ContextMenuItem> </ContextMenuItem>
) )

View file

@ -1,8 +1,8 @@
import * as React from 'react' import * as React from 'react'
import { styled } from '~styles' import { styled } from '~styles'
import * as RadixContextMenu from '@radix-ui/react-context-menu' import * as RadixContextMenu from '@radix-ui/react-context-menu'
import { useTLDrawContext } from '~hooks' import { useTldrawApp } from '~hooks'
import { TLDrawSnapshot, AlignType, DistributeType, StretchType } from '~types' import { TDSnapshot, AlignType, DistributeType, StretchType } from '~types'
import { import {
AlignBottomIcon, AlignBottomIcon,
AlignCenterHorizontallyIcon, AlignCenterHorizontallyIcon,
@ -21,21 +21,21 @@ import { CMTriggerButton } from './CMTriggerButton'
import { Divider } from '~components/Divider' import { Divider } from '~components/Divider'
import { MenuContent } from '~components/MenuContent' import { MenuContent } from '~components/MenuContent'
const has1SelectedIdsSelector = (s: TLDrawSnapshot) => { const has1SelectedIdsSelector = (s: TDSnapshot) => {
return s.document.pageStates[s.appState.currentPageId].selectedIds.length > 0 return s.document.pageStates[s.appState.currentPageId].selectedIds.length > 0
} }
const has2SelectedIdsSelector = (s: TLDrawSnapshot) => { const has2SelectedIdsSelector = (s: TDSnapshot) => {
return s.document.pageStates[s.appState.currentPageId].selectedIds.length > 1 return s.document.pageStates[s.appState.currentPageId].selectedIds.length > 1
} }
const has3SelectedIdsSelector = (s: TLDrawSnapshot) => { const has3SelectedIdsSelector = (s: TDSnapshot) => {
return s.document.pageStates[s.appState.currentPageId].selectedIds.length > 2 return s.document.pageStates[s.appState.currentPageId].selectedIds.length > 2
} }
const isDebugModeSelector = (s: TLDrawSnapshot) => { const isDebugModeSelector = (s: TDSnapshot) => {
return s.settings.isDebugMode return s.settings.isDebugMode
} }
const hasGroupSelectedSelector = (s: TLDrawSnapshot) => { const hasGroupSelectedSelector = (s: TDSnapshot) => {
return s.document.pageStates[s.appState.currentPageId].selectedIds.some( return s.document.pageStates[s.appState.currentPageId].selectedIds.some(
(id) => s.document.pages[s.appState.currentPageId].shapes[id].children !== undefined (id) => s.document.pages[s.appState.currentPageId].shapes[id].children !== undefined
) )
@ -49,77 +49,81 @@ interface ContextMenuProps {
} }
export const ContextMenu = ({ onBlur, children }: ContextMenuProps): JSX.Element => { export const ContextMenu = ({ onBlur, children }: ContextMenuProps): JSX.Element => {
const { state, useSelector } = useTLDrawContext() const app = useTldrawApp()
const hasSelection = useSelector(has1SelectedIdsSelector) const hasSelection = app.useStore(has1SelectedIdsSelector)
const hasTwoOrMore = useSelector(has2SelectedIdsSelector) const hasTwoOrMore = app.useStore(has2SelectedIdsSelector)
const hasThreeOrMore = useSelector(has3SelectedIdsSelector) const hasThreeOrMore = app.useStore(has3SelectedIdsSelector)
const isDebugMode = useSelector(isDebugModeSelector) const isDebugMode = app.useStore(isDebugModeSelector)
const hasGroupSelected = useSelector(hasGroupSelectedSelector) const hasGroupSelected = app.useStore(hasGroupSelectedSelector)
const rContent = React.useRef<HTMLDivElement>(null) const rContent = React.useRef<HTMLDivElement>(null)
const handleFlipHorizontal = React.useCallback(() => { const handleFlipHorizontal = React.useCallback(() => {
state.flipHorizontal() app.flipHorizontal()
}, [state]) }, [app])
const handleFlipVertical = React.useCallback(() => { const handleFlipVertical = React.useCallback(() => {
state.flipVertical() app.flipVertical()
}, [state]) }, [app])
const handleDuplicate = React.useCallback(() => { const handleDuplicate = React.useCallback(() => {
state.duplicate() app.duplicate()
}, [state]) }, [app])
const handleLock = React.useCallback(() => {
app.toggleLocked()
}, [app])
const handleGroup = React.useCallback(() => { const handleGroup = React.useCallback(() => {
state.group() app.group()
}, [state]) }, [app])
const handleMoveToBack = React.useCallback(() => { const handleMoveToBack = React.useCallback(() => {
state.moveToBack() app.moveToBack()
}, [state]) }, [app])
const handleMoveBackward = React.useCallback(() => { const handleMoveBackward = React.useCallback(() => {
state.moveBackward() app.moveBackward()
}, [state]) }, [app])
const handleMoveForward = React.useCallback(() => { const handleMoveForward = React.useCallback(() => {
state.moveForward() app.moveForward()
}, [state]) }, [app])
const handleMoveToFront = React.useCallback(() => { const handleMoveToFront = React.useCallback(() => {
state.moveToFront() app.moveToFront()
}, [state]) }, [app])
const handleDelete = React.useCallback(() => { const handleDelete = React.useCallback(() => {
state.delete() app.delete()
}, [state]) }, [app])
const handleCopyJson = React.useCallback(() => { const handleCopyJson = React.useCallback(() => {
state.copyJson() app.copyJson()
}, [state]) }, [app])
const handleCopy = React.useCallback(() => { const handleCopy = React.useCallback(() => {
state.copy() app.copy()
}, [state]) }, [app])
const handlePaste = React.useCallback(() => { const handlePaste = React.useCallback(() => {
state.paste() app.paste()
}, [state]) }, [app])
const handleCopySvg = React.useCallback(() => { const handleCopySvg = React.useCallback(() => {
state.copySvg() app.copySvg()
}, [state]) }, [app])
const handleUndo = React.useCallback(() => { const handleUndo = React.useCallback(() => {
state.undo() app.undo()
}, [state]) }, [app])
const handleRedo = React.useCallback(() => { const handleRedo = React.useCallback(() => {
state.redo() app.redo()
}, [state]) }, [app])
return ( return (
<RadixContextMenu.Root> <RadixContextMenu.Root dir="ltr">
<RadixContextMenu.Trigger dir="ltr">{children}</RadixContextMenu.Trigger> <RadixContextMenu.Trigger dir="ltr">{children}</RadixContextMenu.Trigger>
<RadixContextMenu.Content <RadixContextMenu.Content
dir="ltr" dir="ltr"
@ -132,38 +136,41 @@ export const ContextMenu = ({ onBlur, children }: ContextMenuProps): JSX.Element
<MenuContent> <MenuContent>
{hasSelection ? ( {hasSelection ? (
<> <>
<CMRowButton onSelect={handleFlipHorizontal} kbd="⇧H"> <CMRowButton onClick={handleDuplicate} kbd="#D">
Duplicate
</CMRowButton>
<CMRowButton onClick={handleFlipHorizontal} kbd="⇧H">
Flip Horizontal Flip Horizontal
</CMRowButton> </CMRowButton>
<CMRowButton onSelect={handleFlipVertical} kbd="⇧V"> <CMRowButton onClick={handleFlipVertical} kbd="⇧V">
Flip Vertical Flip Vertical
</CMRowButton> </CMRowButton>
<CMRowButton onSelect={handleDuplicate} kbd="#D"> <CMRowButton onClick={handleLock} kbd="#⇧L">
Duplicate Lock / Unlock
</CMRowButton> </CMRowButton>
{(hasTwoOrMore || hasGroupSelected) && <Divider />} {(hasTwoOrMore || hasGroupSelected) && <Divider />}
{hasTwoOrMore && ( {hasTwoOrMore && (
<CMRowButton onSelect={handleGroup} kbd="#G"> <CMRowButton onClick={handleGroup} kbd="#G">
Group Group
</CMRowButton> </CMRowButton>
)} )}
{hasGroupSelected && ( {hasGroupSelected && (
<CMRowButton onSelect={handleGroup} kbd="#⇧G"> <CMRowButton onClick={handleGroup} kbd="#⇧G">
Ungroup Ungroup
</CMRowButton> </CMRowButton>
)} )}
<Divider /> <Divider />
<ContextMenuSubMenu label="Move"> <ContextMenuSubMenu label="Move">
<CMRowButton onSelect={handleMoveToFront} kbd="⇧]"> <CMRowButton onClick={handleMoveToFront} kbd="⇧]">
To Front To Front
</CMRowButton> </CMRowButton>
<CMRowButton onSelect={handleMoveForward} kbd="]"> <CMRowButton onClick={handleMoveForward} kbd="]">
Forward Forward
</CMRowButton> </CMRowButton>
<CMRowButton onSelect={handleMoveBackward} kbd="["> <CMRowButton onClick={handleMoveBackward} kbd="[">
Backward Backward
</CMRowButton> </CMRowButton>
<CMRowButton onSelect={handleMoveToBack} kbd="⇧["> <CMRowButton onClick={handleMoveToBack} kbd="⇧[">
To Back To Back
</CMRowButton> </CMRowButton>
</ContextMenuSubMenu> </ContextMenuSubMenu>
@ -175,30 +182,30 @@ export const ContextMenu = ({ onBlur, children }: ContextMenuProps): JSX.Element
/> />
)} )}
<Divider /> <Divider />
<CMRowButton onSelect={handleCopy} kbd="#C"> <CMRowButton onClick={handleCopy} kbd="#C">
Copy Copy
</CMRowButton> </CMRowButton>
<CMRowButton onSelect={handleCopySvg} kbd="⇧#C"> <CMRowButton onClick={handleCopySvg} kbd="⇧#C">
Copy as SVG Copy as SVG
</CMRowButton> </CMRowButton>
{isDebugMode && <CMRowButton onSelect={handleCopyJson}>Copy as JSON</CMRowButton>} {isDebugMode && <CMRowButton onClick={handleCopyJson}>Copy as JSON</CMRowButton>}
<CMRowButton onSelect={handlePaste} kbd="#V"> <CMRowButton onClick={handlePaste} kbd="#V">
Paste Paste
</CMRowButton> </CMRowButton>
<Divider /> <Divider />
<CMRowButton onSelect={handleDelete} kbd="⌫"> <CMRowButton onClick={handleDelete} kbd="⌫">
Delete Delete
</CMRowButton> </CMRowButton>
</> </>
) : ( ) : (
<> <>
<CMRowButton onSelect={handlePaste} kbd="#V"> <CMRowButton onClick={handlePaste} kbd="#V">
Paste Paste
</CMRowButton> </CMRowButton>
<CMRowButton onSelect={handleUndo} kbd="#Z"> <CMRowButton onClick={handleUndo} kbd="#Z">
Undo Undo
</CMRowButton> </CMRowButton>
<CMRowButton onSelect={handleRedo} kbd="#⇧Z"> <CMRowButton onClick={handleRedo} kbd="#⇧Z">
Redo Redo
</CMRowButton> </CMRowButton>
</> </>
@ -215,84 +222,84 @@ function AlignDistributeSubMenu({
hasTwoOrMore: boolean hasTwoOrMore: boolean
hasThreeOrMore: boolean hasThreeOrMore: boolean
}) { }) {
const { state } = useTLDrawContext() const app = useTldrawApp()
const alignTop = React.useCallback(() => { const alignTop = React.useCallback(() => {
state.align(AlignType.Top) app.align(AlignType.Top)
}, [state]) }, [app])
const alignCenterVertical = React.useCallback(() => { const alignCenterVertical = React.useCallback(() => {
state.align(AlignType.CenterVertical) app.align(AlignType.CenterVertical)
}, [state]) }, [app])
const alignBottom = React.useCallback(() => { const alignBottom = React.useCallback(() => {
state.align(AlignType.Bottom) app.align(AlignType.Bottom)
}, [state]) }, [app])
const stretchVertically = React.useCallback(() => { const stretchVertically = React.useCallback(() => {
state.stretch(StretchType.Vertical) app.stretch(StretchType.Vertical)
}, [state]) }, [app])
const distributeVertically = React.useCallback(() => { const distributeVertically = React.useCallback(() => {
state.distribute(DistributeType.Vertical) app.distribute(DistributeType.Vertical)
}, [state]) }, [app])
const alignLeft = React.useCallback(() => { const alignLeft = React.useCallback(() => {
state.align(AlignType.Left) app.align(AlignType.Left)
}, [state]) }, [app])
const alignCenterHorizontal = React.useCallback(() => { const alignCenterHorizontal = React.useCallback(() => {
state.align(AlignType.CenterHorizontal) app.align(AlignType.CenterHorizontal)
}, [state]) }, [app])
const alignRight = React.useCallback(() => { const alignRight = React.useCallback(() => {
state.align(AlignType.Right) app.align(AlignType.Right)
}, [state]) }, [app])
const stretchHorizontally = React.useCallback(() => { const stretchHorizontally = React.useCallback(() => {
state.stretch(StretchType.Horizontal) app.stretch(StretchType.Horizontal)
}, [state]) }, [app])
const distributeHorizontally = React.useCallback(() => { const distributeHorizontally = React.useCallback(() => {
state.distribute(DistributeType.Horizontal) app.distribute(DistributeType.Horizontal)
}, [state]) }, [app])
return ( return (
<RadixContextMenu.Root> <RadixContextMenu.Root dir="ltr">
<CMTriggerButton isSubmenu>Align / Distribute</CMTriggerButton> <CMTriggerButton isSubmenu>Align / Distribute</CMTriggerButton>
<RadixContextMenu.Content asChild sideOffset={2} alignOffset={-2}> <RadixContextMenu.Content asChild sideOffset={2} alignOffset={-2}>
<StyledGridContent selectedStyle={hasThreeOrMore ? 'threeOrMore' : 'twoOrMore'}> <StyledGridContent selectedStyle={hasThreeOrMore ? 'threeOrMore' : 'twoOrMore'}>
<CMIconButton onSelect={alignLeft}> <CMIconButton onClick={alignLeft}>
<AlignLeftIcon /> <AlignLeftIcon />
</CMIconButton> </CMIconButton>
<CMIconButton onSelect={alignCenterHorizontal}> <CMIconButton onClick={alignCenterHorizontal}>
<AlignCenterHorizontallyIcon /> <AlignCenterHorizontallyIcon />
</CMIconButton> </CMIconButton>
<CMIconButton onSelect={alignRight}> <CMIconButton onClick={alignRight}>
<AlignRightIcon /> <AlignRightIcon />
</CMIconButton> </CMIconButton>
<CMIconButton onSelect={stretchHorizontally}> <CMIconButton onClick={stretchHorizontally}>
<StretchHorizontallyIcon /> <StretchHorizontallyIcon />
</CMIconButton> </CMIconButton>
{hasThreeOrMore && ( {hasThreeOrMore && (
<CMIconButton onSelect={distributeHorizontally}> <CMIconButton onClick={distributeHorizontally}>
<SpaceEvenlyHorizontallyIcon /> <SpaceEvenlyHorizontallyIcon />
</CMIconButton> </CMIconButton>
)} )}
<CMIconButton onSelect={alignTop}> <CMIconButton onClick={alignTop}>
<AlignTopIcon /> <AlignTopIcon />
</CMIconButton> </CMIconButton>
<CMIconButton onSelect={alignCenterVertical}> <CMIconButton onClick={alignCenterVertical}>
<AlignCenterVerticallyIcon /> <AlignCenterVerticallyIcon />
</CMIconButton> </CMIconButton>
<CMIconButton onSelect={alignBottom}> <CMIconButton onClick={alignBottom}>
<AlignBottomIcon /> <AlignBottomIcon />
</CMIconButton> </CMIconButton>
<CMIconButton onSelect={stretchVertically}> <CMIconButton onClick={stretchVertically}>
<StretchVerticallyIcon /> <StretchVerticallyIcon />
</CMIconButton> </CMIconButton>
{hasThreeOrMore && ( {hasThreeOrMore && (
<CMIconButton onSelect={distributeVertically}> <CMIconButton onClick={distributeVertically}>
<SpaceEvenlyVerticallyIcon /> <SpaceEvenlyVerticallyIcon />
</CMIconButton> </CMIconButton>
)} )}
@ -319,13 +326,13 @@ const StyledGridContent = styled(MenuContent, {
/* ------------------ Move to Page ------------------ */ /* ------------------ Move to Page ------------------ */
const currentPageIdSelector = (s: TLDrawSnapshot) => s.appState.currentPageId const currentPageIdSelector = (s: TDSnapshot) => s.appState.currentPageId
const documentPagesSelector = (s: TLDrawSnapshot) => s.document.pages const documentPagesSelector = (s: TDSnapshot) => s.document.pages
function MoveToPageMenu(): JSX.Element | null { function MoveToPageMenu(): JSX.Element | null {
const { state, useSelector } = useTLDrawContext() const app = useTldrawApp()
const currentPageId = useSelector(currentPageIdSelector) const currentPageId = app.useStore(currentPageIdSelector)
const documentPages = useSelector(documentPagesSelector) const documentPages = app.useStore(documentPagesSelector)
const sorted = Object.values(documentPages) const sorted = Object.values(documentPages)
.sort((a, b) => (a.childIndex || 0) - (b.childIndex || 0)) .sort((a, b) => (a.childIndex || 0) - (b.childIndex || 0))
@ -342,7 +349,7 @@ function MoveToPageMenu(): JSX.Element | null {
<CMRowButton <CMRowButton
key={id} key={id}
disabled={id === currentPageId} disabled={id === currentPageId}
onSelect={() => state.moveToPage(id)} onClick={() => app.moveToPage(id)}
> >
{name || `Page ${i}`} {name || `Page ${i}`}
</CMRowButton> </CMRowButton>

View file

@ -1,18 +1,21 @@
import * as React from 'react' import * as React from 'react'
import { CheckboxItem } from '@radix-ui/react-dropdown-menu' import { CheckboxItem } from '@radix-ui/react-dropdown-menu'
import { RowButton } from '~components/RowButton' import { RowButton, RowButtonProps } from '~components/RowButton'
import { preventEvent } from '~components/preventEvent'
interface DMCheckboxItemProps { interface DMCheckboxItemProps {
checked: boolean checked: boolean
disabled?: boolean disabled?: boolean
onCheckedChange: (isChecked: boolean) => void onCheckedChange: (isChecked: boolean) => void
children: React.ReactNode children: React.ReactNode
variant?: RowButtonProps['variant']
kbd?: string kbd?: string
} }
export function DMCheckboxItem({ export function DMCheckboxItem({
checked, checked,
disabled = false, disabled = false,
variant,
onCheckedChange, onCheckedChange,
kbd, kbd,
children, children,
@ -20,12 +23,13 @@ export function DMCheckboxItem({
return ( return (
<CheckboxItem <CheckboxItem
dir="ltr" dir="ltr"
onSelect={preventEvent}
onCheckedChange={onCheckedChange} onCheckedChange={onCheckedChange}
checked={checked} checked={checked}
disabled={disabled} disabled={disabled}
asChild asChild
> >
<RowButton kbd={kbd} hasIndicator> <RowButton kbd={kbd} variant={variant} hasIndicator>
{children} {children}
</RowButton> </RowButton>
</CheckboxItem> </CheckboxItem>

View file

@ -2,18 +2,29 @@ import * as React from 'react'
import { Content } from '@radix-ui/react-dropdown-menu' import { Content } from '@radix-ui/react-dropdown-menu'
import { styled } from '~styles/stitches.config' import { styled } from '~styles/stitches.config'
import { MenuContent } from '~components/MenuContent' import { MenuContent } from '~components/MenuContent'
import { stopPropagation } from '~components/stopPropagation'
export interface DMContentProps { export interface DMContentProps {
variant?: 'grid' | 'menu' | 'horizontal' variant?: 'menu' | 'horizontal'
align?: 'start' | 'center' | 'end' align?: 'start' | 'center' | 'end'
sideOffset?: number
children: React.ReactNode children: React.ReactNode
} }
const preventDefault = (e: Event) => e.stopPropagation() export function DMContent({
sideOffset = 8,
export function DMContent({ children, align, variant }: DMContentProps): JSX.Element { children,
align,
variant,
}: DMContentProps): JSX.Element {
return ( return (
<Content sideOffset={8} dir="ltr" asChild align={align} onEscapeKeyDown={preventDefault}> <Content
dir="ltr"
align={align}
sideOffset={sideOffset}
onEscapeKeyDown={stopPropagation}
asChild
>
<StyledContent variant={variant}>{children}</StyledContent> <StyledContent variant={variant}>{children}</StyledContent>
</Content> </Content>
) )
@ -25,11 +36,6 @@ export const StyledContent = styled(MenuContent, {
minWidth: 0, minWidth: 0,
variants: { variants: {
variant: { variant: {
grid: {
display: 'grid',
gridTemplateColumns: 'repeat(4, auto)',
gap: 0,
},
horizontal: { horizontal: {
flexDirection: 'row', flexDirection: 'row',
}, },

View file

@ -2,7 +2,10 @@ import * as React from 'react'
import { Item } from '@radix-ui/react-dropdown-menu' import { Item } from '@radix-ui/react-dropdown-menu'
import { RowButton, RowButtonProps } from '~components/RowButton' import { RowButton, RowButtonProps } from '~components/RowButton'
export function DMItem({ onSelect, ...rest }: RowButtonProps): JSX.Element { export function DMItem({
onSelect,
...rest
}: RowButtonProps & { onSelect?: (event: Event) => void }): JSX.Element {
return ( return (
<Item dir="ltr" asChild onSelect={onSelect}> <Item dir="ltr" asChild onSelect={onSelect}>
<RowButton {...rest} /> <RowButton {...rest} />

View file

@ -16,11 +16,32 @@ export const DMRadioItem = styled(RadioItem, {
pointerEvents: 'all', pointerEvents: 'all',
cursor: 'pointer', cursor: 'pointer',
'&:focus': { variants: {
backgroundColor: '$hover', isActive: {
true: {
backgroundColor: '$selected',
color: '$panel',
},
false: {},
},
bp: {
mobile: {},
small: {},
},
}, },
'&:hover:not(:disabled)': { compoundVariants: [
backgroundColor: '$hover', {
}, isActive: false,
bp: 'small',
css: {
'&:focus': {
backgroundColor: '$hover',
},
'&:hover:not(:disabled)': {
backgroundColor: '$hover',
},
},
},
],
}) })

View file

@ -1,15 +1,15 @@
import * as React from 'react' import * as React from 'react'
import { Trigger } from '@radix-ui/react-dropdown-menu' import { Trigger } from '@radix-ui/react-dropdown-menu'
import { ToolButton } from '~components/ToolButton' import { ToolButton, ToolButtonProps } from '~components/ToolButton'
interface DMTriggerIconProps { interface DMTriggerIconProps extends ToolButtonProps {
children: React.ReactNode children: React.ReactNode
} }
export function DMTriggerIcon({ children }: DMTriggerIconProps) { export function DMTriggerIcon({ children, ...rest }: DMTriggerIconProps) {
return ( return (
<Trigger asChild> <Trigger asChild>
<ToolButton>{children}</ToolButton> <ToolButton {...rest}>{children}</ToolButton>
</Trigger> </Trigger>
) )
} }

View file

@ -23,7 +23,7 @@ const StyledButtonContainer = styled('div', {
backgroundColor: 'transparent', backgroundColor: 'transparent',
'& svg': { '& svg': {
color: '$muted', color: '$text',
}, },
'&:hover svg': { '&:hover svg': {

View file

@ -33,8 +33,9 @@ export const StyledKbd = styled('kbd', {
textAlign: 'center', textAlign: 'center',
fontSize: '$0', fontSize: '$0',
fontFamily: '$ui', fontFamily: '$ui',
fontWeight: 400,
color: '$text', color: '$text',
background: 'none',
fontWeight: 400,
gap: '$1', gap: '$1',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@ -51,7 +52,7 @@ export const StyledKbd = styled('kbd', {
variant: { variant: {
tooltip: { tooltip: {
'& > span': { '& > span': {
color: '$tooltipText', color: '$tooltipContrast',
background: '$overlayContrast', background: '$overlayContrast',
boxShadow: '$key', boxShadow: '$key',
width: '20px', width: '20px',

View file

@ -6,17 +6,28 @@ export const Panel = styled('div', {
flexDirection: 'row', flexDirection: 'row',
boxShadow: '$panel', boxShadow: '$panel',
padding: '$2', padding: '$2',
border: '1px solid $panelContrast',
gap: 0,
variants: { variants: {
side: { side: {
center: { center: {
borderTopLeftRadius: '$4', borderRadius: '$4',
borderTopRightRadius: '$4',
}, },
left: { left: {
padding: 0,
borderTop: 0,
borderLeft: 0,
borderTopRightRadius: '$1',
borderBottomRightRadius: '$3', borderBottomRightRadius: '$3',
borderBottomLeftRadius: '$1',
}, },
right: { right: {
padding: 0,
borderTop: 0,
borderRight: 0,
borderTopLeftRadius: '$1',
borderBottomLeftRadius: '$3', borderBottomLeftRadius: '$3',
borderBottomRightRadius: '$1',
}, },
}, },
}, },

View file

@ -7,10 +7,12 @@ import { SmallIcon } from '~components/SmallIcon'
import { styled } from '~styles' import { styled } from '~styles'
export interface RowButtonProps { export interface RowButtonProps {
onSelect?: () => void onClick?: React.MouseEventHandler<HTMLButtonElement>
children: React.ReactNode children: React.ReactNode
disabled?: boolean disabled?: boolean
kbd?: string kbd?: string
variant?: 'wide'
isSponsor?: boolean
isActive?: boolean isActive?: boolean
isWarning?: boolean isWarning?: boolean
hasIndicator?: boolean hasIndicator?: boolean
@ -20,12 +22,14 @@ export interface RowButtonProps {
export const RowButton = React.forwardRef<HTMLButtonElement, RowButtonProps>( export const RowButton = React.forwardRef<HTMLButtonElement, RowButtonProps>(
( (
{ {
onSelect, onClick,
isActive = false, isActive = false,
isWarning = false, isWarning = false,
hasIndicator = false, hasIndicator = false,
hasArrow = false, hasArrow = false,
disabled = false, disabled = false,
isSponsor = false,
variant,
kbd, kbd,
children, children,
...rest ...rest
@ -38,8 +42,10 @@ export const RowButton = React.forwardRef<HTMLButtonElement, RowButtonProps>(
bp={breakpoints} bp={breakpoints}
isWarning={isWarning} isWarning={isWarning}
isActive={isActive} isActive={isActive}
isSponsor={isSponsor}
disabled={disabled} disabled={disabled}
onPointerDown={onSelect} onClick={onClick}
variant={variant}
{...rest} {...rest}
> >
<StyledRowButtonInner> <StyledRowButtonInner>
@ -66,13 +72,10 @@ export const RowButton = React.forwardRef<HTMLButtonElement, RowButtonProps>(
const StyledRowButtonInner = styled('div', { const StyledRowButtonInner = styled('div', {
height: '100%', height: '100%',
width: '100%', width: '100%',
color: '$text',
fontFamily: '$ui',
fontWeight: 400,
fontSize: '$1',
backgroundColor: '$panel', backgroundColor: '$panel',
borderRadius: '$2', borderRadius: '$2',
display: 'flex', display: 'flex',
gap: '$1',
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
padding: '0 $3', padding: '0 $3',
@ -95,6 +98,10 @@ export const StyledRowButton = styled('button', {
cursor: 'pointer', cursor: 'pointer',
height: '32px', height: '32px',
outline: 'none', outline: 'none',
color: '$text',
fontFamily: '$ui',
fontWeight: 400,
fontSize: '$1',
borderRadius: 4, borderRadius: 4,
userSelect: 'none', userSelect: 'none',
margin: 0, margin: 0,
@ -112,17 +119,33 @@ export const StyledRowButton = styled('button', {
backgroundColor: '$hover', backgroundColor: '$hover',
}, },
'& a': {
textDecoration: 'none',
color: '$text',
},
variants: { variants: {
bp: { bp: {
mobile: {}, mobile: {},
small: {}, small: {},
}, },
variant: {
wide: {
gridColumn: '1 / span 4',
},
},
size: { size: {
icon: { icon: {
padding: '4px ', padding: '4px ',
width: 'auto', width: 'auto',
}, },
}, },
isSponsor: {
true: {
color: '#eb30a2',
},
false: {},
},
isWarning: { isWarning: {
true: { true: {
color: '$warn', color: '$warn',
@ -132,7 +155,29 @@ export const StyledRowButton = styled('button', {
true: { true: {
backgroundColor: '$hover', backgroundColor: '$hover',
}, },
false: { false: {},
},
},
compoundVariants: [
{
isActive: false,
isSponsor: true,
bp: 'small',
css: {
[`&:hover:not(:disabled) ${StyledRowButtonInner}`]: {
backgroundColor: '$sponsorContrast',
border: '1px solid $panel',
'& *[data-shy="true"]': {
opacity: 1,
},
},
},
},
{
isActive: false,
isSponsor: false,
bp: 'small',
css: {
[`&:hover:not(:disabled) ${StyledRowButtonInner}`]: { [`&:hover:not(:disabled) ${StyledRowButtonInner}`]: {
backgroundColor: '$hover', backgroundColor: '$hover',
border: '1px solid $panel', border: '1px solid $panel',
@ -142,5 +187,5 @@ export const StyledRowButton = styled('button', {
}, },
}, },
}, },
}, ],
}) })

View file

@ -12,7 +12,7 @@ export const SmallIcon = styled('div', {
border: 'none', border: 'none',
pointerEvents: 'all', pointerEvents: 'all',
cursor: 'pointer', cursor: 'pointer',
color: '$text', color: 'currentColor',
'& svg': { '& svg': {
height: 16, height: 16,

View file

@ -1,32 +1,52 @@
import * as React from 'react' import * as React from 'react'
import { breakpoints } from '~components/breakpoints' import { breakpoints } from '~components/breakpoints'
import { Tooltip } from '~components/Tooltip' import { Tooltip } from '~components/Tooltip'
import { useTLDrawContext } from '~hooks' import { useTldrawApp } from '~hooks'
import { styled } from '~styles' import { styled } from '~styles'
export interface ToolButtonProps { export interface ToolButtonProps {
onClick?: () => void onClick?: () => void
onSelect?: () => void onSelect?: () => void
onDoubleClick?: () => void onDoubleClick?: () => void
disabled?: boolean
isActive?: boolean isActive?: boolean
isSponsor?: boolean
isToolLocked?: boolean
variant?: 'icon' | 'text' | 'circle' | 'primary' variant?: 'icon' | 'text' | 'circle' | 'primary'
children: React.ReactNode children: React.ReactNode
} }
export const ToolButton = React.forwardRef<HTMLButtonElement, ToolButtonProps>( export const ToolButton = React.forwardRef<HTMLButtonElement, ToolButtonProps>(
({ onSelect, onClick, onDoubleClick, isActive = false, variant, children, ...rest }, ref) => { (
{
onSelect,
onClick,
onDoubleClick,
variant,
children,
isToolLocked = false,
disabled = false,
isActive = false,
isSponsor = false,
...rest
},
ref
) => {
return ( return (
<StyledToolButton <StyledToolButton
ref={ref} ref={ref}
isActive={isActive} isActive={isActive}
isSponsor={isSponsor}
variant={variant} variant={variant}
onClick={onClick} onClick={onClick}
disabled={disabled}
onPointerDown={onSelect} onPointerDown={onSelect}
onDoubleClick={onDoubleClick} onDoubleClick={onDoubleClick}
bp={breakpoints} bp={breakpoints}
{...rest} {...rest}
> >
<StyledToolButtonInner>{children}</StyledToolButtonInner> <StyledToolButtonInner>{children}</StyledToolButtonInner>
{isToolLocked && <ToolLockIndicator />}
</StyledToolButton> </StyledToolButton>
) )
} }
@ -36,20 +56,30 @@ export const ToolButton = React.forwardRef<HTMLButtonElement, ToolButtonProps>(
interface ToolButtonWithTooltipProps extends ToolButtonProps { interface ToolButtonWithTooltipProps extends ToolButtonProps {
label: string label: string
isLocked?: boolean
kbd?: string kbd?: string
} }
export function ToolButtonWithTooltip({ label, kbd, ...rest }: ToolButtonWithTooltipProps) { export function ToolButtonWithTooltip({
const { state } = useTLDrawContext() label,
kbd,
isLocked,
...rest
}: ToolButtonWithTooltipProps) {
const app = useTldrawApp()
const handleDoubleClick = React.useCallback(() => { const handleDoubleClick = React.useCallback(() => {
console.log('double clicking') app.toggleToolLock()
state.toggleToolLock()
}, []) }, [])
return ( return (
<Tooltip label={label[0].toUpperCase() + label.slice(1)} kbd={kbd}> <Tooltip label={label[0].toUpperCase() + label.slice(1)} kbd={kbd}>
<ToolButton {...rest} variant="primary" onDoubleClick={handleDoubleClick} /> <ToolButton
{...rest}
variant="primary"
isToolLocked={isLocked && rest.isActive}
onDoubleClick={handleDoubleClick}
/>
</Tooltip> </Tooltip>
) )
} }
@ -112,6 +142,7 @@ export const StyledToolButton = styled('button', {
circle: { circle: {
padding: '$2', padding: '$2',
[`& ${StyledToolButtonInner}`]: { [`& ${StyledToolButtonInner}`]: {
border: '1px solid $panelContrast',
borderRadius: '100%', borderRadius: '100%',
boxShadow: '$panel', boxShadow: '$panel',
}, },
@ -121,23 +152,17 @@ export const StyledToolButton = styled('button', {
}, },
}, },
}, },
isActive: { isSponsor: {
true: { true: {
[`${StyledToolButtonInner}`]: { [`${StyledToolButtonInner}`]: {
backgroundColor: '$selected', backgroundColor: '$sponsorContrast',
color: '$panelActive',
},
},
false: {
[`&:hover:not(:disabled) ${StyledToolButtonInner}`]: {
backgroundColor: '$hover',
border: '1px solid $panel',
},
[`&:focus:not(:disabled) ${StyledToolButtonInner}`]: {
backgroundColor: '$hover',
}, },
}, },
}, },
isActive: {
true: {},
false: {},
},
bp: { bp: {
mobile: {}, mobile: {},
small: {}, small: {},
@ -168,5 +193,40 @@ export const StyledToolButton = styled('button', {
}, },
}, },
}, },
{
isActive: true,
isSponsor: false,
css: {
[`${StyledToolButtonInner}`]: {
backgroundColor: '$selected',
color: '$selectedContrast',
},
},
},
{
isActive: false,
isSponsor: false,
bp: 'small',
css: {
[`&:hover:not(:disabled) ${StyledToolButtonInner}`]: {
backgroundColor: '$hover',
border: '1px solid $panel',
},
[`&:focus:not(:disabled) ${StyledToolButtonInner}`]: {
backgroundColor: '$hover',
},
},
},
], ],
}) })
const ToolLockIndicator = styled('div', {
position: 'absolute',
width: 10,
height: 10,
backgroundColor: '$selected',
borderRadius: '100%',
bottom: -2,
border: '2px solid $panel',
zIndex: 100,
})

View file

@ -1,9 +1,9 @@
import * as React from 'react' import * as React from 'react'
import { Tooltip } from '~components/Tooltip/Tooltip' import { Tooltip } from '~components/Tooltip/Tooltip'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { useTLDrawContext } from '~hooks' import { useTldrawApp } from '~hooks'
import { styled } from '~styles' import { styled } from '~styles'
import { AlignType, TLDrawSnapshot, DistributeType, StretchType } from '~types' import { AlignType, TDSnapshot, DistributeType, StretchType } from '~types'
import { import {
ArrowDownIcon, ArrowDownIcon,
ArrowUpIcon, ArrowUpIcon,
@ -30,25 +30,24 @@ import {
import { DMContent } from '~components/DropdownMenu' import { DMContent } from '~components/DropdownMenu'
import { Divider } from '~components/Divider' import { Divider } from '~components/Divider'
import { TrashIcon } from '~components/icons' import { TrashIcon } from '~components/icons'
import { IconButton } from '~components/IconButton'
import { ToolButton } from '~components/ToolButton' import { ToolButton } from '~components/ToolButton'
const selectedShapesCountSelector = (s: TLDrawSnapshot) => const selectedShapesCountSelector = (s: TDSnapshot) =>
s.document.pageStates[s.appState.currentPageId].selectedIds.length s.document.pageStates[s.appState.currentPageId].selectedIds.length
const isAllLockedSelector = (s: TLDrawSnapshot) => { const isAllLockedSelector = (s: TDSnapshot) => {
const page = s.document.pages[s.appState.currentPageId] const page = s.document.pages[s.appState.currentPageId]
const { selectedIds } = s.document.pageStates[s.appState.currentPageId] const { selectedIds } = s.document.pageStates[s.appState.currentPageId]
return selectedIds.every((id) => page.shapes[id].isLocked) return selectedIds.every((id) => page.shapes[id].isLocked)
} }
const isAllAspectLockedSelector = (s: TLDrawSnapshot) => { const isAllAspectLockedSelector = (s: TDSnapshot) => {
const page = s.document.pages[s.appState.currentPageId] const page = s.document.pages[s.appState.currentPageId]
const { selectedIds } = s.document.pageStates[s.appState.currentPageId] const { selectedIds } = s.document.pageStates[s.appState.currentPageId]
return selectedIds.every((id) => page.shapes[id].isAspectRatioLocked) return selectedIds.every((id) => page.shapes[id].isAspectRatioLocked)
} }
const isAllGroupedSelector = (s: TLDrawSnapshot) => { const isAllGroupedSelector = (s: TDSnapshot) => {
const page = s.document.pages[s.appState.currentPageId] const page = s.document.pages[s.appState.currentPageId]
const selectedShapes = s.document.pageStates[s.appState.currentPageId].selectedIds.map( const selectedShapes = s.document.pageStates[s.appState.currentPageId].selectedIds.map(
(id) => page.shapes[id] (id) => page.shapes[id]
@ -62,110 +61,110 @@ const isAllGroupedSelector = (s: TLDrawSnapshot) => {
) )
} }
const hasSelectionClickor = (s: TLDrawSnapshot) => { const hasSelectionClickor = (s: TDSnapshot) => {
const { selectedIds } = s.document.pageStates[s.appState.currentPageId] const { selectedIds } = s.document.pageStates[s.appState.currentPageId]
return selectedIds.length > 0 return selectedIds.length > 0
} }
const hasMultipleSelectionClickor = (s: TLDrawSnapshot) => { const hasMultipleSelectionClickor = (s: TDSnapshot) => {
const { selectedIds } = s.document.pageStates[s.appState.currentPageId] const { selectedIds } = s.document.pageStates[s.appState.currentPageId]
return selectedIds.length > 1 return selectedIds.length > 1
} }
export function ActionButton(): JSX.Element { export function ActionButton(): JSX.Element {
const { state, useSelector } = useTLDrawContext() const app = useTldrawApp()
const isAllLocked = useSelector(isAllLockedSelector) const isAllLocked = app.useStore(isAllLockedSelector)
const isAllAspectLocked = useSelector(isAllAspectLockedSelector) const isAllAspectLocked = app.useStore(isAllAspectLockedSelector)
const isAllGrouped = useSelector(isAllGroupedSelector) const isAllGrouped = app.useStore(isAllGroupedSelector)
const hasSelection = useSelector(hasSelectionClickor) const hasSelection = app.useStore(hasSelectionClickor)
const hasMultipleSelection = useSelector(hasMultipleSelectionClickor) const hasMultipleSelection = app.useStore(hasMultipleSelectionClickor)
const handleRotate = React.useCallback(() => { const handleRotate = React.useCallback(() => {
state.rotate() app.rotate()
}, [state]) }, [app])
const handleDuplicate = React.useCallback(() => { const handleDuplicate = React.useCallback(() => {
state.duplicate() app.duplicate()
}, [state]) }, [app])
const handleToggleLocked = React.useCallback(() => { const handleToggleLocked = React.useCallback(() => {
state.toggleLocked() app.toggleLocked()
}, [state]) }, [app])
const handleToggleAspectRatio = React.useCallback(() => { const handleToggleAspectRatio = React.useCallback(() => {
state.toggleAspectRatioLocked() app.toggleAspectRatioLocked()
}, [state]) }, [app])
const handleGroup = React.useCallback(() => { const handleGroup = React.useCallback(() => {
state.group() app.group()
}, [state]) }, [app])
const handleMoveToBack = React.useCallback(() => { const handleMoveToBack = React.useCallback(() => {
state.moveToBack() app.moveToBack()
}, [state]) }, [app])
const handleMoveBackward = React.useCallback(() => { const handleMoveBackward = React.useCallback(() => {
state.moveBackward() app.moveBackward()
}, [state]) }, [app])
const handleMoveForward = React.useCallback(() => { const handleMoveForward = React.useCallback(() => {
state.moveForward() app.moveForward()
}, [state]) }, [app])
const handleMoveToFront = React.useCallback(() => { const handleMoveToFront = React.useCallback(() => {
state.moveToFront() app.moveToFront()
}, [state]) }, [app])
const handleDelete = React.useCallback(() => { const handleDelete = React.useCallback(() => {
state.delete() app.delete()
}, [state]) }, [app])
const alignTop = React.useCallback(() => { const alignTop = React.useCallback(() => {
state.align(AlignType.Top) app.align(AlignType.Top)
}, [state]) }, [app])
const alignCenterVertical = React.useCallback(() => { const alignCenterVertical = React.useCallback(() => {
state.align(AlignType.CenterVertical) app.align(AlignType.CenterVertical)
}, [state]) }, [app])
const alignBottom = React.useCallback(() => { const alignBottom = React.useCallback(() => {
state.align(AlignType.Bottom) app.align(AlignType.Bottom)
}, [state]) }, [app])
const stretchVertically = React.useCallback(() => { const stretchVertically = React.useCallback(() => {
state.stretch(StretchType.Vertical) app.stretch(StretchType.Vertical)
}, [state]) }, [app])
const distributeVertically = React.useCallback(() => { const distributeVertically = React.useCallback(() => {
state.distribute(DistributeType.Vertical) app.distribute(DistributeType.Vertical)
}, [state]) }, [app])
const alignLeft = React.useCallback(() => { const alignLeft = React.useCallback(() => {
state.align(AlignType.Left) app.align(AlignType.Left)
}, [state]) }, [app])
const alignCenterHorizontal = React.useCallback(() => { const alignCenterHorizontal = React.useCallback(() => {
state.align(AlignType.CenterHorizontal) app.align(AlignType.CenterHorizontal)
}, [state]) }, [app])
const alignRight = React.useCallback(() => { const alignRight = React.useCallback(() => {
state.align(AlignType.Right) app.align(AlignType.Right)
}, [state]) }, [app])
const stretchHorizontally = React.useCallback(() => { const stretchHorizontally = React.useCallback(() => {
state.stretch(StretchType.Horizontal) app.stretch(StretchType.Horizontal)
}, [state]) }, [app])
const distributeHorizontally = React.useCallback(() => { const distributeHorizontally = React.useCallback(() => {
state.distribute(DistributeType.Horizontal) app.distribute(DistributeType.Horizontal)
}, [state]) }, [app])
const selectedShapesCount = useSelector(selectedShapesCountSelector) const selectedShapesCount = app.useStore(selectedShapesCountSelector)
const hasTwoOrMore = selectedShapesCount > 1 const hasTwoOrMore = selectedShapesCount > 1
const hasThreeOrMore = selectedShapesCount > 2 const hasThreeOrMore = selectedShapesCount > 2
@ -177,99 +176,99 @@ export function ActionButton(): JSX.Element {
<DotsHorizontalIcon /> <DotsHorizontalIcon />
</ToolButton> </ToolButton>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DMContent> <DMContent sideOffset={16}>
<> <>
<ButtonsRow> <ButtonsRow>
<IconButton disabled={!hasSelection} onClick={handleDuplicate}> <ToolButton variant="icon" disabled={!hasSelection} onClick={handleDuplicate}>
<Tooltip label="Duplicate" kbd={`#D`}> <Tooltip label="Duplicate" kbd={`#D`}>
<CopyIcon /> <CopyIcon />
</Tooltip> </Tooltip>
</IconButton> </ToolButton>
<IconButton disabled={!hasSelection} onClick={handleRotate}> <ToolButton disabled={!hasSelection} onClick={handleRotate}>
<Tooltip label="Rotate"> <Tooltip label="Rotate">
<RotateCounterClockwiseIcon /> <RotateCounterClockwiseIcon />
</Tooltip> </Tooltip>
</IconButton> </ToolButton>
<IconButton disabled={!hasSelection} onClick={handleToggleLocked}> <ToolButton disabled={!hasSelection} onClick={handleToggleLocked}>
<Tooltip label="Toogle Locked" kbd={`#L`}> <Tooltip label="Toogle Locked" kbd={`#L`}>
{isAllLocked ? <LockClosedIcon /> : <LockOpen1Icon opacity={0.4} />} {isAllLocked ? <LockClosedIcon /> : <LockOpen1Icon opacity={0.4} />}
</Tooltip> </Tooltip>
</IconButton> </ToolButton>
<IconButton disabled={!hasSelection} onClick={handleToggleAspectRatio}> <ToolButton disabled={!hasSelection} onClick={handleToggleAspectRatio}>
<Tooltip label="Toogle Aspect Ratio Lock"> <Tooltip label="Toogle Aspect Ratio Lock">
<AspectRatioIcon opacity={isAllAspectLocked ? 1 : 0.4} /> <AspectRatioIcon opacity={isAllAspectLocked ? 1 : 0.4} />
</Tooltip> </Tooltip>
</IconButton> </ToolButton>
<IconButton <ToolButton
disabled={!hasSelection || (!isAllGrouped && !hasMultipleSelection)} disabled={!hasSelection || (!isAllGrouped && !hasMultipleSelection)}
onClick={handleGroup} onClick={handleGroup}
> >
<Tooltip label="Group" kbd={`#G`}> <Tooltip label="Group" kbd={`#G`}>
<GroupIcon opacity={isAllGrouped ? 1 : 0.4} /> <GroupIcon opacity={isAllGrouped ? 1 : 0.4} />
</Tooltip> </Tooltip>
</IconButton> </ToolButton>
</ButtonsRow> </ButtonsRow>
<ButtonsRow> <ButtonsRow>
<IconButton disabled={!hasSelection} onClick={handleMoveToBack}> <ToolButton disabled={!hasSelection} onClick={handleMoveToBack}>
<Tooltip label="Move to Back" kbd={`#⇧[`}> <Tooltip label="Move to Back" kbd={`#⇧[`}>
<PinBottomIcon /> <PinBottomIcon />
</Tooltip> </Tooltip>
</IconButton> </ToolButton>
<IconButton disabled={!hasSelection} onClick={handleMoveBackward}> <ToolButton disabled={!hasSelection} onClick={handleMoveBackward}>
<Tooltip label="Move Backward" kbd={`#[`}> <Tooltip label="Move Backward" kbd={`#[`}>
<ArrowDownIcon /> <ArrowDownIcon />
</Tooltip> </Tooltip>
</IconButton> </ToolButton>
<IconButton disabled={!hasSelection} onClick={handleMoveForward}> <ToolButton disabled={!hasSelection} onClick={handleMoveForward}>
<Tooltip label="Move Forward" kbd={`#]`}> <Tooltip label="Move Forward" kbd={`#]`}>
<ArrowUpIcon /> <ArrowUpIcon />
</Tooltip> </Tooltip>
</IconButton> </ToolButton>
<IconButton disabled={!hasSelection} onClick={handleMoveToFront}> <ToolButton disabled={!hasSelection} onClick={handleMoveToFront}>
<Tooltip label="More to Front" kbd={`#⇧]`}> <Tooltip label="More to Front" kbd={`#⇧]`}>
<PinTopIcon /> <PinTopIcon />
</Tooltip> </Tooltip>
</IconButton> </ToolButton>
<IconButton disabled={!hasSelection} onClick={handleDelete}> <ToolButton disabled={!hasSelection} onClick={handleDelete}>
<Tooltip label="Delete" kbd="⌫"> <Tooltip label="Delete" kbd="⌫">
<TrashIcon /> <TrashIcon />
</Tooltip> </Tooltip>
</IconButton> </ToolButton>
</ButtonsRow> </ButtonsRow>
<Divider /> <Divider />
<ButtonsRow> <ButtonsRow>
<IconButton disabled={!hasTwoOrMore} onClick={alignLeft}> <ToolButton disabled={!hasTwoOrMore} onClick={alignLeft}>
<AlignLeftIcon /> <AlignLeftIcon />
</IconButton> </ToolButton>
<IconButton disabled={!hasTwoOrMore} onClick={alignCenterHorizontal}> <ToolButton disabled={!hasTwoOrMore} onClick={alignCenterHorizontal}>
<AlignCenterHorizontallyIcon /> <AlignCenterHorizontallyIcon />
</IconButton> </ToolButton>
<IconButton disabled={!hasTwoOrMore} onClick={alignRight}> <ToolButton disabled={!hasTwoOrMore} onClick={alignRight}>
<AlignRightIcon /> <AlignRightIcon />
</IconButton> </ToolButton>
<IconButton disabled={!hasTwoOrMore} onClick={stretchHorizontally}> <ToolButton disabled={!hasTwoOrMore} onClick={stretchHorizontally}>
<StretchHorizontallyIcon /> <StretchHorizontallyIcon />
</IconButton> </ToolButton>
<IconButton disabled={!hasThreeOrMore} onClick={distributeHorizontally}> <ToolButton disabled={!hasThreeOrMore} onClick={distributeHorizontally}>
<SpaceEvenlyHorizontallyIcon /> <SpaceEvenlyHorizontallyIcon />
</IconButton> </ToolButton>
</ButtonsRow> </ButtonsRow>
<ButtonsRow> <ButtonsRow>
<IconButton disabled={!hasTwoOrMore} onClick={alignTop}> <ToolButton disabled={!hasTwoOrMore} onClick={alignTop}>
<AlignTopIcon /> <AlignTopIcon />
</IconButton> </ToolButton>
<IconButton disabled={!hasTwoOrMore} onClick={alignCenterVertical}> <ToolButton disabled={!hasTwoOrMore} onClick={alignCenterVertical}>
<AlignCenterVerticallyIcon /> <AlignCenterVerticallyIcon />
</IconButton> </ToolButton>
<IconButton disabled={!hasTwoOrMore} onClick={alignBottom}> <ToolButton disabled={!hasTwoOrMore} onClick={alignBottom}>
<AlignBottomIcon /> <AlignBottomIcon />
</IconButton> </ToolButton>
<IconButton disabled={!hasTwoOrMore} onClick={stretchVertically}> <ToolButton disabled={!hasTwoOrMore} onClick={stretchVertically}>
<StretchVerticallyIcon /> <StretchVerticallyIcon />
</IconButton> </ToolButton>
<IconButton disabled={!hasThreeOrMore} onClick={distributeVertically}> <ToolButton disabled={!hasThreeOrMore} onClick={distributeVertically}>
<SpaceEvenlyVerticallyIcon /> <SpaceEvenlyVerticallyIcon />
</IconButton> </ToolButton>
</ButtonsRow> </ButtonsRow>
</> </>
</DMContent> </DMContent>

View file

@ -1,24 +1,24 @@
import * as React from 'react' import * as React from 'react'
import { styled } from '~styles' import { styled } from '~styles'
import type { TLDrawSnapshot } from '~types' import type { TDSnapshot } from '~types'
import { useTLDrawContext } from '~hooks' import { useTldrawApp } from '~hooks'
import { RowButton } from '~components/RowButton' import { RowButton } from '~components/RowButton'
import { MenuContent } from '~components/MenuContent' import { MenuContent } from '~components/MenuContent'
const isEmptyCanvasSelector = (s: TLDrawSnapshot) => const isEmptyCanvasSelector = (s: TDSnapshot) =>
Object.keys(s.document.pages[s.appState.currentPageId].shapes).length > 0 && Object.keys(s.document.pages[s.appState.currentPageId].shapes).length > 0 &&
s.appState.isEmptyCanvas s.appState.isEmptyCanvas
export const BackToContent = React.memo(function BackToContent() { export const BackToContent = React.memo(function BackToContent() {
const { state, useSelector } = useTLDrawContext() const app = useTldrawApp()
const isEmptyCanvas = useSelector(isEmptyCanvasSelector) const isEmptyCanvas = app.useStore(isEmptyCanvasSelector)
if (!isEmptyCanvas) return null if (!isEmptyCanvas) return null
return ( return (
<BackToContentContainer> <BackToContentContainer>
<RowButton onSelect={state.zoomToContent}>Back to content</RowButton> <RowButton onClick={app.zoomToContent}>Back to content</RowButton>
</BackToContentContainer> </BackToContentContainer>
) )
}) })

View file

@ -1,18 +1,18 @@
import * as React from 'react' import * as React from 'react'
import { Tooltip } from '~components/Tooltip' import { Tooltip } from '~components/Tooltip'
import { useTLDrawContext } from '~hooks' import { useTldrawApp } from '~hooks'
import { ToolButton } from '~components/ToolButton' import { ToolButton } from '~components/ToolButton'
import { TrashIcon } from '~components/icons' import { TrashIcon } from '~components/icons'
export function DeleteButton(): JSX.Element { export function DeleteButton(): JSX.Element {
const { state } = useTLDrawContext() const app = useTldrawApp()
const handleDelete = React.useCallback(() => { const handleDelete = React.useCallback(() => {
state.delete() app.delete()
}, [state]) }, [app])
return ( return (
<Tooltip label="Delete" kbd="7"> <Tooltip label="Delete" kbd="">
<ToolButton variant="circle" onSelect={handleDelete}> <ToolButton variant="circle" onSelect={handleDelete}>
<TrashIcon /> <TrashIcon />
</ToolButton> </ToolButton>

View file

@ -1,20 +1,20 @@
import * as React from 'react' import * as React from 'react'
import { LockClosedIcon, LockOpen1Icon } from '@radix-ui/react-icons' import { LockClosedIcon, LockOpen1Icon } from '@radix-ui/react-icons'
import { Tooltip } from '~components/Tooltip' import { Tooltip } from '~components/Tooltip'
import { useTLDrawContext } from '~hooks' import { useTldrawApp } from '~hooks'
import { ToolButton } from '~components/ToolButton' import { ToolButton } from '~components/ToolButton'
import type { TLDrawSnapshot } from '~types' import type { TDSnapshot } from '~types'
const isToolLockedSelector = (s: TLDrawSnapshot) => s.appState.isToolLocked const isToolLockedSelector = (s: TDSnapshot) => s.appState.isToolLocked
export function LockButton(): JSX.Element { export function LockButton(): JSX.Element {
const { state, useSelector } = useTLDrawContext() const app = useTldrawApp()
const isToolLocked = useSelector(isToolLockedSelector) const isToolLocked = app.useStore(isToolLockedSelector)
return ( return (
<Tooltip label="Lock Tool" kbd="7"> <Tooltip label="Lock Tool" kbd="7">
<ToolButton variant="circle" isActive={isToolLocked} onSelect={state.toggleToolLock}> <ToolButton variant="circle" isActive={isToolLocked} onSelect={app.toggleToolLock}>
{isToolLocked ? <LockClosedIcon /> : <LockOpen1Icon />} {isToolLocked ? <LockClosedIcon /> : <LockOpen1Icon />}
</ToolButton> </ToolButton>
</Tooltip> </Tooltip>

View file

@ -0,0 +1,76 @@
import * as React from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { Panel } from '~components/Panel'
import { ToolButton } from '~components/ToolButton'
import { TDShapeType, TDToolType } from '~types'
import { useTldrawApp } from '~hooks'
import { Pencil1Icon } from '@radix-ui/react-icons'
import { Tooltip } from '~components/Tooltip'
interface ShapesMenuProps {
activeTool: TDToolType
}
type PenShape = TDShapeType.Draw
const penShapes: PenShape[] = [TDShapeType.Draw]
const penShapeIcons = {
[TDShapeType.Draw]: <Pencil1Icon />,
}
export const PenMenu = React.memo(function PenMenu({ activeTool }: ShapesMenuProps) {
const app = useTldrawApp()
const [lastActiveTool, setLastActiveTool] = React.useState<PenShape>(TDShapeType.Draw)
React.useEffect(() => {
if (penShapes.includes(activeTool as PenShape) && lastActiveTool !== activeTool) {
setLastActiveTool(activeTool as PenShape)
}
}, [activeTool])
const selectShapeTool = React.useCallback(() => {
app.selectTool(lastActiveTool)
}, [activeTool, app])
const handleDoubleClick = React.useCallback(() => {
app.toggleToolLock()
}, [])
return (
<DropdownMenu.Root dir="ltr">
<DropdownMenu.Trigger dir="ltr" asChild>
<ToolButton
variant="primary"
onDoubleClick={handleDoubleClick}
onClick={selectShapeTool}
isActive={penShapes.includes(activeTool as PenShape)}
>
{penShapeIcons[lastActiveTool]}
</ToolButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content asChild dir="ltr" side="top" sideOffset={12}>
<Panel side="center">
{penShapes.map((shape, i) => (
<Tooltip
key={shape}
label={shape[0].toUpperCase() + shape.slice(1)}
kbd={(1 + i).toString()}
>
<DropdownMenu.Item asChild>
<ToolButton
variant="primary"
onClick={() => {
app.selectTool(shape)
setLastActiveTool(shape)
}}
>
{penShapeIcons[shape]}
</ToolButton>
</DropdownMenu.Item>
</Tooltip>
))}
</Panel>
</DropdownMenu.Content>
</DropdownMenu.Root>
)
})

View file

@ -1,52 +1,51 @@
import * as React from 'react' import * as React from 'react'
import { import {
ArrowTopRightIcon, ArrowTopRightIcon,
CircleIcon,
CursorArrowIcon, CursorArrowIcon,
Pencil1Icon, Pencil1Icon,
Pencil2Icon, Pencil2Icon,
SquareIcon,
TextIcon, TextIcon,
} from '@radix-ui/react-icons' } from '@radix-ui/react-icons'
import { TLDrawSnapshot, TLDrawShapeType } from '~types' import { TDSnapshot, TDShapeType } from '~types'
import { useTLDrawContext } from '~hooks' import { useTldrawApp } from '~hooks'
import { ToolButtonWithTooltip } from '~components/ToolButton' import { ToolButtonWithTooltip } from '~components/ToolButton'
import { Panel } from '~components/Panel' import { Panel } from '~components/Panel'
import { ShapesMenu } from './ShapesMenu'
import { EraserIcon } from '~components/icons'
const activeToolSelector = (s: TLDrawSnapshot) => s.appState.activeTool const activeToolSelector = (s: TDSnapshot) => s.appState.activeTool
const toolLockedSelector = (s: TDSnapshot) => s.appState.isToolLocked
export const PrimaryTools = React.memo(function PrimaryTools(): JSX.Element { export const PrimaryTools = React.memo(function PrimaryTools(): JSX.Element {
const { state, useSelector } = useTLDrawContext() const app = useTldrawApp()
const activeTool = useSelector(activeToolSelector) const activeTool = app.useStore(activeToolSelector)
const isToolLocked = app.useStore(toolLockedSelector)
const selectSelectTool = React.useCallback(() => { const selectSelectTool = React.useCallback(() => {
state.selectTool('select') app.selectTool('select')
}, [state]) }, [app])
const selectEraseTool = React.useCallback(() => {
app.selectTool('erase')
}, [app])
const selectDrawTool = React.useCallback(() => { const selectDrawTool = React.useCallback(() => {
state.selectTool(TLDrawShapeType.Draw) app.selectTool(TDShapeType.Draw)
}, [state]) }, [app])
const selectRectangleTool = React.useCallback(() => {
state.selectTool(TLDrawShapeType.Rectangle)
}, [state])
const selectEllipseTool = React.useCallback(() => {
state.selectTool(TLDrawShapeType.Ellipse)
}, [state])
const selectArrowTool = React.useCallback(() => { const selectArrowTool = React.useCallback(() => {
state.selectTool(TLDrawShapeType.Arrow) app.selectTool(TDShapeType.Arrow)
}, [state]) }, [app])
const selectTextTool = React.useCallback(() => { const selectTextTool = React.useCallback(() => {
state.selectTool(TLDrawShapeType.Text) app.selectTool(TDShapeType.Text)
}, [state]) }, [app])
const selectStickyTool = React.useCallback(() => { const selectStickyTool = React.useCallback(() => {
state.selectTool(TLDrawShapeType.Sticky) app.selectTool(TDShapeType.Sticky)
}, [state]) }, [app])
return ( return (
<Panel side="center"> <Panel side="center">
@ -60,49 +59,43 @@ export const PrimaryTools = React.memo(function PrimaryTools(): JSX.Element {
</ToolButtonWithTooltip> </ToolButtonWithTooltip>
<ToolButtonWithTooltip <ToolButtonWithTooltip
kbd={'2'} kbd={'2'}
label={TLDrawShapeType.Draw} label={TDShapeType.Draw}
onClick={selectDrawTool} onClick={selectDrawTool}
isActive={activeTool === TLDrawShapeType.Draw} isActive={activeTool === TDShapeType.Draw}
> >
<Pencil1Icon /> <Pencil1Icon />
</ToolButtonWithTooltip> </ToolButtonWithTooltip>
<ToolButtonWithTooltip <ToolButtonWithTooltip
kbd={'3'} kbd={'3'}
label={TLDrawShapeType.Rectangle} label={'eraser'}
onClick={selectRectangleTool} onClick={selectEraseTool}
isActive={activeTool === TLDrawShapeType.Rectangle} isActive={activeTool === 'erase'}
> >
<SquareIcon /> <EraserIcon />
</ToolButtonWithTooltip> </ToolButtonWithTooltip>
<ShapesMenu activeTool={activeTool} isToolLocked={isToolLocked} />
<ToolButtonWithTooltip <ToolButtonWithTooltip
kbd={'4'} kbd={'6'}
label={TLDrawShapeType.Ellipse} label={TDShapeType.Arrow}
onClick={selectEllipseTool}
isActive={activeTool === TLDrawShapeType.Ellipse}
>
<CircleIcon />
</ToolButtonWithTooltip>
<ToolButtonWithTooltip
kbd={'5'}
label={TLDrawShapeType.Arrow}
onClick={selectArrowTool} onClick={selectArrowTool}
isActive={activeTool === TLDrawShapeType.Arrow} isLocked={isToolLocked}
isActive={activeTool === TDShapeType.Arrow}
> >
<ArrowTopRightIcon /> <ArrowTopRightIcon />
</ToolButtonWithTooltip> </ToolButtonWithTooltip>
<ToolButtonWithTooltip <ToolButtonWithTooltip
kbd={'6'} kbd={'7'}
label={TLDrawShapeType.Text} label={TDShapeType.Text}
onClick={selectTextTool} onClick={selectTextTool}
isActive={activeTool === TLDrawShapeType.Text} isActive={activeTool === TDShapeType.Text}
> >
<TextIcon /> <TextIcon />
</ToolButtonWithTooltip> </ToolButtonWithTooltip>
<ToolButtonWithTooltip <ToolButtonWithTooltip
kbd={'7'} kbd={'8'}
label={TLDrawShapeType.Sticky} label={TDShapeType.Sticky}
onClick={selectStickyTool} onClick={selectStickyTool}
isActive={activeTool === TLDrawShapeType.Sticky} isActive={activeTool === TDShapeType.Sticky}
> >
<Pencil2Icon /> <Pencil2Icon />
</ToolButtonWithTooltip> </ToolButtonWithTooltip>

View file

@ -0,0 +1,83 @@
import * as React from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { Panel } from '~components/Panel'
import { ToolButton } from '~components/ToolButton'
import { TDShapeType, TDToolType } from '~types'
import { useTldrawApp } from '~hooks'
import { SquareIcon, CircleIcon } from '@radix-ui/react-icons'
import { Tooltip } from '~components/Tooltip'
interface ShapesMenuProps {
activeTool: TDToolType
isToolLocked: boolean
}
type ShapeShape = TDShapeType.Rectangle | TDShapeType.Ellipse
const shapeShapes: ShapeShape[] = [TDShapeType.Rectangle, TDShapeType.Ellipse]
const shapeShapeIcons = {
[TDShapeType.Rectangle]: <SquareIcon />,
[TDShapeType.Ellipse]: <CircleIcon />,
}
export const ShapesMenu = React.memo(function ShapesMenu({
activeTool,
isToolLocked,
}: ShapesMenuProps) {
const app = useTldrawApp()
const [lastActiveTool, setLastActiveTool] = React.useState<ShapeShape>(TDShapeType.Rectangle)
React.useEffect(() => {
if (shapeShapes.includes(activeTool as ShapeShape) && lastActiveTool !== activeTool) {
setLastActiveTool(activeTool as ShapeShape)
}
}, [activeTool])
const selectShapeTool = React.useCallback(() => {
app.selectTool(lastActiveTool)
}, [activeTool, app])
const handleDoubleClick = React.useCallback(() => {
app.toggleToolLock()
}, [app])
const isActive = shapeShapes.includes(activeTool as ShapeShape)
return (
<DropdownMenu.Root dir="ltr" onOpenChange={selectShapeTool}>
<DropdownMenu.Trigger dir="ltr" asChild>
<ToolButton
variant="primary"
onDoubleClick={handleDoubleClick}
isToolLocked={isActive && isToolLocked}
isActive={isActive}
>
{shapeShapeIcons[lastActiveTool]}
</ToolButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content asChild dir="ltr" side="top" sideOffset={12}>
<Panel side="center">
{shapeShapes.map((shape, i) => (
<Tooltip
key={shape}
label={shape[0].toUpperCase() + shape.slice(1)}
kbd={(4 + i).toString()}
>
<DropdownMenu.Item asChild>
<ToolButton
variant="primary"
onClick={() => {
app.selectTool(shape)
setLastActiveTool(shape)
}}
>
{shapeShapeIcons[shape]}
</ToolButton>
</DropdownMenu.Item>
</Tooltip>
))}
</Panel>
</DropdownMenu.Content>
</DropdownMenu.Root>
)
})

View file

@ -1,16 +1,16 @@
import * as React from 'react' import * as React from 'react'
import { useTLDrawContext } from '~hooks' import { useTldrawApp } from '~hooks'
import type { TLDrawSnapshot } from '~types' import type { TDSnapshot } from '~types'
import { styled } from '~styles' import { styled } from '~styles'
import { breakpoints } from '~components/breakpoints' import { breakpoints } from '~components/breakpoints'
const statusSelector = (s: TLDrawSnapshot) => s.appState.status const statusSelector = (s: TDSnapshot) => s.appState.status
const activeToolSelector = (s: TLDrawSnapshot) => s.appState.activeTool const activeToolSelector = (s: TDSnapshot) => s.appState.activeTool
export function StatusBar(): JSX.Element | null { export function StatusBar(): JSX.Element | null {
const { useSelector } = useTLDrawContext() const app = useTldrawApp()
const status = useSelector(statusSelector) const status = app.useStore(statusSelector)
const activeTool = useSelector(activeToolSelector) const activeTool = app.useStore(activeToolSelector)
return ( return (
<StyledStatusBar bp={breakpoints}> <StyledStatusBar bp={breakpoints}>
@ -24,7 +24,7 @@ export function StatusBar(): JSX.Element | null {
const StyledStatusBar = styled('div', { const StyledStatusBar = styled('div', {
height: 40, height: 40,
userSelect: 'none', userSelect: 'none',
borderTop: '1px solid $border', borderTop: '1px solid $panelContrast',
gridArea: 'status', gridArea: 'status',
display: 'flex', display: 'flex',
color: '$text', color: '$text',

View file

@ -1,20 +1,19 @@
import * as React from 'react' import * as React from 'react'
import { styled } from '~styles' import { styled } from '~styles'
import type { TLDrawSnapshot } from '~types' import type { TDSnapshot } from '~types'
import { useTLDrawContext } from '~hooks' import { useTldrawApp } from '~hooks'
import { StatusBar } from './StatusBar' import { StatusBar } from './StatusBar'
import { BackToContent } from './BackToContent' import { BackToContent } from './BackToContent'
import { PrimaryTools } from './PrimaryTools' import { PrimaryTools } from './PrimaryTools'
import { ActionButton } from './ActionButton' import { ActionButton } from './ActionButton'
import { LockButton } from './LockButton'
import { DeleteButton } from './DeleteButton' import { DeleteButton } from './DeleteButton'
const isDebugModeSelector = (s: TLDrawSnapshot) => s.settings.isDebugMode const isDebugModeSelector = (s: TDSnapshot) => s.settings.isDebugMode
export const ToolsPanel = React.memo(function ToolsPanel(): JSX.Element { export const ToolsPanel = React.memo(function ToolsPanel(): JSX.Element {
const { useSelector } = useTLDrawContext() const app = useTldrawApp()
const isDebugMode = useSelector(isDebugModeSelector) const isDebugMode = app.useStore(isDebugModeSelector)
return ( return (
<StyledToolsPanelContainer> <StyledToolsPanelContainer>
@ -48,6 +47,7 @@ const StyledToolsPanelContainer = styled('div', {
gridTemplateRows: 'auto auto', gridTemplateRows: 'auto auto',
justifyContent: 'space-between', justifyContent: 'space-between',
padding: '0', padding: '0',
gap: '$4',
zIndex: 200, zIndex: 200,
pointerEvents: 'none', pointerEvents: 'none',
'& > div > *': { '& > div > *': {

View file

@ -22,10 +22,10 @@ export function Tooltip({
}: TooltipProps): JSX.Element { }: TooltipProps): JSX.Element {
return ( return (
<RadixTooltip.Root> <RadixTooltip.Root>
<RadixTooltip.Trigger asChild={true}> <RadixTooltip.Trigger dir="ltr" asChild={true}>
<span>{children}</span> <span>{children}</span>
</RadixTooltip.Trigger> </RadixTooltip.Trigger>
<StyledContent side={side} sideOffset={8}> <StyledContent dir="ltr" side={side} sideOffset={8}>
{label} {label}
{kbdProp ? <Kbd variant="tooltip">{kbdProp}</Kbd> : null} {kbdProp ? <Kbd variant="tooltip">{kbdProp}</Kbd> : null}
<StyledArrow /> <StyledArrow />
@ -38,8 +38,8 @@ const StyledContent = styled(RadixTooltip.Content, {
borderRadius: 3, borderRadius: 3,
padding: '$3 $3 $3 $3', padding: '$3 $3 $3 $3',
fontSize: '$1', fontSize: '$1',
backgroundColor: '$tooltipBg', backgroundColor: '$tooltip',
color: '$tooltipText', color: '$tooltipContrast',
boxShadow: '$3', boxShadow: '$3',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@ -48,6 +48,6 @@ const StyledContent = styled(RadixTooltip.Content, {
}) })
const StyledArrow = styled(RadixTooltip.Arrow, { const StyledArrow = styled(RadixTooltip.Arrow, {
fill: '$tooltipBg', fill: '$tooltip',
margin: '0 8px', margin: '0 8px',
}) })

View file

@ -1,43 +0,0 @@
import * as React from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { strokes } from '~state/shapes/shape-styles'
import { useTLDrawContext } from '~hooks'
import { DMContent, DMTriggerIcon } from '~components/DropdownMenu'
import { BoxIcon, CircleIcon } from '~components/icons'
import { ToolButton } from '~components/ToolButton'
import type { TLDrawSnapshot, ColorStyle } from '~types'
const selectColor = (s: TLDrawSnapshot) => s.appState.selectedStyle.color
const preventEvent = (e: Event) => e.preventDefault()
const themeSelector = (data: TLDrawSnapshot) => (data.settings.isDarkMode ? 'dark' : 'light')
export const ColorMenu = React.memo(function ColorMenu(): JSX.Element {
const { state, useSelector } = useTLDrawContext()
const theme = useSelector(themeSelector)
const color = useSelector(selectColor)
return (
<DropdownMenu.Root dir="ltr">
<DMTriggerIcon>
<CircleIcon size={16} fill={strokes[theme][color]} stroke={strokes[theme][color]} />
</DMTriggerIcon>
<DMContent variant="grid">
{Object.keys(strokes[theme]).map((colorStyle: string) => (
<DropdownMenu.Item key={colorStyle} onSelect={preventEvent} asChild>
<ToolButton
variant="icon"
isActive={color === colorStyle}
onClick={() => state.style({ color: colorStyle as ColorStyle })}
>
<BoxIcon
fill={strokes[theme][colorStyle as ColorStyle]}
stroke={strokes[theme][colorStyle as ColorStyle]}
/>
</ToolButton>
</DropdownMenu.Item>
))}
</DMContent>
</DropdownMenu.Root>
)
})

View file

@ -1,103 +0,0 @@
import * as React from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { useTLDrawContext } from '~hooks'
import { DashStyle, TLDrawSnapshot } from '~types'
import { DMContent, DMTriggerIcon } from '~components/DropdownMenu'
import { ToolButton } from '~components/ToolButton'
import { DashDashedIcon, DashDottedIcon, DashDrawIcon, DashSolidIcon } from '~components/icons'
const dashes = {
[DashStyle.Draw]: <DashDrawIcon />,
[DashStyle.Solid]: <DashSolidIcon />,
[DashStyle.Dashed]: <DashDashedIcon />,
[DashStyle.Dotted]: <DashDottedIcon />,
}
const selectDash = (s: TLDrawSnapshot) => s.appState.selectedStyle.dash
const preventEvent = (e: Event) => e.preventDefault()
export const DashMenu = React.memo(function DashMenu(): JSX.Element {
const { state, useSelector } = useTLDrawContext()
const dash = useSelector(selectDash)
return (
<DropdownMenu.Root dir="ltr">
<DMTriggerIcon>{dashes[dash]}</DMTriggerIcon>
<DMContent variant="horizontal">
{Object.values(DashStyle).map((dashStyle) => (
<DropdownMenu.Item key={dashStyle} onSelect={preventEvent} asChild>
<ToolButton
variant="icon"
isActive={dash === dashStyle}
onClick={() => state.style({ dash: dashStyle as DashStyle })}
>
{dashes[dashStyle as DashStyle]}
</ToolButton>
</DropdownMenu.Item>
))}
</DMContent>
</DropdownMenu.Root>
)
})
// function DashSolidIcon(): JSX.Element {
// return (
// <svg width="24" height="24" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
// <circle cx={12} cy={12} r={8} fill="none" strokeWidth={2} strokeLinecap="round" />
// </svg>
// )
// }
// function DashDashedIcon(): JSX.Element {
// return (
// <svg width="24" height="24" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
// <circle
// cx={12}
// cy={12}
// r={8}
// fill="none"
// strokeWidth={2.5}
// strokeLinecap="round"
// strokeDasharray={50.26548 * 0.1}
// />
// </svg>
// )
// }
// const dottedDasharray = `${50.26548 * 0.025} ${50.26548 * 0.1}`
// function DashDottedIcon(): JSX.Element {
// return (
// <svg width="24" height="24" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
// <circle
// cx={12}
// cy={12}
// r={8}
// fill="none"
// strokeWidth={2.5}
// strokeLinecap="round"
// strokeDasharray={dottedDasharray}
// />
// </svg>
// )
// }
// function DashDrawIcon(): JSX.Element {
// return (
// <svg
// width="24"
// height="24"
// viewBox="1 1.5 21 22"
// fill="currentColor"
// stroke="currentColor"
// xmlns="http://www.w3.org/2000/svg"
// >
// <path
// d="M10.0162 19.2768C10.0162 19.2768 9.90679 19.2517 9.6879 19.2017C9.46275 19.1454 9.12816 19.0422 8.68413 18.8921C8.23384 18.7358 7.81482 18.545 7.42707 18.3199C7.03307 18.101 6.62343 17.7883 6.19816 17.3818C5.77289 16.9753 5.33511 16.3718 4.88482 15.5713C4.43453 14.7645 4.1531 13.8545 4.04053 12.8414C3.92795 11.822 4.04991 10.8464 4.40639 9.91451C4.76286 8.98266 5.39452 8.10084 6.30135 7.26906C7.21444 6.44353 8.29325 5.83377 9.5378 5.43976C10.7823 5.05202 11.833 4.92068 12.6898 5.04576C13.5466 5.16459 14.3878 5.43664 15.2133 5.86191C16.0388 6.28718 16.7768 6.8688 17.4272 7.60678C18.0714 8.34475 18.5404 9.21406 18.8344 10.2147C19.1283 11.2153 19.1721 12.2598 18.9657 13.348C18.7593 14.4299 18.2872 15.4337 17.5492 16.3593C16.8112 17.2849 15.9263 18.0072 14.8944 18.5263C13.8624 19.0391 12.9056 19.3174 12.0238 19.3612C11.142 19.405 10.2101 19.2705 9.22823 18.9578C8.24635 18.6451 7.35828 18.151 6.56402 17.4756C5.77601 16.8002 6.08871 16.8658 7.50212 17.6726C8.90927 18.4731 10.1444 18.8484 11.2076 18.7983C12.2645 18.7545 13.2965 18.4825 14.3034 17.9822C15.3102 17.4819 16.1264 16.8221 16.7518 16.0028C17.3772 15.1835 17.7681 14.3111 17.9244 13.3855C18.0808 12.4599 18.0401 11.5781 17.8025 10.74C17.5586 9.902 17.1739 9.15464 16.6486 8.49797C16.1233 7.8413 15.2289 7.27844 13.9656 6.80939C12.7086 6.34034 11.4203 6.20901 10.1007 6.41539C8.78732 6.61552 7.69599 7.06893 6.82669 7.77564C5.96363 8.48859 5.34761 9.26409 4.97863 10.1021C4.60964 10.9402 4.45329 11.8376 4.50958 12.7945C4.56586 13.7513 4.79101 14.6238 5.18501 15.4118C5.57276 16.1998 5.96363 16.8002 6.35764 17.2129C6.75164 17.6257 7.13313 17.9509 7.50212 18.1886C7.87736 18.4325 8.28074 18.642 8.71227 18.8171C9.15005 18.9922 9.47839 19.111 9.69728 19.1736C9.91617 19.2361 10.0256 19.2705 10.0256 19.2768H10.0162Z"
// strokeWidth="2"
// />
// </svg>
// )
// }

View file

@ -1,30 +0,0 @@
import * as React from 'react'
import * as Checkbox from '@radix-ui/react-checkbox'
import { useTLDrawContext } from '~hooks'
import type { TLDrawSnapshot } from '~types'
import { BoxIcon, IsFilledIcon } from '~components/icons'
import { ToolButton } from '~components/ToolButton'
const isFilledSelector = (s: TLDrawSnapshot) => s.appState.selectedStyle.isFilled
export const FillCheckbox = React.memo(function FillCheckbox(): JSX.Element {
const { state, useSelector } = useTLDrawContext()
const isFilled = useSelector(isFilledSelector)
const handleIsFilledChange = React.useCallback(
(isFilled: boolean) => state.style({ isFilled }),
[state]
)
return (
<Checkbox.Root dir="ltr" asChild checked={isFilled} onCheckedChange={handleIsFilledChange}>
<ToolButton variant="icon">
<BoxIcon />
<Checkbox.Indicator>
<IsFilledIcon />
</Checkbox.Indicator>
</ToolButton>
</Checkbox.Root>
)
})

View file

@ -0,0 +1,3 @@
export function HelpMenu() {
return <div />
}

View file

@ -1,90 +1,93 @@
import * as React from 'react' import * as React from 'react'
import { ExitIcon, HamburgerMenuIcon } from '@radix-ui/react-icons' import { ExitIcon, GitHubLogoIcon, HamburgerMenuIcon, TwitterLogoIcon } from '@radix-ui/react-icons'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { useTLDrawContext } from '~hooks' import { useTldrawApp } from '~hooks'
import { PreferencesMenu } from './PreferencesMenu' import { PreferencesMenu } from './PreferencesMenu'
import { DMItem, DMContent, DMDivider, DMSubMenu, DMTriggerIcon } from '~components/DropdownMenu' import { DMItem, DMContent, DMDivider, DMSubMenu, DMTriggerIcon } from '~components/DropdownMenu'
import { SmallIcon } from '~components/SmallIcon' import { SmallIcon } from '~components/SmallIcon'
import { useFileSystemHandlers } from '~hooks' import { useFileSystemHandlers } from '~hooks'
import { HeartIcon } from '~components/icons/HeartIcon'
import { preventEvent } from '~components/preventEvent'
interface MenuProps { interface MenuProps {
showSponsorLink: boolean
readOnly: boolean readOnly: boolean
} }
export const Menu = React.memo(function Menu({ readOnly }: MenuProps) { export const Menu = React.memo(function Menu({ showSponsorLink, readOnly }: MenuProps) {
const { state } = useTLDrawContext() const app = useTldrawApp()
const { onNewProject, onOpenProject, onSaveProject, onSaveProjectAs } = useFileSystemHandlers() const { onNewProject, onOpenProject, onSaveProject, onSaveProjectAs } = useFileSystemHandlers()
const handleSignIn = React.useCallback(() => { const handleSignIn = React.useCallback(() => {
state.callbacks.onSignIn?.(state) app.callbacks.onSignIn?.(app)
}, [state]) }, [app])
const handleSignOut = React.useCallback(() => { const handleSignOut = React.useCallback(() => {
state.callbacks.onSignOut?.(state) app.callbacks.onSignOut?.(app)
}, [state]) }, [app])
const handleCut = React.useCallback(() => { const handleCut = React.useCallback(() => {
state.cut() app.cut()
}, [state]) }, [app])
const handleCopy = React.useCallback(() => { const handleCopy = React.useCallback(() => {
state.copy() app.copy()
}, [state]) }, [app])
const handlePaste = React.useCallback(() => { const handlePaste = React.useCallback(() => {
state.paste() app.paste()
}, [state]) }, [app])
const handleCopySvg = React.useCallback(() => { const handleCopySvg = React.useCallback(() => {
state.copySvg() app.copySvg()
}, [state]) }, [app])
const handleCopyJson = React.useCallback(() => { const handleCopyJson = React.useCallback(() => {
state.copyJson() app.copyJson()
}, [state]) }, [app])
const handleSelectAll = React.useCallback(() => { const handleSelectAll = React.useCallback(() => {
state.selectAll() app.selectAll()
}, [state]) }, [app])
const handleselectNone = React.useCallback(() => { const handleselectNone = React.useCallback(() => {
state.selectNone() app.selectNone()
}, [state]) }, [app])
const showFileMenu = const showFileMenu =
state.callbacks.onNewProject || app.callbacks.onNewProject ||
state.callbacks.onOpenProject || app.callbacks.onOpenProject ||
state.callbacks.onSaveProject || app.callbacks.onSaveProject ||
state.callbacks.onSaveProjectAs app.callbacks.onSaveProjectAs
const showSignInOutMenu = state.callbacks.onSignIn || state.callbacks.onSignOut const showSignInOutMenu = app.callbacks.onSignIn || app.callbacks.onSignOut || showSponsorLink
return ( return (
<DropdownMenu.Root> <DropdownMenu.Root dir="ltr">
<DMTriggerIcon> <DMTriggerIcon isSponsor={showSponsorLink}>
<HamburgerMenuIcon /> <HamburgerMenuIcon />
</DMTriggerIcon> </DMTriggerIcon>
<DMContent variant="menu"> <DMContent variant="menu">
{showFileMenu && ( {showFileMenu && (
<DMSubMenu label="File..."> <DMSubMenu label="File...">
{state.callbacks.onNewProject && ( {app.callbacks.onNewProject && (
<DMItem onSelect={onNewProject} kbd="#N"> <DMItem onClick={onNewProject} kbd="#N">
New Project New Project
</DMItem> </DMItem>
)} )}
{state.callbacks.onOpenProject && ( {app.callbacks.onOpenProject && (
<DMItem onSelect={onOpenProject} kbd="#O"> <DMItem onClick={onOpenProject} kbd="#O">
Open... Open...
</DMItem> </DMItem>
)} )}
{state.callbacks.onSaveProject && ( {app.callbacks.onSaveProject && (
<DMItem onSelect={onSaveProject} kbd="#S"> <DMItem onClick={onSaveProject} kbd="#S">
Save Save
</DMItem> </DMItem>
)} )}
{state.callbacks.onSaveProjectAs && ( {app.callbacks.onSaveProjectAs && (
<DMItem onSelect={onSaveProjectAs} kbd="⇧#S"> <DMItem onClick={onSaveProjectAs} kbd="⇧#S">
Save As... Save As...
</DMItem> </DMItem>
)} )}
@ -93,42 +96,76 @@ export const Menu = React.memo(function Menu({ readOnly }: MenuProps) {
{!readOnly && ( {!readOnly && (
<> <>
<DMSubMenu label="Edit..."> <DMSubMenu label="Edit...">
<DMItem onSelect={state.undo} kbd="#Z"> <DMItem onSelect={preventEvent} onClick={app.undo} kbd="#Z">
Undo Undo
</DMItem> </DMItem>
<DMItem onSelect={state.redo} kbd="#⇧Z"> <DMItem onSelect={preventEvent} onClick={app.redo} kbd="#⇧Z">
Redo Redo
</DMItem> </DMItem>
<DMDivider dir="ltr" /> <DMDivider dir="ltr" />
<DMItem onSelect={handleCut} kbd="#X"> <DMItem onSelect={preventEvent} onClick={handleCut} kbd="#X">
Cut Cut
</DMItem> </DMItem>
<DMItem onSelect={handleCopy} kbd="#C"> <DMItem onSelect={preventEvent} onClick={handleCopy} kbd="#C">
Copy Copy
</DMItem> </DMItem>
<DMItem onSelect={handlePaste} kbd="#V"> <DMItem onSelect={preventEvent} onClick={handlePaste} kbd="#V">
Paste Paste
</DMItem> </DMItem>
<DMDivider dir="ltr" /> <DMDivider dir="ltr" />
<DMItem onSelect={handleCopySvg} kbd="#⇧C"> <DMItem onSelect={preventEvent} onClick={handleCopySvg} kbd="#⇧C">
Copy as SVG Copy as SVG
</DMItem> </DMItem>
<DMItem onSelect={handleCopyJson}>Copy as JSON</DMItem> <DMItem onSelect={preventEvent} onClick={handleCopyJson}>
Copy as JSON
</DMItem>
<DMDivider dir="ltr" /> <DMDivider dir="ltr" />
<DMItem onSelect={handleSelectAll} kbd="#A"> <DMItem onSelect={preventEvent} onClick={handleSelectAll} kbd="#A">
Select All Select All
</DMItem> </DMItem>
<DMItem onSelect={handleselectNone}>Select None</DMItem> <DMItem onSelect={preventEvent} onClick={handleselectNone}>
Select None
</DMItem>
</DMSubMenu> </DMSubMenu>
<DMDivider dir="ltr" />
</> </>
)} )}
<a href="https://tldraw.com/r">
<DMItem>Create a Multiplayer Room</DMItem>
</a>
<DMDivider dir="ltr" />
<PreferencesMenu /> <PreferencesMenu />
<DMDivider dir="ltr" />
<a href="https://github.com/Tldraw/Tldraw" target="_blank" rel="nofollow">
<DMItem>
Github
<SmallIcon>
<GitHubLogoIcon />
</SmallIcon>
</DMItem>
</a>
<a href="https://twitter.com/Tldraw" target="_blank" rel="nofollow">
<DMItem>
Twitter
<SmallIcon>
<TwitterLogoIcon />
</SmallIcon>
</DMItem>
</a>
{showSponsorLink && (
<a href="https://github.com/sponsors/steveruizok" target="_blank" rel="nofollow">
<DMItem isSponsor>
Become a Sponsor{' '}
<SmallIcon>
<HeartIcon />
</SmallIcon>
</DMItem>
</a>
)}
{showSignInOutMenu && ( {showSignInOutMenu && (
<> <>
<DMDivider dir="ltr" />{' '} <DMDivider dir="ltr" />{' '}
{state.callbacks.onSignIn && <DMItem onSelect={handleSignIn}>Sign In</DMItem>} {app.callbacks.onSignIn && <DMItem onSelect={handleSignIn}>Sign In</DMItem>}
{state.callbacks.onSignOut && ( {app.callbacks.onSignOut && (
<DMItem onSelect={handleSignOut}> <DMItem onSelect={handleSignOut}>
Sign Out Sign Out
<SmallIcon> <SmallIcon>

View file

@ -0,0 +1,82 @@
import * as React from 'react'
import { CheckIcon, ClipboardIcon } from '@radix-ui/react-icons'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { useTldrawApp } from '~hooks'
import { DMItem, DMContent, DMDivider, DMTriggerIcon } from '~components/DropdownMenu'
import { SmallIcon } from '~components/SmallIcon'
import { MultiplayerIcon } from '~components/icons'
import type { TDSnapshot } from '~types'
import { TLDR } from '~state/TLDR'
const roomSelector = (state: TDSnapshot) => state.room
export const MultiplayerMenu = React.memo(function MultiplayerMenu() {
const app = useTldrawApp()
const room = app.useStore(roomSelector)
const [copied, setCopied] = React.useState(false)
const handleCopySelect = React.useCallback(() => {
setCopied(true)
TLDR.copyStringToClipboard(window.location.href)
setTimeout(() => setCopied(false), 1200)
}, [])
const handleCreateMultiplayerRoom = React.useCallback(async () => {
if (app.isDirty) {
if (app.fileSystemHandle) {
if (window.confirm('Do you want to save changes to your current project?')) {
await app.saveProject()
}
} else {
if (window.confirm('Do you want to save your current project?')) {
await app.saveProject()
}
}
} else if (!app.fileSystemHandle) {
if (window.confirm('Do you want to save your current project?')) {
await app.saveProject()
}
}
}, [])
const handleCopyToMultiplayerRoom = React.useCallback(async () => {
const myHeaders = new Headers({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
})
const res = await fetch('http://tldraw.com/api/create-multiplayer-room', {
headers: myHeaders,
method: 'POST',
mode: 'cors',
cache: 'no-cache',
body: JSON.stringify(app.document),
}).then((res) => res.json())
window.location.href = `http://tldraw.com/r/${res.roomId}`
}, [])
return (
<DropdownMenu.Root dir="ltr">
<DMTriggerIcon>
<MultiplayerIcon />
</DMTriggerIcon>
<DMContent variant="menu" align="start">
<DMItem onClick={handleCreateMultiplayerRoom}>
<a href="https://tldraw.com/r">Create a Multiplayer Room</a>
</DMItem>
<DMItem onClick={handleCopyToMultiplayerRoom}>Copy to Multiplayer Room</DMItem>
{room && (
<>
<DMDivider />
<DMItem onClick={handleCopySelect}>
Copy Invite Link<SmallIcon>{copied ? <CheckIcon /> : <ClipboardIcon />}</SmallIcon>
</DMItem>
</>
)}
</DMContent>
</DropdownMenu.Root>
)
})

View file

@ -3,23 +3,22 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { PlusIcon, CheckIcon } from '@radix-ui/react-icons' import { PlusIcon, CheckIcon } from '@radix-ui/react-icons'
import { PageOptionsDialog } from './PageOptionsDialog' import { PageOptionsDialog } from './PageOptionsDialog'
import { styled } from '~styles' import { styled } from '~styles'
import { useTLDrawContext } from '~hooks' import { useTldrawApp } from '~hooks'
import type { TLDrawSnapshot } from '~types' import type { TDSnapshot } from '~types'
import { DMContent, DMDivider } from '~components/DropdownMenu' import { DMContent, DMDivider } from '~components/DropdownMenu'
import { SmallIcon } from '~components/SmallIcon' import { SmallIcon } from '~components/SmallIcon'
import { RowButton } from '~components/RowButton' import { RowButton } from '~components/RowButton'
import { ToolButton } from '~components/ToolButton' import { ToolButton } from '~components/ToolButton'
const sortedSelector = (s: TLDrawSnapshot) => const sortedSelector = (s: TDSnapshot) =>
Object.values(s.document.pages).sort((a, b) => (a.childIndex || 0) - (b.childIndex || 0)) Object.values(s.document.pages).sort((a, b) => (a.childIndex || 0) - (b.childIndex || 0))
const currentPageNameSelector = (s: TLDrawSnapshot) => const currentPageNameSelector = (s: TDSnapshot) => s.document.pages[s.appState.currentPageId].name
s.document.pages[s.appState.currentPageId].name
const currentPageIdSelector = (s: TLDrawSnapshot) => s.document.pages[s.appState.currentPageId].id const currentPageIdSelector = (s: TDSnapshot) => s.document.pages[s.appState.currentPageId].id
export function PageMenu(): JSX.Element { export function PageMenu(): JSX.Element {
const { useSelector } = useTLDrawContext() const app = useTldrawApp()
const rIsOpen = React.useRef(false) const rIsOpen = React.useRef(false)
@ -43,7 +42,7 @@ export function PageMenu(): JSX.Element {
}, },
[setIsOpen] [setIsOpen]
) )
const currentPageName = useSelector(currentPageNameSelector) const currentPageName = app.useStore(currentPageNameSelector)
return ( return (
<DropdownMenu.Root dir="ltr" open={isOpen} onOpenChange={handleOpenChange}> <DropdownMenu.Root dir="ltr" open={isOpen} onOpenChange={handleOpenChange}>
@ -58,27 +57,27 @@ export function PageMenu(): JSX.Element {
} }
function PageMenuContent({ onClose }: { onClose: () => void }) { function PageMenuContent({ onClose }: { onClose: () => void }) {
const { state, useSelector } = useTLDrawContext() const app = useTldrawApp()
const sortedPages = useSelector(sortedSelector) const sortedPages = app.useStore(sortedSelector)
const currentPageId = useSelector(currentPageIdSelector) const currentPageId = app.useStore(currentPageIdSelector)
const handleCreatePage = React.useCallback(() => { const handleCreatePage = React.useCallback(() => {
state.createPage() app.createPage()
}, [state]) }, [app])
const handleChangePage = React.useCallback( const handleChangePage = React.useCallback(
(id: string) => { (id: string) => {
onClose() onClose()
state.changePage(id) app.changePage(id)
}, },
[state] [app]
) )
return ( return (
<> <>
<DropdownMenu.RadioGroup value={currentPageId} onValueChange={handleChangePage}> <DropdownMenu.RadioGroup dir="ltr" value={currentPageId} onValueChange={handleChangePage}>
{sortedPages.map((page) => ( {sortedPages.map((page) => (
<ButtonWithOptions key={page.id}> <ButtonWithOptions key={page.id}>
<DropdownMenu.RadioItem <DropdownMenu.RadioItem

View file

@ -1,8 +1,8 @@
import * as React from 'react' import * as React from 'react'
import * as Dialog from '@radix-ui/react-alert-dialog' import * as Dialog from '@radix-ui/react-alert-dialog'
import { MixerVerticalIcon } from '@radix-ui/react-icons' import { MixerVerticalIcon } from '@radix-ui/react-icons'
import type { TLDrawSnapshot, TLDrawPage } from '~types' import type { TDSnapshot, TDPage } from '~types'
import { useTLDrawContext } from '~hooks' import { useTldrawApp } from '~hooks'
import { RowButton, RowButtonProps } from '~components/RowButton' import { RowButton, RowButtonProps } from '~components/RowButton'
import { styled } from '~styles' import { styled } from '~styles'
import { Divider } from '~components/Divider' import { Divider } from '~components/Divider'
@ -10,36 +10,36 @@ import { IconButton } from '~components/IconButton/IconButton'
import { SmallIcon } from '~components/SmallIcon' import { SmallIcon } from '~components/SmallIcon'
import { breakpoints } from '~components/breakpoints' import { breakpoints } from '~components/breakpoints'
const canDeleteSelector = (s: TLDrawSnapshot) => { const canDeleteSelector = (s: TDSnapshot) => {
return Object.keys(s.document.pages).length > 1 return Object.keys(s.document.pages).length > 1
} }
interface PageOptionsDialogProps { interface PageOptionsDialogProps {
page: TLDrawPage page: TDPage
onOpen?: () => void onOpen?: () => void
onClose?: () => void onClose?: () => void
} }
export function PageOptionsDialog({ page, onOpen, onClose }: PageOptionsDialogProps): JSX.Element { export function PageOptionsDialog({ page, onOpen, onClose }: PageOptionsDialogProps): JSX.Element {
const { state, useSelector } = useTLDrawContext() const app = useTldrawApp()
const [isOpen, setIsOpen] = React.useState(false) const [isOpen, setIsOpen] = React.useState(false)
const canDelete = useSelector(canDeleteSelector) const canDelete = app.useStore(canDeleteSelector)
const rInput = React.useRef<HTMLInputElement>(null) const rInput = React.useRef<HTMLInputElement>(null)
const handleDuplicate = React.useCallback(() => { const handleDuplicate = React.useCallback(() => {
state.duplicatePage(page.id) app.duplicatePage(page.id)
onClose?.() onClose?.()
}, [state]) }, [app])
const handleDelete = React.useCallback(() => { const handleDelete = React.useCallback(() => {
if (window.confirm(`Are you sure you want to delete this page?`)) { if (window.confirm(`Are you sure you want to delete this page?`)) {
state.deletePage(page.id) app.deletePage(page.id)
onClose?.() onClose?.()
} }
}, [state]) }, [app])
const handleOpenChange = React.useCallback( const handleOpenChange = React.useCallback(
(isOpen: boolean) => { (isOpen: boolean) => {
@ -50,7 +50,7 @@ export function PageOptionsDialog({ page, onOpen, onClose }: PageOptionsDialogPr
return return
} }
}, },
[state, name] [app]
) )
function stopPropagation(e: React.KeyboardEvent<HTMLDivElement>) { function stopPropagation(e: React.KeyboardEvent<HTMLDivElement>) {
@ -60,7 +60,7 @@ export function PageOptionsDialog({ page, onOpen, onClose }: PageOptionsDialogPr
// TODO: Replace with text input // TODO: Replace with text input
function handleRename() { function handleRename() {
const nextName = window.prompt('New name:', page.name) const nextName = window.prompt('New name:', page.name)
state.renamePage(page.id, nextName || page.name || 'Page') app.renamePage(page.id, nextName || page.name || 'Page')
} }
React.useEffect(() => { React.useEffect(() => {
@ -82,7 +82,7 @@ export function PageOptionsDialog({ page, onOpen, onClose }: PageOptionsDialogPr
</IconButton> </IconButton>
</Dialog.Trigger> </Dialog.Trigger>
<StyledDialogOverlay /> <StyledDialogOverlay />
<StyledDialogContent onKeyDown={stopPropagation} onKeyUp={stopPropagation}> <StyledDialogContent dir="ltr" onKeyDown={stopPropagation} onKeyUp={stopPropagation}>
<DialogAction onSelect={handleRename}>Rename</DialogAction> <DialogAction onSelect={handleRename}>Rename</DialogAction>
<DialogAction onSelect={handleDuplicate}>Duplicate</DialogAction> <DialogAction onSelect={handleDuplicate}>Duplicate</DialogAction>
<DialogAction disabled={!canDelete} onSelect={handleDelete}> <DialogAction disabled={!canDelete} onSelect={handleDelete}>
@ -112,7 +112,6 @@ export const StyledDialogContent = styled(Dialog.Content, {
marginTop: '-5vh', marginTop: '-5vh',
pointerEvents: 'all', pointerEvents: 'all',
backgroundColor: '$panel', backgroundColor: '$panel',
border: '1px solid $panelBorder',
padding: '$0', padding: '$0',
borderRadius: '$2', borderRadius: '$2',
font: '$ui', font: '$ui',
@ -132,9 +131,9 @@ export const StyledDialogOverlay = styled(Dialog.Overlay, {
height: '100%', height: '100%',
}) })
function DialogAction({ onSelect, ...rest }: RowButtonProps) { function DialogAction({ ...rest }: RowButtonProps & { onSelect: (e: Event) => void }) {
return ( return (
<Dialog.Action asChild onClick={onSelect}> <Dialog.Action asChild>
<RowButton {...rest} /> <RowButton {...rest} />
</Dialog.Action> </Dialog.Action>
) )

View file

@ -1,42 +1,42 @@
import * as React from 'react' import * as React from 'react'
import { DMCheckboxItem, DMDivider, DMSubMenu } from '~components/DropdownMenu' import { DMCheckboxItem, DMDivider, DMSubMenu } from '~components/DropdownMenu'
import { useTLDrawContext } from '~hooks' import { useTldrawApp } from '~hooks'
import type { TLDrawSnapshot } from '~types' import type { TDSnapshot } from '~types'
const settingsSelector = (s: TLDrawSnapshot) => s.settings const settingsSelector = (s: TDSnapshot) => s.settings
export function PreferencesMenu() { export function PreferencesMenu() {
const { state, useSelector } = useTLDrawContext() const app = useTldrawApp()
const settings = useSelector(settingsSelector) const settings = app.useStore(settingsSelector)
const toggleDebugMode = React.useCallback(() => { const toggleDebugMode = React.useCallback(() => {
state.setSetting('isDebugMode', (v) => !v) app.setSetting('isDebugMode', (v) => !v)
}, [state]) }, [app])
const toggleDarkMode = React.useCallback(() => { const toggleDarkMode = React.useCallback(() => {
state.setSetting('isDarkMode', (v) => !v) app.setSetting('isDarkMode', (v) => !v)
}, [state]) }, [app])
const toggleFocusMode = React.useCallback(() => { const toggleFocusMode = React.useCallback(() => {
state.setSetting('isFocusMode', (v) => !v) app.setSetting('isFocusMode', (v) => !v)
}, [state]) }, [app])
const toggleRotateHandle = React.useCallback(() => { const toggleRotateHandle = React.useCallback(() => {
state.setSetting('showRotateHandles', (v) => !v) app.setSetting('showRotateHandles', (v) => !v)
}, [state]) }, [app])
const toggleBoundShapesHandle = React.useCallback(() => { const toggleBoundShapesHandle = React.useCallback(() => {
state.setSetting('showBindingHandles', (v) => !v) app.setSetting('showBindingHandles', (v) => !v)
}, [state]) }, [app])
const toggleisSnapping = React.useCallback(() => { const toggleisSnapping = React.useCallback(() => {
state.setSetting('isSnapping', (v) => !v) app.setSetting('isSnapping', (v) => !v)
}, [state]) }, [app])
const toggleCloneControls = React.useCallback(() => { const toggleCloneControls = React.useCallback(() => {
state.setSetting('showCloneHandles', (v) => !v) app.setSetting('showCloneHandles', (v) => !v)
}, [state]) }, [app])
return ( return (
<DMSubMenu label="Preferences"> <DMSubMenu label="Preferences">

View file

@ -1,42 +0,0 @@
import * as React from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { TLDrawSnapshot, SizeStyle } from '~types'
import { useTLDrawContext } from '~hooks'
import { DMContent, DMTriggerIcon } from '~components/DropdownMenu'
import { ToolButton } from '~components/ToolButton'
import { SizeSmallIcon, SizeMediumIcon, SizeLargeIcon } from '~components/icons'
const sizes = {
[SizeStyle.Small]: <SizeSmallIcon />,
[SizeStyle.Medium]: <SizeMediumIcon />,
[SizeStyle.Large]: <SizeLargeIcon />,
}
const selectSize = (s: TLDrawSnapshot) => s.appState.selectedStyle.size
const preventEvent = (e: Event) => e.preventDefault()
export const SizeMenu = React.memo(function SizeMenu(): JSX.Element {
const { state, useSelector } = useTLDrawContext()
const size = useSelector(selectSize)
return (
<DropdownMenu.Root dir="ltr">
<DMTriggerIcon>{sizes[size as SizeStyle]}</DMTriggerIcon>
<DMContent variant="horizontal">
{Object.values(SizeStyle).map((sizeStyle: string) => (
<DropdownMenu.Item key={sizeStyle} onSelect={preventEvent} asChild>
<ToolButton
isActive={size === sizeStyle}
variant="icon"
onClick={() => state.style({ size: sizeStyle as SizeStyle })}
>
{sizes[sizeStyle as SizeStyle]}
</ToolButton>
</DropdownMenu.Item>
))}
</DMContent>
</DropdownMenu.Root>
)
})

View file

@ -0,0 +1,209 @@
import * as React from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { strokes, fills } from '~state/shapes/shared/shape-styles'
import { useTldrawApp } from '~hooks'
import { DMCheckboxItem, DMContent, DMRadioItem, DMTriggerIcon } from '~components/DropdownMenu'
import {
CircleIcon,
DashDashedIcon,
DashDottedIcon,
DashDrawIcon,
DashSolidIcon,
SizeLargeIcon,
SizeMediumIcon,
SizeSmallIcon,
} from '~components/icons'
import { ToolButton } from '~components/ToolButton'
import { TDSnapshot, ColorStyle, DashStyle, SizeStyle } from '~types'
import { styled } from '~styles'
import { breakpoints } from '~components/breakpoints'
import { Divider } from '~components/Divider'
import { preventEvent } from '~components/preventEvent'
const selectedStyleSelector = (s: TDSnapshot) => s.appState.selectedStyle
const dashes = {
[DashStyle.Draw]: <DashDrawIcon />,
[DashStyle.Solid]: <DashSolidIcon />,
[DashStyle.Dashed]: <DashDashedIcon />,
[DashStyle.Dotted]: <DashDottedIcon />,
}
const sizes = {
[SizeStyle.Small]: <SizeSmallIcon />,
[SizeStyle.Medium]: <SizeMediumIcon />,
[SizeStyle.Large]: <SizeLargeIcon />,
}
const themeSelector = (data: TDSnapshot) => (data.settings.isDarkMode ? 'dark' : 'light')
export const StyleMenu = React.memo(function ColorMenu(): JSX.Element {
const app = useTldrawApp()
const theme = app.useStore(themeSelector)
const style = app.useStore(selectedStyleSelector)
const handleToggleFilled = React.useCallback((checked: boolean) => {
app.style({ isFilled: checked })
}, [])
const handleDashChange = React.useCallback((value: string) => {
app.style({ dash: value as DashStyle })
}, [])
const handleSizeChange = React.useCallback((value: string) => {
app.style({ size: value as SizeStyle })
}, [])
return (
<DropdownMenu.Root dir="ltr">
<DMTriggerIcon>
<OverlapIcons
style={{
color: strokes[theme][style.color as ColorStyle],
}}
>
{style.isFilled && (
<CircleIcon size={16} stroke="none" fill={fills[theme][style.color as ColorStyle]} />
)}
{dashes[style.dash]}
</OverlapIcons>
</DMTriggerIcon>
<DMContent>
<StyledRow variant="tall">
<span>Color</span>
<ColorGrid>
{Object.keys(strokes.light).map((colorStyle: string) => (
<DropdownMenu.Item key={colorStyle} onSelect={preventEvent} asChild>
<ToolButton
variant="icon"
isActive={style.color === colorStyle}
onClick={() => app.style({ color: colorStyle as ColorStyle })}
>
<CircleIcon
size={18}
strokeWidth={2.5}
fill={style.isFilled ? fills.light[colorStyle as ColorStyle] : 'transparent'}
stroke={strokes.light[colorStyle as ColorStyle]}
/>
</ToolButton>
</DropdownMenu.Item>
))}
</ColorGrid>
</StyledRow>
<Divider />
<StyledRow>
Dash
<StyledGroup dir="ltr" value={style.dash} onValueChange={handleDashChange}>
{Object.values(DashStyle).map((dashStyle) => (
<DMRadioItem
key={dashStyle}
isActive={dashStyle === style.dash}
value={dashStyle}
onSelect={preventEvent}
bp={breakpoints}
>
{dashes[dashStyle as DashStyle]}
</DMRadioItem>
))}
</StyledGroup>
</StyledRow>
<Divider />
<StyledRow>
Size
<StyledGroup dir="ltr" value={style.size} onValueChange={handleSizeChange}>
{Object.values(SizeStyle).map((sizeStyle) => (
<DMRadioItem
key={sizeStyle}
isActive={sizeStyle === style.size}
value={sizeStyle}
onSelect={preventEvent}
bp={breakpoints}
>
{sizes[sizeStyle as SizeStyle]}
</DMRadioItem>
))}
</StyledGroup>
</StyledRow>
<Divider />
<DMCheckboxItem checked={!!style.isFilled} onCheckedChange={handleToggleFilled}>
Fill
</DMCheckboxItem>
</DMContent>
</DropdownMenu.Root>
)
})
const ColorGrid = styled('div', {
display: 'grid',
gridTemplateColumns: 'repeat(4, auto)',
gap: 0,
})
// const StyledRowInner = styled('div', {
// height: '100%',
// width: '100%',
// backgroundColor: '$panel',
// borderRadius: '$2',
// display: 'flex',
// gap: '$1',
// flexDirection: 'row',
// alignItems: 'center',
// padding: '0 $3',
// justifyContent: 'space-between',
// border: '1px solid transparent',
// '& svg': {
// position: 'relative',
// stroke: '$overlay',
// strokeWidth: 1,
// zIndex: 1,
// },
// })
export const StyledRow = styled('div', {
position: 'relative',
width: '100%',
background: 'none',
border: 'none',
cursor: 'pointer',
minHeight: '32px',
outline: 'none',
color: '$text',
fontFamily: '$ui',
fontWeight: 400,
fontSize: '$1',
padding: '0 0 0 $3',
borderRadius: 4,
userSelect: 'none',
margin: 0,
display: 'flex',
gap: '$3',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
variants: {
variant: {
tall: {
alignItems: 'flex-start',
'& > span': {
paddingTop: '$4',
},
},
},
},
})
const StyledGroup = styled(DropdownMenu.DropdownMenuRadioGroup, {
display: 'flex',
flexDirection: 'row',
})
const OverlapIcons = styled('div', {
display: 'grid',
'& > *': {
gridColumn: 1,
gridRow: 1,
},
})

View file

@ -3,10 +3,7 @@ import { Menu } from './Menu'
import { styled } from '~styles' import { styled } from '~styles'
import { PageMenu } from './PageMenu' import { PageMenu } from './PageMenu'
import { ZoomMenu } from './ZoomMenu' import { ZoomMenu } from './ZoomMenu'
import { DashMenu } from './DashMenu' import { StyleMenu } from './StyleMenu'
import { SizeMenu } from './SizeMenu'
import { FillCheckbox } from './FillCheckbox'
import { ColorMenu } from './ColorMenu'
import { Panel } from '~components/Panel' import { Panel } from '~components/Panel'
interface TopPanelProps { interface TopPanelProps {
@ -15,28 +12,29 @@ interface TopPanelProps {
showMenu: boolean showMenu: boolean
showStyles: boolean showStyles: boolean
showZoom: boolean showZoom: boolean
showSponsorLink: boolean
} }
export function TopPanel({ readOnly, showPages, showMenu, showStyles, showZoom }: TopPanelProps) { export function TopPanel({
readOnly,
showPages,
showMenu,
showStyles,
showZoom,
showSponsorLink,
}: TopPanelProps) {
return ( return (
<StyledTopPanel> <StyledTopPanel>
{(showMenu || showPages) && ( {(showMenu || showPages) && (
<Panel side="left"> <Panel side="left">
{showMenu && <Menu readOnly={readOnly} />} {showMenu && <Menu showSponsorLink={showSponsorLink} readOnly={readOnly} />}
{showPages && <PageMenu />} {showPages && <PageMenu />}
</Panel> </Panel>
)} )}
<StyledSpacer /> <StyledSpacer />
{(showStyles || showZoom) && ( {(showStyles || showZoom) && (
<Panel side="right"> <Panel side="right">
{showStyles && !readOnly && ( {showStyles && !readOnly && <StyleMenu />}
<>
<ColorMenu />
<SizeMenu />
<DashMenu />
<FillCheckbox />
</>
)}
{showZoom && <ZoomMenu />} {showZoom && <ZoomMenu />}
</Panel> </Panel>
)} )}

View file

@ -1,43 +1,46 @@
import * as React from 'react' import * as React from 'react'
import { useTLDrawContext } from '~hooks' import { useTldrawApp } from '~hooks'
import type { TLDrawSnapshot } from '~types' import type { TDSnapshot } from '~types'
import { styled } from '~styles' import { styled } from '~styles'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { DMItem, DMContent } from '~components/DropdownMenu' import { DMItem, DMContent } from '~components/DropdownMenu'
import { ToolButton } from '~components/ToolButton' import { ToolButton } from '~components/ToolButton'
import { preventEvent } from '~components/preventEvent'
const zoomSelector = (s: TLDrawSnapshot) => const zoomSelector = (s: TDSnapshot) => s.document.pageStates[s.appState.currentPageId].camera.zoom
s.document.pageStates[s.appState.currentPageId].camera.zoom
export function ZoomMenu() { export const ZoomMenu = React.memo(function ZoomMenu() {
const { state, useSelector } = useTLDrawContext() const app = useTldrawApp()
const zoom = useSelector(zoomSelector)
const zoom = app.useStore(zoomSelector)
return ( return (
<DropdownMenu.Root> <DropdownMenu.Root dir="ltr">
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger dir="ltr" asChild>
<FixedWidthToolButton variant="text">{Math.round(zoom * 100)}%</FixedWidthToolButton> <FixedWidthToolButton onDoubleClick={app.resetZoom} variant="text">
{Math.round(zoom * 100)}%
</FixedWidthToolButton>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DMContent align="end"> <DMContent align="end">
<DMItem onSelect={state.zoomIn} kbd="#+"> <DMItem onSelect={preventEvent} onClick={app.zoomIn} kbd="#+">
Zoom In Zoom In
</DMItem> </DMItem>
<DMItem onSelect={state.zoomOut} kbd="#"> <DMItem onSelect={preventEvent} onClick={app.zoomOut} kbd="#">
Zoom Out Zoom Out
</DMItem> </DMItem>
<DMItem onSelect={state.resetZoom} kbd="⇧0"> <DMItem onSelect={preventEvent} onClick={app.resetZoom} kbd="⇧0">
To 100% To 100%
</DMItem> </DMItem>
<DMItem onSelect={state.zoomToFit} kbd="⇧1"> <DMItem onSelect={preventEvent} onClick={app.zoomToFit} kbd="⇧1">
To Fit To Fit
</DMItem> </DMItem>
<DMItem onSelect={state.zoomToSelection} kbd="⇧2"> <DMItem onSelect={preventEvent} onClick={app.zoomToSelection} kbd="⇧2">
To Selection To Selection
</DMItem> </DMItem>
</DMContent> </DMContent>
</DropdownMenu.Root> </DropdownMenu.Root>
) )
} })
const FixedWidthToolButton = styled(ToolButton, { const FixedWidthToolButton = styled(ToolButton, {
minWidth: 56, minWidth: 56,

View file

@ -3,9 +3,11 @@ import * as React from 'react'
export function BoxIcon({ export function BoxIcon({
fill = 'none', fill = 'none',
stroke = 'currentColor', stroke = 'currentColor',
strokeWidth = 2,
}: { }: {
fill?: string fill?: string
stroke?: string stroke?: string
strokeWidth?: number
}): JSX.Element { }): JSX.Element {
return ( return (
<svg <svg
@ -13,10 +15,11 @@ export function BoxIcon({
height="24" height="24"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke={stroke} stroke={stroke}
strokeWidth={strokeWidth}
fill={fill} fill={fill}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<rect x="4" y="4" width="16" height="16" rx="2" strokeWidth="2" /> <rect x="4" y="4" width="16" height="16" rx="2" />
</svg> </svg>
) )
} }

View file

@ -1,7 +1,7 @@
import * as React from 'react' import * as React from 'react'
export function CircleIcon( export function CircleIcon(
props: Pick<React.SVGProps<SVGSVGElement>, 'stroke' | 'fill'> & { props: Pick<React.SVGProps<SVGSVGElement>, 'strokeWidth' | 'stroke' | 'fill'> & {
size: number size: number
} }
) { ) {

View file

@ -0,0 +1,21 @@
import * as React from 'react'
export function EraserIcon(): JSX.Element {
return (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M1.72838 9.33987L8.84935 2.34732C9.23874 1.96494 9.86279 1.96539 10.2516 2.34831L13.5636 5.60975C13.9655 6.00555 13.9607 6.65526 13.553 7.04507L8.13212 12.2278C7.94604 12.4057 7.69851 12.505 7.44107 12.505L6.06722 12.505L3.83772 12.505C3.5673 12.505 3.30842 12.3954 3.12009 12.2014L1.7114 10.7498C1.32837 10.3551 1.33596 9.72521 1.72838 9.33987Z"
stroke="currentColor"
/>
<line
x1="6.01807"
y1="12.5"
x2="10.7959"
y2="12.5"
stroke="currentColor"
strokeLinecap="round"
/>
<line x1="5.50834" y1="5.74606" x2="10.1984" y2="10.4361" stroke="currentColor" />
</svg>
)
}

View file

@ -0,0 +1,14 @@
import * as React from 'react'
export function HeartIcon() {
return (
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
strokeWidth={2}
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
/>
</svg>
)
}

View file

@ -11,7 +11,7 @@ export function IsFilledIcon(): JSX.Element {
rx="2" rx="2"
strokeWidth="2" strokeWidth="2"
fill="currentColor" fill="currentColor"
opacity=".3" opacity=".9"
/> />
</svg> </svg>
) )

View file

@ -0,0 +1,14 @@
import * as React from 'react'
export function MultiplayerIcon(): JSX.Element {
return (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.20634 0.691501C6.38439 0.610179 6.59352 0.640235 6.74146 0.768408L14.4999 7.49004C14.6546 7.62409 14.712 7.83893 14.6447 8.03228C14.5774 8.22564 14.399 8.35843 14.1945 8.36745L11.4343 8.48919L13.0504 12.04C13.1647 12.2913 13.0538 12.5877 12.8026 12.7022L10.9536 13.5444C10.7023 13.6589 10.4059 13.5481 10.2913 13.2968L8.67272 9.74659L6.83997 11.679L7.40596 12.9226C7.52032 13.1739 7.40939 13.4703 7.15815 13.5848L5.56925 14.3086C5.31801 14.423 5.02156 14.3122 4.90703 14.061L3.55936 11.105L2.00195 12.7471C1.86112 12.8956 1.64403 12.9433 1.45395 12.8675C1.26386 12.7917 1.13916 12.6077 1.13916 12.4031V3.59046C1.13916 3.39472 1.25338 3.21698 1.43143 3.13565C1.60949 3.05433 1.81862 3.08439 1.96656 3.21256L5.91406 6.63254V1.14631C5.91406 0.950565 6.02829 0.772823 6.20634 0.691501ZM7.13109 9.91888L6.91406 10.1477V9.92845V8.92747V8.82201V7.49891V2.24104L12.8962 7.42374L10.6504 7.52279C10.4845 7.53011 10.333 7.61939 10.2462 7.76104C10.2353 7.77874 10.2256 7.79698 10.2172 7.81563C10.1579 7.94618 10.1572 8.0971 10.2174 8.2294C10.2174 8.22941 10.2174 8.22941 10.2174 8.22942L11.9332 11.9993L10.9939 12.4272L9.27506 8.65717C9.2061 8.50591 9.06648 8.39882 8.90252 8.37142C8.73857 8.34402 8.57171 8.3999 8.45732 8.52051L8.2931 8.69366L7.1311 9.91887L7.13109 9.91888ZM5.91406 8.97158V7.95564L2.13916 4.68519V11.1493L3.34396 9.87894C3.45835 9.75833 3.62521 9.70245 3.78916 9.72985C3.95312 9.75725 4.09274 9.86434 4.1617 10.0156L5.60957 13.1913L6.28874 12.8819L4.84345 9.70633C4.77463 9.55512 4.78541 9.3796 4.87222 9.23795C4.95903 9.0963 5.11053 9.00702 5.2765 8.9997L5.91406 8.97158Z"
fill="black"
/>
</svg>
)
}

View file

@ -11,3 +11,5 @@ export * from './UndoIcon'
export * from './SizeSmallIcon' export * from './SizeSmallIcon'
export * from './SizeMediumIcon' export * from './SizeMediumIcon'
export * from './SizeLargeIcon' export * from './SizeLargeIcon'
export * from './EraserIcon'
export * from './MultiplayerIcon'

View file

@ -0,0 +1 @@
export const preventEvent = (e: Event) => e.preventDefault()

View file

@ -0,0 +1 @@
export const stopPropagation = (e: Event) => e.stopPropagation()

View file

@ -6,6 +6,8 @@ export const SNAP_DISTANCE = 5
export const EMPTY_ARRAY = [] as any[] export const EMPTY_ARRAY = [] as any[]
export const SLOW_SPEED = 10 export const SLOW_SPEED = 10
export const VERY_SLOW_SPEED = 2.5 export const VERY_SLOW_SPEED = 2.5
export const GHOSTED_OPACITY = 0.3
export const DEAD_ZONE = 3
import type { Easing } from '~types' import type { Easing } from '~types'

View file

@ -1,5 +1,5 @@
export * from './useKeyboardShortcuts' export * from './useKeyboardShortcuts'
export * from './useTLDrawContext' export * from './useTldrawApp'
export * from './useTheme' export * from './useTheme'
export * from './useStylesheet' export * from './useStylesheet'
export * from './useFileSystemHandlers' export * from './useFileSystemHandlers'

View file

@ -1,41 +1,41 @@
import * as React from 'react' import * as React from 'react'
import type { TLDrawState } from '~state' import type { TldrawApp } from '~state'
export function useFileSystem() { export function useFileSystem() {
const promptSaveBeforeChange = React.useCallback(async (state: TLDrawState) => { const promptSaveBeforeChange = React.useCallback(async (app: TldrawApp) => {
if (state.isDirty) { if (app.isDirty) {
if (state.fileSystemHandle) { if (app.fileSystemHandle) {
if (window.confirm('Do you want to save changes to your current project?')) { if (window.confirm('Do you want to save changes to your current project?')) {
await state.saveProject() await app.saveProject()
} }
} else { } else {
if (window.confirm('Do you want to save your current project?')) { if (window.confirm('Do you want to save your current project?')) {
await state.saveProject() await app.saveProject()
} }
} }
} }
}, []) }, [])
const onNewProject = React.useCallback( const onNewProject = React.useCallback(
async (state: TLDrawState) => { async (app: TldrawApp) => {
await promptSaveBeforeChange(state) await promptSaveBeforeChange(app)
state.newProject() app.newProject()
}, },
[promptSaveBeforeChange] [promptSaveBeforeChange]
) )
const onSaveProject = React.useCallback((state: TLDrawState) => { const onSaveProject = React.useCallback((app: TldrawApp) => {
state.saveProject() app.saveProject()
}, []) }, [])
const onSaveProjectAs = React.useCallback((state: TLDrawState) => { const onSaveProjectAs = React.useCallback((app: TldrawApp) => {
state.saveProjectAs() app.saveProjectAs()
}, []) }, [])
const onOpenProject = React.useCallback( const onOpenProject = React.useCallback(
async (state: TLDrawState) => { async (app: TldrawApp) => {
await promptSaveBeforeChange(state) await promptSaveBeforeChange(app)
state.openProject() app.openProject()
}, },
[promptSaveBeforeChange] [promptSaveBeforeChange]
) )

View file

@ -1,39 +1,39 @@
import * as React from 'react' import * as React from 'react'
import { useTLDrawContext } from '~hooks' import { useTldrawApp } from '~hooks'
export function useFileSystemHandlers() { export function useFileSystemHandlers() {
const { state } = useTLDrawContext() const app = useTldrawApp()
const onNewProject = React.useCallback( const onNewProject = React.useCallback(
async (e?: KeyboardEvent) => { async (e?: React.MouseEvent | React.KeyboardEvent | KeyboardEvent) => {
if (e && state.callbacks.onOpenProject) e.preventDefault() if (e && app.callbacks.onOpenProject) e.preventDefault()
state.callbacks.onNewProject?.(state) app.callbacks.onNewProject?.(app)
}, },
[state] [app]
) )
const onSaveProject = React.useCallback( const onSaveProject = React.useCallback(
(e?: KeyboardEvent) => { (e?: React.MouseEvent | React.KeyboardEvent | KeyboardEvent) => {
if (e && state.callbacks.onOpenProject) e.preventDefault() if (e && app.callbacks.onOpenProject) e.preventDefault()
state.callbacks.onSaveProject?.(state) app.callbacks.onSaveProject?.(app)
}, },
[state] [app]
) )
const onSaveProjectAs = React.useCallback( const onSaveProjectAs = React.useCallback(
(e?: KeyboardEvent) => { (e?: React.MouseEvent | React.KeyboardEvent | KeyboardEvent) => {
if (e && state.callbacks.onOpenProject) e.preventDefault() if (e && app.callbacks.onOpenProject) e.preventDefault()
state.callbacks.onSaveProjectAs?.(state) app.callbacks.onSaveProjectAs?.(app)
}, },
[state] [app]
) )
const onOpenProject = React.useCallback( const onOpenProject = React.useCallback(
async (e?: KeyboardEvent) => { async (e?: React.MouseEvent | React.KeyboardEvent | KeyboardEvent) => {
if (e && state.callbacks.onOpenProject) e.preventDefault() if (e && app.callbacks.onOpenProject) e.preventDefault()
state.callbacks.onOpenProject?.(state) app.callbacks.onOpenProject?.(app)
}, },
[state] [app]
) )
return { return {

View file

@ -1,10 +1,10 @@
import * as React from 'react' import * as React from 'react'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { TLDrawShapeType } from '~types' import { TDShapeType } from '~types'
import { useFileSystemHandlers, useTLDrawContext } from '~hooks' import { useFileSystemHandlers, useTldrawApp } from '~hooks'
export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) { export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
const { state } = useTLDrawContext() const app = useTldrawApp()
const canHandleEvent = React.useCallback(() => { const canHandleEvent = React.useCallback(() => {
const elm = ref.current const elm = ref.current
@ -16,63 +16,80 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
useHotkeys( useHotkeys(
'v,1', 'v,1',
() => { () => {
if (canHandleEvent()) state.selectTool('select') if (!canHandleEvent()) return
app.selectTool('select')
}, },
[state, ref.current] [app, ref.current]
) )
useHotkeys( useHotkeys(
'd,2', 'd,2',
() => { () => {
if (canHandleEvent()) state.selectTool(TLDrawShapeType.Draw) if (!canHandleEvent()) return
app.selectTool(TDShapeType.Draw)
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'r,3', 'e,3',
() => { () => {
if (canHandleEvent()) state.selectTool(TLDrawShapeType.Rectangle) if (!canHandleEvent()) return
app.selectTool('erase')
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'e,4', 'r,4',
() => { () => {
if (canHandleEvent()) state.selectTool(TLDrawShapeType.Ellipse) if (!canHandleEvent()) return
app.selectTool(TDShapeType.Rectangle)
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'a,5', '5',
() => { () => {
if (canHandleEvent()) state.selectTool(TLDrawShapeType.Arrow) if (!canHandleEvent()) return
app.selectTool(TDShapeType.Ellipse)
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
't,6', 'a,6',
() => { () => {
if (canHandleEvent()) state.selectTool(TLDrawShapeType.Text) if (!canHandleEvent()) return
app.selectTool(TDShapeType.Arrow)
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'n,7', 't,7',
() => { () => {
if (canHandleEvent()) state.selectTool(TLDrawShapeType.Sticky) if (!canHandleEvent()) return
app.selectTool(TDShapeType.Text)
}, },
undefined, undefined,
[state] [app]
)
useHotkeys(
'n,8',
() => {
if (!canHandleEvent()) return
app.selectTool(TDShapeType.Sticky)
},
undefined,
[app]
) )
/* ---------------------- Misc ---------------------- */ /* ---------------------- Misc ---------------------- */
@ -82,13 +99,12 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
useHotkeys( useHotkeys(
'ctrl+shift+d,command+shift+d', 'ctrl+shift+d,command+shift+d',
(e) => { (e) => {
if (canHandleEvent()) { if (!canHandleEvent()) return
state.toggleDarkMode() app.toggleDarkMode()
e.preventDefault() e.preventDefault()
}
}, },
undefined, undefined,
[state] [app]
) )
// Focus Mode // Focus Mode
@ -96,10 +112,11 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
useHotkeys( useHotkeys(
'ctrl+.,command+.', 'ctrl+.,command+.',
() => { () => {
if (canHandleEvent()) state.toggleFocusMode() if (!canHandleEvent()) return
app.toggleFocusMode()
}, },
undefined, undefined,
[state] [app]
) )
// File System // File System
@ -109,43 +126,43 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
useHotkeys( useHotkeys(
'ctrl+n,command+n', 'ctrl+n,command+n',
(e) => { (e) => {
if (canHandleEvent()) { if (!canHandleEvent()) return
onNewProject(e)
} onNewProject(e)
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'ctrl+s,command+s', 'ctrl+s,command+s',
(e) => { (e) => {
if (canHandleEvent()) { if (!canHandleEvent()) return
onSaveProject(e)
} onSaveProject(e)
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'ctrl+shift+s,command+shift+s', 'ctrl+shift+s,command+shift+s',
(e) => { (e) => {
if (canHandleEvent()) { if (!canHandleEvent()) return
onSaveProjectAs(e)
} onSaveProjectAs(e)
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'ctrl+o,command+o', 'ctrl+o,command+o',
(e) => { (e) => {
if (canHandleEvent()) { if (!canHandleEvent()) return
onOpenProject(e)
} onOpenProject(e)
}, },
undefined, undefined,
[state] [app]
) )
// Undo Redo // Undo Redo
@ -153,31 +170,31 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
useHotkeys( useHotkeys(
'command+z,ctrl+z', 'command+z,ctrl+z',
() => { () => {
if (canHandleEvent()) { if (!canHandleEvent()) return
if (state.session) {
state.cancelSession() if (app.session) {
} else { app.cancelSession()
state.undo() } else {
} app.undo()
} }
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'ctrl+shift-z,command+shift+z', 'ctrl+shift-z,command+shift+z',
() => { () => {
if (canHandleEvent()) { if (!canHandleEvent()) return
if (state.session) {
state.cancelSession() if (app.session) {
} else { app.cancelSession()
state.redo() } else {
} app.redo()
} }
}, },
undefined, undefined,
[state] [app]
) )
// Undo Redo // Undo Redo
@ -185,19 +202,21 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
useHotkeys( useHotkeys(
'command+u,ctrl+u', 'command+u,ctrl+u',
() => { () => {
if (canHandleEvent()) state.undoSelect() if (!canHandleEvent()) return
app.undoSelect()
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'ctrl+shift-u,command+shift+u', 'ctrl+shift-u,command+shift+u',
() => { () => {
if (canHandleEvent()) state.redoSelect() if (!canHandleEvent()) return
app.redoSelect()
}, },
undefined, undefined,
[state] [app]
) )
/* -------------------- Commands -------------------- */ /* -------------------- Commands -------------------- */
@ -207,52 +226,55 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
useHotkeys( useHotkeys(
'ctrl+=,command+=', 'ctrl+=,command+=',
(e) => { (e) => {
if (canHandleEvent()) { if (!canHandleEvent()) return
state.zoomIn()
e.preventDefault() app.zoomIn()
} e.preventDefault()
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'ctrl+-,command+-', 'ctrl+-,command+-',
(e) => { (e) => {
if (canHandleEvent()) { if (!canHandleEvent()) return
state.zoomOut()
e.preventDefault() app.zoomOut()
} e.preventDefault()
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'shift+1', 'shift+1',
() => { () => {
if (canHandleEvent()) state.zoomToFit() if (!canHandleEvent()) return
app.zoomToFit()
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'shift+2', 'shift+2',
() => { () => {
if (canHandleEvent()) state.zoomToSelection() if (!canHandleEvent()) return
app.zoomToSelection()
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'shift+0', 'shift+0',
() => { () => {
if (canHandleEvent()) state.resetZoom() if (!canHandleEvent()) return
app.resetZoom()
}, },
undefined, undefined,
[state] [app]
) )
// Duplicate // Duplicate
@ -260,13 +282,13 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
useHotkeys( useHotkeys(
'ctrl+d,command+d', 'ctrl+d,command+d',
(e) => { (e) => {
if (canHandleEvent()) { if (!canHandleEvent()) return
state.duplicate()
e.preventDefault() app.duplicate()
} e.preventDefault()
}, },
undefined, undefined,
[state] [app]
) )
// Flip // Flip
@ -274,19 +296,21 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
useHotkeys( useHotkeys(
'shift+h', 'shift+h',
() => { () => {
if (canHandleEvent()) state.flipHorizontal() if (!canHandleEvent()) return
app.flipHorizontal()
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'shift+v', 'shift+v',
() => { () => {
if (canHandleEvent()) state.flipVertical() if (!canHandleEvent()) return
app.flipVertical()
}, },
undefined, undefined,
[state] [app]
) )
// Cancel // Cancel
@ -294,12 +318,12 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
useHotkeys( useHotkeys(
'escape', 'escape',
() => { () => {
if (canHandleEvent()) { if (!canHandleEvent()) return
state.cancel()
} app.cancel()
}, },
undefined, undefined,
[state] [app]
) )
// Delete // Delete
@ -307,10 +331,11 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
useHotkeys( useHotkeys(
'backspace', 'backspace',
() => { () => {
if (canHandleEvent()) state.delete() if (!canHandleEvent()) return
app.delete()
}, },
undefined, undefined,
[state] [app]
) )
// Select All // Select All
@ -318,10 +343,11 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
useHotkeys( useHotkeys(
'command+a,ctrl+a', 'command+a,ctrl+a',
() => { () => {
if (canHandleEvent()) state.selectAll() if (!canHandleEvent()) return
app.selectAll()
}, },
undefined, undefined,
[state] [app]
) )
// Nudge // Nudge
@ -329,73 +355,91 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
useHotkeys( useHotkeys(
'up', 'up',
() => { () => {
if (canHandleEvent()) state.nudge([0, -1], false) if (!canHandleEvent()) return
app.nudge([0, -1], false)
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'right', 'right',
() => { () => {
if (canHandleEvent()) state.nudge([1, 0], false) if (!canHandleEvent()) return
app.nudge([1, 0], false)
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'down', 'down',
() => { () => {
if (canHandleEvent()) state.nudge([0, 1], false) if (!canHandleEvent()) return
app.nudge([0, 1], false)
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'left', 'left',
() => { () => {
if (canHandleEvent()) state.nudge([-1, 0], false) if (!canHandleEvent()) return
app.nudge([-1, 0], false)
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'shift+up', 'shift+up',
() => { () => {
if (canHandleEvent()) state.nudge([0, -1], true) if (!canHandleEvent()) return
app.nudge([0, -1], true)
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'shift+right', 'shift+right',
() => { () => {
if (canHandleEvent()) state.nudge([1, 0], true) if (!canHandleEvent()) return
app.nudge([1, 0], true)
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'shift+down', 'shift+down',
() => { () => {
if (canHandleEvent()) state.nudge([0, 1], true) if (!canHandleEvent()) return
app.nudge([0, 1], true)
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'shift+left', 'shift+left',
() => { () => {
if (canHandleEvent()) state.nudge([-1, 0], true) if (!canHandleEvent()) return
app.nudge([-1, 0], true)
}, },
undefined, undefined,
[state] [app]
)
useHotkeys(
'command+shift+l,ctrl+shift+l',
() => {
if (!canHandleEvent()) return
app.toggleLocked()
},
undefined,
[app]
) )
// Copy, Cut & Paste // Copy, Cut & Paste
@ -403,28 +447,31 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
useHotkeys( useHotkeys(
'command+c,ctrl+c', 'command+c,ctrl+c',
() => { () => {
if (canHandleEvent()) state.copy() if (!canHandleEvent()) return
app.copy()
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'command+x,ctrl+x', 'command+x,ctrl+x',
() => { () => {
if (canHandleEvent()) state.cut() if (!canHandleEvent()) return
app.cut()
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'command+v,ctrl+v', 'command+v,ctrl+v',
() => { () => {
if (canHandleEvent()) state.paste() if (!canHandleEvent()) return
app.paste()
}, },
undefined, undefined,
[state] [app]
) )
// Group & Ungroup // Group & Ungroup
@ -432,25 +479,25 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
useHotkeys( useHotkeys(
'command+g,ctrl+g', 'command+g,ctrl+g',
(e) => { (e) => {
if (canHandleEvent()) { if (!canHandleEvent()) return
state.group()
e.preventDefault() app.group()
} e.preventDefault()
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'command+shift+g,ctrl+shift+g', 'command+shift+g,ctrl+shift+g',
(e) => { (e) => {
if (canHandleEvent()) { if (!canHandleEvent()) return
state.ungroup()
e.preventDefault() app.ungroup()
} e.preventDefault()
}, },
undefined, undefined,
[state] [app]
) )
// Move // Move
@ -458,50 +505,53 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
useHotkeys( useHotkeys(
'[', '[',
() => { () => {
if (canHandleEvent()) state.moveBackward() if (!canHandleEvent()) return
app.moveBackward()
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
']', ']',
() => { () => {
if (canHandleEvent()) state.moveForward() if (!canHandleEvent()) return
app.moveForward()
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'shift+[', 'shift+[',
() => { () => {
if (canHandleEvent()) state.moveToBack() if (!canHandleEvent()) return
app.moveToBack()
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'shift+]', 'shift+]',
() => { () => {
if (canHandleEvent()) state.moveToFront() if (!canHandleEvent()) return
app.moveToFront()
}, },
undefined, undefined,
[state] [app]
) )
useHotkeys( useHotkeys(
'command+shift+backspace', 'command+shift+backspace',
(e) => { (e) => {
if (canHandleEvent()) { if (!canHandleEvent()) return
if (process.env.NODE_ENV === 'development') { if (app.settings.isDebugMode) {
state.resetDocument() app.resetDocument()
}
e.preventDefault()
} }
e.preventDefault()
}, },
undefined, undefined,
[state] [app]
) )
} }

View file

@ -2,8 +2,10 @@ import * as React from 'react'
const styles = new Map<string, HTMLStyleElement>() const styles = new Map<string, HTMLStyleElement>()
const UID = `tldraw-fonts` const UID = `Tldraw-fonts`
const CSS = `@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&display=swap')` const CSS = `
@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&display=swap')
`
export function useStylesheet() { export function useStylesheet() {
React.useLayoutEffect(() => { React.useLayoutEffect(() => {

View file

@ -1,17 +0,0 @@
import * as React from 'react'
import type { TLDrawSnapshot } from '~types'
import type { UseBoundStore } from 'zustand'
import type { TLDrawState } from '~state'
export interface TLDrawContextType {
state: TLDrawState
useSelector: UseBoundStore<TLDrawSnapshot>
}
export const TLDrawContext = React.createContext<TLDrawContextType>({} as TLDrawContextType)
export function useTLDrawContext() {
const context = React.useContext(TLDrawContext)
return context
}

View file

@ -1,14 +1,14 @@
import type { TLDrawSnapshot, Theme } from '~types' import type { TDSnapshot, Theme } from '~types'
import { useTLDrawContext } from './useTLDrawContext' import { useTldrawApp } from './useTldrawApp'
const themeSelector = (data: TLDrawSnapshot): Theme => (data.settings.isDarkMode ? 'dark' : 'light') const themeSelector = (data: TDSnapshot): Theme => (data.settings.isDarkMode ? 'dark' : 'light')
export function useTheme() { export function useTheme() {
const { state, useSelector } = useTLDrawContext() const app = useTldrawApp()
const theme = useSelector(themeSelector) const theme = app.useStore(themeSelector)
return { return {
theme, theme,
toggle: state.toggleDarkMode, toggle: app.toggleDarkMode,
} }
} }

View file

@ -0,0 +1,10 @@
import * as React from 'react'
import type { TldrawApp } from '~state'
export const TldrawContext = React.createContext<TldrawApp>({} as TldrawApp)
export function useTldrawApp() {
const context = React.useContext(TldrawContext)
return context
}

View file

@ -1,5 +1,5 @@
export * from './TLDraw' export * from './Tldraw'
export * from './types' export * from './types'
export * from './state/shapes' export * from './state/shapes'
export { TLDrawState } from './state' export { TldrawApp } from './state'
export { useFileSystem } from './hooks' export { useFileSystem } from './hooks'

View file

@ -1,36 +1,36 @@
import { TLBounds, TLTransformInfo, Utils, TLPageState } from '@tldraw/core' import { TLBounds, TLTransformInfo, Utils, TLPageState } from '@tldraw/core'
import { import {
TLDrawSnapshot, TDSnapshot,
ShapeStyles, ShapeStyles,
ShapesWithProp, ShapesWithProp,
TLDrawShape, TDShape,
TLDrawBinding, TDBinding,
TLDrawPage, TDPage,
TLDrawCommand, TldrawCommand,
TLDrawPatch, TldrawPatch,
TLDrawShapeType, TDShapeType,
ArrowShape, ArrowShape,
} from '~types' } from '~types'
import { Vec } from '@tldraw/vec' import { Vec } from '@tldraw/vec'
import type { TLDrawShapeUtil } from './shapes/TLDrawShapeUtil' import type { TDShapeUtil } from './shapes/TDShapeUtil'
import { getShapeUtils } from './shapes' import { getShapeUtil } from './shapes'
export class TLDR { export class TLDR {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
static getShapeUtils<T extends TLDrawShape>(type: T['type']): TLDrawShapeUtil<T> static getShapeUtil<T extends TDShape>(type: T['type']): TDShapeUtil<T>
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
static getShapeUtils<T extends TLDrawShape>(shape: T): TLDrawShapeUtil<T> static getShapeUtil<T extends TDShape>(shape: T): TDShapeUtil<T>
static getShapeUtils<T extends TLDrawShape>(shape: T | T['type']) { static getShapeUtil<T extends TDShape>(shape: T | T['type']) {
return getShapeUtils<T>(shape) return getShapeUtil<T>(shape)
} }
static getSelectedShapes(data: TLDrawSnapshot, pageId: string) { static getSelectedShapes(data: TDSnapshot, pageId: string) {
const page = TLDR.getPage(data, pageId) const page = TLDR.getPage(data, pageId)
const selectedIds = TLDR.getSelectedIds(data, pageId) const selectedIds = TLDR.getSelectedIds(data, pageId)
return selectedIds.map((id) => page.shapes[id]) return selectedIds.map((id) => page.shapes[id])
} }
static screenToWorld(data: TLDrawSnapshot, point: number[]) { static screenToWorld(data: TDSnapshot, point: number[]) {
const camera = TLDR.getPageState(data, data.appState.currentPageId).camera const camera = TLDR.getPageState(data, data.appState.currentPageId).camera
return Vec.sub(Vec.div(point, camera.zoom), camera.point) return Vec.sub(Vec.div(point, camera.zoom), camera.point)
} }
@ -39,59 +39,59 @@ export class TLDR {
return Utils.clamp(zoom, 0.1, 5) return Utils.clamp(zoom, 0.1, 5)
} }
static getPage(data: TLDrawSnapshot, pageId: string): TLDrawPage { static getPage(data: TDSnapshot, pageId: string): TDPage {
return data.document.pages[pageId] return data.document.pages[pageId]
} }
static getPageState(data: TLDrawSnapshot, pageId: string): TLPageState { static getPageState(data: TDSnapshot, pageId: string): TLPageState {
return data.document.pageStates[pageId] return data.document.pageStates[pageId]
} }
static getSelectedIds(data: TLDrawSnapshot, pageId: string): string[] { static getSelectedIds(data: TDSnapshot, pageId: string): string[] {
return TLDR.getPageState(data, pageId).selectedIds return TLDR.getPageState(data, pageId).selectedIds
} }
static getShapes(data: TLDrawSnapshot, pageId: string): TLDrawShape[] { static getShapes(data: TDSnapshot, pageId: string): TDShape[] {
return Object.values(TLDR.getPage(data, pageId).shapes) return Object.values(TLDR.getPage(data, pageId).shapes)
} }
static getCamera(data: TLDrawSnapshot, pageId: string): TLPageState['camera'] { static getCamera(data: TDSnapshot, pageId: string): TLPageState['camera'] {
return TLDR.getPageState(data, pageId).camera return TLDR.getPageState(data, pageId).camera
} }
static getShape<T extends TLDrawShape = TLDrawShape>( static getShape<T extends TDShape = TDShape>(
data: TLDrawSnapshot, data: TDSnapshot,
shapeId: string, shapeId: string,
pageId: string pageId: string
): T { ): T {
return TLDR.getPage(data, pageId).shapes[shapeId] as T return TLDR.getPage(data, pageId).shapes[shapeId] as T
} }
static getCenter<T extends TLDrawShape>(shape: T) { static getCenter<T extends TDShape>(shape: T) {
return TLDR.getShapeUtils(shape).getCenter(shape) return TLDR.getShapeUtil(shape).getCenter(shape)
} }
static getBounds<T extends TLDrawShape>(shape: T) { static getBounds<T extends TDShape>(shape: T) {
return TLDR.getShapeUtils(shape).getBounds(shape) return TLDR.getShapeUtil(shape).getBounds(shape)
} }
static getRotatedBounds<T extends TLDrawShape>(shape: T) { static getRotatedBounds<T extends TDShape>(shape: T) {
return TLDR.getShapeUtils(shape).getRotatedBounds(shape) return TLDR.getShapeUtil(shape).getRotatedBounds(shape)
} }
static getSelectedBounds(data: TLDrawSnapshot): TLBounds { static getSelectedBounds(data: TDSnapshot): TLBounds {
return Utils.getCommonBounds( return Utils.getCommonBounds(
TLDR.getSelectedShapes(data, data.appState.currentPageId).map((shape) => TLDR.getSelectedShapes(data, data.appState.currentPageId).map((shape) =>
TLDR.getShapeUtils(shape).getBounds(shape) TLDR.getShapeUtil(shape).getBounds(shape)
) )
) )
} }
static getParentId(data: TLDrawSnapshot, id: string, pageId: string) { static getParentId(data: TDSnapshot, id: string, pageId: string) {
return TLDR.getShape(data, id, pageId).parentId return TLDR.getShape(data, id, pageId).parentId
} }
// static getPointedId(data: TLDrawSnapshot, id: string, pageId: string): string { // static getPointedId(data: TDSnapshot, id: string, pageId: string): string {
// const page = TLDR.getPage(data, pageId) // const page = TLDR.getPage(data, pageId)
// const pageState = TLDR.getPageState(data, data.appState.currentPageId) // const pageState = TLDR.getPageState(data, data.appState.currentPageId)
// const shape = TLDR.getShape(data, id, pageId) // const shape = TLDR.getShape(data, id, pageId)
@ -102,7 +102,7 @@ export class TLDR {
// : TLDR.getPointedId(data, shape.parentId, pageId) // : TLDR.getPointedId(data, shape.parentId, pageId)
// } // }
// static getDrilledPointedId(data: TLDrawSnapshot, id: string, pageId: string): string { // static getDrilledPointedId(data: TDSnapshot, id: string, pageId: string): string {
// const shape = TLDR.getShape(data, id, pageId) // const shape = TLDR.getShape(data, id, pageId)
// const { currentPageId } = data.appState // const { currentPageId } = data.appState
// const { currentParentId, pointedId } = TLDR.getPageState(data, data.appState.currentPageId) // const { currentParentId, pointedId } = TLDR.getPageState(data, data.appState.currentPageId)
@ -114,7 +114,7 @@ export class TLDR {
// : TLDR.getDrilledPointedId(data, shape.parentId, pageId) // : TLDR.getDrilledPointedId(data, shape.parentId, pageId)
// } // }
// static getTopParentId(data: TLDrawSnapshot, id: string, pageId: string): string { // static getTopParentId(data: TDSnapshot, id: string, pageId: string): string {
// const page = TLDR.getPage(data, pageId) // const page = TLDR.getPage(data, pageId)
// const pageState = TLDR.getPageState(data, pageId) // const pageState = TLDR.getPageState(data, pageId)
// const shape = TLDR.getShape(data, id, pageId) // const shape = TLDR.getShape(data, id, pageId)
@ -129,7 +129,7 @@ export class TLDR {
// } // }
// Get an array of a shape id and its descendant shapes' ids // Get an array of a shape id and its descendant shapes' ids
static getDocumentBranch(data: TLDrawSnapshot, id: string, pageId: string): string[] { static getDocumentBranch(data: TDSnapshot, id: string, pageId: string): string[] {
const shape = TLDR.getShape(data, id, pageId) const shape = TLDR.getShape(data, id, pageId)
if (shape.children === undefined) return [id] if (shape.children === undefined) return [id]
@ -142,16 +142,16 @@ export class TLDR {
// Get a deep array of unproxied shapes and their descendants // Get a deep array of unproxied shapes and their descendants
static getSelectedBranchSnapshot<K>( static getSelectedBranchSnapshot<K>(
data: TLDrawSnapshot, data: TDSnapshot,
pageId: string, pageId: string,
fn: (shape: TLDrawShape) => K fn: (shape: TDShape) => K
): ({ id: string } & K)[] ): ({ id: string } & K)[]
static getSelectedBranchSnapshot(data: TLDrawSnapshot, pageId: string): TLDrawShape[] static getSelectedBranchSnapshot(data: TDSnapshot, pageId: string): TDShape[]
static getSelectedBranchSnapshot<K>( static getSelectedBranchSnapshot<K>(
data: TLDrawSnapshot, data: TDSnapshot,
pageId: string, pageId: string,
fn?: (shape: TLDrawShape) => K fn?: (shape: TDShape) => K
): (TLDrawShape | K)[] { ): (TDShape | K)[] {
const page = TLDR.getPage(data, pageId) const page = TLDR.getPage(data, pageId)
const copies = TLDR.getSelectedIds(data, pageId) const copies = TLDR.getSelectedIds(data, pageId)
@ -167,17 +167,17 @@ export class TLDR {
} }
// Get a shallow array of unproxied shapes // Get a shallow array of unproxied shapes
static getSelectedShapeSnapshot(data: TLDrawSnapshot, pageId: string): TLDrawShape[] static getSelectedShapeSnapshot(data: TDSnapshot, pageId: string): TDShape[]
static getSelectedShapeSnapshot<K>( static getSelectedShapeSnapshot<K>(
data: TLDrawSnapshot, data: TDSnapshot,
pageId: string, pageId: string,
fn?: (shape: TLDrawShape) => K fn?: (shape: TDShape) => K
): ({ id: string } & K)[] ): ({ id: string } & K)[]
static getSelectedShapeSnapshot<K>( static getSelectedShapeSnapshot<K>(
data: TLDrawSnapshot, data: TDSnapshot,
pageId: string, pageId: string,
fn?: (shape: TLDrawShape) => K fn?: (shape: TDShape) => K
): (TLDrawShape | K)[] { ): (TDShape | K)[] {
const copies = TLDR.getSelectedShapes(data, pageId) const copies = TLDR.getSelectedShapes(data, pageId)
.filter((shape) => !shape.isLocked) .filter((shape) => !shape.isLocked)
.map(Utils.deepClone) .map(Utils.deepClone)
@ -191,7 +191,7 @@ export class TLDR {
// For a given array of shape ids, an array of all other shapes that may be affected by a mutation to it. // For a given array of shape ids, an array of all other shapes that may be affected by a mutation to it.
// Use this to decide which shapes to clone as before / after for a command. // Use this to decide which shapes to clone as before / after for a command.
static getAllEffectedShapeIds(data: TLDrawSnapshot, ids: string[], pageId: string): string[] { static getAllEffectedShapeIds(data: TDSnapshot, ids: string[], pageId: string): string[] {
const page = TLDR.getPage(data, pageId) const page = TLDR.getPage(data, pageId)
const visited = new Set(ids) const visited = new Set(ids)
@ -200,7 +200,7 @@ export class TLDR {
const shape = page.shapes[id] const shape = page.shapes[id]
// Add descendant shapes // Add descendant shapes
function collectDescendants(shape: TLDrawShape): void { function collectDescendants(shape: TDShape): void {
if (shape.children === undefined) return if (shape.children === undefined) return
shape.children shape.children
.filter((childId) => !visited.has(childId)) .filter((childId) => !visited.has(childId))
@ -213,7 +213,7 @@ export class TLDR {
collectDescendants(shape) collectDescendants(shape)
// Add asecendant shapes // Add asecendant shapes
function collectAscendants(shape: TLDrawShape): void { function collectAscendants(shape: TDShape): void {
const parentId = shape.parentId const parentId = shape.parentId
if (parentId === page.id) return if (parentId === page.id) return
if (visited.has(parentId)) return if (visited.has(parentId)) return
@ -236,47 +236,47 @@ export class TLDR {
} }
static updateBindings( static updateBindings(
data: TLDrawSnapshot, data: TDSnapshot,
id: string, id: string,
beforeShapes: Record<string, Partial<TLDrawShape>> = {}, beforeShapes: Record<string, Partial<TDShape>> = {},
afterShapes: Record<string, Partial<TLDrawShape>> = {}, afterShapes: Record<string, Partial<TDShape>> = {},
pageId: string pageId: string
): TLDrawSnapshot { ): TDSnapshot {
const page = { ...TLDR.getPage(data, pageId) } const page = { ...TLDR.getPage(data, pageId) }
return Object.values(page.bindings) return Object.values(page.bindings)
.filter((binding) => binding.fromId === id || binding.toId === id) .filter((binding) => binding.fromId === id || binding.toId === id)
.reduce((cTLDrawSnapshot, binding) => { .reduce((cTDSnapshot, binding) => {
if (!beforeShapes[binding.fromId]) { if (!beforeShapes[binding.fromId]) {
beforeShapes[binding.fromId] = Utils.deepClone( beforeShapes[binding.fromId] = Utils.deepClone(
TLDR.getShape(cTLDrawSnapshot, binding.fromId, pageId) TLDR.getShape(cTDSnapshot, binding.fromId, pageId)
) )
} }
if (!beforeShapes[binding.toId]) { if (!beforeShapes[binding.toId]) {
beforeShapes[binding.toId] = Utils.deepClone( beforeShapes[binding.toId] = Utils.deepClone(
TLDR.getShape(cTLDrawSnapshot, binding.toId, pageId) TLDR.getShape(cTDSnapshot, binding.toId, pageId)
) )
} }
TLDR.onBindingChange( TLDR.onBindingChange(
TLDR.getShape(cTLDrawSnapshot, binding.fromId, pageId), TLDR.getShape(cTDSnapshot, binding.fromId, pageId),
binding, binding,
TLDR.getShape(cTLDrawSnapshot, binding.toId, pageId) TLDR.getShape(cTDSnapshot, binding.toId, pageId)
) )
afterShapes[binding.fromId] = Utils.deepClone( afterShapes[binding.fromId] = Utils.deepClone(
TLDR.getShape(cTLDrawSnapshot, binding.fromId, pageId) TLDR.getShape(cTDSnapshot, binding.fromId, pageId)
) )
afterShapes[binding.toId] = Utils.deepClone( afterShapes[binding.toId] = Utils.deepClone(
TLDR.getShape(cTLDrawSnapshot, binding.toId, pageId) TLDR.getShape(cTDSnapshot, binding.toId, pageId)
) )
return cTLDrawSnapshot return cTDSnapshot
}, data) }, data)
} }
static getLinkedShapes( static getLinkedShapeIds(
data: TLDrawSnapshot, data: TDSnapshot,
pageId: string, pageId: string,
direction: 'center' | 'left' | 'right', direction: 'center' | 'left' | 'right',
includeArrows = true includeArrows = true
@ -294,7 +294,7 @@ export class TLDR {
const arrows = new Set( const arrows = new Set(
Object.values(page.shapes).filter((shape) => { Object.values(page.shapes).filter((shape) => {
return ( return (
shape.type === TLDrawShapeType.Arrow && shape.type === TDShapeType.Arrow &&
(shape.handles.start.bindingId || shape.handles?.end.bindingId) (shape.handles.start.bindingId || shape.handles?.end.bindingId)
) )
}) as ArrowShape[] }) as ArrowShape[]
@ -378,11 +378,11 @@ export class TLDR {
return Array.from(linkedIds.values()) return Array.from(linkedIds.values())
} }
static getChildIndexAbove(data: TLDrawSnapshot, id: string, pageId: string): number { static getChildIndexAbove(data: TDSnapshot, id: string, pageId: string): number {
const page = data.document.pages[pageId] const page = data.document.pages[pageId]
const shape = page.shapes[id] const shape = page.shapes[id]
let siblings: TLDrawShape[] let siblings: TDShape[]
if (shape.parentId === page.id) { if (shape.parentId === page.id) {
siblings = Object.values(page.shapes) siblings = Object.values(page.shapes)
@ -409,27 +409,29 @@ export class TLDR {
/* Mutations */ /* Mutations */
/* -------------------------------------------------- */ /* -------------------------------------------------- */
static getBeforeShape<T extends TLDrawShape>(shape: T, change: Partial<T>): Partial<T> { static getBeforeShape<T extends TDShape>(shape: T, change: Partial<T>): Partial<T> {
return Object.fromEntries( return Object.fromEntries(
Object.keys(change).map((k) => [k, shape[k as keyof T]]) Object.keys(change).map((k) => [k, shape[k as keyof T]])
) as Partial<T> ) as Partial<T>
} }
static mutateShapes<T extends TLDrawShape>( static mutateShapes<T extends TDShape>(
data: TLDrawSnapshot, data: TDSnapshot,
ids: string[], ids: string[],
fn: (shape: T, i: number) => Partial<T> | void, fn: (shape: T, i: number) => Partial<T> | void,
pageId: string pageId: string
): { ): {
before: Record<string, Partial<T>> before: Record<string, Partial<T>>
after: Record<string, Partial<T>> after: Record<string, Partial<T>>
data: TLDrawSnapshot data: TDSnapshot
} { } {
const beforeShapes: Record<string, Partial<T>> = {} const beforeShapes: Record<string, Partial<T>> = {}
const afterShapes: Record<string, Partial<T>> = {} const afterShapes: Record<string, Partial<T>> = {}
ids.forEach((id, i) => { ids.forEach((id, i) => {
const shape = TLDR.getShape<T>(data, id, pageId) const shape = TLDR.getShape<T>(data, id, pageId)
if (shape.isLocked) return
const change = fn(shape, i) const change = fn(shape, i)
if (change) { if (change) {
beforeShapes[id] = TLDR.getBeforeShape(shape, change) beforeShapes[id] = TLDR.getBeforeShape(shape, change)
@ -446,8 +448,8 @@ export class TLDR {
}, },
}, },
}) })
const dataWithBindingChanges = ids.reduce<TLDrawSnapshot>((cTLDrawSnapshot, id) => { const dataWithBindingChanges = ids.reduce<TDSnapshot>((cTDSnapshot, id) => {
return TLDR.updateBindings(cTLDrawSnapshot, id, beforeShapes, afterShapes, pageId) return TLDR.updateBindings(cTDSnapshot, id, beforeShapes, afterShapes, pageId)
}, dataWithMutations) }, dataWithMutations)
return { return {
@ -457,17 +459,15 @@ export class TLDR {
} }
} }
static createShapes(data: TLDrawSnapshot, shapes: TLDrawShape[], pageId: string): TLDrawCommand { static createShapes(data: TDSnapshot, shapes: TDShape[], pageId: string): TldrawCommand {
const before: TLDrawPatch = { const before: TldrawPatch = {
document: { document: {
pages: { pages: {
[pageId]: { [pageId]: {
shapes: { shapes: {
...Object.fromEntries( ...Object.fromEntries(
shapes.flatMap((shape) => { shapes.flatMap((shape) => {
const results: [string, Partial<TLDrawShape> | undefined][] = [ const results: [string, Partial<TDShape> | undefined][] = [[shape.id, undefined]]
[shape.id, undefined],
]
// If the shape is a child of another shape, also save that shape // If the shape is a child of another shape, also save that shape
if (shape.parentId !== pageId) { if (shape.parentId !== pageId) {
@ -485,7 +485,7 @@ export class TLDR {
}, },
} }
const after: TLDrawPatch = { const after: TldrawPatch = {
document: { document: {
pages: { pages: {
[pageId]: { [pageId]: {
@ -493,9 +493,7 @@ export class TLDR {
shapes: { shapes: {
...Object.fromEntries( ...Object.fromEntries(
shapes.flatMap((shape) => { shapes.flatMap((shape) => {
const results: [string, Partial<TLDrawShape> | undefined][] = [ const results: [string, Partial<TDShape> | undefined][] = [[shape.id, shape]]
[shape.id, shape],
]
// If the shape is a child of a different shape, update its parent // If the shape is a child of a different shape, update its parent
if (shape.parentId !== pageId) { if (shape.parentId !== pageId) {
@ -521,10 +519,10 @@ export class TLDR {
} }
static deleteShapes( static deleteShapes(
data: TLDrawSnapshot, data: TDSnapshot,
shapes: TLDrawShape[] | string[], shapes: TDShape[] | string[],
pageId?: string pageId?: string
): TLDrawCommand { ): TldrawCommand {
pageId = pageId ? pageId : data.appState.currentPageId pageId = pageId ? pageId : data.appState.currentPageId
const page = TLDR.getPage(data, pageId) const page = TLDR.getPage(data, pageId)
@ -532,9 +530,9 @@ export class TLDR {
const shapeIds = const shapeIds =
typeof shapes[0] === 'string' typeof shapes[0] === 'string'
? (shapes as string[]) ? (shapes as string[])
: (shapes as TLDrawShape[]).map((shape) => shape.id) : (shapes as TDShape[]).map((shape) => shape.id)
const before: TLDrawPatch = { const before: TldrawPatch = {
document: { document: {
pages: { pages: {
[pageId]: { [pageId]: {
@ -543,7 +541,7 @@ export class TLDR {
...Object.fromEntries( ...Object.fromEntries(
shapeIds.flatMap((id) => { shapeIds.flatMap((id) => {
const shape = page.shapes[id] const shape = page.shapes[id]
const results: [string, Partial<TLDrawShape> | undefined][] = [[shape.id, shape]] const results: [string, Partial<TDShape> | undefined][] = [[shape.id, shape]]
// If the shape is a child of another shape, also add that shape // If the shape is a child of another shape, also add that shape
if (shape.parentId !== pageId) { if (shape.parentId !== pageId) {
@ -573,7 +571,7 @@ export class TLDR {
}, },
} }
const after: TLDrawPatch = { const after: TldrawPatch = {
document: { document: {
pages: { pages: {
[pageId]: { [pageId]: {
@ -581,9 +579,7 @@ export class TLDR {
...Object.fromEntries( ...Object.fromEntries(
shapeIds.flatMap((id) => { shapeIds.flatMap((id) => {
const shape = page.shapes[id] const shape = page.shapes[id]
const results: [string, Partial<TLDrawShape> | undefined][] = [ const results: [string, Partial<TDShape> | undefined][] = [[shape.id, undefined]]
[shape.id, undefined],
]
// If the shape is a child of a different shape, update its parent // If the shape is a child of a different shape, update its parent
if (shape.parentId !== page.id) { if (shape.parentId !== page.id) {
@ -612,16 +608,16 @@ export class TLDR {
} }
} }
static onSessionComplete<T extends TLDrawShape>(shape: T) { static onSessionComplete<T extends TDShape>(shape: T) {
const delta = TLDR.getShapeUtils(shape).onSessionComplete?.(shape) const delta = TLDR.getShapeUtil(shape).onSessionComplete?.(shape)
if (!delta) return shape if (!delta) return shape
return { ...shape, ...delta } return { ...shape, ...delta }
} }
static onChildrenChange<T extends TLDrawShape>(data: TLDrawSnapshot, shape: T, pageId: string) { static onChildrenChange<T extends TDShape>(data: TDSnapshot, shape: T, pageId: string) {
if (!shape.children) return if (!shape.children) return
const delta = TLDR.getShapeUtils(shape).onChildrenChange?.( const delta = TLDR.getShapeUtil(shape).onChildrenChange?.(
shape, shape,
shape.children.map((id) => TLDR.getShape(data, id, pageId)) shape.children.map((id) => TLDR.getShape(data, id, pageId))
) )
@ -631,35 +627,27 @@ export class TLDR {
return { ...shape, ...delta } return { ...shape, ...delta }
} }
static onBindingChange<T extends TLDrawShape>( static onBindingChange<T extends TDShape>(shape: T, binding: TDBinding, otherShape: TDShape) {
shape: T, const delta = TLDR.getShapeUtil(shape).onBindingChange?.(
binding: TLDrawBinding,
otherShape: TLDrawShape
) {
const delta = TLDR.getShapeUtils(shape).onBindingChange?.(
shape, shape,
binding, binding,
otherShape, otherShape,
TLDR.getShapeUtils(otherShape).getBounds(otherShape), TLDR.getShapeUtil(otherShape).getBounds(otherShape),
TLDR.getShapeUtils(otherShape).getCenter(otherShape) TLDR.getShapeUtil(otherShape).getCenter(otherShape)
) )
if (!delta) return shape if (!delta) return shape
return { ...shape, ...delta } return { ...shape, ...delta }
} }
static transform<T extends TLDrawShape>(shape: T, bounds: TLBounds, info: TLTransformInfo<T>) { static transform<T extends TDShape>(shape: T, bounds: TLBounds, info: TLTransformInfo<T>) {
const delta = TLDR.getShapeUtils(shape).transform(shape, bounds, info) const delta = TLDR.getShapeUtil(shape).transform(shape, bounds, info)
if (!delta) return shape if (!delta) return shape
return { ...shape, ...delta } return { ...shape, ...delta }
} }
static transformSingle<T extends TLDrawShape>( static transformSingle<T extends TDShape>(shape: T, bounds: TLBounds, info: TLTransformInfo<T>) {
shape: T, const delta = TLDR.getShapeUtil(shape).transformSingle(shape, bounds, info)
bounds: TLBounds,
info: TLTransformInfo<T>
) {
const delta = TLDR.getShapeUtils(shape).transformSingle(shape, bounds, info)
if (!delta) return shape if (!delta) return shape
return { ...shape, ...delta } return { ...shape, ...delta }
} }
@ -671,7 +659,7 @@ export class TLDR {
* @param origin the page point to rotate around. * @param origin the page point to rotate around.
* @param rotation the amount to rotate the shape. * @param rotation the amount to rotate the shape.
*/ */
static getRotatedShapeMutation<T extends TLDrawShape>( static getRotatedShapeMutation<T extends TDShape>(
shape: T, // in page space shape: T, // in page space
center: number[], // in page space center: number[], // in page space
origin: number[], // in page space (probably the center of common bounds) origin: number[], // in page space (probably the center of common bounds)
@ -690,7 +678,7 @@ export class TLDR {
// of rotating the shape. Shapes with handles should never be rotated, // of rotating the shape. Shapes with handles should never be rotated,
// because that makes a lot of other things incredible difficult. // because that makes a lot of other things incredible difficult.
if (shape.handles !== undefined) { if (shape.handles !== undefined) {
const change = this.getShapeUtils(shape).onHandleChange?.( const change = this.getShapeUtil(shape).onHandleChange?.(
// Base the change on a shape with the next point // Base the change on a shape with the next point
{ ...shape, point: nextPoint }, { ...shape, point: nextPoint },
Object.fromEntries( Object.fromEntries(
@ -723,7 +711,7 @@ export class TLDR {
/* Parents */ /* Parents */
/* -------------------------------------------------- */ /* -------------------------------------------------- */
static updateParents(data: TLDrawSnapshot, pageId: string, changedShapeIds: string[]): void { static updateParents(data: TDSnapshot, pageId: string, changedShapeIds: string[]): void {
const page = TLDR.getPage(data, pageId) const page = TLDR.getPage(data, pageId)
if (changedShapeIds.length === 0) return if (changedShapeIds.length === 0) return
@ -747,7 +735,7 @@ export class TLDR {
TLDR.updateParents(data, pageId, parentToUpdateIds) TLDR.updateParents(data, pageId, parentToUpdateIds)
} }
static getSelectedStyle(data: TLDrawSnapshot, pageId: string): ShapeStyles | false { static getSelectedStyle(data: TDSnapshot, pageId: string): ShapeStyles | false {
const { currentStyle } = data.appState const { currentStyle } = data.appState
const page = data.document.pages[pageId] const page = data.document.pages[pageId]
@ -788,27 +776,23 @@ export class TLDR {
/* Bindings */ /* Bindings */
/* -------------------------------------------------- */ /* -------------------------------------------------- */
static getBinding(data: TLDrawSnapshot, id: string, pageId: string): TLDrawBinding { static getBinding(data: TDSnapshot, id: string, pageId: string): TDBinding {
return TLDR.getPage(data, pageId).bindings[id] return TLDR.getPage(data, pageId).bindings[id]
} }
static getBindings(data: TLDrawSnapshot, pageId: string): TLDrawBinding[] { static getBindings(data: TDSnapshot, pageId: string): TDBinding[] {
const page = TLDR.getPage(data, pageId) const page = TLDR.getPage(data, pageId)
return Object.values(page.bindings) return Object.values(page.bindings)
} }
static getBindableShapeIds(data: TLDrawSnapshot) { static getBindableShapeIds(data: TDSnapshot) {
return TLDR.getShapes(data, data.appState.currentPageId) return TLDR.getShapes(data, data.appState.currentPageId)
.filter((shape) => TLDR.getShapeUtils(shape).canBind) .filter((shape) => TLDR.getShapeUtil(shape).canBind)
.sort((a, b) => b.childIndex - a.childIndex) .sort((a, b) => b.childIndex - a.childIndex)
.map((shape) => shape.id) .map((shape) => shape.id)
} }
static getBindingsWithShapeIds( static getBindingsWithShapeIds(data: TDSnapshot, ids: string[], pageId: string): TDBinding[] {
data: TLDrawSnapshot,
ids: string[],
pageId: string
): TLDrawBinding[] {
return Array.from( return Array.from(
new Set( new Set(
TLDR.getBindings(data, pageId).filter((binding) => { TLDR.getBindings(data, pageId).filter((binding) => {
@ -818,7 +802,7 @@ export class TLDR {
) )
} }
static getRelatedBindings(data: TLDrawSnapshot, ids: string[], pageId: string): TLDrawBinding[] { static getRelatedBindings(data: TDSnapshot, ids: string[], pageId: string): TDBinding[] {
const changedShapeIds = new Set(ids) const changedShapeIds = new Set(ids)
const page = TLDR.getPage(data, pageId) const page = TLDR.getPage(data, pageId)
@ -897,7 +881,7 @@ export class TLDR {
/* Groups */ /* Groups */
/* -------------------------------------------------- */ /* -------------------------------------------------- */
static flattenShape = (data: TLDrawSnapshot, shape: TLDrawShape): TLDrawShape[] => { static flattenShape = (data: TDSnapshot, shape: TDShape): TDShape[] => {
return [ return [
shape, shape,
...(shape.children ?? []) ...(shape.children ?? [])
@ -907,13 +891,13 @@ export class TLDR {
] ]
} }
static flattenPage = (data: TLDrawSnapshot, pageId: string): TLDrawShape[] => { static flattenPage = (data: TDSnapshot, pageId: string): TDShape[] => {
return Object.values(data.document.pages[pageId].shapes) return Object.values(data.document.pages[pageId].shapes)
.sort((a, b) => a.childIndex - b.childIndex) .sort((a, b) => a.childIndex - b.childIndex)
.reduce<TLDrawShape[]>((acc, shape) => [...acc, ...TLDR.flattenShape(data, shape)], []) .reduce<TDShape[]>((acc, shape) => [...acc, ...TLDR.flattenShape(data, shape)], [])
} }
static getTopChildIndex = (data: TLDrawSnapshot, pageId: string): number => { static getTopChildIndex = (data: TDSnapshot, pageId: string): number => {
const shapes = TLDR.getShapes(data, pageId) const shapes = TLDR.getShapes(data, pageId)
return shapes.length === 0 return shapes.length === 0
? 1 ? 1
@ -922,12 +906,23 @@ export class TLDR {
.sort((a, b) => b.childIndex - a.childIndex)[0].childIndex + 1 .sort((a, b) => b.childIndex - a.childIndex)[0].childIndex + 1
} }
/* -------------------------------------------------- */
/* Text */
/* -------------------------------------------------- */
static fixNewLines = /\r?\n|\r/g
static fixSpaces = / /g
static normalizeText(text: string) {
return text.replace(TLDR.fixNewLines, '\n').replace(TLDR.fixSpaces, '\u00a0')
}
/* -------------------------------------------------- */ /* -------------------------------------------------- */
/* Assertions */ /* Assertions */
/* -------------------------------------------------- */ /* -------------------------------------------------- */
static assertShapeHasProperty<P extends keyof TLDrawShape>( static assertShapeHasProperty<P extends keyof TDShape>(
shape: TLDrawShape, shape: TDShape,
prop: P prop: P
): asserts shape is ShapesWithProp<P> { ): asserts shape is ShapesWithProp<P> {
if (shape[prop] === undefined) { if (shape[prop] === undefined) {

View file

@ -1,653 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { TLDrawState } from './TLDrawState'
import { mockDocument, TLDrawStateUtils } from '~test'
import { ArrowShape, ColorStyle, SessionType, TLDrawShapeType } from '~types'
import type { SelectTool } from './tools/SelectTool'
describe('TLDrawState', () => {
const state = new TLDrawState()
const tlu = new TLDrawStateUtils(state)
describe('When copying and pasting...', () => {
it('copies a shape', () => {
state.loadDocument(mockDocument).selectNone().copy(['rect1'])
})
it('pastes a shape', () => {
state.loadDocument(mockDocument)
const prevCount = Object.keys(state.page.shapes).length
state.selectNone().copy(['rect1']).paste()
expect(Object.keys(state.page.shapes).length).toBe(prevCount + 1)
state.undo()
expect(Object.keys(state.page.shapes).length).toBe(prevCount)
state.redo()
expect(Object.keys(state.page.shapes).length).toBe(prevCount + 1)
})
it('pastes a shape to a new page', () => {
state.loadDocument(mockDocument)
state.selectNone().copy(['rect1']).createPage().paste()
expect(Object.keys(state.page.shapes).length).toBe(1)
state.undo()
expect(Object.keys(state.page.shapes).length).toBe(0)
state.redo()
expect(Object.keys(state.page.shapes).length).toBe(1)
})
it('Copies grouped shapes.', () => {
const state = new TLDrawState()
.loadDocument(mockDocument)
.group(['rect1', 'rect2'], 'groupA')
.select('groupA')
.copy()
const beforeShapes = state.shapes
state.paste()
expect(state.shapes.filter((shape) => shape.type === TLDrawShapeType.Group).length).toBe(2)
const afterShapes = state.shapes
const newShapes = afterShapes.filter(
(shape) => !beforeShapes.find(({ id }) => id === shape.id)
)
const newGroup = newShapes.find((shape) => shape.type === TLDrawShapeType.Group)
const newChildIds = newShapes
.filter((shape) => shape.type !== TLDrawShapeType.Group)
.map((shape) => shape.id)
expect(new Set(newGroup!.children)).toEqual(new Set(newChildIds))
})
it.todo("Pastes in to the top child index of the page's children.")
it.todo('Pastes in the correct child index order.')
})
describe('When copying and pasting a shape with bindings', () => {
it('copies two bound shapes and their binding', () => {
const state = new TLDrawState()
state
.createShapes(
{ type: TLDrawShapeType.Rectangle, id: 'target1', point: [0, 0], size: [100, 100] },
{ type: TLDrawShapeType.Arrow, id: 'arrow1', point: [200, 200] }
)
.select('arrow1')
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([55, 55])
.completeSession()
expect(state.bindings.length).toBe(1)
state.selectAll().copy().paste()
const newArrow = state.shapes.sort((a, b) => b.childIndex - a.childIndex)[0] as ArrowShape
expect(newArrow.handles.start.bindingId).not.toBe(
state.getShape<ArrowShape>('arrow1').handles.start.bindingId
)
expect(state.bindings.length).toBe(2)
})
it('removes bindings from copied shape handles', () => {
const state = new TLDrawState()
state
.createShapes(
{ type: TLDrawShapeType.Rectangle, id: 'target1', point: [0, 0], size: [100, 100] },
{ type: TLDrawShapeType.Arrow, id: 'arrow1', point: [200, 200] }
)
.select('arrow1')
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([55, 55])
.completeSession()
expect(state.bindings.length).toBe(1)
expect(state.getShape<ArrowShape>('arrow1').handles.start.bindingId).toBeDefined()
state.select('arrow1').copy().paste()
const newArrow = state.shapes.sort((a, b) => b.childIndex - a.childIndex)[0] as ArrowShape
expect(newArrow.handles.start.bindingId).toBeUndefined()
})
})
describe('Selection', () => {
it('selects a shape', () => {
state.loadDocument(mockDocument).selectNone()
tlu.clickShape('rect1')
expect(state.selectedIds).toStrictEqual(['rect1'])
expect(state.appState.status).toBe('idle')
})
it('selects and deselects a shape', () => {
state.loadDocument(mockDocument).selectNone()
tlu.clickShape('rect1')
tlu.clickCanvas()
expect(state.selectedIds).toStrictEqual([])
expect(state.appState.status).toBe('idle')
})
it('selects multiple shapes', () => {
state.loadDocument(mockDocument).selectNone()
tlu.clickShape('rect1')
tlu.clickShape('rect2', { shiftKey: true })
expect(state.selectedIds).toStrictEqual(['rect1', 'rect2'])
expect(state.appState.status).toBe('idle')
})
it('shift-selects to deselect shapes', () => {
state.loadDocument(mockDocument).selectNone()
tlu.clickShape('rect1')
tlu.clickShape('rect2', { shiftKey: true })
tlu.clickShape('rect2', { shiftKey: true })
expect(state.selectedIds).toStrictEqual(['rect1'])
expect(state.appState.status).toBe('idle')
})
it('clears selection when clicking bounds', () => {
state.loadDocument(mockDocument).selectNone()
state.startSession(SessionType.Brush, [-10, -10])
state.updateSession([110, 110])
state.completeSession()
expect(state.selectedIds.length).toBe(3)
})
it('selects selected shape when single-clicked', () => {
state.loadDocument(mockDocument).selectAll()
tlu.clickShape('rect2')
expect(state.selectedIds).toStrictEqual(['rect2'])
})
// it('selects shape when double-clicked', () => {
// state.loadDocument(mockDocument).selectAll()
// tlu.doubleClickShape('rect2')
// expect(state.selectedIds).toStrictEqual(['rect2'])
// })
it('does not select on meta-click', () => {
state.loadDocument(mockDocument).selectNone()
tlu.clickShape('rect1', { ctrlKey: true })
expect(state.selectedIds).toStrictEqual([])
expect(state.appState.status).toBe('idle')
})
it.todo('deletes shapes if cancelled during creating')
it.todo('deletes shapes on undo after creating')
it.todo('re-creates shapes on redo after creating')
describe('When selecting all', () => {
it('selects all', () => {
const state = new TLDrawState().loadDocument(mockDocument).selectAll()
expect(state.selectedIds).toMatchSnapshot('selected all')
})
it('does not select children of a group', () => {
const state = new TLDrawState().loadDocument(mockDocument).selectAll().group()
expect(state.selectedIds.length).toBe(1)
})
})
// Single click on a selected shape to select just that shape
it('single-selects shape in selection on click', () => {
state.selectNone()
tlu.clickShape('rect1')
tlu.clickShape('rect2', { shiftKey: true })
tlu.clickShape('rect2')
expect(state.selectedIds).toStrictEqual(['rect2'])
expect(state.appState.status).toBe('idle')
})
it('single-selects shape in selection on pointerup only', () => {
state.selectNone()
tlu.clickShape('rect1')
tlu.clickShape('rect2', { shiftKey: true })
tlu.pointShape('rect2')
expect(state.selectedIds).toStrictEqual(['rect1', 'rect2'])
tlu.stopPointing('rect2')
expect(state.selectedIds).toStrictEqual(['rect2'])
expect(state.appState.status).toBe('idle')
})
// it('selects shapes if shift key is lifted before pointerup', () => {
// state.selectNone()
// tlu.clickShape('rect1')
// tlu.pointShape('rect2', { shiftKey: true })
// expect(state.appState.status).toBe('pointingBounds')
// tlu.stopPointing('rect2')
// expect(state.selectedIds).toStrictEqual(['rect2'])
// expect(state.appState.status).toBe('idle')
// })
})
describe('Select history', () => {
it('selects, undoes and redoes', () => {
state.reset().loadDocument(mockDocument)
expect(state.selectHistory.pointer).toBe(0)
expect(state.selectHistory.stack).toStrictEqual([[]])
expect(state.selectedIds).toStrictEqual([])
tlu.pointShape('rect1')
expect(state.selectHistory.pointer).toBe(1)
expect(state.selectHistory.stack).toStrictEqual([[], ['rect1']])
expect(state.selectedIds).toStrictEqual(['rect1'])
tlu.stopPointing('rect1')
expect(state.selectHistory.pointer).toBe(1)
expect(state.selectHistory.stack).toStrictEqual([[], ['rect1']])
expect(state.selectedIds).toStrictEqual(['rect1'])
tlu.clickShape('rect2', { shiftKey: true })
expect(state.selectHistory.pointer).toBe(2)
expect(state.selectHistory.stack).toStrictEqual([[], ['rect1'], ['rect1', 'rect2']])
expect(state.selectedIds).toStrictEqual(['rect1', 'rect2'])
state.undoSelect()
expect(state.selectHistory.pointer).toBe(1)
expect(state.selectHistory.stack).toStrictEqual([[], ['rect1'], ['rect1', 'rect2']])
expect(state.selectedIds).toStrictEqual(['rect1'])
state.undoSelect()
expect(state.selectHistory.pointer).toBe(0)
expect(state.selectHistory.stack).toStrictEqual([[], ['rect1'], ['rect1', 'rect2']])
expect(state.selectedIds).toStrictEqual([])
state.redoSelect()
expect(state.selectHistory.pointer).toBe(1)
expect(state.selectHistory.stack).toStrictEqual([[], ['rect1'], ['rect1', 'rect2']])
expect(state.selectedIds).toStrictEqual(['rect1'])
state.select('rect2')
expect(state.selectHistory.pointer).toBe(2)
expect(state.selectHistory.stack).toStrictEqual([[], ['rect1'], ['rect2']])
expect(state.selectedIds).toStrictEqual(['rect2'])
state.delete()
expect(state.selectHistory.pointer).toBe(0)
expect(state.selectHistory.stack).toStrictEqual([[]])
expect(state.selectedIds).toStrictEqual([])
state.undoSelect()
expect(state.selectHistory.pointer).toBe(0)
expect(state.selectHistory.stack).toStrictEqual([[]])
expect(state.selectedIds).toStrictEqual([])
})
})
describe('Copies to JSON', () => {
state.selectAll()
expect(state.copyJson()).toMatchSnapshot('copied json')
})
describe('Mutates bound shapes', () => {
const state = new TLDrawState()
.createShapes(
{
id: 'rect',
point: [0, 0],
size: [100, 100],
childIndex: 1,
type: TLDrawShapeType.Rectangle,
},
{
id: 'arrow',
point: [200, 200],
childIndex: 2,
type: TLDrawShapeType.Arrow,
}
)
.select('arrow')
.startSession(SessionType.Arrow, [200, 200], 'start')
.updateSession([10, 10])
.completeSession()
.selectAll()
.style({ color: ColorStyle.Red })
expect(state.getShape('arrow').style.color).toBe(ColorStyle.Red)
expect(state.getShape('rect').style.color).toBe(ColorStyle.Red)
state.undo()
expect(state.getShape('arrow').style.color).toBe(ColorStyle.Black)
expect(state.getShape('rect').style.color).toBe(ColorStyle.Black)
state.redo()
expect(state.getShape('arrow').style.color).toBe(ColorStyle.Red)
expect(state.getShape('rect').style.color).toBe(ColorStyle.Red)
})
describe('when selecting shapes in a group', () => {
it('selects the group when a grouped shape is clicked', () => {
const state = new TLDrawState().loadDocument(mockDocument).group(['rect1', 'rect2'], 'groupA')
const tlu = new TLDrawStateUtils(state)
tlu.clickShape('rect1')
expect((state.currentTool as SelectTool).selectedGroupId).toBeUndefined()
expect(state.selectedIds).toStrictEqual(['groupA'])
})
it('selects the grouped shape when double clicked', () => {
const state = new TLDrawState().loadDocument(mockDocument).group(['rect1', 'rect2'], 'groupA')
const tlu = new TLDrawStateUtils(state)
tlu.doubleClickShape('rect1')
expect((state.currentTool as SelectTool).selectedGroupId).toStrictEqual('groupA')
expect(state.selectedIds).toStrictEqual(['rect1'])
})
it('clears the selectedGroupId when selecting a different shape', () => {
const state = new TLDrawState().loadDocument(mockDocument).group(['rect1', 'rect2'], 'groupA')
const tlu = new TLDrawStateUtils(state)
tlu.doubleClickShape('rect1')
tlu.clickShape('rect3')
expect((state.currentTool as SelectTool).selectedGroupId).toBeUndefined()
expect(state.selectedIds).toStrictEqual(['rect3'])
})
it('selects a grouped shape when meta-shift-clicked', () => {
const state = new TLDrawState()
.loadDocument(mockDocument)
.group(['rect1', 'rect2'], 'groupA')
.selectNone()
const tlu = new TLDrawStateUtils(state)
tlu.clickShape('rect1', { ctrlKey: true, shiftKey: true })
expect(state.selectedIds).toStrictEqual(['rect1'])
tlu.clickShape('rect1', { ctrlKey: true, shiftKey: true })
expect(state.selectedIds).toStrictEqual([])
})
it('selects a hovered shape from the selected group when meta-shift-clicked', () => {
const state = new TLDrawState().loadDocument(mockDocument).group(['rect1', 'rect2'], 'groupA')
const tlu = new TLDrawStateUtils(state)
tlu.clickShape('rect1', { ctrlKey: true, shiftKey: true })
expect(state.selectedIds).toStrictEqual(['rect1'])
tlu.clickShape('rect1', { ctrlKey: true, shiftKey: true })
expect(state.selectedIds).toStrictEqual([])
})
})
describe('when creating shapes', () => {
it('Creates shapes with the correct child index', () => {
const state = new TLDrawState()
.createShapes(
{
id: 'rect1',
type: TLDrawShapeType.Rectangle,
childIndex: 1,
},
{
id: 'rect2',
type: TLDrawShapeType.Rectangle,
childIndex: 2,
},
{
id: 'rect3',
type: TLDrawShapeType.Rectangle,
childIndex: 3,
}
)
.selectTool(TLDrawShapeType.Rectangle)
const tlu = new TLDrawStateUtils(state)
const prevA = state.shapes.map((shape) => shape.id)
tlu.pointCanvas({ x: 0, y: 0 })
tlu.movePointer({ x: 100, y: 100 })
tlu.stopPointing()
const newIdA = state.shapes.map((shape) => shape.id).find((id) => !prevA.includes(id))!
const shapeA = state.getShape(newIdA)
expect(shapeA.childIndex).toBe(4)
state.group(['rect2', 'rect3', newIdA], 'groupA')
expect(state.getShape('groupA').childIndex).toBe(2)
state.selectNone()
state.selectTool(TLDrawShapeType.Rectangle)
const prevB = state.shapes.map((shape) => shape.id)
tlu.pointCanvas({ x: 0, y: 0 })
tlu.movePointer({ x: 100, y: 100 })
tlu.stopPointing()
const newIdB = state.shapes.map((shape) => shape.id).find((id) => !prevB.includes(id))!
const shapeB = state.getShape(newIdB)
expect(shapeB.childIndex).toBe(3)
})
})
it('Exposes undo/redo stack', () => {
const state = new TLDrawState()
.loadDocument(mockDocument)
.createShapes({
id: 'rect1',
type: TLDrawShapeType.Rectangle,
point: [0, 0],
size: [100, 200],
})
.createShapes({
id: 'rect2',
type: TLDrawShapeType.Rectangle,
point: [0, 0],
size: [100, 200],
})
expect(state.history.length).toBe(2)
expect(state.history).toBeDefined()
expect(state.history).toMatchSnapshot('history')
state.history = []
expect(state.history).toEqual([])
const before = state.state
state.undo()
const after = state.state
expect(before).toBe(after)
})
it('Exposes undo/redo stack up to the current pointer', () => {
const state = new TLDrawState()
.loadDocument(mockDocument)
.createShapes({
id: 'rect1',
type: TLDrawShapeType.Rectangle,
point: [0, 0],
size: [100, 200],
})
.createShapes({
id: 'rect2',
type: TLDrawShapeType.Rectangle,
point: [0, 0],
size: [100, 200],
})
.undo()
expect(state.history.length).toBe(1)
})
it('Sets the undo/redo history', () => {
const state = new TLDrawState('some_state_a')
.createShapes({
id: 'rect1',
type: TLDrawShapeType.Rectangle,
point: [0, 0],
size: [100, 200],
})
.createShapes({
id: 'rect2',
type: TLDrawShapeType.Rectangle,
point: [0, 0],
size: [100, 200],
})
// Save the history and document from the first state
const doc = state.document
const history = state.history
// Create a new state
const state2 = new TLDrawState('some_state_b')
// Load the document and set the history
state2.loadDocument(doc)
state2.history = history
expect(state2.shapes.length).toBe(2)
// We should be able to undo the change that was made on the first
// state, now that we've brought in its undo / redo stack
state2.undo()
expect(state2.shapes.length).toBe(1)
})
describe('When copying to SVG', () => {
it('Copies shapes.', () => {
const state = new TLDrawState()
const result = state
.loadDocument(mockDocument)
.select('rect1')
.rotate(0.1)
.selectAll()
.copySvg()
expect(result).toMatchSnapshot('copied svg')
})
it('Copies grouped shapes.', () => {
const state = new TLDrawState()
const result = state
.loadDocument(mockDocument)
.select('rect1', 'rect2')
.group()
.selectAll()
.copySvg()
expect(result).toMatchSnapshot('copied svg with group')
})
it.todo('Copies Text shapes as <text> elements.')
// it('Copies Text shapes as <text> elements.', () => {
// const state2 = new TLDrawState()
// const svgString = state2
// .createShapes({
// id: 'text1',
// type: TLDrawShapeType.Text,
// text: 'hello world!',
// })
// .select('text1')
// .copySvg()
// expect(svgString).toBeTruthy()
// })
})
describe('when the document prop changes', () => {
it.todo('replaces the document if the ids are different')
it.todo('updates the document if the new id is the same as the old one')
})
/*
We want to be able to use the `document` property to update the
document without blowing out the current state. For example, we
may want to patch in changes that occurred from another user.
When the `document` prop changes in the TLDraw component, we want
to update the document in a way that preserves the identity of as
much as possible, while still protecting against invalid states.
If this isn't possible, then we should guide the developer to
instead use a helper like `patchDocument` to update the document.
If the `id` property of the new document is the same as the
previous document, then we call `updateDocument`. Otherwise, we
call `replaceDocument`, which does a harder reset of the state's
internal state.
*/
jest.setTimeout(10000)
describe('When changing versions', () => {
it('migrates correctly', (done) => {
const defaultState = TLDrawState.defaultState
const withoutRoom = {
...defaultState,
}
delete withoutRoom.room
TLDrawState.defaultState = withoutRoom
const state = new TLDrawState('migrate_1')
state.createShapes({
id: 'rect1',
type: TLDrawShapeType.Rectangle,
})
setTimeout(() => {
// TODO: Force the version to change and restore room.
TLDrawState.version = 100
TLDrawState.defaultState.room = defaultState.room
const state2 = new TLDrawState('migrate_1')
setTimeout(() => {
try {
expect(state2.getShape('rect1')).toBeTruthy()
done()
} catch (e) {
done(e)
}
}, 100)
}, 100)
})
})
})

View file

@ -0,0 +1,661 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { mockDocument, TldrawTestApp } from '~test'
import { ArrowShape, ColorStyle, SessionType, TDShapeType } from '~types'
import type { SelectTool } from './tools/SelectTool'
describe('TldrawTestApp', () => {
describe('When copying and pasting...', () => {
it('copies a shape', () => {
const app = new TldrawTestApp().loadDocument(mockDocument).selectNone().copy(['rect1'])
})
it('pastes a shape', () => {
const app = new TldrawTestApp().loadDocument(mockDocument)
const prevCount = Object.keys(app.page.shapes).length
app.selectNone().copy(['rect1']).paste()
expect(Object.keys(app.page.shapes).length).toBe(prevCount + 1)
app.undo()
expect(Object.keys(app.page.shapes).length).toBe(prevCount)
app.redo()
expect(Object.keys(app.page.shapes).length).toBe(prevCount + 1)
})
it('pastes a shape to a new page', () => {
const app = new TldrawTestApp().loadDocument(mockDocument)
app.selectNone().copy(['rect1']).createPage().paste()
expect(Object.keys(app.page.shapes).length).toBe(1)
app.undo()
expect(Object.keys(app.page.shapes).length).toBe(0)
app.redo()
expect(Object.keys(app.page.shapes).length).toBe(1)
})
it('Copies grouped shapes.', () => {
const app = new TldrawTestApp()
.loadDocument(mockDocument)
.group(['rect1', 'rect2'], 'groupA')
.select('groupA')
.copy()
const beforeShapes = app.shapes
app.paste()
expect(app.shapes.filter((shape) => shape.type === TDShapeType.Group).length).toBe(2)
const afterShapes = app.shapes
const newShapes = afterShapes.filter(
(shape) => !beforeShapes.find(({ id }) => id === shape.id)
)
const newGroup = newShapes.find((shape) => shape.type === TDShapeType.Group)
const newChildIds = newShapes
.filter((shape) => shape.type !== TDShapeType.Group)
.map((shape) => shape.id)
expect(new Set(newGroup!.children)).toEqual(new Set(newChildIds))
})
it.todo("Pastes in to the top child index of the page's children.")
it.todo('Pastes in the correct child index order.')
})
describe('When copying and pasting a shape with bindings', () => {
it('copies two bound shapes and their binding', () => {
const app = new TldrawTestApp()
app
.createShapes(
{ type: TDShapeType.Rectangle, id: 'target1', point: [0, 0], size: [100, 100] },
{ type: TDShapeType.Arrow, id: 'arrow1', point: [200, 200] }
)
.select('arrow1')
.movePointer([200, 200])
.startSession(SessionType.Arrow, 'arrow1', 'start')
.movePointer([55, 55])
.completeSession()
expect(app.bindings.length).toBe(1)
app.selectAll().copy().paste()
const newArrow = app.shapes.sort((a, b) => b.childIndex - a.childIndex)[0] as ArrowShape
expect(newArrow.handles.start.bindingId).not.toBe(
app.getShape<ArrowShape>('arrow1').handles.start.bindingId
)
expect(app.bindings.length).toBe(2)
})
it('removes bindings from copied shape handles', () => {
const app = new TldrawTestApp()
app
.createShapes(
{ type: TDShapeType.Rectangle, id: 'target1', point: [0, 0], size: [100, 100] },
{ type: TDShapeType.Arrow, id: 'arrow1', point: [200, 200] }
)
.select('arrow1')
.movePointer([200, 200])
.startSession(SessionType.Arrow, 'arrow1', 'start')
.movePointer([55, 55])
.completeSession()
expect(app.bindings.length).toBe(1)
expect(app.getShape<ArrowShape>('arrow1').handles.start.bindingId).toBeDefined()
app.select('arrow1').copy().paste()
const newArrow = app.shapes.sort((a, b) => b.childIndex - a.childIndex)[0] as ArrowShape
expect(newArrow.handles.start.bindingId).toBeUndefined()
})
})
describe('Selection', () => {
it('selects a shape', () => {
const app = new TldrawTestApp().loadDocument(mockDocument).selectNone().clickShape('rect1')
expect(app.selectedIds).toStrictEqual(['rect1'])
expect(app.status).toBe('idle')
})
it('selects and deselects a shape', () => {
const app = new TldrawTestApp()
.loadDocument(mockDocument)
.selectNone()
.clickShape('rect1')
.clickCanvas()
expect(app.selectedIds).toStrictEqual([])
expect(app.status).toBe('idle')
})
it('selects multiple shapes', () => {
const app = new TldrawTestApp()
.loadDocument(mockDocument)
.selectNone()
.clickShape('rect1')
.clickShape('rect2', { shiftKey: true })
expect(app.selectedIds).toStrictEqual(['rect1', 'rect2'])
expect(app.status).toBe('idle')
})
it('shift-selects to deselect shapes', () => {
const app = new TldrawTestApp()
.loadDocument(mockDocument)
.selectNone()
.clickShape('rect1')
.clickShape('rect2', { shiftKey: true })
.clickShape('rect2', { shiftKey: true })
expect(app.selectedIds).toStrictEqual(['rect1'])
expect(app.status).toBe('idle')
})
it('clears selection when clicking bounds', () => {
const app = new TldrawTestApp()
.loadDocument(mockDocument)
.selectAll()
.clickBounds()
.completeSession()
expect(app.selectedIds.length).toBe(0)
})
it('selects selected shape when single-clicked', () => {
new TldrawTestApp()
.loadDocument(mockDocument)
.selectAll()
.expectSelectedIdsToBe(['rect1', 'rect2', 'rect3'])
.pointShape('rect1')
.pointBounds() // because it is selected, argh
.stopPointing('rect1')
.expectSelectedIdsToBe(['rect1'])
})
// it('selects shape when double-clicked', () => {
// app.loadDocument(mockDocument).selectAll()
// .doubleClickShape('rect2')
// expect(app.selectedIds).toStrictEqual(['rect2'])
// })
it('does not select on meta-click', () => {
const app = new TldrawTestApp()
.loadDocument(mockDocument)
.selectNone()
.clickShape('rect1', { ctrlKey: true })
.expectSelectedIdsToBe([])
expect(app.status).toBe('idle')
})
it.todo('deletes shapes if cancelled during creating')
it.todo('deletes shapes on undo after creating')
it.todo('re-creates shapes on redo after creating')
describe('When selecting all', () => {
it('selects all', () => {
const app = new TldrawTestApp().loadDocument(mockDocument).selectAll()
expect(app.selectedIds).toMatchSnapshot('selected all')
})
it('does not select children of a group', () => {
const app = new TldrawTestApp().loadDocument(mockDocument).selectAll().group()
expect(app.selectedIds.length).toBe(1)
})
})
// Single click on a selected shape to select just that shape
it('single-selects shape in selection on click', () => {
const app = new TldrawTestApp()
.loadDocument(mockDocument)
.clickShape('rect1')
.clickShape('rect2', { shiftKey: true })
.clickShape('rect2')
expect(app.selectedIds).toStrictEqual(['rect2'])
expect(app.status).toBe('idle')
})
it('single-selects shape in selection on pointerup only', () => {
const app = new TldrawTestApp()
.loadDocument(mockDocument)
.clickShape('rect1')
.clickShape('rect2', { shiftKey: true })
.pointShape('rect2')
expect(app.selectedIds).toStrictEqual(['rect1', 'rect2'])
app.stopPointing('rect2')
expect(app.selectedIds).toStrictEqual(['rect2'])
expect(app.status).toBe('idle')
})
// it('selects shapes if shift key is lifted before pointerup', () => {
// app.selectNone()
// .clickShape('rect1')
// .pointShape('rect2', { shiftKey: true })
// expect(app.status).toBe('pointingBounds')
// .stopPointing('rect2')
// expect(app.selectedIds).toStrictEqual(['rect2'])
// expect(app.status).toBe('idle')
// })
})
describe('Select history', () => {
it('selects, undoes and redoes', () => {
const app = new TldrawTestApp().loadDocument(mockDocument)
expect(app.selectHistory.pointer).toBe(0)
expect(app.selectHistory.stack).toStrictEqual([[]])
expect(app.selectedIds).toStrictEqual([])
app.pointShape('rect1')
expect(app.selectHistory.pointer).toBe(1)
expect(app.selectHistory.stack).toStrictEqual([[], ['rect1']])
expect(app.selectedIds).toStrictEqual(['rect1'])
app.stopPointing('rect1')
expect(app.selectHistory.pointer).toBe(1)
expect(app.selectHistory.stack).toStrictEqual([[], ['rect1']])
expect(app.selectedIds).toStrictEqual(['rect1'])
app.clickShape('rect2', { shiftKey: true })
expect(app.selectHistory.pointer).toBe(2)
expect(app.selectHistory.stack).toStrictEqual([[], ['rect1'], ['rect1', 'rect2']])
expect(app.selectedIds).toStrictEqual(['rect1', 'rect2'])
app.undoSelect()
expect(app.selectHistory.pointer).toBe(1)
expect(app.selectHistory.stack).toStrictEqual([[], ['rect1'], ['rect1', 'rect2']])
expect(app.selectedIds).toStrictEqual(['rect1'])
app.undoSelect()
expect(app.selectHistory.pointer).toBe(0)
expect(app.selectHistory.stack).toStrictEqual([[], ['rect1'], ['rect1', 'rect2']])
expect(app.selectedIds).toStrictEqual([])
app.redoSelect()
expect(app.selectHistory.pointer).toBe(1)
expect(app.selectHistory.stack).toStrictEqual([[], ['rect1'], ['rect1', 'rect2']])
expect(app.selectedIds).toStrictEqual(['rect1'])
app.select('rect2')
expect(app.selectHistory.pointer).toBe(2)
expect(app.selectHistory.stack).toStrictEqual([[], ['rect1'], ['rect2']])
expect(app.selectedIds).toStrictEqual(['rect2'])
app.delete()
expect(app.selectHistory.pointer).toBe(0)
expect(app.selectHistory.stack).toStrictEqual([[]])
expect(app.selectedIds).toStrictEqual([])
app.undoSelect()
expect(app.selectHistory.pointer).toBe(0)
expect(app.selectHistory.stack).toStrictEqual([[]])
expect(app.selectedIds).toStrictEqual([])
})
})
describe('Copies to JSON', () => {
const app = new TldrawTestApp().loadDocument(mockDocument).selectAll()
expect(app.copyJson()).toMatchSnapshot('copied json')
})
describe('Mutates bound shapes', () => {
const app = new TldrawTestApp()
.createShapes(
{
id: 'rect',
point: [0, 0],
size: [100, 100],
childIndex: 1,
type: TDShapeType.Rectangle,
},
{
id: 'arrow',
point: [200, 200],
childIndex: 2,
type: TDShapeType.Arrow,
}
)
.select('arrow')
.movePointer([200, 200])
.startSession(SessionType.Arrow, 'arrow', 'start')
.movePointer([10, 10])
.completeSession()
.selectAll()
.style({ color: ColorStyle.Red })
expect(app.getShape('arrow').style.color).toBe(ColorStyle.Red)
expect(app.getShape('rect').style.color).toBe(ColorStyle.Red)
app.undo()
expect(app.getShape('arrow').style.color).toBe(ColorStyle.Black)
expect(app.getShape('rect').style.color).toBe(ColorStyle.Black)
app.redo()
expect(app.getShape('arrow').style.color).toBe(ColorStyle.Red)
expect(app.getShape('rect').style.color).toBe(ColorStyle.Red)
})
describe('when selecting shapes in a group', () => {
it('selects the group when a grouped shape is clicked', () => {
const app = new TldrawTestApp()
.loadDocument(mockDocument)
.group(['rect1', 'rect2'], 'groupA')
.clickShape('rect1')
expect((app.currentTool as SelectTool).selectedGroupId).toBeUndefined()
expect(app.selectedIds).toStrictEqual(['groupA'])
})
it('selects the grouped shape when double clicked', () => {
const app = new TldrawTestApp()
.loadDocument(mockDocument)
.group(['rect1', 'rect2'], 'groupA')
.doubleClickShape('rect1')
expect((app.currentTool as SelectTool).selectedGroupId).toStrictEqual('groupA')
expect(app.selectedIds).toStrictEqual(['rect1'])
})
it('clears the selectedGroupId when selecting a different shape', () => {
const app = new TldrawTestApp()
.loadDocument(mockDocument)
.group(['rect1', 'rect2'], 'groupA')
.doubleClickShape('rect1')
.clickShape('rect3')
expect((app.currentTool as SelectTool).selectedGroupId).toBeUndefined()
expect(app.selectedIds).toStrictEqual(['rect3'])
})
it('selects a grouped shape when meta-shift-clicked', () => {
const app = new TldrawTestApp()
.loadDocument(mockDocument)
.group(['rect1', 'rect2'], 'groupA')
.selectNone()
.clickShape('rect1', { ctrlKey: true, shiftKey: true })
expect(app.selectedIds).toStrictEqual(['rect1'])
app.clickShape('rect1', { ctrlKey: true, shiftKey: true })
expect(app.selectedIds).toStrictEqual([])
})
it('selects a hovered shape from the selected group when meta-shift-clicked', () => {
const app = new TldrawTestApp()
.loadDocument(mockDocument)
.group(['rect1', 'rect2'], 'groupA')
.clickShape('rect1', { ctrlKey: true, shiftKey: true })
expect(app.selectedIds).toStrictEqual(['rect1'])
app.clickShape('rect1', { ctrlKey: true, shiftKey: true })
expect(app.selectedIds).toStrictEqual([])
})
})
describe('when creating shapes', () => {
it('Creates shapes with the correct child index', () => {
const app = new TldrawTestApp()
.createShapes(
{
id: 'rect1',
type: TDShapeType.Rectangle,
childIndex: 1,
},
{
id: 'rect2',
type: TDShapeType.Rectangle,
childIndex: 2,
},
{
id: 'rect3',
type: TDShapeType.Rectangle,
childIndex: 3,
}
)
.selectTool(TDShapeType.Rectangle)
const prevA = app.shapes.map((shape) => shape.id)
app.pointCanvas({ x: 0, y: 0 }).movePointer({ x: 100, y: 100 }).stopPointing()
const newIdA = app.shapes.map((shape) => shape.id).find((id) => !prevA.includes(id))!
const shapeA = app.getShape(newIdA)
expect(shapeA.childIndex).toBe(4)
app.group(['rect2', 'rect3', newIdA], 'groupA')
expect(app.getShape('groupA').childIndex).toBe(2)
app.selectNone()
app.selectTool(TDShapeType.Rectangle)
const prevB = app.shapes.map((shape) => shape.id)
app.pointCanvas({ x: 0, y: 0 }).movePointer({ x: 100, y: 100 }).stopPointing()
const newIdB = app.shapes.map((shape) => shape.id).find((id) => !prevB.includes(id))!
const shapeB = app.getShape(newIdB)
expect(shapeB.childIndex).toBe(3)
})
})
it('Exposes undo/redo stack', () => {
const app = new TldrawTestApp()
.loadDocument(mockDocument)
.createShapes({
id: 'rect1',
type: TDShapeType.Rectangle,
point: [0, 0],
size: [100, 200],
})
.createShapes({
id: 'rect2',
type: TDShapeType.Rectangle,
point: [0, 0],
size: [100, 200],
})
expect(app.history.length).toBe(2)
expect(app.history).toBeDefined()
expect(app.history).toMatchSnapshot('history')
app.history = []
expect(app.history).toEqual([])
const before = app.state
app.undo()
const after = app.state
expect(before).toBe(after)
})
it('Exposes undo/redo stack up to the current pointer', () => {
const app = new TldrawTestApp()
.loadDocument(mockDocument)
.createShapes({
id: 'rect1',
type: TDShapeType.Rectangle,
point: [0, 0],
size: [100, 200],
})
.createShapes({
id: 'rect2',
type: TDShapeType.Rectangle,
point: [0, 0],
size: [100, 200],
})
.undo()
expect(app.history.length).toBe(1)
})
it('Sets the undo/redo history', () => {
const app = new TldrawTestApp('some_state_a')
.createShapes({
id: 'rect1',
type: TDShapeType.Rectangle,
point: [0, 0],
size: [100, 200],
})
.createShapes({
id: 'rect2',
type: TDShapeType.Rectangle,
point: [0, 0],
size: [100, 200],
})
// Save the history and document from the first state
const doc = app.document
const history = app.history
// Create a new state
const state2 = new TldrawTestApp('some_state_b')
// Load the document and set the history
state2.loadDocument(doc)
state2.history = history
expect(state2.shapes.length).toBe(2)
// We should be able to undo the change that was made on the first
// state, now that we've brought in its undo / redo stack
state2.undo()
expect(state2.shapes.length).toBe(1)
})
describe('When copying to SVG', () => {
it('Copies shapes.', () => {
const result = new TldrawTestApp()
.loadDocument(mockDocument)
.select('rect1')
.rotate(0.1)
.selectAll()
.copySvg()
expect(result).toMatchSnapshot('copied svg')
})
it('Copies grouped shapes.', () => {
const result = new TldrawTestApp()
.loadDocument(mockDocument)
.select('rect1', 'rect2')
.group()
.selectAll()
.copySvg()
expect(result).toMatchSnapshot('copied svg with group')
})
it.todo('Copies Text shapes as <text> elements.')
// it('Copies Text shapes as <text> elements.', () => {
// const state2 = new TldrawTestApp()
// const svgString = state2
// .createShapes({
// id: 'text1',
// type: TDShapeType.Text,
// text: 'hello world!',
// })
// .select('text1')
// .copySvg()
// expect(svgString).toBeTruthy()
// })
})
describe('when the document prop changes', () => {
it.todo('replaces the document if the ids are different')
it.todo('updates the document if the new id is the same as the old one')
})
/*
We want to be able to use the `document` property to update the
document without blowing out the current app. For example, we
may want to patch in changes that occurred from another user.
When the `document` prop changes in the Tldraw component, we want
to update the document in a way that preserves the identity of as
much as possible, while still protecting against invalid states.
If this isn't possible, then we should guide the developer to
instead use a helper like `patchDocument` to update the document.
If the `id` property of the new document is the same as the
previous document, then we call `updateDocument`. Otherwise, we
call `replaceDocument`, which does a harder reset of the state's
internal app.
*/
jest.setTimeout(10000)
describe('When changing versions', () => {
it('migrates correctly', (done) => {
const defaultState = TldrawTestApp.defaultState
const withoutRoom = {
...defaultState,
}
delete withoutRoom.room
TldrawTestApp.defaultState = withoutRoom
const app = new TldrawTestApp('migrate_1')
app.createShapes({
id: 'rect1',
type: TDShapeType.Rectangle,
})
setTimeout(() => {
// TODO: Force the version to change and restore room.
TldrawTestApp.version = 100
TldrawTestApp.defaultState.room = defaultState.room
const app2 = new TldrawTestApp('migrate_1')
setTimeout(() => {
try {
expect(app2.getShape('rect1')).toBeTruthy()
done()
} catch (e) {
done(e)
}
}, 100)
}, 100)
})
})
})

View file

@ -1,8 +1,71 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[` 1`] = `"[]"`; exports[` 1`] = `
"[
{
\\"id\\": \\"rect1\\",
\\"parentId\\": \\"page1\\",
\\"name\\": \\"Rectangle\\",
\\"childIndex\\": 1,
\\"type\\": \\"rectangle\\",
\\"point\\": [
0,
0
],
\\"size\\": [
100,
100
],
\\"style\\": {
\\"dash\\": \\"draw\\",
\\"size\\": \\"medium\\",
\\"color\\": \\"blue\\"
}
},
{
\\"id\\": \\"rect2\\",
\\"parentId\\": \\"page1\\",
\\"name\\": \\"Rectangle\\",
\\"childIndex\\": 2,
\\"type\\": \\"rectangle\\",
\\"point\\": [
100,
100
],
\\"size\\": [
100,
100
],
\\"style\\": {
\\"dash\\": \\"draw\\",
\\"size\\": \\"medium\\",
\\"color\\": \\"blue\\"
}
},
{
\\"id\\": \\"rect3\\",
\\"parentId\\": \\"page1\\",
\\"name\\": \\"Rectangle\\",
\\"childIndex\\": 3,
\\"type\\": \\"rectangle\\",
\\"point\\": [
20,
20
],
\\"size\\": [
100,
100
],
\\"style\\": {
\\"dash\\": \\"draw\\",
\\"size\\": \\"medium\\",
\\"color\\": \\"blue\\"
}
}
]"
`;
exports[`TLDrawState Exposes undo/redo stack: history 1`] = ` exports[`TldrawTestApp Exposes undo/redo stack: history 1`] = `
Array [ Array [
Object { Object {
"after": Object { "after": Object {
@ -36,6 +99,7 @@ Array [
"color": "black", "color": "black",
"dash": "draw", "dash": "draw",
"isFilled": false, "isFilled": false,
"scale": 1,
"size": "small", "size": "small",
}, },
"type": "rectangle", "type": "rectangle",
@ -96,6 +160,7 @@ Array [
"color": "black", "color": "black",
"dash": "draw", "dash": "draw",
"isFilled": false, "isFilled": false,
"scale": 1,
"size": "small", "size": "small",
}, },
"type": "rectangle", "type": "rectangle",
@ -129,7 +194,7 @@ Array [
] ]
`; `;
exports[`TLDrawState Selection When selecting all selects all: selected all 1`] = ` exports[`TldrawTestApp Selection When selecting all selects all: selected all 1`] = `
Array [ Array [
"rect1", "rect1",
"rect2", "rect2",
@ -137,6 +202,6 @@ Array [
] ]
`; `;
exports[`TLDrawState When copying to SVG Copies grouped shapes.: copied svg with group 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"-16 -16 232 232\\" width=\\"200\\" height=\\"200\\"><g/></svg>"`; exports[`TldrawTestApp When copying to SVG Copies grouped shapes.: copied svg with group 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"-16 -16 232 232\\" width=\\"200\\" height=\\"200\\"><g/></svg>"`;
exports[`TLDrawState When copying to SVG Copies shapes.: copied svg 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"-20.741879096242684 -20.741879096242684 236.74 236.74\\" width=\\"204.74\\" height=\\"204.74\\"/>"`; exports[`TldrawTestApp When copying to SVG Copies shapes.: copied svg 1`] = `"<svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"-20.741879096242684 -20.741879096242684 236.74 236.74\\" width=\\"204.74\\" height=\\"204.74\\"/>"`;

View file

@ -1,17 +1,16 @@
import Vec from '@tldraw/vec' import Vec from '@tldraw/vec'
import { TLDrawState } from '~state' import { mockDocument, TldrawTestApp } from '~test'
import { mockDocument, TLDrawStateUtils } from '~test' import { AlignType, TDShapeType } from '~types'
import { AlignType, TLDrawShapeType } from '~types'
describe('Align command', () => { describe('Align command', () => {
const state = new TLDrawState() const app = new TldrawTestApp()
describe('when less than two shapes are selected', () => { describe('when less than two shapes are selected', () => {
it('does nothing', () => { it('does nothing', () => {
state.loadDocument(mockDocument).select('rect2') app.loadDocument(mockDocument).select('rect2')
const initialState = state.state const initialState = app.state
state.align(AlignType.Top) app.align(AlignType.Top)
const currentState = state.state const currentState = app.state
expect(currentState).toEqual(initialState) expect(currentState).toEqual(initialState)
}) })
@ -19,86 +18,89 @@ describe('Align command', () => {
describe('when multiple shapes are selected', () => { describe('when multiple shapes are selected', () => {
beforeEach(() => { beforeEach(() => {
state.loadDocument(mockDocument) app.loadDocument(mockDocument)
state.selectAll() app.selectAll()
}) })
it('does, undoes and redoes command', () => { it('does, undoes and redoes command', () => {
state.align(AlignType.Top) app.align(AlignType.Top)
expect(state.getShape('rect2').point).toEqual([100, 0]) expect(app.getShape('rect2').point).toEqual([100, 0])
state.undo() app.undo()
expect(state.getShape('rect2').point).toEqual([100, 100]) expect(app.getShape('rect2').point).toEqual([100, 100])
state.redo() app.redo()
expect(state.getShape('rect2').point).toEqual([100, 0]) expect(app.getShape('rect2').point).toEqual([100, 0])
}) })
it('aligns top', () => { it('aligns top', () => {
state.align(AlignType.Top) app.align(AlignType.Top)
expect(state.getShape('rect2').point).toEqual([100, 0]) expect(app.getShape('rect2').point).toEqual([100, 0])
}) })
it('aligns right', () => { it('aligns right', () => {
state.align(AlignType.Right) app.align(AlignType.Right)
expect(state.getShape('rect1').point).toEqual([100, 0]) expect(app.getShape('rect1').point).toEqual([100, 0])
}) })
it('aligns bottom', () => { it('aligns bottom', () => {
state.align(AlignType.Bottom) app.align(AlignType.Bottom)
expect(state.getShape('rect1').point).toEqual([0, 100]) expect(app.getShape('rect1').point).toEqual([0, 100])
}) })
it('aligns left', () => { it('aligns left', () => {
state.align(AlignType.Left) app.align(AlignType.Left)
expect(state.getShape('rect2').point).toEqual([0, 100]) expect(app.getShape('rect2').point).toEqual([0, 100])
}) })
it('aligns center horizontal', () => { it('aligns center horizontal', () => {
state.align(AlignType.CenterHorizontal) app.align(AlignType.CenterHorizontal)
expect(state.getShape('rect1').point).toEqual([50, 0]) expect(app.getShape('rect1').point).toEqual([50, 0])
expect(state.getShape('rect2').point).toEqual([50, 100]) expect(app.getShape('rect2').point).toEqual([50, 100])
}) })
it('aligns center vertical', () => { it('aligns center vertical', () => {
state.align(AlignType.CenterVertical) app.align(AlignType.CenterVertical)
expect(state.getShape('rect1').point).toEqual([0, 50]) expect(app.getShape('rect1').point).toEqual([0, 50])
expect(state.getShape('rect2').point).toEqual([100, 50]) expect(app.getShape('rect2').point).toEqual([100, 50])
}) })
}) })
}) })
describe('when aligning groups', () => { describe('when aligning groups', () => {
it('aligns children', () => { it('aligns children', () => {
const state = new TLDrawState() const app = new TldrawTestApp()
.createShapes( .createShapes(
{ id: 'rect1', type: TLDrawShapeType.Rectangle, point: [0, 0], size: [100, 100] }, { id: 'rect1', type: TDShapeType.Rectangle, point: [0, 0], size: [100, 100] },
{ id: 'rect2', type: TLDrawShapeType.Rectangle, point: [100, 100], size: [100, 100] }, { id: 'rect2', type: TDShapeType.Rectangle, point: [100, 100], size: [100, 100] },
{ id: 'rect3', type: TLDrawShapeType.Rectangle, point: [200, 200], size: [100, 100] }, { id: 'rect3', type: TDShapeType.Rectangle, point: [200, 200], size: [100, 100] },
{ id: 'rect4', type: TLDrawShapeType.Rectangle, point: [0, 0], size: [200, 200] } { id: 'rect4', type: TDShapeType.Rectangle, point: [0, 0], size: [200, 200] }
) )
.group(['rect1', 'rect2'], 'groupA') .group(['rect1', 'rect2'], 'groupA')
.select('rect3', 'rect4') .select('rect3', 'rect4')
.align(AlignType.CenterVertical) .align(AlignType.CenterVertical)
const p0 = state.getShape('rect4').point const p0 = app.getShape('rect4').point
const p1 = state.getShape('rect3').point const p1 = app.getShape('rect3').point
state.undo().delete(['rect4']).selectAll().align(AlignType.CenterVertical) app
.undo()
new TLDrawStateUtils(state).expectShapesToBeAtPoints({ .delete(['rect4'])
rect1: p0, .selectAll()
rect2: Vec.add(p0, [100, 100]), .align(AlignType.CenterVertical)
rect3: p1, .expectShapesToBeAtPoints({
}) rect1: p0,
rect2: Vec.add(p0, [100, 100]),
rect3: p1,
})
}) })
}) })

View file

@ -1,27 +1,27 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Utils } from '@tldraw/core' import { Utils } from '@tldraw/core'
import { AlignType, TLDrawCommand, TLDrawShapeType } from '~types' import { AlignType, TldrawCommand, TDShapeType } from '~types'
import type { TLDrawSnapshot } from '~types' import type { TDSnapshot } from '~types'
import { TLDR } from '~state/TLDR' import { TLDR } from '~state/TLDR'
import Vec from '@tldraw/vec' import Vec from '@tldraw/vec'
import type { TldrawApp } from '../../internal'
export function alignShapes(data: TLDrawSnapshot, ids: string[], type: AlignType): TLDrawCommand { export function alignShapes(app: TldrawApp, ids: string[], type: AlignType): TldrawCommand {
const { currentPageId } = data.appState const { currentPageId } = app
const initialShapes = ids.map((id) => TLDR.getShape(data, id, currentPageId)) const initialShapes = ids.map((id) => app.getShape(id))
const boundsForShapes = initialShapes.map((shape) => { const boundsForShapes = initialShapes.map((shape) => {
return { return {
id: shape.id, id: shape.id,
point: [...shape.point], point: [...shape.point],
bounds: TLDR.getShapeUtils(shape).getBounds(shape), bounds: TLDR.getBounds(shape),
} }
}) })
const commonBounds = Utils.getCommonBounds(boundsForShapes.map(({ bounds }) => bounds)) const commonBounds = Utils.getCommonBounds(boundsForShapes.map(({ bounds }) => bounds))
const midX = commonBounds.minX + commonBounds.width / 2 const midX = commonBounds.minX + commonBounds.width / 2
const midY = commonBounds.minY + commonBounds.height / 2 const midY = commonBounds.minY + commonBounds.height / 2
const deltaMap = Object.fromEntries( const deltaMap = Object.fromEntries(
@ -44,7 +44,7 @@ export function alignShapes(data: TLDrawSnapshot, ids: string[], type: AlignType
) )
const { before, after } = TLDR.mutateShapes( const { before, after } = TLDR.mutateShapes(
data, app.state,
ids, ids,
(shape) => { (shape) => {
if (!deltaMap[shape.id]) return shape if (!deltaMap[shape.id]) return shape
@ -54,11 +54,11 @@ export function alignShapes(data: TLDrawSnapshot, ids: string[], type: AlignType
) )
initialShapes.forEach((shape) => { initialShapes.forEach((shape) => {
if (shape.type === TLDrawShapeType.Group) { if (shape.type === TDShapeType.Group) {
const delta = Vec.sub(after[shape.id].point!, before[shape.id].point!) const delta = Vec.sub(after[shape.id].point!, before[shape.id].point!)
shape.children.forEach((id) => { shape.children.forEach((id) => {
const child = TLDR.getShape(data, id, currentPageId) const child = app.getShape(id)
before[child.id] = { point: child.point } before[child.id] = { point: child.point }
after[child.id] = { point: Vec.add(child.point, delta) } after[child.id] = { point: Vec.add(child.point, delta) }
}) })

View file

@ -1,32 +1,31 @@
import { TLDrawState } from '~state' import { mockDocument, TldrawTestApp } from '~test'
import { mockDocument } from '~test'
describe('Change page command', () => { describe('Change page command', () => {
const state = new TLDrawState() const app = new TldrawTestApp()
it('does, undoes and redoes command', () => { it('does, undoes and redoes command', () => {
state.loadDocument(mockDocument) app.loadDocument(mockDocument)
const initialId = state.page.id const initialId = app.page.id
state.createPage() app.createPage()
const nextId = state.page.id const nextId = app.page.id
state.changePage(initialId) app.changePage(initialId)
expect(state.page.id).toBe(initialId) expect(app.page.id).toBe(initialId)
state.changePage(nextId) app.changePage(nextId)
expect(state.page.id).toBe(nextId) expect(app.page.id).toBe(nextId)
state.undo() app.undo()
expect(state.page.id).toBe(initialId) expect(app.page.id).toBe(initialId)
state.redo() app.redo()
expect(state.page.id).toBe(nextId) expect(app.page.id).toBe(nextId)
}) })
}) })

View file

@ -1,11 +1,12 @@
import type { TLDrawSnapshot, TLDrawCommand } from '~types' import type { TldrawCommand } from '~types'
import type { TldrawApp } from '../../internal'
export function changePage(data: TLDrawSnapshot, pageId: string): TLDrawCommand { export function changePage(app: TldrawApp, pageId: string): TldrawCommand {
return { return {
id: 'change_page', id: 'change_page',
before: { before: {
appState: { appState: {
currentPageId: data.appState.currentPageId, currentPageId: app.currentPageId,
}, },
}, },
after: { after: {

View file

@ -1,34 +1,33 @@
import { TLDrawState } from '~state' import { mockDocument, TldrawTestApp } from '~test'
import { mockDocument } from '~test'
describe('Create page command', () => { describe('Create page command', () => {
const state = new TLDrawState() const app = new TldrawTestApp()
it('does, undoes and redoes command', () => { it('does, undoes and redoes command', () => {
state.loadDocument(mockDocument) app.loadDocument(mockDocument)
const initialId = state.page.id const initialId = app.page.id
const initialPageState = state.pageState const initialPageState = app.pageState
state.createPage() app.createPage()
const nextId = state.page.id const nextId = app.page.id
const nextPageState = state.pageState const nextPageState = app.pageState
expect(Object.keys(state.document.pages).length).toBe(2) expect(Object.keys(app.document.pages).length).toBe(2)
expect(state.page.id).toBe(nextId) expect(app.page.id).toBe(nextId)
expect(state.pageState).toEqual(nextPageState) expect(app.pageState).toEqual(nextPageState)
state.undo() app.undo()
expect(Object.keys(state.document.pages).length).toBe(1) expect(Object.keys(app.document.pages).length).toBe(1)
expect(state.page.id).toBe(initialId) expect(app.page.id).toBe(initialId)
expect(state.pageState).toEqual(initialPageState) expect(app.pageState).toEqual(initialPageState)
state.redo() app.redo()
expect(Object.keys(state.document.pages).length).toBe(2) expect(Object.keys(app.document.pages).length).toBe(2)
expect(state.page.id).toBe(nextId) expect(app.page.id).toBe(nextId)
expect(state.pageState).toEqual(nextPageState) expect(app.pageState).toEqual(nextPageState)
}) })
}) })

View file

@ -1,14 +1,15 @@
import type { TLDrawSnapshot, TLDrawCommand, TLDrawPage } from '~types' import type { TldrawCommand, TDPage } from '~types'
import { Utils, TLPageState } from '@tldraw/core' import { Utils, TLPageState } from '@tldraw/core'
import type { TldrawApp } from '~state'
export function createPage( export function createPage(
data: TLDrawSnapshot, app: TldrawApp,
center: number[], center: number[],
pageId = Utils.uniqueId() pageId = Utils.uniqueId()
): TLDrawCommand { ): TldrawCommand {
const { currentPageId } = data.appState const { currentPageId } = app
const topPage = Object.values(data.document.pages).sort( const topPage = Object.values(app.state.document.pages).sort(
(a, b) => (b.childIndex || 0) - (a.childIndex || 0) (a, b) => (b.childIndex || 0) - (a.childIndex || 0)
)[0] )[0]
@ -17,7 +18,7 @@ export function createPage(
// TODO: Iterate the name better // TODO: Iterate the name better
const nextName = `New Page` const nextName = `New Page`
const page: TLDrawPage = { const page: TDPage = {
id: pageId, id: pageId,
name: nextName, name: nextName,
childIndex: nextChildIndex, childIndex: nextChildIndex,

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