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

Configure Feed

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

at main 141 lines 5.4 kB view raw
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}