oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
38export 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.
47export 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.
54export 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.
63export 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.
82export 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.
116export 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.
146export 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