feat(chat): support Ctrl+Enter and auto-resize for multiline chat messages

- Enable Ctrl+Enter (and Cmd+Enter) to insert a newline without sending
- Prevent plain Enter send handler when modifier keys (Ctrl/Cmd) are pressed
- Support dynamic multiline auto-resizing across Lobby, People, and Network chats
- Preserve Shift+Enter standard newline behavior
This commit is contained in:
defnax 2026-09-09 22:59:37 +02:00
parent 2bca038a4a
commit 87f1aa8e28
8 changed files with 125 additions and 35 deletions

View File

@ -14,6 +14,7 @@ const {
ChatRoomsModel,
ChatLobbyModel,
ChatHubState,
autoResizeTextarea,
} = chatState;
chatEmoji.setDependencies({ ChatHubState });
@ -222,6 +223,7 @@ function pollHashStatus(localpath, delay = HASH_POLL_START_MS) {
if (textarea) {
const val = textarea.value;
textarea.value = val ? val + '\n' + fileLink : fileLink;
autoResizeTextarea(textarea);
}
ChatHubState.showAttachModal = false;
@ -528,6 +530,7 @@ const ChatConversationView = () => {
const end = textarea.selectionEnd || 0;
const val = textarea.value;
textarea.value = val.substring(0, start) + imgTag + val.substring(end);
autoResizeTextarea(textarea);
m.redraw();
}
});
@ -539,6 +542,10 @@ const ChatConversationView = () => {
placeholder: 'Type a message...',
disabled: !canTalk,
enterkeyhint: 'send',
rows: 1,
oncreate: (vnode) => autoResizeTextarea(vnode.dom),
onupdate: (vnode) => autoResizeTextarea(vnode.dom),
oninput: (e) => autoResizeTextarea(e.target),
onpaste: (e) => {
if (!canTalk) return;
const items = (e.clipboardData || (e.originalEvent && e.originalEvent.clipboardData))?.items;
@ -554,6 +561,7 @@ const ChatConversationView = () => {
const end = textarea.selectionEnd || 0;
const val = textarea.value;
textarea.value = val.substring(0, start) + imgTag + val.substring(end);
autoResizeTextarea(textarea);
m.redraw();
}
});
@ -562,16 +570,31 @@ const ChatConversationView = () => {
}
},
onkeydown: (e) => {
if ((e.key === 'Enter' || e.keyCode === 13) && !e.shiftKey) {
if (!canTalk) return false;
const msg = e.target.value;
if (msg.trim() === '') return false;
e.target.value = ' sending ... ';
ChatLobbyModel.sendMessage(msg, () => {
e.target.value = '';
scrollChatToBottom();
});
return false;
if (e.key === 'Enter' || e.keyCode === 13) {
if (!e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
if (!canTalk) return false;
const msg = e.target.value;
if (msg.trim() === '') return false;
e.target.value = ' sending ... ';
ChatLobbyModel.sendMessage(msg, () => {
e.target.value = '';
autoResizeTextarea(e.target);
scrollChatToBottom();
});
return false;
}
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
if (!document.execCommand || !document.execCommand('insertText', false, '\n')) {
const start = e.target.selectionStart || 0;
const end = e.target.selectionEnd || 0;
const val = e.target.value;
e.target.value = val.substring(0, start) + '\n' + val.substring(end);
e.target.selectionStart = e.target.selectionEnd = start + 1;
}
autoResizeTextarea(e.target);
}
}
},
}),
@ -588,6 +611,7 @@ const ChatConversationView = () => {
textarea.value = ' sending ... ';
ChatLobbyModel.sendMessage(msg, () => {
textarea.value = '';
autoResizeTextarea(textarea);
scrollChatToBottom();
});
},

View File

