>_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.ts | the vim engine (vendored), ~7.1k lines; imports its "CodeMirror" from an adapter |
| adapters/cm_core.ts | the surface-agnostic half of the shim — text / key / event helpers, importing no Monaco. Both adapters build on it |
| adapters/monaco_adapter.ts | implements the CodeMirror API over a Monaco editor |
| adapters/dom_adapter.ts | the 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/put | reading 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 |
| Bounds | a 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 engine | the full CodeMirror vim keymap (motions, operators, registers, marks, ex commands, search) vendored via monaco-vim, bolted onto whichever adapter class it imports |
| Surfaces | two 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 & snippets | the 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 engine | monaco-emacs' extension — keybindings, kill-ring, mark, incremental search — over the Monaco editor API |
| Ownership | vendored 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 bar | vim mode / key-buffer / ex command line render into a caller-supplied element (src/statusbar.ts) |
| Theme | zmodal-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 build | modal-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 Monaco | the 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 Monaco | use 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 / StatusBar | the 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) |
snippets | the snippet store: list / add / remove / expand / match / openManager / setEvaluator / runStryke |
VimMode / StatusBar | the 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 / Enter | accept the selected candidate |
| Ctrl-N / ↓ | next candidate |
| Ctrl-P / ↑ | previous candidate |
| Esc | dismiss |
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.js | Monaco's base web worker, fetched at runtime |
modal-editor-dom.bundle.js | the 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.ts | the Monaco IIFE entry — theme + worker wiring, the create / attachVim / attachEmacs facade on window.ZModal |
src/index-dom.ts | the Monaco-free IIFE entry — the same window.ZModal global with attach / attachVim / snippets |
src/engine/vim/keymap_vim.ts | the vim engine (vendored from monaco-vim); imports its "CodeMirror" from the surface adapter |
src/adapters/cm_core.ts | the surface-agnostic half of the CodeMirror shim; imports no Monaco |
src/adapters/monaco_adapter.ts | the CodeMirror-API adapter over a Monaco editor |
src/adapters/dom_adapter.ts | the same API over a contenteditable element, plus the blinking block caret |
src/completion.ts | the insert-mode completion popup for the DOM editor |
src/snippets.ts | the 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.ts | the vim mode / key-buffer / ex command-line status bar |
src/worker-entry.ts | Monaco base editor web-worker entry |
scripts/build-modal-editor.mjs | esbuild 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)