[feature] filesystem + readonly (#218)

* Fix escape key for menu

* Adds filesystem support, readonly mode

* Move file system events to external hook

* Adds onSignIn callback, prevent event by default
This commit is contained in:
Steve Ruiz 2021-11-05 14:13:14 +00:00 committed by GitHub
parent 61ac6427fb
commit fb77323ef2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1132 additions and 208 deletions

View file

@ -3,8 +3,6 @@ import fs from 'fs'
import esbuild from 'esbuild'
import serve, { error, log } from 'create-serve'
const isDevServer = true
if (!fs.existsSync('./dist')) {
fs.mkdirSync('./dist')
}
@ -16,17 +14,18 @@ fs.copyFile('./src/index.html', './dist/index.html', (err) => {
esbuild
.build({
entryPoints: ['src/index.tsx'],
bundle: true,
outfile: 'dist/bundle.js',
outfile: 'dist/index.js',
minify: false,
bundle: true,
sourcemap: true,
incremental: isDevServer,
target: ['chrome58', 'firefox57', 'safari11', 'edge18'],
incremental: true,
format: 'esm',
target: 'esnext',
define: {
'process.env.LIVEBLOCKS_PUBLIC_API_KEY': process.env.LIVEBLOCKS_PUBLIC_API_KEY,
'process.env.NODE_ENV': isDevServer ? '"development"' : '"production"',
'process.env.NODE_ENV': '"development"',
},
watch: isDevServer && {
watch: {
onRebuild(err) {
serve.update()
err ? error('❌ Failed') : log('✅ Updated')
@ -35,10 +34,8 @@ esbuild
})
.catch(() => process.exit(1))
if (isDevServer) {
serve.start({
port: 5000,
root: './dist',
live: true,
})
}
serve.start({
port: 5000,
root: './dist',
live: true,
})

View file

@ -18,11 +18,9 @@
"src"
],
"sideEffects": false,
"peerDependencies": {
"react": ">=16.8",
"react-dom": "^16.8 || ^17.0"
},
"devDependencies": {
"react": ">=16.8",
"react-dom": "^16.8 || ^17.0",
"@liveblocks/client": "^0.12.1",
"@liveblocks/react": "^0.12.1",
"@tldraw/tldraw": "^0.1.2",
@ -39,4 +37,4 @@
"typescript": "4.2.3"
},
"gitHead": "a7dac0f83ad998e205c2aab58182cb4ba4e099a6"
}
}

View file

@ -1,6 +1,7 @@
import * as React from 'react'
import { Switch, Route, Link } from 'react-router-dom'
import Basic from './basic'
import ReadOnly from './readonly'
import Controlled from './controlled'
import Imperative from './imperative'
import Embedded from './embedded'
@ -16,6 +17,9 @@ export default function App(): JSX.Element {
<Route path="/basic">
<Basic />
</Route>
<Route path="/readonly">
<ReadOnly />
</Route>
<Route path="/controlled">
<Controlled />
</Route>
@ -39,6 +43,9 @@ export default function App(): JSX.Element {
<li>
<Link to="/basic">basic</Link>
</li>
<li>
<Link to="/readonly">readonly</Link>
</li>
<li>
<Link to="/controlled">controlled</Link>
</li>

View file

@ -1,9 +1,11 @@
import * as React from 'react'
import { TLDraw, TLDrawProps, TLDrawState } from '@tldraw/tldraw'
import { TLDraw, TLDrawProps, TLDrawState, useFileSystem } from '@tldraw/tldraw'
export default function Editor(props: TLDrawProps): JSX.Element {
const rTLDrawState = React.useRef<TLDrawState>()
const fileSystemEvents = useFileSystem()
const handleMount = React.useCallback((state: TLDrawState) => {
rTLDrawState.current = state
props.onMount?.(state)
@ -12,9 +14,25 @@ export default function Editor(props: TLDrawProps): JSX.Element {
window.tlstate = state
}, [])
const onSignIn = React.useCallback((state: TLDrawState) => {
// Sign in?
}, [])
const onSignOut = React.useCallback((state: TLDrawState) => {
// Sign out?
}, [])
return (
<div className="tldraw">
<TLDraw id="tldraw1" {...props} onMount={handleMount} autofocus />
<TLDraw
id="tldraw1"
{...props}
onMount={handleMount}
onSignIn={onSignIn}
onSignOut={onSignOut}
{...fileSystemEvents}
autofocus
/>
</div>
)
}

View file

@ -2,14 +2,14 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="bundle.css" />
<link rel="stylesheet" href="index.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>tldraw</title>
</head>
<body>
<div id="root"></div>
<noscript>You need to enable JavaScript to run this app.</noscript>
<script type="module" src="./bundle.js"></script>
<script type="module" src="./index.js"></script>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.

6
example/src/readonly.tsx Normal file
View file

@ -0,0 +1,6 @@
import * as React from 'react'
import Editor from './components/editor'
export default function Basic(): JSX.Element {
return <Editor readOnly />
}

View file

@ -41,6 +41,7 @@ async function main() {
'perfect-freehand',
'rko',
'react-hotkeys-hook',
'browser-fs-access',
],
metafile: true,
})
@ -74,6 +75,7 @@ async function main() {
'perfect-freehand',
'rko',
'react-hotkeys-hook',
'browser-fs-access',
],
metafile: true,
})

View file

@ -33,6 +33,7 @@ async function main() {
'perfect-freehand',
'rko',
'react-hotkeys-hook',
'browser-fs-access',
],
sourcemap: true,
incremental: true,

View file

@ -1,29 +1,17 @@
import * as React from 'react'
import { render } from '@testing-library/react'
import { render, waitFor } from '@testing-library/react'
import { TLDraw } from './TLDraw'
describe('tldraw', () => {
test('mounts component without crashing', () => {
render(<TLDraw />)
})
test('mounts component and calls onMount', (done) => {
test('mounts component and calls onMount', async () => {
const onMount = jest.fn()
render(<TLDraw onMount={onMount} />)
// The call is asynchronous: it won't be called until the next tick.
setTimeout(() => {
expect(onMount).toHaveBeenCalled()
done()
}, 100)
await waitFor(onMount)
})
test('mounts component and calls onMount when id is present', (done) => {
test('mounts component and calls onMount when id is present', async () => {
const onMount = jest.fn()
render(<TLDraw id="someId" onMount={onMount} />)
// The call is asynchronous: it won't be called until the next tick.
setTimeout(() => {
expect(onMount).toHaveBeenCalled()
done()
}, 100)
await waitFor(onMount)
})
})

View file

@ -4,7 +4,13 @@ import { Renderer } from '@tldraw/core'
import styled, { dark } from '~styles'
import { Data, TLDrawDocument, TLDrawStatus, TLDrawUser } from '~types'
import { TLDrawState } from '~state'
import { TLDrawContext, useCustomFonts, useKeyboardShortcuts, useTLDrawContext } from '~hooks'
import {
TLDrawContext,
TLDrawContextType,
useCustomFonts,
useKeyboardShortcuts,
useTLDrawContext,
} from '~hooks'
import { shapeUtils } from '~shape-utils'
import { ToolsPanel } from '~components/ToolsPanel'
import { TopPanel } from '~components/TopPanel'
@ -85,6 +91,11 @@ export interface TLDrawProps {
*/
showUI?: boolean
/**
* (optional) Whether to the document should be read only.
*/
readOnly?: boolean
/**
* (optional) A callback to run when the component mounts.
*/
@ -95,6 +106,33 @@ export interface TLDrawProps {
*/
onChange?: TLDrawState['_onChange']
/**
* (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
/**
* (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
/**
* (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
/**
* (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
/**
* (optional) A callback to run when the user signs in via the menu.
*/
onSignIn?: (state: TLDrawState) => void
/**
* (optional) A callback to run when the user signs out via the menu.
*/
onSignOut?: (state: TLDrawState) => void
/**
* (optional) A callback to run when the user creates a new project.
*/
onUserChange?: (state: TLDrawState, user: TLDrawUser) => void
}
@ -109,26 +147,68 @@ export function TLDraw({
showZoom = true,
showStyles = true,
showUI = true,
readOnly = false,
onMount,
onChange,
onUserChange,
onNewProject,
onSaveProject,
onSaveProjectAs,
onOpenProject,
onSignOut,
onSignIn,
}: TLDrawProps) {
const [sId, setSId] = React.useState(id)
const [tlstate, setTlstate] = React.useState(
() => new TLDrawState(id, onMount, onChange, onUserChange)
)
const [context, setContext] = React.useState(() => ({ tlstate, useSelector: tlstate.useStore }))
const [context, setContext] = React.useState<TLDrawContextType>(() => ({
tlstate,
useSelector: tlstate.useStore,
callbacks: {
onNewProject,
onSaveProject,
onSaveProjectAs,
onOpenProject,
onSignIn,
onSignOut,
},
}))
React.useEffect(() => {
if (id === sId) return
// If a new id is loaded, replace the entire state
setSId(id)
const newState = new TLDrawState(id, onMount, onChange, onUserChange)
setTlstate(newState)
setContext({ tlstate: newState, useSelector: newState.useStore })
setSId(id)
setContext((ctx) => ({
...ctx,
tlstate: newState,
useSelector: newState.useStore,
}))
}, [sId, id])
// Update the callbacks when any callback changes
React.useEffect(() => {
setContext((ctx) => ({
...ctx,
callbacks: {
onNewProject,
onSaveProject,
onSaveProjectAs,
onOpenProject,
onSignIn,
onSignOut,
},
}))
}, [onNewProject, onSaveProject, onSaveProjectAs, onOpenProject, onSignIn, onSignOut])
React.useEffect(() => {
tlstate.readOnly = readOnly
}, [tlstate, readOnly])
// Use the `key` to ensure that new selector hooks are made when the id changes
return (
<TLDrawContext.Provider value={context}>
@ -145,6 +225,7 @@ export function TLDraw({
showZoom={showZoom}
showTools={showTools}
showUI={showUI}
readOnly={readOnly}
/>
</IdProvider>
</TLDrawContext.Provider>
@ -161,6 +242,7 @@ interface InnerTLDrawProps {
showStyles: boolean
showUI: boolean
showTools: boolean
readOnly: boolean
document?: TLDrawDocument
}
@ -173,6 +255,7 @@ function InnerTldraw({
showZoom,
showStyles,
showTools,
readOnly,
showUI,
document,
}: InnerTLDrawProps) {
@ -250,12 +333,12 @@ function InnerTldraw({
<Renderer
id={id}
containerRef={rWrapper}
shapeUtils={shapeUtils}
page={page}
pageState={pageState}
snapLines={snapLines}
users={users}
userId={tlstate.state.room?.userId}
shapeUtils={shapeUtils}
theme={theme}
meta={meta}
hideBounds={hideBounds}
@ -322,13 +405,14 @@ function InnerTldraw({
) : (
<>
<TopPanel
readOnly={readOnly}
showPages={showPages}
showMenu={showMenu}
showZoom={showZoom}
showStyles={showStyles}
showZoom={showZoom}
/>
<StyledSpacer />
{showTools && <ToolsPanel />}
{showTools && !readOnly && <ToolsPanel />}
</>
)}
</StyledUI>

View file

@ -41,6 +41,8 @@ const hasGroupSelectedSelector = (s: Data) => {
)
}
const preventDefault = (e: Event) => e.stopPropagation()
interface ContextMenuProps {
children: React.ReactNode
}
@ -118,7 +120,7 @@ export const ContextMenu = ({ children }: ContextMenuProps): JSX.Element => {
return (
<RadixContextMenu.Root>
<RadixContextMenu.Trigger dir="ltr">{children}</RadixContextMenu.Trigger>
<RadixContextMenu.Content dir="ltr" ref={rContent} asChild>
<RadixContextMenu.Content dir="ltr" ref={rContent} onEscapeKeyDown={preventDefault} asChild>
<MenuContent>
{hasSelection ? (
<>

View file

@ -9,9 +9,11 @@ export interface DMContentProps {
children: React.ReactNode
}
const preventDefault = (e: Event) => e.stopPropagation()
export function DMContent({ children, align, variant }: DMContentProps): JSX.Element {
return (
<Content sideOffset={8} dir="ltr" asChild align={align}>
<Content sideOffset={8} dir="ltr" asChild align={align} onEscapeKeyDown={preventDefault}>
<StyledContent variant={variant}>{children}</StyledContent>
</Content>
)

View file

@ -53,7 +53,7 @@ export const PrimaryTools = React.memo((): JSX.Element => {
<ToolButtonWithTooltip
kbd={'2'}
label={'select'}
onSelect={selectSelectTool}
onClick={selectSelectTool}
isActive={activeTool === 'select'}
>
<CursorArrowIcon />
@ -61,7 +61,7 @@ export const PrimaryTools = React.memo((): JSX.Element => {
<ToolButtonWithTooltip
kbd={'2'}
label={TLDrawShapeType.Draw}
onSelect={selectDrawTool}
onClick={selectDrawTool}
isActive={activeTool === TLDrawShapeType.Draw}
>
<Pencil1Icon />
@ -69,7 +69,7 @@ export const PrimaryTools = React.memo((): JSX.Element => {
<ToolButtonWithTooltip
kbd={'3'}
label={TLDrawShapeType.Rectangle}
onSelect={selectRectangleTool}
onClick={selectRectangleTool}
isActive={activeTool === TLDrawShapeType.Rectangle}
>
<SquareIcon />
@ -77,7 +77,7 @@ export const PrimaryTools = React.memo((): JSX.Element => {
<ToolButtonWithTooltip
kbd={'4'}
label={TLDrawShapeType.Draw}
onSelect={selectEllipseTool}
onClick={selectEllipseTool}
isActive={activeTool === TLDrawShapeType.Ellipse}
>
<CircleIcon />
@ -85,7 +85,7 @@ export const PrimaryTools = React.memo((): JSX.Element => {
<ToolButtonWithTooltip
kbd={'5'}
label={TLDrawShapeType.Arrow}
onSelect={selectArrowTool}
onClick={selectArrowTool}
isActive={activeTool === TLDrawShapeType.Arrow}
>
<ArrowTopRightIcon />
@ -93,7 +93,7 @@ export const PrimaryTools = React.memo((): JSX.Element => {
<ToolButtonWithTooltip
kbd={'6'}
label={TLDrawShapeType.Text}
onSelect={selectTextTool}
onClick={selectTextTool}
isActive={activeTool === TLDrawShapeType.Text}
>
<TextIcon />
@ -101,7 +101,7 @@ export const PrimaryTools = React.memo((): JSX.Element => {
<ToolButtonWithTooltip
kbd={'7'}
label={TLDrawShapeType.Sticky}
onSelect={selectStickyTool}
onClick={selectStickyTool}
isActive={activeTool === TLDrawShapeType.Sticky}
>
<Pencil2Icon />

View file

@ -9,7 +9,6 @@ import { BoxIcon } from '~components/icons'
import { ToolButton } from '~components/ToolButton'
const selectColor = (s: Data) => s.appState.selectedStyle.color
const preventEvent = (e: Event) => e.preventDefault()
export const ColorMenu = React.memo((): JSX.Element => {

View file

@ -5,26 +5,23 @@ import { useTLDrawContext } from '~hooks'
import { PreferencesMenu } from './PreferencesMenu'
import { DMItem, DMContent, DMDivider, DMSubMenu, DMTriggerIcon } from '~components/DropdownMenu'
import { SmallIcon } from '~components/SmallIcon'
import { useFileSystemHandlers } from '~hooks'
export const Menu = React.memo(() => {
const { tlstate } = useTLDrawContext()
interface MenuProps {
readOnly: boolean
}
const handleNew = React.useCallback(() => {
if (window.confirm('Are you sure you want to start a new project?')) {
tlstate.newProject()
}
}, [tlstate])
export const Menu = React.memo(({ readOnly }: MenuProps) => {
const { tlstate, callbacks } = useTLDrawContext()
const handleSave = React.useCallback(() => {
tlstate.saveProject()
}, [tlstate])
const { onNewProject, onOpenProject, onSaveProject, onSaveProjectAs } = useFileSystemHandlers()
const handleLoad = React.useCallback(() => {
tlstate.loadProject()
const handleSignIn = React.useCallback(() => {
callbacks.onSignIn?.(tlstate)
}, [tlstate])
const handleSignOut = React.useCallback(() => {
tlstate.signOut()
callbacks.onSignOut?.(tlstate)
}, [tlstate])
const handleCopy = React.useCallback(() => {
@ -51,60 +48,90 @@ export const Menu = React.memo(() => {
tlstate.deselectAll()
}, [tlstate])
const showFileMenu =
callbacks.onNewProject ||
callbacks.onOpenProject ||
callbacks.onSaveProject ||
callbacks.onSaveProjectAs
const showSignInOutMenu = callbacks.onSignIn || callbacks.onSignOut
return (
<DropdownMenu.Root>
<DMTriggerIcon>
<HamburgerMenuIcon />
</DMTriggerIcon>
<DMContent variant="menu">
<DMSubMenu label="File...">
<DMItem onSelect={handleNew} kbd="#N">
New Project
</DMItem>
<DMItem disabled onSelect={handleLoad} kbd="#L">
Open...
</DMItem>
<DMItem disabled onSelect={handleSave} kbd="#S">
Save
</DMItem>
<DMItem disabled onSelect={handleSave} kbd="⇧#S">
Save As...
</DMItem>
</DMSubMenu>
<DMSubMenu label="Edit...">
<DMItem onSelect={tlstate.undo} kbd="#Z">
Undo
</DMItem>
<DMItem onSelect={tlstate.redo} kbd="#⇧Z">
Redo
</DMItem>
<DMDivider dir="ltr" />
<DMItem onSelect={handleCopy} kbd="#C">
Copy
</DMItem>
<DMItem onSelect={handlePaste} kbd="#V">
Paste
</DMItem>
<DMDivider dir="ltr" />
<DMItem onSelect={handleCopySvg} kbd="#⇧C">
Copy as SVG
</DMItem>
<DMItem onSelect={handleCopyJson}>Copy as JSON</DMItem>
<DMDivider dir="ltr" />
<DMItem onSelect={handleSelectAll} kbd="#A">
Select All
</DMItem>
<DMItem onSelect={handleDeselectAll}>Select None</DMItem>
</DMSubMenu>
<DMDivider dir="ltr" />
{showFileMenu && (
<DMSubMenu label="File...">
{callbacks.onNewProject && (
<DMItem onSelect={onNewProject} kbd="#N">
New Project
</DMItem>
)}
{callbacks.onOpenProject && (
<DMItem onSelect={onOpenProject} kbd="#L">
Open...
</DMItem>
)}
{callbacks.onSaveProject && (
<DMItem onSelect={onSaveProject} kbd="#S">
Save
</DMItem>
)}
{callbacks.onSaveProjectAs && (
<DMItem onSelect={onSaveProjectAs} kbd="⇧#S">
Save As...
</DMItem>
)}
</DMSubMenu>
)}
{!readOnly && (
<>
{' '}
<DMSubMenu label="Edit...">
<DMItem onSelect={tlstate.undo} kbd="#Z">
Undo
</DMItem>
<DMItem onSelect={tlstate.redo} kbd="#⇧Z">
Redo
</DMItem>
<DMDivider dir="ltr" />
<DMItem onSelect={handleCopy} kbd="#C">
Copy
</DMItem>
<DMItem onSelect={handlePaste} kbd="#V">
Paste
</DMItem>
<DMDivider dir="ltr" />
<DMItem onSelect={handleCopySvg} kbd="#⇧C">
Copy as SVG
</DMItem>
<DMItem onSelect={handleCopyJson}>Copy as JSON</DMItem>
<DMDivider dir="ltr" />
<DMItem onSelect={handleSelectAll} kbd="#A">
Select All
</DMItem>
<DMItem onSelect={handleDeselectAll}>Select None</DMItem>
</DMSubMenu>
<DMDivider dir="ltr" />
</>
)}
<PreferencesMenu />
<DMDivider dir="ltr" />
<DMItem disabled onSelect={handleSignOut}>
Sign Out
<SmallIcon>
<ExitIcon />
</SmallIcon>
</DMItem>
{showSignInOutMenu && (
<>
<DMDivider dir="ltr" />{' '}
{callbacks.onSignIn && <DMItem onSelect={handleSignOut}>Sign In</DMItem>}
{callbacks.onSignOut && (
<DMItem onSelect={handleSignOut}>
Sign Out
<SmallIcon>
<ExitIcon />
</SmallIcon>
</DMItem>
)}
</>
)}
</DMContent>
</DropdownMenu.Root>
)

View file

@ -2,7 +2,7 @@ import * as React from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { Data, SizeStyle } from '~types'
import { useTLDrawContext } from '~hooks'
import { DMContent, DMItem, DMTriggerIcon } from '~components/DropdownMenu'
import { DMContent, DMTriggerIcon } from '~components/DropdownMenu'
import { ToolButton } from '~components/ToolButton'
import { SizeSmallIcon, SizeMediumIcon, SizeLargeIcon } from '~components/icons'

View file

@ -10,25 +10,26 @@ import { ColorMenu } from './ColorMenu'
import { Panel } from '~components/Panel'
interface TopPanelProps {
readOnly: boolean
showPages: boolean
showMenu: boolean
showStyles: boolean
showZoom: boolean
}
export function TopPanel({ showPages, showMenu, showStyles, showZoom }: TopPanelProps) {
export function TopPanel({ readOnly, showPages, showMenu, showStyles, showZoom }: TopPanelProps) {
return (
<StyledTopPanel>
{(showMenu || showPages) && (
<Panel side="left">
{showMenu && <Menu />}
{showMenu && <Menu readOnly={readOnly} />}
{showPages && <PageMenu />}
</Panel>
)}
<StyledSpacer />
{(showStyles || showZoom) && (
<Panel side="right">
{showStyles && (
{showStyles && !readOnly && (
<>
<ColorMenu />
<SizeMenu />

View file

@ -2,3 +2,5 @@ export * from './useKeyboardShortcuts'
export * from './useTLDrawContext'
export * from './useTheme'
export * from './useCustomFonts'
export * from './useFileSystemHandlers'
export * from './useFileSystem'

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import * as React from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { TLDrawShapeType } from '~types'
import { useTLDrawContext } from '~hooks'
import { useFileSystemHandlers, useTLDrawContext } from '~hooks'
export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
const { tlstate } = useTLDrawContext()
@ -102,13 +102,46 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
[tlstate]
)
// Save
// File System
const { onNewProject, onOpenProject, onSaveProject, onSaveProjectAs } = useFileSystemHandlers()
useHotkeys(
'ctrl+n,command+n',
(e) => {
if (canHandleEvent()) {
onNewProject(e)
}
},
undefined,
[tlstate]
)
useHotkeys(
'ctrl+s,command+s',
(e) => {
if (canHandleEvent()) {
onSaveProject(e)
}
},
undefined,
[tlstate]
)
useHotkeys(
'ctrl+shift+s,command+shift+s',
() => {
(e) => {
if (canHandleEvent()) {
tlstate.saveProject()
onSaveProjectAs(e)
}
},
undefined,
[tlstate]
)
useHotkeys(
'ctrl+o,command+o',
(e) => {
if (canHandleEvent()) {
onOpenProject(e)
}
},
undefined,
@ -453,7 +486,9 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
'command+shift+backspace',
(e) => {
if (canHandleEvent()) {
tlstate.resetDocument()
if (process.env.NODE_ENV === 'development') {
tlstate.resetDocument()
}
e.preventDefault()
}
},

View file

@ -1,11 +1,19 @@
import * as React from 'react'
import type { Data } from '~types'
import type { UseStore } from 'zustand'
import type { UseBoundStore } from 'zustand'
import type { TLDrawState } from '~state'
export interface TLDrawContextType {
tlstate: TLDrawState
useSelector: UseStore<Data>
useSelector: UseBoundStore<Data>
callbacks: {
onNewProject?: (tlstate: TLDrawState) => void
onSaveProject?: (tlstate: TLDrawState) => void
onSaveProjectAs?: (tlstate: TLDrawState) => void
onOpenProject?: (tlstate: TLDrawState) => void
onSignIn?: (tlstate: TLDrawState) => void
onSignOut?: (tlstate: TLDrawState) => void
}
}
export const TLDrawContext = React.createContext<TLDrawContextType>({} as TLDrawContextType)

View file

@ -2,3 +2,4 @@ export * from './TLDraw'
export * from './types'
export * from './shape-utils'
export { TLDrawState } from './state'
export { useFileSystem } from './hooks'

View file

@ -0,0 +1,99 @@
/**
* Opens file(s) from disk.
*/
export function fileOpen<M extends boolean | undefined = false>(options?: {
/** Acceptable MIME types. [] */
mimeTypes?: string[]
/** Acceptable file extensions. Defaults to "". */
extensions?: string[]
/** Suggested file description. Defaults to "". */
description?: string
/** Allow multiple files to be selected. Defaults to false. */
multiple?: M
/**
* Configurable cleanup and `Promise` rejector usable with legacy API for
* determining when (and reacting if) a user cancels the operation. The
* method will be passed a reference to the internal `rejectionHandler` that
* can, e.g., be attached to/removed from the window or called after a
* timeout. The method should return a function that will be called when
* either the user chooses to open a file or the `rejectionHandler` is
* called. In the latter case, the returned function will also be passed a
* reference to the `reject` callback for the `Promise` returned by
* `fileOpen`, so that developers may reject the `Promise` when desired at
* that time.
* ToDo: Remove this workaround once
* https://github.com/whatwg/html/issues/6376 is specified and supported.
*/
setupLegacyCleanupAndRejection?: (
rejectionHandler?: () => void
) => (reject: (reason?: any) => void) => void
}): M extends false | undefined ? Promise<FileWithHandle> : Promise<FileWithHandle[]>
/**
* Saves a file to disk.
* @returns Optional file handle to save in place.
*/
export function fileSave(
/** To-be-saved blob */
blob: Blob,
options?: {
/** Suggested file name. Defaults to "Untitled". */
fileName?: string
/** Suggested file extensions. Defaults to [""]. */
extensions?: string[]
/** Suggested file description. Defaults to "". */
description?: string
},
/**
* A potentially existing file handle for a file to save to. Defaults to
* null.
*/
existingHandle?: FileSystemHandle | null,
/**
* Determines whether to throw (rather than open a new file save dialog)
* when existingHandle is no longer good. Defaults to false.
*/
throwIfExistingHandleNotGood?: boolean | false
): Promise<FileSystemHandle>
/**
* Opens a directory from disk using the File System Access API.
* @returns Contained files.
*/
export function directoryOpen(options?: {
/** Whether to recursively get subdirectories. */
recursive: boolean
}): Promise<FileWithDirectoryHandle[]>
/**
* Whether the File System Access API is supported.
*/
export const supported: boolean
export function imageToBlob(img: HTMLImageElement): Promise<Blob>
export interface FileWithHandle extends File {
handle?: FileSystemHandle
}
export interface FileWithDirectoryHandle extends File {
directoryHandle?: FileSystemHandle
}
// The following typings implement the relevant parts of the File System Access
// API. This can be removed once the specification reaches the Candidate phase
// and is implemented as part of microsoft/TSJS-lib-generator.
export interface FileSystemHandlePermissionDescriptor {
mode?: 'read' | 'readwrite'
}
export interface FileSystemHandle {
readonly kind: 'file' | 'directory'
readonly name: string
isSameEntry: (other: FileSystemHandle) => Promise<boolean>
queryPermission: (descriptor?: FileSystemHandlePermissionDescriptor) => Promise<PermissionState>
requestPermission: (descriptor?: FileSystemHandlePermissionDescriptor) => Promise<PermissionState>
}

View file

@ -0,0 +1,334 @@
/* eslint-disable */
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
var w = Object.defineProperty
var q = (e) => w(e, '__esModule', { value: !0 })
var u = (e, t) => () => e && (t = e((e = 0))),
t
var p = (e, t) => {
q(e)
for (var i in t) w(e, i, { get: t[i], enumerable: !0 })
}
var r = (e, t, i) =>
new Promise((d, a) => {
var l = (s) => {
try {
n(i.next(s))
} catch (o) {
a(o)
}
},
c = (s) => {
try {
n(i.throw(s))
} catch (o) {
a(o)
}
},
n = (s) => (s.done ? d(s.value) : Promise.resolve(s.value).then(l, c))
n((i = i.apply(e, t)).next())
})
var y = {}
p(y, { default: () => z })
var z,
v = u(() => {
z = (...t) =>
r(void 0, [...t], function* (e = {}) {
return new Promise((i, d) => {
let a = document.createElement('input')
a.type = 'file'
let l = [...(e.mimeTypes ? e.mimeTypes : []), e.extensions ? e.extensions : []].join()
;(a.multiple = e.multiple || !1), (a.accept = l || '')
let c,
n = () => c(d)
e.setupLegacyCleanupAndRejection
? (c = e.setupLegacyCleanupAndRejection(n))
: ((c = (s) => {
window.removeEventListener('pointermove', n),
window.removeEventListener('pointerdown', n),
window.removeEventListener('keydown', n),
s && s(new DOMException('The user aborted a request.', 'AbortError'))
}),
window.addEventListener('pointermove', n),
window.addEventListener('pointerdown', n),
window.addEventListener('keydown', n)),
a.addEventListener('change', () => {
c(), i(a.multiple ? Array.from(a.files) : a.files[0])
}),
a.click()
})
})
})
var E = {}
p(E, { default: () => B })
var h,
B,
j = u(() => {
;(h = (e) =>
r(void 0, null, function* () {
let t = yield e.getFile()
return (t.handle = e), t
})),
(B = (...t) =>
r(void 0, [...t], function* (e = {}) {
let i = yield window.chooseFileSystemEntries({
accepts: [
{
description: e.description || '',
mimeTypes: e.mimeTypes || ['*/*'],
extensions: e.extensions || [''],
},
],
multiple: e.multiple || !1,
})
return e.multiple ? Promise.all(i.map(h)) : h(i)
}))
})
var g = {}
p(g, { default: () => I })
var G,
I,
x = u(() => {
;(G = (e) =>
r(void 0, null, function* () {
let t = yield e.getFile()
return (t.handle = e), t
})),
(I = (...t) =>
r(void 0, [...t], function* (e = {}) {
let i = {}
e.mimeTypes
? e.mimeTypes.map((l) => {
i[l] = e.extensions || []
})
: (i['*/*'] = e.extensions || [])
let d = yield window.showOpenFilePicker({
types: [{ description: e.description || '', accept: i }],
multiple: e.multiple || !1,
}),
a = yield Promise.all(d.map(G))
return e.multiple ? a : a[0]
}))
})
var F = {}
p(F, { default: () => K })
var K,
k = u(() => {
K = (...t) =>
r(void 0, [...t], function* (e = {}) {
return (
(e.recursive = e.recursive || !1),
new Promise((i, d) => {
let a = document.createElement('input')
;(a.type = 'file'), (a.webkitdirectory = !0)
let l,
c = () => l(d)
e.setupLegacyCleanupAndRejection
? (l = e.setupLegacyCleanupAndRejection(c))
: ((l = (n) => {
window.removeEventListener('pointermove', c),
window.removeEventListener('pointerdown', c),
window.removeEventListener('keydown', c),
n && n(new DOMException('The user aborted a request.', 'AbortError'))
}),
window.addEventListener('pointermove', c),
window.addEventListener('pointerdown', c),
window.addEventListener('keydown', c)),
a.addEventListener('change', () => {
l()
let n = Array.from(a.files)
e.recursive || (n = n.filter((s) => s.webkitRelativePath.split('/').length === 2)),
i(n)
}),
a.click()
})
)
})
})
var b = {}
p(b, { default: () => Q })
var P,
Q,
O = u(() => {
;(P = (d, a, ...l) =>
r(void 0, [d, a, ...l], function* (e, t, i = e.name) {
let c = [],
n = []
for (let s of e.getEntries()) {
let o = `${i}/${s.name}`
s.isFile
? n.push(
yield s.getFile().then(
(f) => (
(f.directoryHandle = e),
Object.defineProperty(f, 'webkitRelativePath', {
configurable: !0,
enumerable: !0,
get: () => o,
})
)
)
)
: s.isDirectory && t && c.push(yield P(s, t, o))
}
return [...(yield Promise.all(c)).flat(), ...(yield Promise.all(n))]
})),
(Q = (...t) =>
r(void 0, [...t], function* (e = {}) {
e.recursive = e.recursive || !1
let i = yield window.chooseFileSystemEntries({ type: 'open-directory' })
return P(i, e.recursive)
}))
})
var T = {}
p(T, { default: () => V })
var R,
V,
S = u(() => {
;(R = (d, a, ...l) =>
r(void 0, [d, a, ...l], function* (e, t, i = e.name) {
let c = [],
n = []
for (let s of e.values()) {
let o = `${i}/${s.name}`
s.kind === 'file'
? n.push(
yield s.getFile().then(
(f) => (
(f.directoryHandle = e),
Object.defineProperty(f, 'webkitRelativePath', {
configurable: !0,
enumerable: !0,
get: () => o,
})
)
)
)
: s.kind === 'directory' && t && c.push(yield R(s, t, o))
}
return [...(yield Promise.all(c)).flat(), ...(yield Promise.all(n))]
})),
(V = (...t) =>
r(void 0, [...t], function* (e = {}) {
e.recursive = e.recursive || !1
let i = yield window.showDirectoryPicker()
return R(i, e.recursive)
}))
})
var N = {}
p(N, { default: () => Y })
var Y,
U = u(() => {
Y = (i, ...d) =>
r(void 0, [i, ...d], function* (e, t = {}) {
let a = document.createElement('a')
;(a.download = t.fileName || 'Untitled'),
(a.href = URL.createObjectURL(e)),
a.addEventListener('click', () => {
setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1e3)
}),
a.click()
})
})
var C = {}
p(C, { default: () => Z })
var Z,
D = u(() => {
Z = (d, ...a) =>
r(void 0, [d, ...a], function* (e, t = {}, i = null) {
;(t.fileName = t.fileName || 'Untitled'),
(i =
i ||
(yield window.chooseFileSystemEntries({
type: 'save-file',
accepts: [
{
description: t.description || '',
mimeTypes: [e.type],
extensions: t.extensions || [''],
},
],
})))
let l = yield i.createWritable()
return yield l.write(e), yield l.close(), i
})
})
var M = {}
p(M, { default: () => _ })
var _,
W = u(() => {
_ = (a, ...l) =>
r(void 0, [a, ...l], function* (e, t = {}, i = null, d = !1) {
t.fileName = t.fileName || 'Untitled'
let c = {}
if (
(t.mimeTypes
? (t.mimeTypes.push(e.type),
t.mimeTypes.map((o) => {
c[o] = t.extensions || []
}))
: (c[e.type] = t.extensions || []),
i)
)
try {
yield i.getFile()
} catch (o) {
if (((i = null), d)) throw o
}
let n =
i ||
(yield window.showSaveFilePicker({
suggestedName: t.fileName,
types: [{ description: t.description || '', accept: c }],
})),
s = yield n.createWritable()
return yield s.write(e), yield s.close(), n
})
})
p(exports, { directoryOpen: () => A, fileOpen: () => L, fileSave: () => $, supported: () => m })
var H = (() => {
if ('top' in self && self !== top)
try {
top.location + ''
} catch (e) {
return !1
}
else {
if ('chooseFileSystemEntries' in self) return 'chooseFileSystemEntries'
if ('showOpenFilePicker' in self) return 'showOpenFilePicker'
}
return !1
})(),
m = H
var J = m
? m === 'chooseFileSystemEntries'
? Promise.resolve().then(() => (j(), E))
: Promise.resolve().then(() => (x(), g))
: Promise.resolve().then(() => (v(), y))
function L(...e) {
return r(this, null, function* () {
return (yield J).default(...e)
})
}
var X = m
? m === 'chooseFileSystemEntries'
? Promise.resolve().then(() => (O(), b))
: Promise.resolve().then(() => (S(), T))
: Promise.resolve().then(() => (k(), F))
function A(...e) {
return r(this, null, function* () {
return (yield X).default(...e)
})
}
var ee = m
? m === 'chooseFileSystemEntries'
? Promise.resolve().then(() => (D(), C))
: Promise.resolve().then(() => (W(), M))
: Promise.resolve().then(() => (U(), N))
function $(...e) {
return r(this, null, function* () {
return (yield ee).default(...e)
})
}
// @license © 2020 Google LLC. Licensed under the Apache License, Version 2.0.

View file

@ -0,0 +1,10 @@
describe('when saving data to the file system', () => {
it.todo('saves a new file in the filesystem')
it.todo('saves a new file in the filesystem')
})
describe('when opening files from file system', () => {
it.todo('opens a file and loads it into the document')
it.todo('opens an older file, migrates it, and loads it into the document')
it.todo('opens a corrupt file, tries to fix it, and fails without crashing')
})

View file

@ -0,0 +1,101 @@
import type { TLDrawDocument, TLDrawFile } from '~types'
import { fileSave, fileOpen, FileSystemHandle } from './browser-fs-access'
import { get as getFromIdb, set as setToIdb } from 'idb-keyval'
const options = { mode: 'readwrite' as const }
const checkPermissions = async (handle: FileSystemHandle) => {
return (
(await handle.queryPermission(options)) === 'granted' ||
(await handle.requestPermission(options)) === 'granted'
)
}
export async function loadFileHandle() {
const fileHandle = await getFromIdb(`tldraw_file_handle_${window.location.origin}`)
if (!fileHandle) return null
return fileHandle
}
export async function saveFileHandle(fileHandle: FileSystemHandle | null) {
return setToIdb(`tldraw_file_handle_${window.location.origin}`, fileHandle)
}
export async function saveToFileSystem(
document: TLDrawDocument,
fileHandle: FileSystemHandle | null
) {
// Create the saved file data
const file: TLDrawFile = {
name: document.name || 'New Document',
fileHandle: fileHandle ?? null,
document,
assets: {},
}
// Serialize to JSON
const json = JSON.stringify(file, null, 2)
// Create blob
const blob = new Blob([json], {
type: 'application/vnd.tldraw+json',
})
if (fileHandle) {
const hasPermissions = await checkPermissions(fileHandle)
if (!hasPermissions) return null
}
// Save to file system
const newFileHandle = await fileSave(
blob,
{
fileName: `${file.name}.tldr`,
description: 'TLDraw File',
extensions: [`.tldr`],
},
fileHandle
)
await saveFileHandle(newFileHandle)
// Return true
return newFileHandle
}
export async function openFromFileSystem(): Promise<null | {
fileHandle: FileSystemHandle | null
document: TLDrawDocument
}> {
// Get the blob
const blob = await fileOpen({
description: 'TLDraw File',
extensions: [`.tldr`],
multiple: false,
})
if (!blob) return null
// Get JSON from blob
const json: string = await new Promise((resolve) => {
const reader = new FileReader()
reader.onloadend = () => {
if (reader.readyState === FileReader.DONE) {
resolve(reader.result as string)
}
}
reader.readAsText(blob, 'utf8')
})
// Parse
const file: TLDrawFile = JSON.parse(json)
const fileHandle = blob.handle ?? null
await saveFileHandle(fileHandle)
return {
fileHandle,
document: file.document,
}
}

View file

@ -0,0 +1,15 @@
import type { TLDrawDocument } from '~types'
import { TLDrawState } from '~state'
import oldDoc from '../../test/old-doc'
import oldDoc2 from '../../test/old-doc-2'
describe('When migrating bindings', () => {
it('migrates a document without a version', () => {
new TLDrawState().loadDocument(oldDoc as unknown as TLDrawDocument)
})
it('migrates a document with an older version', () => {
const tlstate = new TLDrawState().loadDocument(oldDoc2 as unknown as TLDrawDocument)
expect(tlstate.getShape('d7ab0a49-3cb3-43ae-3d83-f5cf2f4a510a').style.color).toBe('black')
})
})

View file

@ -4,10 +4,9 @@ import { Decoration, TLDrawDocument, TLDrawShapeType } from '~types'
export function migrate(document: TLDrawDocument, newVersion: number): TLDrawDocument {
const { version = 0 } = document
console.log(`Migrating document from ${version} to ${newVersion}.`)
if (version === newVersion) return document
// Lowercase styles, move binding meta to binding
if (version <= 13) {
Object.values(document.pages).forEach((page) => {
Object.values(page.bindings).forEach((binding) => {
@ -39,6 +38,12 @@ export function migrate(document: TLDrawDocument, newVersion: number): TLDrawDoc
})
}
// Add document name and file system handle
if (version <= 13.1) {
document.name = 'New Document'
}
// Cleanup
Object.values(document.pageStates).forEach((pageState) => {
pageState.selectedIds = pageState.selectedIds.filter((id) => {
return document.pages[pageState.id].shapes[id] !== undefined

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { StateManager } from 'rko'
@ -47,7 +48,9 @@ import { sample } from './utils'
import { createTools, ToolType } from './tool'
import type { BaseTool } from './tool/BaseTool'
import { USER_COLORS, FIT_TO_SCREEN_PADDING } from '~constants'
import { migrate } from './migrate'
import { migrate } from './data/migrate'
import { loadFileHandle, openFromFileSystem, saveToFileSystem } from './data/filesystem'
import type { FileSystemHandle } from './data/browser-fs-access'
const uuid = Utils.uniqueId()
@ -56,6 +59,8 @@ export class TLDrawState extends StateManager<Data> {
private _onChange?: (tlstate: TLDrawState, data: Data, reason: string) => void
private _onUserChange?: (tlstate: TLDrawState, user: TLDrawUser) => void
readOnly = false
inputs?: Inputs
selectHistory: SelectHistory = {
@ -94,6 +99,10 @@ export class TLDrawState extends StateManager<Data> {
offset: [0, 0],
}
fileSystemHandle: FileSystemHandle | null = null
isDirty = false
constructor(
id?: string,
onMount?: (tlstate: TLDrawState) => void,
@ -113,6 +122,10 @@ export class TLDrawState extends StateManager<Data> {
this.loadDocument(this.document)
this.patchState({ document: migrate(this.document, TLDrawState.version) })
loadFileHandle().then((fileHandle) => {
this.fileSystemHandle = fileHandle
})
this._onChange = onChange
this._onMount = onMount
this._onUserChange = onUserChange
@ -334,6 +347,13 @@ export class TLDrawState extends StateManager<Data> {
}
}
// Temporary block on editing pages while in readonly mode.
// This is a broad solution but not a very good one: the UX
// for interacting with a readOnly document will be more nuanced.
if (this.readOnly) {
data.document.pages = prev.document.pages
}
return data
}
@ -344,6 +364,12 @@ export class TLDrawState extends StateManager<Data> {
*/
protected onStateDidChange = (state: Data, id: string): void => {
if (!id.startsWith('patch')) {
if (!id.startsWith('replace')) {
// If we've changed the undo stack, then the file is out of
// sync with any saved version on the file system.
this.isDirty = true
}
this.clearSelectHistory()
}
@ -583,22 +609,7 @@ export class TLDrawState extends StateManager<Data> {
this.resetHistory()
.clearSelectHistory()
.loadDocument(TLDrawState.defaultDocument)
.patchState(
{
document: {
pageStates: {
[this.currentPageId]: {
bindingId: null,
editingId: null,
hoveredId: null,
pointedId: null,
},
},
},
},
'reset_document'
)
.loadDocument(migrate(TLDrawState.defaultDocument, TLDrawState.version))
.persist()
return this
}
@ -813,32 +824,73 @@ export class TLDrawState extends StateManager<Data> {
)
}
// Should we move this to the app layer? onSave, onSaveAs, etc?
/**
* Create a new project.
* Should move to the www layer.
* @todo
*/
newProject = () => {
// TODO
if (!this.isLocal) return
this.fileSystemHandle = null
this.resetDocument()
}
/**
* Save the current project.
* Should move to the www layer.
* @todo
*/
saveProject = () => {
this.persist()
saveProject = async () => {
if (this.readOnly) return
try {
const fileHandle = await saveToFileSystem(this.document, this.fileSystemHandle)
this.fileSystemHandle = fileHandle
this.persist()
this.isDirty = false
} catch (e: any) {
// Likely cancelled
console.error(e.message)
}
return this
}
/**
* Save the current project as a new file.
*/
saveProjectAs = async () => {
try {
const fileHandle = await saveToFileSystem(this.document, null)
this.fileSystemHandle = fileHandle
this.persist()
this.isDirty = false
} catch (e: any) {
// Likely cancelled
console.error(e.message)
}
return this
}
/**
* Load a project from the filesystem.
* Should move to the www layer.
* @todo
*/
loadProject = () => {
// TODO
openProject = async () => {
if (!this.isLocal) return
try {
const result = await openFromFileSystem()
if (!result) {
throw Error()
}
const { fileHandle, document } = result
this.loadDocument(document)
this.fileSystemHandle = fileHandle
this.zoomToFit()
this.persist()
} catch (e) {
console.error(e)
} finally {
this.persist()
}
}
/**
@ -847,7 +899,7 @@ export class TLDrawState extends StateManager<Data> {
* @todo
*/
signOut = () => {
// TODO
// todo
}
/* -------------------- Getters --------------------- */
@ -1037,6 +1089,7 @@ export class TLDrawState extends StateManager<Data> {
* @param pageId The id of the page to duplicate.
*/
duplicatePage = (pageId: string): this => {
if (this.readOnly) return this
return this.setState(
Commands.duplicatePage(this.state, [-this.bounds.width / 2, -this.bounds.height / 2], pageId)
)
@ -1047,6 +1100,7 @@ export class TLDrawState extends StateManager<Data> {
* @param pageId The id of the page to delete.
*/
deletePage = (pageId?: string): this => {
if (this.readOnly) return this
if (Object.values(this.document.pages).length <= 1) return this
return this.setState(Commands.deletePage(this.state, pageId ? pageId : this.currentPageId))
}
@ -1110,6 +1164,7 @@ export class TLDrawState extends StateManager<Data> {
* @param point
*/
paste = (point?: number[]) => {
if (this.readOnly) return
const pasteInCurrentPage = (shapes: TLDrawShape[], bindings: TLDrawBinding[]) => {
const idsMap: Record<string, string> = {}
@ -1669,6 +1724,7 @@ export class TLDrawState extends StateManager<Data> {
* @param args arguments of the session's start method.
*/
startSession = <T extends SessionType>(type: T, ...args: ExceptFirstTwo<ArgsOfType<T>>): this => {
if (this.readOnly && type !== SessionType.Brush) return this
if (this.session) {
throw Error(`Already in a session! (${this.session.constructor.name})`)
}
@ -1738,9 +1794,7 @@ export class TLDrawState extends StateManager<Data> {
const { session } = this
if (!session) return this
this.session = undefined
const result = session.complete(this.state)
if (result === undefined) {
@ -1901,7 +1955,7 @@ export class TLDrawState extends StateManager<Data> {
)
}
createTextShapeAtPoint(point: number[]) {
createTextShapeAtPoint(point: number[]): this {
const {
shapes,
appState: { currentPageId, currentStyle },
@ -1915,9 +1969,7 @@ export class TLDrawState extends StateManager<Data> {
.sort((a, b) => b.childIndex - a.childIndex)[0].childIndex + 1
const id = Utils.uniqueId()
const Text = shapeUtils[TLDrawShapeType.Text]
const newShape = Text.create({
id,
parentId: currentPageId,
@ -1925,14 +1977,12 @@ export class TLDrawState extends StateManager<Data> {
point,
style: { ...currentStyle },
})
const bounds = Text.getBounds(newShape)
newShape.point = Vec.sub(newShape.point, [bounds.width / 2, bounds.height / 2])
this.createShapes(newShape)
this.setEditingId(id)
return this
}
/**
@ -2099,6 +2149,7 @@ export class TLDrawState extends StateManager<Data> {
* @param ids The ids to duplicate (defaults to selection).
*/
duplicate = (ids = this.selectedIds, point?: number[]): this => {
if (this.readOnly) return this
if (ids.length === 0) return this
return this.setState(Commands.duplicate(this.state, ids, point))
}
@ -2173,6 +2224,8 @@ export class TLDrawState extends StateManager<Data> {
groupId = Utils.uniqueId(),
pageId = this.currentPageId
): this => {
if (this.readOnly) return this
if (ids.length === 1 && this.getShape(ids[0], pageId).type === TLDrawShapeType.Group) {
return this.ungroup(ids, pageId)
}
@ -2189,6 +2242,8 @@ export class TLDrawState extends StateManager<Data> {
* @todo
*/
ungroup = (ids = this.selectedIds, pageId = this.currentPageId): this => {
if (this.readOnly) return this
const groups = ids
.map((id) => this.getShape(id, pageId))
.filter((shape) => shape.type === TLDrawShapeType.Group)
@ -2431,6 +2486,10 @@ export class TLDrawState extends StateManager<Data> {
return this.selectedIds.includes(id)
}
get isLocal() {
return this.state.room === undefined || this.state.room.id === 'local'
}
get status() {
return this.appState.status
}
@ -2465,6 +2524,7 @@ export class TLDrawState extends StateManager<Data> {
static defaultDocument: TLDrawDocument = {
id: 'doc',
name: 'New Document',
version: 13,
pages: {
page: {

View file

@ -1,35 +0,0 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { TLDrawState } from '~state'
import type { TLDrawDocument } from '~types'
import oldDoc from './old-doc'
import oldDoc2 from './old-doc-2'
describe('When migrating bindings', () => {
it('migrates', () => {
// Object.values((oldDoc as unknown as TLDrawDocument).pages).forEach((page) => {
// Object.values(page.bindings).forEach((binding) => {
// if ('meta' in binding) {
// // @ts-ignore
// Object.assign(binding, binding.meta)
// }
// })
// })
new TLDrawState().loadDocument(oldDoc as unknown as TLDrawDocument)
})
it('migrates older document', () => {
// Object.values((oldDoc as unknown as TLDrawDocument).pages).forEach((page) => {
// Object.values(page.bindings).forEach((binding) => {
// if ('meta' in binding) {
// // @ts-ignore
// Object.assign(binding, binding.meta)
// }
// })
// })
const tlstate = new TLDrawState().loadDocument(oldDoc2 as unknown as TLDrawDocument)
expect(tlstate.getShape('d7ab0a49-3cb3-43ae-3d83-f5cf2f4a510a').style.color).toBe('black')
})
})

View file

@ -3,6 +3,7 @@ import { TLDrawDocument, ColorStyle, DashStyle, SizeStyle, TLDrawShapeType } fro
export const mockDocument: TLDrawDocument = {
version: 0,
id: 'doc',
name: 'New Document',
pages: {
page1: {
id: 'page1',

View file

@ -8,7 +8,7 @@ import { render } from '@testing-library/react'
export const Wrapper: React.FC = ({ children }) => {
const [tlstate] = React.useState(() => new TLDrawState())
const [context] = React.useState(() => {
return { tlstate, useSelector: tlstate.useStore }
return { tlstate, useSelector: tlstate.useStore, callbacks: {} }
})
const rWrapper = React.useRef<HTMLDivElement>(null)

View file

@ -13,6 +13,7 @@ import type {
import type { TLPage, TLUser, TLPageState } from '@tldraw/core'
import type { StoreApi } from 'zustand'
import type { Command, Patch } from 'rko'
import type { FileSystemHandle } from '~state/data/browser-fs-access'
export interface TLDrawHandle extends TLHandle {
canBind?: boolean
@ -36,6 +37,7 @@ export type TLDrawPage = TLPage<TLDrawShape, TLDrawBinding>
export interface TLDrawDocument {
id: string
name: string
pages: Record<string, TLDrawPage>
pageStates: Record<string, TLPageState>
version: number
@ -396,3 +398,12 @@ export type Easing =
| 'easeInExpo'
| 'easeOutExpo'
| 'easeInOutExpo'
/* ------------------- File System ------------------ */
export interface TLDrawFile {
name: string
fileHandle: FileSystemHandle | null
document: TLDrawDocument
assets: Record<string, unknown>
}

View file

@ -1,4 +1,4 @@
import { TLDraw, TLDrawState, Data } from '@tldraw/tldraw'
import { TLDraw, TLDrawState, Data, useFileSystem } from '@tldraw/tldraw'
import * as gtag from '-utils/gtag'
import React from 'react'
@ -29,9 +29,17 @@ export default function Editor({ id = 'home' }: EditorProps) {
[id]
)
const fileSystemEvents = useFileSystem()
return (
<div className="tldraw">
<TLDraw id={id} onMount={handleMount} onChange={handleChange} autofocus />
<TLDraw
id={id}
onMount={handleMount}
onChange={handleChange}
autofocus
{...fileSystemEvents}
/>
</div>
)
}

View file

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

View file

@ -9920,7 +9920,7 @@ raw-body@2.4.1:
iconv-lite "0.4.24"
unpipe "1.0.0"
react-dom@17.0.2:
react-dom@17.0.2, "react-dom@^16.8 || ^17.0":
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
@ -10008,7 +10008,7 @@ react-style-singleton@^2.1.0:
invariant "^2.2.4"
tslib "^1.0.0"
react@17.0.2:
react@17.0.2, react@>=16.8:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==