Merge branch 'master' of https://github.com/naturalcrit/homebrewery into fix-codemirror

This commit is contained in:
Víctor Losada Hernández
2026-09-20 20:40:13 +02:00
17 changed files with 742 additions and 799 deletions
+26 -40
View File
@@ -23,7 +23,6 @@ import safeHTML from './safeHTML.js';
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
const PAGEBREAK_REGEX_LEGACY = /\\page(?:break)?/m;
const COLUMNBREAK_REGEX_LEGACY = /\\column(:?break)?/m;
const PAGE_HEIGHT = 1056;
const TOOLBAR_STATE_KEY = 'HB_renderer_toolbarState';
@@ -47,31 +46,25 @@ const BrewPage = (props)=>{
};
const pageRef = useRef(null);
const cleanText = safeHTML(props.contents);
const pageNum = props.index + 1;
useEffect(()=>{
if(!pageRef.current) return;
// Observer for tracking pages within the `.pages` div
// Observer for tracking which pages are at least 30% visible in the iframe
const visibleObserver = new IntersectionObserver(
(entries)=>{
entries.forEach((entry)=>{
if(entry.isIntersecting)
props.onVisibilityChange(props.index + 1, true, false); // add page to array of visible pages.
else
props.onVisibilityChange(props.index + 1, false, false);
});
},
(entries)=>entries.forEach((entry)=>{
props.onVisibilityChange(pageNum, entry.isIntersecting, false); // add page to array of visible pages.
}),
{ threshold: .3, rootMargin: '0px 0px 0px 0px' } // detect when >30% of page is within bounds.
);
// Observer for tracking the page at the center of the iframe.
const centerObserver = new IntersectionObserver(
(entries)=>{
entries.forEach((entry)=>{
if(entry.isIntersecting)
props.onVisibilityChange(props.index + 1, true, true); // Set this page as the center page
});
},
(entries)=>entries.forEach((entry)=>{
if(entry.isIntersecting)
props.onVisibilityChange(pageNum, true, true); // Set this page as the center page
}),
{ threshold: 0, rootMargin: '-50% 0px -50% 0px' } // Detect when the page is at the center
);
@@ -92,7 +85,7 @@ const BrewPage = (props)=>{
//v=====--------------------< Brew Renderer Component >-------------------=====v//
let renderedPages = [];
let pageTemplates = [];
const pageTemplates = [];
let rawPages = [];
const BrewRenderer = (props)=>{
@@ -100,22 +93,21 @@ const BrewRenderer = (props)=>{
text : '',
style : '',
renderer : 'legacy',
theme : '5ePHB',
lang : '',
errors : [],
currentEditorCursorPageNum : 1,
currentEditorViewPageNum : 1,
currentBrewRendererPageNum : 1,
themeBundle : {},
onPageChange : ()=>{},
...props
};
const [visiblePages, setVisiblePages] = useState([]);
const [centerPage , setCenterPage ] = useState(1);
const [state, setState] = useState({
isMounted : false,
visibility : 'hidden',
visiblePages : [],
centerPage : 1
isMounted : false,
visibility : 'hidden'
});
const [displayOptions, setDisplayOptions] = useState({
@@ -135,9 +127,7 @@ const BrewRenderer = (props)=>{
const [headerState, setHeaderState] = useState(false);
const mainRef = useRef(null);
const pagesRef = useRef(null);
const urlRef = useRef('');
if(props.renderer == 'legacy') {
rawPages = props.text.split(PAGEBREAK_REGEX_LEGACY);
@@ -146,20 +136,16 @@ const BrewRenderer = (props)=>{
}
const handlePageVisibilityChange = (pageNum, isVisible, isCenter)=>{
setState((prevState)=>{
const updatedVisiblePages = new Set(prevState.visiblePages);
if(!isCenter)
isVisible ? updatedVisiblePages.add(pageNum) : updatedVisiblePages.delete(pageNum);
return {
...prevState,
visiblePages : [...updatedVisiblePages].sort((a, b)=>a - b),
centerPage : isCenter ? pageNum : prevState.centerPage
};
setVisiblePages((prev)=>{
const updatedVisiblePages = new Set(prev);
isVisible ? updatedVisiblePages.add(pageNum) : updatedVisiblePages.delete(pageNum);
return [...updatedVisiblePages].sort((a, b)=>a - b);
});
if(isCenter)
if(isCenter) {
setCenterPage(pageNum);
props.onPageChange(pageNum);
}
};
const isInView = (index)=>{
@@ -341,19 +327,19 @@ const BrewRenderer = (props)=>{
: null}
<ErrorBar errors={props.errors} />
<div className='popups' ref={mainRef}>
<div className='popups'>
<RenderWarnings />
<NotificationPopup />
</div>
<ToolBar displayOptions={displayOptions} onDisplayOptionsChange={handleDisplayOptionsChange} visiblePages={state.visiblePages.length > 0 ? state.visiblePages : [state.centerPage]} totalPages={rawPages.length} headerState={headerState} setHeaderState={setHeaderState}/>
<ToolBar displayOptions={displayOptions} onDisplayOptionsChange={handleDisplayOptionsChange} visiblePages={visiblePages.length > 0 ? visiblePages : [centerPage]} totalPages={rawPages.length} headerState={headerState} setHeaderState={setHeaderState}/>
{/*render in iFrame so broken code doesn't crash the site.*/}
<Frame id='BrewRenderer' title="Rendered Brew Content" initialContent={INITIAL_CONTENT}
<Frame id='BrewRenderer' title='Rendered Brew Content' initialContent={INITIAL_CONTENT}
style={{ width: '100%', height: '100%', visibility: state.visibility }}
contentDidMount={frameDidMount}
onClick={()=>{emitClick();}}
sandbox="allow-same-origin allow-modals allow-top-navigation"
sandbox='allow-same-origin allow-modals allow-top-navigation'
>
<div className='brewRenderer'
onKeyDown={handleControlKeys}
-1
View File
@@ -1,6 +1,5 @@
import 'core-js/es/string/to-well-formed.js'; // Polyfill for older browsers
import './homebrew.less';
import React from 'react';
import { BrowserRouter as Router, Routes, Route, useParams, useSearchParams } from 'react-router';
import { updateLocalStorage } from './utils/updateLocalStorage/updateLocalStorageKeys.js';
+11 -17
View File
@@ -1,34 +1,28 @@
import './navbar.less';
import React from 'react';
import createReactClass from 'create-react-class';
import Nav from './nav.jsx';
import PatreonNavItem from './patreon.navitem.jsx';
const Navbar = createReactClass({
displayName : 'Navbar',
getInitialState : function() {
return {
ver : global.version || '0.0.0'
};
},
const Navbar = ({ children })=>{
const version = global.version || '0.0.0';
render : function(){
return <Nav.base>
return (
<Nav.base>
<Nav.section>
<Nav.logo />
<Nav.item href='/' className='homebrewLogo'>
<div>The Homebrewery</div>
</Nav.item>
<Nav.item newTab={true} href='/changelog' color='purple' icon='far fa-file-alt'>
{`v${this.state.ver}`}
{`v${version}`}
</Nav.item>
<PatreonNavItem />
{/*this.renderChromeWarning()*/}
{/* this.renderChromeWarning() */}
</Nav.section>
{this.props.children}
</Nav.base>;
}
});
{children}
</Nav.base>
);
};
export default Navbar;
export default Navbar;
+12 -98
View File
@@ -9,7 +9,7 @@ import _ from 'lodash';
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
import useCommonEditPageFunctions from '../../utils/commonEditPageFunctions.js'
import useCommonEditPageFunctions from '../../utils/commonEditPageFunctions.jsx'
import SplitPane from '@components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx';
@@ -38,17 +38,13 @@ import LockNotification from './lockNotification/lockNotification.jsx';
import { updateHistory, versionHistoryGarbageCollection } from '../../utils/versionHistory.js';
import googleDriveIcon from '../../googleDrive.svg';
const SAVE_TIMEOUT = 10000;
const UNSAVED_WARNING_TIMEOUT = 900000; //Warn user afer 15 minutes of unsaved changes
const UNSAVED_WARNING_POPUP_TIMEOUT = 4000; //Show the warning for 4 seconds
const BREWKEY = 'HB_newPage_content';
const STYLEKEY = 'HB_newPage_style';
const SNIPKEY = 'HB_newPage_snippets';
const METAKEY = 'HB_newPage_meta';
const useLocalStorage = false;
const sandbox = false;
const sandbox = false;
const EditPage = (props)=>{
props = {
@@ -57,8 +53,6 @@ const EditPage = (props)=>{
};
const [currentBrew, setCurrentBrew] = useState(props.brew);
const [isSaving, setIsSaving] = useState(false);
const [lastSavedTime, setLastSavedTime] = useState(new Date());
const [saveGoogle, setSaveGoogle] = useState(!!props.brew.googleId);
const [error, setError] = useState(null);
const [HTMLErrors, setHTMLErrors] = useState(hbfm.validate(props.brew.text));
@@ -66,18 +60,13 @@ const EditPage = (props)=>{
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [themeBundle, setThemeBundle] = useState({});
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [alertTrashedGoogleBrew, setAlertTrashedGoogleBrew] = useState(props.brew.trashed);
const [alertNoGoogleToTransfer, setAlertNoGoogleToTransfer] = useState(false);
const [alertOwnershipToTransfer, setAlertOwnershipToTransfer] = useState(false);
const [confirmGoogleTransfer, setConfirmGoogleTransfer] = useState(false);
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true);
const [warnUnsavedChanges, setWarnUnsavedChanges] = useState(true);
const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
const saveTimeout = useRef(null);
const warnUnsavedTimeout = useRef(null);
const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
const updateBrew = (newData)=>setCurrentBrew((prevBrew)=>({
...prevBrew,
@@ -86,12 +75,6 @@ const EditPage = (props)=>{
snippets : newData.snippets
}));
const resetWarnUnsavedTimer = ()=>{
setTimeout(()=>setWarnUnsavedChanges(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds
clearTimeout(warnUnsavedTimeout.current);
warnUnsavedTimeout.current = setTimeout(()=>setWarnUnsavedChanges(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved work warnings
};
const handleGoogleClick = ()=>{
if(currentBrew.authors.length > 0 && global.account?.username !== currentBrew.authors[0]) {
setAlertOwnershipToTransfer(true);
@@ -122,25 +105,6 @@ const EditPage = (props)=>{
trySave(true, true, newSaveGoogle);
};
const trySave = useEffectEvent((immediate = false, hasChanges = true, saveToGoogle = false)=>{
clearTimeout(saveTimeout.current);
if(isSaving) return;
if(!hasChanges && !immediate) return;
const newTimeout = immediate ? 0 : SAVE_TIMEOUT;
saveTimeout.current = setTimeout(async ()=>{
setIsSaving(true);
setError(null);
await save(currentBrew, saveToGoogle)
.catch((err)=>{
setError(err);
});
setIsSaving(false);
setLastSavedTime(new Date());
if(!autoSaveEnabled) resetWarnUnsavedTimer();
}, newTimeout);
});
const save = async (brew, saveToGoogle)=>{
setHTMLErrors(hbfm.validate(brew.text));
@@ -237,60 +201,12 @@ const EditPage = (props)=>{
</Nav.item>
);
const renderSaveButton = ()=>{
// #1 - Currently saving, show SAVING
if(isSaving)
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
if(unsavedChanges && warnUnsavedChanges) {
resetWarnUnsavedTimer();
const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
const text = elapsedTime === 0
? `Autosave is OFF${sandbox ? ' for this sandbox page' : ''}.`
: `Autosave is OFF${sandbox ? ' for this sandbox page' : ''}, and you haven't saved for ${elapsedTime} minutes.`;
return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
Reminder...
<div className='errorContainer'>{text}</div>
</Nav.item>;
}
// #3 - Unsaved changes exist, click to save, show SAVE NOW
if(unsavedChanges)
return <Nav.item className='save' onClick={()=>trySave(true, true, saveGoogle)} color='blue' icon='fas fa-save'>save now</Nav.item>;
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
if(autoSaveEnabled)
return <Nav.item className='save saved'>auto-saved</Nav.item>;
// #5 - Sandbox with no unsaved changes, and has never been saved, hide the button
if(sandbox)
return <Nav.item className='save sandbox' disabled={true}>save now</Nav.item>;
// DEFAULT - No unsaved changes, show SAVED
return <Nav.item className='save saved'>saved</Nav.item>;
};
const toggleAutoSave = ()=>{
clearTimeout(warnUnsavedTimeout.current);
clearTimeout(saveTimeout.current);
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(!autoSaveEnabled));
setAutoSaveEnabled(!autoSaveEnabled);
setWarnUnsavedChanges(autoSaveEnabled);
};
const renderAutoSaveButton = ()=>(
<Nav.item onClick={toggleAutoSave}>
Autosave <i className={autoSaveEnabled ? 'fas fa-power-off active' : 'fas fa-power-off'}></i>
</Nav.item>
);
const clearError = ()=>{
setError(null);
setIsSaving(false);
};
const renderNavbar = ()=>{
return <Navbar>
<Nav.section>
@@ -318,7 +234,12 @@ const EditPage = (props)=>{
const {
handleSplitMove,
handleBrewChange
handleBrewChange,
toggleAutoSave,
clearError,
renderSaveButton,
autoSaveEnabled,
trySave
} = useCommonEditPageFunctions({
saveGoogle,
setError,
@@ -333,15 +254,10 @@ const EditPage = (props)=>{
SNIPKEY,
METAKEY,
hbfm,
autoSaveEnabled,
setAutoSaveEnabled,
setWarnUnsavedChanges,
unsavedChanges,
setUnsavedChanges,
trySave,
sandbox,
lastSavedBrew,
editorRef
editorRef,
save,
});
return (
@@ -373,12 +289,10 @@ const EditPage = (props)=>{
text={currentBrew.text}
style={currentBrew.style}
renderer={currentBrew.renderer}
theme={currentBrew.theme}
themeBundle={themeBundle}
errors={HTMLErrors}
lang={currentBrew.lang}
onPageChange={setCurrentBrewRendererPageNum}
currentEditorViewPageNum={currentEditorViewPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum}
allowPrint={true}
+23 -76
View File
@@ -9,7 +9,7 @@ import _ from 'lodash';
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
import useCommonEditPageFunctions from '../../utils/commonEditPageFunctions.js'
import useCommonEditPageFunctions from '../../utils/commonEditPageFunctions.jsx'
import SplitPane from '@components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx';
@@ -31,10 +31,6 @@ const { both: RecentNavItem } = RecentNavItems;
import Headtags from '@vitreum/headtags.js';
const Meta = Headtags.Meta;
const SAVE_TIMEOUT = 10000;
const UNSAVED_WARNING_TIMEOUT = 900000; //Warn user afer 15 minutes of unsaved changes
const UNSAVED_WARNING_POPUP_TIMEOUT = 4000; //Show the warning for 4 seconds
const BREWKEY = 'HB_newPage_content';
const STYLEKEY = 'HB_newPage_style';
const SNIPKEY = 'HB_newPage_snippets';
@@ -50,79 +46,30 @@ const HomePage =(props)=>{
};
const [currentBrew, setCurrentBrew] = useState(props.brew);
const [saveGoogle, setSaveGoogle] = useState(global.account?.googleId ? true : false);
const [error, setError] = useState(undefined);
const [HTMLErrors, setHTMLErrors] = useState(hbfm.validate(props.brew.text));
const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1);
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [themeBundle, setThemeBundle] = useState({});
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [lastSavedTime, setLastSavedTime] = useState(new Date());
const [autoSaveEnabled, setAutoSaveEnabled] = useState(false);
const [warnUnsavedChanges, setWarnUnsavedChanges] = useState(true);
const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
const warnUnsavedTimeout = useRef(null);
const save = ()=>{
request.post('/api')
.send(currentBrew)
.end((err, res)=>{
if(err) {
setError(err);
return;
}
const saved = res.body;
window.location = `/edit/${saved.editId}`;
const save = async (brew, saveToGoogle)=>{
const res = await request
.post(`/api${saveGoogle ? '?saveToGoogle=true' : ''}`)
.send(brew)
.catch((err)=>{
console.error('Error Updating Local Brew');
setError(err);
});
};
if(!res) return;
const resetWarnUnsavedTimer = ()=>{
setTimeout(()=>setWarnUnsavedChanges(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds
clearTimeout(warnUnsavedTimeout.current);
warnUnsavedTimeout.current = setTimeout(()=>setWarnUnsavedChanges(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved work warnings
};
const renderSaveButton = ()=>{
// #1 - Currently saving, show SAVING
if(isSaving)
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
if(unsavedChanges && warnUnsavedChanges) {
resetWarnUnsavedTimer();
const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
const text = elapsedTime === 0
? `Autosave is OFF${sandbox ? ' for this sandbox page' : ''}.`
: `Autosave is OFF${sandbox ? ' for this sandbox page' : ''}, and you haven't saved for ${elapsedTime} minutes.`;
return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
Reminder...
<div className='errorContainer'>{text}</div>
</Nav.item>;
}
// #3 - Unsaved changes exist, click to save, show SAVE NOW
if(unsavedChanges)
return <Nav.item className='save' onClick={save} color='blue' icon='fas fa-save'>save now</Nav.item>;
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
if(autoSaveEnabled)
return <Nav.item className='save saved'>auto-saved</Nav.item>;
// #5 - Sandbox with no unsaved changes, and has never been saved, hide the button
if(sandbox)
return <Nav.item className='save neverSaved' disabled={true}>save now</Nav.item>;
// DEFAULT - No unsaved changes, show SAVED
return <Nav.item className='save saved'>saved</Nav.item>;
};
const clearError = ()=>{
setError(null);
setIsSaving(false);
const saved = res.body;
window.onbeforeunload = null;
window.location = `/edit/${saved.editId}`;
};
const renderNavbar = ()=>{
@@ -143,8 +90,13 @@ const HomePage =(props)=>{
const {
handleSplitMove,
handleBrewChange
handleBrewChange,
clearError,
renderSaveButton,
unsavedChanges,
trySave
} = useCommonEditPageFunctions({
saveGoogle,
setError,
setThemeBundle,
HTMLErrors,
@@ -157,14 +109,10 @@ const HomePage =(props)=>{
SNIPKEY,
METAKEY,
hbfm,
autoSaveEnabled,
setAutoSaveEnabled,
setWarnUnsavedChanges,
unsavedChanges,
setUnsavedChanges,
sandbox,
lastSavedBrew,
editorRef
editorRef,
save,
});
return (
@@ -190,15 +138,14 @@ const HomePage =(props)=>{
text={currentBrew.text}
style={currentBrew.style}
renderer={currentBrew.renderer}
themeBundle={themeBundle}
onPageChange={setCurrentBrewRendererPageNum}
currentEditorViewPageNum={currentEditorViewPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum}
themeBundle={themeBundle}
/>
</SplitPane>
</div>
<div className={`floatingSaveButton${unsavedChanges ? ' show' : ''}`} onClick={save}>
<div className={`floatingSaveButton${unsavedChanges ? ' show' : ''}`} onClick={()=>trySave(true, true, saveGoogle)}>
Save current <i className='fas fa-save' />
</div>
+17 -80
View File
@@ -10,7 +10,7 @@ import _ from 'lodash';
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '@shared/helpers.js';
import useCommonEditPageFunctions from '../../utils/commonEditPageFunctions.js'
import useCommonEditPageFunctions from '../../utils/commonEditPageFunctions.jsx'
import SplitPane from '@components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx';
@@ -28,10 +28,6 @@ import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
// Page specific imports
const SAVE_TIMEOUT = 10000;
const UNSAVED_WARNING_TIMEOUT = 900000; //Warn user afer 15 minutes of unsaved changes
const UNSAVED_WARNING_POPUP_TIMEOUT = 4000; //Show the warning for 4 seconds
const BREWKEY = 'HB_newPage_content';
const STYLEKEY = 'HB_newPage_style';
const SNIPKEY = 'HB_newPage_snippets';
@@ -49,8 +45,6 @@ const NewPage = (props)=>{
};
const [currentBrew, setCurrentBrew] = useState(props.brew);
const [isSaving, setIsSaving] = useState(false);
const [lastSavedTime, setLastSavedTime] = useState(new Date());
const [saveGoogle, setSaveGoogle] = useState(global.account?.googleId ? true : false);
const [error, setError] = useState(null);
const [HTMLErrors, setHTMLErrors] = useState(hbfm.validate(props.brew.text));
@@ -58,14 +52,9 @@ const NewPage = (props)=>{
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [themeBundle, setThemeBundle] = useState({});
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [autoSaveEnabled, setAutoSaveEnabled] = useState(false);
const [warnUnsavedChanges, setWarnUnsavedChanges] = useState(true);
const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
// const saveTimeout = useRef(null);
const warnUnsavedTimeout = useRef(null);
useEffect(()=>{
loadBrew();
@@ -100,30 +89,22 @@ const NewPage = (props)=>{
window.history.replaceState({}, window.location.title, '/new/');
};
const resetWarnUnsavedTimer = ()=>{
setTimeout(()=>setWarnUnsavedChanges(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds
clearTimeout(warnUnsavedTimeout.current);
warnUnsavedTimeout.current = setTimeout(()=>setWarnUnsavedChanges(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved work warnings
};
const trySave = useEffectEvent(async ()=>{
setIsSaving(true);
const updatedBrew = { ...currentBrew };
splitTextStyleAndMetadata(updatedBrew);
const pageRegex = updatedBrew.renderer === 'legacy' ? /\\page/g : /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/gm;
updatedBrew.pageCount = (updatedBrew.text.match(pageRegex) || []).length + 1;
const save = async (brew, saveToGoogle)=>{
//Prepare content to send to server
const brewToSave = {
...brew,
text : brew.text.normalize('NFC'),
pageCount : ((brew.renderer === 'legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/gm)) || []).length + 1,
textBin : undefined
};
const res = await request
.post(`/api${saveGoogle ? '?saveToGoogle=true' : ''}`)
.send(updatedBrew)
.send(brewToSave)
.catch((err)=>{
setIsSaving(false);
console.error('Error Updating Local Brew');
setError(err);
});
setIsSaving(false);
if(!res) return;
const savedBrew = res.body;
@@ -133,46 +114,6 @@ const NewPage = (props)=>{
localStorage.removeItem(METAKEY);
window.onbeforeunload = null;
window.location = `/edit/${savedBrew.editId}`;
});
const renderSaveButton = ()=>{
// #1 - Currently saving, show SAVING
if(isSaving)
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
if(unsavedChanges && warnUnsavedChanges) {
resetWarnUnsavedTimer();
const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
const text = elapsedTime === 0
? `Autosave is OFF${sandbox ? ' for this sandbox page' : ''}.`
: `Autosave is OFF${sandbox ? ' for this sandbox page' : ''}, and you haven't saved for ${elapsedTime} minutes.`;
return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
Reminder...
<div className='errorContainer'>{text}</div>
</Nav.item>;
}
// #3 - Unsaved changes exist, click to save, show SAVE NOW
if(unsavedChanges)
return <Nav.item className='save' onClick={trySave} color='blue' icon='fas fa-save'>save now</Nav.item>;
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
if(autoSaveEnabled)
return <Nav.item className='save saved'>auto-saved</Nav.item>;
// #5 - Sandbox with no unsaved changes, and has never been saved, hide the button
if(sandbox)
return <Nav.item className='save neverSaved' disabled={true}>save now</Nav.item>;
// DEFAULT - No unsaved changes, show SAVED
return <Nav.item className='save saved'>saved</Nav.item>;
};
const clearError = ()=>{
setError(null);
setIsSaving(false);
};
const renderNavbar = ()=>(
@@ -197,8 +138,11 @@ const NewPage = (props)=>{
const {
handleSplitMove,
handleBrewChange
handleBrewChange,
clearError,
renderSaveButton
} = useCommonEditPageFunctions({
saveGoogle,
setError,
setThemeBundle,
HTMLErrors,
@@ -211,15 +155,10 @@ const NewPage = (props)=>{
SNIPKEY,
METAKEY,
hbfm,
autoSaveEnabled,
setAutoSaveEnabled,
setWarnUnsavedChanges,
unsavedChanges,
setUnsavedChanges,
trySave,
sandbox,
lastSavedBrew,
editorRef
editorRef,
save,
});
return (
@@ -244,12 +183,10 @@ const NewPage = (props)=>{
text={currentBrew.text}
style={currentBrew.style}
renderer={currentBrew.renderer}
theme={currentBrew.theme}
themeBundle={themeBundle}
errors={HTMLErrors}
lang={currentBrew.lang}
onPageChange={setCurrentBrewRendererPageNum}
currentEditorViewPageNum={currentEditorViewPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum}
allowPrint={true}
@@ -39,6 +39,18 @@ const SharePage = (props)=>{
document.addEventListener('keydown', handleControlKeys);
fetchThemeBundle(undefined, setThemeBundle, brew.renderer, brew.theme);
// listen for changes in the brew version
const eventSource = new EventSource('/stream');
eventSource.addEventListener('message', (evt)=>{
const messageData = JSON.parse(evt.data);
if(messageData.eventType == 'brewUpdated'){
if(messageData.shareId == brew.shareId && messageData.version != brew.version) {
console.log(`brew has been updated, viewing ${brew.version}, new version is ${messageData.version}`);
}
}
});
return ()=>{
document.removeEventListener('keydown', handleControlKeys);
};
@@ -1,104 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
import _ from 'lodash';
const AUTOSAVE_KEY = 'HB_editor_autoSaveOn';
export default function useCommonEditPageFunctions(dependencies) {
const {
setError,
setThemeBundle,
HTMLErrors,
setHTMLErrors,
currentBrew,
setCurrentBrew,
useLocalStorage,
BREWKEY,
STYLEKEY,
SNIPKEY,
METAKEY,
hbfm,
autoSaveEnabled,
setAutoSaveEnabled,
setWarnUnsavedChanges,
trySave = ()=>{},
sandbox,
saveGoogle = false,
unsavedChanges,
setUnsavedChanges,
lastSavedBrew,
editorRef
} = dependencies;
const unsavedChangesRef = useRef(unsavedChanges); // onBeforeUnload lives outside React and needs ref to unsavedChanges
//==--------- Page setup ----------==//
useEffect(()=>{
const autoSavePref = !sandbox && JSON.parse(localStorage.getItem(AUTOSAVE_KEY) ?? true);
setAutoSaveEnabled(autoSavePref);
setWarnUnsavedChanges(!autoSavePref);
setHTMLErrors(hbfm.validate(currentBrew.text));
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
const handleControlKeys = (e)=>{
if(!(e.ctrlKey || e.metaKey)) return;
if(e.keyCode === 83) trySave(true, true, saveGoogle);
if(e.keyCode === 80) printCurrentBrew();
if([83, 80].includes(e.keyCode)) {
e.stopPropagation();
e.preventDefault();
}
};
document.addEventListener('keydown', handleControlKeys);
window.onbeforeunload = ()=>{
if(unsavedChangesRef.current)
return 'You have unsaved changes!';
};
return ()=>{
document.removeEventListener('keydown', handleControlKeys);
window.onBeforeUnload = null;
};
}, []);
//======----- Check for unsaved changes and autosave if enabled -----======
useEffect(()=>{
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
setUnsavedChanges(hasChange);
unsavedChangesRef.current = hasChange;
if(autoSaveEnabled) trySave(false, hasChange, saveGoogle);
}, [currentBrew]);
const handleSplitMove = ()=>{
editorRef.current.update();
};
const handleBrewChange = (field)=>(value, subfield)=>{ //'text', 'style', 'snippets', 'metadata'
if(subfield == 'renderer' || subfield == 'theme')
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
//If there are HTML errors, run the validator on every change to give quick feedback
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
setHTMLErrors(hbfm.validate(value));
if(field == 'metadata') setCurrentBrew((prev)=>({ ...prev, ...value }));
else setCurrentBrew((prev)=>({ ...prev, [field]: value }));
if(useLocalStorage) {
if(field == 'text') localStorage.setItem(BREWKEY, value);
if(field == 'style') localStorage.setItem(STYLEKEY, value);
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
renderer : value.renderer,
theme : value.theme,
lang : value.lang
}));
}
};
return {
handleSplitMove,
handleBrewChange
}
}
@@ -0,0 +1,184 @@
import React, { useState, useEffect, useEffectEvent, useRef } from 'react';
import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
import _ from 'lodash';
import Nav from '@navbar/nav.jsx';
const AUTOSAVE_KEY = 'HB_editor_autoSaveOn';
const SAVE_TIMEOUT = 10000; //Autosave 10 seconds after last change
const UNSAVED_WARNING_TIMEOUT = 900000; //Warn user afer 15 minutes of unsaved changes
const UNSAVED_WARNING_POPUP_TIMEOUT = 4000; //Show the warning for 4 seconds
export default function useCommonEditPageFunctions(dependencies) {
const {
saveGoogle,
setError,
setThemeBundle,
HTMLErrors,
setHTMLErrors,
currentBrew,
setCurrentBrew,
useLocalStorage,
BREWKEY,
STYLEKEY,
SNIPKEY,
METAKEY,
hbfm,
sandbox,
lastSavedBrew,
editorRef,
save,
} = dependencies;
const [isSaving , setIsSaving] = useState(false);
const [lastSavedTime , setLastSavedTime] = useState(new Date());
const [autoSaveEnabled , setAutoSaveEnabled] = useState(!sandbox);
const [warnUnsavedChanges, setWarnUnsavedChanges] = useState(true);
const [unsavedChanges , setUnsavedChanges] = useState(false);
const unsavedChangesRef = useRef(unsavedChanges); // onBeforeUnload lives outside React and needs ref to unsavedChanges
const warnUnsavedTimeout = useRef(null); // timers live outside React and need ref to consistently track time
const saveTimeout = useRef(null);
//==--------- Page setup ----------==//
useEffect(()=>{
const autoSavePref = !sandbox && JSON.parse(localStorage.getItem(AUTOSAVE_KEY) ?? true);
setAutoSaveEnabled(autoSavePref);
setWarnUnsavedChanges(!autoSavePref);
setHTMLErrors(hbfm.validate(currentBrew.text));
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
const handleControlKeys = (e)=>{
if(!(e.ctrlKey || e.metaKey)) return;
if(e.keyCode === 83) trySave(true, true, saveGoogle);
if(e.keyCode === 80) printCurrentBrew();
if([83, 80].includes(e.keyCode)) {
e.stopPropagation();
e.preventDefault();
}
};
document.addEventListener('keydown', handleControlKeys);
window.onbeforeunload = ()=>{
if(unsavedChangesRef.current)
return 'You have unsaved changes!';
};
return ()=>{
document.removeEventListener('keydown', handleControlKeys);
window.onBeforeUnload = null;
};
}, []);
//======----- Check for unsaved changes and autosave if enabled -----======
useEffect(()=>{
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
setUnsavedChanges(hasChange);
unsavedChangesRef.current = hasChange;
if(autoSaveEnabled) trySave(false, hasChange, saveGoogle);
}, [currentBrew]);
const resetWarnUnsavedTimer = ()=>{
setTimeout(()=>setWarnUnsavedChanges(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds
clearTimeout(warnUnsavedTimeout.current);
warnUnsavedTimeout.current = setTimeout(()=>setWarnUnsavedChanges(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved work warnings
};
const handleSplitMove = ()=>{
editorRef.current.update();
};
const handleBrewChange = (field)=>(value, subfield)=>{ //'text', 'style', 'snippets', 'metadata'
if(subfield == 'renderer' || subfield == 'theme')
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
//If there are HTML errors, run the validator on every change to give quick feedback
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
setHTMLErrors(hbfm.validate(value));
if(field == 'metadata') setCurrentBrew((prev)=>({ ...prev, ...value }));
else setCurrentBrew((prev)=>({ ...prev, [field]: value }));
if(useLocalStorage) {
if(field == 'text') localStorage.setItem(BREWKEY, value);
if(field == 'style') localStorage.setItem(STYLEKEY, value);
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
renderer : value.renderer,
theme : value.theme,
lang : value.lang
}));
}
};
const toggleAutoSave = ()=>{
clearTimeout(warnUnsavedTimeout.current);
clearTimeout(saveTimeout.current);
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(!autoSaveEnabled));
setAutoSaveEnabled(!autoSaveEnabled);
setWarnUnsavedChanges(autoSaveEnabled);
};
const clearError = ()=>{
setError(null);
setIsSaving(false);
};
const trySave = useEffectEvent((forceSave = false, hasChanges = true, saveToGoogle = false)=>{
clearTimeout(saveTimeout.current);
if(isSaving) return;
if(!forceSave && !hasChanges) return;
const newTimeout = forceSave ? 0 : SAVE_TIMEOUT;
saveTimeout.current = setTimeout(async ()=>{
setIsSaving(true);
setError(null);
await save(currentBrew, saveToGoogle)
.catch((err)=>{
setError(err);
});
setIsSaving(false);
setLastSavedTime(new Date());
if(!autoSaveEnabled) resetWarnUnsavedTimer();
}, newTimeout);
});
const renderSaveButton = ()=>{
if(isSaving)
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
if(unsavedChanges && warnUnsavedChanges) {
resetWarnUnsavedTimer();
const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
const text = elapsedTime === 0
? `Autosave is OFF${sandbox ? ' for this sandbox page' : ''}.`
: `Autosave is OFF${sandbox ? ' for this sandbox page' : ''}, and you haven't saved for ${elapsedTime} minutes.`;
return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
Reminder...
<div className='errorContainer'>{text}</div>
</Nav.item>;
}
if(unsavedChanges)
return <Nav.item className='save' onClick={()=>trySave(true, true, saveGoogle)} color='blue' icon='fas fa-save'>save now</Nav.item>;
if(autoSaveEnabled)
return <Nav.item className='save saved'>auto-saved</Nav.item>;
if(sandbox)
return <Nav.item className='save neverSaved' disabled={true}>save now</Nav.item>;
return <Nav.item className='save saved'>saved</Nav.item>;
};
return {
handleSplitMove,
handleBrewChange,
toggleAutoSave,
clearError,
trySave,
renderSaveButton,
autoSaveEnabled,
unsavedChanges,
}
}