Просмотр исходного кода

feat: implement click-to-scroll functionality and color-matched avatars in EditingUserList

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

+ 193 - 2
.kiro/specs/collaborative-editor-awareness/design.md

@@ -55,6 +55,7 @@ graph TB
         CM[CodeMirrorEditorMain]
         EUL[EditingUserList]
         ATOM[editingClientsAtom - Jotai]
+        ATOM2[scrollToRemoteCursorAtom - Jotai]
     end
 
     CM --> COLLAB
@@ -67,6 +68,8 @@ graph TB
     RICH -->|viewport comparison| RICH
     COLLAB -->|filtered clientList| ATOM
     ATOM --> EUL
+    COLLAB -->|onScrollToRemoteCursorReady| ATOM2
+    ATOM2 -->|onUserClick| EUL
 ```
 
 **Key architectural properties**:
@@ -86,6 +89,30 @@ graph TB
 
 ## System Flows
 
+### Click-to-Scroll Flow (Requirement 6)
+
+```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)
+    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, not capture 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.
+
 ### Awareness Update → EditingUserList
 
 ```mermaid
@@ -152,13 +179,24 @@ sequenceDiagram
 | 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 `<span>` 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 |
 
 ## 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 |
+| `use-collaborative-editor-mode` | packages/editor — Hook | Fix awareness filter bug; compose extensions with rich cursor; expose scroll-to-remote-cursor callback | 1.1–1.4, 2.1, 2.4, 6.1–6.3 | `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 off-screen indicators at editor edges | 3.1–3.10, 4.1–4.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 |
+| `EditingUserList` | apps/app — Component | Renders active editor avatars with color-matched borders; handles click-to-scroll | 5.1–5.3, 6.1, 6.4–6.5 | `EditingClient[]` (P0) | View |
 
 ### packages/editor — Hook
 
@@ -167,17 +205,19 @@ sequenceDiagram
 | 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 |
+| Requirements | 1.1, 1.2, 1.3, 1.4, 2.1, 2.4, 6.1–6.3 |
 
 **Responsibilities & Constraints**
 - Filters `undefined` awareness entries before calling `onEditorsUpdated`
 - Does not mutate `awareness.getStates()` directly
 - Composes `yCollab(null)` + `yRichCursors(awareness)` to achieve text-sync, undo, and rich cursor rendering without the default `yRemoteSelections` plugin
+- Creates and registers a `scrollFn` callback (requirement 6) that resolves a remote user's cursor position and dispatches a CodeMirror scroll effect
 
 **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]
 
@@ -205,6 +245,33 @@ sequenceDiagram
 - 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 (Requirement 6)
+
+```typescript
+type Configuration = {
+  user?: IUserHasId;
+  pageId?: string;
+  reviewMode?: boolean;
+  onEditorsUpdated?: (clientList: EditingClient[]) => void;
+  // NEW: called with the scroll function when provider+ydoc are ready; null on cleanup
+  onScrollToRemoteCursorReady?: (fn: ((clientId: number) => void) | null) => void;
+};
+```
+
+The `scrollFn` is created in the "Setup Ydoc Extensions" `useEffect` where both `provider` and `activeDoc` are in scope:
+
+```
+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' }) })
+```
+
+`onScrollToRemoteCursorReady(scrollFn)` is called after the function is created. On effect cleanup, `onScrollToRemoteCursorReady(null)` is called to clear the atom.
+
 ---
 
 ### packages/editor — Extension
@@ -352,6 +419,115 @@ After `replaceChildren()`, the plugin calls `view.requestMeasure()`:
 - Validation: `imageUrlCached` is optional; if undefined or empty, the `<img>` 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 */
