diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ed65ed49b0..fa557526e5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -375,10 +375,25 @@ jobs:
working-directory: packages/skia
run: yarn build && yarn pack
+ - name: Log package.json before change
+ working-directory: externals/skia-web-app
+ run: |
+ echo "=== package.json BEFORE change ==="
+ grep -n "@shopify/react-native-skia" package.json || echo "Pattern not found"
+ echo "=================================="
+
- name: Update package.json to use local Skia package
working-directory: externals/skia-web-app
run: |
- sed -i '' 's/"@shopify\/react-native-skia": "[^"]*"/"@shopify\/react-native-skia": "file:..\/..\/packages\/skia\/package.tgz"/' package.json
+ # More robust regex that handles different whitespace
+ sed -i '' 's/"@shopify\/react-native-skia"[[:space:]]*:[[:space:]]*"[^"]*"/"@shopify\/react-native-skia": "file:..\/..\/packages\/skia\/package.tgz"/' package.json
+
+ - name: Log package.json after change
+ working-directory: externals/skia-web-app
+ run: |
+ echo "=== package.json AFTER change ==="
+ grep -n "@shopify/react-native-skia" package.json || echo "Pattern not found"
+ echo "================================="
- name: Install dependencies for skia-web-app
working-directory: externals/skia-web-app
diff --git a/apps/docs/docs/getting-started/web.mdx b/apps/docs/docs/getting-started/web.mdx
index 4793527679..200cc387eb 100644
--- a/apps/docs/docs/getting-started/web.mdx
+++ b/apps/docs/docs/getting-started/web.mdx
@@ -156,6 +156,37 @@ LoadSkiaWeb({
});
```
+## WebGL Contextes
+
+Web browsers limit the number of WebGL contexts to 16 per webpage.
+Usually developers will see this error when they exceed this limit:
+
+```
+WARNING: Too many active WebGL contexts. Oldest context will be lost.
+```
+If you canvas is static and doesn't contain animation values, you can use the `__destroyWebGLContextAfterRender={true}` prop on your Canvas components to destroy the WebGL context after rendering.
+This even works with animated canvases but it will come with a performance cost as the context will be recreated on each render.
+
+```tsx twoslash
+import { View } from 'react-native';
+import { Canvas, Fill } from "@shopify/react-native-skia";
+
+export default function App() {
+ return (
+
+ {
+ // 20 Skia Canvases with __destroyWebGLContextAfterRender={true}
+ new Array(20).fill(0).map((_, i) => (
+
+ ))
+ }
+
+ );
+}
+```
+
## Unsupported Features
The following React Native Skia APIs are currently unsupported on React Native Web.
diff --git a/apps/example/src/Tests/Tests.tsx b/apps/example/src/Tests/Tests.tsx
index e0c5129945..4651cc78fa 100644
--- a/apps/example/src/Tests/Tests.tsx
+++ b/apps/example/src/Tests/Tests.tsx
@@ -32,7 +32,7 @@ export const Tests = ({ assets }: TestsProps) => {
const [client, hostname] = useClient();
const [drawing, setDrawing] = useState(null);
const [screen, setScreen] = useState(null);
-
+
useEffect(() => {
if (client !== null) {
// Define the message handler as a separate function
@@ -66,16 +66,16 @@ export const Tests = ({ assets }: TestsProps) => {
};
// Use addEventListener instead of onmessage
- client.addEventListener('message', handleMessage);
+ client.addEventListener("message", handleMessage);
// Clean up: remove the specific event listener
return () => {
- client.removeEventListener('message', handleMessage);
+ client.removeEventListener("message", handleMessage);
};
}
return;
}, [assets, client]);
-
+
useEffect(() => {
if (drawing && client) {
const it = setTimeout(() => {
@@ -104,7 +104,7 @@ export const Tests = ({ assets }: TestsProps) => {
}
return;
}, [client, drawing, ref]);
-
+
useEffect(() => {
if (screen && client) {
const it = setTimeout(async () => {
@@ -120,7 +120,7 @@ export const Tests = ({ assets }: TestsProps) => {
}
return;
}, [client, screen]);
-
+
return (
diff --git a/packages/skia/src/renderer/Canvas.tsx b/packages/skia/src/renderer/Canvas.tsx
index 7ba53a5efa..78beb9e906 100644
--- a/packages/skia/src/renderer/Canvas.tsx
+++ b/packages/skia/src/renderer/Canvas.tsx
@@ -56,6 +56,7 @@ export interface CanvasProps extends Omit {
onSize?: SharedValue;
colorSpace?: "p3" | "srgb";
ref?: React.Ref;
+ __destroyWebGLContextAfterRender?: boolean;
}
export const Canvas = ({
diff --git a/packages/skia/src/renderer/__tests__/e2e/ParagraphMethods.spec.tsx b/packages/skia/src/renderer/__tests__/e2e/ParagraphMethods.spec.tsx
index d39ba491ec..2a814577f6 100644
--- a/packages/skia/src/renderer/__tests__/e2e/ParagraphMethods.spec.tsx
+++ b/packages/skia/src/renderer/__tests__/e2e/ParagraphMethods.spec.tsx
@@ -11,109 +11,109 @@ const RobotoRegular = Array.from(
describe("Paragraph Methods", () => {
describe("getRectsForPlaceholders", () => {
- it("should handle multiple placeholders with different alignments", async () => {
- const placeholderRects = await surface.eval(
- (Skia, ctx) => {
- const robotoRegular = Skia.Typeface.MakeFreeTypeFaceFromData(
- Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular))
- )!;
- const provider = Skia.TypefaceFontProvider.Make();
- provider.registerFont(robotoRegular, "Roboto");
-
- const builder = Skia.ParagraphBuilder.Make(
- {
- textStyle: {
- color: Skia.Color("black"),
- fontFamilies: ["Roboto"],
- fontSize: 16,
+ it("should handle multiple placeholders with different alignments", async () => {
+ const placeholderRects = await surface.eval(
+ (Skia, ctx) => {
+ const robotoRegular = Skia.Typeface.MakeFreeTypeFaceFromData(
+ Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular))
+ )!;
+ const provider = Skia.TypefaceFontProvider.Make();
+ provider.registerFont(robotoRegular, "Roboto");
+
+ const builder = Skia.ParagraphBuilder.Make(
+ {
+ textStyle: {
+ color: Skia.Color("black"),
+ fontFamilies: ["Roboto"],
+ fontSize: 16,
+ },
},
- },
- provider
- );
-
- builder.addText("Start ");
- builder.addPlaceholder(
- 20,
- 20,
- ctx.PlaceholderAlignment.Baseline,
- ctx.TextBaseline.Alphabetic
- );
- builder.addText(" middle ");
- builder.addPlaceholder(
- 15,
- 15,
- ctx.PlaceholderAlignment.Top,
- ctx.TextBaseline.Alphabetic
- );
- builder.addText(" end");
-
- const paragraph = builder.build();
- paragraph.layout(200);
-
- const rects = paragraph.getRectsForPlaceholders();
- return rects.map((r) => ({
- x: r.rect.x,
- y: r.rect.y,
- width: r.rect.width,
- height: r.rect.height,
- direction: r.direction,
- }));
- },
- {
- RobotoRegular,
- PlaceholderAlignment,
- TextBaseline,
- }
- );
-
- expect(placeholderRects).toHaveLength(2);
- expect(placeholderRects[0].width).toBe(20);
- expect(placeholderRects[0].height).toBe(20);
- expect(placeholderRects[1].width).toBeCloseTo(15);
- expect(placeholderRects[1].height).toBeCloseTo(15);
- });
+ provider
+ );
- it("should return correct direction for placeholders", async () => {
- const placeholderInfo = await surface.eval(
- (Skia, ctx) => {
- const robotoRegular = Skia.Typeface.MakeFreeTypeFaceFromData(
- Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular))
- )!;
- const provider = Skia.TypefaceFontProvider.Make();
- provider.registerFont(robotoRegular, "Roboto");
-
- const builder = Skia.ParagraphBuilder.Make(
- {
- textStyle: {
- color: Skia.Color("black"),
- fontFamilies: ["Roboto"],
- fontSize: 16,
+ builder.addText("Start ");
+ builder.addPlaceholder(
+ 20,
+ 20,
+ ctx.PlaceholderAlignment.Baseline,
+ ctx.TextBaseline.Alphabetic
+ );
+ builder.addText(" middle ");
+ builder.addPlaceholder(
+ 15,
+ 15,
+ ctx.PlaceholderAlignment.Top,
+ ctx.TextBaseline.Alphabetic
+ );
+ builder.addText(" end");
+
+ const paragraph = builder.build();
+ paragraph.layout(200);
+
+ const rects = paragraph.getRectsForPlaceholders();
+ return rects.map((r) => ({
+ x: r.rect.x,
+ y: r.rect.y,
+ width: r.rect.width,
+ height: r.rect.height,
+ direction: r.direction,
+ }));
+ },
+ {
+ RobotoRegular,
+ PlaceholderAlignment,
+ TextBaseline,
+ }
+ );
+
+ expect(placeholderRects).toHaveLength(2);
+ expect(placeholderRects[0].width).toBe(20);
+ expect(placeholderRects[0].height).toBe(20);
+ expect(placeholderRects[1].width).toBeCloseTo(15);
+ expect(placeholderRects[1].height).toBeCloseTo(15);
+ });
+
+ it("should return correct direction for placeholders", async () => {
+ const placeholderInfo = await surface.eval(
+ (Skia, ctx) => {
+ const robotoRegular = Skia.Typeface.MakeFreeTypeFaceFromData(
+ Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular))
+ )!;
+ const provider = Skia.TypefaceFontProvider.Make();
+ provider.registerFont(robotoRegular, "Roboto");
+
+ const builder = Skia.ParagraphBuilder.Make(
+ {
+ textStyle: {
+ color: Skia.Color("black"),
+ fontFamilies: ["Roboto"],
+ fontSize: 16,
+ },
},
- },
- provider
- );
-
- builder.addText("Text with ");
- builder.addPlaceholder(30, 30);
- builder.addText(" placeholder");
-
- const paragraph = builder.build();
- paragraph.layout(300);
-
- const rects = paragraph.getRectsForPlaceholders();
- return rects.map((r) => ({
- direction: r.direction === ctx.TextDirection.LTR ? "LTR" : "RTL",
- }));
- },
- {
- RobotoRegular,
- TextDirection,
- }
- );
-
- expect(placeholderInfo).toHaveLength(1);
- expect(placeholderInfo[0].direction).toBe("LTR");
- });
+ provider
+ );
+
+ builder.addText("Text with ");
+ builder.addPlaceholder(30, 30);
+ builder.addText(" placeholder");
+
+ const paragraph = builder.build();
+ paragraph.layout(300);
+
+ const rects = paragraph.getRectsForPlaceholders();
+ return rects.map((r) => ({
+ direction: r.direction === ctx.TextDirection.LTR ? "LTR" : "RTL",
+ }));
+ },
+ {
+ RobotoRegular,
+ TextDirection,
+ }
+ );
+
+ expect(placeholderInfo).toHaveLength(1);
+ expect(placeholderInfo[0].direction).toBe("LTR");
+ });
it("should return empty array when no placeholders", async () => {
const placeholderCount = await surface.eval(
@@ -194,7 +194,7 @@ describe("Paragraph Methods", () => {
expect(lineMetrics[0].ascent).toBeGreaterThan(0);
expect(lineMetrics[0].descent).toBeGreaterThan(0);
// Note: Even single lines without explicit breaks may report isHardBreak as true
- expect(typeof lineMetrics[0].isHardBreak).toBe('boolean');
+ expect(typeof lineMetrics[0].isHardBreak).toBe("boolean");
});
it("should return line metrics for multi-line text with wrapping", async () => {
@@ -232,12 +232,12 @@ describe("Paragraph Methods", () => {
);
expect(lineMetrics.length).toBeGreaterThan(1);
-
+
// Check first line
expect(lineMetrics[0].lineNumber).toBe(0);
expect(lineMetrics[0].startIndex).toBe(0);
expect(lineMetrics[0].width).toBeLessThanOrEqual(100);
-
+
// Check second line
expect(lineMetrics[1].lineNumber).toBe(1);
expect(lineMetrics[1].startIndex).toBeGreaterThan(0);
@@ -277,12 +277,12 @@ describe("Paragraph Methods", () => {
);
expect(lineMetrics).toHaveLength(3);
-
+
// All lines report isHardBreak as true in this implementation
expect(lineMetrics[0].isHardBreak).toBe(true);
expect(lineMetrics[1].isHardBreak).toBe(true);
expect(lineMetrics[2].isHardBreak).toBe(true);
-
+
// Check line numbers
expect(lineMetrics[0].lineNumber).toBe(0);
expect(lineMetrics[1].lineNumber).toBe(1);
@@ -322,19 +322,24 @@ describe("Paragraph Methods", () => {
);
expect(lineMetrics).toHaveLength(2);
-
+
// First line
const firstLine = lineMetrics[0];
// Height should be close to ascent + descent
expect(firstLine.height).toBeGreaterThan(0);
- expect(Math.abs(firstLine.height - (firstLine.ascent + firstLine.descent))).toBeLessThan(1);
+ expect(
+ Math.abs(firstLine.height - (firstLine.ascent + firstLine.descent))
+ ).toBeLessThan(1);
expect(firstLine.left).toBe(0);
expect(firstLine.baseline).toBeGreaterThan(0);
-
+
// Second line should be below the first
const secondLine = lineMetrics[1];
expect(secondLine.baseline).toBeGreaterThan(firstLine.baseline);
- expect(secondLine.baseline - firstLine.baseline).toBeCloseTo(firstLine.height, 1);
+ expect(secondLine.baseline - firstLine.baseline).toBeCloseTo(
+ firstLine.height,
+ 1
+ );
});
it("should handle empty lines correctly", async () => {
@@ -370,7 +375,7 @@ describe("Paragraph Methods", () => {
);
expect(lineMetrics).toHaveLength(3);
-
+
// Middle line should be empty but still have metrics
const emptyLine = lineMetrics[1];
// Empty line might have startIndex != endIndex depending on implementation
diff --git a/packages/skia/src/specs/NativeSkiaModule.web.ts b/packages/skia/src/specs/NativeSkiaModule.web.ts
index 86d94e6c23..5dfb9c2e1c 100644
--- a/packages/skia/src/specs/NativeSkiaModule.web.ts
+++ b/packages/skia/src/specs/NativeSkiaModule.web.ts
@@ -1,19 +1,19 @@
/* eslint-disable import/no-anonymous-default-export */
import type { SkPicture, SkRect } from "../skia/types";
import type { ISkiaViewApi } from "../views/types";
-import type { SkiaPictureView } from "../views/SkiaPictureView.web";
+import type { SkiaPictureViewHandle } from "../views/SkiaPictureView.web";
export type ISkiaViewApiWeb = ISkiaViewApi & {
- views: Record;
+ views: Record;
deferedPictures: Record;
- registerView(nativeId: string, view: SkiaPictureView): void;
+ registerView(nativeId: string, view: SkiaPictureViewHandle): void;
};
global.SkiaViewApi = {
views: {},
deferedPictures: {},
web: true,
- registerView(nativeId: string, view: SkiaPictureView) {
+ registerView(nativeId: string, view: SkiaPictureViewHandle) {
// Maybe a picture for this view was already set
if (this.deferedPictures[nativeId]) {
view.setPicture(this.deferedPictures[nativeId] as SkPicture);
diff --git a/packages/skia/src/views/SkiaBaseWebView.tsx b/packages/skia/src/views/SkiaBaseWebView.tsx
deleted file mode 100644
index d7f16d5ef2..0000000000
--- a/packages/skia/src/views/SkiaBaseWebView.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-/* global HTMLCanvasElement */
-import React from "react";
-import type { LayoutChangeEvent } from "react-native";
-
-import type { SkRect, SkCanvas } from "../skia/types";
-import { JsiSkSurface } from "../skia/web/JsiSkSurface";
-import { Platform } from "../Platform";
-
-import type { SkiaBaseViewProps } from "./types";
-
-const pd = Platform.PixelRatio;
-
-export abstract class SkiaBaseWebView<
- TProps extends SkiaBaseViewProps
-> extends React.Component {
- constructor(props: TProps) {
- super(props);
- }
-
- private _surface: JsiSkSurface | null = null;
- private _unsubscriptions: Array<() => void> = [];
- private _canvas: SkCanvas | null = null;
- private _canvasRef = React.createRef();
- private _redrawRequests = 0;
- private requestId = 0;
-
- protected width = 0;
- protected height = 0;
-
- private unsubscribeAll() {
- this._unsubscriptions.forEach((u) => u());
- this._unsubscriptions = [];
- }
-
- private onLayoutEvent(evt: LayoutChangeEvent) {
- const { CanvasKit } = global;
- // Reset canvas / surface on layout change
- 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);
- const ctx = canvas.getContext("webgl2");
- if (ctx) {
- ctx.drawingBufferColorSpace = "display-p3";
- }
- if (!surface) {
- throw new Error("Could not create surface");
- }
- this._surface = new JsiSkSurface(CanvasKit, surface);
- this._canvas = this._surface.getCanvas();
- this.redraw();
- }
- // Call onLayout callback if it exists
- if (this.props.onLayout) {
- this.props.onLayout(evt);
- }
- }
-
- getSize() {
- return { width: this.width, height: this.height };
- }
-
- componentDidMount() {
- // Start render loop
- this.tick();
- }
-
- componentDidUpdate() {
- this.redraw();
- }
-
- componentWillUnmount() {
- this.unsubscribeAll();
- cancelAnimationFrame(this.requestId);
- // eslint-disable-next-line max-len
- // https://stackoverflow.com/questions/23598471/how-do-i-clean-up-and-unload-a-webgl-canvas-context-from-gpu-after-use
- // https://developer.mozilla.org/en-US/docs/Web/API/WEBGL_lose_context
- // We delete the context, only if the context has been intialized
- if (this._surface) {
- this._canvasRef.current
- ?.getContext("webgl2")
- ?.getExtension("WEBGL_lose_context")
- ?.loseContext();
- }
- }
-
- /**
- * Creates a snapshot from the canvas in the surface
- * @param rect Rect to use as bounds. Optional.
- * @returns An Image object.
- */
- public makeImageSnapshot(rect?: SkRect) {
- this._canvas!.clear(CanvasKit.TRANSPARENT);
- this.renderInCanvas(this._canvas!);
- this._surface?.ref.flush();
- return this._surface?.makeImageSnapshot(rect);
- }
-
- /**
- * Override to render
- */
- protected abstract renderInCanvas(canvas: SkCanvas): void;
-
- /**
- * Sends a redraw request to the native SkiaView.
- */
- private tick() {
- if (this._redrawRequests > 0) {
- this._redrawRequests = 0;
- if (this._canvas) {
- const canvas = this._canvas!;
- canvas.clear(Float32Array.of(0, 0, 0, 0));
- canvas.save();
- canvas.scale(pd, pd);
- this.renderInCanvas(canvas);
- canvas.restore();
- this._surface?.ref.flush();
- }
- }
- this.requestId = requestAnimationFrame(this.tick.bind(this));
- }
-
- public redraw() {
- this._redrawRequests++;
- }
-
- private onLayout = this.onLayoutEvent.bind(this);
-
- render() {
- const { debug = false, ...viewProps } = this.props;
- return (
-
-
-
- );
- }
-}
diff --git a/packages/skia/src/views/SkiaPictureView.web.tsx b/packages/skia/src/views/SkiaPictureView.web.tsx
index 16523ee026..0425518502 100644
--- a/packages/skia/src/views/SkiaPictureView.web.tsx
+++ b/packages/skia/src/views/SkiaPictureView.web.tsx
@@ -1,31 +1,325 @@
-import type { SkCanvas, SkPicture } from "../skia/types";
+/* global HTMLCanvasElement */
+import React, {
+ useRef,
+ useEffect,
+ useCallback,
+ useImperativeHandle,
+ forwardRef,
+} from "react";
+import type { LayoutChangeEvent } from "react-native";
+
+import type { SkRect, SkPicture, SkImage } from "../skia/types";
+import { JsiSkSurface } from "../skia/web/JsiSkSurface";
+import { Platform } from "../Platform";
import type { ISkiaViewApiWeb } from "../specs/NativeSkiaModule.web";
import type { SkiaPictureViewNativeProps } from "./types";
-import { SkiaBaseWebView } from "./SkiaBaseWebView";
+import { SkiaViewNativeId } from "./SkiaViewNativeId";
+
+interface Renderer {
+ onResize(): void;
+ draw(picture: SkPicture): void;
+ makeImageSnapshot(picture: SkPicture, rect?: SkRect): SkImage | null;
+ dispose(): void;
+}
+
+class WebGLRenderer implements Renderer {
+ private surface: JsiSkSurface | null = null;
+
+ constructor(private canvas: HTMLCanvasElement, private pd: number) {
+ this.onResize();
+ }
+
+ makeImageSnapshot(picture: SkPicture, rect?: SkRect): SkImage | null {
+ if (!this.surface) {
+ return null;
+ }
+ const canvas = this.surface.getCanvas();
+ canvas!.clear(CanvasKit.TRANSPARENT);
+ this.draw(picture);
+ this.surface.ref.flush();
+ return this.surface.makeImageSnapshot(rect);
+ }
+
+ onResize() {
+ const { canvas, pd } = this;
+ canvas.width = canvas.clientWidth * pd;
+ canvas.height = canvas.clientHeight * pd;
+ const surface = CanvasKit.MakeWebGLCanvasSurface(canvas);
+ const ctx = canvas.getContext("webgl2");
+ if (ctx) {
+ ctx.drawingBufferColorSpace = "display-p3";
+ }
+ if (!surface) {
+ throw new Error("Could not create surface");
+ }
+ this.surface = new JsiSkSurface(CanvasKit, surface);
+ }
+
+ draw(picture: SkPicture) {
+ if (this.surface) {
+ const canvas = this.surface.getCanvas();
+ canvas.clear(Float32Array.of(0, 0, 0, 0));
+ canvas.save();
+ canvas.scale(pd, pd);
+ canvas.drawPicture(picture);
+ canvas.restore();
+ this.surface.ref.flush();
+ }
+ }
-export class SkiaPictureView extends SkiaBaseWebView {
- private picture: SkPicture | null = null;
+ dispose(): void {
+ if (this.surface) {
+ this.canvas
+ ?.getContext("webgl2")
+ ?.getExtension("WEBGL_lose_context")
+ ?.loseContext();
+ this.surface.ref.delete();
+ this.surface = null;
+ }
+ }
+}
+
+class StaticWebGLRenderer implements Renderer {
+ private cachedImage: SkImage | null = null;
+
+ constructor(private canvas: HTMLCanvasElement, private pd: number) {}
+
+ onResize(): void {
+ this.cachedImage = null;
+ }
+
+ private renderPictureToSurface(
+ picture: SkPicture
+ ): { surface: JsiSkSurface; tempCanvas: OffscreenCanvas } | null {
+ const tempCanvas = new OffscreenCanvas(
+ this.canvas.clientWidth * this.pd,
+ this.canvas.clientHeight * this.pd
+ );
+
+ let surface: JsiSkSurface | null = null;
+
+ try {
+ const webglSurface = CanvasKit.MakeWebGLCanvasSurface(tempCanvas);
+ const ctx = tempCanvas.getContext("webgl2");
+ if (ctx) {
+ ctx.drawingBufferColorSpace = "display-p3";
+ }
+
+ if (!webglSurface) {
+ throw new Error("Could not create WebGL surface");
+ }
+
+ surface = new JsiSkSurface(CanvasKit, webglSurface);
+
+ const skiaCanvas = surface.getCanvas();
+ skiaCanvas.clear(Float32Array.of(0, 0, 0, 0));
+ skiaCanvas.save();
+ skiaCanvas.scale(this.pd, this.pd);
+ skiaCanvas.drawPicture(picture);
+ skiaCanvas.restore();
+ surface.ref.flush();
+
+ return { surface, tempCanvas };
+ } catch (error) {
+ if (surface) {
+ surface.ref.delete();
+ }
+ this.cleanupWebGLContext(tempCanvas);
+ return null;
+ }
+ }
- constructor(props: SkiaPictureViewNativeProps) {
- super(props);
- const { nativeID } = props;
- if (!nativeID) {
- throw new Error("SkiaPictureView requires a nativeID prop");
+ private cleanupWebGLContext(tempCanvas: OffscreenCanvas): void {
+ const ctx = tempCanvas.getContext("webgl2");
+ if (ctx) {
+ const loseContext = ctx.getExtension("WEBGL_lose_context");
+ if (loseContext) {
+ loseContext.loseContext();
+ }
}
- (global.SkiaViewApi as ISkiaViewApiWeb).registerView(nativeID, this);
}
- public setPicture(picture: SkPicture) {
- this.picture = picture;
- this.redraw();
+ draw(picture: SkPicture): void {
+ const renderResult = this.renderPictureToSurface(picture);
+ if (!renderResult) {
+ return;
+ }
+ const { tempCanvas } = renderResult;
+ const ctx2d = this.canvas.getContext("2d");
+ if (!ctx2d) {
+ throw new Error("Could not get 2D context");
+ }
+
+ // Set canvas dimensions to match pixel density
+ this.canvas.width = this.canvas.clientWidth * this.pd;
+ this.canvas.height = this.canvas.clientHeight * this.pd;
+
+ // Draw the tempCanvas scaled down to the display size
+ ctx2d.drawImage(
+ tempCanvas,
+ 0,
+ 0,
+ tempCanvas.width,
+ tempCanvas.height,
+ 0,
+ 0,
+ this.canvas.clientWidth * this.pd,
+ this.canvas.clientHeight * this.pd
+ );
+
+ this.cleanupWebGLContext(tempCanvas);
}
- protected renderInCanvas(canvas: SkCanvas): void {
- if (this.props.picture) {
- canvas.drawPicture(this.props.picture);
- } else if (this.picture) {
- canvas.drawPicture(this.picture);
+ makeImageSnapshot(picture: SkPicture, rect?: SkRect): SkImage | null {
+ if (!this.cachedImage) {
+ const renderResult = this.renderPictureToSurface(picture);
+ if (!renderResult) {
+ return null;
+ }
+
+ const { surface, tempCanvas } = renderResult;
+
+ try {
+ this.cachedImage = surface.makeImageSnapshot(rect);
+ } catch (error) {
+ console.error("Error creating image snapshot:", error);
+ } finally {
+ surface.ref.delete();
+ this.cleanupWebGLContext(tempCanvas);
+ }
}
+
+ return this.cachedImage;
+ }
+
+ dispose(): void {
+ this.cachedImage?.dispose();
+ this.cachedImage = null;
}
}
+
+const pd = Platform.PixelRatio;
+
+export interface SkiaPictureViewHandle {
+ setPicture(picture: SkPicture): void;
+ getSize(): { width: number; height: number };
+ redraw(): void;
+ makeImageSnapshot(rect?: SkRect): SkImage | null;
+}
+
+export const SkiaPictureView = forwardRef<
+ SkiaPictureViewHandle,
+ SkiaPictureViewNativeProps
+>((props, ref) => {
+ const canvasRef = useRef(null);
+ const renderer = useRef(null);
+ const redrawRequestsRef = useRef(0);
+ const requestIdRef = useRef(0);
+ const pictureRef = useRef(null);
+
+ const { picture, onLayout } = props;
+
+ const redraw = useCallback(() => {
+ redrawRequestsRef.current++;
+ }, []);
+
+ const getSize = useCallback(() => {
+ return {
+ width: canvasRef.current?.clientWidth || 0,
+ height: canvasRef.current?.clientHeight || 0,
+ };
+ }, []);
+
+ const setPicture = useCallback(
+ (newPicture: SkPicture) => {
+ pictureRef.current = newPicture;
+ redraw();
+ },
+ [redraw]
+ );
+
+ const makeImageSnapshot = useCallback((rect?: SkRect) => {
+ if (renderer.current && pictureRef.current) {
+ return renderer.current.makeImageSnapshot(pictureRef.current, rect);
+ }
+ return null;
+ }, []);
+
+ const tick = useCallback(() => {
+ if (redrawRequestsRef.current > 0) {
+ redrawRequestsRef.current = 0;
+ if (renderer.current && pictureRef.current) {
+ renderer.current.draw(pictureRef.current);
+ }
+ }
+ requestIdRef.current = requestAnimationFrame(tick);
+ }, []);
+
+ const onLayoutEvent = useCallback(
+ (evt: LayoutChangeEvent) => {
+ const canvas = canvasRef.current;
+ if (canvas) {
+ renderer.current =
+ props.__destroyWebGLContextAfterRender === true
+ ? new StaticWebGLRenderer(canvas, pd)
+ : new WebGLRenderer(canvas, pd);
+ if (pictureRef.current) {
+ renderer.current.draw(pictureRef.current);
+ }
+ }
+ if (onLayout) {
+ onLayout(evt);
+ }
+ },
+ [onLayout, props.__destroyWebGLContextAfterRender]
+ );
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ setPicture,
+ getSize,
+ redraw,
+ makeImageSnapshot,
+ }),
+ [setPicture, getSize, redraw, makeImageSnapshot]
+ );
+
+ useEffect(() => {
+ const nativeID = props.nativeID ?? `${SkiaViewNativeId.current++}`;
+ (global.SkiaViewApi as ISkiaViewApiWeb).registerView(nativeID, {
+ setPicture,
+ getSize,
+ redraw,
+ makeImageSnapshot,
+ } as SkiaPictureViewHandle);
+ if (props.picture) {
+ setPicture(props.picture);
+ }
+ }, [setPicture, getSize, redraw, makeImageSnapshot, props]);
+
+ useEffect(() => {
+ tick();
+ return () => {
+ cancelAnimationFrame(requestIdRef.current);
+ if (renderer.current) {
+ renderer.current.dispose();
+ renderer.current = null;
+ }
+ };
+ }, [tick]);
+
+ useEffect(() => {
+ if (renderer.current && pictureRef.current) {
+ renderer.current.draw(pictureRef.current);
+ }
+ }, [picture, redraw]);
+
+ const { debug = false, ...viewProps } = props;
+ return (
+
+
+
+ );
+});
diff --git a/packages/skia/src/views/types.ts b/packages/skia/src/views/types.ts
index 224024a3f6..dcc084be0d 100644
--- a/packages/skia/src/views/types.ts
+++ b/packages/skia/src/views/types.ts
@@ -31,6 +31,10 @@ export interface SkiaBaseViewProps extends ViewProps {
onSize?: SharedValue;
opaque?: boolean;
+
+ // On web, only 16 WebGL contextes are allowed. If the drawing is non-animated, set
+ // __destroyWebGLContextAfterRender to true to release the context after each draw.
+ __destroyWebGLContextAfterRender?: boolean;
}
export interface SkiaPictureViewNativeProps extends SkiaBaseViewProps {