comparison rich_editor/rich_editor.js @ 201:6cdee35a7ba9

[MrJuneJune] notes
author MrJuneJune <me@mrjunejune.com>
date Sun, 15 Feb 2026 07:07:50 -0800
parents
children e5aed6c36672
comparison
equal deleted inserted replaced
200:90dfcef375fb 201:6cdee35a7ba9
1 /**
2 * Rich Editor
3 * -----------
4 * A vanilla JavaScript rich text editor with:
5 * - Text formatting (h1-h6, paragraphs, lists)
6 * - Image/file upload via paste, drop, click, or /upload command
7 * - Debounced auto-save
8 * - Generic callback functions for uploads and saves
9 *
10 * Usage:
11 * const editor = RichEditor.init('editor-container', {
12 * uploadCallback: async (file) => { return { url: '...', key: '...' }; },
13 * saveCallback: async (content) => { console.log('Saved:', content); },
14 * debounceMs: 1000,
15 * placeholder: 'Start writing...'
16 * });
17 */
18
19 const RichEditor = (function() {
20 'use strict';
21
22 const DEFAULT_OPTIONS = {
23 uploadCallback: null,
24 saveCallback: null,
25 debounceMs: 1000,
26 placeholder: 'Start writing... (Use /upload to insert files)',
27 cloudFrontUrl: ''
28 };
29
30 function debounce(func, wait) {
31 let timeout;
32 return function executedFunction(...args) {
33 const later = () => {
34 clearTimeout(timeout);
35 func(...args);
36 };
37 clearTimeout(timeout);
38 timeout = setTimeout(later, wait);
39 };
40 }
41
42 function createToolbar(editor) {
43 const toolbar = document.createElement('div');
44 toolbar.className = 'rich-editor-toolbar';
45
46 const buttons = [
47 { cmd: 'h1', label: 'H1', title: 'Heading 1' },
48 { cmd: 'h2', label: 'H2', title: 'Heading 2' },
49 { cmd: 'h3', label: 'H3', title: 'Heading 3' },
50 { cmd: 'p', label: 'ΒΆ', title: 'Paragraph' },
51 { cmd: 'ul', label: 'β€’', title: 'Bullet List' },
52 { cmd: 'ol', label: '1.', title: 'Numbered List' },
53 { cmd: 'separator' },
54 { cmd: 'bold', label: 'B', title: 'Bold' },
55 { cmd: 'italic', label: 'I', title: 'Italic' },
56 { cmd: 'separator' },
57 { cmd: 'link', label: 'πŸ”—', title: 'Insert Link' },
58 { cmd: 'notelink', label: 'πŸ“', title: 'Link to Note' },
59 { cmd: 'upload', label: 'πŸ“Ž', title: 'Upload File' }
60 ];
61
62 buttons.forEach(btn => {
63 if (btn.cmd === 'separator') {
64 const sep = document.createElement('span');
65 sep.className = 'rich-editor-separator';
66 toolbar.appendChild(sep);
67 return;
68 }
69
70 const button = document.createElement('button');
71 button.type = 'button';
72 button.className = 'rich-editor-btn';
73 button.textContent = btn.label;
74 button.title = btn.title;
75 button.dataset.cmd = btn.cmd;
76
77 button.addEventListener('click', (e) => {
78 e.preventDefault();
79 editor.execCommand(btn.cmd);
80 });
81
82 toolbar.appendChild(button);
83 });
84
85 return toolbar;
86 }
87
88 function createStyles() {
89 if (document.getElementById('rich-editor-styles')) return;
90
91 const style = document.createElement('style');
92 style.id = 'rich-editor-styles';
93 style.textContent = `
94 .rich-editor-container {
95 border: 1px solid #ccc;
96 border-radius: 4px;
97 overflow: hidden;
98 }
99
100 .rich-editor-toolbar {
101 display: flex;
102 flex-wrap: wrap;
103 gap: 4px;
104 padding: 8px;
105 background: #f5f5f5;
106 border-bottom: 1px solid #ccc;
107 }
108
109 .rich-editor-btn {
110 padding: 6px 12px;
111 border: 1px solid #ccc;
112 border-radius: 4px;
113 background: #fff;
114 cursor: pointer;
115 font-size: 14px;
116 min-width: 32px;
117 }
118
119 .rich-editor-btn:hover {
120 background: #e9e9e9;
121 }
122
123 .rich-editor-btn:active {
124 background: #ddd;
125 }
126
127 .rich-editor-separator {
128 width: 1px;
129 background: #ccc;
130 margin: 0 4px;
131 }
132
133 .rich-editor-content {
134 min-height: 300px;
135 padding: 16px;
136 outline: none;
137 overflow-y: auto;
138 }
139
140 .rich-editor-content:empty:before {
141 content: attr(data-placeholder);
142 color: #999;
143 pointer-events: none;
144 }
145
146 .rich-editor-content img {
147 max-width: 100%;
148 height: auto;
149 border-radius: 4px;
150 margin: 8px 0;
151 }
152
153 .rich-editor-content a {
154 color: #0078ff;
155 text-decoration: none;
156 }
157
158 .rich-editor-content a:hover {
159 text-decoration: underline;
160 }
161
162 .rich-editor-content a.note-link {
163 background: #e8f4ff;
164 padding: 2px 6px;
165 border-radius: 3px;
166 }
167
168 .rich-editor-content a.note-link:hover {
169 background: #d0e8ff;
170 }
171
172 .rich-editor-content .upload-placeholder {
173 display: inline-block;
174 padding: 8px 16px;
175 background: #f0f0f0;
176 border-radius: 4px;
177 color: #666;
178 font-style: italic;
179 }
180
181 .rich-editor-content .upload-placeholder.loading {
182 padding: 8px 16px;
183 background: #fffacd;
184 border-radius: 4px;
185 animation: pulse 1.5s ease-in-out infinite;
186 }
187
188 @keyframes pulse {
189 0%, 100% { opacity: 1; }
190 50% { opacity: 0.6; }
191 }
192
193 .rich-editor-upload-overlay {
194 position: absolute;
195 top: 0;
196 left: 0;
197 right: 0;
198 bottom: 0;
199 background: rgba(0, 120, 255, 0.1);
200 border: 2px dashed #0078ff;
201 display: flex;
202 align-items: center;
203 justify-content: center;
204 font-size: 18px;
205 color: #0078ff;
206 pointer-events: none;
207 z-index: 10;
208 }
209
210 .rich-editor-status {
211 padding: 4px 8px;
212 font-size: 12px;
213 color: #666;
214 background: #f9f9f9;
215 border-top: 1px solid #eee;
216 }
217
218 .rich-editor-file-input {
219 display: none;
220 }
221 `;
222 document.head.appendChild(style);
223 }
224
225 class Editor {
226 constructor(elementId, options) {
227 this.options = { ...DEFAULT_OPTIONS, ...options };
228 this.container = document.getElementById(elementId);
229
230 if (!this.container) {
231 throw new Error(`Element with id "${elementId}" not found`);
232 }
233
234 this.init();
235 }
236
237 init() {
238 createStyles();
239
240 // Create wrapper
241 this.wrapper = document.createElement('div');
242 this.wrapper.className = 'rich-editor-container';
243 this.wrapper.style.position = 'relative';
244
245 // Create toolbar
246 this.toolbar = createToolbar(this);
247 this.wrapper.appendChild(this.toolbar);
248
249 // Create content area
250 this.content = document.createElement('div');
251 this.content.className = 'rich-editor-content';
252 this.content.contentEditable = true;
253 this.content.dataset.placeholder = this.options.placeholder;
254 this.wrapper.appendChild(this.content);
255
256 // Create status bar
257 this.status = document.createElement('div');
258 this.status.className = 'rich-editor-status';
259 this.status.textContent = 'Ready';
260 this.wrapper.appendChild(this.status);
261
262 // Create hidden file input
263 this.fileInput = document.createElement('input');
264 this.fileInput.type = 'file';
265 this.fileInput.className = 'rich-editor-file-input';
266 this.fileInput.multiple = true;
267 this.fileInput.accept = 'image/*,application/pdf,.doc,.docx,.txt';
268 this.wrapper.appendChild(this.fileInput);
269
270 // Add to container
271 this.container.appendChild(this.wrapper);
272
273 // Setup event listeners
274 this.setupEvents();
275
276 // Setup debounced save
277 if (this.options.saveCallback) {
278 this.debouncedSave = debounce(() => {
279 this.save();
280 }, this.options.debounceMs);
281 }
282 }
283
284 setupEvents() {
285 // Input events for auto-save
286 this.content.addEventListener('input', () => {
287 if (this.debouncedSave) {
288 this.setStatus('Editing...');
289 this.debouncedSave();
290 }
291 });
292
293 // Handle paste
294 this.content.addEventListener('paste', (e) => {
295 const items = e.clipboardData?.items;
296 if (!items) return;
297
298 for (const item of items) {
299 if (item.type.startsWith('image/') || item.kind === 'file') {
300 e.preventDefault();
301 const file = item.getAsFile();
302 if (file) this.uploadFile(file);
303 return;
304 }
305 }
306 });
307
308 // Handle drop
309 this.content.addEventListener('dragover', (e) => {
310 e.preventDefault();
311 this.showDropOverlay();
312 });
313
314 this.content.addEventListener('dragleave', (e) => {
315 e.preventDefault();
316 this.hideDropOverlay();
317 });
318
319 this.content.addEventListener('drop', (e) => {
320 e.preventDefault();
321 this.hideDropOverlay();
322
323 const files = e.dataTransfer?.files;
324 if (files && files.length > 0) {
325 for (const file of files) {
326 this.uploadFile(file);
327 }
328 }
329 });
330
331 // Handle /upload command
332 this.content.addEventListener('keydown', (e) => {
333 if (e.key === 'Enter') {
334 const selection = window.getSelection();
335 const node = selection.anchorNode;
336 if (node && node.textContent) {
337 const text = node.textContent;
338 if (text.trim() === '/upload') {
339 e.preventDefault();
340 node.textContent = '';
341 this.triggerFileUpload();
342 }
343 }
344 }
345 });
346
347 // File input change
348 this.fileInput.addEventListener('change', (e) => {
349 const files = e.target.files;
350 if (files && files.length > 0) {
351 for (const file of files) {
352 this.uploadFile(file);
353 }
354 }
355 this.fileInput.value = '';
356 });
357
358 // Handle keyboard shortcuts
359 this.content.addEventListener('keydown', (e) => {
360 if (e.ctrlKey || e.metaKey) {
361 switch (e.key.toLowerCase()) {
362 case 'b':
363 e.preventDefault();
364 this.execCommand('bold');
365 break;
366 case 'i':
367 e.preventDefault();
368 this.execCommand('italic');
369 break;
370 case 's':
371 e.preventDefault();
372 this.save();
373 break;
374 }
375 }
376 });
377 }
378
379 showDropOverlay() {
380 if (this.dropOverlay) return;
381
382 this.dropOverlay = document.createElement('div');
383 this.dropOverlay.className = 'rich-editor-upload-overlay';
384 this.dropOverlay.textContent = 'Drop files here to upload';
385 this.wrapper.appendChild(this.dropOverlay);
386 }
387
388 hideDropOverlay() {
389 if (this.dropOverlay) {
390 this.dropOverlay.remove();
391 this.dropOverlay = null;
392 }
393 }
394
395 triggerFileUpload() {
396 this.fileInput.click();
397 }
398
399 async uploadFile(file) {
400 if (!this.options.uploadCallback) {
401 console.warn('No upload callback configured');
402 return;
403 }
404
405 // Insert loading placeholder
406 const placeholder = document.createElement('div');
407 placeholder.className = 'upload-placeholder loading';
408 placeholder.textContent = file.type.startsWith('image/')
409 ? `Processing ${file.name}... ⏳`
410 : `Uploading ${file.name}...`;
411
412 this.insertAtCursor(placeholder);
413
414 this.setStatus(`Uploading ${file.name}...`);
415
416 try {
417 const result = await this.options.uploadCallback(file);
418
419 if (result && result.url) {
420 // Replace placeholder with actual content
421 if (file.type.startsWith('image/')) {
422 const img = document.createElement('img');
423 img.src = result.url;
424 img.alt = file.name;
425 placeholder.replaceWith(img);
426 } else {
427 const link = document.createElement('a');
428 link.href = result.url;
429 link.textContent = file.name;
430 link.target = '_blank';
431 placeholder.replaceWith(link);
432 }
433
434 this.setStatus(`Uploaded ${file.name}`);
435 if (this.debouncedSave) this.debouncedSave();
436 } else {
437 placeholder.textContent = `Failed to upload ${file.name}`;
438 placeholder.className = 'upload-placeholder';
439 this.setStatus('Upload failed');
440 }
441 } catch (error) {
442 console.error('Upload error:', error);
443 placeholder.textContent = `Error: ${error.message}`;
444 placeholder.className = 'upload-placeholder';
445 this.setStatus('Upload error');
446 }
447 }
448
449 insertAtCursor(element) {
450 const selection = window.getSelection();
451 if (selection.rangeCount > 0) {
452 const range = selection.getRangeAt(0);
453 range.deleteContents();
454 range.insertNode(element);
455 range.setStartAfter(element);
456 range.collapse(true);
457 selection.removeAllRanges();
458 selection.addRange(range);
459 } else {
460 this.content.appendChild(element);
461 }
462 this.content.focus();
463 }
464
465 execCommand(cmd) {
466 this.content.focus();
467
468 switch (cmd) {
469 case 'h1':
470 case 'h2':
471 case 'h3':
472 case 'h4':
473 case 'h5':
474 case 'h6':
475 document.execCommand('formatBlock', false, cmd);
476 break;
477 case 'p':
478 document.execCommand('formatBlock', false, 'p');
479 break;
480 case 'ul':
481 document.execCommand('insertUnorderedList', false, null);
482 break;
483 case 'ol':
484 document.execCommand('insertOrderedList', false, null);
485 break;
486 case 'bold':
487 document.execCommand('bold', false, null);
488 break;
489 case 'italic':
490 document.execCommand('italic', false, null);
491 break;
492 case 'link':
493 this.insertLink();
494 break;
495 case 'notelink':
496 this.insertNoteLink();
497 break;
498 case 'upload':
499 this.triggerFileUpload();
500 break;
501 }
502
503 if (this.debouncedSave) this.debouncedSave();
504 }
505
506 insertLink() {
507 const url = prompt('Enter URL:');
508 if (!url) return;
509
510 const selection = window.getSelection();
511 const selectedText = selection.toString() || url;
512
513 const link = document.createElement('a');
514 link.href = url;
515 link.textContent = selectedText;
516 link.target = '_blank';
517
518 if (selection.rangeCount > 0) {
519 const range = selection.getRangeAt(0);
520 range.deleteContents();
521 range.insertNode(link);
522 range.setStartAfter(link);
523 range.collapse(true);
524 selection.removeAllRanges();
525 selection.addRange(range);
526 }
527 }
528
529 insertNoteLink() {
530 const noteId = prompt('Enter note ID (e.g., my-ideas):');
531 if (!noteId) return;
532
533 const sanitizedId = noteId.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
534 const selection = window.getSelection();
535 const selectedText = selection.toString() || sanitizedId;
536
537 const link = document.createElement('a');
538 link.href = '/notes/' + encodeURIComponent(sanitizedId);
539 link.textContent = selectedText;
540 link.className = 'note-link';
541
542 if (selection.rangeCount > 0) {
543 const range = selection.getRangeAt(0);
544 range.deleteContents();
545 range.insertNode(link);
546 range.setStartAfter(link);
547 range.collapse(true);
548 selection.removeAllRanges();
549 selection.addRange(range);
550 }
551 }
552
553 setStatus(text) {
554 this.status.textContent = text;
555 }
556
557 async save() {
558 if (!this.options.saveCallback) return;
559
560 this.setStatus('Saving...');
561
562 try {
563 await this.options.saveCallback(this.getContent());
564 this.setStatus('Saved');
565 } catch (error) {
566 console.error('Save error:', error);
567 this.setStatus('Save failed');
568 }
569 }
570
571 getContent() {
572 return this.content.innerHTML;
573 }
574
575 setContent(html) {
576 this.content.innerHTML = html;
577 }
578
579 getText() {
580 return this.content.textContent;
581 }
582
583 clear() {
584 this.content.innerHTML = '';
585 }
586
587 focus() {
588 this.content.focus();
589 }
590 }
591
592 return {
593 init: function(elementId, options) {
594 return new Editor(elementId, options);
595 }
596 };
597 })();
598
599 // Export for module systems
600 if (typeof module !== 'undefined' && module.exports) {
601 module.exports = RichEditor;
602 }