# Design Document: collaborative-editor-awareness
## Overview
**Purpose**: This feature fixes intermittent disappearance of the `EditingUserList` component, upgrades in-editor cursors to display a user's name and avatar, adds off-screen cursor indicators with click-to-scroll navigation, and surfaces username tooltips in `EditingUserList`.
**Users**: All GROWI users who use real-time collaborative page editing. They will see stable editing-user indicators, rich avatar-bearing cursor flags, off-screen indicators they can click to jump to a co-editor's position, and username tooltips on hover in the editing user list.
**Impact**: Modifies `use-collaborative-editor-mode` in `@growi/editor`, replaces the default `yRemoteSelections` cursor plugin with `yRichCursors`, adds off-screen click-to-scroll via a mutable ref pattern, and enhances `EditingUserList` with color-matched borders, click-to-scroll, and username tooltips.
### Goals
- Eliminate `EditingUserList` disappearance caused by `undefined` entries from uninitialized awareness states
- Remove incorrect direct mutation of Yjs-managed `awareness.getStates()` map
- Render remote cursors with display name and profile image avatar
- Read user data exclusively from `state.editors` (GROWI's canonical awareness field), eliminating the current `state.user` mismatch
- Enable click-to-scroll on both `EditingUserList` avatars and off-screen cursor indicators
- Display username tooltips on `EditingUserList` avatar hover without reintroducing the HOC Fragment layout issue
### Non-Goals
- Server-side awareness bridging (covered in `collaborative-editor` spec)
- Upgrading `y-codemirror.next` or `yjs`
- Cursor rendering for the local user's own cursor
## Architecture
### Existing Architecture Analysis
The current flow has two defects:
1. **`emitEditorList` in `use-collaborative-editor-mode`**: maps `awareness.getStates().values()` to `value.editors`, producing `undefined` for any client whose awareness state has not yet included an `editors` field. The `Array.isArray` guard is always true and does not filter. `EditingUserList` then receives a list containing `undefined`, leading to a React render error that wipes the component.
2. **Cursor field mismatch**: `yCollab(activeText, provider.awareness, { undoManager })` adds `yRemoteSelections`, which reads `state.user.name` and `state.user.color`. GROWI sets `state.editors` (not `state.user`). The result is that all cursors render as "Anonymous" with a default blue color. This is also fixed by the new design.
### Architecture Pattern & Boundary Map
```mermaid
graph TB
subgraph packages_editor
COLLAB[use-collaborative-editor-mode]
RICH[yRichCursors ViewPlugin]
YCOLLAB[yCollab - null awareness]
SCROLLREF[scrollCallbackRef - MutableRef]
end
subgraph y_codemirror_next
YSYNC[ySync - text sync]
YUNDO[yUndoManager - undo]
end
subgraph Yjs_Awareness
AWR[provider.awareness]
end
subgraph apps_app
CM[CodeMirrorEditorMain]
EUL[EditingUserList]
ATOM[editingClientsAtom - Jotai]
ATOM2[scrollToRemoteCursorAtom - Jotai]
end
CM --> COLLAB
COLLAB -->|null awareness| YCOLLAB
YCOLLAB --> YSYNC
YCOLLAB --> YUNDO
COLLAB -->|awareness + scrollCallbackRef| RICH
RICH -->|reads state.editors| AWR
RICH -->|sets state.cursor| AWR
RICH -->|viewport comparison| RICH
RICH -->|indicator click| SCROLLREF
COLLAB -->|filtered clientList| ATOM
ATOM --> EUL
COLLAB -->|scrollFn written to ref| SCROLLREF
COLLAB -->|onScrollToRemoteCursorReady| ATOM2
ATOM2 -->|onUserClick| EUL
```
**Key architectural properties**:
- `yCollab` is called with `null` awareness to suppress the built-in `yRemoteSelections` plugin; text-sync (`ySync`) and undo (`yUndoManager`) are not affected
- `yRichCursors` is added as a separate extension alongside `yCollab`'s output; it owns all awareness-cursor interaction, including in-viewport widget rendering and off-screen indicators
- `state.editors` remains the single source of truth for user identity data
- `state.cursor` (anchor/head relative positions) continues to be used for cursor position broadcasting, consistent with `y-codemirror.next` convention
- Off-screen indicators are managed within the same `yRichCursors` ViewPlugin — it compares each remote cursor's absolute position against `view.visibleRanges` (the actually visible content range, excluding CodeMirror's pre-render buffer) to decide between widget decoration (in-view) and DOM overlay (off-screen)
- **`scrollCallbackRef`** is a `{ current: ((clientId: number) => void) | null }` mutable object created once alongside the `yRichCursors` extension. Because the scroll function is created in a separate `useEffect` from the extension instantiation, passing it as a plain value would require recreating the extension on every update. The mutable ref allows `yRichCursors` to hold a stable reference to the container while the hook silently updates `.current` when the scroll function is registered or cleared.
**Dual-path scroll delivery — why both `scrollCallbackRef` and `onScrollToRemoteCursorReady` coexist**:
The scroll-to-remote-cursor function has two independent consumers that live in fundamentally different runtime contexts:
| Consumer | Context | Why this delivery mechanism |
|----------|---------|----------------------------|
| Off-screen indicator (DOM click) | CodeMirror `ViewPlugin` — vanilla JS, not a React component | Cannot call React hooks (`useAtomValue`) to read a Jotai atom. Needs a plain mutable ref whose `.current` is read at click time. |
| `EditingUserList` avatar click | React component in `apps/app` | Needs a React-compatible state update (Jotai atom) so that `EditorNavbar` re-renders when the scroll function becomes available. A mutable ref change does not trigger re-render. |
Consolidating into a single mechanism is not feasible:
- **Ref-only**: React components that read `useRef` do not re-render when `.current` changes; `EditorNavbar` would receive `null` on initial render and never update.
- **Atom-only**: `yRichCursors` is a CodeMirror `ViewPlugin` class (not a React component) and cannot call `useAtomValue`. Importing the atom directly from `apps/app` into `packages/editor` would violate the monorepo dependency direction (lower package must not depend on higher).
- **Event-emitter**: Considered as an alternative to the callback prop chain. A typed event emitter (e.g., `mitt`) would replace the two callback props (`onEditorsUpdated`, `onScrollToRemoteCursorReady`) with a single event bus prop. However, with only two events, the abstraction cost outweighs the benefit: event emitters introduce implicit coupling (string-keyed subscriptions are harder to trace and not caught by the compiler if one side is renamed), require manual subscribe/unsubscribe lifecycle management (risk of stale handler leaks), and add an external dependency — all for marginal reduction in prop drilling (2 → 1).
The `onScrollToRemoteCursorReady` callback follows the same pattern as the existing `onEditorsUpdated` callback, which also bridges `packages/editor` → `apps/app` across the package boundary via props.
### Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|-------|------------------|-----------------|-------|
| Editor extensions | `y-codemirror.next@0.3.5` | `yCollab` for text-sync and undo; `yRemoteSelectionsTheme` for base caret CSS | No version change; `yRemoteSelections` no longer used |
| Cursor rendering | CodeMirror `ViewPlugin` + `WidgetType` (`@codemirror/view`) | DOM-based cursor widget with avatar `` | No new dependency |
| Awareness | `y-websocket` `awareness` object | State read (`getStates`) and write (`setLocalStateField`) | `Awareness` type derived via `WebsocketProvider['awareness']` — `y-protocols` is not a direct dependency |
## System Flows
### Click-to-Scroll Flow — EditingUserList Avatar (Requirements 6.1–6.5)
```mermaid
sequenceDiagram
participant EUL as EditingUserList
participant ATOM2 as scrollToRemoteCursorAtom
participant HOOK as use-collaborative-editor-mode
participant AW as provider.awareness
participant CM as CodeMirror EditorView
EUL->>ATOM2: onUserClick(clientId)
ATOM2->>HOOK: scrollFn(clientId)
HOOK->>AW: getStates().get(clientId)
AW-->>HOOK: AwarenessState { cursor.head }
Note over HOOK: cursor.head == null → return (no-op, req 6.3)
HOOK->>HOOK: createAbsolutePositionFromRelativePosition(head, activeDoc)
HOOK->>CM: view.dispatch(EditorView.scrollIntoView(pos.index, { y: 'center' }))
```
**Key design decisions**:
- `scrollFn` closes over `codeMirrorEditor` (accessed lazily via `codeMirrorEditor?.view` at call time) so late-mounted editors are handled correctly.
- `activeDoc` (Y.Doc) is captured in the same effect that creates `scrollFn`; the function is invalidated and recreated whenever `activeDoc` or `provider` changes.
- If `cursor.head` is absent (user connected but not focused), the click is silently ignored per requirement 6.3.
### Click-to-Scroll Flow — Off-Screen Indicator (Requirements 6.6–6.7)
```mermaid
sequenceDiagram
participant IND as Off-Screen Indicator DOM
participant REF as scrollCallbackRef
participant HOOK as use-collaborative-editor-mode
participant AW as provider.awareness
participant CM as CodeMirror EditorView
Note over REF: Ref created alongside yRichCursors extension
HOOK->>REF: scrollCallbackRef.current = scrollFn (on setup)
IND->>REF: click handler calls scrollCallbackRef.current(clientId)
REF->>HOOK: scrollFn(clientId)
HOOK->>AW: getStates().get(clientId)
AW-->>HOOK: AwarenessState { cursor.head }
Note over HOOK: cursor.head == null → return (no-op, req 6.3)
HOOK->>HOOK: createAbsolutePositionFromRelativePosition(head, activeDoc)
HOOK->>CM: view.dispatch(EditorView.scrollIntoView(pos.index, { y: 'center' }))
```
**Key design decisions for off-screen click**:
- `scrollCallbackRef` is a plain object `{ current: Fn | null }` created with `useRef` in `use-collaborative-editor-mode` and passed to `yRichCursors(awareness, { onClickIndicator: scrollCallbackRef })`. This is the standard React mutable-ref pattern but without the React import constraint (the `packages/editor` package uses it as a plain typed object).
- The extension is created once; the ref's `.current` value is updated silently by the hook's scroll-function `useEffect`. This avoids recreating CodeMirror extensions on every provider change.
- `createOffScreenIndicator` receives `clientId` and `onClick` callback, attaching a `click` event listener that calls `onClick(clientId)`. The indicator element has `cursor: pointer` via the theme CSS or inline style.
### Awareness Update → EditingUserList
```mermaid
sequenceDiagram
participant AW as provider.awareness
participant HOOK as use-collaborative-editor-mode
participant ATOM as editingClientsAtom
participant EUL as EditingUserList
AW->>HOOK: awareness.on('update', handler)
HOOK->>HOOK: filter: state.editors != null
HOOK->>ATOM: onEditorsUpdated(filteredList)
ATOM->>EUL: re-render with valid EditingClient[]
```
The filter (`value.editors != null`) ensures `EditingUserList` never receives `undefined` entries. The `.delete()` call on `getStates()` is removed; Yjs clears stale entries before emitting `update`.
### Cursor Render Cycle
```mermaid
sequenceDiagram
participant CM as CodeMirror EditorView
participant RC as yRichCursors Plugin
participant AW as provider.awareness
CM->>RC: update(ViewUpdate)
RC->>AW: setLocalStateField('cursor', {anchor, head})
Note over AW,RC: awareness fires 'change' — but changeListener ignores events where only the local client changed
AW-->>RC: awareness.on('change') for REMOTE client
RC->>CM: dispatch with yRichCursorsAnnotation
CM->>RC: update(ViewUpdate) — triggered by annotation
RC->>RC: rebuild decorations from state.editors + state.cursor
```
**Annotation-driven update strategy**: The awareness `change` listener does not call `view.dispatch()` unconditionally — doing so would crash with "Calls to EditorView.update are not allowed while an update is in progress" because `setLocalStateField` in the `update()` method itself triggers an awareness `change` event synchronously. Instead, the listener filters by `clientID`: it dispatches (with a `yRichCursorsAnnotation`) only when at least one **remote** client's state has changed. Local-only awareness changes (from the cursor broadcast in the same `update()` cycle) are silently ignored, and the decoration set is rebuilt in the next `update()` call naturally.
## Requirements Traceability
| Requirement | Summary | Components | Key Interfaces |
|-------------|---------|------------|----------------|
| 1.1 | Filter undefined awareness entries | `use-collaborative-editor-mode` | `emitEditorList` filter |
| 1.2 | Remove `getStates().delete()` mutation | `use-collaborative-editor-mode` | `updateAwarenessHandler` |
| 1.3 | EditingUserList remains stable | `use-collaborative-editor-mode` → `editingClientsAtom` | `onEditorsUpdated` callback |
| 1.4 | Skip entries without `editors` field | `use-collaborative-editor-mode` | `emitEditorList` filter |
| 2.1 | Broadcast user presence via awareness | `use-collaborative-editor-mode` | `awareness.setLocalStateField('editors', ...)` |
| 2.2–2.3 | Socket.IO awareness events (server) | Out of scope — `collaborative-editor` spec | — |
| 2.4 | Display active editors | `EditingUserList` (unchanged) | — |
| 3.1 | Avatar overlay below caret (no block space) | `yRichCursors` | `RichCaretWidget.toDOM()` — `position: absolute` overlay |
| 3.2 | Avatar size (`AVATAR_SIZE` in `theme.ts`) | `yRichCursors` | `RichCaretWidget.toDOM()` — CSS sizing via shared token |
| 3.3 | Name label visible on hover only | `yRichCursors` | CSS `:hover` on `.cm-yRichCursorFlag` |
| 3.4 | Avatar image with initials fallback | `yRichCursors` | `RichCaretWidget.toDOM()` — `` onerror → initials |
| 3.5 | Cursor caret color, fallback background, and avatar border from `state.editors.color` | `yRichCursors` | `RichCaretWidget` constructor + `borderColor` inline style |
| 3.6 | Custom cursor via replacement plugin | `yRichCursors` replaces `yRemoteSelections` | `yCollab(activeText, null, { undoManager })` |
| 3.7 | Cursor updates on awareness change | `yRichCursors` awareness change listener | `awareness.on('change', ...)` |
| 3.8 | Default semi-transparent avatar | `yRichCursors` | CSS `opacity` on `.cm-yRichCursorFlag` |
| 3.9 | Full opacity on hover | `yRichCursors` | CSS `:hover` rule |
| 3.10 | Full opacity during active editing (3s) | `yRichCursors` | `lastActivityMap` + `.cm-yRichCursorActive` class + `setTimeout` |
| 4.1 | Off-screen indicator at top edge with `arrow_drop_up` above avatar | `yRichCursors` | `topContainer` + Material Symbol icon |
| 4.2 | Off-screen indicator at bottom edge with `arrow_drop_down` below avatar | `yRichCursors` | `bottomContainer` + Material Symbol icon |
| 4.3 | No indicator when cursor is in viewport | `yRichCursors` | multi-mode classification in `update()` (rangedMode / coords mode) |
| 4.4 | Same avatar/color as in-editor widget | `yRichCursors` | shared `state.editors` data |
| 4.5 | Indicators positioned at cursor's column | `yRichCursors` | `requestMeasure` → `coordsAtPos` → `left: Xpx; transform: translateX(-50%)` |
| 4.6 | Transition on scroll (indicator ↔ widget) | `yRichCursors` | classification re-run on every `update()` |
| 4.7 | Overlay positioning (no layout impact) | `yRichCursors` | `position: absolute` on `view.dom` |
| 4.8 | Indicator X position derived from cursor column | `yRichCursors` | `view.coordsAtPos` (measure phase) or char-width fallback |
| 4.9 | Arrow always fully opaque in cursor color; avatar fades when idle | `yRichCursors` | `opacity: 1` on `.cm-offScreenArrow`; `opacity: IDLE_OPACITY` on avatar/initials |
| 5.1 | Avatar border color = `editingClient.color` (replaces fixed `border-info`) | `EditingUserList` | Wrapper `` with `style={{ border: '2px solid {color}', borderRadius: '50%' }}` |
| 5.2 | Border weight equivalent to existing border | `EditingUserList` | 2 px solid, same as Bootstrap `border` baseline |
| 5.3 | Color-matched border in overflow popover | `EditingUserList` | Replace `UserPictureList` with inline rendering sharing the same wrapper pattern |
| 6.1 | Click avatar → editor scrolls to that user's cursor | `EditingUserList` + `use-collaborative-editor-mode` | `onUserClick(clientId)` → `scrollFn` → `view.dispatch(scrollIntoView)` |
| 6.2 | Scroll centers cursor vertically | `use-collaborative-editor-mode` | `EditorView.scrollIntoView(pos, { y: 'center' })` |
| 6.3 | No-op when cursor absent from awareness | `use-collaborative-editor-mode` | Guard: `cursor?.head == null → return` |
| 6.4 | `cursor: pointer` on each avatar | `EditingUserList` | CSS `cursor: pointer` on the clickable wrapper element |
| 6.5 | Overflow popover avatars also support click-to-scroll | `EditingUserList` | Inline rendering in popover body shares same `onUserClick` prop |
| 6.6 | Click off-screen indicator → scroll to remote cursor | `yRichCursors` + `use-collaborative-editor-mode` | `scrollCallbackRef.current(clientId)` → same `scrollFn` path as 6.1–6.3 |
| 6.7 | `cursor: pointer` on each off-screen indicator | `yRichCursors` | `cursor: pointer` via theme or inline style in `createOffScreenIndicator` |
| 7.1 | Tooltip shows display name on avatar hover in EditingUserList | `UserPicture` (refactored) | Built-in tooltip renders `@username` + display name via portal child |
| 7.2 | Tooltip on both direct and overflow popover avatars | `EditingUserList` | `noTooltip` removed from `UserPicture`; tooltip renders automatically for all avatars |
| 7.3 | Tooltip coexists with color-matched border and click-to-scroll | `UserPicture` (refactored) | Tooltip is a portal child of the root ``; no Fragment siblings to disturb flex layout |
| 7.4 | Tooltip mechanism does not use `UserPicture` HOC | `UserPicture` (refactored) | `withTooltip` HOC eliminated; tooltip inlined in `UserPicture` render function |
| 7.5 | Tooltip appears with hover-intent delay; disappears on pointer leave | `UserPicture` (refactored) | `UncontrolledTooltip` with `delay={0}` and `fade={false}` (existing behavior preserved) |
## Components and Interfaces
| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies (P0) | Contracts |
|-----------|--------------|--------|--------------|----------------------|-----------|
| `use-collaborative-editor-mode` | packages/editor — Hook | Fix awareness filter bug; compose extensions with rich cursor; expose scroll-to-remote-cursor callback; own `scrollCallbackRef` lifecycle | 1.1–1.4, 2.1, 2.4, 6.1–6.3, 6.6 | `yCollab` (P0), `yRichCursors` (P0) | State |
| `yRichCursors` | packages/editor — Extension | Custom ViewPlugin: broadcasts local cursor position, renders in-viewport cursors with overlay avatar+hover name+activity opacity, renders clickable off-screen indicators at editor edges | 3.1–3.10, 4.1–4.9, 6.6, 6.7 | `@codemirror/view` (P0), `y-websocket awareness` (P0) | Service |
| `CodeMirrorEditorMain` | packages/editor — Component | Bridge: passes `onScrollToRemoteCursorReady` prop from apps/app into `useCollaborativeEditorMode` | 6.1 | `useCollaborativeEditorMode` (P0) | State |
| `scrollToRemoteCursorAtom` | apps/app — Jotai atom | Stores the scroll callback registered by `useCollaborativeEditorMode`; read by EditorNavbar | 6.1 | `jotai` (P0) | State |
| `UserPicture` | packages/ui — Component | Refactored: eliminates `withTooltip` HOC; renders tooltip as portal child of root `` instead of Fragment sibling | 7.1, 7.3–7.5 | `UncontrolledTooltip` (P1) | View |
| `EditingUserList` | apps/app — Component | Renders active editor avatars with color-matched borders, click-to-scroll; tooltips via native `UserPicture` (no `noTooltip`) | 5.1–5.3, 6.1, 6.4–6.5, 7.2 | `EditingClient[]` (P0) | View |
### packages/editor — Hook
#### `use-collaborative-editor-mode` (modified)
| Field | Detail |
|-------|--------|
| Intent | Orchestrates WebSocket provider, awareness, and CodeMirror extension lifecycle for collaborative editing |
| Requirements | 1.1, 1.2, 1.3, 1.4, 2.1, 2.4, 6.1–6.3, 6.6 |
**Responsibilities & Constraints**
- Filters `undefined` awareness entries before calling `onEditorsUpdated`
- Does not mutate `awareness.getStates()` directly
- Composes `yCollab(null)` + `yRichCursors(awareness, { onClickIndicator: scrollCallbackRef })` to achieve text-sync, undo, rich cursor rendering, and off-screen indicator click handling
- Creates and registers a `scrollFn` callback (requirement 6) that resolves a remote user's cursor position and dispatches a CodeMirror scroll effect
- Owns the `scrollCallbackRef` lifecycle: writes `scrollFn` to `scrollCallbackRef.current` when the scroll function is ready; writes `null` on cleanup
**Dependencies**
- Outbound: `yCollab` from `y-codemirror.next` — text-sync and undo (P0)
- Outbound: `yRichCursors` — rich cursor rendering (P0)
- Outbound: `provider.awareness` — read states, set local state (P0)
- Outbound: `EditorView.scrollIntoView` — scroll dispatch (P0)
**Contracts**: State [x]
##### State Management
- **Bug fix — `emitEditorList`**:
```
Before: Array.from(getStates().values(), v => v.editors) // contains undefined
After: Array.from(getStates().values())
.map(v => v.editors)
.filter((v): v is EditingClient => v != null)
```
- **Bug fix — `updateAwarenessHandler`**: Remove `awareness.getStates().delete(clientId)` for all `update.removed` entries; Yjs removes them before emitting the event.
- **Extension composition change**:
```
Before: yCollab(activeText, provider.awareness, { undoManager })
After: [
yCollab(activeText, null, { undoManager }),
yRichCursors(provider.awareness),
]
```
Note: `yCollab` already includes `yUndoManagerKeymap` in its return array, so it must NOT be added separately to avoid keymap duplication. Verify during implementation by inspecting the return value of `yCollab`.
**Implementation Notes**
- Integration: `yCollab` with `null` awareness suppresses `yRemoteSelections` and `yRemoteSelectionsTheme`. Text-sync (`ySync`) and undo (`yUndoManager`) are not affected by the null awareness value.
- Risks: If `y-codemirror.next` is upgraded, re-verify that passing `null` awareness still suppresses only the cursor plugins.
##### Configuration Type Extension (Requirements 6, 6.6)
```typescript
type Configuration = {
user?: IUserHasId;
pageId?: string;
reviewMode?: boolean;
onEditorsUpdated?: (clientList: EditingClient[]) => void;
// called with the scroll function when provider+ydoc are ready; null on cleanup
onScrollToRemoteCursorReady?: (fn: ((clientId: number) => void) | null) => void;
};
```
**`scrollCallbackRef` pattern** (new for req 6.6):
```typescript
// Defined inside useCollaborativeEditorMode, created once per hook mount
const scrollCallbackRef: { current: ((clientId: number) => void) | null } = useRef(null);
// Extension creation effect (depends on provider, activeDoc, codeMirrorEditor)
// scrollCallbackRef is captured by reference — stable across provider changes
yRichCursors(provider.awareness, { onClickIndicator: scrollCallbackRef })
// Scroll function registration effect (same dependencies)
scrollCallbackRef.current = scrollFn; // updated silently — no extension recreation
onScrollToRemoteCursorReady?.(scrollFn);
// Cleanup
scrollCallbackRef.current = null;
onScrollToRemoteCursorReady?.(null);
```
The `scrollFn` is shared by both paths (avatar click via atom, indicator click via ref). Its logic:
```
scrollFn(clientId: number):
1. view = codeMirrorEditor?.view → undefined → return (editor not mounted)
2. rawState = awareness.getStates().get(clientId) as AwarenessState | undefined
3. cursor?.head == null → return (req 6.3: no-op)
4. absPos = Y.createAbsolutePositionFromRelativePosition(cursor.head, activeDoc)
5. absPos == null → return
6. view.dispatch({ effects: EditorView.scrollIntoView(absPos.index, { y: 'center' }) })
```
---
### packages/editor — Extension
#### `yRichCursors` (new)
| Field | Detail |
|-------|--------|
| Intent | CodeMirror ViewPlugin — broadcasts local cursor position, renders in-viewport cursors with overlay avatar and hover-revealed name, renders clickable off-screen indicators pinned to editor edges for cursors outside the viewport |
| Requirements | 3.1–3.10, 4.1–4.9, 6.6, 6.7 |
**Responsibilities & Constraints**
- On each `ViewUpdate`: derives local cursor anchor/head → converts to Yjs relative positions → calls `awareness.setLocalStateField('cursor', { anchor, head })` (matches `state.cursor` convention from `y-codemirror.next`)
- On awareness `change` event: rebuilds decoration set reading `state.editors` (color, name, imageUrlCached) and `state.cursor` (anchor, head) for each remote client
- Does NOT render a cursor for the local client (`clientid === awareness.doc.clientID`)
- Selection highlight (background color from `state.editors.colorLight`) is rendered alongside the caret widget
**Dependencies**
- External: `@codemirror/view` `ViewPlugin`, `WidgetType`, `Decoration`, `EditorView` (P0)
- External: `@codemirror/state` `RangeSet`, `Annotation` (P0) — `Annotation.define()` used for `yRichCursorsAnnotation`
- External: `yjs` `createRelativePositionFromTypeIndex`, `createAbsolutePositionFromRelativePosition` (P0)
- External: `y-codemirror.next` `ySyncFacet` (to access `ytext` for position conversion) (P0)
- External: `y-websocket` — `Awareness` type derived via `WebsocketProvider['awareness']` (not `y-protocols/awareness`, which is not a direct dependency) (P0)
- Inbound: `provider.awareness` passed as parameter (P0)
**Contracts**: Service [x]
##### Service Interface
```typescript
/** Mutable ref container for the scroll-to-remote-cursor function. */
type ScrollCallbackRef = { current: ((clientId: number) => void) | null };
/** Options for the yRichCursors extension. */
type YRichCursorsOptions = {
/**
* Mutable ref holding the scroll-to-remote-cursor callback.
* When set, off-screen indicator clicks invoke ref.current(clientId).
* Null or unset means clicks are no-ops.
*/
onClickIndicator?: ScrollCallbackRef;
};
/**
* Creates a CodeMirror Extension that renders remote user cursors with
* name labels and avatar images, reading user data from state.editors.
* Also broadcasts the local user's cursor position via state.cursor.
* Renders clickable off-screen indicators for cursors outside the viewport.
*/
export function yRichCursors(awareness: Awareness, options?: YRichCursorsOptions): Extension;
```
Preconditions:
- `awareness` is an active `y-websocket` Awareness instance
- `ySyncFacet` is installed by a preceding `yCollab` call so that `ytext` can be resolved for position conversion
- If `options.onClickIndicator` is provided, `onClickIndicator.current` must be set before any indicator click occurs (typically set synchronously by `use-collaborative-editor-mode` in the scroll-function registration effect)
Postconditions:
- Remote cursors within the visible viewport are rendered as `cm-yRichCaret` widget decorations at each remote client's head position
- Remote cursors outside the visible viewport are rendered as off-screen indicator overlays pinned to the top or bottom edge of `view.dom`; each indicator responds to click events by invoking `options.onClickIndicator?.current(clientId)`
- Local cursor position is broadcast to awareness as `state.cursor.{ anchor, head }` on each focus-selection change
Invariants:
- Local client's own cursor is never rendered
- Cursor decorations are rebuilt when awareness `change` fires for **remote** clients (dispatched via `yRichCursorsAnnotation`); local-only changes are ignored to prevent recursive `dispatch` during an in-progress update
- `state.cursor` field is written exclusively by `yRichCursors`; no other plugin or code path may call `awareness.setLocalStateField('cursor', ...)` to avoid data races
- Off-screen indicator click is a no-op when `options.onClickIndicator` is undefined or `.current` is null
##### Widget DOM Structure
```
OR
{name}
```
**CSS strategy**: Applied via `EditorView.baseTheme` in `theme.ts`, exported alongside the ViewPlugin.
Key design decisions:
- **Caret**: Both-side 1px borders with negative margins (zero layout width). Modeled after `yRemoteSelectionsTheme` in `y-codemirror.next`.
- **Overlay flag**: `position: absolute; top: 100%` below the caret. Always hoverable (no `pointer-events: none`), so the avatar is a direct hover target.
- **Name label**: Positioned at `left: 0; z-index: -1` (behind the avatar). Left border-radius matches the avatar circle, creating a tab shape that flows from the avatar. Left padding clears the avatar width. Shown on `.cm-yRichCursorFlag:hover`.
- **Opacity**: `cm-yRichCursorFlag` carries `opacity: IDLE_OPACITY` and transitions to `opacity: 1` on hover or `.cm-yRichCursorActive` (3-second activity window).
- **Avatar border**: `1.5px solid` border in the cursor's `color` with `box-sizing: border-box` so the 20×20 outer size is preserved. Applied via inline `style.borderColor` in `toDOM()` / `createInitialsElement()`.
- **Design tokens**: `AVATAR_SIZE = '20px'` and `IDLE_OPACITY = '0.6'` are defined at the top of `theme.ts` and shared across all cursor/off-screen styles.
**Design decision — CSS-only, no React**: The overlay, sizing, and hover behavior are achievable with `position: absolute` and `:hover`. `document.createElement` in `toDOM()` avoids React's async rendering overhead and context isolation.
**Activity tracking** (JavaScript, within `YRichCursorsPluginValue`):
- `lastActivityMap: Map` — `clientId` → timestamp of last awareness change
- `activeTimers: Map>` — per-client 3-second inactivity timers
- On awareness `change` for remote clients: update timestamp, reset timer. Timer expiry dispatches with `yRichCursorsAnnotation` to trigger decoration rebuild.
- `isActive` is passed to both `RichCaretWidget` and off-screen indicators. `eq()` includes `isActive` so state transitions trigger widget re-creation (at most twice per user per 3-second cycle).
`RichCaretWidget` (extends `WidgetType`):
- Constructor: `RichCaretWidgetOptions` object (`color`, `name`, `imageUrlCached`, `isActive`)
- `toDOM()`: creates the DOM tree above; `onerror` on `` replaces with initials fallback
- `eq(other)`: true when all option fields match
- `estimatedHeight`: `-1` (inline widget), `ignoreEvent()`: `true`
Selection highlight: `Decoration.mark` on selected range with `background-color: {colorLight}`.
##### Off-Screen Cursor Indicators
When a remote cursor's absolute position falls outside the actually visible viewport, the ViewPlugin renders an off-screen indicator instead of a widget decoration.
**Viewport classification — multi-mode strategy**: Because `view.visibleRanges` and `view.viewport` are equal in GROWI's page-scroll editor setup (the editor expands to full content height; the browser page handles scrolling), a single character-position comparison is insufficient. The plugin uses three modes, chosen once per `update()` call:
| Mode | Condition | Method |
|------|-----------|--------|
| **rangedMode** | `visibleRanges` is a non-empty, non-trivial sub-range of `viewport` (internal-scroll editor, or jsdom tests with styled heights) | Compare `headIndex` against `visibleRanges[0].from` / `visibleRanges[last].to` |
| **coords mode** | `visibleRanges == viewport` AND `scrollDOM.getBoundingClientRect().height > 0` (GROWI's page-scroll production setup) | `lineBlockAt(headIndex)` + `scrollDOMRect.top` vs `window.innerHeight` |
| **degenerate** | `scrollRect.height == 0` (jsdom with 0-height container) | No off-screen classification; every cursor gets a widget decoration |
`view.lineBlockAt()` reads stored height-map data (safe to call in `update()`). `scrollDOM.getBoundingClientRect()` is a raw DOM call, not restricted by CodeMirror's "Reading the editor layout isn't allowed during an update" guard.
**DOM management**: The ViewPlugin creates two persistent container elements (`topContainer`, `bottomContainer`) and appends them to `view.dom` in the `constructor`. They are removed in `destroy()`. The containers are always present in the DOM but empty when no off-screen cursors exist in that direction.
```
view.dom (position: relative — already set by CodeMirror)
├── .cm-scroller (managed by CM)
│ └── .cm-content ...
├── .cm-offScreenTop ← topContainer (absolute, top: 0, height: AVATAR_SIZE + 14px)
│ ├── .cm-offScreenIndicator style="left: {colX}px; transform: translateX(-50%)"
│ │ ├── .cm-offScreenArrow (material-symbols-outlined) — "arrow_drop_up"
│ │ └── .cm-offScreenAvatar / .cm-offScreenInitials
│ └── .cm-offScreenIndicator (another user, different column)
└── .cm-offScreenBottom ← bottomContainer (absolute, bottom: 0, height: AVATAR_SIZE + 14px)
└── .cm-offScreenIndicator style="left: {colX}px; transform: translateX(-50%)"
├── .cm-offScreenAvatar / .cm-offScreenInitials
└── .cm-offScreenArrow (material-symbols-outlined) — "arrow_drop_down"
```
**`OffScreenIndicatorOptions` type extension** (req 6.6, 6.7):
```typescript
export type OffScreenIndicatorOptions = {
direction: 'above' | 'below';
clientId: number; // NEW: identifies user for click handler
color: string;
name: string;
imageUrlCached: string | undefined;
isActive: boolean;
onClick?: (clientId: number) => void; // NEW: invoked on indicator click
};
```
`createOffScreenIndicator` attaches a `click` event listener on the root `` element that calls `onClick(clientId)`. The indicator root element also receives `style.cursor = 'pointer'` when `onClick` is provided, satisfying req 6.7.
**Indicator DOM structure** (built by `createOffScreenIndicator()` in `off-screen-indicator.ts`):
- **above**: `[arrow_drop_up icon][avatar or initials]` stacked vertically (flex-column)
- **below**: `[avatar or initials][arrow_drop_down icon]` stacked vertically (flex-column)
- Arrow element: `arrow_drop_up` — font loaded via `var(--grw-font-family-material-symbols-outlined)` (Next.js-registered Material Symbols Outlined)
- Avatar: same `borderColor`, `AVATAR_SIZE`, and onerror→initials fallback as in-editor widget
- Opacity: arrow always `opacity: 1`; avatar/initials use `IDLE_OPACITY` → `1` via `.cm-yRichCursorActive` on the indicator
**Horizontal positioning** (deferred to measure phase):
After `replaceChildren()`, the plugin calls `view.requestMeasure()`:
- **read phase**: for each indicator, call `view.coordsAtPos(headIndex, 1)` to get screen X. If null (virtualized position), fall back to `contentDOM.getBoundingClientRect().left + col * view.defaultCharacterWidth`.
- **write phase**: set `indicator.style.left = Xpx` and `indicator.style.transform = 'translateX(-50%)'` to center the indicator on the cursor column.
**Update cycle**:
1. Classify all remote cursors (mode-dependent: rangedMode/coords/degenerate)
2. Build `aboveIndicators: {el, headIndex}[]` and `belowIndicators: {el, headIndex}[]`
3. `topContainer.replaceChildren(...aboveIndicators.map(i => i.el))`; same for bottom
4. If any indicators exist, call `view.requestMeasure()` to set horizontal positions
5. Cursors that lack `state.cursor` or `state.editors` are excluded from both in-view and off-screen rendering
**Implementation Notes**
- Integration: file location `packages/editor/src/client/services-internal/extensions/y-rich-cursors/` (directory — split into `index.ts`, `plugin.ts`, `widget.ts`, `off-screen-indicator.ts`, `theme.ts`); exported from `packages/editor/src/client/services-internal/extensions/index.ts` and consumed directly in `use-collaborative-editor-mode.ts`
- Validation: `imageUrlCached` is optional; if undefined or empty, the `` element is skipped and only initials are shown
- Risks: `ySyncFacet` must be present in the editor state when the plugin initializes; guaranteed since `yCollab` (which installs `ySyncFacet`) is added before `yRichCursors` in the extension array
---
### apps/app — Jotai Atom
#### `scrollToRemoteCursorAtom` (new)
| Field | Detail |
|-------|--------|
| Intent | Stores the scroll-to-remote-cursor callback registered by `useCollaborativeEditorMode`; consumed by `EditorNavbar` → `EditingUserList` |
| Requirements | 6.1 |
**File**: `apps/app/src/states/ui/editor/scroll-to-remote-cursor.ts`
```typescript
const scrollToRemoteCursorAtom = atom<((clientId: number) => void) | null>(null);
/** Read the scroll callback (null when collaboration is not active) */
export const useScrollToRemoteCursor = (): ((clientId: number) => void) | null =>
useAtomValue(scrollToRemoteCursorAtom);
/** Register or clear the scroll callback.
* Wraps the raw setAtom call to prevent Jotai from treating a function
* value as an updater. Jotai's `setAtom(fn)` signature interprets `fn`
* as `(prev) => next`; passing `setAtom(() => fn)` forces it to store
* the function value itself instead of invoking it. */
export const useSetScrollToRemoteCursor = (): ((
fn: ((clientId: number) => void) | null,
) => void) => {
const setAtom = useSetAtom(scrollToRemoteCursorAtom);
return useCallback(
(fn: ((clientId: number) => void) | null) => {
setAtom(() => fn);
},
[setAtom],
);
};
```
**Lifecycle**: set when `useCollaborativeEditorMode`'s extension effect runs, cleared on effect cleanup.
---
### packages/ui — Component
#### `UserPicture` (refactored)
| Field | Detail |
|-------|--------|
| Intent | Eliminate the `withTooltip` HOC that returns a React Fragment, replacing it with inline tooltip rendering as a portal child of the root `` |
| Requirements | 7.1, 7.3–7.5 |
**Problem**: The current `withTooltip` HOC wraps `UserPictureRootWithoutLink`/`UserPictureRootWithLink` and returns a Fragment (`<> >`). When the HOC-wrapped `UserPicture` is placed inside a flex container (e.g., the `