@filipc77/cowrite 0.6.6 → 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.
@@ -0,0 +1,120 @@
1
+ // @ts-check
2
+
3
+ import { $ } from './utils.js';
4
+ import { state } from './state.js';
5
+
6
+ // --- Resizable Sidebar ---
7
+
8
+ function initResizableSidebar() {
9
+ const handle = document.getElementById("sidebarDragHandle");
10
+ const sidebar = document.getElementById("sidebar");
11
+ if (!handle || !sidebar) return;
12
+
13
+ // Restore saved width
14
+ const saved = localStorage.getItem("cowrite-sidebar-width");
15
+ if (saved) document.documentElement.style.setProperty("--sidebar-width", saved + "px");
16
+
17
+ let startX = 0;
18
+ let startWidth = 0;
19
+
20
+ handle.addEventListener("mousedown", (e) => {
21
+ e.preventDefault();
22
+ startX = e.clientX;
23
+ startWidth = sidebar.offsetWidth;
24
+ document.body.classList.add("sidebar-resizing");
25
+ document.addEventListener("mousemove", onMouseMove);
26
+ document.addEventListener("mouseup", onMouseUp);
27
+ });
28
+
29
+ function onMouseMove(e) {
30
+ const delta = startX - e.clientX; // sidebar is on the right
31
+ const newWidth = Math.min(Math.max(startWidth + delta, 300), window.innerWidth * 0.5);
32
+ document.documentElement.style.setProperty("--sidebar-width", newWidth + "px");
33
+ }
34
+
35
+ function onMouseUp() {
36
+ document.body.classList.remove("sidebar-resizing");
37
+ document.removeEventListener("mousemove", onMouseMove);
38
+ document.removeEventListener("mouseup", onMouseUp);
39
+ const width = sidebar.offsetWidth;
40
+ localStorage.setItem("cowrite-sidebar-width", String(width));
41
+ }
42
+ }
43
+
44
+ // --- Theme Toggle ---
45
+
46
+ const THEME_KEY = "cowrite-theme";
47
+
48
+ /** @type {(renderFn: () => void) => void} */
49
+ let onThemeRender = () => {};
50
+
51
+ function applyTheme(theme) {
52
+ const themeToggle = /** @type {HTMLInputElement} */ ($("#themeToggle"));
53
+
54
+ document.documentElement.setAttribute("data-theme", theme);
55
+ themeToggle.checked = theme === "light";
56
+ const icon = themeToggle.closest(".theme-toggle")?.querySelector(".toggle-icon");
57
+ if (icon) icon.textContent = theme === "light" ? "\u2600" : "\u263E";
58
+ const label = document.querySelector(".toggle-label");
59
+ if (label) label.textContent = theme === "light" ? "Light" : "Dark";
60
+ const hljsDark = document.getElementById("hljs-dark");
61
+ const hljsLight = document.getElementById("hljs-light");
62
+ if (hljsDark && hljsLight) {
63
+ hljsDark.disabled = theme === "light";
64
+ hljsLight.disabled = theme !== "light";
65
+ }
66
+ const gmcDark = document.getElementById("gmc-dark");
67
+ const gmcLight = document.getElementById("gmc-light");
68
+ if (gmcDark && gmcLight) {
69
+ gmcDark.disabled = theme === "light";
70
+ gmcLight.disabled = theme !== "light";
71
+ }
72
+ if (window.__mermaid && state.currentHtml) {
73
+ window.__mermaid.initialize({ startOnLoad: false, theme: theme === "light" ? "default" : "dark" });
74
+ onThemeRender();
75
+ }
76
+ }
77
+
78
+ function initTheme(renderCallback) {
79
+ onThemeRender = renderCallback;
80
+ const themeToggle = /** @type {HTMLInputElement} */ ($("#themeToggle"));
81
+
82
+ const savedTheme = localStorage.getItem(THEME_KEY) || "dark";
83
+ applyTheme(savedTheme);
84
+
85
+ themeToggle.addEventListener("change", () => {
86
+ const theme = themeToggle.checked ? "light" : "dark";
87
+ localStorage.setItem(THEME_KEY, theme);
88
+ applyTheme(theme);
89
+ });
90
+ }
91
+
92
+ // --- Font Size Toggle ---
93
+
94
+ const FONT_SIZE_KEY = "cowrite-font-size";
95
+
96
+ function initFontSize() {
97
+ const saved = localStorage.getItem(FONT_SIZE_KEY) || "large";
98
+ if (saved === "large") document.body.classList.add("font-large");
99
+ for (const btn of document.querySelectorAll(".font-size-btn")) {
100
+ btn.setAttribute("aria-pressed", btn.dataset.size === saved ? "true" : "false");
101
+ btn.addEventListener("click", () => {
102
+ const size = btn.dataset.size;
103
+ document.body.classList.toggle("font-large", size === "large");
104
+ localStorage.setItem(FONT_SIZE_KEY, size);
105
+ for (const b of document.querySelectorAll(".font-size-btn")) {
106
+ b.setAttribute("aria-pressed", b.dataset.size === size ? "true" : "false");
107
+ }
108
+ });
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Initialize all preference-related UI (sidebar, theme, font size).
114
+ * @param {() => void} renderCallback - Called when theme change requires re-rendering content
115
+ */
116
+ export function initPreferences(renderCallback) {
117
+ initResizableSidebar();
118
+ initTheme(renderCallback);
119
+ initFontSize();
120
+ }
@@ -0,0 +1,73 @@
1
+ // @ts-check
2
+
3
+ /** @typedef {import('../../src/types.js').Comment} Comment */
4
+
5
+ /**
6
+ * Centralized application state.
7
+ */
8
+ export const state = {
9
+ /** @type {Comment[]} */
10
+ comments: [],
11
+ /** @type {string} */
12
+ currentFile: "",
13
+ /** @type {string} */
14
+ currentContent: "",
15
+ /** @type {WebSocket|null} */
16
+ ws: null,
17
+ /** @type {{offset: number, length: number, selectedText: string, anchor?: {textQuote: {exact: string, prefix: string, suffix: string}, offset: number, length: number}}|null} */
18
+ selectionInfo: null,
19
+ /** @type {string} */
20
+ currentHtml: "",
21
+ /** @type {Array<{sourceStart: number, sourceEnd: number}>} */
22
+ currentBlocks: [],
23
+ /** @type {HTMLElement|null} */
24
+ insertBtn: null,
25
+ /** @type {HTMLElement|null} */
26
+ insertLine: null,
27
+ /** @type {number} */
28
+ activeGapIndex: -1,
29
+ /** @type {Array<{file: string, content: string}>} */
30
+ undoStack: [],
31
+ /** @type {number} */
32
+ MAX_UNDO: 50,
33
+ // Click-to-edit state
34
+ /** @type {number} */
35
+ editingBlockIndex: -1,
36
+ /** @type {HTMLElement|null} */
37
+ editingBlockEl: null,
38
+ /** @type {string} */
39
+ editingOriginalSource: "",
40
+ /** @type {string} */
41
+ editingContentSnapshot: "",
42
+ /** @type {object|null} */
43
+ pendingFileUpdate: null,
44
+ /** @type {number} */
45
+ pendingEditAfterInsert: -1,
46
+ /** @type {boolean} */
47
+ contentEditableActive: false,
48
+ /** @type {boolean} Whether the user has actually edited content in TipTap (not just clicked) */
49
+ editorDirty: false,
50
+ };
51
+
52
+ // --- Simple pub/sub ---
53
+
54
+ /** @type {Map<string, Set<Function>>} */
55
+ const listeners = new Map();
56
+
57
+ /**
58
+ * Subscribe to state change notifications.
59
+ * @param {string} key
60
+ * @param {Function} fn
61
+ */
62
+ export function subscribe(key, fn) {
63
+ if (!listeners.has(key)) listeners.set(key, new Set());
64
+ listeners.get(key).add(fn);
65
+ }
66
+
67
+ /**
68
+ * Notify all subscribers of a state change.
69
+ * @param {string} key
70
+ */
71
+ export function notify(key) {
72
+ if (listeners.has(key)) listeners.get(key).forEach((fn) => fn());
73
+ }
@@ -0,0 +1,408 @@
1
+ // @ts-check
2
+
3
+ import { $ } from './utils.js';
4
+ import { state } from './state.js';
5
+ import { send } from './ws-client.js';
6
+ import { pushUndo } from './undo-manager.js';
7
+ import { getEditor, isMarkdownFile } from './editor.js';
8
+ import { createAnchor } from './comment-anchoring.js';
9
+
10
+ const fileContentEl = $("#fileContent");
11
+ const popup = $("#commentPopup");
12
+ const popupSelection = $("#popupSelection");
13
+ const commentInput = /** @type {HTMLTextAreaElement} */ ($("#commentInput"));
14
+ const selectionToolbar = $("#selectionToolbar");
15
+ const commentTrigger = $("#commentTrigger");
16
+ const formatButtons = $("#formatButtons");
17
+ const fileCommentBtn = $("#fileCommentBtn");
18
+
19
+ // --- Rich text formatting (markdown files only) ---
20
+
21
+ const FORMAT_SYNTAX = {
22
+ bold: { prefix: "**", suffix: "**" },
23
+ italic: { prefix: "*", suffix: "*" },
24
+ strikethrough: { prefix: "~~", suffix: "~~" },
25
+ code: { prefix: "`", suffix: "`" },
26
+ };
27
+
28
+ function computeOffset(selection, text) {
29
+ const range = selection.getRangeAt(0);
30
+ const startNode = range.startContainer;
31
+ const startCharOffset = range.startOffset;
32
+
33
+ // Walk up from the range start to find a [data-offset] element
34
+ let lineEl = startNode.nodeType === Node.TEXT_NODE ? startNode.parentElement : startNode;
35
+ while (lineEl && !lineEl.dataset?.offset && lineEl !== fileContentEl) {
36
+ lineEl = lineEl.parentElement;
37
+ }
38
+
39
+ if (lineEl?.dataset?.offset) {
40
+ // Plain text mode: use data-offset directly
41
+ const lineOffset = parseInt(lineEl.dataset.offset, 10);
42
+ // Count characters before the selection start within this line span
43
+ let charsBefore = 0;
44
+ const walker = document.createTreeWalker(lineEl, NodeFilter.SHOW_TEXT);
45
+ while (walker.nextNode()) {
46
+ if (walker.currentNode === startNode) {
47
+ charsBefore += startCharOffset;
48
+ break;
49
+ }
50
+ charsBefore += walker.currentNode.textContent?.length || 0;
51
+ }
52
+ return lineOffset + charsBefore;
53
+ }
54
+
55
+ // Markdown mode: search for the text in current content near visual position
56
+ const idx = state.currentContent.indexOf(text);
57
+ if (idx !== -1) return idx;
58
+
59
+ // Exact match failed -- likely a cross-element selection (e.g. multiple list
60
+ // items) where the browser's selection.toString() strips markdown syntax.
61
+ // Use the DOM block structure to scope the search.
62
+ const markdownBody = fileContentEl.querySelector(".markdown-body");
63
+ if (markdownBody && state.currentBlocks.length) {
64
+ let el = startNode.nodeType === Node.TEXT_NODE ? startNode.parentElement : startNode;
65
+ while (el && el.parentElement !== markdownBody) {
66
+ el = el.parentElement;
67
+ }
68
+ if (el && el.parentElement === markdownBody) {
69
+ let blockIndex = 0;
70
+ let sib = el.previousElementSibling;
71
+ while (sib) {
72
+ blockIndex++;
73
+ sib = sib.previousElementSibling;
74
+ }
75
+ if (blockIndex < state.currentBlocks.length) {
76
+ const block = state.currentBlocks[blockIndex];
77
+ const blockSource = state.currentContent.slice(block.sourceStart, block.sourceEnd);
78
+ const firstLine = text.split("\n")[0].trim();
79
+ if (firstLine) {
80
+ const lineIdx = blockSource.indexOf(firstLine);
81
+ if (lineIdx !== -1) return block.sourceStart + lineIdx;
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ // Last resort: match the first line of the selection anywhere in the content
88
+ const firstLine = text.split("\n")[0].trim();
89
+ if (firstLine && firstLine !== text) {
90
+ const flIdx = state.currentContent.indexOf(firstLine);
91
+ if (flIdx !== -1) return flIdx;
92
+ }
93
+
94
+ return -1;
95
+ }
96
+
97
+ function hideTrigger() {
98
+ selectionToolbar.hidden = true;
99
+ state.selectionInfo = null;
100
+ }
101
+
102
+ function applyTipTapFormat(editor, format) {
103
+ switch (format) {
104
+ case 'bold': editor.chain().focus().toggleBold().run(); break;
105
+ case 'italic': editor.chain().focus().toggleItalic().run(); break;
106
+ case 'strikethrough': editor.chain().focus().toggleStrike().run(); break;
107
+ case 'code': editor.chain().focus().toggleCode().run(); break;
108
+ case 'link': {
109
+ const url = prompt('Link URL:', 'https://');
110
+ if (url) editor.chain().focus().setLink({ href: url }).run();
111
+ break;
112
+ }
113
+ case 'blockquote': editor.chain().focus().toggleBlockquote().run(); break;
114
+ case 'bulletList': editor.chain().focus().toggleBulletList().run(); break;
115
+ }
116
+ }
117
+
118
+ function applyMarkdownFormat(format) {
119
+ if (!state.selectionInfo || state.selectionInfo.offset < 0) return;
120
+
121
+ // Use TipTap commands for markdown files when editor is available
122
+ const editor = getEditor();
123
+ if (editor && isMarkdownFile(state.currentFile)) {
124
+ applyTipTapFormat(editor, format);
125
+ hideTrigger();
126
+ return;
127
+ }
128
+
129
+ // Fallback: text manipulation for non-markdown files
130
+ const { offset, length, selectedText } = state.selectionInfo;
131
+
132
+ pushUndo();
133
+
134
+ if (format === "link") {
135
+ const url = prompt("Link URL:", "https://");
136
+ if (!url) return;
137
+ send({ type: "edit_apply", offset, length, newText: `[${selectedText}](${url})` });
138
+ hideTrigger();
139
+ return;
140
+ }
141
+
142
+ if (format === "blockquote") {
143
+ const lines = selectedText.split("\n").map(l => `> ${l}`).join("\n");
144
+ send({ type: "edit_apply", offset, length, newText: lines });
145
+ hideTrigger();
146
+ return;
147
+ }
148
+
149
+ if (format === "bulletList") {
150
+ const lines = selectedText.split("\n").map(l => `- ${l}`).join("\n");
151
+ send({ type: "edit_apply", offset, length, newText: lines });
152
+ hideTrigger();
153
+ return;
154
+ }
155
+
156
+ const { prefix, suffix } = FORMAT_SYNTAX[format];
157
+
158
+ // Toggle detection: check if selection is already wrapped
159
+ const before = state.currentContent.slice(offset - prefix.length, offset);
160
+ const after = state.currentContent.slice(offset + length, offset + length + suffix.length);
161
+ if (before === prefix && after === suffix) {
162
+ // Unwrap: remove the surrounding markers
163
+ send({ type: "edit_apply", offset: offset - prefix.length, length: length + prefix.length + suffix.length, newText: selectedText });
164
+ } else {
165
+ // Wrap: add markers
166
+ send({ type: "edit_apply", offset, length, newText: `${prefix}${selectedText}${suffix}` });
167
+ }
168
+
169
+ hideTrigger();
170
+ }
171
+
172
+ function hidePopup() {
173
+ popup.hidden = true;
174
+ state.selectionInfo = null;
175
+ // Reset popup to default state for next use
176
+ popupSelection.hidden = false;
177
+ popup.querySelector(".popup-header").textContent = "Selection";
178
+ commentInput.placeholder = "Leave a comment...";
179
+ }
180
+
181
+ function submitComment() {
182
+ const text = commentInput.value.trim();
183
+ if (!text || !state.selectionInfo) return;
184
+
185
+ const msg = {
186
+ type: "comment_add",
187
+ file: state.currentFile,
188
+ offset: state.selectionInfo.offset,
189
+ length: state.selectionInfo.length,
190
+ selectedText: state.selectionInfo.selectedText,
191
+ comment: text,
192
+ };
193
+
194
+ // Add hybrid anchor for markdown files
195
+ if (isMarkdownFile(state.currentFile) && state.selectionInfo.anchor) {
196
+ msg.anchor = state.selectionInfo.anchor;
197
+ }
198
+
199
+ send(msg);
200
+ hidePopup();
201
+ window.getSelection()?.removeAllRanges();
202
+ }
203
+
204
+ /**
205
+ * Show toolbar for TipTap editor selection (called from onSelectionUpdate).
206
+ * Uses ProseMirror's authoritative selection — no racing with DOM events.
207
+ * @param {any} editor - TipTap Editor instance
208
+ */
209
+ export function showToolbarForSelection(editor) {
210
+ const { from, to } = editor.state.selection;
211
+ if (from === to) {
212
+ hideTrigger();
213
+ return;
214
+ }
215
+
216
+ const anchor = createAnchor(editor, from, to);
217
+ state.selectionInfo = {
218
+ offset: anchor.offset,
219
+ length: anchor.length,
220
+ selectedText: anchor.textQuote.exact,
221
+ anchor,
222
+ };
223
+
224
+ formatButtons.hidden = false;
225
+
226
+ const startCoords = editor.view.coordsAtPos(from);
227
+ const endCoords = editor.view.coordsAtPos(to);
228
+ const left = (startCoords.left + endCoords.right) / 2 - 40;
229
+ const top = Math.max(8, startCoords.top - 40);
230
+ selectionToolbar.style.left = `${left}px`;
231
+ selectionToolbar.style.top = `${top}px`;
232
+ selectionToolbar.hidden = false;
233
+ }
234
+
235
+ export function initToolbar() {
236
+ // --- Selection detection ---
237
+
238
+ document.addEventListener("mousedown", (e) => {
239
+ if (!selectionToolbar.contains(e.target) &&
240
+ !popup.contains(e.target) &&
241
+ !$("#sidebar").contains(e.target)) {
242
+ hideTrigger();
243
+ }
244
+ });
245
+
246
+ document.addEventListener("mouseup", (e) => {
247
+ // Don't trigger when clicking inside popup, toolbar, or sidebar
248
+ if (popup.contains(e.target) || selectionToolbar.contains(e.target) || $("#sidebar").contains(e.target)) return;
249
+
250
+ // For markdown files with editor, let onSelectionUpdate handle it
251
+ const editor = getEditor();
252
+ if (editor && isMarkdownFile(state.currentFile)) return;
253
+
254
+ const selection = window.getSelection();
255
+ if (!selection || selection.isCollapsed) {
256
+ hideTrigger();
257
+ return;
258
+ }
259
+
260
+ const text = selection.toString().trim();
261
+ if (!text) {
262
+ hideTrigger();
263
+ return;
264
+ }
265
+
266
+ // Non-markdown: use DOM offset computation
267
+ const offset = computeOffset(selection, text);
268
+ if (offset === -1) { hideTrigger(); return; }
269
+ state.selectionInfo = { offset, length: text.length, selectedText: text };
270
+
271
+ formatButtons.hidden = true;
272
+
273
+ const range = selection.getRangeAt(0);
274
+ const rect = range.getBoundingClientRect();
275
+ selectionToolbar.style.left = `${rect.left + rect.width / 2 - 40}px`;
276
+ selectionToolbar.style.top = `${Math.max(8, rect.top - 40)}px`;
277
+ selectionToolbar.hidden = false;
278
+ });
279
+
280
+ // Prevent the mousedown from clearing the text selection
281
+ selectionToolbar.addEventListener("mousedown", (e) => {
282
+ e.preventDefault();
283
+ });
284
+
285
+ // --- Comment popup ---
286
+
287
+ commentTrigger.addEventListener("click", () => {
288
+ if (!state.selectionInfo) return;
289
+
290
+ const toolbarRect = selectionToolbar.getBoundingClientRect();
291
+ let left = Math.min(toolbarRect.left, window.innerWidth - 360);
292
+ let top = toolbarRect.bottom + 8;
293
+ // Clamp so popup doesn't overflow below the viewport
294
+ const popupHeight = 280;
295
+ if (top + popupHeight > window.innerHeight) {
296
+ top = Math.max(8, toolbarRect.top - popupHeight - 8);
297
+ }
298
+ popup.style.left = `${Math.max(8, left)}px`;
299
+ popup.style.top = `${top}px`;
300
+ popupSelection.textContent = state.selectionInfo.selectedText;
301
+ commentInput.value = "";
302
+ popup.hidden = false;
303
+ selectionToolbar.hidden = true;
304
+ commentInput.focus();
305
+ });
306
+
307
+ // Make comment popup draggable via its header
308
+ {
309
+ const popupHeader = popup.querySelector(".popup-header");
310
+ let dragging = false, dragStartX = 0, dragStartY = 0, popupStartX = 0, popupStartY = 0;
311
+
312
+ popupHeader.addEventListener("mousedown", (e) => {
313
+ dragging = true;
314
+ dragStartX = e.clientX;
315
+ dragStartY = e.clientY;
316
+ popupStartX = popup.offsetLeft;
317
+ popupStartY = popup.offsetTop;
318
+ e.preventDefault();
319
+ });
320
+
321
+ document.addEventListener("mousemove", (e) => {
322
+ if (!dragging) return;
323
+ const dx = e.clientX - dragStartX;
324
+ const dy = e.clientY - dragStartY;
325
+ popup.style.left = `${popupStartX + dx}px`;
326
+ popup.style.top = `${popupStartY + dy}px`;
327
+ });
328
+
329
+ document.addEventListener("mouseup", () => {
330
+ dragging = false;
331
+ });
332
+ }
333
+
334
+ $("#commentCancel").addEventListener("click", hidePopup);
335
+ $("#commentSubmit").addEventListener("click", submitComment);
336
+
337
+ commentInput.addEventListener("keydown", (e) => {
338
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
339
+ e.preventDefault();
340
+ submitComment();
341
+ }
342
+ if (e.key === "Escape") {
343
+ hidePopup();
344
+ }
345
+ });
346
+
347
+ // File-level comment button
348
+ fileCommentBtn.addEventListener("click", () => {
349
+ if (!state.currentFile) return;
350
+
351
+ // Position popup below the button
352
+ const btnRect = fileCommentBtn.getBoundingClientRect();
353
+ popup.style.left = `${Math.max(8, btnRect.left - 280)}px`;
354
+ popup.style.top = `${btnRect.bottom + 8}px`;
355
+
356
+ // Hide the selection preview, set file-comment mode
357
+ popupSelection.hidden = true;
358
+ popup.querySelector(".popup-header").textContent = "File comment";
359
+ commentInput.value = "";
360
+ commentInput.placeholder = "Comment on the whole file...";
361
+ state.selectionInfo = { offset: 0, length: 0, selectedText: "" };
362
+ popup.hidden = false;
363
+ commentInput.focus();
364
+ });
365
+
366
+ // --- Format button clicks ---
367
+
368
+ selectionToolbar.addEventListener("click", (e) => {
369
+ const btn = e.target.closest("[data-format]");
370
+ if (!btn) return;
371
+ applyMarkdownFormat(btn.dataset.format);
372
+ });
373
+
374
+ // --- Keyboard shortcuts for formatting ---
375
+
376
+ document.addEventListener("keydown", (e) => {
377
+ if (!state.selectionInfo || state.selectionInfo.offset < 0) return;
378
+ if (!isMarkdownFile(state.currentFile)) return;
379
+ if (e.target.closest("input, textarea, [contenteditable]")) return;
380
+
381
+ const mod = e.metaKey || e.ctrlKey;
382
+ if (!mod) return;
383
+
384
+ const map = { b: "bold", i: "italic", e: "code", k: "link" };
385
+ if (e.shiftKey && e.key.toLowerCase() === "s") {
386
+ e.preventDefault();
387
+ applyMarkdownFormat("strikethrough");
388
+ } else if (map[e.key]) {
389
+ e.preventDefault();
390
+ applyMarkdownFormat(map[e.key]);
391
+ }
392
+ });
393
+
394
+ // Hide trigger when selection is cleared (non-markdown files only)
395
+ document.addEventListener("selectionchange", () => {
396
+ // For markdown files with editor, onSelectionUpdate handles show/hide
397
+ const editor = getEditor();
398
+ if (editor && isMarkdownFile(state.currentFile)) return;
399
+
400
+ const selection = window.getSelection();
401
+ if (!selection || selection.isCollapsed) {
402
+ setTimeout(() => {
403
+ const sel = window.getSelection();
404
+ if (popup.hidden && (!sel || sel.isCollapsed)) hideTrigger();
405
+ }, 100);
406
+ }
407
+ });
408
+ }
@@ -0,0 +1,78 @@
1
+ // @ts-check
2
+
3
+ import { $ } from './utils.js';
4
+ import { state } from './state.js';
5
+ import { send } from './ws-client.js';
6
+
7
+ const undoBtn = /** @type {HTMLButtonElement} */ ($("#undoBtn"));
8
+
9
+ /**
10
+ * Save the undo stack for a file to sessionStorage.
11
+ * @param {string} file
12
+ */
13
+ export function saveUndoStack(file) {
14
+ try {
15
+ sessionStorage.setItem("cowrite-undo:" + file, JSON.stringify(state.undoStack));
16
+ } catch (e) {
17
+ state.undoStack.splice(0, state.undoStack.length - 5);
18
+ try { sessionStorage.setItem("cowrite-undo:" + file, JSON.stringify(state.undoStack)); } catch (_) {}
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Load the undo stack for a file from sessionStorage.
24
+ * @param {string} file
25
+ * @returns {Array<{file: string, content: string}>}
26
+ */
27
+ export function loadUndoStack(file) {
28
+ try {
29
+ const data = sessionStorage.getItem("cowrite-undo:" + file);
30
+ if (data) {
31
+ const parsed = JSON.parse(data);
32
+ if (Array.isArray(parsed)) return parsed;
33
+ }
34
+ } catch (e) {}
35
+ return [];
36
+ }
37
+
38
+ /**
39
+ * Push the current content onto the undo stack.
40
+ */
41
+ export function pushUndo() {
42
+ if (!state.currentContent || !state.currentFile) return;
43
+ state.undoStack.push({ file: state.currentFile, content: state.currentContent });
44
+ if (state.undoStack.length > state.MAX_UNDO) state.undoStack.shift();
45
+ undoBtn.disabled = false;
46
+ saveUndoStack(state.currentFile);
47
+ }
48
+
49
+ function performUndo() {
50
+ if (state.undoStack.length === 0) return;
51
+ const snapshot = state.undoStack.pop();
52
+ if (state.undoStack.length === 0) undoBtn.disabled = true;
53
+ saveUndoStack(state.currentFile);
54
+
55
+ send({
56
+ type: "edit_apply",
57
+ offset: 0,
58
+ length: state.currentContent.length,
59
+ newText: snapshot.content,
60
+ });
61
+ }
62
+
63
+ export function initUndoManager() {
64
+ undoBtn.addEventListener("click", performUndo);
65
+
66
+ document.addEventListener("keydown", (e) => {
67
+ if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) {
68
+ const tag = document.activeElement?.tagName?.toLowerCase();
69
+ if (tag === "textarea" || tag === "input") return;
70
+ if (document.activeElement?.contentEditable === "true") return;
71
+ // Let ProseMirror handle undo when editor is focused
72
+ if (document.activeElement?.closest('.ProseMirror')) return;
73
+
74
+ e.preventDefault();
75
+ performUndo();
76
+ }
77
+ });
78
+ }