collabmd 0.1.19 → 0.1.21
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/README.md +13 -11
- package/docker-compose.yml +1 -1
- package/package.json +1 -1
- package/public/assets/css/style.css +1 -1
- package/public/assets/js/chunks/chunk-GFDM7YCF.js +1 -0
- package/public/assets/js/chunks/editor-session-EAUBJCHH.js +22 -0
- package/public/assets/js/chunks/{preview-render-compiler-UZ4SZDQQ.js → preview-render-compiler-6Y7TKXFJ.js} +1 -1
- package/public/assets/js/chunks/{quick-switcher-controller-J7I3CUET.js → quick-switcher-controller-A6SKH5HI.js} +1 -1
- package/public/assets/js/{excalidraw-editor-ZDYKMZOL.js → excalidraw-editor-TCIH6GJL.js} +1 -1
- package/public/assets/js/excalidraw-editor.js +1 -1
- package/public/assets/js/main.js +102 -75
- package/public/assets/js/preview-render-worker.js +15 -15
- package/public/index.html +14 -4
- package/src/client/application/app-shell/git-feature.js +86 -19
- package/src/client/application/app-shell/ui-feature.js +4 -0
- package/src/client/application/app-shell-elements.js +1 -0
- package/src/client/bootstrap/collabmd-app-shell.js +16 -0
- package/src/client/domain/vault-utils.js +2 -2
- package/src/client/excalidraw-editor.js +2 -1
- package/src/client/infrastructure/editor-session.js +4 -0
- package/src/client/infrastructure/editor-view-adapter.js +245 -7
- package/src/client/infrastructure/workspace-sync-client.js +324 -0
- package/src/client/presentation/backlinks-panel.js +157 -101
- package/src/client/presentation/comment-ui-controller.js +233 -10
- package/src/client/presentation/file-explorer-controller.js +15 -3
- package/src/client/presentation/file-explorer-view.js +152 -11
- package/src/client/presentation/git-panel-controller.js +73 -0
- package/src/client/presentation/outline-controller.js +25 -0
- package/src/client/styles/style.css +184 -9
- package/src/domain/wiki-link-resolver.js +16 -5
- package/src/domain/workspace-change.js +68 -0
- package/src/domain/workspace-room.js +3 -0
- package/src/server/create-app-server.js +41 -10
- package/src/server/domain/backlink-index.js +94 -1
- package/src/server/domain/collaboration/collaboration-room.js +191 -22
- package/src/server/domain/collaboration/room-registry.js +64 -10
- package/src/server/infrastructure/git/errors.js +4 -1
- package/src/server/infrastructure/git/git-service.js +215 -1
- package/src/server/infrastructure/git/responses.js +12 -19
- package/src/server/infrastructure/http/create-git-api-command-handler.js +58 -43
- package/src/server/infrastructure/http/create-git-api-handler.js +2 -0
- package/src/server/infrastructure/http/create-git-api-query-handler.js +16 -1
- package/src/server/infrastructure/http/create-request-handler.js +7 -0
- package/src/server/infrastructure/http/create-vault-api-command-handler.js +58 -14
- package/src/server/infrastructure/http/create-vault-api-handler.js +3 -0
- package/src/server/infrastructure/http/create-vault-api-query-handler.js +3 -1
- package/src/server/infrastructure/http/http-response.js +65 -28
- package/src/server/infrastructure/persistence/pull-backup-store.js +283 -0
- package/src/server/infrastructure/persistence/vault-file-store.js +179 -52
- package/src/server/infrastructure/workspace/file-system-sync-service.js +488 -0
- package/src/server/infrastructure/workspace/workspace-mutation-coordinator.js +551 -0
- package/public/assets/js/chunks/chunk-R3DDMJHH.js +0 -1
- package/public/assets/js/chunks/editor-session-AH6Z3MXW.js +0 -22
|
@@ -2,62 +2,95 @@ import { escapeHtml } from '../domain/vault-utils.js';
|
|
|
2
2
|
import { resolveApiUrl } from '../domain/runtime-paths.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* BacklinksPanel —
|
|
6
|
-
* which files link to the currently open file ("Linked Mentions").
|
|
5
|
+
* BacklinksPanel — renders "Linked Mentions" for the current file.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Desktop uses a docked bottom-left panel outside the preview scroll flow.
|
|
8
|
+
* Mobile keeps an inline collapsible panel at the end of the preview content.
|
|
10
9
|
*/
|
|
11
10
|
export class BacklinksPanel {
|
|
12
|
-
constructor({
|
|
13
|
-
|
|
11
|
+
constructor({
|
|
12
|
+
documentRef = document,
|
|
13
|
+
inlinePanelElement = null,
|
|
14
|
+
onFileSelect,
|
|
15
|
+
panelElement,
|
|
16
|
+
}) {
|
|
17
|
+
this.documentRef = documentRef;
|
|
18
|
+
this.panelRoot = panelElement;
|
|
19
|
+
this.inlinePanel = inlinePanelElement;
|
|
14
20
|
this.onFileSelect = onFileSelect;
|
|
15
21
|
|
|
16
|
-
this.header = this.panel?.querySelector('.backlinks-header');
|
|
17
|
-
this.toggle = this.panel?.querySelector('.backlinks-toggle');
|
|
18
|
-
this.countBadge = this.panel?.querySelector('.backlinks-count');
|
|
19
|
-
this.body = this.panel?.querySelector('.backlinks-body');
|
|
20
|
-
this.list = this.panel?.querySelector('.backlinks-list');
|
|
21
|
-
|
|
22
22
|
this._expanded = false;
|
|
23
23
|
this._currentFile = null;
|
|
24
24
|
this._fetchController = null;
|
|
25
25
|
this._backlinks = [];
|
|
26
26
|
|
|
27
|
+
const desktopPanel = this.panelRoot?.querySelector('[data-backlinks-variant="dock"]') ?? this.panelRoot;
|
|
28
|
+
this.panels = [
|
|
29
|
+
this._createPanelRef(desktopPanel),
|
|
30
|
+
this._createPanelRef(this.inlinePanel),
|
|
31
|
+
].filter(Boolean);
|
|
32
|
+
|
|
33
|
+
this.handleDocumentPointerDown = (event) => {
|
|
34
|
+
if (!this._expanded) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const target = event?.target;
|
|
39
|
+
if (target && this.panels.some(({ panel }) => panel?.contains?.(target))) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
this.close();
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
this.handleDocumentKeyDown = (event) => {
|
|
47
|
+
if (!this._expanded || event?.key !== 'Escape') {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
event.preventDefault?.();
|
|
52
|
+
this.close();
|
|
53
|
+
};
|
|
54
|
+
|
|
27
55
|
this.bindEvents();
|
|
28
56
|
}
|
|
29
57
|
|
|
30
58
|
bindEvents() {
|
|
31
|
-
this.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
59
|
+
this.panels.forEach((refs) => {
|
|
60
|
+
refs.header?.addEventListener('click', () => {
|
|
61
|
+
if (this._backlinks.length === 0) return;
|
|
62
|
+
this._expanded = !this._expanded;
|
|
63
|
+
this._applyExpandState();
|
|
64
|
+
});
|
|
36
65
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
66
|
+
refs.header?.addEventListener('keydown', (event) => {
|
|
67
|
+
if (event.key !== 'Enter' && event.key !== ' ') {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
41
70
|
|
|
42
|
-
|
|
43
|
-
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
refs.header.click();
|
|
73
|
+
});
|
|
44
74
|
});
|
|
75
|
+
|
|
76
|
+
this.documentRef?.addEventListener?.('pointerdown', this.handleDocumentPointerDown);
|
|
77
|
+
this.documentRef?.addEventListener?.('keydown', this.handleDocumentKeyDown);
|
|
45
78
|
}
|
|
46
79
|
|
|
47
|
-
/**
|
|
48
|
-
* Load backlinks for a file. Call on every file open and after
|
|
49
|
-
* content changes (debounced).
|
|
50
|
-
*/
|
|
51
80
|
async load(filePath) {
|
|
81
|
+
const fileChanged = filePath !== this._currentFile;
|
|
52
82
|
this._currentFile = filePath;
|
|
53
83
|
|
|
84
|
+
if (fileChanged) {
|
|
85
|
+
this._expanded = false;
|
|
86
|
+
}
|
|
87
|
+
|
|
54
88
|
if (!filePath) {
|
|
55
89
|
this._backlinks = [];
|
|
56
90
|
this._render();
|
|
57
91
|
return;
|
|
58
92
|
}
|
|
59
93
|
|
|
60
|
-
// Abort any in-flight fetch
|
|
61
94
|
this._fetchController?.abort();
|
|
62
95
|
this._fetchController = new AbortController();
|
|
63
96
|
|
|
@@ -68,7 +101,6 @@ export class BacklinksPanel {
|
|
|
68
101
|
);
|
|
69
102
|
const data = await response.json();
|
|
70
103
|
|
|
71
|
-
// Guard against stale responses (user switched files)
|
|
72
104
|
if (this._currentFile !== filePath) return;
|
|
73
105
|
|
|
74
106
|
this._backlinks = data.backlinks ?? [];
|
|
@@ -81,7 +113,6 @@ export class BacklinksPanel {
|
|
|
81
113
|
this._render();
|
|
82
114
|
}
|
|
83
115
|
|
|
84
|
-
/** Clear the panel (e.g. when navigating to empty state). */
|
|
85
116
|
clear() {
|
|
86
117
|
this._currentFile = null;
|
|
87
118
|
this._backlinks = [];
|
|
@@ -89,100 +120,125 @@ export class BacklinksPanel {
|
|
|
89
120
|
this._render();
|
|
90
121
|
}
|
|
91
122
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
123
|
+
close() {
|
|
124
|
+
if (!this._expanded) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
96
127
|
|
|
97
|
-
|
|
98
|
-
|
|
128
|
+
this._expanded = false;
|
|
129
|
+
this._applyExpandState();
|
|
130
|
+
}
|
|
99
131
|
|
|
100
|
-
|
|
101
|
-
|
|
132
|
+
_createPanelRef(panel) {
|
|
133
|
+
if (!panel) {
|
|
134
|
+
return null;
|
|
102
135
|
}
|
|
103
136
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
137
|
+
return {
|
|
138
|
+
panel,
|
|
139
|
+
header: panel.querySelector('.backlinks-header'),
|
|
140
|
+
toggle: panel.querySelector('.backlinks-toggle'),
|
|
141
|
+
countBadge: panel.querySelector('.backlinks-count'),
|
|
142
|
+
body: panel.querySelector('.backlinks-body'),
|
|
143
|
+
list: panel.querySelector('.backlinks-list'),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
107
146
|
|
|
108
|
-
|
|
109
|
-
this.
|
|
147
|
+
_render() {
|
|
148
|
+
const count = this._backlinks.length;
|
|
149
|
+
const label = count === 1 ? 'Linked Mention' : 'Linked Mentions';
|
|
150
|
+
const shouldShow = Boolean(this._currentFile) && count > 0;
|
|
110
151
|
|
|
111
|
-
|
|
112
|
-
this.
|
|
152
|
+
this.panelRoot?.classList?.toggle('hidden', !shouldShow);
|
|
153
|
+
this.inlinePanel?.classList?.toggle('hidden', !shouldShow);
|
|
113
154
|
|
|
114
|
-
if (
|
|
155
|
+
if (!shouldShow) {
|
|
115
156
|
this._expanded = false;
|
|
116
|
-
this._applyExpandState();
|
|
117
|
-
if (this.list) this.list.innerHTML = '';
|
|
118
|
-
return;
|
|
119
157
|
}
|
|
120
158
|
|
|
121
|
-
this.
|
|
159
|
+
this.panels.forEach((refs) => {
|
|
160
|
+
refs.countBadge.textContent = shouldShow ? String(count) : '';
|
|
161
|
+
refs.toggle.textContent = label;
|
|
162
|
+
refs.header?.classList.toggle('backlinks-header-empty', !shouldShow);
|
|
163
|
+
|
|
164
|
+
if (!shouldShow) {
|
|
165
|
+
refs.list.innerHTML = '';
|
|
166
|
+
} else {
|
|
167
|
+
this._renderList(refs.list);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
122
171
|
this._applyExpandState();
|
|
123
172
|
}
|
|
124
173
|
|
|
125
|
-
_renderList() {
|
|
126
|
-
if (!
|
|
127
|
-
|
|
174
|
+
_renderList(listElement) {
|
|
175
|
+
if (!listElement) return;
|
|
176
|
+
listElement.innerHTML = '';
|
|
128
177
|
|
|
129
|
-
const fragment =
|
|
178
|
+
const fragment = this.documentRef?.createDocumentFragment?.();
|
|
179
|
+
const target = fragment ?? listElement;
|
|
130
180
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
? backlink.file.substring(0, backlink.file.lastIndexOf('/'))
|
|
135
|
-
: '';
|
|
181
|
+
this._backlinks.forEach((backlink) => {
|
|
182
|
+
target.appendChild(this._createBacklinkItem(backlink));
|
|
183
|
+
});
|
|
136
184
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
this.onFileSelect?.(backlink.file);
|
|
142
|
-
});
|
|
185
|
+
if (fragment) {
|
|
186
|
+
listElement.appendChild(fragment);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
143
189
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
// Context snippets
|
|
158
|
-
for (const ctx of backlink.contexts.slice(0, 3)) {
|
|
159
|
-
const contextEl = document.createElement('div');
|
|
160
|
-
contextEl.className = 'backlink-context';
|
|
161
|
-
contextEl.textContent = ctx;
|
|
162
|
-
item.appendChild(contextEl);
|
|
163
|
-
}
|
|
190
|
+
_createBacklinkItem(backlink) {
|
|
191
|
+
const fileName = backlink.file.replace(/\.md$/i, '').split('/').pop();
|
|
192
|
+
const dirPath = backlink.file.includes('/')
|
|
193
|
+
? backlink.file.substring(0, backlink.file.lastIndexOf('/'))
|
|
194
|
+
: '';
|
|
195
|
+
|
|
196
|
+
const item = this.documentRef.createElement('button');
|
|
197
|
+
item.type = 'button';
|
|
198
|
+
item.className = 'backlink-item';
|
|
199
|
+
item.addEventListener('click', () => {
|
|
200
|
+
this.close();
|
|
201
|
+
this.onFileSelect?.(backlink.file);
|
|
202
|
+
});
|
|
164
203
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
204
|
+
const nameRow = this.documentRef.createElement('div');
|
|
205
|
+
nameRow.className = 'backlink-file-name';
|
|
206
|
+
nameRow.innerHTML = `
|
|
207
|
+
<svg class="backlink-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
208
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
209
|
+
<polyline points="14 2 14 8 20 8"/>
|
|
210
|
+
</svg>
|
|
211
|
+
<span>${escapeHtml(fileName)}</span>
|
|
212
|
+
${dirPath ? `<span class="backlink-dir">${escapeHtml(dirPath)}</span>` : ''}
|
|
213
|
+
`;
|
|
214
|
+
item.appendChild(nameRow);
|
|
215
|
+
|
|
216
|
+
backlink.contexts.slice(0, 3).forEach((ctx) => {
|
|
217
|
+
const contextElement = this.documentRef.createElement('div');
|
|
218
|
+
contextElement.className = 'backlink-context';
|
|
219
|
+
contextElement.textContent = ctx;
|
|
220
|
+
item.appendChild(contextElement);
|
|
221
|
+
});
|
|
171
222
|
|
|
172
|
-
|
|
223
|
+
if (backlink.contexts.length > 3) {
|
|
224
|
+
const moreElement = this.documentRef.createElement('div');
|
|
225
|
+
moreElement.className = 'backlink-context backlink-more';
|
|
226
|
+
moreElement.textContent = `+${backlink.contexts.length - 3} more`;
|
|
227
|
+
item.appendChild(moreElement);
|
|
173
228
|
}
|
|
174
229
|
|
|
175
|
-
|
|
230
|
+
return item;
|
|
176
231
|
}
|
|
177
232
|
|
|
178
233
|
_applyExpandState() {
|
|
179
|
-
this.
|
|
180
|
-
|
|
181
|
-
this.
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
234
|
+
const expanded = this._expanded && this._backlinks.length > 0;
|
|
235
|
+
|
|
236
|
+
this.panels.forEach((refs) => {
|
|
237
|
+
refs.panel.classList.toggle('expanded', expanded);
|
|
238
|
+
refs.header?.setAttribute('aria-expanded', String(expanded));
|
|
239
|
+
refs.header?.setAttribute('aria-disabled', String(this._backlinks.length === 0));
|
|
240
|
+
refs.body?.setAttribute('aria-hidden', String(!expanded));
|
|
241
|
+
refs.body?.toggleAttribute('inert', !expanded);
|
|
242
|
+
});
|
|
187
243
|
}
|
|
188
244
|
}
|