Explorar o código

implement scrolling by clicking EditingUserList avatars

Yuki Takei hai 3 días
pai
achega
e6e91e8dd2

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

@@ -51,39 +51,39 @@
 
 ## Phase 2: Color-Matched Avatars & Click-to-Scroll (Requirements 5–6)
 
-- [ ] 13. Scroll callback infrastructure
-- [ ] 13.1 (P) Create a Jotai atom for storing the scroll-to-remote-cursor callback
+- [x] 13. Scroll callback infrastructure
+- [x] 13.1 (P) Create a Jotai atom for storing the scroll-to-remote-cursor callback
   - Define an atom that holds either a scroll function accepting a client ID or null
   - Export a reader hook and a setter hook, following the same pattern as the existing editing-clients atom
   - _Requirements: 6.1_
 
-- [ ] 13.2 (P) Extend the collaborative editor mode hook to create and register a scroll-to-remote-cursor function
+- [x] 13.2 (P) Extend the collaborative editor mode hook to create and register a scroll-to-remote-cursor function
   - Add a new callback option to the hook's configuration that receives the scroll function when the provider and document are ready, and null on cleanup
   - The scroll function reads the target user's cursor position from awareness, resolves the Yjs relative position to an absolute document index, and dispatches a vertically centered scroll command to the CodeMirror editor view
   - Guard against missing cursor data and unmounted editor view by returning silently
   - _Requirements: 6.1, 6.2, 6.3_
 
-- [ ] 14. (P) Update EditingUserList with color-matched borders and click-to-scroll
+- [x] 14. (P) Update EditingUserList with color-matched borders and click-to-scroll
   - Replace the fixed blue border on each avatar with a wrapper element whose border color matches the user's cursor color from the awareness state
   - Accept a new click callback prop and wrap each avatar in a clickable element with pointer cursor styling
   - Replace the generic UserPictureList component in the overflow popover with inline rendering so that color-matched borders and click handling apply to all avatars consistently
   - _Requirements: 5.1, 5.2, 5.3, 6.4, 6.5_
 
-- [ ] 15. Connect all components end-to-end
+- [x] 15. Connect all components end-to-end
   - Bridge the scroll-ready callback through the main editor component's props into the collaborative editor mode hook
   - Wire the page editor to store the received scroll callback in the Jotai atom
   - Wire the editor navbar to read the atom and pass the scroll function to the editing user list as the click callback
   - Verify that clicking an avatar scrolls the editor to that user's remote cursor position; verify no-op for users without a cursor
   - _Requirements: 6.1, 6.5_
 
-- [ ]\* 16. Test coverage for color-matched borders and click-to-scroll
-- [ ]\* 16.1 (P) Unit tests for EditingUserList rendering and click behavior
+- [x]\* 16. Test coverage for color-matched borders and click-to-scroll
+- [x]\* 16.1 (P) Unit tests for EditingUserList rendering and click behavior
   - Verify that each avatar renders a colored border matching the user's cursor color instead of the fixed blue
   - Verify that clicking an avatar invokes the callback with the correct client ID
   - Verify that overflow popover avatars also invoke the callback on click
   - _Requirements: 5.1, 6.4, 6.5_
 
-- [ ]\* 16.2 (P) Integration test for the scroll function in the collaborative editor mode hook
+- [x]\* 16.2 (P) Integration test for the scroll function in the collaborative editor mode hook
   - Verify that the configuration callback receives a function when the provider is set up
   - Verify that calling the scroll function with a valid remote client ID dispatches a centered scroll effect to the editor view
   - Verify that calling the scroll function for a client without a cursor position is a silent no-op

+ 5 - 0
apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.module.scss

@@ -3,3 +3,8 @@
 .user-list-popover {
   @extend %user-list-popover;
 }
+
+.avatar-wrapper {
+  // Collapse inline-element ghost space inside the flex container
+  line-height: 0;
+}

+ 204 - 0
apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.spec.tsx

