Skip to content

Commit

Permalink
feat: add a new save indicator (#101)
Browse files Browse the repository at this point in the history
  • Loading branch information
mscharley committed Mar 12, 2022
1 parent a0f5bf3 commit cb80054
Show file tree
Hide file tree
Showing 7 changed files with 115 additions and 26 deletions.
51 changes: 51 additions & 0 deletions src/renderer/components/NotificationsOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useAppSelector, useDebouncedState } from '~renderer/hooks';
import Box from '@mui/material/Box';
import type { BoxProps } from '@mui/system/Box';
import SaveAsSharp from '@mui/icons-material/SaveAsSharp';
import { styled } from '@mui/material';
import Typography from '@mui/material/Typography';
import { useEffect } from 'react';

const NotificationsBox = styled(Box)<BoxProps>((context) => ({
position: 'fixed',
bottom: 0,
right: 0,
height: 35,
display: 'flex',
alignItems: 'center',
flexDirection: 'row-reverse',
columnGap: '0.5em',
padding: '0 0.5em',
borderTop: '1px solid',
borderLeft: '1px solid',
borderColor: context.theme.palette.text.disabled,
borderRadius: '3px 0 0 0',
pointerEvents: 'none',
background: context.theme.palette.background.paper,
}));

const fontSize = 'small';

