@filipc77/cowrite 0.4.28 → 0.6.0

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