| 1 | // fixtures/text-keys.mjs — the keyboard inside a text block, and the |
| 2 | // link commands behind Ctrl+K. One keymap shared by every view the |
| 3 | // stage binds: the four mark toggles the schema has (schema.mjs's b, i, |
| 4 | // c, h), the link dialog, and undo/redo handed through to the deck's |
| 5 | // one UndoManager (editor-entry.js) rather than to a per-view history. |
| 6 | // |
| 7 | // Undo is bound HERE as well as on the document, because a keystroke |
| 8 | // inside a contenteditable reaches ProseMirror first, and an unbound |
| 9 | // Mod-z would fall through to the browser's own undo of the DOM — |
| 10 | // which y-prosemirror then reads back as an edit. |
| 11 | // |
| 12 | // The commands are plain ProseMirror commands over state, so |
| 13 | // text-keys.test.mjs runs them with no DOM. |
| 14 | import { toggleMark } from 'prosemirror-commands' |
| 15 | import { schema, safeHref } from './schema.mjs' |
| 16 | |
| 17 | const href = schema.marks.href |
| 18 | |
| 19 | /** |
| 20 | * The link run around pos in doc — {from, to, href} — or null when pos |
| 21 | * is not inside (or at the edge of) a link. A run is the adjacent text |
| 22 | * nodes carrying the same link mark; the paragraph is walked once and |
| 23 | * the run that spans pos is the answer. |
| 24 | */ |
| 25 | function linkRange (doc, pos) { |
| 26 | const $pos = doc.resolve(pos) |
| 27 | const start = $pos.start() |
| 28 | let run = null |
| 29 | let found = null |
| 30 | $pos.parent.forEach((child, offset) => { |
| 31 | const at = start + offset |
| 32 | const mark = child.isText ? href.isInSet(child.marks) : null |
| 33 | if (mark && run && run.mark.eq(mark) && run.to === at) { |
| 34 | run.to = at + child.nodeSize |
| 35 | } else if (mark) { |
| 36 | run = { from: at, to: at + child.nodeSize, mark } |
| 37 | } else { |
| 38 | run = null |
| 39 | } |
| 40 | if (run && run.from <= pos && pos <= run.to) found = run |
| 41 | }) |
| 42 | return found ? { from: found.from, to: found.to, href: found.mark.attrs.href } : null |
| 43 | } |
| 44 | |
| 45 | /** The link under the cursor (or at the selection's head), or null. */ |
| 46 | export function linkAt (state) { |
| 47 | const r = linkRange(state.doc, state.selection.head) |
| 48 | return r ? r.href : null |
| 49 | } |
| 50 | |
| 51 | /** |
| 52 | * A command that links the selection — or the link the cursor is in — |
| 53 | * to url. A url outside the allowlist (schema.mjs's safeHref: http, |
| 54 | * https, mailto) is refused: the command returns false and writes |
| 55 | * nothing, so a javascript: address never enters the document from |
| 56 | * here, whatever the dialog's field accepted. |
| 57 | */ |
| 58 | export function setLink (url) { |
| 59 | return (state, dispatch) => { |
| 60 | if (!safeHref(url)) return false |
| 61 | let { from, to } = state.selection |
| 62 | if (from === to) { |
| 63 | const r = linkRange(state.doc, from) |
| 64 | if (!r) return false |
| 65 | from = r.from |
| 66 | to = r.to |
| 67 | } |
| 68 | if (dispatch) { |
| 69 | dispatch(state.tr.removeMark(from, to, href).addMark(from, to, href.create({ href: url }))) |
| 70 | } |
| 71 | return true |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | /** A command that removes the link from the selection, or from the link the cursor is in. */ |
| 76 | export function removeLink (state, dispatch) { |
| 77 | let { from, to } = state.selection |
| 78 | if (from === to) { |
| 79 | const r = linkRange(state.doc, from) |
| 80 | if (!r) return false |
| 81 | from = r.from |
| 82 | to = r.to |
| 83 | } |
| 84 | if (!state.doc.rangeHasMark(from, to, href)) return false |
| 85 | if (dispatch) dispatch(state.tr.removeMark(from, to, href)) |
| 86 | return true |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * The keymap. undo/redo are the deck's; link(view) opens the dialog |
| 91 | * (link-dialog.mjs) and applies its answer to the view it was opened |
| 92 | * for. Mod-e for code and Mod-Shift-h for highlight, because Mod-c and |
| 93 | * Mod-h belong to the browser. |
| 94 | */ |
| 95 | export function textKeys ({ undo, redo, link }) { |
| 96 | return { |
| 97 | 'Mod-b': toggleMark(schema.marks.b), |
| 98 | 'Mod-i': toggleMark(schema.marks.i), |
| 99 | 'Mod-e': toggleMark(schema.marks.c), |
| 100 | 'Mod-Shift-h': toggleMark(schema.marks.h), |
| 101 | 'Mod-k': (state, dispatch, view) => { |
| 102 | if (dispatch) link(view) |
| 103 | return true |
| 104 | }, |
| 105 | 'Mod-z': () => { undo(); return true }, |
| 106 | 'Mod-Shift-z': () => { redo(); return true }, |
| 107 | 'Mod-y': () => { redo(); return true } |
| 108 | } |
| 109 | } |
| 110 | |