/* eslint max-lines: ["error", { "max": 400 }] */ import './codeEditor.less'; import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react'; import { EditorView, keymap, lineNumbers, highlightActiveLineGutter, 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 { 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 { searchKeymap, search } from '@codemirror/search'; import { closeBrackets } from '@codemirror/autocomplete'; const autoCloseBrackets = closeBrackets({ brackets: ['()', '[]', '{{}}'] }); import defaultCM5Theme from '@themes/codeMirror/default.js'; import darkbrewery from '@themes/codeMirror/darkbrewery.js'; import cm5Themes from 'codemirror-5-themes'; const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery }; const themeCompartment = new Compartment(); const highlightCompartment = new Compartment(); import { generalKeymap, markdownKeymap } from './customKeyMaps.js'; import foldOnPages from './customFolding.js'; import { customHighlightStyle, tokenizeCustomMarkdown, tokenizeCustomCSS } from './customHighlight.js'; import { legacyCustomHighlightStyle, legacyTokenizeCustomMarkdown } from './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({ create() { return Decoration.none; }, update(decorations, transitionState) { //deco is the decoratiions object //tr is the transition state object, tr.effects is an array of stateEffects //seems to be the easiest way of setting a class programatically only when called for (const effects of transitionState.effects) { if(effects.is(setProgrammaticCursorLine)) { const pos = effects.value; if(pos == null) return Decoration.none; const line = transitionState.state.doc.lineAt(pos); return Decoration.set([ Decoration.line({ class : 'sourceMoveFlash' }).range(line.from) ]); } } return decorations; }, provide : (decorationSet)=>EditorView.decorations.from(decorationSet) }); const CodeEditor = forwardRef( ( { language = '', tab = 'brewText', view, value = '', onChange = ()=>{}, onCursorChange = ()=>{}, onViewChange = ()=>{}, editorTheme = 'default', style, renderer, ...props }, ref, )=>{ const editorRef = useRef(null); const viewRef = useRef(null); const docsRef = useRef({}); const tabRef = useRef(tab); const prevTabRef = useRef(tab); const scrollRef = useRef({}); const foldsRef = useRef({}); const pageMap = useRef([]); const recomputePages = (doc)=>{ if(tab !== 'brewText') return; const pages = [0]; const text = doc.toString(); let offset = 0; for (const line of text.split('\n')) { if(PAGEBREAK_REGEX_V3.test(line)) { pages.push(offset); } offset += line.length + 1; } pageMap.current = pages; }; const findPageFromPos = (pos)=>{ const pages = pageMap.current; let page = 1; for (let i = 1; i < pages.length; i++) { if(pos >= pages[i]) page = i + 1; } return page; }; const getFoldRanges = (state) => { const folds = []; state.field(foldState, false)?.between(0, state.doc.length, (from, to) => { folds.push({ from, to }); }); return folds; }; const createExtensions = ({ onChange, language, editorTheme })=>{ const setEventListeners = EditorView.updateListener.of((update)=>{ if(update.docChanged) { recomputePages(update.state.doc); onChange(update.state.doc.toString()); } if(update.selectionSet) { const pos = update.state.selection.main.head; const page = findPageFromPos(pos); onCursorChange(page); } }); const highlightExtension = renderer === 'V3' ? syntaxHighlighting(customHighlightStyle) : syntaxHighlighting(legacyCustomHighlightStyle); const 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']; return [ EditorView.lineWrapping, setEventListeners, languageExtension, autoCloseBrackets, lineNumbers(), scrollPastEnd(), search(), history(), //allows for undo and redo ...(tab !== 'brewStyles' ? [autocompleteEmoji] : []), //folding foldOnPages, foldGutter({ openText : '▾', closedText : '▸' }), //highlights highlightCompartment.of([customHighlightPlugin, highlightExtension]), themeCompartment.of(themeExtension), highlightActiveLine(), highlightActiveLineGutter(), //keyboard shortcut keymap.of([...defaultKeymap, foldKeymap, ...searchKeymap]), generalKeymap, ...(tab !== 'brewStyles' ? [markdownKeymap] : []), //multiple cursors and selections drawSelection(), rectangularSelection(), crosshairCursor(), EditorState.allowMultipleSelections.of(true), dropCursor(), programmaticCursorLineField, ]; }; useEffect(()=>{ if(!editorRef.current) return; const state = EditorState.create({ doc : value, extensions : createExtensions({ onChange, language, editorTheme }), }); recomputePages(state.doc); viewRef.current = new EditorView({ state, parent : editorRef.current, }); const view = viewRef.current; let ticking = false; const handleScroll = ()=>{ if(ticking) return; ticking = true; requestAnimationFrame(()=>{ const top = view.scrollDOM.scrollTop; scrollRef.current[tabRef.current] = top; const block = view.lineBlockAtHeight(top); const page = findPageFromPos(block.from); onViewChange(page); ticking = false; }); }; view.scrollDOM.addEventListener('scroll', handleScroll); docsRef.current[tab] = state; return ()=>{ view.scrollDOM.removeEventListener('scroll', handleScroll); viewRef.current?.destroy(); }; }, []); const restoreFolds = (view, folds) => { if (!folds?.length) return; view.dispatch({ effects: folds.map(f => foldEffect.of(f)) }); }; useEffect(()=>{ const view = viewRef.current; if(!view) return; tabRef.current = tab; const prevTab = prevTabRef.current; foldsRef.current[prevTab] = getFoldRanges(view.state); if(prevTab !== tab) { docsRef.current[prevTab] = view.state; let nextState = docsRef.current[tab]; if(!nextState) { nextState = EditorState.create({ doc : value, extensions : createExtensions({ onChange, language, editorTheme }), }); } view.setState(nextState); restoreFolds(view, foldsRef.current[tab]); const savedScroll = scrollRef.current[tab]; if(savedScroll != null) { requestAnimationFrame(()=>{ view.scrollDOM.scrollTop = savedScroll; }); } prevTabRef.current = tab; } view.focus(); }, [tab]); useEffect(()=>{ const view = viewRef.current; if(!view) return; const current = view.state.doc.toString(); if(value !== current) { view.dispatch({ changes : { from: 0, to: current.length, insert: value }, }); } }, [value]); useEffect(()=>{ //rebuild theme extension on theme change const view = viewRef.current; if(!view) return; const themeExtension = Array.isArray(themes[editorTheme])? themes[editorTheme]: themes[editorTheme] || themes['default']; view.dispatch({ effects : themeCompartment.reconfigure(themeExtension), }); }, [editorTheme]); useEffect(()=>{ //rebuild syntax highlight when changing tab or renderer const view = viewRef.current; if(!view) return; const highlightExtension =renderer === 'V3' ? syntaxHighlighting(customHighlightStyle) : syntaxHighlighting(legacyCustomHighlightStyle); const customHighlightPlugin = createHighlightPlugin(renderer, tab); view.dispatch({ effects : highlightCompartment.reconfigure([customHighlightPlugin, highlightExtension]), }); }, [renderer, tab]); useImperativeHandle(ref, ()=>({ injectText : (text)=>{ const view = viewRef.current; view.dispatch( view.state.replaceSelection(text) ); view.focus(); }, getCursorPosition : ()=>viewRef.current.state.selection.main.head, scrollToPage : (pageNumber, smooth = true)=>{ const view = viewRef.current; if(!view) return; const pos = pageMap.current[pageNumber - 1] ?? 0; view.dispatch({ selection : { anchor: pos }, effects : [setProgrammaticCursorLine.of(pos), EditorView.scrollIntoView(pos, { y: 'start' })], }); view.focus(); setTimeout(()=>{ view.dispatch({ effects : setProgrammaticCursorLine.of(null) }); }, 400); }, undo : ()=>undo(viewRef.current), redo : ()=>redo(viewRef.current), historySize : ()=>{ const view = viewRef.current; if(!view) return { done: 0, undone: 0 }; return { done : undoDepth(view.state), undone : redoDepth(view.state), }; }, foldAll : ()=>{ const view = viewRef.current; if(!view) return; const doc = view.state.doc; const pages = pageMap.current; const effects = pages.map((start, i)=>{ const next = pages[i + 1] || doc.length; const from = i ? doc.line(doc.lineAt(start).number + 1).from : 0; const to = doc.line(doc.lineAt(next).number).from - 1; return to > from ? foldEffect.of({ from, to }) : null; }).filter(Boolean); view.dispatch({ effects }); }, unfoldAll : ()=>{ const view = viewRef.current; if(!view) return; view.dispatch(unfoldAllCmd(view)); }, focus : ()=>viewRef.current.focus(), })); return
; }, ); export default CodeEditor;