Yuki Takei 3 дней назад
Родитель
Сommit
641a8098fa

+ 54 - 0
packages/editor/src/client/services-internal/extensions/y-rich-cursors/activity-tracker.ts

@@ -0,0 +1,54 @@
+/**
+ * Tracks per-client activity timestamps and inactivity timers.
+ *
+ * When a remote client's awareness state changes, the tracker records the
+ * timestamp and starts a 3-second inactivity timer. On expiry the supplied
+ * callback fires (typically dispatching a decoration rebuild).
+ */
+
+const ACTIVITY_TIMEOUT_MS = 3000;
+
+export class ActivityTracker {
+  private readonly lastActivityMap = new Map<number, number>();
+  private readonly activeTimers = new Map<
+    number,
+    ReturnType<typeof setTimeout>
+  >();
+
+  /** Record activity for a remote client; resets the inactivity timer. */
+  recordActivity(clientId: number, now: number, onInactive: () => void): void {
+    this.lastActivityMap.set(clientId, now);
+
+    const existing = this.activeTimers.get(clientId);
+    if (existing != null) clearTimeout(existing);
+
+    this.activeTimers.set(
+      clientId,
+      setTimeout(onInactive, ACTIVITY_TIMEOUT_MS),
+    );
+  }
+
+  /** Clean up tracking state for a disconnected client. */
+  removeClient(clientId: number): void {
+    this.lastActivityMap.delete(clientId);
+    const timer = this.activeTimers.get(clientId);
+    if (timer != null) clearTimeout(timer);
+    this.activeTimers.delete(clientId);
+  }
+
+  /** Whether the client has been active within the last 3 seconds. */
+  isActive(clientId: number, now: number): boolean {
+    return (
+      now - (this.lastActivityMap.get(clientId) ?? 0) < ACTIVITY_TIMEOUT_MS
+    );
+  }
+
+  /** Clear all timers and state. */
+  destroy(): void {
+    for (const timer of this.activeTimers.values()) {
+      clearTimeout(timer);
+    }
+    this.activeTimers.clear();
+    this.lastActivityMap.clear();
+  }
+}

+ 4 - 0
packages/editor/src/client/services-internal/extensions/y-rich-cursors/dom/index.ts

@@ -0,0 +1,4 @@
+export type { OffScreenIndicatorOptions } from './off-screen-indicator';
+export { createOffScreenIndicator } from './off-screen-indicator';
+export { richCursorsTheme } from './theme';
+export { RichCaretWidget } from './widget';

+ 0 - 0
packages/editor/src/client/services-internal/extensions/y-rich-cursors/off-screen-indicator.spec.ts → packages/editor/src/client/services-internal/extensions/y-rich-cursors/dom/off-screen-indicator.spec.ts


+ 0 - 0
packages/editor/src/client/services-internal/extensions/y-rich-cursors/off-screen-indicator.ts → packages/editor/src/client/services-internal/extensions/y-rich-cursors/dom/off-screen-indicator.ts


+ 0 - 0
packages/editor/src/client/services-internal/extensions/y-rich-cursors/theme.ts → packages/editor/src/client/services-internal/extensions/y-rich-cursors/dom/theme.ts


+ 0 - 0
packages/editor/src/client/services-internal/extensions/y-rich-cursors/widget.spec.ts → packages/editor/src/client/services-internal/extensions/y-rich-cursors/dom/widget.spec.ts


+ 0 - 0
packages/editor/src/client/services-internal/extensions/y-rich-cursors/widget.ts → packages/editor/src/client/services-internal/extensions/y-rich-cursors/dom/widget.ts


+ 1 - 6
packages/editor/src/client/services-internal/extensions/y-rich-cursors/index.ts

@@ -2,14 +2,9 @@ import type { Extension } from '@codemirror/state';
 import { ViewPlugin } from '@codemirror/view';
 import type { WebsocketProvider } from 'y-websocket';
 
