mirror of
https://github.com/naturalcrit/homebrewery.git
synced 2026-11-14 21:18:44 +00:00
Merge branch 'master' of https://github.com/naturalcrit/homebrewery into search-by-tags
This commit is contained in:
@@ -49,4 +49,4 @@ const Admin = ()=>{
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = Admin;
|
||||
export default Admin;
|
||||
|
||||
+10
-8
@@ -1,11 +1,9 @@
|
||||
@import 'naturalcrit/styles/reset.less';
|
||||
@import 'naturalcrit/styles/elements.less';
|
||||
@import 'naturalcrit/styles/animations.less';
|
||||
@import 'naturalcrit/styles/colors.less';
|
||||
@import 'naturalcrit/styles/tooltip.less';
|
||||
@import './themes/fonts/iconFonts/fontAwesome.less';
|
||||
|
||||
@import 'font-awesome/css/font-awesome.css';
|
||||
@import '@sharedStyles/reset.less';
|
||||
@import '@sharedStyles/elements.less';
|
||||
@import '@sharedStyles/animations.less';
|
||||
@import '@sharedStyles/colors.less';
|
||||
@import '@sharedStyles/tooltip.less';
|
||||
@import '@themes/fonts/iconFonts/fontAwesome.less';
|
||||
|
||||
html,body, #reactContainer, .naturalCrit { min-height : 100%; }
|
||||
|
||||
@@ -109,9 +107,13 @@ body {
|
||||
}
|
||||
|
||||
th, td {
|
||||
max-width : 50ch;
|
||||
padding : 5px 10px;
|
||||
overflow : hidden;
|
||||
text-overflow : ellipsis;
|
||||
vertical-align : middle;
|
||||
text-align : center;
|
||||
white-space : nowrap;
|
||||
border-right : 1px solid;
|
||||
|
||||
&:last-child { border-right : none; }
|
||||
|
||||
@@ -1,71 +1,176 @@
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import React, { useState } from 'react';
|
||||
import request from 'superagent';
|
||||
import Moment from 'moment';
|
||||
|
||||
const BrewCleanup = createReactClass({
|
||||
displayName : 'BrewCleanup',
|
||||
getDefaultProps(){
|
||||
return {};
|
||||
},
|
||||
getInitialState() {
|
||||
return {
|
||||
count : 0,
|
||||
const BrewCleanup = ({})=>{
|
||||
const [junkBrewCollection, setJunkBrewCollection] = useState([]);
|
||||
const [lostBrewCollection, setLostBrewCollection] = useState([]);
|
||||
const [pendingJunk, setPendingJunk] = useState(false);
|
||||
const [pendingLost, setPendingLost] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
pending : false,
|
||||
primed : false,
|
||||
err : null
|
||||
};
|
||||
},
|
||||
prime(){
|
||||
this.setState({ pending: true });
|
||||
const find = async (type)=>{
|
||||
|
||||
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')
|
||||
.then((res)=>this.setState({ count: res.body.count }))
|
||||
.catch((err)=>this.setState({ error: err }))
|
||||
.finally(()=>this.setState({ pending: false, primed: false }));
|
||||
},
|
||||
renderPrimed(){
|
||||
if(!this.state.primed) return;
|
||||
if(type === 'junk') try {
|
||||
setPendingJunk(true);
|
||||
const res = await request.get('/admin/cleanupJunk');
|
||||
|
||||
if(!this.state.count){
|
||||
return <div className='result noBrews'>No Matching Brews found.</div>;
|
||||
setJunkBrewCollection(res.body.brewCollection);
|
||||
} 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'>
|
||||
<button onClick={this.cleanup} className='remove'>
|
||||
{this.state.pending
|
||||
{deleteButton && <button onClick={()=>cleanup(type)} className='remove'>
|
||||
{pendingLost && type === 'lost' || pendingJunk && type === 'junk'
|
||||
? <i className='fas fa-spin fa-spinner' />
|
||||
: <span><i className='fas fa-times' /> Remove</span>
|
||||
}
|
||||
</button>
|
||||
<span>Found {this.state.count} Brews that could be removed. </span>
|
||||
}
|
||||
{renderBrewList(type)}
|
||||
</div>;
|
||||
},
|
||||
render(){
|
||||
return <div className='brewUtil brewCleanup'>
|
||||
<h2> Brew Cleanup </h2>
|
||||
<p>Removes very short brews to tidy up the database</p>
|
||||
};
|
||||
const renderJunkBrewCleanup = ()=>{
|
||||
return <div className='junk'>
|
||||
<h3> Junk brews</h3>
|
||||
<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'>
|
||||
{this.state.pending
|
||||
<button onClick={()=>find('junk')} className='query'>
|
||||
{pendingJunk
|
||||
? <i className='fas fa-spin fa-spinner' />
|
||||
: 'Query Brews'
|
||||
}
|
||||
</button>
|
||||
{this.renderPrimed()}
|
||||
{renderFound('junk')}
|
||||
|
||||
{this.state.error
|
||||
&& <div className='error noBrews'>{this.state.error.toString()}</div>
|
||||
}
|
||||
{error && <div className='error noBrews'>{error.toString()}</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;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import '@sharedStyles/colors.less';
|
||||
|
||||
.brewUtil {
|
||||
.result {
|
||||
margin-top : 20px;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
margin-left : 10px;
|
||||
}
|
||||
&.checkbox {
|
||||
line-height: 1.5em;
|
||||
line-height : 1.5em;
|
||||
input {
|
||||
width : 1.5em;
|
||||
height : 1.5em;
|
||||
@@ -26,9 +26,9 @@
|
||||
}
|
||||
|
||||
.lockSuggestions {
|
||||
columns : 2;
|
||||
line-height : 1.2em;
|
||||
break-inside : avoid;
|
||||
columns : 2;
|
||||
h2 { column-span : all; }
|
||||
h3 { margin-top : 0px; }
|
||||
b { font-weight : 600; }
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import Admin from './admin.jsx';
|
||||
import { bootstrapAnchorPositioningPolyfill } from '@components/anchorPositioningPolyfill.js';
|
||||
|
||||
const props = window.__INITIAL_PROPS__ || {};
|
||||
|
||||
createRoot(document.getElementById('reactRoot')).render(<Admin {...props} />);
|
||||
bootstrapAnchorPositioningPolyfill();
|
||||
@@ -71,10 +71,14 @@ const Anchored = ({ children })=>{
|
||||
// forward ref for AnchoredTrigger
|
||||
const AnchoredTrigger = forwardRef(({ toggleVisibility, visible, children, className, ...props }, ref)=>(
|
||||
<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}`}
|
||||
onClick={toggleVisibility}
|
||||
style={{ anchorName: `--${props.id}` }} // setting anchor properties here allows greater recyclability.
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -84,9 +88,12 @@ const AnchoredTrigger = forwardRef(({ toggleVisibility, visible, children, class
|
||||
// forward ref for AnchoredBox
|
||||
const AnchoredBox = forwardRef(({ visible, children, className, anchorId, ...props }, ref)=>(
|
||||
<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}`}
|
||||
style={{ positionAnchor: `--${anchorId}` }} // setting anchor properties here allows greater recyclability.
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
|
||||
|
||||
.anchored-box {
|
||||
position : absolute;
|
||||
visibility : hidden;
|
||||
justify-self : anchor-center;
|
||||
@supports (inset-block-start: anchor(bottom)) {
|
||||
inset-block-start : anchor(bottom);
|
||||
}
|
||||
position : absolute;
|
||||
inset-block-start : anchor(bottom);
|
||||
visibility : hidden;
|
||||
justify-self : anchor-center;
|
||||
&.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
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -1,458 +1,442 @@
|
||||
/* eslint-disable max-lines */
|
||||
/* eslint max-lines: ["error", { "max": 455 }] */
|
||||
import './codeEditor.less';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import _ from 'lodash';
|
||||
import closeTag from './close-tag';
|
||||
import autoCompleteEmoji from './autocompleteEmoji';
|
||||
let CodeMirror;
|
||||
import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
|
||||
const CodeEditor = createReactClass({
|
||||
displayName : 'CodeEditor',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
language : '',
|
||||
value : '',
|
||||
wrap : true,
|
||||
onChange : ()=>{},
|
||||
enableFolding : true,
|
||||
editorTheme : 'default'
|
||||
};
|
||||
import {
|
||||
EditorView,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
highlightActiveLineGutter,
|
||||
highlightActiveLine,
|
||||
scrollPastEnd,
|
||||
Decoration,
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
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();
|
||||
const settingsCompartment = new Compartment();
|
||||
|
||||
import { generalKeymap, markdownKeymap, cssKeymap, formatCSS } from './extensions/customKeyMaps.js';
|
||||
import foldOnPages from './extensions/customFolding.js';
|
||||
import { customHighlightStyle, customHighlightPlugin } 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 {
|
||||
docs : {}
|
||||
};
|
||||
},
|
||||
|
||||
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];
|
||||
return Decoration.set([
|
||||
Decoration.line({
|
||||
class : 'sourceMoveFlash'
|
||||
}).range(line.from)
|
||||
]);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return decorations;
|
||||
},
|
||||
|
||||
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();
|
||||
},
|
||||
|
||||
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) {
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
this.codeMirror?.replaceSelection(' ', 'end');
|
||||
},
|
||||
|
||||
makeSpace : function() {
|
||||
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() {
|
||||
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() {
|
||||
this.codeMirror?.replaceSelection('\n\\column\n\n', 'end');
|
||||
},
|
||||
|
||||
newPage : function() {
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
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.props.language === 'gfm'){
|
||||
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() {
|
||||
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) {
|
||||
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}`;
|
||||
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`;
|
||||
}
|
||||
};
|
||||
},
|
||||
//----------------------//
|
||||
|
||||
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}/>
|
||||
</>;
|
||||
}
|
||||
provide : (decorationSet)=>EditorView.decorations.from(decorationSet)
|
||||
});
|
||||
|
||||
export default CodeEditor;
|
||||
const createSettingsExtensions = (settings)=>[
|
||||
...(settings.autoCloseBrackets ? [autoCloseBrackets] : []),
|
||||
...(settings.lineNumbers ? [lineNumbers()] : []),
|
||||
...(settings.activeLineShading ? [highlightActiveLine(),
|
||||
highlightActiveLineGutter()] : []),
|
||||
...(settings.fontSize
|
||||
? [EditorView.theme({
|
||||
'&, .cm-content' : {
|
||||
fontSize : `${settings.fontSize || 1}em`,
|
||||
},
|
||||
})]
|
||||
: []),
|
||||
];
|
||||
|
||||
const CodeEditor = forwardRef(
|
||||
(
|
||||
{
|
||||
language = '',
|
||||
tab = 'brewText',
|
||||
view,
|
||||
value = '',
|
||||
onChange = ()=>{},
|
||||
onCursorChange = ()=>{},
|
||||
onViewChange = ()=>{},
|
||||
onThemeChange = ()=>{},
|
||||
editorTheme = 'default',
|
||||
style,
|
||||
renderer,
|
||||
settings = {},
|
||||
...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 foldAll = (view = viewRef.current)=>{
|
||||
if(!view) return false;
|
||||
|
||||
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 });
|
||||
return true;
|
||||
};
|
||||
|
||||
const unfoldAll = (view = viewRef.current)=>{
|
||||
if(!view) return false;
|
||||
|
||||
view.dispatch(unfoldAllCmd(view));
|
||||
return true;
|
||||
};
|
||||
|
||||
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,
|
||||
settingsCompartment.of(createSettingsExtensions(settings)),
|
||||
scrollPastEnd(),
|
||||
search(),
|
||||
history(), //allows for undo and redo
|
||||
...(tab !== 'brewStyles' ? [autocompleteEmoji] : []),
|
||||
|
||||
//folding
|
||||
foldOnPages,
|
||||
foldGutter({
|
||||
openText : '▾',
|
||||
closedText : '▸'
|
||||
}),
|
||||
|
||||
//highlights
|
||||
highlightCompartment.of([customHighlightPlugin(renderer, tab, settings), highlightExtension]),
|
||||
themeCompartment.of(themeExtension),
|
||||
|
||||
//keyboard shortcut
|
||||
keymap.of([...defaultKeymap, foldKeymap, ...searchKeymap]),
|
||||
generalKeymap(foldAll, unfoldAll),
|
||||
...(tab === 'brewStyles' ? [cssKeymap] : [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);
|
||||
view.dispatch({
|
||||
effects : settingsCompartment.reconfigure(
|
||||
createSettingsExtensions(settings)
|
||||
),
|
||||
});
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
const isDark = view.state.facet(EditorView.darkTheme);
|
||||
onThemeChange(isDark);
|
||||
}, [editorTheme, tab]);
|
||||
|
||||
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, settings), highlightExtension])
|
||||
});
|
||||
}, [renderer, tab]);
|
||||
|
||||
useEffect(()=>{
|
||||
const view = viewRef.current;
|
||||
if(!view) return;
|
||||
|
||||
view.dispatch({
|
||||
effects : settingsCompartment.reconfigure(
|
||||
createSettingsExtensions(settings)
|
||||
),
|
||||
});
|
||||
}, [settings]);
|
||||
|
||||
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);
|
||||
},
|
||||
|
||||
formatCode : ()=>formatCSS(viewRef.current),
|
||||
|
||||
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 : ()=>foldAll(),
|
||||
unfoldAll : ()=>unfoldAll(),
|
||||
|
||||
focus : ()=>viewRef.current.focus(),
|
||||
}));
|
||||
|
||||
return <div className={`codeEditor ${tab}`} ref={editorRef} style={style} />;
|
||||
},
|
||||
);
|
||||
|
||||
export default CodeEditor;
|
||||
|
||||
@@ -1,60 +1,235 @@
|
||||
@import (less) 'codemirror/lib/codemirror.css';
|
||||
@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/elderberryInn.less';
|
||||
@import (less) './themes/fonts/iconFonts/gameIcons.less';
|
||||
@import (less) './themes/fonts/iconFonts/fontAwesome.less';
|
||||
// Icon fonts for emoji/autocomplete
|
||||
@import (less) '@themes/fonts/iconFonts/diceFont.less';
|
||||
@import (less) '@themes/fonts/iconFonts/elderberryInn.less';
|
||||
@import (less) '@themes/fonts/iconFonts/gameIcons.less';
|
||||
@import (less) '@themes/fonts/iconFonts/fontAwesome.less';
|
||||
|
||||
@keyframes sourceMoveAnimation {
|
||||
50% { color : white;background-color : red;}
|
||||
100% { color : unset;background-color : unset;}
|
||||
50% {
|
||||
color : white;
|
||||
background-color : red;
|
||||
}
|
||||
100% {
|
||||
color : unset;
|
||||
background-color : unset;
|
||||
}
|
||||
}
|
||||
|
||||
.codeEditor {
|
||||
@keyframes slideacross {
|
||||
0% {
|
||||
bottom : -200px;
|
||||
left : -200px;
|
||||
}
|
||||
|
||||
100% {
|
||||
bottom : 100%;
|
||||
left : 100%;
|
||||
}
|
||||
}
|
||||
|
||||
:where(.codeEditor) {
|
||||
width : 100%;
|
||||
height : calc(100% - 25px);
|
||||
font-family : monospace;
|
||||
|
||||
.cm-editor {
|
||||
height : 100%;
|
||||
outline : none !important;
|
||||
|
||||
&.cm-flash {
|
||||
position : relative;
|
||||
&::after {
|
||||
position : absolute;
|
||||
bottom : -200px;
|
||||
left : -200px;
|
||||
display : block;
|
||||
width : 200%;
|
||||
height : 100px;
|
||||
content : '';
|
||||
background : linear-gradient(0deg, #89EAFC00 0px, #89EBFC8E 50px, #89EAFC00 100px, transparent);
|
||||
|
||||
rotate : 30deg;
|
||||
translate : -50%;
|
||||
|
||||
@media screen and (prefers-reduced-motion : no-preference) {
|
||||
animation : 0.5s linear 1 slideacross;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.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) {
|
||||
font-size : 16px;
|
||||
}
|
||||
.CodeMirror-foldmarker {
|
||||
|
||||
.cm-gutterElement span {
|
||||
font-family : inherit;
|
||||
font-weight : 600;
|
||||
color : grey;
|
||||
text-shadow : none;
|
||||
}
|
||||
|
||||
.CodeMirror-foldgutter {
|
||||
.cm-foldGutter {
|
||||
cursor : pointer;
|
||||
border-left : 1px solid #EEEEEE;
|
||||
transition : background 0.1s;
|
||||
&:hover { background : #DDDDDD; }
|
||||
}
|
||||
|
||||
.sourceMoveFlash .CodeMirror-line {
|
||||
/* Flash animation for source moves */
|
||||
.cm-line.sourceMoveFlash {
|
||||
animation-name : sourceMoveAnimation;
|
||||
animation-duration : 0.4s;
|
||||
}
|
||||
|
||||
.CodeMirror-search-field {
|
||||
width:25em !important;
|
||||
outline:1px inset #00000055 !important;
|
||||
/* Search input */
|
||||
.cm-searchField {
|
||||
width : 25em !important;
|
||||
outline : 1px inset #00000055 !important;
|
||||
}
|
||||
|
||||
.cm-image {
|
||||
position : relative;
|
||||
|
||||
.cm-preview {
|
||||
position : absolute;
|
||||
bottom : 0;
|
||||
left : 0;
|
||||
z-index : 1000;
|
||||
width : 200px;
|
||||
height : 200px;
|
||||
padding : 5px;
|
||||
pointer-events : none;
|
||||
object-fit : contain;
|
||||
background-color : #FFFFFF;
|
||||
border : 3px solid grey;
|
||||
border-radius : 10px;
|
||||
opacity : 0;
|
||||
translate : 0 100%;
|
||||
transition : 0.2s opacity 0.5s;
|
||||
}
|
||||
|
||||
&:hover .cm-preview { opacity : 1; }
|
||||
}
|
||||
|
||||
/* Tab character visualization (optional) */
|
||||
//.cm-tab {
|
||||
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAMCAQAAACOs/baAAAARUlEQVR4nGJgIAG8JkXxUAcCtDWemcGR1lY4MvgzCEKY7jSBjgxBDAG09UEQzAe0AMwMHrSOAwEGRtpaMIwAAAAA//8DAG4ID9EKs6YqAAAAAElFTkSuQmCC) no-repeat right;
|
||||
// background: url(...) no-repeat right;
|
||||
//}
|
||||
|
||||
//.cm-trailingspace {
|
||||
// .cm-space {
|
||||
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAQAgMAAABW5NbuAAAACVBMVEVHcEwAAAAAAAAWawmTAAAAA3RSTlMAPBJ6PMxpAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAFUlEQVQI12NgwACcCQysASAEZGAAACMuAX06aCQUAAAAAElFTkSuQmCC) no-repeat right;
|
||||
// }
|
||||
/* Trailing space visualization (optional) */
|
||||
//.cm-trailingSpace .cm-space {
|
||||
// background: url(...) no-repeat right;
|
||||
//}
|
||||
}
|
||||
|
||||
/* Emoji preview styling */
|
||||
.emojiPreview {
|
||||
font-size : 1.5em;
|
||||
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,468 @@
|
||||
/* 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;
|
||||
|
||||
if (img.onerror) return document.createElement('span');
|
||||
return img;
|
||||
}
|
||||
|
||||
eq(other) {
|
||||
return other.url === this.url;
|
||||
}
|
||||
}
|
||||
|
||||
export function customHighlightPlugin(renderer, tab, settings) {
|
||||
//this function takes the custom tokens created in the tokenize function in customhighlight files
|
||||
//takes the tokens defined by that function and assigns classes to them
|
||||
//it also creates page number and snippet number widgets
|
||||
|
||||
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' && settings.showImagePreviews) {
|
||||
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 && settings.showImagePreviews) {
|
||||
|
||||
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,257 @@
|
||||
/* eslint max-lines: ["error", { "max": 300 }] */
|
||||
import { keymap } from '@codemirror/view';
|
||||
import { undo, redo, indentMore, indentLess, deleteLine } from '@codemirror/commands';
|
||||
import { EditorSelection, ChangeSet } from '@codemirror/state';
|
||||
import { Prec } from '@codemirror/state';
|
||||
import * as prettier from 'prettier/standalone';
|
||||
import * as postcssPlugin from 'prettier/plugins/postcss';
|
||||
|
||||
export async function formatCSS(view) {
|
||||
try {
|
||||
const { from, to, empty } = view.state.selection.main;
|
||||
const fullDoc = view.state.doc.toString();
|
||||
const selection = view.state.doc.sliceString(from, to);
|
||||
const code = empty ? fullDoc : selection;
|
||||
|
||||
let formatted = await prettier.format(code, {
|
||||
parser : 'css',
|
||||
plugins : [postcssPlugin],
|
||||
|
||||
// formatting options
|
||||
tabWidth : 2,
|
||||
useTabs : false,
|
||||
printWidth : 100,
|
||||
singleQuote : false,
|
||||
trailingComma : 'all',
|
||||
bracketSpacing : true,
|
||||
endOfLine : 'lf'
|
||||
});
|
||||
|
||||
//format manually single declaration rules to span one line.
|
||||
//Prettier can't do it by default, this is crude but it works
|
||||
formatted = formatted.replace(
|
||||
/([^{]+)\{\s*\n\s*([^;\n]+:[^;\n]+;)\s*\n\s*\}(\s*)/g,
|
||||
(_, selector, decl, whitespace)=>`${selector} { ${decl.trim()} }${whitespace}`
|
||||
);
|
||||
if(formatted === code) return true;
|
||||
|
||||
view.dom.classList.add('cm-flash');
|
||||
setTimeout(()=>{
|
||||
view.dom.classList.remove('cm-flash');
|
||||
|
||||
view.dispatch({
|
||||
changes : {
|
||||
from : empty ? 0 : from,
|
||||
to : empty ? view.state.doc.length : to,
|
||||
insert : formatted
|
||||
}
|
||||
});
|
||||
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
console.error('Error formatting css: ', err);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const forEachSelection = (view, fn)=>{
|
||||
view.dispatch(view.state.changeByRange((range)=>fn(view.state, range.from, range.to, view.state.doc.sliceString(range.from, range.to))));
|
||||
return true;
|
||||
};
|
||||
|
||||
const insertTab = (view)=>{
|
||||
// If any selection spans multiple lines, delegates to CodeMirror's indentMore
|
||||
// Otherwise inserts two spaces at each cursor/selection
|
||||
const shouldIndent = view.state.selection.ranges.some((range)=>view.state.doc.lineAt(range.from).number !==
|
||||
view.state.doc.lineAt(range.to).number
|
||||
);
|
||||
if(shouldIndent) return indentMore(view);
|
||||
|
||||
return forEachSelection(view, (state, from, to, selected)=>{
|
||||
return {
|
||||
changes : { from, to, insert: ' ' }, // Insert two spaces, not a tab char!
|
||||
range : EditorSelection.cursor(from + 2)
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const wrapSelection = (prefix, suffix)=>(view)=>forEachSelection(view, (state, from, to, selected)=>{
|
||||
const noSelection = from === to;
|
||||
if(noSelection) {
|
||||
const insert = prefix + suffix;
|
||||
|
||||
return {
|
||||
changes : { from, to, insert },
|
||||
range : EditorSelection.cursor(from + prefix.length)
|
||||
};
|
||||
}
|
||||
|
||||
const hasWrapper = selected.startsWith(prefix) && selected.endsWith(suffix);
|
||||
if(hasWrapper) {
|
||||
return {
|
||||
changes : [
|
||||
{ from: to - suffix.length, to, insert: '' },
|
||||
{ from, to: from + prefix.length, insert: '' }
|
||||
],
|
||||
range : EditorSelection.range(from, to - prefix.length - suffix.length)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
changes : { from, to, insert: prefix + selected + suffix },
|
||||
range : EditorSelection.range(from, to + prefix.length + suffix.length)
|
||||
};
|
||||
});
|
||||
|
||||
const makeNbsp = (view)=>forEachSelection(view, (state, from, to, selected)=>{
|
||||
const prev2 = from >= 2 ? state.doc.sliceString(from - 2, from) : '';
|
||||
const insert = (prev2 === ':>' || prev2 === '>>') ? '>' : ':>';
|
||||
|
||||
return {
|
||||
changes : { from, to, insert },
|
||||
range : EditorSelection.cursor(from + insert.length)
|
||||
};
|
||||
});
|
||||
|
||||
const makeSpace = (view)=>forEachSelection(view, (state, from, to, selected)=>{
|
||||
let insert = '{{width:10% }}';
|
||||
const match = selected.match(/^{{width:(\d+)% }}$/);
|
||||
if(match) {
|
||||
const percent = Math.min(parseInt(match[1], 10) + 10, 100);
|
||||
insert = `{{width:${percent}% }}`;
|
||||
}
|
||||
return {
|
||||
changes : { from, to, insert },
|
||||
range : EditorSelection.range(from, from + insert.length)
|
||||
};
|
||||
});
|
||||
|
||||
const removeSpace = (view)=>forEachSelection(view, (state, from, to, selected)=>{
|
||||
const match = selected.match(/^{{width:(\d+)% }}$/);
|
||||
if(match) {
|
||||
const percent = parseInt(match[1], 10) - 10;
|
||||
const insert = percent > 0 ? `{{width:${percent}% }}` : '';
|
||||
return { changes: { from, to, insert: insert }, range: EditorSelection.range(from, from + insert.length) };
|
||||
}
|
||||
});
|
||||
|
||||
const makeLink = (view)=>forEachSelection(view, (state, from, to, selected)=>{
|
||||
// If the selection is already a Markdown link, unwrap it
|
||||
const existingLink = /^\[(.*)\]\((.*)\)$/.exec(selected);
|
||||
if(existingLink) {
|
||||
const [, text, url] = existingLink;
|
||||
const insert = `${text} ${url}`;
|
||||
return {
|
||||
changes : { from, to, insert },
|
||||
range : EditorSelection.range(
|
||||
from,
|
||||
from + insert.length
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// If the selection is a URL, use its domain as the link text.
|
||||
const isUrl =
|
||||
/^(https?:\/\/|www\.)\S+$/i.test(selected) ||
|
||||
/^[a-z0-9-]+(\.[a-z0-9-]+)+([/?#]\S*)?$/i.test(selected);
|
||||
if(isUrl) {
|
||||
const domain = selected
|
||||
.replace(/^https?:\/\//i, '')
|
||||
.replace(/^www\./i, '')
|
||||
.split(/[/?#]/)[0];
|
||||
|
||||
const name = domain.split('.')[0];
|
||||
const insert = `[${name}](${selected})`;
|
||||
return {
|
||||
changes : { from, to, insert },
|
||||
range : EditorSelection.range(from, from + insert.length),
|
||||
};
|
||||
}
|
||||
|
||||
// If the selection is just text, use it as alt text and set url as placeholder
|
||||
const insert = `[${selected || 'alt text'}](url)`;
|
||||
return {
|
||||
changes : { from, to, insert },
|
||||
range : EditorSelection.range(from, from + insert.length),
|
||||
};
|
||||
});
|
||||
|
||||
const addPrefixAtLineStart = (prefix)=>(view)=>forEachSelection(view, (state, from, to, selected)=>{
|
||||
const startLine = state.doc.lineAt(from);
|
||||
const endLine = state.doc.lineAt(to);
|
||||
const selectedLines = [];
|
||||
|
||||
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber++) { selectedLines.push(state.doc.line(lineNumber)); }
|
||||
|
||||
const allLinesHavePrefix = selectedLines.every((line)=>line.text.startsWith(prefix));
|
||||
|
||||
const changes = selectedLines.map((line)=>{
|
||||
const shouldRemovePrefix = allLinesHavePrefix && line.text.startsWith(prefix);
|
||||
|
||||
return {
|
||||
from : line.from,
|
||||
to : shouldRemovePrefix
|
||||
? line.from + prefix.length
|
||||
: line.from,
|
||||
insert : shouldRemovePrefix ? '' : prefix,
|
||||
};
|
||||
});
|
||||
const changeSet = ChangeSet.of(changes, state.doc.length);
|
||||
|
||||
return {
|
||||
changes,
|
||||
range : EditorSelection.range(
|
||||
changeSet.mapPos(from, -1),
|
||||
changeSet.mapPos(to, 1)
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const newBreak = (type)=>(view)=>forEachSelection(view, (state, from, to, selected)=>{
|
||||
const insert = `\n\\${type}\n\n`;
|
||||
return {
|
||||
changes : { from, to, insert },
|
||||
range : EditorSelection.cursor(from + insert.length)
|
||||
};
|
||||
});
|
||||
|
||||
export const generalKeymap = (foldAll, unfoldAll) => Prec.high(keymap.of([
|
||||
{ key: 'Tab', run: insertTab },
|
||||
{ key: 'Shift-Tab', run: indentLess },
|
||||
{ key: 'Mod-z', run: undo }, //it may be unnecessary
|
||||
{ key: 'Mod-Shift-z', run: redo },
|
||||
{ key: 'Mod-y', run: redo }, //user asked, so double keybind
|
||||
{ key: 'Mod-d', run: deleteLine }, //annoyingly overrides "selectNextOccurrence" because users asked
|
||||
{ key: 'Mod-Shift-[', run: foldAll },
|
||||
{ key: 'Mod-Shift-]', run: unfoldAll },
|
||||
]));
|
||||
|
||||
export const cssKeymap = Prec.highest(keymap.of([
|
||||
{ key: 'Mod-Shift-f', run: formatCSS },
|
||||
{ key: 'Alt-Shift-f', run: formatCSS },
|
||||
]));
|
||||
|
||||
export const markdownKeymap = Prec.highest(keymap.of([
|
||||
{ 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: wrapSelection('{{', '}}') },
|
||||
{ key: 'Shift-Mod-m', run: wrapSelection('{{\n', '\n}}') },
|
||||
{ key: 'Mod-/', run: wrapSelection('<!-- ', ' -->') },
|
||||
{ key: 'Mod-Shift-k', run: makeLink },
|
||||
{ key: 'Mod-Shift-u', run: addPrefixAtLineStart('- ') },
|
||||
{ key: 'Mod-Shift-o', run: addPrefixAtLineStart('1. ') },
|
||||
{ key: 'Shift-Mod-1', run: addPrefixAtLineStart('# ') },
|
||||
{ key: 'Shift-Mod-2', run: addPrefixAtLineStart('## ') },
|
||||
{ key: 'Shift-Mod-3', run: addPrefixAtLineStart('### ') },
|
||||
{ key: 'Shift-Mod-4', run: addPrefixAtLineStart('#### ') },
|
||||
{ key: 'Shift-Mod-5', run: addPrefixAtLineStart('##### ') },
|
||||
{ key: 'Shift-Mod-6', run: addPrefixAtLineStart('###### ') },
|
||||
{ key: 'Mod-Enter', run: newBreak('page') },
|
||||
{ key: 'Shift-Mod-Enter', run: newBreak('column') },
|
||||
]));
|
||||
@@ -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' },
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -7,18 +7,22 @@ const Combobox = createReactClass({
|
||||
displayName : 'Combobox',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
id : '',
|
||||
className : '',
|
||||
trigger : 'hover',
|
||||
default : '',
|
||||
placeholder : '',
|
||||
tooltip : '',
|
||||
autoSuggest : {
|
||||
clearAutoSuggestOnClick : true,
|
||||
suggestMethod : 'includes',
|
||||
filterOn : [] // should allow as array to filter on multiple attributes, or even custom filter
|
||||
},
|
||||
valuePatterns : /.+/
|
||||
};
|
||||
},
|
||||
getInitialState : function() {
|
||||
this.dropdownRef = React.createRef();
|
||||
return {
|
||||
showDropdown : false,
|
||||
value : '',
|
||||
@@ -39,7 +43,7 @@ const Combobox = createReactClass({
|
||||
},
|
||||
handleClickOutside : function(e){
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
@@ -69,11 +73,15 @@ const Combobox = createReactClass({
|
||||
return (
|
||||
<div className='dropdown-input item'
|
||||
onMouseEnter={this.props.trigger == 'hover' ? ()=>{this.handleDropdown(true);} : undefined}
|
||||
onClick= {this.props.trigger == 'click' ? ()=>{this.handleDropdown(true);} : undefined}>
|
||||
onClick= {this.props.trigger == 'click' ? ()=>{this.handleDropdown(true);} : undefined}
|
||||
{...(this.props.tooltip ? { 'data-tooltip-right': this.props.tooltip } : {})}>
|
||||
<input
|
||||
id={this.props.id}
|
||||
type='text'
|
||||
onChange={(e)=>this.handleInput(e)}
|
||||
value={this.state.value || ''}
|
||||
title=''
|
||||
pattern={this.props.valuePatterns}
|
||||
placeholder={this.props.placeholder}
|
||||
onBlur={(e)=>{
|
||||
if(!e.target.checkValidity()){
|
||||
@@ -82,6 +90,12 @@ const Combobox = createReactClass({
|
||||
});
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e)=>{
|
||||
if(e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.props.onEntry(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<i className='fas fa-caret-down'/>
|
||||
</div>
|
||||
@@ -117,7 +131,7 @@ const Combobox = createReactClass({
|
||||
});
|
||||
return (
|
||||
<div className={`dropdown-container ${this.props.className}`}
|
||||
ref='dropdown'
|
||||
ref={this.dropdownRef}
|
||||
onMouseLeave={this.props.trigger == 'hover' ? ()=>{this.handleDropdown(false);} : undefined}>
|
||||
{this.renderTextInput()}
|
||||
{this.renderDropdown(dropdownChildren)}
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
.item i {
|
||||
position : absolute;
|
||||
right : 10px;
|
||||
color : black;
|
||||
color : inherit;
|
||||
}
|
||||
.dropdown-options {
|
||||
position : absolute;
|
||||
z-index : 100;
|
||||
width : 100%;
|
||||
height : max-content;
|
||||
max-height : 200px;
|
||||
overflow-y : auto;
|
||||
background-color : white;
|
||||
@@ -30,15 +31,12 @@
|
||||
font-family : 'Open Sans';
|
||||
font-size : 11px;
|
||||
cursor : default;
|
||||
&:hover {
|
||||
background-color : rgb(163, 163, 163);
|
||||
filter : brightness(120%);
|
||||
}
|
||||
&:hover { background-color : #DDDDDD; }
|
||||
.detail {
|
||||
width : 100%;
|
||||
font-size : 9px;
|
||||
font-style : italic;
|
||||
color : rgb(124, 124, 124);
|
||||
color : #7C7C7C;
|
||||
text-align : left;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 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, disabled = false, ...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, disabled]);
|
||||
|
||||
// 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());
|
||||
};
|
||||
|
||||
if(disabled){
|
||||
return (
|
||||
<li className={['menu-wrapper', className].filter(Boolean).join(' ')} role='none'>
|
||||
<button
|
||||
className={['menu-item', color].filter(Boolean).join(' ')}
|
||||
aria-label={groupName}
|
||||
role='menuitem'
|
||||
disabled
|
||||
>
|
||||
{trigger(groupName, icon)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={['menu-wrapper', className].filter(Boolean).join(' ')} role='none'>
|
||||
<button
|
||||
id={`${menuId}-trigger`}
|
||||
className={['menu-item', color].join(' ')}
|
||||
popoverTarget={menuId}
|
||||
aria-haspopup='menu'
|
||||
aria-label={groupName}
|
||||
role='menuitem'
|
||||
disabled={!React.Children.count(children)}
|
||||
ref={triggerRef}
|
||||
>
|
||||
{trigger(groupName, icon)}
|
||||
</button>
|
||||
<MenuDepthContext.Provider value={depth + 1}>
|
||||
<ul
|
||||
ref={menuRef}
|
||||
id={menuId}
|
||||
className='menu-list'
|
||||
popover='auto'
|
||||
role='menu'
|
||||
aria-label={`${groupName} Submenu`}
|
||||
onClick={handleMenuActionClick}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
</MenuDepthContext.Provider>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export { Dropdown };
|
||||
@@ -0,0 +1,38 @@
|
||||
@property --menuColor {
|
||||
syntax : '<color>';
|
||||
inherits : true;
|
||||
initial-value : #DDDDDD;
|
||||
}
|
||||
|
||||
@property --activeTriggerColor {
|
||||
syntax : '<color>';
|
||||
inherits : true;
|
||||
initial-value : #999999;
|
||||
}
|
||||
|
||||
:root { --activeTriggerColor : var(--activeTriggerColor); }
|
||||
.menu-wrapper {
|
||||
list-style: none;
|
||||
}
|
||||
.menu-list {
|
||||
position : fixed;
|
||||
top : anchor(bottom);
|
||||
left : anchor(left);
|
||||
position-try : flip-inline flip-block;
|
||||
contain : content;
|
||||
color : inherit; // [popover] gets a `canvastext` color value from useragent.
|
||||
background : var(--menuColor);
|
||||
li > .menu-list {
|
||||
top : anchor(top);
|
||||
left : anchor(right);
|
||||
margin : 0 0px;
|
||||
position-try : flip-inline;
|
||||
}
|
||||
}
|
||||
.menu-wrapper:has(:popover-open) > button { // if menu is open...
|
||||
background-color : var(--activeTriggerColor, hsl(from var(--menuColor) h s calc(l * 0.85))); // tint menu triggers based on menu color
|
||||
}
|
||||
|
||||
.darkMode .menu-wrapper:has(:popover-open) > button { // if menu is open...
|
||||
--activeTriggerColor : #444444; // tint menu triggers based on menu color
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@import '@sharedStyles/colors.less';
|
||||
|
||||
.renderWarnings {
|
||||
position : relative;
|
||||
float : right;
|
||||
|
||||
@@ -18,8 +18,7 @@ const SplitPane = (props)=>{
|
||||
const [liveScroll, setLiveScroll] = useState(false);
|
||||
|
||||
useEffect(()=>{
|
||||
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);
|
||||
handleResize();
|
||||
setLiveScroll(window.localStorage.getItem(LIVE_SCROLL_KEY) === 'true');
|
||||
|
||||
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)));
|
||||
|
||||
//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)=>{
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@import '@sharedStyles/core.less';
|
||||
|
||||
.splitPane {
|
||||
position : relative;
|
||||
|
||||
@@ -1,35 +1,38 @@
|
||||
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
|
||||
import brewRendererStylesUrl from './brewRenderer.less?url';
|
||||
import headerNavStylesUrl from './headerNav/headerNav.less?url';
|
||||
import './brewRenderer.less';
|
||||
import React, { useState, useRef, useMemo, useEffect } from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import MarkdownLegacy from '../../../shared/markdownLegacy.js';
|
||||
import Markdown from '../../../shared/markdown.js';
|
||||
import MarkdownLegacy from '@shared/markdownLegacy.js';
|
||||
import { hbfm } from 'marked-hbfm';
|
||||
import ErrorBar from './errorBar/errorBar.jsx';
|
||||
import ToolBar from './toolBar/toolBar.jsx';
|
||||
|
||||
//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 Frame from 'react-frame-component';
|
||||
import dedent from 'dedent';
|
||||
import { printCurrentBrew } from '../../../shared/helpers.js';
|
||||
import { printCurrentBrew } from '@shared/helpers.js';
|
||||
|
||||
import HeaderNav from './headerNav/headerNav.jsx';
|
||||
import { safeHTML } from './safeHTML.js';
|
||||
import safeHTML from './safeHTML.js';
|
||||
|
||||
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
|
||||
const PAGEBREAK_REGEX_LEGACY = /\\page(?:break)?/m;
|
||||
const COLUMNBREAK_REGEX_LEGACY = /\\column(:?break)?/m;
|
||||
const PAGE_HEIGHT = 1056;
|
||||
|
||||
const TOOLBAR_STATE_KEY = 'HB_renderer_toolbarState';
|
||||
|
||||
const INITIAL_CONTENT = dedent`
|
||||
<!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' />
|
||||
<base target=_blank>
|
||||
<link href="${brewRendererStylesUrl}" rel="stylesheet" />
|
||||
<link href="${headerNavStylesUrl}" rel="stylesheet" />
|
||||
<base target="_top">
|
||||
</head><body style='overflow: hidden'><div></div></body></html>`;
|
||||
|
||||
|
||||
@@ -42,31 +45,25 @@ const BrewPage = (props)=>{
|
||||
};
|
||||
const pageRef = useRef(null);
|
||||
const cleanText = safeHTML(props.contents);
|
||||
const pageNum = props.index + 1;
|
||||
|
||||
useEffect(()=>{
|
||||
if(!pageRef.current) return;
|
||||
|
||||
// Observer for tracking pages within the `.pages` div
|
||||
// Observer for tracking which pages are at least 30% visible in the iframe
|
||||
const visibleObserver = new IntersectionObserver(
|
||||
(entries)=>{
|
||||
entries.forEach((entry)=>{
|
||||
if(entry.isIntersecting)
|
||||
props.onVisibilityChange(props.index + 1, true, false); // add page to array of visible pages.
|
||||
else
|
||||
props.onVisibilityChange(props.index + 1, false, false);
|
||||
});
|
||||
},
|
||||
(entries)=>entries.forEach((entry)=>{
|
||||
props.onVisibilityChange(pageNum, entry.isIntersecting, false); // add/remove page from array of visible pages.
|
||||
}),
|
||||
{ threshold: .3, rootMargin: '0px 0px 0px 0px' } // detect when >30% of page is within bounds.
|
||||
);
|
||||
|
||||
// Observer for tracking the page at the center of the iframe.
|
||||
const centerObserver = new IntersectionObserver(
|
||||
(entries)=>{
|
||||
entries.forEach((entry)=>{
|
||||
if(entry.isIntersecting)
|
||||
props.onVisibilityChange(props.index + 1, true, true); // Set this page as the center page
|
||||
});
|
||||
},
|
||||
(entries)=>entries.forEach((entry)=>{
|
||||
if(entry.isIntersecting)
|
||||
props.onVisibilityChange(pageNum, true, true); // Set this page as the center page
|
||||
}),
|
||||
{ threshold: 0, rootMargin: '-50% 0px -50% 0px' } // Detect when the page is at the center
|
||||
);
|
||||
|
||||
@@ -87,6 +84,7 @@ const BrewPage = (props)=>{
|
||||
|
||||
//v=====--------------------< Brew Renderer Component >-------------------=====v//
|
||||
let renderedPages = [];
|
||||
const pageTemplates = [];
|
||||
let rawPages = [];
|
||||
|
||||
const BrewRenderer = (props)=>{
|
||||
@@ -94,22 +92,23 @@ const BrewRenderer = (props)=>{
|
||||
text : '',
|
||||
style : '',
|
||||
renderer : 'legacy',
|
||||
theme : '5ePHB',
|
||||
lang : '',
|
||||
errors : [],
|
||||
currentEditorCursorPageNum : 1,
|
||||
currentEditorViewPageNum : 1,
|
||||
currentBrewRendererPageNum : 1,
|
||||
themeBundle : {},
|
||||
onPageChange : ()=>{},
|
||||
...props
|
||||
};
|
||||
|
||||
const pagesRef = useRef(null);
|
||||
|
||||
const [visiblePages, setVisiblePages] = useState([]);
|
||||
const [centerPage, setCenterPage] = useState(1);
|
||||
const [headerState, setHeaderState] = useState(false);
|
||||
|
||||
const [state, setState] = useState({
|
||||
isMounted : false,
|
||||
visibility : 'hidden',
|
||||
visiblePages : [],
|
||||
centerPage : 1
|
||||
isMounted : false,
|
||||
visibility : 'hidden'
|
||||
});
|
||||
|
||||
const [displayOptions, setDisplayOptions] = useState({
|
||||
@@ -127,11 +126,6 @@ const BrewRenderer = (props)=>{
|
||||
toolbarState && setDisplayOptions(toolbarState);
|
||||
}, []);
|
||||
|
||||
const [headerState, setHeaderState] = useState(false);
|
||||
|
||||
const mainRef = useRef(null);
|
||||
const pagesRef = useRef(null);
|
||||
|
||||
if(props.renderer == 'legacy') {
|
||||
rawPages = props.text.split(PAGEBREAK_REGEX_LEGACY);
|
||||
} else {
|
||||
@@ -139,20 +133,16 @@ const BrewRenderer = (props)=>{
|
||||
}
|
||||
|
||||
const handlePageVisibilityChange = (pageNum, isVisible, isCenter)=>{
|
||||
setState((prevState)=>{
|
||||
const updatedVisiblePages = new Set(prevState.visiblePages);
|
||||
if(!isCenter)
|
||||
isVisible ? updatedVisiblePages.add(pageNum) : updatedVisiblePages.delete(pageNum);
|
||||
|
||||
return {
|
||||
...prevState,
|
||||
visiblePages : [...updatedVisiblePages].sort((a, b)=>a - b),
|
||||
centerPage : isCenter ? pageNum : prevState.centerPage
|
||||
};
|
||||
setVisiblePages((prev)=>{
|
||||
const updatedVisiblePages = new Set(prev);
|
||||
isVisible ? updatedVisiblePages.add(pageNum) : updatedVisiblePages.delete(pageNum);
|
||||
return [...updatedVisiblePages].sort((a, b)=>a - b);
|
||||
});
|
||||
|
||||
if(isCenter)
|
||||
if(isCenter) {
|
||||
setCenterPage(pageNum);
|
||||
props.onPageChange(pageNum);
|
||||
}
|
||||
};
|
||||
|
||||
const isInView = (index)=>{
|
||||
@@ -162,17 +152,15 @@ const BrewRenderer = (props)=>{
|
||||
if(index == props.currentEditorCursorPageNum - 1) //Already rendered before this step
|
||||
return false;
|
||||
|
||||
if(Math.abs(index - props.currentBrewRendererPageNum - 1) <= 3)
|
||||
if(Math.abs(index - centerPage - 1) <= 3)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const renderDummyPage = (index)=>{
|
||||
return <div className='phb page' id={`p${index + 1}`} key={index}>
|
||||
<i className='fas fa-spinner fa-spin' />
|
||||
</div>;
|
||||
};
|
||||
const renderDummyPage = (index)=><div className='phb page' id={`p${index + 1}`} key={index}>
|
||||
<i className='fas fa-spinner fa-spin' />
|
||||
</div>;
|
||||
|
||||
const renderStyle = ()=>{
|
||||
const themeStyles = props.themeBundle?.joinedStyles ?? '<style>@import url("/themes/V3/Blank/style.css");</style>';
|
||||
@@ -196,13 +184,27 @@ const BrewRenderer = (props)=>{
|
||||
return <BrewPage className='page phb' index={index} key={index} contents={html} style={styles} onVisibilityChange={handlePageVisibilityChange} />;
|
||||
} else {
|
||||
if(pageText.startsWith('\\page')) {
|
||||
const firstLineTokens = Markdown.marked.lexer(pageText.split('\n', 1)[0])[0].tokens;
|
||||
const firstLineTokens = hbfm.marked.lexer(pageText.split('\n', 1)[0])[0].tokens;
|
||||
const injectedTags = firstLineTokens?.find((obj)=>obj.injectedTags !== undefined)?.injectedTags;
|
||||
if(injectedTags) {
|
||||
styles = { ...styles, ...injectedTags.styles };
|
||||
styles = _.mapKeys(styles, (v, k)=>k.startsWith('--') ? k : _.camelCase(k)); // Convert CSS to camelCase for React
|
||||
classes = [classes, injectedTags.classes].join(' ').trim();
|
||||
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
|
||||
}
|
||||
@@ -210,18 +212,20 @@ const BrewRenderer = (props)=>{
|
||||
// DO NOT REMOVE!!! REQUIRED FOR BACKWARDS COMPATIBILITY WITH NON-UPGRADABLE VERSIONS OF CHROME.
|
||||
pageText += `\n\n \n\\column\n `; //Artificial column break at page end to emulate column-fill:auto (until `wide` is used, when column-fill:balance will reappear)
|
||||
|
||||
const html = Markdown.render(pageText, index);
|
||||
const html = hbfm.render(pageText, index);
|
||||
|
||||
return <BrewPage className={classes} index={index} key={index} contents={html} style={styles} attributes={attributes} onVisibilityChange={handlePageVisibilityChange} />;
|
||||
}
|
||||
};
|
||||
|
||||
const renderPages = ()=>{
|
||||
if(props.errors && props.errors.length)
|
||||
if(props.errors?.length)
|
||||
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;
|
||||
pageTemplates.length = 0;
|
||||
}
|
||||
|
||||
// Render currently-edited page first so cross-page effects (variables, links) can propagate out first
|
||||
if(rawPages.length > props.currentEditorCursorPageNum -1)
|
||||
@@ -268,7 +272,9 @@ const BrewRenderer = (props)=>{
|
||||
const frameDidMount = ()=>{ //This triggers when iFrame finishes internal "componentDidMount"
|
||||
scrollToHash(window.location.hash);
|
||||
|
||||
setTimeout(()=>{ //We still see a flicker where the style isn't applied yet, so wait 100ms before showing iFrame
|
||||
window.addEventListener('hashchange', ()=>scrollToHash(window.location.hash));
|
||||
|
||||
setTimeout(()=>{ //We still see a flicker where the style isn't applied yet, so wait 100ms before showing iFrame
|
||||
renderPages(); //Make sure page is renderable before showing
|
||||
setState((prevState)=>({
|
||||
...prevState,
|
||||
@@ -295,7 +301,7 @@ const BrewRenderer = (props)=>{
|
||||
};
|
||||
|
||||
const renderedStyle = useMemo(()=>renderStyle(), [props.style, props.themeBundle]);
|
||||
renderedPages = useMemo(()=>renderPages(), [props.text, displayOptions]);
|
||||
renderedPages = useMemo(()=>renderPages(), [props.text, centerPage, displayOptions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -309,18 +315,19 @@ const BrewRenderer = (props)=>{
|
||||
: null}
|
||||
|
||||
<ErrorBar errors={props.errors} />
|
||||
<div className='popups' ref={mainRef}>
|
||||
<div className='popups'>
|
||||
<RenderWarnings />
|
||||
<NotificationPopup />
|
||||
</div>
|
||||
|
||||
<ToolBar displayOptions={displayOptions} onDisplayOptionsChange={handleDisplayOptionsChange} visiblePages={state.visiblePages.length > 0 ? state.visiblePages : [state.centerPage]} totalPages={rawPages.length} headerState={headerState} setHeaderState={setHeaderState}/>
|
||||
<ToolBar displayOptions={displayOptions} onDisplayOptionsChange={handleDisplayOptionsChange} visiblePages={visiblePages.length > 0 ? visiblePages : [centerPage]} totalPages={rawPages.length} headerState={headerState} setHeaderState={setHeaderState}/>
|
||||
|
||||
{/*render in iFrame so broken code doesn't crash the site.*/}
|
||||
<Frame id='BrewRenderer' initialContent={INITIAL_CONTENT}
|
||||
<Frame id='BrewRenderer' title='Rendered Brew Content' initialContent={INITIAL_CONTENT}
|
||||
style={{ width: '100%', height: '100%', visibility: state.visibility }}
|
||||
contentDidMount={frameDidMount}
|
||||
onClick={()=>{emitClick();}}
|
||||
onClick={emitClick}
|
||||
sandbox='allow-same-origin allow-modals allow-top-navigation'
|
||||
>
|
||||
<div className='brewRenderer'
|
||||
onKeyDown={handleControlKeys}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import (multiple, less) 'shared/naturalcrit/styles/reset.less';
|
||||
@import '@sharedStyles/core.less';
|
||||
|
||||
.brewRenderer {
|
||||
height : 100vh;
|
||||
@@ -61,9 +61,7 @@
|
||||
}
|
||||
|
||||
@supports (break-after:always) {
|
||||
.columnSplit {
|
||||
margin-bottom: 100vh;
|
||||
}
|
||||
.columnSplit { margin-bottom : 100vh; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,13 +74,11 @@
|
||||
height : 100%;
|
||||
padding : unset;
|
||||
overflow-y : unset;
|
||||
&:has(.facing, .flow) {
|
||||
padding : unset;
|
||||
}
|
||||
&:has(.facing, .flow) { padding : unset; }
|
||||
.pages {
|
||||
display : block;
|
||||
margin : 0px;
|
||||
zoom : 100% !important;
|
||||
display : block;
|
||||
& > .page { box-shadow : unset; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import './errorBar.less';
|
||||
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' />;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@import '@sharedStyles/colors.less';
|
||||
|
||||
.errorBar {
|
||||
position : absolute;
|
||||
|
||||
@@ -104,7 +104,7 @@ const HeaderNavItem = ({ link, text, depth, className })=>{
|
||||
if(!link || !text) return;
|
||||
|
||||
return <li>
|
||||
<a href={`#${link}`} target='_self' className={`depth-${depth} ${className ?? ''}`}>
|
||||
<a href={`#${link}`} className={`depth-${depth} ${className ?? ''}`}>
|
||||
{trimString(text, depth)}
|
||||
</a>
|
||||
</li>;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import './notificationPopup.less';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import Markdown from 'markdown.js';
|
||||
import { hbfm } from 'marked-hbfm';
|
||||
|
||||
import Dialog from '../../../components/dialog.jsx';
|
||||
import Dialog from '@components/dialog.jsx';
|
||||
|
||||
const DISMISS_BUTTON = <i className='fas fa-times dismiss' />;
|
||||
|
||||
@@ -44,7 +44,7 @@ const NotificationPopup = ()=>{
|
||||
return notifications.map((notification)=>(
|
||||
<li key={notification.dismissKey} >
|
||||
<em>{notification.title}</em><br />
|
||||
<p dangerouslySetInnerHTML={{ __html: Markdown.render(notification.text) }}></p>
|
||||
<p dangerouslySetInnerHTML={{ __html: hbfm.render(notification.text) }}></p>
|
||||
</li>
|
||||
));
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import './client/homebrew/navbar/navbar.less';
|
||||
|
||||
.popups {
|
||||
position : fixed;
|
||||
top : calc(@navbarHeight + @viewerToolsHeight);
|
||||
|
||||
@@ -32,15 +32,15 @@ function safeHTML(htmlString) {
|
||||
return;
|
||||
}
|
||||
// Check remaining elements for blacklisted attributes
|
||||
for (const attribute of element.attributes){
|
||||
[...element.attributes].forEach((attribute)=>{
|
||||
if(blacklistAttrs.some((test)=>{return test(attribute);})) {
|
||||
element.removeAttribute(attribute.localName);
|
||||
break;
|
||||
element.removeAttribute(attribute.name);
|
||||
return;
|
||||
};
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
module.exports.safeHTML = safeHTML;
|
||||
export default safeHTML;
|
||||
@@ -3,7 +3,7 @@ import './toolBar.less';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { Anchored, AnchoredBox, AnchoredTrigger } from '../../../components/Anchored.jsx';
|
||||
import { Anchored, AnchoredBox, AnchoredTrigger } from '@components/Anchored.jsx';
|
||||
|
||||
const MAX_ZOOM = 300;
|
||||
const MIN_ZOOM = 10;
|
||||
@@ -99,46 +99,54 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
|
||||
return (
|
||||
<div id='preview-toolbar' className={`toolBar ${toolsVisible ? 'visible' : 'hidden'}`} role='toolbar'>
|
||||
<div className='toggleButton'>
|
||||
<button title={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`} onClick={()=>{
|
||||
setToolsVisible(!toolsVisible);
|
||||
localStorage.setItem(TOOLBAR_VISIBILITY, !toolsVisible);
|
||||
}}><i className='fas fa-glasses' /></button>
|
||||
<button title={`${headerState ? 'Hide' : 'Show'} Header Navigation`} onClick={()=>{setHeaderState(!headerState);}}><i className='fas fa-rectangle-list' /></button>
|
||||
<button data-tooltip-right={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`}
|
||||
aria-label={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`}
|
||||
onClick={()=>{ setToolsVisible(!toolsVisible); localStorage.setItem(TOOLBAR_VISIBILITY, !toolsVisible); }}>
|
||||
<i aria-hidden='true' className='fas fa-glasses' />
|
||||
</button>
|
||||
<button data-tooltip-right={`${headerState ? 'Hide' : 'Show'} Header Navigation`}
|
||||
aria-label={`${headerState ? 'Hide' : 'Show'} Header Navigation`}
|
||||
onClick={()=>{setHeaderState(!headerState);}}>
|
||||
<i aria-hidden='true' className='fas fa-rectangle-list' />
|
||||
</button>
|
||||
</div>
|
||||
{/*v=====----------------------< Zoom Controls >---------------------=====v*/}
|
||||
<div className='group' role='group' aria-label='Zoom' aria-hidden={!toolsVisible}>
|
||||
<button
|
||||
id='fill-width'
|
||||
className='tool'
|
||||
title='Set zoom to fill preview with one page'
|
||||
data-tooltip-bottom='Set zoom to fill preview with one page'
|
||||
aria-label='Set zoom to fill preview with one page'
|
||||
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fill'))}
|
||||
>
|
||||
<i className='fac fit-width' />
|
||||
<i aria-hidden='true' className='fac fit-width' />
|
||||
</button>
|
||||
<button
|
||||
id='zoom-to-fit'
|
||||
className='tool'
|
||||
title='Set zoom to fit entire page in preview'
|
||||
data-tooltip-bottom='Set zoom to fit entire page in preview'
|
||||
aria-label='Set zoom to fit entire page in preview'
|
||||
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fit'))}
|
||||
>
|
||||
<i className='fac zoom-to-fit' />
|
||||
<i aria-hidden='true' className='fac zoom-to-fit' />
|
||||
</button>
|
||||
<button
|
||||
id='zoom-out'
|
||||
className='tool'
|
||||
onClick={()=>handleZoomButton(displayOptions.zoomLevel - 20)}
|
||||
disabled={displayOptions.zoomLevel <= MIN_ZOOM}
|
||||
title='Zoom Out'
|
||||
data-tooltip-bottom='Zoom Out'
|
||||
aria-label='Zoom Out'
|
||||
>
|
||||
<i className='fas fa-magnifying-glass-minus' />
|
||||
<i aria-hidden='true' className='fas fa-magnifying-glass-minus' />
|
||||
</button>
|
||||
<input
|
||||
id='zoom-slider'
|
||||
className='range-input tool hover-tooltip'
|
||||
type='range'
|
||||
name='zoom'
|
||||
title='Set Zoom'
|
||||
list='zoomLevels'
|
||||
aria-label='Zoom Amount'
|
||||
min={MIN_ZOOM}
|
||||
max={MAX_ZOOM}
|
||||
step='1'
|
||||
@@ -154,9 +162,10 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
|
||||
className='tool'
|
||||
onClick={()=>handleZoomButton(displayOptions.zoomLevel + 20)}
|
||||
disabled={displayOptions.zoomLevel >= MAX_ZOOM}
|
||||
title='Zoom In'
|
||||
data-tooltip-bottom='Zoom In'
|
||||
aria-label='Zoom In'
|
||||
>
|
||||
<i className='fas fa-magnifying-glass-plus' />
|
||||
<i aria-hidden='true' className='fas fa-magnifying-glass-plus' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -166,44 +175,49 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
|
||||
<button role='radio'
|
||||
id='single-spread'
|
||||
className='tool'
|
||||
title='Single Page'
|
||||
data-tooltip-bottom='Single Page'
|
||||
aria-label='Single Page Spread'
|
||||
onClick={()=>{handleOptionChange('spread', 'single');}}
|
||||
aria-checked={displayOptions.spread === 'single'}
|
||||
><i className='fac single-spread' /></button>
|
||||
><i aria-hidden='true' className='fac single-spread' /></button>
|
||||
<button role='radio'
|
||||
id='facing-spread'
|
||||
className='tool'
|
||||
title='Facing Pages'
|
||||
data-tooltip-bottom='Facing Pages'
|
||||
aria-label='Facing Pages Spread'
|
||||
onClick={()=>{handleOptionChange('spread', 'facing');}}
|
||||
aria-checked={displayOptions.spread === 'facing'}
|
||||
><i className='fac facing-spread' /></button>
|
||||
><i aria-hidden='true' className='fac facing-spread' /></button>
|
||||
<button role='radio'
|
||||
id='flow-spread'
|
||||
className='tool'
|
||||
title='Flow Pages'
|
||||
data-tooltip-bottom='Flow Pages'
|
||||
aria-label='Flow Pages Spread'
|
||||
onClick={()=>{handleOptionChange('spread', 'flow');}}
|
||||
aria-checked={displayOptions.spread === 'flow'}
|
||||
><i className='fac flow-spread' /></button>
|
||||
><i aria-hidden='true' className='fac flow-spread' /></button>
|
||||
|
||||
</div>
|
||||
<Anchored>
|
||||
<AnchoredTrigger id='spread-settings' className='tool' title='Spread options'><i className='fas fa-gear' /></AnchoredTrigger>
|
||||
<AnchoredBox title='Options'>
|
||||
<AnchoredTrigger id='spread-settings' className='tool' aria-label='Spread options' data-tooltip-bottom='Spread options'>
|
||||
<i aria-hidden='true' className='fas fa-gear' />
|
||||
</AnchoredTrigger>
|
||||
<AnchoredBox>
|
||||
<h1>Options</h1>
|
||||
<label title='Modify the horizontal space between pages.'>
|
||||
<label data-tooltip-left='Modify the horizontal space between pages.'>
|
||||
Column gap
|
||||
<input type='range' min={0} max={200} defaultValue={displayOptions.columnGap || 10} className='range-input' onChange={(evt)=>handleOptionChange('columnGap', evt.target.value)} />
|
||||
</label>
|
||||
<label title='Modify the vertical space between rows of pages.'>
|
||||
<label data-tooltip-left='Modify the vertical space between rows of pages.'>
|
||||
Row gap
|
||||
<input type='range' min={0} max={200} defaultValue={displayOptions.rowGap || 10} className='range-input' onChange={(evt)=>handleOptionChange('rowGap', evt.target.value)} />
|
||||
</label>
|
||||
<label title='Start 1st page on the right side, such as if you have cover page.'>
|
||||
<label data-tooltip-left='Start 1st page on the right side, such as if you have cover page.'>
|
||||
Start on right
|
||||
<input type='checkbox' checked={displayOptions.startOnRight} onChange={()=>{handleOptionChange('startOnRight', !displayOptions.startOnRight);}}
|
||||
title={displayOptions.spread !== 'facing' ? 'Switch to Facing to enable toggle.' : null} />
|
||||
data-tooltip-right={displayOptions.spread !== 'facing' ? 'Switch to Facing to enable toggle.' : null} />
|
||||
</label>
|
||||
<label title='Toggle the page shadow on every page.'>
|
||||
<label data-tooltip-left='Toggle the page shadow on every page.'>
|
||||
Page shadows
|
||||
<input type='checkbox' checked={displayOptions.pageShadows} onChange={()=>{handleOptionChange('pageShadows', !displayOptions.pageShadows);}} />
|
||||
</label>
|
||||
@@ -217,11 +231,12 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
|
||||
id='previous-page'
|
||||
className='previousPage tool'
|
||||
type='button'
|
||||
title='Previous Page(s)'
|
||||
data-tooltip-bottom='Previous Page(s)'
|
||||
aria-label='Previous Page'
|
||||
onClick={()=>scrollToPage(_.min(visiblePages) - visiblePages.length)}
|
||||
disabled={visiblePages.includes(1)}
|
||||
>
|
||||
<i className='fas fa-arrow-left'></i>
|
||||
<i aria-hidden='true' className='fas fa-arrow-left'></i>
|
||||
</button>
|
||||
|
||||
<div className='tool'>
|
||||
@@ -230,7 +245,8 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
|
||||
className='text-input'
|
||||
type='text'
|
||||
name='page'
|
||||
title='Current page(s) in view'
|
||||
data-tooltip-bottom='Current page(s) in view'
|
||||
aria-label='Current page in view'
|
||||
inputMode='numeric'
|
||||
pattern='[0-9]'
|
||||
value={pageNum}
|
||||
@@ -240,18 +256,19 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
|
||||
onKeyDown={(e)=>e.key == 'Enter' && scrollToPage(pageNum)}
|
||||
style={{ width: `${pageNum.length}ch` }}
|
||||
/>
|
||||
<span id='page-count' title='Total Page Count'>/ {totalPages}</span>
|
||||
<span id='page-count' aria-label={`${totalPages} Total Pages`} data-tooltip-bottom='Total Page Count'><span aria-hidden='true'>/ {totalPages}</span></span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id='next-page'
|
||||
className='tool'
|
||||
type='button'
|
||||
title='Next Page(s)'
|
||||
data-tooltip-bottom='Next Page(s)'
|
||||
aria-label='Next Page'
|
||||
onClick={()=>scrollToPage(_.max(visiblePages) + 1)}
|
||||
disabled={visiblePages.includes(totalPages)}
|
||||
>
|
||||
<i className='fas fa-arrow-right'></i>
|
||||
<i aria-hidden='true' className='fas fa-arrow-right'></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import (less) './client/icons/customIcons.less';
|
||||
@import (less) '../../../icons/customIcons.less';
|
||||
|
||||
.toolBar {
|
||||
position : absolute;
|
||||
@@ -166,7 +166,7 @@
|
||||
|
||||
&.hidden {
|
||||
flex-wrap : nowrap;
|
||||
width : 92px;
|
||||
width : 50%;
|
||||
overflow : hidden;
|
||||
background-color : unset;
|
||||
opacity : 0.7;
|
||||
@@ -176,9 +176,7 @@
|
||||
transition : all 0.2s ease;
|
||||
}
|
||||
|
||||
.toggleButton button i {
|
||||
filter: drop-shadow(0 0 2px black) drop-shadow(0 0 1px black);
|
||||
}
|
||||
.toggleButton button i { filter : drop-shadow(0 0 2px black) drop-shadow(0 0 1px black); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+332
-479
@@ -1,19 +1,43 @@
|
||||
/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/
|
||||
import './editor.less';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import _ from 'lodash';
|
||||
import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import dedent from 'dedent';
|
||||
import Markdown from '../../../shared/markdown.js';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
import CodeEditor from '../../components/codeEditor/codeEditor.jsx';
|
||||
import CodeEditor from '@components/codeEditor/codeEditor.jsx';
|
||||
import SnippetBar from './snippetbar/snippetbar.jsx';
|
||||
import MetadataEditor from './metadataEditor/metadataEditor.jsx';
|
||||
import SettingsEditor from './settingsEditor/settingsEditor.jsx';
|
||||
|
||||
const EDITOR_THEME_KEY = 'HB_editor_theme';
|
||||
const EDITOR_SETTINGS_KEY = 'HB_edit_settings';
|
||||
|
||||
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
|
||||
const SNIPPETBREAK_REGEX_V3 = /^\\snippet\ .*$/;
|
||||
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 EditorThemes = Object.entries(themes)
|
||||
.filter(([name, value])=>Array.isArray(value) && !name.endsWith('Init') && !name.endsWith('Style'))
|
||||
.map(([name])=>name);
|
||||
|
||||
const themeNames = Object.entries(themes)
|
||||
.filter(([name, value])=>Array.isArray(value) &&
|
||||
!name.endsWith('Init') &&
|
||||
!name.endsWith('Style')
|
||||
)
|
||||
.map(([name])=>name);
|
||||
|
||||
const EditorThemeNameList = [
|
||||
'default',
|
||||
...themeNames
|
||||
.filter((name)=>name !== 'default')
|
||||
.sort((a, b)=>a.localeCompare(b))
|
||||
];
|
||||
|
||||
//const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
|
||||
//const SNIPPETBREAK_REGEX_V3 = /^\\snippet\ .*$/;
|
||||
const DEFAULT_STYLE_TEXT = dedent`
|
||||
/*=======--- Example CSS styling ---=======*/
|
||||
/* Any CSS here will apply to your document! */
|
||||
@@ -30,518 +54,347 @@ 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.
|
||||
`;
|
||||
let isJumping = false;
|
||||
let jumpSource = null;
|
||||
|
||||
const Editor = createReactClass({
|
||||
displayName : 'Editor',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
brew : {
|
||||
text : '',
|
||||
style : ''
|
||||
},
|
||||
const Editor = forwardRef(
|
||||
(
|
||||
{
|
||||
brew = {},
|
||||
|
||||
onBrewChange : ()=>{},
|
||||
reportError : ()=>{},
|
||||
onBrewChange = ()=>{},
|
||||
reportError = ()=>{},
|
||||
|
||||
onCursorPageChange : ()=>{},
|
||||
onViewPageChange : ()=>{},
|
||||
onCursorPageChange = ()=>{},
|
||||
onViewPageChange = ()=>{},
|
||||
|
||||
editorTheme : 'default',
|
||||
renderer : 'legacy',
|
||||
renderer = 'legacy',
|
||||
|
||||
currentEditorCursorPageNum : 1,
|
||||
currentEditorViewPageNum : 1,
|
||||
currentBrewRendererPageNum : 1,
|
||||
};
|
||||
},
|
||||
getInitialState : function() {
|
||||
return {
|
||||
editorTheme : this.props.editorTheme,
|
||||
view : 'text', //'text', 'style', 'meta', 'snippet'
|
||||
snippetBarHeight : 26,
|
||||
};
|
||||
},
|
||||
moveBrew,
|
||||
moveSource,
|
||||
liveScroll,
|
||||
|
||||
editor : React.createRef(null),
|
||||
codeEditor : React.createRef(null),
|
||||
setMoveArrows,
|
||||
updateBrew,
|
||||
showEditButtons,
|
||||
themeBundle,
|
||||
userThemes,
|
||||
|
||||
isText : function() {return this.state.view == 'text';},
|
||||
isStyle : function() {return this.state.view == 'style';},
|
||||
isMeta : function() {return this.state.view == 'meta';},
|
||||
isSnip : function() {return this.state.view == 'snippet';},
|
||||
|
||||
componentDidMount : function() {
|
||||
|
||||
this.highlightCustomMarkdown();
|
||||
document.getElementById('BrewRenderer').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);
|
||||
if(editorTheme) {
|
||||
this.setState({
|
||||
editorTheme : editorTheme
|
||||
});
|
||||
}
|
||||
const snippetBar = document.querySelector('.editor > .snippetBar');
|
||||
if (!snippetBar) return;
|
||||
|
||||
this.resizeObserver = new ResizeObserver(entries => {
|
||||
const height = document.querySelector('.editor > .snippetBar').offsetHeight;
|
||||
this.setState({ snippetBarHeight: height });
|
||||
currentEditorCursorPageNum = 1,
|
||||
currentEditorViewPageNum = 1,
|
||||
currentBrewRendererPageNum = 1,
|
||||
},
|
||||
ref,
|
||||
)=>{
|
||||
const [view, setView] = useState('text'); // 'text', 'style', 'meta', 'snippet'
|
||||
const [snippetBarHeight, setSnippetBarHeight] = useState(26);
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
const [editorSettings, setEditorSettings] = useState({
|
||||
autoCloseBrackets : true,
|
||||
showImagePreviews : true,
|
||||
activeLineShading : true,
|
||||
lineNumbers : true,
|
||||
fontSize : 1,
|
||||
editorTheme : 'default',
|
||||
});
|
||||
|
||||
this.resizeObserver.observe(snippetBar);
|
||||
},
|
||||
const editor = useRef(null);
|
||||
const codeEditor = useRef(null);
|
||||
const throttleBrewMove = useRef(null);
|
||||
|
||||
componentDidUpdate : function(prevProps, prevState, snapshot) {
|
||||
const isText = ()=>isView('text');
|
||||
const isStyle = ()=>isView('style');
|
||||
const isMeta = ()=>isView('meta');
|
||||
const isSnip = ()=>isView('snippet');
|
||||
const isSettings = ()=>isView('settings');
|
||||
|
||||
this.highlightCustomMarkdown();
|
||||
if(prevProps.moveBrew !== this.props.moveBrew)
|
||||
this.brewJump();
|
||||
const isView = (name)=>view === name;
|
||||
|
||||
if(prevProps.moveSource !== this.props.moveSource)
|
||||
this.sourceJump();
|
||||
useEffect(()=>{
|
||||
const brewRenderer = document.getElementById('BrewRenderer');
|
||||
brewRenderer.onload = ()=>brewRenderer.contentDocument?.addEventListener('keydown', handleControlKeys);
|
||||
document.addEventListener('keydown', handleControlKeys);
|
||||
|
||||
if(this.props.liveScroll) {
|
||||
if(prevProps.currentBrewRendererPageNum !== this.props.currentBrewRendererPageNum) {
|
||||
this.sourceJump(this.props.currentBrewRendererPageNum, false);
|
||||
} else if(prevProps.currentEditorViewPageNum !== this.props.currentEditorViewPageNum) {
|
||||
this.brewJump(this.props.currentEditorViewPageNum, false);
|
||||
} else if(prevProps.currentEditorCursorPageNum !== this.props.currentEditorCursorPageNum) {
|
||||
this.brewJump(this.props.currentEditorCursorPageNum, false);
|
||||
}
|
||||
}
|
||||
},
|
||||
const localEditorTheme = window.localStorage.getItem(EDITOR_THEME_KEY);
|
||||
if(localEditorTheme && EditorThemes.includes(localEditorTheme)) {
|
||||
setEditorSettings({ ...editorSettings, editorTheme: localEditorTheme });
|
||||
} else setEditorSettings({ ...editorSettings, editorTheme: 'default' });
|
||||
const localEditorSettings = window.localStorage.getItem(EDITOR_SETTINGS_KEY);
|
||||
if(localEditorSettings) setEditorSettings(JSON.parse(localEditorSettings));
|
||||
const snippetBar = document.querySelector('.editor > .snippetBar');
|
||||
if(!snippetBar) return;
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.resizeObserver) this.resizeObserver.disconnect();
|
||||
},
|
||||
|
||||
handleControlKeys : function(e){
|
||||
if(!(e.ctrlKey && e.metaKey && e.shiftKey)) return;
|
||||
const LEFTARROW_KEY = 37;
|
||||
const RIGHTARROW_KEY = 39;
|
||||
if(e.keyCode == RIGHTARROW_KEY) this.brewJump();
|
||||
if(e.keyCode == LEFTARROW_KEY) this.sourceJump();
|
||||
if(e.keyCode == LEFTARROW_KEY || e.keyCode == RIGHTARROW_KEY) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
|
||||
updateCurrentCursorPage : function(cursor) {
|
||||
const lines = this.props.brew.text.split('\n').slice(1, cursor.line + 1);
|
||||
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) {
|
||||
const lines = this.props.brew.text.split('\n').slice(1, topScrollLine + 1);
|
||||
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){
|
||||
this.codeEditor.current?.injectText(injectText, false);
|
||||
},
|
||||
|
||||
handleViewChange : function(newView){
|
||||
this.props.setMoveArrows(newView === 'text');
|
||||
|
||||
this.setState({
|
||||
view : newView
|
||||
}, ()=>{
|
||||
this.codeEditor.current?.codeMirror?.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));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
const resizeObserver = new ResizeObserver((entries)=>{
|
||||
const height = document.querySelector('.editor > .snippetBar').offsetHeight;
|
||||
setSnippetBarHeight(height);
|
||||
});
|
||||
}
|
||||
},
|
||||
resizeObserver.observe(snippetBar);
|
||||
|
||||
brewJump : function(targetPage=this.props.currentEditorCursorPageNum, smooth=true){
|
||||
if(!window || !this.isText() || isJumping)
|
||||
return;
|
||||
|
||||
// Get current brewRenderer scroll position and calculate target position
|
||||
const brewRenderer = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer')[0];
|
||||
const currentPos = brewRenderer.scrollTop;
|
||||
const targetPos = window.frames['BrewRenderer'].contentDocument.getElementById(`p${targetPage}`).getBoundingClientRect().top;
|
||||
|
||||
let scrollingTimeout;
|
||||
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;
|
||||
brewRenderer.removeEventListener('scroll', checkIfScrollComplete);
|
||||
}, 150); // If 150 ms pass without a brewRenderer scroll event, assume scrolling is done
|
||||
};
|
||||
|
||||
isJumping = true;
|
||||
checkIfScrollComplete();
|
||||
brewRenderer.addEventListener('scroll', checkIfScrollComplete);
|
||||
|
||||
if(smooth) {
|
||||
const bouncePos = targetPos >= 0 ? -30 : 30; //Do a little bounce before scrolling
|
||||
const bounceDelay = 100;
|
||||
const scrollDelay = 500;
|
||||
|
||||
if(!this.throttleBrewMove) {
|
||||
this.throttleBrewMove = _.throttle((currentPos, bouncePos, targetPos)=>{
|
||||
brewRenderer.scrollTo({ top: currentPos + bouncePos, behavior: 'smooth' });
|
||||
setTimeout(()=>{
|
||||
brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'smooth', block: 'start' });
|
||||
}, bounceDelay);
|
||||
}, scrollDelay, { leading: true, trailing: false });
|
||||
return ()=>{
|
||||
if(resizeObserver) resizeObserver.disconnect();
|
||||
};
|
||||
this.throttleBrewMove(currentPos, bouncePos, targetPos);
|
||||
} else {
|
||||
brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'instant', block: 'start' });
|
||||
}
|
||||
},
|
||||
}, []);
|
||||
|
||||
sourceJump : function(targetPage=this.props.currentBrewRendererPageNum, smooth=true){
|
||||
if(!this.isText() || isJumping)
|
||||
return;
|
||||
useEffect(()=>{ if(moveBrew) brewJump(); }, [moveBrew]);
|
||||
useEffect(()=>{ if(moveSource) sourceJump(); }, [moveSource]);
|
||||
useEffect(()=>{ if(liveScroll) sourceJump(currentBrewRendererPageNum, false); }, [currentBrewRendererPageNum, liveScroll]);
|
||||
useEffect(()=>{ if(liveScroll) brewJump(currentEditorViewPageNum, false); }, [currentEditorViewPageNum, liveScroll]);
|
||||
useEffect(()=>{ if(liveScroll) brewJump(currentEditorCursorPageNum, false); }, [currentEditorCursorPageNum, liveScroll]);
|
||||
|
||||
const textSplit = this.props.renderer == 'V3' ? PAGEBREAK_REGEX_V3 : /\\page/;
|
||||
const textString = this.props.brew.text.split(textSplit).slice(0, targetPage-1).join(textSplit);
|
||||
const targetLine = textString.match('\n') ? textString.split('\n').length - 1 : -1;
|
||||
|
||||
let currentY = this.codeEditor.current.codeMirror?.getScrollInfo().top;
|
||||
let targetY = this.codeEditor.current.codeMirror?.heightAtLine(targetLine, 'local', true);
|
||||
|
||||
let scrollingTimeout;
|
||||
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
|
||||
const handleFormatCode = ()=>{
|
||||
codeEditor.current?.formatCode();
|
||||
};
|
||||
|
||||
isJumping = true;
|
||||
checkIfScrollComplete();
|
||||
if (this.codeEditor.current?.codeMirror) {
|
||||
this.codeEditor.current.codeMirror?.on('scroll', checkIfScrollComplete);
|
||||
}
|
||||
const handleControlKeys = (e)=>{
|
||||
if(!(e.ctrlKey && e.metaKey && e.shiftKey)) return;
|
||||
const LEFTARROW_KEY = 37;
|
||||
const RIGHTARROW_KEY = 39;
|
||||
if(e.keyCode == RIGHTARROW_KEY) brewJump();
|
||||
if(e.keyCode == LEFTARROW_KEY) sourceJump();
|
||||
if(e.keyCode == LEFTARROW_KEY || e.keyCode == RIGHTARROW_KEY) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
const updateCurrentCursorPage = (pageNumber)=>{
|
||||
onCursorPageChange(pageNumber);
|
||||
};
|
||||
|
||||
// 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);
|
||||
const updateCurrentViewPage = (pageNumber)=>{
|
||||
onViewPageChange(pageNumber);
|
||||
};
|
||||
|
||||
// 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);
|
||||
const handleInject = (injectText)=>{
|
||||
codeEditor.current?.injectText(injectText);
|
||||
};
|
||||
|
||||
const handleViewChange = (newView)=>{
|
||||
setMoveArrows(newView === 'text');
|
||||
setView(newView);
|
||||
};
|
||||
useEffect(()=>{
|
||||
codeEditor.current?.focus();
|
||||
}, [view]);
|
||||
|
||||
const brewJump = (targetPage = currentEditorCursorPageNum, smooth = true)=>{
|
||||
if(!window || !isText() || isJumping || jumpSource === 'source') return;
|
||||
|
||||
const brewRenderer =
|
||||
window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer')[0];
|
||||
|
||||
const currentPos = brewRenderer.scrollTop;
|
||||
|
||||
const targetPos = window.frames['BrewRenderer'].contentDocument
|
||||
.getElementById(`p${targetPage}`)
|
||||
.getBoundingClientRect().top;
|
||||
|
||||
let scrollingTimeout;
|
||||
|
||||
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;
|
||||
jumpSource = null;
|
||||
|
||||
brewRenderer.removeEventListener('scroll', checkIfScrollComplete);
|
||||
}, 150);// If 150 ms pass without a brewRenderer scroll event, assume scrolling is done
|
||||
};
|
||||
|
||||
isJumping = true;
|
||||
jumpSource = 'brew';
|
||||
|
||||
checkIfScrollComplete();
|
||||
brewRenderer.addEventListener('scroll', checkIfScrollComplete);
|
||||
|
||||
if(smooth) {
|
||||
const bouncePos = targetPos >= 0 ? -30 : 30; //Do a little bounce before scrolling
|
||||
const now = Date.now();
|
||||
|
||||
if(now - throttleBrewMove.current >= 500) {
|
||||
throttleBrewMove.current = now;
|
||||
|
||||
brewRenderer.scrollTo({ top: currentPos + bouncePos, behavior: 'smooth' });
|
||||
|
||||
setTimeout(()=>{
|
||||
brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'smooth', block: 'start' });
|
||||
}, 100);
|
||||
}
|
||||
}, 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');
|
||||
}
|
||||
},
|
||||
} else {
|
||||
brewRenderer.scrollTo({ top : currentPos + targetPos, behavior : 'instant', block : 'start',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
//Called when there are changes to the editor's dimensions
|
||||
update : function(){},
|
||||
const sourceJump = (targetPage = currentBrewRendererPageNum, smooth = true)=>{
|
||||
if(!isText() || isJumping || jumpSource === 'brew') return;
|
||||
|
||||
updateEditorTheme : function(newTheme){
|
||||
window.localStorage.setItem(EDITOR_THEME_KEY, newTheme);
|
||||
this.setState({
|
||||
editorTheme : newTheme
|
||||
});
|
||||
},
|
||||
if(!codeEditor.current) return;
|
||||
jumpSource = 'source';
|
||||
|
||||
//Called by CodeEditor after document switch, so Snippetbar can refresh UndoHistory
|
||||
rerenderParent : function (){
|
||||
this.forceUpdate();
|
||||
},
|
||||
codeEditor.current.scrollToPage(targetPage);
|
||||
setTimeout(()=>{
|
||||
jumpSource = null;
|
||||
}, 200);
|
||||
};
|
||||
|
||||
renderEditor : function(){
|
||||
if(this.isText()){
|
||||
return <>
|
||||
<CodeEditor key='codeEditor'
|
||||
ref={this.codeEditor}
|
||||
language='gfm'
|
||||
view={this.state.view}
|
||||
value={this.props.brew.text}
|
||||
onChange={this.props.onBrewChange('text')}
|
||||
editorTheme={this.state.editorTheme}
|
||||
rerenderParent={this.rerenderParent}
|
||||
style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }} />
|
||||
</>;
|
||||
}
|
||||
if(this.isStyle()){
|
||||
return <>
|
||||
<CodeEditor key='codeEditor'
|
||||
ref={this.codeEditor}
|
||||
language='css'
|
||||
view={this.state.view}
|
||||
value={this.props.brew.style ?? DEFAULT_STYLE_TEXT}
|
||||
onChange={this.props.onBrewChange('style')}
|
||||
enableFolding={true}
|
||||
editorTheme={this.state.editorTheme}
|
||||
rerenderParent={this.rerenderParent}
|
||||
style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }} />
|
||||
</>;
|
||||
}
|
||||
if(this.isMeta()){
|
||||
return <>
|
||||
<CodeEditor key='codeEditor'
|
||||
view={this.state.view}
|
||||
style={{ display: 'none' }}
|
||||
rerenderParent={this.rerenderParent} />
|
||||
<MetadataEditor
|
||||
metadata={this.props.brew}
|
||||
themeBundle={this.props.themeBundle}
|
||||
onChange={this.props.onBrewChange('metadata')}
|
||||
reportError={this.props.reportError}
|
||||
userThemes={this.props.userThemes}/>
|
||||
</>;
|
||||
}
|
||||
if(this.isSnip()){
|
||||
if(!this.props.brew.snippets) { this.props.brew.snippets = DEFAULT_SNIPPET_TEXT; }
|
||||
return <>
|
||||
<CodeEditor key='codeEditor'
|
||||
ref={this.codeEditor}
|
||||
language='gfm'
|
||||
view={this.state.view}
|
||||
value={this.props.brew.snippets}
|
||||
onChange={this.props.onBrewChange('snippets')}
|
||||
enableFolding={true}
|
||||
editorTheme={this.state.editorTheme}
|
||||
rerenderParent={this.rerenderParent}
|
||||
style={{ height: `calc(100% -${this.state.snippetBarHeight}px)` }} />
|
||||
</>;
|
||||
}
|
||||
},
|
||||
const updateEditorTheme = (newTheme)=>{
|
||||
window.localStorage.setItem(EDITOR_THEME_KEY, newTheme);
|
||||
setEditorSettings({ ...editorSettings, editorTheme: newTheme });
|
||||
};
|
||||
|
||||
redo : function(){
|
||||
return this.codeEditor.current?.redo();
|
||||
},
|
||||
const updateEditorSettings = (newEditorSettings)=>{
|
||||
window.localStorage.setItem(EDITOR_SETTINGS_KEY, JSON.stringify(newEditorSettings));
|
||||
setEditorSettings(newEditorSettings);
|
||||
};
|
||||
|
||||
historySize : function(){
|
||||
return this.codeEditor.current?.historySize();
|
||||
},
|
||||
const renderEditor = ()=>{
|
||||
if(isText()) {
|
||||
return (
|
||||
<>
|
||||
<CodeEditor
|
||||
key='codeEditor'
|
||||
ref={codeEditor}
|
||||
language='gfm'
|
||||
tab='brewText'
|
||||
view={view}
|
||||
value={brew.text}
|
||||
onChange={onBrewChange('text')}
|
||||
onCursorChange={(page)=>updateCurrentCursorPage(page)}
|
||||
onViewChange={(page)=>updateCurrentViewPage(page)}
|
||||
editorTheme={editorSettings.editorTheme}
|
||||
onThemeChange={setIsDark}
|
||||
renderer={brew.renderer}
|
||||
style={{ height: `calc(100% - ${snippetBarHeight}px)` }}
|
||||
settings={editorSettings}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if(isStyle()) {
|
||||
return (
|
||||
<>
|
||||
<CodeEditor
|
||||
key='codeEditor'
|
||||
ref={codeEditor}
|
||||
language='css'
|
||||
tab='brewStyles'
|
||||
view={view}
|
||||
value={brew.style ?? DEFAULT_STYLE_TEXT}
|
||||
onChange={onBrewChange('style')}
|
||||
editorTheme={editorSettings.editorTheme}
|
||||
onThemeChange={setIsDark}
|
||||
renderer={brew.renderer}
|
||||
style={{ height: `calc(100% - ${snippetBarHeight}px)` }}
|
||||
settings={editorSettings}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if(isSnip()) {
|
||||
if(!brew.snippets) {
|
||||
brew.snippets = DEFAULT_SNIPPET_TEXT;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<CodeEditor
|
||||
key='codeEditor'
|
||||
ref={codeEditor}
|
||||
language='gfm'
|
||||
tab='brewSnippets'
|
||||
view={view}
|
||||
value={brew.snippets}
|
||||
onChange={onBrewChange('snippets')}
|
||||
enableFolding={true}
|
||||
editorTheme={editorSettings.editorTheme}
|
||||
onThemeChange={setIsDark}
|
||||
renderer={brew.renderer}
|
||||
style={{ height: `calc(100% - 25px)` }}
|
||||
settings={editorSettings}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if(isMeta()) {
|
||||
return (
|
||||
<>
|
||||
<CodeEditor key='codeEditor' tab='brewMetadata' editorTheme={editorSettings.editorTheme} onThemeChange={setIsDark} view={view} style={{ display: 'none' }} settings={editorSettings} />
|
||||
<MetadataEditor
|
||||
metadata={brew}
|
||||
themeBundle={themeBundle}
|
||||
onChange={onBrewChange('metadata')}
|
||||
reportError={reportError}
|
||||
userThemes={userThemes}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if(isSettings()){
|
||||
return (
|
||||
<>
|
||||
<CodeEditor
|
||||
key='codeEditor'
|
||||
tab='brewSettings' //necessary or the brew object loses its contents, culprit possibly on the tab dependent useEffect in codeEditor.jsx
|
||||
view={view}
|
||||
style={{ display: 'none' }}
|
||||
editorTheme={editorSettings.editorTheme}
|
||||
onThemeChange={setIsDark}
|
||||
settings={editorSettings}
|
||||
/>
|
||||
<SettingsEditor
|
||||
settings={editorSettings}
|
||||
EditorThemeNameList={EditorThemeNameList}
|
||||
updateSettings={updateEditorSettings}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
undo : function(){
|
||||
return this.codeEditor.current?.undo();
|
||||
},
|
||||
const redo = ()=>codeEditor.current?.redo();
|
||||
const historySize = ()=>codeEditor.current?.historySize();
|
||||
const undo = ()=>codeEditor.current?.undo();
|
||||
const foldCode = ()=>codeEditor.current?.foldAll();
|
||||
const unfoldCode = ()=>codeEditor.current?.unfoldAll();
|
||||
|
||||
foldCode : function(){
|
||||
return this.codeEditor.current?.foldAllCode();
|
||||
},
|
||||
//Called when there are changes to the editor's dimensions
|
||||
const update = ()=>{};
|
||||
|
||||
unfoldCode : function(){
|
||||
return this.codeEditor.current?.unfoldAllCode();
|
||||
},
|
||||
|
||||
render : function(){
|
||||
useImperativeHandle(ref, ()=>({
|
||||
update,
|
||||
undo,
|
||||
redo,
|
||||
foldCode,
|
||||
unfoldCode,
|
||||
historySize,
|
||||
}));
|
||||
return (
|
||||
<div className='editor' ref={this.editor}>
|
||||
<div className={`editor${isDark ? ' darkMode' : ''}`} ref={editor}>
|
||||
<SnippetBar
|
||||
brew={this.props.brew}
|
||||
view={this.state.view}
|
||||
onViewChange={this.handleViewChange}
|
||||
onInject={this.handleInject}
|
||||
showEditButtons={this.props.showEditButtons}
|
||||
renderer={this.props.renderer}
|
||||
theme={this.props.brew.theme}
|
||||
undo={this.undo}
|
||||
redo={this.redo}
|
||||
foldCode={this.foldCode}
|
||||
unfoldCode={this.unfoldCode}
|
||||
historySize={this.historySize()}
|
||||
currentEditorTheme={this.state.editorTheme}
|
||||
updateEditorTheme={this.updateEditorTheme}
|
||||
themeBundle={this.props.themeBundle}
|
||||
cursorPos={this.codeEditor.current?.getCursorPosition() || {}}
|
||||
updateBrew={this.props.updateBrew}
|
||||
brew={brew}
|
||||
view={view}
|
||||
onViewChange={handleViewChange}
|
||||
onInject={handleInject}
|
||||
showEditButtons={showEditButtons}
|
||||
renderer={renderer}
|
||||
theme={brew.theme}
|
||||
undo={undo}
|
||||
redo={redo}
|
||||
foldCode={foldCode}
|
||||
unfoldCode={unfoldCode}
|
||||
formatCode={isStyle() ? handleFormatCode : null}
|
||||
historySize={historySize()}
|
||||
currentEditorTheme={editorSettings.editorTheme}
|
||||
updateEditorTheme={updateEditorTheme}
|
||||
themeBundle={themeBundle}
|
||||
cursorPos={codeEditor.current?.getCursorPosition() || {}}
|
||||
updateBrew={updateBrew}
|
||||
/>
|
||||
|
||||
{this.renderEditor()}
|
||||
{renderEditor()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
export default Editor;
|
||||
|
||||
@@ -1,87 +1,11 @@
|
||||
@import 'themes/codeMirror/customEditorStyles.less';
|
||||
.editor {
|
||||
position : relative;
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
container : editor / inline-size;
|
||||
.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); }
|
||||
}
|
||||
}
|
||||
@import '@sharedStyles/core.less';
|
||||
|
||||
:where(.editor) {
|
||||
position : relative;
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
container : editor / inline-size;
|
||||
background : white;
|
||||
|
||||
.brewJump {
|
||||
position : absolute;
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
/* eslint-disable max-lines */
|
||||
import './metadataEditor.less';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import '../uiEditor.less';
|
||||
import React, { useState } from 'react';
|
||||
import _ from 'lodash';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import Combobox from '../../../components/combobox.jsx';
|
||||
import TagInput from '../tagInput/tagInput.jsx';
|
||||
|
||||
import Themes from 'themes/themes.json';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import Themes from '@themes/themes.json';
|
||||
import validations from './validations.js';
|
||||
|
||||
const SYSTEMS = ['5e', '4e', '3.5e', 'Pathfinder'];
|
||||
import Combobox from '@components/combobox.jsx';
|
||||
import TagInput from '../tagInput/tagInput.jsx';
|
||||
|
||||
import homebreweryThumbnail from '../../thumbnail.png';
|
||||
|
||||
@@ -20,48 +18,28 @@ const callIfExists = (val, fn, ...args)=>{
|
||||
}
|
||||
};
|
||||
|
||||
const MetadataEditor = createReactClass({
|
||||
displayName : 'MetadataEditor',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
metadata : {
|
||||
editId : null,
|
||||
shareId : null,
|
||||
title : '',
|
||||
description : '',
|
||||
thumbnail : '',
|
||||
tags : [],
|
||||
published : false,
|
||||
authors : [],
|
||||
systems : [],
|
||||
renderer : 'legacy',
|
||||
theme : '5ePHB',
|
||||
lang : 'en'
|
||||
},
|
||||
|
||||
onChange : ()=>{},
|
||||
reportError : ()=>{}
|
||||
};
|
||||
const MetadataEditor = ({
|
||||
metadata = {
|
||||
shareId : null,
|
||||
editId : null,
|
||||
title : '',
|
||||
description : '',
|
||||
tags : [],
|
||||
lang : 'en',
|
||||
renderer : 'legacy',
|
||||
authors : [],
|
||||
published : false,
|
||||
thumbnail : '',
|
||||
theme : '5ePHB',
|
||||
},
|
||||
onChange = ()=>{},
|
||||
reportError = ()=>{},
|
||||
userThemes = {},
|
||||
themeBundle = {}
|
||||
})=>{
|
||||
const [showThumbnail, setShowThumbnail] = useState(true);
|
||||
|
||||
getInitialState : function(){
|
||||
return {
|
||||
showThumbnail : true
|
||||
};
|
||||
},
|
||||
|
||||
toggleThumbnailDisplay : function(){
|
||||
this.setState({
|
||||
showThumbnail : !this.state.showThumbnail
|
||||
});
|
||||
},
|
||||
|
||||
renderThumbnail : function(){
|
||||
if(!this.state.showThumbnail) return;
|
||||
return <img className='thumbnail-preview' src={this.props.metadata.thumbnail || homebreweryThumbnail}></img>;
|
||||
},
|
||||
|
||||
handleFieldChange : function(name, e){
|
||||
const handleFieldChange = (name, e)=>{
|
||||
// load validation rules, and check input value against them
|
||||
const inputRules = validations[name] ?? [];
|
||||
const validationErr = inputRules.map((rule)=>rule(e.target.value)).filter(Boolean);
|
||||
@@ -74,8 +52,8 @@ const MetadataEditor = createReactClass({
|
||||
// if no validation rules, save to props
|
||||
if(validationErr.length === 0){
|
||||
callIfExists(e.target, 'setCustomValidity', '');
|
||||
this.props.onChange({
|
||||
...this.props.metadata,
|
||||
onChange({
|
||||
...metadata,
|
||||
[name] : e.target.value
|
||||
});
|
||||
return true;
|
||||
@@ -85,58 +63,40 @@ const MetadataEditor = createReactClass({
|
||||
return `- ${err}`;
|
||||
}).join('\n');
|
||||
|
||||
|
||||
debouncedReportValidity(e.target, errMessage);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
handleSystem : function(system, e){
|
||||
};
|
||||
const handleRenderer = (renderer, e)=>{
|
||||
if(e.target.checked){
|
||||
this.props.metadata.systems.push(system);
|
||||
} else {
|
||||
this.props.metadata.systems = _.without(this.props.metadata.systems, system);
|
||||
}
|
||||
this.props.onChange(this.props.metadata);
|
||||
},
|
||||
|
||||
handleRenderer : function(renderer, e){
|
||||
if(e.target.checked){
|
||||
this.props.metadata.renderer = renderer;
|
||||
metadata.renderer = renderer;
|
||||
if(renderer == 'legacy')
|
||||
this.props.metadata.theme = '5ePHB';
|
||||
metadata.theme = '5ePHB';
|
||||
}
|
||||
this.props.onChange(this.props.metadata, 'renderer');
|
||||
},
|
||||
|
||||
handlePublish : function(val){
|
||||
this.props.onChange({
|
||||
...this.props.metadata,
|
||||
onChange(metadata, 'renderer');
|
||||
};
|
||||
const handlePublish = (val)=>{
|
||||
onChange({
|
||||
...metadata,
|
||||
published : val
|
||||
});
|
||||
},
|
||||
|
||||
handleTheme : function(theme){
|
||||
this.props.metadata.renderer = theme.renderer;
|
||||
this.props.metadata.theme = theme.path;
|
||||
|
||||
this.props.onChange(this.props.metadata, 'theme');
|
||||
},
|
||||
|
||||
handleThemeWritein : function(e) {
|
||||
};
|
||||
const handleTheme = (theme)=>{
|
||||
metadata.renderer = theme.renderer;
|
||||
metadata.theme = theme.path;
|
||||
onChange(metadata, 'theme');
|
||||
};
|
||||
const handleThemeWritein = (e)=>{
|
||||
const shareId = e.target.value.split('/').pop(); //Extract just the ID if a URL was pasted in
|
||||
this.props.metadata.theme = shareId;
|
||||
|
||||
this.props.onChange(this.props.metadata, 'theme');
|
||||
},
|
||||
|
||||
handleLanguage : function(languageCode){
|
||||
this.props.metadata.lang = languageCode;
|
||||
this.props.onChange(this.props.metadata);
|
||||
},
|
||||
|
||||
handleDelete : function(){
|
||||
if(this.props.metadata.authors && this.props.metadata.authors.length <= 1){
|
||||
metadata.theme = shareId;
|
||||
onChange(metadata, 'theme');
|
||||
};
|
||||
const handleLanguage = (languageCode)=>{
|
||||
metadata.lang = languageCode;
|
||||
onChange(metadata);
|
||||
};
|
||||
const handleDelete = ()=>{
|
||||
if(metadata.authors && metadata.authors.length <= 1){
|
||||
if(!confirm('Are you sure you want to delete this brew? Because you are the only owner of this brew, the document will be deleted permanently.')) return;
|
||||
if(!confirm('Are you REALLY sure? You will not be able to recover the document.')) return;
|
||||
} else {
|
||||
@@ -144,73 +104,91 @@ const MetadataEditor = createReactClass({
|
||||
if(!confirm('Are you REALLY sure? You will lose editor access to this document.')) return;
|
||||
}
|
||||
|
||||
request.delete(`/api/${this.props.metadata.googleId ?? ''}${this.props.metadata.editId}`)
|
||||
request.delete(`/api/${metadata.googleId ?? ''}${metadata.editId}`)
|
||||
.send()
|
||||
.end((err, res)=>{
|
||||
if(err) {
|
||||
this.props.reportError(err);
|
||||
reportError(err);
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
renderSystems : function(){
|
||||
return _.map(SYSTEMS, (val)=>{
|
||||
return <label key={val}>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={_.includes(this.props.metadata.systems, val)}
|
||||
onChange={(e)=>this.handleSystem(val, e)} />
|
||||
{val}
|
||||
</label>;
|
||||
};
|
||||
const handleDeleteAuthor = (author)=>{
|
||||
if(!confirm('Are you sure you want to remove this author? They will lose all edit access to this brew, and it will dissapear from their userpage.')) return;
|
||||
if(!metadata.authors.includes(author)) return;
|
||||
onChange({
|
||||
...metadata,
|
||||
authors : metadata.authors.filter((a)=>a !== author)
|
||||
});
|
||||
},
|
||||
|
||||
renderPublish : function(){
|
||||
if(this.props.metadata.published){
|
||||
return <button className='unpublish' onClick={()=>this.handlePublish(false)}>
|
||||
<i className='fas fa-ban' /> unpublish
|
||||
};
|
||||
const renderPublish = ()=>{
|
||||
if(metadata.published){
|
||||
return <button id='publish-button' className='unpublish' onClick={()=>handlePublish(false)}>
|
||||
<i className='fas fa-ban' aria-hidden='true' /> unpublish
|
||||
</button>;
|
||||
} else {
|
||||
return <button className='publish' onClick={()=>this.handlePublish(true)}>
|
||||
<i className='fas fa-globe' /> publish
|
||||
return <button id='publish-button' className='publish' onClick={()=>handlePublish(true)}>
|
||||
<i className='fas fa-globe' aria-hidden='true' /> publish
|
||||
</button>;
|
||||
}
|
||||
},
|
||||
|
||||
renderDelete : function(){
|
||||
if(!this.props.metadata.editId) return;
|
||||
|
||||
return <div className='field delete'>
|
||||
<label>delete</label>
|
||||
<div className='value'>
|
||||
<button className='publish' onClick={this.handleDelete}>
|
||||
<i className='fas fa-trash-alt' /> delete brew
|
||||
</button>
|
||||
};
|
||||
const renderAuthors = ()=>{
|
||||
const authors = metadata.authors;
|
||||
const isOwner = global.account?.username && global.account?.username === authors[0];
|
||||
if(!isOwner || authors.length < 2) return (
|
||||
<div className='field authors'>
|
||||
<label>authors</label>
|
||||
<div className='value'>
|
||||
{authors.length > 0 && (
|
||||
<a href={`/user/${authors[0]}`} className='author-link' target='_blank' title={`Owner - Click to open ${authors[0]}'s profile in a new tab`}>
|
||||
{authors[0]}{authors.length > 1 && ', '}
|
||||
</a>
|
||||
)}
|
||||
{authors.length > 1 && authors.slice(1).map((author, i)=>(
|
||||
<a href={`/user/${author}`} className='author-link' title={`Author - Click to open ${author}'s profile in a new tab`}>
|
||||
{author}{i+2 < authors.length && ', '}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
},
|
||||
);
|
||||
return (
|
||||
<div className='field authors'>
|
||||
<label>Authors</label>
|
||||
<ul className='list'>
|
||||
{authors.length > 0 && (
|
||||
<li className='tag owner' title='Owner'>
|
||||
<a href={`/user/${authors[0]}`} className='author-link' title={`Owner - Click to open ${authors[0]}'s profile in a new tab`}>
|
||||
{authors[0]}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
|
||||
renderAuthors : function(){
|
||||
let text = 'None.';
|
||||
if(this.props.metadata.authors && this.props.metadata.authors.length){
|
||||
text = this.props.metadata.authors.join(', ');
|
||||
}
|
||||
return <div className='field authors'>
|
||||
<label>authors</label>
|
||||
<div className='value'>
|
||||
{text}
|
||||
{authors.length > 1 && authors.slice(1).map((author, i)=>(
|
||||
<li className='tag author' key={i + 1} title='Author'>
|
||||
<a href={`/user/${author}`} className='author-link' title={`Author - Click to open ${authors[0]}'s profile in a new tab`}>
|
||||
{author}
|
||||
</a>
|
||||
<button
|
||||
onClick={()=>handleDeleteAuthor(author)}
|
||||
className='delete'
|
||||
title={`Remove ${author} as an author`}
|
||||
>
|
||||
<i className='fa fa-times fa-fw' />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>;
|
||||
},
|
||||
|
||||
renderThemeDropdown : function(){
|
||||
const mergedThemes = _.merge(Themes, this.props.userThemes);
|
||||
);
|
||||
};
|
||||
const renderThemeDropdown = ()=>{
|
||||
const mergedThemes = _.merge(Themes, userThemes);
|
||||
|
||||
const listThemes = (renderer)=>{
|
||||
return _.map(_.values(mergedThemes[renderer]), (theme)=>{
|
||||
if(theme.path == this.props.metadata.shareId) return;
|
||||
if(theme.path == metadata.shareId) return;
|
||||
const preview = theme.thumbnail || `/themes/${theme.renderer}/${theme.path}/dropdownPreview.png`;
|
||||
const texture = theme.thumbnail || `/themes/${theme.renderer}/${theme.path}/dropdownTexture.png`;
|
||||
return <div className='item' key={`${renderer}_${theme.name}`} value={`${theme.author ?? renderer} : ${theme.name}`} data={theme} title={''}>
|
||||
@@ -226,8 +204,8 @@ const MetadataEditor = createReactClass({
|
||||
}).filter(Boolean);
|
||||
};
|
||||
|
||||
const currentRenderer = this.props.metadata.renderer;
|
||||
const currentThemeDisplay = this.props.themeBundle?.name ? `${this.props.themeBundle.author ?? currentRenderer} : ${this.props.themeBundle.name}` : 'No Theme Selected';
|
||||
const currentRenderer = metadata.renderer;
|
||||
const currentThemeDisplay = themeBundle?.name ? `${themeBundle.author ?? currentRenderer} : ${themeBundle.name}` : 'No Theme Selected';
|
||||
let dropdown;
|
||||
|
||||
if(currentRenderer == 'legacy') {
|
||||
@@ -237,16 +215,17 @@ const MetadataEditor = createReactClass({
|
||||
</div>;
|
||||
} else {
|
||||
dropdown =
|
||||
<div className='value'>
|
||||
<div className='value' data-tooltip-top='Select from the list below (built-in themes and brews you have tagged "meta:theme"), or paste in the Share URL or Share ID of any brew.'>
|
||||
<Combobox trigger='click'
|
||||
id='combobox-themes'
|
||||
className='themes-dropdown'
|
||||
default={currentThemeDisplay}
|
||||
placeholder='Select from below, or enter the Share URL or ID of a brew with the meta:theme tag'
|
||||
onSelect={(value)=>this.handleTheme(value)}
|
||||
onSelect={(value)=>handleTheme(value)}
|
||||
onEntry={(e)=>{
|
||||
e.target.setCustomValidity(''); //Clear the validation popup while typing
|
||||
if(this.handleFieldChange('theme', e))
|
||||
this.handleThemeWritein(e);
|
||||
if(handleFieldChange('theme', e))
|
||||
handleThemeWritein(e);
|
||||
}}
|
||||
options={listThemes(currentRenderer)}
|
||||
autoSuggest={{
|
||||
@@ -255,17 +234,15 @@ const MetadataEditor = createReactClass({
|
||||
filterOn : ['value', 'title']
|
||||
}}
|
||||
/>
|
||||
<small>Select from the list below (built-in themes and brews you have tagged "meta:theme"), or paste in the Share URL or Share ID of any brew.</small>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <div className='field themes'>
|
||||
<label>theme</label>
|
||||
<label htmlFor='combobox-themes'>theme</label>
|
||||
{dropdown}
|
||||
</div>;
|
||||
},
|
||||
|
||||
renderLanguageDropdown : function(){
|
||||
};
|
||||
const renderLanguageDropdown = ()=>{
|
||||
const langCodes = ['en', 'de', 'de-ch', 'fr', 'ja', 'es', 'it', 'sv', 'ru', 'zh-Hans', 'zh-Hant'];
|
||||
const listLanguages = ()=>{
|
||||
return _.map(langCodes.sort(), (code, index)=>{
|
||||
@@ -279,16 +256,17 @@ const MetadataEditor = createReactClass({
|
||||
};
|
||||
|
||||
return <div className='field language'>
|
||||
<label>language</label>
|
||||
<div className='value'>
|
||||
<label htmlFor='combobox-language'>language</label>
|
||||
<div className='value' data-tooltip-right='Sets the HTML Lang property for your brew. May affect hyphenation or spellcheck.'>
|
||||
<Combobox trigger='click'
|
||||
id='combobox-language'
|
||||
className='language-dropdown'
|
||||
default={this.props.metadata.lang || ''}
|
||||
default={metadata.lang || ''}
|
||||
placeholder='en'
|
||||
onSelect={(value)=>this.handleLanguage(value)}
|
||||
onSelect={(value)=>handleLanguage(value)}
|
||||
onEntry={(e)=>{
|
||||
e.target.setCustomValidity(''); //Clear the validation popup while typing
|
||||
this.handleFieldChange('lang', e);
|
||||
handleFieldChange('lang', e);
|
||||
}}
|
||||
options={listLanguages()}
|
||||
autoSuggest={{
|
||||
@@ -297,14 +275,11 @@ const MetadataEditor = createReactClass({
|
||||
filterOn : ['value', 'detail', 'title']
|
||||
}}
|
||||
/>
|
||||
<small>Sets the HTML Lang property for your brew. May affect hyphenation or spellcheck.</small>
|
||||
</div>
|
||||
|
||||
</div>;
|
||||
},
|
||||
|
||||
renderRenderOptions : function(){
|
||||
return <div className='field systems'>
|
||||
};
|
||||
const renderRenderOptions = ()=>{
|
||||
return <div className='field renderers'>
|
||||
<label>Renderer</label>
|
||||
<div className='value'>
|
||||
<label key='legacy'>
|
||||
@@ -312,8 +287,8 @@ const MetadataEditor = createReactClass({
|
||||
type='radio'
|
||||
value = 'legacy'
|
||||
name = 'renderer'
|
||||
checked={this.props.metadata.renderer === 'legacy'}
|
||||
onChange={(e)=>this.handleRenderer('legacy', e)} />
|
||||
checked={metadata.renderer === 'legacy'}
|
||||
onChange={(e)=>handleRenderer('legacy', e)} />
|
||||
Legacy
|
||||
</label>
|
||||
|
||||
@@ -322,92 +297,121 @@ const MetadataEditor = createReactClass({
|
||||
type='radio'
|
||||
value = 'V3'
|
||||
name = 'renderer'
|
||||
checked={this.props.metadata.renderer === 'V3'}
|
||||
onChange={(e)=>this.handleRenderer('V3', e)} />
|
||||
checked={metadata.renderer === 'V3'}
|
||||
onChange={(e)=>handleRenderer('V3', e)} />
|
||||
V3
|
||||
</label>
|
||||
<small><a href='/legacy' target='_blank' rel='noopener noreferrer'>Click here to see the demo page for the old Legacy renderer!</a></small>
|
||||
</div>
|
||||
</div>;
|
||||
},
|
||||
};
|
||||
|
||||
render : function(){
|
||||
return <div className='metadataEditor'>
|
||||
return (
|
||||
<div className='metadataEditor uiEditor'>
|
||||
<h1>Properties Editor</h1>
|
||||
|
||||
<div className='field title'>
|
||||
<label>title</label>
|
||||
<input type='text' className='value'
|
||||
defaultValue={this.props.metadata.title}
|
||||
onChange={(e)=>this.handleFieldChange('title', e)} />
|
||||
<label htmlFor='title_field'>title</label>
|
||||
<input
|
||||
type='text'
|
||||
id='title_field'
|
||||
className='value'
|
||||
defaultValue={metadata.title}
|
||||
onChange={(e)=>handleFieldChange('title', e)}
|
||||
/>
|
||||
</div>
|
||||
<div className='field-group'>
|
||||
<fieldset className='field-group'>
|
||||
<div className='field-column'>
|
||||
<div className='field description'>
|
||||
<label>description</label>
|
||||
<textarea defaultValue={this.props.metadata.description} className='value'
|
||||
onChange={(e)=>this.handleFieldChange('description', e)} />
|
||||
<label htmlFor='description_field'>description</label>
|
||||
<textarea
|
||||
id='description_field'
|
||||
defaultValue={metadata.description}
|
||||
className='value'
|
||||
onChange={(e)=>handleFieldChange('description', e)}
|
||||
/>
|
||||
</div>
|
||||
<div className='field thumbnail'>
|
||||
<label>thumbnail</label>
|
||||
<input type='text'
|
||||
defaultValue={this.props.metadata.thumbnail}
|
||||
<label htmlFor='thumbnail_field'>thumbnail</label>
|
||||
<input
|
||||
type='text'
|
||||
id='thumbnail_field'
|
||||
defaultValue={metadata.thumbnail}
|
||||
placeholder='https://my.thumbnail.url'
|
||||
className='value'
|
||||
onChange={(e)=>this.handleFieldChange('thumbnail', e)} />
|
||||
<button className='display' onClick={this.toggleThumbnailDisplay}>
|
||||
<i className={`fas fa-caret-${this.state.showThumbnail ? 'right' : 'left'}`} />
|
||||
onChange={(e)=>handleFieldChange('thumbnail', e)}
|
||||
/>
|
||||
<button
|
||||
className='display'
|
||||
onClick={()=>setShowThumbnail(!showThumbnail)}
|
||||
aria-label={`${showThumbnail ? 'hide thumbnail' : 'show thumbnail'}`}>
|
||||
<i className={`fas fa-caret-${showThumbnail ? 'right' : 'left'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{this.renderThumbnail()}
|
||||
</div>
|
||||
|
||||
<TagInput label='tags' valuePatterns={[/^(?:(?:group|meta|system|type):)?[A-Za-z0-9][A-Za-z0-9 \/.\-]{0,40}$/]}
|
||||
placeholder='add tag' unique={true}
|
||||
values={this.props.metadata.tags}
|
||||
onChange={(e)=>this.handleFieldChange('tags', e)}
|
||||
/>
|
||||
|
||||
<div className='field systems'>
|
||||
<label>systems</label>
|
||||
{showThumbnail && <img className='thumbnail-preview' src={metadata.thumbnail || homebreweryThumbnail}></img>}
|
||||
</fieldset>
|
||||
<div className='field tags'>
|
||||
<label htmlFor='combobox-tags'>Tags</label>
|
||||
<div className='value'>
|
||||
{this.renderSystems()}
|
||||
<TagInput
|
||||
id='combobox-tags'
|
||||
label='tags'
|
||||
valuePatterns={/^\s*(?:(?:group|meta|system|type)\s*:\s*)?[A-Za-z0-9][A-Za-z0-9 \/\\.&_\-]{0,40}\s*$/}
|
||||
placeholder='add tag'
|
||||
unique={true}
|
||||
values={metadata.tags}
|
||||
onChange={(e)=>handleFieldChange('tags', e)}
|
||||
tooltip='You may start tags with "type", "system", "group" or "meta" followed by a colon ":", these will be colored in your userpage.'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{this.renderLanguageDropdown()}
|
||||
|
||||
{this.renderThemeDropdown()}
|
||||
|
||||
{this.renderRenderOptions()}
|
||||
{renderLanguageDropdown()}
|
||||
{renderThemeDropdown()}
|
||||
{renderRenderOptions()}
|
||||
|
||||
<h2>Authors</h2>
|
||||
|
||||
{this.renderAuthors()}
|
||||
|
||||
<TagInput label='invited authors' valuePatterns={[/.+/]}
|
||||
validators={[(v)=>!this.props.metadata.authors?.includes(v)]}
|
||||
placeholder='invite author' unique={true}
|
||||
values={this.props.metadata.invitedAuthors}
|
||||
notes={['Invited author usernames are case sensitive.', 'After adding an invited author, send them the edit link. There, they can choose to accept or decline the invitation.']}
|
||||
onChange={(e)=>this.handleFieldChange('invitedAuthors', e)}
|
||||
/>
|
||||
|
||||
<h2>Privacy</h2>
|
||||
|
||||
<div className='field publish'>
|
||||
<label>publish</label>
|
||||
{renderAuthors()}
|
||||
<div className='field invitedAuthors'>
|
||||
<label htmlFor='combobox-invited-authors'>Invited authors</label>
|
||||
<div className='value'>
|
||||
{this.renderPublish()}
|
||||
<small>Published brews are searchable in <a href='/vault'>the Vault</a> and visible on your user page. Unpublished brews are not indexed in the Vault or visible on your user page, but can still be shared and indexed by search engines. You can unpublish a brew any time.</small>
|
||||
<TagInput
|
||||
id='combobox-invited-authors'
|
||||
label='invited authors'
|
||||
valuePatterns={/.+/}
|
||||
validators={[(v)=>!metadata.authors?.includes(v)]}
|
||||
placeholder='invite author'
|
||||
unique={true}
|
||||
tooltip={`Invited author usernames are case sensitive.
|
||||
After adding an invited author, send them the edit link. There, they can choose to accept or decline the invitation.`}
|
||||
values={metadata.invitedAuthors}
|
||||
onChange={(e)=>handleFieldChange('invitedAuthors', e)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{this.renderDelete()}
|
||||
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
<h2>Privacy</h2>
|
||||
<div className='field publish'>
|
||||
<label htmlFor='publish-button'>publish</label>
|
||||
<div className='value'>
|
||||
{renderPublish()}
|
||||
<small>
|
||||
Published brews are searchable in <a href='/vault'>the Vault</a> and visible on your user page. Unpublished brews are not
|
||||
indexed in the Vault or visible on your user page, but can still be shared and indexed by search engines. You can unpublish a
|
||||
brew any time.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{metadata.editId && <div className='field delete'>
|
||||
<label htmlFor='delete-button'>delete</label>
|
||||
<div className='value'>
|
||||
<button id='delete-button' onClick={handleDelete}>
|
||||
<i className='fas fa-trash-alt' /> delete brew
|
||||
</button>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetadataEditor;
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
@import 'naturalcrit/styles/colors.less';
|
||||
|
||||
.userThemeName {
|
||||
padding-right : 10px;
|
||||
padding-left : 10px;
|
||||
}
|
||||
|
||||
.metadataEditor {
|
||||
position : absolute;
|
||||
box-sizing : border-box;
|
||||
width : 100%;
|
||||
height : calc(100vh - 54px); // 54px is the height of the navbar + snippet bar. probably a better way to dynamic get this.
|
||||
padding : 25px;
|
||||
overflow-y : auto;
|
||||
font-size : 13px;
|
||||
background-color : #999999;
|
||||
|
||||
h1 {
|
||||
margin : 0 0 40px;
|
||||
font-weight : bold;
|
||||
text-transform : uppercase;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin : 20px 0;
|
||||
font-weight : bold;
|
||||
color : #555555;
|
||||
border-bottom : 2px solid gray;
|
||||
}
|
||||
|
||||
& > div { margin-bottom : 10px; }
|
||||
|
||||
.field-group {
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
gap : 10px;
|
||||
width : 100%;
|
||||
}
|
||||
|
||||
.field-column {
|
||||
display : flex;
|
||||
flex : 5 0 200px;
|
||||
flex-direction : column;
|
||||
gap : 10px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.field {
|
||||
position : relative;
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
width : 100%;
|
||||
min-width : 200px;
|
||||
& > label {
|
||||
width : 80px;
|
||||
font-size : 0.9em;
|
||||
font-weight : 800;
|
||||
line-height : 1.8em;
|
||||
text-transform : uppercase;
|
||||
}
|
||||
& > .value {
|
||||
flex : 1 1 auto;
|
||||
width : 50px;
|
||||
&:invalid { background : #FFB9B9; }
|
||||
small {
|
||||
display : block;
|
||||
font-size : 0.9em;
|
||||
font-style : italic;
|
||||
line-height : 1.4em;
|
||||
}
|
||||
}
|
||||
input[type='text'], textarea {
|
||||
border : 1px solid gray;
|
||||
&:focus { outline : 1px solid #444444; }
|
||||
}
|
||||
&.thumbnail, &.themes {
|
||||
label { line-height : 2.0em; }
|
||||
.value {
|
||||
overflow : hidden;
|
||||
text-overflow : ellipsis;
|
||||
}
|
||||
button {
|
||||
.colorButton();
|
||||
padding : 0px 5px;
|
||||
color : white;
|
||||
background-color : black;
|
||||
border : 1px solid #999999;
|
||||
&:hover { background-color : #777777; }
|
||||
}
|
||||
}
|
||||
|
||||
&.themes {
|
||||
.value {
|
||||
overflow : visible;
|
||||
text-overflow : auto;
|
||||
}
|
||||
button {
|
||||
padding-right : 5px;
|
||||
padding-left : 5px;
|
||||
}
|
||||
}
|
||||
|
||||
&.description {
|
||||
flex : 1;
|
||||
textarea.value {
|
||||
height : auto;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
resize : none;
|
||||
}
|
||||
}
|
||||
|
||||
&.language .language-dropdown {
|
||||
z-index : 200;
|
||||
max-width : 150px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.thumbnail-preview {
|
||||
position : relative;
|
||||
flex : 1 1;
|
||||
justify-self : center;
|
||||
width : 80px;
|
||||
height : min-content;
|
||||
max-height : 115px;
|
||||
aspect-ratio : 1 / 1;
|
||||
object-fit : contain;
|
||||
background-color : #AAAAAA;
|
||||
}
|
||||
|
||||
.systems.field .value {
|
||||
label {
|
||||
display : inline-flex;
|
||||
align-items : center;
|
||||
margin-right : 15px;
|
||||
font-size : 0.9em;
|
||||
font-weight : 800;
|
||||
vertical-align : middle;
|
||||
white-space : nowrap;
|
||||
cursor : pointer;
|
||||
user-select : none;
|
||||
}
|
||||
input {
|
||||
margin : 3px;
|
||||
vertical-align : middle;
|
||||
cursor : pointer;
|
||||
}
|
||||
}
|
||||
.publish.field .value {
|
||||
position : relative;
|
||||
margin-bottom : 15px;
|
||||
button { width : 100%; }
|
||||
button.publish {
|
||||
.colorButton(@blueLight);
|
||||
}
|
||||
button.unpublish {
|
||||
.colorButton(@silver);
|
||||
}
|
||||
}
|
||||
|
||||
.delete.field .value {
|
||||
button {
|
||||
.colorButton(@red);
|
||||
}
|
||||
}
|
||||
.authors.field .value { line-height : 1.5em; }
|
||||
|
||||
.themes.field {
|
||||
& .dropdown-container {
|
||||
position : relative;
|
||||
z-index : 100;
|
||||
background-color : white;
|
||||
}
|
||||
& .dropdown-options { overflow-y : visible; }
|
||||
.disabled {
|
||||
font-style : italic;
|
||||
color : dimgray;
|
||||
background-color : darkgray;
|
||||
}
|
||||
.item {
|
||||
position : relative;
|
||||
padding : 3px 3px;
|
||||
overflow : visible;
|
||||
background-color : white;
|
||||
border-top : 1px solid rgb(118, 118, 118);
|
||||
.preview {
|
||||
position : absolute;
|
||||
top : 0;
|
||||
right : 0;
|
||||
z-index : 1;
|
||||
display : flex;
|
||||
flex-direction : column;
|
||||
width : 200px;
|
||||
overflow : hidden;
|
||||
color : black;
|
||||
background : #CCCCCC;
|
||||
border-radius : 5px;
|
||||
box-shadow : 0 0 5px black;
|
||||
opacity : 0;
|
||||
transition : opacity 250ms ease;
|
||||
h6 {
|
||||
padding-block : 0.5em;
|
||||
padding-inline : 1em;
|
||||
font-weight : 900;
|
||||
border-bottom : 2px solid hsl(0,0%,40%);
|
||||
}
|
||||
}
|
||||
|
||||
.texture-container {
|
||||
position : absolute;
|
||||
top : 0;
|
||||
left : 0;
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
min-height : 100%;
|
||||
overflow : hidden;
|
||||
> img {
|
||||
position : absolute;
|
||||
top : 0;
|
||||
right : 0;
|
||||
width : 50%;
|
||||
min-height : 100%;
|
||||
-webkit-mask-image : linear-gradient(90deg, transparent, black 20%);
|
||||
mask-image : linear-gradient(90deg, transparent, black 20%);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color : white;
|
||||
background-color : @blue;
|
||||
filter : unset;
|
||||
}
|
||||
&:hover > .preview { opacity : 1; }
|
||||
}
|
||||
}
|
||||
|
||||
.field .list {
|
||||
display : flex;
|
||||
flex : 1 0;
|
||||
flex-wrap : wrap;
|
||||
|
||||
> * { flex : 0 0 auto; }
|
||||
|
||||
#groupedIcon {
|
||||
#backgroundColors;
|
||||
position : relative;
|
||||
top : -0.3em;
|
||||
right : -0.3em;
|
||||
display : inline-block;
|
||||
min-width : 20px;
|
||||
height : ~'calc(100% + 0.6em)';
|
||||
color : white;
|
||||
text-align : center;
|
||||
cursor : pointer;
|
||||
|
||||
i {
|
||||
position : relative;
|
||||
top : 50%;
|
||||
transform : translateY(-50%);
|
||||
}
|
||||
|
||||
&:not(:last-child) { border-right : 1px solid black; }
|
||||
|
||||
&:last-child { border-radius : 0 0.5em 0.5em 0; }
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding : 0.3em;
|
||||
margin : 2px;
|
||||
font-size : 0.9em;
|
||||
background-color : #DDDDDD;
|
||||
border-radius : 0.5em;
|
||||
|
||||
.icon { #groupedIcon; }
|
||||
}
|
||||
|
||||
.input-group {
|
||||
height : ~'calc(.9em + 4px + .6em)';
|
||||
|
||||
input { border-radius : 0.5em 0 0 0.5em; }
|
||||
|
||||
input:last-child { border-radius : 0.5em; }
|
||||
|
||||
.value {
|
||||
width : 7.5vw;
|
||||
min-width : 75px;
|
||||
height : 100%;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
height : ~'calc(.9em + 4px + .6em)';
|
||||
|
||||
input { border-radius : 0.5em 0 0 0.5em; }
|
||||
|
||||
input:last-child { border-radius : 0.5em; }
|
||||
|
||||
.value {
|
||||
width : 7.5vw;
|
||||
min-width : 75px;
|
||||
height : 100%;
|
||||
}
|
||||
|
||||
.invalid:focus { background-color : pink; }
|
||||
|
||||
.icon {
|
||||
#groupedIcon;
|
||||
top : -0.54em;
|
||||
right : 1px;
|
||||
height : 97%;
|
||||
|
||||
i { font-size : 1.125em; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import '../uiEditor.less';
|
||||
import React from 'react';
|
||||
|
||||
const SettingsEditor = ({ settings, updateSettings = ()=>{}, EditorThemeNameList })=>{
|
||||
|
||||
const validations = {};
|
||||
|
||||
const handleFieldChange = (setting, e)=>{
|
||||
const value =
|
||||
e.target.type === 'checkbox'
|
||||
? e.target.checked
|
||||
: e.target.value;
|
||||
|
||||
const inputRules = validations[setting] ?? [];
|
||||
|
||||
const validationErrors = inputRules
|
||||
.map((rule)=>rule(value))
|
||||
.filter(Boolean);
|
||||
|
||||
if(validationErrors.length > 0) {
|
||||
e.target.setCustomValidity(validationErrors.join('\n'));
|
||||
e.target.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
e.target.setCustomValidity('');
|
||||
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
[setting] : e.target.type === 'number'
|
||||
? Number(value)
|
||||
: value,
|
||||
};
|
||||
|
||||
updateSettings(updatedSettings);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='settingsEditor uiEditor'>
|
||||
<h1>Editor Settings</h1>
|
||||
|
||||
<div className='field'>
|
||||
<label htmlFor='changeEditorTheme'>
|
||||
Select your Editor Theme
|
||||
</label>
|
||||
<div className='value'>
|
||||
<select id='changeEditorTheme' value={settings.editorTheme} onChange={(e)=>handleFieldChange('editorTheme', e)} >
|
||||
{EditorThemeNameList.map((theme, key)=>{
|
||||
return <option key={key} value={theme}>{theme}</option>;
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='field'>
|
||||
<label htmlFor='autoCloseBrackets'>
|
||||
Automatically close brackets
|
||||
</label>
|
||||
<div className='value'>
|
||||
<input
|
||||
id='autoCloseBrackets'
|
||||
type='checkbox'
|
||||
name='autoCloseBrackets'
|
||||
checked={settings.autoCloseBrackets}
|
||||
onChange={(e)=>handleFieldChange('autoCloseBrackets', e)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='field'>
|
||||
<label htmlFor='showImagePreviews'>
|
||||
Show Image Previews when hovering a link
|
||||
</label>
|
||||
<div className='value'>
|
||||
<input
|
||||
id='showImagePreviews'
|
||||
type='checkbox'
|
||||
name='showImagePreviews'
|
||||
checked={settings.showImagePreviews}
|
||||
onChange={(e)=>handleFieldChange('showImagePreviews', e)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='field'>
|
||||
<label htmlFor='activeLineShading'>
|
||||
Background shading of active line
|
||||
</label>
|
||||
<div className='value'>
|
||||
<input
|
||||
id='activeLineShading'
|
||||
type='checkbox'
|
||||
name='activeLineShading'
|
||||
checked={settings.activeLineShading}
|
||||
onChange={(e)=>handleFieldChange('activeLineShading', e)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className='field'>
|
||||
<label htmlFor='lineNumbers'>Show Line Numbers</label>
|
||||
<div className='value'>
|
||||
<input
|
||||
id='lineNumbers'
|
||||
type='checkbox'
|
||||
name='lineNumbers'
|
||||
checked={settings.lineNumbers}
|
||||
onChange={(e)=>handleFieldChange('lineNumbers', e)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='field'>
|
||||
<label htmlFor='fontSize'>
|
||||
Editor Font Size
|
||||
</label>
|
||||
|
||||
<div className='value'>
|
||||
<small style={{ fontSize: `${settings.fontSize || 1}em` }}>from 7px to 26px</small>
|
||||
<input
|
||||
id='fontSize'
|
||||
type='range'
|
||||
min={.5}
|
||||
step={.1}
|
||||
max={2}
|
||||
list='font-sizes'
|
||||
name='fontSize'
|
||||
title={`${Math.round(settings.fontSize * 13)}px`}
|
||||
value={settings.fontSize || 1}
|
||||
onChange={(e)=>handleFieldChange('fontSize', e)}
|
||||
/>
|
||||
<datalist id='font-sizes'>
|
||||
<option value='1' />
|
||||
</datalist>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsEditor;
|
||||
@@ -1,19 +1,20 @@
|
||||
/*eslint max-lines: ["warn", {"max": 350, "skipBlankLines": true, "skipComments": true}]*/
|
||||
import './snippetbar.less';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Dropdown } from '@components/dropdown/dropdown.jsx';
|
||||
|
||||
import _ from 'lodash';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { loadHistory } from '../../utils/versionHistory.js';
|
||||
import { brewSnippetsToJSON } from '../../../../shared/helpers.js';
|
||||
import { brewSnippetsToJSON } from '@shared/helpers.js';
|
||||
|
||||
import Legacy5ePHB from 'themes/Legacy/5ePHB/snippets.js';
|
||||
import V3_5ePHB from 'themes/V3/5ePHB/snippets.js';
|
||||
import V3_5eDMG from 'themes/V3/5eDMG/snippets.js';
|
||||
import V3_Journal from 'themes/V3/Journal/snippets.js';
|
||||
import V3_Blank from 'themes/V3/Blank/snippets.js';
|
||||
/*eslint-disable camelcase*/
|
||||
import Legacy5ePHB from '@themes/Legacy/5ePHB/snippets.js';
|
||||
import V3_5ePHB from '@themes/V3/5ePHB/snippets.js';
|
||||
import V3_5eDMG from '@themes/V3/5eDMG/snippets.js';
|
||||
import V3_Journal from '@themes/V3/Journal/snippets.js';
|
||||
import V3_Blank from '@themes/V3/Blank/snippets.js';
|
||||
|
||||
const ThemeSnippets = {
|
||||
Legacy_5ePHB : Legacy5ePHB,
|
||||
@@ -22,316 +23,273 @@ const ThemeSnippets = {
|
||||
V3_Journal : V3_Journal,
|
||||
V3_Blank : V3_Blank,
|
||||
};
|
||||
|
||||
import EditorThemes from 'build/homebrew/codeMirror/editorThemes.json';
|
||||
/*eslint-enable camelcase */
|
||||
|
||||
const execute = function(val, props){
|
||||
if(_.isFunction(val)) return val(props);
|
||||
return val;
|
||||
};
|
||||
|
||||
const Snippetbar = createReactClass({
|
||||
displayName : 'SnippetBar',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
brew : {},
|
||||
view : 'text',
|
||||
onViewChange : ()=>{},
|
||||
onInject : ()=>{},
|
||||
onToggle : ()=>{},
|
||||
showEditButtons : true,
|
||||
renderer : 'legacy',
|
||||
undo : ()=>{},
|
||||
redo : ()=>{},
|
||||
historySize : ()=>{},
|
||||
foldCode : ()=>{},
|
||||
unfoldCode : ()=>{},
|
||||
updateEditorTheme : ()=>{},
|
||||
cursorPos : {},
|
||||
themeBundle : [],
|
||||
updateBrew : ()=>{}
|
||||
};
|
||||
},
|
||||
const SnippetBar = ({
|
||||
brew = {},
|
||||
view = 'text',
|
||||
onViewChange = ()=>{},
|
||||
onInject = ()=>{},
|
||||
onToggle = ()=>{},
|
||||
showEditButtons = true,
|
||||
renderer = 'legacy',
|
||||
undo = ()=>{},
|
||||
redo = ()=>{},
|
||||
historySize = ()=>{},
|
||||
foldCode = ()=>{},
|
||||
unfoldCode = ()=>{},
|
||||
formatCode = ()=>{},
|
||||
cursorPos = {},
|
||||
themeBundle = [],
|
||||
updateBrew = ()=>{}
|
||||
})=>{
|
||||
const [snippets, setSnippets] = useState([]);
|
||||
const [historyExists, setHistoryExists] = useState(false);
|
||||
const [historyItems, setHistoryItems] = useState([]);
|
||||
|
||||
getInitialState : function() {
|
||||
return {
|
||||
renderer : this.props.renderer,
|
||||
themeSelector : false,
|
||||
snippets : [],
|
||||
showHistory : false,
|
||||
historyExists : false,
|
||||
historyItems : []
|
||||
};
|
||||
},
|
||||
useEffect(()=>{
|
||||
setSnippets(compileSnippets());
|
||||
}, []);
|
||||
|
||||
componentDidMount : async function(prevState) {
|
||||
const snippets = this.compileSnippets();
|
||||
this.setState({
|
||||
snippets : snippets
|
||||
});
|
||||
},
|
||||
|
||||
componentDidUpdate : async function(prevProps, prevState) {
|
||||
if(prevProps.renderer != this.props.renderer ||
|
||||
prevProps.theme != this.props.theme ||
|
||||
prevProps.themeBundle != this.props.themeBundle ||
|
||||
prevProps.brew.snippets != this.props.brew.snippets) {
|
||||
this.setState({
|
||||
snippets : this.compileSnippets()
|
||||
});
|
||||
};
|
||||
|
||||
// Update history list if it has changed
|
||||
const checkHistoryItems = await loadHistory(this.props.brew);
|
||||
|
||||
// If all items have the noData property, there is no saved data
|
||||
const checkHistoryExists = !checkHistoryItems.every((historyItem)=>{
|
||||
return historyItem?.noData;
|
||||
});
|
||||
if(prevState.historyExists != checkHistoryExists){
|
||||
this.setState({
|
||||
historyExists : checkHistoryExists
|
||||
});
|
||||
}
|
||||
|
||||
// If any history items have changed, update the list
|
||||
if(checkHistoryExists && checkHistoryItems.some((historyItem, index)=>{
|
||||
return index >= prevState.historyItems.length || !_.isEqual(historyItem, prevState.historyItems[index]);
|
||||
})){
|
||||
this.setState({
|
||||
historyItems : checkHistoryItems
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
mergeCustomizer : function(oldValue, newValue, key) {
|
||||
if(key == 'snippets') {
|
||||
const mergeCustomizer = (oldValue, newValue, key)=>{
|
||||
if(key == 'snippets') {
|
||||
const result = _.reverse(_.unionBy(_.reverse(newValue), _.reverse(oldValue), 'name')); // Join snippets together, with preference for the child theme over the parent theme
|
||||
return result.filter((snip)=>snip.gen || snip.subsnippets);
|
||||
};
|
||||
},
|
||||
|
||||
compileSnippets : function() {
|
||||
};
|
||||
const compileSnippets = ()=>{
|
||||
let compiledSnippets = [];
|
||||
|
||||
let oldSnippets = _.keyBy(compiledSnippets, 'groupName');
|
||||
|
||||
if(this.props.themeBundle.snippets) {
|
||||
for (let snippets of this.props.themeBundle.snippets) {
|
||||
if(themeBundle.snippets) {
|
||||
for (let snippets of themeBundle.snippets) {
|
||||
if(typeof(snippets) == 'string') // load staticThemes as needed; they were sent as just a file name
|
||||
snippets = ThemeSnippets[snippets];
|
||||
|
||||
const newSnippets = _.keyBy(_.cloneDeep(snippets), 'groupName');
|
||||
compiledSnippets = _.values(_.mergeWith(oldSnippets, newSnippets, this.mergeCustomizer));
|
||||
compiledSnippets = _.values(_.mergeWith(oldSnippets, newSnippets, mergeCustomizer));
|
||||
|
||||
oldSnippets = _.keyBy(compiledSnippets, 'groupName');
|
||||
}
|
||||
}
|
||||
|
||||
const userSnippetsasJSON = brewSnippetsToJSON(this.props.brew.title || 'New Document', this.props.brew.snippets, this.props.themeBundle.snippets);
|
||||
const userSnippetsasJSON = brewSnippetsToJSON(brew.title || 'New Document', brew.snippets, themeBundle.snippets);
|
||||
compiledSnippets.push(userSnippetsasJSON);
|
||||
|
||||
return compiledSnippets;
|
||||
},
|
||||
};
|
||||
|
||||
handleSnippetClick : function(injectedText){
|
||||
this.props.onInject(injectedText);
|
||||
},
|
||||
useEffect(()=>{
|
||||
setSnippets(compileSnippets());
|
||||
}, [renderer, themeBundle, brew.snippets]);
|
||||
|
||||
toggleThemeSelector : function(e){
|
||||
if(e.target.tagName != 'SELECT'){
|
||||
this.setState({
|
||||
themeSelector : !this.state.themeSelector
|
||||
});
|
||||
}
|
||||
},
|
||||
useEffect(()=>{
|
||||
const updateHistory = async ()=>{
|
||||
// Update history list if it has changed
|
||||
const checkHistoryItems = await loadHistory(brew);
|
||||
|
||||
changeTheme : function(e){
|
||||
if(e.target.value == this.props.currentEditorTheme) return;
|
||||
this.props.updateEditorTheme(e.target.value);
|
||||
// If all items have the noData property, there is no saved data
|
||||
const checkHistoryExists = !checkHistoryItems.every(
|
||||
(historyItem)=>historyItem?.noData
|
||||
);
|
||||
|
||||
this.setState({
|
||||
showThemeSelector : false,
|
||||
});
|
||||
},
|
||||
if(historyExists !== checkHistoryExists) {
|
||||
setHistoryExists(checkHistoryExists);
|
||||
}
|
||||
|
||||
renderThemeSelector : function(){
|
||||
return <div className='themeSelector'>
|
||||
<select value={this.props.currentEditorTheme} onChange={this.changeTheme} >
|
||||
{EditorThemes.map((theme, key)=>{
|
||||
return <option key={key} value={theme}>{theme}</option>;
|
||||
})}
|
||||
</select>
|
||||
</div>;
|
||||
},
|
||||
// If any history items have changed, update the list
|
||||
if(!_.isEqual(checkHistoryItems, historyItems)) {
|
||||
setHistoryItems(checkHistoryItems);
|
||||
}
|
||||
};
|
||||
|
||||
renderSnippetGroups : function(){
|
||||
const snippets = this.state.snippets.filter((snippetGroup)=>snippetGroup.view === this.props.view);
|
||||
if(snippets.length === 0) return null;
|
||||
updateHistory();
|
||||
}, [brew]);
|
||||
|
||||
return <div className='snippets'>
|
||||
{_.map(snippets, (snippetGroup)=>{
|
||||
const handleSnippetClick = (injectedText)=>onInject(injectedText);
|
||||
|
||||
const renderSnippetGroups = ()=>{
|
||||
const currentSnippets = snippets.filter((snippetGroup)=>snippetGroup.view === view);
|
||||
if(currentSnippets.length === 0) return null;
|
||||
|
||||
return <ul className='snippets' role='menubar' aria-label='Snippets Menubar'>
|
||||
{_.map(currentSnippets, (snippetGroup)=>{
|
||||
return <SnippetGroup
|
||||
brew={this.props.brew}
|
||||
brew={brew}
|
||||
groupName={snippetGroup.groupName}
|
||||
icon={snippetGroup.icon}
|
||||
snippets={snippetGroup.snippets}
|
||||
key={snippetGroup.groupName}
|
||||
onSnippetClick={this.handleSnippetClick}
|
||||
cursorPos={this.props.cursorPos}
|
||||
onSnippetClick={handleSnippetClick}
|
||||
cursorPos={cursorPos}
|
||||
/>;
|
||||
})
|
||||
}
|
||||
</div>;
|
||||
},
|
||||
</ul>;
|
||||
};
|
||||
|
||||
replaceContent : function(item){
|
||||
return this.props.updateBrew(item);
|
||||
},
|
||||
const replaceContent = (item)=>{
|
||||
return updateBrew(item);
|
||||
};
|
||||
|
||||
toggleHistoryMenu : function(){
|
||||
this.setState({
|
||||
showHistory : !this.state.showHistory
|
||||
});
|
||||
},
|
||||
const renderHistoryMenu = ()=>{
|
||||
return (
|
||||
<Dropdown
|
||||
groupName='History'
|
||||
icon='fas fa-clock-rotate-left'
|
||||
className='history'
|
||||
customTrigger={<><i className='fas fa-clock-rotate-left' /></>}
|
||||
disabled={!historyExists}
|
||||
>
|
||||
{historyExists && _.map(historyItems, (item, index)=>{
|
||||
if(item.noData || !item.savedAt) return null;
|
||||
|
||||
renderHistoryItems : function() {
|
||||
if(!this.state.historyExists) return;
|
||||
const saveTime = new Date(item.savedAt);
|
||||
const diffMs = new Date() - saveTime;
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
|
||||
return <div className='dropdown'>
|
||||
{_.map(this.state.historyItems, (item, index)=>{
|
||||
if(item.noData || !item.savedAt) return;
|
||||
let diffString = `about ${diffSecs} seconds ago`;
|
||||
|
||||
const saveTime = new Date(item.savedAt);
|
||||
const diffMs = new Date() - saveTime;
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
if(diffSecs > 60)
|
||||
diffString = `about ${Math.floor(diffSecs / 60)} minutes ago`;
|
||||
if(diffSecs > (60 * 60))
|
||||
diffString = `about ${Math.floor(diffSecs / (60 * 60))} hours ago`;
|
||||
if(diffSecs > (24 * 60 * 60))
|
||||
diffString = `about ${Math.floor(diffSecs / (24 * 60 * 60))} days ago`;
|
||||
if(diffSecs > (7 * 24 * 60 * 60))
|
||||
diffString = `about ${Math.floor(diffSecs / (7 * 24 * 60 * 60))} weeks ago`;
|
||||
|
||||
let diffString = `about ${diffSecs} seconds ago`;
|
||||
return (
|
||||
<li key={index} role='none'>
|
||||
<button
|
||||
className='menu-item'
|
||||
onClick={()=>replaceContent(item)}
|
||||
role='menuitem'
|
||||
>
|
||||
<i className={`fas fa-${index + 1}`} />
|
||||
<span
|
||||
className='name'
|
||||
title={saveTime.toISOString()}
|
||||
>
|
||||
v{item.version} : {diffString}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
if(diffSecs > 60) diffString = `about ${Math.floor(diffSecs / 60)} minutes ago`;
|
||||
if(diffSecs > (60 * 60)) diffString = `about ${Math.floor(diffSecs / (60 * 60))} hours ago`;
|
||||
if(diffSecs > (24 * 60 * 60)) diffString = `about ${Math.floor(diffSecs / (24 * 60 * 60))} days ago`;
|
||||
if(diffSecs > (7 * 24 * 60 * 60)) diffString = `about ${Math.floor(diffSecs / (7 * 24 * 60 * 60))} weeks ago`;
|
||||
|
||||
return <div className='snippet' key={index} onClick={()=>{this.replaceContent(item);}} >
|
||||
<i className={`fas fa-${index+1}`} />
|
||||
<span className='name' title={saveTime.toISOString()}>v{item.version} : {diffString}</span>
|
||||
</div>;
|
||||
})}
|
||||
</div>;
|
||||
},
|
||||
|
||||
renderEditorButtons : function(){
|
||||
if(!this.props.showEditButtons) return;
|
||||
const renderEditorButtons =()=>{
|
||||
if(!showEditButtons) return;
|
||||
|
||||
return (
|
||||
<div className='editors'>
|
||||
{this.props.view !== 'meta' && <><div className='historyTools'>
|
||||
<div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`}
|
||||
onClick={this.toggleHistoryMenu} >
|
||||
<i className='fas fa-clock-rotate-left' />
|
||||
{ this.state.showHistory && this.renderHistoryItems() }
|
||||
</div>
|
||||
<div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`}
|
||||
onClick={this.props.undo} >
|
||||
{view !== 'meta' && view !== 'settings' && <><div className='historyTools'>
|
||||
{renderHistoryMenu()}
|
||||
<button className={`editorTool undo ${historySize.done ? 'active' : ''}`}
|
||||
onClick={undo} >
|
||||
<i className='fas fa-undo' />
|
||||
</div>
|
||||
<div className={`editorTool redo ${this.props.historySize.redo ? 'active' : ''}`}
|
||||
onClick={this.props.redo} >
|
||||
</button>
|
||||
<button className={`editorTool redo ${historySize.undone ? 'active' : ''}`}
|
||||
onClick={redo} >
|
||||
<i className='fas fa-redo' />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className='codeTools'>
|
||||
<div className={`editorTool foldAll ${this.props.foldCode ? 'active' : ''}`}
|
||||
onClick={this.props.foldCode} >
|
||||
<button className={`editorTool foldAll ${foldCode ? 'active' : ''}`}
|
||||
onClick={foldCode} >
|
||||
<i className='fas fa-compress-alt' />
|
||||
</div>
|
||||
<div className={`editorTool unfoldAll ${this.props.unfoldCode ? 'active' : ''}`}
|
||||
onClick={this.props.unfoldCode} >
|
||||
</button>
|
||||
<button className={`editorTool unfoldAll ${unfoldCode ? 'active' : ''}`}
|
||||
onClick={unfoldCode} >
|
||||
<i className='fas fa-expand-alt' />
|
||||
</div>
|
||||
<div className={`editorTheme ${this.state.themeSelector ? 'active' : ''}`}
|
||||
onClick={this.toggleThemeSelector} >
|
||||
<i className='fas fa-palette' />
|
||||
{this.state.themeSelector && this.renderThemeSelector()}
|
||||
</div>
|
||||
</button>
|
||||
<button className={`editorTool formatCode ${formatCode ? 'active' : ''}`}
|
||||
onClick={formatCode} >
|
||||
<i className='fas fa-wand-magic-sparkles' />
|
||||
</button>
|
||||
</div></>}
|
||||
|
||||
<div className='tabs'>
|
||||
<div className={cx('text', { selected: this.props.view === 'text' })}
|
||||
onClick={()=>this.props.onViewChange('text')}>
|
||||
<button className={cx('text', { selected: view === 'text' })}
|
||||
onClick={()=>onViewChange('text')}>
|
||||
<i className='fa fa-beer' />
|
||||
</div>
|
||||
<div className={cx('style', { selected: this.props.view === 'style' })}
|
||||
onClick={()=>this.props.onViewChange('style')}>
|
||||
</button>
|
||||
<button className={cx('style', { selected: view === 'style' })}
|
||||
onClick={()=>onViewChange('style')}>
|
||||
<i className='fa fa-paint-brush' />
|
||||
</div>
|
||||
<div className={cx('snippet', { selected: this.props.view === 'snippet' })}
|
||||
onClick={()=>this.props.onViewChange('snippet')}>
|
||||
</button>
|
||||
<button className={cx('snippet', { selected: view === 'snippet' })}
|
||||
onClick={()=>onViewChange('snippet')}>
|
||||
<i className='fas fa-th-list' />
|
||||
</div>
|
||||
<div className={cx('meta', { selected: this.props.view === 'meta' })}
|
||||
onClick={()=>this.props.onViewChange('meta')}>
|
||||
</button>
|
||||
<button className={cx('meta', { selected: view === 'meta' })}
|
||||
onClick={()=>onViewChange('meta')}>
|
||||
<i className='fas fa-info-circle' />
|
||||
</div>
|
||||
</button>
|
||||
<button className={cx('settings', { selected: view === 'settings' })}
|
||||
onClick={()=>onViewChange('settings')}>
|
||||
<i className='fas fa-gear' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
render : function(){
|
||||
return <div className='snippetBar'>
|
||||
{this.renderSnippetGroups()}
|
||||
{this.renderEditorButtons()}
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
return <div className='snippetBar'>
|
||||
{renderSnippetGroups()}
|
||||
{renderEditorButtons()}
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default Snippetbar;
|
||||
export default SnippetBar;
|
||||
|
||||
const SnippetGroup = createReactClass({
|
||||
displayName : 'SnippetGroup',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
brew : {},
|
||||
groupName : '',
|
||||
icon : 'fas fa-rocket',
|
||||
snippets : [],
|
||||
onSnippetClick : function(){},
|
||||
};
|
||||
},
|
||||
handleSnippetClick : function(e, snippet){
|
||||
e.stopPropagation();
|
||||
this.props.onSnippetClick(execute(snippet.gen, this.props));
|
||||
},
|
||||
renderSnippets : function(snippets){
|
||||
const SnippetGroup = (props)=>{
|
||||
const {
|
||||
brew = {},
|
||||
groupName = '',
|
||||
icon = 'fas fa-rocket',
|
||||
snippets = [],
|
||||
onSnippetClick = function(){},
|
||||
} = props;
|
||||
|
||||
const handleSnippetClick = (e, snippet)=>{
|
||||
onSnippetClick(execute(snippet.gen, props));
|
||||
};
|
||||
const renderSnippets = (snippets)=>{
|
||||
return _.map(snippets, (snippet)=>{
|
||||
return <div className='snippet' key={snippet.name} onClick={(e)=>this.handleSnippetClick(e, snippet)}>
|
||||
<i className={snippet.icon} />
|
||||
<span className={`name${snippet.disabled ? ' disabled' : ''}`} title={snippet.name}>{snippet.name}</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.subsnippets && <>
|
||||
<i className='fas fa-caret-right'></i>
|
||||
<div className='dropdown side'>
|
||||
{this.renderSnippets(snippet.subsnippets)}
|
||||
</div></>}
|
||||
</div>;
|
||||
if(!snippet.subsnippets){
|
||||
return (
|
||||
<li key={snippet.name} role='none'>
|
||||
<button className='menu-item' onClick={(e)=>handleSnippetClick(e, snippet)} role='menuitem' aria-label={snippet.name} disabled={snippet.disabled}>
|
||||
<i className={snippet.icon} />
|
||||
<span className={`name${snippet.disabled ? ' disabled' : ''}`} title={snippet.name}>{snippet.name}</span>
|
||||
{snippet.experimental && <span className='status'>beta</span>}
|
||||
{snippet.disabled && <span className='status' title='temporarily disabled due to large slowdown; under re-design'>disabled</span>}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
} else if(snippet.subsnippets){
|
||||
return (
|
||||
<Dropdown groupName={snippet.name} icon={snippet.icon} key={snippet.name}>
|
||||
{renderSnippets(snippet.subsnippets)}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
render : function(){
|
||||
const snippetGroup = `snippetGroup snippetBarButton ${this.props.snippets.length === 0 ? 'disabledSnippets' : ''}`;
|
||||
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)}
|
||||
</div>
|
||||
</div>;
|
||||
},
|
||||
});
|
||||
return <Dropdown groupName={groupName} id={groupName} icon={icon}>
|
||||
{renderSnippets(snippets)}
|
||||
</Dropdown>;
|
||||
};
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
@import (less) './client/icons/customIcons.less';
|
||||
@import (less) '././././themes/fonts/5e/fonts.less';
|
||||
@import '@sharedStyles/core.less';
|
||||
@import (less) '../../../icons/customIcons.less';
|
||||
@import (less) '@themes/fonts/5e/fonts.less';
|
||||
|
||||
.snippetBar {
|
||||
--menuColor : #DDDDDD;
|
||||
--textColor : black;
|
||||
--hoverMenuColor : #999999;
|
||||
|
||||
@menuHeight : 25px;
|
||||
position : relative;
|
||||
display : flex;
|
||||
flex-wrap : wrap-reverse;
|
||||
justify-content : space-between;
|
||||
height : auto;
|
||||
color : black;
|
||||
background-color : #DDDDDD;
|
||||
|
||||
.snippets {
|
||||
display : flex;
|
||||
justify-content : flex-start;
|
||||
min-width : 499.35px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied
|
||||
}
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
font-size : 0.65rem;
|
||||
font-weight : 800;
|
||||
color : var(--textColor);
|
||||
text-transform : uppercase;
|
||||
background-color : var(--menuColor);
|
||||
reading-flow : flex-visual;
|
||||
|
||||
.editors {
|
||||
display : flex;
|
||||
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 : 275px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied
|
||||
font-size:0.85rem;
|
||||
&:only-child {min-width : unset; margin-left : auto;}
|
||||
reading-order : 2;
|
||||
|
||||
>div {
|
||||
display : flex;
|
||||
@@ -31,7 +36,7 @@
|
||||
|
||||
&:first-child { border-left : none; }
|
||||
|
||||
& > div {
|
||||
& > button, & > li {
|
||||
position : relative;
|
||||
width : @menuHeight;
|
||||
height : @menuHeight;
|
||||
@@ -39,9 +44,9 @@
|
||||
text-align : center;
|
||||
cursor : pointer;
|
||||
|
||||
&.editorTool:not(.active) { cursor : not-allowed; }
|
||||
&.editorTool:not(.active),&.editorTool:disabled { cursor : not-allowed; }
|
||||
|
||||
&:hover,&.selected { background-color : #999999; }
|
||||
&:hover,&.selected { background-color : var(--hoverMenuColor); }
|
||||
&.text {
|
||||
.tooltipLeft('Brew Editor');
|
||||
}
|
||||
@@ -54,37 +59,45 @@
|
||||
&.snippet {
|
||||
.tooltipLeft('Snippets');
|
||||
}
|
||||
&.settings {
|
||||
.tooltipLeft('Settings');
|
||||
}
|
||||
&.undo {
|
||||
.tooltipLeft('Undo');
|
||||
font-size : 0.75em;
|
||||
color : grey;
|
||||
&.active { color : inherit; }
|
||||
}
|
||||
&.redo {
|
||||
.tooltipLeft('Redo');
|
||||
font-size : 0.75em;
|
||||
color : grey;
|
||||
&.active { color : inherit; }
|
||||
}
|
||||
&.foldAll {
|
||||
.tooltipLeft('Fold All');
|
||||
font-size : 0.75em;
|
||||
color : grey;
|
||||
&.active { color : inherit; }
|
||||
}
|
||||
&.unfoldAll {
|
||||
.tooltipLeft('Unfold All');
|
||||
font-size : 0.75em;
|
||||
color : grey;
|
||||
&.active { color : inherit; }
|
||||
}
|
||||
&.formatCode {
|
||||
.tooltipLeft('Clean your Code');
|
||||
color : grey;
|
||||
&.active { color : inherit; }
|
||||
}
|
||||
&.history {
|
||||
font-size:.70rem;
|
||||
|
||||
i {
|
||||
min-width:auto;
|
||||
}
|
||||
.tooltipLeft('History');
|
||||
position : relative;
|
||||
font-size : 0.75em;
|
||||
color : grey;
|
||||
border : none;
|
||||
&.active { color : inherit; }
|
||||
&:not(:disabled) { color : inherit; }
|
||||
& > .dropdown {
|
||||
right : -1px;
|
||||
& > .snippet { padding-right : 10px; }
|
||||
@@ -92,18 +105,12 @@
|
||||
}
|
||||
&.editorTheme {
|
||||
.tooltipLeft('Editor Themes');
|
||||
font-size : 0.75em;
|
||||
color : inherit;
|
||||
&.active {
|
||||
position : relative;
|
||||
background-color : #999999;
|
||||
}
|
||||
}
|
||||
&.divider {
|
||||
width : 5px;
|
||||
background : linear-gradient(currentColor, currentColor) no-repeat center/1px 100%;
|
||||
&:hover { background-color : inherit; }
|
||||
}
|
||||
}
|
||||
.themeSelector {
|
||||
position : absolute;
|
||||
@@ -119,23 +126,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.snippetBarButton {
|
||||
display : inline-block;
|
||||
height : @menuHeight;
|
||||
padding : 0px 5px;
|
||||
font-size : 0.625em;
|
||||
font-weight : 800;
|
||||
line-height : @menuHeight;
|
||||
text-transform : uppercase;
|
||||
text-wrap : nowrap;
|
||||
cursor : pointer;
|
||||
&:hover, &.selected { background-color : #999999; }
|
||||
i {
|
||||
margin-right : 3px;
|
||||
font-size : 1.4em;
|
||||
vertical-align : middle;
|
||||
}
|
||||
}
|
||||
|
||||
.toggleMeta {
|
||||
position : absolute;
|
||||
top : 0px;
|
||||
@@ -143,112 +134,106 @@
|
||||
border-left : 1px solid black;
|
||||
.tooltipLeft('Edit Brew Properties');
|
||||
}
|
||||
.snippetGroup {
|
||||
|
||||
&:hover {
|
||||
& > .dropdown { visibility : visible; }
|
||||
.snippets {
|
||||
display : flex;
|
||||
justify-content : flex-start;
|
||||
min-width : 565.95px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied
|
||||
reading-order : 1;
|
||||
}
|
||||
|
||||
// removed caret for top level items, by request (makes buttons too wide).
|
||||
.menu-wrapper .menu-item:is(.snippets > .menu-wrapper > .menu-item):first-child .caret { display : none; }
|
||||
|
||||
.menu-item {
|
||||
position : relative;
|
||||
display : flex;
|
||||
align-items : center;
|
||||
justify-content : space-between;
|
||||
width : 100%;
|
||||
min-width : max-content;
|
||||
padding : 5px;
|
||||
cursor : pointer;
|
||||
&:is(.menu-list .menu-item) [class*='name'] {
|
||||
padding-inline : 8px; // additional space between icon and name (helpful in Fonts menu especially).
|
||||
}
|
||||
.dropdown {
|
||||
position : absolute;
|
||||
top : 100%;
|
||||
z-index : 1000;
|
||||
visibility : hidden;
|
||||
padding : 0px;
|
||||
margin-left : -5px;
|
||||
background-color : #DDDDDD;
|
||||
.snippet {
|
||||
position : relative;
|
||||
display : flex;
|
||||
align-items : center;
|
||||
min-width : max-content;
|
||||
padding : 5px;
|
||||
font-size : 10px;
|
||||
cursor : pointer;
|
||||
.animate(background-color);
|
||||
i {
|
||||
min-width : 25px;
|
||||
height : 1.2em;
|
||||
margin-right : 8px;
|
||||
font-size : 1.2em;
|
||||
text-align : center;
|
||||
& ~ i {
|
||||
margin-right : 0;
|
||||
margin-left : 5px;
|
||||
}
|
||||
/* Fonts */
|
||||
&.font {
|
||||
height : auto;
|
||||
&::before {
|
||||
font-size : 1em;
|
||||
content : 'ABC';
|
||||
}
|
||||
.menu-name {
|
||||
flex : 1;
|
||||
text-align : left;
|
||||
text-box-trim : trim-end;
|
||||
}
|
||||
i {
|
||||
min-width : 25px;
|
||||
height : 0.85rem;
|
||||
font-size : 1.2em;
|
||||
text-align : center;
|
||||
&.caret { margin-right : 0; }
|
||||
&.caret:is(.menu-wrapper .menu-wrapper *) { text-align : right; }
|
||||
/* Fonts */
|
||||
&.font {
|
||||
height : auto;
|
||||
&::before {
|
||||
font-size : 1em;
|
||||
content : 'ABC';
|
||||
}
|
||||
|
||||
&.OpenSans {font-family : 'OpenSans';}
|
||||
&.CodeBold {font-family : 'CodeBold';}
|
||||
&.CodeLight {font-family : 'CodeLight';}
|
||||
&.ScalySansRemake {font-family : 'ScalySansRemake';}
|
||||
&.BookInsanityRemake {font-family : 'BookInsanityRemake';}
|
||||
&.MrEavesRemake {font-family : 'MrEavesRemake';}
|
||||
&.SolberaImitationRemake {font-family : 'SolberaImitationRemake';}
|
||||
&.ScalySansSmallCapsRemake {font-family : 'ScalySansSmallCapsRemake';}
|
||||
&.WalterTurncoat {font-family : 'WalterTurncoat';}
|
||||
&.Lato {font-family : 'Lato';}
|
||||
&.Courier {font-family : 'Courier';}
|
||||
&.NodestoCapsCondensed {font-family : 'NodestoCapsCondensed';}
|
||||
&.Overpass {font-family : 'Overpass';}
|
||||
&.Davek {font-family : 'Davek';}
|
||||
&.Iokharic {font-family : 'Iokharic';}
|
||||
&.Rellanic {font-family : 'Rellanic';}
|
||||
&.TimesNewRoman {font-family : 'Times New Roman';}
|
||||
}
|
||||
}
|
||||
.name { margin-right : auto; }
|
||||
.disabled { text-decoration : line-through; }
|
||||
.beta {
|
||||
align-self : center;
|
||||
padding : 4px 6px;
|
||||
margin-left : 5px;
|
||||
font-family : monospace;
|
||||
line-height : 1em;
|
||||
color : white;
|
||||
background : grey;
|
||||
border-radius : 12px;
|
||||
}
|
||||
&:hover {
|
||||
background-color : #999999;
|
||||
& > .dropdown {
|
||||
visibility : visible;
|
||||
&.side {
|
||||
top : 0%;
|
||||
left : 100%;
|
||||
margin-left : 0;
|
||||
box-shadow : -1px 1px 2px 0px #999999;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.OpenSans {font-family : 'OpenSans';}
|
||||
&.CodeBold {font-family : 'CodeBold';}
|
||||
&.CodeLight {font-family : 'CodeLight';}
|
||||
&.ScalySansRemake {font-family : 'ScalySansRemake';}
|
||||
&.BookInsanityRemake {font-family : 'BookInsanityRemake';}
|
||||
&.MrEavesRemake {font-family : 'MrEavesRemake';}
|
||||
&.SolberaImitationRemake {font-family : 'SolberaImitationRemake';}
|
||||
&.ScalySansSmallCapsRemake {font-family : 'ScalySansSmallCapsRemake';}
|
||||
&.WalterTurncoat {font-family : 'WalterTurncoat';}
|
||||
&.Lato {font-family : 'Lato';}
|
||||
&.Courier {font-family : 'Courier';}
|
||||
&.NodestoCapsCondensed {font-family : 'NodestoCapsCondensed';}
|
||||
&.Overpass {font-family : 'Overpass';}
|
||||
&.Davek {font-family : 'Davek';}
|
||||
&.Iokharic {font-family : 'Iokharic';}
|
||||
&.Rellanic {font-family : 'Rellanic';}
|
||||
&.TimesNewRoman {font-family : 'Times New Roman';}
|
||||
}
|
||||
}
|
||||
.name { margin-right : auto; }
|
||||
.status {
|
||||
align-self : center;
|
||||
padding : 4px 6px;
|
||||
margin-left : 5px;
|
||||
font-family : monospace;
|
||||
line-height : 1em;
|
||||
color : white;
|
||||
background : grey;
|
||||
border-radius : 12px;
|
||||
}
|
||||
&:hover { background-color : var(--hoverMenuColor); }
|
||||
&:disabled {
|
||||
color : gray;
|
||||
cursor : not-allowed;
|
||||
&:hover { background-color : unset; }
|
||||
}
|
||||
}
|
||||
.disabledSnippets {
|
||||
color: grey;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover { background-color: #DDDDDD;}
|
||||
}
|
||||
|
||||
}
|
||||
@container editor (width < 750px) {
|
||||
@container editor (width < 841px) {
|
||||
.snippetBar {
|
||||
.editors {
|
||||
flex : 1;
|
||||
justify-content : space-between;
|
||||
border-bottom : 1px solid;
|
||||
reading-order : 1;
|
||||
}
|
||||
.snippets {
|
||||
flex : 1;
|
||||
justify-content : space-evenly;
|
||||
reading-order : 2;
|
||||
}
|
||||
.editors > div.history > .dropdown { right : unset; }
|
||||
}
|
||||
}
|
||||
|
||||
.editor.darkMode .snippetBar {
|
||||
--menuColor : #666666;
|
||||
--textColor : #EEEEEE;
|
||||
--hoverMenuColor : #444444;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
export const tagSuggestionList = [
|
||||
// ############################## Systems
|
||||
// D&D
|
||||
'system:D&D Original',
|
||||
'system:D&D Basic',
|
||||
'system:AD&D 1e',
|
||||
'system:AD&D 2e',
|
||||
'system:D&D 3e',
|
||||
'system:D&D 3.5e',
|
||||
'system:D&D 4e',
|
||||
'system:D&D 5e',
|
||||
'system:D&D 5e 2024',
|
||||
'system:BD&D (B/X)',
|
||||
'system:D&D Essentials',
|
||||
|
||||
// Other Famous RPGs
|
||||
'system:Pathfinder 1e',
|
||||
'system:Pathfinder 2e',
|
||||
'system:Vampire: The Masquerade',
|
||||
'system:Werewolf: The Apocalypse',
|
||||
'system:Mage: The Ascension',
|
||||
'system:Call of Cthulhu',
|
||||
'system:Shadowrun',
|
||||
'system:Star Wars RPG (D6/D20/Edge of the Empire)',
|
||||
'system:Warhammer Fantasy Roleplay',
|
||||
'system:Cyberpunk 2020',
|
||||
'system:Blades in the Dark',
|
||||
'system:Daggerheart',
|
||||
'system:Draw Steel',
|
||||
'system:Mutants and Masterminds',
|
||||
|
||||
// Meta
|
||||
'meta:V3',
|
||||
'meta:Legacy',
|
||||
'meta:Template',
|
||||
'meta:Theme',
|
||||
'meta:free',
|
||||
'meta:Character Sheet',
|
||||
'meta:Documentation',
|
||||
'meta:NPC',
|
||||
'meta:Guide',
|
||||
'meta:Resource',
|
||||
'meta:Notes',
|
||||
'meta:Example',
|
||||
|
||||
// Book type
|
||||
'type:Campaign',
|
||||
'type:Campaign Setting',
|
||||
'type:Adventure',
|
||||
'type:One-Shot',
|
||||
'type:Setting',
|
||||
'type:World',
|
||||
'type:Lore',
|
||||
'type:History',
|
||||
'type:Dungeon Master',
|
||||
'type:Encounter Pack',
|
||||
'type:Encounter',
|
||||
'type:Session Notes',
|
||||
'type:reference',
|
||||
'type:Handbook',
|
||||
'type:Manual',
|
||||
'type:Manuals',
|
||||
'type:Compendium',
|
||||
'type:Bestiary',
|
||||
|
||||
// ###################################### RPG Keywords
|
||||
|
||||
// Classes / Subclasses / Archetypes
|
||||
'Class',
|
||||
'Subclass',
|
||||
'Archetype',
|
||||
'Martial',
|
||||
'Half-Caster',
|
||||
'Full Caster',
|
||||
'Artificer',
|
||||
'Barbarian',
|
||||
'Bard',
|
||||
'Cleric',
|
||||
'Druid',
|
||||
'Fighter',
|
||||
'Monk',
|
||||
'Paladin',
|
||||
'Rogue',
|
||||
'Sorcerer',
|
||||
'Warlock',
|
||||
'Wizard',
|
||||
|
||||
// Races / Species / Lineages
|
||||
'Race',
|
||||
'Ancestry',
|
||||
'Lineage',
|
||||
'Aasimar',
|
||||
'Beastfolk',
|
||||
'Dragonborn',
|
||||
'Dwarf',
|
||||
'Elf',
|
||||
'Goblin',
|
||||
'Half-Elf',
|
||||
'Half-Orc',
|
||||
'Human',
|
||||
'Kobold',
|
||||
'Lizardfolk',
|
||||
'Lycan',
|
||||
'Orc',
|
||||
'Tiefling',
|
||||
'Vampire',
|
||||
'Yuan-Ti',
|
||||
|
||||
// Magic / Spells / Items
|
||||
'Magic',
|
||||
'Magic Item',
|
||||
'Magic Items',
|
||||
'Wondrous Item',
|
||||
'Magic Weapon',
|
||||
'Artifact',
|
||||
'Spell',
|
||||
'Spells',
|
||||
'Cantrip',
|
||||
'Cantrips',
|
||||
'Eldritch',
|
||||
'Eldritch Invocation',
|
||||
'Invocation',
|
||||
'Invocations',
|
||||
'Pact boon',
|
||||
'Pact Boon',
|
||||
'Spellcaster',
|
||||
'Spellblade',
|
||||
'Magical Tattoos',
|
||||
'Enchantment',
|
||||
'Enchanted',
|
||||
'Attunement',
|
||||
'Requires Attunement',
|
||||
'Rune',
|
||||
'Runes',
|
||||
'Wand',
|
||||
'Rod',
|
||||
'Scroll',
|
||||
'Potion',
|
||||
'Potions',
|
||||
'Item',
|
||||
'Items',
|
||||
'Bag of Holding',
|
||||
|
||||
// Monsters / Creatures / Enemies
|
||||
'Monster',
|
||||
'Creatures',
|
||||
'Creature',
|
||||
'Beast',
|
||||
'Beasts',
|
||||
'Humanoid',
|
||||
'Undead',
|
||||
'Fiend',
|
||||
'Aberration',
|
||||
'Ooze',
|
||||
'Giant',
|
||||
'Dragon',
|
||||
'Monstrosity',
|
||||
'Demon',
|
||||
'Devil',
|
||||
'Elemental',
|
||||
'Construct',
|
||||
'Constructs',
|
||||
'Boss',
|
||||
'BBEG',
|
||||
|
||||
// ############################# Media / Pop Culture
|
||||
'One Piece',
|
||||
'Dragon Ball',
|
||||
'Dragon Ball Z',
|
||||
'Naruto',
|
||||
'Jujutsu Kaisen',
|
||||
'Fairy Tail',
|
||||
'Final Fantasy',
|
||||
'Kingdom Hearts',
|
||||
'Elder Scrolls',
|
||||
'Skyrim',
|
||||
'WoW',
|
||||
'World of Warcraft',
|
||||
'Marvel Comics',
|
||||
'DC Comics',
|
||||
'Pokemon',
|
||||
'League of Legends',
|
||||
'Runeterra',
|
||||
'Arcane',
|
||||
'Yu-Gi-Oh',
|
||||
'Minecraft',
|
||||
'Don\'t Starve',
|
||||
'Witcher',
|
||||
'Witcher 3',
|
||||
'Cyberpunk',
|
||||
'Cyberpunk 2077',
|
||||
'Fallout',
|
||||
'Divinity Original Sin 2',
|
||||
'Fullmetal Alchemist',
|
||||
'Fullmetal Alchemist Brotherhood',
|
||||
'Lobotomy Corporation',
|
||||
'Bloodborne',
|
||||
'Dragonlance',
|
||||
'Shackled City Adventure Path',
|
||||
'Baldurs Gate 3',
|
||||
'Library of Ruina',
|
||||
'Radiant Citadel',
|
||||
'Ravenloft',
|
||||
'Forgotten Realms',
|
||||
'Exandria',
|
||||
'Critical Role',
|
||||
'Star Wars',
|
||||
'SW5e',
|
||||
'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'],
|
||||
];
|
||||
@@ -1,102 +1,199 @@
|
||||
import './tagInput.less';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import _ from 'lodash';
|
||||
import Combobox from '@components/combobox.jsx';
|
||||
|
||||
const TagInput = ({ unique = true, values = [], ...props })=>{
|
||||
const [tempInputText, setTempInputText] = useState('');
|
||||
const [tagList, setTagList] = useState(values.map((value)=>({ value, editing: false })));
|
||||
import { tagSuggestionList, canonizationList } from './curatedTagSuggestionList.js';
|
||||
|
||||
const TagInput = ({ id, tooltip, label, valuePatterns, values = [], unique = true, placeholder = '', smallText = '', onChange })=>{
|
||||
const [tagList, setTagList] = useState(
|
||||
values.map((value)=>({
|
||||
value,
|
||||
editing : false,
|
||||
draft : '',
|
||||
})),
|
||||
);
|
||||
|
||||
useEffect(()=>{
|
||||
handleChange(tagList.map((context)=>context.value));
|
||||
const incoming = values || [];
|
||||
const current = tagList.map((t)=>t.value);
|
||||
|
||||
const changed = incoming.length !== current.length || incoming.some((v, i)=>v !== current[i]);
|
||||
|
||||
if(changed) {
|
||||
setTagList(
|
||||
incoming.map((value)=>({
|
||||
value,
|
||||
editing : false,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}, [values]);
|
||||
|
||||
useEffect(()=>{
|
||||
onChange?.({
|
||||
target : { value: tagList.map((t)=>t.value) },
|
||||
});
|
||||
}, [tagList]);
|
||||
|
||||
const handleChange = (value)=>{
|
||||
props.onChange({
|
||||
target : { value }
|
||||
});
|
||||
};
|
||||
const normalizeValue = (input)=>{
|
||||
const lowerInput = input.toLowerCase();
|
||||
let normalizedTag = input;
|
||||
|
||||
const handleInputKeyDown = ({ evt, value, index, options = {} })=>{
|
||||
if(_.includes(['Enter', ','], evt.key)) {
|
||||
evt.preventDefault();
|
||||
submitTag(evt.target.value, value, index);
|
||||
if(options.clear) {
|
||||
setTempInputText('');
|
||||
for (const group of canonizationList) {
|
||||
for (const tag of group) {
|
||||
if(!tag) continue;
|
||||
|
||||
const index = lowerInput.indexOf(tag.toLowerCase());
|
||||
if(index !== -1) {
|
||||
normalizedTag = input.slice(0, index) + group[0] + input.slice(index + tag.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(normalizedTag.includes(':')) {
|
||||
const [rawType, rawValue = ''] = normalizedTag.split(':');
|
||||
const tagType = rawType.trim().toLowerCase();
|
||||
const tagValue = rawValue.trim();
|
||||
|
||||
if(tagValue.length > 0) {
|
||||
normalizedTag = `${tagType}:${tagValue[0].toUpperCase()}${tagValue.slice(1)}`;
|
||||
}
|
||||
//trims spaces around colon and capitalizes the first word after the colon
|
||||
//this is preferred to users not understanding they can't put spaces in
|
||||
}
|
||||
|
||||
return normalizedTag;
|
||||
};
|
||||
|
||||
const submitTag = (newValue, originalValue, index)=>{
|
||||
setTagList((prevContext)=>{
|
||||
// remove existing tag
|
||||
if(newValue === null){
|
||||
return [...prevContext].filter((context, i)=>i !== index);
|
||||
const submitTag = (newValue, index = null)=>{
|
||||
const trimmed = newValue?.trim();
|
||||
if(!trimmed) return;
|
||||
if(!valuePatterns.test(trimmed)) return;
|
||||
|
||||
const normalizedTag = normalizeValue(trimmed);
|
||||
|
||||
setTagList((prev)=>{
|
||||
const existsIndex = prev.findIndex((t)=>t.value.toLowerCase() === normalizedTag.toLowerCase());
|
||||
if(unique && existsIndex !== -1) return prev;
|
||||
if(index !== null) {
|
||||
return prev.map((t, i)=>(i === index ? { ...t, value: normalizedTag, editing: false } : t));
|
||||
}
|
||||
// add new tag
|
||||
if(originalValue === null){
|
||||
return [...prevContext, { value: newValue, editing: false }];
|
||||
}
|
||||
// update existing tag
|
||||
return prevContext.map((context, i)=>{
|
||||
if(i === index) {
|
||||
return { ...context, value: newValue, editing: false };
|
||||
}
|
||||
return context;
|
||||
});
|
||||
|
||||
return [...prev, { value: normalizedTag, editing: false }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeTag = (index)=>{
|
||||
setTagList((prev)=>prev.filter((_, i)=>i !== index));
|
||||
};
|
||||
|
||||
const editTag = (index)=>{
|
||||
setTagList((prevContext)=>{
|
||||
return prevContext.map((context, i)=>{
|
||||
if(i === index) {
|
||||
return { ...context, editing: true };
|
||||
}
|
||||
return { ...context, editing: false };
|
||||
});
|
||||
});
|
||||
setTagList((prev)=>prev.map((t, i)=>(i === index ? { ...t, editing: true, draft: t.value } : t)));
|
||||
};
|
||||
|
||||
const renderReadTag = (context, index)=>{
|
||||
return (
|
||||
<li key={index}
|
||||
data-value={context.value}
|
||||
className='tag'
|
||||
onClick={()=>editTag(index)}>
|
||||
{context.value}
|
||||
<button onClick={(evt)=>{evt.stopPropagation(); submitTag(null, context.value, index);}}><i className='fa fa-times fa-fw'/></button>
|
||||
</li>
|
||||
);
|
||||
const stopEditing = (index)=>{
|
||||
setTagList((prev)=>prev.map((t, i)=>(i === index ? { ...t, editing: false, draft: '' } : t)));
|
||||
};
|
||||
|
||||
const renderWriteTag = (context, index)=>{
|
||||
const suggestionOptions = tagSuggestionList.map((tag)=>{
|
||||
const tagType = tag.split(':');
|
||||
|
||||
let classes = 'item';
|
||||
switch (tagType[0]) {
|
||||
case 'type':
|
||||
classes = 'item type';
|
||||
break;
|
||||
case 'group':
|
||||
classes = 'item group';
|
||||
break;
|
||||
case 'meta':
|
||||
classes = 'item meta';
|
||||
break;
|
||||
case 'system':
|
||||
classes = 'item system';
|
||||
break;
|
||||
default:
|
||||
classes = 'item';
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<input type='text'
|
||||
key={index}
|
||||
defaultValue={context.value}
|
||||
onKeyDown={(evt)=>handleInputKeyDown({ evt, value: context.value, index: index })}
|
||||
autoFocus
|
||||
/>
|
||||
<div className={classes} key={`tag-${tag}`} value={tag} data={tag}>
|
||||
{tag}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='field'>
|
||||
<label>{props.label}</label>
|
||||
<div className='value'>
|
||||
<ul className='list'>
|
||||
{tagList.map((context, index)=>{ return context.editing ? renderWriteTag(context, index) : renderReadTag(context, index); })}
|
||||
</ul>
|
||||
|
||||
<input
|
||||
type='text'
|
||||
className='value'
|
||||
placeholder={props.placeholder}
|
||||
value={tempInputText}
|
||||
onChange={(e)=>setTempInputText(e.target.value)}
|
||||
onKeyDown={(evt)=>handleInputKeyDown({ evt, value: null, options: { clear: true } })}
|
||||
/>
|
||||
</div>
|
||||
<div className='tagInputWrap'>
|
||||
<Combobox
|
||||
id={id}
|
||||
trigger='click'
|
||||
className='tagInput-dropdown'
|
||||
default=''
|
||||
placeholder={placeholder}
|
||||
options={label === 'tags' ? suggestionOptions : []}
|
||||
tooltip={tooltip}
|
||||
autoSuggest={
|
||||
label === 'tags'
|
||||
? {
|
||||
suggestMethod : 'startsWith',
|
||||
clearAutoSuggestOnClick : true,
|
||||
filterOn : ['value', 'title'],
|
||||
}
|
||||
: { suggestMethod: 'includes', clearAutoSuggestOnClick: true, filterOn: [] }
|
||||
}
|
||||
valuePatterns={valuePatterns.source}
|
||||
onSelect={(value)=>submitTag(value)}
|
||||
onEntry={(e)=>{
|
||||
if(e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submitTag(e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ul className='list'>
|
||||
{tagList.map((t, i)=>t.editing ? (
|
||||
<input
|
||||
id={`${id}-${i}`}
|
||||
key={i}
|
||||
type='text'
|
||||
value={t.draft} // always use draft
|
||||
pattern={valuePatterns.source}
|
||||
onChange={(e)=>setTagList((prev)=>prev.map((tag, idx)=>(idx === i ? { ...tag, draft: e.target.value } : tag)),
|
||||
)
|
||||
}
|
||||
onKeyDown={(e)=>{
|
||||
if(e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submitTag(t.draft, i); // submit draft
|
||||
setTagList((prev)=>prev.map((tag, idx)=>(idx === i ? { ...tag, draft: '' } : tag)),
|
||||
);
|
||||
}
|
||||
if(e.key === 'Escape') {
|
||||
stopEditing(i);
|
||||
e.target.blur();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<li key={i} className='tag' onClick={()=>editTag(i)}>
|
||||
{t.value}
|
||||
<button
|
||||
type='button'
|
||||
aria-label={`remove ${t.value} tag`}
|
||||
onClick={(e)=>{
|
||||
e.stopPropagation();
|
||||
removeTag(i);
|
||||
}}>
|
||||
<i className='fa fa-times fa-fw' />
|
||||
</button>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
.tags {
|
||||
|
||||
.tagInputWrap {
|
||||
display : grid;
|
||||
grid-template-columns : 200px 3fr;
|
||||
gap : 10px;
|
||||
}
|
||||
|
||||
.list input { border-radius : 5px; }
|
||||
|
||||
.tagInput-dropdown {
|
||||
.dropdown-options {
|
||||
.item {
|
||||
&.type { background-color : #00800035; }
|
||||
&.group { background-color : #50505035; }
|
||||
&.meta { background-color : #00008035; }
|
||||
&.system { background-color : #80000035; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
@import '@themes/assets/assets.less';
|
||||
@import '@sharedStyles/core.less';
|
||||
|
||||
.userThemeName {
|
||||
padding-right : 10px;
|
||||
padding-left : 10px;
|
||||
}
|
||||
|
||||
.uiEditor {
|
||||
--bg-clr : white;
|
||||
--bg-image : @backgroundImageAlt;
|
||||
--h1-clr : black;
|
||||
--h2-clr : #000077;
|
||||
--label-clr : black;
|
||||
--input-bg : white;
|
||||
--input-bg-hover : #DDDDDD;
|
||||
--input-text-clr : black;
|
||||
--input-placeholder-clr : grey;
|
||||
|
||||
position : absolute;
|
||||
box-sizing : border-box;
|
||||
width : 100%;
|
||||
height : calc(100vh - 54px); // 54px is the height of the navbar + snippet bar. probably a better way to dynamic get this.
|
||||
padding : 25px;
|
||||
overflow-y : auto;
|
||||
font-size : 13px;
|
||||
color : var(--label-clr);
|
||||
background-color : var(--bg-clr);
|
||||
background-image : var(--bg-image);
|
||||
background-size : cover;
|
||||
|
||||
h1 {
|
||||
margin : 0 0 40px;
|
||||
font-size : 18px;
|
||||
font-weight : bold;
|
||||
color : var(--h1-clr);
|
||||
text-transform : uppercase;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin : 16px 0;
|
||||
font-size : 15px;
|
||||
font-weight : bold;
|
||||
color : var(--h2-clr);
|
||||
border-bottom : 2px solid currentColor;
|
||||
}
|
||||
|
||||
& > div ,& > fieldset { margin-bottom : 10px; }
|
||||
|
||||
.field-group {
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
gap : 10px;
|
||||
width : 100%;
|
||||
}
|
||||
|
||||
.field-column {
|
||||
display : flex;
|
||||
flex : 5 0 250px;
|
||||
flex-direction : column;
|
||||
gap : 10px;
|
||||
}
|
||||
|
||||
.field {
|
||||
position : relative;
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
width : 100%;
|
||||
min-width : 250px;
|
||||
padding-left : 10px;
|
||||
|
||||
& > label {
|
||||
width : 100px;
|
||||
font-size : 1em;
|
||||
font-weight : 800;
|
||||
line-height : 1.8em;
|
||||
color : inherit;
|
||||
text-transform : capitalize;
|
||||
}
|
||||
& > .value {
|
||||
flex : 1 1 auto;
|
||||
width : 50px;
|
||||
&[data-tooltip-right] { max-width : 380px; }
|
||||
&:invalid { background : #FFB9B9; }
|
||||
small {
|
||||
display : block;
|
||||
width : fit-content;
|
||||
margin-inline : 5px;
|
||||
font-size : 0.9em;
|
||||
font-style : italic;
|
||||
line-height : 1.4em;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
input[type='checkbox'], input[type='number'] {
|
||||
float : right;
|
||||
text-align : end;
|
||||
}
|
||||
input[type='text'], textarea, select {
|
||||
color : var(--input-text-clr);
|
||||
background-color : var(--input-bg);
|
||||
border : 1px solid var(--input-placeholder-clr);
|
||||
&:hover, :focus { outline : 1px solid var(--input-placeholder-clr); background-color : var(--input-bg-hover); }
|
||||
&::placeholder { color : var(--input-placeholder-clr); }
|
||||
}
|
||||
|
||||
input[type='range'] { color : var(--input-text-clr); }
|
||||
|
||||
&.description {
|
||||
flex : 1;
|
||||
textarea.value {
|
||||
height : auto;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
resize : none;
|
||||
}
|
||||
}
|
||||
&.thumbnail, &.themes {
|
||||
label { line-height : 2.0em; }
|
||||
.value {
|
||||
overflow : hidden;
|
||||
text-overflow : ellipsis;
|
||||
}
|
||||
button {
|
||||
.colorButton();
|
||||
padding : 0px 5px;
|
||||
color : var(--input-text-clr);
|
||||
background-color : var(--input-bg);
|
||||
border : 1px solid #999999;
|
||||
&:hover { background-color : #777777; }
|
||||
}
|
||||
}
|
||||
&.tags .tagInput-dropdown {
|
||||
z-index : 400;
|
||||
max-width : 200px;
|
||||
|
||||
.dropdown-options {
|
||||
color : var(--input-text-clr);
|
||||
background-color : var(--input-bg);
|
||||
|
||||
.item:hover {
|
||||
color : var(--input-bg);
|
||||
background-color : var(--input-text-clr);
|
||||
}
|
||||
}
|
||||
}
|
||||
&.language .value {
|
||||
z-index : 300;
|
||||
max-width : 150px;
|
||||
}
|
||||
&.themes {
|
||||
.value { overflow : visible; }
|
||||
button {
|
||||
padding-right : 5px;
|
||||
padding-left : 5px;
|
||||
}
|
||||
& .dropdown-container { z-index : 200; }
|
||||
& .dropdown-options { overflow-y : visible; }
|
||||
.disabled {
|
||||
font-style : italic;
|
||||
color : dimgray;
|
||||
background-color : darkgray;
|
||||
}
|
||||
.item.dropdown-input { border : none;}
|
||||
.item {
|
||||
position : relative;
|
||||
overflow : visible;
|
||||
color : inherit;
|
||||
background-color : var(--input-bg);
|
||||
border-top : 1px solid #767676;
|
||||
.preview {
|
||||
position : absolute;
|
||||
top : 0;
|
||||
right : 0;
|
||||
z-index : 10;
|
||||
display : flex;
|
||||
flex-direction : column;
|
||||
width : 200px;
|
||||
overflow : hidden;
|
||||
color : var(--input-text-clr);
|
||||
background : var(--input-bg);
|
||||
border-radius : 5px;
|
||||
box-shadow : 0 0 5px var(--input-text-clr);
|
||||
opacity : 0;
|
||||
transition : opacity 250ms ease;
|
||||
h6 {
|
||||
padding-block : 0.5em;
|
||||
padding-inline : 1em;
|
||||
font-weight : 900;
|
||||
border-bottom : 2px solid #666666;
|
||||
}
|
||||
}
|
||||
|
||||
input { padding-right : 25px; }
|
||||
|
||||
.texture-container {
|
||||
position : absolute;
|
||||
top : 0;
|
||||
left : 0;
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
min-height : 100%;
|
||||
overflow : hidden;
|
||||
> img {
|
||||
position : absolute;
|
||||
top : 0;
|
||||
right : 0;
|
||||
width : 50%;
|
||||
min-height : 100%;
|
||||
-webkit-mask-image : linear-gradient(90deg, transparent, black 20%);
|
||||
mask-image : linear-gradient(90deg, transparent, black 20%);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover > .preview { opacity : 1; }
|
||||
}
|
||||
}
|
||||
&.renderers .value {
|
||||
label {
|
||||
display : inline-flex;
|
||||
align-items : center;
|
||||
margin-right : 15px;
|
||||
font-size : 0.9em;
|
||||
font-weight : 800;
|
||||
vertical-align : middle;
|
||||
white-space : nowrap;
|
||||
cursor : pointer;
|
||||
user-select : none;
|
||||
}
|
||||
input {
|
||||
margin : 3px;
|
||||
vertical-align : middle;
|
||||
cursor : pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&.authors {
|
||||
.tag {
|
||||
font-weight : 300;
|
||||
transition : background-color 0.2s;
|
||||
|
||||
&.owner {
|
||||
position : relative;
|
||||
display : grid;
|
||||
place-items : center;
|
||||
min-width : 25px;
|
||||
font-weight : 900;
|
||||
background-color : @silverLight;
|
||||
|
||||
&::after {
|
||||
position : absolute;
|
||||
top : 0;
|
||||
left : 0;
|
||||
width : 15px;
|
||||
height : 15px;
|
||||
font-family : 'Font Awesome 6 Free';
|
||||
color : gold;
|
||||
content : '\f521';
|
||||
transform : scaleY(0.7);
|
||||
rotate : -25deg;
|
||||
translate : -30% -50%;
|
||||
}
|
||||
}
|
||||
&:has(button) a { padding-right : 5px; }
|
||||
&:has(button:hover) { background : #D97D7D; }
|
||||
|
||||
button { color : @red; }
|
||||
|
||||
}
|
||||
a { text-underline-offset : 0.2em; }
|
||||
}
|
||||
&.invitedAuthors .value {
|
||||
z-index : 100;
|
||||
|
||||
.tagInput-dropdown { max-width : 200px; }
|
||||
}
|
||||
|
||||
&.publish .value {
|
||||
position : relative;
|
||||
margin-bottom : 15px;
|
||||
button { width : 100%; }
|
||||
button.publish {
|
||||
.colorButton(@blueLight);
|
||||
}
|
||||
button.unpublish {
|
||||
.colorButton(@silver);
|
||||
}
|
||||
}
|
||||
&.delete .value {
|
||||
button {
|
||||
.colorButton(@red);
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
display : flex;
|
||||
flex : 1 0;
|
||||
flex-wrap : wrap;
|
||||
|
||||
> * { flex : 0 0 auto; }
|
||||
|
||||
#groupedIcon {
|
||||
#backgroundColors;
|
||||
position : relative;
|
||||
top : -0.3em;
|
||||
right : -0.3em;
|
||||
display : inline-block;
|
||||
min-width : 20px;
|
||||
height : ~'calc(100% + 0.6em)';
|
||||
color : white;
|
||||
text-align : center;
|
||||
cursor : pointer;
|
||||
|
||||
i {
|
||||
position : relative;
|
||||
top : 50%;
|
||||
transform : translateY(-50%);
|
||||
}
|
||||
|
||||
&:not(:last-child) { border-right : 1px solid black; }
|
||||
|
||||
&:last-child { border-radius : 0 0.5em 0.5em 0; }
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding : 0.35em;
|
||||
margin : 2px;
|
||||
font-size : 0.95em;
|
||||
border-radius : 0.5em;
|
||||
|
||||
.icon { #groupedIcon; }
|
||||
|
||||
button {
|
||||
cursor : pointer;
|
||||
|
||||
&:hover { color : @redLight; }
|
||||
}
|
||||
}
|
||||
|
||||
.input-group {
|
||||
height : ~'calc(.9em + 4px + .6em)';
|
||||
|
||||
input { border-radius : 0.5em 0 0 0.5em; }
|
||||
|
||||
input:last-child { border-radius : 0.5em; }
|
||||
|
||||
.value {
|
||||
width : 7.5vw;
|
||||
min-width : 75px;
|
||||
height : 100%;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
height : ~'calc(.9em + 4px + .6em)';
|
||||
|
||||
input { border-radius : 0.5em 0 0 0.5em; }
|
||||
|
||||
input:last-child { border-radius : 0.5em; }
|
||||
|
||||
.value {
|
||||
width : 7.5vw;
|
||||
min-width : 75px;
|
||||
height : 100%;
|
||||
}
|
||||
|
||||
.invalid:focus { background-color : pink; }
|
||||
|
||||
.icon {
|
||||
#groupedIcon;
|
||||
top : -0.54em;
|
||||
right : 1px;
|
||||
height : 97%;
|
||||
|
||||
i { font-size : 1.125em; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.thumbnail-preview {
|
||||
position : relative;
|
||||
flex : 1 1;
|
||||
justify-self : center;
|
||||
width : 80px;
|
||||
height : min-content;
|
||||
max-height : 115px;
|
||||
aspect-ratio : 1 / 1;
|
||||
object-fit : contain;
|
||||
background-color : #AAAAAA;
|
||||
}
|
||||
|
||||
.tag {
|
||||
color : var(--input-bg);
|
||||
background : var(--input-text-clr);
|
||||
}
|
||||
}
|
||||
|
||||
.settingsEditor .field {
|
||||
padding-bottom : 10px;
|
||||
border-bottom : 1px solid black;
|
||||
|
||||
.value {
|
||||
|
||||
display : flex;
|
||||
justify-content : end;
|
||||
}
|
||||
|
||||
> label {
|
||||
width : fit-content;
|
||||
max-width : calc(100% - 200px);
|
||||
}
|
||||
}
|
||||
|
||||
.editor.darkMode .uiEditor {
|
||||
--bg-clr : #555555;
|
||||
--bg-image : @backgroundImageAltDark;
|
||||
--h1-clr : white;
|
||||
--h2-clr : #AAAAFF;
|
||||
--label-clr : #DDDDDD;
|
||||
--input-bg : #333333;
|
||||
--input-bg-hover : #666666;
|
||||
--input-text-clr : #EEEEEE;
|
||||
--input-placeholder-clr : #AAAAAA;
|
||||
|
||||
.field { border-color : var(--h1-clr); }
|
||||
|
||||
a {
|
||||
color : @blueLight;
|
||||
|
||||
&:visited { color : #CB8AD8; }
|
||||
}
|
||||
|
||||
.thumbnail-preview[src='/client/homebrew/thumbnail.png'] { filter : invert(0.8); }
|
||||
button.publish {
|
||||
.colorButton(#3137de) !important;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
|
||||
import 'core-js/es/string/to-well-formed.js'; //Polyfill for older browsers
|
||||
import 'core-js/es/string/to-well-formed.js'; // Polyfill for older browsers
|
||||
import './homebrew.less';
|
||||
import React from 'react';
|
||||
import { StaticRouter as Router, Route, Routes, useParams, useSearchParams } from 'react-router';
|
||||
import { BrowserRouter as Router, Routes, Route, useParams, useSearchParams } from 'react-router';
|
||||
|
||||
import { updateLocalStorage } from './utils/updateLocalStorage/updateLocalStorageKeys.js';
|
||||
|
||||
@@ -38,46 +36,66 @@ const Homebrew = (props)=>{
|
||||
lang : ''
|
||||
},
|
||||
userThemes,
|
||||
brews
|
||||
brews,
|
||||
enablev4
|
||||
} = props;
|
||||
|
||||
global.account = account;
|
||||
global.version = version;
|
||||
global.config = config;
|
||||
global.enablev4 = enablev4;
|
||||
|
||||
const backgroundObject = ()=>{
|
||||
if(global.config.deployment || (config.local && config.development)){
|
||||
const bgText = global.config.deployment || 'Local';
|
||||
return {
|
||||
backgroundImage : `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' height='100px' width='200px'><text x='0' y='15' fill='%23fff7' font-size='20'>${bgText}</text></svg>")`
|
||||
};
|
||||
if(config?.deployment || config?.developmentStyle) {
|
||||
const bgText = config?.deployment || 'Local';
|
||||
return {
|
||||
backgroundImage : `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' height='100px' width='200px'><text x='0' y='15' fill='%23fff7' font-size='20'>${bgText}</text></svg>")`
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
updateLocalStorage();
|
||||
|
||||
if(brew.pureError) {
|
||||
return (
|
||||
<Router>
|
||||
<div className={`homebrew${(config?.deployment || config?.developmentStyle) ? ' deployment' : ''}`} style={backgroundObject()}>
|
||||
<Routes>
|
||||
<Route path={brew.originalUrl} element={<WithRoute el={ErrorPage} brew={brew} />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Router location={url}>
|
||||
<div className={`homebrew${(config.deployment || config.local) ? ' deployment' : ''}`} style={backgroundObject()}>
|
||||
<Router>
|
||||
<div className={`homebrew${(config?.deployment || config?.developmentStyle) ? ' deployment' : ''}`} style={backgroundObject()}>
|
||||
<Routes>
|
||||
<Route path='/edit/:id' element={<WithRoute el={EditPage} brew={brew} userThemes={userThemes}/>} />
|
||||
<Route path='/share/:id' element={<WithRoute el={SharePage} brew={brew} />} />
|
||||
<Route path='/new/:id' element={<WithRoute el={NewPage} brew={brew} userThemes={userThemes}/>} />
|
||||
<Route path='/new' element={<WithRoute el={NewPage} userThemes={userThemes}/> } />
|
||||
<Route path='/user/:username' element={<WithRoute el={UserPage} brews={brews} />} />
|
||||
<Route path='/vault' element={<WithRoute el={VaultPage}/>}/>
|
||||
<Route path='/changelog' element={<WithRoute el={SharePage} brew={brew} disableMeta={true} />} />
|
||||
<Route path='/faq' element={<WithRoute el={SharePage} brew={brew} disableMeta={true} />} />
|
||||
<Route path='/migrate' element={<WithRoute el={SharePage} brew={brew} disableMeta={true} />} />
|
||||
<Route path='/account' element={<WithRoute el={AccountPage} brew={brew} accountDetails={brew.accountDetails} />} />
|
||||
<Route path='/' element={<WithRoute el={HomePage} brew={brew} />} />
|
||||
<Route path='/*' element={<WithRoute el={HomePage} brew={brew} />} />
|
||||
<Route path='/legacy' element={<WithRoute el={HomePage} brew={brew} />} />
|
||||
<Route path='/error' element={<WithRoute el={ErrorPage} brew={brew} />} />
|
||||
<Route path='/' element={<WithRoute el={HomePage} brew={brew} />} />
|
||||
<Route path='/*' element={<WithRoute el={HomePage} brew={brew} />} />
|
||||
|
||||
<Route path='/new/:id' element={<WithRoute el={NewPage} brew={brew} userThemes={userThemes}/>} />
|
||||
<Route path='/new' element={<WithRoute el={NewPage} userThemes={userThemes}/> } />
|
||||
|
||||
<Route path='/edit/:id' element={<WithRoute el={EditPage} brew={brew} userThemes={userThemes}/>} />
|
||||
|
||||
<Route path='/share/:id' element={<WithRoute el={SharePage} brew={brew} />} />
|
||||
<Route path='/changelog' element={<WithRoute el={SharePage} brew={brew} disableMeta={true} />} />
|
||||
<Route path='/faq' element={<WithRoute el={SharePage} brew={brew} disableMeta={true} />} />
|
||||
<Route path='/migrate' element={<WithRoute el={SharePage} brew={brew} disableMeta={true} />} />
|
||||
|
||||
<Route path='/account' element={<WithRoute el={AccountPage} brew={brew} accountDetails={brew.accountDetails} />} />
|
||||
<Route path='/error' element={<WithRoute el={ErrorPage} brew={brew} />} />
|
||||
|
||||
<Route path='/user/:username' element={<WithRoute el={UserPage} brews={brews} />} />
|
||||
<Route path='/vault' element={<WithRoute el={VaultPage}/>}/>
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = Homebrew;
|
||||
export default Homebrew;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@import 'naturalcrit/styles/core.less';
|
||||
@import '@sharedStyles/core.less';
|
||||
.homebrew {
|
||||
height : 100%;
|
||||
background-color:@steel;
|
||||
height : 100%;
|
||||
background-color : @steel;
|
||||
&.deployment { background-color : darkred; }
|
||||
|
||||
.sitePage {
|
||||
@@ -15,21 +15,5 @@
|
||||
height : calc(~'100% - 29px'); //Navbar height
|
||||
overflow-y : hidden;
|
||||
}
|
||||
&.listPage .content {
|
||||
overflow-y : scroll;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width : 20px;
|
||||
&:horizontal {
|
||||
width : auto;
|
||||
height : 20px;
|
||||
}
|
||||
&-thumb {
|
||||
background : linear-gradient(90deg, #D3C1AF 15px, #00000000 15px);
|
||||
&:horizontal { background : linear-gradient(0deg, #D3C1AF 15px, #00000000 15px); }
|
||||
}
|
||||
&-corner { visibility : hidden; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import Homebrew from './homebrew.jsx';
|
||||
import { bootstrapAnchorPositioningPolyfill } from '@components/anchorPositioningPolyfill.js';
|
||||
|
||||
const props = window.__INITIAL_PROPS__ || {};
|
||||
|
||||
createRoot(document.getElementById('reactRoot')).render(<Homebrew {...props} />);
|
||||
bootstrapAnchorPositioningPolyfill();
|
||||
@@ -97,7 +97,7 @@ const Account = createReactClass({
|
||||
|
||||
// Logged out
|
||||
// LOCAL ONLY
|
||||
if(global.config.local) {
|
||||
if(global.config?.local) {
|
||||
return <Nav.item color='teal' icon='fas fa-sign-in-alt' onClick={this.localLogin}>
|
||||
login
|
||||
</Nav.item>;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import '@sharedStyles/core.less';
|
||||
|
||||
.navItem.error {
|
||||
position : relative;
|
||||
background-color : @red;
|
||||
|
||||
@@ -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(){
|
||||
return <div className={`window ${this.state.showMetaWindow ? 'active' : 'inactive'}`}>
|
||||
<div className='row'>
|
||||
@@ -65,10 +60,6 @@ const MetadataNav = createReactClass({
|
||||
<h4>Tags</h4>
|
||||
<p>{this.getTags()}</p>
|
||||
</div>
|
||||
<div className='row'>
|
||||
<h4>Systems</h4>
|
||||
<p>{this.getSystems()}</p>
|
||||
</div>
|
||||
<div className='row'>
|
||||
<h4>Updated</h4>
|
||||
<p>{Moment(this.props.brew.updatedAt).fromNow()}</p>
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import './navbar.less';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import _ from 'lodash';
|
||||
import cx from 'classnames';
|
||||
|
||||
import NaturalCritIcon from '../../components/svg/naturalcrit-d20.svg.jsx';
|
||||
import NaturalCritIcon from '@components/svg/naturalcrit-d20.svg.jsx';
|
||||
|
||||
const Nav = {
|
||||
base : createReactClass({
|
||||
displayName : 'Nav.base',
|
||||
render : function(){
|
||||
return <nav>
|
||||
{this.props.children}
|
||||
</nav>;
|
||||
}
|
||||
}),
|
||||
logo : function(){
|
||||
base : ({ children, className, ...props })=>{
|
||||
return <nav className={className}>
|
||||
{children}
|
||||
</nav>;
|
||||
},
|
||||
logo : ()=>{
|
||||
return <a className='navLogo' href='https://www.naturalcrit.com'>
|
||||
<NaturalCritIcon />
|
||||
<span className='name'>
|
||||
@@ -24,50 +19,26 @@ const Nav = {
|
||||
</a>;
|
||||
},
|
||||
|
||||
section : createReactClass({
|
||||
displayName : 'Nav.section',
|
||||
render : function(){
|
||||
return <div className={`navSection ${this.props.className ?? ''}`}>
|
||||
{this.props.children}
|
||||
</div>;
|
||||
section : ({ children, className, ...props })=>{
|
||||
return <div className={cx([`navSection`, className])}>
|
||||
{children}
|
||||
</div>;
|
||||
},
|
||||
|
||||
item : ({ icon, href, newTab, onClick, color, children, className, ...props })=>{
|
||||
const classes = cx('navItem', color, className);
|
||||
if(href){
|
||||
return <a className={classes} href={href} target={newTab ? '_blank' : '_self'} {...props}>
|
||||
{children}
|
||||
{icon && <i className={icon}></i>}
|
||||
</a>;
|
||||
} else {
|
||||
return <button {...props} className={classes} onClick={onClick} >
|
||||
{children}
|
||||
{icon && <i className={icon}></i>}
|
||||
</button>;
|
||||
}
|
||||
}),
|
||||
|
||||
item : createReactClass({
|
||||
displayName : 'Nav.item',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
icon : null,
|
||||
href : null,
|
||||
newTab : false,
|
||||
onClick : function(){},
|
||||
color : null
|
||||
};
|
||||
},
|
||||
handleClick : function(e){
|
||||
this.props.onClick(e);
|
||||
},
|
||||
render : function(){
|
||||
const classes = cx('navItem', this.props.color, this.props.className);
|
||||
|
||||
let icon;
|
||||
if(this.props.icon) icon = <i className={this.props.icon} />;
|
||||
|
||||
const props = _.omit(this.props, ['newTab']);
|
||||
|
||||
if(this.props.href){
|
||||
return <a {...props} className={classes} target={this.props.newTab ? '_blank' : '_self'} >
|
||||
{this.props.children}
|
||||
{icon}
|
||||
</a>;
|
||||
} else {
|
||||
return <div {...props} className={classes} onClick={this.handleClick} >
|
||||
{this.props.children}
|
||||
{icon}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
|
||||
dropdown : function dropdown(props) {
|
||||
props = Object.assign({}, props, {
|
||||
|
||||
@@ -1,52 +1,28 @@
|
||||
import './navbar.less';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
|
||||
import Nav from './nav.jsx';
|
||||
import PatreonNavItem from './patreon.navitem.jsx';
|
||||
|
||||
const Navbar = createReactClass({
|
||||
displayName : 'Navbar',
|
||||
getInitialState : function() {
|
||||
return {
|
||||
//showNonChromeWarning : false,
|
||||
ver : '0.0.0'
|
||||
};
|
||||
},
|
||||
const Navbar = ({ children })=>{
|
||||
const version = global.version || '0.0.0';
|
||||
|
||||
getInitialState : function() {
|
||||
return {
|
||||
ver : global.version
|
||||
};
|
||||
},
|
||||
|
||||
/*
|
||||
renderChromeWarning : function(){
|
||||
if(!this.state.showNonChromeWarning) return;
|
||||
return <Nav.item className='warning' icon='fa-exclamation-triangle'>
|
||||
Optimized for Chrome
|
||||
<div className='dropdown'>
|
||||
If you are experiencing rendering issues, use Chrome instead
|
||||
</div>
|
||||
</Nav.item>
|
||||
},
|
||||
*/
|
||||
render : function(){
|
||||
return <Nav.base>
|
||||
return (
|
||||
<Nav.base>
|
||||
<Nav.section>
|
||||
<Nav.logo />
|
||||
<Nav.item href='/' className='homebrewLogo'>
|
||||
<div>The Homebrewery</div>
|
||||
</Nav.item>
|
||||
<Nav.item newTab={true} href='/changelog' color='purple' icon='far fa-file-alt'>
|
||||
{`v${this.state.ver}`}
|
||||
{`v${version}`}
|
||||
</Nav.item>
|
||||
<PatreonNavItem />
|
||||
{/*this.renderChromeWarning()*/}
|
||||
{/* this.renderChromeWarning() */}
|
||||
</Nav.section>
|
||||
{this.props.children}
|
||||
</Nav.base>;
|
||||
}
|
||||
});
|
||||
{children}
|
||||
</Nav.base>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
export default Navbar;
|
||||
@@ -1,4 +1,4 @@
|
||||
@import 'naturalcrit/styles/colors.less';
|
||||
@import '@sharedStyles/core.less';
|
||||
|
||||
@navbarHeight : 28px;
|
||||
@viewerToolsHeight : 32px;
|
||||
@@ -39,9 +39,7 @@
|
||||
flex-grow : 1;
|
||||
min-width : 300px;
|
||||
}
|
||||
>.brewTitle {
|
||||
cursor:auto;
|
||||
}
|
||||
>.brewTitle { cursor : auto; }
|
||||
}
|
||||
// "NaturalCrit" logo
|
||||
.navLogo {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import Nav from './nav.jsx';
|
||||
import { splitTextStyleAndMetadata } from '../../../shared/helpers.js';
|
||||
import { splitTextStyleAndMetadata } from '@shared/helpers.js';
|
||||
|
||||
const BREWKEY = 'HB_newPage_content';
|
||||
const STYLEKEY = 'HB_newPage_style';
|
||||
@@ -24,7 +24,7 @@ const NewBrew = ()=>{
|
||||
localStorage.setItem(BREWKEY, newBrew.text);
|
||||
localStorage.setItem(STYLEKEY, newBrew.style);
|
||||
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';
|
||||
return;
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Nav from './nav.jsx';
|
||||
import { printCurrentBrew } from '../../../shared/helpers.js';
|
||||
import { printCurrentBrew } from '@shared/helpers.js';
|
||||
|
||||
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'>
|
||||
get PDF
|
||||
{printing ? 'loading' : 'get PDF'}
|
||||
</Nav.item>;
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ const getRedditLink = (brew)=>{
|
||||
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.item color='teal' icon='fas fa-share-alt'>
|
||||
share
|
||||
@@ -28,6 +28,12 @@ export default ({ brew })=>(
|
||||
<Nav.item color='blue' onClick={()=>{navigator.clipboard.writeText(`${global.config.baseUrl}/share/${getShareId(brew)}`);}}>
|
||||
copy url
|
||||
</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'>
|
||||
post to reddit
|
||||
</Nav.item>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
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 = '';
|
||||
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import React, { useState, useEffect, useEffectEvent, useRef } from 'react';
|
||||
import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
|
||||
import _ from 'lodash';
|
||||
import { hbfm } from 'marked-hbfm';
|
||||
import { md5 } from 'hash-wasm';
|
||||
import { makePatches, stringifyPatches } from '@sanity/diff-match-patch';
|
||||
|
||||
import Headtags from '@vitreum/headtags.js';
|
||||
import SplitPane from '@components/splitPane/splitPane.jsx';
|
||||
import Editor from '../../../editor/editor.jsx';
|
||||
import BrewRenderer from '../../../brewRenderer/brewRenderer.jsx';
|
||||
import LockNotification from '../../editPage/lockNotification/lockNotification.jsx';
|
||||
const Meta = Headtags.Meta;
|
||||
|
||||
//===---- Navbar
|
||||
import Nav from '@navbar/nav.jsx';
|
||||
import Navbar from '@navbar/navbar.jsx';
|
||||
import NewBrewItem from '@navbar/newbrew.navitem.jsx';
|
||||
import AccountNavItem from '@navbar/account.navitem.jsx';
|
||||
import ErrorNavItem from '@navbar/error-navitem.jsx';
|
||||
import HelpNavItem from '@navbar/help.navitem.jsx';
|
||||
import VaultNavItem from '@navbar/vault.navitem.jsx';
|
||||
import PrintNavItem from '@navbar/print.navitem.jsx';
|
||||
import ShareNavItem from '@navbar/share.navitem.jsx';
|
||||
import RecentNavItems from '@navbar/recent.navitem.jsx';
|
||||
const { both: RecentNavItem } = RecentNavItems;
|
||||
import googleDriveIcon from '../../../googleDrive.svg';
|
||||
|
||||
const AUTOSAVE_KEY = 'HB_editor_autoSaveOn';
|
||||
const BREWKEY = 'HB_newPage_content';
|
||||
const STYLEKEY = 'HB_newPage_style';
|
||||
const SNIPKEY = 'HB_newPage_snippets';
|
||||
const METAKEY = 'HB_newPage_meta';
|
||||
const SAVEKEY_PREFIX = 'HB_editor_defaultSave_';
|
||||
|
||||
const SAVE_TIMEOUT = 10000; //Autosave 10 seconds after last change
|
||||
const UNSAVED_WARNING_TIMEOUT = 900000; //Warn user afer 15 minutes of unsaved changes
|
||||
const UNSAVED_WARNING_POPUP_TIMEOUT = 4000; //Show the warning for 4 seconds
|
||||
|
||||
const EditorPage = (props)=>{
|
||||
const {
|
||||
brew,
|
||||
useLocalStorage,
|
||||
sandbox,
|
||||
showFloatingButtons,
|
||||
save,
|
||||
onSaveSuccess,
|
||||
pageName,
|
||||
showEditorButtons,
|
||||
userThemes = {}
|
||||
} = props;
|
||||
|
||||
const [currentBrew, setCurrentBrew] = useState(brew);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [lastSavedTime, setLastSavedTime] = useState(new Date());
|
||||
const [autoSaveEnabled, setAutoSaveEnabled] = useState(!sandbox);
|
||||
const [warnUnsavedChanges, setWarnUnsavedChanges] = useState(true);
|
||||
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
||||
const [themeBundle, setThemeBundle] = useState({});
|
||||
const [HTMLErrors, setHTMLErrors] = useState(hbfm.validate(currentBrew.text));
|
||||
const [saveGoogle, setSaveGoogle] = useState(currentBrew.googleId);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const [alertTrashedGoogleBrew, setAlertTrashedGoogleBrew] = useState(currentBrew.trashed);
|
||||
const [alertNoGoogleToTransfer, setAlertNoGoogleToTransfer] = useState(false);
|
||||
const [alertOwnershipToTransfer, setAlertOwnershipToTransfer] = useState(false);
|
||||
const [confirmGoogleTransfer, setConfirmGoogleTransfer] = useState(false);
|
||||
|
||||
const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1);
|
||||
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
|
||||
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
|
||||
|
||||
const unsavedChangesRef = useRef(unsavedChanges); // onBeforeUnload lives outside React and needs ref to unsavedChanges
|
||||
const warnUnsavedTimeout = useRef(null); // timers live outside React and need ref to consistently track time
|
||||
const lastSavedBrew = useRef(_.cloneDeep(currentBrew));
|
||||
const saveTimeout = useRef(null);
|
||||
const editorRef = useRef(null);
|
||||
|
||||
//==--------- Page setup ----------==//
|
||||
useEffect(()=>{
|
||||
if(useLocalStorage) loadBrewFromLocalStorage();
|
||||
const autoSavePref = !sandbox && JSON.parse(localStorage.getItem(AUTOSAVE_KEY) ?? true);
|
||||
setAutoSaveEnabled(autoSavePref);
|
||||
setWarnUnsavedChanges(!autoSavePref);
|
||||
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
|
||||
|
||||
const handleControlKeys = (e)=>{
|
||||
if(!(e.ctrlKey || e.metaKey)) return;
|
||||
if(e.keyCode === 83) trySave(true, true, saveGoogle);
|
||||
if(e.keyCode === 80) printCurrentBrew();
|
||||
if([83, 80].includes(e.keyCode)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleControlKeys);
|
||||
window.onbeforeunload = ()=>{
|
||||
if(unsavedChangesRef.current)
|
||||
return 'You have unsaved changes!';
|
||||
};
|
||||
return ()=>{
|
||||
document.removeEventListener('keydown', handleControlKeys);
|
||||
window.onbeforeunload = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadBrewFromLocalStorage = ()=>{
|
||||
const brew = { ...currentBrew };
|
||||
if(!brew.shareId && typeof window !== 'undefined') { //Load from localStorage if in client browser
|
||||
const brewStorage = localStorage.getItem(BREWKEY);
|
||||
const styleStorage = localStorage.getItem(STYLEKEY);
|
||||
const snipStorage = localStorage.getItem(SNIPKEY);
|
||||
const metaStorage = JSON.parse(localStorage.getItem(METAKEY));
|
||||
|
||||
brew.title = metaStorage?.title ?? brew.title;
|
||||
brew.description = metaStorage?.description ?? brew.description;
|
||||
brew.tags = metaStorage?.tags ?? brew.tags;
|
||||
brew.text = brewStorage ?? brew.text;
|
||||
brew.style = styleStorage ?? brew.style;
|
||||
brew.snippets = snipStorage ?? brew.snippets;
|
||||
brew.renderer = metaStorage?.renderer ?? brew.renderer;
|
||||
brew.theme = metaStorage?.theme ?? brew.theme;
|
||||
brew.lang = metaStorage?.lang ?? brew.lang;
|
||||
}
|
||||
|
||||
const SAVEKEY = `${SAVEKEY_PREFIX}${global.account?.username}`;
|
||||
const saveStorage = localStorage.getItem(SAVEKEY) || 'HOMEBREWERY';
|
||||
|
||||
setCurrentBrew(brew);
|
||||
lastSavedBrew.current = brew;
|
||||
setSaveGoogle(saveStorage == 'GOOGLE-DRIVE' && saveGoogle);
|
||||
|
||||
localStorage.setItem(BREWKEY, brew.text);
|
||||
if(brew.style) localStorage.setItem(STYLEKEY, brew.style);
|
||||
if(brew.snippets) localStorage.setItem(SNIPKEY, brew.snippets);
|
||||
localStorage.setItem(METAKEY, JSON.stringify({ title: brew.title, description: brew.description, tags: brew.tags, renderer: brew.renderer, theme: brew.theme, lang: brew.lang }));
|
||||
if(window.location.pathname !== '/new')
|
||||
window.history.replaceState({}, window.location.title, '/new/');
|
||||
};
|
||||
|
||||
const clearLocalStorage = ()=>{
|
||||
localStorage.removeItem(BREWKEY);
|
||||
localStorage.removeItem(STYLEKEY);
|
||||
localStorage.removeItem(SNIPKEY);
|
||||
localStorage.removeItem(METAKEY);
|
||||
};
|
||||
|
||||
//======----- Check for unsaved changes and autosave if enabled -----======
|
||||
useEffect(()=>{
|
||||
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
|
||||
setUnsavedChanges(hasChange);
|
||||
unsavedChangesRef.current = hasChange;
|
||||
|
||||
if(autoSaveEnabled) trySave(false, hasChange, saveGoogle);
|
||||
}, [currentBrew]);
|
||||
|
||||
const resetWarnUnsavedTimer = ()=>{
|
||||
setTimeout(()=>setWarnUnsavedChanges(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds
|
||||
clearTimeout(warnUnsavedTimeout.current);
|
||||
warnUnsavedTimeout.current = setTimeout(()=>setWarnUnsavedChanges(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved work warnings
|
||||
};
|
||||
|
||||
const handleSplitMove = ()=>{
|
||||
editorRef.current.update();
|
||||
};
|
||||
|
||||
const handleBrewChange = (field)=>(value, subfield)=>{ //'text', 'style', 'snippets', 'metadata'
|
||||
if(subfield == 'renderer' || subfield == 'theme')
|
||||
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
|
||||
|
||||
//If there are HTML errors, run the validator on every change to give quick feedback
|
||||
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
|
||||
setHTMLErrors(hbfm.validate(value));
|
||||
|
||||
if(field == 'metadata') setCurrentBrew((prev)=>({ ...prev, ...value }));
|
||||
else setCurrentBrew((prev)=>({ ...prev, [field]: value }));
|
||||
|
||||
if(useLocalStorage) {
|
||||
if(field == 'text') localStorage.setItem(BREWKEY, value);
|
||||
if(field == 'style') localStorage.setItem(STYLEKEY, value);
|
||||
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
|
||||
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
|
||||
title : value.title,
|
||||
description : value.description,
|
||||
tags : value.tags,
|
||||
renderer : value.renderer,
|
||||
theme : value.theme,
|
||||
lang : value.lang
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAutoSave = ()=>{
|
||||
clearTimeout(warnUnsavedTimeout.current);
|
||||
clearTimeout(saveTimeout.current);
|
||||
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(!autoSaveEnabled));
|
||||
setAutoSaveEnabled(!autoSaveEnabled);
|
||||
setWarnUnsavedChanges(autoSaveEnabled);
|
||||
};
|
||||
|
||||
const clearError = ()=>{
|
||||
setError(null);
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
const trySave = useEffectEvent((forceSave = false, hasChanges = true, saveToGoogle = false)=>{
|
||||
clearTimeout(saveTimeout.current);
|
||||
if(isSaving) return;
|
||||
if(!forceSave && !hasChanges) return;
|
||||
const newTimeout = forceSave ? 0 : SAVE_TIMEOUT;
|
||||
|
||||
saveTimeout.current = setTimeout(async ()=>{
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
setHTMLErrors(hbfm.validate(currentBrew.text));
|
||||
|
||||
//Prepare content to send to server
|
||||
const snapshotBrewBeforeSave = currentBrew;
|
||||
const brewToSave = {
|
||||
...currentBrew,
|
||||
text : currentBrew.text.normalize('NFC'),
|
||||
pageCount : ((currentBrew.renderer === 'legacy' ? currentBrew.text.match(/\\page/g) : currentBrew.text.match(/^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/gm)) || []).length + 1,
|
||||
patches : stringifyPatches(makePatches(encodeURI(lastSavedBrew.current.text.normalize('NFC')), encodeURI(currentBrew.text.normalize('NFC')))),
|
||||
hash : await md5(lastSavedBrew.current.text.normalize('NFC')),
|
||||
textBin : undefined,
|
||||
version : lastSavedBrew.current.version
|
||||
};
|
||||
|
||||
const savedBrew = await save(brewToSave, saveToGoogle)
|
||||
.catch((err)=>{
|
||||
console.error('Error Updating Local Brew');
|
||||
setError(err);
|
||||
});
|
||||
if(savedBrew) {
|
||||
lastSavedBrew.current = {
|
||||
...snapshotBrewBeforeSave,
|
||||
...savedBrew
|
||||
};
|
||||
|
||||
setCurrentBrew((prevBrew)=>({
|
||||
...prevBrew,
|
||||
...savedBrew
|
||||
}));
|
||||
|
||||
if(useLocalStorage) clearLocalStorage();
|
||||
setLastSavedTime(new Date());
|
||||
if(!autoSaveEnabled) resetWarnUnsavedTimer();
|
||||
onSaveSuccess(savedBrew);
|
||||
}
|
||||
setIsSaving(false);
|
||||
}, newTimeout);
|
||||
});
|
||||
|
||||
const renderSaveButton = ()=>{
|
||||
if(isSaving)
|
||||
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
|
||||
|
||||
if(unsavedChanges && warnUnsavedChanges) {
|
||||
resetWarnUnsavedTimer();
|
||||
const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
|
||||
const text = elapsedTime === 0
|
||||
? `Autosave is OFF${sandbox ? ' for this sandbox page' : ''}.`
|
||||
: `Autosave is OFF${sandbox ? ' for this sandbox page' : ''}, and you haven't saved for ${elapsedTime} minutes.`;
|
||||
|
||||
return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
|
||||
Reminder...
|
||||
<div className='errorContainer'>{text}</div>
|
||||
</Nav.item>;
|
||||
}
|
||||
|
||||
if(unsavedChanges)
|
||||
return <Nav.item className='save' onClick={()=>trySave(true, true, saveGoogle)} color='blue' icon='fas fa-save'>save now</Nav.item>;
|
||||
|
||||
if(autoSaveEnabled)
|
||||
return <Nav.item className='save saved'>auto-saved</Nav.item>;
|
||||
|
||||
if(sandbox)
|
||||
return <Nav.item className='save neverSaved' disabled={true}>save now</Nav.item>;
|
||||
|
||||
return <Nav.item className='save saved'>saved</Nav.item>;
|
||||
};
|
||||
|
||||
//TODO: Candidate for refactor/rename; used with history tool to load previous verion; may overlap with snippet injection or handleBrewChange
|
||||
const updateBrew = (newData)=>setCurrentBrew((prevBrew)=>({
|
||||
...prevBrew,
|
||||
style : newData.style,
|
||||
text : newData.text,
|
||||
snippets : newData.snippets
|
||||
}));
|
||||
|
||||
//======----- Google Toggle Button -----======
|
||||
const closeAlerts = (e)=>{
|
||||
e.stopPropagation(); //Only handle click once so alert doesn't reopen
|
||||
setAlertTrashedGoogleBrew(false);
|
||||
setAlertNoGoogleToTransfer(false);
|
||||
setConfirmGoogleTransfer(false);
|
||||
setAlertOwnershipToTransfer(false);
|
||||
};
|
||||
|
||||
const handleGoogleClick = ()=>{
|
||||
if(currentBrew.authors.length > 0 && global.account?.username !== currentBrew.authors[0]) {
|
||||
setAlertOwnershipToTransfer(true);
|
||||
return;
|
||||
}
|
||||
if(!global.account?.googleId) {
|
||||
setAlertNoGoogleToTransfer(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setConfirmGoogleTransfer((prev)=>!prev);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const toggleGoogleStorage = (e)=>{
|
||||
closeAlerts(e);
|
||||
const newSaveGoogle = !saveGoogle;
|
||||
setSaveGoogle((prev)=>!prev);
|
||||
setError(null);
|
||||
trySave(true, true, newSaveGoogle);
|
||||
};
|
||||
|
||||
const renderGoogleDriveIcon = ()=>(
|
||||
<Nav.item className='googleDriveStorage' onClick={handleGoogleClick}>
|
||||
<img src={googleDriveIcon} className={saveGoogle ? '' : 'inactive'} alt='Google Drive icon' />
|
||||
|
||||
{alertOwnershipToTransfer && (
|
||||
<div className='errorContainer'>
|
||||
You must be the Owner to transfer between the Homebrewery and Google Drive!
|
||||
The owner of this file is {currentBrew.authors[0]}.
|
||||
<br></br>
|
||||
<div className='confirm' onClick={closeAlerts}> Okay </div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{alertNoGoogleToTransfer && (
|
||||
<div className='errorContainer'>
|
||||
You must be signed in to a Google account to transfer between the Homebrewery and Google Drive!
|
||||
<a target='_blank' rel='noopener noreferrer' href={`https://www.naturalcrit.com/login?redirect=${window.location.href}`}>
|
||||
<div className='confirm' onClick={closeAlerts}> Sign In </div>
|
||||
</a>
|
||||
<div className='deny' onClick={closeAlerts}> Not Now </div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{alertTrashedGoogleBrew && (
|
||||
<div className='errorContainer'>
|
||||
This brew is currently in your Trash folder on Google Drive!<br />
|
||||
If you want to keep it, make sure to move it before it is deleted permanently!<br />
|
||||
<div className='confirm' onClick={toggleGoogleStorage}> Save my brew </div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmGoogleTransfer && (
|
||||
<div className='errorContainer'>
|
||||
{saveGoogle
|
||||
? 'Would you like to transfer this brew from your Google Drive storage back to the Homebrewery?'
|
||||
: 'Would you like to transfer this brew from the Homebrewery to your personal Google Drive storage?'}
|
||||
<br />
|
||||
<div className='confirm' onClick={toggleGoogleStorage}> Yes </div>
|
||||
<div className='deny' onClick={closeAlerts}> No </div>
|
||||
</div>
|
||||
)}
|
||||
</Nav.item>
|
||||
);
|
||||
|
||||
//======----- Navbar -----======
|
||||
const renderNavbar = ()=>(
|
||||
<Navbar>
|
||||
<Nav.section>
|
||||
<Nav.item className='brewTitle'>{currentBrew.title}</Nav.item>
|
||||
</Nav.section>
|
||||
<Nav.section>
|
||||
{(pageName == 'editPage') && renderGoogleDriveIcon()}
|
||||
{error
|
||||
? <ErrorNavItem error={error} clearError={clearError} />
|
||||
: <Nav.dropdown className='save-menu'>
|
||||
{renderSaveButton()}
|
||||
{(pageName == 'editPage') && renderAutoSaveButton()}
|
||||
</Nav.dropdown>}
|
||||
<NewBrewItem />
|
||||
<PrintNavItem />
|
||||
<HelpNavItem />
|
||||
<VaultNavItem />
|
||||
{(pageName == 'editPage') && <ShareNavItem brew={currentBrew} currentPage={currentBrewRendererPageNum} />}
|
||||
<RecentNavItem brew={currentBrew} storageKey={(pageName == 'editPage') ? 'edit' : undefined} />
|
||||
<AccountNavItem />
|
||||
</Nav.section>
|
||||
</Navbar>
|
||||
);
|
||||
|
||||
const renderAutoSaveButton = ()=>(
|
||||
<Nav.item onClick={toggleAutoSave}>
|
||||
Autosave <i className={autoSaveEnabled ? 'fas fa-power-off active' : 'fas fa-power-off'}></i>
|
||||
</Nav.item>
|
||||
);
|
||||
|
||||
const renderFloatingSaveButtons = ()=>(
|
||||
<>
|
||||
<div className={`floatingSaveButton${unsavedChanges ? ' show' : ''}`} onClick={()=>trySave(true, true, saveGoogle)}>
|
||||
Save current <i className='fas fa-save' />
|
||||
</div>
|
||||
<a href='/new' className='floatingNewButton'>
|
||||
Create your own <i className='fas fa-magic' />
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className= {`${pageName} sitePage`}>
|
||||
<Meta name='google-site-verification' content='NwnAQSSJZzAT7N-p5MY6ydQ7Njm67dtbu73ZSyE5Fy4' />
|
||||
{(pageName == 'editPage') && <Meta name='robots' content='noindex, nofollow' />}
|
||||
{renderNavbar()}
|
||||
{currentBrew.lock && <LockNotification shareId={currentBrew.shareId} message={currentBrew.lock.editMessage} reviewRequested={currentBrew.lock.reviewRequested}/>}
|
||||
<div className='content'>
|
||||
<SplitPane onDragFinish={handleSplitMove}>
|
||||
<Editor
|
||||
ref={editorRef}
|
||||
brew={currentBrew}
|
||||
onBrewChange={handleBrewChange}
|
||||
reportError={setError}
|
||||
renderer={currentBrew.renderer}
|
||||
userThemes={userThemes}
|
||||
showEditButtons={showEditorButtons}
|
||||
themeBundle={themeBundle}
|
||||
updateBrew={updateBrew}
|
||||
onCursorPageChange={setCurrentEditorCursorPageNum}
|
||||
onViewPageChange={setCurrentEditorViewPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
/>
|
||||
<BrewRenderer
|
||||
lang={currentBrew.lang}
|
||||
text={currentBrew.text}
|
||||
style={currentBrew.style}
|
||||
renderer={currentBrew.renderer}
|
||||
themeBundle={themeBundle}
|
||||
errors={HTMLErrors}
|
||||
onPageChange={setCurrentBrewRendererPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
allowPrint={true} //TODO: candidate for cleanup in brewRenderer. Homepage only place where allowPrint = false. Vote OK to allow print everywhere
|
||||
/>
|
||||
</SplitPane>
|
||||
</div>
|
||||
{showFloatingButtons && renderFloatingSaveButtons()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorPage;
|
||||
@@ -1,10 +1,11 @@
|
||||
import './brewItem.less';
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import moment from 'moment';
|
||||
import request from '../../../../utils/request-middleware.js';
|
||||
|
||||
import googleDriveIcon from '../../../../googleDrive.svg';
|
||||
import homebreweryIcon from '../../../../thumbnail.svg';
|
||||
import homebreweryIconWhite from '@themes/assets/naturalCritLogoWhite.svg';
|
||||
import homebreweryIconBlack from '@themes/assets/naturalCritLogoBlack.svg';
|
||||
import dedent from 'dedent';
|
||||
|
||||
const BrewItem = ({
|
||||
@@ -16,9 +17,12 @@ const BrewItem = ({
|
||||
},
|
||||
updateListFilter = ()=>{},
|
||||
reportError = ()=>{},
|
||||
theme,
|
||||
renderStorage = true,
|
||||
})=>{
|
||||
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
|
||||
const deleteBrew = useCallback(()=>{
|
||||
if(brew.authors.length <= 1) {
|
||||
if(!window.confirm('Are you sure you want to delete this brew? Because you are the only owner of this brew, the document will be deleted permanently.')) return;
|
||||
@@ -39,9 +43,9 @@ const BrewItem = ({
|
||||
if(!brew.editId) return null;
|
||||
|
||||
return (
|
||||
<a className='deleteLink' onClick={deleteBrew}>
|
||||
<i className='fas fa-trash-alt' title='Delete' />
|
||||
</a>
|
||||
<button aria-label={`Delete ${brew.title}`} className='deleteLink' onClick={deleteBrew}>
|
||||
<i className='fas fa-trash-alt' aria-hidden='true' title='Delete' />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -52,7 +56,7 @@ const BrewItem = ({
|
||||
if(brew.googleId && !brew.stubbed) editLink = brew.googleId + editLink;
|
||||
|
||||
return (
|
||||
<a className='editLink' href={`/edit/${editLink}`} target='_blank' rel='noopener noreferrer'>
|
||||
<a className='editLink' href={`/edit/${editLink}`} aria-label={`Edit ${brew.title}`} target='_blank' rel='noopener noreferrer'>
|
||||
<i className='fas fa-pencil-alt' title='Edit' />
|
||||
</a>
|
||||
);
|
||||
@@ -67,7 +71,7 @@ const BrewItem = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<a className='shareLink' href={`/share/${shareLink}`} target='_blank' rel='noopener noreferrer'>
|
||||
<a className='shareLink' href={`/share/${shareLink}`} aria-label={`Share ${brew.title}`} target='_blank' rel='noopener noreferrer'>
|
||||
<i className='fas fa-share-alt' title='Share' />
|
||||
</a>
|
||||
);
|
||||
@@ -82,7 +86,7 @@ const BrewItem = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<a className='downloadLink' href={`/download/${shareLink}`}>
|
||||
<a className='downloadLink' aria-label={`Download ${brew.title}`} href={`/download/${shareLink}`}>
|
||||
<i className='fas fa-download' title='Download' />
|
||||
</a>
|
||||
);
|
||||
@@ -92,17 +96,16 @@ const BrewItem = ({
|
||||
if(!renderStorage) return null;
|
||||
if(brew.googleId) {
|
||||
return (
|
||||
<span title={brew.webViewLink ? 'Your Google Drive Storage' : 'Another User\'s Google Drive Storage'}>
|
||||
<span className='storage' title={brew.webViewLink ? 'Your Google Drive Storage' : 'Another User\'s Google Drive Storage'}>
|
||||
<a href={brew.webViewLink} target='_blank'>
|
||||
<img className='googleDriveIcon' src={googleDriveIcon} alt='googleDriveIcon' />
|
||||
<img className='googleDriveIcon' src={googleDriveIcon} alt='Google Drive Storage' />
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span title='Homebrewery Storage'>
|
||||
<img className='homebreweryIcon' src={homebreweryIcon} alt='homebreweryIcon' />
|
||||
<span className='storage' title='Homebrewery Storage'>
|
||||
<img className='homebreweryIcon' src={theme === 'light' ? homebreweryIconBlack : homebreweryIconWhite} alt='Homebrewery Storage' />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -117,13 +120,12 @@ const BrewItem = ({
|
||||
const dateFormatString = 'YYYY-MM-DD HH:mm:ss';
|
||||
|
||||
return (
|
||||
<div className='brewItem'>
|
||||
<div className={`brewItem${detailsOpen ? ' detailsOpen' : ''}`} onClick={() => setDetailsOpen(!detailsOpen)} >
|
||||
{brew.thumbnail && <div className='thumbnail' style={{ backgroundImage: `url(${brew.thumbnail})` }}></div>}
|
||||
<div className='text'>
|
||||
<h2>{brew.title}</h2>
|
||||
<h2 title={brew.title}>{brew.title}</h2>
|
||||
<p className='description'>{brew.description}</p>
|
||||
</div>
|
||||
<hr />
|
||||
<div className='info'>
|
||||
{brew.tags?.length ? (
|
||||
<div className='brewTags' title={`${brew.tags.length} tags:\n${brew.tags.join('\n')}`}>
|
||||
@@ -134,7 +136,7 @@ const BrewItem = ({
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<span title={`Authors:\n${brew.authors?.join('\n')}`}>
|
||||
<div className='brewAuthors' title={`Authors:\n${brew.authors?.join('\n')}`}>
|
||||
<i className='fas fa-user' />{' '}
|
||||
{brew.authors?.map((author, index)=>(
|
||||
<React.Fragment key={index}>
|
||||
@@ -146,21 +148,22 @@ const BrewItem = ({
|
||||
{index < brew.authors.length - 1 && ', '}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
<br />
|
||||
<span title={`Last viewed: ${moment(brew.lastViewed).local().format(dateFormatString)}`}>
|
||||
<i className='fas fa-eye' /> {brew.views}
|
||||
</span>
|
||||
</div>
|
||||
{brew.pageCount && (
|
||||
<span title={`Page count: ${brew.pageCount}`}>
|
||||
<i className='far fa-file' /> {brew.pageCount}
|
||||
<span className='brewPages' aria-label={`${brew.pageCount} pages`} title={`Page count: ${brew.pageCount}`}>
|
||||
<span aria-hidden='true'><i className='far fa-file' /> {brew.pageCount}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className='lastViewed' aria-label={`Viewed ${brew.views} times`} title={`Last viewed: ${moment(brew.lastViewed).local().format(dateFormatString)}`}>
|
||||
<span aria-hidden='true'><i className='fas fa-eye' /> {brew.views}</span>
|
||||
</span>
|
||||
<span
|
||||
className='lastUpdated'
|
||||
aria-label={`Last updated ${moment(brew.updatedAt).fromNow()}`}
|
||||
title={dedent` Created: ${moment(brew.createdAt).local().format(dateFormatString)}
|
||||
Last updated: ${moment(brew.updatedAt).local().format(dateFormatString)}`}
|
||||
>
|
||||
<i className='fas fa-sync-alt' /> {moment(brew.updatedAt).fromNow()}
|
||||
<i className='fas fa-sync-alt' /><span aria-hidden='true'> {moment(brew.updatedAt).fromNow()}</span>
|
||||
</span>
|
||||
{renderStorageIcon()}
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
@import '@sharedStyles/core.less';
|
||||
@import '@themes/assets/assets.less';
|
||||
@import url('https://fonts.googleapis.com/css2?family=Alata&display=swap');
|
||||
|
||||
.brewItem {
|
||||
--bg-clr : #EEEEEE;
|
||||
--bg-image : @backgroundImageAlt;
|
||||
--text-clr : black;
|
||||
--text-shadow-clr : white;
|
||||
--hb-logo: url('@themes/assets/naturalCritLogoBlack.svg');
|
||||
|
||||
position : relative;
|
||||
box-sizing : border-box;
|
||||
display : inline-block;
|
||||
width : 48%;
|
||||
min-height : 105px;
|
||||
padding : 5px 15px 2px 6px;
|
||||
padding-right : 15px;
|
||||
margin-right : 15px;
|
||||
margin-bottom : 15px;
|
||||
display : flex;
|
||||
flex-direction : column;
|
||||
overflow : hidden;
|
||||
vertical-align : top;
|
||||
background-color : #CAB2802E;
|
||||
border : 1px solid #C9AD6A;
|
||||
border-radius : 5px;
|
||||
box-shadow : 0px 4px 5px 0px #333333;
|
||||
font-size : 15px;
|
||||
color : var(--text-clr);
|
||||
background-color : var(--bg-clr);
|
||||
background-image : var(--bg-image);
|
||||
background-size : cover;
|
||||
isolation : isolate;
|
||||
break-inside : avoid;
|
||||
-webkit-column-break-inside : avoid;
|
||||
page-break-inside : avoid;
|
||||
.thumbnail {
|
||||
position : absolute;
|
||||
top : 0;
|
||||
right : 0;
|
||||
right : 30px;
|
||||
z-index : -1;
|
||||
width : 150px;
|
||||
width : 155px;
|
||||
height : 100%;
|
||||
background-repeat : no-repeat;
|
||||
background-position : right top;
|
||||
@@ -33,17 +36,14 @@
|
||||
mask-image : linear-gradient(80deg, #00000000 20%, #005500 40%);
|
||||
}
|
||||
.text {
|
||||
min-height : 54px;
|
||||
h4 {
|
||||
margin-bottom : 5px;
|
||||
font-size : 2.2em;
|
||||
h2 {
|
||||
font-family : 'Alata';
|
||||
font-size : 1.25em;
|
||||
}
|
||||
}
|
||||
.info {
|
||||
position : initial;
|
||||
bottom : 2px;
|
||||
font-family : "ScalySansRemake";
|
||||
font-size : 1.2em;
|
||||
a { color : inherit; }
|
||||
font-size : 0.9em;
|
||||
& > span {
|
||||
margin-right : 12px;
|
||||
line-height : 1.5em;
|
||||
@@ -58,72 +58,404 @@
|
||||
font-weight : bold;
|
||||
white-space : nowrap;
|
||||
cursor : pointer;
|
||||
background-color : #C8AC6E3B;
|
||||
border : 1px solid #C8AC6E;
|
||||
border-color : currentColor;
|
||||
color:black;
|
||||
background : hsl(from currentColor h 20% 80%);
|
||||
border : 1px solid currentColor;
|
||||
border-radius : 4px;
|
||||
|
||||
&::before {
|
||||
margin-right : 3px;
|
||||
font-family : 'Font Awesome 6 Free';
|
||||
font-size : 12px;
|
||||
}
|
||||
--tag-base : #C8AC6E;
|
||||
--tag-type : #008000;
|
||||
--tag-group : #000000;
|
||||
--tag-meta : #000080;
|
||||
--tag-system : #800000;
|
||||
&.type {
|
||||
color : #008000;
|
||||
background-color : #0080003B;
|
||||
color : var(--tag-type);
|
||||
&::before { content : '\f0ad'; }
|
||||
}
|
||||
&.group {
|
||||
color : #000000;
|
||||
background-color : #5050503B;
|
||||
color : var(--tag-group);
|
||||
&::before { content : '\f500'; }
|
||||
}
|
||||
&.meta {
|
||||
color : #000080;
|
||||
background-color : #0000803B;
|
||||
color : var(--tag-meta);
|
||||
&::before { content : '\f05a'; }
|
||||
}
|
||||
&.system {
|
||||
color : #800000;
|
||||
background-color : #8000003B;
|
||||
color : var(--tag-system);
|
||||
&::before { content : '\f518'; }
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
.links { opacity : 1; }
|
||||
}
|
||||
&:nth-child(2n + 1) { margin-right : 0px; }
|
||||
.links {
|
||||
.animate(opacity);
|
||||
position : absolute;
|
||||
top : 0px;
|
||||
right : 0px;
|
||||
width : 2em;
|
||||
height : 100%;
|
||||
text-align : center;
|
||||
background-color : fade(black, 60%);
|
||||
opacity : 0;
|
||||
a {
|
||||
.animate(opacity);
|
||||
display : block;
|
||||
margin : 8px 0px;
|
||||
font-size : 1.3em;
|
||||
color : white;
|
||||
text-decoration : unset;
|
||||
opacity : 0.6;
|
||||
&:hover { opacity : 1; }
|
||||
i { cursor : pointer; }
|
||||
}
|
||||
}
|
||||
&:nth-child(2n + 1) { margin-right : 0; }
|
||||
.googleDriveIcon {
|
||||
padding : 0px;
|
||||
margin : -5px;
|
||||
height : 18px;
|
||||
padding : 0;
|
||||
margin : -5px;
|
||||
}
|
||||
.homebreweryIcon {
|
||||
position : relative;
|
||||
padding : 0px;
|
||||
top : 5px;
|
||||
left : -7.5px;
|
||||
height : 18px;
|
||||
padding : 0;
|
||||
}
|
||||
.links {
|
||||
position : absolute;
|
||||
right : 0;
|
||||
display : flex;
|
||||
height : 100%;
|
||||
a,
|
||||
button {
|
||||
display : block;
|
||||
align-content : center;
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
font-size : 1.3em;
|
||||
text-align : center;
|
||||
text-decoration : unset;
|
||||
cursor : pointer;
|
||||
&:hover { background-color : hsl(from currentColor h 30% 90%); }
|
||||
}
|
||||
.shareLink { color : @yellow; }
|
||||
.editLink { color : @green; }
|
||||
.downloadLink { color : @blueLight; }
|
||||
.deleteLink { color : @red; }
|
||||
}
|
||||
}
|
||||
|
||||
.darkMode .brewItem {
|
||||
--bg-clr : #444;
|
||||
--bg-image : @backgroundImageAltDark;
|
||||
--text-clr : #ddd;
|
||||
--text-shadow-clr : black;
|
||||
--hb-logo: url('@themes/assets/naturalCritLogoWhite.svg');
|
||||
}
|
||||
|
||||
.grid > .brewItem {
|
||||
justify-content : space-between;
|
||||
width : clamp(300px, 48%, 500px);
|
||||
min-height : 125px;
|
||||
padding : 7px 17px 7px 8px;
|
||||
padding-right : 30px;
|
||||
border-radius : 5px;
|
||||
box-shadow : 0 4px 5px 0 #333333;
|
||||
.text {
|
||||
min-height : 54px;
|
||||
h2 { margin-bottom : 5px; }
|
||||
.description { padding-left : 5px; }
|
||||
}
|
||||
.info { padding-left : 5px; }
|
||||
.links {
|
||||
top : 0;
|
||||
flex-direction : column;
|
||||
width : 2em;
|
||||
a,
|
||||
button {
|
||||
display : block;
|
||||
width : 100%;
|
||||
font-size : 1.1em;
|
||||
i { cursor : pointer; }
|
||||
}
|
||||
}
|
||||
}
|
||||
.card > .brewItem {
|
||||
gap : 5px;
|
||||
justify-content : flex-end;
|
||||
width : 300px;
|
||||
height : 400px;
|
||||
padding : 100px 10px 50px;
|
||||
background-image : var(--hb-logo), var(--bg-image);
|
||||
background-repeat : no-repeat;
|
||||
background-position : center 20%, center;
|
||||
background-size : 50%, cover;
|
||||
border : 3px solid;
|
||||
border-radius : 10px;
|
||||
box-shadow : 0 4px 5px #222222;
|
||||
&::before {
|
||||
position : absolute;
|
||||
right : 0;
|
||||
bottom : 0;
|
||||
left : 0;
|
||||
z-index : -1;
|
||||
display : block;
|
||||
height : 50%;
|
||||
content : '';
|
||||
background-image : linear-gradient(0deg, var(--bg-clr) 0, hsl(from var(--bg-clr) h s l / 0) 90%);
|
||||
transition : height @defaultDuration;
|
||||
}
|
||||
&:hover {
|
||||
&::before {
|
||||
height : 100%;
|
||||
background-image : linear-gradient(0deg, var(--bg-clr) 0, var(--bg-clr) 55%, hsl(from var(--bg-clr) h s l / 0) 65%);
|
||||
}
|
||||
.text .description { height : fit-content; }
|
||||
}
|
||||
.thumbnail {
|
||||
position : absolute;
|
||||
top : 0;
|
||||
left : 0;
|
||||
z-index : -2;
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
background-repeat : no-repeat;
|
||||
background-position : center top;
|
||||
background-size : cover;
|
||||
opacity : 1;
|
||||
mask-image : unset;
|
||||
}
|
||||
.text {
|
||||
filter : drop-shadow(0 1px 4px var(--text-shadow-clr));
|
||||
h2 { font-weight : 900; }
|
||||
.description {
|
||||
height : 0;
|
||||
overflow : hidden;
|
||||
transition : height @defaultDuration;
|
||||
}
|
||||
}
|
||||
.info {
|
||||
display : grid;
|
||||
grid-template-areas : 'tags tags tags tags'
|
||||
'authors authors authors authors'
|
||||
'pages views . .'
|
||||
'update update storage .';
|
||||
grid-template-columns : 1fr 1fr 1fr 30px;
|
||||
height : 110px;
|
||||
filter : drop-shadow(0 1px 2px var(--text-shadow-clr));
|
||||
.brewTags {
|
||||
grid-area : tags;
|
||||
color : inherit;
|
||||
i { color : inherit;}
|
||||
}
|
||||
.brewAuthors { grid-area : authors; }
|
||||
.brewPages { grid-area : pages; }
|
||||
.lastViewed { grid-area : views; }
|
||||
.lastUpdated { grid-area : update; }
|
||||
.storage { grid-area : storage; }
|
||||
}
|
||||
.links {
|
||||
bottom : 0;
|
||||
left : 0;
|
||||
height : 40px;
|
||||
}
|
||||
}
|
||||
.darkMode .card .brewItem {
|
||||
border-width:2px;
|
||||
}
|
||||
.list > .brewItem {
|
||||
display : grid;
|
||||
grid-template-columns : 35% 3fr 150px;
|
||||
gap : 10px;
|
||||
align-items : center;
|
||||
width : 100%;
|
||||
height : 50px;
|
||||
.thumbnail { display : none; }
|
||||
.text {
|
||||
padding-left : 10px;
|
||||
h2 {
|
||||
.ellipsis();
|
||||
align-content : center;
|
||||
min-height : 1.4em;
|
||||
}
|
||||
.description { display : none; }
|
||||
}
|
||||
.info {
|
||||
display : grid;
|
||||
grid-template-areas : 'tags authors pages views update storage';
|
||||
grid-template-columns : 2fr 150px 0.7fr 65px 1.5fr 20px;
|
||||
align-content : space-around;
|
||||
align-items : center;
|
||||
justify-content : end;
|
||||
width : clamp(400px, 100%, 650px);
|
||||
br { display : none; }
|
||||
span { display : inline-block; margin : 0; }
|
||||
.brewTags {
|
||||
display : inline-flex;
|
||||
flex-wrap : wrap;
|
||||
grid-area : tags;
|
||||
align-items : center;
|
||||
width : 100%;
|
||||
i, span { margin-right : 5px; }
|
||||
&:has(span:nth-of-type(3)) {
|
||||
position : relative;
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
overflow : hidden;
|
||||
span:nth-of-type(n + 3) { display : none; }
|
||||
}
|
||||
}
|
||||
.brewAuthors {
|
||||
.ellipsis();
|
||||
display : -webkit-box;
|
||||
grid-area : authors;
|
||||
align-content : center;
|
||||
width : 150px;
|
||||
height : 40px;
|
||||
padding-right : 5px;
|
||||
-webkit-line-clamp : 3;
|
||||
line-clamp : 3;
|
||||
-webkit-box-orient : vertical;
|
||||
&:has(a:only-of-type) { width : fit-content; }
|
||||
}
|
||||
.lastViewed {
|
||||
grid-area : views;
|
||||
min-width : 50px;
|
||||
}
|
||||
.brewPages {
|
||||
grid-area : pages;
|
||||
min-width : 40px;
|
||||
}
|
||||
.lastUpdated {
|
||||
display : flex;
|
||||
grid-area : update;
|
||||
gap : 5px;
|
||||
align-items : center;
|
||||
}
|
||||
.storage {
|
||||
grid-area : storage;
|
||||
filter : drop-shadow(0 0 10px var(--text-shadow-clr));
|
||||
.homebreweryIcon {
|
||||
top : 3px;
|
||||
left : -5px;
|
||||
height : 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.links {
|
||||
position : relative;
|
||||
a, button {
|
||||
align-content : center;
|
||||
height : 100%;
|
||||
text-align : center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//these media queries are just for the list layout and could be moved inside it, not sure it is a good idea
|
||||
@container (max-width : 1400px) {
|
||||
.brewCollection .brewGroup.list {
|
||||
.brewItem:not(.detailsOpen) {
|
||||
.info {
|
||||
grid-template-areas : 'tags update storage';
|
||||
grid-template-columns : 2fr 1fr 20px;
|
||||
width : clamp(200px, 100%, 650px);
|
||||
}
|
||||
.brewPages,
|
||||
.lastViewed,
|
||||
.brewAuthors { display : none; }
|
||||
}
|
||||
.brewItem {
|
||||
cursor : pointer;
|
||||
transition : height @defaultDuration;
|
||||
&:hover {
|
||||
box-shadow : 0 0 5px var(--bg-clr);
|
||||
filter : brightness(0.97);
|
||||
}
|
||||
}
|
||||
.detailsOpen {
|
||||
grid-template-columns : 25% 3fr 150px;
|
||||
height : 70px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@container (max-width : 1200px) {
|
||||
.brewCollection .brewGroup.list {
|
||||
.brewItem:not(.detailsOpen) {
|
||||
grid-template-columns : 35% 3fr 150px;
|
||||
.info {
|
||||
grid-template-areas : 'tags storage';
|
||||
grid-template-columns : 1fr 20px;
|
||||
}
|
||||
.brewAuthors,
|
||||
.lastUpdated { display : none; }
|
||||
}
|
||||
.detailsOpen {
|
||||
height : 100px;
|
||||
.info {
|
||||
grid-template-areas : 'tags views pages .'
|
||||
'authors update update storage';
|
||||
grid-template-rows : 1fr 1fr;
|
||||
grid-template-columns : 2fr 1fr 1fr 30px;
|
||||
width : clamp(200px, 100%, 650px);
|
||||
}
|
||||
.lastUpdated {
|
||||
.ellipsis();
|
||||
max-width : 120px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@container (max-width : 800px) {
|
||||
.brewCollection .brewGroup.list {
|
||||
.brewItem:not(.detailsOpen) {
|
||||
.info {
|
||||
grid-template-areas : 'tags';
|
||||
grid-template-columns : 1fr;
|
||||
width : clamp(200px, 100%, 650px);
|
||||
}
|
||||
.storage { display : none; }
|
||||
}
|
||||
.brewItem {
|
||||
h2 {
|
||||
width : 200px;
|
||||
font-size : 1em;
|
||||
}
|
||||
transition : height @defaultDuration;
|
||||
}
|
||||
.detailsOpen {
|
||||
display : grid;
|
||||
grid-template-columns : 1fr 4fr 30px;
|
||||
height : 100px;
|
||||
padding : 5px;
|
||||
.text {
|
||||
height : 100%;
|
||||
padding : 0;
|
||||
h2 {
|
||||
display : -webkit-box;
|
||||
height : fit-content;
|
||||
overflow : hidden;
|
||||
text-overflow : ellipsis;
|
||||
-webkit-line-clamp : 3;
|
||||
line-clamp : 3;
|
||||
white-space : wrap;
|
||||
-webkit-box-orient : vertical;
|
||||
}
|
||||
}
|
||||
.info {
|
||||
display : grid;
|
||||
grid-template-areas :
|
||||
'tags pages views views'
|
||||
'authors update update storage';
|
||||
grid-template-columns : 2fr 1fr 1fr 20px;
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
}
|
||||
.links {
|
||||
display : flex;
|
||||
flex-direction : column;
|
||||
gap : 5px;
|
||||
a,
|
||||
button { margin : 0; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@container (max-width : 650px) {
|
||||
.brewCollection .brewGroup.list {
|
||||
.brewItem:not(.detailsOpen) {
|
||||
grid-template-columns : 3fr 2fr;
|
||||
.info { display : none; }
|
||||
}
|
||||
.detailsOpen {
|
||||
.info {
|
||||
.lastUpdated {
|
||||
.ellipsis();
|
||||
width : 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,145 +1,140 @@
|
||||
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
|
||||
import './listPage.less';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import _ from 'lodash';
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import moment from 'moment';
|
||||
import _ from 'lodash';
|
||||
|
||||
import BrewItem from './brewItem/brewItem.jsx';
|
||||
|
||||
const USERPAGE_SORT_DIR = 'HB_listPage_sortDir';
|
||||
const USERPAGE_SORT_TYPE = 'HB_listPage_sortType';
|
||||
const USERPAGE_GROUP_VISIBILITY_PREFIX = 'HB_listPage_visibility_group';
|
||||
const USERPAGE_LAYOUT_MODE = 'HB_listPage_layout_mode';
|
||||
const USERPAGE_THEME_MODE = 'HB_listPage_theme_mode';
|
||||
|
||||
const DEFAULT_SORT_TYPE = 'alpha';
|
||||
const DEFAULT_SORT_DIR = 'asc';
|
||||
const DEFAULT_LAYOUT_MODE = 'grid';
|
||||
const DEFAULT_THEME_MODE = 'light';
|
||||
|
||||
const ListPage = createReactClass({
|
||||
displayName : 'ListPage',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
brewCollection : [
|
||||
{
|
||||
title : '',
|
||||
class : '',
|
||||
brews : []
|
||||
}
|
||||
],
|
||||
navItems : <></>,
|
||||
reportError : null
|
||||
const ListPage = ({ brewCollection = [{ title: '', class: '', brews: [] }], navItems = <></>, reportError = null, query })=>{
|
||||
const [filterString, setFilterString] = useState(query?.filter || '');
|
||||
const [filterTags, setFilterTags] = useState([]);
|
||||
const [sortType, setSortType] = useState(query?.sort || null);
|
||||
const [sortDir, setSortDir] = useState(query?.dir || null);
|
||||
const [groupVisibility, setGroupVisibility] = useState({});
|
||||
const [layoutMode, setLayoutMode] = useState(DEFAULT_LAYOUT_MODE);
|
||||
const [themeMode, setThemeMode] = useState(DEFAULT_THEME_MODE);
|
||||
|
||||
const groupVisibilityRef = useRef(groupVisibility);
|
||||
const sortTypeRef = useRef(sortType);
|
||||
const sortDirRef = useRef(sortDir);
|
||||
|
||||
useEffect(()=>{
|
||||
groupVisibilityRef.current = groupVisibility;
|
||||
}, [groupVisibility]);
|
||||
|
||||
useEffect(()=>{
|
||||
sortTypeRef.current = sortType;
|
||||
}, [sortType]);
|
||||
|
||||
useEffect(()=>{
|
||||
sortDirRef.current = sortDir;
|
||||
}, [sortDir]);
|
||||
|
||||
useEffect(()=>{
|
||||
window.onbeforeunload = saveToLocalStorage;
|
||||
if(typeof window === 'undefined') return;
|
||||
|
||||
const newSortType = sortType ?? (localStorage.getItem(USERPAGE_SORT_TYPE) || DEFAULT_SORT_TYPE);
|
||||
const newSortDir = sortDir ?? (localStorage.getItem(USERPAGE_SORT_DIR) || DEFAULT_SORT_DIR);
|
||||
updateUrl(filterString, newSortType, newSortDir);
|
||||
|
||||
const namedBrewCollection = brewCollection.reduce((visibility, brewGroup)=>{
|
||||
visibility[brewGroup.class] = (localStorage.getItem(`${USERPAGE_GROUP_VISIBILITY_PREFIX}_${brewGroup.class}`) ?? 'true') == 'true';
|
||||
return visibility;
|
||||
}, {});
|
||||
|
||||
const newLayoutMode = localStorage.getItem(USERPAGE_LAYOUT_MODE) || DEFAULT_LAYOUT_MODE;
|
||||
const newThemeMode = localStorage.getItem(USERPAGE_THEME_MODE) || DEFAULT_THEME_MODE;
|
||||
|
||||
setGroupVisibility(namedBrewCollection);
|
||||
setSortType(newSortType);
|
||||
setSortDir(newSortDir);
|
||||
setLayoutMode(newLayoutMode);
|
||||
setThemeMode(newThemeMode);
|
||||
|
||||
return ()=>{
|
||||
window.onbeforeunload = null;
|
||||
};
|
||||
},
|
||||
getInitialState : function() {
|
||||
// HIDE ALL GROUPS UNTIL LOADED
|
||||
const brewCollection = this.props.brewCollection.map((brewGroup)=>{
|
||||
brewGroup.visible = false;
|
||||
return brewGroup;
|
||||
}, []);
|
||||
|
||||
const saveToLocalStorage = ()=>{
|
||||
brewCollection.forEach((brewGroup)=>{
|
||||
localStorage.setItem(`${USERPAGE_GROUP_VISIBILITY_PREFIX}_${brewGroup.class}`, `${groupVisibilityRef.current[brewGroup.class]}`);
|
||||
});
|
||||
localStorage.setItem(USERPAGE_SORT_TYPE, sortTypeRef.current);
|
||||
localStorage.setItem(USERPAGE_SORT_DIR, sortDirRef.current);
|
||||
};
|
||||
|
||||
return {
|
||||
filterString : this.props.query?.filter || '',
|
||||
filterTags : [],
|
||||
sortType : this.props.query?.sort || null,
|
||||
sortDir : this.props.query?.dir || null,
|
||||
query : this.props.query,
|
||||
brewCollection : brewCollection
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount : function() {
|
||||
// SAVE TO LOCAL STORAGE WHEN LEAVING PAGE
|
||||
window.onbeforeunload = this.saveToLocalStorage;
|
||||
|
||||
// LOAD FROM LOCAL STORAGE
|
||||
if(typeof window !== 'undefined') {
|
||||
const newSortType = (this.state.sortType ?? (localStorage.getItem(USERPAGE_SORT_TYPE) || DEFAULT_SORT_TYPE));
|
||||
const newSortDir = (this.state.sortDir ?? (localStorage.getItem(USERPAGE_SORT_DIR) || DEFAULT_SORT_DIR));
|
||||
this.updateUrl(this.state.filterString, newSortType, newSortDir);
|
||||
|
||||
const brewCollection = this.props.brewCollection.map((brewGroup)=>{
|
||||
brewGroup.visible = (localStorage.getItem(`${USERPAGE_GROUP_VISIBILITY_PREFIX}_${brewGroup.class}`) ?? 'true')=='true';
|
||||
return brewGroup;
|
||||
});
|
||||
|
||||
this.setState({
|
||||
brewCollection : brewCollection,
|
||||
sortType : newSortType,
|
||||
sortDir : newSortDir
|
||||
});
|
||||
};
|
||||
},
|
||||
|
||||
componentWillUnmount : function() {
|
||||
window.onbeforeunload = function(){};
|
||||
},
|
||||
|
||||
saveToLocalStorage : function() {
|
||||
this.state.brewCollection.map((brewGroup)=>{
|
||||
localStorage.setItem(`${USERPAGE_GROUP_VISIBILITY_PREFIX}_${brewGroup.class}`, `${brewGroup.visible}`);
|
||||
});
|
||||
localStorage.setItem(USERPAGE_SORT_TYPE, this.state.sortType);
|
||||
localStorage.setItem(USERPAGE_SORT_DIR, this.state.sortDir);
|
||||
},
|
||||
|
||||
renderBrews : function(brews){
|
||||
const renderBrews = (brews)=>{
|
||||
if(!brews || !brews.length) return <div className='noBrews'>No Brews.</div>;
|
||||
|
||||
return _.map(brews, (brew, idx)=>{
|
||||
return <BrewItem brew={brew} key={idx} reportError={this.props.reportError} updateListFilter={ (tag)=>{ this.updateUrl(this.state.filterString, this.state.sortType, this.state.sortDir, tag); }}/>;
|
||||
});
|
||||
},
|
||||
return _.map(brews, (brew, idx)=>(
|
||||
<BrewItem
|
||||
brew={brew}
|
||||
key={idx}
|
||||
reportError={reportError}
|
||||
theme={themeMode}
|
||||
updateListFilter={(tag)=>{
|
||||
updateUrl(filterString, sortType, sortDir, tag);
|
||||
}}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
sortBrewOrder : function(brew){
|
||||
if(!brew.title){brew.title = 'No Title';}
|
||||
const sortBrewOrder = (brew)=>{
|
||||
const title = brew.title || 'No Title';
|
||||
const mapping = {
|
||||
'alpha' : _.deburr(brew.title.trim().toLowerCase()),
|
||||
'alpha' : _.deburr(title.trim().toLowerCase()),
|
||||
'created' : moment(brew.createdAt).format(),
|
||||
'updated' : moment(brew.updatedAt).format(),
|
||||
'views' : brew.views,
|
||||
'latest' : moment(brew.lastViewed).format()
|
||||
'latest' : moment(brew.lastViewed).format(),
|
||||
};
|
||||
return mapping[this.state.sortType];
|
||||
},
|
||||
return mapping[sortType];
|
||||
};
|
||||
|
||||
handleSortOptionChange : function(event){
|
||||
this.updateUrl(this.state.filterString, event.target.value, this.state.sortDir);
|
||||
this.setState({
|
||||
sortType : event.target.value
|
||||
});
|
||||
},
|
||||
const handleSortOptionChange = (event)=>{
|
||||
updateUrl(filterString, event.target.value, sortDir);
|
||||
setSortType(event.target.value);
|
||||
};
|
||||
|
||||
handleSortDirChange : function(event){
|
||||
const newDir = this.state.sortDir == 'asc' ? 'desc' : 'asc';
|
||||
const handleSortDirChange = (event)=>{
|
||||
const newDir = sortDir == 'asc' ? 'desc' : 'asc';
|
||||
|
||||
this.updateUrl(this.state.filterString, this.state.sortType, newDir);
|
||||
this.setState({
|
||||
sortDir : newDir
|
||||
});
|
||||
},
|
||||
updateUrl(filterString, sortType, newDir);
|
||||
setSortDir(newDir);
|
||||
};
|
||||
|
||||
renderSortOption : function(sortTitle, sortValue){
|
||||
return <div className={`sort-option ${(this.state.sortType == sortValue ? 'active' : '')}`}>
|
||||
<button
|
||||
value={`${sortValue}`}
|
||||
onClick={this.state.sortType == sortValue ? this.handleSortDirChange : this.handleSortOptionChange}
|
||||
>
|
||||
{`${sortTitle}`}
|
||||
</button>
|
||||
{this.state.sortType == sortValue &&
|
||||
<i className={`sortDir fas ${this.state.sortDir == 'asc' ? 'fa-sort-up' : 'fa-sort-down'}`}></i>
|
||||
}
|
||||
</div>;
|
||||
},
|
||||
const renderSortOption = (sortTitle, sortValue)=>{
|
||||
return (
|
||||
<label className={`option ${sortType == sortValue ? 'active' : ''}`}>
|
||||
<button value={`${sortValue}`} onClick={sortType == sortValue ? handleSortDirChange : handleSortOptionChange}>
|
||||
{`${sortTitle}`}
|
||||
</button>
|
||||
{sortType == sortValue && <i className={`sortDir fas ${sortDir == 'asc' ? 'fa-sort-up' : 'fa-sort-down'}`}></i>}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
handleFilterTextChange : function(e){
|
||||
this.setState({
|
||||
filterString : e.target.value,
|
||||
});
|
||||
this.updateUrl(e.target.value, this.state.sortType, this.state.sortDir);
|
||||
const handleFilterTextChange = (e)=>{
|
||||
setFilterString(e.target.value);
|
||||
updateUrl(e.target.value, sortType, sortDir);
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
updateUrl : function(filterTerm, sortType, sortDir, filterTag=''){
|
||||
const updateUrl = (filterTerm, sortType, sortDir, filterTag = '')=>{
|
||||
const url = new URL(window.location.href);
|
||||
const urlParams = new URLSearchParams(url.search);
|
||||
|
||||
@@ -148,135 +143,222 @@ const ListPage = createReactClass({
|
||||
|
||||
let filterTags = urlParams.getAll('tag');
|
||||
if(filterTag != '') {
|
||||
if(filterTags.findIndex((tag)=>{return tag.toLowerCase()==filterTag.toLowerCase();}) == -1){
|
||||
if(
|
||||
filterTags.findIndex((tag)=>{
|
||||
return tag.toLowerCase() == filterTag.toLowerCase();
|
||||
}) == -1
|
||||
) {
|
||||
filterTags.push(filterTag);
|
||||
} else {
|
||||
filterTags = filterTags.filter((tag)=>{ return tag.toLowerCase() != filterTag.toLowerCase(); });
|
||||
filterTags = filterTags.filter((tag)=>{
|
||||
return tag.toLowerCase() != filterTag.toLowerCase();
|
||||
});
|
||||
}
|
||||
}
|
||||
urlParams.delete('tag');
|
||||
// Add tags to URL in the order they were clicked
|
||||
filterTags.forEach((tag)=>{ urlParams.append('tag', tag); });
|
||||
filterTags.forEach((tag)=>urlParams.append('tag', tag));
|
||||
// Sort tags before updating state
|
||||
filterTags.sort((a, b)=>{
|
||||
return a.indexOf(':') - b.indexOf(':') != 0 ? a.indexOf(':') - b.indexOf(':') : a.toLowerCase().localeCompare(b.toLowerCase());
|
||||
});
|
||||
|
||||
this.setState({
|
||||
filterTags
|
||||
});
|
||||
setFilterTags(filterTags);
|
||||
|
||||
if(!filterTerm)
|
||||
urlParams.delete('filter');
|
||||
else
|
||||
urlParams.set('filter', filterTerm);
|
||||
if(!filterTerm) urlParams.delete('filter');
|
||||
else urlParams.set('filter', filterTerm);
|
||||
|
||||
url.search = urlParams;
|
||||
window.history.replaceState(null, null, url);
|
||||
},
|
||||
};
|
||||
|
||||
renderFilterOption : function(){
|
||||
return <div className='filter-option'>
|
||||
<label>
|
||||
<i className='fas fa-search'></i>
|
||||
<input
|
||||
type='search'
|
||||
placeholder='filter title/description'
|
||||
onChange={this.handleFilterTextChange}
|
||||
value={this.state.filterString}
|
||||
/>
|
||||
</label>
|
||||
</div>;
|
||||
},
|
||||
const renderFilterOption = ()=>{
|
||||
return (
|
||||
<div className='filter-option'>
|
||||
<label>
|
||||
<i className='fas fa-search'></i>
|
||||
<input type='search' placeholder='filter title/description/tags' onChange={handleFilterTextChange} value={filterString} />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderTagsOptions : function(){
|
||||
if(this.state.filterTags?.length == 0) return;
|
||||
return <div className='tags-container'>
|
||||
{_.map(this.state.filterTags, (tag, idx)=>{
|
||||
const matches = tag.match(/^(?:([^:]+):)?([^:]+)$/);
|
||||
return <span key={idx} className={matches[1]} onClick={()=>{ this.updateUrl(this.state.filterString, this.state.sortType, this.state.sortDir, tag); }}>{matches[2]}</span>;
|
||||
})}
|
||||
</div>;
|
||||
},
|
||||
const renderTagsOptions = ()=>{
|
||||
if(filterTags?.length == 0) return;
|
||||
return (
|
||||
<div className='tags-container'>
|
||||
Tags:
|
||||
{_.map(filterTags, (tag, idx)=>{
|
||||
const matches = tag.match(/^(?:([^:]+):)?([^:]+)$/);
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className={matches[1]}
|
||||
onClick={()=>{
|
||||
updateUrl(filterString, sortType, sortDir, tag);
|
||||
}}>
|
||||
{matches[2]}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderSortOptions : function(){
|
||||
return <div className='sort-container'>
|
||||
<h6>Sort by :</h6>
|
||||
{this.renderSortOption('Title', 'alpha')}
|
||||
{this.renderSortOption('Created Date', 'created')}
|
||||
{this.renderSortOption('Updated Date', 'updated')}
|
||||
{this.renderSortOption('Views', 'views')}
|
||||
{/* {this.renderSortOption('Latest', 'latest')} */}
|
||||
const renderSortOptions = ()=>{
|
||||
return (
|
||||
<div className='radio-toggle sort-container'>
|
||||
<h6>Sort by :</h6>
|
||||
{renderSortOption('Title', 'alpha')}
|
||||
{renderSortOption('Created Date', 'created')}
|
||||
{renderSortOption('Updated Date', 'updated')}
|
||||
{renderSortOption('Views', 'views')}
|
||||
{/* {renderSortOption('Latest', 'latest')} */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
{this.renderFilterOption()}
|
||||
</div>;
|
||||
},
|
||||
|
||||
getSortedBrews : function(brews){
|
||||
const testString = _.deburr(this.state.filterString).toLowerCase();
|
||||
const getSortedBrews = (brews)=>{
|
||||
const testString = _.deburr(filterString).toLowerCase();
|
||||
|
||||
brews = _.filter(brews, (brew)=>{
|
||||
// Filter by user entered text
|
||||
const brewStrings = _.deburr([
|
||||
brew.title,
|
||||
brew.description,
|
||||
brew.tags].join('\n')
|
||||
.toLowerCase());
|
||||
|
||||
const brewStrings = _.deburr([brew.title, brew.description, brew.tags].join('\n').toLowerCase());
|
||||
const filterTextTest = brewStrings.includes(testString);
|
||||
|
||||
// Filter by user selected tags
|
||||
let filterTagTest = true;
|
||||
if(this.state.filterTags.length > 0){
|
||||
filterTagTest = Array.isArray(brew.tags) && this.state.filterTags?.every((tag)=>{
|
||||
return brew.tags.findIndex((brewTag)=>{
|
||||
return brewTag.toLowerCase() == tag.toLowerCase();
|
||||
}) >= 0;
|
||||
});
|
||||
if(filterTags.length > 0) {
|
||||
filterTagTest =
|
||||
Array.isArray(brew.tags) &&
|
||||
filterTags?.every((tag)=>{
|
||||
return (
|
||||
brew.tags.findIndex((brewTag)=>{
|
||||
return brewTag.toLowerCase() == tag.toLowerCase();
|
||||
}) >= 0
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return filterTextTest && filterTagTest;
|
||||
});
|
||||
|
||||
return _.orderBy(brews, (brew)=>{ return this.sortBrewOrder(brew); }, this.state.sortDir);
|
||||
},
|
||||
return _.orderBy(
|
||||
brews,
|
||||
(brew)=>{
|
||||
return sortBrewOrder(brew);
|
||||
},
|
||||
sortDir,
|
||||
);
|
||||
};
|
||||
|
||||
toggleBrewCollectionState : function(brewGroupClass) {
|
||||
this.setState((prevState)=>({
|
||||
brewCollection : prevState.brewCollection.map(
|
||||
(brewGroup)=>brewGroup.class === brewGroupClass ? { ...brewGroup, visible: !brewGroup.visible } : brewGroup
|
||||
)
|
||||
}));
|
||||
},
|
||||
const sortedBrewCollection = useMemo(()=>{
|
||||
return brewCollection.map((brewGroup)=>({ ...brewGroup, brews: getSortedBrews(brewGroup.brews) }));
|
||||
}, [brewCollection, filterString, filterTags, sortType, sortDir]);
|
||||
|
||||
renderBrewCollection : function(brewCollection){
|
||||
if(brewCollection == []) return <div className='brewCollection'>
|
||||
<h1>No Brews</h1>
|
||||
</div>;
|
||||
return _.map(brewCollection, (brewGroup, idx)=>{
|
||||
return <div key={idx} className={`brewCollection ${brewGroup.class ?? ''}`}>
|
||||
<h1 className={brewGroup.visible ? 'active' : 'inactive'} onClick={()=>{this.toggleBrewCollectionState(brewGroup.class);}}>{brewGroup.title || 'No Title'}</h1>
|
||||
{brewGroup.visible ? this.renderBrews(this.getSortedBrews(brewGroup.brews)) : <></>}
|
||||
</div>;
|
||||
});
|
||||
},
|
||||
const toggleBrewCollectionState = (brewGroupClass)=>{
|
||||
setGroupVisibility((prevVisibility)=>({ ...prevVisibility, [brewGroupClass]: !prevVisibility[brewGroupClass] }));
|
||||
};
|
||||
|
||||
render : function(){
|
||||
return <div className='listPage sitePage'>
|
||||
{/*<style>@layer V3_5ePHB, bundle;</style>*/}
|
||||
<link href='/themes/V3/Blank/style.css' type='text/css' rel='stylesheet'/>
|
||||
<link href='/themes/V3/5ePHB/style.css' type='text/css' rel='stylesheet'/>
|
||||
{this.props.navItems}
|
||||
{this.renderSortOptions()}
|
||||
{this.renderTagsOptions()}
|
||||
|
||||
<div className='content V3'>
|
||||
<div className='page'>
|
||||
{this.renderBrewCollection(this.state.brewCollection)}
|
||||
const renderBrewCollection = (brewCollection)=>{
|
||||
if(brewCollection.length === 0)
|
||||
return (
|
||||
<div className='brewCollection'>
|
||||
<h1>No Brews</h1>
|
||||
</div>
|
||||
);
|
||||
return _.map(brewCollection, (brewGroup, idx)=>{
|
||||
const sortedBrewGroup = sortedBrewCollection[idx];
|
||||
const visible = groupVisibility[brewGroup.class];
|
||||
|
||||
return (<>
|
||||
<h1
|
||||
className={visible ? 'active' : 'inactive'}
|
||||
onClick={()=>{
|
||||
toggleBrewCollectionState(brewGroup.class);
|
||||
}}>
|
||||
{brewGroup.title || 'No Title'}
|
||||
</h1>
|
||||
<div key={idx} className={`brewGroup ${brewGroup.class ?? ''} ${layoutMode}`}>
|
||||
|
||||
{visible ? renderBrews(sortedBrewGroup.brews) : <></>}
|
||||
</div>
|
||||
</>);
|
||||
});
|
||||
};
|
||||
|
||||
const handleLayoutChange = (e, mode)=>{
|
||||
setLayoutMode(e.target.checked ? mode : DEFAULT_LAYOUT_MODE);
|
||||
localStorage.setItem(USERPAGE_LAYOUT_MODE, e.target.checked ? mode : DEFAULT_LAYOUT_MODE);
|
||||
return;
|
||||
};
|
||||
|
||||
const renderLayoutModeOptions = ()=>{
|
||||
return (
|
||||
<div className='radio-toggle layout-container'>
|
||||
<h6>View:</h6>
|
||||
<label className='option' title='grid'>
|
||||
<input name='layout-mode' type='radio' onChange={(e)=>handleLayoutChange(e, 'grid')} checked={layoutMode === 'grid'} />
|
||||
<i className='fac compressed-grid'></i>
|
||||
</label>
|
||||
<label className='option' title='list'>
|
||||
<input name='layout-mode' type='radio' onChange={(e)=>handleLayoutChange(e, 'list')} checked={layoutMode === 'list'} />
|
||||
<i className='fas fa-bars'></i>
|
||||
</label>
|
||||
<label className='option' title='card'>
|
||||
<input name='layout-mode' type='radio' onChange={(e)=>handleLayoutChange(e, 'card')} checked={layoutMode === 'card'} />
|
||||
<i className='fac format-card'></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
);
|
||||
};
|
||||
|
||||
const handleThemeChange = (e, mode)=>{
|
||||
setThemeMode(e.target.checked ? mode : DEFAULT_THEME_MODE);
|
||||
localStorage.setItem(USERPAGE_THEME_MODE, e.target.checked ? mode : DEFAULT_THEME_MODE);
|
||||
return;
|
||||
};
|
||||
const renderThemePicker = ()=>{
|
||||
return (
|
||||
<div className="radio-toggle theme-container">
|
||||
<h6>Theme:</h6>
|
||||
<label className="option">
|
||||
<input type="radio" name="theme" onChange={(e)=>handleThemeChange(e, 'light')} checked={themeMode === 'light'}/>
|
||||
<i className="fas fa-sun"/>
|
||||
</label>
|
||||
<label className="option">
|
||||
<input type="radio" name="theme" onChange={(e)=>handleThemeChange(e, 'dark')} checked={themeMode === 'dark'}/>
|
||||
<i className="fas fa-moon"/>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
};
|
||||
|
||||
const renderNav = () =>{
|
||||
|
||||
return (
|
||||
<div className="listPageNav">
|
||||
{renderLayoutModeOptions()}
|
||||
{renderThemePicker()}
|
||||
{renderSortOptions()}
|
||||
{renderFilterOption()}
|
||||
{renderTagsOptions()}
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='sitePage listPage'>
|
||||
{navItems}
|
||||
|
||||
{renderNav()}
|
||||
|
||||
<div className={`content V3 ${themeMode + 'Mode'}`}>
|
||||
<div className='brewCollection'>{renderBrewCollection(brewCollection)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListPage;
|
||||
|
||||
@@ -1,163 +1,242 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Alata&display=swap');
|
||||
@import '@sharedStyles/colors.less';
|
||||
@import '../../../../icons/customIcons.less';
|
||||
|
||||
.noColumns() {
|
||||
column-count : auto;
|
||||
column-fill : auto;
|
||||
column-gap : normal;
|
||||
-webkit-column-width : auto;
|
||||
-moz-column-width : auto;
|
||||
column-width : auto;
|
||||
-webkit-column-count : auto;
|
||||
-moz-column-count : auto;
|
||||
-webkit-column-width : auto;
|
||||
-moz-column-width : auto;
|
||||
column-count : auto;
|
||||
column-fill : auto;
|
||||
-webkit-column-gap : normal;
|
||||
-moz-column-gap : normal;
|
||||
column-gap : normal;
|
||||
height : auto;
|
||||
min-height : 279.4mm;
|
||||
margin : 20px auto;
|
||||
contain : unset;
|
||||
}
|
||||
.listPage {
|
||||
|
||||
.sitePage.listPage {
|
||||
--bg-clr : white;
|
||||
container-type: inline-size;
|
||||
.content {
|
||||
z-index : 1;
|
||||
.page {
|
||||
.noColumns() !important; //Needed to override PHB Theme since this is on a lower @layer
|
||||
&::after { display : none; }
|
||||
.noBrews {
|
||||
margin : 10px 0px;
|
||||
font-size : 1.3em;
|
||||
font-style : italic;
|
||||
padding : clamp(20px, 5vh, 200px) clamp(20px, 20cqi, 200px);
|
||||
overflow-y : scroll;
|
||||
scrollbar-gutter : stable;
|
||||
@container (max-width : 1200px) {
|
||||
padding : clamp(20px, 5vh, 200px) clamp(20px, 10cqi, 200px);
|
||||
}
|
||||
@container (max-width : 800px) {
|
||||
padding : clamp(20px, 5vh, 200px) clamp(20px, 5cqi, 200px);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width : 15px;
|
||||
&:horizontal {
|
||||
width : auto;
|
||||
height : 20px;
|
||||
}
|
||||
.brewCollection {
|
||||
h1:hover { cursor : pointer; }
|
||||
.active::before, .inactive::before {
|
||||
&-thumb {
|
||||
background : linear-gradient(90deg, var(--bg-clr) 12px, #00000000 12px);
|
||||
&:horizontal { background : linear-gradient(0deg, var(--bg-clr) 15px, #00000000 15px); }
|
||||
}
|
||||
&-corner { visibility : hidden; }
|
||||
}
|
||||
|
||||
.noBrews {
|
||||
margin : 10px 0px;
|
||||
font-size : 1.3em;
|
||||
font-style : italic;
|
||||
font-weight : 900;
|
||||
color : white;
|
||||
}
|
||||
.brewCollection {
|
||||
|
||||
h1 {
|
||||
width : fit-content;
|
||||
margin-bottom : 20px;
|
||||
font-size : 25px;
|
||||
color : #DDDDDD;
|
||||
|
||||
&.active::before,
|
||||
&.inactive::before {
|
||||
padding-right : 0.5em;
|
||||
font-family : 'Font Awesome 6 Free';
|
||||
font-size : 0.6cm;
|
||||
font-weight : 900;
|
||||
}
|
||||
.active { color : var(--HB_Color_HeaderText); }
|
||||
.active::before { content : '\f107'; }
|
||||
.inactive { color : #707070; }
|
||||
.inactive::before { content : '\f105'; }
|
||||
&.active {
|
||||
color : white;
|
||||
&::before { content : '\f107'; }
|
||||
}
|
||||
&.inactive::before { content : '\f105'; }
|
||||
}
|
||||
h1:hover { cursor : pointer; }
|
||||
|
||||
.brewGroup {
|
||||
margin-bottom : 20px;
|
||||
&.grid {
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
gap : 20px;
|
||||
justify-content : center;
|
||||
width : fit-content;
|
||||
min-width : 200px;
|
||||
}
|
||||
|
||||
&.list {
|
||||
display : flex;
|
||||
flex-direction : column;
|
||||
gap : 2px;
|
||||
}
|
||||
&.card {
|
||||
--columns : 4;
|
||||
display : grid;
|
||||
grid-template-columns : repeat(var(--columns), 1fr);
|
||||
gap : 20px;
|
||||
justify-items : center;
|
||||
|
||||
@container (max-width : 1700px) {--columns : 3;}
|
||||
@container (max-width : 1400px) {--columns : 2;}
|
||||
@container (max-width : 900px) {--columns : 1;}
|
||||
h1 { position : absolute; }
|
||||
|
||||
&:not(:has(.brewItem:last-child:nth-child(3n))) {
|
||||
.brewItem:last-child {
|
||||
margin-right : auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sort-container {
|
||||
position : sticky;
|
||||
top : 0;
|
||||
left : 0;
|
||||
z-index : 1;
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
row-gap : 5px;
|
||||
column-gap : 15px;
|
||||
align-items : baseline;
|
||||
justify-content : center;
|
||||
width : 100%;
|
||||
height : 30px;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
color : white;
|
||||
text-align : center;
|
||||
background-color : #555555;
|
||||
border-top : 1px solid #666666;
|
||||
border-bottom : 1px solid #666666;
|
||||
h6 {
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
font-size : 11px;
|
||||
font-weight : bold;
|
||||
text-transform : uppercase;
|
||||
|
||||
.listPageNav {
|
||||
position : relative;
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
row-gap : 10px;
|
||||
column-gap : 20px;
|
||||
align-items : center;
|
||||
justify-content : center;
|
||||
width : 100%;
|
||||
height : fit-content;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
color : white;
|
||||
background : #555555;
|
||||
border-top : 1px solid #666666;
|
||||
border-bottom : 1px solid #666666;
|
||||
row-rule : 1px solid #666666;
|
||||
|
||||
> div {
|
||||
width : fit-content;
|
||||
height : 30px;
|
||||
padding-inline : 10px;
|
||||
}
|
||||
.sort-option {
|
||||
display : flex;
|
||||
align-items : center;
|
||||
height : 100%;
|
||||
padding : 0 8px;
|
||||
color : #CCCCCC;
|
||||
|
||||
&:hover { background-color : #444444; }
|
||||
|
||||
&.active {
|
||||
font-weight : bold;
|
||||
color : #DDDDDD;
|
||||
background-color : #333333;
|
||||
|
||||
button {
|
||||
height : 100%;
|
||||
font-weight : 800;
|
||||
color : white;
|
||||
& + .sortDir { padding-left : 5px; }
|
||||
}
|
||||
.radio-toggle {
|
||||
z-index : 1;
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
row-gap : 5px;
|
||||
align-items : center;
|
||||
justify-content : center;
|
||||
text-align : center;
|
||||
h6 {
|
||||
font-size : 14px;
|
||||
margin-right:5px;
|
||||
}
|
||||
|
||||
.option {
|
||||
display : flex;
|
||||
align-items : center;
|
||||
height : 100%;
|
||||
padding : 0 8px;
|
||||
color : #CCCCCC;
|
||||
|
||||
input[type='radio'] { display : none; }
|
||||
|
||||
button {
|
||||
padding : 0;
|
||||
font-size : 11px;
|
||||
font-weight : normal;
|
||||
text-transform : uppercase;
|
||||
background-color : transparent;
|
||||
}
|
||||
&:hover { background-color : #444444; }
|
||||
|
||||
&.active, &:has(input:checked) {
|
||||
font-weight : bold;
|
||||
color : white;
|
||||
background-color : #333333;
|
||||
|
||||
button {
|
||||
height : 100%;
|
||||
font-weight : 800;
|
||||
& + .sortDir { padding-left : 5px; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-option {
|
||||
margin-left : 20px;
|
||||
display : grid;
|
||||
place-items : center;
|
||||
font-size : 11px;
|
||||
background-color : transparent !important;
|
||||
i { padding-right : 5px; }
|
||||
}
|
||||
button {
|
||||
padding : 0;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
font-size : 11px;
|
||||
font-weight : normal;
|
||||
color : #CCCCCC;
|
||||
text-transform : uppercase;
|
||||
background-color : transparent;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
.tags-container {
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
row-gap : 5px;
|
||||
column-gap : 15px;
|
||||
align-items : center;
|
||||
justify-content : center;
|
||||
height : 30px;
|
||||
color : white;
|
||||
background-color : #555555;
|
||||
border-top : 1px solid #666666;
|
||||
border-bottom : 1px solid #666666;
|
||||
span {
|
||||
padding : 3px;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
font-size : 11px;
|
||||
font-weight : bold;
|
||||
color : #DFDFDF;
|
||||
cursor : pointer;
|
||||
border : 1px solid;
|
||||
border-radius : 3px;
|
||||
&::before {
|
||||
margin-right : 3px;
|
||||
font-family : 'Font Awesome 6 Free';
|
||||
font-size : 12px;
|
||||
}
|
||||
&::after {
|
||||
margin-left : 3px;
|
||||
font-family : 'Font Awesome 6 Free';
|
||||
font-size : 12px;
|
||||
content : '\f00d';
|
||||
}
|
||||
&.type {
|
||||
background-color : #008000;
|
||||
border-color : #00A000;
|
||||
&::before { content : '\f0ad'; }
|
||||
}
|
||||
&.group {
|
||||
background-color : #505050;
|
||||
border-color : #000000;
|
||||
&::before { content : '\f500'; }
|
||||
}
|
||||
&.meta {
|
||||
background-color : #000080;
|
||||
border-color : #0000A0;
|
||||
&::before { content : '\f05a'; }
|
||||
}
|
||||
&.system {
|
||||
background-color : #800000;
|
||||
border-color : #A00000;
|
||||
&::before { content : '\f518'; }
|
||||
.tags-container {
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
row-gap : 5px;
|
||||
column-gap : 15px;
|
||||
align-items : center;
|
||||
justify-content : center;
|
||||
height : 30px;
|
||||
span {
|
||||
padding : 3px;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
font-size : 11px;
|
||||
font-weight : bold;
|
||||
color : #DFDFDF;
|
||||
cursor : pointer;
|
||||
border : 1px solid;
|
||||
border-radius : 3px;
|
||||
&::before {
|
||||
margin-right : 3px;
|
||||
font-family : 'Font Awesome 6 Free';
|
||||
font-size : 12px;
|
||||
}
|
||||
&::after {
|
||||
margin-left : 3px;
|
||||
font-family : 'Font Awesome 6 Free';
|
||||
font-size : 12px;
|
||||
content : '\f00d';
|
||||
}
|
||||
&.type {
|
||||
background-color : #008000;
|
||||
border-color : #00A000;
|
||||
&::before { content : '\f0ad'; }
|
||||
}
|
||||
&.group {
|
||||
background-color : #505050;
|
||||
border-color : #000000;
|
||||
&::before { content : '\f500'; }
|
||||
}
|
||||
&.meta {
|
||||
background-color : #000080;
|
||||
border-color : #0000A0;
|
||||
&::before { content : '\f05a'; }
|
||||
}
|
||||
&.system {
|
||||
background-color : #800000;
|
||||
border-color : #A00000;
|
||||
&::before { content : '\f518'; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import './uiPage.less';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
|
||||
import Nav from '../../../navbar/nav.jsx';
|
||||
import Navbar from '../../../navbar/navbar.jsx';
|
||||
import NewBrewItem from '../../../navbar/newbrew.navitem.jsx';
|
||||
@@ -10,30 +7,22 @@ import RecentNavItems from '../../../navbar/recent.navitem.jsx';
|
||||
const { both: RecentNavItem } = RecentNavItems;
|
||||
import Account from '../../../navbar/account.navitem.jsx';
|
||||
|
||||
const UIPage = ({ brew, children })=><div className='sitePage uiPage'>
|
||||
<Navbar>
|
||||
<Nav.section>
|
||||
<Nav.item className='brewTitle'>{brew?.title}</Nav.item>
|
||||
</Nav.section>
|
||||
<Nav.section>
|
||||
<NewBrewItem />
|
||||
<HelpNavItem />
|
||||
<RecentNavItem />
|
||||
<Account />
|
||||
</Nav.section>
|
||||
</Navbar>
|
||||
|
||||
const UIPage = createReactClass({
|
||||
displayName : 'UIPage',
|
||||
|
||||
render : function(){
|
||||
return <div className='uiPage sitePage'>
|
||||
<Navbar>
|
||||
<Nav.section>
|
||||
<Nav.item className='brewTitle'>{this.props.brew.title}</Nav.item>
|
||||
</Nav.section>
|
||||
|
||||
<Nav.section>
|
||||
<NewBrewItem />
|
||||
<HelpNavItem />
|
||||
<RecentNavItem />
|
||||
<Account />
|
||||
</Nav.section>
|
||||
</Navbar>
|
||||
|
||||
<div className='content'>
|
||||
{this.props.children}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
<div className='content'>
|
||||
{children}
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
export default UIPage;
|
||||
|
||||
@@ -1,54 +1,14 @@
|
||||
/* eslint-disable max-lines */
|
||||
import './editPage.less';
|
||||
|
||||
// Common imports
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import Markdown from '../../../../shared/markdown.js';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
|
||||
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '../../../../shared/helpers.js';
|
||||
|
||||
import SplitPane from '../../../components/splitPane/splitPane.jsx';
|
||||
import Editor from '../../editor/editor.jsx';
|
||||
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
|
||||
|
||||
import Nav from '../../navbar/nav.jsx';
|
||||
import Navbar from '../../navbar/navbar.jsx';
|
||||
import NewBrewItem from '../../navbar/newbrew.navitem.jsx';
|
||||
import AccountNavItem from '../../navbar/account.navitem.jsx';
|
||||
import ErrorNavItem from '../../navbar/error-navitem.jsx';
|
||||
import HelpNavItem from '../../navbar/help.navitem.jsx';
|
||||
import VaultNavItem from '../../navbar/vault.navitem.jsx';
|
||||
import PrintNavItem from '../../navbar/print.navitem.jsx';
|
||||
import RecentNavItems from '../../navbar/recent.navitem.jsx';
|
||||
const { both: RecentNavItem } = RecentNavItems;
|
||||
import React from 'react';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import _ from 'lodash';
|
||||
import EditorPage from '../basePages/editPage/editorPage.jsx';
|
||||
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
|
||||
|
||||
// Page specific imports
|
||||
import { Meta } from 'vitreum/headtags';
|
||||
import { md5 } from 'hash-wasm';
|
||||
import { gzipSync, strToU8 } from 'fflate';
|
||||
import { makePatches, stringifyPatches } from '@sanity/diff-match-patch';
|
||||
|
||||
import ShareNavItem from '../../navbar/share.navitem.jsx';
|
||||
import LockNotification from './lockNotification/lockNotification.jsx';
|
||||
import { updateHistory, versionHistoryGarbageCollection } from '../../utils/versionHistory.js';
|
||||
import googleDriveIcon from '../../googleDrive.svg';
|
||||
|
||||
const SAVE_TIMEOUT = 10000;
|
||||
const UNSAVED_WARNING_TIMEOUT = 900000; //Warn user afer 15 minutes of unsaved changes
|
||||
const UNSAVED_WARNING_POPUP_TIMEOUT = 4000; //Show the warning for 4 seconds
|
||||
|
||||
|
||||
const AUTOSAVE_KEY = 'HB_editor_autoSaveOn';
|
||||
const BREWKEY = 'HB_newPage_content';
|
||||
const STYLEKEY = 'HB_newPage_style';
|
||||
const SNIPKEY = 'HB_newPage_snippets';
|
||||
const METAKEY = 'HB_newPage_meta';
|
||||
|
||||
const useLocalStorage = false;
|
||||
const neverSaved = false;
|
||||
|
||||
const EditPage = (props)=>{
|
||||
props = {
|
||||
@@ -56,364 +16,46 @@ const EditPage = (props)=>{
|
||||
...props
|
||||
};
|
||||
|
||||
const [currentBrew , setCurrentBrew ] = useState(props.brew);
|
||||
const [isSaving , setIsSaving ] = useState(false);
|
||||
const [lastSavedTime , setLastSavedTime ] = useState(new Date());
|
||||
const [saveGoogle , setSaveGoogle ] = useState(!!props.brew.googleId);
|
||||
const [error , setError ] = useState(null);
|
||||
const [HTMLErrors , setHTMLErrors ] = useState(Markdown.validate(props.brew.text));
|
||||
const [currentEditorViewPageNum , setCurrentEditorViewPageNum ] = useState(1);
|
||||
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
|
||||
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
|
||||
const [themeBundle , setThemeBundle ] = useState({});
|
||||
const [unsavedChanges , setUnsavedChanges ] = useState(false);
|
||||
const [alertTrashedGoogleBrew , setAlertTrashedGoogleBrew ] = useState(props.brew.trashed);
|
||||
const [alertLoginToTransfer , setAlertLoginToTransfer ] = useState(false);
|
||||
const [confirmGoogleTransfer , setConfirmGoogleTransfer ] = useState(false);
|
||||
const [autoSaveEnabled , setAutoSaveEnabled ] = useState(true);
|
||||
const [warnUnsavedChanges , setWarnUnsavedChanges ] = useState(true);
|
||||
|
||||
const editorRef = useRef(null);
|
||||
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
|
||||
const saveTimeout = useRef(null);
|
||||
const warnUnsavedTimeout = useRef(null);
|
||||
const trySaveRef = useRef(trySave); // CTRL+S listener lives outside React and needs ref to use trySave with latest copy of brew
|
||||
const unsavedChangesRef = useRef(unsavedChanges); // Similarly, onBeforeUnload lives outside React and needs ref to unsavedChanges
|
||||
|
||||
useEffect(()=>{
|
||||
const autoSavePref = JSON.parse(localStorage.getItem(AUTOSAVE_KEY) ?? true);
|
||||
setAutoSaveEnabled(autoSavePref);
|
||||
setWarnUnsavedChanges(!autoSavePref);
|
||||
setHTMLErrors(Markdown.validate(currentBrew.text));
|
||||
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
|
||||
|
||||
const handleControlKeys = (e)=>{
|
||||
if(!(e.ctrlKey || e.metaKey)) return;
|
||||
if(e.keyCode === 83) trySaveRef.current(true);
|
||||
if(e.keyCode === 80) printCurrentBrew();
|
||||
if([83, 80].includes(e.keyCode)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleControlKeys);
|
||||
window.onbeforeunload = ()=>{
|
||||
if(unsavedChangesRef.current)
|
||||
return 'You have unsaved changes!';
|
||||
};
|
||||
return ()=>{
|
||||
document.removeEventListener('keydown', handleControlKeys);
|
||||
window.onBeforeUnload = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(()=>{
|
||||
trySaveRef.current = trySave;
|
||||
unsavedChangesRef.current = unsavedChanges;
|
||||
});
|
||||
|
||||
useEffect(()=>{
|
||||
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
|
||||
setUnsavedChanges(hasChange);
|
||||
|
||||
if(autoSaveEnabled) trySave(false, hasChange);
|
||||
}, [currentBrew]);
|
||||
|
||||
useEffect(()=>{
|
||||
trySave(true);
|
||||
}, [saveGoogle]);
|
||||
|
||||
const handleSplitMove = ()=>{
|
||||
editorRef.current?.update();
|
||||
};
|
||||
|
||||
const handleBrewChange = (field)=>(value, subfield)=>{ //'text', 'style', 'snippets', 'metadata'
|
||||
if(subfield == 'renderer' || subfield == 'theme')
|
||||
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
|
||||
|
||||
//If there are HTML errors, run the validator on every change to give quick feedback
|
||||
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
|
||||
setHTMLErrors(Markdown.validate(value));
|
||||
|
||||
if(field == 'metadata') setCurrentBrew((prev)=>({ ...prev, ...value }));
|
||||
else setCurrentBrew((prev)=>({ ...prev, [field]: value }));
|
||||
|
||||
if(useLocalStorage) {
|
||||
if(field == 'text') localStorage.setItem(BREWKEY, value);
|
||||
if(field == 'style') localStorage.setItem(STYLEKEY, value);
|
||||
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
|
||||
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
|
||||
renderer : value.renderer,
|
||||
theme : value.theme,
|
||||
lang : value.lang
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const updateBrew = (newData)=>setCurrentBrew((prevBrew)=>({
|
||||
...prevBrew,
|
||||
style : newData.style,
|
||||
text : newData.text,
|
||||
snippets : newData.snippets
|
||||
}));
|
||||
|
||||
const resetWarnUnsavedTimer = ()=>{
|
||||
setTimeout(()=>setWarnUnsavedChanges(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds
|
||||
clearTimeout(warnUnsavedTimeout.current);
|
||||
warnUnsavedTimeout.current = setTimeout(()=>setWarnUnsavedChanges(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved work warnings
|
||||
};
|
||||
|
||||
const handleGoogleClick = ()=>{
|
||||
if(!global.account?.googleId) {
|
||||
setAlertLoginToTransfer(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setConfirmGoogleTransfer((prev)=>!prev);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const closeAlerts = (e)=>{
|
||||
e.stopPropagation(); //Only handle click once so alert doesn't reopen
|
||||
setAlertTrashedGoogleBrew(false);
|
||||
setAlertLoginToTransfer(false);
|
||||
setConfirmGoogleTransfer(false);
|
||||
};
|
||||
|
||||
const toggleGoogleStorage = ()=>{
|
||||
setSaveGoogle((prev)=>!prev);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const trySave = (immediate = false, hasChanges = true)=>{
|
||||
clearTimeout(saveTimeout.current);
|
||||
if(isSaving) return;
|
||||
if(!hasChanges && !immediate) return;
|
||||
const newTimeout = immediate ? 0 : SAVE_TIMEOUT;
|
||||
|
||||
saveTimeout.current = setTimeout(async ()=>{
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
await save(currentBrew, saveGoogle)
|
||||
.catch((err)=>{
|
||||
setError(err);
|
||||
});
|
||||
setIsSaving(false);
|
||||
setLastSavedTime(new Date());
|
||||
if(!autoSaveEnabled) resetWarnUnsavedTimer();
|
||||
}, newTimeout);
|
||||
};
|
||||
|
||||
const save = async (brew, saveToGoogle)=>{
|
||||
setHTMLErrors(Markdown.validate(brew.text));
|
||||
|
||||
await updateHistory(brew).catch(console.error);
|
||||
await versionHistoryGarbageCollection().catch(console.error);
|
||||
|
||||
//Prepare content to send to server
|
||||
const brewToSave = {
|
||||
...brew,
|
||||
text : brew.text.normalize('NFC'),
|
||||
pageCount : ((brew.renderer === 'legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1,
|
||||
patches : stringifyPatches(makePatches(encodeURI(lastSavedBrew.current.text.normalize('NFC')), encodeURI(brew.text.normalize('NFC')))),
|
||||
hash : await md5(lastSavedBrew.current.text.normalize('NFC')),
|
||||
textBin : undefined,
|
||||
version : lastSavedBrew.current.version
|
||||
};
|
||||
|
||||
const compressedBrew = gzipSync(strToU8(JSON.stringify(brewToSave)));
|
||||
const compressedBrew = gzipSync(strToU8(JSON.stringify(brew)));
|
||||
const transfer = saveToGoogle === _.isNil(brew.googleId);
|
||||
const params = transfer ? `?${saveToGoogle ? 'saveToGoogle' : 'removeFromGoogle'}=true` : '';
|
||||
|
||||
const res = await request
|
||||
.put(`/api/update/${brewToSave.editId}${params}`)
|
||||
.put(`/api/update/${brew.editId}${params}`)
|
||||
.set('Content-Encoding', 'gzip')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(compressedBrew)
|
||||
.catch((err)=>{
|
||||
console.error('Error Updating Local Brew');
|
||||
setError(err);
|
||||
});
|
||||
if(!res) return;
|
||||
.send(compressedBrew);
|
||||
|
||||
const updatedFields = {
|
||||
googleId : res.body.googleId ?? null,
|
||||
editId : res.body.editId,
|
||||
googleId : res.body.googleId ?? null, //TODO: investigate if we can set googleId:null in server/update instead of undefined
|
||||
editId : res.body.editId, //then, editPage can just apply res.body instead of breaking out these fields.
|
||||
shareId : res.body.shareId,
|
||||
version : res.body.version
|
||||
};
|
||||
|
||||
lastSavedBrew.current = {
|
||||
...brew,
|
||||
...updatedFields
|
||||
};
|
||||
|
||||
setCurrentBrew((prevBrew)=>({
|
||||
...prevBrew,
|
||||
...updatedFields
|
||||
}));
|
||||
|
||||
history.replaceState(null, null, `/edit/${res.body.editId}`);
|
||||
return updatedFields;
|
||||
};
|
||||
|
||||
const renderGoogleDriveIcon = ()=>(
|
||||
<Nav.item className='googleDriveStorage' onClick={handleGoogleClick}>
|
||||
<img src={googleDriveIcon} className={saveGoogle ? '' : 'inactive'} alt='Google Drive icon' />
|
||||
|
||||
{confirmGoogleTransfer && (
|
||||
<div className='errorContainer' onClick={closeAlerts}>
|
||||
{saveGoogle
|
||||
? 'Would you like to transfer this brew from your Google Drive storage back to the Homebrewery?'
|
||||
: 'Would you like to transfer this brew from the Homebrewery to your personal Google Drive storage?'}
|
||||
<br />
|
||||
<div className='confirm' onClick={toggleGoogleStorage}> Yes </div>
|
||||
<div className='deny'> No </div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{alertLoginToTransfer && (
|
||||
<div className='errorContainer' onClick={closeAlerts}>
|
||||
You must be signed in to a Google account to transfer between the homebrewery and Google Drive!
|
||||
<a target='_blank' rel='noopener noreferrer' href={`https://www.naturalcrit.com/login?redirect=${window.location.href}`}>
|
||||
<div className='confirm'> Sign In </div>
|
||||
</a>
|
||||
<div className='deny'> Not Now </div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{alertTrashedGoogleBrew && (
|
||||
<div className='errorContainer' onClick={closeAlerts}>
|
||||
This brew is currently in your Trash folder on Google Drive!<br />
|
||||
If you want to keep it, make sure to move it before it is deleted permanently!<br />
|
||||
<div className='confirm'> OK </div>
|
||||
</div>
|
||||
)}
|
||||
</Nav.item>
|
||||
);
|
||||
|
||||
const renderSaveButton = ()=>{
|
||||
// #1 - Currently saving, show SAVING
|
||||
if(isSaving)
|
||||
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
|
||||
|
||||
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
|
||||
if(unsavedChanges && warnUnsavedChanges) {
|
||||
resetWarnUnsavedTimer();
|
||||
const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
|
||||
const text = elapsedTime === 0
|
||||
? 'Autosave is OFF.'
|
||||
: `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`;
|
||||
|
||||
return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
|
||||
Reminder...
|
||||
<div className='errorContainer'>{text}</div>
|
||||
</Nav.item>;
|
||||
}
|
||||
|
||||
// #3 - Unsaved changes exist, click to save, show SAVE NOW
|
||||
if(unsavedChanges)
|
||||
return <Nav.item className='save' onClick={()=>trySave(true)} color='blue' icon='fas fa-save'>save now</Nav.item>;
|
||||
|
||||
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
|
||||
if(autoSaveEnabled)
|
||||
return <Nav.item className='save saved'>auto-saved</Nav.item>;
|
||||
|
||||
// #5 - No unsaved changes, and has never been saved, hide the button
|
||||
if(neverSaved)
|
||||
return <Nav.item className='save neverSaved'>save now</Nav.item>;
|
||||
|
||||
// DEFAULT - No unsaved changes, show SAVED
|
||||
return <Nav.item className='save saved'>saved</Nav.item>;
|
||||
};
|
||||
|
||||
const toggleAutoSave = ()=>{
|
||||
clearTimeout(warnUnsavedTimeout.current);
|
||||
clearTimeout(saveTimeout.current);
|
||||
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(!autoSaveEnabled));
|
||||
setAutoSaveEnabled(!autoSaveEnabled);
|
||||
setWarnUnsavedChanges(autoSaveEnabled);
|
||||
};
|
||||
|
||||
const renderAutoSaveButton = ()=>(
|
||||
<Nav.item onClick={toggleAutoSave}>
|
||||
Autosave <i className={autoSaveEnabled ? 'fas fa-power-off active' : 'fas fa-power-off'}></i>
|
||||
</Nav.item>
|
||||
);
|
||||
|
||||
const clearError = ()=>{
|
||||
setError(null);
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
const renderNavbar = ()=>{
|
||||
return <Navbar>
|
||||
<Nav.section>
|
||||
<Nav.item className='brewTitle'>{currentBrew.title}</Nav.item>
|
||||
</Nav.section>
|
||||
|
||||
<Nav.section>
|
||||
{renderGoogleDriveIcon()}
|
||||
{error
|
||||
? <ErrorNavItem error={error} clearError={clearError} />
|
||||
: <Nav.dropdown className='save-menu'>
|
||||
{renderSaveButton()}
|
||||
{renderAutoSaveButton()}
|
||||
</Nav.dropdown>}
|
||||
<NewBrewItem />
|
||||
<PrintNavItem />
|
||||
<HelpNavItem />
|
||||
<VaultNavItem />
|
||||
<ShareNavItem brew={currentBrew} />
|
||||
<RecentNavItem brew={currentBrew} storageKey='edit' />
|
||||
<AccountNavItem/>
|
||||
</Nav.section>
|
||||
</Navbar>;
|
||||
const onSaveSuccess = (savedBrew)=>{
|
||||
history.replaceState(null, null, `/edit/${savedBrew.editId}`);;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='editPage sitePage'>
|
||||
<Meta name='robots' content='noindex, nofollow' />
|
||||
|
||||
{renderNavbar()}
|
||||
|
||||
{currentBrew.lock && <LockNotification shareId={currentBrew.shareId} message={currentBrew.lock.editMessage} reviewRequested={currentBrew.lock.reviewRequested}/>}
|
||||
|
||||
<div className='content'>
|
||||
<SplitPane onDragFinish={handleSplitMove}>
|
||||
<Editor
|
||||
ref={editorRef}
|
||||
brew={currentBrew}
|
||||
onBrewChange={handleBrewChange}
|
||||
reportError={setError}
|
||||
renderer={currentBrew.renderer}
|
||||
userThemes={props.userThemes}
|
||||
themeBundle={themeBundle}
|
||||
updateBrew={updateBrew}
|
||||
onCursorPageChange={setCurrentEditorCursorPageNum}
|
||||
onViewPageChange={setCurrentEditorViewPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
/>
|
||||
<BrewRenderer
|
||||
text={currentBrew.text}
|
||||
style={currentBrew.style}
|
||||
renderer={currentBrew.renderer}
|
||||
theme={currentBrew.theme}
|
||||
themeBundle={themeBundle}
|
||||
errors={HTMLErrors}
|
||||
lang={currentBrew.lang}
|
||||
onPageChange={setCurrentBrewRendererPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
allowPrint={true}
|
||||
/>
|
||||
</SplitPane>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<EditorPage
|
||||
useLocalStorage = {false}
|
||||
sandbox = {false}
|
||||
showFloatingButtons = {false}
|
||||
showEditorButtons = {true}
|
||||
pageName = {'editPage'}
|
||||
brew = {props.brew}
|
||||
userThemes = {props.userThemes}
|
||||
save = {save}
|
||||
onSaveSuccess = {onSaveSuccess}
|
||||
></EditorPage>);
|
||||
};
|
||||
|
||||
export default EditPage;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import './lockNotification.less';
|
||||
import * as React from 'react';
|
||||
import request from '../../../utils/request-middleware.js';
|
||||
import Dialog from '../../../../components/dialog.jsx';
|
||||
import Dialog from '@components/dialog.jsx';
|
||||
|
||||
function LockNotification(props) {
|
||||
props = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import './errorPage.less';
|
||||
import React from 'react';
|
||||
import UIPage from '../basePages/uiPage/uiPage.jsx';
|
||||
import Markdown from '../../../../shared/markdown.js';
|
||||
import { hbfm } from 'marked-hbfm';
|
||||
import ErrorIndex from './errors/errorIndex.js';
|
||||
|
||||
const ErrorPage = ({ brew })=>{
|
||||
@@ -16,7 +16,7 @@ const ErrorPage = ({ brew })=>{
|
||||
<h4>{brew?.text || 'No error text'}</h4>
|
||||
</div>
|
||||
<hr />
|
||||
<div dangerouslySetInnerHTML={{ __html: Markdown.render(errorText) }} />
|
||||
<div dangerouslySetInnerHTML={{ __html: hbfm.render(errorText) }} />
|
||||
</div>
|
||||
</UIPage>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
.homebrew {
|
||||
.uiPage.sitePage {
|
||||
.uiPage.sitePage:has(.errorTitle) {
|
||||
.errorTitle {
|
||||
//background-color: @orange;
|
||||
color : #D02727;
|
||||
text-align : center;
|
||||
}
|
||||
|
||||
@@ -1,235 +1,42 @@
|
||||
/* eslint-disable max-lines */
|
||||
import './homePage.less';
|
||||
|
||||
// Common imports
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import Markdown from '../../../../shared/markdown.js';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
|
||||
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '../../../../shared/helpers.js';
|
||||
|
||||
import SplitPane from '../../../components/splitPane/splitPane.jsx';
|
||||
import Editor from '../../editor/editor.jsx';
|
||||
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
|
||||
|
||||
import Nav from '../../navbar/nav.jsx';
|
||||
import Navbar from '../../navbar/navbar.jsx';
|
||||
import NewBrewItem from '../../navbar/newbrew.navitem.jsx';
|
||||
import AccountNavItem from '../../navbar/account.navitem.jsx';
|
||||
import ErrorNavItem from '../../navbar/error-navitem.jsx';
|
||||
import HelpNavItem from '../../navbar/help.navitem.jsx';
|
||||
import VaultNavItem from '../../navbar/vault.navitem.jsx';
|
||||
import PrintNavItem from '../../navbar/print.navitem.jsx';
|
||||
import RecentNavItems from '../../navbar/recent.navitem.jsx';
|
||||
const { both: RecentNavItem } = RecentNavItems;
|
||||
|
||||
|
||||
// Page specific imports
|
||||
import { Meta } from 'vitreum/headtags';
|
||||
|
||||
const BREWKEY = 'homebrewery-new';
|
||||
const STYLEKEY = 'homebrewery-new-style';
|
||||
const SNIPKEY = 'homebrewery-new-snippets';
|
||||
const METAKEY = 'homebrewery-new-meta';
|
||||
|
||||
const useLocalStorage = false;
|
||||
const neverSaved = true;
|
||||
import React from 'react';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import _ from 'lodash';
|
||||
import EditorPage from '../basePages/editPage/editorPage.jsx';
|
||||
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
|
||||
|
||||
const HomePage =(props)=>{
|
||||
props = {
|
||||
brew : DEFAULT_BREW,
|
||||
ver : '0.0.0',
|
||||
...props
|
||||
};
|
||||
|
||||
const [currentBrew , setCurrentBrew] = useState(props.brew);
|
||||
const [error , setError] = useState(undefined);
|
||||
const [HTMLErrors , setHTMLErrors] = useState(Markdown.validate(props.brew.text));
|
||||
const [currentEditorViewPageNum , setCurrentEditorViewPageNum] = useState(1);
|
||||
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
|
||||
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
|
||||
const [themeBundle , setThemeBundle] = useState({});
|
||||
const [unsavedChanges , setUnsavedChanges] = useState(false);
|
||||
const [isSaving , setIsSaving] = useState(false);
|
||||
const [autoSaveEnabled , setAutoSaveEnable] = useState(false);
|
||||
const save = async (brew, saveToGoogle)=>{
|
||||
const res = await request
|
||||
.post(`/api${saveToGoogle ? '?saveToGoogle=true' : ''}`)
|
||||
.send(brew);
|
||||
|
||||
const editorRef = useRef(null);
|
||||
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
|
||||
const unsavedChangesRef = useRef(unsavedChanges);
|
||||
|
||||
useEffect(()=>{
|
||||
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
|
||||
|
||||
const handleControlKeys = (e)=>{
|
||||
if(!(e.ctrlKey || e.metaKey)) return;
|
||||
if(e.keyCode === 83) trySaveRef.current(true);
|
||||
if(e.keyCode === 80) printCurrentBrew();
|
||||
if([83, 80].includes(e.keyCode)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleControlKeys);
|
||||
window.onbeforeunload = ()=>{
|
||||
if(unsavedChangesRef.current)
|
||||
return 'You have unsaved changes!';
|
||||
};
|
||||
return ()=>{
|
||||
document.removeEventListener('keydown', handleControlKeys);
|
||||
window.onbeforeunload = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(()=>{
|
||||
unsavedChangesRef.current = unsavedChanges;
|
||||
}, [unsavedChanges]);
|
||||
|
||||
const save = ()=>{
|
||||
request.post('/api')
|
||||
.send(currentBrew)
|
||||
.end((err, res)=>{
|
||||
if(err) {
|
||||
setError(err);
|
||||
return;
|
||||
}
|
||||
const saved = res.body;
|
||||
window.location = `/edit/${saved.editId}`;
|
||||
});
|
||||
return res.body;
|
||||
};
|
||||
|
||||
useEffect(()=>{
|
||||
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
|
||||
setUnsavedChanges(hasChange);
|
||||
|
||||
if(autoSaveEnabled) trySave(false, hasChange);
|
||||
}, [currentBrew]);
|
||||
|
||||
const handleSplitMove = ()=>{
|
||||
editorRef.current.update();
|
||||
};
|
||||
|
||||
const handleBrewChange = (field)=>(value, subfield)=>{ //'text', 'style', 'snippets', 'metadata'
|
||||
if(subfield == 'renderer' || subfield == 'theme')
|
||||
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
|
||||
|
||||
//If there are HTML errors, run the validator on every change to give quick feedback
|
||||
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
|
||||
setHTMLErrors(Markdown.validate(value));
|
||||
|
||||
if(field == 'metadata') setCurrentBrew((prev)=>({ ...prev, ...value }));
|
||||
else setCurrentBrew((prev)=>({ ...prev, [field]: value }));
|
||||
|
||||
if(useLocalStorage) {
|
||||
if(field == 'text') localStorage.setItem(BREWKEY, value);
|
||||
if(field == 'style') localStorage.setItem(STYLEKEY, value);
|
||||
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
|
||||
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
|
||||
renderer : value.renderer,
|
||||
theme : value.theme,
|
||||
lang : value.lang
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const renderSaveButton = ()=>{
|
||||
// #1 - Currently saving, show SAVING
|
||||
if(isSaving)
|
||||
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
|
||||
|
||||
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
|
||||
// if(unsavedChanges && warnUnsavedChanges) {
|
||||
// resetWarnUnsavedTimer();
|
||||
// const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
|
||||
// const text = elapsedTime === 0
|
||||
// ? 'Autosave is OFF.'
|
||||
// : `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`;
|
||||
|
||||
// return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
|
||||
// Reminder...
|
||||
// <div className='errorContainer'>{text}</div>
|
||||
// </Nav.item>;
|
||||
// }
|
||||
|
||||
// #3 - Unsaved changes exist, click to save, show SAVE NOW
|
||||
if(unsavedChanges)
|
||||
return <Nav.item className='save' onClick={save} color='blue' icon='fas fa-save'>save now</Nav.item>;
|
||||
|
||||
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
|
||||
if(autoSaveEnabled)
|
||||
return <Nav.item className='save saved'>auto-saved</Nav.item>;
|
||||
|
||||
// #5 - No unsaved changes, and has never been saved, hide the button
|
||||
if(neverSaved)
|
||||
return <Nav.item className='save neverSaved'>save now</Nav.item>;
|
||||
|
||||
// DEFAULT - No unsaved changes, show SAVED
|
||||
return <Nav.item className='save saved'>saved</Nav.item>;
|
||||
};
|
||||
|
||||
const clearError = ()=>{
|
||||
setError(null);
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
const renderNavbar = ()=>{
|
||||
return <Navbar ver={props.ver}>
|
||||
<Nav.section>
|
||||
{error
|
||||
? <ErrorNavItem error={error} clearError={clearError} />
|
||||
: renderSaveButton()}
|
||||
<NewBrewItem />
|
||||
<PrintNavItem />
|
||||
<HelpNavItem />
|
||||
<VaultNavItem />
|
||||
<RecentNavItem />
|
||||
<AccountNavItem />
|
||||
</Nav.section>
|
||||
</Navbar>;
|
||||
const onSaveSuccess = (savedBrew)=>{
|
||||
window.onbeforeunload = null;
|
||||
window.location = `/edit/${savedBrew.editId}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='homePage sitePage'>
|
||||
<Meta name='google-site-verification' content='NwnAQSSJZzAT7N-p5MY6ydQ7Njm67dtbu73ZSyE5Fy4' />
|
||||
{renderNavbar()}
|
||||
<div className='content'>
|
||||
<SplitPane onDragFinish={handleSplitMove}>
|
||||
<Editor
|
||||
ref={editorRef}
|
||||
brew={currentBrew}
|
||||
onBrewChange={handleBrewChange}
|
||||
renderer={currentBrew.renderer}
|
||||
showEditButtons={false}
|
||||
themeBundle={themeBundle}
|
||||
onCursorPageChange={setCurrentEditorCursorPageNum}
|
||||
onViewPageChange={setCurrentEditorViewPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
/>
|
||||
<BrewRenderer
|
||||
text={currentBrew.text}
|
||||
style={currentBrew.style}
|
||||
renderer={currentBrew.renderer}
|
||||
onPageChange={setCurrentBrewRendererPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
themeBundle={themeBundle}
|
||||
/>
|
||||
</SplitPane>
|
||||
</div>
|
||||
<div className={`floatingSaveButton${unsavedChanges ? ' show' : ''}`} onClick={save}>
|
||||
Save current <i className='fas fa-save' />
|
||||
</div>
|
||||
|
||||
<a href='/new' className='floatingNewButton'>
|
||||
Create your own <i className='fas fa-magic' />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
<EditorPage
|
||||
useLocalStorage = {false}
|
||||
sandbox = {true}
|
||||
showFloatingButtons = {true}
|
||||
showEditorButtons = {false}
|
||||
pageName = {'homePage'}
|
||||
brew = {props.brew}
|
||||
userThemes = {{}}
|
||||
save = {save}
|
||||
onSaveSuccess = {onSaveSuccess}
|
||||
></EditorPage>);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import '@sharedStyles/core.less';
|
||||
|
||||
.homePage {
|
||||
position : relative;
|
||||
a.floatingNewButton {
|
||||
@@ -35,14 +37,14 @@
|
||||
|
||||
.navItem.save {
|
||||
background-color : @orange;
|
||||
transition:all 0.2s;
|
||||
transition : all 0.2s;
|
||||
&:hover { background-color : @green; }
|
||||
|
||||
&.neverSaved {
|
||||
translate:-100%;
|
||||
opacity: 0;
|
||||
background-color :#333;
|
||||
cursor:auto;
|
||||
cursor : auto;
|
||||
background-color : #333333;
|
||||
opacity : 0;
|
||||
translate : -100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ After clicking the "Print" item in the navbar a new page will open and a print d
|
||||
If you want to save ink or have a monochrome printer, add the **PRINT → {{fas,fa-tint}} Ink Friendly** snippet to your brew!
|
||||
}}
|
||||
|
||||
 {position:absolute,bottom:20px,left:130px,width:220px}
|
||||
 {position:absolute,bottom:20px,left:130px,width:220px}
|
||||
|
||||
{{artist,bottom:160px,left:100px
|
||||
##### Homebrew Mug
|
||||
@@ -77,16 +77,16 @@ If you wish to sell or in some way gain profit for what's created on this site,
|
||||
If you'd like to credit us in your brew, we'd be flattered! Just reference that you made it with The Homebrewery.
|
||||
|
||||
### More Homebrew Resources
|
||||
[{width:50px,float:right,padding-left:10px}](https://discord.gg/by3deKx)
|
||||
[{width:50px,float:right,padding-left:10px}](https://discord.gg/by3deKx)
|
||||
|
||||
If you are looking for more 5e Homebrew resources check out [r/UnearthedArcana](https://www.reddit.com/r/UnearthedArcana/) and their list of useful resources [here](https://www.reddit.com/r/UnearthedArcana/wiki/resources). The [Discord Of Many Things](https://discord.gg/by3deKx) is another great resource to connect with fellow homebrewers for help and feedback.
|
||||
|
||||
|
||||
{{position:absolute;top:20px;right:20px;width:auto
|
||||
[{height:30px}](https://discord.gg/by3deKx)
|
||||
[{height:30px}](https://github.com/naturalcrit/homebrewery)
|
||||
[{height:30px}](https://patreon.com/NaturalCrit)
|
||||
[{height:30px}](https://www.reddit.com/r/homebrewery/)
|
||||
[{height:30px}](https://discord.gg/by3deKx)
|
||||
[{height:30px}](https://github.com/naturalcrit/homebrewery)
|
||||
[{height:30px}](https://patreon.com/NaturalCrit)
|
||||
[{height:30px}](https://www.reddit.com/r/homebrewery/)
|
||||
}}
|
||||
|
||||
\page
|
||||
@@ -162,7 +162,7 @@ Images must be hosted online somewhere, like [Imgur](https://www.imgur.com). You
|
||||
|
||||
Using *Curly Injection* you can assign an id, classes, or inline CSS properties to the Markdown image syntax.
|
||||
|
||||
 {width:100px,border:"2px solid",border-radius:10px}
|
||||
 {width:100px,border:"2px solid",border-radius:10px}
|
||||
|
||||
\* *When using Imgur-hosted images, use the "direct link", which can be found when you click into your image in the Imgur interface.*
|
||||
|
||||
|
||||
@@ -1,41 +1,10 @@
|
||||
/* eslint-disable max-lines */
|
||||
import './newPage.less';
|
||||
|
||||
// Common imports
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import Markdown from '../../../../shared/markdown.js';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
|
||||
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '../../../../shared/helpers.js';
|
||||
|
||||
import SplitPane from '../../../components/splitPane/splitPane.jsx';
|
||||
import Editor from '../../editor/editor.jsx';
|
||||
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
|
||||
|
||||
import Nav from '../../navbar/nav.jsx';
|
||||
import Navbar from '../../navbar/navbar.jsx';
|
||||
import NewBrewItem from '../../navbar/newbrew.navitem.jsx';
|
||||
import AccountNavItem from '../../navbar/account.navitem.jsx';
|
||||
import ErrorNavItem from '../../navbar/error-navitem.jsx';
|
||||
import HelpNavItem from '../../navbar/help.navitem.jsx';
|
||||
import VaultNavItem from '../../navbar/vault.navitem.jsx';
|
||||
import PrintNavItem from '../../navbar/print.navitem.jsx';
|
||||
import RecentNavItems from '../../navbar/recent.navitem.jsx';
|
||||
const { both: RecentNavItem } = RecentNavItems;
|
||||
|
||||
// Page specific imports
|
||||
import { Meta } from 'vitreum/headtags';
|
||||
|
||||
const BREWKEY = 'HB_newPage_content';
|
||||
const STYLEKEY = 'HB_newPage_style';
|
||||
const METAKEY = 'HB_newPage_metadata';
|
||||
const SNIPKEY = 'HB_newPage_snippets';
|
||||
const SAVEKEYPREFIX = 'HB_editor_defaultSave_';
|
||||
|
||||
const useLocalStorage = true;
|
||||
const neverSaved = true;
|
||||
import React from 'react';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import _ from 'lodash';
|
||||
import EditorPage from '../basePages/editPage/editorPage.jsx';
|
||||
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
|
||||
|
||||
const NewPage = (props)=>{
|
||||
props = {
|
||||
@@ -43,238 +12,31 @@ const NewPage = (props)=>{
|
||||
...props
|
||||
};
|
||||
|
||||
const [currentBrew , setCurrentBrew ] = useState(props.brew);
|
||||
const [isSaving , setIsSaving ] = useState(false);
|
||||
const [saveGoogle , setSaveGoogle ] = useState(global.account?.googleId ? true : false);
|
||||
const [error , setError ] = useState(null);
|
||||
const [HTMLErrors , setHTMLErrors ] = useState(Markdown.validate(props.brew.text));
|
||||
const [currentEditorViewPageNum , setCurrentEditorViewPageNum ] = useState(1);
|
||||
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
|
||||
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
|
||||
const [themeBundle , setThemeBundle ] = useState({});
|
||||
const [unsavedChanges , setUnsavedChanges ] = useState(false);
|
||||
const [autoSaveEnabled , setAutoSaveEnabled ] = useState(false);
|
||||
|
||||
const editorRef = useRef(null);
|
||||
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
|
||||
// const saveTimeout = useRef(null);
|
||||
// const warnUnsavedTimeout = useRef(null);
|
||||
const trySaveRef = useRef(trySave); // CTRL+S listener lives outside React and needs ref to use trySave with latest copy of brew
|
||||
const unsavedChangesRef = useRef(unsavedChanges); // Similarly, onBeforeUnload lives outside React and needs ref to unsavedChanges
|
||||
|
||||
useEffect(()=>{
|
||||
loadBrew();
|
||||
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
|
||||
|
||||
const handleControlKeys = (e)=>{
|
||||
if(!(e.ctrlKey || e.metaKey)) return;
|
||||
if(e.keyCode === 83) trySaveRef.current(true);
|
||||
if(e.keyCode === 80) printCurrentBrew();
|
||||
if([83, 80].includes(e.keyCode)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleControlKeys);
|
||||
|
||||
return ()=>{
|
||||
document.removeEventListener('keydown', handleControlKeys);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadBrew = ()=>{
|
||||
const brew = { ...currentBrew };
|
||||
if(!brew.shareId && typeof window !== 'undefined') { //Load from localStorage if in client browser
|
||||
const brewStorage = localStorage.getItem(BREWKEY);
|
||||
const styleStorage = localStorage.getItem(STYLEKEY);
|
||||
const metaStorage = JSON.parse(localStorage.getItem(METAKEY));
|
||||
|
||||
brew.text = brewStorage ?? brew.text;
|
||||
brew.style = styleStorage ?? brew.style;
|
||||
brew.renderer = metaStorage?.renderer ?? brew.renderer;
|
||||
brew.theme = metaStorage?.theme ?? brew.theme;
|
||||
brew.lang = metaStorage?.lang ?? brew.lang;
|
||||
}
|
||||
|
||||
const SAVEKEY = `${SAVEKEYPREFIX}${global.account?.username}`;
|
||||
const saveStorage = localStorage.getItem(SAVEKEY) || 'HOMEBREWERY';
|
||||
|
||||
setCurrentBrew(brew);
|
||||
lastSavedBrew.current = brew;
|
||||
setSaveGoogle(saveStorage == 'GOOGLE-DRIVE' && saveGoogle);
|
||||
|
||||
localStorage.setItem(BREWKEY, brew.text);
|
||||
if(brew.style)
|
||||
localStorage.setItem(STYLEKEY, brew.style);
|
||||
localStorage.setItem(METAKEY, JSON.stringify({ renderer: brew.renderer, theme: brew.theme, lang: brew.lang }));
|
||||
if(window.location.pathname !== '/new')
|
||||
window.history.replaceState({}, window.location.title, '/new/');
|
||||
};
|
||||
|
||||
useEffect(()=>{
|
||||
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
|
||||
setUnsavedChanges(hasChange);
|
||||
|
||||
if(autoSaveEnabled) trySave(false, hasChange);
|
||||
}, [currentBrew]);
|
||||
|
||||
useEffect(()=>{
|
||||
trySaveRef.current = trySave;
|
||||
unsavedChangesRef.current = unsavedChanges;
|
||||
});
|
||||
|
||||
const handleSplitMove = ()=>{
|
||||
editorRef.current.update();
|
||||
};
|
||||
|
||||
const handleBrewChange = (field)=>(value, subfield)=>{ //'text', 'style', 'snippets', 'metadata'
|
||||
if(subfield == 'renderer' || subfield == 'theme')
|
||||
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
|
||||
|
||||
//If there are HTML errors, run the validator on every change to give quick feedback
|
||||
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
|
||||
setHTMLErrors(Markdown.validate(value));
|
||||
|
||||
if(field == 'metadata') setCurrentBrew((prev)=>({ ...prev, ...value }));
|
||||
else setCurrentBrew((prev)=>({ ...prev, [field]: value }));
|
||||
|
||||
if(useLocalStorage) {
|
||||
if(field == 'text') localStorage.setItem(BREWKEY, value);
|
||||
if(field == 'style') localStorage.setItem(STYLEKEY, value);
|
||||
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
|
||||
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
|
||||
renderer : value.renderer,
|
||||
theme : value.theme,
|
||||
lang : value.lang
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const trySave = async ()=>{
|
||||
setIsSaving(true);
|
||||
|
||||
const updatedBrew = { ...currentBrew };
|
||||
splitTextStyleAndMetadata(updatedBrew);
|
||||
|
||||
const pageRegex = updatedBrew.renderer === 'legacy' ? /\\page/g : /^\\page$/gm;
|
||||
updatedBrew.pageCount = (updatedBrew.text.match(pageRegex) || []).length + 1;
|
||||
|
||||
const save = async (brew, saveToGoogle)=>{
|
||||
const res = await request
|
||||
.post(`/api${saveGoogle ? '?saveToGoogle=true' : ''}`)
|
||||
.send(updatedBrew)
|
||||
.catch((err)=>{
|
||||
setIsSaving(false);
|
||||
setError(err);
|
||||
});
|
||||
.post(`/api${saveToGoogle ? '?saveToGoogle=true' : ''}`)
|
||||
.send(brew);
|
||||
|
||||
setIsSaving(false);
|
||||
if(!res) return;
|
||||
return res.body;
|
||||
};
|
||||
|
||||
const savedBrew = res.body;
|
||||
|
||||
localStorage.removeItem(BREWKEY);
|
||||
localStorage.removeItem(STYLEKEY);
|
||||
localStorage.removeItem(METAKEY);
|
||||
const onSaveSuccess = (savedBrew)=>{
|
||||
window.onbeforeunload = null;
|
||||
window.location = `/edit/${savedBrew.editId}`;
|
||||
};
|
||||
|
||||
const renderSaveButton = ()=>{
|
||||
// #1 - Currently saving, show SAVING
|
||||
if(isSaving)
|
||||
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
|
||||
|
||||
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
|
||||
// if(unsavedChanges && warnUnsavedChanges) {
|
||||
// resetWarnUnsavedTimer();
|
||||
// const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
|
||||
// const text = elapsedTime === 0
|
||||
// ? 'Autosave is OFF.'
|
||||
// : `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`;
|
||||
|
||||
// return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
|
||||
// Reminder...
|
||||
// <div className='errorContainer'>{text}</div>
|
||||
// </Nav.item>;
|
||||
// }
|
||||
|
||||
// #3 - Unsaved changes exist, click to save, show SAVE NOW
|
||||
if(unsavedChanges)
|
||||
return <Nav.item className='save' onClick={trySave} color='blue' icon='fas fa-save'>save now</Nav.item>;
|
||||
|
||||
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
|
||||
if(autoSaveEnabled)
|
||||
return <Nav.item className='save saved'>auto-saved</Nav.item>;
|
||||
|
||||
// #5 - No unsaved changes, and has never been saved, hide the button
|
||||
if(neverSaved)
|
||||
return <Nav.item className='save neverSaved'>save now</Nav.item>;
|
||||
|
||||
// DEFAULT - No unsaved changes, show SAVED
|
||||
return <Nav.item className='save saved'>saved</Nav.item>;
|
||||
};
|
||||
|
||||
const clearError = ()=>{
|
||||
setError(null);
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
const renderNavbar = ()=>(
|
||||
<Navbar>
|
||||
<Nav.section>
|
||||
<Nav.item className='brewTitle'>{currentBrew.title}</Nav.item>
|
||||
</Nav.section>
|
||||
|
||||
<Nav.section>
|
||||
{error
|
||||
? <ErrorNavItem error={error} clearError={clearError} />
|
||||
: renderSaveButton()}
|
||||
<NewBrewItem />
|
||||
<PrintNavItem />
|
||||
<HelpNavItem />
|
||||
<VaultNavItem />
|
||||
<RecentNavItem />
|
||||
<AccountNavItem />
|
||||
</Nav.section>
|
||||
</Navbar>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='newPage sitePage'>
|
||||
{renderNavbar()}
|
||||
<div className='content'>
|
||||
<SplitPane onDragFinish={handleSplitMove}>
|
||||
<Editor
|
||||
ref={editorRef}
|
||||
brew={currentBrew}
|
||||
onBrewChange={handleBrewChange}
|
||||
renderer={currentBrew.renderer}
|
||||
userThemes={props.userThemes}
|
||||
themeBundle={themeBundle}
|
||||
onCursorPageChange={setCurrentEditorCursorPageNum}
|
||||
onViewPageChange={setCurrentEditorViewPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
/>
|
||||
<BrewRenderer
|
||||
text={currentBrew.text}
|
||||
style={currentBrew.style}
|
||||
renderer={currentBrew.renderer}
|
||||
theme={currentBrew.theme}
|
||||
themeBundle={themeBundle}
|
||||
errors={HTMLErrors}
|
||||
lang={currentBrew.lang}
|
||||
onPageChange={setCurrentBrewRendererPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
allowPrint={true}
|
||||
/>
|
||||
</SplitPane>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<EditorPage
|
||||
useLocalStorage = {true}
|
||||
sandbox = {true}
|
||||
showFloatingButtons = {false}
|
||||
showEditorButtons = {true}
|
||||
pageName = {'newPage'}
|
||||
brew = {props.brew}
|
||||
userThemes = {props.userThemes}
|
||||
save = {save}
|
||||
onSaveSuccess = {onSaveSuccess}
|
||||
></EditorPage>);
|
||||
};
|
||||
|
||||
export default NewPage;
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
@import '@sharedStyles/colors.less';
|
||||
|
||||
.newPage {
|
||||
.navItem.save {
|
||||
background-color : @orange;
|
||||
transition:all 0.2s;
|
||||
transition : all 0.2s;
|
||||
&:hover { background-color : @green; }
|
||||
|
||||
&.neverSaved {
|
||||
translate:-100%;
|
||||
opacity: 0;
|
||||
background-color :#333;
|
||||
cursor:auto;
|
||||
cursor : auto;
|
||||
background-color : #333333;
|
||||
opacity : 0;
|
||||
translate : -100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import './sharePage.less';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Meta } from 'vitreum/headtags';
|
||||
import Headtags from '../../../../vitreum/headtags.js';
|
||||
const Meta = Headtags.Meta;
|
||||
|
||||
import Nav from '../../navbar/nav.jsx';
|
||||
import Navbar from '../../navbar/navbar.jsx';
|
||||
import MetadataNav from '../../navbar/metadata.navitem.jsx';
|
||||
import PrintNavItem from '../../navbar/print.navitem.jsx';
|
||||
import RecentNavItems from '../../navbar/recent.navitem.jsx';
|
||||
import Nav from '@navbar/nav.jsx';
|
||||
import Navbar from '@navbar/navbar.jsx';
|
||||
import MetadataNav from '@navbar/metadata.navitem.jsx';
|
||||
import PrintNavItem from '@navbar/print.navitem.jsx';
|
||||
import RecentNavItems from '@navbar/recent.navitem.jsx';
|
||||
const { both: RecentNavItem } = RecentNavItems;
|
||||
import Account from '../../navbar/account.navitem.jsx';
|
||||
import Account from '@navbar/account.navitem.jsx';
|
||||
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
|
||||
|
||||
import request from '../../utils/request-middleware.js';
|
||||
|
||||
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
|
||||
import { printCurrentBrew, fetchThemeBundle } from '../../../../shared/helpers.js';
|
||||
import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
|
||||
|
||||
const SharePage = (props)=>{
|
||||
const { brew = DEFAULT_BREW_LOAD, disableMeta = false } = props;
|
||||
const { disableMeta = false } = props;
|
||||
|
||||
const [currentBrew, setCurrentBrew] = useState(props.brew || DEFAULT_BREW_LOAD);
|
||||
const [themeBundle, setThemeBundle] = useState({});
|
||||
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
|
||||
|
||||
@@ -34,23 +38,51 @@ const SharePage = (props)=>{
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUpdatedBrew = async ()=>{
|
||||
const response = await request
|
||||
.get(`/api/fetch/${currentBrew.shareId}`)
|
||||
.catch((error)=>{
|
||||
console.log('error at fetching updated brew: ', error);
|
||||
});
|
||||
if(response.ok && !!response.body.brew) {
|
||||
setCurrentBrew(response.body.brew);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(()=>{
|
||||
document.addEventListener('keydown', handleControlKeys);
|
||||
fetchThemeBundle(undefined, setThemeBundle, brew.renderer, brew.theme);
|
||||
fetchThemeBundle(undefined, setThemeBundle, currentBrew.renderer, currentBrew.theme);
|
||||
|
||||
let eventSource;
|
||||
// Start a stream if the brew has been updated in the last month
|
||||
if(Date.parse(props.brew?.updatedAt) > (new Date).setDate((new Date).getDate() - 30)){
|
||||
// listen for changes in the brew version
|
||||
eventSource = new EventSource(`/api/stream/${props.brew.shareId}`);
|
||||
|
||||
eventSource.addEventListener('brewUpdated', (evt)=>{
|
||||
const messageData = JSON.parse(evt.data);
|
||||
|
||||
if(messageData.version != currentBrew.version) {
|
||||
console.log('should fetch brew');
|
||||
fetchUpdatedBrew();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return ()=>{
|
||||
document.removeEventListener('keydown', handleControlKeys);
|
||||
eventSource?.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const processShareId = ()=>{
|
||||
return brew.googleId && !brew.stubbed ? brew.googleId + brew.shareId : brew.shareId;
|
||||
return currentBrew.googleId && !currentBrew.stubbed ? currentBrew.googleId + currentBrew.shareId : currentBrew.shareId;
|
||||
};
|
||||
|
||||
const renderEditLink = ()=>{
|
||||
if(!brew.editId) return null;
|
||||
if(!currentBrew.editId) return null;
|
||||
|
||||
const editLink = brew.googleId && ! brew.stubbed ? brew.googleId + brew.editId : brew.editId;
|
||||
const editLink = currentBrew.googleId && ! currentBrew.stubbed ? currentBrew.googleId + currentBrew.editId : currentBrew.editId;
|
||||
|
||||
return (
|
||||
<Nav.item color='orange' icon='fas fa-pencil-alt' href={`/edit/${editLink}`}>
|
||||
@@ -61,7 +93,7 @@ const SharePage = (props)=>{
|
||||
|
||||
const titleEl = (
|
||||
<Nav.item className='brewTitle' style={disableMeta ? { cursor: 'default' } : {}}>
|
||||
{brew.title}
|
||||
{currentBrew.title}
|
||||
</Nav.item>
|
||||
);
|
||||
|
||||
@@ -70,11 +102,11 @@ const SharePage = (props)=>{
|
||||
<Meta name='robots' content='noindex, nofollow' />
|
||||
<Navbar>
|
||||
<Nav.section className='titleSection'>
|
||||
{disableMeta ? titleEl : <MetadataNav brew={brew}>{titleEl}</MetadataNav>}
|
||||
{disableMeta ? titleEl : <MetadataNav brew={currentBrew}>{titleEl}</MetadataNav>}
|
||||
</Nav.section>
|
||||
|
||||
<Nav.section>
|
||||
{brew.shareId && (
|
||||
{currentBrew.shareId && (
|
||||
<>
|
||||
<PrintNavItem />
|
||||
<Nav.dropdown>
|
||||
@@ -91,24 +123,36 @@ const SharePage = (props)=>{
|
||||
<Nav.item color='blue' icon='fas fa-clone' href={`/new/${processShareId()}`}>
|
||||
clone to new
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
<RecentNavItem brew={brew} storageKey='view' />
|
||||
<RecentNavItem brew={currentBrew} storageKey='view' />
|
||||
<Account />
|
||||
</Nav.section>
|
||||
</Navbar>
|
||||
|
||||
<div className='content'>
|
||||
<BrewRenderer
|
||||
text={brew.text}
|
||||
style={brew.style}
|
||||
lang={brew.lang}
|
||||
renderer={brew.renderer}
|
||||
theme={brew.theme}
|
||||
text={currentBrew.text}
|
||||
style={currentBrew.style}
|
||||
lang={currentBrew.lang}
|
||||
renderer={currentBrew.renderer}
|
||||
theme={currentBrew.theme}
|
||||
themeBundle={themeBundle}
|
||||
onPageChange={handleBrewRendererPageChange}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
allowPrint={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,15 +3,15 @@ import _ from 'lodash';
|
||||
|
||||
import ListPage from '../basePages/listPage/listPage.jsx';
|
||||
|
||||
import Nav from '../../navbar/nav.jsx';
|
||||
import Navbar from '../../navbar/navbar.jsx';
|
||||
import RecentNavItems from '../../navbar/recent.navitem.jsx';
|
||||
import Nav from '@navbar/nav.jsx';
|
||||
import Navbar from '@navbar/navbar.jsx';
|
||||
import RecentNavItems from '@navbar/recent.navitem.jsx';
|
||||
const { both: RecentNavItem } = RecentNavItems;
|
||||
import Account from '../../navbar/account.navitem.jsx';
|
||||
import NewBrew from '../../navbar/newbrew.navitem.jsx';
|
||||
import HelpNavItem from '../../navbar/help.navitem.jsx';
|
||||
import ErrorNavItem from '../../navbar/error-navitem.jsx';
|
||||
import VaultNavitem from '../../navbar/vault.navitem.jsx';
|
||||
import Account from '@navbar/account.navitem.jsx';
|
||||
import NewBrew from '@navbar/newbrew.navitem.jsx';
|
||||
import HelpNavItem from '@navbar/help.navitem.jsx';
|
||||
import ErrorNavItem from '@navbar/error-navitem.jsx';
|
||||
import VaultNavitem from '@navbar/vault.navitem.jsx';
|
||||
|
||||
const UserPage = (props)=>{
|
||||
props = {
|
||||
|
||||
@@ -3,21 +3,28 @@
|
||||
import './vaultPage.less';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
import Nav from '../../navbar/nav.jsx';
|
||||
import Navbar from '../../navbar/navbar.jsx';
|
||||
import RecentNavItems from '../../navbar/recent.navitem.jsx';
|
||||
import Nav from '@navbar/nav.jsx';
|
||||
import Navbar from '@navbar/navbar.jsx';
|
||||
import RecentNavItems from '@navbar/recent.navitem.jsx';
|
||||
const { both: RecentNavItem } = RecentNavItems;
|
||||
import Account from '../../navbar/account.navitem.jsx';
|
||||
import NewBrew from '../../navbar/newbrew.navitem.jsx';
|
||||
import HelpNavItem from '../../navbar/help.navitem.jsx';
|
||||
import Account from '@navbar/account.navitem.jsx';
|
||||
import NewBrew from '@navbar/newbrew.navitem.jsx';
|
||||
import HelpNavItem from '@navbar/help.navitem.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 TagInput from '../../editor/tagInput/tagInput.jsx';
|
||||
|
||||
import request from '../../utils/request-middleware.js';
|
||||
|
||||
const DEFAULT_SORT_TYPE = 'alpha';
|
||||
const DEFAULT_SORT_DIR = 'asc';
|
||||
const DEFAULT_LAYOUT_MODE = 'grid';
|
||||
const DEFAULT_THEME_MODE = 'light';
|
||||
const USERPAGE_LAYOUT_MODE = 'HB_listPage_layout_mode';
|
||||
const USERPAGE_THEME_MODE = 'HB_listPage_theme_mode';
|
||||
|
||||
const VaultPage = (props)=>{
|
||||
const [pageState, setPageState] = useState(parseInt(props.query.page) || 1);
|
||||
|
||||
@@ -29,6 +36,8 @@ const VaultPage = (props)=>{
|
||||
const [totalBrews, setTotalBrews] = useState(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [layoutMode, setLayoutMode] = useState(DEFAULT_LAYOUT_MODE);
|
||||
const [themeMode, setThemeMode] = useState(DEFAULT_THEME_MODE);
|
||||
|
||||
const titleRef = useRef(null);
|
||||
const authorRef = useRef(null);
|
||||
@@ -39,6 +48,13 @@ const VaultPage = (props)=>{
|
||||
|
||||
useEffect(()=>{
|
||||
disableSubmitIfFormInvalid();
|
||||
|
||||
const newLayoutMode = localStorage.getItem(USERPAGE_LAYOUT_MODE) || DEFAULT_LAYOUT_MODE;
|
||||
const newThemeMode = localStorage.getItem(USERPAGE_THEME_MODE) || DEFAULT_THEME_MODE;
|
||||
|
||||
setLayoutMode(newLayoutMode);
|
||||
setThemeMode(newThemeMode);
|
||||
|
||||
loadPage(pageState, true, props.query.sort, props.query.dir);
|
||||
}, []);
|
||||
|
||||
@@ -76,7 +92,7 @@ const VaultPage = (props)=>{
|
||||
updateStateWithBrews([], 1);
|
||||
});
|
||||
|
||||
if(response.ok)
|
||||
if(response?.ok)
|
||||
updateStateWithBrews(response.body.brews, page);
|
||||
};
|
||||
|
||||
@@ -85,7 +101,8 @@ const VaultPage = (props)=>{
|
||||
|
||||
const response = await request.get(`/api/vault/total?title=${title}&author=${author}&v3=${v3}&legacy=${legacy}`)
|
||||
.catch((error)=>{
|
||||
console.log('error at loadTotal: ', error);
|
||||
console.log('error at loadTotal: ');
|
||||
console.error(error);
|
||||
setError(error);
|
||||
updateStateWithBrews([], 1);
|
||||
});
|
||||
@@ -274,7 +291,7 @@ const VaultPage = (props)=>{
|
||||
const oppositeDir = dirState === 'asc' ? 'desc' : 'asc';
|
||||
|
||||
return (
|
||||
<div className={`sort-option ${sortState === optionValue ? `active` : ''}`}>
|
||||
<div className={`option ${sortState === optionValue ? `active` : ''}`}>
|
||||
<button onClick={()=>loadPage(1, false, optionValue, oppositeDir)}>
|
||||
{optionTitle}
|
||||
</button>
|
||||
@@ -285,10 +302,10 @@ const VaultPage = (props)=>{
|
||||
);
|
||||
};
|
||||
|
||||
const renderSortBar = ()=>{
|
||||
const renderSortOptions = ()=>{
|
||||
|
||||
return (
|
||||
<div className='sort-container'>
|
||||
<div className='radio-toggle sort-container'>
|
||||
{renderSortOption('Title', 'title', props.query.dir)}
|
||||
{renderSortOption('Created Date', 'createdAt', props.query.dir)}
|
||||
{renderSortOption('Updated Date', 'updatedAt', props.query.dir)}
|
||||
@@ -297,6 +314,64 @@ const VaultPage = (props)=>{
|
||||
);
|
||||
};
|
||||
|
||||
const handleLayoutChange = (e, mode)=>{
|
||||
setLayoutMode(e.target.checked ? mode : DEFAULT_LAYOUT_MODE);
|
||||
localStorage.setItem(USERPAGE_LAYOUT_MODE, e.target.checked ? mode : DEFAULT_LAYOUT_MODE);
|
||||
return;
|
||||
};
|
||||
const renderLayoutModeOptions = ()=>{
|
||||
return (
|
||||
<div className='radio-toggle layout-container'>
|
||||
<h6>View:</h6>
|
||||
<label className='option' title='grid'>
|
||||
<input name='layout-mode' type='radio' onChange={(e)=>handleLayoutChange(e, 'grid')} checked={layoutMode === 'grid'} />
|
||||
<i className='fac compressed-grid'></i>
|
||||
</label>
|
||||
<label className='option' title='list'>
|
||||
<input name='layout-mode' type='radio' onChange={(e)=>handleLayoutChange(e, 'list')} checked={layoutMode === 'list'} />
|
||||
<i className='fas fa-bars'></i>
|
||||
</label>
|
||||
<label className='option' title='card'>
|
||||
<input name='layout-mode' type='radio' onChange={(e)=>handleLayoutChange(e, 'card')} checked={layoutMode === 'card'} />
|
||||
<i className='fac format-card'></i>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const handleThemeChange = (e, mode)=>{
|
||||
setThemeMode(e.target.checked ? mode : DEFAULT_THEME_MODE);
|
||||
localStorage.setItem(USERPAGE_THEME_MODE, e.target.checked ? mode : DEFAULT_THEME_MODE);
|
||||
return;
|
||||
};
|
||||
|
||||
const renderThemePicker = ()=>{
|
||||
return (
|
||||
<div className="radio-toggle theme-container">
|
||||
<h6>Theme:</h6>
|
||||
<label className="option">
|
||||
<input type="radio" name="theme" onChange={(e)=>handleThemeChange(e, 'light')} checked={themeMode === 'light'}/>
|
||||
<i className="fas fa-sun"/>
|
||||
</label>
|
||||
<label className="option">
|
||||
<input type="radio" name="theme" onChange={(e)=>handleThemeChange(e, 'dark')} checked={themeMode === 'dark'}/>
|
||||
<i className="fas fa-moon"/>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
const renderNav = () =>{
|
||||
return (
|
||||
<div className="listPageNav">
|
||||
{renderLayoutModeOptions()}
|
||||
{renderThemePicker()}
|
||||
{renderSortOptions()}
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
const renderPaginationControls = ()=>{
|
||||
if(!totalBrews || totalBrews < 10) return null;
|
||||
|
||||
@@ -377,13 +452,23 @@ const VaultPage = (props)=>{
|
||||
}
|
||||
|
||||
if(error) {
|
||||
const errorText = ErrorIndex()[error.HBErrorCode.toString()] || '';
|
||||
if(error.HBErrorCode === undefined) {
|
||||
return (
|
||||
<div className='foundBrews noBrews'>
|
||||
<h3>Error: Unknown error</h3>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
|
||||
return (
|
||||
<div className='foundBrews noBrews'>
|
||||
const errorText = ErrorIndex()[error.HBErrorCode.toString()] || '';
|
||||
|
||||
return (
|
||||
<div className='foundBrews noBrews'>
|
||||
<h3>Error: {errorText}</h3>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if(!brewCollection) {
|
||||
@@ -403,22 +488,26 @@ const VaultPage = (props)=>{
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='foundBrews'>
|
||||
<div className={`foundBrews ${themeMode + 'Mode'}`}>
|
||||
<span className='totalBrews'>
|
||||
{`Brews found: `}
|
||||
<span>{totalBrews}</span>
|
||||
</span>
|
||||
{brewCollection.length > 10 && renderPaginationControls()}
|
||||
{brewCollection.map((brew, index)=>{
|
||||
return (
|
||||
<BrewItem
|
||||
brew={{ ...brew }}
|
||||
key={index}
|
||||
reportError={props.reportError}
|
||||
renderStorage={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className='brewCollection'>
|
||||
<div className={`brewGroup ${layoutMode}`}>
|
||||
{brewCollection.map((brew, index)=>{
|
||||
return (
|
||||
<BrewItem
|
||||
brew={{ ...brew }}
|
||||
key={index}
|
||||
reportError={props.reportError}
|
||||
renderStorage={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{renderPaginationControls()}
|
||||
</div>
|
||||
);
|
||||
@@ -433,7 +522,7 @@ const VaultPage = (props)=>{
|
||||
<SplitPane showDividerButtons={false}>
|
||||
<div className='form dataGroup'>{renderForm()}</div>
|
||||
<div className='resultsContainer dataGroup'>
|
||||
{renderSortBar()}
|
||||
{renderNav()}
|
||||
{renderFoundBrews()}
|
||||
</div>
|
||||
</SplitPane>
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
@import '@themes/assets/assets.less';
|
||||
@import '@sharedStyles/core.less';
|
||||
|
||||
.vaultPage {
|
||||
height : 100%;
|
||||
overflow-y : hidden;
|
||||
|
||||
--bg-clr : white;
|
||||
--bg-image : @backgroundImageAlt;
|
||||
--h1-clr : black;
|
||||
--h2-clr : #000077;
|
||||
--label-clr : black;
|
||||
--input-bg : white;
|
||||
--input-bg-hover : #DDDDDD;
|
||||
--input-text-clr : black;
|
||||
--input-placeholder-clr : grey;
|
||||
|
||||
*:not(input) { user-select : none; }
|
||||
|
||||
.form {
|
||||
background:white;
|
||||
}
|
||||
.form { background : white; }
|
||||
|
||||
:where(.content .dataGroup) {
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
|
||||
&.form .brewLookup {
|
||||
color : var(--label-clr);
|
||||
background-color : var(--bg-clr);
|
||||
background-image : var(--bg-image);
|
||||
background-size : cover;
|
||||
height:100%;
|
||||
|
||||
position : relative;
|
||||
padding : 50px clamp(20px, 4vw, 50px);
|
||||
|
||||
|
||||
small {
|
||||
font-size : 10pt;
|
||||
@@ -33,6 +51,7 @@
|
||||
h1, h2, h3, h4 {
|
||||
font-family : 'CodeBold';
|
||||
letter-spacing : 2px;
|
||||
color : var(--h1-clr);
|
||||
}
|
||||
|
||||
legend {
|
||||
@@ -87,14 +106,14 @@
|
||||
align-items : center;
|
||||
margin : 10px 0;
|
||||
}
|
||||
select { margin : 0 10px; }
|
||||
|
||||
input {
|
||||
input[type='text'], textarea, select {
|
||||
margin : 0 10px;
|
||||
|
||||
&:invalid { background : rgb(255, 188, 181); }
|
||||
|
||||
|
||||
color : var(--input-text-clr);
|
||||
background-color : var(--input-bg);
|
||||
border : 1px solid var(--input-placeholder-clr);
|
||||
&:hover, :focus { outline : 1px solid var(--input-placeholder-clr); background-color : var(--input-bg-hover); }
|
||||
&::placeholder { color : var(--input-placeholder-clr); }
|
||||
&:invalid { background : #ffbcb5; }
|
||||
}
|
||||
|
||||
#searchButton {
|
||||
@@ -117,69 +136,40 @@
|
||||
height : 100%;
|
||||
overflow-y : auto;
|
||||
font-size : 0.34cm;
|
||||
|
||||
h3 {
|
||||
font-family : 'Open Sans';
|
||||
font-weight : 900;
|
||||
color : white;
|
||||
}
|
||||
|
||||
.sort-container {
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
column-gap : 15px;
|
||||
justify-content : center;
|
||||
height : 30px;
|
||||
color : white;
|
||||
background-color : #555555;
|
||||
border-top : 1px solid #666666;
|
||||
border-bottom : 1px solid #666666;
|
||||
|
||||
.sort-option {
|
||||
display : flex;
|
||||
align-items : center;
|
||||
padding : 0 8px;
|
||||
|
||||
&:hover { background-color : #444444; }
|
||||
|
||||
&.active {
|
||||
background-color : #333333;
|
||||
|
||||
button {
|
||||
font-weight : 800;
|
||||
color : white;
|
||||
|
||||
& + .sortDir { padding-left : 5px; }
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
padding : 0;
|
||||
font-size : 11px;
|
||||
font-weight : normal;
|
||||
color : #CCCCCC;
|
||||
text-transform : uppercase;
|
||||
background-color : transparent;
|
||||
|
||||
&:hover { background : none; }
|
||||
}
|
||||
}
|
||||
}
|
||||
container-type : inline-size;
|
||||
|
||||
.foundBrews {
|
||||
position : relative;
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
max-height : 100%;
|
||||
padding : 70px 50px;
|
||||
display:flex;
|
||||
flex-direction: column;
|
||||
gap:20px;
|
||||
padding : clamp(20px, 5vh, 200px) clamp(20px, 20vw, 200px);
|
||||
overflow-y : scroll;
|
||||
container-type : inline-size;
|
||||
scrollbar-gutter : stable;
|
||||
@container (max-width : 1200px) {
|
||||
padding : clamp(20px, 5vh, 200px) clamp(20px, 10vw, 200px);
|
||||
}
|
||||
@container (max-width : 800px) {
|
||||
padding : clamp(20px, 5vh, 200px) clamp(20px, 5vw, 200px);
|
||||
}
|
||||
|
||||
h3 { font-size : 25px; }
|
||||
&::-webkit-scrollbar {
|
||||
width : 15px;
|
||||
&:horizontal {
|
||||
width : auto;
|
||||
height : 20px;
|
||||
}
|
||||
&-thumb {
|
||||
background : linear-gradient(90deg, var(--bg-clr) 12px, #00000000 12px);
|
||||
&:horizontal { background : linear-gradient(0deg, var(--bg-clr) 15px, #00000000 15px); }
|
||||
}
|
||||
&-corner { visibility : hidden; }
|
||||
}
|
||||
|
||||
&.noBrews {
|
||||
display : grid;
|
||||
place-items : center;
|
||||
margin : 10px 0px;
|
||||
font-size : 25px;
|
||||
font-style : italic;
|
||||
font-weight : 900;
|
||||
color : white;
|
||||
}
|
||||
|
||||
@@ -202,6 +192,64 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.brewCollection {
|
||||
h1 {
|
||||
width : fit-content;
|
||||
margin-bottom : 20px;
|
||||
font-size : 25px;
|
||||
color : #DDDDDD;
|
||||
|
||||
&.active::before,
|
||||
&.inactive::before {
|
||||
padding-right : 0.5em;
|
||||
font-family : 'Font Awesome 6 Free';
|
||||
font-size : 0.6cm;
|
||||
font-weight : 900;
|
||||
}
|
||||
&.active {
|
||||
color : white;
|
||||
&::before { content : '\f107'; }
|
||||
}
|
||||
&.inactive::before { content : '\f105'; }
|
||||
}
|
||||
h1:hover { cursor : pointer; }
|
||||
|
||||
.brewGroup {
|
||||
margin-bottom : 20px;
|
||||
&.grid {
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
gap : 20px;
|
||||
justify-content : center;
|
||||
width : fit-content;
|
||||
min-width : 200px;
|
||||
}
|
||||
|
||||
&.list {
|
||||
display : flex;
|
||||
flex-direction : column;
|
||||
gap : 2px;
|
||||
}
|
||||
&.card {
|
||||
--columns : 4;
|
||||
display : grid;
|
||||
grid-template-columns : repeat(var(--columns), 1fr);
|
||||
gap : 20px;
|
||||
justify-items : center;
|
||||
|
||||
@container (max-width : 1700px) {--columns : 3;}
|
||||
@container (max-width : 1400px) {--columns : 2;}
|
||||
@container (max-width : 900px) {--columns : 1;}
|
||||
h1 { position : absolute; }
|
||||
|
||||
&:not(:has(.brewItem:last-child:nth-child(3n))) {
|
||||
.brewItem:last-child { margin-right : auto; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.totalBrews {
|
||||
position : fixed;
|
||||
right : 0;
|
||||
@@ -233,66 +281,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.brewItem {
|
||||
width : 47%;
|
||||
margin-right : 40px;
|
||||
color : black;
|
||||
isolation : isolate;
|
||||
transition : width 0.5s;
|
||||
|
||||
&::after {
|
||||
position : absolute;
|
||||
inset : 0;
|
||||
z-index : -2;
|
||||
display : block;
|
||||
content : '';
|
||||
background-image : url('/assets/parchmentBackground.jpg');
|
||||
}
|
||||
|
||||
&:nth-child(even of .brewItem) { margin-right : 0; }
|
||||
|
||||
h2 {
|
||||
font-family : 'MrEavesRemake';
|
||||
font-size : 0.75cm;
|
||||
font-weight : 800;
|
||||
line-height : 0.988em;
|
||||
color : var(--HB_Color_HeaderText);
|
||||
}
|
||||
.info {
|
||||
position : relative;
|
||||
z-index : 2;
|
||||
font-family : 'ScalySansRemake';
|
||||
font-size : 1.2em;
|
||||
|
||||
>span {
|
||||
margin-right : 12px;
|
||||
line-height : 1.5em;
|
||||
}
|
||||
}
|
||||
.links { z-index : 2; }
|
||||
|
||||
hr {
|
||||
visibility : hidden;
|
||||
margin : 0px;
|
||||
}
|
||||
|
||||
.thumbnail { z-index : -1; }
|
||||
}
|
||||
|
||||
.paginationControls {
|
||||
position : absolute;
|
||||
top : 35px;
|
||||
left : 50%;
|
||||
display : grid;
|
||||
grid-template-areas : 'previousPage currentPage nextPage';
|
||||
grid-template-columns : 50px 1fr 50px;
|
||||
gap : 20px;
|
||||
place-items : center;
|
||||
width : auto;
|
||||
width : fit-content;
|
||||
margin-inline:auto;
|
||||
font-size : 15px;
|
||||
translate : -50%;
|
||||
|
||||
&:last-child { top : unset; }
|
||||
|
||||
.pages {
|
||||
display : flex;
|
||||
@@ -314,8 +311,11 @@
|
||||
text-underline-position : under;
|
||||
cursor : pointer;
|
||||
|
||||
&.currentPage {
|
||||
&:hover {
|
||||
color : gold;
|
||||
}
|
||||
&.currentPage {
|
||||
color:@yellow;
|
||||
text-decoration : underline;
|
||||
pointer-events : none;
|
||||
}
|
||||
@@ -339,6 +339,77 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listPageNav {
|
||||
position : relative;
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
row-gap : 10px;
|
||||
column-gap : 20px;
|
||||
align-items : center;
|
||||
justify-content : center;
|
||||
width : 100%;
|
||||
height : fit-content;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
color : white;
|
||||
background : #555555;
|
||||
border-top : 1px solid #666666;
|
||||
border-bottom : 1px solid #666666;
|
||||
row-rule : 1px solid #666666;
|
||||
|
||||
> div {
|
||||
width : fit-content;
|
||||
height : 30px;
|
||||
padding-inline : 10px;
|
||||
}
|
||||
|
||||
.radio-toggle {
|
||||
z-index : 1;
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
row-gap : 5px;
|
||||
align-items : center;
|
||||
justify-content : center;
|
||||
text-align : center;
|
||||
h6 {
|
||||
margin-right : 5px;
|
||||
font-size : 14px;
|
||||
}
|
||||
|
||||
.option {
|
||||
display : flex;
|
||||
align-items : center;
|
||||
height : 100%;
|
||||
padding : 0 8px;
|
||||
color : #CCCCCC;
|
||||
|
||||
input[type='radio'] { display : none; }
|
||||
|
||||
button {
|
||||
padding : 0;
|
||||
font-size : 11px;
|
||||
font-weight : normal;
|
||||
text-transform : uppercase;
|
||||
background-color : transparent;
|
||||
}
|
||||
&:hover { background-color : #444444; }
|
||||
|
||||
&.active, &:has(input:checked) {
|
||||
font-weight : bold;
|
||||
color : white;
|
||||
background-color : #333333;
|
||||
|
||||
button {
|
||||
height : 100%;
|
||||
font-weight : 800;
|
||||
& + .sortDir { padding-left : 5px; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes trailingDots {
|
||||
@@ -351,16 +422,4 @@
|
||||
|
||||
66%,
|
||||
100% { content : ' ...'; }
|
||||
}
|
||||
|
||||
@container (width < 670px) {
|
||||
.vaultPage {
|
||||
|
||||
.dataGroup.form .brewLookup { padding : 1px 20px 20px 10px; }
|
||||
|
||||
.dataGroup.resultsContainer .foundBrews .brewItem {
|
||||
width : 100%;
|
||||
margin-inline : auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 399.76 400.41">
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<rect width="183.16" height="77.61" rx="16.76" ry="16.76"/>
|
||||
<rect y="107.6" width="183.16" height="77.61" rx="16.76" ry="16.76"/>
|
||||
<rect y="215.2" width="183.16" height="77.61" rx="16.76" ry="16.76"/>
|
||||
<rect y="322.8" width="183.16" height="77.61" rx="16.76" ry="16.76"/>
|
||||
<rect x="216.6" width="183.16" height="77.61" rx="16.76" ry="16.76"/>
|
||||
<rect x="216.6" y="107.6" width="183.16" height="77.61" rx="16.76" ry="16.76"/>
|
||||
<rect x="216.6" y="215.2" width="183.16" height="77.61" rx="16.76" ry="16.76"/>
|
||||
<rect x="216.6" y="322.8" width="183.16" height="77.61" rx="16.76" ry="16.76"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 807 B |
@@ -32,3 +32,5 @@
|
||||
.single-spread { mask-image : url('../icons/single-spread.svg'); }
|
||||
.facing-spread { mask-image : url('../icons/facing-spread.svg'); }
|
||||
.flow-spread { mask-image : url('../icons/flow-spread.svg'); }
|
||||
.format-card { mask-image: url('../icons/format-card.svg'); }
|
||||
.compressed-grid { mask-image: url('../icons//compressed-grid.svg'); }
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 324.38 433.46" width="324.38" height="433.46" xml:space="preserve">
|
||||
<g id="Layer_2-2" data-name="Layer 2">
|
||||
<path d="M267.61,0H56.77C25.42,0,0,25.42,0,56.77v319.92c0,31.35,25.42,56.77,56.77,56.77h210.84c31.35,0,56.77-25.42,56.77-56.77V56.77c0-31.35-25.42-56.77-56.77-56.77ZM94.52,45.62c12.6,0,22.81,10.21,22.81,22.81s-10.21,22.81-22.81,22.81-22.81-10.21-22.81-22.81,10.21-22.81,22.81-22.81ZM73.06,187.38l34.38-57.2c4.29-7.14,14.6-7.26,19.05-.22l7.7,12.17c1.02,1.61,3.36,1.65,4.44.08l31.31-45.56c4.44-6.45,13.96-6.47,18.42-.04l62.23,89.65c5.3,7.63-.16,18.07-9.45,18.07H82.65c-8.7.01-14.07-9.49-9.59-16.95ZM258.24,388.61H67.91c-6.8,0-12.31-5.51-12.31-12.31s5.51-12.31,12.31-12.31h190.33c6.8,0,12.31,5.51,12.31,12.31s-5.51,12.31-12.31,12.31ZM258.24,348.81H67.91c-6.8,0-12.31-5.51-12.31-12.31s5.51-12.31,12.31-12.31h190.33c6.8,0,12.31,5.51,12.31,12.31s-5.51,12.31-12.31,12.31ZM258.24,309H67.91c-6.8,0-12.31-5.51-12.31-12.31s5.51-12.31,12.31-12.31h190.33c6.8,0,12.31,5.51,12.31,12.31s-5.51,12.31-12.31,12.31Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,33 +0,0 @@
|
||||
const template = async function(name, title='', props = {}){
|
||||
const ogTags = [];
|
||||
const ogMeta = props.ogMeta ?? {};
|
||||
Object.entries(ogMeta).forEach(([key, value])=>{
|
||||
if(!value) return;
|
||||
const tag = `<meta property="og:${key}" content="${value}">`;
|
||||
ogTags.push(tag);
|
||||
});
|
||||
const ogMetaTags = ogTags.join('\n');
|
||||
|
||||
const ssrModule = await import(`../build/${name}/ssr.cjs`);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" 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 href=${`/${name}/bundle.css`} type="text/css" rel='stylesheet' />
|
||||
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon" />
|
||||
${ogMetaTags}
|
||||
<meta name="twitter:card" content="summary">
|
||||
<title>${title.length ? `${title} - The Homebrewery`: 'The Homebrewery - NaturalCrit'}</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="reactRoot">${ssrModule.default(props)}</main>
|
||||
<script src=${`/${name}/bundle.js`}></script>
|
||||
<script>start_app(${JSON.stringify(props)})</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
export default template;
|
||||
Reference in New Issue
Block a user