Add pixel comparison mode to Compare tool (#6109)

Co-authored-by: Anthony Stirling <[email protected]>
Co-authored-by: EthanHealy01 <[email protected]>
This commit is contained in:
plind
2026-04-20 19:31:07 +01:00
committed by GitHub
co-authored by Anthony Stirling EthanHealy01
parent 089e448cf4
commit 66a75b1f28
14 changed files with 1302 additions and 7 deletions
@@ -0,0 +1,211 @@
import { useMemo, useState } from "react";
import { Badge, Group, SegmentedControl, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import type {
CompareResultPixelData,
ComparePixelPageResult,
} from "@app/types/compare";
import "@app/components/tools/compare/compareView.css";
interface ComparePixelWorkbenchViewProps {
result: CompareResultPixelData;
}
type PixelViewMode = "side-by-side" | "diff-only" | "overlay";
const formatPercent = (ratio: number): string => {
if (!Number.isFinite(ratio) || ratio <= 0) return "0%";
if (ratio < 0.0001) return "<0.01%";
return `${(ratio * 100).toFixed(2)}%`;
};
const PixelPageCard = ({
page,
viewMode,
t,
}: {
page: ComparePixelPageResult;
viewMode: PixelViewMode;
t: TFunction;
}) => {
const aspectRatio = `${page.width} / ${page.height}`;
return (
<Stack gap="xs" className="compare-pixel-page">
<Group justify="space-between" align="center">
<Text size="sm" fw={600}>
{t("compare.pixel.pageLabel", "Page")} {page.pageNumber}
</Text>
<Group gap="xs">
{page.missingBase && (
<Badge size="xs" color="red" variant="light">
{t("compare.pixel.missingInBase", "Missing in original")}
</Badge>
)}
{page.missingComparison && (
<Badge size="xs" color="red" variant="light">
{t("compare.pixel.missingInComparison", "Missing in edited")}
</Badge>
)}
{page.sizeMismatch && (
<Badge size="xs" color="yellow" variant="light">
{t("compare.pixel.sizeMismatch", "Size mismatch")}
</Badge>
)}
<Badge
size="xs"
color={page.diffPixels > 0 ? "red" : "green"}
variant="light"
>
{formatPercent(page.diffRatio)}{" "}
{t("compare.pixel.changed", "changed")}
</Badge>
</Group>
</Group>
{viewMode === "side-by-side" && (
<div className="compare-pixel-triptych">
<figure>
<img
src={page.baseImageUrl}
alt={t("compare.pixel.base", "Original")}
loading="lazy"
decoding="async"
style={{ aspectRatio, width: "100%" }}
/>
<figcaption>{t("compare.pixel.base", "Original")}</figcaption>
</figure>
<figure>
<img
src={page.diffImageUrl}
alt={t("compare.pixel.diff", "Differences")}
loading="lazy"
decoding="async"
style={{ aspectRatio, width: "100%" }}
/>
<figcaption>{t("compare.pixel.diff", "Differences")}</figcaption>
</figure>
<figure>
<img
src={page.comparisonImageUrl}
alt={t("compare.pixel.comparison", "Edited")}
loading="lazy"
decoding="async"
style={{ aspectRatio, width: "100%" }}
/>
<figcaption>{t("compare.pixel.comparison", "Edited")}</figcaption>
</figure>
</div>
)}
{viewMode === "diff-only" && (
<figure>
<img
src={page.diffImageUrl}
alt={t("compare.pixel.diff", "Differences")}
loading="lazy"
decoding="async"
style={{ aspectRatio, width: "100%" }}
/>
</figure>
)}
{viewMode === "overlay" && (
<div className="compare-pixel-overlay" style={{ aspectRatio }}>
<img
src={page.baseImageUrl}
alt={t("compare.pixel.base", "Original")}
loading="lazy"
decoding="async"
className="compare-pixel-overlay-img"
/>
<img
src={page.diffImageUrl}
alt={t("compare.pixel.diff", "Differences")}
loading="lazy"
decoding="async"
className="compare-pixel-overlay-img compare-pixel-overlay-top"
/>
</div>
)}
</Stack>
);
};
const ComparePixelWorkbenchView = ({
result,
}: ComparePixelWorkbenchViewProps) => {
const { t } = useTranslation();
const [viewMode, setViewMode] = useState<PixelViewMode>("side-by-side");
const totalPercent = useMemo(
() => formatPercent(result.totals.diffRatio),
[result.totals.diffRatio],
);
return (
<Stack className="compare-workbench compare-pixel-workbench" gap="md">
<Group justify="space-between" align="center" wrap="wrap">
<Group gap="md">
<Text fw={600}>
{t("compare.pixel.summaryTitle", "Pixel comparison")}
</Text>
<Badge
color={result.totals.diffPixels > 0 ? "red" : "green"}
variant="light"
>
{totalPercent} {t("compare.pixel.overall", "overall")}
</Badge>
<Badge color="blue" variant="light">
{result.totals.pagesWithChanges}/{result.pages.length}{" "}
{t("compare.pixel.pagesChanged", "pages changed")}
</Badge>
<Badge color="gray" variant="light">
{result.settings.dpi} DPI
</Badge>
</Group>
<SegmentedControl
size="xs"
value={viewMode}
onChange={(value) => setViewMode(value as PixelViewMode)}
data={[
{
value: "side-by-side",
label: t("compare.pixel.sideBySide", "Side-by-side"),
},
{
value: "diff-only",
label: t("compare.pixel.diffOnly", "Diff only"),
},
{ value: "overlay", label: t("compare.pixel.overlay", "Overlay") },
]}
/>
</Group>
{result.warnings.length > 0 && (
<Stack gap={4}>
{result.warnings.map((w, i) => (
<Text key={i} size="xs" c="yellow.7">
{w}
</Text>
))}
</Stack>
)}
<Stack gap="lg" className="compare-pixel-list">
{result.pages.map((page) => (
<PixelPageCard
key={page.pageNumber}
page={page}
viewMode={viewMode}
t={t}
/>
))}
</Stack>
</Stack>
);
};
export default ComparePixelWorkbenchView;
@@ -12,6 +12,7 @@ import {
computeMaxSharedPages,
} from "@app/components/tools/compare/compare";
import { CompareResultData, CompareWorkbenchData } from "@app/types/compare";
import ComparePixelWorkbenchView from "@app/components/tools/compare/ComparePixelWorkbenchView";
import { useFileContext } from "@app/contexts/file/fileHooks";
import { useRightRailButtons } from "@app/hooks/useRightRailButtons";
import CompareDocumentPane from "@app/components/tools/compare/CompareDocumentPane";
@@ -36,11 +37,25 @@ interface CompareWorkbenchViewProps {
// helpers moved to compare.ts
const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
const rawResult = data?.result ?? null;
if (rawResult && rawResult.mode === "pixel") {
return <ComparePixelWorkbenchView result={rawResult} />;
}
return <CompareTextWorkbenchView data={data} />;
};
interface CompareTextWorkbenchViewProps {
data: CompareWorkbenchData | null;
}
const CompareTextWorkbenchView = ({ data }: CompareTextWorkbenchViewProps) => {
const { t } = useTranslation();
const prefersStacked = useIsMobile();
const { selectors } = useFileContext();
const result: CompareResultData | null = data?.result ?? null;
const rawResult = data?.result ?? null;
const result: CompareResultData | null =
rawResult && rawResult.mode === "text" ? rawResult : null;
const baseFileId = data?.baseFileId ?? null;
const comparisonFileId = data?.comparisonFileId ?? null;
const isOperationLoading = data?.isLoading ?? false;
@@ -521,3 +521,58 @@
margin-right: 0 !important;
}
}
/* Pixel compare mode */
.compare-pixel-workbench {
padding: 16px 24px 32px;
overflow-y: auto;
}
.compare-pixel-list {
padding-bottom: 32px;
}
.compare-pixel-page {
border: 1px solid var(--mantine-color-default-border, #dee2e6);
border-radius: 8px;
padding: 12px;
background: var(--mantine-color-body, #fff);
}
.compare-pixel-triptych {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
}
.compare-pixel-triptych figure {
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.compare-pixel-triptych figure img {
display: block;
border: 1px solid var(--mantine-color-default-border, #dee2e6);
background: #fff;
}
.compare-pixel-triptych figcaption {
font-size: 11px;
text-align: center;
color: var(--mantine-color-dimmed, #868e96);
}
.compare-pixel-overlay {
position: relative;
width: 100%;
}
.compare-pixel-overlay-img {
display: block;
width: 100%;
border: 1px solid var(--mantine-color-default-border, #dee2e6);
}
.compare-pixel-overlay-top {
position: absolute;
inset: 0;
mix-blend-mode: normal;
}
@media (max-width: 768px) {
.compare-pixel-triptych {
grid-template-columns: 1fr;
}
}