Merge pull request #940 from judicaelandria/feat/save-project-as-support

feat: save project as (support for firefox and safari)
This commit is contained in:
David Sheldrick 2022-09-01 11:23:43 +01:00 committed by GitHub
commit 759407e40c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 348 additions and 201 deletions

View file

@ -144,7 +144,7 @@ const StyledContent = styled(AlertDialogPrimitive.Content, {
boxShadow: '$panel', boxShadow: '$panel',
}) })
const Button = styled('button', { export const Button = styled('button', {
all: 'unset', all: 'unset',
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',

View file

@ -0,0 +1,120 @@
import * as Dialog from '@radix-ui/react-alert-dialog'
import { Pencil1Icon } from '@radix-ui/react-icons'
import * as React from 'react'
import { FormattedMessage, useIntl } from 'react-intl'
import { useContainer, useTldrawApp } from '~hooks'
import { styled } from '~styles'
import { TextField } from '../TextField'
import { Button } from './AlertDialog'
interface FilenameDialogProps {
isOpen: boolean
onClose: () => void
}
export const FilenameDialog = ({ isOpen, onClose }: FilenameDialogProps) => {
const app = useTldrawApp()
const container = useContainer()
const intl = useIntl()
const [filename, setFilename] = React.useState(app.document.name)
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value.trimStart()
setFilename(value)
}, [])
function stopPropagation(e: React.KeyboardEvent<HTMLDivElement>) {
e.stopPropagation()
}
const handleTextFieldKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'Enter': {
app.saveProjectAs(filename)
onClose()
break
}
case 'Escape': {
onClose()
break
}
}
}, [])
return (
<Dialog.Root open={isOpen}>
<Dialog.Portal container={container.current}>
<StyledDialogOverlay onPointerDown={onClose} />
<StyledDialogContent dir="ltr" onKeyDown={stopPropagation} onKeyUp={stopPropagation}>
<Input
placeholder={intl.formatMessage({ id: 'enter.file.name' })}
value={filename}
onChange={handleChange}
onKeyDown={handleTextFieldKeyDown}
icon={<Pencil1Icon />}
/>
<ActionWrapper>
<Dialog.Action asChild>
<Button onClick={onClose}>
<FormattedMessage id="cancel" />
</Button>
</Dialog.Action>
<Dialog.Action asChild>
<Button
css={{ backgroundColor: '#2F80ED', color: 'White' }}
onClick={() => {
// Remove the file extension if the user entered it
const name = filename.trim().replace(/\.tldr$/, '')
app.saveProjectAs(name)
onClose()
}}
>
<FormattedMessage id="save" />
</Button>
</Dialog.Action>
</ActionWrapper>
</StyledDialogContent>
</Dialog.Portal>
</Dialog.Root>
)
}
const StyledDialogContent = styled(Dialog.Content, {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
minWidth: 300,
maxWidth: 'fit-content',
maxHeight: '85vh',
marginTop: '-5vh',
pointerEvents: 'all',
backgroundColor: '$panel',
padding: '$3',
borderRadius: '$2',
font: '$ui',
zIndex: 999999,
'&:focus': {
outline: 'none',
},
})
const StyledDialogOverlay = styled(Dialog.Overlay, {
backgroundColor: 'rgba(0, 0, 0, .15)',
position: 'absolute',
pointerEvents: 'all',
inset: 0,
zIndex: 999998,
})
const ActionWrapper = styled('div', {
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 8,
justifyContent: 'flex-end',
marginTop: 10,
})
const Input = styled(TextField, {
background: '$hover',
})

View file

@ -1,2 +1,3 @@
export * from './AlertDialog' export * from './AlertDialog'
export * from './FilenameDialog'
export * from './Alert' export * from './Alert'

View file

