Fix front component pointer/mouse event coordinates (#21117)
Fixes https://github.com/twentyhq/twenty/issues/21000 Front-component event handlers read standard event fields (event.clientX, event.offsetX, …), but these were always undefined. On the remote side, serialized event data was passed only as the CustomEvent's detail — and CustomEvent ignores every constructor option except detail, so the values lived at event.detail.clientX and never on the event object itself. - Added `applySerializedEventProperties`, to copy a curated allowlist of event-level keys onto the event. Element/target state (value, checked, files, scroll, media props) stays in `applySerializedEventTargetProperties`, applied to this (the dispatch element = event.target). - Added x/y to `SerializedEventData` and to host-side serialization in `createHtmlHostWrapper`. - Added an `svg-pointer `story + `createHtmlTagPointerStory` Note: Also pinned @types/react to v18 so the renderer stops dragging in React 19 types and breaking typecheck.
This commit is contained in:
+24
-6
@@ -129,11 +129,22 @@ const generateCommonEventsType = (
|
||||
});
|
||||
writer.writeLine(');');
|
||||
writer.blankLine();
|
||||
writer.writeLine('return new CustomEvent(eventType, {');
|
||||
writer.writeLine('const event = new CustomEvent(eventType, {');
|
||||
writer.indent(() => {
|
||||
writer.writeLine('detail: eventData,');
|
||||
});
|
||||
writer.writeLine('}) as RemoteEvent<SerializedEventData>;');
|
||||
writer.blankLine();
|
||||
writer.writeLine('applySerializedEventProperties(');
|
||||
writer.indent(() => {
|
||||
writer.writeLine(
|
||||
'event as unknown as Record<string, unknown>,',
|
||||
);
|
||||
writer.writeLine('eventData,');
|
||||
});
|
||||
writer.writeLine(');');
|
||||
writer.blankLine();
|
||||
writer.writeLine('return event;');
|
||||
});
|
||||
writer.writeLine('},');
|
||||
});
|
||||
@@ -400,11 +411,18 @@ export const generateRemoteElements = (
|
||||
});
|
||||
|
||||
sourceFile.addImportDeclaration({
|
||||
moduleSpecifier: '@/constants/SerializedEventData',
|
||||
namedImports: [
|
||||
'applySerializedEventTargetProperties',
|
||||
{ name: 'SerializedEventData', isTypeOnly: true },
|
||||
],
|
||||
moduleSpecifier: '@/constants/applySerializedEventProperties',
|
||||
namedImports: ['applySerializedEventProperties'],
|
||||
});
|
||||
|
||||
sourceFile.addImportDeclaration({
|
||||
moduleSpecifier: '@/constants/applySerializedEventTargetProperties',
|
||||
namedImports: ['applySerializedEventTargetProperties'],
|
||||
});
|
||||
|
||||
sourceFile.addImportDeclaration({
|
||||
moduleSpecifier: '@/types/SerializedEventData',
|
||||
namedImports: [{ name: 'SerializedEventData', isTypeOnly: true }],
|
||||
});
|
||||
|
||||
const commonPropertyNames = new Set(Object.keys(commonProperties));
|
||||
|
||||
+5
@@ -8,6 +8,7 @@ import {
|
||||
import {
|
||||
createHtmlTagClickStory,
|
||||
createHtmlTagFocusStory,
|
||||
createHtmlTagPointerStory,
|
||||
} from '@/__stories__/shared/test-utils/createHtmlElementStory';
|
||||
|
||||
const meta: Meta<typeof FrontComponentRenderer> = {
|
||||
@@ -27,3 +28,7 @@ export const Click = createHtmlTagClickStory({
|
||||
export const FocusBlur = createHtmlTagFocusStory({
|
||||
frontComponentBundleName: 'svg-focus-blur',
|
||||
});
|
||||
|
||||
export const Pointer = createHtmlTagPointerStory({
|
||||
frontComponentBundleName: 'svg-pointer',
|
||||
});
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
EventLog,
|
||||
useEventLog,
|
||||
} from '@/__stories__/shared/front-components/event-log';
|
||||
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
|
||||
import { SVG_ROOT_STYLE } from '@/__stories__/shared/front-components/styles';
|
||||
import { useState } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
|
||||
const SvgPointerFrontComponent = () => {
|
||||
const [interactionCount, setInteractionCount] = useState(0);
|
||||
const [pointerCoordinates, setPointerCoordinates] = useState('');
|
||||
const { entries, pushEvent } = useEventLog();
|
||||
|
||||
return (
|
||||
<FrontComponentCard title="svg:pointer">
|
||||
<svg
|
||||
data-testid="subject"
|
||||
viewBox="0 0 200 120"
|
||||
onPointerDown={(event) => {
|
||||
setInteractionCount((previous) => previous + 1);
|
||||
setPointerCoordinates(`${event.clientX},${event.clientY}`);
|
||||
pushEvent(event);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
pushEvent(event);
|
||||
}}
|
||||
tabIndex={0}
|
||||
style={SVG_ROOT_STYLE}
|
||||
>
|
||||
<rect x="20" y="20" width="160" height="80" fill="#2563eb" />
|
||||
</svg>
|
||||
<span data-testid="front-component-value">{interactionCount}</span>
|
||||
<span data-testid="pointer-coordinates">{pointerCoordinates}</span>
|
||||
<EventLog entries={entries} />
|
||||
</FrontComponentCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'fc-svg-c-pointer-0000000-0000-0000-0000-000000000021',
|
||||
name: 'svg-pointer-front-component',
|
||||
description: 'Front component covering pointer events on <svg>',
|
||||
component: SvgPointerFrontComponent,
|
||||
});
|
||||
+36
@@ -24,6 +24,12 @@ export type LoggedEventEntry = {
|
||||
scrollLeft?: number;
|
||||
deltaX?: number;
|
||||
deltaY?: number;
|
||||
clientX?: number;
|
||||
clientY?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
movementX?: number;
|
||||
movementY?: number;
|
||||
};
|
||||
|
||||
const EVENT_LOG_STYLE = {
|
||||
@@ -216,6 +222,36 @@ export const useEventLog = () => {
|
||||
entry.deltaY = deltaY;
|
||||
}
|
||||
|
||||
const clientX = pickFromRecords(records, 'clientX', isNumberValue);
|
||||
if (isDefined(clientX)) {
|
||||
entry.clientX = clientX;
|
||||
}
|
||||
|
||||
const clientY = pickFromRecords(records, 'clientY', isNumberValue);
|
||||
if (isDefined(clientY)) {
|
||||
entry.clientY = clientY;
|
||||
}
|
||||
|
||||
const offsetX = pickFromRecords(records, 'offsetX', isNumberValue);
|
||||
if (isDefined(offsetX)) {
|
||||
entry.offsetX = offsetX;
|
||||
}
|
||||
|
||||
const offsetY = pickFromRecords(records, 'offsetY', isNumberValue);
|
||||
if (isDefined(offsetY)) {
|
||||
entry.offsetY = offsetY;
|
||||
}
|
||||
|
||||
const movementX = pickFromRecords(records, 'movementX', isNumberValue);
|
||||
if (isDefined(movementX)) {
|
||||
entry.movementX = movementX;
|
||||
}
|
||||
|
||||
const movementY = pickFromRecords(records, 'movementY', isNumberValue);
|
||||
if (isDefined(movementY)) {
|
||||
entry.movementY = movementY;
|
||||
}
|
||||
|
||||
return [...previousEntries, entry];
|
||||
});
|
||||
};
|
||||
|
||||
+29
-2
@@ -1,10 +1,10 @@
|
||||
import { type StoryObj } from '@storybook/react-vite';
|
||||
import { userEvent, within } from 'storybook/test';
|
||||
import { expect, fireEvent, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { type FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
|
||||
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';
|
||||
import { type FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
|
||||
|
||||
type Story = StoryObj<typeof FrontComponentRenderer>;
|
||||
|
||||
@@ -30,6 +30,33 @@ export const createHtmlTagClickStory = ({
|
||||
},
|
||||
});
|
||||
|
||||
export const createHtmlTagPointerStory = ({
|
||||
frontComponentBundleName,
|
||||
}: CreateHtmlTagStoryParams): Story =>
|
||||
runFrontComponentStory({
|
||||
frontComponentBundleName,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expectFrontComponentMounted(canvas);
|
||||
|
||||
const subject = await canvas.findByTestId('subject');
|
||||
|
||||
fireEvent.pointerDown(subject, { clientX: 123, clientY: 45 });
|
||||
|
||||
await expectEventLogged({
|
||||
canvas,
|
||||
matcher: { type: 'pointerdown', clientX: 123, clientY: 45 },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const coordinates = canvas.getByTestId('pointer-coordinates');
|
||||
|
||||
expect(coordinates.textContent).toBe('123,45');
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const createHtmlTagFocusStory = ({
|
||||
frontComponentBundleName,
|
||||
}: CreateHtmlTagStoryParams): Story =>
|
||||
|
||||
+6
@@ -17,6 +17,12 @@ type LoggedEventMatcher = {
|
||||
ctrlKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
altKey?: boolean;
|
||||
clientX?: number;
|
||||
clientY?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
movementX?: number;
|
||||
movementY?: number;
|
||||
files?: { name?: string; type?: string }[];
|
||||
};
|
||||
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
export type SerializedFileData = {
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
lastModified: number;
|
||||
};
|
||||
|
||||
export type SerializedEventData = {
|
||||
type: string;
|
||||
altKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
clientX?: number;
|
||||
clientY?: number;
|
||||
pageX?: number;
|
||||
pageY?: number;
|
||||
screenX?: number;
|
||||
screenY?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
movementX?: number;
|
||||
movementY?: number;
|
||||
button?: number;
|
||||
buttons?: number;
|
||||
pointerId?: number;
|
||||
pointerType?: string;
|
||||
pressure?: number;
|
||||
tangentialPressure?: number;
|
||||
tiltX?: number;
|
||||
tiltY?: number;
|
||||
twist?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
isPrimary?: boolean;
|
||||
key?: string;
|
||||
code?: string;
|
||||
repeat?: boolean;
|
||||
value?: string;
|
||||
checked?: boolean;
|
||||
scrollTop?: number;
|
||||
scrollLeft?: number;
|
||||
deltaX?: number;
|
||||
deltaY?: number;
|
||||
deltaZ?: number;
|
||||
deltaMode?: number;
|
||||
currentTime?: number;
|
||||
duration?: number;
|
||||
paused?: boolean;
|
||||
ended?: boolean;
|
||||
volume?: number;
|
||||
muted?: boolean;
|
||||
playbackRate?: number;
|
||||
files?: SerializedFileData[];
|
||||
};
|
||||
|
||||
export const applySerializedEventTargetProperties = (
|
||||
element: Record<string, unknown>,
|
||||
eventData: SerializedEventData,
|
||||
): void => {
|
||||
if ('value' in eventData) {
|
||||
element.value = eventData.value;
|
||||
}
|
||||
|
||||
if ('checked' in eventData) {
|
||||
element.checked = eventData.checked;
|
||||
}
|
||||
|
||||
if ('files' in eventData) {
|
||||
element.files = eventData.files;
|
||||
}
|
||||
|
||||
if ('scrollTop' in eventData) {
|
||||
element.scrollTop = eventData.scrollTop;
|
||||
}
|
||||
|
||||
if ('scrollLeft' in eventData) {
|
||||
element.scrollLeft = eventData.scrollLeft;
|
||||
}
|
||||
|
||||
if ('currentTime' in eventData) {
|
||||
element.currentTime = eventData.currentTime;
|
||||
}
|
||||
|
||||
if ('duration' in eventData) {
|
||||
element.duration = eventData.duration;
|
||||
}
|
||||
|
||||
if ('paused' in eventData) {
|
||||
element.paused = eventData.paused;
|
||||
}
|
||||
|
||||
if ('ended' in eventData) {
|
||||
element.ended = eventData.ended;
|
||||
}
|
||||
|
||||
if ('volume' in eventData) {
|
||||
element.volume = eventData.volume;
|
||||
}
|
||||
|
||||
if ('muted' in eventData) {
|
||||
element.muted = eventData.muted;
|
||||
}
|
||||
|
||||
if ('playbackRate' in eventData) {
|
||||
element.playbackRate = eventData.playbackRate;
|
||||
}
|
||||
};
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { type SerializedEventData } from '@/types/SerializedEventData';
|
||||
|
||||
const SERIALIZED_EVENT_PROPERTY_KEYS = [
|
||||
'altKey',
|
||||
'ctrlKey',
|
||||
'metaKey',
|
||||
'shiftKey',
|
||||
'clientX',
|
||||
'clientY',
|
||||
'x',
|
||||
'y',
|
||||
'pageX',
|
||||
'pageY',
|
||||
'screenX',
|
||||
'screenY',
|
||||
'offsetX',
|
||||
'offsetY',
|
||||
'movementX',
|
||||
'movementY',
|
||||
'button',
|
||||
'buttons',
|
||||
'pointerId',
|
||||
'pointerType',
|
||||
'pressure',
|
||||
'tangentialPressure',
|
||||
'tiltX',
|
||||
'tiltY',
|
||||
'twist',
|
||||
'width',
|
||||
'height',
|
||||
'isPrimary',
|
||||
'key',
|
||||
'code',
|
||||
'repeat',
|
||||
'deltaX',
|
||||
'deltaY',
|
||||
'deltaZ',
|
||||
'deltaMode',
|
||||
] as const satisfies readonly (keyof SerializedEventData)[];
|
||||
|
||||
export const applySerializedEventProperties = (
|
||||
event: Record<string, unknown>,
|
||||
eventData: SerializedEventData,
|
||||
): void => {
|
||||
for (const key of SERIALIZED_EVENT_PROPERTY_KEYS) {
|
||||
if (key in eventData) {
|
||||
event[key] = eventData[key];
|
||||
}
|
||||
}
|
||||
};
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { type SerializedEventData } from '@/types/SerializedEventData';
|
||||
|
||||
export const applySerializedEventTargetProperties = (
|
||||
element: Record<string, unknown>,
|
||||
eventData: SerializedEventData,
|
||||
): void => {
|
||||
if ('value' in eventData) {
|
||||
element.value = eventData.value;
|
||||
}
|
||||
|
||||
if ('checked' in eventData) {
|
||||
element.checked = eventData.checked;
|
||||
}
|
||||
|
||||
if ('files' in eventData) {
|
||||
element.files = eventData.files;
|
||||
}
|
||||
|
||||
if ('scrollTop' in eventData) {
|
||||
element.scrollTop = eventData.scrollTop;
|
||||
}
|
||||
|
||||
if ('scrollLeft' in eventData) {
|
||||
element.scrollLeft = eventData.scrollLeft;
|
||||
}
|
||||
|
||||
if ('currentTime' in eventData) {
|
||||
element.currentTime = eventData.currentTime;
|
||||
}
|
||||
|
||||
if ('duration' in eventData) {
|
||||
element.duration = eventData.duration;
|
||||
}
|
||||
|
||||
if ('paused' in eventData) {
|
||||
element.paused = eventData.paused;
|
||||
}
|
||||
|
||||
if ('ended' in eventData) {
|
||||
element.ended = eventData.ended;
|
||||
}
|
||||
|
||||
if ('volume' in eventData) {
|
||||
element.volume = eventData.volume;
|
||||
}
|
||||
|
||||
if ('muted' in eventData) {
|
||||
element.muted = eventData.muted;
|
||||
}
|
||||
|
||||
if ('playbackRate' in eventData) {
|
||||
element.playbackRate = eventData.playbackRate;
|
||||
}
|
||||
};
|
||||
@@ -11,10 +11,8 @@ import React, { useContext } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { EVENT_TO_REACT } from '@/constants/EventToReact';
|
||||
import {
|
||||
type SerializedEventData,
|
||||
type SerializedFileData,
|
||||
} from '@/constants/SerializedEventData';
|
||||
import { type SerializedEventData } from '@/types/SerializedEventData';
|
||||
import { type SerializedFileData } from '@/types/SerializedFileData';
|
||||
import {
|
||||
FrontComponentInputFocusContext,
|
||||
type SetEditableFocused,
|
||||
@@ -145,6 +143,12 @@ const serializeEvent = (event: unknown): SerializedEventData => {
|
||||
if (isNumber(domEvent.clientY)) {
|
||||
serialized.clientY = domEvent.clientY;
|
||||
}
|
||||
if (isNumber(domEvent.x)) {
|
||||
serialized.x = domEvent.x;
|
||||
}
|
||||
if (isNumber(domEvent.y)) {
|
||||
serialized.y = domEvent.y;
|
||||
}
|
||||
if (isNumber(domEvent.pageX)) {
|
||||
serialized.pageX = domEvent.pageX;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,9 @@ import {
|
||||
type RemoteElementEventListenerDefinition,
|
||||
type RemoteElementEventListenersDefinition,
|
||||
} from '@remote-dom/core/elements';
|
||||
import {
|
||||
applySerializedEventTargetProperties,
|
||||
type SerializedEventData,
|
||||
} from '@/constants/SerializedEventData';
|
||||
import { applySerializedEventProperties } from '@/constants/applySerializedEventProperties';
|
||||
import { applySerializedEventTargetProperties } from '@/constants/applySerializedEventTargetProperties';
|
||||
import { type SerializedEventData } from '@/types/SerializedEventData';
|
||||
|
||||
export type HtmlCommonProperties = {
|
||||
id?: string;
|
||||
@@ -94,9 +93,16 @@ const createSerializedEventConfig = (
|
||||
eventData,
|
||||
);
|
||||
|
||||
return new CustomEvent(eventType, {
|
||||
const event = new CustomEvent(eventType, {
|
||||
detail: eventData,
|
||||
}) as RemoteEvent<SerializedEventData>;
|
||||
|
||||
applySerializedEventProperties(
|
||||
event as unknown as Record<string, unknown>,
|
||||
eventData,
|
||||
);
|
||||
|
||||
return event;
|
||||
},
|
||||
});
|
||||
const HTML_COMMON_EVENTS_CONFIG = Object.fromEntries(
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { type SerializedFileData } from '@/types/SerializedFileData';
|
||||
|
||||
export type SerializedEventData = {
|
||||
type: string;
|
||||
altKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
clientX?: number;
|
||||
clientY?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
pageX?: number;
|
||||
pageY?: number;
|
||||
screenX?: number;
|
||||
screenY?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
movementX?: number;
|
||||
movementY?: number;
|
||||
button?: number;
|
||||
buttons?: number;
|
||||
pointerId?: number;
|
||||
pointerType?: string;
|
||||
pressure?: number;
|
||||
tangentialPressure?: number;
|
||||
tiltX?: number;
|
||||
tiltY?: number;
|
||||
twist?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
isPrimary?: boolean;
|
||||
key?: string;
|
||||
code?: string;
|
||||
repeat?: boolean;
|
||||
value?: string;
|
||||
checked?: boolean;
|
||||
scrollTop?: number;
|
||||
scrollLeft?: number;
|
||||
deltaX?: number;
|
||||
deltaY?: number;
|
||||
deltaZ?: number;
|
||||
deltaMode?: number;
|
||||
currentTime?: number;
|
||||
duration?: number;
|
||||
paused?: boolean;
|
||||
ended?: boolean;
|
||||
volume?: number;
|
||||
muted?: boolean;
|
||||
playbackRate?: number;
|
||||
files?: SerializedFileData[];
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export type SerializedFileData = {
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
lastModified: number;
|
||||
};
|
||||
Reference in New Issue
Block a user