Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: collaborative follow mode (#50)' (#132) from feat/follow-mode into main

scott 9f570d0b 8db8b73b

+323
+167
src/lib/follow-mode.ts
··· 1 + /** 2 + * Follow Mode — viewport syncing to follow another collaborator's cursor. 3 + * 4 + * Pure logic module: follow state, viewport calculation, event handling. 5 + * DOM scroll synchronization handled by the editor layer. 6 + */ 7 + 8 + export interface CursorPosition { 9 + userId: string; 10 + userName: string; 11 + /** Scroll position (pixels from top) */ 12 + scrollTop: number; 13 + /** Selection anchor position (document offset for docs, cell ID for sheets) */ 14 + anchor: string | number; 15 + /** Timestamp of last update */ 16 + updatedAt: number; 17 + } 18 + 19 + export interface FollowState { 20 + /** Currently followed user ID, or null if not following */ 21 + followingUserId: string | null; 22 + /** Whether follow mode is active */ 23 + active: boolean; 24 + /** Auto-unfollow when the local user scrolls manually */ 25 + autoUnfollow: boolean; 26 + /** Threshold in ms to consider a cursor stale */ 27 + staleThreshold: number; 28 + } 29 + 30 + /** 31 + * Create initial follow state. 32 + */ 33 + export function createFollowState( 34 + staleThreshold = 10000, 35 + ): FollowState { 36 + return { 37 + followingUserId: null, 38 + active: false, 39 + autoUnfollow: true, 40 + staleThreshold, 41 + }; 42 + } 43 + 44 + /** 45 + * Start following a user. 46 + */ 47 + export function startFollowing( 48 + state: FollowState, 49 + userId: string, 50 + ): FollowState { 51 + return { 52 + ...state, 53 + followingUserId: userId, 54 + active: true, 55 + }; 56 + } 57 + 58 + /** 59 + * Stop following. 60 + */ 61 + export function stopFollowing(state: FollowState): FollowState { 62 + return { 63 + ...state, 64 + followingUserId: null, 65 + active: false, 66 + }; 67 + } 68 + 69 + /** 70 + * Toggle follow mode for a user. 71 + */ 72 + export function toggleFollow( 73 + state: FollowState, 74 + userId: string, 75 + ): FollowState { 76 + if (state.active && state.followingUserId === userId) { 77 + return stopFollowing(state); 78 + } 79 + return startFollowing(state, userId); 80 + } 81 + 82 + /** 83 + * Check if a cursor position is stale. 84 + */ 85 + export function isCursorStale( 86 + cursor: CursorPosition, 87 + now: number, 88 + threshold: number, 89 + ): boolean { 90 + return now - cursor.updatedAt > threshold; 91 + } 92 + 93 + /** 94 + * Handle a local user scroll event. 95 + * If autoUnfollow is enabled and the user scrolls manually, stop following. 96 + */ 97 + export function handleLocalScroll( 98 + state: FollowState, 99 + isManualScroll: boolean, 100 + ): FollowState { 101 + if (!state.active || !state.autoUnfollow || !isManualScroll) { 102 + return state; 103 + } 104 + return stopFollowing(state); 105 + } 106 + 107 + /** 108 + * Determine if the viewport should scroll to follow a cursor update. 109 + */ 110 + export function shouldScrollToFollow( 111 + state: FollowState, 112 + cursor: CursorPosition, 113 + now: number, 114 + ): boolean { 115 + if (!state.active) return false; 116 + if (state.followingUserId !== cursor.userId) return false; 117 + if (isCursorStale(cursor, now, state.staleThreshold)) return false; 118 + return true; 119 + } 120 + 121 + /** 122 + * Compute the target scroll position to center the followed cursor. 123 + */ 124 + export function computeFollowScroll( 125 + cursorScrollTop: number, 126 + viewportHeight: number, 127 + ): number { 128 + // Center the cursor in the viewport 129 + const target = cursorScrollTop - viewportHeight / 2; 130 + return Math.max(0, target); 131 + } 132 + 133 + /** 134 + * Get a list of available users to follow from cursor positions. 135 + * Excludes the local user and stale cursors. 136 + */ 137 + export function getFollowableUsers( 138 + cursors: CursorPosition[], 139 + localUserId: string, 140 + now: number, 141 + staleThreshold: number, 142 + ): CursorPosition[] { 143 + return cursors.filter(c => 144 + c.userId !== localUserId && 145 + !isCursorStale(c, now, staleThreshold), 146 + ); 147 + } 148 + 149 + /** 150 + * Set the autoUnfollow preference. 151 + */ 152 + export function setAutoUnfollow( 153 + state: FollowState, 154 + enabled: boolean, 155 + ): FollowState { 156 + return { ...state, autoUnfollow: enabled }; 157 + } 158 + 159 + /** 160 + * Check if currently following a specific user. 161 + */ 162 + export function isFollowing( 163 + state: FollowState, 164 + userId: string, 165 + ): boolean { 166 + return state.active && state.followingUserId === userId; 167 + }
+156
tests/follow-mode.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createFollowState, 4 + startFollowing, 5 + stopFollowing, 6 + toggleFollow, 7 + isCursorStale, 8 + handleLocalScroll, 9 + shouldScrollToFollow, 10 + computeFollowScroll, 11 + getFollowableUsers, 12 + setAutoUnfollow, 13 + isFollowing, 14 + type CursorPosition, 15 + } from '../src/lib/follow-mode.js'; 16 + 17 + const NOW = 10000; 18 + 19 + const CURSORS: CursorPosition[] = [ 20 + { userId: 'alice', userName: 'Alice', scrollTop: 500, anchor: 100, updatedAt: NOW - 1000 }, 21 + { userId: 'bob', userName: 'Bob', scrollTop: 1200, anchor: 250, updatedAt: NOW - 2000 }, 22 + { userId: 'carol', userName: 'Carol', scrollTop: 0, anchor: 0, updatedAt: NOW - 20000 }, // stale 23 + ]; 24 + 25 + describe('Follow Mode', () => { 26 + describe('createFollowState', () => { 27 + it('creates inactive state', () => { 28 + const state = createFollowState(); 29 + expect(state.active).toBe(false); 30 + expect(state.followingUserId).toBeNull(); 31 + expect(state.autoUnfollow).toBe(true); 32 + }); 33 + }); 34 + 35 + describe('startFollowing / stopFollowing', () => { 36 + it('starts following a user', () => { 37 + const state = startFollowing(createFollowState(), 'alice'); 38 + expect(state.active).toBe(true); 39 + expect(state.followingUserId).toBe('alice'); 40 + }); 41 + 42 + it('stops following', () => { 43 + const state = stopFollowing(startFollowing(createFollowState(), 'alice')); 44 + expect(state.active).toBe(false); 45 + expect(state.followingUserId).toBeNull(); 46 + }); 47 + }); 48 + 49 + describe('toggleFollow', () => { 50 + it('starts following if not active', () => { 51 + const state = toggleFollow(createFollowState(), 'alice'); 52 + expect(state.active).toBe(true); 53 + expect(state.followingUserId).toBe('alice'); 54 + }); 55 + 56 + it('stops following if already following same user', () => { 57 + const active = startFollowing(createFollowState(), 'alice'); 58 + const toggled = toggleFollow(active, 'alice'); 59 + expect(toggled.active).toBe(false); 60 + }); 61 + 62 + it('switches to new user if following different user', () => { 63 + const active = startFollowing(createFollowState(), 'alice'); 64 + const switched = toggleFollow(active, 'bob'); 65 + expect(switched.active).toBe(true); 66 + expect(switched.followingUserId).toBe('bob'); 67 + }); 68 + }); 69 + 70 + describe('isCursorStale', () => { 71 + it('returns false for fresh cursor', () => { 72 + expect(isCursorStale(CURSORS[0], NOW, 10000)).toBe(false); 73 + }); 74 + 75 + it('returns true for stale cursor', () => { 76 + expect(isCursorStale(CURSORS[2], NOW, 10000)).toBe(true); 77 + }); 78 + }); 79 + 80 + describe('handleLocalScroll', () => { 81 + it('unfollows on manual scroll when autoUnfollow enabled', () => { 82 + const state = startFollowing(createFollowState(), 'alice'); 83 + const updated = handleLocalScroll(state, true); 84 + expect(updated.active).toBe(false); 85 + }); 86 + 87 + it('keeps following on programmatic scroll', () => { 88 + const state = startFollowing(createFollowState(), 'alice'); 89 + const updated = handleLocalScroll(state, false); 90 + expect(updated.active).toBe(true); 91 + }); 92 + 93 + it('keeps following when autoUnfollow disabled', () => { 94 + let state = startFollowing(createFollowState(), 'alice'); 95 + state = setAutoUnfollow(state, false); 96 + const updated = handleLocalScroll(state, true); 97 + expect(updated.active).toBe(true); 98 + }); 99 + }); 100 + 101 + describe('shouldScrollToFollow', () => { 102 + it('returns true when following the cursor user', () => { 103 + const state = startFollowing(createFollowState(), 'alice'); 104 + expect(shouldScrollToFollow(state, CURSORS[0], NOW)).toBe(true); 105 + }); 106 + 107 + it('returns false when not active', () => { 108 + expect(shouldScrollToFollow(createFollowState(), CURSORS[0], NOW)).toBe(false); 109 + }); 110 + 111 + it('returns false for different user', () => { 112 + const state = startFollowing(createFollowState(), 'alice'); 113 + expect(shouldScrollToFollow(state, CURSORS[1], NOW)).toBe(false); 114 + }); 115 + 116 + it('returns false for stale cursor', () => { 117 + const state = startFollowing(createFollowState(), 'carol'); 118 + expect(shouldScrollToFollow(state, CURSORS[2], NOW)).toBe(false); 119 + }); 120 + }); 121 + 122 + describe('computeFollowScroll', () => { 123 + it('centers cursor in viewport', () => { 124 + const target = computeFollowScroll(1000, 600); 125 + expect(target).toBe(700); // 1000 - 600/2 126 + }); 127 + 128 + it('clamps to zero', () => { 129 + const target = computeFollowScroll(100, 800); 130 + expect(target).toBe(0); 131 + }); 132 + }); 133 + 134 + describe('getFollowableUsers', () => { 135 + it('excludes local user and stale cursors', () => { 136 + const followable = getFollowableUsers(CURSORS, 'alice', NOW, 10000); 137 + expect(followable).toHaveLength(1); 138 + expect(followable[0].userId).toBe('bob'); 139 + }); 140 + 141 + it('returns empty when alone', () => { 142 + expect(getFollowableUsers(CURSORS, 'alice', NOW, 1)).toEqual([]); 143 + }); 144 + }); 145 + 146 + describe('isFollowing', () => { 147 + it('returns true when following the specified user', () => { 148 + const state = startFollowing(createFollowState(), 'alice'); 149 + expect(isFollowing(state, 'alice')).toBe(true); 150 + }); 151 + 152 + it('returns false when not following', () => { 153 + expect(isFollowing(createFollowState(), 'alice')).toBe(false); 154 + }); 155 + }); 156 + });