mirror of
https://github.com/naturalcrit/homebrewery.git
synced 2026-06-22 00:38:38 +00:00
dff93002d0
Add short circuit so the full hoisting only happens on brew load rather than every parse. Remove stray CR in unrelated file.
458 lines
12 KiB
React
458 lines
12 KiB
React
/* 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 <div className={`codeEditor ${tab}`} ref={editorRef} style={style} />;
|
|
},
|
|
);
|
|
|
|
export default CodeEditor;
|