// fixtures/align.mjs — paragraph alignment, in one place. // // The first block-level formatting this editor has. Everything else the // toolbar does is a mark: attribute-free, its presence its whole // meaning, rendered as a span with a fixed class (see // core/src/fragment.rs's MARKS table, and editor-entry.js's spanMark). // Alignment cannot be one of those — it belongs to the block, not to // the run — so it is a node attribute, and a node attribute carries a // value. // // That is why this module exists rather than three lines inlined in the // schema. The value has to be a member of a CLOSED set at every point // it is used, and it is used in four places that must agree: // // - editor-entry.js's schema, which writes it into the CRDT // (y-prosemirror stores a node's attributes as Yjs XML attributes, // so an aligned paragraph genuinely changes what the document // contains) and reads it back off a pasted
; // - toolbar.mjs's three controls, which set and report it; // - core/src/fragment.rs's ALIGNMENTS table, which renders it for a // reader with no JavaScript; // - internal/export/pdf.go's alignOf, which lays it out on paper. // // The last two are separate binaries and cannot import this. What keeps // all four honest is that none of them ever emits the value: each maps // it through its own copy of the same closed table, and anything not in // that table is dropped. On the Rust side that is load-bearing for // safety as well as tidiness — fragment_html's output goes into the // page unescaped, so a value reaching attribute position would be an // injection. Here it is load-bearing for agreement: a value this table // does not know would be stored, rendered by nothing, and lost on the // next export. // // left is deliberately not a member. A paragraph with no `align` is // left, ProseMirror stores the attribute as null, y-prosemirror skips // null attributes entirely, and so a document nobody has aligned is // byte-identical to one written before this module existed. export const ALIGN_CLASSES = { center: 'docs-align-center', right: 'docs-align-right', } // The alignments a person can choose, in the order the toolbar shows // them. 'left' is here and not in ALIGN_CLASSES for the reason above: // it is a choice a person can make, and it is spelled as the absence of // the attribute. export const ALIGNMENTS = ['left', 'center', 'right'] // alignClass maps an alignment to the class the read view styles, or // null for left, for an absent value, and for anything unrecognised — // the three cases that all render the same way. hasOwnProperty rather // than a bare lookup: `align: 'constructor'` would otherwise find // something on Object.prototype and put it in a class attribute. export function alignClass(value) { if (!Object.prototype.hasOwnProperty.call(ALIGN_CLASSES, value)) return null return ALIGN_CLASSES[value] } // alignFromClasses is the inverse, for a paste: given the class names on // a
, which alignment (if any) did it carry. Returns null for left, // which is also the attribute's default, so a plain
and a // left-aligned one parse to the same node. export function alignFromClasses(names) { for (const [value, cls] of Object.entries(ALIGN_CLASSES)) { if (names.includes(cls)) return value } return null } // alignedParagraph takes prosemirror-schema-basic's own paragraph spec // and returns it with the attribute added. // // Built from the base spec rather than written out, so content, group // and anything a future prosemirror-schema-basic adds stay whatever // that package says they are — this file's business is the one // attribute. // // One parseDOM rule, not three. Order between rules of equal priority // is a subtlety nobody should have to remember (a bare `{tag: 'p'}` // listed first would match a centred paragraph and swallow its class), // and one rule with a getAttrs cannot have that bug. export function alignedParagraph(base) { return { ...base, attrs: { ...(base.attrs || {}), align: { default: null } }, parseDOM: [ { tag: 'p', getAttrs: (dom) => ({ align: alignFromClasses(Array.from(dom.classList || [])), }), }, ], toDOM: (node) => { const cls = alignClass(node.attrs.align) return cls ? ['p', { class: cls }, 0] : ['p', 0] }, } } // setAlign is the toolbar's command: set (or clear) the alignment of // every textblock in the selection that has the attribute. // // It honours ProseMirror's dispatch contract exactly — called with a // null dispatch it performs NOTHING and only reports whether it could. // toolbar.mjs's sync calls every command that way on every single state // update to decide whether to grey its button out, and a command that // acted anyway would edit the document continuously; see buildCommands' // own comment for the evening that cost. state.tr below builds a // transaction and, without the dispatch, throws it away. // // setNodeMarkup and not a wholesale replace: the node keeps its type, // its marks and its content, and the replacement is the same size as // what it replaces — so the positions gathered from the original // document stay valid across the whole loop. export function setAlign(value) { return (state, dispatch) => { const { from, to } = state.selection let applicable = false const tr = state.tr state.doc.nodesBetween(from, to, (node, pos) => { if (!node.type.isTextblock) return if (!node.type.spec.attrs || !node.type.spec.attrs.align) return applicable = true if (dispatch) { tr.setNodeMarkup(pos, null, { ...node.attrs, align: value }) } }) if (!applicable) return false if (dispatch) dispatch(tr) return true } } // currentAlign is what the toolbar's buttons read: the alignment of the // block the cursor is in. // // The block it STARTS in, when a selection spans two blocks aligned // differently — the same choice currentBlock makes in toolbar.mjs, and // for the same reason: three buttons with none pressed reads as broken, // and the first block is the one the cursor is in. // // Normalised to 'left' rather than left as null, because this feeds a // comparison against a button's name and 'left' is what that button is // called. export function currentAlign(state) { const parent = state.selection.$from.parent const value = parent.attrs && parent.attrs.align return alignClass(value) ? value : 'left' }