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:
commit
759407e40c
8 changed files with 348 additions and 201 deletions
|
@ -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',
|
||||||
|
|
|
@ -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',
|
||||||
|
})
|
|
@ -1,2 +1,3 @@
|
||||||
export * from './AlertDialog'
|
export * from './AlertDialog'
|
||||||
|
export * from './FilenameDialog'
|
||||||
export * from './Alert'
|
export * from './Alert'
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue