Mercurial
view rich_editor/rich_editor.js @ 208:5d3e116dd745
[MrJuneJune] made it more mobile friendly.
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sun, 15 Feb 2026 12:33:54 -0800 |
| parents | 240337164a80 |
| children |
line wrap: on
line source
/** * 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: '', title: 'Heading 1', icons: "/public/icons/h1.png" }, { cmd: 'h2', label: '', title: 'Heading 2', icons: "/public/icons/h2.png" }, { cmd: 'h3', label: '', title: 'Heading 3', icons: "/public/icons/h3.png" }, { cmd: 'p', label: '¶', title: 'Paragraph' }, { cmd: 'ul', label: '•', title: 'Bullet List' }, { cmd: 'ol', label: '1.', title: 'Numbered List' }, { cmd: 'bold', label: '', title: 'Bold', icons: "/public/icons/bold.png" }, { cmd: 'separator' }, { cmd: 'link', label: '', title: 'Insert Link', icons: "/public/icons/link.png" }, { cmd: 'notelink', label: '', title: 'Link to Note', icons: "/public/icons/binder.png" }, { cmd: 'upload', label: '', title: 'Upload File', icons: "/public/icons/clipy.png" }, { cmd: 'separator' }, { cmd: 'readonly', label: '', title: 'readonly', icons: "/public/icons/book.png" }, ]; 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'; if (btn.icons) button.style.backgroundImage = `url("${btn.icons}")`; 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 ensureViewportMeta() { // Check if viewport meta tag exists let viewport = document.querySelector('meta[name="viewport"]'); if (!viewport) { viewport = document.createElement('meta'); viewport.name = 'viewport'; viewport.content = 'width=device-width, initial-scale=1.0, maximum-scale=5.0'; document.head.appendChild(viewport); } } function createStyles() { if (document.getElementById('rich-editor-styles')) return; ensureViewportMeta(); 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; background-repeat: no-repeat; background-position: center; background-size: 24px 24px; border: none; } .rich-editor-btn:hover { background: #e9e9e9; background-repeat: no-repeat; background-position: center; background-size: 24px 24px; } .rich-editor-btn:active { background: #ddd; background-repeat: no-repeat; background-position: center; background-size: 24px 24px; } .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; cursor: pointer; } .rich-editor-content img.selected { outline: 2px solid #0078ff; outline-offset: 2px; } .rich-editor-resize-wrapper { position: relative; display: inline-block; margin: 8px 0; } .rich-editor-resize-handle { position: absolute; width: 12px; height: 12px; background: #0078ff; border: 2px solid white; border-radius: 50%; cursor: nwse-resize; z-index: 10; } .rich-editor-resize-handle.nw { top: -6px; left: -6px; cursor: nwse-resize; } .rich-editor-resize-handle.ne { top: -6px; right: -6px; cursor: nesw-resize; } .rich-editor-resize-handle.sw { bottom: -6px; left: -6px; cursor: nesw-resize; } .rich-editor-resize-handle.se { bottom: -6px; right: -6px; cursor: nwse-resize; } .rich-editor-size-input { position: absolute; top: -35px; left: 50%; transform: translateX(-50%); background: white; border: 1px solid #ccc; padding: 4px 8px; border-radius: 4px; font-size: 12px; display: flex; gap: 8px; align-items: center; box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 11; } .rich-editor-size-input input { width: 60px; padding: 2px 4px; border: 1px solid #ccc; border-radius: 2px; font-size: 12px; } .rich-editor-size-input label { font-size: 11px; color: #666; } .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; padding: 20px; text-align: center; } .rich-editor-status { padding: 4px 8px; font-size: 12px; color: #666; background: #f9f9f9; border-top: 1px solid #eee; } .rich-editor-file-input { display: none; } ol,ul { padding: 16px; } /* Mobile optimizations */ @media (max-width: 720px) { .rich-editor-toolbar { padding: 6px; gap: 3px; overflow-x: auto; -webkit-overflow-scrolling: touch; } .rich-editor-btn { min-width: 44px; min-height: 44px; padding: 8px; flex-shrink: 0; } .rich-editor-content { min-height: 200px; padding: 12px; font-size: 16px; /* Prevents iOS zoom */ line-height: 1.6; } .rich-editor-content h1 { font-size: 1.75em; } .rich-editor-content h2 { font-size: 1.5em; } .rich-editor-content h3 { font-size: 1.25em; } .rich-editor-status { font-size: 11px; padding: 6px 8px; } /* Mobile-friendly image resizing */ .rich-editor-resize-handle { width: 20px; height: 20px; border-width: 3px; } .rich-editor-resize-handle.nw { top: -10px; left: -10px; } .rich-editor-resize-handle.ne { top: -10px; right: -10px; } .rich-editor-resize-handle.sw { bottom: -10px; left: -10px; } .rich-editor-resize-handle.se { bottom: -10px; right: -10px; } .rich-editor-size-input { top: -40px; padding: 6px 10px; } .rich-editor-size-input input { width: 70px; padding: 4px 6px; font-size: 16px; /* Prevents iOS zoom */ } /* Better touch targets for images */ .rich-editor-content img { margin: 12px 0; } /* Hide drop overlay text on small screens */ .rich-editor-upload-overlay { font-size: 16px; } /* Make link/note dialogs mobile-friendly */ .rich-editor-content a { padding: 2px 0; } } /* Extra small devices */ @media (max-width: 480px) { .rich-editor-toolbar { padding: 4px; gap: 2px; } .rich-editor-btn { min-width: 40px; min-height: 40px; padding: 6px; } .rich-editor-content { padding: 10px; font-size: 16px; } .rich-editor-size-input { font-size: 11px; } .rich-editor-size-input input { width: 60px; font-size: 14px; } } `; document.head.appendChild(style); } class Editor { constructor(elementId, options) { this.options = { ...DEFAULT_OPTIONS, ...options }; this.container = document.getElementById(elementId); this.state = { readOnly: false, selectedImage: null, resizing: false }; 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; } } }); // Handle image clicks for resizing this.content.addEventListener('click', (e) => { if (e.target.tagName === 'IMG') { e.preventDefault(); this.selectImage(e.target); } else if (!e.target.closest('.rich-editor-resize-wrapper')) { this.deselectImage(); } }); // Deselect image when clicking outside document.addEventListener('click', (e) => { if (!this.content.contains(e.target)) { this.deselectImage(); } }); } 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(); } readOnly() { this.state.readOnly = !this.state.readOnly; if (this.state.readOnly) { this.content.contentEditable = false; this.setStatus('Read-only mode'); const buttons = this.toolbar.querySelectorAll('.rich-editor-btn'); buttons.forEach(btn => { if (btn.dataset.cmd !== 'readonly') { btn.disabled = true; } }); } else { // Enable editing this.content.contentEditable = true; this.content.style.backgroundColor = ''; this.setStatus('Ready'); // Enable toolbar buttons const buttons = this.toolbar.querySelectorAll('.rich-editor-btn'); buttons.forEach(btn => { btn.disabled = false; }); } } 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; // Store original dimensions when image loads img.onload = () => { img.dataset.originalWidth = img.naturalWidth; img.dataset.originalHeight = img.naturalHeight; }; 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; case 'readonly': this.readOnly(); 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(); } selectImage(img) { if (this.state.readOnly) return; // Deselect previous image this.deselectImage(); this.state.selectedImage = img; img.classList.add('selected'); // Wrap image in resize container if not already wrapped if (!img.parentElement.classList.contains('rich-editor-resize-wrapper')) { const wrapper = document.createElement('div'); wrapper.className = 'rich-editor-resize-wrapper'; wrapper.contentEditable = false; // Store original dimensions if not set if (!img.dataset.originalWidth) { img.dataset.originalWidth = img.naturalWidth; img.dataset.originalHeight = img.naturalHeight; } img.parentNode.insertBefore(wrapper, img); wrapper.appendChild(img); } const wrapper = img.parentElement; // Add resize handles const corners = ['nw', 'ne', 'sw', 'se']; corners.forEach(corner => { const handle = document.createElement('div'); handle.className = `rich-editor-resize-handle ${corner}`; handle.dataset.corner = corner; // Mouse events handle.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); this.startResize(e, img, corner); }); // Touch events for mobile handle.addEventListener('touchstart', (e) => { e.preventDefault(); e.stopPropagation(); const touch = e.touches[0]; this.startResize(touch, img, corner, true); }); wrapper.appendChild(handle); }); // Add size input const sizeInput = document.createElement('div'); sizeInput.className = 'rich-editor-size-input'; sizeInput.innerHTML = ` <label>Width:</label> <input type="number" class="width-input" value="${img.width}" min="50" max="2000"> <label>px</label> `; const widthInput = sizeInput.querySelector('.width-input'); widthInput.addEventListener('input', (e) => { const newWidth = parseInt(e.target.value); if (newWidth && newWidth > 0) { this.resizeImage(img, newWidth); } }); wrapper.appendChild(sizeInput); } deselectImage() { if (!this.state.selectedImage) return; const img = this.state.selectedImage; img.classList.remove('selected'); const wrapper = img.parentElement; if (wrapper && wrapper.classList.contains('rich-editor-resize-wrapper')) { // Remove resize handles and size input wrapper.querySelectorAll('.rich-editor-resize-handle, .rich-editor-size-input').forEach(el => el.remove()); // Unwrap image wrapper.parentNode.insertBefore(img, wrapper); wrapper.remove(); } this.state.selectedImage = null; } startResize(e, img, corner, isTouch = false) { this.state.resizing = true; const startX = e.clientX; const startY = e.clientY; const startWidth = img.width; const startHeight = img.height; const aspectRatio = startWidth / startHeight; const onMove = (e) => { if (!this.state.resizing) return; const clientX = isTouch ? e.touches[0].clientX : e.clientX; const clientY = isTouch ? e.touches[0].clientY : e.clientY; let deltaX = clientX - startX; let deltaY = clientY - startY; // Adjust delta based on corner if (corner === 'nw' || corner === 'sw') { deltaX = -deltaX; } if (corner === 'nw' || corner === 'ne') { deltaY = -deltaY; } // Use the larger delta to maintain aspect ratio const delta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY; const newWidth = Math.max(50, startWidth + delta); this.resizeImage(img, newWidth, aspectRatio); }; const onEnd = () => { this.state.resizing = false; if (isTouch) { document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onEnd); } else { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onEnd); } // Update width input const wrapper = img.parentElement; const widthInput = wrapper.querySelector('.width-input'); if (widthInput) { widthInput.value = img.width; } if (this.debouncedSave) this.debouncedSave(); }; if (isTouch) { document.addEventListener('touchmove', onMove, { passive: false }); document.addEventListener('touchend', onEnd); } else { document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onEnd); } } resizeImage(img, newWidth, aspectRatio = null) { if (!aspectRatio) { const originalWidth = parseInt(img.dataset.originalWidth) || img.naturalWidth; const originalHeight = parseInt(img.dataset.originalHeight) || img.naturalHeight; aspectRatio = originalWidth / originalHeight; } const newHeight = newWidth / aspectRatio; img.width = newWidth; img.height = newHeight; img.style.width = newWidth + 'px'; img.style.height = newHeight + 'px'; } } return { init: function(elementId, options) { return new Editor(elementId, options); } }; })(); // Export for module systems if (typeof module !== 'undefined' && module.exports) { module.exports = RichEditor; }