Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/salty-facts-drum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus-editor": minor
---

Content creators can now exempt SVG images from being inverted in dark mode, in addition to PNGs.
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {describe, it, expect} from "@jest/globals";

import {isGraphicalImage} from "./dark-mode-toggle";

describe("isGraphicalImage", () => {
it("is true for a PNG", () => {
expect(isGraphicalImage("https://example.com/foo.png")).toBe(true);
});

it("is true for an SVG", () => {
expect(isGraphicalImage("https://example.com/foo.svg")).toBe(true);
});

it("is false for a JPEG", () => {
expect(isGraphicalImage("https://example.com/foo.jpg")).toBe(false);
});

it("is false for null", () => {
expect(isGraphicalImage(null)).toBe(false);
});

it("is false for undefined", () => {
expect(isGraphicalImage(undefined)).toBe(false);
});

it("is false for a non-url string", () => {
expect(isGraphicalImage("!foo")).toBe(false);
});

it("is true for a graphical image URL with a query param", () => {
expect(isGraphicalImage("https://example.com/foo.png?q=1")).toBe(true);
});

it("is false for a non-graphical image URL with a query param containing a graphical filename", () => {
// This test forces us to actually parse the URL, not just pattern-match
// on the string.
expect(isGraphicalImage("https://example.com/foo.jpg?q=bar.png")).toBe(
false,
);
});

it("is false for an image with an unrecognized extension starting with .png", () => {
expect(isGraphicalImage("https://example.com/foo.pngqxz")).toBe(false);
});

it("is false for an image with another extension after .png", () => {
expect(isGraphicalImage("https://example.com/foo.png.qxz")).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,6 @@ export default function DarkModeToggle({
})();
const suppressFilter = imageUrl?.searchParams.has("dark-mode") ?? false;

// Determine if the image is a PNG, regardless of any possible query string.
const imageIsPng = /\.png(\?.*)?$/.test(backgroundImage.url ?? "");

const toggleDarkMode = () => {
onShowToggle(showDarkMode ? undefined : "syl-dark");
setShowDarkMode(!showDarkMode);
Expand Down Expand Up @@ -83,21 +80,40 @@ export default function DarkModeToggle({
<LabeledSwitch
label="Suppress Dark Mode Filter"
checked={suppressFilter}
disabled={editingDisabled || !imageIsPng}
disabled={
editingDisabled ||
!isGraphicalImage(backgroundImage.url)
}
onChange={toggleSuppressFilter}
/>
<InfoTip>
When the color in the image is important (like in an image
of a flag), you can suppress the filter that is used to make
images compatible with dark mode.
{!imageIsPng && (
{!isGraphicalImage(backgroundImage.url) && (
<strong>
{" "}
This option is only available for PNG images!
This option is only available for PNG and SVG
images. Other types of images (e.g. JPG, GIF) never
change in dark mode.
</strong>
)}
</InfoTip>
</div>
</div>
);
}

/**
* Determines whether an image is a "graphical" type (PNG or SVG) with large
* flat areas of meaningful color and potentially a transparent background.
*/
// exported for testing
export function isGraphicalImage(url: string | null | undefined): boolean {
try {
const {pathname} = new URL(url ?? "");
return /\.(png|svg)$/.test(pathname);
} catch {
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1174,7 +1174,7 @@ describe("image editor", () => {
).toHaveAttribute("aria-disabled", "true");
});

it("tooltip shows note about image format for non-PNG images", async () => {
it("tooltip shows note about supported image formats", async () => {
// Arrange
render(
<ImageEditorWithDependencies
Expand All @@ -1197,10 +1197,9 @@ describe("image editor", () => {
// Assert
const tooltip = screen.getByRole("tooltip");
expect(
within(tooltip).getByText(
"This option is only available for PNG images!",
{exact: false},
),
within(tooltip).getByText("This option is only available for", {
exact: false,
}),
).toBeInTheDocument();
});

Expand Down
5 changes: 5 additions & 0 deletions packages/perseus/src/styles/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,11 @@
* GLOBAL DARK MODE SETTINGS *
*****************************/
[data-wb-theme$="-dark"] .framework-perseus {
/* TODO(benchristel): this set of file extensions is duplicated in
* image.css and dark-mode-toggle.tsx. Replace these selectors with a
* class that gets added to images that should be inverted, so the set of
* extensions can have a single representation in TypeScript code.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My thought with this current setup (all images with either of these extensions) is that any image in Perseus automatically gets this treatment. So, for instance, if content has an inline image, it will get the dark mode treatment. If we set up a function that adds a class to images, I would want to ensure that all avenues of including an image (both now and in the future) are managed by that code.

img[src$=".png"],
img[src$=".svg"],
.graphie:not(.perseus-widget-plotter),
Expand Down
Loading