oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1// fixtures/indent.mjs — paragraph indent, and what the Tab key does.
2//
3// The second block-level formatting this editor has, and it follows
4// align.mjs's shape deliberately rather than inventing a second one:
5// read that module's head comment first, because every argument it
6// makes about a closed set agreed across four places that cannot import
7// each other applies here unchanged.
8//
9// One difference, and it is the interesting one. Alignment's value is a
10// STRING from a closed set of three, so fragment.rs maps it through a
11// table and drops anything else. Indent's value is a NUMBER, so it is
12// handled the way core/src/fragment.rs already handles heading.level:
13// parsed as an integer and clamped to the allowed range. That is a
14// different mechanism reaching the same guarantee — no byte from a
15// document reaches attribute position — and it matters that it is the
16// existing one rather than a new one, because the safety property
17// fragment_html's unescaped output depends on is the thing this repo
18// can least afford to have two versions of.
19//
20// MAX_INDENT is 8 for the same reason word processors stop somewhere:
21// past eight half-inch steps a paragraph on US Letter has no measure
22// left. It is a clamp, not a validation — a document arriving with
23// indent 400 through POST /d/{sqid}/updates renders at 8 rather than
24// being rejected, which is the same forgiving-read choice heading.level
25// already makes.
26export const MAX_INDENT = 8
27
28// The step, in inches, per level. Google Docs, Word and Pages all use a
29// half inch, and this is the one number that has to be the same on
30// screen and on paper: views/show.html's .docs-indent-N rules and
31// internal/export/pdf.go's indentOf both restate it, and
32// TestPageGeometryMatchesTheStylesheet is what holds them equal.
33export const INDENT_STEP_IN = 0.5
34
35// indentClass maps a level to the class the read view styles, or null
36// for the levels that render as no indent at all: 0, null, and anything
37// that is not a whole number in range.
38//
39// Built from the number rather than looked up in a table of eight
40// literals, which is a deliberate departure from ALIGN_CLASSES. It is
41// safe here and is not safe there: this value has been proved to be a
42// small integer before it is interpolated, so the result can only ever
43// be one of eight strings. On the Rust side the same reasoning applies
44// and the same care is needed — see fragment.rs's own comment.
45export function indentClass(level) {
46 const n = normaliseIndent(level)
47 return n ? 'docs-indent-' + n : null
48}
49
50// normaliseIndent is the one place the range rule lives: a whole number
51// from 1 to MAX_INDENT, or 0 for everything else. 0 and null are the
52// same answer, and both mean "no attribute" — so a document nobody has
53// indented stays byte-identical to one written before this module
54// existed, exactly as align.mjs's `left` does.
55export function normaliseIndent(level) {
56 // DIGITS ONLY, matching core/src/fragment.rs's indent_level exactly.
57 //
58 // This was a bare Number() for one review cycle, and Number() is much
59 // more forgiving than any parse Rust will do: it accepts " 3", "3.0",
60 // "1e1" and "0x3" and turns them into 3, 3, 10 and 3. The renderer
61 // rejected all four, so a document carrying one of them indented on
62 // screen and rendered flush left for a reader with no JavaScript —
63 // exactly the disagreement this module's head comment says the closed
64 // vocabulary exists to prevent.
65 //
66 // A number is allowed through as itself because that is what the
67 // editor's own schema stores; a string has to look like one.
68 if (typeof level !== 'number') {
69 if (typeof level !== 'string' || !/^\d+$/.test(level)) return 0
70 }
71 const n = Number(level)
72 if (!Number.isInteger(n) || n < 1) return 0
73 return Math.min(n, MAX_INDENT)
74}
75
76// indentFromClasses is the inverse, for a paste.
77export function indentFromClasses(names) {
78 for (const name of names) {
79 if (!name.startsWith('docs-indent-')) continue
80 const n = normaliseIndent(name.slice('docs-indent-'.length))
81 if (n) return n
82 }
83 return null
84}
85
86// withIndent adds the attribute to a block spec that already has
87// whatever else it has — used for the paragraph (on top of align.mjs's
88// own wrapper) and for headings, which are indentable in every word
89// processor a person has used.
90//
91// It composes with alignedParagraph rather than replacing it: both
92// wrappers add one attribute and one parseDOM rule apiece, and the
93// paragraph needs both. That is why getAttrs below MERGES with whatever
94// the base spec's own rule returned instead of replacing it — an
95// earlier version returned only the indent and silently dropped
96// alignment from every pasted paragraph, which no test caught because
97// nothing pasted a paragraph that was both aligned and indented.
98export function withIndent(base, tag) {
99 const baseRules = base.parseDOM || [{ tag }]
100 return {
101 ...base,
102 attrs: { ...(base.attrs || {}), indent: { default: null } },
103 parseDOM: baseRules.map((rule) => ({
104 ...rule,
105 getAttrs: (dom) => {
106 const inherited = rule.getAttrs ? rule.getAttrs(dom) : {}
107 if (inherited === false) return false
108 return {
109 ...(inherited || {}),
110 indent: indentFromClasses(Array.from(dom.classList || [])),
111 }
112 },
113 })),
114 toDOM: (node) => {
115 const out = base.toDOM ? base.toDOM(node) : [tag, 0]
116 const cls = indentClass(node.attrs.indent)
117 if (!cls) return out
118 // out is [tag, attrs?, hole]. Both shapes occur — alignedParagraph
119 // returns the two-element form for an unaligned paragraph and the
120 // three-element form for an aligned one — so the class is merged
121 // into whichever this is rather than assuming either.
122 const [name, second, ...rest] = out
123 if (second && typeof second === 'object' && !Array.isArray(second)) {
124 const merged = { ...second, class: second.class ? second.class + ' ' + cls : cls }
125 return [name, merged, ...rest]
126 }
127 return [name, { class: cls }, second, ...rest]
128 },
129 }
130}
131
132// changeIndent is the command behind Tab, Shift+Tab and the two Format
133// menu items: move every indentable block in the selection by one step.
134//
135// It honours ProseMirror's dispatch contract exactly — see setAlign's
136// own comment, and buildCommands' record of the evening a command that
137// ignored it cost. Called with a null dispatch it reports whether it
138// WOULD move anything, which is what greys the menu item out at the
139// ends of the range rather than offering a step that does nothing.
140export function changeIndent(delta) {
141 return (state, dispatch) => {
142 const { from, to } = state.selection
143 let applicable = false
144 const tr = state.tr
145 state.doc.nodesBetween(from, to, (node, pos) => {
146 if (!node.type.isTextblock) return
147 if (!node.type.spec.attrs || !node.type.spec.attrs.indent) return
148 const now = normaliseIndent(node.attrs.indent)
149 const next = Math.max(0, Math.min(MAX_INDENT, now + delta))
150 // Already at the end of the range: this block cannot move, so it
151 // does not make the command available on its own account.
152 if (next === now) return
153 applicable = true
154 if (dispatch) {
155 tr.setNodeMarkup(pos, null, { ...node.attrs, indent: next === 0 ? null : next })
156 }
157 })
158 if (!applicable) return false
159 if (dispatch) dispatch(tr)
160 return true
161 }
162}
163
164// currentIndent is what a control reads: the indent of the block the
165// cursor starts in. Same choice as currentAlign, same reason.
166export function currentIndent(state) {
167 return normaliseIndent(state.selection.$from.parent.attrs?.indent)
168}
169