// fixtures/toolbar.mjs — the formatting bar. // // Chrome, so it lives outside [docs-editor]: ProseMirror's {mount: el} // takes over everything inside that element, and a toolbar rendered // inside it would be replaced by the document on the first mount. // views/show.html renders the markup, server-side, with every label // resolved from the catalog — this module carries no copy of its own, // the same rule the save-status indicator already follows. // // It never holds an EditorView. attemptResync (editor-entry.js) // destroys and rebuilds the view wholesale, so anything holding one // dispatches into a dead view after a resync; every command here reads // getView() at the moment it runs instead. // // The button state is kept in step by a ProseMirror plugin rather than // by listening for clicks and keystrokes: the selection also moves // under the arrow keys, under a click on the page, and under a remote // edit arriving through the CRDT, and a bar that only updates on its // own buttons is wrong most of the time. import { Plugin } from 'prosemirror-state' import { toggleMark, setBlockType, wrapIn, lift, selectAll, chainCommands } from 'prosemirror-commands' import { wrapInList, liftListItem, sinkListItem } from 'prosemirror-schema-list' import { undoCommand, redoCommand } from 'y-prosemirror' import { setAttr, setProp } from './dom-writes.mjs' import { setupRoving } from './toolbar-roving.mjs' import { ALIGNMENTS, alignClass, setAlign, currentAlign } from './align.mjs' import { FONT_MARKS, fontMark, fontForMarks } from './fonts.mjs' import { changeIndent } from './indent.mjs' import { SIZE_MARKS, sizeMark, sizeForMarks } from './sizes.mjs' // The two exclusive mark groups. A run is in one font and at one size, // so choosing one has to clear the others — the schema's own // `excludes` handles that for a toggle, but the select controls set a // value rather than toggling, which is what setExclusive below is for. // // The font group is fonts.mjs's, not a second list here. It has to be // every font mark including the default's, or clearing would leave the // mark it missed in place and the run would be in two faces at once — // and the default's mark is precisely the one a toolbar might think it // could skip, since this toolbar never writes it. It is also the one // most documents that predate the move actually carry: sans was a // choice people made when it was not the default, so font_sans is out // there in quantity. The size group has no such subtlety — "normal" has // never had a mark — so it stays a literal beside its own use. // The three alignment commands: each button's data-docs-command name, // and the attribute value that button sets. // // A module-level table, not three entries inside buildCommands, because // the guard below has to be able to check it with no schema and no // browser — see alignmentsAreCovered. // // The names are literal keys, and that matters: internal/docs's // TestToolbarOptionValuesMatchTheBundle checks that every // data-docs-command in views/show.html is a name this file implements, // and it does that by looking for the name in this file's source. A // loop building "align_" + value would leave it nothing to find, so it // would pass by seeing nothing rather than by agreeing with anything. // // Left is `null`, not the string "left": ProseMirror's default for the // attribute is null and y-prosemirror removes a null attribute from the // CRDT rather than storing it, so choosing left returns a paragraph to // exactly the bytes it had before anyone aligned it. export const ALIGN_COMMANDS = { align_left: null, align_center: 'center', align_right: 'right', } // Every alignment align.mjs offers has a command in that table, every // command in it is one align.mjs offers, and each command's NAME // matches the VALUE it sets. // // That last clause is the half nothing else can check. This guard's // first version compared names only, and so does // TestToolbarOptionValuesMatchTheBundle — so `align_center: 'centre'` // had the right name, sat under the right button, satisfied both, and // wrote an alignment that core/src/fragment.rs and both exporters drop // on the floor. A person would click Centre and watch nothing happen, // with no error anywhere. // // It runs on the line below, at module load, rather than inside // createToolbar where it used to sit. That was the finding: nothing in // the gate imports this module, and inside createToolbar it ran only // when a browser actually mounted the editor — dead code in CI, so a // mismatch the other tests missed would have passed the entire gate and // then thrown at startup for every user. fixtures/align.test.mjs now // imports this module, which is what makes the call below execute on // every gate run. // // A module-load throw does abort the whole bundle, earlier than // editor-entry.js's own try/catch, so [docs-editor] would not get its // data-editor-failed marker — the page would keep the server-rendered // read view without saying why. That is the right trade now that this // cannot reach production without failing CI first, and it is stated // here rather than left for someone to discover. function alignmentsAreCovered() { const names = Object.keys(ALIGN_COMMANDS) for (const name of names) { const value = ALIGN_COMMANDS[name] const expected = 'align_' + (value === null ? 'left' : value) if (name !== expected) { throw new Error( `toolbar: ${name} sets the alignment ${JSON.stringify(value)}, which belongs to ${expected}`, ) } // alignClass, not a bare lookup: it is the allowlist, so a value // align.mjs does not know is rejected here rather than stored. if (value !== null && !alignClass(value)) { throw new Error(`toolbar: ${name} sets ${JSON.stringify(value)}, which align.mjs does not know`) } } const covered = names.map((name) => name.slice('align_'.length)).sort() const offered = [...ALIGNMENTS].sort() if (covered.join() !== offered.join()) { throw new Error(`toolbar: alignment commands [${covered}] do not match align.mjs's [${offered}]`) } } alignmentsAreCovered() // comboInputFor returns the text input ui/select.js built for a // which goes on working // without it. function comboInputFor(select) { const wrap = select.previousElementSibling if (!wrap || !wrap.hasAttribute('rst-combo')) return null return wrap.querySelector('input') } // nameCombo gives the combobox the name the select had. // // select.js transfers a