@@ -0,0 +1,204 @@
+import type { EditingClient } from '@growi/editor';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+/**
+ * Unit tests for EditingUserList component.
+ *
+ * Covers:
+ * - Task 14: Color-matched avatar borders and click-to-scroll
+ * - Task 16.1: Unit tests for EditingUserList rendering and click behavior
+ * - Requirements: 5.1, 6.4, 6.5
+ */
+
+// ---------------------------------------------------------------------------
+// Module mocks
+// ---------------------------------------------------------------------------
+
+vi.mock('@growi/ui/dist/components', () => ({
+  UserPicture: ({
+    user,
+    className,
+  }: {
+    user: EditingClient;
+    className?: string;
+  }) => (
+    <span data-testid={`user-picture-${user.clientId}`} className={className}>
+      {user.name}
+    </span>
+  ),
+}));
+
+vi.mock('../../Common/UserPictureList', () => ({
+  default: ({ users }: { users: EditingClient[] }) => (
+    <div data-testid="user-picture-list">
+      {users.map((u) => (
+        <span key={u.clientId} data-testid={`overflow-user-${u.clientId}`}>
+          {u.name}
+        </span>
+      ))}
+    </div>
+  ),
+}));
+
+vi.mock('reactstrap', () => ({
+  Popover: ({
+    children,
+    isOpen,
+  }: {
+    children: React.ReactNode;
+    isOpen: boolean;
+  }) => (isOpen ? <div data-testid="popover">{children}</div> : null),
+  PopoverBody: ({ children }: { children: React.ReactNode }) => (
+    <div data-testid="popover-body">{children}</div>
+  ),
+}));
+
+vi.mock('./EditingUserList.module.scss', () => ({
+  default: { 'user-list-popover': 'user-list-popover' },
+}));
+
+// ---------------------------------------------------------------------------
+// Test data
+// ---------------------------------------------------------------------------
+
+const makeClient = (
+  id: number,
+  name: string,
+  color: string,
+): EditingClient => ({
+  clientId: id,
+  name,
+  color,
+  colorLight: `${color}33`,
+});
+
+const clientAlice = makeClient(1, 'Alice', '#ff0000');
+const clientBob = makeClient(2, 'Bob', '#00ff00');
+const clientCarol = makeClient(3, 'Carol', '#0000ff');
+const clientDave = makeClient(4, 'Dave', '#ffff00');
+const clientEve = makeClient(5, 'Eve', '#ff00ff');
+
+import { EditingUserList } from './EditingUserList';
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('EditingUserList — Task 16.1', () => {
+  describe('Req 5.1 — color-matched avatar borders', () => {
+    it("renders a wrapper with border color matching the user's cursor color", () => {
+      render(<EditingUserList clientList={[clientAlice]} />);
+
+      const wrapper = screen.getByTestId('avatar-wrapper-1');
+      expect(wrapper).toHaveStyle({ borderColor: clientAlice.color });
+    });
+
+    it('does NOT use the fixed border-info CSS class', () => {
+      render(<EditingUserList clientList={[clientAlice]} />);
+
+      const wrapper = screen.getByTestId('avatar-wrapper-1');
+      expect(wrapper).not.toHaveClass('border-info');
+    });
+
+    it('applies color-matched borders to all first-4 avatars', () => {
+      render(
+        <EditingUserList
+          clientList={[clientAlice, clientBob, clientCarol, clientDave]}
+        />,
+      );
+
+      for (const client of [clientAlice, clientBob, clientCarol, clientDave]) {
+        const wrapper = screen.getByTestId(`avatar-wrapper-${client.clientId}`);
+        expect(wrapper).toHaveStyle({ borderColor: client.color });
+      }
+    });
+  });
+
+  describe('Req 6.4 — cursor: pointer affordance', () => {
+    it('avatar wrapper is a button element (provides pointer affordance)', () => {
+      render(<EditingUserList clientList={[clientAlice]} />);
+
+      const wrapper = screen.getByTestId('avatar-wrapper-1');
+      expect(wrapper.tagName).toBe('BUTTON');
+    });
+  });
+
+  describe('Req 6.5 / Task 16.1 — clicking an avatar invokes callback with correct clientId', () => {
+    it("calls onUserClick with the client's clientId when clicked", async () => {
+      const onUserClick = vi.fn();
+      render(
+        <EditingUserList
+          clientList={[clientAlice, clientBob]}
+          onUserClick={onUserClick}
+        />,
+      );
+
+      await userEvent.click(screen.getByTestId('avatar-wrapper-1'));
+      expect(onUserClick).toHaveBeenCalledWith(clientAlice.clientId);
+
+      await userEvent.click(screen.getByTestId('avatar-wrapper-2'));
+      expect(onUserClick).toHaveBeenCalledWith(clientBob.clientId);
+    });
+
+    it('does not throw when onUserClick is not provided', async () => {
+      render(<EditingUserList clientList={[clientAlice]} />);
+      await userEvent.click(screen.getByTestId('avatar-wrapper-1'));
+      // No error expected
+    });
+  });
+
+  describe('Req 6.5 — overflow popover avatars support click-to-scroll', () => {
+    it('renders color-matched wrappers for overflow avatars in the popover', async () => {
+      const clients = [
+        clientAlice,
+        clientBob,
+        clientCarol,
+        clientDave,
+        clientEve,
+      ];
+      render(<EditingUserList clientList={clients} onUserClick={vi.fn()} />);
+
+      // Open the popover by clicking the +1 button
+      const btn = screen.getByRole('button', { name: /^\+1$/ });
+      await userEvent.click(btn);
+
+      // Eve is the 5th user, rendered in overflow
+      const eveWrapper = screen.queryByTestId('avatar-wrapper-5');
+      if (eveWrapper != null) {
+        expect(eveWrapper).toHaveStyle({ borderColor: clientEve.color });
+      }
+    });
+
+    it('calls onUserClick when an overflow avatar is clicked', async () => {
+      const onUserClick = vi.fn();
+      const clients = [
+        clientAlice,
+        clientBob,
+        clientCarol,
+        clientDave,
+        clientEve,
+      ];
+      render(
+        <EditingUserList clientList={clients} onUserClick={onUserClick} />,
+      );
+
+      // Open the popover
+      await userEvent.click(screen.getByRole('button', { name: /^\+1$/ }));
+
+      // Click Eve's avatar in the overflow
+      const eveWrapper = screen.queryByTestId('avatar-wrapper-5');
+      if (eveWrapper != null) {
+        await userEvent.click(eveWrapper);
+        expect(onUserClick).toHaveBeenCalledWith(clientEve.clientId);
+      }
+    });
+  });
+
+  describe('Empty list', () => {
+    it('renders nothing when clientList is empty', () => {
+      const { container } = render(<EditingUserList clientList={[]} />);
+      expect(container.firstChild).toBeNull();
+    });
+  });
+});

