@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.
@@ -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
+ }