| 1 | // text-keys.mjs's commands over ProseMirror state alone: the link |
| 2 | // commands and the keymap's shape. No DOM, no view — EditorState is |
| 3 | // enough to run a command and read the marks it left. |
| 4 | import test from 'node:test' |
| 5 | import assert from 'node:assert/strict' |
| 6 | import { EditorState, TextSelection } from 'prosemirror-state' |
| 7 | import { schema, docFromParagraphs } from './schema.mjs' |
| 8 | import { linkAt, setLink, removeLink, textKeys } from './text-keys.mjs' |
| 9 | |
| 10 | function state (paragraphs, from, to = from) { |
| 11 | const doc = docFromParagraphs(paragraphs) |
| 12 | let s = EditorState.create({ schema, doc }) |
| 13 | return s.apply(s.tr.setSelection(TextSelection.create(s.doc, from, to))) |
| 14 | } |
| 15 | |
| 16 | function run (s, cmd) { |
| 17 | let next = s |
| 18 | const ok = cmd(s, tr => { next = s.apply(tr) }) |
| 19 | return { ok, state: next } |
| 20 | } |
| 21 | |
| 22 | function marksOf (s) { |
| 23 | const out = [] |
| 24 | s.doc.descendants(n => { if (n.isText) out.push([n.text, n.marks.map(m => m.type.name === 'href' ? m.attrs.href : m.type.name)]) }) |
| 25 | return out |
| 26 | } |
| 27 | |
| 28 | test('linkAt reads the link under the cursor and nothing elsewhere', () => { |
| 29 | const s = state([[{ t: 'see ' }, { t: 'this', href: 'https://a' }, { t: ' now' }]], 6) |
| 30 | assert.equal(linkAt(s), 'https://a') |
| 31 | assert.equal(linkAt(state([[{ t: 'plain' }]], 2)), null) |
| 32 | }) |
| 33 | |
| 34 | test('setLink marks the selection', () => { |
| 35 | const { ok, state: s } = run(state([[{ t: 'hello world' }]], 1, 6), setLink('https://x')) |
| 36 | assert.ok(ok) |
| 37 | assert.deepEqual(marksOf(s), [['hello', ['https://x']], [' world', []]]) |
| 38 | }) |
| 39 | |
| 40 | test('setLink on a collapsed cursor inside a link replaces that link', () => { |
| 41 | const { ok, state: s } = run(state([[{ t: 'see ' }, { t: 'this', href: 'https://a' }, { t: ' now' }]], 6), setLink('https://b')) |
| 42 | assert.ok(ok) |
| 43 | assert.deepEqual(marksOf(s), [['see ', []], ['this', ['https://b']], [' now', []]]) |
| 44 | }) |
| 45 | |
| 46 | test('setLink with nothing selected and no link does nothing', () => { |
| 47 | const { ok } = run(state([[{ t: 'plain' }]], 2), setLink('https://x')) |
| 48 | assert.equal(ok, false) |
| 49 | }) |
| 50 | |
| 51 | test('removeLink clears the link under the cursor', () => { |
| 52 | const { ok, state: s } = run(state([[{ t: 'see ' }, { t: 'this', href: 'https://a' }, { t: ' now' }]], 6), removeLink) |
| 53 | assert.ok(ok) |
| 54 | assert.deepEqual(marksOf(s), [['see this now', []]]) |
| 55 | }) |
| 56 | |
| 57 | test('the keymap binds every documented shortcut', () => { |
| 58 | const keys = textKeys({ undo: () => {}, redo: () => {}, link: () => {} }) |
| 59 | for (const k of ['Mod-b', 'Mod-i', 'Mod-e', 'Mod-Shift-h', 'Mod-k', 'Mod-z', 'Mod-Shift-z', 'Mod-y']) { |
| 60 | assert.equal(typeof keys[k], 'function', k) |
| 61 | } |
| 62 | }) |
| 63 | |
| 64 | test('Mod-b toggles bold through the keymap', () => { |
| 65 | const keys = textKeys({ undo: () => {}, redo: () => {}, link: () => {} }) |
| 66 | const { state: s } = run(state([[{ t: 'hello' }]], 1, 6), keys['Mod-b']) |
| 67 | assert.deepEqual(marksOf(s), [['hello', ['b']]]) |
| 68 | }) |
| 69 | |
| 70 | test('setLink refuses an address outside the allowlist and writes nothing', () => { |
| 71 | for (const bad of ['javascript:alert(1)', 'data:text/html,hi', 'JavaScript:void(0)', '/relative', ' https://padded', '']) { |
| 72 | const s = state([[{ t: 'click here' }]], 1, 6) |
| 73 | const { ok, state: next } = run(s, setLink(bad)) |
| 74 | assert.equal(ok, false, bad) |
| 75 | assert.deepEqual(marksOf(next), [['click here', []]], bad) |
| 76 | } |
| 77 | const { ok, state: next } = run(state([[{ t: 'click here' }]], 1, 6), setLink('mailto:a@b.c')) |
| 78 | assert.equal(ok, true) |
| 79 | assert.deepEqual(marksOf(next), [['click', ['mailto:a@b.c']], [' here', []]]) |
| 80 | }) |
| 81 | |