oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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.
14import { toggleMark } from 'prosemirror-commands'
15import { schema, safeHref } from './schema.mjs'
16
17const 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 */
25function 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. */
46export 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 */
58export 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. */
76export 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 */
95export 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