Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/volto-slate/news/8348.bugfix
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions packages/volto-slate/src/editor/SlateEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 });
Expand Down
1 change: 1 addition & 0 deletions packages/volto/news/8348.bugfix
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down
109 changes: 109 additions & 0 deletions packages/volto/src/components/manage/Blocks/Title/Edit.ime.test.jsx
Original file line number Diff line number Diff line change
@@ -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 <Editable />
// (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 <Editable /> 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) => (
<div
role="textbox"
tabIndex={0}
aria-label={props['aria-label']}
onKeyDown={props.onKeyDown}
/>
),
};
});

// 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(
<Provider store={store}>
<Edit
properties={{ title: 'My Title' }}
selected={false}
block="1234"
onAddBlock={() => {}}
onChangeField={() => {}}
onSelectBlock={() => {}}
onDeleteBlock={() => {}}
onFocusPreviousBlock={() => {}}
onFocusNextBlock={() => {}}
index={1}
blockNode={{ current: null }}
data={{}}
{...props}
/>
</Provider>,
);
}

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();
});
5 changes: 5 additions & 0 deletions packages/volto/src/components/manage/Blocks/Title/Edit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 11 additions & 0 deletions packages/volto/src/helpers/Utils/Utils.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
19 changes: 19 additions & 0 deletions packages/volto/src/helpers/Utils/Utils.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
slugify,
cloneDeepSchema,
normalizeString,
isIMEComposing,
} from './Utils';
import moment from 'moment';
import deepFreeze from 'deep-freeze';
Expand Down Expand Up @@ -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);
});
});
});
1 change: 1 addition & 0 deletions packages/volto/src/helpers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export {
arrayRange,
reorderArray,
isInteractiveElement,
isIMEComposing,
slugify,
normalizeString,
} from '@plone/volto/helpers/Utils/Utils';
Expand Down
Loading