collabmd 0.1.18 → 0.1.20
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 +107 -25
- package/docker-compose.yml +1 -1
- package/package.json +1 -1
- package/public/assets/css/style.css +1 -1
- package/public/assets/js/chunks/{preview-render-compiler-WD3A76I6.js → chunk-5QRWYPYT.js} +18 -18
- package/public/assets/js/chunks/chunk-HEWVH67U.js +9 -0
- package/public/assets/js/chunks/chunk-R3DDMJHH.js +1 -0
- package/public/assets/js/chunks/editor-session-TAV2VXTA.js +22 -0
- package/public/assets/js/chunks/preview-render-compiler-UZ4SZDQQ.js +1 -0
- package/public/assets/js/chunks/quick-switcher-controller-J7I3CUET.js +5 -0
- package/public/assets/js/{excalidraw-editor-LKW2AZBU.js → excalidraw-editor-ZDYKMZOL.js} +33 -33
- package/public/assets/js/excalidraw-editor.js +1 -1
- package/public/assets/js/main.js +100 -73
- package/public/assets/js/preview-render-worker.js +19 -19
- package/public/index.html +14 -4
- package/src/client/application/app-shell/git-feature.js +29 -1
- package/src/client/application/app-shell/presence-feature.js +2 -1
- package/src/client/application/app-shell/ui-feature.js +113 -1
- package/src/client/application/app-shell/workspace-feature.js +9 -0
- package/src/client/application/app-shell-elements.js +1 -0
- package/src/client/application/preview-render-compiler.js +55 -5
- package/src/client/application/preview-render-executor.js +8 -0
- package/src/client/application/preview-render-worker.js +13 -2
- package/src/client/application/preview-renderer.js +5 -0
- package/src/client/application/workspace-chrome-controller.js +12 -1
- package/src/client/application/workspace-coordinator.js +16 -7
- package/src/client/application/workspace-preview-controller.js +45 -5
- package/src/client/application/workspace-route-controller.js +4 -0
- package/src/client/bootstrap/collabmd-app-shell.js +19 -3
- package/src/client/domain/room.js +37 -0
- package/src/client/domain/vault-utils.js +46 -0
- package/src/client/infrastructure/comment-thread-store.js +98 -0
- package/src/client/infrastructure/editor-paste-utils.js +45 -0
- package/src/client/infrastructure/editor-session.js +10 -0
- package/src/client/infrastructure/editor-view-adapter.js +98 -7
- package/src/client/infrastructure/vault-api-client.js +17 -0
- package/src/client/presentation/backlinks-panel.js +157 -101
- package/src/client/presentation/comment-markdown-renderer.js +68 -0
- package/src/client/presentation/comment-ui-controller.js +352 -20
- package/src/client/presentation/file-explorer-controller.js +5 -0
- package/src/client/presentation/file-explorer-view.js +10 -2
- package/src/client/presentation/file-tree-state.js +7 -1
- package/src/client/presentation/git-panel-controller.js +73 -0
- package/src/client/presentation/image-lightbox-controller.js +394 -0
- package/src/client/presentation/outline-controller.js +25 -0
- package/src/client/styles/style.css +677 -21
- package/src/domain/comment-threads.js +95 -13
- package/src/domain/file-kind.js +16 -1
- package/src/server/infrastructure/git/errors.js +4 -1
- package/src/server/infrastructure/git/git-service.js +215 -1
- package/src/server/infrastructure/http/create-git-api-command-handler.js +6 -1
- package/src/server/infrastructure/http/create-git-api-query-handler.js +16 -1
- package/src/server/infrastructure/http/create-vault-api-command-handler.js +48 -2
- package/src/server/infrastructure/http/create-vault-api-query-handler.js +67 -1
- package/src/server/infrastructure/http/request-body.js +11 -2
- package/src/server/infrastructure/persistence/path-utils.js +1 -1
- package/src/server/infrastructure/persistence/pull-backup-store.js +283 -0
- package/src/server/infrastructure/persistence/vault-file-store.js +203 -2
- package/public/assets/js/chunks/chunk-BBHPYU2R.js +0 -1
- package/public/assets/js/chunks/chunk-OG2TNZEU.js +0 -9
- package/public/assets/js/chunks/chunk-QSTBGTWJ.js +0 -1
- package/public/assets/js/chunks/chunk-SR3U53EQ.js +0 -1
- package/public/assets/js/chunks/editor-session-IHFTYG5D.js +0 -22
- package/public/assets/js/chunks/quick-switcher-controller-JYDIJVAJ.js +0 -5
|
@@ -11,9 +11,10 @@ import {
|
|
|
11
11
|
} from '@codemirror/language';
|
|
12
12
|
import { languages } from '@codemirror/language-data';
|
|
13
13
|
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
|
|
14
|
-
import { Compartment, EditorSelection, EditorState } from '@codemirror/state';
|
|
14
|
+
import { Compartment, EditorSelection, EditorState, Prec } from '@codemirror/state';
|
|
15
15
|
import { oneDark } from '@codemirror/theme-one-dark';
|
|
16
16
|
import {
|
|
17
|
+
Decoration,
|
|
17
18
|
EditorView,
|
|
18
19
|
crosshairCursor,
|
|
19
20
|
drawSelection,
|
|
@@ -30,8 +31,41 @@ import { normalizeCommentQuote } from '../../domain/comment-threads.js';
|
|
|
30
31
|
import { createMarkdownToolbarEdit } from '../domain/markdown-formatting.js';
|
|
31
32
|
import { wikiLinkCompletions } from '../domain/wiki-link-completions.js';
|
|
32
33
|
import { plantUmlLanguage, plantUmlLanguageDescription } from '../domain/plantuml-language.js';
|
|
34
|
+
import { handleImagePasteEvent } from './editor-paste-utils.js';
|
|
33
35
|
|
|
34
36
|
const markdownCodeLanguages = [...languages, plantUmlLanguageDescription];
|
|
37
|
+
const pairedMatchingBracketMark = Decoration.mark({ class: 'cm-matchingBracket cm-matchingBracket-paired' });
|
|
38
|
+
const nonmatchingBracketMark = Decoration.mark({ class: 'cm-nonmatchingBracket' });
|
|
39
|
+
|
|
40
|
+
function isBracketBeforeCaret(range, state) {
|
|
41
|
+
return state.selection.ranges.some((selectionRange) =>
|
|
42
|
+
selectionRange.empty && range.to === selectionRange.head
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function renderBracketMatch(match, state) {
|
|
47
|
+
const decorations = [];
|
|
48
|
+
|
|
49
|
+
if (!match.matched) {
|
|
50
|
+
decorations.push(nonmatchingBracketMark.range(match.start.from, match.start.to));
|
|
51
|
+
if (match.end) {
|
|
52
|
+
decorations.push(nonmatchingBracketMark.range(match.end.from, match.end.to));
|
|
53
|
+
}
|
|
54
|
+
return decorations;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const shouldHideStartBracket = isBracketBeforeCaret(match.start, state);
|
|
58
|
+
const shouldHideEndBracket = match.end ? isBracketBeforeCaret(match.end, state) : false;
|
|
59
|
+
|
|
60
|
+
if (!shouldHideStartBracket) {
|
|
61
|
+
decorations.push(pairedMatchingBracketMark.range(match.start.from, match.start.to));
|
|
62
|
+
}
|
|
63
|
+
if (match.end && !shouldHideEndBracket) {
|
|
64
|
+
decorations.push(pairedMatchingBracketMark.range(match.end.from, match.end.to));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return decorations;
|
|
68
|
+
}
|
|
35
69
|
|
|
36
70
|
function createEditorTheme(theme) {
|
|
37
71
|
const activeLineBackground = theme === 'dark'
|
|
@@ -46,6 +80,21 @@ function createEditorTheme(theme) {
|
|
|
46
80
|
const selectionBorder = theme === 'dark'
|
|
47
81
|
? 'oklch(from var(--color-primary) l c h / 0.65)'
|
|
48
82
|
: 'oklch(from var(--color-primary) l c h / 0.5)';
|
|
83
|
+
const caretColor = theme === 'dark'
|
|
84
|
+
? 'color-mix(in oklab, var(--color-primary) 78%, white)'
|
|
85
|
+
: 'color-mix(in oklab, var(--color-primary) 84%, black)';
|
|
86
|
+
const bracketPairBackground = theme === 'dark'
|
|
87
|
+
? 'oklch(from var(--color-primary) l c h / 0.2)'
|
|
88
|
+
: 'oklch(from var(--color-primary) l c h / 0.12)';
|
|
89
|
+
const bracketPairOutline = theme === 'dark'
|
|
90
|
+
? 'oklch(from var(--color-primary) calc(l + 0.08) c h / 0.85)'
|
|
91
|
+
: 'oklch(from var(--color-primary) calc(l - 0.04) c h / 0.7)';
|
|
92
|
+
const nonmatchingBracketBackground = theme === 'dark'
|
|
93
|
+
? 'oklch(from var(--color-error) l c h / 0.12)'
|
|
94
|
+
: 'oklch(from var(--color-error) l c h / 0.1)';
|
|
95
|
+
const nonmatchingBracketOutline = theme === 'dark'
|
|
96
|
+
? 'oklch(from var(--color-error) calc(l + 0.08) c h / 0.8)'
|
|
97
|
+
: 'oklch(from var(--color-error) calc(l - 0.04) c h / 0.7)';
|
|
49
98
|
|
|
50
99
|
return EditorView.theme({
|
|
51
100
|
'&': {
|
|
@@ -53,13 +102,14 @@ function createEditorTheme(theme) {
|
|
|
53
102
|
color: 'var(--color-text)',
|
|
54
103
|
},
|
|
55
104
|
'.cm-content': {
|
|
56
|
-
caretColor
|
|
105
|
+
caretColor,
|
|
57
106
|
fontFamily: 'var(--font-mono)',
|
|
58
107
|
padding: '16px 0',
|
|
59
108
|
},
|
|
60
109
|
'.cm-cursor, .cm-dropCursor': {
|
|
61
|
-
borderLeftColor:
|
|
62
|
-
borderLeftWidth: '
|
|
110
|
+
borderLeftColor: caretColor,
|
|
111
|
+
borderLeftWidth: '3px',
|
|
112
|
+
marginLeft: '-1px',
|
|
63
113
|
},
|
|
64
114
|
'.cm-foldPlaceholder': {
|
|
65
115
|
backgroundColor: 'var(--color-surface-dynamic)',
|
|
@@ -85,8 +135,17 @@ function createEditorTheme(theme) {
|
|
|
85
135
|
color: 'var(--color-text-muted)',
|
|
86
136
|
},
|
|
87
137
|
'.cm-matchingBracket': {
|
|
88
|
-
|
|
89
|
-
|
|
138
|
+
borderRadius: '2px',
|
|
139
|
+
color: 'inherit',
|
|
140
|
+
},
|
|
141
|
+
'.cm-matchingBracket.cm-matchingBracket-paired': {
|
|
142
|
+
backgroundColor: bracketPairBackground,
|
|
143
|
+
outline: `1px solid ${bracketPairOutline}`,
|
|
144
|
+
},
|
|
145
|
+
'.cm-nonmatchingBracket': {
|
|
146
|
+
backgroundColor: nonmatchingBracketBackground,
|
|
147
|
+
outline: `1px solid ${nonmatchingBracketOutline}`,
|
|
148
|
+
borderRadius: '2px',
|
|
90
149
|
},
|
|
91
150
|
'.cm-selectionMatch': {
|
|
92
151
|
backgroundColor: 'var(--color-primary-highlight)',
|
|
@@ -127,6 +186,7 @@ export class EditorViewAdapter {
|
|
|
127
186
|
lineInfoElement,
|
|
128
187
|
lineWrappingEnabled = true,
|
|
129
188
|
onDocChanged = null,
|
|
189
|
+
onImagePaste = null,
|
|
130
190
|
onSelectionChanged = null,
|
|
131
191
|
onViewportChanged = null,
|
|
132
192
|
}) {
|
|
@@ -136,6 +196,7 @@ export class EditorViewAdapter {
|
|
|
136
196
|
this.lineInfoElement = lineInfoElement;
|
|
137
197
|
this.lineWrappingEnabled = lineWrappingEnabled;
|
|
138
198
|
this.onDocChanged = onDocChanged;
|
|
199
|
+
this.onImagePaste = onImagePaste;
|
|
139
200
|
this.onSelectionChanged = onSelectionChanged;
|
|
140
201
|
this.onViewportChanged = onViewportChanged;
|
|
141
202
|
this.editorView = null;
|
|
@@ -188,7 +249,7 @@ export class EditorViewAdapter {
|
|
|
188
249
|
EditorState.allowMultipleSelections.of(true),
|
|
189
250
|
indentOnInput(),
|
|
190
251
|
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
|
191
|
-
bracketMatching(),
|
|
252
|
+
bracketMatching({ renderMatch: renderBracketMatch }),
|
|
192
253
|
closeBrackets(),
|
|
193
254
|
autocompletion({
|
|
194
255
|
override: [wikiLinkCompletions(this.getFileList)],
|
|
@@ -208,6 +269,9 @@ export class EditorViewAdapter {
|
|
|
208
269
|
EditorView.contentAttributes.of({
|
|
209
270
|
'aria-label': 'Markdown editor',
|
|
210
271
|
}),
|
|
272
|
+
Prec.highest(EditorView.domEventHandlers({
|
|
273
|
+
paste: (event) => handleImagePasteEvent(event, this.onImagePaste),
|
|
274
|
+
})),
|
|
211
275
|
createLanguageExtension(filePath),
|
|
212
276
|
this.themeCompartment.of(createEditorTheme(this.initialTheme)),
|
|
213
277
|
this.syntaxThemeCompartment.of(this.initialTheme === 'dark' ? oneDark : []),
|
|
@@ -561,6 +625,33 @@ export class EditorViewAdapter {
|
|
|
561
625
|
return true;
|
|
562
626
|
}
|
|
563
627
|
|
|
628
|
+
insertText(text) {
|
|
629
|
+
if (!this.editorView) {
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const insertValue = String(text ?? '');
|
|
634
|
+
const { state } = this.editorView;
|
|
635
|
+
const range = state.selection.main;
|
|
636
|
+
const anchor = range.from + insertValue.length;
|
|
637
|
+
|
|
638
|
+
this.editorView.dispatch({
|
|
639
|
+
changes: {
|
|
640
|
+
from: range.from,
|
|
641
|
+
insert: insertValue,
|
|
642
|
+
to: range.to,
|
|
643
|
+
},
|
|
644
|
+
scrollIntoView: true,
|
|
645
|
+
selection: {
|
|
646
|
+
anchor,
|
|
647
|
+
head: anchor,
|
|
648
|
+
},
|
|
649
|
+
userEvent: 'input',
|
|
650
|
+
});
|
|
651
|
+
this.editorView.focus();
|
|
652
|
+
return true;
|
|
653
|
+
}
|
|
654
|
+
|
|
564
655
|
updateCursorInfo(state) {
|
|
565
656
|
if (!this.lineInfoElement) {
|
|
566
657
|
return;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { resolveApiUrl } from '../domain/runtime-paths.js';
|
|
2
2
|
|
|
3
|
+
function encodeHeaderMetadata(value) {
|
|
4
|
+
return encodeURIComponent(String(value ?? ''));
|
|
5
|
+
}
|
|
6
|
+
|
|
3
7
|
async function parseApiResponse(response, fallbackError) {
|
|
4
8
|
const data = await response.json().catch(() => ({}));
|
|
5
9
|
if (!response.ok || data.ok === false) {
|
|
@@ -56,6 +60,19 @@ export class VaultApiClient {
|
|
|
56
60
|
});
|
|
57
61
|
return parseApiResponse(response, 'Failed to create folder');
|
|
58
62
|
}
|
|
63
|
+
|
|
64
|
+
async uploadImageAttachment({ file, fileName = '', sourcePath }) {
|
|
65
|
+
const response = await fetch(resolveApiUrl('/attachments'), {
|
|
66
|
+
body: file,
|
|
67
|
+
headers: {
|
|
68
|
+
'Content-Type': file?.type || 'application/octet-stream',
|
|
69
|
+
'X-CollabMD-File-Name': encodeHeaderMetadata(fileName),
|
|
70
|
+
'X-CollabMD-Source-Path': encodeHeaderMetadata(sourcePath),
|
|
71
|
+
},
|
|
72
|
+
method: 'POST',
|
|
73
|
+
});
|
|
74
|
+
return parseApiResponse(response, 'Failed to upload image');
|
|
75
|
+
}
|
|
59
76
|
}
|
|
60
77
|
|
|
61
78
|
export const vaultApiClient = new VaultApiClient();
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import markdownIt from 'markdown-it';
|
|
2
|
+
import hljs from 'highlight.js';
|
|
3
|
+
|
|
4
|
+
import { escapeHtml } from '../domain/vault-utils.js';
|
|
5
|
+
|
|
6
|
+
function renderToken(renderer, tokens, index, options, env, self) {
|
|
7
|
+
return renderer?.(tokens, index, options, env, self) ?? self.renderToken(tokens, index, options);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function renderPlainTextCommentHtml(text = '') {
|
|
11
|
+
const escaped = escapeHtml(String(text ?? ''));
|
|
12
|
+
return `<p>${escaped.replace(/\n/g, '<br>')}</p>`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function createCommentMarkdownRenderer() {
|
|
16
|
+
const markdown = markdownIt({
|
|
17
|
+
breaks: true,
|
|
18
|
+
highlight(source, language) {
|
|
19
|
+
try {
|
|
20
|
+
if (language && hljs.getLanguage(language)) {
|
|
21
|
+
return hljs.highlight(source, {
|
|
22
|
+
ignoreIllegals: true,
|
|
23
|
+
language,
|
|
24
|
+
}).value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return hljs.highlightAuto(source).value;
|
|
28
|
+
} catch {
|
|
29
|
+
return escapeHtml(source);
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
html: false,
|
|
33
|
+
linkify: true,
|
|
34
|
+
typographer: true,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const fallbackLinkOpen = markdown.renderer.rules.link_open;
|
|
38
|
+
const fallbackTableOpen = markdown.renderer.rules.table_open;
|
|
39
|
+
const fallbackTableClose = markdown.renderer.rules.table_close;
|
|
40
|
+
|
|
41
|
+
markdown.renderer.rules.link_open = (tokens, index, options, env, self) => {
|
|
42
|
+
tokens[index].attrSet('target', '_blank');
|
|
43
|
+
tokens[index].attrSet('rel', 'noopener noreferrer');
|
|
44
|
+
return renderToken(fallbackLinkOpen, tokens, index, options, env, self);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
markdown.renderer.rules.table_open = (tokens, index, options, env, self) => (
|
|
48
|
+
`<div class="comment-markdown-table">${renderToken(fallbackTableOpen, tokens, index, options, env, self)}`
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
markdown.renderer.rules.table_close = (tokens, index, options, env, self) => (
|
|
52
|
+
`${renderToken(fallbackTableClose, tokens, index, options, env, self)}</div>`
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return markdown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const commentMarkdownRenderer = createCommentMarkdownRenderer();
|
|
59
|
+
|
|
60
|
+
export function renderCommentMarkdownToHtml(markdownText = '') {
|
|
61
|
+
const normalizedMarkdown = String(markdownText ?? '');
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
return commentMarkdownRenderer.render(normalizedMarkdown);
|
|
65
|
+
} catch {
|
|
66
|
+
return renderPlainTextCommentHtml(normalizedMarkdown);
|
|
67
|
+
}
|
|
68
|
+
}
|