Teact: Support SVG (#3593)

This commit is contained in:
Alexander Zinchuk 2023-07-20 15:58:43 +02:00
parent 9b967cce31
commit 46d9278900
5 changed files with 132 additions and 62 deletions

View File

@ -28,7 +28,7 @@ import LockScreen from './main/LockScreen.async';
import AppInactive from './main/AppInactive';
import Transition from './ui/Transition';
import UiLoader from './common/UiLoader';
// import Test from './test/TestUpdateRef';
// import Test from './test/TestSvg';
import styles from './App.module.scss';

View File

@ -0,0 +1,51 @@
import React, { useState } from '../../lib/teact/teact';
export function App() {
const [stateValue, setStateValue] = useState(false);
return (
<div
className="App"
onClick={() => {
setStateValue((current) => !current);
}}
>
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" version="1.1">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:rgb(0,255,0);stop-opacity:1" />
<stop offset="100%" style="stop-color:rgb(0,0,255);stop-opacity:1" />
</linearGradient>
</defs>
{stateValue && (
<circle
cx="75"
cy="75"
r="50"
stroke-dasharray="85 20"
stroke="#dddddd"
stroke-linecap="round"
stroke-width="10"
fill="none"
/>
)}
<circle
cx="75"
cy="75"
r="50"
className="shared-canvas-container"
stroke-dashoffset={stateValue ? '140' : '0'}
stroke-dasharray={stateValue ? '85 160' : '85 1000'}
stroke="url(#grad1)"
stroke-linecap="round"
stroke-width="10"
fill="none"
/>
</svg>
</div>
);
}
export default App;

View File

@ -1,15 +1,15 @@
import { DEBUG } from '../../config';
type Handler = (e: Event) => void;
type DelegationRegistry = Map<HTMLElement, Handler>;
type DelegationRegistry = Map<Element, Handler>;
const NON_BUBBLEABLE_EVENTS = new Set(['scroll', 'mouseenter', 'mouseleave', 'load']);
const documentEventCounters: Record<string, number> = {};
const delegationRegistryByEventType: Record<string, DelegationRegistry> = {};
const delegatedEventTypesByElement = new Map<HTMLElement, Set<string>>();
const delegatedEventTypesByElement = new Map<Element, Set<string>>();
export function addEventListener(element: HTMLElement, propName: string, handler: Handler, asCapture = false) {
export function addEventListener(element: Element, propName: string, handler: Handler, asCapture = false) {
const eventType = resolveEventType(propName, element);
if (canUseEventDelegation(eventType, element, asCapture)) {
addDelegatedListener(eventType, element, handler);
@ -18,7 +18,7 @@ export function addEventListener(element: HTMLElement, propName: string, handler
}
}
export function removeEventListener(element: HTMLElement, propName: string, handler: Handler, asCapture = false) {
export function removeEventListener(element: Element, propName: string, handler: Handler, asCapture = false) {
const eventType = resolveEventType(propName, element);
if (canUseEventDelegation(eventType, element, asCapture)) {
removeDelegatedListener(eventType, element);
@ -27,7 +27,7 @@ export function removeEventListener(element: HTMLElement, propName: string, hand
}
}
function resolveEventType(propName: string, element: HTMLElement) {
function resolveEventType(propName: string, element: Element) {
const eventType = propName
.replace(/^on/, '')
.replace(/Capture$/, '').toLowerCase();
@ -54,7 +54,7 @@ function resolveEventType(propName: string, element: HTMLElement) {
return eventType;
}
function canUseEventDelegation(realEventType: string, element: HTMLElement, asCapture: boolean) {
function canUseEventDelegation(realEventType: string, element: Element, asCapture: boolean) {
return (
!asCapture
&& !NON_BUBBLEABLE_EVENTS.has(realEventType)
@ -63,7 +63,7 @@ function canUseEventDelegation(realEventType: string, element: HTMLElement, asCa
);
}
function addDelegatedListener(eventType: string, element: HTMLElement, handler: Handler) {
function addDelegatedListener(eventType: string, element: Element, handler: Handler) {
if (!documentEventCounters[eventType]) {
documentEventCounters[eventType] = 0;
document.addEventListener(eventType, handleEvent);
@ -74,7 +74,7 @@ function addDelegatedListener(eventType: string, element: HTMLElement, handler:
documentEventCounters[eventType]++;
}
function removeDelegatedListener(eventType: string, element: HTMLElement) {
function removeDelegatedListener(eventType: string, element: Element) {
documentEventCounters[eventType]--;
if (!documentEventCounters[eventType]) {
// Synchronous deletion on 0 will cause perf degradation in the case of 1 element
@ -86,7 +86,7 @@ function removeDelegatedListener(eventType: string, element: HTMLElement) {
delegatedEventTypesByElement.get(element)!.delete(eventType);
}
export function removeAllDelegatedListeners(element: HTMLElement) {
export function removeAllDelegatedListeners(element: Element) {
const eventTypes = delegatedEventTypesByElement.get(element);
if (!eventTypes) {
return;
@ -101,7 +101,7 @@ function handleEvent(realEvent: Event) {
if (events) {
let furtherCallsPrevented = false;
let current: HTMLElement = realEvent.target as HTMLElement;
let current: Element = realEvent.target as Element;
const stopPropagation = () => {
furtherCallsPrevented = true;
@ -138,7 +138,7 @@ function handleEvent(realEvent: Event) {
}
}
current = current.parentNode as HTMLElement;
current = current.parentNode as Element;
}
}
}
@ -151,7 +151,7 @@ function resolveDelegationRegistry(eventType: string) {
return delegationRegistryByEventType[eventType];
}
function resolveDelegatedEventTypes(element: HTMLElement) {
function resolveDelegatedEventTypes(element: Element) {
const existing = delegatedEventTypesByElement.get(element);
if (existing) {
return existing;

View File

@ -32,6 +32,8 @@ interface SelectionState {
isCaretAtEnd: boolean;
}
type DOMElement = HTMLElement | SVGElement;
const FILTERED_ATTRIBUTES = new Set(['key', 'ref', 'teactFastList', 'teactOrderKey']);
const HTML_ATTRIBUTES = new Set(['dir', 'role', 'form']);
const CONTROLLABLE_TAGS = ['INPUT', 'TEXTAREA', 'SELECT'];
@ -71,7 +73,7 @@ function render($element: VirtualElement | undefined, parentEl: HTMLElement) {
}
function renderWithVirtual<T extends VirtualElement | undefined>(
parentEl: HTMLElement,
parentEl: DOMElement,
$current: VirtualElement | undefined,
$new: T,
$parent: VirtualElementParent | VirtualDomHead,
@ -81,17 +83,22 @@ function renderWithVirtual<T extends VirtualElement | undefined>(
nextSibling?: ChildNode;
forceMoveToEnd?: boolean;
fragment?: DocumentFragment;
isSvg?: true;
} = {},
): T {
const { skipComponentUpdate, fragment } = options;
let { nextSibling } = options;
let { nextSibling, isSvg } = options;
const isCurrentComponent = $current && $current.type === VirtualType.Component;
const isNewComponent = $new && $new.type === VirtualType.Component;
const isCurrentComponent = $current?.type === VirtualType.Component;
const isNewComponent = $new?.type === VirtualType.Component;
const $newAsReal = $new as VirtualElementReal;
const isCurrentFragment = $current && !isCurrentComponent && $current.type === VirtualType.Fragment;
const isNewFragment = $new && !isNewComponent && $new.type === VirtualType.Fragment;
const isCurrentFragment = !isCurrentComponent && $current?.type === VirtualType.Fragment;
const isNewFragment = !isNewComponent && $new?.type === VirtualType.Fragment;
if ($new?.type === VirtualType.Tag && $new.tag === 'svg') {
isSvg = true;
}
if (
!skipComponentUpdate
@ -127,7 +134,9 @@ function renderWithVirtual<T extends VirtualElement | undefined>(
$new = initComponent(parentEl, $new as VirtualElementComponent, $parent, index) as unknown as typeof $new;
}
mountChildren(parentEl, $new as VirtualElementComponent | VirtualElementFragment, { nextSibling, fragment });
mountChildren(parentEl, $new as VirtualElementComponent | VirtualElementFragment, {
nextSibling, fragment, isSvg,
});
} else {
const canSetTextContent = !fragment
&& !nextSibling
@ -139,12 +148,12 @@ function renderWithVirtual<T extends VirtualElement | undefined>(
parentEl.textContent = $newAsReal.value;
$newAsReal.target = parentEl.firstChild!;
} else {
const node = createNode($newAsReal);
const node = createNode($newAsReal, isSvg);
$newAsReal.target = node;
insertBefore(fragment || parentEl, node, nextSibling);
if ($newAsReal.type === VirtualType.Tag) {
setElementRef($newAsReal, node as HTMLElement);
setElementRef($newAsReal, node as DOMElement);
}
}
}
@ -162,14 +171,16 @@ function renderWithVirtual<T extends VirtualElement | undefined>(
}
remount(parentEl, $current, undefined);
mountChildren(parentEl, $new as VirtualElementComponent | VirtualElementFragment, { nextSibling, fragment });
mountChildren(parentEl, $new as VirtualElementComponent | VirtualElementFragment, {
nextSibling, fragment, isSvg,
});
} else {
const node = createNode($newAsReal);
const node = createNode($newAsReal, isSvg);
$newAsReal.target = node;
remount(parentEl, $current, node, nextSibling);
if ($newAsReal.type === VirtualType.Tag) {
setElementRef($newAsReal, node as HTMLElement);
setElementRef($newAsReal, node as DOMElement);
}
}
} else {
@ -196,14 +207,14 @@ function renderWithVirtual<T extends VirtualElement | undefined>(
const $newAsTag = $new as VirtualElementTag;
setElementRef($current, undefined);
setElementRef($newAsTag, currentTarget as HTMLElement);
setElementRef($newAsTag, currentTarget as DOMElement);
if (nextSibling || options.forceMoveToEnd) {
insertBefore(parentEl, currentTarget, nextSibling);
}
updateAttributes($current, $newAsTag, currentTarget as HTMLElement);
renderChildren($current, $newAsTag, currentTarget as HTMLElement);
updateAttributes($current, $newAsTag, currentTarget as DOMElement, isSvg);
renderChildren($current, $newAsTag, currentTarget as DOMElement, undefined, undefined, isSvg);
}
}
}
@ -213,7 +224,7 @@ function renderWithVirtual<T extends VirtualElement | undefined>(
}
function initComponent(
parentEl: HTMLElement,
parentEl: DOMElement,
$element: VirtualElementComponent,
$parent: VirtualElementParent | VirtualDomHead,
index: number,
@ -235,7 +246,7 @@ function updateComponent($current: VirtualElementComponent, $new: VirtualElement
}
function setupComponentUpdateListener(
parentEl: HTMLElement,
parentEl: DOMElement,
$element: VirtualElementComponent,
$parent: VirtualElementParent | VirtualDomHead,
index: number,
@ -255,11 +266,12 @@ function setupComponentUpdateListener(
}
function mountChildren(
parentEl: HTMLElement,
parentEl: DOMElement,
$element: VirtualElementComponent | VirtualElementFragment,
options: {
nextSibling?: ChildNode;
fragment?: DocumentFragment;
isSvg?: true;
},
) {
const { children } = $element;
@ -272,13 +284,13 @@ function mountChildren(
}
}
function unmountChildren(parentEl: HTMLElement, $element: VirtualElementComponent | VirtualElementFragment) {
function unmountChildren(parentEl: DOMElement, $element: VirtualElementComponent | VirtualElementFragment) {
for (const $child of $element.children) {
renderWithVirtual(parentEl, $child, undefined, $element, -1);
}
}
function createNode($element: VirtualElementReal): Node {
function createNode($element: VirtualElementReal, isSvg?: true): Node {
if ($element.type === VirtualType.Empty) {
return document.createTextNode('');
}
@ -288,7 +300,7 @@ function createNode($element: VirtualElementReal): Node {
}
const { tag, props, children } = $element;
const element = document.createElement(tag);
const element = isSvg ? document.createElementNS('http://www.w3.org/2000/svg', tag) : document.createElement(tag);
processControlled(tag, props);
@ -297,7 +309,7 @@ function createNode($element: VirtualElementReal): Node {
if (!props.hasOwnProperty(key)) continue;
if (props[key] !== undefined) {
setAttribute(element, key, props[key]);
setAttribute(element, key, props[key], isSvg);
}
}
@ -305,7 +317,7 @@ function createNode($element: VirtualElementReal): Node {
for (let i = 0, l = children.length; i < l; i++) {
const $child = children[i];
const $renderedChild = renderWithVirtual(element, undefined, $child, $element, i);
const $renderedChild = renderWithVirtual(element, undefined, $child, $element, i, { isSvg });
if ($renderedChild !== $child) {
children[i] = $renderedChild;
}
@ -315,7 +327,7 @@ function createNode($element: VirtualElementReal): Node {
}
function remount(
parentEl: HTMLElement,
parentEl: DOMElement,
$current: VirtualElement,
node: Node | undefined,
componentNextSibling?: ChildNode,
@ -366,7 +378,7 @@ function unmountRealTree($element: VirtualElement) {
}
}
function insertBefore(parentEl: HTMLElement | DocumentFragment, node: Node, nextSibling?: ChildNode) {
function insertBefore(parentEl: DOMElement | DocumentFragment, node: Node, nextSibling?: ChildNode) {
if (nextSibling) {
parentEl.insertBefore(node, nextSibling);
} else {
@ -386,9 +398,10 @@ function getNextSibling($current: VirtualElement): ChildNode | undefined {
function renderChildren(
$current: VirtualElementParent,
$new: VirtualElementParent,
currentEl: HTMLElement,
currentEl: DOMElement,
nextSibling?: ChildNode,
forceMoveToEnd = false,
isSvg?: true,
) {
if (DEBUG) {
DEBUG_checkKeyUniqueness($new.children);
@ -419,7 +432,7 @@ function renderChildren(
newChildren[i],
$new,
i,
i >= currentChildrenLength ? { fragment } : { nextSibling, forceMoveToEnd },
i >= currentChildrenLength ? { fragment, isSvg } : { nextSibling, forceMoveToEnd, isSvg },
);
if ($renderedChild && $renderedChild !== newChildren[i]) {
@ -434,7 +447,7 @@ function renderChildren(
// This function allows to prepend/append a bunch of new DOM nodes to the top/bottom of preserved ones.
// It also allows to selectively move particular preserved nodes within their DOM list.
function renderFastListChildren($current: VirtualElementParent, $new: VirtualElementParent, currentEl: HTMLElement) {
function renderFastListChildren($current: VirtualElementParent, $new: VirtualElementParent, currentEl: DOMElement) {
const currentChildren = $current.children;
const newChildren = $new.children;
@ -549,7 +562,7 @@ function renderFastListChildren($current: VirtualElementParent, $new: VirtualEle
}
function renderFragment(
fragmentIndex: number, fragmentSize: number, parentEl: HTMLElement, $parent: VirtualElementParent,
fragmentIndex: number, fragmentSize: number, parentEl: DOMElement, $parent: VirtualElementParent,
) {
const nextSibling = parentEl.childNodes[fragmentIndex];
@ -576,13 +589,13 @@ function renderFragment(
insertBefore(parentEl, fragment, nextSibling);
}
function setElementRef($element: VirtualElementTag, htmlElement: HTMLElement | undefined) {
function setElementRef($element: VirtualElementTag, DOMElement: DOMElement | undefined) {
const { ref } = $element.props;
if (typeof ref === 'object') {
ref.current = htmlElement;
ref.current = DOMElement;
} else if (typeof ref === 'function') {
ref(htmlElement);
ref(DOMElement);
}
}
@ -629,7 +642,7 @@ function processControlled(tag: string, props: AnyLiteral) {
};
}
function processUncontrolledOnMount(element: HTMLElement, props: AnyLiteral) {
function processUncontrolledOnMount(element: DOMElement, props: AnyLiteral) {
if (!CONTROLLABLE_TAGS.includes(element.tagName)) {
return;
}
@ -643,7 +656,7 @@ function processUncontrolledOnMount(element: HTMLElement, props: AnyLiteral) {
}
}
function updateAttributes($current: VirtualElementTag, $new: VirtualElementTag, element: HTMLElement) {
function updateAttributes($current: VirtualElementTag, $new: VirtualElementTag, element: DOMElement, isSvg?: true) {
processControlled(element.tagName, $new.props);
const currentEntries = Object.entries($current.props);
@ -667,14 +680,14 @@ function updateAttributes($current: VirtualElementTag, $new: VirtualElementTag,
const currentValue = $current.props[key];
if (newValue !== undefined && newValue !== currentValue) {
setAttribute(element, key, newValue);
setAttribute(element, key, newValue, isSvg);
}
}
}
function setAttribute(element: HTMLElement, key: string, value: any) {
function setAttribute(element: DOMElement, key: string, value: any, isSvg?: true) {
if (key === 'className') {
updateClassName(element, value);
updateClassName(element, value, isSvg);
} else if (key === 'value') {
const inputEl = element as HTMLInputElement;
@ -701,14 +714,14 @@ function setAttribute(element: HTMLElement, key: string, value: any) {
element.innerHTML = value.__html;
} else if (key.startsWith('on')) {
addEventListener(element, key, value, key.endsWith('Capture'));
} else if (key.startsWith('data-') || key.startsWith('aria-') || HTML_ATTRIBUTES.has(key)) {
} else if (isSvg || key.startsWith('data-') || key.startsWith('aria-') || HTML_ATTRIBUTES.has(key)) {
element.setAttribute(key, value);
} else if (!FILTERED_ATTRIBUTES.has(key)) {
(element as any)[MAPPED_ATTRIBUTES[key] || key] = value;
}
}
function removeAttribute(element: HTMLElement, key: string, value: any) {
function removeAttribute(element: DOMElement, key: string, value: any) {
if (key === 'className') {
updateClassName(element, '');
} else if (key === 'value') {
@ -724,10 +737,16 @@ function removeAttribute(element: HTMLElement, key: string, value: any) {
}
}
function updateClassName(element: HTMLElement, value: string) {
function updateClassName(element: DOMElement, value: string, isSvg?: true) {
if (isSvg) {
element.setAttribute('class', value);
return;
}
const htmlElement = element as HTMLElement;
const extra = extraClasses.get(element);
if (!extra) {
element.className = value;
htmlElement.className = value;
return;
}
@ -736,10 +755,10 @@ function updateClassName(element: HTMLElement, value: string) {
extraArray.push(value);
}
element.className = extraArray.join(' ');
htmlElement.className = extraArray.join(' ');
}
function updateStyle(element: HTMLElement, value: string) {
function updateStyle(element: DOMElement, value: string) {
element.style.cssText = value;
const extraObject = extraStyles.get(element);
@ -748,7 +767,7 @@ function updateStyle(element: HTMLElement, value: string) {
}
}
export function addExtraClass(element: Element, className: string, forceSingle = false) {
export function addExtraClass(element: DOMElement, className: string, forceSingle = false) {
if (!forceSingle) {
const classNames = className.split(' ');
if (classNames.length > 1) {
@ -770,7 +789,7 @@ export function addExtraClass(element: Element, className: string, forceSingle =
}
}
export function removeExtraClass(element: Element, className: string, forceSingle = false) {
export function removeExtraClass(element: DOMElement, className: string, forceSingle = false) {
if (!forceSingle) {
const classNames = className.split(' ');
if (classNames.length > 1) {
@ -794,7 +813,7 @@ export function removeExtraClass(element: Element, className: string, forceSingl
}
}
export function toggleExtraClass(element: Element, className: string, force?: boolean, forceSingle = false) {
export function toggleExtraClass(element: DOMElement, className: string, force?: boolean, forceSingle = false) {
if (!forceSingle) {
const classNames = className.split(' ');
if (classNames.length > 1) {
@ -815,12 +834,12 @@ export function toggleExtraClass(element: Element, className: string, force?: bo
}
}
export function setExtraStyles(element: HTMLElement, styles: Partial<CSSStyleDeclaration> & AnyLiteral) {
export function setExtraStyles(element: DOMElement, styles: Partial<CSSStyleDeclaration> & AnyLiteral) {
extraStyles.set(element, styles);
applyExtraStyles(element);
}
function applyExtraStyles(element: HTMLElement) {
function applyExtraStyles(element: DOMElement) {
const standardStyles = Object.entries(extraStyles.get(element)!).reduce<Record<string, string>>(
(acc, [prop, value]) => {
if (prop.startsWith('--')) {

View File

@ -37,7 +37,7 @@ interface VirtualElementText {
export interface VirtualElementTag {
type: VirtualType.Tag;
target?: HTMLElement;
target?: HTMLElement | SVGElement;
tag: string;
props: Props;
children: VirtualElementChildren;