+ 36 - 13
apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.tsx

@@ -1,19 +1,37 @@
-import { type FC, useState } from 'react';
+import { type FC, useId, useState } from 'react';
 import type { EditingClient } from '@growi/editor';
 import { UserPicture } from '@growi/ui/dist/components';
 import { Popover, PopoverBody } from 'reactstrap';
 
-import UserPictureList from '../../Common/UserPictureList';
-
 import styles from './EditingUserList.module.scss';
 
 const userListPopoverClass = styles['user-list-popover'] ?? '';
+const avatarWrapperClass = styles['avatar-wrapper'] ?? '';
 
 type Props = {
   clientList: EditingClient[];
+  onUserClick?: (clientId: number) => void;
+};
+
+const AvatarWrapper: FC<{
+  client: EditingClient;
+  onUserClick?: (clientId: number) => void;
+}> = ({ client, onUserClick }) => {
+  return (
+    <button
+      type="button"
+      data-testid={`avatar-wrapper-${client.clientId}`}
+      className={`${avatarWrapperClass} d-inline-flex align-items-center justify-content-center p-0 bg-transparent rounded-circle`}
+      style={{ border: `2px solid ${client.color}` }}
+      onClick={() => onUserClick?.(client.clientId)}
+    >
+      <UserPicture user={client} noLink noTooltip />
+    </button>
+  );
 };
 
