Skip to content

Commit 973f583

Browse files
authored
Merge pull request #11 from TibetOS/claude/export-options
feat: export format, scale presets, and copy-to-clipboard
2 parents 31ab9ae + 9b76898 commit 973f583

4 files changed

Lines changed: 199 additions & 28 deletions

File tree

src/App.test.tsx

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"
22
import { describe, it, expect, vi } from "vitest"
33
import { App } from "./App"
44

5+
// jsdom cannot rasterize DOM nodes; return a stub canvas so export flows
6+
// can be exercised end-to-end.
7+
vi.mock("html-to-image", () => ({
8+
toCanvas: vi.fn(async () => ({
9+
toDataURL: () => "data:image/png;base64,AAAA",
10+
toBlob: (cb: (b: Blob | null) => void) =>
11+
cb(new Blob(["x"], { type: "image/png" })),
12+
})),
13+
}))
14+
515
describe("App", () => {
616
it("renders the app title", () => {
717
render(<App />)
@@ -170,8 +180,7 @@ describe("reactions & replies", () => {
170180

171181
it("adds a reply that renders the quoted message inside the bubble", () => {
172182
render(<App />)
173-
// Default sender is the contact, so the only combobox is "Reply to".
174-
const replySelect = screen.getByRole("combobox")
183+
const replySelect = screen.getByLabelText("Reply to message")
175184
const options = replySelect.querySelectorAll("option")
176185
// options[0] is "None"; options[1] is the first quotable message.
177186
fireEvent.change(replySelect, { target: { value: options[1].value } })
@@ -216,7 +225,9 @@ describe("reactions & replies", () => {
216225
fireEvent.click(screen.getByRole("button", { name: "System" }))
217226

218227
expect(screen.queryByPlaceholderText("👍 ❤️ 😂")).not.toBeInTheDocument()
219-
expect(screen.queryByRole("combobox")).not.toBeInTheDocument()
228+
expect(
229+
screen.queryByLabelText("Reply to message"),
230+
).not.toBeInTheDocument()
220231
})
221232
})
222233

@@ -293,6 +304,54 @@ describe("media messages", () => {
293304
})
294305
})
295306

307+
describe("export options", () => {
308+
it("downloads at the selected format and scale", async () => {
309+
const { toCanvas } = await import("html-to-image")
310+
const clickSpy = vi
311+
.spyOn(HTMLAnchorElement.prototype, "click")
312+
.mockImplementation(() => {})
313+
render(<App />)
314+
315+
fireEvent.change(screen.getByLabelText("Export format"), {
316+
target: { value: "webp" },
317+
})
318+
fireEvent.change(screen.getByLabelText("Export scale"), {
319+
target: { value: "3" },
320+
})
321+
fireEvent.click(screen.getByRole("button", { name: "Download WEBP" }))
322+
323+
await waitFor(() => expect(clickSpy).toHaveBeenCalled())
324+
expect(toCanvas).toHaveBeenCalledWith(
325+
expect.anything(),
326+
expect.objectContaining({ pixelRatio: 3 }),
327+
)
328+
clickSpy.mockRestore()
329+
})
330+
331+
it("copies a PNG to the clipboard", async () => {
332+
const write = vi.fn(async () => {})
333+
vi.stubGlobal("navigator", { ...navigator, clipboard: { write } })
334+
vi.stubGlobal(
335+
"ClipboardItem",
336+
class {
337+
items: Record<string, Blob>
338+
constructor(items: Record<string, Blob>) {
339+
this.items = items
340+
}
341+
},
342+
)
343+
try {
344+
render(<App />)
345+
346+
fireEvent.click(screen.getByRole("button", { name: "Copy" }))
347+
348+
await waitFor(() => expect(write).toHaveBeenCalled())
349+
} finally {
350+
vi.unstubAllGlobals()
351+
}
352+
})
353+
})
354+
296355
describe("voice notes", () => {
297356
it("adds a voice note without text and renders duration in the bubble", () => {
298357
render(<App />)

src/App.tsx

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useRef, useState, useCallback } from "react"
2-
import { toPng } from "html-to-image"
3-
import type { Message, ChatConfig } from "./types"
2+
import { toCanvas } from "html-to-image"
3+
import type { Message, ChatConfig, ExportOptions } from "./types"
44
import { ChatPreview } from "./components/ChatPreview"
55
import { ControlPanel } from "./components/ControlPanel"
66

@@ -107,21 +107,57 @@ function App() {
107107
[],
108108
)
109109

110-
const handleExport = useCallback(async () => {
111-
if (!phoneRef.current) return
112-
try {
113-
const dataUrl = await toPng(phoneRef.current, {
114-
pixelRatio: 2,
115-
cacheBust: true,
116-
})
117-
const link = document.createElement("a")
118-
link.download = "whatsapp-chat.png"
119-
link.href = dataUrl
120-
link.click()
121-
} catch (err) {
122-
console.error("Export failed:", err)
123-
}
124-
}, [])
110+
const handleExport = useCallback(
111+
async ({ mode, format, scale }: ExportOptions) => {
112+
if (!phoneRef.current) return
113+
try {
114+
const canvas = await toCanvas(phoneRef.current, {
115+
pixelRatio: scale,
116+
cacheBust: true,
117+
})
118+
119+
if (mode === "copy") {
120+
// Clipboard API needs a secure context (HTTPS) and a modern browser.
121+
if (!navigator.clipboard || typeof ClipboardItem === "undefined") {
122+
throw new Error(
123+
"Copying images requires a modern browser and HTTPS.",
124+
)
125+
}
126+
// Clipboard images are PNG-only across browsers.
127+
const blob = await new Promise<Blob | null>((resolve) =>
128+
canvas.toBlob(resolve, "image/png"),
129+
)
130+
if (!blob) throw new Error("Could not create image blob")
131+
await navigator.clipboard.write([
132+
new ClipboardItem({ "image/png": blob }),
133+
])
134+
return
135+
}
136+
137+
const mime =
138+
format === "png"
139+
? "image/png"
140+
: format === "jpeg"
141+
? "image/jpeg"
142+
: "image/webp"
143+
const link = document.createElement("a")
144+
link.download = `whatsapp-chat.${format === "jpeg" ? "jpg" : format}`
145+
link.href = canvas.toDataURL(mime, 0.95)
146+
// Firefox ignores clicks on anchors that aren't in the document.
147+
document.body.appendChild(link)
148+
link.click()
149+
link.remove()
150+
} catch (err) {
151+
console.error("Export failed:", err)
152+
alert(
153+
err instanceof Error && err.message
154+
? err.message
155+
: "Export failed. Please try again.",
156+
)
157+
}
158+
},
159+
[],
160+
)
125161

126162
return (
127163
<div className="min-h-screen bg-gray-100 py-6 px-4">

src/components/ControlPanel.tsx

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import type {
66
MessageStatus,
77
ChatConfig,
88
PhoneType,
9+
ExportFormat,
10+
ExportOptions,
911
} from "../types"
1012

1113
type ControlPanelProps = {
@@ -16,7 +18,7 @@ type ControlPanelProps = {
1618
onDeleteMessage: (id: string) => void
1719
onMoveMessage: (id: string, direction: "up" | "down") => void
1820
onUpdateConfig: (config: ChatConfig) => void
19-
onExport: () => void
21+
onExport: (options: ExportOptions) => void
2022
}
2123

2224
export function ControlPanel({
@@ -39,6 +41,8 @@ export function ControlPanel({
3941
const [image, setImage] = useState("")
4042
const [voiceDuration, setVoiceDuration] = useState("")
4143
const [editingId, setEditingId] = useState<string | null>(null)
44+
const [exportFormat, setExportFormat] = useState<ExportFormat>("png")
45+
const [exportScale, setExportScale] = useState(2)
4246
const fileInputRef = useRef<HTMLInputElement>(null)
4347

4448
const isSystem = sender === "system"
@@ -137,13 +141,75 @@ export function ControlPanel({
137141

138142
return (
139143
<div className="flex flex-col gap-4 w-full max-w-md">
140-
{/* Export button */}
141-
<button
142-
onClick={onExport}
143-
className="w-full py-2.5 bg-[#008069] hover:bg-[#006b57] text-white rounded-lg font-medium transition-colors cursor-pointer"
144-
>
145-
Export as PNG
146-
</button>
144+
{/* Export */}
145+
<div className="bg-white rounded-xl p-4 shadow-sm flex flex-col gap-3">
146+
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide">
147+
Export
148+
</h3>
149+
<div className="flex gap-3">
150+
<div className="flex-1">
151+
<label
152+
htmlFor="export-format"
153+
className="block text-sm text-gray-600 mb-1"
154+
>
155+
Format
156+
</label>
157+
<select
158+
id="export-format"
159+
aria-label="Export format"
160+
value={exportFormat}
161+
onChange={(e) => setExportFormat(e.target.value as ExportFormat)}
162+
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#008069]/30 focus:border-[#008069] bg-white"
163+
>
164+
<option value="png">PNG</option>
165+
<option value="jpeg">JPEG</option>
166+
<option value="webp">WebP</option>
167+
</select>
168+
</div>
169+
<div className="flex-1">
170+
<label
171+
htmlFor="export-scale"
172+
className="block text-sm text-gray-600 mb-1"
173+
>
174+
Scale
175+
</label>
176+
<select
177+
id="export-scale"
178+
aria-label="Export scale"
179+
value={exportScale}
180+
onChange={(e) => setExportScale(Number(e.target.value))}
181+
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#008069]/30 focus:border-[#008069] bg-white"
182+
>
183+
<option value={1}>1× (375px)</option>
184+
<option value={2}>2× (750px)</option>
185+
<option value={3}>3× (1125px)</option>
186+
</select>
187+
</div>
188+
</div>
189+
<div className="flex gap-2">
190+
<button
191+
onClick={() =>
192+
onExport({
193+
mode: "download",
194+
format: exportFormat,
195+
scale: exportScale,
196+
})
197+
}
198+
className="flex-1 py-2.5 bg-[#008069] hover:bg-[#006b57] text-white rounded-lg text-sm font-medium transition-colors cursor-pointer"
199+
>
200+
Download {exportFormat.toUpperCase()}
201+
</button>
202+
<button
203+
onClick={() =>
204+
onExport({ mode: "copy", format: "png", scale: exportScale })
205+
}
206+
className="px-4 py-2.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg text-sm font-medium transition-colors cursor-pointer"
207+
title="Copy PNG to clipboard"
208+
>
209+
Copy
210+
</button>
211+
</div>
212+
</div>
147213

148214
{/* Chat Settings */}
149215
<div className="bg-white rounded-xl p-4 shadow-sm">
@@ -355,6 +421,7 @@ export function ControlPanel({
355421
<span className="text-gray-400 font-normal">(optional)</span>
356422
</label>
357423
<select
424+
aria-label="Reply to message"
358425
value={replyTo}
359426
onChange={(e) => setReplyTo(e.target.value)}
360427
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#008069]/30 focus:border-[#008069] bg-white"

src/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,12 @@ export type ChatConfig = {
3737
// Renders a typing-dots bubble at the end of the conversation.
3838
showTyping: boolean
3939
}
40+
41+
export type ExportFormat = "png" | "jpeg" | "webp"
42+
43+
export type ExportOptions = {
44+
mode: "download" | "copy"
45+
format: ExportFormat
46+
// Device-pixel multiplier for the rendered 375px-wide frame.
47+
scale: number
48+
}

0 commit comments

Comments
 (0)