diff --git a/client/components/codeEditor/codeEditor.jsx b/client/components/codeEditor/codeEditor.jsx index c0be756b8..d6d564a60 100644 --- a/client/components/codeEditor/codeEditor.jsx +++ b/client/components/codeEditor/codeEditor.jsx @@ -36,6 +36,53 @@ const highlightStyle = HighlightStyle.define([ // … ]); +/*custom tokens */ +import { Decoration, ViewPlugin, WidgetType } from "@codemirror/view"; +import { tokenizeCustomMarkdown, customTags } from "./customMarkdownGrammar.js"; + +const customHighlightStyle = HighlightStyle.define([ + { tag: tags.heading1, color: "#000", fontWeight: "700" }, + { tag: tags.keyword, color: "#07a" }, // example for your markdown headings + { tag: customTags.pageLine, color: "#f0a" }, + { tag: customTags.snippetBreak, class: "cm-snippet-break", color: "#0af" }, + { tag: customTags.inlineBlock, class: "cm-inline-block", backgroundColor: "#fffae6" }, + { tag: customTags.emoji, class: "cm-emoji", color: "#fa0" }, + { tag: customTags.superscript, class: "cm-superscript", verticalAlign: "super", fontSize: "0.8em" }, + { tag: customTags.subscript, class: "cm-subscript", verticalAlign: "sub", fontSize: "0.8em" }, + { tag: customTags.definitionTerm, class: "cm-dt", fontWeight: "bold", color: "#0a0" }, + { tag: customTags.definitionDesc, class: "cm-dd", color: "#070" }, +]); + +const customHighlightPlugin = ViewPlugin.fromClass( + class { + constructor(view) { + this.decorations = this.buildDecorations(view); + } + update(update) { + if (update.docChanged) { + this.decorations = this.buildDecorations(update.view); + } + } + buildDecorations(view) { + const widgets = []; + const tokens = tokenizeCustomMarkdown(view.state.doc.toString()); + + // sort by line number + tokens.sort((a, b) => a.line - b.line); + + tokens.forEach((tok) => { + const line = view.state.doc.line(tok.line + 1); // CM lines are 1-based + widgets.push(Decoration.line({ class: `cm-${tok.type}` }).range(line.from)); + }); + + return Decoration.set(widgets); + } + }, + { + decorations: (v) => v.decorations, + }, +); + const CodeEditor = forwardRef( ( { @@ -116,6 +163,8 @@ const CodeEditor = forwardRef( lineNumbers(), themeExtension, syntaxHighlighting(highlightStyle), + customHighlightPlugin, + syntaxHighlighting(customHighlightStyle), ]; }; diff --git a/client/components/codeEditor/customMarkdownGrammar.js b/client/components/codeEditor/customMarkdownGrammar.js new file mode 100644 index 000000000..625983ad1 --- /dev/null +++ b/client/components/codeEditor/customMarkdownGrammar.js @@ -0,0 +1,95 @@ +// customMarkdownGrammar.js + +// --- Custom tags with CM6-compatible class names --- +export const customTags = { + pageLine: "pageLine", // .cm-pageLine + snippetLine: "snippetLine", // .cm-snippetLine + columnSplit: "columnSplit", // .cm-columnSplit + snippetBreak: "snippetBreak", // .cm-snippetBreak + inlineBlock: "inline-block", // .cm-inline-block + block: "block", // .cm-block + emoji: "emoji", // .cm-emoji + superscript: "superscript", // .cm-superscript + subscript: "subscript", // .cm-subscript + definitionTerm: "dt-highlight", // .cm-dt-highlight + definitionDesc: "dd-highlight", // .cm-dd-highlight + injection: "injection", // .cm-injection +}; + +// --- Tokenizer function --- +export function tokenizeCustomMarkdown(text) { + const tokens = []; + const lines = text.split("\n"); + + // Track multi-line blocks + let inBlock = false; + let blockStart = 0; + + lines.forEach((lineText, lineNumber) => { + // --- Page / snippet lines --- + if (/\\page/.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 }); + if (/\\snippet/.test(lineText)) tokens.push({ line: lineNumber, type: customTags.snippetBreak }); + + // --- Emoji --- + if (/:\w+?:/.test(lineText)) tokens.push({ line: lineNumber, type: customTags.emoji }); + + // --- Superscript / Subscript --- + if (/\^\^/.test(lineText)) tokens.push({ line: lineNumber, type: customTags.subscript }); + if (/\^/.test(lineText)) tokens.push({ line: lineNumber, type: customTags.superscript }); + + // --- Definition lists --- + if (/::/.test(lineText)) { + tokens.push({ line: lineNumber, type: customTags.definitionDesc }); + tokens.push({ line: lineNumber, type: customTags.definitionTerm }); + } + + // Track ranges already marked for injections + const injectionRanges = []; + + if (line.includes("{") && line.includes("}")) { + const regex = /{[^{}]*}/gm; + let match; + while ((match = regex.exec(line)) != null) { + codeMirror?.markText( + { line: lineNumber, ch: match.index }, + { line: lineNumber, ch: match.index + match[0].length }, + { className: "injection" }, + ); + injectionRanges.push([match.index, match.index + match[0].length]); + } + } + + // Now mark inline blocks, but skip overlapping injection ranges + if (line.includes("{{") && line.includes("}}")) { + const regex = /{{[^{}]*}}/gm; + let match; + while ((match = regex.exec(line)) != null) { + const start = match.index, + end = match.index + match[0].length; + const overlaps = injectionRanges.some(([iStart, iEnd]) => start < iEnd && end > iStart); + if (!overlaps) { + codeMirror?.markText( + { line: lineNumber, ch: start }, + { line: lineNumber, ch: end }, + { className: "inline-block" }, + ); + } + } + } + + // --- Multi-line blocks `{{…}}` --- only start/end lines + if (lineText.trimLeft().startsWith("{{") && !lineText.trimLeft().endsWith("}}")) { + inBlock = true; + blockStart = lineNumber; + tokens.push({ line: lineNumber, type: customTags.block }); + } + if (lineText.trimLeft().startsWith("}}") && inBlock) { + tokens.push({ line: lineNumber, type: customTags.block }); + inBlock = false; + } + }); + + return tokens; +} diff --git a/client/homebrew/editor/editor.jsx b/client/homebrew/editor/editor.jsx index 06fd469a0..a2ecbcb37 100644 --- a/client/homebrew/editor/editor.jsx +++ b/client/homebrew/editor/editor.jsx @@ -1,16 +1,16 @@ /*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 dedent from 'dedent'; -import Markdown from '@shared/markdown.js'; +import "./editor.less"; +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 SnippetBar from './snippetbar/snippetbar.jsx'; -import MetadataEditor from './metadataEditor/metadataEditor.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 EDITOR_THEME_KEY = "HB_editor_theme"; const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m; const SNIPPETBREAK_REGEX_V3 = /^\\snippet\ .*$/; @@ -32,295 +32,348 @@ const DEFAULT_SNIPPET_TEXT = dedent` let isJumping = false; const Editor = createReactClass({ - displayName : 'Editor', - getDefaultProps : function() { + displayName: "Editor", + getDefaultProps: function () { return { - brew : { - text : '', - style : '' + brew: { + text: "", + style: "", }, - onBrewChange : ()=>{}, - reportError : ()=>{}, + onBrewChange: () => {}, + reportError: () => {}, - onCursorPageChange : ()=>{}, - onViewPageChange : ()=>{}, + onCursorPageChange: () => {}, + onViewPageChange: () => {}, - editorTheme : 'default', - renderer : 'legacy', + editorTheme: "default", + renderer: "legacy", - currentEditorCursorPageNum : 1, - currentEditorViewPageNum : 1, - currentBrewRendererPageNum : 1, + currentEditorCursorPageNum: 1, + currentEditorViewPageNum: 1, + currentBrewRendererPageNum: 1, }; }, - getInitialState : function() { + getInitialState: function () { return { - editorTheme : this.props.editorTheme, - view : 'text', //'text', 'style', 'meta', 'snippet' - snippetBarHeight : 26, + editorTheme: this.props.editorTheme, + view: "text", //'text', 'style', 'meta', 'snippet' + snippetBarHeight: 26, }; }, - editor : React.createRef(null), - codeEditor : React.createRef(null), + 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';}, - isSnip : function() {return this.state.view == 'snippet';}, - - componentDidMount : function() { + 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); + 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)); + 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) { + if (editorTheme) { this.setState({ - editorTheme : editorTheme + editorTheme: editorTheme, }); } - const snippetBar = document.querySelector('.editor > .snippetBar'); - if(!snippetBar) return; + const snippetBar = document.querySelector(".editor > .snippetBar"); + if (!snippetBar) return; - this.resizeObserver = new ResizeObserver((entries)=>{ - const height = document.querySelector('.editor > .snippetBar').offsetHeight; + this.resizeObserver = new ResizeObserver((entries) => { + const height = document.querySelector(".editor > .snippetBar").offsetHeight; this.setState({ snippetBarHeight: height }); }); this.resizeObserver.observe(snippetBar); }, - componentDidUpdate : function(prevProps, prevState, snapshot) { - + componentDidUpdate: function (prevProps, prevState, snapshot) { this.highlightCustomMarkdown(); - if(prevProps.moveBrew !== this.props.moveBrew) - this.brewJump(); + if (prevProps.moveBrew !== this.props.moveBrew) this.brewJump(); - if(prevProps.moveSource !== this.props.moveSource) - this.sourceJump(); + if (prevProps.moveSource !== this.props.moveSource) this.sourceJump(); - if(this.props.liveScroll) { - if(prevProps.currentBrewRendererPageNum !== this.props.currentBrewRendererPageNum) { + if (this.props.liveScroll) { + if (prevProps.currentBrewRendererPageNum !== this.props.currentBrewRendererPageNum) { this.sourceJump(this.props.currentBrewRendererPageNum, false); - } else if(prevProps.currentEditorViewPageNum !== this.props.currentEditorViewPageNum) { + } else if (prevProps.currentEditorViewPageNum !== this.props.currentEditorViewPageNum) { this.brewJump(this.props.currentEditorViewPageNum, false); - } else if(prevProps.currentEditorCursorPageNum !== this.props.currentEditorCursorPageNum) { + } else if (prevProps.currentEditorCursorPageNum !== this.props.currentEditorCursorPageNum) { this.brewJump(this.props.currentEditorCursorPageNum, false); } } }, componentWillUnmount() { - if(this.resizeObserver) this.resizeObserver.disconnect(); + if (this.resizeObserver) this.resizeObserver.disconnect(); }, - handleControlKeys : function(e){ - if(!(e.ctrlKey && e.metaKey && e.shiftKey)) return; + 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) { + 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); + 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); + 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){ + handleInject: function (injectText) { this.codeEditor.current?.injectText(injectText, false); }, - handleViewChange : function(newView){ - this.props.setMoveArrows(newView === 'text'); + handleViewChange: function (newView) { + this.props.setMoveArrows(newView === "text"); - this.setState({ - view : newView - }, ()=>{ - this.codeEditor.current?.codeMirror?.focus(); - }); + 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')) { + 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 + codeMirror?.operation(() => { + // Batch CodeMirror styling const foldLines = []; //reset custom text styles - const customHighlights = codeMirror?.getAllMarks().filter((mark)=>{ + const customHighlights = codeMirror?.getAllMarks().filter((mark) => { // Record details of folded sections - if(mark.__isFold) { + 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(); + 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'; + 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'); + 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; + 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, + 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; + 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?.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'); + 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. + if (line.includes("::")) { + if (/^:*$/.test(line) == true) { + return; + } + const regex = /^([^\n]*?:?\s?)(::[^\n]*)(?:\n|$)/dmy; // 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' }); + 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' }); + 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('^'); + if (line.includes("^")) { + let startIndex = line.indexOf("^"); const superRegex = /\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^/gy; - const subRegex = /\^\^(?!\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) { + if (match) { isSuper = !subRegex.lastIndex; - codeMirror?.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: isSuper ? 'superscript' : 'subscript' }); + 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)); + 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; + // Injections: single-line {…} + if (line.includes("{") && line.includes("}")) { + const injectionRegex = + /(?:^|[^{\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' }); + while ((match = injectionRegex.exec(line)) !== null) { + tokens.push({ + line: lineNumber, + from: match.index, + to: match.index + match[1].length, + type: "injection", + }); } - } - // Highlight inline spans {{content}} - if(line.includes('{{') && line.includes('}}')){ - const regex = /{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *|}}/g; + } else if (line.includes("{{") && line.includes("}}")) { // Inline blocks: single-line {{…}} + const spanRegex = /{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *|}}/g; let match; let blockCount = 0; - while ((match = regex.exec(line)) != null) { - if(match[0].startsWith('{')) { + while ((match = spanRegex.exec(line)) !== null) { + if (match[0].startsWith("{{")) { blockCount += 1; } else { blockCount -= 1; } - if(blockCount < 0) { + if (blockCount < 0) { blockCount = 0; continue; } - codeMirror?.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: 'inline-block' }); + tokens.push({ + line: lineNumber, + from: match.index, + to: match.index + match[0].length, + type: "inline-block", + }); } - } else if(line.trimLeft().startsWith('{{') || line.trimLeft().startsWith('}}')){ + } else if (line.trimLeft().startsWith("{{") || line.trimLeft().startsWith("}}")) { // Highlight block divs {{\n Content \n}} - let endCh = line.length+1; + 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' }); + 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(':'); + 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) { + if (match) { let tokens = Markdown.marked.lexer(match[0]); - tokens = tokens[0].tokens.filter((t)=>t.type == 'emoji'); - if(!tokens.length) - return; + 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 }; + 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(); + marks.forEach(function (marker) { + if (!marker.__isFold) marker.clear(); }); - codeMirror?.markText(startPos, endPos, { className: 'emoji' }); + codeMirror?.markText(startPos, endPos, { className: "emoji" }); } - startIndex = line.indexOf(':', Math.max(startIndex + 1, emojiRegex.lastIndex)); + startIndex = line.indexOf(":", Math.max(startIndex + 1, emojiRegex.lastIndex)); } } } @@ -329,198 +382,226 @@ const Editor = createReactClass({ } }, - brewJump : function(targetPage=this.props.currentEditorCursorPageNum, smooth=true){ - if(!window || !this.isText() || isJumping) - return; + 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 brewRenderer = window.frames["BrewRenderer"].contentDocument.getElementsByClassName("brewRenderer")[0]; const currentPos = brewRenderer.scrollTop; - const targetPos = window.frames['BrewRenderer'].contentDocument.getElementById(`p${targetPage}`).getBoundingClientRect().top; + 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(()=>{ + 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 + 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); + brewRenderer.addEventListener("scroll", checkIfScrollComplete); - if(smooth) { - const bouncePos = targetPos >= 0 ? -30 : 30; //Do a little bounce before scrolling + 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 }); - }; + 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 }, + ); + } this.throttleBrewMove(currentPos, bouncePos, targetPos); } else { - brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'instant', block: 'start' }); + brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: "instant", block: "start" }); } }, - sourceJump : function(targetPage=this.props.currentBrewRendererPageNum, smooth=true){ - if(!this.isText() || isJumping) - return; + sourceJump: function (targetPage = this.props.currentBrewRendererPageNum, smooth = true) { + if (!this.isText() || isJumping) 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 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 targetY = this.codeEditor.current.codeMirror?.heightAtLine(targetLine, "local", true); let scrollingTimeout; - const checkIfScrollComplete = ()=>{ // Prevent interrupting a scroll in progress if user clicks multiple times + 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(()=>{ + scrollingTimeout = setTimeout(() => { isJumping = false; - this.codeEditor.current.codeMirror?.off('scroll', checkIfScrollComplete); + 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 (this.codeEditor.current?.codeMirror) { + this.codeEditor.current.codeMirror?.on("scroll", checkIfScrollComplete); } - if(smooth) { + if (smooth) { //Scroll 1/10 of the way every 10ms until 1px off. - const incrementalScroll = setInterval(()=>{ + 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); + 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 + 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'); + 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.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'); + this.codeEditor.current.codeMirror?.addLineClass(targetLine + 1, "wrap", "sourceMoveFlash"); } }, //Called when there are changes to the editor's dimensions - update : function(){}, + update: function () {}, - updateEditorTheme : function(newTheme){ + updateEditorTheme: function (newTheme) { window.localStorage.setItem(EDITOR_THEME_KEY, newTheme); this.setState({ - editorTheme : newTheme + editorTheme: newTheme, }); }, //Called by CodeEditor after document switch, so Snippetbar can refresh UndoHistory - rerenderParent : function (){ + rerenderParent: function () { this.forceUpdate(); }, - renderEditor : function(){ - if(this.isText()){ - return <> - - ; + renderEditor: function () { + if (this.isText()) { + return ( + <> + + + ); } - if(this.isStyle()){ - return <> - - ; + if (this.isStyle()) { + return ( + <> + + + ); } - if(this.isMeta()){ - return <> - - - ; + if (this.isMeta()) { + return ( + <> + + + + ); } - if(this.isSnip()){ - if(!this.props.brew.snippets) { this.props.brew.snippets = DEFAULT_SNIPPET_TEXT; } - return <> - - ; + if (this.isSnip()) { + if (!this.props.brew.snippets) { + this.props.brew.snippets = DEFAULT_SNIPPET_TEXT; + } + return ( + <> + + + ); } }, - redo : function(){ + redo: function () { return this.codeEditor.current?.redo(); }, - historySize : function(){ + historySize: function () { return this.codeEditor.current?.historySize(); }, - undo : function(){ + undo: function () { return this.codeEditor.current?.undo(); }, - foldCode : function(){ + foldCode: function () { return this.codeEditor.current?.foldAllCode(); }, - unfoldCode : function(){ + unfoldCode: function () { return this.codeEditor.current?.unfoldAllCode(); }, - render : function(){ + render: function () { return ( -
+
); - } + }, }); export default Editor; diff --git a/client/homebrew/editor/editor.less b/client/homebrew/editor/editor.less index 9db6df26f..db2421b0f 100644 --- a/client/homebrew/editor/editor.less +++ b/client/homebrew/editor/editor.less @@ -10,25 +10,25 @@ .codeEditor { height : calc(100% - 25px); .cm-editor { height : 100%; } - .pageLine, .snippetLine { + .cm-pageLine, .cm-snippetLine { background : #33333328; border-top : #333399 solid 1px; } - .editor-page-count { + .cm-editor-page-count { float : right; color : grey; } - .editor-snippet-count { + .cm-editor-snippet-count { float : right; color : grey; } - .columnSplit { + .cm-cm-columnSplit { font-style : italic; color : grey; background-color : fade(#229999, 15%); border-bottom : #229999 solid 1px; } - .define { + .cm-define { &:not(.term):not(.definition) { font-weight : bold; color : #949494; @@ -38,21 +38,21 @@ &.term { color : rgb(96, 117, 143); } &.definition { color : rgb(97, 57, 178); } } - .block:not(.cm-comment) { + .cm-block:not(.cm-comment) { font-weight : bold; color : purple; //font-style: italic; } - .inline-block:not(.cm-comment) { + .cm-inline-block:not(.cm-comment) { font-weight : bold; color : red; //font-style: italic; } - .injection:not(.cm-comment) { + .cm-injection:not(.cm-comment) { font-weight : bold; color : green; } - .emoji:not(.cm-comment) { + .cm-emoji:not(.cm-comment) { padding-bottom : 1px; margin-left : 2px; font-weight : bold; @@ -62,19 +62,19 @@ background : #FFC8FF; border-radius : 6px; } - .superscript:not(.cm-comment) { + .cm-superscript:not(.cm-comment) { font-size : 0.9em; font-weight : bold; vertical-align : super; color : goldenrod; } - .subscript:not(.cm-comment) { + .cm-subscript:not(.cm-comment) { font-size : 0.9em; font-weight : bold; vertical-align : sub; color : rgb(123, 123, 15); } - .dl-highlight { + .cm-dl-highlight { &.dl-colon-highlight { font-weight : bold; color : #949494;