@ -1,7 +1,9 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { HamburgerMenuIcon } from '@radix-ui/react-icons' import { HamburgerMenuIcon } from '@radix-ui/react-icons'
import { supported } from 'browser-fs-access'
import * as React from 'react' import * as React from 'react'
import { FormattedMessage, useIntl } from 'react-intl' import { FormattedMessage, useIntl } from 'react-intl'
import { FilenameDialog } from '~components/Primitives/AlertDialog'
import { Divider } from '~components/Primitives/Divider' import { Divider } from '~components/Primitives/Divider'
import { DMContent, DMItem, DMSubMenu, DMTriggerIcon } from '~components/Primitives/DropdownMenu' import { DMContent, DMItem, DMSubMenu, DMTriggerIcon } from '~components/Primitives/DropdownMenu'
import { preventEvent } from '~components/preventEvent' import { preventEvent } from '~components/preventEvent'
@ -25,6 +27,7 @@ const disableAssetsSelector = (s: TDSnapshot) => {
export const Menu = React.memo(function Menu({ readOnly }: MenuProps) { export const Menu = React.memo(function Menu({ readOnly }: MenuProps) {
const app = useTldrawApp() const app = useTldrawApp()
const intl = useIntl() const intl = useIntl()
const [openDialog, setOpenDialog] = React.useState(false)
const numberOfSelectedIds = app.useStore(numberOfSelectedIdsSelector) const numberOfSelectedIds = app.useStore(numberOfSelectedIdsSelector)
@ -36,6 +39,14 @@ export const Menu = React.memo(function Menu({ readOnly }: MenuProps) {
const { onNewProject, onOpenProject, onSaveProject, onSaveProjectAs } = useFileSystemHandlers() const { onNewProject, onOpenProject, onSaveProject, onSaveProjectAs } = useFileSystemHandlers()
const handleSaveProjectAs = React.useCallback(() => {
if (!supported) {
setOpenDialog(true)
} else {
app.saveProjectAs()
}
}, [app])
const handleDelete = React.useCallback(() => { const handleDelete = React.useCallback(() => {
app.delete() app.delete()
}, [app]) }, [app])
@ -110,203 +121,214 @@ export const Menu = React.memo(function Menu({ readOnly }: MenuProps) {
const hasSelection = numberOfSelectedIds > 0 const hasSelection = numberOfSelectedIds > 0
return ( return (
<DropdownMenu.Root dir="ltr"> <>
<DMTriggerIcon id="TD-MenuIcon"> <DropdownMenu.Root dir="ltr">
<HamburgerMenuIcon /> <DMTriggerIcon id="TD-MenuIcon">
</DMTriggerIcon> <HamburgerMenuIcon />
<DMContent </DMTriggerIcon>
variant="menu" <DMContent
id="TD-Menu" variant="menu"
side="bottom" id="TD-Menu"
align="start" side="bottom"
sideOffset={4} align="start"
alignOffset={4} sideOffset={4}
> alignOffset={4}
{showFileMenu && ( >
<DMSubMenu label={`${intl.formatMessage({ id: 'menu.file' })}...`} id="TD-MenuItem-File"> {showFileMenu && (
{app.callbacks.onNewProject && ( <DMSubMenu
<DMItem onClick={onNewProject} kbd="#N" id="TD-MenuItem-File-New_Project"> label={`${intl.formatMessage({ id: 'menu.file' })}...`}
<FormattedMessage id="new.project" /> id="TD-MenuItem-File"
</DMItem> >
)} {app.callbacks.onNewProject && (
{app.callbacks.onOpenProject && ( <DMItem onClick={onNewProject} kbd="#N" id="TD-MenuItem-File-New_Project">
<DMItem onClick={onOpenProject} kbd="#O" id="TD-MenuItem-File-Open"> <FormattedMessage id="new.project" />
<FormattedMessage id="open" />
...
</DMItem>
)}
{app.callbacks.onSaveProject && (
<DMItem onClick={onSaveProject} kbd="#S" id="TD-MenuItem-File-Save">
<FormattedMessage id="save" />
</DMItem>
)}
{app.callbacks.onSaveProjectAs && (
<DMItem onClick={onSaveProjectAs} kbd="#⇧S" id="TD-MenuItem-File-Save_As">
<FormattedMessage id="save.as" />
...
</DMItem>
)}
{!disableAssets && (
<>
<Divider />
<DMItem onClick={handleUploadMedia} kbd="#U" id="TD-MenuItem-File-Upload_Media">
<FormattedMessage id="upload.media" />
</DMItem> </DMItem>
</> )}
)} {app.callbacks.onOpenProject && (
</DMSubMenu> <DMItem onClick={onOpenProject} kbd="#O" id="TD-MenuItem-File-Open">
)} <FormattedMessage id="open" />
<DMSubMenu label={`${intl.formatMessage({ id: 'menu.edit' })}...`} id="TD-MenuItem-Edit"> ...
<DMItem </DMItem>
onSelect={preventEvent} )}
onClick={app.undo} {app.callbacks.onSaveProject && (
disabled={readOnly} <DMItem onClick={onSaveProject} kbd="#S" id="TD-MenuItem-File-Save">
kbd="#Z" <FormattedMessage id="save" />
id="TD-MenuItem-Edit-Undo" </DMItem>
> )}
<FormattedMessage id="undo" /> {app.callbacks.onSaveProjectAs && (
</DMItem> <DMItem onClick={handleSaveProjectAs} kbd="#⇧S" id="TD-MenuItem-File-Save_As">
<DMItem <FormattedMessage id="save.as" />
onSelect={preventEvent} ...
onClick={app.redo} </DMItem>
disabled={readOnly} )}
kbd="#⇧Z" {!disableAssets && (
id="TD-MenuItem-Edit-Redo" <>
> <Divider />
<FormattedMessage id="redo" /> <DMItem onClick={handleUploadMedia} kbd="#U" id="TD-MenuItem-File-Upload_Media">
</DMItem> <FormattedMessage id="upload.media" />
<Divider /> </DMItem>
<DMItem </>
onSelect={preventEvent} )}
disabled={!hasSelection || readOnly} </DMSubMenu>
onClick={handleCut} )}
kbd="#X" <DMSubMenu label={`${intl.formatMessage({ id: 'menu.edit' })}...`} id="TD-MenuItem-Edit">
id="TD-MenuItem-Edit-Cut" <DMItem
> onSelect={preventEvent}
<FormattedMessage id="cut" /> onClick={app.undo}
</DMItem> disabled={readOnly}
<DMItem kbd="#Z"
onSelect={preventEvent} id="TD-MenuItem-Edit-Undo"
disabled={!hasSelection} >
onClick={handleCopy} <FormattedMessage id="undo" />
kbd="#C"
id="TD-MenuItem-Edit-Copy"
>
<FormattedMessage id="copy" />
</DMItem>
<DMItem
onSelect={preventEvent}
onClick={handlePaste}
kbd="#V"
id="TD-MenuItem-Edit-Paste"
>
<FormattedMessage id="paste" />
</DMItem>
<Divider />
<DMSubMenu
label={`${intl.formatMessage({ id: 'copy.as' })}...`}
size="small"
id="TD-MenuItem-Copy-As"
>
<DMItem onClick={handleCopySVG} id="TD-MenuItem-Copy-as-SVG">
SVG
</DMItem> </DMItem>
<DMItem onClick={handleCopyPNG} id="TD-MenuItem-Copy-As-PNG"> <DMItem
PNG onSelect={preventEvent}
onClick={app.redo}
disabled={readOnly}
kbd="#⇧Z"
id="TD-MenuItem-Edit-Redo"
>
<FormattedMessage id="redo" />
</DMItem> </DMItem>
<DMItem onClick={handleCopyJSON} id="TD-MenuItem-Copy_as_JSON"> <Divider />
JSON <DMItem
onSelect={preventEvent}
disabled={!hasSelection || readOnly}
onClick={handleCut}
kbd="#X"
id="TD-MenuItem-Edit-Cut"
>
<FormattedMessage id="cut" />
</DMItem> </DMItem>
</DMSubMenu> <DMItem
<DMSubMenu onSelect={preventEvent}
label={`${intl.formatMessage({ id: 'export.as' })}...`} disabled={!hasSelection}
size="small" onClick={handleCopy}
id="TD-MenuItem-Export" kbd="#C"
> id="TD-MenuItem-Edit-Copy"
<DMItem onClick={handleExportSVG} id="TD-MenuItem-Export-SVG"> >
SVG <FormattedMessage id="copy" />
</DMItem> </DMItem>
<DMItem onClick={handleExportPNG} id="TD-MenuItem-Export-PNG"> <DMItem
PNG onSelect={preventEvent}
onClick={handlePaste}
kbd="#V"
id="TD-MenuItem-Edit-Paste"
>
<FormattedMessage id="paste" />
</DMItem> </DMItem>
<DMItem onClick={handleExportJPG} id="TD-MenuItem-Export-JPG"> <Divider />
JPG <DMSubMenu
</DMItem> label={`${intl.formatMessage({ id: 'copy.as' })}...`}
<DMItem onClick={handleExportWEBP} id="TD-MenuItem-Export-WEBP"> size="small"
WEBP id="TD-MenuItem-Copy-As"
</DMItem> >
<DMItem onClick={handleExportJSON} id="TD-MenuItem-Export-JSON"> <DMItem onClick={handleCopySVG} id="TD-MenuItem-Copy-as-SVG">
JSON SVG
</DMItem> </DMItem>
</DMSubMenu> <DMItem onClick={handleCopyPNG} id="TD-MenuItem-Copy-As-PNG">
PNG
</DMItem>
<DMItem onClick={handleCopyJSON} id="TD-MenuItem-Copy_as_JSON">
JSON
</DMItem>
</DMSubMenu>
<DMSubMenu
label={`${intl.formatMessage({ id: 'export.as' })}...`}
size="small"
id="TD-MenuItem-Export"
>
<DMItem onClick={handleExportSVG} id="TD-MenuItem-Export-SVG">
SVG
</DMItem>
<DMItem onClick={handleExportPNG} id="TD-MenuItem-Export-PNG">
PNG
</DMItem>
<DMItem onClick={handleExportJPG} id="TD-MenuItem-Export-JPG">
JPG
</DMItem>
<DMItem onClick={handleExportWEBP} id="TD-MenuItem-Export-WEBP">
WEBP
</DMItem>
<DMItem onClick={handleExportJSON} id="TD-MenuItem-Export-JSON">
JSON
</DMItem>
</DMSubMenu>
<Divider />
<DMItem
onSelect={preventEvent}
onClick={handleSelectAll}
kbd="#A"
id="TD-MenuItem-Select_All"
>
<FormattedMessage id="select.all" />
</DMItem>
<DMItem
onSelect={preventEvent}
disabled={!hasSelection}
onClick={handleSelectNone}
id="TD-MenuItem-Select_None"
>
<FormattedMessage id="select.none" />
</DMItem>
<Divider />
<DMItem
onSelect={handleDelete}
disabled={!hasSelection}
kbd="⌫"
id="TD-MenuItem-Delete"
>
<FormattedMessage id="delete" />
</DMItem>
</DMSubMenu>
<DMSubMenu label={intl.formatMessage({ id: 'menu.view' })} id="TD-MenuItem-Edit">
<DMItem
onSelect={preventEvent}
onClick={app.zoomIn}
kbd="#+"
id="TD-MenuItem-View-ZoomIn"
>
<FormattedMessage id="zoom.in" />
</DMItem>
<DMItem
onSelect={preventEvent}
onClick={app.zoomOut}
kbd="#-"
id="TD-MenuItem-View-ZoomOut"
>
<FormattedMessage id="zoom.out" />
</DMItem>
<DMItem
onSelect={preventEvent}
onClick={handleZoomTo100}
kbd="⇧+0"
id="TD-MenuItem-View-ZoomTo100"
>
<FormattedMessage id="zoom.to" /> 100%
</DMItem>
<DMItem
onSelect={preventEvent}
onClick={app.zoomToFit}
kbd="⇧+1"
id="TD-MenuItem-View-ZoomToFit"
>
<FormattedMessage id="zoom.to.fit" />
</DMItem>
<DMItem
onSelect={preventEvent}
onClick={app.zoomToSelection}
kbd="⇧+2"
id="TD-MenuItem-View-ZoomToSelection"
>
<FormattedMessage id="zoom.to.selection" />
</DMItem>
</DMSubMenu>
<Divider /> <Divider />
<DMItem <PreferencesMenu />
onSelect={preventEvent} </DMContent>
onClick={handleSelectAll} </DropdownMenu.Root>
kbd="#A" <FilenameDialog isOpen={openDialog} onClose={() => setOpenDialog(false)} />
id="TD-MenuItem-Select_All" </>
>
<FormattedMessage id="select.all" />
</DMItem>
<DMItem
onSelect={preventEvent}
disabled={!hasSelection}
onClick={handleSelectNone}
id="TD-MenuItem-Select_None"
>
<FormattedMessage id="select.none" />
</DMItem>
<Divider />
<DMItem onSelect={handleDelete} disabled={!hasSelection} kbd="⌫" id="TD-MenuItem-Delete">
<FormattedMessage id="delete" />
</DMItem>
</DMSubMenu>
<DMSubMenu label={intl.formatMessage({ id: 'menu.view' })} id="TD-MenuItem-Edit">
<DMItem
onSelect={preventEvent}
onClick={app.zoomIn}
kbd="#+"
id="TD-MenuItem-View-ZoomIn"
>
<FormattedMessage id="zoom.in" />
</DMItem>
<DMItem
onSelect={preventEvent}
onClick={app.zoomOut}
kbd="#-"
id="TD-MenuItem-View-ZoomOut"
>
<FormattedMessage id="zoom.out" />
</DMItem>
<DMItem
onSelect={preventEvent}
onClick={handleZoomTo100}
kbd="⇧+0"
id="TD-MenuItem-View-ZoomTo100"
>
<FormattedMessage id="zoom.to" /> 100%
</DMItem>
<DMItem
onSelect={preventEvent}
onClick={app.zoomToFit}
kbd="⇧+1"
id="TD-MenuItem-View-ZoomToFit"
>
<FormattedMessage id="zoom.to.fit" />
</DMItem>
<DMItem
onSelect={preventEvent}
onClick={app.zoomToSelection}
kbd="⇧+2"
id="TD-MenuItem-View-ZoomToSelection"
>
<FormattedMessage id="zoom.to.selection" />
</DMItem>
</DMSubMenu>
<Divider />
<PreferencesMenu />
</DMContent>
</DropdownMenu.Root>
) )
}) })

View file

@ -17,6 +17,8 @@ export const LABEL_POINT = [0.5, 0.5]
export const PI2 = Math.PI * 2 export const PI2 = Math.PI * 2
export const FILE_EXTENSION = '.tldr'
export const EASINGS: Record<Easing, (t: number) => number> = { export const EASINGS: Record<Easing, (t: number) => number> = {
linear: (t) => t, linear: (t) => t,
easeInQuad: (t) => t * t, easeInQuad: (t) => t * t,

View file

@ -1441,9 +1441,9 @@ export class TldrawApp extends StateManager<TDSnapshot> {
/** /**
* Save the current project as a new file. * Save the current project as a new file.
*/ */
saveProjectAs = async () => { saveProjectAs = async (filename?: string) => {
try { try {
const fileHandle = await saveToFileSystem(this.document, null) const fileHandle = await saveToFileSystem(this.document, null, filename)
this.fileSystemHandle = fileHandle this.fileSystemHandle = fileHandle
this.persist({}) this.persist({})
this.isDirty = false this.isDirty = false

View file

@ -1,7 +1,7 @@
import { fileOpen, fileSave } from 'browser-fs-access' import { fileOpen, fileSave, supported } from 'browser-fs-access'
import type { FileSystemHandle } from 'browser-fs-access' import type { FileSystemHandle } from 'browser-fs-access'
import { get as getFromIdb, set as setToIdb } from 'idb-keyval' import { get as getFromIdb, set as setToIdb } from 'idb-keyval'
import { IMAGE_EXTENSIONS, VIDEO_EXTENSIONS } from '~constants' import { FILE_EXTENSION, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS } from '~constants'
import type { TDDocument, TDFile } from '~types' import type { TDDocument, TDFile } from '~types'
const options = { mode: 'readwrite' as const } const options = { mode: 'readwrite' as const }
@ -26,7 +26,8 @@ export async function saveFileHandle(fileHandle: FileSystemFileHandle | null) {
export async function saveToFileSystem( export async function saveToFileSystem(
document: TDDocument, document: TDDocument,
fileHandle: FileSystemFileHandle | null fileHandle: FileSystemFileHandle | null,
name?: string
) { ) {
// Create the saved file data // Create the saved file data
const file: TDFile = { const file: TDFile = {
@ -48,14 +49,14 @@ export async function saveToFileSystem(
const hasPermissions = await checkPermissions(fileHandle) const hasPermissions = await checkPermissions(fileHandle)
if (!hasPermissions) return null if (!hasPermissions) return null
} }
const filename = !supported && name?.length ? name : `${file.name}`
// Save to file system // Save to file system
const newFileHandle = await fileSave( const newFileHandle = await fileSave(
blob, blob,
{ {
fileName: `${file.name}.tldr`, fileName: `${filename}${FILE_EXTENSION}`,
description: 'Tldraw File', description: 'Tldraw File',
extensions: [`.tldr`], extensions: [`${FILE_EXTENSION}`],
}, },
fileHandle fileHandle
) )
@ -73,7 +74,7 @@ export async function openFromFileSystem(): Promise<null | {
// Get the blob // Get the blob
const blob = await fileOpen({ const blob = await fileOpen({
description: 'Tldraw File', description: 'Tldraw File',
extensions: [`.tldr`], extensions: [`${FILE_EXTENSION}`],
multiple: false, multiple: false,
}) })

View file

@ -126,5 +126,6 @@
"dialog.save.again": "Do you want to save changes to your current project?", "dialog.save.again": "Do you want to save changes to your current project?",
"dialog.cancel": "Cancel", "dialog.cancel": "Cancel",
"dialog.no": "No", "dialog.no": "No",
"dialog.yes": "Yes" "dialog.yes": "Yes",
"enter.file.name": "Enter file name"
} }