+export const useSetScrollToRemoteCursor = () =>
+  useSetAtom(scrollToRemoteCursorAtom);
+```
+
+**Lifecycle**: set when `useCollaborativeEditorMode`'s extension effect runs, cleared on effect cleanup.
+
+---
+
+### apps/app — Component
+
+#### `EditingUserList` (modified)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Displays active editor avatars with color-matched borders; delegates click events to parent-supplied callback |
+| Requirements | 5.1–5.3, 6.1, 6.4–6.5 |
+
+**Props change**:
+
+```typescript
+type Props = {
+  clientList: EditingClient[];
+  onUserClick?: (clientId: number) => void;  // NEW: scroll-to-cursor callback
+};
+```
+
+**Color-matched border (req 5.1–5.3)**:
+
+`UserPicture` does not accept a `style` prop (the prop is applied to the `<img>` tag, not the root element). A wrapper `<span>` with an inline border style is used instead:
+
+```
+<span
+  style={{ border: `2px solid ${editingClient.color}`, borderRadius: '50%', display: 'inline-block' }}
+>
+  <UserPicture user={editingClient} noLink />
+</span>
+```
+
+The `border border-info` className is removed from `UserPicture`.
+
+**Click-to-scroll (req 6.1, 6.4)**:
+
+Each avatar wrapper is made interactive:
+
+```
+<button
+  type="button"
+  style={{ cursor: 'pointer', background: 'none', border: 'none', padding: 0 }}
+  onClick={() => onUserClick?.(editingClient.clientId)}
+>
+  <span style={{ border: `2px solid ${editingClient.color}`, borderRadius: '50%', display: 'inline-block' }}>
+    <UserPicture user={editingClient} noLink />
+  </span>
+</button>
+```
+
+**Overflow popover (req 5.3, 6.5)**:
+
+`UserPictureList` (a generic legacy class component that does not accept `onUserClick` or color props) is replaced by inline rendering within `EditingUserList`, applying the same wrapper and button pattern to `remainingUsers`.
+
+**`EditorNavbar` wiring**:
+
+```typescript
+// EditorNavbar.tsx
+const EditingUsers = (): JSX.Element => {
+  const editingClients = useEditingClients();
+  const scrollToRemoteCursor = useScrollToRemoteCursor();
+  return (
+    <EditingUserList
+      clientList={editingClients}
+      onUserClick={scrollToRemoteCursor ?? undefined}
+    />
+  );
+};
+```
+
+**`PageEditor` wiring**:
+
+```typescript
+// PageEditor.tsx — existing hook setup
+const setScrollToRemoteCursor = useSetScrollToRemoteCursor();
+// ...
+<CodeMirrorEditorMain
+  onScrollToRemoteCursorReady={setScrollToRemoteCursor}
+  // ...existing props
+/>
+```
+
 ## Data Models
 
 ### Domain Model
@@ -387,6 +563,9 @@ type CursorState = {
 | `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 |
+| Click-to-scroll: view not mounted | `scrollFn` called before CodeMirror mounts | `codeMirrorEditor?.view == null` guard returns early; no crash |
+| Click-to-scroll: cursor absent | Clicked user has no `cursor.head` in awareness | Guard `cursor?.head == null → return`; no-op per req 6.3 |
+| Click-to-scroll: position unresolvable | `createAbsolutePositionFromRelativePosition` returns `null` (stale document state) | Guard `absPos == null → return`; no crash |
 
 ## Testing Strategy
 
@@ -394,3 +573,15 @@ Test files are co-located with source in `y-rich-cursors/`:
 - **Unit**: `widget.spec.ts` (DOM structure, eq, fallback), `off-screen-indicator.spec.ts` (indicator DOM, direction, fallback)
 - **Integration**: `plugin.integ.ts` (awareness filter, cursor broadcast, viewport classification, activity timers)
 - **E2E** (Playwright, deferred): hover behavior, off-screen scroll transitions, pointer-events pass-through
+
+### Additional Tests for Requirements 5 & 6
+
+- **Unit — `EditingUserList.spec.tsx`** (new or extended):
+  - Renders a colored border wrapper matching `editingClient.color` (req 5.1)
+  - Does not render `border-info` class (req 5.1)
+  - Calls `onUserClick(clientId)` when avatar is clicked (req 6.1, 6.4)
+  - Overflow popover avatars also call `onUserClick` (req 6.5)
+- **Integration — `use-collaborative-editor-mode` scroll test** (added to existing integ file):
+  - `onScrollToRemoteCursorReady` is called with a function when provider is set up
+  - `scrollFn(clientId)` dispatches `scrollIntoView` to the view when cursor is available (req 6.1–6.2)
+  - `scrollFn(clientId)` is a no-op when cursor is absent (req 6.3)

