@filipc77/cowrite 0.6.7 → 0.6.8
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/dist/bin/cowrite.js +61 -3
- package/dist/bin/cowrite.js.map +1 -1
- package/package.json +1 -1
- package/ui/index.html +22 -1
- package/ui/modules/app.js +90 -0
- package/ui/modules/block-menu.js +251 -0
- package/ui/modules/comment-anchoring.js +112 -0
- package/ui/modules/comment-highlight.js +243 -0
- package/ui/modules/comment-sidebar.js +192 -0
- package/ui/modules/editor.js +117 -0
- package/ui/modules/file-picker.js +74 -0
- package/ui/modules/markdown-sync.js +229 -0
- package/ui/modules/preferences.js +120 -0
- package/ui/modules/state.js +73 -0
- package/ui/modules/toolbar.js +408 -0
- package/ui/modules/undo-manager.js +78 -0
- package/ui/modules/utils.js +34 -0
- package/ui/modules/ws-client.js +63 -0
- package/ui/styles.css +138 -0
- package/ui/client.js +0 -1743
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { $, escapeHtml, timeAgo } from './utils.js';
|
|
4
|
+
import { state } from './state.js';
|
|
5
|
+
import { send } from './ws-client.js';
|
|
6
|
+
import { resolveAnchor } from './comment-anchoring.js';
|
|
7
|
+
import { isMarkdownFile } from './editor.js';
|
|
8
|
+
|
|
9
|
+
const fileContentEl = $("#fileContent");
|
|
10
|
+
const commentListEl = $("#commentList");
|
|
11
|
+
|
|
12
|
+
export function renderComments() {
|
|
13
|
+
if (state.comments.length === 0) {
|
|
14
|
+
commentListEl.innerHTML = `
|
|
15
|
+
<div class="empty-state">
|
|
16
|
+
<p>No comments yet.</p>
|
|
17
|
+
<p>Select text to comment, or use + for file comments.</p>
|
|
18
|
+
</div>
|
|
19
|
+
`;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
commentListEl.innerHTML = state.comments.map((c) => {
|
|
24
|
+
// Check if the anchor is orphaned (text no longer found in content)
|
|
25
|
+
const isOrphaned = c.selectedText && state.currentContent && (() => {
|
|
26
|
+
const anchor = c.anchor || {
|
|
27
|
+
textQuote: { exact: c.selectedText, prefix: '', suffix: '' },
|
|
28
|
+
offset: c.offset || 0,
|
|
29
|
+
length: c.selectedText.length,
|
|
30
|
+
};
|
|
31
|
+
return resolveAnchor(anchor, state.currentContent) === null;
|
|
32
|
+
})();
|
|
33
|
+
|
|
34
|
+
const repliesHtml = c.replies.length > 0 ? `
|
|
35
|
+
<div class="comment-replies">
|
|
36
|
+
${c.replies.map((r) => r.proposal ? `
|
|
37
|
+
<div class="reply agent proposal-reply proposal-${r.proposal.status}">
|
|
38
|
+
<div class="reply-from agent">agent \u2014 proposal</div>
|
|
39
|
+
<div class="proposal-explanation">${escapeHtml(r.proposal.explanation)}</div>
|
|
40
|
+
${r.proposal.status === "pending" ? `
|
|
41
|
+
<div class="proposal-diff">
|
|
42
|
+
<div class="proposal-old"><span class="proposal-label">Current</span><pre>${escapeHtml(r.proposal.oldText)}</pre></div>
|
|
43
|
+
<div class="proposal-new"><span class="proposal-label">Proposed</span><pre>${escapeHtml(r.proposal.newText)}</pre></div>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="proposal-actions">
|
|
46
|
+
<button class="proposal-apply-btn" onclick="applyProposal('${c.id}', '${r.id}')">Apply</button>
|
|
47
|
+
<button class="proposal-reject-btn" onclick="rejectProposal('${c.id}', '${r.id}')">Reject</button>
|
|
48
|
+
</div>
|
|
49
|
+
` : r.proposal.status === "applied" ? `
|
|
50
|
+
<div class="proposal-diff">
|
|
51
|
+
<div class="proposal-new"><span class="proposal-label">✓ Applied</span><pre>${escapeHtml(r.proposal.newText)}</pre></div>
|
|
52
|
+
</div>
|
|
53
|
+
` : `
|
|
54
|
+
<div class="proposal-diff">
|
|
55
|
+
<div class="proposal-old"><span class="proposal-label">✗ Rejected</span><pre>${escapeHtml(r.proposal.oldText)}</pre></div>
|
|
56
|
+
</div>
|
|
57
|
+
`}
|
|
58
|
+
</div>
|
|
59
|
+
` : `
|
|
60
|
+
<div class="reply ${r.from}">
|
|
61
|
+
<div class="reply-from ${r.from}">${r.from}</div>
|
|
62
|
+
<div>${escapeHtml(r.text)}</div>
|
|
63
|
+
</div>
|
|
64
|
+
`).join("")}
|
|
65
|
+
</div>
|
|
66
|
+
` : "";
|
|
67
|
+
|
|
68
|
+
if (c.status === "resolved") {
|
|
69
|
+
const truncated = c.comment.length > 60 ? c.comment.slice(0, 60) + "..." : c.comment;
|
|
70
|
+
return `
|
|
71
|
+
<div class="comment-card resolved" data-id="${c.id}">
|
|
72
|
+
<button class="comment-delete-btn" onclick="deleteComment('${c.id}')" title="Delete comment">
|
|
73
|
+
<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>
|
|
74
|
+
</button>
|
|
75
|
+
<div class="resolved-summary" onclick="toggleResolvedExpand('${c.id}')">
|
|
76
|
+
<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>
|
|
77
|
+
<span class="comment-status resolved">resolved</span>
|
|
78
|
+
<span class="resolved-summary-text">${escapeHtml(truncated)}</span>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="resolved-details" hidden>
|
|
81
|
+
${c.selectedText
|
|
82
|
+
? `<div class="comment-selected-text">${escapeHtml(c.selectedText)}</div>`
|
|
83
|
+
: `<div class="comment-file-badge">Whole file</div>`
|
|
84
|
+
}
|
|
85
|
+
<div class="comment-text">${escapeHtml(c.comment)}</div>
|
|
86
|
+
${repliesHtml}
|
|
87
|
+
<div class="comment-meta">
|
|
88
|
+
<span>${timeAgo(c.createdAt)}</span>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="comment-actions">
|
|
91
|
+
<button onclick="reopenComment('${c.id}')">Reopen</button>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return `
|
|
99
|
+
<div class="comment-card ${c.status}${isOrphaned ? ' orphaned' : ''}" data-id="${c.id}">
|
|
100
|
+
<button class="comment-delete-btn" onclick="deleteComment('${c.id}')" title="Delete comment">
|
|
101
|
+
<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>
|
|
102
|
+
</button>
|
|
103
|
+
${c.selectedText
|
|
104
|
+
? `<div class="comment-selected-text">${escapeHtml(c.selectedText)}${isOrphaned ? '<span class="orphaned-badge">Anchor lost</span>' : ''}</div>`
|
|
105
|
+
: `<div class="comment-file-badge">Whole file</div>`
|
|
106
|
+
}
|
|
107
|
+
<div class="comment-text">${escapeHtml(c.comment)}</div>
|
|
108
|
+
${repliesHtml}
|
|
109
|
+
<div class="comment-meta">
|
|
110
|
+
<span>${timeAgo(c.createdAt)}</span>
|
|
111
|
+
<span class="comment-status ${c.status}">${c.status}</span>
|
|
112
|
+
</div>
|
|
113
|
+
<div class="comment-actions">
|
|
114
|
+
<button onclick="showReplyForm('${c.id}')">Reply</button>
|
|
115
|
+
<button onclick="resolveComment('${c.id}')">Resolve</button>
|
|
116
|
+
</div>
|
|
117
|
+
<div class="reply-form" id="reply-form-${c.id}" hidden>
|
|
118
|
+
<textarea rows="2" placeholder="Reply..."></textarea>
|
|
119
|
+
<div class="reply-form-actions">
|
|
120
|
+
<button onclick="submitReply('${c.id}')">Send</button>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
`;
|
|
125
|
+
}).join("");
|
|
126
|
+
|
|
127
|
+
// Click to scroll to highlight
|
|
128
|
+
for (const card of commentListEl.querySelectorAll(".comment-card")) {
|
|
129
|
+
card.addEventListener("click", (e) => {
|
|
130
|
+
if (e.target.tagName === "BUTTON" || e.target.tagName === "TEXTAREA") return;
|
|
131
|
+
const id = card.dataset.id;
|
|
132
|
+
const highlight = fileContentEl.querySelector(`[data-comment-id="${id}"]`);
|
|
133
|
+
if (highlight) {
|
|
134
|
+
highlight.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
135
|
+
highlight.style.outline = "2px solid var(--accent)";
|
|
136
|
+
setTimeout(() => highlight.style.outline = "", 1500);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function initCommentSidebar() {
|
|
143
|
+
// Global functions for inline onclick handlers
|
|
144
|
+
window.showReplyForm = function (id) {
|
|
145
|
+
const form = document.getElementById(`reply-form-${id}`);
|
|
146
|
+
if (form) {
|
|
147
|
+
form.hidden = !form.hidden;
|
|
148
|
+
if (!form.hidden) form.querySelector("textarea").focus();
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
window.resolveComment = function (id) {
|
|
153
|
+
send({ type: "comment_resolve", commentId: id });
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
window.reopenComment = function (id) {
|
|
157
|
+
send({ type: "comment_reopen", commentId: id });
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
window.deleteComment = function (id) {
|
|
161
|
+
send({ type: "comment_delete", commentId: id });
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
window.submitReply = function (id) {
|
|
165
|
+
const form = document.getElementById(`reply-form-${id}`);
|
|
166
|
+
const textarea = form?.querySelector("textarea");
|
|
167
|
+
const text = textarea?.value.trim();
|
|
168
|
+
if (!text) return;
|
|
169
|
+
send({ type: "comment_reply", commentId: id, text });
|
|
170
|
+
textarea.value = "";
|
|
171
|
+
form.hidden = true;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
window.applyProposal = function (commentId, replyId) {
|
|
175
|
+
send({ type: "proposal_apply", commentId, replyId });
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
window.rejectProposal = function (commentId, replyId) {
|
|
179
|
+
send({ type: "proposal_reject", commentId, replyId });
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
window.toggleResolvedExpand = function (id) {
|
|
183
|
+
const card = commentListEl.querySelector(`.comment-card[data-id="${id}"]`);
|
|
184
|
+
if (!card) return;
|
|
185
|
+
const details = card.querySelector(".resolved-details");
|
|
186
|
+
const chevron = card.querySelector(".resolved-chevron");
|
|
187
|
+
if (!details) return;
|
|
188
|
+
const expanding = details.hidden;
|
|
189
|
+
details.hidden = !expanding;
|
|
190
|
+
card.classList.toggle("resolved-expanded", expanding);
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { Editor } from '@tiptap/core';
|
|
3
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
4
|
+
import { Markdown } from 'tiptap-markdown';
|
|
5
|
+
import Table from '@tiptap/extension-table';
|
|
6
|
+
import TableRow from '@tiptap/extension-table-row';
|
|
7
|
+
import TableCell from '@tiptap/extension-table-cell';
|
|
8
|
+
import TableHeader from '@tiptap/extension-table-header';
|
|
9
|
+
import TaskList from '@tiptap/extension-task-list';
|
|
10
|
+
import TaskItem from '@tiptap/extension-task-item';
|
|
11
|
+
import Placeholder from '@tiptap/extension-placeholder';
|
|
12
|
+
|
|
13
|
+
let editor = null;
|
|
14
|
+
let isProgrammaticUpdate = false;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create the TipTap editor and mount it into the given container.
|
|
18
|
+
* @param {HTMLElement} container
|
|
19
|
+
* @param {object} options
|
|
20
|
+
* @param {(markdown: string) => void} [options.onUpdate] - Called on content change
|
|
21
|
+
* @param {(params: {editor: any}) => void} [options.onSelectionUpdate] - Called on selection change
|
|
22
|
+
* @param {Array} [options.extensions] - Extra TipTap extensions to include
|
|
23
|
+
* @returns {Editor}
|
|
24
|
+
*/
|
|
25
|
+
export function createEditor(container, options = {}) {
|
|
26
|
+
if (editor) editor.destroy();
|
|
27
|
+
|
|
28
|
+
editor = new Editor({
|
|
29
|
+
element: container,
|
|
30
|
+
editable: true,
|
|
31
|
+
extensions: [
|
|
32
|
+
StarterKit.configure({
|
|
33
|
+
codeBlock: {
|
|
34
|
+
HTMLAttributes: { class: 'hljs' },
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
Markdown.configure({
|
|
38
|
+
html: true,
|
|
39
|
+
tightLists: true,
|
|
40
|
+
transformPastedText: true,
|
|
41
|
+
transformCopiedText: true,
|
|
42
|
+
}),
|
|
43
|
+
Table.configure({ resizable: false }),
|
|
44
|
+
TableRow,
|
|
45
|
+
TableCell,
|
|
46
|
+
TableHeader,
|
|
47
|
+
TaskList,
|
|
48
|
+
TaskItem.configure({ nested: true }),
|
|
49
|
+
Placeholder.configure({
|
|
50
|
+
placeholder: 'Type / for commands...',
|
|
51
|
+
}),
|
|
52
|
+
...(options.extensions || []),
|
|
53
|
+
],
|
|
54
|
+
onUpdate({ editor }) {
|
|
55
|
+
if (options.onUpdate) {
|
|
56
|
+
const md = editor.storage.markdown.getMarkdown();
|
|
57
|
+
options.onUpdate(md);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
onSelectionUpdate({ editor }) {
|
|
61
|
+
if (options.onSelectionUpdate) {
|
|
62
|
+
options.onSelectionUpdate({ editor });
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// @ts-ignore - expose for browser testing
|
|
68
|
+
window.__tiptap = editor;
|
|
69
|
+
|
|
70
|
+
return editor;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Set the editor content from markdown string.
|
|
75
|
+
* @param {string} markdown
|
|
76
|
+
*/
|
|
77
|
+
export function setMarkdownContent(markdown) {
|
|
78
|
+
if (!editor) return;
|
|
79
|
+
isProgrammaticUpdate = true;
|
|
80
|
+
editor.commands.setContent(markdown, false);
|
|
81
|
+
isProgrammaticUpdate = false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if a programmatic content update is in progress.
|
|
86
|
+
* Used to prevent onUpdate from setting editorDirty during setMarkdownContent().
|
|
87
|
+
* @returns {boolean}
|
|
88
|
+
*/
|
|
89
|
+
export function isProgrammaticContentUpdate() {
|
|
90
|
+
return isProgrammaticUpdate;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get the current editor content as markdown.
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
export function getMarkdownContent() {
|
|
98
|
+
if (!editor) return '';
|
|
99
|
+
return editor.storage.markdown.getMarkdown();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get the TipTap editor instance.
|
|
104
|
+
* @returns {Editor|null}
|
|
105
|
+
*/
|
|
106
|
+
export function getEditor() {
|
|
107
|
+
return editor;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check if a file is markdown based on extension.
|
|
112
|
+
* @param {string} filePath
|
|
113
|
+
* @returns {boolean}
|
|
114
|
+
*/
|
|
115
|
+
export function isMarkdownFile(filePath) {
|
|
116
|
+
return /\.(md|mdx|markdown)$/i.test(filePath);
|
|
117
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { $ } from './utils.js';
|
|
4
|
+
import { state } from './state.js';
|
|
5
|
+
import { send } from './ws-client.js';
|
|
6
|
+
import { loadUndoStack, saveUndoStack } from './undo-manager.js';
|
|
7
|
+
|
|
8
|
+
const filePicker = /** @type {HTMLInputElement} */ ($("#filePicker"));
|
|
9
|
+
const fileList = $("#fileList");
|
|
10
|
+
const undoBtn = /** @type {HTMLButtonElement} */ ($("#undoBtn"));
|
|
11
|
+
|
|
12
|
+
// Track meta key for Cmd+Click to open in new tab
|
|
13
|
+
let lastClickHadMeta = false;
|
|
14
|
+
document.addEventListener("mousedown", (e) => { lastClickHadMeta = e.metaKey || e.ctrlKey; });
|
|
15
|
+
|
|
16
|
+
export async function loadFileList() {
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch("/api/files");
|
|
19
|
+
const data = await res.json();
|
|
20
|
+
fileList.innerHTML = "";
|
|
21
|
+
for (const file of data.files) {
|
|
22
|
+
if (!/\.(md|markdown|mdx)$/i.test(file)) continue;
|
|
23
|
+
const option = document.createElement("option");
|
|
24
|
+
option.value = file;
|
|
25
|
+
fileList.appendChild(option);
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
// Will retry on reconnect
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function switchFile(file) {
|
|
33
|
+
if (!file || !state.ws || state.ws.readyState !== WebSocket.OPEN) return;
|
|
34
|
+
if (state.currentFile) saveUndoStack(state.currentFile);
|
|
35
|
+
state.undoStack = loadUndoStack(file);
|
|
36
|
+
undoBtn.disabled = state.undoStack.length === 0;
|
|
37
|
+
send({ type: "switch_file", file });
|
|
38
|
+
filePicker.value = "";
|
|
39
|
+
// Update URL without reload
|
|
40
|
+
const url = new URL(location.href);
|
|
41
|
+
url.searchParams.set("file", file);
|
|
42
|
+
history.replaceState(null, "", url.toString());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function openFileInNewTab(file) {
|
|
46
|
+
const url = new URL(location.href);
|
|
47
|
+
url.searchParams.set("file", file);
|
|
48
|
+
window.open(url.toString(), "_blank");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function initFilePicker() {
|
|
52
|
+
filePicker.addEventListener("change", () => {
|
|
53
|
+
const file = filePicker.value.trim();
|
|
54
|
+
if (!file) return;
|
|
55
|
+
if (lastClickHadMeta) {
|
|
56
|
+
openFileInNewTab(file);
|
|
57
|
+
filePicker.value = "";
|
|
58
|
+
} else {
|
|
59
|
+
switchFile(file);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
filePicker.addEventListener("keydown", (e) => {
|
|
64
|
+
const file = filePicker.value.trim();
|
|
65
|
+
if (!file) return;
|
|
66
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
openFileInNewTab(file);
|
|
69
|
+
filePicker.value = "";
|
|
70
|
+
} else if (e.key === "Enter") {
|
|
71
|
+
switchFile(file);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { state } from './state.js';
|
|
3
|
+
import { getEditor, setMarkdownContent, getMarkdownContent, isMarkdownFile } from './editor.js';
|
|
4
|
+
import { send } from './ws-client.js';
|
|
5
|
+
import { pushUndo } from './undo-manager.js';
|
|
6
|
+
import { loadUndoStack } from './undo-manager.js';
|
|
7
|
+
import { applyHighlights } from './comment-highlight.js';
|
|
8
|
+
import { $ } from './utils.js';
|
|
9
|
+
|
|
10
|
+
let pendingUpdate = null;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Render mermaid diagrams found in the ProseMirror DOM.
|
|
14
|
+
* Converts `pre > code.language-mermaid` blocks into rendered SVG diagrams.
|
|
15
|
+
*/
|
|
16
|
+
async function renderMermaidDiagrams() {
|
|
17
|
+
if (!window.__mermaid) return;
|
|
18
|
+
const fileContentEl = $('#fileContent');
|
|
19
|
+
const blocks = fileContentEl.querySelectorAll('pre code.language-mermaid');
|
|
20
|
+
if (blocks.length === 0) return;
|
|
21
|
+
|
|
22
|
+
const containers = [];
|
|
23
|
+
for (const code of blocks) {
|
|
24
|
+
const pre = code.parentElement;
|
|
25
|
+
if (!pre) continue;
|
|
26
|
+
// Wrap in a mermaid-container if not already
|
|
27
|
+
if (!pre.parentElement?.classList.contains('mermaid-container')) {
|
|
28
|
+
const wrapper = document.createElement('div');
|
|
29
|
+
wrapper.className = 'mermaid-container';
|
|
30
|
+
pre.parentElement.insertBefore(wrapper, pre);
|
|
31
|
+
wrapper.appendChild(pre);
|
|
32
|
+
}
|
|
33
|
+
pre.classList.add('mermaid');
|
|
34
|
+
containers.push(pre);
|
|
35
|
+
}
|
|
36
|
+
if (containers.length === 0) return;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
await window.__mermaid.run({ nodes: containers });
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.error('Mermaid rendering failed:', err);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Enhance code blocks with a header (language label + copy button).
|
|
47
|
+
* Skips mermaid blocks and already-wrapped blocks.
|
|
48
|
+
*/
|
|
49
|
+
function enhanceCodeBlocks() {
|
|
50
|
+
const fileContentEl = $('#fileContent');
|
|
51
|
+
const codeBlocks = fileContentEl.querySelectorAll('.ProseMirror pre');
|
|
52
|
+
|
|
53
|
+
for (const pre of codeBlocks) {
|
|
54
|
+
// Skip mermaid blocks
|
|
55
|
+
if (pre.closest('.mermaid-container')) continue;
|
|
56
|
+
// Skip already wrapped blocks
|
|
57
|
+
if (pre.parentElement?.classList.contains('code-block-wrapper')) continue;
|
|
58
|
+
|
|
59
|
+
const code = pre.querySelector('code');
|
|
60
|
+
const langClass = code?.className.match(/language-(\w+)/);
|
|
61
|
+
const lang = langClass ? langClass[1] : '';
|
|
62
|
+
|
|
63
|
+
const wrapper = document.createElement('div');
|
|
64
|
+
wrapper.className = 'code-block-wrapper';
|
|
65
|
+
|
|
66
|
+
const header = document.createElement('div');
|
|
67
|
+
header.className = 'code-block-header';
|
|
68
|
+
|
|
69
|
+
const langSpan = document.createElement('span');
|
|
70
|
+
langSpan.className = 'code-block-lang';
|
|
71
|
+
langSpan.textContent = lang || 'code';
|
|
72
|
+
header.appendChild(langSpan);
|
|
73
|
+
|
|
74
|
+
const copyBtn = document.createElement('button');
|
|
75
|
+
copyBtn.className = 'code-copy-btn';
|
|
76
|
+
copyBtn.textContent = 'Copy';
|
|
77
|
+
header.appendChild(copyBtn);
|
|
78
|
+
|
|
79
|
+
pre.parentElement.insertBefore(wrapper, pre);
|
|
80
|
+
wrapper.appendChild(header);
|
|
81
|
+
wrapper.appendChild(pre);
|
|
82
|
+
|
|
83
|
+
// Copy button handler
|
|
84
|
+
copyBtn.addEventListener('click', (e) => {
|
|
85
|
+
e.stopPropagation();
|
|
86
|
+
navigator.clipboard.writeText(code?.textContent || '').then(() => {
|
|
87
|
+
copyBtn.textContent = 'Copied!';
|
|
88
|
+
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 2000);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Post-process ProseMirror content: enhance code blocks + render mermaid.
|
|
96
|
+
*/
|
|
97
|
+
async function postProcessContent() {
|
|
98
|
+
enhanceCodeBlocks();
|
|
99
|
+
await renderMermaidDiagrams();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Handle file_update from WebSocket.
|
|
104
|
+
* For markdown files: update TipTap editor content.
|
|
105
|
+
* For non-markdown files: render as plain text with data-offset spans.
|
|
106
|
+
* @param {object} msg - {file, content, html}
|
|
107
|
+
*/
|
|
108
|
+
export function handleFileUpdate(msg) {
|
|
109
|
+
const filePathEl = $('#filePath');
|
|
110
|
+
filePathEl.textContent = msg.file;
|
|
111
|
+
|
|
112
|
+
const fileChanged = state.currentFile !== msg.file;
|
|
113
|
+
state.currentFile = msg.file;
|
|
114
|
+
state.currentContent = msg.content;
|
|
115
|
+
state.currentHtml = msg.html;
|
|
116
|
+
|
|
117
|
+
if (fileChanged && msg.file) {
|
|
118
|
+
state.undoStack = loadUndoStack(msg.file);
|
|
119
|
+
const undoBtn = /** @type {HTMLButtonElement} */ ($('#undoBtn'));
|
|
120
|
+
undoBtn.disabled = state.undoStack.length === 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const fileContentEl = $('#fileContent');
|
|
124
|
+
const editor = getEditor();
|
|
125
|
+
|
|
126
|
+
if (!isMarkdownFile(msg.file)) {
|
|
127
|
+
// Non-markdown: use server-rendered HTML (plain text with data-offset spans)
|
|
128
|
+
// Hide TipTap editor, show plain HTML
|
|
129
|
+
const proseMirror = fileContentEl.querySelector('.ProseMirror');
|
|
130
|
+
if (proseMirror) /** @type {HTMLElement} */ (proseMirror).style.display = 'none';
|
|
131
|
+
|
|
132
|
+
// Create or reuse a plain-text container
|
|
133
|
+
let plainContainer = /** @type {HTMLElement|null} */ (fileContentEl.querySelector('.plain-text-container'));
|
|
134
|
+
if (!plainContainer) {
|
|
135
|
+
plainContainer = document.createElement('div');
|
|
136
|
+
plainContainer.className = 'plain-text-container';
|
|
137
|
+
fileContentEl.appendChild(plainContainer);
|
|
138
|
+
}
|
|
139
|
+
plainContainer.style.display = '';
|
|
140
|
+
plainContainer.innerHTML = msg.html;
|
|
141
|
+
applyHighlights(null, false);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Markdown file: use TipTap
|
|
146
|
+
// Show TipTap, hide plain container
|
|
147
|
+
const proseMirror = fileContentEl.querySelector('.ProseMirror');
|
|
148
|
+
if (proseMirror) /** @type {HTMLElement} */ (proseMirror).style.display = '';
|
|
149
|
+
const plainContainer = /** @type {HTMLElement|null} */ (fileContentEl.querySelector('.plain-text-container'));
|
|
150
|
+
if (plainContainer) plainContainer.style.display = 'none';
|
|
151
|
+
|
|
152
|
+
if (editor && editor.isFocused) {
|
|
153
|
+
// Queue update for when editor loses focus
|
|
154
|
+
pendingUpdate = msg;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (editor) {
|
|
159
|
+
// Apply content update
|
|
160
|
+
setMarkdownContent(msg.content);
|
|
161
|
+
postProcessContent().then(() => applyHighlights(editor, true));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Apply any pending file update (called when editor loses focus).
|
|
167
|
+
*/
|
|
168
|
+
export function applyPendingUpdate() {
|
|
169
|
+
if (!pendingUpdate) return;
|
|
170
|
+
const msg = pendingUpdate;
|
|
171
|
+
pendingUpdate = null;
|
|
172
|
+
|
|
173
|
+
state.currentContent = msg.content;
|
|
174
|
+
state.currentHtml = msg.html;
|
|
175
|
+
state.editorDirty = false;
|
|
176
|
+
setMarkdownContent(msg.content);
|
|
177
|
+
const editor = getEditor();
|
|
178
|
+
postProcessContent().then(() => applyHighlights(editor, true));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Submit the current editor content as an edit if it changed.
|
|
183
|
+
*/
|
|
184
|
+
export function submitEdit() {
|
|
185
|
+
const editor = getEditor();
|
|
186
|
+
if (!editor) return;
|
|
187
|
+
|
|
188
|
+
// Only submit if the user actually edited (typed/pasted/formatted),
|
|
189
|
+
// not just clicked in and out. TipTap's markdown serialization isn't
|
|
190
|
+
// lossless, so comparing content alone causes false positives that
|
|
191
|
+
// corrupt the file.
|
|
192
|
+
if (!state.editorDirty) return;
|
|
193
|
+
|
|
194
|
+
const newContent = getMarkdownContent();
|
|
195
|
+
if (newContent === state.currentContent) {
|
|
196
|
+
state.editorDirty = false;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
pushUndo();
|
|
201
|
+
send({
|
|
202
|
+
type: 'edit_apply',
|
|
203
|
+
offset: 0,
|
|
204
|
+
length: state.currentContent.length,
|
|
205
|
+
newText: newContent,
|
|
206
|
+
});
|
|
207
|
+
state.currentContent = newContent;
|
|
208
|
+
state.editorDirty = false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Re-render content for theme changes etc.
|
|
213
|
+
* For TipTap markdown files, highlights just need to be re-applied.
|
|
214
|
+
* For non-markdown files, re-render from stored HTML.
|
|
215
|
+
*/
|
|
216
|
+
export function reRenderContent() {
|
|
217
|
+
const editor = getEditor();
|
|
218
|
+
const isMd = isMarkdownFile(state.currentFile);
|
|
219
|
+
if (isMd) {
|
|
220
|
+
postProcessContent().then(() => applyHighlights(editor, true));
|
|
221
|
+
} else {
|
|
222
|
+
const fileContentEl = $('#fileContent');
|
|
223
|
+
const plainContainer = /** @type {HTMLElement|null} */ (fileContentEl.querySelector('.plain-text-container'));
|
|
224
|
+
if (plainContainer) {
|
|
225
|
+
plainContainer.innerHTML = state.currentHtml;
|
|
226
|
+
}
|
|
227
|
+
applyHighlights(null, false);
|
|
228
|
+
}
|
|
229
|
+
}
|