mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
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:
co-authored by
Anthony Stirling
EthanHealy01
parent
089e448cf4
commit
66a75b1f28
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user