Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 80 additions & 13 deletions src/features/tools/qr-code-generator/browser-actions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import type { ErrorCorrectionLevel } from "./types"
import { FILE_INPUT_POLICIES, validateFileAgainstPolicy } from "@/core/files/file-input-policy"

const OBJECT_URL_REVOKE_DELAY_MS = 1_000

export type BrowserDownloadResult = {
ok: true
} | {
ok: false
error: string
}

let qrCodePromise: Promise<typeof import("qrcode")> | null = null
let toastPromise: Promise<typeof import("sonner")["toast"]> | null = null

Expand Down Expand Up @@ -64,11 +73,75 @@ export function readFileAsDataUrl(file: File): Promise<string> {
})
}

export function downloadDataUrl(dataUrl: string, filename: string) {
const a = document.createElement("a")
a.href = dataUrl
a.download = filename
a.click()
function deferObjectUrlRevoke(url: string) {
window.setTimeout(() => URL.revokeObjectURL(url), OBJECT_URL_REVOKE_DELAY_MS)
}

function formatDownloadError(error: unknown): string {
return error instanceof Error ? error.message : String(error || "Browser download failed")
}

export function downloadUrl(url: string, filename: string, options: { revokeObjectUrl?: boolean } = {}): BrowserDownloadResult {
if (typeof document === "undefined") {
return { ok: false, error: "Downloads require a browser document." }
}

const anchor = document.createElement("a")
anchor.href = url
anchor.download = filename
anchor.rel = "noopener"
anchor.style.display = "none"

try {
document.body.appendChild(anchor)
anchor.click()
return { ok: true }
} catch (error) {
return { ok: false, error: formatDownloadError(error) }
} finally {
anchor.remove()
if (options.revokeObjectUrl) {
deferObjectUrlRevoke(url)
}
}
}

export function downloadBlob(blob: Blob, filename: string): BrowserDownloadResult {
try {
const url = URL.createObjectURL(blob)
return downloadUrl(url, filename, { revokeObjectUrl: true })
} catch (error) {
return { ok: false, error: formatDownloadError(error) }
}
}

export function canvasToPngBlob(canvas: HTMLCanvasElement): Promise<Blob> {
if (typeof canvas.toBlob === "function") {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob)
return
}
reject(new Error("Canvas did not produce a PNG blob."))
}, "image/png")
})
}

return fetch(canvas.toDataURL("image/png")).then((response) => response.blob())
}

export async function downloadCanvasPng(canvas: HTMLCanvasElement, filename: string): Promise<BrowserDownloadResult> {
try {
const blob = await canvasToPngBlob(canvas)
return downloadBlob(blob, filename)
} catch (error) {
return { ok: false, error: formatDownloadError(error) }
}
}

export function downloadDataUrl(dataUrl: string, filename: string): BrowserDownloadResult {
return downloadUrl(dataUrl, filename)
}

