diff --git a/.changeset/full-donuts-help.md b/.changeset/full-donuts-help.md new file mode 100644 index 00000000..f97be5ad --- /dev/null +++ b/.changeset/full-donuts-help.md @@ -0,0 +1,8 @@ +--- +"streamdown": minor +--- + +- Add custom download filenames for code, table, and mermaid via the `controls` prop +- Configure downloads with `download: { filename: "customName" }` while keeping boolean `true`/`false` to show or hide +- Preserve automatic file-extension mapping based on language or export format +- Remove the `codeDownload` prop in favor of the unified `controls` API diff --git a/.gitignore b/.gitignore index d102c5ed..a0aec0b4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules .pnp .pnp.js +.pnpm-store/ # Local env files .env diff --git a/apps/website/content/docs/code-blocks.mdx b/apps/website/content/docs/code-blocks.mdx index cee82ba6..17bcfb61 100644 --- a/apps/website/content/docs/code-blocks.mdx +++ b/apps/website/content/docs/code-blocks.mdx @@ -236,6 +236,22 @@ Disable individual code block buttons using the `controls` prop: {markdown} ``` +### Custom Download Filename + +Pass `download: { filename }` to set a custom base name. Streamdown appends the language-appropriate extension automatically (for example `myScript.ts`). The default is `file.`. + +```tsx title="app/page.tsx" + + {markdown} + +``` + ## Inline Code Inline code uses backticks and receives subtle styling: diff --git a/apps/website/content/docs/configuration.mdx b/apps/website/content/docs/configuration.mdx index fa45b730..2539a1be 100644 --- a/apps/website/content/docs/configuration.mdx +++ b/apps/website/content/docs/configuration.mdx @@ -153,7 +153,8 @@ Math rendering and CJK support require installing separate plugins. See [Mathema type: "MermaidOptions", }, controls: { - description: "Control visibility of interactive buttons", + description: + "Control visibility of interactive buttons and custom download filenames for code, tables, and mermaid diagrams.", type: "ControlsConfig", default: "true", }, @@ -337,23 +338,23 @@ import { Streamdown, defaultUrlTransform } from 'streamdown'; }} /> -The `controls` prop can be configured granularly: +The `controls` prop can be configured granularly. Set a block type to `false` to hide all of its buttons, or pass an object to toggle individual actions. For downloads, pass `{ filename: "customName" }` to set a custom base filename — the file extension is added automatically. ```tsx title="app/page.tsx" ``` +You can still use `download: true` (or omit it) to keep the default filenames: `file.` for code, `table.csv` / `table.md` for tables, and `diagram.svg` / `diagram.png` / `diagram.mmd` for mermaid. + ### Remend Options The `remend` prop configures which Markdown completions are performed during streaming. All options default to `true` when not specified. Set an option to `false` to disable that completion: diff --git a/apps/website/content/docs/gfm.mdx b/apps/website/content/docs/gfm.mdx index b45df23e..291a61ad 100644 --- a/apps/website/content/docs/gfm.mdx +++ b/apps/website/content/docs/gfm.mdx @@ -118,6 +118,16 @@ You can disable the table download button: ``` +### Custom Download Filename + +By default, table downloads use `table.csv` and `table.md`. Set a custom base name with `download: { filename }`: + +```tsx + + {markdown} + +``` + ### Custom CSV Separator By default, copied and downloaded CSV uses a comma. Set `controls.table.csvSeparator` to `";"`, `"\t"`, or `"auto"` (picks `;` in comma-decimal locales): diff --git a/apps/website/content/docs/interactivity.mdx b/apps/website/content/docs/interactivity.mdx index 88d745ba..2baf5de5 100644 --- a/apps/website/content/docs/interactivity.mdx +++ b/apps/website/content/docs/interactivity.mdx @@ -39,7 +39,15 @@ Tables include a copy button that opens a dropdown menu allowing users to copy t ### Download Tables -Tables can be downloaded in two formats: CSV and Markdown. The download button will be shown for tables in the top-right corner on hover. The download button opens a dropdown menu with options to download as CSV or Markdown, making it easy to export table data for use in spreadsheets or documentation. +Tables can be downloaded in two formats: CSV and Markdown. The download button will be shown for tables in the top-right corner on hover. The download button opens a dropdown menu with options to download as CSV or Markdown, making it easy to export table data for use in spreadsheets or documentation. By default files are named `table.csv` and `table.md`. Customize the base name with `controls.table.download`: + +```tsx + + {markdown} + +``` + +This downloads `report.csv` or `report.md` depending on the format the user chooses. CSV copy and download use a comma by default. Customize the delimiter with `controls.table.csvSeparator` (`","`, `";"`, `"\t"`, or `"auto"`): @@ -57,7 +65,15 @@ Every code block includes a copy button that appears on hover. The copy button w ### Download Code -Code blocks also include a download button that appears on hover. The download button will be shown for code blocks in the top-right corner on hover. The download button will download the code with the appropriate file extension based on language. It will also use "file.[extension]" as the filename. It will preserve formatting and indentation. +Code blocks also include a download button that appears on hover. The download button will be shown for code blocks in the top-right corner on hover. The download button will download the code with the appropriate file extension based on language. It will also use "file.[extension]" as the filename by default. Customize the base name with `controls.code.download`: + +```tsx + + {markdown} + +``` + +A JavaScript block would download as `myScript.js`. It will preserve formatting and indentation. ## Mermaid Diagram Buttons @@ -67,7 +83,13 @@ Mermaid diagrams include a copy button that allows users to copy the diagram sou ### Download Diagrams -Mermaid diagrams can be downloaded as SVG files. The download button will be shown for Mermaid diagrams in the top-right corner on hover. The download button will download the rendered diagram as an SVG file. It will use "diagram.svg" as the default filename. +Mermaid diagrams can be downloaded as SVG, PNG, or Mermaid source (`.mmd`). The download button will be shown for Mermaid diagrams in the top-right corner on hover. By default files are named `diagram.svg`, `diagram.png`, and `diagram.mmd`. Customize the base name with `controls.mermaid.download`: + +```tsx + + {markdown} + +``` ### Pan and Zoom diff --git a/apps/website/content/docs/plugins/mermaid.mdx b/apps/website/content/docs/plugins/mermaid.mdx index e0d89f30..53bc13f7 100644 --- a/apps/website/content/docs/plugins/mermaid.mdx +++ b/apps/website/content/docs/plugins/mermaid.mdx @@ -469,7 +469,7 @@ Click the fullscreen button to view the diagram in an overlay with a dark backgr ### Download -Download the diagram as an SVG file for use in presentations or documentation. +Download the diagram as SVG, PNG, or Mermaid source. By default files are named `diagram.svg`, `diagram.png`, and `diagram.mmd`. Pass `download: { filename: "flowchart" }` to use a custom base name. ### Copy @@ -485,7 +485,7 @@ You can customize which controls are shown: controls={{ mermaid: { fullscreen: true, - download: true, + download: { filename: "flowchart" }, // Download as flowchart.svg / flowchart.png / flowchart.mmd copy: true, panZoom: true, // Enable pan and zoom controls }, diff --git a/packages/streamdown/__tests__/code-block-download.test.tsx b/packages/streamdown/__tests__/code-block-download.test.tsx index 74b2e860..c2736ddb 100644 --- a/packages/streamdown/__tests__/code-block-download.test.tsx +++ b/packages/streamdown/__tests__/code-block-download.test.tsx @@ -137,4 +137,191 @@ describe("CodeBlockDownloadButton", () => { ); expect(button?.hasAttribute("disabled")).toBe(true); }); + + it("should use custom filename from controls", async () => { + const { save } = await import("../lib/utils"); + + const { container } = render( + + + + + + ); + + await waitFor(() => { + const button = container.querySelector( + '[data-streamdown="code-block-download-button"]' + ); + expect(button?.hasAttribute("disabled")).toBe(false); + }); + + const button = container.querySelector( + '[data-streamdown="code-block-download-button"]' + ); + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(button!); + + expect(save).toHaveBeenCalledWith( + "myScript.js", + "console.log('test');", + "text/plain" + ); + }); + + it("should use custom filename with unknown language", async () => { + const { save } = await import("../lib/utils"); + + const { container } = render( + + + + + + ); + + await waitFor(() => { + const button = container.querySelector( + '[data-streamdown="code-block-download-button"]' + ); + expect(button?.hasAttribute("disabled")).toBe(false); + }); + + const button = container.querySelector( + '[data-streamdown="code-block-download-button"]' + ); + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(button!); + + expect(save).toHaveBeenCalledWith("output.txt", "some data", "text/plain"); + }); + + it("should fall back to default filename when controls is true", async () => { + const { save } = await import("../lib/utils"); + + const { container } = render( + + + + + + ); + + await waitFor(() => { + const button = container.querySelector( + '[data-streamdown="code-block-download-button"]' + ); + expect(button?.hasAttribute("disabled")).toBe(false); + }); + + const button = container.querySelector( + '[data-streamdown="code-block-download-button"]' + ); + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(button!); + + expect(save).toHaveBeenCalledWith("file.py", "python code", "text/plain"); + }); + + it("should fall back to default filename when download is enabled without a filename", async () => { + const { save } = await import("../lib/utils"); + + const { container } = render( + + + + + + ); + + await waitFor(() => { + const button = container.querySelector( + '[data-streamdown="code-block-download-button"]' + ); + expect(button?.hasAttribute("disabled")).toBe(false); + }); + + const button = container.querySelector( + '[data-streamdown="code-block-download-button"]' + ); + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(button!); + + expect(save).toHaveBeenCalledWith("file.rs", "rust code", "text/plain"); + }); + + it("should handle special characters in custom filename", async () => { + const { save } = await import("../lib/utils"); + + const { container } = render( + + + + + + ); + + await waitFor(() => { + const button = container.querySelector( + '[data-streamdown="code-block-download-button"]' + ); + expect(button?.hasAttribute("disabled")).toBe(false); + }); + + const button = container.querySelector( + '[data-streamdown="code-block-download-button"]' + ); + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(button!); + + expect(save).toHaveBeenCalledWith( + "my-config.backup.json", + "config data", + "text/plain" + ); + }); }); diff --git a/packages/streamdown/__tests__/controls.test.ts b/packages/streamdown/__tests__/controls.test.ts new file mode 100644 index 00000000..0211b4b6 --- /dev/null +++ b/packages/streamdown/__tests__/controls.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { getDownloadFilename } from "../lib/controls"; + +describe("getDownloadFilename", () => { + it("returns the fallback when controls is a boolean", () => { + expect(getDownloadFilename(true, "code", "file")).toBe("file"); + expect(getDownloadFilename(false, "table", "table")).toBe("table"); + }); + + it("returns the fallback when the block type is not configured", () => { + expect(getDownloadFilename({}, "code", "file")).toBe("file"); + expect(getDownloadFilename({ table: true }, "code", "file")).toBe("file"); + }); + + it("returns the fallback when the block type is a boolean", () => { + expect(getDownloadFilename({ mermaid: true }, "mermaid", "diagram")).toBe( + "diagram" + ); + expect(getDownloadFilename({ mermaid: false }, "mermaid", "diagram")).toBe( + "diagram" + ); + }); + + it("returns the fallback when download is a boolean", () => { + expect( + getDownloadFilename({ code: { download: true } }, "code", "file") + ).toBe("file"); + expect( + getDownloadFilename({ table: { download: false } }, "table", "table") + ).toBe("table"); + }); + + it("returns the custom filename when download is configured", () => { + expect( + getDownloadFilename( + { code: { download: { filename: "myScript" } } }, + "code", + "file" + ) + ).toBe("myScript"); + expect( + getDownloadFilename( + { table: { download: { filename: "report" } } }, + "table", + "table" + ) + ).toBe("report"); + expect( + getDownloadFilename( + { mermaid: { download: { filename: "flowchart" } } }, + "mermaid", + "diagram" + ) + ).toBe("flowchart"); + }); + + it("returns the fallback when filename is empty", () => { + expect( + getDownloadFilename( + { code: { download: { filename: "" } } }, + "code", + "file" + ) + ).toBe("file"); + }); +}); diff --git a/packages/streamdown/__tests__/mermaid-download.test.tsx b/packages/streamdown/__tests__/mermaid-download.test.tsx index 005f0b7c..8764203b 100644 --- a/packages/streamdown/__tests__/mermaid-download.test.tsx +++ b/packages/streamdown/__tests__/mermaid-download.test.tsx @@ -50,11 +50,12 @@ describe("MermaidDownloadDropdown", () => { const renderWithContext = ( props: any, - plugin: DiagramPlugin = createMockPlugin() + plugin: DiagramPlugin = createMockPlugin(), + context = defaultContext ) => { return render( - + @@ -324,4 +325,72 @@ describe("MermaidDownloadDropdown", () => { const button = container.querySelector("button"); expect(button?.hasAttribute("disabled")).toBe(true); }); + + it("should use custom filename from controls for mmd downloads", async () => { + const { save } = await import("../lib/utils"); + const onDownload = vi.fn(); + const { container } = renderWithContext( + { + chart: "graph TD; A-->B", + onDownload, + }, + createMockPlugin(), + { + ...defaultContext, + controls: { mermaid: { download: { filename: "flowchart" } } }, + } + ); + + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(container.querySelector("button")!); + + const mmdButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent === "MMD" + ); + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(mmdButton!); + + await waitFor(() => { + expect(save).toHaveBeenCalledWith( + "flowchart.mmd", + "graph TD; A-->B", + "text/plain" + ); + expect(onDownload).toHaveBeenCalledWith("mmd"); + }); + }); + + it("should use custom filename from controls for svg downloads", async () => { + const { save } = await import("../lib/utils"); + const onDownload = vi.fn(); + const { container } = renderWithContext( + { + chart: "graph TD; A-->B", + onDownload, + }, + createMockPlugin(), + { + ...defaultContext, + controls: { mermaid: { download: { filename: "flowchart" } } }, + } + ); + + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(container.querySelector("button")!); + + const svgButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent === "SVG" + ); + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(svgButton!); + + await waitFor(() => { + expect(save).toHaveBeenCalledWith( + "flowchart.svg", + expect.any(String), + "image/svg+xml" + ); + expect(onDownload).toHaveBeenCalledWith("svg"); + }); + }); }); diff --git a/packages/streamdown/__tests__/show-controls.test.tsx b/packages/streamdown/__tests__/show-controls.test.tsx index 8c3d285e..00146a34 100644 --- a/packages/streamdown/__tests__/show-controls.test.tsx +++ b/packages/streamdown/__tests__/show-controls.test.tsx @@ -351,6 +351,19 @@ graph TD expect(downloadBtn).toBeTruthy(); }); + it("should show download when table.download is a filename config", () => { + const { container } = render( + + {markdownWithTable} + + ); + + const downloadBtn = container.querySelector( + 'button[title="Download table"]' + ); + expect(downloadBtn).toBeTruthy(); + }); + it("should hide all table controls when no sub-controls are visible", () => { const { container } = render( { + const { container } = render( + + {markdownWithCode} + + ); + + await waitFor(() => { + const downloadBtn = container.querySelector( + 'button[title="Download file"]' + ); + expect(downloadBtn).toBeTruthy(); + }); + }); }); describe("image controls", () => { diff --git a/packages/streamdown/__tests__/table-dropdowns.test.tsx b/packages/streamdown/__tests__/table-dropdowns.test.tsx index cddb356a..251c9c4f 100644 --- a/packages/streamdown/__tests__/table-dropdowns.test.tsx +++ b/packages/streamdown/__tests__/table-dropdowns.test.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { StreamdownContext } from "../index"; +import { type ControlsConfig, StreamdownContext } from "../index"; import { TableCopyDropdown } from "../lib/table/copy-dropdown"; import { TableDownloadButton, @@ -15,12 +15,15 @@ vi.mock("../lib/utils", async () => { }; }); -const renderInTableWrapper = (ui: React.ReactElement) => { +const renderInTableWrapper = ( + ui: React.ReactElement, + controls: ControlsConfig = true +) => { return render( { expect(onDownload).toHaveBeenCalledWith("markdown"); }); + it("should use custom filename from controls", async () => { + const { save } = await import("../lib/utils"); + const onDownload = vi.fn(); + + const { container } = renderInTableWrapper( + , + { table: { download: { filename: "report" } } } + ); + + const toggleBtn = container.querySelector('button[title="Download table"]'); + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(toggleBtn!); + + const csvBtn = container.querySelector( + 'button[title="Download table as CSV"]' + ); + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(csvBtn!); + + expect(save).toHaveBeenCalledWith( + "report.csv", + expect.any(String), + "text/csv" + ); + expect(onDownload).toHaveBeenCalledWith("csv"); + }); + it("should use csvSeparator from controls for CSV downloads", async () => { const { save } = await import("../lib/utils"); const onDownload = vi.fn(); @@ -314,6 +344,29 @@ describe("TableDownloadButton with format='markdown'", () => { expect(onDownload).toHaveBeenCalled(); }); + it("should use custom filename from controls when filename prop is omitted", async () => { + const { save } = await import("../lib/utils"); + const onDownload = vi.fn(); + + const { container } = renderInTableWrapper( + , + { table: { download: { filename: "export" } } } + ); + + const btn = container.querySelector( + 'button[title="Download table as CSV"]' + ); + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(btn!); + + expect(save).toHaveBeenCalledWith( + "export.csv", + expect.any(String), + "text/csv" + ); + expect(onDownload).toHaveBeenCalled(); + }); + it("should use csvSeparator from controls for CSV button downloads", async () => { const { save } = await import("../lib/utils"); const onDownload = vi.fn(); diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index 9ce7df88..2c6bbe1b 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -137,6 +137,8 @@ export const normalizeHtmlIndentation = (content: string): string => { return content.replace(HTML_LINE_INDENT_PATTERN, "$1"); }; +export type DownloadControlConfig = boolean | { filename: string }; + export type ControlsConfig = | boolean | { @@ -145,19 +147,19 @@ export type ControlsConfig = | { copy?: boolean; csvSeparator?: CSVSeparator; - download?: boolean; + download?: DownloadControlConfig; fullscreen?: boolean; }; code?: | boolean | { copy?: boolean; - download?: boolean; + download?: DownloadControlConfig; }; mermaid?: | boolean | { - download?: boolean; + download?: DownloadControlConfig; copy?: boolean; fullscreen?: boolean; panZoom?: boolean; diff --git a/packages/streamdown/lib/code-block/download-button.tsx b/packages/streamdown/lib/code-block/download-button.tsx index 3edbca76..6c76f481 100644 --- a/packages/streamdown/lib/code-block/download-button.tsx +++ b/packages/streamdown/lib/code-block/download-button.tsx @@ -1,5 +1,6 @@ import { type ComponentProps, useContext } from "react"; import { StreamdownContext } from "../../index"; +import { getDownloadFilename } from "../controls"; import { useIcons } from "../icon-context"; import { useCn } from "../prefix-context"; import { useTranslations } from "../translations-context"; @@ -334,7 +335,7 @@ export const CodeBlockDownloadButton = ({ }) => { const cn = useCn(); const { code: contextCode } = useCodeBlockContext(); - const { isAnimating } = useContext(StreamdownContext); + const { isAnimating, controls } = useContext(StreamdownContext); const t = useTranslations(); const icons = useIcons(); const code = propCode ?? contextCode; @@ -342,7 +343,7 @@ export const CodeBlockDownloadButton = ({ language && language in languageExtensionMap ? languageExtensionMap[language] : "txt"; - const filename = `file.${extension}`; + const filename = `${getDownloadFilename(controls, "code", "file")}.${extension}`; const mimeType = "text/plain"; const downloadCode = () => { diff --git a/packages/streamdown/lib/controls.ts b/packages/streamdown/lib/controls.ts new file mode 100644 index 00000000..4918663a --- /dev/null +++ b/packages/streamdown/lib/controls.ts @@ -0,0 +1,23 @@ +import type { ControlsConfig } from "../index"; + +export const getDownloadFilename = ( + config: ControlsConfig, + type: "code" | "table" | "mermaid", + fallback: string +): string => { + if (typeof config === "boolean") { + return fallback; + } + + const typeConfig = config[type]; + if (typeof typeConfig !== "object") { + return fallback; + } + + const downloadConfig = typeConfig.download; + if (typeof downloadConfig !== "object") { + return fallback; + } + + return downloadConfig.filename || fallback; +}; diff --git a/packages/streamdown/lib/mermaid/download-button.tsx b/packages/streamdown/lib/mermaid/download-button.tsx index da6b446b..8c57b35e 100644 --- a/packages/streamdown/lib/mermaid/download-button.tsx +++ b/packages/streamdown/lib/mermaid/download-button.tsx @@ -1,5 +1,6 @@ import { useContext, useEffect, useRef, useState } from "react"; import { StreamdownContext } from "../../index"; +import { getDownloadFilename } from "../controls"; import { useIcons } from "../icon-context"; import { useMermaidPlugin } from "../plugin-context"; import type { MermaidConfig } from "../plugin-types"; @@ -28,16 +29,17 @@ export const MermaidDownloadDropdown = ({ const cn = useCn(); const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); - const { isAnimating } = useContext(StreamdownContext); + const { isAnimating, controls } = useContext(StreamdownContext); const icons = useIcons(); const mermaidPlugin = useMermaidPlugin(); const t = useTranslations(); + const baseFilename = getDownloadFilename(controls, "mermaid", "diagram"); const downloadMermaid = async (format: "mmd" | "png" | "svg") => { try { if (format === "mmd") { // Download as Mermaid source code - const filename = "diagram.mmd"; + const filename = `${baseFilename}.mmd`; const mimeType = "text/plain"; save(filename, chart, mimeType); setIsOpen(false); @@ -72,7 +74,7 @@ export const MermaidDownloadDropdown = ({ const serializedSvg = serializeSvgForDownload(svg); if (format === "svg") { - const filename = "diagram.svg"; + const filename = `${baseFilename}.svg`; const mimeType = "image/svg+xml"; save(filename, serializedSvg, mimeType); setIsOpen(false); @@ -82,7 +84,7 @@ export const MermaidDownloadDropdown = ({ if (format === "png") { const blob = await svgToPngBlob(serializedSvg); - save("diagram.png", blob, "image/png"); + save(`${baseFilename}.png`, blob, "image/png"); onDownload?.(format); setIsOpen(false); return; diff --git a/packages/streamdown/lib/table/download-dropdown.tsx b/packages/streamdown/lib/table/download-dropdown.tsx index 39e18ccd..ef187969 100644 --- a/packages/streamdown/lib/table/download-dropdown.tsx +++ b/packages/streamdown/lib/table/download-dropdown.tsx @@ -1,5 +1,6 @@ import { useContext, useEffect, useRef, useState } from "react"; import { StreamdownContext } from "../../index"; +import { getDownloadFilename } from "../controls"; import { useIcons } from "../icon-context"; import { useCn } from "../prefix-context"; import { useTranslations } from "../translations-context"; @@ -69,7 +70,11 @@ export const TableDownloadButton = ({ extension = "csv"; } - save(`${filename || "table"}.${extension}`, content, mimeType); + save( + `${filename || getDownloadFilename(controls, "table", "table")}.${extension}`, + content, + mimeType + ); onDownload?.(); } catch (error) { @@ -136,10 +141,10 @@ export const TableDownloadDropdown = ({ ? tableDataToCSV(tableData, csvSeparator) : tableDataToMarkdown(tableData); const extension = format === "csv" ? "csv" : "md"; - const filename = `table.${extension}`; + const downloadFilename = `${getDownloadFilename(controls, "table", "table")}.${extension}`; const mimeType = format === "csv" ? "text/csv" : "text/markdown"; - save(filename, content, mimeType); + save(downloadFilename, content, mimeType); setIsOpen(false); onDownload?.(format); } catch (error) { diff --git a/skills/streamdown/SKILL.md b/skills/streamdown/SKILL.md index 1875ccc5..70a2df54 100644 --- a/skills/streamdown/SKILL.md +++ b/skills/streamdown/SKILL.md @@ -110,7 +110,7 @@ export default function Chat() { | `isAnimating` | `boolean` | `false` | Streaming indicator | | `caret` | `"block" \| "circle"` | — | Cursor style | | `components` | `Components` | — | Custom element overrides | -| `controls` | `boolean \| object` | `true` | Interactive buttons | +| `controls` | `boolean \| object` | `true` | Interactive buttons; `download: { filename }` sets custom download names | | `linkSafety` | `LinkSafetyConfig` | `{ enabled: true }` | Link confirmation modal | | `shikiTheme` | `[light, dark]` | `['github-light', 'github-dark']` | Code themes | | `className` | `string` | — | Container class | diff --git a/skills/streamdown/references/api.md b/skills/streamdown/references/api.md index 99e2fbe1..b5a68063 100644 --- a/skills/streamdown/references/api.md +++ b/skills/streamdown/references/api.md @@ -147,21 +147,22 @@ interface RemendOptions { ## ControlsConfig ```tsx +type DownloadControlConfig = boolean | { filename: string }; type CSVSeparator = "," | ";" | "\t" | "auto"; type ControlsConfig = boolean | { table?: boolean | { copy?: boolean; - download?: boolean; + download?: DownloadControlConfig; fullscreen?: boolean; csvSeparator?: CSVSeparator; // default: "," }; code?: boolean | { copy?: boolean; - download?: boolean; + download?: DownloadControlConfig; }; mermaid?: boolean | { - download?: boolean; + download?: DownloadControlConfig; copy?: boolean; fullscreen?: boolean; panZoom?: boolean; @@ -169,6 +170,8 @@ type ControlsConfig = boolean | { }; ``` +Use `download: { filename: "customName" }` to set a custom base filename. The file extension is appended automatically (`file.js`, `table.csv`, `diagram.svg`, etc.). + ## LinkSafetyConfig ```tsx diff --git a/skills/streamdown/references/features.md b/skills/streamdown/references/features.md index 85f8576a..3e634f07 100644 --- a/skills/streamdown/references/features.md +++ b/skills/streamdown/references/features.md @@ -120,12 +120,15 @@ Auto-added buttons for images, tables, code, and Mermaid. controls={{ table: { copy: true, - download: true, + download: { filename: "report" }, // report.csv / report.md csvSeparator: "auto", // "," | ";" | "\t" | "auto" }, - code: false, // No copy/download on code blocks + code: { + copy: true, + download: { filename: "myScript" }, // myScript.js, myScript.py, etc. + }, mermaid: { - download: true, + download: { filename: "flowchart" }, // flowchart.svg / flowchart.png / flowchart.mmd copy: true, fullscreen: true, panZoom: false, @@ -136,9 +139,9 @@ Auto-added buttons for images, tables, code, and Mermaid. **Button types by element:** - **Images:** Download (auto-detected format, alt text as filename) -- **Tables:** Copy (CSV/TSV/HTML), Download (CSV/Markdown) -- **Code blocks:** Copy (raw code), Download (with correct extension) -- **Mermaid:** Copy (source), Download (SVG), Fullscreen, Pan/zoom +- **Tables:** Copy (CSV/TSV/HTML), Download (CSV/Markdown; default `table.`) +- **Code blocks:** Copy (raw code), Download (language extension; default `file.`) +- **Mermaid:** Copy (source), Download (SVG/PNG/MMD; default `diagram.`), Fullscreen, Pan/zoom All buttons disabled during streaming when `isAnimating={true}`. diff --git a/skills/streamdown/references/plugins.md b/skills/streamdown/references/plugins.md index 7d542a7e..6d19503f 100644 --- a/skills/streamdown/references/plugins.md +++ b/skills/streamdown/references/plugins.md @@ -126,12 +126,17 @@ const mermaid = createMermaidPlugin({ **Supported diagram types:** Flowcharts, sequence, state, class, pie, Gantt, ER, git graphs. -**Interactive controls:** Fullscreen, download SVG, copy source, pan/zoom. Customize via `controls` prop: +**Interactive controls:** Fullscreen, download SVG/PNG/MMD, copy source, pan/zoom. Customize via `controls` prop: ```tsx ```