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 instead — canvas painting is not a resource load, so +// no img-src check applies. See decode-image.ts. +// +// is a replaced element, so the object-fit / aspect-ratio rules the +// thumbnails rely on still apply. +export function BlobImage({ blob, alt, class: className }: Props) { + const ref = useRef(null) + + useEffect(() => { + let cancelled = false + ;(async () => { + const src = await decodeImage(blob) + if (!src) return + const canvas = ref.current + if (cancelled || !canvas) { + closeSource(src) + return + } + canvas.width = sourceWidth(src) + canvas.height = sourceHeight(src) + canvas.getContext("2d")?.drawImage(src, 0, 0) + closeSource(src) + })() + return () => { + cancelled = true + } + }, [blob]) + + return h("canvas", { ref, class: className, role: "img", "aria-label": alt }) +} diff --git a/packages/ui/src/decode-image.test.ts b/packages/ui/src/decode-image.test.ts new file mode 100644 index 00000000..c6243cca --- /dev/null +++ b/packages/ui/src/decode-image.test.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Window } from "happy-dom" +import { decodeImage, sourceHeight, sourceWidth } from "./decode-image" + +type Mutable = Record + +function setupDom() { + const win = new Window() + const g = globalThis as unknown as Mutable + g.document = win.document + g.window = win + return win +} + +// Keep the real URL constructor intact — happy-dom's Window needs it. Only the +// object-URL helpers get stubbed, and only for the duration of a test. +const realCreateObjectURL = URL.createObjectURL +const realRevokeObjectURL = URL.revokeObjectURL +const realImage = (globalThis as unknown as Mutable).Image +const realCreateImageBitmap = (globalThis as unknown as Mutable).createImageBitmap + +function stubObjectUrl(create: () => string, revoke: () => void) { + URL.createObjectURL = create as unknown as typeof URL.createObjectURL + URL.revokeObjectURL = revoke as unknown as typeof URL.revokeObjectURL +} + +afterEach(() => { + const g = globalThis as unknown as Mutable + g.createImageBitmap = realCreateImageBitmap + g.Image = realImage + URL.createObjectURL = realCreateObjectURL + URL.revokeObjectURL = realRevokeObjectURL +}) + +const PNG = new Blob([new Uint8Array([137, 80, 78, 71])], { type: "image/png" }) + +describe("decodeImage", () => { + // The reason this module exists: host pages commonly ship + // `img-src 'self' data:` with no `blob:`. Routing the screenshot through + // URL.createObjectURL + gets refused by CSP, fires `error` instead of + // `load`, and used to strand the reporter on "Capturing…" forever. + // createImageBitmap decodes the Blob directly with no resource fetch, so + // img-src never applies. + test("decodes via createImageBitmap without minting a blob: URL", async () => { + setupDom() + const g = globalThis as unknown as Mutable + const bitmap = { width: 800, height: 600, close: () => {} } + g.createImageBitmap = async () => bitmap + + let objectUrls = 0 + stubObjectUrl( + () => { + objectUrls++ + return "blob:nope" + }, + () => {}, + ) + + const out = await decodeImage(PNG) + + expect(out).toBe(bitmap as unknown as ImageBitmap) + expect(objectUrls).toBe(0) + }) + + test("returns null instead of hanging when the image cannot be decoded", async () => { + setupDom() + const g = globalThis as unknown as Mutable + g.createImageBitmap = undefined + // Simulate the CSP refusal: assigning src fires `error`, never `load`. + g.Image = class { + onload: (() => void) | null = null + listeners: Record void>> = {} + addEventListener(type: string, cb: () => void) { + ;(this.listeners[type] ??= []).push(cb) + } + set src(_v: string) { + queueMicrotask(() => this.listeners.error?.forEach((cb) => cb())) + } + } + + const out = await decodeImage(PNG) + + expect(out).toBeNull() + }) + + test("falls back to an when createImageBitmap is unavailable", async () => { + setupDom() + const g = globalThis as unknown as Mutable + g.createImageBitmap = undefined + let revoked = 0 + stubObjectUrl( + () => "blob:ok", + () => { + revoked++ + }, + ) + const img: Mutable = {} + g.Image = class { + naturalWidth = 320 + naturalHeight = 240 + listeners: Record void>> = {} + constructor() { + Object.assign(img, this) + } + addEventListener(type: string, cb: () => void) { + ;(this.listeners[type] ??= []).push(cb) + } + set src(_v: string) { + queueMicrotask(() => this.listeners.load?.forEach((cb) => cb())) + } + } + + const out = await decodeImage(PNG) + + if (!out) throw new Error("expected decodeImage to fall back to an ") + expect(sourceWidth(out)).toBe(320) + expect(sourceHeight(out)).toBe(240) + // The object URL must not leak once the image has loaded. + expect(revoked).toBe(1) + }) +}) + +describe("sourceWidth / sourceHeight", () => { + test("reads intrinsic dimensions from an ImageBitmap", () => { + const bitmap = { width: 1280, height: 720 } as unknown as ImageBitmap + expect(sourceWidth(bitmap)).toBe(1280) + expect(sourceHeight(bitmap)).toBe(720) + }) + + test("prefers naturalWidth/naturalHeight on an HTMLImageElement", () => { + // width/height are layout attributes and can disagree with the real + // pixel dimensions; the annotation canvas needs the intrinsic ones. + const img = { + naturalWidth: 1280, + naturalHeight: 720, + width: 100, + height: 50, + } as unknown as HTMLImageElement + expect(sourceWidth(img)).toBe(1280) + expect(sourceHeight(img)).toBe(720) + }) +}) diff --git a/packages/ui/src/decode-image.ts b/packages/ui/src/decode-image.ts new file mode 100644 index 00000000..e553f991 --- /dev/null +++ b/packages/ui/src/decode-image.ts @@ -0,0 +1,81 @@ +// Turning a captured Blob into something drawable must not depend on the host +// page's Content-Security-Policy. +// +// The obvious route — URL.createObjectURL(blob) assigned to an — is a +// resource load, so it is checked against `img-src`. Real host pages routinely +// ship `img-src 'self' data:` with no `blob:` (Pantheon/WordPress defaults, +// most CSP generators). There the load is refused, `error` fires instead of +// `load`, and the reporter is left with no screenshot. +// +// createImageBitmap decodes the Blob in-process with no fetch, so `img-src` +// never applies. It is the primary path; the route survives only as a +// fallback for engines without it, and always resolves (null on failure) +// rather than hanging. + +export type ImageSource = ImageBitmap | HTMLImageElement + +// How long the fallback waits before giving up. Only reachable on +// engines without createImageBitmap; a refused load normally fires `error` +// immediately, but a policy that neither loads nor errors must not strand the +// caller. +const FALLBACK_TIMEOUT_MS = 10_000 + +export function sourceWidth(src: ImageSource): number { + return "naturalWidth" in src ? src.naturalWidth : src.width +} + +export function sourceHeight(src: ImageSource): number { + return "naturalHeight" in src ? src.naturalHeight : src.height +} + +// Release an ImageBitmap's backing memory. Large frames otherwise sit in GPU +// memory until GC runs. No-op for . +export function closeSource(src: ImageSource | null): void { + if (src && "close" in src && typeof src.close === "function") src.close() +} + +export async function decodeImage(blob: Blob): Promise { + const create = ( + globalThis as { + createImageBitmap?: (b: Blob) => Promise + } + ).createImageBitmap + if (typeof create === "function") { + try { + return await create.call(globalThis, blob) + } catch { + // Corrupt/undecodable data, or an engine that rejects this blob type. + // Try the route before giving up. + } + } + return await decodeViaObjectUrl(blob) +} + +async function decodeViaObjectUrl(blob: Blob): Promise { + let created: string + try { + created = URL.createObjectURL(blob) + } catch { + return null + } + const img = new Image() + let timer: ReturnType | undefined + try { + // `once` listeners are mutually exclusive, and the timeout only decides the + // race — whichever settles first wins and the rest are inert. + const loaded = await Promise.race([ + new Promise((resolve) => { + img.addEventListener("load", () => resolve(true), { once: true }) + img.addEventListener("error", () => resolve(false), { once: true }) + img.src = created + }), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), FALLBACK_TIMEOUT_MS) + }), + ]) + return loaded ? img : null + } finally { + clearTimeout(timer) + URL.revokeObjectURL(created) + } +} diff --git a/packages/ui/src/reporter.tsx b/packages/ui/src/reporter.tsx index 0ae6275f..3b37b215 100644 --- a/packages/ui/src/reporter.tsx +++ b/packages/ui/src/reporter.tsx @@ -1,6 +1,7 @@ import { h } from "preact" import { useEffect, useMemo, useRef, useState } from "preact/hooks" import { reset, shapes } from "./annotation/store" +import { closeSource, decodeImage, type ImageSource } from "./decode-image" import { DEFAULT_ATTACHMENT_LIMITS, validateAttachments, type Attachment } from "@reprojs/sdk-utils" import { StepAnnotate } from "./wizard/step-annotate" import { StepDetails } from "./wizard/step-details" @@ -32,7 +33,7 @@ type StepName = "annotate" | "details" | "review" const STEP_INDEX: Record = { annotate: 0, details: 1, review: 2 } export function Reporter({ onClose, onCapture, onSubmit, openedAt }: ReporterProps) { - const [bg, setBg] = useState(null) + const [bg, setBg] = useState(null) const [annotatedBlob, setAnnotatedBlob] = useState(null) const [rawScreenshot, setRawScreenshot] = useState(null) const [step, setStep] = useState("annotate") @@ -44,47 +45,36 @@ export function Reporter({ onClose, onCapture, onSubmit, openedAt }: ReporterPro const hpRef = useRef(null) const [attachments, setAttachments] = useState([]) const [attachmentErrors, setAttachmentErrors] = useState([]) - const attachmentsRef = useRef([]) useEffect(() => { - attachmentsRef.current = attachments - }, [attachments]) - - useEffect(() => { - return () => { - for (const a of attachmentsRef.current) { - if (a.previewUrl) URL.revokeObjectURL(a.previewUrl) - } - } - }, []) - - useEffect(() => { - let revoked = false - let url: string | null = null - const revokeOnce = () => { - if (url) { - URL.revokeObjectURL(url) - url = null - } - } + let cancelled = false + let decoded: ImageSource | null = null ;(async () => { const blob = await onCapture() if (!blob) { - if (!revoked) onClose() + if (!cancelled) onClose() return } setRawScreenshot(blob) - url = URL.createObjectURL(blob) - const img = new Image() - img.addEventListener("load", () => { - if (!revoked) setBg(img) - revokeOnce() - }) - img.addEventListener("error", revokeOnce) - img.src = url + // Decode without minting a blob: URL — host pages whose CSP omits + // `blob:` from img-src would refuse to load it. See decode-image.ts. + decoded = await decodeImage(blob) + if (cancelled) { + closeSource(decoded) + return + } + if (!decoded) { + // Never leave the wizard gated on a screenshot that will never + // arrive — the loading overlay would hang with the page scroll + // locked behind it. + console.warn("[repro] could not decode the screenshot; closing the reporter") + onClose() + return + } + setBg(decoded) })() return () => { - revoked = true - revokeOnce() + cancelled = true + closeSource(decoded) reset() } }, []) @@ -115,11 +105,11 @@ export function Reporter({ onClose, onCapture, onSubmit, openedAt }: ReporterPro function handleAttachmentsAdd(files: File[]) { const result = validateAttachments(files, attachments, DEFAULT_ATTACHMENT_LIMITS) if (result.accepted.length > 0) { - const withPreviews = result.accepted.map((a) => ({ - ...a, - previewUrl: a.isImage ? URL.createObjectURL(a.blob) : undefined, - })) - setAttachments((prev) => [...prev, ...withPreviews]) + // No previewUrl on web: thumbnails render straight from the blob via + // BlobImage, since a blob: URL in is refused by host CSPs + // that omit `blob:` from img-src. (The Expo SDK still populates + // previewUrl with a file:// uri — see @reprojs/expo provider.) + setAttachments((prev) => [...prev, ...result.accepted]) } if (result.rejected.length > 0) { setAttachmentErrors( @@ -144,11 +134,7 @@ export function Reporter({ onClose, onCapture, onSubmit, openedAt }: ReporterPro } function handleAttachmentRemove(id: string) { - setAttachments((prev) => { - const target = prev.find((a) => a.id === id) - if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl) - return prev.filter((a) => a.id !== id) - }) + setAttachments((prev) => prev.filter((a) => a.id !== id)) } // Paste-to-attach: while the user is on the Details step, intercept paste diff --git a/packages/ui/src/styles-inline.ts b/packages/ui/src/styles-inline.ts index 32c1ef59..b777496c 100644 --- a/packages/ui/src/styles-inline.ts +++ b/packages/ui/src/styles-inline.ts @@ -157,7 +157,11 @@ export default String.raw`:host, align-items: flex-start; justify-content: center; } -.ft-wizard-details-preview img { +/* The preview renders as a (BlobImage) rather than an : host + CSPs that omit blob: from img-src refuse a blob: object URL. Both are + matched so the constraint survives either element. */ +.ft-wizard-details-preview img, +.ft-wizard-details-preview canvas { max-width: 100%; max-height: calc(100vh - 220px); border: 1px solid var(--ft-color-border); @@ -189,7 +193,8 @@ export default String.raw`:host, .ft-wizard-details-preview { position: static; } - .ft-wizard-details-preview img { + .ft-wizard-details-preview img, + .ft-wizard-details-preview canvas { max-height: 40vh; } } diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index 3ee9dfff..a9e1e4d8 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -154,7 +154,11 @@ align-items: flex-start; justify-content: center; } -.ft-wizard-details-preview img { +/* The preview renders as a (BlobImage) rather than an : host + CSPs that omit blob: from img-src refuse a blob: object URL. Both are + matched so the constraint survives either element. */ +.ft-wizard-details-preview img, +.ft-wizard-details-preview canvas { max-width: 100%; max-height: calc(100vh - 220px); border: 1px solid var(--ft-color-border); @@ -186,7 +190,8 @@ .ft-wizard-details-preview { position: static; } - .ft-wizard-details-preview img { + .ft-wizard-details-preview img, + .ft-wizard-details-preview canvas { max-height: 40vh; } } diff --git a/packages/ui/src/wizard/attachment-list.test.ts b/packages/ui/src/wizard/attachment-list.test.ts index eb8d4473..a354485d 100644 --- a/packages/ui/src/wizard/attachment-list.test.ts +++ b/packages/ui/src/wizard/attachment-list.test.ts @@ -117,11 +117,14 @@ describe("AttachmentList", () => { expect(removedId).toBe("remove-me") }) + // Thumbnails must render straight from the blob, never via a blob: object + // URL in — host CSPs with `img-src 'self' data:` refuse those, so + // previewUrl is deliberately absent here to prove it isn't relied on. test("renders thumbnail for image attachments and icon for non-images", () => { const win = setupDom() const root = win.document.createElement("div") win.document.body.appendChild(root as unknown as Node) - const imageAtt = makeAttachment({ id: "img-1", isImage: true, previewUrl: "blob:preview" }) + const imageAtt = makeAttachment({ id: "img-1", isImage: true, previewUrl: undefined }) const fileAtt = makeAttachment({ id: "file-1", filename: "doc.pdf", @@ -140,6 +143,7 @@ describe("AttachmentList", () => { ) const thumb = walkForClass(root as unknown as Element, "ft-attach-thumb") expect(thumb).toBeTruthy() + expect(thumb?.tagName?.toLowerCase()).toBe("canvas") const icon = walkForClass(root as unknown as Element, "ft-attach-icon") expect(icon).toBeTruthy() }) diff --git a/packages/ui/src/wizard/attachment-list.tsx b/packages/ui/src/wizard/attachment-list.tsx index 253d0490..c93f3f75 100644 --- a/packages/ui/src/wizard/attachment-list.tsx +++ b/packages/ui/src/wizard/attachment-list.tsx @@ -1,5 +1,6 @@ import { h } from "preact" -import { useEffect, useRef, useState } from "preact/hooks" +import { useRef, useState } from "preact/hooks" +import { BlobImage } from "../blob-image" import type { Attachment, AttachmentLimits } from "@reprojs/sdk-utils" interface Props { @@ -28,13 +29,6 @@ export function AttachmentList({ attachments, limits, errors, onAdd, onRemove }: const atCap = attachments.length >= limits.maxCount const shortcut = isMacLike() ? "⌘V" : "Ctrl+V" - // Revoke object URLs on unmount. - useEffect(() => { - return () => { - for (const a of attachments) if (a.previewUrl) URL.revokeObjectURL(a.previewUrl) - } - }, []) - function openPicker() { if (atCap) return fileInputRef.current?.click() @@ -84,8 +78,8 @@ export function AttachmentList({ attachments, limits, errors, onAdd, onRemove }: }, "✕", ), - a.isImage && a.previewUrl - ? h("img", { class: "ft-attach-thumb", src: a.previewUrl, alt: a.filename }) + a.isImage + ? h(BlobImage, { class: "ft-attach-thumb", blob: a.blob, alt: a.filename }) : h("div", { class: "ft-attach-icon" }, "📄"), h("div", { class: "ft-attach-name", title: a.filename }, a.filename), h("div", { class: "ft-attach-meta" }, formatBytes(a.size)), diff --git a/packages/ui/src/wizard/step-annotate.tsx b/packages/ui/src/wizard/step-annotate.tsx index a1c5868a..6089a1af 100644 --- a/packages/ui/src/wizard/step-annotate.tsx +++ b/packages/ui/src/wizard/step-annotate.tsx @@ -7,10 +7,11 @@ import { clear, redo, shapes, tool, undo, viewport } from "../annotation/store" import { ToolPicker } from "../annotation/tool-picker" import type { Tool } from "@reprojs/sdk-utils" import { fitTransform } from "../annotation/viewport" +import { sourceHeight, sourceWidth, type ImageSource } from "../decode-image" import { PrimaryButton, SecondaryButton, WizardHeader } from "./controls" interface Props { - bg: HTMLImageElement + bg: ImageSource steps: readonly string[] currentStep: number onSkip: () => void @@ -42,9 +43,12 @@ export function StepAnnotate({ bg, steps, currentStep, onSkip, onNext, onCancel case "cancel.draft": return case "resetView": { - const w = (bg as unknown as { naturalWidth?: number }).naturalWidth ?? bg.width - const hh = (bg as unknown as { naturalHeight?: number }).naturalHeight ?? bg.height - viewport.value = fitTransform(w, hh, window.innerWidth, window.innerHeight) + viewport.value = fitTransform( + sourceWidth(bg), + sourceHeight(bg), + window.innerWidth, + window.innerHeight, + ) return } } diff --git a/packages/ui/src/wizard/step-details.tsx b/packages/ui/src/wizard/step-details.tsx index 4075d9cf..06106390 100644 --- a/packages/ui/src/wizard/step-details.tsx +++ b/packages/ui/src/wizard/step-details.tsx @@ -1,5 +1,5 @@ import { h } from "preact" -import { useEffect, useState } from "preact/hooks" +import { BlobImage } from "../blob-image" import { FieldLabel } from "./controls" import { AttachmentList } from "./attachment-list" import { @@ -33,19 +33,8 @@ export function StepDetails({ onAttachmentsAdd, onAttachmentRemove, }: Props) { - const [previewUrl, setPreviewUrl] = useState(null) - useEffect(() => { - if (!annotatedBlob) { - setPreviewUrl(null) - return - } - const url = URL.createObjectURL(annotatedBlob) - setPreviewUrl(url) - return () => URL.revokeObjectURL(url) - }, [annotatedBlob]) - - const preview = previewUrl - ? h("img", { src: previewUrl, alt: "Annotated screenshot" }) + const preview = annotatedBlob + ? h(BlobImage, { blob: annotatedBlob, alt: "Annotated screenshot" }) : h("div", { class: "ft-wizard-details-preview-empty" }, "No screenshot") return h(