Drop unsafe props from front component elements (#22458)
## What Front components are third-party React components rendered into the host page through a restricted element allow-list. `filterProps` (where their props become real DOM attributes) used to forward unrecognized values as-is, which left two ways to run script in the host origin: - an `on*` attribute with a string value, which React renders as an inline event handler; - a dangerous-scheme URL (`javascript:`, `data:`, `vbscript:`) on a link, which executes on navigation. ## Change `filterProps` now drops both: - `on*` props are kept only when the value is a real function (still wrapped as before); any non-function `on*` is dropped. - `javascript:` / `data:` / `vbscript:` URLs are dropped, but only on **navigation targets** (`<a>`/`<area>` `href`/`xlink:href`, `<form>` `action`, `<button>`/`<input>` `formaction`), after normalizing away control-character obfuscation (e.g. `java\tscript:`). Resource-loading attributes are left alone, so `<img src="data:image/...">` keeps working. Well-behaved components are unaffected: function handlers are still wrapped and normal URLs pass through. Host-side only, no worker or SDK changes. ## Scope: the actual behavior change is small The diff looks large, but most of it is **not** a behavior change. `createHtmlHostWrapper.ts` (~460 lines) was split into one-export-per-file utils (`filterProps`, `serializeEvent`, `parseCssString`, `hasDangerousUrlScheme`, etc.), each with its own unit test, leaving `createHtmlHostWrapper.ts` as a thin orchestrator. Those helpers were **moved unchanged** — the only real logic change is the `filterProps` hardening described above. The pre-existing render-based integration test passes untouched, which confirms the split is behavior-neutral; the rest of the new files are extractions plus added test coverage. ## Why these schemes, and only on navigation targets Per MDN, `javascript:` (and `data:`) URLs are dangerous specifically where a URL is a *navigation target*, not where it is a *resource location* (like an image `src`) — which is exactly how the check is scoped: - [`javascript:` URLs (MDN)](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/javascript) - [`data:` URLs (MDN)](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/data) - [URI schemes overview (MDN)](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes) This is a prerequisite for later work that widens the raw-attribute surface (innerHTML rendering).
This commit is contained in:
+84
@@ -0,0 +1,84 @@
|
||||
import { type ReactElement } from 'react';
|
||||
|
||||
import { createCaretPreservingElement } from '../createCaretPreservingElement';
|
||||
|
||||
const getProps = (element: ReactElement): Record<string, unknown> =>
|
||||
element.props as Record<string, unknown>;
|
||||
|
||||
describe('createCaretPreservingElement', () => {
|
||||
it('should create an element of the requested tag', () => {
|
||||
const element = createCaretPreservingElement(
|
||||
'input',
|
||||
{ type: 'text' },
|
||||
undefined,
|
||||
null,
|
||||
);
|
||||
|
||||
expect(element.type).toBe('input');
|
||||
expect(getProps(element).type).toBe('text');
|
||||
});
|
||||
|
||||
it('should seed the initial value from value', () => {
|
||||
const element = createCaretPreservingElement(
|
||||
'input',
|
||||
{ value: 'hello' },
|
||||
undefined,
|
||||
null,
|
||||
);
|
||||
|
||||
expect(getProps(element).defaultValue).toBe('hello');
|
||||
});
|
||||
|
||||
it('should prefer defaultValue over value for the initial value', () => {
|
||||
const element = createCaretPreservingElement(
|
||||
'input',
|
||||
{ value: 'v', defaultValue: 'd' },
|
||||
undefined,
|
||||
null,
|
||||
);
|
||||
|
||||
expect(getProps(element).defaultValue).toBe('d');
|
||||
});
|
||||
|
||||
it('should apply forced props', () => {
|
||||
const element = createCaretPreservingElement(
|
||||
'textarea',
|
||||
{},
|
||||
{ readOnly: true },
|
||||
null,
|
||||
);
|
||||
|
||||
expect(getProps(element).readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('should notify focus state and forward the original focus handler', () => {
|
||||
const setEditableFocused = jest.fn();
|
||||
const onFocus = jest.fn();
|
||||
const element = createCaretPreservingElement(
|
||||
'input',
|
||||
{ onFocus },
|
||||
undefined,
|
||||
setEditableFocused,
|
||||
);
|
||||
|
||||
const event = {} as never;
|
||||
(getProps(element).onFocus as (event: unknown) => void)(event);
|
||||
|
||||
expect(setEditableFocused).toHaveBeenCalledWith(true);
|
||||
expect(onFocus).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it('should notify blur state', () => {
|
||||
const setEditableFocused = jest.fn();
|
||||
const element = createCaretPreservingElement(
|
||||
'input',
|
||||
{},
|
||||
undefined,
|
||||
setEditableFocused,
|
||||
);
|
||||
|
||||
(getProps(element).onBlur as (event: unknown) => void)({} as never);
|
||||
|
||||
expect(setEditableFocused).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import './setupServerRenderingGlobals';
|
||||
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { createHtmlHostWrapper } from '../createHtmlHostWrapper';
|
||||
|
||||
const renderWrapper = (
|
||||
htmlTag: string,
|
||||
props: Record<string, unknown>,
|
||||
children?: string,
|
||||
): string =>
|
||||
renderToStaticMarkup(
|
||||
createElement(createHtmlHostWrapper(htmlTag), props, children),
|
||||
);
|
||||
|
||||
describe('createHtmlHostWrapper prop hardening', () => {
|
||||
it('should drop an on* attribute whose value is not a function', () => {
|
||||
const markup = renderWrapper('div', { onmouseover: 'alert(1)' });
|
||||
|
||||
expect(markup).not.toContain('onmouseover');
|
||||
expect(markup).not.toContain('alert(1)');
|
||||
});
|
||||
|
||||
it('should drop a normalized event attribute whose value is not a function', () => {
|
||||
const markup = renderWrapper('div', { onClick: 'alert(1)' });
|
||||
|
||||
expect(markup).not.toContain('alert(1)');
|
||||
});
|
||||
|
||||
it('should drop a javascript: url on href', () => {
|
||||
const markup = renderWrapper('a', { href: 'javascript:alert(1)' }, 'link');
|
||||
|
||||
expect(markup).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
it('should drop a data: url on href', () => {
|
||||
const markup = renderWrapper(
|
||||
'a',
|
||||
{ href: 'data:text/html,<script>alert(1)</script>' },
|
||||
'link',
|
||||
);
|
||||
|
||||
expect(markup).not.toContain('data:');
|
||||
});
|
||||
|
||||
it('should drop a vbscript: url on href', () => {
|
||||
const markup = renderWrapper('a', { href: 'vbscript:msgbox(1)' }, 'link');
|
||||
|
||||
expect(markup).not.toContain('vbscript:');
|
||||
});
|
||||
|
||||
it('should drop a javascript: url on an anchor xlink:href', () => {
|
||||
const markup = renderWrapper(
|
||||
'a',
|
||||
{ 'xlink:href': 'javascript:alert(1)' },
|
||||
'link',
|
||||
);
|
||||
|
||||
expect(markup).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
it('should drop a javascript: url on a React-style anchor xlinkHref', () => {
|
||||
const markup = renderWrapper(
|
||||
'a',
|
||||
{ xlinkHref: 'javascript:alert(1)' },
|
||||
'link',
|
||||
);
|
||||
|
||||
expect(markup).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
it('should drop a javascript: url obfuscated with control characters', () => {
|
||||
const markup = renderWrapper(
|
||||
'a',
|
||||
{ href: 'java\tscript:alert(1)' },
|
||||
'link',
|
||||
);
|
||||
|
||||
expect(markup).not.toContain('script:');
|
||||
});
|
||||
|
||||
it('should keep a safe href', () => {
|
||||
const markup = renderWrapper('a', { href: 'https://twenty.com' }, 'link');
|
||||
|
||||
expect(markup).toContain('href="https://twenty.com"');
|
||||
});
|
||||
|
||||
it('should keep a data: image on src', () => {
|
||||
const dataImageUrl = 'data:image/png;base64,iVBORw0KGgo=';
|
||||
const markup = renderWrapper('img', { src: dataImageUrl });
|
||||
|
||||
expect(markup).toContain(dataImageUrl);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { filterProps } from '../filterProps';
|
||||
|
||||
const filter = (props: Record<string, unknown>, htmlTag: string) =>
|
||||
filterProps(props, htmlTag) as Record<string, unknown>;
|
||||
|
||||
describe('filterProps', () => {
|
||||
it('should drop internal remote-dom props', () => {
|
||||
const result = filter(
|
||||
{ element: {}, receiver: {}, components: {}, id: 'keep' },
|
||||
'div',
|
||||
);
|
||||
|
||||
expect(result).toEqual({ id: 'keep' });
|
||||
});
|
||||
|
||||
it('should drop undefined values', () => {
|
||||
const result = filter({ title: undefined, id: 'x' }, 'div');
|
||||
|
||||
expect('title' in result).toBe(false);
|
||||
expect(result.id).toBe('x');
|
||||
});
|
||||
|
||||
it('should parse the style string into an object', () => {
|
||||
const result = filter({ style: 'color: red' }, 'div');
|
||||
|
||||
expect(result.style).toEqual({ color: 'red' });
|
||||
});
|
||||
|
||||
it('should wrap function event handlers and normalize their key', () => {
|
||||
const onClick = jest.fn();
|
||||
const result = filter({ onClick }, 'div');
|
||||
|
||||
expect(typeof result.onClick).toBe('function');
|
||||
expect(result.onClick).not.toBe(onClick);
|
||||
});
|
||||
|
||||
it('should drop event-handler props whose value is not a function', () => {
|
||||
const result = filter({ onClick: 'alert(1)', onmouseover: 'x' }, 'div');
|
||||
|
||||
expect('onClick' in result).toBe(false);
|
||||
expect('onMouseOver' in result).toBe(false);
|
||||
expect('onmouseover' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('should drop a dangerous scheme on a navigation attribute', () => {
|
||||
const result = filter({ href: 'javascript:alert(1)' }, 'a');
|
||||
|
||||
expect('href' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('should keep a dangerous scheme on a non-navigation attribute', () => {
|
||||
const dataImage = 'data:image/png;base64,iVBOR';
|
||||
const result = filter({ src: dataImage }, 'img');
|
||||
|
||||
expect(result.src).toBe(dataImage);
|
||||
});
|
||||
|
||||
it('should keep a safe url on a navigation attribute', () => {
|
||||
const result = filter({ href: 'https://twenty.com' }, 'a');
|
||||
|
||||
expect(result.href).toBe('https://twenty.com');
|
||||
});
|
||||
});
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { hasDangerousUrlScheme } from '../hasDangerousUrlScheme';
|
||||
|
||||
describe('hasDangerousUrlScheme', () => {
|
||||
it('should detect javascript, data and vbscript schemes', () => {
|
||||
expect(hasDangerousUrlScheme('javascript:alert(1)')).toBe(true);
|
||||
expect(hasDangerousUrlScheme('data:text/html,<script>')).toBe(true);
|
||||
expect(hasDangerousUrlScheme('vbscript:msgbox(1)')).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore casing', () => {
|
||||
expect(hasDangerousUrlScheme('JavaScript:alert(1)')).toBe(true);
|
||||
});
|
||||
|
||||
it('should see through leading and interior control-character obfuscation', () => {
|
||||
expect(hasDangerousUrlScheme(' javascript:alert(1)')).toBe(true);
|
||||
expect(hasDangerousUrlScheme('java\tscript:alert(1)')).toBe(true);
|
||||
expect(hasDangerousUrlScheme('javascript:alert(1)')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow safe schemes and relative urls', () => {
|
||||
expect(hasDangerousUrlScheme('https://twenty.com')).toBe(false);
|
||||
expect(hasDangerousUrlScheme('mailto:hi@twenty.com')).toBe(false);
|
||||
expect(hasDangerousUrlScheme('/relative/path')).toBe(false);
|
||||
expect(hasDangerousUrlScheme('#anchor')).toBe(false);
|
||||
expect(hasDangerousUrlScheme('blob:https://twenty.com/abc')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-string values', () => {
|
||||
expect(hasDangerousUrlScheme(undefined)).toBe(false);
|
||||
expect(hasDangerousUrlScheme(123)).toBe(false);
|
||||
expect(hasDangerousUrlScheme({})).toBe(false);
|
||||
});
|
||||
});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { isEventHandlerKey } from '../isEventHandlerKey';
|
||||
|
||||
describe('isEventHandlerKey', () => {
|
||||
it('should match keys starting with on regardless of casing', () => {
|
||||
expect(isEventHandlerKey('onClick')).toBe(true);
|
||||
expect(isEventHandlerKey('onclick')).toBe(true);
|
||||
expect(isEventHandlerKey('ONMOUSEOVER')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match non-event keys', () => {
|
||||
expect(isEventHandlerKey('href')).toBe(false);
|
||||
expect(isEventHandlerKey('className')).toBe(false);
|
||||
expect(isEventHandlerKey('data-testid')).toBe(false);
|
||||
});
|
||||
});
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { isNavigationUrlAttribute } from '../isNavigationUrlAttribute';
|
||||
|
||||
describe('isNavigationUrlAttribute', () => {
|
||||
it('should treat anchor href and xlink:href as navigation attributes', () => {
|
||||
expect(isNavigationUrlAttribute('a', 'href')).toBe(true);
|
||||
expect(isNavigationUrlAttribute('a', 'xlink:href')).toBe(true);
|
||||
expect(isNavigationUrlAttribute('a', 'xlinkHref')).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore casing of the tag and the attribute', () => {
|
||||
expect(isNavigationUrlAttribute('A', 'HREF')).toBe(true);
|
||||
});
|
||||
|
||||
it('should treat form action and formaction as navigation attributes', () => {
|
||||
expect(isNavigationUrlAttribute('area', 'href')).toBe(true);
|
||||
expect(isNavigationUrlAttribute('form', 'action')).toBe(true);
|
||||
expect(isNavigationUrlAttribute('button', 'formaction')).toBe(true);
|
||||
expect(isNavigationUrlAttribute('input', 'formaction')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not treat resource-loading attributes as navigation', () => {
|
||||
expect(isNavigationUrlAttribute('img', 'src')).toBe(false);
|
||||
expect(isNavigationUrlAttribute('a', 'src')).toBe(false);
|
||||
expect(isNavigationUrlAttribute('image', 'xlink:href')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match attributes on unrelated tags', () => {
|
||||
expect(isNavigationUrlAttribute('div', 'href')).toBe(false);
|
||||
expect(isNavigationUrlAttribute('a', 'action')).toBe(false);
|
||||
});
|
||||
});
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { isTextLikeInputType } from '../isTextLikeInputType';
|
||||
|
||||
describe('isTextLikeInputType', () => {
|
||||
it('should treat text-like input types as text-like', () => {
|
||||
expect(isTextLikeInputType('text')).toBe(true);
|
||||
expect(isTextLikeInputType('email')).toBe(true);
|
||||
expect(isTextLikeInputType('password')).toBe(true);
|
||||
expect(isTextLikeInputType('number')).toBe(true);
|
||||
});
|
||||
|
||||
it('should treat a missing type as text-like', () => {
|
||||
expect(isTextLikeInputType(undefined)).toBe(true);
|
||||
expect(isTextLikeInputType('')).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore casing', () => {
|
||||
expect(isTextLikeInputType('TEXT')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not treat non-text input types as text-like', () => {
|
||||
expect(isTextLikeInputType('checkbox')).toBe(false);
|
||||
expect(isTextLikeInputType('radio')).toBe(false);
|
||||
expect(isTextLikeInputType('file')).toBe(false);
|
||||
expect(isTextLikeInputType('button')).toBe(false);
|
||||
});
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { parseCssString } from '../parseCssString';
|
||||
|
||||
describe('parseCssString', () => {
|
||||
it('should return the input unchanged when it is not a non-empty string', () => {
|
||||
expect(parseCssString(undefined)).toBeUndefined();
|
||||
expect(parseCssString('')).toBe('');
|
||||
});
|
||||
|
||||
it('should convert kebab-case properties to camelCase', () => {
|
||||
expect(parseCssString('background-color: red')).toEqual({
|
||||
backgroundColor: 'red',
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep custom properties as-is', () => {
|
||||
expect(parseCssString('--my-var: 1px')).toEqual({ '--my-var': '1px' });
|
||||
});
|
||||
|
||||
it('should parse multiple declarations and tolerate a trailing semicolon', () => {
|
||||
expect(parseCssString('color: red; font-size: 12px;')).toEqual({
|
||||
color: 'red',
|
||||
fontSize: '12px',
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip declarations without a colon', () => {
|
||||
expect(parseCssString('color: red; invalid')).toEqual({ color: 'red' });
|
||||
});
|
||||
|
||||
it('should only split on the first colon so values may contain colons', () => {
|
||||
expect(parseCssString('background: url(http://example.com)')).toEqual({
|
||||
background: 'url(http://example.com)',
|
||||
});
|
||||
});
|
||||
});
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { serializeEvent } from '../serializeEvent';
|
||||
|
||||
describe('serializeEvent', () => {
|
||||
it('should return an unknown type for a non-object', () => {
|
||||
expect(serializeEvent(null)).toEqual({ type: 'unknown' });
|
||||
});
|
||||
|
||||
it('should default the type to unknown when missing', () => {
|
||||
expect(serializeEvent({})).toEqual({ type: 'unknown' });
|
||||
});
|
||||
|
||||
it('should copy only whitelisted, correctly-typed properties', () => {
|
||||
const result = serializeEvent({
|
||||
type: 'click',
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
altKey: true,
|
||||
key: 'Enter',
|
||||
unexpected: 'ignored',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'click',
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
altKey: true,
|
||||
key: 'Enter',
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore properties with the wrong type', () => {
|
||||
const result = serializeEvent({ type: 'wheel', deltaX: 'not-a-number' });
|
||||
|
||||
expect(result).toEqual({ type: 'wheel' });
|
||||
});
|
||||
|
||||
it('should extract safe target properties', () => {
|
||||
const result = serializeEvent({
|
||||
type: 'change',
|
||||
target: { value: 'hello', checked: true, scrollTop: 5 },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: 'change',
|
||||
value: 'hello',
|
||||
checked: true,
|
||||
scrollTop: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('should serialize target files', () => {
|
||||
const result = serializeEvent({
|
||||
type: 'change',
|
||||
target: {
|
||||
files: {
|
||||
length: 1,
|
||||
0: {
|
||||
name: 'a.png',
|
||||
size: 1,
|
||||
type: 'image/png',
|
||||
lastModified: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.files).toEqual([
|
||||
{ name: 'a.png', size: 1, type: 'image/png', lastModified: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { serializeFileList } from '../serializeFileList';
|
||||
|
||||
const validFile = {
|
||||
name: 'report.csv',
|
||||
size: 42,
|
||||
type: 'text/csv',
|
||||
lastModified: 1700000000000,
|
||||
};
|
||||
|
||||
describe('serializeFileList', () => {
|
||||
it('should return undefined for a non-object', () => {
|
||||
expect(serializeFileList(null)).toBeUndefined();
|
||||
expect(serializeFileList('files')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when there is no numeric length', () => {
|
||||
expect(serializeFileList({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should serialize only the safe metadata of each file', () => {
|
||||
const result = serializeFileList({
|
||||
length: 1,
|
||||
0: { ...validFile, arrayBuffer: () => {} },
|
||||
});
|
||||
|
||||
expect(result).toEqual([validFile]);
|
||||
});
|
||||
|
||||
it('should skip entries missing required fields', () => {
|
||||
const result = serializeFileList({
|
||||
length: 2,
|
||||
0: validFile,
|
||||
1: { name: 'incomplete' },
|
||||
});
|
||||
|
||||
expect(result).toEqual([validFile]);
|
||||
});
|
||||
});
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { TextDecoder, TextEncoder } from 'node:util';
|
||||
|
||||
class StubMessagePort {
|
||||
onmessage: unknown = null;
|
||||
postMessage(): void {}
|
||||
addEventListener(): void {}
|
||||
removeEventListener(): void {}
|
||||
start(): void {}
|
||||
close(): void {}
|
||||
}
|
||||
|
||||
class StubMessageChannel {
|
||||
port1 = new StubMessagePort();
|
||||
port2 = new StubMessagePort();
|
||||
}
|
||||
|
||||
const mutableGlobal = globalThis as unknown as Record<string, unknown>;
|
||||
|
||||
if (typeof mutableGlobal.MessageChannel === 'undefined') {
|
||||
mutableGlobal.MessageChannel = StubMessageChannel;
|
||||
}
|
||||
|
||||
if (typeof mutableGlobal.TextEncoder === 'undefined') {
|
||||
mutableGlobal.TextEncoder = TextEncoder;
|
||||
}
|
||||
|
||||
if (typeof mutableGlobal.TextDecoder === 'undefined') {
|
||||
mutableGlobal.TextDecoder = TextDecoder;
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { syncValuePreservingCaret } from '../syncValuePreservingCaret';
|
||||
|
||||
describe('syncValuePreservingCaret', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('should update the value when it differs', () => {
|
||||
const input = document.createElement('input');
|
||||
input.value = 'old';
|
||||
|
||||
syncValuePreservingCaret(input, 'new');
|
||||
|
||||
expect(input.value).toBe('new');
|
||||
});
|
||||
|
||||
it('should do nothing when the value is already equal', () => {
|
||||
const input = document.createElement('input');
|
||||
input.value = 'same';
|
||||
|
||||
syncValuePreservingCaret(input, 'same');
|
||||
|
||||
expect(input.value).toBe('same');
|
||||
});
|
||||
|
||||
it('should preserve the caret selection when the element is focused', () => {
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
input.value = 'hello world';
|
||||
input.focus();
|
||||
input.setSelectionRange(2, 5);
|
||||
|
||||
syncValuePreservingCaret(input, 'HELLO world');
|
||||
|
||||
expect(input.value).toBe('HELLO world');
|
||||
expect(input.selectionStart).toBe(2);
|
||||
expect(input.selectionEnd).toBe(5);
|
||||
});
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { wrapEventHandler } from '../wrapEventHandler';
|
||||
|
||||
describe('wrapEventHandler', () => {
|
||||
it('should return a function', () => {
|
||||
expect(typeof wrapEventHandler(() => {})).toBe('function');
|
||||
});
|
||||
|
||||
it('should invoke the handler with the serialized event', () => {
|
||||
const handler = jest.fn();
|
||||
|
||||
wrapEventHandler(handler)({ type: 'click', clientX: 3 });
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ type: 'click', clientX: 3 });
|
||||
});
|
||||
|
||||
it('should serialize away non-whitelisted event fields before calling the handler', () => {
|
||||
const handler = jest.fn();
|
||||
|
||||
wrapEventHandler(handler)({ type: 'click', secret: 'leaked' });
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ type: 'click' });
|
||||
});
|
||||
});
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { isFunction, isNonEmptyString } from '@sniptt/guards';
|
||||
import React from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type SetEditableFocused } from '@/host/contexts/FrontComponentInputFocusContext';
|
||||
import { syncValuePreservingCaret } from '@/host/utils/syncValuePreservingCaret';
|
||||
|
||||
type CaretPreservingElement = HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
export const createCaretPreservingElement = (
|
||||
htmlTag: 'input' | 'textarea',
|
||||
reactProps: Record<string, unknown>,
|
||||
forcedProps: Record<string, unknown> | undefined,
|
||||
setEditableFocused: SetEditableFocused | null,
|
||||
) => {
|
||||
const {
|
||||
value,
|
||||
defaultValue,
|
||||
onFocus: forwardedOnFocus,
|
||||
onBlur: forwardedOnBlur,
|
||||
...rest
|
||||
} = reactProps;
|
||||
const initialValue = isNonEmptyString(defaultValue)
|
||||
? defaultValue
|
||||
: isNonEmptyString(value)
|
||||
? value
|
||||
: undefined;
|
||||
|
||||
const handleFocus = (event: React.FocusEvent<CaretPreservingElement>) => {
|
||||
setEditableFocused?.(true);
|
||||
if (isFunction(forwardedOnFocus)) {
|
||||
forwardedOnFocus(event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = (event: React.FocusEvent<CaretPreservingElement>) => {
|
||||
setEditableFocused?.(false);
|
||||
if (isFunction(forwardedOnBlur)) {
|
||||
forwardedOnBlur(event);
|
||||
}
|
||||
};
|
||||
|
||||
return React.createElement(htmlTag, {
|
||||
...rest,
|
||||
...forcedProps,
|
||||
defaultValue: initialValue,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
ref: (node: CaretPreservingElement | null) => {
|
||||
if (!isDefined(node)) {
|
||||
return;
|
||||
}
|
||||
if (isNonEmptyString(value)) {
|
||||
syncValuePreservingCaret(node, value);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,33 +1,11 @@
|
||||
import {
|
||||
isBoolean,
|
||||
isFunction,
|
||||
isNonEmptyString,
|
||||
isNumber,
|
||||
isObject,
|
||||
isString,
|
||||
isUndefined,
|
||||
} from '@sniptt/guards';
|
||||
import React, { useContext } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { EVENT_TO_REACT } from '@/constants/EventToReact';
|
||||
import { type SerializedEventData } from '@/types/SerializedEventData';
|
||||
import { type SerializedFileData } from '@/types/SerializedFileData';
|
||||
import {
|
||||
FrontComponentInputFocusContext,
|
||||
type SetEditableFocused,
|
||||
} from '@/host/contexts/FrontComponentInputFocusContext';
|
||||
import { FrontComponentInputFocusContext } from '@/host/contexts/FrontComponentInputFocusContext';
|
||||
import { createCaretPreservingElement } from '@/host/utils/createCaretPreservingElement';
|
||||
import { filterProps } from '@/host/utils/filterProps';
|
||||
import { isTextLikeInputType } from '@/host/utils/isTextLikeInputType';
|
||||
import { sanitizeIframeSandbox } from '@/host/utils/sanitizeIframeSandbox';
|
||||
|
||||
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',
|
||||
@@ -44,365 +22,8 @@ const VOID_ELEMENTS = new Set([
|
||||
'wbr',
|
||||
]);
|
||||
|
||||
const parseCssString = (
|
||||
styleString: string | undefined,
|
||||
): React.CSSProperties | undefined => {
|
||||
if (!isNonEmptyString(styleString)) {
|
||||
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 serializeFileList = (
|
||||
files: unknown,
|
||||
): SerializedFileData[] | undefined => {
|
||||
if (!isObject(files)) {
|
||||
return undefined;
|
||||
}
|
||||
const fileListLike = files as { length?: unknown } & Record<number, unknown>;
|
||||
if (!isNumber(fileListLike.length)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const serialized: SerializedFileData[] = [];
|
||||
for (let index = 0; index < fileListLike.length; index++) {
|
||||
const file = fileListLike[index];
|
||||
if (!isObject(file)) {
|
||||
continue;
|
||||
}
|
||||
const fileRecord = file as Record<string, unknown>;
|
||||
if (
|
||||
!isString(fileRecord.name) ||
|
||||
!isNumber(fileRecord.size) ||
|
||||
!isString(fileRecord.type) ||
|
||||
!isNumber(fileRecord.lastModified)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
serialized.push({
|
||||
name: fileRecord.name,
|
||||
size: fileRecord.size,
|
||||
type: fileRecord.type,
|
||||
lastModified: fileRecord.lastModified,
|
||||
});
|
||||
}
|
||||
|
||||
return serialized;
|
||||
};
|
||||
|
||||
const serializeEvent = (event: unknown): SerializedEventData => {
|
||||
if (!isObject(event)) {
|
||||
return { type: 'unknown' };
|
||||
}
|
||||
|
||||
const domEvent = event as Record<string, unknown>;
|
||||
const serialized: SerializedEventData = {
|
||||
type: isString(domEvent.type) ? domEvent.type : 'unknown',
|
||||
};
|
||||
|
||||
if (isBoolean(domEvent.altKey)) {
|
||||
serialized.altKey = domEvent.altKey;
|
||||
}
|
||||
if (isBoolean(domEvent.ctrlKey)) {
|
||||
serialized.ctrlKey = domEvent.ctrlKey;
|
||||
}
|
||||
if (isBoolean(domEvent.metaKey)) {
|
||||
serialized.metaKey = domEvent.metaKey;
|
||||
}
|
||||
if (isBoolean(domEvent.shiftKey)) {
|
||||
serialized.shiftKey = domEvent.shiftKey;
|
||||
}
|
||||
|
||||
if (isNumber(domEvent.clientX)) {
|
||||
serialized.clientX = domEvent.clientX;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (isNumber(domEvent.pageY)) {
|
||||
serialized.pageY = domEvent.pageY;
|
||||
}
|
||||
if (isNumber(domEvent.screenX)) {
|
||||
serialized.screenX = domEvent.screenX;
|
||||
}
|
||||
if (isNumber(domEvent.screenY)) {
|
||||
serialized.screenY = domEvent.screenY;
|
||||
}
|
||||
if (isNumber(domEvent.offsetX)) {
|
||||
serialized.offsetX = domEvent.offsetX;
|
||||
}
|
||||
if (isNumber(domEvent.offsetY)) {
|
||||
serialized.offsetY = domEvent.offsetY;
|
||||
}
|
||||
if (isNumber(domEvent.movementX)) {
|
||||
serialized.movementX = domEvent.movementX;
|
||||
}
|
||||
if (isNumber(domEvent.movementY)) {
|
||||
serialized.movementY = domEvent.movementY;
|
||||
}
|
||||
if (isNumber(domEvent.button)) {
|
||||
serialized.button = domEvent.button;
|
||||
}
|
||||
if (isNumber(domEvent.buttons)) {
|
||||
serialized.buttons = domEvent.buttons;
|
||||
}
|
||||
|
||||
if (isNumber(domEvent.pointerId)) {
|
||||
serialized.pointerId = domEvent.pointerId;
|
||||
}
|
||||
if (isString(domEvent.pointerType)) {
|
||||
serialized.pointerType = domEvent.pointerType;
|
||||
}
|
||||
if (isNumber(domEvent.pressure)) {
|
||||
serialized.pressure = domEvent.pressure;
|
||||
}
|
||||
if (isNumber(domEvent.tangentialPressure)) {
|
||||
serialized.tangentialPressure = domEvent.tangentialPressure;
|
||||
}
|
||||
if (isNumber(domEvent.tiltX)) {
|
||||
serialized.tiltX = domEvent.tiltX;
|
||||
}
|
||||
if (isNumber(domEvent.tiltY)) {
|
||||
serialized.tiltY = domEvent.tiltY;
|
||||
}
|
||||
if (isNumber(domEvent.twist)) {
|
||||
serialized.twist = domEvent.twist;
|
||||
}
|
||||
if (isNumber(domEvent.width)) {
|
||||
serialized.width = domEvent.width;
|
||||
}
|
||||
if (isNumber(domEvent.height)) {
|
||||
serialized.height = domEvent.height;
|
||||
}
|
||||
if (isBoolean(domEvent.isPrimary)) {
|
||||
serialized.isPrimary = domEvent.isPrimary;
|
||||
}
|
||||
|
||||
if (isString(domEvent.key)) {
|
||||
serialized.key = domEvent.key;
|
||||
}
|
||||
if (isString(domEvent.code)) {
|
||||
serialized.code = domEvent.code;
|
||||
}
|
||||
if (isBoolean(domEvent.repeat)) {
|
||||
serialized.repeat = domEvent.repeat;
|
||||
}
|
||||
|
||||
if (isNumber(domEvent.deltaX)) {
|
||||
serialized.deltaX = domEvent.deltaX;
|
||||
}
|
||||
if (isNumber(domEvent.deltaY)) {
|
||||
serialized.deltaY = domEvent.deltaY;
|
||||
}
|
||||
if (isNumber(domEvent.deltaZ)) {
|
||||
serialized.deltaZ = domEvent.deltaZ;
|
||||
}
|
||||
if (isNumber(domEvent.deltaMode)) {
|
||||
serialized.deltaMode = domEvent.deltaMode;
|
||||
}
|
||||
|
||||
const target = domEvent.target;
|
||||
if (isObject(target)) {
|
||||
const targetRecord = target as Record<string, unknown>;
|
||||
if (isString(targetRecord.value)) {
|
||||
serialized.value = targetRecord.value;
|
||||
}
|
||||
if (isBoolean(targetRecord.checked)) {
|
||||
serialized.checked = targetRecord.checked;
|
||||
}
|
||||
if (isNumber(targetRecord.scrollTop)) {
|
||||
serialized.scrollTop = targetRecord.scrollTop;
|
||||
}
|
||||
if (isNumber(targetRecord.scrollLeft)) {
|
||||
serialized.scrollLeft = targetRecord.scrollLeft;
|
||||
}
|
||||
if (isNumber(targetRecord.currentTime)) {
|
||||
serialized.currentTime = targetRecord.currentTime;
|
||||
}
|
||||
if (isNumber(targetRecord.duration)) {
|
||||
serialized.duration = targetRecord.duration;
|
||||
}
|
||||
if (isBoolean(targetRecord.paused)) {
|
||||
serialized.paused = targetRecord.paused;
|
||||
}
|
||||
if (isBoolean(targetRecord.ended)) {
|
||||
serialized.ended = targetRecord.ended;
|
||||
}
|
||||
if (isNumber(targetRecord.volume)) {
|
||||
serialized.volume = targetRecord.volume;
|
||||
}
|
||||
if (isBoolean(targetRecord.muted)) {
|
||||
serialized.muted = targetRecord.muted;
|
||||
}
|
||||
if (isNumber(targetRecord.playbackRate)) {
|
||||
serialized.playbackRate = targetRecord.playbackRate;
|
||||
}
|
||||
|
||||
const files = serializeFileList(targetRecord.files);
|
||||
if (isDefined(files)) {
|
||||
serialized.files = files;
|
||||
}
|
||||
}
|
||||
|
||||
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) || isUndefined(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'style') {
|
||||
filtered.style = parseCssString(value as string | undefined);
|
||||
} else {
|
||||
const normalizedKey = EVENT_NAME_MAP[key.toLowerCase()] || key;
|
||||
|
||||
if (normalizedKey.startsWith('on') && isFunction(value)) {
|
||||
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 TEXT_LIKE_INPUT_TYPES = new Set([
|
||||
'text',
|
||||
'search',
|
||||
'url',
|
||||
'tel',
|
||||
'password',
|
||||
'email',
|
||||
'number',
|
||||
'',
|
||||
]);
|
||||
|
||||
const isTextLikeInputType = (type: unknown): boolean => {
|
||||
const inputType = isString(type) ? type.toLowerCase() : '';
|
||||
return TEXT_LIKE_INPUT_TYPES.has(inputType);
|
||||
};
|
||||
|
||||
type CaretPreservingElement = HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
const syncValuePreservingCaret = (
|
||||
element: CaretPreservingElement,
|
||||
nextValue: string,
|
||||
): void => {
|
||||
if (element.value === nextValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFocused = document.activeElement === element;
|
||||
const start = isFocused ? element.selectionStart : null;
|
||||
const end = isFocused ? element.selectionEnd : null;
|
||||
|
||||
element.value = nextValue;
|
||||
|
||||
if (isFocused && isDefined(start) && isDefined(end)) {
|
||||
try {
|
||||
element.setSelectionRange(start, end);
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
|
||||
const createCaretPreservingElement = (
|
||||
htmlTag: 'input' | 'textarea',
|
||||
reactProps: Record<string, unknown>,
|
||||
forcedProps: Record<string, unknown> | undefined,
|
||||
setEditableFocused: SetEditableFocused | null,
|
||||
) => {
|
||||
const {
|
||||
value,
|
||||
defaultValue,
|
||||
onFocus: forwardedOnFocus,
|
||||
onBlur: forwardedOnBlur,
|
||||
...rest
|
||||
} = reactProps;
|
||||
const initialValue = isNonEmptyString(defaultValue)
|
||||
? defaultValue
|
||||
: isNonEmptyString(value)
|
||||
? value
|
||||
: undefined;
|
||||
|
||||
const handleFocus = (event: React.FocusEvent<CaretPreservingElement>) => {
|
||||
setEditableFocused?.(true);
|
||||
if (isFunction(forwardedOnFocus)) {
|
||||
forwardedOnFocus(event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = (event: React.FocusEvent<CaretPreservingElement>) => {
|
||||
setEditableFocused?.(false);
|
||||
if (isFunction(forwardedOnBlur)) {
|
||||
forwardedOnBlur(event);
|
||||
}
|
||||
};
|
||||
|
||||
return React.createElement(htmlTag, {
|
||||
...rest,
|
||||
...forcedProps,
|
||||
defaultValue: initialValue,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
ref: (node: CaretPreservingElement | null) => {
|
||||
if (!isDefined(node)) {
|
||||
return;
|
||||
}
|
||||
if (isNonEmptyString(value)) {
|
||||
syncValuePreservingCaret(node, value);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const createHtmlHostWrapper = (htmlTag: string) => {
|
||||
const isVoid = VOID_ELEMENTS.has(htmlTag);
|
||||
const isIframe = htmlTag === 'iframe';
|
||||
@@ -410,7 +31,7 @@ export const createHtmlHostWrapper = (htmlTag: string) => {
|
||||
|
||||
return ({ children, ...props }: WrapperProps) => {
|
||||
const setEditableFocused = useContext(FrontComponentInputFocusContext);
|
||||
const reactProps = filterProps(props);
|
||||
const reactProps = filterProps(props, htmlTag);
|
||||
|
||||
const forcedProps: Record<string, unknown> | undefined = isIframe
|
||||
? { sandbox: sanitizeIframeSandbox(reactProps.sandbox) }
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { isFunction, isUndefined } from '@sniptt/guards';
|
||||
|
||||
import { EVENT_TO_REACT } from '@/constants/EventToReact';
|
||||
import { hasDangerousUrlScheme } from '@/host/utils/hasDangerousUrlScheme';
|
||||
import { isEventHandlerKey } from '@/host/utils/isEventHandlerKey';
|
||||
import { isNavigationUrlAttribute } from '@/host/utils/isNavigationUrlAttribute';
|
||||
import { parseCssString } from '@/host/utils/parseCssString';
|
||||
import { wrapEventHandler } from '@/host/utils/wrapEventHandler';
|
||||
import { type SerializedEventData } from '@/types/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,
|
||||
]),
|
||||
);
|
||||
|
||||
export const filterProps = <T extends object>(props: T, htmlTag: string): T => {
|
||||
const filtered: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (INTERNAL_PROPS.has(key) || isUndefined(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'style') {
|
||||
filtered.style = parseCssString(value as string | undefined);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedKey = EVENT_NAME_MAP[key.toLowerCase()] || key;
|
||||
|
||||
if (isEventHandlerKey(normalizedKey)) {
|
||||
if (isFunction(value)) {
|
||||
filtered[normalizedKey] = wrapEventHandler(
|
||||
value as (detail: SerializedEventData) => void,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
isNavigationUrlAttribute(htmlTag, normalizedKey) &&
|
||||
hasDangerousUrlScheme(value)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
filtered[normalizedKey] = value;
|
||||
}
|
||||
|
||||
return filtered as T;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { isString } from '@sniptt/guards';
|
||||
|
||||
const DANGEROUS_URL_SCHEMES = ['javascript:', 'vbscript:', 'data:'];
|
||||
|
||||
export const hasDangerousUrlScheme = (value: unknown): boolean => {
|
||||
if (!isString(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedValue = value
|
||||
.replace(/[\u0000-\u0020\u007F-\u009F]/g, '')
|
||||
.toLowerCase();
|
||||
|
||||
return DANGEROUS_URL_SCHEMES.some((scheme) =>
|
||||
normalizedValue.startsWith(scheme),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export const isEventHandlerKey = (key: string): boolean =>
|
||||
key.toLowerCase().startsWith('on');
|
||||
@@ -0,0 +1,15 @@
|
||||
const NAVIGATION_URL_ATTRIBUTES_BY_TAG: Record<string, Set<string>> = {
|
||||
a: new Set(['href', 'xlinkhref']),
|
||||
area: new Set(['href']),
|
||||
form: new Set(['action']),
|
||||
button: new Set(['formaction']),
|
||||
input: new Set(['formaction']),
|
||||
};
|
||||
|
||||
export const isNavigationUrlAttribute = (
|
||||
htmlTag: string,
|
||||
key: string,
|
||||
): boolean =>
|
||||
NAVIGATION_URL_ATTRIBUTES_BY_TAG[htmlTag.toLowerCase()]?.has(
|
||||
key.toLowerCase().replace(/:/g, ''),
|
||||
) ?? false;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { isString } from '@sniptt/guards';
|
||||
|
||||
const TEXT_LIKE_INPUT_TYPES = new Set([
|
||||
'text',
|
||||
'search',
|
||||
'url',
|
||||
'tel',
|
||||
'password',
|
||||
'email',
|
||||
'number',
|
||||
'',
|
||||
]);
|
||||
|
||||
export const isTextLikeInputType = (type: unknown): boolean => {
|
||||
const inputType = isString(type) ? type.toLowerCase() : '';
|
||||
return TEXT_LIKE_INPUT_TYPES.has(inputType);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { type CSSProperties } from 'react';
|
||||
|
||||
export const parseCssString = (
|
||||
styleString: string | undefined,
|
||||
): CSSProperties | undefined => {
|
||||
if (!isNonEmptyString(styleString)) {
|
||||
return styleString as 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;
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
import { isBoolean, isNumber, isObject, isString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { serializeFileList } from '@/host/utils/serializeFileList';
|
||||
import { type SerializedEventData } from '@/types/SerializedEventData';
|
||||
|
||||
export const serializeEvent = (event: unknown): SerializedEventData => {
|
||||
if (!isObject(event)) {
|
||||
return { type: 'unknown' };
|
||||
}
|
||||
|
||||
const domEvent = event as Record<string, unknown>;
|
||||
const serialized: SerializedEventData = {
|
||||
type: isString(domEvent.type) ? domEvent.type : 'unknown',
|
||||
};
|
||||
|
||||
if (isBoolean(domEvent.altKey)) {
|
||||
serialized.altKey = domEvent.altKey;
|
||||
}
|
||||
if (isBoolean(domEvent.ctrlKey)) {
|
||||
serialized.ctrlKey = domEvent.ctrlKey;
|
||||
}
|
||||
if (isBoolean(domEvent.metaKey)) {
|
||||
serialized.metaKey = domEvent.metaKey;
|
||||
}
|
||||
if (isBoolean(domEvent.shiftKey)) {
|
||||
serialized.shiftKey = domEvent.shiftKey;
|
||||
}
|
||||
|
||||
if (isNumber(domEvent.clientX)) {
|
||||
serialized.clientX = domEvent.clientX;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (isNumber(domEvent.pageY)) {
|
||||
serialized.pageY = domEvent.pageY;
|
||||
}
|
||||
if (isNumber(domEvent.screenX)) {
|
||||
serialized.screenX = domEvent.screenX;
|
||||
}
|
||||
if (isNumber(domEvent.screenY)) {
|
||||
serialized.screenY = domEvent.screenY;
|
||||
}
|
||||
if (isNumber(domEvent.offsetX)) {
|
||||
serialized.offsetX = domEvent.offsetX;
|
||||
}
|
||||
if (isNumber(domEvent.offsetY)) {
|
||||
serialized.offsetY = domEvent.offsetY;
|
||||
}
|
||||
if (isNumber(domEvent.movementX)) {
|
||||
serialized.movementX = domEvent.movementX;
|
||||
}
|
||||
if (isNumber(domEvent.movementY)) {
|
||||
serialized.movementY = domEvent.movementY;
|
||||
}
|
||||
if (isNumber(domEvent.button)) {
|
||||
serialized.button = domEvent.button;
|
||||
}
|
||||
if (isNumber(domEvent.buttons)) {
|
||||
serialized.buttons = domEvent.buttons;
|
||||
}
|
||||
|
||||
if (isNumber(domEvent.pointerId)) {
|
||||
serialized.pointerId = domEvent.pointerId;
|
||||
}
|
||||
if (isString(domEvent.pointerType)) {
|
||||
serialized.pointerType = domEvent.pointerType;
|
||||
}
|
||||
if (isNumber(domEvent.pressure)) {
|
||||
serialized.pressure = domEvent.pressure;
|
||||
}
|
||||
if (isNumber(domEvent.tangentialPressure)) {
|
||||
serialized.tangentialPressure = domEvent.tangentialPressure;
|
||||
}
|
||||
if (isNumber(domEvent.tiltX)) {
|
||||
serialized.tiltX = domEvent.tiltX;
|
||||
}
|
||||
if (isNumber(domEvent.tiltY)) {
|
||||
serialized.tiltY = domEvent.tiltY;
|
||||
}
|
||||
if (isNumber(domEvent.twist)) {
|
||||
serialized.twist = domEvent.twist;
|
||||
}
|
||||
if (isNumber(domEvent.width)) {
|
||||
serialized.width = domEvent.width;
|
||||
}
|
||||
if (isNumber(domEvent.height)) {
|
||||
serialized.height = domEvent.height;
|
||||
}
|
||||
if (isBoolean(domEvent.isPrimary)) {
|
||||
serialized.isPrimary = domEvent.isPrimary;
|
||||
}
|
||||
|
||||
if (isString(domEvent.key)) {
|
||||
serialized.key = domEvent.key;
|
||||
}
|
||||
if (isString(domEvent.code)) {
|
||||
serialized.code = domEvent.code;
|
||||
}
|
||||
if (isBoolean(domEvent.repeat)) {
|
||||
serialized.repeat = domEvent.repeat;
|
||||
}
|
||||
|
||||
if (isNumber(domEvent.deltaX)) {
|
||||
serialized.deltaX = domEvent.deltaX;
|
||||
}
|
||||
if (isNumber(domEvent.deltaY)) {
|
||||
serialized.deltaY = domEvent.deltaY;
|
||||
}
|
||||
if (isNumber(domEvent.deltaZ)) {
|
||||
serialized.deltaZ = domEvent.deltaZ;
|
||||
}
|
||||
if (isNumber(domEvent.deltaMode)) {
|
||||
serialized.deltaMode = domEvent.deltaMode;
|
||||
}
|
||||
|
||||
const target = domEvent.target;
|
||||
if (isObject(target)) {
|
||||
const targetRecord = target as Record<string, unknown>;
|
||||
if (isString(targetRecord.value)) {
|
||||
serialized.value = targetRecord.value;
|
||||
}
|
||||
if (isBoolean(targetRecord.checked)) {
|
||||
serialized.checked = targetRecord.checked;
|
||||
}
|
||||
if (isNumber(targetRecord.scrollTop)) {
|
||||
serialized.scrollTop = targetRecord.scrollTop;
|
||||
}
|
||||
if (isNumber(targetRecord.scrollLeft)) {
|
||||
serialized.scrollLeft = targetRecord.scrollLeft;
|
||||
}
|
||||
if (isNumber(targetRecord.currentTime)) {
|
||||
serialized.currentTime = targetRecord.currentTime;
|
||||
}
|
||||
if (isNumber(targetRecord.duration)) {
|
||||
serialized.duration = targetRecord.duration;
|
||||
}
|
||||
if (isBoolean(targetRecord.paused)) {
|
||||
serialized.paused = targetRecord.paused;
|
||||
}
|
||||
if (isBoolean(targetRecord.ended)) {
|
||||
serialized.ended = targetRecord.ended;
|
||||
}
|
||||
if (isNumber(targetRecord.volume)) {
|
||||
serialized.volume = targetRecord.volume;
|
||||
}
|
||||
if (isBoolean(targetRecord.muted)) {
|
||||
serialized.muted = targetRecord.muted;
|
||||
}
|
||||
if (isNumber(targetRecord.playbackRate)) {
|
||||
serialized.playbackRate = targetRecord.playbackRate;
|
||||
}
|
||||
|
||||
const files = serializeFileList(targetRecord.files);
|
||||
if (isDefined(files)) {
|
||||
serialized.files = files;
|
||||
}
|
||||
}
|
||||
|
||||
return serialized;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { isNumber, isObject, isString } from '@sniptt/guards';
|
||||
|
||||
import { type SerializedFileData } from '@/types/SerializedFileData';
|
||||
|
||||
export const serializeFileList = (
|
||||
files: unknown,
|
||||
): SerializedFileData[] | undefined => {
|
||||
if (!isObject(files)) {
|
||||
return undefined;
|
||||
}
|
||||
const fileListLike = files as { length?: unknown } & Record<number, unknown>;
|
||||
if (!isNumber(fileListLike.length)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const serialized: SerializedFileData[] = [];
|
||||
for (let index = 0; index < fileListLike.length; index++) {
|
||||
const file = fileListLike[index];
|
||||
if (!isObject(file)) {
|
||||
continue;
|
||||
}
|
||||
const fileRecord = file as Record<string, unknown>;
|
||||
if (
|
||||
!isString(fileRecord.name) ||
|
||||
!isNumber(fileRecord.size) ||
|
||||
!isString(fileRecord.type) ||
|
||||
!isNumber(fileRecord.lastModified)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
serialized.push({
|
||||
name: fileRecord.name,
|
||||
size: fileRecord.size,
|
||||
type: fileRecord.type,
|
||||
lastModified: fileRecord.lastModified,
|
||||
});
|
||||
}
|
||||
|
||||
return serialized;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type CaretPreservingElement = HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
export const syncValuePreservingCaret = (
|
||||
element: CaretPreservingElement,
|
||||
nextValue: string,
|
||||
): void => {
|
||||
if (element.value === nextValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFocused = document.activeElement === element;
|
||||
const start = isFocused ? element.selectionStart : null;
|
||||
const end = isFocused ? element.selectionEnd : null;
|
||||
|
||||
element.value = nextValue;
|
||||
|
||||
if (isFocused && isDefined(start) && isDefined(end)) {
|
||||
try {
|
||||
element.setSelectionRange(start, end);
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { serializeEvent } from '@/host/utils/serializeEvent';
|
||||
import { type SerializedEventData } from '@/types/SerializedEventData';
|
||||
|
||||
export const wrapEventHandler =
|
||||
(handler: (detail: SerializedEventData) => void) =>
|
||||
(event: unknown): void => {
|
||||
handler(serializeEvent(event));
|
||||
};
|
||||
Reference in New Issue
Block a user