Просмотр исходного кода

Replace the chatbox textarea with a contenteditable div

JC Brand 6 лет назад
Родитель
Сommit
f5825f4579

+ 8 - 0
sass/_chatbox.scss

@@ -263,6 +263,14 @@
             }
 
             .chat-textarea {
+                &:empty:not(:focus):before {
+                    content: attr(data-placeholder);
+                    color: var(--subdued-color);
+                    font-style: italic;
+                }
+                &.disabled {
+                    background-color: var(--disabled-color);
+                }
                 color: var(--chat-textarea-color);
                 background-color: var(--chat-textarea-background-color);
                 border-top-left-radius: 0;

+ 1 - 0
sass/_variables.scss

@@ -2,6 +2,7 @@ $mobile_landscape_height: 450px !default;
 $mobile_portrait_length: 480px !default;
 
 #conversejs, #conversejs-bg, .converse-fullscreen {
+    --disabled-color: #EEE;
     --subdued-color: #A8ABA1;
 
     --green: #3AA569;

+ 41 - 36
spec/autocomplete.js

@@ -38,17 +38,18 @@
             });
 
             // Test that pressing @ brings up all options
-            const textarea = view.el.querySelector('textarea.chat-textarea');
+            const msg_compose_el = view.el.querySelector('.chat-textarea');
             const at_event = {
-                'target': textarea,
+                'target': msg_compose_el,
                 'preventDefault': _.noop,
                 'stopPropagation': _.noop,
                 'keyCode': 50,
                 'key': '@'
             };
-            view.keyPressed(at_event);
-            textarea.value = '@';
-            view.keyUp(at_event);
+            view.onKeyDown(at_event);
+            msg_compose_el.textContent = '@';
+            await u.placeCaret('end', msg_compose_el);
+            view.onKeyUp(at_event);
 
             expect(view.el.querySelectorAll('.suggestion-box__results li').length).toBe(3);
             expect(view.el.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick');
@@ -78,33 +79,34 @@
             _converse.connection._dataRecv(test_utils.createRequest(presence));
             expect(view.model.occupants.length).toBe(2);
 
-            const textarea = view.el.querySelector('textarea.chat-textarea');
-            textarea.value = "hello som";
+            const msg_compose_el = view.el.querySelector('.chat-textarea');
+            msg_compose_el.textContent = "hello som";
 
             // Press tab
             const tab_event = {
-                'target': textarea,
+                'target': msg_compose_el,
                 'preventDefault': _.noop,
                 'stopPropagation': _.noop,
                 'keyCode': 9,
                 'key': 'Tab'
             }
-            view.keyPressed(tab_event);
-            view.keyUp(tab_event);
+            await u.placeCaret('end', msg_compose_el);
+            view.onKeyDown(tab_event);
+            view.onKeyUp(tab_event);
             expect(view.el.querySelector('.suggestion-box__results').hidden).toBeFalsy();
             expect(view.el.querySelectorAll('.suggestion-box__results li').length).toBe(1);
             expect(view.el.querySelector('.suggestion-box__results li').textContent).toBe('some1');
 
             const backspace_event = {
-                'target': textarea,
+                'target': msg_compose_el,
                 'preventDefault': _.noop,
                 'keyCode': 8
             }
             for (var i=0; i<3; i++) {
                 // Press backspace 3 times to remove "som"
-                view.keyPressed(backspace_event);
-                textarea.value = textarea.value.slice(0, textarea.value.length-1)
-                view.keyUp(backspace_event);
+                view.onKeyDown(backspace_event);
+                msg_compose_el.textContent = msg_compose_el.textContent.slice(0, msg_compose_el.textContent.length-1)
+                view.onKeyUp(backspace_event);
             }
             expect(view.el.querySelector('.suggestion-box__results').hidden).toBeTruthy();
 
@@ -120,31 +122,32 @@
                 });
             _converse.connection._dataRecv(test_utils.createRequest(presence));
 
-            textarea.value = "hello s s";
-            view.keyPressed(tab_event);
-            view.keyUp(tab_event);
+            msg_compose_el.textContent = "hello s s";
+            await u.placeCaret('end', msg_compose_el);
+            view.onKeyDown(tab_event);
+            view.onKeyUp(tab_event);
             expect(view.el.querySelector('.suggestion-box__results').hidden).toBeFalsy();
             expect(view.el.querySelectorAll('.suggestion-box__results li').length).toBe(2);
 
             const up_arrow_event = {
-                'target': textarea,
+                'target': msg_compose_el,
                 'preventDefault': () => (up_arrow_event.defaultPrevented = true),
                 'stopPropagation': _.noop,
                 'keyCode': 38
             }
-            view.keyPressed(up_arrow_event);
-            view.keyUp(up_arrow_event);
+            view.onKeyDown(up_arrow_event);
+            view.onKeyUp(up_arrow_event);
             expect(view.el.querySelectorAll('.suggestion-box__results li').length).toBe(2);
             expect(view.el.querySelector('.suggestion-box__results li[aria-selected="false"]').textContent).toBe('some1');
             expect(view.el.querySelector('.suggestion-box__results li[aria-selected="true"]').textContent).toBe('some2');
 
-            view.keyPressed({
-                'target': textarea,
+            view.onKeyDown({
+                'target': msg_compose_el,
                 'preventDefault': _.noop,
                 'stopPropagation': _.noop,
                 'keyCode': 13 // Enter
             });
-            expect(textarea.value).toBe('hello s @some2 ');
+            expect(msg_compose_el.textContent).toBe('hello s @some2 ');
 
             // Test that pressing tab twice selects
             presence = $pres({
@@ -158,13 +161,14 @@
                     'role': 'participant'
                 });
             _converse.connection._dataRecv(test_utils.createRequest(presence));
-            textarea.value = "hello z";
-            view.keyPressed(tab_event);
-            view.keyUp(tab_event);
-
-            view.keyPressed(tab_event);
-            view.keyUp(tab_event);
-            expect(textarea.value).toBe('hello @z3r0 ');
+            msg_compose_el.textContent = "hello z";
+            await u.placeCaret('end', msg_compose_el);
+            view.onKeyDown(tab_event);
+            view.onKeyUp(tab_event);
+
+            view.onKeyDown(tab_event);
+            view.onKeyUp(tab_event);
+            expect(msg_compose_el.textContent).toBe('hello @z3r0 ');
             done();
         }));
 
@@ -189,20 +193,21 @@
             _converse.connection._dataRecv(test_utils.createRequest(presence));
             expect(view.model.occupants.length).toBe(2);
 
-            const textarea = view.el.querySelector('textarea.chat-textarea');
-            textarea.value = "hello @some1 ";
+            const msg_compose_el = view.el.querySelector('.chat-textarea');
+            msg_compose_el.textContent = "hello @some1 ";
 
             // Press backspace
             const backspace_event = {
-                'target': textarea,
+                'target': msg_compose_el,
                 'preventDefault': _.noop,
                 'stopPropagation': _.noop,
                 'keyCode': 8,
                 'key': 'Backspace'
             }
-            view.keyPressed(backspace_event);
-            textarea.value = "hello @some1"; // Mimic backspace
-            view.keyUp(backspace_event);
+            msg_compose_el.textContent = "hello @some1"; // Mimic backspace
+            await u.placeCaret('end', msg_compose_el);
+            view.onKeyDown(backspace_event);
+            view.onKeyUp(backspace_event);
             expect(view.el.querySelector('.suggestion-box__results').hidden).toBeFalsy();
             expect(view.el.querySelectorAll('.suggestion-box__results li').length).toBe(1);
             expect(view.el.querySelector('.suggestion-box__results li').textContent).toBe('some1');

+ 16 - 16
spec/chatbox.js

@@ -470,7 +470,7 @@
                     var items = picker.querySelectorAll('.emoji-picker li');
                     items[0].click()
                     expect(view.insertEmoji).toHaveBeenCalled();
