Files
chatwoot/app/javascript/shared/helpers/specs/KeyboardHelpers.spec.js
Sivin VargheseandGitHub 437dd9d38c fix: prevent Ctrl+Enter adding extra line break on send (Windows) (#14077)
# Pull Request Template

## Description

This PR includes,
On Windows, pressing **Ctrl+Enter** in the reply editor was inserting an
unintended line break before sending. This led to two issues:

* **Unexpected blank lines**
After adding a line break with Shift+Enter and removing it with
Backspace, the editor looked correct. However, sending with Ctrl+Enter
reintroduced a hidden break, resulting in an extra blank line in the
final message.

* **Selected text being replaced**
When text was selected and Ctrl+Enter was pressed, the selection was
replaced with a line break instead of being sent.

Fixes
https://linear.app/chatwoot/issue/CW-6840/newline-bug-in-the-editor


### **Cause**

Two keyboard handlers responded to **Ctrl+Enter** on Windows:

* ProseMirror (`Mod-Enter`) inserted a hard break
* ReplyBox (`$mod+Enter`) triggered send

The existing guard only checked `metaKey` (Cmd), so it never worked on
Windows. As a result, a line break was inserted just before sending.

### **Solution**

Make the modifier check platform-aware so the editor correctly
intercepts the send shortcut:

* Added `detectOS`, `isMac`, and `OS` constants
* Introduced `hasPressedMod` (uses `metaKey` on macOS, `ctrlKey`
elsewhere)

This ensures Ctrl+Enter sends the message without modifying content,
while keeping existing behavior unchanged.


**NB:** macOS behavior with Cmd+Enter remains unchanged



## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

**Case 1: line break**

1. Type `hello`
2. Press Shift+Enter, then Backspace
3. Press Ctrl+Enter
   → Message contains an unexpected blank new line

**Case 2: Selection replaced**

1. Type two lines using Shift+Enter
2. Select text on the second line
3. Press Ctrl+Enter
   → Selected text is replaced and not sent

### Screencast

**Before**


https://github.com/user-attachments/assets/d6d285a9-260b-4711-8bbd-d0c8519e8d20

**After**



https://github.com/user-attachments/assets/c0ace1f7-5d22-44a2-8e08-22190ee21e61



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-04-20 18:16:41 +05:30

194 lines
5.5 KiB
JavaScript

import {
isEnter,
isEscape,
hasPressedShift,
hasPressedCommand,
hasPressedMod,
hasPressedCommandAndEnter,
hasPressedEnterAndNotCmdOrShift,
isActiveElementTypeable,
} from '../KeyboardHelpers';
const setNavigator = navigatorValue => {
Object.defineProperty(global, 'navigator', {
value: navigatorValue,
configurable: true,
writable: true,
});
};
const onMac = () => setNavigator({ userAgentData: { platform: 'macOS' } });
const onWindows = () =>
setNavigator({ userAgentData: { platform: 'Windows' } });
const onIOS = () =>
setNavigator({
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
});
describe('#KeyboardHelpers', () => {
describe('#isEnter', () => {
it('return correct values', () => {
expect(isEnter({ key: 'Enter' })).toEqual(true);
});
});
describe('#isEscape', () => {
it('return correct values', () => {
expect(isEscape({ key: 'Escape' })).toEqual(true);
});
});
describe('#hasPressedShift', () => {
it('return correct values', () => {
expect(hasPressedShift({ shiftKey: true })).toEqual(true);
});
});
describe('#hasPressedCommand', () => {
it('return correct values', () => {
expect(hasPressedCommand({ metaKey: true })).toEqual(true);
});
});
describe('#hasPressedMod', () => {
const originalNavigator = global.navigator;
afterEach(() => {
setNavigator(originalNavigator);
});
it('uses metaKey on macOS', () => {
onMac();
expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(true);
expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(false);
});
it('uses ctrlKey on Windows', () => {
onWindows();
expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(true);
expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(false);
});
it('uses metaKey on iOS hardware keyboards', () => {
onIOS();
expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(true);
expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(false);
});
it('returns false when no modifier is held', () => {
onWindows();
expect(hasPressedMod({ metaKey: false, ctrlKey: false })).toBe(false);
});
});
describe('#hasPressedCommandAndEnter', () => {
const originalNavigator = global.navigator;
afterEach(() => {
setNavigator(originalNavigator);
});
it('returns true for Cmd+Enter on macOS', () => {
onMac();
expect(hasPressedCommandAndEnter({ key: 'Enter', metaKey: true })).toBe(
true
);
});
it('returns true for Ctrl+Enter on Windows (CW-6859 fix)', () => {
onWindows();
expect(hasPressedCommandAndEnter({ key: 'Enter', ctrlKey: true })).toBe(
true
);
});
it('returns false for Ctrl+Enter on macOS (Mac uses Cmd, not Ctrl)', () => {
onMac();
expect(hasPressedCommandAndEnter({ key: 'Enter', ctrlKey: true })).toBe(
false
);
});
it('returns true for Cmd+Enter on iOS hardware keyboards', () => {
onIOS();
expect(hasPressedCommandAndEnter({ key: 'Enter', metaKey: true })).toBe(
true
);
});
it('returns false for plain Enter', () => {
onWindows();
expect(hasPressedCommandAndEnter({ key: 'Enter' })).toBe(false);
});
});
describe('#hasPressedEnterAndNotCmdOrShift', () => {
const originalNavigator = global.navigator;
afterEach(() => {
setNavigator(originalNavigator);
});
it('returns true for plain Enter on Windows', () => {
onWindows();
expect(hasPressedEnterAndNotCmdOrShift({ key: 'Enter' })).toBe(true);
});
it('returns false for Ctrl+Enter on Windows (mod is held)', () => {
onWindows();
expect(
hasPressedEnterAndNotCmdOrShift({ key: 'Enter', ctrlKey: true })
).toBe(false);
});
it('returns false for Cmd+Enter on macOS (mod is held)', () => {
onMac();
expect(
hasPressedEnterAndNotCmdOrShift({ key: 'Enter', metaKey: true })
).toBe(false);
});
it('returns false for Shift+Enter', () => {
onWindows();
expect(
hasPressedEnterAndNotCmdOrShift({ key: 'Enter', shiftKey: true })
).toBe(false);
});
});
});
describe('isActiveElementTypeable', () => {
it('should return true if the active element is an input element', () => {
const event = { target: document.createElement('input') };
const result = isActiveElementTypeable(event);
expect(result).toBe(true);
});
it('should return true if the active element is a textarea element', () => {
const event = { target: document.createElement('textarea') };
const result = isActiveElementTypeable(event);
expect(result).toBe(true);
});
it('should return true if the active element is a contentEditable element', () => {
const element = document.createElement('div');
element.contentEditable = 'true';
const event = { target: element };
const result = isActiveElementTypeable(event);
expect(result).toBe(true);
});
it('should return false if the active element is not typeable', () => {
const event = { target: document.createElement('div') };
const result = isActiveElementTypeable(event);
expect(result).toBe(false);
});
it('should return false if the active element is null', () => {
const event = { target: null };
const result = isActiveElementTypeable(event);
expect(result).toBe(false);
});
});