Merge branch 'master' of https://github.com/naturalcrit/homebrewery into fit-listpage-into-vault

This commit is contained in:
Víctor Losada Hernández
2026-09-25 19:22:53 +02:00
51 changed files with 2725 additions and 4275 deletions
+41 -7
View File
@@ -1,4 +1,4 @@
/* eslint max-lines: ["error", { "max": 405 }] */
/* eslint max-lines: ["error", { "max": 455 }] */
import './codeEditor.less';
import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
@@ -42,6 +42,7 @@ import cm5Themes from 'codemirror-5-themes';
const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery };
const themeCompartment = new Compartment();
const highlightCompartment = new Compartment();
const settingsCompartment = new Compartment();
import { generalKeymap, markdownKeymap, cssKeymap, formatCSS } from './extensions/customKeyMaps.js';
import foldOnPages from './extensions/customFolding.js';
@@ -78,6 +79,20 @@ const programmaticCursorLineField = StateField.define({
provide : (decorationSet)=>EditorView.decorations.from(decorationSet)
});
const createSettingsExtensions = (settings)=>[
...(settings.autoCloseBrackets ? [autoCloseBrackets] : []),
...(settings.lineNumbers ? [lineNumbers()] : []),
...(settings.activeLineShading ? [highlightActiveLine(),
highlightActiveLineGutter()] : []),
...(settings.fontSize
? [EditorView.theme({
'&, .cm-content' : {
fontSize : `${settings.fontSize || 1}em`,
},
})]
: []),
];
const CodeEditor = forwardRef(
(
{
@@ -88,9 +103,11 @@ const CodeEditor = forwardRef(
onChange = ()=>{},
onCursorChange = ()=>{},
onViewChange = ()=>{},
onThemeChange = ()=>{},
editorTheme = 'default',
style,
renderer,
settings = {},
...props
},
ref,
@@ -163,8 +180,7 @@ const CodeEditor = forwardRef(
EditorView.lineWrapping,
setEventListeners,
languageExtension,
autoCloseBrackets,
lineNumbers(),
settingsCompartment.of(createSettingsExtensions(settings)),
scrollPastEnd(),
search(),
history(), //allows for undo and redo
@@ -178,10 +194,8 @@ const CodeEditor = forwardRef(
}),
//highlights
highlightCompartment.of([customHighlightPlugin(renderer, tab), highlightExtension]),
highlightCompartment.of([customHighlightPlugin(renderer, tab, settings), highlightExtension]),
themeCompartment.of(themeExtension),
highlightActiveLine(),
highlightActiveLineGutter(),
//keyboard shortcut
keymap.of([...defaultKeymap, foldKeymap, ...searchKeymap]),
@@ -271,6 +285,12 @@ const CodeEditor = forwardRef(
}
view.setState(nextState);
view.dispatch({
effects : settingsCompartment.reconfigure(
createSettingsExtensions(settings)
),
});
restoreFolds(view, foldsRef.current[tab]);
const savedScroll = scrollRef.current[tab];
@@ -308,6 +328,9 @@ const CodeEditor = forwardRef(
view.dispatch({
effects : themeCompartment.reconfigure(themeExtension),
});
const isDark = view.state.facet(EditorView.darkTheme);
onThemeChange(isDark);
}, [editorTheme, tab]);
useEffect(()=>{
@@ -320,10 +343,21 @@ const CodeEditor = forwardRef(
: syntaxHighlighting(legacyCustomHighlightStyle);
view.dispatch({
effects : highlightCompartment.reconfigure([customHighlightPlugin(renderer, tab), highlightExtension]),
effects : highlightCompartment.reconfigure([customHighlightPlugin(renderer, tab, settings), highlightExtension])
});
}, [renderer, tab]);
useEffect(()=>{
const view = viewRef.current;
if(!view) return;
view.dispatch({
effects : settingsCompartment.reconfigure(
createSettingsExtensions(settings)
),
});
}, [settings]);
useImperativeHandle(ref, ()=>({
injectText : (text)=>{
@@ -366,7 +366,7 @@ class ImageWidget extends WidgetType {
}
}
export function customHighlightPlugin(renderer, tab) {
export function customHighlightPlugin(renderer, tab, settings) {
//this function takes the custom tokens created in the tokenize function in customhighlight files
//takes the tokens defined by that function and assigns classes to them
//it also creates page number and snippet number widgets
@@ -398,7 +398,7 @@ export function customHighlightPlugin(renderer, tab) {
const tree = ensureSyntaxTree(view.state, view.state.doc.length, 50) || syntaxTree(view.state);
tree.iterate({
enter : (node)=>{
if(node.name === 'Image') {
if(node.name === 'Image' && settings.showImagePreviews) {
const url = getUrl(node, view.state.doc);
const widgetPosition = node.node.lastChild.from;
@@ -431,7 +431,7 @@ export function customHighlightPlugin(renderer, tab) {
const to = line.from + token.to;
const attrs = {};
if(token.type === 'Image' && token.url) {
if(token.type === 'Image' && token.url && settings.showImagePreviews) {
attrs['data-url'] = token.url;
}
+2
View File
@@ -7,6 +7,7 @@ const Combobox = createReactClass({
displayName : 'Combobox',
getDefaultProps : function() {
return {
id : '',
className : '',
trigger : 'hover',
default : '',
@@ -75,6 +76,7 @@ const Combobox = createReactClass({
onClick= {this.props.trigger == 'click' ? ()=>{this.handleDropdown(true);} : undefined}
{...(this.props.tooltip ? { 'data-tooltip-right': this.props.tooltip } : {})}>
<input
id={this.props.id}
type='text'
onChange={(e)=>this.handleInput(e)}
value={this.state.value || ''}
+3 -4
View File
@@ -4,7 +4,7 @@
.item i {
position : absolute;
right : 10px;
color : black;
color : inherit;
}
.dropdown-options {
position : absolute;
@@ -32,14 +32,13 @@
font-size : 11px;
cursor : default;
&:hover {
background-color : rgb(163, 163, 163);
filter : brightness(120%);
background-color : #ddd;
}
.detail {
width : 100%;
font-size : 9px;
font-style : italic;
color : rgb(124, 124, 124);
color : #7c7c7c;
text-align : left;
}
}
+5 -1
View File
@@ -7,7 +7,7 @@
@property --activeTriggerColor {
syntax: '<color>';
inherits: true;
initial-value: #DDD;
initial-value: #999;
}
:root{
@@ -30,4 +30,8 @@
}
.menu-wrapper:has(:popover-open) > button { // if menu is open...
background-color: var(--activeTriggerColor, hsl(from var(--menuColor) h s calc(l * .85))); // tint menu triggers based on menu color
}
.darkMode .menu-wrapper:has(:popover-open) > button { // if menu is open...
--activeTriggerColor : #444; // tint menu triggers based on menu color
}
+40 -65
View File
@@ -6,7 +6,7 @@ import React, { useState, useRef, useMemo, useEffect } from 'react';
import _ from 'lodash';
import MarkdownLegacy from '@shared/markdownLegacy.js';
import { hbfm } from 'hbmarkedwrapper';
import { hbfm } from 'marked-hbfm';
import ErrorBar from './errorBar/errorBar.jsx';
import ToolBar from './toolBar/toolBar.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';
@@ -42,36 +41,29 @@ const BrewPage = (props)=>{
props = {
contents : '',
index : 0,
hoisted : false,
...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/remove page from 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 +84,7 @@ const BrewPage = (props)=>{
//v=====--------------------< Brew Renderer Component >-------------------=====v//
let renderedPages = [];
let pageTemplates = [];
const pageTemplates = [];
let rawPages = [];
const BrewRenderer = (props)=>{
@@ -100,22 +92,23 @@ const BrewRenderer = (props)=>{
text : '',
style : '',
renderer : 'legacy',
theme : '5ePHB',
lang : '',
errors : [],
currentEditorCursorPageNum : 1,
currentEditorViewPageNum : 1,
currentBrewRendererPageNum : 1,
themeBundle : {},
onPageChange : ()=>{},
...props
};
const pagesRef = useRef(null);
const [visiblePages, setVisiblePages] = useState([]);
const [centerPage , setCenterPage ] = useState(1);
const [headerState , setHeaderState ] = useState(false);
const [state, setState] = useState({
isMounted : false,
visibility : 'hidden',
visiblePages : [],
centerPage : 1
isMounted : false,
visibility : 'hidden'
});
const [displayOptions, setDisplayOptions] = useState({
@@ -133,12 +126,6 @@ const BrewRenderer = (props)=>{
toolbarState && setDisplayOptions(toolbarState);
}, []);
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);
} else {
@@ -146,20 +133,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)=>{
@@ -169,17 +152,16 @@ const BrewRenderer = (props)=>{
if(index == props.currentEditorCursorPageNum - 1) //Already rendered before this step
return false;
if(Math.abs(index - props.currentBrewRendererPageNum - 1) <= 3)
if(Math.abs(index - centerPage - 1) <= 3)
return true;
return false;
};
const renderDummyPage = (index)=>{
return <div className='phb page' id={`p${index + 1}`} key={index}>
const renderDummyPage = (index)=>
<div className='phb page' id={`p${index + 1}`} key={index}>
<i className='fas fa-spinner fa-spin' />
</div>;
};
const renderStyle = ()=>{
const themeStyles = props.themeBundle?.joinedStyles ?? '<style>@import url("/themes/V3/Blank/style.css");</style>';
@@ -237,9 +219,8 @@ const BrewRenderer = (props)=>{
}
};
const renderPages = (checkHoists = false)=>{
if(props.errors && props.errors.length)
const renderPages = ()=>{
if(props.errors?.length)
return renderedPages;
if(rawPages.length != renderedPages.length) { // Re-render all pages when page count changes
@@ -252,16 +233,10 @@ const BrewRenderer = (props)=>{
renderedPages[props.currentEditorCursorPageNum - 1] = renderPage(rawPages[props.currentEditorCursorPageNum - 1], props.currentEditorCursorPageNum - 1);
_.forEach(rawPages, (page, index)=>{
const varsOnPageRegex = /([!$]?)\[((?!\s*\])(?:\\.|[^\[\]\\])+)\]/g; // Find out if there are any vars on the page.
const forceRender = checkHoists &&
!props.hoisted &&
(page.match(varsOnPageRegex)); // forceRender forces pages outside of the PPR range to render if true.
// This is necessary on the first load to fully populate the variable table.
if((isInView(index) || !renderedPages[index] || forceRender) && typeof window !== 'undefined'){
if((isInView(index) || !renderedPages[index]) && typeof window !== 'undefined'){
renderedPages[index] = renderPage(page, index); // Render any page not yet rendered, but only re-render those in PPR range
}
});
if(!props.hoisted) { props.hoisted = true; } // Only fully hoist once.
return renderedPages;
};
@@ -300,8 +275,8 @@ const BrewRenderer = (props)=>{
window.addEventListener('hashchange', ()=>scrollToHash(window.location.hash));
setTimeout(()=>{ //We still see a flicker where the style isn't applied yet, so wait 100ms before showing iFrame
renderPages(true); //Make sure page is renderable before showing
setTimeout(()=>{ //We still see a flicker where the style isn't applied yet, so wait 100ms before showing iFrame
renderPages(); //Make sure page is renderable before showing
setState((prevState)=>({
...prevState,
isMounted : true,
@@ -327,7 +302,7 @@ const BrewRenderer = (props)=>{
};
const renderedStyle = useMemo(()=>renderStyle(), [props.style, props.themeBundle]);
renderedPages = useMemo(()=>renderPages(), [props.text, displayOptions]);
renderedPages = useMemo(()=>renderPages(), [props.text, centerPage, displayOptions]);
return (
<>
@@ -341,19 +316,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"
onClick={emitClick}
sandbox='allow-same-origin allow-modals allow-top-navigation'
>
<div className='brewRenderer'
onKeyDown={handleControlKeys}
@@ -1,7 +1,7 @@
import './notificationPopup.less';
import React, { useEffect, useState } from 'react';
import request from '../../utils/request-middleware.js';
import { hbfm } from 'hbmarkedwrapper';
import { hbfm } from 'marked-hbfm';
import Dialog from '@components/dialog.jsx';
+72 -13
View File
@@ -2,12 +2,15 @@
import './editor.less';
import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
import dedent from 'dedent';
import { EditorView } from '@codemirror/view';
import CodeEditor from '@components/codeEditor/codeEditor.jsx';
import SnippetBar from './snippetbar/snippetbar.jsx';
import MetadataEditor from './metadataEditor/metadataEditor.jsx';
import SettingsEditor from './settingsEditor/settingsEditor.jsx';
const EDITOR_THEME_KEY = 'HB_editor_theme';
const EDITOR_SETTINGS_KEY = 'HB_edit_settings';
import defaultCM5Theme from '@themes/codeMirror/default.js';
import darkbrewery from '@themes/codeMirror/darkbrewery.js';
@@ -19,6 +22,20 @@ const EditorThemes = Object.entries(themes)
.filter(([name, value])=>Array.isArray(value) && !name.endsWith('Init') && !name.endsWith('Style'))
.map(([name])=>name);
const themeNames = Object.entries(themes)
.filter(([name, value])=>Array.isArray(value) &&
!name.endsWith('Init') &&
!name.endsWith('Style')
)
.map(([name])=>name);
const EditorThemeNameList = [
'default',
...themeNames
.filter((name)=>name !== 'default')
.sort((a, b)=>a.localeCompare(b))
];
//const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
//const SNIPPETBREAK_REGEX_V3 = /^\\snippet\ .*$/;
const DEFAULT_STYLE_TEXT = dedent`
@@ -50,7 +67,6 @@ const Editor = forwardRef(
onCursorPageChange = ()=>{},
onViewPageChange = ()=>{},
editorTheme = 'default',
renderer = 'legacy',
moveBrew,
@@ -69,9 +85,17 @@ const Editor = forwardRef(
},
ref,
)=>{
const [currentEditorTheme, setEditorTheme] = useState(editorTheme);
const [view, setView] = useState('text'); // 'text', 'style', 'meta', 'snippet'
const [snippetBarHeight, setSnippetBarHeight] = useState(26);
const [isDark, setIsDark] = useState(false);
const [editorSettings, setEditorSettings] = useState({
autoCloseBrackets : true,
showImagePreviews : true,
activeLineShading : true,
lineNumbers : true,
fontSize : 1,
editorTheme : 'default',
});
const editor = useRef(null);
const codeEditor = useRef(null);
@@ -81,6 +105,7 @@ const Editor = forwardRef(
const isStyle = ()=>isView('style');
const isMeta = ()=>isView('meta');
const isSnip = ()=>isView('snippet');
const isSettings = ()=>isView('settings');
const isView = (name)=>view === name;
@@ -89,8 +114,12 @@ const Editor = forwardRef(
brewRenderer.onload = ()=>brewRenderer.contentDocument?.addEventListener('keydown', handleControlKeys);
document.addEventListener('keydown', handleControlKeys);
const editorTheme = window.localStorage.getItem(EDITOR_THEME_KEY);
if(editorTheme && EditorThemes.includes(editorTheme)) setEditorTheme(editorTheme); else setEditorTheme('default');
const localEditorTheme = window.localStorage.getItem(EDITOR_THEME_KEY);
if(localEditorTheme && EditorThemes.includes(localEditorTheme)) {
setEditorSettings({ ...editorSettings, editorTheme: localEditorTheme });
} else setEditorSettings({ ...editorSettings, editorTheme: 'default' });
const localEditorSettings = window.localStorage.getItem(EDITOR_SETTINGS_KEY);
if(localEditorSettings) setEditorSettings(JSON.parse(localEditorSettings));
const snippetBar = document.querySelector('.editor > .snippetBar');
if(!snippetBar) return;
@@ -111,7 +140,7 @@ const Editor = forwardRef(
useEffect(()=>{ if(liveScroll) brewJump(currentEditorViewPageNum, false); }, [currentEditorViewPageNum, liveScroll]);
useEffect(()=>{ if(liveScroll) brewJump(currentEditorCursorPageNum, false); }, [currentEditorCursorPageNum, liveScroll]);
const handleFormatCode = () => {
const handleFormatCode = ()=>{
codeEditor.current?.formatCode();
};
@@ -211,7 +240,12 @@ const Editor = forwardRef(
const updateEditorTheme = (newTheme)=>{
window.localStorage.setItem(EDITOR_THEME_KEY, newTheme);
setEditorTheme(newTheme);
setEditorSettings({ ...editorSettings, editorTheme: newTheme });
};
const updateEditorSettings = (newEditorSettings)=>{
window.localStorage.setItem(EDITOR_SETTINGS_KEY, JSON.stringify(newEditorSettings));
setEditorSettings(newEditorSettings);
};
const renderEditor = ()=>{
@@ -228,9 +262,11 @@ const Editor = forwardRef(
onChange={onBrewChange('text')}
onCursorChange={(page)=>updateCurrentCursorPage(page)}
onViewChange={(page)=>updateCurrentViewPage(page)}
editorTheme={currentEditorTheme}
editorTheme={editorSettings.editorTheme}
onThemeChange={setIsDark}
renderer={brew.renderer}
style={{ height: `calc(100% - ${snippetBarHeight}px)` }}
settings={editorSettings}
/>
</>
);
@@ -246,9 +282,11 @@ const Editor = forwardRef(
view={view}
value={brew.style ?? DEFAULT_STYLE_TEXT}
onChange={onBrewChange('style')}
editorTheme={currentEditorTheme}
editorTheme={editorSettings.editorTheme}
onThemeChange={setIsDark}
renderer={brew.renderer}
style={{ height: `calc(100% - ${snippetBarHeight}px)` }}
settings={editorSettings}
/>
</>
);
@@ -268,9 +306,11 @@ const Editor = forwardRef(
value={brew.snippets}
onChange={onBrewChange('snippets')}
enableFolding={true}
editorTheme={currentEditorTheme}
editorTheme={editorSettings.editorTheme}
onThemeChange={setIsDark}
renderer={brew.renderer}
style={{ height: `calc(100% - 25px)` }}
settings={editorSettings}
/>
</>
);
@@ -278,7 +318,7 @@ const Editor = forwardRef(
if(isMeta()) {
return (
<>
<CodeEditor key='codeEditor' view={view} style={{ display: 'none' }} />
<CodeEditor key='codeEditor' tab='brewMetadata' editorTheme={editorSettings.editorTheme} onThemeChange={setIsDark} view={view} style={{ display: 'none' }} settings={editorSettings} />
<MetadataEditor
metadata={brew}
themeBundle={themeBundle}
@@ -289,6 +329,26 @@ const Editor = forwardRef(
</>
);
}
if(isSettings()){
return (
<>
<CodeEditor
key='codeEditor'
tab='brewSettings' //necessary or the brew object loses its contents, culprit possibly on the tab dependent useEffect in codeEditor.jsx
view={view}
style={{ display: 'none' }}
editorTheme={editorSettings.editorTheme}
onThemeChange={setIsDark}
settings={editorSettings}
/>
<SettingsEditor
settings={editorSettings}
EditorThemeNameList={EditorThemeNameList}
updateSettings={updateEditorSettings}
/>
</>
);
}
};
const redo = ()=>codeEditor.current?.redo();
@@ -308,9 +368,8 @@ const Editor = forwardRef(
unfoldCode,
historySize,
}));
return (
<div className='editor' ref={editor}>
<div className={`editor${isDark ? ' darkMode' : ''}`} ref={editor}>
<SnippetBar
brew={brew}
view={view}
@@ -325,7 +384,7 @@ const Editor = forwardRef(
unfoldCode={unfoldCode}
formatCode={isStyle() ? handleFormatCode : null}
historySize={historySize()}
currentEditorTheme={currentEditorTheme}
currentEditorTheme={editorSettings.editorTheme}
updateEditorTheme={updateEditorTheme}
themeBundle={themeBundle}
cursorPos={codeEditor.current?.getCursorPosition() || {}}
@@ -1,5 +1,5 @@
/* eslint-disable max-lines */
import './metadataEditor.less';
import '../uiEditor.less';
import React from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
@@ -7,7 +7,6 @@ import request from '../../utils/request-middleware.js';
import Combobox from '@components/combobox.jsx';
import TagInput from '../tagInput/tagInput.jsx';
import Themes from '@themes/themes.json';
import validations from './validations.js';
@@ -84,7 +83,6 @@ const MetadataEditor = createReactClass({
return `- ${err}`;
}).join('\n');
debouncedReportValidity(e.target, errMessage);
return false;
}
@@ -156,11 +154,11 @@ const MetadataEditor = createReactClass({
renderPublish : function(){
if(this.props.metadata.published){
return <button className='unpublish' onClick={()=>this.handlePublish(false)}>
return <button id='publish-button' className='unpublish' onClick={()=>this.handlePublish(false)}>
<i className='fas fa-ban' aria-hidden='true' /> unpublish
</button>;
} else {
return <button className='publish' onClick={()=>this.handlePublish(true)}>
return <button id='publish-button' className='publish' onClick={()=>this.handlePublish(true)}>
<i className='fas fa-globe' aria-hidden='true' /> publish
</button>;
}
@@ -170,9 +168,9 @@ const MetadataEditor = createReactClass({
if(!this.props.metadata.editId) return;
return <div className='field delete'>
<label>delete</label>
<label htmlFor='delete-button'>delete</label>
<div className='value'>
<button className='publish' onClick={this.handleDelete}>
<button id='delete-button' onClick={this.handleDelete}>
<i className='fas fa-trash-alt' /> delete brew
</button>
</div>
@@ -186,8 +184,8 @@ const MetadataEditor = createReactClass({
<label>authors</label>
<div className='value'>
{authors.length > 0 && (
<a href={`/user/${authors[0]}`} className='author-link' target="_blank" title={`Owner - Click to open ${authors[0]}'s profile in a new tab`}>
{authors[0]}{authors.length > 1 && ', '}
<a href={`/user/${authors[0]}`} className='author-link' target='_blank' title={`Owner - Click to open ${authors[0]}'s profile in a new tab`}>
{authors[0]}{authors.length > 1 && ', '}
</a>
)}
{authors.length > 1 && authors.slice(1).map((author, i)=>(
@@ -227,7 +225,6 @@ const MetadataEditor = createReactClass({
</ul>
</div>
);
},
renderThemeDropdown : function(){
@@ -264,6 +261,7 @@ const MetadataEditor = createReactClass({
dropdown =
<div className='value' data-tooltip-top='Select from the list below (built-in themes and brews you have tagged "meta:theme"), or paste in the Share URL or Share ID of any brew.'>
<Combobox trigger='click'
id='combobox-themes'
className='themes-dropdown'
default={currentThemeDisplay}
placeholder='Select from below, or enter the Share URL or ID of a brew with the meta:theme tag'
@@ -284,7 +282,7 @@ const MetadataEditor = createReactClass({
}
return <div className='field themes'>
<label>theme</label>
<label htmlFor='combobox-themes'>theme</label>
{dropdown}
</div>;
},
@@ -303,9 +301,10 @@ const MetadataEditor = createReactClass({
};
return <div className='field language'>
<label>language</label>
<label htmlFor='combobox-language'>language</label>
<div className='value' data-tooltip-right='Sets the HTML Lang property for your brew. May affect hyphenation or spellcheck.'>
<Combobox trigger='click'
id='combobox-language'
className='language-dropdown'
default={this.props.metadata.lang || ''}
placeholder='en'
@@ -355,24 +354,24 @@ const MetadataEditor = createReactClass({
},
render : function(){
return <div className='metadataEditor'>
return <div className='metadataEditor uiEditor'>
<h1>Properties Editor</h1>
<div className='field title'>
<label for='title_field'>title</label>
<label htmlFor='title_field'>title</label>
<input type='text' id='title_field' className='value'
defaultValue={this.props.metadata.title}
onChange={(e)=>this.handleFieldChange('title', e)} />
</div>
<div className='field-group'>
<fieldset className='field-group'>
<div className='field-column'>
<div className='field description'>
<label for='description_field'>description</label>
<label htmlFor='description_field'>description</label>
<textarea id='description_field' defaultValue={this.props.metadata.description} className='value'
onChange={(e)=>this.handleFieldChange('description', e)} />
</div>
<div className='field thumbnail'>
<label for='thumbnail_field'>thumbnail</label>
<label htmlFor='thumbnail_field'>thumbnail</label>
<input type='text'
id='thumbnail_field'
defaultValue={this.props.metadata.thumbnail}
@@ -380,18 +379,19 @@ const MetadataEditor = createReactClass({
className='value'
onChange={(e)=>this.handleFieldChange('thumbnail', e)} />
<button className='display' onClick={this.toggleThumbnailDisplay}
aria-label={`${this.state.showThumbnail ? 'hide thumbnail' : 'show thumbnail'}`}>
aria-label={`${this.state.showThumbnail ? 'hide thumbnail' : 'show thumbnail'}`}>
<i className={`fas fa-caret-${this.state.showThumbnail ? 'right' : 'left'}`} />
</button>
</div>
</div>
{this.renderThumbnail()}
</div>
</fieldset>
<div className='field tags'>
<label>Tags</label>
<label htmlFor='combobox-tags'>Tags</label>
<div className='value' >
<TagInput
id='combobox-tags'
label='tags'
valuePatterns={/^\s*(?:(?:group|meta|system|type)\s*:\s*)?[A-Za-z0-9][A-Za-z0-9 \/\\.&_\-]{0,40}\s*$/}
placeholder='add tag' unique={true}
@@ -402,7 +402,6 @@ const MetadataEditor = createReactClass({
</div>
</div>
{this.renderLanguageDropdown()}
{this.renderThemeDropdown()}
@@ -414,9 +413,10 @@ const MetadataEditor = createReactClass({
{this.renderAuthors()}
<div className='field invitedAuthors'>
<label>Invited authors</label>
<label htmlFor='combobox-invited-authors'>Invited authors</label>
<div className='value'>
<TagInput
id='combobox-invited-authors'
label='invited authors'
valuePatterns={/.+/}
validators={[(v)=>!this.props.metadata.authors?.includes(v)]}
@@ -429,11 +429,10 @@ const MetadataEditor = createReactClass({
</div>
</div>
<h2>Privacy</h2>
<div className='field publish'>
<label>publish</label>
<label htmlFor='publish-button'>publish</label>
<div className='value'>
{this.renderPublish()}
<small>Published brews are searchable in <a href='/vault'>the Vault</a> and visible on your user page. Unpublished brews are not indexed in the Vault or visible on your user page, but can still be shared and indexed by search engines. You can unpublish a brew any time.</small>
@@ -1,374 +0,0 @@
@import '@sharedStyles/core.less';
.userThemeName {
padding-right : 10px;
padding-left : 10px;
}
.metadataEditor {
position : absolute;
box-sizing : border-box;
width : 100%;
height : calc(100vh - 54px); // 54px is the height of the navbar + snippet bar. probably a better way to dynamic get this.
padding : 25px;
overflow-y : auto;
font-size : 13px;
background-color : #999999;
h1 {
margin : 0 0 40px;
font-weight : bold;
text-transform : uppercase;
}
h2 {
margin : 20px 0;
font-weight : bold;
color : #555555;
border-bottom : 2px solid gray;
}
& > div { margin-bottom : 10px; }
.field-group {
display : flex;
flex-wrap : wrap;
gap : 10px;
width : 100%;
}
.field-column {
display : flex;
flex : 5 0 200px;
flex-direction : column;
gap : 10px;
}
.field {
position : relative;
display : flex;
flex-wrap : wrap;
width : 100%;
min-width : 200px;
& > label {
width : 80px;
font-size : 0.9em;
font-weight : 800;
line-height : 1.8em;
text-transform : uppercase;
}
& > .value {
flex : 1 1 auto;
width : 50px;
&[data-tooltip-right] { max-width : 380px; }
&:invalid { background : #FFB9B9; }
small {
display : block;
font-size : 0.9em;
font-style : italic;
line-height : 1.4em;
}
}
input[type='text'], textarea {
border : 1px solid gray;
&:focus { outline : 1px solid #444444; }
}
&.description {
flex : 1;
textarea.value {
height : auto;
font-family : 'Open Sans', sans-serif;
resize : none;
}
}
&.thumbnail, &.themes {
label { line-height : 2.0em; }
.value {
overflow : hidden;
text-overflow : ellipsis;
}
button {
.colorButton();
padding : 0px 5px;
color : white;
background-color : black;
border : 1px solid #999999;
&:hover { background-color : #777777; }
}
}
&.tags .tagInput-dropdown {
z-index : 400;
max-width : 200px;
}
&.language .value {
z-index : 300;
max-width : 150px;
}
&.themes {
.value {
overflow : visible;
text-overflow : auto;
}
button {
padding-right : 5px;
padding-left : 5px;
}
}
&.invitedAuthors .value {
z-index : 100;
.tagInput-dropdown { max-width : 200px; }
}
}
.thumbnail-preview {
position : relative;
flex : 1 1;
justify-self : center;
width : 80px;
height : min-content;
max-height : 115px;
aspect-ratio : 1 / 1;
object-fit : contain;
background-color : #AAAAAA;
}
.renderers.field .value {
label {
display : inline-flex;
align-items : center;
margin-right : 15px;
font-size : 0.9em;
font-weight : 800;
vertical-align : middle;
white-space : nowrap;
cursor : pointer;
user-select : none;
}
input {
margin : 3px;
vertical-align : middle;
cursor : pointer;
}
}
.publish.field .value {
position : relative;
margin-bottom : 15px;
button { width : 100%; }
button.publish {
.colorButton(@blueLight);
}
button.unpublish {
.colorButton(@silver);
}
}
.delete.field .value {
button {
.colorButton(@red);
}
}
.authors.field {
.tag {
font-weight:300;
transition:background-color 0.2s;
&.owner {
position: relative;
background-color:@silverLight;
min-width:25px;
display:grid;
place-items:center;
font-weight: 900;
&::after {
content: "\f521";
font-family: "Font Awesome 6 Free";
color:gold;
position: absolute;
top: 0;
left: 0;
width:15px;
height:15px;
rotate:-25deg;
translate:-30% -50%;
transform: scaleY(0.7);
}
}
&:has(button) a {
padding-right:5px;
}
&:has(button:hover) {
background:#d97d7d;
}
button {
color:@red;
}
}
a {
color:black;
text-underline-offset:0.2em;
}
}
.themes.field {
& .dropdown-container {
position : relative;
z-index : 200;
background-color : white;
}
& .dropdown-options { overflow-y : visible; }
.disabled {
font-style : italic;
color : dimgray;
background-color : darkgray;
}
.item {
position : relative;
padding : 3px 3px;
overflow : visible;
background-color : white;
border-top : 1px solid rgb(118, 118, 118);
.preview {
position : absolute;
top : 0;
right : 0;
z-index : 1;
display : flex;
flex-direction : column;
width : 200px;
overflow : hidden;
color : black;
background : #CCCCCC;
border-radius : 5px;
box-shadow : 0 0 5px black;
opacity : 0;
transition : opacity 250ms ease;
h6 {
padding-block : 0.5em;
padding-inline : 1em;
font-weight : 900;
border-bottom : 2px solid hsl(0,0%,40%);
}
}
.texture-container {
position : absolute;
top : 0;
left : 0;
width : 100%;
height : 100%;
min-height : 100%;
overflow : hidden;
> img {
position : absolute;
top : 0;
right : 0;
width : 50%;
min-height : 100%;
-webkit-mask-image : linear-gradient(90deg, transparent, black 20%);
mask-image : linear-gradient(90deg, transparent, black 20%);
}
}
&:hover {
color : white;
background-color : @blue;
filter : unset;
}
&:hover > .preview { opacity : 1; }
}
}
.field .list {
display : flex;
flex : 1 0;
flex-wrap : wrap;
> * { flex : 0 0 auto; }
#groupedIcon {
#backgroundColors;
position : relative;
top : -0.3em;
right : -0.3em;
display : inline-block;
min-width : 20px;
height : ~'calc(100% + 0.6em)';
color : white;
text-align : center;
cursor : pointer;
i {
position : relative;
top : 50%;
transform : translateY(-50%);
}
&:not(:last-child) { border-right : 1px solid black; }
&:last-child { border-radius : 0 0.5em 0.5em 0; }
}
.tag {
padding : 0.35em;
margin : 2px;
font-size : 0.95em;
background-color : #DDDDDD;
border-radius : 0.5em;
.icon { #groupedIcon; }
button {
cursor : pointer;
}
}
.input-group {
height : ~'calc(.9em + 4px + .6em)';
input { border-radius : 0.5em 0 0 0.5em; }
input:last-child { border-radius : 0.5em; }
.value {
width : 7.5vw;
min-width : 75px;
height : 100%;
}
.input-group {
height : ~'calc(.9em + 4px + .6em)';
input { border-radius : 0.5em 0 0 0.5em; }
input:last-child { border-radius : 0.5em; }
.value {
width : 7.5vw;
min-width : 75px;
height : 100%;
}
.invalid:focus { background-color : pink; }
.icon {
#groupedIcon;
top : -0.54em;
right : 1px;
height : 97%;
i { font-size : 1.125em; }
}
}
}
}
}
@@ -0,0 +1,143 @@
import '../uiEditor.less';
import React from 'react';
const SettingsEditor = ({ settings, updateSettings = ()=>{}, EditorThemeNameList })=>{
const validations = {};
const handleFieldChange = (setting, e)=>{
const value =
e.target.type === 'checkbox'
? e.target.checked
: e.target.value;
const inputRules = validations[setting] ?? [];
const validationErrors = inputRules
.map((rule)=>rule(value))
.filter(Boolean);
if(validationErrors.length > 0) {
e.target.setCustomValidity(validationErrors.join('\n'));
e.target.reportValidity();
return;
}
e.target.setCustomValidity('');
const updatedSettings = {
...settings,
[setting] : e.target.type === 'number'
? Number(value)
: value,
};
updateSettings(updatedSettings);
};
return (
<div className='settingsEditor uiEditor'>
<h1>Editor Settings</h1>
<div className='field'>
<label htmlFor='changeEditorTheme'>
Select your Editor Theme
</label>
<div className='value'>
<select id='changeEditorTheme' value={settings.editorTheme} onChange={(e)=>handleFieldChange('editorTheme', e)} >
{EditorThemeNameList.map((theme, key)=>{
return <option key={key} value={theme}>{theme}</option>;
})}
</select>
</div>
</div>
<div className='field'>
<label htmlFor='autoCloseBrackets'>
Automatically close brackets
</label>
<div className='value'>
<input
id='autoCloseBrackets'
type='checkbox'
name='autoCloseBrackets'
checked={settings.autoCloseBrackets}
onChange={(e)=>handleFieldChange('autoCloseBrackets', e)}
/>
</div>
</div>
<div className='field'>
<label htmlFor='showImagePreviews'>
Show Image Previews when hovering a link
</label>
<div className='value'>
<input
id='showImagePreviews'
type='checkbox'
name='showImagePreviews'
checked={settings.showImagePreviews}
onChange={(e)=>handleFieldChange('showImagePreviews', e)}
/>
</div>
</div>
<div className='field'>
<label htmlFor='activeLineShading'>
Background shading of active line
</label>
<div className='value'>
<input
id='activeLineShading'
type='checkbox'
name='activeLineShading'
checked={settings.activeLineShading}
onChange={(e)=>handleFieldChange('activeLineShading', e)}
/>
</div>
</div>
<div className='field'>
<label htmlFor='lineNumbers'>Show Line Numbers</label>
<div className='value'>
<input
id='lineNumbers'
type='checkbox'
name='lineNumbers'
checked={settings.lineNumbers}
onChange={(e)=>handleFieldChange('lineNumbers', e)}
/>
</div>
</div>
<div className='field'>
<label htmlFor='fontSize'>
Editor Font Size
</label>
<div className='value'>
<small style={{ fontSize: `${settings.fontSize || 1}em` }}>from 7px to 26px</small>
<input
id='fontSize'
type='range'
min={.5}
step={.1}
max={2}
list='font-sizes'
name='fontSize'
title={`${Math.round(settings.fontSize * 13)}px`}
value={settings.fontSize || 1}
onChange={(e)=>handleFieldChange('fontSize', e)}
/>
<datalist id='font-sizes'>
<option value='1' />
</datalist>
</div>
</div>
</div>
);
};
export default SettingsEditor;
@@ -10,6 +10,7 @@ import cx from 'classnames';
import { loadHistory } from '../../utils/versionHistory.js';
import { brewSnippetsToJSON } from '@shared/helpers.js';
/*eslint-disable camelcase*/
import Legacy5ePHB from '@themes/Legacy/5ePHB/snippets.js';
import V3_5ePHB from '@themes/V3/5ePHB/snippets.js';
import V3_5eDMG from '@themes/V3/5eDMG/snippets.js';
@@ -23,26 +24,7 @@ const ThemeSnippets = {
V3_Journal : V3_Journal,
V3_Blank : V3_Blank,
};
import defaultCM5Theme from '@themes/codeMirror/default.js';
import darkbrewery from '@themes/codeMirror/darkbrewery.js';
import cm5Themes from 'codemirror-5-themes';
const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery };
const themeNames = Object.entries(themes)
.filter(([name, value])=>Array.isArray(value) &&
!name.endsWith('Init') &&
!name.endsWith('Style')
)
.map(([name])=>name);
const EditorThemes = [
'default',
...themeNames
.filter((name)=>name !== 'default')
.sort((a, b)=>a.localeCompare(b))
];
/*eslint-enable camelcase */
const execute = function(val, props){
if(_.isFunction(val)) return val(props);
@@ -53,30 +35,28 @@ const Snippetbar = createReactClass({
displayName : 'SnippetBar',
getDefaultProps : function() {
return {
brew : {},
view : 'text',
onViewChange : ()=>{},
onInject : ()=>{},
onToggle : ()=>{},
showEditButtons : true,
renderer : 'legacy',
undo : ()=>{},
redo : ()=>{},
historySize : ()=>{},
foldCode : ()=>{},
unfoldCode : ()=>{},
formatCode : ()=>{},
updateEditorTheme : ()=>{},
cursorPos : {},
themeBundle : [],
updateBrew : ()=>{}
brew : {},
view : 'text',
onViewChange : ()=>{},
onInject : ()=>{},
onToggle : ()=>{},
showEditButtons : true,
renderer : 'legacy',
undo : ()=>{},
redo : ()=>{},
historySize : ()=>{},
foldCode : ()=>{},
unfoldCode : ()=>{},
formatCode : ()=>{},
cursorPos : {},
themeBundle : [],
updateBrew : ()=>{}
};
},
getInitialState : function() {
return {
renderer : this.props.renderer,
themeSelector : false,
snippets : [],
showHistory : false,
historyExists : false,
@@ -93,7 +73,6 @@ const Snippetbar = createReactClass({
componentDidUpdate : async function(prevProps, prevState) {
if(prevProps.renderer != this.props.renderer ||
prevProps.theme != this.props.theme ||
prevProps.themeBundle != this.props.themeBundle ||
prevProps.brew.snippets != this.props.brew.snippets) {
this.setState({
@@ -158,33 +137,6 @@ const Snippetbar = createReactClass({
this.props.onInject(injectedText);
},
toggleThemeSelector : function(e){
if(e.target.tagName != 'SELECT'){
this.setState({
themeSelector : !this.state.themeSelector
});
}
},
changeTheme : function(e){
if(e.target.value == this.props.currentEditorTheme) return;
this.props.updateEditorTheme(e.target.value);
this.setState({
themeSelector : false,
});
},
renderThemeSelector : function(){
return <div className='themeSelector'>
<select value={this.props.currentEditorTheme} onChange={this.changeTheme} >
{EditorThemes.map((theme, key)=>{
return <option key={key} value={theme}>{theme}</option>;
})}
</select>
</div>;
},
renderSnippetGroups : function(){
const snippets = this.state.snippets.filter((snippetGroup)=>snippetGroup.view === this.props.view);
if(snippets.length === 0) return null;
@@ -246,7 +198,7 @@ const Snippetbar = createReactClass({
return (
<div className='editors'>
{this.props.view !== 'meta' && <><div className='historyTools'>
{this.props.view !== 'meta' && this.props.view !== 'settings' && <><div className='historyTools'>
<button className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`}
onClick={this.toggleHistoryMenu} >
<i className='fas fa-clock-rotate-left' />
@@ -274,11 +226,6 @@ const Snippetbar = createReactClass({
onClick={this.props.formatCode} >
<i className='fas fa-wand-magic-sparkles' />
</button>
<button className={`editorTheme ${this.state.themeSelector ? 'active' : ''}`}
onClick={this.toggleThemeSelector} >
<i className='fas fa-palette' />
{this.state.themeSelector && this.renderThemeSelector()}
</button>
</div></>}
<div className='tabs'>
@@ -298,6 +245,10 @@ const Snippetbar = createReactClass({
onClick={()=>this.props.onViewChange('meta')}>
<i className='fas fa-info-circle' />
</button>
<button className={cx('settings', { selected: this.props.view === 'settings' })}
onClick={()=>this.props.onViewChange('settings')}>
<i className='fas fa-gear' />
</button>
</div>
</div>
@@ -346,7 +297,7 @@ const SnippetGroup = createReactClass({
<Dropdown groupName={snippet.name} icon={snippet.icon} key={snippet.name}>
{this.renderSnippets(snippet.subsnippets)}
</Dropdown>
)
);
}
});
@@ -3,8 +3,10 @@
@import (less) '@themes/fonts/5e/fonts.less';
.snippetBar {
--activeTriggerColor: inherit;
--menuColor : #DDDDDD;
--textColor : black;
--hoverMenuColor : #999;
@menuHeight : 25px;
position : relative;
display : flex;
@@ -12,8 +14,8 @@
reading-flow : flex-visual;
justify-content : space-between;
height : auto;
color : black;
background-color : #DDDDDD;
color : var(--textColor);
background-color : var(--menuColor);
font-size : .65rem;
font-family: 'Open Sans', sans-serif;
text-transform: uppercase;
@@ -23,7 +25,7 @@
display : flex;
justify-content : flex-end;
min-width : 275px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied
font-size: .85rem;
font-size : 0.85rem;
&:only-child {min-width : unset; margin-left : auto;}
reading-order : 2;
@@ -44,7 +46,7 @@
&.editorTool:not(.active) { cursor : not-allowed; }
&:hover,&.selected { background-color : #999999; }
&:hover,&.selected { background-color : var(--hoverMenuColor) }
&.text {
.tooltipLeft('Brew Editor');
}
@@ -101,11 +103,6 @@
background-color : #999999;
}
}
&.divider {
width : 5px;
background : linear-gradient(currentColor, currentColor) no-repeat center/1px 100%;
&:hover { background-color : inherit; }
}
}
.themeSelector {
position : absolute;
@@ -138,36 +135,32 @@
}
// removed caret for top level items, by request (makes buttons too wide).
.menu-wrapper .menu-item:is(.snippets > .menu-wrapper > .menu-item):first-child .caret { display: none; }
.menu-wrapper .menu-item:is(.snippets > .menu-wrapper > .menu-item):first-child .caret { display : none; }
.menu-item {
position : relative;
display : flex;
justify-content: space-between;
align-items : center;
min-width : max-content;
padding : 5px;
cursor : pointer;
width: 100%;
&:is(.menu-list .menu-item) [class*="name"] {
padding-inline: 8px; // additional space between icon and name (helpful in Fonts menu especially).
position : relative;
display : flex;
align-items : center;
justify-content : space-between;
width : 100%;
min-width : max-content;
padding : 5px;
cursor : pointer;
&:is(.menu-list .menu-item) [class*='name'] {
padding-inline : 8px; // additional space between icon and name (helpful in Fonts menu especially).
}
.menu-name {
flex: 1;
text-align: left;
text-box-trim: trim-end;
flex : 1;
text-align : left;
text-box-trim : trim-end;
}
i {
min-width : 25px;
height : .85rem;
height : 0.85rem;
font-size : 1.2em;
text-align : center;
&.caret {
margin-right: 0;
}
&.caret:is(.menu-wrapper .menu-wrapper * ) {
text-align: right;
}
&.caret { margin-right : 0; }
&.caret:is(.menu-wrapper .menu-wrapper *) { text-align : right; }
/* Fonts */
&.font {
height : auto;
@@ -207,17 +200,17 @@
border-radius : 12px;
}
&:hover {
background-color : #999999;
background-color : var(--hoverMenuColor);
}
&:disabled {
color: gray;
cursor: not-allowed;
&:hover { background-color: unset; }
color : gray;
cursor : not-allowed;
&:hover { background-color : unset; }
}
}
}
@container editor (width < 841px) {
.snippetBar {
.snippetBar {
.editors {
flex : 1;
justify-content : space-between;
@@ -233,3 +226,8 @@
}
}
.editor.darkMode .snippetBar {
--menuColor : #666;
--textColor : #eee;
--hoverMenuColor : #444;
}
+3 -1
View File
@@ -4,7 +4,7 @@ import Combobox from '@components/combobox.jsx';
import { tagSuggestionList, canonizationList } from './curatedTagSuggestionList.js';
const TagInput = ({ tooltip, label, valuePatterns, values = [], unique = true, placeholder = '', smallText = '', onChange })=>{
const TagInput = ({ id, tooltip, label, valuePatterns, values = [], unique = true, placeholder = '', smallText = '', onChange })=>{
const [tagList, setTagList] = useState(
values.map((value)=>({
value,
@@ -128,6 +128,7 @@ const TagInput = ({ tooltip, label, valuePatterns, values = [], unique = true, p
return (
<div className='tagInputWrap'>
<Combobox
id={id}
trigger='click'
className='tagInput-dropdown'
default=''
@@ -155,6 +156,7 @@ const TagInput = ({ tooltip, label, valuePatterns, values = [], unique = true, p
<ul className='list'>
{tagList.map((t, i)=>t.editing ? (
<input
id={`${id}-${i}`}
key={i}
type='text'
value={t.draft} // always use draft
+442
View File
@@ -0,0 +1,442 @@
@import '@themes/assets/assets.less';
@import '@sharedStyles/core.less';
.userThemeName {
padding-right : 10px;
padding-left : 10px;
}
.uiEditor {
--bg-clr : white;
--bg-image : @backgroundImageAlt;
--h1-clr : black;
--h2-clr : #000077;
--label-clr : black;
--input-bg : white;
--input-bg-hover : #DDDDDD;
--input-text-clr : black;
--input-placeholder-clr : grey;
position : absolute;
box-sizing : border-box;
width : 100%;
height : calc(100vh - 54px); // 54px is the height of the navbar + snippet bar. probably a better way to dynamic get this.
padding : 25px;
overflow-y : auto;
font-size : 13px;
color : var(--label-clr);
background-color : var(--bg-clr);
background-image : var(--bg-image);
background-size : cover;
h1 {
margin : 0 0 40px;
font-size : 18px;
font-weight : bold;
color : var(--h1-clr);
text-transform : uppercase;
}
h2 {
margin : 16px 0;
font-size : 15px;
font-weight : bold;
color : var(--h2-clr);
border-bottom : 2px solid currentColor;
}
& > div ,& > fieldset { margin-bottom : 10px; }
.field-group {
display : flex;
flex-wrap : wrap;
gap : 10px;
width : 100%;
}
.field-column {
display : flex;
flex : 5 0 250px;
flex-direction : column;
gap : 10px;
}
.field {
position : relative;
display : flex;
flex-wrap : wrap;
width : 100%;
min-width : 250px;
padding-left : 10px;
& > label {
width : 100px;
font-size : 1em;
font-weight : 800;
line-height : 1.8em;
color : inherit;
text-transform : capitalize;
}
& > .value {
flex : 1 1 auto;
width : 50px;
&[data-tooltip-right] { max-width : 380px; }
&:invalid { background : #FFB9B9; }
small {
display : block;
width : fit-content;
margin-inline : 5px;
font-size : 0.9em;
font-style : italic;
line-height : 1.4em;
}
}
input[type='checkbox'], input[type='number'] {
float : right;
text-align : end;
}
input[type='text'], textarea, select {
color : var(--input-text-clr);
background-color : var(--input-bg);
border : 1px solid var(--input-placeholder-clr);
&:hover, :focus { outline : 1px solid var(--input-placeholder-clr); background-color : var(--input-bg-hover); }
&::placeholder { color : var(--input-placeholder-clr); }
}
input[type='range'] {
color: var(--input-text-clr);
}
&.description {
flex : 1;
textarea.value {
height : auto;
font-family : 'Open Sans', sans-serif;
resize : none;
}
}
&.thumbnail, &.themes {
label { line-height : 2.0em; }
.value {
overflow : hidden;
text-overflow : ellipsis;
}
button {
.colorButton();
padding : 0px 5px;
color : var(--input-text-clr);
background-color : var(--input-bg);
border : 1px solid #999999;
&:hover { background-color : #777777; }
}
}
&.tags .tagInput-dropdown {
z-index : 400;
max-width : 200px;
.dropdown-options {
color : var(--input-text-clr);
background-color : var(--input-bg);
.item:hover {
color : var(--input-bg);
background-color : var(--input-text-clr);
}
}
}
&.language .value {
z-index : 300;
max-width : 150px;
}
&.themes {
.value {
overflow : visible;
}
button {
padding-right : 5px;
padding-left : 5px;
}
& .dropdown-container { z-index : 200; }
& .dropdown-options { overflow-y : visible; }
.disabled {
font-style : italic;
color : dimgray;
background-color : darkgray;
}
.item.dropdown-input { border:none;}
.item {
position : relative;
overflow : visible;
color : inherit;
background-color : var(--input-bg);
border-top : 1px solid #767676;
.preview {
position : absolute;
top : 0;
right : 0;
z-index : 10;
display : flex;
flex-direction : column;
width : 200px;
overflow : hidden;
color : var(--input-text-clr);
background : var(--input-bg);
border-radius : 5px;
box-shadow : 0 0 5px var(--input-text-clr);
opacity : 0;
transition : opacity 250ms ease;
h6 {
padding-block : 0.5em;
padding-inline : 1em;
font-weight : 900;
border-bottom : 2px solid #666666;
}
}
input { padding-right : 25px; }
.texture-container {
position : absolute;
top : 0;
left : 0;
width : 100%;
height : 100%;
min-height : 100%;
overflow : hidden;
> img {
position : absolute;
top : 0;
right : 0;
width : 50%;
min-height : 100%;
-webkit-mask-image : linear-gradient(90deg, transparent, black 20%);
mask-image : linear-gradient(90deg, transparent, black 20%);
}
}
&:hover > .preview { opacity : 1; }
}
}
&.renderers .value {
label {
display : inline-flex;
align-items : center;
margin-right : 15px;
font-size : 0.9em;
font-weight : 800;
vertical-align : middle;
white-space : nowrap;
cursor : pointer;
user-select : none;
}
input {
margin : 3px;
vertical-align : middle;
cursor : pointer;
}
}
&.authors {
.tag {
font-weight : 300;
transition : background-color 0.2s;
&.owner {
position : relative;
display : grid;
place-items : center;
min-width : 25px;
font-weight : 900;
background-color : @silverLight;
&::after {
position : absolute;
top : 0;
left : 0;
width : 15px;
height : 15px;
font-family : 'Font Awesome 6 Free';
color : gold;
content : '\f521';
transform : scaleY(0.7);
rotate : -25deg;
translate : -30% -50%;
}
}
&:has(button) a { padding-right : 5px; }
&:has(button:hover) { background : #D97D7D; }
button { color : @red; }
}
a { text-underline-offset : 0.2em; }
}
&.invitedAuthors .value {
z-index : 100;
.tagInput-dropdown { max-width : 200px; }
}
&.publish .value {
position : relative;
margin-bottom : 15px;
button { width : 100%; }
button.publish {
.colorButton(@blueLight);
}
button.unpublish {
.colorButton(@silver);
}
}
&.delete .value {
button {
.colorButton(@red);
}
}
.list {
display : flex;
flex : 1 0;
flex-wrap : wrap;
> * { flex : 0 0 auto; }
#groupedIcon {
#backgroundColors;
position : relative;
top : -0.3em;
right : -0.3em;
display : inline-block;
min-width : 20px;
height : ~'calc(100% + 0.6em)';
color : white;
text-align : center;
cursor : pointer;
i {
position : relative;
top : 50%;
transform : translateY(-50%);
}
&:not(:last-child) { border-right : 1px solid black; }
&:last-child { border-radius : 0 0.5em 0.5em 0; }
}
.tag {
padding : 0.35em;
margin : 2px;
font-size : 0.95em;
border-radius : 0.5em;
.icon { #groupedIcon; }
button {
cursor : pointer;
&:hover { color : @redLight; }
}
}
.input-group {
height : ~'calc(.9em + 4px + .6em)';
input { border-radius : 0.5em 0 0 0.5em; }
input:last-child { border-radius : 0.5em; }
.value {
width : 7.5vw;
min-width : 75px;
height : 100%;
}
.input-group {
height : ~'calc(.9em + 4px + .6em)';
input { border-radius : 0.5em 0 0 0.5em; }
input:last-child { border-radius : 0.5em; }
.value {
width : 7.5vw;
min-width : 75px;
height : 100%;
}
.invalid:focus { background-color : pink; }
.icon {
#groupedIcon;
top : -0.54em;
right : 1px;
height : 97%;
i { font-size : 1.125em; }
}
}
}
}
}
.thumbnail-preview {
position : relative;
flex : 1 1;
justify-self : center;
width : 80px;
height : min-content;
max-height : 115px;
aspect-ratio : 1 / 1;
object-fit : contain;
background-color : #AAAAAA;
}
.tag {
color : var(--input-bg);
background : var(--input-text-clr);
}
}
.settingsEditor .field {
padding-bottom : 10px;
border-bottom : 1px solid black;
.value {
display : flex;
justify-content : end;
}
> label {
width : fit-content;
max-width : calc(100% - 200px);
}
}
.editor.darkMode .uiEditor {
--bg-clr : #555555;
--bg-image : @backgroundImageAltDark;
--h1-clr : white;
--h2-clr : #AAAAFF;
--label-clr : #DDDDDD;
--input-bg : #333333;
--input-bg-hover : #666666;
--input-text-clr : #EEEEEE;
--input-placeholder-clr : #AAAAAA;
.field { border-color : var(--h1-clr); }
a {
color : @blueLight;
&:visited { color : #CB8AD8; }
}
.thumbnail-preview[src='/client/homebrew/thumbnail.png'] { filter : invert(0.8); }
button.publish {
.colorButton(#3137de) !important;
}
}
+3 -4
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';
@@ -47,7 +46,7 @@ const Homebrew = (props)=>{
global.enablev4 = enablev4;
const backgroundObject = ()=>{
if(config?.deployment || (config?.local && config?.development)) {
if(config?.deployment || config?.developmentStyle) {
const bgText = config?.deployment || 'Local';
return {
backgroundImage : `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' height='100px' width='200px'><text x='0' y='15' fill='%23fff7' font-size='20'>${bgText}</text></svg>")`
@@ -61,7 +60,7 @@ const Homebrew = (props)=>{
if(brew.pureError) {
return (
<Router>
<div className={`homebrew${(config?.deployment || config?.local) ? ' deployment' : ''}`} style={backgroundObject()}>
<div className={`homebrew${(config?.deployment || config?.developmentStyle) ? ' deployment' : ''}`} style={backgroundObject()}>
<Routes>
<Route path={brew.originalUrl} element={<WithRoute el={ErrorPage} brew={brew} />} />
</Routes>
@@ -73,7 +72,7 @@ const Homebrew = (props)=>{
return (
<Router>
<div className={`homebrew${(config?.deployment || config?.local) ? ' deployment' : ''}`} style={backgroundObject()}>
<div className={`homebrew${(config?.deployment || config?.developmentStyle) ? ' deployment' : ''}`} style={backgroundObject()}>
<Routes>
<Route path='/edit/:id' element={<WithRoute el={EditPage} brew={brew} userThemes={userThemes}/>} />
<Route path='/share/:id' element={<WithRoute el={SharePage} brew={brew} />} />
+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;
+34 -160
View File
@@ -2,15 +2,14 @@
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 { hbfm } from 'marked-hbfm';
import _ from 'lodash';
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle } 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';
@@ -39,18 +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 sandbox = false;
const sandbox = false;
const EditPage = (props)=>{
props = {
@@ -59,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));
@@ -68,83 +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
const {
handleBrewChange
} = useCommonEditPageFunctions({
setError,
setThemeBundle,
HTMLErrors,
setHTMLErrors,
setCurrentBrew,
useLocalStorage,
BREWKEY,
STYLEKEY,
SNIPKEY,
METAKEY,
fetchThemeBundle,
hbfm
});
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) 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 editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
const updateBrew = (newData)=>setCurrentBrew((prevBrew)=>({
...prevBrew,
@@ -153,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);
@@ -189,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));
@@ -304,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>
@@ -383,6 +232,34 @@ const EditPage = (props)=>{
</Navbar>;
};
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 (
<div className='editPage sitePage'>
<Meta name='robots' content='noindex, nofollow' />
@@ -412,14 +289,11 @@ 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}
/>
</SplitPane>
@@ -1,7 +1,7 @@
import './errorPage.less';
import React from 'react';
import UIPage from '../basePages/uiPage/uiPage.jsx';
import { hbfm } from 'hbmarkedwrapper';
import { hbfm } from 'marked-hbfm';
import ErrorIndex from './errors/errorIndex.js';
const ErrorPage = ({ brew })=>{
+47 -140
View File
@@ -2,15 +2,14 @@
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 { hbfm } from 'marked-hbfm';
import _ from 'lodash';
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle } 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';
@@ -32,11 +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 AUTOSAVE_KEY = 'HB_editor_autoSaveOn';
const BREWKEY = 'HB_newPage_content';
const STYLEKEY = 'HB_newPage_style';
const SNIPKEY = 'HB_newPage_snippets';
@@ -52,142 +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 unsavedChangesRef = useRef(unsavedChanges);
const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
const {
handleBrewChange
} = useCommonEditPageFunctions({
setError,
setThemeBundle,
HTMLErrors,
setHTMLErrors,
setCurrentBrew,
useLocalStorage,
BREWKEY,
STYLEKEY,
SNIPKEY,
METAKEY,
fetchThemeBundle,
hbfm
});
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) 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 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 = ()=>{
@@ -206,6 +88,33 @@ const HomePage =(props)=>{
</Navbar>;
};
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 (
<div className='homePage sitePage'>
<Meta name='google-site-verification' content='NwnAQSSJZzAT7N-p5MY6ydQ7Njm67dtbu73ZSyE5Fy4' />
@@ -229,15 +138,13 @@ const HomePage =(props)=>{
text={currentBrew.text}
style={currentBrew.style}
renderer={currentBrew.renderer}
onPageChange={setCurrentBrewRendererPageNum}
currentEditorViewPageNum={currentEditorViewPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum}
themeBundle={themeBundle}
onPageChange={setCurrentBrewRendererPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum}
/>
</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>
+40 -143
View File
@@ -2,15 +2,15 @@
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 { hbfm } from 'marked-hbfm';
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,11 +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 AUTOSAVE_KEY = 'HB_editor_autoSaveOn';
const BREWKEY = 'HB_newPage_content';
const STYLEKEY = 'HB_newPage_style';
const SNIPKEY = 'HB_newPage_snippets';
@@ -50,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));
@@ -59,68 +52,14 @@ 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);
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();
}, []);
const {
handleBrewChange
} = useCommonEditPageFunctions({
setError,
setThemeBundle,
HTMLErrors,
setHTMLErrors,
setCurrentBrew,
useLocalStorage,
BREWKEY,
STYLEKEY,
SNIPKEY,
METAKEY,
fetchThemeBundle,
hbfm
});
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) 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;
};
}, []);
const loadBrew = ()=>{
const brew = { ...currentBrew };
if(!brew.shareId && typeof window !== 'undefined') { //Load from localStorage if in client browser
@@ -150,46 +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 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 = 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;
@@ -201,46 +116,6 @@ const NewPage = (props)=>{
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 = ()=>(
<Navbar>
<Nav.section>
@@ -261,6 +136,31 @@ const NewPage = (props)=>{
</Navbar>
);
const {
handleSplitMove,
handleBrewChange,
clearError,
renderSaveButton
} = useCommonEditPageFunctions({
saveGoogle,
setError,
setThemeBundle,
HTMLErrors,
setHTMLErrors,
currentBrew,
setCurrentBrew,
useLocalStorage,
BREWKEY,
STYLEKEY,
SNIPKEY,
METAKEY,
hbfm,
sandbox,
lastSavedBrew,
editorRef,
save,
});
return (
<div className='newPage sitePage'>
{renderNavbar()}
@@ -283,14 +183,11 @@ 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}
/>
</SplitPane>
+41 -15
View File
@@ -12,12 +12,15 @@ const { both: RecentNavItem } = RecentNavItems;
import Account from '@navbar/account.navitem.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
import request from '../../utils/request-middleware.js';
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
const SharePage = (props)=>{
const { brew = DEFAULT_BREW_LOAD, disableMeta = false } = props;
const { disableMeta = false } = props;
const [currentBrew, setCurrentBrew] = useState(props.brew || DEFAULT_BREW_LOAD);
const [themeBundle, setThemeBundle] = useState({});
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
@@ -35,9 +38,33 @@ const SharePage = (props)=>{
}
};
const fetchUpdatedBrew = async ()=>{
const response = await request
.get(`/api/fetch/${currentBrew.shareId}`)
.catch((error)=>{
console.log('error at fetching updated brew: ', error);
});
if(response.ok && !!response.body.brew) {
setCurrentBrew(response.body.brew);
}
};
useEffect(()=>{
document.addEventListener('keydown', handleControlKeys);
fetchThemeBundle(undefined, setThemeBundle, brew.renderer, brew.theme);
fetchThemeBundle(undefined, setThemeBundle, currentBrew.renderer, currentBrew.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 == currentBrew.shareId && messageData.version != currentBrew.version) {
console.log('should fetch brew');
fetchUpdatedBrew();
}
}
});
return ()=>{
document.removeEventListener('keydown', handleControlKeys);
@@ -45,13 +72,13 @@ const SharePage = (props)=>{
}, []);
const processShareId = ()=>{
return brew.googleId && !brew.stubbed ? brew.googleId + brew.shareId : brew.shareId;
return currentBrew.googleId && !currentBrew.stubbed ? currentBrew.googleId + currentBrew.shareId : currentBrew.shareId;
};
const renderEditLink = ()=>{
if(!brew.editId) return null;
if(!currentBrew.editId) return null;
const editLink = brew.googleId && ! brew.stubbed ? brew.googleId + brew.editId : brew.editId;
const editLink = currentBrew.googleId && ! currentBrew.stubbed ? currentBrew.googleId + currentBrew.editId : currentBrew.editId;
return (
<Nav.item color='orange' icon='fas fa-pencil-alt' href={`/edit/${editLink}`}>
@@ -62,7 +89,7 @@ const SharePage = (props)=>{
const titleEl = (
<Nav.item className='brewTitle' style={disableMeta ? { cursor: 'default' } : {}}>
{brew.title}
{currentBrew.title}
</Nav.item>
);
@@ -71,11 +98,11 @@ const SharePage = (props)=>{
<Meta name='robots' content='noindex, nofollow' />
<Navbar>
<Nav.section className='titleSection'>
{disableMeta ? titleEl : <MetadataNav brew={brew}>{titleEl}</MetadataNav>}
{disableMeta ? titleEl : <MetadataNav brew={currentBrew}>{titleEl}</MetadataNav>}
</Nav.section>
<Nav.section>
{brew.shareId && (
{currentBrew.shareId && (
<>
<PrintNavItem />
<Nav.dropdown>
@@ -108,21 +135,20 @@ const SharePage = (props)=>{
</Nav.dropdown>
</>
)}
<RecentNavItem brew={brew} storageKey='view' />
<RecentNavItem brew={currentBrew} storageKey='view' />
<Account />
</Nav.section>
</Navbar>
<div className='content'>
<BrewRenderer
text={brew.text}
style={brew.style}
lang={brew.lang}
renderer={brew.renderer}
theme={brew.theme}
text={currentBrew.text}
style={currentBrew.style}
lang={currentBrew.lang}
renderer={currentBrew.renderer}
theme={currentBrew.theme}
themeBundle={themeBundle}
onPageChange={handleBrewRendererPageChange}
currentBrewRendererPageNum={currentBrewRendererPageNum}
allowPrint={true}
/>
</div>
@@ -1,43 +0,0 @@
export default function useCommonEditPageFunctions(dependencies) {
const {
setError,
setThemeBundle,
HTMLErrors,
setHTMLErrors,
setCurrentBrew,
useLocalStorage,
BREWKEY,
STYLEKEY,
SNIPKEY,
METAKEY,
fetchThemeBundle,
hbfm
} = dependencies;
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 {
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,
}
}