* 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:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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/sw.js
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).
@ -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:.
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
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).
🙌 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
@ -24,13 +24,13 @@ npm i @tldraw/tldraw
## Usage
Import the `TLDraw` React component and use it in your app.
Import the `tldraw` React component and use it in your app.
```tsx
import { TLDraw } from '@tldraw/tldraw'
import { Tldraw } from '@tldraw/tldraw'
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.
```tsx
import { TLDraw } from '@tldraw/tldraw'
import { Tldraw } from '@tldraw/tldraw'
function App() {
return <TLDraw id="myState" />
return <Tldraw id="myState" />
}
```
### Controlling the Component through Props
You can control the `TLDraw` component through its props.
You can control the `tldraw` component through its props.
```tsx
import { TLDraw, TLDrawDocument } from '@tldraw/tldraw'
import { Tldraw, TDDocument } from '@tldraw/tldraw'
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
import { TLDraw, TLDrawState } from '@tldraw/tldraw'
import { Tldraw, tldrawApp } from '@tldraw/tldraw'
function App() {
const handleMount = React.useCallback((state: TLDrawState) => {
const handleMount = React.useCallback((state: tldrawApp) => {
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
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
import { TLDraw, TLDrawState } from '@tldraw/tldraw'
import { Tldraw, tldrawApp } from '@tldraw/tldraw'
function App() {
const handleChange = React.useCallback((state: TLDrawState, reason: string) => {
const handleChange = React.useCallback((state: tldrawApp, reason: string) => {
// 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
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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
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) => {
ipcRenderer.send(channel, data)
},
@ -10,6 +10,6 @@ const api: TLApi = {
},
}
contextBridge?.exposeInMainWorld('TLApi', api)
contextBridge?.exposeInMainWorld('TldrawBridgeApi', api)
export {}

View file

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

View file

View file

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

View file

@ -35,5 +35,9 @@
"rimraf": "3.0.2",
"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 */
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 {
const rTLDrawState = React.useRef<TLDrawState>()
const rTldrawApp = React.useRef<TldrawApp>()
const handleMount = React.useCallback((state: TLDrawState) => {
rTLDrawState.current = state
const handleMount = React.useCallback((app: TldrawApp) => {
rTldrawApp.current = app
state.createShapes(
app.createShapes(
{
id: 'rect1',
type: TLDrawShapeType.Rectangle,
type: TDShapeType.Rectangle,
name: 'Rectangle',
childIndex: 1,
point: [0, 0],
@ -20,7 +20,7 @@ export default function Imperative(): JSX.Element {
{
id: 'rect2',
name: 'Rectangle',
type: TLDrawShapeType.Rectangle,
type: TDShapeType.Rectangle,
point: [200, 200],
size: [100, 100],
}
@ -30,13 +30,13 @@ export default function Imperative(): JSX.Element {
React.useEffect(() => {
let i = 0
const interval = setInterval(() => {
const state = rTLDrawState.current!
const rect1 = state.getShape('rect1')
const app = rTldrawApp.current!
const rect1 = app.getShape('rect1')
if (!rect1) {
state.createShapes({
app.createShapes({
id: 'rect1',
type: TLDrawShapeType.Rectangle,
type: TDShapeType.Rectangle,
name: 'Rectangle',
childIndex: 1,
point: [0, 0],
@ -47,7 +47,7 @@ export default function Imperative(): JSX.Element {
const color = i % 2 ? ColorStyle.Red : ColorStyle.Blue
state.patchShapes({
app.patchShapes({
id: 'rect1',
style: {
...rect1.style,
@ -60,5 +60,5 @@ export default function Imperative(): JSX.Element {
return () => clearInterval(interval)
}, [])
return <TLDraw onMount={handleMount} />
return <Tldraw onMount={handleMount} />
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,18 +1,18 @@
import * as React from 'react'
import {
TLDraw,
Tldraw,
ColorStyle,
DashStyle,
SizeStyle,
TLDrawDocument,
TLDrawShapeType,
TLDrawState,
TDDocument,
TDShapeType,
TldrawApp,
} from '@tldraw/tldraw'
export default function Controlled() {
const rDocument = React.useRef<TLDrawDocument>({
const rDocument = React.useRef<TDDocument>({
name: 'New Document',
version: TLDrawState.version,
version: TldrawApp.version,
id: 'doc',
pages: {
page1: {
@ -20,7 +20,7 @@ export default function Controlled() {
shapes: {
rect1: {
id: 'rect1',
type: TLDrawShapeType.Rectangle,
type: TDShapeType.Rectangle,
parentId: 'page1',
name: 'Rectangle',
childIndex: 1,
@ -34,7 +34,7 @@ export default function Controlled() {
},
rect2: {
id: 'rect2',
type: TLDrawShapeType.Rectangle,
type: TDShapeType.Rectangle,
parentId: 'page1',
name: 'Rectangle',
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(() => {
let i = 0
@ -105,5 +105,5 @@ export default function Controlled() {
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'
export default function ReadOnly(): JSX.Element {
const [file, setFile] = React.useState<TLDrawFile>()
const [file, setFile] = React.useState<TDFile>()
React.useEffect(() => {
async function loadFile(): Promise<void> {
@ -15,7 +15,7 @@ export default function ReadOnly(): JSX.Element {
return (
<div className="tldraw">
<TLDraw readOnly document={file?.document} />
<Tldraw readOnly document={file?.document} />
</div>
)
}

View file

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

View file

@ -8,7 +8,7 @@ From the root folder:
- 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:

View file

@ -2,7 +2,7 @@
## 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:
@ -10,38 +10,39 @@ In addition to the docs written below, this project also includes **generated do
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 |
| ----------------- | ---------------- | --------------------------------------------------------------------------------------------------------- |
| `id` | `string` | An id under which to persist the component's state. |
| `document` | `TLDrawDocument` | An initial [`TLDrawDocument`](#tldrawdocument) object. |
| `currentPageId` | `string` | A current page id, referencing the `TLDrawDocument` object provided via the `document` prop. |
| `autofocus` | `boolean` | Whether the editor should immediately receive focus. Defaults to true. |
| `showMenu` | `boolean` | Whether to show the menu. |
| `showPages` | `boolean` | Whether to show the pages menu. |
| `showStyles` | `boolean` | Whether to show the styles menu. |
| `showTools` | `boolean` | Whether to show the tools. |
| `showUI` | `boolean` | Whether to show any UI other than the canvas. |
| `onMount` | `Function` | Called when the editor first mounts, receiving the current `TLDrawState`. |
| `onPatch` | `Function` | Called when the state is updated via a patch. |
| `onCommand` | `Function` | Called when the state is updated via a command. |
| `onPersist` | `Function` | Called when the state is persisted after an action. |
| `onChange` | `Function` | Called when the `TLDrawState` updates for any reason. |
| `onUserChange` | `Function` | Called when the user's "presence" information changes. |
| `onUndo` | `Function` | Called when the `TLDrawState` updates after an undo. |
| `onRedo` | `Function` | Called when the `TLDrawState` updates after a redo. |
| `onSignIn` | `Function` | Called when the user selects Sign In from the menu. |
| `onSignOut` | `Function` | Called when the user selects Sign Out from the menu. |
| `onNewProject` | `Function` | Called when the user when the user creates 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. |
| `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. |
| Prop | Type | Description |
| ----------------- | ------------ | --------------------------------------------------------------------------------------------------------- |
| `id` | `string` | An id under which to persist the component's state. |
| `document` | `TDDocument` | An initial [`TDDocument`](#TDDocument) object. |
| `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. |
| `showMenu` | `boolean` | Whether to show the menu. |
| `showPages` | `boolean` | Whether to show the pages menu. |
| `showStyles` | `boolean` | Whether to show the styles menu. |
| `showTools` | `boolean` | Whether to show the tools. |
| `showUI` | `boolean` | Whether to show any UI other than the canvas. |
| `showSponsorLink` | `boolean` | Whether to show a sponsor link. |
| `onMount` | `Function` | Called when the editor first mounts, receiving the current `TldrawApp`. |
| `onPatch` | `Function` | Called when the state is updated via a patch. |
| `onCommand` | `Function` | Called when the state is updated via a command. |
| `onPersist` | `Function` | Called when the state is persisted after an action. |
| `onChange` | `Function` | Called when the `TldrawApp` updates for any reason. |
| `onUserChange` | `Function` | Called when the user's "presence" information changes. |
| `onUndo` | `Function` | Called when the `TldrawApp` updates after an undo. |
| `onRedo` | `Function` | Called when the `TldrawApp` updates after a redo. |
| `onSignIn` | `Function` | Called when the user selects Sign In from the menu. |
| `onSignOut` | `Function` | Called when the user selects Sign Out from the menu. |
| `onNewProject` | `Function` | Called when the user when the user creates 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. |
| `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`.
@ -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.
```ts
import { TLDraw, useFileSystem } from '@tldraw/tldraw'
import { Tldraw, useFileSystem } from '@tldraw/tldraw'
function App() {
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
- `pages` - A table of `TLDrawPage` objects
- `pages` - A table of `TDPage` objects
- `pageStates` - A table of `TLPageState` objects
- `version` - The document's version, used internally for migrations.
```ts
import { TLDrawDocument, TLDrawState } from '@tldraw/tldraw'
import { TDDocument, TldrawApp } from '@tldraw/tldraw'
const myDocument: TLDrawDocument = {
const myDocument: TDDocument = {
id: 'doc',
version: TLDrawState.version,
version: TldrawApp.version,
pages: {
page1: {
id: 'page1',
@ -95,34 +96,34 @@ const myDocument: TLDrawDocument = {
}
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.
## 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 |
| --------------------- | ---------------- | --------------------------------------------------------------- |
| `id` | `string` | A unique ID for the shape. |
| `name` | `string` | The shape's name. |
| `type` | `string` | The shape's type. |
| `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. |
| `point` | `number[]` | The `[x, y]` position of the shape. |
| `rotation` | `number[]` | (optional) The shape's rotation in radians. |
| `children` | `string[]` | (optional) The shape's child shape ids. |
| `handles` | `TLDrawHandle{}` | (optional) A table of `TLHandle` objects. |
| `isLocked` | `boolean` | (optional) True if the shape is locked. |
| `isHidden` | `boolean` | (optional) True if the shape is hidden. |
| `isEditing` | `boolean` | (optional) True if the shape is currently editing. |
| `isGenerated` | `boolean` | (optional) True if the shape is generated. |
| `isAspectRatioLocked` | `boolean` | (optional) True if the shape's aspect ratio is locked. |
| Property | Type | Description |
| --------------------- | ------------ | --------------------------------------------------------------- |
| `id` | `string` | A unique ID for the shape. |
| `name` | `string` | The shape's name. |
| `type` | `string` | The shape's type. |
| `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. |
| `point` | `number[]` | The `[x, y]` position of the shape. |
| `rotation` | `number[]` | (optional) The shape's rotation in radians. |
| `children` | `string[]` | (optional) The shape's child shape ids. |
| `handles` | `TDHandle{}` | (optional) A table of `TLHandle` objects. |
| `isLocked` | `boolean` | (optional) True if the shape is locked. |
| `isHidden` | `boolean` | (optional) True if the shape is hidden. |
| `isEditing` | `boolean` | (optional) True if the shape is currently editing. |
| `isGenerated` | `boolean` | (optional) True if the shape is generated. |
| `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.
@ -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. |
| `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
import { TLDraw, TLDrawState } from '@tldraw/tldraw'
import { Tldraw, TldrawApp } from '@tldraw/tldraw'
function App() {
const handleMount = React.useCallback((state: TLDrawState) => {
const handleMount = React.useCallback((state: TldrawApp) => {
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:

View file

@ -5,7 +5,7 @@
"author": "@steveruizok",
"repository": {
"type": "git",
"url": "git+https://github.com/tldraw/tldraw.git"
"url": "https://github.com/tldraw/tldraw.git"
},
"license": "MIT",
"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
- Fixes "shifting" bug with drawing tool. Finally!
@ -61,7 +87,7 @@
- Extracted into its own repository (`tldraw/core`) and open sourced! :clap:
### TLDraw
### tldraw
- Updated with latest `@tldraw/core`, updated ShapeUtils API.
@ -71,31 +97,31 @@
- Major change to ShapeUtils API.
### TLDraw
### tldraw
- Rewrite utils with new API.
## 0.0.126
### TLDraw
### tldraw
- Swap behavior of command and alt keys in arrow shapes.
## 0.0.125
### TLDraw
### tldraw
- Bug fixes.
## 0.0.124
### TLDraw
### tldraw
- Fix typings.
## 0.0.123
### TLDraw
### tldraw
- Adds bound shape controls.
- Drag a bound shape control to translate shapes in that direction.
@ -104,7 +130,7 @@
## 0.0.122
### TLDraw
### tldraw
- Adds snapping for transforming shapes.
@ -114,20 +140,20 @@
- Adds `snapLines`.
### TLDraw
### tldraw
- Adds shape snapping while translating. Hold Command/Control to disable while dragging.
## 0.0.120
### TLDraw
### tldraw
- Improves rectangle rendering.
- Improves zoom to fit and zoom to selection.
## 0.0.119
### TLDraw
### tldraw
- Fixes bug with bound arrows after undo.
@ -137,7 +163,7 @@
- Improves multiplayer features.
### TLDraw
### tldraw
- Fixes bugs in text, arrows, stickies.
- Adds start binding for new arrows.
@ -151,7 +177,7 @@
- Improves rendering on Safari.
### TLDraw
### tldraw
- Improves rendering on Safari.
- Minor bug fixes around selection.
@ -164,32 +190,32 @@
- Adds [side cloning](https://github.com/tldraw/tldraw/pull/149).
- Improves rendering.
### TLDraw
### tldraw
- Adds sticky note [side cloning](https://github.com/tldraw/tldraw/pull/149).
## 0.0.114
### TLDraw
### tldraw
- Improves fills for filled shapes.
## 0.0.113
### TLDraw
### tldraw
- Improves grouping and ungrouping.
## 0.0.112
### TLDraw
### tldraw
- Fixes centering on embedded TLDraw components.
- Fixes centering on embedded tldraw components.
- Removes expensive calls to window.
## 0.0.111
### TLDraw
### tldraw
- Adjust stroke widths and sizes.
- 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.
### TLDraw
### tldraw
- Adds multiplayer support.
- Adds `showMenu` and `showPages` props.
@ -208,7 +234,7 @@
## 0.0.109
### TLDraw
### tldraw
- Bumps perfect-freehand
- Fixes dots for small sized draw shapes
@ -217,6 +243,6 @@
- Adds CHANGELOG. Only 108 releases late!
### TLDraw
### tldraw
- Fixes a bug with bounding boxes on arrows.

View file

@ -4,10 +4,10 @@
# @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).
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",
"repository": {
"type": "git",
"url": "git+https://github.com/tldraw/tldraw.git"
"url": "https://github.com/tldraw/tldraw.git"
},
"license": "MIT",
"keywords": [
@ -59,4 +59,4 @@
"tsconfig-replace-paths": "^0.0.5"
},
"gitHead": "083b36e167b6911927a6b58cbbb830b11b33f00a"
}
}

View file

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

View file

@ -2,15 +2,9 @@ import * as React from 'react'
import { IdProvider } from '@radix-ui/react-id'
import { Renderer } from '@tldraw/core'
import { styled, dark } from '~styles'
import { TLDrawSnapshot, TLDrawDocument, TLDrawStatus, TLDrawUser } from '~types'
import { TLDrawCallbacks, TLDrawState } from '~state'
import {
TLDrawContext,
TLDrawContextType,
useStylesheet,
useKeyboardShortcuts,
useTLDrawContext,
} from '~hooks'
import { TDDocument, TDStatus, TDUser } from '~types'
import { TldrawApp, TDCallbacks } from '~state'
import { TldrawContext, useStylesheet, useKeyboardShortcuts, useTldrawApp } from '~hooks'
import { shapeUtils } from '~state/shapes'
import { ToolsPanel } from '~components/ToolsPanel'
import { TopPanel } from '~components/TopPanel'
@ -18,29 +12,7 @@ import { TLDR } from '~state/TLDR'
import { ContextMenu } from '~components/ContextMenu'
import { FocusButton } from '~components/FocusButton/FocusButton'
// Selectors
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 {
export interface TldrawProps extends TDCallbacks {
/**
* (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.
*/
document?: TLDrawDocument
document?: TDDocument
/**
* (optional) The current page id.
@ -86,6 +58,11 @@ export interface TLDrawProps extends TLDrawCallbacks {
*/
showTools?: boolean
/**
* (optional) Whether to show a sponsor link for Tldraw.
*/
showSponsorLink?: boolean
/**
* (optional) Whether to show the UI.
*/
@ -96,69 +73,81 @@ export interface TLDrawProps extends TLDrawCallbacks {
*/
readOnly?: boolean
/**
* (optional) Whether to to show the app's dark mode UI.
*/
darkMode?: boolean
/**
* (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.
*/
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.
*/
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.
*/
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.
*/
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.
*/
onSignIn?: (state: TLDrawState) => void
onSignIn?: (state: TldrawApp) => void
/**
* (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.
*/
onUserChange?: (state: TLDrawState, user: TLDrawUser) => void
onUserChange?: (state: TldrawApp, user: TDUser) => void
/**
* (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.
*/
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.
*/
onCommand?: (state: TLDrawState, reason?: string) => void
onCommand?: (state: TldrawApp, reason?: string) => void
/**
* (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.
*/
onUndo?: (state: TLDrawState) => void
onUndo?: (state: TldrawApp) => void
/**
* (optional) A callback to run when the user redos.
*/
onRedo?: (state: TLDrawState) => void
onRedo?: (state: TldrawApp) => void
}
export function TLDraw({
export function Tldraw({
id,
document,
currentPageId,
darkMode = false,
autofocus = true,
showMenu = true,
showPages = true,
@ -167,6 +156,7 @@ export function TLDraw({
showStyles = true,
showUI = true,
readOnly = false,
showSponsorLink = false,
onMount,
onChange,
onUserChange,
@ -181,12 +171,13 @@ export function TLDraw({
onPersist,
onPatch,
onCommand,
}: TLDrawProps) {
}: TldrawProps) {
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,
onChange,
onUserChange,
@ -204,15 +195,11 @@ export function TLDraw({
})
)
const [context, setContext] = React.useState<TLDrawContextType>(() => ({
state,
useSelector: state.useStore,
}))
// Create a new app if the `id` prop changes.
React.useEffect(() => {
if (id === sId) return
const newState = new TLDrawState(id, {
const newApp = new TldrawApp(id, {
onMount,
onChange,
onUserChange,
@ -231,31 +218,42 @@ export function TLDraw({
setSId(id)
setContext((ctx) => ({
...ctx,
state: newState,
useSelector: newState.useStore,
}))
setState(newState)
setApp(newApp)
}, [sId, id])
React.useEffect(() => {
state.readOnly = readOnly
}, [state, readOnly])
// Update the document if the `document` prop changes but the ids,
// are the same, or else load a new document if the ids are different.
React.useEffect(() => {
if (!document) return
if (document.id === state.document.id) {
state.updateDocument(document)
if (document.id === app.document.id) {
app.updateDocument(document)
} else {
state.loadDocument(document)
app.loadDocument(document)
}
}, [document, state])
}, [document, app])
// Change the page when the `currentPageId` prop changes
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,
onChange,
onUserChange,
@ -272,7 +270,7 @@ export function TLDraw({
onPersist,
}
}, [
state,
app,
onMount,
onChange,
onUserChange,
@ -291,12 +289,11 @@ export function TLDraw({
// Use the `key` to ensure that new selector hooks are made when the id changes
return (
<TLDrawContext.Provider value={context}>
<TldrawContext.Provider value={app}>
<IdProvider>
<InnerTLDraw
key={sId || 'tldraw'}
<InnerTldraw
key={sId || 'Tldraw'}
id={sId}
currentPageId={currentPageId}
autofocus={autofocus}
showPages={showPages}
showMenu={showMenu}
@ -304,16 +301,16 @@ export function TLDraw({
showZoom={showZoom}
showTools={showTools}
showUI={showUI}
showSponsorLink={showSponsorLink}
readOnly={readOnly}
/>
</IdProvider>
</TLDrawContext.Provider>
</TldrawContext.Provider>
)
}
interface InnerTLDrawProps {
interface InnerTldrawProps {
id?: string
currentPageId?: string
autofocus: boolean
showPages: boolean
showMenu: boolean
@ -321,44 +318,49 @@ interface InnerTLDrawProps {
showStyles: boolean
showUI: boolean
showTools: boolean
showSponsorLink: boolean
readOnly: boolean
}
const InnerTLDraw = React.memo(function InnerTLDraw({
const InnerTldraw = React.memo(function InnerTldraw({
id,
currentPageId,
autofocus,
showPages,
showMenu,
showZoom,
showStyles,
showTools,
showSponsorLink,
readOnly,
showUI,
}: InnerTLDrawProps) {
const { state, useSelector } = useTLDrawContext()
}: InnerTldrawProps) {
const app = useTldrawApp()
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 = state.session !== undefined
const isInSession = app.session !== undefined
// Hide bounds when not using the select tool, or when the only selected shape has handles
const hideBounds =
(isInSession && state.session?.constructor.name !== 'BrushSession') ||
(isInSession && app.session?.constructor.name !== 'BrushSession') ||
!isSelecting ||
isHideBoundsShape ||
!!pageState.editingId
@ -368,10 +370,12 @@ const InnerTLDraw = React.memo(function InnerTLDraw({
// Hide indicators when not using the select tool, or when in session
const hideIndicators =
(isInSession && state.appState.status !== TLDrawStatus.Brushing) || !isSelecting
(isInSession && state.appState.status !== TDStatus.Brushing) || !isSelecting
// 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
const theme = React.useMemo(() => {
@ -381,7 +385,7 @@ const InnerTLDraw = React.memo(function InnerTLDraw({
brushStroke: 'rgba(180, 180, 180, .25)',
selected: 'rgba(38, 150, 255, 1.000)',
selectFill: 'rgba(38, 150, 255, 0.05)',
background: '#343d45',
background: '#212529',
foreground: '#49555f',
}
}
@ -389,11 +393,6 @@ const InnerTLDraw = React.memo(function InnerTLDraw({
return {}
}, [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
// 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.
@ -415,72 +414,73 @@ const InnerTLDraw = React.memo(function InnerTLDraw({
shapeUtils={shapeUtils}
page={page}
pageState={pageState}
snapLines={snapLines}
users={users}
userId={state.state.room?.userId}
snapLines={appState.snapLines}
users={room?.users}
userId={room?.userId}
theme={theme}
meta={meta}
hideBounds={hideBounds}
hideHandles={hideHandles}
hideResizeHandles={isHideResizeHandlesShape}
hideIndicators={hideIndicators}
hideBindingHandles={!settings.showBindingHandles}
hideCloneHandles={!settings.showCloneHandles}
hideRotateHandles={!settings.showRotateHandles}
onPinchStart={state.onPinchStart}
onPinchEnd={state.onPinchEnd}
onPinch={state.onPinch}
onPan={state.onPan}
onZoom={state.onZoom}
onPointerDown={state.onPointerDown}
onPointerMove={state.onPointerMove}
onPointerUp={state.onPointerUp}
onPointCanvas={state.onPointCanvas}
onDoubleClickCanvas={state.onDoubleClickCanvas}
onRightPointCanvas={state.onRightPointCanvas}
onDragCanvas={state.onDragCanvas}
onReleaseCanvas={state.onReleaseCanvas}
onPointShape={state.onPointShape}
onDoubleClickShape={state.onDoubleClickShape}
onRightPointShape={state.onRightPointShape}
onDragShape={state.onDragShape}
onHoverShape={state.onHoverShape}
onUnhoverShape={state.onUnhoverShape}
onReleaseShape={state.onReleaseShape}
onPointBounds={state.onPointBounds}
onDoubleClickBounds={state.onDoubleClickBounds}
onRightPointBounds={state.onRightPointBounds}
onDragBounds={state.onDragBounds}
onHoverBounds={state.onHoverBounds}
onUnhoverBounds={state.onUnhoverBounds}
onReleaseBounds={state.onReleaseBounds}
onPointBoundsHandle={state.onPointBoundsHandle}
onDoubleClickBoundsHandle={state.onDoubleClickBoundsHandle}
onRightPointBoundsHandle={state.onRightPointBoundsHandle}
onDragBoundsHandle={state.onDragBoundsHandle}
onHoverBoundsHandle={state.onHoverBoundsHandle}
onUnhoverBoundsHandle={state.onUnhoverBoundsHandle}
onReleaseBoundsHandle={state.onReleaseBoundsHandle}
onPointHandle={state.onPointHandle}
onDoubleClickHandle={state.onDoubleClickHandle}
onRightPointHandle={state.onRightPointHandle}
onDragHandle={state.onDragHandle}
onHoverHandle={state.onHoverHandle}
onUnhoverHandle={state.onUnhoverHandle}
onReleaseHandle={state.onReleaseHandle}
onError={state.onError}
onRenderCountChange={state.onRenderCountChange}
onShapeChange={state.onShapeChange}
onShapeBlur={state.onShapeBlur}
onShapeClone={state.onShapeClone}
onBoundsChange={state.updateBounds}
onKeyDown={state.onKeyDown}
onKeyUp={state.onKeyUp}
onPinchStart={app.onPinchStart}
onPinchEnd={app.onPinchEnd}
onPinch={app.onPinch}
onPan={app.onPan}
onZoom={app.onZoom}
onPointerDown={app.onPointerDown}
onPointerMove={app.onPointerMove}
onPointerUp={app.onPointerUp}
onPointCanvas={app.onPointCanvas}
onDoubleClickCanvas={app.onDoubleClickCanvas}
onRightPointCanvas={app.onRightPointCanvas}
onDragCanvas={app.onDragCanvas}
onReleaseCanvas={app.onReleaseCanvas}
onPointShape={app.onPointShape}
onDoubleClickShape={app.onDoubleClickShape}
onRightPointShape={app.onRightPointShape}
onDragShape={app.onDragShape}
onHoverShape={app.onHoverShape}
onUnhoverShape={app.onUnhoverShape}
onReleaseShape={app.onReleaseShape}
onPointBounds={app.onPointBounds}
onDoubleClickBounds={app.onDoubleClickBounds}
onRightPointBounds={app.onRightPointBounds}
onDragBounds={app.onDragBounds}
onHoverBounds={app.onHoverBounds}
onUnhoverBounds={app.onUnhoverBounds}
onReleaseBounds={app.onReleaseBounds}
onPointBoundsHandle={app.onPointBoundsHandle}
onDoubleClickBoundsHandle={app.onDoubleClickBoundsHandle}
onRightPointBoundsHandle={app.onRightPointBoundsHandle}
onDragBoundsHandle={app.onDragBoundsHandle}
onHoverBoundsHandle={app.onHoverBoundsHandle}
onUnhoverBoundsHandle={app.onUnhoverBoundsHandle}
onReleaseBoundsHandle={app.onReleaseBoundsHandle}
onPointHandle={app.onPointHandle}
onDoubleClickHandle={app.onDoubleClickHandle}
onRightPointHandle={app.onRightPointHandle}
onDragHandle={app.onDragHandle}
onHoverHandle={app.onHoverHandle}
onUnhoverHandle={app.onUnhoverHandle}
onReleaseHandle={app.onReleaseHandle}
onError={app.onError}
onRenderCountChange={app.onRenderCountChange}
onShapeChange={app.onShapeChange}
onShapeBlur={app.onShapeBlur}
onShapeClone={app.onShapeClone}
onBoundsChange={app.updateBounds}
onKeyDown={app.onKeyDown}
onKeyUp={app.onKeyUp}
/>
</ContextMenu>
{showUI && (
<StyledUI>
{settings.isFocusMode ? (
<FocusButton onSelect={state.toggleFocusMode} />
<FocusButton onSelect={app.toggleFocusMode} />
) : (
<>
<TopPanel
@ -489,6 +489,7 @@ const InnerTLDraw = React.memo(function InnerTLDraw({
showMenu={showMenu}
showStyles={showStyles}
showZoom={showZoom}
showSponsorLink={showSponsorLink}
/>
<StyledSpacer />
{showTools && !readOnly && <ToolsPanel />}
@ -539,6 +540,13 @@ const StyledLayout = styled('div', {
width: '100%',
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', {

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,10 @@ import * as React from 'react'
import { Item } from '@radix-ui/react-dropdown-menu'
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 (
<Item dir="ltr" asChild onSelect={onSelect}>
<RowButton {...rest} />

View file

@ -16,11 +16,32 @@ export const DMRadioItem = styled(RadioItem, {
pointerEvents: 'all',
cursor: 'pointer',
'&:focus': {
backgroundColor: '$hover',
variants: {
isActive: {
true: {
backgroundColor: '$selected',
color: '$panel',
},
false: {},
},
bp: {
mobile: {},
small: {},
},
},
'&:hover:not(:disabled)': {
backgroundColor: '$hover',
},
compoundVariants: [
{
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 { 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
}
export function DMTriggerIcon({ children }: DMTriggerIconProps) {
export function DMTriggerIcon({ children, ...rest }: DMTriggerIconProps) {
return (
<Trigger asChild>
<ToolButton>{children}</ToolButton>
<ToolButton {...rest}>{children}</ToolButton>
</Trigger>
)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,32 +1,52 @@
import * as React from 'react'
import { breakpoints } from '~components/breakpoints'
import { Tooltip } from '~components/Tooltip'
import { useTLDrawContext } from '~hooks'
import { useTldrawApp } from '~hooks'
import { styled } from '~styles'
export interface ToolButtonProps {
onClick?: () => void
onSelect?: () => void
onDoubleClick?: () => void
disabled?: boolean
isActive?: boolean
isSponsor?: boolean
isToolLocked?: boolean
variant?: 'icon' | 'text' | 'circle' | 'primary'
children: React.ReactNode
}
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 (
<StyledToolButton
ref={ref}
isActive={isActive}
isSponsor={isSponsor}
variant={variant}
onClick={onClick}
disabled={disabled}
onPointerDown={onSelect}
onDoubleClick={onDoubleClick}
bp={breakpoints}
{...rest}
>
<StyledToolButtonInner>{children}</StyledToolButtonInner>
{isToolLocked && <ToolLockIndicator />}
</StyledToolButton>
)
}
@ -36,20 +56,30 @@ export const ToolButton = React.forwardRef<HTMLButtonElement, ToolButtonProps>(
interface ToolButtonWithTooltipProps extends ToolButtonProps {
label: string
isLocked?: boolean
kbd?: string
}
export function ToolButtonWithTooltip({ label, kbd, ...rest }: ToolButtonWithTooltipProps) {
const { state } = useTLDrawContext()
export function ToolButtonWithTooltip({
label,
kbd,
isLocked,
...rest
}: ToolButtonWithTooltipProps) {
const app = useTldrawApp()
const handleDoubleClick = React.useCallback(() => {
console.log('double clicking')
state.toggleToolLock()
app.toggleToolLock()
}, [])
return (
<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>
)
}
@ -112,6 +142,7 @@ export const StyledToolButton = styled('button', {
circle: {
padding: '$2',
[`& ${StyledToolButtonInner}`]: {
border: '1px solid $panelContrast',
borderRadius: '100%',
boxShadow: '$panel',
},
@ -121,23 +152,17 @@ export const StyledToolButton = styled('button', {
},
},
},
isActive: {
isSponsor: {
true: {
[`${StyledToolButtonInner}`]: {
backgroundColor: '$selected',
color: '$panelActive',
},
},
false: {
[`&:hover:not(:disabled) ${StyledToolButtonInner}`]: {
backgroundColor: '$hover',
border: '1px solid $panel',
},
[`&:focus:not(:disabled) ${StyledToolButtonInner}`]: {
backgroundColor: '$hover',
backgroundColor: '$sponsorContrast',
},
},
},
isActive: {
true: {},
false: {},
},
bp: {
mobile: {},
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 { Tooltip } from '~components/Tooltip/Tooltip'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { useTLDrawContext } from '~hooks'
import { useTldrawApp } from '~hooks'
import { styled } from '~styles'
import { AlignType, TLDrawSnapshot, DistributeType, StretchType } from '~types'
import { AlignType, TDSnapshot, DistributeType, StretchType } from '~types'
import {
ArrowDownIcon,
ArrowUpIcon,
@ -30,25 +30,24 @@ import {
import { DMContent } from '~components/DropdownMenu'
import { Divider } from '~components/Divider'
import { TrashIcon } from '~components/icons'
import { IconButton } from '~components/IconButton'
import { ToolButton } from '~components/ToolButton'
const selectedShapesCountSelector = (s: TLDrawSnapshot) =>
const selectedShapesCountSelector = (s: TDSnapshot) =>
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 { selectedIds } = s.document.pageStates[s.appState.currentPageId]
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 { selectedIds } = s.document.pageStates[s.appState.currentPageId]
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 selectedShapes = s.document.pageStates[s.appState.currentPageId].selectedIds.map(
(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]
return selectedIds.length > 0
}
const hasMultipleSelectionClickor = (s: TLDrawSnapshot) => {
const hasMultipleSelectionClickor = (s: TDSnapshot) => {
const { selectedIds } = s.document.pageStates[s.appState.currentPageId]
return selectedIds.length > 1
}
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(() => {
state.rotate()
}, [state])
app.rotate()
}, [app])
const handleDuplicate = React.useCallback(() => {
state.duplicate()
}, [state])
app.duplicate()
}, [app])
const handleToggleLocked = React.useCallback(() => {
state.toggleLocked()
}, [state])
app.toggleLocked()
}, [app])
const handleToggleAspectRatio = React.useCallback(() => {
state.toggleAspectRatioLocked()
}, [state])
app.toggleAspectRatioLocked()
}, [app])
const handleGroup = React.useCallback(() => {
state.group()
}, [state])
app.group()
}, [app])
const handleMoveToBack = React.useCallback(() => {
state.moveToBack()
}, [state])
app.moveToBack()
}, [app])
const handleMoveBackward = React.useCallback(() => {
state.moveBackward()
}, [state])
app.moveBackward()
}, [app])
const handleMoveForward = React.useCallback(() => {
state.moveForward()
}, [state])
app.moveForward()
}, [app])
const handleMoveToFront = React.useCallback(() => {
state.moveToFront()
}, [state])
app.moveToFront()
}, [app])
const handleDelete = React.useCallback(() => {
state.delete()
}, [state])
app.delete()
}, [app])
const alignTop = React.useCallback(() => {
state.align(AlignType.Top)
}, [state])
app.align(AlignType.Top)
}, [app])
const alignCenterVertical = React.useCallback(() => {
state.align(AlignType.CenterVertical)
}, [state])
app.align(AlignType.CenterVertical)
}, [app])
const alignBottom = React.useCallback(() => {
state.align(AlignType.Bottom)
}, [state])
app.align(AlignType.Bottom)
}, [app])
const stretchVertically = React.useCallback(() => {
state.stretch(StretchType.Vertical)
}, [state])
app.stretch(StretchType.Vertical)
}, [app])
const distributeVertically = React.useCallback(() => {
state.distribute(DistributeType.Vertical)
}, [state])
app.distribute(DistributeType.Vertical)
}, [app])
const alignLeft = React.useCallback(() => {
state.align(AlignType.Left)
}, [state])
app.align(AlignType.Left)
}, [app])
const alignCenterHorizontal = React.useCallback(() => {
state.align(AlignType.CenterHorizontal)
}, [state])
app.align(AlignType.CenterHorizontal)
}, [app])
const alignRight = React.useCallback(() => {
state.align(AlignType.Right)
}, [state])
app.align(AlignType.Right)
}, [app])
const stretchHorizontally = React.useCallback(() => {
state.stretch(StretchType.Horizontal)
}, [state])
app.stretch(StretchType.Horizontal)
}, [app])
const distributeHorizontally = React.useCallback(() => {
state.distribute(DistributeType.Horizontal)
}, [state])
app.distribute(DistributeType.Horizontal)
}, [app])
const selectedShapesCount = useSelector(selectedShapesCountSelector)
const selectedShapesCount = app.useStore(selectedShapesCountSelector)
const hasTwoOrMore = selectedShapesCount > 1
const hasThreeOrMore = selectedShapesCount > 2
@ -177,99 +176,99 @@ export function ActionButton(): JSX.Element {
<DotsHorizontalIcon />
</ToolButton>
</DropdownMenu.Trigger>
<DMContent>
<DMContent sideOffset={16}>
<>
<ButtonsRow>
<IconButton disabled={!hasSelection} onClick={handleDuplicate}>
<ToolButton variant="icon" disabled={!hasSelection} onClick={handleDuplicate}>
<Tooltip label="Duplicate" kbd={`#D`}>
<CopyIcon />
</Tooltip>
</IconButton>
<IconButton disabled={!hasSelection} onClick={handleRotate}>
</ToolButton>
<ToolButton disabled={!hasSelection} onClick={handleRotate}>
<Tooltip label="Rotate">
<RotateCounterClockwiseIcon />
</Tooltip>
</IconButton>
<IconButton disabled={!hasSelection} onClick={handleToggleLocked}>
</ToolButton>
<ToolButton disabled={!hasSelection} onClick={handleToggleLocked}>
<Tooltip label="Toogle Locked" kbd={`#L`}>
{isAllLocked ? <LockClosedIcon /> : <LockOpen1Icon opacity={0.4} />}
</Tooltip>
</IconButton>
<IconButton disabled={!hasSelection} onClick={handleToggleAspectRatio}>
</ToolButton>
<ToolButton disabled={!hasSelection} onClick={handleToggleAspectRatio}>
<Tooltip label="Toogle Aspect Ratio Lock">
<AspectRatioIcon opacity={isAllAspectLocked ? 1 : 0.4} />
</Tooltip>
</IconButton>
<IconButton
</ToolButton>
<ToolButton
disabled={!hasSelection || (!isAllGrouped && !hasMultipleSelection)}
onClick={handleGroup}
>
<Tooltip label="Group" kbd={`#G`}>
<GroupIcon opacity={isAllGrouped ? 1 : 0.4} />
</Tooltip>
</IconButton>
</ToolButton>
</ButtonsRow>
<ButtonsRow>
<IconButton disabled={!hasSelection} onClick={handleMoveToBack}>
<ToolButton disabled={!hasSelection} onClick={handleMoveToBack}>
<Tooltip label="Move to Back" kbd={`#⇧[`}>
<PinBottomIcon />
</Tooltip>
</IconButton>
<IconButton disabled={!hasSelection} onClick={handleMoveBackward}>
</ToolButton>
<ToolButton disabled={!hasSelection} onClick={handleMoveBackward}>
<Tooltip label="Move Backward" kbd={`#[`}>
<ArrowDownIcon />
</Tooltip>
</IconButton>
<IconButton disabled={!hasSelection} onClick={handleMoveForward}>
</ToolButton>
<ToolButton disabled={!hasSelection} onClick={handleMoveForward}>
<Tooltip label="Move Forward" kbd={`#]`}>
<ArrowUpIcon />
</Tooltip>
</IconButton>
<IconButton disabled={!hasSelection} onClick={handleMoveToFront}>
</ToolButton>
<ToolButton disabled={!hasSelection} onClick={handleMoveToFront}>
<Tooltip label="More to Front" kbd={`#⇧]`}>
<PinTopIcon />
</Tooltip>
</IconButton>
<IconButton disabled={!hasSelection} onClick={handleDelete}>
</ToolButton>
<ToolButton disabled={!hasSelection} onClick={handleDelete}>
<Tooltip label="Delete" kbd="⌫">
<TrashIcon />
</Tooltip>
</IconButton>
</ToolButton>
</ButtonsRow>
<Divider />
<ButtonsRow>
<IconButton disabled={!hasTwoOrMore} onClick={alignLeft}>
<ToolButton disabled={!hasTwoOrMore} onClick={alignLeft}>
<AlignLeftIcon />
</IconButton>
<IconButton disabled={!hasTwoOrMore} onClick={alignCenterHorizontal}>
</ToolButton>
<ToolButton disabled={!hasTwoOrMore} onClick={alignCenterHorizontal}>
<AlignCenterHorizontallyIcon />
</IconButton>
<IconButton disabled={!hasTwoOrMore} onClick={alignRight}>
</ToolButton>
<ToolButton disabled={!hasTwoOrMore} onClick={alignRight}>
<AlignRightIcon />
</IconButton>
<IconButton disabled={!hasTwoOrMore} onClick={stretchHorizontally}>
</ToolButton>
<ToolButton disabled={!hasTwoOrMore} onClick={stretchHorizontally}>
<StretchHorizontallyIcon />
</IconButton>
<IconButton disabled={!hasThreeOrMore} onClick={distributeHorizontally}>
</ToolButton>
<ToolButton disabled={!hasThreeOrMore} onClick={distributeHorizontally}>
<SpaceEvenlyHorizontallyIcon />
</IconButton>
</ToolButton>
</ButtonsRow>
<ButtonsRow>
<IconButton disabled={!hasTwoOrMore} onClick={alignTop}>
<ToolButton disabled={!hasTwoOrMore} onClick={alignTop}>
<AlignTopIcon />
</IconButton>
<IconButton disabled={!hasTwoOrMore} onClick={alignCenterVertical}>
</ToolButton>
<ToolButton disabled={!hasTwoOrMore} onClick={alignCenterVertical}>
<AlignCenterVerticallyIcon />
</IconButton>
<IconButton disabled={!hasTwoOrMore} onClick={alignBottom}>
</ToolButton>
<ToolButton disabled={!hasTwoOrMore} onClick={alignBottom}>
<AlignBottomIcon />
</IconButton>
<IconButton disabled={!hasTwoOrMore} onClick={stretchVertically}>
</ToolButton>
<ToolButton disabled={!hasTwoOrMore} onClick={stretchVertically}>
<StretchVerticallyIcon />
</IconButton>
<IconButton disabled={!hasThreeOrMore} onClick={distributeVertically}>
</ToolButton>
<ToolButton disabled={!hasThreeOrMore} onClick={distributeVertically}>
<SpaceEvenlyVerticallyIcon />
</IconButton>
</ToolButton>
</ButtonsRow>
</>
</DMContent>

View file

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

View file

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

View file

@ -1,20 +1,20 @@
import * as React from 'react'
import { LockClosedIcon, LockOpen1Icon } from '@radix-ui/react-icons'
import { Tooltip } from '~components/Tooltip'
import { useTLDrawContext } from '~hooks'
import { useTldrawApp } from '~hooks'
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 {
const { state, useSelector } = useTLDrawContext()
const app = useTldrawApp()
const isToolLocked = useSelector(isToolLockedSelector)
const isToolLocked = app.useStore(isToolLockedSelector)
return (
<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 />}
</ToolButton>
</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 {
ArrowTopRightIcon,
CircleIcon,
CursorArrowIcon,
Pencil1Icon,
Pencil2Icon,
SquareIcon,
TextIcon,
} from '@radix-ui/react-icons'
import { TLDrawSnapshot, TLDrawShapeType } from '~types'
import { useTLDrawContext } from '~hooks'
import { TDSnapshot, TDShapeType } from '~types'
import { useTldrawApp } from '~hooks'
import { ToolButtonWithTooltip } from '~components/ToolButton'
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 {
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(() => {
state.selectTool('select')
}, [state])
app.selectTool('select')
}, [app])
const selectEraseTool = React.useCallback(() => {
app.selectTool('erase')
}, [app])
const selectDrawTool = React.useCallback(() => {
state.selectTool(TLDrawShapeType.Draw)
}, [state])
const selectRectangleTool = React.useCallback(() => {
state.selectTool(TLDrawShapeType.Rectangle)
}, [state])
const selectEllipseTool = React.useCallback(() => {
state.selectTool(TLDrawShapeType.Ellipse)
}, [state])
app.selectTool(TDShapeType.Draw)
}, [app])
const selectArrowTool = React.useCallback(() => {
state.selectTool(TLDrawShapeType.Arrow)
}, [state])
app.selectTool(TDShapeType.Arrow)
}, [app])
const selectTextTool = React.useCallback(() => {
state.selectTool(TLDrawShapeType.Text)
}, [state])
app.selectTool(TDShapeType.Text)
}, [app])
const selectStickyTool = React.useCallback(() => {
state.selectTool(TLDrawShapeType.Sticky)
}, [state])
app.selectTool(TDShapeType.Sticky)
}, [app])
return (
<Panel side="center">
@ -60,49 +59,43 @@ export const PrimaryTools = React.memo(function PrimaryTools(): JSX.Element {
</ToolButtonWithTooltip>
<ToolButtonWithTooltip
kbd={'2'}
label={TLDrawShapeType.Draw}
label={TDShapeType.Draw}
onClick={selectDrawTool}
isActive={activeTool === TLDrawShapeType.Draw}
isActive={activeTool === TDShapeType.Draw}
>
<Pencil1Icon />
</ToolButtonWithTooltip>
<ToolButtonWithTooltip
kbd={'3'}
label={TLDrawShapeType.Rectangle}
onClick={selectRectangleTool}
isActive={activeTool === TLDrawShapeType.Rectangle}
label={'eraser'}
onClick={selectEraseTool}
isActive={activeTool === 'erase'}
>
<SquareIcon />
<EraserIcon />
</ToolButtonWithTooltip>
<ShapesMenu activeTool={activeTool} isToolLocked={isToolLocked} />
<ToolButtonWithTooltip
kbd={'4'}
label={TLDrawShapeType.Ellipse}
onClick={selectEllipseTool}
isActive={activeTool === TLDrawShapeType.Ellipse}
>
<CircleIcon />
</ToolButtonWithTooltip>
<ToolButtonWithTooltip
kbd={'5'}
label={TLDrawShapeType.Arrow}
kbd={'6'}
label={TDShapeType.Arrow}
onClick={selectArrowTool}
isActive={activeTool === TLDrawShapeType.Arrow}
isLocked={isToolLocked}
isActive={activeTool === TDShapeType.Arrow}
>
<ArrowTopRightIcon />
</ToolButtonWithTooltip>
<ToolButtonWithTooltip
kbd={'6'}
label={TLDrawShapeType.Text}
kbd={'7'}
label={TDShapeType.Text}
onClick={selectTextTool}
isActive={activeTool === TLDrawShapeType.Text}
isActive={activeTool === TDShapeType.Text}
>
<TextIcon />
</ToolButtonWithTooltip>
<ToolButtonWithTooltip
kbd={'7'}
label={TLDrawShapeType.Sticky}
kbd={'8'}
label={TDShapeType.Sticky}
onClick={selectStickyTool}
isActive={activeTool === TLDrawShapeType.Sticky}
isActive={activeTool === TDShapeType.Sticky}
>
<Pencil2Icon />
</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 { useTLDrawContext } from '~hooks'
import type { TLDrawSnapshot } from '~types'
import { useTldrawApp } from '~hooks'
import type { TDSnapshot } from '~types'
import { styled } from '~styles'
import { breakpoints } from '~components/breakpoints'
const statusSelector = (s: TLDrawSnapshot) => s.appState.status
const activeToolSelector = (s: TLDrawSnapshot) => s.appState.activeTool
const statusSelector = (s: TDSnapshot) => s.appState.status
const activeToolSelector = (s: TDSnapshot) => s.appState.activeTool
export function StatusBar(): JSX.Element | null {
const { useSelector } = useTLDrawContext()
const status = useSelector(statusSelector)
const activeTool = useSelector(activeToolSelector)
const app = useTldrawApp()
const status = app.useStore(statusSelector)
const activeTool = app.useStore(activeToolSelector)
return (
<StyledStatusBar bp={breakpoints}>
@ -24,7 +24,7 @@ export function StatusBar(): JSX.Element | null {
const StyledStatusBar = styled('div', {
height: 40,
userSelect: 'none',
borderTop: '1px solid $border',
borderTop: '1px solid $panelContrast',
gridArea: 'status',
display: 'flex',
color: '$text',

View file

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

View file

@ -22,10 +22,10 @@ export function Tooltip({
}: TooltipProps): JSX.Element {
return (
<RadixTooltip.Root>
<RadixTooltip.Trigger asChild={true}>
<RadixTooltip.Trigger dir="ltr" asChild={true}>
<span>{children}</span>
</RadixTooltip.Trigger>
<StyledContent side={side} sideOffset={8}>
<StyledContent dir="ltr" side={side} sideOffset={8}>
{label}
{kbdProp ? <Kbd variant="tooltip">{kbdProp}</Kbd> : null}
<StyledArrow />
@ -38,8 +38,8 @@ const StyledContent = styled(RadixTooltip.Content, {
borderRadius: 3,
padding: '$3 $3 $3 $3',
fontSize: '$1',
backgroundColor: '$tooltipBg',
color: '$tooltipText',
backgroundColor: '$tooltip',
color: '$tooltipContrast',
boxShadow: '$3',
display: 'flex',
alignItems: 'center',
@ -48,6 +48,6 @@ const StyledContent = styled(RadixTooltip.Content, {
})
const StyledArrow = styled(RadixTooltip.Arrow, {
fill: '$tooltipBg',
fill: '$tooltip',
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 { 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 { useTLDrawContext } from '~hooks'
import { useTldrawApp } from '~hooks'
import { PreferencesMenu } from './PreferencesMenu'
import { DMItem, DMContent, DMDivider, DMSubMenu, DMTriggerIcon } from '~components/DropdownMenu'
import { SmallIcon } from '~components/SmallIcon'
import { useFileSystemHandlers } from '~hooks'
import { HeartIcon } from '~components/icons/HeartIcon'
import { preventEvent } from '~components/preventEvent'
interface MenuProps {
showSponsorLink: boolean
readOnly: boolean
}
export const Menu = React.memo(function Menu({ readOnly }: MenuProps) {
const { state } = useTLDrawContext()
export const Menu = React.memo(function Menu({ showSponsorLink, readOnly }: MenuProps) {
const app = useTldrawApp()
const { onNewProject, onOpenProject, onSaveProject, onSaveProjectAs } = useFileSystemHandlers()
const handleSignIn = React.useCallback(() => {
state.callbacks.onSignIn?.(state)
}, [state])
app.callbacks.onSignIn?.(app)
}, [app])
const handleSignOut = React.useCallback(() => {
state.callbacks.onSignOut?.(state)
}, [state])
app.callbacks.onSignOut?.(app)
}, [app])
const handleCut = React.useCallback(() => {
state.cut()
}, [state])
app.cut()
}, [app])
const handleCopy = React.useCallback(() => {
state.copy()
}, [state])
app.copy()
}, [app])
const handlePaste = React.useCallback(() => {
state.paste()
}, [state])
app.paste()
}, [app])
const handleCopySvg = React.useCallback(() => {
state.copySvg()
}, [state])
app.copySvg()
}, [app])
const handleCopyJson = React.useCallback(() => {
state.copyJson()
}, [state])
app.copyJson()
}, [app])
const handleSelectAll = React.useCallback(() => {
state.selectAll()
}, [state])
app.selectAll()
}, [app])
const handleselectNone = React.useCallback(() => {
state.selectNone()
}, [state])
app.selectNone()
}, [app])
const showFileMenu =
state.callbacks.onNewProject ||
state.callbacks.onOpenProject ||
state.callbacks.onSaveProject ||
state.callbacks.onSaveProjectAs
app.callbacks.onNewProject ||
app.callbacks.onOpenProject ||
app.callbacks.onSaveProject ||
app.callbacks.onSaveProjectAs
const showSignInOutMenu = state.callbacks.onSignIn || state.callbacks.onSignOut
const showSignInOutMenu = app.callbacks.onSignIn || app.callbacks.onSignOut || showSponsorLink
return (
<DropdownMenu.Root>
<DMTriggerIcon>
<DropdownMenu.Root dir="ltr">
<DMTriggerIcon isSponsor={showSponsorLink}>
<HamburgerMenuIcon />
</DMTriggerIcon>
<DMContent variant="menu">
{showFileMenu && (
<DMSubMenu label="File...">
{state.callbacks.onNewProject && (
<DMItem onSelect={onNewProject} kbd="#N">
{app.callbacks.onNewProject && (
<DMItem onClick={onNewProject} kbd="#N">
New Project
</DMItem>
)}
{state.callbacks.onOpenProject && (
<DMItem onSelect={onOpenProject} kbd="#O">
{app.callbacks.onOpenProject && (
<DMItem onClick={onOpenProject} kbd="#O">
Open...
</DMItem>
)}
{state.callbacks.onSaveProject && (
<DMItem onSelect={onSaveProject} kbd="#S">
{app.callbacks.onSaveProject && (
<DMItem onClick={onSaveProject} kbd="#S">
Save
</DMItem>
)}
{state.callbacks.onSaveProjectAs && (
<DMItem onSelect={onSaveProjectAs} kbd="⇧#S">
{app.callbacks.onSaveProjectAs && (
<DMItem onClick={onSaveProjectAs} kbd="⇧#S">
Save As...
</DMItem>
)}
@ -93,42 +96,76 @@ export const Menu = React.memo(function Menu({ readOnly }: MenuProps) {
{!readOnly && (
<>
<DMSubMenu label="Edit...">
<DMItem onSelect={state.undo} kbd="#Z">
<DMItem onSelect={preventEvent} onClick={app.undo} kbd="#Z">
Undo
</DMItem>
<DMItem onSelect={state.redo} kbd="#⇧Z">
<DMItem onSelect={preventEvent} onClick={app.redo} kbd="#⇧Z">
Redo
</DMItem>
<DMDivider dir="ltr" />
<DMItem onSelect={handleCut} kbd="#X">
<DMItem onSelect={preventEvent} onClick={handleCut} kbd="#X">
Cut
</DMItem>
<DMItem onSelect={handleCopy} kbd="#C">
<DMItem onSelect={preventEvent} onClick={handleCopy} kbd="#C">
Copy
</DMItem>
<DMItem onSelect={handlePaste} kbd="#V">
<DMItem onSelect={preventEvent} onClick={handlePaste} kbd="#V">
Paste
</DMItem>
<DMDivider dir="ltr" />
<DMItem onSelect={handleCopySvg} kbd="#⇧C">
<DMItem onSelect={preventEvent} onClick={handleCopySvg} kbd="#⇧C">
Copy as SVG
</DMItem>
<DMItem onSelect={handleCopyJson}>Copy as JSON</DMItem>
<DMItem onSelect={preventEvent} onClick={handleCopyJson}>
Copy as JSON
</DMItem>
<DMDivider dir="ltr" />
<DMItem onSelect={handleSelectAll} kbd="#A">
<DMItem onSelect={preventEvent} onClick={handleSelectAll} kbd="#A">
Select All
</DMItem>
<DMItem onSelect={handleselectNone}>Select None</DMItem>
<DMItem onSelect={preventEvent} onClick={handleselectNone}>
Select None
</DMItem>
</DMSubMenu>
<DMDivider dir="ltr" />
</>
)}
<a href="https://tldraw.com/r">
<DMItem>Create a Multiplayer Room</DMItem>
</a>
<DMDivider dir="ltr" />
<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 && (
<>
<DMDivider dir="ltr" />{' '}
{state.callbacks.onSignIn && <DMItem onSelect={handleSignIn}>Sign In</DMItem>}
{state.callbacks.onSignOut && (
{app.callbacks.onSignIn && <DMItem onSelect={handleSignIn}>Sign In</DMItem>}
{app.callbacks.onSignOut && (
<DMItem onSelect={handleSignOut}>
Sign Out
<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 { PageOptionsDialog } from './PageOptionsDialog'
import { styled } from '~styles'
import { useTLDrawContext } from '~hooks'
import type { TLDrawSnapshot } from '~types'
import { useTldrawApp } from '~hooks'
import type { TDSnapshot } from '~types'
import { DMContent, DMDivider } from '~components/DropdownMenu'
import { SmallIcon } from '~components/SmallIcon'
import { RowButton } from '~components/RowButton'
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))
const currentPageNameSelector = (s: TLDrawSnapshot) =>
s.document.pages[s.appState.currentPageId].name
const currentPageNameSelector = (s: TDSnapshot) => 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 {
const { useSelector } = useTLDrawContext()
const app = useTldrawApp()
const rIsOpen = React.useRef(false)
@ -43,7 +42,7 @@ export function PageMenu(): JSX.Element {
},
[setIsOpen]
)
const currentPageName = useSelector(currentPageNameSelector)
const currentPageName = app.useStore(currentPageNameSelector)
return (
<DropdownMenu.Root dir="ltr" open={isOpen} onOpenChange={handleOpenChange}>
@ -58,27 +57,27 @@ export function PageMenu(): JSX.Element {
}
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(() => {
state.createPage()
}, [state])
app.createPage()
}, [app])
const handleChangePage = React.useCallback(
(id: string) => {
onClose()
state.changePage(id)
app.changePage(id)
},
[state]
[app]
)
return (
<>
<DropdownMenu.RadioGroup value={currentPageId} onValueChange={handleChangePage}>
<DropdownMenu.RadioGroup dir="ltr" value={currentPageId} onValueChange={handleChangePage}>
{sortedPages.map((page) => (
<ButtonWithOptions key={page.id}>
<DropdownMenu.RadioItem

View file

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

View file

@ -1,42 +1,42 @@
import * as React from 'react'
import { DMCheckboxItem, DMDivider, DMSubMenu } from '~components/DropdownMenu'
import { useTLDrawContext } from '~hooks'
import type { TLDrawSnapshot } from '~types'
import { useTldrawApp } from '~hooks'
import type { TDSnapshot } from '~types'
const settingsSelector = (s: TLDrawSnapshot) => s.settings
const settingsSelector = (s: TDSnapshot) => s.settings
export function PreferencesMenu() {
const { state, useSelector } = useTLDrawContext()
const app = useTldrawApp()
const settings = useSelector(settingsSelector)
const settings = app.useStore(settingsSelector)
const toggleDebugMode = React.useCallback(() => {
state.setSetting('isDebugMode', (v) => !v)
}, [state])
app.setSetting('isDebugMode', (v) => !v)
}, [app])
const toggleDarkMode = React.useCallback(() => {
state.setSetting('isDarkMode', (v) => !v)
}, [state])
app.setSetting('isDarkMode', (v) => !v)
}, [app])
const toggleFocusMode = React.useCallback(() => {
state.setSetting('isFocusMode', (v) => !v)
}, [state])
app.setSetting('isFocusMode', (v) => !v)
}, [app])
const toggleRotateHandle = React.useCallback(() => {
state.setSetting('showRotateHandles', (v) => !v)
}, [state])
app.setSetting('showRotateHandles', (v) => !v)
}, [app])
const toggleBoundShapesHandle = React.useCallback(() => {
state.setSetting('showBindingHandles', (v) => !v)
}, [state])
app.setSetting('showBindingHandles', (v) => !v)
}, [app])
const toggleisSnapping = React.useCallback(() => {
state.setSetting('isSnapping', (v) => !v)
}, [state])
app.setSetting('isSnapping', (v) => !v)
}, [app])
const toggleCloneControls = React.useCallback(() => {
state.setSetting('showCloneHandles', (v) => !v)
}, [state])
app.setSetting('showCloneHandles', (v) => !v)
}, [app])
return (
<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 { PageMenu } from './PageMenu'
import { ZoomMenu } from './ZoomMenu'
import { DashMenu } from './DashMenu'
import { SizeMenu } from './SizeMenu'
import { FillCheckbox } from './FillCheckbox'
import { ColorMenu } from './ColorMenu'
import { StyleMenu } from './StyleMenu'
import { Panel } from '~components/Panel'
interface TopPanelProps {
@ -15,28 +12,29 @@ interface TopPanelProps {
showMenu: boolean
showStyles: 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 (
<StyledTopPanel>
{(showMenu || showPages) && (
<Panel side="left">
{showMenu && <Menu readOnly={readOnly} />}
{showMenu && <Menu showSponsorLink={showSponsorLink} readOnly={readOnly} />}
{showPages && <PageMenu />}
</Panel>
)}
<StyledSpacer />
{(showStyles || showZoom) && (
<Panel side="right">
{showStyles && !readOnly && (
<>
<ColorMenu />
<SizeMenu />
<DashMenu />
<FillCheckbox />
</>
)}
{showStyles && !readOnly && <StyleMenu />}
{showZoom && <ZoomMenu />}
</Panel>
)}

View file

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

View file

@ -3,9 +3,11 @@ import * as React from 'react'
export function BoxIcon({
fill = 'none',
stroke = 'currentColor',
strokeWidth = 2,
}: {
fill?: string
stroke?: string
strokeWidth?: number
}): JSX.Element {
return (
<svg
@ -13,10 +15,11 @@ export function BoxIcon({
height="24"
viewBox="0 0 24 24"
stroke={stroke}
strokeWidth={strokeWidth}
fill={fill}
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>
)
}

View file

@ -1,7 +1,7 @@
import * as React from 'react'
export function CircleIcon(
props: Pick<React.SVGProps<SVGSVGElement>, 'stroke' | 'fill'> & {
props: Pick<React.SVGProps<SVGSVGElement>, 'strokeWidth' | 'stroke' | 'fill'> & {
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"
strokeWidth="2"
fill="currentColor"
opacity=".3"
opacity=".9"
/>
</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 './SizeMediumIcon'
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 SLOW_SPEED = 10
export const VERY_SLOW_SPEED = 2.5
export const GHOSTED_OPACITY = 0.3
export const DEAD_ZONE = 3
import type { Easing } from '~types'

View file

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

View file

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

View file

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

View file

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

View file

@ -2,8 +2,10 @@ import * as React from 'react'
const styles = new Map<string, HTMLStyleElement>()
const UID = `tldraw-fonts`
const CSS = `@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&display=swap')`
const UID = `Tldraw-fonts`
const CSS = `
@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&display=swap')
`
export function useStylesheet() {
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 { useTLDrawContext } from './useTLDrawContext'
import type { TDSnapshot, Theme } from '~types'
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() {
const { state, useSelector } = useTLDrawContext()
const theme = useSelector(themeSelector)
const app = useTldrawApp()
const theme = app.useStore(themeSelector)
return {
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 './state/shapes'
export { TLDrawState } from './state'
export { TldrawApp } from './state'
export { useFileSystem } from './hooks'

View file

@ -1,36 +1,36 @@
import { TLBounds, TLTransformInfo, Utils, TLPageState } from '@tldraw/core'
import {
TLDrawSnapshot,
TDSnapshot,
ShapeStyles,
ShapesWithProp,
TLDrawShape,
TLDrawBinding,
TLDrawPage,
TLDrawCommand,
TLDrawPatch,
TLDrawShapeType,
TDShape,
TDBinding,
TDPage,
TldrawCommand,
TldrawPatch,
TDShapeType,
ArrowShape,
} from '~types'
import { Vec } from '@tldraw/vec'
import type { TLDrawShapeUtil } from './shapes/TLDrawShapeUtil'
import { getShapeUtils } from './shapes'
import type { TDShapeUtil } from './shapes/TDShapeUtil'
import { getShapeUtil } from './shapes'
export class TLDR {
// 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
static getShapeUtils<T extends TLDrawShape>(shape: T): TLDrawShapeUtil<T>
static getShapeUtils<T extends TLDrawShape>(shape: T | T['type']) {
return getShapeUtils<T>(shape)
static getShapeUtil<T extends TDShape>(shape: T): TDShapeUtil<T>
static getShapeUtil<T extends TDShape>(shape: T | T['type']) {
return getShapeUtil<T>(shape)
}
static getSelectedShapes(data: TLDrawSnapshot, pageId: string) {
static getSelectedShapes(data: TDSnapshot, pageId: string) {
const page = TLDR.getPage(data, pageId)
const selectedIds = TLDR.getSelectedIds(data, pageId)
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
return Vec.sub(Vec.div(point, camera.zoom), camera.point)
}
@ -39,59 +39,59 @@ export class TLDR {
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]
}
static getPageState(data: TLDrawSnapshot, pageId: string): TLPageState {
static getPageState(data: TDSnapshot, pageId: string): TLPageState {
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
}
static getShapes(data: TLDrawSnapshot, pageId: string): TLDrawShape[] {
static getShapes(data: TDSnapshot, pageId: string): TDShape[] {
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
}
static getShape<T extends TLDrawShape = TLDrawShape>(
data: TLDrawSnapshot,
static getShape<T extends TDShape = TDShape>(
data: TDSnapshot,
shapeId: string,
pageId: string
): T {
return TLDR.getPage(data, pageId).shapes[shapeId] as T
}
static getCenter<T extends TLDrawShape>(shape: T) {
return TLDR.getShapeUtils(shape).getCenter(shape)
static getCenter<T extends TDShape>(shape: T) {
return TLDR.getShapeUtil(shape).getCenter(shape)
}
static getBounds<T extends TLDrawShape>(shape: T) {
return TLDR.getShapeUtils(shape).getBounds(shape)
static getBounds<T extends TDShape>(shape: T) {
return TLDR.getShapeUtil(shape).getBounds(shape)
}
static getRotatedBounds<T extends TLDrawShape>(shape: T) {
return TLDR.getShapeUtils(shape).getRotatedBounds(shape)
static getRotatedBounds<T extends TDShape>(shape: T) {
return TLDR.getShapeUtil(shape).getRotatedBounds(shape)
}
static getSelectedBounds(data: TLDrawSnapshot): TLBounds {
static getSelectedBounds(data: TDSnapshot): TLBounds {
return Utils.getCommonBounds(
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
}
// 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 pageState = TLDR.getPageState(data, data.appState.currentPageId)
// const shape = TLDR.getShape(data, id, pageId)
@ -102,7 +102,7 @@ export class TLDR {
// : 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 { currentPageId } = data.appState
// const { currentParentId, pointedId } = TLDR.getPageState(data, data.appState.currentPageId)
@ -114,7 +114,7 @@ export class TLDR {
// : 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 pageState = TLDR.getPageState(data, 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
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)
if (shape.children === undefined) return [id]
@ -142,16 +142,16 @@ export class TLDR {
// Get a deep array of unproxied shapes and their descendants
static getSelectedBranchSnapshot<K>(
data: TLDrawSnapshot,
data: TDSnapshot,
pageId: string,
fn: (shape: TLDrawShape) => K
fn: (shape: TDShape) => K
): ({ id: string } & K)[]
static getSelectedBranchSnapshot(data: TLDrawSnapshot, pageId: string): TLDrawShape[]
static getSelectedBranchSnapshot(data: TDSnapshot, pageId: string): TDShape[]
static getSelectedBranchSnapshot<K>(
data: TLDrawSnapshot,
data: TDSnapshot,
pageId: string,
fn?: (shape: TLDrawShape) => K
): (TLDrawShape | K)[] {
fn?: (shape: TDShape) => K
): (TDShape | K)[] {
const page = TLDR.getPage(data, pageId)
const copies = TLDR.getSelectedIds(data, pageId)
@ -167,17 +167,17 @@ export class TLDR {
}
// Get a shallow array of unproxied shapes
static getSelectedShapeSnapshot(data: TLDrawSnapshot, pageId: string): TLDrawShape[]
static getSelectedShapeSnapshot(data: TDSnapshot, pageId: string): TDShape[]
static getSelectedShapeSnapshot<K>(
data: TLDrawSnapshot,
data: TDSnapshot,
pageId: string,
fn?: (shape: TLDrawShape) => K
fn?: (shape: TDShape) => K
): ({ id: string } & K)[]
static getSelectedShapeSnapshot<K>(
data: TLDrawSnapshot,
data: TDSnapshot,
pageId: string,
fn?: (shape: TLDrawShape) => K
): (TLDrawShape | K)[] {
fn?: (shape: TDShape) => K
): (TDShape | K)[] {
const copies = TLDR.getSelectedShapes(data, pageId)
.filter((shape) => !shape.isLocked)
.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.
// 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 visited = new Set(ids)
@ -200,7 +200,7 @@ export class TLDR {
const shape = page.shapes[id]
// Add descendant shapes
function collectDescendants(shape: TLDrawShape): void {
function collectDescendants(shape: TDShape): void {
if (shape.children === undefined) return
shape.children
.filter((childId) => !visited.has(childId))
@ -213,7 +213,7 @@ export class TLDR {
collectDescendants(shape)
// Add asecendant shapes
function collectAscendants(shape: TLDrawShape): void {
function collectAscendants(shape: TDShape): void {
const parentId = shape.parentId
if (parentId === page.id) return
if (visited.has(parentId)) return
@ -236,47 +236,47 @@ export class TLDR {
}
static updateBindings(
data: TLDrawSnapshot,
data: TDSnapshot,
id: string,
beforeShapes: Record<string, Partial<TLDrawShape>> = {},
afterShapes: Record<string, Partial<TLDrawShape>> = {},
beforeShapes: Record<string, Partial<TDShape>> = {},
afterShapes: Record<string, Partial<TDShape>> = {},
pageId: string
): TLDrawSnapshot {
): TDSnapshot {
const page = { ...TLDR.getPage(data, pageId) }
return Object.values(page.bindings)
.filter((binding) => binding.fromId === id || binding.toId === id)
.reduce((cTLDrawSnapshot, binding) => {
.reduce((cTDSnapshot, binding) => {
if (!beforeShapes[binding.fromId]) {
beforeShapes[binding.fromId] = Utils.deepClone(
TLDR.getShape(cTLDrawSnapshot, binding.fromId, pageId)
TLDR.getShape(cTDSnapshot, binding.fromId, pageId)
)
}
if (!beforeShapes[binding.toId]) {
beforeShapes[binding.toId] = Utils.deepClone(
TLDR.getShape(cTLDrawSnapshot, binding.toId, pageId)
TLDR.getShape(cTDSnapshot, binding.toId, pageId)
)
}
TLDR.onBindingChange(
TLDR.getShape(cTLDrawSnapshot, binding.fromId, pageId),
TLDR.getShape(cTDSnapshot, binding.fromId, pageId),
binding,
TLDR.getShape(cTLDrawSnapshot, binding.toId, pageId)
TLDR.getShape(cTDSnapshot, binding.toId, pageId)
)
afterShapes[binding.fromId] = Utils.deepClone(
TLDR.getShape(cTLDrawSnapshot, binding.fromId, pageId)
TLDR.getShape(cTDSnapshot, binding.fromId, pageId)
)
afterShapes[binding.toId] = Utils.deepClone(
TLDR.getShape(cTLDrawSnapshot, binding.toId, pageId)
TLDR.getShape(cTDSnapshot, binding.toId, pageId)
)
return cTLDrawSnapshot
return cTDSnapshot
}, data)
}
static getLinkedShapes(
data: TLDrawSnapshot,
static getLinkedShapeIds(
data: TDSnapshot,
pageId: string,
direction: 'center' | 'left' | 'right',
includeArrows = true
@ -294,7 +294,7 @@ export class TLDR {
const arrows = new Set(
Object.values(page.shapes).filter((shape) => {
return (
shape.type === TLDrawShapeType.Arrow &&
shape.type === TDShapeType.Arrow &&
(shape.handles.start.bindingId || shape.handles?.end.bindingId)
)
}) as ArrowShape[]
@ -378,11 +378,11 @@ export class TLDR {
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 shape = page.shapes[id]
let siblings: TLDrawShape[]
let siblings: TDShape[]
if (shape.parentId === page.id) {
siblings = Object.values(page.shapes)
@ -409,27 +409,29 @@ export class TLDR {
/* 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(
Object.keys(change).map((k) => [k, shape[k as keyof T]])
) as Partial<T>
}
static mutateShapes<T extends TLDrawShape>(
data: TLDrawSnapshot,
static mutateShapes<T extends TDShape>(
data: TDSnapshot,
ids: string[],
fn: (shape: T, i: number) => Partial<T> | void,
pageId: string
): {
before: Record<string, Partial<T>>
after: Record<string, Partial<T>>
data: TLDrawSnapshot
data: TDSnapshot
} {
const beforeShapes: Record<string, Partial<T>> = {}
const afterShapes: Record<string, Partial<T>> = {}
ids.forEach((id, i) => {
const shape = TLDR.getShape<T>(data, id, pageId)
if (shape.isLocked) return
const change = fn(shape, i)
if (change) {
beforeShapes[id] = TLDR.getBeforeShape(shape, change)
@ -446,8 +448,8 @@ export class TLDR {
},
},
})
const dataWithBindingChanges = ids.reduce<TLDrawSnapshot>((cTLDrawSnapshot, id) => {
return TLDR.updateBindings(cTLDrawSnapshot, id, beforeShapes, afterShapes, pageId)
const dataWithBindingChanges = ids.reduce<TDSnapshot>((cTDSnapshot, id) => {
return TLDR.updateBindings(cTDSnapshot, id, beforeShapes, afterShapes, pageId)
}, dataWithMutations)
return {
@ -457,17 +459,15 @@ export class TLDR {
}
}
static createShapes(data: TLDrawSnapshot, shapes: TLDrawShape[], pageId: string): TLDrawCommand {
const before: TLDrawPatch = {
static createShapes(data: TDSnapshot, shapes: TDShape[], pageId: string): TldrawCommand {
const before: TldrawPatch = {
document: {
pages: {
[pageId]: {
shapes: {
...Object.fromEntries(
shapes.flatMap((shape) => {
const results: [string, Partial<TLDrawShape> | undefined][] = [
[shape.id, undefined],
]
const results: [string, Partial<TDShape> | undefined][] = [[shape.id, undefined]]
// If the shape is a child of another shape, also save that shape
if (shape.parentId !== pageId) {
@ -485,7 +485,7 @@ export class TLDR {
},
}
const after: TLDrawPatch = {
const after: TldrawPatch = {
document: {
pages: {
[pageId]: {
@ -493,9 +493,7 @@ export class TLDR {
shapes: {
...Object.fromEntries(
shapes.flatMap((shape) => {
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 a different shape, update its parent
if (shape.parentId !== pageId) {
@ -521,10 +519,10 @@ export class TLDR {
}
static deleteShapes(
data: TLDrawSnapshot,
shapes: TLDrawShape[] | string[],
data: TDSnapshot,
shapes: TDShape[] | string[],
pageId?: string
): TLDrawCommand {
): TldrawCommand {
pageId = pageId ? pageId : data.appState.currentPageId
const page = TLDR.getPage(data, pageId)
@ -532,9 +530,9 @@ export class TLDR {
const shapeIds =
typeof shapes[0] === '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: {
pages: {
[pageId]: {
@ -543,7 +541,7 @@ export class TLDR {
...Object.fromEntries(
shapeIds.flatMap((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 (shape.parentId !== pageId) {
@ -573,7 +571,7 @@ export class TLDR {
},
}
const after: TLDrawPatch = {
const after: TldrawPatch = {
document: {
pages: {
[pageId]: {
@ -581,9 +579,7 @@ export class TLDR {
...Object.fromEntries(
shapeIds.flatMap((id) => {
const shape = page.shapes[id]
const results: [string, Partial<TLDrawShape> | undefined][] = [
[shape.id, undefined],
]
const results: [string, Partial<TDShape> | undefined][] = [[shape.id, undefined]]
// If the shape is a child of a different shape, update its parent
if (shape.parentId !== page.id) {
@ -612,16 +608,16 @@ export class TLDR {
}
}
static onSessionComplete<T extends TLDrawShape>(shape: T) {
const delta = TLDR.getShapeUtils(shape).onSessionComplete?.(shape)
static onSessionComplete<T extends TDShape>(shape: T) {
const delta = TLDR.getShapeUtil(shape).onSessionComplete?.(shape)
if (!delta) return shape
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
const delta = TLDR.getShapeUtils(shape).onChildrenChange?.(
const delta = TLDR.getShapeUtil(shape).onChildrenChange?.(
shape,
shape.children.map((id) => TLDR.getShape(data, id, pageId))
)
@ -631,35 +627,27 @@ export class TLDR {
return { ...shape, ...delta }
}
static onBindingChange<T extends TLDrawShape>(
shape: T,
binding: TLDrawBinding,
otherShape: TLDrawShape
) {
const delta = TLDR.getShapeUtils(shape).onBindingChange?.(
static onBindingChange<T extends TDShape>(shape: T, binding: TDBinding, otherShape: TDShape) {
const delta = TLDR.getShapeUtil(shape).onBindingChange?.(
shape,
binding,
otherShape,
TLDR.getShapeUtils(otherShape).getBounds(otherShape),
TLDR.getShapeUtils(otherShape).getCenter(otherShape)
TLDR.getShapeUtil(otherShape).getBounds(otherShape),
TLDR.getShapeUtil(otherShape).getCenter(otherShape)
)
if (!delta) return shape
return { ...shape, ...delta }
}
static transform<T extends TLDrawShape>(shape: T, bounds: TLBounds, info: TLTransformInfo<T>) {
const delta = TLDR.getShapeUtils(shape).transform(shape, bounds, info)
static transform<T extends TDShape>(shape: T, bounds: TLBounds, info: TLTransformInfo<T>) {
const delta = TLDR.getShapeUtil(shape).transform(shape, bounds, info)
if (!delta) return shape
return { ...shape, ...delta }
}
static transformSingle<T extends TLDrawShape>(
shape: T,
bounds: TLBounds,
info: TLTransformInfo<T>
) {
const delta = TLDR.getShapeUtils(shape).transformSingle(shape, bounds, info)
static transformSingle<T extends TDShape>(shape: T, bounds: TLBounds, info: TLTransformInfo<T>) {
const delta = TLDR.getShapeUtil(shape).transformSingle(shape, bounds, info)
if (!delta) return shape
return { ...shape, ...delta }
}
@ -671,7 +659,7 @@ export class TLDR {
* @param origin the page point to rotate around.
* @param rotation the amount to rotate the shape.
*/
static getRotatedShapeMutation<T extends TLDrawShape>(
static getRotatedShapeMutation<T extends TDShape>(
shape: T, // in page space
center: number[], // in page space
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,
// because that makes a lot of other things incredible difficult.
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
{ ...shape, point: nextPoint },
Object.fromEntries(
@ -723,7 +711,7 @@ export class TLDR {
/* 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)
if (changedShapeIds.length === 0) return
@ -747,7 +735,7 @@ export class TLDR {
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 page = data.document.pages[pageId]
@ -788,27 +776,23 @@ export class TLDR {
/* 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]
}
static getBindings(data: TLDrawSnapshot, pageId: string): TLDrawBinding[] {
static getBindings(data: TDSnapshot, pageId: string): TDBinding[] {
const page = TLDR.getPage(data, pageId)
return Object.values(page.bindings)
}
static getBindableShapeIds(data: TLDrawSnapshot) {
static getBindableShapeIds(data: TDSnapshot) {
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)
.map((shape) => shape.id)
}
static getBindingsWithShapeIds(
data: TLDrawSnapshot,
ids: string[],
pageId: string
): TLDrawBinding[] {
static getBindingsWithShapeIds(data: TDSnapshot, ids: string[], pageId: string): TDBinding[] {
return Array.from(
new Set(
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 page = TLDR.getPage(data, pageId)
@ -897,7 +881,7 @@ export class TLDR {
/* Groups */
/* -------------------------------------------------- */
static flattenShape = (data: TLDrawSnapshot, shape: TLDrawShape): TLDrawShape[] => {
static flattenShape = (data: TDSnapshot, shape: TDShape): TDShape[] => {
return [
shape,
...(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)
.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)
return shapes.length === 0
? 1
@ -922,12 +906,23 @@ export class TLDR {
.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 */
/* -------------------------------------------------- */
static assertShapeHasProperty<P extends keyof TLDrawShape>(
shape: TLDrawShape,
static assertShapeHasProperty<P extends keyof TDShape>(
shape: TDShape,
prop: P
): asserts shape is ShapesWithProp<P> {
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
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 [
Object {
"after": Object {
@ -36,6 +99,7 @@ Array [
"color": "black",
"dash": "draw",
"isFilled": false,
"scale": 1,
"size": "small",
},
"type": "rectangle",
@ -96,6 +160,7 @@ Array [
"color": "black",
"dash": "draw",
"isFilled": false,
"scale": 1,
"size": "small",
},
"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 [
"rect1",
"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 { TLDrawState } from '~state'
import { mockDocument, TLDrawStateUtils } from '~test'
import { AlignType, TLDrawShapeType } from '~types'
import { mockDocument, TldrawTestApp } from '~test'
import { AlignType, TDShapeType } from '~types'
describe('Align command', () => {
const state = new TLDrawState()
const app = new TldrawTestApp()
describe('when less than two shapes are selected', () => {
it('does nothing', () => {
state.loadDocument(mockDocument).select('rect2')
const initialState = state.state
state.align(AlignType.Top)
const currentState = state.state
app.loadDocument(mockDocument).select('rect2')
const initialState = app.state
app.align(AlignType.Top)
const currentState = app.state
expect(currentState).toEqual(initialState)
})
@ -19,86 +18,89 @@ describe('Align command', () => {
describe('when multiple shapes are selected', () => {
beforeEach(() => {
state.loadDocument(mockDocument)
state.selectAll()
app.loadDocument(mockDocument)
app.selectAll()
})
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', () => {
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', () => {
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', () => {
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', () => {
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', () => {
state.align(AlignType.CenterHorizontal)
app.align(AlignType.CenterHorizontal)
expect(state.getShape('rect1').point).toEqual([50, 0])
expect(state.getShape('rect2').point).toEqual([50, 100])
expect(app.getShape('rect1').point).toEqual([50, 0])
expect(app.getShape('rect2').point).toEqual([50, 100])
})
it('aligns center vertical', () => {
state.align(AlignType.CenterVertical)
app.align(AlignType.CenterVertical)
expect(state.getShape('rect1').point).toEqual([0, 50])
expect(state.getShape('rect2').point).toEqual([100, 50])
expect(app.getShape('rect1').point).toEqual([0, 50])
expect(app.getShape('rect2').point).toEqual([100, 50])
})
})
})
describe('when aligning groups', () => {
it('aligns children', () => {
const state = new TLDrawState()
const app = new TldrawTestApp()
.createShapes(
{ id: 'rect1', type: TLDrawShapeType.Rectangle, point: [0, 0], size: [100, 100] },
{ id: 'rect2', type: TLDrawShapeType.Rectangle, point: [100, 100], size: [100, 100] },
{ id: 'rect3', type: TLDrawShapeType.Rectangle, point: [200, 200], size: [100, 100] },
{ id: 'rect4', type: TLDrawShapeType.Rectangle, point: [0, 0], size: [200, 200] }
{ id: 'rect1', type: TDShapeType.Rectangle, point: [0, 0], size: [100, 100] },
{ id: 'rect2', type: TDShapeType.Rectangle, point: [100, 100], size: [100, 100] },
{ id: 'rect3', type: TDShapeType.Rectangle, point: [200, 200], size: [100, 100] },
{ id: 'rect4', type: TDShapeType.Rectangle, point: [0, 0], size: [200, 200] }
)
.group(['rect1', 'rect2'], 'groupA')
.select('rect3', 'rect4')
.align(AlignType.CenterVertical)
const p0 = state.getShape('rect4').point
const p1 = state.getShape('rect3').point
const p0 = app.getShape('rect4').point
const p1 = app.getShape('rect3').point
state.undo().delete(['rect4']).selectAll().align(AlignType.CenterVertical)
new TLDrawStateUtils(state).expectShapesToBeAtPoints({
rect1: p0,
rect2: Vec.add(p0, [100, 100]),
rect3: p1,
})
app
.undo()
.delete(['rect4'])
.selectAll()
.align(AlignType.CenterVertical)
.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 */
import { Utils } from '@tldraw/core'
import { AlignType, TLDrawCommand, TLDrawShapeType } from '~types'
import type { TLDrawSnapshot } from '~types'
import { AlignType, TldrawCommand, TDShapeType } from '~types'
import type { TDSnapshot } from '~types'
import { TLDR } from '~state/TLDR'
import Vec from '@tldraw/vec'
import type { TldrawApp } from '../../internal'
export function alignShapes(data: TLDrawSnapshot, ids: string[], type: AlignType): TLDrawCommand {
const { currentPageId } = data.appState
export function alignShapes(app: TldrawApp, ids: string[], type: AlignType): TldrawCommand {
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) => {
return {
id: shape.id,
point: [...shape.point],
bounds: TLDR.getShapeUtils(shape).getBounds(shape),
bounds: TLDR.getBounds(shape),
}
})
const commonBounds = Utils.getCommonBounds(boundsForShapes.map(({ bounds }) => bounds))
const midX = commonBounds.minX + commonBounds.width / 2
const midY = commonBounds.minY + commonBounds.height / 2
const deltaMap = Object.fromEntries(
@ -44,7 +44,7 @@ export function alignShapes(data: TLDrawSnapshot, ids: string[], type: AlignType
)
const { before, after } = TLDR.mutateShapes(
data,
app.state,
ids,
(shape) => {
if (!deltaMap[shape.id]) return shape
@ -54,11 +54,11 @@ export function alignShapes(data: TLDrawSnapshot, ids: string[], type: AlignType
)
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!)
shape.children.forEach((id) => {
const child = TLDR.getShape(data, id, currentPageId)
const child = app.getShape(id)
before[child.id] = { point: child.point }
after[child.id] = { point: Vec.add(child.point, delta) }
})

View file

@ -1,32 +1,31 @@
import { TLDrawState } from '~state'
import { mockDocument } from '~test'
import { mockDocument, TldrawTestApp } from '~test'
describe('Change page command', () => {
const state = new TLDrawState()
const app = new TldrawTestApp()
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 {
id: 'change_page',
before: {
appState: {
currentPageId: data.appState.currentPageId,
currentPageId: app.currentPageId,
},
},
after: {

View file

@ -1,34 +1,33 @@
import { TLDrawState } from '~state'
import { mockDocument } from '~test'
import { mockDocument, TldrawTestApp } from '~test'
describe('Create page command', () => {
const state = new TLDrawState()
const app = new TldrawTestApp()
it('does, undoes and redoes command', () => {
state.loadDocument(mockDocument)
app.loadDocument(mockDocument)
const initialId = state.page.id
const initialPageState = state.pageState
const initialId = app.page.id
const initialPageState = app.pageState
state.createPage()
app.createPage()
const nextId = state.page.id
const nextPageState = state.pageState
const nextId = app.page.id
const nextPageState = app.pageState
expect(Object.keys(state.document.pages).length).toBe(2)
expect(state.page.id).toBe(nextId)
expect(state.pageState).toEqual(nextPageState)
expect(Object.keys(app.document.pages).length).toBe(2)
expect(app.page.id).toBe(nextId)
expect(app.pageState).toEqual(nextPageState)
state.undo()
app.undo()
expect(Object.keys(state.document.pages).length).toBe(1)
expect(state.page.id).toBe(initialId)
expect(state.pageState).toEqual(initialPageState)
expect(Object.keys(app.document.pages).length).toBe(1)
expect(app.page.id).toBe(initialId)
expect(app.pageState).toEqual(initialPageState)
state.redo()
app.redo()
expect(Object.keys(state.document.pages).length).toBe(2)
expect(state.page.id).toBe(nextId)
expect(state.pageState).toEqual(nextPageState)
expect(Object.keys(app.document.pages).length).toBe(2)
expect(app.page.id).toBe(nextId)
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 type { TldrawApp } from '~state'
export function createPage(
data: TLDrawSnapshot,
app: TldrawApp,
center: number[],
pageId = Utils.uniqueId()
): TLDrawCommand {
const { currentPageId } = data.appState
): TldrawCommand {
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)
)[0]
@ -17,7 +18,7 @@ export function createPage(
// TODO: Iterate the name better
const nextName = `New Page`
const page: TLDrawPage = {
const page: TDPage = {
id: pageId,
name: nextName,
childIndex: nextChildIndex,

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