comparison rich_editor/rich_editor.js @ 231:09a96dcb2b4c hg-web

[merge] Join existing hg-web branch head
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 16:50:48 -0700
parents 5d3e116dd745
children 9c2eec61a152
comparison
equal deleted inserted replaced
217:7ef4c9d2a72d 231:09a96dcb2b4c
1 /**
2 * Rich Editor
3 * -----------
4 * A vanilla JavaScript rich text editor with:
5 * - Text formatting (h1-h6, paragraphs, lists)
6 * - Image/file upload via paste, drop, click, or /upload command
7 * - Debounced auto-save
8 * - Generic callback functions for uploads and saves
9 *
10 * Usage:
11 * const editor = RichEditor.init('editor-container', {
12 * uploadCallback: async (file) => { return { url: '...', key: '...' }; },
13 * saveCallback: async (content) => { console.log('Saved:', content); },
14 * debounceMs: 1000,
15 * placeholder: 'Start writing...'
16 * });
17 */
18
19 const RichEditor = (function() {
20 'use strict';
21
22 const DEFAULT_OPTIONS = {
23 uploadCallback: null,
24 saveCallback: null,
25 debounceMs: 1000,
26 placeholder: 'Start writing... (Use /upload to insert files)',
27 cloudFrontUrl: ''
28 };
29
30 function debounce(func, wait) {
31 let timeout;
32 return function executedFunction(...args) {
33 const later = () => {
34 clearTimeout(timeout);
35 func(...args);
36 };
37 clearTimeout(timeout);
38 timeout = setTimeout(later, wait);
39 };
40 }
41
42 function createToolbar(editor) {
43 const toolbar = document.createElement('div');
44 toolbar.className = 'rich-editor-toolbar';
45
46 const buttons = [
47 { cmd: 'h1', label: '', title: 'Heading 1', icons: "/public/icons/h1.png" },
48 { cmd: 'h2', label: '', title: 'Heading 2', icons: "/public/icons/h2.png" },
49 { cmd: 'h3', label: '', title: 'Heading 3', icons: "/public/icons/h3.png" },
50 { cmd: 'p', label: '¶', title: 'Paragraph' },
51 { cmd: 'ul', label: '•', title: 'Bullet List' },
52 { cmd: 'ol', label: '1.', title: 'Numbered List' },
53 { cmd: 'bold', label: '', title: 'Bold', icons: "/public/icons/bold.png" },
54 { cmd: 'separator' },
55 { cmd: 'link', label: '', title: 'Insert Link', icons: "/public/icons/link.png" },
56 { cmd: 'notelink', label: '', title: 'Link to Note', icons: "/public/icons/binder.png" },
57 { cmd: 'upload', label: '', title: 'Upload File', icons: "/public/icons/clipy.png" },
58 { cmd: 'separator' },
59 { cmd: 'readonly', label: '', title: 'readonly', icons: "/public/icons/book.png" },
60 ];
61
62 buttons.forEach(btn => {
63 if (btn.cmd === 'separator') {
64 const sep = document.createElement('span');
65 sep.className = 'rich-editor-separator';
66 toolbar.appendChild(sep);
67 return;
68 }
69
70 const button = document.createElement('button');
71 button.type = 'button';
72 button.className = 'rich-editor-btn';
73 if (btn.icons)
74 button.style.backgroundImage = `url("${btn.icons}")`;
75 button.textContent = btn.label;
76 button.title = btn.title;
77 button.dataset.cmd = btn.cmd;
78
79 button.addEventListener('click', (e) => {
80 e.preventDefault();
81 editor.execCommand(btn.cmd);
82 });
83
84 toolbar.appendChild(button);
85 });
86
87 return toolbar;
88 }
89
90 function ensureViewportMeta() {
91 // Check if viewport meta tag exists
92 let viewport = document.querySelector('meta[name="viewport"]');
93 if (!viewport) {
94 viewport = document.createElement('meta');
95 viewport.name = 'viewport';
96 viewport.content = 'width=device-width, initial-scale=1.0, maximum-scale=5.0';
97 document.head.appendChild(viewport);
98 }
99 }
100
101 function createStyles() {
102 if (document.getElementById('rich-editor-styles')) return;
103
104 ensureViewportMeta();
105
106 const style = document.createElement('style');
107 style.id = 'rich-editor-styles';
108 style.textContent = `
109 .rich-editor-container {
110 border: 1px solid #ccc;
111 border-radius: 4px;
112 overflow: hidden;
113 }
114
115 .rich-editor-toolbar {
116 display: flex;
117 flex-wrap: wrap;
118 gap: 4px;
119 padding: 8px;
120 background: #f5f5f5;
121 border-bottom: 1px solid #ccc;
122 }
123
124 .rich-editor-btn {
125 padding: 6px 12px;
126 border: 1px solid #ccc;
127 border-radius: 4px;
128 background: #fff;
129 cursor: pointer;
130 font-size: 14px;
131 min-width: 32px;
132 background-repeat: no-repeat;
133 background-position: center;
134 background-size: 24px 24px;
135 border: none;
136 }
137
138 .rich-editor-btn:hover {
139 background: #e9e9e9;
140 background-repeat: no-repeat;
141 background-position: center;
142 background-size: 24px 24px;
143 }
144
145 .rich-editor-btn:active {
146 background: #ddd;
147 background-repeat: no-repeat;
148 background-position: center;
149 background-size: 24px 24px;
150 }
151
152 .rich-editor-separator {
153 width: 1px;
154 background: #ccc;
155 margin: 0 4px;
156 }
157
158 .rich-editor-content {
159 min-height: 300px;
160 padding: 16px;
161 outline: none;
162 overflow-y: auto;
163 }
164
165 .rich-editor-content:empty:before {
166 content: attr(data-placeholder);
167 color: #999;
168 pointer-events: none;
169 }
170
171 .rich-editor-content img {
172 max-width: 100%;
173 height: auto;
174 border-radius: 4px;
175 margin: 8px 0;
176 cursor: pointer;
177 }
178
179 .rich-editor-content img.selected {
180 outline: 2px solid #0078ff;
181 outline-offset: 2px;
182 }
183
184 .rich-editor-resize-wrapper {
185 position: relative;
186 display: inline-block;
187 margin: 8px 0;
188 }
189
190 .rich-editor-resize-handle {
191 position: absolute;
192 width: 12px;
193 height: 12px;
194 background: #0078ff;
195 border: 2px solid white;
196 border-radius: 50%;
197 cursor: nwse-resize;
198 z-index: 10;
199 }
200
201 .rich-editor-resize-handle.nw {
202 top: -6px;
203 left: -6px;
204 cursor: nwse-resize;
205 }
206
207 .rich-editor-resize-handle.ne {
208 top: -6px;
209 right: -6px;
210 cursor: nesw-resize;
211 }
212
213 .rich-editor-resize-handle.sw {
214 bottom: -6px;
215 left: -6px;
216 cursor: nesw-resize;
217 }
218
219 .rich-editor-resize-handle.se {
220 bottom: -6px;
221 right: -6px;
222 cursor: nwse-resize;
223 }
224
225 .rich-editor-size-input {
226 position: absolute;
227 top: -35px;
228 left: 50%;
229 transform: translateX(-50%);
230 background: white;
231 border: 1px solid #ccc;
232 padding: 4px 8px;
233 border-radius: 4px;
234 font-size: 12px;
235 display: flex;
236 gap: 8px;
237 align-items: center;
238 box-shadow: 0 2px 8px rgba(0,0,0,0.1);
239 z-index: 11;
240 }
241
242 .rich-editor-size-input input {
243 width: 60px;
244 padding: 2px 4px;
245 border: 1px solid #ccc;
246 border-radius: 2px;
247 font-size: 12px;
248 }
249
250 .rich-editor-size-input label {
251 font-size: 11px;
252 color: #666;
253 }
254
255 .rich-editor-content a {
256 color: #0078ff;
257 text-decoration: none;
258 }
259
260 .rich-editor-content a:hover {
261 text-decoration: underline;
262 }
263
264 .rich-editor-content a.note-link {
265 background: #e8f4ff;
266 padding: 2px 6px;
267 border-radius: 3px;
268 }
269
270 .rich-editor-content a.note-link:hover {
271 background: #d0e8ff;
272 }
273
274 .rich-editor-content .upload-placeholder {
275 display: inline-block;
276 padding: 8px 16px;
277 background: #f0f0f0;
278 border-radius: 4px;
279 color: #666;
280 font-style: italic;
281 }
282
283 .rich-editor-content .upload-placeholder.loading {
284 padding: 8px 16px;
285 background: #fffacd;
286 border-radius: 4px;
287 animation: pulse 1.5s ease-in-out infinite;
288 }
289
290 @keyframes pulse {
291 0%, 100% { opacity: 1; }
292 50% { opacity: 0.6; }
293 }
294
295 .rich-editor-upload-overlay {
296 position: absolute;
297 top: 0;
298 left: 0;
299 right: 0;
300 bottom: 0;
301 background: rgba(0, 120, 255, 0.1);
302 border: 2px dashed #0078ff;
303 display: flex;
304 align-items: center;
305 justify-content: center;
306 font-size: 18px;
307 color: #0078ff;
308 pointer-events: none;
309 z-index: 10;
310 padding: 20px;
311 text-align: center;
312 }
313
314 .rich-editor-status {
315 padding: 4px 8px;
316 font-size: 12px;
317 color: #666;
318 background: #f9f9f9;
319 border-top: 1px solid #eee;
320 }
321
322 .rich-editor-file-input {
323 display: none;
324 }
325
326 ol,ul {
327 padding: 16px;
328 }
329
330 /* Mobile optimizations */
331 @media (max-width: 720px) {
332 .rich-editor-toolbar {
333 padding: 6px;
334 gap: 3px;
335 overflow-x: auto;
336 -webkit-overflow-scrolling: touch;
337 }
338
339 .rich-editor-btn {
340 min-width: 44px;
341 min-height: 44px;
342 padding: 8px;
343 flex-shrink: 0;
344 }
345
346 .rich-editor-content {
347 min-height: 200px;
348 padding: 12px;
349 font-size: 16px; /* Prevents iOS zoom */
350 line-height: 1.6;
351 }
352
353 .rich-editor-content h1 {
354 font-size: 1.75em;
355 }
356
357 .rich-editor-content h2 {
358 font-size: 1.5em;
359 }
360
361 .rich-editor-content h3 {
362 font-size: 1.25em;
363 }
364
365 .rich-editor-status {
366 font-size: 11px;
367 padding: 6px 8px;
368 }
369
370 /* Mobile-friendly image resizing */
371 .rich-editor-resize-handle {
372 width: 20px;
373 height: 20px;
374 border-width: 3px;
375 }
376
377 .rich-editor-resize-handle.nw {
378 top: -10px;
379 left: -10px;
380 }
381
382 .rich-editor-resize-handle.ne {
383 top: -10px;
384 right: -10px;
385 }
386
387 .rich-editor-resize-handle.sw {
388 bottom: -10px;
389 left: -10px;
390 }
391
392 .rich-editor-resize-handle.se {
393 bottom: -10px;
394 right: -10px;
395 }
396
397 .rich-editor-size-input {
398 top: -40px;
399 padding: 6px 10px;
400 }
401
402 .rich-editor-size-input input {
403 width: 70px;
404 padding: 4px 6px;
405 font-size: 16px; /* Prevents iOS zoom */
406 }
407
408 /* Better touch targets for images */
409 .rich-editor-content img {
410 margin: 12px 0;
411 }
412
413 /* Hide drop overlay text on small screens */
414 .rich-editor-upload-overlay {
415 font-size: 16px;
416 }
417
418 /* Make link/note dialogs mobile-friendly */
419 .rich-editor-content a {
420 padding: 2px 0;
421 }
422 }
423
424 /* Extra small devices */
425 @media (max-width: 480px) {
426 .rich-editor-toolbar {
427 padding: 4px;
428 gap: 2px;
429 }
430
431 .rich-editor-btn {
432 min-width: 40px;
433 min-height: 40px;
434 padding: 6px;
435 }
436
437 .rich-editor-content {
438 padding: 10px;
439 font-size: 16px;
440 }
441
442 .rich-editor-size-input {
443 font-size: 11px;
444 }
445
446 .rich-editor-size-input input {
447 width: 60px;
448 font-size: 14px;
449 }
450 }
451 `;
452 document.head.appendChild(style);
453 }
454
455 class Editor {
456 constructor(elementId, options) {
457 this.options = { ...DEFAULT_OPTIONS, ...options };
458 this.container = document.getElementById(elementId);
459 this.state = {
460 readOnly: false,
461 selectedImage: null,
462 resizing: false
463 };
464
465 if (!this.container) {
466 throw new Error(`Element with id "${elementId}" not found`);
467 }
468
469 this.init();
470 }
471
472 init() {
473 createStyles();
474
475 // Create wrapper
476 this.wrapper = document.createElement('div');
477 this.wrapper.className = 'rich-editor-container';
478 this.wrapper.style.position = 'relative';
479
480 // Create toolbar
481 this.toolbar = createToolbar(this);
482 this.wrapper.appendChild(this.toolbar);
483
484 // Create content area
485 this.content = document.createElement('div');
486 this.content.className = 'rich-editor-content';
487 this.content.contentEditable = true;
488 this.content.dataset.placeholder = this.options.placeholder;
489 this.wrapper.appendChild(this.content);
490
491 // Create status bar
492 this.status = document.createElement('div');
493 this.status.className = 'rich-editor-status';
494 this.status.textContent = 'Ready';
495 this.wrapper.appendChild(this.status);
496
497 // Create hidden file input
498 this.fileInput = document.createElement('input');
499 this.fileInput.type = 'file';
500 this.fileInput.className = 'rich-editor-file-input';
501 this.fileInput.multiple = true;
502 this.fileInput.accept = 'image/*,application/pdf,.doc,.docx,.txt';
503 this.wrapper.appendChild(this.fileInput);
504
505 // Add to container
506 this.container.appendChild(this.wrapper);
507
508 // Setup event listeners
509 this.setupEvents();
510
511 // Setup debounced save
512 if (this.options.saveCallback) {
513 this.debouncedSave = debounce(() => {
514 this.save();
515 }, this.options.debounceMs);
516 }
517 }
518
519 setupEvents() {
520 // Input events for auto-save
521 this.content.addEventListener('input', () => {
522 if (this.debouncedSave) {
523 this.setStatus('Editing...');
524 this.debouncedSave();
525 }
526 });
527
528 // Handle paste
529 this.content.addEventListener('paste', (e) => {
530 const items = e.clipboardData?.items;
531 if (!items) return;
532
533 for (const item of items) {
534 if (item.type.startsWith('image/') || item.kind === 'file') {
535 e.preventDefault();
536 const file = item.getAsFile();
537 if (file) this.uploadFile(file);
538 return;
539 }
540 }
541 });
542
543 // Handle drop
544 this.content.addEventListener('dragover', (e) => {
545 e.preventDefault();
546 this.showDropOverlay();
547 });
548
549 this.content.addEventListener('dragleave', (e) => {
550 e.preventDefault();
551 this.hideDropOverlay();
552 });
553
554 this.content.addEventListener('drop', (e) => {
555 e.preventDefault();
556 this.hideDropOverlay();
557
558 const files = e.dataTransfer?.files;
559 if (files && files.length > 0) {
560 for (const file of files) {
561 this.uploadFile(file);
562 }
563 }
564 });
565
566 // Handle /upload command
567 this.content.addEventListener('keydown', (e) => {
568 if (e.key === 'Enter') {
569 const selection = window.getSelection();
570 const node = selection.anchorNode;
571 if (node && node.textContent) {
572 const text = node.textContent;
573 if (text.trim() === '/upload') {
574 e.preventDefault();
575 node.textContent = '';
576 this.triggerFileUpload();
577 }
578 }
579 }
580 });
581
582 // File input change
583 this.fileInput.addEventListener('change', (e) => {
584 const files = e.target.files;
585 if (files && files.length > 0) {
586 for (const file of files) {
587 this.uploadFile(file);
588 }
589 }
590 this.fileInput.value = '';
591 });
592
593 // Handle keyboard shortcuts
594 this.content.addEventListener('keydown', (e) => {
595 if (e.ctrlKey || e.metaKey) {
596 switch (e.key.toLowerCase()) {
597 case 'b':
598 e.preventDefault();
599 this.execCommand('bold');
600 break;
601 case 'i':
602 e.preventDefault();
603 this.execCommand('italic');
604 break;
605 case 's':
606 e.preventDefault();
607 this.save();
608 break;
609 }
610 }
611 });
612
613 // Handle image clicks for resizing
614 this.content.addEventListener('click', (e) => {
615 if (e.target.tagName === 'IMG') {
616 e.preventDefault();
617 this.selectImage(e.target);
618 } else if (!e.target.closest('.rich-editor-resize-wrapper')) {
619 this.deselectImage();
620 }
621 });
622
623 // Deselect image when clicking outside
624 document.addEventListener('click', (e) => {
625 if (!this.content.contains(e.target)) {
626 this.deselectImage();
627 }
628 });
629 }
630
631 showDropOverlay() {
632 if (this.dropOverlay) return;
633
634 this.dropOverlay = document.createElement('div');
635 this.dropOverlay.className = 'rich-editor-upload-overlay';
636 this.dropOverlay.textContent = 'Drop files here to upload';
637 this.wrapper.appendChild(this.dropOverlay);
638 }
639
640 hideDropOverlay() {
641 if (this.dropOverlay) {
642 this.dropOverlay.remove();
643 this.dropOverlay = null;
644 }
645 }
646
647 triggerFileUpload() {
648 this.fileInput.click();
649 }
650
651 readOnly() {
652 this.state.readOnly = !this.state.readOnly;
653
654 if (this.state.readOnly)
655 {
656 this.content.contentEditable = false;
657 this.setStatus('Read-only mode');
658 const buttons = this.toolbar.querySelectorAll('.rich-editor-btn');
659 buttons.forEach(btn => {
660 if (btn.dataset.cmd !== 'readonly') {
661 btn.disabled = true;
662 }
663 });
664 }
665 else
666 {
667 // Enable editing
668 this.content.contentEditable = true;
669 this.content.style.backgroundColor = '';
670 this.setStatus('Ready');
671
672 // Enable toolbar buttons
673 const buttons = this.toolbar.querySelectorAll('.rich-editor-btn');
674 buttons.forEach(btn => {
675 btn.disabled = false;
676 });
677 }
678 }
679
680 async uploadFile(file) {
681 if (!this.options.uploadCallback) {
682 console.warn('No upload callback configured');
683 return;
684 }
685
686 // Insert loading placeholder
687 const placeholder = document.createElement('div');
688 placeholder.className = 'upload-placeholder loading';
689 placeholder.textContent = file.type.startsWith('image/')
690 ? `Processing ${file.name}... ⏳`
691 : `Uploading ${file.name}...`;
692
693 this.insertAtCursor(placeholder);
694
695 this.setStatus(`Uploading ${file.name}...`);
696
697 try {
698 const result = await this.options.uploadCallback(file);
699
700 if (result && result.url) {
701 // Replace placeholder with actual content
702 if (file.type.startsWith('image/')) {
703 const img = document.createElement('img');
704 img.src = result.url;
705 img.alt = file.name;
706
707 // Store original dimensions when image loads
708 img.onload = () => {
709 img.dataset.originalWidth = img.naturalWidth;
710 img.dataset.originalHeight = img.naturalHeight;
711 };
712
713 placeholder.replaceWith(img);
714 } else {
715 const link = document.createElement('a');
716 link.href = result.url;
717 link.textContent = file.name;
718 link.target = '_blank';
719 placeholder.replaceWith(link);
720 }
721
722 this.setStatus(`Uploaded ${file.name}`);
723 if (this.debouncedSave) this.debouncedSave();
724 } else {
725 placeholder.textContent = `Failed to upload ${file.name}`;
726 placeholder.className = 'upload-placeholder';
727 this.setStatus('Upload failed');
728 }
729 } catch (error) {
730 console.error('Upload error:', error);
731 placeholder.textContent = `Error: ${error.message}`;
732 placeholder.className = 'upload-placeholder';
733 this.setStatus('Upload error');
734 }
735 }
736
737 insertAtCursor(element) {
738 const selection = window.getSelection();
739 if (selection.rangeCount > 0) {
740 const range = selection.getRangeAt(0);
741 range.deleteContents();
742 range.insertNode(element);
743 range.setStartAfter(element);
744 range.collapse(true);
745 selection.removeAllRanges();
746 selection.addRange(range);
747 } else {
748 this.content.appendChild(element);
749 }
750 this.content.focus();
751 }
752
753 execCommand(cmd) {
754 this.content.focus();
755
756 switch (cmd) {
757 case 'h1':
758 case 'h2':
759 case 'h3':
760 case 'h4':
761 case 'h5':
762 case 'h6':
763 document.execCommand('formatBlock', false, cmd);
764 break;
765 case 'p':
766 document.execCommand('formatBlock', false, 'p');
767 break;
768 case 'ul':
769 document.execCommand('insertUnorderedList', false, null);
770 break;
771 case 'ol':
772 document.execCommand('insertOrderedList', false, null);
773 break;
774 case 'bold':
775 document.execCommand('bold', false, null);
776 break;
777 case 'italic':
778 document.execCommand('italic', false, null);
779 break;
780 case 'link':
781 this.insertLink();
782 break;
783 case 'notelink':
784 this.insertNoteLink();
785 break;
786 case 'upload':
787 this.triggerFileUpload();
788 break;
789 case 'readonly':
790 this.readOnly();
791 break;
792 }
793
794 if (this.debouncedSave) this.debouncedSave();
795 }
796
797 insertLink() {
798 const url = prompt('Enter URL:');
799 if (!url) return;
800
801 const selection = window.getSelection();
802 const selectedText = selection.toString() || url;
803
804 const link = document.createElement('a');
805 link.href = url;
806 link.textContent = selectedText;
807 link.target = '_blank';
808
809 if (selection.rangeCount > 0) {
810 const range = selection.getRangeAt(0);
811 range.deleteContents();
812 range.insertNode(link);
813 range.setStartAfter(link);
814 range.collapse(true);
815 selection.removeAllRanges();
816 selection.addRange(range);
817 }
818 }
819
820 insertNoteLink() {
821 const noteId = prompt('Enter note ID (e.g., my-ideas):');
822 if (!noteId) return;
823
824 const sanitizedId = noteId.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
825 const selection = window.getSelection();
826 const selectedText = selection.toString() || sanitizedId;
827
828 const link = document.createElement('a');
829 link.href = '/notes/' + encodeURIComponent(sanitizedId);
830 link.textContent = selectedText;
831 link.className = 'note-link';
832
833 if (selection.rangeCount > 0) {
834 const range = selection.getRangeAt(0);
835 range.deleteContents();
836 range.insertNode(link);
837 range.setStartAfter(link);
838 range.collapse(true);
839 selection.removeAllRanges();
840 selection.addRange(range);
841 }
842 }
843
844 setStatus(text) {
845 this.status.textContent = text;
846 }
847
848 async save() {
849 if (!this.options.saveCallback) return;
850
851 this.setStatus('Saving...');
852
853 try {
854 await this.options.saveCallback(this.getContent());
855 this.setStatus('Saved');
856 } catch (error) {
857 console.error('Save error:', error);
858 this.setStatus('Save failed');
859 }
860 }
861
862 getContent() {
863 return this.content.innerHTML;
864 }
865
866 setContent(html) {
867 this.content.innerHTML = html;
868 }
869
870 getText() {
871 return this.content.textContent;
872 }
873
874 clear() {
875 this.content.innerHTML = '';
876 }
877
878 focus() {
879 this.content.focus();
880 }
881
882 selectImage(img) {
883 if (this.state.readOnly) return;
884
885 // Deselect previous image
886 this.deselectImage();
887
888 this.state.selectedImage = img;
889 img.classList.add('selected');
890
891 // Wrap image in resize container if not already wrapped
892 if (!img.parentElement.classList.contains('rich-editor-resize-wrapper')) {
893 const wrapper = document.createElement('div');
894 wrapper.className = 'rich-editor-resize-wrapper';
895 wrapper.contentEditable = false;
896
897 // Store original dimensions if not set
898 if (!img.dataset.originalWidth) {
899 img.dataset.originalWidth = img.naturalWidth;
900 img.dataset.originalHeight = img.naturalHeight;
901 }
902
903 img.parentNode.insertBefore(wrapper, img);
904 wrapper.appendChild(img);
905 }
906
907 const wrapper = img.parentElement;
908
909 // Add resize handles
910 const corners = ['nw', 'ne', 'sw', 'se'];
911 corners.forEach(corner => {
912 const handle = document.createElement('div');
913 handle.className = `rich-editor-resize-handle ${corner}`;
914 handle.dataset.corner = corner;
915
916 // Mouse events
917 handle.addEventListener('mousedown', (e) => {
918 e.preventDefault();
919 e.stopPropagation();
920 this.startResize(e, img, corner);
921 });
922
923 // Touch events for mobile
924 handle.addEventListener('touchstart', (e) => {
925 e.preventDefault();
926 e.stopPropagation();
927 const touch = e.touches[0];
928 this.startResize(touch, img, corner, true);
929 });
930
931 wrapper.appendChild(handle);
932 });
933
934 // Add size input
935 const sizeInput = document.createElement('div');
936 sizeInput.className = 'rich-editor-size-input';
937 sizeInput.innerHTML = `
938 <label>Width:</label>
939 <input type="number" class="width-input" value="${img.width}" min="50" max="2000">
940 <label>px</label>
941 `;
942
943 const widthInput = sizeInput.querySelector('.width-input');
944 widthInput.addEventListener('input', (e) => {
945 const newWidth = parseInt(e.target.value);
946 if (newWidth && newWidth > 0) {
947 this.resizeImage(img, newWidth);
948 }
949 });
950
951 wrapper.appendChild(sizeInput);
952 }
953
954 deselectImage() {
955 if (!this.state.selectedImage) return;
956
957 const img = this.state.selectedImage;
958 img.classList.remove('selected');
959
960 const wrapper = img.parentElement;
961 if (wrapper && wrapper.classList.contains('rich-editor-resize-wrapper')) {
962 // Remove resize handles and size input
963 wrapper.querySelectorAll('.rich-editor-resize-handle, .rich-editor-size-input').forEach(el => el.remove());
964
965 // Unwrap image
966 wrapper.parentNode.insertBefore(img, wrapper);
967 wrapper.remove();
968 }
969
970 this.state.selectedImage = null;
971 }
972
973 startResize(e, img, corner, isTouch = false) {
974 this.state.resizing = true;
975
976 const startX = e.clientX;
977 const startY = e.clientY;
978 const startWidth = img.width;
979 const startHeight = img.height;
980 const aspectRatio = startWidth / startHeight;
981
982 const onMove = (e) => {
983 if (!this.state.resizing) return;
984
985 const clientX = isTouch ? e.touches[0].clientX : e.clientX;
986 const clientY = isTouch ? e.touches[0].clientY : e.clientY;
987
988 let deltaX = clientX - startX;
989 let deltaY = clientY - startY;
990
991 // Adjust delta based on corner
992 if (corner === 'nw' || corner === 'sw') {
993 deltaX = -deltaX;
994 }
995 if (corner === 'nw' || corner === 'ne') {
996 deltaY = -deltaY;
997 }
998
999 // Use the larger delta to maintain aspect ratio
1000 const delta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY;
1001 const newWidth = Math.max(50, startWidth + delta);
1002
1003 this.resizeImage(img, newWidth, aspectRatio);
1004 };
1005
1006 const onEnd = () => {
1007 this.state.resizing = false;
1008
1009 if (isTouch) {
1010 document.removeEventListener('touchmove', onMove);
1011 document.removeEventListener('touchend', onEnd);
1012 } else {
1013 document.removeEventListener('mousemove', onMove);
1014 document.removeEventListener('mouseup', onEnd);
1015 }
1016
1017 // Update width input
1018 const wrapper = img.parentElement;
1019 const widthInput = wrapper.querySelector('.width-input');
1020 if (widthInput) {
1021 widthInput.value = img.width;
1022 }
1023
1024 if (this.debouncedSave) this.debouncedSave();
1025 };
1026
1027 if (isTouch) {
1028 document.addEventListener('touchmove', onMove, { passive: false });
1029 document.addEventListener('touchend', onEnd);
1030 } else {
1031 document.addEventListener('mousemove', onMove);
1032 document.addEventListener('mouseup', onEnd);
1033 }
1034 }
1035
1036 resizeImage(img, newWidth, aspectRatio = null) {
1037 if (!aspectRatio) {
1038 const originalWidth = parseInt(img.dataset.originalWidth) || img.naturalWidth;
1039 const originalHeight = parseInt(img.dataset.originalHeight) || img.naturalHeight;
1040 aspectRatio = originalWidth / originalHeight;
1041 }
1042
1043 const newHeight = newWidth / aspectRatio;
1044
1045 img.width = newWidth;
1046 img.height = newHeight;
1047 img.style.width = newWidth + 'px';
1048 img.style.height = newHeight + 'px';
1049 }
1050 }
1051
1052 return {
1053 init: function(elementId, options) {
1054 return new Editor(elementId, options);
1055 }
1056 };
1057 })();
1058
1059 // Export for module systems
1060 if (typeof module !== 'undefined' && module.exports) {
1061 module.exports = RichEditor;
1062 }