| 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. |
| 26 | export 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. |
| 33 | export 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. |
| 45 | export 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. |
| 55 | export 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. |
| 77 | export 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. |
| 98 | export 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. |
| 140 | export 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. |
| 166 | export function currentIndent(state) { |
| 167 | return normaliseIndent(state.selection.$from.parent.attrs?.indent) |
| 168 | } |
| 169 | |