@filipc77/cowrite 0.6.7 → 0.6.9

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 DELETED
@@ -1,1743 +0,0 @@
1
- // @ts-check
2
-
3
- /** @typedef {import('../src/types.js').Comment} Comment */
4
- /** @typedef {import('../src/types.js').WSServerMessage} WSServerMessage */
5
-
6
- const $ = (sel) => document.querySelector(sel);
7
- const fileContentEl = $("#fileContent");
8
- const commentListEl = $("#commentList");
9
- const commentCountEl = $("#commentCount");
10
- const filePathEl = $("#filePath");
11
- const statusEl = $("#status");
12
- const popup = $("#commentPopup");
13
- const popupSelection = $("#popupSelection");
14
- const commentInput = $("#commentInput");
15
- const selectionToolbar = $("#selectionToolbar");
16
- const commentTrigger = $("#commentTrigger");
17
- const undoBtn = $("#undoBtn");
18
- const filePicker = $("#filePicker");
19
- const fileList = $("#fileList");
20
- const fileCommentBtn = $("#fileCommentBtn");
21
- const formatButtons = $("#formatButtons");
22
-
23
- /** @type {Comment[]} */
24
- let comments = [];
25
- let currentFile = "";
26
- let currentContent = "";
27
- let ws = null;
28
- let selectionInfo = null;
29
- let currentHtml = "";
30
- let currentBlocks = [];
31
- let insertBtn = null;
32
- let insertLine = null;
33
- let activeGapIndex = -1;
34
- let undoStack = [];
35
- const MAX_UNDO = 50;
36
-
37
- // Click-to-edit state
38
- let editingBlockIndex = -1;
39
- let editingBlockEl = null;
40
- let editingOriginalSource = "";
41
- let editingContentSnapshot = "";
42
- let pendingFileUpdate = null;
43
- let pendingEditAfterInsert = -1;
44
- let contentEditableActive = false;
45
-
46
- // --- Resizable Sidebar ---
47
- (function initResizableSidebar() {
48
- const handle = document.getElementById("sidebarDragHandle");
49
- const sidebar = document.getElementById("sidebar");
50
- if (!handle || !sidebar) return;
51
-
52
- // Restore saved width
53
- const saved = localStorage.getItem("cowrite-sidebar-width");
54
- if (saved) document.documentElement.style.setProperty("--sidebar-width", saved + "px");
55
-
56
- let startX = 0;
57
- let startWidth = 0;
58
-
59
- handle.addEventListener("mousedown", (e) => {
60
- e.preventDefault();
61
- startX = e.clientX;
62
- startWidth = sidebar.offsetWidth;
63
- document.body.classList.add("sidebar-resizing");
64
- document.addEventListener("mousemove", onMouseMove);
65
- document.addEventListener("mouseup", onMouseUp);
66
- });
67
-
68
- function onMouseMove(e) {
69
- const delta = startX - e.clientX; // sidebar is on the right
70
- const newWidth = Math.min(Math.max(startWidth + delta, 300), window.innerWidth * 0.5);
71
- document.documentElement.style.setProperty("--sidebar-width", newWidth + "px");
72
- }
73
-
74
- function onMouseUp() {
75
- document.body.classList.remove("sidebar-resizing");
76
- document.removeEventListener("mousemove", onMouseMove);
77
- document.removeEventListener("mouseup", onMouseUp);
78
- const width = sidebar.offsetWidth;
79
- localStorage.setItem("cowrite-sidebar-width", String(width));
80
- }
81
- })();
82
-
83
- const BLOCK_TYPES = [
84
- { id: "text", label: "Text", category: "Basic blocks", icon: "Aa", template: "\u200B" },
85
- { id: "h1", label: "Heading 1", category: "Basic blocks", icon: "H1", template: "# " },
86
- { id: "h2", label: "Heading 2", category: "Basic blocks", icon: "H2", template: "## " },
87
- { id: "h3", label: "Heading 3", category: "Basic blocks", icon: "H3", template: "### " },
88
- { id: "bullet", label: "Bulleted list", category: "Basic blocks", icon: "\u2022", template: "- " },
89
- { id: "number", label: "Numbered list", category: "Basic blocks", icon: "1.", template: "1. " },
90
- { id: "quote", label: "Quote", category: "Basic blocks", icon: "\u201C", template: "> " },
91
- { id: "divider", label: "Divider", category: "Basic blocks", icon: "\u2014", template: "---" },
92
- { id: "code", label: "Code block", category: "Advanced", icon: "</>", template: "```\n\n```" },
93
- { id: "table", label: "Table", category: "Advanced", icon: "\u229E", template: "| Column 1 | Column 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |" },
94
- ];
95
-
96
- // --- File Picker ---
97
-
98
- async function loadFileList() {
99
- try {
100
- const res = await fetch("/api/files");
101
- const data = await res.json();
102
- fileList.innerHTML = "";
103
- for (const file of data.files) {
104
- if (!/\.(md|markdown|mdx)$/i.test(file)) continue;
105
- const option = document.createElement("option");
106
- option.value = file;
107
- fileList.appendChild(option);
108
- }
109
- } catch {
110
- // Will retry on reconnect
111
- }
112
- }
113
-
114
- function switchFile(file) {
115
- if (!file || !ws || ws.readyState !== WebSocket.OPEN) return;
116
- if (currentFile) saveUndoStack(currentFile);
117
- undoStack = loadUndoStack(file);
118
- undoBtn.disabled = undoStack.length === 0;
119
- send({ type: "switch_file", file });
120
- filePicker.value = "";
121
- // Update URL without reload
122
- const url = new URL(location.href);
123
- url.searchParams.set("file", file);
124
- history.replaceState(null, "", url.toString());
125
- }
126
-
127
- // Track meta key for Cmd+Click to open in new tab
128
- let lastClickHadMeta = false;
129
- document.addEventListener("mousedown", (e) => { lastClickHadMeta = e.metaKey || e.ctrlKey; });
130
-
131
- function openFileInNewTab(file) {
132
- const url = new URL(location.href);
133
- url.searchParams.set("file", file);
134
- window.open(url.toString(), "_blank");
135
- }
136
-
137
- filePicker.addEventListener("change", () => {
138
- const file = filePicker.value.trim();
139
- if (!file) return;
140
- if (lastClickHadMeta) {
141
- openFileInNewTab(file);
142
- filePicker.value = "";
143
- } else {
144
- switchFile(file);
145
- }
146
- });
147
-
148
- filePicker.addEventListener("keydown", (e) => {
149
- const file = filePicker.value.trim();
150
- if (!file) return;
151
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
152
- e.preventDefault();
153
- openFileInNewTab(file);
154
- filePicker.value = "";
155
- } else if (e.key === "Enter") {
156
- switchFile(file);
157
- }
158
- });
159
-
160
- // --- WebSocket ---
161
-
162
- function connect() {
163
- const protocol = location.protocol === "https:" ? "wss:" : "ws:";
164
- ws = new WebSocket(`${protocol}//${location.host}`);
165
-
166
- ws.onopen = () => {
167
- statusEl.innerHTML = '<span class="status-dot"></span>Connected';
168
- statusEl.className = "status connected";
169
-
170
- // If URL has ?file= param, switch to that file
171
- const params = new URLSearchParams(location.search);
172
- const fileParam = params.get("file");
173
- if (fileParam) {
174
- send({ type: "switch_file", file: fileParam });
175
- undoStack = loadUndoStack(fileParam);
176
- undoBtn.disabled = undoStack.length === 0;
177
- }
178
-
179
- loadFileList();
180
- };
181
-
182
- ws.onclose = () => {
183
- statusEl.innerHTML = '<span class="status-dot"></span>Disconnected';
184
- statusEl.className = "status";
185
- setTimeout(connect, 2000);
186
- };
187
-
188
- ws.onmessage = (event) => {
189
- /** @type {WSServerMessage} */
190
- const msg = JSON.parse(event.data);
191
- switch (msg.type) {
192
- case "file_update":
193
- closeBlockTypeMenu();
194
- if (editingBlockIndex !== -1) {
195
- pendingFileUpdate = msg;
196
- currentFile = msg.file;
197
- currentContent = msg.content;
198
- currentHtml = msg.html;
199
- break;
200
- }
201
- applyFileUpdate(msg);
202
- break;
203
- case "comments_update":
204
- comments = msg.comments;
205
- commentCountEl.textContent = String(comments.filter(c => c.status === "pending").length);
206
- renderComments();
207
- applyHighlights();
208
- break;
209
- case "error":
210
- console.error("Server error:", msg.message);
211
- break;
212
- }
213
- };
214
- }
215
-
216
- function send(msg) {
217
- if (ws && ws.readyState === WebSocket.OPEN) {
218
- ws.send(JSON.stringify(msg));
219
- }
220
- }
221
-
222
- function applyFileUpdate(msg) {
223
- const fileChanged = currentFile !== msg.file;
224
- currentFile = msg.file;
225
- currentContent = msg.content;
226
- currentHtml = msg.html;
227
- if (fileChanged && msg.file) {
228
- undoStack = loadUndoStack(msg.file);
229
- undoBtn.disabled = undoStack.length === 0;
230
- }
231
- filePathEl.textContent = msg.file;
232
- fileContentEl.innerHTML = msg.html;
233
- insertBtn = null;
234
- insertLine = null;
235
- renderMermaidDiagrams();
236
- applyHighlights();
237
- updateBlockMap();
238
-
239
- if (pendingEditAfterInsert !== -1) {
240
- const idx = pendingEditAfterInsert;
241
- pendingEditAfterInsert = -1;
242
- if (idx >= 0 && idx < currentBlocks.length) {
243
- // Enter edit mode synchronously so editingBlockIndex is set before
244
- // any subsequent file_update messages can re-render the DOM
245
- enterBlockEditDispatch(idx);
246
- }
247
- }
248
- }
249
-
250
- function reRenderContent() {
251
- fileContentEl.innerHTML = currentHtml;
252
- insertBtn = null;
253
- insertLine = null;
254
- renderMermaidDiagrams();
255
- applyHighlights();
256
- updateBlockMap();
257
- }
258
-
259
- // --- Selection & Comment Creation ---
260
-
261
- document.addEventListener("mousedown", (e) => {
262
- if (!selectionToolbar.contains(e.target) &&
263
- !popup.contains(e.target) &&
264
- !$("#sidebar").contains(e.target)) {
265
- hideTrigger();
266
- }
267
- });
268
-
269
- document.addEventListener("mouseup", (e) => {
270
- // Don't trigger when clicking inside popup, toolbar, or sidebar
271
- if (popup.contains(e.target) || selectionToolbar.contains(e.target) || $("#sidebar").contains(e.target)) return;
272
-
273
- const selection = window.getSelection();
274
- if (!selection || selection.isCollapsed) {
275
- hideTrigger();
276
- return;
277
- }
278
-
279
- const text = selection.toString().trim();
280
- if (!text) {
281
- hideTrigger();
282
- return;
283
- }
284
-
285
- // Compute character offset in the source content
286
- const offset = computeOffset(selection, text);
287
- if (offset === -1) {
288
- hideTrigger();
289
- return;
290
- }
291
-
292
- selectionInfo = { offset, length: text.length, selectedText: text };
293
-
294
- const isMarkdown = /\.(md|mdx)$/i.test(currentFile);
295
- formatButtons.hidden = !isMarkdown;
296
-
297
- const range = selection.getRangeAt(0);
298
- const rect = range.getBoundingClientRect();
299
- selectionToolbar.style.left = `${rect.left + rect.width / 2 - 40}px`;
300
- selectionToolbar.style.top = `${rect.top - 40}px`;
301
- selectionToolbar.hidden = false;
302
- });
303
-
304
- // Prevent the mousedown from clearing the text selection
305
- selectionToolbar.addEventListener("mousedown", (e) => {
306
- e.preventDefault();
307
- });
308
-
309
- // Clicking Comment opens the full comment popup
310
- commentTrigger.addEventListener("click", () => {
311
- if (!selectionInfo) return;
312
-
313
- const toolbarRect = selectionToolbar.getBoundingClientRect();
314
- let left = Math.min(toolbarRect.left, window.innerWidth - 360);
315
- let top = toolbarRect.bottom + 8;
316
- // Clamp so popup doesn't overflow below the viewport
317
- const popupHeight = 280;
318
- if (top + popupHeight > window.innerHeight) {
319
- top = Math.max(8, toolbarRect.top - popupHeight - 8);
320
- }
321
- popup.style.left = `${Math.max(8, left)}px`;
322
- popup.style.top = `${top}px`;
323
- popupSelection.textContent = selectionInfo.selectedText;
324
- commentInput.value = "";
325
- popup.hidden = false;
326
- selectionToolbar.hidden = true;
327
- commentInput.focus();
328
- });
329
-
330
- // Make comment popup draggable via its header
331
- {
332
- const popupHeader = popup.querySelector(".popup-header");
333
- let dragging = false, dragStartX = 0, dragStartY = 0, popupStartX = 0, popupStartY = 0;
334
-
335
- popupHeader.addEventListener("mousedown", (e) => {
336
- dragging = true;
337
- dragStartX = e.clientX;
338
- dragStartY = e.clientY;
339
- popupStartX = popup.offsetLeft;
340
- popupStartY = popup.offsetTop;
341
- e.preventDefault();
342
- });
343
-
344
- document.addEventListener("mousemove", (e) => {
345
- if (!dragging) return;
346
- const dx = e.clientX - dragStartX;
347
- const dy = e.clientY - dragStartY;
348
- popup.style.left = `${popupStartX + dx}px`;
349
- popup.style.top = `${popupStartY + dy}px`;
350
- });
351
-
352
- document.addEventListener("mouseup", () => {
353
- dragging = false;
354
- });
355
- }
356
-
357
- function computeOffset(selection, text) {
358
- const range = selection.getRangeAt(0);
359
- const startNode = range.startContainer;
360
- const startCharOffset = range.startOffset;
361
-
362
- // Walk up from the range start to find a [data-offset] element
363
- let lineEl = startNode.nodeType === Node.TEXT_NODE ? startNode.parentElement : startNode;
364
- while (lineEl && !lineEl.dataset?.offset && lineEl !== fileContentEl) {
365
- lineEl = lineEl.parentElement;
366
- }
367
-
368
- if (lineEl?.dataset?.offset) {
369
- // Plain text mode: use data-offset directly
370
- const lineOffset = parseInt(lineEl.dataset.offset, 10);
371
- // Count characters before the selection start within this line span
372
- let charsBefore = 0;
373
- const walker = document.createTreeWalker(lineEl, NodeFilter.SHOW_TEXT);
374
- while (walker.nextNode()) {
375
- if (walker.currentNode === startNode) {
376
- charsBefore += startCharOffset;
377
- break;
378
- }
379
- charsBefore += walker.currentNode.textContent?.length || 0;
380
- }
381
- return lineOffset + charsBefore;
382
- }
383
-
384
- // Markdown mode: search for the text in current content near visual position
385
- const idx = currentContent.indexOf(text);
386
- if (idx !== -1) return idx;
387
-
388
- // Exact match failed — likely a cross-element selection (e.g. multiple list
389
- // items) where the browser's selection.toString() strips markdown syntax.
390
- // Use the DOM block structure to scope the search.
391
- const markdownBody = fileContentEl.querySelector(".markdown-body");
392
- if (markdownBody && currentBlocks.length) {
393
- let el = startNode.nodeType === Node.TEXT_NODE ? startNode.parentElement : startNode;
394
- while (el && el.parentElement !== markdownBody) {
395
- el = el.parentElement;
396
- }
397
- if (el && el.parentElement === markdownBody) {
398
- let blockIndex = 0;
399
- let sib = el.previousElementSibling;
400
- while (sib) {
401
- blockIndex++;
402
- sib = sib.previousElementSibling;
403
- }
404
- if (blockIndex < currentBlocks.length) {
405
- const block = currentBlocks[blockIndex];
406
- const blockSource = currentContent.slice(block.sourceStart, block.sourceEnd);
407
- const firstLine = text.split("\n")[0].trim();
408
- if (firstLine) {
409
- const lineIdx = blockSource.indexOf(firstLine);
410
- if (lineIdx !== -1) return block.sourceStart + lineIdx;
411
- }
412
- }
413
- }
414
- }
415
-
416
- // Last resort: match the first line of the selection anywhere in the content
417
- const firstLine = text.split("\n")[0].trim();
418
- if (firstLine && firstLine !== text) {
419
- const flIdx = currentContent.indexOf(firstLine);
420
- if (flIdx !== -1) return flIdx;
421
- }
422
-
423
- return -1;
424
- }
425
-
426
- function hideTrigger() {
427
- selectionToolbar.hidden = true;
428
- selectionInfo = null;
429
- }
430
-
431
- // --- Rich text formatting (markdown files only) ---
432
-
433
- const FORMAT_SYNTAX = {
434
- bold: { prefix: "**", suffix: "**" },
435
- italic: { prefix: "*", suffix: "*" },
436
- strikethrough: { prefix: "~~", suffix: "~~" },
437
- code: { prefix: "`", suffix: "`" },
438
- };
439
-
440
- function applyMarkdownFormat(format) {
441
- if (!selectionInfo || selectionInfo.offset < 0) return;
442
- const { offset, length, selectedText } = selectionInfo;
443
-
444
- // Exit contenteditable mode first so file_update from our edit applies normally
445
- if (contentEditableActive && editingBlockEl) {
446
- cancelContentEditable(editingBlockEl);
447
- }
448
-
449
- pushUndo();
450
-
451
- if (format === "link") {
452
- const url = prompt("Link URL:", "https://");
453
- if (!url) return;
454
- send({ type: "edit_apply", offset, length, newText: `[${selectedText}](${url})` });
455
- hideTrigger();
456
- return;
457
- }
458
-
459
- if (format === "blockquote") {
460
- const lines = selectedText.split("\n").map(l => `> ${l}`).join("\n");
461
- send({ type: "edit_apply", offset, length, newText: lines });
462
- hideTrigger();
463
- return;
464
- }
465
-
466
- if (format === "bulletList") {
467
- const lines = selectedText.split("\n").map(l => `- ${l}`).join("\n");
468
- send({ type: "edit_apply", offset, length, newText: lines });
469
- hideTrigger();
470
- return;
471
- }
472
-
473
- const { prefix, suffix } = FORMAT_SYNTAX[format];
474
-
475
- // Toggle detection: check if selection is already wrapped
476
- const before = currentContent.slice(offset - prefix.length, offset);
477
- const after = currentContent.slice(offset + length, offset + length + suffix.length);
478
- if (before === prefix && after === suffix) {
479
- // Unwrap: remove the surrounding markers
480
- send({ type: "edit_apply", offset: offset - prefix.length, length: length + prefix.length + suffix.length, newText: selectedText });
481
- } else {
482
- // Wrap: add markers
483
- send({ type: "edit_apply", offset, length, newText: `${prefix}${selectedText}${suffix}` });
484
- }
485
-
486
- hideTrigger();
487
- }
488
-
489
- selectionToolbar.addEventListener("click", (e) => {
490
- const btn = e.target.closest("[data-format]");
491
- if (!btn) return;
492
- applyMarkdownFormat(btn.dataset.format);
493
- });
494
-
495
- document.addEventListener("keydown", (e) => {
496
- if (!selectionInfo || selectionInfo.offset < 0) return;
497
- if (!/\.(md|mdx)$/i.test(currentFile)) return;
498
- if (e.target.closest("input, textarea, [contenteditable]")) return;
499
-
500
- const mod = e.metaKey || e.ctrlKey;
501
- if (!mod) return;
502
-
503
- const map = { b: "bold", i: "italic", e: "code", k: "link" };
504
- if (e.shiftKey && e.key.toLowerCase() === "s") {
505
- e.preventDefault();
506
- applyMarkdownFormat("strikethrough");
507
- } else if (map[e.key]) {
508
- e.preventDefault();
509
- applyMarkdownFormat(map[e.key]);
510
- }
511
- });
512
-
513
- function hidePopup() {
514
- popup.hidden = true;
515
- selectionInfo = null;
516
- // Reset popup to default state for next use
517
- popupSelection.hidden = false;
518
- popup.querySelector(".popup-header").textContent = "Selection";
519
- commentInput.placeholder = "Leave a comment...";
520
- }
521
-
522
- $("#commentCancel").addEventListener("click", hidePopup);
523
- $("#commentSubmit").addEventListener("click", submitComment);
524
-
525
- commentInput.addEventListener("keydown", (e) => {
526
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
527
- e.preventDefault();
528
- submitComment();
529
- }
530
- if (e.key === "Escape") {
531
- hidePopup();
532
- }
533
- });
534
-
535
- fileCommentBtn.addEventListener("click", () => {
536
- if (!currentFile) return;
537
-
538
- // Position popup below the button
539
- const btnRect = fileCommentBtn.getBoundingClientRect();
540
- popup.style.left = `${Math.max(8, btnRect.left - 280)}px`;
541
- popup.style.top = `${btnRect.bottom + 8}px`;
542
-
543
- // Hide the selection preview, set file-comment mode
544
- popupSelection.hidden = true;
545
- popup.querySelector(".popup-header").textContent = "File comment";
546
- commentInput.value = "";
547
- commentInput.placeholder = "Comment on the whole file...";
548
- selectionInfo = { offset: 0, length: 0, selectedText: "" };
549
- popup.hidden = false;
550
- commentInput.focus();
551
- });
552
-
553
- function submitComment() {
554
- const text = commentInput.value.trim();
555
- if (!text || !selectionInfo) return;
556
-
557
- send({
558
- type: "comment_add",
559
- file: currentFile,
560
- offset: selectionInfo.offset,
561
- length: selectionInfo.length,
562
- selectedText: selectionInfo.selectedText,
563
- comment: text,
564
- });
565
-
566
- hidePopup();
567
- window.getSelection()?.removeAllRanges();
568
- }
569
-
570
- // --- Comment Sidebar Rendering ---
571
-
572
- function renderComments() {
573
- if (comments.length === 0) {
574
- commentListEl.innerHTML = `
575
- <div class="empty-state">
576
- <p>No comments yet.</p>
577
- <p>Select text to comment, or use + for file comments.</p>
578
- </div>
579
- `;
580
- return;
581
- }
582
-
583
- commentListEl.innerHTML = comments.map((c) => {
584
- const repliesHtml = c.replies.length > 0 ? `
585
- <div class="comment-replies">
586
- ${c.replies.map((r) => r.proposal ? `
587
- <div class="reply agent proposal-reply proposal-${r.proposal.status}">
588
- <div class="reply-from agent">agent — proposal</div>
589
- <div class="proposal-explanation">${escapeHtml(r.proposal.explanation)}</div>
590
- ${r.proposal.status === "pending" ? `
591
- <div class="proposal-diff">
592
- <div class="proposal-old"><span class="proposal-label">Current</span><pre>${escapeHtml(r.proposal.oldText)}</pre></div>
593
- <div class="proposal-new"><span class="proposal-label">Proposed</span><pre>${escapeHtml(r.proposal.newText)}</pre></div>
594
- </div>
595
- <div class="proposal-actions">
596
- <button class="proposal-apply-btn" onclick="applyProposal('${c.id}', '${r.id}')">Apply</button>
597
- <button class="proposal-reject-btn" onclick="rejectProposal('${c.id}', '${r.id}')">Reject</button>
598
- </div>
599
- ` : r.proposal.status === "applied" ? `
600
- <div class="proposal-diff">
601
- <div class="proposal-new"><span class="proposal-label">&#10003; Applied</span><pre>${escapeHtml(r.proposal.newText)}</pre></div>
602
- </div>
603
- ` : `
604
- <div class="proposal-diff">
605
- <div class="proposal-old"><span class="proposal-label">&#10007; Rejected</span><pre>${escapeHtml(r.proposal.oldText)}</pre></div>
606
- </div>
607
- `}
608
- </div>
609
- ` : `
610
- <div class="reply ${r.from}">
611
- <div class="reply-from ${r.from}">${r.from}</div>
612
- <div>${escapeHtml(r.text)}</div>
613
- </div>
614
- `).join("")}
615
- </div>
616
- ` : "";
617
-
618
- if (c.status === "resolved") {
619
- const truncated = c.comment.length > 60 ? c.comment.slice(0, 60) + "..." : c.comment;
620
- return `
621
- <div class="comment-card resolved" data-id="${c.id}">
622
- <button class="comment-delete-btn" onclick="deleteComment('${c.id}')" title="Delete comment">
623
- <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>
624
- </button>
625
- <div class="resolved-summary" onclick="toggleResolvedExpand('${c.id}')">
626
- <svg class="resolved-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
627
- <span class="comment-status resolved">resolved</span>
628
- <span class="resolved-summary-text">${escapeHtml(truncated)}</span>
629
- </div>
630
- <div class="resolved-details" hidden>
631
- ${c.selectedText
632
- ? `<div class="comment-selected-text">${escapeHtml(c.selectedText)}</div>`
633
- : `<div class="comment-file-badge">Whole file</div>`
634
- }
635
- <div class="comment-text">${escapeHtml(c.comment)}</div>
636
- ${repliesHtml}
637
- <div class="comment-meta">
638
- <span>${timeAgo(c.createdAt)}</span>
639
- </div>
640
- <div class="comment-actions">
641
- <button onclick="reopenComment('${c.id}')">Reopen</button>
642
- </div>
643
- </div>
644
- </div>
645
- `;
646
- }
647
-
648
- return `
649
- <div class="comment-card ${c.status}" data-id="${c.id}">
650
- <button class="comment-delete-btn" onclick="deleteComment('${c.id}')" title="Delete comment">
651
- <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>
652
- </button>
653
- ${c.selectedText
654
- ? `<div class="comment-selected-text">${escapeHtml(c.selectedText)}</div>`
655
- : `<div class="comment-file-badge">Whole file</div>`
656
- }
657
- <div class="comment-text">${escapeHtml(c.comment)}</div>
658
- ${repliesHtml}
659
- <div class="comment-meta">
660
- <span>${timeAgo(c.createdAt)}</span>
661
- <span class="comment-status ${c.status}">${c.status}</span>
662
- </div>
663
- <div class="comment-actions">
664
- <button onclick="showReplyForm('${c.id}')">Reply</button>
665
- <button onclick="resolveComment('${c.id}')">Resolve</button>
666
- </div>
667
- <div class="reply-form" id="reply-form-${c.id}" hidden>
668
- <textarea rows="2" placeholder="Reply..."></textarea>
669
- <div class="reply-form-actions">
670
- <button onclick="submitReply('${c.id}')">Send</button>
671
- </div>
672
- </div>
673
- </div>
674
- `;
675
- }).join("");
676
-
677
- // Click to scroll to highlight
678
- for (const card of commentListEl.querySelectorAll(".comment-card")) {
679
- card.addEventListener("click", (e) => {
680
- if (e.target.tagName === "BUTTON" || e.target.tagName === "TEXTAREA") return;
681
- const id = card.dataset.id;
682
- const highlight = fileContentEl.querySelector(`[data-comment-id="${id}"]`);
683
- if (highlight) {
684
- highlight.scrollIntoView({ behavior: "smooth", block: "center" });
685
- highlight.style.outline = "2px solid var(--accent)";
686
- setTimeout(() => highlight.style.outline = "", 1500);
687
- }
688
- });
689
- }
690
- }
691
-
692
- // Global functions for inline onclick handlers
693
- window.showReplyForm = function (id) {
694
- const form = document.getElementById(`reply-form-${id}`);
695
- if (form) {
696
- form.hidden = !form.hidden;
697
- if (!form.hidden) form.querySelector("textarea").focus();
698
- }
699
- };
700
-
701
- window.resolveComment = function (id) {
702
- send({ type: "comment_resolve", commentId: id });
703
- };
704
-
705
- window.reopenComment = function (id) {
706
- send({ type: "comment_reopen", commentId: id });
707
- };
708
-
709
- window.deleteComment = function (id) {
710
- send({ type: "comment_delete", commentId: id });
711
- };
712
-
713
- window.submitReply = function (id) {
714
- const form = document.getElementById(`reply-form-${id}`);
715
- const textarea = form?.querySelector("textarea");
716
- const text = textarea?.value.trim();
717
- if (!text) return;
718
- send({ type: "comment_reply", commentId: id, text });
719
- textarea.value = "";
720
- form.hidden = true;
721
- };
722
-
723
- window.applyProposal = function (commentId, replyId) {
724
- send({ type: "proposal_apply", commentId, replyId });
725
- };
726
-
727
- window.rejectProposal = function (commentId, replyId) {
728
- send({ type: "proposal_reject", commentId, replyId });
729
- };
730
-
731
- window.toggleResolvedExpand = function (id) {
732
- const card = commentListEl.querySelector(`.comment-card[data-id="${id}"]`);
733
- if (!card) return;
734
- const details = card.querySelector(".resolved-details");
735
- const chevron = card.querySelector(".resolved-chevron");
736
- if (!details) return;
737
- const expanding = details.hidden;
738
- details.hidden = !expanding;
739
- card.classList.toggle("resolved-expanded", expanding);
740
- };
741
-
742
- // --- Highlights ---
743
-
744
- function applyHighlights() {
745
- // Remove existing highlights
746
- for (const el of fileContentEl.querySelectorAll(".comment-highlight")) {
747
- const parent = el.parentNode;
748
- parent.replaceChild(document.createTextNode(el.textContent), el);
749
- parent.normalize();
750
- }
751
-
752
- if (comments.length === 0) return;
753
-
754
- // Build a text-node map
755
- const walker = document.createTreeWalker(fileContentEl, NodeFilter.SHOW_TEXT);
756
- const textNodes = [];
757
- let totalOffset = 0;
758
-
759
- while (walker.nextNode()) {
760
- const node = walker.currentNode;
761
- const text = node.textContent || "";
762
- textNodes.push({ node, start: totalOffset, end: totalOffset + text.length });
763
- totalOffset += text.length;
764
- }
765
-
766
- const fullText = textNodes.map((n) => n.node.textContent).join("");
767
-
768
- // Highlight pending and answered comments — resolved ones act as regular text
769
- for (const comment of comments) {
770
- // Render highlights for all statuses (resolved ones are invisible via CSS but present for click-to-scroll)
771
- if (!comment.selectedText) continue;
772
- const searchText = comment.selectedText;
773
- // Search near the expected offset first, then globally
774
- let textIdx = fullText.indexOf(searchText, Math.max(0, comment.offset - 50));
775
- if (textIdx === -1 || Math.abs(textIdx - comment.offset) > 200) {
776
- textIdx = fullText.indexOf(searchText);
777
- }
778
- if (textIdx === -1) continue;
779
-
780
- wrapRange(textNodes, textIdx, textIdx + searchText.length, comment);
781
- }
782
- }
783
-
784
- function wrapRange(textNodes, start, end, comment) {
785
- for (let i = 0; i < textNodes.length; i++) {
786
- const tn = textNodes[i];
787
- if (tn.end <= start || tn.start >= end) continue;
788
-
789
- const nodeStart = Math.max(start - tn.start, 0);
790
- const nodeEnd = Math.min(end - tn.start, tn.node.textContent.length);
791
-
792
- const range = document.createRange();
793
- range.setStart(tn.node, nodeStart);
794
- range.setEnd(tn.node, nodeEnd);
795
-
796
- const span = document.createElement("span");
797
- span.className = `comment-highlight ${comment.status}`;
798
- span.dataset.commentId = comment.id;
799
- span.title = comment.comment;
800
-
801
- try {
802
- range.surroundContents(span);
803
- return;
804
- } catch {
805
- return;
806
- }
807
- }
808
- }
809
-
810
- // --- Utilities ---
811
-
812
- function escapeHtml(text) {
813
- const div = document.createElement("div");
814
- div.textContent = text;
815
- return div.innerHTML;
816
- }
817
-
818
- function timeAgo(iso) {
819
- const diff = Date.now() - new Date(iso).getTime();
820
- const mins = Math.floor(diff / 60000);
821
- if (mins < 1) return "just now";
822
- if (mins < 60) return `${mins}m ago`;
823
- const hours = Math.floor(mins / 60);
824
- if (hours < 24) return `${hours}h ago`;
825
- return `${Math.floor(hours / 24)}d ago`;
826
- }
827
-
828
- // --- Theme Toggle ---
829
-
830
- const themeToggle = $("#themeToggle");
831
- const THEME_KEY = "cowrite-theme";
832
-
833
- function applyTheme(theme) {
834
- document.documentElement.setAttribute("data-theme", theme);
835
- themeToggle.checked = theme === "light";
836
- const icon = themeToggle.closest(".theme-toggle").querySelector(".toggle-icon");
837
- if (icon) icon.textContent = theme === "light" ? "\u2600" : "\u263E";
838
- const label = document.querySelector(".toggle-label");
839
- if (label) label.textContent = theme === "light" ? "Light" : "Dark";
840
- const hljsDark = document.getElementById("hljs-dark");
841
- const hljsLight = document.getElementById("hljs-light");
842
- if (hljsDark && hljsLight) {
843
- hljsDark.disabled = theme === "light";
844
- hljsLight.disabled = theme !== "light";
845
- }
846
- const gmcDark = document.getElementById("gmc-dark");
847
- const gmcLight = document.getElementById("gmc-light");
848
- if (gmcDark && gmcLight) {
849
- gmcDark.disabled = theme === "light";
850
- gmcLight.disabled = theme !== "light";
851
- }
852
- if (window.__mermaid && currentHtml) {
853
- window.__mermaid.initialize({ startOnLoad: false, theme: theme === "light" ? "default" : "dark" });
854
- fileContentEl.innerHTML = currentHtml;
855
- renderMermaidDiagrams();
856
- applyHighlights();
857
- updateBlockMap();
858
- }
859
- }
860
-
861
- const savedTheme = localStorage.getItem(THEME_KEY) || "dark";
862
- applyTheme(savedTheme);
863
-
864
- themeToggle.addEventListener("change", () => {
865
- const theme = themeToggle.checked ? "light" : "dark";
866
- localStorage.setItem(THEME_KEY, theme);
867
- applyTheme(theme);
868
- });
869
-
870
- // --- Font size toggle ---
871
- const FONT_SIZE_KEY = "cowrite-font-size";
872
- (function initFontSize() {
873
- const saved = localStorage.getItem(FONT_SIZE_KEY) || "large";
874
- if (saved === "large") document.body.classList.add("font-large");
875
- for (const btn of document.querySelectorAll(".font-size-btn")) {
876
- btn.setAttribute("aria-pressed", btn.dataset.size === saved ? "true" : "false");
877
- btn.addEventListener("click", () => {
878
- const size = btn.dataset.size;
879
- document.body.classList.toggle("font-large", size === "large");
880
- localStorage.setItem(FONT_SIZE_KEY, size);
881
- for (const b of document.querySelectorAll(".font-size-btn")) {
882
- b.setAttribute("aria-pressed", b.dataset.size === size ? "true" : "false");
883
- }
884
- });
885
- }
886
- })();
887
-
888
- // Hide trigger when selection is cleared
889
- document.addEventListener("selectionchange", () => {
890
- const selection = window.getSelection();
891
- if (!selection || selection.isCollapsed) {
892
- setTimeout(() => {
893
- if (popup.hidden) hideTrigger();
894
- }, 100);
895
- }
896
- });
897
-
898
- // --- Mermaid Rendering ---
899
-
900
- async function renderMermaidDiagrams() {
901
- if (!window.__mermaid) return;
902
- const blocks = fileContentEl.querySelectorAll("pre.mermaid");
903
- if (blocks.length === 0) return;
904
- try {
905
- await window.__mermaid.run({ nodes: blocks });
906
- } catch (err) {
907
- console.error("Mermaid rendering failed:", err);
908
- }
909
- }
910
-
911
- // --- Block Gutter Insert ---
912
-
913
- function updateBlockMap() {
914
- const container = fileContentEl.querySelector(".markdown-body, .plain-text");
915
- if (!container || !container.dataset.blocks) {
916
- currentBlocks = [];
917
- return;
918
- }
919
- try {
920
- currentBlocks = JSON.parse(container.dataset.blocks);
921
- } catch {
922
- currentBlocks = [];
923
- }
924
- }
925
-
926
- function ensureInsertElements() {
927
- if (!insertBtn) {
928
- insertBtn = document.createElement("button");
929
- insertBtn.className = "block-insert-btn";
930
- insertBtn.textContent = "+";
931
- insertBtn.addEventListener("mousedown", (e) => e.preventDefault());
932
- insertBtn.addEventListener("click", handleInsertClick);
933
- fileContentEl.appendChild(insertBtn);
934
-
935
- insertLine = document.createElement("div");
936
- insertLine.className = "block-insert-line";
937
- fileContentEl.appendChild(insertLine);
938
- }
939
- }
940
-
941
- function getBlockChildren() {
942
- const container = fileContentEl.querySelector(".markdown-body, .plain-text");
943
- if (!container) return [];
944
- return Array.from(container.children).filter(
945
- (el) =>
946
- !el.classList.contains("block-insert-btn") &&
947
- !el.classList.contains("block-insert-line") &&
948
- !el.classList.contains("inline-editor") &&
949
- !el.classList.contains("block-type-menu") &&
950
- !el.classList.contains("block-edit-wrapper")
951
- );
952
- }
953
-
954
- $("#contentPanel").addEventListener("mousemove", (e) => {
955
- if (!currentBlocks.length) return;
956
- ensureInsertElements();
957
-
958
- const children = getBlockChildren();
959
- if (children.length === 0) return;
960
-
961
- const containerRect = fileContentEl.getBoundingClientRect();
962
- const mouseY = e.clientY;
963
- const hitZone = 14;
964
- let foundGap = -1;
965
-
966
- // Gap before first block
967
- const firstRect = children[0].getBoundingClientRect();
968
- if (mouseY < firstRect.top + hitZone && mouseY > firstRect.top - hitZone * 2) {
969
- foundGap = 0;
970
- }
971
-
972
- // Gaps between blocks
973
- if (foundGap === -1) {
974
- for (let i = 0; i < children.length - 1; i++) {
975
- const bottomOfCurrent = children[i].getBoundingClientRect().bottom;
976
- const topOfNext = children[i + 1].getBoundingClientRect().top;
977
- const gapCenter = (bottomOfCurrent + topOfNext) / 2;
978
-
979
- if (Math.abs(mouseY - gapCenter) < hitZone) {
980
- foundGap = i + 1;
981
- break;
982
- }
983
- }
984
- }
985
-
986
- // Gap after last block
987
- if (foundGap === -1) {
988
- const lastRect = children[children.length - 1].getBoundingClientRect();
989
- if (mouseY > lastRect.bottom - hitZone && mouseY < lastRect.bottom + hitZone * 2) {
990
- foundGap = children.length;
991
- }
992
- }
993
-
994
- if (foundGap !== -1) {
995
- activeGapIndex = foundGap;
996
- let gapY;
997
- if (foundGap === 0) {
998
- gapY = children[0].getBoundingClientRect().top - containerRect.top - 8;
999
- } else if (foundGap === children.length) {
1000
- gapY = children[children.length - 1].getBoundingClientRect().bottom - containerRect.top + 8;
1001
- } else {
1002
- const bottom = children[foundGap - 1].getBoundingClientRect().bottom;
1003
- const top = children[foundGap].getBoundingClientRect().top;
1004
- gapY = (bottom + top) / 2 - containerRect.top;
1005
- }
1006
-
1007
- insertBtn.style.top = `${gapY}px`;
1008
- insertBtn.classList.add("visible");
1009
- insertLine.style.top = `${gapY}px`;
1010
- insertLine.classList.add("visible");
1011
- } else {
1012
- insertBtn.classList.remove("visible");
1013
- insertLine.classList.remove("visible");
1014
- activeGapIndex = -1;
1015
- }
1016
- });
1017
-
1018
- $("#contentPanel").addEventListener("mouseleave", () => {
1019
- if (insertBtn) {
1020
- insertBtn.classList.remove("visible");
1021
- insertLine.classList.remove("visible");
1022
- }
1023
- activeGapIndex = -1;
1024
- });
1025
-
1026
- function handleInsertClick() {
1027
- if (activeGapIndex === -1 || !currentBlocks.length) return;
1028
-
1029
- let insertOffset;
1030
- if (activeGapIndex === 0) {
1031
- insertOffset = 0;
1032
- } else if (activeGapIndex >= currentBlocks.length) {
1033
- insertOffset = currentContent.length;
1034
- } else {
1035
- insertOffset = currentBlocks[activeGapIndex].sourceStart;
1036
- }
1037
-
1038
- showBlockTypeMenu(insertOffset, activeGapIndex);
1039
- }
1040
-
1041
- function closeBlockTypeMenu() {
1042
- const existing = fileContentEl.querySelector(".block-type-menu");
1043
- if (existing) existing.remove();
1044
- if (closeBlockTypeMenu._handler) {
1045
- document.removeEventListener("mousedown", closeBlockTypeMenu._handler);
1046
- closeBlockTypeMenu._handler = null;
1047
- }
1048
- }
1049
-
1050
- function showBlockTypeMenu(insertOffset, gapIndex) {
1051
- closeBlockTypeMenu();
1052
-
1053
- const container = fileContentEl.querySelector(".markdown-body, .plain-text");
1054
- if (!container) return;
1055
-
1056
- if (insertBtn) {
1057
- insertBtn.classList.remove("visible");
1058
- insertLine.classList.remove("visible");
1059
- }
1060
-
1061
- // Capture gapIndex now — activeGapIndex may change as the user moves the mouse to the menu
1062
- const capturedGapIndex = gapIndex;
1063
-
1064
- const menu = document.createElement("div");
1065
- menu.className = "block-type-menu";
1066
-
1067
- const filter = document.createElement("input");
1068
- filter.className = "block-type-filter";
1069
- filter.placeholder = "Filter...";
1070
- menu.appendChild(filter);
1071
-
1072
- const list = document.createElement("div");
1073
- list.className = "block-type-list";
1074
- menu.appendChild(list);
1075
-
1076
- let highlightIdx = 0;
1077
-
1078
- function renderItems(query) {
1079
- list.innerHTML = "";
1080
- const q = query.toLowerCase();
1081
- const filtered = BLOCK_TYPES.filter(
1082
- (bt) => !q || bt.label.toLowerCase().includes(q) || bt.id.includes(q)
1083
- );
1084
-
1085
- if (filtered.length === 0) {
1086
- list.innerHTML = '<div class="block-type-empty">No matches</div>';
1087
- return [];
1088
- }
1089
-
1090
- let lastCategory = "";
1091
- const items = [];
1092
- for (const bt of filtered) {
1093
- if (bt.category !== lastCategory) {
1094
- lastCategory = bt.category;
1095
- const header = document.createElement("div");
1096
- header.className = "block-type-category";
1097
- header.textContent = bt.category;
1098
- list.appendChild(header);
1099
- }
1100
- const item = document.createElement("div");
1101
- item.className = "block-type-item";
1102
- item.innerHTML = `<span class="block-type-icon">${escapeHtml(bt.icon)}</span><span>${escapeHtml(bt.label)}</span>`;
1103
- item.addEventListener("click", () => {
1104
- applyBlockTypeInsert(bt, insertOffset, capturedGapIndex);
1105
- closeBlockTypeMenu();
1106
- });
1107
- list.appendChild(item);
1108
- items.push({ el: item, bt });
1109
- }
1110
-
1111
- items.forEach((it, i) => {
1112
- it.el.addEventListener("mouseenter", () => {
1113
- highlightIdx = i;
1114
- updateHighlight(items);
1115
- });
1116
- });
1117
-
1118
- highlightIdx = Math.min(highlightIdx, items.length - 1);
1119
- updateHighlight(items);
1120
- return items;
1121
- }
1122
-
1123
- function updateHighlight(items) {
1124
- for (let i = 0; i < items.length; i++) {
1125
- items[i].el.classList.toggle("highlighted", i === highlightIdx);
1126
- }
1127
- }
1128
-
1129
- let currentItems = renderItems("");
1130
-
1131
- filter.addEventListener("input", () => {
1132
- highlightIdx = 0;
1133
- currentItems = renderItems(filter.value);
1134
- });
1135
-
1136
- filter.addEventListener("keydown", (e) => {
1137
- if (e.key === "ArrowDown") {
1138
- e.preventDefault();
1139
- if (currentItems.length > 0) {
1140
- highlightIdx = (highlightIdx + 1) % currentItems.length;
1141
- updateHighlight(currentItems);
1142
- currentItems[highlightIdx].el.scrollIntoView({ block: "nearest" });
1143
- }
1144
- } else if (e.key === "ArrowUp") {
1145
- e.preventDefault();
1146
- if (currentItems.length > 0) {
1147
- highlightIdx = (highlightIdx - 1 + currentItems.length) % currentItems.length;
1148
- updateHighlight(currentItems);
1149
- currentItems[highlightIdx].el.scrollIntoView({ block: "nearest" });
1150
- }
1151
- } else if (e.key === "Enter") {
1152
- e.preventDefault();
1153
- if (currentItems.length > 0 && currentItems[highlightIdx]) {
1154
- applyBlockTypeInsert(currentItems[highlightIdx].bt, insertOffset, capturedGapIndex);
1155
- closeBlockTypeMenu();
1156
- }
1157
- } else if (e.key === "Escape") {
1158
- e.preventDefault();
1159
- closeBlockTypeMenu();
1160
- }
1161
- });
1162
-
1163
- const children = getBlockChildren();
1164
- if (gapIndex >= children.length) {
1165
- container.appendChild(menu);
1166
- } else {
1167
- container.insertBefore(menu, children[gapIndex]);
1168
- }
1169
-
1170
- filter.focus();
1171
-
1172
- setTimeout(() => {
1173
- const handler = (e) => {
1174
- if (!menu.contains(e.target)) {
1175
- closeBlockTypeMenu();
1176
- }
1177
- };
1178
- closeBlockTypeMenu._handler = handler;
1179
- document.addEventListener("mousedown", handler);
1180
- }, 0);
1181
- }
1182
-
1183
- function applyBlockTypeInsert(blockType, insertOffset, gapIndex) {
1184
- const template = blockType.template;
1185
- let newText;
1186
- if (insertOffset === 0) {
1187
- newText = template + "\n\n";
1188
- } else if (insertOffset === currentContent.length) {
1189
- newText = "\n\n" + template;
1190
- } else {
1191
- // Must use \n\n separators so marked treats the new block as a separate paragraph
1192
- newText = "\n\n" + template + "\n\n";
1193
- }
1194
-
1195
- if (blockType.id !== "divider") {
1196
- pendingEditAfterInsert = gapIndex;
1197
- }
1198
-
1199
- pushUndo();
1200
- send({
1201
- type: "edit_apply",
1202
- offset: insertOffset,
1203
- length: 0,
1204
- newText: newText,
1205
- });
1206
- }
1207
-
1208
- // --- Click-to-edit ---
1209
-
1210
- function findClickedBlockIndex(target) {
1211
- const container = fileContentEl.querySelector(".markdown-body, .plain-text");
1212
- if (!container) return -1;
1213
-
1214
- let el = target;
1215
- while (el && el.parentElement !== container) {
1216
- el = el.parentElement;
1217
- if (!el) return -1;
1218
- }
1219
-
1220
- const children = getBlockChildren();
1221
- return children.indexOf(el);
1222
- }
1223
-
1224
- function enterBlockEdit(blockIndex) {
1225
- if (editingBlockIndex !== -1) return;
1226
- if (blockIndex < 0 || blockIndex >= currentBlocks.length) return;
1227
-
1228
- const block = currentBlocks[blockIndex];
1229
- const source = currentContent.slice(block.sourceStart, block.sourceEnd);
1230
-
1231
- const container = fileContentEl.querySelector(".markdown-body, .plain-text");
1232
- if (!container) return;
1233
-
1234
- const children = getBlockChildren();
1235
- const blockEl = children[blockIndex];
1236
- if (!blockEl) return;
1237
-
1238
- editingBlockIndex = blockIndex;
1239
- editingOriginalSource = source;
1240
- editingContentSnapshot = currentContent;
1241
-
1242
- const wrapper = document.createElement("div");
1243
- wrapper.className = "block-edit-wrapper";
1244
-
1245
- const textarea = document.createElement("textarea");
1246
- textarea.className = "block-edit-textarea";
1247
- textarea.value = source.replace(/\u200B/g, "");
1248
- wrapper.appendChild(textarea);
1249
-
1250
- const hint = document.createElement("div");
1251
- hint.className = "block-edit-hint";
1252
- hint.textContent = "Cmd+Enter to save \u00B7 Escape to cancel";
1253
- wrapper.appendChild(hint);
1254
-
1255
- container.replaceChild(wrapper, blockEl);
1256
- editingBlockEl = wrapper;
1257
-
1258
- textarea.style.height = "auto";
1259
- textarea.style.height = textarea.scrollHeight + "px";
1260
-
1261
- textarea.addEventListener("input", () => {
1262
- textarea.style.height = "auto";
1263
- textarea.style.height = textarea.scrollHeight + "px";
1264
- });
1265
-
1266
- textarea.addEventListener("keydown", (e) => {
1267
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
1268
- e.preventDefault();
1269
- commitBlockEdit(textarea.value);
1270
- } else if (e.key === "Escape") {
1271
- e.preventDefault();
1272
- cancelBlockEdit();
1273
- } else if (e.key === "Tab") {
1274
- e.preventDefault();
1275
- const start = textarea.selectionStart;
1276
- const end = textarea.selectionEnd;
1277
- textarea.value = textarea.value.substring(0, start) + " " + textarea.value.substring(end);
1278
- textarea.selectionStart = textarea.selectionEnd = start + 2;
1279
- textarea.dispatchEvent(new Event("input"));
1280
- }
1281
- });
1282
-
1283
- textarea.focus();
1284
- textarea.setSelectionRange(textarea.value.length, textarea.value.length);
1285
-
1286
- // Ensure focus sticks after any pending browser layout/paint
1287
- requestAnimationFrame(() => {
1288
- if (document.activeElement !== textarea) {
1289
- textarea.focus();
1290
- textarea.setSelectionRange(textarea.value.length, textarea.value.length);
1291
- }
1292
- });
1293
-
1294
- textarea.addEventListener("blur", () => {
1295
- setTimeout(() => {
1296
- if (editingBlockIndex !== -1) commitBlockEdit(textarea.value);
1297
- }, 100);
1298
- });
1299
- }
1300
-
1301
- function commitBlockEdit(newSource) {
1302
- if (editingBlockIndex === -1) return;
1303
-
1304
- const blockIndex = editingBlockIndex;
1305
- const originalSource = editingOriginalSource;
1306
- const snapshot = editingContentSnapshot;
1307
-
1308
- editingBlockIndex = -1;
1309
- editingBlockEl = null;
1310
- editingOriginalSource = "";
1311
- editingContentSnapshot = "";
1312
-
1313
- if (newSource === originalSource) {
1314
- if (pendingFileUpdate) {
1315
- applyFileUpdate(pendingFileUpdate);
1316
- pendingFileUpdate = null;
1317
- } else {
1318
- reRenderContent();
1319
- }
1320
- return;
1321
- }
1322
-
1323
- let block;
1324
- if (currentContent === snapshot) {
1325
- block = currentBlocks[blockIndex];
1326
- } else {
1327
- const tmp = document.createElement("div");
1328
- tmp.innerHTML = currentHtml;
1329
- const tmpContainer = tmp.querySelector("[data-blocks]");
1330
- if (tmpContainer) {
1331
- try {
1332
- const updatedBlocks = JSON.parse(tmpContainer.dataset.blocks);
1333
- if (blockIndex < updatedBlocks.length) {
1334
- block = updatedBlocks[blockIndex];
1335
- }
1336
- } catch {}
1337
- }
1338
- }
1339
-
1340
- if (!block) {
1341
- if (pendingFileUpdate) {
1342
- applyFileUpdate(pendingFileUpdate);
1343
- pendingFileUpdate = null;
1344
- } else {
1345
- reRenderContent();
1346
- }
1347
- return;
1348
- }
1349
-
1350
- pushUndo();
1351
- send({
1352
- type: "edit_apply",
1353
- offset: block.sourceStart,
1354
- length: block.sourceEnd - block.sourceStart,
1355
- newText: newSource,
1356
- });
1357
-
1358
- if (pendingFileUpdate) {
1359
- applyFileUpdate(pendingFileUpdate);
1360
- pendingFileUpdate = null;
1361
- }
1362
- }
1363
-
1364
- function cancelBlockEdit() {
1365
- editingBlockIndex = -1;
1366
- editingBlockEl = null;
1367
- editingOriginalSource = "";
1368
- editingContentSnapshot = "";
1369
-
1370
- if (pendingFileUpdate) {
1371
- applyFileUpdate(pendingFileUpdate);
1372
- pendingFileUpdate = null;
1373
- } else {
1374
- reRenderContent();
1375
- }
1376
- }
1377
-
1378
- // --- Block type detection for hybrid editing ---
1379
-
1380
- function getBlockType(element) {
1381
- if (!element || !element.tagName) return "unknown";
1382
- const tag = element.tagName.toLowerCase();
1383
- if (tag === "p") return "paragraph";
1384
- if (/^h[1-6]$/.test(tag)) return "heading";
1385
- if (tag === "ul" || tag === "ol") return "list";
1386
- if (tag === "blockquote") return "blockquote";
1387
- if (element.classList.contains("code-block-wrapper")) return "code";
1388
- if (tag === "pre") return "code";
1389
- if (tag === "table") return "table";
1390
- if (tag === "hr") return "divider";
1391
- if (element.classList.contains("mermaid-container")) return "mermaid";
1392
- return "unknown";
1393
- }
1394
-
1395
- function enterBlockEditDispatch(blockIndex) {
1396
- if (editingBlockIndex !== -1) return;
1397
- if (blockIndex < 0 || blockIndex >= currentBlocks.length) return;
1398
-
1399
- const container = fileContentEl.querySelector(".markdown-body, .plain-text");
1400
- if (!container) return;
1401
-
1402
- const children = getBlockChildren();
1403
- const blockEl = children[blockIndex];
1404
- if (!blockEl) return;
1405
-
1406
- const blockType = getBlockType(blockEl);
1407
-
1408
- if (blockType === "divider" || blockType === "mermaid") return;
1409
- if (blockType === "code" || blockType === "table" || blockType === "unknown") {
1410
- enterBlockEdit(blockIndex);
1411
- return;
1412
- }
1413
-
1414
- enterContentEditable(blockIndex, blockEl);
1415
- }
1416
-
1417
- function enterContentEditable(blockIndex, blockEl) {
1418
- if (editingBlockIndex !== -1) return;
1419
- if (blockIndex < 0 || blockIndex >= currentBlocks.length) return;
1420
-
1421
- const block = currentBlocks[blockIndex];
1422
- const source = currentContent.slice(block.sourceStart, block.sourceEnd);
1423
-
1424
- editingBlockIndex = blockIndex;
1425
- editingOriginalSource = source;
1426
- editingContentSnapshot = currentContent;
1427
- contentEditableActive = true;
1428
- editingBlockEl = blockEl;
1429
-
1430
- blockEl.contentEditable = "true";
1431
- blockEl.classList.add("block-editing");
1432
-
1433
- // For empty blocks (ZWS placeholder), set up a clean empty editable state
1434
- const isEmpty = !blockEl.textContent.replace(/\u200B/g, "").trim();
1435
- if (isEmpty) {
1436
- blockEl.innerHTML = "";
1437
- }
1438
-
1439
- blockEl.focus();
1440
-
1441
- if (isEmpty) {
1442
- const sel = window.getSelection();
1443
- const range = document.createRange();
1444
- range.setStart(blockEl, 0);
1445
- range.collapse(true);
1446
- sel.removeAllRanges();
1447
- sel.addRange(range);
1448
- }
1449
-
1450
- // Ensure focus sticks after any pending browser events
1451
- setTimeout(() => {
1452
- if (document.activeElement !== blockEl) {
1453
- blockEl.focus();
1454
- }
1455
- }, 0);
1456
-
1457
- const blockType = getBlockType(blockEl);
1458
-
1459
- // Markdown shortcut patterns: detected on Space keydown BEFORE browser default.
1460
- // This prevents Chrome from auto-formatting "* " into a native list element.
1461
- const MD_SHORTCUTS = [
1462
- { pattern: /^(\*|-)\s*$/, prefix: "- " },
1463
- { pattern: /^1\.\s*$/, prefix: "1. " },
1464
- { pattern: /^###\s*$/, prefix: "### " },
1465
- { pattern: /^##\s*$/, prefix: "## " },
1466
- { pattern: /^#\s*$/, prefix: "# " },
1467
- { pattern: /^>\s*$/, prefix: "> " },
1468
- ];
1469
-
1470
- const onKeydown = (e) => {
1471
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
1472
- e.preventDefault();
1473
- commitContentEditable(blockEl);
1474
- } else if (e.key === "Escape") {
1475
- e.preventDefault();
1476
- cancelContentEditable(blockEl);
1477
- } else if (e.key === "Enter" && blockType === "heading") {
1478
- e.preventDefault();
1479
- commitContentEditable(blockEl);
1480
- } else if (e.key === "Enter" && blockType === "paragraph") {
1481
- // Insert a line break within the same block
1482
- e.preventDefault();
1483
- document.execCommand("insertLineBreak");
1484
- } else if (e.key === " " && blockType === "paragraph") {
1485
- // Markdown shortcuts: intercept Space BEFORE browser auto-formats.
1486
- // At keydown time, Space isn't in the DOM yet, so check text before cursor.
1487
- if (editingBlockIndex === -1 || !contentEditableActive) return;
1488
- const sel = window.getSelection();
1489
- if (!sel || !sel.rangeCount) return;
1490
- const range = sel.getRangeAt(0);
1491
- // Get text from start of block to cursor position
1492
- const preRange = range.cloneRange();
1493
- preRange.selectNodeContents(blockEl);
1494
- preRange.setEnd(range.startContainer, range.startOffset);
1495
- const textBeforeCursor = preRange.toString().replace(/\u200B/g, "");
1496
- // Get text after cursor
1497
- const postRange = range.cloneRange();
1498
- postRange.selectNodeContents(blockEl);
1499
- postRange.setStart(range.endContainer, range.endOffset);
1500
- const textAfterCursor = postRange.toString().replace(/\u200B/g, "");
1501
-
1502
- for (const { pattern, prefix } of MD_SHORTCUTS) {
1503
- if (!pattern.test(textBeforeCursor)) continue;
1504
- // Match found — prevent browser default (stops Chrome list auto-format)
1505
- e.preventDefault();
1506
- const newSource = prefix + textAfterCursor;
1507
- pendingEditAfterInsert = editingBlockIndex;
1508
- cleanupContentEditable(blockEl);
1509
- commitBlockEdit(newSource);
1510
- return;
1511
- }
1512
- }
1513
- };
1514
-
1515
- const onBlur = () => {
1516
- setTimeout(() => {
1517
- if (editingBlockIndex !== -1 && contentEditableActive) {
1518
- commitContentEditable(blockEl);
1519
- }
1520
- }, 100);
1521
- };
1522
-
1523
- const onPaste = (e) => {
1524
- e.preventDefault();
1525
- const text = e.clipboardData.getData("text/plain");
1526
- document.execCommand("insertText", false, text);
1527
- };
1528
-
1529
- blockEl._ceHandlers = { onKeydown, onBlur, onPaste };
1530
- blockEl.addEventListener("keydown", onKeydown);
1531
- blockEl.addEventListener("blur", onBlur);
1532
- blockEl.addEventListener("paste", onPaste);
1533
- }
1534
-
1535
- // Walk DOM nodes and reconstruct markdown, preserving inline formatting
1536
- function domToMarkdown(node) {
1537
- if (node.nodeType === Node.TEXT_NODE) {
1538
- return node.textContent.replace(/\u200B/g, "");
1539
- }
1540
- if (node.nodeType !== Node.ELEMENT_NODE) return "";
1541
-
1542
- const tag = node.tagName.toLowerCase();
1543
-
1544
- // Line break
1545
- if (tag === "br") return " \n";
1546
-
1547
- // Recurse into children
1548
- const inner = Array.from(node.childNodes).map(domToMarkdown).join("");
1549
-
1550
- // Inline formatting
1551
- if (tag === "strong" || tag === "b") return `**${inner}**`;
1552
- if (tag === "em" || tag === "i") return `*${inner}*`;
1553
- if (tag === "del" || tag === "s") return `~~${inner}~~`;
1554
- if (tag === "code") return `\`${inner}\``;
1555
- if (tag === "a") {
1556
- const href = node.getAttribute("href") || "";
1557
- return `[${inner}](${href})`;
1558
- }
1559
-
1560
- return inner;
1561
- }
1562
-
1563
- function extractMarkdownFromElement(element, originalSource) {
1564
- const blockType = getBlockType(element);
1565
-
1566
- if (blockType === "paragraph") {
1567
- return domToMarkdown(element).trim();
1568
- }
1569
-
1570
- if (blockType === "heading") {
1571
- const match = originalSource.match(/^(#{1,6})\s/);
1572
- const prefix = match ? match[1] : "#";
1573
- const inner = domToMarkdown(element).trim();
1574
- return prefix + " " + inner;
1575
- }
1576
-
1577
- if (blockType === "blockquote") {
1578
- const inner = domToMarkdown(element).trim();
1579
- return inner.split("\n").map(line => "> " + line.trim()).join("\n");
1580
- }
1581
-
1582
- if (blockType === "list") {
1583
- const isOrdered = element.tagName.toLowerCase() === "ol";
1584
- const items = Array.from(element.querySelectorAll("li"));
1585
- return items.map((li, i) => {
1586
- const prefix = isOrdered ? `${i + 1}. ` : "- ";
1587
- return prefix + domToMarkdown(li).trim();
1588
- }).join("\n");
1589
- }
1590
-
1591
- const text = (element.innerText || element.textContent).replace(/\u200B/g, "").trim();
1592
- return text;
1593
- }
1594
-
1595
- function cleanupContentEditable(blockEl) {
1596
- blockEl.contentEditable = "false";
1597
- blockEl.classList.remove("block-editing");
1598
- if (blockEl._ceHandlers) {
1599
- blockEl.removeEventListener("keydown", blockEl._ceHandlers.onKeydown);
1600
- blockEl.removeEventListener("blur", blockEl._ceHandlers.onBlur);
1601
- blockEl.removeEventListener("paste", blockEl._ceHandlers.onPaste);
1602
- blockEl._ceHandlers = null;
1603
- }
1604
- contentEditableActive = false;
1605
- }
1606
-
1607
- function commitContentEditable(blockEl) {
1608
- if (editingBlockIndex === -1 || !contentEditableActive) return;
1609
-
1610
- let newSource = extractMarkdownFromElement(blockEl, editingOriginalSource);
1611
-
1612
- // Preserve trailing whitespace from original source (e.g. \n\n after headings)
1613
- const trimmedOriginal = editingOriginalSource.trimEnd();
1614
- const trailingWs = editingOriginalSource.slice(trimmedOriginal.length);
1615
- if (trailingWs) newSource = newSource.trimEnd() + trailingWs;
1616
-
1617
- cleanupContentEditable(blockEl);
1618
- commitBlockEdit(newSource);
1619
- }
1620
-
1621
- function cancelContentEditable(blockEl) {
1622
- cleanupContentEditable(blockEl);
1623
- cancelBlockEdit();
1624
- }
1625
-
1626
- fileContentEl.addEventListener("click", (e) => {
1627
- if (editingBlockIndex !== -1) return;
1628
-
1629
- const sel = window.getSelection();
1630
- if (sel && !sel.isCollapsed) return;
1631
-
1632
- const target = e.target;
1633
- if (target.closest("a, .mermaid-container, .block-insert-btn, .block-type-menu, .inline-editor, .block-edit-wrapper, .block-editing, .code-block-header")) return;
1634
-
1635
- // Handle comment highlight clicks — scroll to sidebar card (skip resolved, let them edit)
1636
- const highlightEl = target.closest(".comment-highlight");
1637
- if (highlightEl) {
1638
- const commentId = highlightEl.dataset.commentId;
1639
- const comment = comments.find(c => c.id === commentId);
1640
- if (comment && comment.status !== "resolved") {
1641
- for (const card of commentListEl.querySelectorAll(".comment-card")) {
1642
- card.classList.remove("active");
1643
- }
1644
- const card = commentListEl.querySelector(`.comment-card[data-id="${comment.id}"]`);
1645
- if (card) {
1646
- card.classList.add("active");
1647
- card.scrollIntoView({ behavior: "smooth", block: "nearest" });
1648
- }
1649
- return;
1650
- }
1651
- // Resolved highlights fall through to block editing
1652
- }
1653
-
1654
- if (!currentBlocks.length) return;
1655
-
1656
- const blockIndex = findClickedBlockIndex(target);
1657
- if (blockIndex === -1) return;
1658
-
1659
- enterBlockEditDispatch(blockIndex);
1660
- });
1661
-
1662
- // --- Code copy button handler ---
1663
- fileContentEl.addEventListener("click", (e) => {
1664
- const btn = e.target.closest(".code-copy-btn");
1665
- if (!btn) return;
1666
- e.stopPropagation();
1667
- const wrapper = btn.closest(".code-block-wrapper");
1668
- const code = wrapper?.querySelector("code");
1669
- if (!code) return;
1670
- navigator.clipboard.writeText(code.textContent || "").then(() => {
1671
- btn.textContent = "Copied!";
1672
- setTimeout(() => { btn.textContent = "Copy"; }, 2000);
1673
- });
1674
- });
1675
-
1676
- // --- Highlight click: clear active on outside click ---
1677
- document.addEventListener("mousedown", (e) => {
1678
- if (!e.target.closest(".comment-highlight")) {
1679
- for (const card of commentListEl.querySelectorAll(".comment-card.active")) {
1680
- card.classList.remove("active");
1681
- }
1682
- }
1683
- });
1684
-
1685
- // --- Undo Stack ---
1686
-
1687
- function saveUndoStack(file) {
1688
- try {
1689
- sessionStorage.setItem("cowrite-undo:" + file, JSON.stringify(undoStack));
1690
- } catch (e) {
1691
- undoStack.splice(0, undoStack.length - 5);
1692
- try { sessionStorage.setItem("cowrite-undo:" + file, JSON.stringify(undoStack)); } catch (_) {}
1693
- }
1694
- }
1695
-
1696
- function loadUndoStack(file) {
1697
- try {
1698
- const data = sessionStorage.getItem("cowrite-undo:" + file);
1699
- if (data) {
1700
- const parsed = JSON.parse(data);
1701
- if (Array.isArray(parsed)) return parsed;
1702
- }
1703
- } catch (e) {}
1704
- return [];
1705
- }
1706
-
1707
- function pushUndo() {
1708
- if (!currentContent || !currentFile) return;
1709
- undoStack.push({ file: currentFile, content: currentContent });
1710
- if (undoStack.length > MAX_UNDO) undoStack.shift();
1711
- undoBtn.disabled = false;
1712
- saveUndoStack(currentFile);
1713
- }
1714
-
1715
- function performUndo() {
1716
- if (undoStack.length === 0) return;
1717
- const snapshot = undoStack.pop();
1718
- if (undoStack.length === 0) undoBtn.disabled = true;
1719
- saveUndoStack(currentFile);
1720
-
1721
- send({
1722
- type: "edit_apply",
1723
- offset: 0,
1724
- length: currentContent.length,
1725
- newText: snapshot.content,
1726
- });
1727
- }
1728
-
1729
- undoBtn.addEventListener("click", performUndo);
1730
-
1731
- document.addEventListener("keydown", (e) => {
1732
- if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) {
1733
- const tag = document.activeElement?.tagName?.toLowerCase();
1734
- if (tag === "textarea" || tag === "input") return;
1735
- if (document.activeElement?.contentEditable === "true") return;
1736
-
1737
- e.preventDefault();
1738
- performUndo();
1739
- }
1740
- });
1741
-
1742
- // --- Init ---
1743
- connect();