@filipc77/cowrite 0.4.28 → 0.6.0
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/README.md +31 -18
- package/dist/bin/cowrite.js +154 -45
- package/dist/bin/cowrite.js.map +1 -1
- package/package.json +5 -2
- package/ui/client.js +1011 -112
- package/ui/index.html +20 -2
- package/ui/styles.css +435 -147
package/ui/client.js
CHANGED
|
@@ -12,9 +12,15 @@ const statusEl = $("#status");
|
|
|
12
12
|
const popup = $("#commentPopup");
|
|
13
13
|
const popupSelection = $("#popupSelection");
|
|
14
14
|
const commentInput = $("#commentInput");
|
|
15
|
+
const selectionToolbar = $("#selectionToolbar");
|
|
15
16
|
const commentTrigger = $("#commentTrigger");
|
|
17
|
+
const highlightPopover = $("#highlightPopover");
|
|
18
|
+
const highlightPopoverText = $("#highlightPopoverText");
|
|
19
|
+
const highlightEditBtn = $("#highlightEditBtn");
|
|
20
|
+
const undoBtn = $("#undoBtn");
|
|
16
21
|
const filePicker = $("#filePicker");
|
|
17
22
|
const fileList = $("#fileList");
|
|
23
|
+
const fileCommentBtn = $("#fileCommentBtn");
|
|
18
24
|
|
|
19
25
|
/** @type {Comment[]} */
|
|
20
26
|
let comments = [];
|
|
@@ -23,6 +29,35 @@ let currentContent = "";
|
|
|
23
29
|
let ws = null;
|
|
24
30
|
let selectionInfo = null;
|
|
25
31
|
let currentHtml = "";
|
|
32
|
+
let currentBlocks = [];
|
|
33
|
+
let insertBtn = null;
|
|
34
|
+
let insertLine = null;
|
|
35
|
+
let activeGapIndex = -1;
|
|
36
|
+
let activeHighlightCommentId = null;
|
|
37
|
+
let undoStack = [];
|
|
38
|
+
const MAX_UNDO = 50;
|
|
39
|
+
|
|
40
|
+
// Click-to-edit state
|
|
41
|
+
let editingBlockIndex = -1;
|
|
42
|
+
let editingBlockEl = null;
|
|
43
|
+
let editingOriginalSource = "";
|
|
44
|
+
let editingContentSnapshot = "";
|
|
45
|
+
let pendingFileUpdate = null;
|
|
46
|
+
let pendingEditAfterInsert = -1;
|
|
47
|
+
let contentEditableActive = false;
|
|
48
|
+
|
|
49
|
+
const BLOCK_TYPES = [
|
|
50
|
+
{ id: "text", label: "Text", category: "Basic blocks", icon: "Aa", template: "\u200B" },
|
|
51
|
+
{ id: "h1", label: "Heading 1", category: "Basic blocks", icon: "H1", template: "# " },
|
|
52
|
+
{ id: "h2", label: "Heading 2", category: "Basic blocks", icon: "H2", template: "## " },
|
|
53
|
+
{ id: "h3", label: "Heading 3", category: "Basic blocks", icon: "H3", template: "### " },
|
|
54
|
+
{ id: "bullet", label: "Bulleted list", category: "Basic blocks", icon: "\u2022", template: "- " },
|
|
55
|
+
{ id: "number", label: "Numbered list", category: "Basic blocks", icon: "1.", template: "1. " },
|
|
56
|
+
{ id: "quote", label: "Quote", category: "Basic blocks", icon: "\u201C", template: "> " },
|
|
57
|
+
{ id: "divider", label: "Divider", category: "Basic blocks", icon: "\u2014", template: "---" },
|
|
58
|
+
{ id: "code", label: "Code block", category: "Advanced", icon: "</>", template: "```\n\n```" },
|
|
59
|
+
{ id: "table", label: "Table", category: "Advanced", icon: "\u229E", template: "| Column 1 | Column 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |" },
|
|
60
|
+
];
|
|
26
61
|
|
|
27
62
|
// --- File Picker ---
|
|
28
63
|
|
|
@@ -32,6 +67,7 @@ async function loadFileList() {
|
|
|
32
67
|
const data = await res.json();
|
|
33
68
|
fileList.innerHTML = "";
|
|
34
69
|
for (const file of data.files) {
|
|
70
|
+
if (!/\.(md|markdown|mdx)$/i.test(file)) continue;
|
|
35
71
|
const option = document.createElement("option");
|
|
36
72
|
option.value = file;
|
|
37
73
|
fileList.appendChild(option);
|
|
@@ -43,6 +79,9 @@ async function loadFileList() {
|
|
|
43
79
|
|
|
44
80
|
function switchFile(file) {
|
|
45
81
|
if (!file || !ws || ws.readyState !== WebSocket.OPEN) return;
|
|
82
|
+
if (currentFile) saveUndoStack(currentFile);
|
|
83
|
+
undoStack = loadUndoStack(file);
|
|
84
|
+
undoBtn.disabled = undoStack.length === 0;
|
|
46
85
|
send({ type: "switch_file", file });
|
|
47
86
|
filePicker.value = "";
|
|
48
87
|
// Update URL without reload
|
|
@@ -78,6 +117,8 @@ function connect() {
|
|
|
78
117
|
const fileParam = params.get("file");
|
|
79
118
|
if (fileParam) {
|
|
80
119
|
send({ type: "switch_file", file: fileParam });
|
|
120
|
+
undoStack = loadUndoStack(fileParam);
|
|
121
|
+
undoBtn.disabled = undoStack.length === 0;
|
|
81
122
|
}
|
|
82
123
|
|
|
83
124
|
loadFileList();
|
|
@@ -94,13 +135,15 @@ function connect() {
|
|
|
94
135
|
const msg = JSON.parse(event.data);
|
|
95
136
|
switch (msg.type) {
|
|
96
137
|
case "file_update":
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
138
|
+
closeBlockTypeMenu();
|
|
139
|
+
if (editingBlockIndex !== -1) {
|
|
140
|
+
pendingFileUpdate = msg;
|
|
141
|
+
currentFile = msg.file;
|
|
142
|
+
currentContent = msg.content;
|
|
143
|
+
currentHtml = msg.html;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
applyFileUpdate(msg);
|
|
104
147
|
break;
|
|
105
148
|
case "comments_update":
|
|
106
149
|
comments = msg.comments;
|
|
@@ -121,11 +164,56 @@ function send(msg) {
|
|
|
121
164
|
}
|
|
122
165
|
}
|
|
123
166
|
|
|
167
|
+
function applyFileUpdate(msg) {
|
|
168
|
+
const fileChanged = currentFile !== msg.file;
|
|
169
|
+
currentFile = msg.file;
|
|
170
|
+
currentContent = msg.content;
|
|
171
|
+
currentHtml = msg.html;
|
|
172
|
+
if (fileChanged && msg.file) {
|
|
173
|
+
undoStack = loadUndoStack(msg.file);
|
|
174
|
+
undoBtn.disabled = undoStack.length === 0;
|
|
175
|
+
}
|
|
176
|
+
filePathEl.textContent = msg.file;
|
|
177
|
+
fileContentEl.innerHTML = msg.html;
|
|
178
|
+
insertBtn = null;
|
|
179
|
+
insertLine = null;
|
|
180
|
+
renderMermaidDiagrams();
|
|
181
|
+
applyHighlights();
|
|
182
|
+
updateBlockMap();
|
|
183
|
+
|
|
184
|
+
if (pendingEditAfterInsert !== -1) {
|
|
185
|
+
const idx = pendingEditAfterInsert;
|
|
186
|
+
pendingEditAfterInsert = -1;
|
|
187
|
+
if (idx >= 0 && idx < currentBlocks.length) {
|
|
188
|
+
// Enter edit mode synchronously so editingBlockIndex is set before
|
|
189
|
+
// any subsequent file_update messages can re-render the DOM
|
|
190
|
+
enterBlockEditDispatch(idx);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function reRenderContent() {
|
|
196
|
+
fileContentEl.innerHTML = currentHtml;
|
|
197
|
+
insertBtn = null;
|
|
198
|
+
insertLine = null;
|
|
199
|
+
renderMermaidDiagrams();
|
|
200
|
+
applyHighlights();
|
|
201
|
+
updateBlockMap();
|
|
202
|
+
}
|
|
203
|
+
|
|
124
204
|
// --- Selection & Comment Creation ---
|
|
125
205
|
|
|
206
|
+
document.addEventListener("mousedown", (e) => {
|
|
207
|
+
if (!selectionToolbar.contains(e.target) &&
|
|
208
|
+
!popup.contains(e.target) &&
|
|
209
|
+
!$("#sidebar").contains(e.target)) {
|
|
210
|
+
hideTrigger();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
126
214
|
document.addEventListener("mouseup", (e) => {
|
|
127
|
-
// Don't trigger when clicking inside popup,
|
|
128
|
-
if (popup.contains(e.target) ||
|
|
215
|
+
// Don't trigger when clicking inside popup, toolbar, or sidebar
|
|
216
|
+
if (popup.contains(e.target) || selectionToolbar.contains(e.target) || $("#sidebar").contains(e.target)) return;
|
|
129
217
|
|
|
130
218
|
const selection = window.getSelection();
|
|
131
219
|
if (!selection || selection.isCollapsed) {
|
|
@@ -148,34 +236,66 @@ document.addEventListener("mouseup", (e) => {
|
|
|
148
236
|
|
|
149
237
|
selectionInfo = { offset, length: text.length, selectedText: text };
|
|
150
238
|
|
|
151
|
-
// Show the small "Comment" trigger button near the selection end
|
|
152
239
|
const range = selection.getRangeAt(0);
|
|
153
240
|
const rect = range.getBoundingClientRect();
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
241
|
+
selectionToolbar.style.left = `${rect.left + rect.width / 2 - 40}px`;
|
|
242
|
+
selectionToolbar.style.top = `${rect.top - 40}px`;
|
|
243
|
+
selectionToolbar.hidden = false;
|
|
157
244
|
});
|
|
158
245
|
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
// Prevent the mousedown from clearing the text selection
|
|
246
|
+
// Prevent the mousedown from clearing the text selection
|
|
247
|
+
selectionToolbar.addEventListener("mousedown", (e) => {
|
|
162
248
|
e.preventDefault();
|
|
163
249
|
});
|
|
164
250
|
|
|
251
|
+
// Clicking Comment opens the full comment popup
|
|
165
252
|
commentTrigger.addEventListener("click", () => {
|
|
166
253
|
if (!selectionInfo) return;
|
|
167
254
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
popup
|
|
255
|
+
const toolbarRect = selectionToolbar.getBoundingClientRect();
|
|
256
|
+
let left = Math.min(toolbarRect.left, window.innerWidth - 360);
|
|
257
|
+
let top = toolbarRect.bottom + 8;
|
|
258
|
+
// Clamp so popup doesn't overflow below the viewport
|
|
259
|
+
const popupHeight = 280;
|
|
260
|
+
if (top + popupHeight > window.innerHeight) {
|
|
261
|
+
top = Math.max(8, toolbarRect.top - popupHeight - 8);
|
|
262
|
+
}
|
|
263
|
+
popup.style.left = `${Math.max(8, left)}px`;
|
|
264
|
+
popup.style.top = `${top}px`;
|
|
172
265
|
popupSelection.textContent = selectionInfo.selectedText;
|
|
173
266
|
commentInput.value = "";
|
|
174
267
|
popup.hidden = false;
|
|
175
|
-
|
|
268
|
+
selectionToolbar.hidden = true;
|
|
176
269
|
commentInput.focus();
|
|
177
270
|
});
|
|
178
271
|
|
|
272
|
+
// Make comment popup draggable via its header
|
|
273
|
+
{
|
|
274
|
+
const popupHeader = popup.querySelector(".popup-header");
|
|
275
|
+
let dragging = false, dragStartX = 0, dragStartY = 0, popupStartX = 0, popupStartY = 0;
|
|
276
|
+
|
|
277
|
+
popupHeader.addEventListener("mousedown", (e) => {
|
|
278
|
+
dragging = true;
|
|
279
|
+
dragStartX = e.clientX;
|
|
280
|
+
dragStartY = e.clientY;
|
|
281
|
+
popupStartX = popup.offsetLeft;
|
|
282
|
+
popupStartY = popup.offsetTop;
|
|
283
|
+
e.preventDefault();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
document.addEventListener("mousemove", (e) => {
|
|
287
|
+
if (!dragging) return;
|
|
288
|
+
const dx = e.clientX - dragStartX;
|
|
289
|
+
const dy = e.clientY - dragStartY;
|
|
290
|
+
popup.style.left = `${popupStartX + dx}px`;
|
|
291
|
+
popup.style.top = `${popupStartY + dy}px`;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
document.addEventListener("mouseup", () => {
|
|
295
|
+
dragging = false;
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
179
299
|
function computeOffset(selection, text) {
|
|
180
300
|
const range = selection.getRangeAt(0);
|
|
181
301
|
const startNode = range.startContainer;
|
|
@@ -187,106 +307,49 @@ function computeOffset(selection, text) {
|
|
|
187
307
|
lineEl = lineEl.parentElement;
|
|
188
308
|
}
|
|
189
309
|
|
|
190
|
-
if (lineEl?.dataset?.offset
|
|
191
|
-
//
|
|
310
|
+
if (lineEl?.dataset?.offset) {
|
|
311
|
+
// Plain text mode: use data-offset directly
|
|
192
312
|
const lineOffset = parseInt(lineEl.dataset.offset, 10);
|
|
193
|
-
//
|
|
194
|
-
const walker = document.createTreeWalker(lineEl, NodeFilter.SHOW_TEXT);
|
|
313
|
+
// Count characters before the selection start within this line span
|
|
195
314
|
let charsBefore = 0;
|
|
315
|
+
const walker = document.createTreeWalker(lineEl, NodeFilter.SHOW_TEXT);
|
|
196
316
|
while (walker.nextNode()) {
|
|
197
317
|
if (walker.currentNode === startNode) {
|
|
198
|
-
|
|
318
|
+
charsBefore += startCharOffset;
|
|
319
|
+
break;
|
|
199
320
|
}
|
|
200
|
-
charsBefore += walker.currentNode.textContent
|
|
201
|
-
}
|
|
202
|
-
// If the start node wasn't found in this line, the selection might start
|
|
203
|
-
// at the line element boundary itself
|
|
204
|
-
return lineOffset;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Fallback: multi-strategy search for rendered text in raw source
|
|
208
|
-
// (selection.toString() strips markdown syntax like #, **, etc.)
|
|
209
|
-
|
|
210
|
-
// Strategy 1: exact match
|
|
211
|
-
const exactIdx = currentContent.indexOf(text);
|
|
212
|
-
if (exactIdx !== -1) return exactIdx;
|
|
213
|
-
|
|
214
|
-
// Strategy 2: progressive prefix matching — shorter prefixes are more likely
|
|
215
|
-
// to appear verbatim in source even when selection spans markdown elements
|
|
216
|
-
for (const len of [200, 100, 50, 30, 15]) {
|
|
217
|
-
if (text.length <= len) continue;
|
|
218
|
-
const prefix = text.slice(0, len);
|
|
219
|
-
const idx = currentContent.indexOf(prefix);
|
|
220
|
-
if (idx !== -1) return idx;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Strategy 3: line-by-line search — find the first line of the selection in source
|
|
224
|
-
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
225
|
-
for (const line of lines) {
|
|
226
|
-
if (line.length < 3) continue;
|
|
227
|
-
const idx = currentContent.indexOf(line);
|
|
228
|
-
if (idx !== -1) return idx;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Strategy 4: DOM position estimation — walk text nodes to compute the
|
|
232
|
-
// selection's DOM offset, then map proportionally to source offset and
|
|
233
|
-
// search nearby for a matching line
|
|
234
|
-
const domOffset = computeDomOffset(range.startContainer, range.startOffset);
|
|
235
|
-
if (domOffset !== -1) {
|
|
236
|
-
const fullTextLen = fileContentEl.textContent?.length || 1;
|
|
237
|
-
const ratio = domOffset / fullTextLen;
|
|
238
|
-
const estimatedSrcOffset = Math.floor(ratio * currentContent.length);
|
|
239
|
-
|
|
240
|
-
// Search for any selection line near the estimated offset
|
|
241
|
-
const searchRadius = 500;
|
|
242
|
-
const start = Math.max(0, estimatedSrcOffset - searchRadius);
|
|
243
|
-
const end = Math.min(currentContent.length, estimatedSrcOffset + searchRadius);
|
|
244
|
-
const nearby = currentContent.slice(start, end);
|
|
245
|
-
|
|
246
|
-
for (const line of lines) {
|
|
247
|
-
if (line.length < 3) continue;
|
|
248
|
-
const idx = nearby.indexOf(line);
|
|
249
|
-
if (idx !== -1) return start + idx;
|
|
321
|
+
charsBefore += walker.currentNode.textContent?.length || 0;
|
|
250
322
|
}
|
|
251
|
-
|
|
252
|
-
// If no line matched, return the estimated offset as a best guess
|
|
253
|
-
return estimatedSrcOffset;
|
|
323
|
+
return lineOffset + charsBefore;
|
|
254
324
|
}
|
|
255
325
|
|
|
256
|
-
|
|
257
|
-
|
|
326
|
+
// Markdown mode: search for the text in current content near visual position
|
|
327
|
+
const idx = currentContent.indexOf(text);
|
|
328
|
+
if (idx !== -1) return idx;
|
|
258
329
|
|
|
259
|
-
function computeDomOffset(node, charOffset) {
|
|
260
|
-
const walker = document.createTreeWalker(fileContentEl, NodeFilter.SHOW_TEXT);
|
|
261
|
-
let offset = 0;
|
|
262
|
-
while (walker.nextNode()) {
|
|
263
|
-
if (walker.currentNode === node) {
|
|
264
|
-
return offset + charOffset;
|
|
265
|
-
}
|
|
266
|
-
offset += (walker.currentNode.textContent || "").length;
|
|
267
|
-
}
|
|
268
330
|
return -1;
|
|
269
331
|
}
|
|
270
332
|
|
|
271
333
|
function hideTrigger() {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if (popup.hidden) {
|
|
275
|
-
selectionInfo = null;
|
|
276
|
-
}
|
|
334
|
+
selectionToolbar.hidden = true;
|
|
335
|
+
selectionInfo = null;
|
|
277
336
|
}
|
|
278
337
|
|
|
279
338
|
function hidePopup() {
|
|
280
339
|
popup.hidden = true;
|
|
281
|
-
commentTrigger.hidden = true;
|
|
282
340
|
selectionInfo = null;
|
|
341
|
+
// Reset popup to default state for next use
|
|
342
|
+
popupSelection.hidden = false;
|
|
343
|
+
popup.querySelector(".popup-header").textContent = "Selection";
|
|
344
|
+
commentInput.placeholder = "Leave a comment...";
|
|
283
345
|
}
|
|
284
346
|
|
|
285
|
-
$("#commentSubmit").addEventListener("click", submitComment);
|
|
286
347
|
$("#commentCancel").addEventListener("click", hidePopup);
|
|
348
|
+
$("#commentSubmit").addEventListener("click", submitComment);
|
|
287
349
|
|
|
288
350
|
commentInput.addEventListener("keydown", (e) => {
|
|
289
351
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
352
|
+
e.preventDefault();
|
|
290
353
|
submitComment();
|
|
291
354
|
}
|
|
292
355
|
if (e.key === "Escape") {
|
|
@@ -294,6 +357,24 @@ commentInput.addEventListener("keydown", (e) => {
|
|
|
294
357
|
}
|
|
295
358
|
});
|
|
296
359
|
|
|
360
|
+
fileCommentBtn.addEventListener("click", () => {
|
|
361
|
+
if (!currentFile) return;
|
|
362
|
+
|
|
363
|
+
// Position popup below the button
|
|
364
|
+
const btnRect = fileCommentBtn.getBoundingClientRect();
|
|
365
|
+
popup.style.left = `${Math.max(8, btnRect.left - 280)}px`;
|
|
366
|
+
popup.style.top = `${btnRect.bottom + 8}px`;
|
|
367
|
+
|
|
368
|
+
// Hide the selection preview, set file-comment mode
|
|
369
|
+
popupSelection.hidden = true;
|
|
370
|
+
popup.querySelector(".popup-header").textContent = "File comment";
|
|
371
|
+
commentInput.value = "";
|
|
372
|
+
commentInput.placeholder = "Comment on the whole file...";
|
|
373
|
+
selectionInfo = { offset: 0, length: 0, selectedText: "" };
|
|
374
|
+
popup.hidden = false;
|
|
375
|
+
commentInput.focus();
|
|
376
|
+
});
|
|
377
|
+
|
|
297
378
|
function submitComment() {
|
|
298
379
|
const text = commentInput.value.trim();
|
|
299
380
|
if (!text || !selectionInfo) return;
|
|
@@ -318,7 +399,7 @@ function renderComments() {
|
|
|
318
399
|
commentListEl.innerHTML = `
|
|
319
400
|
<div class="empty-state">
|
|
320
401
|
<p>No comments yet.</p>
|
|
321
|
-
<p>Select text
|
|
402
|
+
<p>Select text to comment, or use + for file comments.</p>
|
|
322
403
|
</div>
|
|
323
404
|
`;
|
|
324
405
|
return;
|
|
@@ -326,7 +407,13 @@ function renderComments() {
|
|
|
326
407
|
|
|
327
408
|
commentListEl.innerHTML = comments.map((c) => `
|
|
328
409
|
<div class="comment-card ${c.status}" data-id="${c.id}">
|
|
329
|
-
<
|
|
410
|
+
<button class="comment-delete-btn" onclick="deleteComment('${c.id}')" title="Delete comment">
|
|
411
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
|
412
|
+
</button>
|
|
413
|
+
${c.selectedText
|
|
414
|
+
? `<div class="comment-selected-text">${escapeHtml(c.selectedText)}</div>`
|
|
415
|
+
: `<div class="comment-file-badge">Whole file</div>`
|
|
416
|
+
}
|
|
330
417
|
<div class="comment-text">${escapeHtml(c.comment)}</div>
|
|
331
418
|
${c.replies.length > 0 ? `
|
|
332
419
|
<div class="comment-replies">
|
|
@@ -342,12 +429,16 @@ function renderComments() {
|
|
|
342
429
|
<span>${timeAgo(c.createdAt)}</span>
|
|
343
430
|
<span class="comment-status ${c.status}">${c.status}</span>
|
|
344
431
|
</div>
|
|
345
|
-
${c.status
|
|
432
|
+
${c.status !== "resolved" ? `
|
|
346
433
|
<div class="comment-actions">
|
|
347
434
|
<button onclick="showReplyForm('${c.id}')">Reply</button>
|
|
348
435
|
<button onclick="resolveComment('${c.id}')">Resolve</button>
|
|
349
436
|
</div>
|
|
350
|
-
` :
|
|
437
|
+
` : `
|
|
438
|
+
<div class="comment-actions">
|
|
439
|
+
<button onclick="reopenComment('${c.id}')">Reopen</button>
|
|
440
|
+
</div>
|
|
441
|
+
`}
|
|
351
442
|
<div class="reply-form" id="reply-form-${c.id}" hidden>
|
|
352
443
|
<textarea rows="2" placeholder="Reply..."></textarea>
|
|
353
444
|
<div class="reply-form-actions">
|
|
@@ -385,6 +476,14 @@ window.resolveComment = function (id) {
|
|
|
385
476
|
send({ type: "comment_resolve", commentId: id });
|
|
386
477
|
};
|
|
387
478
|
|
|
479
|
+
window.reopenComment = function (id) {
|
|
480
|
+
send({ type: "comment_reopen", commentId: id });
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
window.deleteComment = function (id) {
|
|
484
|
+
send({ type: "comment_delete", commentId: id });
|
|
485
|
+
};
|
|
486
|
+
|
|
388
487
|
window.submitReply = function (id) {
|
|
389
488
|
const form = document.getElementById(`reply-form-${id}`);
|
|
390
489
|
const textarea = form?.querySelector("textarea");
|
|
@@ -395,7 +494,7 @@ window.submitReply = function (id) {
|
|
|
395
494
|
form.hidden = true;
|
|
396
495
|
};
|
|
397
496
|
|
|
398
|
-
// ---
|
|
497
|
+
// --- Highlights ---
|
|
399
498
|
|
|
400
499
|
function applyHighlights() {
|
|
401
500
|
// Remove existing highlights
|
|
@@ -421,8 +520,10 @@ function applyHighlights() {
|
|
|
421
520
|
|
|
422
521
|
const fullText = textNodes.map((n) => n.node.textContent).join("");
|
|
423
522
|
|
|
424
|
-
//
|
|
523
|
+
// Highlight pending and answered comments — resolved ones act as regular text
|
|
425
524
|
for (const comment of comments) {
|
|
525
|
+
// Render highlights for all statuses (resolved ones are invisible via CSS but present for click-to-scroll)
|
|
526
|
+
if (!comment.selectedText) continue;
|
|
426
527
|
const searchText = comment.selectedText;
|
|
427
528
|
// Search near the expected offset first, then globally
|
|
428
529
|
let textIdx = fullText.indexOf(searchText, Math.max(0, comment.offset - 50));
|
|
@@ -454,11 +555,8 @@ function wrapRange(textNodes, start, end, comment) {
|
|
|
454
555
|
|
|
455
556
|
try {
|
|
456
557
|
range.surroundContents(span);
|
|
457
|
-
// After wrapping, we need to update remaining text nodes
|
|
458
|
-
// Simply break — for simplicity, one highlight per pass is fine
|
|
459
558
|
return;
|
|
460
559
|
} catch {
|
|
461
|
-
// Range may cross element boundaries; skip this highlight
|
|
462
560
|
return;
|
|
463
561
|
}
|
|
464
562
|
}
|
|
@@ -490,22 +588,31 @@ const THEME_KEY = "cowrite-theme";
|
|
|
490
588
|
function applyTheme(theme) {
|
|
491
589
|
document.documentElement.setAttribute("data-theme", theme);
|
|
492
590
|
themeToggle.checked = theme === "light";
|
|
493
|
-
// Update toggle icon
|
|
494
591
|
const icon = themeToggle.closest(".theme-toggle").querySelector(".toggle-icon");
|
|
495
592
|
if (icon) icon.textContent = theme === "light" ? "\u2600" : "\u263E";
|
|
496
|
-
// Update toggle label
|
|
497
593
|
const label = document.querySelector(".toggle-label");
|
|
498
594
|
if (label) label.textContent = theme === "light" ? "Light" : "Dark";
|
|
499
|
-
|
|
595
|
+
const hljsDark = document.getElementById("hljs-dark");
|
|
596
|
+
const hljsLight = document.getElementById("hljs-light");
|
|
597
|
+
if (hljsDark && hljsLight) {
|
|
598
|
+
hljsDark.disabled = theme === "light";
|
|
599
|
+
hljsLight.disabled = theme !== "light";
|
|
600
|
+
}
|
|
601
|
+
const gmcDark = document.getElementById("gmc-dark");
|
|
602
|
+
const gmcLight = document.getElementById("gmc-light");
|
|
603
|
+
if (gmcDark && gmcLight) {
|
|
604
|
+
gmcDark.disabled = theme === "light";
|
|
605
|
+
gmcLight.disabled = theme !== "light";
|
|
606
|
+
}
|
|
500
607
|
if (window.__mermaid && currentHtml) {
|
|
501
608
|
window.__mermaid.initialize({ startOnLoad: false, theme: theme === "light" ? "default" : "dark" });
|
|
502
609
|
fileContentEl.innerHTML = currentHtml;
|
|
503
610
|
renderMermaidDiagrams();
|
|
504
611
|
applyHighlights();
|
|
612
|
+
updateBlockMap();
|
|
505
613
|
}
|
|
506
614
|
}
|
|
507
615
|
|
|
508
|
-
// Load saved preference, default to dark
|
|
509
616
|
const savedTheme = localStorage.getItem(THEME_KEY) || "dark";
|
|
510
617
|
applyTheme(savedTheme);
|
|
511
618
|
|
|
@@ -515,11 +622,10 @@ themeToggle.addEventListener("change", () => {
|
|
|
515
622
|
applyTheme(theme);
|
|
516
623
|
});
|
|
517
624
|
|
|
518
|
-
// Hide trigger when selection is cleared
|
|
625
|
+
// Hide trigger when selection is cleared
|
|
519
626
|
document.addEventListener("selectionchange", () => {
|
|
520
627
|
const selection = window.getSelection();
|
|
521
628
|
if (!selection || selection.isCollapsed) {
|
|
522
|
-
// Small delay to avoid race with the trigger button click
|
|
523
629
|
setTimeout(() => {
|
|
524
630
|
if (popup.hidden) hideTrigger();
|
|
525
631
|
}, 100);
|
|
@@ -539,5 +645,798 @@ async function renderMermaidDiagrams() {
|
|
|
539
645
|
}
|
|
540
646
|
}
|
|
541
647
|
|
|
648
|
+
// --- Block Gutter Insert ---
|
|
649
|
+
|
|
650
|
+
function updateBlockMap() {
|
|
651
|
+
const container = fileContentEl.querySelector(".markdown-body, .plain-text");
|
|
652
|
+
if (!container || !container.dataset.blocks) {
|
|
653
|
+
currentBlocks = [];
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
try {
|
|
657
|
+
currentBlocks = JSON.parse(container.dataset.blocks);
|
|
658
|
+
} catch {
|
|
659
|
+
currentBlocks = [];
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function ensureInsertElements() {
|
|
664
|
+
if (!insertBtn) {
|
|
665
|
+
insertBtn = document.createElement("button");
|
|
666
|
+
insertBtn.className = "block-insert-btn";
|
|
667
|
+
insertBtn.textContent = "+";
|
|
668
|
+
insertBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
|
669
|
+
insertBtn.addEventListener("click", handleInsertClick);
|
|
670
|
+
fileContentEl.appendChild(insertBtn);
|
|
671
|
+
|
|
672
|
+
insertLine = document.createElement("div");
|
|
673
|
+
insertLine.className = "block-insert-line";
|
|
674
|
+
fileContentEl.appendChild(insertLine);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function getBlockChildren() {
|
|
679
|
+
const container = fileContentEl.querySelector(".markdown-body, .plain-text");
|
|
680
|
+
if (!container) return [];
|
|
681
|
+
return Array.from(container.children).filter(
|
|
682
|
+
(el) =>
|
|
683
|
+
!el.classList.contains("block-insert-btn") &&
|
|
684
|
+
!el.classList.contains("block-insert-line") &&
|
|
685
|
+
!el.classList.contains("inline-editor") &&
|
|
686
|
+
!el.classList.contains("block-type-menu") &&
|
|
687
|
+
!el.classList.contains("block-edit-wrapper")
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
$("#contentPanel").addEventListener("mousemove", (e) => {
|
|
692
|
+
if (!currentBlocks.length) return;
|
|
693
|
+
ensureInsertElements();
|
|
694
|
+
|
|
695
|
+
const children = getBlockChildren();
|
|
696
|
+
if (children.length === 0) return;
|
|
697
|
+
|
|
698
|
+
const containerRect = fileContentEl.getBoundingClientRect();
|
|
699
|
+
const mouseY = e.clientY;
|
|
700
|
+
const hitZone = 14;
|
|
701
|
+
let foundGap = -1;
|
|
702
|
+
|
|
703
|
+
// Gap before first block
|
|
704
|
+
const firstRect = children[0].getBoundingClientRect();
|
|
705
|
+
if (mouseY < firstRect.top + hitZone && mouseY > firstRect.top - hitZone * 2) {
|
|
706
|
+
foundGap = 0;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Gaps between blocks
|
|
710
|
+
if (foundGap === -1) {
|
|
711
|
+
for (let i = 0; i < children.length - 1; i++) {
|
|
712
|
+
const bottomOfCurrent = children[i].getBoundingClientRect().bottom;
|
|
713
|
+
const topOfNext = children[i + 1].getBoundingClientRect().top;
|
|
714
|
+
const gapCenter = (bottomOfCurrent + topOfNext) / 2;
|
|
715
|
+
|
|
716
|
+
if (Math.abs(mouseY - gapCenter) < hitZone) {
|
|
717
|
+
foundGap = i + 1;
|
|
718
|
+
break;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Gap after last block
|
|
724
|
+
if (foundGap === -1) {
|
|
725
|
+
const lastRect = children[children.length - 1].getBoundingClientRect();
|
|
726
|
+
if (mouseY > lastRect.bottom - hitZone && mouseY < lastRect.bottom + hitZone * 2) {
|
|
727
|
+
foundGap = children.length;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (foundGap !== -1) {
|
|
732
|
+
activeGapIndex = foundGap;
|
|
733
|
+
let gapY;
|
|
734
|
+
if (foundGap === 0) {
|
|
735
|
+
gapY = children[0].getBoundingClientRect().top - containerRect.top - 8;
|
|
736
|
+
} else if (foundGap === children.length) {
|
|
737
|
+
gapY = children[children.length - 1].getBoundingClientRect().bottom - containerRect.top + 8;
|
|
738
|
+
} else {
|
|
739
|
+
const bottom = children[foundGap - 1].getBoundingClientRect().bottom;
|
|
740
|
+
const top = children[foundGap].getBoundingClientRect().top;
|
|
741
|
+
gapY = (bottom + top) / 2 - containerRect.top;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
insertBtn.style.top = `${gapY}px`;
|
|
745
|
+
insertBtn.classList.add("visible");
|
|
746
|
+
insertLine.style.top = `${gapY}px`;
|
|
747
|
+
insertLine.classList.add("visible");
|
|
748
|
+
} else {
|
|
749
|
+
insertBtn.classList.remove("visible");
|
|
750
|
+
insertLine.classList.remove("visible");
|
|
751
|
+
activeGapIndex = -1;
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
$("#contentPanel").addEventListener("mouseleave", () => {
|
|
756
|
+
if (insertBtn) {
|
|
757
|
+
insertBtn.classList.remove("visible");
|
|
758
|
+
insertLine.classList.remove("visible");
|
|
759
|
+
}
|
|
760
|
+
activeGapIndex = -1;
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
function handleInsertClick() {
|
|
764
|
+
if (activeGapIndex === -1 || !currentBlocks.length) return;
|
|
765
|
+
|
|
766
|
+
let insertOffset;
|
|
767
|
+
if (activeGapIndex === 0) {
|
|
768
|
+
insertOffset = 0;
|
|
769
|
+
} else if (activeGapIndex >= currentBlocks.length) {
|
|
770
|
+
insertOffset = currentContent.length;
|
|
771
|
+
} else {
|
|
772
|
+
insertOffset = currentBlocks[activeGapIndex].sourceStart;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
showBlockTypeMenu(insertOffset, activeGapIndex);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function closeBlockTypeMenu() {
|
|
779
|
+
const existing = fileContentEl.querySelector(".block-type-menu");
|
|
780
|
+
if (existing) existing.remove();
|
|
781
|
+
if (closeBlockTypeMenu._handler) {
|
|
782
|
+
document.removeEventListener("mousedown", closeBlockTypeMenu._handler);
|
|
783
|
+
closeBlockTypeMenu._handler = null;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function showBlockTypeMenu(insertOffset, gapIndex) {
|
|
788
|
+
closeBlockTypeMenu();
|
|
789
|
+
|
|
790
|
+
const container = fileContentEl.querySelector(".markdown-body, .plain-text");
|
|
791
|
+
if (!container) return;
|
|
792
|
+
|
|
793
|
+
if (insertBtn) {
|
|
794
|
+
insertBtn.classList.remove("visible");
|
|
795
|
+
insertLine.classList.remove("visible");
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Capture gapIndex now — activeGapIndex may change as the user moves the mouse to the menu
|
|
799
|
+
const capturedGapIndex = gapIndex;
|
|
800
|
+
|
|
801
|
+
const menu = document.createElement("div");
|
|
802
|
+
menu.className = "block-type-menu";
|
|
803
|
+
|
|
804
|
+
const filter = document.createElement("input");
|
|
805
|
+
filter.className = "block-type-filter";
|
|
806
|
+
filter.placeholder = "Filter...";
|
|
807
|
+
menu.appendChild(filter);
|
|
808
|
+
|
|
809
|
+
const list = document.createElement("div");
|
|
810
|
+
list.className = "block-type-list";
|
|
811
|
+
menu.appendChild(list);
|
|
812
|
+
|
|
813
|
+
let highlightIdx = 0;
|
|
814
|
+
|
|
815
|
+
function renderItems(query) {
|
|
816
|
+
list.innerHTML = "";
|
|
817
|
+
const q = query.toLowerCase();
|
|
818
|
+
const filtered = BLOCK_TYPES.filter(
|
|
819
|
+
(bt) => !q || bt.label.toLowerCase().includes(q) || bt.id.includes(q)
|
|
820
|
+
);
|
|
821
|
+
|
|
822
|
+
if (filtered.length === 0) {
|
|
823
|
+
list.innerHTML = '<div class="block-type-empty">No matches</div>';
|
|
824
|
+
return [];
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
let lastCategory = "";
|
|
828
|
+
const items = [];
|
|
829
|
+
for (const bt of filtered) {
|
|
830
|
+
if (bt.category !== lastCategory) {
|
|
831
|
+
lastCategory = bt.category;
|
|
832
|
+
const header = document.createElement("div");
|
|
833
|
+
header.className = "block-type-category";
|
|
834
|
+
header.textContent = bt.category;
|
|
835
|
+
list.appendChild(header);
|
|
836
|
+
}
|
|
837
|
+
const item = document.createElement("div");
|
|
838
|
+
item.className = "block-type-item";
|
|
839
|
+
item.innerHTML = `<span class="block-type-icon">${escapeHtml(bt.icon)}</span><span>${escapeHtml(bt.label)}</span>`;
|
|
840
|
+
item.addEventListener("click", () => {
|
|
841
|
+
applyBlockTypeInsert(bt, insertOffset, capturedGapIndex);
|
|
842
|
+
closeBlockTypeMenu();
|
|
843
|
+
});
|
|
844
|
+
list.appendChild(item);
|
|
845
|
+
items.push({ el: item, bt });
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
items.forEach((it, i) => {
|
|
849
|
+
it.el.addEventListener("mouseenter", () => {
|
|
850
|
+
highlightIdx = i;
|
|
851
|
+
updateHighlight(items);
|
|
852
|
+
});
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
highlightIdx = Math.min(highlightIdx, items.length - 1);
|
|
856
|
+
updateHighlight(items);
|
|
857
|
+
return items;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function updateHighlight(items) {
|
|
861
|
+
for (let i = 0; i < items.length; i++) {
|
|
862
|
+
items[i].el.classList.toggle("highlighted", i === highlightIdx);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
let currentItems = renderItems("");
|
|
867
|
+
|
|
868
|
+
filter.addEventListener("input", () => {
|
|
869
|
+
highlightIdx = 0;
|
|
870
|
+
currentItems = renderItems(filter.value);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
filter.addEventListener("keydown", (e) => {
|
|
874
|
+
if (e.key === "ArrowDown") {
|
|
875
|
+
e.preventDefault();
|
|
876
|
+
if (currentItems.length > 0) {
|
|
877
|
+
highlightIdx = (highlightIdx + 1) % currentItems.length;
|
|
878
|
+
updateHighlight(currentItems);
|
|
879
|
+
currentItems[highlightIdx].el.scrollIntoView({ block: "nearest" });
|
|
880
|
+
}
|
|
881
|
+
} else if (e.key === "ArrowUp") {
|
|
882
|
+
e.preventDefault();
|
|
883
|
+
if (currentItems.length > 0) {
|
|
884
|
+
highlightIdx = (highlightIdx - 1 + currentItems.length) % currentItems.length;
|
|
885
|
+
updateHighlight(currentItems);
|
|
886
|
+
currentItems[highlightIdx].el.scrollIntoView({ block: "nearest" });
|
|
887
|
+
}
|
|
888
|
+
} else if (e.key === "Enter") {
|
|
889
|
+
e.preventDefault();
|
|
890
|
+
if (currentItems.length > 0 && currentItems[highlightIdx]) {
|
|
891
|
+
applyBlockTypeInsert(currentItems[highlightIdx].bt, insertOffset, capturedGapIndex);
|
|
892
|
+
closeBlockTypeMenu();
|
|
893
|
+
}
|
|
894
|
+
} else if (e.key === "Escape") {
|
|
895
|
+
e.preventDefault();
|
|
896
|
+
closeBlockTypeMenu();
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
const children = getBlockChildren();
|
|
901
|
+
if (gapIndex >= children.length) {
|
|
902
|
+
container.appendChild(menu);
|
|
903
|
+
} else {
|
|
904
|
+
container.insertBefore(menu, children[gapIndex]);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
filter.focus();
|
|
908
|
+
|
|
909
|
+
setTimeout(() => {
|
|
910
|
+
const handler = (e) => {
|
|
911
|
+
if (!menu.contains(e.target)) {
|
|
912
|
+
closeBlockTypeMenu();
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
closeBlockTypeMenu._handler = handler;
|
|
916
|
+
document.addEventListener("mousedown", handler);
|
|
917
|
+
}, 0);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function applyBlockTypeInsert(blockType, insertOffset, gapIndex) {
|
|
921
|
+
const template = blockType.template;
|
|
922
|
+
let newText;
|
|
923
|
+
if (insertOffset === 0) {
|
|
924
|
+
newText = template + "\n\n";
|
|
925
|
+
} else if (insertOffset === currentContent.length) {
|
|
926
|
+
newText = "\n\n" + template;
|
|
927
|
+
} else {
|
|
928
|
+
// Must use \n\n separators so marked treats the new block as a separate paragraph
|
|
929
|
+
newText = "\n\n" + template + "\n\n";
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
if (blockType.id !== "divider") {
|
|
933
|
+
pendingEditAfterInsert = gapIndex;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
pushUndo();
|
|
937
|
+
send({
|
|
938
|
+
type: "edit_apply",
|
|
939
|
+
offset: insertOffset,
|
|
940
|
+
length: 0,
|
|
941
|
+
newText: newText,
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// --- Click-to-edit ---
|
|
946
|
+
|
|
947
|
+
function findClickedBlockIndex(target) {
|
|
948
|
+
const container = fileContentEl.querySelector(".markdown-body, .plain-text");
|
|
949
|
+
if (!container) return -1;
|
|
950
|
+
|
|
951
|
+
let el = target;
|
|
952
|
+
while (el && el.parentElement !== container) {
|
|
953
|
+
el = el.parentElement;
|
|
954
|
+
if (!el) return -1;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const children = getBlockChildren();
|
|
958
|
+
return children.indexOf(el);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function enterBlockEdit(blockIndex) {
|
|
962
|
+
if (editingBlockIndex !== -1) return;
|
|
963
|
+
if (blockIndex < 0 || blockIndex >= currentBlocks.length) return;
|
|
964
|
+
|
|
965
|
+
const block = currentBlocks[blockIndex];
|
|
966
|
+
const source = currentContent.slice(block.sourceStart, block.sourceEnd);
|
|
967
|
+
|
|
968
|
+
const container = fileContentEl.querySelector(".markdown-body, .plain-text");
|
|
969
|
+
if (!container) return;
|
|
970
|
+
|
|
971
|
+
const children = getBlockChildren();
|
|
972
|
+
const blockEl = children[blockIndex];
|
|
973
|
+
if (!blockEl) return;
|
|
974
|
+
|
|
975
|
+
editingBlockIndex = blockIndex;
|
|
976
|
+
editingOriginalSource = source;
|
|
977
|
+
editingContentSnapshot = currentContent;
|
|
978
|
+
|
|
979
|
+
const wrapper = document.createElement("div");
|
|
980
|
+
wrapper.className = "block-edit-wrapper";
|
|
981
|
+
|
|
982
|
+
const textarea = document.createElement("textarea");
|
|
983
|
+
textarea.className = "block-edit-textarea";
|
|
984
|
+
textarea.value = source.replace(/\u200B/g, "");
|
|
985
|
+
wrapper.appendChild(textarea);
|
|
986
|
+
|
|
987
|
+
const hint = document.createElement("div");
|
|
988
|
+
hint.className = "block-edit-hint";
|
|
989
|
+
hint.textContent = "Cmd+Enter to save \u00B7 Escape to cancel";
|
|
990
|
+
wrapper.appendChild(hint);
|
|
991
|
+
|
|
992
|
+
container.replaceChild(wrapper, blockEl);
|
|
993
|
+
editingBlockEl = wrapper;
|
|
994
|
+
|
|
995
|
+
textarea.style.height = "auto";
|
|
996
|
+
textarea.style.height = textarea.scrollHeight + "px";
|
|
997
|
+
|
|
998
|
+
textarea.addEventListener("input", () => {
|
|
999
|
+
textarea.style.height = "auto";
|
|
1000
|
+
textarea.style.height = textarea.scrollHeight + "px";
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
textarea.addEventListener("keydown", (e) => {
|
|
1004
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
1005
|
+
e.preventDefault();
|
|
1006
|
+
commitBlockEdit(textarea.value);
|
|
1007
|
+
} else if (e.key === "Escape") {
|
|
1008
|
+
e.preventDefault();
|
|
1009
|
+
cancelBlockEdit();
|
|
1010
|
+
} else if (e.key === "Tab") {
|
|
1011
|
+
e.preventDefault();
|
|
1012
|
+
const start = textarea.selectionStart;
|
|
1013
|
+
const end = textarea.selectionEnd;
|
|
1014
|
+
textarea.value = textarea.value.substring(0, start) + " " + textarea.value.substring(end);
|
|
1015
|
+
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
|
1016
|
+
textarea.dispatchEvent(new Event("input"));
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
textarea.focus();
|
|
1021
|
+
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
|
1022
|
+
|
|
1023
|
+
// Ensure focus sticks after any pending browser layout/paint
|
|
1024
|
+
requestAnimationFrame(() => {
|
|
1025
|
+
if (document.activeElement !== textarea) {
|
|
1026
|
+
textarea.focus();
|
|
1027
|
+
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
textarea.addEventListener("blur", () => {
|
|
1032
|
+
setTimeout(() => {
|
|
1033
|
+
if (editingBlockIndex !== -1) commitBlockEdit(textarea.value);
|
|
1034
|
+
}, 100);
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function commitBlockEdit(newSource) {
|
|
1039
|
+
if (editingBlockIndex === -1) return;
|
|
1040
|
+
|
|
1041
|
+
const blockIndex = editingBlockIndex;
|
|
1042
|
+
const originalSource = editingOriginalSource;
|
|
1043
|
+
const snapshot = editingContentSnapshot;
|
|
1044
|
+
|
|
1045
|
+
editingBlockIndex = -1;
|
|
1046
|
+
editingBlockEl = null;
|
|
1047
|
+
editingOriginalSource = "";
|
|
1048
|
+
editingContentSnapshot = "";
|
|
1049
|
+
|
|
1050
|
+
if (newSource === originalSource) {
|
|
1051
|
+
if (pendingFileUpdate) {
|
|
1052
|
+
applyFileUpdate(pendingFileUpdate);
|
|
1053
|
+
pendingFileUpdate = null;
|
|
1054
|
+
} else {
|
|
1055
|
+
reRenderContent();
|
|
1056
|
+
}
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
let block;
|
|
1061
|
+
if (currentContent === snapshot) {
|
|
1062
|
+
block = currentBlocks[blockIndex];
|
|
1063
|
+
} else {
|
|
1064
|
+
const tmp = document.createElement("div");
|
|
1065
|
+
tmp.innerHTML = currentHtml;
|
|
1066
|
+
const tmpContainer = tmp.querySelector("[data-blocks]");
|
|
1067
|
+
if (tmpContainer) {
|
|
1068
|
+
try {
|
|
1069
|
+
const updatedBlocks = JSON.parse(tmpContainer.dataset.blocks);
|
|
1070
|
+
if (blockIndex < updatedBlocks.length) {
|
|
1071
|
+
block = updatedBlocks[blockIndex];
|
|
1072
|
+
}
|
|
1073
|
+
} catch {}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
if (!block) {
|
|
1078
|
+
if (pendingFileUpdate) {
|
|
1079
|
+
applyFileUpdate(pendingFileUpdate);
|
|
1080
|
+
pendingFileUpdate = null;
|
|
1081
|
+
} else {
|
|
1082
|
+
reRenderContent();
|
|
1083
|
+
}
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
pushUndo();
|
|
1088
|
+
send({
|
|
1089
|
+
type: "edit_apply",
|
|
1090
|
+
offset: block.sourceStart,
|
|
1091
|
+
length: block.sourceEnd - block.sourceStart,
|
|
1092
|
+
newText: newSource,
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
if (pendingFileUpdate) {
|
|
1096
|
+
applyFileUpdate(pendingFileUpdate);
|
|
1097
|
+
pendingFileUpdate = null;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function cancelBlockEdit() {
|
|
1102
|
+
editingBlockIndex = -1;
|
|
1103
|
+
editingBlockEl = null;
|
|
1104
|
+
editingOriginalSource = "";
|
|
1105
|
+
editingContentSnapshot = "";
|
|
1106
|
+
|
|
1107
|
+
if (pendingFileUpdate) {
|
|
1108
|
+
applyFileUpdate(pendingFileUpdate);
|
|
1109
|
+
pendingFileUpdate = null;
|
|
1110
|
+
} else {
|
|
1111
|
+
reRenderContent();
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// --- Block type detection for hybrid editing ---
|
|
1116
|
+
|
|
1117
|
+
function getBlockType(element) {
|
|
1118
|
+
if (!element || !element.tagName) return "unknown";
|
|
1119
|
+
const tag = element.tagName.toLowerCase();
|
|
1120
|
+
if (tag === "p") return "paragraph";
|
|
1121
|
+
if (/^h[1-6]$/.test(tag)) return "heading";
|
|
1122
|
+
if (tag === "ul" || tag === "ol") return "list";
|
|
1123
|
+
if (tag === "blockquote") return "blockquote";
|
|
1124
|
+
if (element.classList.contains("code-block-wrapper")) return "code";
|
|
1125
|
+
if (tag === "pre") return "code";
|
|
1126
|
+
if (tag === "table") return "table";
|
|
1127
|
+
if (tag === "hr") return "divider";
|
|
1128
|
+
if (element.classList.contains("mermaid-container")) return "mermaid";
|
|
1129
|
+
return "unknown";
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function enterBlockEditDispatch(blockIndex) {
|
|
1133
|
+
if (editingBlockIndex !== -1) return;
|
|
1134
|
+
if (blockIndex < 0 || blockIndex >= currentBlocks.length) return;
|
|
1135
|
+
|
|
1136
|
+
const container = fileContentEl.querySelector(".markdown-body, .plain-text");
|
|
1137
|
+
if (!container) return;
|
|
1138
|
+
|
|
1139
|
+
const children = getBlockChildren();
|
|
1140
|
+
const blockEl = children[blockIndex];
|
|
1141
|
+
if (!blockEl) return;
|
|
1142
|
+
|
|
1143
|
+
const blockType = getBlockType(blockEl);
|
|
1144
|
+
|
|
1145
|
+
if (blockType === "divider" || blockType === "mermaid") return;
|
|
1146
|
+
if (blockType === "code" || blockType === "table" || blockType === "unknown") {
|
|
1147
|
+
enterBlockEdit(blockIndex);
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
enterContentEditable(blockIndex, blockEl);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function enterContentEditable(blockIndex, blockEl) {
|
|
1155
|
+
if (editingBlockIndex !== -1) return;
|
|
1156
|
+
if (blockIndex < 0 || blockIndex >= currentBlocks.length) return;
|
|
1157
|
+
|
|
1158
|
+
const block = currentBlocks[blockIndex];
|
|
1159
|
+
const source = currentContent.slice(block.sourceStart, block.sourceEnd);
|
|
1160
|
+
|
|
1161
|
+
editingBlockIndex = blockIndex;
|
|
1162
|
+
editingOriginalSource = source;
|
|
1163
|
+
editingContentSnapshot = currentContent;
|
|
1164
|
+
contentEditableActive = true;
|
|
1165
|
+
editingBlockEl = blockEl;
|
|
1166
|
+
|
|
1167
|
+
blockEl.contentEditable = "true";
|
|
1168
|
+
blockEl.classList.add("block-editing");
|
|
1169
|
+
|
|
1170
|
+
// For empty blocks (ZWS placeholder), set up a clean empty editable state
|
|
1171
|
+
const isEmpty = !blockEl.textContent.replace(/\u200B/g, "").trim();
|
|
1172
|
+
if (isEmpty) {
|
|
1173
|
+
blockEl.innerHTML = "";
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
blockEl.focus();
|
|
1177
|
+
|
|
1178
|
+
if (isEmpty) {
|
|
1179
|
+
const sel = window.getSelection();
|
|
1180
|
+
const range = document.createRange();
|
|
1181
|
+
range.setStart(blockEl, 0);
|
|
1182
|
+
range.collapse(true);
|
|
1183
|
+
sel.removeAllRanges();
|
|
1184
|
+
sel.addRange(range);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// Ensure focus sticks after any pending browser events
|
|
1188
|
+
setTimeout(() => {
|
|
1189
|
+
if (document.activeElement !== blockEl) {
|
|
1190
|
+
blockEl.focus();
|
|
1191
|
+
}
|
|
1192
|
+
}, 0);
|
|
1193
|
+
|
|
1194
|
+
const blockType = getBlockType(blockEl);
|
|
1195
|
+
|
|
1196
|
+
const onKeydown = (e) => {
|
|
1197
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
1198
|
+
e.preventDefault();
|
|
1199
|
+
commitContentEditable(blockEl);
|
|
1200
|
+
} else if (e.key === "Escape") {
|
|
1201
|
+
e.preventDefault();
|
|
1202
|
+
cancelContentEditable(blockEl);
|
|
1203
|
+
} else if (e.key === "Enter" && blockType === "heading") {
|
|
1204
|
+
e.preventDefault();
|
|
1205
|
+
commitContentEditable(blockEl);
|
|
1206
|
+
}
|
|
1207
|
+
};
|
|
1208
|
+
|
|
1209
|
+
const onBlur = () => {
|
|
1210
|
+
setTimeout(() => {
|
|
1211
|
+
if (editingBlockIndex !== -1 && contentEditableActive) {
|
|
1212
|
+
commitContentEditable(blockEl);
|
|
1213
|
+
}
|
|
1214
|
+
}, 100);
|
|
1215
|
+
};
|
|
1216
|
+
|
|
1217
|
+
const onPaste = (e) => {
|
|
1218
|
+
e.preventDefault();
|
|
1219
|
+
const text = e.clipboardData.getData("text/plain");
|
|
1220
|
+
document.execCommand("insertText", false, text);
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
blockEl._ceHandlers = { onKeydown, onBlur, onPaste };
|
|
1224
|
+
blockEl.addEventListener("keydown", onKeydown);
|
|
1225
|
+
blockEl.addEventListener("blur", onBlur);
|
|
1226
|
+
blockEl.addEventListener("paste", onPaste);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function extractMarkdownFromElement(element, originalSource) {
|
|
1230
|
+
const blockType = getBlockType(element);
|
|
1231
|
+
const text = element.textContent.replace(/\u200B/g, "").trim();
|
|
1232
|
+
|
|
1233
|
+
if (blockType === "paragraph") {
|
|
1234
|
+
return text;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
if (blockType === "heading") {
|
|
1238
|
+
const match = originalSource.match(/^(#{1,6})\s/);
|
|
1239
|
+
const prefix = match ? match[1] : "#";
|
|
1240
|
+
return prefix + " " + text;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
if (blockType === "blockquote") {
|
|
1244
|
+
return text.split("\n").map(line => "> " + line.trim()).join("\n");
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
if (blockType === "list") {
|
|
1248
|
+
const isOrdered = element.tagName.toLowerCase() === "ol";
|
|
1249
|
+
const items = Array.from(element.querySelectorAll("li"));
|
|
1250
|
+
return items.map((li, i) => {
|
|
1251
|
+
const prefix = isOrdered ? `${i + 1}. ` : "- ";
|
|
1252
|
+
return prefix + li.textContent.trim();
|
|
1253
|
+
}).join("\n");
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
return text;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function cleanupContentEditable(blockEl) {
|
|
1260
|
+
blockEl.contentEditable = "false";
|
|
1261
|
+
blockEl.classList.remove("block-editing");
|
|
1262
|
+
if (blockEl._ceHandlers) {
|
|
1263
|
+
blockEl.removeEventListener("keydown", blockEl._ceHandlers.onKeydown);
|
|
1264
|
+
blockEl.removeEventListener("blur", blockEl._ceHandlers.onBlur);
|
|
1265
|
+
blockEl.removeEventListener("paste", blockEl._ceHandlers.onPaste);
|
|
1266
|
+
blockEl._ceHandlers = null;
|
|
1267
|
+
}
|
|
1268
|
+
contentEditableActive = false;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function commitContentEditable(blockEl) {
|
|
1272
|
+
if (editingBlockIndex === -1 || !contentEditableActive) return;
|
|
1273
|
+
|
|
1274
|
+
let newSource = extractMarkdownFromElement(blockEl, editingOriginalSource);
|
|
1275
|
+
|
|
1276
|
+
// Preserve trailing whitespace from original source (e.g. \n\n after headings)
|
|
1277
|
+
const trimmedOriginal = editingOriginalSource.trimEnd();
|
|
1278
|
+
const trailingWs = editingOriginalSource.slice(trimmedOriginal.length);
|
|
1279
|
+
if (trailingWs) newSource = newSource.trimEnd() + trailingWs;
|
|
1280
|
+
|
|
1281
|
+
cleanupContentEditable(blockEl);
|
|
1282
|
+
commitBlockEdit(newSource);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function cancelContentEditable(blockEl) {
|
|
1286
|
+
cleanupContentEditable(blockEl);
|
|
1287
|
+
cancelBlockEdit();
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
fileContentEl.addEventListener("click", (e) => {
|
|
1291
|
+
if (editingBlockIndex !== -1) return;
|
|
1292
|
+
|
|
1293
|
+
const sel = window.getSelection();
|
|
1294
|
+
if (sel && !sel.isCollapsed) return;
|
|
1295
|
+
|
|
1296
|
+
const target = e.target;
|
|
1297
|
+
if (target.closest("a, .mermaid-container, .block-insert-btn, .block-type-menu, .inline-editor, .block-edit-wrapper, .block-editing, .code-block-header")) return;
|
|
1298
|
+
|
|
1299
|
+
// Handle comment highlight clicks
|
|
1300
|
+
const highlightEl = target.closest(".comment-highlight");
|
|
1301
|
+
if (highlightEl) {
|
|
1302
|
+
const commentId = highlightEl.dataset.commentId;
|
|
1303
|
+
const comment = comments.find(c => c.id === commentId);
|
|
1304
|
+
if (comment) {
|
|
1305
|
+
showHighlightPopover(highlightEl, comment);
|
|
1306
|
+
}
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
if (!currentBlocks.length) return;
|
|
1311
|
+
|
|
1312
|
+
const blockIndex = findClickedBlockIndex(target);
|
|
1313
|
+
if (blockIndex === -1) return;
|
|
1314
|
+
|
|
1315
|
+
enterBlockEditDispatch(blockIndex);
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
// --- Code copy button handler ---
|
|
1319
|
+
fileContentEl.addEventListener("click", (e) => {
|
|
1320
|
+
const btn = e.target.closest(".code-copy-btn");
|
|
1321
|
+
if (!btn) return;
|
|
1322
|
+
e.stopPropagation();
|
|
1323
|
+
const wrapper = btn.closest(".code-block-wrapper");
|
|
1324
|
+
const code = wrapper?.querySelector("code");
|
|
1325
|
+
if (!code) return;
|
|
1326
|
+
navigator.clipboard.writeText(code.textContent || "").then(() => {
|
|
1327
|
+
btn.textContent = "Copied!";
|
|
1328
|
+
setTimeout(() => { btn.textContent = "Copy"; }, 2000);
|
|
1329
|
+
});
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
// --- Highlight Popover ---
|
|
1333
|
+
|
|
1334
|
+
function showHighlightPopover(el, comment) {
|
|
1335
|
+
activeHighlightCommentId = comment.id;
|
|
1336
|
+
highlightPopoverText.textContent = comment.comment;
|
|
1337
|
+
|
|
1338
|
+
const rect = el.getBoundingClientRect();
|
|
1339
|
+
highlightPopover.style.left = `${Math.min(rect.left, window.innerWidth - 260)}px`;
|
|
1340
|
+
highlightPopover.style.top = `${rect.bottom + 8}px`;
|
|
1341
|
+
highlightPopover.hidden = false;
|
|
1342
|
+
|
|
1343
|
+
// Highlight and scroll to the corresponding comment card in the sidebar
|
|
1344
|
+
for (const card of commentListEl.querySelectorAll(".comment-card")) {
|
|
1345
|
+
card.classList.remove("active");
|
|
1346
|
+
}
|
|
1347
|
+
const card = commentListEl.querySelector(`.comment-card[data-id="${comment.id}"]`);
|
|
1348
|
+
if (card) {
|
|
1349
|
+
card.classList.add("active");
|
|
1350
|
+
card.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
function hideHighlightPopover() {
|
|
1355
|
+
highlightPopover.hidden = true;
|
|
1356
|
+
activeHighlightCommentId = null;
|
|
1357
|
+
for (const card of commentListEl.querySelectorAll(".comment-card.active")) {
|
|
1358
|
+
card.classList.remove("active");
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
highlightEditBtn.addEventListener("click", () => {
|
|
1363
|
+
if (!activeHighlightCommentId) return;
|
|
1364
|
+
const comment = comments.find(c => c.id === activeHighlightCommentId);
|
|
1365
|
+
if (!comment) { hideHighlightPopover(); return; }
|
|
1366
|
+
|
|
1367
|
+
for (let i = 0; i < currentBlocks.length; i++) {
|
|
1368
|
+
const b = currentBlocks[i];
|
|
1369
|
+
if (comment.offset >= b.sourceStart && comment.offset < b.sourceEnd) {
|
|
1370
|
+
hideHighlightPopover();
|
|
1371
|
+
enterBlockEditDispatch(i);
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
hideHighlightPopover();
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
document.addEventListener("mousedown", (e) => {
|
|
1379
|
+
if (!highlightPopover.hidden && !highlightPopover.contains(e.target) && !e.target.closest(".comment-highlight")) {
|
|
1380
|
+
hideHighlightPopover();
|
|
1381
|
+
}
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
// --- Undo Stack ---
|
|
1385
|
+
|
|
1386
|
+
function saveUndoStack(file) {
|
|
1387
|
+
try {
|
|
1388
|
+
sessionStorage.setItem("cowrite-undo:" + file, JSON.stringify(undoStack));
|
|
1389
|
+
} catch (e) {
|
|
1390
|
+
undoStack.splice(0, undoStack.length - 5);
|
|
1391
|
+
try { sessionStorage.setItem("cowrite-undo:" + file, JSON.stringify(undoStack)); } catch (_) {}
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
function loadUndoStack(file) {
|
|
1396
|
+
try {
|
|
1397
|
+
const data = sessionStorage.getItem("cowrite-undo:" + file);
|
|
1398
|
+
if (data) {
|
|
1399
|
+
const parsed = JSON.parse(data);
|
|
1400
|
+
if (Array.isArray(parsed)) return parsed;
|
|
1401
|
+
}
|
|
1402
|
+
} catch (e) {}
|
|
1403
|
+
return [];
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function pushUndo() {
|
|
1407
|
+
if (!currentContent || !currentFile) return;
|
|
1408
|
+
undoStack.push({ file: currentFile, content: currentContent });
|
|
1409
|
+
if (undoStack.length > MAX_UNDO) undoStack.shift();
|
|
1410
|
+
undoBtn.disabled = false;
|
|
1411
|
+
saveUndoStack(currentFile);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function performUndo() {
|
|
1415
|
+
if (undoStack.length === 0) return;
|
|
1416
|
+
const snapshot = undoStack.pop();
|
|
1417
|
+
if (undoStack.length === 0) undoBtn.disabled = true;
|
|
1418
|
+
saveUndoStack(currentFile);
|
|
1419
|
+
|
|
1420
|
+
send({
|
|
1421
|
+
type: "edit_apply",
|
|
1422
|
+
offset: 0,
|
|
1423
|
+
length: currentContent.length,
|
|
1424
|
+
newText: snapshot.content,
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
undoBtn.addEventListener("click", performUndo);
|
|
1429
|
+
|
|
1430
|
+
document.addEventListener("keydown", (e) => {
|
|
1431
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) {
|
|
1432
|
+
const tag = document.activeElement?.tagName?.toLowerCase();
|
|
1433
|
+
if (tag === "textarea" || tag === "input") return;
|
|
1434
|
+
if (document.activeElement?.contentEditable === "true") return;
|
|
1435
|
+
|
|
1436
|
+
e.preventDefault();
|
|
1437
|
+
performUndo();
|
|
1438
|
+
}
|
|
1439
|
+
});
|
|
1440
|
+
|
|
542
1441
|
// --- Init ---
|
|
543
1442
|
connect();
|