markdown-notes-engine 1.0.0

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,665 @@
1
+ /**
2
+ * Markdown Notes Engine - Frontend Module
3
+ *
4
+ * A markdown note-taking editor with GitHub integration
5
+ */
6
+
7
+ class NotesEditor {
8
+ constructor(config) {
9
+ this.config = {
10
+ apiEndpoint: config.apiEndpoint || '/api',
11
+ container: config.container,
12
+ theme: config.theme || 'light',
13
+ autoSave: config.autoSave !== false,
14
+ autoSaveDelay: config.autoSaveDelay || 2000,
15
+ onSave: config.onSave || null,
16
+ onLoad: config.onLoad || null,
17
+ onError: config.onError || null
18
+ };
19
+
20
+ // Check localStorage for saved dark mode preference
21
+ const savedDarkMode = localStorage.getItem('notesDarkMode');
22
+ const isDarkMode = savedDarkMode === 'enabled' || (savedDarkMode === null && config.theme === 'dark');
23
+
24
+ this.state = {
25
+ currentNote: null,
26
+ previewMode: 'editor', // 'editor', 'split', 'preview'
27
+ structure: [],
28
+ isDarkMode: isDarkMode
29
+ };
30
+
31
+ this.timers = {
32
+ autoSave: null,
33
+ previewUpdate: null
34
+ };
35
+
36
+ this.init();
37
+ }
38
+
39
+ /**
40
+ * Initialize the editor
41
+ */
42
+ init() {
43
+ const container = document.querySelector(this.config.container);
44
+ if (!container) {
45
+ throw new Error(`Container "${this.config.container}" not found`);
46
+ }
47
+
48
+ // Inject HTML structure
49
+ container.innerHTML = this.getTemplate();
50
+
51
+ // Cache DOM elements
52
+ this.elements = {
53
+ sidebar: container.querySelector('.notes-sidebar'),
54
+ editor: container.querySelector('.notes-editor'),
55
+ preview: container.querySelector('.notes-preview'),
56
+ fileTree: container.querySelector('.notes-file-tree'),
57
+ notePath: container.querySelector('.notes-path-input'),
58
+ editorTextarea: container.querySelector('.notes-editor-textarea'),
59
+ previewContent: container.querySelector('.notes-preview-content'),
60
+ searchInput: container.querySelector('.notes-search-input')
61
+ };
62
+
63
+ // Apply theme
64
+ if (this.state.isDarkMode) {
65
+ container.classList.add('notes-dark-mode');
66
+ }
67
+
68
+ // Update theme button icon
69
+ const themeBtn = container.querySelector('[data-action="theme"]');
70
+ if (themeBtn) {
71
+ themeBtn.textContent = this.state.isDarkMode ? '☀️' : '🌙';
72
+ }
73
+
74
+ // Setup event listeners
75
+ this.setupEventListeners();
76
+
77
+ // Load structure
78
+ this.loadStructure();
79
+
80
+ // Load marked.js and highlight.js from CDN
81
+ this.loadDependencies();
82
+ }
83
+
84
+ /**
85
+ * Get HTML template
86
+ */
87
+ getTemplate() {
88
+ return `
89
+ <div class="notes-container">
90
+ <div class="notes-sidebar">
91
+ <div class="notes-toolbar">
92
+ <button class="notes-btn" data-action="new">+ New</button>
93
+ <button class="notes-btn" data-action="folder">+ Folder</button>
94
+ <button class="notes-btn" data-action="theme">🌙</button>
95
+ </div>
96
+ <input type="text" class="notes-search-input" placeholder="Search notes...">
97
+ <div class="notes-file-tree"></div>
98
+ </div>
99
+ <div class="notes-main">
100
+ <div class="notes-header">
101
+ <input type="text" class="notes-path-input" placeholder="Note path (e.g., my-note)">
102
+ <div class="notes-actions">
103
+ <button class="notes-btn" data-action="save" title="Save (Ctrl+S)">💾 Save</button>
104
+ <button class="notes-btn" data-action="preview" title="Toggle Preview (Ctrl+P)">👁️ Preview</button>
105
+ <button class="notes-btn" data-action="upload-image" title="Upload Image (Ctrl+U)">🖼️</button>
106
+ <button class="notes-btn" data-action="upload-video" title="Upload Video (Ctrl+Shift+U)">🎥</button>
107
+ <button class="notes-btn notes-btn-danger" data-action="delete">🗑️</button>
108
+ </div>
109
+ </div>
110
+ <div class="notes-content">
111
+ <div class="notes-editor">
112
+ <textarea class="notes-editor-textarea" placeholder="Start writing..."></textarea>
113
+ </div>
114
+ <div class="notes-preview" style="display: none;">
115
+ <div class="notes-preview-content"></div>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ </div>
120
+ `;
121
+ }
122
+
123
+ /**
124
+ * Setup event listeners
125
+ */
126
+ setupEventListeners() {
127
+ const container = document.querySelector(this.config.container);
128
+
129
+ // Button actions
130
+ container.addEventListener('click', (e) => {
131
+ const btn = e.target.closest('[data-action]');
132
+ if (!btn) return;
133
+
134
+ const action = btn.dataset.action;
135
+ const actions = {
136
+ 'new': () => this.createNewNote(),
137
+ 'save': () => this.saveNote(),
138
+ 'delete': () => this.deleteNote(),
139
+ 'preview': () => this.togglePreview(),
140
+ 'folder': () => this.createFolder(),
141
+ 'upload-image': () => this.uploadImage(),
142
+ 'upload-video': () => this.uploadVideo(),
143
+ 'theme': () => this.toggleTheme()
144
+ };
145
+
146
+ if (actions[action]) {
147
+ actions[action]();
148
+ }
149
+ });
150
+
151
+ // Auto-save
152
+ if (this.config.autoSave) {
153
+ this.elements.editorTextarea.addEventListener('input', () => {
154
+ clearTimeout(this.timers.autoSave);
155
+ this.timers.autoSave = setTimeout(() => {
156
+ if (this.state.currentNote) {
157
+ this.saveNote(true);
158
+ }
159
+ }, this.config.autoSaveDelay);
160
+
161
+ // Update preview
162
+ if (this.state.previewMode !== 'editor') {
163
+ clearTimeout(this.timers.previewUpdate);
164
+ this.timers.previewUpdate = setTimeout(() => {
165
+ this.renderPreview();
166
+ }, 100);
167
+ }
168
+ });
169
+ }
170
+
171
+ // Keyboard shortcuts
172
+ this.elements.editorTextarea.addEventListener('keydown', (e) => {
173
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') {
174
+ e.preventDefault();
175
+ this.saveNote();
176
+ } else if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
177
+ e.preventDefault();
178
+ this.togglePreview();
179
+ } else if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
180
+ e.preventDefault();
181
+ this.createNewNote();
182
+ }
183
+ });
184
+
185
+ // Drag and drop
186
+ this.elements.editorTextarea.addEventListener('drop', async (e) => {
187
+ e.preventDefault();
188
+ const files = Array.from(e.dataTransfer.files);
189
+ for (const file of files) {
190
+ if (file.type.startsWith('image/')) {
191
+ await this.uploadImageFile(file);
192
+ } else if (file.type.startsWith('video/')) {
193
+ await this.uploadVideoFile(file);
194
+ }
195
+ }
196
+ });
197
+
198
+ this.elements.editorTextarea.addEventListener('dragover', (e) => {
199
+ e.preventDefault();
200
+ });
201
+
202
+ // Paste images
203
+ this.elements.editorTextarea.addEventListener('paste', async (e) => {
204
+ const items = Array.from(e.clipboardData.items);
205
+ for (const item of items) {
206
+ if (item.type.startsWith('image/')) {
207
+ e.preventDefault();
208
+ const file = item.getAsFile();
209
+ if (file) await this.uploadImageFile(file);
210
+ }
211
+ }
212
+ });
213
+ }
214
+
215
+ /**
216
+ * Load external dependencies
217
+ */
218
+ loadDependencies() {
219
+ if (!window.marked) {
220
+ const script = document.createElement('script');
221
+ script.src = 'https://cdn.jsdelivr.net/npm/marked/marked.min.js';
222
+ document.head.appendChild(script);
223
+ }
224
+ if (!window.hljs) {
225
+ const script = document.createElement('script');
226
+ script.src = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js';
227
+ document.head.appendChild(script);
228
+
229
+ // Load both light and dark themes
230
+ const lightTheme = document.createElement('link');
231
+ lightTheme.rel = 'stylesheet';
232
+ lightTheme.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css';
233
+ lightTheme.id = 'hljs-light-theme';
234
+ lightTheme.disabled = this.state.isDarkMode; // Disable if dark mode is active
235
+ document.head.appendChild(lightTheme);
236
+
237
+ const darkTheme = document.createElement('link');
238
+ darkTheme.rel = 'stylesheet';
239
+ darkTheme.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css';
240
+ darkTheme.id = 'hljs-dark-theme';
241
+ darkTheme.disabled = !this.state.isDarkMode; // Disable if light mode is active
242
+ document.head.appendChild(darkTheme);
243
+ }
244
+ }
245
+
246
+ /**
247
+ * API Methods
248
+ */
249
+
250
+ async loadStructure() {
251
+ try {
252
+ const response = await fetch(`${this.config.apiEndpoint}/structure`);
253
+ const data = await response.json();
254
+ this.state.structure = data.structure || [];
255
+ this.renderFileTree();
256
+ } catch (error) {
257
+ this.state.structure = [];
258
+ this.handleError('Failed to load file structure', error);
259
+ }
260
+ }
261
+
262
+ async loadNote(path) {
263
+ try {
264
+ const response = await fetch(`${this.config.apiEndpoint}/note?path=${encodeURIComponent(path)}`);
265
+ const data = await response.json();
266
+
267
+ this.state.currentNote = path;
268
+ this.elements.notePath.value = path;
269
+ this.elements.editorTextarea.value = data.content;
270
+
271
+ if (this.state.previewMode !== 'editor') {
272
+ this.renderPreview();
273
+ }
274
+
275
+ if (this.config.onLoad) {
276
+ this.config.onLoad(path, data.content);
277
+ }
278
+ } catch (error) {
279
+ this.handleError('Failed to load note', error);
280
+ }
281
+ }
282
+
283
+ async saveNote(silent = false) {
284
+ const path = this.elements.notePath.value.trim();
285
+ const content = this.elements.editorTextarea.value;
286
+
287
+ if (!path) {
288
+ if (!silent) alert('Please enter a note path');
289
+ return;
290
+ }
291
+
292
+ try {
293
+ const response = await fetch(`${this.config.apiEndpoint}/note`, {
294
+ method: 'POST',
295
+ headers: { 'Content-Type': 'application/json' },
296
+ body: JSON.stringify({ path, content })
297
+ });
298
+
299
+ const data = await response.json();
300
+
301
+ if (data.success) {
302
+ this.state.currentNote = data.path;
303
+ this.elements.notePath.value = data.path;
304
+ if (!silent) this.showNotification('Note saved!', 'success');
305
+ this.loadStructure();
306
+
307
+ if (this.config.onSave) {
308
+ this.config.onSave(data.path, content);
309
+ }
310
+ }
311
+ } catch (error) {
312
+ this.handleError('Failed to save note', error);
313
+ }
314
+ }
315
+
316
+ async deleteNote() {
317
+ if (!this.state.currentNote) {
318
+ alert('No note to delete');
319
+ return;
320
+ }
321
+
322
+ if (!confirm(`Delete "${this.state.currentNote}"?`)) {
323
+ return;
324
+ }
325
+
326
+ try {
327
+ const response = await fetch(`${this.config.apiEndpoint}/note`, {
328
+ method: 'DELETE',
329
+ headers: { 'Content-Type': 'application/json' },
330
+ body: JSON.stringify({ path: this.state.currentNote })
331
+ });
332
+
333
+ const data = await response.json();
334
+
335
+ if (data.success) {
336
+ this.createNewNote();
337
+ this.loadStructure();
338
+ this.showNotification('Note deleted!', 'success');
339
+ }
340
+ } catch (error) {
341
+ this.handleError('Failed to delete note', error);
342
+ }
343
+ }
344
+
345
+ async uploadImageFile(file) {
346
+ try {
347
+ const formData = new FormData();
348
+ formData.append('image', file);
349
+
350
+ const response = await fetch(`${this.config.apiEndpoint}/upload-image`, {
351
+ method: 'POST',
352
+ body: formData
353
+ });
354
+
355
+ const data = await response.json();
356
+
357
+ if (data.imageUrl) {
358
+ const markdown = `![${file.name}](${data.imageUrl})\n`;
359
+ this.insertAtCursor(markdown);
360
+ this.showNotification('Image uploaded!', 'success');
361
+ }
362
+ } catch (error) {
363
+ this.handleError('Failed to upload image', error);
364
+ }
365
+ }
366
+
367
+ async uploadVideoFile(file) {
368
+ try {
369
+ const formData = new FormData();
370
+ formData.append('video', file);
371
+
372
+ const response = await fetch(`${this.config.apiEndpoint}/upload-video`, {
373
+ method: 'POST',
374
+ body: formData
375
+ });
376
+
377
+ const data = await response.json();
378
+
379
+ if (data.videoUrl) {
380
+ const markdown = `![${file.name}](${data.videoUrl})\n`;
381
+ this.insertAtCursor(markdown);
382
+ this.showNotification('Video uploaded!', 'success');
383
+ }
384
+ } catch (error) {
385
+ this.handleError('Failed to upload video', error);
386
+ }
387
+ }
388
+
389
+ /**
390
+ * UI Methods
391
+ */
392
+
393
+ createNewNote() {
394
+ this.state.currentNote = null;
395
+ this.elements.notePath.value = '';
396
+ this.elements.editorTextarea.value = '';
397
+ this.elements.previewContent.innerHTML = '';
398
+ this.elements.editorTextarea.focus();
399
+ }
400
+
401
+ createFolder() {
402
+ const path = prompt('Enter folder path:');
403
+ if (!path) return;
404
+
405
+ fetch(`${this.config.apiEndpoint}/folder`, {
406
+ method: 'POST',
407
+ headers: { 'Content-Type': 'application/json' },
408
+ body: JSON.stringify({ path })
409
+ })
410
+ .then(res => res.json())
411
+ .then(data => {
412
+ if (data.success) {
413
+ this.loadStructure();
414
+ this.showNotification('Folder created!', 'success');
415
+ }
416
+ })
417
+ .catch(error => this.handleError('Failed to create folder', error));
418
+ }
419
+
420
+ uploadImage() {
421
+ const input = document.createElement('input');
422
+ input.type = 'file';
423
+ input.accept = 'image/*';
424
+ input.onchange = (e) => {
425
+ const file = e.target.files[0];
426
+ if (file) this.uploadImageFile(file);
427
+ };
428
+ input.click();
429
+ }
430
+
431
+ uploadVideo() {
432
+ const input = document.createElement('input');
433
+ input.type = 'file';
434
+ input.accept = 'video/*';
435
+ input.onchange = (e) => {
436
+ const file = e.target.files[0];
437
+ if (file) this.uploadVideoFile(file);
438
+ };
439
+ input.click();
440
+ }
441
+
442
+ togglePreview() {
443
+ const modes = ['editor', 'split', 'preview'];
444
+ const currentIndex = modes.indexOf(this.state.previewMode);
445
+ const nextIndex = (currentIndex + 1) % modes.length;
446
+ this.state.previewMode = modes[nextIndex];
447
+
448
+ // Use 'flex' instead of 'block' to maintain flex container layout
449
+ this.elements.editor.style.display =
450
+ this.state.previewMode === 'preview' ? 'none' : 'flex';
451
+ this.elements.preview.style.display =
452
+ this.state.previewMode === 'editor' ? 'none' : 'flex';
453
+
454
+ if (this.state.previewMode === 'split') {
455
+ this.elements.editor.style.width = '50%';
456
+ this.elements.preview.style.width = '50%';
457
+ } else {
458
+ this.elements.editor.style.width = '100%';
459
+ this.elements.preview.style.width = '100%';
460
+ }
461
+
462
+ if (this.state.previewMode !== 'editor') {
463
+ this.renderPreview();
464
+ }
465
+ }
466
+
467
+ toggleTheme() {
468
+ this.state.isDarkMode = !this.state.isDarkMode;
469
+ const container = document.querySelector(this.config.container);
470
+ container.classList.toggle('notes-dark-mode');
471
+
472
+ // Save preference to localStorage
473
+ localStorage.setItem('notesDarkMode', this.state.isDarkMode ? 'enabled' : 'disabled');
474
+
475
+ // Update theme button icon
476
+ const themeBtn = container.querySelector('[data-action="theme"]');
477
+ if (themeBtn) {
478
+ themeBtn.textContent = this.state.isDarkMode ? '☀️' : '🌙';
479
+ }
480
+
481
+ // Switch highlight.js theme
482
+ const lightTheme = document.getElementById('hljs-light-theme');
483
+ const darkTheme = document.getElementById('hljs-dark-theme');
484
+ if (lightTheme && darkTheme) {
485
+ lightTheme.disabled = this.state.isDarkMode;
486
+ darkTheme.disabled = !this.state.isDarkMode;
487
+ }
488
+ }
489
+
490
+ renderFileTree() {
491
+ this.elements.fileTree.innerHTML = '';
492
+
493
+ if (!this.state.structure || !Array.isArray(this.state.structure)) {
494
+ this.elements.fileTree.innerHTML = '<div style="padding: 10px; color: #999;">No notes yet</div>';
495
+ return;
496
+ }
497
+
498
+ if (this.state.structure.length === 0) {
499
+ this.elements.fileTree.innerHTML = '<div style="padding: 10px; color: #999;">No notes yet. Create one to get started!</div>';
500
+ return;
501
+ }
502
+
503
+ // Recursive function to render tree items
504
+ const renderTreeItems = (items, container, level = 0) => {
505
+ items.forEach(item => {
506
+ const itemDiv = document.createElement('div');
507
+ itemDiv.style.paddingLeft = `${level * 10}px`;
508
+
509
+ if (item.type === 'folder') {
510
+ const folderDiv = document.createElement('div');
511
+ folderDiv.className = 'notes-tree-item notes-tree-folder';
512
+ folderDiv.innerHTML = `
513
+ <span class="notes-tree-toggle">▶</span>
514
+ <span class="notes-tree-icon">📁</span>
515
+ <span>${item.name}</span>
516
+ `;
517
+
518
+ const childrenDiv = document.createElement('div');
519
+ childrenDiv.className = 'notes-tree-children';
520
+ childrenDiv.style.display = 'none';
521
+
522
+ folderDiv.addEventListener('click', (e) => {
523
+ e.stopPropagation();
524
+ const isOpen = childrenDiv.style.display === 'block';
525
+ childrenDiv.style.display = isOpen ? 'none' : 'block';
526
+ folderDiv.querySelector('.notes-tree-toggle').textContent = isOpen ? '▶' : '▼';
527
+ });
528
+
529
+ itemDiv.appendChild(folderDiv);
530
+ itemDiv.appendChild(childrenDiv);
531
+
532
+ if (item.children && item.children.length > 0) {
533
+ renderTreeItems(item.children, childrenDiv, level + 1);
534
+ }
535
+ } else {
536
+ itemDiv.className = 'notes-tree-item notes-tree-file';
537
+ itemDiv.innerHTML = `
538
+ <span class="notes-tree-toggle"></span>
539
+ <span class="notes-tree-icon">📄</span>
540
+ <span>${item.name}</span>
541
+ `;
542
+
543
+ itemDiv.addEventListener('click', () => {
544
+ this.loadNote(item.path);
545
+
546
+ // Highlight active item
547
+ container.closest('.notes-file-tree').querySelectorAll('.notes-tree-item').forEach(el => {
548
+ el.classList.remove('active');
549
+ });
550
+ itemDiv.classList.add('active');
551
+ });
552
+ }
553
+
554
+ container.appendChild(itemDiv);
555
+ });
556
+ };
557
+
558
+ renderTreeItems(this.state.structure, this.elements.fileTree);
559
+ }
560
+
561
+ renderPreview() {
562
+ if (!window.marked) {
563
+ setTimeout(() => this.renderPreview(), 100);
564
+ return;
565
+ }
566
+
567
+ const markdown = this.elements.editorTextarea.value;
568
+ const basePath = this.elements.notePath.value;
569
+
570
+ // Create a custom renderer for internal links and videos
571
+ const renderer = {
572
+ link: (token) => {
573
+ const href = token.href;
574
+ const title = token.title || '';
575
+ const text = token.text;
576
+
577
+ // Check if it's an internal markdown link
578
+ if (href && href.endsWith('.md') && !href.startsWith('http')) {
579
+ // Resolve relative path based on current note location
580
+ const parts = basePath.split('/');
581
+ parts.pop(); // Remove filename
582
+ const dir = parts.join('/');
583
+ const linkPath = dir ? `${dir}/${href}`.replace(/\/\.\//g, '/') : href.replace('./', '');
584
+
585
+ return `<a href="#" class="internal-link" data-path="${linkPath}" title="${title}">${text}</a>`;
586
+ }
587
+
588
+ return false; // Use default renderer
589
+ },
590
+
591
+ image: (token) => {
592
+ const href = token.href;
593
+ const title = token.title || '';
594
+
595
+ // Check if it's a video file
596
+ if (href &&
597
+ (href.endsWith('.mp4') || href.endsWith('.webm') ||
598
+ href.endsWith('.mov') || href.endsWith('.avi') ||
599
+ href.endsWith('.mkv') || href.includes('/videos/'))) {
600
+ return `<video controls style="max-width: 100%; height: auto;" title="${title}">
601
+ <source src="${href}" type="video/mp4">
602
+ Your browser does not support the video tag.
603
+ </video>`;
604
+ }
605
+
606
+ return false; // Use default renderer
607
+ }
608
+ };
609
+
610
+ // Use marked with custom renderer
611
+ marked.use({ renderer });
612
+ const html = marked.parse(markdown);
613
+ this.elements.previewContent.innerHTML = html;
614
+
615
+ // Highlight code blocks
616
+ if (window.hljs) {
617
+ this.elements.previewContent.querySelectorAll('pre code').forEach(block => {
618
+ hljs.highlightBlock(block);
619
+ });
620
+ }
621
+
622
+ // Add click handlers for internal links
623
+ this.elements.previewContent.querySelectorAll('.internal-link').forEach(link => {
624
+ link.addEventListener('click', (e) => {
625
+ e.preventDefault();
626
+ const linkPath = link.getAttribute('data-path');
627
+ if (linkPath) {
628
+ this.loadNote(linkPath);
629
+ }
630
+ });
631
+ });
632
+ }
633
+
634
+ insertAtCursor(text) {
635
+ const textarea = this.elements.editorTextarea;
636
+ const start = textarea.selectionStart;
637
+ const end = textarea.selectionEnd;
638
+ const value = textarea.value;
639
+
640
+ textarea.value = value.substring(0, start) + text + value.substring(end);
641
+ textarea.selectionStart = textarea.selectionEnd = start + text.length;
642
+ textarea.focus();
643
+ }
644
+
645
+ showNotification(message, type = 'info') {
646
+ console.log(`[${type.toUpperCase()}] ${message}`);
647
+ // You can implement a custom notification system here
648
+ }
649
+
650
+ handleError(message, error) {
651
+ console.error(message, error);
652
+ if (this.config.onError) {
653
+ this.config.onError(message, error);
654
+ } else {
655
+ alert(message);
656
+ }
657
+ }
658
+ }
659
+
660
+ // Export for different module systems
661
+ if (typeof module !== 'undefined' && module.exports) {
662
+ module.exports = { NotesEditor };
663
+ } else {
664
+ window.NotesEditor = NotesEditor;
665
+ }