Full document, spreadsheet, slideshow, and diagram tooling
1/**
2 * AI Doc Action Executor — applies AI actions to TipTap documents.
3 *
4 * Supports inserting, replacing, and suggesting changes (track changes).
5 * All operations go through TipTap's transaction system, so they are
6 * Yjs-aware and will sync to collaborators.
7 */
8
9import type { Editor } from '@tiptap/core';
10import { createSuggestionAttrs } from '../lib/suggesting.js';
11import type { DocAction, DocInsertAction, DocReplaceAction, DocSuggestInsertAction, DocSuggestReplaceAction } from '../lib/ai-actions.js';
12
13export interface ActionResult {
14 success: boolean;
15 error?: string;
16}
17
18/**
19 * Execute a doc action on the TipTap editor.
20 */
21export function executeDocAction(
22 editor: Editor,
23 action: DocAction,
24): ActionResult {
25 switch (action.type) {
26 case 'doc_insert':
27 return executeInsert(editor, action);
28 case 'doc_replace':
29 return executeReplace(editor, action);
30 case 'doc_suggest_insert':
31 return executeSuggestInsert(editor, action);
32 case 'doc_suggest_replace':
33 return executeSuggestReplace(editor, action);
34 }
35}
36
37function resolvePosition(editor: Editor, position: 'cursor' | 'start' | 'end'): number {
38 switch (position) {
39 case 'cursor':
40 return editor.state.selection.head;
41 case 'start':
42 return 1;
43 case 'end':
44 return editor.state.doc.content.size - 1;
45 }
46}
47
48function executeInsert(editor: Editor, action: DocInsertAction): ActionResult {
49 const pos = resolvePosition(editor, action.position);
50 const success = editor.chain()
51 .focus()
52 .insertContentAt(pos, action.content)
53 .run();
54 return { success };
55}
56
57function executeReplace(editor: Editor, action: DocReplaceAction): ActionResult {
58 const { state } = editor;
59 const docText = state.doc.textContent;
60 const searchIdx = docText.indexOf(action.search);
61
62 if (searchIdx === -1) {
63 return { success: false, error: `Text not found: "${action.search.slice(0, 50)}"` };
64 }
65
66 // Map text offset to ProseMirror position
67 // Walk the document to find the actual position
68 let found = false;
69 const searchLen = action.search.length;
70
71 state.doc.descendants((node, pos) => {
72 if (found || !node.isText) return;
73 const nodeText = node.text || '';
74 const idx = nodeText.indexOf(action.search);
75 if (idx !== -1) {
76 const from = pos + idx;
77 const to = from + searchLen;
78 editor.chain()
79 .focus()
80 .command(({ tr }) => {
81 tr.replaceWith(from, to, action.replace
82 ? state.schema.text(action.replace)
83 : state.schema.text(''));
84 return true;
85 })
86 .run();
87 found = true;
88 }
89 });
90
91 if (!found) {
92 // Fallback: try across node boundaries using the full-doc text
93 // This handles cases where the search text spans multiple nodes
94 let textOffset = 0;
95 let fromPos = -1;
96
97 state.doc.descendants((node, pos) => {
98 if (fromPos !== -1) return;
99 if (node.isText) {
100 const nodeText = node.text || '';
101 // Check if search starts in this node
102 const remaining = action.search.slice(0);
103 const localStart = docText.indexOf(remaining, textOffset) - textOffset;
104 if (localStart >= 0 && localStart < nodeText.length) {
105 fromPos = pos + localStart;
106 }
107 textOffset += nodeText.length;
108 }
109 });
110
111 if (fromPos !== -1) {
112 const toPos = fromPos + searchLen;
113 editor.chain()
114 .focus()
115 .command(({ tr }) => {
116 if (action.replace) {
117 tr.replaceWith(fromPos, toPos, state.schema.text(action.replace));
118 } else {
119 tr.delete(fromPos, toPos);
120 }
121 return true;
122 })
123 .run();
124 found = true;
125 }
126 }
127
128 return found
129 ? { success: true }
130 : { success: false, error: `Could not locate text in document structure` };
131}
132
133function executeSuggestInsert(editor: Editor, action: DocSuggestInsertAction): ActionResult {
134 const pos = resolvePosition(editor, action.position);
135 const attrs = createSuggestionAttrs({ type: 'insert', author: 'AI' });
136 const markType = editor.state.schema.marks['suggestion-insert'];
137
138 if (!markType) {
139 return { success: false, error: 'Suggestion marks not available in this editor' };
140 }
141
142 const success = editor.chain()
143 .focus()
144 .command(({ tr }) => {
145 const mark = markType.create(attrs);
146 const textNode = editor.state.schema.text(action.content, [mark]);
147 tr.insert(pos, textNode);
148 return true;
149 })
150 .run();
151
152 return { success };
153}
154
155function executeSuggestReplace(editor: Editor, action: DocSuggestReplaceAction): ActionResult {
156 const { state } = editor;
157 const suggestionId = createSuggestionAttrs({ type: 'delete', author: 'AI' }).suggestionId;
158 const deleteMarkType = state.schema.marks['suggestion-delete'];
159 const insertMarkType = state.schema.marks['suggestion-insert'];
160
161 if (!deleteMarkType || !insertMarkType) {
162 return { success: false, error: 'Suggestion marks not available in this editor' };
163 }
164
165 // Find the search text in the document
166 let fromPos = -1;
167 let toPos = -1;
168
169 state.doc.descendants((node, pos) => {
170 if (fromPos !== -1) return;
171 if (node.isText) {
172 const nodeText = node.text || '';
173 const idx = nodeText.indexOf(action.search);
174 if (idx !== -1) {
175 fromPos = pos + idx;
176 toPos = fromPos + action.search.length;
177 }
178 }
179 });
180
181 if (fromPos === -1) {
182 return { success: false, error: `Text not found: "${action.search.slice(0, 50)}"` };
183 }
184
185 const timestamp = new Date().toISOString();
186
187 const success = editor.chain()
188 .focus()
189 .command(({ tr }) => {
190 // Mark the old text as suggestion-delete
191 const deleteMark = deleteMarkType.create({
192 suggestionId,
193 author: 'AI',
194 type: 'delete',
195 timestamp,
196 });
197 tr.addMark(fromPos, toPos, deleteMark);
198
199 // Insert replacement text with suggestion-insert mark right after
200 const insertMark = insertMarkType.create({
201 suggestionId,
202 author: 'AI',
203 type: 'insert',
204 timestamp,
205 });
206 const textNode = state.schema.text(action.replace, [insertMark]);
207 tr.insert(toPos, textNode);
208
209 return true;
210 })
211 .run();
212
213 return { success };
214}