Files
twenty/packages/twenty-front-component-renderer/src/host/utils/createHtmlHostWrapper.ts
T
Charles BochetandGitHub 41571ea377 feat(front-component-renderer): forward offset/movement coordinates on serialised events (#20046)
## Summary

Adds `offsetX`, `offsetY`, `movementX`, `movementY` to
`SerializedEventData` and the host event serialiser so apps can reason
about element-relative pointer positions without trying to read the host
element's bounding rect (which is impossible from a remote-DOM worker).

## Motivation

I was building a front-component with click-to-drop-pin and trackpad
pan/zoom (custom OSM tile renderer). Two real bugs surfaced from the
current event-serialisation surface:

1. **Wheel pan/zoom was broken.** The host already forwards
`deltaX`/`deltaY`, but app authors naturally read them off the
React-style event handler argument as `e.deltaX`/`e.deltaY`. Because
remote-DOM bridges everything via `RemoteEvent extends
CustomEvent<Detail>`, the payload actually arrives at `e.detail.deltaX`.
Reading the wrong place gives `undefined`, and `undefined < 0 ===
false`, so every wheel notch zoomed in the same direction. App code now
uses `e.detail`, but this was a sharp papercut worth flagging in docs /
a helper (separate change).

2. **Element-local click coords are unobtainable from a worker.** With
only `clientX/Y`, an app needs the stage's bounding rect to translate
viewport coordinates to local — which can't be read across the worker
boundary. `offsetX`/`offsetY` close that gap with a one-read solution.
`movementX`/`movementY` round out the set for any future drag-style
interactions if `mousemove` later joins the allow-list.
2026-04-25 10:12:28 +00:00

190 lines
6.3 KiB
TypeScript

import React from 'react';
import { EVENT_TO_REACT } from '@/constants/EventToReact';
import { type SerializedEventData } from '@/constants/SerializedEventData';
const INTERNAL_PROPS = new Set(['element', 'receiver', 'components']);
const EVENT_NAME_MAP: Record<string, string> = Object.fromEntries(
Object.entries(EVENT_TO_REACT).map(([domEvent, reactProp]) => [
`on${domEvent}`,
reactProp,
]),
);
const VOID_ELEMENTS = new Set([
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'source',
'track',
'wbr',
]);
const parseCssString = (
styleString: string | undefined,
): React.CSSProperties | undefined => {
if (!styleString || typeof styleString !== 'string') {
return styleString as React.CSSProperties | undefined;
}
const style: Record<string, string> = {};
const declarations = styleString.split(';').filter(Boolean);
for (const declaration of declarations) {
const colonIndex = declaration.indexOf(':');
if (colonIndex === -1) continue;
const property = declaration.slice(0, colonIndex).trim();
const value = declaration.slice(colonIndex + 1).trim();
const isCssCustomProperty = property.startsWith('--');
const key = isCssCustomProperty
? property
: property.replace(/-([a-z])/g, (_, letter: string) =>
letter.toUpperCase(),
);
style[key] = value;
}
return style;
};
const serializeEvent = (event: unknown): SerializedEventData => {
if (!event || typeof event !== 'object') {
return { type: 'unknown' };
}
const domEvent = event as Record<string, unknown>;
const serialized: SerializedEventData = {
type: typeof domEvent.type === 'string' ? domEvent.type : 'unknown',
};
if ('altKey' in domEvent) serialized.altKey = domEvent.altKey as boolean;
if ('ctrlKey' in domEvent) serialized.ctrlKey = domEvent.ctrlKey as boolean;
if ('metaKey' in domEvent) serialized.metaKey = domEvent.metaKey as boolean;
if ('shiftKey' in domEvent)
serialized.shiftKey = domEvent.shiftKey as boolean;
if ('clientX' in domEvent) serialized.clientX = domEvent.clientX as number;
if ('clientY' in domEvent) serialized.clientY = domEvent.clientY as number;
if ('pageX' in domEvent) serialized.pageX = domEvent.pageX as number;
if ('pageY' in domEvent) serialized.pageY = domEvent.pageY as number;
if ('screenX' in domEvent) serialized.screenX = domEvent.screenX as number;
if ('screenY' in domEvent) serialized.screenY = domEvent.screenY as number;
if ('offsetX' in domEvent) serialized.offsetX = domEvent.offsetX as number;
if ('offsetY' in domEvent) serialized.offsetY = domEvent.offsetY as number;
if ('movementX' in domEvent)
serialized.movementX = domEvent.movementX as number;
if ('movementY' in domEvent)
serialized.movementY = domEvent.movementY as number;
if ('button' in domEvent) serialized.button = domEvent.button as number;
if ('buttons' in domEvent) serialized.buttons = domEvent.buttons as number;
if ('key' in domEvent) serialized.key = domEvent.key as string;
if ('code' in domEvent) serialized.code = domEvent.code as string;
if ('repeat' in domEvent) serialized.repeat = domEvent.repeat as boolean;
if ('deltaX' in domEvent) serialized.deltaX = domEvent.deltaX as number;
if ('deltaY' in domEvent) serialized.deltaY = domEvent.deltaY as number;
if ('deltaZ' in domEvent) serialized.deltaZ = domEvent.deltaZ as number;
if ('deltaMode' in domEvent)
serialized.deltaMode = domEvent.deltaMode as number;
const target = domEvent.target as Record<string, unknown> | undefined;
if (target && typeof target === 'object') {
if ('value' in target && typeof target.value === 'string') {
serialized.value = target.value;
}
if ('checked' in target && typeof target.checked === 'boolean') {
serialized.checked = target.checked;
}
if ('scrollTop' in target && typeof target.scrollTop === 'number') {
serialized.scrollTop = target.scrollTop;
}
if ('scrollLeft' in target && typeof target.scrollLeft === 'number') {
serialized.scrollLeft = target.scrollLeft;
}
if ('currentTime' in target && typeof target.currentTime === 'number') {
serialized.currentTime = target.currentTime;
}
if ('duration' in target && typeof target.duration === 'number') {
serialized.duration = target.duration;
}
if ('paused' in target && typeof target.paused === 'boolean') {
serialized.paused = target.paused;
}
if ('ended' in target && typeof target.ended === 'boolean') {
serialized.ended = target.ended;
}
if ('volume' in target && typeof target.volume === 'number') {
serialized.volume = target.volume;
}
if ('muted' in target && typeof target.muted === 'boolean') {
serialized.muted = target.muted;
}
if ('playbackRate' in target && typeof target.playbackRate === 'number') {
serialized.playbackRate = target.playbackRate;
}
}
return serialized;
};
const wrapEventHandler = (handler: (detail: SerializedEventData) => void) => {
return (event: unknown) => {
handler(serializeEvent(event));
};
};
const filterProps = <T extends object>(props: T): T => {
const filtered: Record<string, unknown> = {};
for (const [key, value] of Object.entries(props)) {
if (INTERNAL_PROPS.has(key) || value === undefined) continue;
if (key === 'style') {
filtered.style = parseCssString(value as string | undefined);
} else {
const normalizedKey = EVENT_NAME_MAP[key.toLowerCase()] || key;
if (normalizedKey.startsWith('on') && typeof value === 'function') {
filtered[normalizedKey] = wrapEventHandler(
value as (detail: SerializedEventData) => void,
);
} else {
filtered[normalizedKey] = value;
}
}
}
return filtered as T;
};
type WrapperProps = { children?: React.ReactNode } & Record<string, unknown>;
const FORCED_PROPS_BY_TAG: Record<string, Record<string, unknown>> = {
iframe: { sandbox: '' },
};
export const createHtmlHostWrapper = (htmlTag: string) => {
const isVoid = VOID_ELEMENTS.has(htmlTag);
const forcedProps = FORCED_PROPS_BY_TAG[htmlTag];
return ({ children, ...props }: WrapperProps) =>
React.createElement(
htmlTag,
{ ...filterProps(props), ...forcedProps },
isVoid ? undefined : children,
);
};