+ 22 - 0
.kiro/specs/collaborative-editor-awareness/requirements.md

@@ -69,3 +69,25 @@ GROWI's collaborative editor uses Yjs awareness protocol to track which users ar
 7. The off-screen indicators shall be rendered as overlays (absolute positioning within the editor container) and shall not affect the editor's scroll height or content layout.
 8. The horizontal position of each off-screen indicator shall reflect the remote cursor's column position in the document. The `left` CSS value of the indicator shall be derived from the cursor's screen X coordinate (via `view.coordsAtPos` in the measure phase) or, for virtualized positions, approximated using character-width estimation. The indicator shall be centered on the cursor column (`transform: translateX(-50%)`).
 9. The direction arrow icon of the off-screen indicator shall always be rendered at full opacity (1.0) in the cursor's color, regardless of the idle/active state. Only the avatar image or initials element shall fade to reduced opacity (`IDLE_OPACITY`) when the user is idle, and return to full opacity when active.
+
+### Requirement 5: Color-Matched User Avatars in EditingUserList
+
+**Objective:** As a wiki user editing collaboratively, I want the avatar border in `EditingUserList` to use each user's cursor color, so that I can visually associate an avatar in the list with that user's cursor in the editor.
+
+#### Acceptance Criteria
+
+1. Each `UserPicture` in `EditingUserList` shall display a border whose color equals `editingClient.color` from the user's `EditingClient` data, replacing the current fixed `border-info` color.
+2. The border width and visual weight shall be equivalent to the existing border appearance (1–2 px solid ring).
+3. The same color-matched border shall be applied to avatars in the overflow popover (the remaining users shown via the `+N` button).
+
+### Requirement 6: Scroll to Remote Cursor on Avatar Click
+
+**Objective:** As a wiki user editing collaboratively, I want to click a user's avatar in `EditingUserList` to jump to that user's cursor position in the editor, so that I can quickly navigate to where they are editing.
+
+#### Acceptance Criteria
+
+1. Clicking an avatar in `EditingUserList` shall scroll the editor viewport so that the clicked user's remote cursor becomes visible.
+2. The editor shall scroll to center the cursor vertically (`y: 'center'`).
+3. If the clicked user has no active cursor position in the awareness state, the click shall have no effect (no error or crash).
+4. Each avatar shall display a `cursor: pointer` affordance to indicate it is clickable.
+5. Both the first-4 avatars displayed directly in the navbar and the avatars in the overflow popover shall support click-to-scroll.

+ 27 - 2
.kiro/specs/collaborative-editor-awareness/research.md

@@ -8,11 +8,15 @@
 ## Summary
 
 - **Feature**: `collaborative-editor-awareness`
-- **Discovery Scope**: Extension (existing collaborative editor system)
-- **Key Findings**:
+- **Discovery Scope**: Extension (existing collaborative editor system); Phase 2 adds Requirements 5 & 6 (color-matched avatars + click-to-scroll)
+- **Key Findings** (original):
   - `y-codemirror.next@0.3.5` reads `state.user` for cursor info, but GROWI sets `state.editors` — causing all cursors to render as "Anonymous" with default blue color today
   - `yCollab` in v0.3.5 does NOT support a `cursorBuilder` option; the cursor DOM is hardcoded in `YRemoteCaretWidget`
   - `awareness.getStates().delete(clientId)` in the current `updateAwarenessHandler` is an incorrect direct mutation of Yjs-managed internal state; Yjs removes stale entries before emitting `update`
+- **Key Findings** (Phase 2):
+  - `UserPicture` (`@growi/ui`) does not accept a `style` prop; dynamic border colors require a wrapper element approach
+  - `packages/editor` cannot import from `apps/app`; callback props (`onScrollToRemoteCursorReady`) are used to cross the package boundary
+  - `EditorView.scrollIntoView(pos, { y: 'center' })` (CodeMirror built-in) is sufficient for the scroll-to-cursor feature; no new dependencies required
 
 ## Research Log
 
