>_EXECUTIVE SUMMARY
zpwr-modal-editor is the shared Vim / Emacs modal-editing editor of the MenkeTechnologies app stack — one TypeScript source of truth for the vim and emacs editing modes, vendored from monaco-vim and monaco-emacs and adapted so we own them outright. The source lives in src/ and each consuming app bundles it with its own monaco-editor dependency.
The design turns on a surface adapter: the vim engine talks only to a CodeMirror-shaped adapter, so the same ~7.1k-line engine drives either surface unchanged. Both adapters now ship — monaco_adapter over a Monaco editor, and dom_adapter over a plain contenteditable element with no Monaco at all — and the bundler emits a bundle for each. Both are esbuild IIFE (ES2020, minified) and both expose a single window.ZModal facade: create / attachVim / attachEmacs on the Monaco build, attach / attachVim / snippets on the DOM build. A consumer loads one or the other, never both.
Counts derived from the repo: src/**/*.ts = 17 files / 12,030 lines; vim engine engine/vim/keymap_vim.ts = 7,135; adapters/monaco_adapter.ts = 1,119 and adapters/dom_adapter.ts = 1,089 (both over the shared 232-line adapters/cm_core.ts); engine/emacs/*.ts = 1,329; modal engines = vim + emacs; surface adapters = monaco + dom; window.ZModal methods = create + attachVim + attachEmacs (Monaco build) / attach + attachVim + snippets (DOM build); runtime deps = 0 (lodash replaced by a local util).
~ARCHITECTURE
One engine source, built per consumer, behind a swappable surface adapter. There are two entries: src/index.ts wires the Monaco theme + worker and the Monaco facade, src/index-dom.ts the Monaco-free one. The vim engine is bolted onto whichever adapter class it imports — the DOM build aliases its ../../adapters/monaco_adapter import to dom_adapter.ts at bundle time, so the vendored 7k-line engine is reused byte-for-byte and the bundle never pulls in Monaco. The bundler runs inside each app to resolve its own Monaco dep and emit vendored IIFE artifacts.
| Layer | Implementation |
|---|---|
| Vim engine | the full CodeMirror vim keymap (motions, operators, registers, marks, ex commands, search, macros) vendored via monaco-vim; it references its host editor only through the adapter it imports as "CodeMirror" |
| Adapter core | adapters/cm_core.ts — the surface-agnostic half of the CodeMirror shim (word/char classification, Pos, signals, bracket matching, key lookup / keymaps, Marker, StringStream). It imports no Monaco, which is what lets the DOM bundle exist |
| Monaco adapter | monaco_adapter.ts implements the CodeMirror API (cursor, selections, ranges, marks, operations, scrolling) over a Monaco editor |
| DOM adapter | dom_adapter.ts implements the same API over a plain contenteditable element. The host's block-level descendants are "lines"; inline nodes (span / a / img) are transparent text; <br> is a hard break; columns are character offsets into a line's concatenated text, with the index rebuilt lazily after any edit. Intra-line edits go through execCommand, preserving surrounding inline formatting and riding the browser's native undo; multi-line / structural edits rebuild the affected blocks as plain paragraphs |
| Rich linewise yank/put | reading a range caches each fully-covered line's inline HTML keyed by its plain text, and 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. Bounded FIFO of 256 lines; yanks spanning more than 200 lines are skipped. Entirely in dom_adapter.ts — the vendored vim core is untouched |
| Emacs engine | monaco-emacs' extension — keybindings, kill-ring, mark, incremental search — coupled directly to the Monaco editor API; rides the Monaco adapter only |
| Insert-mode completion | completion.ts — a popup for the DOM editor, fed by a per-field source (attach({ completion }) or host.zmodalCompletion) plus the snippet store. Its keydown listener captures on document so it consumes Tab / Enter / arrows before the Vim engine's host-level capture sees them, and only while the popup is open — with no source configured every key flows to Vim unchanged |
| Snippets | snippets.ts — { trigger, body } pairs in localStorage, offered by the completion popup. Bodies resolve $DATE / $TIME / $DATETIME / $DATE_ISO / $YEAR at insertion time; a body flagged stryke is a stryke script that is RUN on expansion, its output inserted, via an evaluator the host registers once (absent → inserts ""). Ships its own manager UI |
| No runtime deps | the two lodash helpers monaco-emacs used (throttle, kebabCase) are replaced by engine/emacs/localutil.ts, so the package carries zero external runtime deps |
| Facade | window.ZModal — Monaco build: create + attachVim / attachEmacs + VimMode / EmacsExtension / StatusBar. DOM build: attach + attachVim + snippets + VimMode / StatusBar |
| Build | esbuild IIFE, ES2020, minified; codicon .ttf inlined as a data URL; runs inside the consumer, writes to its frontend/lib |
&FACADE SURFACE
Both bundles expose one global, window.ZModal — a consumer loads whichever it needs, never both.
Monaco build (modal-editor.bundle.js): create is the convenience mount (news up a Monaco editor and applies a mode); attachVim / attachEmacs are the primitives it is built on, for hosts that already own a Monaco editor and must not bundle a second one.
| Method | Role |
|---|---|
create(host, opts) | create a Monaco editor + apply mode; returns a handle (getValue / setValue / focus / setMode / layout / destroy) |
attachVim(editor, statusEl?) | attach vim to an existing Monaco editor; mode / key-buffer / ex line render into statusEl; 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 |
DOM build (modal-editor-dom.bundle.js): vim only, over a contenteditable element, with no Monaco anywhere in the bundle.
| Method | Role |
|---|---|
attach(host, opts) | attach modal editing to an existing contenteditable element. opts: mode ('default' | 'vim', default vim), statusBar, onChange, readOnly, completion. Returns a handle (adapter / host / getValue / focus / setMode / isVim / destroy); setMode('default') detaches the engine and hands typing back to the browser |
attachVim(host, statusEl?) | the lower-level primitive: attach vim to an element and return the 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 & SNIPPETS
The DOM editor grows a completion popup in INSERT mode: it takes the word before the caret and queries a per-field source, merged with any matching snippet triggers. The source is supplied either as attach({ completion }) or per element as host.zmodalCompletion (read at query time), so a generic mount can attach fields whose candidate lists it does not know. CompletionConfig also carries separators (token terminators), minChars (default 1) and maxItems (default 8). With no source configured the popup never opens and every key — Tab included — reaches the Vim engine unchanged.
| Key | Action |
|---|---|
| Tab / Enter | accept the selected candidate |
| Ctrl-N / ↓ | next candidate |
| Ctrl-P / ↑ | previous candidate |
| Esc | dismiss |
Snippets are { trigger, body } pairs persisted in localStorage per app WebView, created and edited through the built-in manager (snippets.openManager()) so the feature is self-contained in the editor. Bodies resolve $DATE, $TIME, $DATETIME, $DATE_ISO and $YEAR at insertion time. A snippet flagged stryke is a stryke script rather than literal text: on expansion its body is run through an evaluator the host registers once with snippets.setEvaluator(fn) (e.g. wired to a run_stryke_hook Tauri command) and the script's output is what lands in the document. Stryke candidates resolve asynchronously on accept; with no evaluator registered they insert the empty string.
!INTEGRATION — ONE MONACO PER PAGE
The Monaco build bundles its own Monaco. If a host page also loads another Monaco bundle (e.g. zpwr-hooks-editor), a WebKit / WKWebView content process can crash on the second full Monaco — the window renders fully blank (the content process is gone). Chromium tolerates two Monacos on one page; WebKit does not. This is an integration constraint, not a bug in the engine. Three supported patterns:
| Pattern | How |
|---|---|
| DOM build | modal-editor-dom.bundle.js sidesteps the constraint entirely for vim — in-place modal editing on the app's existing contenteditable pages with no Monaco at all. This is what the word / ppt / spreadsheet surfaces load, alongside the hooks editor's Monaco. |
| Share one Monaco | the app creates its Monaco editor and calls attachVim(editor) / attachEmacs(editor); no second Monaco is bundled. A monaco-editor-external variant of the Monaco bundle (the clean way to ship this) is still an open packaging task. |
| Be the only Monaco | use create(...) (bundles Monaco) only in apps that don't already load one. |
/BUNDLER & RESOLVE FIXES
scripts/build-modal-editor.mjs emits three artifacts — modal-editor.bundle.js (+ .css), modal-editor.worker.js and modal-editor-dom.bundle.js — and carries esbuild resolve overrides so the vendored engines and Monaco resolve to ESM rather than AMD define() builds that throw in the WebView. Bare monaco-editor imports (the vendored monaco-emacs require()s it) are pinned to esm/vs/editor/edcore.main.js — the same lean ESM instance the entry uses — instead of the AMD min build. Deep monaco-editor/esm/* subpaths without a file extension get .js appended and resolve to the package dir directly (the package exports map only resolves extensioned paths). The build defaults its output to <cwd>/frontend/lib and honors MODAL_EDITOR_OUT. Because the vim/emacs engines are vendored, the consumer needs only esbuild + monaco-editor in devDependencies — no monaco-vim / monaco-emacs.
The DOM bundle is built by a second, tiny plugin rather than a second copy of the engine: vim-dom-adapter-alias rewrites the engine's hard adapters/monaco_adapter import to adapters/dom_adapter.ts. It uses no monaco-resolve-fix, so nothing in that bundle can reach Monaco. Setting MODAL_EDITOR_DOM_ONLY=1 emits only that bundle and skips the monaco-editor presence check — a DOM-only consumer needs no monaco-editor dependency at all. Otherwise the bundler fails fast with the resolved path when node_modules/monaco-editor is missing, rather than producing a broken artifact.
$CI GATES
This is a non-Rust (frontend / TypeScript) submodule, so it is exempt from the meta repo's cargo gates (fmt / clippy / doc / cargo test). The umbrella MenkeTechnologiesMeta repo enforces the documentation and brand gates on docs/:
| Gate | Requirement |
|---|---|
| doctype / charset / lang | <!DOCTYPE html> first line, <meta charset="utf-8">, <html lang="en"> |
| meta tags | non-empty <title>, viewport meta, description meta on both pages |
| structure | at least one <h1>; every <img> has alt; no deprecated HTML4 tags |
| links | external href/src https-only; no placeholder hrefs; target="_blank" carries rel="noopener noreferrer"; index ↔ report cross-linked |
| behavior | no inline event handlers (all via hud-theme.js); referenced css/js exist in docs/ |
| brand | "zpwr-modal-editor" present in titles + headings |
#PROJECT METADATA
| Item | Value |
|---|---|
| Version | 0.1.0 |
| Type | module (ESM); TypeScript source |
| Pinned devDeps (consumer) | esbuild ^0.28.1, monaco-editor 0.55.1 (the vim/emacs engines are vendored — no monaco-vim / monaco-emacs deps) |
| Bundle artifacts | modal-editor.bundle.js + modal-editor.bundle.css + modal-editor.worker.js |
| Vendored from | monaco-vim 0.4.4, monaco-emacs 0.3.0 (MIT; upstream licenses retained under src/engine/) |
| Author | MenkeTechnologies |
| Repository | github.com/MenkeTechnologies/zpwr-modal-editor |
| Meta umbrella | MenkeTechnologiesMeta |