mirror of
https://github.com/naturalcrit/homebrewery.git
synced 2026-08-06 08:57:38 +00:00
Merge branch 'master' of https://github.com/naturalcrit/homebrewery into add-remove-author-if-owner
This commit is contained in:
@@ -4,16 +4,29 @@ import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import _ from 'lodash';
|
||||
import dedent from 'dedent';
|
||||
import Markdown from '@shared/markdown.js';
|
||||
|
||||
import CodeEditor from '../../components/codeEditor/codeEditor.jsx';
|
||||
import CodeEditor from '@components/codeEditor/codeEditor.jsx';
|
||||
import SnippetBar from './snippetbar/snippetbar.jsx';
|
||||
import MetadataEditor from './metadataEditor/metadataEditor.jsx';
|
||||
|
||||
const EDITOR_THEME_KEY = 'HB_editor_theme';
|
||||
|
||||
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 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,6 +43,7 @@ const DEFAULT_SNIPPET_TEXT = dedent`
|
||||
This snippet is accessible in the brew tab, and will be inherited if the brew is used as a theme.
|
||||
`;
|
||||
let isJumping = false;
|
||||
let jumpSource = null;
|
||||
|
||||
const Editor = createReactClass({
|
||||
displayName : 'Editor',
|
||||
@@ -72,23 +86,20 @@ const Editor = createReactClass({
|
||||
|
||||
componentDidMount : function() {
|
||||
|
||||
this.highlightCustomMarkdown();
|
||||
document.getElementById('BrewRenderer').addEventListener('keydown', this.handleControlKeys);
|
||||
const brewRenderer = document.getElementById('BrewRenderer');
|
||||
brewRenderer.onload = ()=>brewRenderer.contentDocument?.addEventListener('keydown', this.handleControlKeys);
|
||||
document.addEventListener('keydown', this.handleControlKeys);
|
||||
|
||||
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
|
||||
});
|
||||
if(editorTheme && EditorThemes.includes(editorTheme)) {
|
||||
this.setState({ editorTheme });
|
||||
} else {
|
||||
this.setState({ editorTheme: 'default' });
|
||||
}
|
||||
const snippetBar = document.querySelector('.editor > .snippetBar');
|
||||
if(!snippetBar) return;
|
||||
|
||||
this.resizeObserver = new ResizeObserver(entries=>{
|
||||
this.resizeObserver = new ResizeObserver((entries)=>{
|
||||
const height = document.querySelector('.editor > .snippetBar').offsetHeight;
|
||||
this.setState({ snippetBarHeight: height });
|
||||
});
|
||||
@@ -98,7 +109,6 @@ const Editor = createReactClass({
|
||||
|
||||
componentDidUpdate : function(prevProps, prevState, snapshot) {
|
||||
|
||||
this.highlightCustomMarkdown();
|
||||
if(prevProps.moveBrew !== this.props.moveBrew)
|
||||
this.brewJump();
|
||||
|
||||
@@ -132,22 +142,16 @@ const Editor = createReactClass({
|
||||
}
|
||||
},
|
||||
|
||||
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);
|
||||
updateCurrentCursorPage : function(pageNumber) {
|
||||
this.props.onCursorPageChange(pageNumber);
|
||||
},
|
||||
|
||||
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);
|
||||
updateCurrentViewPage : function(pageNumber) {
|
||||
this.props.onViewPageChange(pageNumber);
|
||||
},
|
||||
|
||||
handleInject : function(injectText){
|
||||
this.codeEditor.current?.injectText(injectText, false);
|
||||
this.codeEditor.current?.injectText(injectText);
|
||||
},
|
||||
|
||||
handleViewChange : function(newView){
|
||||
@@ -156,181 +160,12 @@ const Editor = createReactClass({
|
||||
this.setState({
|
||||
view : newView
|
||||
}, ()=>{
|
||||
this.codeEditor.current?.codeMirror?.focus();
|
||||
this.codeEditor.current?.focus();
|
||||
});
|
||||
},
|
||||
|
||||
highlightCustomMarkdown : function(){
|
||||
if(!this.codeEditor.current?.codeMirror) return;
|
||||
if((this.state.view === 'text') ||(this.state.view === 'snippet')) {
|
||||
const codeMirror = this.codeEditor.current.codeMirror;
|
||||
|
||||
codeMirror?.operation(()=>{ // Batch CodeMirror styling
|
||||
|
||||
const foldLines = [];
|
||||
|
||||
//reset custom text styles
|
||||
const customHighlights = codeMirror?.getAllMarks().filter((mark)=>{
|
||||
// Record details of folded sections
|
||||
if(mark.__isFold) {
|
||||
const fold = mark.find();
|
||||
foldLines.push({ from: fold.from?.line, to: fold.to?.line });
|
||||
}
|
||||
return !mark.__isFold;
|
||||
}); //Don't undo code folding
|
||||
|
||||
for (let i=customHighlights.length - 1;i>=0;i--) customHighlights[i].clear();
|
||||
|
||||
let userSnippetCount = 1; // start snippet count from snippet 1
|
||||
let editorPageCount = 1; // start page count from page 1
|
||||
|
||||
const whichSource = this.state.view === 'text' ? this.props.brew.text : this.props.brew.snippets;
|
||||
_.forEach(whichSource?.split('\n'), (line, lineNumber)=>{
|
||||
|
||||
const tabHighlight = this.state.view === 'text' ? 'pageLine' : 'snippetLine';
|
||||
const textOrSnip = this.state.view === 'text';
|
||||
|
||||
//reset custom line styles
|
||||
codeMirror?.removeLineClass(lineNumber, 'background', 'pageLine');
|
||||
codeMirror?.removeLineClass(lineNumber, 'background', 'snippetLine');
|
||||
codeMirror?.removeLineClass(lineNumber, 'text');
|
||||
codeMirror?.removeLineClass(lineNumber, 'wrap', 'sourceMoveFlash');
|
||||
|
||||
// Don't process lines inside folded text
|
||||
// If the current lineNumber is inside any folded marks, skip line styling
|
||||
if(foldLines.some((fold)=>lineNumber >= fold.from && lineNumber <= fold.to))
|
||||
return;
|
||||
|
||||
// Styling for \page breaks
|
||||
if((this.props.renderer == 'legacy' && line.includes('\\page')) ||
|
||||
(this.props.renderer == 'V3' && line.match(textOrSnip ? PAGEBREAK_REGEX_V3 : SNIPPETBREAK_REGEX_V3))) {
|
||||
|
||||
if((lineNumber > 0) && (textOrSnip)) // Since \page is optional on first line of document,
|
||||
editorPageCount += 1; // don't use it to increment page count; stay at 1
|
||||
else if(this.state.view !== 'text') userSnippetCount += 1;
|
||||
|
||||
// add back the original class 'background' but also add the new class '.pageline'
|
||||
codeMirror?.addLineClass(lineNumber, 'background', tabHighlight);
|
||||
const pageCountElement = Object.assign(document.createElement('span'), {
|
||||
className : 'editor-page-count',
|
||||
textContent : textOrSnip ? editorPageCount : userSnippetCount
|
||||
});
|
||||
codeMirror?.setBookmark({ line: lineNumber, ch: line.length }, pageCountElement);
|
||||
};
|
||||
|
||||
|
||||
// New CodeMirror styling for V3 renderer
|
||||
if(this.props.renderer === 'V3') {
|
||||
if(line.match(/^\\column(?:break)?$/)){
|
||||
codeMirror?.addLineClass(lineNumber, 'text', 'columnSplit');
|
||||
}
|
||||
|
||||
// definition lists
|
||||
if(line.includes('::')){
|
||||
if(/^:*$/.test(line) == true){ return; };
|
||||
const regex = /^([^\n]*?:?\s?)(::[^\n]*)(?:\n|$)/ymd; // the `d` flag, for match indices, throws an ESLint error.
|
||||
let match;
|
||||
while ((match = regex.exec(line)) != null){
|
||||
codeMirror?.markText({ line: lineNumber, ch: match.indices[0][0] }, { line: lineNumber, ch: match.indices[0][1] }, { className: 'dl-highlight' });
|
||||
codeMirror?.markText({ line: lineNumber, ch: match.indices[1][0] }, { line: lineNumber, ch: match.indices[1][1] }, { className: 'dt-highlight' });
|
||||
codeMirror?.markText({ line: lineNumber, ch: match.indices[2][0] }, { line: lineNumber, ch: match.indices[2][1] }, { className: 'dd-highlight' });
|
||||
const ddIndex = match.indices[2][0];
|
||||
const colons = /::/g;
|
||||
const colonMatches = colons.exec(match[2]);
|
||||
if(colonMatches !== null){
|
||||
codeMirror?.markText({ line: lineNumber, ch: colonMatches.index + ddIndex }, { line: lineNumber, ch: colonMatches.index + colonMatches[0].length + ddIndex }, { className: 'dl-colon-highlight' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscript & Superscript
|
||||
if(line.includes('^')) {
|
||||
let startIndex = line.indexOf('^');
|
||||
const superRegex = /\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^/gy;
|
||||
const subRegex = /\^\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^\^/gy;
|
||||
|
||||
while (startIndex >= 0) {
|
||||
superRegex.lastIndex = subRegex.lastIndex = startIndex;
|
||||
let isSuper = false;
|
||||
const match = subRegex.exec(line) || superRegex.exec(line);
|
||||
if(match) {
|
||||
isSuper = !subRegex.lastIndex;
|
||||
codeMirror?.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: isSuper ? 'superscript' : 'subscript' });
|
||||
}
|
||||
startIndex = line.indexOf('^', Math.max(startIndex + 1, subRegex.lastIndex, superRegex.lastIndex));
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight injectors {style}
|
||||
if(line.includes('{') && line.includes('}')){
|
||||
const regex = /(?:^|[^{\n])({(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\2})/gm;
|
||||
let match;
|
||||
while ((match = regex.exec(line)) != null) {
|
||||
codeMirror?.markText({ line: lineNumber, ch: line.indexOf(match[1]) }, { line: lineNumber, ch: line.indexOf(match[1]) + match[1].length }, { className: 'injection' });
|
||||
}
|
||||
}
|
||||
// Highlight inline spans {{content}}
|
||||
if(line.includes('{{') && line.includes('}}')){
|
||||
const regex = /{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *|}}/g;
|
||||
let match;
|
||||
let blockCount = 0;
|
||||
while ((match = regex.exec(line)) != null) {
|
||||
if(match[0].startsWith('{')) {
|
||||
blockCount += 1;
|
||||
} else {
|
||||
blockCount -= 1;
|
||||
}
|
||||
if(blockCount < 0) {
|
||||
blockCount = 0;
|
||||
continue;
|
||||
}
|
||||
codeMirror?.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: 'inline-block' });
|
||||
}
|
||||
} else if(line.trimLeft().startsWith('{{') || line.trimLeft().startsWith('}}')){
|
||||
// Highlight block divs {{\n Content \n}}
|
||||
let endCh = line.length+1;
|
||||
|
||||
const match = line.match(/^ *{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *$|^ *}}$/);
|
||||
if(match)
|
||||
endCh = match.index+match[0].length;
|
||||
codeMirror?.markText({ line: lineNumber, ch: 0 }, { line: lineNumber, ch: endCh }, { className: 'block' });
|
||||
}
|
||||
|
||||
// Emojis
|
||||
if(line.match(/:[^\s:]+:/g)) {
|
||||
let startIndex = line.indexOf(':');
|
||||
const emojiRegex = /:[^\s:]+:/gy;
|
||||
|
||||
while (startIndex >= 0) {
|
||||
emojiRegex.lastIndex = startIndex;
|
||||
const match = emojiRegex.exec(line);
|
||||
if(match) {
|
||||
let tokens = Markdown.marked.lexer(match[0]);
|
||||
tokens = tokens[0].tokens.filter((t)=>t.type == 'emoji');
|
||||
if(!tokens.length)
|
||||
return;
|
||||
|
||||
const startPos = { line: lineNumber, ch: match.index };
|
||||
const endPos = { line: lineNumber, ch: match.index + match[0].length };
|
||||
|
||||
// Iterate over conflicting marks and clear them
|
||||
const marks = codeMirror?.findMarks(startPos, endPos);
|
||||
marks.forEach(function(marker) {
|
||||
if(!marker.__isFold) marker.clear();
|
||||
});
|
||||
codeMirror?.markText(startPos, endPos, { className: 'emoji' });
|
||||
}
|
||||
startIndex = line.indexOf(':', Math.max(startIndex + 1, emojiRegex.lastIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
brewJump : function(targetPage=this.props.currentEditorCursorPageNum, smooth=true){
|
||||
if(!window || !this.isText() || isJumping)
|
||||
if(!window || !this.isText() || isJumping || jumpSource === 'source')
|
||||
return;
|
||||
|
||||
// Get current brewRenderer scroll position and calculate target position
|
||||
@@ -343,11 +178,13 @@ const Editor = createReactClass({
|
||||
clearTimeout(scrollingTimeout); // Reset the timer every time a scroll event occurs
|
||||
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);
|
||||
|
||||
@@ -371,54 +208,17 @@ const Editor = createReactClass({
|
||||
},
|
||||
|
||||
sourceJump : function(targetPage=this.props.currentBrewRendererPageNum, smooth=true){
|
||||
if(!this.isText() || isJumping)
|
||||
if(!this.isText() || isJumping || jumpSource === 'brew')
|
||||
return;
|
||||
|
||||
const textSplit = this.props.renderer == 'V3' ? PAGEBREAK_REGEX_V3 : /\\page/;
|
||||
const textString = this.props.brew.text.split(textSplit).slice(0, targetPage-1).join(textSplit);
|
||||
const targetLine = textString.match('\n') ? textString.split('\n').length - 1 : -1;
|
||||
const editor = this.codeEditor.current;
|
||||
if(!editor) return;
|
||||
jumpSource = 'source';
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
isJumping = true;
|
||||
checkIfScrollComplete();
|
||||
if(this.codeEditor.current?.codeMirror) {
|
||||
this.codeEditor.current.codeMirror?.on('scroll', checkIfScrollComplete);
|
||||
}
|
||||
|
||||
if(smooth) {
|
||||
//Scroll 1/10 of the way every 10ms until 1px off.
|
||||
const incrementalScroll = setInterval(()=>{
|
||||
currentY += (targetY - currentY) / 10;
|
||||
this.codeEditor.current.codeMirror?.scrollTo(null, currentY);
|
||||
|
||||
// Update target: target height is not accurate until within +-10 lines of the visible window
|
||||
if(Math.abs(targetY - currentY > 100))
|
||||
targetY = this.codeEditor.current.codeMirror?.heightAtLine(targetLine, 'local', true);
|
||||
|
||||
// End when close enough
|
||||
if(Math.abs(targetY - currentY) < 1) {
|
||||
this.codeEditor.current.codeMirror?.scrollTo(null, targetY); // Scroll any remaining difference
|
||||
this.codeEditor.current.setCursorPosition({ line: targetLine + 1, ch: 0 });
|
||||
this.codeEditor.current.codeMirror?.addLineClass(targetLine + 1, 'wrap', 'sourceMoveFlash');
|
||||
clearInterval(incrementalScroll);
|
||||
}
|
||||
}, 10);
|
||||
} else {
|
||||
this.codeEditor.current.codeMirror?.scrollTo(null, targetY); // Scroll any remaining difference
|
||||
this.codeEditor.current.setCursorPosition({ line: targetLine + 1, ch: 0 });
|
||||
this.codeEditor.current.codeMirror?.addLineClass(targetLine + 1, 'wrap', 'sourceMoveFlash');
|
||||
}
|
||||
editor.scrollToPage(targetPage);
|
||||
setTimeout(()=>{
|
||||
jumpSource = null;
|
||||
}, 200);
|
||||
},
|
||||
|
||||
//Called when there are changes to the editor's dimensions
|
||||
@@ -446,9 +246,11 @@ const Editor = createReactClass({
|
||||
view={this.state.view}
|
||||
value={this.props.brew.text}
|
||||
onChange={this.props.onBrewChange('text')}
|
||||
onCursorChange={(page)=>this.updateCurrentCursorPage(page)}
|
||||
onViewChange={(page)=>this.updateCurrentViewPage(page)}
|
||||
editorTheme={this.state.editorTheme}
|
||||
rerenderParent={this.rerenderParent}
|
||||
style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }} />
|
||||
renderer={this.props.brew.renderer}
|
||||
style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }}/>
|
||||
</>;
|
||||
}
|
||||
if(this.isStyle()){
|
||||
@@ -460,18 +262,16 @@ const Editor = createReactClass({
|
||||
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)` }} />
|
||||
renderer={this.props.brew.renderer}
|
||||
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} />
|
||||
style={{ display: 'none' }}/>
|
||||
<MetadataEditor
|
||||
metadata={this.props.brew}
|
||||
themeBundle={this.props.themeBundle}
|
||||
@@ -492,8 +292,9 @@ const Editor = createReactClass({
|
||||
onChange={this.props.onBrewChange('snippets')}
|
||||
enableFolding={true}
|
||||
editorTheme={this.state.editorTheme}
|
||||
renderer={this.props.brew.renderer}
|
||||
rerenderParent={this.rerenderParent}
|
||||
style={{ height: `calc(100% -${this.state.snippetBarHeight}px)` }} />
|
||||
style={{ height: `calc(100% - 25px)` }}/>
|
||||
</>;
|
||||
}
|
||||
},
|
||||
@@ -510,14 +311,13 @@ const Editor = createReactClass({
|
||||
return this.codeEditor.current?.undo();
|
||||
},
|
||||
|
||||
foldCode : function(){
|
||||
return this.codeEditor.current?.foldAllCode();
|
||||
foldCode : function() {
|
||||
return this.codeEditor.current?.foldAll();
|
||||
},
|
||||
|
||||
unfoldCode : function(){
|
||||
return this.codeEditor.current?.unfoldAllCode();
|
||||
unfoldCode : function() {
|
||||
return this.codeEditor.current?.unfoldAll();
|
||||
},
|
||||
|
||||
render : function(){
|
||||
return (
|
||||
<div className='editor' ref={this.editor}>
|
||||
@@ -547,4 +347,4 @@ const Editor = createReactClass({
|
||||
}
|
||||
});
|
||||
|
||||
export default Editor;
|
||||
export default Editor;
|
||||
@@ -1,90 +1,11 @@
|
||||
@import '@sharedStyles/core.less';
|
||||
@import '@themes/codeMirror/customEditorStyles.less';
|
||||
|
||||
.editor {
|
||||
position : relative;
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
container : editor / inline-size;
|
||||
background:white;
|
||||
.codeEditor {
|
||||
height : calc(100% - 25px);
|
||||
.CodeMirror { height : 100%; }
|
||||
.pageLine, .snippetLine {
|
||||
background : #33333328;
|
||||
border-top : #333399 solid 1px;
|
||||
}
|
||||
.editor-page-count {
|
||||
float : right;
|
||||
color : grey;
|
||||
}
|
||||
.editor-snippet-count {
|
||||
float : right;
|
||||
color : grey;
|
||||
}
|
||||
.columnSplit {
|
||||
font-style : italic;
|
||||
color : grey;
|
||||
background-color : fade(#229999, 15%);
|
||||
border-bottom : #229999 solid 1px;
|
||||
}
|
||||
.define {
|
||||
&:not(.term):not(.definition) {
|
||||
font-weight : bold;
|
||||
color : #949494;
|
||||
background : #E5E5E5;
|
||||
border-radius : 3px;
|
||||
}
|
||||
&.term { color : rgb(96, 117, 143); }
|
||||
&.definition { color : rgb(97, 57, 178); }
|
||||
}
|
||||
.block:not(.cm-comment) {
|
||||
font-weight : bold;
|
||||
color : purple;
|
||||
//font-style: italic;
|
||||
}
|
||||
.inline-block:not(.cm-comment) {
|
||||
font-weight : bold;
|
||||
color : red;
|
||||
//font-style: italic;
|
||||
}
|
||||
.injection:not(.cm-comment) {
|
||||
font-weight : bold;
|
||||
color : green;
|
||||
}
|
||||
.emoji:not(.cm-comment) {
|
||||
padding-bottom : 1px;
|
||||
margin-left : 2px;
|
||||
font-weight : bold;
|
||||
color : #360034;
|
||||
outline : solid 2px #FF96FC;
|
||||
outline-offset : -2px;
|
||||
background : #FFC8FF;
|
||||
border-radius : 6px;
|
||||
}
|
||||
.superscript:not(.cm-comment) {
|
||||
font-size : 0.9em;
|
||||
font-weight : bold;
|
||||
vertical-align : super;
|
||||
color : goldenrod;
|
||||
}
|
||||
.subscript:not(.cm-comment) {
|
||||
font-size : 0.9em;
|
||||
font-weight : bold;
|
||||
vertical-align : sub;
|
||||
color : rgb(123, 123, 15);
|
||||
}
|
||||
.dl-highlight {
|
||||
&.dl-colon-highlight {
|
||||
font-weight : bold;
|
||||
color : #949494;
|
||||
background : #E5E5E5;
|
||||
border-radius : 3px;
|
||||
}
|
||||
&.dt-highlight { color : rgb(96, 117, 143); }
|
||||
&.dd-highlight { color : rgb(97, 57, 178); }
|
||||
}
|
||||
}
|
||||
:where(.editor) {
|
||||
position : relative;
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
container : editor / inline-size;
|
||||
background : white;
|
||||
|
||||
.brewJump {
|
||||
position : absolute;
|
||||
|
||||
@@ -4,7 +4,7 @@ import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import _ from 'lodash';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import Combobox from '../../../components/combobox.jsx';
|
||||
import Combobox from '@components/combobox.jsx';
|
||||
import TagInput from '../tagInput/tagInput.jsx';
|
||||
|
||||
|
||||
@@ -386,9 +386,9 @@ const MetadataEditor = createReactClass({
|
||||
{this.renderThumbnail()}
|
||||
</div>
|
||||
|
||||
<div className="field tags">
|
||||
<div className='field tags'>
|
||||
<label>Tags</label>
|
||||
<div className="value" >
|
||||
<div className='value' >
|
||||
<TagInput
|
||||
label='tags'
|
||||
valuePatterns={/^\s*(?:(?:group|meta|system|type)\s*:\s*)?[A-Za-z0-9][A-Za-z0-9 \/\\.&_\-]{0,40}\s*$/}
|
||||
@@ -399,7 +399,7 @@ const MetadataEditor = createReactClass({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{this.renderLanguageDropdown()}
|
||||
|
||||
@@ -411,9 +411,9 @@ const MetadataEditor = createReactClass({
|
||||
|
||||
{this.renderAuthors()}
|
||||
|
||||
<div className="field invitedAuthors">
|
||||
<div className='field invitedAuthors'>
|
||||
<label>Invited authors</label>
|
||||
<div className="value">
|
||||
<div className='value'>
|
||||
<TagInput
|
||||
label='invited authors'
|
||||
valuePatterns={/.+/}
|
||||
@@ -426,7 +426,7 @@ const MetadataEditor = createReactClass({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<h2>Privacy</h2>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import './snippetbar.less';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import { Dropdown } from '@components/dropdown/dropdown.jsx';
|
||||
|
||||
import _ from 'lodash';
|
||||
import cx from 'classnames';
|
||||
@@ -23,7 +24,25 @@ const ThemeSnippets = {
|
||||
V3_Blank : V3_Blank,
|
||||
};
|
||||
|
||||
import EditorThemes from '../../../../build/homebrew/codeMirror/editorThemes.json';
|
||||
import defaultCM5Theme from '@themes/codeMirror/default.js';
|
||||
import darkbrewery from '@themes/codeMirror/darkbrewery.js';
|
||||
import cm5Themes from 'codemirror-5-themes';
|
||||
|
||||
const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery };
|
||||
|
||||
const themeNames = Object.entries(themes)
|
||||
.filter(([name, value])=>Array.isArray(value) &&
|
||||
!name.endsWith('Init') &&
|
||||
!name.endsWith('Style')
|
||||
)
|
||||
.map(([name])=>name);
|
||||
|
||||
const EditorThemes = [
|
||||
'default',
|
||||
...themeNames
|
||||
.filter((name)=>name !== 'default')
|
||||
.sort((a, b)=>a.localeCompare(b))
|
||||
];
|
||||
|
||||
const execute = function(val, props){
|
||||
if(_.isFunction(val)) return val(props);
|
||||
@@ -151,7 +170,7 @@ const Snippetbar = createReactClass({
|
||||
this.props.updateEditorTheme(e.target.value);
|
||||
|
||||
this.setState({
|
||||
showThemeSelector : false,
|
||||
themeSelector : false,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -232,11 +251,11 @@ const Snippetbar = createReactClass({
|
||||
<i className='fas fa-clock-rotate-left' />
|
||||
{ this.state.showHistory && this.renderHistoryItems() }
|
||||
</div>
|
||||
<div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`}
|
||||
<div className={`editorTool undo ${this.props.historySize.done ? 'active' : ''}`}
|
||||
onClick={this.props.undo} >
|
||||
<i className='fas fa-undo' />
|
||||
</div>
|
||||
<div className={`editorTool redo ${this.props.historySize.redo ? 'active' : ''}`}
|
||||
<div className={`editorTool redo ${this.props.historySize.undone ? 'active' : ''}`}
|
||||
onClick={this.props.redo} >
|
||||
<i className='fas fa-redo' />
|
||||
</div>
|
||||
@@ -302,36 +321,33 @@ const SnippetGroup = createReactClass({
|
||||
};
|
||||
},
|
||||
handleSnippetClick : function(e, snippet){
|
||||
e.stopPropagation();
|
||||
this.props.onSnippetClick(execute(snippet.gen, this.props));
|
||||
},
|
||||
renderSnippets : function(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'>
|
||||
if(!snippet.subsnippets){
|
||||
return (
|
||||
<button className='menu-item' key={snippet.name} onClick={(e)=>this.handleSnippetClick(e, snippet)} role='menuitem'>
|
||||
<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>}
|
||||
</button>
|
||||
);
|
||||
} else if(snippet.subsnippets){
|
||||
return (
|
||||
<Dropdown groupName={snippet.name} icon={snippet.icon} key={snippet.name}>
|
||||
{this.renderSnippets(snippet.subsnippets)}
|
||||
</div></>}
|
||||
</div>;
|
||||
</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={this.props.groupName} id={this.props.groupName} icon={this.props.icon}>
|
||||
{this.renderSnippets(this.props.snippets)}
|
||||
</Dropdown>;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
height : auto;
|
||||
color : black;
|
||||
background-color : #DDDDDD;
|
||||
font-size : .65rem;
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
text-transform: uppercase;
|
||||
font-weight: 800;
|
||||
|
||||
.snippets {
|
||||
display : flex;
|
||||
@@ -22,7 +26,7 @@
|
||||
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
|
||||
|
||||
font-size: .85rem;
|
||||
&:only-child {min-width : unset; margin-left : auto;}
|
||||
|
||||
>div {
|
||||
@@ -57,32 +61,27 @@
|
||||
}
|
||||
&.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; }
|
||||
}
|
||||
&.history {
|
||||
.tooltipLeft('History');
|
||||
position : relative;
|
||||
font-size : 0.75em;
|
||||
color : grey;
|
||||
border : none;
|
||||
&.active { color : inherit; }
|
||||
@@ -93,7 +92,6 @@
|
||||
}
|
||||
&.editorTheme {
|
||||
.tooltipLeft('Editor Themes');
|
||||
font-size : 0.75em;
|
||||
color : inherit;
|
||||
&.active {
|
||||
position : relative;
|
||||
@@ -144,91 +142,97 @@
|
||||
border-left : 1px solid black;
|
||||
.tooltipLeft('Edit Brew Properties');
|
||||
}
|
||||
.snippetGroup {
|
||||
|
||||
&:hover {
|
||||
& > .dropdown { visibility : visible; }
|
||||
}
|
||||
.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';
|
||||
}
|
||||
|
||||
&.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-wrapper {
|
||||
.menu-item:is(.snippets > .menu-wrapper > .menu-item):first-child {
|
||||
.caret {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.menu-list {
|
||||
padding : 0px;
|
||||
background-color : #DDDDDD;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
position : relative;
|
||||
display : flex;
|
||||
justify-content: space-between;
|
||||
align-items : center;
|
||||
min-width : max-content;
|
||||
padding : 5px;
|
||||
cursor : pointer;
|
||||
width: 100%;
|
||||
.animate(background-color);
|
||||
&:is(.menu-list .menu-item) [class*="name"] {
|
||||
padding-inline: 8px;
|
||||
}
|
||||
.menu-name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
text-box-trim: trim-end;
|
||||
}
|
||||
i {
|
||||
min-width : 25px;
|
||||
height : .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;
|
||||
}
|
||||
&:disabled {
|
||||
color: gray;
|
||||
cursor: not-allowed;
|
||||
&:hover { background-color: unset; }
|
||||
}
|
||||
}
|
||||
.disabledSnippets {
|
||||
color: grey;
|
||||
|
||||
@@ -1,210 +1,219 @@
|
||||
export default [
|
||||
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",
|
||||
'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",
|
||||
'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",
|
||||
'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",
|
||||
'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",
|
||||
'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",
|
||||
'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",
|
||||
'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",
|
||||
'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",
|
||||
'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,71 +1,62 @@
|
||||
import "./tagInput.less";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Combobox from "../../../components/combobox.jsx";
|
||||
import './tagInput.less';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Combobox from '@components/combobox.jsx';
|
||||
|
||||
import tagSuggestionList from "./curatedTagSuggestionList.js";
|
||||
import { tagSuggestionList, canonizationList } from './curatedTagSuggestionList.js';
|
||||
|
||||
const TagInput = ({tooltip, label, valuePatterns, values = [], unique = true, placeholder = "", smallText = "", onChange }) => {
|
||||
const TagInput = ({ tooltip, label, valuePatterns, values = [], unique = true, placeholder = '', smallText = '', onChange })=>{
|
||||
const [tagList, setTagList] = useState(
|
||||
values.map((value) => ({
|
||||
values.map((value)=>({
|
||||
value,
|
||||
editing: false,
|
||||
draft: "",
|
||||
editing : false,
|
||||
draft : '',
|
||||
})),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(()=>{
|
||||
const incoming = values || [];
|
||||
const current = tagList.map((t) => t.value);
|
||||
const current = tagList.map((t)=>t.value);
|
||||
|
||||
const changed = incoming.length !== current.length || incoming.some((v, i) => v !== current[i]);
|
||||
const changed = incoming.length !== current.length || incoming.some((v, i)=>v !== current[i]);
|
||||
|
||||
if (changed) {
|
||||
if(changed) {
|
||||
setTagList(
|
||||
incoming.map((value) => ({
|
||||
incoming.map((value)=>({
|
||||
value,
|
||||
editing: false,
|
||||
editing : false,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}, [values]);
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(()=>{
|
||||
onChange?.({
|
||||
target: { value: tagList.map((t) => t.value) },
|
||||
target : { value: tagList.map((t)=>t.value) },
|
||||
});
|
||||
}, [tagList]);
|
||||
|
||||
// substrings to be normalized to the first value on the array
|
||||
const duplicateGroups = [
|
||||
["5e 2024", "5.5e", "5e'24", "5.24", "5e24", "5.5"],
|
||||
["5e", "5th Edition"],
|
||||
["Dungeons & Dragons", "Dungeons and Dragons", "Dungeons n dragons"],
|
||||
["D&D", "DnD", "dnd", "Dnd", "dnD", "d&d", "d&D", "D&d"],
|
||||
["P2e", "p2e", "P2E", "Pathfinder 2e"],
|
||||
];
|
||||
|
||||
const normalizeValue = (input) => {
|
||||
const normalizeValue = (input)=>{
|
||||
const lowerInput = input.toLowerCase();
|
||||
let normalizedTag = input;
|
||||
|
||||
for (const group of duplicateGroups) {
|
||||
for (const group of canonizationList) {
|
||||
for (const tag of group) {
|
||||
if (!tag) continue;
|
||||
if(!tag) continue;
|
||||
|
||||
const index = lowerInput.indexOf(tag.toLowerCase());
|
||||
if (index !== -1) {
|
||||
if(index !== -1) {
|
||||
normalizedTag = input.slice(0, index) + group[0] + input.slice(index + tag.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedTag.includes(":")) {
|
||||
const [rawType, rawValue = ""] = normalizedTag.split(":");
|
||||
if(normalizedTag.includes(':')) {
|
||||
const [rawType, rawValue = ''] = normalizedTag.split(':');
|
||||
const tagType = rawType.trim().toLowerCase();
|
||||
const tagValue = rawValue.trim();
|
||||
|
||||
if (tagValue.length > 0) {
|
||||
if(tagValue.length > 0) {
|
||||
normalizedTag = `${tagType}:${tagValue[0].toUpperCase()}${tagValue.slice(1)}`;
|
||||
}
|
||||
//trims spaces around colon and capitalizes the first word after the colon
|
||||
@@ -75,56 +66,56 @@ const TagInput = ({tooltip, label, valuePatterns, values = [], unique = true, pl
|
||||
return normalizedTag;
|
||||
};
|
||||
|
||||
const submitTag = (newValue, index = null) => {
|
||||
const submitTag = (newValue, index = null)=>{
|
||||
const trimmed = newValue?.trim();
|
||||
if (!trimmed) return;
|
||||
if (!valuePatterns.test(trimmed)) return;
|
||||
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));
|
||||
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));
|
||||
}
|
||||
|
||||
return [...prev, { value: normalizedTag, editing: false }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeTag = (index) => {
|
||||
setTagList((prev) => prev.filter((_, i) => i !== index));
|
||||
const removeTag = (index)=>{
|
||||
setTagList((prev)=>prev.filter((_, i)=>i !== index));
|
||||
};
|
||||
|
||||
const editTag = (index) => {
|
||||
setTagList((prev) => prev.map((t, i) => (i === index ? { ...t, editing: true, draft: t.value } : t)));
|
||||
const editTag = (index)=>{
|
||||
setTagList((prev)=>prev.map((t, i)=>(i === index ? { ...t, editing: true, draft: t.value } : t)));
|
||||
};
|
||||
|
||||
const stopEditing = (index) => {
|
||||
setTagList((prev) => prev.map((t, i) => (i === index ? { ...t, editing: false, draft: "" } : t)));
|
||||
const stopEditing = (index)=>{
|
||||
setTagList((prev)=>prev.map((t, i)=>(i === index ? { ...t, editing: false, draft: '' } : t)));
|
||||
};
|
||||
|
||||
const suggestionOptions = tagSuggestionList.map((tag) => {
|
||||
const tagType = tag.split(":");
|
||||
const suggestionOptions = tagSuggestionList.map((tag)=>{
|
||||
const tagType = tag.split(':');
|
||||
|
||||
let classes = "item";
|
||||
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;
|
||||
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 (
|
||||
@@ -135,73 +126,69 @@ const TagInput = ({tooltip, label, valuePatterns, values = [], unique = true, pl
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="tagInputWrap">
|
||||
<div className='tagInputWrap'>
|
||||
<Combobox
|
||||
trigger="click"
|
||||
className="tagInput-dropdown"
|
||||
default=""
|
||||
trigger='click'
|
||||
className='tagInput-dropdown'
|
||||
default=''
|
||||
placeholder={placeholder}
|
||||
options={label === "tags" ? suggestionOptions : []}
|
||||
options={label === 'tags' ? suggestionOptions : []}
|
||||
tooltip={tooltip}
|
||||
autoSuggest={
|
||||
label === "tags"
|
||||
label === 'tags'
|
||||
? {
|
||||
suggestMethod: "startsWith",
|
||||
clearAutoSuggestOnClick: true,
|
||||
filterOn: ["value", "title"],
|
||||
}
|
||||
: { suggestMethod: "includes", clearAutoSuggestOnClick: true, filterOn: [] }
|
||||
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") {
|
||||
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
|
||||
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)),
|
||||
)
|
||||
<ul className='list'>
|
||||
{tagList.map((t, i)=>t.editing ? (
|
||||
<input
|
||||
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)),
|
||||
);
|
||||
}
|
||||
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"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTag(i);
|
||||
}}>
|
||||
<i className="fa fa-times fa-fw" />
|
||||
</button>
|
||||
</li>
|
||||
),
|
||||
if(e.key === 'Escape') {
|
||||
stopEditing(i);
|
||||
e.target.blur();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<li key={i} className='tag' onClick={()=>editTag(i)}>
|
||||
{t.value}
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e)=>{
|
||||
e.stopPropagation();
|
||||
removeTag(i);
|
||||
}}>
|
||||
<i className='fa fa-times fa-fw' />
|
||||
</button>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user