change requests

This commit is contained in:
EthanHealy01 2025-10-27 16:40:41 +00:00
parent cce19fbda3
commit b1de286b02
7 changed files with 383 additions and 91 deletions

View File

@ -1,5 +1,6 @@
import React, { useState, useCallback } from "react";
import { SegmentedControl, Loader } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import { useRainbowThemeContext } from "./RainbowThemeProvider";
import rainbowStyles from '../../styles/rainbow.module.css';
import VisibilityIcon from "@mui/icons-material/Visibility";
@ -21,14 +22,15 @@ const viewOptionStyle: React.CSSProperties = {
};
// Build view options showing text always
// Build view options; in compact mode, only the selected shows text, others show icon only
const createViewOptions = (
currentView: WorkbenchType,
switchingTo: WorkbenchType | null,
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>,
currentFileIndex: number,
onFileSelect?: (index: number) => void,
customViews?: CustomWorkbenchViewInstance[]
customViews?: CustomWorkbenchViewInstance[],
compactNonSelected?: boolean
) => {
const currentFile = activeFiles[currentFileIndex];
const isInViewer = currentView === 'viewer';
@ -54,7 +56,9 @@ const createViewOptions = (
) : (
<VisibilityIcon fontSize="small" />
)}
<span className="ph-no-capture">{displayName}</span>
{compactNonSelected && currentView !== ("viewer" as WorkbenchType) ? null : (
<span className="ph-no-capture">{displayName}</span>
)}
</div>
),
value: "viewer",
@ -66,12 +70,12 @@ const createViewOptions = (
{currentView === "pageEditor" ? (
<>
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
<span>Page Editor</span>
{compactNonSelected && currentView !== ("pageEditor" as WorkbenchType) ? null : <span>Page Editor</span>}
</>
) : (
<>
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
<span>Page Editor</span>
{compactNonSelected && currentView !== ("pageEditor" as WorkbenchType) ? null : <span>Page Editor</span>}
</>
)}
</div>
@ -85,12 +89,12 @@ const createViewOptions = (
{currentView === "fileEditor" ? (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
<span>Active Files</span>
{compactNonSelected && currentView !== ("fileEditor" as WorkbenchType) ? null : <span>Active Files</span>}
</>
) : (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
<span>Active Files</span>
{compactNonSelected && currentView !== ("fileEditor" as WorkbenchType) ? null : <span>Active Files</span>}
</>
)}
</div>
@ -114,7 +118,7 @@ const createViewOptions = (
) : (
view.icon || <PictureAsPdfIcon fontSize="small" />
)}
<span>{view.label}</span>
{compactNonSelected && currentView !== view.workbenchId ? null : <span>{view.label}</span>}
</div>
),
value: view.workbenchId,
@ -142,6 +146,7 @@ const TopControls = ({
}: TopControlsProps) => {
const { isRainbowMode } = useRainbowThemeContext();
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
const isMobile = useMediaQuery('(max-width: 768px)') ?? false;
const handleViewChange = useCallback((view: string) => {
if (!isValidWorkbench(view)) {
@ -165,39 +170,42 @@ const TopControls = ({
});
}, [setCurrentView]);
const totalOptions = 3 + (customViews?.filter((v) => v.data != null).length ?? 0);
const compactNonSelected = isMobile && totalOptions > 3;
return (
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className="flex justify-center mt-[0.5rem]">
<SegmentedControl
data-tour="view-switcher"
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, customViews)}
value={currentView}
onChange={handleViewChange}
color="blue"
fullWidth
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
style={{
transition: 'all 0.2s ease',
opacity: switchingTo ? 0.8 : 1,
pointerEvents: 'auto'
}}
styles={{
root: {
borderRadius: 9999,
maxHeight: '2.6rem',
},
control: {
borderRadius: 9999,
},
indicator: {
borderRadius: 9999,
maxHeight: '2rem',
},
label: {
paddingTop: '0rem',
}
}}
/>
<div className="flex justify-center mt-[0.5rem]" style={{ pointerEvents: 'auto' }}>
<SegmentedControl
data-tour="view-switcher"
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, customViews, compactNonSelected)}
value={currentView}
onChange={handleViewChange}
color="blue"
fullWidth
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
style={{
transition: 'all 0.2s ease',
opacity: switchingTo ? 0.8 : 1,
pointerEvents: 'auto'
}}
styles={{
root: {
borderRadius: 9999,
maxHeight: '2.6rem',
},
control: {
borderRadius: 9999,
},
indicator: {
borderRadius: 9999,
maxHeight: '2rem',
},
label: {
paddingTop: '0rem',
}
}}
/>
</div>
</div>
);

