diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 2204679a6..8915c39dd 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -66,10 +66,6 @@ updates:
- dependency-name: "@babel/preset-react"
versions:
- 7.13.13
- - dependency-name: codemirror
- versions:
- - 5.59.3
- - 5.60.0
- dependency-name: classnames
versions:
- 2.3.0
diff --git a/changelog.md b/changelog.md
index 1e1ac70e2..22af73b80 100644
--- a/changelog.md
+++ b/changelog.md
@@ -85,14 +85,40 @@ pre {
}
.page .df {
- font-size: 2em;
- vertical-align: middle;
+ font-size: 2em;
+ vertical-align: middle;
}
```
## changelog
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
{{taskList
@@ -2358,4 +2384,4 @@ Massive changelog incoming:
* Added `phb.standalone.css` plus a build system for creating it
* Added page numbers and footer text
-* Page accent now flips each page
+* Page accent now flips each page
\ No newline at end of file
diff --git a/client/components/codeEditor/autocompleteEmoji.js b/client/components/codeEditor/autocompleteEmoji.js
index 7268dcc73..309668884 100644
--- a/client/components/codeEditor/autocompleteEmoji.js
+++ b/client/components/codeEditor/autocompleteEmoji.js
@@ -13,7 +13,7 @@ const emojis = {
};
const emojiCompletionList = (context)=>{
- const word = context.matchBefore(/:[^\s:]*/);
+ const word = context.matchBefore(/:[^\s:]+/);
if(!word) return null;
const line = context.state.doc.lineAt(context.pos);
@@ -49,8 +49,9 @@ const emojiCompletionList = (context)=>{
//Info is the tooltip
return {
- from : word.from + 1,
+ from : word.from + 1,
options,
+ filter : false,
};
};
diff --git a/client/components/codeEditor/close-tag.js b/client/components/codeEditor/close-tag.js
deleted file mode 100644
index 84cf62169..000000000
--- a/client/components/codeEditor/close-tag.js
+++ /dev/null
@@ -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);
- }
-};
\ No newline at end of file
diff --git a/client/components/codeEditor/codeEditor.jsx b/client/components/codeEditor/codeEditor.jsx
index cf11999a9..708a65cb3 100644
--- a/client/components/codeEditor/codeEditor.jsx
+++ b/client/components/codeEditor/codeEditor.jsx
@@ -11,22 +11,21 @@ import {
scrollPastEnd,
Decoration,
ViewPlugin,
- WidgetType,
drawSelection,
dropCursor,
} 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 { defaultKeymap, history, undo, redo, undoDepth, redoDepth } from '@codemirror/commands';
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 { html } from '@codemirror/lang-html';
import { autocompleteEmoji } from './autocompleteEmoji.js';
import { searchKeymap, search } from '@codemirror/search';
import { closeBrackets } from '@codemirror/autocomplete';
-const customClose = closeBrackets({ brackets: ['()', '[]', '{{}}'] });
+const autoCloseBrackets = closeBrackets({ brackets: ['()', '[]', '{{}}'] });
import * as themesImport from '@uiw/codemirror-themes-all';
import defaultCM5Theme from '@themes/codeMirror/default.js';
@@ -36,11 +35,13 @@ const themes = { default: defaultCM5Theme, darkbrewery, ...themesImport };
const themeCompartment = new Compartment();
const highlightCompartment = new Compartment();
-import customKeymap from './customKeyMap.js';
-import pageFoldExtension from './customFolding.js';
+import { generalKeymap, markdownKeymap } from './customKeyMaps.js';
+import foldOnPages from './customFolding.js';
import { customHighlightStyle, tokenizeCustomMarkdown, tokenizeCustomCSS } from './customHighlight.js';
import { legacyCustomHighlightStyle, legacyTokenizeCustomMarkdown } from './legacyCustomHighlight.js';
+const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
+
const createHighlightPlugin = (renderer, tab)=>{
//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
@@ -84,7 +85,7 @@ const createHighlightPlugin = (renderer, tab)=>{
}
if(tok.type === 'snippetLine' && tab === 'brewSnippets') {
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(
(
{
+ language = '',
+ tab = 'brewText',
+ view,
value = '',
onChange = ()=>{},
onCursorChange = ()=>{},
onViewChange = ()=>{},
- language = '',
- tab = 'brewText',
editorTheme = 'default',
- view,
style,
renderer,
...props
@@ -119,22 +148,44 @@ const CodeEditor = forwardRef(
const docsRef = useRef({});
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 setEventListeners = EditorView.updateListener.of((update)=>{
if(update.docChanged) {
+ recomputePages(update.state.doc);
onChange(update.state.doc.toString());
}
if(update.selectionSet) {
const pos = update.state.selection.main.head;
- const line = update.state.doc.lineAt(pos).number;
-
- onCursorChange(line);
- }
- if(update.viewportChanged) {
- const { from } = update.view.viewport;
- const line = update.state.doc.lineAt(from).number;
-
- onViewChange(line);
+ const page = findPageFromPos(pos);
+ onCursorChange(page);
}
});
@@ -145,34 +196,42 @@ const CodeEditor = forwardRef(
const customHighlightPlugin = createHighlightPlugin(renderer, tab);
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 [
- history(), //allows for undo and redo
- setEventListeners,
EditorView.lineWrapping,
- scrollPastEnd(),
+ setEventListeners,
languageExtension,
+ autoCloseBrackets,
lineNumbers(),
- pageFoldExtension,
+ scrollPastEnd(),
+ search(),
+ history(), //allows for undo and redo
+ ...(tab !== 'brewStyles' ? [autocompleteEmoji] : []),
+ //folding
+ foldOnPages,
foldGutter({
openText : '▾',
closedText : '▸'
}),
- highlightActiveLine(),
- highlightActiveLineGutter(),
+ //highlights
highlightCompartment.of([customHighlightPlugin, highlightExtension]),
themeCompartment.of(themeExtension),
- ...(tab !== 'brewStyles' ? [autocompleteEmoji] : []),
- search(),
+ highlightActiveLine(),
+ highlightActiveLineGutter(),
+
+ //keyboard shortcut
keymap.of([...defaultKeymap, foldKeymap, ...searchKeymap]),
- customKeymap,
+ generalKeymap,
+ ...(tab !== 'brewStyles' ? [markdownKeymap] : []),
+
+ //multiple cursors and selections
drawSelection(),
EditorState.allowMultipleSelections.of(true),
- customClose,
dropCursor(),
+ programmaticCursorLineField,
];
};
@@ -184,14 +243,40 @@ const CodeEditor = forwardRef(
extensions : createExtensions({ onChange, language, editorTheme }),
});
+ recomputePages(state.doc);
+
viewRef.current = new EditorView({
state,
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;
- return ()=>viewRef.current?.destroy();
+ return ()=>{
+ view.scrollDOM.removeEventListener('scroll', handleScroll);
+ viewRef.current?.destroy();
+ };
}, []);
useEffect(()=>{
@@ -215,6 +300,7 @@ const CodeEditor = forwardRef(
view.setState(nextState);
prevTabRef.current = tab;
}
+ view.focus();
}, [tab]);
useEffect(()=>{
@@ -234,13 +320,15 @@ const CodeEditor = forwardRef(
const view = viewRef.current;
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({
effects : themeCompartment.reconfigure(themeExtension),
});
}, [editorTheme]);
+
useEffect(()=>{
+ //rebuild syntax highlight when changing tab or renderer
const view = viewRef.current;
if(!view) return;
@@ -256,59 +344,36 @@ const CodeEditor = forwardRef(
}, [renderer, tab]);
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)=>{
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();
},
getCursorPosition : ()=>viewRef.current.state.selection.main.head,
- getScrollTop : ()=>viewRef.current.scrollDOM.scrollTop,
-
- scrollToY : (y)=>{
- viewRef.current.scrollDOM.scrollTo({ top: y });
- },
-
- getLineTop : (lineNumber)=>{
+ scrollToPage : (pageNumber, smooth = true)=>{
const view = viewRef.current;
- if(!view) return 0;
+ if(!view) return;
- const line = view.state.doc.line(lineNumber);
- return view.coordsAtPos(line.from)?.top ?? 0;
- },
-
- setCursorToLine : (lineNumber)=>{
- const view = viewRef.current;
- const line = view.state.doc.line(lineNumber);
+ const pos = pageMap.current[pageNumber - 1] ?? 0;
view.dispatch({
- selection : { anchor: line.from }
+ selection : { anchor: pos },
+ effects : [setProgrammaticCursorLine.of(pos), EditorView.scrollIntoView(pos, { y: 'start' })],
});
view.focus();
+
+ setTimeout(()=>{
+ view.dispatch({
+ effects : setProgrammaticCursorLine.of(null)
+ });
+ }, 400);
},
undo : ()=>undo(viewRef.current),
diff --git a/client/components/codeEditor/codeEditor.less b/client/components/codeEditor/codeEditor.less
index 5fba31f08..3f3869756 100644
--- a/client/components/codeEditor/codeEditor.less
+++ b/client/components/codeEditor/codeEditor.less
@@ -43,7 +43,7 @@
}
/* Flash animation for source moves */
- .sourceMoveFlash .cm-line {
+ .cm-line.sourceMoveFlash {
animation-name: sourceMoveAnimation;
animation-duration: 0.4s;
}
diff --git a/client/components/codeEditor/customFolding.js b/client/components/codeEditor/customFolding.js
index ea9087c03..49cb449e7 100644
--- a/client/components/codeEditor/customFolding.js
+++ b/client/components/codeEditor/customFolding.js
@@ -1,6 +1,6 @@
import { foldService, codeFolding } from '@codemirror/language';
-const pageFoldExtension = [
+const foldOnPages = [
foldService.of((state, lineStart)=>{ //tells where to fold
const doc = state.doc;
const matcher = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
@@ -43,4 +43,4 @@ const pageFoldExtension = [
}),
];
-export default pageFoldExtension;
\ No newline at end of file
+export default foldOnPages;
\ No newline at end of file
diff --git a/client/components/codeEditor/customHighlight.js b/client/components/codeEditor/customHighlight.js
index 596434a9c..3fa164757 100644
--- a/client/components/codeEditor/customHighlight.js
+++ b/client/components/codeEditor/customHighlight.js
@@ -26,6 +26,8 @@ export function tokenizeCustomMarkdown(text) {
const tokens = [];
const lines = text.split('\n');
+ //tokens without a `from` or `to` are interpreted by the custom plugin as line tokens
+
lines.forEach((lineText, lineNumber)=>{
// --- Page / snippet lines ---
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);
if(match) {
@@ -127,7 +129,7 @@ export function tokenizeCustomMarkdown(text) {
return;
}
- // multiline def list
+ // --- multiline def list ---
if(!/^::/.test(lines[lineNumber]) && lineNumber + 1 < lines.length && /^::/.test(lines[lineNumber + 1])) {
const startLine = lineNumber;
const defs = [];
@@ -187,8 +189,8 @@ export function tokenizeCustomMarkdown(text) {
while ((match = injectionRegex.exec(lineText)) !== null) {
tokens.push({
line : lineNumber,
- from : match.index +1,
- to : match.index + match[1].length +1,
+ from : match.index,
+ to : match.index + match[1].length,
type : customTags.injection,
});
}
diff --git a/client/components/codeEditor/customKeyMap.js b/client/components/codeEditor/customKeyMaps.js
similarity index 90%
rename from client/components/codeEditor/customKeyMap.js
rename to client/components/codeEditor/customKeyMaps.js
index 95670c0a4..c6b412d90 100644
--- a/client/components/codeEditor/customKeyMap.js
+++ b/client/components/codeEditor/customKeyMaps.js
@@ -1,6 +1,6 @@
/* eslint max-lines: ["error", { "max": 300 }] */
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 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 { from, to } = view.state.selection.main;
const lines = [];
@@ -227,8 +206,13 @@ const newPage = (view)=>{
return true;
};
-export default keymap.of([
- { key: 'Tab', run: insertTabAtCursor },
+export const generalKeymap = keymap.of([
+ { 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: indentLess },
{ key: 'Mod-b', run: makeBold },
@@ -253,7 +237,5 @@ export default keymap.of([
{ key: 'Shift-Mod-6', run: makeHeader(6) },
{ key: 'Shift-Mod-Enter', run: newColumn },
{ key: 'Mod-Enter', run: newPage },
- { key: 'Mod-z', run: undo }, //i think it may be unnecessary
- { key: 'Mod-Shift-z', run: redo },
- { key: 'alt-Shift-f', run: formatCSS },
+ { key: 'Mod-Shift-f', run: formatCSS },
]);
diff --git a/client/homebrew/brewRenderer/brewRenderer.jsx b/client/homebrew/brewRenderer/brewRenderer.jsx
index 8e74473b3..202c1a375 100644
--- a/client/homebrew/brewRenderer/brewRenderer.jsx
+++ b/client/homebrew/brewRenderer/brewRenderer.jsx
@@ -33,7 +33,7 @@ const INITIAL_CONTENT = dedent`
-