UNPKG

@amazon-devices/react-native-svg

Version:
567 lines (499 loc) 16.9 kB
import * as React from 'react'; import type { CircleProps } from './elements/Circle'; import type { ClipPathProps } from './elements/ClipPath'; import type { EllipseProps } from './elements/Ellipse'; import type { ForeignObjectProps } from './elements/ForeignObject'; import type { GProps } from './elements/G'; import type { ImageProps } from './elements/Image'; import type { LineProps } from './elements/Line'; import type { LinearGradientProps } from './elements/LinearGradient'; import type { MarkerProps } from './elements/Marker'; import type { MaskProps } from './elements/Mask'; import type { PathProps } from './elements/Path'; import type { PatternProps } from './elements/Pattern'; import type { PolygonProps } from './elements/Polygon'; import type { PolylineProps } from './elements/Polyline'; import type { RadialGradientProps } from './elements/RadialGradient'; import type { RectProps } from './elements/Rect'; import type { StopProps } from './elements/Stop'; import type { SvgProps } from './elements/Svg'; import type { SymbolProps } from './elements/Symbol'; import type { TextProps } from './elements/Text'; import type { TextPathProps } from './elements/TextPath'; import type { TSpanProps } from './elements/TSpan'; import type { UseProps } from './elements/Use'; import type { GestureResponderEvent, TransformsStyle } from 'react-native'; import { // @ts-ignore it is not seen in exports unstable_createElement as createElement, } from 'react-native'; import type { NumberArray, NumberProp, TransformProps, } from './lib/extract/types'; import SvgTouchableMixin from './lib/SvgTouchableMixin'; import { resolve } from './lib/resolve'; import { transformsArrayToProps } from './lib/extract/extractTransform'; type BlurEvent = object; type FocusEvent = object; type PressEvent = object; type LayoutEvent = object; type EdgeInsetsProp = object; interface BaseProps { accessible?: boolean; accessibilityLabel?: string; accessibilityHint?: string; accessibilityIgnoresInvertColors?: boolean; accessibilityRole?: string; accessibilityState?: object; delayLongPress?: number; delayPressIn?: number; delayPressOut?: number; disabled?: boolean; hitSlop?: EdgeInsetsProp; nativeID?: string; touchSoundDisabled?: boolean; onBlur?: (e: BlurEvent) => void; onFocus?: (e: FocusEvent) => void; onLayout?: (event: LayoutEvent) => object; onLongPress?: (event: PressEvent) => object; onClick?: (event: PressEvent) => object; onPress?: (event: PressEvent) => object; onPressIn?: (event: PressEvent) => object; onPressOut?: (event: PressEvent) => object; pressRetentionOffset?: EdgeInsetsProp; rejectResponderTermination?: boolean; transform?: TransformProps['transform']; translate?: NumberArray; translateX?: NumberProp; translateY?: NumberProp; scale?: NumberArray; scaleX?: NumberProp; scaleY?: NumberProp; rotation?: NumberProp; skewX?: NumberProp; skewY?: NumberProp; origin?: NumberArray; originX?: NumberProp; originY?: NumberProp; fontStyle?: string; fontWeight?: NumberProp; fontSize?: NumberProp; fontFamily?: string; forwardedRef?: | React.RefCallback<SVGElement> | React.MutableRefObject<SVGElement | null>; style?: Iterable<unknown>; // different tranform props gradientTransform?: TransformProps['transform']; patternTransform?: TransformProps['transform']; } const hasTouchableProperty = (props: BaseProps) => props.onPress || props.onPressIn || props.onPressOut || props.onLongPress; const camelCaseToDashed = (camelCase: string) => { return camelCase.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()); }; function stringifyTransformProps(transformProps: TransformProps) { const transformArray = []; if (transformProps.translate != null) { transformArray.push(`translate(${transformProps.translate})`); } if (transformProps.translateX != null || transformProps.translateY != null) { transformArray.push( `translate(${transformProps.translateX || 0}, ${ transformProps.translateY || 0 })` ); } if (transformProps.scale != null) { transformArray.push(`scale(${transformProps.scale})`); } if (transformProps.scaleX != null || transformProps.scaleY != null) { transformArray.push( `scale(${transformProps.scaleX || 1}, ${transformProps.scaleY || 1})` ); } // rotation maps to rotate, not to collide with the text rotate attribute (which acts per glyph rather than block) if (transformProps.rotation != null) { transformArray.push(`rotate(${transformProps.rotation})`); } if (transformProps.skewX != null) { transformArray.push(`skewX(${transformProps.skewX})`); } if (transformProps.skewY != null) { transformArray.push(`skewY(${transformProps.skewY})`); } return transformArray; } function parseTransformProp( transform: TransformProps['transform'], props?: BaseProps ) { const transformArray: string[] = []; props && transformArray.push(...stringifyTransformProps(props)); if (Array.isArray(transform)) { if (typeof transform[0] === 'number') { transformArray.push(`matrix(${transform.join(' ')})`); } else { const stringifiedProps = transformsArrayToProps( transform as TransformsStyle['transform'] ); transformArray.push(...stringifyTransformProps(stringifiedProps)); } } else if (typeof transform === 'string') { transformArray.push(transform); } return transformArray.length ? transformArray.join(' ') : undefined; } /** * `react-native-svg` supports additional props that aren't defined in the spec. * This function replaces them in a spec conforming manner. * * @param {WebShape} self Instance given to us. * @param {Object?} props Optional overridden props given to us. * @returns {Object} Cleaned props object. * @private */ const prepare = <T extends BaseProps>( self: WebShape<T>, props = self.props ) => { const { transform, origin, originX, originY, fontFamily, fontSize, fontWeight, fontStyle, style, forwardedRef, gradientTransform, patternTransform, ...rest } = props; const clean: { onStartShouldSetResponder?: (e: GestureResponderEvent) => boolean; onResponderMove?: (e: GestureResponderEvent) => void; onResponderGrant?: (e: GestureResponderEvent) => void; onResponderRelease?: (e: GestureResponderEvent) => void; onResponderTerminate?: (e: GestureResponderEvent) => void; onResponderTerminationRequest?: (e: GestureResponderEvent) => boolean; transform?: string; gradientTransform?: string; patternTransform?: string; 'transform-origin'?: string; style?: object; ref?: unknown; } = { ...(hasTouchableProperty(props) ? { onStartShouldSetResponder: self.touchableHandleStartShouldSetResponder, onResponderTerminationRequest: self.touchableHandleResponderTerminationRequest, onResponderGrant: self.touchableHandleResponderGrant, onResponderMove: self.touchableHandleResponderMove, onResponderRelease: self.touchableHandleResponderRelease, onResponderTerminate: self.touchableHandleResponderTerminate, } : null), ...rest, }; if (origin != null) { clean['transform-origin'] = origin.toString().replace(',', ' '); } else if (originX != null || originY != null) { clean['transform-origin'] = `${originX || 0} ${originY || 0}`; } // we do it like this because setting transform as undefined causes error in web const parsedTransform = parseTransformProp(transform, props); if (parsedTransform) { clean.transform = parsedTransform; } const parsedGradientTransform = parseTransformProp(gradientTransform); if (parsedGradientTransform) { clean.gradientTransform = parsedGradientTransform; } const parsedPatternTransform = parseTransformProp(patternTransform); if (parsedPatternTransform) { clean.patternTransform = parsedPatternTransform; } clean.ref = (el: SVGElement | null) => { self.elementRef.current = el; if (typeof forwardedRef === 'function') { forwardedRef(el); } else if (forwardedRef) { forwardedRef.current = el; } }; const styles: { fontStyle?: string; fontFamily?: string; fontSize?: NumberProp; fontWeight?: NumberProp; } = {}; if (fontFamily != null) { styles.fontFamily = fontFamily; } if (fontSize != null) { styles.fontSize = fontSize; } if (fontWeight != null) { styles.fontWeight = fontWeight; } if (fontStyle != null) { styles.fontStyle = fontStyle; } clean.style = resolve(style, styles); return clean; }; const getBoundingClientRect = (node: SVGElement) => { if (node) { const isElement = node.nodeType === 1; /* Node.ELEMENT_NODE */ if (isElement && typeof node.getBoundingClientRect === 'function') { return node.getBoundingClientRect(); } } throw new Error('Can not get boundingClientRect of ' + node || 'undefined'); }; const measureLayout = ( node: SVGElement, callback: ( x: number, y: number, width: number, height: number, left: number, top: number ) => void ) => { const relativeNode = node?.parentNode; if (relativeNode) { setTimeout(() => { // @ts-expect-error TODO: handle it better const relativeRect = getBoundingClientRect(relativeNode); const { height, left, top, width } = getBoundingClientRect(node); const x = left - relativeRect.left; const y = top - relativeRect.top; callback(x, y, width, height, left, top); }, 0); } }; // eslint-disable-next-line @typescript-eslint/no-explicit-any function remeasure(this: any) { const tag = this.state.touchable.responderID; if (tag === null) { return; } measureLayout(tag, this._handleQueryLayout); } export class WebShape< P extends BaseProps = BaseProps, > extends React.Component<P> { [x: string]: unknown; protected tag?: React.ElementType; protected prepareProps(props: P) { return props; } elementRef = React.createRef<SVGElement>() as React.MutableRefObject<SVGElement | null>; lastMergedProps: Partial<P> = {}; /** * disclaimer: I am not sure why the props are wrapped in a `style` attribute here, but that's how reanimated calls it */ setNativeProps(props: { style: P }) { const merged = Object.assign( {}, this.props, this.lastMergedProps, props.style ); this.lastMergedProps = merged; const clean = prepare(this, this.prepareProps(merged)); const current = this.elementRef.current; if (current) { for (const cleanAttribute of Object.keys(clean)) { const cleanValue = clean[cleanAttribute as keyof typeof clean]; switch (cleanAttribute) { case 'ref': case 'children': break; case 'style': // style can be an object here or an array, so we convert it to an array and assign each element for (const partialStyle of ([] as unknown[]).concat( clean.style ?? [] )) { Object.assign(current.style, partialStyle); } break; default: // apply all other incoming prop updates as attributes on the node // same logic as in https://github.com/software-mansion/react-native-reanimated/blob/d04720c82f5941532991b235787285d36d717247/src/reanimated2/js-reanimated/index.ts#L38-L39 // @ts-expect-error TODO: fix this current.setAttribute(camelCaseToDashed(cleanAttribute), cleanValue); break; } } } } _remeasureMetricsOnActivation: () => void; touchableHandleStartShouldSetResponder?: ( e: GestureResponderEvent ) => boolean; touchableHandleResponderMove?: (e: GestureResponderEvent) => void; touchableHandleResponderGrant?: (e: GestureResponderEvent) => void; touchableHandleResponderRelease?: (e: GestureResponderEvent) => void; touchableHandleResponderTerminate?: (e: GestureResponderEvent) => void; touchableHandleResponderTerminationRequest?: ( e: GestureResponderEvent ) => boolean; constructor(props: P) { super(props); // Do not attach touchable mixin handlers if SVG element doesn't have a touchable prop if (hasTouchableProperty(props)) { SvgTouchableMixin(this); } this._remeasureMetricsOnActivation = remeasure.bind(this); } render(): JSX.Element { if (!this.tag) { throw new Error( 'When extending `WebShape` you need to overwrite either `tag` or `render`!' ); } this.lastMergedProps = {}; return createElement( this.tag, prepare(this, this.prepareProps(this.props)) ); } } export class Circle extends WebShape<BaseProps & CircleProps> { tag = 'circle' as const; } export class ClipPath extends WebShape<BaseProps & ClipPathProps> { tag = 'clipPath' as const; } export class Defs extends WebShape { tag = 'defs' as const; } export class Ellipse extends WebShape<BaseProps & EllipseProps> { tag = 'ellipse' as const; } export class G extends WebShape<BaseProps & GProps> { tag = 'g' as const; prepareProps(props: BaseProps & GProps) { const { x, y, ...rest } = props; if ((x || y) && !rest.translate) { rest.translate = `${x || 0}, ${y || 0}`; } return rest; } } export class Image extends WebShape<BaseProps & ImageProps> { tag = 'image' as const; } export class Line extends WebShape<BaseProps & LineProps> { tag = 'line' as const; } export class LinearGradient extends WebShape<BaseProps & LinearGradientProps> { tag = 'linearGradient' as const; } export class Path extends WebShape<BaseProps & PathProps> { tag = 'path' as const; } export class Polygon extends WebShape<BaseProps & PolygonProps> { tag = 'polygon' as const; } export class Polyline extends WebShape<BaseProps & PolylineProps> { tag = 'polyline' as const; } export class RadialGradient extends WebShape<BaseProps & RadialGradientProps> { tag = 'radialGradient' as const; } export class Rect extends WebShape<BaseProps & RectProps> { tag = 'rect' as const; } export class Stop extends WebShape<BaseProps & StopProps> { tag = 'stop' as const; } /* Taken from here: https://gist.github.com/jennyknuth/222825e315d45a738ed9d6e04c7a88d0 */ function encodeSvg(svgString: string) { return svgString .replace( '<svg', ~svgString.indexOf('xmlns') ? '<svg' : '<svg xmlns="http://www.w3.org/2000/svg"' ) .replace(/"/g, "'") .replace(/%/g, '%25') .replace(/#/g, '%23') .replace(/{/g, '%7B') .replace(/}/g, '%7D') .replace(/</g, '%3C') .replace(/>/g, '%3E') .replace(/\s+/g, ' '); } export class Svg extends WebShape<BaseProps & SvgProps> { tag = 'svg' as const; toDataURL( callback: (data: string) => void, options: { width?: number; height?: number } = {} ) { const ref = this.elementRef.current; if (ref === null) { return; } const rect = getBoundingClientRect(ref); const width = Number(options.width) || rect.width; const height = Number(options.height) || rect.height; const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', `0 0 ${rect.width} ${rect.height}`); svg.setAttribute('width', String(width)); svg.setAttribute('height', String(height)); svg.appendChild(ref.cloneNode(true)); const img = new window.Image(); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const context = canvas.getContext('2d'); context?.drawImage(img, 0, 0); callback(canvas.toDataURL().replace('data:image/png;base64,', '')); }; img.src = `data:image/svg+xml;utf8,${encodeSvg( new window.XMLSerializer().serializeToString(svg) )}`; } } export class Symbol extends WebShape<BaseProps & SymbolProps> { tag = 'symbol' as const; } export class Text extends WebShape<BaseProps & TextProps> { tag = 'text' as const; } export class TSpan extends WebShape<BaseProps & TSpanProps> { tag = 'tspan' as const; } export class TextPath extends WebShape<BaseProps & TextPathProps> { tag = 'textPath' as const; } export class Use extends WebShape<BaseProps & UseProps> { tag = 'use' as const; } export class Mask extends WebShape<BaseProps & MaskProps> { tag = 'mask' as const; } export class ForeignObject extends WebShape<BaseProps & ForeignObjectProps> { tag = 'foreignObject' as const; } export class Marker extends WebShape<BaseProps & MarkerProps> { tag = 'marker' as const; } export class Pattern extends WebShape<BaseProps & PatternProps> { tag = 'pattern' as const; } export default Svg;