1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2026-04-17 10:28:46 -04:00

fix: image comparison component weirdness

This commit is contained in:
2026-01-28 17:11:28 -05:00
parent 4dca81b58a
commit 9afe8abfed
6 changed files with 52 additions and 126 deletions

View File

@@ -1,40 +1,11 @@
/* eslint-disable jsx-a11y/alt-text, @next/next/no-img-element */
"use client";
import { useState, useRef, useEffect, Children } from "react";
import { Children } from "react";
import { getImageProps } from "next/image";
import { ReactCompareSlider, ReactCompareSliderImage } from "react-compare-slider";
import { cn } from "@/lib/utils";
import { ChevronsLeftRightIcon } from "lucide-react";
const ImageDiff = ({ children, className }: { children: React.ReactElement[]; className?: string }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [sliderPosition, setSliderPosition] = useState(50);
const [isDragging, setIsDragging] = useState(false);
// Add event listeners to handle dragging outside the component
useEffect(() => {
const handleMouseUpGlobal = () => {
setIsDragging(false);
};
const handleMouseMoveGlobal = (e: MouseEvent) => {
if (!isDragging || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const percentage = (x / rect.width) * 100;
setSliderPosition(percentage);
};
document.addEventListener("mouseup", handleMouseUpGlobal);
document.addEventListener("mousemove", handleMouseMoveGlobal);
return () => {
document.removeEventListener("mouseup", handleMouseUpGlobal);
document.removeEventListener("mousemove", handleMouseMoveGlobal);
};
}, [isDragging]);
// Extract the two image children
const childrenArray = Children.toArray(children);
if (childrenArray.length !== 2) {
@@ -42,88 +13,27 @@ const ImageDiff = ({ children, className }: { children: React.ReactElement[]; cl
return null;
}
// Get the original image source to extract dimensions for aspect ratio
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const beforeImageProps = getImageProps(children[0].props as any).props;
const firstChildProps = children[0].props as any;
const imageSrc = firstChildProps.src;
const aspectRatio =
typeof imageSrc === "object" && "width" in imageSrc && "height" in imageSrc
? imageSrc.width / imageSrc.height
: 16 / 9;
// Extract image props, stripping out MDX className (margins, etc.) that would break slider layout
const beforeImageProps = getImageProps(firstChildProps).props;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const afterImageProps = getImageProps(children[1].props as any).props;
const handleMouseDown = () => {
setIsDragging(true);
};
const handleMouseUp = () => {
setIsDragging(false);
};
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!isDragging || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const percentage = (x / rect.width) * 100;
setSliderPosition(percentage);
};
const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
if (!containerRef.current) return;
const touch = e.touches[0];
const rect = containerRef.current.getBoundingClientRect();
const x = Math.max(0, Math.min(touch.clientX - rect.left, rect.width));
const percentage = (x / rect.width) * 100;
setSliderPosition(percentage);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
const step = 5;
if (e.key === "ArrowLeft") {
setSliderPosition((prev) => Math.max(0, prev - step));
e.preventDefault();
} else if (e.key === "ArrowRight") {
setSliderPosition((prev) => Math.min(100, prev + step));
e.preventDefault();
}
};
return (
<div
ref={containerRef}
onMouseMove={handleMouseMove}
onTouchMove={handleTouchMove}
onKeyDown={handleKeyDown}
className={cn("relative isolate w-full max-w-full overflow-hidden select-none", className)}
style={{ ["--slider-position" as string]: `${sliderPosition}%` }}
role="slider"
aria-label="Image comparison slider"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={sliderPosition}
tabIndex={0}
>
{/* After image (full width, underneath) */}
<div className="h-full w-full">
<img {...afterImageProps} className="h-full w-full object-cover object-top-left" />
</div>
{/* Before image (clipped with width based on slider position) */}
<div className="absolute top-0 left-0 h-full w-[var(--slider-position)] overflow-hidden">
<img {...beforeImageProps} className="h-full w-full object-cover object-top-left" />
</div>
{/* Divider line */}
<div className="bg-muted-foreground absolute top-0 bottom-0 left-[var(--slider-position)] w-1 -translate-x-1/2 drop-shadow-md" />
{/* Slider handle */}
<div
onMouseDown={handleMouseDown}
onTouchStart={handleMouseDown}
onTouchEnd={handleMouseUp}
className="bg-muted absolute top-1/2 left-[var(--slider-position)] flex size-10 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize touch-none items-center justify-center rounded-full border-2 drop-shadow-md"
aria-hidden="true"
>
<ChevronsLeftRightIcon className="text-foreground/70 size-6" />
</div>
</div>
<ReactCompareSlider
className={cn("my-8 w-full max-w-full overflow-hidden rounded-sm", className)}
style={{ aspectRatio }}
itemOne={<ReactCompareSliderImage {...beforeImageProps} className="size-full object-cover" />}
itemTwo={<ReactCompareSliderImage {...afterImageProps} className="size-full object-cover" />}
/>
);
};

View File

@@ -5,20 +5,21 @@ import siteConfig from "@/lib/config/site";
const Footer = () => {
return (
<footer className="text-muted-foreground mt-8 w-full py-6 text-center text-[13px] leading-loose">
Content <Link href="/license">licensed under {siteConfig.license}</Link>,{" "}
<Link href="/previously" title="Previously on...">
{siteConfig.copyrightYearStart}
</Link>{" "}
2026.{" "}
All content is licensed under{" "}
<Link href="/license" className="underline underline-offset-4">
{siteConfig.license}
</Link>
. Code is{" "}
<a
href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_REPO}`}
target="_blank"
rel="noopener noreferrer"
title="View Source on GitHub"
className="font-medium underline underline-offset-4"
className="underline underline-offset-4"
>
View source.
open source
</a>
.
</footer>
);
};

View File

@@ -62,19 +62,19 @@ const PostStats = ({ slug }: { slug: string }) => {
return (
<>
{viewCount > 0 && (
<Badge variant="secondary" className="tabular-nums">
<EyeIcon className="text-foreground/85" aria-hidden="true" />
<Badge variant="secondary" className="text-foreground/80 gap-[5px] tabular-nums">
<EyeIcon className="text-foreground/65" aria-hidden="true" />
{numberFormatter.format(viewCount)}
</Badge>
)}
{commentCount > 0 && (
<Badge variant="secondary" className="tabular-nums" asChild>
<Badge variant="secondary" className="text-foreground/80 gap-[5px] tabular-nums" asChild>
<Link
href={`/${slug}#comments`}
title={`${numberFormatter.format(commentCount)} ${commentCount === 1 ? "comment" : "comments"}`}
>
<MessagesSquareIcon className="text-foreground/85" aria-hidden="true" />
<MessagesSquareIcon className="text-foreground/65" aria-hidden="true" />
{numberFormatter.format(commentCount)}
</Link>
</Badge>

View File

@@ -4,7 +4,6 @@ const siteConfig = {
description:
"Hi there! I'm a frontend web developer based in Boston, Massachusetts specializing in TypeScript, React, Next.js, and other JavaScript frameworks.",
license: "CC-BY-4.0",
copyrightYearStart: 2001,
} as const;
export default siteConfig;

View File

@@ -66,7 +66,8 @@
"next-themes": "^0.4.6",
"pg": "^8.17.2",
"react": "19.2.4",
"react-activity-calendar": "^3.1.0",
"react-activity-calendar": "^3.1.1",
"react-compare-slider": "^3.1.0",
"react-countup": "^6.5.3",
"react-dom": "19.2.4",
"react-lite-youtube-embed": "^3.3.3",
@@ -97,7 +98,7 @@
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"unified": "^11.0.5",
"use-debounce": "^10.0.4",
"use-debounce": "^10.1.0",
"zod": "^4.3.6"
},
"devDependencies": {

23
pnpm-lock.yaml generated
View File

@@ -147,6 +147,9 @@ importers:
specifier: 19.2.4
version: 19.2.4
react-activity-calendar:
specifier: ^3.1.1
version: 3.1.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react-compare-slider:
specifier: ^3.1.0
version: 3.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react-countup:
@@ -240,7 +243,7 @@ importers:
specifier: ^11.0.5
version: 11.0.5
use-debounce:
specifier: ^10.0.4
specifier: ^10.1.0
version: 10.1.0(react@19.2.4)
zod:
specifier: ^4.3.6
@@ -4375,11 +4378,18 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
react-activity-calendar@3.1.0:
resolution: {integrity: sha512-KUkzV9Jz60ueKiIDVkWaDO5HqyMyWUrKtPJ1bS8DfOOlQ4maZDSreGN6A+jHl1z1R2JrBn7O/VJtiQ/aT30KHw==}
react-activity-calendar@3.1.1:
resolution: {integrity: sha512-YvHNS4anlW3Xy0fxOU2ZTWrJkOt0ALxX8IfgDRg6jr/vgYsbh//4djf2c7CPhYNROh+5oYZzR0EJaPbrFUj2tA==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-compare-slider@3.1.0:
resolution: {integrity: sha512-TQVbZYmYyTIeKRmQciVXCmUwHjTThQTON7GfWfzMAOInRRG9tCiQnVXnCUd5DJ5l3Hngh4IEzOb9TG82gjoEhQ==}
engines: {node: '>=16.9.0'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
react-countup@6.5.3:
resolution: {integrity: sha512-udnqVQitxC7QWADSPDOxVWULkLvKUWrDapn5i53HE4DPRVgs+Y5rr4bo25qEl8jSh+0l2cToJgGMx+clxPM3+w==}
peerDependencies:
@@ -9427,7 +9437,7 @@ snapshots:
queue-microtask@1.2.3: {}
react-activity-calendar@3.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
react-activity-calendar@3.1.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
date-fns: 4.1.0
@@ -9435,6 +9445,11 @@ snapshots:
transitivePeerDependencies:
- react-dom
react-compare-slider@3.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-countup@6.5.3(react@19.2.4):
dependencies:
countup.js: 2.9.0