Yuki Takei 1 неделя назад
Родитель
Сommit
52899a15ff

+ 15 - 15
.kiro/specs/collaborative-editor-awareness/tasks.md

@@ -42,8 +42,8 @@
 - [x] 5.2 Test cursor position broadcasting and remote cursor rendering in the editor view
   - _Requirements: 3.6, 3.7_
 
-- [ ] 6. Add baseTheme with Overlay Positioning, Hover, and Opacity Rules
-- [ ] 6.1 (P) Create the EditorView.baseTheme defining all cursor overlay CSS rules
+- [x] 6. Add baseTheme with Overlay Positioning, Hover, and Opacity Rules
+- [x] 6.1 (P) Create the EditorView.baseTheme defining all cursor overlay CSS rules
   - Define overlay positioning for the cursor flag element: absolute below the caret, centered on the 1px caret line
   - Set avatar and initials fallback sizes to 16×16 pixels with circular clipping
   - Set up the two-step hover cascade: pointer-events none by default on the flag, enabled on caret hover
@@ -52,15 +52,15 @@
   - Include the theme extension in the return value of the rich cursors factory function
   - _Requirements: 3.1, 3.2, 3.3, 3.8, 3.9_
 
-- [ ] 6.2 (P) Define off-screen container and indicator styles in the same baseTheme
+- [x] 6.2 (P) Define off-screen container and indicator styles in the same baseTheme
   - Define the top and bottom off-screen containers as absolute-positioned, flex-layout, pointer-events none
   - Define the off-screen indicator as flex with gap, semi-transparent by default, full opacity with the active class
   - Define the off-screen avatar and initials with 16×16 sizing matching the in-editor widget
   - Define the arrow indicator styling
   - _Requirements: 4.5, 4.7_
 
-- [ ] 7. Rework RichCaretWidget for Overlay Avatar with Activity State
-- [ ] 7.1 Rebuild the widget DOM to render as an overlay with avatar, initials fallback, and hover-revealed name label
+- [x] 7. Rework RichCaretWidget for Overlay Avatar with Activity State
+- [x] 7.1 Rebuild the widget DOM to render as an overlay with avatar, initials fallback, and hover-revealed name label
   - Restructure the widget DOM to wrap the avatar and name label inside a flag container element positioned as an overlay below the caret
   - Render the avatar image at 16×16 pixels when the image URL is available, with an error handler that swaps in the initials fallback
   - When no image URL is provided, render the initials fallback directly as a colored circle with the user's initial letters
@@ -69,7 +69,7 @@
   - Update the equality check to include the `isActive` parameter alongside color, name, and image URL
   - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.10_
 
-- [ ] 7.2 Add activity tracking to the ViewPlugin with per-client timers
+- [x] 7.2 Add activity tracking to the ViewPlugin with per-client timers
   - Maintain a map of each remote client's last awareness change timestamp
   - Maintain a map of per-client timer handles for the 3-second inactivity window
   - On awareness change for a remote client, record the current timestamp and reset the client's timer to dispatch a decoration rebuild after 3 seconds
@@ -78,14 +78,14 @@
   - Clear all timers on plugin destruction
   - _Requirements: 3.10_
 
-- [ ] 8. Build Off-Screen Cursor Indicators
-- [ ] 8.1 Create persistent off-screen containers attached to the editor DOM
+- [x] 8. Build Off-Screen Cursor Indicators
+- [x] 8.1 Create persistent off-screen containers attached to the editor DOM
   - Create top and bottom container elements in the ViewPlugin constructor and append them to the editor's outer DOM element
   - The containers remain in the DOM for the plugin's lifetime (empty when no off-screen cursors exist)
   - Remove both containers in the plugin's destroy method
   - _Requirements: 4.7_
 
-- [ ] 8.2 Classify remote cursors by viewport position and render off-screen indicators
+- [x] 8.2 Classify remote cursors by viewport position and render off-screen indicators
   - After computing absolute positions for all remote cursors in the update method, compare each position against the current viewport range
   - For cursors above the viewport, build an indicator element (arrow up + avatar or initials fallback) and add it to the top container
   - For cursors below the viewport, build an indicator element (arrow down + avatar or initials fallback) and add it to the bottom container
@@ -95,8 +95,8 @@
   - Rebuild containers when the viewport changes or awareness changes
   - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_
 
-- [ ] 9. Unit Tests for Updated Widget and Off-Screen Indicators
-- [ ] 9.1 (P) Test the updated widget DOM structure, overlay flag, sizing, and isActive class behavior
+- [x] 9. Unit Tests for Updated Widget and Off-Screen Indicators
+- [x] 9.1 (P) Test the updated widget DOM structure, overlay flag, sizing, and isActive class behavior
   - Verify the widget renders a flag container with position absolute styling inside the caret element
   - Verify the avatar image renders at 16×16 when image URL is provided
   - Verify the initials fallback renders with the user's color as background when no image URL is given
@@ -106,18 +106,18 @@
   - Verify the equality check returns false when isActive differs
   - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.10_
 
