PluginProbe
Forumax – AI Powered Advanced Community Forum Plugin / 2.5.0
Forumax – AI Powered Advanced Community Forum Plugin v2.5.0
2.5.1 2.5.0 2.4.4 2.4.3 2.4.2 2.4.1 2.4.0 trunk 1.0.8 1.1.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.4.1 All 31 releases
bbp-core / assets / frontend / js / frmx-inline-edit.js

frmx-inline-edit.js in Forumax – AI Powered Advanced Community Forum Plugin 2.5.0, at assets/frontend/js/frmx-inline-edit.js

1,158 lines 46.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Forumax Inline Edit - Topic
3 *
4 * Intercepts the Edit link in the admin dropdown,
5 * swaps the topic title and content with a visual TinyMCE editor,
6 * and saves changes via AJAX without page reload.
7 *
8 * @package Forumax
9 */
10 ; (function ($) {
11
12 'use strict';
13
14 /**
15 * TinyMCE editor ID used for the inline editor instance.
16 *
17 * @type {string}
18 */
19 var EDITOR_ID = 'frmx-edit-content';
20
21 /**
22 * Inline edit state management.
23 */
24 var FrmxInlineEdit = {
25
26 isEditing: false,
27
28 /**
29 * Initialize inline edit functionality.
30 */
31 init: function () {
32 this.bindEvents();
33 },
34
35 /**
36 * Bind event listeners.
37 */
38 bindEvents: function () {
39 var self = this;
40
41 // Intercept the Edit option in the topic admin dropdown (nice-select).
42 // nice-select does NOT copy data-action attributes, so we find the
43 // corresponding <option> by index from the original <select>.
44 $(document).on('click', '.topic-content-wrapper .bbp-admin-links-dropdown .nice-select .list .option', function (e) {
45 var $option = $(this);
46 var optIndex = $option.index();
47 var $dropdown = $option.closest('.bbp-admin-links-dropdown');
48 var $origOpt = $dropdown.find('select option').eq(optIndex);
49 var action = $origOpt.data('action');
50
51 if (action === 'edit') {
52 e.preventDefault();
53 e.stopPropagation();
54
55 var $wrapper = $dropdown.closest('.topic-content-wrapper');
56
57 // Close the nice-select dropdown
58 $dropdown.find('.nice-select').removeClass('open');
59
60 if (!self.isEditing) {
61 self.startEditing($wrapper);
62 }
63
64 return false;
65 }
66 });
67
68 // Intercept changes on the original <select> (fallback for non-nice-select).
69 // Scoped to topic-content-wrapper so it does not catch reply dropdowns.
70 $(document).on('change', '.topic-content-wrapper .bbp-admin-dropdown-menu', function () {
71 var $selected = $(this).find('option:selected');
72 var action = $selected.data('action');
73
74 if (action === 'edit') {
75 $(this).val('');
76
77 var $dropdown = $(this).closest('.bbp-admin-links-dropdown');
78 var $wrapper = $dropdown.closest('.topic-content-wrapper');
79
80 if (!self.isEditing) {
81 self.startEditing($wrapper);
82 }
83
84 return false;
85 }
86 });
87 },
88
89 /**
90 * Start inline editing mode.
91 *
92 * @param {jQuery} $wrapper - The .topic-content-wrapper element.
93 */
94 startEditing: function ($wrapper) {
95 var self = this;
96 var topicId = frmxInlineEdit.topic_id;
97
98 if (!topicId) {
99 return;
100 }
101
102 self.isEditing = true;
103
104 // Show loading state
105 var $titleEl = $wrapper.find('.q-title h1');
106 var $contentEl = $wrapper.find('.content.editor-content');
107
108 // $titleEl.css('opacity', '0.5');
109 $contentEl.css('opacity', '0.5');
110
111 // Fetch raw content via AJAX
112 $.ajax({
113 url: frmxInlineEdit.ajaxurl,
114 type: 'POST',
115 data: {
116 action: 'forumax_get_topic_content',
117 nonce: frmxInlineEdit.nonce,
118 topic_id: topicId
119 },
120 success: function (response) {
121 if (response.success) {
122 self.renderEditor($wrapper, response.data);
123 } else {
124 self.showNotice($wrapper, response.data.message, 'error');
125 self.isEditing = false;
126 }
127
128 $titleEl.css('opacity', '');
129 $contentEl.css('opacity', '');
130 },
131 error: function () {
132 self.showNotice($wrapper, frmxInlineEdit.i18n.error, 'error');
133 self.isEditing = false;
134 $titleEl.css('opacity', '');
135 $contentEl.css('opacity', '');
136 }
137 });
138 },
139
140 /**
141 * Render the inline editor form with TinyMCE visual editor.
142 *
143 * @param {jQuery} $wrapper - The .topic-content-wrapper element.
144 * @param {Object} data - { title, content } from the server.
145 */
146 renderEditor: function ($wrapper, data) {
147 var self = this;
148
149 var $qTitle = $wrapper.find('.q-title');
150 var $contentEl = $wrapper.find('.content.editor-content');
151
152 // Store original HTML for cancel
153 self.originalTitleHtml = $qTitle.html();
154 self.originalContentHtml = $contentEl.html();
155
156 // Replace title with input
157 var titleInput = '<div class="frmx-inline-edit-title">' +
158 '<label for="frmx-edit-title" class="screen-reader-text">' + frmxInlineEdit.i18n.title_label + '</label>' +
159 '<input type="text" id="frmx-edit-title" class="frmx-edit-title-input" value="' + self.escapeAttr(data.title) + '" />' +
160 '</div>';
161 $qTitle.html(titleInput);
162
163 // Replace content with a textarea that TinyMCE will enhance
164 var editorHtml = '<div class="frmx-inline-edit-content">' +
165 '<label for="' + EDITOR_ID + '" class="screen-reader-text">' + frmxInlineEdit.i18n.content_label + '</label>' +
166 '<textarea id="' + EDITOR_ID + '" class="frmx-edit-content-textarea" rows="12">' + data.content + '</textarea>' +
167 '<div class="frmx-inline-edit-actions">' +
168 '<button type="button" class="frmx-edit-save">' + frmxInlineEdit.i18n.save + '</button>' +
169 '<button type="button" class="frmx-edit-cancel">' + frmxInlineEdit.i18n.cancel + '</button>' +
170 '</div>' +
171 '</div>';
172 $contentEl.html(editorHtml);
173
174 // Add editing class for styling
175 $wrapper.addClass('frmx-is-editing');
176
177 // Initialize TinyMCE visual editor on the textarea
178 self.initTinyMCE();
179
180 // Focus the title input
181 $wrapper.find('.frmx-edit-title-input').focus();
182
183 // Bind save/cancel
184 $contentEl.find('.frmx-edit-save').on('click', function () {
185 self.saveEdit($wrapper);
186 });
187
188 $contentEl.find('.frmx-edit-cancel').on('click', function () {
189 self.cancelEdit($wrapper);
190 });
191
192 // Keyboard shortcuts on the title input
193 $wrapper.find('.frmx-edit-title-input').on('keydown', function (e) {
194 // Ctrl+Enter / Cmd+Enter to save
195 if ((e.ctrlKey || e.metaKey) && e.keyCode === 13) {
196 e.preventDefault();
197 self.saveEdit($wrapper);
198 }
199 // Escape to cancel
200 if (e.keyCode === 27) {
201 e.preventDefault();
202 self.cancelEdit($wrapper);
203 }
204 });
205 },
206
207 /**
208 * Initialize TinyMCE on the editor textarea.
209 *
210 * Uses the WordPress wp.editor API for consistent behavior.
211 */
212 initTinyMCE: function () {
213 var self = this;
214
215 // Remove any existing editor instance first
216 if (typeof wp !== 'undefined' && wp.editor) {
217 wp.editor.remove(EDITOR_ID);
218 }
219
220 // Force Visual mode — set the WP user preference before initialization.
221 if (typeof setUserSetting === 'function') {
222 setUserSetting('editor', 'tinymce');
223 }
224
225 // Use wp.editor.initialize for proper TinyMCE setup
226 if (typeof wp !== 'undefined' && wp.editor && wp.editor.initialize) {
227 wp.editor.initialize(EDITOR_ID, {
228 tinymce: {
229 wpautop: true,
230 toolbar1: 'bold,italic,underline,strikethrough,|,bullist,numlist,|,blockquote,|,link,unlink,|,undo,redo',
231 toolbar2: '',
232 height: 250,
233 statusbar: false,
234 resize: true,
235 setup: function (editor) {
236 // Ensure Visual mode is active after editor initialises.
237 editor.on('init', function () {
238 self.forceVisualMode(EDITOR_ID);
239 });
240
241 // Ctrl+Enter / Cmd+Enter to save from inside the editor
242 editor.on('keydown', function (e) {
243 if ((e.ctrlKey || e.metaKey) && e.keyCode === 13) {
244 e.preventDefault();
245 var $wrapper = $('#' + EDITOR_ID).closest('.topic-content-wrapper');
246 self.saveEdit($wrapper);
247 }
248 if (e.keyCode === 27) {
249 e.preventDefault();
250 var $wrapper = $('#' + EDITOR_ID).closest('.topic-content-wrapper');
251 self.cancelEdit($wrapper);
252 }
253 });
254 }
255 },
256 quicktags: true,
257 mediaButtons: false
258 });
259
260 // Also force Visual mode immediately after initialize call.
261 this.forceVisualMode(EDITOR_ID);
262 }
263 },
264
265 /**
266 * Force the editor wrapper into Visual (TinyMCE) mode.
267 * Directly manipulates DOM classes and visibility as a
268 * reliable alternative to switchEditors.go().
269 *
270 * @param {string} editorId - The editor textarea ID.
271 */
272 forceVisualMode: function (editorId) {
273 var $wrap = $('#wp-' + editorId + '-wrap');
274 console.log('[forceVisualMode] editorId:', editorId, '| #wp-' + editorId + '-wrap found:', $wrap.length);
275 if (!$wrap.length) {
276 $wrap = $('#' + editorId).closest('.wp-editor-wrap');
277 console.log('[forceVisualMode] fallback .closest found:', $wrap.length);
278 }
279
280 if (!$wrap.length) {
281 console.warn('[forceVisualMode] No wrapper found for', editorId);
282 return;
283 }
284
285 console.log('[forceVisualMode] wrapper classes BEFORE:', $wrap.attr('class'));
286 console.log('[forceVisualMode] quicktags found:', $wrap.find('.quicktags-toolbar').length, '| offsetHeight:', $wrap.find('.quicktags-toolbar')[0] ? $wrap.find('.quicktags-toolbar')[0].offsetHeight : 'N/A');
287 console.log('[forceVisualMode] mce-tinymce found:', $wrap.find('.mce-tinymce').length);
288
289 // Swap classes to Visual mode.
290 $wrap.removeClass('html-active').addClass('tmce-active');
291
292 // Hide the quicktags toolbar.
293 $wrap.find('.quicktags-toolbar').css({
294 display: 'none',
295 height: '0',
296 padding: '0',
297 overflow: 'hidden'
298 });
299
300 // Set the Visual tab as active.
301 $wrap.find('.switch-tmce').attr('aria-pressed', 'true');
302 $wrap.find('.switch-html').attr('aria-pressed', 'false');
303
304 // Show the TinyMCE container, hide the raw textarea.
305 $wrap.find('.mce-tinymce').show();
306 $wrap.find('#' + editorId).hide();
307
308 console.log('[forceVisualMode] wrapper classes AFTER:', $wrap.attr('class'));
309 console.log('[forceVisualMode] quicktags offsetHeight AFTER:', $wrap.find('.quicktags-toolbar')[0] ? $wrap.find('.quicktags-toolbar')[0].offsetHeight : 'N/A');
310 },
311
312 /**
313 * Get the content from TinyMCE or fallback textarea.
314 *
315 * @return {string} The editor content.
316 */
317 getEditorContent: function () {
318 // Try TinyMCE first
319 if (typeof tinyMCE !== 'undefined') {
320 var editor = tinyMCE.get(EDITOR_ID);
321 if (editor && !editor.isHidden()) {
322 return editor.getContent();
323 }
324 }
325
326 // Fallback to textarea value (text mode)
327 return $('#' + EDITOR_ID).val();
328 },
329
330 /**
331 * Destroy the TinyMCE editor instance.
332 */
333 destroyEditor: function () {
334 if (typeof wp !== 'undefined' && wp.editor) {
335 wp.editor.remove(EDITOR_ID);
336 }
337 },
338
339 /**
340 * Save the inline edit via AJAX.
341 *
342 * @param {jQuery} $wrapper - The .topic-content-wrapper element.
343 */
344 saveEdit: function ($wrapper) {
345 var self = this;
346 var topicId = frmxInlineEdit.topic_id;
347 var newTitle = $wrapper.find('.frmx-edit-title-input').val().trim();
348 var newContent = self.getEditorContent().trim();
349
350 if (!newTitle) {
351 self.showNotice($wrapper, frmxInlineEdit.i18n.title_required, 'error');
352 $wrapper.find('.frmx-edit-title-input').focus();
353 return;
354 }
355
356 if (!newContent) {
357 self.showNotice($wrapper, frmxInlineEdit.i18n.content_required, 'error');
358 return;
359 }
360
361 // Disable buttons and show saving state
362 var $saveBtn = $wrapper.find('.frmx-edit-save');
363 var $cancelBtn = $wrapper.find('.frmx-edit-cancel');
364 $saveBtn.prop('disabled', true).text(frmxInlineEdit.i18n.saving);
365 $cancelBtn.prop('disabled', true);
366
367 $.ajax({
368 url: frmxInlineEdit.ajaxurl,
369 type: 'POST',
370 data: {
371 action: 'forumax_save_topic_edit',
372 nonce: frmxInlineEdit.nonce,
373 topic_id: topicId,
374 title: newTitle,
375 content: newContent
376 },
377 success: function (response) {
378 if (response.success) {
379 // Destroy TinyMCE before replacing HTML
380 self.destroyEditor();
381
382 // Update the displayed content
383 var $qTitle = $wrapper.find('.q-title');
384 var $contentEl = $wrapper.find('.content.editor-content');
385
386 // Restore title area with new title
387 $qTitle.html(self.originalTitleHtml);
388 $qTitle.find('h1').contents().first().replaceWith(response.data.title);
389
390 // Restore content area with new rendered content
391 $contentEl.html(response.data.content);
392
393 $wrapper.removeClass('frmx-is-editing');
394 self.isEditing = false;
395
396 self.showNotice($wrapper, response.data.message, 'success');
397
398 // Re-initialize nice-select on admin dropdown if it exists
399 if (typeof $.fn.frmxNiceSelect === 'function') {
400 $wrapper.find('select.bbp-admin-dropdown-menu').each(function () {
401 var $select = $(this);
402 $select.nextAll('.nice-select, .frmx-nice-select').remove();
403 $select.frmxNiceSelect();
404 $select.next('.frmx-nice-select').removeClass('nice-select');
405 });
406 }
407 if (typeof window.frmxEnhanceAdminDropdowns === 'function') {
408 window.frmxEnhanceAdminDropdowns();
409 }
410 } else {
411 self.showNotice($wrapper, response.data.message, 'error');
412 $saveBtn.prop('disabled', false).text(frmxInlineEdit.i18n.save);
413 $cancelBtn.prop('disabled', false);
414 }
415 },
416 error: function () {
417 self.showNotice($wrapper, frmxInlineEdit.i18n.error, 'error');
418 $saveBtn.prop('disabled', false).text(frmxInlineEdit.i18n.save);
419 $cancelBtn.prop('disabled', false);
420 }
421 });
422 },
423
424 /**
425 * Cancel inline editing and restore original content.
426 *
427 * @param {jQuery} $wrapper - The .topic-content-wrapper element.
428 */
429 cancelEdit: function ($wrapper) {
430 // Destroy TinyMCE before replacing HTML
431 this.destroyEditor();
432
433 var $qTitle = $wrapper.find('.q-title');
434 var $contentEl = $wrapper.find('.content.editor-content');
435
436 $qTitle.html(this.originalTitleHtml);
437 $contentEl.html(this.originalContentHtml);
438
439 $wrapper.removeClass('frmx-is-editing');
440 this.isEditing = false;
441 },
442
443 /**
444 * Show a temporary notice message.
445 *
446 * @param {jQuery} $wrapper - The wrapper element.
447 * @param {string} message - The message text.
448 * @param {string} type - 'success' or 'error'.
449 */
450 showNotice: function ($wrapper, message, type) {
451 // Remove any existing notices
452 $wrapper.find('.frmx-inline-edit-notice').remove();
453
454 var noticeClass = 'frmx-inline-edit-notice frmx-notice-' + type;
455 var $notice = $('<div class="' + noticeClass + '">' + message + '</div>');
456
457 $wrapper.find('.q-title').after($notice);
458
459 // Auto-dismiss after 4 seconds
460 setTimeout(function () {
461 $notice.fadeOut(300, function () {
462 $(this).remove();
463 });
464 }, 4000);
465 },
466
467 /**
468 * Escape HTML entities for safe attribute insertion.
469 *
470 * @param {string} str - The string to escape.
471 * @return {string} Escaped string.
472 */
473 escapeAttr: function (str) {
474 return str
475 .replace(/&/g, '&amp;')
476 .replace(/"/g, '&quot;')
477 .replace(/'/g, '&#39;')
478 .replace(/</g, '&lt;')
479 .replace(/>/g, '&gt;');
480 }
481 };
482
483 $(document).ready(function () {
484 // Only initialize on single topic pages
485 if (typeof frmxInlineEdit !== 'undefined') {
486 if ($('.topic-content-wrapper').length) {
487 FrmxInlineEdit.init();
488 }
489 if ($('.forum-comment').length) {
490 FrmxReplyInlineEdit.init();
491 }
492 }
493 });
494
495 /**
496 * Reply inline edit state management.
497 *
498 * Intercepts the Edit link in reply admin dropdowns,
499 * swaps the reply content with a visual TinyMCE editor,
500 * and saves changes via AJAX without page reload.
501 */
502 var FrmxReplyInlineEdit = {
503
504 /**
505 * Track which reply is currently being edited (reply ID or false).
506 *
507 * @type {number|boolean}
508 */
509 editingReplyId: false,
510
511 /**
512 * Initialize reply inline edit functionality.
513 */
514 init: function () {
515 this.bindEvents();
516 },
517
518 /**
519 * Bind event listeners for reply edit interception.
520 */
521 bindEvents: function () {
522 var self = this;
523
524 // Intercept the Edit option in reply admin dropdown (nice-select).
525 // nice-select does NOT copy data-action attributes, so we find the
526 // corresponding <option> by index from the original <select>.
527 $(document).on('click', '.reply-admin-links .bbp-admin-links-dropdown .nice-select .list .option, .reply-admin-links .bbp-admin-links-dropdown .frmx-nice-select .list .option', function (e) {
528 var $option = $(this);
529 var optIndex = $option.index();
530 var $dropdown = $option.closest('.bbp-admin-links-dropdown');
531 var $origOpt = $dropdown.find('select option').eq(optIndex);
532 var action = $origOpt.data('action');
533
534 if (action === 'edit') {
535 e.preventDefault();
536 e.stopPropagation();
537
538 var $reply = $dropdown.closest('.forum-comment');
539
540 // Close the nice-select dropdown
541 $dropdown.find('.nice-select, .frmx-nice-select').removeClass('open');
542
543 if (!self.editingReplyId) {
544 self.startEditing($reply);
545 }
546
547 return false;
548 }
549 });
550
551 // Intercept changes on the original <select> (fallback for non-nice-select).
552 $(document).on('change', '.reply-admin-links .bbp-admin-dropdown-menu', function () {
553 var $selected = $(this).find('option:selected');
554 var action = $selected.data('action');
555
556 if (action === 'edit') {
557 $(this).val('');
558
559 var $dropdown = $(this).closest('.bbp-admin-links-dropdown');
560 var $reply = $dropdown.closest('.forum-comment');
561
562 if (!self.editingReplyId) {
563 self.startEditing($reply);
564 }
565
566 return false;
567 }
568 });
569 },
570
571 /**
572 * Get the reply ID from a .forum-comment element.
573 *
574 * @param {jQuery} $reply - The .forum-comment element.
575 * @return {number} The reply ID.
576 */
577 getReplyId: function ($reply) {
578 // The reply container has id="post-{id}"
579 var postId = $reply.attr('id');
580 if (postId) {
581 return parseInt(postId.replace('post-', ''), 10);
582 }
583 return 0;
584 },
585
586 /**
587 * Get the TinyMCE editor ID for a specific reply.
588 *
589 * @param {number} replyId - The reply ID.
590 * @return {string} The editor textarea ID.
591 */
592 getEditorId: function (replyId) {
593 return 'frmx-reply-edit-content-' + replyId;
594 },
595
596 /**
597 * Track whether content has been modified during editing.
598 *
599 * @type {boolean}
600 */
601 contentModified: false,
602
603 /**
604 * Start inline editing mode for a reply.
605 *
606 * @param {jQuery} $reply - The .forum-comment element.
607 */
608 startEditing: function ($reply) {
609 var self = this;
610 var replyId = self.getReplyId($reply);
611
612 if (!replyId) {
613 return;
614 }
615
616 self.editingReplyId = replyId;
617 self.contentModified = false;
618
619 // Show loading spinner overlay
620 var $contentEl = $reply.find('.comment-content').first();
621 var $spinner = $('<div class="frmx-edit-loading">' +
622 '<div class="frmx-edit-loading-spinner"></div>' +
623 '<span>' + frmxInlineEdit.i18n.loading + '</span>' +
624 '</div>');
625 $contentEl.css({ position: 'relative', minHeight: '80px' });
626 $contentEl.append($spinner);
627 $contentEl.children().not('.frmx-edit-loading').css('opacity', '0.35');
628
629 // Fetch raw content via AJAX
630 $.ajax({
631 url: frmxInlineEdit.ajaxurl,
632 type: 'POST',
633 data: {
634 action: 'forumax_get_reply_content',
635 nonce: frmxInlineEdit.nonce,
636 reply_id: replyId
637 },
638 success: function (response) {
639 $spinner.remove();
640 $contentEl.css({ position: '', minHeight: '' });
641
642 // Always restore children opacity before handing off to renderEditor
643 // so originalContentHtml is stored without the inline opacity style.
644 $contentEl.children().css('opacity', '');
645
646 if (response.success) {
647 self.renderEditor($reply, response.data);
648 } else {
649 self.showNotice($reply, response.data.message, 'error');
650 self.editingReplyId = false;
651 }
652 },
653 error: function () {
654 $spinner.remove();
655 $contentEl.css({ position: '', minHeight: '' });
656 self.showNotice($reply, frmxInlineEdit.i18n.error, 'error');
657 self.editingReplyId = false;
658 $contentEl.children().css('opacity', '');
659 }
660 });
661 },
662
663 /**
664 * Render the inline editor form for a reply with TinyMCE visual editor.
665 *
666 * @param {jQuery} $reply - The .forum-comment element.
667 * @param {Object} data - { content } from the server.
668 */
669 renderEditor: function ($reply, data) {
670 var self = this;
671 var replyId = self.getReplyId($reply);
672 var editorId = self.getEditorId(replyId);
673
674 var $contentEl = $reply.find('.comment-content').first();
675
676 // Store original HTML for cancel
677 self.originalContentHtml = $contentEl.html();
678
679 // Build enhanced editor UI with header bar and footer
680 var isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
681 var modKey = isMac ? '⌘' : 'Ctrl';
682 var editorHtml = '<div class="frmx-inline-edit-content frmx-reply-inline-edit" role="form" aria-label="' + frmxInlineEdit.i18n.editing_reply + '">' +
683 // Header bar
684 '<div class="frmx-edit-header">' +
685 '<span class="frmx-edit-header-label">' +
686 '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>' +
687 frmxInlineEdit.i18n.editing_reply +
688 '</span>' +
689 '<span class="frmx-edit-header-shortcut">' +
690 '<kbd>' + modKey + '</kbd><kbd>Enter</kbd> ' + frmxInlineEdit.i18n.to_save +
691 '<span class="frmx-edit-shortcut-sep" aria-hidden="true">·</span>' +
692 '<kbd>Esc</kbd> ' + frmxInlineEdit.i18n.to_cancel +
693 '</span>' +
694 '</div>' +
695 // Editor textarea
696 '<label for="' + editorId + '" class="screen-reader-text">' + frmxInlineEdit.i18n.content_label + '</label>' +
697 '<textarea id="' + editorId + '" class="frmx-edit-content-textarea" rows="8">' + data.content + '</textarea>' +
698 // Footer actions
699 '<div class="frmx-inline-edit-actions">' +
700 '<div class="frmx-edit-actions-left">' +
701 '<span class="frmx-edit-unsaved-hint" role="status" aria-live="polite"></span>' +
702 '<span class="frmx-edit-char-count" aria-hidden="true"></span>' +
703 '</div>' +
704 '<div class="frmx-edit-actions-right">' +
705 '<button type="button" class="frmx-edit-cancel">' + frmxInlineEdit.i18n.cancel + '</button>' +
706 '<button type="button" class="frmx-edit-save">' +
707 '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>' +
708 frmxInlineEdit.i18n.save +
709 '</button>' +
710 '</div>' +
711 // Inline discard confirmation (hidden by default)
712 '<div class="frmx-edit-discard-prompt" role="alertdialog" aria-hidden="true">' +
713 '<span class="frmx-edit-discard-text">' + frmxInlineEdit.i18n.discard_confirm + '</span>' +
714 '<div class="frmx-edit-discard-actions">' +
715 '<button type="button" class="frmx-edit-discard-keep">' + frmxInlineEdit.i18n.keep_editing + '</button>' +
716 '<button type="button" class="frmx-edit-discard-confirm">' + frmxInlineEdit.i18n.discard + '</button>' +
717 '</div>' +
718 '</div>' +
719 '</div>' +
720 '</div>';
721
722 // Animate: wrap content, collapse, insert editor, expand
723 $contentEl.css('overflow', 'hidden');
724 var origHeight = $contentEl.outerHeight();
725 $contentEl.html(editorHtml);
726
727 // Add editing class for styling
728 $reply.addClass('frmx-is-editing');
729
730 // Animate expand
731 var $editWrapper = $contentEl.find('.frmx-reply-inline-edit');
732 $editWrapper.css({ opacity: 0 });
733
734 // Initialize TinyMCE visual editor on the textarea
735 self.initTinyMCE(editorId);
736
737 // Fade in the edit wrapper
738 requestAnimationFrame(function () {
739 $editWrapper.css({ opacity: 1, transition: 'opacity 0.25s ease' });
740 $contentEl.css('overflow', '');
741 });
742
743 // Track content modifications
744 self.contentModified = false;
745
746 // Bind save/cancel
747 $contentEl.find('.frmx-edit-save').on('click', function () {
748 self.saveEdit($reply);
749 });
750
751 $contentEl.find('.frmx-edit-cancel').on('click', function () {
752 if (self.contentModified) {
753 self.showDiscardPrompt($reply);
754 return;
755 }
756 self.cancelEdit($reply);
757 });
758
759 // Inline discard prompt actions
760 $contentEl.find('.frmx-edit-discard-keep').on('click', function () {
761 self.hideDiscardPrompt($reply);
762 });
763
764 $contentEl.find('.frmx-edit-discard-confirm').on('click', function () {
765 self.cancelEdit($reply);
766 });
767 },
768
769 /**
770 * Show the inline discard-changes confirmation prompt.
771 *
772 * @param {jQuery} $reply - The .forum-comment element.
773 */
774 showDiscardPrompt: function ($reply) {
775 var $actions = $reply.find('.frmx-inline-edit-actions');
776 $actions.addClass('frmx-show-discard');
777 $actions.find('.frmx-edit-discard-prompt').attr('aria-hidden', 'false');
778 $actions.find('.frmx-edit-discard-keep').focus();
779 },
780
781 /**
782 * Hide the inline discard-changes confirmation prompt.
783 *
784 * @param {jQuery} $reply - The .forum-comment element.
785 */
786 hideDiscardPrompt: function ($reply) {
787 var $actions = $reply.find('.frmx-inline-edit-actions');
788 $actions.removeClass('frmx-show-discard');
789 $actions.find('.frmx-edit-discard-prompt').attr('aria-hidden', 'true');
790 },
791
792 /**
793 * Update the live character counter in the editor footer.
794 *
795 * @param {jQuery} $reply - The .forum-comment element.
796 * @param {string} editorId - The editor textarea ID.
797 */
798 updateCharCount: function ($reply, editorId) {
799 var raw = this.getEditorContent(editorId);
800 var plain = raw.replace(/<[^>]*>/g, '').replace(/&nbsp;/g, ' ').trim();
801 var count = plain.length;
802 var label = count === 1 ? '1 character' : count + ' characters';
803 $reply.find('.frmx-edit-char-count').text(label);
804 },
805
806 /**
807 * Initialize TinyMCE on the reply editor textarea.
808 *
809 * @param {string} editorId - The textarea element ID.
810 */
811 initTinyMCE: function (editorId) {
812 var self = this;
813
814 // Remove any existing editor instance first
815 if (typeof wp !== 'undefined' && wp.editor) {
816 wp.editor.remove(editorId);
817 }
818
819 // Force Visual mode — set the WP user preference before initialization.
820 if (typeof setUserSetting === 'function') {
821 setUserSetting('editor', 'tinymce');
822 }
823
824 // Use wp.editor.initialize for proper TinyMCE setup
825 if (typeof wp !== 'undefined' && wp.editor && wp.editor.initialize) {
826 wp.editor.initialize(editorId, {
827 tinymce: {
828 wpautop: true,
829 toolbar1: 'bold,italic,underline,strikethrough,|,bullist,numlist,|,blockquote,|,link,unlink,|,undo,redo',
830 toolbar2: '',
831 height: 180,
832 statusbar: false,
833 resize: true,
834 setup: function (editor) {
835 // Auto-focus the editor and move caret to the end on init.
836 editor.on('init', function () {
837 // Ensure Visual mode is active after editor initialises.
838 self.forceVisualMode(editorId);
839
840 editor.focus();
841 editor.selection.select(editor.getBody(), true);
842 editor.selection.collapse(false);
843
844 var $reply = $('#' + editorId).closest('.forum-comment');
845 self.updateCharCount($reply, editorId);
846 });
847
848 // Track content modifications + live character count.
849 editor.on('input change keyup', function () {
850 var $reply = $('#' + editorId).closest('.forum-comment');
851
852 if (!self.contentModified) {
853 self.contentModified = true;
854 $reply.find('.frmx-edit-unsaved-hint').text(frmxInlineEdit.i18n.unsaved_changes);
855 }
856
857 self.updateCharCount($reply, editorId);
858 });
859
860 // Ctrl+Enter / Cmd+Enter to save from inside the editor
861 editor.on('keydown', function (e) {
862 if ((e.ctrlKey || e.metaKey) && e.keyCode === 13) {
863 e.preventDefault();
864 var $reply = $('#' + editorId).closest('.forum-comment');
865 self.saveEdit($reply);
866 }
867 if (e.keyCode === 27) {
868 e.preventDefault();
869 var $reply = $('#' + editorId).closest('.forum-comment');
870 if (self.contentModified) {
871 self.showDiscardPrompt($reply);
872 return;
873 }
874 self.cancelEdit($reply);
875 }
876 });
877 }
878 },
879 quicktags: true,
880 mediaButtons: false
881 });
882
883 // Also force Visual mode immediately after initialize call.
884 this.forceVisualMode(editorId);
885 }
886 },
887
888 /**
889 * Force the editor wrapper into Visual (TinyMCE) mode.
890 * Directly manipulates DOM classes and visibility as a
891 * reliable alternative to switchEditors.go().
892 *
893 * @param {string} editorId - The editor textarea ID.
894 */
895 forceVisualMode: function (editorId) {
896 var $wrap = $('#wp-' + editorId + '-wrap');
897 if (!$wrap.length) {
898 $wrap = $('#' + editorId).closest('.wp-editor-wrap');
899 }
900
901 if (!$wrap.length) {
902 return;
903 }
904
905 // Swap classes to Visual mode.
906 $wrap.removeClass('html-active').addClass('tmce-active');
907
908 // Hide the quicktags toolbar.
909 $wrap.find('.quicktags-toolbar').css({
910 display: 'none',
911 height: '0',
912 padding: '0',
913 overflow: 'hidden'
914 });
915
916 // Set the Visual tab as active.
917 $wrap.find('.switch-tmce').attr('aria-pressed', 'true');
918 $wrap.find('.switch-html').attr('aria-pressed', 'false');
919
920 // Show the TinyMCE container, hide the raw textarea.
921 $wrap.find('.mce-tinymce').show();
922 $wrap.find('#' + editorId).hide();
923 },
924
925 /**
926 * Get the content from TinyMCE or fallback textarea for a reply.
927 *
928 * @param {string} editorId - The editor textarea ID.
929 * @return {string} The editor content.
930 */
931 getEditorContent: function (editorId) {
932 // Try TinyMCE first
933 if (typeof tinyMCE !== 'undefined') {
934 var editor = tinyMCE.get(editorId);
935 if (editor && !editor.isHidden()) {
936 return editor.getContent();
937 }
938 }
939
940 // Fallback to textarea value (text mode)
941 return $('#' + editorId).val();
942 },
943
944 /**
945 * Destroy the TinyMCE editor instance for a reply.
946 *
947 * @param {string} editorId - The editor textarea ID.
948 */
949 destroyEditor: function (editorId) {
950 if (typeof wp !== 'undefined' && wp.editor) {
951 wp.editor.remove(editorId);
952 }
953 },
954
955 /**
956 * Save the inline reply edit via AJAX.
957 *
958 * @param {jQuery} $reply - The .forum-comment element.
959 */
960 saveEdit: function ($reply) {
961 var self = this;
962 var replyId = self.getReplyId($reply);
963 var editorId = self.getEditorId(replyId);
964 var newContent = self.getEditorContent(editorId).trim();
965
966 if (!newContent) {
967 self.showNotice($reply, frmxInlineEdit.i18n.content_required, 'error');
968 return;
969 }
970
971 // Disable buttons and show saving state
972 var $saveBtn = $reply.find('.frmx-edit-save');
973 var $cancelBtn = $reply.find('.frmx-edit-cancel');
974 $saveBtn.prop('disabled', true).html(
975 '<svg class="frmx-btn-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10" stroke-dasharray="31.4 31.4" stroke-linecap="round"/></svg>' +
976 frmxInlineEdit.i18n.saving
977 );
978 $cancelBtn.prop('disabled', true);
979
980 $.ajax({
981 url: frmxInlineEdit.ajaxurl,
982 type: 'POST',
983 data: {
984 action: 'forumax_save_reply_edit',
985 nonce: frmxInlineEdit.nonce,
986 reply_id: replyId,
987 content: newContent
988 },
989 success: function (response) {
990 if (response.success) {
991 // Destroy TinyMCE before replacing HTML
992 self.destroyEditor(editorId);
993
994 // Animate back to display content
995 var $contentEl = $reply.find('.comment-content').first();
996 $contentEl.css('opacity', '0');
997
998 setTimeout(function () {
999 $contentEl.html(response.data.content);
1000 $reply.removeClass('frmx-is-editing');
1001 $contentEl.css({ opacity: 1, transition: 'opacity 0.25s ease' });
1002 }, 150);
1003
1004 self.editingReplyId = false;
1005 self.contentModified = false;
1006
1007 self.showNotice($reply, response.data.message, 'success');
1008
1009 // Re-initialize nice-select on admin dropdown if it exists
1010 if (typeof $.fn.frmxNiceSelect === 'function') {
1011 $reply.find('select.bbp-admin-dropdown-menu').each(function () {
1012 var $select = $(this);
1013 $select.nextAll('.nice-select, .frmx-nice-select').remove();
1014 $select.frmxNiceSelect();
1015 $select.next('.frmx-nice-select').removeClass('nice-select');
1016 });
1017 }
1018 if (typeof window.frmxEnhanceAdminDropdowns === 'function') {
1019 window.frmxEnhanceAdminDropdowns();
1020 }
1021
1022 // Re-initialize nested reply buttons if they exist
1023 self.reinitNestedReplyButtons($reply);
1024 } else {
1025 self.showNotice($reply, response.data.message, 'error');
1026 $saveBtn.prop('disabled', false).text(frmxInlineEdit.i18n.save);
1027 $cancelBtn.prop('disabled', false);
1028 }
1029 },
1030 error: function () {
1031 self.showNotice($reply, frmxInlineEdit.i18n.error, 'error');
1032 $saveBtn.prop('disabled', false).text(frmxInlineEdit.i18n.save);
1033 $cancelBtn.prop('disabled', false);
1034 }
1035 });
1036 },
1037
1038 /**
1039 * Cancel inline editing and restore original reply content.
1040 *
1041 * @param {jQuery} $reply - The .forum-comment element.
1042 */
1043 cancelEdit: function ($reply) {
1044 var self = this;
1045 var editorId = self.getEditorId(self.getReplyId($reply));
1046
1047 // Destroy TinyMCE before replacing HTML
1048 self.destroyEditor(editorId);
1049
1050 var $contentEl = $reply.find('.comment-content').first();
1051
1052 // Animate out
1053 $contentEl.css('opacity', '0');
1054
1055 setTimeout(function () {
1056 $contentEl.html(self.originalContentHtml);
1057 $reply.removeClass('frmx-is-editing');
1058 $contentEl.css({ opacity: 1, transition: 'opacity 0.25s ease' });
1059 }, 150);
1060
1061 self.editingReplyId = false;
1062 self.contentModified = false;
1063 },
1064
1065 /**
1066 * Show a temporary notice message for a reply.
1067 *
1068 * @param {jQuery} $reply - The .forum-comment element.
1069 * @param {string} message - The message text.
1070 * @param {string} type - 'success' or 'error'.
1071 */
1072 showNotice: function ($reply, message, type) {
1073 // Remove any existing notices
1074 $reply.find('.frmx-inline-edit-notice').remove();
1075
1076 var noticeClass = 'frmx-inline-edit-notice frmx-notice-' + type;
1077 var $notice = $('<div class="' + noticeClass + '">' + message + '</div>');
1078
1079 $reply.find('.comment-content').first().before($notice);
1080
1081 // Auto-dismiss after 4 seconds
1082 setTimeout(function () {
1083 $notice.fadeOut(300, function () {
1084 $(this).remove();
1085 });
1086 }, 4000);
1087 },
1088
1089 /**
1090 * Re-initialize nested reply buttons after content update.
1091 *
1092 * This ensures the "Reply" button works correctly after inline editing.
1093 *
1094 * @param {jQuery} $reply - The .forum-comment element.
1095 */
1096 reinitNestedReplyButtons: function ($reply) {
1097 // Only proceed if bbPress reply system is loaded and nested replies is enabled.
1098 if (typeof addReply === 'undefined' || ! addReply.moveForm) {
1099 return;
1100 }
1101
1102 // Find all reply-to links in the reply.
1103 $reply.find('a.bbp-reply-to-link').each(function () {
1104 var $link = $(this);
1105
1106 // Extract the reply ID from the link's onclick or data attributes.
1107 var replyId = $link.data('reply-id');
1108
1109 // If no data attribute, try to extract from onclick attribute.
1110 if (!replyId) {
1111 var onclick = $link.attr('onclick');
1112 if (onclick) {
1113 var match = onclick.match(/addReply\.moveForm\([^,]+,\s*['"](\d+)['"]/);
1114 if (match) {
1115 replyId = match[1];
1116 }
1117 }
1118 }
1119
1120 // If we still don't have a reply ID, try to get it from the parent post ID.
1121 if (!replyId) {
1122 var $post = $link.closest('[id^="post-"]');
1123 if ($post.length) {
1124 replyId = $post.attr('id').replace('post-', '');
1125 }
1126 }
1127
1128 if (replyId) {
1129 // Remove any existing onclick to avoid duplicates.
1130 $link.removeAttr('onclick');
1131
1132 // Attach the click handler.
1133 $link.on('click', function (e) {
1134 e.preventDefault();
1135
1136 // Get topic ID from localized script if available.
1137 var topicId = (typeof frmxInlineEdit !== 'undefined' && frmxInlineEdit.topic_id)
1138 ? frmxInlineEdit.topic_id
1139 : 0;
1140
1141 // Use bbPress's addReply.moveForm to move the reply form.
1142 // Parameters: anchor, replyId, respondId, postId
1143 addReply.moveForm(
1144 'bbp-reply-to-link-' + replyId,
1145 replyId,
1146 'new-post',
1147 topicId
1148 );
1149
1150 return false;
1151 });
1152 }
1153 });
1154 }
1155 };
1156
1157 })(jQuery);
1158