fix(packages/excalidraw): setViewport stops following a collaborator (#12184)

Navigating with `setViewport` takes the view over from the followed user,
as a user's pan or zoom does, so it now asks to stop following
(`requestUnfollow`: `onUserFollow` with `action: "UNFOLLOW"`, when
`userToFollow` is set). `setViewport(null)`, which only clears a lock, and
a target that doesn't resolve don't navigate and leave following alone.

Following itself doesn't go through `setViewport`: excalidraw-app and
Excalidraw+ both apply the followed user's viewport with `updateScene` +
`zoomToFitBounds`, so it's unaffected.

Tests: navigating emits the UNFOLLOW intent once; clearing a lock and an
unresolved target don't.

Claude Opus 5.5 (1M), xhigh effort
This commit is contained in:
David Luzar
2026-09-27 19:47:29 +02:00
committed by GitHub
parent 74423812ec
commit 02fc9f3505
4 changed files with 56 additions and 2 deletions
+4
View File
@@ -31,6 +31,10 @@ Please add the latest change on the top under the correct section.
## Excalidraw API
### `setViewport` stops following a collaborator (2026-09-26) [#XXXX](https://github.com/excalidraw/excalidraw/pull/XXXX)
- `setViewport` now asks to stop following a collaborator whenever it navigates (`onUserFollow` with `action: "UNFOLLOW"`, if `userToFollow` is set), as a user's pan or zoom does. `setViewport(null)` (clearing a lock) and a target that doesn't resolve don't navigate, and leave following alone.
### `onDuplicate` can replace and veto duplicates (2026-09-18) [#XXXX](https://github.com/excalidraw/excalidraw/pull/XXXX)
- `props.onDuplicate` may now return new objects for the duplicated elements in every duplication path. Previously the editor kept using the objects it created, so on paste (and library insert) the frame assignment and bound text redraw went to objects that were no longer in the scene, and alt-drag dragged an element that wasn't in the scene. The returned changes are now merged into the editor's duplicates, so they're part of the duplication's own undo entry and durable increment (no follow-up `updateScene` needed).
+2 -1
View File
@@ -5321,7 +5321,8 @@ class App extends React.Component<AppProps, AppState> {
};
/** emits an UNFOLLOW intent if currently following someone — use on
* user-initiated viewport changes which should break follow mode */
* viewport changes that take the view over from the followed user (user
* pans and zooms, `setViewport` navigation) */
public requestUnfollow = () => {
if (this.props.userToFollow) {
this.emitUserFollowIntent({
@@ -665,7 +665,10 @@ export class AppViewport {
});
};
/** Navigates to a target and optionally installs scroll/zoom constraints. */
/**
* Navigates to a target and optionally installs scroll/zoom constraints.
* Navigating stops following a collaborator, as a user's pan does.
*/
setViewport = (opts: SetViewportOptions | null) => {
if (opts === null) {
this.cancelTransition();
@@ -699,6 +702,10 @@ export class AppViewport {
return;
}
// the view is about to show something other than what the followed
// user sees
this.app.requestUnfollow();
const viewportUpdate = getConstrainedTargetViewport(
this.app.state,
bounds,
@@ -1,4 +1,5 @@
import React from "react";
import { vi } from "vitest";
import { Excalidraw } from "../index";
import { SCROLL_TO_CONTENT_ANIMATION_KEY } from "../components/App.viewport";
@@ -8,6 +9,8 @@ import { getNormalizedZoom } from "../scene";
import { API } from "./helpers/api";
import { act, render } from "./test-utils";
import type { SocketId } from "../types";
const { h } = window;
/**
@@ -229,6 +232,45 @@ describe("none", () => {
});
});
describe("following a collaborator", () => {
const userToFollow = { socketId: "socket-1" as SocketId, username: "Ann" };
it("should stop following when navigating", async () => {
const onUserFollow = vi.fn();
await render(
<Excalidraw userToFollow={userToFollow} onUserFollow={onUserFollow} />,
);
const rectElement = API.createElement({ x: 1000, y: 1000 });
API.setElements([rectElement]);
act(() => {
h.app.viewport.setViewport({ target: rectElement, animation: false });
});
expect(onUserFollow).toHaveBeenCalledTimes(1);
expect(onUserFollow).toHaveBeenCalledWith({
userToFollow,
action: "UNFOLLOW",
});
});
it("should keep following when not navigating", async () => {
const onUserFollow = vi.fn();
await render(
<Excalidraw userToFollow={userToFollow} onUserFollow={onUserFollow} />,
);
act(() => {
// clears a lock; the view stays where it is
h.app.viewport.setViewport(null);
// resolves to nothing
h.app.viewport.setViewport({ target: "missing-id", animation: false });
});
expect(onUserFollow).not.toHaveBeenCalled();
});
});
describe("scale-down animated", () => {
beforeEach(() => {
// pace the animation via requestAnimationFrame instead of a tight