@filipc77/cowrite 0.4.27 → 0.5.0

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