| 1 | // fixtures/toolbar.mjs — the formatting bar. |
| 2 | // |
| 3 | // Chrome, so it lives outside [docs-editor]: ProseMirror's {mount: el} |
| 4 | // takes over everything inside that element, and a toolbar rendered |
| 5 | // inside it would be replaced by the document on the first mount. |
| 6 | // views/show.html renders the markup, server-side, with every label |
| 7 | // resolved from the catalog — this module carries no copy of its own, |
| 8 | // the same rule the save-status indicator already follows. |
| 9 | // |
| 10 | // It never holds an EditorView. attemptResync (editor-entry.js) |
| 11 | // destroys and rebuilds the view wholesale, so anything holding one |
| 12 | // dispatches into a dead view after a resync; every command here reads |
| 13 | // getView() at the moment it runs instead. |
| 14 | // |
| 15 | // The button state is kept in step by a ProseMirror plugin rather than |
| 16 | // by listening for clicks and keystrokes: the selection also moves |
| 17 | // under the arrow keys, under a click on the page, and under a remote |
| 18 | // edit arriving through the CRDT, and a bar that only updates on its |
| 19 | // own buttons is wrong most of the time. |
| 20 | |
| 21 | import { Plugin } from 'prosemirror-state' |
| 22 | import { toggleMark, setBlockType, wrapIn, lift, selectAll, chainCommands } from 'prosemirror-commands' |
| 23 | import { wrapInList, liftListItem, sinkListItem } from 'prosemirror-schema-list' |
| 24 | import { undoCommand, redoCommand } from 'y-prosemirror' |
| 25 | import { setAttr, setProp } from './dom-writes.mjs' |
| 26 | import { setupRoving } from './toolbar-roving.mjs' |
| 27 | import { ALIGNMENTS, alignClass, setAlign, currentAlign } from './align.mjs' |
| 28 | import { FONT_MARKS, fontMark, fontForMarks } from './fonts.mjs' |
| 29 | import { changeIndent } from './indent.mjs' |
| 30 | import { SIZE_MARKS, sizeMark, sizeForMarks } from './sizes.mjs' |
| 31 | |
| 32 | // The two exclusive mark groups. A run is in one font and at one size, |
| 33 | // so choosing one has to clear the others — the schema's own |
| 34 | // `excludes` handles that for a toggle, but the select controls set a |
| 35 | // value rather than toggling, which is what setExclusive below is for. |
| 36 | // |
| 37 | // The font group is fonts.mjs's, not a second list here. It has to be |
| 38 | // every font mark including the default's, or clearing would leave the |
| 39 | // mark it missed in place and the run would be in two faces at once — |
| 40 | // and the default's mark is precisely the one a toolbar might think it |
| 41 | // could skip, since this toolbar never writes it. It is also the one |
| 42 | // most documents that predate the move actually carry: sans was a |
| 43 | // choice people made when it was not the default, so font_sans is out |
| 44 | // there in quantity. The size group has no such subtlety — "normal" has |
| 45 | // never had a mark — so it stays a literal beside its own use. |
| 46 | |
| 47 | |
| 48 | // The three alignment commands: each button's data-docs-command name, |
| 49 | // and the attribute value that button sets. |
| 50 | // |
| 51 | // A module-level table, not three entries inside buildCommands, because |
| 52 | // the guard below has to be able to check it with no schema and no |
| 53 | // browser — see alignmentsAreCovered. |
| 54 | // |
| 55 | // The names are literal keys, and that matters: internal/docs's |
| 56 | // TestToolbarOptionValuesMatchTheBundle checks that every |
| 57 | // data-docs-command in views/show.html is a name this file implements, |
| 58 | // and it does that by looking for the name in this file's source. A |
| 59 | // loop building "align_" + value would leave it nothing to find, so it |
| 60 | // would pass by seeing nothing rather than by agreeing with anything. |
| 61 | // |
| 62 | // Left is `null`, not the string "left": ProseMirror's default for the |
| 63 | // attribute is null and y-prosemirror removes a null attribute from the |
| 64 | // CRDT rather than storing it, so choosing left returns a paragraph to |
| 65 | // exactly the bytes it had before anyone aligned it. |
| 66 | export const ALIGN_COMMANDS = { |
| 67 | align_left: null, |
| 68 | align_center: 'center', |
| 69 | align_right: 'right', |
| 70 | } |
| 71 | |
| 72 | // Every alignment align.mjs offers has a command in that table, every |
| 73 | // command in it is one align.mjs offers, and each command's NAME |
| 74 | // matches the VALUE it sets. |
| 75 | // |
| 76 | // That last clause is the half nothing else can check. This guard's |
| 77 | // first version compared names only, and so does |
| 78 | // TestToolbarOptionValuesMatchTheBundle — so `align_center: 'centre'` |
| 79 | // had the right name, sat under the right button, satisfied both, and |
| 80 | // wrote an alignment that core/src/fragment.rs and both exporters drop |
| 81 | // on the floor. A person would click Centre and watch nothing happen, |
| 82 | // with no error anywhere. |
| 83 | // |
| 84 | // It runs on the line below, at module load, rather than inside |
| 85 | // createToolbar where it used to sit. That was the finding: nothing in |
| 86 | // the gate imports this module, and inside createToolbar it ran only |
| 87 | // when a browser actually mounted the editor — dead code in CI, so a |
| 88 | // mismatch the other tests missed would have passed the entire gate and |
| 89 | // then thrown at startup for every user. fixtures/align.test.mjs now |
| 90 | // imports this module, which is what makes the call below execute on |
| 91 | // every gate run. |
| 92 | // |
| 93 | // A module-load throw does abort the whole bundle, earlier than |
| 94 | // editor-entry.js's own try/catch, so [docs-editor] would not get its |
| 95 | // data-editor-failed marker — the page would keep the server-rendered |
| 96 | // read view without saying why. That is the right trade now that this |
| 97 | // cannot reach production without failing CI first, and it is stated |
| 98 | // here rather than left for someone to discover. |
| 99 | function alignmentsAreCovered() { |
| 100 | const names = Object.keys(ALIGN_COMMANDS) |
| 101 | for (const name of names) { |
| 102 | const value = ALIGN_COMMANDS[name] |
| 103 | const expected = 'align_' + (value === null ? 'left' : value) |
| 104 | if (name !== expected) { |
| 105 | throw new Error( |
| 106 | `toolbar: ${name} sets the alignment ${JSON.stringify(value)}, which belongs to ${expected}`, |
| 107 | ) |
| 108 | } |
| 109 | // alignClass, not a bare lookup: it is the allowlist, so a value |
| 110 | // align.mjs does not know is rejected here rather than stored. |
| 111 | if (value !== null && !alignClass(value)) { |
| 112 | throw new Error(`toolbar: ${name} sets ${JSON.stringify(value)}, which align.mjs does not know`) |
| 113 | } |
| 114 | } |
| 115 | const covered = names.map((name) => name.slice('align_'.length)).sort() |
| 116 | const offered = [...ALIGNMENTS].sort() |
| 117 | if (covered.join() !== offered.join()) { |
| 118 | throw new Error(`toolbar: alignment commands [${covered}] do not match align.mjs's [${offered}]`) |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | alignmentsAreCovered() |
| 123 | |
| 124 | // comboInputFor returns the text input ui/select.js built for a |
| 125 | // <select>, or null if the enhancement has not run (or was opted out |
| 126 | // of, or the script never loaded). |
| 127 | // |
| 128 | // select.js inserts its [rst-combo] wrapper immediately BEFORE the |
| 129 | // native select and leaves the select in place, visually hidden — see |
| 130 | // its own `native.parentNode.insertBefore(wrap, native)`. So the |
| 131 | // wrapper is the select's previous sibling, and that relationship is |
| 132 | // the only handle we get; there is no id linking the two. |
| 133 | // |
| 134 | // Null-safe everywhere it is used, because "the enhancement did not |
| 135 | // run" is a state this bar must keep working in: the whole point of |
| 136 | // select.js is that it enhances a real <select> which goes on working |
| 137 | // without it. |
| 138 | function comboInputFor(select) { |
| 139 | const wrap = select.previousElementSibling |
| 140 | if (!wrap || !wrap.hasAttribute('rst-combo')) return null |
| 141 | return wrap.querySelector('input') |
| 142 | } |
| 143 | |
| 144 | // nameCombo gives the combobox the name the select had. |
| 145 | // |
| 146 | // select.js transfers a <label for> onto its input, and nothing else. |
| 147 | // These three selects are named by aria-label — they sit in a toolbar |
| 148 | // with no visible labels — so without this the control a person |
| 149 | // actually reaches has no accessible name at all beyond its |
| 150 | // placeholder, and all three announce as "Type to filter". SC 4.1.2, |
| 151 | // and AGENTS.md allows no exemptions. |
| 152 | function nameCombo(select, input) { |
| 153 | if (!input) return |
| 154 | const name = select.getAttribute('aria-label') |
| 155 | if (name) setAttr(input, 'aria-label', name) |
| 156 | } |
| 157 | |
| 158 | // showInCombo keeps the combobox's visible text in step with the |
| 159 | // select's value. |
| 160 | // |
| 161 | // select.js writes input.value at enhancement, on a user's pick, and on |
| 162 | // blur — and never again. Everything in this file sets select.value |
| 163 | // programmatically, which fires no event and moves nothing a person can |
| 164 | // see: put the caret in a 24pt heading and the native select becomes |
| 165 | // "24" while the combobox still reads whatever was last chosen by hand. |
| 166 | // |
| 167 | // That is not cosmetic. The spec's argument for keeping Font and Size |
| 168 | // out of the Format menu was precisely that a person needs to SEE what |
| 169 | // they are in without opening anything, so a control that displays a |
| 170 | // stale value is the one failure this design cannot absorb. |
| 171 | // |
| 172 | // Skipped while the input has focus: overwriting what somebody is |
| 173 | // halfway through typing into the filter is worse than a stale display. |
| 174 | function showInCombo(select, input) { |
| 175 | if (!input || document.activeElement === input) return |
| 176 | const option = select.options[select.selectedIndex] |
| 177 | setProp(input, 'value', option ? option.text : '') |
| 178 | } |
| 179 | |
| 180 | // onSync, when given, is called after every sync with the live view. It |
| 181 | // is how the menu bar (fixtures/menu.mjs) keeps its items in step |
| 182 | // without a second ProseMirror plugin: one plugin means the two |
| 183 | // surfaces cannot disagree about the same selection, which they would |
| 184 | // eventually do if each watched the editor for itself. |
| 185 | export function createToolbar({ root, schema, getView, onSync }) { |
| 186 | // The command table is built even with no toolbar in the DOM, and |
| 187 | // returned either way — the menu bar reads it, and a template that |
| 188 | // renders a menu without a formatting bar (or a future one that drops |
| 189 | // the bar entirely) must still get working menu commands. |
| 190 | // focusStop is in the early return too, and its absence was a real |
| 191 | // bug for one commit: editor-entry.js binds Escape to it and its |
| 192 | // comment promises that with no toolbar there is "nowhere to go" and |
| 193 | // false comes back — but this branch returned no focusStop at all, so |
| 194 | // Escape would have thrown a TypeError inside ProseMirror's keymap. |
| 195 | // Unreachable today because show.html always renders the bar, which |
| 196 | // is exactly why a comment asserting the guarantee needed the code to |
| 197 | // hold it. toolbar-roving.mjs's own early return already did. |
| 198 | if (!root) return { plugin: null, commands: buildCommands(schema), focusStop: () => false } |
| 199 | |
| 200 | const buttons = Array.from(root.querySelectorAll('[data-docs-command]')) |
| 201 | const selects = Array.from(root.querySelectorAll('[data-docs-select]')) |
| 202 | |
| 203 | const commands = buildCommands(schema) |
| 204 | |
| 205 | for (const button of buttons) { |
| 206 | // Without this the browser blurs the editor on mousedown, which |
| 207 | // collapses the very selection the command is about to act on. |
| 208 | button.addEventListener('mousedown', (e) => e.preventDefault()) |
| 209 | button.addEventListener('click', () => { |
| 210 | const view = getView() |
| 211 | const run = commands[button.getAttribute('data-docs-command')] |
| 212 | if (!view || !run) return |
| 213 | run(view.state, view.dispatch, view) |
| 214 | view.focus() |
| 215 | }) |
| 216 | } |
| 217 | |
| 218 | for (const select of selects) { |
| 219 | select.addEventListener('mousedown', (e) => e.stopPropagation()) |
| 220 | // The caret goes back to the document after a choice, always. |
| 221 | // |
| 222 | // Every other control in this bar is a <button>, which cannot hold |
| 223 | // a caret, so nothing needed this before. Once ui/select.js has |
| 224 | // enhanced these, choosing a size leaves focus in a text input — |
| 225 | // and the next thing a person types goes into the font filter |
| 226 | // rather than into their document. The change handler below already |
| 227 | // dispatches; this is the other half of what a toolbar control owes |
| 228 | // the editor. |
| 229 | // |
| 230 | // On the native select too, not only the combobox: without the |
| 231 | // enhancement (a browser that never ran the script, or a future |
| 232 | // opt-out) the behaviour should be the same. |
| 233 | select.addEventListener('change', () => { |
| 234 | const view = getView() |
| 235 | if (view) view.focus() |
| 236 | }) |
| 237 | select.addEventListener('change', () => { |
| 238 | const view = getView() |
| 239 | if (!view) return |
| 240 | const kind = select.getAttribute('data-docs-select') |
| 241 | const value = select.value |
| 242 | if (kind === 'block') setBlock(schema, value)(view.state, view.dispatch) |
| 243 | // fontMark returns undefined for a face this build does not know, |
| 244 | // which must change nothing — null is a real instruction to clear |
| 245 | // the group, and treating the two alike would reset a person's |
| 246 | // font because a stale page offered an option that has since gone. |
| 247 | if (kind === 'font') { |
| 248 | const mark = fontMark(value) |
| 249 | if (mark !== undefined) setExclusive(schema, FONT_MARKS, mark)(view.state, view.dispatch) |
| 250 | } |
| 251 | // sizeMark, not 'size_' + value: it is the allowlist, and it |
| 252 | // returns undefined for a size this build does not offer where |
| 253 | // null means the default. The same undefined/null distinction |
| 254 | // fontMark makes on the line above, and for the same reason — a |
| 255 | // stale page sending an option this build dropped must not clear |
| 256 | // a person's size. |
| 257 | if (kind === 'size') { |
| 258 | const mark = sizeMark(value) |
| 259 | if (mark !== undefined) setExclusive(schema, SIZE_MARKS, mark)(view.state, view.dispatch) |
| 260 | } |
| 261 | view.focus() |
| 262 | }) |
| 263 | } |
| 264 | |
| 265 | function sync(view) { |
| 266 | // Every write below goes through setAttr/setProp, which write only |
| 267 | // when the value would change. sync runs on EVERY state update, so |
| 268 | // rewriting a value that is already there costs a style invalidation |
| 269 | // and a mutation record per control per keystroke — and shows up as a |
| 270 | // visible flash on the frame a hidden tab comes back. See |
| 271 | // fixtures/dom-writes.mjs for the measurement. |
| 272 | const state = view.state |
| 273 | for (const button of buttons) { |
| 274 | const name = button.getAttribute('data-docs-command') |
| 275 | const run = commands[name] |
| 276 | if (!run) continue |
| 277 | // A command called with no dispatch reports whether it would do |
| 278 | // anything, which is exactly "should this button be available". |
| 279 | // |
| 280 | // Every command in the table above must honour that contract — |
| 281 | // see buildCommands for the one that did not, and what it cost. |
| 282 | // A command that acts anyway turns this line, which runs on |
| 283 | // every single state update, into an edit nobody asked for. |
| 284 | const enabled = run(state, null, view) |
| 285 | setProp(button, 'disabled', !enabled) |
| 286 | const on = isActive(state, schema, name) |
| 287 | setAttr(button, 'aria-pressed', on ? 'true' : 'false') |
| 288 | } |
| 289 | for (const select of selects) { |
| 290 | // Enabled here, having been rendered disabled. views/show.html |
| 291 | // renders every control disabled so that a page with no working |
| 292 | // JavaScript offers nothing that silently does nothing when |
| 293 | // operated — the buttons were already doing this and the selects |
| 294 | // were not, so with scripting off a person could change "Font" |
| 295 | // to Monospace and watch the document ignore them. |
| 296 | setProp(select, 'disabled', false) |
| 297 | // And the combobox, if ui/select.js enhanced this select. |
| 298 | // |
| 299 | // The enhancement does not mirror `disabled` — the string does |
| 300 | // not appear in select.js at all. It mirrors value, options, |
| 301 | // optgroups, required and reset, and stops there. So without |
| 302 | // this line the page ships a control that looks usable before the |
| 303 | // editor has mounted, takes focus and typing, and drives a |
| 304 | // disabled select behind it. |
| 305 | // |
| 306 | // Looked up here rather than captured at mount: select.js runs |
| 307 | // from the shell's own deferred script and this bundle runs from |
| 308 | // a later one, so today the wrapper already exists by the time |
| 309 | // createToolbar runs — but that is an ordering between two files |
| 310 | // neither of which states it, and a lookup costs nothing. |
| 311 | const combo = comboInputFor(select) |
| 312 | if (combo) setProp(combo, 'disabled', false) |
| 313 | nameCombo(select, combo) |
| 314 | const kind = select.getAttribute('data-docs-select') |
| 315 | if (kind === 'block') setProp(select, 'value', currentBlock(state)) |
| 316 | // Not currentGroup: the font group's fallback is not a literal |
| 317 | // this file gets to choose. An unmarked run is in whatever face |
| 318 | // fonts.mjs says the default is, and that is the same answer |
| 319 | // views/show.html and internal/export/pdf.go have to give. |
| 320 | if (kind === 'font') setProp(select, 'value', currentFont(state, schema)) |
| 321 | // Not currentGroup, for the same reason the font control stopped |
| 322 | // using it: the fallback is not a literal this file gets to |
| 323 | // choose, and the answer for a legacy mark is not its own name. |
| 324 | // sizes.mjs maps size_lg to 14 so the control names a size that |
| 325 | // is on the ladder and in the list, rather than a value no option |
| 326 | // carries — which a <select> renders by showing nothing at all. |
| 327 | if (kind === 'size') { |
| 328 | setProp(select, 'value', String(sizeForMarks(activeMarkNames(state, schema, SIZE_MARKS)))) |
| 329 | } |
| 330 | // Every branch above sets select.value programmatically, which |
| 331 | // fires no event, so the combobox has to be told. See |
| 332 | // showInCombo — without this the enhanced control displays |
| 333 | // whatever was last chosen by hand while the document says |
| 334 | // otherwise. |
| 335 | showInCombo(select, comboInputFor(select)) |
| 336 | } |
| 337 | if (onSync) onSync(view) |
| 338 | } |
| 339 | |
| 340 | // The roving tabindex walks what a person can actually reach. |
| 341 | // |
| 342 | // For an enhanced select that is the combobox input, not the select: |
| 343 | // select.js gives the native one tabindex="-1" and aria-hidden="true" |
| 344 | // and hides it, so leaving it in this list would put an arrow-key |
| 345 | // stop on an element no one can see. Where the enhancement has not |
| 346 | // run, the select itself is still the control and still belongs here. |
| 347 | // |
| 348 | // Each select's stand-in takes that select's own place in the list, |
| 349 | // so this substitution changes nothing about the order. The order |
| 350 | // itself is buttons-then-selects, which is NOT the visual order — |
| 351 | // the three selects sit between undo/redo and bold on screen. That |
| 352 | // predates this change and is left alone here rather than fixed in |
| 353 | // passing; it is worth someone's attention, because arrow keys that |
| 354 | // jump around a bar are their own small bug. |
| 355 | const roving = setupRoving(root, [ |
| 356 | ...buttons, |
| 357 | ...selects.map((select) => comboInputFor(select) || select), |
| 358 | ]) |
| 359 | |
| 360 | // One Plugin instance, reused by every EditorState this page creates |
| 361 | // — a plugin is configuration, not state, so a resync's fresh state |
| 362 | // can take the same one. |
| 363 | const plugin = new Plugin({ |
| 364 | view: (view) => { |
| 365 | sync(view) |
| 366 | roving.ensureReachable() |
| 367 | return { |
| 368 | update: (v) => { |
| 369 | sync(v) |
| 370 | roving.ensureReachable() |
| 371 | }, |
| 372 | } |
| 373 | }, |
| 374 | }) |
| 375 | |
| 376 | // isPressed answers the menu bar's check column (menu.mjs): which |
| 377 | // paragraph style and which alignment the selection is in. Paragraph |
| 378 | // styles are named as the block select names them. |
| 379 | const isPressed = (state, name) => { |
| 380 | if (name.startsWith('block_')) return currentBlock(state) === name.slice('block_'.length) |
| 381 | return isActive(state, schema, name) |
| 382 | } |
| 383 | return { plugin, commands, focusStop: roving.focusStop, isPressed } |
| 384 | } |
| 385 | |
| 386 | function buildCommands(schema) { |
| 387 | const marks = schema.marks |
| 388 | const nodes = schema.nodes |
| 389 | return { |
| 390 | // undoCommand/redoCommand, NOT y-prosemirror's undo/redo. |
| 391 | // |
| 392 | // This distinction cost an evening, so it is written down. A |
| 393 | // ProseMirror command's contract is that calling it with a null |
| 394 | // dispatch performs nothing and only reports whether it could — |
| 395 | // which is how `sync` below decides whether to grey a button out. |
| 396 | // y-prosemirror's exported `undo` does not honour that contract: |
| 397 | // |
| 398 | // export const undo = state => |
| 399 | // yUndoPluginKey.getState(state)?.undoManager?.undo() != null |
| 400 | // |
| 401 | // It ignores dispatch entirely and undoes immediately. So a |
| 402 | // toolbar that asked "could I undo?" on every state update — which |
| 403 | // is exactly what keeping a button's disabled state honest means — |
| 404 | // performed a real undo on every state update, and every edit |
| 405 | // anyone made was silently reverted a moment later. Typing did |
| 406 | // nothing. Bold did nothing. There was no error, because nothing |
| 407 | // went wrong: the button asked a question and the library answered |
| 408 | // it by doing the thing. |
| 409 | // |
| 410 | // undoCommand/redoCommand are the library's own dispatch-aware |
| 411 | // pair (canUndo()/canRedo() when dispatch is null), and they are |
| 412 | // what a command table read by `sync` must use. The keymap in |
| 413 | // editor-entry.js keeps the bare undo/redo, correctly: a keymap |
| 414 | // never calls a command with a null dispatch. |
| 415 | undo: undoCommand, |
| 416 | redo: redoCommand, |
| 417 | strong: toggleMark(marks.strong), |
| 418 | em: toggleMark(marks.em), |
| 419 | code: toggleMark(marks.code), |
| 420 | bullet_list: toggleList(nodes.bullet_list, nodes.list_item), |
| 421 | ordered_list: toggleList(nodes.ordered_list, nodes.list_item), |
| 422 | blockquote: toggleWrap(nodes.blockquote), |
| 423 | // The two menu-only commands. They live here rather than in |
| 424 | // fixtures/menu.mjs because this is the command table both surfaces |
| 425 | // read, and a command implemented beside one of them is a command |
| 426 | // the other cannot offer — which is the duplication the menu was |
| 427 | // supposed to avoid. Neither has a toolbar button today; both would |
| 428 | // be reasonable ones later, and that costs a line of markup rather |
| 429 | // than a move. |
| 430 | select_all: selectAll, |
| 431 | clear_formatting: clearFormatting, |
| 432 | // The paragraph styles, as commands. |
| 433 | // |
| 434 | // The Style select already sets these, through setBlock and its own |
| 435 | // change handler — but a select is not reachable as a command, and |
| 436 | // the Format menu needs them by name. Rather than a second |
| 437 | // implementation, each of these IS setBlock, bound to one of the |
| 438 | // select's own option values, so the two controls cannot come to |
| 439 | // mean different things. |
| 440 | // |
| 441 | // Names are written out rather than built in a loop, for the reason |
| 442 | // ALIGN_COMMANDS' comment gives at length: internal/docs's tests |
| 443 | // look for the literal name in this file's source, and a loop would |
| 444 | // leave them nothing to find, so they would pass by seeing nothing. |
| 445 | block_paragraph: setBlock(schema, 'paragraph'), |
| 446 | 'block_heading-1': setBlock(schema, 'heading-1'), |
| 447 | 'block_heading-2': setBlock(schema, 'heading-2'), |
| 448 | 'block_heading-3': setBlock(schema, 'heading-3'), |
| 449 | // Indent and outdent. The same two commands the Tab key runs |
| 450 | // (editor-entry.js's keymap), chained the same way so the menu and |
| 451 | // the key cannot disagree about what Tab does in a list: nest the |
| 452 | // item if there is one, step the block's indent otherwise. |
| 453 | indent: chainCommands(sinkListItem(nodes.list_item), changeIndent(1)), |
| 454 | outdent: chainCommands(liftListItem(nodes.list_item), changeIndent(-1)), |
| 455 | // The three alignments, from the module-level table above — the |
| 456 | // one place their names and their values are written down. |
| 457 | ...Object.fromEntries( |
| 458 | Object.entries(ALIGN_COMMANDS).map(([name, value]) => [name, setAlign(value)]), |
| 459 | ), |
| 460 | } |
| 461 | } |
| 462 | |
| 463 | // clearFormatting strips every mark from the selection and returns its |
| 464 | // blocks to plain paragraphs — the thing a person reaches for after a |
| 465 | // bad paste, and the one command in this table that is defined by what |
| 466 | // it removes rather than by what it applies. |
| 467 | // |
| 468 | // It honours the null-dispatch contract the whole table is held to (see |
| 469 | // buildCommands' own comment): with a collapsed selection there is |
| 470 | // nothing to strip and nothing to report, so it says no rather than |
| 471 | // quietly clearing the stored marks a person just set up for their next |
| 472 | // word. |
| 473 | // |
| 474 | // removeMark(from, to, null) removes ALL marks in the range, which is |
| 475 | // deliberately not a loop over the schema's mark types: the CRDT can |
| 476 | // carry a mark this build's schema has never heard of — a document |
| 477 | // written by a later version, or bytes posted straight to |
| 478 | // /d/{sqid}/updates — and a loop over known types would leave exactly |
| 479 | // those in place while telling a person the formatting was cleared. |
| 480 | // |
| 481 | // The block reset is separate and comes second, because setBlockType |
| 482 | // maps over the range and would otherwise be undone by the mark |
| 483 | // removal's own mapping. |
| 484 | function clearFormatting(state, dispatch) { |
| 485 | const { from, to, empty } = state.selection |
| 486 | if (empty) return false |
| 487 | if (dispatch) { |
| 488 | const tr = state.tr.removeMark(from, to, null) |
| 489 | tr.setBlockType(from, to, state.schema.nodes.paragraph) |
| 490 | dispatch(tr.scrollIntoView()) |
| 491 | } |
| 492 | return true |
| 493 | } |
| 494 | |
| 495 | // setBlock turns the block containing the selection into a paragraph |
| 496 | // or a heading. The select's values are "paragraph" and "heading-N". |
| 497 | function setBlock(schema, value) { |
| 498 | if (value === 'paragraph') return setBlockType(schema.nodes.paragraph) |
| 499 | const level = Number(value.split('-')[1]) |
| 500 | if (!Number.isInteger(level)) return () => false |
| 501 | return setBlockType(schema.nodes.heading, { level }) |
| 502 | } |
| 503 | |
| 504 | // setExclusive clears every mark in a group and then applies one of |
| 505 | // them (or none). One transaction, not two: internal/core caps the |
| 506 | // retained update log at 4096 with no compaction yet, and every |
| 507 | // transaction the editor dispatches becomes an update in it, so a |
| 508 | // control that spent two per click would spend that budget twice as |
| 509 | // fast for no benefit a person can see. |
| 510 | function setExclusive(schema, group, wanted) { |
| 511 | return (state, dispatch) => { |
| 512 | const types = group.map((n) => schema.marks[n]).filter(Boolean) |
| 513 | const target = wanted ? schema.marks[wanted] : null |
| 514 | if (wanted && !target) return false |
| 515 | if (!dispatch) return true |
| 516 | |
| 517 | const { from, to, empty } = state.selection |
| 518 | if (empty) { |
| 519 | // No selection: the choice applies to whatever is typed next, |
| 520 | // which ProseMirror models as stored marks rather than as a |
| 521 | // change to the document. |
| 522 | let stored = (state.storedMarks || state.selection.$from.marks()).filter( |
| 523 | (m) => !types.includes(m.type), |
| 524 | ) |
| 525 | if (target) stored = target.create().addToSet(stored) |
| 526 | dispatch(state.tr.setStoredMarks(stored)) |
| 527 | return true |
| 528 | } |
| 529 | |
| 530 | const tr = state.tr |
| 531 | for (const type of types) tr.removeMark(from, to, type) |
| 532 | if (target) tr.addMark(from, to, target.create()) |
| 533 | dispatch(tr) |
| 534 | return true |
| 535 | } |
| 536 | } |
| 537 | |
| 538 | // toggleList wraps the selection in a list, or lifts it out if it is |
| 539 | // already in one — a list button that only ever wraps is a button you |
| 540 | // cannot undo without reaching for the keyboard. |
| 541 | function toggleList(listType, itemType) { |
| 542 | const wrap = wrapInList(listType) |
| 543 | const unwrap = liftListItem(itemType) |
| 544 | return (state, dispatch, view) => { |
| 545 | if (inside(state, listType)) return unwrap(state, dispatch, view) |
| 546 | return wrap(state, dispatch, view) |
| 547 | } |
| 548 | } |
| 549 | |
| 550 | function toggleWrap(nodeType) { |
| 551 | const wrap = wrapIn(nodeType) |
| 552 | return (state, dispatch, view) => { |
| 553 | if (inside(state, nodeType)) return lift(state, dispatch, view) |
| 554 | return wrap(state, dispatch, view) |
| 555 | } |
| 556 | } |
| 557 | |
| 558 | // inside reports whether the selection sits anywhere within a node of |
| 559 | // this type. |
| 560 | function inside(state, nodeType) { |
| 561 | const $from = state.selection.$from |
| 562 | for (let d = $from.depth; d > 0; d--) { |
| 563 | if ($from.node(d).type === nodeType) return true |
| 564 | } |
| 565 | return false |
| 566 | } |
| 567 | |
| 568 | function isActive(state, schema, name) { |
| 569 | // Exactly one of the three is pressed at any moment, and it is the |
| 570 | // alignment the block will actually RENDER at — currentAlign reports |
| 571 | // left for a value this build does not know, because a button |
| 572 | // pressed for something the reader will never see would be a lie |
| 573 | // about the document. |
| 574 | if (name.startsWith('align_')) return currentAlign(state) === name.slice('align_'.length) |
| 575 | if (name === 'bullet_list') return inside(state, schema.nodes.bullet_list) |
| 576 | if (name === 'ordered_list') return inside(state, schema.nodes.ordered_list) |
| 577 | if (name === 'blockquote') return inside(state, schema.nodes.blockquote) |
| 578 | const type = schema.marks[name] |
| 579 | if (!type) return false |
| 580 | return markActive(state, type) |
| 581 | } |
| 582 | |
| 583 | // markActive is ProseMirror's own idiom: for an empty selection the |
| 584 | // answer is about the marks that would apply to the next keystroke |
| 585 | // (stored marks, or the marks at the cursor), not about the document. |
| 586 | function markActive(state, type) { |
| 587 | const { from, $from, to, empty } = state.selection |
| 588 | if (empty) return !!type.isInSet(state.storedMarks || $from.marks()) |
| 589 | return state.doc.rangeHasMark(from, to, type) |
| 590 | } |
| 591 | |
| 592 | // currentBlock is what the block select should read. A selection |
| 593 | // spanning two different block types reports the one it starts in |
| 594 | // rather than blanking the control: a select with no value looks |
| 595 | // broken, and the first block is the one a person's cursor is in. |
| 596 | function currentBlock(state) { |
| 597 | const parent = state.selection.$from.parent |
| 598 | if (parent.type.name === 'heading') return 'heading-' + parent.attrs.level |
| 599 | return 'paragraph' |
| 600 | } |
| 601 | |
| 602 | // currentFont is what the font select should read: the face the |
| 603 | // selection is actually in, which for a run carrying no font mark is |
| 604 | // the default rather than "nothing". |
| 605 | // |
| 606 | // The reading is split from the answering on purpose. This function |
| 607 | // knows about ProseMirror state and nothing about which face is the |
| 608 | // default; fonts.mjs's fontForMarks knows which face is the default and |
| 609 | // nothing about ProseMirror. That is what lets the default be pinned by |
| 610 | // a test that needs no editor (fonts.test.mjs) and, through it, held |
| 611 | // equal to the stylesheet's and the exporter's (internal/docs's |
| 612 | // TestTheToolbarDefaultFontIsTheDefaultFace). |
| 613 | function currentFont(state, schema) { |
| 614 | const active = FONT_MARKS.filter((name) => { |
| 615 | const type = schema.marks[name] |
| 616 | return type && markActive(state, type) |
| 617 | }) |
| 618 | return fontForMarks(active) |
| 619 | } |
| 620 | |
| 621 | // currentGroup reads which of an exclusive mark group applies, as the |
| 622 | // select's own value. A selection carrying two different sizes reports |
| 623 | // the fallback rather than picking one of them arbitrarily. |
| 624 | // |
| 625 | // Only the size control uses it now: "normal" is a fallback this file |
| 626 | // can name, because unlike the default FACE nothing outside the editor |
| 627 | // has an opinion about it — no stylesheet rule and no exporter constant |
| 628 | // says what "normal" is, the body size says it. |
| 629 | function currentGroup(state, schema, group, prefix, fallback) { |
| 630 | for (const name of group) { |
| 631 | const type = schema.marks[name] |
| 632 | if (type && markActive(state, type)) return name.slice(prefix.length) |
| 633 | } |
| 634 | return fallback |
| 635 | } |
| 636 | |
| 637 | // activeMarkNames is currentGroup's raw half: which of a group's marks |
| 638 | // are on the selection, in the group's own order, without deciding what |
| 639 | // they mean. |
| 640 | // |
| 641 | // It exists because the size group stopped being answerable by string |
| 642 | // surgery. currentGroup returns the mark's name with its prefix cut |
| 643 | // off, which was the whole answer while the sizes were called sm, lg |
| 644 | // and xl — the control's option values were those same three strings. |
| 645 | // They are numbers now, and three legacy marks map onto the ladder |
| 646 | // rather than naming themselves, so the mapping is sizes.mjs's job. |
| 647 | // This hands it the names and lets sizeForMarks answer. |
| 648 | function activeMarkNames(state, schema, group) { |
| 649 | const out = [] |
| 650 | for (const name of group) { |
| 651 | const type = schema.marks[name] |
| 652 | if (type && markActive(state, type)) out.push(name) |
| 653 | } |
| 654 | return out |
| 655 | } |
| 656 | |