diff rich_editor/rich_editor.js @ 219:8c9bb0b0759e hg-web

[hg-web] Merge current Zenbu baseline
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 08:34:54 -0700
parents 5d3e116dd745
children 9c2eec61a152
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/rich_editor/rich_editor.js	Sun Aug 02 08:34:54 2026 -0700
@@ -0,0 +1,1062 @@
+/**
+ * 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;
+}