Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
7.7 kB · 229 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230// src/editor/extensions/selection-validation.ts/** * Utilities for validating and adjusting selections to avoid positions inside * replaced widget ranges (block widgets, atomic ranges). * * CM6's `atomicRanges` facet prevents cursor NAVIGATION (arrow keys, click * snap) from landing inside atomic ranges, but it does NOT prevent * programmatic `view.dispatch({ selection: ... })` from placing the cursor * there. When the cursor is inside a replaced block widget range, CM6 draws * the caret spanning the entire widget height, causing selection geometry * invariant violations (e.g., `caret_height_matches_line_height`). * * Use these utilities when restoring saved selections (e.g., after checkbox * toggle, image viewer close) to ensure the cursor doesn't end up inside a * replaced widget range. * * @see checkbox-widget.ts — restores `preClickSelection` after toggle * @see image-viewer-overlay.ts — restores `preClickSelection` after close * @see #286 — erratic selection around widgets */import { EditorSelection, type EditorState, type SelectionRange } from '@codemirror/state'import { getFrontmatterRange } from './markdown-context'
/** * Check if a position is inside the frontmatter widget range. * * The frontmatter widget replaces `[0, endOffset)` with a block widget. * Any cursor position in `[0, endOffset)` is inside the replaced range * and would cause the caret to span the entire widget height. * * We check frontmatter specifically because it's the most common case of * a block-replacing widget that covers position 0 (the default cursor * position). Other widgets (inline wikilinks, checkboxes, etc.) use * `Decoration.replace` without `block: true`, so cursor inside them * doesn't cause the same full-height caret bug. */export function isPositionInsideFrontmatterWidget( state: EditorState, pos: number,): boolean { const fm = getFrontmatterRange(state) if (!fm) return false // Frontmatter widget replaces [0, endOffset). Position inside means: // pos >= 0 && pos < endOffset // Since pos is always >= 0, we just check pos < endOffset return pos < fm.endOffset}
/** * Adjust a selection range to avoid positions inside the frontmatter widget. * * If either anchor or head is inside the frontmatter widget range, move it * to the first position after the widget (`frontmatter.endOffset`). * * Returns the original range unchanged if: * - No frontmatter exists * - Neither anchor nor head is inside the frontmatter range */export function adjustRangeOutsideFrontmatter( state: EditorState, range: SelectionRange,): SelectionRange { const fm = getFrontmatterRange(state) if (!fm) return range
const safePos = fm.endOffset let anchor = range.anchor let head = range.head
// Adjust positions that fall inside the widget range if (anchor < safePos) anchor = safePos if (head < safePos) head = safePos
// If no adjustment was needed, return original if (anchor === range.anchor && head === range.head) { return range }
return EditorSelection.range(anchor, head)}
/** * Adjust an entire selection to avoid positions inside the frontmatter widget. * * Processes each range in the selection and adjusts any that fall inside * the frontmatter widget range. Returns a new EditorSelection with adjusted * ranges, preserving the main selection index. * * Returns the original selection unchanged if no adjustments were needed. */export function adjustSelectionOutsideFrontmatter( state: EditorState, selection: EditorSelection,): EditorSelection { const fm = getFrontmatterRange(state) if (!fm) return selection
const safePos = fm.endOffset let needsAdjustment = false
// Check if any range needs adjustment for (const range of selection.ranges) { if (range.anchor < safePos || range.head < safePos) { needsAdjustment = true break } }
if (!needsAdjustment) return selection
// Build adjusted ranges const adjustedRanges = selection.ranges.map(range => adjustRangeOutsideFrontmatter(state, range), )
return EditorSelection.create(adjustedRanges, selection.mainIndex)}
/** * Validate that a selection's positions are safe to restore. * * A selection is "safe" if: * 1. All positions are within document bounds (0 <= pos <= docLength) * 2. No positions fall inside the frontmatter widget range * * Returns `true` if the selection is safe to use directly, `false` if it * needs adjustment via `adjustSelectionOutsideFrontmatter()`. */export function isSelectionSafeToRestore( state: EditorState, selection: EditorSelection,): boolean { const docLen = state.doc.length const fm = getFrontmatterRange(state) const safePos = fm?.endOffset ?? 0
for (const range of selection.ranges) { // Check document bounds if ( range.anchor < 0 || range.anchor > docLen || range.head < 0 || range.head > docLen ) { return false }
// Check frontmatter widget range if (fm && (range.anchor < safePos || range.head < safePos)) { return false } }
return true}
/** * Get a safe selection for restoration, adjusting if necessary. * * This is the primary utility for selection restoration flows. It: * 1. Validates that positions are within document bounds * 2. Adjusts any positions inside the frontmatter widget range * * Returns `null` if the selection cannot be made safe (e.g., all positions * are out of document bounds after concurrent edits). */export function getSafeSelectionForRestore( state: EditorState, selection: EditorSelection,): EditorSelection { const docLen = state.doc.length const fm = getFrontmatterRange(state) const safePos = fm?.endOffset ?? 0
// First pass: check if any range is completely out of bounds for (const range of selection.ranges) { // Both positions must be clampable to valid range const clampedAnchor = Math.max(0, Math.min(range.anchor, docLen)) const clampedHead = Math.max(0, Math.min(range.head, docLen))
// If both positions were outside bounds and clamped to same point, // the range is still valid (collapsed cursor) if (clampedAnchor !== range.anchor || clampedHead !== range.head) { // Range needs clamping — we'll rebuild below } }
// Build safe ranges const safeRanges: SelectionRange[] = [] for (const range of selection.ranges) { // Clamp to document bounds let anchor = Math.max(0, Math.min(range.anchor, docLen)) let head = Math.max(0, Math.min(range.head, docLen))
// Adjust for frontmatter widget if (fm) { if (anchor < safePos) anchor = safePos if (head < safePos) head = safePos }
safeRanges.push(EditorSelection.range(anchor, head)) }
return EditorSelection.create(safeRanges, selection.mainIndex)}
/** * Get the first safe cursor position for initial document load. * * If the document has frontmatter, returns the position immediately after * the frontmatter widget (endOffset). Otherwise returns 0. * * Use this when creating an EditorState to avoid the default cursor at * position 0 landing inside the frontmatter widget. */export function getInitialCursorPosition(state: EditorState): number { const fm = getFrontmatterRange(state) return fm?.endOffset ?? 0}
/** * Create an initial EditorSelection for a document. * * Returns a cursor (collapsed selection) at the first safe position — * after the frontmatter widget if one exists, otherwise at 0. */export function createInitialSelection(state: EditorState): EditorSelection { return EditorSelection.single(getInitialCursorPosition(state))}