Mercurial
diff 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 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/rich_editor/rich_editor.js Sun Feb 15 07:07:50 2026 -0800 @@ -0,0 +1,602 @@ +/** + * Rich Editor + * ----------- + * A vanilla JavaScript rich text editor with: + * - Text formatting (h1-h6, paragraphs, lists) + * - Image/file upload via paste, drop, click, or /upload command + * - Debounced auto-save + * - Generic callback functions for uploads and saves + * + * Usage: + * const editor = RichEditor.init('editor-container', { + * uploadCallback: async (file) => { return { url: '...', key: '...' }; }, + * saveCallback: async (content) => { console.log('Saved:', content); }, + * debounceMs: 1000, + * placeholder: 'Start writing...' + * }); + */ + +const RichEditor = (function() { + 'use strict'; + + const DEFAULT_OPTIONS = { + uploadCallback: null, + saveCallback: null, + debounceMs: 1000, + placeholder: 'Start writing... (Use /upload to insert files)', + cloudFrontUrl: '' + }; + + function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } + + function createToolbar(editor) { + const toolbar = document.createElement('div'); + toolbar.className = 'rich-editor-toolbar'; + + const buttons = [ + { cmd: 'h1', label: 'H1', title: 'Heading 1' }, + { cmd: 'h2', label: 'H2', title: 'Heading 2' }, + { cmd: 'h3', label: 'H3', title: 'Heading 3' }, + { cmd: 'p', label: 'ΒΆ', title: 'Paragraph' }, + { cmd: 'ul', label: 'β’', title: 'Bullet List' }, + { cmd: 'ol', label: '1.', title: 'Numbered List' }, + { cmd: 'separator' }, + { cmd: 'bold', label: 'B', title: 'Bold' }, + { cmd: 'italic', label: 'I', title: 'Italic' }, + { cmd: 'separator' }, + { cmd: 'link', label: 'π', title: 'Insert Link' }, + { cmd: 'notelink', label: 'π', title: 'Link to Note' }, + { cmd: 'upload', label: 'π', title: 'Upload File' } + ]; + + buttons.forEach(btn => { + if (btn.cmd === 'separator') { + const sep = document.createElement('span'); + sep.className = 'rich-editor-separator'; + toolbar.appendChild(sep); + return; + } + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'rich-editor-btn'; + button.textContent = btn.label; + button.title = btn.title; + button.dataset.cmd = btn.cmd; + + button.addEventListener('click', (e) => { + e.preventDefault(); + editor.execCommand(btn.cmd); + }); + + toolbar.appendChild(button); + }); + + return toolbar; + } + + function createStyles() { + if (document.getElementById('rich-editor-styles')) return; + + const style = document.createElement('style'); + style.id = 'rich-editor-styles'; + style.textContent = ` + .rich-editor-container { + border: 1px solid #ccc; + border-radius: 4px; + overflow: hidden; + } + + .rich-editor-toolbar { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 8px; + background: #f5f5f5; + border-bottom: 1px solid #ccc; + } + + .rich-editor-btn { + padding: 6px 12px; + border: 1px solid #ccc; + border-radius: 4px; + background: #fff; + cursor: pointer; + font-size: 14px; + min-width: 32px; + } + + .rich-editor-btn:hover { + background: #e9e9e9; + } + + .rich-editor-btn:active { + background: #ddd; + } + + .rich-editor-separator { + width: 1px; + background: #ccc; + margin: 0 4px; + } + + .rich-editor-content { + min-height: 300px; + padding: 16px; + outline: none; + overflow-y: auto; + } + + .rich-editor-content:empty:before { + content: attr(data-placeholder); + color: #999; + pointer-events: none; + } + + .rich-editor-content img { + max-width: 100%; + height: auto; + border-radius: 4px; + margin: 8px 0; + } + + .rich-editor-content a { + color: #0078ff; + text-decoration: none; + } + + .rich-editor-content a:hover { + text-decoration: underline; + } + + .rich-editor-content a.note-link { + background: #e8f4ff; + padding: 2px 6px; + border-radius: 3px; + } + + .rich-editor-content a.note-link:hover { + background: #d0e8ff; + } + + .rich-editor-content .upload-placeholder { + display: inline-block; + padding: 8px 16px; + background: #f0f0f0; + border-radius: 4px; + color: #666; + font-style: italic; + } + + .rich-editor-content .upload-placeholder.loading { + padding: 8px 16px; + background: #fffacd; + border-radius: 4px; + animation: pulse 1.5s ease-in-out infinite; + } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } + } + + .rich-editor-upload-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 120, 255, 0.1); + border: 2px dashed #0078ff; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: #0078ff; + pointer-events: none; + z-index: 10; + } + + .rich-editor-status { + padding: 4px 8px; + font-size: 12px; + color: #666; + background: #f9f9f9; + border-top: 1px solid #eee; + } + + .rich-editor-file-input { + display: none; + } + `; + document.head.appendChild(style); + } + + class Editor { + constructor(elementId, options) { + this.options = { ...DEFAULT_OPTIONS, ...options }; + this.container = document.getElementById(elementId); + + if (!this.container) { + throw new Error(`Element with id "${elementId}" not found`); + } + + this.init(); + } + + init() { + createStyles(); + + // Create wrapper + this.wrapper = document.createElement('div'); + this.wrapper.className = 'rich-editor-container'; + this.wrapper.style.position = 'relative'; + + // Create toolbar + this.toolbar = createToolbar(this); + this.wrapper.appendChild(this.toolbar); + + // Create content area + this.content = document.createElement('div'); + this.content.className = 'rich-editor-content'; + this.content.contentEditable = true; + this.content.dataset.placeholder = this.options.placeholder; + this.wrapper.appendChild(this.content); + + // Create status bar + this.status = document.createElement('div'); + this.status.className = 'rich-editor-status'; + this.status.textContent = 'Ready'; + this.wrapper.appendChild(this.status); + + // Create hidden file input + this.fileInput = document.createElement('input'); + this.fileInput.type = 'file'; + this.fileInput.className = 'rich-editor-file-input'; + this.fileInput.multiple = true; + this.fileInput.accept = 'image/*,application/pdf,.doc,.docx,.txt'; + this.wrapper.appendChild(this.fileInput); + + // Add to container + this.container.appendChild(this.wrapper); + + // Setup event listeners + this.setupEvents(); + + // Setup debounced save + if (this.options.saveCallback) { + this.debouncedSave = debounce(() => { + this.save(); + }, this.options.debounceMs); + } + } + + setupEvents() { + // Input events for auto-save + this.content.addEventListener('input', () => { + if (this.debouncedSave) { + this.setStatus('Editing...'); + this.debouncedSave(); + } + }); + + // Handle paste + this.content.addEventListener('paste', (e) => { + const items = e.clipboardData?.items; + if (!items) return; + + for (const item of items) { + if (item.type.startsWith('image/') || item.kind === 'file') { + e.preventDefault(); + const file = item.getAsFile(); + if (file) this.uploadFile(file); + return; + } + } + }); + + // Handle drop + this.content.addEventListener('dragover', (e) => { + e.preventDefault(); + this.showDropOverlay(); + }); + + this.content.addEventListener('dragleave', (e) => { + e.preventDefault(); + this.hideDropOverlay(); + }); + + this.content.addEventListener('drop', (e) => { + e.preventDefault(); + this.hideDropOverlay(); + + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + for (const file of files) { + this.uploadFile(file); + } + } + }); + + // Handle /upload command + this.content.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const selection = window.getSelection(); + const node = selection.anchorNode; + if (node && node.textContent) { + const text = node.textContent; + if (text.trim() === '/upload') { + e.preventDefault(); + node.textContent = ''; + this.triggerFileUpload(); + } + } + } + }); + + // File input change + this.fileInput.addEventListener('change', (e) => { + const files = e.target.files; + if (files && files.length > 0) { + for (const file of files) { + this.uploadFile(file); + } + } + this.fileInput.value = ''; + }); + + // Handle keyboard shortcuts + this.content.addEventListener('keydown', (e) => { + if (e.ctrlKey || e.metaKey) { + switch (e.key.toLowerCase()) { + case 'b': + e.preventDefault(); + this.execCommand('bold'); + break; + case 'i': + e.preventDefault(); + this.execCommand('italic'); + break; + case 's': + e.preventDefault(); + this.save(); + break; + } + } + }); + } + + showDropOverlay() { + if (this.dropOverlay) return; + + this.dropOverlay = document.createElement('div'); + this.dropOverlay.className = 'rich-editor-upload-overlay'; + this.dropOverlay.textContent = 'Drop files here to upload'; + this.wrapper.appendChild(this.dropOverlay); + } + + hideDropOverlay() { + if (this.dropOverlay) { + this.dropOverlay.remove(); + this.dropOverlay = null; + } + } + + triggerFileUpload() { + this.fileInput.click(); + } + + async uploadFile(file) { + if (!this.options.uploadCallback) { + console.warn('No upload callback configured'); + return; + } + + // Insert loading placeholder + const placeholder = document.createElement('div'); + placeholder.className = 'upload-placeholder loading'; + placeholder.textContent = file.type.startsWith('image/') + ? `Processing ${file.name}... β³` + : `Uploading ${file.name}...`; + + this.insertAtCursor(placeholder); + + this.setStatus(`Uploading ${file.name}...`); + + try { + const result = await this.options.uploadCallback(file); + + if (result && result.url) { + // Replace placeholder with actual content + if (file.type.startsWith('image/')) { + const img = document.createElement('img'); + img.src = result.url; + img.alt = file.name; + placeholder.replaceWith(img); + } else { + const link = document.createElement('a'); + link.href = result.url; + link.textContent = file.name; + link.target = '_blank'; + placeholder.replaceWith(link); + } + + this.setStatus(`Uploaded ${file.name}`); + if (this.debouncedSave) this.debouncedSave(); + } else { + placeholder.textContent = `Failed to upload ${file.name}`; + placeholder.className = 'upload-placeholder'; + this.setStatus('Upload failed'); + } + } catch (error) { + console.error('Upload error:', error); + placeholder.textContent = `Error: ${error.message}`; + placeholder.className = 'upload-placeholder'; + this.setStatus('Upload error'); + } + } + + insertAtCursor(element) { + const selection = window.getSelection(); + if (selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + range.insertNode(element); + range.setStartAfter(element); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } else { + this.content.appendChild(element); + } + this.content.focus(); + } + + execCommand(cmd) { + this.content.focus(); + + switch (cmd) { + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': + document.execCommand('formatBlock', false, cmd); + break; + case 'p': + document.execCommand('formatBlock', false, 'p'); + break; + case 'ul': + document.execCommand('insertUnorderedList', false, null); + break; + case 'ol': + document.execCommand('insertOrderedList', false, null); + break; + case 'bold': + document.execCommand('bold', false, null); + break; + case 'italic': + document.execCommand('italic', false, null); + break; + case 'link': + this.insertLink(); + break; + case 'notelink': + this.insertNoteLink(); + break; + case 'upload': + this.triggerFileUpload(); + break; + } + + if (this.debouncedSave) this.debouncedSave(); + } + + insertLink() { + const url = prompt('Enter URL:'); + if (!url) return; + + const selection = window.getSelection(); + const selectedText = selection.toString() || url; + + const link = document.createElement('a'); + link.href = url; + link.textContent = selectedText; + link.target = '_blank'; + + if (selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + range.insertNode(link); + range.setStartAfter(link); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } + } + + insertNoteLink() { + const noteId = prompt('Enter note ID (e.g., my-ideas):'); + if (!noteId) return; + + const sanitizedId = noteId.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-'); + const selection = window.getSelection(); + const selectedText = selection.toString() || sanitizedId; + + const link = document.createElement('a'); + link.href = '/notes/' + encodeURIComponent(sanitizedId); + link.textContent = selectedText; + link.className = 'note-link'; + + if (selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + range.insertNode(link); + range.setStartAfter(link); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } + } + + setStatus(text) { + this.status.textContent = text; + } + + async save() { + if (!this.options.saveCallback) return; + + this.setStatus('Saving...'); + + try { + await this.options.saveCallback(this.getContent()); + this.setStatus('Saved'); + } catch (error) { + console.error('Save error:', error); + this.setStatus('Save failed'); + } + } + + getContent() { + return this.content.innerHTML; + } + + setContent(html) { + this.content.innerHTML = html; + } + + getText() { + return this.content.textContent; + } + + clear() { + this.content.innerHTML = ''; + } + + focus() { + this.content.focus(); + } + } + + return { + init: function(elementId, options) { + return new Editor(elementId, options); + } + }; +})(); + +// Export for module systems +if (typeof module !== 'undefined' && module.exports) { + module.exports = RichEditor; +}