Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/image-controls.md
Original file line number Diff line number Diff line change
@@ -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
73 changes: 73 additions & 0 deletions packages/streamdown/__tests__/image.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ImageComponent
alt="Test"
node={null as any}
showDownloadControl={false}
src="https://example.com/image.png"
/>
);

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(
<ImageComponent
alt="Test"
node={null as any}
showControls={false}
src="https://example.com/image.png"
/>
);

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(
<ImageComponent
alt="Test"
node={null as any}
showControls={true}
src="https://example.com/image.png"
/>
);

const img = container.querySelector('img[data-streamdown="image"]');
if (img) {
fireEvent.load(img);
}

const button = container.querySelector('button[title="Download image"]');
expect(button).toBeTruthy();
});
});
134 changes: 133 additions & 1 deletion packages/streamdown/__tests__/show-controls.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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(
<Streamdown controls={false}>{markdownWithImage}</Streamdown>
);

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(
<Streamdown controls={{ image: false }}>{markdownWithImage}</Streamdown>
);

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(
<Streamdown controls={{ image: true }}>{markdownWithImage}</Streamdown>
);

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(
<Streamdown controls={{ image: { download: false } }}>
{markdownWithImage}
</Streamdown>
);

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(
<Streamdown controls={{ code: false }}>{markdownWithImage}</Streamdown>
);

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(
<Streamdown controls={true}>{markdownWithImage}</Streamdown>
);

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) => (
Expand Down
1 change: 1 addition & 0 deletions packages/streamdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export type ControlsConfig =
fullscreen?: boolean;
panZoom?: boolean;
};
image?: boolean | { download?: boolean };
};

export interface LinkSafetyModalProps {
Expand Down
53 changes: 47 additions & 6 deletions packages/streamdown/lib/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<JSX.IntrinsicElements["ol"]>;
const MemoOl = memo<OlProps>(
({ children, className, node, ...props }: OlProps) => {
Expand Down Expand Up @@ -964,11 +985,31 @@ const MemoCode = memo<
);
MemoCode.displayName = "MarkdownCode";

const MemoImg = memo<
DetailedHTMLProps<ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement> &
ExtraProps
>(
ImageComponent,
type ImgProps = DetailedHTMLProps<
ImgHTMLAttributes<HTMLImageElement>,
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 (
<ImageComponent
className={className}
node={node}
showControls={showImageControls}
showDownloadControl={showDownloadControl}
{...props}
/>
);
};

const MemoImg = memo<ImgProps>(
ImageWrapper,
(p, n) => p.className === n.className && sameNodePosition(p.node, n.node)
);

Expand Down
23 changes: 16 additions & 7 deletions packages/streamdown/lib/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ type ImageComponentProps = DetailedHTMLProps<
ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement
> &
ExtraProps;
ExtraProps & {
showControls?: boolean;
showDownloadControl?: boolean;
};

export const ImageComponent = ({
node,
Expand All @@ -21,6 +24,8 @@ export const ImageComponent = ({
alt,
onLoad: onLoadProp,
onError: onErrorProp,
showControls = true,
showDownloadControl = true,
...props
}: ImageComponentProps) => {
const { DownloadIcon } = useIcons();
Expand All @@ -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)
Expand Down Expand Up @@ -148,11 +154,14 @@ export const ImageComponent = ({
{t.imageNotAvailable}
</span>
)}
<div
className={cn(
"pointer-events-none absolute inset-0 hidden rounded-lg bg-black/10 group-hover:block"
)}
/>
{showControls && (
<div
className={cn(
"pointer-events-none absolute inset-0 hidden rounded-lg bg-black/10 group-hover:block"
)}
data-streamdown="image-overlay"
/>
)}
{showDownload && (
<button
className={cn(
Expand Down
Loading