-export const EditingUserList: FC<Props> = ({ clientList }) => {
+export const EditingUserList: FC<Props> = ({ clientList, onUserClick }) => {
+  const popoverTargetId = useId();
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
 
   const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
@@ -22,7 +40,7 @@ export const EditingUserList: FC<Props> = ({ clientList }) => {
   const remainingUsers = clientList.slice(4);
 
   if (clientList.length === 0) {
-    return <></>;
+    return null;
   }
 
   return (
@@ -30,11 +48,7 @@ export const EditingUserList: FC<Props> = ({ clientList }) => {
       <div className="d-flex justify-content-start justify-content-sm-end">
         {firstFourUsers.map((editingClient) => (
           <div key={editingClient.clientId} className="ms-1">
-            <UserPicture
-              user={editingClient}
-              noLink
-              className="border border-info"
-            />
+            <AvatarWrapper client={editingClient} onUserClick={onUserClick} />
           </div>
         ))}
 
@@ -42,8 +56,9 @@ export const EditingUserList: FC<Props> = ({ clientList }) => {
           <div className="ms-1">
             <button
               type="button"
-              id="btn-editing-user"
+              id={popoverTargetId}
               className="btn border-0 bg-info-subtle rounded-pill p-0"
+              onClick={togglePopover}
             >
               <span className="fw-bold text-info p-1">
                 +{remainingUsers.length}
@@ -52,12 +67,20 @@ export const EditingUserList: FC<Props> = ({ clientList }) => {
             <Popover
               placement="bottom"
               isOpen={isPopoverOpen}
-              target="btn-editing-user"
+              target={popoverTargetId}
               toggle={togglePopover}
               trigger="legacy"
             >
               <PopoverBody className={userListPopoverClass}>
-                <UserPictureList users={remainingUsers} />
+                <div className="d-flex flex-wrap gap-1">
+                  {remainingUsers.map((editingClient) => (
+                    <AvatarWrapper
+                      key={editingClient.clientId}
+                      client={editingClient}
+                      onUserClick={onUserClick}
+                    />
+                  ))}
+                </div>
               </PopoverBody>
             </Popover>
           </div>

+ 8 - 1
apps/app/src/client/components/PageEditor/EditorNavbar/EditorNavbar.tsx

@@ -2,6 +2,7 @@ import type { JSX } from 'react';
 
 import { PageHeader } from '~/client/components/PageHeader';
 import { useEditingClients } from '~/states/ui/editor/editing-clients';
+import { useScrollToRemoteCursor } from '~/states/ui/editor/scroll-to-remote-cursor';
 
 import { EditingUserList } from './EditingUserList';
 
@@ -11,7 +12,13 @@ const moduleClass = styles['editor-navbar'] ?? '';
 
 const EditingUsers = (): JSX.Element => {
   const editingClients = useEditingClients();
-  return <EditingUserList clientList={editingClients} />;
+  const scrollToRemoteCursor = useScrollToRemoteCursor();
+  return (
+    <EditingUserList
+      clientList={editingClients}
+      onUserClick={scrollToRemoteCursor ?? undefined}
+    />
+  );
 };
 
 export const EditorNavbar = (): JSX.Element => {

+ 3 - 0
apps/app/src/client/components/PageEditor/PageEditor.tsx

@@ -57,6 +57,7 @@ import {
   useWaitingSaveProcessingActions,
 } from '~/states/ui/editor';
 import { useSetEditingClients } from '~/states/ui/editor/editing-clients';
+import { useSetScrollToRemoteCursor } from '~/states/ui/editor/scroll-to-remote-cursor';
 import { useEditorSettings } from '~/stores/editor';
 import { useSWRxCurrentGrantData } from '~/stores/page';
 import { mutatePageTree, mutateRecentlyUpdated } from '~/stores/page-listing';
@@ -128,6 +129,7 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
   );
   const user = useCurrentUser();
   const setEditingClients = useSetEditingClients();
+  const setScrollToRemoteCursor = useSetScrollToRemoteCursor();
   const onConflict = useConflictResolver();
   const reservedNextCaretLine = useReservedNextCaretLineValue();
   const setReservedNextCaretLine = useSetReservedNextCaretLine();
@@ -474,6 +476,7 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
           pageId={pageId ?? undefined}
           editorSettings={editorSettings}
           onEditorsUpdated={setEditingClients}
+          onScrollToRemoteCursorReady={setScrollToRemoteCursor}
           cmProps={cmProps}
         />
       </div>

+ 25 - 0
apps/app/src/states/ui/editor/scroll-to-remote-cursor.ts

@@ -0,0 +1,25 @@
+import { useCallback } from 'react';
+import { atom, useAtomValue, useSetAtom } from 'jotai';
+
+type ScrollToRemoteCursorFn = (clientId: number) => void;
+
+const scrollToRemoteCursorAtom = atom<ScrollToRemoteCursorFn | null>(null);
+
+// Read-only hook
+export const useScrollToRemoteCursor = (): ScrollToRemoteCursorFn | null => {
+  return useAtomValue(scrollToRemoteCursorAtom);
+};
+
+// Setter hook — wraps function values to prevent Jotai from treating them as
+// updater functions (Jotai calls setAtom(fn) as setAtom(prev => fn(prev))).
+export const useSetScrollToRemoteCursor = (): ((
+  fn: ScrollToRemoteCursorFn | null,
+) => void) => {
+  const setAtom = useSetAtom(scrollToRemoteCursorAtom);
+  return useCallback(
+    (fn: ScrollToRemoteCursorFn | null) => {
+      setAtom(() => fn);
+    },
+    [setAtom],
+  );
+};

+ 5 - 0
packages/editor/src/client/components/CodeMirrorEditorMain.tsx

@@ -28,6 +28,9 @@ type Props = CodeMirrorEditorProps & {
   enableCollaboration?: boolean;
   enableUnifiedMergeView?: boolean;
   onEditorsUpdated?: (clientList: EditingClient[]) => void;
+  onScrollToRemoteCursorReady?: (
+    scrollFn: ((clientId: number) => void) | null,
+  ) => void;
 };
 
 export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
@@ -39,6 +42,7 @@ export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
     cmProps,
     onSave,
     onEditorsUpdated,
+    onScrollToRemoteCursorReady,
     ...otherProps
   } = props;
 
@@ -50,6 +54,7 @@ export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
     user,
     pageId,
     onEditorsUpdated,
+    onScrollToRemoteCursorReady,
     reviewMode: enableUnifiedMergeView,
   });
 

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

@@ -36,10 +36,7 @@ export class YRichCursorsPluginValue {
   private readonly topContainer: HTMLElement;
   private readonly bottomContainer: HTMLElement;
 
-  constructor(
-    private readonly view: EditorView,
-    awareness: Awareness,
-  ) {
+  constructor(view: EditorView, awareness: Awareness) {
     this.awareness = awareness;
     this.decorations = RangeSet.of([]);
 

+ 148 - 0
packages/editor/src/client/stores/use-collaborative-editor-mode-scroll.spec.ts

@@ -0,0 +1,148 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import * as Y from 'yjs';
+
+import type { EditingClient } from '../../interfaces';
+
+/**
+ * Integration tests for the scroll-to-remote-cursor logic extracted from
+ * use-collaborative-editor-mode.ts.
+ *
+ * Covers:
+ * - Task 13.2: Scroll function creation and registration
+ * - Task 16.2: Integration test for scroll function
+ * - Requirements: 6.1, 6.2, 6.3
+ */
+
+// ---------------------------------------------------------------------------
+// Minimal stubs
+// ---------------------------------------------------------------------------
+
+type AwarenessState = {
+  editors?: EditingClient;
+  cursor?: {
+    anchor: Y.RelativePosition;
+    head: Y.RelativePosition;
+  };
+};
+
+type FakeEditorView = {
+  dispatch: ReturnType<typeof vi.fn>;
+};
+
+// Import the pure function after it is implemented.
+// Using a dynamic import wrapper so this test file can compile before
+// the implementation exists (RED phase). We test the extracted pure function.
+import { createScrollToRemoteCursorFn } from './use-collaborative-editor-mode';
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('createScrollToRemoteCursorFn — Task 16.2', () => {
+  let ydoc: Y.Doc;
+  let ytext: Y.Text;
+  let states: Map<number, AwarenessState>;
+  let awareness: {
+    getStates: () => Map<number, AwarenessState>;
+    doc: Y.Doc;
+  };
+  let view: FakeEditorView;
+
+  beforeEach(() => {
+    ydoc = new Y.Doc();
+    ytext = ydoc.getText('codemirror');
+    ytext.insert(0, 'Hello World');
+
+    states = new Map<number, AwarenessState>();
+    awareness = {
+      getStates: () => states,
+      doc: ydoc,
+    };
+
+    view = { dispatch: vi.fn() };
+  });
+
+  describe('Task 16.2 — configuration callback receives a scroll function', () => {
+    it('returns a function (not null/undefined)', () => {
+      const scrollFn = createScrollToRemoteCursorFn(
+        awareness,
+        ydoc,
+        () => view as never,
+      );
+      expect(typeof scrollFn).toBe('function');
+    });
+  });
+
+  describe('Task 16.2 — calling scrollFn with a valid remote client dispatches scrollIntoView', () => {
+    it('dispatches an effect when cursor.head resolves to a valid position', () => {
+      const remoteClientId = 42;
+      const headIndex = 5;
+
+      const head = Y.createRelativePositionFromTypeIndex(ytext, headIndex);
+      const anchor = Y.createRelativePositionFromTypeIndex(ytext, 0);
+      states.set(remoteClientId, { cursor: { anchor, head } });
+
+      const scrollFn = createScrollToRemoteCursorFn(
+        awareness,
+        ydoc,
+        () => view as never,
+      );
+      scrollFn(remoteClientId);
+
+      expect(view.dispatch).toHaveBeenCalledOnce();
+      const callArg = view.dispatch.mock.calls[0][0];
+      expect(callArg).toHaveProperty('effects');
+    });
+  });
+
+  describe('Task 16.2 / Req 6.3 — no-op when cursor is absent', () => {
+    it('does not dispatch when the client has no cursor in awareness', () => {
+      const remoteClientId = 42;
+      // Client exists in awareness but has no cursor
+      states.set(remoteClientId, {
+        editors: {
+          clientId: remoteClientId,
+          name: 'Bob',
+          color: '#0000ff',
+          colorLight: '#0000ff33',
+        },
+      });
+
+      const scrollFn = createScrollToRemoteCursorFn(
+        awareness,
+        ydoc,
+        () => view as never,
+      );
+      scrollFn(remoteClientId);
+
+      expect(view.dispatch).not.toHaveBeenCalled();
+    });
+
+    it('does not dispatch when the client is completely absent from awareness', () => {
+      const scrollFn = createScrollToRemoteCursorFn(
+        awareness,
+        ydoc,
+        () => view as never,
+      );
+      scrollFn(9999);
+
+      expect(view.dispatch).not.toHaveBeenCalled();
+    });
+
+    it('does not dispatch when the editor view is not mounted', () => {
+      const remoteClientId = 42;
+      const head = Y.createRelativePositionFromTypeIndex(ytext, 3);
+      const anchor = Y.createRelativePositionFromTypeIndex(ytext, 0);
+      states.set(remoteClientId, { cursor: { anchor, head } });
+
+      const scrollFn = createScrollToRemoteCursorFn(
+        awareness,
+        ydoc,
+        () => undefined,
+      );
+      scrollFn(remoteClientId);
+
+      expect(view.dispatch).not.toHaveBeenCalled();
+    });
+  });
+});

+ 2 - 2
packages/editor/src/client/stores/use-collaborative-editor-mode.spec.ts

@@ -1,4 +1,4 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
 
 import type { EditingClient } from '../../interfaces';
 
@@ -31,7 +31,7 @@ function emitEditorList(
 
 /** Replicates the FIXED updateAwarenessHandler logic */
 function updateAwarenessHandler(
-  update: { added: number[]; updated: number[]; removed: number[] },
+  _update: { added: number[]; updated: number[]; removed: number[] },
   awareness: { getStates: () => Map<number, AwarenessState> },
   onEditorsUpdated: (list: EditingClient[]) => void,
 ): void {

+ 87 - 2
packages/editor/src/client/stores/use-collaborative-editor-mode.ts

@@ -1,5 +1,5 @@
 import { useEffect, useState } from 'react';
-import { keymap } from '@codemirror/view';
+import { EditorView, keymap } from '@codemirror/view';
 import { YJS_WEBSOCKET_BASE_PATH } from '@growi/core/dist/consts';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import { yCollab, yUndoManagerKeymap } from 'y-codemirror.next';
@@ -12,11 +12,59 @@ import type { UseCodeMirrorEditor } from '../services';
 import { yRichCursors } from '../services-internal/extensions/y-rich-cursors';
 import { useSecondaryYdocs } from './use-secondary-ydocs';
 
+type Awareness = WebsocketProvider['awareness'];
+
+type AwarenessState = {
+  editors?: EditingClient;
+  cursor?: {
+    anchor: Y.RelativePosition;
+    head: Y.RelativePosition;
+  };
+};
+
 type Configuration = {
   user?: IUserHasId;
   pageId?: string;
   reviewMode?: boolean;
   onEditorsUpdated?: (clientList: EditingClient[]) => void;
+  onScrollToRemoteCursorReady?: (
+    scrollFn: ((clientId: number) => void) | null,
+  ) => void;
+};
+
+/**
+ * Pure function that creates a scroll-to-remote-cursor callback.
+ * Extracted for unit testability.
+ *
+ * @param awareness - Yjs awareness instance for reading remote cursor positions
+ * @param activeDoc - The active Y.Doc used to resolve relative positions
+ * @param getView - Lazy accessor for the CodeMirror EditorView
+ */
+export const createScrollToRemoteCursorFn = (
+  awareness: Pick<Awareness, 'getStates'>,
+  activeDoc: Y.Doc,
+  getView: () => EditorView | undefined,
+): ((clientId: number) => void) => {
+  return (clientId: number) => {
+    const state = awareness.getStates().get(clientId) as
+      | AwarenessState
+      | undefined;
+    const cursor = state?.cursor;
+    if (cursor?.head == null) return;
+
+    const pos = Y.createAbsolutePositionFromRelativePosition(
+      cursor.head,
+      activeDoc,
+    );
+    if (pos == null) return;
+
+    const view = getView();
+    if (view == null) return;
+
+    view.dispatch({
+      effects: EditorView.scrollIntoView(pos.index, { y: 'center' }),
+    });
+  };
 };
 
 export const useCollaborativeEditorMode = (
@@ -24,7 +72,13 @@ export const useCollaborativeEditorMode = (
   codeMirrorEditor?: UseCodeMirrorEditor,
   configuration?: Configuration,
 ): void => {
-  const { user, pageId, onEditorsUpdated, reviewMode } = configuration ?? {};
+  const {
+    user,
+    pageId,
+    onEditorsUpdated,
+    reviewMode,
+    onScrollToRemoteCursorReady,
+  } = configuration ?? {};
 
   const { primaryDoc, activeDoc } =
     useSecondaryYdocs(isEnabled, {
@@ -139,4 +193,35 @@ export const useCollaborativeEditorMode = (
       codeMirrorEditor.initDoc('');
     };
   }, [isEnabled, codeMirrorEditor, provider, primaryDoc, activeDoc]);
+
+  // Setup scroll-to-remote-cursor callback
+  useEffect(() => {
+    if (
+      !isEnabled ||
+      provider == null ||
+      activeDoc == null ||
+      codeMirrorEditor == null
+    ) {
+      onScrollToRemoteCursorReady?.(null);
+      return;
+    }
+
+    const scrollFn = createScrollToRemoteCursorFn(
+      provider.awareness,
+      activeDoc,
+      () => codeMirrorEditor.view,
+    );
+
+    onScrollToRemoteCursorReady?.(scrollFn);
+
+    return () => {
+      onScrollToRemoteCursorReady?.(null);
+    };
+  }, [
+    isEnabled,
+    provider,
+    activeDoc,
+    codeMirrorEditor,
+    onScrollToRemoteCursorReady,
+  ]);
 };