@@ -134,9 +138,30 @@
 - **Finding**: `view.coordsAtPos()` cannot be called during `update()` (throws "Reading the editor layout" error). Horizontal positioning must be deferred.
 - **Solution**: After `replaceChildren()`, call `view.requestMeasure()` to schedule a read phase (`coordsAtPos` → screen X) and write phase (`style.left` + `transform: translateX(-50%)`). For virtualized positions (outside viewport), fall back to `contentDOM.getBoundingClientRect().left + col * view.defaultCharacterWidth`.
 
+### Phase 2 — Color-Matched Avatars & Click-to-Scroll
+
+#### UserPicture Style API Analysis
+
+- **Context**: Requirement 5.1 requires setting the border color of `UserPicture` avatars dynamically per user.
+- **Findings**: `UserPicture.tsx` in `packages/ui/src/components/UserPicture.tsx` accepts only `{ user, size, noLink, noTooltip, className }`. The `className` is applied to the `<img>` element (not the root `<span>`). There is no `style` prop forwarded to either element.
+- **Implications**: Cannot set `borderColor` via `UserPicture`'s own props. Must wrap in a parent element with an inline `border` style. The `border border-info` className on `UserPicture` is removed; the wrapper element provides the colored border.
+
+#### Cross-Package Callback Pattern
+
+- **Context**: `use-collaborative-editor-mode` (in `packages/editor`) needs to provide a scroll function to `EditingUserList` (in `apps/app`). Direct import from `apps/app` → `packages/editor` is the existing direction; reverse import is prohibited.
+- **Findings**: The existing `onEditorsUpdated` callback in `Configuration` follows exactly this pattern: `packages/editor` calls a callback provided by `apps/app`. The same pattern is appropriate for `onScrollToRemoteCursorReady`.
+- **Implications**: No new dependency or architectural mechanism needed; extend `Configuration` type with the new callback.
+
+#### CodeMirror Scroll API
+
+- **Context**: How to programmatically scroll the editor to a specific character position.
+- **Findings**: `EditorView.scrollIntoView(pos: number, options?: { y?: 'nearest' | 'start' | 'end' | 'center' })` is the standard CodeMirror API. Dispatching `{ effects: EditorView.scrollIntoView(pos, { y: 'center' }) }` scrolls the editor so the position is vertically centered. No additional plugins or dependencies required.
+- **Implications**: Scroll is a one-liner dispatch; no new package dependencies. The position is resolved from `Y.createAbsolutePositionFromRelativePosition(cursor.head, ydoc)` which is already used in `plugin.ts`.
+
 ## References
 
 - y-codemirror.next v0.3.5 source: `node_modules/.pnpm/y-codemirror.next@0.3.5_.../src/`
 - Yjs awareness protocol: https://docs.yjs.dev/api/about-awareness
 - CodeMirror WidgetType: https://codemirror.net/docs/ref/#view.WidgetType
 - CodeMirror EditorView.lineBlockAt: https://codemirror.net/docs/ref/#view.EditorView.lineBlockAt
+- CodeMirror EditorView.scrollIntoView: https://codemirror.net/docs/ref/#view.EditorView^scrollIntoView

+ 7 - 7
.kiro/specs/collaborative-editor-awareness/spec.json

@@ -1,9 +1,9 @@
 {
   "feature_name": "collaborative-editor-awareness",
   "created_at": "2026-04-07T00:00:00.000Z",
-  "updated_at": "2026-04-10T13:00:00.000Z",
+  "updated_at": "2026-04-10T14:00:00.000Z",
   "language": "en",
-  "phase": "implementation-complete",
+  "phase": "design-generated",
   "approvals": {
     "requirements": {
       "generated": true,
@@ -11,13 +11,13 @@
     },
     "design": {
       "generated": true,
-      "approved": true
+      "approved": false
     },
     "tasks": {
-      "generated": true,
-      "approved": true
+      "generated": false,
+      "approved": false
     }
   },
-  "ready_for_implementation": true,
-  "cleanup_completed": true
+  "ready_for_implementation": false,
+  "cleanup_completed": false
 }