UNPKG

vzcode

Version:
405 lines (362 loc) 11.8 kB
import { EditorView } from 'codemirror'; import { Compartment, EditorState, } from '@codemirror/state'; import { javascript } from '@codemirror/lang-javascript'; import { svelte } from '@replit/codemirror-lang-svelte'; import { json } from '@codemirror/lang-json'; import { markdown } from '@codemirror/lang-markdown'; import { html } from '@codemirror/lang-html'; import { css } from '@codemirror/lang-css'; import { json1Sync } from 'codemirror-ot'; import { autocompletion } from '@codemirror/autocomplete'; import { indentationMarkers } from '@replit/codemirror-indentation-markers'; // import { showMinimap } from '@replit/codemirror-minimap'; import { vscodeKeymap } from '@replit/codemirror-vscode-keymap'; import { Diagnostic, linter } from '@codemirror/lint'; import { json1Presence, textUnicode } from '../../ot'; import { PaneId, ShareDBDoc, TabState, Username, } from '../../types'; import { VizFileId, VizContent } from '@vizhub/viz-types'; import { json1PresenceBroadcast } from './json1PresenceBroadcast'; import { json1PresenceDisplay } from './json1PresenceDisplay'; import { colorsInTextPlugin, rotationIndicator, widgets, } from './InteractiveWidgets'; import { EditorCache, editorCacheKey, EditorCacheValue, } from '../useEditorCache'; import { ThemeLabel, themeOptionsByLabel } from '../themes'; import { keymap } from '@codemirror/view'; import { basicSetup } from './basicSetup'; import { InteractRule } from '@replit/codemirror-interact'; import rainbowBrackets from '../CodeEditor/rainbowBrackets'; import { cssLanguage } from '@codemirror/lang-css'; import { javascriptLanguage } from '@codemirror/lang-javascript'; import { copilot } from './Copilot'; const DEBUG = false; // Enables TypeScript +JSX support in CodeMirror. const tsx = () => javascript({ jsx: true, typescript: true }); const htmlConfig = { matchClosingTags: true, selfClosingTags: false, autoCloseTags: true, extraTags: {}, extraGlobalAttributes: {}, nestedLanguages: [ { tag: 'script', language: javascript, parser: javascriptLanguage.parser, }, { tag: 'style', language: css, parser: cssLanguage.parser, }, ], nestedAttributes: [], }; // Language extensions for CodeMirror. // Keys are file extensions. // Values are CodeMirror extensions. // TODO consider moving this to a separate file. const languageExtensions = { json, tsx, js: tsx, jsx: tsx, ts: tsx, html: () => html(htmlConfig), css, md: markdown, svelte, }; // Gets a value at a path in an object. // e.g. getAtPath({a: {b: 1}}, ['a', 'b']) === 1 const getAtPath = (obj, path) => { let current = obj; for (const key of path) { if (!current) return undefined; current = current[key]; } return current; }; // Extend the EditorCacheValue type to include the compartments and the updateRainbowBrackets method interface ExtendedEditorCacheValue extends EditorCacheValue { themeCompartment: Compartment; rainbowBracketsCompartment: Compartment; updateRainbowBrackets: (enabled: boolean) => void; } // Gets or creates an `editorCache` entry for the given file id. // Looks in `editorCache` first, and if not found, creates a new editor. export const getOrCreateEditor = ({ paneId = 'root', fileId, shareDBDoc, content, filesPath, localPresence, docPresence, theme, onInteract, editorCache, usernameRef, customInteractRules, enableAutoFollowRef, openTab, aiCopilotEndpoint, rainbowBracketsEnabled = true, }: { // TODO pass this in from the outside paneId?: PaneId; fileId: VizFileId; // The ShareDB document that contains the file. // Used when the editor is created for: // * Figuring out the initial text // * Figuring out the initial language extension // based on the file extension. // It's also passed into the `json1Sync` extension, // which is used for multiplayer editing. // This can be `undefined` in the case where we are // viewing files read-only, in which case multiplayer // editing is not enabled. shareDBDoc: ShareDBDoc<VizContent> | undefined; // The initial content to present. content: VizContent; filesPath: string[]; localPresence: any; docPresence: any; theme: ThemeLabel; onInteract?: () => void; editorCache: EditorCache; usernameRef: React.MutableRefObject<Username>; aiAssistEndpoint?: string; customInteractRules?: Array<InteractRule>; // Ref to a boolean that determines whether to // enable auto-following the cursors of remote users. enableAutoFollowRef: React.MutableRefObject<boolean>; openTab: (tabState: TabState) => void; aiCopilotEndpoint?: string; rainbowBracketsEnabled?: boolean; // New parameter type }): ExtendedEditorCacheValue => { // Cache hit const cacheKey = editorCacheKey(fileId, paneId); if (editorCache.has(cacheKey)) { return editorCache.get( cacheKey, ) as ExtendedEditorCacheValue; } // Cache miss // Compute `text` and `fileExtension` from the ShareDB document. const textPath = [...filesPath, fileId, 'text']; const namePath = [...filesPath, fileId, 'name']; const text = getAtPath(content, textPath); const name = getAtPath(content, namePath); const fileExtension = name.split('.').pop(); // Create a compartment for the theme so that it can be changed dynamically. // Inspired by: https://github.com/craftzdog/cm6-themes/blob/main/example/index.ts let themeCompartment = new Compartment(); // Create a compartment for rainbow brackets so that it can be enabled/disabled dynamically. let rainbowBracketsCompartment = new Compartment(); // The CodeMirror extensions to use. // const extensions = [autocompletion(), html(htmlConfig)] const extensions = []; // This plugin implements multiplayer editing, // real-time synchronozation of changes across clients. // Does not deal with showing others' cursors. if (shareDBDoc) { extensions.push( json1Sync({ shareDBDoc, path: textPath, json1: json1Presence, textUnicode, }), ); // Deals with broadcasting changes in cursor location and selection. if (localPresence) { extensions.push( json1PresenceBroadcast({ path: textPath, localPresence, usernameRef, }), ); } // Deals with receiving the broadcast from other clients and displaying them. if (docPresence) { extensions.push( json1PresenceDisplay({ path: textPath, docPresence, enableAutoFollowRef, openTab, }), ); } } else { // If the ShareDB document is not provided, // then we do not allow editing. extensions.push(EditorView.editable.of(false)); } extensions.push(colorsInTextPlugin); // This is the "basic setup" for CodeMirror, // which actually adds a ton on functionality. // TODO vet this functionality, and determine how much // we want to replace with // https://github.com/vizhub-core/vzcode/issues/134 extensions.push(basicSetup); // This supports dynamic changing of the theme. extensions.push( themeCompartment.of(themeOptionsByLabel[theme].value), ); // Adds compartment for rainbow brackets with initial toggle state. extensions.push( rainbowBracketsCompartment.of( rainbowBracketsEnabled ? rainbowBrackets() : [], ), ); // TODO handle dynamic changing of the file extension. // TODO handle dynamic file extensions by making // the CodeMirror language extension dynamic // using a Compartment. const languageCompartment = new Compartment(); // See https://github.com/vizhub-core/vzcode/issues/55 const getLanguageExtension = (fileExtension: string) => { const languageFunc = languageExtensions[fileExtension]; return languageFunc ? languageFunc() : undefined; }; const languageExtension = getLanguageExtension(fileExtension); if (languageExtension) { extensions.push( languageCompartment.of(languageExtension), ); } else { // Not sure if this case even works. // TODO manually test this case by creating a file // that has no extension, opening it up, // and then adding an extension. // console.warn( // `No language extension for file extension: ${fileExtension}`, // ); // We still need to push the compartment, // otherwise the compartment won't work when // a file extension _is_ added later on. extensions.push(languageCompartment.of([])); } // Add interactive widgets. // Includes the Alt+drag functionality for numbers. // Calls `onInteract` when one of those widgets is interacted with. // This can be used to trigger a transition to throttled mode // for hot reloading. // TODO consider leveraging the new `dragEnd` event handler. // and removing the `onInteract` callback, replacing it with // `onInteractStart` and `onInteractEnd`. // That may be tricky for one-off interactions though, like // the boolean checkboxes. The color pickers are also tricky, // as they would also need to be able to handle `onInteractEnd`. // See https://github.com/replit/codemirror-interact/issues/14 extensions.push( widgets({ onInteract, customInteractRules }), ); // TODO fix the bugginess in this one where // the highlight persists after the mouse leaves. // extensions.push(highlightWidgets); extensions.push(rotationIndicator); // extensions.push( // AIAssistCodeMirrorKeyMap({ // shareDBDoc, // fileId, // tabList, // aiAssistEndpoint, // aiAssistOptions, // }), // ); // Add the extension that provides indentation markers. extensions.push( indentationMarkers({ // thickness: 2, colors: { light: '#4d586b', dark: '#4d586b', activeLight: '#8e949f', activeDark: '#8e949f', }, }), ); // Show the minimap // See https://github.com/replit/codemirror-minimap#usage // This extension has poor performance, so it's disabled for now. // extensions.push( // showMinimap.compute(['doc'], () => ({ // create: () => ({ // dom: document.createElement('div'), // }), // // Without this, performance is terrible. // displayText: 'blocks', // })), // ); // VSCode keybindings // See https://github.com/replit/codemirror-vscode-keymap#usage // extensions.push(keymap.of(vscodeKeymap)); extensions.push( keymap.of( vscodeKeymap.map((binding) => { // Here we override the Shift+Enter behavior specifically, // as that can be used to trigger a manual save/Prettier, // and the default behavior from the keymap interferes. if (binding.key === 'Enter') { delete binding.shift; } return binding; }), ), ); // Adds copilot completions DEBUG && console.log( '[getOrCreateEditor] aiCopilotEndpoint: ', aiCopilotEndpoint, ); if (aiCopilotEndpoint) { extensions.push(copilot({ aiCopilotEndpoint })); } const editor = new EditorView({ state: EditorState.create({ doc: text, extensions, }), }); const editorCacheValue: ExtendedEditorCacheValue = { editor, themeCompartment, rainbowBracketsCompartment, updateRainbowBrackets: (enabled) => { editor.dispatch({ effects: rainbowBracketsCompartment.reconfigure( enabled ? rainbowBrackets() : [], ), }); }, }; // Populate the cache. editorCache.set(cacheKey, editorCacheValue); // Initialize rainbow brackets based on the toggle state editorCacheValue.updateRainbowBrackets( rainbowBracketsEnabled, ); return editorCacheValue; };