mirror of
https://github.com/naturalcrit/homebrewery.git
synced 2026-01-11 17:42:42 +00:00
Fix Autosave and unsaved changes warning
Use normal setTimeout for autosave instead of _.debounce. Fixes a lot of issues with functional component. Also fix existing bug where multiple "unsaved data" warnings could be queued up if the user keeps typing while the warning is being displayed.
This commit is contained in:
@@ -36,7 +36,7 @@ import { updateHistory, versionHistoryGarbageCollection } from '../../utils/vers
|
|||||||
import googleDriveIcon from '../../googleDrive.svg';
|
import googleDriveIcon from '../../googleDrive.svg';
|
||||||
|
|
||||||
const SAVE_TIMEOUT = 10000;
|
const SAVE_TIMEOUT = 10000;
|
||||||
const UNSAVED_WARNING_TIMEOUT = 900000; //Warn user afer 15 minutes of unsaved changes
|
const UNSAVED_WARNING_TIMEOUT = 9000; //Warn user afer 15 minutes of unsaved changes
|
||||||
const UNSAVED_WARNING_POPUP_TIMEOUT = 4000; //Show the warning for 4 seconds
|
const UNSAVED_WARNING_POPUP_TIMEOUT = 4000; //Show the warning for 4 seconds
|
||||||
|
|
||||||
const EditPage = (props)=>{
|
const EditPage = (props)=>{
|
||||||
@@ -47,6 +47,7 @@ const EditPage = (props)=>{
|
|||||||
|
|
||||||
const [currentBrew , setCurrentBrew ] = useState(props.brew);
|
const [currentBrew , setCurrentBrew ] = useState(props.brew);
|
||||||
const [isSaving , setIsSaving ] = useState(false);
|
const [isSaving , setIsSaving ] = useState(false);
|
||||||
|
const [lastSavedTime , setLastSavedTime ] = useState(new Date());
|
||||||
const [saveGoogle , setSaveGoogle ] = useState(!!props.brew.googleId);
|
const [saveGoogle , setSaveGoogle ] = useState(!!props.brew.googleId);
|
||||||
const [error , setError ] = useState(null);
|
const [error , setError ] = useState(null);
|
||||||
const [HTMLErrors , setHTMLErrors ] = useState(Markdown.validate(props.brew.text));
|
const [HTMLErrors , setHTMLErrors ] = useState(Markdown.validate(props.brew.text));
|
||||||
@@ -58,56 +59,56 @@ const EditPage = (props)=>{
|
|||||||
const [alertTrashedGoogleBrew , setAlertTrashedGoogleBrew ] = useState(props.brew.trashed);
|
const [alertTrashedGoogleBrew , setAlertTrashedGoogleBrew ] = useState(props.brew.trashed);
|
||||||
const [alertLoginToTransfer , setAlertLoginToTransfer ] = useState(false);
|
const [alertLoginToTransfer , setAlertLoginToTransfer ] = useState(false);
|
||||||
const [confirmGoogleTransfer , setConfirmGoogleTransfer ] = useState(false);
|
const [confirmGoogleTransfer , setConfirmGoogleTransfer ] = useState(false);
|
||||||
const [url , setUrl ] = useState('');
|
|
||||||
const [autoSaveEnabled , setAutoSaveEnabled ] = useState(true);
|
const [autoSaveEnabled , setAutoSaveEnabled ] = useState(true);
|
||||||
const [autoSaveWarning , setAutoSaveWarning ] = useState(true);
|
const [warnUnsavedChanges , setWarnUnsavedChanges ] = useState(true);
|
||||||
const [unsavedTime , setUnsavedTime ] = useState(new Date());
|
|
||||||
|
|
||||||
const editorRef = useRef(null);
|
const editorRef = useRef(null);
|
||||||
const savedBrew = useRef(_.cloneDeep(props.brew));
|
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
|
||||||
const warningTimer = useRef(null);
|
const saveTimeout = useRef(null);
|
||||||
const debounceSave = useCallback(_.debounce((brew, saveToGoogle)=>save(brew, saveToGoogle), SAVE_TIMEOUT), []);
|
const warnUnsavedTimeout = useRef(null);
|
||||||
|
const trySaveRef = useRef(trySave); // CTRL+S listener lives outside React and needs ref to use trySave with latest copy of brew
|
||||||
|
const unsavedChangesRef = useRef(unsavedChanges); // Similarly, onBeforeUnload lives outside React and needs ref to unsavedChanges
|
||||||
|
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
setUrl(window.location.href);
|
|
||||||
|
|
||||||
const autoSavePref = JSON.parse(localStorage.getItem('AUTOSAVE_ON') ?? true);
|
const autoSavePref = JSON.parse(localStorage.getItem('AUTOSAVE_ON') ?? true);
|
||||||
setAutoSaveEnabled(autoSavePref);
|
setAutoSaveEnabled(autoSavePref);
|
||||||
setAutoSaveWarning(!autoSavePref);
|
setWarnUnsavedChanges(!autoSavePref);
|
||||||
setHTMLErrors(Markdown.validate(currentBrew.text));
|
setHTMLErrors(Markdown.validate(currentBrew.text));
|
||||||
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
|
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
|
||||||
|
|
||||||
document.addEventListener('keydown', handleControlKeys);
|
const handleControlKeys = (e) => {
|
||||||
window.onbeforeunload = ()=>{
|
if (!(e.ctrlKey || e.metaKey)) return;
|
||||||
if(isSaving || unsavedChanges)
|
if (e.keyCode === 83) trySaveRef.current(true);
|
||||||
return 'You have unsaved changes!';
|
if (e.keyCode === 80) printCurrentBrew();
|
||||||
|
if ([83, 80].includes(e.keyCode)) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return ()=>{
|
document.addEventListener('keydown', handleControlKeys);
|
||||||
|
window.onbeforeunload = ()=>{
|
||||||
|
if(unsavedChangesRef.current)
|
||||||
|
return 'You have unsaved changes!';
|
||||||
|
};
|
||||||
|
return () => {
|
||||||
document.removeEventListener('keydown', handleControlKeys);
|
document.removeEventListener('keydown', handleControlKeys);
|
||||||
window.onbeforeunload = null;
|
window.onBeforeUnload = null;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
const hasChange = !_.isEqual(currentBrew, savedBrew.current);
|
trySaveRef.current = trySave;
|
||||||
|
unsavedChangesRef.current = unsavedChanges;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(()=>{
|
||||||
|
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
|
||||||
setUnsavedChanges(hasChange);
|
setUnsavedChanges(hasChange);
|
||||||
|
|
||||||
if(hasChange && autoSaveEnabled) trySave();
|
if(autoSaveEnabled) trySave(false, hasChange);
|
||||||
}, [currentBrew]);
|
}, [currentBrew]);
|
||||||
|
|
||||||
const handleControlKeys = (e)=>{
|
|
||||||
if(!(e.ctrlKey || e.metaKey)) return;
|
|
||||||
const S_KEY = 83;
|
|
||||||
const P_KEY = 80;
|
|
||||||
if(e.keyCode === S_KEY) trySave(true);
|
|
||||||
if(e.keyCode === P_KEY) printCurrentBrew();
|
|
||||||
if(e.keyCode === S_KEY || e.keyCode === P_KEY) {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSplitMove = ()=>{
|
const handleSplitMove = ()=>{
|
||||||
editorRef.current?.update();
|
editorRef.current?.update();
|
||||||
};
|
};
|
||||||
@@ -156,22 +157,10 @@ const EditPage = (props)=>{
|
|||||||
snippets : newData.snippets
|
snippets : newData.snippets
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const trySave = (immediate = false)=>{
|
const resetWarnUnsavedTimer = ()=>{
|
||||||
//debounceSave = _.debounce(save, SAVE_TIMEOUT);
|
setTimeout(()=>setWarnUnsavedChanges(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds
|
||||||
if(isSaving) return;
|
clearTimeout(warnUnsavedTimeout.current);
|
||||||
|
warnUnsavedTimeout.current = setTimeout(()=>setWarnUnsavedChanges(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved work warnings
|
||||||
const hasChange = !_.isEqual(currentBrew, savedBrew.current);
|
|
||||||
|
|
||||||
if(immediate) {
|
|
||||||
debounceSave(currentBrew, saveGoogle);
|
|
||||||
debounceSave.flush?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(hasChange)
|
|
||||||
debounceSave(currentBrew, saveGoogle);
|
|
||||||
else
|
|
||||||
debounceSave.cancel?.();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGoogleClick = ()=>{
|
const handleGoogleClick = ()=>{
|
||||||
@@ -197,11 +186,26 @@ const EditPage = (props)=>{
|
|||||||
trySave(true);
|
trySave(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const save = async (brew, saveToGoogle)=>{
|
const trySave = (immediate = false, hasChanges = true)=>{
|
||||||
debounceSave?.cancel?.();
|
clearTimeout(saveTimeout.current);
|
||||||
|
if(isSaving) return;
|
||||||
|
if(!hasChanges && !immediate) return;
|
||||||
|
const newTimeout = immediate ? 0 : SAVE_TIMEOUT;
|
||||||
|
|
||||||
setIsSaving(true);
|
saveTimeout.current = setTimeout(async () => {
|
||||||
setError(null);
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
await save(currentBrew, saveGoogle)
|
||||||
|
.catch((err)=>{
|
||||||
|
setError(err);
|
||||||
|
});
|
||||||
|
setIsSaving(false);
|
||||||
|
setLastSavedTime(new Date());
|
||||||
|
if(!autoSaveEnabled) resetWarnUnsavedTimer();
|
||||||
|
}, newTimeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async (brew, saveToGoogle)=>{
|
||||||
setHTMLErrors(Markdown.validate(brew.text));
|
setHTMLErrors(Markdown.validate(brew.text));
|
||||||
|
|
||||||
await updateHistory(brew).catch(console.error);
|
await updateHistory(brew).catch(console.error);
|
||||||
@@ -212,9 +216,10 @@ const EditPage = (props)=>{
|
|||||||
...brew,
|
...brew,
|
||||||
text : brew.text.normalize('NFC'),
|
text : brew.text.normalize('NFC'),
|
||||||
pageCount : ((brew.renderer === 'legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1,
|
pageCount : ((brew.renderer === 'legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1,
|
||||||
patches : stringifyPatches(makePatches(encodeURI(savedBrew.current.text.normalize('NFC')), encodeURI(brew.text.normalize('NFC')))),
|
patches : stringifyPatches(makePatches(encodeURI(lastSavedBrew.current.text.normalize('NFC')), encodeURI(brew.text.normalize('NFC')))),
|
||||||
hash : await md5(savedBrew.current.text),
|
hash : await md5(lastSavedBrew.current.text),
|
||||||
textBin : undefined
|
textBin : undefined,
|
||||||
|
version : lastSavedBrew.current.version
|
||||||
};
|
};
|
||||||
|
|
||||||
const compressedBrew = gzipSync(strToU8(JSON.stringify(brewToSave)));
|
const compressedBrew = gzipSync(strToU8(JSON.stringify(brewToSave)));
|
||||||
@@ -232,22 +237,24 @@ const EditPage = (props)=>{
|
|||||||
});
|
});
|
||||||
if(!res) return;
|
if(!res) return;
|
||||||
|
|
||||||
const { googleId, editId, shareId, version } = res.body;
|
const updatedFields = {
|
||||||
|
googleId: res.body.googleId ?? null,
|
||||||
savedBrew.current = {
|
editId : res.body.editId,
|
||||||
...brew,
|
shareId : res.body.shareId,
|
||||||
googleId : googleId ?? null,
|
version : res.body.version
|
||||||
editId,
|
|
||||||
shareId,
|
|
||||||
version
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setCurrentBrew(savedBrew.current);
|
lastSavedBrew.current = {
|
||||||
|
...brew,
|
||||||
|
...updatedFields
|
||||||
|
};
|
||||||
|
|
||||||
setIsSaving(false);
|
setCurrentBrew((prevBrew)=>({
|
||||||
setUnsavedTime(new Date());
|
...prevBrew,
|
||||||
|
...updatedFields
|
||||||
|
}));
|
||||||
|
|
||||||
history.replaceState(null, null, `/edit/${editId}`);
|
history.replaceState(null, null, `/edit/${res.body.editId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderGoogleDriveIcon = ()=>(
|
const renderGoogleDriveIcon = ()=>(
|
||||||
@@ -268,7 +275,7 @@ const EditPage = (props)=>{
|
|||||||
{alertLoginToTransfer && (
|
{alertLoginToTransfer && (
|
||||||
<div className='errorContainer' onClick={closeAlerts}>
|
<div className='errorContainer' onClick={closeAlerts}>
|
||||||
You must be signed in to a Google account to transfer between the homebrewery and Google Drive!
|
You must be signed in to a Google account to transfer between the homebrewery and Google Drive!
|
||||||
<a target='_blank' rel='noopener noreferrer' href={`https://www.naturalcrit.com/login?redirect=${url}`}>
|
<a target='_blank' rel='noopener noreferrer' href={`https://www.naturalcrit.com/login?redirect=${window.location.href}`}>
|
||||||
<div className='confirm'> Sign In </div>
|
<div className='confirm'> Sign In </div>
|
||||||
</a>
|
</a>
|
||||||
<div className='deny'> Not Now </div>
|
<div className='deny'> Not Now </div>
|
||||||
@@ -291,9 +298,9 @@ const EditPage = (props)=>{
|
|||||||
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
|
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
|
||||||
|
|
||||||
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
|
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
|
||||||
if(unsavedChanges && autoSaveWarning) {
|
if(unsavedChanges && warnUnsavedChanges) {
|
||||||
resetAutoSaveWarning();
|
resetWarnUnsavedTimer();
|
||||||
const elapsedTime = Math.round((new Date() - unsavedTime) / 1000 / 60);
|
const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
|
||||||
const text = elapsedTime === 0
|
const text = elapsedTime === 0
|
||||||
? 'Autosave is OFF.'
|
? 'Autosave is OFF.'
|
||||||
: `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`;
|
: `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`;
|
||||||
@@ -317,15 +324,11 @@ const EditPage = (props)=>{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleAutoSave = ()=>{
|
const toggleAutoSave = ()=>{
|
||||||
if(warningTimer.current) clearTimeout(warningTimer.current);
|
clearTimeout(warnUnsavedTimeout.current);
|
||||||
|
clearTimeout(saveTimeout.current);
|
||||||
localStorage.setItem('AUTOSAVE_ON', JSON.stringify(!autoSaveEnabled));
|
localStorage.setItem('AUTOSAVE_ON', JSON.stringify(!autoSaveEnabled));
|
||||||
setAutoSaveWarning(autoSaveWarning);
|
|
||||||
setAutoSaveEnabled(!autoSaveEnabled);
|
setAutoSaveEnabled(!autoSaveEnabled);
|
||||||
};
|
setWarnUnsavedChanges(autoSaveEnabled);
|
||||||
|
|
||||||
const resetAutoSaveWarning = ()=>{
|
|
||||||
setTimeout(()=>setAutoSaveWarning(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds
|
|
||||||
warningTimer.current = setTimeout(()=>setAutoSaveWarning(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved changes warnings
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderAutoSaveButton = ()=>(
|
const renderAutoSaveButton = ()=>(
|
||||||
|
|||||||
Reference in New Issue
Block a user