@filipc77/cowrite 0.4.28 → 0.5.0

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