diff --git a/packages/ui/demo/serve.ts b/packages/ui/demo/serve.ts
index 8a11106c..59e25947 100644
--- a/packages/ui/demo/serve.ts
+++ b/packages/ui/demo/serve.ts
@@ -7,6 +7,41 @@ const repoRoot = join(here, "..", "..", "..")
const sdkIife = join(repoRoot, "packages", "core", "dist", "repro.iife.js")
const indexHtml = join(here, "index.html")
+// A real host-page CSP, copied from a site that hit the "Capturing…" hang.
+// The load-bearing part is `img-src 'self' data:` with **no `blob:`** — an
+//
is refused under this policy, which is why the SDK
+// decodes screenshots with createImageBitmap (no resource load) instead.
+// Serve the same demo page under it at /csp to exercise that path.
+const STRICT_CSP = [
+ "default-src 'self'",
+ "script-src 'self' 'unsafe-inline'",
+ "style-src 'self' 'unsafe-inline'",
+ "img-src 'self' data:",
+ "connect-src 'self'",
+].join("; ")
+
+// Capture is entirely client-side, so a well-formed-but-fake key is enough to
+// exercise the screenshot flow. Override with REPRO_DEMO_KEY to submit for
+// real against a running dashboard.
+const DEMO_KEY = process.env.REPRO_DEMO_KEY ?? "rp_pk_demo00000000000000000000"
+
+function renderPage(html: string, csp: boolean): string {
+ const banner = csp
+ ? `
+ CSP mode — img-src 'self' data: (no blob:).
+ Capture must still work. Before the createImageBitmap fix this hung on “Capturing…”.
+
`
+ : `
+ No CSP — baseline. Compare against
/csp.
+
`
+ return html
+ .replace(
+ "",
+ ``,
+ )
+ .replace("", `${banner}`)
+}
+
Bun.serve({
port: 4000,
hostname: "localhost",
@@ -14,10 +49,19 @@ Bun.serve({
const url = new URL(req.url)
if (url.pathname === "/" || url.pathname === "/index.html") {
const body = await readFile(indexHtml, "utf8")
- return new Response(body, {
+ return new Response(renderPage(body, false), {
headers: { "Content-Type": "text/html; charset=utf-8" },
})
}
+ if (url.pathname === "/csp") {
+ const body = await readFile(indexHtml, "utf8")
+ return new Response(renderPage(body, true), {
+ headers: {
+ "Content-Type": "text/html; charset=utf-8",
+ "Content-Security-Policy": STRICT_CSP,
+ },
+ })
+ }
if (url.pathname === "/sdk.iife.js") {
try {
const body = await readFile(sdkIife)
@@ -35,4 +79,6 @@ Bun.serve({
},
})
-console.info("Repro demo playground: http://localhost:4000")
+console.info("Repro demo playground:")
+console.info(" baseline (no CSP): http://localhost:4000/")
+console.info(" strict CSP: http://localhost:4000/csp ← the reported bug's policy")
diff --git a/packages/ui/src/annotation/canvas.tsx b/packages/ui/src/annotation/canvas.tsx
index 6392be64..b4857d37 100644
--- a/packages/ui/src/annotation/canvas.tsx
+++ b/packages/ui/src/annotation/canvas.tsx
@@ -3,6 +3,7 @@ import { effect } from "@preact/signals"
import { h } from "preact"
import { useEffect, useRef, useState } from "preact/hooks"
import { render as renderAll } from "./render"
+import { sourceHeight, sourceWidth, type ImageSource } from "../decode-image"
import { color, commit, draft, shapes, strokeW, tool, viewport } from "./store"
import { arrowTool, highlightTool, penTool, rectTool, textTool } from "@reprojs/sdk-utils"
import type { ToolHandler } from "@reprojs/sdk-utils"
@@ -18,13 +19,11 @@ const HANDLERS: Record = {
}
export interface CanvasProps {
- bg: HTMLImageElement
+ bg: ImageSource
}
-function naturalDims(bg: HTMLImageElement): { w: number; h: number } {
- const imgW = (bg as unknown as { naturalWidth?: number }).naturalWidth ?? bg.width
- const imgH = (bg as unknown as { naturalHeight?: number }).naturalHeight ?? bg.height
- return { w: imgW, h: imgH }
+function naturalDims(bg: ImageSource): { w: number; h: number } {
+ return { w: sourceWidth(bg), h: sourceHeight(bg) }
}
export function Canvas({ bg }: CanvasProps) {
diff --git a/packages/ui/src/annotation/flatten.ts b/packages/ui/src/annotation/flatten.ts
index 4496662f..03df52a2 100644
--- a/packages/ui/src/annotation/flatten.ts
+++ b/packages/ui/src/annotation/flatten.ts
@@ -1,9 +1,10 @@
import { render } from "./render"
+import { sourceHeight, sourceWidth, type ImageSource } from "../decode-image"
import { IDENTITY_TRANSFORM, type Shape } from "@reprojs/sdk-utils"
-export async function flatten(bg: HTMLImageElement, shapes: Shape[]): Promise {
- const width = bg.naturalWidth ?? bg.width
- const height = bg.naturalHeight ?? bg.height
+export async function flatten(bg: ImageSource, shapes: Shape[]): Promise {
+ const width = sourceWidth(bg)
+ const height = sourceHeight(bg)
const canvas = document.createElement("canvas")
canvas.width = width
diff --git a/packages/ui/src/annotation/render.ts b/packages/ui/src/annotation/render.ts
index e42bb0a6..965a6fc3 100644
--- a/packages/ui/src/annotation/render.ts
+++ b/packages/ui/src/annotation/render.ts
@@ -1,9 +1,10 @@
import { wrapText } from "./text-wrap"
+import type { ImageSource } from "../decode-image"
import type { Shape, Transform } from "@reprojs/sdk-utils"
export function render(
ctx: CanvasRenderingContext2D,
- bg: HTMLImageElement | HTMLCanvasElement,
+ bg: ImageSource | HTMLCanvasElement,
shapes: Shape[],
t: Transform,
): void {
diff --git a/packages/ui/src/blob-image.tsx b/packages/ui/src/blob-image.tsx
new file mode 100644
index 00000000..d3d6cfb7
--- /dev/null
+++ b/packages/ui/src/blob-image.tsx
@@ -0,0 +1,42 @@
+import { h } from "preact"
+import { useEffect, useRef } from "preact/hooks"
+import { closeSource, decodeImage, sourceHeight, sourceWidth } from "./decode-image"
+
+interface Props {
+ blob: Blob
+ alt: string
+ class?: string
+}
+
+// Renders a Blob as an image without going through a blob: object URL, which
+// host-page CSPs routinely refuse (`img-src 'self' data:`). Draws the decoded
+// bitmap into a