diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index ab33e1423..68d6bc210 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -375,6 +375,7 @@ export class Editor extends EventEmitter { ease?: (t: number) => number; }): this; animateToShape(shapeId: TLShapeId, opts?: TLAnimationOptions): this; + animateToUser(userId: string): void; get animationSpeed(): number; // @internal (undocumented) annotateError(error: unknown, { origin, willCrashApp, tags, extras, }: { diff --git a/packages/editor/src/lib/components/LiveCollaborators.tsx b/packages/editor/src/lib/components/LiveCollaborators.tsx index 23d41b6f2..62d44d36d 100644 --- a/packages/editor/src/lib/components/LiveCollaborators.tsx +++ b/packages/editor/src/lib/components/LiveCollaborators.tsx @@ -1,4 +1,6 @@ +import { useEffect, useRef, useState } from 'react' import { track } from 'signia-react' +import { COLLABORATOR_CHECK_INTERVAL, COLLABORATOR_TIMEOUT } from '../constants' import { useEditor } from '../hooks/useEditor' import { useEditorComponents } from '../hooks/useEditorComponents' import { usePeerIds } from '../hooks/usePeerIds' @@ -28,8 +30,34 @@ const Collaborator = track(function Collaborator({ userId }: { userId: string }) } = useEditorComponents() const latestPresence = usePresence(userId) + + const [isTimedOut, setIsTimedOut] = useState(false) + const rLastSeen = useRef(-1) + + useEffect(() => { + const interval = setInterval(() => { + setIsTimedOut(Date.now() - rLastSeen.current > COLLABORATOR_TIMEOUT) + }, COLLABORATOR_CHECK_INTERVAL) + + return () => clearInterval(interval) + }, []) + if (!latestPresence) return null + // We can do this on every render, it's free and would be the same as running a useEffect with a dependency on the timestamp + rLastSeen.current = latestPresence.lastActivityTimestamp + + // If the user has timed out + // ... and we're not following them + // ... and they're not highlighted + // then we'll hide the contributor + if ( + isTimedOut && + editor.instanceState.followingUserId !== userId && + !editor.instanceState.highlightedUserIds.includes(userId) + ) + return null + // if the collaborator is on another page, ignore them if (latestPresence.currentPageId !== editor.currentPageId) return null diff --git a/packages/editor/src/lib/constants.ts b/packages/editor/src/lib/constants.ts index dfef18e5d..0e7a69c81 100644 --- a/packages/editor/src/lib/constants.ts +++ b/packages/editor/src/lib/constants.ts @@ -273,3 +273,17 @@ export const BLACKLISTED_PROPS = new Set([ 'url', 'growY', ]) + +/** @internal */ +export const COLLABORATOR_TIMEOUT = 3000 + +/** @internal */ +export const COLLABORATOR_CHECK_INTERVAL = 1200 + +/** + * Negative pointer ids are reserved for internal use. + * + * @internal */ +export const INTERNAL_POINTER_IDS = { + CAMERA_MOVE: -10, +} as const diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 18ebfeef1..79826d97f 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -86,6 +86,7 @@ import { ANIMATION_MEDIUM_MS, BLACKLISTED_PROPS, COARSE_DRAG_DISTANCE, + COLLABORATOR_TIMEOUT, DEFAULT_ANIMATION_OPTIONS, DRAG_DISTANCE, FOLLOW_CHASE_PAN_SNAP, @@ -95,6 +96,7 @@ import { FOLLOW_CHASE_ZOOM_UNSNAP, GRID_INCREMENT, HAND_TOOL_FRICTION, + INTERNAL_POINTER_IDS, MAJOR_NUDGE_FACTOR, MAX_PAGES, MAX_SHAPES_PER_PAGE, @@ -3802,17 +3804,18 @@ export class Editor extends EventEmitter { previousScreenPoint.setTo(currentScreenPoint) previousPagePoint.setTo(currentPagePoint) - const px = (sx - screenBounds.x) / cz - cx - const py = (sy - screenBounds.y) / cz - cy - currentScreenPoint.set(sx, sy) - currentPagePoint.set(px, py, sz ?? 0.5) + currentPagePoint.set( + (sx - screenBounds.x) / cz - cx, + (sy - screenBounds.y) / cz - cy, + sz ?? 0.5 + ) this.inputs.isPen = info.type === 'pointer' && info.isPen // Reset velocity on pointer down if (info.name === 'pointer_down') { - this.inputs.pointerVelocity = new Vec2d() + this.inputs.pointerVelocity.set(0, 0) } // todo: We only have to do this if there are multiple users in the document @@ -3822,7 +3825,12 @@ export class Editor extends EventEmitter { typeName: 'pointer', x: currentPagePoint.x, y: currentPagePoint.y, - lastActivityTimestamp: Date.now(), + lastActivityTimestamp: + // If our pointer moved only because we're following some other user, then don't + // update our last activity timestamp; otherwise, update it to the current timestamp. + info.type === 'pointer' && info.pointerId === INTERNAL_POINTER_IDS.CAMERA_MOVE + ? this.store.get(TLPOINTER_ID)?.lastActivityTimestamp ?? Date.now() + : Date.now(), }, ]) } @@ -8391,7 +8399,7 @@ export class Editor extends EventEmitter { target: 'canvas', name: 'pointer_move', point: currentScreenPoint, - pointerId: 0, + pointerId: INTERNAL_POINTER_IDS.CAMERA_MOVE, ctrlKey: this.inputs.ctrlKey, altKey: this.inputs.altKey, shiftKey: this.inputs.shiftKey, @@ -9013,6 +9021,60 @@ export class Editor extends EventEmitter { return this } + /** + * Animate the camera to a user's cursor position. + * This also briefly show the user's cursor if it's not currently visible. + * + * @param userId - The id of the user to aniamte to. + * @public + */ + animateToUser(userId: string) { + const presences = this.store.query.records('instance_presence', () => ({ + userId: { eq: userId }, + })) + + const presence = [...presences.value] + .sort((a, b) => { + return a.lastActivityTimestamp - b.lastActivityTimestamp + }) + .pop() + + if (!presence) return + + this.batch(() => { + // If we're following someone, stop following them + if (this.instanceState.followingUserId !== null) { + this.stopFollowingUser() + } + + // If we're not on the same page, move to the page they're on + const isOnSamePage = presence.currentPageId === this.currentPageId + if (!isOnSamePage) { + this.setCurrentPageId(presence.currentPageId) + } + + // Only animate the camera if the user is on the same page as us + const options = isOnSamePage ? { duration: 500 } : undefined + + const position = presence.cursor + + this.centerOnPoint(position.x, position.y, options) + + // Highlight the user's cursor + const { highlightedUserIds } = this.instanceState + this.updateInstanceState({ highlightedUserIds: [...highlightedUserIds, userId] }) + + // Unhighlight the user's cursor after a few seconds + setTimeout(() => { + const highlightedUserIds = [...this.instanceState.highlightedUserIds] + const index = highlightedUserIds.indexOf(userId) + if (index < 0) return + highlightedUserIds.splice(index, 1) + this.updateInstanceState({ highlightedUserIds }) + }, COLLABORATOR_TIMEOUT) + }) + } + /** * Start viewport-following a user. * @@ -9021,9 +9083,6 @@ export class Editor extends EventEmitter { * @public */ startFollowingUser(userId: string) { - // Currently, we get the leader's viewport page bounds from their user presence. - // This is a placeholder until the ephemeral PR lands. - // After that, we'll be able to get the required data from their instance presence instead. const leaderPresences = this.store.query.records('instance_presence', () => ({ userId: { eq: userId }, })) diff --git a/packages/tlschema/api-report.md b/packages/tlschema/api-report.md index 1bd61fb38..04bba1081 100644 --- a/packages/tlschema/api-report.md +++ b/packages/tlschema/api-report.md @@ -951,6 +951,8 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> { // (undocumented) followingUserId: null | string; // (undocumented) + highlightedUserIds: string[]; + // (undocumented) isChatting: boolean; // (undocumented) isDebugMode: boolean; diff --git a/packages/tlschema/src/migrations.test.ts b/packages/tlschema/src/migrations.test.ts index edbbfd3d3..0ac137c8e 100644 --- a/packages/tlschema/src/migrations.test.ts +++ b/packages/tlschema/src/migrations.test.ts @@ -1110,6 +1110,18 @@ describe('hoist opacity', () => { }) }) +describe('Adds highlightedUserIds to instance', () => { + const { up, down } = instanceMigrations.migrators[instanceTypeVersions.AddHighlightedUserIds] + + test('up works as expected', () => { + expect(up({})).toEqual({ highlightedUserIds: [] }) + }) + + test('down works as expected', () => { + expect(down({ highlightedUserIds: [] })).toEqual({}) + }) +}) + describe('Adds chat message to presence', () => { const { up, down } = instancePresenceMigrations.migrators[3] diff --git a/packages/tlschema/src/records/TLInstance.ts b/packages/tlschema/src/records/TLInstance.ts index b952619b8..3ec1a5826 100644 --- a/packages/tlschema/src/records/TLInstance.ts +++ b/packages/tlschema/src/records/TLInstance.ts @@ -33,6 +33,7 @@ export type TLInstancePropsForNextShape = Pick export interface TLInstance extends BaseRecord<'instance', TLInstanceId> { currentPageId: TLPageId followingUserId: string | null + highlightedUserIds: string[] brush: Box2dModel | null opacityForNextShape: TLOpacityType propsForNextShape: TLInstancePropsForNextShape @@ -66,6 +67,7 @@ export const instanceTypeValidator: T.Validator = T.model( id: idValidator('instance'), currentPageId: pageIdValidator, followingUserId: T.string.nullable(), + highlightedUserIds: T.arrayOf(T.string), brush: T.boxModel.nullable(), opacityForNextShape: opacityValidator, propsForNextShape: T.object({ @@ -113,13 +115,14 @@ const Versions = { AddIsPenModeAndIsGridMode: 12, HoistOpacity: 13, AddChat: 14, + AddHighlightedUserIds: 15, } as const export { Versions as instanceTypeVersions } /** @public */ export const instanceMigrations = defineMigrations({ - currentVersion: Versions.AddChat, + currentVersion: Versions.AddHighlightedUserIds, migrators: { [Versions.AddTransparentExportBgs]: { up: (instance: TLInstance) => { @@ -296,6 +299,14 @@ export const instanceMigrations = defineMigrations({ return instance }, }, + [Versions.AddHighlightedUserIds]: { + up: (instance: TLInstance) => { + return { ...instance, highlightedUserIds: [] } + }, + down: ({ highlightedUserIds: _, ...instance }: TLInstance) => { + return instance + }, + }, }, }) @@ -307,6 +318,7 @@ export const InstanceRecordType = createRecordType('instance', { }).withDefaultProperties( (): Omit => ({ followingUserId: null, + highlightedUserIds: [], opacityForNextShape: 1, propsForNextShape: { color: 'black',