export async function buildQrSvg(options: {
Expand Down Expand Up @@ -99,12 +172,6 @@ export async function buildQrSvg(options: {
: svg
}

export function downloadSvg(svg: string, filename: string) {
const blob = new Blob([svg], { type: "image/svg+xml" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
export function downloadSvg(svg: string, filename: string): BrowserDownloadResult {
return downloadBlob(new Blob([svg], { type: "image/svg+xml" }), filename)
}
2 changes: 1 addition & 1 deletion src/features/tools/qr-code-generator/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const BUTTON_SIZE_CLASS = {
} as const

export const MAX_LOGO_SIZE = 2 * 1024 * 1024
export const DEFAULT_QR_TEXT = "https://example.com/r/42"
export const DEFAULT_QR_TEXT = ""
export const SAMPLE_QR_TEXT = "https://example.com/qr?id=42"

export const PRESETS: QrPreset[] = [
Expand Down
46 changes: 33 additions & 13 deletions src/features/tools/qr-code-generator/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { ToolActionBar, type ToolAction } from "@/features/tool-shell/tool-action-bar"
import { ToolActionBar, type ToolAction, type ToolActionResult } from "@/features/tool-shell/tool-action-bar"
import { ToolPreviewArea } from "@/features/tool-shell/tool-preview-area"
import { RelatedTools } from "@/core/seo/components/related-tools"
import { safeClipboardWrite } from "@/core/clipboard/clipboard"
import {
buildQrSvg,
downloadDataUrl,
downloadCanvasPng,
downloadSvg as downloadSvgFile,
drawRoundedRect,
loadImage,
Expand Down Expand Up @@ -89,7 +89,10 @@ export function QrCodeGeneratorPage() {
}

const canvas = canvasRef.current
if (!canvas) return
if (!canvas) {
setDataUrl("")
return
}

const qrCode = await loadQRCode()

Expand Down Expand Up @@ -175,14 +178,24 @@ export function QrCodeGeneratorPage() {
setLogoEnabled(false)
}

const downloadPng = () => {
if (!dataUrl) return
downloadDataUrl(dataUrl, "qr-code.png")
void notifySuccess(textFor("downloaded_png"))
const downloadPng = async (): Promise<ToolActionResult> => {
const canvas = canvasRef.current
if (!canvas || !text.trim()) {
return { status: "failed", message: textFor("download_error") }
}

const result = await downloadCanvasPng(canvas, "qr-code.png")
if (!result.ok) {
await notifyError(textFor("download_error"))
return { status: "failed", message: textFor("download_error"), description: result.error }
}

await notifySuccess(textFor("downloaded_png"))
return { status: "success", message: textFor("downloaded_png") }
}

const downloadSvg = async () => {
if (!text.trim()) return
const downloadSvg = async (): Promise<ToolActionResult> => {
if (!text.trim()) return { status: "failed", message: textFor("download_error") }

try {
const finalSvg = await buildQrSvg({
Expand All @@ -196,10 +209,16 @@ export function QrCodeGeneratorPage() {
logoEnabled,
logoScale,
})
downloadSvgFile(finalSvg, "qr-code.svg")
const result = downloadSvgFile(finalSvg, "qr-code.svg")
if (!result.ok) {
await notifyError(textFor("download_error"))
return { status: "failed", message: textFor("download_error"), description: result.error }
}
await notifySuccess(textFor("downloaded_svg"))
return { status: "success", message: textFor("downloaded_svg") }
} catch {
await notifyError(textFor("download_error"))
return { status: "failed", message: textFor("download_error") }
}
}

Expand Down Expand Up @@ -239,14 +258,14 @@ export function QrCodeGeneratorPage() {
onClick: handleSample,
},
{
id: "png",
id: "download_png",
label: "PNG",
icon: Download,
onClick: downloadPng,
disabled: !dataUrl,
},
{
id: "svg",
id: "download_svg",
label: "SVG",
icon: Download,
onClick: downloadSvg,
Expand Down Expand Up @@ -296,9 +315,10 @@ export function QrCodeGeneratorPage() {
<textarea
value={text}
onChange={(event) => setText(event.target.value)}
className="h-24 w-full resize-none rounded-md border bg-background px-3 py-2 font-mono text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="min-h-[9rem] w-full resize-y rounded-md border bg-background px-3 py-2 font-mono text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder={textFor("placeholder")}
/>
<p className="text-xs leading-5 text-muted-foreground">{text.trim() ? textFor("prompt") : textFor("placeholder")}</p>
</div>

<div className="space-y-2">
Expand Down
33 changes: 33 additions & 0 deletions tests/guards/qr-empty-first-and-download-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import fs from "node:fs"
import path from "node:path"
import { describe, expect, it } from "vitest"

function readRepoFile(filePath: string) {
return fs.readFileSync(path.join(process.cwd(), filePath), "utf8")
}

describe("QR generator UX regression guard", () => {
it("keeps QR content empty-first and sample-driven", () => {
const page = readRepoFile("src/features/tools/qr-code-generator/page.tsx")
const constants = readRepoFile("src/features/tools/qr-code-generator/constants.ts")

expect(constants).toContain('export const DEFAULT_QR_TEXT = ""')
expect(constants).toContain('export const SAMPLE_QR_TEXT = "https://example.com/qr?id=42"')
expect(page).toContain("React.useState(DEFAULT_QR_TEXT)")
expect(page).toContain("setText(SAMPLE_QR_TEXT)")
expect(page).not.toContain('React.useState("https://example.com')
expect(page).not.toContain("setText(\"https://example.com/r/42\")")
})

it("uses robust browser download helpers for QR exports", () => {
const page = readRepoFile("src/features/tools/qr-code-generator/page.tsx")
const browserActions = readRepoFile("src/features/tools/qr-code-generator/browser-actions.ts")

expect(page).toContain("downloadCanvasPng")
expect(page).not.toContain("downloadDataUrl(dataUrl, \"qr-code.png\")")
expect(browserActions).toContain("document.body.appendChild(anchor)")
expect(browserActions).toContain("anchor.remove()")
expect(browserActions).toContain("window.setTimeout(() => URL.revokeObjectURL(url), OBJECT_URL_REVOKE_DELAY_MS)")
expect(browserActions).not.toContain("a.click()\n URL.revokeObjectURL(url)")
})
})
88 changes: 88 additions & 0 deletions tests/unit/qr-code-generator-browser-actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { downloadBlob, downloadDataUrl, downloadSvg } from "@/features/tools/qr-code-generator/browser-actions"

const ORIGINAL_CREATE_OBJECT_URL_DESCRIPTOR = Object.getOwnPropertyDescriptor(URL, "createObjectURL")
const ORIGINAL_REVOKE_OBJECT_URL_DESCRIPTOR = Object.getOwnPropertyDescriptor(URL, "revokeObjectURL")

function restoreUrlProperty(name: "createObjectURL" | "revokeObjectURL", descriptor: PropertyDescriptor | undefined) {
if (descriptor) {
Object.defineProperty(URL, name, descriptor)
return
}

delete (URL as unknown as Record<string, unknown>)[name]
}

function restoreObjectUrlApi() {
restoreUrlProperty("createObjectURL", ORIGINAL_CREATE_OBJECT_URL_DESCRIPTOR)
restoreUrlProperty("revokeObjectURL", ORIGINAL_REVOKE_OBJECT_URL_DESCRIPTOR)
}

function stubObjectUrlApi() {
const createObjectURL = vi.fn(() => "blob:byteflow-test")
const revokeObjectURL = vi.fn()
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: createObjectURL,
})
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: revokeObjectURL,
})
return { createObjectURL, revokeObjectURL }
}

describe("qr code generator browser exports", () => {
let clickCount = 0

beforeEach(() => {
clickCount = 0
document.body.innerHTML = ""
vi.useFakeTimers()
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {
clickCount += 1
})
})

afterEach(() => {
restoreObjectUrlApi()
vi.useRealTimers()
vi.restoreAllMocks()
document.body.innerHTML = ""
})

it("exports data URLs through a temporary attached anchor", () => {
const result = downloadDataUrl("data:image/png;base64,abc", "qr-code.png")

expect(result).toEqual({ ok: true })
expect(clickCount).toBe(1)
expect(document.body.querySelector("a")).toBeNull()
})

it("exports blobs and defers object URL revocation", () => {
const { createObjectURL, revokeObjectURL } = stubObjectUrlApi()

const result = downloadBlob(new Blob(["png"], { type: "image/png" }), "qr-code.png")

expect(result).toEqual({ ok: true })
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
expect(clickCount).toBe(1)
expect(revokeObjectURL).not.toHaveBeenCalled()

vi.advanceTimersByTime(1_000)
expect(revokeObjectURL).toHaveBeenCalledWith("blob:byteflow-test")
})

it("exports SVG through the same delayed-revoke path", () => {
const { revokeObjectURL } = stubObjectUrlApi()

const result = downloadSvg("<svg />", "qr-code.svg")

expect(result).toEqual({ ok: true })
expect(clickCount).toBe(1)
expect(revokeObjectURL).not.toHaveBeenCalled()

vi.advanceTimersByTime(1_000)
expect(revokeObjectURL).toHaveBeenCalledWith("blob:byteflow-test")
})
})
Loading