Files
jason.woltje 9353675d1c webui: a WYSIWYG Markdown editor in the Console docs view, built from reviewable source (row 154, Q168, #1648)
Jason's Q168: a visual editor for the Console's project documents,
held to row 148's rendering rules.

Library. ProseMirror with prosemirror-markdown and markdown-it, chosen
in the phase 1 library report (LIBRARY.md, REPORT.md in the packet).
packages/webui/third_party/editor/ holds 19 upstream packages as source,
byte for byte, each with its license, plus NOTICE and PROVENANCE.json.

Build. packages/webui/editor/ holds the model, view, entry and
build.mjs. esbuild 0.28.2 is an exact devDependency of packages/webui
with its own lock; editor/toolchain.mjs checks the version and the
binary's sha256 before any build. MOSAIC_REQUIRE_EDITOR_BUILD=1 turns a
missing or different esbuild into a failure, and the rebuild test then
checks the committed src/public/editor.js and editor.css match a fresh
build. The built bundle is committed under Mos's conditions
(mos-0755, mos-0765).

Console. The docs view gets Visual and Source modes. A save still
writes Markdown through the existing project write path, with the same
stale-base and read-only handling. A mode switch reuses the draft and
reads nothing from the project. Links and images keep row 148's
target rules, and no raw HTML runs. serve.mjs serves /editor.js and
/editor.css; index.html loads them.

Owner Researcher, brief docs/plans/2026-10-16_q168-wysiwyg-editor.md.
Round 5 is a rebase onto d88613bac (row 155); only docs/TOOLS.md,
webui/README.md, public/bus.js and serve.mjs differ from the round
before. Reviewers on candidate manifest 3a4bc21c...3b05: Dewey
(security) approve 29027, Filbert approve 29026. Mos's decision 123 go
for public.diff 902f0244 (handwritten part ea59079e): mos-0824. Sage gate
on 72c3fa598 with MOSAIC_REQUIRE_EDITOR_BUILD=1: GATE_PASSED 25/25
(node 2397, selftests 422), 22:07:29Z to 22:13:50Z; the rebuild test
ran and passed.

Nothing live.
2026-10-18 17:14:39 -05:00

210 lines
9.4 KiB
JavaScript

// Row 154 (#1648, Q168): the editor view. A ProseMirror EditorView over model.mjs's schema, a toolbar, a
// link bar, and the keymap. Everything is built with DOM calls (createElement, textContent, setAttribute);
// there is no string-to-markup sink in this file.
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { history, undo, redo } from 'prosemirror-history';
import { keymap } from 'prosemirror-keymap';
import { baseKeymap, toggleMark, setBlockType, wrapIn, chainCommands, exitCode } from 'prosemirror-commands';
import { wrapInList, splitListItem, liftListItem, sinkListItem } from 'prosemirror-schema-list';
import { InputRule, inputRules, textblockTypeInputRule, wrappingInputRule, undoInputRule } from 'prosemirror-inputrules';
import { schema, parse, serialise, linkTarget } from './model.mjs';
const { paragraph, heading, code_block, blockquote, bullet_list, ordered_list, list_item, hard_break, horizontal_rule } = schema.nodes;
const { strong, em, code, link } = schema.marks;
const inCode = state => state.selection.$from.parent.type.spec.code;
function markActive(state, type) {
const { from, $from, to, empty } = state.selection;
return empty ? !!type.isInSet(state.storedMarks || $from.marks()) : state.doc.rangeHasMark(from, to, type);
}
// -------------------------------------------------------------------------------- commands
const insertBreak = chainCommands(exitCode, (state, dispatch) => {
if (dispatch) dispatch(state.tr.replaceSelectionWith(hard_break.create()).scrollIntoView());
return true;
});
const insertRule = (state, dispatch) => {
if (dispatch) dispatch(state.tr.replaceSelectionWith(horizontal_rule.create()).scrollIntoView());
return true;
};
const heading_ = level => setBlockType(heading, { level });
// The link bar: set the target on the selection, or remove the link. The gate decides; a refused target
// changes nothing and the bar says so.
function applyLink(view, raw) {
const { state } = view, { from, to, empty } = state.selection;
if (raw === '') {
if (!empty) view.dispatch(state.tr.removeMark(from, to, link));
return true;
}
const href = linkTarget(raw);
if (!href || empty) return false;
view.dispatch(state.tr.removeMark(from, to, link).addMark(from, to, link.create({ href })));
return true;
}
// -------------------------------------------------------------------------------- input rules
const rules = [
textblockTypeInputRule(/^(#{1,6})\s$/, heading, m => ({ level: m[1].length })),
textblockTypeInputRule(/^```$/, code_block),
wrappingInputRule(/^\s*>\s$/, blockquote),
wrappingInputRule(/^\s*([-+*])\s$/, bullet_list),
wrappingInputRule(/^(\d+)\.\s$/, ordered_list, m => ({ order: +m[1] }), (m, node) => node.childCount + node.attrs.order === +m[1]),
new InputRule(/^(?:---|___)$/, (state, match, start, end) => state.tr.replaceRangeWith(start, end, horizontal_rule.create()).scrollIntoView()),
];
// -------------------------------------------------------------------------------- toolbar
const BUTTONS = [
['strong', 'B', 'Bold (Ctrl+B)', toggleMark(strong), s => markActive(s, strong)],
['em', 'I', 'Italic (Ctrl+I)', toggleMark(em), s => markActive(s, em)],
['code', '</>', 'Code (Ctrl+`)', toggleMark(code), s => markActive(s, code)],
['link', 'Link', 'Link (Ctrl+K)', null, s => markActive(s, link)],
['h1', 'H1', 'Heading 1', heading_(1), s => s.selection.$from.parent.hasMarkup(heading, { level: 1 })],
['h2', 'H2', 'Heading 2', heading_(2), s => s.selection.$from.parent.hasMarkup(heading, { level: 2 })],
['h3', 'H3', 'Heading 3', heading_(3), s => s.selection.$from.parent.hasMarkup(heading, { level: 3 })],
['p', 'Text', 'Paragraph', setBlockType(paragraph), s => s.selection.$from.parent.type === paragraph],
['ul', '• List', 'Bulleted list', wrapInList(bullet_list), () => false],
['ol', '1. List', 'Numbered list', wrapInList(ordered_list), () => false],
['quote', '❝', 'Quote', wrapIn(blockquote), () => false],
['codeblock', '{ }', 'Code block', setBlockType(code_block), s => s.selection.$from.parent.type === code_block],
['rule', '―', 'Horizontal rule', insertRule, () => false],
['undo', 'Undo', 'Undo (Ctrl+Z)', undo, () => false],
['redo', 'Redo', 'Redo (Ctrl+Y)', redo, () => false],
];
function toolbar(getView, openLink) {
const bar = document.createElement('div');
bar.className = 'ed-toolbar';
bar.setAttribute('role', 'toolbar');
bar.setAttribute('aria-label', 'Formatting');
const items = BUTTONS.map(([id, label, title, command, active]) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'ed-btn';
b.dataset.cmd = id;
b.title = title;
b.setAttribute('aria-label', title);
b.textContent = label;
// mousedown keeps the editor selection; the command runs on click
b.addEventListener('mousedown', e => e.preventDefault());
b.addEventListener('click', () => {
const view = getView();
if (!view || view.editable === false) return;
if (id === 'link') openLink(); else command(view.state, view.dispatch, view);
if (id !== 'link') view.focus();
});
bar.appendChild(b);
return { b, command, active };
});
const sync = state => {
for (const { b, command, active } of items) {
b.classList.toggle('on', !!active(state));
b.disabled = command ? !command(state) : false;
b.setAttribute('aria-pressed', b.classList.contains('on') ? 'true' : 'false');
}
};
return { bar, sync };
}
function linkBar(getView) {
const form = document.createElement('form');
form.className = 'ed-linkbar';
form.hidden = true;
const input = document.createElement('input');
input.type = 'text';
input.className = 'ed-link-input';
input.setAttribute('aria-label', 'Link target (http, https or a relative path)');
input.placeholder = 'https://… or a relative path; empty removes the link';
input.autocomplete = 'off';
input.spellcheck = false;
const ok = document.createElement('button');
ok.type = 'submit';
ok.className = 'ed-btn';
ok.textContent = 'Set';
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.className = 'ed-btn';
cancel.textContent = 'Cancel';
const note = document.createElement('span');
note.className = 'ed-link-note';
note.setAttribute('role', 'status');
form.append(input, ok, cancel, note);
const close = () => { form.hidden = true; note.textContent = ''; getView()?.focus(); };
cancel.addEventListener('click', close);
input.addEventListener('keydown', e => { if (e.key === 'Escape') { e.preventDefault(); close(); } });
form.addEventListener('submit', e => {
e.preventDefault();
e.stopPropagation(); // the Console's own submit handler is for the save form
const view = getView();
if (!view) return;
if (applyLink(view, input.value.trim())) close();
else note.textContent = 'That target is not allowed. Use http, https or a relative path, and select the text first.';
});
const open = () => {
const view = getView();
if (!view) return;
const { from, to, empty, $from } = view.state.selection;
if (empty && !markActive(view.state, link)) { form.hidden = false; input.value = ''; note.textContent = 'Select the text to link first.'; return; }
const at = view.state.doc.rangeHasMark(from, to, link) ? view.state.doc.nodeAt(from)?.marks.find(m => m.type === link) : null;
input.value = at ? at.attrs.href : '';
note.textContent = '';
form.hidden = false;
input.focus();
input.select();
};
return { form, open };
}
// -------------------------------------------------------------------------------- the editor
/// mount(host, {text, editable, onChange}) -> { text(), setText(text), destroy(), focus(), view }
export function mount(host, { text = '', editable = true, onChange = () => {} } = {}) {
let view = null;
const getView = () => view;
const wrap = document.createElement('div');
wrap.className = 'ed-wrap';
const link_ = linkBar(getView);
const tools = toolbar(getView, link_.open);
const surface = document.createElement('div');
surface.className = 'ed-surface';
if (editable) wrap.append(tools.bar, link_.form);
wrap.appendChild(surface);
host.appendChild(wrap);
const keys = {
'Mod-z': undo, 'Mod-y': redo, 'Shift-Mod-z': redo, Backspace: undoInputRule,
'Mod-b': toggleMark(strong), 'Mod-i': toggleMark(em), 'Mod-`': toggleMark(code),
'Mod-k': () => { link_.open(); return true; },
'Shift-Enter': insertBreak, 'Mod-Enter': insertBreak,
Enter: splitListItem(list_item),
Tab: sinkListItem(list_item), 'Shift-Tab': liftListItem(list_item),
};
const fresh = src => EditorState.create({
schema, doc: parse(src),
plugins: [inputRules({ rules }), keymap(keys), keymap(baseKeymap), history()],
});
view = new EditorView(surface, {
state: fresh(text),
editable: () => editable,
attributes: { 'aria-label': 'Document', 'aria-multiline': 'true', role: 'textbox', spellcheck: 'true', 'data-field': 'doc-visual' },
dispatchTransaction(tr) {
const next = view.state.apply(tr);
view.updateState(next);
tools.sync(next);
if (tr.docChanged) onChange();
},
// Pasted and dropped content goes through the schema's parse rules only; nothing here adds to them.
});
tools.sync(view.state);
return {
view,
text: () => serialise(view.state.doc),
setText(src) { view.updateState(fresh(src)); tools.sync(view.state); },
focus: () => view.focus(),
destroy() { view.destroy(); wrap.remove(); },
};
}