diff --git a/src/App.tsx b/src/App.tsx index 63f6a79..16d465c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { SceneProvider } from './SceneProvider'; import { SiteHeader } from './SiteHeader'; import { ThemeProvider } from './ThemeProvider'; import { ToolbarProvider } from './ToolbarProvider'; +import { useFileLoaderDropTarget } from './useFileLoader'; const useStyles = makeStyles({ root: { @@ -68,16 +69,26 @@ export const BaseProviders: React.FC = ({ children }) => { }; const Layout: React.FC = () => { + return ( + + + + ); +}; + +const Root: React.FC = () => { const classes = useStyles(); + const { onDragOver, onDrop, renderModal } = useFileLoaderDropTarget(); return ( - -
+ <> +
- + {renderModal()} + ); }; diff --git a/src/FileOpenPage.tsx b/src/FileOpenPage.tsx index 19eec91..bd61b2d 100644 --- a/src/FileOpenPage.tsx +++ b/src/FileOpenPage.tsx @@ -1,9 +1,7 @@ import { Button, makeStyles, tokens } from '@fluentui/react-components'; import React, { ReactNode, useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useLoadScene } from './SceneProvider'; -import { openFile } from './file'; -import { getFileSource } from './file/filesystem'; +import { useFileLoader } from './useFileLoader'; function isFile(handle: FileSystemHandle): handle is FileSystemFileHandle { return handle.kind === 'file'; @@ -16,7 +14,7 @@ function isFile(handle: FileSystemHandle): handle is FileSystemFileHandle { export const FileOpenPage: React.FC = () => { const classes = useStyles(); const navigate = useNavigate(); - const loadScene = useLoadScene(); + const loadFile = useFileLoader(); const [error, setError] = useState(); const navigateToMainPage = useCallback(() => { @@ -33,12 +31,7 @@ export const FileOpenPage: React.FC = () => { } try { - const source = getFileSource(file); - const scene = await openFile(source); - - // TODO: add to recent files list - - loadScene(scene, source); + await loadFile(file); navigateToMainPage(); } catch (ex) { console.error('Failed to open file', ex); @@ -56,7 +49,7 @@ export const FileOpenPage: React.FC = () => { console.error('Cannot open file. This browser does not support window.launchQueue.'); navigateToMainPage(); } - }, [loadScene, navigateToMainPage, setError]); + }, [loadFile, navigateToMainPage, setError]); return (
diff --git a/src/MainToolbar.tsx b/src/MainToolbar.tsx index 5090b80..1028885 100644 --- a/src/MainToolbar.tsx +++ b/src/MainToolbar.tsx @@ -7,28 +7,115 @@ import { MenuTrigger, Toolbar, ToolbarDivider, + makeStyles, } from '@fluentui/react-components'; -import { ArrowRedoRegular, ArrowUndoRegular, OpenRegular, SaveEditRegular, SaveRegular } from '@fluentui/react-icons'; -import React, { useCallback, useMemo, useState } from 'react'; +import { + ArrowDownloadRegular, + ArrowRedoRegular, + ArrowUndoRegular, + OpenRegular, + SaveEditRegular, + SaveRegular, +} from '@fluentui/react-icons'; +import React, { ReactElement, useCallback, useMemo, useState } from 'react'; import { CollapsableSplitButton, CollapsableToolbarButton } from './CollapsableToolbarButton'; import { useHotkeys } from './HotkeyHelpProvider'; -import { useScene, useSceneUndoRedo, useSceneUndoRedoPossible } from './SceneProvider'; +import { FileSource, useScene, useSceneUndoRedo, useSceneUndoRedoPossible } from './SceneProvider'; import { saveFile } from './file'; import { OpenDialog, SaveAsDialog } from './file/FileDialog'; import { ShareDialogButton } from './file/ShareDialogButton'; +import { downloadScene, getBlobSource } from './file/blob'; import { DialogOpenContext } from './useCloseDialog'; import { useIsDirty, useSetSavedState } from './useIsDirty'; import { useToolbar } from './useToolbar'; +const useStyles = makeStyles({ + toolbar: { + paddingLeft: 0, + paddingRight: 0, + }, +}); + export const MainToolbar: React.FC = () => { + const classes = useStyles(); + const [undo, redo] = useSceneUndoRedo(); + const [undoPossible, redoPossible] = useSceneUndoRedoPossible(); const [openFileOpen, setOpenFileOpen] = useState(false); - const [saveAsOpen, setSaveAsOpen] = useState(false); + useHotkeys( + 'ctrl+o', + '2.File', + 'Open', + (e) => { + setOpenFileOpen(true); + e.preventDefault(); + }, + [setOpenFileOpen], + ); + + const toolbar = useMemo(() => { + return ( + + {/* }>New */} + } onClick={() => setOpenFileOpen(true)}> + Open + + + + + } onClick={undo} disabled={!undoPossible}> + Undo + + } onClick={redo} disabled={!redoPossible}> + Redo + + + + + Share + + ); + }, [undoPossible, redoPossible, classes.toolbar, setOpenFileOpen, undo, redo]); + + useToolbar(toolbar); + + return ( + <> + + setOpenFileOpen(data.open)} /> + + + ); +}; + +interface SaveButtonState { + type: 'save' | 'saveas' | 'download'; + text: string; + icon: ReactElement; + disabled?: boolean; +} + +function getSaveButtonState(source: FileSource | undefined, isDirty: boolean): SaveButtonState { + if (!source) { + return { type: 'saveas', text: 'Save as', icon: }; + } + + if (source.type === 'blob') { + return { type: 'download', text: 'Download', icon: }; + } + + return { type: 'save', text: 'Save', icon: , disabled: !isDirty }; +} + +interface SaveButtonProps {} + +const SaveButton: React.FC = () => { const isDirty = useIsDirty(); const setSavedState = useSetSavedState(); - const [undo, redo] = useSceneUndoRedo(); - const [undoPossible, redoPossible] = useSceneUndoRedoPossible(); - const { scene, source } = useScene(); + const [saveAsOpen, setSaveAsOpen] = useState(false); + const { scene, source, dispatch } = useScene(); + + const { type, text, icon, disabled } = getSaveButtonState(source, isDirty); const save = useCallback(async () => { if (!source) { @@ -39,16 +126,29 @@ export const MainToolbar: React.FC = () => { } }, [scene, source, isDirty, setSavedState, setSaveAsOpen]); - useHotkeys( - 'ctrl+o', - '2.File', - 'Open', - (e) => { - setOpenFileOpen(true); - e.preventDefault(); - }, - [setOpenFileOpen], - ); + const download = useCallback(() => { + downloadScene(scene, source?.name); + if (!source) { + dispatch({ type: 'setSource', source: getBlobSource() }); + } + }, [scene, source, dispatch]); + + const handleClick = useCallback(() => { + switch (type) { + case 'save': + save(); + break; + + case 'saveas': + setSaveAsOpen(true); + break; + + case 'download': + download(); + break; + } + }, [type, download, save, setSaveAsOpen]); + useHotkeys( 'ctrl+s', '2.File', @@ -70,67 +170,39 @@ export const MainToolbar: React.FC = () => { [setSaveAsOpen], ); - const toolbar = useMemo(() => { - const saveButton = ( + // TODO: now that save button is always enabled, add a dirty indicator elsewhere. Put file name and indicator next + // to XIVPlan title? + + return ( + <> {(triggerProps: MenuButtonProps) => ( } + primaryActionButton={{ onClick: handleClick, disabled }} + icon={icon} appearance="subtle" > - Save + {text} )} - } onClick={() => setSaveAsOpen(true)}> - Save as... - + {type !== 'saveas' && ( + } onClick={() => setSaveAsOpen(true)}> + Save as... + + )} + {type !== 'download' && ( + } onClick={download}> + Download + + )} - ); - - const saveAsButton = ( - } onClick={() => setSaveAsOpen(true)}> - Save as - - ); - - return ( - - {/* }>New */} - } onClick={() => setOpenFileOpen(true)}> - Open - - - {source ? saveButton : saveAsButton} - - } onClick={undo} disabled={!undoPossible}> - Undo - - } onClick={redo} disabled={!redoPossible}> - Redo - - - - - Share - - ); - }, [source, isDirty, undoPossible, redoPossible, undo, redo, save, setOpenFileOpen]); - - useToolbar(toolbar); - - return ( - <> - - setOpenFileOpen(data.open)} /> - setSaveAsOpen(data.open)} /> diff --git a/src/MessageToast.tsx b/src/MessageToast.tsx new file mode 100644 index 0000000..71921ff --- /dev/null +++ b/src/MessageToast.tsx @@ -0,0 +1,18 @@ +import { Toast, ToastBody, ToastProps, ToastTitle } from '@fluentui/react-components'; +import React from 'react'; + +export interface MessageToastProps extends ToastProps { + title: string; + message: string | Error | unknown; +} + +export const MessageToast: React.FC = ({ title, message, ...props }) => { + const messageStr = typeof message === 'string' ? message : message instanceof Error ? message.message : undefined; + + return ( + + {title} + {messageStr} + + ); +}; diff --git a/src/SceneProvider.tsx b/src/SceneProvider.tsx index 3987fc4..6e40498 100644 --- a/src/SceneProvider.tsx +++ b/src/SceneProvider.tsx @@ -141,7 +141,13 @@ export interface FileSystemFileSource { handle: FileSystemFileHandle; } -export type FileSource = LocalStorageFileSource | FileSystemFileSource; +export interface BlobFileSource { + type: 'blob'; + name: string; + file?: File; +} + +export type FileSource = LocalStorageFileSource | FileSystemFileSource | BlobFileSource; export interface EditorState { scene: Scene; diff --git a/src/file.ts b/src/file.ts index 96cd305..0adbbdc 100644 --- a/src/file.ts +++ b/src/file.ts @@ -2,6 +2,7 @@ import { Base64 } from 'js-base64'; import { deflate, inflate } from 'pako'; import { FileSource } from './SceneProvider'; +import { downloadScene, openFileBlob } from './file/blob'; import { openFileFs, saveFileFs } from './file/filesystem'; import { openFileLocalStorage, saveFileLocalStorage } from './file/localStorage'; import { upgradeScene } from './file/upgrade'; @@ -16,6 +17,10 @@ export async function saveFile(scene: Readonly, source: FileSource): Prom case 'fs': await saveFileFs(scene, source.handle); break; + + case 'blob': + downloadScene(scene, source.name); + break; } } @@ -31,6 +36,12 @@ async function openFileUnvalidated(source: FileSource) { case 'fs': return await openFileFs(source.handle); + + case 'blob': + if (!source.file) { + throw new Error('File not set'); + } + return await openFileBlob(source.file); } } diff --git a/src/file/DownloadButton.tsx b/src/file/DownloadButton.tsx new file mode 100644 index 0000000..9b4e140 --- /dev/null +++ b/src/file/DownloadButton.tsx @@ -0,0 +1,15 @@ +import { Button, ButtonProps } from '@fluentui/react-components'; +import { ArrowDownloadRegular } from '@fluentui/react-icons'; +import React from 'react'; +import { useScene } from '../SceneProvider'; +import { downloadScene } from './blob'; + +export const DownloadButton: React.FC = ({ ...props }) => { + const { scene, source } = useScene(); + + return ( + + ); +}; diff --git a/src/file/FileDialog.tsx b/src/file/FileDialog.tsx index 7bb8624..f2dc71c 100644 --- a/src/file/FileDialog.tsx +++ b/src/file/FileDialog.tsx @@ -80,7 +80,7 @@ export const SaveAsDialog: React.FC = (props) => { {tab === 'file' && } {tab === 'localStorage' && } - {tab === 'fileUnsupported' && } + {tab === 'fileUnsupported' && } @@ -96,7 +96,7 @@ const useStyles = makeStyles({ }, saveContent: { - minHeight: '130px', + minHeight: '140px', }, tabs: { diff --git a/src/file/FileDialogFileSystem.tsx b/src/file/FileDialogFileSystem.tsx index 705aa4e..69072f9 100644 --- a/src/file/FileDialogFileSystem.tsx +++ b/src/file/FileDialogFileSystem.tsx @@ -7,6 +7,7 @@ import { openFile, saveFile } from '../file'; import { useCloseDialog } from '../useCloseDialog'; import { useDialogActions } from '../useDialogActions'; import { useIsDirty, useSetSavedState } from '../useIsDirty'; +import { DownloadButton } from './DownloadButton'; import { useConfirmUnsavedChanges } from './confirm'; import { addRecentFile, @@ -128,9 +129,14 @@ export const SaveFileSystem: React.FC = () => { ); }; -export const FileSystemNotSupportedMessage: React.FC = () => { +export interface FileSystemNotSupportedMessageProps { + download?: boolean; +} + +export const FileSystemNotSupportedMessage: React.FC = ({ download }) => { useDialogActions( <> + {download && } @@ -140,7 +146,10 @@ export const FileSystemNotSupportedMessage: React.FC = () => { return ( <>
-

Your browser does not support the experimental File System API.

+

+ Your browser does not support the experimental File System API. You can download the plan as an{' '} + .xivplan file, then drag and drop it onto the page to open it again. +

To save files locally, use a Chromium-based browser such as{' '} Edge or{' '} diff --git a/src/file/ShareDialogButton.tsx b/src/file/ShareDialogButton.tsx index b09e2d2..d74a209 100644 --- a/src/file/ShareDialogButton.tsx +++ b/src/file/ShareDialogButton.tsx @@ -11,6 +11,7 @@ import { Textarea, Toast, ToastTitle, + makeStyles, useToastController, } from '@fluentui/react-components'; import { CopyRegular, ShareRegular } from '@fluentui/react-icons'; @@ -19,6 +20,7 @@ import { CollapsableToolbarButton } from '../CollapsableToolbarButton'; import { useScene } from '../SceneProvider'; import { sceneToText } from '../file'; import { Scene } from '../scene'; +import { DownloadButton } from './DownloadButton'; export interface ShareDialogButtonProps { children?: ReactNode | undefined; @@ -39,6 +41,7 @@ export const ShareDialogButton: React.FC = ({ children } }; const ShareDialogBody: React.FC = () => { + const classes = useStyles(); const { scene } = useScene(); const { dispatchToast } = useToastController(); const url = useMemo(() => getSceneUrl(scene), [scene]); @@ -56,11 +59,14 @@ const ShareDialogBody: React.FC = () => {