diff --git a/client/homebrew/brewRenderer/brewRenderer.jsx b/client/homebrew/brewRenderer/brewRenderer.jsx index 65de45177..75a07c7d1 100644 --- a/client/homebrew/brewRenderer/brewRenderer.jsx +++ b/client/homebrew/brewRenderer/brewRenderer.jsx @@ -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} -
+
- 0 ? state.visiblePages : [state.centerPage]} totalPages={rawPages.length} headerState={headerState} setHeaderState={setHeaderState}/> + 0 ? visiblePages : [centerPage]} totalPages={rawPages.length} headerState={headerState} setHeaderState={setHeaderState}/> {/*render in iFrame so broken code doesn't crash the site.*/} - {emitClick();}} - sandbox="allow-same-origin allow-modals allow-top-navigation" + sandbox='allow-same-origin allow-modals allow-top-navigation' >
{ + const version = global.version || '0.0.0'; - /* - renderChromeWarning : function(){ - if(!this.state.showNonChromeWarning) return; - return - Optimized for Chrome -
- If you are experiencing rendering issues, use Chrome instead -
-
- }, -*/ - render : function(){ - return + return ( +
The Homebrewery
- {`v${this.state.ver}`} + {`v${version}`} - {/*this.renderChromeWarning()*/} + {/* this.renderChromeWarning() */}
- {this.props.children} -
; - } -}); + {children} +
+ ); +}; -export default Navbar; +export default Navbar; \ No newline at end of file diff --git a/client/homebrew/pages/editPage/editPage.jsx b/client/homebrew/pages/editPage/editPage.jsx index 36df0f1bb..57667ca6e 100644 --- a/client/homebrew/pages/editPage/editPage.jsx +++ b/client/homebrew/pages/editPage/editPage.jsx @@ -2,26 +2,27 @@ import './editPage.less'; // Common imports -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useEffectEvent } from 'react'; import request from '../../utils/request-middleware.js'; import { hbfm } from 'hbmarkedwrapper'; import _ from 'lodash'; import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js'; -import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js'; + +import useCommonEditPageFunctions from '../../utils/commonEditPageFunctions.jsx' import SplitPane from '@components/splitPane/splitPane.jsx'; import Editor from '../../editor/editor.jsx'; import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; -import Nav from '@navbar/nav.jsx'; -import Navbar from '@navbar/navbar.jsx'; -import NewBrewItem from '@navbar/newbrew.navitem.jsx'; -import AccountNavItem from '@navbar/account.navitem.jsx'; -import ErrorNavItem from '@navbar/error-navitem.jsx'; -import HelpNavItem from '@navbar/help.navitem.jsx'; -import VaultNavItem from '@navbar/vault.navitem.jsx'; -import PrintNavItem from '@navbar/print.navitem.jsx'; +import Nav from '@navbar/nav.jsx'; +import Navbar from '@navbar/navbar.jsx'; +import NewBrewItem from '@navbar/newbrew.navitem.jsx'; +import AccountNavItem from '@navbar/account.navitem.jsx'; +import ErrorNavItem from '@navbar/error-navitem.jsx'; +import HelpNavItem from '@navbar/help.navitem.jsx'; +import VaultNavItem from '@navbar/vault.navitem.jsx'; +import PrintNavItem from '@navbar/print.navitem.jsx'; import RecentNavItems from '@navbar/recent.navitem.jsx'; const { both: RecentNavItem } = RecentNavItems; @@ -37,19 +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 AUTOSAVE_KEY = 'HB_editor_autoSaveOn'; const BREWKEY = 'HB_newPage_content'; const STYLEKEY = 'HB_newPage_style'; const SNIPKEY = 'HB_newPage_snippets'; const METAKEY = 'HB_newPage_meta'; const useLocalStorage = false; -const neverSaved = false; +const sandbox = false; const EditPage = (props)=>{ props = { @@ -58,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)); @@ -67,87 +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 trySaveRef = useRef(null); // 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(()=>{ - const autoSavePref = 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) trySaveRef.current(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; - }; - }, []); - - useEffect(()=>{ - trySaveRef.current = trySave; - unsavedChangesRef.current = unsavedChanges; - }); - - useEffect(()=>{ - const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current); - setUnsavedChanges(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 - })); - } - }; + const editorRef = useRef(null); + const lastSavedBrew = useRef(_.cloneDeep(props.brew)); const updateBrew = (newData)=>setCurrentBrew((prevBrew)=>({ ...prevBrew, @@ -156,15 +75,9 @@ 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(global.account !== currentBrew.authors[0]) { - setalertOwnershipToTransfer(true); + if(currentBrew.authors.length > 0 && global.account?.username !== currentBrew.authors[0]) { + setAlertOwnershipToTransfer(true); return; } if(!global.account?.googleId) { @@ -181,7 +94,7 @@ const EditPage = (props)=>{ setAlertTrashedGoogleBrew(false); setAlertNoGoogleToTransfer(false); setConfirmGoogleTransfer(false); - setalertOwnershipToTransfer(false); + setAlertOwnershipToTransfer(false); }; const toggleGoogleStorage = (e)=>{ @@ -192,25 +105,6 @@ const EditPage = (props)=>{ trySave(true, true, newSaveGoogle); }; - const trySave = (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)); @@ -267,12 +161,11 @@ const EditPage = (props)=>{ Google Drive icon - - {alertOwnershipToTransfer && (
- You must be the Owner to transfer between the Homebrewery and Google Drive! - The owner of this file is {currentBrew.authors[0]} + You must be the Owner to transfer between the Homebrewery and Google Drive! + The owner of this file is {currentBrew.authors[0]}. +

