Skip to content

Commit

Permalink
Support download and file drag and drop
Browse files Browse the repository at this point in the history
Added buttons to download an .xivplan file to the save and share menus.
Added a drop handler to open .xivplan files by dragging and dropping
them onto the app. This gives a way to save and open files on browsers
that don't support the file system API.
  • Loading branch information
joelspadin committed Jun 21, 2024
1 parent 4571415 commit db86851
Show file tree
Hide file tree
Showing 12 changed files with 352 additions and 85 deletions.
17 changes: 14 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -68,16 +69,26 @@ export const BaseProviders: React.FC<PropsWithChildren> = ({ children }) => {
};

const Layout: React.FC = () => {
return (
<BaseProviders>
<Root />
</BaseProviders>
);
};

const Root: React.FC = () => {
const classes = useStyles();
const { onDragOver, onDrop, renderModal } = useFileLoaderDropTarget();

return (
<BaseProviders>
<div className={classes.root}>
<>
<div className={classes.root} onDragOver={onDragOver} onDrop={onDrop}>
<Toaster position="top" />
<SiteHeader className={classes.header} />
<Outlet />
</div>
</BaseProviders>
{renderModal()}
</>
);
};

Expand Down
15 changes: 4 additions & 11 deletions src/FileOpenPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<ReactNode>();

const navigateToMainPage = useCallback(() => {
Expand All @@ -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);
Expand All @@ -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 (
<div className={classes.root}>
Expand Down
198 changes: 135 additions & 63 deletions src/MainToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Toolbar className={classes.toolbar}>
{/* <CollapsableToolbarButton icon={<NewRegular />}>New</CollapsableToolbarButton> */}
<CollapsableToolbarButton icon={<OpenRegular />} onClick={() => setOpenFileOpen(true)}>
Open
</CollapsableToolbarButton>

<SaveButton />

<CollapsableToolbarButton icon={<ArrowUndoRegular />} onClick={undo} disabled={!undoPossible}>
Undo
</CollapsableToolbarButton>
<CollapsableToolbarButton icon={<ArrowRedoRegular />} onClick={redo} disabled={!redoPossible}>
Redo
</CollapsableToolbarButton>

<ToolbarDivider />

<ShareDialogButton>Share</ShareDialogButton>
</Toolbar>
);
}, [undoPossible, redoPossible, classes.toolbar, setOpenFileOpen, undo, redo]);

useToolbar(toolbar);

return (
<>
<DialogOpenContext.Provider value={setOpenFileOpen}>
<OpenDialog open={openFileOpen} onOpenChange={(ev, data) => setOpenFileOpen(data.open)} />
</DialogOpenContext.Provider>
</>
);
};

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: <SaveEditRegular /> };
}

if (source.type === 'blob') {
return { type: 'download', text: 'Download', icon: <ArrowDownloadRegular /> };
}

return { type: 'save', text: 'Save', icon: <SaveRegular />, disabled: !isDirty };
}

interface SaveButtonProps {}

const SaveButton: React.FC<SaveButtonProps> = () => {
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) {
Expand All @@ -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',
Expand All @@ -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 (
<>
<Menu positioning="below-end">
<MenuTrigger disableButtonEnhancement>
{(triggerProps: MenuButtonProps) => (
<CollapsableSplitButton
menuButton={triggerProps}
primaryActionButton={{ onClick: save, disabled: !isDirty }}
icon={<SaveRegular />}
primaryActionButton={{ onClick: handleClick, disabled }}
icon={icon}
appearance="subtle"
>
Save
{text}
</CollapsableSplitButton>
)}
</MenuTrigger>
<MenuPopover>
<MenuList>
<MenuItem icon={<SaveEditRegular />} onClick={() => setSaveAsOpen(true)}>
Save as...
</MenuItem>
{type !== 'saveas' && (
<MenuItem icon={<SaveEditRegular />} onClick={() => setSaveAsOpen(true)}>
Save as...
</MenuItem>
)}
{type !== 'download' && (
<MenuItem icon={<ArrowDownloadRegular />} onClick={download}>
Download
</MenuItem>
)}
</MenuList>
</MenuPopover>
</Menu>
);

const saveAsButton = (
<CollapsableToolbarButton icon={<SaveEditRegular />} onClick={() => setSaveAsOpen(true)}>
Save as
</CollapsableToolbarButton>
);

return (
<Toolbar>
{/* <CollapsableToolbarButton icon={<NewRegular />}>New</CollapsableToolbarButton> */}
<CollapsableToolbarButton icon={<OpenRegular />} onClick={() => setOpenFileOpen(true)}>
Open
</CollapsableToolbarButton>

{source ? saveButton : saveAsButton}

<CollapsableToolbarButton icon={<ArrowUndoRegular />} onClick={undo} disabled={!undoPossible}>
Undo
</CollapsableToolbarButton>
<CollapsableToolbarButton icon={<ArrowRedoRegular />} onClick={redo} disabled={!redoPossible}>
Redo
</CollapsableToolbarButton>

<ToolbarDivider />

<ShareDialogButton>Share</ShareDialogButton>
</Toolbar>
);
}, [source, isDirty, undoPossible, redoPossible, undo, redo, save, setOpenFileOpen]);

useToolbar(toolbar);

return (
<>
<DialogOpenContext.Provider value={setOpenFileOpen}>
<OpenDialog open={openFileOpen} onOpenChange={(ev, data) => setOpenFileOpen(data.open)} />
</DialogOpenContext.Provider>
<DialogOpenContext.Provider value={setSaveAsOpen}>
<SaveAsDialog open={saveAsOpen} onOpenChange={(ev, data) => setSaveAsOpen(data.open)} />
</DialogOpenContext.Provider>
Expand Down
18 changes: 18 additions & 0 deletions src/MessageToast.tsx
Original file line number Diff line number Diff line change
@@ -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<MessageToastProps> = ({ title, message, ...props }) => {
const messageStr = typeof message === 'string' ? message : message instanceof Error ? message.message : undefined;

return (
<Toast {...props}>
<ToastTitle>{title}</ToastTitle>
<ToastBody>{messageStr}</ToastBody>
</Toast>
);
};
8 changes: 7 additions & 1 deletion src/SceneProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions src/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,6 +17,10 @@ export async function saveFile(scene: Readonly<Scene>, source: FileSource): Prom
case 'fs':
await saveFileFs(scene, source.handle);
break;

case 'blob':
downloadScene(scene, source.name);
break;
}
}

Expand All @@ -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);
}
}

Expand Down
Loading

0 comments on commit db86851

Please sign in to comment.