0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-05-07 18:48:39 +00:00

Merge branch 'master' of https://github.com/naturalcrit/homebrewery into add-cm-features

This commit is contained in:
Víctor Losada Hernández
2026-04-20 13:33:33 +02:00
20 changed files with 720 additions and 710 deletions
-4
View File
@@ -66,10 +66,6 @@ updates:
- dependency-name: "@babel/preset-react" - dependency-name: "@babel/preset-react"
versions: versions:
- 7.13.13 - 7.13.13
- dependency-name: codemirror
versions:
- 5.59.3
- 5.60.0
- dependency-name: classnames - dependency-name: classnames
versions: versions:
- 2.3.0 - 2.3.0
+29 -3
View File
@@ -85,14 +85,40 @@ pre {
} }
.page .df { .page .df {
font-size: 2em; font-size: 2em;
vertical-align: middle; vertical-align: middle;
} }
``` ```
## changelog ## changelog
For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery). For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery).
### Saturday 4/04/2026 - v3.21.0
{{taskList
##### Gazook89
* [x] Allow custom {{openSans **:fas_table_list: SNIPPETS**}} to be inserted mid-line
##### abquintic
* [x] Move example snippet images out of imgur (for folks without imgur access)
##### 5e-Cleric
* [x] Add auto-suggest to tag entry input box
* [x] Replace all example artwork with
* [x] Added tooltips to the {{openSans :fas_circle_info: **Properties**}} menu
* [x] Removed {{openSans **SYSTEMS**}} checkboxes from {{openSans :fas_circle_info: **Properties**}} menu; instead {{openSans **TAGS**}} should be used for this purpose
* [x] Replace all AI-generated art with public domain art
* [x] Major backend refactor to use Vite
##### A1Asriel (new contributor!)
* [x] Add fix for column breaks on Firefox
Fixes issues [#543](https://github.com/naturalcrit/homebrewery/issues/543), [#2473](https://github.com/naturalcrit/homebrewery/issues/2473), [#3712](https://github.com/naturalcrit/homebrewery/issues/3712)
##### G-Ambatte, abquintic, 5e-Cleric
* [x] Multiple other backend fixes and refactors
}}
### Friday 1/11/2026 - v3.20.1 ### Friday 1/11/2026 - v3.20.1
{{taskList {{taskList
@@ -2358,4 +2384,4 @@ Massive changelog incoming:
* Added `phb.standalone.css` plus a build system for creating it * Added `phb.standalone.css` plus a build system for creating it
* Added page numbers and footer text * Added page numbers and footer text
* Page accent now flips each page * Page accent now flips each page
@@ -13,7 +13,7 @@ const emojis = {
}; };
const emojiCompletionList = (context)=>{ const emojiCompletionList = (context)=>{
const word = context.matchBefore(/:[^\s:]*/); const word = context.matchBefore(/:[^\s:]+/);
if(!word) return null; if(!word) return null;
const line = context.state.doc.lineAt(context.pos); const line = context.state.doc.lineAt(context.pos);
@@ -49,8 +49,9 @@ const emojiCompletionList = (context)=>{
//Info is the tooltip //Info is the tooltip
return { return {
from : word.from + 1, from : word.from + 1,
options, options,
filter : false,
}; };
}; };
-48
View File
@@ -1,48 +0,0 @@
const autoCloseCurlyBraces = function(CodeMirror, cm, typingClosingBrace) {
const ranges = cm.listSelections(), replacements = [];
for (let i = 0; i < ranges.length; i++) {
if(!ranges[i].empty()) return CodeMirror.Pass;
const pos = ranges[i].head, line = cm.getLine(pos.line), tok = cm.getTokenAt(pos);
if(!typingClosingBrace && (tok.type == 'string' || tok.string.charAt(0) != '{' || tok.start != pos.ch - 1))
return CodeMirror.Pass;
else if(typingClosingBrace) {
let hasUnclosedBraces = false, index = -1;
do {
index = line.indexOf('{{', index + 1);
if(index !== -1 && line.indexOf('}}', index + 1) === -1) {
hasUnclosedBraces = true;
break;
}
} while (index !== -1);
if(!hasUnclosedBraces) return CodeMirror.Pass;
}
replacements[i] = typingClosingBrace ? {
text : '}}',
newPos : CodeMirror.Pos(pos.line, pos.ch + 2)
} : {
text : '{}}',
newPos : CodeMirror.Pos(pos.line, pos.ch + 1)
};
}
for (let i = ranges.length - 1; i >= 0; i--) {
const info = replacements[i];
cm.replaceRange(info.text, ranges[i].head, ranges[i].anchor, '+insert');
const sel = cm.listSelections().slice(0);
sel[i] = {
head : info.newPos,
anchor : info.newPos
};
cm.setSelections(sel);
}
};
export default {
autoCloseCurlyBraces : function(CodeMirror, codeMirror) {
const map = { name: 'autoCloseCurlyBraces' };
map[`'{'`] = function(cm) { return autoCloseCurlyBraces(CodeMirror, cm); };
map[`'}'`] = function(cm) { return autoCloseCurlyBraces(CodeMirror, cm, true); };
codeMirror?.addKeyMap(map);
}
};
+134 -69
View File
@@ -11,22 +11,21 @@ import {
scrollPastEnd, scrollPastEnd,
Decoration, Decoration,
ViewPlugin, ViewPlugin,
WidgetType,
drawSelection, drawSelection,
dropCursor, dropCursor,
} from '@codemirror/view'; } from '@codemirror/view';
import { EditorState, Compartment } 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, syntaxHighlighting } 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, cssLanguage } from '@codemirror/lang-css'; import { css } from '@codemirror/lang-css';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { html } from '@codemirror/lang-html'; import { html } from '@codemirror/lang-html';
import { autocompleteEmoji } from './autocompleteEmoji.js'; import { autocompleteEmoji } from './autocompleteEmoji.js';
import { searchKeymap, search } from '@codemirror/search'; import { searchKeymap, search } from '@codemirror/search';
import { closeBrackets } from '@codemirror/autocomplete'; import { closeBrackets } from '@codemirror/autocomplete';
const customClose = closeBrackets({ brackets: ['()', '[]', '{{}}'] }); const autoCloseBrackets = closeBrackets({ brackets: ['()', '[]', '{{}}'] });
import * as themesImport from '@uiw/codemirror-themes-all'; import * as themesImport from '@uiw/codemirror-themes-all';
import defaultCM5Theme from '@themes/codeMirror/default.js'; import defaultCM5Theme from '@themes/codeMirror/default.js';
@@ -36,11 +35,13 @@ const themes = { default: defaultCM5Theme, darkbrewery, ...themesImport };
const themeCompartment = new Compartment(); const themeCompartment = new Compartment();
const highlightCompartment = new Compartment(); const highlightCompartment = new Compartment();
import customKeymap from './customKeyMap.js'; import { generalKeymap, markdownKeymap } from './customKeyMaps.js';
import pageFoldExtension from './customFolding.js'; import foldOnPages from './customFolding.js';
import { customHighlightStyle, tokenizeCustomMarkdown, tokenizeCustomCSS } from './customHighlight.js'; import { customHighlightStyle, tokenizeCustomMarkdown, tokenizeCustomCSS } from './customHighlight.js';
import { legacyCustomHighlightStyle, legacyTokenizeCustomMarkdown } from './legacyCustomHighlight.js'; import { legacyCustomHighlightStyle, legacyTokenizeCustomMarkdown } from './legacyCustomHighlight.js';
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
const createHighlightPlugin = (renderer, tab)=>{ const createHighlightPlugin = (renderer, tab)=>{
//this function takes the custom tokens created in the tokenize function in customhighlight files //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 //takes the tokens defined by that function and assigns classes to them
@@ -84,7 +85,7 @@ const createHighlightPlugin = (renderer, tab)=>{
} }
if(tok.type === 'snippetLine' && tab === 'brewSnippets') { if(tok.type === 'snippetLine' && tab === 'brewSnippets') {
snippetCount++; snippetCount++;
decos.push(Decoration.line({ attributes: { 'data-page-number': pageCount } }).range(line.from)); decos.push(Decoration.line({ attributes: { 'data-page-number': snippetCount } }).range(line.from));
} }
} }
}); });
@@ -97,17 +98,45 @@ const createHighlightPlugin = (renderer, tab)=>{
); );
}; };
const setProgrammaticCursorLine = StateEffect.define();
const programmaticCursorLineField = StateField.define({
create() {
return Decoration.none;
},
update(decorations, transitionState) {
//deco is the decoratiions object
//tr is the transition state object, tr.effects is an array of stateEffects
//seems to be the easiest way of setting a class programatically only when called
for (const effects of transitionState.effects) {
if(effects.is(setProgrammaticCursorLine)) {
const pos = effects.value;
if(pos == null) return Decoration.none;
const line = transitionState.state.doc.lineAt(pos);
return Decoration.set([
Decoration.line({
class : 'sourceMoveFlash'
}).range(line.from)
]);
}
}
return decorations;
},
provide : (decorationSet)=>EditorView.decorations.from(decorationSet)
});
const CodeEditor = forwardRef( const CodeEditor = forwardRef(
( (
{ {
language = '',
tab = 'brewText',
view,
value = '', value = '',
onChange = ()=>{}, onChange = ()=>{},
onCursorChange = ()=>{}, onCursorChange = ()=>{},
onViewChange = ()=>{}, onViewChange = ()=>{},
language = '',
tab = 'brewText',
editorTheme = 'default', editorTheme = 'default',
view,
style, style,
renderer, renderer,
...props ...props
@@ -119,22 +148,44 @@ const CodeEditor = forwardRef(
const docsRef = useRef({}); const docsRef = useRef({});
const prevTabRef = useRef(tab); const prevTabRef = useRef(tab);
const pageMap = useRef([]);
const recomputePages = (doc)=>{
const pages = [0];
const text = doc.toString();
let offset = 0;
for (const line of text.split('\n')) {
if(PAGEBREAK_REGEX_V3.test(line)) {
pages.push(offset);
}
offset += line.length + 1;
}
pageMap.current = pages;
};
const findPageFromPos = (pos)=>{
const pages = pageMap.current;
let page = 1;
for (let i = 1; i < pages.length; i++) {
if(pos >= pages[i]) page = i + 1;
}
return page;
};
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) {
recomputePages(update.state.doc);
onChange(update.state.doc.toString()); onChange(update.state.doc.toString());
} }
if(update.selectionSet) { if(update.selectionSet) {
const pos = update.state.selection.main.head; const pos = update.state.selection.main.head;
const line = update.state.doc.lineAt(pos).number; const page = findPageFromPos(pos);
onCursorChange(page);
onCursorChange(line);
}
if(update.viewportChanged) {
const { from } = update.view.viewport;
const line = update.state.doc.lineAt(from).number;
onViewChange(line);
} }
}); });
@@ -145,34 +196,42 @@ const CodeEditor = forwardRef(
const customHighlightPlugin = createHighlightPlugin(renderer, tab); const customHighlightPlugin = createHighlightPlugin(renderer, tab);
const languageExtension = language === 'css' ? css() : [markdown({ base: markdownLanguage, codeLanguages: languages }), html({ autoCloseTags: true })]; const languageExtension = language === 'css' ? css() : [markdown({ base: markdownLanguage, codeLanguages: languages }), html({ autoCloseTags: true })];
const themeExtension = Array.isArray(themes[editorTheme]) ? themes[editorTheme] : themes[0]; const themeExtension = Array.isArray(themes[editorTheme]) ? themes[editorTheme] : themes[editorTheme] || themes['default'];
return [ return [
history(), //allows for undo and redo
setEventListeners,
EditorView.lineWrapping, EditorView.lineWrapping,
scrollPastEnd(), setEventListeners,
languageExtension, languageExtension,
autoCloseBrackets,
lineNumbers(), lineNumbers(),
pageFoldExtension, scrollPastEnd(),
search(),
history(), //allows for undo and redo
...(tab !== 'brewStyles' ? [autocompleteEmoji] : []),
//folding
foldOnPages,
foldGutter({ foldGutter({
openText : '▾', openText : '▾',
closedText : '▸' closedText : '▸'
}), }),
highlightActiveLine(), //highlights
highlightActiveLineGutter(),
highlightCompartment.of([customHighlightPlugin, highlightExtension]), highlightCompartment.of([customHighlightPlugin, highlightExtension]),
themeCompartment.of(themeExtension), themeCompartment.of(themeExtension),
...(tab !== 'brewStyles' ? [autocompleteEmoji] : []), highlightActiveLine(),
search(), highlightActiveLineGutter(),
//keyboard shortcut
keymap.of([...defaultKeymap, foldKeymap, ...searchKeymap]), keymap.of([...defaultKeymap, foldKeymap, ...searchKeymap]),
customKeymap, generalKeymap,
...(tab !== 'brewStyles' ? [markdownKeymap] : []),
//multiple cursors and selections
drawSelection(), drawSelection(),
EditorState.allowMultipleSelections.of(true), EditorState.allowMultipleSelections.of(true),
customClose,
dropCursor(), dropCursor(),
programmaticCursorLineField,
]; ];
}; };
@@ -184,14 +243,40 @@ const CodeEditor = forwardRef(
extensions : createExtensions({ onChange, language, editorTheme }), extensions : createExtensions({ onChange, language, editorTheme }),
}); });
recomputePages(state.doc);
viewRef.current = new EditorView({ viewRef.current = new EditorView({
state, state,
parent : editorRef.current, parent : editorRef.current,
}); });
const view = viewRef.current;
let ticking = false;
const handleScroll = ()=>{
if(ticking) return;
ticking = true;
requestAnimationFrame(()=>{
const top = view.scrollDOM.scrollTop;
const block = view.lineBlockAtHeight(top);
const page = findPageFromPos(block.from); // CHANGED
onViewChange(page);
ticking = false;
});
};
view.scrollDOM.addEventListener('scroll', handleScroll);
docsRef.current[tab] = state; docsRef.current[tab] = state;
return ()=>viewRef.current?.destroy(); return ()=>{
view.scrollDOM.removeEventListener('scroll', handleScroll);
viewRef.current?.destroy();
};
}, []); }, []);
useEffect(()=>{ useEffect(()=>{
@@ -215,6 +300,7 @@ const CodeEditor = forwardRef(
view.setState(nextState); view.setState(nextState);
prevTabRef.current = tab; prevTabRef.current = tab;
} }
view.focus();
}, [tab]); }, [tab]);
useEffect(()=>{ useEffect(()=>{
@@ -234,13 +320,15 @@ const CodeEditor = forwardRef(
const view = viewRef.current; const view = viewRef.current;
if(!view) return; if(!view) return;
const themeExtension = Array.isArray(themes[editorTheme]) ? themes[editorTheme] : []; const themeExtension = Array.isArray(themes[editorTheme])? themes[editorTheme]: themes[editorTheme] || themes['default'];
view.dispatch({ view.dispatch({
effects : themeCompartment.reconfigure(themeExtension), effects : themeCompartment.reconfigure(themeExtension),
}); });
}, [editorTheme]); }, [editorTheme]);
useEffect(()=>{ useEffect(()=>{
//rebuild syntax highlight when changing tab or renderer
const view = viewRef.current; const view = viewRef.current;
if(!view) return; if(!view) return;
@@ -256,59 +344,36 @@ const CodeEditor = forwardRef(
}, [renderer, tab]); }, [renderer, tab]);
useImperativeHandle(ref, ()=>({ useImperativeHandle(ref, ()=>({
getValue : ()=>viewRef.current.state.doc.toString(),
setValue : (text)=>{
const view = viewRef.current;
view.dispatch({
changes : { from: 0, to: view.state.doc.length, insert: text },
});
},
injectText : (text)=>{ injectText : (text)=>{
const view = viewRef.current; const view = viewRef.current;
const changes = view.state.selection.ranges.map((range)=>({
from : range.from,
to : range.to,
insert : text
}));
const newRanges = view.state.selection.ranges.map((range)=>({
anchor : range.from + text.length
}));
view.dispatch({
changes,
selection : { ranges: newRanges }
});
view.dispatch(
view.state.replaceSelection(text)
);
view.focus(); view.focus();
}, },
getCursorPosition : ()=>viewRef.current.state.selection.main.head, getCursorPosition : ()=>viewRef.current.state.selection.main.head,
getScrollTop : ()=>viewRef.current.scrollDOM.scrollTop, scrollToPage : (pageNumber, smooth = true)=>{
scrollToY : (y)=>{
viewRef.current.scrollDOM.scrollTo({ top: y });
},
getLineTop : (lineNumber)=>{
const view = viewRef.current; const view = viewRef.current;
if(!view) return 0; if(!view) return;
const line = view.state.doc.line(lineNumber); const pos = pageMap.current[pageNumber - 1] ?? 0;
return view.coordsAtPos(line.from)?.top ?? 0;
},
setCursorToLine : (lineNumber)=>{
const view = viewRef.current;
const line = view.state.doc.line(lineNumber);
view.dispatch({ view.dispatch({
selection : { anchor: line.from } selection : { anchor: pos },
effects : [setProgrammaticCursorLine.of(pos), EditorView.scrollIntoView(pos, { y: 'start' })],
}); });
view.focus(); view.focus();
setTimeout(()=>{
view.dispatch({
effects : setProgrammaticCursorLine.of(null)
});
}, 400);
}, },
undo : ()=>undo(viewRef.current), undo : ()=>undo(viewRef.current),
+1 -1
View File
@@ -43,7 +43,7 @@
} }
/* Flash animation for source moves */ /* Flash animation for source moves */
.sourceMoveFlash .cm-line { .cm-line.sourceMoveFlash {
animation-name: sourceMoveAnimation; animation-name: sourceMoveAnimation;
animation-duration: 0.4s; animation-duration: 0.4s;
} }
@@ -1,6 +1,6 @@
import { foldService, codeFolding } from '@codemirror/language'; import { foldService, codeFolding } from '@codemirror/language';
const pageFoldExtension = [ const foldOnPages = [
foldService.of((state, lineStart)=>{ //tells where to fold foldService.of((state, lineStart)=>{ //tells where to fold
const doc = state.doc; const doc = state.doc;
const matcher = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m; const matcher = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
@@ -43,4 +43,4 @@ const pageFoldExtension = [
}), }),
]; ];
export default pageFoldExtension; export default foldOnPages;
@@ -26,6 +26,8 @@ export function tokenizeCustomMarkdown(text) {
const tokens = []; const tokens = [];
const lines = text.split('\n'); const lines = text.split('\n');
//tokens without a `from` or `to` are interpreted by the custom plugin as line tokens
lines.forEach((lineText, lineNumber)=>{ lines.forEach((lineText, lineNumber)=>{
// --- Page / snippet lines --- // --- Page / snippet lines ---
if(/^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m.test(lineText)) tokens.push({ line: lineNumber, type: customTags.pageLine }); if(/^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m.test(lineText)) tokens.push({ line: lineNumber, type: customTags.pageLine });
@@ -79,8 +81,8 @@ export function tokenizeCustomMarkdown(text) {
} }
} }
const singleLineRegex = /^([^:\n]*\S)(\s*)(::)([^\n]*)$/dmy; // --- single line def list ---
const singleLineRegex = /^(?=.*[^:])(.+?)(\s*)(::)([^\n]*)$/dmy;
const match = singleLineRegex.exec(lineText); const match = singleLineRegex.exec(lineText);
if(match) { if(match) {
@@ -127,7 +129,7 @@ export function tokenizeCustomMarkdown(text) {
return; return;
} }
// multiline def list // --- multiline def list ---
if(!/^::/.test(lines[lineNumber]) && lineNumber + 1 < lines.length && /^::/.test(lines[lineNumber + 1])) { if(!/^::/.test(lines[lineNumber]) && lineNumber + 1 < lines.length && /^::/.test(lines[lineNumber + 1])) {
const startLine = lineNumber; const startLine = lineNumber;
const defs = []; const defs = [];
@@ -187,8 +189,8 @@ export function tokenizeCustomMarkdown(text) {
while ((match = injectionRegex.exec(lineText)) !== null) { while ((match = injectionRegex.exec(lineText)) !== null) {
tokens.push({ tokens.push({
line : lineNumber, line : lineNumber,
from : match.index +1, from : match.index,
to : match.index + match[1].length +1, to : match.index + match[1].length,
type : customTags.injection, type : customTags.injection,
}); });
} }
@@ -1,6 +1,6 @@
/* eslint max-lines: ["error", { "max": 300 }] */ /* eslint max-lines: ["error", { "max": 300 }] */
import { keymap } from '@codemirror/view'; import { keymap } from '@codemirror/view';
import { undo, redo } from '@codemirror/commands'; import { undo, redo, indentMore } from '@codemirror/commands';
import * as prettier from "prettier/standalone"; import * as prettier from "prettier/standalone";
import * as postcssPlugin from "prettier/plugins/postcss"; import * as postcssPlugin from "prettier/plugins/postcss";
@@ -21,27 +21,6 @@ async function formatCSS(view) {
}); });
} }
const insertTabAtCursor = (view)=>{
const { from } = view.state.selection.main;
view.dispatch({
changes : { from, insert: ' ' },
selection : { anchor: from + 1 }
});
return true;
};
const indentMore = (view)=>{
const { from, to } = view.state.selection.main;
const lines = [];
for (let l = view.state.doc.lineAt(from).number; l <= view.state.doc.lineAt(to).number; l++) {
const line = view.state.doc.line(l);
lines.push({ from: line.from, to: line.from, insert: ' ' }); // 2 spaces for tab
}
view.dispatch({ changes: lines });
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 = [];
@@ -227,8 +206,13 @@ const newPage = (view)=>{
return true; return true;
}; };
export default keymap.of([ export const generalKeymap = keymap.of([
{ key: 'Tab', run: insertTabAtCursor }, { key: 'Tab', run: indentMore },
{ key: 'Mod-z', run: undo }, //i think it may be unnecessary
{ key: 'Mod-Shift-z', run: redo },
]);
export const markdownKeymap = keymap.of([
//{ key: 'Shift-Tab', run: indentMore }, //{ key: 'Shift-Tab', run: indentMore },
{ key: 'Shift-Tab', run: indentLess }, { key: 'Shift-Tab', run: indentLess },
{ key: 'Mod-b', run: makeBold }, { key: 'Mod-b', run: makeBold },
@@ -253,7 +237,5 @@ export default keymap.of([
{ key: 'Shift-Mod-6', run: makeHeader(6) }, { key: 'Shift-Mod-6', run: makeHeader(6) },
{ key: 'Shift-Mod-Enter', run: newColumn }, { key: 'Shift-Mod-Enter', run: newColumn },
{ key: 'Mod-Enter', run: newPage }, { key: 'Mod-Enter', run: newPage },
{ key: 'Mod-z', run: undo }, //i think it may be unnecessary { key: 'Mod-Shift-f', run: formatCSS },
{ key: 'Mod-Shift-z', run: redo },
{ key: 'alt-Shift-f', run: formatCSS },
]); ]);
@@ -33,7 +33,7 @@ const INITIAL_CONTENT = dedent`
<link href='/homebrew/bundle.css' type="text/css" rel='stylesheet' /> <link href='/homebrew/bundle.css' type="text/css" rel='stylesheet' />
<link href="${brewRendererStylesUrl}" rel="stylesheet" /> <link href="${brewRendererStylesUrl}" rel="stylesheet" />
<link href="${headerNavStylesUrl}" rel="stylesheet" /> <link href="${headerNavStylesUrl}" rel="stylesheet" />
<base target=_blank> <base target="_top">
</head><body style='overflow: hidden'><div></div></body></html>`; </head><body style='overflow: hidden'><div></div></body></html>`;
@@ -135,6 +135,7 @@ const BrewRenderer = (props)=>{
const mainRef = useRef(null); const mainRef = useRef(null);
const pagesRef = useRef(null); const pagesRef = useRef(null);
const urlRef = useRef('');
if(props.renderer == 'legacy') { if(props.renderer == 'legacy') {
rawPages = props.text.split(PAGEBREAK_REGEX_LEGACY); rawPages = props.text.split(PAGEBREAK_REGEX_LEGACY);
@@ -272,6 +273,8 @@ const BrewRenderer = (props)=>{
const frameDidMount = ()=>{ //This triggers when iFrame finishes internal "componentDidMount" const frameDidMount = ()=>{ //This triggers when iFrame finishes internal "componentDidMount"
scrollToHash(window.location.hash); 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(); //Make sure page is renderable before showing
setState((prevState)=>({ setState((prevState)=>({
@@ -104,7 +104,7 @@ const HeaderNavItem = ({ link, text, depth, className })=>{
if(!link || !text) return; if(!link || !text) return;
return <li> return <li>
<a href={`#${link}`} target='_self' className={`depth-${depth} ${className ?? ''}`}> <a href={`#${link}`} className={`depth-${depth} ${className ?? ''}`}>
{trimString(text, depth)} {trimString(text, depth)}
</a> </a>
</li>; </li>;
+51 -70
View File
@@ -11,7 +11,21 @@ import MetadataEditor from './metadataEditor/metadataEditor.jsx';
const EDITOR_THEME_KEY = 'HB_editor_theme'; const EDITOR_THEME_KEY = 'HB_editor_theme';
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m; import * as themesImport from '@uiw/codemirror-themes-all';
import defaultCM5Theme from '@themes/codeMirror/default.js';
import darkbrewery from '@themes/codeMirror/darkbrewery.js';
const themes = { default: defaultCM5Theme, darkbrewery, ...themesImport };
const EditorThemes = Object.entries(themes)
.filter(([name, value])=>Array.isArray(value) &&
!name.endsWith('Init') &&
!name.endsWith('Style')
)
.map(([name])=>name);
//const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
//const SNIPPETBREAK_REGEX_V3 = /^\\snippet\ .*$/; //const SNIPPETBREAK_REGEX_V3 = /^\\snippet\ .*$/;
const DEFAULT_STYLE_TEXT = dedent` const DEFAULT_STYLE_TEXT = dedent`
/*=======--- Example CSS styling ---=======*/ /*=======--- Example CSS styling ---=======*/
@@ -29,6 +43,7 @@ const DEFAULT_SNIPPET_TEXT = dedent`
This snippet is accessible in the brew tab, and will be inherited if the brew is used as a theme. This snippet is accessible in the brew tab, and will be inherited if the brew is used as a theme.
`; `;
let isJumping = false; let isJumping = false;
let jumpSource = null;
const Editor = createReactClass({ const Editor = createReactClass({
displayName : 'Editor', displayName : 'Editor',
@@ -71,14 +86,15 @@ const Editor = createReactClass({
componentDidMount : function() { componentDidMount : function() {
document.getElementById('BrewRenderer').addEventListener('keydown', this.handleControlKeys); const brewRenderer = document.getElementById('BrewRenderer');
brewRenderer.onload = ()=>brewRenderer.contentDocument?.addEventListener('keydown', this.handleControlKeys);
document.addEventListener('keydown', this.handleControlKeys); document.addEventListener('keydown', this.handleControlKeys);
const editorTheme = window.localStorage.getItem(EDITOR_THEME_KEY); const editorTheme = window.localStorage.getItem(EDITOR_THEME_KEY);
if(editorTheme) { if(editorTheme && EditorThemes.includes(editorTheme)) {
this.setState({ this.setState({ editorTheme });
editorTheme : editorTheme } else {
}); this.setState({ editorTheme: 'default' });
} }
const snippetBar = document.querySelector('.editor > .snippetBar'); const snippetBar = document.querySelector('.editor > .snippetBar');
if(!snippetBar) return; if(!snippetBar) return;
@@ -126,22 +142,16 @@ const Editor = createReactClass({
} }
}, },
updateCurrentCursorPage : function(lineNumber) { updateCurrentCursorPage : function(pageNumber) {
const lines = this.props.brew.text.split('\n').slice(0, lineNumber); this.props.onCursorPageChange(pageNumber);
const pageRegex = this.props.brew.renderer == 'V3' ? PAGEBREAK_REGEX_V3 : /\\page/;
const currentPage = lines.reduce((count, line)=>count + (pageRegex.test(line) ? 1 : 0), 1);
this.props.onCursorPageChange(currentPage);
}, },
updateCurrentViewPage : function(topLine) { updateCurrentViewPage : function(pageNumber) {
const lines = this.props.brew.text.split('\n').slice(0, topLine); this.props.onViewPageChange(pageNumber);
const pageRegex = this.props.brew.renderer == 'V3' ? PAGEBREAK_REGEX_V3 : /\\page/;
const currentPage = lines.reduce((count, line)=>count + (pageRegex.test(line) ? 1 : 0), 1);
this.props.onViewPageChange(currentPage);
}, },
handleInject : function(injectText){ handleInject : function(injectText){
this.codeEditor.current?.injectText(injectText, false); this.codeEditor.current?.injectText(injectText);
}, },
handleViewChange : function(newView){ handleViewChange : function(newView){
@@ -150,12 +160,12 @@ const Editor = createReactClass({
this.setState({ this.setState({
view : newView view : newView
}, ()=>{ }, ()=>{
this.codeEditor.current?.codeMirror?.focus(); this.codeEditor.current?.focus();
}); });
}, },
brewJump : function(targetPage=this.props.currentEditorCursorPageNum, smooth=true){ brewJump : function(targetPage=this.props.currentEditorCursorPageNum, smooth=true){
if(!window || !this.isText() || isJumping) if(!window || !this.isText() || isJumping || jumpSource === 'source')
return; return;
// Get current brewRenderer scroll position and calculate target position // Get current brewRenderer scroll position and calculate target position
@@ -168,11 +178,13 @@ const Editor = createReactClass({
clearTimeout(scrollingTimeout); // Reset the timer every time a scroll event occurs clearTimeout(scrollingTimeout); // Reset the timer every time a scroll event occurs
scrollingTimeout = setTimeout(()=>{ scrollingTimeout = setTimeout(()=>{
isJumping = false; isJumping = false;
jumpSource = null;
brewRenderer.removeEventListener('scroll', checkIfScrollComplete); brewRenderer.removeEventListener('scroll', checkIfScrollComplete);
}, 150); // If 150 ms pass without a brewRenderer scroll event, assume scrolling is done }, 150); // If 150 ms pass without a brewRenderer scroll event, assume scrolling is done
}; };
isJumping = true; isJumping = true;
jumpSource = 'brew';
checkIfScrollComplete(); checkIfScrollComplete();
brewRenderer.addEventListener('scroll', checkIfScrollComplete); brewRenderer.addEventListener('scroll', checkIfScrollComplete);
@@ -196,46 +208,19 @@ const Editor = createReactClass({
}, },
sourceJump : function(targetPage=this.props.currentBrewRendererPageNum, smooth=true){ sourceJump : function(targetPage=this.props.currentBrewRendererPageNum, smooth=true){
if(!this.isText() || isJumping) if(!this.isText() || isJumping || jumpSource === 'brew')
return; return;
const textSplit = this.props.renderer == 'V3' ? PAGEBREAK_REGEX_V3 : /\\page/;
const textString = this.props.brew.text.split(textSplit).slice(0, targetPage-1).join(textSplit);
const targetLine = textString.match('\n') ? textString.split('\n').length : 1;
const editor = this.codeEditor.current; const editor = this.codeEditor.current;
if(!editor) return;
jumpSource = 'source';
let currentY = editor.getScrollTop(); editor.scrollToPage(targetPage);
const targetY = editor.getLineTop(targetLine); setTimeout(()=>{
jumpSource = null;
let scrollingTimeout; }, 200);
const checkIfScrollComplete = ()=>{ // Prevent interrupting a scroll in progress if user clicks multiple times
clearTimeout(scrollingTimeout); // Reset the timer every time a scroll event occurs
scrollingTimeout = setTimeout(()=>{
isJumping = false;
}, 150); // If 150 ms pass without a scroll event, assume scrolling is done
};
isJumping = true;
checkIfScrollComplete();
if(smooth) {
//Scroll 1/10 of the way every 10ms until 1px off.
const incrementalScroll = setInterval(()=>{
currentY += (targetY - currentY) / 10;
editor.scrollToY(currentY);
if(Math.abs(targetY - currentY) < 1) {
editor.scrollToY(targetY);
editor.setCursorToLine(targetLine);
clearInterval(incrementalScroll);
}
}, 10);
} else {
editor.scrollToY(targetY);
editor.setCursorToLine(targetLine);
}
}, },
//Called when there are changes to the editor's dimensions //Called when there are changes to the editor's dimensions
update : function(){}, update : function(){},
@@ -261,12 +246,11 @@ const Editor = createReactClass({
view={this.state.view} view={this.state.view}
value={this.props.brew.text} value={this.props.brew.text}
onChange={this.props.onBrewChange('text')} onChange={this.props.onBrewChange('text')}
onCursorChange={(line)=>this.updateCurrentCursorPage(line)} onCursorChange={(page)=>this.updateCurrentCursorPage(page)}
onViewChange={(line)=>this.updateCurrentViewPage(line)} onViewChange={(page)=>this.updateCurrentViewPage(page)}
editorTheme={this.state.editorTheme} editorTheme={this.state.editorTheme}
renderer={this.props.brew.renderer} renderer={this.props.brew.renderer}
rerenderParent={this.rerenderParent} style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }}/>
style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }} />
</>; </>;
} }
if(this.isStyle()){ if(this.isStyle()){
@@ -278,19 +262,16 @@ const Editor = createReactClass({
view={this.state.view} view={this.state.view}
value={this.props.brew.style ?? DEFAULT_STYLE_TEXT} value={this.props.brew.style ?? DEFAULT_STYLE_TEXT}
onChange={this.props.onBrewChange('style')} onChange={this.props.onBrewChange('style')}
enableFolding={true}
editorTheme={this.state.editorTheme} editorTheme={this.state.editorTheme}
renderer={this.props.brew.renderer} renderer={this.props.brew.renderer}
rerenderParent={this.rerenderParent} style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }}/>
style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }} />
</>; </>;
} }
if(this.isMeta()){ if(this.isMeta()){
return <> return <>
<CodeEditor key='codeEditor' <CodeEditor key='codeEditor'
view={this.state.view} view={this.state.view}
style={{ display: 'none' }} style={{ display: 'none' }}/>
rerenderParent={this.rerenderParent} />
<MetadataEditor <MetadataEditor
metadata={this.props.brew} metadata={this.props.brew}
themeBundle={this.props.themeBundle} themeBundle={this.props.themeBundle}
@@ -313,7 +294,7 @@ const Editor = createReactClass({
editorTheme={this.state.editorTheme} editorTheme={this.state.editorTheme}
renderer={this.props.brew.renderer} renderer={this.props.brew.renderer}
rerenderParent={this.rerenderParent} rerenderParent={this.rerenderParent}
style={{ height: `calc(100% - 25px)` }} /> style={{ height: `calc(100% - 25px)` }}/>
</>; </>;
} }
}, },
@@ -330,13 +311,13 @@ const Editor = createReactClass({
return this.codeEditor.current?.undo(); return this.codeEditor.current?.undo();
}, },
foldCode: function() { foldCode : function() {
return this.codeEditor.current?.foldAll(); return this.codeEditor.current?.foldAll();
}, },
unfoldCode: function() { unfoldCode : function() {
return this.codeEditor.current?.unfoldAll(); return this.codeEditor.current?.unfoldAll();
}, },
render : function(){ render : function(){
return ( return (
<div className='editor' ref={this.editor}> <div className='editor' ref={this.editor}>
@@ -23,7 +23,6 @@ const ThemeSnippets = {
V3_Blank : V3_Blank, V3_Blank : V3_Blank,
}; };
//import EditorThemes from '../../../../build/homebrew/codeMirror/editorThemes.json';
import * as themesImport from '@uiw/codemirror-themes-all'; import * as themesImport from '@uiw/codemirror-themes-all';
import defaultCM5Theme from '@themes/codeMirror/default.js'; import defaultCM5Theme from '@themes/codeMirror/default.js';
import darkbrewery from '@themes/codeMirror/darkbrewery.js'; import darkbrewery from '@themes/codeMirror/darkbrewery.js';
+4 -4
View File
@@ -25,18 +25,18 @@
let prefix = ''; let prefix = '';
if (url.includes('://homebrewery-stage.')) { if (url && url?.includes('://homebrewery-stage.')) {
prefix = `Stage `; prefix = `Stage `;
} else if (url.includes('://homebrewery-pr-')) { } else if (url?.includes('://homebrewery-pr-')) {
const match = url.match(/pr-(\d+)/); const match = url.match(/pr-(\d+)/);
if (match) prefix = `PR-${match[1]} `; if (match) prefix = `PR-${match[1]} `;
} else if (url.includes('://localhost')) { } else if (url?.includes('://localhost')) {
prefix = 'Local '; prefix = 'Local ';
} }
if (title) { if (title) {
document.title = `${prefix} - ${title} - The Homebrewery`; document.title = `${prefix} - ${title} - The Homebrewery`;
} else { } else if (prefix) {
document.title = `${prefix} - The Homebrewery`; document.title = `${prefix} - The Homebrewery`;
} }
+455 -453
View File
File diff suppressed because it is too large Load Diff
+5 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "homebrewery", "name": "homebrewery",
"description": "Create authentic looking D&D homebrews using only markdown", "description": "Create authentic looking D&D homebrews using only markdown",
"version": "3.20.1", "version": "3.21.0",
"type": "module", "type": "module",
"engines": { "engines": {
"npm": ">=10.8 <12", "npm": ">=10.8 <12",
@@ -110,7 +110,6 @@
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"cm6-theme-basic-light": "^0.2.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"core-js": "^3.49.0", "core-js": "^3.49.0",
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -126,7 +125,7 @@
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"jwt-simple": "^0.5.6", "jwt-simple": "^0.5.6",
"less": "^4.6.4", "less": "^4.6.4",
"lodash": "^4.17.21", "lodash": "^4.18.1",
"marked": "15.0.12", "marked": "15.0.12",
"marked-alignment-paragraphs": "^1.0.0", "marked-alignment-paragraphs": "^1.0.0",
"marked-definition-lists": "^1.0.1", "marked-definition-lists": "^1.0.1",
@@ -142,12 +141,12 @@
"mongoose": "^9.3.3", "mongoose": "^9.3.3",
"nanoid": "5.1.7", "nanoid": "5.1.7",
"nconf": "^0.13.0", "nconf": "^0.13.0",
"node": "^25.8.2", "node": "^25.9.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-frame-component": "^5.3.1", "react-frame-component": "^5.3.2",
"react-router": "^7.13.2", "react-router": "^7.14.0",
"sanitize-filename": "1.6.4", "sanitize-filename": "1.6.4",
"superagent": "^10.2.1" "superagent": "^10.2.1"
}, },
+2 -2
View File
@@ -392,7 +392,7 @@ const api = {
if(brewFromServer?.hash !== brewFromClient?.hash) { if(brewFromServer?.hash !== brewFromClient?.hash) {
console.log(`Hash mismatch on brew ${brewFromClient.editId}`); console.log(`Hash mismatch on brew ${brewFromClient.editId}`);
//debugTextMismatch(brewFromClient.text, brewFromServer.text, `edit/${brewFromClient.editId}`); debugTextMismatch(brewFromClient.text, brewFromServer.text, `edit/${brewFromClient.editId}`);
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
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.` }));
} }
@@ -405,7 +405,7 @@ const api = {
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,
brewId : brewFromClient.editId || 'unknown', brewId : brewFromClient.editId || 'unknown',
+3 -3
View File
@@ -70,9 +70,9 @@ renderer.link = function (token) {
if(title) { if(title) {
out += ` title="${escape(title)}"`; out += ` title="${escape(title)}"`;
} }
if(self) { // if(self) {
out += ' target="_self"'; // out += ' target="_self"';
} // }
out += `>${text}</a>`; out += `>${text}</a>`;
return out; return out;
}; };
+3 -3
View File
@@ -34,9 +34,9 @@ renderer.link = function (href, title, text) {
if(title) { if(title) {
out += ` title="${title}"`; out += ` title="${title}"`;
} }
if(self) { // if(self) {
out += ' target="_self"'; // out += ' target="_self"';
} // }
out += `>${text}</a>`; out += `>${text}</a>`;
return out; return out;
}; };
+7 -5
View File
@@ -8,8 +8,10 @@ test('Processes the markdown within an HTML block if its just a class wrapper',
expect(rendered).toBe('<div> <p><em>Bold text</em></p>\n </div>'); expect(rendered).toBe('<div> <p><em>Bold text</em></p>\n </div>');
}); });
test('Check markdown is using the custom renderer; specifically that it adds target=_self attribute to internal links in HTML blocks', function() { // TEST REMOVED AS IT IS NO LONGER REQUIRED
const source = '<div>[Has _self Attribute?](#p1)</div>'; //
const rendered = Markdown.render(source); // test('Check markdown is using the custom renderer; specifically that it adds target=_self attribute to internal links in HTML blocks', function() {
expect(rendered).toBe('<div> <p><a href="#p1" target="_self">Has _self Attribute?</a></p>\n </div>'); // const source = '<div>[Has _self Attribute?](#p1)</div>';
}); // const rendered = Markdown.render(source);
// expect(rendered).toBe('<div> <p><a href="#p1" target="_self">Has _self Attribute?</a></p>\n </div>');
// });