Full document, spreadsheet, slideshow, and diagram tooling
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, '&').replace(/</g, '<').replace(/>/g, '>');
211}
212
213function escapeAttr(s: string): string {
214 return s.replace(/"/g, '"').replace(/&/g, '&');
215}