View File

@ -5,9 +5,11 @@ import type { TokenBoundingBox } from '../../../types/compare';
import CompareNavigationDropdown from './CompareNavigationDropdown';
import { toRgba } from './compareUtils';
import LazyLoadContainer from '../../shared/LazyLoadContainer';
import { useMediaQuery } from '@mantine/hooks';
interface CompareDocumentPaneProps {
pane: 'base' | 'comparison';
layout: 'side-by-side' | 'stacked';
scrollRef: RefObject<HTMLDivElement | null>;
peerScrollRef: RefObject<HTMLDivElement | null>;
handleScrollSync: (source: HTMLDivElement | null, target: HTMLDivElement | null) => void;
@ -76,6 +78,7 @@ const mergeConnectedRects = (rects: TokenBoundingBox[]): TokenBoundingBox[] => {
const CompareDocumentPane = ({
pane,
layout,
scrollRef,
peerScrollRef,
handleScrollSync,
@ -165,6 +168,16 @@ const CompareDocumentPane = ({
const fit = targetHeight / page.height;
const rowHeightPx = getRowHeightPx(page.pageNumber);
const highlightOffset = OFFSET_PIXELS / page.height;
const rotationNorm = ((page.rotation ?? 0) % 360 + 360) % 360;
const isPortrait = rotationNorm === 0 || rotationNorm === 180;
const isStackedPortrait = layout === 'stacked' && isPortrait;
const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1200;
const isMobile = useMediaQuery('(max-width: 1024px)');
const containerW = scrollRef.current?.clientWidth ?? viewportWidth;
const stackedWidth = isMobile
? Math.max(320, Math.round(containerW))
: Math.max(320, Math.round(viewportWidth * 0.5));
const stackedHeight = Math.round(stackedWidth * 1.4142);
const wordRects = wordHighlightMap.get(page.pageNumber) ?? [];
const groupedRects = new Map<string, TokenBoundingBox[]>();
@ -226,7 +239,9 @@ const CompareDocumentPane = ({
</Text>
<div
className="compare-diff-page__canvas compare-diff-page__canvas--zoom"
style={{ width: `${Math.round(page.width * fit)}px` }}
style={isStackedPortrait
? { width: `${stackedWidth}px`, height: `${stackedHeight}px`, marginLeft: 'auto', marginRight: 'auto' }
: { width: `${Math.round(page.width * fit)}px` }}
>
<div
className={`compare-diff-page__inner compare-diff-page__inner--${pane}`}

View File

@ -1,5 +1,5 @@
import { Combobox, ScrollArea, useCombobox } from '@mantine/core';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
interface NavigationDropdownProps {
@ -18,7 +18,33 @@ const CompareNavigationDropdown = ({
const { t } = useTranslation();
const newLineLabel = t('compare.newLine', 'new-line');
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
onDropdownClose: () => {
combobox.resetSelectedOption();
// Reset sticky header state when dropdown closes
setStickyPage(null);
groupOffsetsRef.current = [];
const viewport = viewportRef.current;
if (viewport) viewport.scrollTop = 0;
},
onDropdownOpen: () => {
// Ensure we start at the top and initialize sticky to first page
const viewport = viewportRef.current;
if (viewport) viewport.scrollTop = 0;
requestAnimationFrame(() => {
const headers = Array.from((viewportRef.current?.querySelectorAll('.compare-dropdown-group') ?? [])) as HTMLElement[];
// Rebuild offsets so scrolling after reopen updates correctly
groupOffsetsRef.current = headers.map((el) => {
const text = el.textContent || '';
const page = parseInt(text.replace(/[^0-9]/g, ''), 10) || 0;
return { top: el.offsetTop, page };
});
if (groupOffsetsRef.current.length > 0) {
setStickyPage(groupOffsetsRef.current[0].page);
} else {
setStickyPage(null);
}
});
},
});
const sanitize = (s: string) => {
@ -51,6 +77,11 @@ const CompareNavigationDropdown = ({
};
const [query, setQuery] = useState('');
const viewportRef = useRef<HTMLDivElement | null>(null);
const searchRef = useRef<HTMLDivElement | null>(null);
const [stickyPage, setStickyPage] = useState<number | null>(null);
const [searchHeight, setSearchHeight] = useState(0);
const groupOffsetsRef = useRef<Array<{ top: number; page: number }>>([]);
const normalizedChanges = useMemo(() => {
// Helper to strip localized new-line marker occurrences from labels
@ -69,6 +100,49 @@ const CompareNavigationDropdown = ({
return cleaned.filter((c) => c.label.toLowerCase().includes(q));
}, [changes, query, newLineLabel]);
useEffect(() => {
// Measure search height for sticky offset
setSearchHeight(searchRef.current?.offsetHeight ?? 0);
}, []);
useEffect(() => {
// Build offsets for group headers whenever list changes
const viewport = viewportRef.current;
if (!viewport) return;
const headers = Array.from(viewport.querySelectorAll('.compare-dropdown-group')) as HTMLElement[];
groupOffsetsRef.current = headers.map((el) => {
const text = el.textContent || '';
const page = parseInt(text.replace(/[^0-9]/g, ''), 10) || 0;
return { top: el.offsetTop, page };
});
// Initialize sticky label
if (groupOffsetsRef.current.length > 0) {
setStickyPage(groupOffsetsRef.current[0].page);
} else {
setStickyPage(null);
}
}, [normalizedChanges]);
const handleScrollPos = ({ y }: { x: number; y: number }) => {
const offsets = groupOffsetsRef.current;
if (offsets.length === 0) return;
// Find the last header whose top is <= scroll, so the next header replaces it
let low = 0;
let high = offsets.length - 1;
let idx = 0;
while (low <= high) {
const mid = (low + high) >> 1;
if (offsets[mid].top <= y + 1) { // +1 to avoid jitter at exact boundary
idx = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
const page = offsets[idx]?.page ?? offsets[0].page;
if (page !== stickyPage) setStickyPage(page);
};
return (
<Combobox
store={combobox}
@ -91,27 +165,54 @@ const CompareNavigationDropdown = ({
</Combobox.Target>
<Combobox.Dropdown>
<ScrollArea.Autosize mah={300}>
<Combobox.Search placeholder="Search changes..." value={query} onChange={(e) => setQuery(e.currentTarget.value)} />
<Combobox.Options>
<div className="compare-dropdown-scrollwrap">
<ScrollArea.Autosize mah={300} viewportRef={viewportRef} onScrollPositionChange={handleScrollPos}>
<div ref={searchRef}>
<Combobox.Search placeholder="Search changes..." value={query} onChange={(e) => setQuery(e.currentTarget.value)} />
</div>
{stickyPage != null && (
<div className="compare-dropdown-sticky" style={{ top: searchHeight }}>{`Page ${stickyPage}`}</div>
)}
<Combobox.Options className="compare-dropdown-options">
{normalizedChanges.length > 0 ? (
normalizedChanges.map((item) => (
<Combobox.Option
value={item.value}
key={item.value}
onClick={() => {
onNavigate(item.value, item.pageNumber);
combobox.closeDropdown();
}}
>
{item.label}
</Combobox.Option>
))
(() => {
const nodes: React.ReactNode[] = [];
let lastPage: number | null = null;
for (const item of normalizedChanges) {
if (item.pageNumber && item.pageNumber !== lastPage) {
lastPage = item.pageNumber;
nodes.push(
<div
className={["compare-dropdown-group", stickyPage === lastPage ? "compare-dropdown-group--hidden" : ""].filter(Boolean).join(" ")}
key={`group-${lastPage}`}
>
{`Page ${lastPage}`}
</div>
);
}
nodes.push(
<Combobox.Option
value={item.value}
key={item.value}
onClick={() => {
onNavigate(item.value, item.pageNumber);
combobox.closeDropdown();
}}
>
<div className="compare-dropdown-option">
<span className="compare-dropdown-option__text">{item.label}</span>
</div>
</Combobox.Option>
);
}
return nodes;
})()
) : (
<Combobox.Empty>No changes found</Combobox.Empty>
)}
</Combobox.Options>
</ScrollArea.Autosize>
</Combobox.Options>
</ScrollArea.Autosize>
</div>
</Combobox.Dropdown>
</Combobox>
);

View File

@ -290,6 +290,7 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
>
<CompareDocumentPane
pane="base"
layout={layout}
scrollRef={baseScrollRef}
peerScrollRef={comparisonScrollRef}
handleScrollSync={handleScrollSync}
@ -321,6 +322,7 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
/>
<CompareDocumentPane
pane="comparison"
layout={layout}
scrollRef={comparisonScrollRef}
peerScrollRef={baseScrollRef}
handleScrollSync={handleScrollSync}

View File

@ -1,3 +1,23 @@
.compare-dropdown-scrollwrap {
position: relative;
}
.compare-dropdown-sticky {
position: sticky;
z-index: 2;
background: var(--bg-surface);
color: var(--text-muted);
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--border-subtle);
pointer-events: none;
}
[data-mantine-color-scheme="dark"] .compare-dropdown-sticky {
background: var(--bg-toolbar);
color: var(--text-secondary);
border-bottom: 1px solid var(--border-default);
}
.compare-workbench {
display: flex;
flex-direction: column;
@ -293,6 +313,11 @@
width: 100%;
}
/* Center canvas in stacked portrait mode (width/height set inline) */
.compare-diff-page__canvas[style*="margin-left: auto"] {
display: block;
}
.compare-diff-page__canvas--zoom {
overflow: hidden;
max-width: 100%;
@ -319,6 +344,49 @@
mix-blend-mode: normal; /* reduce dark spots on overlap */
}
/* Compare dropdown option formatting (page + clamped text) */
.compare-dropdown-option {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.compare-dropdown-option__page {
font-size: 0.7rem;
color: var(--text-muted);
}
.compare-dropdown-option__text {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
line-clamp: 3;
}
/* Non-sticky in-flow group headers; sticky handled by floating header */
.compare-dropdown-group {
position: static;
background: transparent;
color: var(--text-muted);
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--border-subtle);
}
.compare-dropdown-group.compare-dropdown-group--hidden {
height: 0;
padding: 0;
margin: 0;
border: 0;
overflow: hidden;
}
[data-mantine-color-scheme="dark"] .compare-dropdown-group {
background: transparent;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-default);
}
/* Inline paragraph highlights in summary */
.compare-inline {
border-radius: 0.2rem;
@ -442,3 +510,19 @@
min-height: 18rem;
}
}
/* Mobile: remove side margins and let canvases take full width inside column */
@media (max-width: 768px) {
.compare-workbench__columns {
grid-template-columns: 1fr;
}
.compare-diff-page__canvas.compare-diff-page__canvas--zoom {
width: 100% !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
.compare-diff-page__inner {
margin-left: 0 !important;
margin-right: 0 !important;
}
}

View File

@ -123,6 +123,7 @@ export const useComparePanZoom = ({
const [comparisonPan, setComparisonPan] = useState<PanState>({ x: 0, y: 0 });
const wheelZoomAccumRef = useRef<{ base: number; comparison: number }>({ base: 0, comparison: 0 });
const pinchRef = useRef<PinchState>({ active: false, pane: null, startDistance: 0, startZoom: 1 });
const edgeOverscrollRef = useRef<{ base: number; comparison: number }>({ base: 0, comparison: 0 });
const [layout, setLayoutState] = useState<'side-by-side' | 'stacked'>(prefersStacked ? 'stacked' : 'side-by-side');
const setLayout = useCallback((next: 'side-by-side' | 'stacked') => {
@ -382,6 +383,7 @@ export const useComparePanZoom = ({
targetStartPanX: pane === 'base' ? comparisonPan.x : basePan.x,
targetStartPanY: pane === 'base' ? comparisonPan.y : basePan.y,
};
edgeOverscrollRef.current[pane] = 0;
lastActivePaneRef.current = pane;
(container as HTMLDivElement).style.cursor = 'grabbing';
},
@ -399,11 +401,56 @@ export const useComparePanZoom = ({
const isBase = drag.source === 'base';
const bounds = getPanBounds(drag.source);
const rawX = drag.startPanX - dx;
const rawY = drag.startPanY - dy;
const desired = {
x: Math.max(0, Math.min(bounds.maxX, drag.startPanX - dx)),
y: Math.max(0, Math.min(bounds.maxY, drag.startPanY - dy)),
x: Math.max(0, Math.min(bounds.maxX, rawX)),
y: Math.max(0, Math.min(bounds.maxY, rawY)),
};
// On vertical overscroll beyond pan bounds, scroll the page (with deadzone + incremental steps)
const container = isBase ? baseScrollRef.current : comparisonScrollRef.current;
if (container) {
const DEADZONE = 32; // pixels
const STEP = 48; // pixels per incremental scroll
let overflowY = 0;
if (rawY < 0) overflowY = rawY; // negative -> scroll up
else if (rawY > bounds.maxY) overflowY = rawY - bounds.maxY; // positive -> scroll down
let applyCandidate = 0;
if (overflowY < -DEADZONE) applyCandidate = overflowY + DEADZONE;
else if (overflowY > DEADZONE) applyCandidate = overflowY - DEADZONE;
if (applyCandidate !== 0) {
const key = isBase ? 'base' : 'comparison';
const deltaSinceLast = applyCandidate - edgeOverscrollRef.current[key];
const magnitude = Math.abs(deltaSinceLast);
if (magnitude >= STEP) {
const stepDelta = Math.sign(deltaSinceLast) * Math.floor(magnitude / STEP) * STEP;
edgeOverscrollRef.current[key] += stepDelta;
const prevTop = container.scrollTop;
const nextTop = Math.max(0, Math.min(container.scrollHeight - container.clientHeight, prevTop + stepDelta));
if (nextTop !== prevTop) {
container.scrollTop = nextTop;
if (isScrollLinked) {
const sourceIsBase = isBase;
const target = isBase ? comparisonScrollRef.current : baseScrollRef.current;
if (target) {
const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight);
const mappedTop = mapScrollTopBetweenPanes(nextTop, sourceIsBase);
const deltaPx = sourceIsBase
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx));
target.scrollTop = desiredTop;
}
}
}
}
} else {
// Reset accumulator when back within deadzone
edgeOverscrollRef.current[isBase ? 'base' : 'comparison'] = 0;
}
}
if (isScrollLinked) {
if (isBase) {
setBasePan(desired);
@ -520,6 +567,7 @@ export const useComparePanZoom = ({
targetStartPanX: pane === 'base' ? comparisonPan.x : basePan.x,
targetStartPanY: pane === 'base' ? comparisonPan.y : basePan.y,
};
edgeOverscrollRef.current[pane] = 0;
event.preventDefault();
}
},
@ -564,10 +612,52 @@ export const useComparePanZoom = ({
const dy = touch.clientY - panDragRef.current.startY;
const isBase = panDragRef.current.source === 'base';
const bounds = getPanBounds(panDragRef.current.source!);
const rawX = panDragRef.current.startPanX - dx;
const rawY = panDragRef.current.startPanY - dy;
const desired = {
x: Math.max(0, Math.min(bounds.maxX, panDragRef.current.startPanX - dx)),
y: Math.max(0, Math.min(bounds.maxY, panDragRef.current.startPanY - dy)),
x: Math.max(0, Math.min(bounds.maxX, rawX)),
y: Math.max(0, Math.min(bounds.maxY, rawY)),
};
const container = isBase ? baseScrollRef.current : comparisonScrollRef.current;
if (container) {
const DEADZONE = 32;
const STEP = 48;
let overflowY = 0;
if (rawY < 0) overflowY = rawY; else if (rawY > bounds.maxY) overflowY = rawY - bounds.maxY;
let applyCandidate = 0;
if (overflowY < -DEADZONE) applyCandidate = overflowY + DEADZONE;
else if (overflowY > DEADZONE) applyCandidate = overflowY - DEADZONE;
if (applyCandidate !== 0) {
const key = isBase ? 'base' : 'comparison';
const deltaSinceLast = applyCandidate - edgeOverscrollRef.current[key];
const magnitude = Math.abs(deltaSinceLast);
if (magnitude >= STEP) {
const stepDelta = Math.sign(deltaSinceLast) * Math.floor(magnitude / STEP) * STEP;
edgeOverscrollRef.current[key] += stepDelta;
const prevTop = container.scrollTop;
const nextTop = Math.max(0, Math.min(container.scrollHeight - container.clientHeight, prevTop + stepDelta));
if (nextTop !== prevTop) {
container.scrollTop = nextTop;
if (isScrollLinked) {
const sourceIsBase = isBase;
const target = isBase ? comparisonScrollRef.current : baseScrollRef.current;
if (target) {
const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight);
const mappedTop = mapScrollTopBetweenPanes(nextTop, sourceIsBase);
const deltaPx = sourceIsBase
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx));
target.scrollTop = desiredTop;
}
}
}
}
} else {
edgeOverscrollRef.current[isBase ? 'base' : 'comparison'] = 0;
}
}
if (isScrollLinked) {
if (isBase) {
setBasePan(desired);
@ -605,6 +695,12 @@ export const useComparePanZoom = ({
panDragRef.current.active = false;
}, []);
// Auto-toggle Pan Mode based on zoom level
useEffect(() => {
const shouldPan = baseZoom > 1 || comparisonZoom > 1;
if (isPanMode !== shouldPan) setIsPanMode(shouldPan);
}, [baseZoom, comparisonZoom, isPanMode]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (isScrollLinked) return;

View File

@ -6,6 +6,7 @@ import { Tooltip } from '../../../shared/Tooltip';
import { alert } from '../../../toast';
import type { ToastLocation } from '../../../toast/types';
import type { RightRailButtonWithAction } from '../../../../hooks/useRightRailButtons';
import { useMediaQuery } from '@mantine/hooks';
type Pane = 'base' | 'comparison';
@ -45,6 +46,7 @@ export const useCompareRightRailButtons = ({
zoomLimits,
}: UseCompareRightRailButtonsOptions): RightRailButtonWithAction[] => {
const { t } = useTranslation();
const isMobile = useMediaQuery('(max-width: 768px)') ?? false;
return useMemo<RightRailButtonWithAction[]>(() => [
{
@ -66,26 +68,6 @@ export const useCompareRightRailButtons = ({
order: 10,
onClick: toggleLayout,
},
{
id: 'compare-pan-mode',
section: 'top',
order: 12,
render: ({ disabled }: { disabled: boolean }) => (
<Tooltip content={t('rightRail.panMode', 'Pan Mode')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant={isPanMode ? 'default' : 'subtle'}
radius="md"
className="right-rail-icon"
onClick={() => setIsPanMode(!isPanMode)}
disabled={disabled}
aria-label={t('rightRail.panMode', 'Pan Mode')}
style={isPanMode ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined}
>
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
),
},
{
id: 'compare-zoom-out',
icon: <LocalIcon icon="zoom-out" width="1.5rem" height="1.5rem" />,
@ -127,6 +109,7 @@ export const useCompareRightRailButtons = ({
ariaLabel: t('compare.actions.resetView', 'Reset zoom and pan'),
section: 'top',
order: 14.5,
disabled: baseZoom === 1 && comparisonZoom === 1,
onClick: () => {
setBaseZoom(1);
setComparisonZoom(1);
@ -157,14 +140,16 @@ export const useCompareRightRailButtons = ({
if (next) {
captureScrollLinkDelta();
} else {
alert({
alertType: 'neutral',
title: t('compare.toasts.unlinkedTitle', 'Independent scroll & pan enabled'),
body: t('compare.toasts.unlinkedBody', 'Tip: Arrow Up/Down scroll both panes; panning only moves the active pane.'),
durationMs: 5000,
location: 'bottom-center' as ToastLocation,
expandable: false,
});
if (!isMobile) {
alert({
alertType: 'neutral',
title: t('compare.toasts.unlinkedTitle', 'Independent scroll & pan enabled'),
body: t('compare.toasts.unlinkedBody', 'Tip: Arrow Up/Down scroll both panes; panning only moves the active pane.'),
durationMs: 5000,
location: 'bottom-center' as ToastLocation,
expandable: false,
});
}
}
setIsScrollLinked(next);
},
@ -186,6 +171,7 @@ export const useCompareRightRailButtons = ({
setIsScrollLinked,
zoomLimits,
t,
isMobile,
]);
};