// // Copyright 2024 DXOS.org // import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'; import { defaultKeymap, history, historyKeymap, indentWithTab, standardKeymap } from '@codemirror/commands'; import { HighlightStyle, bracketMatching, syntaxHighlighting } from '@codemirror/language'; import { searchKeymap } from '@codemirror/search'; import { type ChangeSpec, EditorState, type Extension, type TransactionSpec } from '@codemirror/state'; import { EditorView, type KeyBinding, ViewPlugin, drawSelection, dropCursor, highlightActiveLine, keymap, lineNumbers, placeholder, } from '@codemirror/view'; import { vscodeDarkStyle, vscodeLightStyle } from '@uiw/codemirror-theme-vscode'; import defaultsDeep from 'lodash.defaultsdeep'; import { generateName } from '@dxos/display-name'; import { Doc } from '@dxos/echo-doc'; import { log } from '@dxos/log'; import { type Messenger } from '@dxos/protocols'; import { type ChromaticPalette, type ThemeMode } from '@dxos/ui-types'; import { hexToHue, isTruthy } from '@dxos/util'; import { baseTheme, createFontTheme, editorGutter } from '../../styles/index.ts'; import { automerge } from '../collab/automerge/index.ts'; import { SpaceAwarenessProvider, awareness } from '../collab/awareness/index.ts'; import { focus } from '../state/focus.ts'; import { scrollPastEnd } from '../streaming/scrolling/index.ts'; // // Basic // /** * Enable tabbing into editor (required for focus-group navigation to reach it). */ export const tabbable = EditorView.contentAttributes.of({ tabindex: '0' }); /** * Transaction filter that strips characters matching the given pattern from inserted text. */ export const filterChars = (chars: RegExp) => { return EditorState.transactionFilter.of((transaction) => { if (!transaction.docChanged) { return transaction; } const changes: ChangeSpec[] = []; transaction.changes.iterChanges((fromA, toA, fromB, toB, text) => { const inserted = text.toString(); const filtered = inserted.replace(chars, ''); if (inserted !== filtered) { changes.push({ from: fromB, to: toB, insert: filtered, }); } }); if (changes.length) { return [transaction, { changes, sequential: true } as TransactionSpec]; } return transaction; }); }; /** * https://codemirror.net/docs/extensions * https://github.com/codemirror/basic-setup * https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts * https://github.com/codemirror/theme-one-dark */ export type BasicExtensionsOptions = { allowMultipleSelections?: boolean; bracketMatching?: boolean; closeBrackets?: boolean; dropCursor?: boolean; drawSelection?: boolean; editable?: boolean; focus?: boolean; highlightActiveLine?: boolean; history?: boolean; indentWithTab?: boolean; keymap?: null | 'default' | 'standard'; lineNumbers?: boolean; /** If false then do not set a max-width or side margin on the editor. */ lineWrapping?: boolean; placeholder?: string; /** If true user cannot edit the text, but they can still select and copy it. */ readOnly?: boolean; search?: boolean; /** NOTE: Do not use with stack sections. */ scrollPastEnd?: boolean; standardKeymap?: boolean; tabbable?: boolean; tabSize?: number; }; const defaultBasicOptions: BasicExtensionsOptions = { allowMultipleSelections: true, bracketMatching: true, closeBrackets: true, drawSelection: true, focus: true, history: true, keymap: 'standard', lineWrapping: true, search: false, } as const; const keymaps: { [key: string]: readonly KeyBinding[] } = { // https://codemirror.net/docs/ref/#commands.standardKeymap standard: standardKeymap, // https://codemirror.net/docs/ref/#commands.defaultKeymap default: defaultKeymap, }; /** * Assembles the base editor setup: keymaps, history, bracket matching/closing, drop cursor, * read-only handling, line wrapping, and tab handling. */ export const createBasicExtensions = (propsProp?: BasicExtensionsOptions): Extension => { const props = defaultsDeep({}, propsProp, defaultBasicOptions); return [ // NOTE: Doesn't catch errors in keymap functions. EditorView.exceptionSink.of((err) => { log.catch(err); }), props.allowMultipleSelections && EditorState.allowMultipleSelections.of(true), props.bracketMatching && bracketMatching(), props.closeBrackets && closeBrackets(), props.dropCursor && dropCursor(), props.drawSelection && drawSelection({ cursorBlinkRate: 1_200 }), props.editable !== undefined && EditorView.editable.of(props.editable), props.focus && focus, props.highlightActiveLine && highlightActiveLine(), props.history && history(), props.lineNumbers && [lineNumbers(), editorGutter], props.lineWrapping && EditorView.lineWrapping, props.placeholder && placeholder(props.placeholder), props.readOnly !== undefined && EditorState.readOnly.of(props.readOnly), // `EditorState.readOnly` is advisory — CodeMirror doesn't auto-reject doc-changing // transactions. Some extensions (e.g. `@codemirror/lang-markdown`'s Enter handler that // continues a list) dispatch programmatic edits regardless. Drop user-initiated edits // (`input` / `delete` keymap dispatches plus `undo` / `redo` from the history extension) // but pass programmatic dispatches — streaming `MarkdownStream` and similar consumers // depend on being able to populate the doc themselves. props.readOnly && EditorState.transactionFilter.of((tr) => tr.docChanged && (tr.isUserEvent('input') || tr.isUserEvent('delete') || tr.isUserEvent('undo') || tr.isUserEvent('redo')) ? [] : tr, ), props.scrollPastEnd && scrollPastEnd(), props.tabbable && tabbable, props.tabSize && EditorState.tabSize.of(props.tabSize), // https://codemirror.net/docs/ref/#view.KeyBinding keymap.of( [ ...((props.keymap && keymaps[props.keymap]) ?? []), // NOTE: Tabs are also configured by markdown extension. // https://codemirror.net/docs/ref/#commands.indentWithTab ...(props.indentWithTab ? [indentWithTab] : []), // https://codemirror.net/docs/ref/#autocomplete.closeBracketsKeymap ...(props.closeBrackets ? closeBracketsKeymap : []), // https://codemirror.net/docs/ref/#commands.historyKeymap ...(props.history ? historyKeymap : []), // https://codemirror.net/docs/ref/#search.searchKeymap ...(props.search ? searchKeymap : []), // Disable bindings that conflict with system shortcuts. // TODO(burdon): Catalog global shortcuts. { key: 'Mod-Shift-k', preventDefault: true, run: () => true, }, ].filter(isTruthy), ), ].filter(isTruthy); }; // // Theme // export type ThemeExtensionsOptions = { monospace?: boolean; themeMode?: ThemeMode; scrollbarThin?: boolean; slots?: { editor?: { className?: string; }; scroller?: { className?: string; }; content?: { className?: string; }; }; syntaxHighlighting?: boolean; }; export const grow: ThemeExtensionsOptions['slots'] = { editor: { className: 'dx-fill', }, } as const; export const fullWidth: ThemeExtensionsOptions['slots'] = { editor: { className: 'w-full', }, } as const; export const defaultThemeSlots = grow; export const defaultStyles = { dark: vscodeDarkStyle, light: vscodeLightStyle, }; /** * https://codemirror.net/examples/styling */ export const createThemeExtensions = ({ monospace, scrollbarThin, slots: slotsProp, syntaxHighlighting: syntaxHighlightingProp, themeMode, }: ThemeExtensionsOptions = {}): Extension => { const slots: NonNullable = defaultsDeep({}, slotsProp, defaultThemeSlots); return [ baseTheme, EditorView.darkTheme.of(themeMode === 'dark'), createFontTheme({ monospace }), syntaxHighlightingProp && syntaxHighlighting(HighlightStyle.define(themeMode === 'dark' ? defaultStyles.dark : defaultStyles.light)), slots.editor?.className && EditorView.editorAttributes.of({ class: slots.editor.className }), slots.content?.className && EditorView.contentAttributes.of({ class: slots.content.className }), (slots.scroller?.className || scrollbarThin) && ViewPlugin.fromClass( class { constructor(view: EditorView) { if (slots.scroller?.className) { view.scrollDOM.classList.add(...slots.scroller.className.split(/\s+/)); } if (scrollbarThin) { view.scrollDOM.style.setProperty('--scrollbar-size', '4px'); } } }, ), ].filter(isTruthy); }; // // Data // /** * Minimal identity shape consumed by awareness. Kept structural so both the * client identity and the `@dxos/halo` `Identity.Info` can be passed without * this foundational editor package depending on either. */ export type DataExtensionsIdentity = { /** Hex-encoded identity key, used as the awareness peer id. */ identityKey?: string; displayName?: string; /** Free-form profile metadata (e.g. `hue`). */ data?: Record; }; export type DataExtensionsProps = { id: string; text?: Doc.Accessor; messenger?: Messenger; identity?: DataExtensionsIdentity | null; }; /** * Wires document sync (automerge) and presence (awareness) for a text accessor. */ export const createDataExtensions = ({ id, text, messenger, identity }: DataExtensionsProps): Extension[] => { const extensions: Extension[] = []; if (text) { extensions.push(automerge(text)); } if (messenger && identity?.identityKey) { const peerId = identity.identityKey; const hue = (identity.data?.hue as ChromaticPalette | undefined) ?? hexToHue(peerId); extensions.push( awareness( new SpaceAwarenessProvider({ messenger, channel: `awareness.${id}`, peerId, info: { darkColor: `var(--color-${hue}-border)`, lightColor: `var(--color-${hue}-border)`, displayName: identity.displayName ?? generateName(peerId), }, }), ), ); } return extensions; };