+import { richCursorsTheme } from './dom';
 import type { ScrollCallbackRef } from './plugin';
 import { YRichCursorsPluginValue } from './plugin';
-import { richCursorsTheme } from './theme';
-
-export type { OffScreenIndicatorOptions } from './off-screen-indicator';
-export { createOffScreenIndicator } from './off-screen-indicator';
-export type { ScrollCallbackRef } from './plugin';
-export { RichCaretWidget } from './widget';
 
 type Awareness = WebsocketProvider['awareness'];
 

+ 55 - 0
packages/editor/src/client/services-internal/extensions/y-rich-cursors/local-cursor.ts

@@ -0,0 +1,55 @@
+import type { ViewUpdate } from '@codemirror/view';
+import type { WebsocketProvider } from 'y-websocket';
+import * as Y from 'yjs';
+
+type Awareness = WebsocketProvider['awareness'];
+
+type LocalCursorState = {
+  cursor?: {
+    anchor: Y.RelativePosition;
+    head: Y.RelativePosition;
+  };
+};
+
+/**
+ * Broadcasts the local user's cursor position to the Yjs awareness protocol.
+ *
+ * Compares the current selection with the stored awareness cursor to avoid
+ * redundant broadcasts. Clears the cursor field when the editor loses focus.
+ */
+export function broadcastLocalCursor(
+  viewUpdate: ViewUpdate,
+  awareness: Awareness,
+  ytext: Y.Text,
+): void {
+  const localState = awareness.getLocalState() as LocalCursorState | null;
+  if (localState == null) return;
+
+  const hasFocus =
+    viewUpdate.view.hasFocus && viewUpdate.view.dom.ownerDocument.hasFocus();
+  const sel = hasFocus ? viewUpdate.state.selection.main : null;
+
+  if (sel != null) {
+    const anchor = Y.createRelativePositionFromTypeIndex(ytext, sel.anchor);
+    const head = Y.createRelativePositionFromTypeIndex(ytext, sel.head);
+
+    const currentAnchor =
+      localState.cursor?.anchor != null
+        ? Y.createRelativePositionFromJSON(localState.cursor.anchor)
+        : null;
+    const currentHead =
+      localState.cursor?.head != null
+        ? Y.createRelativePositionFromJSON(localState.cursor.head)
+        : null;
+
+    if (
+      localState.cursor == null ||
+      !Y.compareRelativePositions(currentAnchor, anchor) ||
+      !Y.compareRelativePositions(currentHead, head)
+    ) {
+      awareness.setLocalStateField('cursor', { anchor, head });
+    }
+  } else if (localState.cursor != null && hasFocus) {
+    awareness.setLocalStateField('cursor', null);
+  }
+}

+ 117 - 244
packages/editor/src/client/services-internal/extensions/y-rich-cursors/plugin.ts

@@ -6,8 +6,13 @@ import type { WebsocketProvider } from 'y-websocket';
 import * as Y from 'yjs';
 
 import type { EditingClient } from '../../../../interfaces';
-import { createOffScreenIndicator } from './off-screen-indicator';
-import { RichCaretWidget } from './widget';
+import { ActivityTracker } from './activity-tracker';
+import { createOffScreenIndicator, RichCaretWidget } from './dom';
+import { broadcastLocalCursor } from './local-cursor';
+import {
+  classifyCursorPosition,
+  createViewportContext,
+} from './viewport-classification';
 
 type Awareness = WebsocketProvider['awareness'];
 
@@ -19,6 +24,8 @@ type AwarenessState = {
   };
 };
 
