diff --git a/packages/tldraw/src/components/Primitives/AlertDialog/AlertDialog.tsx b/packages/tldraw/src/components/Primitives/AlertDialog/AlertDialog.tsx index 10658cd5f..ee3f39425 100644 --- a/packages/tldraw/src/components/Primitives/AlertDialog/AlertDialog.tsx +++ b/packages/tldraw/src/components/Primitives/AlertDialog/AlertDialog.tsx @@ -144,7 +144,7 @@ const StyledContent = styled(AlertDialogPrimitive.Content, { boxShadow: '$panel', }) -const Button = styled('button', { +export const Button = styled('button', { all: 'unset', display: 'inline-flex', alignItems: 'center', diff --git a/packages/tldraw/src/components/Primitives/AlertDialog/FilenameDialog.tsx b/packages/tldraw/src/components/Primitives/AlertDialog/FilenameDialog.tsx new file mode 100644 index 000000000..d06092da5 --- /dev/null +++ b/packages/tldraw/src/components/Primitives/AlertDialog/FilenameDialog.tsx @@ -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) => { + const value = event.target.value.trimStart() + setFilename(value) + }, []) + + function stopPropagation(e: React.KeyboardEvent) { + e.stopPropagation() + } + + const handleTextFieldKeyDown = React.useCallback((e: React.KeyboardEvent) => { + switch (e.key) { + case 'Enter': { + app.saveProjectAs(filename) + onClose() + break + } + case 'Escape': { + onClose() + break + } + } + }, []) + + return ( + + + + + } + /> + + + + + + + + + + + + ) +} +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', +}) diff --git a/packages/tldraw/src/components/Primitives/AlertDialog/index.ts b/packages/tldraw/src/components/Primitives/AlertDialog/index.ts index 697b6b489..ca1d3eb9c 100644 --- a/packages/tldraw/src/components/Primitives/AlertDialog/index.ts +++ b/packages/tldraw/src/components/Primitives/AlertDialog/index.ts @@ -1,2 +1,3 @@ export * from './AlertDialog' +export * from './FilenameDialog' export * from './Alert' diff --git a/packages/tldraw/src/components/TopPanel/Menu/Menu.tsx b/packages/tldraw/src/components/TopPanel/Menu/Menu.tsx index 7f7bb7d23..4bcd9820f 100644 --- a/packages/tldraw/src/components/TopPanel/Menu/Menu.tsx +++ b/packages/tldraw/src/components/TopPanel/Menu/Menu.tsx @@ -1,7 +1,9 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import { HamburgerMenuIcon } from '@radix-ui/react-icons' +import { supported } from 'browser-fs-access' import * as React from 'react' import { FormattedMessage, useIntl } from 'react-intl' +import { FilenameDialog } from '~components/Primitives/AlertDialog' import { Divider } from '~components/Primitives/Divider' import { DMContent, DMItem, DMSubMenu, DMTriggerIcon } from '~components/Primitives/DropdownMenu' import { preventEvent } from '~components/preventEvent' @@ -25,6 +27,7 @@ const disableAssetsSelector = (s: TDSnapshot) => { export const Menu = React.memo(function Menu({ readOnly }: MenuProps) { const app = useTldrawApp() const intl = useIntl() + const [openDialog, setOpenDialog] = React.useState(false) 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 handleSaveProjectAs = React.useCallback(() => { + if (!supported) { + setOpenDialog(true) + } else { + app.saveProjectAs() + } + }, [app]) + const handleDelete = React.useCallback(() => { app.delete() }, [app]) @@ -110,203 +121,214 @@ export const Menu = React.memo(function Menu({ readOnly }: MenuProps) { const hasSelection = numberOfSelectedIds > 0 return ( - - - - - - {showFileMenu && ( - - {app.callbacks.onNewProject && ( - - - - )} - {app.callbacks.onOpenProject && ( - - - ... - - )} - {app.callbacks.onSaveProject && ( - - - - )} - {app.callbacks.onSaveProjectAs && ( - - - ... - - )} - {!disableAssets && ( - <> - - - + <> + + + + + + {showFileMenu && ( + + {app.callbacks.onNewProject && ( + + - - )} - - )} - - - - - - - - - - - - - - - - - - - - - SVG + )} + {app.callbacks.onOpenProject && ( + + + ... + + )} + {app.callbacks.onSaveProject && ( + + + + )} + {app.callbacks.onSaveProjectAs && ( + + + ... + + )} + {!disableAssets && ( + <> + + + + + + )} + + )} + + + - - PNG + + - - JSON + + + - - - - SVG + + - - PNG + + - - JPG - - - WEBP - - - JSON - - + + + + SVG + + + PNG + + + JSON + + + + + SVG + + + PNG + + + JPG + + + WEBP + + + JSON + + + + + + + + + + + + + + + + + + + + + + + 100% + + + + + + + + - - - - - - - - - - - - - - - - - - - - 100% - - - - - - - - - - - - + + + + setOpenDialog(false)} /> + ) }) diff --git a/packages/tldraw/src/constants.ts b/packages/tldraw/src/constants.ts index e8c8ae4a5..cc2630aed 100644 --- a/packages/tldraw/src/constants.ts +++ b/packages/tldraw/src/constants.ts @@ -17,6 +17,8 @@ export const LABEL_POINT = [0.5, 0.5] export const PI2 = Math.PI * 2 +export const FILE_EXTENSION = '.tldr' + export const EASINGS: Record number> = { linear: (t) => t, easeInQuad: (t) => t * t, diff --git a/packages/tldraw/src/state/TldrawApp.ts b/packages/tldraw/src/state/TldrawApp.ts index e1454337b..8966d3c47 100644 --- a/packages/tldraw/src/state/TldrawApp.ts +++ b/packages/tldraw/src/state/TldrawApp.ts @@ -1441,9 +1441,9 @@ export class TldrawApp extends StateManager { /** * Save the current project as a new file. */ - saveProjectAs = async () => { + saveProjectAs = async (filename?: string) => { try { - const fileHandle = await saveToFileSystem(this.document, null) + const fileHandle = await saveToFileSystem(this.document, null, filename) this.fileSystemHandle = fileHandle this.persist({}) this.isDirty = false diff --git a/packages/tldraw/src/state/data/filesystem.ts b/packages/tldraw/src/state/data/filesystem.ts index e36c14eee..3e1f9b9dc 100644 --- a/packages/tldraw/src/state/data/filesystem.ts +++ b/packages/tldraw/src/state/data/filesystem.ts @@ -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 { 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' const options = { mode: 'readwrite' as const } @@ -26,7 +26,8 @@ export async function saveFileHandle(fileHandle: FileSystemFileHandle | null) { export async function saveToFileSystem( document: TDDocument, - fileHandle: FileSystemFileHandle | null + fileHandle: FileSystemFileHandle | null, + name?: string ) { // Create the saved file data const file: TDFile = { @@ -48,14 +49,14 @@ export async function saveToFileSystem( const hasPermissions = await checkPermissions(fileHandle) if (!hasPermissions) return null } - + const filename = !supported && name?.length ? name : `${file.name}` // Save to file system const newFileHandle = await fileSave( blob, { - fileName: `${file.name}.tldr`, + fileName: `${filename}${FILE_EXTENSION}`, description: 'Tldraw File', - extensions: [`.tldr`], + extensions: [`${FILE_EXTENSION}`], }, fileHandle ) @@ -73,7 +74,7 @@ export async function openFromFileSystem(): Promise