From 68f05735c7bc0ee862047cfb37b3775f21e0d66f Mon Sep 17 00:00:00 2001 From: William Candillon Date: Wed, 17 May 2023 10:02:23 +0200 Subject: [PATCH] Remove dep on React Native Web (#1578) --- docs/docs/getting-started/web.mdx | 2 + example/jestSetup.js | 12 +++ package/jestSetup.js | 20 +++- package/src/Platform/IPlatform.ts | 20 ++++ package/src/Platform/Platform.ts | 28 ++++++ package/src/Platform/Platform.web.tsx | 136 ++++++++++++++++++++++++++ package/src/Platform/index.ts | 1 + package/src/skia/NativeSetup.ts | 4 +- package/src/skia/core/Data.ts | 19 +--- package/src/skia/core/Image.ts | 5 +- package/src/views/SkiaBaseWebView.tsx | 43 ++++---- package/src/views/SkiaDomView.tsx | 4 +- package/src/views/SkiaPictureView.tsx | 4 +- package/src/views/SkiaView.tsx | 4 +- package/src/views/useTouchHandler.ts | 6 +- package/src/web/WithSkiaWeb.tsx | 3 +- 16 files changed, 259 insertions(+), 52 deletions(-) create mode 100644 package/src/Platform/IPlatform.ts create mode 100644 package/src/Platform/Platform.ts create mode 100644 package/src/Platform/Platform.web.tsx create mode 100644 package/src/Platform/index.ts diff --git a/docs/docs/getting-started/web.mdx b/docs/docs/getting-started/web.mdx index 5d72fd9633..75dbbacb9a 100644 --- a/docs/docs/getting-started/web.mdx +++ b/docs/docs/getting-started/web.mdx @@ -14,6 +14,8 @@ While this is a substantial file size, you have control over the user experience We provide direct integrations with [Expo](#Expo) and [Remotion](#Remotion). Below you will also find the manual installation steps to run the module on any React Native Web projects. +You can use React Native Skia without React Native Web. + ## Expo Using React Native Skia on Expo web is reasonably straightforward. diff --git a/example/jestSetup.js b/example/jestSetup.js index 775aacff17..4aeba7d1d3 100644 --- a/example/jestSetup.js +++ b/example/jestSetup.js @@ -17,6 +17,18 @@ jest.mock("react-native-reanimated", () => { jest.mock("react-native/Libraries/Animated/NativeAnimatedHelper"); jest.mock("@shopify/react-native-skia", () => { + jest.mock("../package/src/Platform", () => { + const Noop = () => undefined; + return { + OS: "web", + PixelRatio: 1, + requireNativeComponent: Noop, + resolveAsset: Noop, + findNodeHandle: Noop, + NativeModules: Noop, + View: Noop, + }; + }); return require("../package/src/mock").Mock(global.CanvasKit); }); diff --git a/package/jestSetup.js b/package/jestSetup.js index 75c1efe3f5..ac147e89e0 100644 --- a/package/jestSetup.js +++ b/package/jestSetup.js @@ -1,4 +1,18 @@ /* globals jest */ -jest.mock("@shopify/react-native-skia", () => - require("@shopify/react-native-skia/lib/commonjs/mock").Mock(global.CanvasKit) -); +jest.mock("@shopify/react-native-skia", () => { + jest.mock("@shopify/react-native-skia/lib/commonjs/Platform", () => { + const Noop = () => undefined; + return { + OS: "web", + PixelRatio: 1, + requireNativeComponent: Noop, + resolveAsset: Noop, + findNodeHandle: Noop, + NativeModules: Noop, + View: Noop, + }; + }); + return require("@shopify/react-native-skia/lib/commonjs/mock").Mock( + global.CanvasKit + ); +}); diff --git a/package/src/Platform/IPlatform.ts b/package/src/Platform/IPlatform.ts new file mode 100644 index 0000000000..101c7f4963 --- /dev/null +++ b/package/src/Platform/IPlatform.ts @@ -0,0 +1,20 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { HostComponent, NodeHandle, ViewComponent } from "react-native"; + +import type { DataModule } from "../skia/types"; + +export interface IPlatform { + OS: string; + requireNativeComponent: (viewName: string) => HostComponent; + PixelRatio: number; + NativeModules: Record; + findNodeHandle: ( + componentOrHandle: + | null + | number + | React.Component + | React.ComponentClass + ) => null | NodeHandle; + resolveAsset: (source: DataModule) => string; + View: typeof ViewComponent; +} diff --git a/package/src/Platform/Platform.ts b/package/src/Platform/Platform.ts new file mode 100644 index 0000000000..b9372f08d6 --- /dev/null +++ b/package/src/Platform/Platform.ts @@ -0,0 +1,28 @@ +import { + Image, + PixelRatio, + requireNativeComponent, + Platform as RNPlatform, + findNodeHandle, + NativeModules, + View, +} from "react-native"; + +import type { DataModule } from "../skia/types"; +import { isRNModule } from "../skia/types"; + +import type { IPlatform } from "./IPlatform"; + +export const Platform: IPlatform = { + OS: RNPlatform.OS, + PixelRatio: PixelRatio.get(), + requireNativeComponent, + resolveAsset: (source: DataModule) => { + return isRNModule(source) + ? Image.resolveAssetSource(source).uri + : source.default; + }, + findNodeHandle, + NativeModules, + View, +}; diff --git a/package/src/Platform/Platform.web.tsx b/package/src/Platform/Platform.web.tsx new file mode 100644 index 0000000000..df6a774b4d --- /dev/null +++ b/package/src/Platform/Platform.web.tsx @@ -0,0 +1,136 @@ +import type { RefObject, CSSProperties } from "react"; +import React, { useLayoutEffect, useMemo, useRef } from "react"; +import type { LayoutChangeEvent, ViewComponent, ViewProps } from "react-native"; + +import type { DataModule } from "../skia/types"; +import { isRNModule } from "../skia/types"; + +import type { IPlatform } from "./IPlatform"; + +// eslint-disable-next-line max-len +// https://github.com/necolas/react-native-web/blob/master/packages/react-native-web/src/modules/useElementLayout/index.js +const DOM_LAYOUT_HANDLER_NAME = "__reactLayoutHandler"; +type OnLayout = ((event: LayoutChangeEvent) => void) | undefined; +type Div = HTMLDivElement & { + __reactLayoutHandler: OnLayout; +}; + +let resizeObserver: ResizeObserver | null = null; + +const getObserver = () => { + if (resizeObserver == null) { + resizeObserver = new window.ResizeObserver(function (entries) { + entries.forEach((entry) => { + const node = entry.target as Div; + const { left, top, width, height } = entry.contentRect; + const onLayout = node[DOM_LAYOUT_HANDLER_NAME]; + if (typeof onLayout === "function") { + // setTimeout 0 is taken from react-native-web (UIManager) + setTimeout( + () => + onLayout({ + timeStamp: Date.now(), + nativeEvent: { layout: { x: left, y: top, width, height } }, + currentTarget: 0, + target: 0, + bubbles: false, + cancelable: false, + defaultPrevented: false, + eventPhase: 0, + isDefaultPrevented() { + throw new Error("Method not supported on web."); + }, + isPropagationStopped() { + throw new Error("Method not supported on web."); + }, + persist() { + throw new Error("Method not supported on web."); + }, + preventDefault() { + throw new Error("Method not supported on web."); + }, + stopPropagation() { + throw new Error("Method not supported on web."); + }, + isTrusted: true, + type: "", + }), + 0 + ); + } + }); + }); + } + return resizeObserver; +}; + +const useElementLayout = (ref: RefObject
, onLayout: OnLayout) => { + const observer = getObserver(); + + useLayoutEffect(() => { + const node = ref.current; + if (node !== null) { + node[DOM_LAYOUT_HANDLER_NAME] = onLayout; + } + }, [ref, onLayout]); + + useLayoutEffect(() => { + const node = ref.current; + if (node != null && observer != null) { + if (typeof node[DOM_LAYOUT_HANDLER_NAME] === "function") { + observer.observe(node); + } else { + observer.unobserve(node); + } + } + return () => { + if (node != null && observer != null) { + observer.unobserve(node); + } + }; + }, [observer, ref]); +}; + +const View = (({ children, onLayout, style: rawStyle }: ViewProps) => { + const style = useMemo(() => (rawStyle ?? {}) as CSSProperties, [rawStyle]); + const ref = useRef
(null); + useElementLayout(ref, onLayout); + const cssStyles = useMemo(() => { + return { + ...style, + display: "flex", + flexDirection: style.flexDirection || "column", + flexWrap: style.flexWrap || "nowrap", + justifyContent: style.justifyContent || "flex-start", + alignItems: style.alignItems || "stretch", + alignContent: style.alignContent || "stretch", + }; + }, [style]); + + return ( +
+ {children} +
+ ); +}) as unknown as typeof ViewComponent; + +export const Platform: IPlatform = { + OS: "web", + PixelRatio: window.devicePixelRatio, + requireNativeComponent: () => { + throw new Error("requireNativeComponent is not supported on the web"); + }, + resolveAsset: (source: DataModule) => { + if (isRNModule(source)) { + throw new Error( + "Image source is a number - this is not supported on the web" + ); + } + return source.default; + }, + findNodeHandle: () => { + throw new Error("findNodeHandle is not supported on the web"); + }, + NativeModules: {}, + View, +}; diff --git a/package/src/Platform/index.ts b/package/src/Platform/index.ts new file mode 100644 index 0000000000..687c5ddc32 --- /dev/null +++ b/package/src/Platform/index.ts @@ -0,0 +1 @@ +export * from "./Platform"; diff --git a/package/src/skia/NativeSetup.ts b/package/src/skia/NativeSetup.ts index b6e3ed3ba9..4886287c46 100644 --- a/package/src/skia/NativeSetup.ts +++ b/package/src/skia/NativeSetup.ts @@ -1,8 +1,8 @@ -import { NativeModules, Platform } from "react-native"; +import { Platform } from "../Platform"; if (Platform.OS !== "web" && global.SkiaApi == null) { // Initialize RN Skia - const SkiaModule = NativeModules.RNSkia; + const SkiaModule = Platform.NativeModules.RNSkia; if (SkiaModule == null || typeof SkiaModule.install !== "function") { throw new Error( "Native RNSkia Module cannot be found! Make sure you correctly " + diff --git a/package/src/skia/core/Data.ts b/package/src/skia/core/Data.ts index d205832102..0f88650f37 100644 --- a/package/src/skia/core/Data.ts +++ b/package/src/skia/core/Data.ts @@ -1,20 +1,8 @@ import { useEffect, useRef, useState } from "react"; -import { Image } from "react-native"; import { Skia } from "../Skia"; -import { isRNModule } from "../types"; -import type { - SkData, - DataModule, - DataSourceParam, - JsiDisposable, -} from "../types"; - -const resolveAsset = (source: DataModule) => { - return isRNModule(source) - ? Image.resolveAssetSource(source).uri - : source.default; -}; +import type { SkData, DataSourceParam, JsiDisposable } from "../types"; +import { Platform } from "../../Platform"; const factoryWrapper = ( data2: SkData, @@ -42,7 +30,8 @@ const loadData = ( resolve(factoryWrapper(Skia.Data.fromBytes(source), factory, onError)) ); } else { - const uri = typeof source === "string" ? source : resolveAsset(source); + const uri = + typeof source === "string" ? source : Platform.resolveAsset(source); return Skia.Data.fromURI(uri).then((d) => factoryWrapper(d, factory, onError) ); diff --git a/package/src/skia/core/Image.ts b/package/src/skia/core/Image.ts index e9c08df2c3..706bdc7388 100644 --- a/package/src/skia/core/Image.ts +++ b/package/src/skia/core/Image.ts @@ -1,5 +1,4 @@ -import { findNodeHandle, Platform } from "react-native"; - +import { Platform } from "../../Platform"; import { Skia } from "../Skia"; import type { DataSourceParam, SkImage } from "../types"; @@ -48,7 +47,7 @@ export const makeImageFromView = < ); } } - const viewTag = findNodeHandle(viewRef.current); + const viewTag = Platform.findNodeHandle(viewRef.current); if (viewTag !== null && viewTag !== 0) { return Skia.Image.MakeImageFromViewTag(viewTag); } diff --git a/package/src/views/SkiaBaseWebView.tsx b/package/src/views/SkiaBaseWebView.tsx index 0a18474a36..19be117eac 100644 --- a/package/src/views/SkiaBaseWebView.tsx +++ b/package/src/views/SkiaBaseWebView.tsx @@ -2,16 +2,16 @@ import React from "react"; import type { PointerEvent } from "react"; import type { LayoutChangeEvent } from "react-native"; -import { PixelRatio, View } from "react-native"; import type { SkRect, SkCanvas } from "../skia/types"; import type { SkiaValue } from "../values"; import { JsiSkSurface } from "../skia/web/JsiSkSurface"; +import { Platform } from "../Platform"; import type { DrawMode, SkiaBaseViewProps, TouchInfo } from "./types"; import { TouchType } from "./types"; -const pd = PixelRatio.get(); +const pd = Platform.PixelRatio; export abstract class SkiaBaseWebView< TProps extends SkiaBaseViewProps @@ -38,17 +38,16 @@ export abstract class SkiaBaseWebView< this._unsubscriptions = []; } - private onLayout(evt: LayoutChangeEvent) { + private onLayoutEvent(evt: LayoutChangeEvent) { const { CanvasKit } = global; - const { width, height } = evt.nativeEvent.layout; - this.width = width; - this.height = height; // Reset canvas / surface on layout change - if (this._canvasRef.current) { - const canvas = this._canvasRef.current; - canvas.width = width * pd; - canvas.height = height * pd; - const surface = CanvasKit.MakeWebGLCanvasSurface(this._canvasRef.current); + const canvas = this._canvasRef.current; + if (canvas) { + this.width = canvas.clientWidth; + this.height = canvas.clientHeight; + canvas.width = this.width * pd; + canvas.height = this.height * pd; + const surface = CanvasKit.MakeWebGLCanvasSurface(canvas); if (!surface) { throw new Error("Could not create surface"); } @@ -178,21 +177,27 @@ export abstract class SkiaBaseWebView< return (evt: PointerEvent) => this.handleTouchEvent(evt, touchType); } + private onStart = this.createTouchHandler(TouchType.Start); + private onActive = this.createTouchHandler(TouchType.Active); + private onCancel = this.createTouchHandler(TouchType.Cancelled); + private onEnd = this.createTouchHandler(TouchType.End); + private onLayout = this.onLayoutEvent.bind(this); + render() { const { mode, debug = false, ...viewProps } = this.props; return ( - + - + ); } } diff --git a/package/src/views/SkiaDomView.tsx b/package/src/views/SkiaDomView.tsx index 497db12ff0..146657e51f 100644 --- a/package/src/views/SkiaDomView.tsx +++ b/package/src/views/SkiaDomView.tsx @@ -1,9 +1,9 @@ import React from "react"; -import { requireNativeComponent, Platform } from "react-native"; import type { HostComponent } from "react-native"; import type { SkRect } from "../skia/types"; import type { SkiaValue } from "../values"; +import { Platform } from "../Platform"; import { SkiaViewApi } from "./api"; import { SkiaViewNativeId } from "./SkiaView"; @@ -11,7 +11,7 @@ import type { NativeSkiaViewProps, SkiaDomViewProps } from "./types"; const NativeSkiaDomView: HostComponent = Platform.OS !== "web" - ? requireNativeComponent("SkiaDomView") + ? Platform.requireNativeComponent("SkiaDomView") : // eslint-disable-next-line @typescript-eslint/no-explicit-any (null as any); diff --git a/package/src/views/SkiaPictureView.tsx b/package/src/views/SkiaPictureView.tsx index 49d19323cd..6214ab5d73 100644 --- a/package/src/views/SkiaPictureView.tsx +++ b/package/src/views/SkiaPictureView.tsx @@ -1,15 +1,15 @@ import React from "react"; -import { requireNativeComponent } from "react-native"; import type { SkRect } from "../skia/types"; import type { SkiaValue } from "../values"; +import { Platform } from "../Platform"; import { SkiaViewApi } from "./api"; import { SkiaViewNativeId } from "./SkiaView"; import type { NativeSkiaViewProps, SkiaPictureViewProps } from "./types"; const NativeSkiaPictureView = - requireNativeComponent("SkiaPictureView"); + Platform.requireNativeComponent("SkiaPictureView"); export class SkiaPictureView extends React.Component { constructor(props: SkiaPictureViewProps) { diff --git a/package/src/views/SkiaView.tsx b/package/src/views/SkiaView.tsx index c5ae687d00..4a4078067b 100644 --- a/package/src/views/SkiaView.tsx +++ b/package/src/views/SkiaView.tsx @@ -1,8 +1,8 @@ import React from "react"; -import { requireNativeComponent } from "react-native"; import type { SkRect } from "../skia/types"; import type { SkiaValue } from "../values"; +import { Platform } from "../Platform"; import { SkiaViewApi } from "./api"; import type { NativeSkiaViewProps, SkiaDrawViewProps } from "./types"; @@ -10,7 +10,7 @@ import type { NativeSkiaViewProps, SkiaDrawViewProps } from "./types"; export const SkiaViewNativeId = { current: 1000 }; const NativeSkiaView = - requireNativeComponent("SkiaDrawView"); + Platform.requireNativeComponent("SkiaDrawView"); export class SkiaView extends React.Component { constructor(props: SkiaDrawViewProps) { diff --git a/package/src/views/useTouchHandler.ts b/package/src/views/useTouchHandler.ts index 31482f5e8c..793efee103 100644 --- a/package/src/views/useTouchHandler.ts +++ b/package/src/views/useTouchHandler.ts @@ -1,8 +1,8 @@ import type { DependencyList } from "react"; import { useCallback, useRef } from "react"; -import { PixelRatio } from "react-native"; import type { Vector } from "../skia/types"; +import { Platform } from "../Platform"; import type { ExtendedTouchInfo, @@ -46,8 +46,8 @@ const useInternalTouchHandler = ( timeDiffseconds > 0 ) { prevVelocityRef.current[touch.id] = { - x: distX / timeDiffseconds / PixelRatio.get(), - y: distY / timeDiffseconds / PixelRatio.get(), + x: distX / timeDiffseconds / Platform.PixelRatio, + y: distY / timeDiffseconds / Platform.PixelRatio, }; } diff --git a/package/src/web/WithSkiaWeb.tsx b/package/src/web/WithSkiaWeb.tsx index ae497b1a4a..1a940481e4 100644 --- a/package/src/web/WithSkiaWeb.tsx +++ b/package/src/web/WithSkiaWeb.tsx @@ -1,6 +1,7 @@ import type { ComponentProps, ComponentType } from "react"; import React, { useMemo, lazy, Suspense } from "react"; -import { Platform } from "react-native"; + +import { Platform } from "../Platform"; import { LoadSkiaWeb } from "./LoadSkiaWeb";