@filipc77/cowrite 0.4.28 → 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 +972 -108
- package/ui/index.html +19 -2
- package/ui/styles.css +365 -147
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
|
|
|
@@ -23,6 +28,35 @@ let currentContent = "";
|
|
|
23
28
|
let ws = null;
|
|
24
29
|
let selectionInfo = null;
|
|
25
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
|
+
];
|
|
26
60
|
|
|
27
61
|
// --- File Picker ---
|
|
28
62
|
|
|
@@ -32,6 +66,7 @@ async function loadFileList() {
|
|
|
32
66
|
const data = await res.json();
|
|
33
67
|
fileList.innerHTML = "";
|
|
34
68
|
for (const file of data.files) {
|
|
69
|
+
if (!/\.(md|markdown|mdx)$/i.test(file)) continue;
|
|
35
70
|
const option = document.createElement("option");
|
|
36
71
|
option.value = file;
|
|
37
72
|
fileList.appendChild(option);
|
|
@@ -43,6 +78,9 @@ async function loadFileList() {
|
|
|
43
78
|
|
|
44
79
|
function switchFile(file) {
|
|
45
80
|
if (!file || !ws || ws.readyState !== WebSocket.OPEN) return;
|
|
81
|
+
if (currentFile) saveUndoStack(currentFile);
|
|
82
|
+
undoStack = loadUndoStack(file);
|
|
83
|
+
undoBtn.disabled = undoStack.length === 0;
|
|
46
84
|
send({ type: "switch_file", file });
|
|
47
85
|
filePicker.value = "";
|
|
48
86
|
// Update URL without reload
|
|
@@ -78,6 +116,8 @@ function connect() {
|
|
|
78
116
|
const fileParam = params.get("file");
|
|
79
117
|
if (fileParam) {
|
|
80
118
|
send({ type: "switch_file", file: fileParam });
|
|
119
|
+
undoStack = loadUndoStack(fileParam);
|
|
120
|
+
undoBtn.disabled = undoStack.length === 0;
|
|
81
121
|
}
|
|
82
122
|
|
|
83
123
|
loadFileList();
|
|
@@ -94,13 +134,15 @@ function connect() {
|
|
|
94
134
|
const msg = JSON.parse(event.data);
|
|
95
135
|
switch (msg.type) {
|
|
96
136
|
case "file_update":
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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);
|
|
104
146
|
break;
|
|
105
147
|
case "comments_update":
|
|
106
148
|
comments = msg.comments;
|
|
@@ -121,11 +163,56 @@ function send(msg) {
|
|
|
121
163
|
}
|
|
122
164
|
}
|
|
123
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
|
+
|
|
124
203
|
// --- Selection & Comment Creation ---
|
|
125
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
|
+
|
|
126
213
|
document.addEventListener("mouseup", (e) => {
|
|
127
|
-
// Don't trigger when clicking inside popup,
|
|
128
|
-
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;
|
|
129
216
|
|
|
130
217
|
const selection = window.getSelection();
|
|
131
218
|
if (!selection || selection.isCollapsed) {
|
|
@@ -148,34 +235,66 @@ document.addEventListener("mouseup", (e) => {
|
|
|
148
235
|
|
|
149
236
|
selectionInfo = { offset, length: text.length, selectedText: text };
|
|
150
237
|
|
|
151
|
-
// Show the small "Comment" trigger button near the selection end
|
|
152
238
|
const range = selection.getRangeAt(0);
|
|
153
239
|
const rect = range.getBoundingClientRect();
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
240
|
+
selectionToolbar.style.left = `${rect.left + rect.width / 2 - 40}px`;
|
|
241
|
+
selectionToolbar.style.top = `${rect.top - 40}px`;
|
|
242
|
+
selectionToolbar.hidden = false;
|
|
157
243
|
});
|
|
158
244
|
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
// Prevent the mousedown from clearing the text selection
|
|
245
|
+
// Prevent the mousedown from clearing the text selection
|
|
246
|
+
selectionToolbar.addEventListener("mousedown", (e) => {
|
|
162
247
|
e.preventDefault();
|
|
163
248
|
});
|
|
164
249
|
|
|
250
|
+
// Clicking Comment opens the full comment popup
|
|
165
251
|
commentTrigger.addEventListener("click", () => {
|
|
166
252
|
if (!selectionInfo) return;
|
|
167
253
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
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`;
|
|
172
264
|
popupSelection.textContent = selectionInfo.selectedText;
|
|
173
265
|
commentInput.value = "";
|
|
174
266
|
popup.hidden = false;
|
|
175
|
-
|
|
267
|
+
selectionToolbar.hidden = true;
|
|
176
268
|
commentInput.focus();
|
|
177
269
|
});
|
|
178
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
|
+
|
|
179
298
|
function computeOffset(selection, text) {
|
|
180
299
|
const range = selection.getRangeAt(0);
|
|
181
300
|
const startNode = range.startContainer;
|
|
@@ -187,106 +306,45 @@ function computeOffset(selection, text) {
|
|
|
187
306
|
lineEl = lineEl.parentElement;
|
|
188
307
|
}
|
|
189
308
|
|
|
190
|
-
if (lineEl?.dataset?.offset
|
|
191
|
-
//
|
|
309
|
+
if (lineEl?.dataset?.offset) {
|
|
310
|
+
// Plain text mode: use data-offset directly
|
|
192
311
|
const lineOffset = parseInt(lineEl.dataset.offset, 10);
|
|
193
|
-
//
|
|
194
|
-
const walker = document.createTreeWalker(lineEl, NodeFilter.SHOW_TEXT);
|
|
312
|
+
// Count characters before the selection start within this line span
|
|
195
313
|
let charsBefore = 0;
|
|
314
|
+
const walker = document.createTreeWalker(lineEl, NodeFilter.SHOW_TEXT);
|
|
196
315
|
while (walker.nextNode()) {
|
|
197
316
|
if (walker.currentNode === startNode) {
|
|
198
|
-
|
|
317
|
+
charsBefore += startCharOffset;
|
|
318
|
+
break;
|
|
199
319
|
}
|
|
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;
|
|
320
|
+
charsBefore += walker.currentNode.textContent?.length || 0;
|
|
250
321
|
}
|
|
251
|
-
|
|
252
|
-
// If no line matched, return the estimated offset as a best guess
|
|
253
|
-
return estimatedSrcOffset;
|
|
322
|
+
return lineOffset + charsBefore;
|
|
254
323
|
}
|
|
255
324
|
|
|
256
|
-
|
|
257
|
-
|
|
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;
|
|
258
328
|
|
|
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
329
|
return -1;
|
|
269
330
|
}
|
|
270
331
|
|
|
271
332
|
function hideTrigger() {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if (popup.hidden) {
|
|
275
|
-
selectionInfo = null;
|
|
276
|
-
}
|
|
333
|
+
selectionToolbar.hidden = true;
|
|
334
|
+
selectionInfo = null;
|
|
277
335
|
}
|
|
278
336
|
|
|
279
337
|
function hidePopup() {
|
|
280
338
|
popup.hidden = true;
|
|
281
|
-
commentTrigger.hidden = true;
|
|
282
339
|
selectionInfo = null;
|
|
283
340
|
}
|
|
284
341
|
|
|
285
|
-
$("#commentSubmit").addEventListener("click", submitComment);
|
|
286
342
|
$("#commentCancel").addEventListener("click", hidePopup);
|
|
343
|
+
$("#commentSubmit").addEventListener("click", submitComment);
|
|
287
344
|
|
|
288
345
|
commentInput.addEventListener("keydown", (e) => {
|
|
289
346
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
347
|
+
e.preventDefault();
|
|
290
348
|
submitComment();
|
|
291
349
|
}
|
|
292
350
|
if (e.key === "Escape") {
|
|
@@ -326,6 +384,9 @@ function renderComments() {
|
|
|
326
384
|
|
|
327
385
|
commentListEl.innerHTML = comments.map((c) => `
|
|
328
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>
|
|
329
390
|
<div class="comment-selected-text">${escapeHtml(c.selectedText)}</div>
|
|
330
391
|
<div class="comment-text">${escapeHtml(c.comment)}</div>
|
|
331
392
|
${c.replies.length > 0 ? `
|
|
@@ -385,6 +446,10 @@ window.resolveComment = function (id) {
|
|
|
385
446
|
send({ type: "comment_resolve", commentId: id });
|
|
386
447
|
};
|
|
387
448
|
|
|
449
|
+
window.deleteComment = function (id) {
|
|
450
|
+
send({ type: "comment_delete", commentId: id });
|
|
451
|
+
};
|
|
452
|
+
|
|
388
453
|
window.submitReply = function (id) {
|
|
389
454
|
const form = document.getElementById(`reply-form-${id}`);
|
|
390
455
|
const textarea = form?.querySelector("textarea");
|
|
@@ -395,7 +460,7 @@ window.submitReply = function (id) {
|
|
|
395
460
|
form.hidden = true;
|
|
396
461
|
};
|
|
397
462
|
|
|
398
|
-
// ---
|
|
463
|
+
// --- Highlights ---
|
|
399
464
|
|
|
400
465
|
function applyHighlights() {
|
|
401
466
|
// Remove existing highlights
|
|
@@ -421,8 +486,9 @@ function applyHighlights() {
|
|
|
421
486
|
|
|
422
487
|
const fullText = textNodes.map((n) => n.node.textContent).join("");
|
|
423
488
|
|
|
424
|
-
//
|
|
489
|
+
// Only highlight pending comments — resolved ones act as regular text
|
|
425
490
|
for (const comment of comments) {
|
|
491
|
+
if (comment.status !== "pending") continue;
|
|
426
492
|
const searchText = comment.selectedText;
|
|
427
493
|
// Search near the expected offset first, then globally
|
|
428
494
|
let textIdx = fullText.indexOf(searchText, Math.max(0, comment.offset - 50));
|
|
@@ -454,11 +520,8 @@ function wrapRange(textNodes, start, end, comment) {
|
|
|
454
520
|
|
|
455
521
|
try {
|
|
456
522
|
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
523
|
return;
|
|
460
524
|
} catch {
|
|
461
|
-
// Range may cross element boundaries; skip this highlight
|
|
462
525
|
return;
|
|
463
526
|
}
|
|
464
527
|
}
|
|
@@ -490,22 +553,31 @@ const THEME_KEY = "cowrite-theme";
|
|
|
490
553
|
function applyTheme(theme) {
|
|
491
554
|
document.documentElement.setAttribute("data-theme", theme);
|
|
492
555
|
themeToggle.checked = theme === "light";
|
|
493
|
-
// Update toggle icon
|
|
494
556
|
const icon = themeToggle.closest(".theme-toggle").querySelector(".toggle-icon");
|
|
495
557
|
if (icon) icon.textContent = theme === "light" ? "\u2600" : "\u263E";
|
|
496
|
-
// Update toggle label
|
|
497
558
|
const label = document.querySelector(".toggle-label");
|
|
498
559
|
if (label) label.textContent = theme === "light" ? "Light" : "Dark";
|
|
499
|
-
|
|
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
|
+
}
|
|
500
572
|
if (window.__mermaid && currentHtml) {
|
|
501
573
|
window.__mermaid.initialize({ startOnLoad: false, theme: theme === "light" ? "default" : "dark" });
|
|
502
574
|
fileContentEl.innerHTML = currentHtml;
|
|
503
575
|
renderMermaidDiagrams();
|
|
504
576
|
applyHighlights();
|
|
577
|
+
updateBlockMap();
|
|
505
578
|
}
|
|
506
579
|
}
|
|
507
580
|
|
|
508
|
-
// Load saved preference, default to dark
|
|
509
581
|
const savedTheme = localStorage.getItem(THEME_KEY) || "dark";
|
|
510
582
|
applyTheme(savedTheme);
|
|
511
583
|
|
|
@@ -515,11 +587,10 @@ themeToggle.addEventListener("change", () => {
|
|
|
515
587
|
applyTheme(theme);
|
|
516
588
|
});
|
|
517
589
|
|
|
518
|
-
// Hide trigger when selection is cleared
|
|
590
|
+
// Hide trigger when selection is cleared
|
|
519
591
|
document.addEventListener("selectionchange", () => {
|
|
520
592
|
const selection = window.getSelection();
|
|
521
593
|
if (!selection || selection.isCollapsed) {
|
|
522
|
-
// Small delay to avoid race with the trigger button click
|
|
523
594
|
setTimeout(() => {
|
|
524
595
|
if (popup.hidden) hideTrigger();
|
|
525
596
|
}, 100);
|
|
@@ -539,5 +610,798 @@ async function renderMermaidDiagrams() {
|
|
|
539
610
|
}
|
|
540
611
|
}
|
|
541
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
|
+
|
|
542
1406
|
// --- Init ---
|
|
543
1407
|
connect();
|