diff --git a/packages/volto-slate/news/8348.bugfix b/packages/volto-slate/news/8348.bugfix
new file mode 100644
index 00000000000..e826c304439
--- /dev/null
+++ b/packages/volto-slate/news/8348.bugfix
@@ -0,0 +1 @@
+Ignore keydown events in the Slate editor while an IME composition is active, so confirming a Japanese/Chinese/Korean conversion with Enter (or navigating candidates with arrow keys) is no longer misinterpreted as a keyboard shortcut or block navigation. @terapyon
diff --git a/packages/volto-slate/src/editor/SlateEditor.jsx b/packages/volto-slate/src/editor/SlateEditor.jsx
index 5bd6f001dac..23d08d815da 100644
--- a/packages/volto-slate/src/editor/SlateEditor.jsx
+++ b/packages/volto-slate/src/editor/SlateEditor.jsx
@@ -7,6 +7,7 @@ import React, { Component } from 'react'; // , useState
import { v4 as uuid } from 'uuid';
import config from '@plone/volto/registry';
+import { isIMEComposing } from '@plone/volto/helpers/Utils/Utils';
import { Element, Leaf } from './render';
@@ -354,6 +355,9 @@ class SlateEditor extends Component {
}, 200);
}}
onKeyDown={(event) => {
+ // Ignore keys while an IME composition is active (e.g. CJK
+ // conversion); slate-react handles composition itself.
+ if (isIMEComposing(event)) return;
const handled = handleHotKeys(editor, event, slateSettings);
if (handled) return;
onKeyDown && onKeyDown({ editor, event });
diff --git a/packages/volto/news/8348.bugfix b/packages/volto/news/8348.bugfix
new file mode 100644
index 00000000000..3490478f3e8
--- /dev/null
+++ b/packages/volto/news/8348.bugfix
@@ -0,0 +1 @@
+Fixed IME composition Enter key in the Title and Description blocks and in TextLineEdit being misinterpreted as a move to the next block, which broke Japanese/Chinese/Korean input (e.g. confirming a conversion in Safari). @terapyon
diff --git a/packages/volto/src/components/manage/Blocks/Description/Edit.jsx b/packages/volto/src/components/manage/Blocks/Description/Edit.jsx
index 5899e4e1e04..4cdb7dfb2ee 100644
--- a/packages/volto/src/components/manage/Blocks/Description/Edit.jsx
+++ b/packages/volto/src/components/manage/Blocks/Description/Edit.jsx
@@ -10,6 +10,7 @@ import { ReactEditor, Editable, Slate, withReact } from 'slate-react';
import PropTypes from 'prop-types';
import { defineMessages, useIntl } from 'react-intl';
import config from '@plone/volto/registry';
+import { isIMEComposing } from '@plone/volto/helpers/Utils/Utils';
import { P } from '@plone/volto-slate/constants';
import cx from 'classnames';
@@ -108,6 +109,10 @@ export const DescriptionBlockEdit = (props) => {
const handleKeyDown = useCallback(
(ev) => {
+ // Ignore keys while an IME composition is active (e.g. CJK conversion).
+ if (isIMEComposing(ev)) {
+ return;
+ }
if (ev.key === 'Backspace' && Node.string(editor).length === 0) {
ev.preventDefault();
onDeleteBlock(block, true);
diff --git a/packages/volto/src/components/manage/Blocks/Title/Edit.ime.test.jsx b/packages/volto/src/components/manage/Blocks/Title/Edit.ime.test.jsx
new file mode 100644
index 00000000000..893bad5659a
--- /dev/null
+++ b/packages/volto/src/components/manage/Blocks/Title/Edit.ime.test.jsx
@@ -0,0 +1,109 @@
+// Regression test for https://github.com/plone/volto/issues/8348: Enter
+// pressed to confirm an IME composition must not be handled as a keystroke.
+// The Title block is tested as the representative of the shared guard
+// (isIMEComposing) also used by the Description block, TextLineEdit and the
+// volto-slate SlateEditor; the guard itself is unit-tested in Utils.test.jsx.
+// This is a separate file from Edit.test.jsx because mocking
+// (required, slate-react does not work in jsdom) would break its snapshots.
+import React from 'react';
+import configureStore from 'redux-mock-store';
+import { Provider } from 'react-intl-redux';
+import { render, fireEvent, screen } from '@testing-library/react';
+import config from '@plone/volto/registry';
+
+import Edit from './Edit';
+
+// slate-react's contenteditable machinery does not work in jsdom, so replace
+// only with a plain element that forwards onKeyDown to the real
+// handleKeyDown of the component under test.
+vi.mock('slate-react', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ Editable: (props) => (
+
+ ),
+ };
+});
+
+// test-setup-globals.js sets global.__SERVER__ = false, which the component
+// treats as "rendering on the server" and then renders nothing.
+beforeAll(() => {
+ delete global.__SERVER__;
+});
+
+const mockStore = configureStore();
+
+function renderEdit(props) {
+ const store = mockStore({ intl: { locale: 'en', messages: {} } });
+ return render(
+
+ {}}
+ onChangeField={() => {}}
+ onSelectBlock={() => {}}
+ onDeleteBlock={() => {}}
+ onFocusPreviousBlock={() => {}}
+ onFocusNextBlock={() => {}}
+ index={1}
+ blockNode={{ current: null }}
+ data={{}}
+ {...props}
+ />
+ ,
+ );
+}
+
+test('Enter adds a new block after the title', () => {
+ const onAddBlock = vi.fn(() => 'new-block-id');
+ const onSelectBlock = vi.fn();
+ renderEdit({ onAddBlock, onSelectBlock });
+ const notCancelled = fireEvent.keyDown(screen.getByRole('textbox'), {
+ key: 'Enter',
+ });
+ expect(onAddBlock).toHaveBeenCalledWith(config.settings.defaultBlockType, 2);
+ expect(onSelectBlock).toHaveBeenCalledWith('new-block-id');
+ // the keydown must be preventDefault-ed
+ expect(notCancelled).toBe(false);
+});
+
+test('Enter during IME composition is ignored (isComposing)', () => {
+ const onAddBlock = vi.fn();
+ renderEdit({ onAddBlock });
+ const notCancelled = fireEvent.keyDown(screen.getByRole('textbox'), {
+ key: 'Enter',
+ isComposing: true,
+ });
+ expect(onAddBlock).not.toHaveBeenCalled();
+ // the keydown must be left to the IME, not preventDefault-ed
+ expect(notCancelled).toBe(true);
+});
+
+test('Enter during IME composition is ignored (Safari, keyCode 229)', () => {
+ const onAddBlock = vi.fn();
+ renderEdit({ onAddBlock });
+ const notCancelled = fireEvent.keyDown(screen.getByRole('textbox'), {
+ key: 'Enter',
+ keyCode: 229,
+ });
+ expect(onAddBlock).not.toHaveBeenCalled();
+ expect(notCancelled).toBe(true);
+});
+
+test('ArrowDown during IME composition does not move focus', () => {
+ const onFocusNextBlock = vi.fn();
+ renderEdit({ onFocusNextBlock });
+ fireEvent.keyDown(screen.getByRole('textbox'), {
+ key: 'ArrowDown',
+ isComposing: true,
+ });
+ expect(onFocusNextBlock).not.toHaveBeenCalled();
+});
diff --git a/packages/volto/src/components/manage/Blocks/Title/Edit.jsx b/packages/volto/src/components/manage/Blocks/Title/Edit.jsx
index 2ba56888ef8..dadffa1faea 100644
--- a/packages/volto/src/components/manage/Blocks/Title/Edit.jsx
+++ b/packages/volto/src/components/manage/Blocks/Title/Edit.jsx
@@ -10,6 +10,7 @@ import { ReactEditor, Editable, Slate, withReact } from 'slate-react';
import PropTypes from 'prop-types';
import { defineMessages, useIntl } from 'react-intl';
import config from '@plone/volto/registry';
+import { isIMEComposing } from '@plone/volto/helpers/Utils/Utils';
import { P } from '@plone/volto-slate/constants';
const messages = defineMessages({
@@ -112,6 +113,10 @@ export const TitleBlockEdit = (props) => {
const handleKeyDown = useCallback(
(ev) => {
+ // Ignore keys while an IME composition is active (e.g. CJK conversion).
+ if (isIMEComposing(ev)) {
+ return;
+ }
if (ev.key === 'Return' || ev.key === 'Enter') {
ev.preventDefault();
if (!disableNewBlocks) {
diff --git a/packages/volto/src/components/manage/TextLineEdit/TextLineEdit.jsx b/packages/volto/src/components/manage/TextLineEdit/TextLineEdit.jsx
index 54d845e5a57..1e458a4b4d5 100644
--- a/packages/volto/src/components/manage/TextLineEdit/TextLineEdit.jsx
+++ b/packages/volto/src/components/manage/TextLineEdit/TextLineEdit.jsx
@@ -4,6 +4,7 @@ import { ReactEditor, Editable, Slate, withReact } from 'slate-react';
import PropTypes from 'prop-types';
import { defineMessages, useIntl } from 'react-intl';
import { usePrevious } from '@plone/volto/helpers/Utils/usePrevious';
+import { isIMEComposing } from '@plone/volto/helpers/Utils/Utils';
import config from '@plone/volto/registry';
import { P } from '@plone/volto-slate/constants';
import cx from 'classnames';
@@ -126,6 +127,10 @@ export const TextLineEdit = (props) => {
const handleKeyDown = useCallback(
(ev) => {
+ // Ignore keys while an IME composition is active (e.g. CJK conversion).
+ if (isIMEComposing(ev)) {
+ return;
+ }
if (ev.key === 'Return' || ev.key === 'Enter') {
ev.preventDefault();
if (!disableNewBlocks) {
diff --git a/packages/volto/src/helpers/Utils/Utils.jsx b/packages/volto/src/helpers/Utils/Utils.jsx
index 79c9bbe2a40..6c9fd28f47f 100644
--- a/packages/volto/src/helpers/Utils/Utils.jsx
+++ b/packages/volto/src/helpers/Utils/Utils.jsx
@@ -392,3 +392,14 @@ export function isInteractiveElement(
return false;
}
+
+/**
+ * Returns whether an IME (Input Method Editor) composition is in progress,
+ * e.g. while confirming a Japanese/Chinese/Korean conversion with Enter.
+ * @param {KeyboardEvent} event The (React synthetic or native) keyboard event
+ * @returns {boolean} True if an IME composition is active
+ */
+export function isIMEComposing(event) {
+ const nativeEvent = event?.nativeEvent ?? event;
+ return Boolean(nativeEvent?.isComposing) || nativeEvent?.keyCode === 229;
+}
diff --git a/packages/volto/src/helpers/Utils/Utils.test.jsx b/packages/volto/src/helpers/Utils/Utils.test.jsx
index 590048e7c26..2a55aa077a4 100644
--- a/packages/volto/src/helpers/Utils/Utils.test.jsx
+++ b/packages/volto/src/helpers/Utils/Utils.test.jsx
@@ -16,6 +16,7 @@ import {
slugify,
cloneDeepSchema,
normalizeString,
+ isIMEComposing,
} from './Utils';
import moment from 'moment';
import deepFreeze from 'deep-freeze';
@@ -515,4 +516,22 @@ describe('Utils tests', () => {
});
});
});
+
+ describe('isIMEComposing', () => {
+ it('returns true when the native event is composing', () => {
+ expect(isIMEComposing({ nativeEvent: { isComposing: true } })).toBe(true);
+ });
+ it('returns true when the native event keyCode is 229', () => {
+ expect(isIMEComposing({ nativeEvent: { keyCode: 229 } })).toBe(true);
+ });
+ it('returns false for a regular Enter key press', () => {
+ expect(
+ isIMEComposing({ nativeEvent: { isComposing: false, keyCode: 13 } }),
+ ).toBe(false);
+ });
+ it('accepts a native event directly', () => {
+ expect(isIMEComposing({ isComposing: true })).toBe(true);
+ expect(isIMEComposing({ isComposing: false, keyCode: 13 })).toBe(false);
+ });
+ });
});
diff --git a/packages/volto/src/helpers/index.js b/packages/volto/src/helpers/index.js
index 638c4ef200c..161b5ce53e2 100644
--- a/packages/volto/src/helpers/index.js
+++ b/packages/volto/src/helpers/index.js
@@ -108,6 +108,7 @@ export {
arrayRange,
reorderArray,
isInteractiveElement,
+ isIMEComposing,
slugify,
normalizeString,
} from '@plone/volto/helpers/Utils/Utils';