Okay
)} @@ -308,60 +201,12 @@ const EditPage = (props)=>{
); - const renderSaveButton = ()=>{ - // #1 - Currently saving, show SAVING - if(isSaving) - return saving...; - - // #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.' - : `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`; - - return - Reminder... -
{text}
-
; - } - - // #3 - Unsaved changes exist, click to save, show SAVE NOW - if(unsavedChanges) - return trySave(true, true, saveGoogle)} color='blue' icon='fas fa-save'>save now; - - // #4 - No unsaved changes, autosave is ON, show AUTO-SAVED - if(autoSaveEnabled) - return auto-saved; - - // #5 - No unsaved changes, and has never been saved, hide the button - if(neverSaved) - return save now; - - // DEFAULT - No unsaved changes, show SAVED - return saved; - }; - - const toggleAutoSave = ()=>{ - clearTimeout(warnUnsavedTimeout.current); - clearTimeout(saveTimeout.current); - localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(!autoSaveEnabled)); - setAutoSaveEnabled(!autoSaveEnabled); - setWarnUnsavedChanges(autoSaveEnabled); - }; - const renderAutoSaveButton = ()=>( Autosave ); - const clearError = ()=>{ - setError(null); - setIsSaving(false); - }; - const renderNavbar = ()=>{ return @@ -387,6 +232,34 @@ const EditPage = (props)=>{ ; }; + const { + handleSplitMove, + handleBrewChange, + toggleAutoSave, + clearError, + renderSaveButton, + autoSaveEnabled, + trySave + } = useCommonEditPageFunctions({ + saveGoogle, + setError, + setThemeBundle, + HTMLErrors, + setHTMLErrors, + currentBrew, + setCurrentBrew, + useLocalStorage, + BREWKEY, + STYLEKEY, + SNIPKEY, + METAKEY, + hbfm, + sandbox, + lastSavedBrew, + editorRef, + save, + }); + return (
@@ -416,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} diff --git a/client/homebrew/pages/homePage/homePage.jsx b/client/homebrew/pages/homePage/homePage.jsx index 4dc505ea9..0ac78f0e1 100644 --- a/client/homebrew/pages/homePage/homePage.jsx +++ b/client/homebrew/pages/homePage/homePage.jsx @@ -2,26 +2,27 @@ import './homePage.less'; // Common imports -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useEffectEvent } from 'react'; import request from '../../utils/request-middleware.js'; import { hbfm } from 'hbmarkedwrapper'; import _ from 'lodash'; import { DEFAULT_BREW } from '../../../../server/brewDefaults.js'; -import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js'; + +import useCommonEditPageFunctions from '../../utils/commonEditPageFunctions.jsx' import SplitPane from '@components/splitPane/splitPane.jsx'; import Editor from '../../editor/editor.jsx'; import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; -import Nav from '@navbar/nav.jsx'; -import Navbar from '@navbar/navbar.jsx'; -import NewBrewItem from '@navbar/newbrew.navitem.jsx'; -import AccountNavItem from '@navbar/account.navitem.jsx'; -import ErrorNavItem from '@navbar/error-navitem.jsx'; -import HelpNavItem from '@navbar/help.navitem.jsx'; -import VaultNavItem from '@navbar/vault.navitem.jsx'; -import PrintNavItem from '@navbar/print.navitem.jsx'; +import Nav from '@navbar/nav.jsx'; +import Navbar from '@navbar/navbar.jsx'; +import NewBrewItem from '@navbar/newbrew.navitem.jsx'; +import AccountNavItem from '@navbar/account.navitem.jsx'; +import ErrorNavItem from '@navbar/error-navitem.jsx'; +import HelpNavItem from '@navbar/help.navitem.jsx'; +import VaultNavItem from '@navbar/vault.navitem.jsx'; +import PrintNavItem from '@navbar/print.navitem.jsx'; import RecentNavItems from '@navbar/recent.navitem.jsx'; const { both: RecentNavItem } = RecentNavItems; @@ -30,149 +31,45 @@ const { both: RecentNavItem } = RecentNavItems; import Headtags from '@vitreum/headtags.js'; const Meta = Headtags.Meta; -const BREWKEY = 'homebrewery-new'; -const STYLEKEY = 'homebrewery-new-style'; -const SNIPKEY = 'homebrewery-new-snippets'; -const METAKEY = 'homebrewery-new-meta'; +const BREWKEY = 'HB_newPage_content'; +const STYLEKEY = 'HB_newPage_style'; +const SNIPKEY = 'HB_newPage_snippets'; +const METAKEY = 'HB_newPage_meta'; const useLocalStorage = false; -const neverSaved = true; +const sandbox = true; const HomePage =(props)=>{ props = { brew : DEFAULT_BREW, - ver : '0.0.0', ...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 [autoSaveEnabled, setAutoSaveEnable] = useState(false); - const editorRef = useRef(null); - const lastSavedBrew = useRef(_.cloneDeep(props.brew)); - const unsavedChangesRef = useRef(unsavedChanges); + const editorRef = useRef(null); + const lastSavedBrew = useRef(_.cloneDeep(props.brew)); - useEffect(()=>{ - fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme); - - const handleControlKeys = (e)=>{ - if(!(e.ctrlKey || e.metaKey)) return; - if(e.keyCode === 83) trySaveRef.current(true); - 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; - }; - }, []); - - useEffect(()=>{ - unsavedChangesRef.current = unsavedChanges; - }, [unsavedChanges]); - - 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; - useEffect(()=>{ - const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current); - setUnsavedChanges(hasChange); - - if(autoSaveEnabled) trySave(false, hasChange); - }, [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 - })); - } - }; - - const renderSaveButton = ()=>{ - // #1 - Currently saving, show SAVING - if(isSaving) - return saving...; - - // #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.' - // : `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`; - - // return - // Reminder... - //
{text}
- //
; - // } - - // #3 - Unsaved changes exist, click to save, show SAVE NOW - if(unsavedChanges) - return save now; - - // #4 - No unsaved changes, autosave is ON, show AUTO-SAVED - if(autoSaveEnabled) - return auto-saved; - - // #5 - No unsaved changes, and has never been saved, hide the button - if(neverSaved) - return save now; - - // DEFAULT - No unsaved changes, show SAVED - return saved; - }; - - const clearError = ()=>{ - setError(null); - setIsSaving(false); + const saved = res.body; + window.onbeforeunload = null; + window.location = `/edit/${saved.editId}`; }; const renderNavbar = ()=>{ @@ -191,6 +88,33 @@ const HomePage =(props)=>{ ; }; + const { + handleSplitMove, + handleBrewChange, + clearError, + renderSaveButton, + unsavedChanges, + trySave + } = useCommonEditPageFunctions({ + saveGoogle, + setError, + setThemeBundle, + HTMLErrors, + setHTMLErrors, + currentBrew, + setCurrentBrew, + useLocalStorage, + BREWKEY, + STYLEKEY, + SNIPKEY, + METAKEY, + hbfm, + sandbox, + lastSavedBrew, + editorRef, + save, + }); + return (
@@ -214,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} />
-
+
trySave(true, true, saveGoogle)}> Save current
diff --git a/client/homebrew/pages/newPage/newPage.jsx b/client/homebrew/pages/newPage/newPage.jsx index c45090fb7..c7b6c7160 100644 --- a/client/homebrew/pages/newPage/newPage.jsx +++ b/client/homebrew/pages/newPage/newPage.jsx @@ -2,7 +2,7 @@ import './newPage.less'; // Common imports -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useEffectEvent } from 'react'; import request from '../../utils/request-middleware.js'; import { hbfm } from 'hbmarkedwrapper'; import _ from 'lodash'; @@ -10,31 +10,33 @@ import _ from 'lodash'; import { DEFAULT_BREW } from '../../../../server/brewDefaults.js'; import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '@shared/helpers.js'; +import useCommonEditPageFunctions from '../../utils/commonEditPageFunctions.jsx' + import SplitPane from '@components/splitPane/splitPane.jsx'; import Editor from '../../editor/editor.jsx'; import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; -import Nav from '@navbar/nav.jsx'; -import Navbar from '@navbar/navbar.jsx'; -import NewBrewItem from '@navbar/newbrew.navitem.jsx'; -import AccountNavItem from '@navbar/account.navitem.jsx'; -import ErrorNavItem from '@navbar/error-navitem.jsx'; -import HelpNavItem from '@navbar/help.navitem.jsx'; -import VaultNavItem from '@navbar/vault.navitem.jsx'; -import PrintNavItem from '@navbar/print.navitem.jsx'; +import Nav from '@navbar/nav.jsx'; +import Navbar from '@navbar/navbar.jsx'; +import NewBrewItem from '@navbar/newbrew.navitem.jsx'; +import AccountNavItem from '@navbar/account.navitem.jsx'; +import ErrorNavItem from '@navbar/error-navitem.jsx'; +import HelpNavItem from '@navbar/help.navitem.jsx'; +import VaultNavItem from '@navbar/vault.navitem.jsx'; +import PrintNavItem from '@navbar/print.navitem.jsx'; import RecentNavItems from '@navbar/recent.navitem.jsx'; const { both: RecentNavItem } = RecentNavItems; // Page specific imports - const BREWKEY = 'HB_newPage_content'; const STYLEKEY = 'HB_newPage_style'; -const METAKEY = 'HB_newPage_metadata'; const SNIPKEY = 'HB_newPage_snippets'; +const METAKEY = 'HB_newPage_meta'; + const SAVEKEYPREFIX = 'HB_editor_defaultSave_'; const useLocalStorage = true; -const neverSaved = true; +const sandbox = true; const NewPage = (props)=>{ props = { @@ -43,7 +45,6 @@ const NewPage = (props)=>{ }; const [currentBrew, setCurrentBrew] = useState(props.brew); - const [isSaving, setIsSaving] = useState(false); const [saveGoogle, setSaveGoogle] = useState(global.account?.googleId ? true : false); const [error, setError] = useState(null); const [HTMLErrors, setHTMLErrors] = useState(hbfm.validate(props.brew.text)); @@ -51,35 +52,12 @@ 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 editorRef = useRef(null); - const lastSavedBrew = useRef(_.cloneDeep(props.brew)); - // const saveTimeout = useRef(null); - // const warnUnsavedTimeout = useRef(null); - const trySaveRef = useRef(null); // 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 + const editorRef = useRef(null); + const lastSavedBrew = useRef(_.cloneDeep(props.brew)); useEffect(()=>{ loadBrew(); - fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme); - - const handleControlKeys = (e)=>{ - if(!(e.ctrlKey || e.metaKey)) return; - if(e.keyCode === 83) trySaveRef.current(true); - if(e.keyCode === 80) printCurrentBrew(); - if([83, 80].includes(e.keyCode)) { - e.stopPropagation(); - e.preventDefault(); - } - }; - - document.addEventListener('keydown', handleControlKeys); - - return ()=>{ - document.removeEventListener('keydown', handleControlKeys); - }; }, []); const loadBrew = ()=>{ @@ -111,63 +89,22 @@ const NewPage = (props)=>{ window.history.replaceState({}, window.location.title, '/new/'); }; - useEffect(()=>{ - const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current); - setUnsavedChanges(hasChange); - - if(autoSaveEnabled) trySave(false, hasChange); - }, [currentBrew]); - - useEffect(()=>{ - trySaveRef.current = trySave; - unsavedChangesRef.current = unsavedChanges; - }); - - 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 trySave = 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; @@ -175,49 +112,10 @@ const NewPage = (props)=>{ localStorage.removeItem(BREWKEY); localStorage.removeItem(STYLEKEY); localStorage.removeItem(METAKEY); + window.onbeforeunload = null; window.location = `/edit/${savedBrew.editId}`; }; - const renderSaveButton = ()=>{ - // #1 - Currently saving, show SAVING - if(isSaving) - return saving...; - - // #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.' - // : `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`; - - // return - // Reminder... - //
{text}
- //
; - // } - - // #3 - Unsaved changes exist, click to save, show SAVE NOW - if(unsavedChanges) - return save now; - - // #4 - No unsaved changes, autosave is ON, show AUTO-SAVED - if(autoSaveEnabled) - return auto-saved; - - // #5 - No unsaved changes, and has never been saved, hide the button - if(neverSaved) - return save now; - - // DEFAULT - No unsaved changes, show SAVED - return saved; - }; - - const clearError = ()=>{ - setError(null); - setIsSaving(false); - }; - const renderNavbar = ()=>( @@ -238,6 +136,31 @@ const NewPage = (props)=>{ ); + const { + handleSplitMove, + handleBrewChange, + clearError, + renderSaveButton + } = useCommonEditPageFunctions({ + saveGoogle, + setError, + setThemeBundle, + HTMLErrors, + setHTMLErrors, + currentBrew, + setCurrentBrew, + useLocalStorage, + BREWKEY, + STYLEKEY, + SNIPKEY, + METAKEY, + hbfm, + sandbox, + lastSavedBrew, + editorRef, + save, + }); + return (
{renderNavbar()} @@ -260,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} diff --git a/client/homebrew/utils/commonEditPageFunctions.jsx b/client/homebrew/utils/commonEditPageFunctions.jsx new file mode 100644 index 000000000..c8ecbaa0c --- /dev/null +++ b/client/homebrew/utils/commonEditPageFunctions.jsx @@ -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 saving...; + + 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 + Reminder... +
{text}
+
; + } + + if(unsavedChanges) + return trySave(true, true, saveGoogle)} color='blue' icon='fas fa-save'>save now; + + if(autoSaveEnabled) + return auto-saved; + + if(sandbox) + return save now; + + return saved; + }; + + return { + handleSplitMove, + handleBrewChange, + toggleAutoSave, + clearError, + trySave, + renderSaveButton, + autoSaveEnabled, + unsavedChanges, + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8309fba77..f7d138beb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12085,21 +12085,6 @@ "url": "https://opencollective.com/mongoose" } }, - "node_modules/mongoose/node_modules/gcp-metadata": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-7.0.1.tgz", - "integrity": "sha512-UcO3kefx6dCcZkgcTGgVOTFb7b1LlQ02hY1omMjjrrBzkajRMCFgYOjs7J71WqnuG1k2b+9ppGL7FsOfhZMQKQ==", - "extraneous": true, - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/mongoose/node_modules/mongodb": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.5.0.tgz", diff --git a/server/app.js b/server/app.js index e4772271f..06efbe7db 100644 --- a/server/app.js +++ b/server/app.js @@ -16,18 +16,15 @@ import path from 'path'; import fs from 'fs-extra'; import api from './homebrew.api.js'; -const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = api; +const { homebrewApi, getBrew, getCSS } = api; import adminApi from './admin.api.js'; import vaultApi from './vault.api.js'; -import GoogleActions from './googleActions.js'; +import pageRoutes from './page-routes.js'; + import serveCompressedStaticAssets from './static-assets.mv.js'; -import sanitizeFilename from 'sanitize-filename'; import asyncHandler from 'express-async-handler'; import { model as HomebrewModel } from './homebrew.model.js'; -import { DEFAULT_BREW } from './brewDefaults.js'; -import { splitTextStyleAndMetadata } from '../shared/helpers.js'; - //==== Middleware Imports ====// import contentNegotiation from './middleware/content-negotiation.js'; import bodyParser from 'body-parser'; @@ -118,12 +115,6 @@ export default async function createApp(vite) { app.use(adminApi(vite)); app.use(vaultApi); - const welcomeText = fs.readFileSync('./client/homebrew/pages/homePage/welcome_msg.md', 'utf8'); - const welcomeTextLegacy = fs.readFileSync('./client/homebrew/pages/homePage/welcome_msg_legacy.md', 'utf8'); - const migrateText = fs.readFileSync('./client/homebrew/pages/homePage/migrate.md', 'utf8'); - const changelogText = fs.readFileSync('changelog.md', 'utf8'); - const faqText = fs.readFileSync('faq.md', 'utf8'); - String.prototype.replaceAll = function(s, r){return this.split(s).join(r);}; const defaultMetaTags = { @@ -134,134 +125,17 @@ export default async function createApp(vite) { type : 'website' }; + app.use(pageRoutes({ + defaultMetaTags, + HomebrewModel, + sanitizeBrew, + })); + //Robots.txt app.get('/robots.txt', (req, res)=>{ return res.sendFile(`robots.txt`, { root: process.cwd() }); }); - //Home page - app.get('/', (req, res, next)=>{ - req.brew = { - text : welcomeText, - renderer : 'V3', - theme : '5ePHB' - }, - - req.ogMeta = { ...defaultMetaTags, - title : 'Homepage', - description : 'Homepage' - }; - - splitTextStyleAndMetadata(req.brew); - return next(); - }); - - //Home page Legacy - app.get('/legacy', (req, res, next)=>{ - req.brew = { - text : welcomeTextLegacy, - renderer : 'legacy', - theme : '5ePHB' - }, - - req.ogMeta = { ...defaultMetaTags, - title : 'Homepage (Legacy)', - description : 'Homepage' - }; - - splitTextStyleAndMetadata(req.brew); - return next(); - }); - - //Legacy/Other Document -> v3 Migration Guide - app.get('/migrate', (req, res, next)=>{ - req.brew = { - text : migrateText, - renderer : 'V3', - theme : '5ePHB' - }, - - req.ogMeta = { ...defaultMetaTags, - title : 'v3 Migration Guide', - description : 'A brief guide to converting Legacy documents to the v3 renderer.' - }; - - splitTextStyleAndMetadata(req.brew); - return next(); - }); - - //Changelog page - app.get('/changelog', async (req, res, next)=>{ - req.brew = { - title : 'Changelog', - text : changelogText, - renderer : 'V3', - theme : '5ePHB' - }, - - req.ogMeta = { ...defaultMetaTags, - title : 'Changelog', - description : 'Development changelog.' - }; - - splitTextStyleAndMetadata(req.brew); - return next(); - }); - - //FAQ page - app.get('/faq', async (req, res, next)=>{ - req.brew = { - title : 'FAQ', - text : faqText, - renderer : 'V3', - theme : '5ePHB' - }, - - req.ogMeta = { ...defaultMetaTags, - title : 'FAQ', - description : 'Frequently Asked Questions' - }; - - splitTextStyleAndMetadata(req.brew); - return next(); - }); - - //Source page - app.get('/source/:id', asyncHandler(getBrew('share')), (req, res)=>{ - const { brew } = req; - - const replaceStrings = { '&': '&', '<': '<', '>': '>' }; - let text = brew.text; - for (const replaceStr in replaceStrings) { - text = text.replaceAll(replaceStr, replaceStrings[replaceStr]); - } - text = `
${text}
`; - res.status(200).send(text); - }); - - //Download brew source page - app.get('/download/:id', asyncHandler(getBrew('share')), (req, res)=>{ - const { brew } = req; - sanitizeBrew(brew, 'share'); - const prefix = 'HB - '; - - const encodeRFC3986ValueChars = (str)=>{ - return ( - encodeURIComponent(str) - .replace(/[!'()*]/g, (char)=>{`%${char.charCodeAt(0).toString(16).toUpperCase()}`;}) - ); - }; - - let fileName = sanitizeFilename(`${prefix}${brew.title}`).replaceAll(' ', ''); - if(!fileName || !fileName.length) { fileName = `${prefix}-Untitled-Brew`; }; - res.set({ - 'Cache-Control' : 'no-cache', - 'Content-Type' : 'text/plain', - 'Content-Disposition' : `attachment; filename*=UTF-8''${encodeRFC3986ValueChars(fileName)}.txt` - }); - res.status(200).send(brew.text); - }); - //Serve brew metadata app.get('/metadata/:id', asyncHandler(getBrew('share')), (req, res)=>{ const { brew } = req; @@ -282,78 +156,6 @@ export default async function createApp(vite) { //Serve brew styling app.get('/css/:id', asyncHandler(getBrew('share')), (req, res)=>{getCSS(req, res);}); - //User Page - app.get('/user/:username', dbCheck, async (req, res, next)=>{ - const ownAccount = req.account && (req.account.username == req.params.username); - - req.ogMeta = { ...defaultMetaTags, - title : `${req.params.username}'s Collection`, - description : 'View my collection of homebrew on the Homebrewery.' - // type : could be 'profile'? - }; - - const fields = [ - 'googleId', - 'title', - 'pageCount', - 'description', - 'authors', - 'lang', - 'published', - 'views', - 'shareId', - 'editId', - 'createdAt', - 'updatedAt', - 'lastViewed', - 'thumbnail', - 'tags' - ]; - - let brews = await HomebrewModel.getByUser(req.params.username, ownAccount, fields) - .catch((err)=>{ - console.log(err); - }); - - brews.forEach((brew)=>brew.stubbed = true); //All brews from MongoDB are "stubbed" - - if(ownAccount && req?.account?.googleId){ - const auth = await GoogleActions.authCheck(req.account, res); - let googleBrews = await GoogleActions.listGoogleBrews(auth) - .catch((err)=>{ - console.error(err); - }); - - // If stub matches file from Google, use Google metadata over stub metadata - if(googleBrews && googleBrews.length > 0) { - for (const brew of brews.filter((brew)=>brew.googleId)) { - const match = googleBrews.findIndex((b)=>b.editId === brew.editId); - if(match !== -1) { - brew.googleId = googleBrews[match].googleId; - brew.pageCount = googleBrews[match].pageCount; - brew.renderer = googleBrews[match].renderer; - brew.version = googleBrews[match].version; - brew.webViewLink = googleBrews[match].webViewLink; - googleBrews.splice(match, 1); - } - } - - //Remaining unstubbed google brews display current user as author - googleBrews = googleBrews.map((brew)=>({ ...brew, authors: [req.account.username] })); - brews = _.concat(brews, googleBrews); - } - } - - req.brews = _.map(brews, (brew)=>{ - // Clean up brew data - brew.title = brew.title?.trim(); - brew.description = brew.description?.trim(); - return sanitizeBrew(brew, ownAccount ? 'edit' : 'share'); - }); - - return next(); - }); - //Change author name on brews app.put('/api/user/rename', dbCheck, async (req, res)=>{ const { username, newUsername } = req.body; @@ -382,144 +184,6 @@ export default async function createApp(vite) { } }); - //Edit Page - app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, next)=>{ - req.brew = req.brew.toObject ? req.brew.toObject() : req.brew; - - req.userThemes = await(getUsersBrewThemes(req.account?.username)); - - req.ogMeta = { ...defaultMetaTags, - title : req.brew.title || 'Untitled Brew', - description : req.brew.description || 'No description.', - image : req.brew.thumbnail || defaultMetaTags.image, - locale : req.brew.lang, - type : 'article' - }; - - sanitizeBrew(req.brew, 'edit'); - splitTextStyleAndMetadata(req.brew); - res.header('Cache-Control', 'no-cache, no-store'); //reload the latest saved brew when pressing back button, not the cached version before save. - return next(); - })); - - //New Page from ID - app.get('/new/:id', asyncHandler(getBrew('share')), asyncHandler(async(req, res, next)=>{ - sanitizeBrew(req.brew, 'share'); - splitTextStyleAndMetadata(req.brew); - const brew = { - shareId : req.brew.shareId, - title : `CLONE - ${req.brew.title}`, - text : req.brew.text, - style : req.brew.style, - renderer : req.brew.renderer, - theme : req.brew.theme, - tags : req.brew.tags, - snippets : req.brew.snippets - }; - req.brew = _.defaults(brew, DEFAULT_BREW); - - req.userThemes = await(getUsersBrewThemes(req.account?.username)); - - req.ogMeta = { ...defaultMetaTags, - title : 'New', - description : 'Start crafting your homebrew on the Homebrewery!' - }; - - return next(); - })); - - //New Page - app.get('/new', asyncHandler(async(req, res, next)=>{ - req.userThemes = await(getUsersBrewThemes(req.account?.username)); - - req.ogMeta = { ...defaultMetaTags, - title : 'New', - description : 'Start crafting your homebrew on the Homebrewery!' - }; - - return next(); - })); - - //Share Page - app.get('/share/:id', dbCheck, asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{ - const { brew } = req; - req.ogMeta = { ...defaultMetaTags, - title : `${req.brew.title || 'Untitled Brew'} - ${req.brew.authors[0] || 'No author.'}`, - description : req.brew.description || 'No description.', - image : req.brew.thumbnail || defaultMetaTags.image, - type : 'article' - }; - - // increase visitor view count, do not include visits by author(s) - if(!brew.authors.includes(req.account?.username)){ - if(req.params.id.length > 12 && !brew._id) { - const googleId = brew.googleId; - const shareId = brew.shareId; - await GoogleActions.increaseView(googleId, shareId, 'share', brew) - .catch((err)=>{next(err);}); - } else { - await HomebrewModel.increaseView({ shareId: brew.shareId }); - } - }; - - brew.authors.includes(req.account?.username) ? sanitizeBrew(req.brew, 'shareAuthor') : sanitizeBrew(req.brew, 'share'); - splitTextStyleAndMetadata(req.brew); - return next(); - })); - - //Account Page - app.get('/account', dbCheck, asyncHandler(async (req, res, next)=>{ - const data = {}; - data.title = 'Account Information Page'; - - if(!req.account) { - res.set('WWW-Authenticate', 'Bearer realm="Authorization Required"'); - const error = new Error('No valid account'); - error.status = 401; - error.HBErrorCode = '50'; - error.page = data.title; - return next(error); - }; - - let auth; - let googleCount = []; - if(req.account) { - if(req.account.googleId) { - auth = await GoogleActions.authCheck(req.account, res, false); - - googleCount = await GoogleActions.listGoogleBrews(auth) - .catch((err)=>{ - console.error(err); - }); - } - - const query = { authors: req.account.username, googleId: { $exists: false } }; - const mongoCount = await HomebrewModel.countDocuments(query) - .catch((err)=>{ - console.log(err); - return 0; - }); - - data.accountDetails = { - username : req.account.username, - issued : req.account.issued, - googleId : Boolean(req.account.googleId), - authCheck : Boolean(req.account.googleId && auth?.credentials.access_token), - mongoCount : mongoCount, - googleCount : googleCount?.length - }; - } - - req.brew = data; - - req.ogMeta = { ...defaultMetaTags, - title : `Account Page`, - description : null - }; - - return next(); - })); - // Create Event Stream source for pages to listen to app.get('/stream', (req, res)=>{ res.writeHead(200, { @@ -557,15 +221,6 @@ export default async function createApp(vite) { app.use('/staticImages', express.static(config.get('hb_images') && fs.existsSync(config.get('hb_images')) ? config.get('hb_images') :'staticImages')); app.use('/staticFonts', express.static(config.get('hb_fonts') && fs.existsSync(config.get('hb_fonts')) ? config.get('hb_fonts'):'staticFonts')); - //Vault Page - app.get('/vault', asyncHandler(async(req, res, next)=>{ - req.ogMeta = { ...defaultMetaTags, - title : 'The Vault', - description : 'Search for Brews' - }; - return next(); - })); - //Send rendered page app.use(asyncHandler(async (req, res, next)=>{ if(!req.route) return res.redirect('/'); // Catch-all for invalid routes diff --git a/server/page-routes.js b/server/page-routes.js new file mode 100644 index 000000000..e2e7be193 --- /dev/null +++ b/server/page-routes.js @@ -0,0 +1,380 @@ +/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/ +// page-routes.js + +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; +const __dirname = dirname(fileURLToPath(import.meta.url)); +process.chdir(`${__dirname}/..`); + +import _ from 'lodash'; +import express from 'express'; +import asyncHandler from 'express-async-handler'; +import fs from 'fs'; + +//==== Middleware Imports ====// +import dbCheck from './middleware/dbCheck.js'; +import sanitizeFilename from 'sanitize-filename'; +import { DEFAULT_BREW } from './brewDefaults.js'; +import { splitTextStyleAndMetadata } from '../shared/helpers.js'; +import GoogleActions from './googleActions.js'; + +import api from './homebrew.api.js'; +const { getBrew, getUsersBrewThemes } = api; + +const welcomeText = fs.readFileSync('./client/homebrew/pages/homePage/welcome_msg.md', 'utf8'); +const welcomeTextLegacy = fs.readFileSync('./client/homebrew/pages/homePage/welcome_msg_legacy.md', 'utf8'); +const migrateText = fs.readFileSync('./client/homebrew/pages/homePage/migrate.md', 'utf8'); +const changelogText = fs.readFileSync('changelog.md', 'utf8'); +const faqText = fs.readFileSync('faq.md', 'utf8'); + +export default function pageRoutes({ + defaultMetaTags, + HomebrewModel, + sanitizeBrew, +}) { + const app = express.Router(); + + //Home page + app.get('/', (req, res, next)=>{ + req.brew = { + text : welcomeText, + renderer : 'V3', + theme : '5ePHB' + }, + + req.ogMeta = { ...defaultMetaTags, + title : 'Homepage', + description : 'Homepage' + }; + + splitTextStyleAndMetadata(req.brew); + return next(); + }); + + //Home page Legacy + app.get('/legacy', (req, res, next)=>{ + req.brew = { + text : welcomeTextLegacy, + renderer : 'legacy', + theme : '5ePHB' + }, + + req.ogMeta = { ...defaultMetaTags, + title : 'Homepage (Legacy)', + description : 'Homepage' + }; + + splitTextStyleAndMetadata(req.brew); + return next(); + }); + + //Legacy/Other Document -> v3 Migration Guide + app.get('/migrate', (req, res, next)=>{ + req.brew = { + text : migrateText, + renderer : 'V3', + theme : '5ePHB' + }, + + req.ogMeta = { ...defaultMetaTags, + title : 'v3 Migration Guide', + description : 'A brief guide to converting Legacy documents to the v3 renderer.' + }; + + splitTextStyleAndMetadata(req.brew); + return next(); + }); + + //Changelog page + app.get('/changelog', async (req, res, next)=>{ + req.brew = { + title : 'Changelog', + text : changelogText, + renderer : 'V3', + theme : '5ePHB' + }, + + req.ogMeta = { ...defaultMetaTags, + title : 'Changelog', + description : 'Development changelog.' + }; + + splitTextStyleAndMetadata(req.brew); + return next(); + }); + + //FAQ page + app.get('/faq', async (req, res, next)=>{ + req.brew = { + title : 'FAQ', + text : faqText, + renderer : 'V3', + theme : '5ePHB' + }, + + req.ogMeta = { ...defaultMetaTags, + title : 'FAQ', + description : 'Frequently Asked Questions' + }; + + splitTextStyleAndMetadata(req.brew); + return next(); + }); + + //Source page + app.get('/source/:id', asyncHandler(getBrew('share')), (req, res)=>{ + const { brew } = req; + + const replaceStrings = { '&': '&', '<': '<', '>': '>' }; + let text = brew.text; + for (const replaceStr in replaceStrings) { + text = text.replaceAll(replaceStr, replaceStrings[replaceStr]); + } + text = `
${text}
`; + res.status(200).send(text); + }); + + //Download brew source page + app.get('/download/:id', asyncHandler(getBrew('share')), (req, res)=>{ + const { brew } = req; + sanitizeBrew(brew, 'share'); + const prefix = 'HB - '; + + const encodeRFC3986ValueChars = (str)=>{ + return ( + encodeURIComponent(str) + .replace(/[!'()*]/g, (char)=>{`%${char.charCodeAt(0).toString(16).toUpperCase()}`;}) + ); + }; + + let fileName = sanitizeFilename(`${prefix}${brew.title}`).replaceAll(' ', ''); + if(!fileName || !fileName.length) { fileName = `${prefix}-Untitled-Brew`; }; + res.set({ + 'Cache-Control' : 'no-cache', + 'Content-Type' : 'text/plain', + 'Content-Disposition' : `attachment; filename*=UTF-8''${encodeRFC3986ValueChars(fileName)}.txt` + }); + res.status(200).send(brew.text); + }); + + //User Page + app.get('/user/:username', dbCheck, async (req, res, next)=>{ + const ownAccount = req.account && (req.account.username == req.params.username); + + req.ogMeta = { ...defaultMetaTags, + title : `${req.params.username}'s Collection`, + description : 'View my collection of homebrew on the Homebrewery.' + // type : could be 'profile'? + }; + + const fields = [ + 'googleId', + 'title', + 'pageCount', + 'description', + 'authors', + 'lang', + 'published', + 'views', + 'shareId', + 'editId', + 'createdAt', + 'updatedAt', + 'lastViewed', + 'thumbnail', + 'tags' + ]; + + let brews = await HomebrewModel.getByUser(req.params.username, ownAccount, fields) + .catch((err)=>{ + console.log(err); + }); + + brews.forEach((brew)=>brew.stubbed = true); //All brews from MongoDB are "stubbed" + + if(ownAccount && req?.account?.googleId){ + const auth = await GoogleActions.authCheck(req.account, res); + let googleBrews = await GoogleActions.listGoogleBrews(auth) + .catch((err)=>{ + console.error(err); + }); + + // If stub matches file from Google, use Google metadata over stub metadata + if(googleBrews && googleBrews.length > 0) { + for (const brew of brews.filter((brew)=>brew.googleId)) { + const match = googleBrews.findIndex((b)=>b.editId === brew.editId); + if(match !== -1) { + brew.googleId = googleBrews[match].googleId; + brew.pageCount = googleBrews[match].pageCount; + brew.renderer = googleBrews[match].renderer; + brew.version = googleBrews[match].version; + brew.webViewLink = googleBrews[match].webViewLink; + googleBrews.splice(match, 1); + } + } + + //Remaining unstubbed google brews display current user as author + googleBrews = googleBrews.map((brew)=>({ ...brew, authors: [req.account.username] })); + brews = _.concat(brews, googleBrews); + } + } + + req.brews = _.map(brews, (brew)=>{ + // Clean up brew data + brew.title = brew.title?.trim(); + brew.description = brew.description?.trim(); + return sanitizeBrew(brew, ownAccount ? 'edit' : 'share'); + }); + + return next(); + }); + + //Edit Page + app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, next)=>{ + req.brew = req.brew.toObject ? req.brew.toObject() : req.brew; + + req.userThemes = await(getUsersBrewThemes(req.account?.username)); + + req.ogMeta = { ...defaultMetaTags, + title : req.brew.title || 'Untitled Brew', + description : req.brew.description || 'No description.', + image : req.brew.thumbnail || defaultMetaTags.image, + locale : req.brew.lang, + type : 'article' + }; + + sanitizeBrew(req.brew, 'edit'); + splitTextStyleAndMetadata(req.brew); + res.header('Cache-Control', 'no-cache, no-store'); //reload the latest saved brew when pressing back button, not the cached version before save. + return next(); + })); + + //New Page from ID + app.get('/new/:id', asyncHandler(getBrew('share')), asyncHandler(async(req, res, next)=>{ + sanitizeBrew(req.brew, 'share'); + splitTextStyleAndMetadata(req.brew); + const brew = { + shareId : req.brew.shareId, + title : `CLONE - ${req.brew.title}`, + text : req.brew.text, + style : req.brew.style, + renderer : req.brew.renderer, + theme : req.brew.theme, + tags : req.brew.tags, + snippets : req.brew.snippets + }; + req.brew = _.defaults(brew, DEFAULT_BREW); + + req.userThemes = await(getUsersBrewThemes(req.account?.username)); + + req.ogMeta = { ...defaultMetaTags, + title : 'New', + description : 'Start crafting your homebrew on the Homebrewery!' + }; + + return next(); + })); + + //New Page + app.get('/new', asyncHandler(async(req, res, next)=>{ + req.userThemes = await(getUsersBrewThemes(req.account?.username)); + + req.ogMeta = { ...defaultMetaTags, + title : 'New', + description : 'Start crafting your homebrew on the Homebrewery!' + }; + + return next(); + })); + + //Share Page + app.get('/share/:id', dbCheck, asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{ + const { brew } = req; + req.ogMeta = { ...defaultMetaTags, + title : `${req.brew.title || 'Untitled Brew'} - ${req.brew.authors[0] || 'No author.'}`, + description : req.brew.description || 'No description.', + image : req.brew.thumbnail || defaultMetaTags.image, + type : 'article' + }; + + // increase visitor view count, do not include visits by author(s) + if(!brew.authors.includes(req.account?.username)){ + if(req.params.id.length > 12 && !brew._id) { + const googleId = brew.googleId; + const shareId = brew.shareId; + await GoogleActions.increaseView(googleId, shareId, 'share', brew) + .catch((err)=>{next(err);}); + } else { + await HomebrewModel.increaseView({ shareId: brew.shareId }); + } + }; + + brew.authors.includes(req.account?.username) ? sanitizeBrew(req.brew, 'shareAuthor') : sanitizeBrew(req.brew, 'share'); + splitTextStyleAndMetadata(req.brew); + return next(); + })); + + //Account Page + app.get('/account', dbCheck, asyncHandler(async (req, res, next)=>{ + const data = {}; + data.title = 'Account Information Page'; + + if(!req.account) { + res.set('WWW-Authenticate', 'Bearer realm="Authorization Required"'); + const error = new Error('No valid account'); + error.status = 401; + error.HBErrorCode = '50'; + error.page = data.title; + return next(error); + }; + + let auth; + let googleCount = []; + if(req.account) { + if(req.account.googleId) { + auth = await GoogleActions.authCheck(req.account, res, false); + + googleCount = await GoogleActions.listGoogleBrews(auth) + .catch((err)=>{ + console.error(err); + }); + } + + const query = { authors: req.account.username, googleId: { $exists: false } }; + const mongoCount = await HomebrewModel.countDocuments(query) + .catch((err)=>{ + console.log(err); + return 0; + }); + + data.accountDetails = { + username : req.account.username, + issued : req.account.issued, + googleId : Boolean(req.account.googleId), + authCheck : Boolean(req.account.googleId && auth?.credentials.access_token), + mongoCount : mongoCount, + googleCount : googleCount?.length + }; + } + + req.brew = data; + + req.ogMeta = { ...defaultMetaTags, + title : `Account Page`, + description : null + }; + + return next(); + })); + + //Vault Page + app.get('/vault', asyncHandler(async(req, res, next)=>{ + req.ogMeta = { ...defaultMetaTags, + title : 'The Vault', + description : 'Search for Brews' + }; + return next(); + })); + + return app; +} \ No newline at end of file diff --git a/shared/helpers.js b/shared/helpers.js index 667d7b6b1..db046b810 100644 --- a/shared/helpers.js +++ b/shared/helpers.js @@ -146,7 +146,7 @@ const printCurrentBrew = async ()=>{ } }; -const fetchThemeBundle = async (setError, setThemeBundle, renderer, theme)=>{ +const fetchThemeBundle = async (setError = ()=>{}, setThemeBundle = ()=>{}, renderer, theme)=>{ if(!renderer || !theme) return; const res = await request .get(`/api/theme/${renderer}/${theme}`) diff --git a/themes/V3/5eDMG/style.less b/themes/V3/5eDMG/style.less index d79533c2c..8aad90315 100644 --- a/themes/V3/5eDMG/style.less +++ b/themes/V3/5eDMG/style.less @@ -14,6 +14,7 @@ .note table tbody tr:nth-child(odd) { background : #FFFFFF; } /* DROP CAP */ + .first-letter, .drop-cap h1 + p::first-letter { color : black; background-image : unset; diff --git a/themes/V3/5ePHB/snippets.js b/themes/V3/5ePHB/snippets.js index fee12e1f6..73ac5cb43 100644 --- a/themes/V3/5ePHB/snippets.js +++ b/themes/V3/5ePHB/snippets.js @@ -30,6 +30,7 @@ export default [ name : 'Tweak Drop Cap', icon : 'fas fa-sliders-h', gen : dedent`/* Drop Cap settings */ + .page .first-letter, .page .drop-cap, .page h1 + p::first-letter { font-family: SolberaImitationRemake; font-size: 3.5cm; diff --git a/themes/V3/5ePHB/style.less b/themes/V3/5ePHB/style.less index 12601bb5d..28bde8a2f 100644 --- a/themes/V3/5ePHB/style.less +++ b/themes/V3/5ePHB/style.less @@ -85,23 +85,24 @@ line-height : 1em; -webkit-column-span : all; -moz-column-span : all; - & + p::first-letter { - float : left; - padding-bottom : 2px; - padding-left : 40px; //Allow background color to extend into margins - margin-top : -0.3cm; - margin-bottom : -20px; - margin-left : -40px; - font-family : 'SolberaImitationRemake'; - font-size : 3.5cm; - line-height : 1em; - color : rgba(0, 0, 0, 0); - background-image : linear-gradient(-45deg, #322814, #998250, #322814); - -webkit-background-clip : text; - background-clip : text; - } & + p::first-line { font-variant : small-caps; } } + .first-letter, .drop-cap, + h1 + p::first-letter { + float : left; + padding-bottom : 2px; + padding-left : 40px; //Allow background color to extend into margins + margin-top : -0.3cm; + margin-bottom : -20px; + margin-left : -40px; + font-family : 'SolberaImitationRemake'; + font-size : 3.5cm; + line-height : 1em; + color : rgba(0, 0, 0, 0); + background-image : linear-gradient(-45deg, #322814, #998250, #322814); + -webkit-background-clip : text; + background-clip : text; + } h2 { //margin-top : 0px; //Font is misaligned. Shift up slightly //margin-bottom : 0.05cm; @@ -196,7 +197,7 @@ } & + * { margin-top : 0.54cm; } - + } // ***************************** @@ -279,7 +280,7 @@ .watermark { color : black; } /* Watercolor */ - + .watercolor1 { --wc : @watercolor1; } .watercolor2 { --wc : @watercolor2; } .watercolor3 { --wc : @watercolor3; } diff --git a/themes/V3/Journal/style.less b/themes/V3/Journal/style.less index 74c976f47..9b9b7d024 100644 --- a/themes/V3/Journal/style.less +++ b/themes/V3/Journal/style.less @@ -80,21 +80,22 @@ font-size : 0.89cm; font-variant : small-caps; line-height : 1em; - & + p::first-letter { - float : left; - padding-top : 0.3em; - padding-bottom : 2px; - padding-left : 40px; //Allow background color to extend into margins - margin-top : -0.3cm; - margin-right : 0.1em; - margin-bottom : -20px; - margin-left : -40px; - font-family : 'FrederickaTheGreat'; - font-size : 1.9em; - line-height : 1em; - } & + p::first-line { font-variant : small-caps; } } + .first-letter, .drop-cap, + h1 + p::first-letter { + float : left; + padding-top : 0.3em; + padding-bottom : 2px; + padding-left : 40px; //Allow background color to extend into margins + margin-top : -0.3cm; + margin-right : 0.1em; + margin-bottom : -20px; + margin-left : -40px; + font-family : 'FrederickaTheGreat'; + font-size : 1.9em; + line-height : 1em; + } h2 { font-size : 0.62cm; line-height : 0.988em; //Font is misaligned. Shift up slightly