Forward editing and clipboard events to front components (#22630)

Adds the text-editing events input-heavy front components need:
`beforeinput`, `compositionstart/update/end` and `copy/paste/cut`,
allowed on `input` and `textarea` only.

These events carry payload: `beforeinput` forwards `inputType`/`data`
through a native host listener (React synthesizes `onBeforeInput`
without them), composition events forward `data`, and paste forwards
`clipboardData.getData('text')` capped at 100k chars. Clipboard text is
read only on an explicit paste into the component's own input, never on
copy/cut, and the worker synthesizes a minimal `clipboardData` so
`onPaste` handlers work. `beforeinput` is observe-only: `preventDefault`
cannot cross the async worker boundary.

Allow-listing these events makes the host bind them, so the
`buildHostReactPropsFromRemoteProps` test that pinned them as rejected
now pins events that are still unmapped.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22630?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Raphaël Bosi
2026-07-21 16:21:53 +02:00
committed by GitHub
parent 8b0e7a93a4
commit 07be5e0892
24 changed files with 664 additions and 7 deletions
@@ -1,4 +1,5 @@
import type { Project, SourceFile } from 'ts-morph';
import { isDefined } from 'twenty-shared/utils';
import { DOM_EVENT_TYPE_TO_REACT_PROP } from '../../../src/constants/DomEventTypeToReactProp';
import { type ComponentSchema } from './schemas';
@@ -16,6 +17,11 @@ const generateComponentDefinition = (
const eventProps = component.events
.map((event) => {
const propName = DOM_EVENT_TYPE_TO_REACT_PROP[event];
if (!isDefined(propName)) {
throw new Error(
`Missing DOM_EVENT_TYPE_TO_REACT_PROP mapping for '${event}'`,
);
}
return ` ${propName}: { event: '${event}' },`;
})
.join('\n');
@@ -0,0 +1,41 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
INPUT_STYLE,
LABEL_STYLE,
SUBJECT_WRAPPER_STYLE,
} from '@/__stories__/shared/front-components/styles';
const InputClipboardFrontComponent = () => {
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="input:text:clipboard">
<div style={SUBJECT_WRAPPER_STYLE}>
<label style={LABEL_STYLE}>Clipboard input</label>
<input
data-testid="subject"
type="text"
onPaste={pushEvent}
onCopy={pushEvent}
onCut={pushEvent}
style={INPUT_STYLE}
/>
</div>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier:
'fc-input-clipboard-00000000-0000-0000-0000-000000000020',
name: 'input-clipboard-front-component',
description: 'Front component covering clipboard events on <input>',
component: InputClipboardFrontComponent,
});
@@ -0,0 +1,69 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectEventLogged } from '@/__stories__/shared/test-utils/matchers/expectEventLogged';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Input/Clipboard',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const Paste: Story = runFrontComponentStory({
frontComponentBundleName: 'input-clipboard',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await userEvent.paste('hello-clipboard');
await expectEventLogged({
canvas,
matcher: { type: 'paste', clipboardText: 'hello-clipboard' },
});
},
});
export const CopyAndCutStayPrivate: Story = runFrontComponentStory({
frontComponentBundleName: 'input-clipboard',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await userEvent.type(subject, 'secret');
(subject as HTMLInputElement).select();
await userEvent.copy();
await expectEventLogged({
canvas,
matcher: { type: 'copy', clipboardText: undefined },
});
await userEvent.cut();
await expectEventLogged({
canvas,
matcher: { type: 'cut', clipboardText: undefined },
});
},
});
@@ -0,0 +1,42 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
INPUT_STYLE,
LABEL_STYLE,
SUBJECT_WRAPPER_STYLE,
} from '@/__stories__/shared/front-components/styles';
const InputEditingFrontComponent = () => {
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="input:text:editing">
<div style={SUBJECT_WRAPPER_STYLE}>
<label style={LABEL_STYLE}>Editing input</label>
<input
data-testid="subject"
type="text"
onBeforeInput={pushEvent}
onCompositionStart={pushEvent}
onCompositionUpdate={pushEvent}
onCompositionEnd={pushEvent}
style={INPUT_STYLE}
/>
</div>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-input-editing-00000000-0000-0000-0000-000000000020',
name: 'input-editing-front-component',
description:
'Front component covering beforeinput and composition events on <input>',
component: InputEditingFrontComponent,
});
@@ -0,0 +1,90 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectEventLogged } from '@/__stories__/shared/test-utils/matchers/expectEventLogged';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Input/Editing',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const BeforeInputInsert: Story = runFrontComponentStory({
frontComponentBundleName: 'input-editing',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.type(subject, 'a');
await expectEventLogged({
canvas,
matcher: { type: 'beforeinput', inputType: 'insertText', data: 'a' },
});
},
});
export const BeforeInputDelete: Story = runFrontComponentStory({
frontComponentBundleName: 'input-editing',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.type(subject, 'ab{backspace}');
await expectEventLogged({
canvas,
matcher: { type: 'beforeinput', inputType: 'deleteContentBackward' },
});
},
});
export const Composition: Story = runFrontComponentStory({
frontComponentBundleName: 'input-editing',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
subject.dispatchEvent(
new CompositionEvent('compositionstart', { bubbles: true }),
);
subject.dispatchEvent(
new CompositionEvent('compositionupdate', { data: 'か', bubbles: true }),
);
subject.dispatchEvent(
new CompositionEvent('compositionend', { data: 'か', bubbles: true }),
);
await expectEventLogged({ canvas, matcher: { type: 'compositionstart' } });
await expectEventLogged({
canvas,
matcher: { type: 'compositionupdate', data: 'か' },
});
await expectEventLogged({
canvas,
matcher: { type: 'compositionend', data: 'か' },
});
},
});
@@ -16,6 +16,9 @@ export type LoggedEventEntry = {
files?: LoggedEventFile[];
key?: string;
code?: string;
inputType?: string;
data?: string;
clipboardText?: string;
shiftKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
@@ -61,6 +64,10 @@ const isBooleanValue = (value: unknown): value is boolean =>
const isUnknownRecord = (value: unknown): value is Record<string, unknown> =>
isDefined(value) && typeof value === 'object';
const isFunctionValue = (
value: unknown,
): value is (...args: unknown[]) => unknown => typeof value === 'function';
const isElement = (value: unknown): value is Element =>
isUnknownRecord(value) && typeof value.getAttribute === 'function';
@@ -133,6 +140,26 @@ const readTestId = (
const toUnknownRecord = (value: unknown): Record<string, unknown> =>
isUnknownRecord(value) ? value : {};
const readClipboardText = (
eventRecord: Record<string, unknown>,
): string | undefined => {
const clipboardData = eventRecord.clipboardData;
if (!isUnknownRecord(clipboardData)) {
return undefined;
}
const getData = clipboardData.getData;
if (!isFunctionValue(getData)) {
return undefined;
}
const clipboardText = getData.call(clipboardData, 'text');
return isStringValue(clipboardText) && clipboardText !== ''
? clipboardText
: undefined;
};
export const useEventLog = () => {
const [entries, setEntries] = useState<LoggedEventEntry[]>([]);
@@ -182,6 +209,21 @@ export const useEventLog = () => {
entry.code = code;
}
const inputType = pickFromRecords(records, 'inputType', isStringValue);
if (isDefined(inputType)) {
entry.inputType = inputType;
}
const data = pickFromRecords(records, 'data', isStringValue);
if (isDefined(data)) {
entry.data = data;
}
const clipboardText = readClipboardText(eventRecord);
if (isDefined(clipboardText)) {
entry.clipboardText = clipboardText;
}
const shiftKey = pickFromRecords(records, 'shiftKey', isBooleanValue);
if (isDefined(shiftKey)) {
entry.shiftKey = shiftKey;
@@ -13,6 +13,9 @@ type LoggedEventMatcher = {
checked?: boolean;
key?: string;
code?: string;
inputType?: string;
data?: string;
clipboardText?: string;
shiftKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
@@ -85,6 +85,15 @@ export const ALLOWED_HTML_ELEMENTS: AllowedHtmlElement[] = [
multiple: { type: 'boolean', optional: true },
capture: { type: 'string', optional: true },
},
events: [
'beforeinput',
'compositionstart',
'compositionupdate',
'compositionend',
'copy',
'paste',
'cut',
],
},
{
tag: 'html-textarea',
@@ -98,6 +107,15 @@ export const ALLOWED_HTML_ELEMENTS: AllowedHtmlElement[] = [
rows: { type: 'number', optional: true },
cols: { type: 'number', optional: true },
},
events: [
'beforeinput',
'compositionstart',
'compositionupdate',
'compositionend',
'copy',
'paste',
'cut',
],
},
{
tag: 'html-select',
@@ -45,6 +45,13 @@ export const DOM_EVENT_TYPE_TO_REACT_PROP: Record<string, string> = {
scrollend: 'onScrollEnd',
toggle: 'onToggle',
load: 'onLoad',
beforeinput: 'onBeforeInput',
compositionstart: 'onCompositionStart',
compositionupdate: 'onCompositionUpdate',
compositionend: 'onCompositionEnd',
copy: 'onCopy',
paste: 'onPaste',
cut: 'onCut',
timeupdate: 'onTimeUpdate',
play: 'onPlay',
pause: 'onPause',
@@ -0,0 +1,52 @@
import { applySerializedEventClipboardData } from '@/constants/applySerializedEventClipboardData';
type SynthesizedClipboardData = {
types: string[];
getData: (format: string) => string;
setData: (format: string, data: string) => void;
};
const applyToEvent = (eventData: {
type: string;
clipboardText?: string;
}): SynthesizedClipboardData | undefined => {
const event: Record<string, unknown> = {};
applySerializedEventClipboardData(event, eventData);
return event.clipboardData as SynthesizedClipboardData | undefined;
};
describe('applySerializedEventClipboardData', () => {
it('should expose the pasted text through getData on a paste event', () => {
const clipboardData = applyToEvent({
type: 'paste',
clipboardText: 'pasted text',
});
expect(clipboardData?.getData('text')).toBe('pasted text');
expect(clipboardData?.getData('text/plain')).toBe('pasted text');
expect(clipboardData?.types).toEqual(['text/plain']);
});
it('should return an empty string for other formats', () => {
const clipboardData = applyToEvent({
type: 'paste',
clipboardText: 'pasted text',
});
expect(clipboardData?.getData('text/html')).toBe('');
});
it('should synthesize an empty clipboard for copy and cut events', () => {
for (const type of ['copy', 'cut']) {
const clipboardData = applyToEvent({ type });
expect(clipboardData?.getData('text')).toBe('');
expect(clipboardData?.types).toEqual([]);
expect(clipboardData?.setData('text/plain', 'x')).toBeUndefined();
}
});
it('should not synthesize clipboard data for other event types', () => {
expect(applyToEvent({ type: 'input' })).toBeUndefined();
});
});
@@ -0,0 +1,22 @@
import { type SerializedEventData } from '@/types/SerializedEventData';
const CLIPBOARD_EVENT_TYPES = new Set(['copy', 'cut', 'paste']);
const CLIPBOARD_TEXT_FORMATS = new Set(['text', 'text/plain']);
export const applySerializedEventClipboardData = (
event: Record<string, unknown>,
eventData: SerializedEventData,
): void => {
if (!CLIPBOARD_EVENT_TYPES.has(eventData.type)) {
return;
}
const clipboardText = eventData.clipboardText ?? '';
event.clipboardData = {
types: clipboardText === '' ? [] : ['text/plain'],
getData: (format: string) =>
CLIPBOARD_TEXT_FORMATS.has(format) ? clipboardText : '',
setData: () => undefined,
};
};
@@ -1,3 +1,4 @@
import { applySerializedEventClipboardData } from '@/constants/applySerializedEventClipboardData';
import { type SerializedEventData } from '@/types/SerializedEventData';
const SERIALIZED_EVENT_PROPERTY_KEYS = [
@@ -32,6 +33,8 @@ const SERIALIZED_EVENT_PROPERTY_KEYS = [
'key',
'code',
'repeat',
'inputType',
'data',
'deltaX',
'deltaY',
'deltaZ',
@@ -47,4 +50,6 @@ export const applySerializedEventProperties = (
event[key] = eventData[key];
}
}
applySerializedEventClipboardData(event, eventData);
};
@@ -0,0 +1 @@
export const MAX_SERIALIZED_EVENT_TEXT_LENGTH = 100_000;
@@ -4,6 +4,7 @@ export const REACT_UNSUPPORTED_EVENT_PROP_TO_EVENT_TYPE: Record<
string,
ReactUnsupportedEventType
> = {
onBeforeInput: 'beforeinput',
onFocusIn: 'focusin',
onFocusOut: 'focusout',
};
@@ -1 +1 @@
export type ReactUnsupportedEventType = 'focusin' | 'focusout';
export type ReactUnsupportedEventType = 'beforeinput' | 'focusin' | 'focusout';
@@ -87,15 +87,38 @@ describe('buildHostReactPropsFromRemoteProps', () => {
expect('onmouseover' in result).toBe(false);
});
it('should normalize and wrap editing and clipboard event handlers', () => {
const handler = jest.fn();
const result = buildHostReactPropsFromRemoteProps(
{
onBeforeinput: handler,
onCompositionstart: handler,
onCompositionupdate: handler,
onCompositionend: handler,
onCopy: handler,
onPaste: handler,
onCut: handler,
},
'div',
);
expect(typeof result.onBeforeInput).toBe('function');
expect(typeof result.onCompositionStart).toBe('function');
expect(typeof result.onCompositionUpdate).toBe('function');
expect(typeof result.onCompositionEnd).toBe('function');
expect(typeof result.onCopy).toBe('function');
expect(typeof result.onPaste).toBe('function');
expect(typeof result.onCut).toBe('function');
});
it('should drop handler props whose event type is not allow-listed', () => {
const handler = jest.fn();
const result = buildHostReactPropsFromRemoteProps(
{
onCopy: handler,
onCut: handler,
onPaste: handler,
onSelect: handler,
onBeforeInput: handler,
onInvalid: handler,
onReset: handler,
onAbort: handler,
},
'div',
);
@@ -155,6 +155,40 @@ describe('createHtmlHostWrapper client events', () => {
expect(handleFocusIn).not.toHaveBeenCalled();
});
it('should forward beforeinput on a text input through a native listener', () => {
const handleBeforeInput = jest.fn();
const Wrapper = createHtmlHostWrapper('input');
act(() => {
root.render(
createElement(Wrapper, {
onBeforeinput: handleBeforeInput,
type: 'text',
}),
);
});
const node = container.firstElementChild as HTMLInputElement;
act(() => {
node.dispatchEvent(
new InputEvent('beforeinput', {
bubbles: true,
inputType: 'insertText',
data: 'a',
}),
);
});
expect(handleBeforeInput).toHaveBeenCalledTimes(1);
expect(handleBeforeInput).toHaveBeenCalledWith(
expect.objectContaining({
type: 'beforeinput',
inputType: 'insertText',
data: 'a',
}),
);
});
it('should prevent default on dragover when a remote drop handler exists', () => {
const handleDrop = jest.fn();
const Wrapper = createHtmlHostWrapper('div');
@@ -15,6 +15,18 @@ describe('extractReactUnsupportedEventHandlers', () => {
expect(reactBindableProps).toEqual({ id: 'x' });
});
it('should extract a beforeinput handler from react props', () => {
const onBeforeInput = jest.fn();
const { reactUnsupportedEventHandlers, reactBindableProps } =
extractReactUnsupportedEventHandlers({ onBeforeInput, id: 'x' });
expect(reactUnsupportedEventHandlers).toEqual({
beforeinput: onBeforeInput,
});
expect(reactBindableProps).toEqual({ id: 'x' });
});
it('should drop a react-unsupported handler whose value is not a function', () => {
const { reactUnsupportedEventHandlers, reactBindableProps } =
extractReactUnsupportedEventHandlers({ onFocusIn: 'alert(1)' });
@@ -1,3 +1,5 @@
import { MAX_SERIALIZED_EVENT_TEXT_LENGTH } from '@/host/constants/MaxSerializedEventTextLength';
import { serializeEvent } from '../serializeEvent';
describe('serializeEvent', () => {
@@ -80,6 +82,101 @@ describe('serializeEvent', () => {
expect(result).toEqual({ type: 'touchend' });
});
it('should forward the input type and data of a beforeinput event', () => {
const result = serializeEvent({
type: 'beforeinput',
inputType: 'insertText',
data: 'a',
});
expect(result).toEqual({
type: 'beforeinput',
inputType: 'insertText',
data: 'a',
});
});
it('should forward the input type without data on a deletion beforeinput', () => {
const result = serializeEvent({
type: 'beforeinput',
inputType: 'deleteContentBackward',
data: null,
});
expect(result).toEqual({
type: 'beforeinput',
inputType: 'deleteContentBackward',
});
});
it('should cap the forwarded data length', () => {
const result = serializeEvent({
type: 'beforeinput',
inputType: 'insertFromPaste',
data: 'a'.repeat(MAX_SERIALIZED_EVENT_TEXT_LENGTH + 1),
});
expect(result.data).toHaveLength(MAX_SERIALIZED_EVENT_TEXT_LENGTH);
});
it('should forward the composition data', () => {
const result = serializeEvent({
type: 'compositionupdate',
data: 'か',
});
expect(result).toEqual({ type: 'compositionupdate', data: 'か' });
});
it('should forward the clipboard text of a paste event', () => {
const result = serializeEvent({
type: 'paste',
clipboardData: { getData: () => 'pasted text' },
});
expect(result).toEqual({ type: 'paste', clipboardText: 'pasted text' });
});
it('should cap the forwarded clipboard text length', () => {
const result = serializeEvent({
type: 'paste',
clipboardData: {
getData: () => 'a'.repeat(MAX_SERIALIZED_EVENT_TEXT_LENGTH + 1),
},
});
expect(result.clipboardText).toHaveLength(MAX_SERIALIZED_EVENT_TEXT_LENGTH);
});
it('should never read clipboard data on copy and cut events', () => {
const getData = jest.fn(() => 'selected text');
const copyResult = serializeEvent({
type: 'copy',
clipboardData: { getData },
});
const cutResult = serializeEvent({
type: 'cut',
clipboardData: { getData },
});
expect(getData).not.toHaveBeenCalled();
expect(copyResult).toEqual({ type: 'copy' });
expect(cutResult).toEqual({ type: 'cut' });
});
it('should ignore malformed clipboard data on paste', () => {
expect(
serializeEvent({ type: 'paste', clipboardData: 'not-an-object' }),
).toEqual({ type: 'paste' });
expect(
serializeEvent({ type: 'paste', clipboardData: { getData: 'x' } }),
).toEqual({ type: 'paste' });
expect(
serializeEvent({ type: 'paste', clipboardData: { getData: () => 1 } }),
).toEqual({ type: 'paste' });
});
it('should extract safe target properties', () => {
const result = serializeEvent({
type: 'change',
@@ -0,0 +1,34 @@
import { isFunction, isObject, isString } from '@sniptt/guards';
import { MAX_SERIALIZED_EVENT_TEXT_LENGTH } from '@/host/constants/MaxSerializedEventTextLength';
import { type SerializedEventData } from '@/types/SerializedEventData';
export const applyPasteClipboardText = (
serializedEvent: SerializedEventData,
domEvent: Record<string, unknown>,
): void => {
if (serializedEvent.type !== 'paste') {
return;
}
const clipboardData = domEvent.clipboardData;
if (!isObject(clipboardData)) {
return;
}
const getData = (clipboardData as Record<string, unknown>).getData;
if (!isFunction(getData)) {
return;
}
const clipboardText = getData.call(clipboardData, 'text');
if (isString(clipboardText)) {
serializedEvent.clipboardText = clipboardText.slice(
0,
MAX_SERIALIZED_EVENT_TEXT_LENGTH,
);
}
};
@@ -1,7 +1,9 @@
import { isBoolean, isNumber, isObject, isString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { MAX_SERIALIZED_EVENT_TEXT_LENGTH } from '@/host/constants/MaxSerializedEventTextLength';
import { applyFirstChangedTouchCoordinates } from '@/host/utils/applyFirstChangedTouchCoordinates';
import { applyPasteClipboardText } from '@/host/utils/applyPasteClipboardText';
import { serializeFileList } from '@/host/utils/serializeFileList';
import { type SerializedEventData } from '@/types/SerializedEventData';
@@ -114,6 +116,15 @@ export const serializeEvent = (event: unknown): SerializedEventData => {
serialized.repeat = domEvent.repeat;
}
if (isString(domEvent.inputType)) {
serialized.inputType = domEvent.inputType;
}
if (isString(domEvent.data)) {
serialized.data = domEvent.data.slice(0, MAX_SERIALIZED_EVENT_TEXT_LENGTH);
}
applyPasteClipboardText(serialized, domEvent);
if (isNumber(domEvent.deltaX)) {
serialized.deltaX = domEvent.deltaX;
}
@@ -1586,6 +1586,13 @@ export const HtmlInput = createRemoteComponent('html-input', HtmlInputElement, {
onAnimationEnd: { event: 'animationend' },
onTransitionEnd: { event: 'transitionend' },
onScrollEnd: { event: 'scrollend' },
onBeforeInput: { event: 'beforeinput' },
onCompositionStart: { event: 'compositionstart' },
onCompositionUpdate: { event: 'compositionupdate' },
onCompositionEnd: { event: 'compositionend' },
onCopy: { event: 'copy' },
onPaste: { event: 'paste' },
onCut: { event: 'cut' },
},
});
export const HtmlTextarea = createRemoteComponent(
@@ -1637,6 +1644,13 @@ export const HtmlTextarea = createRemoteComponent(
onAnimationEnd: { event: 'animationend' },
onTransitionEnd: { event: 'transitionend' },
onScrollEnd: { event: 'scrollend' },
onBeforeInput: { event: 'beforeinput' },
onCompositionStart: { event: 'compositionstart' },
onCompositionUpdate: { event: 'compositionupdate' },
onCompositionEnd: { event: 'compositionend' },
onCopy: { event: 'copy' },
onPaste: { event: 'paste' },
onCut: { event: 'cut' },
},
},
);
@@ -540,7 +540,15 @@ export const HtmlInputElement = createRemoteElement<
HtmlInputProperties,
Record<string, never>,
Record<string, never>,
HtmlCommonEvents
HtmlCommonEvents & {
beforeinput(event: RemoteEvent<SerializedEventData>): void;
compositionstart(event: RemoteEvent<SerializedEventData>): void;
compositionupdate(event: RemoteEvent<SerializedEventData>): void;
compositionend(event: RemoteEvent<SerializedEventData>): void;
copy(event: RemoteEvent<SerializedEventData>): void;
paste(event: RemoteEvent<SerializedEventData>): void;
cut(event: RemoteEvent<SerializedEventData>): void;
}
>({
properties: {
...HTML_COMMON_PROPERTIES_CONFIG,
@@ -557,6 +565,13 @@ export const HtmlInputElement = createRemoteElement<
},
events: {
...HTML_COMMON_EVENTS_CONFIG,
beforeinput: createSerializedEventConfig('beforeinput'),
compositionstart: createSerializedEventConfig('compositionstart'),
compositionupdate: createSerializedEventConfig('compositionupdate'),
compositionend: createSerializedEventConfig('compositionend'),
copy: createSerializedEventConfig('copy'),
paste: createSerializedEventConfig('paste'),
cut: createSerializedEventConfig('cut'),
},
});
@@ -574,7 +589,15 @@ export const HtmlTextareaElement = createRemoteElement<
HtmlTextareaProperties,
Record<string, never>,
Record<string, never>,
HtmlCommonEvents
HtmlCommonEvents & {
beforeinput(event: RemoteEvent<SerializedEventData>): void;
compositionstart(event: RemoteEvent<SerializedEventData>): void;
compositionupdate(event: RemoteEvent<SerializedEventData>): void;
compositionend(event: RemoteEvent<SerializedEventData>): void;
copy(event: RemoteEvent<SerializedEventData>): void;
paste(event: RemoteEvent<SerializedEventData>): void;
cut(event: RemoteEvent<SerializedEventData>): void;
}
>({
properties: {
...HTML_COMMON_PROPERTIES_CONFIG,
@@ -588,6 +611,13 @@ export const HtmlTextareaElement = createRemoteElement<
},
events: {
...HTML_COMMON_EVENTS_CONFIG,
beforeinput: createSerializedEventConfig('beforeinput'),
compositionstart: createSerializedEventConfig('compositionstart'),
compositionupdate: createSerializedEventConfig('compositionupdate'),
compositionend: createSerializedEventConfig('compositionend'),
copy: createSerializedEventConfig('copy'),
paste: createSerializedEventConfig('paste'),
cut: createSerializedEventConfig('cut'),
},
});
@@ -33,6 +33,9 @@ export type SerializedEventData = {
key?: string;
code?: string;
repeat?: boolean;
inputType?: string;
data?: string;
clipboardText?: string;
value?: string;
checked?: boolean;
scrollTop?: number;