@filipc77/cowrite 0.6.7 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/cowrite.js +61 -3
- package/dist/bin/cowrite.js.map +1 -1
- package/package.json +1 -1
- package/ui/index.html +22 -1
- package/ui/modules/app.js +90 -0
- package/ui/modules/block-menu.js +251 -0
- package/ui/modules/comment-anchoring.js +112 -0
- package/ui/modules/comment-highlight.js +243 -0
- package/ui/modules/comment-sidebar.js +192 -0
- package/ui/modules/editor.js +117 -0
- package/ui/modules/file-picker.js +74 -0
- package/ui/modules/markdown-sync.js +229 -0
- package/ui/modules/preferences.js +120 -0
- package/ui/modules/state.js +73 -0
- package/ui/modules/toolbar.js +408 -0
- package/ui/modules/undo-manager.js +78 -0
- package/ui/modules/utils.js +34 -0
- package/ui/modules/ws-client.js +63 -0
- package/ui/styles.css +138 -0
- package/ui/client.js +0 -1743
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/** @typedef {import('../../src/types.js').Comment} Comment */
|
|
4
|
+
/** @typedef {import('../../src/types.js').WSServerMessage} WSServerMessage */
|
|
5
|
+
|
|
6
|
+
import { $ } from './utils.js';
|
|
7
|
+
import { state } from './state.js';
|
|
8
|
+
import { initPreferences } from './preferences.js';
|
|
9
|
+
import { initWebSocket, send } from './ws-client.js';
|
|
10
|
+
import { initFilePicker, loadFileList } from './file-picker.js';
|
|
11
|
+
import { initCommentSidebar, renderComments } from './comment-sidebar.js';
|
|
12
|
+
import { initCommentHighlights, applyHighlights, createCommentHighlightExtension } from './comment-highlight.js';
|
|
13
|
+
import { initToolbar, showToolbarForSelection } from './toolbar.js';
|
|
14
|
+
import { initUndoManager, loadUndoStack } from './undo-manager.js';
|
|
15
|
+
import { createEditor, getEditor, isMarkdownFile, isProgrammaticContentUpdate } from './editor.js';
|
|
16
|
+
import { handleFileUpdate, applyPendingUpdate, submitEdit, reRenderContent } from './markdown-sync.js';
|
|
17
|
+
import { createSlashCommandExtension, blockCommandInProgress } from './block-menu.js';
|
|
18
|
+
|
|
19
|
+
const commentCountEl = $("#commentCount");
|
|
20
|
+
const undoBtn = /** @type {HTMLButtonElement} */ ($("#undoBtn"));
|
|
21
|
+
const fileContentEl = $("#fileContent");
|
|
22
|
+
|
|
23
|
+
// Initialize preferences (sidebar resize, theme, font size)
|
|
24
|
+
// Pass reRenderContent as callback for theme changes that need to re-render mermaid/highlights
|
|
25
|
+
initPreferences(reRenderContent);
|
|
26
|
+
|
|
27
|
+
// Initialize all modules
|
|
28
|
+
initFilePicker();
|
|
29
|
+
initCommentSidebar();
|
|
30
|
+
initCommentHighlights();
|
|
31
|
+
initToolbar();
|
|
32
|
+
initUndoManager();
|
|
33
|
+
|
|
34
|
+
// Create the TipTap editor, mounting it into #fileContent
|
|
35
|
+
createEditor(fileContentEl, {
|
|
36
|
+
extensions: [createCommentHighlightExtension(), createSlashCommandExtension()],
|
|
37
|
+
onUpdate(markdown) {
|
|
38
|
+
if (isProgrammaticContentUpdate()) return;
|
|
39
|
+
state.editorDirty = true;
|
|
40
|
+
},
|
|
41
|
+
onSelectionUpdate({ editor }) {
|
|
42
|
+
if (isMarkdownFile(state.currentFile)) {
|
|
43
|
+
showToolbarForSelection(editor);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Wire editor blur to apply pending updates and submit edits
|
|
49
|
+
fileContentEl.addEventListener('focusout', (e) => {
|
|
50
|
+
// Only act when focus leaves the ProseMirror editor entirely
|
|
51
|
+
// (not when focus moves within it, e.g. between nodes)
|
|
52
|
+
setTimeout(() => {
|
|
53
|
+
if (blockCommandInProgress) return;
|
|
54
|
+
const proseMirror = fileContentEl.querySelector('.ProseMirror');
|
|
55
|
+
if (proseMirror && proseMirror.contains(document.activeElement)) return;
|
|
56
|
+
submitEdit(); // Send user changes first
|
|
57
|
+
applyPendingUpdate(); // Then apply queued server update
|
|
58
|
+
}, 100);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Initialize WebSocket and wire up message handlers
|
|
62
|
+
initWebSocket({
|
|
63
|
+
onFileUpdate(msg) {
|
|
64
|
+
handleFileUpdate(msg);
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
onCommentsUpdate(msg) {
|
|
68
|
+
state.comments = msg.comments;
|
|
69
|
+
commentCountEl.textContent = String(state.comments.filter(c => c.status !== "resolved").length);
|
|
70
|
+
renderComments();
|
|
71
|
+
applyHighlights(getEditor(), isMarkdownFile(state.currentFile));
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
onError(msg) {
|
|
75
|
+
console.error("Server error:", msg.message);
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
onOpen() {
|
|
79
|
+
// If URL has ?file= param, switch to that file
|
|
80
|
+
const params = new URLSearchParams(location.search);
|
|
81
|
+
const fileParam = params.get("file");
|
|
82
|
+
if (fileParam) {
|
|
83
|
+
send({ type: "switch_file", file: fileParam });
|
|
84
|
+
state.undoStack = loadUndoStack(fileParam);
|
|
85
|
+
undoBtn.disabled = state.undoStack.length === 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
loadFileList();
|
|
89
|
+
},
|
|
90
|
+
});
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { Extension } from '@tiptap/core';
|
|
3
|
+
import Suggestion from '@tiptap/suggestion';
|
|
4
|
+
|
|
5
|
+
/** True while a block command is being applied — prevents focusout from submitting edits. */
|
|
6
|
+
export let blockCommandInProgress = false;
|
|
7
|
+
|
|
8
|
+
const BLOCK_TYPES = [
|
|
9
|
+
{ id: 'text', label: 'Text', category: 'Basic blocks', icon: 'Aa', command: (editor) => editor.chain().focus().setParagraph().run() },
|
|
10
|
+
{ id: 'h1', label: 'Heading 1', category: 'Basic blocks', icon: 'H1', command: (editor) => editor.chain().focus().setHeading({ level: 1 }).run() },
|
|
11
|
+
{ id: 'h2', label: 'Heading 2', category: 'Basic blocks', icon: 'H2', command: (editor) => editor.chain().focus().setHeading({ level: 2 }).run() },
|
|
12
|
+
{ id: 'h3', label: 'Heading 3', category: 'Basic blocks', icon: 'H3', command: (editor) => editor.chain().focus().setHeading({ level: 3 }).run() },
|
|
13
|
+
{ id: 'bullet', label: 'Bulleted list', category: 'Basic blocks', icon: '\u2022', command: (editor) => editor.chain().focus().toggleBulletList().run() },
|
|
14
|
+
{ id: 'number', label: 'Numbered list', category: 'Basic blocks', icon: '1.', command: (editor) => editor.chain().focus().toggleOrderedList().run() },
|
|
15
|
+
{ id: 'task', label: 'Task list', category: 'Basic blocks', icon: '\u2611', command: (editor) => editor.chain().focus().toggleTaskList().run() },
|
|
16
|
+
{ id: 'quote', label: 'Quote', category: 'Basic blocks', icon: '\u201C', command: (editor) => editor.chain().focus().toggleBlockquote().run() },
|
|
17
|
+
{ id: 'divider', label: 'Divider', category: 'Basic blocks', icon: '\u2014', command: (editor) => editor.chain().focus().setHorizontalRule().run() },
|
|
18
|
+
{ id: 'code', label: 'Code block', category: 'Advanced', icon: '</>', command: (editor) => editor.chain().focus().toggleCodeBlock().run() },
|
|
19
|
+
{ id: 'table', label: 'Table', category: 'Advanced', icon: '\u229E', command: (editor) => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Escape HTML entities.
|
|
24
|
+
* @param {string} text
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
function escapeHtml(text) {
|
|
28
|
+
const div = document.createElement('div');
|
|
29
|
+
div.textContent = text;
|
|
30
|
+
return div.innerHTML;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create the popup DOM element.
|
|
35
|
+
* @param {object} props - Suggestion props
|
|
36
|
+
* @param {number} selectedIndex
|
|
37
|
+
* @returns {{ element: HTMLElement, currentItems: Array<object> }}
|
|
38
|
+
*/
|
|
39
|
+
function createPopupElement(props, selectedIndex) {
|
|
40
|
+
const menu = document.createElement('div');
|
|
41
|
+
menu.className = 'block-type-menu';
|
|
42
|
+
menu.style.position = 'fixed';
|
|
43
|
+
menu.style.zIndex = '1001';
|
|
44
|
+
|
|
45
|
+
const list = document.createElement('div');
|
|
46
|
+
list.className = 'block-type-list';
|
|
47
|
+
menu.appendChild(list);
|
|
48
|
+
|
|
49
|
+
const component = { element: menu, currentItems: [], commandFn: props.command || null };
|
|
50
|
+
renderItems(component, props.items, selectedIndex, component.commandFn);
|
|
51
|
+
return component;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Render filtered items into the popup list, grouped by category.
|
|
56
|
+
* @param {{ element: HTMLElement, currentItems: Array<object> }} component
|
|
57
|
+
* @param {Array<object>} items
|
|
58
|
+
* @param {number} selectedIndex
|
|
59
|
+
*/
|
|
60
|
+
function renderItems(component, items, selectedIndex, commandFn) {
|
|
61
|
+
const list = component.element.querySelector('.block-type-list');
|
|
62
|
+
if (!list) return;
|
|
63
|
+
list.innerHTML = '';
|
|
64
|
+
component.currentItems = items;
|
|
65
|
+
|
|
66
|
+
if (items.length === 0) {
|
|
67
|
+
list.innerHTML = '<div class="block-type-empty">No matches</div>';
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let lastCategory = '';
|
|
72
|
+
items.forEach((item, i) => {
|
|
73
|
+
if (item.category !== lastCategory) {
|
|
74
|
+
lastCategory = item.category;
|
|
75
|
+
const header = document.createElement('div');
|
|
76
|
+
header.className = 'block-type-category';
|
|
77
|
+
header.textContent = item.category;
|
|
78
|
+
list.appendChild(header);
|
|
79
|
+
}
|
|
80
|
+
const el = document.createElement('div');
|
|
81
|
+
el.className = 'block-type-item';
|
|
82
|
+
if (i === selectedIndex) el.classList.add('highlighted');
|
|
83
|
+
el.innerHTML = `<span class="block-type-icon">${escapeHtml(item.icon)}</span><span>${escapeHtml(item.label)}</span>`;
|
|
84
|
+
el.addEventListener('mouseenter', () => {
|
|
85
|
+
for (const other of list.querySelectorAll('.block-type-item')) {
|
|
86
|
+
other.classList.remove('highlighted');
|
|
87
|
+
}
|
|
88
|
+
el.classList.add('highlighted');
|
|
89
|
+
});
|
|
90
|
+
el.addEventListener('mousedown', (e) => {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
});
|
|
93
|
+
el.addEventListener('click', () => {
|
|
94
|
+
if (commandFn) {
|
|
95
|
+
blockCommandInProgress = true;
|
|
96
|
+
commandFn(item);
|
|
97
|
+
setTimeout(() => { blockCommandInProgress = false; }, 300);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
list.appendChild(el);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Update the popup with new items.
|
|
106
|
+
* @param {{ element: HTMLElement, currentItems: Array<object> }} component
|
|
107
|
+
* @param {object} props - Suggestion props
|
|
108
|
+
* @param {number} selectedIndex
|
|
109
|
+
*/
|
|
110
|
+
function updatePopupItems(component, props, selectedIndex) {
|
|
111
|
+
component.commandFn = props.command || null;
|
|
112
|
+
component.currentItems = props.items;
|
|
113
|
+
renderItems(component, props.items, selectedIndex, component.commandFn);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Update the highlighted item in the popup.
|
|
118
|
+
* @param {{ element: HTMLElement, currentItems: Array<object> }} component
|
|
119
|
+
* @param {number} selectedIndex
|
|
120
|
+
*/
|
|
121
|
+
function updateHighlight(component, selectedIndex) {
|
|
122
|
+
const items = component.element.querySelectorAll('.block-type-item');
|
|
123
|
+
items.forEach((el, i) => {
|
|
124
|
+
el.classList.toggle('highlighted', i === selectedIndex);
|
|
125
|
+
});
|
|
126
|
+
// Scroll highlighted item into view
|
|
127
|
+
if (items[selectedIndex]) {
|
|
128
|
+
items[selectedIndex].scrollIntoView({ block: 'nearest' });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Position the popup near the cursor.
|
|
134
|
+
* @param {HTMLElement} element
|
|
135
|
+
* @param {object} props - Suggestion props with clientRect
|
|
136
|
+
*/
|
|
137
|
+
function positionPopup(element, props) {
|
|
138
|
+
const rect = props.clientRect?.();
|
|
139
|
+
if (!rect) return;
|
|
140
|
+
|
|
141
|
+
element.style.left = `${rect.left}px`;
|
|
142
|
+
element.style.top = `${rect.bottom + 4}px`;
|
|
143
|
+
|
|
144
|
+
// Clamp so popup doesn't overflow below viewport
|
|
145
|
+
requestAnimationFrame(() => {
|
|
146
|
+
const popupRect = element.getBoundingClientRect();
|
|
147
|
+
if (popupRect.bottom > window.innerHeight) {
|
|
148
|
+
element.style.top = `${rect.top - popupRect.height - 4}px`;
|
|
149
|
+
}
|
|
150
|
+
if (popupRect.right > window.innerWidth) {
|
|
151
|
+
element.style.left = `${window.innerWidth - popupRect.width - 8}px`;
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Create the slash command TipTap extension.
|
|
158
|
+
* @returns {Extension}
|
|
159
|
+
*/
|
|
160
|
+
export function createSlashCommandExtension() {
|
|
161
|
+
return Extension.create({
|
|
162
|
+
name: 'slashCommand',
|
|
163
|
+
|
|
164
|
+
addOptions() {
|
|
165
|
+
return {
|
|
166
|
+
suggestion: {
|
|
167
|
+
char: '/',
|
|
168
|
+
startOfLine: true,
|
|
169
|
+
command: ({ editor, range, props }) => {
|
|
170
|
+
// Delete the slash command text
|
|
171
|
+
editor.chain().focus().deleteRange(range).run();
|
|
172
|
+
// Execute the block type command
|
|
173
|
+
props.command(editor);
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
addProseMirrorPlugins() {
|
|
180
|
+
return [
|
|
181
|
+
Suggestion({
|
|
182
|
+
editor: this.editor,
|
|
183
|
+
...this.options.suggestion,
|
|
184
|
+
items: ({ query }) => {
|
|
185
|
+
return BLOCK_TYPES.filter(item =>
|
|
186
|
+
item.label.toLowerCase().includes(query.toLowerCase()) ||
|
|
187
|
+
item.id.includes(query.toLowerCase())
|
|
188
|
+
);
|
|
189
|
+
},
|
|
190
|
+
render: () => {
|
|
191
|
+
let component = null;
|
|
192
|
+
let selectedIndex = 0;
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
onStart(props) {
|
|
196
|
+
selectedIndex = 0;
|
|
197
|
+
component = createPopupElement(props, selectedIndex);
|
|
198
|
+
document.body.appendChild(component.element);
|
|
199
|
+
positionPopup(component.element, props);
|
|
200
|
+
},
|
|
201
|
+
onUpdate(props) {
|
|
202
|
+
if (!component) return;
|
|
203
|
+
selectedIndex = 0;
|
|
204
|
+
updatePopupItems(component, props, selectedIndex);
|
|
205
|
+
positionPopup(component.element, props);
|
|
206
|
+
},
|
|
207
|
+
onKeyDown(props) {
|
|
208
|
+
if (!component) return false;
|
|
209
|
+
const items = component.currentItems;
|
|
210
|
+
if (items.length === 0) return false;
|
|
211
|
+
|
|
212
|
+
if (props.event.key === 'ArrowDown') {
|
|
213
|
+
selectedIndex = (selectedIndex + 1) % items.length;
|
|
214
|
+
updateHighlight(component, selectedIndex);
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
if (props.event.key === 'ArrowUp') {
|
|
218
|
+
selectedIndex = (selectedIndex - 1 + items.length) % items.length;
|
|
219
|
+
updateHighlight(component, selectedIndex);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
if (props.event.key === 'Enter') {
|
|
223
|
+
if (items[selectedIndex] && component.commandFn) {
|
|
224
|
+
blockCommandInProgress = true;
|
|
225
|
+
component.commandFn(items[selectedIndex]);
|
|
226
|
+
setTimeout(() => { blockCommandInProgress = false; }, 300);
|
|
227
|
+
}
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
if (props.event.key === 'Escape') {
|
|
231
|
+
component.element.remove();
|
|
232
|
+
component = null;
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
return false;
|
|
236
|
+
},
|
|
237
|
+
onExit() {
|
|
238
|
+
if (component) {
|
|
239
|
+
const el = component.element;
|
|
240
|
+
component = null;
|
|
241
|
+
setTimeout(() => { el.remove(); }, 0);
|
|
242
|
+
}
|
|
243
|
+
selectedIndex = 0;
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
},
|
|
247
|
+
}),
|
|
248
|
+
];
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create a CommentAnchor from TipTap editor selection.
|
|
5
|
+
* @param {import('@tiptap/core').Editor} editor - TipTap editor instance
|
|
6
|
+
* @param {number} from - ProseMirror selection start
|
|
7
|
+
* @param {number} to - ProseMirror selection end
|
|
8
|
+
* @returns {{textQuote: {exact: string, prefix: string, suffix: string}, offset: number, length: number}}
|
|
9
|
+
*/
|
|
10
|
+
export function createAnchor(editor, from, to) {
|
|
11
|
+
const doc = editor.state.doc;
|
|
12
|
+
|
|
13
|
+
// Get the selected text
|
|
14
|
+
const exact = doc.textBetween(from, to, '\n', '\n');
|
|
15
|
+
|
|
16
|
+
// Get prefix (up to 30 chars before selection)
|
|
17
|
+
const prefixStart = Math.max(0, from - 30);
|
|
18
|
+
const prefix = doc.textBetween(prefixStart, from, '\n', '\n');
|
|
19
|
+
|
|
20
|
+
// Get suffix (up to 30 chars after selection)
|
|
21
|
+
const suffixEnd = Math.min(doc.content.size, to + 30);
|
|
22
|
+
const suffix = doc.textBetween(to, suffixEnd, '\n', '\n');
|
|
23
|
+
|
|
24
|
+
// Compute character offset in the flat text
|
|
25
|
+
const textBefore = doc.textBetween(0, from, '\n', '\n');
|
|
26
|
+
const offset = textBefore.length;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
textQuote: { exact, prefix, suffix },
|
|
30
|
+
offset,
|
|
31
|
+
length: exact.length,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve a comment anchor in current content.
|
|
37
|
+
* Returns {offset, length} or null if orphaned.
|
|
38
|
+
* @param {{textQuote?: {exact: string, prefix: string, suffix: string}, offset: number, length: number}} anchor
|
|
39
|
+
* @param {string} content - Current document text content
|
|
40
|
+
* @returns {{offset: number, length: number} | null}
|
|
41
|
+
*/
|
|
42
|
+
export function resolveAnchor(anchor, content) {
|
|
43
|
+
if (!anchor) return null;
|
|
44
|
+
|
|
45
|
+
const searchText = anchor.textQuote?.exact || '';
|
|
46
|
+
if (!searchText) return null;
|
|
47
|
+
|
|
48
|
+
// Strategy 1: Text quote selector with prefix/suffix scoring
|
|
49
|
+
if (anchor.textQuote) {
|
|
50
|
+
const candidates = [];
|
|
51
|
+
let pos = 0;
|
|
52
|
+
while (pos < content.length) {
|
|
53
|
+
const idx = content.indexOf(searchText, pos);
|
|
54
|
+
if (idx === -1) break;
|
|
55
|
+
|
|
56
|
+
// Score by prefix/suffix match
|
|
57
|
+
const actualPrefix = content.slice(Math.max(0, idx - 30), idx);
|
|
58
|
+
const actualSuffix = content.slice(idx + searchText.length, idx + searchText.length + 30);
|
|
59
|
+
const prefixScore = matchScore(anchor.textQuote.prefix, actualPrefix);
|
|
60
|
+
const suffixScore = matchScore(anchor.textQuote.suffix, actualSuffix);
|
|
61
|
+
const proximityScore = 1000 - Math.min(1000, Math.abs(idx - anchor.offset));
|
|
62
|
+
|
|
63
|
+
candidates.push({
|
|
64
|
+
offset: idx,
|
|
65
|
+
score: prefixScore + suffixScore + proximityScore * 0.1,
|
|
66
|
+
});
|
|
67
|
+
pos = idx + 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (candidates.length > 0) {
|
|
71
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
72
|
+
return { offset: candidates[0].offset, length: searchText.length };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Strategy 2: Try exact text near expected offset (+/-100 chars)
|
|
77
|
+
const windowStart = Math.max(0, anchor.offset - 100);
|
|
78
|
+
const windowEnd = Math.min(content.length, anchor.offset + anchor.length + 100);
|
|
79
|
+
const windowText = content.slice(windowStart, windowEnd);
|
|
80
|
+
const localIdx = windowText.indexOf(searchText);
|
|
81
|
+
if (localIdx !== -1) {
|
|
82
|
+
return { offset: windowStart + localIdx, length: searchText.length };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Strategy 3: Global search
|
|
86
|
+
const globalIdx = content.indexOf(searchText);
|
|
87
|
+
if (globalIdx !== -1) {
|
|
88
|
+
return { offset: globalIdx, length: searchText.length };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return null; // Orphaned
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Score how well two strings match from their boundaries.
|
|
96
|
+
* @param {string} expected
|
|
97
|
+
* @param {string} actual
|
|
98
|
+
* @returns {number}
|
|
99
|
+
*/
|
|
100
|
+
function matchScore(expected, actual) {
|
|
101
|
+
if (!expected || !actual) return 0;
|
|
102
|
+
let score = 0;
|
|
103
|
+
const minLen = Math.min(expected.length, actual.length);
|
|
104
|
+
for (let i = 0; i < minLen; i++) {
|
|
105
|
+
if (expected[expected.length - 1 - i] === actual[actual.length - 1 - i]) {
|
|
106
|
+
score++;
|
|
107
|
+
} else {
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return score;
|
|
112
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { $ } from './utils.js';
|
|
4
|
+
import { state } from './state.js';
|
|
5
|
+
import { resolveAnchor } from './comment-anchoring.js';
|
|
6
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
|
7
|
+
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
|
8
|
+
import { Extension } from '@tiptap/core';
|
|
9
|
+
|
|
10
|
+
const commentHighlightKey = new PluginKey('commentHighlight');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create the CommentHighlight TipTap extension.
|
|
14
|
+
* This extension manages a DecorationSet for comment highlights.
|
|
15
|
+
*/
|
|
16
|
+
export function createCommentHighlightExtension() {
|
|
17
|
+
return Extension.create({
|
|
18
|
+
name: 'commentHighlight',
|
|
19
|
+
|
|
20
|
+
addProseMirrorPlugins() {
|
|
21
|
+
return [
|
|
22
|
+
new Plugin({
|
|
23
|
+
key: commentHighlightKey,
|
|
24
|
+
state: {
|
|
25
|
+
init() {
|
|
26
|
+
return DecorationSet.empty;
|
|
27
|
+
},
|
|
28
|
+
apply(tr, oldSet) {
|
|
29
|
+
const meta = tr.getMeta(commentHighlightKey);
|
|
30
|
+
if (meta?.decorations) {
|
|
31
|
+
return meta.decorations;
|
|
32
|
+
}
|
|
33
|
+
// Remap existing decorations through document changes
|
|
34
|
+
if (tr.docChanged) {
|
|
35
|
+
return oldSet.map(tr.mapping, tr.doc);
|
|
36
|
+
}
|
|
37
|
+
return oldSet;
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
props: {
|
|
41
|
+
decorations(editorState) {
|
|
42
|
+
return commentHighlightKey.getState(editorState);
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
];
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Apply comment highlights using ProseMirror decorations for markdown files,
|
|
53
|
+
* or DOM wrapping for non-markdown files.
|
|
54
|
+
* @param {import('@tiptap/core').Editor} [editor] - TipTap editor instance (optional)
|
|
55
|
+
* @param {boolean} [isMarkdown] - Whether the current file is markdown
|
|
56
|
+
*/
|
|
57
|
+
export function applyHighlights(editor, isMarkdown) {
|
|
58
|
+
if (editor && isMarkdown) {
|
|
59
|
+
applyProseMirrorHighlights(editor);
|
|
60
|
+
} else {
|
|
61
|
+
applyDomHighlights();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Apply highlights via ProseMirror decorations.
|
|
67
|
+
* @param {import('@tiptap/core').Editor} editor
|
|
68
|
+
*/
|
|
69
|
+
function applyProseMirrorHighlights(editor) {
|
|
70
|
+
const doc = editor.state.doc;
|
|
71
|
+
const decorations = [];
|
|
72
|
+
|
|
73
|
+
for (const comment of state.comments) {
|
|
74
|
+
if (!comment.selectedText) continue;
|
|
75
|
+
|
|
76
|
+
// Build an anchor-like object from the comment
|
|
77
|
+
const anchor = comment.anchor || {
|
|
78
|
+
textQuote: { exact: comment.selectedText, prefix: '', suffix: '' },
|
|
79
|
+
offset: comment.offset,
|
|
80
|
+
length: comment.length,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Get the full text from ProseMirror doc
|
|
84
|
+
const fullText = doc.textBetween(0, doc.content.size, '\n', '\n');
|
|
85
|
+
const resolved = resolveAnchor(anchor, fullText);
|
|
86
|
+
|
|
87
|
+
if (!resolved) continue;
|
|
88
|
+
|
|
89
|
+
// Map text offset to ProseMirror position
|
|
90
|
+
const pmFrom = textOffsetToPmPos(doc, resolved.offset);
|
|
91
|
+
const pmTo = textOffsetToPmPos(doc, resolved.offset + resolved.length);
|
|
92
|
+
|
|
93
|
+
if (pmFrom === null || pmTo === null) continue;
|
|
94
|
+
if (pmFrom >= pmTo || pmTo > doc.content.size) continue;
|
|
95
|
+
|
|
96
|
+
decorations.push(
|
|
97
|
+
Decoration.inline(pmFrom, pmTo, {
|
|
98
|
+
class: `comment-highlight ${comment.status}`,
|
|
99
|
+
'data-comment-id': comment.id,
|
|
100
|
+
title: comment.comment,
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const decoSet = DecorationSet.create(doc, decorations);
|
|
106
|
+
|
|
107
|
+
// Dispatch a transaction with our decorations as metadata
|
|
108
|
+
const tr = editor.state.tr;
|
|
109
|
+
tr.setMeta(commentHighlightKey, { decorations: decoSet });
|
|
110
|
+
editor.view.dispatch(tr);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Convert a text offset (character position in flat text) to a ProseMirror position.
|
|
115
|
+
* Mirrors the traversal logic of doc.textBetween(0, size, '\n', '\n') exactly,
|
|
116
|
+
* tracking both the flat-text offset and the corresponding PM position.
|
|
117
|
+
* @param {import('@tiptap/pm/model').Node} doc
|
|
118
|
+
* @param {number} targetOffset
|
|
119
|
+
* @returns {number | null}
|
|
120
|
+
*/
|
|
121
|
+
function textOffsetToPmPos(doc, targetOffset) {
|
|
122
|
+
let textPos = 0;
|
|
123
|
+
let separated = true; // mirrors textBetween initial state
|
|
124
|
+
let result = null;
|
|
125
|
+
|
|
126
|
+
doc.descendants((node, pos) => {
|
|
127
|
+
if (result !== null) return false;
|
|
128
|
+
|
|
129
|
+
if (node.isText) {
|
|
130
|
+
const len = node.text.length;
|
|
131
|
+
if (textPos + len >= targetOffset) {
|
|
132
|
+
result = pos + (targetOffset - textPos);
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
textPos += len;
|
|
136
|
+
separated = false;
|
|
137
|
+
return false; // text nodes have no children
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (node.isLeaf) {
|
|
141
|
+
// Non-text leaf (hard break, horizontal rule, etc.)
|
|
142
|
+
// textBetween adds leafText ('\n') for these
|
|
143
|
+
if (textPos >= targetOffset) {
|
|
144
|
+
result = pos;
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
textPos += 1;
|
|
148
|
+
separated = false;
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Block node with children — textBetween inserts '\n' separator
|
|
153
|
+
// when we've already seen text content (separated === false)
|
|
154
|
+
if (!separated && node.isBlock) {
|
|
155
|
+
if (textPos >= targetOffset) {
|
|
156
|
+
result = pos + 1;
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
textPos += 1;
|
|
160
|
+
separated = true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return true; // descend into children
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Fallback: apply highlights via DOM wrapping (for non-markdown files).
|
|
171
|
+
*/
|
|
172
|
+
function applyDomHighlights() {
|
|
173
|
+
const fileContentEl = $('#fileContent');
|
|
174
|
+
const container = fileContentEl.querySelector('.plain-text-container') || fileContentEl;
|
|
175
|
+
|
|
176
|
+
// Remove existing highlights
|
|
177
|
+
for (const el of container.querySelectorAll('.comment-highlight')) {
|
|
178
|
+
const parent = el.parentNode;
|
|
179
|
+
parent.replaceChild(document.createTextNode(el.textContent), el);
|
|
180
|
+
parent.normalize();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (state.comments.length === 0) return;
|
|
184
|
+
|
|
185
|
+
// Build text-node map
|
|
186
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
|
187
|
+
const textNodes = [];
|
|
188
|
+
let totalOffset = 0;
|
|
189
|
+
while (walker.nextNode()) {
|
|
190
|
+
const node = walker.currentNode;
|
|
191
|
+
const text = node.textContent || '';
|
|
192
|
+
textNodes.push({ node, start: totalOffset, end: totalOffset + text.length });
|
|
193
|
+
totalOffset += text.length;
|
|
194
|
+
}
|
|
195
|
+
const fullText = textNodes.map(n => n.node.textContent).join('');
|
|
196
|
+
|
|
197
|
+
for (const comment of state.comments) {
|
|
198
|
+
if (!comment.selectedText) continue;
|
|
199
|
+
const searchText = comment.selectedText;
|
|
200
|
+
let textIdx = fullText.indexOf(searchText, Math.max(0, comment.offset - 50));
|
|
201
|
+
if (textIdx === -1 || Math.abs(textIdx - comment.offset) > 200) {
|
|
202
|
+
textIdx = fullText.indexOf(searchText);
|
|
203
|
+
}
|
|
204
|
+
if (textIdx === -1) continue;
|
|
205
|
+
wrapRange(textNodes, textIdx, textIdx + searchText.length, comment);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function wrapRange(textNodes, start, end, comment) {
|
|
210
|
+
for (let i = 0; i < textNodes.length; i++) {
|
|
211
|
+
const tn = textNodes[i];
|
|
212
|
+
if (tn.end <= start || tn.start >= end) continue;
|
|
213
|
+
const nodeStart = Math.max(start - tn.start, 0);
|
|
214
|
+
const nodeEnd = Math.min(end - tn.start, tn.node.textContent.length);
|
|
215
|
+
const range = document.createRange();
|
|
216
|
+
range.setStart(tn.node, nodeStart);
|
|
217
|
+
range.setEnd(tn.node, nodeEnd);
|
|
218
|
+
const span = document.createElement('span');
|
|
219
|
+
span.className = `comment-highlight ${comment.status}`;
|
|
220
|
+
span.dataset.commentId = comment.id;
|
|
221
|
+
span.title = comment.comment;
|
|
222
|
+
try {
|
|
223
|
+
range.surroundContents(span);
|
|
224
|
+
return;
|
|
225
|
+
} catch {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Initialize comment highlight click behavior.
|
|
233
|
+
*/
|
|
234
|
+
export function initCommentHighlights() {
|
|
235
|
+
const commentListEl = $('#commentList');
|
|
236
|
+
document.addEventListener('mousedown', (e) => {
|
|
237
|
+
if (!e.target.closest('.comment-highlight')) {
|
|
238
|
+
for (const card of commentListEl.querySelectorAll('.comment-card.active')) {
|
|
239
|
+
card.classList.remove('active');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|