-- [ ] 9.2 (P) Test off-screen indicator DOM construction and avatar fallback
+- [x] 9.2 (P) Test off-screen indicator DOM construction and avatar fallback
   - Verify an off-screen indicator element contains an arrow element and an avatar image when image URL is provided
   - Verify an off-screen indicator falls back to an initials element when image URL is absent
   - Verify the active CSS class is applied to the indicator element when the client is active
   - _Requirements: 4.1, 4.2, 4.4_
 
-- [ ] 10. Integration Tests for Viewport Classification and Activity Tracking
-- [ ] 10.1 Test that remote cursors outside the viewport are excluded from widget decorations
+- [x] 10. Integration Tests for Viewport Classification and Activity Tracking
+- [x] 10.1 Test that remote cursors outside the viewport are excluded from widget decorations
   - Simulate a remote client with a cursor position beyond the viewport range and verify that no widget decoration is created for that client
   - _Requirements: 4.3, 4.6_
 
-- [ ] 10.2 Test activity tracking timer lifecycle with fake timers
+- [x] 10.2 Test activity tracking timer lifecycle with fake timers
   - Simulate an awareness change for a remote client and verify the client is marked as active
   - Advance fake timers by 3 seconds and verify a decoration rebuild is triggered, resulting in the client being marked as inactive
   - Simulate a new awareness change before the timer expires and verify the timer is reset

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

@@ -1,6 +1,10 @@
+import { EditorState } from '@codemirror/state';
+import { EditorView } from '@codemirror/view';
+import { yCollab } from 'y-codemirror.next';
 import * as Y from 'yjs';
 
 import type { EditingClient } from '../../../interfaces';
