diff --git a/docker/freecad-mcp/Dockerfile b/docker/freecad-mcp/Dockerfile index ccd7a19..a6e0e0a 100644 --- a/docker/freecad-mcp/Dockerfile +++ b/docker/freecad-mcp/Dockerfile @@ -16,7 +16,7 @@ WORKDIR /app # Copy MCP server source COPY freecad-mcp-server/ . -# Install Node deps + supergateway (stdio→SSE bridge) +# Install Node deps + supergateway (stdio→HTTP bridge) RUN npm install && npm install -g supergateway # Ensure generated files dir exists @@ -24,4 +24,5 @@ RUN mkdir -p /data/files/generated EXPOSE 8808 -CMD ["supergateway", "--stdio", "node build/index.js", "--port", "8808"] +# Use StreamableHttp (supports multiple concurrent connections, unlike SSE) +CMD ["supergateway", "--stdio", "node build/index.js", "--port", "8808", "--outputTransport", "streamableHttp"] diff --git a/docker/kicad-mcp/Dockerfile b/docker/kicad-mcp/Dockerfile index b803f0c..19344b7 100644 --- a/docker/kicad-mcp/Dockerfile +++ b/docker/kicad-mcp/Dockerfile @@ -1,31 +1,38 @@ FROM node:20-slim -# Install KiCad, Python, and build dependencies +# Install KiCad (includes pcbnew Python module), Python, and build dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ kicad \ python3 \ python3-pip \ - python3-venv \ ca-certificates \ && rm -rf /var/lib/apt/lists/* # Use SWIG backend (headless — no KiCad GUI needed) ENV KICAD_BACKEND=swig +# Ensure pcbnew module is findable (installed by kicad package) +ENV PYTHONPATH=/usr/lib/python3/dist-packages +# Point KiCad MCP to system Python (absolute path for existsSync validation) +ENV KICAD_PYTHON=/usr/bin/python3 WORKDIR /app # Copy MCP server source COPY KiCAD-MCP-Server/ . +# Remove any venv so the server uses system Python (which has pcbnew) +RUN rm -rf .venv venv + # Install Node deps + supergateway (stdio→SSE bridge) RUN npm install && npm install -g supergateway -# Install Python requirements (Pillow, cairosvg, etc.) -RUN pip3 install --break-system-packages -r python/requirements.txt +# Install Python requirements into system Python (Pillow, cairosvg, requests, etc.) +RUN pip3 install --break-system-packages -r python/requirements.txt requests # Ensure generated files dir exists RUN mkdir -p /data/files/generated EXPOSE 8809 -CMD ["supergateway", "--stdio", "node dist/index.js", "--port", "8809"] +# Use StreamableHttp (supports multiple concurrent connections, unlike SSE) +CMD ["supergateway", "--stdio", "node dist/index.js", "--port", "8809", "--outputTransport", "streamableHttp"] diff --git a/modules/rnotes/components/comment-panel.ts b/modules/rnotes/components/comment-panel.ts index a237e02..1f34b1b 100644 --- a/modules/rnotes/components/comment-panel.ts +++ b/modules/rnotes/components/comment-panel.ts @@ -130,14 +130,25 @@ class NotesCommentPanel extends HTMLElement { this.shadow.innerHTML = ` -
Maya is tracking expenses in rF
const s = this.getSessionInfo();
return { authorId: s.userId, authorName: s.username };
},
- () => this.editor?.view ?? null,
);
this.editor.registerPlugin(suggestionPlugin);
@@ -2303,6 +2302,118 @@ Gear: EUR 400 (10%) Maya is tracking expenses in rF
if (container) {
container.classList.toggle('suggesting-mode', this.suggestingMode);
}
+
+ // Show/hide suggestion review bar
+ this.updateSuggestionReviewBar();
+ }
+
+ /** Show a review bar when there are pending suggestions. */
+ private updateSuggestionReviewBar() {
+ let bar = this.shadow.getElementById('suggestion-review-bar');
+ if (!this.editor) {
+ bar?.remove();
+ return;
+ }
+
+ // Count suggestions
+ const ids = new Set Maya is tracking expenses in rF
if (sidebar) sidebar.classList.remove('has-comments');
}
- /** Wire click handling on comment highlights in the editor to open comment panel. */
+ /** Wire click handling on comment highlights and suggestion marks in the editor. */
private wireCommentHighlightClicks() {
if (!this.editor) return;
@@ -2358,15 +2469,36 @@ Gear: EUR 400 (10%) Maya is tracking expenses in rF
}
});
- // Direct click on comment highlight in the DOM
+ // On any change, update the suggestion review bar
+ this.editor.on('update', () => {
+ this.updateSuggestionReviewBar();
+ });
+
+ // Direct click on comment highlight or suggestion marks in the DOM
const container = this.shadow.getElementById('tiptap-container');
if (container) {
container.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
+
+ // Comment highlights
const highlight = target.closest?.('.comment-highlight') as HTMLElement;
if (highlight) {
const threadId = highlight.getAttribute('data-thread-id');
if (threadId) this.showCommentPanel(threadId);
+ return;
+ }
+
+ // Suggestion marks — show accept/reject popover
+ const suggestionEl = target.closest?.('.suggestion-insert, .suggestion-delete') as HTMLElement;
+ if (suggestionEl) {
+ const suggestionId = suggestionEl.getAttribute('data-suggestion-id');
+ const authorName = suggestionEl.getAttribute('data-author-name') || 'Unknown';
+ const type = suggestionEl.classList.contains('suggestion-insert') ? 'insert' : 'delete';
+ if (suggestionId) {
+ const rect = suggestionEl.getBoundingClientRect();
+ this.showSuggestionPopover(suggestionId, authorName, type as 'insert' | 'delete', rect);
+ }
+ return;
}
});
}
@@ -3060,6 +3192,7 @@ Gear: EUR 400 (10%) Maya is tracking expenses in rF
}
.comment-sidebar.has-comments {
width: 280px;
+ overflow-y: auto;
border-left: 1px solid var(--rs-border, #e5e7eb);
}
@media (max-width: 768px) {
@@ -3574,16 +3707,24 @@ Gear: EUR 400 (10%) Maya is tracking expenses in rF
/* ── Collaboration: Suggestions ── */
.tiptap-container .tiptap .suggestion-insert {
- color: #16a34a;
- background: rgba(22, 163, 74, 0.08);
- border-bottom: 1px dashed #16a34a;
+ color: #137333;
+ background: rgba(22, 163, 74, 0.1);
+ border-bottom: 2px solid rgba(22, 163, 74, 0.4);
text-decoration: none;
+ cursor: pointer;
+ }
+ .tiptap-container .tiptap .suggestion-insert:hover {
+ background: rgba(22, 163, 74, 0.18);
}
.tiptap-container .tiptap .suggestion-delete {
- color: #dc2626;
+ color: #c5221f;
background: rgba(220, 38, 38, 0.08);
text-decoration: line-through;
- text-decoration-color: #dc2626;
+ text-decoration-color: #c5221f;
+ cursor: pointer;
+ }
+ .tiptap-container .tiptap .suggestion-delete:hover {
+ background: rgba(220, 38, 38, 0.15);
}
.tiptap-container.suggesting-mode {
border-left: 3px solid #f59e0b;
@@ -3592,6 +3733,56 @@ Gear: EUR 400 (10%) Maya is tracking expenses in rF
background: #f59e0b !important;
color: #fff !important;
}
+
+ /* ── Suggestion Review Bar ── */
+ .suggestion-review-bar {
+ display: flex; align-items: center; gap: 8px;
+ padding: 4px 12px; height: 32px;
+ background: color-mix(in srgb, #f59e0b 8%, var(--rs-bg-surface, #fff));
+ border-bottom: 1px solid color-mix(in srgb, #f59e0b 20%, var(--rs-border, #e5e7eb));
+ font-size: 12px; color: var(--rs-text-secondary, #666);
+ }
+ .srb-label { font-weight: 600; color: #b45309; }
+ .srb-count { margin-left: auto; color: var(--rs-text-muted, #999); }
+ .srb-hint { color: var(--rs-text-muted, #999); font-style: italic; }
+ .srb-btn {
+ padding: 3px 10px; border-radius: 4px; border: 1px solid var(--rs-border, #ddd);
+ font-size: 11px; cursor: pointer; font-weight: 500; background: var(--rs-bg-surface, #fff);
+ color: var(--rs-text-primary, #111);
+ }
+ .srb-btn:hover { background: var(--rs-bg-hover, #f5f5f5); }
+ .srb-accept-all { color: #137333; border-color: #137333; }
+ .srb-accept-all:hover { background: rgba(22, 163, 74, 0.08); }
+ .srb-reject-all { color: #c5221f; border-color: #c5221f; }
+ .srb-reject-all:hover { background: rgba(220, 38, 38, 0.08); }
+
+ /* ── Suggestion Popover (accept/reject on click) ── */
+ .suggestion-popover {
+ position: absolute;
+ z-index: 100;
+ background: var(--rs-bg-surface, #fff);
+ border: 1px solid var(--rs-border, #e5e7eb);
+ border-radius: 8px;
+ box-shadow: 0 2px 12px rgba(0,0,0,0.12);
+ padding: 8px 10px;
+ display: flex; flex-direction: column; gap: 6px;
+ min-width: 140px;
+ font-size: 12px;
+ }
+ .sp-header { display: flex; align-items: center; gap: 6px; }
+ .sp-author { font-weight: 600; color: var(--rs-text-primary, #111); }
+ .sp-type { color: var(--rs-text-muted, #999); font-size: 11px; }
+ .sp-actions { display: flex; gap: 6px; }
+ .sp-btn {
+ flex: 1; padding: 4px 0; border: 1px solid var(--rs-border, #ddd);
+ border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 600;
+ background: var(--rs-bg-surface, #fff); text-align: center;
+ }
+ .sp-btn:hover { background: var(--rs-bg-hover, #f5f5f5); }
+ .sp-accept { color: #137333; border-color: #137333; }
+ .sp-accept:hover { background: rgba(22, 163, 74, 0.08); }
+ .sp-reject { color: #c5221f; border-color: #c5221f; }
+ .sp-reject:hover { background: rgba(220, 38, 38, 0.08); }
`;
}
}
diff --git a/modules/rnotes/components/suggestion-plugin.ts b/modules/rnotes/components/suggestion-plugin.ts
index 04078e3..649b455 100644
--- a/modules/rnotes/components/suggestion-plugin.ts
+++ b/modules/rnotes/components/suggestion-plugin.ts
@@ -1,227 +1,315 @@
/**
- * ProseMirror plugin that intercepts transactions in "suggesting" mode
- * and converts them into track-changes marks instead of direct edits.
+ * ProseMirror plugin that intercepts user input in "suggesting" mode
+ * and converts edits into track-changes marks instead of direct mutations.
*
* In suggesting mode:
- * - Insertions → wraps inserted text with `suggestionInsert` mark
- * - Deletions → converts to `suggestionDelete` mark instead of deleting
+ * - Typed text → inserted with `suggestionInsert` mark (green underline)
+ * - Backspace/Delete → text NOT deleted, marked with `suggestionDelete` (red strikethrough)
+ * - Select + type → old text gets `suggestionDelete`, new text gets `suggestionInsert`
+ * - Paste → same as select + type
*
- * Accept/reject operations remove marks (and optionally the text).
+ * Uses ProseMirror props (handleTextInput, handleKeyDown, handlePaste) rather
+ * than filterTransaction for reliability.
*/
-import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state';
+import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
import type { EditorView } from '@tiptap/pm/view';
+import type { Slice } from '@tiptap/pm/model';
import type { Editor } from '@tiptap/core';
const pluginKey = new PluginKey('suggestion-plugin');
-interface SuggestionPluginState {
- suggesting: boolean;
- authorId: string;
- authorName: string;
+function makeSuggestionId(): string {
+ return `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
/**
* Create the suggestion mode ProseMirror plugin.
* @param getSuggesting - callback that returns current suggesting mode state
* @param getAuthor - callback that returns { authorId, authorName }
- * @param getView - callback that returns the EditorView (needed to dispatch replacement transactions)
*/
export function createSuggestionPlugin(
getSuggesting: () => boolean,
getAuthor: () => { authorId: string; authorName: string },
- getView?: () => EditorView | null,
): Plugin {
return new Plugin({
key: pluginKey,
- filterTransaction(tr: Transaction, state) {
- if (!getSuggesting()) return true;
- if (!tr.docChanged) return true;
- if (tr.getMeta('suggestion-accept') || tr.getMeta('suggestion-reject')) return true;
- if (tr.getMeta('suggestion-applied')) return true;
+ props: {
+ /** Intercept typed text — insert with suggestionInsert mark. */
+ handleTextInput(view: EditorView, from: number, to: number, text: string): boolean {
+ if (!getSuggesting()) return false;
- // Intercept the transaction and convert it to suggestion marks
- const { authorId, authorName } = getAuthor();
- const suggestionId = `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+ const { state } = view;
+ const { authorId, authorName } = getAuthor();
+ const suggestionId = makeSuggestionId();
+ const tr = state.tr;
- // We need to rebuild the transaction with suggestion marks
- const newTr = state.tr;
- let blocked = false;
+ // If there's a selection (replacement), mark the selected text as deleted
+ if (from !== to) {
+ // Check if selected text is all suggestionInsert from the same author
+ // → if so, just replace it (editing your own suggestion)
+ const ownInsert = isOwnSuggestionInsert(state, from, to, authorId);
+ if (ownInsert) {
+ tr.replaceWith(from, to, state.schema.text(text, [
+ state.schema.marks.suggestionInsert.create({
+ suggestionId: ownInsert, authorId, authorName, createdAt: Date.now(),
+ }),
+ ]));
+ tr.setMeta('suggestion-applied', true);
+ view.dispatch(tr);
+ return true;
+ }
- tr.steps.forEach((step, i) => {
- const stepMap = step.getMap();
- let hasInsert = false;
- let hasDelete = false;
+ const deleteMark = state.schema.marks.suggestionDelete.create({
+ suggestionId, authorId, authorName, createdAt: Date.now(),
+ });
+ tr.addMark(from, to, deleteMark);
+ }
- stepMap.forEach((oldStart: number, oldEnd: number, newStart: number, newEnd: number) => {
- if (newEnd > newStart) hasInsert = true;
- if (oldEnd > oldStart) hasDelete = true;
+ // Insert the new text with insert mark after the (marked-for-deletion) text
+ const insertPos = to;
+ const insertMark = state.schema.marks.suggestionInsert.create({
+ suggestionId, authorId, authorName, createdAt: Date.now(),
+ });
+ tr.insert(insertPos, state.schema.text(text, [insertMark]));
+ tr.setMeta('suggestion-applied', true);
+
+ // Place cursor after the inserted text
+ tr.setSelection(TextSelection.create(tr.doc, insertPos + text.length));
+
+ view.dispatch(tr);
+ return true;
+ },
+
+ /** Intercept Backspace/Delete — mark text as deleted instead of removing. */
+ handleKeyDown(view: EditorView, event: KeyboardEvent): boolean {
+ if (!getSuggesting()) return false;
+ if (event.key !== 'Backspace' && event.key !== 'Delete') return false;
+
+ const { state } = view;
+ const { from, to, empty } = state.selection;
+ const { authorId, authorName } = getAuthor();
+
+ let deleteFrom = from;
+ let deleteTo = to;
+
+ if (empty) {
+ if (event.key === 'Backspace') {
+ if (from === 0) return true;
+ deleteFrom = from - 1;
+ deleteTo = from;
+ } else {
+ if (from >= state.doc.content.size) return true;
+ deleteFrom = from;
+ deleteTo = from + 1;
+ }
+
+ // Don't cross block boundaries
+ const $from = state.doc.resolve(deleteFrom);
+ const $to = state.doc.resolve(deleteTo);
+ if ($from.parent !== $to.parent) return true;
+ }
+
+ // Backspace/delete on own suggestionInsert → actually remove it
+ const ownInsert = isOwnSuggestionInsert(state, deleteFrom, deleteTo, authorId);
+ if (ownInsert) {
+ const tr = state.tr;
+ tr.delete(deleteFrom, deleteTo);
+ tr.setMeta('suggestion-applied', true);
+ view.dispatch(tr);
+ return true;
+ }
+
+ // Already marked as suggestionDelete → skip past it
+ if (isAlreadySuggestionDelete(state, deleteFrom, deleteTo)) {
+ const tr = state.tr;
+ const newPos = event.key === 'Backspace' ? deleteFrom : deleteTo;
+ tr.setSelection(TextSelection.create(state.doc, newPos));
+ view.dispatch(tr);
+ return true;
+ }
+
+ // Mark the text as deleted
+ const suggestionId = makeSuggestionId();
+ const tr = state.tr;
+ const deleteMark = state.schema.marks.suggestionDelete.create({
+ suggestionId, authorId, authorName, createdAt: Date.now(),
+ });
+ tr.addMark(deleteFrom, deleteTo, deleteMark);
+ tr.setMeta('suggestion-applied', true);
+
+ if (event.key === 'Backspace') {
+ tr.setSelection(TextSelection.create(tr.doc, deleteFrom));
+ } else {
+ tr.setSelection(TextSelection.create(tr.doc, deleteTo));
+ }
+
+ view.dispatch(tr);
+ return true;
+ },
+
+ /** Intercept paste — insert pasted text as a suggestion. */
+ handlePaste(view: EditorView, _event: ClipboardEvent, slice: Slice): boolean {
+ if (!getSuggesting()) return false;
+
+ const { state } = view;
+ const { from, to } = state.selection;
+ const { authorId, authorName } = getAuthor();
+ const suggestionId = makeSuggestionId();
+ const tr = state.tr;
+
+ // Mark selected text as deleted
+ if (from !== to) {
+ const deleteMark = state.schema.marks.suggestionDelete.create({
+ suggestionId, authorId, authorName, createdAt: Date.now(),
+ });
+ tr.addMark(from, to, deleteMark);
+ }
+
+ // Extract text from slice and insert with mark
+ let pastedText = '';
+ slice.content.forEach((node: any) => {
+ if (pastedText) pastedText += '\n';
+ pastedText += node.textContent;
});
- if (hasInsert && !hasDelete) {
- // Pure insertion — let it through but add the suggestionInsert mark
- blocked = true;
- const doc = tr.docs[i];
- stepMap.forEach((_oldStart: number, _oldEnd: number, newStart: number, newEnd: number) => {
- if (newEnd > newStart) {
- const insertedText = tr.docs[i + 1]?.textBetween(newStart, newEnd, '', '') || '';
- if (insertedText) {
- const insertMark = state.schema.marks.suggestionInsert.create({
- suggestionId,
- authorId,
- authorName,
- createdAt: Date.now(),
- });
- newTr.insertText(insertedText, newStart);
- newTr.addMark(newStart, newStart + insertedText.length, insertMark);
- newTr.setMeta('suggestion-applied', true);
- }
- }
+ if (pastedText) {
+ const insertPos = to;
+ const insertMark = state.schema.marks.suggestionInsert.create({
+ suggestionId, authorId, authorName, createdAt: Date.now(),
});
- } else if (hasDelete && !hasInsert) {
- // Pure deletion — convert to suggestionDelete mark instead
- blocked = true;
- stepMap.forEach((oldStart: number, oldEnd: number) => {
- if (oldEnd > oldStart) {
- const deleteMark = state.schema.marks.suggestionDelete.create({
- suggestionId,
- authorId,
- authorName,
- createdAt: Date.now(),
- });
- newTr.addMark(oldStart, oldEnd, deleteMark);
- newTr.setMeta('suggestion-applied', true);
- }
- });
- } else if (hasInsert && hasDelete) {
- // Replacement (delete + insert) — mark old text as deleted, new text as inserted
- blocked = true;
- stepMap.forEach((oldStart: number, oldEnd: number, newStart: number, newEnd: number) => {
- if (oldEnd > oldStart) {
- const deleteMark = state.schema.marks.suggestionDelete.create({
- suggestionId,
- authorId,
- authorName,
- createdAt: Date.now(),
- });
- newTr.addMark(oldStart, oldEnd, deleteMark);
- }
- if (newEnd > newStart) {
- const insertedText = tr.docs[i + 1]?.textBetween(newStart, newEnd, '', '') || '';
- if (insertedText) {
- const insertMark = state.schema.marks.suggestionInsert.create({
- suggestionId,
- authorId,
- authorName,
- createdAt: Date.now(),
- });
- // Insert after the "deleted" text
- const insertPos = oldEnd;
- newTr.insertText(insertedText, insertPos);
- newTr.addMark(insertPos, insertPos + insertedText.length, insertMark);
- }
- }
- });
- newTr.setMeta('suggestion-applied', true);
+ tr.insert(insertPos, state.schema.text(pastedText, [insertMark]));
+ tr.setMeta('suggestion-applied', true);
+ tr.setSelection(TextSelection.create(tr.doc, insertPos + pastedText.length));
}
- });
- if (blocked && newTr.docChanged) {
- // Dispatch our modified transaction instead on the next tick
- setTimeout(() => {
- const view = getView?.();
- if (view) view.dispatch(newTr);
- }, 0);
- return false; // Block the original transaction
- }
-
- return !blocked;
+ view.dispatch(tr);
+ return true;
+ },
},
});
}
+/** Check if the range is entirely covered by suggestionInsert marks from the same author. */
+function isOwnSuggestionInsert(
+ state: { doc: any; schema: any },
+ from: number,
+ to: number,
+ authorId: string,
+): string | null {
+ let allOwn = true;
+ let foundId: string | null = null;
+ state.doc.nodesBetween(from, to, (node: any) => {
+ if (!node.isText) return;
+ const mark = node.marks.find(
+ (m: any) => m.type.name === 'suggestionInsert' && m.attrs.authorId === authorId
+ );
+ if (!mark) {
+ allOwn = false;
+ } else if (!foundId) {
+ foundId = mark.attrs.suggestionId;
+ }
+ });
+ return allOwn && foundId ? foundId : null;
+}
+
+/** Check if the range is already entirely covered by suggestionDelete marks. */
+function isAlreadySuggestionDelete(state: { doc: any }, from: number, to: number): boolean {
+ let allDeleted = true;
+ state.doc.nodesBetween(from, to, (node: any) => {
+ if (!node.isText) return;
+ if (!node.marks.find((m: any) => m.type.name === 'suggestionDelete')) allDeleted = false;
+ });
+ return allDeleted;
+}
+
+
/**
- * Accept a suggestion by removing the mark.
- * - For insertions: remove the mark (text stays)
- * - For deletions: remove the text and the mark
+ * Accept a suggestion: insertions stay, deletions are removed.
*/
export function acceptSuggestion(editor: Editor, suggestionId: string) {
const { state } = editor;
- const { doc, tr } = state;
+ const { tr } = state;
- // Find all marks with this suggestionId
- doc.descendants((node, pos) => {
+ // Collect ranges first, apply from end→start to preserve positions
+ const deleteRanges: [number, number][] = [];
+ const insertRanges: [number, number, any][] = [];
+
+ state.doc.descendants((node: any, pos: number) => {
if (!node.isText) return;
-
- // Check for suggestionDelete marks — accept = remove the text
const deleteMark = node.marks.find(
- m => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId
+ (m: any) => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId
);
if (deleteMark) {
- tr.delete(pos, pos + node.nodeSize);
- tr.setMeta('suggestion-accept', true);
- return false;
+ deleteRanges.push([pos, pos + node.nodeSize]);
+ return;
}
-
- // Check for suggestionInsert marks — accept = remove the mark, keep text
const insertMark = node.marks.find(
- m => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId
+ (m: any) => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId
);
if (insertMark) {
- tr.removeMark(pos, pos + node.nodeSize, insertMark);
- tr.setMeta('suggestion-accept', true);
+ insertRanges.push([pos, pos + node.nodeSize, insertMark]);
}
});
+ for (const [from, to] of deleteRanges.sort((a, b) => b[0] - a[0])) {
+ tr.delete(from, to);
+ }
+ for (const [from, to, mark] of insertRanges.sort((a, b) => b[0] - a[0])) {
+ tr.removeMark(from, to, mark);
+ }
+
if (tr.docChanged) {
+ tr.setMeta('suggestion-accept', true);
editor.view.dispatch(tr);
}
}
/**
- * Reject a suggestion by reverting it.
- * - For insertions: remove the text and the mark
- * - For deletions: remove the mark (text stays)
+ * Reject a suggestion: insertions are removed, deletions stay.
*/
export function rejectSuggestion(editor: Editor, suggestionId: string) {
const { state } = editor;
- const { doc, tr } = state;
+ const { tr } = state;
- doc.descendants((node, pos) => {
+ const insertRanges: [number, number][] = [];
+ const deleteRanges: [number, number, any][] = [];
+
+ state.doc.descendants((node: any, pos: number) => {
if (!node.isText) return;
-
- // Check for suggestionInsert marks — reject = remove text + mark
const insertMark = node.marks.find(
- m => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId
+ (m: any) => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId
);
if (insertMark) {
- tr.delete(pos, pos + node.nodeSize);
- tr.setMeta('suggestion-reject', true);
- return false;
+ insertRanges.push([pos, pos + node.nodeSize]);
+ return;
}
-
- // Check for suggestionDelete marks — reject = remove the mark, keep text
const deleteMark = node.marks.find(
- m => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId
+ (m: any) => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId
);
if (deleteMark) {
- tr.removeMark(pos, pos + node.nodeSize, deleteMark);
- tr.setMeta('suggestion-reject', true);
+ deleteRanges.push([pos, pos + node.nodeSize, deleteMark]);
}
});
+ for (const [from, to] of insertRanges.sort((a, b) => b[0] - a[0])) {
+ tr.delete(from, to);
+ }
+ for (const [from, to, mark] of deleteRanges.sort((a, b) => b[0] - a[0])) {
+ tr.removeMark(from, to, mark);
+ }
+
if (tr.docChanged) {
+ tr.setMeta('suggestion-reject', true);
editor.view.dispatch(tr);
}
}
-/**
- * Accept all suggestions in the document.
- */
+/** Accept all suggestions in the document. */
export function acceptAllSuggestions(editor: Editor) {
const ids = new Set