diff --git a/app/globals.css b/app/globals.css index 669cff8..6d971dd 100644 --- a/app/globals.css +++ b/app/globals.css @@ -466,6 +466,25 @@ button:disabled { color: var(--accent-deep); transform: none; } +.file-menu-export-options { + margin: 0 10px 6px 20px; + padding-left: 6px; +} +.file-menu-export-options button { + font-size: 12px; + gap: 8px; + min-height: 30px; + padding: 6px 10px; +} +.file-menu-export-options button::before { + border: 1px solid var(--accent-deep); + border-radius: 50%; + content: ""; + flex: 0 0 12px; + height: 12px; + outline: 1px solid var(--accent-deep); + outline-offset: -4px; +} .file-menu-divider { border-top: 1px solid var(--line); margin: 5px 3px; diff --git a/components/export-tools.tsx b/components/export-tools.tsx index 11b6164..2b53e7b 100644 --- a/components/export-tools.tsx +++ b/components/export-tools.tsx @@ -61,7 +61,7 @@ function canvasBlob(canvas: HTMLCanvasElement) { }); } -function renderChart(document: ChartDocument) { +function renderChart(document: ChartDocument, showGrid: boolean) { const aspect = cellAspectRatio(document); const maximumChartDimension = 4000; const cellHeight = Math.min( @@ -129,7 +129,7 @@ function renderChart(document: ChartDocument) { } } - for (let column = 0; column <= document.width; column += 1) { + for (let column = 0; showGrid && column <= document.width; column += 1) { context.beginPath(); context.strokeStyle = column % 5 === 0 ? "rgba(28,31,29,.72)" : "rgba(28,31,29,.42)"; @@ -139,7 +139,7 @@ function renderChart(document: ChartDocument) { context.lineTo(x, top + chartHeight); context.stroke(); } - for (let row = 0; row <= document.height; row += 1) { + for (let row = 0; showGrid && row <= document.height; row += 1) { context.beginPath(); context.strokeStyle = row % 5 === 0 ? "rgba(28,31,29,.72)" : "rgba(28,31,29,.42)"; @@ -205,6 +205,7 @@ export function ExportTools({ const inputRef = useRef(null); const menuRef = useRef(null); const [open, setOpen] = useState(false); + const [choosingChartImage, setChoosingChartImage] = useState(false); const [message, setMessage] = useState(""); useEffect(() => { @@ -238,11 +239,11 @@ export function ExportTools({ setMessage("Editable KnitPlot file downloaded."); } - async function downloadChart() { + async function downloadChart(showGrid: boolean) { try { downloadBlob( - await canvasBlob(renderChart(document)), - fileName(document.name, "chart.png"), + await canvasBlob(renderChart(document, showGrid)), + fileName(document.name, showGrid ? "chart.png" : "chart-no-grid.png"), ); setMessage("Chart downloaded."); } catch { @@ -313,6 +314,7 @@ export function ExportTools({ aria-expanded={open} onClick={() => { setOpen((value) => !value); + setChoosingChartImage(false); setMessage(""); }} > @@ -384,9 +386,33 @@ export function ExportTools({ ) : null}

Export

- + {choosingChartImage ? ( +
+ + +
+ ) : null} diff --git a/e2e/editor.spec.ts b/e2e/editor.spec.ts index cdf7b86..c022378 100644 --- a/e2e/editor.spec.ts +++ b/e2e/editor.spec.ts @@ -38,6 +38,59 @@ test("missing Clerk configuration never grants private chart access", async ({ expect(response.status()).toBe(401); }); +test("chart image downloads offer grid lines on or off without changing the chart", async ({ + page, +}) => { + await page.goto("/"); + const cell = page.locator(".chart-svg > g:first-child > rect").first(); + const originalColor = await cell.getAttribute("fill"); + await page.getByRole("button", { name: "File", exact: true }).click(); + await page + .getByRole("menuitem", { name: "Download chart image", exact: true }) + .click(); + + const images: number[][] = []; + for (const showGrid of [true, false]) { + const downloadEvent = page.waitForEvent("download"); + await page + .getByRole("menuitem", { + name: showGrid ? "With grid" : "Without grid", + exact: true, + }) + .click(); + const download = await downloadEvent; + expect(download.suggestedFilename()).toMatch( + showGrid ? /\.chart\.png$/ : /\.chart-no-grid\.png$/, + ); + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream!) chunks.push(Buffer.from(chunk)); + images.push( + await page.evaluate(async (base64) => { + const image = new Image(); + image.src = `data:image/png;base64,${base64}`; + await image.decode(); + const canvas = document.createElement("canvas"); + canvas.width = image.width; + canvas.height = image.height; + const context = canvas.getContext("2d")!; + context.drawImage(image, 0, 0); + // Left edge of the chart, away from labels and horizontal grid lines. + return [ + image.width, + image.height, + ...context.getImageData(54, 80, 1, 1).data, + ...context.getImageData(60, 80, 1, 1).data, + ]; + }, Buffer.concat(chunks).toString("base64")), + ); + } + expect(images[0].slice(0, 2)).toEqual(images[1].slice(0, 2)); + expect(images[0].slice(2, 6)).not.toEqual(images[0].slice(6, 10)); + expect(images[1].slice(2, 6)).toEqual(images[1].slice(6, 10)); + await expect(cell).toHaveAttribute("fill", originalColor!); +}); + test("development samples are not production routes", async ({ request }) => { const response = await request.get("/stitch-samples/yarn"); expect(response.status()).toBe(404);