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 'refactor(docs): phase 2 — extract slash menu, suggesting, comments, AI chat, collaboration, sidebar' (#291) from refactor/docs-decompose-phase2 into main

scott 6896404a 70058398

+1177 -1015
+154
src/docs/ai-chat-wiring.ts
··· 1 + /** 2 + * AI Chat Wiring — chat sidebar creation, message sending with streaming, 3 + * action card rendering, and doc-action execution. 4 + * 5 + * Extracted from main.ts for decomposition. 6 + */ 7 + 8 + import type { Editor } from '@tiptap/core'; 9 + import { 10 + createChatSidebar, createChatState, loadConfig, isConfigured, 11 + buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 12 + renderMarkdown, appendActionCard, escapeHtml, initChatWiring, 13 + type ChatMessage, 14 + } from '../lib/ai-chat.js'; 15 + import { splitResponse, isDocAction } from '../lib/ai-actions.js'; 16 + import { executeDocAction } from './ai-doc-actions.js'; 17 + 18 + // ── Types ─────────────────────────────────────────────────── 19 + 20 + export interface AiChatWiringDeps { 21 + editor: Editor; 22 + titleInput: HTMLInputElement; 23 + $: (id: string) => HTMLElement; 24 + } 25 + 26 + export interface AiChatWiringResult { 27 + chatUI: ReturnType<typeof createChatSidebar>; 28 + chatState: ReturnType<typeof createChatState>; 29 + } 30 + 31 + // ── AI Chat Wiring ────────────────────────────────────────── 32 + 33 + export function wireAiChat(deps: AiChatWiringDeps): AiChatWiringResult { 34 + const { editor, titleInput, $ } = deps; 35 + 36 + const chatUI = createChatSidebar(); 37 + const mainContent = $('main-content'); 38 + mainContent.appendChild(chatUI.container); 39 + 40 + const chatState = createChatState(); 41 + 42 + const chatWiring = initChatWiring({ 43 + chatUI, 44 + chatState, 45 + chatConfig: loadConfig(), 46 + toggleBtn: $('btn-ai-chat'), 47 + editorType: 'doc', 48 + onSend: sendMessage, 49 + }); 50 + 51 + async function sendMessage(): Promise<void> { 52 + const text = chatUI.input.value.trim(); 53 + if (!text || chatState.loading) return; 54 + 55 + const cfg = chatWiring.getConfig(); 56 + if (!isConfigured(cfg)) { 57 + chatUI.settingsPanel.style.display = ''; 58 + chatUI.endpointInput.focus(); 59 + return; 60 + } 61 + 62 + // Add user message 63 + const userMsg: ChatMessage = { role: 'user', content: text, ts: Date.now() }; 64 + chatState.messages.push(userMsg); 65 + appendMessage(chatUI.messageList, userMsg); 66 + 67 + chatUI.input.value = ''; 68 + chatUI.input.style.height = ''; 69 + chatUI.sendBtn.style.display = 'none'; 70 + chatUI.stopBtn.style.display = ''; 71 + chatState.loading = true; 72 + chatState.error = null; 73 + 74 + // Build context 75 + const docTitle = titleInput.value.trim() || 'Untitled'; 76 + const includeContext = chatUI.contextToggle.checked; 77 + const actionsEnabled = chatUI.actionsToggle.checked; 78 + const docText = includeContext ? editor.getText() : ''; 79 + const { from, to } = editor.state.selection; 80 + const selectionText = from !== to ? editor.state.doc.textBetween(from, to) : ''; 81 + const systemPrompt = buildSystemMessage(docTitle, docText, { 82 + editorType: 'doc', 83 + actionsEnabled, 84 + selectionContext: selectionText || undefined, 85 + }); 86 + 87 + // Streaming response 88 + const abortController = new AbortController(); 89 + chatState.abortController = abortController; 90 + const bubble = appendStreamingBubble(chatUI.messageList); 91 + let fullText = ''; 92 + 93 + await streamChat( 94 + cfg, 95 + chatState.messages, 96 + systemPrompt, 97 + { 98 + onChunk(chunk) { 99 + fullText += chunk; 100 + bubble.update(renderMarkdown(fullText)); 101 + }, 102 + onDone(text) { 103 + if (text) { 104 + chatState.messages.push({ role: 'assistant', content: text, ts: Date.now() }); 105 + 106 + // Parse and render action cards 107 + if (actionsEnabled) { 108 + const { displayText, actions } = splitResponse(text); 109 + if (actions.length > 0) { 110 + bubble.update(renderMarkdown(displayText)); 111 + for (const action of actions) { 112 + if (!isDocAction(action)) continue; 113 + appendActionCard(chatUI.messageList, action, { 114 + onApply: (a) => { 115 + const result = executeDocAction(editor, a as Parameters<typeof executeDocAction>[1]); 116 + if (!result.success && result.error) { 117 + appendMessage(chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 118 + } 119 + }, 120 + onSuggest: (a) => { 121 + const suggestAction = a.type === 'doc_insert' 122 + ? { ...a, type: 'doc_suggest_insert' as const } 123 + : a.type === 'doc_replace' 124 + ? { ...a, type: 'doc_suggest_replace' as const } 125 + : a; 126 + const result = executeDocAction(editor, suggestAction as Parameters<typeof executeDocAction>[1]); 127 + if (!result.success && result.error) { 128 + appendMessage(chatUI.messageList, { role: 'assistant', content: `Suggestion failed: ${result.error}`, ts: Date.now() }); 129 + } 130 + }, 131 + onDismiss: () => {}, 132 + }); 133 + } 134 + } 135 + } 136 + } 137 + }, 138 + onError(err) { 139 + chatState.error = err; 140 + bubble.el.classList.add('ai-chat-bubble--error'); 141 + bubble.update(`<span class="ai-chat-error">${escapeHtml(err)}</span>`); 142 + }, 143 + }, 144 + abortController.signal, 145 + ); 146 + 147 + chatState.loading = false; 148 + chatState.abortController = null; 149 + chatUI.sendBtn.style.display = ''; 150 + chatUI.stopBtn.style.display = 'none'; 151 + } 152 + 153 + return { chatUI, chatState }; 154 + }
+141
src/docs/collaboration-ui.ts
··· 1 + /** 2 + * Collaboration UI — avatar rendering, follow mode, and typewriter/focus mode. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import type * as Y from 'yjs'; 8 + import type { Editor } from '@tiptap/core'; 9 + import type { EncryptedProvider } from '../lib/provider.js'; 10 + import { 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 + 23 + export interface CollaborationUIDeps { 24 + editor: Editor; 25 + ydoc: Y.Doc; 26 + provider: EncryptedProvider; 27 + } 28 + 29 + export interface CollaborationUIResult { 30 + processFollowUpdate: (cursor: CursorPosition) => void; 31 + } 32 + 33 + // ── Collaboration Avatars ─────────────────────────────────── 34 + 35 + export 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 + 57 + export 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 + 107 + export 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 + }
+149
src/docs/comments-sidebar-ui.ts
··· 1 + /** 2 + * Comments Sidebar UI — threaded comments rendering, reply/resolve/reopen 3 + * actions, Yjs sync, and new-comment input wiring. 4 + * 5 + * Extracted from main.ts for decomposition. 6 + */ 7 + 8 + import type * as Y from 'yjs'; 9 + import type { Editor } from '@tiptap/core'; 10 + import { 11 + type CommentThread, 12 + createThread, 13 + addReply, 14 + resolveThread, 15 + reopenThread, 16 + sortThreads, 17 + generateCommentId, 18 + } from '../lib/comment-threads.js'; 19 + import { escapeHtml } from '../lib/ai-chat.js'; 20 + 21 + // ── Types ─────────────────────────────────────────────────── 22 + 23 + export interface CommentsSidebarDeps { 24 + editor: Editor; 25 + ydoc: Y.Doc; 26 + userName: string; 27 + } 28 + 29 + export interface CommentsSidebarResult { 30 + loadCommentsFromYjs: () => void; 31 + } 32 + 33 + // ── Comments Sidebar UI ───────────────────────────────────── 34 + 35 + export function wireCommentsSidebar(deps: CommentsSidebarDeps): CommentsSidebarResult { 36 + const { editor, ydoc, userName } = deps; 37 + 38 + const commentsSidebar = document.getElementById('comments-sidebar') as HTMLElement; 39 + const commentsListEl = document.getElementById('comments-list') as HTMLElement; 40 + const commentsSidebarClose = document.getElementById('comments-sidebar-close') as HTMLElement; 41 + const commentNewInput = document.getElementById('comment-new-input') as HTMLInputElement; 42 + const btnComments = document.getElementById('btn-comments') as HTMLElement; 43 + 44 + let commentThreads: CommentThread[] = []; 45 + 46 + function renderCommentThreads(): void { 47 + const sorted = sortThreads(commentThreads, 'unresolved'); 48 + if (sorted.length === 0) { 49 + commentsListEl.innerHTML = '<div style="padding:var(--space-sm);color:var(--color-text-faint);font-size:0.8rem;">No comments yet. Select text and click the comment button to start a thread.</div>'; 50 + return; 51 + } 52 + commentsListEl.innerHTML = sorted.map(t => { 53 + const allComments = [t.root, ...t.replies]; 54 + return `<div class="comment-thread ${t.resolved ? 'resolved' : ''}" data-thread-id="${t.id}"> 55 + ${allComments.map(c => `<div> 56 + <span class="comment-author">${escapeHtml(c.author)}</span> 57 + <span class="comment-time">${new Date(c.createdAt).toLocaleString()}</span> 58 + <div class="comment-body">${escapeHtml(c.text)}</div> 59 + </div>`).join('')} 60 + <div class="comment-actions"> 61 + ${t.resolved 62 + ? `<button data-action="reopen" data-id="${t.id}">Reopen</button>` 63 + : `<button data-action="resolve" data-id="${t.id}">Resolve</button>`} 64 + <button data-action="reply" data-id="${t.id}">Reply</button> 65 + </div> 66 + </div>`; 67 + }).join(''); 68 + 69 + commentsListEl.querySelectorAll('[data-action]').forEach(btn => { 70 + btn.addEventListener('click', () => { 71 + const action = btn.getAttribute('data-action'); 72 + const threadId = btn.getAttribute('data-id')!; 73 + const idx = commentThreads.findIndex(t => t.id === threadId); 74 + if (idx === -1) return; 75 + 76 + if (action === 'resolve') { 77 + commentThreads[idx] = resolveThread(commentThreads[idx], userName); 78 + renderCommentThreads(); 79 + syncCommentsToYjs(); 80 + } else if (action === 'reopen') { 81 + commentThreads[idx] = reopenThread(commentThreads[idx]); 82 + renderCommentThreads(); 83 + syncCommentsToYjs(); 84 + } else if (action === 'reply') { 85 + const text = prompt('Reply:'); 86 + if (text) { 87 + commentThreads[idx] = addReply(commentThreads[idx], userName, text); 88 + renderCommentThreads(); 89 + syncCommentsToYjs(); 90 + } 91 + } 92 + }); 93 + }); 94 + } 95 + 96 + function syncCommentsToYjs(): void { 97 + ydoc.getMap('meta').set('commentThreads', JSON.stringify(commentThreads)); 98 + } 99 + 100 + function loadCommentsFromYjs(): void { 101 + const raw = ydoc.getMap('meta').get('commentThreads') as string | undefined; 102 + if (raw) { 103 + try { commentThreads = JSON.parse(raw); } catch { commentThreads = []; } 104 + } 105 + renderCommentThreads(); 106 + } 107 + 108 + // Sync comments on yjs changes 109 + ydoc.getMap('meta').observe(() => { loadCommentsFromYjs(); }); 110 + 111 + btnComments.addEventListener('click', () => { 112 + const showing = commentsSidebar.style.display !== 'none'; 113 + commentsSidebar.style.display = showing ? 'none' : ''; 114 + if (!showing) renderCommentThreads(); 115 + }); 116 + 117 + commentsSidebarClose.addEventListener('click', () => { 118 + commentsSidebar.style.display = 'none'; 119 + }); 120 + 121 + // Add comment from sidebar input on Enter (requires text selection in editor) 122 + commentNewInput.addEventListener('keydown', (e) => { 123 + if (e.key !== 'Enter') return; 124 + const text = commentNewInput.value.trim(); 125 + if (!text) return; 126 + 127 + const { from, to } = editor.state.selection; 128 + if (from === to) { 129 + commentNewInput.placeholder = 'Select text first...'; 130 + return; 131 + } 132 + 133 + const anchorId = generateCommentId(); 134 + editor.chain().focus().setComment({ 135 + commentId: anchorId, 136 + author: userName, 137 + timestamp: new Date().toISOString(), 138 + text, 139 + }).run(); 140 + 141 + const thread = createThread(anchorId, userName, text); 142 + commentThreads.push(thread); 143 + renderCommentThreads(); 144 + syncCommentsToYjs(); 145 + commentNewInput.value = ''; 146 + }); 147 + 148 + return { loadCommentsFromYjs }; 149 + }
+54 -1015
src/docs/main.ts
··· 31 31 import Collaboration from '@tiptap/extension-collaboration'; 32 32 import CollaborationCursor from '@tiptap/extension-collaboration-cursor'; 33 33 34 - import { importKey, encrypt, decrypt, encryptString, decryptString } from '../lib/crypto.js'; 34 + import { importKey, encryptString, decryptString } from '../lib/crypto.js'; 35 35 import { storeKey, pushKeysToServer, fetchServerKeys, getLocalKeys } from '../lib/key-sync.js'; 36 36 import { EncryptedProvider } from '../lib/provider.js'; 37 37 import { FontSize } from './extensions/font-size.js'; 38 38 import { Indent } from './extensions/indent.js'; 39 39 import { Comment } from './extensions/comment.js'; 40 - import { LineSpacing, LINE_SPACING_PRESETS } from './extensions/line-spacing.js'; 41 - import { ParagraphSpacing, PARAGRAPH_SPACING_PRESETS } from './extensions/paragraph-spacing.js'; 40 + import { LineSpacing } from './extensions/line-spacing.js'; 41 + import { ParagraphSpacing } from './extensions/paragraph-spacing.js'; 42 42 import { PageBreak } from './extensions/page-break.js'; 43 43 import { ToggleBlock, ToggleSummary } from './extensions/toggle-block.js'; 44 44 import { Footnote, getAllFootnotes } from './extensions/footnote.js'; ··· 53 53 import { htmlToMarkdown as turndownHtmlToMarkdown } from './markdown-export.js'; 54 54 import { createMarkdownToggle, TOGGLE_MODE } from './markdown-toggle.js'; 55 55 import { createVersionPanel } from '../version-panel.js'; 56 - import { extractHeadings, computeViewportIndicator } from './minimap.js'; 57 - import { 58 - type CommentThread, 59 - createThread, 60 - addReply, 61 - resolveThread, 62 - reopenThread, 63 - sortThreads, 64 - unresolvedCount, 65 - findThreadByAnchor, 66 - generateCommentId, 67 - } from '../lib/comment-threads.js'; 68 - import { 69 - type FollowState, 70 - createFollowState, 71 - startFollowing, 72 - stopFollowing, 73 - shouldScrollToFollow, 74 - computeFollowScroll, 75 - getFollowableUsers, 76 - handleLocalScroll, 77 - type CursorPosition, 78 - } from '../lib/follow-mode.js'; 79 - import { SuggestionManager, createSuggestionAttrs } from '../lib/suggesting.js'; 80 56 import { OfflineManager } from '../lib/offline.js'; 81 - import { extractHeadings as extractOutlineHeadings, OutlineState } from './outline.js'; 82 - import { TableToolbarState } from './table-toolbar.js'; 83 - import { LinkPreviewState, truncateUrl, computeTooltipPosition } from './link-preview.js'; 84 - import { 85 - createChatSidebar, createChatState, loadConfig, isConfigured, 86 - buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 87 - renderMarkdown, appendActionCard, escapeHtml, initChatWiring, 88 - type ChatMessage, 89 - } from '../lib/ai-chat.js'; 90 - import { splitResponse, isDocAction } from '../lib/ai-actions.js'; 91 - import { executeDocAction } from './ai-doc-actions.js'; 92 - import { ZenModeState, ZEN_STORAGE_KEY, ZEN_CLASS, ZEN_TRANSITION_MS } from './zen-mode.js'; 93 - import { SLASH_COMMAND_ITEMS, SlashMenuState, filterCommands, PLACEHOLDER_EMPTY, PLACEHOLDER_BLOCK } from './slash-menu.js'; 57 + import { ZenModeState, ZEN_STORAGE_KEY, ZEN_CLASS } from './zen-mode.js'; 58 + import { filterCommands, PLACEHOLDER_EMPTY, PLACEHOLDER_BLOCK } from './slash-menu.js'; 94 59 import { createSlashCommands, getCommandExecutor } from './extensions/slash-commands.js'; 95 60 import { createCommandPalette, type PaletteAction } from '../command-palette.js'; 96 61 97 - // --- Extracted modules --- 62 + // --- Extracted modules (phase 1) --- 98 63 import { 99 64 closeAllDropdowns as _closeAllDropdowns, 100 65 toggleDropdown as _toggleDropdown, ··· 117 82 import { wireVersionHistory, wireShareDialog } from './version-history-ui.js'; 118 83 import { wireBlockHandleUI } from './block-handle-ui.js'; 119 84 85 + // --- Extracted modules (phase 2) --- 86 + import { createSlashMenuUI } from './slash-menu-ui.js'; 87 + import { wireSuggestingUI } from './suggesting-ui.js'; 88 + import { wireCommentsSidebar } from './comments-sidebar-ui.js'; 89 + import { wireAiChat } from './ai-chat-wiring.js'; 90 + import { wireCollabAvatars, wireFollowMode, wireTypewriterMode } from './collaboration-ui.js'; 91 + import { wireOutlineSidebar, wireTableToolbar, wireLinkPreview, wireMinimap } from './sidebar-wiring.js'; 92 + 120 93 // --- Resolve document ID and encryption key --- 121 94 const pathParts = location.pathname.split('/').filter(Boolean); 122 95 const docId = pathParts[1]; ··· 124 97 125 98 // Migrate legacy localStorage keys 126 99 if (localStorage.getItem('crypt-keys') && !localStorage.getItem('tools-keys')) { 127 - localStorage.setItem('tools-keys', localStorage.getItem('crypt-keys')); 100 + localStorage.setItem('tools-keys', localStorage.getItem('crypt-keys')!); 128 101 localStorage.removeItem('crypt-keys'); 129 102 } 130 103 if (localStorage.getItem('crypt-username') && !localStorage.getItem('tools-username')) { 131 - localStorage.setItem('tools-username', localStorage.getItem('crypt-username')); 104 + localStorage.setItem('tools-username', localStorage.getItem('crypt-username')!); 132 105 localStorage.removeItem('crypt-username'); 133 106 } 134 107 ··· 179 152 } 180 153 }).catch(() => { /* anonymous access */ }); 181 154 155 + // --- Slash Command Menu (hoisted before editor for reference in extensions) --- 156 + const { slashMenuState, renderSlashMenu, hideSlashMenu, updateSlashMenuSelection, getCommandRef } = createSlashMenuUI(); 157 + 182 158 // --- TipTap Editor --- 159 + // Late-binding wrapper for find bar (wired after editor creation) 160 + let findBarUpdateFn: (() => void) | null = null; 161 + 183 162 const editor = new Editor({ 184 163 element: document.getElementById('editor'), 185 164 extensions: [ ··· 228 207 SuggestionInsert, 229 208 SuggestionDelete, 230 209 SearchReplace.configure({ 231 - onStateChange: () => findBarResult.updateFindBar(), 210 + onStateChange: () => findBarUpdateFn?.(), 232 211 }), 233 212 TabSupport, 234 213 MarkdownAutoformat, ··· 266 245 } 267 246 if (event.key === 'Enter') { 268 247 const item = slashMenuState.getSelectedItem(); 269 - if (item && slashMenuCommandRef) { 270 - slashMenuCommandRef({ ...item, execute: getCommandExecutor(item) }); 248 + const commandRef = getCommandRef(); 249 + if (item && commandRef) { 250 + commandRef({ ...item, execute: getCommandExecutor(item) }); 271 251 } 272 252 return true; 273 253 } ··· 284 264 }); 285 265 286 266 // --- Markdown Paste Handler --- 287 - // Convert pasted markdown to rich text using the existing markdown-it parser. 288 - // Only triggers for plain-text paste that looks like markdown (conservative detection). 289 267 editor.setOptions({ 290 268 editorProps: { 291 269 handlePaste: (_view, event) => { ··· 293 271 if (!clip) return false; 294 272 const text = clip.getData('text/plain'); 295 273 if (!text || !looksLikeMarkdown(text)) return false; 296 - // If clipboard has HTML, only override when the HTML contains 297 - // unrendered markdown syntax (e.g. literal [text](url) not in <a> tags). 298 - // Well-rendered HTML (from rich text editors) is left to TipTap. 299 274 if (clip.types.includes('text/html')) { 300 275 const clipHtml = clip.getData('text/html'); 301 276 if (!htmlContainsRawMarkdown(clipHtml)) return false; ··· 309 284 }, 310 285 }); 311 286 312 - // --- Slash Command Menu --- 313 - const slashMenuState = new SlashMenuState(); 314 - let slashMenuCommandRef: ((item: Record<string, unknown>) => void) | null = null; 315 - 316 - // Create the slash menu popup element 317 - const slashMenuEl = document.createElement('div'); 318 - slashMenuEl.className = 'slash-menu'; 319 - slashMenuEl.id = 'slash-menu'; 320 - slashMenuEl.style.display = 'none'; 321 - document.body.appendChild(slashMenuEl); 322 - 323 - function renderSlashMenu(props: { command: (item: Record<string, unknown>) => void; query?: string; clientRect?: (() => DOMRect | null) | null }): void { 324 - slashMenuCommandRef = props.command; 325 - const items = slashMenuState.getFilteredItems(); 326 - const grouped = slashMenuState.getGroupedItems(); 327 - 328 - if (items.length === 0) { 329 - slashMenuEl.innerHTML = '<div class="slash-menu-empty">No results</div>'; 330 - slashMenuEl.style.display = 'block'; 331 - positionSlashMenu(props); 332 - return; 333 - } 334 - 335 - let html = ''; 336 - let flatIdx = 0; 337 - for (const group of grouped) { 338 - html += `<div class="slash-menu-category">${group.label}</div>`; 339 - for (const item of group.items) { 340 - const selected = flatIdx === slashMenuState.selectedIndex ? ' slash-menu-item-selected' : ''; 341 - html += `<button class="slash-menu-item${selected}" data-command-id="${item.id}" data-index="${flatIdx}">`; 342 - html += `<span class="slash-menu-item-icon">${item.icon}</span>`; 343 - html += `<span class="slash-menu-item-body">`; 344 - html += `<span class="slash-menu-item-name">${item.name}</span>`; 345 - html += `<span class="slash-menu-item-desc">${item.description}</span>`; 346 - html += `</span>`; 347 - if (item.shortcut) { 348 - html += `<span class="slash-menu-item-shortcut">${item.shortcut}</span>`; 349 - } 350 - html += `</button>`; 351 - flatIdx++; 352 - } 353 - } 354 - slashMenuEl.innerHTML = html; 355 - slashMenuEl.style.display = 'block'; 356 - positionSlashMenu(props); 357 - 358 - // Click handler for items 359 - slashMenuEl.querySelectorAll('.slash-menu-item').forEach(btn => { 360 - btn.addEventListener('mousedown', (e) => { 361 - e.preventDefault(); 362 - const cmdId = btn.dataset.commandId; 363 - const item = SLASH_COMMAND_ITEMS.find(i => i.id === cmdId); 364 - if (item && slashMenuCommandRef) { 365 - slashMenuCommandRef({ ...item, execute: getCommandExecutor(item) }); 366 - } 367 - }); 368 - btn.addEventListener('mouseenter', () => { 369 - const idx = parseInt(btn.dataset.index, 10); 370 - slashMenuState.selectedIndex = idx; 371 - updateSlashMenuSelection(); 372 - }); 373 - }); 374 - } 375 - 376 - function positionSlashMenu(props: { clientRect?: (() => DOMRect | null) | null }): void { 377 - if (!props.clientRect) return; 378 - const rect = props.clientRect(); 379 - if (!rect) return; 380 - slashMenuEl.style.left = `${rect.left}px`; 381 - slashMenuEl.style.top = `${rect.bottom + 4}px`; 382 - } 383 - 384 - function updateSlashMenuSelection() { 385 - const items = slashMenuEl.querySelectorAll('.slash-menu-item'); 386 - items.forEach((el, idx) => { 387 - el.classList.toggle('slash-menu-item-selected', idx === slashMenuState.selectedIndex); 388 - }); 389 - // Scroll selected into view 390 - const selected = slashMenuEl.querySelector('.slash-menu-item-selected'); 391 - if (selected) { 392 - selected.scrollIntoView({ block: 'nearest' }); 393 - } 394 - } 395 - 396 - function hideSlashMenu() { 397 - slashMenuEl.style.display = 'none'; 398 - slashMenuEl.innerHTML = ''; 399 - slashMenuCommandRef = null; 400 - } 401 - 402 287 // --- Block Handle (extracted) --- 403 - const { blockHandleState } = wireBlockHandleUI({ editor, $: (id) => document.getElementById(id) as HTMLElement }); 288 + wireBlockHandleUI({ editor, $: (id) => document.getElementById(id) as HTMLElement }); 404 289 405 290 // --- Toolbar wiring (extracted) --- 406 291 const $ = (id: string): HTMLElement => document.getElementById(id) as HTMLElement; ··· 466 351 sessionStorage.removeItem(pendingKey); 467 352 try { 468 353 const pending = JSON.parse(pendingRaw); 469 - // Set import-in-progress flag to prevent snapshot saves during async import 470 354 window.__importInProgress = true; 471 - // Convert data URL back to a File object 472 355 fetch(pending.data) 473 356 .then(r => r.blob()) 474 357 .then(async blob => { ··· 477 360 }) 478 361 .finally(async () => { 479 362 window.__importInProgress = false; 480 - // Force save now that import flag is cleared 481 363 await provider._saveSnapshot(); 482 364 }); 483 365 } catch { ··· 544 426 545 427 setInterval(updateSaveTimestamp, 30_000); 546 428 547 - // Listen for save-status events from the provider (replaces monkey-patching) 548 429 provider.on('save-status', (payload) => { 549 430 if (payload.status === 'saving') setSaveState('saving'); 550 431 else if (payload.status === 'saved') { 551 432 setSaveState('saved', Date.now()); 552 - // Show "Saved locally" when offline (saved to IDB only, not server) 553 433 if (!provider.connected && saveText) { 554 434 saveText.textContent = 'Saved locally'; 555 435 } ··· 557 437 else if (payload.status === 'error') setSaveState('unsaved'); 558 438 }); 559 439 560 - // Update save dot color based on save-status events 561 440 const saveDot = document.querySelector('.save-dot') as HTMLElement | null; 562 441 if (saveDot) { 563 442 provider.on('save-status', (payload) => { ··· 623 502 editor.on('update', updateFootnoteSection); 624 503 updateFootnoteSection(); 625 504 626 - // --- Minimap (#60) --- 627 - const minimapEl = document.getElementById('minimap') as HTMLElement; 628 - const minimapViewport = document.getElementById('minimap-viewport') as HTMLElement; 629 - const minimapHeadingsEl = document.getElementById('minimap-headings') as HTMLElement; 630 - 631 - function updateMinimap() { 632 - const html = editor.getHTML(); 633 - const headings = extractHeadings(html); 634 - 635 - if (headings.length < 2) { 636 - minimapEl.style.display = 'none'; 637 - return; 638 - } 639 - 640 - minimapEl.style.display = ''; 641 - minimapHeadingsEl.innerHTML = headings.map(h => 642 - `<div class="minimap-heading" data-level="${h.level}" title="${h.text}">${h.text}</div>` 643 - ).join(''); 644 - 645 - // Click to scroll to heading 646 - minimapHeadingsEl.querySelectorAll('.minimap-heading').forEach((el, i) => { 647 - el.addEventListener('click', () => { 648 - const headingEls = editor.view.dom.querySelectorAll('h1, h2, h3, h4, h5, h6'); 649 - if (headingEls[i]) { 650 - headingEls[i].scrollIntoView({ behavior: 'smooth', block: 'start' }); 651 - } 652 - }); 653 - }); 654 - 655 - updateMinimapViewport(); 656 - } 657 - 658 - function updateMinimapViewport() { 659 - const editorEl = editor.view.dom.closest('.editor-container') || editor.view.dom.parentElement; 660 - if (!editorEl) return; 661 - const scrollEl = editorEl.closest('.editor-container') || document.documentElement; 662 - const { top, height } = computeViewportIndicator( 663 - scrollEl.scrollTop, 664 - scrollEl.clientHeight, 665 - scrollEl.scrollHeight, 666 - ); 667 - minimapViewport.style.top = top + '%'; 668 - minimapViewport.style.height = height + '%'; 669 - } 670 - 671 - editor.on('update', updateMinimap); 672 - document.addEventListener('scroll', updateMinimapViewport, { passive: true }); 673 - // Delayed initial render 674 - setTimeout(updateMinimap, 500); 505 + // --- Minimap (extracted) --- 506 + wireMinimap({ editor }); 675 507 676 508 // --- Keyboard Shortcuts (extracted) --- 677 509 $('btn-shortcuts').addEventListener('click', showShortcutModal); ··· 679 511 // --- Find Bar (extracted) --- 680 512 insertFindBar(); 681 513 const findBarResult = wireFindBar({ editor, $ }); 514 + findBarUpdateFn = findBarResult.updateFindBar; 682 515 683 516 // --- Keyboard shortcuts (extracted) --- 684 517 wireGlobalShortcuts({ ··· 705 538 markdownToHtml: markdownToHtml, 706 539 onModeChange: (mode) => { 707 540 const isMd = mode === TOGGLE_MODE.MARKDOWN; 708 - // Show/hide editor vs textarea 709 541 editorWrapper.style.display = isMd ? 'none' : ''; 710 542 markdownTextarea.style.display = isMd ? '' : 'none'; 711 - // Toggle active state on button 712 543 mdToggleBtn.classList.toggle('active', isMd); 713 - // Disable toolbar in markdown mode 714 544 const toolbar = $('toolbar'); 715 545 if (toolbar) { 716 546 toolbar.style.pointerEvents = isMd ? 'none' : ''; 717 547 toolbar.style.opacity = isMd ? '0.5' : ''; 718 548 } 719 549 if (isMd) { 720 - markdownTextarea.value = mdToggle.getMarkdownContent(); 550 + (markdownTextarea as HTMLTextAreaElement).value = mdToggle.getMarkdownContent(); 721 551 markdownTextarea.focus(); 722 552 } 723 553 }, 724 554 }); 725 555 726 - // Sync textarea edits back to toggle state 727 - markdownTextarea.addEventListener('input', () => { 728 - mdToggle.setMarkdownContent(markdownTextarea.value); 556 + (markdownTextarea as HTMLTextAreaElement).addEventListener('input', () => { 557 + mdToggle.setMarkdownContent((markdownTextarea as HTMLTextAreaElement).value); 729 558 }); 730 559 731 560 mdToggleBtn.addEventListener('click', () => { 732 561 mdToggle.toggle(); 733 562 }); 734 563 735 - // --- Collaboration avatars --- 736 - const avatarContainer = $('collab-avatars'); 564 + // --- Collaboration avatars (extracted) --- 565 + wireCollabAvatars({ provider, ydoc, avatarContainer: $('collab-avatars') }); 737 566 738 - provider.awareness.on('change', () => { 739 - const states = provider.awareness.getStates(); 740 - avatarContainer.innerHTML = ''; 741 - states.forEach((state, clientId) => { 742 - if (clientId === ydoc.clientID) return; 743 - const user = state.user; 744 - if (!user) return; 745 - const avatar = document.createElement('div'); 746 - avatar.className = 'collab-avatar'; 747 - avatar.style.background = user.color; 748 - avatar.textContent = user.name.charAt(0).toUpperCase(); 749 - avatar.title = user.name; 750 - avatarContainer.appendChild(avatar); 751 - }); 752 - }); 753 - 754 - // ============================================= 755 567 // --- Version History (extracted) --- 756 - // ============================================= 757 568 wireVersionHistory({ editor, ydoc, provider, docId, cryptoKey, userName, $ }); 758 569 759 570 // --- Share Dialog (extracted) --- ··· 770 581 }, 771 582 }); 772 583 773 - // Wire Cmd+Shift+H to toggle version panel 774 584 document.addEventListener('keydown', (e) => { 775 585 if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'h') { 776 586 e.preventDefault(); ··· 778 588 } 779 589 }); 780 590 781 - // ============================================= 782 - // --- Suggesting Mode --- 783 - // ============================================= 784 - const suggestionMgr = new SuggestionManager(); 785 - const suggestingLabel = $('suggesting-label'); 786 - const suggestingBtn = $('btn-suggesting'); 787 - const suggestionPopover = $('suggestion-popover'); 788 - const suggestionAuthorEl = $('suggestion-author'); 789 - const suggestionTimeEl = $('suggestion-time'); 790 - const suggestionTypeEl = $('suggestion-type'); 791 - let activeSuggestion: { id: string; element: Element; type: 'insert' | 'delete' } | null = null; 591 + // --- Suggesting Mode (extracted) --- 592 + wireSuggestingUI({ editor, userName, $ }); 792 593 793 - suggestingBtn.addEventListener('click', () => { 794 - suggestionMgr.toggleMode(); 795 - updateSuggestingUI(); 796 - }); 797 - 798 - function updateSuggestingUI() { 799 - const suggesting = suggestionMgr.isSuggesting(); 800 - suggestingLabel.textContent = suggesting ? 'Suggesting' : 'Editing'; 801 - suggestingBtn.classList.toggle('active', suggesting); 802 - } 803 - 804 - // Intercept editor transactions in suggesting mode 805 - editor.on('beforeCreate', ({ editor: ed }) => { 806 - // This runs before the editor is fully created, so we set up the plugin after 807 - }); 808 - 809 - // Handle suggestion mark clicks 810 - document.addEventListener('click', (e) => { 811 - const suggestionEl = e.target.closest('.suggestion-insert, .suggestion-delete'); 812 - if (suggestionEl) { 813 - const id = suggestionEl.getAttribute('data-suggestion-id'); 814 - const author = suggestionEl.getAttribute('data-suggestion-author'); 815 - const timestamp = suggestionEl.getAttribute('data-suggestion-timestamp'); 816 - const type = suggestionEl.classList.contains('suggestion-insert') ? 'Insertion' : 'Deletion'; 817 - 818 - suggestionAuthorEl.textContent = author || 'Unknown'; 819 - suggestionTimeEl.textContent = timestamp ? new Date(timestamp).toLocaleString() : ''; 820 - suggestionTypeEl.textContent = type; 821 - activeSuggestion = { id, element: suggestionEl, type: type === 'Insertion' ? 'insert' : 'delete' }; 822 - 823 - const rect = suggestionEl.getBoundingClientRect(); 824 - suggestionPopover.style.display = ''; 825 - suggestionPopover.style.left = `${Math.min(rect.left, window.innerWidth - 260)}px`; 826 - suggestionPopover.style.top = `${rect.bottom + 4}px`; 827 - } else if (!e.target.closest('.suggestion-popover')) { 828 - suggestionPopover.style.display = 'none'; 829 - activeSuggestion = null; 830 - } 831 - }); 832 - 833 - function handleSuggestionAction(action: 'accept' | 'reject'): void { 834 - if (!activeSuggestion) return; 835 - const { id, type } = activeSuggestion; 836 - const markType = type === 'insert' ? 'suggestionInsert' : 'suggestionDelete'; 837 - const markSchema = editor.schema.marks[markType]; 838 - const { doc } = editor.state; 839 - 840 - // Find all positions with this suggestion ID 841 - const positions = []; 842 - doc.descendants((node, pos) => { 843 - if (node.isText) { 844 - node.marks.forEach((mark) => { 845 - if (mark.type === markSchema && mark.attrs.suggestionId === id) { 846 - positions.push({ from: pos, to: pos + node.nodeSize }); 847 - } 848 - }); 849 - } 850 - }); 851 - 852 - if (action === 'accept') { 853 - if (type === 'insert') { 854 - // Accept insert: keep text, remove mark 855 - editor.chain().focus().command(({ tr }) => { 856 - positions.forEach(({ from, to }) => tr.removeMark(from, to, markSchema)); 857 - return true; 858 - }).run(); 859 - } else { 860 - // Accept delete: actually delete the text 861 - editor.chain().focus().command(({ tr }) => { 862 - // Delete from end to start to preserve positions 863 - const sorted = [...positions].sort((a, b) => b.from - a.from); 864 - sorted.forEach(({ from, to }) => tr.delete(from, to)); 865 - return true; 866 - }).run(); 867 - } 868 - } else { 869 - // Reject 870 - if (type === 'insert') { 871 - // Reject insert: delete the inserted text 872 - editor.chain().focus().command(({ tr }) => { 873 - const sorted = [...positions].sort((a, b) => b.from - a.from); 874 - sorted.forEach(({ from, to }) => tr.delete(from, to)); 875 - return true; 876 - }).run(); 877 - } else { 878 - // Reject delete: keep text, remove mark 879 - editor.chain().focus().command(({ tr }) => { 880 - positions.forEach(({ from, to }) => tr.removeMark(from, to, markSchema)); 881 - return true; 882 - }).run(); 883 - } 884 - } 885 - 886 - suggestionPopover.style.display = 'none'; 887 - activeSuggestion = null; 888 - } 889 - 890 - $('suggestion-accept').addEventListener('click', () => handleSuggestionAction('accept')); 891 - $('suggestion-reject').addEventListener('click', () => handleSuggestionAction('reject')); 892 - 893 - function handleBulkSuggestionAction(action: 'accept' | 'reject'): void { 894 - const insertMark = editor.schema.marks.suggestionInsert; 895 - const deleteMark = editor.schema.marks.suggestionDelete; 896 - const { doc } = editor.state; 897 - 898 - // Collect all suggestion ranges grouped by type 899 - const inserts: { from: number; to: number }[] = []; 900 - const deletes: { from: number; to: number }[] = []; 901 - 902 - doc.descendants((node, pos) => { 903 - if (node.isText) { 904 - for (const mark of node.marks) { 905 - if (mark.type === insertMark) { 906 - inserts.push({ from: pos, to: pos + node.nodeSize }); 907 - } else if (mark.type === deleteMark) { 908 - deletes.push({ from: pos, to: pos + node.nodeSize }); 909 - } 910 - } 911 - } 912 - }); 913 - 914 - if (inserts.length === 0 && deletes.length === 0) return; 915 - 916 - editor.chain().focus().command(({ tr }) => { 917 - if (action === 'accept') { 918 - // Accept inserts: keep text, remove marks 919 - inserts.forEach(({ from, to }) => tr.removeMark(from, to, insertMark)); 920 - // Accept deletes: delete the text (from end to start) 921 - deletes.sort((a, b) => b.from - a.from).forEach(({ from, to }) => tr.delete(from, to)); 922 - } else { 923 - // Reject deletes: keep text, remove marks 924 - deletes.forEach(({ from, to }) => tr.removeMark(from, to, deleteMark)); 925 - // Reject inserts: delete the text (from end to start) 926 - inserts.sort((a, b) => b.from - a.from).forEach(({ from, to }) => tr.delete(from, to)); 927 - } 928 - return true; 929 - }).run(); 930 - 931 - suggestionPopover.style.display = 'none'; 932 - activeSuggestion = null; 933 - } 934 - 935 - $('suggestion-accept-all').addEventListener('click', () => handleBulkSuggestionAction('accept')); 936 - $('suggestion-reject-all').addEventListener('click', () => handleBulkSuggestionAction('reject')); 937 - 938 - // In suggesting mode, wrap insertions/deletions with suggestion marks 939 - // We use a ProseMirror plugin-like approach via editor transaction filtering 940 - const originalDispatch = editor.view.dispatch.bind(editor.view); 941 - editor.view.dispatch = (tr) => { 942 - if (!suggestionMgr.isSuggesting() || !tr.docChanged || tr.getMeta('suggestion') || tr.getMeta('paste')) { 943 - originalDispatch(tr); 944 - return; 945 - } 946 - 947 - // Build suggestion-wrapped transaction 948 - const suggestTr = editor.view.state.tr; 949 - suggestTr.setMeta('suggestion', true); 950 - 951 - // Use session-aware attrs so consecutive keystrokes share one suggestion ID 952 - const cursorPos = editor.state.selection.from; 953 - const attrs = suggestionMgr.getSessionAttrs({ type: 'insert', author: userName, cursorPos }); 954 - const insertMark = editor.schema.marks.suggestionInsert.create(attrs); 955 - const deleteAttrs = suggestionMgr.getSessionAttrs({ type: 'delete', author: userName, cursorPos }); 956 - const deleteMark = editor.schema.marks.suggestionDelete.create(deleteAttrs); 957 - 958 - let hasSuggestions = false; 959 - 960 - tr.steps.forEach((step, i) => { 961 - const map = tr.mapping.maps[i]; 962 - if (!map) return; 963 - 964 - map.forEach((oldStart, oldEnd, newStart, newEnd) => { 965 - // Deletion: mark old content as deleted instead of removing 966 - if (oldEnd > oldStart && newEnd === newStart) { 967 - // This is a pure deletion — add delete mark to the range 968 - suggestTr.addMark(oldStart, oldEnd, deleteMark); 969 - hasSuggestions = true; 970 - } 971 - // Insertion: add the text with insert mark 972 - else if (newEnd > newStart) { 973 - // Apply the original step first, then mark 974 - // For simplicity, let the original transaction through but mark new content 975 - } 976 - }); 977 - }); 978 - 979 - if (hasSuggestions) { 980 - originalDispatch(suggestTr); 981 - // Update session cursor to end of the deletion range 982 - suggestionMgr.updateSessionCursor(cursorPos); 983 - } else { 984 - // For insertions, apply original and then mark the inserted range 985 - originalDispatch(tr); 986 - // Mark newly inserted content 987 - const newTr = editor.view.state.tr; 988 - let marked = false; 989 - let lastNewEnd = cursorPos; 990 - tr.steps.forEach((step, i) => { 991 - const map = tr.mapping.maps[i]; 992 - if (!map) return; 993 - map.forEach((oldStart, oldEnd, newStart, newEnd) => { 994 - if (newEnd > newStart && newEnd <= editor.view.state.doc.content.size) { 995 - newTr.addMark(newStart, newEnd, insertMark); 996 - marked = true; 997 - lastNewEnd = newEnd; 998 - } 999 - }); 1000 - }); 1001 - if (marked) { 1002 - newTr.setMeta('suggestion', true); 1003 - originalDispatch(newTr); 1004 - // Update session cursor to end of inserted content 1005 - suggestionMgr.updateSessionCursor(lastNewEnd); 1006 - } 1007 - } 1008 - }; 1009 - 1010 - // ============================================= 1011 594 // --- Offline Support --- 1012 - // ============================================= 1013 595 const offlineMgr = new OfflineManager(); 1014 596 1015 - // Listen for browser online/offline events 1016 597 window.addEventListener('online', () => { 1017 598 offlineMgr.setOnline(true); 1018 599 updateOfflineUI(); ··· 1023 604 updateOfflineUI(); 1024 605 }); 1025 606 1026 - // Sync offline state with provider connection status 1027 607 provider.on('status', ({ connected }) => { 1028 608 if (!connected && !navigator.onLine) { 1029 609 offlineMgr.setOnline(false); ··· 1035 615 }); 1036 616 1037 617 function updateOfflineUI() { 1038 - const text = offlineMgr.getStatusText(); 1039 618 if (!offlineMgr.isOnline()) { 1040 619 statusDot.classList.remove('connected'); 1041 620 statusText.textContent = 'Offline'; 1042 621 } 1043 622 } 1044 623 1045 - // ============================================= 1046 - // --- Document Outline Sidebar --- 1047 - // ============================================= 1048 - const outlineState = new OutlineState(); 1049 - const outlineSidebar = $('outline-sidebar'); 1050 - const outlineList = $('outline-list'); 1051 - 1052 - function renderOutline() { 1053 - const json = editor.getJSON(); 1054 - const headings = extractOutlineHeadings(json); 1055 - outlineState.updateHeadings(headings); 624 + // --- Outline Sidebar (extracted) --- 625 + wireOutlineSidebar({ editor, $ }); 1056 626 1057 - if (headings.length === 0) { 1058 - outlineList.innerHTML = '<div class="outline-empty">No headings</div>'; 1059 - return; 1060 - } 627 + // --- Table Toolbar (extracted) --- 628 + wireTableToolbar({ editor, $ }); 1061 629 1062 - outlineList.innerHTML = ''; 1063 - for (const heading of headings) { 1064 - const item = document.createElement('button'); 1065 - item.className = 'outline-item'; 1066 - item.setAttribute('data-level', heading.level); 1067 - item.setAttribute('data-id', heading.id); 1068 - item.textContent = heading.text || '(empty heading)'; 1069 - item.title = heading.text || '(empty heading)'; 1070 - item.addEventListener('click', () => scrollToHeading(heading)); 1071 - outlineList.appendChild(item); 1072 - } 1073 - } 630 + // --- Link Preview (extracted) --- 631 + wireLinkPreview({ editor, $ }); 1074 632 1075 - function scrollToHeading(heading: { id: string; level: number; text: string }): void { 1076 - // Find the heading node in the editor and scroll to it 1077 - const { doc } = editor.state; 1078 - let targetPos = null; 1079 - let headingIndex = 0; 1080 - 1081 - doc.descendants((node, pos) => { 1082 - if (node.type.name === 'heading' && node.attrs.level <= 3) { 1083 - const currentHeadings = extractOutlineHeadings(editor.getJSON()); 1084 - if (currentHeadings[headingIndex] && currentHeadings[headingIndex].id === heading.id) { 1085 - targetPos = pos; 1086 - return false; 1087 - } 1088 - headingIndex++; 1089 - } 1090 - }); 1091 - 1092 - if (targetPos !== null) { 1093 - editor.chain().focus().setTextSelection(targetPos + 1).run(); 1094 - // Scroll the heading into view 1095 - const domNode = editor.view.domAtPos(targetPos + 1); 1096 - if (domNode?.node) { 1097 - const el = domNode.node.nodeType === 1 ? domNode.node : domNode.node.parentElement; 1098 - if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); 1099 - } 1100 - } 1101 - } 1102 - 1103 - function toggleOutline() { 1104 - outlineState.toggle(); 1105 - outlineSidebar.style.display = outlineState.isOpen ? '' : 'none'; 1106 - $('btn-outline').classList.toggle('active', outlineState.isOpen); 1107 - if (outlineState.isOpen) renderOutline(); 1108 - } 1109 - 1110 - $('btn-outline').addEventListener('click', toggleOutline); 1111 - $('outline-sidebar-close').addEventListener('click', () => { 1112 - outlineState.close(); 1113 - outlineSidebar.style.display = 'none'; 1114 - $('btn-outline').classList.remove('active'); 1115 - }); 1116 - 1117 - // Update outline on editor changes 1118 - editor.on('update', () => { 1119 - if (outlineState.isOpen) renderOutline(); 1120 - }); 1121 - 1122 - // ============================================= 1123 - // --- Table Toolbar (Floating) --- 1124 - // ============================================= 1125 - const tableToolbarState = new TableToolbarState(); 1126 - const tableToolbar = $('table-toolbar'); 1127 - 1128 - function updateTableToolbar() { 1129 - if (!editor.isActive('table')) { 1130 - if (tableToolbarState.visible) { 1131 - tableToolbarState.hide(); 1132 - tableToolbar.style.display = 'none'; 1133 - } 1134 - return; 1135 - } 1136 - 1137 - // Find the table DOM element 1138 - const { state } = editor; 1139 - const { $from } = state.selection; 1140 - let tableNode = null; 1141 - for (let d = $from.depth; d > 0; d--) { 1142 - if ($from.node(d).type.name === 'table') { 1143 - const domNode = editor.view.nodeDOM($from.before(d)); 1144 - tableNode = domNode; 1145 - break; 1146 - } 1147 - } 1148 - 1149 - if (tableNode) { 1150 - const rect = tableNode.getBoundingClientRect(); 1151 - const pos = { 1152 - top: rect.top - 40, 1153 - left: rect.left, 1154 - }; 1155 - tableToolbarState.show(pos); 1156 - tableToolbar.style.display = ''; 1157 - tableToolbar.style.top = `${Math.max(0, pos.top)}px`; 1158 - tableToolbar.style.left = `${pos.left}px`; 1159 - } 1160 - } 1161 - 1162 - // Wire up table toolbar buttons 1163 - tableToolbar.querySelectorAll('[data-cmd]').forEach(btn => { 1164 - btn.addEventListener('click', (e) => { 1165 - e.preventDefault(); 1166 - const cmd = btn.dataset.cmd; 1167 - if (editor.can()[cmd]?.()) { 1168 - editor.chain().focus()[cmd]().run(); 1169 - } else { 1170 - // Try running anyway (some commands don't have can() checks) 1171 - try { 1172 - editor.chain().focus()[cmd]().run(); 1173 - } catch {} 1174 - } 1175 - // Reposition after table structure change 1176 - requestAnimationFrame(updateTableToolbar); 1177 - }); 1178 - }); 1179 - 1180 - // Cell background color 1181 - const cellColorInput = $('table-cell-color'); 1182 - cellColorInput.addEventListener('input', (e) => { 1183 - editor.chain().focus().setCellAttribute('backgroundColor', e.target.value).run(); 1184 - }); 1185 - 1186 - editor.on('selectionUpdate', updateTableToolbar); 1187 - editor.on('transaction', () => { 1188 - requestAnimationFrame(updateTableToolbar); 1189 - }); 1190 - 1191 - // ============================================= 1192 - // --- Link Preview Tooltip --- 1193 - // ============================================= 1194 - const linkPreviewState = new LinkPreviewState(); 1195 - const linkPreview = $('link-preview'); 1196 - const linkPreviewUrl = $('link-preview-url'); 1197 - let linkPreviewTimeout: ReturnType<typeof setTimeout> | null = null; 1198 - 1199 - function showLinkPreview(linkEl: Element): void { 1200 - const href = linkEl.getAttribute('href'); 1201 - if (!href) return; 1202 - 1203 - const rect = linkEl.getBoundingClientRect(); 1204 - const pos = computeTooltipPosition( 1205 - rect, 1206 - window.innerWidth, 1207 - window.innerHeight, 1208 - 280 1209 - ); 1210 - 1211 - linkPreviewState.show({ href, position: pos }); 1212 - linkPreviewUrl.textContent = truncateUrl(href, 50); 1213 - linkPreviewUrl.title = href; 1214 - linkPreview.style.display = ''; 1215 - linkPreview.style.top = `${pos.top}px`; 1216 - linkPreview.style.left = `${pos.left}px`; 1217 - } 1218 - 1219 - function hideLinkPreview() { 1220 - linkPreviewState.hide(); 1221 - linkPreview.style.display = 'none'; 1222 - } 1223 - 1224 - // Mouse enter/leave on links in the editor 1225 - document.addEventListener('mouseover', (e) => { 1226 - const linkEl = e.target.closest('.tiptap a[href]'); 1227 - if (linkEl) { 1228 - clearTimeout(linkPreviewTimeout); 1229 - linkPreviewTimeout = setTimeout(() => showLinkPreview(linkEl), 200); 1230 - } 1231 - }); 1232 - 1233 - document.addEventListener('mouseout', (e) => { 1234 - const linkEl = e.target.closest('.tiptap a[href]'); 1235 - if (linkEl) { 1236 - clearTimeout(linkPreviewTimeout); 1237 - linkPreviewTimeout = setTimeout(() => { 1238 - // Only hide if mouse isn't over the tooltip itself 1239 - if (!linkPreview.matches(':hover')) { 1240 - hideLinkPreview(); 1241 - } 1242 - }, 300); 1243 - } 1244 - }); 1245 - 1246 - linkPreview.addEventListener('mouseleave', () => { 1247 - clearTimeout(linkPreviewTimeout); 1248 - hideLinkPreview(); 1249 - }); 1250 - 1251 - // Dismiss on Escape 1252 - document.addEventListener('keydown', (e) => { 1253 - if (e.key === 'Escape' && linkPreviewState.visible) { 1254 - hideLinkPreview(); 1255 - } 1256 - }); 1257 - 1258 - // Link preview actions 1259 - $('link-preview-open').addEventListener('click', () => { 1260 - if (linkPreviewState.href) { 1261 - window.open(linkPreviewState.href, '_blank', 'noopener'); 1262 - } 1263 - hideLinkPreview(); 1264 - }); 1265 - 1266 - $('link-preview-edit').addEventListener('click', () => { 1267 - const currentHref = linkPreviewState.href; 1268 - hideLinkPreview(); 1269 - const newHref = prompt('Edit URL:', currentHref || ''); 1270 - if (newHref !== null && newHref !== currentHref) { 1271 - editor.chain().focus().extendMarkRange('link').setLink({ href: newHref }).run(); 1272 - } 1273 - }); 1274 - 1275 - $('link-preview-remove').addEventListener('click', () => { 1276 - hideLinkPreview(); 1277 - editor.chain().focus().extendMarkRange('link').unsetLink().run(); 1278 - }); 1279 - 1280 - // ============================================= 1281 633 // --- Focus/Zen Mode --- 1282 - // ============================================= 1283 634 const zenState = ZenModeState.fromStored(localStorage.getItem(ZEN_STORAGE_KEY)); 1284 635 const appShell = $('app'); 1285 636 const zenExitBtn = $('zen-exit'); ··· 1300 651 applyZenMode(); 1301 652 } 1302 653 1303 - // Keyboard shortcut: Cmd+Shift+F 1304 654 document.addEventListener('keydown', (e) => { 1305 655 const mod = e.metaKey || e.ctrlKey; 1306 656 if (mod && e.shiftKey && e.key.toLowerCase() === 'f') { ··· 1314 664 applyZenMode(); 1315 665 }); 1316 666 1317 - // Apply on load if was previously active 1318 667 applyZenMode(); 1319 668 1320 - // Add zen mode shortcut to cheatsheet 1321 669 DOCS_SHORTCUTS.push({ 1322 670 category: 'View', 1323 671 shortcuts: [ ··· 1351 699 const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 1352 700 const results: PaletteAction[] = []; 1353 701 for (const doc of docs) { 1354 - if (doc.id === docId) continue; // skip current doc 702 + if (doc.id === docId) continue; 1355 703 const keyStr = keys[doc.id]; 1356 704 if (!keyStr) continue; 1357 705 const pathMap: Record<string, string> = { doc: '/docs', sheet: '/sheets', form: '/forms', slide: '/slides', diagram: '/diagrams' }; ··· 1378 726 }, 1379 727 }); 1380 728 1381 - // ── AI Chat Panel ──────────────────────────────────────────────────────── 1382 - 1383 - const chatUI = createChatSidebar(); 1384 - const mainContent = $('main-content'); 1385 - mainContent.appendChild(chatUI.container); 1386 - 1387 - const chatState = createChatState(); 1388 - 1389 - const chatWiring = initChatWiring({ 1390 - chatUI, 1391 - chatState, 1392 - chatConfig: loadConfig(), 1393 - toggleBtn: $('btn-ai-chat'), 1394 - editorType: 'doc', 1395 - onSend: sendMessage, 1396 - }); 1397 - 1398 - // Send message 1399 - async function sendMessage(): Promise<void> { 1400 - const text = chatUI.input.value.trim(); 1401 - if (!text || chatState.loading) return; 1402 - 1403 - const cfg = chatWiring.getConfig(); 1404 - if (!isConfigured(cfg)) { 1405 - chatUI.settingsPanel.style.display = ''; 1406 - chatUI.endpointInput.focus(); 1407 - return; 1408 - } 1409 - 1410 - // Add user message 1411 - const userMsg: ChatMessage = { role: 'user', content: text, ts: Date.now() }; 1412 - chatState.messages.push(userMsg); 1413 - appendMessage(chatUI.messageList, userMsg); 1414 - 1415 - chatUI.input.value = ''; 1416 - chatUI.input.style.height = ''; 1417 - chatUI.sendBtn.style.display = 'none'; 1418 - chatUI.stopBtn.style.display = ''; 1419 - chatState.loading = true; 1420 - chatState.error = null; 1421 - 1422 - // Build context 1423 - const docTitle = (titleInput as HTMLInputElement).value.trim() || 'Untitled'; 1424 - const includeContext = chatUI.contextToggle.checked; 1425 - const actionsEnabled = chatUI.actionsToggle.checked; 1426 - const docText = includeContext ? editor.getText() : ''; 1427 - const { from, to } = editor.state.selection; 1428 - const selectionText = from !== to ? editor.state.doc.textBetween(from, to) : ''; 1429 - const systemPrompt = buildSystemMessage(docTitle, docText, { 1430 - editorType: 'doc', 1431 - actionsEnabled, 1432 - selectionContext: selectionText || undefined, 1433 - }); 1434 - 1435 - // Streaming response 1436 - const abortController = new AbortController(); 1437 - chatState.abortController = abortController; 1438 - const bubble = appendStreamingBubble(chatUI.messageList); 1439 - let fullText = ''; 1440 - 1441 - await streamChat( 1442 - cfg, 1443 - chatState.messages, 1444 - systemPrompt, 1445 - { 1446 - onChunk(chunk) { 1447 - fullText += chunk; 1448 - bubble.update(renderMarkdown(fullText)); 1449 - }, 1450 - onDone(text) { 1451 - if (text) { 1452 - chatState.messages.push({ role: 'assistant', content: text, ts: Date.now() }); 1453 - 1454 - // Parse and render action cards 1455 - if (actionsEnabled) { 1456 - const { displayText, actions } = splitResponse(text); 1457 - if (actions.length > 0) { 1458 - bubble.update(renderMarkdown(displayText)); 1459 - for (const action of actions) { 1460 - if (!isDocAction(action)) continue; 1461 - appendActionCard(chatUI.messageList, action, { 1462 - onApply: (a) => { 1463 - const result = executeDocAction(editor, a as Parameters<typeof executeDocAction>[1]); 1464 - if (!result.success && result.error) { 1465 - appendMessage(chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 1466 - } 1467 - }, 1468 - onSuggest: (a) => { 1469 - // Convert to suggestion variant if it's a direct action 1470 - const suggestAction = a.type === 'doc_insert' 1471 - ? { ...a, type: 'doc_suggest_insert' as const } 1472 - : a.type === 'doc_replace' 1473 - ? { ...a, type: 'doc_suggest_replace' as const } 1474 - : a; 1475 - const result = executeDocAction(editor, suggestAction as Parameters<typeof executeDocAction>[1]); 1476 - if (!result.success && result.error) { 1477 - appendMessage(chatUI.messageList, { role: 'assistant', content: `Suggestion failed: ${result.error}`, ts: Date.now() }); 1478 - } 1479 - }, 1480 - onDismiss: () => {}, 1481 - }); 1482 - } 1483 - } 1484 - } 1485 - } 1486 - }, 1487 - onError(err) { 1488 - chatState.error = err; 1489 - bubble.el.classList.add('ai-chat-bubble--error'); 1490 - bubble.update(`<span class="ai-chat-error">${escapeHtml(err)}</span>`); 1491 - }, 1492 - }, 1493 - abortController.signal, 1494 - ); 1495 - 1496 - chatState.loading = false; 1497 - chatState.abortController = null; 1498 - chatUI.sendBtn.style.display = ''; 1499 - chatUI.stopBtn.style.display = 'none'; 1500 - } 1501 - 1502 - // ────────────────────────────────────────────────────────────────────── 1503 - // Wave 8: Threaded Comments Sidebar 1504 - // ────────────────────────────────────────────────────────────────────── 1505 - 1506 - const commentsSidebar = document.getElementById('comments-sidebar') as HTMLElement; 1507 - const commentsListEl = document.getElementById('comments-list') as HTMLElement; 1508 - const commentsSidebarClose = document.getElementById('comments-sidebar-close') as HTMLElement; 1509 - const commentNewInput = document.getElementById('comment-new-input') as HTMLInputElement; 1510 - const btnComments = document.getElementById('btn-comments') as HTMLElement; 729 + // --- AI Chat Panel (extracted) --- 730 + wireAiChat({ editor, titleInput, $ }); 1511 731 1512 - let commentThreads: CommentThread[] = []; 732 + // --- Comments Sidebar (extracted) --- 733 + const { loadCommentsFromYjs } = wireCommentsSidebar({ editor, ydoc, userName }); 1513 734 1514 - function renderCommentThreads() { 1515 - const sorted = sortThreads(commentThreads, 'unresolved'); 1516 - if (sorted.length === 0) { 1517 - commentsListEl.innerHTML = '<div style="padding:var(--space-sm);color:var(--color-text-faint);font-size:0.8rem;">No comments yet. Select text and click the comment button to start a thread.</div>'; 1518 - return; 1519 - } 1520 - commentsListEl.innerHTML = sorted.map(t => { 1521 - const allComments = [t.root, ...t.replies]; 1522 - return `<div class="comment-thread ${t.resolved ? 'resolved' : ''}" data-thread-id="${t.id}"> 1523 - ${allComments.map(c => `<div> 1524 - <span class="comment-author">${escapeHtml(c.author)}</span> 1525 - <span class="comment-time">${new Date(c.createdAt).toLocaleString()}</span> 1526 - <div class="comment-body">${escapeHtml(c.text)}</div> 1527 - </div>`).join('')} 1528 - <div class="comment-actions"> 1529 - ${t.resolved 1530 - ? `<button data-action="reopen" data-id="${t.id}">Reopen</button>` 1531 - : `<button data-action="resolve" data-id="${t.id}">Resolve</button>`} 1532 - <button data-action="reply" data-id="${t.id}">Reply</button> 1533 - </div> 1534 - </div>`; 1535 - }).join(''); 735 + // --- Follow Mode (extracted) --- 736 + wireFollowMode({ editor }); 1536 737 1537 - commentsListEl.querySelectorAll('[data-action]').forEach(btn => { 1538 - btn.addEventListener('click', () => { 1539 - const action = btn.getAttribute('data-action'); 1540 - const threadId = btn.getAttribute('data-id')!; 1541 - const idx = commentThreads.findIndex(t => t.id === threadId); 1542 - if (idx === -1) return; 738 + // --- Typewriter / Focus Mode (extracted) --- 739 + wireTypewriterMode({ editor }); 1543 740 1544 - if (action === 'resolve') { 1545 - commentThreads[idx] = resolveThread(commentThreads[idx], userName); 1546 - renderCommentThreads(); 1547 - syncCommentsToYjs(); 1548 - } else if (action === 'reopen') { 1549 - commentThreads[idx] = reopenThread(commentThreads[idx]); 1550 - renderCommentThreads(); 1551 - syncCommentsToYjs(); 1552 - } else if (action === 'reply') { 1553 - const text = prompt('Reply:'); 1554 - if (text) { 1555 - commentThreads[idx] = addReply(commentThreads[idx], userName, text); 1556 - renderCommentThreads(); 1557 - syncCommentsToYjs(); 1558 - } 1559 - } 1560 - }); 1561 - }); 1562 - } 1563 - 1564 - function syncCommentsToYjs() { 1565 - ydoc.getMap('meta').set('commentThreads', JSON.stringify(commentThreads)); 1566 - } 1567 - 1568 - function loadCommentsFromYjs() { 1569 - const raw = ydoc.getMap('meta').get('commentThreads') as string | undefined; 1570 - if (raw) { 1571 - try { commentThreads = JSON.parse(raw); } catch { commentThreads = []; } 1572 - } 1573 - renderCommentThreads(); 1574 - } 1575 - 1576 - // Sync comments on yjs changes 1577 - ydoc.getMap('meta').observe(() => { loadCommentsFromYjs(); }); 1578 - 1579 - btnComments.addEventListener('click', () => { 1580 - const showing = commentsSidebar.style.display !== 'none'; 1581 - commentsSidebar.style.display = showing ? 'none' : ''; 1582 - if (!showing) renderCommentThreads(); 1583 - }); 1584 - 1585 - commentsSidebarClose.addEventListener('click', () => { 1586 - commentsSidebar.style.display = 'none'; 1587 - }); 1588 - 1589 - // Add comment from sidebar input on Enter (requires text selection in editor) 1590 - commentNewInput.addEventListener('keydown', (e) => { 1591 - if (e.key !== 'Enter') return; 1592 - const text = commentNewInput.value.trim(); 1593 - if (!text) return; 1594 - 1595 - const { from, to } = editor.state.selection; 1596 - if (from === to) { 1597 - commentNewInput.placeholder = 'Select text first...'; 1598 - return; 1599 - } 1600 - 1601 - const anchorId = generateCommentId(); 1602 - editor.chain().focus().setComment({ 1603 - commentId: anchorId, 1604 - author: userName, 1605 - timestamp: new Date().toISOString(), 1606 - text, 1607 - }).run(); 1608 - 1609 - const thread = createThread(anchorId, userName, text); 1610 - commentThreads.push(thread); 1611 - renderCommentThreads(); 1612 - syncCommentsToYjs(); 1613 - commentNewInput.value = ''; 1614 - }); 1615 - 1616 - // ────────────────────────────────────────────────────────────────────── 1617 - // Wave 8: Follow Mode 1618 - // ────────────────────────────────────────────────────────────────────── 1619 - 1620 - const followBanner = document.getElementById('follow-banner') as HTMLElement; 1621 - const followLabel = document.getElementById('follow-label') as HTMLElement; 1622 - const followStop = document.getElementById('follow-stop') as HTMLElement; 1623 - 1624 - let followState = createFollowState(); 1625 - let remoteCursors: CursorPosition[] = []; 1626 - let isFollowScroll = false; 1627 - const editorContainer = document.querySelector('.editor-container'); 1628 - 1629 - followStop.addEventListener('click', () => { 1630 - followState = stopFollowing(followState); 1631 - followBanner.style.display = 'none'; 1632 - }); 1633 - 1634 - // Listen for manual scroll to auto-unfollow 1635 - if (editorContainer) { 1636 - editorContainer.addEventListener('scroll', () => { 1637 - if (isFollowScroll) { isFollowScroll = false; return; } 1638 - followState = handleLocalScroll(followState, true); 1639 - if (!followState.active) followBanner.style.display = 'none'; 1640 - }, { passive: true }); 1641 - } 1642 - 1643 - // Follow a collaborator: triggered by clicking their avatar in the topbar 1644 - document.getElementById('collab-avatars')?.addEventListener('click', (e) => { 1645 - const avatarEl = (e.target as HTMLElement).closest('[data-user-id]'); 1646 - if (!avatarEl) return; 1647 - const userId = avatarEl.getAttribute('data-user-id')!; 1648 - const displayName = avatarEl.getAttribute('title') || userId; 1649 - 1650 - followState = startFollowing(followState, userId); 1651 - followLabel.textContent = `Following ${displayName}`; 1652 - followBanner.style.display = ''; 1653 - }); 1654 - 1655 - // Process remote cursor updates for follow mode 1656 - function processFollowUpdate(cursor: CursorPosition) { 1657 - if (!shouldScrollToFollow(followState, cursor, Date.now())) return; 1658 - if (!editorContainer) return; 1659 - 1660 - const scrollTarget = computeFollowScroll(cursor.scrollTop, editorContainer.clientHeight); 1661 - isFollowScroll = true; 1662 - editorContainer.scrollTo({ top: scrollTarget, behavior: 'smooth' }); 1663 - } 1664 - 1665 - // ────────────────────────────────────────────────────────────────────── 1666 - // Wave 10: Typewriter / Focus Mode 1667 - // ────────────────────────────────────────────────────────────────────── 1668 - 1669 - const btnTypewriter = document.getElementById('btn-typewriter') as HTMLElement; 1670 - let typewriterActive = false; 1671 - 1672 - btnTypewriter.addEventListener('click', () => { 1673 - typewriterActive = !typewriterActive; 1674 - document.body.classList.toggle('typewriter-mode', typewriterActive); 1675 - btnTypewriter.classList.toggle('active', typewriterActive); 1676 - 1677 - if (typewriterActive) { 1678 - updateTypewriterFocus(); 1679 - } 1680 - }); 1681 - 1682 - function updateTypewriterFocus() { 1683 - if (!typewriterActive) return; 1684 - const prosemirror = editor.view.dom; 1685 - // Remove previous active marks 1686 - prosemirror.querySelectorAll('.is-active-node').forEach(el => el.classList.remove('is-active-node')); 1687 - 1688 - // Find the block containing the cursor 1689 - const { $anchor } = editor.state.selection; 1690 - const resolvedPos = editor.view.domAtPos($anchor.pos); 1691 - let node = resolvedPos.node; 1692 - if (node.nodeType === Node.TEXT_NODE) node = node.parentElement!; 1693 - const block = (node as HTMLElement).closest('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre, .task-item') as HTMLElement | null; 1694 - if (block) { 1695 - block.classList.add('is-active-node'); 1696 - block.scrollIntoView({ behavior: 'smooth', block: 'center' }); 1697 - } 1698 - } 1699 - 1700 - editor.on('selectionUpdate', updateTypewriterFocus); 1701 - 1702 - // Initial load of comments from yjs 741 + // --- Initial load of comments from yjs --- 1703 742 setTimeout(loadCommentsFromYjs, 500); 1704 743 1705 - // Styled tooltips 744 + // --- Styled tooltips --- 1706 745 setupTooltips();
+309
src/docs/sidebar-wiring.ts
··· 1 + /** 2 + * Sidebar Wiring — outline sidebar, table toolbar, link preview tooltip, 3 + * and minimap rendering/interaction. 4 + * 5 + * Extracted from main.ts for decomposition. 6 + */ 7 + 8 + import type { Editor } from '@tiptap/core'; 9 + import { extractHeadings, computeViewportIndicator } from './minimap.js'; 10 + import { extractHeadings as extractOutlineHeadings, OutlineState } from './outline.js'; 11 + import { TableToolbarState } from './table-toolbar.js'; 12 + import { LinkPreviewState, truncateUrl, computeTooltipPosition } from './link-preview.js'; 13 + 14 + // ── Types ─────────────────────────────────────────────────── 15 + 16 + export interface SidebarWiringDeps { 17 + editor: Editor; 18 + $: (id: string) => HTMLElement; 19 + } 20 + 21 + // ── Outline Sidebar ───────────────────────────────────────── 22 + 23 + export function wireOutlineSidebar(deps: SidebarWiringDeps): void { 24 + const { editor, $ } = deps; 25 + const outlineState = new OutlineState(); 26 + const outlineSidebar = $('outline-sidebar'); 27 + const outlineList = $('outline-list'); 28 + 29 + function renderOutline(): void { 30 + const json = editor.getJSON(); 31 + const headings = extractOutlineHeadings(json); 32 + outlineState.updateHeadings(headings); 33 + 34 + if (headings.length === 0) { 35 + outlineList.innerHTML = '<div class="outline-empty">No headings</div>'; 36 + return; 37 + } 38 + 39 + outlineList.innerHTML = ''; 40 + for (const heading of headings) { 41 + const item = document.createElement('button'); 42 + item.className = 'outline-item'; 43 + item.setAttribute('data-level', String(heading.level)); 44 + item.setAttribute('data-id', heading.id); 45 + item.textContent = heading.text || '(empty heading)'; 46 + item.title = heading.text || '(empty heading)'; 47 + item.addEventListener('click', () => scrollToHeading(heading)); 48 + outlineList.appendChild(item); 49 + } 50 + } 51 + 52 + function scrollToHeading(heading: { id: string; level: number; text: string }): void { 53 + const { doc } = editor.state; 54 + let targetPos: number | null = null; 55 + let headingIndex = 0; 56 + 57 + doc.descendants((node, pos) => { 58 + if (node.type.name === 'heading' && node.attrs.level <= 3) { 59 + const currentHeadings = extractOutlineHeadings(editor.getJSON()); 60 + if (currentHeadings[headingIndex] && currentHeadings[headingIndex].id === heading.id) { 61 + targetPos = pos; 62 + return false; 63 + } 64 + headingIndex++; 65 + } 66 + }); 67 + 68 + if (targetPos !== null) { 69 + editor.chain().focus().setTextSelection(targetPos + 1).run(); 70 + const domNode = editor.view.domAtPos(targetPos + 1); 71 + if (domNode?.node) { 72 + const el = domNode.node.nodeType === 1 ? domNode.node : domNode.node.parentElement; 73 + if (el) (el as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'center' }); 74 + } 75 + } 76 + } 77 + 78 + function toggleOutline(): void { 79 + outlineState.toggle(); 80 + outlineSidebar.style.display = outlineState.isOpen ? '' : 'none'; 81 + $('btn-outline').classList.toggle('active', outlineState.isOpen); 82 + if (outlineState.isOpen) renderOutline(); 83 + } 84 + 85 + $('btn-outline').addEventListener('click', toggleOutline); 86 + $('outline-sidebar-close').addEventListener('click', () => { 87 + outlineState.close(); 88 + outlineSidebar.style.display = 'none'; 89 + $('btn-outline').classList.remove('active'); 90 + }); 91 + 92 + // Update outline on editor changes 93 + editor.on('update', () => { 94 + if (outlineState.isOpen) renderOutline(); 95 + }); 96 + } 97 + 98 + // ── Table Toolbar (Floating) ──────────────────────────────── 99 + 100 + export function wireTableToolbar(deps: SidebarWiringDeps): void { 101 + const { editor, $ } = deps; 102 + const tableToolbarState = new TableToolbarState(); 103 + const tableToolbar = $('table-toolbar'); 104 + 105 + function updateTableToolbar(): void { 106 + if (!editor.isActive('table')) { 107 + if (tableToolbarState.visible) { 108 + tableToolbarState.hide(); 109 + tableToolbar.style.display = 'none'; 110 + } 111 + return; 112 + } 113 + 114 + // Find the table DOM element 115 + const { state } = editor; 116 + const { $from } = state.selection; 117 + let tableNode: any = null; 118 + for (let d = $from.depth; d > 0; d--) { 119 + if ($from.node(d).type.name === 'table') { 120 + const domNode = editor.view.nodeDOM($from.before(d)); 121 + tableNode = domNode; 122 + break; 123 + } 124 + } 125 + 126 + if (tableNode) { 127 + const rect = tableNode.getBoundingClientRect(); 128 + const pos = { 129 + top: rect.top - 40, 130 + left: rect.left, 131 + }; 132 + tableToolbarState.show(pos); 133 + tableToolbar.style.display = ''; 134 + tableToolbar.style.top = `${Math.max(0, pos.top)}px`; 135 + tableToolbar.style.left = `${pos.left}px`; 136 + } 137 + } 138 + 139 + // Wire up table toolbar buttons 140 + tableToolbar.querySelectorAll('[data-cmd]').forEach(btn => { 141 + btn.addEventListener('click', (e) => { 142 + e.preventDefault(); 143 + const cmd = (btn as HTMLElement).dataset.cmd!; 144 + if ((editor.can() as any)[cmd]?.()) { 145 + (editor.chain().focus() as any)[cmd]().run(); 146 + } else { 147 + try { 148 + (editor.chain().focus() as any)[cmd]().run(); 149 + } catch {} 150 + } 151 + requestAnimationFrame(updateTableToolbar); 152 + }); 153 + }); 154 + 155 + // Cell background color 156 + const cellColorInput = $('table-cell-color'); 157 + cellColorInput.addEventListener('input', (e) => { 158 + editor.chain().focus().setCellAttribute('backgroundColor', (e.target as HTMLInputElement).value).run(); 159 + }); 160 + 161 + editor.on('selectionUpdate', updateTableToolbar); 162 + editor.on('transaction', () => { 163 + requestAnimationFrame(updateTableToolbar); 164 + }); 165 + } 166 + 167 + // ── Link Preview Tooltip ──────────────────────────────────── 168 + 169 + export function wireLinkPreview(deps: SidebarWiringDeps): void { 170 + const { editor, $ } = deps; 171 + const linkPreviewState = new LinkPreviewState(); 172 + const linkPreview = $('link-preview'); 173 + const linkPreviewUrl = $('link-preview-url'); 174 + let linkPreviewTimeout: ReturnType<typeof setTimeout> | null = null; 175 + 176 + function showLinkPreview(linkEl: Element): void { 177 + const href = linkEl.getAttribute('href'); 178 + if (!href) return; 179 + 180 + const rect = linkEl.getBoundingClientRect(); 181 + const pos = computeTooltipPosition( 182 + rect, 183 + window.innerWidth, 184 + window.innerHeight, 185 + 280 186 + ); 187 + 188 + linkPreviewState.show({ href, position: pos }); 189 + linkPreviewUrl.textContent = truncateUrl(href, 50); 190 + linkPreviewUrl.title = href; 191 + linkPreview.style.display = ''; 192 + linkPreview.style.top = `${pos.top}px`; 193 + linkPreview.style.left = `${pos.left}px`; 194 + } 195 + 196 + function hideLinkPreview(): void { 197 + linkPreviewState.hide(); 198 + linkPreview.style.display = 'none'; 199 + } 200 + 201 + // Mouse enter/leave on links in the editor 202 + document.addEventListener('mouseover', (e) => { 203 + const linkEl = (e.target as HTMLElement).closest('.tiptap a[href]'); 204 + if (linkEl) { 205 + clearTimeout(linkPreviewTimeout!); 206 + linkPreviewTimeout = setTimeout(() => showLinkPreview(linkEl), 200); 207 + } 208 + }); 209 + 210 + document.addEventListener('mouseout', (e) => { 211 + const linkEl = (e.target as HTMLElement).closest('.tiptap a[href]'); 212 + if (linkEl) { 213 + clearTimeout(linkPreviewTimeout!); 214 + linkPreviewTimeout = setTimeout(() => { 215 + if (!linkPreview.matches(':hover')) { 216 + hideLinkPreview(); 217 + } 218 + }, 300); 219 + } 220 + }); 221 + 222 + linkPreview.addEventListener('mouseleave', () => { 223 + clearTimeout(linkPreviewTimeout!); 224 + hideLinkPreview(); 225 + }); 226 + 227 + // Dismiss on Escape 228 + document.addEventListener('keydown', (e) => { 229 + if (e.key === 'Escape' && linkPreviewState.visible) { 230 + hideLinkPreview(); 231 + } 232 + }); 233 + 234 + // Link preview actions 235 + $('link-preview-open').addEventListener('click', () => { 236 + if (linkPreviewState.href) { 237 + window.open(linkPreviewState.href, '_blank', 'noopener'); 238 + } 239 + hideLinkPreview(); 240 + }); 241 + 242 + $('link-preview-edit').addEventListener('click', () => { 243 + const currentHref = linkPreviewState.href; 244 + hideLinkPreview(); 245 + const newHref = prompt('Edit URL:', currentHref || ''); 246 + if (newHref !== null && newHref !== currentHref) { 247 + editor.chain().focus().extendMarkRange('link').setLink({ href: newHref }).run(); 248 + } 249 + }); 250 + 251 + $('link-preview-remove').addEventListener('click', () => { 252 + hideLinkPreview(); 253 + editor.chain().focus().extendMarkRange('link').unsetLink().run(); 254 + }); 255 + } 256 + 257 + // ── Minimap ───────────────────────────────────────────────── 258 + 259 + export function wireMinimap(deps: { editor: Editor }): void { 260 + const { editor } = deps; 261 + const minimapEl = document.getElementById('minimap') as HTMLElement; 262 + const minimapViewport = document.getElementById('minimap-viewport') as HTMLElement; 263 + const minimapHeadingsEl = document.getElementById('minimap-headings') as HTMLElement; 264 + 265 + function updateMinimap(): void { 266 + const html = editor.getHTML(); 267 + const headings = extractHeadings(html); 268 + 269 + if (headings.length < 2) { 270 + minimapEl.style.display = 'none'; 271 + return; 272 + } 273 + 274 + minimapEl.style.display = ''; 275 + minimapHeadingsEl.innerHTML = headings.map(h => 276 + `<div class="minimap-heading" data-level="${h.level}" title="${h.text}">${h.text}</div>` 277 + ).join(''); 278 + 279 + // Click to scroll to heading 280 + minimapHeadingsEl.querySelectorAll('.minimap-heading').forEach((el, i) => { 281 + el.addEventListener('click', () => { 282 + const headingEls = editor.view.dom.querySelectorAll('h1, h2, h3, h4, h5, h6'); 283 + if (headingEls[i]) { 284 + headingEls[i].scrollIntoView({ behavior: 'smooth', block: 'start' }); 285 + } 286 + }); 287 + }); 288 + 289 + updateMinimapViewport(); 290 + } 291 + 292 + function updateMinimapViewport(): void { 293 + const editorEl = editor.view.dom.closest('.editor-container') || editor.view.dom.parentElement; 294 + if (!editorEl) return; 295 + const scrollEl = editorEl.closest('.editor-container') || document.documentElement; 296 + const { top, height } = computeViewportIndicator( 297 + scrollEl.scrollTop, 298 + scrollEl.clientHeight, 299 + scrollEl.scrollHeight, 300 + ); 301 + minimapViewport.style.top = top + '%'; 302 + minimapViewport.style.height = height + '%'; 303 + } 304 + 305 + editor.on('update', updateMinimap); 306 + document.addEventListener('scroll', updateMinimapViewport, { passive: true }); 307 + // Delayed initial render 308 + setTimeout(updateMinimap, 500); 309 + }
+130
src/docs/slash-menu-ui.ts
··· 1 + /** 2 + * Slash Menu UI — DOM rendering, positioning, selection tracking, 3 + * and event wiring for the slash command popup. 4 + * 5 + * Extracted from main.ts for decomposition. 6 + */ 7 + 8 + import { SLASH_COMMAND_ITEMS, SlashMenuState } from './slash-menu.js'; 9 + import { getCommandExecutor } from './extensions/slash-commands.js'; 10 + 11 + // ── Types ─────────────────────────────────────────────────── 12 + 13 + export interface SlashMenuUIResult { 14 + slashMenuState: SlashMenuState; 15 + renderSlashMenu: (props: SlashMenuRenderProps) => void; 16 + hideSlashMenu: () => void; 17 + updateSlashMenuSelection: () => void; 18 + getCommandRef: () => SlashMenuCommandRef | null; 19 + setCommandRef: (ref: SlashMenuCommandRef | null) => void; 20 + } 21 + 22 + export type SlashMenuCommandRef = (item: Record<string, unknown>) => void; 23 + 24 + export interface SlashMenuRenderProps { 25 + command: (item: Record<string, unknown>) => void; 26 + query?: string; 27 + clientRect?: (() => DOMRect | null) | null; 28 + } 29 + 30 + // ── Slash Menu UI ─────────────────────────────────────────── 31 + 32 + export function createSlashMenuUI(): SlashMenuUIResult { 33 + const slashMenuState = new SlashMenuState(); 34 + let slashMenuCommandRef: SlashMenuCommandRef | null = null; 35 + 36 + // Create the slash menu popup element 37 + const slashMenuEl = document.createElement('div'); 38 + slashMenuEl.className = 'slash-menu'; 39 + slashMenuEl.id = 'slash-menu'; 40 + slashMenuEl.style.display = 'none'; 41 + document.body.appendChild(slashMenuEl); 42 + 43 + function positionSlashMenu(props: { clientRect?: (() => DOMRect | null) | null }): void { 44 + if (!props.clientRect) return; 45 + const rect = props.clientRect(); 46 + if (!rect) return; 47 + slashMenuEl.style.left = `${rect.left}px`; 48 + slashMenuEl.style.top = `${rect.bottom + 4}px`; 49 + } 50 + 51 + function updateSlashMenuSelection(): void { 52 + const items = slashMenuEl.querySelectorAll('.slash-menu-item'); 53 + items.forEach((el, idx) => { 54 + el.classList.toggle('slash-menu-item-selected', idx === slashMenuState.selectedIndex); 55 + }); 56 + // Scroll selected into view 57 + const selected = slashMenuEl.querySelector('.slash-menu-item-selected'); 58 + if (selected) { 59 + selected.scrollIntoView({ block: 'nearest' }); 60 + } 61 + } 62 + 63 + function renderSlashMenu(props: SlashMenuRenderProps): void { 64 + slashMenuCommandRef = props.command; 65 + const items = slashMenuState.getFilteredItems(); 66 + const grouped = slashMenuState.getGroupedItems(); 67 + 68 + if (items.length === 0) { 69 + slashMenuEl.innerHTML = '<div class="slash-menu-empty">No results</div>'; 70 + slashMenuEl.style.display = 'block'; 71 + positionSlashMenu(props); 72 + return; 73 + } 74 + 75 + let html = ''; 76 + let flatIdx = 0; 77 + for (const group of grouped) { 78 + html += `<div class="slash-menu-category">${group.label}</div>`; 79 + for (const item of group.items) { 80 + const selected = flatIdx === slashMenuState.selectedIndex ? ' slash-menu-item-selected' : ''; 81 + html += `<button class="slash-menu-item${selected}" data-command-id="${item.id}" data-index="${flatIdx}">`; 82 + html += `<span class="slash-menu-item-icon">${item.icon}</span>`; 83 + html += `<span class="slash-menu-item-body">`; 84 + html += `<span class="slash-menu-item-name">${item.name}</span>`; 85 + html += `<span class="slash-menu-item-desc">${item.description}</span>`; 86 + html += `</span>`; 87 + if (item.shortcut) { 88 + html += `<span class="slash-menu-item-shortcut">${item.shortcut}</span>`; 89 + } 90 + html += `</button>`; 91 + flatIdx++; 92 + } 93 + } 94 + slashMenuEl.innerHTML = html; 95 + slashMenuEl.style.display = 'block'; 96 + positionSlashMenu(props); 97 + 98 + // Click handler for items 99 + slashMenuEl.querySelectorAll('.slash-menu-item').forEach(btn => { 100 + btn.addEventListener('mousedown', (e) => { 101 + e.preventDefault(); 102 + const cmdId = (btn as HTMLElement).dataset.commandId; 103 + const item = SLASH_COMMAND_ITEMS.find(i => i.id === cmdId); 104 + if (item && slashMenuCommandRef) { 105 + slashMenuCommandRef({ ...item, execute: getCommandExecutor(item) }); 106 + } 107 + }); 108 + btn.addEventListener('mouseenter', () => { 109 + const idx = parseInt((btn as HTMLElement).dataset.index!, 10); 110 + slashMenuState.selectedIndex = idx; 111 + updateSlashMenuSelection(); 112 + }); 113 + }); 114 + } 115 + 116 + function hideSlashMenu(): void { 117 + slashMenuEl.style.display = 'none'; 118 + slashMenuEl.innerHTML = ''; 119 + slashMenuCommandRef = null; 120 + } 121 + 122 + return { 123 + slashMenuState, 124 + renderSlashMenu, 125 + hideSlashMenu, 126 + updateSlashMenuSelection, 127 + getCommandRef: () => slashMenuCommandRef, 128 + setCommandRef: (ref) => { slashMenuCommandRef = ref; }, 129 + }; 130 + }
+240
src/docs/suggesting-ui.ts
··· 1 + /** 2 + * Suggesting Mode UI — toggle button, popover wiring, suggestion mark 3 + * click handling, accept/reject actions (single + bulk), and the 4 + * ProseMirror dispatch wrapper that wraps edits with suggestion marks. 5 + * 6 + * Extracted from main.ts for decomposition. 7 + */ 8 + 9 + import type { Editor } from '@tiptap/core'; 10 + import { SuggestionManager } from '../lib/suggesting.js'; 11 + import type { ActiveSuggestion } from './types.js'; 12 + 13 + // ── Types ─────────────────────────────────────────────────── 14 + 15 + export interface SuggestingUIDeps { 16 + editor: Editor; 17 + userName: string; 18 + $: (id: string) => HTMLElement; 19 + } 20 + 21 + export interface SuggestingUIResult { 22 + suggestionMgr: SuggestionManager; 23 + } 24 + 25 + // ── Suggesting Mode UI ────────────────────────────────────── 26 + 27 + export function wireSuggestingUI(deps: SuggestingUIDeps): SuggestingUIResult { 28 + const { editor, userName, $ } = deps; 29 + const suggestionMgr = new SuggestionManager(); 30 + const suggestingLabel = $('suggesting-label'); 31 + const suggestingBtn = $('btn-suggesting'); 32 + const suggestionPopover = $('suggestion-popover'); 33 + const suggestionAuthorEl = $('suggestion-author'); 34 + const suggestionTimeEl = $('suggestion-time'); 35 + const suggestionTypeEl = $('suggestion-type'); 36 + let activeSuggestion: ActiveSuggestion | null = null; 37 + 38 + suggestingBtn.addEventListener('click', () => { 39 + suggestionMgr.toggleMode(); 40 + updateSuggestingUI(); 41 + }); 42 + 43 + function updateSuggestingUI(): void { 44 + const suggesting = suggestionMgr.isSuggesting(); 45 + suggestingLabel.textContent = suggesting ? 'Suggesting' : 'Editing'; 46 + suggestingBtn.classList.toggle('active', suggesting); 47 + } 48 + 49 + // Handle suggestion mark clicks 50 + document.addEventListener('click', (e) => { 51 + const target = e.target as HTMLElement; 52 + const suggestionEl = target.closest('.suggestion-insert, .suggestion-delete'); 53 + if (suggestionEl) { 54 + const id = suggestionEl.getAttribute('data-suggestion-id'); 55 + const author = suggestionEl.getAttribute('data-suggestion-author'); 56 + const timestamp = suggestionEl.getAttribute('data-suggestion-timestamp'); 57 + const type = suggestionEl.classList.contains('suggestion-insert') ? 'Insertion' : 'Deletion'; 58 + 59 + suggestionAuthorEl.textContent = author || 'Unknown'; 60 + suggestionTimeEl.textContent = timestamp ? new Date(timestamp).toLocaleString() : ''; 61 + suggestionTypeEl.textContent = type; 62 + activeSuggestion = { id: id!, element: suggestionEl, type: type === 'Insertion' ? 'insert' : 'delete' }; 63 + 64 + const rect = suggestionEl.getBoundingClientRect(); 65 + suggestionPopover.style.display = ''; 66 + suggestionPopover.style.left = `${Math.min(rect.left, window.innerWidth - 260)}px`; 67 + suggestionPopover.style.top = `${rect.bottom + 4}px`; 68 + } else if (!target.closest('.suggestion-popover')) { 69 + suggestionPopover.style.display = 'none'; 70 + activeSuggestion = null; 71 + } 72 + }); 73 + 74 + function handleSuggestionAction(action: 'accept' | 'reject'): void { 75 + if (!activeSuggestion) return; 76 + const { id, type } = activeSuggestion; 77 + const markType = type === 'insert' ? 'suggestionInsert' : 'suggestionDelete'; 78 + const markSchema = editor.schema.marks[markType]; 79 + const { doc } = editor.state; 80 + 81 + // Find all positions with this suggestion ID 82 + const positions: { from: number; to: number }[] = []; 83 + doc.descendants((node, pos) => { 84 + if (node.isText) { 85 + node.marks.forEach((mark) => { 86 + if (mark.type === markSchema && mark.attrs.suggestionId === id) { 87 + positions.push({ from: pos, to: pos + node.nodeSize }); 88 + } 89 + }); 90 + } 91 + }); 92 + 93 + if (action === 'accept') { 94 + if (type === 'insert') { 95 + // Accept insert: keep text, remove mark 96 + editor.chain().focus().command(({ tr }) => { 97 + positions.forEach(({ from, to }) => tr.removeMark(from, to, markSchema)); 98 + return true; 99 + }).run(); 100 + } else { 101 + // Accept delete: actually delete the text 102 + editor.chain().focus().command(({ tr }) => { 103 + const sorted = [...positions].sort((a, b) => b.from - a.from); 104 + sorted.forEach(({ from, to }) => tr.delete(from, to)); 105 + return true; 106 + }).run(); 107 + } 108 + } else { 109 + // Reject 110 + if (type === 'insert') { 111 + // Reject insert: delete the inserted text 112 + editor.chain().focus().command(({ tr }) => { 113 + const sorted = [...positions].sort((a, b) => b.from - a.from); 114 + sorted.forEach(({ from, to }) => tr.delete(from, to)); 115 + return true; 116 + }).run(); 117 + } else { 118 + // Reject delete: keep text, remove mark 119 + editor.chain().focus().command(({ tr }) => { 120 + positions.forEach(({ from, to }) => tr.removeMark(from, to, markSchema)); 121 + return true; 122 + }).run(); 123 + } 124 + } 125 + 126 + suggestionPopover.style.display = 'none'; 127 + activeSuggestion = null; 128 + } 129 + 130 + $('suggestion-accept').addEventListener('click', () => handleSuggestionAction('accept')); 131 + $('suggestion-reject').addEventListener('click', () => handleSuggestionAction('reject')); 132 + 133 + function handleBulkSuggestionAction(action: 'accept' | 'reject'): void { 134 + const insertMark = editor.schema.marks.suggestionInsert; 135 + const deleteMark = editor.schema.marks.suggestionDelete; 136 + const { doc } = editor.state; 137 + 138 + // Collect all suggestion ranges grouped by type 139 + const inserts: { from: number; to: number }[] = []; 140 + const deletes: { from: number; to: number }[] = []; 141 + 142 + doc.descendants((node, pos) => { 143 + if (node.isText) { 144 + for (const mark of node.marks) { 145 + if (mark.type === insertMark) { 146 + inserts.push({ from: pos, to: pos + node.nodeSize }); 147 + } else if (mark.type === deleteMark) { 148 + deletes.push({ from: pos, to: pos + node.nodeSize }); 149 + } 150 + } 151 + } 152 + }); 153 + 154 + if (inserts.length === 0 && deletes.length === 0) return; 155 + 156 + editor.chain().focus().command(({ tr }) => { 157 + if (action === 'accept') { 158 + inserts.forEach(({ from, to }) => tr.removeMark(from, to, insertMark)); 159 + deletes.sort((a, b) => b.from - a.from).forEach(({ from, to }) => tr.delete(from, to)); 160 + } else { 161 + deletes.forEach(({ from, to }) => tr.removeMark(from, to, deleteMark)); 162 + inserts.sort((a, b) => b.from - a.from).forEach(({ from, to }) => tr.delete(from, to)); 163 + } 164 + return true; 165 + }).run(); 166 + 167 + suggestionPopover.style.display = 'none'; 168 + activeSuggestion = null; 169 + } 170 + 171 + $('suggestion-accept-all').addEventListener('click', () => handleBulkSuggestionAction('accept')); 172 + $('suggestion-reject-all').addEventListener('click', () => handleBulkSuggestionAction('reject')); 173 + 174 + // In suggesting mode, wrap insertions/deletions with suggestion marks. 175 + // We use a dispatch wrapper approach via editor transaction filtering. 176 + const originalDispatch = editor.view.dispatch.bind(editor.view); 177 + editor.view.dispatch = (tr) => { 178 + if (!suggestionMgr.isSuggesting() || !tr.docChanged || tr.getMeta('suggestion') || tr.getMeta('paste')) { 179 + originalDispatch(tr); 180 + return; 181 + } 182 + 183 + // Build suggestion-wrapped transaction 184 + const suggestTr = editor.view.state.tr; 185 + suggestTr.setMeta('suggestion', true); 186 + 187 + // Use session-aware attrs so consecutive keystrokes share one suggestion ID 188 + const cursorPos = editor.state.selection.from; 189 + const attrs = suggestionMgr.getSessionAttrs({ type: 'insert', author: userName, cursorPos }); 190 + const insertMark = editor.schema.marks.suggestionInsert.create(attrs); 191 + const deleteAttrs = suggestionMgr.getSessionAttrs({ type: 'delete', author: userName, cursorPos }); 192 + const deleteMark = editor.schema.marks.suggestionDelete.create(deleteAttrs); 193 + 194 + let hasSuggestions = false; 195 + 196 + tr.steps.forEach((step, i) => { 197 + const map = tr.mapping.maps[i]; 198 + if (!map) return; 199 + 200 + map.forEach((oldStart: number, oldEnd: number, newStart: number, newEnd: number) => { 201 + // Deletion: mark old content as deleted instead of removing 202 + if (oldEnd > oldStart && newEnd === newStart) { 203 + suggestTr.addMark(oldStart, oldEnd, deleteMark); 204 + hasSuggestions = true; 205 + } 206 + // Insertion: add the text with insert mark 207 + // For simplicity, let the original transaction through but mark new content 208 + }); 209 + }); 210 + 211 + if (hasSuggestions) { 212 + originalDispatch(suggestTr); 213 + suggestionMgr.updateSessionCursor(cursorPos); 214 + } else { 215 + // For insertions, apply original and then mark the inserted range 216 + originalDispatch(tr); 217 + const newTr = editor.view.state.tr; 218 + let marked = false; 219 + let lastNewEnd = cursorPos; 220 + tr.steps.forEach((step, i) => { 221 + const map = tr.mapping.maps[i]; 222 + if (!map) return; 223 + map.forEach((oldStart: number, oldEnd: number, newStart: number, newEnd: number) => { 224 + if (newEnd > newStart && newEnd <= editor.view.state.doc.content.size) { 225 + newTr.addMark(newStart, newEnd, insertMark); 226 + marked = true; 227 + lastNewEnd = newEnd; 228 + } 229 + }); 230 + }); 231 + if (marked) { 232 + newTr.setMeta('suggestion', true); 233 + originalDispatch(newTr); 234 + suggestionMgr.updateSessionCursor(lastNewEnd); 235 + } 236 + } 237 + }; 238 + 239 + return { suggestionMgr }; 240 + }