|
|
@@ -38,7 +38,7 @@ The current flow has two defects:
|
|
|
graph TB
|
|
|
subgraph packages_editor
|
|
|
COLLAB[use-collaborative-editor-mode]
|
|
|
- RICH[yRichCursors ViewPlugin - new]
|
|
|
+ RICH[yRichCursors ViewPlugin]
|
|
|
YCOLLAB[yCollab - null awareness]
|
|
|
end
|
|
|
|
|
|
@@ -64,15 +64,17 @@ graph TB
|
|
|
COLLAB -->|awareness| RICH
|
|
|
RICH -->|reads state.editors| AWR
|
|
|
RICH -->|sets state.cursor| AWR
|
|
|
+ RICH -->|viewport comparison| RICH
|
|
|
COLLAB -->|filtered clientList| ATOM
|
|
|
ATOM --> 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
|
|
|
+- `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.viewport` to decide between widget decoration (in-view) and DOM overlay (off-screen)
|
|
|
|
|
|
### Technology Stack
|
|
|
|
|
|
@@ -131,19 +133,30 @@ sequenceDiagram
|
|
|
| 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 | Cursor name label | `yRichCursors` | `RichCaretWidget.toDOM()` |
|
|
|
-| 3.2 | Cursor avatar image | `yRichCursors` | `RichCaretWidget.toDOM()` — `<img>` from `state.editors.imageUrlCached` |
|
|
|
-| 3.3 | Avatar fallback for missing image | `yRichCursors` | `RichCaretWidget.toDOM()` — initials fallback |
|
|
|
-| 3.4 | Cursor flag color from `state.editors.color` | `yRichCursors` | `RichCaretWidget` constructor |
|
|
|
-| 3.5 | Custom cursor via replacement plugin | `yRichCursors` replaces `yRemoteSelections` | `yCollab(activeText, null, { undoManager })` |
|
|
|
-| 3.6 | Cursor updates on awareness change | `yRichCursors` awareness change listener | `awareness.on('change', ...)` |
|
|
|
+| 3.1 | Avatar overlay below caret (no block space) | `yRichCursors` | `RichCaretWidget.toDOM()` — `position: absolute` overlay |
|
|
|
+| 3.2 | Avatar size 24×24px (matches EditingUserList) | `yRichCursors` | `RichCaretWidget.toDOM()` — CSS sizing |
|
|
|
+| 3.3 | Name label visible on hover only | `yRichCursors` | CSS `:hover` on `.cm-yRichCursorFlag` |
|
|
|
+| 3.4 | Avatar image with initials fallback | `yRichCursors` | `RichCaretWidget.toDOM()` — `<img>` onerror → initials |
|
|
|
+| 3.5 | Cursor caret and fallback color from `state.editors.color` | `yRichCursors` | `RichCaretWidget` constructor |
|
|
|
+| 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 pinned to top edge (↑) | `yRichCursors` | `offScreenContainer` top overlay |
|
|
|
+| 4.2 | Off-screen indicator pinned to bottom edge (↓) | `yRichCursors` | `offScreenContainer` bottom overlay |
|
|
|
+| 4.3 | No indicator when cursor is in viewport | `yRichCursors` | viewport comparison in `update()` |
|
|
|
+| 4.4 | Same avatar/color as in-editor widget | `yRichCursors` | shared `state.editors` data |
|
|
|
+| 4.5 | Multiple indicators side by side | `yRichCursors` | horizontal flex layout |
|
|
|
+| 4.6 | Transition on scroll (indicator ↔ widget) | `yRichCursors` | `viewportChanged` check in `update()` |
|
|
|
+| 4.7 | Overlay positioning (no layout impact) | `yRichCursors` | `position: absolute` on `view.dom` |
|
|
|
|
|
|
## 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 | 1.1–1.4, 2.1, 2.4 | `yCollab` (P0), `yRichCursors` (P0) | State |
|
|
|
-| `yRichCursors` | packages/editor — Extension | Custom ViewPlugin: broadcasts local cursor position, renders remote cursors with avatar+name | 3.1–3.6 | `@codemirror/view` (P0), `y-websocket awareness` (P0) | Service |
|
|
|
+| `yRichCursors` | packages/editor — Extension | Custom ViewPlugin: broadcasts local cursor position, renders in-viewport cursors with overlay avatar+hover name+activity opacity, renders off-screen indicators at editor edges | 3.1–3.10, 4.1–4.7 | `@codemirror/view` (P0), `y-websocket awareness` (P0) | Service |
|
|
|
|
|
|
### packages/editor — Hook
|
|
|
|
|
|
@@ -198,8 +211,8 @@ sequenceDiagram
|
|
|
|
|
|
| Field | Detail |
|
|
|
|-------|--------|
|
|
|
-| Intent | CodeMirror ViewPlugin — broadcasts local cursor position, renders remote cursors with name and avatar from `state.editors` |
|
|
|
-| Requirements | 3.1, 3.2, 3.3, 3.4, 3.5, 3.6 |
|
|
|
+| Intent | CodeMirror ViewPlugin — broadcasts local cursor position, renders in-viewport cursors with overlay avatar and hover-revealed name, renders off-screen indicators pinned to editor edges for cursors outside the viewport |
|
|
|
+| Requirements | 3.1–3.10, 4.1–4.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`)
|
|
|
@@ -234,7 +247,8 @@ Preconditions:
|
|
|
- `ySyncFacet` is installed by a preceding `yCollab` call so that `ytext` can be resolved for position conversion
|
|
|
|
|
|
Postconditions:
|
|
|
-- Remote cursors are rendered as `cm-yRichCursor` widgets at each remote client's head position
|
|
|
+- 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`
|
|
|
- Local cursor position is broadcast to awareness as `state.cursor.{ anchor, head }` on each focus-selection change
|
|
|
|
|
|
Invariants:
|
|
|
@@ -245,23 +259,201 @@ Invariants:
|
|
|
##### Widget DOM Structure
|
|
|
|
|
|
```
|
|
|
-<span class="cm-yRichCaret" style="border-color: {color}">
|
|
|
- <img class="cm-yRichCursorAvatar" src="{imageUrlCached}" alt="{name}" />
|
|
|
- <!-- fallback when img fails to load: -->
|
|
|
- <!-- <span class="cm-yRichCursorInitials">{initials}</span> -->
|
|
|
- <span class="cm-yRichCursorInfo" style="background-color: {color}">{name}</span>
|
|
|
+<span class="cm-yRichCaret" style="border-color: {color}; position: relative;">
|
|
|
+ <!-- Overlay flag: positioned below the caret, does NOT consume block space -->
|
|
|
+ <span class="cm-yRichCursorFlag">
|
|
|
+ <!-- Avatar: 16×16px circular -->
|
|
|
+ <img class="cm-yRichCursorAvatar" src="{imageUrlCached}" alt="{name}" />
|
|
|
+ <!-- OR fallback when img absent or fails to load: -->
|
|
|
+ <span class="cm-yRichCursorInitials" style="background-color: {color}">{initials}</span>
|
|
|
+ <!-- Name label: hidden by default, shown on :hover -->
|
|
|
+ <span class="cm-yRichCursorInfo" style="background-color: {color}">{name}</span>
|
|
|
+ </span>
|
|
|
</span>
|
|
|
```
|
|
|
|
|
|
+**CSS strategy** (applied via `EditorView.baseTheme` exported alongside the ViewPlugin):
|
|
|
+
|
|
|
+`:hover` pseudo-class cannot be expressed via inline styles, so a `baseTheme` is mandatory. The theme is included in the Extension array returned by `yRichCursors()`.
|
|
|
+
|
|
|
+```css
|
|
|
+/* Caret line — the hover anchor */
|
|
|
+.cm-yRichCaret {
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+
|
|
|
+/* Overlay flag — pointer-events: none to avoid stealing clicks from the editor.
|
|
|
+ Shown on caret hover so the user can then interact with the flag. */
|
|
|
+.cm-yRichCursorFlag {
|
|
|
+ position: absolute;
|
|
|
+ top: 100%; /* directly below the caret line */
|
|
|
+ left: -8px; /* center the 16px avatar on the 1px caret */
|
|
|
+ z-index: 10;
|
|
|
+ pointer-events: none; /* default: pass clicks through to editor */
|
|
|
+}
|
|
|
+.cm-yRichCaret:hover .cm-yRichCursorFlag {
|
|
|
+ pointer-events: auto; /* enable interaction once caret is hovered */
|
|
|
+}
|
|
|
+
|
|
|
+.cm-yRichCursorAvatar {
|
|
|
+ width: 16px;
|
|
|
+ height: 16px;
|
|
|
+ border-radius: 50%;
|
|
|
+ display: block;
|
|
|
+}
|
|
|
+.cm-yRichCursorInitials {
|
|
|
+ width: 16px;
|
|
|
+ height: 16px;
|
|
|
+ border-radius: 50%;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: white;
|
|
|
+ font-size: 8px;
|
|
|
+ font-weight: bold;
|
|
|
+}
|
|
|
+
|
|
|
+/* Name label — hidden by default, shown when the flag itself is hovered */
|
|
|
+.cm-yRichCursorInfo {
|
|
|
+ display: none;
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ left: 20px; /* right of the 16px avatar + 4px gap */
|
|
|
+ white-space: nowrap;
|
|
|
+ padding: 2px 6px;
|
|
|
+ border-radius: 3px;
|
|
|
+ color: white;
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 16px;
|
|
|
+}
|
|
|
+.cm-yRichCursorFlag:hover .cm-yRichCursorInfo {
|
|
|
+ display: block; /* shown on hover */
|
|
|
+}
|
|
|
+
|
|
|
+/* --- Opacity: semi-transparent by default, full on hover or active editing --- */
|
|
|
+.cm-yRichCursorFlag {
|
|
|
+ opacity: 0.4;
|
|
|
+ transition: opacity 0.3s ease;
|
|
|
+}
|
|
|
+.cm-yRichCaret:hover .cm-yRichCursorFlag,
|
|
|
+.cm-yRichCursorFlag.cm-yRichCursorActive {
|
|
|
+ opacity: 1;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**Activity tracking for opacity** (JavaScript, within `YRichCursorsPluginValue`):
|
|
|
+- Maintain `lastActivityMap: Map<number, number>` — maps `clientId` → timestamp of last awareness cursor change
|
|
|
+- Maintain `activeTimers: Map<number, ReturnType<typeof setTimeout>>` — maps `clientId` → timer handle
|
|
|
+- On awareness `change` for remote clients:
|
|
|
+ - Update `lastActivityMap.set(clientId, Date.now())`
|
|
|
+ - Clear any existing timer for that client, set a new `setTimeout(3000)` that calls `view.dispatch()` with `yRichCursorsAnnotation` to trigger a decoration rebuild
|
|
|
+- In `update()`, when building decorations:
|
|
|
+ - Compute `isActive = (Date.now() - (lastActivityMap.get(clientId) ?? 0)) < 3000` for each remote client
|
|
|
+ - Pass `isActive` to `new RichCaretWidget(color, name, imageUrlCached, isActive)` — `toDOM()` applies `.cm-yRichCursorActive` when `true`
|
|
|
+ - Pass `isActive` when building off-screen indicator elements as well (add `.cm-yRichCursorActive` class to `.cm-offScreenIndicator`)
|
|
|
+- `eq()` includes `isActive`, so a state transition (active→inactive or vice versa) triggers `toDOM()` re-creation — this occurs at most twice per user per 3-second cycle, which is acceptable
|
|
|
+- On `destroy()`: clear all timers
|
|
|
+
|
|
|
+**Off-screen indicators** also respect the same opacity pattern: `.cm-offScreenIndicator` defaults to `opacity: 0.4` and receives `.cm-yRichCursorActive` when the remote user is active.
|
|
|
+
|
|
|
+**Pointer-events strategy**: The overlay flag uses `pointer-events: none` by default so it never intercepts clicks or text selection in the editor. When the user hovers the caret line (`.cm-yRichCaret:hover`), `pointer-events: auto` is enabled on the flag, allowing the user to then hover the avatar to reveal the name label. This two-step hover cascade ensures the editor remains fully interactive while still providing discoverability.
|
|
|
+
|
|
|
+**Design decision — CSS-only, no React**: The overlay, sizing, and hover behavior are all achievable with `position: absolute` and the `:hover` pseudo-class. No JavaScript state management is needed, so `document.createElement` remains the implementation strategy. React's `createRoot` would introduce async rendering (flash of empty container), context isolation, and per-widget overhead without any benefit.
|
|
|
+
|
|
|
`RichCaretWidget` (extends `WidgetType`):
|
|
|
-- Constructor parameters: `color: string`, `name: string`, `imageUrlCached: string | undefined`
|
|
|
-- `toDOM()`: creates the DOM tree above using `document.createElement`; attaches `onerror` on `<img>` to replace with initials fallback
|
|
|
-- `eq(other)`: returns `true` when `color`, `name`, and `imageUrlCached` all match (avoids unnecessary re-creation)
|
|
|
+- Constructor parameters: `color: string`, `name: string`, `imageUrlCached: string | undefined`, `isActive: boolean`
|
|
|
+- `toDOM()`: creates the DOM tree above using `document.createElement`; attaches `onerror` on `<img>` to replace with initials fallback; applies CSS classes via `baseTheme`; adds `.cm-yRichCursorActive` to `.cm-yRichCursorFlag` when `isActive` is `true`
|
|
|
+- `eq(other)`: returns `true` when `color`, `name`, `imageUrlCached`, and `isActive` all match (avoids unnecessary re-creation; activity state transitions cause at most 2 re-creations per user per 3-second cycle)
|
|
|
- `estimatedHeight`: `-1` (inline widget)
|
|
|
- `ignoreEvent()`: `true`
|
|
|
|
|
|
Selection highlight: rendered as `Decoration.mark` on the selected range with `background-color: {colorLight}` (same as `yRemoteSelections`).
|
|
|
|
|
|
+##### Off-Screen Cursor Indicators
|
|
|
+
|
|
|
+When a remote cursor's absolute position falls outside `view.viewport.from`..`view.viewport.to`, the ViewPlugin renders an off-screen indicator instead of a widget decoration.
|
|
|
+
|
|
|
+**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 (zero height) 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)
|
|
|
+│ ├── .cm-offScreenIndicator (Alice ↑)
|
|
|
+│ └── .cm-offScreenIndicator (Bob ↑)
|
|
|
+└── .cm-offScreenBottom ← bottomContainer (absolute, bottom: 0)
|
|
|
+ └── .cm-offScreenIndicator (Charlie ↓)
|
|
|
+```
|
|
|
+
|
|
|
+**Indicator DOM structure**:
|
|
|
+```html
|
|
|
+<span class="cm-offScreenIndicator">
|
|
|
+ <span class="cm-offScreenArrow">↑</span> <!-- or ↓ -->
|
|
|
+ <img class="cm-offScreenAvatar" src="{imageUrlCached}" alt="{name}" />
|
|
|
+ <!-- OR fallback: -->
|
|
|
+ <span class="cm-offScreenInitials" style="background-color: {color}">{initials}</span>
|
|
|
+</span>
|
|
|
+```
|
|
|
+
|
|
|
+**CSS** (included in the same `EditorView.baseTheme`):
|
|
|
+```css
|
|
|
+.cm-offScreenTop,
|
|
|
+.cm-offScreenBottom {
|
|
|
+ position: absolute;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ display: flex;
|
|
|
+ gap: 4px;
|
|
|
+ padding: 2px 4px;
|
|
|
+ pointer-events: none;
|
|
|
+ z-index: 10;
|
|
|
+}
|
|
|
+.cm-offScreenTop {
|
|
|
+ top: 0;
|
|
|
+}
|
|
|
+.cm-offScreenBottom {
|
|
|
+ bottom: 0;
|
|
|
+}
|
|
|
+.cm-offScreenIndicator {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 2px;
|
|
|
+ opacity: 0.4;
|
|
|
+ transition: opacity 0.3s ease;
|
|
|
+}
|
|
|
+.cm-offScreenIndicator.cm-yRichCursorActive {
|
|
|
+ opacity: 1;
|
|
|
+}
|
|
|
+.cm-offScreenArrow {
|
|
|
+ font-size: 10px;
|
|
|
+ line-height: 1;
|
|
|
+}
|
|
|
+.cm-offScreenAvatar {
|
|
|
+ width: 16px;
|
|
|
+ height: 16px;
|
|
|
+ border-radius: 50%;
|
|
|
+}
|
|
|
+.cm-offScreenInitials {
|
|
|
+ width: 16px;
|
|
|
+ height: 16px;
|
|
|
+ border-radius: 50%;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: white;
|
|
|
+ font-size: 8px;
|
|
|
+ font-weight: bold;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**Update cycle**:
|
|
|
+1. In the `update(viewUpdate)` method, after computing absolute positions for all remote cursors, classify each into: `inViewport`, `above`, or `below` based on comparison with `view.viewport.{from, to}`
|
|
|
+2. For `inViewport` cursors: create `Decoration.widget` (same as current behavior)
|
|
|
+3. For `above` / `below` cursors: rebuild `topContainer` / `bottomContainer` children via `replaceChildren()` — clear old indicator elements and append new ones
|
|
|
+4. Containers are rebuilt on every update where `viewportChanged` is true OR awareness has changed (same trigger as decoration rebuild)
|
|
|
+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.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 `<img>` element is skipped and only initials are shown
|
|
|
@@ -297,9 +489,11 @@ type CursorState = {
|
|
|
| Error Type | Scenario | Response |
|
|
|
|------------|----------|----------|
|
|
|
| Missing `editors` field | Client connects but has not set awareness yet | Filtered out in `emitEditorList`; not rendered in `EditingUserList` |
|
|
|
-| Avatar image load failure | `imageUrlCached` URL returns 4xx/5xx | `<img>` `onerror` replaces element with initials `<span>` |
|
|
|
+| Avatar image load failure | `imageUrlCached` URL returns 4xx/5xx | `<img>` `onerror` replaces element with initials `<span>` (colored circle with user initials) |
|
|
|
| `state.cursor` absent | Remote client connected but editor not focused | Cursor widget not rendered for that client (no `cursor.anchor` → skip) |
|
|
|
| `ySyncFacet` not installed | `yRichCursors` initialized before `yCollab` | Position conversion returns `null`; cursor is skipped for that update cycle. Extension array order in `use-collaborative-editor-mode` guarantees correct sequencing. |
|
|
|
+| Off-screen container detached | `view.dom` removed from DOM before `destroy()` | `destroy()` calls `remove()` on both containers; if already detached, `remove()` is a no-op |
|
|
|
+| Viewport not yet initialized | First `update()` before CM calculates viewport | `view.viewport` always has valid `from`/`to` from initialization; safe to compare |
|
|
|
|
|
|
## Testing Strategy
|
|
|
|
|
|
@@ -308,8 +502,11 @@ type CursorState = {
|
|
|
- `emitEditorList` filter: given awareness states `[{ editors: validClient }, {}, { editors: undefined }]`, `onEditorsUpdated` is called with only the valid client
|
|
|
- `updateAwarenessHandler`: `removed` client IDs are processed without calling `awareness.getStates().delete()`
|
|
|
- `RichCaretWidget.eq()`: returns `true` for same color/name/imageUrlCached, `false` for any difference
|
|
|
-- `RichCaretWidget.toDOM()`: when `imageUrlCached` is provided, renders `<img>` element; when undefined, renders initials `<span>`
|
|
|
-- Avatar fallback: `onerror` on `<img>` replaces the element with the initials fallback
|
|
|
+- `RichCaretWidget.toDOM()`: when `imageUrlCached` is provided, renders `<img>` element (24×24px, circular); when undefined, renders initials `<span>` with `background-color` from `color`
|
|
|
+- Avatar fallback: `onerror` on `<img>` replaces the element with the initials fallback (colored circle)
|
|
|
+- Overlay positioning: the `.cm-yRichCursorFlag` element has `position: absolute` and `top: 100%` (does not consume block space)
|
|
|
+- Hover behavior (structural only): `.cm-yRichCursorInfo` exists in the DOM with no inline `display` override (the `baseTheme` sets `display: none` by default). Actual `:hover` toggle is CSS-only and cannot be simulated in happy-dom/jsdom — **deferred to E2E tests (Playwright)**
|
|
|
+- Activity tracking: `RichCaretWidget` constructed with `isActive: true` adds `.cm-yRichCursorActive` to the flag element; with `isActive: false` it does not. `eq()` returns `false` when `isActive` differs, triggering widget re-creation
|
|
|
|
|
|
### Integration Tests
|
|
|
|
|
|
@@ -317,7 +514,15 @@ type CursorState = {
|
|
|
- One client has no `state.editors` (just connected) → `EditingUserList` receives only the client that has editors set
|
|
|
- Cursor position broadcast: on selection change, `awareness.getLocalState().cursor` is updated with the correct relative position
|
|
|
- Remote cursor rendering: given awareness state with `state.cursor` and `state.editors`, the editor view contains a `cm-yRichCaret` widget at the correct position
|
|
|
+- Off-screen classification: given a remote cursor position outside `view.viewport`, verify the cursor is not rendered as a widget decoration (widget count is zero for that client)
|
|
|
+
|
|
|
+### E2E Tests (Playwright)
|
|
|
+
|
|
|
+- `:hover` behavior on `.cm-yRichCursorFlag`: verify name label appears on hover, hidden otherwise (Req 3.3)
|
|
|
+- Off-screen indicator visibility: scroll the editor so a remote cursor goes off-screen; verify `.cm-offScreenTop` or `.cm-offScreenBottom` contains the expected indicator element; scroll back and verify the indicator disappears and the in-editor widget reappears (Req 4.6)
|
|
|
+- Pointer-events: verify that clicking on text underneath the overlay flag correctly places the editor cursor (Req 4.7)
|
|
|
|
|
|
### Performance
|
|
|
|
|
|
- `RichCaretWidget.eq()` prevents re-creation when awareness updates do not change user info — confirmed by CodeMirror's decoration update logic calling `eq` before `toDOM`
|
|
|
+- Off-screen container updates use `replaceChildren()` for efficient batch DOM mutation; containers are not removed/re-created on each update cycle
|