Agentic Coding Experiment: Monaco-textarea-adapter

Tags:

I wanted to add some progressive enhancement to a small web app I wrote. Like many of these small tools, it has a <textarea> for entering some text.

For progressive enhancement, I wanted to switch that textarea to an instance of the Monaco code editor if Javascript is enabled. But surprisingly, Monaco does not have a simple .attach('textarea') API. Thinking that this should be a simple matter of coding, I wrote down my ideas about the API and then set the GLM model by z.ai onto the task, mediated by the Claude program. Here is the prompt / checklist I gave it:

The overall goal is to implement a function in a Javascript module that allows progressive enhancement of
`textarea` elements with the Monaco editor.

* [ ] suggest a name for the module
    * maybe something like `progressive-textarea-monaco`, but maybe there is something more fitting in the Javascript world
    * list three to five alternatives with precedent in the Javascript world
* [ ] main function is `function attach( selector )`
    * if `selector` is not given, `selector` should simply match `textarea`
    * attaches a Monaco instance to all `textarea` elements matched by `selector` by doing the following steps
       * insert empty `div` after the `textarea` element
       * attach Monaco to the new `div`
       * fill Monaco editor with the content of the `textarea` element
       * hide the `textarea` element
       * attach `change` handlers to Monaco that update the `value` property of the `textarea` element so that `form` submission still works as expected
* [ ] manual testing will be enough, but if automated testing without a browser is possible, test it to the extent possible

## Example HTML file

```html
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/monaco-editor@latest/min/vs/loader.js"></script>
</head>
<body>
<textarea>This is some text
in the text area
</textarea>
</body>
</html>
```

It produced some code which I then fixed up.

Here is the resulting code, not yet uploaded to npm (and thus available via unpkg etc.), as I'm not really thinking the code should be published even though it does the task I set out:

import {
  getTextareas,
  isAttached,
  markAttached,
  unmarkAttached,
  createContainer,
  hideTextarea,
  showTextarea,
  debounce,
  defaultErrorHandler
} from './utils.js';

import { ensureMonaco } from './loader.js';

/**
 * @typedef {import('monaco-editor').editor.IStandaloneCodeEditor} MonacoEditor
 */

/**
 * Track all Monaco instances and their associated textareas
 * @type {Map<TextareaElement, { editor: MonacoEditor, container: HTMLDivElement, disposeChangeHandler: Function }>}
 */
const instances = new Map();

/**
 * Sync Monaco content to textarea
 * @param {MonacoEditor} editor
 * @param {TextareaElement} textarea
 */
const syncToTextarea = debounce((editor, textarea) => {
  textarea.value = editor.getValue();
  const event = new Event('change');
  textarea.dispatchEvent(event);
}, 50);

/**
 * Attach Monaco to a single textarea
 * @param {typeof import('monaco-editor')} monaco
 * @param {TextareaElement} textarea
 * @param {Object} options
 * @param {Object} [options.monacoOptions={}]
 * @param {Function} [options.onError]
 * @returns {boolean} - true if successfully attached
 */
function attachToTextarea(monaco, textarea, options = {}) {
  // Skip if already attached
  if (isAttached(textarea)) {
    return false;
  }

  try {
    // Create container and insert after textarea
    const container = createContainer(textarea);
    textarea.parentNode.insertBefore(container, textarea.nextSibling);

    // Initialize Monaco editor
    const editor = monaco.editor.create(container, {
      value: textarea.value,
      ...options.monacoOptions
    });

    // Setup content sync from Monaco to textarea
    const changeHandler = editor.onDidChangeModelContent(() => {
      syncToTextarea(editor, textarea);
    });

    // Store instance for cleanup
    instances.set(textarea, {
      editor,
      container,
      disposeChangeHandler: () => changeHandler.dispose()
    });

    // Hide textarea and mark as attached
    hideTextarea(textarea);
    markAttached(textarea);

    return true;
  } catch (error) {
    if (options.onError) {
      options.onError(error, textarea, { phase: 'attach' });
    } else {
      defaultErrorHandler(error, textarea, { phase: 'attach' });
    }
    return false;
  }
}

/**
 * Detach Monaco from a single textarea
 * @param {TextareaElement} textarea
 * @returns {boolean} - true if successfully detached
 */