export const NotificationsOverlay: React.FC = () => {
const isDev = useAppSelector((s) => s.about.details?.isDevBuild) ?? false;
const notificationsState = useAppSelector((s) => s.notifications);
const [saving, setSaving, flushSaving] = useDebouncedState(false, 2_000);

useEffect(() => {
setSaving(notificationsState.saving);
if (notificationsState.saving) {
flushSaving();
}
}, [setSaving, flushSaving, notificationsState.saving]);

const devBuild = isDev ? (
<Typography component='div' key='dev' fontSize={fontSize}>
DEV BUILD
</Typography>
) : null;
const saveIcon = saving ? <SaveAsSharp key='save' fontSize={fontSize} /> : null;

const notifications = [devBuild, saveIcon].filter((x) => x != null);

return notifications.length > 0 ? <NotificationsBox>{notifications}</NotificationsBox> : <></>;
};
44 changes: 26 additions & 18 deletions src/renderer/components/editor/MarkdownEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { useEffect, useRef } from 'react';
import Box from '@mui/material/Box';
import CodeMirror from '@uiw/react-codemirror';
import { languages } from '@codemirror/language-data';
import type { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { setFatalError } from '~renderer/redux';
import { useAppDispatch } from '~renderer/hooks';
import { useRef } from 'react';
import { useCodeMirror } from '@uiw/react-codemirror';

export interface MarkdownEditorProps {
value: string;
Expand All @@ -14,26 +13,35 @@ export interface MarkdownEditorProps {

export const MarkdownEditor: React.FC<MarkdownEditorProps> = ({ onChange, value }) => {
const dispatch = useAppDispatch();
const codemirror = useRef<ReactCodeMirrorRef | null>(null);
const editor = useRef<HTMLDivElement | null>(null);

const handleClick: React.MouseEventHandler<HTMLDivElement> = (ev) => {
if (ev.target !== codemirror.current?.editor) {
codemirror.current?.view?.focus();
const { setContainer, view } = useCodeMirror({
container: editor.current,
extensions: [markdown({ base: markdownLanguage, codeLanguages: languages })],
onChange: (s) => {
Promise.resolve(onChange(s)).catch((e) => {
dispatch(setFatalError(e));
});
},
value,
});

useEffect(() => {
if (editor.current != null) {
setContainer(editor.current);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor.current]);

const handleBackgroundClick: React.MouseEventHandler<HTMLDivElement> = (ev) => {
if (ev.target !== editor.current) {
view?.focus();
}
};

return (
<Box sx={{ paddingBottom: 'calc(100vh - 1.7em)' }} onClick={handleClick}>
<CodeMirror
extensions={[markdown({ base: markdownLanguage, codeLanguages: languages })]}
onChange={(s): void => {
Promise.resolve(onChange(s)).catch((e) => {
dispatch(setFatalError(e));
});
}}
value={value}
ref={codemirror}
/>
<Box sx={{ paddingBottom: 'calc(100vh - 1.7em)' }} onClick={handleBackgroundClick}>
<div ref={editor}></div>
</Box>
);
};
4 changes: 4 additions & 0 deletions src/renderer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
import './index.scss';

import createCache from '@emotion/cache';
import { DialogOverlays } from './components/DialogOverlays';
import { generateStore } from '~renderer/redux';
import { LayoutRouter } from './layouts/LayoutRouter';
import { NotificationsOverlay } from './components/NotificationsOverlay';
import { ProviderWrapper } from './ProviderWrapper';
import { render } from 'react-dom';
import { sleep } from '~shared/util';
Expand Down Expand Up @@ -33,6 +35,8 @@ if (root == null) {
render(
<ProviderWrapper cache={cache} store={store}>
<LayoutRouter />
<DialogOverlays />
<NotificationsOverlay />
</ProviderWrapper>,
root,
);
Expand Down
18 changes: 10 additions & 8 deletions src/renderer/layouts/TwoColumnLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import * as http from '~shared/http';
import { useAppSelector, useDebouncedState } from '~renderer/hooks';
import { useAppDispatch, useAppSelector, useDebouncedState } from '~renderer/hooks';
import { useCallback, useEffect } from 'react';
import Box from '@mui/material/Box';
import { DialogOverlays } from '~renderer/components/DialogOverlays';
import Drawer from '@mui/material/Drawer';
import type { FileDescription } from '~shared/model';
import { FileListing } from '~renderer/components/sidebar/FileListing';
import { MarkdownEditor } from '~renderer/components/editor/MarkdownEditor';
import { NoFile } from '~renderer/components/editor/NoFile';
import { setSaving } from '~renderer/redux';
import { SidebarFooter } from '~renderer/components/sidebar/SidebarFooter';

const TITLE_SUFFIX = 'Notes';
Expand All @@ -28,15 +28,17 @@ interface FileState {
content: string;
}

const saveFile = async (state: Partial<FileState>): Promise<void> => {
const saveFile = async (dispatch: ReturnType<typeof useAppDispatch>, state: Partial<FileState>): Promise<void> => {
if (state.loading !== true && state.file != null && state.content != null) {
dispatch(setSaving(true));
const resp = await fetch(state.file.url, {
method: 'PUT',
headers: {
'content-type': 'text/plain',
},
body: state.content,
});
dispatch(setSaving(false));

if (resp.status !== http.OK) {
throw new Error(await resp.text());
Expand All @@ -50,14 +52,15 @@ const drawerWidth = 300;
* Main application entrypoint component.
*/
export const TwoColumnLayout: React.FC = () => {
const dispatch = useAppDispatch();
const openFile = useAppSelector((state) => state.files);
const [contents, setContents, flushContents] = useDebouncedState<Partial<FileState>>({}, SAVE_DELAY);

useEffect(() => {
saveFile(contents as FileState).catch((e) => {
saveFile(dispatch, contents as FileState).catch((e) => {
log.error(e);
});
}, [contents]);
}, [dispatch, contents]);

useEffect(() => {
window.addEventListener('beforeunload', () => {
Expand Down Expand Up @@ -115,13 +118,12 @@ export const TwoColumnLayout: React.FC = () => {
<SidebarFooter width={`${drawerWidth - 1}px`} />
</Drawer>
<Box component='main' sx={{ flexGrow: '1' }}>
{openFile.currentFile == null ? (
{contents.file == null ? (
<NoFile />
) : (
<MarkdownEditor value={contents.content ?? ''} onChange={onChange} />
<MarkdownEditor key={contents.file.name} value={contents.content ?? ''} onChange={onChange} />
)}
</Box>
<DialogOverlays />
</Box>
);
};
1 change: 1 addition & 0 deletions src/renderer/redux/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export { setAboutDetails } from './about/details-slice';
export { updateAppConfiguration } from './configuration/configuration-slice';
export { setFatalError } from './fatal-errors/errors-slice';
export { closeCurrentFile, setCurrentFile, setCurrentFolder, setFileListing } from './markdown-files/files-slice';
export { setSaving } from './notifications/notifications-slice';
export { closeOverlay, confirmDelete, overrideActiveOverlay, setActiveOverlay } from './overlay/overlay-slice';
21 changes: 21 additions & 0 deletions src/renderer/redux/notifications/notifications-slice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createAction, createSlice } from '@reduxjs/toolkit';

export interface NotificationsState {
saving: boolean;
}

const initialState = { saving: false };

export const setSaving = createAction<boolean>('setSaving');

const slice = createSlice({
name: 'notifications',
initialState,
reducers: {},
extraReducers: (builder) =>
builder.addCase(setSaving, (state, { payload: saving }) => {
state.saving = saving;
}),
});

export default slice.reducer;
2 changes: 2 additions & 0 deletions src/renderer/redux/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import aboutReducer from './about/details-slice';
import configurationReducer from './configuration/configuration-slice';
import errorReducer from './fatal-errors/errors-slice';
import filesReducer from './markdown-files/files-slice';
import notificationsReducer from './notifications/notifications-slice';
import overlayReducer from './overlay/overlay-slice';

export const reducer = {
about: aboutReducer,
configuration: configurationReducer,
fatalError: errorReducer,
files: filesReducer,
notifications: notificationsReducer,
overlay: overlayReducer,
} as const;

0 comments on commit cb80054

Please sign in to comment.