import {extname} from '../../utils.ts'; import {createElementFromHTML, toggleElem} from '../../utils/dom.ts'; import {html, htmlRaw} from '../../utils/html.ts'; import {svg} from '../../svg.ts'; import {commandPalette} from './command-palette.ts'; import type {PaletteCommand} from './command-palette.ts'; import {contextMenu, collectSymbols, selectAllOccurrences} from './context-menu.ts'; import {createJsonLinter, createSyntaxErrorLinter} from './linter.ts'; import {clickableUrls, goToDefinitionAt, trimTrailingWhitespaceFromView} from './utils.ts'; import type {LanguageDescription} from '@codemirror/language'; import type {Compartment, Extension} from '@codemirror/state'; import type {EditorView, ViewUpdate} from '@codemirror/view'; // CodeEditorConfig is also used by backend, defined in "editor_util.go" const codeEditorConfigDefault = { filename: '', // the current filename (base name, not full path), used for language detection autofocus: false, // whether to autofocus the editor on load previewableExtensions: [] as string[], // file extensions that support preview rendering lineWrapExtensions: [] as string[], // file extensions that enable line wrapping by default lineWrap: false, // whether line wrapping is enabled for the current file indentStyle: '', // "space" or "tab", from .editorconfig, or empty for not specified (detect from source code) indentSize: 0, // number of spaces per indent level, from .editorconfig, or 0 for not specified (detect from source code) tabWidth: 4, // display width of a tab character, from .editorconfig, defaults to 4 trimTrailingWhitespace: false, // whether to trim trailing whitespace on save, from .editorconfig }; type CodeEditorConfig = typeof codeEditorConfigDefault; export type CodemirrorEditor = { view: EditorView; trimTrailingWhitespace: boolean; togglePalette: (view: EditorView) => boolean; updateFilename: (filename: string) => Promise; languages: LanguageDescription[]; compartments: { wordWrap: Compartment; language: Compartment; tabSize: Compartment; indentUnit: Compartment; lint: Compartment; }; }; export type CodemirrorModules = Awaited>; async function importCodemirror() { const [autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap] = await Promise.all([ import('@codemirror/autocomplete'), import('@codemirror/commands'), import('@codemirror/language'), import('@codemirror/language-data'), import('@codemirror/lint'), import('@codemirror/search'), import('@codemirror/state'), import('@codemirror/view'), import('@lezer/highlight'), import('@replit/codemirror-indentation-markers'), import('@replit/codemirror-vscode-keymap'), ]); return {autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap}; } function togglePreviewDisplay(previewable: boolean): void { // FIXME: here and below, the selector is too broad, it should only query in the editor related scope const previewTab = document.querySelector('a[data-tab="preview"]'); // the "preview tab" exists for "file code editor", but doesn't exist for "git hook editor" if (!previewTab) return; toggleElem(previewTab, previewable); if (previewable) return; // If not previewable but the "preview" tab was active (user changes the filename to a non-previewable one), // then the "preview" tab becomes inactive (hidden), so the "write" tab should become active if (previewTab.classList.contains('active')) { const writeTab = document.querySelector('a[data-tab="write"]'); writeTab!.click(); } } export async function createCodeEditor(textarea: HTMLTextAreaElement, filenameInput?: HTMLInputElement): Promise { const config: CodeEditorConfig = { ...codeEditorConfigDefault, ...JSON.parse(textarea.getAttribute('data-code-editor-config')!), }; const previewableExts = new Set(config.previewableExtensions || []); const lineWrapExts = config.lineWrapExtensions || []; const cm = await importCodemirror(); const languageDescriptions: LanguageDescription[] = [ ...cm.languageData.languages.filter((l: LanguageDescription) => l.name !== 'Markdown'), cm.language.LanguageDescription.of({ name: 'Markdown', extensions: ['md', 'markdown', 'mkd'], load: async () => (await import('@codemirror/lang-markdown')).markdown({codeLanguages: languageDescriptions}), }), cm.language.LanguageDescription.of({ name: 'Elixir', extensions: ['ex', 'exs'], load: async () => (await import('codemirror-lang-elixir')).elixir(), }), cm.language.LanguageDescription.of({ name: 'Nix', extensions: ['nix'], load: async () => (await import('@replit/codemirror-lang-nix')).nix(), }), cm.language.LanguageDescription.of({ name: 'Svelte', extensions: ['svelte'], load: async () => (await import('@replit/codemirror-lang-svelte')).svelte(), }), cm.language.LanguageDescription.of({ name: 'Makefile', filename: /^(GNUm|M|m)akefile$/, load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)), }), cm.language.LanguageDescription.of({ name: 'Dotenv', extensions: ['env'], filename: /^\.env(\..*)?$/, load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)), }), cm.language.LanguageDescription.of({ name: 'JSON5', extensions: ['json5', 'jsonc'], load: async () => (await import('@codemirror/lang-json')).json(), }), ]; const matchedLang = cm.language.LanguageDescription.matchFilename(languageDescriptions, config.filename); const container = document.createElement('div'); container.className = 'code-editor-container'; container.setAttribute('data-language', matchedLang?.name.toLowerCase() || ''); // Replace the loading placeholder with the editor container in one operation // to avoid a flash where neither element is in the DOM. const loading = textarea.parentNode!.querySelector('.editor-loading'); if (loading) { loading.replaceWith(container); } else { textarea.parentNode!.append(container); } const loadedLang = matchedLang ? await matchedLang.load() : null; const wordWrap = new cm.state.Compartment(); const language = new cm.state.Compartment(); const tabSize = new cm.state.Compartment(); const indentUnitComp = new cm.state.Compartment(); const lintComp = new cm.state.Compartment(); const palette = commandPalette(cm); const goToSymbol = (view: EditorView) => { const symbols = collectSymbols(cm, view); const items: PaletteCommand[] = symbols.map((sym) => ({ label: `${sym.label} (${sym.kind})`, keys: '', run: (v: EditorView) => v.dispatch({selection: {anchor: sym.from}, scrollIntoView: true}), })); palette.showWithItems(view, items, 'Go to symbol…'); return true; }; const view = new cm.view.EditorView({ doc: textarea.defaultValue, // use defaultValue to prevent browser from restoring form values on refresh parent: container, extensions: [ cm.view.lineNumbers(), cm.language.codeFolding({ placeholderDOM(_view: EditorView, onclick: (event: Event) => void) { const el = createElementFromHTML(html`${htmlRaw(svg('octicon-kebab-horizontal', 13))}`); el.addEventListener('click', onclick); return el as unknown as HTMLElement; }, }), cm.language.foldGutter({ markerDOM(open: boolean) { return createElementFromHTML(svg(open ? 'octicon-chevron-down' : 'octicon-chevron-right', 13)); }, }), cm.view.highlightActiveLineGutter(), cm.view.highlightSpecialChars(), cm.view.highlightActiveLine(), cm.view.drawSelection(), cm.view.dropCursor(), cm.view.rectangularSelection(), cm.view.crosshairCursor(), cm.view.placeholder(textarea.placeholder), config.trimTrailingWhitespace ? cm.view.highlightTrailingWhitespace() : [], cm.search.search({top: true}), cm.search.highlightSelectionMatches(), cm.view.keymap.of([ ...cm.vscodeKeymap.vscodeKeymap, ...cm.search.searchKeymap, ...cm.lint.lintKeymap, cm.commands.indentWithTab, {key: 'Mod-k Mod-x', run: (view) => { trimTrailingWhitespaceFromView(view); return true }, preventDefault: true}, {key: 'Mod-Enter', run: cm.commands.insertBlankLine, preventDefault: true}, {key: 'Mod-k Mod-k', run: cm.commands.deleteToLineEnd, preventDefault: true}, {key: 'Mod-k Mod-Backspace', run: cm.commands.deleteToLineStart, preventDefault: true}, ]), cm.state.EditorState.allowMultipleSelections.of(true), cm.language.indentOnInput(), cm.language.syntaxHighlighting(cm.highlight.classHighlighter), cm.language.bracketMatching(), indentUnitComp.of( cm.language.indentUnit.of( config.indentStyle === 'tab' ? '\t' : ' '.repeat(config.indentSize || 4), ), ), cm.autocomplete.closeBrackets(), cm.autocomplete.autocompletion(), cm.state.EditorState.languageData.of(() => [{autocomplete: cm.autocomplete.completeAnyWord}]), cm.indentMarkers.indentationMarkers({ colors: { light: 'transparent', dark: 'transparent', activeLight: 'var(--color-secondary-dark-3)', activeDark: 'var(--color-secondary-dark-3)', }, }), cm.commands.history(), palette.extensions, cm.view.keymap.of([ {key: 'Mod-Shift-o', run: goToSymbol, preventDefault: true}, {key: 'Mod-F2', run: (v) => { selectAllOccurrences(cm, v); return true }, preventDefault: true}, {key: 'F12', run: (v) => goToDefinitionAt(cm, v, v.state.selection.main.from), preventDefault: true}, ]), contextMenu(cm, palette.togglePalette, goToSymbol), clickableUrls(cm), tabSize.of(cm.state.EditorState.tabSize.of(config.tabWidth || 4)), wordWrap.of(config.lineWrap ? cm.view.EditorView.lineWrapping : []), language.of(loadedLang ?? []), lintComp.of(await getLinterExtension(cm, config.filename, loadedLang)), cm.view.EditorView.updateListener.of((update: ViewUpdate) => { if (update.docChanged) { textarea.value = update.state.doc.toString(); textarea.dispatchEvent(new Event('change')); // needed for jquery-are-you-sure } }), ], }); const editor: CodemirrorEditor = { view, trimTrailingWhitespace: config.trimTrailingWhitespace, togglePalette: palette.togglePalette, updateFilename: async (filename: string) => { togglePreviewDisplay(previewableExts.has(extname(filename))); await updateEditorLanguage(cm, editor, filename, lineWrapExts); }, languages: languageDescriptions, compartments: {wordWrap, language, tabSize, indentUnit: indentUnitComp, lint: lintComp}, }; const elEditorOptions = textarea.closest('form')!.querySelector('.code-editor-options'); if (elEditorOptions) { const indentStyleSelect = elEditorOptions.querySelector('.js-indent-style-select')!; const indentSizeSelect = elEditorOptions.querySelector('.js-indent-size-select')!; const applyIndentSettings = (style: string, size: number) => { view.dispatch({ effects: [ indentUnitComp.reconfigure(cm.language.indentUnit.of(style === 'tab' ? '\t' : ' '.repeat(size))), tabSize.reconfigure(cm.state.EditorState.tabSize.of(size)), ], }); }; indentStyleSelect.addEventListener('change', () => { applyIndentSettings(indentStyleSelect.value, Number(indentSizeSelect.value) || 4); }); indentSizeSelect.addEventListener('change', () => { applyIndentSettings(indentStyleSelect.value || 'space', Number(indentSizeSelect.value) || 4); }); elEditorOptions.querySelector('.js-code-find')!.addEventListener('click', () => { if (cm.search.searchPanelOpen(view.state)) { cm.search.closeSearchPanel(view); } else { cm.search.openSearchPanel(view); } }); elEditorOptions.querySelector('.js-code-command-palette')!.addEventListener('click', () => { palette.togglePalette(view); }); elEditorOptions.querySelector('.js-line-wrap-select')!.addEventListener('change', (e) => { const target = e.target as HTMLSelectElement; view.dispatch({ effects: wordWrap.reconfigure(target.value === 'on' ? cm.view.EditorView.lineWrapping : []), }); }); } togglePreviewDisplay(previewableExts.has(extname(config.filename))); if (config.autofocus) { editor.view.focus(); } else if (filenameInput) { filenameInput.focus(); } return editor; } // files that are JSONC despite having a .json extension const jsoncFilesRegex = /^([jt]sconfig.*|devcontainer)\.json$/; async function getLinterExtension(cm: CodemirrorModules, filename: string, loadedLang: {language: unknown} | null): Promise { const ext = extname(filename).toLowerCase(); if (ext === '.json' || ext === '.map') { return jsoncFilesRegex.test(filename) ? [] : [cm.lint.lintGutter(), await createJsonLinter(cm)]; } // StreamLanguage (legacy modes) don't produce Lezer error nodes if (!loadedLang || loadedLang.language instanceof cm.language.StreamLanguage) return []; return [cm.lint.lintGutter(), createSyntaxErrorLinter(cm)]; } async function updateEditorLanguage(cm: CodemirrorModules, editor: CodemirrorEditor, filename: string, lineWrapExts: string[]): Promise { const {compartments, view, languages: editorLanguages} = editor; const newLanguage = cm.language.LanguageDescription.matchFilename(editorLanguages, filename); const newLoadedLang = newLanguage ? await newLanguage.load() : null; view.dom.closest('.code-editor-container')!.setAttribute('data-language', newLanguage?.name.toLowerCase() || ''); view.dispatch( { effects: [ compartments.wordWrap.reconfigure( lineWrapExts.includes(extname(filename).toLowerCase()) ? cm.view.EditorView.lineWrapping : [], ), compartments.language.reconfigure(newLoadedLang ?? []), compartments.lint.reconfigure(await getLinterExtension(cm, filename, newLoadedLang)), ], }, // clear stale diagnostics from the previous language on filename change cm.lint.setDiagnostics(view.state, []), ); }