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