| 1 | // fixtures/align.mjs — paragraph alignment, in one place. |
| 2 | // |
| 3 | // The first block-level formatting this editor has. Everything else the |
| 4 | // toolbar does is a mark: attribute-free, its presence its whole |
| 5 | // meaning, rendered as a span with a fixed class (see |
| 6 | // core/src/fragment.rs's MARKS table, and editor-entry.js's spanMark). |
| 7 | // Alignment cannot be one of those — it belongs to the block, not to |
| 8 | // the run — so it is a node attribute, and a node attribute carries a |
| 9 | // value. |
| 10 | // |
| 11 | // That is why this module exists rather than three lines inlined in the |
| 12 | // schema. The value has to be a member of a CLOSED set at every point |
| 13 | // it is used, and it is used in four places that must agree: |
| 14 | // |
| 15 | // - editor-entry.js's schema, which writes it into the CRDT |
| 16 | // (y-prosemirror stores a node's attributes as Yjs XML attributes, |
| 17 | // so an aligned paragraph genuinely changes what the document |
| 18 | // contains) and reads it back off a pasted <p>; |
| 19 | // - toolbar.mjs's three controls, which set and report it; |
| 20 | // - core/src/fragment.rs's ALIGNMENTS table, which renders it for a |
| 21 | // reader with no JavaScript; |
| 22 | // - internal/export/pdf.go's alignOf, which lays it out on paper. |
| 23 | // |
| 24 | // The last two are separate binaries and cannot import this. What keeps |
| 25 | // all four honest is that none of them ever emits the value: each maps |
| 26 | // it through its own copy of the same closed table, and anything not in |
| 27 | // that table is dropped. On the Rust side that is load-bearing for |
| 28 | // safety as well as tidiness — fragment_html's output goes into the |
| 29 | // page unescaped, so a value reaching attribute position would be an |
| 30 | // injection. Here it is load-bearing for agreement: a value this table |
| 31 | // does not know would be stored, rendered by nothing, and lost on the |
| 32 | // next export. |
| 33 | // |
| 34 | // left is deliberately not a member. A paragraph with no `align` is |
| 35 | // left, ProseMirror stores the attribute as null, y-prosemirror skips |
| 36 | // null attributes entirely, and so a document nobody has aligned is |
| 37 | // byte-identical to one written before this module existed. |
| 38 | export const ALIGN_CLASSES = { |
| 39 | center: 'docs-align-center', |
| 40 | right: 'docs-align-right', |
| 41 | } |
| 42 | |
| 43 | // The alignments a person can choose, in the order the toolbar shows |
| 44 | // them. 'left' is here and not in ALIGN_CLASSES for the reason above: |
| 45 | // it is a choice a person can make, and it is spelled as the absence of |
| 46 | // the attribute. |
| 47 | export const ALIGNMENTS = ['left', 'center', 'right'] |
| 48 | |
| 49 | // alignClass maps an alignment to the class the read view styles, or |
| 50 | // null for left, for an absent value, and for anything unrecognised — |
| 51 | // the three cases that all render the same way. hasOwnProperty rather |
| 52 | // than a bare lookup: `align: 'constructor'` would otherwise find |
| 53 | // something on Object.prototype and put it in a class attribute. |
| 54 | export function alignClass(value) { |
| 55 | if (!Object.prototype.hasOwnProperty.call(ALIGN_CLASSES, value)) return null |
| 56 | return ALIGN_CLASSES[value] |
| 57 | } |
| 58 | |
| 59 | // alignFromClasses is the inverse, for a paste: given the class names on |
| 60 | // a <p>, which alignment (if any) did it carry. Returns null for left, |
| 61 | // which is also the attribute's default, so a plain <p> and a |
| 62 | // left-aligned one parse to the same node. |
| 63 | export function alignFromClasses(names) { |
| 64 | for (const [value, cls] of Object.entries(ALIGN_CLASSES)) { |
| 65 | if (names.includes(cls)) return value |
| 66 | } |
| 67 | return null |
| 68 | } |
| 69 | |
| 70 | // alignedParagraph takes prosemirror-schema-basic's own paragraph spec |
| 71 | // and returns it with the attribute added. |
| 72 | // |
| 73 | // Built from the base spec rather than written out, so content, group |
| 74 | // and anything a future prosemirror-schema-basic adds stay whatever |
| 75 | // that package says they are — this file's business is the one |
| 76 | // attribute. |
| 77 | // |
| 78 | // One parseDOM rule, not three. Order between rules of equal priority |
| 79 | // is a subtlety nobody should have to remember (a bare `{tag: 'p'}` |
| 80 | // listed first would match a centred paragraph and swallow its class), |
| 81 | // and one rule with a getAttrs cannot have that bug. |
| 82 | export function alignedParagraph(base) { |
| 83 | return { |
| 84 | ...base, |
| 85 | attrs: { ...(base.attrs || {}), align: { default: null } }, |
| 86 | parseDOM: [ |
| 87 | { |
| 88 | tag: 'p', |
| 89 | getAttrs: (dom) => ({ |
| 90 | align: alignFromClasses(Array.from(dom.classList || [])), |
| 91 | }), |
| 92 | }, |
| 93 | ], |
| 94 | toDOM: (node) => { |
| 95 | const cls = alignClass(node.attrs.align) |
| 96 | return cls ? ['p', { class: cls }, 0] : ['p', 0] |
| 97 | }, |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | // setAlign is the toolbar's command: set (or clear) the alignment of |
| 102 | // every textblock in the selection that has the attribute. |
| 103 | // |
| 104 | // It honours ProseMirror's dispatch contract exactly — called with a |
| 105 | // null dispatch it performs NOTHING and only reports whether it could. |
| 106 | // toolbar.mjs's sync calls every command that way on every single state |
| 107 | // update to decide whether to grey its button out, and a command that |
| 108 | // acted anyway would edit the document continuously; see buildCommands' |
| 109 | // own comment for the evening that cost. state.tr below builds a |
| 110 | // transaction and, without the dispatch, throws it away. |
| 111 | // |
| 112 | // setNodeMarkup and not a wholesale replace: the node keeps its type, |
| 113 | // its marks and its content, and the replacement is the same size as |
| 114 | // what it replaces — so the positions gathered from the original |
| 115 | // document stay valid across the whole loop. |
| 116 | export function setAlign(value) { |
| 117 | return (state, dispatch) => { |
| 118 | const { from, to } = state.selection |
| 119 | let applicable = false |
| 120 | const tr = state.tr |
| 121 | state.doc.nodesBetween(from, to, (node, pos) => { |
| 122 | if (!node.type.isTextblock) return |
| 123 | if (!node.type.spec.attrs || !node.type.spec.attrs.align) return |
| 124 | applicable = true |
| 125 | if (dispatch) { |
| 126 | tr.setNodeMarkup(pos, null, { ...node.attrs, align: value }) |
| 127 | } |
| 128 | }) |
| 129 | if (!applicable) return false |
| 130 | if (dispatch) dispatch(tr) |
| 131 | return true |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | // currentAlign is what the toolbar's buttons read: the alignment of the |
| 136 | // block the cursor is in. |
| 137 | // |
| 138 | // The block it STARTS in, when a selection spans two blocks aligned |
| 139 | // differently — the same choice currentBlock makes in toolbar.mjs, and |
| 140 | // for the same reason: three buttons with none pressed reads as broken, |
| 141 | // and the first block is the one the cursor is in. |
| 142 | // |
| 143 | // Normalised to 'left' rather than left as null, because this feeds a |
| 144 | // comparison against a button's name and 'left' is what that button is |
| 145 | // called. |
| 146 | export function currentAlign(state) { |
| 147 | const parent = state.selection.$from.parent |
| 148 | const value = parent.attrs && parent.attrs.align |
| 149 | return alignClass(value) ? value : 'left' |
| 150 | } |
| 151 | |