mirror of
https://github.com/naturalcrit/homebrewery.git
synced 2025-12-28 02:42:39 +00:00
This sets the focus on the editor (and thus reveals cursor position) between editor changes (style to text to meta and back).
470 lines
16 KiB
JavaScript
470 lines
16 KiB
JavaScript
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
|
|
require('./editor.less');
|
|
const React = require('react');
|
|
const createClass = require('create-react-class');
|
|
const _ = require('lodash');
|
|
const cx = require('classnames');
|
|
const dedent = require('dedent-tabs').default;
|
|
const Markdown = require('../../../shared/naturalcrit/markdown.js');
|
|
|
|
const CodeEditor = require('naturalcrit/codeEditor/codeEditor.jsx');
|
|
const SnippetBar = require('./snippetbar/snippetbar.jsx');
|
|
const MetadataEditor = require('./metadataEditor/metadataEditor.jsx');
|
|
|
|
const EDITOR_THEME_KEY = 'HOMEBREWERY-EDITOR-THEME';
|
|
|
|
const SNIPPETBAR_HEIGHT = 25;
|
|
const DEFAULT_STYLE_TEXT = dedent`
|
|
/*=======--- Example CSS styling ---=======*/
|
|
/* Any CSS here will apply to your document! */
|
|
|
|
.myExampleClass {
|
|
color: black;
|
|
}`;
|
|
|
|
|
|
const Editor = createClass({
|
|
displayName : 'Editor',
|
|
getDefaultProps : function() {
|
|
return {
|
|
brew : {
|
|
text : '',
|
|
style : ''
|
|
},
|
|
|
|
onTextChange : ()=>{},
|
|
onStyleChange : ()=>{},
|
|
onMetaChange : ()=>{},
|
|
reportError : ()=>{},
|
|
|
|
editorTheme : 'default',
|
|
renderer : 'legacy'
|
|
};
|
|
},
|
|
getInitialState : function() {
|
|
return {
|
|
editorTheme : this.props.editorTheme,
|
|
view : 'text' //'text', 'style', 'meta'
|
|
};
|
|
},
|
|
|
|
editor : React.createRef(null),
|
|
codeEditor : React.createRef(null),
|
|
|
|
isText : function() {return this.state.view == 'text';},
|
|
isStyle : function() {return this.state.view == 'style';},
|
|
isMeta : function() {return this.state.view == 'meta';},
|
|
|
|
componentDidMount : function() {
|
|
this.updateEditorSize();
|
|
this.highlightCustomMarkdown();
|
|
window.addEventListener('resize', this.updateEditorSize);
|
|
document.getElementById('BrewRenderer').addEventListener('keydown', this.handleControlKeys);
|
|
document.addEventListener('keydown', this.handleControlKeys);
|
|
|
|
const editorTheme = window.localStorage.getItem(EDITOR_THEME_KEY);
|
|
if(editorTheme) {
|
|
this.setState({
|
|
editorTheme : editorTheme
|
|
});
|
|
}
|
|
},
|
|
|
|
componentWillUnmount : function() {
|
|
window.removeEventListener('resize', this.updateEditorSize);
|
|
},
|
|
|
|
componentDidUpdate : function(prevProps, prevState, snapshot) {
|
|
this.highlightCustomMarkdown();
|
|
if(prevProps.moveBrew !== this.props.moveBrew) {
|
|
this.brewJump();
|
|
};
|
|
if(prevProps.moveSource !== this.props.moveSource) {
|
|
this.sourceJump();
|
|
};
|
|
this.codeEditor.current?.codeMirror.focus();
|
|
},
|
|
|
|
handleControlKeys : function(e){
|
|
if(!(e.ctrlKey && e.metaKey)) return;
|
|
const LEFTARROW_KEY = 37;
|
|
const RIGHTARROW_KEY = 39;
|
|
if (e.shiftKey && (e.keyCode == RIGHTARROW_KEY)) this.brewJump();
|
|
if (e.shiftKey && (e.keyCode == LEFTARROW_KEY)) this.sourceJump();
|
|
if ((e.keyCode == LEFTARROW_KEY) || (e.keyCode == RIGHTARROW_KEY)) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
}
|
|
},
|
|
|
|
|
|
updateEditorSize : function() {
|
|
if(this.codeEditor.current) {
|
|
let paneHeight = this.editor.current.parentNode.clientHeight;
|
|
paneHeight -= SNIPPETBAR_HEIGHT;
|
|
this.codeEditor.current.codeMirror.setSize(null, paneHeight);
|
|
}
|
|
},
|
|
|
|
handleInject : function(injectText){
|
|
this.codeEditor.current?.injectText(injectText, false);
|
|
},
|
|
|
|
handleViewChange : function(newView){
|
|
this.props.setMoveArrows(newView === 'text');
|
|
this.setState({
|
|
view : newView
|
|
}, this.updateEditorSize); //TODO: not sure if updateeditorsize needed
|
|
},
|
|
|
|
getCurrentPage : function(){
|
|
const lines = this.props.brew.text.split('\n').slice(0, this.codeEditor.current.getCursorPosition().line + 1);
|
|
return _.reduce(lines, (r, line)=>{
|
|
if(
|
|
(this.props.renderer == 'legacy' && line.indexOf('\\page') !== -1)
|
|
||
|
|
(this.props.renderer == 'V3' && line.match(/^\\page$/))
|
|
) r++;
|
|
return r;
|
|
}, 1);
|
|
},
|
|
|
|
highlightCustomMarkdown : function(){
|
|
if(!this.codeEditor.current) return;
|
|
if(this.state.view === 'text') {
|
|
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 editorPageCount = 2; // start page count from page 2
|
|
|
|
_.forEach(this.props.brew.text.split('\n'), (line, lineNumber)=>{
|
|
|
|
//reset custom line styles
|
|
codeMirror.removeLineClass(lineNumber, 'background', 'pageLine');
|
|
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(/^\\page$/))) {
|
|
|
|
// add back the original class 'background' but also add the new class '.pageline'
|
|
codeMirror.addLineClass(lineNumber, 'background', 'pageLine');
|
|
const pageCountElement = Object.assign(document.createElement('span'), {
|
|
className : 'editor-page-count',
|
|
textContent : editorPageCount
|
|
});
|
|
codeMirror.setBookmark({ line: lineNumber, ch: line.length }, pageCountElement);
|
|
|
|
editorPageCount += 1;
|
|
};
|
|
|
|
// New Codemirror styling for V3 renderer
|
|
if(this.props.renderer == 'V3') {
|
|
if(line.match(/^\\column$/)){
|
|
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];
|
|
let colons = /::/g;
|
|
let 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;
|
|
let 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;
|
|
let 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;
|
|
|
|
let startPos = { line: lineNumber, ch: match.index };
|
|
let endPos = { line: lineNumber, ch: match.index + match[0].length };
|
|
|
|
// Iterate over conflicting marks and clear them
|
|
var 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.getCurrentPage()){
|
|
if(!window) return;
|
|
// console.log(`Scroll to: p${targetPage}`);
|
|
const brewRenderer = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer')[0];
|
|
const currentPos = brewRenderer.scrollTop;
|
|
const targetPos = window.frames['BrewRenderer'].contentDocument.getElementById(`p${targetPage}`).getBoundingClientRect().top;
|
|
const interimPos = targetPos >= 0 ? -30 : 30;
|
|
|
|
const bounceDelay = 100;
|
|
const scrollDelay = 500;
|
|
|
|
if(!this.throttleBrewMove) {
|
|
this.throttleBrewMove = _.throttle((currentPos, interimPos, targetPos)=>{
|
|
brewRenderer.scrollTo({ top: currentPos + interimPos, behavior: 'smooth' });
|
|
setTimeout(()=>{
|
|
brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'smooth', block: 'start' });
|
|
}, bounceDelay);
|
|
}, scrollDelay, { leading: true, trailing: false });
|
|
};
|
|
this.throttleBrewMove(currentPos, interimPos, targetPos);
|
|
|
|
// const hashPage = (page != 1) ? `p${page}` : '';
|
|
// window.location.hash = hashPage;
|
|
},
|
|
|
|
sourceJump : function(targetLine=null){
|
|
if(this.isText()) {
|
|
if(targetLine == null) {
|
|
targetLine = 0;
|
|
|
|
const pageCollection = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('page');
|
|
const brewRendererHeight = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer').item(0).getBoundingClientRect().height;
|
|
|
|
let currentPage = 1;
|
|
for (const page of pageCollection) {
|
|
if(page.getBoundingClientRect().bottom > (brewRendererHeight / 2)) {
|
|
currentPage = parseInt(page.id.slice(1)) || 1;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const textSplit = this.props.renderer == 'V3' ? /^\\page$/gm : /\\page/;
|
|
const textString = this.props.brew.text.split(textSplit).slice(0, currentPage-1).join(textSplit);
|
|
const textPosition = textString.length;
|
|
const lineCount = textString.match('\n') ? textString.slice(0, textPosition).split('\n').length : 0;
|
|
|
|
targetLine = lineCount - 1; //Scroll to `\page`, which is one line back.
|
|
|
|
let currentY = this.codeEditor.current.codeMirror.getScrollInfo().top;
|
|
let targetY = this.codeEditor.current.codeMirror.heightAtLine(targetLine, 'local', true);
|
|
|
|
//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);
|
|
}
|
|
}
|
|
},
|
|
|
|
//Called when there are changes to the editor's dimensions
|
|
update : function(){
|
|
this.codeEditor.current?.updateSize();
|
|
},
|
|
|
|
updateEditorTheme : function(newTheme){
|
|
window.localStorage.setItem(EDITOR_THEME_KEY, newTheme);
|
|
this.setState({
|
|
editorTheme : newTheme
|
|
});
|
|
},
|
|
|
|
//Called by CodeEditor after document switch, so Snippetbar can refresh UndoHistory
|
|
rerenderParent : function (){
|
|
this.forceUpdate();
|
|
},
|
|
|
|
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.onTextChange}
|
|
editorTheme={this.state.editorTheme}
|
|
rerenderParent={this.rerenderParent} />
|
|
</>;
|
|
}
|
|
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.onStyleChange}
|
|
enableFolding={true}
|
|
editorTheme={this.state.editorTheme}
|
|
rerenderParent={this.rerenderParent} />
|
|
</>;
|
|
}
|
|
if(this.isMeta()){
|
|
return <>
|
|
<CodeEditor key='codeEditor'
|
|
view={this.state.view}
|
|
style={{ display: 'none' }}
|
|
rerenderParent={this.rerenderParent} />
|
|
<MetadataEditor
|
|
metadata={this.props.brew}
|
|
onChange={this.props.onMetaChange}
|
|
reportError={this.props.reportError}
|
|
userThemes={this.props.userThemes}/>
|
|
</>;
|
|
}
|
|
},
|
|
|
|
redo : function(){
|
|
return this.codeEditor.current?.redo();
|
|
},
|
|
|
|
historySize : function(){
|
|
return this.codeEditor.current?.historySize();
|
|
},
|
|
|
|
undo : function(){
|
|
return this.codeEditor.current?.undo();
|
|
},
|
|
|
|
foldCode : function(){
|
|
return this.codeEditor.current?.foldAllCode();
|
|
},
|
|
|
|
unfoldCode : function(){
|
|
return this.codeEditor.current?.unfoldAllCode();
|
|
},
|
|
|
|
render : function(){
|
|
return (
|
|
<div className='editor' ref={this.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}
|
|
snippetBundle={this.props.snippetBundle}
|
|
cursorPos={this.codeEditor.current?.getCursorPosition() || {}} />
|
|
|
|
{this.renderEditor()}
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
|
|
module.exports = Editor;
|