## 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.
190 lines
6.3 KiB
TypeScript
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,
|
|
);
|
|
};
|