function detachFromTextarea(textarea) {
  const instance = instances.get(textarea);
  if (!instance) {
    return false;
  }

  // Sync final content
  textarea.value = instance.editor.getValue();

  // Dispose Monaco
  instance.disposeChangeHandler();
  instance.editor.dispose();

  // Remove container
  instance.container.remove();

  // Show textarea and unmark
  showTextarea(textarea);
  unmarkAttached(textarea);

  // Remove from tracking
  instances.delete(textarea);

  return true;
}

/**
 * Attach Monaco editor to textarea elements
 *
 * @param {string|HTMLElement|HTMLElement[]} [selector='textarea']
 * @param {Object} [options={}]
 * @param {Object} [options.monacoOptions] - Options passed to Monaco editor constructor
 * @param {string} [options.monacoLoaderUrl] - Custom URL for Monaco loader script
 * @param {string} [options.monacoBaseUrl] - Custom base URL for Monaco
 * @param {Function} [options.onError] - Error callback(error, textarea, context)
 * @returns {Promise<Function>} Cleanup function that detaches Monaco from all attached textareas
 *
 * @example
 * // Basic usage
 * const cleanup = await attach();
 *
 * @example
 * // With selector
 * const cleanup = await attach('textarea.code-editor');
 *
 * @example
 * // With options
 * const cleanup = await attach('textarea', {
 *   monacoOptions: {
 *     theme: 'vs-dark',
 *     language: 'javascript'
 *   }
 * });
 *
 * @example
 * // Cleanup
 * cleanup(); // Detaches all textareas from this attach() call
 *
 * @since 1.0.0
 * @version 1.0.0
 */
export async function attach(selector = 'textarea', options = {}) {
  try {
    const monaco = await ensureMonaco(options);
    const textareas = getTextareas(selector);
    return performAttachment(monaco, textareas, options);
  } catch (error) {
    if (options.onError) {
      options.onError(error, null, { phase: 'load' });
    } else {
      console.warn('monaco-textarea-adapter: Failed to load Monaco', error);
    }
    // Return no-op cleanup function
    return () => {};
  }
}

/**
 * Internal: Perform the actual attachment after Monaco is loaded
 * @param {typeof import('monaco-editor')} monaco
 * @param {TextareaElement[]} textareas
 * @param {Object} options
 * @returns {Function} Cleanup function
 */
export function performAttachment(monaco, textareas, options) {
  const attached = [];

  for (const textarea of textareas) {
    if (attachToTextarea(monaco, textarea, options)) {
      attached.push(textarea);
    }
  }

  return function cleanup() {
    for (const textarea of attached) {
      detachFromTextarea(textarea);
    }
  };
}

/**
 * Get the Monaco editor instance for a textarea
 * @param {TextareaElement} textarea
 * @returns {MonacoEditor | undefined}
 */
export function getMonacoInstance(textarea) {
  const instance = instances.get(textarea);
  return instance?.editor;
}

/**
 * Check if a textarea has Monaco attached
 * @param {TextareaElement} textarea
 * @returns {boolean}
 */
export function isMonacoAttached(textarea) {
  return instances.has(textarea);
}

/**
 * Detach Monaco from all textareas
 * @returns {number} Number of textareas detached
 */
export function detachAll() {
  let count = 0;
  for (const textarea of instances.keys()) {
    if (detachFromTextarea(textarea)) {
      count++;
    }
  }
  return count;
}

Needed manual fixes

  • the code is overly convoluted, split up into four separate files
  • the code uses a separate loader.js and rollup as a packaging tool where no such tool would be needed.
  • The code keeps a function for "backwards compatibility", except there is no "backwards" to be compatible with
  • The code implements a debounce function that was never specified or asked for, just to reduce the number of updates. The framework I would be using the code with, HTMX, already does its own debouncing.

Unfixable things

Monaco itself is not really suitable for self-hosting / vendoring. Their own loader.js loading mechanism works, but they have no list of files that are needed and which directory structure the files / URLs should have. I've gone back to a plain <textarea> element as that is simply easier to debug and while Monaco offers many convenient features like syntax highlighting out of the box, I don't want to fight with its (lack of) installation instructions.