[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:
parent
61ac6427fb
commit
fb77323ef2
37 changed files with 1132 additions and 208 deletions
|
@ -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({
|
||||
serve.start({
|
||||
port: 5000,
|
||||
root: './dist',
|
||||
live: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
6
example/src/readonly.tsx
Normal file
|
@ -0,0 +1,6 @@
|
|||
import * as React from 'react'
|
||||
import Editor from './components/editor'
|
||||
|
||||
export default function Basic(): JSX.Element {
|
||||
return <Editor readOnly />
|
||||
}
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -33,6 +33,7 @@ async function main() {
|
|||
'perfect-freehand',
|
||||
'rko',
|
||||
'react-hotkeys-hook',
|
||||
'browser-fs-access',
|
||||
],
|
||||
sourcemap: true,
|
||||
incremental: true,
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 ? (
|
||||
<>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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,26 +48,47 @@ 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">
|
||||
{showFileMenu && (
|
||||
<DMSubMenu label="File...">
|
||||
<DMItem onSelect={handleNew} kbd="#N">
|
||||
{callbacks.onNewProject && (
|
||||
<DMItem onSelect={onNewProject} kbd="#N">
|
||||
New Project
|
||||
</DMItem>
|
||||
<DMItem disabled onSelect={handleLoad} kbd="#L">
|
||||
)}
|
||||
{callbacks.onOpenProject && (
|
||||
<DMItem onSelect={onOpenProject} kbd="#L">
|
||||
Open...
|
||||
</DMItem>
|
||||
<DMItem disabled onSelect={handleSave} kbd="#S">
|
||||
)}
|
||||
{callbacks.onSaveProject && (
|
||||
<DMItem onSelect={onSaveProject} kbd="#S">
|
||||
Save
|
||||
</DMItem>
|
||||
<DMItem disabled onSelect={handleSave} kbd="⇧#S">
|
||||
)}
|
||||
{callbacks.onSaveProjectAs && (
|
||||
<DMItem onSelect={onSaveProjectAs} kbd="⇧#S">
|
||||
Save As...
|
||||
</DMItem>
|
||||
)}
|
||||
</DMSubMenu>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<>
|
||||
{' '}
|
||||
<DMSubMenu label="Edit...">
|
||||
<DMItem onSelect={tlstate.undo} kbd="#Z">
|
||||
Undo
|
||||
|
@ -97,14 +115,23 @@ export const Menu = React.memo(() => {
|
|||
<DMItem onSelect={handleDeselectAll}>Select None</DMItem>
|
||||
</DMSubMenu>
|
||||
<DMDivider dir="ltr" />
|
||||
</>
|
||||
)}
|
||||
<PreferencesMenu />
|
||||
<DMDivider dir="ltr" />
|
||||
<DMItem disabled onSelect={handleSignOut}>
|
||||
{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>
|
||||
)
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -2,3 +2,5 @@ export * from './useKeyboardShortcuts'
|
|||
export * from './useTLDrawContext'
|
||||
export * from './useTheme'
|
||||
export * from './useCustomFonts'
|
||||
export * from './useFileSystemHandlers'
|
||||
export * from './useFileSystem'
|
||||
|
|
49
packages/tldraw/src/hooks/useFileSystem.ts
Normal file
49
packages/tldraw/src/hooks/useFileSystem.ts
Normal 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,
|
||||
}
|
||||
}
|
45
packages/tldraw/src/hooks/useFileSystemHandlers.ts
Normal file
45
packages/tldraw/src/hooks/useFileSystemHandlers.ts
Normal 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,
|
||||
}
|
||||
}
|
|
@ -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()) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
tlstate.resetDocument()
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -2,3 +2,4 @@ export * from './TLDraw'
|
|||
export * from './types'
|
||||
export * from './shape-utils'
|
||||
export { TLDrawState } from './state'
|
||||
export { useFileSystem } from './hooks'
|
||||
|
|
99
packages/tldraw/src/state/data/browser-fs-access/index.d.ts
vendored
Normal file
99
packages/tldraw/src/state/data/browser-fs-access/index.d.ts
vendored
Normal 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>
|
||||
}
|
334
packages/tldraw/src/state/data/browser-fs-access/index.js
Normal file
334
packages/tldraw/src/state/data/browser-fs-access/index.js
Normal 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.
|
10
packages/tldraw/src/state/data/filesystem.spec.ts
Normal file
10
packages/tldraw/src/state/data/filesystem.spec.ts
Normal 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')
|
||||
})
|
101
packages/tldraw/src/state/data/filesystem.ts
Normal file
101
packages/tldraw/src/state/data/filesystem.ts
Normal 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,
|
||||
}
|
||||
}
|
15
packages/tldraw/src/state/data/migrate.spec.ts
Normal file
15
packages/tldraw/src/state/data/migrate.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
|
@ -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
|
|
@ -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 = () => {
|
||||
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: {
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
|
@ -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',
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
43
www/hooks/useFileSystem.ts
Normal file
43
www/hooks/useFileSystem.ts
Normal 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,
|
||||
}
|
||||
}
|
|
@ -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==
|
||||
|
|
Loading…
Reference in a new issue