0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-08-06 04:37:37 +00:00

Merge branch 'master' of https://github.com/naturalcrit/homebrewery into add-remove-author-if-owner

This commit is contained in:
Víctor Losada Hernández
2026-07-26 00:22:32 +02:00
109 changed files with 8323 additions and 6738 deletions
+3 -3
View File
@@ -10,7 +10,7 @@ orbs:
jobs: jobs:
build: build:
docker: docker:
- image: cimg/node:20.18.0 - image: cimg/node:26.4
- image: mongo:4.4 - image: mongo:4.4
working_directory: ~/homebrewery working_directory: ~/homebrewery
@@ -27,7 +27,7 @@ jobs:
# fallback to using the latest cache if no exact match is found # fallback to using the latest cache if no exact match is found
- v1-dependencies- - v1-dependencies-
- run: sudo npm install -g npm@10.8.2 - run: sudo npm install -g npm@11.17.0
- node/install-packages: - node/install-packages:
app-dir: ~/homebrewery app-dir: ~/homebrewery
cache-path: node_modules cache-path: node_modules
@@ -45,7 +45,7 @@ jobs:
test: test:
docker: docker:
- image: cimg/node:20.17.0 - image: cimg/node:26.4
working_directory: ~/homebrewery working_directory: ~/homebrewery
parallelism: 1 parallelism: 1
-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
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine FROM node:26.4.0-alpine
RUN apk --no-cache add git RUN apk --no-cache add git
ENV NODE_ENV=docker ENV NODE_ENV=docker
+38
View File
@@ -93,6 +93,44 @@ pre {
## 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/20/2026 - v3.22.0
{{taskList
##### 5e-Cleric
* [x] Major update to editor framework (Codemirror 6)
Fixes issues [#3511](https://github.com/naturalcrit/homebrewery/issues/3511), [#4590](https://github.com/naturalcrit/homebrewery/issues/4590), [#4563](https://github.com/naturalcrit/homebrewery/issues/4653), [#4655](https://github.com/naturalcrit/homebrewery/issues/4655)
* [x] Fix to Admin page tab names
##### G-Ambatte
* [x] Fix white page crash on certain browsers
}}
### 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
+4
View File
@@ -111,6 +111,10 @@ body {
vertical-align : middle; vertical-align : middle;
text-align : center; text-align : center;
border-right : 1px solid; border-right : 1px solid;
max-width:50ch;
overflow:hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:last-child { border-right : none; } &:last-child { border-right : none; }
} }
@@ -1,71 +1,176 @@
import React from 'react'; import React, { useState } from 'react';
import createReactClass from 'create-react-class';
import request from 'superagent'; import request from 'superagent';
import Moment from 'moment';
const BrewCleanup = createReactClass({ const BrewCleanup = ({})=>{
displayName : 'BrewCleanup', const [junkBrewCollection, setJunkBrewCollection] = useState([]);
getDefaultProps(){ const [lostBrewCollection, setLostBrewCollection] = useState([]);
return {}; const [pendingJunk, setPendingJunk] = useState(false);
}, const [pendingLost, setPendingLost] = useState(false);
getInitialState() { const [error, setError] = useState(null);
return {
count : 0,
pending : false, const find = async (type)=>{
primed : false,
err : null
};
},
prime(){
this.setState({ pending: true });
request.get('/admin/cleanup')
.then((res)=>this.setState({ count: res.body.count, primed: true }))
.catch((err)=>this.setState({ error: err }))
.finally(()=>this.setState({ pending: false }));
},
cleanup(){
this.setState({ pending: true });
request.post('/admin/cleanup') if(type === 'junk') try {
.then((res)=>this.setState({ count: res.body.count })) setPendingJunk(true);
.catch((err)=>this.setState({ error: err })) const res = await request.get('/admin/cleanupJunk');
.finally(()=>this.setState({ pending: false, primed: false }));
},
renderPrimed(){
if(!this.state.primed) return;
if(!this.state.count){ setJunkBrewCollection(res.body.brewCollection);
return <div className='result noBrews'>No Matching Brews found.</div>; } catch (err) {
setError(err);
} finally {
setPendingJunk(false);
} }
if(type === 'lost') try {
setPendingLost(true);
const res = await request.get('/admin/cleanupLost');
setLostBrewCollection(res.body.brewCollection);
} catch (err) {
setError(err);
} finally {
setPendingLost(false);
}
};
const cleanup = async (type)=>{
if(type === 'junk') try {
setPendingJunk(true);
console.log('deleting junk');
const res = await request.post('/admin/cleanupJunk');
} catch (err) {
setError(err);
} finally {
setPendingJunk(false);
setJunkBrewCollection([]);
}
if(type === 'lost') try {
setPendingLost(true);
const res = await request.post('/admin/cleanupLost');
} catch (err) {
setError(err);
} finally {
setPendingLost(false);
setLostBrewCollection([]);
}
};
const renderBrewList = (type)=>{
const brewList = type === 'lost' ? lostBrewCollection : junkBrewCollection;
if(!brewList || brewList.length === 0) {
return <>
<h3>{`Results - No brews found` }</h3>
<table className='resultsTable'>
<thead>
<tr>
<th>Title</th>
<th>Last Update</th>
<th>last viewed</th>
<th>Storage</th>
</tr>
</thead>
<tbody>
<tr>
<td colSpan={4}><strong>"No brews found"</strong></td>
</tr>
</tbody>
</table>
</>;
}
console.log(type);
console.log(brewList);
return <>
<h3>{`Results - ${brewList.length} brews` }</h3>
<table className='resultsTable'>
<thead>
<tr>
<th>Title</th>
<th>Last Update</th>
<th>last viewed</th>
<th>Storage</th>
</tr>
</thead>
<tbody>
{brewList
.sort((a, b)=>{ // Sort brews from most recently updated
if(a.lastViewed > b.lastViewed) return -1;
return 1;
})
.map((brew, idx)=>{
return <tr key={idx}>
<td><strong>{brew.title || 'No Title'}</strong></td>
<td style={{ width: '200px' }}>{Moment(brew.updatedAt).fromNow()}</td>
<td>{brew.lastViewed ? Moment(brew.lastViewed).fromNow() : 'No last viewed date'}</td>
<td>{brew.googleId ? 'Google' : 'Homebrewery'}</td>
</tr>
})}
</tbody>
</table>
</>;
};
const renderFound = (type)=>{
const deleteButton = !(type === 'junk' && junkBrewCollection.length === 0 || type === 'lost' && lostBrewCollection.length === 0);
return <div className='result'> return <div className='result'>
<button onClick={this.cleanup} className='remove'> {deleteButton && <button onClick={()=>cleanup(type)} className='remove'>
{this.state.pending {pendingLost && type === "lost" || pendingJunk && type === "junk"
? <i className='fas fa-spin fa-spinner' /> ? <i className='fas fa-spin fa-spinner' />
: <span><i className='fas fa-times' /> Remove</span> : <span><i className='fas fa-times' /> Remove</span>
} }
</button> </button>
<span>Found {this.state.count} Brews that could be removed. </span> }
{renderBrewList(type)}
</div>; </div>;
}, };
render(){ const renderJunkBrewCleanup = ()=>{
return <div className='brewUtil brewCleanup'> return <div className='junk'>
<h2> Brew Cleanup </h2> <h3> Junk brews</h3>
<p>Removes very short brews to tidy up the database</p> <p>Queries unauthored brews that have not been viewed or <br/>updated in 30 days and are shorter than 140 bytes (up to 300)</p>
<button onClick={this.prime} className='query'> <button onClick={()=>find('junk')} className='query'>
{this.state.pending {pendingJunk
? <i className='fas fa-spin fa-spinner' /> ? <i className='fas fa-spin fa-spinner' />
: 'Query Brews' : 'Query Brews'
} }
</button> </button>
{this.renderPrimed()} {renderFound('junk')}
{this.state.error {error && <div className='error noBrews'>{error.toString()}</div>}
&& <div className='error noBrews'>{this.state.error.toString()}</div>
}
</div>; </div>;
};
const renderLostBrewCleanup = ()=>{
return <div className='lost'>
<h3> Lost brews</h3>
<p>Queries unauthored brews that have not been <br/>updated or viewed for 2 years (up to 500)</p>
<button onClick={()=>find('lost')} className='query'>
{pendingLost
? <i className='fas fa-spin fa-spinner' />
: 'Query Brews'
} }
}); </button>
{renderFound('lost')}
{error && <div className='error noBrews'>{error.toString()}</div>}
</div>;
};
return <div className='brewUtil brewCleanup'>
<h2> Brew Cleanup </h2>
{renderJunkBrewCleanup()}
<br/>
<br/>
{renderLostBrewCleanup()}
</div>;
};
export default BrewCleanup; export default BrewCleanup;
+5 -3
View File
@@ -1,6 +1,8 @@
import { createRoot } from "react-dom/client"; import { createRoot } from 'react-dom/client';
import Admin from "./admin.jsx"; import Admin from './admin.jsx';
import { bootstrapAnchorPositioningPolyfill } from '@components/anchorPositioningPolyfill.js';
const props = window.__INITIAL_PROPS__ || {}; const props = window.__INITIAL_PROPS__ || {};
createRoot(document.getElementById("reactRoot")).render(<Admin {...props} />); createRoot(document.getElementById('reactRoot')).render(<Admin {...props} />);
bootstrapAnchorPositioningPolyfill();
+11 -4
View File
@@ -71,10 +71,14 @@ const Anchored = ({ children })=>{
// forward ref for AnchoredTrigger // forward ref for AnchoredTrigger
const AnchoredTrigger = forwardRef(({ toggleVisibility, visible, children, className, ...props }, ref)=>( const AnchoredTrigger = forwardRef(({ toggleVisibility, visible, children, className, ...props }, ref)=>(
<button <button
ref={ref} ref={(el)=>{
// setAttribute bypasses React's style sanitization so the anchor polyfill can read it
el?.setAttribute('style', `anchor-name: --${props.id}`);
if(typeof ref === 'function') ref(el);
else if(ref) ref.current = el;
}}
className={`anchored-trigger${visible ? ' active' : ''} ${className}`} className={`anchored-trigger${visible ? ' active' : ''} ${className}`}
onClick={toggleVisibility} onClick={toggleVisibility}
style={{ anchorName: `--${props.id}` }} // setting anchor properties here allows greater recyclability.
{...props} {...props}
> >
{children} {children}
@@ -84,9 +88,12 @@ const AnchoredTrigger = forwardRef(({ toggleVisibility, visible, children, class
// forward ref for AnchoredBox // forward ref for AnchoredBox
const AnchoredBox = forwardRef(({ visible, children, className, anchorId, ...props }, ref)=>( const AnchoredBox = forwardRef(({ visible, children, className, anchorId, ...props }, ref)=>(
<div <div
ref={ref} ref={(el)=>{
el?.setAttribute('style', `position-anchor: --${anchorId}`);
if(typeof ref === 'function') ref(el);
else if(ref) ref.current = el;
}}
className={`anchored-box${visible ? ' active' : ''} ${className}`} className={`anchored-box${visible ? ' active' : ''} ${className}`}
style={{ positionAnchor: `--${anchorId}` }} // setting anchor properties here allows greater recyclability.
{...props} {...props}
> >
{children} {children}
-2
View File
@@ -4,8 +4,6 @@
position : absolute; position : absolute;
visibility : hidden; visibility : hidden;
justify-self : anchor-center; justify-self : anchor-center;
@supports (inset-block-start: anchor(bottom)) {
inset-block-start : anchor(bottom); inset-block-start : anchor(bottom);
}
&.active { visibility : visible; } &.active { visibility : visible; }
} }
@@ -0,0 +1,27 @@
/*
This file basically checks support for Anchor Positioning API in the browser,
and then loads the Oddbird polyfill if support is lacking.
*/
let polyfillPromise;
// look for `anchorName` in the computed styles
const supportsAnchorPositioning = ()=>'anchorName' in document.documentElement.style;
export const bootstrapAnchorPositioningPolyfill = ()=>{
if(supportsAnchorPositioning()) return Promise.resolve(false);
if(polyfillPromise) return polyfillPromise;
polyfillPromise = (async ()=>{
try {
const { default: polyfill } = await import('@oddbird/css-anchor-positioning/fn');
await polyfill();
return true;
} catch (error){
polyfillPromise = undefined;
throw error;
}
})();
return polyfillPromise;
};
@@ -1,84 +0,0 @@
import diceFont from '@themes/fonts/iconFonts/diceFont.js';
import elderberryInn from '@themes/fonts/iconFonts/elderberryInn.js';
import fontAwesome from '@themes/fonts/iconFonts/fontAwesome.js';
import gameIcons from '@themes/fonts/iconFonts/gameIcons.js';
const emojis = {
...diceFont,
...elderberryInn,
...fontAwesome,
...gameIcons
};
const showAutocompleteEmoji = function(CodeMirror, editor) {
CodeMirror.commands.autocomplete = function(editor) {
editor.showHint({
completeSingle : false,
hint : function(editor) {
const cursor = editor.getCursor();
const line = cursor.line;
const lineContent = editor.getLine(line);
const start = lineContent.lastIndexOf(':', cursor.ch - 1) + 1;
const end = cursor.ch;
const currentWord = lineContent.slice(start, end);
const list = Object.keys(emojis).filter(function(emoji) {
return emoji.toLowerCase().indexOf(currentWord.toLowerCase()) >= 0;
}).sort((a, b)=>{
const lowerA = a.replace(/\d+/g, function(match) { // Temporarily convert any numbers in emoji string
return match.padStart(4, '0'); // to 4-digits, left-padded with 0's, to aid in
}).toLowerCase(); // sorting numbers, i.e., "d6, d10, d20", not "d10, d20, d6"
const lowerB = b.replace(/\d+/g, function(match) { // Also make lowercase for case-insensitive alpha sorting
return match.padStart(4, '0');
}).toLowerCase();
if(lowerA < lowerB)
return -1;
return 1;
}).map(function(emoji) {
return {
text : `${emoji}:`, // Text to output to editor when option is selected
render : function(element, self, data) { // How to display the option in the dropdown
const div = document.createElement('div');
div.innerHTML = `<i class="emojiPreview ${emojis[emoji]}"></i> ${emoji}`;
element.appendChild(div);
}
};
});
return {
list : list.length ? list : [],
from : CodeMirror.Pos(line, start),
to : CodeMirror.Pos(line, end)
};
}
});
};
editor.on('inputRead', function(instance, change) {
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
// Get the text from the start of the line to the cursor
const textToCursor = line.slice(0, cursor.ch);
// Do not autosuggest emojis in curly span/div/injector properties
if(line.includes('{')) {
const curlyToCursor = textToCursor.slice(textToCursor.indexOf(`{`));
const curlySpanRegex = /{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1$/g;
if(curlySpanRegex.test(curlyToCursor))
return;
}
// Check if the text ends with ':xyz'
if(/:[^\s:]+$/.test(textToCursor)) {
CodeMirror.commands.autocomplete(editor);
}
});
};
export default {
showAutocompleteEmoji
};
-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);
}
};
+392 -480
View File
@@ -1,490 +1,402 @@
/* eslint-disable max-lines */ /* eslint max-lines: ["error", { "max": 405 }] */
import './codeEditor.less'; import './codeEditor.less';
import React from 'react'; import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
import closeTag from './close-tag';
import autoCompleteEmoji from './autocompleteEmoji';
let CodeMirror;
const CodeEditor = createReactClass({ import {
displayName : 'CodeEditor', EditorView,
getDefaultProps : function() { keymap,
return { lineNumbers,
language : '', highlightActiveLineGutter,
tab : 'brewText', highlightActiveLine,
value : '', scrollPastEnd,
wrap : true, Decoration,
onChange : ()=>{}, drawSelection,
enableFolding : true, dropCursor,
editorTheme : 'default' rectangularSelection,
}; crosshairCursor,
} from '@codemirror/view';
import { EditorState, Compartment, StateEffect, StateField } from '@codemirror/state';
import {
unfoldAll as unfoldAllCmd,
foldGutter,
foldKeymap,
foldEffect,
foldState,
syntaxHighlighting,
} from '@codemirror/language';
import { defaultKeymap, history, undo, redo, undoDepth, redoDepth } from '@codemirror/commands';
import { languages } from '@codemirror/language-data';
import { css } from '@codemirror/lang-css';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { html } from '@codemirror/lang-html';
import { autocompleteEmoji } from './extensions/autocompleteEmoji.js';
import { searchKeymap, search } from '@codemirror/search';
import { closeBrackets } from '@codemirror/autocomplete';
const autoCloseBrackets = closeBrackets({ brackets: ['()', '[]', '{{}}'] });
import defaultCM5Theme from '@themes/codeMirror/default.js';
import darkbrewery from '@themes/codeMirror/darkbrewery.js';
import cm5Themes from 'codemirror-5-themes';
const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery };
const themeCompartment = new Compartment();
const highlightCompartment = new Compartment();
import { generalKeymap, markdownKeymap } from './extensions/customKeyMaps.js';
import foldOnPages from './extensions/customFolding.js';
import { customHighlightPlugin, customHighlightStyle } from './extensions/customHighlight.js';
import { legacyCustomHighlightStyle } from './extensions/legacyCustomHighlight.js';
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
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);
getInitialState : function() { return Decoration.set([
return { Decoration.line({
docs : {} class : 'sourceMoveFlash'
}; }).range(line.from)
}, ]);
editor : React.createRef(null),
async componentDidMount() {
CodeMirror = (await import('codemirror')).default;
this.CodeMirror = CodeMirror;
await import('codemirror/mode/gfm/gfm.js');
await import('codemirror/mode/css/css.js');
await import('codemirror/mode/javascript/javascript.js');
// addons
await import('codemirror/addon/fold/foldcode.js');
await import('codemirror/addon/fold/foldgutter.js');
await import('codemirror/addon/fold/xml-fold.js');
await import('codemirror/addon/search/search.js');
await import('codemirror/addon/search/searchcursor.js');
await import('codemirror/addon/search/jump-to-line.js');
await import('codemirror/addon/search/match-highlighter.js');
await import('codemirror/addon/search/matchesonscrollbar.js');
await import('codemirror/addon/dialog/dialog.js');
await import('codemirror/addon/scroll/scrollpastend.js');
await import('codemirror/addon/edit/closetag.js');
await import('codemirror/addon/hint/show-hint.js');
// import 'codemirror/addon/selection/active-line.js';
// import 'codemirror/addon/edit/trailingspace.js';
// register helpers dynamically as well
const foldPagesCode = (await import('./fold-pages')).default;
const foldCSSCode = (await import('./fold-css')).default;
foldPagesCode.registerHomebreweryHelper(CodeMirror);
foldCSSCode.registerHomebreweryHelper(CodeMirror);
this.buildEditor();
const newDoc = CodeMirror?.Doc(this.props.value, this.props.language);
this.codeMirror?.swapDoc(newDoc);
},
componentDidUpdate : function(prevProps) {
if(prevProps.view !== this.props.view){ //view changed; swap documents
let newDoc;
if(!this.state.docs[this.props.view]) {
newDoc = CodeMirror?.Doc(this.props.value, this.props.language);
} else {
newDoc = this.state.docs[this.props.view];
}
const oldDoc = { [prevProps.view]: this.codeMirror?.swapDoc(newDoc) };
this.setState((prevState)=>({
docs : _.merge({}, prevState.docs, oldDoc)
}));
this.props.rerenderParent();
} else if(this.codeMirror?.getValue() != this.props.value) { //update editor contents if brew.text is changed from outside
this.codeMirror?.setValue(this.props.value);
}
if(this.props.enableFolding) {
this.codeMirror?.setOption('foldOptions', this.foldOptions(this.codeMirror));
} else {
this.codeMirror?.setOption('foldOptions', false);
}
if(prevProps.editorTheme !== this.props.editorTheme){
this.codeMirror?.setOption('theme', this.props.editorTheme);
}
},
buildEditor : function() {
this.codeMirror = CodeMirror(this.editor.current, {
lineNumbers : true,
lineWrapping : this.props.wrap,
indentWithTabs : false,
tabSize : 2,
smartIndent : false,
historyEventDelay : 250,
scrollPastEnd : true,
extraKeys : {
'Tab' : this.indent,
'Shift-Tab' : this.dedent,
'Ctrl-B' : this.makeBold,
'Cmd-B' : this.makeBold,
'Shift-Ctrl-=' : this.makeSuper,
'Shift-Cmd-=' : this.makeSuper,
'Ctrl-=' : this.makeSub,
'Cmd-=' : this.makeSub,
'Ctrl-I' : this.makeItalic,
'Cmd-I' : this.makeItalic,
'Ctrl-U' : this.makeUnderline,
'Cmd-U' : this.makeUnderline,
'Ctrl-.' : this.makeNbsp,
'Cmd-.' : this.makeNbsp,
'Shift-Ctrl-.' : this.makeSpace,
'Shift-Cmd-.' : this.makeSpace,
'Shift-Ctrl-,' : this.removeSpace,
'Shift-Cmd-,' : this.removeSpace,
'Ctrl-M' : this.makeSpan,
'Cmd-M' : this.makeSpan,
'Shift-Ctrl-M' : this.makeDiv,
'Shift-Cmd-M' : this.makeDiv,
'Ctrl-/' : this.makeComment,
'Cmd-/' : this.makeComment,
'Ctrl-K' : this.makeLink,
'Cmd-K' : this.makeLink,
'Ctrl-L' : ()=>this.makeList('UL'),
'Cmd-L' : ()=>this.makeList('UL'),
'Shift-Ctrl-L' : ()=>this.makeList('OL'),
'Shift-Cmd-L' : ()=>this.makeList('OL'),
'Shift-Ctrl-1' : ()=>this.makeHeader(1),
'Shift-Ctrl-2' : ()=>this.makeHeader(2),
'Shift-Ctrl-3' : ()=>this.makeHeader(3),
'Shift-Ctrl-4' : ()=>this.makeHeader(4),
'Shift-Ctrl-5' : ()=>this.makeHeader(5),
'Shift-Ctrl-6' : ()=>this.makeHeader(6),
'Shift-Cmd-1' : ()=>this.makeHeader(1),
'Shift-Cmd-2' : ()=>this.makeHeader(2),
'Shift-Cmd-3' : ()=>this.makeHeader(3),
'Shift-Cmd-4' : ()=>this.makeHeader(4),
'Shift-Cmd-5' : ()=>this.makeHeader(5),
'Shift-Cmd-6' : ()=>this.makeHeader(6),
'Shift-Ctrl-Enter' : this.newColumn,
'Shift-Cmd-Enter' : this.newColumn,
'Ctrl-Enter' : this.newPage,
'Cmd-Enter' : this.newPage,
'Ctrl-F' : 'findPersistent',
'Cmd-F' : 'findPersistent',
'Shift-Enter' : 'findPersistentPrevious',
'Ctrl-[' : this.foldAllCode,
'Cmd-[' : this.foldAllCode,
'Ctrl-]' : this.unfoldAllCode,
'Cmd-]' : this.unfoldAllCode
},
foldGutter : true,
foldOptions : this.foldOptions(this.codeMirror),
gutters : ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
autoCloseTags : true,
styleActiveLine : true,
showTrailingSpace : false,
theme : this.props.editorTheme
// specialChars : / /,
// specialCharPlaceholder : function(char) {
// const el = document.createElement('span');
// el.className = 'cm-space';
// el.innerHTML = ' ';
// return el;
// }
});
// Add custom behaviors (auto-close curlies and auto-complete emojis)
closeTag.autoCloseCurlyBraces(CodeMirror, this.codeMirror);
autoCompleteEmoji.showAutocompleteEmoji(CodeMirror, this.codeMirror);
// Note: codeMirror passes a copy of itself in this callback. cm === this.codeMirror?. Either one works.
this.codeMirror?.on('change', (cm)=>{this.props.onChange(cm.getValue());});
this.updateSize();
},
// Use for GFM tabs that use common hot-keys
isGFM : function() {
if((this.isGFM()) || (this.props.tab === 'brewSnippets')) return true;
return false;
},
isBrewText : function() {
if(this.isGFM()) return true;
return false;
},
isBrewSnippets : function() {
if(this.props.tab === 'brewSnippets') return true;
return false;
},
indent : function () {
const cm = this.codeMirror;
if(cm.somethingSelected()) {
cm.execCommand('indentMore');
} else {
cm.execCommand('insertSoftTab');
}
},
dedent : function () {
this.codeMirror?.execCommand('indentLess');
},
makeHeader : function (number) {
if(!this.isGFM()) return;
const selection = this.codeMirror?.getSelection();
const header = Array(number).fill('#').join('');
this.codeMirror?.replaceSelection(`${header} ${selection}`, 'around');
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch + selection.length + number + 1 });
},
makeBold : function() {
if(!this.isGFM()) return;
const selection = this.codeMirror?.getSelection(), t = selection.slice(0, 2) === '**' && selection.slice(-2) === '**';
this.codeMirror?.replaceSelection(t ? selection.slice(2, -2) : `**${selection}**`, 'around');
if(selection.length === 0){
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch - 2 });
}
},
makeItalic : function() {
if(!this.isGFM()) return;
const selection = this.codeMirror.getSelection(), t = selection.slice(0, 1) === '*' && selection.slice(-1) === '*';
this.codeMirror?.replaceSelection(t ? selection.slice(1, -1) : `*${selection}*`, 'around');
if(selection.length === 0){
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch - 1 });
}
},
makeSuper : function() {
if(!this.isGFM()) return;
const selection = this.codeMirror.getSelection(), t = selection.slice(0, 1) === '^' && selection.slice(-1) === '^';
this.codeMirror?.replaceSelection(t ? selection.slice(1, -1) : `^${selection}^`, 'around');
if(selection.length === 0){
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch - 1 });
}
},
makeSub : function() {
if(!this.isGFM()) return;
const selection = this.codeMirror.getSelection(), t = selection.slice(0, 2) === '^^' && selection.slice(-2) === '^^';
this.codeMirror?.replaceSelection(t ? selection.slice(2, -2) : `^^${selection}^^`, 'around');
if(selection.length === 0){
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch - 2 });
}
},
makeNbsp : function() {
if(!this.isGFM()) return;
this.codeMirror?.replaceSelection('&nbsp;', 'end');
},
makeSpace : function() {
if(!this.isGFM()) return;
const selection = this.codeMirror?.getSelection();
const t = selection.slice(0, 8) === '{{width:' && selection.slice(0 -4) === '% }}';
if(t){
const percent = parseInt(selection.slice(8, -4)) + 10;
this.codeMirror?.replaceSelection(percent < 90 ? `{{width:${percent}% }}` : '{{width:100% }}', 'around');
} else {
this.codeMirror?.replaceSelection(`{{width:10% }}`, 'around');
}
},
removeSpace : function() {
if(!this.isGFM()) return;
const selection = this.codeMirror?.getSelection();
const t = selection.slice(0, 8) === '{{width:' && selection.slice(0 -4) === '% }}';
if(t){
const percent = parseInt(selection.slice(8, -4)) - 10;
this.codeMirror?.replaceSelection(percent > 10 ? `{{width:${percent}% }}` : '', 'around');
}
},
newColumn : function() {
if(!this.isGFM()) return;
this.codeMirror?.replaceSelection('\n\\column\n\n', 'end');
},
newPage : function() {
if(!this.isGFM()) return;
this.codeMirror?.replaceSelection('\n\\page\n\n', 'end');
},
injectText : function(injectText, overwrite=true) {
const cm = this.codeMirror;
if(!overwrite) {
cm.setCursor(cm.getCursor('from'));
}
cm.replaceSelection(injectText, 'end');
cm.focus();
},
makeUnderline : function() {
if(!this.isGFM()) return;
const selection = this.codeMirror.getSelection(), t = selection.slice(0, 3) === '<u>' && selection.slice(-4) === '</u>';
this.codeMirror?.replaceSelection(t ? selection.slice(3, -4) : `<u>${selection}</u>`, 'around');
if(selection.length === 0){
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch - 4 });
}
},
makeSpan : function() {
if(!this.isGFM()) return;
const selection = this.codeMirror.getSelection(), t = selection.slice(0, 2) === '{{' && selection.slice(-2) === '}}';
this.codeMirror?.replaceSelection(t ? selection.slice(2, -2) : `{{ ${selection}}}`, 'around');
if(selection.length === 0){
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch - 2 });
}
},
makeDiv : function() {
if(!this.isGFM()) return;
const selection = this.codeMirror.getSelection(), t = selection.slice(0, 2) === '{{' && selection.slice(-2) === '}}';
this.codeMirror?.replaceSelection(t ? selection.slice(2, -2) : `{{\n${selection}\n}}`, 'around');
if(selection.length === 0){
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setCursor({ line: cursor.line - 1, ch: cursor.ch }); // set to -2? if wanting to enter classes etc. if so, get rid of first \n when replacing selection
}
},
makeComment : function() {
let regex;
let cursorPos;
let newComment;
const selection = this.codeMirror?.getSelection();
if(this.isGFM()){
regex = /^\s*(<!--\s?)(.*?)(\s?-->)\s*$/gs;
cursorPos = 4;
newComment = `<!-- ${selection} -->`;
} else {
regex = /^\s*(\/\*\s?)(.*?)(\s?\*\/)\s*$/gs;
cursorPos = 3;
newComment = `/* ${selection} */`;
}
this.codeMirror?.replaceSelection(regex.test(selection) == true ? selection.replace(regex, '$2') : newComment, 'around');
if(selection.length === 0){
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch - cursorPos });
};
},
makeLink : function() {
if(!this.isGFM()) return;
const isLink = /^\[(.*)\]\((.*)\)$/;
const selection = this.codeMirror?.getSelection().trim();
let match;
if(match = isLink.exec(selection)){
const altText = match[1];
const url = match[2];
this.codeMirror?.replaceSelection(`${altText} ${url}`);
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setSelection({ line: cursor.line, ch: cursor.ch - url.length }, { line: cursor.line, ch: cursor.ch });
} else {
this.codeMirror?.replaceSelection(`[${selection || 'alt text'}](url)`);
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setSelection({ line: cursor.line, ch: cursor.ch - 4 }, { line: cursor.line, ch: cursor.ch - 1 });
}
},
makeList : function(listType) {
if(!this.isGFM()) return;
const selectionStart = this.codeMirror.getCursor('from'), selectionEnd = this.codeMirror.getCursor('to');
this.codeMirror?.setSelection(
{ line: selectionStart.line, ch: 0 },
{ line: selectionEnd.line, ch: this.codeMirror?.getLine(selectionEnd.line).length }
);
const newSelection = this.codeMirror?.getSelection();
const regex = /^\d+\.\s|^-\s/gm;
if(newSelection.match(regex) != null){ // if selection IS A LIST
this.codeMirror?.replaceSelection(newSelection.replace(regex, ''), 'around');
} else { // if selection IS NOT A LIST
listType == 'UL' ? this.codeMirror?.replaceSelection(newSelection.replace(/^/gm, `- `), 'around') :
this.codeMirror?.replaceSelection(newSelection.replace(/^/gm, (()=>{
let n = 1;
return ()=>{
return `${n++}. `;
};
})()), 'around');
}
},
foldAllCode : function() {
this.codeMirror?.execCommand('foldAll');
},
unfoldAllCode : function() {
this.codeMirror?.execCommand('unfoldAll');
},
//=-- Externally used -==//
setCursorPosition : function(line, char){
setTimeout(()=>{
this.codeMirror?.focus();
this.codeMirror?.doc.setCursor(line, char);
}, 10);
},
getCursorPosition : function(){
return this.codeMirror?.getCursor();
},
getTopVisibleLine : function(){
const rect = this.codeMirror?.getWrapperElement().getBoundingClientRect();
const topVisibleLine = this.codeMirror?.lineAtHeight(rect.top, 'window');
return topVisibleLine;
},
updateSize : function(){
this.codeMirror?.refresh();
},
redo : function(){
return this.codeMirror?.redo();
},
undo : function(){
return this.codeMirror?.undo();
},
historySize : function(){
return this.codeMirror?.doc.historySize();
},
foldOptions : function(cm){
return {
scanUp : true,
rangeFinder : this.props.language === 'css' ? CodeMirror.fold.homebrewerycss : CodeMirror.fold.homebrewery,
widget : (from, to)=>{
let text = '';
let currentLine = from.line;
let maxLength = 50;
let foldPreviewText = '';
while (currentLine <= to.line && text.length <= maxLength) {
const currentText = this.codeMirror?.getLine(currentLine);
currentLine++;
if(currentText[0] == '#'){
foldPreviewText = currentText;
break;
}
if(!foldPreviewText && currentText != '\n') {
foldPreviewText = currentText;
} }
} }
text = foldPreviewText || `Lines ${from.line+1}-${to.line+1}`; return decorations;
text = text.replace('{', '').trim();
// Truncate data URLs at `data:`
const startOfData = text.indexOf('data:');
if(startOfData > 0)
maxLength = Math.min(startOfData + 5, maxLength);
if(text.length > maxLength)
text = `${text.slice(0, maxLength)}...`;
return `\u21A4 ${text} \u21A6`;
}
};
}, },
//----------------------// provide : (decorationSet)=>EditorView.decorations.from(decorationSet)
render : function(){
return <>
<link href={`../homebrew/cm-themes/${this.props.editorTheme}.css`} type='text/css' rel='stylesheet' />
<div className='codeEditor' ref={this.editor} style={this.props.style}/>
</>;
}
}); });
export default CodeEditor; const CodeEditor = forwardRef(
(
{
language = '',
tab = 'brewText',
view,
value = '',
onChange = ()=>{},
onCursorChange = ()=>{},
onViewChange = ()=>{},
editorTheme = 'default',
style,
renderer,
...props
},
ref,
)=>{
const editorRef = useRef(null);
const viewRef = useRef(null);
const docsRef = useRef({});
const tabRef = useRef(tab);
const prevTabRef = useRef(tab);
const scrollRef = useRef({});
const foldsRef = useRef({});
const pageMap = useRef([]);
const recomputePages = (doc)=>{
if(tab !== 'brewText') return;
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 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 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 page = findPageFromPos(pos);
onCursorChange(page);
}
});
const highlightExtension = renderer === 'V3'
? syntaxHighlighting(customHighlightStyle)
: syntaxHighlighting(legacyCustomHighlightStyle);
const languageExtension = language === 'css' ? css() : [markdown({ base: markdownLanguage, codeLanguages: languages }), html({ autoCloseTags: true })];
const themeExtension = Array.isArray(themes[editorTheme]) ? themes[editorTheme] : themes[editorTheme] || themes['default'];
return [
EditorView.lineWrapping,
setEventListeners,
languageExtension,
autoCloseBrackets,
lineNumbers(),
scrollPastEnd(),
search(),
history(), //allows for undo and redo
...(tab !== 'brewStyles' ? [autocompleteEmoji] : []),
//folding
foldOnPages,
foldGutter({
openText : '▾',
closedText : '▸'
}),
//highlights
highlightCompartment.of([customHighlightPlugin(renderer, tab), highlightExtension]),
themeCompartment.of(themeExtension),
highlightActiveLine(),
highlightActiveLineGutter(),
//keyboard shortcut
keymap.of([...defaultKeymap, foldKeymap, ...searchKeymap]),
generalKeymap,
...(tab !== 'brewStyles' ? [markdownKeymap] : []),
//multiple cursors and selections
drawSelection(),
rectangularSelection(),
crosshairCursor(),
EditorState.allowMultipleSelections.of(true),
dropCursor(),
programmaticCursorLineField,
];
};
useEffect(()=>{
if(!editorRef.current) return;
const state = EditorState.create({
doc : value,
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;
scrollRef.current[tabRef.current] = top;
const block = view.lineBlockAtHeight(top);
const page = findPageFromPos(block.from);
onViewChange(page);
ticking = false;
});
};
view.scrollDOM.addEventListener('scroll', handleScroll);
docsRef.current[tab] = state;
return ()=>{
view.scrollDOM.removeEventListener('scroll', handleScroll);
viewRef.current?.destroy();
};
}, []);
const restoreFolds = (view, folds)=>{
if(!folds?.length) return;
view.dispatch({
effects : folds.map((f)=>foldEffect.of(f))
});
};
useEffect(()=>{
const view = viewRef.current;
if(!view) return;
tabRef.current = tab;
const prevTab = prevTabRef.current;
foldsRef.current[prevTab] = getFoldRanges(view.state);
if(prevTab !== tab) {
docsRef.current[prevTab] = view.state;
let nextState = docsRef.current[tab];
if(!nextState) {
nextState = EditorState.create({
doc : value,
extensions : createExtensions({ onChange, language, editorTheme }),
});
}
view.setState(nextState);
restoreFolds(view, foldsRef.current[tab]);
const savedScroll = scrollRef.current[tab];
if(savedScroll != null) {
requestAnimationFrame(()=>{
view.scrollDOM.scrollTop = savedScroll;
});
}
prevTabRef.current = tab;
}
view.focus();
}, [tab]);
useEffect(()=>{
const view = viewRef.current;
if(!view) return;
const current = view.state.doc.toString();
if(value !== current) {
view.dispatch({
changes : { from: 0, to: current.length, insert: value },
});
}
}, [value]);
useEffect(()=>{
//rebuild theme extension on theme change
const view = viewRef.current;
if(!view) return;
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;
const highlightExtension =renderer === 'V3'
? syntaxHighlighting(customHighlightStyle)
: syntaxHighlighting(legacyCustomHighlightStyle);
view.dispatch({
effects : highlightCompartment.reconfigure([customHighlightPlugin(renderer, tab), highlightExtension]),
});
}, [renderer, tab]);
useImperativeHandle(ref, ()=>({
injectText : (text)=>{
const view = viewRef.current;
view.dispatch(
view.state.replaceSelection(text)
);
view.focus();
},
getCursorPosition : ()=>viewRef.current.state.selection.main.head,
scrollToPage : (pageNumber, smooth = true)=>{
const view = viewRef.current;
if(!view) return;
const pos = pageMap.current[pageNumber - 1] ?? 0;
view.dispatch({
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),
redo : ()=>redo(viewRef.current),
historySize : ()=>{
const view = viewRef.current;
if(!view) return { done: 0, undone: 0 };
return {
done : undoDepth(view.state),
undone : redoDepth(view.state),
};
},
foldAll : ()=>{
const view = viewRef.current;
if(!view) return;
const doc = view.state.doc;
const pages = pageMap.current;
const effects = pages.map((start, i)=>{
const next = pages[i + 1] || doc.length;
const from = i ? doc.line(doc.lineAt(start).number + 1).from : 0;
const to = doc.line(doc.lineAt(next).number).from - 1;
return to > from ? foldEffect.of({ from, to }) : null;
}).filter(Boolean);
view.dispatch({ effects });
},
unfoldAll : ()=>{
const view = viewRef.current;
if(!view) return;
view.dispatch(unfoldAllCmd(view));
},
focus : ()=>viewRef.current.focus(),
}));
return <div className={`codeEditor ${tab}`} ref={editorRef} style={style} />;
},
);
export default CodeEditor;
+168 -21
View File
@@ -1,59 +1,206 @@
@import (less) 'codemirror/lib/codemirror.css'; // Icon fonts for emoji/autocomplete
@import (less) 'codemirror/addon/fold/foldgutter.css';
@import (less) 'codemirror/addon/search/matchesonscrollbar.css';
@import (less) 'codemirror/addon/dialog/dialog.css';
@import (less) 'codemirror/addon/hint/show-hint.css';
//Icon fonts included so they can appear in emoji autosuggest dropdown
@import (less) '@themes/fonts/iconFonts/diceFont.less'; @import (less) '@themes/fonts/iconFonts/diceFont.less';
@import (less) '@themes/fonts/iconFonts/elderberryInn.less'; @import (less) '@themes/fonts/iconFonts/elderberryInn.less';
@import (less) '@themes/fonts/iconFonts/gameIcons.less'; @import (less) '@themes/fonts/iconFonts/gameIcons.less';
@import (less) '@themes/fonts/iconFonts/fontAwesome.less'; @import (less) '@themes/fonts/iconFonts/fontAwesome.less';
@keyframes sourceMoveAnimation { @keyframes sourceMoveAnimation {
50% { color : white;background-color : red;} 50% {
100% { color : unset;background-color : unset;} color : white;
background-color : red;
}
100% {
color : unset;
background-color : unset;
}
} }
.codeEditor { :where(.codeEditor) {
width : 100%;
height : calc(100% - 25px);
font-family : monospace;
.cm-editor {
height : 100%;
outline : none !important;
}
&.brewSnippets .cm-snippetLine,
:where(&.brewText) .cm-pageLine {
background : #33333328;
border-top : #333399 solid 1px;
}
&.brewSnippets {
.cm-pageLine {
color : #777777;
background : #3E4E3E1B;
border-top : #3399423B solid 1px;
}
}
&:where(.brewText), &.brewSnippets {
.cm-pageLine[data-page-number]::after {
float : right;
color : grey;
content : attr(data-page-number);
}
.cm-columnSplit {
font-style : italic;
color : grey;
background-color : fade(#229999, 15%);
border-bottom : #229999 solid 1px;
}
.cm-define {
&:not(.term):not(.definition) {
font-weight : bold;
color : #949494;
background : #E5E5E5;
border-radius : 3px;
}
&.term { color : rgb(96, 117, 143); }
&.definition { color : rgb(97, 57, 178); }
}
.cm-block:not(.cm-comment),
.cm-block:not(.cm-comment) * {
font-weight : bold;
color : purple;
}
.cm-inline-block,
.cm-define .cm-inline-block {
font-weight : bold;
color : red;
span:not(.cm-comment) { color : inherit; }
}
.cm-injection:not(.cm-comment) {
font-weight : bold;
color : green;
span { color : inherit; }
}
.cm-emoji:not(.cm-comment) {
padding-bottom : 1px;
margin-left : 2px;
font-weight : bold;
color : #360034;
outline : solid 2px #FF96FC;
outline-offset : -2px;
background : #FFC8FF;
border-radius : 6px;
}
.cm-superscript:not(.cm-comment) {
font-size : 0.9em;
font-weight : bold;
vertical-align : super;
color : goldenrod;
}
.cm-subscript:not(.cm-comment) {
font-size : 0.9em;
font-weight : bold;
vertical-align : sub;
color : rgb(123, 123, 15);
}
.cm-strikethrough {
text-decoration: line-through;
}
.cm-definitionList {
.cm-definitionTerm { color : rgb(96, 117, 143); }
.cm-definitionColon:not(:has(.cm-comment)) {
font-weight : bold;
color : #949494;
background : #E5E5E5;
border-radius : 3px;
}
.cm-definitionDesc { color : rgb(97, 57, 178); }
}
.cm-tooltip-autocomplete {
li {
display : flex;
gap : 10px;
align-items : center;
justify-content : flex-start;
.cm-completionIcon { display : none; }
.cm-tooltip-autocomplete .cm-completionLabel { translate : 0 -2px; }
}
}
}
.cm-content { tab-size : 2 !important; }
@media screen and (pointer : coarse) { @media screen and (pointer : coarse) {
font-size : 16px; font-size : 16px;
} }
.CodeMirror-foldmarker {
.cm-gutterElement span {
font-family : inherit; font-family : inherit;
font-weight : 600; font-weight : 600;
color : grey; color : grey;
text-shadow : none; text-shadow : none;
} }
.CodeMirror-foldgutter { .cm-foldGutter {
cursor : pointer; cursor : pointer;
border-left : 1px solid #EEEEEE; border-left : 1px solid #EEEEEE;
transition : background 0.1s; transition : background 0.1s;
&:hover { background : #DDDDDD; } &:hover { background : #DDDDDD; }
} }
.sourceMoveFlash .CodeMirror-line { /* Flash animation for source moves */
.cm-line.sourceMoveFlash {
animation-name : sourceMoveAnimation; animation-name : sourceMoveAnimation;
animation-duration : 0.4s; animation-duration : 0.4s;
} }
.CodeMirror-search-field { /* Search input */
width:25em !important; .cm-searchField {
outline:1px inset #00000055 !important; width : 25em !important;
outline : 1px inset #00000055 !important;
} }
.cm-image {
position:relative;
.cm-preview {
object-fit: contain;
position:absolute;
bottom:0;
left:0;
height:200px;
width:200px;
height:200px;
padding:5px;
background-color: #fff;
border-radius:10px;
border:3px solid grey;
pointer-events: none;
opacity:0;
transition:0.2s opacity 0.5s;
translate:0 100%;
z-index:1000;
}
&:hover .cm-preview {
opacity:1;
}
}
/* Tab character visualization (optional) */
//.cm-tab { //.cm-tab {
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAMCAQAAACOs/baAAAARUlEQVR4nGJgIAG8JkXxUAcCtDWemcGR1lY4MvgzCEKY7jSBjgxBDAG09UEQzAe0AMwMHrSOAwEGRtpaMIwAAAAA//8DAG4ID9EKs6YqAAAAAElFTkSuQmCC) no-repeat right; // background: url(...) no-repeat right;
//} //}
//.cm-trailingspace { /* Trailing space visualization (optional) */
// .cm-space { //.cm-trailingSpace .cm-space {
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAQAgMAAABW5NbuAAAACVBMVEVHcEwAAAAAAAAWawmTAAAAA3RSTlMAPBJ6PMxpAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAFUlEQVQI12NgwACcCQysASAEZGAAACMuAX06aCQUAAAAAElFTkSuQmCC) no-repeat right; // background: url(...) no-repeat right;
// }
//} //}
} }
/* Emoji preview styling */
.emojiPreview { .emojiPreview {
font-size : 1.5em; font-size : 1.5em;
line-height : 1.2em; line-height : 1.2em;
@@ -0,0 +1,76 @@
import { autocompletion } from '@codemirror/autocomplete';
import diceFont from '@themes/fonts/iconFonts/diceFont.js';
import elderberryInn from '@themes/fonts/iconFonts/elderberryInn.js';
import fontAwesome from '@themes/fonts/iconFonts/fontAwesome.js';
import gameIcons from '@themes/fonts/iconFonts/gameIcons.js';
const emojis = {
...diceFont,
...elderberryInn,
...fontAwesome,
...gameIcons
};
const emojiCompletionList = (context)=>{
const word = context.matchBefore(/:[^\s:]+/);
if(!word) return null;
const line = context.state.doc.lineAt(context.pos);
const textToCursor = line.text.slice(0, context.pos - line.from);
if(textToCursor.includes('{')) {
const curlyToCursor = textToCursor.slice(textToCursor.indexOf('{'));
const curlySpanRegex = /{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1$/g;
if(curlySpanRegex.test(curlyToCursor)) return null;
}
const currentWord = word.text.slice(1); // remove ':'
const options = Object.keys(emojis)
.filter((e)=>e.toLowerCase().includes(currentWord.toLowerCase()))
.sort((a, b)=>{
const normalize = (str)=>str.replace(/\d+/g, (m)=>m.padStart(4, '0')).toLowerCase();
return normalize(a) < normalize(b) ? -1 : 1;
})
.map((e)=>({
label : e,
apply : `${e}:`,
type : 'text',
info : ()=>{
const div = document.createElement('div');
div.innerHTML = `<i class="emojiPreview ${emojis[e]}"></i> ${e}`;
return div;
}
}));
//Label is the text in the list, comes with an icon that just
//renders example text "abc", hid that with css because i didn't see other choice
//Apply is the text that is set when the choice is selected
//Info is the tooltip
return {
from : word.from + 1,
options,
filter : false,
};
};
export const autocompleteEmoji = autocompletion({
override : [emojiCompletionList],
activateOnTyping : true,
addToOptions : [
{
render(completion) {
const e = completion.label;
const icon = document.createElement('i');
icon.className = `emojiPreview ${emojis[e]}`;
const fragment = document.createDocumentFragment();
fragment.appendChild(icon);
return fragment;
}
}
]
});
@@ -0,0 +1,46 @@
import { foldService, codeFolding } from '@codemirror/language';
const foldOnPages = [
foldService.of((state, lineStart)=>{ //tells where to fold
const doc = state.doc;
const matcher = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
const startLine = doc.lineAt(lineStart);
const prevLineText = startLine.number > 1 ? doc.line(startLine.number - 1).text : '';
if(!matcher.test(prevLineText)) return null;
let endLine = startLine.number;
while (endLine < doc.lines && !matcher.test(doc.line(endLine + 1).text)) {
endLine++;
}
if(endLine === startLine.number) return null;
return { from: startLine.from, to: doc.line(endLine).to };
}),
codeFolding({
preparePlaceholder : (state, range)=>{
const doc = state.doc;
const start = doc.lineAt(range.from).number;
const end = doc.lineAt(range.to).number;
if(doc.line(start).text.trim()) return ` ↤ Lines ${start}-${end}`;
const preview = Array.from({ length: end - start }, (_, i)=>doc.line(start + 1 + i).text.trim()
).find(Boolean) || `Lines ${start}-${end}`;
return `${preview.replace('{', '').slice(0, 50).trim()}${preview.length > 50 ? '...' : ''}`;
},
placeholderDOM(view, onclick, prepared) {
const span = document.createElement('span');
span.className = 'cm-fold-placeholder';
span.textContent = prepared;
span.onclick = onclick;
span.style.color = '#989898';
return span;
},
}),
];
export default foldOnPages;
@@ -0,0 +1,472 @@
/* eslint max-lines: ["error", { "max": 500 }] */
import { HighlightStyle } from '@codemirror/language';
import { tags } from '@lezer/highlight';
import { legacyTokenizeCustomMarkdown } from './legacyCustomHighlight';
import {
Decoration,
ViewPlugin,
} from '@codemirror/view';
import {
syntaxTree,
ensureSyntaxTree
} from '@codemirror/language';
// Making the tokens
const customTags = {
pageLine : 'pageLine', // .cm-pageLine
snippetLine : 'snippetLine', // .cm-snippetLine
columnSplit : 'columnSplit', // .cm-columnSplit
block : 'block', // .cm-block
inlineBlock : 'inline-block', // .cm-inline-block
injection : 'injection', // .cm-injection
emoji : 'emoji', // .cm-emoji
superscript : 'superscript', // .cm-superscript
subscript : 'subscript', // .cm-subscript
definitionList : 'definitionList', // .cm-definitionList
definitionTerm : 'definitionTerm', // .cm-definitionTerm
definitionDesc : 'definitionDesc', // .cm-definitionDesc
definitionColon : 'definitionColon', // .cm-definitionColon
strikethrough : 'strikethrough', // .cm-strikethrough
//CSS
variable : 'variable',
};
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 });
if(/^\\snippet\ .*$/.test(lineText)) tokens.push({ line: lineNumber, type: customTags.snippetLine });
if(/^\\column(?:break)?$/.test(lineText)) tokens.push({ line: lineNumber, type: customTags.columnSplit });
// --- Emoji ---
if(/:.\w+?:/.test(lineText)) {
const emojiRegex = /(:\w+?:)/g;
let match;
while ((match = emojiRegex.exec(lineText)) !== null) {
tokens.push({
line : lineNumber,
type : customTags.emoji,
from : match.index,
to : match.index + match[0].length,
});
}
}
// --- Superscript / Subscript ---
if(/\^/.test(lineText)) {
let startIndex = lineText.indexOf('^');
const superRegex = /\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^/gy;
const subRegex = /\^\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^\^/gy;
while (startIndex >= 0) {
superRegex.lastIndex = subRegex.lastIndex = startIndex;
let match = subRegex.exec(lineText);
let type = customTags.subscript;
if(!match) {
match = superRegex.exec(lineText);
type = customTags.superscript;
}
if(match) {
tokens.push({
line : lineNumber,
type,
from : match.index,
to : match.index + match[0].length,
});
}
startIndex = lineText.indexOf(
'^',
Math.max(startIndex + 1, superRegex.lastIndex || 0, subRegex.lastIndex || 0),
);
}
}
// --- 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 ---
const singleLineRegex = /^(?=.*[^:])(.+?)(\s*)(::)([^\n]*)$/dmy;
const match = singleLineRegex.exec(lineText);
if(match) {
const [full, term, spaces, colons, desc] = match;
let offset = 0;
tokens.push({
line : lineNumber,
type : customTags.definitionList,
});
// Term
tokens.push({
line : lineNumber,
type : customTags.definitionTerm,
from : offset,
to : offset + term.length,
});
offset += term.length;
// Spaces before ::
if(spaces) {
offset += spaces.length;
}
// :: colons
tokens.push({
line : lineNumber,
type : customTags.definitionColon,
from : offset,
to : offset + colons.length,
});
offset += colons.length;
// Definition
tokens.push({
line : lineNumber,
type : customTags.definitionDesc,
from : offset,
to : offset + desc.length,
});
}
// --- multiline def list ---
if(!/^::/.test(lines[lineNumber]) && lineNumber + 1 < lines.length && /^::/.test(lines[lineNumber + 1])) {
const startLine = lineNumber;
const defs = [];
// collect all following :: definitions
for (let i = lineNumber + 1; i < lines.length; i++) {
const nextLine = lines[i];
const onlyColonsMatch = /^:*$/.test(nextLine);
const defMatch = /^(::)(.+)$/.exec(nextLine);
if(!onlyColonsMatch && defMatch) {
defs.push({ colons: defMatch[1], desc: defMatch[2], line: i });
} else break;
}
if(defs.length > 0 && lineText.trim().length > 0) {
tokens.push({
line : startLine,
type : customTags.definitionList,
});
// term
tokens.push({
line : startLine,
type : customTags.definitionTerm,
from : 0,
to : lineText.length,
});
// definitions
defs.forEach((d)=>{
tokens.push({
line : d.line,
type : customTags.definitionList,
});
tokens.push({
line : d.line,
type : customTags.definitionColon,
from : 0,
to : d.colons.length,
});
tokens.push({
line : d.line,
type : customTags.definitionDesc,
from : d.colons.length,
to : d.colons.length + d.desc?.length,
});
});
}
}
if(lineText.includes('{') && lineText.includes('}')) {
const injectionRegex = /(?:^|[^{\n])({(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\2})/gmd;
let match;
while ((match = injectionRegex.exec(lineText)) !== null) {
tokens.push({
line : lineNumber,
from : match.indices[1][0],
to : match.indices[1][1],
type : customTags.injection,
});
}
}
if(lineText.includes('{{') && lineText.includes('}}')) {
// Inline blocks: single-line {{…}}
const spanRegex = /{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *|}}/g;
let match;
let blockCount = 0;
while ((match = spanRegex.exec(lineText)) !== null) {
if(match[0].startsWith('{{')) {
blockCount += 1;
} else {
blockCount -= 1;
}
if(blockCount < 0) {
blockCount = 0;
continue;
}
tokens.push({
line : lineNumber,
from : match.index,
to : match.index + match[0].length,
type : customTags.inlineBlock,
});
}
} else if(lineText.trimLeft().startsWith('{{') || lineText.trimLeft().startsWith('}}')) {
// Highlight block divs {{\n Content \n}}
let endCh = lineText.length + 1;
const match = lineText.match(
/^ *{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *$|^ *}}$/,
);
if(match) endCh = match.index + match[0].length;
const closingMatch = lineText.match(/ *(}})/d);
if(closingMatch) {
tokens.push({ line: lineNumber, from: closingMatch.indices[1][0], to: closingMatch.indices[1][1], type: customTags.block });
} else {
tokens.push({ line: lineNumber, type: customTags.block });
}
}
});
return tokens;
}
function tokenizeCustomCSS(text) {
const tokens = [];
const lines = text.split('\n');
lines.forEach((lineText, lineNumber)=>{
if(/--[a-zA-Z0-9-_]+/gm.test(lineText)) {
const varRegex =/--[a-zA-Z0-9-_]+/gm;
let match;
while ((match = varRegex.exec(lineText)) !== null) {
tokens.push({
line : lineNumber,
from : match.index +1,
to : match.index + match.length[1] +1,
type : customTags.varProperty,
});
}
}
});
return tokens;
}
//assign classes to tags provided by lezer, not unlike the function above
export const customHighlightStyle = HighlightStyle.define([
{ tag: tags.heading, class: 'cm-header' },
{ tag: tags.heading1, class: 'cm-header cm-header-1' },
{ tag: tags.heading2, class: 'cm-header cm-header-2' },
{ tag: tags.heading3, class: 'cm-header cm-header-3' },
{ tag: tags.heading4, class: 'cm-header cm-header-4' },
{ tag: tags.heading5, class: 'cm-header cm-header-5' },
{ tag: tags.heading6, class: 'cm-header cm-header-6' },
{ tag: tags.link, class: 'cm-link' },
{ tag: tags.string, class: 'cm-string' },
{ tag: tags.url, class: 'cm-string cm-url' },
{ tag: tags.list, class: 'cm-list' },
{ tag: tags.strong, class: 'cm-strong' },
{ tag: tags.emphasis, class: 'cm-em' },
{ tag: tags.quote, class: 'cm-quote' },
{ tag: tags.comment, class: 'cm-comment' },
{ tag: tags.monospace, class: 'cm-comment' },
//css tags
{ tag: tags.tagName, class: 'cm-tag' },
{ tag: tags.className, class: 'cm-class' },
{ tag: tags.propertyName, class: 'cm-property' },
{ tag: tags.attributeValue, class: 'cm-value' },
{ tag: tags.keyword, class: 'cm-keyword' },
{ tag: tags.atom, class: 'cm-atom' },
{ tag: tags.integer, class: 'cm-integer' },
{ tag: tags.unit, class: 'cm-unit' },
{ tag: tags.color, class: 'cm-color' },
{ tag: tags.paren, class: 'cm-paren' },
{ tag: tags.variableName, class: 'cm-variable' },
{ tag: tags.invalid, class: 'cm-error' },
]);
function getUrl(node, doc) {
let url = null;
const cursor = node.node.cursor();
if(cursor.firstChild()) {
do {
if(cursor.name === 'URL') {
url = doc.sliceString(cursor.from, cursor.to);
break;
}
} while (cursor.nextSibling());
}
return url;
}
import { WidgetType } from '@codemirror/view';
class ImageWidget extends WidgetType {
constructor(url) {
super();
this.url = url;
}
toDOM() {
const img = document.createElement('img');
img.loading = "lazy";
img.className = 'cm-preview';
img.src = this.url;
img.onerror = ()=>{
img.src = 'client/icons/broken-image.jpg';
};
return img;
}
eq(other) {
return other.url === this.url;
}
}
export function customHighlightPlugin(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
//it also creates page number and snippet number widgets
let tokenize;
if(tab === 'brewStyles') {
tokenize = tokenizeCustomCSS;
} else {
tokenize = renderer === 'V3' ? tokenizeCustomMarkdown : legacyTokenizeCustomMarkdown;
}
return ViewPlugin.fromClass(
class {
constructor(view) {
this.decorations = this.buildDecorations(view);
}
update(update) {
if(update.docChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view) {
const decos = [];
const tokens = tokenize(view.state.doc.toString());
let pageCount = 1;
let snippetCount = 0;
const tree = ensureSyntaxTree(view.state, view.state.doc.length, 50) || syntaxTree(view.state);
tree.iterate({
enter : (node)=>{
if(node.name === 'Image') {
const url = getUrl(node, view.state.doc);
const widgetPosition = node.node.lastChild.from;
//this is not exactly standard, but should hold,
//and is the shortest way i could find of positioning
//the image inside the cm-image node
if(!url) return;
decos.push(
Decoration.mark({
class : 'cm-image'
}).range(node.from, node.to)
);
decos.push(
Decoration.widget({
widget : new ImageWidget(url),
side : 1
}).range(widgetPosition)
);
}
}
});
tokens.forEach((token)=>{
const line = view.state.doc.line(token.line + 1);
if(token.from != null && token.to != null && token.from < token.to) {
const from = line.from + token.from;
const to = line.from + token.to;
const attrs = {};
if(token.type === 'Image' && token.url) {
attrs['data-url'] = token.url;
}
decos.push(
Decoration.mark({
class : `cm-${token.type}`,
...(Object.keys(attrs).length
? { attributes: attrs }
: {})
}).range(from, to)
);
} else {
decos.push(
Decoration.line({
class : `cm-${token.type}`
}).range(line.from)
);
if(token.type === 'pageLine' && tab === 'brewText') {
pageCount++;
if(line.from === 0) pageCount--;
decos.push(Decoration.line({ attributes: { 'data-page-number': pageCount } }).range(line.from));
}
if(token.type === 'snippetLine' && tab === 'brewSnippets') {
snippetCount++;
decos.push(Decoration.line({ attributes: { 'data-page-number': snippetCount } }).range(line.from));
}
}
});
decos.sort((a, b)=>a.from - b.from || a.to - b.to);
return Decoration.set(decos);
}
},
{ decorations: (v)=>v.decorations }
);
};
@@ -0,0 +1,204 @@
/* eslint max-lines: ["error", { "max": 300 }] */
import { keymap } from '@codemirror/view';
import { undo, redo, indentMore, deleteLine } from '@codemirror/commands';
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 { 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);
const match = line.text.match(/^ {1,2}/); // match up to 2 spaces
if(match) {
lines.push({ from: line.from, to: line.from + match[0].length, insert: '' });
}
}
if(lines.length > 0) view.dispatch({ changes: lines });
return true;
};
const wrapSelection = (prefix, suffix)=>(view)=>{
const changes = [];
for (const range of view.state.selection.ranges) {
const { from, to } = range;
const selected = view.state.doc.sliceString(from, to);
let text;
if(from === to) { text = prefix + suffix; } else if(selected.startsWith(prefix) && selected.endsWith(suffix)) {
text = selected.slice(prefix.length, -suffix.length);
} else {text = `${prefix}${selected}${suffix}`;}
changes.push({ from, to, insert: text });
}
view.dispatch({
changes
});
return true;
};
const makeNbsp = (view)=>{
const { from } = view.state.selection.main;
const prev2 = from >= 2
? view.state.doc.sliceString(from - 2, from)
: '';
const insert = (prev2 === ':>' || prev2 === '>>') ? '>' : ':>';
view.dispatch({
changes : { from, to: from, insert },
selection : { anchor: from + insert.length },
});
return true;
};
const makeSpace = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
const match = selected.match(/^{{width:(\d+)% }}$/);
let newText = '{{width:10% }}';
if(match) {
const percent = Math.min(parseInt(match[1], 10) + 10, 100);
newText = `{{width:${percent}% }}`;
}
view.dispatch({ changes: { from, to, insert: newText } });
return true;
};
const removeSpace = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
const match = selected.match(/^{{width:(\d+)% }}$/);
if(match) {
const percent = parseInt(match[1], 10) - 10;
const newText = percent > 0 ? `{{width:${percent}% }}` : '';
view.dispatch({ changes: { from, to, insert: newText } });
}
return true;
};
const makeSpan = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
const text = selected.startsWith('{{') && selected.endsWith('}}')
? selected.slice(2, -2)
: `{{${selected}}}`;
view.dispatch({ changes: { from, to, insert: text } });
return true;
};
const makeDiv = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
const text = selected.startsWith('{{') && selected.endsWith('}}')
? selected.slice(2, -2)
: `{{\n${selected}\n}}`;
view.dispatch({ changes: { from, to, insert: text } });
return true;
};
const makeComment = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
const isHtmlComment = selected.startsWith('<!--') && selected.endsWith('-->');
const text = isHtmlComment
? selected.slice(4, -3)
: `<!-- ${selected} -->`;
view.dispatch({ changes: { from, to, insert: text } });
return true;
};
const makeLink = (view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to).trim();
const isLink = /^\[(.*)\]\((.*)\)$/.exec(selected);
const text = isLink ? `${isLink[1]} ${isLink[2]}` : `[${selected || 'alt text'}](url)`;
view.dispatch({ changes: { from, to, insert: text } });
return true;
};
const makeList = (type)=>(view)=>{
const { from, to } = view.state.selection.main;
const lines = [];
for (let l = from; l <= to; l++) {
const lineText = view.state.doc.line(l + 1).text;
lines.push(lineText);
}
const joined = lines.join('\n');
let newText;
if(type === 'UL') newText = joined.replace(/^/gm, '- ');
else newText = joined.replace(/^/gm, (m, i)=>`${i + 1}. `);
view.dispatch({ changes: { from, to, insert: newText } });
return true;
};
const makeHeader = (level)=>(view)=>{
const { from, to } = view.state.selection.main;
const selected = view.state.doc.sliceString(from, to);
const text = `${'#'.repeat(level)} ${selected}`;
view.dispatch({ changes: { from, to, insert: text } });
return true;
};
const newColumn = (view)=>{
const { from, to } = view.state.selection.main;
view.dispatch({ changes: { from, to, insert: '\n\\column\n\n' } });
return true;
};
const newPage = (view)=>{
const { from, to } = view.state.selection.main;
view.dispatch({ changes: { from, to, insert: '\n\\page\n\n' } });
return true;
};
export const generalKeymap = Prec.high(keymap.of([
{ key: 'Tab', run: insertTab },
{ key: 'Mod-z', run: undo }, //i think it may be unnecessary
{ key: 'Mod-Shift-z', run: redo },
{ key: 'Mod-y', run: redo },
{ key: 'Mod-d', run: deleteLine },
]));
export const markdownKeymap = Prec.highest(keymap.of([
//{ key: 'Shift-Tab', run: indentMore },
{ key: 'Shift-Tab', run: indentLess },
{ key: 'Mod-b', run: wrapSelection('**', '**') }, // makeBold
{ key: 'Mod-i', run: wrapSelection('*', '*') }, // makeItalic
{ key: 'Mod-u', run: wrapSelection('<u>', '</u>') }, // makeUnderline
{ key: 'Shift-Mod-=', run: wrapSelection('^', '^') }, // makeSuper
{ key: 'Mod-=', run: wrapSelection('^^', '^^') }, // makeSub
{ key: 'Mod-.', run: makeNbsp },
{ key: 'Shift-Mod-.', run: makeSpace },
{ key: 'Shift-Mod-,', run: removeSpace },
{ key: 'Mod-m', run: makeSpan },
{ key: 'Shift-Mod-m', run: makeDiv },
{ key: 'Mod-/', run: makeComment },
{ key: 'Mod-k', run: makeLink },
{ key: 'Mod-l', run: makeList('UL') },
{ key: 'Shift-Mod-l', run: makeList('OL') },
{ key: 'Shift-Mod-1', run: makeHeader(1) },
{ key: 'Shift-Mod-2', run: makeHeader(2) },
{ key: 'Shift-Mod-3', run: makeHeader(3) },
{ key: 'Shift-Mod-4', run: makeHeader(4) },
{ key: 'Shift-Mod-5', run: makeHeader(5) },
{ key: 'Shift-Mod-6', run: makeHeader(6) },
{ key: 'Mod-Enter', run: newPage },
{ key: 'Shift-Mod-Enter', run: newColumn },
]));
@@ -0,0 +1,54 @@
import { HighlightStyle } from '@codemirror/language';
import { tags } from '@lezer/highlight';
const customTags = {
pageLine : 'pageLine', // .cm-pageLine
snippetLine : 'snippetLine', // .cm-snippetLine
};
export function legacyTokenizeCustomMarkdown(text) {
const tokens = [];
const lines = text.split('\n');
lines.forEach((lineText, lineNumber)=>{
// --- Page / snippet lines ---
if(/^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m.test(lineText)) tokens.push({ line: lineNumber, type: customTags.pageLine });
if(/^\\snippet\ .*$/.test(lineText)) tokens.push({ line: lineNumber, type: customTags.snippetLine });
});
return tokens;
}
export const legacyCustomHighlightStyle = HighlightStyle.define([
{ tag: tags.heading, class: 'cm-header' },
{ tag: tags.heading1, class: 'cm-header cm-header-1' },
{ tag: tags.heading2, class: 'cm-header cm-header-2' },
{ tag: tags.heading3, class: 'cm-header cm-header-3' },
{ tag: tags.heading4, class: 'cm-header cm-header-4' },
{ tag: tags.heading5, class: 'cm-header cm-header-5' },
{ tag: tags.heading6, class: 'cm-header cm-header-6' },
{ tag: tags.link, class: 'cm-link' },
{ tag: tags.string, class: 'cm-string' },
{ tag: tags.url, class: 'cm-string cm-url' },
{ tag: tags.list, class: 'cm-list' },
{ tag: tags.strong, class: 'cm-strong' },
{ tag: tags.emphasis, class: 'cm-em' },
{ tag: tags.quote, class: 'cm-quote' },
//css tags
{ tag: tags.tagName, class: 'cm-tag' },
{ tag: tags.className, class: 'cm-class' },
{ tag: tags.propertyName, class: 'cm-property' },
{ tag: tags.attributeValue, class: 'cm-value' },
{ tag: tags.keyword, class: 'cm-keyword' },
{ tag: tags.atom, class: 'cm-atom' },
{ tag: tags.integer, class: 'cm-integer' },
{ tag: tags.unit, class: 'cm-unit' },
{ tag: tags.color, class: 'cm-color' },
{ tag: tags.paren, class: 'cm-paren' },
{ tag: tags.variableName, class: 'cm-variable' },
{ tag: tags.invalid, class: 'cm-error' },
{ tag: tags.comment, class: 'cm-comment' },
]);
-44
View File
@@ -1,44 +0,0 @@
export default {
registerHomebreweryHelper : function(CodeMirror) {
CodeMirror.registerHelper('fold', 'homebrewerycss', function(cm, start) {
// BRACE FOLDING
const startMatcher = /\{[ \t]*$/;
const endMatcher = /\}[ \t]*$/;
const activeLine = cm.getLine(start.line);
if(activeLine.match(startMatcher)) {
const lastLineNo = cm.lastLine();
let end = start.line + 1;
let braceCount = 1;
while (end < lastLineNo) {
const curLine = cm.getLine(end);
if(curLine.match(startMatcher)) braceCount++;
if(curLine.match(endMatcher)) braceCount--;
if(braceCount == 0) break;
++end;
}
return {
from : CodeMirror.Pos(start.line, 0),
to : CodeMirror.Pos(end, cm.getLine(end).length)
};
}
// @import and data-url folding
const importMatcher = /^@import.*?;/;
const dataURLMatcher = /url\(.*?data\:.*\)/;
if(activeLine.match(importMatcher) || activeLine.match(dataURLMatcher)) {
return {
from : CodeMirror.Pos(start.line, 0),
to : CodeMirror.Pos(start.line, activeLine.length)
};
}
return null;
});
}
};
@@ -1,26 +0,0 @@
export default {
registerHomebreweryHelper : function(CodeMirror) {
CodeMirror.registerHelper('fold', 'homebrewery', function(cm, start) {
const matcher = /^\\page.*/;
const prevLine = cm.getLine(start.line - 1);
if(start.line === cm.firstLine() || prevLine.match(matcher)) {
const lastLineNo = cm.lastLine();
let end = start.line;
while (end < lastLineNo) {
if(cm.getLine(end + 1).match(matcher))
break;
++end;
}
return {
from : CodeMirror.Pos(start.line, 0),
to : CodeMirror.Pos(end, cm.getLine(end).length)
};
}
return null;
});
}
};
+6 -5
View File
@@ -11,16 +11,17 @@ const Combobox = createReactClass({
trigger : 'hover', trigger : 'hover',
default : '', default : '',
placeholder : '', placeholder : '',
tooltip: '', tooltip : '',
autoSuggest : { autoSuggest : {
clearAutoSuggestOnClick : true, clearAutoSuggestOnClick : true,
suggestMethod : 'includes', suggestMethod : 'includes',
filterOn : [] // should allow as array to filter on multiple attributes, or even custom filter filterOn : [] // should allow as array to filter on multiple attributes, or even custom filter
}, },
valuePatterns: /.+/ valuePatterns : /.+/
}; };
}, },
getInitialState : function() { getInitialState : function() {
this.dropdownRef = React.createRef();
return { return {
showDropdown : false, showDropdown : false,
value : '', value : '',
@@ -41,7 +42,7 @@ const Combobox = createReactClass({
}, },
handleClickOutside : function(e){ handleClickOutside : function(e){
// Close dropdown when clicked outside // Close dropdown when clicked outside
if(this.refs.dropdown && !this.refs.dropdown.contains(e.target)) { if(this.dropdownRef.current && !this.dropdownRef.current.contains(e.target)) {
this.handleDropdown(false); this.handleDropdown(false);
} }
}, },
@@ -88,7 +89,7 @@ const Combobox = createReactClass({
} }
}} }}
onKeyDown={(e)=>{ onKeyDown={(e)=>{
if (e.key === "Enter") { if(e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
this.props.onEntry(e); this.props.onEntry(e);
} }
@@ -128,7 +129,7 @@ const Combobox = createReactClass({
}); });
return ( return (
<div className={`dropdown-container ${this.props.className}`} <div className={`dropdown-container ${this.props.className}`}
ref='dropdown' ref={this.dropdownRef}
onMouseLeave={this.props.trigger == 'hover' ? ()=>{this.handleDropdown(false);} : undefined}> onMouseLeave={this.props.trigger == 'hover' ? ()=>{this.handleDropdown(false);} : undefined}>
{this.renderTextInput()} {this.renderTextInput()}
{this.renderDropdown(dropdownChildren)} {this.renderDropdown(dropdownChildren)}
+123
View File
@@ -0,0 +1,123 @@
/**
* A dropdown menu component that uses the Anchor Positioning API to position the elements. It supports nested submenus as well.
* Anchor Positioning is now supported in all major browsers. A polyfill is conditionally loaded for older browsers.
*
* As-is, the menus will always open down aligned on left to trigger, submenus open to the right initially.
* If no space, menus will still open down, but aligned to the right of the trigger. Submenus will flip to the other side of the top menu.
* This could be customized either in more specific CSS, or as a `direction` prop on the component (in future iterations).
*
* @param {string} props.groupName - Name of the menu. Appears as the trigger text.
* @param {string} [props.icon] - Icon to display in the trigger.
* @param {string} [props.color] - Color class to add to the trigger.
* @param {string} [props.className] - Additional classes for the menu wrapper.
* @param {React.ReactNode} [props.customTrigger] - Custom element to use as a trigger.
* @param {React.ReactNode} [props.children] - Child elements to render in the menu.
* @returns {React.JSX.Element}
*/
import './dropdown.less';
import React, { useEffect, useId, useRef } from 'react';
import _ from 'lodash';
// use react context to keep track of the menu depth (menus in menus)
const MenuDepthContext = React.createContext(0);
const Dropdown = ({ groupName, className = null, icon, children, color = null, customTrigger, ...props })=>{
const reactId = useId();
const safeId = reactId.replace(/[^a-zA-Z0-9_-]/g, '');
const menuId = `${_.kebabCase(groupName)}-${safeId}-menu`;
const anchorName = `--${menuId}`;
const depth = React.useContext(MenuDepthContext);
// A menu is a submenu if depth > 0
const isSubMenu = depth > 0;
const triggerRef = useRef(null);
const menuRef = useRef(null);
// use setAttribute instead of the React style prop because React strips unknown CSS
// properties (like anchor-name) from inline styles in browsers that don't support them.
// setAttribute writes raw CSS text that the anchor positioning polyfill can read
useEffect(()=>{
triggerRef.current?.setAttribute('style', `anchor-name: ${anchorName}`);
menuRef.current?.setAttribute('style', `position-anchor: ${anchorName}`);
}, [anchorName]);
// hide popover with click inside iframe (not supported by light dismiss)
useEffect(()=>{
const menuElement = document.getElementById(menuId);
if(!menuElement) return;
const handleClick = ()=>{
if(menuElement.matches(':popover-open')) {
menuElement.hidePopover();
}
};
// Listen for clicks from both the main document and the iframe
document.addEventListener('iframe-click', handleClick);
return ()=>{
document.removeEventListener('iframe-click', handleClick);
};
}, [menuId]);
// the trigger is the piece placed inside the opening button of the menu.
// This method allows for creating a generic span with the group name,
// or using a bespoke element (like a graphic) passed in from props to be used as the trigger
const trigger = (groupName = 'menu', icon = '')=>{
if(!customTrigger){
return <>
<i className={icon}></i><span className='menu-name'>{groupName}</span><i className={`caret fas fa-caret-${isSubMenu ? 'right' : 'down'}`}></i>
</>;
} else {
return customTrigger;
}
};
// handle clicks on menu items. By default, actions do dismiss.
const handleMenuActionClick = (event)=>{
const menuElement = menuRef.current;
if(!menuElement) return;
const menuAction = event.target.closest('button, a, [role="menuitem"]');
if(!menuAction || !menuElement.contains(menuAction)) return;
// don't dismiss if the target triggers a submenu
if(menuAction.hasAttribute('popovertarget')) return;
// don't dismiss if the target has `no-dismiss` attribute
const noDismissValue = menuAction.getAttribute('no-dismiss')?.toLowerCase();
if(noDismissValue === '' || noDismissValue === 'true') return;
document.querySelectorAll('.menu-list:popover-open').forEach((openMenu)=>openMenu.hidePopover());
};
return (
<div className={['menu-wrapper', className].join(' ')} role='none' >
<button
id={`${menuId}-trigger`}
className={['menu-item', color].join(' ')}
popoverTarget={menuId}
aria-haspopup='menu'
role='menuitem'
disabled={!React.Children.count(children)}
ref={triggerRef}
>
{trigger(groupName, icon)}
</button>
<MenuDepthContext.Provider value={depth + 1}>
<div
ref={menuRef}
id={menuId}
className='menu-list'
popover='auto'
role='menu'
onClick={handleMenuActionClick}
>
{children}
</div>
</MenuDepthContext.Provider>
</div>
);
};
export { Dropdown };
+24
View File
@@ -0,0 +1,24 @@
.menu-wrapper {
position: relative;
&:is(.menu-bar > .menu-section > .menu-wrapper){
display: inline-block;
}
}
.menu-list {
position : fixed;
z-index : 1000;
top : anchor(bottom);
left : anchor(left);
position-try: flip-inline flip-block;
color: inherit; // [popover] gets a `canvastext` color value from useragent.
> .menu-wrapper {
position:relative;
> .menu-list {
margin: 0 0px;
top : anchor(top);
left : anchor(right);
position-try: flip-inline;
}
}
}
+5 -3
View File
@@ -18,8 +18,7 @@ const SplitPane = (props)=>{
const [liveScroll, setLiveScroll] = useState(false); const [liveScroll, setLiveScroll] = useState(false);
useEffect(()=>{ useEffect(()=>{
const savedPos = window.localStorage.getItem(PANE_WIDTH_KEY); handleResize();
setDividerPos(savedPos ? limitPosition(savedPos, 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13)) : window.innerWidth / 2);
setLiveScroll(window.localStorage.getItem(LIVE_SCROLL_KEY) === 'true'); setLiveScroll(window.localStorage.getItem(LIVE_SCROLL_KEY) === 'true');
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
@@ -29,7 +28,10 @@ const SplitPane = (props)=>{
const limitPosition = (x, min = 1, max = window.innerWidth - 13)=>Math.round(Math.min(max, Math.max(min, x))); const limitPosition = (x, min = 1, max = window.innerWidth - 13)=>Math.round(Math.min(max, Math.max(min, x)));
//when resizing, the divider should grow smaller if less space is given, then grow back if the space is restored, to the original position //when resizing, the divider should grow smaller if less space is given, then grow back if the space is restored, to the original position
const handleResize = ()=>setDividerPos(limitPosition(window.localStorage.getItem(PANE_WIDTH_KEY), 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13))); const handleResize = ()=>{
const savedPos = window.localStorage.getItem(PANE_WIDTH_KEY);
setDividerPos(savedPos ? limitPosition(savedPos, 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13)) : window.innerWidth / 2);
};
const handleUp =(e)=>{ const handleUp =(e)=>{
e.preventDefault(); e.preventDefault();
+37 -8
View File
@@ -11,7 +11,7 @@ import ErrorBar from './errorBar/errorBar.jsx';
import ToolBar from './toolBar/toolBar.jsx'; import ToolBar from './toolBar/toolBar.jsx';
//TODO: move to the brew renderer //TODO: move to the brew renderer
import RenderWarnings from '../../components/renderWarnings/renderWarnings.jsx'; import RenderWarnings from '@components/renderWarnings/renderWarnings.jsx';
import NotificationPopup from './notificationPopup/notificationPopup.jsx'; import NotificationPopup from './notificationPopup/notificationPopup.jsx';
import Frame from 'react-frame-component'; import Frame from 'react-frame-component';
import dedent from 'dedent'; import dedent from 'dedent';
@@ -29,11 +29,11 @@ const TOOLBAR_STATE_KEY = 'HB_renderer_toolbarState';
const INITIAL_CONTENT = dedent` const INITIAL_CONTENT = dedent`
<!DOCTYPE html><html><head> <!DOCTYPE html><html><head>
<link href="//fonts.googleapis.com/css?family=Open+Sans:400,300,600,700" rel="stylesheet" type="text/css" /> <title>Rendered Brew Content</title>
<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>`;
@@ -42,6 +42,7 @@ const BrewPage = (props)=>{
props = { props = {
contents : '', contents : '',
index : 0, index : 0,
hoisted : false,
...props ...props
}; };
const pageRef = useRef(null); const pageRef = useRef(null);
@@ -91,6 +92,7 @@ const BrewPage = (props)=>{
//v=====--------------------< Brew Renderer Component >-------------------=====v// //v=====--------------------< Brew Renderer Component >-------------------=====v//
let renderedPages = []; let renderedPages = [];
let pageTemplates = [];
let rawPages = []; let rawPages = [];
const BrewRenderer = (props)=>{ const BrewRenderer = (props)=>{
@@ -135,6 +137,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);
@@ -207,6 +210,20 @@ const BrewRenderer = (props)=>{
styles = _.mapKeys(styles, (v, k)=>k.startsWith('--') ? k : _.camelCase(k)); // Convert CSS to camelCase for React styles = _.mapKeys(styles, (v, k)=>k.startsWith('--') ? k : _.camelCase(k)); // Convert CSS to camelCase for React
classes = [classes, injectedTags.classes].join(' ').trim(); classes = [classes, injectedTags.classes].join(' ').trim();
attributes = injectedTags.attributes; attributes = injectedTags.attributes;
if(global.enablev4) {
if(attributes && Object.hasOwn(attributes, 'hbtemplate')) {
pageTemplates[index] = attributes['hbtemplate'];
}
}
}
if(global.enablev4) {
// If we don't have a template for this page, look backwards until one is found or the first page.
if(!pageTemplates[index]) {
for (let i=index;i>=0; i--) {
// If one is found, add the template attribute
if(pageTemplates[i]) attributes['hbtemplate'] = pageTemplates[i];
}
}
} }
pageText = pageText.includes('\n') ? pageText.substring(pageText.indexOf('\n') + 1) : ''; // Remove the \page line pageText = pageText.includes('\n') ? pageText.substring(pageText.indexOf('\n') + 1) : ''; // Remove the \page line
} }
@@ -220,22 +237,31 @@ 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;
if(rawPages.length != renderedPages.length) // Re-render all pages when page count changes if(rawPages.length != renderedPages.length) { // Re-render all pages when page count changes
renderedPages.length = 0; renderedPages.length = 0;
pageTemplates.length = 0;
}
// Render currently-edited page first so cross-page effects (variables, links) can propagate out first // Render currently-edited page first so cross-page effects (variables, links) can propagate out first
if(rawPages.length > props.currentEditorCursorPageNum -1) if(rawPages.length > props.currentEditorCursorPageNum -1)
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;
}; };
@@ -272,8 +298,10 @@ 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(true); //Make sure page is renderable before showing
setState((prevState)=>({ setState((prevState)=>({
...prevState, ...prevState,
isMounted : true, isMounted : true,
@@ -321,10 +349,11 @@ const BrewRenderer = (props)=>{
<ToolBar displayOptions={displayOptions} onDisplayOptionsChange={handleDisplayOptionsChange} visiblePages={state.visiblePages.length > 0 ? state.visiblePages : [state.centerPage]} totalPages={rawPages.length} headerState={headerState} setHeaderState={setHeaderState}/> <ToolBar displayOptions={displayOptions} onDisplayOptionsChange={handleDisplayOptionsChange} visiblePages={state.visiblePages.length > 0 ? state.visiblePages : [state.centerPage]} totalPages={rawPages.length} headerState={headerState} setHeaderState={setHeaderState}/>
{/*render in iFrame so broken code doesn't crash the site.*/} {/*render in iFrame so broken code doesn't crash the site.*/}
<Frame id='BrewRenderer' initialContent={INITIAL_CONTENT} <Frame id='BrewRenderer' title="Rendered Brew Content" initialContent={INITIAL_CONTENT}
style={{ width: '100%', height: '100%', visibility: state.visibility }} style={{ width: '100%', height: '100%', visibility: state.visibility }}
contentDidMount={frameDidMount} contentDidMount={frameDidMount}
onClick={()=>{emitClick();}} onClick={()=>{emitClick();}}
sandbox="allow-same-origin allow-modals allow-top-navigation"
> >
<div className='brewRenderer' <div className='brewRenderer'
onKeyDown={handleControlKeys} onKeyDown={handleControlKeys}
@@ -1,7 +1,7 @@
import './errorBar.less'; import './errorBar.less';
import React from 'react'; import React from 'react';
import Dialog from '../../../components/dialog.jsx'; import Dialog from '@components/dialog.jsx';
const DISMISS_BUTTON = <i className='fas fa-times dismiss' />; const DISMISS_BUTTON = <i className='fas fa-times dismiss' />;
@@ -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>;
@@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react';
import request from '../../utils/request-middleware.js'; import request from '../../utils/request-middleware.js';
import Markdown from '@shared/markdown.js'; import Markdown from '@shared/markdown.js';
import Dialog from '../../../components/dialog.jsx'; import Dialog from '@components/dialog.jsx';
const DISMISS_BUTTON = <i className='fas fa-times dismiss' />; const DISMISS_BUTTON = <i className='fas fa-times dismiss' />;
@@ -3,7 +3,7 @@ import './toolBar.less';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import _ from 'lodash'; import _ from 'lodash';
import { Anchored, AnchoredBox, AnchoredTrigger } from '../../../components/Anchored.jsx'; import { Anchored, AnchoredBox, AnchoredTrigger } from '@components/Anchored.jsx';
const MAX_ZOOM = 300; const MAX_ZOOM = 300;
const MIN_ZOOM = 10; const MIN_ZOOM = 10;
+55 -255
View File
@@ -4,16 +4,29 @@ import React from 'react';
import createReactClass from 'create-react-class'; import createReactClass from 'create-react-class';
import _ from 'lodash'; import _ from 'lodash';
import dedent from 'dedent'; import dedent from 'dedent';
import Markdown from '@shared/markdown.js';
import CodeEditor from '../../components/codeEditor/codeEditor.jsx'; import CodeEditor from '@components/codeEditor/codeEditor.jsx';
import SnippetBar from './snippetbar/snippetbar.jsx'; import SnippetBar from './snippetbar/snippetbar.jsx';
import MetadataEditor from './metadataEditor/metadataEditor.jsx'; 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 defaultCM5Theme from '@themes/codeMirror/default.js';
const SNIPPETBREAK_REGEX_V3 = /^\\snippet\ .*$/; import darkbrewery from '@themes/codeMirror/darkbrewery.js';
import cm5Themes from 'codemirror-5-themes';
const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery };
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 DEFAULT_STYLE_TEXT = dedent` const DEFAULT_STYLE_TEXT = dedent`
/*=======--- Example CSS styling ---=======*/ /*=======--- Example CSS styling ---=======*/
/* Any CSS here will apply to your document! */ /* Any CSS here will apply to your document! */
@@ -30,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',
@@ -72,23 +86,20 @@ const Editor = createReactClass({
componentDidMount : function() { componentDidMount : function() {
this.highlightCustomMarkdown(); const brewRenderer = document.getElementById('BrewRenderer');
document.getElementById('BrewRenderer').addEventListener('keydown', this.handleControlKeys); brewRenderer.onload = ()=>brewRenderer.contentDocument?.addEventListener('keydown', this.handleControlKeys);
document.addEventListener('keydown', this.handleControlKeys); document.addEventListener('keydown', this.handleControlKeys);
this.codeEditor.current.codeMirror?.on('cursorActivity', (cm)=>{this.updateCurrentCursorPage(cm.getCursor());});
this.codeEditor.current.codeMirror?.on('scroll', _.throttle(()=>{this.updateCurrentViewPage(this.codeEditor.current.getTopVisibleLine());}, 200));
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;
this.resizeObserver = new ResizeObserver(entries=>{ this.resizeObserver = new ResizeObserver((entries)=>{
const height = document.querySelector('.editor > .snippetBar').offsetHeight; const height = document.querySelector('.editor > .snippetBar').offsetHeight;
this.setState({ snippetBarHeight: height }); this.setState({ snippetBarHeight: height });
}); });
@@ -98,7 +109,6 @@ const Editor = createReactClass({
componentDidUpdate : function(prevProps, prevState, snapshot) { componentDidUpdate : function(prevProps, prevState, snapshot) {
this.highlightCustomMarkdown();
if(prevProps.moveBrew !== this.props.moveBrew) if(prevProps.moveBrew !== this.props.moveBrew)
this.brewJump(); this.brewJump();
@@ -132,22 +142,16 @@ const Editor = createReactClass({
} }
}, },
updateCurrentCursorPage : function(cursor) { updateCurrentCursorPage : function(pageNumber) {
const lines = this.props.brew.text.split('\n').slice(1, cursor.line + 1); 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(topScrollLine) { updateCurrentViewPage : function(pageNumber) {
const lines = this.props.brew.text.split('\n').slice(1, topScrollLine + 1); 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){
@@ -156,181 +160,12 @@ const Editor = createReactClass({
this.setState({ this.setState({
view : newView view : newView
}, ()=>{ }, ()=>{
this.codeEditor.current?.codeMirror?.focus(); this.codeEditor.current?.focus();
}); });
}, },
highlightCustomMarkdown : function(){
if(!this.codeEditor.current?.codeMirror) return;
if((this.state.view === 'text') ||(this.state.view === 'snippet')) {
const codeMirror = this.codeEditor.current.codeMirror;
codeMirror?.operation(()=>{ // Batch CodeMirror styling
const foldLines = [];
//reset custom text styles
const customHighlights = codeMirror?.getAllMarks().filter((mark)=>{
// Record details of folded sections
if(mark.__isFold) {
const fold = mark.find();
foldLines.push({ from: fold.from?.line, to: fold.to?.line });
}
return !mark.__isFold;
}); //Don't undo code folding
for (let i=customHighlights.length - 1;i>=0;i--) customHighlights[i].clear();
let userSnippetCount = 1; // start snippet count from snippet 1
let editorPageCount = 1; // start page count from page 1
const whichSource = this.state.view === 'text' ? this.props.brew.text : this.props.brew.snippets;
_.forEach(whichSource?.split('\n'), (line, lineNumber)=>{
const tabHighlight = this.state.view === 'text' ? 'pageLine' : 'snippetLine';
const textOrSnip = this.state.view === 'text';
//reset custom line styles
codeMirror?.removeLineClass(lineNumber, 'background', 'pageLine');
codeMirror?.removeLineClass(lineNumber, 'background', 'snippetLine');
codeMirror?.removeLineClass(lineNumber, 'text');
codeMirror?.removeLineClass(lineNumber, 'wrap', 'sourceMoveFlash');
// Don't process lines inside folded text
// If the current lineNumber is inside any folded marks, skip line styling
if(foldLines.some((fold)=>lineNumber >= fold.from && lineNumber <= fold.to))
return;
// Styling for \page breaks
if((this.props.renderer == 'legacy' && line.includes('\\page')) ||
(this.props.renderer == 'V3' && line.match(textOrSnip ? PAGEBREAK_REGEX_V3 : SNIPPETBREAK_REGEX_V3))) {
if((lineNumber > 0) && (textOrSnip)) // Since \page is optional on first line of document,
editorPageCount += 1; // don't use it to increment page count; stay at 1
else if(this.state.view !== 'text') userSnippetCount += 1;
// add back the original class 'background' but also add the new class '.pageline'
codeMirror?.addLineClass(lineNumber, 'background', tabHighlight);
const pageCountElement = Object.assign(document.createElement('span'), {
className : 'editor-page-count',
textContent : textOrSnip ? editorPageCount : userSnippetCount
});
codeMirror?.setBookmark({ line: lineNumber, ch: line.length }, pageCountElement);
};
// New CodeMirror styling for V3 renderer
if(this.props.renderer === 'V3') {
if(line.match(/^\\column(?:break)?$/)){
codeMirror?.addLineClass(lineNumber, 'text', 'columnSplit');
}
// definition lists
if(line.includes('::')){
if(/^:*$/.test(line) == true){ return; };
const regex = /^([^\n]*?:?\s?)(::[^\n]*)(?:\n|$)/ymd; // the `d` flag, for match indices, throws an ESLint error.
let match;
while ((match = regex.exec(line)) != null){
codeMirror?.markText({ line: lineNumber, ch: match.indices[0][0] }, { line: lineNumber, ch: match.indices[0][1] }, { className: 'dl-highlight' });
codeMirror?.markText({ line: lineNumber, ch: match.indices[1][0] }, { line: lineNumber, ch: match.indices[1][1] }, { className: 'dt-highlight' });
codeMirror?.markText({ line: lineNumber, ch: match.indices[2][0] }, { line: lineNumber, ch: match.indices[2][1] }, { className: 'dd-highlight' });
const ddIndex = match.indices[2][0];
const colons = /::/g;
const colonMatches = colons.exec(match[2]);
if(colonMatches !== null){
codeMirror?.markText({ line: lineNumber, ch: colonMatches.index + ddIndex }, { line: lineNumber, ch: colonMatches.index + colonMatches[0].length + ddIndex }, { className: 'dl-colon-highlight' });
}
}
}
// Subscript & Superscript
if(line.includes('^')) {
let startIndex = line.indexOf('^');
const superRegex = /\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^/gy;
const subRegex = /\^\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^\^/gy;
while (startIndex >= 0) {
superRegex.lastIndex = subRegex.lastIndex = startIndex;
let isSuper = false;
const match = subRegex.exec(line) || superRegex.exec(line);
if(match) {
isSuper = !subRegex.lastIndex;
codeMirror?.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: isSuper ? 'superscript' : 'subscript' });
}
startIndex = line.indexOf('^', Math.max(startIndex + 1, subRegex.lastIndex, superRegex.lastIndex));
}
}
// Highlight injectors {style}
if(line.includes('{') && line.includes('}')){
const regex = /(?:^|[^{\n])({(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\2})/gm;
let match;
while ((match = regex.exec(line)) != null) {
codeMirror?.markText({ line: lineNumber, ch: line.indexOf(match[1]) }, { line: lineNumber, ch: line.indexOf(match[1]) + match[1].length }, { className: 'injection' });
}
}
// Highlight inline spans {{content}}
if(line.includes('{{') && line.includes('}}')){
const regex = /{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *|}}/g;
let match;
let blockCount = 0;
while ((match = regex.exec(line)) != null) {
if(match[0].startsWith('{')) {
blockCount += 1;
} else {
blockCount -= 1;
}
if(blockCount < 0) {
blockCount = 0;
continue;
}
codeMirror?.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: 'inline-block' });
}
} else if(line.trimLeft().startsWith('{{') || line.trimLeft().startsWith('}}')){
// Highlight block divs {{\n Content \n}}
let endCh = line.length+1;
const match = line.match(/^ *{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *$|^ *}}$/);
if(match)
endCh = match.index+match[0].length;
codeMirror?.markText({ line: lineNumber, ch: 0 }, { line: lineNumber, ch: endCh }, { className: 'block' });
}
// Emojis
if(line.match(/:[^\s:]+:/g)) {
let startIndex = line.indexOf(':');
const emojiRegex = /:[^\s:]+:/gy;
while (startIndex >= 0) {
emojiRegex.lastIndex = startIndex;
const match = emojiRegex.exec(line);
if(match) {
let tokens = Markdown.marked.lexer(match[0]);
tokens = tokens[0].tokens.filter((t)=>t.type == 'emoji');
if(!tokens.length)
return;
const startPos = { line: lineNumber, ch: match.index };
const endPos = { line: lineNumber, ch: match.index + match[0].length };
// Iterate over conflicting marks and clear them
const marks = codeMirror?.findMarks(startPos, endPos);
marks.forEach(function(marker) {
if(!marker.__isFold) marker.clear();
});
codeMirror?.markText(startPos, endPos, { className: 'emoji' });
}
startIndex = line.indexOf(':', Math.max(startIndex + 1, emojiRegex.lastIndex));
}
}
}
});
});
}
},
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
@@ -343,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);
@@ -371,54 +208,17 @@ 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 editor = this.codeEditor.current;
const textString = this.props.brew.text.split(textSplit).slice(0, targetPage-1).join(textSplit); if(!editor) return;
const targetLine = textString.match('\n') ? textString.split('\n').length - 1 : -1; jumpSource = 'source';
let currentY = this.codeEditor.current.codeMirror?.getScrollInfo().top; editor.scrollToPage(targetPage);
let targetY = this.codeEditor.current.codeMirror?.heightAtLine(targetLine, 'local', true); 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;
this.codeEditor.current.codeMirror?.off('scroll', checkIfScrollComplete);
}, 150); // If 150 ms pass without a scroll event, assume scrolling is done
};
isJumping = true;
checkIfScrollComplete();
if(this.codeEditor.current?.codeMirror) {
this.codeEditor.current.codeMirror?.on('scroll', checkIfScrollComplete);
}
if(smooth) {
//Scroll 1/10 of the way every 10ms until 1px off.
const incrementalScroll = setInterval(()=>{
currentY += (targetY - currentY) / 10;
this.codeEditor.current.codeMirror?.scrollTo(null, currentY);
// Update target: target height is not accurate until within +-10 lines of the visible window
if(Math.abs(targetY - currentY > 100))
targetY = this.codeEditor.current.codeMirror?.heightAtLine(targetLine, 'local', true);
// End when close enough
if(Math.abs(targetY - currentY) < 1) {
this.codeEditor.current.codeMirror?.scrollTo(null, targetY); // Scroll any remaining difference
this.codeEditor.current.setCursorPosition({ line: targetLine + 1, ch: 0 });
this.codeEditor.current.codeMirror?.addLineClass(targetLine + 1, 'wrap', 'sourceMoveFlash');
clearInterval(incrementalScroll);
}
}, 10);
} else {
this.codeEditor.current.codeMirror?.scrollTo(null, targetY); // Scroll any remaining difference
this.codeEditor.current.setCursorPosition({ line: targetLine + 1, ch: 0 });
this.codeEditor.current.codeMirror?.addLineClass(targetLine + 1, 'wrap', 'sourceMoveFlash');
}
}, },
//Called when there are changes to the editor's dimensions //Called when there are changes to the editor's dimensions
@@ -446,9 +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={(page)=>this.updateCurrentCursorPage(page)}
onViewChange={(page)=>this.updateCurrentViewPage(page)}
editorTheme={this.state.editorTheme} editorTheme={this.state.editorTheme}
rerenderParent={this.rerenderParent} renderer={this.props.brew.renderer}
style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }} /> style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }}/>
</>; </>;
} }
if(this.isStyle()){ if(this.isStyle()){
@@ -460,18 +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}
rerenderParent={this.rerenderParent} renderer={this.props.brew.renderer}
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}
@@ -492,8 +292,9 @@ const Editor = createReactClass({
onChange={this.props.onBrewChange('snippets')} onChange={this.props.onBrewChange('snippets')}
enableFolding={true} enableFolding={true}
editorTheme={this.state.editorTheme} editorTheme={this.state.editorTheme}
renderer={this.props.brew.renderer}
rerenderParent={this.rerenderParent} rerenderParent={this.rerenderParent}
style={{ height: `calc(100% -${this.state.snippetBarHeight}px)` }} /> style={{ height: `calc(100% - 25px)` }}/>
</>; </>;
} }
}, },
@@ -510,14 +311,13 @@ const Editor = createReactClass({
return this.codeEditor.current?.undo(); return this.codeEditor.current?.undo();
}, },
foldCode : function(){ foldCode : function() {
return this.codeEditor.current?.foldAllCode(); return this.codeEditor.current?.foldAll();
}, },
unfoldCode : function(){ unfoldCode : function() {
return this.codeEditor.current?.unfoldAllCode(); return this.codeEditor.current?.unfoldAll();
}, },
render : function(){ render : function(){
return ( return (
<div className='editor' ref={this.editor}> <div className='editor' ref={this.editor}>
+2 -81
View File
@@ -1,90 +1,11 @@
@import '@sharedStyles/core.less'; @import '@sharedStyles/core.less';
@import '@themes/codeMirror/customEditorStyles.less';
.editor { :where(.editor) {
position : relative; position : relative;
width : 100%; width : 100%;
height : 100%; height : 100%;
container : editor / inline-size; container : editor / inline-size;
background:white; background : white;
.codeEditor {
height : calc(100% - 25px);
.CodeMirror { height : 100%; }
.pageLine, .snippetLine {
background : #33333328;
border-top : #333399 solid 1px;
}
.editor-page-count {
float : right;
color : grey;
}
.editor-snippet-count {
float : right;
color : grey;
}
.columnSplit {
font-style : italic;
color : grey;
background-color : fade(#229999, 15%);
border-bottom : #229999 solid 1px;
}
.define {
&:not(.term):not(.definition) {
font-weight : bold;
color : #949494;
background : #E5E5E5;
border-radius : 3px;
}
&.term { color : rgb(96, 117, 143); }
&.definition { color : rgb(97, 57, 178); }
}
.block:not(.cm-comment) {
font-weight : bold;
color : purple;
//font-style: italic;
}
.inline-block:not(.cm-comment) {
font-weight : bold;
color : red;
//font-style: italic;
}
.injection:not(.cm-comment) {
font-weight : bold;
color : green;
}
.emoji:not(.cm-comment) {
padding-bottom : 1px;
margin-left : 2px;
font-weight : bold;
color : #360034;
outline : solid 2px #FF96FC;
outline-offset : -2px;
background : #FFC8FF;
border-radius : 6px;
}
.superscript:not(.cm-comment) {
font-size : 0.9em;
font-weight : bold;
vertical-align : super;
color : goldenrod;
}
.subscript:not(.cm-comment) {
font-size : 0.9em;
font-weight : bold;
vertical-align : sub;
color : rgb(123, 123, 15);
}
.dl-highlight {
&.dl-colon-highlight {
font-weight : bold;
color : #949494;
background : #E5E5E5;
border-radius : 3px;
}
&.dt-highlight { color : rgb(96, 117, 143); }
&.dd-highlight { color : rgb(97, 57, 178); }
}
}
.brewJump { .brewJump {
position : absolute; position : absolute;
@@ -4,7 +4,7 @@ import React from 'react';
import createReactClass from 'create-react-class'; import createReactClass from 'create-react-class';
import _ from 'lodash'; import _ from 'lodash';
import request from '../../utils/request-middleware.js'; import request from '../../utils/request-middleware.js';
import Combobox from '../../../components/combobox.jsx'; import Combobox from '@components/combobox.jsx';
import TagInput from '../tagInput/tagInput.jsx'; import TagInput from '../tagInput/tagInput.jsx';
@@ -386,9 +386,9 @@ const MetadataEditor = createReactClass({
{this.renderThumbnail()} {this.renderThumbnail()}
</div> </div>
<div className="field tags"> <div className='field tags'>
<label>Tags</label> <label>Tags</label>
<div className="value" > <div className='value' >
<TagInput <TagInput
label='tags' label='tags'
valuePatterns={/^\s*(?:(?:group|meta|system|type)\s*:\s*)?[A-Za-z0-9][A-Za-z0-9 \/\\.&_\-]{0,40}\s*$/} valuePatterns={/^\s*(?:(?:group|meta|system|type)\s*:\s*)?[A-Za-z0-9][A-Za-z0-9 \/\\.&_\-]{0,40}\s*$/}
@@ -411,9 +411,9 @@ const MetadataEditor = createReactClass({
{this.renderAuthors()} {this.renderAuthors()}
<div className="field invitedAuthors"> <div className='field invitedAuthors'>
<label>Invited authors</label> <label>Invited authors</label>
<div className="value"> <div className='value'>
<TagInput <TagInput
label='invited authors' label='invited authors'
valuePatterns={/.+/} valuePatterns={/.+/}
@@ -2,6 +2,7 @@
import './snippetbar.less'; import './snippetbar.less';
import React from 'react'; import React from 'react';
import createReactClass from 'create-react-class'; import createReactClass from 'create-react-class';
import { Dropdown } from '@components/dropdown/dropdown.jsx';
import _ from 'lodash'; import _ from 'lodash';
import cx from 'classnames'; import cx from 'classnames';
@@ -23,7 +24,25 @@ const ThemeSnippets = {
V3_Blank : V3_Blank, V3_Blank : V3_Blank,
}; };
import EditorThemes from '../../../../build/homebrew/codeMirror/editorThemes.json'; import defaultCM5Theme from '@themes/codeMirror/default.js';
import darkbrewery from '@themes/codeMirror/darkbrewery.js';
import cm5Themes from 'codemirror-5-themes';
const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery };
const themeNames = Object.entries(themes)
.filter(([name, value])=>Array.isArray(value) &&
!name.endsWith('Init') &&
!name.endsWith('Style')
)
.map(([name])=>name);
const EditorThemes = [
'default',
...themeNames
.filter((name)=>name !== 'default')
.sort((a, b)=>a.localeCompare(b))
];
const execute = function(val, props){ const execute = function(val, props){
if(_.isFunction(val)) return val(props); if(_.isFunction(val)) return val(props);
@@ -151,7 +170,7 @@ const Snippetbar = createReactClass({
this.props.updateEditorTheme(e.target.value); this.props.updateEditorTheme(e.target.value);
this.setState({ this.setState({
showThemeSelector : false, themeSelector : false,
}); });
}, },
@@ -232,11 +251,11 @@ const Snippetbar = createReactClass({
<i className='fas fa-clock-rotate-left' /> <i className='fas fa-clock-rotate-left' />
{ this.state.showHistory && this.renderHistoryItems() } { this.state.showHistory && this.renderHistoryItems() }
</div> </div>
<div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`} <div className={`editorTool undo ${this.props.historySize.done ? 'active' : ''}`}
onClick={this.props.undo} > onClick={this.props.undo} >
<i className='fas fa-undo' /> <i className='fas fa-undo' />
</div> </div>
<div className={`editorTool redo ${this.props.historySize.redo ? 'active' : ''}`} <div className={`editorTool redo ${this.props.historySize.undone ? 'active' : ''}`}
onClick={this.props.redo} > onClick={this.props.redo} >
<i className='fas fa-redo' /> <i className='fas fa-redo' />
</div> </div>
@@ -302,36 +321,33 @@ const SnippetGroup = createReactClass({
}; };
}, },
handleSnippetClick : function(e, snippet){ handleSnippetClick : function(e, snippet){
e.stopPropagation();
this.props.onSnippetClick(execute(snippet.gen, this.props)); this.props.onSnippetClick(execute(snippet.gen, this.props));
}, },
renderSnippets : function(snippets){ renderSnippets : function(snippets){
return _.map(snippets, (snippet)=>{ return _.map(snippets, (snippet)=>{
return <div className='snippet' key={snippet.name} onClick={(e)=>this.handleSnippetClick(e, snippet)}> if(!snippet.subsnippets){
return (
<button className='menu-item' key={snippet.name} onClick={(e)=>this.handleSnippetClick(e, snippet)} role='menuitem'>
<i className={snippet.icon} /> <i className={snippet.icon} />
<span className={`name${snippet.disabled ? ' disabled' : ''}`} title={snippet.name}>{snippet.name}</span> <span className={`name${snippet.disabled ? ' disabled' : ''}`} title={snippet.name}>{snippet.name}</span>
{snippet.experimental && <span className='beta'>beta</span>} {snippet.experimental && <span className='beta'>beta</span>}
{snippet.disabled && <span className='beta' title='temporarily disabled due to large slowdown; under re-design'>disabled</span>} {snippet.disabled && <span className='beta' title='temporarily disabled due to large slowdown; under re-design'>disabled</span>}
{snippet.subsnippets && <> </button>
<i className='fas fa-caret-right'></i> );
<div className='dropdown side'> } else if(snippet.subsnippets){
return (
<Dropdown groupName={snippet.name} icon={snippet.icon} key={snippet.name}>
{this.renderSnippets(snippet.subsnippets)} {this.renderSnippets(snippet.subsnippets)}
</div></>} </Dropdown>
</div>; )
}
}); });
}, },
render : function(){ render : function(){
const snippetGroup = `snippetGroup snippetBarButton ${this.props.snippets.length === 0 ? 'disabledSnippets' : ''}`; return <Dropdown groupName={this.props.groupName} id={this.props.groupName} icon={this.props.icon}>
return <div className={snippetGroup}>
<div className='text'>
<i className={this.props.icon} />
<span className='groupName'>{this.props.groupName}</span>
</div>
<div className='dropdown'>
{this.renderSnippets(this.props.snippets)} {this.renderSnippets(this.props.snippets)}
</div> </Dropdown>;
</div>;
}, },
}); });
@@ -11,6 +11,10 @@
height : auto; height : auto;
color : black; color : black;
background-color : #DDDDDD; background-color : #DDDDDD;
font-size : .65rem;
font-family: 'Open Sans', sans-serif;
text-transform: uppercase;
font-weight: 800;
.snippets { .snippets {
display : flex; display : flex;
@@ -22,7 +26,7 @@
display : flex; display : flex;
justify-content : flex-end; justify-content : flex-end;
min-width : 250px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied min-width : 250px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied
font-size: .85rem;
&:only-child {min-width : unset; margin-left : auto;} &:only-child {min-width : unset; margin-left : auto;}
>div { >div {
@@ -57,32 +61,27 @@
} }
&.undo { &.undo {
.tooltipLeft('Undo'); .tooltipLeft('Undo');
font-size : 0.75em;
color : grey; color : grey;
&.active { color : inherit; } &.active { color : inherit; }
} }
&.redo { &.redo {
.tooltipLeft('Redo'); .tooltipLeft('Redo');
font-size : 0.75em;
color : grey; color : grey;
&.active { color : inherit; } &.active { color : inherit; }
} }
&.foldAll { &.foldAll {
.tooltipLeft('Fold All'); .tooltipLeft('Fold All');
font-size : 0.75em;
color : grey; color : grey;
&.active { color : inherit; } &.active { color : inherit; }
} }
&.unfoldAll { &.unfoldAll {
.tooltipLeft('Unfold All'); .tooltipLeft('Unfold All');
font-size : 0.75em;
color : grey; color : grey;
&.active { color : inherit; } &.active { color : inherit; }
} }
&.history { &.history {
.tooltipLeft('History'); .tooltipLeft('History');
position : relative; position : relative;
font-size : 0.75em;
color : grey; color : grey;
border : none; border : none;
&.active { color : inherit; } &.active { color : inherit; }
@@ -93,7 +92,6 @@
} }
&.editorTheme { &.editorTheme {
.tooltipLeft('Editor Themes'); .tooltipLeft('Editor Themes');
font-size : 0.75em;
color : inherit; color : inherit;
&.active { &.active {
position : relative; position : relative;
@@ -144,37 +142,49 @@
border-left : 1px solid black; border-left : 1px solid black;
.tooltipLeft('Edit Brew Properties'); .tooltipLeft('Edit Brew Properties');
} }
.snippetGroup {
&:hover {
& > .dropdown { visibility : visible; } .menu-wrapper {
.menu-item:is(.snippets > .menu-wrapper > .menu-item):first-child {
.caret {
display: none;
} }
.dropdown { }
position : absolute; .menu-list {
top : 100%;
z-index : 1000;
visibility : hidden;
padding : 0px; padding : 0px;
margin-left : -5px;
background-color : #DDDDDD; background-color : #DDDDDD;
.snippet {
}
}
.menu-item {
position : relative; position : relative;
display : flex; display : flex;
justify-content: space-between;
align-items : center; align-items : center;
min-width : max-content; min-width : max-content;
padding : 5px; padding : 5px;
font-size : 10px;
cursor : pointer; cursor : pointer;
width: 100%;
.animate(background-color); .animate(background-color);
&:is(.menu-list .menu-item) [class*="name"] {
padding-inline: 8px;
}
.menu-name {
flex: 1;
text-align: left;
text-box-trim: trim-end;
}
i { i {
min-width : 25px; min-width : 25px;
height : 1.2em; height : .85rem;
margin-right : 8px;
font-size : 1.2em; font-size : 1.2em;
text-align : center; text-align : center;
& ~ i { &.caret {
margin-right : 0; margin-right: 0;
margin-left : 5px; }
&.caret:is(.menu-wrapper .menu-wrapper * ) {
text-align: right;
} }
/* Fonts */ /* Fonts */
&.font { &.font {
@@ -217,17 +227,11 @@
} }
&:hover { &:hover {
background-color : #999999; background-color : #999999;
& > .dropdown {
visibility : visible;
&.side {
top : 0%;
left : 100%;
margin-left : 0;
box-shadow : -1px 1px 2px 0px #999999;
}
}
}
} }
&:disabled {
color: gray;
cursor: not-allowed;
&:hover { background-color: unset; }
} }
} }
.disabledSnippets { .disabledSnippets {
@@ -1,210 +1,219 @@
export default [ export const tagSuggestionList = [
// ############################## Systems // ############################## Systems
// D&D // D&D
"system:D&D Original", 'system:D&D Original',
"system:D&D Basic", 'system:D&D Basic',
"system:AD&D 1e", 'system:AD&D 1e',
"system:AD&D 2e", 'system:AD&D 2e',
"system:D&D 3e", 'system:D&D 3e',
"system:D&D 3.5e", 'system:D&D 3.5e',
"system:D&D 4e", 'system:D&D 4e',
"system:D&D 5e", 'system:D&D 5e',
"system:D&D 5e 2024", 'system:D&D 5e 2024',
"system:BD&D (B/X)", 'system:BD&D (B/X)',
"system:D&D Essentials", 'system:D&D Essentials',
// Other Famous RPGs // Other Famous RPGs
"system:Pathfinder 1e", 'system:Pathfinder 1e',
"system:Pathfinder 2e", 'system:Pathfinder 2e',
"system:Vampire: The Masquerade", 'system:Vampire: The Masquerade',
"system:Werewolf: The Apocalypse", 'system:Werewolf: The Apocalypse',
"system:Mage: The Ascension", 'system:Mage: The Ascension',
"system:Call of Cthulhu", 'system:Call of Cthulhu',
"system:Shadowrun", 'system:Shadowrun',
"system:Star Wars RPG (D6/D20/Edge of the Empire)", 'system:Star Wars RPG (D6/D20/Edge of the Empire)',
"system:Warhammer Fantasy Roleplay", 'system:Warhammer Fantasy Roleplay',
"system:Cyberpunk 2020", 'system:Cyberpunk 2020',
"system:Blades in the Dark", 'system:Blades in the Dark',
"system:Daggerheart", 'system:Daggerheart',
"system:Draw Steel", 'system:Draw Steel',
"system:Mutants and Masterminds", 'system:Mutants and Masterminds',
// Meta // Meta
"meta:V3", 'meta:V3',
"meta:Legacy", 'meta:Legacy',
"meta:Template", 'meta:Template',
"meta:Theme", 'meta:Theme',
"meta:free", 'meta:free',
"meta:Character Sheet", 'meta:Character Sheet',
"meta:Documentation", 'meta:Documentation',
"meta:NPC", 'meta:NPC',
"meta:Guide", 'meta:Guide',
"meta:Resource", 'meta:Resource',
"meta:Notes", 'meta:Notes',
"meta:Example", 'meta:Example',
// Book type // Book type
"type:Campaign", 'type:Campaign',
"type:Campaign Setting", 'type:Campaign Setting',
"type:Adventure", 'type:Adventure',
"type:One-Shot", 'type:One-Shot',
"type:Setting", 'type:Setting',
"type:World", 'type:World',
"type:Lore", 'type:Lore',
"type:History", 'type:History',
"type:Dungeon Master", 'type:Dungeon Master',
"type:Encounter Pack", 'type:Encounter Pack',
"type:Encounter", 'type:Encounter',
"type:Session Notes", 'type:Session Notes',
"type:reference", 'type:reference',
"type:Handbook", 'type:Handbook',
"type:Manual", 'type:Manual',
"type:Manuals", 'type:Manuals',
"type:Compendium", 'type:Compendium',
"type:Bestiary", 'type:Bestiary',
// ###################################### RPG Keywords // ###################################### RPG Keywords
// Classes / Subclasses / Archetypes // Classes / Subclasses / Archetypes
"Class", 'Class',
"Subclass", 'Subclass',
"Archetype", 'Archetype',
"Martial", 'Martial',
"Half-Caster", 'Half-Caster',
"Full Caster", 'Full Caster',
"Artificer", 'Artificer',
"Barbarian", 'Barbarian',
"Bard", 'Bard',
"Cleric", 'Cleric',
"Druid", 'Druid',
"Fighter", 'Fighter',
"Monk", 'Monk',
"Paladin", 'Paladin',
"Rogue", 'Rogue',
"Sorcerer", 'Sorcerer',
"Warlock", 'Warlock',
"Wizard", 'Wizard',
// Races / Species / Lineages // Races / Species / Lineages
"Race", 'Race',
"Ancestry", 'Ancestry',
"Lineage", 'Lineage',
"Aasimar", 'Aasimar',
"Beastfolk", 'Beastfolk',
"Dragonborn", 'Dragonborn',
"Dwarf", 'Dwarf',
"Elf", 'Elf',
"Goblin", 'Goblin',
"Half-Elf", 'Half-Elf',
"Half-Orc", 'Half-Orc',
"Human", 'Human',
"Kobold", 'Kobold',
"Lizardfolk", 'Lizardfolk',
"Lycan", 'Lycan',
"Orc", 'Orc',
"Tiefling", 'Tiefling',
"Vampire", 'Vampire',
"Yuan-Ti", 'Yuan-Ti',
// Magic / Spells / Items // Magic / Spells / Items
"Magic", 'Magic',
"Magic Item", 'Magic Item',
"Magic Items", 'Magic Items',
"Wondrous Item", 'Wondrous Item',
"Magic Weapon", 'Magic Weapon',
"Artifact", 'Artifact',
"Spell", 'Spell',
"Spells", 'Spells',
"Cantrip", 'Cantrip',
"Cantrips", 'Cantrips',
"Eldritch", 'Eldritch',
"Eldritch Invocation", 'Eldritch Invocation',
"Invocation", 'Invocation',
"Invocations", 'Invocations',
"Pact boon", 'Pact boon',
"Pact Boon", 'Pact Boon',
"Spellcaster", 'Spellcaster',
"Spellblade", 'Spellblade',
"Magical Tattoos", 'Magical Tattoos',
"Enchantment", 'Enchantment',
"Enchanted", 'Enchanted',
"Attunement", 'Attunement',
"Requires Attunement", 'Requires Attunement',
"Rune", 'Rune',
"Runes", 'Runes',
"Wand", 'Wand',
"Rod", 'Rod',
"Scroll", 'Scroll',
"Potion", 'Potion',
"Potions", 'Potions',
"Item", 'Item',
"Items", 'Items',
"Bag of Holding", 'Bag of Holding',
// Monsters / Creatures / Enemies // Monsters / Creatures / Enemies
"Monster", 'Monster',
"Creatures", 'Creatures',
"Creature", 'Creature',
"Beast", 'Beast',
"Beasts", 'Beasts',
"Humanoid", 'Humanoid',
"Undead", 'Undead',
"Fiend", 'Fiend',
"Aberration", 'Aberration',
"Ooze", 'Ooze',
"Giant", 'Giant',
"Dragon", 'Dragon',
"Monstrosity", 'Monstrosity',
"Demon", 'Demon',
"Devil", 'Devil',
"Elemental", 'Elemental',
"Construct", 'Construct',
"Constructs", 'Constructs',
"Boss", 'Boss',
"BBEG", 'BBEG',
// ############################# Media / Pop Culture // ############################# Media / Pop Culture
"One Piece", 'One Piece',
"Dragon Ball", 'Dragon Ball',
"Dragon Ball Z", 'Dragon Ball Z',
"Naruto", 'Naruto',
"Jujutsu Kaisen", 'Jujutsu Kaisen',
"Fairy Tail", 'Fairy Tail',
"Final Fantasy", 'Final Fantasy',
"Kingdom Hearts", 'Kingdom Hearts',
"Elder Scrolls", 'Elder Scrolls',
"Skyrim", 'Skyrim',
"WoW", 'WoW',
"World of Warcraft", 'World of Warcraft',
"Marvel Comics", 'Marvel Comics',
"DC Comics", 'DC Comics',
"Pokemon", 'Pokemon',
"League of Legends", 'League of Legends',
"Runeterra", 'Runeterra',
"Arcane", 'Arcane',
"Yu-Gi-Oh", 'Yu-Gi-Oh',
"Minecraft", 'Minecraft',
"Don't Starve", 'Don\'t Starve',
"Witcher", 'Witcher',
"Witcher 3", 'Witcher 3',
"Cyberpunk", 'Cyberpunk',
"Cyberpunk 2077", 'Cyberpunk 2077',
"Fallout", 'Fallout',
"Divinity Original Sin 2", 'Divinity Original Sin 2',
"Fullmetal Alchemist", 'Fullmetal Alchemist',
"Fullmetal Alchemist Brotherhood", 'Fullmetal Alchemist Brotherhood',
"Lobotomy Corporation", 'Lobotomy Corporation',
"Bloodborne", 'Bloodborne',
"Dragonlance", 'Dragonlance',
"Shackled City Adventure Path", 'Shackled City Adventure Path',
"Baldurs Gate 3", 'Baldurs Gate 3',
"Library of Ruina", 'Library of Ruina',
"Radiant Citadel", 'Radiant Citadel',
"Ravenloft", 'Ravenloft',
"Forgotten Realms", 'Forgotten Realms',
"Exandria", 'Exandria',
"Critical Role", 'Critical Role',
"Star Wars", 'Star Wars',
"SW5e", 'SW5e',
"Star Wars 5e", 'Star Wars 5e',
];
// substrings to be normalized to the first value on the array
export const canonizationList = [
['5e 2024', '5.5e', '5e\'24', '5.24', '5e24', '5.5'],
['5e', '5th Edition'],
['Dungeons & Dragons', 'Dungeons and Dragons', 'Dungeons n dragons'],
['D&D', 'DnD', 'dnd', 'Dnd', 'dnD', 'd&d', 'd&D', 'D&d'],
['P2e', 'p2e', 'P2E', 'Pathfinder 2e'],
]; ];
+74 -87
View File
@@ -1,71 +1,62 @@
import "./tagInput.less"; import './tagInput.less';
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from 'react';
import Combobox from "../../../components/combobox.jsx"; import Combobox from '@components/combobox.jsx';
import tagSuggestionList from "./curatedTagSuggestionList.js"; import { tagSuggestionList, canonizationList } from './curatedTagSuggestionList.js';
const TagInput = ({tooltip, label, valuePatterns, values = [], unique = true, placeholder = "", smallText = "", onChange }) => { const TagInput = ({ tooltip, label, valuePatterns, values = [], unique = true, placeholder = '', smallText = '', onChange })=>{
const [tagList, setTagList] = useState( const [tagList, setTagList] = useState(
values.map((value) => ({ values.map((value)=>({
value, value,
editing: false, editing : false,
draft: "", draft : '',
})), })),
); );
useEffect(() => { useEffect(()=>{
const incoming = values || []; const incoming = values || [];
const current = tagList.map((t) => t.value); const current = tagList.map((t)=>t.value);
const changed = incoming.length !== current.length || incoming.some((v, i) => v !== current[i]); const changed = incoming.length !== current.length || incoming.some((v, i)=>v !== current[i]);
if (changed) { if(changed) {
setTagList( setTagList(
incoming.map((value) => ({ incoming.map((value)=>({
value, value,
editing: false, editing : false,
})), })),
); );
} }
}, [values]); }, [values]);
useEffect(() => { useEffect(()=>{
onChange?.({ onChange?.({
target: { value: tagList.map((t) => t.value) }, target : { value: tagList.map((t)=>t.value) },
}); });
}, [tagList]); }, [tagList]);
// substrings to be normalized to the first value on the array const normalizeValue = (input)=>{
const duplicateGroups = [
["5e 2024", "5.5e", "5e'24", "5.24", "5e24", "5.5"],
["5e", "5th Edition"],
["Dungeons & Dragons", "Dungeons and Dragons", "Dungeons n dragons"],
["D&D", "DnD", "dnd", "Dnd", "dnD", "d&d", "d&D", "D&d"],
["P2e", "p2e", "P2E", "Pathfinder 2e"],
];
const normalizeValue = (input) => {
const lowerInput = input.toLowerCase(); const lowerInput = input.toLowerCase();
let normalizedTag = input; let normalizedTag = input;
for (const group of duplicateGroups) { for (const group of canonizationList) {
for (const tag of group) { for (const tag of group) {
if (!tag) continue; if(!tag) continue;
const index = lowerInput.indexOf(tag.toLowerCase()); const index = lowerInput.indexOf(tag.toLowerCase());
if (index !== -1) { if(index !== -1) {
normalizedTag = input.slice(0, index) + group[0] + input.slice(index + tag.length); normalizedTag = input.slice(0, index) + group[0] + input.slice(index + tag.length);
break; break;
} }
} }
} }
if (normalizedTag.includes(":")) { if(normalizedTag.includes(':')) {
const [rawType, rawValue = ""] = normalizedTag.split(":"); const [rawType, rawValue = ''] = normalizedTag.split(':');
const tagType = rawType.trim().toLowerCase(); const tagType = rawType.trim().toLowerCase();
const tagValue = rawValue.trim(); const tagValue = rawValue.trim();
if (tagValue.length > 0) { if(tagValue.length > 0) {
normalizedTag = `${tagType}:${tagValue[0].toUpperCase()}${tagValue.slice(1)}`; normalizedTag = `${tagType}:${tagValue[0].toUpperCase()}${tagValue.slice(1)}`;
} }
//trims spaces around colon and capitalizes the first word after the colon //trims spaces around colon and capitalizes the first word after the colon
@@ -75,55 +66,55 @@ const TagInput = ({tooltip, label, valuePatterns, values = [], unique = true, pl
return normalizedTag; return normalizedTag;
}; };
const submitTag = (newValue, index = null) => { const submitTag = (newValue, index = null)=>{
const trimmed = newValue?.trim(); const trimmed = newValue?.trim();
if (!trimmed) return; if(!trimmed) return;
if (!valuePatterns.test(trimmed)) return; if(!valuePatterns.test(trimmed)) return;
const normalizedTag = normalizeValue(trimmed); const normalizedTag = normalizeValue(trimmed);
setTagList((prev) => { setTagList((prev)=>{
const existsIndex = prev.findIndex((t) => t.value.toLowerCase() === normalizedTag.toLowerCase()); const existsIndex = prev.findIndex((t)=>t.value.toLowerCase() === normalizedTag.toLowerCase());
if (unique && existsIndex !== -1) return prev; if(unique && existsIndex !== -1) return prev;
if (index !== null) { if(index !== null) {
return prev.map((t, i) => (i === index ? { ...t, value: normalizedTag, editing: false } : t)); return prev.map((t, i)=>(i === index ? { ...t, value: normalizedTag, editing: false } : t));
} }
return [...prev, { value: normalizedTag, editing: false }]; return [...prev, { value: normalizedTag, editing: false }];
}); });
}; };
const removeTag = (index) => { const removeTag = (index)=>{
setTagList((prev) => prev.filter((_, i) => i !== index)); setTagList((prev)=>prev.filter((_, i)=>i !== index));
}; };
const editTag = (index) => { const editTag = (index)=>{
setTagList((prev) => prev.map((t, i) => (i === index ? { ...t, editing: true, draft: t.value } : t))); setTagList((prev)=>prev.map((t, i)=>(i === index ? { ...t, editing: true, draft: t.value } : t)));
}; };
const stopEditing = (index) => { const stopEditing = (index)=>{
setTagList((prev) => prev.map((t, i) => (i === index ? { ...t, editing: false, draft: "" } : t))); setTagList((prev)=>prev.map((t, i)=>(i === index ? { ...t, editing: false, draft: '' } : t)));
}; };
const suggestionOptions = tagSuggestionList.map((tag) => { const suggestionOptions = tagSuggestionList.map((tag)=>{
const tagType = tag.split(":"); const tagType = tag.split(':');
let classes = "item"; let classes = 'item';
switch (tagType[0]) { switch (tagType[0]) {
case "type": case 'type':
classes = "item type"; classes = 'item type';
break; break;
case "group": case 'group':
classes = "item group"; classes = 'item group';
break; break;
case "meta": case 'meta':
classes = "item meta"; classes = 'item meta';
break; break;
case "system": case 'system':
classes = "item system"; classes = 'item system';
break; break;
default: default:
classes = "item"; classes = 'item';
break; break;
} }
@@ -135,54 +126,50 @@ const TagInput = ({tooltip, label, valuePatterns, values = [], unique = true, pl
}); });
return ( return (
<div className="tagInputWrap"> <div className='tagInputWrap'>
<Combobox <Combobox
trigger="click" trigger='click'
className="tagInput-dropdown" className='tagInput-dropdown'
default="" default=''
placeholder={placeholder} placeholder={placeholder}
options={label === "tags" ? suggestionOptions : []} options={label === 'tags' ? suggestionOptions : []}
tooltip={tooltip} tooltip={tooltip}
autoSuggest={ autoSuggest={
label === "tags" label === 'tags'
? { ? {
suggestMethod: "startsWith", suggestMethod : 'startsWith',
clearAutoSuggestOnClick: true, clearAutoSuggestOnClick : true,
filterOn: ["value", "title"], filterOn : ['value', 'title'],
} }
: { suggestMethod: "includes", clearAutoSuggestOnClick: true, filterOn: [] } : { suggestMethod: 'includes', clearAutoSuggestOnClick: true, filterOn: [] }
} }
valuePatterns={valuePatterns.source} valuePatterns={valuePatterns.source}
onSelect={(value) => submitTag(value)} onSelect={(value)=>submitTag(value)}
onEntry={(e) => { onEntry={(e)=>{
if (e.key === "Enter") { if(e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
submitTag(e.target.value); submitTag(e.target.value);
} }
}} }}
/> />
<ul className="list"> <ul className='list'>
{tagList.map((t, i) => {tagList.map((t, i)=>t.editing ? (
t.editing ? (
<input <input
key={i} key={i}
type="text" type='text'
value={t.draft} // always use draft value={t.draft} // always use draft
pattern={valuePatterns.source} pattern={valuePatterns.source}
onChange={(e) => onChange={(e)=>setTagList((prev)=>prev.map((tag, idx)=>(idx === i ? { ...tag, draft: e.target.value } : tag)),
setTagList((prev) =>
prev.map((tag, idx) => (idx === i ? { ...tag, draft: e.target.value } : tag)),
) )
} }
onKeyDown={(e) => { onKeyDown={(e)=>{
if (e.key === "Enter") { if(e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
submitTag(t.draft, i); // submit draft submitTag(t.draft, i); // submit draft
setTagList((prev) => setTagList((prev)=>prev.map((tag, idx)=>(idx === i ? { ...tag, draft: '' } : tag)),
prev.map((tag, idx) => (idx === i ? { ...tag, draft: "" } : tag)),
); );
} }
if (e.key === "Escape") { if(e.key === 'Escape') {
stopEditing(i); stopEditing(i);
e.target.blur(); e.target.blur();
} }
@@ -190,15 +177,15 @@ const TagInput = ({tooltip, label, valuePatterns, values = [], unique = true, pl
autoFocus autoFocus
/> />
) : ( ) : (
<li key={i} className="tag" onClick={() => editTag(i)}> <li key={i} className='tag' onClick={()=>editTag(i)}>
{t.value} {t.value}
<button <button
type="button" type='button'
onClick={(e) => { onClick={(e)=>{
e.stopPropagation(); e.stopPropagation();
removeTag(i); removeTag(i);
}}> }}>
<i className="fa fa-times fa-fw" /> <i className='fa fa-times fa-fw' />
</button> </button>
</li> </li>
), ),
File diff suppressed because it is too large Load Diff
+20 -1
View File
@@ -37,9 +37,15 @@ const Homebrew = (props)=>{
lang : '' lang : ''
}, },
userThemes, userThemes,
brews brews,
enablev4
} = props; } = props;
global.account = account;
global.version = version;
global.config = config;
global.enablev4 = enablev4;
const backgroundObject = ()=>{ const backgroundObject = ()=>{
if(config?.deployment || (config?.local && config?.development)) { if(config?.deployment || (config?.local && config?.development)) {
const bgText = config?.deployment || 'Local'; const bgText = config?.deployment || 'Local';
@@ -52,6 +58,19 @@ const Homebrew = (props)=>{
updateLocalStorage(); updateLocalStorage();
if(brew.pureError) {
return (
<Router>
<div className={`homebrew${(config?.deployment || config?.local) ? ' deployment' : ''}`} style={backgroundObject()}>
<Routes>
<Route path={brew.originalUrl} element={<WithRoute el={ErrorPage} brew={brew} />} />
</Routes>
</div>
</Router>
);
}
return ( return (
<Router> <Router>
<div className={`homebrew${(config?.deployment || config?.local) ? ' deployment' : ''}`} style={backgroundObject()}> <div className={`homebrew${(config?.deployment || config?.local) ? ' deployment' : ''}`} style={backgroundObject()}>
+5 -3
View File
@@ -1,6 +1,8 @@
import { createRoot } from "react-dom/client"; import { createRoot } from 'react-dom/client';
import Homebrew from "./homebrew.jsx"; import Homebrew from './homebrew.jsx';
import { bootstrapAnchorPositioningPolyfill } from '@components/anchorPositioningPolyfill.js';
const props = window.__INITIAL_PROPS__ || {}; const props = window.__INITIAL_PROPS__ || {};
createRoot(document.getElementById("reactRoot")).render(<Homebrew {...props} />); createRoot(document.getElementById('reactRoot')).render(<Homebrew {...props} />);
bootstrapAnchorPositioningPolyfill();
@@ -46,11 +46,6 @@ const MetadataNav = createReactClass({
</>; </>;
}, },
getSystems : function(){
if(!this.props.brew.systems || this.props.brew.systems.length == 0) return 'No systems';
return this.props.brew.systems.join(', ');
},
renderMetaWindow : function(){ renderMetaWindow : function(){
return <div className={`window ${this.state.showMetaWindow ? 'active' : 'inactive'}`}> return <div className={`window ${this.state.showMetaWindow ? 'active' : 'inactive'}`}>
<div className='row'> <div className='row'>
@@ -65,10 +60,6 @@ const MetadataNav = createReactClass({
<h4>Tags</h4> <h4>Tags</h4>
<p>{this.getTags()}</p> <p>{this.getTags()}</p>
</div> </div>
<div className='row'>
<h4>Systems</h4>
<p>{this.getSystems()}</p>
</div>
<div className='row'> <div className='row'>
<h4>Updated</h4> <h4>Updated</h4>
<p>{Moment(this.props.brew.updatedAt).fromNow()}</p> <p>{Moment(this.props.brew.updatedAt).fromNow()}</p>
+1 -1
View File
@@ -4,7 +4,7 @@ import createReactClass from 'create-react-class';
import _ from 'lodash'; import _ from 'lodash';
import cx from 'classnames'; import cx from 'classnames';
import NaturalCritIcon from '../../components/svg/naturalcrit-d20.svg.jsx'; import NaturalCritIcon from '@components/svg/naturalcrit-d20.svg.jsx';
const Nav = { const Nav = {
base : createReactClass({ base : createReactClass({
+2 -2
View File
@@ -7,10 +7,10 @@ import PatreonNavItem from './patreon.navitem.jsx';
const Navbar = createReactClass({ const Navbar = createReactClass({
displayName : 'Navbar', displayName : 'Navbar',
getInitialState: function() { getInitialState : function() {
return { return {
// showNonChromeWarning: false, // uncomment if needed // showNonChromeWarning: false, // uncomment if needed
ver: global.version || '0.0.0' ver : global.version || '0.0.0'
}; };
}, },
+1 -1
View File
@@ -24,7 +24,7 @@ const NewBrew = ()=>{
localStorage.setItem(BREWKEY, newBrew.text); localStorage.setItem(BREWKEY, newBrew.text);
localStorage.setItem(STYLEKEY, newBrew.style); localStorage.setItem(STYLEKEY, newBrew.style);
localStorage.setItem(METAKEY, JSON.stringify( localStorage.setItem(METAKEY, JSON.stringify(
_.pick(newBrew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang']) _.pick(newBrew, ['title', 'description', 'tags', 'renderer', 'theme', 'lang'])
)); ));
window.location.href = '/new'; window.location.href = '/new';
return; return;
+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>;
}; };
+7 -1
View File
@@ -17,7 +17,7 @@ const getRedditLink = (brew)=>{
return `https://www.reddit.com/r/UnearthedArcana/submit?title=${encodeURIComponent(brew.title.toWellFormed())}&text=${encodeURIComponent(text)}`; return `https://www.reddit.com/r/UnearthedArcana/submit?title=${encodeURIComponent(brew.title.toWellFormed())}&text=${encodeURIComponent(text)}`;
}; };
export default ({ brew })=>( export default ({ brew, currentPage })=>(
<Nav.dropdown> <Nav.dropdown>
<Nav.item color='teal' icon='fas fa-share-alt'> <Nav.item color='teal' icon='fas fa-share-alt'>
share share
@@ -28,6 +28,12 @@ export default ({ brew })=>(
<Nav.item color='blue' onClick={()=>{navigator.clipboard.writeText(`${global.config.baseUrl}/share/${getShareId(brew)}`);}}> <Nav.item color='blue' onClick={()=>{navigator.clipboard.writeText(`${global.config.baseUrl}/share/${getShareId(brew)}`);}}>
copy url copy url
</Nav.item> </Nav.item>
{currentPage > 1 &&
<Nav.item
color='blue'
onClick={()=>{navigator.clipboard.writeText(`${global.config.baseUrl}/share/${getShareId(brew)}#p${currentPage}`);}}>
copy url (page {currentPage})
</Nav.item>}
<Nav.item color='blue' href={getRedditLink(brew)} newTab rel='noopener noreferrer'> <Nav.item color='blue' href={getRedditLink(brew)} newTab rel='noopener noreferrer'>
post to reddit post to reddit
</Nav.item> </Nav.item>
@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import moment from 'moment'; import moment from 'moment';
import UIPage from '../basePages/uiPage/uiPage.jsx'; import UIPage from '../basePages/uiPage/uiPage.jsx';
import NaturalCritIcon from '../../../components/svg/naturalcrit-d20.svg.jsx'; import NaturalCritIcon from '@components/svg/naturalcrit-d20.svg.jsx';
let SAVEKEY = ''; let SAVEKEY = '';
+25 -27
View File
@@ -8,9 +8,9 @@ import Markdown from '@shared/markdown.js';
import _ from 'lodash'; import _ from 'lodash';
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js'; import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '@shared/helpers.js'; import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
import SplitPane from '../../../components/splitPane/splitPane.jsx'; import SplitPane from '@components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx'; import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
@@ -57,22 +57,22 @@ const EditPage = (props)=>{
...props ...props
}; };
const [currentBrew , setCurrentBrew ] = useState(props.brew); const [currentBrew, setCurrentBrew] = useState(props.brew);
const [isSaving , setIsSaving ] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [lastSavedTime , setLastSavedTime ] = useState(new Date()); const [lastSavedTime, setLastSavedTime] = useState(new Date());
const [saveGoogle , setSaveGoogle ] = useState(!!props.brew.googleId); const [saveGoogle, setSaveGoogle] = useState(!!props.brew.googleId);
const [error , setError ] = useState(null); const [error, setError] = useState(null);
const [HTMLErrors , setHTMLErrors ] = useState(Markdown.validate(props.brew.text)); const [HTMLErrors, setHTMLErrors] = useState(Markdown.validate(props.brew.text));
const [currentEditorViewPageNum , setCurrentEditorViewPageNum ] = useState(1); const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1);
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1); const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1); const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [themeBundle , setThemeBundle ] = useState({}); const [themeBundle, setThemeBundle] = useState({});
const [unsavedChanges , setUnsavedChanges ] = useState(false); const [unsavedChanges, setUnsavedChanges] = useState(false);
const [alertTrashedGoogleBrew , setAlertTrashedGoogleBrew ] = useState(props.brew.trashed); const [alertTrashedGoogleBrew, setAlertTrashedGoogleBrew] = useState(props.brew.trashed);
const [alertLoginToTransfer , setAlertLoginToTransfer ] = useState(false); const [alertLoginToTransfer, setAlertLoginToTransfer] = useState(false);
const [confirmGoogleTransfer , setConfirmGoogleTransfer ] = useState(false); const [confirmGoogleTransfer, setConfirmGoogleTransfer] = useState(false);
const [autoSaveEnabled , setAutoSaveEnabled ] = useState(true); const [autoSaveEnabled, setAutoSaveEnabled] = useState(true);
const [warnUnsavedChanges , setWarnUnsavedChanges ] = useState(true); const [warnUnsavedChanges, setWarnUnsavedChanges] = useState(true);
const editorRef = useRef(null); const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew)); const lastSavedBrew = useRef(_.cloneDeep(props.brew));
@@ -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)
@@ -365,7 +363,7 @@ const EditPage = (props)=>{
<PrintNavItem /> <PrintNavItem />
<HelpNavItem /> <HelpNavItem />
<VaultNavItem /> <VaultNavItem />
<ShareNavItem brew={currentBrew} /> <ShareNavItem brew={currentBrew} currentPage={currentBrewRendererPageNum} />
<RecentNavItem brew={currentBrew} storageKey='edit' /> <RecentNavItem brew={currentBrew} storageKey='edit' />
<AccountNavItem/> <AccountNavItem/>
</Nav.section> </Nav.section>
@@ -1,7 +1,7 @@
import './lockNotification.less'; import './lockNotification.less';
import * as React from 'react'; import * as React from 'react';
import request from '../../../utils/request-middleware.js'; import request from '../../../utils/request-middleware.js';
import Dialog from '../../../../components/dialog.jsx'; import Dialog from '@components/dialog.jsx';
function LockNotification(props) { function LockNotification(props) {
props = { props = {
@@ -1,7 +1,6 @@
.homebrew { .homebrew {
.uiPage.sitePage { .uiPage.sitePage:has(.errorTitle) {
.errorTitle { .errorTitle {
//background-color: @orange;
color : #D02727; color : #D02727;
text-align : center; text-align : center;
} }
+11 -11
View File
@@ -1,4 +1,4 @@
/* eslint-disable max-lines */
import './homePage.less'; import './homePage.less';
// Common imports // Common imports
@@ -8,9 +8,9 @@ import Markdown from '@shared/markdown.js';
import _ from 'lodash'; import _ from 'lodash';
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js'; import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '@shared/helpers.js'; import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
import SplitPane from '../../../components/splitPane/splitPane.jsx'; import SplitPane from '@components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx'; import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
@@ -45,16 +45,16 @@ const HomePage =(props)=>{
...props ...props
}; };
const [currentBrew , setCurrentBrew] = useState(props.brew); const [currentBrew, setCurrentBrew] = useState(props.brew);
const [error , setError] = useState(undefined); const [error, setError] = useState(undefined);
const [HTMLErrors , setHTMLErrors] = useState(Markdown.validate(props.brew.text)); const [HTMLErrors, setHTMLErrors] = useState(Markdown.validate(props.brew.text));
const [currentEditorViewPageNum , setCurrentEditorViewPageNum] = useState(1); const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1);
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1); const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1); const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [themeBundle , setThemeBundle] = useState({}); const [themeBundle, setThemeBundle] = useState({});
const [unsavedChanges , setUnsavedChanges] = useState(false); const [unsavedChanges, setUnsavedChanges] = useState(false);
const [isSaving , setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [autoSaveEnabled , setAutoSaveEnable] = useState(false); const [autoSaveEnabled, setAutoSaveEnable] = useState(false);
const editorRef = useRef(null); const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew)); const lastSavedBrew = useRef(_.cloneDeep(props.brew));
+11 -11
View File
@@ -10,7 +10,7 @@ import _ from 'lodash';
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js'; import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '@shared/helpers.js'; import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '@shared/helpers.js';
import SplitPane from '../../../components/splitPane/splitPane.jsx'; import SplitPane from '@components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx'; import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
@@ -42,17 +42,17 @@ const NewPage = (props)=>{
...props ...props
}; };
const [currentBrew , setCurrentBrew ] = useState(props.brew); const [currentBrew, setCurrentBrew] = useState(props.brew);
const [isSaving , setIsSaving ] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [saveGoogle , setSaveGoogle ] = useState(global.account?.googleId ? true : false); const [saveGoogle, setSaveGoogle] = useState(global.account?.googleId ? true : false);
const [error , setError ] = useState(null); const [error, setError] = useState(null);
const [HTMLErrors , setHTMLErrors ] = useState(Markdown.validate(props.brew.text)); const [HTMLErrors, setHTMLErrors] = useState(Markdown.validate(props.brew.text));
const [currentEditorViewPageNum , setCurrentEditorViewPageNum ] = useState(1); const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1);
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1); const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1); const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [themeBundle , setThemeBundle ] = useState({}); const [themeBundle, setThemeBundle] = useState({});
const [unsavedChanges , setUnsavedChanges ] = useState(false); const [unsavedChanges, setUnsavedChanges] = useState(false);
const [autoSaveEnabled , setAutoSaveEnabled ] = useState(false); const [autoSaveEnabled, setAutoSaveEnabled] = useState(false);
const editorRef = useRef(null); const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew)); const lastSavedBrew = useRef(_.cloneDeep(props.brew));
@@ -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
@@ -92,6 +92,19 @@ const SharePage = (props)=>{
<Nav.item color='blue' icon='fas fa-clone' href={`/new/${processShareId()}`}> <Nav.item color='blue' icon='fas fa-clone' href={`/new/${processShareId()}`}>
clone to new clone to new
</Nav.item> </Nav.item>
<Nav.item
color='blue'
icon='fas fa-link'
onClick={()=>{navigator.clipboard.writeText(`${global.config.baseUrl}/share/${processShareId()}`);}}>
copy url
</Nav.item>
{currentBrewRendererPageNum > 1 &&
<Nav.item
color='blue'
icon='fas fa-hashtag'
onClick={()=>{navigator.clipboard.writeText(`${global.config.baseUrl}/share/${processShareId()}#p${currentBrewRendererPageNum}`);}}>
copy url (page {currentBrewRendererPageNum})
</Nav.item>}
</Nav.dropdown> </Nav.dropdown>
</> </>
)} )}
@@ -11,7 +11,7 @@ import Account from '@navbar/account.navitem.jsx';
import NewBrew from '@navbar/newbrew.navitem.jsx'; import NewBrew from '@navbar/newbrew.navitem.jsx';
import HelpNavItem from '@navbar/help.navitem.jsx'; import HelpNavItem from '@navbar/help.navitem.jsx';
import BrewItem from '../basePages/listPage/brewItem/brewItem.jsx'; import BrewItem from '../basePages/listPage/brewItem/brewItem.jsx';
import SplitPane from '../../../components/splitPane/splitPane.jsx'; import SplitPane from '@components/splitPane/splitPane.jsx';
import ErrorIndex from '../errorPage/errors/errorIndex.js'; import ErrorIndex from '../errorPage/errors/errorIndex.js';
import request from '../../utils/request-middleware.js'; import request from '../../utils/request-middleware.js';
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+2 -1
View File
@@ -7,5 +7,6 @@
"local_environments" : ["docker", "local"], "local_environments" : ["docker", "local"],
"publicUrl" : "https://homebrewery.naturalcrit.com", "publicUrl" : "https://homebrewery.naturalcrit.com",
"hb_images" : null, "hb_images" : null,
"hb_fonts" : null "hb_fonts" : null,
"enablev4" : true
} }
+21 -4
View File
@@ -5,10 +5,6 @@
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, height=device-height, interactive-widget=resizes-visual" /> content="width=device-width, initial-scale=1, height=device-height, interactive-widget=resizes-visual" />
<link
href="//fonts.googleapis.com/css?family=Open+Sans:400,300,600,700"
rel="stylesheet"
type="text/css" />
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon" /> <link rel="icon" href="/assets/favicon.ico" type="image/x-icon" />
<meta name="twitter:card" content="summary" /> <meta name="twitter:card" content="summary" />
@@ -19,6 +15,27 @@
<main id="reactRoot"></main> <main id="reactRoot"></main>
<script type="module"> <script type="module">
const props = window.__INITIAL_PROPS__ || {};
const url = props.config?.baseUrl;
const title = props.brew?.title;
let prefix = '';
if (url && url?.includes('://homebrewery-stage.')) {
prefix = `Stage `;
} else if (url?.includes('://homebrewery-pr-')) {
const match = url.match(/pr-(\d+)/);
if (match) prefix = `PR-${match[1]} `;
} else if (url?.includes('://localhost')) {
prefix = 'Local ';
}
if (title) {
document.title = `${prefix} - ${title} - The Homebrewery`;
} else if (prefix) {
document.title = `${prefix} - The Homebrewery`;
}
if (window.location.pathname.startsWith('/admin')) { if (window.location.pathname.startsWith('/admin')) {
import('/client/admin/main.jsx'); import('/client/admin/main.jsx');
} else { } else {
+2966 -2238
View File
File diff suppressed because it is too large Load Diff
+49 -36
View File
@@ -1,11 +1,11 @@
{ {
"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.22.0",
"type": "module", "type": "module",
"engines": { "engines": {
"npm": "^10.8.x", "npm": ">=10.8 <12",
"node": "^20.18.x" "node": ">=26.4.0"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -58,7 +58,7 @@
"server" "server"
], ],
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"node_modules/(?!(nanoid|@exodus/bytes|parse5|@asamuzakjp|@csstools)/)" "node_modules/(?!(nanoid|@exodus/bytes|parse5|@asamuzakjp|@csstools|entities)/)"
], ],
"transform": { "transform": {
"^.+\\.[jt]s$": "babel-jest", "^.+\\.[jt]s$": "babel-jest",
@@ -86,72 +86,85 @@
] ]
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.29.0", "@babel/core": "^7.29.7",
"@babel/plugin-transform-runtime": "^7.29.0", "@babel/plugin-transform-runtime": "^7.29.7",
"@babel/preset-env": "^7.29.0", "@babel/preset-env": "^7.29.5",
"@babel/preset-react": "^7.28.5", "@babel/preset-react": "^7.29.7",
"@babel/runtime": "^7.28.6", "@babel/runtime": "^7.29.2",
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.3",
"@codemirror/highlight": "^0.19.8",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.2",
"@codemirror/language-data": "^6.5.2",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.1",
"@dmsnell/diff-match-patch": "^1.1.0", "@dmsnell/diff-match-patch": "^1.1.0",
"@googleapis/drive": "^20.1.0", "@googleapis/drive": "^20.2.0",
"@lezer/highlight": "^1.2.3",
"@oddbird/css-anchor-positioning": "^0.9.0",
"@sanity/diff-match-patch": "^3.2.0", "@sanity/diff-match-patch": "^3.2.0",
"@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",
"codemirror": "^5.65.6", "codemirror-5-themes": "^1.5.1",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"core-js": "^3.47.0", "core-js": "^3.49.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"create-react-class": "^15.7.0", "create-react-class": "^15.7.0",
"dedent": "^1.7.1", "dedent": "^1.7.2",
"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.3",
"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.5",
"js-yaml": "^4.1.1", "js-yaml": "^4.2.0",
"jwt-simple": "^0.5.6", "jwt-simple": "^0.5.6",
"less": "^4.5.1", "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",
"marked-emoji": "^2.0.2", "marked-emoji": "^2.0.3",
"marked-extended-tables": "^2.0.1", "marked-extended-tables": "^2.0.1",
"marked-gfm-heading-id": "^4.1.3", "marked-gfm-heading-id": "^4.1.4",
"marked-nonbreaking-spaces": "^1.0.1", "marked-nonbreaking-spaces": "^1.0.1",
"marked-smartypants-lite": "^1.0.3", "marked-smartypants-lite": "^1.0.3",
"marked-subsuper-text": "^1.0.4", "marked-subsuper-text": "^1.0.4",
"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.2.1", "mongoose": "^9.7.0",
"nanoid": "5.1.6", "nanoid": "5.1.11",
"nconf": "^0.13.0", "nconf": "^0.13.0",
"node": "^25.7.0", "node": "^25.9.0",
"react": "^19.2.4", "react": "^19.2.7",
"react-dom": "^19.2.4", "react-dom": "^19.2.7",
"react-frame-component": "^5.2.7", "react-frame-component": "^5.3.2",
"react-router": "^7.13.1", "react-router": "^7.17.0",
"sanitize-filename": "1.6.3", "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.2.0", "babel-jest": "^30.4.1",
"babel-plugin-transform-import-meta": "^2.3.3", "babel-plugin-transform-import-meta": "^2.3.3",
"eslint": "^9.39.1", "eslint": "9.7",
"eslint-plugin-jest": "^29.1.0", "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.2.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.4.0", "stylelint": "^17.11.1",
"stylelint-config-recess-order": "^7.6.1", "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",
"vite": "^7.3.1" "vite": "^7.3.1"
+16 -16
View File
@@ -1,33 +1,33 @@
import DB from "./server/db.js"; import DB from './server/db.js';
import createApp from "./server/app.js"; import createApp from './server/app.js';
import config from "./server/config.js"; import config from './server/config.js';
import { createServer as createViteServer } from "vite"; import { createServer as createViteServer } from 'vite';
const isDev = process.env.NODE_ENV === "local"; const isDev = config.get('local_environments').includes(process.env.NODE_ENV);
async function start() { async function start() {
let vite; let vite;
if (isDev) { if(isDev) {
vite = await createViteServer({ vite = await createViteServer({
server: { middlewareMode: true }, server : { middlewareMode: true },
appType: "custom", appType : 'custom',
}); });
} }
await DB.connect(config).catch((err) => { await DB.connect(config).catch((err)=>{
console.error("Database connection failed:", err); console.error('Database connection failed:', err);
process.exit(1); process.exit(1);
}); });
const app = await createApp(vite); const app = await createApp(vite);
const PORT = process.env.PORT || config.get("web_port") || 3000; const PORT = process.env.PORT || config.get('web_port') || 3000;
app.listen(PORT, () => { app.listen(PORT, ()=>{
const reset = "\x1b[0m"; // Reset to default style const reset = '\x1b[0m'; // Reset to default style
const bright = "\x1b[1m"; // Bright (bold) style const bright = '\x1b[1m'; // Bright (bold) style
const cyan = "\x1b[36m"; // Cyan color const cyan = '\x1b[36m'; // Cyan color
const underline = "\x1b[4m"; // Underlined style const underline = '\x1b[4m'; // Underlined style
console.log(`\n\tserver started at: ${new Date().toLocaleString()}`); console.log(`\n\tserver started at: ${new Date().toLocaleString()}`);
console.log(`\tserver on port: ${PORT}`); console.log(`\tserver on port: ${PORT}`);
+97 -60
View File
@@ -21,7 +21,7 @@ process.env.ADMIN_PASS = process.env.ADMIN_PASS || 'password3';
export default function createAdminApi(vite) { export default function createAdminApi(vite) {
const router = express.Router(); const router = express.Router();
const mw = { const mw = {
adminOnly : (req, res, next)=>{ adminOnly : (req, res, next)=>{
if(!req.get('authorization')){ if(!req.get('authorization')){
return res return res
@@ -37,36 +37,50 @@ const mw = {
} }
throw { HBErrorCode: '52', code: 401, message: 'Access denied' }; throw { HBErrorCode: '52', code: 401, message: 'Access denied' };
} }
}; };
const junkBrewPipeline = [ // Search for up to 300 brews that have not been viewed or updated in 30 days and are shorter than 140 bytes
const junkBrewsPipeline = [
{ $match : { { $match : {
updatedAt : { $lt: Moment().subtract(30, 'days').toDate() }, updatedAt : { $lt: Moment().subtract(30, 'days').toDate() },
lastViewed : { $lt: Moment().subtract(30, 'days').toDate() } lastViewed : { $lt: Moment().subtract(30, 'days').toDate() }
} }, } },
{ $project: { textBinSize: { $binarySize: '$textBin' } } }, { $project: { _id: 1, textBinSize: { $binarySize: '$textBin' }, updatedAt: 1, lastViewed: 1} },
{ $match: { textBinSize: { $lt: 140 } } }, { $match: { textBinSize: { $lt: 140 } } },
{ $limit: 100 } { $limit: 300 }
]; ];
/* Search for brews that aren't compressed (missing the compressed text field) */ // Search for up to 500 unauthored brews that have not been viewed or updated in two years
const uncompressedBrewQuery = HomebrewModel.find({ const lostBrewsPipeline = [
{
$match: {
authors: [],
updatedAt: { $lt: Moment().subtract(2, 'years').toDate() },
lastViewed: { $lt: Moment().subtract(2, 'years').toDate() }
}
},
{
$limit: 500
}
];
/* Search for brews that aren't compressed (missing the compressed text field) */
const uncompressedBrewQuery = HomebrewModel.find({
'text' : { '$exists': true } 'text' : { '$exists': true }
}).lean().limit(10000).select('_id'); }).lean().limit(10000).select('_id');
// Search for up to 100 brews that have not been viewed or updated in 30 days and are shorter than 140 bytes router.get('/admin/cleanupJunk', mw.adminOnly, (req, res)=>{
router.get('/admin/cleanup', mw.adminOnly, (req, res)=>{ HomebrewModel.aggregate(junkBrewsPipeline).option({ maxTimeMS: 60000 })
HomebrewModel.aggregate(junkBrewPipeline).option({ maxTimeMS: 60000 }) .then((objs)=>res.json({ count: objs.length, brewCollection : objs }))
.then((objs)=>res.json({ count: objs.length }))
.catch((error)=>{ .catch((error)=>{
console.error(error); console.error(error);
res.status(500).json({ error: 'Internal Server Error' }); res.status(500).json({ error: 'Internal Server Error' });
}); });
}); });
// Delete up to 100 brews that have not been viewed or updated in 30 days and are shorter than 140 bytes // Delete result of junkBrewsPipeline
router.post('/admin/cleanup', mw.adminOnly, (req, res)=>{ router.post('/admin/cleanupJunk', mw.adminOnly, (req, res)=>{
HomebrewModel.aggregate(junkBrewPipeline).option({ maxTimeMS: 60000 }) HomebrewModel.aggregate(junkBrewsPipeline).option({ maxTimeMS: 60000 })
.then((docs)=>{ .then((docs)=>{
const ids = docs.map((doc)=>doc._id); const ids = docs.map((doc)=>doc._id);
return HomebrewModel.deleteMany({ _id: { $in: ids } }); return HomebrewModel.deleteMany({ _id: { $in: ids } });
@@ -76,15 +90,38 @@ router.post('/admin/cleanup', mw.adminOnly, (req, res)=>{
console.error(error); console.error(error);
res.status(500).json({ error: 'Internal Server Error' }); res.status(500).json({ error: 'Internal Server Error' });
}); });
}); });
/* Searches for matching edit or share id, also attempts to partial match */ router.get('/admin/cleanupLost', mw.adminOnly, (req, res)=>{
router.get('/admin/lookup/:id', mw.adminOnly, asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res, next)=>{ HomebrewModel.aggregate(lostBrewsPipeline).option({ maxTimeMS: 60000 })
.then((objs)=>res.json({ count: objs.length, brewCollection : objs }))
.catch((error)=>{
console.error(error);
res.status(500).json({ error: 'Internal Server Error' });
});
});
// Delete result of lostBrewsPipeline
router.post('/admin/cleanupLost', mw.adminOnly, (req, res)=>{
HomebrewModel.aggregate(lostBrewsPipeline).option({ maxTimeMS: 60000 })
.then((docs)=>{
const ids = docs.map((doc)=>doc._id);
return HomebrewModel.deleteMany({ _id: { $in: ids } });
}).then((result)=>{
res.json({ count: result.deletedCount });
}).catch((error)=>{
console.error(error);
res.status(500).json({ error: 'Internal Server Error' });
});
});
/* Searches for matching edit or share id, also attempts to partial match */
router.get('/admin/lookup/:id', mw.adminOnly, asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res, next)=>{
return res.json(req.brew); return res.json(req.brew);
}); });
/* Find 50 brews that aren't compressed yet */ /* Find 50 brews that aren't compressed yet */
router.get('/admin/finduncompressed', mw.adminOnly, (req, res)=>{ router.get('/admin/finduncompressed', mw.adminOnly, (req, res)=>{
const query = uncompressedBrewQuery.clone(); const query = uncompressedBrewQuery.clone();
query.exec() query.exec()
@@ -96,10 +133,10 @@ router.get('/admin/finduncompressed', mw.adminOnly, (req, res)=>{
console.error(err); console.error(err);
res.status(500).send(err.message || 'Internal Server Error'); res.status(500).send(err.message || 'Internal Server Error');
}); });
}); });
/* Cleans `<script` and `</script>` from the "text" field of a brew */ /* Cleans `<script` and `</script>` from the "text" field of a brew */
router.put('/admin/clean/script/:id', asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res)=>{ router.put('/admin/clean/script/:id', asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res)=>{
console.log(`[ADMIN: ${req.account?.username || 'Not Logged In'}] Cleaning script tags from ShareID ${req.params.id}`); console.log(`[ADMIN: ${req.account?.username || 'Not Logged In'}] Cleaning script tags from ShareID ${req.params.id}`);
function cleanText(text){return text.replaceAll(/(<\/?s)cript/gi, '');}; function cleanText(text){return text.replaceAll(/(<\/?s)cript/gi, '');};
@@ -119,10 +156,10 @@ router.put('/admin/clean/script/:id', asyncHandler(HomebrewAPI.getBrew('admin',
req.account = undefined; req.account = undefined;
return await HomebrewAPI.updateBrew(req, res); return await HomebrewAPI.updateBrew(req, res);
}); });
/* Get list of a user's documents */ /* Get list of a user's documents */
router.get('/admin/user/list/:user', mw.adminOnly, async (req, res)=>{ router.get('/admin/user/list/:user', mw.adminOnly, async (req, res)=>{
const username = req.params.user; const username = req.params.user;
const fields = { _id: 0, text: 0, textBin: 0 }; // Remove unnecessary fields from document lists const fields = { _id: 0, text: 0, textBin: 0 }; // Remove unnecessary fields from document lists
@@ -131,10 +168,10 @@ router.get('/admin/user/list/:user', mw.adminOnly, async (req, res)=>{
const brews = await HomebrewModel.getByUser(username, true, fields); const brews = await HomebrewModel.getByUser(username, true, fields);
return res.json(brews); return res.json(brews);
}); });
/* Compresses the "text" field of a brew to binary */ /* Compresses the "text" field of a brew to binary */
router.put('/admin/compress/:id', (req, res)=>{ router.put('/admin/compress/:id', (req, res)=>{
HomebrewModel.findOne({ _id: req.params.id }) HomebrewModel.findOne({ _id: req.params.id })
.then((brew)=>{ .then((brew)=>{
if(!brew) if(!brew)
@@ -152,9 +189,9 @@ router.put('/admin/compress/:id', (req, res)=>{
console.error(err); console.error(err);
res.status(500).send('Error while saving'); res.status(500).send('Error while saving');
}); });
}); });
router.get('/admin/stats', mw.adminOnly, async (req, res)=>{ router.get('/admin/stats', mw.adminOnly, async (req, res)=>{
try { try {
const totalBrewsCount = await HomebrewModel.countDocuments({}); const totalBrewsCount = await HomebrewModel.countDocuments({});
const publishedBrewsCount = await HomebrewModel.countDocuments({ published: true }); const publishedBrewsCount = await HomebrewModel.countDocuments({ published: true });
@@ -167,11 +204,11 @@ router.get('/admin/stats', mw.adminOnly, async (req, res)=>{
console.error(error); console.error(error);
return res.status(500).json({ error: 'Internal Server Error' }); return res.status(500).json({ error: 'Internal Server Error' });
} }
}); });
// ####################### LOCKS // ####################### LOCKS
router.get('/api/lock/count', mw.adminOnly, asyncHandler(async (req, res)=>{ router.get('/api/lock/count', mw.adminOnly, asyncHandler(async (req, res)=>{
const countLocksQuery = { const countLocksQuery = {
lock : { $exists: true } lock : { $exists: true }
@@ -183,9 +220,9 @@ router.get('/api/lock/count', mw.adminOnly, asyncHandler(async (req, res)=>{
return res.json({ count }); return res.json({ count });
})); }));
router.get('/api/locks', mw.adminOnly, asyncHandler(async (req, res)=>{ router.get('/api/locks', mw.adminOnly, asyncHandler(async (req, res)=>{
const countLocksPipeline = [ const countLocksPipeline = [
{ {
$match : $match :
@@ -210,9 +247,9 @@ router.get('/api/locks', mw.adminOnly, asyncHandler(async (req, res)=>{
lockedDocuments lockedDocuments
}); });
})); }));
router.post('/api/lock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{ router.post('/api/lock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
const lock = req.body; const lock = req.body;
@@ -242,9 +279,9 @@ router.post('/api/lock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
return res.json({ name: 'LOCKED', message: `Lock applied to brew ID ${brew.shareId} - ${brew.title}`, ...lock }); return res.json({ name: 'LOCKED', message: `Lock applied to brew ID ${brew.shareId} - ${brew.title}`, ...lock });
})); }));
router.put('/api/unlock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{ router.put('/api/unlock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
const filter = { const filter = {
shareId : req.params.id shareId : req.params.id
@@ -265,9 +302,9 @@ router.put('/api/unlock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
}); });
return res.json({ name: 'Unlocked', message: `Lock removed from brew ID ${req.params.id}` }); return res.json({ name: 'Unlocked', message: `Lock removed from brew ID ${req.params.id}` });
})); }));
router.get('/api/lock/reviews', mw.adminOnly, asyncHandler(async (req, res)=>{ router.get('/api/lock/reviews', mw.adminOnly, asyncHandler(async (req, res)=>{
const countReviewsPipeline = [ const countReviewsPipeline = [
{ {
$match : $match :
@@ -292,9 +329,9 @@ router.get('/api/lock/reviews', mw.adminOnly, asyncHandler(async (req, res)=>{
reviewDocuments reviewDocuments
}); });
})); }));
router.put('/api/lock/review/request/:id', asyncHandler(async (req, res)=>{ router.put('/api/lock/review/request/:id', asyncHandler(async (req, res)=>{
// === This route is NOT Admin only === // === This route is NOT Admin only ===
// Any user can request a review of their document // Any user can request a review of their document
const filter = { const filter = {
@@ -319,9 +356,9 @@ router.put('/api/lock/review/request/:id', asyncHandler(async (req, res)=>{
return res.json({ name: 'Review Requested', message: `Review requested on brew ID ${brew.shareId} - ${brew.title}` }); return res.json({ name: 'Review Requested', message: `Review requested on brew ID ${brew.shareId} - ${brew.title}` });
})); }));
router.put('/api/lock/review/remove/:id', mw.adminOnly, asyncHandler(async (req, res)=>{ router.put('/api/lock/review/remove/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
const filter = { const filter = {
shareId : req.params.id, shareId : req.params.id,
@@ -341,11 +378,11 @@ router.put('/api/lock/review/remove/:id', mw.adminOnly, asyncHandler(async (req,
return res.json({ name: 'Review Request Cleared', message: `Review request removed for brew ID ${brew.shareId} - ${brew.title}` }); return res.json({ name: 'Review Request Cleared', message: `Review request removed for brew ID ${brew.shareId} - ${brew.title}` });
})); }));
// ####################### NOTIFICATIONS // ####################### NOTIFICATIONS
router.get('/admin/notification/all', async (req, res, next)=>{ router.get('/admin/notification/all', async (req, res, next)=>{
try { try {
const notifications = await NotificationModel.getAll(); const notifications = await NotificationModel.getAll();
return res.json(notifications); return res.json(notifications);
@@ -354,9 +391,9 @@ router.get('/admin/notification/all', async (req, res, next)=>{
console.log('Error getting all notifications: ', error.message); console.log('Error getting all notifications: ', error.message);
return res.status(500).json({ message: error.message }); return res.status(500).json({ message: error.message });
} }
}); });
router.post('/admin/notification/add', mw.adminOnly, async (req, res, next)=>{ router.post('/admin/notification/add', mw.adminOnly, async (req, res, next)=>{
try { try {
const notification = await NotificationModel.addNotification(req.body); const notification = await NotificationModel.addNotification(req.body);
return res.status(201).json(notification); return res.status(201).json(notification);
@@ -364,9 +401,9 @@ router.post('/admin/notification/add', mw.adminOnly, async (req, res, next)=>{
console.log('Error adding notification: ', error.message); console.log('Error adding notification: ', error.message);
return res.status(500).json({ message: error.message }); return res.status(500).json({ message: error.message });
} }
}); });
router.delete('/admin/notification/delete/:id', mw.adminOnly, async (req, res, next)=>{ router.delete('/admin/notification/delete/:id', mw.adminOnly, async (req, res, next)=>{
try { try {
const notification = await NotificationModel.deleteNotification(req.params.id); const notification = await NotificationModel.deleteNotification(req.params.id);
return res.json(notification); return res.json(notification);
@@ -374,9 +411,9 @@ router.delete('/admin/notification/delete/:id', mw.adminOnly, async (req, res, n
console.error('Error deleting notification: { key: ', req.params.id, ' error: ', error.message, ' }'); console.error('Error deleting notification: { key: ', req.params.id, ' error: ', error.message, ' }');
return res.status(500).json({ message: error.message }); return res.status(500).json({ message: error.message });
} }
}); });
router.get('/admin', mw.adminOnly, asyncHandler(async (req, res) => { router.get('/admin', mw.adminOnly, asyncHandler(async (req, res)=>{
const props = { const props = {
url : req.originalUrl url : req.originalUrl
}; };
@@ -387,7 +424,7 @@ router.get('/admin', mw.adminOnly, asyncHandler(async (req, res) => {
let html = fs.readFileSync(htmlPath, 'utf-8'); let html = fs.readFileSync(htmlPath, 'utf-8');
if (!isProd && vite?.transformIndexHtml) { if(!isProd && vite?.transformIndexHtml) {
html = await vite.transformIndexHtml(req.originalUrl, html); html = await vite.transformIndexHtml(req.originalUrl, html);
} }
@@ -395,7 +432,7 @@ router.get('/admin', mw.adminOnly, asyncHandler(async (req, res) => {
'<head>', '<head>',
`<head>\n<script id="props">window.__INITIAL_PROPS__ = ${JSON.stringify(props)}</script>` `<head>\n<script id="props">window.__INITIAL_PROPS__ = ${JSON.stringify(props)}</script>`
)); ));
})); }));
return router; return router;
+2 -2
View File
@@ -55,7 +55,7 @@ export default async function createApp(vite) {
app.set('trust proxy', 1 /* number of proxies between user and server */); app.set('trust proxy', 1 /* number of proxies between user and server */);
if (vite) { if(vite) {
app.use(vite.middlewares); app.use(vite.middlewares);
} }
@@ -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;
-1
View File
@@ -14,7 +14,6 @@ const DEFAULT_BREW = {
theme : '5ePHB', theme : '5ePHB',
authors : [], authors : [],
tags : [], tags : [],
systems : [],
lang : 'en', lang : 'en',
thumbnail : '', thumbnail : '',
views : 0, views : 0,
-2
View File
@@ -151,7 +151,6 @@ const GoogleActions = {
description : file.description, description : file.description,
views : parseInt(file.properties.views), views : parseInt(file.properties.views),
published : file.properties.published ? file.properties.published == 'true' : false, published : file.properties.published ? file.properties.published == 'true' : false,
systems : [],
lang : file.properties.lang, lang : file.properties.lang,
thumbnail : file.properties.thumbnail, thumbnail : file.properties.thumbnail,
webViewLink : file.webViewLink webViewLink : file.webViewLink
@@ -298,7 +297,6 @@ const GoogleActions = {
text : file.data, text : file.data,
description : obj.data.description, description : obj.data.description,
systems : obj.data.properties.systems ? obj.data.properties.systems.split(',') : [],
authors : [], authors : [],
lang : obj.data.properties.lang, lang : obj.data.properties.lang,
published : obj.data.properties.published ? obj.data.properties.published == 'true' : false, published : obj.data.properties.published ? obj.data.properties.published == 'true' : false,
+42 -8
View File
@@ -31,6 +31,27 @@ const isStaticTheme = (renderer, themeName)=>{
// }); // });
// }; // };
const migrateSystemsToTags = (brew)=>{
if(!('systems' in brew)) return brew;
if(!Array.isArray(brew.systems) || brew.systems.length === 0) {
brew.systems = undefined;
return brew;
}
const systemMap = {
'5e' : 'system:D&D 5e',
'4e' : 'system:D&D 4e',
'3.5e' : 'system:D&D 3.5e',
'Pathfinder' : 'system:Pathfinder 2e'
};
const systemTags = brew.systems.map((s)=>systemMap[s]);
brew.tags = _.uniq([...(brew.tags || []), ...systemTags]);
brew.systems = undefined;
return brew;
};
const MAX_TITLE_LENGTH = 100; const MAX_TITLE_LENGTH = 100;
const api = { const api = {
@@ -167,7 +188,10 @@ const api = {
stub.renderer = stub.renderer || undefined; // Clear empty strings stub.renderer = stub.renderer || undefined; // Clear empty strings
stub = _.defaults(stub, DEFAULT_BREW_LOAD); // Fill in blank fields stub = _.defaults(stub, DEFAULT_BREW_LOAD); // Fill in blank fields
req.brew = stub;
const fixedStub = migrateSystemsToTags(stub);
req.brew = fixedStub;
next(); next();
}; };
}, },
@@ -191,7 +215,7 @@ const api = {
`\`\`\`\n\n` + `\`\`\`\n\n` +
`${text}`; `${text}`;
} }
const metadata = _.pick(brew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme']); const metadata = _.pick(brew, ['title', 'description', 'tags', 'renderer', 'theme']);
const snippetsArray = brewSnippetsToJSON('brew_snippets', brew.snippets, null, false).snippets; const snippetsArray = brewSnippetsToJSON('brew_snippets', brew.snippets, null, false).snippets;
metadata.snippets = snippetsArray.length > 0 ? snippetsArray : undefined; metadata.snippets = snippetsArray.length > 0 ? snippetsArray : undefined;
text = `\`\`\`metadata\n` + text = `\`\`\`metadata\n` +
@@ -365,22 +389,29 @@ 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.` }));
} }
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
}); });
@@ -389,6 +420,9 @@ const api = {
} }
let brew = _.assign(brewFromServer, brewFromClient); let brew = _.assign(brewFromServer, brewFromClient);
migrateSystemsToTags(brew);
brew.title = brew.title.trim(); brew.title = brew.title.trim();
brew.description = brew.description.trim() || ''; brew.description = brew.description.trim() || '';
brew.text = api.mergeBrewText(brew); brew.text = api.mergeBrewText(brew);
@@ -478,7 +512,7 @@ const api = {
await HomebrewModel.deleteOne({ editId: id }); await HomebrewModel.deleteOne({ editId: id });
return next(); return next();
} }
throw(err); throw (err);
} }
let brew = req.brew; let brew = req.brew;
-27
View File
@@ -63,7 +63,6 @@ describe('Tests for api', ()=>{
title : 'some title', title : 'some title',
description : 'this is a description', description : 'this is a description',
tags : ['something', 'fun'], tags : ['something', 'fun'],
systems : ['D&D 5e'],
lang : 'en', lang : 'en',
renderer : 'v3', renderer : 'v3',
theme : 'phb', theme : 'phb',
@@ -350,7 +349,6 @@ describe('Tests for api', ()=>{
renderer : 'legacy', renderer : 'legacy',
lang : 'en', lang : 'en',
shareId : undefined, shareId : undefined,
systems : [],
tags : [], tags : [],
theme : '5ePHB', theme : '5ePHB',
thumbnail : '', thumbnail : '',
@@ -450,7 +448,6 @@ describe('Tests for api', ()=>{
title : 'some title', title : 'some title',
description : 'this is a description', description : 'this is a description',
tags : ['something', 'fun'], tags : ['something', 'fun'],
systems : ['D&D 5e'],
renderer : 'v3', renderer : 'v3',
theme : 'phb', theme : 'phb',
googleId : '12345' googleId : '12345'
@@ -462,8 +459,6 @@ description: this is a description
tags: tags:
- something - something
- fun - fun
systems:
- D&D 5e
renderer: v3 renderer: v3
theme: phb theme: phb
@@ -479,7 +474,6 @@ brew`);
title : 'some title', title : 'some title',
description : 'this is a description', description : 'this is a description',
tags : ['something', 'fun'], tags : ['something', 'fun'],
systems : ['D&D 5e'],
renderer : 'v3', renderer : 'v3',
theme : 'phb', theme : 'phb',
googleId : '12345' googleId : '12345'
@@ -491,8 +485,6 @@ description: this is a description
tags: tags:
- something - something
- fun - fun
systems:
- D&D 5e
renderer: v3 renderer: v3
theme: phb theme: phb
@@ -522,7 +514,6 @@ brew`);
expect(sent).toEqual(googleBrew); expect(sent).toEqual(googleBrew);
expect(result.tags).toBeUndefined(); expect(result.tags).toBeUndefined();
expect(result.systems).toBeUndefined();
expect(result.published).toBeUndefined(); expect(result.published).toBeUndefined();
expect(result.authors).toBeUndefined(); expect(result.authors).toBeUndefined();
expect(result.owner).toBeUndefined(); expect(result.owner).toBeUndefined();
@@ -614,7 +605,6 @@ brew`);
lang : 'en', lang : 'en',
shareId : expect.any(String), shareId : expect.any(String),
style : undefined, style : undefined,
systems : [],
tags : [], tags : [],
text : undefined, text : undefined,
textBin : expect.objectContaining({}), textBin : expect.objectContaining({}),
@@ -674,7 +664,6 @@ brew`);
shareId : expect.any(String), shareId : expect.any(String),
googleId : expect.any(String), googleId : expect.any(String),
style : undefined, style : undefined,
systems : [],
tags : [], tags : [],
text : undefined, text : undefined,
textBin : undefined, textBin : undefined,
@@ -1147,7 +1136,6 @@ brew`);
'title: title\n' + 'title: title\n' +
'description: description\n' + 'description: description\n' +
'tags: [ \'tag a\' , \'tag b\' ]\n' + 'tags: [ \'tag a\' , \'tag b\' ]\n' +
'systems: [ test system ]\n' +
'renderer: legacy\n' + 'renderer: legacy\n' +
'theme: 5ePHB\n' + 'theme: 5ePHB\n' +
'lang: en\n' + 'lang: en\n' +
@@ -1168,8 +1156,6 @@ brew`);
// Metadata // Metadata
expect(testBrew.title).toEqual('title'); expect(testBrew.title).toEqual('title');
expect(testBrew.description).toEqual('description'); expect(testBrew.description).toEqual('description');
expect(testBrew.tags).toEqual(['tag a', 'tag b']);
expect(testBrew.systems).toEqual(['test system']);
expect(testBrew.renderer).toEqual('legacy'); expect(testBrew.renderer).toEqual('legacy');
expect(testBrew.theme).toEqual('5ePHB'); expect(testBrew.theme).toEqual('5ePHB');
expect(testBrew.lang).toEqual('en'); expect(testBrew.lang).toEqual('en');
@@ -1178,19 +1164,6 @@ brew`);
// Text // Text
expect(testBrew.text).toEqual('text\n'); expect(testBrew.text).toEqual('text\n');
}); });
it('convert tags string to array', async ()=>{
const testBrew = {
text : '```metadata\n' +
'tags: tag a\n' +
'```\n\n'
};
splitTextStyleAndMetadata(testBrew);
// Metadata
expect(testBrew.tags).toEqual(['tag a']);
});
}); });
}); });
+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 : [String], 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 },
+52 -5
View File
@@ -91,7 +91,7 @@ const splitTextStyleAndMetadata = (brew)=>{
const index = brew.text.indexOf('\n```\n\n'); const index = brew.text.indexOf('\n```\n\n');
const metadataSection = brew.text.slice(11, index + 1); const metadataSection = brew.text.slice(11, index + 1);
const metadata = yaml.load(metadataSection); const metadata = yaml.load(metadataSection);
Object.assign(brew, _.pick(metadata, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang'])); Object.assign(brew, _.pick(metadata, ['title', 'description', 'renderer', 'theme', 'lang']));
brew.snippets = yamlSnippetsToText(_.pick(metadata, ['snippets']).snippets || ''); brew.snippets = yamlSnippetsToText(_.pick(metadata, ['snippets']).snippets || '');
brew.text = brew.text.slice(index + 6); brew.text = brew.text.slice(index + 6);
} }
@@ -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') {
// fire a custom event for the print cycle
document.dispatchEvent(new CustomEvent('print:startprep'));
try {
const iframeDoc = window.frames['BrewRenderer'].contentDocument;
// 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(); window.frames['BrewRenderer'].contentWindow.print();
//Force DOM reflow; Print dialog causes a repaint, and @media print CSS somehow makes out-of-view pages disappear //Force DOM reflow; Print dialog causes a repaint, and @media print CSS somehow makes out-of-view pages disappear
const node = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer').item(0); const node = iframeDoc.getElementsByClassName('brewRenderer').item(0);
node.style.display='none'; node.style.display='none';
node.offsetHeight; // accessing this is enough to trigger a reflow node.offsetHeight; // accessing this is enough to trigger a reflow
node.style.display=''; 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;
} }
} }
+5 -5
View File
@@ -1,4 +1,4 @@
/* eslint-disable max-depth */
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import _ from 'lodash'; import _ from 'lodash';
import { marked as Marked } from 'marked'; import { marked as Marked } from 'marked';
@@ -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;
}; };
@@ -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}"`;
+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;
}; };
+1 -8
View File
@@ -4,14 +4,7 @@
@import './animations.less'; @import './animations.less';
@import './colors.less'; @import './colors.less';
@import './tooltip.less'; @import './tooltip.less';
@font-face { @import './fonts/fonts.css';
font-family : 'CodeLight';
src : url('./CODE Light.otf') format('opentype');
}
@font-face {
font-family : 'CodeBold';
src : url('./CODE Bold.otf') format('opentype');
}
html,body, #reactRoot { html,body, #reactRoot {
height : 100vh; height : 100vh;
min-height : 100vh; min-height : 100vh;
+38
View File
@@ -0,0 +1,38 @@
/* open-sans-latin-wght-normal */
@font-face {
font-family : 'Open Sans';
font-style : normal;
font-weight : normal;
src : url('open-sans-latin-400-normal.woff2') format('woff2');
font-display : swap;
unicode-range : U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/*
* Nowhere is font-weight: 700 actually used with Open Sans, everything is set to 800.
* But, 800 *is* too bold. And since we don't have an 800 font file, it's just using the
* 700 font file and it looks fine. Not sure it's worth changing everything to 700?
*/
@font-face {
font-family : 'Open Sans';
font-style : normal;
font-weight : bold;
src : url('open-sans-latin-700-normal.woff2') format('woff2');
font-display : swap;
unicode-range : U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family : 'CodeLight';
font-style : normal;
src : url('./CODE Light.otf') format('opentype');
font-display : block;
}
@font-face {
font-family : 'CodeBold';
font-style : normal;
src : url('./CODE Bold.otf') format('opentype');
font-display : block;
}
+2
View File
@@ -21,3 +21,5 @@
text-transform : unset; text-transform : unset;
background-color : unset; background-color : unset;
} }
:where(i){ text-box-trim: trim-both }
+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>');
// });
+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>');
}); });
}); });
@@ -99,7 +99,7 @@ const subtitles = [
function coverPageGen() { function coverPageGen() {
return `<style> return `<style>
.phb#p1{ text-align:center; } .phb#p1{ text-align:center; }
.phb#p1:after{ display:none; } .phb#p1:after{ display:none; }
</style> </style>
+2 -7
View File
@@ -112,25 +112,21 @@ export default [
name : 'Front Cover Page', name : 'Front Cover Page',
icon : 'fac book-front-cover', icon : 'fac book-front-cover',
gen : CoverPageGen.front, gen : CoverPageGen.front,
experimental : true
}, },
{ {
name : 'Inside Cover Page', name : 'Inside Cover Page',
icon : 'fac book-inside-cover', icon : 'fac book-inside-cover',
gen : CoverPageGen.inside, gen : CoverPageGen.inside,
experimental : true
}, },
{ {
name : 'Part Cover Page', name : 'Part Cover Page',
icon : 'fac book-part-cover', icon : 'fac book-part-cover',
gen : CoverPageGen.part, gen : CoverPageGen.part,
experimental : true
}, },
{ {
name : 'Back Cover Page', name : 'Back Cover Page',
icon : 'fac book-back-cover', icon : 'fac book-back-cover',
gen : CoverPageGen.back, gen : CoverPageGen.back,
experimental : true
}, },
{ {
name : 'Magic Item', name : 'Magic Item',
@@ -143,8 +139,8 @@ export default [
gen : function(){ gen : function(){
return dedent` return dedent`
{{artist,top:90px,right:30px {{artist,top:90px,right:30px
##### Starry Night ##### Bird with autumn foliage
[Van Gogh](https://www.vangoghmuseum.nl/en) [by L. Prang & Co.](https://www.loc.gov/resource/pga.14148)
}} }}
\n`; \n`;
}, },
@@ -212,7 +208,6 @@ export default [
name : 'Rune Table', name : 'Rune Table',
icon : 'fas fa-language', icon : 'fas fa-language',
gen : scriptGen.dwarvish, gen : scriptGen.dwarvish,
experimental : true,
subsnippets : [ subsnippets : [
{ {
name : 'Dwarvish', name : 'Dwarvish',
+8 -7
View File
@@ -1,5 +1,6 @@
import _ from 'lodash'; import _ from 'lodash';
import dedent from 'dedent'; import dedent from 'dedent';
const domain = window.location.origin;
const titles = [ const titles = [
'The Burning Gallows', 'The Ring of Nenlast', 'The Burning Gallows', 'The Ring of Nenlast',
@@ -84,7 +85,7 @@ export default {
return dedent` return dedent`
{{frontCover}} {{frontCover}}
{{logo ![](https://homebrewery.naturalcrit.com/assets/naturalCritLogoRed.svg)}} {{logo ![](${domain}/assets/naturalCritLogoRed.svg)}}
# ${_.sample(titles)} # ${_.sample(titles)}
## ${_.sample(subtitles)} ## ${_.sample(subtitles)}
@@ -96,7 +97,7 @@ export default {
${_.sample(footnote)} ${_.sample(footnote)}
}} }}
![background image](https://homebrewery.naturalcrit.com/assets/demontemple.jpg){position:absolute,bottom:0,left:0,height:100%} ![The Departure, 1837, by Thomas Cole](${domain}/assets/the_departure.webp){position:absolute,bottom:0,right:-400px,height:100%}
\page`; \page`;
}, },
@@ -110,10 +111,10 @@ export default {
___ ___
{{imageMaskCenter${_.random(1, 16)},--offsetX:0%,--offsetY:0%,--rotation:0 {{imageMaskCenter${_.random(1, 16)},--offsetX:0%,--offsetY:0%,--rotation:0
![background image](https://homebrewery.naturalcrit.com/assets/mountaincottage.jpg){position:absolute,bottom:0,left:0,height:100%} ![The Spirit of War, 1851, by Jasper Francis Cropsey](${domain}/assets/the_spirit_of_war.webp){position:absolute,bottom:100px,right:70px,height:70%}
}} }}
{{logo ![](https://homebrewery.naturalcrit.com/assets/naturalCritLogoRed.svg)}} {{logo ![](${domain}/assets/naturalCritLogoRed.svg)}}
\page`; \page`;
}, },
@@ -126,7 +127,7 @@ export default {
## ${_.sample(subtitles)} ## ${_.sample(subtitles)}
{{imageMaskEdge${_.random(1, 8)},--offset:10cm,--rotation:180 {{imageMaskEdge${_.random(1, 8)},--offset:10cm,--rotation:180
![Background image](https://homebrewery.naturalcrit.com/assets/nightchapel.jpg){position:absolute,bottom:0,left:0,height:100%} ![The United States Frigate "President" Engaging the British Squadron, 1815, 1850, by Fitz Henry Lane](${domain}/assets/frigate.webp){position:absolute,bottom:0,right:0,height:100%}
}} }}
\page`; \page`;
@@ -143,10 +144,10 @@ export default {
For use with any fantasy roleplaying ruleset. Play the best game of your life! For use with any fantasy roleplaying ruleset. Play the best game of your life!
![background image](https://homebrewery.naturalcrit.com/assets/shopvials.jpg){position:absolute,bottom:0,left:0,height:100%} ![Italian Coast Scene with Ruined Tower, 1838, by Thomas Cole](${domain}/assets/ruined_tower.webp){position:absolute,bottom:0,right:-250px,height:100%}
{{logo {{logo
![](https://homebrewery.naturalcrit.com/assets/naturalCritLogoWhite.svg) ![](${domain}/assets/naturalCritLogoWhite.svg)
Homebrewery.Naturalcrit.com Homebrewery.Naturalcrit.com
}}`; }}`;
+10 -10
View File
@@ -11,6 +11,7 @@ import LicenseDTTRPGGCC from './snippets/licenseDTRPGCC.gen.js';
import LicenseMongoosePublishing from './snippets/licenseMongoose.gen.js'; import LicenseMongoosePublishing from './snippets/licenseMongoose.gen.js';
import TableOfContentsGen from './snippets/tableOfContents.gen.js'; import TableOfContentsGen from './snippets/tableOfContents.gen.js';
import indexGen from './snippets/index.gen.js'; import indexGen from './snippets/index.gen.js';
const domain = window.location.origin;
export default [ export default [
@@ -197,7 +198,6 @@ export default [
name : 'Index', name : 'Index',
icon : 'fas fa-bars', icon : 'fas fa-bars',
gen : indexGen, gen : indexGen,
experimental : true
}, },
] ]
@@ -329,7 +329,7 @@ export default [
}, },
{ {
name : 'DTRPG Community Content', name : 'DTRPG Community Content',
incon : 'fab fa-dtrpg', icon : null,
subsnippets : [ subsnippets : [
{ {
name : 'Chronicle System Guild Colophon', name : 'Chronicle System Guild Colophon',
@@ -519,13 +519,13 @@ export default [
{ {
name : 'MIT License', name : 'MIT License',
icon : 'fas fa-mit', icon : null,
gen : LicenseGen.mit, gen : LicenseGen.mit,
}, },
{ {
name : 'Mongoose Publishing Fair Use', name : 'Mongoose Publishing Fair Use',
icon : 'fas fa-mongoosepub', icon : null,
subsnippets : [ subsnippets : [
{ {
name : 'Long Form Fair Use', name : 'Long Form Fair Use',
@@ -553,14 +553,14 @@ export default [
{ {
name : 'ORC Notice', name : 'ORC Notice',
icon : 'fas fa-Paizo', icon : null,
gen : LicenseGen.orc1, gen : LicenseGen.orc1,
}, },
{ {
name : 'Shadowdark', name : 'Shadowdark',
icon : 'fab fa-shadowdark', icon : null,
subsnippets : [ subsnippets : [
{ {
name : 'Logos', name : 'Logos',
@@ -645,25 +645,25 @@ export default [
name : 'Image', name : 'Image',
icon : 'fas fa-image', icon : 'fas fa-image',
gen : dedent` gen : dedent`
![cat warrior](https://homebrewery.naturalcrit.com/assets/catwarrior.jpg) {width:325px,mix-blend-mode:multiply}` ![Bird with autumn foliage by L. Prang & Co.](${domain}/assets/bird.webp) {width:325px}`
}, },
{ {
name : 'Image Wrap Left', name : 'Image Wrap Left',
icon : 'fac image-wrap-left', icon : 'fac image-wrap-left',
gen : dedent` gen : dedent`
![homebrewery_mug](https://homebrewery.naturalcrit.com/assets/homebrewerymug.png) {width:280px,margin-right:-3cm,wrapLeft}` ![homebrewery_mug](${domain}/assets/homebrewerymug.png) {width:280px,margin-right:-3cm,wrapLeft}`
}, },
{ {
name : 'Image Wrap Right', name : 'Image Wrap Right',
icon : 'fac image-wrap-right', icon : 'fac image-wrap-right',
gen : dedent` gen : dedent`
![homebrewery_mug](https://homebrewery.naturalcrit.com/assets/homebrewerymug.png) {width:280px,margin-left:-3cm,wrapRight}` ![homebrewery_mug](${domain}/assets/homebrewerymug.png) {width:280px,margin-left:-3cm,wrapRight}`
}, },
{ {
name : 'Background Image', name : 'Background Image',
icon : 'fas fa-tree', icon : 'fas fa-tree',
gen : dedent` gen : dedent`
![homebrew mug](https://homebrewery.naturalcrit.com/assets/homebrewerymug.png) {position:absolute,top:50px,right:30px,width:280px}` ![homebrew mug](${domain}/assets/homebrewerymug.png) {position:absolute,top:50px,right:30px,width:280px}`
}, },
{ {
name : 'Watercolor Splatter', name : 'Watercolor Splatter',
+19 -4
View File
@@ -1,11 +1,12 @@
import _ from 'lodash'; import _ from 'lodash';
import dedent from 'dedent'; import dedent from 'dedent';
const domain = window.location.origin;
export default { export default {
center : ()=>{ center : ()=>{
return dedent` return dedent`
{{imageMaskCenter${_.random(1, 16)},--offsetX:0%,--offsetY:0%,--rotation:0 {{imageMaskCenter${_.random(1, 16)},--offsetX:0%,--offsetY:0%,--rotation:0
![](https://homebrewery.naturalcrit.com/assets/dragoninflight.jpg){height:100%} ![The Roman Theater at Taormina, 1828, by Louise-Joséphine sarazin de Belmont](${domain}/assets/roman_theatre.webp){height:100%}
}} }}
<!-- Use --offsetX to shift the mask left or right (can use cm instead of %) <!-- Use --offsetX to shift the mask left or right (can use cm instead of %)
Use --offsetY to shift the mask up or down Use --offsetY to shift the mask up or down
@@ -13,6 +14,20 @@ export default {
}, },
edge : (side = 'bottom')=>{ edge : (side = 'bottom')=>{
const styles = ()=>{
switch (side) {
case 'bottom':
return `{width:100%,bottom:0%}`;
break;
case 'top':
return `{width:100%,top:0%}`;
break;
default:
return `{height:100%}`;
break;
}
};
const rotation = { const rotation = {
'bottom' : 0, 'bottom' : 0,
'top' : 180, 'top' : 180,
@@ -20,8 +35,8 @@ export default {
'right' : 270 'right' : 270
}[side]; }[side];
return dedent` return dedent`
{{imageMaskEdge${_.random(1, 8)},--offset:0%,--rotation:${rotation} {{imageMaskEdge${_.random(1, 8)},--offset:10%,--rotation:${rotation}
![](https://homebrewery.naturalcrit.com/assets/dragoninflight.jpg){height:100%} ![The Roman Theater at Taormina, 1828, by Louise-Joséphine sarazin de Belmont](${domain}/assets/roman_theatre.webp)${styles()}
}} }}
<!-- Use --offset to shift the mask away from page center (can use cm instead of %) <!-- Use --offset to shift the mask away from page center (can use cm instead of %)
Use --rotation to set rotation angle in degrees. -->\n\n`; Use --rotation to set rotation angle in degrees. -->\n\n`;
@@ -32,7 +47,7 @@ export default {
const offsetY = (y == 'top' ? '50%' : '-50%'); const offsetY = (y == 'top' ? '50%' : '-50%');
return dedent` return dedent`
{{imageMaskCorner${_.random(1, 37)},--offsetX:${offsetX},--offsetY:${offsetY},--rotation:0 {{imageMaskCorner${_.random(1, 37)},--offsetX:${offsetX},--offsetY:${offsetY},--rotation:0
![](https://homebrewery.naturalcrit.com/assets/dragoninflight.jpg){height:100%} ![The Roman Theater at Taormina, 1828, by Louise-Joséphine sarazin de Belmont](${domain}/assets/roman_theatre.webp){height:100%}
}} }}
<!-- Use --offsetX to shift the mask left or right (can use cm instead of %) <!-- Use --offsetX to shift the mask left or right (can use cm instead of %)
Use --offsetY to shift the mask up or down Use --offsetY to shift the mask up or down
+4 -3
View File
@@ -1,5 +1,6 @@
import dedent from 'dedent'; import dedent from 'dedent';
const domain = window.location.origin;
// Small and one-off licenses // Small and one-off licenses
// Licenses in this file consist of one or two functions at most. If something is larger, // Licenses in this file consist of one or two functions at most. If something is larger,
@@ -50,10 +51,10 @@ export default {
ccbyndBadge : `![CC BY-ND](https://mirrors.creativecommons.org/presskit/buttons/88x31/svg/by-nd.svg)`, ccbyndBadge : `![CC BY-ND](https://mirrors.creativecommons.org/presskit/buttons/88x31/svg/by-nd.svg)`,
ccbyncndBadge : `![CC BY-NC-ND](https://mirrors.creativecommons.org/presskit/buttons/88x31/svg/by-nc-nd.svg)`, ccbyncndBadge : `![CC BY-NC-ND](https://mirrors.creativecommons.org/presskit/buttons/88x31/svg/by-nc-nd.svg)`,
shadowDarkNotice : `\[Product Name]\ is an independent product published under the Shadowdark RPG Third-Party License and is not affiliated with The Arcane Library, LLC. Shadowdark RPG © 2023 The Arcane Library, LLC.\n`, shadowDarkNotice : `\[Product Name]\ is an independent product published under the Shadowdark RPG Third-Party License and is not affiliated with The Arcane Library, LLC. Shadowdark RPG © 2023 The Arcane Library, LLC.\n`,
shadowDarkBlack : `![Shadowdark Black Logo](https://homebrewery.naturalcrit.com/assets/license_logos/The-Arcane-Library_Third-Party-License_Black.png){width:200px}`, shadowDarkBlack : `![Shadowdark Black Logo](${domain}/assets/license_logos/The-Arcane-Library_Third-Party-License_Black.png){width:200px}`,
shadowDarkWhite : `![Shadowdark White Logo](https://homebrewery.naturalcrit.com/assets/license_logos/The-Arcane-Library_Third-Party-License_White.png){width:200px}`, shadowDarkWhite : `![Shadowdark White Logo](${domain}/assets/license_logos/The-Arcane-Library_Third-Party-License_White.png){width:200px}`,
bladesDarkNotice : `This work is based on Blades in the Dark \(found at (http://www.bladesinthedark.com/)\), product of One Seven Design, developed and authored by John Harper, and licensed for our use under the Creative Commons Attribution 3.0 Unported license \(http://creativecommons.org/licenses/by/3.0/\).\n`, bladesDarkNotice : `This work is based on Blades in the Dark \(found at (http://www.bladesinthedark.com/)\), product of One Seven Design, developed and authored by John Harper, and licensed for our use under the Creative Commons Attribution 3.0 Unported license \(http://creativecommons.org/licenses/by/3.0/\).\n`,
bladesDarkLogo : `![Forged in the Dark](https://homebrewery.naturalcrit.com/assets/license_logos/Evil-Hat_Forged-In-The-Dark_Logo-V2.png)`, bladesDarkLogo : `![Forged in the Dark](${domain}/assets/license_logos/Evil-Hat_Forged-In-The-Dark_Logo-V2.png)`,
bladesDarkLogoAttribution : `*Blades in the Dark^tm^ is a trademark of One Seven Design. The Forged in the Dark Logo is © One Seven Design, and is used with permission.*`, bladesDarkLogoAttribution : `*Blades in the Dark^tm^ is a trademark of One Seven Design. The Forged in the Dark Logo is © One Seven Design, and is used with permission.*`,
iconsCompatibility : 'Compatibility with Icons requires Icons Superpowered Roleplaying from Ad Infinitum Adventures. Ad Infinitum Adventures does not guarantee compatibility, and does not endorse this product.', iconsCompatibility : 'Compatibility with Icons requires Icons Superpowered Roleplaying from Ad Infinitum Adventures. Ad Infinitum Adventures does not guarantee compatibility, and does not endorse this product.',
iconsTrademark : 'Icons Superpowered Roleplaying is a trademark of Steve Kenson, published exclusively by Ad Infinitum Adventures. The Icons Superpowered Roleplaying Compatibility Logo is a trademark of Ad Infinitum Adventures and is used under the Icons Superpowered Roleplaying Compatibility License.', iconsTrademark : 'Icons Superpowered Roleplaying is a trademark of Steve Kenson, published exclusively by Ad Infinitum Adventures. The Icons Superpowered Roleplaying Compatibility Logo is a trademark of Ad Infinitum Adventures and is used under the Icons Superpowered Roleplaying Compatibility License.',
@@ -1,5 +1,6 @@
import dedent from 'dedent'; import dedent from 'dedent';
const domain = window.location.origin;
// DriveThruRPG/OneBookShelf Community Content Programs // DriveThruRPG/OneBookShelf Community Content Programs
@@ -101,10 +102,10 @@ export default {
}, },
// Verify Logo redistribution // Verify Logo redistribution
greenRoninAgeCreatorsAllianceCover : `Requires the \[Game Title\] Rulebook from Green Ronin Publishing for use.`, greenRoninAgeCreatorsAllianceCover : `Requires the \[Game Title\] Rulebook from Green Ronin Publishing for use.`,
greenRoninAgeCreatorsAllianceLogo : `![Age Creators Alliance](https://homebrewery.naturalcrit.com/assets/license_logos/Green-Ronin_AGE-Creators-Alliance_General-Compatibility-Logo.png){width:200px}`, greenRoninAgeCreatorsAllianceLogo : `![Age Creators Alliance](${domain}/assets/license_logos/Green-Ronin_AGE-Creators-Alliance_General-Compatibility-Logo.png){width:200px}`,
greenRoninAgeCreatorsAllianceBlueRoseLogo : `![Age Creators Alliance](https://homebrewery.naturalcrit.com/assets/license_logos/Green-Ronin_AGE-Creators-Alliance_Blue-Rose-Compatibility-Logo.png){width:200px}`, greenRoninAgeCreatorsAllianceBlueRoseLogo : `![Age Creators Alliance](${domain}/assets/license_logos/Green-Ronin_AGE-Creators-Alliance_Blue-Rose-Compatibility-Logo.png){width:200px}`,
greenRoninAgeCreatorsAllianceFantasyAgeCompatible : `![Fantasy AGE Compatible](https://homebrewery.naturalcrit.com/assets/license_logos/Green-Ronin_AGE-Creators-Alliance_Fantasy-AGE-Compatibility-Logo.png){width:200px}`, greenRoninAgeCreatorsAllianceFantasyAgeCompatible : `![Fantasy AGE Compatible](${domain}/assets/license_logos/Green-Ronin_AGE-Creators-Alliance_Fantasy-AGE-Compatibility-Logo.png){width:200px}`,
greenRoninAgeCreatorsAllianceModernAGECompatible : `![Modern AGE Compatible](https://homebrewery.naturalcrit.com/assets/license_logos/Green-Ronin_AGE-Creators-Alliance_Modern-AGE-Compatibility-Logo.png){width:200px}`, greenRoninAgeCreatorsAllianceModernAGECompatible : `![Modern AGE Compatible](${domain}/assets/license_logos/Green-Ronin_AGE-Creators-Alliance_Modern-AGE-Compatibility-Logo.png){width:200px}`,
// Green Ronin's Chronicle - Verify Art and Access // Green Ronin's Chronicle - Verify Art and Access
greenRoninChronicleSystemGuildColophon : function() { greenRoninChronicleSystemGuildColophon : function() {
return dedent` return dedent`
@@ -179,10 +180,10 @@ export default {
`; `;
}, },
// Verify Logo redistribution // Verify Logo redistribution
monteCookLogoDarkLarge : `![Cypher System Compatible](https://homebrewery.naturalcrit.com/assets/license_logos/CSCDarkLarge.png)`, monteCookLogoDarkLarge : `![Cypher System Compatible](${domain}/assets/license_logos/CSCDarkLarge.png)`,
monteCookLogoDarkSmall : `![Cypher System Compatible](https://homebrewery.naturalcrit.com/assets/license_logos/CSCDarkSmall.png)`, monteCookLogoDarkSmall : `![Cypher System Compatible](${domain}/assets/license_logos/CSCDarkSmall.png)`,
monteCookLogoLightLarge : `![Cypher System Compatible](https://homebrewery.naturalcrit.com/assets/license_logos/CSCLightLarge.png)`, monteCookLogoLightLarge : `![Cypher System Compatible](${domain}/assets/license_logos/CSCLightLarge.png)`,
monteCookLogoLightSmall : `![Cypher System Compatible](https://homebrewery.naturalcrit.com/assets/license_logos/CSCLightSmall.png)`, monteCookLogoLightSmall : `![Cypher System Compatible](${domain}/assets/license_logos/CSCLightSmall.png)`,
// Onyx Path Canis Minor - Verify logos and access // Onyx Path Canis Minor - Verify logos and access
onyxPathCanisMinorColophon : function () { onyxPathCanisMinorColophon : function () {
return dedent` return dedent`
@@ -1,6 +1,3 @@
import dedent from 'dedent';
// Mongoose Publishing Licenses // Mongoose Publishing Licenses
export default { export default {
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Some files were not shown because too many files have changed in this diff Show More