Full document, spreadsheet, slideshow, and diagram tooling
1/**
2 * Collaboration UI — avatar rendering, follow mode, and typewriter/focus mode.
3 *
4 * Extracted from main.ts for decomposition.
5 */
6
7import type * as Y from 'yjs';
8import type { Editor } from '@tiptap/core';
9import type { EncryptedProvider } from '../lib/provider.js';
10import {
11 type FollowState,
12 createFollowState,
13 startFollowing,
14 stopFollowing,
15 shouldScrollToFollow,
16 computeFollowScroll,
17 handleLocalScroll,
18 type CursorPosition,
19} from '../lib/follow-mode.js';
20
21// ── Types ───────────────────────────────────────────────────
22
23export interface CollaborationUIDeps {
24 editor: Editor;
25 ydoc: Y.Doc;
26 provider: EncryptedProvider;
27}
28
29export interface CollaborationUIResult {
30 processFollowUpdate: (cursor: CursorPosition) => void;
31}
32
33// ── Collaboration Avatars ───────────────────────────────────
34
35export function wireCollabAvatars(deps: { provider: EncryptedProvider; ydoc: Y.Doc; avatarContainer: HTMLElement }): void {
36 const { provider, ydoc, avatarContainer } = deps;
37
38 provider.awareness.on('change', () => {
39 const states = provider.awareness.getStates();
40 avatarContainer.innerHTML = '';
41 states.forEach((state: any, clientId: number) => {
42 if (clientId === ydoc.clientID) return;
43 const user = state.user;
44 if (!user) return;
45 const avatar = document.createElement('div');
46 avatar.className = 'collab-avatar';
47 avatar.style.background = user.color;
48 avatar.textContent = user.name.charAt(0).toUpperCase();
49 avatar.title = user.name;
50 avatarContainer.appendChild(avatar);
51 });
52 });
53}
54
55// ── Follow Mode ─────────────────────────────────────────────
56
57export function wireFollowMode(deps: { editor: Editor }): CollaborationUIResult {
58 const followBanner = document.getElementById('follow-banner') as HTMLElement;
59 const followLabel = document.getElementById('follow-label') as HTMLElement;
60 const followStop = document.getElementById('follow-stop') as HTMLElement;
61
62 let followState = createFollowState();
63 let isFollowScroll = false;
64 const editorContainer = document.querySelector('.editor-container');
65
66 followStop.addEventListener('click', () => {
67 followState = stopFollowing(followState);
68 followBanner.style.display = 'none';
69 });
70
71 // Listen for manual scroll to auto-unfollow
72 if (editorContainer) {
73 editorContainer.addEventListener('scroll', () => {
74 if (isFollowScroll) { isFollowScroll = false; return; }
75 followState = handleLocalScroll(followState, true);
76 if (!followState.active) followBanner.style.display = 'none';
77 }, { passive: true });
78 }
79
80 // Follow a collaborator: triggered by clicking their avatar in the topbar
81 document.getElementById('collab-avatars')?.addEventListener('click', (e) => {
82 const avatarEl = (e.target as HTMLElement).closest('[data-user-id]');
83 if (!avatarEl) return;
84 const userId = avatarEl.getAttribute('data-user-id')!;
85 const displayName = avatarEl.getAttribute('title') || userId;
86
87 followState = startFollowing(followState, userId);
88 followLabel.textContent = `Following ${displayName}`;
89 followBanner.style.display = '';
90 });
91
92 // Process remote cursor updates for follow mode
93 function processFollowUpdate(cursor: CursorPosition): void {
94 if (!shouldScrollToFollow(followState, cursor, Date.now())) return;
95 if (!editorContainer) return;
96
97 const scrollTarget = computeFollowScroll(cursor.scrollTop, editorContainer.clientHeight);
98 isFollowScroll = true;
99 editorContainer.scrollTo({ top: scrollTarget, behavior: 'smooth' });
100 }
101
102 return { processFollowUpdate };
103}
104
105// ── Typewriter / Focus Mode ─────────────────────────────────
106
107export function wireTypewriterMode(deps: { editor: Editor }): void {
108 const { editor } = deps;
109 const btnTypewriter = document.getElementById('btn-typewriter') as HTMLElement;
110 let typewriterActive = false;
111
112 btnTypewriter.addEventListener('click', () => {
113 typewriterActive = !typewriterActive;
114 document.body.classList.toggle('typewriter-mode', typewriterActive);
115 btnTypewriter.classList.toggle('active', typewriterActive);
116
117 if (typewriterActive) {
118 updateTypewriterFocus();
119 }
120 });
121
122 function updateTypewriterFocus(): void {
123 if (!typewriterActive) return;
124 const prosemirror = editor.view.dom;
125 // Remove previous active marks
126 prosemirror.querySelectorAll('.is-active-node').forEach(el => el.classList.remove('is-active-node'));
127
128 // Find the block containing the cursor
129 const { $anchor } = editor.state.selection;
130 const resolvedPos = editor.view.domAtPos($anchor.pos);
131 let node = resolvedPos.node;
132 if (node.nodeType === Node.TEXT_NODE) node = node.parentElement!;
133 const block = (node as HTMLElement).closest('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre, .task-item') as HTMLElement | null;
134 if (block) {
135 block.classList.add('is-active-node');
136 block.scrollIntoView({ behavior: 'smooth', block: 'center' });
137 }
138 }
139
140 editor.on('selectionUpdate', updateTypewriterFocus);
141}