UNPKG

@atlaskit/editor-plugin-code-block-advanced

Version:

CodeBlockAdvanced plugin for @atlaskit/editor-core

576 lines (523 loc) 18.8 kB
import { closeBrackets } from '@codemirror/autocomplete'; import { syntaxHighlighting, bracketMatching } from '@codemirror/language'; import { Compartment, Facet, EditorState as CodeMirrorState } from '@codemirror/state'; import type { Extension, StateEffect } from '@codemirror/state'; import { EditorView as CodeMirror, lineNumbers, gutters } from '@codemirror/view'; import type { ViewUpdate } from '@codemirror/view'; import type { IntlShape } from 'react-intl'; import { getBrowserInfo } from '@atlaskit/editor-common/browser'; import { areCodeBlockLineNumbersHidden, isCodeBlockWordWrapEnabled, } from '@atlaskit/editor-common/code-block'; import { messages as floatingToolbarMessages } from '@atlaskit/editor-common/floating-toolbar'; import { blockTypeMessages, codeBlockMessages, roleDescriptionMessages, } from '@atlaskit/editor-common/messages'; import type { RelativeSelectionPos } from '@atlaskit/editor-common/selection'; import type { getPosHandler, getPosHandlerNode, ExtractInjectionAPI, EditorContentMode, } from '@atlaskit/editor-common/types'; import { ZERO_WIDTH_SPACE } from '@atlaskit/editor-common/whitespace'; import type { EditorSelectionAPI } from '@atlaskit/editor-plugin-selection'; import type { Node as PMNode } from '@atlaskit/editor-prosemirror/model'; import { NodeSelection } from '@atlaskit/editor-prosemirror/state'; import type { Decoration, DecorationSource, EditorView, NodeView, } from '@atlaskit/editor-prosemirror/view'; import { DecorationSet } from '@atlaskit/editor-prosemirror/view'; import { fg } from '@atlaskit/platform-feature-flags'; import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals'; import { expValEqualsNoExposure } from '@atlaskit/tmp-editor-statsig/exp-val-equals-no-exposure'; import type { CodeBlockAdvancedPlugin } from '../codeBlockAdvancedPluginType'; import { highlightStyle } from '../ui/syntaxHighlightingTheme'; import { cmTheme, codeFoldingTheme } from '../ui/theme'; import { syncCMWithPM } from './codemirrorSync/syncCMWithPM'; import { getCMSelectionChanges } from './codemirrorSync/updateCMSelection'; import { firstCodeBlockInDocument } from './extensions/firstCodeBlockInDocument'; import { foldGutterExtension, getCodeBlockFoldStateEffects } from './extensions/foldGutter'; import { keymapExtension } from './extensions/keymap'; import { lineSeparatorExtension } from './extensions/lineSeparator'; import { manageSelectionMarker } from './extensions/manageSelectionMarker'; import { prosemirrorDecorationPlugin } from './extensions/prosemirrorDecorations'; import { tripleClickSelectAllExtension } from './extensions/tripleClickExtension'; import getLanguageName from './languages/getLanguageName'; import { LanguageLoader } from './languages/loader'; // Store last observed heights of code blocks const codeBlockHeights = new WeakMap<HTMLElement, number>(); export interface ConfigProps { allowCodeFolding: boolean; api: ExtractInjectionAPI<CodeBlockAdvancedPlugin> | undefined; extensions: Extension[]; getIntl: () => IntlShape; } // Based on: https://prosemirror.net/examples/codemirror/ class CodeBlockAdvancedNodeView implements NodeView { dom: Node; private updating: boolean; private view: EditorView; private lineWrappingCompartment = new Compartment(); private lineNumbersCompartment = new Compartment(); private languageCompartment = new Compartment(); private readOnlyCompartment = new Compartment(); private pmDecorationsCompartment = new Compartment(); private themeCompartment = new Compartment(); private node: PMNode; private getPos: getPosHandlerNode; private cm: CodeMirror; private contentMode: EditorContentMode | undefined; private selectionAPI: EditorSelectionAPI | undefined; private maybeTryingToReachNodeSelection = false; private cleanupDisabledState: (() => void) | undefined; private languageLoader: LanguageLoader; private pmFacet = Facet.define<DecorationSource>(); private ro?: ResizeObserver; private unsubscribeContentFormat: (() => void) | undefined; private invisibleAriaDescription?: HTMLSpanElement; private config: ConfigProps; constructor( node: PMNode, view: EditorView, getPos: getPosHandlerNode, innerDecorations: DecorationSource, config: ConfigProps, ) { this.config = config; this.node = node; this.view = view; this.getPos = getPos; const contentFormatSharedState = expValEquals( 'confluence_compact_text_format', 'isEnabled', true, ) ? config.api?.contentFormat?.sharedState : undefined; this.contentMode = expValEquals('confluence_compact_text_format', 'isEnabled', true) ? contentFormatSharedState?.currentState?.()?.contentMode : undefined; this.selectionAPI = config.api?.selection?.actions; const getNode = () => this.node; const onMaybeNodeSelection = () => (this.maybeTryingToReachNodeSelection = true); this.cleanupDisabledState = config.api?.editorDisabled?.sharedState.onChange(() => { this.updateReadonlyState(); }); this.languageLoader = new LanguageLoader((lang) => { this.updating = true; this.cm.dispatch({ effects: this.languageCompartment.reconfigure(lang), }); this.updating = false; }); const { formatMessage } = config.getIntl(); const formattedAriaLabel = formatMessage(blockTypeMessages.codeblock); const isMacOS = getBrowserInfo().mac; this.cm = new CodeMirror({ doc: this.node.textContent, extensions: [ ...config.extensions, this.lineWrappingCompartment.of( isCodeBlockWordWrapEnabled(node) ? CodeMirror.lineWrapping : [], ), this.languageCompartment.of([]), this.pmDecorationsCompartment.of(this.pmFacet.compute([], () => innerDecorations)), keymapExtension({ view, getPos, getNode, selectCodeBlockNode: this.selectCodeBlockNode.bind(this), onMaybeNodeSelection, customFindReplace: Boolean(config.api?.findReplace), }), // Goes before cmTheme to override styles config.allowCodeFolding ? [codeFoldingTheme] : [], this.themeCompartment.of( cmTheme({ contentMode: expValEquals('confluence_compact_text_format', 'isEnabled', true) ? this.contentMode : undefined, }), ), syntaxHighlighting(highlightStyle), bracketMatching(), expValEquals('platform_editor_code_block_q4_lovability', 'isEnabled', true) ? this.lineNumbersCompartment.of(this.getLineNumberVisibilityExtensions(node)) : this.getLineNumberExtensions(), // Explicitly disable "sticky" positioning on all gutters to match // Renderer behaviour. gutters({ fixed: false }), CodeMirror.updateListener.of((update) => this.forwardUpdate(update)), this.readOnlyCompartment.of([ CodeMirrorState.readOnly.of(!this.view.editable), CodeMirror.contentAttributes.of({ contentEditable: `${this.view.editable}` }), ]), closeBrackets(), CodeMirror.editorAttributes.of({ class: 'code-block', ...(fg('platform_editor_adf_with_localid') && { 'data-local-id': this.node.attrs.localId, }), }), manageSelectionMarker(config.api), prosemirrorDecorationPlugin(this.pmFacet, view, getPos), tripleClickSelectAllExtension(), firstCodeBlockInDocument(getPos), CodeMirror.contentAttributes.of({ ...(!expValEquals('editor_a11y_role_textbox', 'isEnabled', true) && { 'aria-label': `${formattedAriaLabel}`, }), ...(isMacOS && expValEquals('editor_a11y_role_textbox', 'isEnabled', true) && { role: 'textbox', 'aria-roledescription': formatMessage(roleDescriptionMessages.codeSnippetTextBox), 'aria-describedby': `codesnippet-${this.node.attrs.localId}`, 'aria-multiline': 'true', 'aria-label': formattedAriaLabel, }), ...(!isMacOS && expValEquals('editor_a11y_role_textbox', 'isEnabled', true) && { 'aria-label': formattedAriaLabel, 'aria-describedby': `codesnippet-${this.node.attrs.localId}`, }), }), config.allowCodeFolding ? [ foldGutterExtension({ selectNode: this.selectCodeBlockNodeAndFocus, getNode: () => this.node, }), ] : [], // With platform_editor_fix_advanced_codeblocks_crlf_patch the lineSeparatorExtension is not needed expValEquals('platform_editor_fix_advanced_codeblocks_crlf_patch', 'isEnabled', true) ? [] : [lineSeparatorExtension()], ], }); if (contentFormatSharedState) { this.unsubscribeContentFormat = contentFormatSharedState.onChange( ({ nextSharedState, prevSharedState }) => { const prevMode = prevSharedState?.contentMode; const nextMode = nextSharedState?.contentMode; if (nextMode === prevMode) { return; } this.applyContentModeTheme(nextMode); if (this.updating || this.cm.hasFocus) { return; } this.cm.requestMeasure(); }, ); } // Observe size changes of the CodeMirror DOM and request a measurement pass if ( !expValEquals('confluence_compact_text_format', 'isEnabled', true) && expValEquals('cc_editor_ai_content_mode', 'variant', 'test') && fg('platform_editor_content_mode_button_mvp') ) { this.ro = new ResizeObserver((entries) => { // Skip measurements when: // 1. Currently updating (prevents feedback loops) // 2. CodeMirror has focus (user is actively typing/editing) if (this.updating || this.cm.hasFocus) { return; } // Only trigger on height changes, not width or other dimension changes for (const entry of entries) { const currentHeight = entry.contentRect.height; const lastHeight = codeBlockHeights.get(this.cm.contentDOM); if (lastHeight !== undefined && lastHeight === currentHeight) { return; } codeBlockHeights.set(this.cm.contentDOM, currentHeight); } // CodeMirror to re-measure when its content size changes this.cm.requestMeasure(); }); this.ro.observe(this.cm.contentDOM); } // We append an additional element that fixes a selection bug on chrome if the code block // is the first element followed by subsequent code blocks const spaceContainer = document.createElement('span'); spaceContainer.innerText = ZERO_WIDTH_SPACE; spaceContainer.style.height = '0'; // The editor's outer node is our DOM representation this.dom = this.cm.dom; this.dom.appendChild(spaceContainer); if ( expValEquals('editor_a11y_role_textbox', 'isEnabled', true) && fg('platform_editor_adf_with_localid') ) { this.invisibleAriaDescription = document.createElement('span'); this.invisibleAriaDescription.hidden = true; this.invisibleAriaDescription.id = `codesnippet-${this.node.attrs.localId}`; this.updateAriaDescription(); this.dom.appendChild(this.invisibleAriaDescription); } // This flag is used to avoid an update loop between the outer and // inner editor this.updating = false; this.updateLanguage(); this.updateLocalIdAttribute(); this.wordWrappingEnabled = isCodeBlockWordWrapEnabled(node); this.lineNumbersHidden = areCodeBlockLineNumbersHidden(node); // Restore fold state after initialization if (config.allowCodeFolding) { this.restoreFoldState(); } } destroy(): void { // ED-27428: CodeMirror gets into an infinite loop as it detects mutations on removed // decorations. When we change the breakout we destroy the node and cleanup these decorations from // codemirror this.clearProseMirrorDecorations(); this.cleanupDisabledState?.(); if (expValEquals('confluence_compact_text_format', 'isEnabled', true)) { this.unsubscribeContentFormat?.(); } if ( !expValEquals('confluence_compact_text_format', 'isEnabled', true) && expValEquals('cc_editor_ai_content_mode', 'variant', 'test') && fg('platform_editor_content_mode_button_mvp') ) { this.ro?.disconnect(); } } forwardUpdate(update: ViewUpdate): void { if (this.updating || !this.cm.hasFocus) { return; } const offset = (this.getPos?.() ?? 0) + 1; syncCMWithPM({ view: this.view, update, offset, }); } setSelection(anchor: number, head: number): void { if (!this.maybeTryingToReachNodeSelection) { this.cm.focus(); } this.updating = true; this.cm.dispatch({ selection: { anchor, head } }); this.updating = false; } private updateReadonlyState() { this.updating = true; this.cm.dispatch({ effects: this.readOnlyCompartment.reconfigure([ CodeMirrorState.readOnly.of(!this.view.editable), CodeMirror.contentAttributes.of({ contentEditable: `${this.view.editable}` }), ]), }); this.updating = false; } private updateLanguage() { this.languageLoader.updateLanguage(this.node.attrs.language); if ( expValEquals('editor_a11y_role_textbox', 'isEnabled', true) && fg('platform_editor_adf_with_localid') ) { this.updateAriaDescription(); } } private updateAriaDescription() { if (!this.invisibleAriaDescription) { return; } const { formatMessage } = this.config.getIntl(); const languageName = getLanguageName(this.node.attrs.language); if (languageName) { this.invisibleAriaDescription.textContent = `${formatMessage( codeBlockMessages.codeblockLanguageAriaDescription, { language: languageName, }, )} ${formatMessage(floatingToolbarMessages.floatingToolbarAnnouncer)}`; } else { // If the lanuage is undefined provide a more human readable message this.invisibleAriaDescription.textContent = `${formatMessage( codeBlockMessages.codeBlockLanguageNotSet, )} ${formatMessage(floatingToolbarMessages.floatingToolbarAnnouncer)}`; } } private updateLocalIdAttribute() { if (fg('platform_editor_adf_with_localid')) { const localId = this.node.attrs.localId; if (localId) { this.cm.dom.setAttribute('data-local-id', localId); } else { this.cm.dom.removeAttribute('data-local-id'); } } } private selectCodeBlockNode(relativeSelectionPos: RelativeSelectionPos | undefined) { const tr = this.selectionAPI?.selectNearNode({ selectionRelativeToNode: relativeSelectionPos, selection: NodeSelection.create(this.view.state.doc, this.getPos?.() ?? 0), })(this.view.state); if (tr) { this.view.dispatch(tr); } } private wordWrappingEnabled = false; private lineNumbersHidden = false; private selectCodeBlockNodeAndFocus = () => { this.selectCodeBlockNode(undefined); this.view.focus(); }; private getLineNumberExtensions(): Extension[] { return [ lineNumbers({ domEventHandlers: { click: () => { this.selectCodeBlockNodeAndFocus(); return true; }, }, }), ]; } private getLineNumberVisibilityExtensions(node: PMNode): Extension[] { if (areCodeBlockLineNumbersHidden(node)) { return []; } return this.getLineNumberExtensions(); } private getLineNumbersEffects(node: PMNode) { const lineNumbersHidden = areCodeBlockLineNumbersHidden(node); if (this.lineNumbersHidden !== lineNumbersHidden) { this.lineNumbersHidden = lineNumbersHidden; return this.lineNumbersCompartment.reconfigure( this.getLineNumberVisibilityExtensions(node), ); } return undefined; } private getWordWrapEffects(node: PMNode) { if (this.wordWrappingEnabled !== isCodeBlockWordWrapEnabled(node)) { this.wordWrappingEnabled = !this.wordWrappingEnabled; return this.lineWrappingCompartment.reconfigure( isCodeBlockWordWrapEnabled(node) ? CodeMirror.lineWrapping : [], ); } return undefined; } private restoreFoldState() { this.updating = true; const effects = getCodeBlockFoldStateEffects({ node: this.node, cm: this.cm }); if (effects) { this.cm.dispatch({ effects }); } this.updating = false; } private applyContentModeTheme(contentMode: EditorContentMode | undefined) { if (contentMode === this.contentMode) { return; } this.contentMode = contentMode; this.updating = true; this.cm.dispatch({ effects: this.themeCompartment.reconfigure(cmTheme({ contentMode })), }); this.updating = false; } update(node: PMNode, _: readonly Decoration[], innerDecorations: DecorationSource): boolean { this.maybeTryingToReachNodeSelection = false; if (node.type !== this.node.type) { return false; } this.node = node; if (this.updating) { return true; } this.updateLanguage(); this.updateLocalIdAttribute(); const newText = node.textContent, curText = this.cm.state.doc.toString(); // Updates bundled for performance (to avoid multiple-dispatches) const changes = getCMSelectionChanges(curText, newText); const wordWrapEffect = this.getWordWrapEffects(node); const lineNumbersEffect = expValEquals( 'platform_editor_code_block_q4_lovability', 'isEnabled', true, ) ? this.getLineNumbersEffects(node) : undefined; const prosemirrorDecorationsEffect = this.getProseMirrorDecorationEffects(innerDecorations); if (changes || wordWrapEffect || lineNumbersEffect || prosemirrorDecorationsEffect) { this.updating = true; this.cm.dispatch({ effects: [wordWrapEffect, lineNumbersEffect, prosemirrorDecorationsEffect].filter( (effect): effect is StateEffect<unknown> => !!effect, ), changes, }); this.updating = false; } return true; } /** * Updates a facet which stores information on the prosemirror decorations * * This then gets translated to codemirror decorations in `prosemirrorDecorationPlugin` * @param decorationSource * @example */ private getProseMirrorDecorationEffects(decorationSource: DecorationSource) { const computedFacet = this.pmFacet.compute([], () => decorationSource); return this.pmDecorationsCompartment.reconfigure(computedFacet); } private clearProseMirrorDecorations() { this.updating = true; const computedFacet = this.pmFacet.compute([], () => DecorationSet.empty); this.cm.dispatch({ effects: this.pmDecorationsCompartment.reconfigure(computedFacet), }); this.updating = false; } stopEvent(e: Event): boolean { // If we have selected the node we should not stop these events if ( (e instanceof KeyboardEvent || e instanceof ClipboardEvent) && this.view.state.selection instanceof NodeSelection && this.view.state.selection.from === this.getPos?.() ) { return false; } if ( e instanceof DragEvent && e.type === 'dragenter' && expValEqualsNoExposure('platform_editor_block_controls_perf_optimization', 'isEnabled', true) ) { return false; // Allow dragenter to propagate so that the editor can handle it } return true; } } export const getCodeBlockAdvancedNodeView = (props: ConfigProps) => ( node: PMNode, view: EditorView, getPos: getPosHandler, innerDecorations: DecorationSource, ): CodeBlockAdvancedNodeView => { return new CodeBlockAdvancedNodeView( node, view, getPos as getPosHandlerNode, innerDecorations, props, ); };