From 106942a3c3f7cf19c063ba35174bc78fe8894fc0 Mon Sep 17 00:00:00 2001 From: Dmitrii Troitskii Date: Thu, 23 Apr 2026 06:10:54 +0000 Subject: [PATCH] feat: add image controls option to disable hover overlay and download button --- .changeset/image-controls.md | 9 ++ packages/streamdown/__tests__/image.test.tsx | 73 ++++++++++ .../__tests__/show-controls.test.tsx | 134 +++++++++++++++++- packages/streamdown/index.tsx | 1 + packages/streamdown/lib/components.tsx | 53 ++++++- packages/streamdown/lib/image.tsx | 23 ++- 6 files changed, 279 insertions(+), 14 deletions(-) create mode 100644 .changeset/image-controls.md diff --git a/.changeset/image-controls.md b/.changeset/image-controls.md new file mode 100644 index 00000000..05ed0f35 --- /dev/null +++ b/.changeset/image-controls.md @@ -0,0 +1,9 @@ +--- +"streamdown": minor +--- + +feat: add image control options (`controls.image`) to disable hover overlay and download button on images + +Add `image` to the `controls` prop, matching existing `code`, `table`, and `mermaid` patterns: +- `controls={{ image: false }}` hides the hover overlay and download button +- `controls={{ image: { download: false } }}` keeps the hover overlay but hides the download button diff --git a/packages/streamdown/__tests__/image.test.tsx b/packages/streamdown/__tests__/image.test.tsx index 88931292..f134a9b4 100644 --- a/packages/streamdown/__tests__/image.test.tsx +++ b/packages/streamdown/__tests__/image.test.tsx @@ -414,3 +414,76 @@ describe("ImageComponent", () => { expect(img?.getAttribute("data-testid")).toBe("custom-image"); }); }); + +describe("ImageComponent control props", () => { + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => { + // Intentionally empty + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("showDownloadControl={false} hides download button after load", () => { + const { container } = render( + + ); + + const img = container.querySelector('img[data-streamdown="image"]'); + if (img) { + fireEvent.load(img); + } + + const button = container.querySelector('button[title="Download image"]'); + expect(button).toBeFalsy(); + }); + + it("showControls={false} hides both overlay and download button", () => { + const { container } = render( + + ); + + const img = container.querySelector('img[data-streamdown="image"]'); + if (img) { + fireEvent.load(img); + } + + const overlay = container.querySelector( + '[data-streamdown="image-overlay"]' + ); + const button = container.querySelector('button[title="Download image"]'); + expect(overlay).toBeFalsy(); + expect(button).toBeFalsy(); + }); + + it("showControls={true} (default) shows download button after load", () => { + const { container } = render( + + ); + + const img = container.querySelector('img[data-streamdown="image"]'); + if (img) { + fireEvent.load(img); + } + + const button = container.querySelector('button[title="Download image"]'); + expect(button).toBeTruthy(); + }); +}); diff --git a/packages/streamdown/__tests__/show-controls.test.tsx b/packages/streamdown/__tests__/show-controls.test.tsx index eda3fedc..8c3d285e 100644 --- a/packages/streamdown/__tests__/show-controls.test.tsx +++ b/packages/streamdown/__tests__/show-controls.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from "@testing-library/react"; +import { fireEvent, render, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { Streamdown } from "../index"; @@ -441,6 +441,138 @@ graph TD }); }); + describe("image controls", () => { + const markdownWithImage = "![alt text](https://example.com/image.png)"; + + it("controls={false} hides image overlay and download button", async () => { + const { container } = render( + {markdownWithImage} + ); + + const img = container.querySelector('[data-streamdown="image"]'); + if (img) { + fireEvent.load(img); + } + + await waitFor(() => { + const wrapper = container.querySelector( + '[data-streamdown="image-wrapper"]' + ); + const buttons = wrapper?.querySelectorAll("button"); + const overlay = wrapper?.querySelector( + '[data-streamdown="image-overlay"]' + ); + expect(buttons?.length).toBe(0); + expect(overlay).toBeFalsy(); + }); + }); + + it("controls={{ image: false }} hides image controls", async () => { + const { container } = render( + {markdownWithImage} + ); + + const img = container.querySelector('[data-streamdown="image"]'); + if (img) { + fireEvent.load(img); + } + + await waitFor(() => { + const wrapper = container.querySelector( + '[data-streamdown="image-wrapper"]' + ); + const buttons = wrapper?.querySelectorAll("button"); + const overlay = wrapper?.querySelector( + '[data-streamdown="image-overlay"]' + ); + expect(buttons?.length).toBe(0); + expect(overlay).toBeFalsy(); + }); + }); + + it("controls={{ image: true }} shows image download button after load", async () => { + const { container } = render( + {markdownWithImage} + ); + + const img = container.querySelector('[data-streamdown="image"]'); + if (img) { + fireEvent.load(img); + } + + await waitFor(() => { + const wrapper = container.querySelector( + '[data-streamdown="image-wrapper"]' + ); + const button = wrapper?.querySelector('button[title="Download image"]'); + expect(button).toBeTruthy(); + }); + }); + + it("controls={{ image: { download: false } }} hides only download button but keeps overlay", async () => { + const { container } = render( + + {markdownWithImage} + + ); + + const img = container.querySelector('[data-streamdown="image"]'); + if (img) { + fireEvent.load(img); + } + + await waitFor(() => { + const wrapper = container.querySelector( + '[data-streamdown="image-wrapper"]' + ); + const button = wrapper?.querySelector('button[title="Download image"]'); + const overlay = wrapper?.querySelector( + '[data-streamdown="image-overlay"]' + ); + expect(button).toBeFalsy(); + expect(overlay).toBeTruthy(); + }); + }); + + it("unspecified image key defaults to showing controls", async () => { + const { container } = render( + {markdownWithImage} + ); + + const img = container.querySelector('[data-streamdown="image"]'); + if (img) { + fireEvent.load(img); + } + + await waitFor(() => { + const wrapper = container.querySelector( + '[data-streamdown="image-wrapper"]' + ); + const button = wrapper?.querySelector('button[title="Download image"]'); + expect(button).toBeTruthy(); + }); + }); + + it("controls={true} shows image controls by default", async () => { + const { container } = render( + {markdownWithImage} + ); + + const img = container.querySelector('[data-streamdown="image"]'); + if (img) { + fireEvent.load(img); + } + + await waitFor(() => { + const wrapper = container.querySelector( + '[data-streamdown="image-wrapper"]' + ); + const button = wrapper?.querySelector('button[title="Download image"]'); + expect(button).toBeTruthy(); + }); + }); + }); + describe("with custom components", () => { it("should respect controls with custom component overrides", () => { const CustomParagraph = ({ children }: any) => ( diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index be32d9fd..b4dba45f 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -156,6 +156,7 @@ export type ControlsConfig = fullscreen?: boolean; panZoom?: boolean; }; + image?: boolean | { download?: boolean }; }; export interface LinkSafetyModalProps { diff --git a/packages/streamdown/lib/components.tsx b/packages/streamdown/lib/components.tsx index 2449ca3a..cb6eb67d 100644 --- a/packages/streamdown/lib/components.tsx +++ b/packages/streamdown/lib/components.tsx @@ -91,7 +91,7 @@ function sameClassAndNode( const shouldShowControls = ( config: ControlsConfig, - type: "table" | "code" | "mermaid" + type: "table" | "code" | "mermaid" | "image" ) => { if (typeof config === "boolean") { return config; @@ -163,6 +163,27 @@ const shouldShowMermaidControl = ( return mermaidConfig[controlType] !== false; }; +const shouldShowImageControl = ( + config: ControlsConfig, + controlType: "download" +): boolean => { + if (typeof config === "boolean") { + return config; + } + + const imageConfig = config.image; + + if (imageConfig === false) { + return false; + } + + if (imageConfig === true || imageConfig === undefined) { + return true; + } + + return imageConfig[controlType] !== false; +}; + type OlProps = WithNode; const MemoOl = memo( ({ children, className, node, ...props }: OlProps) => { @@ -964,11 +985,31 @@ const MemoCode = memo< ); MemoCode.displayName = "MarkdownCode"; -const MemoImg = memo< - DetailedHTMLProps, HTMLImageElement> & - ExtraProps ->( - ImageComponent, +type ImgProps = DetailedHTMLProps< + ImgHTMLAttributes, + HTMLImageElement +> & + ExtraProps; + +const ImageWrapper = ({ node, className, ...props }: ImgProps) => { + const { controls: controlsConfig } = useContext(StreamdownContext); + const showImageControls = shouldShowControls(controlsConfig, "image"); + const showDownloadControl = + showImageControls && shouldShowImageControl(controlsConfig, "download"); + + return ( + + ); +}; + +const MemoImg = memo( + ImageWrapper, (p, n) => p.className === n.className && sameNodePosition(p.node, n.node) ); diff --git a/packages/streamdown/lib/image.tsx b/packages/streamdown/lib/image.tsx index b6290772..b7dd16ac 100644 --- a/packages/streamdown/lib/image.tsx +++ b/packages/streamdown/lib/image.tsx @@ -12,7 +12,10 @@ type ImageComponentProps = DetailedHTMLProps< ImgHTMLAttributes, HTMLImageElement > & - ExtraProps; + ExtraProps & { + showControls?: boolean; + showDownloadControl?: boolean; + }; export const ImageComponent = ({ node, @@ -21,6 +24,8 @@ export const ImageComponent = ({ alt, onLoad: onLoadProp, onError: onErrorProp, + showControls = true, + showDownloadControl = true, ...props }: ImageComponentProps) => { const { DownloadIcon } = useIcons(); @@ -31,7 +36,8 @@ export const ImageComponent = ({ const t = useTranslations(); const hasExplicitDimensions = props.width != null || props.height != null; - const showDownload = (imageLoaded || hasExplicitDimensions) && !imageError; + const canDownload = (imageLoaded || hasExplicitDimensions) && !imageError; + const showDownload = canDownload && showControls && showDownloadControl; const showFallback = imageError && !hasExplicitDimensions; // Handle images already complete before React attaches event handlers (e.g. cached or SSR hydration) @@ -148,11 +154,14 @@ export const ImageComponent = ({ {t.imageNotAvailable} )} -