Mercurial
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 } |