wysiwyg.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681
  1. /**
  2. * Simple WYSIWYG Editor
  3. * Lightweight rich text editor for publication content
  4. */
  5. class WYSIWYGEditor {
  6. constructor(textareaId, options = {}) {
  7. this.textarea = document.getElementById(textareaId);
  8. this.options = {
  9. toolbar: ['bold', 'italic', 'underline', '|', 'link', 'image', '|', 'ul', 'ol', '|', 'h1', 'h2', 'h3'],
  10. ...options
  11. };
  12. this.selectedImages = [];
  13. this.resizingImage = null;
  14. this.resizeData = null;
  15. this.aspectRatioLocked = true;
  16. this.createEditor();
  17. this.bindEvents();
  18. this.setupImageUpload();
  19. this.setupImageResizing();
  20. }
  21. createEditor() {
  22. // Create editor container
  23. this.container = document.createElement('div');
  24. this.container.className = 'wysiwyg-container';
  25. // Create toolbar
  26. this.toolbar = document.createElement('div');
  27. this.toolbar.className = 'wysiwyg-toolbar';
  28. this.createToolbarButtons();
  29. // Create content area
  30. this.content = document.createElement('div');
  31. this.content.className = 'wysiwyg-content';
  32. this.content.contentEditable = true;
  33. // Create character count
  34. this.charCount = document.createElement('div');
  35. this.charCount.className = 'wysiwyg-char-count';
  36. // Assemble editor
  37. this.container.appendChild(this.toolbar);
  38. this.container.appendChild(this.content);
  39. this.container.appendChild(this.charCount);
  40. // Replace textarea
  41. this.textarea.parentNode.insertBefore(this.container, this.textarea);
  42. this.textarea.style.display = 'none';
  43. // Initialize content
  44. this.content.innerHTML = this.textarea.value;
  45. this.updateCharCount();
  46. }
  47. createToolbarButtons() {
  48. this.options.toolbar.forEach(item => {
  49. if (item === '|') {
  50. const separator = document.createElement('div');
  51. separator.className = 'wysiwyg-separator';
  52. this.toolbar.appendChild(separator);
  53. } else {
  54. const button = document.createElement('button');
  55. button.className = 'wysiwyg-btn';
  56. button.type = 'button';
  57. button.innerHTML = this.getButtonLabel(item);
  58. button.dataset.command = item;
  59. button.addEventListener('click', () => this.execCommand(item));
  60. this.toolbar.appendChild(button);
  61. }
  62. });
  63. }
  64. getButtonLabel(command) {
  65. const labels = {
  66. 'bold': 'B',
  67. 'italic': 'I',
  68. 'underline': 'U',
  69. 'link': '🔗',
  70. 'image': '🖼️',
  71. 'ul': '• List',
  72. 'ol': '1. List',
  73. 'h1': 'H1',
  74. 'h2': 'H2',
  75. 'h3': 'H3'
  76. };
  77. return labels[command] || command;
  78. }
  79. execCommand(command) {
  80. switch (command) {
  81. case 'bold':
  82. document.execCommand('bold', false, null);
  83. break;
  84. case 'italic':
  85. document.execCommand('italic', false, null);
  86. break;
  87. case 'underline':
  88. document.execCommand('underline', false, null);
  89. break;
  90. case 'link':
  91. this.insertLink();
  92. break;
  93. case 'image':
  94. this.insertImage();
  95. break;
  96. case 'ul':
  97. document.execCommand('insertUnorderedList', false, null);
  98. break;
  99. case 'ol':
  100. document.execCommand('insertOrderedList', false, null);
  101. break;
  102. case 'h1':
  103. this.formatHeading('h1');
  104. break;
  105. case 'h2':
  106. this.formatHeading('h2');
  107. break;
  108. case 'h3':
  109. this.formatHeading('h3');
  110. break;
  111. }
  112. this.content.focus();
  113. }
  114. insertLink() {
  115. const selection = window.getSelection();
  116. const url = prompt('Enter URL:');
  117. if (url) {
  118. document.execCommand('createLink', false, url);
  119. }
  120. }
  121. insertImage() {
  122. this.openImageGallery();
  123. }
  124. openImageGallery() {
  125. this.selectedImages = [];
  126. this.loadGalleryImages();
  127. document.getElementById('imageGallery').classList.add('open');
  128. }
  129. closeImageGallery() {
  130. document.getElementById('imageGallery').classList.remove('open');
  131. this.selectedImages = [];
  132. this.updateInsertButton();
  133. }
  134. showGalleryTab(tab) {
  135. // Hide all tabs
  136. document.getElementById('galleryBrowse').style.display = 'none';
  137. document.getElementById('galleryUpload').style.display = 'none';
  138. // Remove active class from all tabs
  139. document.querySelectorAll('.gallery-tab').forEach(tab => {
  140. tab.classList.remove('active');
  141. });
  142. // Show selected tab and add active class
  143. if (tab === 'browse') {
  144. document.getElementById('galleryBrowse').style.display = 'block';
  145. document.querySelectorAll('.gallery-tab')[0].classList.add('active');
  146. } else if (tab === 'upload') {
  147. document.getElementById('galleryUpload').style.display = 'block';
  148. document.querySelectorAll('.gallery-tab')[1].classList.add('active');
  149. }
  150. }
  151. async loadGalleryImages() {
  152. try {
  153. const response = await fetch('upload_image.php');
  154. const data = await response.json();
  155. if (data.success) {
  156. this.renderGalleryImages(data.images);
  157. } else {
  158. console.error('Failed to load images:', data.error);
  159. }
  160. } catch (error) {
  161. console.error('Error loading images:', error);
  162. }
  163. }
  164. renderGalleryImages(images) {
  165. const grid = document.getElementById('galleryGrid');
  166. grid.innerHTML = '';
  167. images.forEach(image => {
  168. const item = document.createElement('div');
  169. item.className = 'gallery-item';
  170. item.dataset.imageId = image.id;
  171. item.dataset.imageUrl = image.url;
  172. item.dataset.imageName = image.original_name;
  173. item.innerHTML = `
  174. <img src="${image.thumbnail_url}" alt="${image.original_name}" class="gallery-image">
  175. <div class="gallery-info">
  176. <div class="gallery-name">${image.original_name}</div>
  177. <div class="gallery-size">${this.formatFileSize(image.size)}</div>
  178. </div>
  179. `;
  180. item.addEventListener('click', () => this.selectImage(item));
  181. grid.appendChild(item);
  182. });
  183. }
  184. selectImage(item) {
  185. const imageId = item.dataset.imageId;
  186. if (item.classList.contains('selected')) {
  187. item.classList.remove('selected');
  188. this.selectedImages = this.selectedImages.filter(id => id !== imageId);
  189. } else {
  190. item.classList.add('selected');
  191. this.selectedImages.push(imageId);
  192. }
  193. this.updateInsertButton();
  194. }
  195. updateInsertButton() {
  196. const insertBtn = document.getElementById('galleryInsertBtn');
  197. insertBtn.disabled = this.selectedImages.length === 0;
  198. }
  199. insertSelectedImage() {
  200. if (this.selectedImages.length === 0) return;
  201. const selectedItems = document.querySelectorAll('.gallery-item.selected');
  202. const images = [];
  203. selectedItems.forEach(item => {
  204. images.push({
  205. url: item.dataset.imageUrl,
  206. name: item.dataset.imageName
  207. });
  208. });
  209. // Insert images into editor
  210. images.forEach(image => {
  211. const img = document.createElement('img');
  212. img.src = image.url;
  213. img.alt = image.name;
  214. img.style.maxWidth = '100%';
  215. img.style.height = 'auto';
  216. // Insert at cursor position
  217. const selection = window.getSelection();
  218. if (selection.rangeCount > 0) {
  219. const range = selection.getRangeAt(0);
  220. range.insertNode(img);
  221. range.collapse(false);
  222. } else {
  223. this.content.appendChild(img);
  224. }
  225. });
  226. // Update textarea and close gallery
  227. this.textarea.value = this.content.innerHTML;
  228. this.closeImageGallery();
  229. }
  230. setupImageUpload() {
  231. const uploadArea = document.getElementById('uploadArea');
  232. const fileInput = document.getElementById('fileInput');
  233. // Click to upload
  234. uploadArea.addEventListener('click', () => {
  235. fileInput.click();
  236. });
  237. // File selection
  238. fileInput.addEventListener('change', (e) => {
  239. this.handleFileUpload(e.target.files);
  240. });
  241. // Drag and drop
  242. uploadArea.addEventListener('dragover', (e) => {
  243. e.preventDefault();
  244. uploadArea.classList.add('dragover');
  245. });
  246. uploadArea.addEventListener('dragleave', () => {
  247. uploadArea.classList.remove('dragover');
  248. });
  249. uploadArea.addEventListener('drop', (e) => {
  250. e.preventDefault();
  251. uploadArea.classList.remove('dragover');
  252. this.handleFileUpload(e.dataTransfer.files);
  253. });
  254. }
  255. async handleFileUpload(files) {
  256. const formData = new FormData();
  257. for (let i = 0; i < files.length; i++) {
  258. formData.append('images[]', files[i]);
  259. }
  260. try {
  261. this.showUploadProgress();
  262. const response = await fetch('upload_image.php', {
  263. method: 'POST',
  264. body: formData
  265. });
  266. const data = await response.json();
  267. if (data.success) {
  268. this.showUploadSuccess('Images uploaded successfully!');
  269. // Refresh gallery
  270. this.loadGalleryImages();
  271. // Switch to browse tab
  272. this.showGalleryTab('browse');
  273. } else {
  274. this.showUploadError('Upload failed: ' + data.errors.join(', '));
  275. }
  276. } catch (error) {
  277. this.showUploadError('Upload failed: ' + error.message);
  278. } finally {
  279. this.hideUploadProgress();
  280. }
  281. }
  282. showUploadProgress() {
  283. document.getElementById('uploadProgress').classList.add('active');
  284. document.getElementById('uploadStatus').textContent = 'Uploading...';
  285. document.getElementById('progressFill').style.width = '50%';
  286. }
  287. hideUploadProgress() {
  288. document.getElementById('uploadProgress').classList.remove('active');
  289. document.getElementById('progressFill').style.width = '0%';
  290. }
  291. showUploadSuccess(message) {
  292. const status = document.getElementById('uploadStatus');
  293. status.textContent = message;
  294. status.className = 'upload-status success';
  295. setTimeout(() => {
  296. this.hideUploadProgress();
  297. }, 3000);
  298. }
  299. showUploadError(message) {
  300. const status = document.getElementById('uploadStatus');
  301. status.textContent = message;
  302. status.className = 'upload-status error';
  303. setTimeout(() => {
  304. this.hideUploadProgress();
  305. }, 5000);
  306. }
  307. formatFileSize(bytes) {
  308. if (bytes === 0) return '0 Bytes';
  309. const k = 1024;
  310. const sizes = ['Bytes', 'KB', 'MB', 'GB'];
  311. const i = Math.floor(Math.log(bytes) / Math.log(k));
  312. return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  313. }
  314. setupImageResizing() {
  315. this.content.addEventListener('click', (e) => {
  316. if (e.target.tagName === 'IMG') {
  317. this.selectImageForResize(e.target);
  318. } else if (e.target.closest('.resize-container')) {
  319. this.selectImageForResize(e.target.closest('.resize-container').querySelector('img'));
  320. } else {
  321. this.deselectImageForResize();
  322. }
  323. });
  324. // Handle resize handle mouse events
  325. this.content.addEventListener('mousedown', (e) => {
  326. if (e.target.classList.contains('resize-handle')) {
  327. e.preventDefault();
  328. this.startResize(e);
  329. }
  330. });
  331. // Handle global mouse events for resizing
  332. document.addEventListener('mousemove', (e) => {
  333. if (this.resizingImage) {
  334. this.handleResize(e);
  335. }
  336. });
  337. document.addEventListener('mouseup', () => {
  338. if (this.resizingImage) {
  339. this.stopResize();
  340. }
  341. });
  342. }
  343. selectImageForResize(img) {
  344. this.deselectImageForResize();
  345. // Wrap image in resize container if not already wrapped
  346. if (!img.closest('.resize-container')) {
  347. const container = document.createElement('div');
  348. container.className = 'resize-container';
  349. img.parentNode.insertBefore(container, img);
  350. container.appendChild(img);
  351. }
  352. const container = img.closest('.resize-container');
  353. container.classList.add('resizing');
  354. // Add resize handles
  355. this.addResizeHandles(container);
  356. // Add aspect ratio toggle
  357. this.addAspectRatioToggle(container);
  358. // Add size display
  359. this.addSizeDisplay(container);
  360. this.updateSizeDisplay(container);
  361. }
  362. deselectImageForResize() {
  363. // Remove all resize containers and handles
  364. this.content.querySelectorAll('.resize-container').forEach(container => {
  365. const img = container.querySelector('img');
  366. if (img) {
  367. container.parentNode.insertBefore(img, container);
  368. }
  369. container.remove();
  370. });
  371. this.resizingImage = null;
  372. this.resizeData = null;
  373. }
  374. addResizeHandles(container) {
  375. const handles = ['nw', 'ne', 'sw', 'se', 'n', 's', 'w', 'e'];
  376. handles.forEach(position => {
  377. const handle = document.createElement('div');
  378. handle.className = `resize-handle ${position}`;
  379. handle.dataset.position = position;
  380. container.appendChild(handle);
  381. });
  382. }
  383. addAspectRatioToggle(container) {
  384. const toggle = document.createElement('button');
  385. toggle.className = 'aspect-ratio-toggle';
  386. toggle.textContent = this.aspectRatioLocked ? 'Locked' : 'Free';
  387. toggle.title = 'Toggle aspect ratio lock';
  388. // Set initial locked state
  389. if (this.aspectRatioLocked) {
  390. toggle.classList.add('locked');
  391. }
  392. toggle.addEventListener('click', (e) => {
  393. e.stopPropagation();
  394. this.aspectRatioLocked = !this.aspectRatioLocked;
  395. toggle.textContent = this.aspectRatioLocked ? 'Locked' : 'Free';
  396. toggle.classList.toggle('locked', this.aspectRatioLocked);
  397. });
  398. container.appendChild(toggle);
  399. }
  400. addSizeDisplay(container) {
  401. const display = document.createElement('div');
  402. display.className = 'size-display';
  403. container.appendChild(display);
  404. }
  405. updateSizeDisplay(container) {
  406. const img = container.querySelector('img');
  407. const display = container.querySelector('.size-display');
  408. if (img && display) {
  409. display.textContent = `${img.offsetWidth} × ${img.offsetHeight}`;
  410. }
  411. }
  412. startResize(e) {
  413. const handle = e.target;
  414. const container = handle.closest('.resize-container');
  415. const img = container.querySelector('img');
  416. this.resizingImage = img;
  417. this.resizeData = {
  418. container: container,
  419. handle: handle,
  420. position: handle.dataset.position,
  421. startX: e.clientX,
  422. startY: e.clientY,
  423. startWidth: img.offsetWidth,
  424. startHeight: img.offsetHeight,
  425. aspectRatio: img.offsetWidth / img.offsetHeight
  426. };
  427. }
  428. handleResize(e) {
  429. if (!this.resizeData) return;
  430. const deltaX = e.clientX - this.resizeData.startX;
  431. const deltaY = e.clientY - this.resizeData.startY;
  432. const position = this.resizeData.position;
  433. let newWidth = this.resizeData.startWidth;
  434. let newHeight = this.resizeData.startHeight;
  435. switch (position) {
  436. case 'se':
  437. newWidth = this.resizeData.startWidth + deltaX;
  438. newHeight = this.aspectRatioLocked
  439. ? newWidth / this.resizeData.aspectRatio
  440. : this.resizeData.startHeight + deltaY;
  441. break;
  442. case 'sw':
  443. newWidth = this.resizeData.startWidth - deltaX;
  444. newHeight = this.aspectRatioLocked
  445. ? newWidth / this.resizeData.aspectRatio
  446. : this.resizeData.startHeight + deltaY;
  447. break;
  448. case 'ne':
  449. newWidth = this.resizeData.startWidth + deltaX;
  450. newHeight = this.aspectRatioLocked
  451. ? newWidth / this.resizeData.aspectRatio
  452. : this.resizeData.startHeight - deltaY;
  453. break;
  454. case 'nw':
  455. newWidth = this.resizeData.startWidth - deltaX;
  456. newHeight = this.aspectRatioLocked
  457. ? newWidth / this.resizeData.aspectRatio
  458. : this.resizeData.startHeight - deltaY;
  459. break;
  460. case 'n':
  461. newHeight = this.resizeData.startHeight - deltaY;
  462. if (this.aspectRatioLocked) {
  463. newWidth = newHeight * this.resizeData.aspectRatio;
  464. }
  465. break;
  466. case 's':
  467. newHeight = this.resizeData.startHeight + deltaY;
  468. if (this.aspectRatioLocked) {
  469. newWidth = newHeight * this.resizeData.aspectRatio;
  470. }
  471. break;
  472. case 'w':
  473. newWidth = this.resizeData.startWidth - deltaX;
  474. if (this.aspectRatioLocked) {
  475. newHeight = newWidth / this.resizeData.aspectRatio;
  476. }
  477. break;
  478. case 'e':
  479. newWidth = this.resizeData.startWidth + deltaX;
  480. if (this.aspectRatioLocked) {
  481. newHeight = newWidth / this.resizeData.aspectRatio;
  482. }
  483. break;
  484. }
  485. // Apply minimum size constraints
  486. newWidth = Math.max(50, newWidth);
  487. newHeight = Math.max(50, newHeight);
  488. // Apply new dimensions
  489. this.resizingImage.style.width = newWidth + 'px';
  490. this.resizingImage.style.height = newHeight + 'px';
  491. // Update size display
  492. this.updateSizeDisplay(this.resizeData.container);
  493. }
  494. stopResize() {
  495. if (this.resizeData) {
  496. // Update textarea content
  497. this.textarea.value = this.content.innerHTML;
  498. this.updateCharCount();
  499. }
  500. this.resizingImage = null;
  501. this.resizeData = null;
  502. }
  503. formatHeading(tag) {
  504. const selection = window.getSelection();
  505. const range = selection.getRangeAt(0);
  506. const heading = document.createElement(tag);
  507. heading.textContent = range.toString();
  508. range.deleteContents();
  509. range.insertNode(heading);
  510. }
  511. bindEvents() {
  512. // Update textarea when content changes
  513. this.content.addEventListener('input', () => {
  514. this.textarea.value = this.content.innerHTML;
  515. this.updateCharCount();
  516. });
  517. // Update content when textarea changes (for form submission)
  518. this.textarea.addEventListener('input', () => {
  519. this.content.innerHTML = this.textarea.value;
  520. this.updateCharCount();
  521. });
  522. // Handle paste events
  523. this.content.addEventListener('paste', (e) => {
  524. e.preventDefault();
  525. const text = e.clipboardData.getData('text/html') || e.clipboardData.getData('text/plain');
  526. document.execCommand('insertHTML', false, text);
  527. });
  528. // Keyboard shortcuts
  529. this.content.addEventListener('keydown', (e) => {
  530. if (e.ctrlKey || e.metaKey) {
  531. switch (e.key) {
  532. case 'b':
  533. e.preventDefault();
  534. this.execCommand('bold');
  535. break;
  536. case 'i':
  537. e.preventDefault();
  538. this.execCommand('italic');
  539. break;
  540. case 'u':
  541. e.preventDefault();
  542. this.execCommand('underline');
  543. break;
  544. }
  545. }
  546. });
  547. }
  548. updateCharCount() {
  549. const text = this.content.innerText || this.content.textContent || '';
  550. const count = text.length;
  551. this.charCount.textContent = `Characters: ${count}`;
  552. }
  553. getContent() {
  554. return this.content.innerHTML;
  555. }
  556. setContent(html) {
  557. this.content.innerHTML = html;
  558. this.textarea.value = html;
  559. this.updateCharCount();
  560. }
  561. destroy() {
  562. this.textarea.style.display = 'block';
  563. this.textarea.value = this.content.innerHTML;
  564. this.container.remove();
  565. }
  566. }
  567. // Initialize editor when DOM is ready
  568. document.addEventListener('DOMContentLoaded', () => {
  569. const editor = new WYSIWYGEditor('content');
  570. // Make editor globally accessible
  571. window.wysiwygEditor = editor;
  572. // Handle form submission
  573. const form = document.querySelector('.publication-form');
  574. if (form) {
  575. form.addEventListener('submit', () => {
  576. // Ensure textarea has latest content
  577. document.getElementById('content').value = editor.getContent();
  578. });
  579. }
  580. });