Mercurial
view rich_editor/rich_editor.js @ 279:b3b547563ec7
Add Google connector service and agent wiki
Implement the C/Seobeo Google Drive and Gmail connector with encrypted OAuth storage, Zenbu authentication, browser testing, AI tool discovery, chunked HTTP decoding, and Bazel coverage. Consolidate repository guidance into progressive wiki documentation and enforce arena-first allocation for new first-party C code.
Co-authored-by: Copilot <[email protected]>
Copilot-Session: 84c338fd-0939-4bb3-b7f3-1062eb213e5d
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 17 Aug 2026 22:22:36 -0700 |
| parents | 9c2eec61a152 |
| 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.webp" }, { cmd: 'h2', label: '', title: 'Heading 2', icons: "/public/icons/h2.webp" }, { cmd: 'h3', label: '', title: 'Heading 3', icons: "/public/icons/h3.webp" }, { 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.webp" }, { cmd: 'separator' }, { cmd: 'link', label: '', title: 'Insert Link', icons: "/public/icons/link.webp" }, { cmd: 'notelink', label: '', title: 'Link to Note', icons: "/public/icons/binder.webp" }, { cmd: 'upload', label: '', title: 'Upload File', icons: "/public/icons/clipy.webp" }, { cmd: 'separator' }, { cmd: 'readonly', label: '', title: 'readonly', icons: "/public/icons/book.webp" }, ]; 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; }