view rich_editor/rich_editor.js @ 209:3b47e82ac57e

[MrJuneJune] PWA updates.
author MrJuneJune <me@mrjunejune.com>
date Sun, 15 Feb 2026 15:43:26 -0800
parents 5d3e116dd745
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;
}