-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.ts
More file actions
275 lines (232 loc) · 9.02 KB
/
main.ts
File metadata and controls
275 lines (232 loc) · 9.02 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
// ABOUTME: Main plugin entry point for Obsidian bookmark plugin
// ABOUTME: Coordinates bookmark functionality across views and handles lifecycle
import { Editor, MarkdownView, Plugin } from 'obsidian';
import { BookmarkManager } from './bookmarkManager';
import { ViewActionManager } from './viewActionManager';
import { GutterDecorationManager } from './gutterDecoration';
import { BOOKMARK_MARKER } from './constants';
// Pre-escaped marker pattern for regex operations
// Marker is always inserted as " <!-- bookmark-marker -->" at end of line,
// so we only need to match optional whitespace before the marker
const ESCAPED_MARKER = BOOKMARK_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const MARKER_CLEANUP_RE = new RegExp(`\\s*${ESCAPED_MARKER}`, 'g');
export default class BookmarkPlugin extends Plugin {
private bookmarkManager: BookmarkManager;
private viewActionManager: ViewActionManager;
private gutterManager: GutterDecorationManager;
onload() {
// Initialize managers
this.bookmarkManager = new BookmarkManager();
this.viewActionManager = new ViewActionManager();
this.gutterManager = new GutterDecorationManager();
// Register editor extension for gutter decorations
this.registerEditorExtension(this.gutterManager.createBookmarkGutter());
// Wait for layout to be ready before setting up views
this.app.workspace.onLayoutReady(() => {
// Add bookmark actions to existing markdown views
this.app.workspace.iterateAllLeaves((leaf) => {
if (leaf.view instanceof MarkdownView) {
this.setupView(leaf.view);
}
});
// Listen for new views being created
this.registerEvent(
this.app.workspace.on('active-leaf-change', () => {
this.handleActiveLeafChange();
})
);
});
// Add command for toggle bookmark
this.addCommand({
id: 'toggle',
name: 'Toggle',
editorCheckCallback: (checking, _editor, view) => {
if (checking) {
return view instanceof MarkdownView;
}
void this.toggleBookmark(view as MarkdownView);
return true;
}
});
// Add command to clean up multiple bookmarks
this.addCommand({
id: 'clean-multiple',
name: 'Clean up multiple',
editorCheckCallback: (checking, editor, view) => {
if (checking) {
return view instanceof MarkdownView;
}
void this.cleanupMultipleBookmarks(editor, view as MarkdownView);
return true;
}
});
}
onunload() {
// Clean up all view actions
this.app.workspace.iterateAllLeaves((leaf) => {
if (leaf.view instanceof MarkdownView) {
this.viewActionManager.removeActionFromView(leaf.view);
}
});
}
// MARK: - Private Methods
private handleActiveLeafChange(): void {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (activeView) {
this.setupView(activeView);
}
}
private setupView(view: MarkdownView): void {
// Add bookmark action if not already present
if (!this.viewActionManager.getActionElement(view)) {
this.viewActionManager.addActionToView(view, () => {
void this.toggleBookmark(view);
});
}
// Check for existing bookmark and update icon
this.checkAndUpdateIcon(view);
}
private async toggleBookmark(view: MarkdownView): Promise<void> {
const editor = view.editor;
const content = editor.getValue();
const bookmarkState = this.bookmarkManager.findBookmark(content);
if (bookmarkState.hasBookmark && bookmarkState.lineNumber !== null) {
// Bookmark exists - jump to it, then remove after delay
this.bookmarkManager.jumpToBookmark(editor, bookmarkState.lineNumber, view);
// Wait 500ms before clearing bookmark to let user see the location
setTimeout(() => {
const originalFile = view.file;
void (async () => {
try {
const currentMode = view.getMode();
if (currentMode === 'preview') {
// In preview mode, use Vault.process to modify the file
if (!originalFile) return;
await this.app.vault.process(originalFile, (content) => {
const bookmarkState = this.bookmarkManager.findBookmark(content);
if (bookmarkState.hasBookmark && bookmarkState.lineNumber !== null) {
const eol = content.includes('\r\n') ? '\r\n' : '\n';
const lines = content.split(/\r?\n/);
const lineContent = lines[bookmarkState.lineNumber];
if (lineContent === undefined) return content;
// Remove the marker and all preceding whitespace using same regex as other cleanup paths
lines[bookmarkState.lineNumber] = lineContent.replace(MARKER_CLEANUP_RE, '');
return lines.join(eol);
}
return content;
});
} else {
// Edit mode - direct removal
// If user switched files before the timeout, avoid mutating the wrong buffer.
if (originalFile && view.file?.path !== originalFile.path) {
await this.app.vault.process(originalFile, (content) => {
return content.replace(MARKER_CLEANUP_RE, '');
});
} else {
this.bookmarkManager.removeBookmark(editor);
}
}
this.viewActionManager.updateActionIcon(view, 'bookmark');
} catch (e) {
console.error('Failed to remove bookmark:', e);
}
})();
}, 500);
} else {
// No bookmark - set at visible line (works in both edit and preview modes)
const mode = view.getMode();
if (mode === 'preview') {
// In preview mode, use Vault.process to modify the file
const visibleLine = this.getVisibleLineInPreview(view);
if (!view.file) return;
await this.app.vault.process(view.file, (content) => {
const eol = content.includes('\r\n') ? '\r\n' : '\n';
const lines = content.split(/\r?\n/);
let targetLine = Math.min(visibleLine, lines.length - 1);
// Parse frontmatter to find end index
let frontmatterEndIndex = -1;
if (lines[0] === '---') {
for (let i = 1; i < lines.length; i++) {
if (lines[i] === '---') {
frontmatterEndIndex = i;
break;
}
}
}
// If visibleLine is inside frontmatter, set targetLine to first line after closing '---'
if (frontmatterEndIndex !== -1 && targetLine <= frontmatterEndIndex) {
targetLine = Math.min(frontmatterEndIndex + 1, lines.length - 1);
}
// Ensure target line is valid
targetLine = Math.max(0, Math.min(targetLine, lines.length - 1));
// Re-derive the line content and check if it already contains the marker
const lineContent = lines[targetLine];
if (!lineContent.includes(BOOKMARK_MARKER)) {
// Insert bookmark at end of line
lines[targetLine] = lineContent + ' ' + BOOKMARK_MARKER;
}
return lines.join(eol);
});
} else {
// In source/edit mode, use auto-detection with full bookmark manager logic
const detectedLine = this.bookmarkManager.getFirstVisibleLine(editor);
this.bookmarkManager.insertBookmark(editor, detectedLine);
}
this.viewActionManager.updateActionIcon(view, 'bookmark-check');
}
}
private getVisibleLineInPreview(view: MarkdownView): number {
try {
// Get scroll position in preview mode (likely already a percentage 0-100)
const scroll = view.previewMode.getScroll();
const containerEl = view.previewMode.containerEl;
// Treat scroll as percentage if it's reasonable, otherwise as pixels
let scrollPercentage: number;
if (scroll <= 100) {
// Likely already a percentage (0-100)
scrollPercentage = scroll / 100;
} else {
// Treat as pixel value
scrollPercentage = scroll / containerEl.scrollHeight;
}
// Estimate visible line based on scroll percentage
const editor = view.editor;
const totalLines = editor.lineCount();
const estimatedLine = Math.floor(scrollPercentage * totalLines);
// Ensure within bounds
const finalLine = Math.min(Math.max(0, estimatedLine), totalLines - 1);
return finalLine;
} catch (e) {
console.error('Error in getVisibleLineInPreview:', e);
}
// Fallback to first line
return 0;
}
private async cleanupMultipleBookmarks(editor: Editor, view: MarkdownView): Promise<void> {
const currentMode = view.getMode();
if (currentMode === 'preview') {
// In preview mode, use Vault.process to modify the file
if (!view.file) return;
await this.app.vault.process(view.file, (content) => {
// Remove all bookmark markers with any preceding whitespace
return content.replace(MARKER_CLEANUP_RE, '');
});
} else {
// In source mode, use editor directly
const content = editor.getValue();
const cleanedContent = content.replace(MARKER_CLEANUP_RE, '');
editor.setValue(cleanedContent);
}
// Update icon
this.viewActionManager.updateActionIcon(view, 'bookmark');
}
private checkAndUpdateIcon(view: MarkdownView): void {
const content = view.editor.getValue();
const bookmarkState = this.bookmarkManager.findBookmark(content);
// Check for multiple bookmarks and show notice to user if detected
// (user must manually run "Clean up multiple" command to resolve)
this.bookmarkManager.checkForMultipleBookmarks(content);
const iconName = bookmarkState.hasBookmark ? 'bookmark-check' : 'bookmark';
this.viewActionManager.updateActionIcon(view, iconName);
}
}