| 1 | // fixtures/autoformat.mjs — typing "- " makes a bullet. |
| 2 | // |
| 3 | // Input rules: a pattern that matches the text just typed at the end of |
| 4 | // a textblock, and a transformation applied when it does. Everything |
| 5 | // here is the same shape a person has met in every editor since |
| 6 | // Markdown, which is the whole argument for the specific set — these |
| 7 | // are not chosen for expressiveness, they are chosen for being the ones |
| 8 | // muscle memory already produces. |
| 9 | // |
| 10 | // UNDO IS THE FEATURE, not a nicety attached to it. An autoformat rule |
| 11 | // that cannot be taken back is worse than no rule at all: it turns a |
| 12 | // line that begins "- " into a list a person has to fight, and the |
| 13 | // fighting happens at the exact moment they were mid-sentence. |
| 14 | // undoInputRule, bound to Backspace immediately after a rule fires, |
| 15 | // reverts the transformation and leaves the characters that were typed |
| 16 | // — so "- " stays "- " and nothing was lost. It must be bound BEFORE |
| 17 | // baseKeymap, whose own Backspace binding would otherwise delete a |
| 18 | // character and never give the rule a chance; editor-entry.js's keymap |
| 19 | // block already exists for exactly this ordering reason (see its |
| 20 | // Shift-Enter comment). |
| 21 | // |
| 22 | // One interaction worth stating rather than assuming. This editor's |
| 23 | // undo is y-prosemirror's Yjs UndoManager, not prosemirror-history — |
| 24 | // buildCommands' comment in toolbar.mjs records what confusing the two |
| 25 | // already cost once. undoInputRule is written against ProseMirror's own |
| 26 | // plugin state, not against the history, so it works here — and |
| 27 | // autoformat.test.mjs pins that much: the rule fires, Backspace reverts |
| 28 | // it, and the typed characters come back. |
| 29 | // |
| 30 | // WHAT IS NOT TESTED, said plainly because an earlier version of this |
| 31 | // comment claimed it was: the rule and its revert are two separate Yjs |
| 32 | // edits, so Ctrl+Z after a Backspace-revert should re-apply the list |
| 33 | // rather than continue backwards. That is believed, not asserted — |
| 34 | // exercising it needs a live ySyncPlugin over a real Y.Doc, which none |
| 35 | // of the DOM-free tests here set up. The spec named this interaction as |
| 36 | // one of its five most likely failures and asked for a test; the test |
| 37 | // does not exist, and saying so is better than a comment asserting it |
| 38 | // does. |
| 39 | |
| 40 | import { inputRules, wrappingInputRule, textblockTypeInputRule } from 'prosemirror-inputrules' |
| 41 | |
| 42 | // The three bullet openers, matching what every editor accepts. The |
| 43 | // leading \s* lets a rule fire on an already-indented line. |
| 44 | const BULLET = /^\s*([-+*])\s$/ |
| 45 | |
| 46 | // A numbered list. |
| 47 | // |
| 48 | // The order attribute is DELIBERATELY NOT SET, and this is the one rule |
| 49 | // here that departs from prosemirror-schema-list's own example. That |
| 50 | // example passes the typed number through as `order`, so typing "7. " |
| 51 | // gives a list starting at 7 — and it would be right to, except that |
| 52 | // core/src/fragment.rs does not read `order` today: it renders every |
| 53 | // ordered list as a plain <ol>, starting at 1. Setting the attribute |
| 54 | // would make the number a person sees while typing differ from the |
| 55 | // number a reader with no JavaScript sees, and from the number in the |
| 56 | // PDF, with nothing anywhere reporting the disagreement. |
| 57 | // |
| 58 | // So the rule fires on any number and always starts at 1. Typing "1. " |
| 59 | // — which is what almost everyone types — is exactly right; typing "7. " |
| 60 | // starts a list at 1, which is visibly a limitation rather than a |
| 61 | // silent inconsistency. Making `order` real is a fragment.rs arm, a |
| 62 | // node_json attr, an <ol start> and list numbering in |
| 63 | // internal/export/pdf.go, and it belongs with that batch rather than |
| 64 | // smuggled in behind an input rule. |
| 65 | const ORDERED = /^(\d+)\.\s$/ |
| 66 | |
| 67 | const QUOTE = /^\s*>\s$/ |
| 68 | |
| 69 | // Headings 1 to 3, matching the block select's own range. fragment.rs |
| 70 | // clamps to 6 and the stylesheet styles all six, so widening this is |
| 71 | // three characters here and three options there — but a rule offering a |
| 72 | // level the Style control cannot show would be a way to reach a state |
| 73 | // the toolbar then misreports. |
| 74 | const HEADING = /^(#{1,3})\s$/ |
| 75 | |
| 76 | // autoformatRules builds the plugin. Every rule is guarded by the node |
| 77 | // type it produces existing in the schema — a schema without lists gets |
| 78 | // the heading and quote rules and no error, which matters because this |
| 79 | // module is imported by a test that builds minimal schemas. |
| 80 | export function autoformatRules(schema) { |
| 81 | const rules = [] |
| 82 | const { bullet_list, ordered_list, blockquote, heading } = schema.nodes |
| 83 | |
| 84 | if (bullet_list) rules.push(wrappingInputRule(BULLET, bullet_list)) |
| 85 | if (ordered_list) rules.push(wrappingInputRule(ORDERED, ordered_list)) |
| 86 | if (blockquote) rules.push(wrappingInputRule(QUOTE, blockquote)) |
| 87 | if (heading) { |
| 88 | rules.push( |
| 89 | textblockTypeInputRule(HEADING, heading, (match) => ({ level: match[1].length })), |
| 90 | ) |
| 91 | } |
| 92 | |
| 93 | return inputRules({ rules }) |
| 94 | } |
| 95 | |
| 96 | // The patterns, exported so autoformat.test.mjs can assert what they do |
| 97 | // and do not match without reaching into the plugin. The negative cases |
| 98 | // are the ones worth testing: a rule that fires mid-sentence, on "3.14" |
| 99 | // or on a hyphen inside a word, is the failure people actually notice. |
| 100 | export const PATTERNS = { BULLET, ORDERED, QUOTE, HEADING } |
| 101 | |