Browse Source

Fix pasting in the message form

JC Brand 3 tháng trước cách đây
mục cha
commit
b7b1460177

+ 1 - 0
karma.conf.js

@@ -60,6 +60,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/chatview/tests/me-messages.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/message-audio.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/message-avatar.js", type: 'module' },
+      { pattern: "src/plugins/chatview/tests/message-form.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/message-gifs.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/message-images.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/message-videos.js", type: 'module' },

+ 78 - 54
src/plugins/chatview/message-form.js

@@ -1,11 +1,12 @@
 /**
  * @typedef {import('shared/chat/emoji-dropdown.js').default} EmojiDropdown
  */
-import { _converse, api, converse, constants, u } from "@converse/headless";
-import { __ } from "i18n";
-import { CustomElement } from "shared/components/element.js";
-import tplMessageForm from "./templates/message-form.js";
-import { parseMessageForCommands } from "./utils.js";
+import { _converse, api, converse, constants, u } from '@converse/headless';
+import log from '@converse/log';
+import { __ } from 'i18n';
+import { CustomElement } from 'shared/components/element.js';
+import tplMessageForm from './templates/message-form.js';
+import { parseMessageForCommands } from './utils.js';
 
 const { ACTIVE, COMPOSING } = constants;
 
@@ -23,28 +24,28 @@ export default class MessageForm extends CustomElement {
 
     async initialize() {
         await this.model.initialized;
-        this.listenTo(this.model, "change:composing_spoiler", () => this.requestUpdate());
-        this.listenTo(this.model, "change:draft", () => this.requestUpdate());
-        this.listenTo(this.model, "change:hidden", () => {
-            if (this.model.get("hidden")) {
-                const draft_hint = /** @type {HTMLInputElement} */ (this.querySelector(".spoiler-hint"))?.value;
-                const draft_message = /** @type {HTMLTextAreaElement} */ (this.querySelector(".chat-textarea"))?.value;
+        this.listenTo(this.model, 'change:composing_spoiler', () => this.requestUpdate());
+        this.listenTo(this.model, 'change:draft', () => this.requestUpdate());
+        this.listenTo(this.model, 'change:hidden', () => {
+            if (this.model.get('hidden')) {
+                const draft_hint = /** @type {HTMLInputElement} */ (this.querySelector('.spoiler-hint'))?.value;
+                const draft_message = /** @type {HTMLTextAreaElement} */ (this.querySelector('.chat-textarea'))?.value;
                 u.safeSave(this.model, { draft: draft_message, draft_hint });
             }
         });
 
         this.handleEmojiSelection = (/** @type { CustomEvent } */ { detail }) => {
-            if (this.model.get("jid") === detail.jid) {
+            if (this.model.get('jid') === detail.jid) {
                 this.insertIntoTextArea(detail.value, detail.autocompleting, detail.ac_position);
             }
         };
-        document.addEventListener("emojiSelected", this.handleEmojiSelection);
+        document.addEventListener('emojiSelected', this.handleEmojiSelection);
         this.requestUpdate();
     }
 
     disconnectedCallback() {
         super.disconnectedCallback();
-        document.removeEventListener("emojiSelected", this.handleEmojiSelection);
+        document.removeEventListener('emojiSelected', this.handleEmojiSelection);
     }
 
     render() {
@@ -61,11 +62,11 @@ export default class MessageForm extends CustomElement {
      * @param {number} [position] - The end index of the string to be
      *  replaced with the new value.
      */
-    insertIntoTextArea(value, replace = false, position, separator = " ") {
-        const textarea = /** @type {HTMLTextAreaElement} */ (this.querySelector(".chat-textarea"));
+    insertIntoTextArea(value, replace = false, position, separator = ' ') {
+        const textarea = /** @type {HTMLTextAreaElement} */ (this.querySelector('.chat-textarea'));
         if (replace) {
-            if (position && typeof replace == "string") {
-                textarea.value = textarea.value.replace(new RegExp(replace, "g"), (match, offset) =>
+            if (position && typeof replace == 'string') {
+                textarea.value = textarea.value.replace(new RegExp(replace, 'g'), (match, offset) =>
                     offset == position - replace.length ? value + separator : match
                 );
             } else {
@@ -78,7 +79,7 @@ export default class MessageForm extends CustomElement {
             }
             textarea.value = existing + value + separator;
         }
-        const ev = new Event("change", { bubbles: false, cancelable: true });
+        const ev = new Event('change', { bubbles: false, cancelable: true });
         textarea.dispatchEvent(ev);
         u.placeCaretAtEnd(textarea);
     }
@@ -88,11 +89,11 @@ export default class MessageForm extends CustomElement {
      * @param {KeyboardEvent} ev
      */
     onEscapePressed(ev) {
-        const idx = this.model.messages.findLastIndex("correcting");
+        const idx = this.model.messages.findLastIndex('correcting');
         const message = idx >= 0 ? this.model.messages.at(idx) : null;
         if (message) {
             ev.preventDefault();
-            message.save("correcting", false);
+            message.save('correcting', false);
         }
     }
 
@@ -101,13 +102,36 @@ export default class MessageForm extends CustomElement {
      * @param {ClipboardEvent} ev
      */
     onPaste(ev) {
-        ev.stopPropagation();
         if (ev.clipboardData.files.length !== 0) {
+            ev.stopPropagation();
             ev.preventDefault();
             this.model.sendFiles(Array.from(ev.clipboardData.files));
             return;
         }
-        this.model.save({ draft: ev.clipboardData.getData("text/plain") });
+
+        const textarea = /** @type {HTMLTextAreaElement} */ (this.querySelector('.chat-textarea'));
+        if (!textarea) {
+            log.error("onPaste: could not find textarea to paste in to!");
+            return;
+        }
+
+        ev.preventDefault();
+        ev.stopPropagation();
+
+        const draft = textarea.value ?? '';
+        const pasted_text = ev.clipboardData.getData('text/plain');
+        const cursor_pos = textarea.selectionStart;
+
+        // Insert text at cursor position
+        const before = draft.substring(0, cursor_pos);
+        const after = draft.substring(textarea.selectionEnd);
+        const separator = before.endsWith(' ') || before.length === 0 ? '' : ' ';
+        const end_separator = after.startsWith(' ') || after.length === 0 ? '' : ' ';
+        this.model.save({ draft: `${before}${separator}${pasted_text}${end_separator}${after}` });
+
+        // Set cursor position after the pasted text
+        const new_pos = before.length + separator.length + pasted_text.length + end_separator.length;
+        setTimeout(() => textarea.setSelectionRange(new_pos, new_pos), 0);
     }
 
     /**
@@ -143,10 +167,10 @@ export default class MessageForm extends CustomElement {
 
             if (ev.key === converse.keycodes.TAB) {
                 const value = u.getCurrentWord(target, null, /(:.*?:)/g);
-                if (value.startsWith(":")) {
+                if (value.startsWith(':')) {
                     ev.preventDefault();
                     ev.stopPropagation();
-                    this.model.trigger("emoji-picker-autocomplete", { target, value });
+                    this.model.trigger('emoji-picker-autocomplete', { target, value });
                 }
             } else if (ev.key === converse.keycodes.FORWARD_SLASH) {
                 // Forward slash is used to run commands. Nothing to do here.
@@ -156,7 +180,7 @@ export default class MessageForm extends CustomElement {
             } else if (ev.key === converse.keycodes.ENTER) {
                 return this.onFormSubmitted(ev);
             } else if (ev.key === converse.keycodes.UP_ARROW && !target.selectionEnd) {
-                const textarea = /** @type {HTMLTextAreaElement} */ (this.querySelector(".chat-textarea"));
+                const textarea = /** @type {HTMLTextAreaElement} */ (this.querySelector('.chat-textarea'));
                 if (!textarea.value || this.model.get('correcting')) {
                     return this.model.editEarlierMessage();
                 }
@@ -175,7 +199,7 @@ export default class MessageForm extends CustomElement {
         ) {
             return;
         }
-        if (this.model.get("chat_state") !== COMPOSING) {
+        if (this.model.get('chat_state') !== COMPOSING) {
             // Set chat state to composing if key is not a forward-slash
             // (which would imply an internal command and not a message).
             this.model.setChatState(COMPOSING);
@@ -188,59 +212,59 @@ export default class MessageForm extends CustomElement {
     async onFormSubmitted(ev) {
         ev?.preventDefault?.();
         const { chatboxviews } = _converse.state;
-        const textarea = /** @type {HTMLTextAreaElement} */ (this.querySelector(".chat-textarea"));
+        const textarea = /** @type {HTMLTextAreaElement} */ (this.querySelector('.chat-textarea'));
         const message_text = textarea.value.trim();
         if (
-            (api.settings.get("message_limit") && message_text.length > api.settings.get("message_limit")) ||
-            !message_text.replace(/\s/g, "").length
+            (api.settings.get('message_limit') && message_text.length > api.settings.get('message_limit')) ||
+            !message_text.replace(/\s/g, '').length
         ) {
             return;
         }
         if (!api.connection.get().authenticated) {
-            const err_msg = __("Sorry, the connection has been lost, and your message could not be sent");
-            api.alert("error", __("Error"), err_msg);
+            const err_msg = __('Sorry, the connection has been lost, and your message could not be sent');
+            api.alert('error', __('Error'), err_msg);
             api.connection.reconnect();
             return;
         }
         let spoiler_hint,
             hint_el = {};
-        if (this.model.get("composing_spoiler")) {
-            hint_el = /** @type {HTMLInputElement} */ (this.querySelector("form.chat-message-form input.spoiler-hint"));
+        if (this.model.get('composing_spoiler')) {
+            hint_el = /** @type {HTMLInputElement} */ (this.querySelector('form.chat-message-form input.spoiler-hint'));
             spoiler_hint = hint_el.value;
         }
-        u.addClass("disabled", textarea);
-        textarea.setAttribute("disabled", "disabled");
-        /** @type {EmojiDropdown} */ (this.querySelector("converse-emoji-dropdown"))?.dropdown.hide();
+        u.addClass('disabled', textarea);
+        textarea.setAttribute('disabled', 'disabled');
+        /** @type {EmojiDropdown} */ (this.querySelector('converse-emoji-dropdown'))?.dropdown.hide();
 
         const is_command = await parseMessageForCommands(this.model, message_text);
-        const message = is_command ? null : await this.model.sendMessage({ "body": message_text, spoiler_hint });
+        const message = is_command ? null : await this.model.sendMessage({ 'body': message_text, spoiler_hint });
         if (is_command || message) {
-            hint_el.value = "";
-            textarea.value = "";
-            textarea.style.height = "auto";
-            this.model.save({ "draft": "" });
+            hint_el.value = '';
+            textarea.value = '';
+            textarea.style.height = 'auto';
+            this.model.save({ 'draft': '' });
         }
-        if (api.settings.get("view_mode") === "overlayed") {
+        if (api.settings.get('view_mode') === 'overlayed') {
             // XXX: Chrome flexbug workaround. The .chat-content area
             // doesn't resize when the textarea is resized to its original size.
-            const chatview = chatboxviews.get(this.model.get("jid"));
-            const msgs_container = chatview.querySelector(".chat-content__messages");
-            msgs_container.parentElement.style.display = "none";
+            const chatview = chatboxviews.get(this.model.get('jid'));
+            const msgs_container = chatview.querySelector('.chat-content__messages');
+            msgs_container.parentElement.style.display = 'none';
         }
-        textarea.removeAttribute("disabled");
-        u.removeClass("disabled", textarea);
+        textarea.removeAttribute('disabled');
+        u.removeClass('disabled', textarea);
 
-        if (api.settings.get("view_mode") === "overlayed") {
+        if (api.settings.get('view_mode') === 'overlayed') {
             // XXX: Chrome flexbug workaround.
-            const chatview = chatboxviews.get(this.model.get("jid"));
-            const msgs_container = chatview.querySelector(".chat-content__messages");
-            msgs_container.parentElement.style.display = "";
+            const chatview = chatboxviews.get(this.model.get('jid'));
+            const msgs_container = chatview.querySelector('.chat-content__messages');
+            msgs_container.parentElement.style.display = '';
         }
         // Suppress events, otherwise superfluous CSN gets set
         // immediately after the message, causing rate-limiting issues.
-        this.model.setChatState(ACTIVE, { "silent": true });
+        this.model.setChatState(ACTIVE, { 'silent': true });
         textarea.focus();
     }
 }
 
-api.elements.define("converse-message-form", MessageForm);
+api.elements.define('converse-message-form', MessageForm);

+ 1 - 1
src/plugins/chatview/templates/bottom-panel.js

@@ -6,7 +6,7 @@ export default (o) => {
     const unread_msgs = __('You have unread messages');
     return html`
         ${ o.model.ui.get('scrolled') && o.model.get('num_unread') ?
-            html`<div class="new-msgs-indicator" @click=${ev => o.viewUnreadMessages(ev)}>▼ ${ unread_msgs } ▼</div>` : '' }
+            html`<div class="new-msgs-indicator" @click=${(ev) => o.viewUnreadMessages(ev)}>▼ ${ unread_msgs } ▼</div>` : '' }
         <converse-message-form .model=${o.model}></converse-message-form>
     `;
 }

+ 0 - 1
src/plugins/chatview/tests/message-audio.js

@@ -1,5 +1,4 @@
 /*global mock, converse */
-
 const { sizzle, u } = converse.env;
 
 describe("A Chat Message", function () {

+ 96 - 0
src/plugins/chatview/tests/message-form.js

@@ -0,0 +1,96 @@
+/*global mock, converse */
+const { sizzle, u } = converse.env;
+
+describe('A message form', function () {
+    fit(
+        'can have text pasted into it with automatic space handling',
+        mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+            await mock.waitForRoster(_converse, 'current', 1);
+
+            const contact_jid = mock.cur_names[0].replace(/ /g, '.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, contact_jid);
+            const view = _converse.chatboxviews.get(contact_jid);
+            const textarea = view.querySelector('textarea.chat-textarea');
+
+            // Helper function to simulate paste with automatic space handling
+            function simulatePaste(text, cursorStart, cursorEnd, pastedText) {
+                textarea.value = text;
+                textarea.selectionStart = cursorStart;
+                textarea.selectionEnd = cursorEnd || cursorStart;
+
+                // Create a paste event with clipboard data
+                const pasteEvent = new Event('paste', { bubbles: true, cancelable: true });
+                Object.defineProperty(pasteEvent, 'clipboardData', {
+                    value: {
+                        files: [],
+                        getData: () => pastedText,
+                    },
+                });
+
+                // Dispatch the paste event
+                textarea.dispatchEvent(pasteEvent);
+
+                // Simulate the paste behavior with automatic space handling
+                const startPos = textarea.selectionStart;
+                const endPos = textarea.selectionEnd;
+                const textBeforeSelection = textarea.value.substring(0, startPos);
+                const textAfterSelection = textarea.value.substring(endPos);
+
+                // Add space before pasted text if needed
+                let resultText = textBeforeSelection;
+                if (resultText.length > 0 && !resultText.endsWith(' ') && pastedText.length > 0) {
+                    resultText += ' ';
+                }
+
+                // Add pasted text
+                resultText += pastedText;
+
+                // Add space after pasted text if needed
+                if (pastedText.length > 0 && textAfterSelection.length > 0 && !textAfterSelection.startsWith(' ')) {
+                    resultText += ' ';
+                }
+
+                resultText += textAfterSelection;
+                textarea.value = resultText;
+
+                // Update cursor position after paste
+                const newCursorPos = resultText.length - textAfterSelection.length;
+                textarea.selectionStart = textarea.selectionEnd = newCursorPos;
+
+                return resultText;
+            }
+
+            // Test case 1: Paste at the beginning
+            let result = simulatePaste('Hello world', 0, 0, 'PASTED');
+            expect(result).toBe('PASTED Hello world');
+
+            // Test case 2: Paste in the middle (no space before cursor)
+            result = simulatePaste('Helloworld', 5, 5, 'PASTED');
+            expect(result).toBe('Hello PASTED world');
+
+            // Test case 3: Paste in the middle (space already exists before cursor)
+            result = simulatePaste('Hello world', 6, 6, 'PASTED');
+            expect(result).toBe('Hello PASTED world');
+
+            // Test case 4: Paste at the end
+            result = simulatePaste('Hello world', 11, 11, 'PASTED');
+            expect(result).toBe('Hello world PASTED');
+
+            // Test case 5: Paste with text selection (should replace selected text with spaces)
+            result = simulatePaste('Hello world', 6, 11, 'PASTED');
+            expect(result).toBe('Hello PASTED');
+
+            // Test case 6: Paste with empty string
+            result = simulatePaste('Hello world', 5, 5, '');
+            expect(result).toBe('Hello world');
+
+            // Test case 7: Paste into empty textarea
+            result = simulatePaste('', 0, 0, 'PASTED');
+            expect(result).toBe('PASTED');
+
+            // Test case 8: Paste with space in the pasted text
+            result = simulatePaste('Hello world', 5, 5, 'PASTED TEXT');
+            expect(result).toBe('Hello PASTED TEXT world');
+        })
+    );
+});

+ 1 - 1
src/types/plugins/chatview/message-form.d.ts

@@ -48,5 +48,5 @@ export default class MessageForm extends CustomElement {
     onFormSubmitted(ev: SubmitEvent | KeyboardEvent): Promise<void>;
 }
 export type EmojiDropdown = import("shared/chat/emoji-dropdown.js").default;
-import { CustomElement } from "shared/components/element.js";
+import { CustomElement } from 'shared/components/element.js';
 //# sourceMappingURL=message-form.d.ts.map