0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-06-22 04:58:40 +00:00

Merge branch 'master' into Open-Sans---Installed-vs-Google-Fonts

This commit is contained in:
Víctor Losada Hernández
2026-05-22 22:48:16 +02:00
committed by GitHub
20 changed files with 966 additions and 817 deletions
+36 -8
View File
@@ -17,8 +17,7 @@ import {
crosshairCursor, crosshairCursor,
} from '@codemirror/view'; } from '@codemirror/view';
import { EditorState, Compartment, StateEffect, StateField } from '@codemirror/state'; import { EditorState, Compartment, StateEffect, StateField } from '@codemirror/state';
import { foldAll as foldAllCmd, unfoldAll as unfoldAllCmd, foldGutter, foldKeymap, syntaxHighlighting } from '@codemirror/language'; import { foldAll as foldAllCmd, unfoldAll as unfoldAllCmd, foldGutter, foldKeymap, foldEffect, foldState, syntaxHighlighting } from '@codemirror/language';
import { foldEffect } from '@codemirror/language';
import { defaultKeymap, history, undo, redo, undoDepth, redoDepth } from '@codemirror/commands'; import { defaultKeymap, history, undo, redo, undoDepth, redoDepth } from '@codemirror/commands';
import { languages } from '@codemirror/language-data'; import { languages } from '@codemirror/language-data';
import { css } from '@codemirror/lang-css'; import { css } from '@codemirror/lang-css';
@@ -38,8 +37,6 @@ const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery };
const themeCompartment = new Compartment(); const themeCompartment = new Compartment();
const highlightCompartment = new Compartment(); const highlightCompartment = new Compartment();
console.log(themes);
import { generalKeymap, markdownKeymap } from './customKeyMaps.js'; import { generalKeymap, markdownKeymap } from './customKeyMaps.js';
import foldOnPages from './customFolding.js'; import foldOnPages from './customFolding.js';
import { customHighlightStyle, tokenizeCustomMarkdown, tokenizeCustomCSS } from './customHighlight.js'; import { customHighlightStyle, tokenizeCustomMarkdown, tokenizeCustomCSS } from './customHighlight.js';
@@ -151,11 +148,14 @@ const CodeEditor = forwardRef(
const editorRef = useRef(null); const editorRef = useRef(null);
const viewRef = useRef(null); const viewRef = useRef(null);
const docsRef = useRef({}); const docsRef = useRef({});
const tabRef = useRef(tab);
const prevTabRef = useRef(tab); const prevTabRef = useRef(tab);
const scrollRef = useRef({});
const foldsRef = useRef({});
const pageMap = useRef([]); const pageMap = useRef([]);
const recomputePages = (doc)=>{ const recomputePages = (doc)=>{
if(tab !== 'brewText') return;
const pages = [0]; const pages = [0];
const text = doc.toString(); const text = doc.toString();
let offset = 0; let offset = 0;
@@ -181,6 +181,14 @@ const CodeEditor = forwardRef(
return page; return page;
}; };
const getFoldRanges = (state)=>{
const folds = [];
state.field(foldState, false)?.between(0, state.doc.length, (from, to)=>{
folds.push({ from, to });
});
return folds;
};
const createExtensions = ({ onChange, language, editorTheme })=>{ const createExtensions = ({ onChange, language, editorTheme })=>{
const setEventListeners = EditorView.updateListener.of((update)=>{ const setEventListeners = EditorView.updateListener.of((update)=>{
if(update.docChanged) { if(update.docChanged) {
@@ -267,11 +275,10 @@ const CodeEditor = forwardRef(
ticking = true; ticking = true;
requestAnimationFrame(()=>{ requestAnimationFrame(()=>{
const top = view.scrollDOM.scrollTop; const top = view.scrollDOM.scrollTop;
scrollRef.current[tabRef.current] = top;
const block = view.lineBlockAtHeight(top); const block = view.lineBlockAtHeight(top);
const page = findPageFromPos(block.from);
const page = findPageFromPos(block.from); // CHANGED
onViewChange(page); onViewChange(page);
ticking = false; ticking = false;
}); });
}; };
@@ -286,12 +293,23 @@ const CodeEditor = forwardRef(
}; };
}, []); }, []);
const restoreFolds = (view, folds)=>{
if(!folds?.length) return;
view.dispatch({
effects : folds.map((f)=>foldEffect.of(f))
});
};
useEffect(()=>{ useEffect(()=>{
const view = viewRef.current; const view = viewRef.current;
if(!view) return; if(!view) return;
tabRef.current = tab;
const prevTab = prevTabRef.current; const prevTab = prevTabRef.current;
foldsRef.current[prevTab] = getFoldRanges(view.state);
if(prevTab !== tab) { if(prevTab !== tab) {
docsRef.current[prevTab] = view.state; docsRef.current[prevTab] = view.state;
@@ -305,6 +323,16 @@ const CodeEditor = forwardRef(
} }
view.setState(nextState); view.setState(nextState);
restoreFolds(view, foldsRef.current[tab]);
const savedScroll = scrollRef.current[tab];
if(savedScroll != null) {
requestAnimationFrame(()=>{
view.scrollDOM.scrollTop = savedScroll;
});
}
prevTabRef.current = tab; prevTabRef.current = tab;
} }
view.focus(); view.focus();
@@ -100,6 +100,10 @@
vertical-align : sub; vertical-align : sub;
color : rgb(123, 123, 15); color : rgb(123, 123, 15);
} }
.cm-strikethrough {
text-decoration: line-through;
}
.cm-definitionList { .cm-definitionList {
.cm-definitionTerm { color : rgb(96, 117, 143); } .cm-definitionTerm { color : rgb(96, 117, 143); }
.cm-definitionColon:not(:has(.cm-comment)) { .cm-definitionColon:not(:has(.cm-comment)) {
@@ -16,6 +16,7 @@ const customTags = {
definitionTerm : 'definitionTerm', // .cm-definitionTerm definitionTerm : 'definitionTerm', // .cm-definitionTerm
definitionDesc : 'definitionDesc', // .cm-definitionDesc definitionDesc : 'definitionDesc', // .cm-definitionDesc
definitionColon : 'definitionColon', // .cm-definitionColon definitionColon : 'definitionColon', // .cm-definitionColon
strikethrough : 'strikethrough', // .cm-strikethrough
//CSS //CSS
@@ -81,6 +82,23 @@ export function tokenizeCustomMarkdown(text) {
} }
} }
// --- Strikethrough ---
if(/\~/.test(lineText)) {
const strikethroughRegex = /~(?!\s)(.+?)(?<!\s)~/g;
const match = strikethroughRegex.exec(lineText);
const type = customTags.strikethrough;
if(match) {
tokens.push({
line : lineNumber,
type,
from : match.index,
to : match.index + match[0].length,
});
}
}
// --- single line def list --- // --- single line def list ---
const singleLineRegex = /^(?=.*[^:])(.+?)(\s*)(::)([^\n]*)$/dmy; const singleLineRegex = /^(?=.*[^:])(.+?)(\s*)(::)([^\n]*)$/dmy;
const match = singleLineRegex.exec(lineText); const match = singleLineRegex.exec(lineText);
@@ -182,13 +200,13 @@ export function tokenizeCustomMarkdown(text) {
} }
if(lineText.includes('{') && lineText.includes('}')) { if(lineText.includes('{') && lineText.includes('}')) {
const injectionRegex = /(?:^|[^{\n])({(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\2})/gm; const injectionRegex = /(?:^|[^{\n])({(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\2})/gmd;
let match; let match;
while ((match = injectionRegex.exec(lineText)) !== null) { while ((match = injectionRegex.exec(lineText)) !== null) {
tokens.push({ tokens.push({
line : lineNumber, line : lineNumber,
from : match.index, from : match.indices[1][0],
to : match.index + match[1].length, to : match.indices[1][1],
type : customTags.injection, type : customTags.injection,
}); });
} }
+36 -29
View File
@@ -3,6 +3,17 @@ import { keymap } from '@codemirror/view';
import { undo, redo, indentMore, deleteLine } from '@codemirror/commands'; import { undo, redo, indentMore, deleteLine } from '@codemirror/commands';
import { Prec } from '@codemirror/state'; import { Prec } from '@codemirror/state';
const insertTab = (view)=>{
const { from, to } = view.state.selection.main;
view.dispatch({
changes : { from, to, insert: ' ' },
selection : { anchor: from + 2 }
});
return true;
};
const indentLess = (view)=>{ const indentLess = (view)=>{
const { from, to } = view.state.selection.main; const { from, to } = view.state.selection.main;
const lines = []; const lines = [];
@@ -17,48 +28,44 @@ const indentLess = (view)=>{
return true; return true;
}; };
const wrapSelection = (prefix, suffix) => (view) => { const wrapSelection = (prefix, suffix)=>(view)=>{
const { from, to } = view.state.selection.main; const changes = [];
const selected = view.state.doc.sliceString(from, to);
let text, selection; for (const range of view.state.selection.ranges) {
const { from, to } = range;
const selected = view.state.doc.sliceString(from, to);
if(from === to) { let text;
text = prefix + suffix;
selection = { anchor: from + prefix.length, head: from + prefix.length }; if(from === to) { text = prefix + suffix; } else if(selected.startsWith(prefix) && selected.endsWith(suffix)) {
} text = selected.slice(prefix.length, -suffix.length);
else if(selected.startsWith(prefix) && selected.endsWith(suffix)) { } else {text = `${prefix}${selected}${suffix}`;}
text = selected.slice(prefix.length, -suffix.length);
selection = { anchor: from, head: from + text.length }; changes.push({ from, to, insert: text });
}
else {
text = `${prefix}${selected}${suffix}`;
selection = { anchor: from, head: from + text.length };
} }
view.dispatch({ view.dispatch({
changes : { from, to, insert: text }, changes
selection
}); });
return true; return true;
}; };
const makeNbsp = (view) => { const makeNbsp = (view)=>{
const { from } = view.state.selection.main; const { from } = view.state.selection.main;
const prev2 = from >= 2 const prev2 = from >= 2
? view.state.doc.sliceString(from - 2, from) ? view.state.doc.sliceString(from - 2, from)
: ''; : '';
const insert = (prev2 === ':>' || prev2 === '>>') ? '>' : ':>'; const insert = (prev2 === ':>' || prev2 === '>>') ? '>' : ':>';
view.dispatch({ view.dispatch({
changes : { from, to: from, insert }, changes : { from, to: from, insert },
selection : { anchor: from + insert.length }, selection : { anchor: from + insert.length },
}); });
return true; return true;
}; };
const makeSpace = (view)=>{ const makeSpace = (view)=>{
@@ -162,7 +169,7 @@ const newPage = (view)=>{
}; };
export const generalKeymap = Prec.high(keymap.of([ export const generalKeymap = Prec.high(keymap.of([
{ key: 'Tab', run: indentMore }, { key: 'Tab', run: insertTab },
{ key: 'Mod-z', run: undo }, //i think it may be unnecessary { key: 'Mod-z', run: undo }, //i think it may be unnecessary
{ key: 'Mod-Shift-z', run: redo }, { key: 'Mod-Shift-z', run: redo },
{ key: 'Mod-y', run: redo }, { key: 'Mod-y', run: redo },
+11 -3
View File
@@ -41,6 +41,7 @@ const BrewPage = (props)=>{
props = { props = {
contents : '', contents : '',
index : 0, index : 0,
hoisted : false,
...props ...props
}; };
const pageRef = useRef(null); const pageRef = useRef(null);
@@ -220,7 +221,8 @@ const BrewRenderer = (props)=>{
} }
}; };
const renderPages = ()=>{ const renderPages = (checkHoists = false)=>{
if(props.errors && props.errors.length) if(props.errors && props.errors.length)
return renderedPages; return renderedPages;
@@ -232,10 +234,16 @@ const BrewRenderer = (props)=>{
renderedPages[props.currentEditorCursorPageNum - 1] = renderPage(rawPages[props.currentEditorCursorPageNum - 1], props.currentEditorCursorPageNum - 1); renderedPages[props.currentEditorCursorPageNum - 1] = renderPage(rawPages[props.currentEditorCursorPageNum - 1], props.currentEditorCursorPageNum - 1);
_.forEach(rawPages, (page, index)=>{ _.forEach(rawPages, (page, index)=>{
if((isInView(index) || !renderedPages[index]) && typeof window !== 'undefined'){ 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'){
renderedPages[index] = renderPage(page, index); // Render any page not yet rendered, but only re-render those in PPR range 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; return renderedPages;
}; };
@@ -275,7 +283,7 @@ const BrewRenderer = (props)=>{
window.addEventListener('hashchange', ()=>scrollToHash(window.location.hash)); 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 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 renderPages(true); //Make sure page is renderable before showing
setState((prevState)=>({ setState((prevState)=>({
...prevState, ...prevState,
isMounted : true, isMounted : true,
@@ -30,18 +30,17 @@ import cm5Themes from 'codemirror-5-themes';
const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery }; const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery };
const themeNames = Object.entries(themes) const themeNames = Object.entries(themes)
.filter(([name, value]) => .filter(([name, value])=>Array.isArray(value) &&
Array.isArray(value) &&
!name.endsWith('Init') && !name.endsWith('Init') &&
!name.endsWith('Style') !name.endsWith('Style')
) )
.map(([name]) => name); .map(([name])=>name);
const EditorThemes = [ const EditorThemes = [
'default', 'default',
...themeNames ...themeNames
.filter(name => name !== 'default') .filter((name)=>name !== 'default')
.sort((a, b) => a.localeCompare(b)) .sort((a, b)=>a.localeCompare(b))
]; ];
const execute = function(val, props){ const execute = function(val, props){
+18 -2
View File
@@ -1,9 +1,25 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import Nav from './nav.jsx'; import Nav from './nav.jsx';
import { printCurrentBrew } from '@shared/helpers.js'; import { printCurrentBrew } from '@shared/helpers.js';
export default function(){ export default function(){
const [printing, setPrinting] = useState(false);
// listen for print cycle events to display "loading" message since it can take some time.
useEffect(()=>{
document.addEventListener('print:startprep', handlePrintStartPrep);
document.addEventListener('print:finishedprep', handlePrintPrepFinished);
return ()=>{
document.removeEventListener('print:startprep', handlePrintStartPrep);
document.removeEventListener('print:finishedprep', handlePrintPrepFinished);
}
}, []);
const handlePrintStartPrep = ()=>{ setPrinting(true); };
const handlePrintPrepFinished = ()=>{ setPrinting(false); };
return <Nav.item onClick={printCurrentBrew} color='purple' icon='far fa-file-pdf'> return <Nav.item onClick={printCurrentBrew} color='purple' icon='far fa-file-pdf'>
get PDF {printing ? 'loading' : 'get PDF'}
</Nav.item>; </Nav.item>;
}; };
+8 -10
View File
@@ -90,7 +90,7 @@ const EditPage = (props)=>{
const handleControlKeys = (e)=>{ const handleControlKeys = (e)=>{
if(!(e.ctrlKey || e.metaKey)) return; if(!(e.ctrlKey || e.metaKey)) return;
if(e.keyCode === 83) trySaveRef.current(true); if(e.keyCode === 83) trySaveRef.current(true, true, saveGoogle);
if(e.keyCode === 80) printCurrentBrew(); if(e.keyCode === 80) printCurrentBrew();
if([83, 80].includes(e.keyCode)) { if([83, 80].includes(e.keyCode)) {
e.stopPropagation(); e.stopPropagation();
@@ -118,13 +118,9 @@ const EditPage = (props)=>{
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current); const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
setUnsavedChanges(hasChange); setUnsavedChanges(hasChange);
if(autoSaveEnabled) trySave(false, hasChange); if(autoSaveEnabled) trySave(false, hasChange, saveGoogle);
}, [currentBrew]); }, [currentBrew]);
useEffect(()=>{
trySave(true);
}, [saveGoogle]);
const handleSplitMove = ()=>{ const handleSplitMove = ()=>{
editorRef.current?.update(); editorRef.current?.update();
}; };
@@ -183,11 +179,13 @@ const EditPage = (props)=>{
}; };
const toggleGoogleStorage = ()=>{ const toggleGoogleStorage = ()=>{
const newSaveGoogle = !saveGoogle;
setSaveGoogle((prev)=>!prev); setSaveGoogle((prev)=>!prev);
setError(null); setError(null);
trySave(true, true, newSaveGoogle);
}; };
const trySave = (immediate = false, hasChanges = true)=>{ const trySave = (immediate = false, hasChanges = true, saveToGoogle = false)=>{
clearTimeout(saveTimeout.current); clearTimeout(saveTimeout.current);
if(isSaving) return; if(isSaving) return;
if(!hasChanges && !immediate) return; if(!hasChanges && !immediate) return;
@@ -196,7 +194,7 @@ const EditPage = (props)=>{
saveTimeout.current = setTimeout(async ()=>{ saveTimeout.current = setTimeout(async ()=>{
setIsSaving(true); setIsSaving(true);
setError(null); setError(null);
await save(currentBrew, saveGoogle) await save(currentBrew, saveToGoogle)
.catch((err)=>{ .catch((err)=>{
setError(err); setError(err);
}); });
@@ -216,7 +214,7 @@ const EditPage = (props)=>{
const brewToSave = { const brewToSave = {
...brew, ...brew,
text : brew.text.normalize('NFC'), text : brew.text.normalize('NFC'),
pageCount : ((brew.renderer === 'legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1, pageCount : ((brew.renderer === 'legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/gm)) || []).length + 1,
patches : stringifyPatches(makePatches(encodeURI(lastSavedBrew.current.text.normalize('NFC')), encodeURI(brew.text.normalize('NFC')))), patches : stringifyPatches(makePatches(encodeURI(lastSavedBrew.current.text.normalize('NFC')), encodeURI(brew.text.normalize('NFC')))),
hash : await md5(lastSavedBrew.current.text.normalize('NFC')), hash : await md5(lastSavedBrew.current.text.normalize('NFC')),
textBin : undefined, textBin : undefined,
@@ -314,7 +312,7 @@ const EditPage = (props)=>{
// #3 - Unsaved changes exist, click to save, show SAVE NOW // #3 - Unsaved changes exist, click to save, show SAVE NOW
if(unsavedChanges) if(unsavedChanges)
return <Nav.item className='save' onClick={()=>trySave(true)} color='blue' icon='fas fa-save'>save now</Nav.item>; 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 // #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
if(autoSaveEnabled) if(autoSaveEnabled)
+1 -1
View File
@@ -156,7 +156,7 @@ const NewPage = (props)=>{
const updatedBrew = { ...currentBrew }; const updatedBrew = { ...currentBrew };
splitTextStyleAndMetadata(updatedBrew); splitTextStyleAndMetadata(updatedBrew);
const pageRegex = updatedBrew.renderer === 'legacy' ? /\\page/g : /^\\page$/gm; const pageRegex = updatedBrew.renderer === 'legacy' ? /\\page/g : /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/gm;
updatedBrew.pageCount = (updatedBrew.text.match(pageRegex) || []).length + 1; updatedBrew.pageCount = (updatedBrew.text.match(pageRegex) || []).length + 1;
const res = await request const res = await request
+712 -695
View File
File diff suppressed because it is too large Load Diff
+13 -13
View File
@@ -88,10 +88,10 @@
"dependencies": { "dependencies": {
"@babel/core": "^7.29.0", "@babel/core": "^7.29.0",
"@babel/plugin-transform-runtime": "^7.29.0", "@babel/plugin-transform-runtime": "^7.29.0",
"@babel/preset-env": "^7.29.2", "@babel/preset-env": "^7.29.5",
"@babel/preset-react": "^7.28.5", "@babel/preset-react": "^7.28.5",
"@babel/runtime": "^7.29.2", "@babel/runtime": "^7.29.2",
"@codemirror/autocomplete": "^6.20.1", "@codemirror/autocomplete": "^6.20.2",
"@codemirror/commands": "^6.10.3", "@codemirror/commands": "^6.10.3",
"@codemirror/highlight": "^0.19.8", "@codemirror/highlight": "^0.19.8",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
@@ -101,7 +101,7 @@
"@codemirror/language-data": "^6.5.2", "@codemirror/language-data": "^6.5.2",
"@codemirror/search": "^6.6.0", "@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.6.0", "@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.40.0", "@codemirror/view": "^6.43.0",
"@dmsnell/diff-match-patch": "^1.1.0", "@dmsnell/diff-match-patch": "^1.1.0",
"@googleapis/drive": "^20.1.0", "@googleapis/drive": "^20.1.0",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
@@ -117,9 +117,9 @@
"dedent": "^1.7.1", "dedent": "^1.7.1",
"express": "^5.1.0", "express": "^5.1.0",
"express-async-handler": "^1.2.0", "express-async-handler": "^1.2.0",
"express-static-gzip": "3.0.0", "express-static-gzip": "3.0.1",
"fflate": "^0.8.2", "fflate": "^0.8.2",
"fs-extra": "^11.3.3", "fs-extra": "^11.3.5",
"hash-wasm": "^4.12.0", "hash-wasm": "^4.12.0",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
@@ -138,31 +138,31 @@
"marked-variables": "^1.0.5", "marked-variables": "^1.0.5",
"markedLegacy": "npm:marked@^0.3.19", "markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1", "moment": "^2.30.1",
"mongoose": "^9.3.3", "mongoose": "^9.6.2",
"nanoid": "5.1.7", "nanoid": "5.1.11",
"nconf": "^0.13.0", "nconf": "^0.13.0",
"node": "^25.9.0", "node": "^25.9.0",
"react": "^19.2.4", "react": "^19.2.6",
"react-dom": "^19.2.4", "react-dom": "^19.2.6",
"react-frame-component": "^5.3.2", "react-frame-component": "^5.3.2",
"react-router": "^7.14.0", "react-router": "^7.15.1",
"sanitize-filename": "1.6.4", "sanitize-filename": "1.6.4",
"superagent": "^10.2.1" "superagent": "^10.2.1"
}, },
"devDependencies": { "devDependencies": {
"@stylistic/stylelint-plugin": "^5.0.1", "@stylistic/stylelint-plugin": "^5.0.1",
"babel-jest": "^30.3.0", "babel-jest": "^30.4.1",
"babel-plugin-transform-import-meta": "^2.3.3", "babel-plugin-transform-import-meta": "^2.3.3",
"eslint": "9.7", "eslint": "9.7",
"eslint-plugin-jest": "^29.15.1", "eslint-plugin-jest": "^29.15.1",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"globals": "^16.4.0", "globals": "^16.4.0",
"jest": "^30.3.0", "jest": "^30.4.2",
"jest-expect-message": "^1.1.3", "jest-expect-message": "^1.1.3",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"jsdom-global": "^3.0.2", "jsdom-global": "^3.0.2",
"postcss-less": "^6.0.0", "postcss-less": "^6.0.0",
"stylelint": "^17.6.0", "stylelint": "^17.11.1",
"stylelint-config-recess-order": "^7.7.0", "stylelint-config-recess-order": "^7.7.0",
"stylelint-config-recommended": "^18.0.0", "stylelint-config-recommended": "^18.0.0",
"supertest": "^7.1.4", "supertest": "^7.1.4",
+1 -1
View File
@@ -593,7 +593,7 @@ export default async function createApp(vite) {
html = html.replace( html = html.replace(
'<head>', '<head>',
`<head>\n<script id="props" >window.__INITIAL_PROPS__ = ${JSON.stringify(props)}</script>\n${ogMetaTags}` ()=>{ return `<head>\n<script id="props" >window.__INITIAL_PROPS__ = ${JSON.stringify(props)}</script>\n${ogMetaTags}`; }
); );
return html; return html;
+18 -11
View File
@@ -32,20 +32,20 @@ const isStaticTheme = (renderer, themeName)=>{
// }; // };
const migrateSystemsToTags = (brew) => { const migrateSystemsToTags = (brew)=>{
if (!('systems' in brew)) return brew; if(!('systems' in brew)) return brew;
if (!Array.isArray(brew.systems) || brew.systems.length === 0) { if(!Array.isArray(brew.systems) || brew.systems.length === 0) {
brew.systems = undefined; brew.systems = undefined;
return brew; return brew;
} }
const systemMap = { const systemMap = {
'5e': 'system:D&D 5e', '5e' : 'system:D&D 5e',
'4e': 'system:D&D 4e', '4e' : 'system:D&D 4e',
'3.5e': 'system:D&D 3.5e', '3.5e' : 'system:D&D 3.5e',
'Pathfinder': 'system:Pathfinder 2e' 'Pathfinder' : 'system:Pathfinder 2e'
}; };
const systemTags = brew.systems.map(s => systemMap[s]); const systemTags = brew.systems.map((s)=>systemMap[s]);
brew.tags = _.uniq([...(brew.tags || []), ...systemTags]); brew.tags = _.uniq([...(brew.tags || []), ...systemTags]);
brew.systems = undefined; brew.systems = undefined;
@@ -397,17 +397,24 @@ const api = {
return res.status(409).send(JSON.stringify({ message: `The server copy is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.` })); return res.status(409).send(JSON.stringify({ message: `The server copy is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.` }));
} }
let result = [];
try { try {
const patches = parsePatch(brewFromClient.patches); const patches = parsePatch(brewFromClient.patches);
// Patch to a throwaway variable while parallelizing - we're more concerned with error/no error. // Patch to a throwaway variable while parallelizing - we're more concerned with error/no error.
const patchedResult = decodeURI(applyPatches(patches, encodeURI(brewFromServer.text))[0]); result = applyPatches(patches, encodeURI(brewFromServer.text));
if(patchedResult != brewFromClient.text) const failedPatches = patches.map((patch, index)=>{if(!result[1][index]){ return patch; }});
if(failedPatches > 0){
throw (`Patch failure: ${failedPatches}/${result[1].length} did not apply`);
}
if(decodeURI(result[0]) != brewFromClient.text){
throw ('Patches did not apply cleanly, text mismatch detected'); throw ('Patches did not apply cleanly, text mismatch detected');
}
// brew.text = applyPatches(patches, brewFromServer.text)[0]; // brew.text = applyPatches(patches, brewFromServer.text)[0];
} catch (err) { } catch (err) {
debugTextMismatch(brewFromClient.text, brewFromServer.text, `edit/${brewFromClient.editId}`); debugTextMismatch(brewFromClient.text, brewFromServer.text, `edit/${brewFromClient.editId}`);
console.error('Failed to apply patches:', { console.error('Failed to apply patches:', {
//patches : brewFromClient.patches, // patches : brewFromClient.patches,
// result : result,
brewId : brewFromClient.editId || 'unknown', brewId : brewFromClient.editId || 'unknown',
error : err error : err
}); });
+1 -1
View File
@@ -15,7 +15,7 @@ const HomebrewSchema = mongoose.Schema({
description : { type: String, default: '' }, description : { type: String, default: '' },
tags : { type: [String], index: true }, tags : { type: [String], index: true },
systems : { type: [String], default: undefined }, systems : { type: [String], default: undefined },
lang : { type: String, default: 'en', index: true }, lang : { type: String, default: 'en', index: true },
renderer : { type: String, default: '', index: true }, renderer : { type: String, default: '', index: true },
authors : { type: [String], index: true }, authors : { type: [String], index: true },
+56 -9
View File
@@ -105,14 +105,35 @@ const splitTextStyleAndMetadata = (brew)=>{
if(typeof brew.tags === 'string') brew.tags = brew.tags ? [brew.tags] : []; if(typeof brew.tags === 'string') brew.tags = brew.tags ? [brew.tags] : [];
}; };
const printCurrentBrew = ()=>{ const printCurrentBrew = async ()=>{
if(window.typeof !== 'undefined') { if(window.typeof !== 'undefined') {
window.frames['BrewRenderer'].contentWindow.print(); // fire a custom event for the print cycle
//Force DOM reflow; Print dialog causes a repaint, and @media print CSS somehow makes out-of-view pages disappear document.dispatchEvent(new CustomEvent('print:startprep'));
const node = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer').item(0); try {
node.style.display='none'; const iframeDoc = window.frames['BrewRenderer'].contentDocument;
node.offsetHeight; // accessing this is enough to trigger a reflow
node.style.display=''; // get all img elements with lazy loading (currently only elements generated through MarkedJS)
const lazyImages = [...iframeDoc.querySelectorAll('img[loading="lazy"]')];
lazyImages.forEach((img)=>{ img.loading = 'eager'; });
// waits for images to load before resolving promise and opening print dialog
await Promise.all(
lazyImages
.filter((img)=>!img.complete)
.map((img)=>new Promise((resolve)=>{ img.onload = resolve; img.onerror = resolve; }))
);
window.frames['BrewRenderer'].contentWindow.print();
//Force DOM reflow; Print dialog causes a repaint, and @media print CSS somehow makes out-of-view pages disappear
const node = iframeDoc.getElementsByClassName('brewRenderer').item(0);
node.style.display='none';
node.offsetHeight; // accessing this is enough to trigger a reflow
node.style.display='';
} finally {
// when lazy load images have all been loaded, and the doc re-rendered for print preview, emit 'finished' event.
document.dispatchEvent(new CustomEvent('print:finishedprep'));
}
} }
}; };
@@ -160,9 +181,35 @@ const debugTextMismatch = (clientTextRaw, serverTextRaw, label)=>{
// Char-level diff // Char-level diff
for (let i = 0; i < Math.min(clientText.length, serverText.length); i++) { for (let i = 0; i < Math.min(clientText.length, serverText.length); i++) {
if(clientText[i] !== serverText[i]) { if(clientText[i] !== serverText[i]) {
const getMismatchContext = (text, index, name, size = 10)=>{
const lower = Math.max(index - size, 0);
const upper = Math.min(index + size, text.length);
const slice = `${JSON.stringify(text.slice(lower, index)).slice(1, -1)}\u001B[31m${JSON.stringify(text[i]).slice(1, -1)}\u001B[0m${JSON.stringify(text.slice(index+1, upper)).slice(1, -1)}`;
const lineNo = text.slice(0, index).split('\n').length;
const code = `U+${text.charCodeAt(i).toString(16).toUpperCase()}`;
return {
name,
lineNo,
code,
lower,
upper,
slice
};
};
const boundSize = 10;
const clientContext = getMismatchContext(clientText, i, 'Client', boundSize);
const serverContext = getMismatchContext(serverText, i, 'Server', boundSize);
const logContext = (context)=>{
console.log(` ${context.name} - line ${context.lineNo} : (${context.code})\t${context.slice}`);
};
console.log(`Char mismatch at index ${i}:`); console.log(`Char mismatch at index ${i}:`);
console.log(` Client: '${clientText[i]}' (U+${clientText.charCodeAt(i).toString(16).toUpperCase()})`); logContext(clientContext);
console.log(` Server: '${serverText[i]}' (U+${serverText.charCodeAt(i).toString(16).toUpperCase()})`); logContext(serverContext);
break; break;
} }
} }
+1 -1
View File
@@ -83,7 +83,7 @@ renderer.image = function (token) {
if(href === null) if(href === null)
return text; return text;
let out = `<img src="${href}" alt="${text}" style="--HB_src:url(${href});"`; let out = `<img loading="lazy" src="${href}" alt="${text}" style="--HB_src:url(${href});"`;
if(title) if(title)
out += ` title="${title}"`; out += ` title="${title}"`;
+4 -4
View File
@@ -324,7 +324,7 @@ describe('Injection: When an injection tag follows an element', ()=>{
it('Renders an image element with injected style', function() { it('Renders an image element with injected style', function() {
const source = '![alt text](https://i.imgur.com/hMna6G0.png){position:absolute}'; const source = '![alt text](https://i.imgur.com/hMna6G0.png){position:absolute}';
const rendered = Markdown.render(source).trimReturns(); const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png); position:absolute;" src="https://i.imgur.com/hMna6G0.png" alt="alt text"></p>'); expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png); position:absolute;" loading="lazy" src="https://i.imgur.com/hMna6G0.png" alt="alt text"></p>');
}); });
it('Renders an element modified by only the first of two consecutive injections', function() { it('Renders an element modified by only the first of two consecutive injections', function() {
@@ -343,19 +343,19 @@ describe('Injection: When an injection tag follows an element', ()=>{
it('Renders an image with added attributes', function() { it('Renders an image with added attributes', function() {
const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e}`; const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e}`;
const rendered = Markdown.render(source).trimReturns(); const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png); position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png" alt="homebrew mug" a="b and c" d="e"></p>`); expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png); position:absolute; bottom:20px; left:130px; width:220px;" loading="lazy" src="https://i.imgur.com/hMna6G0.png" alt="homebrew mug" a="b and c" d="e"></p>`);
}); });
it('Renders an image with "=" in the url, and added attributes', function() { it('Renders an image with "=" in the url, and added attributes', function() {
const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png?auth=12345&height=1024) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e}`; const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png?auth=12345&height=1024) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e}`;
const rendered = Markdown.render(source).trimReturns(); const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png?auth=12345&height=1024); position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png?auth=12345&height=1024" alt="homebrew mug" a="b and c" d="e"></p>`); expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png?auth=12345&height=1024); position:absolute; bottom:20px; left:130px; width:220px;" loading="lazy" src="https://i.imgur.com/hMna6G0.png?auth=12345&height=1024" alt="homebrew mug" a="b and c" d="e"></p>`);
}); });
it('Renders an image and added attributes with "=" in the value, ', function() { it('Renders an image and added attributes with "=" in the value, ', function() {
const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e,otherUrl="url?auth=12345"}`; const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e,otherUrl="url?auth=12345"}`;
const rendered = Markdown.render(source).trimReturns(); const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png); position:absolute; bottom:20px; left:130px; width:220px;" src="https://i.imgur.com/hMna6G0.png" alt="homebrew mug" a="b and c" d="e" otherUrl="url?auth=12345"></p>`); expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p><img style="--HB_src:url(https://i.imgur.com/hMna6G0.png); position:absolute; bottom:20px; left:130px; width:220px;" loading="lazy" src="https://i.imgur.com/hMna6G0.png" alt="homebrew mug" a="b and c" d="e" otherUrl="url?auth=12345"></p>`);
}); });
}); });
+7 -7
View File
@@ -315,21 +315,21 @@ describe('Normal Links and Images', ()=>{
const source = `![alt text](url)`; const source = `![alt text](url)`;
const rendered = Markdown.render(source).trimReturns(); const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent` expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
<p><img src="url" alt="alt text" style="--HB_src:url(url);"></p>`.trimReturns()); <p><img loading="lazy" src="url" alt="alt text" style="--HB_src:url(url);"></p>`.trimReturns());
}); });
it('Renders normal images with a title', function() { it('Renders normal images with a title', function() {
const source = 'An image ![alt text](url "and title")!'; const source = 'An image ![alt text](url "and title")!';
const rendered = Markdown.render(source).trimReturns(); const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent` expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
<p>An image <img src="url" alt="alt text" style="--HB_src:url(url);" title="and title">!</p>`.trimReturns()); <p>An image <img loading="lazy" src="url" alt="alt text" style="--HB_src:url(url);" title="and title">!</p>`.trimReturns());
}); });
it('Applies curly injectors to images', function() { it('Applies curly injectors to images', function() {
const source = `![alt text](url){width:100px}`; const source = `![alt text](url){width:100px}`;
const rendered = Markdown.render(source).trimReturns(); const rendered = Markdown.render(source).trimReturns();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent` expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent`
<p><img style="--HB_src:url(url); width:100px;" src="url" alt="alt text"></p>`.trimReturns()); <p><img style="--HB_src:url(url); width:100px;" loading="lazy" src="url" alt="alt text"></p>`.trimReturns());
}); });
it('Renders normal links', function() { it('Renders normal links', function() {
@@ -438,25 +438,25 @@ describe('Regression Tests', ()=>{
it('Handle Extra spaces in image alt-text 1', function(){ it('Handle Extra spaces in image alt-text 1', function(){
const source='![ where is my image??](http://i.imgur.com/hMna6G0.png)'; const source='![ where is my image??](http://i.imgur.com/hMna6G0.png)';
const rendered = Markdown.render(source).trimReturns(); const rendered = Markdown.render(source).trimReturns();
expect(rendered).toBe('<p><img src=\"http://i.imgur.com/hMna6G0.png\" alt=\"where is my image??\" style=\"--HB_src:url(http://i.imgur.com/hMna6G0.png);\"></p>'); expect(rendered).toBe('<p><img loading="lazy" src=\"http://i.imgur.com/hMna6G0.png\" alt=\"where is my image??\" style=\"--HB_src:url(http://i.imgur.com/hMna6G0.png);\"></p>');
}); });
it('Handle Extra spaces in image alt-text 2', function(){ it('Handle Extra spaces in image alt-text 2', function(){
const source='![where is my image??](http://i.imgur.com/hMna6G0.png)'; const source='![where is my image??](http://i.imgur.com/hMna6G0.png)';
const rendered = Markdown.render(source).trimReturns(); const rendered = Markdown.render(source).trimReturns();
expect(rendered).toBe('<p><img src=\"http://i.imgur.com/hMna6G0.png\" alt=\"where is my image??\" style=\"--HB_src:url(http://i.imgur.com/hMna6G0.png);\"></p>'); expect(rendered).toBe('<p><img loading="lazy" src=\"http://i.imgur.com/hMna6G0.png\" alt=\"where is my image??\" style=\"--HB_src:url(http://i.imgur.com/hMna6G0.png);\"></p>');
}); });
it('Handle Extra spaces in image alt-text 3', function(){ it('Handle Extra spaces in image alt-text 3', function(){
const source='![where is my image?? ](http://i.imgur.com/hMna6G0.png)'; const source='![where is my image?? ](http://i.imgur.com/hMna6G0.png)';
const rendered = Markdown.render(source).trimReturns(); const rendered = Markdown.render(source).trimReturns();
expect(rendered).toBe('<p><img src=\"http://i.imgur.com/hMna6G0.png\" alt=\"where is my image??\" style=\"--HB_src:url(http://i.imgur.com/hMna6G0.png);\"></p>'); expect(rendered).toBe('<p><img loading="lazy" src=\"http://i.imgur.com/hMna6G0.png\" alt=\"where is my image??\" style=\"--HB_src:url(http://i.imgur.com/hMna6G0.png);\"></p>');
}); });
it('Handle Extra spaces in image alt-text 4', function(){ it('Handle Extra spaces in image alt-text 4', function(){
const source='![where is my image??](http://i.imgur.com/hMna6G0.png){height=20%,width=20%}'; const source='![where is my image??](http://i.imgur.com/hMna6G0.png){height=20%,width=20%}';
const rendered = Markdown.render(source).trimReturns(); const rendered = Markdown.render(source).trimReturns();
expect(rendered).toBe('<p><img style=\"--HB_src:url(http://i.imgur.com/hMna6G0.png);\" src=\"http://i.imgur.com/hMna6G0.png\" alt=\"where is my image??\" height=\"20%\" width=\"20%\"></p>'); expect(rendered).toBe('<p><img style=\"--HB_src:url(http://i.imgur.com/hMna6G0.png);\" loading="lazy" src=\"http://i.imgur.com/hMna6G0.png\" alt=\"where is my image??\" height=\"20%\" width=\"20%\"></p>');
}); });
}); });
+10 -10
View File
@@ -16,17 +16,17 @@ export default {
edge : (side = 'bottom')=>{ edge : (side = 'bottom')=>{
const styles = ()=>{ const styles = ()=>{
switch (side) { switch (side) {
case 'bottom': case 'bottom':
return `{width:100%,bottom:0%}` return `{width:100%,bottom:0%}`;
break; break;
case 'top': case 'top':
return `{width:100%,top:0%}` return `{width:100%,top:0%}`;
break; break;
default: default:
return `{height:100%}` return `{height:100%}`;
break; break;
} }
} };
const rotation = { const rotation = {
'bottom' : 0, 'bottom' : 0,
+1 -1
View File
@@ -93,7 +93,7 @@ export default EditorView.theme({
'.cm-strong' : { color: '#309dd2', fontWeight: 'bold' }, '.cm-strong' : { color: '#309dd2', fontWeight: 'bold' },
'.cm-em' : { fontStyle: 'italic' }, '.cm-em' : { fontStyle: 'italic' },
'.cm-keyword' : { color: '#fff' }, '.cm-keyword' : { color: '#fff' },
'.cm-atom, .cm-value, .cm-color' : { color: '#c1939a' }, '.cm-atom, .cm-value, .cm-color' : { color: '#c1939a' },
'.cm-number' : { color: '#2986cc' }, '.cm-number' : { color: '#2986cc' },
'.cm-def' : { color: '#2986cc' }, '.cm-def' : { color: '#2986cc' },
'.cm-list' : { color: '#3cbf30' }, '.cm-list' : { color: '#3cbf30' },