+import { yRichCursors } from './y-rich-cursors';
 
 /**
  * Integration tests for collaborative awareness flow.
@@ -96,7 +100,7 @@ class FakeAwareness {
     if (!this.listeners.has(event)) {
       this.listeners.set(event, new Set());
     }
-    this.listeners.get(event)!.add(listener);
+    this.listeners.get(event)?.add(listener);
   }
 
   off(event: string, listener: (...args: unknown[]) => void): void {
@@ -178,7 +182,7 @@ describe('Task 5.1 — Awareness update flow to EditingUserList', () => {
     awareness.setRemoteClientState(2, { editors: remoteClient });
 
     expect(onEditorsUpdated).toHaveBeenCalled();
-    const lastCall = onEditorsUpdated.mock.calls.at(-1)![0] as EditingClient[];
+    const lastCall = onEditorsUpdated.mock.calls.at(-1)?.[0] as EditingClient[];
     expect(lastCall.map((c) => c.name)).toContain('Bob');
   });
 
@@ -231,11 +235,11 @@ describe('Task 5.2 — Cursor position broadcasting', () => {
     expect(stored).toBeDefined();
 
     const restoredAnchor = Y.createAbsolutePositionFromRelativePosition(
-      stored!.anchor,
+      stored?.anchor,
       ydoc,
     );
     const restoredHead = Y.createAbsolutePositionFromRelativePosition(
-      stored!.head,
+      stored?.head,
       ydoc,
     );
 
@@ -263,14 +267,141 @@ describe('Task 5.2 — Cursor position broadcasting', () => {
 
     // Verify that positions can be reconstructed (widget would use this)
     const restoredAnchor = Y.createAbsolutePositionFromRelativePosition(
-      remoteState!.cursor!.anchor,
+      remoteState?.cursor?.anchor,
       ydoc,
     );
     const restoredHead = Y.createAbsolutePositionFromRelativePosition(
-      remoteState!.cursor!.head,
+      remoteState?.cursor?.head,
       ydoc,
     );
     expect(restoredAnchor?.index).toBe(2);
     expect(restoredHead?.index).toBe(6);
   });
 });
+
+// ---------------------------------------------------------------------------
+// Task 10.1 — Viewport classification (off-screen exclusion)
+// ---------------------------------------------------------------------------
+
+describe('Task 10.1 — Remote cursors outside the viewport are excluded from widget decorations', () => {
+  it('does not create widget decorations for a cursor positioned beyond the viewport', () => {
+    const ydoc = new Y.Doc({ guid: 'viewport-test' });
+    const ytext = ydoc.getText('codemirror');
+    // Insert enough content so the remote cursor can be outside the viewport
+    const longContent = 'Line\n'.repeat(200);
+    ytext.insert(0, longContent);
+
+    const awareness = new FakeAwareness(ydoc);
+
+    const state = EditorState.create({
+      doc: longContent,
+      extensions: [yCollab(ytext, null), yRichCursors(awareness as never)],
+    });
+
+    // Create a view with a small height so the viewport is limited
+    const container = document.createElement('div');
+    container.style.height = '100px';
+    container.style.overflow = 'auto';
+    document.body.appendChild(container);
+
+    const view = new EditorView({ state, parent: container });
+
+    // Set a remote client with cursor at a far-away position (end of doc)
+    const farIndex = longContent.length - 10;
+    const anchor = Y.createRelativePositionFromTypeIndex(ytext, farIndex);
+    const head = Y.createRelativePositionFromTypeIndex(ytext, farIndex);
+    const remoteClient = makeClient(999, 'FarUser');
+
+    awareness.setRemoteClientState(999, {
+      editors: remoteClient,
+      cursor: { anchor, head },
+    });
+
+    // Force a view update cycle
+    view.dispatch({});
+
+    // Check that no cm-yRichCaret widget is rendered in the visible content
+    const carets = view.dom.querySelectorAll('.cm-yRichCaret');
+    expect(carets.length).toBe(0);
+
+    view.destroy();
+    container.remove();
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Task 10.2 — Activity tracking timer lifecycle
+// ---------------------------------------------------------------------------
+
+describe('Task 10.2 — Activity tracking timer lifecycle with fake timers', () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it('marks a remote client as active after awareness change, then inactive after 3s', () => {
+    const ydoc = new Y.Doc({ guid: 'activity-test' });
+    const ytext = ydoc.getText('codemirror');
+    ytext.insert(0, 'Hello World');
+
+    const awareness = new FakeAwareness(ydoc);
+
+    const state = EditorState.create({
+      doc: 'Hello World',
+      extensions: [yCollab(ytext, null), yRichCursors(awareness as never)],
+    });
+
+    const container = document.createElement('div');
+    document.body.appendChild(container);
+    const view = new EditorView({ state, parent: container });
+
+    // Set a remote client with cursor in viewport
+    const anchor = Y.createRelativePositionFromTypeIndex(ytext, 0);
+    const head = Y.createRelativePositionFromTypeIndex(ytext, 3);
+    const remoteClient = makeClient(50, 'ActiveUser');
+
+    awareness.setRemoteClientState(50, {
+      editors: remoteClient,
+      cursor: { anchor, head },
+    });
+
+    // Force update
+    view.dispatch({});
+
+    // The widget should have the active class (just changed)
+    let carets = view.dom.querySelectorAll(
+      '.cm-yRichCursorFlag.cm-yRichCursorActive',
+    );
+    expect(carets.length).toBe(1);
+
+    // Advance 3 seconds — timer fires, triggering a decoration rebuild
+    vi.advanceTimersByTime(3000);
+
+    // After the timer dispatch, the widget should lose the active class
+    carets = view.dom.querySelectorAll(
+      '.cm-yRichCursorFlag.cm-yRichCursorActive',
+    );
+    expect(carets.length).toBe(0);
+
+    // A new awareness change should re-activate
+    awareness.setRemoteClientState(50, {
+      editors: remoteClient,
+      cursor: {
+        anchor: Y.createRelativePositionFromTypeIndex(ytext, 1),
+        head: Y.createRelativePositionFromTypeIndex(ytext, 5),
+      },
+    });
+    view.dispatch({});
+
+    carets = view.dom.querySelectorAll(
+      '.cm-yRichCursorFlag.cm-yRichCursorActive',
+    );
+    expect(carets.length).toBe(1);
+
+    view.destroy();
+    container.remove();
+  });
+});

+ 180 - 33
packages/editor/src/client/services-internal/extensions/y-rich-cursors.spec.ts

@@ -1,39 +1,45 @@
 import { describe, expect, it } from 'vitest';
 
-import { RichCaretWidget } from './y-rich-cursors';
+import { createOffScreenIndicator, RichCaretWidget } from './y-rich-cursors';
 
 /**
- * Unit tests for RichCaretWidget.
+ * Unit tests for RichCaretWidget and off-screen indicators.
  *
  * Covers:
- * - Task 2.1 / 4.2: DOM construction with image and initials fallback
- * - Requirements: 3.1, 3.2, 3.3, 3.4
+ * - Task 9.1: Updated widget DOM structure, overlay flag, sizing, isActive class
+ * - Task 9.2: Off-screen indicator DOM construction and avatar fallback
+ * - Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.10, 4.1, 4.2, 4.4
  */
 
 describe('RichCaretWidget', () => {
   describe('toDOM()', () => {
     it('renders a cm-yRichCaret span with border color from the color parameter', () => {
-      const widget = new RichCaretWidget('#ff0000', 'Alice', undefined);
+      const widget = new RichCaretWidget('#ff0000', 'Alice', undefined, false);
       const dom = widget.toDOM();
 
       expect(dom.className).toBe('cm-yRichCaret');
       expect(dom.style.borderColor).toBe('#ff0000');
     });
 
-    it('renders a name label with the display name', () => {
-      const widget = new RichCaretWidget('#ff0000', 'Alice', undefined);
+    it('renders a flag container with position relative inside the caret element', () => {
+      const widget = new RichCaretWidget('#ff0000', 'Alice', undefined, false);
       const dom = widget.toDOM();
 
-      const info = dom.querySelector('.cm-yRichCursorInfo');
-      expect(info).not.toBeNull();
-      expect(info?.textContent).toBe('Alice');
+      const flag = dom.querySelector('.cm-yRichCursorFlag');
+      expect(flag).not.toBeNull();
     });
 
-    it('renders an img element when imageUrlCached is provided', () => {
-      const widget = new RichCaretWidget('#ff0000', 'Alice', '/avatar.png');
+    it('renders an img element inside the flag when imageUrlCached is provided', () => {
+      const widget = new RichCaretWidget(
+        '#ff0000',
+        'Alice',
+        '/avatar.png',
+        false,
+      );
       const dom = widget.toDOM();
 
-      const img = dom.querySelector(
+      const flag = dom.querySelector('.cm-yRichCursorFlag');
+      const img = flag?.querySelector(
         'img.cm-yRichCursorAvatar',
       ) as HTMLImageElement | null;
       expect(img).not.toBeNull();
@@ -42,25 +48,30 @@ describe('RichCaretWidget', () => {
     });
 
     it('does NOT render an img element when imageUrlCached is undefined', () => {
-      const widget = new RichCaretWidget('#ff0000', 'Alice', undefined);
+      const widget = new RichCaretWidget('#ff0000', 'Alice', undefined, false);
       const dom = widget.toDOM();
 
       const img = dom.querySelector('img.cm-yRichCursorAvatar');
       expect(img).toBeNull();
     });
 
-    it('renders initials span when imageUrlCached is undefined', () => {
-      const widget = new RichCaretWidget('#ff0000', 'Alice Bob', undefined);
+    it('renders initials span inside the flag when imageUrlCached is undefined', () => {
+      const widget = new RichCaretWidget(
+        '#ff0000',
+        'Alice Bob',
+        undefined,
+        false,
+      );
       const dom = widget.toDOM();
 
-      const initials = dom.querySelector('.cm-yRichCursorInitials');
+      const flag = dom.querySelector('.cm-yRichCursorFlag');
+      const initials = flag?.querySelector('.cm-yRichCursorInitials');
       expect(initials).not.toBeNull();
-      // initials are first letters of each word, uppercased
       expect(initials?.textContent).toBe('AB');
     });
 
     it('renders initials for a single-word name', () => {
-      const widget = new RichCaretWidget('#ff0000', 'Alice', undefined);
+      const widget = new RichCaretWidget('#ff0000', 'Alice', undefined, false);
       const dom = widget.toDOM();
 
       const initials = dom.querySelector('.cm-yRichCursorInitials');
@@ -68,7 +79,12 @@ describe('RichCaretWidget', () => {
     });
 
     it('replaces img with initials span on onerror', () => {
-      const widget = new RichCaretWidget('#0000ff', 'Bob', '/broken.png');
+      const widget = new RichCaretWidget(
+        '#0000ff',
+        'Bob',
+        '/broken.png',
+        false,
+      );
       const dom = widget.toDOM();
 
       const img = dom.querySelector(
@@ -84,40 +100,73 @@ describe('RichCaretWidget', () => {
       expect(initials).not.toBeNull();
       expect(initials?.textContent).toBe('B');
     });
+
+    it('renders a name label inside the flag container', () => {
+      const widget = new RichCaretWidget('#ff0000', 'Alice', undefined, false);
+      const dom = widget.toDOM();
+
+      const flag = dom.querySelector('.cm-yRichCursorFlag');
+      const info = flag?.querySelector('.cm-yRichCursorInfo');
+      expect(info).not.toBeNull();
+      expect(info?.textContent).toBe('Alice');
+    });
+
+    it('applies cm-yRichCursorActive class to the flag element when isActive is true', () => {
+      const widget = new RichCaretWidget('#ff0000', 'Alice', undefined, true);
+      const dom = widget.toDOM();
+
+      const flag = dom.querySelector('.cm-yRichCursorFlag');
+      expect(flag?.classList.contains('cm-yRichCursorActive')).toBe(true);
+    });
+
+    it('does NOT apply cm-yRichCursorActive class to the flag when isActive is false', () => {
+      const widget = new RichCaretWidget('#ff0000', 'Alice', undefined, false);
+      const dom = widget.toDOM();
+
+      const flag = dom.querySelector('.cm-yRichCursorFlag');
+      expect(flag?.classList.contains('cm-yRichCursorActive')).toBe(false);
+    });
   });
 
   describe('eq()', () => {
-    it('returns true when color, name, and imageUrlCached all match', () => {
-      const a = new RichCaretWidget('#ff0000', 'Alice', '/avatar.png');
-      const b = new RichCaretWidget('#ff0000', 'Alice', '/avatar.png');
+    it('returns true when color, name, imageUrlCached, and isActive all match', () => {
+      const a = new RichCaretWidget('#ff0000', 'Alice', '/avatar.png', false);
+      const b = new RichCaretWidget('#ff0000', 'Alice', '/avatar.png', false);
 
       expect(a.eq(b)).toBe(true);
     });
 
     it('returns false when color differs', () => {
-      const a = new RichCaretWidget('#ff0000', 'Alice', '/avatar.png');
-      const b = new RichCaretWidget('#0000ff', 'Alice', '/avatar.png');
+      const a = new RichCaretWidget('#ff0000', 'Alice', '/avatar.png', false);
+      const b = new RichCaretWidget('#0000ff', 'Alice', '/avatar.png', false);
 
       expect(a.eq(b)).toBe(false);
     });
 
     it('returns false when name differs', () => {
-      const a = new RichCaretWidget('#ff0000', 'Alice', '/avatar.png');
-      const b = new RichCaretWidget('#ff0000', 'Bob', '/avatar.png');
+      const a = new RichCaretWidget('#ff0000', 'Alice', '/avatar.png', false);
+      const b = new RichCaretWidget('#ff0000', 'Bob', '/avatar.png', false);
 
       expect(a.eq(b)).toBe(false);
     });
 
     it('returns false when imageUrlCached differs', () => {
-      const a = new RichCaretWidget('#ff0000', 'Alice', '/avatar.png');
-      const b = new RichCaretWidget('#ff0000', 'Alice', '/other.png');
+      const a = new RichCaretWidget('#ff0000', 'Alice', '/avatar.png', false);
+      const b = new RichCaretWidget('#ff0000', 'Alice', '/other.png', false);
 
       expect(a.eq(b)).toBe(false);
     });
 
     it('returns false when one has imageUrlCached and the other does not', () => {
-      const a = new RichCaretWidget('#ff0000', 'Alice', '/avatar.png');
-      const b = new RichCaretWidget('#ff0000', 'Alice', undefined);
+      const a = new RichCaretWidget('#ff0000', 'Alice', '/avatar.png', false);
+      const b = new RichCaretWidget('#ff0000', 'Alice', undefined, false);
+
+      expect(a.eq(b)).toBe(false);
+    });
+
+    it('returns false when isActive differs', () => {
+      const a = new RichCaretWidget('#ff0000', 'Alice', '/avatar.png', true);
+      const b = new RichCaretWidget('#ff0000', 'Alice', '/avatar.png', false);
 
       expect(a.eq(b)).toBe(false);
     });
@@ -125,15 +174,113 @@ describe('RichCaretWidget', () => {
 
   describe('ignoreEvent()', () => {
     it('returns true', () => {
-      const widget = new RichCaretWidget('#ff0000', 'Alice', undefined);
+      const widget = new RichCaretWidget('#ff0000', 'Alice', undefined, false);
       expect(widget.ignoreEvent()).toBe(true);
     });
   });
 
   describe('estimatedHeight', () => {
     it('is -1 (inline widget)', () => {
-      const widget = new RichCaretWidget('#ff0000', 'Alice', undefined);
+      const widget = new RichCaretWidget('#ff0000', 'Alice', undefined, false);
       expect(widget.estimatedHeight).toBe(-1);
     });
   });
 });
+
+describe('createOffScreenIndicator', () => {
+  it('renders an indicator with an upward arrow for direction "above"', () => {
+    const el = createOffScreenIndicator({
+      direction: 'above',
+      color: '#ff0000',
+      name: 'Alice',
+      imageUrlCached: '/avatar.png',
+      isActive: false,
+    });
+
+    const arrow = el.querySelector('.cm-offScreenArrow');
+    expect(arrow).not.toBeNull();
+    expect(arrow?.textContent).toBe('↑');
+  });
+
+  it('renders an indicator with a downward arrow for direction "below"', () => {
+    const el = createOffScreenIndicator({
+      direction: 'below',
+      color: '#ff0000',
+      name: 'Alice',
+      imageUrlCached: '/avatar.png',
+      isActive: false,
+    });
+
+    const arrow = el.querySelector('.cm-offScreenArrow');
+    expect(arrow?.textContent).toBe('↓');
+  });
+
+  it('renders an avatar image when imageUrlCached is provided', () => {
+    const el = createOffScreenIndicator({
+      direction: 'above',
+      color: '#ff0000',
+      name: 'Alice',
+      imageUrlCached: '/avatar.png',
+      isActive: false,
+    });
+
+    const img = el.querySelector(
+      'img.cm-offScreenAvatar',
+    ) as HTMLImageElement | null;
+    expect(img).not.toBeNull();
+    expect(img?.src).toContain('/avatar.png');
+  });
+
+  it('renders initials fallback when imageUrlCached is undefined', () => {
+    const el = createOffScreenIndicator({
+      direction: 'above',
+      color: '#ff0000',
+      name: 'Alice',
+      imageUrlCached: undefined,
+      isActive: false,
+    });
+
+    const img = el.querySelector('img.cm-offScreenAvatar');
+    expect(img).toBeNull();
+
+    const initials = el.querySelector('.cm-offScreenInitials');
+    expect(initials).not.toBeNull();
+    expect(initials?.textContent).toBe('A');
+  });
+
+  it('applies cm-yRichCursorActive class when isActive is true', () => {
+    const el = createOffScreenIndicator({
+      direction: 'above',
+      color: '#ff0000',
+      name: 'Alice',
+      imageUrlCached: '/avatar.png',
+      isActive: true,
+    });
+
+    expect(el.classList.contains('cm-yRichCursorActive')).toBe(true);
+  });
+
+  it('does NOT apply cm-yRichCursorActive class when isActive is false', () => {
+    const el = createOffScreenIndicator({
+      direction: 'above',
+      color: '#ff0000',
+      name: 'Alice',
+      imageUrlCached: '/avatar.png',
+      isActive: false,
+    });
+
+    expect(el.classList.contains('cm-yRichCursorActive')).toBe(false);
+  });
+
+  it('applies border-color from the color parameter', () => {
+    const el = createOffScreenIndicator({
+      direction: 'above',
+      color: '#ff0000',
+      name: 'Alice',
+      imageUrlCached: undefined,
+      isActive: false,
+    });
+
+    expect(el.style.borderColor).toBe('#ff0000');
+  });
+});

+ 320 - 26
packages/editor/src/client/services-internal/extensions/y-rich-cursors.ts

@@ -3,7 +3,7 @@ import { Annotation, RangeSet } from '@codemirror/state';
 import type { DecorationSet, ViewUpdate } from '@codemirror/view';
 import {
   Decoration,
-  type EditorView,
+  EditorView,
   ViewPlugin,
   WidgetType,
 } from '@codemirror/view';
@@ -16,7 +16,7 @@ type Awareness = WebsocketProvider['awareness'];
 import type { EditingClient } from '../../../interfaces';
 
 // ---------------------------------------------------------------------------
-// RichCaretWidget
+// Helpers
 // ---------------------------------------------------------------------------
 
 /** Derives initials (up to 2 letters) from a display name. */
@@ -28,15 +28,20 @@ function toInitials(name: string): string {
   ).toUpperCase();
 }
 
+// ---------------------------------------------------------------------------
+// RichCaretWidget
+// ---------------------------------------------------------------------------
+
 /**
- * CodeMirror WidgetType that renders a cursor caret with user name and avatar.
+ * CodeMirror WidgetType that renders a cursor caret with an overlay flag
+ * containing avatar image (or initials fallback) and hover-revealed name label.
  *
  * DOM structure:
  * <span class="cm-yRichCaret" style="border-color: {color}">
- *   <img class="cm-yRichCursorAvatar" src="{imageUrlCached}" alt="{name}" />
- *   <!-- OR when no image: -->
- *   <span class="cm-yRichCursorInitials">{initials}</span>
- *   <span class="cm-yRichCursorInfo" style="background-color: {color}">{name}</span>
+ *   <span class="cm-yRichCursorFlag [cm-yRichCursorActive]">
+ *     <img class="cm-yRichCursorAvatar" />  OR  <span class="cm-yRichCursorInitials" />
+ *     <span class="cm-yRichCursorInfo" style="background-color: {color}">{name}</span>
+ *   </span>
  * </span>
  */
 export class RichCaretWidget extends WidgetType {
@@ -44,6 +49,7 @@ export class RichCaretWidget extends WidgetType {
     readonly color: string,
     readonly name: string,
     readonly imageUrlCached: string | undefined,
+    readonly isActive: boolean,
   ) {
     super();
   }
@@ -53,31 +59,33 @@ export class RichCaretWidget extends WidgetType {
     caret.className = 'cm-yRichCaret';
     caret.style.borderColor = this.color;
 
+    const flag = document.createElement('span');
+    flag.className = 'cm-yRichCursorFlag';
+    if (this.isActive) {
+      flag.classList.add('cm-yRichCursorActive');
+    }
+
     if (this.imageUrlCached) {
       const img = document.createElement('img');
       img.className = 'cm-yRichCursorAvatar';
       img.src = this.imageUrlCached;
       img.alt = this.name;
       img.onerror = () => {
-        const initials = document.createElement('span');
-        initials.className = 'cm-yRichCursorInitials';
-        initials.textContent = toInitials(this.name);
+        const initials = createInitialsElement(this.name, this.color);
         img.replaceWith(initials);
       };
-      caret.appendChild(img);
+      flag.appendChild(img);
     } else {
-      const initials = document.createElement('span');
-      initials.className = 'cm-yRichCursorInitials';
-      initials.textContent = toInitials(this.name);
-      caret.appendChild(initials);
+      flag.appendChild(createInitialsElement(this.name, this.color));
     }
 
     const info = document.createElement('span');
     info.className = 'cm-yRichCursorInfo';
     info.style.backgroundColor = this.color;
     info.textContent = this.name;
-    caret.appendChild(info);
+    flag.appendChild(info);
 
+    caret.appendChild(flag);
     return caret;
   }
 
@@ -86,7 +94,8 @@ export class RichCaretWidget extends WidgetType {
     return (
       other.color === this.color &&
       other.name === this.name &&
-      other.imageUrlCached === this.imageUrlCached
+      other.imageUrlCached === this.imageUrlCached &&
+      other.isActive === this.isActive
     );
   }
 
@@ -99,6 +108,71 @@ export class RichCaretWidget extends WidgetType {
   }
 }
 
+// ---------------------------------------------------------------------------
+// Off-Screen Indicator
+// ---------------------------------------------------------------------------
+
+function createInitialsElement(name: string, color: string): HTMLSpanElement {
+  const el = document.createElement('span');
+  el.className = 'cm-yRichCursorInitials';
+  el.style.backgroundColor = color;
+  el.textContent = toInitials(name);
+  return el;
+}
+
+type OffScreenIndicatorOptions = {
+  direction: 'above' | 'below';
+  color: string;
+  name: string;
+  imageUrlCached: string | undefined;
+  isActive: boolean;
+};
+
+/**
+ * Creates an off-screen indicator DOM element for a remote cursor
+ * that is outside the visible viewport.
+ */
+export function createOffScreenIndicator(
+  opts: OffScreenIndicatorOptions,
+): HTMLElement {
+  const { direction, color, name, imageUrlCached, isActive } = opts;
+
+  const indicator = document.createElement('span');
+  indicator.className = 'cm-offScreenIndicator';
+  indicator.style.borderColor = color;
+  if (isActive) {
+    indicator.classList.add('cm-yRichCursorActive');
+  }
+
+  const arrow = document.createElement('span');
+  arrow.className = 'cm-offScreenArrow';
+  arrow.textContent = direction === 'above' ? '↑' : '↓';
+  indicator.appendChild(arrow);
+
+  if (imageUrlCached) {
+    const img = document.createElement('img');
+    img.className = 'cm-offScreenAvatar';
+    img.src = imageUrlCached;
+    img.alt = name;
+    img.onerror = () => {
+      const initials = document.createElement('span');
+      initials.className = 'cm-offScreenInitials';
+      initials.style.backgroundColor = color;
+      initials.textContent = toInitials(name);
+      img.replaceWith(initials);
+    };
+    indicator.appendChild(img);
+  } else {
+    const initials = document.createElement('span');
+    initials.className = 'cm-offScreenInitials';
+    initials.style.backgroundColor = color;
+    initials.textContent = toInitials(name);
+    indicator.appendChild(initials);
+  }
+
+  return indicator;
+}
+
 // ---------------------------------------------------------------------------
 // yRichCursors ViewPlugin
 // ---------------------------------------------------------------------------
@@ -122,13 +196,61 @@ class YRichCursorsPluginValue {
     removed: number[];
   }) => void;
 
-  constructor(view: EditorView, awareness: Awareness) {
+  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;
+
+  constructor(
+    private readonly view: EditorView,
+    awareness: Awareness,
+  ) {
     this.awareness = awareness;
     this.decorations = RangeSet.of([]);
 
+    // Create off-screen containers
+    this.topContainer = document.createElement('div');
+    this.topContainer.className = 'cm-offScreenTop';
+    this.bottomContainer = document.createElement('div');
+    this.bottomContainer.className = 'cm-offScreenBottom';
+    view.dom.appendChild(this.topContainer);
+    view.dom.appendChild(this.bottomContainer);
+
     this.changeListener = ({ added, updated, removed }) => {
       const clients = added.concat(updated).concat(removed);
-      if (clients.findIndex((id) => id !== awareness.doc.clientID) >= 0) {
+      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);
+          }
+        }
+
         view.dispatch({
           annotations: [yRichCursorsAnnotation.of([])],
         });
@@ -139,6 +261,15 @@ class YRichCursorsPluginValue {
 
   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.topContainer.remove();
+    this.bottomContainer.remove();
   }
 
   update(viewUpdate: ViewUpdate): void {
@@ -182,11 +313,17 @@ class YRichCursorsPluginValue {
     // Rebuild remote cursor decorations
     if (ytext == null || ydoc == null) {
       this.decorations = RangeSet.of([]);
+      this.topContainer.replaceChildren();
+      this.bottomContainer.replaceChildren();
       return;
     }
 
     const decorations: { from: number; to: number; value: Decoration }[] = [];
+    const aboveIndicators: HTMLElement[] = [];
+    const belowIndicators: HTMLElement[] = [];
     const localClientId = this.awareness.doc.clientID;
+    const { from: vpFrom, to: vpTo } = viewUpdate.view.viewport;
+    const now = Date.now();
 
     this.awareness.getStates().forEach((rawState, clientId) => {
       if (clientId === localClientId) return;
@@ -217,6 +354,36 @@ class YRichCursorsPluginValue {
         return;
       }
 
+      const isActive = now - (this.lastActivityMap.get(clientId) ?? 0) < 3000;
+      const headIndex = head.index;
+
+      // Classify: in-viewport or off-screen
+      if (headIndex < vpFrom) {
+        aboveIndicators.push(
+          createOffScreenIndicator({
+            direction: 'above',
+            color: editors.color,
+            name: editors.name,
+            imageUrlCached: editors.imageUrlCached,
+            isActive,
+          }),
+        );
+        return;
+      }
+      if (headIndex > vpTo) {
+        belowIndicators.push(
+          createOffScreenIndicator({
+            direction: 'below',
+            color: editors.color,
+            name: editors.name,
+            imageUrlCached: editors.imageUrlCached,
+            isActive,
+          }),
+        );
+        return;
+      }
+
+      // In-viewport: render decorations
       const start = Math.min(anchor.index, head.index);
       const end = Math.max(anchor.index, head.index);
 
@@ -232,24 +399,149 @@ class YRichCursorsPluginValue {
       }
 
       decorations.push({
-        from: head.index,
-        to: head.index,
+        from: headIndex,
+        to: headIndex,
         value: Decoration.widget({
-          side: head.index - anchor.index > 0 ? -1 : 1,
+          side: headIndex - anchor.index > 0 ? -1 : 1,
           block: false,
           widget: new RichCaretWidget(
             editors.color,
             editors.name,
             editors.imageUrlCached,
+            isActive,
           ),
         }),
       });
     });
 
     this.decorations = Decoration.set(decorations, true);
+    this.topContainer.replaceChildren(...aboveIndicators);
+    this.bottomContainer.replaceChildren(...belowIndicators);
   }
 }
 
+// ---------------------------------------------------------------------------
+// baseTheme
+// ---------------------------------------------------------------------------
+
+const richCursorsTheme = EditorView.baseTheme({
+  // Caret line
+  '.cm-yRichCaret': {
+    position: 'relative',
+    borderLeft: '2px solid',
+  },
+
+  // Overlay flag — positioned below the caret
+  '.cm-yRichCursorFlag': {
+    position: 'absolute',
+    top: '100%',
+    left: '-8px',
+    zIndex: '10',
+    pointerEvents: 'none',
+    opacity: '0.4',
+    transition: 'opacity 0.3s ease',
+  },
+  '.cm-yRichCaret:hover .cm-yRichCursorFlag': {
+    pointerEvents: 'auto',
+    opacity: '1',
+  },
+  '.cm-yRichCursorFlag.cm-yRichCursorActive': {
+    opacity: '1',
+  },
+
+  // Avatar image
+  '.cm-yRichCursorAvatar': {
+    width: '16px',
+    height: '16px',
+    borderRadius: '50%',
+    display: 'block',
+  },
+
+  // Initials fallback
+  '.cm-yRichCursorInitials': {
+    width: '16px',
+    height: '16px',
+    borderRadius: '50%',
+    display: 'flex',
+    alignItems: 'center',
+    justifyContent: 'center',
+    color: 'white',
+    fontSize: '8px',
+    fontWeight: 'bold',
+  },
+
+  // Name label — hidden by default, shown on hover
+  '.cm-yRichCursorInfo': {
+    display: 'none',
+    position: 'absolute',
+    top: '0',
+    left: '20px',
+    whiteSpace: 'nowrap',
+    padding: '2px 6px',
+    borderRadius: '3px',
+    color: 'white',
+    fontSize: '12px',
+    lineHeight: '16px',
+  },
+  '.cm-yRichCursorFlag:hover .cm-yRichCursorInfo': {
+    display: 'block',
+  },
+
+  // --- Off-screen containers ---
+  '.cm-offScreenTop, .cm-offScreenBottom': {
+    position: 'absolute',
+    left: '0',
+    right: '0',
+    display: 'flex',
+    gap: '4px',
+    padding: '2px 4px',
+    pointerEvents: 'none',
+    zIndex: '10',
+  },
+  '.cm-offScreenTop': {
+    top: '0',
+  },
+  '.cm-offScreenBottom': {
+    bottom: '0',
+  },
+
+  // Off-screen indicator
+  '.cm-offScreenIndicator': {
+    display: 'flex',
+    alignItems: 'center',
+    gap: '2px',
+    opacity: '0.4',
+    transition: 'opacity 0.3s ease',
+  },
+  '.cm-offScreenIndicator.cm-yRichCursorActive': {
+    opacity: '1',
+  },
+  '.cm-offScreenArrow': {
+    fontSize: '10px',
+    lineHeight: '1',
+  },
+  '.cm-offScreenAvatar': {
+    width: '16px',
+    height: '16px',
+    borderRadius: '50%',
+  },
+  '.cm-offScreenInitials': {
+    width: '16px',
+    height: '16px',
+    borderRadius: '50%',
+    display: 'flex',
+    alignItems: 'center',
+    justifyContent: 'center',
+    color: 'white',
+    fontSize: '8px',
+    fontWeight: 'bold',
+  },
+});
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
 /**
  * Creates a CodeMirror Extension that renders remote user cursors with
  * name labels and avatar images, reading user data from state.editors.
@@ -257,8 +549,10 @@ class YRichCursorsPluginValue {
  * Also broadcasts the local user's cursor position via state.cursor.
  */
 export function yRichCursors(awareness: Awareness): Extension {
-  return ViewPlugin.define(
-    (view) => new YRichCursorsPluginValue(view, awareness),
-    { decorations: (v) => (v as YRichCursorsPluginValue).decorations },
-  );
+  return [
+    ViewPlugin.define((view) => new YRichCursorsPluginValue(view, awareness), {
+      decorations: (v) => (v as YRichCursorsPluginValue).decorations,
+    }),
+    richCursorsTheme,
+  ];
 }