Undo (CMD/CTRL+Z) Not Working Properly When Custom Widgets Are Added in CodeMirror Editor

I am encountering an issue in the CodeMirror editor where the undo functionality (CMD/CTRL+Z) does not work as expected after adding custom widgets. Specifically, once a custom widget is added, pressing CMD/CTRL+Z does not undo the insertion or any other previous changes. This makes it difficult to manage the editor state when working with widgets.

Below is an overview of my setup and configuration:

// editorUtils.ts
import { javascript } from '@codemirror/lang-javascript';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { drawSelection, keymap } from '@codemirror/view';
import { tags } from '@lezer/highlight';
import { EditorView } from 'codemirror';
import { spreadsheet } from 'codemirror-lang-spreadsheet';

import {
  indentAndCompletionWithTab,
  preventNewLine,
  tabObservable,
} from './indent-completion-tab';
import { tagExtension } from './placeholders';

export const varaiableRegexAtTheEndOfString = /({{\d+,[^,]+,[^}]+}}|{\w+})$/g;

function deleteCustomWidget(view: EditorView): boolean {
  // Get the doc text
  const docText = view.state.doc.toString();
  // get cursor location from editorView
  const cursor = view.state.selection.main.head;
  // get the text until the cursor location
  const textUntilCursor = docText.slice(0, cursor);
  // check if the end of the text until the cursor location is a variable with the regex
  const variableWidgetAtCursor = textUntilCursor.match(
    varaiableRegexAtTheEndOfString,
  );

  if (!variableWidgetAtCursor) {
    return false; // deleting the last char regularly
  }
  // we matched a variable we need to delete, delete the variable from the text using the match length
  const transaction = view.state.update({
    changes: {
      from: cursor - variableWidgetAtCursor[0].length,
      to: cursor,
      insert: '',
    },
  });
  view.dispatch(transaction);
  return true;
}

/**
 * Generates a new CodeMirror EditorView instance with specified language support and extensions.
 *
 * @param {any} languageCompart - Compartment for language support
 * @param {any} autocompleteCompart - Compartment for autocompletion
 * @param {React.MutableRefObject<HTMLDivElement | null>} editorRef - Reference to the editor DOM element
 * @returns {EditorView} A new EditorView instance
 */
export const generateEditor = (
  languageCompart: any,
  autocompleteCompart: any,
  editorRef: any,
  onChange: (value: string) => void,
  variables: any,
  setHidden: any,
  initialValue: string = '',
) => {
  /**
   * Extensions to be added to the EditorView instance.
   * @type {any[]} Array of CodeMirror extensions
   */
  const extensions = [
    keymap.of([
      indentAndCompletionWithTab as any,
      preventNewLine(setHidden) as any,
      { key: 'Backspace', run: deleteCustomWidget },
    ]), // Allows using tab for indentation and autocompletion
    syntaxHighlighting(
      // Syntax highlighting definitions
      HighlightStyle.define([
        { tag: tags.name, color: 'green' },
        { tag: tags.bool, color: '#A020F0' },
        { tag: tags.color, color: '#0000FF' },
        { tag: tags.invalid, color: '#FA6F66' },
      ]),
    ),
    tabObservable(), // Enables using tab for indentation and autocompletion
    javascript(), // JavaScript language support
    drawSelection(), // Selection drawing
  ];

  /**
   * Configurations for the new EditorView instance.
   * @type {Object} Editor configuration object
   */
  const editorConfig = {
    extensions: [
      ...extensions, // Spread the extensions array
      languageCompart.of(spreadsheet()), // Add the spreadsheet language support
      autocompleteCompart.of([]), // Add the autocompletion compartment
      // eslint-disable-next-line @typescript-eslint/no-use-before-define
      tagExtension(variables, updateEditorContent), // ?INFO: We need to disable this rule because the function is defined below. Function must be defined before it is used. Because we need to use newEditor value in the function.
      EditorView.updateListener.of((update) => {
        if (update.docChanged) {
          const { doc } = update.state;
          const value = doc.toString();
          onChange(value);
        }
      }),
      EditorView.lineWrapping, // Enable line wrapping
    ],
    parent: editorRef.current, // Attach the editor to the provided DOM element
    drawSelection: false, // Enable selection drawing
  };

  /**
   * Create a new EditorView instance.
   * @type {EditorView} A new EditorView instance
   */
  const newEditor: any = new EditorView({
    ...editorConfig, // Spread the editor configuration object,
    doc: initialValue, // Set the initial value of the editor
  });

  /**
   * Updates the content of the EditorView instance with the provided new value.
   *
   * @param {string} newValue - The new value to be set in the EditorView.
   */
  function updateEditorContent(newValue: string) {
    // Update the editor content
    const transaction = newEditor.state.update({
      changes: { from: 0, to: newEditor.state.doc.length, insert: newValue },
    });
    newEditor.dispatch(transaction);
  }

  newEditor.dispatch({
    selection: { anchor: newEditor.state.doc.length },
  });

  newEditor.focus(); // Focus the editor

  return newEditor;
};

Widget Configration:

import type { Range } from '@codemirror/state';
import { StateField } from '@codemirror/state';
import { Decoration, EditorView } from '@codemirror/view';

import config from './config';
// Importing the data array
import functions from './Functions';
// Importing the data array
import { WidgetWithDropdown, WidgetWithoutDropdown } from './widgets';
import { loadSelectedValue } from './widgets/utility';

