oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

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
21import { Plugin } from 'prosemirror-state'
22import { toggleMark, setBlockType, wrapIn, lift, selectAll, chainCommands } from 'prosemirror-commands'
23import { wrapInList, liftListItem, sinkListItem } from 'prosemirror-schema-list'
24import { undoCommand, redoCommand } from 'y-prosemirror'
25import { setAttr, setProp } from './dom-writes.mjs'
26import { setupRoving } from './toolbar-roving.mjs'
27import { ALIGNMENTS, alignClass, setAlign, currentAlign } from './align.mjs'
28import { FONT_MARKS, fontMark, fontForMarks } from './fonts.mjs'
29import { changeIndent } from './indent.mjs'
30import { 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.
66export 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.
99function 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
122alignmentsAreCovered()
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.
138function 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.
152function 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.
174function 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.
185export 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
386function 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.
484function 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".
497function 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.
510function 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.
541function 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
550function 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.
560function 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
568function 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.
586function 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.
596function 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).
613function 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.
629function 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.
648function 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