// fixtures/indent.mjs — paragraph indent, and what the Tab key does. // // The second block-level formatting this editor has, and it follows // align.mjs's shape deliberately rather than inventing a second one: // read that module's head comment first, because every argument it // makes about a closed set agreed across four places that cannot import // each other applies here unchanged. // // One difference, and it is the interesting one. Alignment's value is a // STRING from a closed set of three, so fragment.rs maps it through a // table and drops anything else. Indent's value is a NUMBER, so it is // handled the way core/src/fragment.rs already handles heading.level: // parsed as an integer and clamped to the allowed range. That is a // different mechanism reaching the same guarantee — no byte from a // document reaches attribute position — and it matters that it is the // existing one rather than a new one, because the safety property // fragment_html's unescaped output depends on is the thing this repo // can least afford to have two versions of. // // MAX_INDENT is 8 for the same reason word processors stop somewhere: // past eight half-inch steps a paragraph on US Letter has no measure // left. It is a clamp, not a validation — a document arriving with // indent 400 through POST /d/{sqid}/updates renders at 8 rather than // being rejected, which is the same forgiving-read choice heading.level // already makes. export const MAX_INDENT = 8 // The step, in inches, per level. Google Docs, Word and Pages all use a // half inch, and this is the one number that has to be the same on // screen and on paper: views/show.html's .docs-indent-N rules and // internal/export/pdf.go's indentOf both restate it, and // TestPageGeometryMatchesTheStylesheet is what holds them equal. export const INDENT_STEP_IN = 0.5 // indentClass maps a level to the class the read view styles, or null // for the levels that render as no indent at all: 0, null, and anything // that is not a whole number in range. // // Built from the number rather than looked up in a table of eight // literals, which is a deliberate departure from ALIGN_CLASSES. It is // safe here and is not safe there: this value has been proved to be a // small integer before it is interpolated, so the result can only ever // be one of eight strings. On the Rust side the same reasoning applies // and the same care is needed — see fragment.rs's own comment. export function indentClass(level) { const n = normaliseIndent(level) return n ? 'docs-indent-' + n : null } // normaliseIndent is the one place the range rule lives: a whole number // from 1 to MAX_INDENT, or 0 for everything else. 0 and null are the // same answer, and both mean "no attribute" — so a document nobody has // indented stays byte-identical to one written before this module // existed, exactly as align.mjs's `left` does. export function normaliseIndent(level) { // DIGITS ONLY, matching core/src/fragment.rs's indent_level exactly. // // This was a bare Number() for one review cycle, and Number() is much // more forgiving than any parse Rust will do: it accepts " 3", "3.0", // "1e1" and "0x3" and turns them into 3, 3, 10 and 3. The renderer // rejected all four, so a document carrying one of them indented on // screen and rendered flush left for a reader with no JavaScript — // exactly the disagreement this module's head comment says the closed // vocabulary exists to prevent. // // A number is allowed through as itself because that is what the // editor's own schema stores; a string has to look like one. if (typeof level !== 'number') { if (typeof level !== 'string' || !/^\d+$/.test(level)) return 0 } const n = Number(level) if (!Number.isInteger(n) || n < 1) return 0 return Math.min(n, MAX_INDENT) } // indentFromClasses is the inverse, for a paste. export function indentFromClasses(names) { for (const name of names) { if (!name.startsWith('docs-indent-')) continue const n = normaliseIndent(name.slice('docs-indent-'.length)) if (n) return n } return null } // withIndent adds the attribute to a block spec that already has // whatever else it has — used for the paragraph (on top of align.mjs's // own wrapper) and for headings, which are indentable in every word // processor a person has used. // // It composes with alignedParagraph rather than replacing it: both // wrappers add one attribute and one parseDOM rule apiece, and the // paragraph needs both. That is why getAttrs below MERGES with whatever // the base spec's own rule returned instead of replacing it — an // earlier version returned only the indent and silently dropped // alignment from every pasted paragraph, which no test caught because // nothing pasted a paragraph that was both aligned and indented. export function withIndent(base, tag) { const baseRules = base.parseDOM || [{ tag }] return { ...base, attrs: { ...(base.attrs || {}), indent: { default: null } }, parseDOM: baseRules.map((rule) => ({ ...rule, getAttrs: (dom) => { const inherited = rule.getAttrs ? rule.getAttrs(dom) : {} if (inherited === false) return false return { ...(inherited || {}), indent: indentFromClasses(Array.from(dom.classList || [])), } }, })), toDOM: (node) => { const out = base.toDOM ? base.toDOM(node) : [tag, 0] const cls = indentClass(node.attrs.indent) if (!cls) return out // out is [tag, attrs?, hole]. Both shapes occur — alignedParagraph // returns the two-element form for an unaligned paragraph and the // three-element form for an aligned one — so the class is merged // into whichever this is rather than assuming either. const [name, second, ...rest] = out if (second && typeof second === 'object' && !Array.isArray(second)) { const merged = { ...second, class: second.class ? second.class + ' ' + cls : cls } return [name, merged, ...rest] } return [name, { class: cls }, second, ...rest] }, } } // changeIndent is the command behind Tab, Shift+Tab and the two Format // menu items: move every indentable block in the selection by one step. // // It honours ProseMirror's dispatch contract exactly — see setAlign's // own comment, and buildCommands' record of the evening a command that // ignored it cost. Called with a null dispatch it reports whether it // WOULD move anything, which is what greys the menu item out at the // ends of the range rather than offering a step that does nothing. export function changeIndent(delta) { 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.indent) return const now = normaliseIndent(node.attrs.indent) const next = Math.max(0, Math.min(MAX_INDENT, now + delta)) // Already at the end of the range: this block cannot move, so it // does not make the command available on its own account. if (next === now) return applicable = true if (dispatch) { tr.setNodeMarkup(pos, null, { ...node.attrs, indent: next === 0 ? null : next }) } }) if (!applicable) return false if (dispatch) dispatch(tr) return true } } // currentIndent is what a control reads: the indent of the block the // cursor starts in. Same choice as currentAlign, same reason. export function currentIndent(state) { return normaliseIndent(state.selection.$from.parent.attrs?.indent) }