Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
6.6 kB · 165 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166// src/editor/extensions/widget-pointer-select-guard.ts/** * Shared helper for detecting pointer-select transactions that land the * cursor at a widget boundary. Used by widget ViewPlugins (footnote, * checkbox, wikilink) to suppress proximity-reveal during a click. * * ## Background * * CM6's default mousedown handler maps click coords to a doc offset and * dispatches a `select.pointer` selection transaction. For Decoration.replace * widgets, the offset collapses to one of the widget's range endpoints * (`from` or `to`). If the widget uses proximity-reveal (hide widget when * cursor is near), this selection change would suppress the widget BEFORE * the click handler fires — dropping the click silently. * * ## Solution Pattern * * 1. Widget plugin caches marker ranges (refresh on docChanged). * 2. In update(), call `detectPointerSelectInRanges(update, markerRanges)`. * 3. If it returns a range, store it as `pointerSelectAtWidget`. * 4. In buildDecorations(), skip proximity-reveal for that specific widget. * 5. Clear `pointerSelectAtWidget` on any non-pointer selection change. * * This keeps the widget visible long enough for the click handler to fire. */import type { ViewUpdate } from '@codemirror/view'import type { Transaction, Text } from '@codemirror/state'
/** * Represents a marker range that a widget might replace via Decoration.replace. * Each widget plugin maintains its own cache of these ranges. */export interface WidgetMarkerRange { from: number to: number}
/** * Detect whether any transaction in this update was a `select.pointer` * userEvent that places the new cursor at one of the cached marker ranges' * endpoints. * * @param update - The CM6 ViewUpdate to inspect. * @param ranges - Cached marker ranges from the widget plugin. * @returns The first matching range, or null if no match. * * ## Boundary behavior * * - Cursor at `range.from` or `range.to` (inclusive at both ends) matches. * - Adjacent ranges (chip1.to === chip2.from): the first range wins. This is * acceptable for current widget usage; the second chip undergoes normal * proximity-reveal. * - Non-empty selection (drag): returns null (not a single-click scenario). * - Non-pointer userEvent: returns null. */export function detectPointerSelectInRanges( update: ViewUpdate, ranges: WidgetMarkerRange[],): WidgetMarkerRange | null { if (!update.selectionSet) return null
const isPointerSelect = update.transactions.some((tr: Transaction) => tr.isUserEvent('select.pointer'), ) if (!isPointerSelect) return null
const { head, empty } = update.state.selection.main if (!empty) return null
for (const range of ranges) { if (head === range.from || head === range.to) { return { from: range.from, to: range.to } } }
return null}
/** * Check if a cursor position causes a widget to be hidden (proximity-reveal). * Uses the SAME-LINE-AWARE check that widget plugins use: cursor is "near" * if it falls within [from - proximityChars, to + proximityChars] AND is on * the same line as the widget. * * This matches the behavior of `isCursorNearSameLine` in markdown-context.ts * which is the actual visibility criterion — widget is hidden only when * cursor is both (a) within distance AND (b) on the same line. * * The same-line gate prevents proximity-reveal from leaking onto neighboring * lines (e.g. cursor at start of line N+1 unhiding markup at the end of * line N). The visibility check must match this behavior exactly to avoid * false negatives where the guard doesn't engage for a visible widget. */function isCursorNearWidgetSameLine( cursorPos: number, range: WidgetMarkerRange, proximityChars: number, doc: Text,): boolean { // Distance check first (fast path) const lowerBound = range.from - proximityChars const upperBound = range.to + proximityChars if (cursorPos < lowerBound || cursorPos > upperBound) return false // Same-line check: cursor must be on the same line as the widget's start // (inline widgets like wikilinks, checkboxes, footnotes cannot span lines) const cursorLine = doc.lineAt(cursorPos).number const widgetLine = doc.lineAt(range.from).number return cursorLine === widgetLine}
/** * Visibility-aware variant of `detectPointerSelectInRanges`. Only returns * a match when: * 1. This is a `select.pointer` userEvent with an empty (collapsed) selection * 2. The new cursor position is at a widget boundary (from or to) * 3. The widget WAS VISIBLE before the click (previous cursor was outside * the proximity range OR on a different line) * * This prevents the "hidden widget still clickable" bug (#323): * - When cursor is far from widget → widget visible → user clicks widget → * guard engages → click handler fires (CORRECT) * - When cursor is near widget AND same line → widget hidden → user clicks to edit → * guard does NOT engage → click places cursor (CORRECT) * - When cursor is near widget BUT different line → widget visible (same-line gate) → * user clicks widget → guard engages → click handler fires (CORRECT) * * @param update - The CM6 ViewUpdate to inspect. * @param ranges - Cached marker ranges from the widget plugin. * @param proximityChars - The PROXIMITY_CHARS constant from the widget plugin. * @returns The first matching range where the widget was visible, or null. */export function detectPointerSelectOnVisibleWidget( update: ViewUpdate, ranges: WidgetMarkerRange[], proximityChars: number,): WidgetMarkerRange | null { if (!update.selectionSet) return null
const isPointerSelect = update.transactions.some((tr: Transaction) => tr.isUserEvent('select.pointer'), ) if (!isPointerSelect) return null
const { head, empty } = update.state.selection.main if (!empty) return null
// Get the cursor position and document from BEFORE the select.pointer transaction const previousCursor = update.startState.selection.main.head const doc = update.startState.doc
for (const range of ranges) { // Check if new cursor is at widget boundary if (head !== range.from && head !== range.to) continue
// Check if widget WAS visible (previous cursor outside proximity OR different line) // Widget is hidden only when cursor is BOTH near AND on the same line const wasWidgetHidden = isCursorNearWidgetSameLine(previousCursor, range, proximityChars, doc) if (!wasWidgetHidden) { return { from: range.from, to: range.to } } }
return null}