// 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(); }, }; }