-                    expect(view.el.querySelector('textarea.chat-textarea').value).toBe(':grinning: ');
+                    expect(view.el.querySelector('.chat-textarea').textContent).toBe(':grinning: ');
                     toolbar.querySelector('li.toggle-smiley').click(); // Close the panel again
                     done();
                 }));
@@ -638,8 +638,8 @@
                         expect(view.model.get('chat_state')).toBe('active');
                         spyOn(_converse.connection, 'send');
                         spyOn(_converse.api, "trigger");
-                        view.keyPressed({
-                            target: view.el.querySelector('textarea.chat-textarea'),
+                        view.onKeyDown({
+                            target: view.el.querySelector('.chat-textarea'),
                             keyCode: 1
                         });
                         expect(view.model.get('chat_state')).toBe('composing');
@@ -653,8 +653,8 @@
                         expect(stanza.childNodes[2].tagName).toBe('no-permanent-store');
 
                         // The notification is not sent again
-                        view.keyPressed({
-                            target: view.el.querySelector('textarea.chat-textarea'),
+                        view.onKeyDown({
+                            target: view.el.querySelector('.chat-textarea'),
                             keyCode: 1
                         });
                         expect(view.model.get('chat_state')).toBe('composing');
@@ -768,8 +768,8 @@
                         spyOn(_converse.connection, 'send');
                         spyOn(view, 'setChatState').and.callThrough();
                         expect(view.model.get('chat_state')).toBe('active');
-                        view.keyPressed({
-                            target: view.el.querySelector('textarea.chat-textarea'),
+                        view.onKeyDown({
+                            target: view.el.querySelector('.chat-textarea'),
                             keyCode: 1
                         });
                         expect(view.model.get('chat_state')).toBe('composing');
@@ -792,15 +792,15 @@
                         // Test #359. A paused notification should not be sent
                         // out if the user simply types longer than the
                         // timeout.
-                        view.keyPressed({
-                            target: view.el.querySelector('textarea.chat-textarea'),
+                        view.onKeyDown({
+                            target: view.el.querySelector('.chat-textarea'),
                             keyCode: 1
                         });
                         expect(view.setChatState).toHaveBeenCalled();
                         expect(view.model.get('chat_state')).toBe('composing');
 
-                        view.keyPressed({
-                            target: view.el.querySelector('textarea.chat-textarea'),
+                        view.onKeyDown({
+                            target: view.el.querySelector('.chat-textarea'),
                             keyCode: 1
                         });
                         expect(view.model.get('chat_state')).toBe('composing');
@@ -899,8 +899,8 @@
                         await test_utils.waitUntil(() => view.model.get('chat_state') === 'active', 1000);
                         console.log('chat_state set to active');
                         expect(view.model.get('chat_state')).toBe('active');
-                        view.keyPressed({
-                            target: view.el.querySelector('textarea.chat-textarea'),
+                        view.onKeyDown({
+                            target: view.el.querySelector('.chat-textarea'),
                             keyCode: 1
                         });
                         await test_utils.waitUntil(() => view.model.get('chat_state') === 'composing', 500);
@@ -1074,9 +1074,9 @@
                 spyOn(window, 'confirm').and.callFake(function () {
                     return true;
                 });
-                view.el.querySelector('.chat-textarea').value = message;
-                view.keyPressed({
-                    target: view.el.querySelector('textarea.chat-textarea'),
+                view.el.querySelector('.chat-textarea').textContent = message;
+                view.onKeyDown({
+                    target: view.el.querySelector('.chat-textarea'),
                     preventDefault: _.noop,
                     keyCode: 13
                 });

+ 90 - 93
spec/messages.js

@@ -22,10 +22,10 @@
             const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
             await test_utils.openChatBoxFor(_converse, contact_jid);
             const view = _converse.api.chatviews.get(contact_jid);
-            const textarea = view.el.querySelector('textarea.chat-textarea');
+            const textarea = view.el.querySelector('.chat-textarea');
 
-            textarea.value = 'But soft, what light through yonder airlock breaks?';
-            view.keyPressed({
+            textarea.textContent = 'But soft, what light through yonder airlock breaks?';
+            view.onKeyDown({
                 target: textarea,
                 preventDefault: _.noop,
                 keyCode: 13 // Enter
@@ -35,7 +35,7 @@
             expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
             expect(view.el.querySelector('.chat-msg__text').textContent)
                 .toBe('But soft, what light through yonder airlock breaks?');
-            expect(textarea.value).toBe('');
+            expect(textarea.textContent).toBe('');
 
             const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
             expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(1);
@@ -45,15 +45,15 @@
             action.style.opacity = 1;
             action.click();
 
-            expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
+            expect(textarea.textContent).toBe('But soft, what light through yonder airlock breaks?');
             expect(view.model.messages.at(0).get('correcting')).toBe(true);
             expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
             await new Promise((resolve, reject) => view.model.messages.once('rendered', resolve));
             expect(u.hasClass('correcting', view.el.querySelector('.chat-msg'))).toBe(true);
 
             spyOn(_converse.connection, 'send');
-            textarea.value = 'But soft, what light through yonder window breaks?';
-            view.keyPressed({
+            textarea.textContent = 'But soft, what light through yonder window breaks?';
+            view.onKeyDown({
                 target: textarea,
                 preventDefault: _.noop,
                 keyCode: 13 // Enter
@@ -91,7 +91,7 @@
             action.click();
             await new Promise((resolve, reject) => view.model.messages.once('rendered', resolve));
 
-            expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+            expect(textarea.textContent).toBe('But soft, what light through yonder window breaks?');
             expect(view.model.messages.at(0).get('correcting')).toBe(true);
             expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
             await test_utils.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')) === true);
@@ -99,7 +99,7 @@
             action = view.el.querySelector('.chat-msg .chat-msg__action');
             action.style.opacity = 1;
             action.click();
-            expect(textarea.value).toBe('');
+            expect(textarea.textContent).toBe('');
             expect(view.model.messages.at(0).get('correcting')).toBe(false);
             expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
             await test_utils.waitUntil(() => (u.hasClass('correcting', view.el.querySelector('.chat-msg')) === false), 500);
@@ -129,17 +129,17 @@
             const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
             await test_utils.openChatBoxFor(_converse, contact_jid)
             const view = _converse.chatboxviews.get(contact_jid);
-            const textarea = view.el.querySelector('textarea.chat-textarea');
-            expect(textarea.value).toBe('');
-            view.keyPressed({
-                target: textarea,
+            const msg_compose_el = view.el.querySelector('.chat-textarea');
+            expect(msg_compose_el.textContent).toBe('');
+            view.onKeyDown({
+                target: msg_compose_el,
                 keyCode: 38 // Up arrow
             });
-            expect(textarea.value).toBe('');
+            expect(msg_compose_el.textContent).toBe('');
 
-            textarea.value = 'But soft, what light through yonder airlock breaks?';
-            view.keyPressed({
-                target: textarea,
+            msg_compose_el.textContent = 'But soft, what light through yonder airlock breaks?';
+            view.onKeyDown({
+                target: msg_compose_el,
                 preventDefault: _.noop,
                 keyCode: 13 // Enter
             });
@@ -149,20 +149,20 @@
                 .toBe('But soft, what light through yonder airlock breaks?');
 
             const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
-            expect(textarea.value).toBe('');
-            view.keyPressed({
-                target: textarea,
+            expect(msg_compose_el.textContent).toBe('');
+            view.onKeyDown({
+                target: msg_compose_el,
                 keyCode: 38 // Up arrow
             });
-            expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
+            expect(msg_compose_el.textContent).toBe('But soft, what light through yonder airlock breaks?');
             expect(view.model.messages.at(0).get('correcting')).toBe(true);
             expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
             await test_utils.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')), 500);
 
             spyOn(_converse.connection, 'send');
-            textarea.value = 'But soft, what light through yonder window breaks?';
-            view.keyPressed({
-                target: textarea,
+            msg_compose_el.textContent = 'But soft, what light through yonder window breaks?';
+            view.onKeyDown({
+                target: msg_compose_el,
                 preventDefault: _.noop,
                 keyCode: 13 // Enter
             });
@@ -194,74 +194,71 @@
             await test_utils.waitUntil(() => (u.hasClass('correcting', view.el.querySelector('.chat-msg')) === false), 500);
 
             // Test that pressing the down arrow cancels message correction
-            expect(textarea.value).toBe('');
-            view.keyPressed({
-                target: textarea,
+            expect(msg_compose_el.textContent).toBe('');
+            view.onKeyDown({
+                target: msg_compose_el,
                 keyCode: 38 // Up arrow
             });
-            expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+            expect(msg_compose_el.textContent).toBe('But soft, what light through yonder window breaks?');
             expect(view.model.messages.at(0).get('correcting')).toBe(true);
             expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
             await test_utils.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')), 500);
-            expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
-            view.keyPressed({
-                target: textarea,
+            expect(msg_compose_el.textContent).toBe('But soft, what light through yonder window breaks?');
+            view.onKeyDown({
+                target: msg_compose_el,
                 keyCode: 40 // Down arrow
             });
-            expect(textarea.value).toBe('');
+            expect(msg_compose_el.textContent).toBe('');
             expect(view.model.messages.at(0).get('correcting')).toBe(false);
             expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
             await test_utils.waitUntil(() => (u.hasClass('correcting', view.el.querySelector('.chat-msg')) === false), 500);
 
-            textarea.value = 'It is the east, and Juliet is the one.';
-            view.keyPressed({
-                target: textarea,
+            msg_compose_el.textContent = 'It is the east, and Juliet is the one.';
+            view.onKeyDown({
+                target: msg_compose_el,
                 preventDefault: _.noop,
                 keyCode: 13 // Enter
             });
             await new Promise((resolve, reject) => view.once('messageInserted', resolve));
             expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
 
-            textarea.value =  'Arise, fair sun, and kill the envious moon';
-            view.keyPressed({
-                target: textarea,
+            msg_compose_el.textContent =  'Arise, fair sun, and kill the envious moon';
+            view.onKeyDown({
+                target: msg_compose_el,
                 preventDefault: _.noop,
                 keyCode: 13 // Enter
             });
             await new Promise((resolve, reject) => view.once('messageInserted', resolve));
             expect(view.el.querySelectorAll('.chat-msg').length).toBe(3);
 
-            view.keyPressed({
-                target: textarea,
+            view.onKeyDown({
+                target: msg_compose_el,
                 keyCode: 38 // Up arrow
             });
-            expect(textarea.value).toBe('Arise, fair sun, and kill the envious moon');
+            expect(msg_compose_el.textContent).toBe('Arise, fair sun, and kill the envious moon');
             expect(view.model.messages.at(0).get('correcting')).toBeFalsy();
             expect(view.model.messages.at(1).get('correcting')).toBeFalsy();
             expect(view.model.messages.at(2).get('correcting')).toBe(true);
             await test_utils.waitUntil(() => u.hasClass('correcting', sizzle('.chat-msg:last', view.el).pop()), 500);
-
-            textarea.selectionEnd = 0; // Happens by pressing up,
-                                    // but for some reason not in tests, so we set it manually.
-            view.keyPressed({
-                target: textarea,
-                keyCode: 38 // Up arrow
-            });
-            expect(textarea.value).toBe('It is the east, and Juliet is the one.');
+            await u.placeCaret('end', msg_compose_el);
+            const ev = {'target': msg_compose_el, 'keyCode': 38}; // up arrow
+            await u.placeCaret('start', msg_compose_el);
+            view.onKeyDown(ev);
+            expect(msg_compose_el.textContent).toBe('It is the east, and Juliet is the one.');
             expect(view.model.messages.at(0).get('correcting')).toBeFalsy();
             expect(view.model.messages.at(1).get('correcting')).toBe(true);
             expect(view.model.messages.at(2).get('correcting')).toBeFalsy();
             await test_utils.waitUntil(() => u.hasClass('correcting', sizzle('.chat-msg', view.el)[1]), 500);
 
-            textarea.value = 'It is the east, and Juliet is the sun.';
-            view.keyPressed({
-                target: textarea,
+            msg_compose_el.textContent = 'It is the east, and Juliet is the sun.';
+            view.onKeyDown({
+                target: msg_compose_el,
                 preventDefault: _.noop,
                 keyCode: 13 // Enter
             });
             await new Promise((resolve, reject) => view.model.messages.once('rendered', resolve));
 
-            expect(textarea.value).toBe('');
+            expect(msg_compose_el.textContent).toBe('');
             const messages = view.el.querySelectorAll('.chat-msg');
             expect(messages.length).toBe(3);
             expect(messages[0].querySelector('.chat-msg__text').textContent)
@@ -1328,9 +1325,9 @@
             const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
             await test_utils.openChatBoxFor(_converse, contact_jid);
             const view = _converse.chatboxviews.get(contact_jid);
-            const textarea = view.el.querySelector('textarea.chat-textarea');
-            textarea.value = 'But soft, what light through yonder airlock breaks?';
-            view.keyPressed({
+            const textarea = view.el.querySelector('.chat-textarea');
+            textarea.textContent = 'But soft, what light through yonder airlock breaks?';
+            view.onKeyDown({
                 target: textarea,
                 preventDefault: _.noop,
                 keyCode: 13 // Enter
@@ -1352,8 +1349,8 @@
 
             // Also handle receipts with type 'chat'. See #1353
             spyOn(_converse.chatboxes, 'onMessage').and.callThrough();
-            textarea.value = 'Another message';
-            view.keyPressed({
+            textarea.textContent = 'Another message';
+            view.onKeyDown({
                 target: textarea,
                 preventDefault: _.noop,
                 keyCode: 13 // Enter
@@ -2449,16 +2446,16 @@
             const room_jid = 'lounge@localhost';
             const room = _converse.api.rooms.get(room_jid);
             const view = _converse.api.chatviews.get(room_jid);
-            const textarea = view.el.querySelector('textarea.chat-textarea');
-            expect(textarea.value).toBe('');
-            view.keyPressed({
+            const textarea = view.el.querySelector('.chat-textarea');
+            expect(textarea.textContent).toBe('');
+            view.onKeyDown({
                 target: textarea,
                 keyCode: 38 // Up arrow
             });
-            expect(textarea.value).toBe('');
+            expect(textarea.textContent).toBe('');
 
-            textarea.value = 'But soft, what light through yonder airlock breaks?';
-            view.keyPressed({
+            textarea.textContent = 'But soft, what light through yonder airlock breaks?';
+            view.onKeyDown({
                 target: textarea,
                 preventDefault: _.noop,
                 keyCode: 13 // Enter
@@ -2469,20 +2466,20 @@
                 .toBe('But soft, what light through yonder airlock breaks?');
 
             const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
-            expect(textarea.value).toBe('');
-            view.keyPressed({
+            expect(textarea.textContent).toBe('');
+            view.onKeyDown({
                 target: textarea,
                 keyCode: 38 // Up arrow
             });
             await new Promise((resolve, reject) => view.model.messages.once('rendered', resolve));
-            expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
+            expect(textarea.textContent).toBe('But soft, what light through yonder airlock breaks?');
             expect(view.model.messages.at(0).get('correcting')).toBe(true);
             expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
             expect(u.hasClass('correcting', view.el.querySelector('.chat-msg'))).toBe(true);
 
             spyOn(_converse.connection, 'send');
-            textarea.value = 'But soft, what light through yonder window breaks?';
-            view.keyPressed({
+            textarea.textContent = 'But soft, what light through yonder window breaks?';
+            view.onKeyDown({
                 target: textarea,
                 preventDefault: _.noop,
                 keyCode: 13 // Enter
@@ -2525,21 +2522,21 @@
             expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
 
             // Test that pressing the down arrow cancels message correction
-            expect(textarea.value).toBe('');
-            view.keyPressed({
+            expect(textarea.textContent).toBe('');
+            view.onKeyDown({
                 target: textarea,
                 keyCode: 38 // Up arrow
             });
-            expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+            expect(textarea.textContent).toBe('But soft, what light through yonder window breaks?');
             expect(view.model.messages.at(0).get('correcting')).toBe(true);
             expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
             await test_utils.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')), 500);
-            expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
-            view.keyPressed({
+            expect(textarea.textContent).toBe('But soft, what light through yonder window breaks?');
+            view.onKeyDown({
                 target: textarea,
                 keyCode: 40 // Down arrow
             });
-            expect(textarea.value).toBe('');
+            expect(textarea.textContent).toBe('');
             expect(view.model.messages.at(0).get('correcting')).toBe(false);
             expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
             await test_utils.waitUntil(() => !u.hasClass('correcting', view.el.querySelector('.chat-msg')), 500);
@@ -2554,9 +2551,9 @@
             await test_utils.waitForRoster(_converse, 'current');
             await test_utils.openAndEnterChatRoom(_converse, 'lounge', 'localhost', 'dummy');
             const view = _converse.chatboxviews.get('lounge@localhost');
-            const textarea = view.el.querySelector('textarea.chat-textarea');
-            textarea.value = 'But soft, what light through yonder airlock breaks?';
-            view.keyPressed({
+            const textarea = view.el.querySelector('.chat-textarea');
+            textarea.textContent = 'But soft, what light through yonder airlock breaks?';
+            view.onKeyDown({
                 target: textarea,
                 preventDefault: _.noop,
                 keyCode: 13 // Enter
@@ -2628,9 +2625,9 @@
             await test_utils.waitForRoster(_converse, 'current');
             await test_utils.openAndEnterChatRoom(_converse, 'lounge', 'localhost', 'dummy');
             const view = _converse.chatboxviews.get('lounge@localhost');
-            const textarea = view.el.querySelector('textarea.chat-textarea');
-            textarea.value = 'But soft, what light through yonder airlock breaks?';
-            view.keyPressed({
+            const textarea = view.el.querySelector('.chat-textarea');
+            textarea.textContent = 'But soft, what light through yonder airlock breaks?';
+            view.onKeyDown({
                 target: textarea,
                 preventDefault: _.noop,
                 keyCode: 13 // Enter
@@ -2663,9 +2660,9 @@
             await test_utils.waitForRoster(_converse, 'current');
             await test_utils.openAndEnterChatRoom(_converse, 'lounge', 'localhost', 'dummy');
             const view = _converse.chatboxviews.get('lounge@localhost');
-            const textarea = view.el.querySelector('textarea.chat-textarea');
-            textarea.value = 'But soft, what light through yonder airlock breaks?';
-            view.keyPressed({
+            const textarea = view.el.querySelector('.chat-textarea');
+            textarea.textContent = 'But soft, what light through yonder airlock breaks?';
+            view.onKeyDown({
                 target: textarea,
                 preventDefault: _.noop,
                 keyCode: 13 // Enter
@@ -2904,8 +2901,8 @@
                         })));
                 });
 
-                const textarea = view.el.querySelector('textarea.chat-textarea');
-                textarea.value = 'hello @z3r0 @gibson @mr.robot, how are you?'
+                const textarea = view.el.querySelector('.chat-textarea');
+                textarea.textContent = 'hello @z3r0 @gibson @mr.robot, how are you?'
                 const enter_event = {
                     'target': textarea,
                     'preventDefault': _.noop,
@@ -2913,7 +2910,7 @@
                     'keyCode': 13 // Enter
                 }
                 spyOn(_converse.connection, 'send');
-                view.keyPressed(enter_event);
+                view.onKeyDown(enter_event);
                 await new Promise((resolve, reject) => view.once('messageInserted', resolve));
                 const msg = _converse.connection.send.calls.all()[0].args[0];
                 expect(msg.toLocaleString())
@@ -2933,13 +2930,13 @@
                 action.style.opacity = 1;
                 action.click();
 
-                expect(textarea.value).toBe('hello @z3r0 @gibson @mr.robot, how are you?');
+                expect(textarea.textContent).toBe('hello @z3r0 @gibson @mr.robot, how are you?');
                 expect(view.model.messages.at(0).get('correcting')).toBe(true);
                 expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
                 await test_utils.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')), 500);
 
-                textarea.value = 'hello @z3r0 @gibson @sw0rdf1sh, how are you?';
-                view.keyPressed(enter_event);
+                textarea.textContent = 'hello @z3r0 @gibson @sw0rdf1sh, how are you?';
+                view.onKeyDown(enter_event);
                 await test_utils.waitUntil(() => view.el.querySelector('.chat-msg__text').textContent ===
                     'hello z3r0 gibson sw0rdf1sh, how are you?', 500);
 
@@ -2981,15 +2978,15 @@
                 });
 
                 spyOn(_converse.connection, 'send');
-                const textarea = view.el.querySelector('textarea.chat-textarea');
-                textarea.value = 'hello @z3r0 @gibson @mr.robot, how are you?'
+                const textarea = view.el.querySelector('.chat-textarea');
+                textarea.textContent = 'hello @z3r0 @gibson @mr.robot, how are you?'
                 const enter_event = {
                     'target': textarea,
                     'preventDefault': _.noop,
                     'stopPropagation': _.noop,
                     'keyCode': 13 // Enter
                 }
-                view.keyPressed(enter_event);
+                view.onKeyDown(enter_event);
 
                 const msg = _converse.connection.send.calls.all()[0].args[0];
                 expect(msg.toLocaleString())

+ 54 - 54
spec/muc.js

@@ -1961,8 +1961,8 @@
                 }
                 const text = 'This is a sent message';
                 const textarea = view.el.querySelector('.chat-textarea');
-                textarea.value = text;
-                view.keyPressed({
+                textarea.textContent = text;
+                view.onKeyDown({
                     target: textarea,
                     preventDefault: _.noop,
                     keyCode: 13
@@ -2658,12 +2658,12 @@
                 await test_utils.openAndEnterChatRoom(_converse, 'lounge', 'localhost', 'dummy');
                 const view = _converse.chatboxviews.get('lounge@localhost');
                 const textarea = view.el.querySelector('.chat-textarea');
-                textarea.value = '/clear';
+                textarea.textContent = '/clear';
 
                 const enter = { 'target': textarea, 'preventDefault': _.noop, 'keyCode': 13 };
-                view.keyPressed(enter);
-                textarea.value = '/help';
-                view.keyPressed(enter);
+                view.onKeyDown(enter);
+                textarea.textContent = '/help';
+                view.onKeyDown(enter);
 
                 let info_messages = Array.prototype.slice.call(view.el.querySelectorAll('.chat-info'), 0);
                 expect(info_messages.length).toBe(19);
@@ -2689,10 +2689,10 @@
 
                 const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid});
                 occupant.set('affiliation', 'admin');
-                textarea.value = '/clear';
-                view.keyPressed(enter);
-                textarea.value = '/help';
-                view.keyPressed(enter);
+                textarea.textContent = '/clear';
+                view.onKeyDown(enter);
+                textarea.textContent = '/help';
+                view.onKeyDown(enter);
                 info_messages = sizzle('.chat-info', view.el).slice(1);
                 expect(info_messages.length).toBe(17);
                 let commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
@@ -2702,20 +2702,20 @@
                     "/op", "/register", "/revoke", "/subject", "/topic", "/voice"
                 ]);
                 occupant.set('affiliation', 'member');
-                textarea.value = '/clear';
-                view.keyPressed(enter);
-                textarea.value = '/help';
-                view.keyPressed(enter);
+                textarea.textContent = '/clear';
+                view.onKeyDown(enter);
+                textarea.textContent = '/help';
+                view.onKeyDown(enter);
                 info_messages = sizzle('.chat-info', view.el).slice(1);
                 expect(info_messages.length).toBe(10);
                 commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
                 expect(commands).toEqual(["/clear", "/help", "/kick", "/me", "/mute", "/nick", "/register", "/subject", "/topic", "/voice"]);
 
                 occupant.set('role', 'participant');
-                textarea.value = '/clear';
-                view.keyPressed(enter);
-                textarea.value = '/help';
-                view.keyPressed(enter);
+                textarea.textContent = '/clear';
+                view.onKeyDown(enter);
+                textarea.textContent = '/help';
+                view.onKeyDown(enter);
                 info_messages = sizzle('.chat-info', view.el).slice(1);
                 expect(info_messages.length).toBe(7);
                 commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
@@ -2733,10 +2733,10 @@
                 var textarea = view.el.querySelector('.chat-textarea');
                 const enter = { 'target': textarea, 'preventDefault': _.noop, 'keyCode': 13 };
                 spyOn(window, 'confirm').and.callFake(() => true);
-                textarea.value = '/clear';
-                view.keyPressed(enter);
-                textarea.value = '/help';
-                view.keyPressed(enter);
+                textarea.textContent = '/clear';
+                view.onKeyDown(enter);
+                textarea.textContent = '/help';
+                view.onKeyDown(enter);
 
                 const info_messages = Array.prototype.slice.call(view.el.querySelectorAll('.chat-info'), 0);
                 expect(info_messages.length).toBe(17);
@@ -2793,8 +2793,8 @@
 
                 // First check that an error message appears when a
                 // non-existent nick is used.
-                textarea.value = '/member chris Welcome to the club!';
-                view.keyPressed({
+                textarea.textContent = '/member chris Welcome to the club!';
+                view.onKeyDown({
                     target: textarea,
                     preventDefault: _.noop,
                     keyCode: 13
@@ -2805,8 +2805,8 @@
                     .toBe(`Error: couldn't find a groupchat participant "chris"`)
 
                 // Now test with an existing nick
-                textarea.value = '/member marc Welcome to the club!';
-                view.keyPressed({
+                textarea.textContent = '/member marc Welcome to the club!';
+                view.onKeyDown({
                     target: textarea,
                     preventDefault: _.noop,
                     keyCode: 13
@@ -2920,8 +2920,8 @@
                 });
                 // Check the alias /topic
                 const textarea = view.el.querySelector('.chat-textarea');
-                textarea.value = '/topic This is the groupchat subject';
-                view.keyPressed({
+                textarea.textContent = '/topic This is the groupchat subject';
+                view.onKeyDown({
                     target: textarea,
                     preventDefault: _.noop,
                     keyCode: 13
@@ -2930,8 +2930,8 @@
                 expect(sent_stanza.textContent).toBe('This is the groupchat subject');
 
                 // Check /subject
-                textarea.value = '/subject This is a new subject';
-                view.keyPressed({
+                textarea.textContent = '/subject This is a new subject';
+                view.onKeyDown({
                     target: textarea,
                     preventDefault: _.noop,
                     keyCode: 13
@@ -2944,8 +2944,8 @@
                     '</message>');
 
                 // Check case insensitivity
-                textarea.value = '/Subject This is yet another subject';
-                view.keyPressed({
+                textarea.textContent = '/Subject This is yet another subject';
+                view.onKeyDown({
                     target: textarea,
                     preventDefault: _.noop,
                     keyCode: 13
@@ -2967,8 +2967,8 @@
                 const view = _converse.chatboxviews.get('lounge@localhost');
                 spyOn(view, 'clearMessages');
                 const textarea = view.el.querySelector('.chat-textarea')
-                textarea.value = '/clear';
-                view.keyPressed({
+                textarea.textContent = '/clear';
+                view.onKeyDown({
                     target: textarea,
                     preventDefault: _.noop,
                     keyCode: 13
@@ -3009,8 +3009,8 @@
                 _converse.connection._dataRecv(test_utils.createRequest(presence));
 
                 var textarea = view.el.querySelector('.chat-textarea')
-                textarea.value = '/owner';
-                view.keyPressed({
+                textarea.textContent = '/owner';
+                view.onKeyDown({
                     target: textarea,
                     preventDefault: _.noop,
                     keyCode: 13
@@ -3022,7 +3022,7 @@
                 // XXX: Calling onFormSubmitted directly, trying
                 // again via triggering Event doesn't work for some weird
                 // reason.
-                textarea.value = '/owner nobody You\'re responsible';
+                textarea.textContent = '/owner nobody You\'re responsible';
                 view.onFormSubmitted(new Event('submit'));
 
                 expect(view.showErrorMessage).toHaveBeenCalledWith(
@@ -3033,7 +3033,7 @@
                 // XXX: Calling onFormSubmitted directly, trying
                 // again via triggering Event doesn't work for some weird
                 // reason.
-                textarea.value = '/owner annoyingGuy You\'re responsible';
+                textarea.textContent = '/owner annoyingGuy You\'re responsible';
                 view.onFormSubmitted(new Event('submit'));
 
                 expect(view.validateRoleChangeCommand.calls.count()).toBe(3);
@@ -3097,8 +3097,8 @@
                 _converse.connection._dataRecv(test_utils.createRequest(presence));
 
                 const textarea = view.el.querySelector('.chat-textarea')
-                textarea.value = '/ban';
-                view.keyPressed({
+                textarea.textContent = '/ban';
+                view.onKeyDown({
                     target: textarea,
                     preventDefault: _.noop,
                     keyCode: 13
@@ -3111,7 +3111,7 @@
                 // XXX: Calling onFormSubmitted directly, trying
                 // again via triggering Event doesn't work for some weird
                 // reason.
-                textarea.value = '/ban annoyingGuy You\'re annoying';
+                textarea.textContent = '/ban annoyingGuy You\'re annoying';
                 view.onFormSubmitted(new Event('submit'));
 
                 expect(view.validateRoleChangeCommand.calls.count()).toBe(2);
@@ -3177,8 +3177,8 @@
                 _converse.connection._dataRecv(test_utils.createRequest(presence));
 
                 var textarea = view.el.querySelector('.chat-textarea')
-                textarea.value = '/kick';
-                view.keyPressed({
+                textarea.textContent = '/kick';
+                view.onKeyDown({
                     target: textarea,
                     preventDefault: _.noop,
                     keyCode: 13
@@ -3191,7 +3191,7 @@
                 // XXX: Calling onFormSubmitted directly, trying
                 // again via triggering Event doesn't work for some weird
                 // reason.
-                textarea.value = '/kick annoyingGuy You\'re annoying';
+                textarea.textContent = '/kick annoyingGuy You\'re annoying';
                 view.onFormSubmitted(new Event('submit'));
 
                 expect(view.validateRoleChangeCommand.calls.count()).toBe(2);
@@ -3278,8 +3278,8 @@
                 expect(info_msgs.pop().textContent).toBe("trustworthyguy has entered the groupchat");
 
                 var textarea = view.el.querySelector('.chat-textarea')
-                textarea.value = '/op';
-                view.keyPressed({
+                textarea.textContent = '/op';
+                view.onKeyDown({
                     target: textarea,
                     preventDefault: _.noop,
                     keyCode: 13
@@ -3294,7 +3294,7 @@
                 // XXX: Calling onFormSubmitted directly, trying
                 // again via triggering Event doesn't work for some weird
                 // reason.
-                textarea.value = '/op trustworthyguy You\'re trustworthy';
+                textarea.textContent = '/op trustworthyguy You\'re trustworthy';
                 view.onFormSubmitted(new Event('submit'));
 
                 expect(view.validateRoleChangeCommand.calls.count()).toBe(2);
@@ -3336,7 +3336,7 @@
                 // XXX: Calling onFormSubmitted directly, trying
                 // again via triggering Event doesn't work for some weird
                 // reason.
-                textarea.value = '/deop trustworthyguy Perhaps not';
+                textarea.textContent = '/deop trustworthyguy Perhaps not';
                 view.onFormSubmitted(new Event('submit'));
 
                 expect(view.validateRoleChangeCommand.calls.count()).toBe(3);
@@ -3420,8 +3420,8 @@
                 expect(info_msgs.pop().textContent).toBe("annoyingGuy has entered the groupchat");
 
                 const textarea = view.el.querySelector('.chat-textarea')
-                textarea.value = '/mute';
-                view.keyPressed({
+                textarea.textContent = '/mute';
+                view.onKeyDown({
                     target: textarea,
                     preventDefault: _.noop,
                     keyCode: 13
@@ -3435,7 +3435,7 @@
                 // XXX: Calling onFormSubmitted directly, trying
                 // again via triggering Event doesn't work for some weird
                 // reason.
-                textarea.value = '/mute annoyingGuy You\'re annoying';
+                textarea.textContent = '/mute annoyingGuy You\'re annoying';
                 view.onFormSubmitted(new Event('submit'));
 
                 expect(view.validateRoleChangeCommand.calls.count()).toBe(2);
@@ -3478,7 +3478,7 @@
                 // XXX: Calling onFormSubmitted directly, trying
                 // again via triggering Event doesn't work for some weird
                 // reason.
-                textarea.value = '/voice annoyingGuy Now you can talk again';
+                textarea.textContent = '/voice annoyingGuy Now you can talk again';
                 view.onFormSubmitted(new Event('submit'));
 
                 expect(view.validateRoleChangeCommand.calls.count()).toBe(3);
@@ -3533,7 +3533,7 @@
                 });
                 const view = _converse.chatboxviews.get('lounge@localhost');
                 const textarea = view.el.querySelector('.chat-textarea');
-                textarea.value = '/destroy bored';
+                textarea.textContent = '/destroy bored';
                 view.onFormSubmitted(new Event('submit'));
                 expect(sent_IQ.toLocaleString()).toBe(
                     `<iq id="${IQ_id}" to="lounge@localhost" type="set" xmlns="jabber:client">`+
@@ -4781,7 +4781,7 @@
                 await test_utils.openAndEnterChatRoom(_converse, 'trollbox', 'localhost', 'troll');
                 const view = _converse.chatboxviews.get('trollbox@localhost');
                 const textarea = view.el.querySelector('.chat-textarea');
-                textarea.value = 'Hello world';
+                textarea.textContent = 'Hello world';
                 view.onFormSubmitted(new Event('submit'));
 
                 const stanza = u.toStanza(`

+ 11 - 11
spec/omemo.js

@@ -123,8 +123,8 @@
             view.model.set('omemo_active', true);
 
             const textarea = view.el.querySelector('.chat-textarea');
-            textarea.value = 'This message will be encrypted';
-            view.keyPressed({
+            textarea.textContent = 'This message will be encrypted';
+            view.onKeyDown({
                 target: textarea,
                 preventDefault: _.noop,
                 keyCode: 13 // Enter
@@ -308,8 +308,8 @@
             expect(u.hasClass('fa-lock', toggle)).toBe(true);
 
             const textarea = view.el.querySelector('.chat-textarea');
-            textarea.value = 'This message will be encrypted';
-            view.keyPressed({
+            textarea.textContent = 'This message will be encrypted';
+            view.onKeyDown({
                 target: textarea,
                 preventDefault: _.noop,
                 keyCode: 13 // Enter
@@ -457,8 +457,8 @@
             expect(devicelist.devices.get('988349631').get('active')).toBe(true);
 
             const textarea = view.el.querySelector('.chat-textarea');
-            textarea.value = 'This is an encrypted message from this device';
-            view.keyPressed({
+            textarea.textContent = 'This is an encrypted message from this device';
+            view.onKeyDown({
                 target: textarea,
                 preventDefault: _.noop,
                 keyCode: 13 // Enter
@@ -514,8 +514,8 @@
             expect(view.model.get('omemo_supported')).toBe(true);
 
             const textarea = view.el.querySelector('.chat-textarea');
-            textarea.value = 'This message will be encrypted';
-            view.keyPressed({
+            textarea.textContent = 'This message will be encrypted';
+            view.onKeyDown({
                 target: textarea,
                 preventDefault: _.noop,
                 keyCode: 13 // Enter
@@ -598,7 +598,7 @@
                       "to be subscribed to their presence in order to see their OMEMO information");
 
             expect(view.model.get('omemo_supported')).toBe(false);
-            expect(view.el.querySelector('.chat-textarea').value).toBe('This message will be encrypted');
+            expect(view.el.querySelector('.chat-textarea').textContent).toBe('This message will be encrypted');
             done();
         }));
 
@@ -1250,8 +1250,8 @@
             expect(u.hasClass('fa-lock', toggle)).toBe(true);
 
             const textarea = view.el.querySelector('.chat-textarea');
-            textarea.value = 'This message will be sent encrypted';
-            view.keyPressed({
+            textarea.textContent = 'This message will be sent encrypted';
+            view.onKeyDown({
                 target: textarea,
                 preventDefault: _.noop,
                 keyCode: 13

+ 2 - 2
spec/room_registration.js

@@ -24,8 +24,8 @@
                 .then(() => {
                     view = _converse.chatboxviews.get(room_jid);
                     const textarea = view.el.querySelector('.chat-textarea')
-                    textarea.value = '/register';
-                    view.keyPressed({
+                    textarea.textContent = '/register';
+                    view.onKeyDown({
                         target: textarea,
                         preventDefault: _.noop,
                         keyCode: 13

+ 6 - 6
spec/spoilers.js

@@ -109,15 +109,15 @@
             spoiler_toggle.click();
 
             const textarea = view.el.querySelector('.chat-textarea');
-            textarea.value = 'This is the spoiler';
-            view.keyPressed({
+            textarea.textContent = 'This is the spoiler';
+            view.onKeyDown({
                 target: textarea,
                 preventDefault: _.noop,
                 keyCode: 13
             });
             await new Promise((resolve, reject) => view.once('messageInserted', resolve));
 
-            /* Test the XML stanza 
+            /* Test the XML stanza
              *
              * <message from="dummy@localhost/resource"
              *          to="max.frankfurter@localhost"
@@ -183,18 +183,18 @@
             spyOn(_converse.connection, 'send');
 
             const textarea = view.el.querySelector('.chat-textarea');
-            textarea.value = 'This is the spoiler';
+            textarea.textContent = 'This is the spoiler';
             const hint_input = view.el.querySelector('.spoiler-hint');
             hint_input.value = 'This is the hint';
 
-            view.keyPressed({
+            view.onKeyDown({
                 target: textarea,
                 preventDefault: _.noop,
                 keyCode: 13
             });
             await new Promise((resolve, reject) => view.once('messageInserted', resolve));
 
-            /* Test the XML stanza 
+            /* Test the XML stanza
              *
              * <message from="dummy@localhost/resource"
              *          to="max.frankfurter@localhost"

+ 16 - 10
src/converse-autocomplete.js

@@ -313,29 +313,35 @@ converse.plugins.add("converse-autocomplete", {
                     }
                     this.auto_completing = true;
                 } else if (ev.key === "Backspace") {
-                    const word = u.getCurrentWord(ev.target, ev.target.selectionEnd-1);
-                    if (this.ac_triggers.includes(word[0])) {
+                    const word = u.getCurrentWord(ev.target);
+                    if (word !== null && this.ac_triggers.includes(word[0])) {
                         this.auto_completing = true;
                     }
                 }
             }
 
             evaluate (ev) {
-                const selecting = this.selected && ev && (
-                    ev.keyCode === _converse.keycodes.UP_ARROW ||
-                    ev.keyCode === _converse.keycodes.DOWN_ARROW
-                );
-                if (!this.auto_evaluate && !this.auto_completing || selecting) {
+                const selecting = () => {
+                    return (
+                        this.selected && ev && (
+                        ev.keyCode === _converse.keycodes.UP_ARROW ||
+                        ev.keyCode === _converse.keycodes.DOWN_ARROW)
+                    );
+                }
+                if (!this.auto_evaluate && !this.auto_completing || selecting()) {
                     return;
                 }
-
                 const list = typeof this._list === "function" ? this._list() : this._list;
                 if (list.length === 0) {
                     return;
                 }
+                let value = this.match_current_word ?
+                    u.getCurrentWord(this.input) :
+                    u.getElementValue(this.input);
 
-                let value = this.match_current_word ? u.getCurrentWord(this.input) : this.input.value;
-
+                if (value === null) {
+                    return;
+                }
                 let ignore_min_chars = false;
                 if (this.ac_triggers.includes(value[0]) && !this.include_triggers.includes(ev.key)) {
                     ignore_min_chars = true;

+ 45 - 40
src/converse-chatview.js

@@ -412,14 +412,14 @@ converse.plugins.add('converse-chatview', {
                         'label_message': this.model.get('composing_spoiler') ? __('Hidden message') : __('Message'),
                         'label_send': __('Send'),
                         'label_spoiler_hint': __('Optional hint'),
-                        'message_value': _.get(this.el.querySelector('.chat-textarea'), 'value'),
+                        'message_value': _.get(this.el.querySelector('.chat-textarea'), 'textContent'),
                         'show_send_button': _converse.show_send_button,
                         'show_toolbar': _converse.show_toolbar,
                         'unread_msgs': __('You have unread messages')
                     }));
-                const textarea_el = this.el.querySelector('.chat-textarea');
-                textarea_el.addEventListener('focus', () => this.emitFocused());
-                textarea_el.addEventListener('blur', () => {
+                const msg_compose_el = this.el.querySelector('.chat-textarea');
+                msg_compose_el.addEventListener('focus', () => this.emitFocused());
+                msg_compose_el.addEventListener('blur', () => {
                     /**
                      * Triggered when the focus has been removed from a particular chat.
                      * @event _converse#chatBoxBlurred
@@ -836,7 +836,7 @@ converse.plugins.add('converse-chatview', {
                 }
                 await this.showMessage(message);
                 if (message.get('correcting')) {
-                    this.insertIntoTextArea(message.get('message'), true, true);
+                    this.insertIntoComposeArea(message.get('message'), true, true);
                 }
                 /**
                  * Triggered once a message has been added to a chatbox.
@@ -906,9 +906,8 @@ converse.plugins.add('converse-chatview', {
 
             async onFormSubmitted (ev) {
                 ev.preventDefault();
-                const textarea = this.el.querySelector('.chat-textarea'),
-                      message = textarea.value;
-
+                const msg_compose_el = this.el.querySelector('.chat-textarea');
+                const message = msg_compose_el.textContent;
                 if (!message.replace(/\s/g, '').length) {
                     return;
                 }
@@ -925,15 +924,14 @@ converse.plugins.add('converse-chatview', {
                     hint_el = this.el.querySelector('form.sendXMPPMessage input.spoiler-hint');
                     spoiler_hint = hint_el.value;
                 }
-                u.addClass('disabled', textarea);
-                textarea.setAttribute('disabled', 'disabled');
+                u.addClass('disabled', msg_compose_el);
+                msg_compose_el.setAttribute('contentEditable', 'false');
                 if (this.parseMessageForCommands(message) ||
                     await this.model.sendMessage(message, spoiler_hint)) {
 
                     hint_el.value = '';
-                    textarea.value = '';
-                    u.removeClass('correcting', textarea);
-                    textarea.style.height = 'auto'; // Fixes weirdness
+                    msg_compose_el.innerHTML = '';
+                    u.removeClass('correcting', msg_compose_el);
                     /**
                      * Triggered just before an HTML5 message notification will be sent out.
                      * @event _converse#messageSend
@@ -942,9 +940,9 @@ converse.plugins.add('converse-chatview', {
                      */
                     _converse.api.trigger('messageSend', message);
                 }
-                textarea.removeAttribute('disabled');
-                u.removeClass('disabled', textarea);
-                textarea.focus();
+                msg_compose_el.setAttribute('contentEditable', true);
+                u.removeClass('disabled', msg_compose_el);
+                msg_compose_el.focus();
                 // Suppress events, otherwise superfluous CSN gets set
                 // immediately after the message, causing rate-limiting issues.
                 this.setChatState(_converse.ACTIVE, {'silent': true});
@@ -957,6 +955,7 @@ converse.plugins.add('converse-chatview', {
                     // When ctrl is pressed, no chars are entered into the textarea.
                     return;
                 }
+
                 if (!ev.shiftKey && !ev.altKey) {
                     if (ev.keyCode === _converse.keycodes.FORWARD_SLASH) {
                         // Forward slash is used to run commands. Nothing to do here.
@@ -968,10 +967,18 @@ converse.plugins.add('converse-chatview', {
                             this.emoji_dropdown.toggle();
                         }
                         return this.onFormSubmitted(ev);
-                    } else if (ev.keyCode === _converse.keycodes.UP_ARROW && !ev.target.selectionEnd) {
-                        return this.editEarlierMessage();
-                    } else if (ev.keyCode === _converse.keycodes.DOWN_ARROW && ev.target.selectionEnd === ev.target.value.length) {
-                        return this.editLaterMessage();
+                    }
+                    const caret_position = u.getCaretPosition(ev.target);
+                    if (caret_position !== null) {
+                        const msg_compose_el = ev.target;
+                        if (ev.keyCode === _converse.keycodes.UP_ARROW && caret_position === 0) {
+                            return this.editEarlierMessage();
+                        } else if (
+                                ev.keyCode === _converse.keycodes.DOWN_ARROW &&
+                                caret_position === msg_compose_el.innerHTML.length
+                        ) {
+                            return this.editLaterMessage();
+                        }
                     }
                 }
                 if (_.includes([
@@ -1002,7 +1009,7 @@ converse.plugins.add('converse-chatview', {
                 if (message) {
                     message.save('correcting', false);
                 }
-                this.insertIntoTextArea('', true, false);
+                this.insertIntoComposeArea('', true, false);
             },
 
             onMessageEditButtonClicked (ev) {
@@ -1017,10 +1024,10 @@ converse.plugins.add('converse-chatview', {
                         currently_correcting.save('correcting', false);
                     }
                     message.save('correcting', true);
-                    this.insertIntoTextArea(u.prefixMentions(message), true, true);
+                    this.insertIntoComposeArea(u.prefixMentions(message), true, true);
                 } else {
                     message.save('correcting', false);
-                    this.insertIntoTextArea('', true, false);
+                    this.insertIntoComposeArea('', true, false);
                 }
             },
 
@@ -1039,10 +1046,10 @@ converse.plugins.add('converse-chatview', {
                     }
                 }
                 if (message) {
-                    this.insertIntoTextArea(message.get('message'), true, true);
+                    this.insertIntoComposeArea(message.get('message'), true, true);
                     message.save('correcting', true);
                 } else {
-                    this.insertIntoTextArea('', true, false);
+                    this.insertIntoComposeArea('', true, false);
                 }
             },
 
@@ -1062,7 +1069,7 @@ converse.plugins.add('converse-chatview', {
                 }
                 message = message || _.findLast(this.getOwnMessages(), msg => msg.get('message'));
                 if (message) {
-                    this.insertIntoTextArea(message.get('message'), true, true);
+                    this.insertIntoComposeArea(message.get('message'), true, true);
                     message.save('correcting', true);
                 }
             },
@@ -1084,25 +1091,23 @@ converse.plugins.add('converse-chatview', {
                 return this;
             },
 
-            insertIntoTextArea (value, replace=false, correcting=false) {
-                const textarea = this.el.querySelector('.chat-textarea');
+            insertIntoComposeArea (value, replace=false, correcting=false) {
+                const msg_compose_el = this.el.querySelector('.chat-textarea');
                 if (correcting) {
-                    u.addClass('correcting', textarea);
+                    u.addClass('correcting', msg_compose_el);
                 } else {
-                    u.removeClass('correcting', textarea);
+                    u.removeClass('correcting', msg_compose_el);
                 }
                 if (replace) {
-                    textarea.value = '';
-                    textarea.value = value;
+                    msg_compose_el.textContent = value;
                 } else {
-                    let existing = textarea.value;
+                    let existing = msg_compose_el.textContent;
                     if (existing && (existing[existing.length-1] !== ' ')) {
                         existing = existing + ' ';
                     }
-                    textarea.value = '';
-                    textarea.value = existing+value+' ';
+                    msg_compose_el.textContent = existing+value+' ';
                 }
-                u.putCurserAtEnd(textarea);
+                u.placeCaret('end', msg_compose_el);
             },
 
             createEmojiPicker () {
@@ -1122,7 +1127,7 @@ converse.plugins.add('converse-chatview', {
                 ev.preventDefault();
                 ev.stopPropagation();
                 const target = ev.target.nodeName === 'IMG' ? ev.target.parentElement : ev.target;
-                this.insertIntoTextArea(target.getAttribute('data-emoji'));
+                this.insertIntoComposeArea(target.getAttribute('data-emoji'));
             },
 
             toggleEmojiMenu (ev) {
@@ -1259,9 +1264,9 @@ converse.plugins.add('converse-chatview', {
             }, 25, {'leading': true}),
 
             focus () {
-                const textarea_el = this.el.querySelector('.chat-textarea');
-                if (!_.isNull(textarea_el)) {
-                    textarea_el.focus();
+                const msg_compose_el = this.el.querySelector('.chat-textarea');
+                if (!_.isNull(msg_compose_el)) {
+                    msg_compose_el.focus();
                     this.emitFocused();
                 }
                 return this;

+ 1 - 1
src/converse-muc-views.js

@@ -834,7 +834,7 @@ converse.plugins.add('converse-muc-views', {
                 /* When an occupant is clicked, insert their nickname into
                  * the chat textarea input.
                  */
-                this.insertIntoTextArea(ev.target.textContent);
+                this.insertIntoComposeArea(ev.target.textContent);
             },
 
             handleChatStateNotification (message) {

+ 77 - 23
src/headless/utils/core.js

@@ -340,19 +340,86 @@ u.siblingIndex = function (el) {
     return i;
 };
 
-u.getCurrentWord = function (input, index) {
-    if (!index) {
-        index = input.selectionEnd || undefined;
+
+function isInputElement (el) {
+    return ['TEXTAREA', 'INPUT'].includes(el.nodeName);
+}
+
+u.placeCaret = function (position, el) {
+    if (el !== document.activeElement) {
+        el.focus();
+    }
+    const promise = u.getResolveablePromise();
+    let setSelection;
+    if (isInputElement(el)) {
+        let len;
+        if (position === 'end') {
+            // Double the length because Opera is inconsistent
+            // about whether a carriage return is one character or two.
+            len = el.value.length * 2;
+        } else if (position === 'start') {
+            len = 0;
+        }
+        setSelection = () => el.setSelectionRange(len, len);
+    } else {
+        const range = document.createRange();
+        if (position === 'end') {
+            const last_node = Array.from(el.childNodes).pop() || el;
+            range.selectNodeContents(last_node);
+        } else if (position === 'start') {
+            const first_node = el.childNodes[0] || el;
+            range.setEnd(first_node, 0);
+        }
+        range.collapse(false);
+        setSelection = () => {
+            const selection = window.getSelection();
+            selection.removeAllRanges();
+            selection.addRange(range);
+        }
     }
-    return _.last(input.value.slice(0, index).split(' '));
+    // Scroll to the bottom, in case we're in a tall el
+    // (Necessary for Firefox and Chrome)
+    this.scrollTop = 999999;
+    setTimeout(() => {
+        setSelection();
+        promise.resolve();
+    }, 1);
+    return promise;
 };
 
-u.replaceCurrentWord = function (input, new_value) {
-    const cursor = input.selectionEnd || undefined,
-          current_word = _.last(input.value.slice(0, cursor).split(' ')),
-          value = input.value;
-    input.value = value.slice(0, cursor - current_word.length) + `${new_value} ` + value.slice(cursor);
-    input.selectionEnd = cursor - current_word.length + new_value.length + 1;
+u.getCaretPosition = function (el) {
+    if (['TEXTAREA', 'INPUT'].includes(el.nodeName)) {
+        return el.selectionEnd || null;
+    } else {
+        const selection = window.getSelection();
+        const caret_position = selection.anchorOffset;
+        if (el.contains(selection.anchorNode)) {
+            return selection.anchorOffset;
+        }
+    }
+    return null;
+};
+
+u.getElementValue = function (el) {
+    return isInputElement(el) ? el.value : el.textContent;
+}
+
+u.getCurrentWord = function (el) {
+    const index = u.getCaretPosition(el);
+    return index !== null ? _.last(u.getElementValue(el).slice(0, index).split(' ')) : null;
+};
+
+u.replaceCurrentWord = function (el, new_word) {
+    const caret = u.getCaretPosition(el);
+    const value = u.getElementValue(el);
+    const current_word = u.getCurrentWord(el);
+    const new_value = value.slice(0, caret - current_word.length) + `${new_word} ` + value.slice(caret);
+    if (isInputElement(el)) {
+        el.value = new_value;
+    } else {
+        el.textContent = new_value;
+    }
+    el.selectionEnd = caret - current_word.length + new_word.length + 1;
 };
 
 u.triggerEvent = function (el, name, type="Event", bubbles=true, cancelable=true) {
@@ -433,19 +500,6 @@ u.getRandomInt = function (max) {
     return Math.floor(Math.random() * Math.floor(max));
 };
 
-u.putCurserAtEnd = function (textarea) {
-    if (textarea !== document.activeElement) {
-        textarea.focus();
-    }
-    // Double the length because Opera is inconsistent about whether a carriage return is one character or two.
-    const len = textarea.value.length * 2;
-    // Timeout seems to be required for Blink
-    setTimeout(() => textarea.setSelectionRange(len, len), 1);
-    // Scroll to the bottom, in case we're in a tall textarea
-    // (Necessary for Firefox and Chrome)
-    this.scrollTop = 999999;
-};
-
 u.getUniqueId = function () {
     return 'xxxxxxxx-xxxx'.replace(/[x]/g, function(c) {
         var r = Math.random() * 16 | 0,

+ 3 - 4
src/templates/chatbox_message_form.html

@@ -9,12 +9,11 @@
 
     <div class="suggestion-box">
         <ul class="suggestion-box__results suggestion-box__results--above" hidden=""></ul>
-        <textarea
-            type="text"
-            class="chat-textarea suggestion-box__input
+        <div contentEditable="true"
+             class="chat-textarea suggestion-box__input
                 {[ if (o.show_send_button) { ]} chat-textarea-send-button {[ } ]}
                 {[ if (o.composing_spoiler) { ]} spoiler {[ } ]}"
-            placeholder="{{{o.label_message}}}">{{ o.message_value }}</textarea>
+            data-placeholder="{{{o.label_message}}}">{{ o.message_value }}</div>
         <span class="suggestion-box__additions visually-hidden" role="status" aria-live="assertive" aria-relevant="additions"></span>
 
         {[ if (o.show_send_button) { ]}

+ 2 - 2
tests/utils.js

@@ -334,8 +334,8 @@
 
     utils.sendMessage = function (view, message) {
         const promise = new Promise((resolve, reject) => view.on('messageInserted', resolve));
-        view.el.querySelector('.chat-textarea').value = message;
-        view.keyPressed({
+        view.el.querySelector('.chat-textarea').textContent = message;
+        view.onKeyDown({
             target: view.el.querySelector('textarea.chat-textarea'),
             preventDefault: _.noop,
             keyCode: 13