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