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

Configure Feed

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

at main 215 lines 7.0 kB view raw
1/** 2 * Suggestions Panel — accept/reject tracked changes in the document. 3 * 4 * Scans the TipTap document for suggestion marks (insert/delete), 5 * groups them by suggestion ID, and renders a panel with accept/reject 6 * buttons. Accepting an insert removes the mark; accepting a delete 7 * removes the text. Rejecting does the opposite. 8 */ 9 10import type { Editor } from '@tiptap/core'; 11 12export interface Suggestion { 13 id: string; 14 type: 'insert' | 'delete'; 15 author: string; 16 timestamp: string; 17 text: string; 18 from: number; 19 to: number; 20} 21 22/** 23 * Extract all suggestions from the editor document. 24 */ 25export function extractSuggestions(editor: Editor): Suggestion[] { 26 const suggestions: Suggestion[] = []; 27 const seen = new Set<string>(); 28 29 editor.state.doc.descendants((node, pos) => { 30 for (const mark of node.marks) { 31 if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') { 32 const id = mark.attrs.suggestionId; 33 if (!id || seen.has(id)) continue; 34 seen.add(id); 35 suggestions.push({ 36 id, 37 type: mark.type.name === 'suggestionInsert' ? 'insert' : 'delete', 38 author: mark.attrs.author || 'Unknown', 39 timestamp: mark.attrs.timestamp || '', 40 text: node.textContent, 41 from: pos, 42 to: pos + node.nodeSize, 43 }); 44 } 45 } 46 return true; 47 }); 48 49 return suggestions; 50} 51 52/** 53 * Accept a suggestion. 54 * - Insert: remove the mark (keep the text) 55 * - Delete: remove the text 56 */ 57export function acceptSuggestion(editor: Editor, suggestion: Suggestion): void { 58 if (suggestion.type === 'insert') { 59 // Keep text, remove the mark 60 editor.chain() 61 .setTextSelection({ from: suggestion.from, to: suggestion.to }) 62 .unsetMark('suggestionInsert') 63 .run(); 64 } else { 65 // Delete: remove the marked text 66 editor.chain() 67 .deleteRange({ from: suggestion.from, to: suggestion.to }) 68 .run(); 69 } 70} 71 72/** 73 * Reject a suggestion. 74 * - Insert: remove the text 75 * - Delete: remove the mark (keep the text) 76 */ 77export function rejectSuggestion(editor: Editor, suggestion: Suggestion): void { 78 if (suggestion.type === 'insert') { 79 // Remove the suggested insertion 80 editor.chain() 81 .deleteRange({ from: suggestion.from, to: suggestion.to }) 82 .run(); 83 } else { 84 // Keep text, remove the strikethrough mark 85 editor.chain() 86 .setTextSelection({ from: suggestion.from, to: suggestion.to }) 87 .unsetMark('suggestionDelete') 88 .run(); 89 } 90} 91 92/** 93 * Accept all suggestions at once. 94 */ 95export function acceptAllSuggestions(editor: Editor): void { 96 // Process in reverse order to maintain position stability 97 const suggestions = extractSuggestions(editor).reverse(); 98 for (const s of suggestions) { 99 acceptSuggestion(editor, s); 100 } 101} 102 103/** 104 * Reject all suggestions at once. 105 */ 106export function rejectAllSuggestions(editor: Editor): void { 107 const suggestions = extractSuggestions(editor).reverse(); 108 for (const s of suggestions) { 109 rejectSuggestion(editor, s); 110 } 111} 112 113/** 114 * Mount the suggestions panel into a container. 115 */ 116export function mountSuggestionsPanel( 117 editor: Editor, 118 container: HTMLElement, 119): { refresh: () => void; destroy: () => void } { 120 let panelEl: HTMLElement | null = null; 121 122 function refresh() { 123 const suggestions = extractSuggestions(editor); 124 125 if (suggestions.length === 0) { 126 if (panelEl) { 127 panelEl.innerHTML = '<p class="suggestions-empty">No pending suggestions</p>'; 128 } 129 return; 130 } 131 132 if (!panelEl) { 133 panelEl = document.createElement('div'); 134 panelEl.className = 'suggestions-panel'; 135 container.appendChild(panelEl); 136 } 137 138 let html = '<div class="suggestions-header">'; 139 html += `<span class="suggestions-count">${suggestions.length} suggestion${suggestions.length !== 1 ? 's' : ''}</span>`; 140 html += '<div class="suggestions-bulk">'; 141 html += '<button class="btn-sm suggestions-accept-all" id="btn-accept-all">Accept All</button>'; 142 html += '<button class="btn-sm suggestions-reject-all" id="btn-reject-all">Reject All</button>'; 143 html += '</div></div>'; 144 145 html += '<div class="suggestions-list">'; 146 for (const s of suggestions) { 147 const timeStr = s.timestamp ? formatRelativeTime(s.timestamp) : ''; 148 const typeLabel = s.type === 'insert' ? 'Added' : 'Deleted'; 149 const typeClass = s.type === 'insert' ? 'suggestion-item--insert' : 'suggestion-item--delete'; 150 const preview = s.text.length > 50 ? s.text.slice(0, 50) + '…' : s.text; 151 152 html += `<div class="suggestion-item ${typeClass}" data-suggestion-id="${escapeAttr(s.id)}">`; 153 html += `<div class="suggestion-meta"><span class="suggestion-type">${typeLabel}</span> by <strong>${escapeHtml(s.author)}</strong>${timeStr ? ' · ' + timeStr : ''}</div>`; 154 html += `<div class="suggestion-preview">${escapeHtml(preview)}</div>`; 155 html += '<div class="suggestion-actions">'; 156 html += `<button class="btn-sm suggestion-accept" data-action="accept" data-sid="${escapeAttr(s.id)}">Accept</button>`; 157 html += `<button class="btn-sm suggestion-reject" data-action="reject" data-sid="${escapeAttr(s.id)}">Reject</button>`; 158 html += '</div></div>'; 159 } 160 html += '</div>'; 161 162 panelEl.innerHTML = html; 163 164 // Wire handlers 165 panelEl.querySelector('#btn-accept-all')?.addEventListener('click', () => { 166 acceptAllSuggestions(editor); 167 refresh(); 168 }); 169 panelEl.querySelector('#btn-reject-all')?.addEventListener('click', () => { 170 rejectAllSuggestions(editor); 171 refresh(); 172 }); 173 panelEl.querySelectorAll('[data-action]').forEach(btn => { 174 btn.addEventListener('click', () => { 175 const sid = (btn as HTMLElement).dataset.sid; 176 const action = (btn as HTMLElement).dataset.action; 177 // Re-extract from editor to get fresh positions (collaborative edits may have shifted them) 178 const freshSuggestions = extractSuggestions(editor); 179 const s = freshSuggestions.find(s => s.id === sid); 180 if (!s) return; 181 if (action === 'accept') acceptSuggestion(editor, s); 182 else rejectSuggestion(editor, s); 183 refresh(); 184 }); 185 }); 186 } 187 188 const handler = () => refresh(); 189 editor.on('transaction', handler); 190 refresh(); 191 192 return { 193 refresh, 194 destroy: () => { 195 editor.off('transaction', handler); 196 if (panelEl) { panelEl.remove(); panelEl = null; } 197 }, 198 }; 199} 200 201function formatRelativeTime(ts: string): string { 202 const diff = Date.now() - new Date(ts).getTime(); 203 if (diff < 60000) return 'just now'; 204 if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago'; 205 if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago'; 206 return Math.floor(diff / 86400000) + 'd ago'; 207} 208 209function escapeHtml(s: string): string { 210 return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 211} 212 213function escapeAttr(s: string): string { 214 return s.replace(/"/g, '&quot;').replace(/&/g, '&amp;'); 215}