- Add 72 missing HTML and SVG elements to the remote-dom component registry (48 HTML + 24 SVG), bringing the total from 47 to 119 supported elements - HTML additions include semantic inline text (b, i, u, s, mark, sub, sup, kbd, etc.), description lists, ruby annotations, structural elements (figure, details, dialog), and form utilities (fieldset, progress, meter, optgroup) - SVG additions include containers (svg, g, defs), shapes (path, circle, rect, line, polygon), text (text, tspan), gradients (linearGradient, radialGradient, stop), and utilities (clipPath, mask, foreignObject, marker) - Add htmlTag override to support SVG elements with camelCase names (e.g. clipPath, foreignObject) while keeping custom element tags lowercase per the Web Components spec
184 lines
5.9 KiB
TypeScript
184 lines
5.9 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 ('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,
|
|
);
|
|
};
|