// fixtures/text-keys.mjs — the keyboard inside a text block, and the // link commands behind Ctrl+K. One keymap shared by every view the // stage binds: the four mark toggles the schema has (schema.mjs's b, i, // c, h), the link dialog, and undo/redo handed through to the deck's // one UndoManager (editor-entry.js) rather than to a per-view history. // // Undo is bound HERE as well as on the document, because a keystroke // inside a contenteditable reaches ProseMirror first, and an unbound // Mod-z would fall through to the browser's own undo of the DOM — // which y-prosemirror then reads back as an edit. // // The commands are plain ProseMirror commands over state, so // text-keys.test.mjs runs them with no DOM. import { toggleMark } from 'prosemirror-commands' import { schema, safeHref } from './schema.mjs' const href = schema.marks.href /** * The link run around pos in doc — {from, to, href} — or null when pos * is not inside (or at the edge of) a link. A run is the adjacent text * nodes carrying the same link mark; the paragraph is walked once and * the run that spans pos is the answer. */ function linkRange (doc, pos) { const $pos = doc.resolve(pos) const start = $pos.start() let run = null let found = null $pos.parent.forEach((child, offset) => { const at = start + offset const mark = child.isText ? href.isInSet(child.marks) : null if (mark && run && run.mark.eq(mark) && run.to === at) { run.to = at + child.nodeSize } else if (mark) { run = { from: at, to: at + child.nodeSize, mark } } else { run = null } if (run && run.from <= pos && pos <= run.to) found = run }) return found ? { from: found.from, to: found.to, href: found.mark.attrs.href } : null } /** The link under the cursor (or at the selection's head), or null. */ export function linkAt (state) { const r = linkRange(state.doc, state.selection.head) return r ? r.href : null } /** * A command that links the selection — or the link the cursor is in — * to url. A url outside the allowlist (schema.mjs's safeHref: http, * https, mailto) is refused: the command returns false and writes * nothing, so a javascript: address never enters the document from * here, whatever the dialog's field accepted. */ export function setLink (url) { return (state, dispatch) => { if (!safeHref(url)) return false let { from, to } = state.selection if (from === to) { const r = linkRange(state.doc, from) if (!r) return false from = r.from to = r.to } if (dispatch) { dispatch(state.tr.removeMark(from, to, href).addMark(from, to, href.create({ href: url }))) } return true } } /** A command that removes the link from the selection, or from the link the cursor is in. */ export function removeLink (state, dispatch) { let { from, to } = state.selection if (from === to) { const r = linkRange(state.doc, from) if (!r) return false from = r.from to = r.to } if (!state.doc.rangeHasMark(from, to, href)) return false if (dispatch) dispatch(state.tr.removeMark(from, to, href)) return true } /** * The keymap. undo/redo are the deck's; link(view) opens the dialog * (link-dialog.mjs) and applies its answer to the view it was opened * for. Mod-e for code and Mod-Shift-h for highlight, because Mod-c and * Mod-h belong to the browser. */ export function textKeys ({ undo, redo, link }) { return { 'Mod-b': toggleMark(schema.marks.b), 'Mod-i': toggleMark(schema.marks.i), 'Mod-e': toggleMark(schema.marks.c), 'Mod-Shift-h': toggleMark(schema.marks.h), 'Mod-k': (state, dispatch, view) => { if (dispatch) link(view) return true }, 'Mod-z': () => { undo(); return true }, 'Mod-Shift-z': () => { redo(); return true }, 'Mod-y': () => { redo(); return true } } }