@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.
- package/dist/bin/cowrite.js +77 -6
- 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,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
|
+
}
|