+type IndicatorEntry = { el: HTMLElement; headIndex: number };
+
 /** Mutable ref container for the scroll-to-remote-cursor function. */
 export type ScrollCallbackRef = {
   current: ((clientId: number) => void) | null;
@@ -30,15 +37,12 @@ export class YRichCursorsPluginValue {
   decorations: DecorationSet;
   private readonly awareness: Awareness;
   private readonly scrollCallbackRef: ScrollCallbackRef | undefined;
+  private readonly activityTracker = new ActivityTracker();
   private readonly changeListener: (update: {
     added: number[];
     updated: number[];
     removed: number[];
   }) => void;
-
-  private readonly lastActivityMap: Map<number, number> = new Map();
-  private readonly activeTimers: Map<number, ReturnType<typeof setTimeout>> =
-    new Map();
   private readonly topContainer: HTMLElement;
   private readonly bottomContainer: HTMLElement;
 
@@ -64,52 +68,31 @@ export class YRichCursorsPluginValue {
       const remoteClients = clients.filter(
         (id) => id !== awareness.doc.clientID,
       );
-      if (remoteClients.length > 0) {
-        // Update activity timestamps for remote clients
-        const now = Date.now();
-        for (const clientId of remoteClients) {
-          // Only track activity for added/updated (not removed)
-          if (!removed.includes(clientId)) {
-            this.lastActivityMap.set(clientId, now);
-
-            // Reset the inactivity timer
-            const existing = this.activeTimers.get(clientId);
-            if (existing != null) clearTimeout(existing);
-
-            this.activeTimers.set(
-              clientId,
-              setTimeout(() => {
-                view.dispatch({
-                  annotations: [yRichCursorsAnnotation.of([])],
-                });
-              }, 3000),
-            );
-          } else {
-            // Clean up removed clients
-            this.lastActivityMap.delete(clientId);
-            const timer = this.activeTimers.get(clientId);
-            if (timer != null) clearTimeout(timer);
-            this.activeTimers.delete(clientId);
-          }
+      if (remoteClients.length === 0) return;
+
+      const now = Date.now();
+      for (const clientId of remoteClients) {
+        if (!removed.includes(clientId)) {
+          this.activityTracker.recordActivity(clientId, now, () => {
+            view.dispatch({
+              annotations: [yRichCursorsAnnotation.of([])],
+            });
+          });
+        } else {
+          this.activityTracker.removeClient(clientId);
         }
-
-        view.dispatch({
-          annotations: [yRichCursorsAnnotation.of([])],
-        });
       }
+
+      view.dispatch({
+        annotations: [yRichCursorsAnnotation.of([])],
+      });
     };
     this.awareness.on('change', this.changeListener);
   }
 
   destroy(): void {
     this.awareness.off('change', this.changeListener);
-    // Clear all timers
-    for (const timer of this.activeTimers.values()) {
-      clearTimeout(timer);
-    }
-    this.activeTimers.clear();
-    this.lastActivityMap.clear();
-    // Remove off-screen containers
+    this.activityTracker.destroy();
     this.topContainer.remove();
     this.bottomContainer.remove();
   }
@@ -120,36 +103,8 @@ export class YRichCursorsPluginValue {
     const ydoc = ytext?.doc as Y.Doc | undefined;
 
     // Broadcast local cursor position
-    const localState = this.awareness.getLocalState() as AwarenessState | null;
-    if (localState != null && ytext != null) {
-      const hasFocus =
-        viewUpdate.view.hasFocus &&
-        viewUpdate.view.dom.ownerDocument.hasFocus();
-      const sel = hasFocus ? viewUpdate.state.selection.main : null;
-
-      if (sel != null) {
-        const anchor = Y.createRelativePositionFromTypeIndex(ytext, sel.anchor);
-        const head = Y.createRelativePositionFromTypeIndex(ytext, sel.head);
-
-        const currentAnchor =
-          localState.cursor?.anchor != null
-            ? Y.createRelativePositionFromJSON(localState.cursor.anchor)
-            : null;
-        const currentHead =
-          localState.cursor?.head != null
-            ? Y.createRelativePositionFromJSON(localState.cursor.head)
-            : null;
-
-        if (
-          localState.cursor == null ||
-          !Y.compareRelativePositions(currentAnchor, anchor) ||
-          !Y.compareRelativePositions(currentHead, head)
-        ) {
-          this.awareness.setLocalStateField('cursor', { anchor, head });
-        }
-      } else if (localState.cursor != null && hasFocus) {
-        this.awareness.setLocalStateField('cursor', null);
-      }
+    if (ytext != null) {
+      broadcastLocalCursor(viewUpdate, this.awareness, ytext);
     }
 
     // Rebuild remote cursor decorations
@@ -160,65 +115,44 @@ export class YRichCursorsPluginValue {
       return;
     }
 
+    const { decorations, aboveIndicators, belowIndicators } =
+      this.buildRemoteCursors(viewUpdate, ytext, ydoc);
+
+    this.decorations = Decoration.set(decorations, true);
+    this.topContainer.replaceChildren(...aboveIndicators.map(({ el }) => el));
+    this.bottomContainer.replaceChildren(
+      ...belowIndicators.map(({ el }) => el),
+    );
+
+    this.positionIndicatorsHorizontally(viewUpdate, [
+      ...aboveIndicators,
+      ...belowIndicators,
+    ]);
+  }
+
+  /** Iterates remote awareness states and builds decorations / off-screen indicators. */
+  private buildRemoteCursors(
+    viewUpdate: ViewUpdate,
+    ytext: Y.Text,
+    ydoc: Y.Doc,
+  ): {
+    decorations: { from: number; to: number; value: Decoration }[];
+    aboveIndicators: IndicatorEntry[];
+    belowIndicators: IndicatorEntry[];
+  } {
     const decorations: { from: number; to: number; value: Decoration }[] = [];
-    type IndicatorEntry = { el: HTMLElement; headIndex: number };
     const aboveIndicators: IndicatorEntry[] = [];
     const belowIndicators: IndicatorEntry[] = [];
     const localClientId = this.awareness.doc.clientID;
-
-    const visibleRanges = viewUpdate.view.visibleRanges;
-    const { from: viewportFrom, to: viewportTo } = viewUpdate.view.viewport;
-
-    // Three classification strategies (chosen once per update call):
-    //
-    // "ranged": visibleRanges is a true sub-range of viewport — CodeMirror's own
-    //   scroller is active (e.g. fixed-height editor in tests). Use character-
-    //   position bounds derived from visibleRanges.
-    //
-    // "coords": visibleRanges == viewport — the editor expands to full content
-    //   height and the *page* handles scrolling (GROWI's production setup). Use
-    //   the cursor's actual screen coordinates vs the editor's visible rect
-    //   (scrollDOM.getBoundingClientRect clamped to window.innerHeight).
-    //
-    // "none" (degenerate — jsdom with 0-height container): scrollRect.height == 0
-    //   so screen coordinates are unreliable. Skip all off-screen classification
-    //   and give every cursor a widget decoration, matching pre-task-12 behaviour.
-    const hasVisibleRanges = visibleRanges.length > 0;
-    // rangedMode: visibleRanges is a meaningful sub-range of viewport.
-    // Requires the visible area to be non-empty (to > from) so that a 0-height
-    // editor (jsdom degenerate) doesn't accidentally classify every cursor as
-    // off-screen via a vpTo of 0.
-    const rangedMode =
-      hasVisibleRanges &&
-      visibleRanges[visibleRanges.length - 1].to > visibleRanges[0].from &&
-      (visibleRanges[0].from > viewportFrom ||
-        visibleRanges[visibleRanges.length - 1].to < viewportTo);
-
-    // For coords mode: compute visible band once before the per-cursor loop.
-    // getBoundingClientRect() is a raw DOM call (not a CodeMirror layout read)
-    // so it is allowed during update(). lineBlockAt() uses the stored height map
-    // and is also safe during update().
-    // When scrollDOMRect.height == 0 (jsdom), screenVisibleBottom == 0 so the
-    // below/above checks never fire and every cursor falls through to a widget.
-    let scrollDOMTop = 0;
-    let scrollDOMBottom = 0;
-    let scrollTop = 0;
-    if (!rangedMode) {
-      const scrollDOMRect = viewUpdate.view.scrollDOM.getBoundingClientRect();
-      scrollDOMTop = scrollDOMRect.top;
-      scrollDOMBottom = scrollDOMRect.bottom;
-      scrollTop = viewUpdate.view.scrollDOM.scrollTop;
-    }
-    const screenVisibleTop = Math.max(scrollDOMTop, 0);
-    const screenVisibleBottom = Math.min(scrollDOMBottom, window.innerHeight);
-
-    const vpFrom = hasVisibleRanges ? visibleRanges[0].from : viewportFrom;
-    const vpTo = hasVisibleRanges
-      ? visibleRanges[visibleRanges.length - 1].to
-      : viewportTo;
-
+    const ctx = createViewportContext(viewUpdate.view);
     const now = Date.now();
 
+    // Build the click handler once (reads ref.current lazily at call time)
+    const onClickIndicator =
+      this.scrollCallbackRef != null
+        ? (id: number) => this.scrollCallbackRef?.current?.(id)
+        : undefined;
+
     this.awareness.getStates().forEach((rawState, clientId) => {
       if (clientId === localClientId) return;
 
@@ -248,95 +182,35 @@ export class YRichCursorsPluginValue {
         return;
       }
 
-      const isActive = now - (this.lastActivityMap.get(clientId) ?? 0) < 3000;
       const headIndex = head.index;
+      const isActive = this.activityTracker.isActive(clientId, now);
+      const classification = classifyCursorPosition(
+        ctx,
+        viewUpdate.view,
+        headIndex,
+      );
 
-      // Classify: off-screen (above/below) or in-viewport
-      // Build a click handler from the mutable ref (captured once per indicator build).
-      // Using a closure that reads ref.current at call-time so no extension recreation
-      // is needed when the scroll function changes.
-      const onClickIndicator =
-        this.scrollCallbackRef != null
-          ? (id: number) => this.scrollCallbackRef?.current?.(id)
-          : undefined;
-
-      if (rangedMode) {
-        if (headIndex < vpFrom) {
-          aboveIndicators.push({
-            el: createOffScreenIndicator({
-              direction: 'above',
-              clientId,
-              color: editors.color,
-              name: editors.name,
-              imageUrlCached: editors.imageUrlCached,
-              isActive,
-              onClick: onClickIndicator,
-            }),
-            headIndex,
-          });
-          return;
-        }
-        if (headIndex > vpTo) {
-          belowIndicators.push({
-            el: createOffScreenIndicator({
-              direction: 'below',
-              clientId,
-              color: editors.color,
-              name: editors.name,
-              imageUrlCached: editors.imageUrlCached,
-              isActive,
-              onClick: onClickIndicator,
-            }),
-            headIndex,
-          });
-          return;
-        }
-      } else {
-        // coords mode: compare screen Y of cursor against the editor's visible rect.
-        // Used when visibleRanges == viewport (page-scroll editor, e.g. GROWI).
-        //
-        // lineBlockAt() reads stored heights (safe during update).
-        // When scrollDOMRect.height == 0 (jsdom) both checks below are false
-        // so every cursor falls through to a widget decoration.
-        const lineBlock = viewUpdate.view.lineBlockAt(headIndex);
-        const cursorTop = scrollDOMTop + lineBlock.top - scrollTop;
-        const cursorBottom = scrollDOMTop + lineBlock.bottom - scrollTop;
-
-        if (cursorBottom < screenVisibleTop) {
-          aboveIndicators.push({
-            el: createOffScreenIndicator({
-              direction: 'above',
-              clientId,
-              color: editors.color,
-              name: editors.name,
-              imageUrlCached: editors.imageUrlCached,
-              isActive,
-              onClick: onClickIndicator,
-            }),
-            headIndex,
-          });
-          return;
-        }
-        if (cursorTop > screenVisibleBottom) {
-          belowIndicators.push({
-            el: createOffScreenIndicator({
-              direction: 'below',
-              clientId,
-              color: editors.color,
-              name: editors.name,
-              imageUrlCached: editors.imageUrlCached,
-              isActive,
-              onClick: onClickIndicator,
-            }),
-            headIndex,
-          });
-          return;
-        }
+      if (classification !== 'in-viewport') {
+        const target =
+          classification === 'above' ? aboveIndicators : belowIndicators;
+        target.push({
+          el: createOffScreenIndicator({
+            direction: classification,
+            clientId,
+            color: editors.color,
+            name: editors.name,
+            imageUrlCached: editors.imageUrlCached,
+            isActive,
+            onClick: onClickIndicator,
+          }),
+          headIndex,
+        });
+        return;
       }
 
       // In-viewport: render decorations
-      const start = Math.min(anchor.index, head.index);
-      const end = Math.max(anchor.index, head.index);
+      const start = Math.min(anchor.index, headIndex);
+      const end = Math.max(anchor.index, headIndex);
 
       if (start !== end) {
         decorations.push({
@@ -365,39 +239,38 @@ export class YRichCursorsPluginValue {
       });
     });
 
-    this.decorations = Decoration.set(decorations, true);
-    this.topContainer.replaceChildren(...aboveIndicators.map(({ el }) => el));
-    this.bottomContainer.replaceChildren(
-      ...belowIndicators.map(({ el }) => el),
-    );
+    return { decorations, aboveIndicators, belowIndicators };
+  }
 
-    // Position each indicator horizontally at the remote cursor's column.
-    // coordsAtPos reads layout so it must be deferred to the measure phase.
-    const allIndicators = [...aboveIndicators, ...belowIndicators];
-    if (allIndicators.length > 0) {
-      viewUpdate.view.requestMeasure({
-        read: (view) => {
-          const editorLeft = view.dom.getBoundingClientRect().left;
-          return allIndicators.map(({ headIndex: hi }) => {
-            const coords = view.coordsAtPos(hi, 1);
-            if (coords != null) {
-              return coords.left - editorLeft;
-            }
-            // Fallback for virtualised positions (outside CodeMirror's viewport)
-            const line = view.state.doc.lineAt(hi);
-            const col = hi - line.from;
-            const contentLeft =
-              view.contentDOM.getBoundingClientRect().left - editorLeft;
-            return contentLeft + col * view.defaultCharacterWidth;
-          });
-        },
-        write: (positions) => {
-          allIndicators.forEach(({ el }, i) => {
-            el.style.left = `${positions[i]}px`;
-            el.style.transform = 'translateX(-50%)';
-          });
-        },
-      });
-    }
+  /** Defers horizontal positioning to CodeMirror's measure phase. */
+  private positionIndicatorsHorizontally(
+    viewUpdate: ViewUpdate,
+    indicators: IndicatorEntry[],
+  ): void {
+    if (indicators.length === 0) return;
+
+    viewUpdate.view.requestMeasure({
+      read: (view) => {
+        const editorLeft = view.dom.getBoundingClientRect().left;
+        return indicators.map(({ headIndex }) => {
+          const coords = view.coordsAtPos(headIndex, 1);
+          if (coords != null) {
+            return coords.left - editorLeft;
+          }
+          // Fallback for virtualised positions (outside CodeMirror's viewport)
+          const line = view.state.doc.lineAt(headIndex);
+          const col = headIndex - line.from;
+          const contentLeft =
+            view.contentDOM.getBoundingClientRect().left - editorLeft;
+          return contentLeft + col * view.defaultCharacterWidth;
+        });
+      },
+      write: (positions) => {
+        indicators.forEach(({ el }, i) => {
+          el.style.left = `${positions[i]}px`;
+          el.style.transform = 'translateX(-50%)';
+        });
+      },
+    });
   }
 }

+ 95 - 0
packages/editor/src/client/services-internal/extensions/y-rich-cursors/viewport-classification.ts

@@ -0,0 +1,95 @@
+import type { EditorView } from '@codemirror/view';
+
+export type CursorVisibility = 'above' | 'below' | 'in-viewport';
+
+/**
+ * Pre-computed viewport context, created once per update() call.
+ *
+ * Two classification strategies:
+ * - "ranged": visibleRanges is a true sub-range of viewport
+ *   (fixed-height editor, or tests with styled heights)
+ * - "coords": visibleRanges == viewport, page handles scrolling
+ *   (GROWI's page-scroll production setup).
+ *   Also covers the degenerate case (scrollDOM height == 0 in jsdom)
+ *   where screenVisibleTop == screenVisibleBottom, causing cursors
+ *   with positive lineBlock.top to be classified as "below".
+ */
+export type ViewportContext =
+  | { readonly kind: 'ranged'; readonly vpFrom: number; readonly vpTo: number }
+  | {
+      readonly kind: 'coords';
+      readonly scrollDOMTop: number;
+      readonly scrollTop: number;
+      readonly screenVisibleTop: number;
+      readonly screenVisibleBottom: number;
+    };
+
+/**
+ * Determines the viewport classification mode from the current editor state.
+ *
+ * `getBoundingClientRect()` is a raw DOM call (not a CodeMirror layout read)
+ * so it is safe to call during `update()`. `lineBlockAt()` (used later in
+ * `classifyCursorPosition`) reads the stored height map and is also safe.
+ */
+export function createViewportContext(view: EditorView): ViewportContext {
+  const { visibleRanges, viewport } = view;
+  const { from: viewportFrom, to: viewportTo } = viewport;
+
+  const hasVisibleRanges = visibleRanges.length > 0;
+
+  // rangedMode: visibleRanges is a meaningful sub-range of viewport.
+  // Requires the visible area to be non-empty (to > from) so that a 0-height
+  // editor (jsdom degenerate) doesn't accidentally classify every cursor as
+  // off-screen via a vpTo of 0.
+  const rangedMode =
+    hasVisibleRanges &&
+    visibleRanges[visibleRanges.length - 1].to > visibleRanges[0].from &&
+    (visibleRanges[0].from > viewportFrom ||
+      visibleRanges[visibleRanges.length - 1].to < viewportTo);
+
+  if (rangedMode) {
+    return {
+      kind: 'ranged',
+      vpFrom: visibleRanges[0].from,
+      vpTo: visibleRanges[visibleRanges.length - 1].to,
+    };
+  }
+
+  // coords mode: compare screen Y of cursor against the editor's visible rect.
+  // When scrollDOMRect.height == 0 (jsdom), screenVisibleTop == screenVisibleBottom,
+  // so cursors with positive lineBlock.top are classified as "below" by the
+  // natural comparison in classifyCursorPosition.
+  const scrollDOMRect = view.scrollDOM.getBoundingClientRect();
+  return {
+    kind: 'coords',
+    scrollDOMTop: scrollDOMRect.top,
+    scrollTop: view.scrollDOM.scrollTop,
+    screenVisibleTop: Math.max(scrollDOMRect.top, 0),
+    screenVisibleBottom: Math.min(scrollDOMRect.bottom, window.innerHeight),
+  };
+}
+
+/**
+ * Classifies a remote cursor as above, below, or within the visible viewport.
+ */
+export function classifyCursorPosition(
+  ctx: ViewportContext,
+  view: EditorView,
+  headIndex: number,
+): CursorVisibility {
+  switch (ctx.kind) {
+    case 'ranged': {
+      if (headIndex < ctx.vpFrom) return 'above';
+      if (headIndex > ctx.vpTo) return 'below';
+      return 'in-viewport';
+    }
+    case 'coords': {
+      const lineBlock = view.lineBlockAt(headIndex);
+      const cursorTop = ctx.scrollDOMTop + lineBlock.top - ctx.scrollTop;
+      const cursorBottom = ctx.scrollDOMTop + lineBlock.bottom - ctx.scrollTop;
+      if (cursorBottom < ctx.screenVisibleTop) return 'above';
+      if (cursorTop > ctx.screenVisibleBottom) return 'below';
+      return 'in-viewport';
+    }
+  }
+}