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 ontod88613bac(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 on72c3fa598with 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.
210 lines
9.4 KiB
JavaScript
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(); },
|
|
};
|
|
}
|