Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
12 kB · 236 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237// src/editor/extensions/hidden-text-classes.ts/** * Single source of truth for CM6 decoration class names that hide * text. Importing extensions reference these constants so the class * names are not duplicated as string literals across the codebase. * * BUG CLASS — read this before adding a new entry. * * CM6 vertical cursor motion (ArrowUp / ArrowDown) is implemented in * terms of `view.coordsAtPos` and `view.posAtCoords`, which in turn * delegate to the browser's native caret-from-point APIs * (`document.caretRangeFromPoint` / `caretPositionFromPoint`). Those * APIs ignore elements that have been collapsed to zero geometry — * `display: none`, `width: 0; height: 0`, `line-height: 0`, etc. * * If the ENTIRE visible content of a `.cm-line` is collapsed by such * a decoration, the line has no caret-targetable surface; CM6 finds * no position there and silently skips the line on ArrowUp/ArrowDown. * * Two safe hide-text idioms that DO survive ArrowUp/ArrowDown: * * 1. INLINE-MARK idiom (within-line use only — line has visible * siblings, OR the line decoration causes CM6 to insert a `<br>` * sentinel as the caret hit-target). * * Two sub-variants depending on what the marker REPLACES: * * a. INLINE FORMATTING markers (bold/italic/strike/inline-code/ * fenced-code-fence) — `.cm-inline-formatting-hidden`. The * line ALWAYS has visible text on at least one side of the * marker, so the cursor-skip mechanism that motivates the * zero-geometry pattern does not apply here. Use plain * `display: none` — removes the marker from layout AND from * caretRangeFromPoint hit-testing entirely. This is what * fixes the click-snap-to-marker-boundary bug. The owning * extensions (inline-format-decoration.ts and * code-marker-decoration.ts) ALSO register the hidden ranges * via `EditorView.atomicRanges` for arrow-key motion paths * that bypass caretRangeFromPoint. See those files' * doc-comments for the rationale. * * b. BLOCKQUOTE / CALLOUT prefixes — `.cm-blockquote-formatting-hidden`. * The marker appears at the START of a line and is potentially * the first child of `.cm-line` before any visible text. The * zero-width inline-block (`display: inline-block; width: 0; * height: 0; line-height: 0; opacity: 0`) keeps a DOM position * for the caret to land on while collapsing visual width. * `display: none` is unsafe here because some browsers strand * the caret if the only child of a contenteditable line is * removed from layout entirely — the inline-block sentinel * avoids that path. * * NOTE — heading prefix uses a HYBRID variant: * The heading prefix `# ` / `## ` / etc. cannot use plain idiom 1 * (zero-everything) because an empty heading line (`# ` only) * presents the prefix as the line's only inline content; zero * height leaves it caret-unreachable. It also cannot use plain * idiom 2 (color: transparent) because that preserves the * prefix's inline width and shifts heading text right by ~1ch on * non-cursor lines. The HYBRID variant threads the needle: * `display: inline-block; width: 0; max-width: 0; height: 1em; * line-height: 1em; overflow: hidden; color: transparent; * user-select: none`. Zero on the WIDTH axis (heading text * starts at column 0, matching pre-fix layout); non-zero on the * HEIGHT axis (empty heading lines remain caret-targetable). * * 2. WHOLE-LINE idiom (line has no other visible content) * Pattern: `Decoration.mark` over the source text + CSS that masks * the glyphs (`color: transparent; user-select: none`, plus * `font-size: 0` / `height: 0` / `line-height: 0; overflow: hidden` * if the line itself must also collapse to zero geometry). * * Two sub-variants depending on whether the cursor reaches the * line via column-Y projection (caretRangeFromPoint) or via an * explicit table-aware keymap that uses `view.dispatch({selection})`: * * a. caretRangeFromPoint-reachable (HR): the line MUST keep * non-zero geometry so the browser's caret-from-point API can * find a position. CSS only masks the glyphs (`color: * transparent`); the line keeps natural line-height. Visible * chrome painted by `.cm-md-hr-line::before` border. * .cm-md-hr-line-hidden — wraps `---` / `***` / etc. * * b. keymap-reachable (table separator): the cursor only ever * arrives here via `view.dispatch({selection})` from the * table-aware ArrowUp/ArrowDown handler in table-keybindings.ts * (which sets the selection by document offset, not by pixel * coordinates). The line is therefore safe to collapse to * zero geometry — header `border-bottom` meets body * `border-top` with no visible gap, matching the pre-#269 * table layout. caretRangeFromPoint is irrelevant here. * .cm-table-separator-anchor — wraps `|---|---|`; line * decoration `.cm-table-separator-row` is `height: 0; * line-height: 0; overflow: hidden`. * .cm-table-pipe-hidden — interleaves with cell content via a * zero-width flex item (`flex: 0 0 0`); not whole-line on its * own, but it co-exists with `.cm-table-separator-anchor` on * the separator row. * * Rejected alternative for both sub-variants: * `Decoration.replace({widget})` rendering an invisible nbsp * anchor span. CM6 wraps every replace-widget in * `contenteditable="false"`, which the browser excludes from caret * hit-testing entirely — ArrowDown still skipped the line, and * even with the keymap-reachable sub-variant the editable-source * behavior was lost. Keeping the source text in the DOM via * `Decoration.mark` avoids that exclusion. (Side-effect: the * marked text remains editable; typing into the separator demotes * the table to a paragraph, same way HR `---` is editable in the * live-preview.) * * NEVER use `display: none` on a class that may cover the entire * visible content of a line UNLESS the cursor only ever reaches that * line through a non-geometry-based path (e.g. an explicit keymap * dispatch). NEVER use `height: 0; line-height: 0` on a `.cm-line` * that the cursor reaches via column-Y projection * (caretRangeFromPoint). NEVER use `Decoration.replace({})` to cover * the entire visible content of a `.cm-line` without ensuring the * line still contains a hit-testable inline DOM child OR is reached * through a keymap-dispatch path. The combination that fails is * "geometry-collapsed line + caret arrives by pixel coordinates" — * that's the cursor-skip bug. * * Two automated guards keep this invariant: * - src/editor/__tests__/cm6-editor-css-hide-audit.test.ts — * fails CI for any cm6-editor.css rule that hides geometry * without an explicit allow-list entry justified by case. * - src/editor/__tests__/decoration-class-registry.test.ts — * snapshots every Decoration class literal in extensions and * cross-references against the CSS audit's classification. * * MAINTENANCE CONTRACT — TEMPORAL COUPLING: * * The audit's `justificationKeyword` field MUST continue to appear * either in the corresponding cm6-editor.css rule body OR in the * comment block immediately preceding the rule. Editing a CSS * justification comment requires updating the matching * `justificationKeyword` in the audit's ALLOW_LIST in the SAME * COMMIT — they have no auto-sync. CI fails on drift. * * If you add a new hide-class: * 1. Add a constant export here (matches the helper's idiom group). * 2. Add the constant to the HIDDEN_TEXT_CLASSES array. * 3. Add an ALLOW_LIST entry in the audit with the expected * classification AND a justificationKeyword that appears in * the new CSS rule's preceding comment. * 4. Write the CSS rule itself with the safe idiom shape (idiom 1 * for inline marks; idiom 2 / hybrid for whole-line). */
/** * Inline-mark hide classes (idiom 1 — line has visible siblings). * * Visual intent: invisible glyph that does not affect line height. * CSS shape: `display: inline-block; width: 0; max-width: 0; height: 0; * line-height: 0; vertical-align: top; overflow: hidden; opacity: 0`. */export const INLINE_FORMATTING_HIDDEN = 'cm-inline-formatting-hidden'export const BLOCKQUOTE_FORMATTING_HIDDEN = 'cm-blockquote-formatting-hidden'
/** * Whole-line hide classes (idiom 2 — entire visible line content is hidden). * * Visual intent: invisible glyph that DOES contribute layout (so the * `.cm-line` retains hit-testable height for ArrowUp/ArrowDown). The * line decoration paints the visible chrome (border, padding). * * Two variants: * - HR_LINE_HIDDEN — pure idiom 2: `color: transparent; * user-select: none`. Acceptable because the line has no other * visible content whose horizontal layout matters; nothing * shifts. * - FORMATTING_HIDDEN — HYBRID idiom 2: `display: inline-block; * width: 0; max-width: 0; height: 1em; line-height: 1em; * overflow: hidden; color: transparent; user-select: none`. The * hybrid variant zeroes VISIBLE inline width (so heading text * after `# ` starts at column 0) but keeps non-zero HEIGHT (so * empty `# ` lines remain caret-targetable). See module-doc * for full rationale. */export const HR_LINE_HIDDEN = 'cm-md-hr-line-hidden'export const FORMATTING_HIDDEN = 'cm-formatting-hidden'
/** * Idiom 2 — table cell pipes. Pipes use a zero-width flex item * (`flex: 0 0 0; color: transparent; user-select: none`) so they stay * in flex flow without consuming visual width. Sits alongside cell * content on header/body rows; on the separator row it sits alongside * the separator-anchor mark below. */export const TABLE_PIPE_HIDDEN = 'cm-table-pipe-hidden'
/** * Idiom 2 — table separator row (keymap-reachable sub-variant). * * The separator's `|---|---|` source text is wrapped by * `Decoration.mark({ class: TABLE_SEPARATOR_ANCHOR })` in * `table-grid.ts`, NOT a `Decoration.replace`. CSS collapses both * the line and the mark to zero geometry (`height: 0; line-height: 0; * overflow: hidden`) so the visible table layout matches the * pre-#269 look — header `border-bottom` and body `border-top` meet * directly with no visible gap. * * The line is reachable as a navigation stop ONLY because the * table-aware keymap (`table-keybindings.ts`) routes ArrowUp/ArrowDown * here via `view.dispatch({selection: cursor(separator.line.from)})` * — a non-geometry-based path. caretRangeFromPoint cannot find a * zero-geometry line, but it doesn't need to here. (See module-doc * above for why this differs from HR's sub-variant.) * * Side-effect of using `Decoration.mark` (vs `Decoration.replace`): * the `|---|---|` text remains in the DOM as editable text. Typing * into it demotes the table to a paragraph — consistent with how HR * `---` is editable in live-preview. See module-doc above for the * rejected widget alternative. */export const TABLE_SEPARATOR_ANCHOR = 'cm-table-separator-anchor'
/** * The full set of hide-class names. Used by the Pillar B snapshot test * to cross-reference against CSS rule shapes — every entry here MUST * appear in cm6-editor.css with the matching idiom. */export const HIDDEN_TEXT_CLASSES = [ INLINE_FORMATTING_HIDDEN, BLOCKQUOTE_FORMATTING_HIDDEN, FORMATTING_HIDDEN, HR_LINE_HIDDEN, TABLE_PIPE_HIDDEN, TABLE_SEPARATOR_ANCHOR,] as const
// `as const` plus an `as readonly string[]` cast at every read-site is// the existing convention in this codebase (see callout-icons.ts).
export type HiddenTextClass = (typeof HIDDEN_TEXT_CLASSES)[number]