/**
 * Handles the case where a formula cell contains an unknown variable.
 *
 * If the matched text is not a number, it creates a `Decoration.replace` with a `WidgetWithoutDropdown` widget that displays "Unknown Variable" with a specific color.
 *
 * @param decorations - The array of decorations to be added to the editor.
 * @param match - The matched text in the formula cell.
 * @param start - The start index of the matched text.
 * @param end - The end index of the matched text.
 * @param matchedText - The text that was matched.
 * @param updateEditorContent - A function to update the editor content.
 */
const handleUnkownVariable = (
  decorations: Range<Decoration>[],
  match: any,
  start: any,
  end: any,
  matchedText: any,
  updateEditorContent: any,
) => {
  if (
    matchedText &&
    matchedText.length > 0 &&
    !Number.isNaN(Number(matchedText))
  ) {
    decorations.push(
      Decoration.replace({
        widget: WidgetWithoutDropdown({
          content: 'Unknown Variable',
          color: 'emptyFormula',
          match,
          updateEditorContent,
          value: loadSelectedValue(match),
        }),
      }).range(start, end),
    );
  }
};
/**
 * Sorts an array of Decoration objects based on their start position and side.
 * The sorting is done in the following order:
 * 1. Ascending by the `from` property of the Decoration.
 * 2. Ascending by the `startSide` property of the Decoration.
 * This ensures that the decorations are rendered in the correct order within the editor.
 *
 * @param a - The first Decoration object to compare.
 * @param b - The second Decoration object to compare.
 * @returns -1 if `a` should be before `b`, 1 if `a` should be after `b`, or 0 if they are equal.
 */
const sortDecorations = (a: any, b: any) => {
  if (a.from < b.from) return -1;
  if (a.from > b.from) return 1;
  if (a.startSide < b.startSide) return -1;
  if (a.startSide > b.startSide) return 1;
  return 0;
};
/**
 * Generates decorations for a document based on formula and variable placeholders.
 *
 * This function takes a document, a list of variables, and a function to update the editor content.
 * It then iterates through the document, finding matches for formula and variable placeholders.
 * For each match, it creates a decoration with a custom widget that displays the label of the matching
 * formula or variable. If a match does not correspond to a known formula or variable, it creates
 * a decoration with a "Unknown Variable" widget.
 *
 * The decorations are sorted and returned as a Decoration set.
 *
 * @param doc - The document to generate decorations for.
 * @param variables - The list of variables to match against.
 * @param updateEditorContent - A function to update the editor content.
 * @returns A Decoration set with the generated decorations.
 */
function tagDecoration(doc: any, variables: any, updateEditorContent: any) {
  const decorations: Range<Decoration>[] = [];
  const range = doc.toString();
  const { formulaRegex, variableRegex } = config;
  const regexes = [variableRegex, formulaRegex];
  regexes.forEach((regex) => {
    for (const match of range.matchAll(regex)) {
      const { index: start, 0: matchText, 1: matchedText } = match;
      const end = start + matchText.length; // End index of the match
      const options = [...variables, ...functions]; // Combine variables and formulas
      const matchingData = options.find(
        (item) => item.label === matchedText || `${item.id}` === matchedText, // Match by label or id
      );
      if (matchingData) {
        // If a match is found
        const { type } = matchingData;
        const isCalculated = type === 'calculations'; // Check if the match is a calculation
        const WidgetComponent = isCalculated // Check if the match is a calculation
          ? WidgetWithDropdown
          : WidgetWithoutDropdown;
        const color = isCalculated // Check if the match is a calculation
          ? 'variable'
          : type === 'inputs'
            ? 'function'
            : 'formula';
        decorations.push(
          // Add the decoration to the array
          Decoration.replace({
            widget: WidgetComponent({
              // Create a widget with the matching data
              content: matchingData.label, // Display the label of the matching data
              color, // Set the color of the widget
              match, // Pass the match object
              updateEditorContent, // Pass the updateEditorContent function
              value: loadSelectedValue(match), // Load the selected value
            }),
          }).range(start, end),
        );
      } else {
        handleUnkownVariable(
          // Handle unknown variables
          decorations,
          match,
          start,
          end,
          matchedText,
          updateEditorContent,
        );
      }
    }
  });
  decorations.sort(sortDecorations); // Sort the decorations
  return Decoration.set(decorations.filter(Boolean)); // Return the decorations
}

export default tagDecoration;

/**
 * State field responsible for managing and updating tag decorations within the editor.
 *
 * @type {StateField}
 * @memberof module:Decorations
 */
export const tagExtension = (variables: any, updateEditorContent: any) => {
  return StateField.define({
    create(view: any) {
      return tagDecoration(view.doc, variables, updateEditorContent);
    },

    update(decorations: any, tr: any) {
      if (tr.docChanged) {
        return tagDecoration(tr.state.doc, variables, updateEditorContent);
      }
      return decorations;
    },

    provide: (field: any) => EditorView.decorations.from(field),
  });
};

You don’t appear to be including the default key bindings. You’ll definitely want those—both to get CodeMirror’s own undo history (instead of the broken browser one that you’re seeing), and to make sure things like backspace and enter actually work reliably.

Thanks for response. How can i add those?

Include minimalSetup or keymap.of(defaultKeymap) in your configuration.

Thanks for solution. it seems work. :slight_smile: