// Milkdown: visual editing, or read-only preview. import { Editor, defaultValueCtx, remarkStringifyOptionsCtx, editorViewCtx, editorViewOptionsCtx, parserCtx, remarkCtx, rootCtx, } from '@milkdown/kit/core' import { commonmark } from '@milkdown/kit/preset/commonmark' import { gfm } from '@milkdown/kit/preset/gfm' import { history } from '@milkdown/kit/plugin/history' import { codeBlockComponent } from '@milkdown/kit/component/code-block' import { Plugin, TextSelection } from '@milkdown/kit/prose/state' import { Decoration, DecorationSet } from '@milkdown/kit/prose/view' import { keymap } from '@milkdown/kit/prose/keymap' import { liftListItem, sinkListItem } from '@milkdown/kit/prose/schema-list' import { redo, redoDepth, undoDepth } from '@milkdown/kit/prose/history' import { $prose, callCommand } from '@milkdown/kit/utils' import { insertTableCommand } from '@milkdown/kit/preset/gfm' import { addColumnAfter, addColumnBefore, addRowAfter, addRowBefore, deleteColumn, deleteRow, } from '@milkdown/kit/prose/tables' import { visit } from 'unist-util-visit' import { image } from './image.js' import { math } from './math.js' import { code } from './code.js' import { diagrams } from './diagrams.js' import { faithful, raw } from './preserve.js' import { docFinder } from './find.js' import { search } from 'prosemirror-search' import { style } from './style.js' /** An editor over `markdown`; null when blocks cannot map. */ export async function visual(root, markdown, editable, texts, changed, edge) { const owner = diagrams() let write = () => ({ text: markdown, spans: [] }) // Setting up raw blocks is no edit. let ready = false const watch = $prose( () => new Plugin({ view: () => ({ update: (view, before) => { if (ready && !view.state.doc.eq(before.doc)) changed(() => write(view.state.doc).text) }, }), }), ) // Focus mode dims all blocks but the caret's. const current = $prose( () => new Plugin({ props: { decorations: (state) => { const { $from } = state.selection if ($from.depth < 1) return null const start = $from.before(1) const block = Decoration.node(start, start + $from.node(1).nodeSize, { class: 'ely-current', }) return DecorationSet.create(state.doc, [block]) }, }, }), ) // Past their history, undo and redo reach the page. const ends = $prose(() => keymap({ 'Mod-z': (state) => undoDepth(state) === 0 && edge('undo'), 'Mod-Shift-z': (state) => redoDepth(state) === 0 && edge('redo'), 'Mod-y': (state) => redoDepth(state) === 0 && edge('redo'), // Tab moves list items; it never leaves the editor. Tab: (state, dispatch) => sinkListItem(state.schema.nodes.list_item)(state, dispatch) || true, 'Shift-Tab': (state, dispatch) => liftListItem(state.schema.nodes.list_item)(state, dispatch) || true, }), ) const made = Editor.make() .config((ctx) => { ctx.set(rootCtx, root) ctx.set(defaultValueCtx, markdown) ctx.update(remarkStringifyOptionsCtx, (prev) => ({ ...prev, ...style(markdown) })) ctx.update(editorViewOptionsCtx, (prev) => ({ ...prev, editable: () => editable })) }) .use(commonmark) .use(gfm) .use(history) .use(image) .use(math) .use(raw) .config(code(texts, editable, owner)) .use(codeBlockComponent) .use(watch) .use(current) .use(ends) .use($prose(() => search())) .create() const editor = await made.catch((error) => { owner.release() throw error }) const view = editor.action((ctx) => ctx.get(editorViewCtx)) const close = () => { editor.destroy() owner.release() } const keep = editor.action((ctx) => faithful(ctx, markdown)) if (!keep) { close() return null } write = keep ready = true // The text once every undone step is redone. const redone = () => { let state = view.state if (!redoDepth(state)) return null while (redoDepth(state)) redo(state, (tr) => (state = state.apply(tr))) return write(state.doc).text } // Headings, raw ones too, by the title their source gives. const [remark, parse] = editor.action((ctx) => [ctx.get(remarkCtx), ctx.get(parserCtx)]) /** An mdast node's words. */ const words = (node) => node.value ?? (node.children ?? []).map(words).join('') // Headings; inside raw blocks, as remark reads their source. const headings = () => { const found = [] view.state.doc.descendants((node, pos) => { if (node.type.name === 'heading') found.push({ level: node.attrs.level, title: node.textContent, pos }) if (node.type.name !== 'ely_raw') return true visit(remark.parse(node.textContent), 'heading', (heading) => { const start = (heading.children[0] ?? heading).position.start.offset found.push({ level: heading.depth, title: words(heading), pos, at: pos + 1 + start }) }) return false }) return found } // `exact`: the caret at `pos`; else in that block. const reveal = (pos, exact) => { if (editable) { const doc = view.state.doc const place = exact ? TextSelection.create(doc, pos) : TextSelection.near(doc.resolve(pos + 1)) view.dispatch(view.state.tr.setSelection(place).scrollIntoView()) return view.focus() } const { node } = view.domAtPos(pos + 1) ;(node.nodeType === 1 ? node : node.parentElement).scrollIntoView({ block: 'center' }) } /** `node`'s text, and where a character of it sits. */ const reading = (node, start) => { const chunks = [] // Text and line breaks, as `textContent` reads them. node.descendants((child, at) => { const words = child.isText ? child.text : child.isLeaf ? child.type.spec.leafText?.(child) : null if (words) chunks.push({ text: words.toLowerCase(), at: start + 1 + at }) }) const at = (offset) => { for (const [index, chunk] of chunks.entries()) { const last = index === chunks.length - 1 if (offset < chunk.text.length || (last && offset === chunk.text.length)) return chunk.at + offset offset -= chunk.text.length } return null } return { joined: chunks.map((chunk) => chunk.text).join(''), at } } // Non-overlapping hits, the same rule as `split`. const hits = (text, needle) => text.split(needle).length - 1 /** The block holding file line `n`: its index and start. */ const blockOf = (n) => { const { text, spans } = write(view.state.doc) let offset = 0 for (let k = 1; k < n && offset >= 0; k++) offset = text.indexOf('\n', offset) + 1 if (n < 1 || offset < 0) return null const index = spans.findIndex((span) => span && span[1] >= offset) if (index < 0) return null let pos = 0 for (let k = 0; k < index; k++) pos += view.state.doc.child(k).nodeSize return { text, spans, offset, index, pos } } /** Line `n`: the query's place in its block. */ const line = (n, query) => { const block = blockOf(n) if (!block) return false const { text, spans, offset, index, pos } = block const needle = query.toLowerCase() const node = view.state.doc.child(index) // Text before and through the line, as shown. const shown = (end) => { const source = text.slice(spans[index][0], Math.max(spans[index][0], end)) return (node.type.name === 'ely_raw' ? source : parse(source).textContent).toLowerCase() } const ends = text.indexOf('\n', offset) const before = shown(offset) const through = shown(ends < 0 ? text.length : ends) const { joined, at } = reading(node, pos) const k = needle ? hits(before, needle) : 0 let found = -1 if (needle && hits(through, needle) > k) { let from = 0 for (let seen = 0; seen <= k; seen++) { found = joined.indexOf(needle, from) if (found < 0) break from = found + needle.length } } // A hit hidden, as in a link: the line's start. const place = found >= 0 ? at(found) : at(before.length) reveal(place ?? pos, place !== null) return true } /** The caret: its block's file line, and how far in. */ const where = () => { const { text, spans } = write(view.state.doc) const { from } = view.state.selection let [start, pos] = [0, 0] view.state.doc.forEach((node, offset, index) => { if (offset <= from && spans[index]) [start, pos] = [spans[index][0], offset] }) return { line: text.slice(0, start).split('\n').length, column: from - pos } } /** The caret back where `where` read it. */ const at = (n, column) => { const block = blockOf(n) if (!block) return false const size = view.state.doc.child(block.index).nodeSize reveal(block.pos + Math.min(Math.max(column, 1), size - 1) - 1, false) return true } // Rows and columns; false when the caret is outside tables. const tables = { 'row-above': addRowBefore, 'row-below': addRowAfter, 'column-left': addColumnBefore, 'column-right': addColumnAfter, 'delete-row': deleteRow, 'delete-column': deleteColumn, } const tablesIn = (doc) => { const found = [] doc.descendants((node, pos) => { if (node.type.name === 'table') found.push({ node, pos }) }) return found } // Milkdown may leave the caret before the new table. const newTable = () => { const old = new Set(tablesIn(view.state.doc).map(({ node }) => node)) editor.action(callCommand(insertTableCommand.key, { row: 3, col: 3 })) const made = tablesIn(view.state.doc).find(({ node }) => !old.has(node)) if (!made) throw new Error('the inserted table is missing') const cell = TextSelection.near(view.state.doc.resolve(made.pos + 1)) view.dispatch(view.state.tr.setSelection(cell).scrollIntoView()) return true } const table = (op) => { if (!editable) return false const done = op === 'insert' ? newTable() : tables[op](view.state, view.dispatch) view.focus() return done } /** Images at the caret: `{ src, alt }` each. */ const insert = (images) => { if (!editable) return false const image = view.state.schema.nodes.image let tr = view.state.tr for (const { src, alt } of images) tr = tr.replaceSelectionWith(image.create({ src, alt, title: '' }), false) view.dispatch(tr.scrollIntoView()) view.focus() return true } return { insert, table, find: docFinder(view), close, text: () => write(view.state.doc).text, redone, focus: () => editable && view.focus(), outline: () => headings().map(({ level, title }) => ({ level, title })), heading: (key) => { const found = headings()[key] if (found) reveal(found.at ?? found.pos, found.at !== undefined) return Boolean(found) }, line, where, at, // The caret's line to the middle of the view. center: () => { const { top } = view.coordsAtPos(view.state.selection.head) window.scrollBy(0, top - window.innerHeight / 2) }, } }