/* 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, } from '@codemirror/view'; import { EditorState, Compartment, StateEffect, StateField } from '@codemirror/state'; import { foldAll as foldAllCmd, unfoldAll as unfoldAllCmd, foldGutter, foldKeymap, syntaxHighlighting } from '@codemirror/language'; import { defaultKeymap, history, undo, redo, undoDepth, redoDepth } from '@codemirror/commands'; import { languages } from '@codemirror/language-data'; import { css, cssLanguage } 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 * as themesImport from '@uiw/codemirror-themes-all'; import defaultCM5Theme from '@themes/codeMirror/default.js'; import darkbrewery from '@themes/codeMirror/darkbrewery.js'; const themes = { default: defaultCM5Theme, darkbrewery, ...themesImport }; 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 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': pageCount } }).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( ( { value = '', onChange = ()=>{}, onCursorChange = ()=>{}, onViewChange = ()=>{}, language = '', tab = 'brewText', editorTheme = 'default', view, style, renderer, ...props }, ref, )=>{ const editorRef = useRef(null); const viewRef = useRef(null); const docsRef = useRef({}); const prevTabRef = useRef(tab); // page map const pageBreaksRef = useRef([]); const recomputePages = (doc)=>{ const pages = [0]; const text = doc.toString(); let offset = 0; for (const line of text.split('\n')) { if(/^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m.test(line)) { pages.push(offset); } offset += line.length + 1; } pageBreaksRef.current = pages; }; const findPageFromPos = (pos)=>{ const pages = pageBreaksRef.current; let page = 1; for (let i = 1; i < pages.length; i++) { if(pos >= pages[i]) page = i + 1; } return page; }; const createExtensions = ({ onChange, language, editorTheme })=>{ const setEventListeners = EditorView.updateListener.of((update)=>{ if(update.docChanged) { recomputePages(update.state.doc); // CHANGED (added) 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(), 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; const block = view.lineBlockAtHeight(top); const page = findPageFromPos(block.from); // CHANGED onViewChange(page); ticking = false; }); }; view.scrollDOM.addEventListener('scroll', handleScroll); docsRef.current[tab] = state; return ()=>{ view.scrollDOM.removeEventListener('scroll', handleScroll); viewRef.current?.destroy(); }; }, []); useEffect(()=>{ const view = viewRef.current; if(!view) return; const prevTab = prevTabRef.current; 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); 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, ()=>({ getValue : ()=>viewRef.current.state.doc.toString(), setValue : (text)=>{ const view = viewRef.current; view.dispatch({ changes : { from: 0, to: view.state.doc.length, insert: text }, }); }, injectText : (text)=>{ const view = viewRef.current; view.dispatch( view.state.replaceSelection(text) ); view.focus(); }, getCursorPosition : ()=>viewRef.current.state.selection.main.head, getScrollTop : ()=>viewRef.current.scrollDOM.scrollTop, scrollToY : (y)=>{ viewRef.current.scrollDOM.scrollTo({ top: y }); }, scrollToPage : (pageNumber, smooth = true)=>{ const view = viewRef.current; if(!view) return; const pos = pageBreaksRef.current[pageNumber - 1] ?? 0; view.dispatch({ effects : EditorView.scrollIntoView(pos, { y: 'start' }) }); }, getPagePos : (pageNumber)=>{ const view = viewRef.current; if(!view) return 0; const pos = pageBreaksRef.current[pageNumber - 1] ?? 0; return pos; }, setCursorToPage : (pageNumber)=>{ const view = viewRef.current; if(!view) return; const pos = pageBreaksRef.current[pageNumber - 1] ?? 0; view.dispatch({ selection : { anchor: pos }, effects : setProgrammaticCursorLine.of(pos) }); 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; view.dispatch(foldAllCmd(view)); }, unfoldAll : ()=>{ const view = viewRef.current; if(!view) return; view.dispatch(unfoldAllCmd(view)); }, focus : ()=>viewRef.current.focus(), })); return
; }, ); export default CodeEditor;