diff --git a/client/components/codeEditor/codeEditor.jsx b/client/components/codeEditor/codeEditor.jsx index db0e3df37..f72089fe2 100644 --- a/client/components/codeEditor/codeEditor.jsx +++ b/client/components/codeEditor/codeEditor.jsx @@ -1,4 +1,4 @@ -/* eslint max-lines: ["error", { "max": 500 }] */ +/* eslint max-lines: ["error", { "max": 405 }] */ import './codeEditor.less'; import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react'; @@ -10,20 +10,26 @@ import { highlightActiveLine, scrollPastEnd, Decoration, - ViewPlugin, drawSelection, dropCursor, rectangularSelection, crosshairCursor, } from '@codemirror/view'; import { EditorState, Compartment, StateEffect, StateField } from '@codemirror/state'; -import { foldAll as foldAllCmd, unfoldAll as unfoldAllCmd, foldGutter, foldKeymap, foldEffect, foldState, syntaxHighlighting } from '@codemirror/language'; +import { + unfoldAll as unfoldAllCmd, + foldGutter, + foldKeymap, + foldEffect, + foldState, + syntaxHighlighting, +} from '@codemirror/language'; import { defaultKeymap, history, undo, redo, undoDepth, redoDepth } from '@codemirror/commands'; import { languages } from '@codemirror/language-data'; import { css } from '@codemirror/lang-css'; import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; import { html } from '@codemirror/lang-html'; -import { autocompleteEmoji } from './autocompleteEmoji.js'; +import { autocompleteEmoji } from './extensions/autocompleteEmoji.js'; import { searchKeymap, search } from '@codemirror/search'; import { closeBrackets } from '@codemirror/autocomplete'; @@ -37,69 +43,13 @@ const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery }; const themeCompartment = new Compartment(); const highlightCompartment = new Compartment(); -import { generalKeymap, markdownKeymap, cssKeymap, formatCSS } from './customKeyMaps.js'; -import foldOnPages from './customFolding.js'; -import { customHighlightStyle, tokenizeCustomMarkdown, tokenizeCustomCSS } from './customHighlight.js'; -import { legacyCustomHighlightStyle, legacyTokenizeCustomMarkdown } from './legacyCustomHighlight.js'; +import { generalKeymap, markdownKeymap, cssKeymap, formatCSS } from './extensions/customKeyMaps.js'; +import foldOnPages from './extensions/customFolding.js'; +import { customHighlightStyle } from './extensions/customHighlight.js'; +import { legacyCustomHighlightStyle } from './extensions/legacyCustomHighlight.js'; const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m; -const createHighlightPlugin = (renderer, tab)=>{ - //this function takes the custom tokens created in the tokenize function in customhighlight files - //takes the tokens defined by that function and assigns classes to them - //it also creates page number and snippet number widgets - - let tokenize; - - if(tab === 'brewStyles') { - tokenize = tokenizeCustomCSS; - } else { - tokenize = renderer === 'V3' ? tokenizeCustomMarkdown : legacyTokenizeCustomMarkdown; - } - - return ViewPlugin.fromClass( - class { - constructor(view) { - this.decorations = this.buildDecorations(view); - } - update(update) { - if(update.docChanged) { - this.decorations = this.buildDecorations(update.view); - } - } - buildDecorations(view) { - const decos = []; - const tokens = tokenize(view.state.doc.toString()); - let pageCount = 1; - let snippetCount = 0; - - tokens.forEach((tok)=>{ - const line = view.state.doc.line(tok.line + 1); - - if(tok.from != null && tok.to != null && tok.from < tok.to) { - decos.push(Decoration.mark({ class: `cm-${tok.type}` }).range(line.from + tok.from, line.from + tok.to)); - } else { - decos.push(Decoration.line({ class: `cm-${tok.type}` }).range(line.from)); - if(tok.type === 'pageLine' && tab === 'brewText') { - pageCount++; - line.from === 0 && pageCount--; - decos.push(Decoration.line({ attributes: { 'data-page-number': pageCount } }).range(line.from)); - } - if(tok.type === 'snippetLine' && tab === 'brewSnippets') { - snippetCount++; - decos.push(Decoration.line({ attributes: { 'data-page-number': snippetCount } }).range(line.from)); - } - } - }); - - decos.sort((a, b)=>a.from - b.from || a.to - b.to); - return Decoration.set(decos); - } - }, - { decorations: (v)=>v.decorations } - ); -}; - const setProgrammaticCursorLine = StateEffect.define(); const programmaticCursorLineField = StateField.define({ @@ -206,8 +156,6 @@ const CodeEditor = forwardRef( ? syntaxHighlighting(customHighlightStyle) : syntaxHighlighting(legacyCustomHighlightStyle); - const customHighlightPlugin = createHighlightPlugin(renderer, tab); - const languageExtension = language === 'css' ? css() : [markdown({ base: markdownLanguage, codeLanguages: languages }), html({ autoCloseTags: true })]; const themeExtension = Array.isArray(themes[editorTheme]) ? themes[editorTheme] : themes[editorTheme] || themes['default']; @@ -230,7 +178,7 @@ const CodeEditor = forwardRef( }), //highlights - highlightCompartment.of([customHighlightPlugin, highlightExtension]), + highlightCompartment.of([customHighlightPlugin(renderer, tab), highlightExtension]), themeCompartment.of(themeExtension), highlightActiveLine(), highlightActiveLineGutter(), @@ -371,10 +319,8 @@ const CodeEditor = forwardRef( ? syntaxHighlighting(customHighlightStyle) : syntaxHighlighting(legacyCustomHighlightStyle); - const customHighlightPlugin = createHighlightPlugin(renderer, tab); - view.dispatch({ - effects : highlightCompartment.reconfigure([customHighlightPlugin, highlightExtension]), + effects : highlightCompartment.reconfigure([customHighlightPlugin(renderer, tab), highlightExtension]), }); }, [renderer, tab]); @@ -383,7 +329,6 @@ const CodeEditor = forwardRef( injectText : (text)=>{ const view = viewRef.current; - view.dispatch( view.state.replaceSelection(text) ); diff --git a/client/components/codeEditor/codeEditor.less b/client/components/codeEditor/codeEditor.less index 0620dfa74..0223ab08f 100644 --- a/client/components/codeEditor/codeEditor.less +++ b/client/components/codeEditor/codeEditor.less @@ -92,7 +92,8 @@ &.term { color : rgb(96, 117, 143); } &.definition { color : rgb(97, 57, 178); } } - .cm-block:not(.cm-comment) { + .cm-block:not(.cm-comment), + .cm-block:not(.cm-comment) * { font-weight : bold; color : purple; } @@ -190,6 +191,33 @@ outline : 1px inset #00000055 !important; } + .cm-image { + position:relative; + + .cm-preview { + object-fit: contain; + position:absolute; + bottom:0; + left:0; + height:200px; + width:200px; + height:200px; + padding:5px; + background-color: #fff; + border-radius:10px; + border:3px solid grey; + pointer-events: none; + opacity:0; + transition:0.2s opacity 0.5s; + translate:0 100%; + z-index:1000; + } + + &:hover .cm-preview { + opacity:1; + } + } + /* Tab character visualization (optional) */ //.cm-tab { // background: url(...) no-repeat right; diff --git a/client/components/codeEditor/autocompleteEmoji.js b/client/components/codeEditor/extensions/autocompleteEmoji.js similarity index 100% rename from client/components/codeEditor/autocompleteEmoji.js rename to client/components/codeEditor/extensions/autocompleteEmoji.js diff --git a/client/components/codeEditor/customFolding.js b/client/components/codeEditor/extensions/customFolding.js similarity index 100% rename from client/components/codeEditor/customFolding.js rename to client/components/codeEditor/extensions/customFolding.js diff --git a/client/components/codeEditor/customHighlight.js b/client/components/codeEditor/extensions/customHighlight.js similarity index 66% rename from client/components/codeEditor/customHighlight.js rename to client/components/codeEditor/extensions/customHighlight.js index 622f8a3bf..33c68da7a 100644 --- a/client/components/codeEditor/customHighlight.js +++ b/client/components/codeEditor/extensions/customHighlight.js @@ -1,5 +1,15 @@ +/* eslint max-lines: ["error", { "max": 500 }] */ import { HighlightStyle } from '@codemirror/language'; import { tags } from '@lezer/highlight'; +import { legacyTokenizeCustomMarkdown } from './legacyCustomHighlight'; +import { + Decoration, + ViewPlugin, +} from '@codemirror/view'; +import { + syntaxTree, + ensureSyntaxTree +} from '@codemirror/language'; // Making the tokens const customTags = { @@ -23,7 +33,7 @@ const customTags = { variable : 'variable', }; -export function tokenizeCustomMarkdown(text) { +function tokenizeCustomMarkdown(text) { const tokens = []; const lines = text.split('\n'); @@ -149,7 +159,6 @@ export function tokenizeCustomMarkdown(text) { if(!/^::/.test(lines[lineNumber]) && lineNumber + 1 < lines.length && /^::/.test(lines[lineNumber + 1])) { const startLine = lineNumber; const defs = []; - let endLine = startLine; // collect all following :: definitions for (let i = lineNumber + 1; i < lines.length; i++) { @@ -158,7 +167,6 @@ export function tokenizeCustomMarkdown(text) { const defMatch = /^(::)(.+)$/.exec(nextLine); if(!onlyColonsMatch && defMatch) { defs.push({ colons: defMatch[1], desc: defMatch[2], line: i }); - endLine = i; } else break; } @@ -202,6 +210,7 @@ export function tokenizeCustomMarkdown(text) { if(lineText.includes('{') && lineText.includes('}')) { const injectionRegex = /(?:^|[^{\n])({(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\2})/gmd; let match; + while ((match = injectionRegex.exec(lineText)) !== null) { tokens.push({ line : lineNumber, @@ -241,14 +250,21 @@ export function tokenizeCustomMarkdown(text) { /^ *{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *$|^ *}}$/, ); if(match) endCh = match.index + match[0].length; - tokens.push({ line: lineNumber, type: customTags.block }); + const closingMatch = lineText.match(/ *(}})/d); + + if(closingMatch) { + tokens.push({ line: lineNumber, from: closingMatch.indices[1][0], to: closingMatch.indices[1][1], type: customTags.block }); + } else { + tokens.push({ line: lineNumber, type: customTags.block }); + } + } }); return tokens; } -export function tokenizeCustomCSS(text) { +function tokenizeCustomCSS(text) { const tokens = []; const lines = text.split('\n'); @@ -307,5 +323,150 @@ export const customHighlightStyle = HighlightStyle.define([ ]); +function getUrl(node, doc) { + let url = null; + const cursor = node.node.cursor(); + if(cursor.firstChild()) { + do { + if(cursor.name === 'URL') { + url = doc.sliceString(cursor.from, cursor.to); + break; + } + } while (cursor.nextSibling()); + } + + return url; +} + +import { WidgetType } from '@codemirror/view'; + +class ImageWidget extends WidgetType { + constructor(url) { + super(); + this.url = url; + } + + toDOM() { + const img = document.createElement('img'); + img.loading = "lazy"; + img.className = 'cm-preview'; + img.src = this.url; + + + img.onerror = ()=>{ + img.src = 'client/icons/broken-image.jpg'; + }; + + return img; + } + + eq(other) { + return other.url === this.url; + } +} + +export function customHighlightPlugin(renderer, tab) { + //this function takes the custom tokens created in the tokenize function in customhighlight files + //takes the tokens defined by that function and assigns classes to them + //it also creates page number and snippet number widgets + + let tokenize; + + if(tab === 'brewStyles') { + tokenize = tokenizeCustomCSS; + } else { + tokenize = renderer === 'V3' ? tokenizeCustomMarkdown : legacyTokenizeCustomMarkdown; + } + + return ViewPlugin.fromClass( + class { + constructor(view) { + this.decorations = this.buildDecorations(view); + } + update(update) { + if(update.docChanged) { + this.decorations = this.buildDecorations(update.view); + } + } + buildDecorations(view) { + const decos = []; + const tokens = tokenize(view.state.doc.toString()); + let pageCount = 1; + let snippetCount = 0; + + const tree = ensureSyntaxTree(view.state, view.state.doc.length, 50) || syntaxTree(view.state); + tree.iterate({ + enter : (node)=>{ + if(node.name === 'Image') { + const url = getUrl(node, view.state.doc); + + const widgetPosition = node.node.lastChild.from; + //this is not exactly standard, but should hold, + //and is the shortest way i could find of positioning + //the image inside the cm-image node + + if(!url) return; + + decos.push( + Decoration.mark({ + class : 'cm-image' + }).range(node.from, node.to) + ); + decos.push( + Decoration.widget({ + widget : new ImageWidget(url), + side : 1 + }).range(widgetPosition) + ); + } + } + }); + + tokens.forEach((token)=>{ + const line = view.state.doc.line(token.line + 1); + + if(token.from != null && token.to != null && token.from < token.to) { + const from = line.from + token.from; + const to = line.from + token.to; + + const attrs = {}; + if(token.type === 'Image' && token.url) { + + attrs['data-url'] = token.url; + } + + decos.push( + Decoration.mark({ + class : `cm-${token.type}`, + ...(Object.keys(attrs).length + ? { attributes: attrs } + : {}) + }).range(from, to) + ); + } else { + decos.push( + Decoration.line({ + class : `cm-${token.type}` + }).range(line.from) + ); + if(token.type === 'pageLine' && tab === 'brewText') { + pageCount++; + if(line.from === 0) pageCount--; + decos.push(Decoration.line({ attributes: { 'data-page-number': pageCount } }).range(line.from)); + } + if(token.type === 'snippetLine' && tab === 'brewSnippets') { + snippetCount++; + decos.push(Decoration.line({ attributes: { 'data-page-number': snippetCount } }).range(line.from)); + } + } + }); + + decos.sort((a, b)=>a.from - b.from || a.to - b.to); + return Decoration.set(decos); + } + }, + { decorations: (v)=>v.decorations } + ); +}; \ No newline at end of file diff --git a/client/components/codeEditor/customKeyMaps.js b/client/components/codeEditor/extensions/customKeyMaps.js similarity index 100% rename from client/components/codeEditor/customKeyMaps.js rename to client/components/codeEditor/extensions/customKeyMaps.js diff --git a/client/components/codeEditor/legacyCustomHighlight.js b/client/components/codeEditor/extensions/legacyCustomHighlight.js similarity index 100% rename from client/components/codeEditor/legacyCustomHighlight.js rename to client/components/codeEditor/extensions/legacyCustomHighlight.js diff --git a/client/icons/broken-image.jpg b/client/icons/broken-image.jpg new file mode 100644 index 000000000..52fa33195 Binary files /dev/null and b/client/icons/broken-image.jpg differ