// ZPWR-MODAL-EDITOR — SHARED VIM / EMACS MODAL EDITOR

shared frontend submodule · monaco-vim + monaco-emacs engines vendored & owned · surface-adapter driven · esbuild IIFE bundle · build-on-each-consumer · one window.ZModal facade

Report GitHub
// Color scheme

>_ZPWR-MODAL-EDITOR

One modal engine, owned in source, droppable anywhere. The shared Vim / Emacs modal-editing editor for the MenkeTechnologies app stack. The Vim engine and Emacs engine are vendored from monaco-vim and monaco-emacs and adapted so we own them outright — no external runtime deps. The source lives here in src/ and each app builds it with its own monaco-editor dependency.

The adapter seam — why it drops in anywhere

The Vim engine talks only to a surface adapter that implements the CodeMirror API it expects. Swap the adapter and the same engine drives a different surface. Both adapters ship — the bundler emits one bundle per surface, and the DOM build aliases the engine's monaco_adapter import to dom_adapter so the vendored engine is reused byte-for-byte with no Monaco in the bundle.

engine/vim/keymap_vim.tsthe vim engine (vendored), ~7.1k lines; imports its "CodeMirror" from an adapter
adapters/cm_core.tsthe surface-agnostic half of the shim — text / key / event helpers, importing no Monaco. Both adapters build on it
adapters/monaco_adapter.tsimplements the CodeMirror API over a Monaco editor
adapters/dom_adapter.tsthe same API over a plain contenteditable element, for in-place editing with no Monaco — a WYSIWYG word-processor page, a slide text box, a spreadsheet cell
engine/emacs/*the emacs extension (vendored); coupled to the Monaco editor API, rides the Monaco adapter only

Vim on a rich-text page

The DOM adapter models the host's block-level descendants as "lines": inline nodes (span / a / img) are transparent text, a <br> is a hard break, and a column is a character offset into a line's concatenated text. Intra-line edits go through execCommand, so surrounding inline formatting survives and the browser's native undo still works. Multi-line / structural edits rebuild the affected blocks as plain paragraphs — a limitation of driving rich text with a line-based engine, with one deliberate exception:

Linewise yank/putreading a range (yy, dd) caches each fully-covered line's inline HTML keyed by its plain text; rebuilding a line whose text matches restores it from that HTML. So yy+p and dd+p keep font / colour / size instead of dropping to plain text
Boundsa FIFO of 256 lines; yanks spanning more than 200 lines skip the capture. The vendored vim core is untouched — this lives entirely in the adapter

What it is

Vim enginethe full CodeMirror vim keymap (motions, operators, registers, marks, ex commands, search) vendored via monaco-vim, bolted onto whichever adapter class it imports
Surfacestwo bundles from the one source — modal-editor.bundle.js (Monaco, Vim + Emacs) and modal-editor-dom.bundle.js (a contenteditable page, Vim only, no Monaco anywhere in the bundle)
Completion & snippetsthe DOM build adds an insert-mode completion popup (Tab to accept) fed by a per-field source and a localStorage-backed snippet store, including snippets whose body is a stryke script run at expansion
Emacs enginemonaco-emacs' extension — keybindings, kill-ring, mark, incremental search — over the Monaco editor API
Ownershipvendored in src/engine/ with upstream MIT licenses retained; the two lodash helpers monaco-emacs used are replaced by engine/emacs/localutil.ts so there are no external runtime deps
Status barvim mode / key-buffer / ex command line render into a caller-supplied element (src/statusbar.ts)
Themezmodal-cyberpunk — neon on near-black, matching the apps' aesthetic

⚠ One Monaco per page

The Monaco build bundles its own Monaco. If the host page also loads another Monaco bundle (e.g. zpwr-hooks-editor), a WebKit/WKWebView content process can crash on the second full Monaco — a blank window (Chromium tolerates two; WebKit does not). Three clean integrations:

Use the DOM buildmodal-editor-dom.bundle.js contains no Monaco at all and drives the app's existing contenteditable pages directly — the option word / ppt / spreadsheet surfaces take
Share one Monacothe app creates the editor and calls attachVim(editor) / attachEmacs(editor) — these need no bundled editor of their own (a monaco-editor-external variant of the Monaco bundle is still the tidy way to ship this)
Be the only Monacouse create(...) (which bundles Monaco) only in apps that don't already load one

Load order (consuming app)

The consumer loads one of the two bundles — each defines the same window.ZModal global. For the Monaco build, the CSS link plus the JS bundle; the Monaco base worker is fetched at runtime via MonacoEnvironment.getWorker.

<link rel='stylesheet' href='lib/modal-editor.bundle.css'>
<script src='lib/modal-editor.bundle.js'></script>
<!-- lib/modal-editor.worker.js is fetched at runtime, not script-tagged -->

For the DOM build, one script tag and nothing else — no stylesheet (the block caret, the completion popup and the snippet manager each inject their own <style> on first use), no worker, no Monaco.

<script src='lib/modal-editor-dom.bundle.js'></script>

Mounting an editor

ZModal.create(host, opts) creates a Monaco editor into host, applies the modal mode and returns a handle. setMode swaps the active modal layer live.

const handle = window.ZModal.create(hostEl, {
  doc: 'hello\n',          // initial text
  mode: 'vim',             // 'default' | 'vim' | 'emacs'
  statusBar: vimStatusEl,  // optional element for the vim status/command line
  language: 'plaintext',
  onChange: (text) => save(text),
});

handle.getValue();        // current text
handle.setValue(text);    // replace text
handle.focus();
handle.setMode('emacs');  // switch modal mode at runtime
handle.layout();          // re-layout after the host resizes / becomes visible
handle.destroy();         // dispose the modal layer, editor and model

Attaching to an existing editor

When the host already has a Monaco editor (so a second Monaco must not be bundled), attach a mode directly. These are the Monaco-agnostic primitives create is built on.

const vim = window.ZModal.attachVim(existingMonacoEditor, vimStatusEl);
vim.dispose();   // detach vim

const emacs = window.ZModal.attachEmacs(existingMonacoEditor);
emacs.dispose(); // detach emacs

window.ZModal facade — Monaco build

create(host, opts)create a Monaco-backed editor + apply a mode; returns a handle (getValue / setValue / focus / setMode / layout / destroy)
attachVim(editor, statusEl?)attach vim to an existing Monaco editor; returns the vim adapter
attachEmacs(editor)attach emacs to an existing Monaco editor; returns the started extension
VimMode / EmacsExtension / StatusBarthe underlying classes, re-exported for advanced use

window.ZModal facade — DOM build

The Monaco-free bundle exposes the same global with a vim-only surface, mounted on an element the app already renders. setMode('default') detaches the engine and hands typing straight back to the browser; setMode('vim') re-engages it live.

const handle = window.ZModal.attach(pageEl, {
  mode: 'vim',                // 'default' | 'vim'  (default: vim)
  statusBar: vimStatusEl,     // optional element for the mode / key-buffer / ex line
  onChange: (text) => save(text),
  readOnly: false,            // true sets contenteditable="false"
  completion: { source: (prefix, fullText) => candidatesFor(prefix) },
});

handle.getValue();   // full text of the surface
handle.focus();
handle.setMode('default');
handle.isVim();      // is the engine currently engaged?
handle.adapter;      // the live vim adapter, or null
handle.destroy();
attach(host, opts)attach modal editing to a contenteditable element; returns the handle above
attachVim(host, statusEl?)the lower-level primitive — returns the vim adapter (.dispose() detaches)
snippetsthe snippet store: list / add / remove / expand / match / openManager / setEvaluator / runStryke
VimMode / StatusBarthe underlying classes, re-exported for advanced use

Insert-mode completion

While typing in INSERT mode the DOM editor takes the word before the caret, queries a per-field source, and shows a popup. The source comes from attach({ completion }) or, per element, from host.zmodalCompletion — read at query time, so a generic mount can attach fields whose candidate lists it does not know. With no source set the popup never opens and every key, Tab included, reaches the Vim engine unchanged.

Tab / Enteraccept the selected candidate
Ctrl-N / next candidate
Ctrl-P / previous candidate
Escdismiss

CompletionConfig also takes separators (characters that terminate the token being completed), minChars (default 1) and maxItems (default 8). A candidate is either a plain string or { label, insert?, detail? }.

Snippets

Snippets are { trigger, body } pairs persisted in localStorage (per app WebView). Type a trigger, the completion popup offers it, Tab inserts the body. Bodies may carry dynamic tokens resolved at insertion time: $DATE, $TIME, $DATETIME, $DATE_ISO, $YEAR. The editor ships its own manager UI, so the feature needs nothing from the host.

A snippet flagged stryke treats its body as a stryke script instead of literal text: on expansion the body is run and its output is inserted. The host registers the runtime once; with no evaluator registered a stryke snippet inserts the empty string.

window.ZModal.snippets.setEvaluator((code) => invoke('run_stryke_hook', { code }));

window.ZModal.snippets.add('sig', '— sent $DATE');         // plain body
window.ZModal.snippets.add('uuid', 'print uuid();', true);  // stryke body
window.ZModal.snippets.list();          // Snippet[]
window.ZModal.snippets.match('si');     // completion candidates for a prefix
window.ZModal.snippets.expand(body);    // resolve dynamic tokens only
window.ZModal.snippets.runStryke(code); // run a body through the evaluator
window.ZModal.snippets.remove('sig');
window.ZModal.snippets.openManager();   // built-in list / add / update / remove UI

Build (build-on-each-consumer)

The editor source lives here in src/, but the bundle is built inside the consuming app so esbuild resolves that app's monaco-editor dep and writes into its frontend/lib/. Each consumer keeps esbuild + the pinned monaco-editor in devDependencies (the vim/emacs engines are vendored here, so no monaco-vim / monaco-emacs deps are needed) and invokes the bundler from its project root:

node <path-to-submodule>/scripts/build-modal-editor.mjs

The output dir defaults to <cwd>/frontend/lib; override with the MODAL_EDITOR_OUT env var. Three artifacts are produced, all build outputs (gitignore them in the consumer):

modal-editor.bundle.{js,css}the Monaco editor + Vim/Emacs; bundles its own Monaco
modal-editor.worker.jsMonaco's base web worker, fetched at runtime
modal-editor-dom.bundle.jsthe Monaco-free contenteditable build (Vim only)

Set MODAL_EDITOR_DOM_ONLY=1 to emit only the DOM bundle — that build imports no Monaco, so the consumer needs no monaco-editor dependency at all. Otherwise the bundler checks for node_modules/monaco-editor up front and fails with the resolved path if it is absent. The bundler emits IIFE, minified, ES2020, with Monaco's codicon .ttf inlined as a data URL so the single .css artifact is self-contained.

Source layout

src/index.tsthe Monaco IIFE entry — theme + worker wiring, the create / attachVim / attachEmacs facade on window.ZModal
src/index-dom.tsthe Monaco-free IIFE entry — the same window.ZModal global with attach / attachVim / snippets
src/engine/vim/keymap_vim.tsthe vim engine (vendored from monaco-vim); imports its "CodeMirror" from the surface adapter
src/adapters/cm_core.tsthe surface-agnostic half of the CodeMirror shim; imports no Monaco
src/adapters/monaco_adapter.tsthe CodeMirror-API adapter over a Monaco editor
src/adapters/dom_adapter.tsthe same API over a contenteditable element, plus the blinking block caret
src/completion.tsthe insert-mode completion popup for the DOM editor
src/snippets.tsthe snippet store, dynamic-token expansion, stryke bodies and the manager UI
src/engine/emacs/*the emacs extension (vendored from monaco-emacs) + localutil.ts (lodash-free throttle / kebab-case)
src/statusbar.tsthe vim mode / key-buffer / ex command-line status bar
src/worker-entry.tsMonaco base editor web-worker entry
scripts/build-modal-editor.mjsesbuild bundler — reads src/ from this repo, writes the bundle into the consuming app

The app stack

zpwr-modal-editor is a shared editor component across the MenkeTechnologies apps. Browse the rest via the MenkeTechnologiesMeta umbrella repo:

  • zpwr-hooks-editor — the shared Monaco code editor (stryke-LSP); the sibling this repo mirrors
  • zgui-core — the shared UI widget toolkit
  • zoffice — the desktop office suite (a consumer)