diff --git a/apps/frontend/src/containers/Build/BuildDiffDetail.tsx b/apps/frontend/src/containers/Build/BuildDiffDetail.tsx index 0db2419d4..0a8b5e4e1 100644 --- a/apps/frontend/src/containers/Build/BuildDiffDetail.tsx +++ b/apps/frontend/src/containers/Build/BuildDiffDetail.tsx @@ -18,7 +18,8 @@ import { useAtom, useAtomValue } from "jotai/react"; import { BlendIcon, ChevronDownIcon, - ChevronsLeftRightIcon, + ChevronLeftIcon, + ChevronRightIcon, ChevronUpIcon, CodeIcon, CopyIcon, @@ -1119,12 +1120,17 @@ function CompareScreenshotChanged(props: { overlay={ dimensions ? (paneSize) => ( - + <> + + {blendMode === "swipe" && paneSize && ( + + )} + ) : undefined } @@ -1177,7 +1183,6 @@ function CompareScreenshotChanged(props: { : undefined, }} /> - {blendMode === "swipe" && } {dimensions && paneSize && ( @@ -1199,19 +1204,38 @@ function CompareScreenshotChanged(props: { } /** - * Draggable divider revealing the changes screenshot on its right side in - * swipe view. Rendered in image space, so sizes are divided by the current - * zoom scale to keep a constant size on screen. + * Draggable divider revealing the changes screenshot on its right side in swipe + * view. + * + * Drawn in the pane's screen space rather than over the image. Sizing it in + * image space meant dividing every length by the zoom, and those tiny lengths + * get quantized by layout before being scaled back up, which is what made the + * bar thicken and the chevrons wobble once zoomed in. Here the lengths are + * plain pixels and only the position goes through the transform. */ -function SwipeDivider() { +function SwipeDivider(props: { + paneSize: { width: number; height: number }; + imgSize: { width: number; height: number }; +}) { + const { paneSize, imgSize } = props; const [position, setPosition] = useAtom(swipePositionAtom); const [handleY, setHandleY] = useAtom(swipeHandleYAtom); const transform = useZoomTransform(); - const containerRef = useRef(null); - const inverseScale = 1 / transform.scale; - // The bar carries no shadow: anything bleeding out of it would tint the - // pixels on both sides, which are the ones being compared. - const handleRing = `0 0 0 ${inverseScale}px rgba(0, 0, 0, 0.35)`; + const [imgScale] = useScaleContext(); + const rootRef = useRef(null); + + // The image is centered in the pane and laid out at `imgScale`, then the zoom + // transform applies on top of that. + const imageWidth = imgSize.width * imgScale; + const imageHeight = imgSize.height * imgScale; + const offsetX = (paneSize.width - imageWidth) / 2; + const toPaneX = (fraction: number) => + (fraction * imageWidth + offsetX) * transform.scale + transform.x; + const toPaneY = (fraction: number) => + fraction * imageHeight * transform.scale + transform.y; + + const paneX = toPaneX(position); + const imageTop = toPaneY(0); /** * Moves the divider to the pointer, and the handle along with it when the @@ -1219,34 +1243,33 @@ function SwipeDivider() { */ const moveToPointer = useEventCallback( (event: React.PointerEvent, moveHandle: boolean) => { - const container = containerRef.current; - if (!container) { - return; - } - const rect = container.getBoundingClientRect(); - if (rect.width === 0 || rect.height === 0) { + const root = rootRef.current; + if (!root || imageWidth === 0 || imageHeight === 0) { return; } - setPosition(clampFraction((event.clientX - rect.left) / rect.width)); + const rect = root.getBoundingClientRect(); + const x = + (event.clientX - rect.left - transform.x) / transform.scale - offsetX; + setPosition(clampFraction(x / imageWidth)); if (moveHandle) { - setHandleY(clampFraction((event.clientY - rect.top) / rect.height)); + const y = (event.clientY - rect.top - transform.y) / transform.scale; + setHandleY(clampFraction(y / imageHeight)); } }, ); return ( -
+
{ event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); @@ -1258,25 +1281,20 @@ function SwipeDivider() { } }} > -
+ {/* No shadow: anything bleeding out of the bar would tint the pixels on + both sides, which are the ones being compared. */} +
{/* Sibling of the bar rather than a child, so dragging it does not also - bubble into the bar's horizontal-only drag. */} + bubble into the bar's horizontal-only drag. The box itself is + transparent: only the two chevrons flanking the bar are drawn, so + nothing covers the pixels being compared. */}
{ event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); @@ -1288,9 +1306,8 @@ function SwipeDivider() { } }} > - + +
); diff --git a/apps/frontend/src/containers/Build/Zoomer.tsx b/apps/frontend/src/containers/Build/Zoomer.tsx index 9b017327f..1e981f56b 100644 --- a/apps/frontend/src/containers/Build/Zoomer.tsx +++ b/apps/frontend/src/containers/Build/Zoomer.tsx @@ -55,6 +55,13 @@ const ZOOMER_CONTROLS_CLASS = "zoomer-controls"; */ export const ZOOMER_OVERLAY_INTERACTIVE_CLASS = "zoomer-overlay-interactive"; +/** + * Overlays that handle the wheel themselves, typically because they scroll. + * Kept separate from {@link ZOOMER_OVERLAY_INTERACTIVE_CLASS}: an overlay can + * need to swallow drags without giving up zooming the screenshot under it. + */ +export const ZOOMER_OVERLAY_SCROLLABLE_CLASS = "zoomer-overlay-scrollable"; + class Zoomer { zoom: ZoomBehavior; selection: Selection; @@ -100,8 +107,10 @@ class Zoomer { this.selection.on( "wheel.zoom", (event: any) => { - // Let interactive overlays (comment prompt/threads) scroll themselves. - if (isWrappedWithClass(event, ZOOMER_OVERLAY_INTERACTIVE_CLASS)) { + // Let scrollable overlays (comment prompt/threads) scroll themselves. + // Other interactive overlays still zoom the screenshot under them, + // rather than letting the browser zoom the whole page. + if (isWrappedWithClass(event, ZOOMER_OVERLAY_SCROLLABLE_CLASS)) { return; } event.preventDefault(); diff --git a/apps/frontend/src/pages/Build/screenshotComments/CommentMarker.tsx b/apps/frontend/src/pages/Build/screenshotComments/CommentMarker.tsx index 5e992c91c..0bc55e029 100644 --- a/apps/frontend/src/pages/Build/screenshotComments/CommentMarker.tsx +++ b/apps/frontend/src/pages/Build/screenshotComments/CommentMarker.tsx @@ -5,7 +5,10 @@ import { AnimatePresence, motion } from "motion/react"; import { createPortal } from "react-dom"; import { AccountAvatar } from "@/containers/AccountAvatar"; -import { ZOOMER_OVERLAY_INTERACTIVE_CLASS } from "@/containers/Build/Zoomer"; +import { + ZOOMER_OVERLAY_INTERACTIVE_CLASS, + ZOOMER_OVERLAY_SCROLLABLE_CLASS, +} from "@/containers/Build/Zoomer"; import { BuildCommentCard } from "@/pages/Build/sidebar/BuildCommentCard"; import { ReadOnlyEditor } from "@/ui/Editor/ReadOnlyEditor"; import { Time } from "@/ui/Time"; @@ -49,6 +52,7 @@ export function CommentMarker(props: {