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';