@ -92,6 +92,7 @@ function insertEmojiIntoTextarea(emoji, onSelect) {
textarea.selectionStart = newPos;
textarea.selectionEnd = newPos;
textarea.focus();
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
const EmojiPicker = () => ({

View File

@ -1325,4 +1325,15 @@ module.exports = {
ChatLobbyModel,
ChatHubState,
receiveLobbyChatMessage,
autoResizeTextarea,
};
function autoResizeTextarea(el) {
if (!el) return;
el.style.height = 'auto';
const maxHeight = 160;
const scrollHeight = el.scrollHeight;
const newHeight = Math.min(Math.max(scrollHeight, 40), maxHeight);
el.style.height = newHeight + 'px';
el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden';
}

View File

@ -8,7 +8,7 @@ const {
sendDirectChatMessage,
loadAllDirectChatHistory,
} = require('network/network_state');
const { renderChatMessage } = require('chat/chat_state');
const { renderChatMessage, autoResizeTextarea } = require('chat/chat_state');
const chatEmoji = require('chat/chat_emoji');
const HistoryBrowserModal = require('people/people_history');
@ -218,7 +218,7 @@ const ChatTab = () => {
name: friend.name,
ownName: State.ownProfile.name || 'You',
}),
m('.chat-input-area', { style: 'display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem; background: #ffffff; border-top: 1px solid #cbd5e1;' }, [
m('.chat-input-area', { style: 'display: flex; align-items: flex-end; gap: 0.5rem; padding: 0.75rem; background: #ffffff; border-top: 1px solid #cbd5e1;' }, [
m('button.chat-hub-action-btn.desktop-chat-attachment', {
title: 'Attach file link',
onclick: () => {
@ -313,9 +313,13 @@ const ChatTab = () => {
m('textarea.chat-textarea', {
placeholder: 'Type a message here...',
value: State.chatInputMsg,
style: 'flex: 1; resize: none; border: 1px solid #cbd5e1; border-radius: 6px; padding: 0.5rem; font-family: inherit; font-size: 0.9rem; outline: none; min-height: 40px; max-height: 120px;',
rows: 1,
style: 'flex: 1; resize: none; border: 1px solid #cbd5e1; border-radius: 0.625rem; padding: 0.55rem 0.75rem; font-family: inherit; font-size: 0.9rem; line-height: 1.45; outline: none; min-height: 40px; max-height: 160px; height: 40px; box-sizing: border-box; overflow-y: hidden;',
oncreate: (vnode) => autoResizeTextarea(vnode.dom),
onupdate: (vnode) => autoResizeTextarea(vnode.dom),
oninput: (e) => {
State.chatInputMsg = e.target.value;
autoResizeTextarea(e.target);
},
onpaste: (e) => {
const items = (e.clipboardData || (e.originalEvent && e.originalEvent.clipboardData))?.items;
@ -335,9 +339,25 @@ const ChatTab = () => {
}
},
onkeydown: (e) => {
if (e.code === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendDirectChatMessage();
if (e.key === 'Enter' || e.code === 'Enter' || e.keyCode === 13) {
if (!e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
sendDirectChatMessage();
} else if (e.ctrlKey || e.metaKey) {
e.preventDefault();
if (!document.execCommand || !document.execCommand('insertText', false, '\n')) {
const start = e.target.selectionStart || 0;
const end = e.target.selectionEnd || 0;
const val = e.target.value;
const newVal = val.substring(0, start) + '\n' + val.substring(end);
State.chatInputMsg = newVal;
e.target.value = newVal;
e.target.selectionStart = e.target.selectionEnd = start + 1;
} else {
State.chatInputMsg = e.target.value;
}
autoResizeTextarea(e.target);
}
}
},
}),

View File

@ -13,7 +13,7 @@ const {
switchChatIdentity,
} = require('people/people_state');
const { startAttachHash, stopAttachHash } = require('people/people_attach');
const { renderChatMessage } = require('chat/chat_state');
const { renderChatMessage, autoResizeTextarea } = require('chat/chat_state');
const chatEmoji = require('chat/chat_emoji');
const peopleUtil = require('people/people_util');
const HistoryBrowserModal = require('people/people_history');
@ -295,7 +295,7 @@ const ChatTab = () => {
}, 'Dismiss'),
]),
m('.chat-input-area', { style: 'display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem; background: #ffffff; border-top: 1px solid #cbd5e1;' }, [
m('.chat-input-area', { style: 'display: flex; align-items: flex-end; gap: 0.5rem; padding: 0.75rem; background: #ffffff; border-top: 1px solid #cbd5e1;' }, [
m('button.chat-hub-action-btn.desktop-chat-attachment', {
disabled: !canTalk,
style: !canTalk ? 'opacity: 0.5; cursor: not-allowed;' : '',
@ -377,9 +377,13 @@ const ChatTab = () => {
placeholder: canTalk ? 'Type a message here...' : 'Waiting for tunnel to be secured...',
disabled: !canTalk,
value: State.chatInputMsg,
style: 'flex: 1; resize: none; border: 1px solid #cbd5e1; border-radius: 6px; padding: 0.5rem; font-family: inherit; font-size: 0.9rem; outline: none; min-height: 40px; max-height: 120px;',
rows: 1,
style: 'flex: 1; resize: none; border: 1px solid #cbd5e1; border-radius: 0.625rem; padding: 0.55rem 0.75rem; font-family: inherit; font-size: 0.9rem; line-height: 1.45; outline: none; min-height: 40px; max-height: 160px; height: 40px; box-sizing: border-box; overflow-y: hidden;',
oncreate: (vnode) => autoResizeTextarea(vnode.dom),
onupdate: (vnode) => autoResizeTextarea(vnode.dom),
oninput: (e) => {
setChatDraft(e.target.value);
autoResizeTextarea(e.target);
},
onpaste: (e) => {
if (!canTalk) return;
@ -400,9 +404,25 @@ const ChatTab = () => {
}
},
onkeydown: (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (canTalk) sendDistantChatMessage();
if (e.key === 'Enter' || e.keyCode === 13) {
if (!e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
if (canTalk) sendDistantChatMessage();
} else if (e.ctrlKey || e.metaKey) {
e.preventDefault();
if (!document.execCommand || !document.execCommand('insertText', false, '\n')) {
const start = e.target.selectionStart || 0;
const end = e.target.selectionEnd || 0;
const val = e.target.value;
const newVal = val.substring(0, start) + '\n' + val.substring(end);
setChatDraft(newVal);
e.target.value = newVal;
e.target.selectionStart = e.target.selectionEnd = start + 1;
} else {
setChatDraft(e.target.value);
}
autoResizeTextarea(e.target);
}
}
},
}),

View File

@ -1309,15 +1309,18 @@ textarea.chatMsg {
.chat-hub-input-area textarea.chat-hub-textarea {
flex: 1;
resize: vertical;
resize: none !important;
min-height: 40px;
max-height: 250px;
max-height: 160px;
height: 40px;
padding: 0.5rem 0.75rem;
padding: 0.55rem 0.75rem;
border: 1px solid #cbd5e1;
border-radius: 0.375rem;
border-radius: 0.625rem;
font-size: 0.9rem;
line-height: 1.45;
outline: none;
overflow-y: hidden;
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
background-color: #f8fafc;
}
@ -1444,12 +1447,15 @@ label.mobile-chat-attachment__option:hover {
min-width: 0;
min-height: 40px;
height: 40px;
max-height: 120px;
max-height: 140px;
padding: 0.55rem 0.7rem;
resize: none;
resize: none !important;
border-color: #dbe2ea;
border-radius: 1.25rem;
background: #ffffff;
box-sizing: border-box;
overflow-y: hidden;
line-height: 1.4;
}
.chat-hub-input-area .emoji-picker-wrapper {

View File

@ -804,17 +804,22 @@
border-top: 1px solid #cbd5e1;
display: flex;
gap: 0.75rem;
align-items: center;
align-items: flex-end;
textarea.chat-textarea {
flex: 1;
resize: none;
resize: none !important;
min-height: 40px;
max-height: 160px;
height: 40px;
padding: 0.5rem 0.75rem;
padding: 0.55rem 0.75rem;
border: 1px solid #cbd5e1;
border-radius: 0.375rem;
border-radius: 0.625rem;
font-size: 0.9rem;
line-height: 1.45;
outline: none;
overflow-y: hidden;
box-sizing: border-box;
transition: all 0.2s;
&:focus {
@ -923,7 +928,7 @@
/* Direct-chat composer: preserve room for typing on a narrow screen. */
.network-chat-view .chat-input-area {
align-items: center !important;
align-items: flex-end !important;
padding: .35rem .45rem !important;
gap: .2rem !important;
min-width: 0;
@ -959,11 +964,14 @@
min-width: 0 !important;
min-height: 40px !important;
height: 40px !important;
max-height: 120px !important;
max-height: 140px !important;
padding: .55rem .7rem !important;
border-color: #dbe2ea !important;
border-radius: 1.25rem !important;
background: #fff !important;
box-sizing: border-box;
overflow-y: hidden;
line-height: 1.4;
}
.network-chat-view .chat-input-area .send-btn {

File diff suppressed because one or more lines are too long