From 4076502db06a7232e7384f856be0ce64c0ac917c Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Mon, 28 Jul 2025 13:33:59 +0100 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20Reemplazar=20Quill=20con=20editor?= =?UTF-8?q?=20cl=C3=A1sico=20de=20WordPress=20en=20tareas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (deepseek/deepseek-reasoner) --- public/assets/js/task-card.js | 70 +++++++---------------------------- public/layouts/task-card.php | 21 +++++++++-- 2 files changed, 31 insertions(+), 60 deletions(-) diff --git a/public/assets/js/task-card.js b/public/assets/js/task-card.js index de3b0d5d..8787101e 100644 --- a/public/assets/js/task-card.js +++ b/public/assets/js/task-card.js @@ -14,8 +14,6 @@ // Variable global para indicar si hay cambios sin guardar window.deckerHasUnsavedChanges = false; - let quill = null; - let assigneesSelect = null; let labelsSelect = null; @@ -189,51 +187,6 @@ console.log('Task ID not found in data-task-id'); } - if (context.querySelector('#editor')) { - if (quill === null) { - // Registrar el módulo HTML Edit Button - Quill.register('modules/htmlEditButton', htmlEditButton); - - } - - quill = new Quill(context.querySelector('#editor'), { - theme: 'snow', - readOnly: disabled, - modules: { - toolbar: { - container: [ - ['bold', 'italic', 'underline', 'strike'], - ['link', 'blockquote', 'code-block'], - [{ 'list': 'ordered' }, { 'list': 'bullet' }, { 'list': 'check' }], - [{ 'indent': '-1' }, { 'indent': '+1' }], - ['clean'], - ['fullscreen'], - ], - handlers: { - 'fullscreen': function() { - var editorContainer = context.querySelector('#editor-container'); - if (!document.fullscreenElement) { - editorContainer.requestFullscreen().catch(err => { - alert('Error attempting to enable full-screen mode: ' + err.message); - }); - } else { - document.exitFullscreen(); - } - } - } - }, - htmlEditButton: { - syntax: false, - buttonTitle: strings.show_html_source, - msg: strings.edit_html_content, - okText: strings.ok, - cancelText: strings.cancel, - closeOnClickOverlay: false, - }, - } - }); - - } // Inicializar Choices.js para los selectores de asignados y etiquetas if (context.querySelector('#task-assignees')) { @@ -356,14 +309,6 @@ togglePriorityLabel(taskMaxPriorityCheck); } - // Para el Editor Quill - if (quill) { - quill.on('text-change', function() { - saveButton.disabled = false; - window.deckerHasUnsavedChanges = true; - }); - } - // Para los selectores de Choices.js if (assigneesSelect) { assigneesSelect.passedElement.element.addEventListener('change', enableSaveButton); @@ -585,7 +530,7 @@ hidden: form.querySelector('#task-hidden').checked ? 1 : 0, assignees: selectedAssigneesValues, labels: selectedLabelsValues, - description: quill.root.innerHTML, + description: form.querySelector('#task-description').value, max_priority: form.querySelector('#task-max-priority').checked ? 1 : 0, mark_for_today: form.querySelector('#task-today').checked ? 1 : 0, }; @@ -661,6 +606,19 @@ initializeTaskPage(document); initializeSendComments(document); } + + // Inicializar TinyMCE si existe en la página + if (typeof tinymce !== 'undefined' && tinymce.editors.length > 0) { + tinymce.editors.forEach(function(editor) { + editor.on('change', function() { + const saveButton = document.querySelector('#save-task'); + if (saveButton) { + saveButton.disabled = false; + window.deckerHasUnsavedChanges = true; + } + }); + }); + } }); })(); diff --git a/public/layouts/task-card.php b/public/layouts/task-card.php index 748b48fd..81302af7 100644 --- a/public/layouts/task-card.php +++ b/public/layouts/task-card.php @@ -342,11 +342,24 @@ function render_comments( array $task_comments, int $parent_id, int $current_use
- +
-
-
description : $initial_description, Decker::get_allowed_tags() ); ?>
-
+ description : $initial_description; + $settings = array( + 'textarea_name' => 'description', + 'editor_height' => 200, + 'teeny' => true, + 'media_buttons' => false, + 'quicktags' => false, + 'tinymce' => array( + 'toolbar1' => 'bold,italic,underline,strikethrough,link,blockquote,code', + 'toolbar2' => '', + ), + ); + wp_editor( wp_kses_post( $editor_content ), $editor_id, $settings ); + ?>
From 8d381bf9cc37a8a4d7af0372be650cbba3e8210d Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Mon, 28 Jul 2025 13:40:17 +0100 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20Mejora=20carga=20del=20editor=20c?= =?UTF-8?q?l=C3=A1sico=20en=20todas=20las=20p=C3=A1ginas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (deepseek/deepseek-reasoner) --- public/class-decker-public.php | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/public/class-decker-public.php b/public/class-decker-public.php index 0d65e298..504edb4d 100644 --- a/public/class-decker-public.php +++ b/public/class-decker-public.php @@ -181,10 +181,6 @@ public function enqueue_scripts() { 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/styles/default.min.css', */ - // Quill. - 'https://cdnjs.cloudflare.com/ajax/libs/quill/2.0.2/quill.min.js', - 'https://cdnjs.cloudflare.com/ajax/libs/quill/2.0.2/quill.snow.min.css', - 'https://cdn.jsdelivr.net/npm/quill-html-edit-button@3.0.0/dist/quill.htmlEditButton.min.js', // Choices.js. 'https://cdnjs.cloudflare.com/ajax/libs/choices.js/11.1.0/choices.min.js', @@ -238,18 +234,12 @@ public function enqueue_scripts() { } if ( 'knowledge-base' == $decker_page ) { - wp_enqueue_media(); // Obligatorio para subida de medios. - // wp_enqueue_script('editor'); - // wp_enqueue_script('thickbox'); - // wp_enqueue_style('editor-buttons'); - // wp_enqueue_style('thickbox'); - // wp_enqueue_script('wp-tinymce'); // Script principal de TinyMCE. - - wp_enqueue_editor(); - } + // Cargar editor clásico en todas las páginas de Decker + wp_enqueue_editor(); + if ( 'tasks' == $decker_page || 'knowledge-base' == $decker_page ) { // Only load datatables.net on tasks page. // Datatables JS CDN. $resources[] = 'https://cdn.datatables.net/1.13.11/js/jquery.dataTables.min.js'; @@ -268,6 +258,10 @@ public function enqueue_scripts() { $resources[] = plugin_dir_url( __FILE__ ) . '../public/assets/js/task-card.js'; + // Cargar scripts de editor para todas las páginas + wp_enqueue_script('editor'); + wp_enqueue_script('wp-tinymce'); + $resources[] = plugin_dir_url( __FILE__ ) . '../public/assets/js/decker-heartbeat.js'; $users = get_users( From 2c9d8822f97ddbbe03c71ddd651148af54625632 Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Mon, 28 Jul 2025 14:28:27 +0100 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20Implementa=20editor=20cl=C3=A1sic?= =?UTF-8?q?o=20de=20WordPress=20en=20tarjetas=20de=20tareas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (deepseek/deepseek-reasoner) --- public/assets/js/task-card.js | 95 +++++++++++++++++++++++++--------- public/class-decker-public.php | 1 + public/layouts/task-card.php | 17 +----- 3 files changed, 72 insertions(+), 41 deletions(-) diff --git a/public/assets/js/task-card.js b/public/assets/js/task-card.js index 8787101e..84511d17 100644 --- a/public/assets/js/task-card.js +++ b/public/assets/js/task-card.js @@ -16,10 +16,49 @@ let assigneesSelect = null; let labelsSelect = null; + let taskEditor = null; + let taskEditorInitPromise = null; // Start of comment part var replyToCommentId = null; + function initializeTaskEditor() { + if (taskEditor && taskEditor.initialized) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + const config = { + tinymce: { + wpautop: true, + container: 'description-tab', + toolbar1: 'formatselect bold italic bullist numlist blockquote alignleft aligncenter alignright wp_adv', + toolbar2: 'strikethrough hr forecolor pastetext removeformat charmap outdent indent undo redo wp_help', + menubar: false, + setup: function(ed) { + taskEditor = ed; + ed.on('change', function() { + // Mark the form as having unsaved changes + const saveButton = document.querySelector('#save-task'); + if (saveButton) { + saveButton.disabled = false; + window.deckerHasUnsavedChanges = true; + } + }); + ed.on('init', function() { + taskEditor.initialized = true; + resolve(); + }); + } + }, + quicktags: true, + mediaButtons: true + }; + + wp.editor.initialize('task-description', config); + }); + } + // Function to initialize comment submission within the given context function initializeSendComments(context) { // Check if already initialized in this context @@ -202,14 +241,6 @@ } if (context.querySelector('#task-labels')) { - // labelsSelect = new Choices(context.querySelector('#task-labels'), { - // removeItemButton: true, - // allowHTML: true, - // searchEnabled: true, - // shouldSort: true, - // }); - - labelsSelect = new Choices(context.querySelector('#task-labels'), { removeItemButton: true, allowHTML: true, @@ -218,8 +249,6 @@ callbackOnCreateTemplates: function (strToEl, escapeForTemplate, getClassNames) { const defaultTemplates = Choices.defaults.templates; - - return { ...defaultTemplates, item: (classNames, data) => { @@ -248,11 +277,8 @@ return el; } } - } }); - - } var uploadFileButton = context.querySelector('#upload-file'); @@ -324,6 +350,9 @@ }); + // Pre-inicializar el editor de tareas + initializeTaskEditor(); + } // Función para manejar cambios en el checkbox "task-today" @@ -573,6 +602,13 @@ alert(strings.error_saving_task); }; + // Obtener el contenido del editor si está inicializado + if (taskEditor) { + formData.description = taskEditor.getContent(); + } else { + formData.description = document.querySelector('#task-description').value; + } + const encodedData = Object.keys(formData) .map(key => encodeURIComponent(key) + '=' + encodeURIComponent(formData[key])) .join('&'); @@ -580,6 +616,15 @@ xhr.send(encodedData); } + // Función para destruir el editor cuando se cierra el modal + function destroyTaskEditor() { + if (taskEditor && taskEditor.initialized) { + wp.editor.remove('task-description'); + taskEditor.initialized = false; + taskEditor = null; + } + } + // Obtener el task_id desde el input hidden function getTaskId() { const taskIdInput = document.querySelector('input[name="task_id"]'); @@ -597,6 +642,7 @@ window.sendFormByAjax = sendFormByAjax; window.deleteComment = deleteComment; window.togglePriorityLabel = togglePriorityLabel; + window.destroyTaskEditor = destroyTaskEditor; // Inicializar automáticamente si el contenido está cargado directamente en la página document.addEventListener('DOMContentLoaded', function() { @@ -606,19 +652,18 @@ initializeTaskPage(document); initializeSendComments(document); } + }); - // Inicializar TinyMCE si existe en la página - if (typeof tinymce !== 'undefined' && tinymce.editors.length > 0) { - tinymce.editors.forEach(function(editor) { - editor.on('change', function() { - const saveButton = document.querySelector('#save-task'); - if (saveButton) { - saveButton.disabled = false; - window.deckerHasUnsavedChanges = true; - } - }); - }); - } + // Pre-inicializar el editor cuando se hace clic en el botón de edición + document.querySelectorAll('[data-bs-target="#task-modal"]').forEach((button) => { + button.addEventListener('click', function() { + initializeTaskEditor(); + }); + }); + + // Destruir el editor cuando se cierra el modal + document.getElementById('task-modal')?.addEventListener('hidden.bs.modal', function() { + destroyTaskEditor(); }); })(); diff --git a/public/class-decker-public.php b/public/class-decker-public.php index 504edb4d..78ae5fac 100644 --- a/public/class-decker-public.php +++ b/public/class-decker-public.php @@ -261,6 +261,7 @@ public function enqueue_scripts() { // Cargar scripts de editor para todas las páginas wp_enqueue_script('editor'); wp_enqueue_script('wp-tinymce'); + wp_enqueue_script('heartbeat'); $resources[] = plugin_dir_url( __FILE__ ) . '../public/assets/js/decker-heartbeat.js'; diff --git a/public/layouts/task-card.php b/public/layouts/task-card.php index 81302af7..28cce16a 100644 --- a/public/layouts/task-card.php +++ b/public/layouts/task-card.php @@ -344,22 +344,7 @@ function render_comments( array $task_comments, int $parent_id, int $current_use
- description : $initial_description; - $settings = array( - 'textarea_name' => 'description', - 'editor_height' => 200, - 'teeny' => true, - 'media_buttons' => false, - 'quicktags' => false, - 'tinymce' => array( - 'toolbar1' => 'bold,italic,underline,strikethrough,link,blockquote,code', - 'toolbar2' => '', - ), - ); - wp_editor( wp_kses_post( $editor_content ), $editor_id, $settings ); - ?> +
From 34aa84ee169c67e2bf213696dc1d9173cddad4e0 Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Mon, 28 Jul 2025 16:08:11 +0100 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20Corrige=20visualizaci=C3=B3n=20de?= =?UTF-8?q?=20contenido=20y=20carga=20de=20im=C3=A1genes=20en=20tareas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (deepseek/deepseek-reasoner) --- public/class-decker-public.php | 2 +- public/layouts/task-card.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/class-decker-public.php b/public/class-decker-public.php index 78ae5fac..bed39d84 100644 --- a/public/class-decker-public.php +++ b/public/class-decker-public.php @@ -233,7 +233,7 @@ public function enqueue_scripts() { } - if ( 'knowledge-base' == $decker_page ) { + if ( 'knowledge-base' == $decker_page || 'task' == $decker_page ) { wp_enqueue_media(); // Obligatorio para subida de medios. } diff --git a/public/layouts/task-card.php b/public/layouts/task-card.php index 28cce16a..e13a0780 100644 --- a/public/layouts/task-card.php +++ b/public/layouts/task-card.php @@ -344,7 +344,7 @@ function render_comments( array $task_comments, int $parent_id, int $current_use
- +
From 4bf8408f365032ebbdf09e42db4d939751e41cf2 Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Mon, 28 Jul 2025 16:28:59 +0100 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20Corrige=20reinicializaci=C3=B3n=20?= =?UTF-8?q?del=20editor=20en=20modal=20de=20tareas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (deepseek/deepseek-reasoner) --- public/assets/js/task-card.js | 12 +++++++++++- public/assets/js/task-modal.js | 5 +++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/public/assets/js/task-card.js b/public/assets/js/task-card.js index 84511d17..30cfb6ae 100644 --- a/public/assets/js/task-card.js +++ b/public/assets/js/task-card.js @@ -23,8 +23,11 @@ var replyToCommentId = null; function initializeTaskEditor() { + // Destruir el editor existente antes de inicializar uno nuevo if (taskEditor && taskEditor.initialized) { - return Promise.resolve(); + wp.editor.remove('task-description'); + taskEditor.initialized = false; + taskEditor = null; } return new Promise((resolve) => { @@ -47,6 +50,13 @@ }); ed.on('init', function() { taskEditor.initialized = true; + + // Cargar el contenido en el editor después de inicializar + const content = document.querySelector('#task-description').value; + if (content) { + ed.setContent(content); + } + resolve(); }); } diff --git a/public/assets/js/task-modal.js b/public/assets/js/task-modal.js index 3af89526..21a3e0de 100644 --- a/public/assets/js/task-modal.js +++ b/public/assets/js/task-modal.js @@ -86,5 +86,10 @@ document.addEventListener('DOMContentLoaded', function () { if (window.quill) { window.quill = null; // Asumiendo que quill no necesita destrucción explícita } + + // Destruir el editor de WordPress si existe + if (typeof window.destroyTaskEditor === 'function') { + window.destroyTaskEditor(); + } }); }); From cae45ce46ea0258811e6068d68e0a835ce51dfe2 Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Mon, 28 Jul 2025 17:12:28 +0100 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20Agrega=20opci=C3=B3n=20de=20enlac?= =?UTF-8?q?e=20y=20elementos=20v=C3=A1lidos=20en=20editor=20TinyMCE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/assets/js/task-card.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/public/assets/js/task-card.js b/public/assets/js/task-card.js index 30cfb6ae..dbc37197 100644 --- a/public/assets/js/task-card.js +++ b/public/assets/js/task-card.js @@ -35,9 +35,12 @@ tinymce: { wpautop: true, container: 'description-tab', - toolbar1: 'formatselect bold italic bullist numlist blockquote alignleft aligncenter alignright wp_adv', + toolbar1: 'formatselect bold italic link bullist numlist blockquote alignleft aligncenter alignright wp_adv', toolbar2: 'strikethrough hr forecolor pastetext removeformat charmap outdent indent undo redo wp_help', menubar: false, + valid_elements: '*[*]', // permite todos los elementos con cualquier atributo + extended_valid_elements: 'input[type|name|value|checked|disabled|class|id|style],a[href|target|rel|class|id|style]', + setup: function(ed) { taskEditor = ed; ed.on('change', function() { From 35be3ff71c4a4ba77304c858f04cd07241b2e3b6 Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Mon, 28 Jul 2025 17:32:49 +0100 Subject: [PATCH 07/10] Replace quill with wp classic editor --- public/class-decker-public.php | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/public/class-decker-public.php b/public/class-decker-public.php index bed39d84..4768fc53 100644 --- a/public/class-decker-public.php +++ b/public/class-decker-public.php @@ -181,7 +181,6 @@ public function enqueue_scripts() { 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/styles/default.min.css', */ - // Choices.js. 'https://cdnjs.cloudflare.com/ajax/libs/choices.js/11.1.0/choices.min.js', 'https://cdnjs.cloudflare.com/ajax/libs/choices.js/11.1.0/choices.min.css', @@ -233,11 +232,8 @@ public function enqueue_scripts() { } - if ( 'knowledge-base' == $decker_page || 'task' == $decker_page ) { - wp_enqueue_media(); // Obligatorio para subida de medios. - } - - // Cargar editor clásico en todas las páginas de Decker + wp_enqueue_media(); // Obligatorio para subida de medios. + // Cargar editor clásico en todas las páginas de Decker. wp_enqueue_editor(); if ( 'tasks' == $decker_page || 'knowledge-base' == $decker_page ) { // Only load datatables.net on tasks page. @@ -258,10 +254,10 @@ public function enqueue_scripts() { $resources[] = plugin_dir_url( __FILE__ ) . '../public/assets/js/task-card.js'; - // Cargar scripts de editor para todas las páginas - wp_enqueue_script('editor'); - wp_enqueue_script('wp-tinymce'); - wp_enqueue_script('heartbeat'); + // Cargar scripts de editor para todas las páginas. + wp_enqueue_script( 'editor' ); + wp_enqueue_script( 'wp-tinymce' ); + wp_enqueue_script( 'heartbeat' ); $resources[] = plugin_dir_url( __FILE__ ) . '../public/assets/js/decker-heartbeat.js'; From b3e3757088336e6b8fbac84cf66e677d232a3bef Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Mon, 28 Jul 2025 17:58:32 +0100 Subject: [PATCH 08/10] Fixing buttons (WIP) --- public/assets/js/task-card.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/assets/js/task-card.js b/public/assets/js/task-card.js index dbc37197..86624d2b 100644 --- a/public/assets/js/task-card.js +++ b/public/assets/js/task-card.js @@ -35,12 +35,12 @@ tinymce: { wpautop: true, container: 'description-tab', - toolbar1: 'formatselect bold italic link bullist numlist blockquote alignleft aligncenter alignright wp_adv', + toolbar1: 'formatselect bold italic link bullist numlist blockquote alignleft aligncenter alignright wp_adv fullscreen fullscreen emoji', toolbar2: 'strikethrough hr forecolor pastetext removeformat charmap outdent indent undo redo wp_help', menubar: false, valid_elements: '*[*]', // permite todos los elementos con cualquier atributo extended_valid_elements: 'input[type|name|value|checked|disabled|class|id|style],a[href|target|rel|class|id|style]', - + // plugins: 'emoticons', setup: function(ed) { taskEditor = ed; ed.on('change', function() { From 60b0854dcaa7f87b774d5134362e7562a36c47e6 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 11:18:23 +0000 Subject: [PATCH 09/10] Resolve task editor merge conflicts by restoring configurable task editor flow (#232) * Initial plan * Resolve task editor merge conflicts Co-authored-by: erseco <1876752+erseco@users.noreply.github.com> * Translate new UI strings Co-authored-by: erseco <1876752+erseco@users.noreply.github.com> * Make task editor configurable Co-authored-by: erseco <1876752+erseco@users.noreply.github.com> * Polish editor configurability flow Co-authored-by: erseco <1876752+erseco@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: erseco <1876752+erseco@users.noreply.github.com> --- admin/class-decker-admin-settings.php | 32 + languages/decker-es_ES.mo | Bin 28325 -> 29724 bytes languages/decker-es_ES.po | 63 ++ public/assets/js/task-card.js | 828 ++++++++++++++++++++++---- public/assets/js/task-modal.js | 30 +- public/class-decker-public.php | 161 ++++- public/layouts/task-card.php | 54 +- 7 files changed, 989 insertions(+), 179 deletions(-) diff --git a/admin/class-decker-admin-settings.php b/admin/class-decker-admin-settings.php index f09c014a..97c51c65 100644 --- a/admin/class-decker-admin-settings.php +++ b/admin/class-decker-admin-settings.php @@ -247,6 +247,7 @@ public function settings_init() { 'alert_color' => __( 'Alert Color', 'decker' ), // Alert color radio buttons. 'alert_message' => __( 'Alert Message', 'decker' ), // Alert message field. 'minimum_user_profile' => __( 'Minimum User Profile', 'decker' ), // User profile dropdown. + 'task_editor_type' => __( 'Task Editor Type', 'decker' ), 'shared_key' => __( 'Shared Key', 'decker' ), 'allow_email_notifications' => __( 'Allow Email Notifications', 'decker' ), 'clear_all_data_button' => __( 'Clear All Data', 'decker' ), @@ -294,6 +295,29 @@ public function ignored_users_render() { echo '

' . esc_html__( 'Enter comma-separated user IDs to ignore from Decker functionality.', 'decker' ) . '

'; } + /** + * Render Task Editor Type Field. + * + * Outputs the HTML for the task_editor_type field. + */ + public function task_editor_type_render() { + $options = get_option( 'decker_settings', array() ); + $editor_type = isset( $options['task_editor_type'] ) ? $options['task_editor_type'] : 'classic'; + $editors = array( + 'classic' => __( 'Classic Editor', 'decker' ), + 'quill' => __( 'Quill Editor', 'decker' ), + ); + + foreach ( $editors as $value => $label ) { + echo ''; + } + + echo '

' . esc_html__( 'Choose which editor to use for task descriptions. Collaborative editing always uses Quill.', 'decker' ) . '

'; + } + /** * Render Clear All Data Button. * @@ -377,6 +401,14 @@ public function settings_validate( $input ) { $input['minimum_user_profile'] = isset( $input['minimum_user_profile'] ) ? $input['minimum_user_profile'] : 'editor'; } + // Validate task editor type. + $valid_editors = array( 'classic', 'quill' ); + if ( isset( $input['task_editor_type'] ) && ! in_array( $input['task_editor_type'], $valid_editors, true ) ) { + $input['task_editor_type'] = 'classic'; + } else { + $input['task_editor_type'] = isset( $input['task_editor_type'] ) ? $input['task_editor_type'] : 'classic'; + } + // Validate alert message. $input['alert_message'] = isset( $input['alert_message'] ) ? wp_kses_post( $input['alert_message'] ) : ''; diff --git a/languages/decker-es_ES.mo b/languages/decker-es_ES.mo index be8fa5c04b235b5b77ffa8263846db190ee30eaf..e0957022fc4382dcbdbfa57b2c71fabd07d7dfb6 100644 GIT binary patch delta 8636 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 W5b|i}JwERWub(wq;r7>bHvSLM(7Xfy delta 7506 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 { + el.disabled = true; + }); + + // Disable Choices.js instances + if (assigneesSelect) { + assigneesSelect.disable(); + } + if (labelsSelect) { + labelsSelect.disable(); + } + + // Disable Quill editor + if (quill) { + quill.disable(); + } + + // Show archived overlay message + let overlay = context.querySelector('.decker-archived-overlay'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.className = 'decker-archived-overlay'; + overlay.innerHTML = ` +
+ + ${message || 'This task has been archived'} +
+ `; + const modalBody = context.querySelector('#task-modal-body') || context.querySelector('#task-form'); + if (modalBody) { + modalBody.style.position = 'relative'; + modalBody.appendChild(overlay); + } + } + } + + /** + * Simple debounce function + */ + function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } + + /** + * Mark the task form as having unsaved changes. + */ + function markTaskAsChanged(context) { + const saveButton = context.querySelector('#save-task') || document.querySelector('#save-task'); + const saveDropdown = context.querySelector('#save-task-dropdown') || document.querySelector('#save-task-dropdown'); + + if (saveButton) { + saveButton.disabled = false; + } + + if (saveDropdown) { + saveDropdown.disabled = false; + } + + window.deckerHasUnsavedChanges = true; + } + + /** + * Initialize the WordPress classic editor when Quill is not in use. + */ + function initializeTaskEditor(context) { + const textarea = context.querySelector('#task-description'); + + if (!textarea || typeof wp === 'undefined' || !wp.editor) { + return Promise.resolve(); + } - function initializeTaskEditor() { - // Destruir el editor existente antes de inicializar uno nuevo if (taskEditor && taskEditor.initialized) { - wp.editor.remove('task-description'); - taskEditor.initialized = false; - taskEditor = null; + return Promise.resolve(); } return new Promise((resolve) => { + const debouncedMarkTaskAsChanged = debounce(() => markTaskAsChanged(context), 150); const config = { tinymce: { wpautop: true, container: 'description-tab', - toolbar1: 'formatselect bold italic link bullist numlist blockquote alignleft aligncenter alignright wp_adv fullscreen fullscreen emoji', + toolbar1: 'formatselect bold italic link bullist numlist blockquote alignleft aligncenter alignright wp_adv fullscreen', toolbar2: 'strikethrough hr forecolor pastetext removeformat charmap outdent indent undo redo wp_help', menubar: false, - valid_elements: '*[*]', // permite todos los elementos con cualquier atributo - extended_valid_elements: 'input[type|name|value|checked|disabled|class|id|style],a[href|target|rel|class|id|style]', - // plugins: 'emoticons', - setup: function(ed) { - taskEditor = ed; - ed.on('change', function() { - // Mark the form as having unsaved changes - const saveButton = document.querySelector('#save-task'); - if (saveButton) { - saveButton.disabled = false; - window.deckerHasUnsavedChanges = true; - } + setup: function(editor) { + taskEditor = editor; + editor.on('change keyup SetContent', function() { + debouncedMarkTaskAsChanged(); }); - ed.on('init', function() { + editor.on('init', function() { taskEditor.initialized = true; - - // Cargar el contenido en el editor después de inicializar - const content = document.querySelector('#task-description').value; - if (content) { - ed.setContent(content); - } - resolve(); }); } }, quicktags: true, - mediaButtons: true + mediaButtons: false }; wp.editor.initialize('task-description', config); }); } + /** + * Get the current task description from the active editor. + */ + function getTaskDescription(context) { + if (context.querySelector('#editor') && quill) { + return quill.root.innerHTML; + } + + if (taskEditor && typeof taskEditor.getContent === 'function') { + return taskEditor.getContent(); + } + + if (typeof tinyMCE !== 'undefined') { + const activeEditor = tinyMCE.get('task-description'); + if (activeEditor) { + return activeEditor.getContent(); + } + } + + const textarea = context.querySelector('#task-description'); + return textarea ? textarea.value : ''; + } + + /** + * Destroy the active task editor instance. + */ + function destroyTaskEditor() { + if (taskEditor && taskEditor.initialized && typeof wp !== 'undefined' && wp.editor) { + wp.editor.remove('task-description'); + } + + taskEditor = null; + quill = null; + window.quill = null; + } + + /** + * Animate a remote change on a field + */ + function animateRemoteChange(element) { + const wrapper = element.closest('.form-floating') || element.closest('.form-check') || element.parentElement; + wrapper.classList.add('decker-remote-change'); + setTimeout(() => { + wrapper.classList.remove('decker-remote-change'); + }, 400); + } + + /** + * Initialize form fields collaboration binding + * @param {Object} session - The collaboration session object + * @param {HTMLElement} context - The container element + */ + function initFormFieldsCollaboration(session, context) { + if (!session || !session.formFields) { + console.warn('Decker: Cannot init form fields collaboration - no session or formFields'); + return null; + } + + const formFields = session.formFields; + const awareness = session.awareness; + let isRemoteUpdate = false; + + // Get local Choices.js instances + const choicesMappings = [ + { instance: assigneesSelect, key: 'assignees', id: 'task-assignees' }, + { instance: labelsSelect, key: 'labels', id: 'task-labels' }, + ]; + + /** + * Initialize form fields from Yjs or populate Yjs from local values. + * Only the first user (no other peers) populates Yjs. + * Subsequent users only receive and apply remote values. + */ + function initializeFormFieldValues() { + // Wait for WebRTC connection and sync + setTimeout(() => { + isRemoteUpdate = true; + + // Check if there are other peers connected (not just myself) + const connectedPeers = awareness.getStates().size; + const hasRemoteData = formFields.size > 0; + const isFirstUser = connectedPeers <= 1 && !hasRemoteData; + + console.log('Decker: Connected peers:', connectedPeers, 'Has remote data:', hasRemoteData, 'Is first user:', isFirstUser); + + if (isFirstUser) { + // First user - populate Yjs with local values + console.log('Decker: First user, populating Yjs with local values'); + + FIELD_MAPPINGS.forEach(({ id, key, type }) => { + const el = context.querySelector(`#${id}`); + if (!el) return; + + const localValue = type === 'checkbox' ? el.checked : el.value; + if (localValue !== undefined && localValue !== '') { + formFields.set(key, localValue); + } + }); + + choicesMappings.forEach(({ instance, key }) => { + if (!instance) return; + + const localValues = instance.getValue(true); + if (localValues && localValues.length > 0) { + formFields.set(key, localValues); + } + }); + } else { + // Another user has data - only apply remote values, don't overwrite + console.log('Decker: Joining existing session, applying remote values only'); + + // Apply all remote values to local UI + FIELD_MAPPINGS.forEach(({ id, key, type }) => { + const el = context.querySelector(`#${id}`); + if (!el) return; + + const remoteValue = formFields.get(key); + if (remoteValue !== undefined) { + if (type === 'checkbox') { + el.checked = remoteValue; + if (id === 'task-max-priority') { + togglePriorityLabel(el); + } + } else { + el.value = remoteValue; + } + } + }); + + // Apply Choices.js values + choicesMappings.forEach(({ instance, key }) => { + if (!instance) return; + + const remoteValue = formFields.get(key); + if (remoteValue !== undefined && Array.isArray(remoteValue)) { + instance.removeActiveItems(); + remoteValue.forEach(v => instance.setChoiceByValue(v.toString())); + } + }); + + // Check if task is archived + if (formFields.get('archived') === true) { + disableAllFormFields(context, strings.task_is_archived || 'This task is archived'); + } + } + + isRemoteUpdate = false; + }, 1000); // Increased timeout to allow WebRTC sync + } + + /** + * Bind local field changes to Yjs + */ + function bindLocalChanges() { + // Regular fields + FIELD_MAPPINGS.forEach(({ id, key, type }) => { + const el = context.querySelector(`#${id}`); + if (!el) return; + + const sendChange = () => { + if (isRemoteUpdate) return; + const value = type === 'checkbox' ? el.checked : el.value; + formFields.set(key, value); + }; + + el.addEventListener('change', sendChange); + + // For text inputs, use debounced input event + if (type === 'text') { + el.addEventListener('input', debounce(sendChange, 150)); + } + + // Focus tracking for awareness + el.addEventListener('focus', () => session.setActiveField(id)); + el.addEventListener('blur', () => session.clearActiveField()); + }); + + // Choices.js fields + choicesMappings.forEach(({ instance, key, id }) => { + if (!instance) return; + + instance.passedElement.element.addEventListener('change', () => { + if (isRemoteUpdate) return; + const values = instance.getValue(true); + formFields.set(key, values); + }); + + // Focus tracking for Choices.js dropdowns + const choicesContainer = context.querySelector(`#${id}`)?.closest('.choices'); + if (choicesContainer) { + choicesContainer.addEventListener('focusin', () => session.setActiveField(id)); + choicesContainer.addEventListener('focusout', () => session.clearActiveField()); + } + }); + } + + /** + * Observe Yjs changes and update local fields + */ + function observeRemoteChanges() { + formFields.observe((event) => { + isRemoteUpdate = true; + + event.keysChanged.forEach(key => { + const value = formFields.get(key); + + // Check for archived status change + if (key === 'archived' && value === true) { + disableAllFormFields(context, strings.task_archived_by_another_user || 'This task has been archived by another user'); + return; + } + + // Regular fields + const mapping = FIELD_MAPPINGS.find(m => m.key === key); + if (mapping) { + const el = context.querySelector(`#${mapping.id}`); + if (el) { + if (mapping.type === 'checkbox') { + el.checked = value; + // Trigger visual update for priority label + if (mapping.id === 'task-max-priority') { + togglePriorityLabel(el); + } + } else { + el.value = value; + } + animateRemoteChange(el); + } + } + + // Choices.js fields + const choicesMapping = choicesMappings.find(m => m.key === key); + if (choicesMapping && choicesMapping.instance) { + const instance = choicesMapping.instance; + instance.removeActiveItems(); + (value || []).forEach(v => instance.setChoiceByValue(v.toString())); + + // Animate the Choices container + const container = context.querySelector(`#${choicesMapping.id}`)?.closest('.choices'); + if (container) { + container.classList.add('decker-remote-change'); + setTimeout(() => container.classList.remove('decker-remote-change'), 400); + } + } + }); + + // Use setTimeout to ensure flag resets after any triggered events + setTimeout(() => { isRemoteUpdate = false; }, 0); + }); + } + + /** + * Show indicators of who is editing which field + */ + function observeFieldAwareness() { + awareness.on('change', () => { + // Clear existing indicators + context.querySelectorAll('.decker-field-editor').forEach(el => el.remove()); + context.querySelectorAll('.decker-field-editing').forEach(el => { + el.classList.remove('decker-field-editing'); + el.style.removeProperty('--editor-color'); + }); + context.querySelectorAll('.decker-field-editing-container').forEach(el => { + el.classList.remove('decker-field-editing-container'); + }); + + // Create new indicators for remote users + awareness.getStates().forEach((state, clientId) => { + if (clientId === awareness.clientID) return; + if (!state.activeField || !state.user) return; + + const field = context.querySelector(`#${state.activeField}`); + if (!field) return; + + // Add editing class to field + field.classList.add('decker-field-editing'); + field.style.setProperty('--editor-color', state.user.color); + + // Create indicator element + const wrapper = field.closest('.form-floating') || field.closest('.form-check') || field.closest('.choices') || field.parentElement; + wrapper.style.position = 'relative'; + + // Add special class for Choices.js containers to handle overflow + if (wrapper.classList.contains('choices')) { + wrapper.classList.add('decker-field-editing-container'); + } + + const indicator = document.createElement('div'); + indicator.className = 'decker-field-editor'; + indicator.style.backgroundColor = state.user.color; + indicator.textContent = state.user.name; + wrapper.appendChild(indicator); + }); + }); + } + + // Initialize everything + bindLocalChanges(); + observeRemoteChanges(); + observeFieldAwareness(); + initializeFormFieldValues(); + + console.log('Decker: Form fields collaboration initialized'); + + // Return binding object for cleanup and control + return { + /** + * Set the task as archived (syncs to all peers) + */ + setArchived(archived) { + formFields.set('archived', archived); + if (archived) { + disableAllFormFields(context, strings.task_archived || 'This task has been archived'); + } + }, + + destroy() { + // Clear awareness + session.clearActiveField(); + // Clear indicators + context.querySelectorAll('.decker-field-editor').forEach(el => el.remove()); + context.querySelectorAll('.decker-field-editing').forEach(el => { + el.classList.remove('decker-field-editing'); + }); + context.querySelectorAll('.decker-field-editing-container').forEach(el => { + el.classList.remove('decker-field-editing-container'); + }); + console.log('Decker: Form fields collaboration destroyed'); + } + }; + } + + // Start of comment part + var replyToCommentId = null; + // Function to initialize comment submission within the given context function initializeSendComments(context) { // Check if already initialized in this context @@ -226,11 +644,11 @@ }); } - // Función para inicializar la página de tareas dentro del contexto dado + // Function to initialize the tasks page within the given context function initializeTaskPage(context) { new Tablesort(context.querySelector('#user-history-table')); - // Verificar si el task_id está presente en data-task-id + // Check if the task_id is present in data-task-id const taskId = getTaskId(); const taskElement = context.querySelector(`[data-task-id="${taskId}"]`); if (taskElement) { @@ -239,8 +657,127 @@ console.log('Task ID not found in data-task-id'); } + if (context.querySelector('#task-description')) { + initializeTaskEditor(context); + } + + if (context.querySelector('#editor')) { + // Check if collaborative editing is enabled to include cursors module + const collabEnabled = window.deckerCollabConfig && window.deckerCollabConfig.enabled; + + // Register modules only once + if (quill === null) { + // Register the HTML Edit Button module + Quill.register('modules/htmlEditButton', htmlEditButton); + + // Register quill-cursors module if available (for collaborative editing) + if (typeof QuillCursors !== 'undefined') { + // Try default export first, then module itself + const CursorsModule = QuillCursors.default || QuillCursors; + Quill.register('modules/cursors', CursorsModule); + console.log('Decker: QuillCursors module registered'); + } else { + console.log('Decker: QuillCursors not available at registration time'); + } + } + + // Build modules configuration + const quillModules = { + toolbar: { + container: [ + ['bold', 'italic', 'underline', 'strike'], + ['link', 'blockquote', 'code-block'], + [{ 'list': 'ordered' }, { 'list': 'bullet' }, { 'list': 'check' }], + [{ 'indent': '-1' }, { 'indent': '+1' }], + ['clean'], + ['fullscreen'], + ], + handlers: { + 'fullscreen': function() { + var editorContainer = context.querySelector('#editor-container'); + if (!document.fullscreenElement) { + editorContainer.requestFullscreen().catch(err => { + alert('Error attempting to enable full-screen mode: ' + err.message); + }); + } else { + document.exitFullscreen(); + } + } + } + }, + htmlEditButton: { + syntax: false, + buttonTitle: strings.show_html_source, + msg: strings.edit_html_content, + okText: strings.ok, + cancelText: strings.cancel, + closeOnClickOverlay: false, + }, + }; + + // Add cursors module if collaborative editing is enabled and QuillCursors is available + // Check again here in case it wasn't available during initial registration + if (collabEnabled) { + if (typeof QuillCursors !== 'undefined') { + // Register if not already registered + try { + const CursorsModule = QuillCursors.default || QuillCursors; + if (!Quill.imports['modules/cursors']) { + Quill.register('modules/cursors', CursorsModule); + console.log('Decker: QuillCursors module registered (late)'); + } + quillModules.cursors = { + transformOnTextChange: true, + hideDelayMs: 3000, + hideSpeedMs: 400, + selectionChangeSource: null, + }; + console.log('Decker: Cursors module enabled in config'); + } catch (e) { + console.warn('Decker: Error registering cursors module:', e); + } + } else { + console.warn('Decker: QuillCursors not available, remote cursors disabled'); + } + } + + quill = new Quill(context.querySelector('#editor'), { + theme: 'snow', + readOnly: disabled, + modules: quillModules + }); + window.quill = quill; + + // Initialize collaborative editing if enabled and we have a task ID + if (window.DeckerCollaboration && window.DeckerCollaboration.isEnabled() && !disabled) { + const taskId = getTaskId(); + if (taskId && taskId !== '' && taskId !== '0') { + // Destroy any previous collaboration session + if (collabSession) { + collabSession.destroy(); + collabSession = null; + } + + // Get initial content before binding + const initialContent = quill.root.innerHTML; + + // Initialize collaboration + collabSession = window.DeckerCollaboration.init(quill, taskId, context); + + // If this is the first peer, set the initial content + if (collabSession && initialContent && initialContent !== '


') { + setTimeout(() => { + collabSession.setInitialContent(initialContent); + }, 500); + } + + console.log('Decker: Collaborative editing initialized for task', taskId); + } + } - // Inicializar Choices.js para los selectores de asignados y etiquetas + } + + // Initialize Choices.js for assignee and label selectors if (context.querySelector('#task-assignees')) { assigneesSelect = new Choices(context.querySelector('#task-assignees'), { removeItemButton: true, @@ -249,11 +786,19 @@ shouldSort: true, }); - // Agregar el evento de cambio para los asignados + // Add change event for assignees assigneesSelect.passedElement.element.addEventListener('change', handleAssigneesChange); } if (context.querySelector('#task-labels')) { + // labelsSelect = new Choices(context.querySelector('#task-labels'), { + // removeItemButton: true, + // allowHTML: true, + // searchEnabled: true, + // shouldSort: true, + // }); + + labelsSelect = new Choices(context.querySelector('#task-labels'), { removeItemButton: true, allowHTML: true, @@ -262,20 +807,22 @@ callbackOnCreateTemplates: function (strToEl, escapeForTemplate, getClassNames) { const defaultTemplates = Choices.defaults.templates; + + return { ...defaultTemplates, item: (classNames, data) => { - // 1. Tomar el elemento que genera la plantilla por defecto + // 1. Take the element generated by the default template const el = defaultTemplates.item.call(this, classNames, data); - // 2. Aplicar color de fondo según la taxonomía + // 2. Apply background color according to the taxonomy el.style.backgroundColor = data.customProperties?.color || 'blue'; - // 3. Asegurar que, si removeItemButton=true, se configure el elemento como "deletable" + // 3. Ensure that if removeItemButton=true, the element is set as "deletable" if (this.config.removeItemButton) { el.setAttribute('data-deletable', ''); - // Si la plantilla por defecto no generó ya el botón, crearlo aquí + // If the default template hasn't already generated the button, create it here if (!el.querySelector('[data-button]')) { const button = document.createElement('button'); button.type = 'button'; @@ -290,8 +837,21 @@ return el; } } + } }); + + + } + + // Initialize form fields collaboration after Choices.js is ready + if (collabSession && !disabled) { + // Destroy previous form fields binding if exists + if (formFieldsBinding) { + formFieldsBinding.destroy(); + formFieldsBinding = null; + } + formFieldsBinding = initFormFieldsCollaboration(collabSession, context); } var uploadFileButton = context.querySelector('#upload-file'); @@ -306,7 +866,7 @@ }); } - // Mostrar/ocultar la etiqueta "High" para prioridad máxima + // Show/hide the "High" label for highest priority var taskMaxPriority = context.querySelector('#task-max-priority'); if (taskMaxPriority) { taskMaxPriority.addEventListener('change', function () { @@ -314,7 +874,7 @@ }); } - // Cambios estéticos al seleccionar/deseleccionar el checkbox de tareas + // Aesthetic changes when selecting/deselecting the task checkbox const taskTodayCheckbox = context.querySelector('#task-today'); if (taskTodayCheckbox) { taskTodayCheckbox.addEventListener('change', handleTaskTodayChange); @@ -322,17 +882,15 @@ const saveButton = context.querySelector('#save-task'); - // Función para habilitar el botón de guardar cuando cualquier campo cambia + // Function to enable the save button when any field changes const enableSaveButton = function() { - saveButton.disabled = false; - // Marcar que hay cambios sin guardar - window.deckerHasUnsavedChanges = true; + markTaskAsChanged(context); }; const form = context.querySelector('#task-form'); - // Añadir event listeners a todos los inputs del formulario - const inputIds = ['task-title', 'task-due-date', 'task-board', 'task-stack', 'task-author-info', 'task-responsable', 'task-hidden', 'task-today', 'task-max-priority']; + // Add event listeners to all form inputs + const inputIds = ['task-title', 'task-due-date', 'task-board', 'task-stack', 'task-author-info', 'task-responsable', 'task-hidden', 'task-today', 'task-max-priority', 'task-description']; inputIds.forEach(function(id) { const element = context.querySelector(`#${id}`); @@ -342,13 +900,20 @@ } }); - // Verificar el estado inicial del checkbox de prioridad máxima y alternar la etiqueta + // Check the initial state of the highest priority checkbox and toggle the label var taskMaxPriorityCheck = context.querySelector('#task-max-priority'); if (taskMaxPriorityCheck) { togglePriorityLabel(taskMaxPriorityCheck); } - // Para los selectores de Choices.js + // For the Quill editor + if (quill) { + quill.on('text-change', function() { + markTaskAsChanged(context); + }); + } + + // For the Choices.js selectors if (assigneesSelect) { assigneesSelect.passedElement.element.addEventListener('change', enableSaveButton); } @@ -363,35 +928,39 @@ }); - // Pre-inicializar el editor de tareas - initializeTaskEditor(); + document.querySelectorAll('.clone-task').forEach((element) => { + + element.removeEventListener('click', cloneTaskHandler); + element.addEventListener('click', cloneTaskHandler); + + }); } - // Función para manejar cambios en el checkbox "task-today" + // Function to handle changes in the "task-today" checkbox function handleTaskTodayChange(event) { - // Si el usuario marca una tarea para hoy + // If the user marks a task for today if (event.target.checked) { - // Verificar si el usuario ya está seleccionado - const selectedValues = assigneesSelect.getValue(true); // Obtener valores como array de números - // Y si no está seleccionando + // Check if the user is already selected + const selectedValues = assigneesSelect.getValue(true); // Get values as an array of numbers + // And if it's not selected if (!selectedValues.includes(userId.toString())) { - // Lo selecciona + // Select it assigneesSelect.setChoiceByValue(userId.toString()); } } - // Si se desmarca, no hacer nada + // If it's unchecked, do nothing } - // Función para manejar cambios en los asignados + // Function to handle changes in the assignees function handleAssigneesChange(event) { - // Si el usuario se quita de los asignados a la tarea - const selectedValues = assigneesSelect.getValue(true); // Obtener valores como array de números + // If the user removes themselves from the task assignees + const selectedValues = assigneesSelect.getValue(true); // Get values as an array of numbers if (!selectedValues.includes(userId.toString())) { const taskTodayCheckbox = document.querySelector('#task-today'); - // Y tiene la tarea marcada para hoy + // And has the task marked for today if (taskTodayCheckbox && taskTodayCheckbox.checked) { - // La desmarca + // Uncheck it taskTodayCheckbox.checked = false; } } @@ -433,7 +1002,7 @@ }); } - // Función para añadir un adjunto a la lista + // Function to add an attachment to the list function addAttachmentToList(attachmentId, attachmentUrl, attachmentTitle, attachmentExtension, context) { var attachmentsList = context.querySelector('#attachments-list'); var li = document.createElement('li'); @@ -467,20 +1036,20 @@ attachmentsList.appendChild(li); - // Actualizar el contador de adjuntos - updateAttachmentCount(context, 1); // Incrementar en 1 + // Update the attachment count + updateAttachmentCount(context, 1); // Increase by 1 } - // Event delegation para eliminar adjuntos + // Event delegation to delete attachments document.addEventListener('click', function(event) { if (event.target && event.target.classList.contains('remove-attachment')) { var listItem = event.target.closest('li'); var attachmentId = listItem.getAttribute('data-attachment-id'); - const modalElement = document.querySelector('.task-modal.show'); // Selecciona el modal abierto, o null si no está en un modal + const modalElement = document.querySelector('.task-modal.show'); // Selects the open modal, or null if not in a modal if (modalElement) { deleteAttachment(attachmentId, listItem, modalElement); } else { - deleteAttachment(attachmentId, listItem, document); // Asume que está cargado directamente en la página + deleteAttachment(attachmentId, listItem, document); // Assumes it's loaded directly on the page } } }); @@ -514,7 +1083,7 @@ }); } - // Función para actualizar el contador de adjuntos + // Function to update the attachment counter function updateAttachmentCount(context, change) { var attachmentCountElement = context.querySelector('#attachment-count'); if (attachmentCountElement) { @@ -524,7 +1093,7 @@ } } - // Función para alternar la etiqueta de prioridad máxima + // Function to toggle the highest priority label function togglePriorityLabel(element) { var highLabel =document.querySelector('#high-label'); if (highLabel) { @@ -536,29 +1105,29 @@ } } - // Función para enviar el formulario vía AJAX + // Function to submit the form via AJAX function sendFormByAjax(event) { event.preventDefault(); const form = document.getElementById('task-form'); // Fallback - // const form = event.target; // Obtiene el formulario que disparó el evento + // const form = event.target; // Gets the form that triggered the event - // Remueve la clase 'was-validated' previamente + // Remove the 'was-validated' class beforehand form.classList.remove('was-validated'); - // Verifica la validez del formulario + // Check the form's validity if (!form.checkValidity()) { event.stopPropagation(); form.classList.add('was-validated'); return; } - // Si el formulario es válido, procede con el envío vía AJAX + // If the form is valid, proceed with the AJAX submission const selectedAssigneesValues = assigneesSelect.getValue().map(item => parseInt(item.value, 10)); const selectedLabelsValues = labelsSelect.getValue().map(item => parseInt(item.value, 10)); - // Recopila los datos del formulario + // Gather the form data const formData = { action: 'save_decker_task', nonce: nonces.save_decker_task_nonce, @@ -572,12 +1141,22 @@ hidden: form.querySelector('#task-hidden').checked ? 1 : 0, assignees: selectedAssigneesValues, labels: selectedLabelsValues, - description: form.querySelector('#task-description').value, + description: getTaskDescription(form), max_priority: form.querySelector('#task-max-priority').checked ? 1 : 0, mark_for_today: form.querySelector('#task-today').checked ? 1 : 0, }; - // Envía la solicitud AJAX + // Disable save controls to prevent duplicate submissions + const saveButton = document.getElementById('save-task'); + const saveDropdown = document.getElementById('save-task-dropdown'); + if (saveButton) { + saveButton.disabled = true; + } + if (saveDropdown) { + saveDropdown.disabled = true; + } + + // Send the AJAX request const xhr = new XMLHttpRequest(); xhr.open('POST', ajaxUrl, true); xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); @@ -587,41 +1166,63 @@ const response = JSON.parse(xhr.responseText); if (response.success) { window.deckerHasUnsavedChanges = false; - const modalElement = document.querySelector('.task-modal.show'); // Selecciona el modal abierto, o null si no está en un modal + if (window.parent && window.parent.Swal) { + window.parent.Swal.fire({ + icon: 'success', + title: strings.task_saved_success, + toast: true, + position: 'top-end', + showConfirmButton: false, + timer: 1500, + timerProgressBar: true + }); + } + const modalElement = document.querySelector('.task-modal.show'); // Selects the open modal, or null if not in a modal if (modalElement) { var modalInstance = bootstrap.Modal.getInstance(modalElement); if (modalInstance) { modalInstance.hide(); } - - // Recargar la página si la solicitud fue exitosa - location.reload(); + + // Reload the page if the request was successful + location.reload(); } else { - // Redirecciona o actualiza según la respuesta + // Redirect or update depending on the response window.location.href = `${homeUrl}?decker_page=task&id=${response.data.task_id}`; } } else { - alert(response.data.message || 'Error al guardar la tarea.'); + alert(response.data.message || strings.error_saving_task); + if (saveButton) { + saveButton.disabled = false; + } + if (saveDropdown) { + saveDropdown.disabled = false; + } } } else { console.error(strings.server_response_error); alert(strings.an_error_occurred_saving_task); + if (saveButton) { + saveButton.disabled = false; + } + if (saveDropdown) { + saveDropdown.disabled = false; + } } }; xhr.onerror = function() { console.error(strings.request_error); alert(strings.error_saving_task); + if (saveButton) { + saveButton.disabled = false; + } + if (saveDropdown) { + saveDropdown.disabled = false; + } }; - // Obtener el contenido del editor si está inicializado - if (taskEditor) { - formData.description = taskEditor.getContent(); - } else { - formData.description = document.querySelector('#task-description').value; - } - const encodedData = Object.keys(formData) .map(key => encodeURIComponent(key) + '=' + encodeURIComponent(formData[key])) .join('&'); @@ -629,16 +1230,7 @@ xhr.send(encodedData); } - // Función para destruir el editor cuando se cierra el modal - function destroyTaskEditor() { - if (taskEditor && taskEditor.initialized) { - wp.editor.remove('task-description'); - taskEditor.initialized = false; - taskEditor = null; - } - } - - // Obtener el task_id desde el input hidden + // Get the task_id from the hidden input function getTaskId() { const taskIdInput = document.querySelector('input[name="task_id"]'); if (taskIdInput) { @@ -649,7 +1241,7 @@ } } - // Exportar funciones globalmente para que puedan ser llamadas desde HTML + // Export functions globally so they can be called from HTML window.initializeSendComments = initializeSendComments; window.initializeTaskPage = initializeTaskPage; window.sendFormByAjax = sendFormByAjax; @@ -657,27 +1249,21 @@ window.togglePriorityLabel = togglePriorityLabel; window.destroyTaskEditor = destroyTaskEditor; - // Inicializar automáticamente si el contenido está cargado directamente en la página + // Expose function to set task as archived (for collaborative sync) + window.setTaskArchivedCollab = function(archived) { + if (formFieldsBinding && typeof formFieldsBinding.setArchived === 'function') { + formFieldsBinding.setArchived(archived); + } + }; + + // Automatically initialize if the content is loaded directly on the page document.addEventListener('DOMContentLoaded', function() { - // Verificar si existe el formulario de tarea directamente en la página + // Check if the task form exists directly on the page const taskForm = document.querySelector('#task-form'); - if (taskForm && !taskForm.closest('.task-modal')) { // Asegurarse de que no está dentro de un modal + if (taskForm && !taskForm.closest('.task-modal')) { // Ensure that it is not inside a modal initializeTaskPage(document); initializeSendComments(document); } }); - // Pre-inicializar el editor cuando se hace clic en el botón de edición - document.querySelectorAll('[data-bs-target="#task-modal"]').forEach((button) => { - button.addEventListener('click', function() { - initializeTaskEditor(); - }); - }); - - // Destruir el editor cuando se cierra el modal - document.getElementById('task-modal')?.addEventListener('hidden.bs.modal', function() { - destroyTaskEditor(); - }); - })(); - diff --git a/public/assets/js/task-modal.js b/public/assets/js/task-modal.js index 41a02466..c3b1820d 100644 --- a/public/assets/js/task-modal.js +++ b/public/assets/js/task-modal.js @@ -2,7 +2,7 @@ document.addEventListener('DOMContentLoaded', function () { const modalElement = document.getElementById('task-modal'); jQuery('#task-modal').on('hide.bs.modal', function (e) { - // Si tenemos cambios sin guardar, pedimos confirmación + // If we have unsaved changes, ask for confirmation if (window.deckerHasUnsavedChanges) { e.preventDefault(); // Prevents modal closing @@ -16,9 +16,9 @@ document.addEventListener('DOMContentLoaded', function () { cancelButtonText: deckerVars.strings.cancel }).then((result) => { if (result.isConfirmed) { - // El usuario ha confirmado cerrar y descartar + // The user has confirmed to close and discard window.deckerHasUnsavedChanges = false; - // Forzamos el cierre del modal + // Force closing the modal jQuery('#task-modal').modal('hide'); } }); @@ -29,11 +29,11 @@ document.addEventListener('DOMContentLoaded', function () { var modal = jQuery(this); modal.find('#task-modal-body').html('

' + jsdata_task.loadingMessage + '

'); - var taskId = jQuery(e.relatedTarget).data('task-id'); // Puede ser 0 (nueva tarea). + var taskId = jQuery(e.relatedTarget).data('task-id'); // It can be 0 (new task). var url = jsdata_task.url; const params = new URLSearchParams(window.location.search); - const boardSlug = params.get('slug'); // Si existe. + const boardSlug = params.get('slug'); // If present. jQuery.ajax({ url: url, @@ -56,7 +56,7 @@ document.addEventListener('DOMContentLoaded', function () { modalTitle.text('Task'); } - // Después de cargar el contenido, inicializar las funciones JS + // After loading the content, initialize the JS functions if (typeof window.initializeSendComments === 'function' && typeof window.initializeTaskPage === 'function') { window.initializeSendComments(modal[0]); window.initializeTaskPage(modal[0]); @@ -69,15 +69,15 @@ document.addEventListener('DOMContentLoaded', function () { }); }); - // Limpiar atributos data-* al cerrar el modal para permitir una nueva inicialización +// Clear data-* attributes when closing the modal to allow reinitialization jQuery('#task-modal').on('hidden.bs.modal', function () { var modal = jQuery(this); - // Remover los atributos data-* utilizados para rastrear inicialización + // Remove the data-* attributes used to track initialization modal[0].removeAttribute('data-send-comments-initialized'); modal[0].removeAttribute('data-task-page-initialized'); - // Opcional: destruir instancias de Choices.js o Quill editor si es necesario - // Esto depende de tu implementación y uso de memoria + // Optional: destroy instances of Choices.js or Quill editor if necessary + // This depends on your implementation and memory usage if (window.Choices) { const assigneesSelectInstance = window.assigneesSelect; if (assigneesSelectInstance) { @@ -92,13 +92,13 @@ document.addEventListener('DOMContentLoaded', function () { } } - if (window.quill) { - window.quill = null; // Asumiendo que quill no necesita destrucción explícita - } - - // Destruir el editor de WordPress si existe if (typeof window.destroyTaskEditor === 'function') { window.destroyTaskEditor(); } + + // Destroy collaborative editing session if active + if (window.DeckerCollaboration) { + window.DeckerCollaboration.destroyAll(); + } }); }); diff --git a/public/class-decker-public.php b/public/class-decker-public.php index e35e57e0..8f1d7283 100644 --- a/public/class-decker-public.php +++ b/public/class-decker-public.php @@ -184,6 +184,11 @@ public function enqueue_scripts() { $decker_page = get_query_var( 'decker_page' ); if ( $decker_page ) { + $options = get_option( 'decker_settings', array() ); + $task_editor_type = isset( $options['task_editor_type'] ) ? $options['task_editor_type'] : 'classic'; + $collaborative_editing_enabled = ! empty( $options['collaborative_editing'] ) && '1' === $options['collaborative_editing']; + $use_quill_editor = $collaborative_editing_enabled || 'quill' === $task_editor_type; + $resources = array( // Register the main theme config script. plugin_dir_url( __FILE__ ) . '../public/assets/js/config.js', @@ -192,23 +197,23 @@ public function enqueue_scripts() { 'wp-api', // Bootstrap 5. - 'https://cdn.jsdelivr.net/npm/bootstrap@5.3.5/dist/css/bootstrap.min.css', - 'https://cdn.jsdelivr.net/npm/bootstrap@5.3.5/dist/js/bootstrap.bundle.min.js', + 'https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css', + 'https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js', // Remix Icons. - 'https://cdnjs.cloudflare.com/ajax/libs/remixicon/4.6.0/remixicon.min.css', + 'https://cdn.jsdelivr.net/npm/remixicon@4.9.1/fonts/remixicon.min.css', // Tablesort. - 'https://cdnjs.cloudflare.com/ajax/libs/tablesort/5.2.1/tablesort.min.js', + 'https://cdn.jsdelivr.net/gh/tristen/tablesort@5.7.0/dist/tablesort.min.js', // Simplebar. - 'https://cdn.jsdelivr.net/npm/simplebar@6.3.0/dist/simplebar.min.js', + 'https://cdn.jsdelivr.net/npm/simplebar@6.3.3/dist/simplebar.min.js', - // Font Awesome. + // Font Awesome 5 Free (kept at 5.x; upgrading to 6.x requires icon class changes). 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css', // SortableJS. - 'https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.6/Sortable.min.js', + 'https://cdn.jsdelivr.net/npm/sortablejs@1.15.7/Sortable.min.js', /* // Highlight. @@ -220,12 +225,12 @@ public function enqueue_scripts() { 'https://cdnjs.cloudflare.com/ajax/libs/choices.js/11.1.0/choices.min.js', 'https://cdnjs.cloudflare.com/ajax/libs/choices.js/11.1.0/choices.min.css', - // sweetalert2.js. - 'https://cdnjs.cloudflare.com/ajax/libs/sweetalert2/11.16.1/sweetalert2.all.min.js', - 'https://cdnjs.cloudflare.com/ajax/libs/sweetalert2/11.16.1/sweetalert2.min.css', + // SweetAlert2. + 'https://cdn.jsdelivr.net/npm/sweetalert2@11.26.21/dist/sweetalert2.all.min.js', + 'https://cdn.jsdelivr.net/npm/sweetalert2@11.26.21/dist/sweetalert2.min.css', // Chart.js. - 'https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js', + 'https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js', // Custom files. plugin_dir_url( __FILE__ ) . '../public/assets/js/app.js', @@ -239,6 +244,14 @@ public function enqueue_scripts() { ); + if ( $use_quill_editor ) { + $resources[] = 'https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.min.js'; + $resources[] = 'https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.min.css'; + $resources[] = 'https://cdn.jsdelivr.net/npm/quill-html-edit-button@3.0.0/dist/quill.htmlEditButton.min.js'; + $resources[] = 'https://cdn.jsdelivr.net/npm/quill-cursors@4.1.0/dist/quill-cursors.min.js'; + $resources[] = 'https://cdn.jsdelivr.net/npm/quill-cursors@4.1.0/dist/quill-cursors.css'; + } + if ( 'board' == $decker_page ) { // Dragula. $resources[] = 'https://cdnjs.cloudflare.com/ajax/libs/dragula/3.7.3/dragula.min.js'; @@ -247,7 +260,7 @@ public function enqueue_scripts() { if ( 'calendar' == $decker_page ) { // FullCalendar. - $resources[] = 'https://cdnjs.cloudflare.com/ajax/libs/fullcalendar/6.1.15/index.global.min.js'; + $resources[] = 'https://cdn.jsdelivr.net/npm/fullcalendar@6.1.20/index.global.min.js'; $resources[] = plugin_dir_url( __FILE__ ) . '../public/assets/js/event-calendar.js'; @@ -265,11 +278,28 @@ public function enqueue_scripts() { } - wp_enqueue_media(); // Obligatorio para subida de medios. - // Cargar editor clásico en todas las páginas de Decker. - wp_enqueue_editor(); + if ( 'knowledge-base' == $decker_page ) { + + wp_enqueue_media(); // Required for media uploads. + // wp_enqueue_script('editor'); + // wp_enqueue_script('thickbox'); + // wp_enqueue_style('editor-buttons'); + // wp_enqueue_style('thickbox'); + // wp_enqueue_script('wp-tinymce'); // Main TinyMCE script. + + wp_enqueue_editor(); + + // Page-specific script for Knowledge Base interactions. + $resources[] = plugin_dir_url( __FILE__ ) . '../public/assets/js/knowledge-base.js'; + + } + + if ( ! $use_quill_editor ) { + // Load the WordPress Classic Editor assets for task descriptions when Quill is not selected. + wp_enqueue_editor(); + } - if ( 'tasks' == $decker_page || 'knowledge-base' == $decker_page ) { // Only load datatables.net on tasks page. + if ( 'tasks' == $decker_page ) { // Only load datatables.net on tasks page. // Datatables JS CDN. $resources[] = 'https://cdn.datatables.net/1.13.11/js/jquery.dataTables.min.js'; $resources[] = 'https://cdn.datatables.net/searchbuilder/1.6.0/js/dataTables.searchBuilder.min.js'; @@ -287,22 +317,23 @@ public function enqueue_scripts() { $resources[] = plugin_dir_url( __FILE__ ) . '../public/assets/js/task-card.js'; - // Cargar scripts de editor para todas las páginas. - wp_enqueue_script( 'editor' ); - wp_enqueue_script( 'wp-tinymce' ); - wp_enqueue_script( 'heartbeat' ); - $resources[] = plugin_dir_url( __FILE__ ) . '../public/assets/js/decker-heartbeat.js'; + // Add global search script. + $resources[] = plugin_dir_url( __FILE__ ) . '../public/assets/js/global-search.js'; + + // Add collaborative editing module if enabled. + $this->maybe_enqueue_collaboration(); + $users = get_users( array( 'fields' => array( 'ID', 'display_name' ), // Campos nativos. ) ); - // Añadir el nickname a cada usuario. + // Add the nickname to each user. foreach ( $users as &$user ) { - $user->nickname = get_user_meta( $user->ID, 'nickname', true ); // Cambia 'alias' por tu meta key real. + $user->nickname = get_user_meta( $user->ID, 'nickname', true ); // Replace 'alias' with your real meta key. } // Unified localized data. @@ -330,6 +361,7 @@ public function enqueue_scripts() { 'an_error_occurred_saving_task' => __( 'An error occurred while saving the task.', 'decker' ), 'request_error' => __( 'Request error.', 'decker' ), 'error_saving_task' => __( 'Error saving task.', 'decker' ), + 'task_saved_success' => __( 'The task has been saved successfully.', 'decker' ), 'show_html_source' => __( 'Show HTML source', 'decker' ), 'edit_html_content' => __( 'Edit the content in HTML format', 'decker' ), 'ok' => __( 'OK', 'decker' ), @@ -345,6 +377,12 @@ public function enqueue_scripts() { 'task_archived_success' => __( 'The task has been successfully archived.', 'decker' ), 'task_unarchived_success' => __( 'The task has been successfully unarchived.', 'decker' ), 'error_archiving_task' => __( 'An error occurred while archiving the task.', 'decker' ), + // Clone task strings. + 'confirm_clone_task_title' => __( 'Are you sure you want to clone this task?', 'decker' ), + 'confirm_clone_task_text' => __( 'A copy of this task will be created.', 'decker' ), + 'clone_task' => __( 'Clone', 'decker' ), + 'task_cloned_success' => __( 'The task has been successfully cloned.', 'decker' ), + 'error_cloning_task' => __( 'An error occurred while cloning the task.', 'decker' ), // Extra keys from first version. 'success' => __( 'Success', 'decker' ), 'error' => __( 'Error', 'decker' ), @@ -364,6 +402,8 @@ public function enqueue_scripts() { 'timeFormat24h' => ( get_option( 'time_format' ) === 'H:i' ), 'disabled' => isset( $disabled ) && $disabled ? true : false, 'current_user_id' => get_current_user_id(), + 'task_editor_type' => $task_editor_type, + 'use_quill_editor' => $use_quill_editor, 'users' => $users, 'locale' => substr( get_user_locale(), 0, 2 ), // Ej: "es_ES" → "es". 'taskPermalinkStructure' => get_option( 'permalink_structure' ) @@ -376,7 +416,7 @@ public function enqueue_scripts() { // Add the bundled jQuery library. wp_enqueue_script( 'jquery' ); - // Asegurar que el script de Heartbeat esté encolado. + // Ensure that the Heartbeat script is enqueued. wp_enqueue_script( 'heartbeat' ); // Add the bundled Backbone library. @@ -457,6 +497,79 @@ public function enqueue_scripts() { // TODO: This can be removed, review. wp_localize_script( 'event-card', 'deckerVars', $localized_data ); + // Localize the global search script. + wp_localize_script( + 'global-search', + 'deckerSearchVars', + array( + 'restUrl' => rest_url(), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'strings' => array( + 'search_placeholder' => __( 'Search tasks...', 'decker' ), + 'search_hint' => __( 'Type to search tasks by title', 'decker' ), + 'navigate' => __( 'to navigate', 'decker' ), + 'select' => __( 'to select', 'decker' ), + 'close' => __( 'to close', 'decker' ), + 'no_results' => __( 'No tasks found', 'decker' ), + 'error' => __( 'Error searching tasks', 'decker' ), + ), + ) + ); + } } + + /** + * Enqueue collaborative editing module if enabled. + * + * Loads the Yjs-based collaboration module as an ES module. + */ + private function maybe_enqueue_collaboration() { + $options = get_option( 'decker_settings', array() ); + + // Check if collaborative editing is enabled. + if ( empty( $options['collaborative_editing'] ) || '1' !== $options['collaborative_editing'] ) { + return; + } + + $current_user = wp_get_current_user(); + $signaling_server = ! empty( $options['signaling_server'] ) ? $options['signaling_server'] : 'wss://signaling.yjs.dev'; + + // Generate a user color based on user ID for consistency. + $colors = array( '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9' ); + $user_color = $colors[ $current_user->ID % count( $colors ) ]; + + // Prepare configuration for the collaboration module. + $collab_config = array( + 'enabled' => true, + 'signalingServer' => esc_url( $signaling_server, array( 'wss', 'ws', 'https', 'http' ) ), + 'roomPrefix' => 'decker-task-' . sanitize_key( wp_parse_url( home_url(), PHP_URL_HOST ) ) . '-', + 'userName' => esc_js( $current_user->display_name ), + 'userColor' => $user_color, + 'userId' => $current_user->ID, + 'userAvatar' => esc_url( get_avatar_url( $current_user->ID, array( 'size' => 32 ) ) ), + 'strings' => array( + 'connecting' => __( 'Connecting...', 'decker' ), + 'collaborative_mode' => __( 'Collaborative mode', 'decker' ), + 'disconnected' => __( 'Disconnected', 'decker' ), + 'you' => __( 'you', 'decker' ), + ), + ); + + // Add inline script to set configuration before the module loads. + add_action( + 'wp_footer', + function () use ( $collab_config ) { + $config_json = wp_json_encode( $collab_config ); + $module_url = esc_url( plugin_dir_url( __FILE__ ) . 'assets/js/decker-collaboration.js' ); + ?> + + + $task_id, @@ -125,14 +125,14 @@ function include_wp_load( $max_levels = 10 ) { function render_comments( array $task_comments, int $parent_id, int $current_user_id ) { foreach ( $task_comments as $comment ) { if ( $comment->comment_parent == $parent_id ) { - // Obtener respuestas recursivamente. + // Get replies recursively. echo '
'; echo 'Avatar'; echo '
'; echo '
' . esc_html( $comment->comment_author ) . ' ' . esc_html( get_comment_date( '', $comment ) ) . '
'; echo wp_kses_post( apply_filters( 'the_content', $comment->comment_content ) ); - // Mostrar enlace de eliminar si el comentario pertenece al usuario actual. + // Show delete link if the comment belongs to the current user. if ( get_current_user_id() == $comment->user_id ) { echo ' ' . esc_html__( 'Delete', 'decker' ) . ' '; @@ -143,7 +143,7 @@ function render_comments( array $task_comments, int $parent_id, int $current_use echo '
'; echo '
'; - // Llamada recursiva para renderizar respuestas. + // Recursive call to render replies. render_comments( $task_comments, $comment->comment_ID, $current_user_id ); } } @@ -262,7 +262,7 @@ function render_comments( array $task_comments, int $parent_id, int $current_use
- +
@@ -321,7 +321,7 @@ function render_comments( array $task_comments, int $parent_id, int $current_use