diff --git a/frontend/src/components/shared/TopControls.tsx b/frontend/src/components/shared/TopControls.tsx index 62f01f9ef..bb2133049 100644 --- a/frontend/src/components/shared/TopControls.tsx +++ b/frontend/src/components/shared/TopControls.tsx @@ -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 = ( ) : ( )} - {displayName} + {compactNonSelected && currentView !== ("viewer" as WorkbenchType) ? null : ( + {displayName} + )} ), value: "viewer", @@ -66,12 +70,12 @@ const createViewOptions = ( {currentView === "pageEditor" ? ( <> {switchingTo === "pageEditor" ? : } - Page Editor + {compactNonSelected && currentView !== ("pageEditor" as WorkbenchType) ? null : Page Editor} ) : ( <> {switchingTo === "pageEditor" ? : } - Page Editor + {compactNonSelected && currentView !== ("pageEditor" as WorkbenchType) ? null : Page Editor} )} @@ -85,12 +89,12 @@ const createViewOptions = ( {currentView === "fileEditor" ? ( <> {switchingTo === "fileEditor" ? : } - Active Files + {compactNonSelected && currentView !== ("fileEditor" as WorkbenchType) ? null : Active Files} ) : ( <> {switchingTo === "fileEditor" ? : } - Active Files + {compactNonSelected && currentView !== ("fileEditor" as WorkbenchType) ? null : Active Files} )} @@ -114,7 +118,7 @@ const createViewOptions = ( ) : ( view.icon || )} - {view.label} + {compactNonSelected && currentView !== view.workbenchId ? null : {view.label}} ), value: view.workbenchId, @@ -142,6 +146,7 @@ const TopControls = ({ }: TopControlsProps) => { const { isRainbowMode } = useRainbowThemeContext(); const [switchingTo, setSwitchingTo] = useState(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 (
-
- +
+
); diff --git a/frontend/src/components/tools/compare/CompareDocumentPane.tsx b/frontend/src/components/tools/compare/CompareDocumentPane.tsx index 81253b247..a3e866b95 100644 --- a/frontend/src/components/tools/compare/CompareDocumentPane.tsx +++ b/frontend/src/components/tools/compare/CompareDocumentPane.tsx @@ -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; peerScrollRef: RefObject; 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(); @@ -226,7 +239,9 @@ const CompareDocumentPane = ({
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(null); + const searchRef = useRef(null); + const [stickyPage, setStickyPage] = useState(null); + const [searchHeight, setSearchHeight] = useState(0); + const groupOffsetsRef = useRef>([]); 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 ( - - setQuery(e.currentTarget.value)} /> - +
+ +
+ setQuery(e.currentTarget.value)} /> +
+ {stickyPage != null && ( +
{`Page ${stickyPage}`}
+ )} + {normalizedChanges.length > 0 ? ( - normalizedChanges.map((item) => ( - { - onNavigate(item.value, item.pageNumber); - combobox.closeDropdown(); - }} - > - {item.label} - - )) + (() => { + 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( +
+ {`Page ${lastPage}`} +
+ ); + } + nodes.push( + { + onNavigate(item.value, item.pageNumber); + combobox.closeDropdown(); + }} + > +
+ {item.label} +
+
+ ); + } + return nodes; + })() ) : ( No changes found )} -
-
+ + +
); diff --git a/frontend/src/components/tools/compare/CompareWorkbenchView.tsx b/frontend/src/components/tools/compare/CompareWorkbenchView.tsx index d0bea7214..85bcb6420 100644 --- a/frontend/src/components/tools/compare/CompareWorkbenchView.tsx +++ b/frontend/src/components/tools/compare/CompareWorkbenchView.tsx @@ -290,6 +290,7 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => { > { /> ({ x: 0, y: 0 }); const wheelZoomAccumRef = useRef<{ base: number; comparison: number }>({ base: 0, comparison: 0 }); const pinchRef = useRef({ 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; diff --git a/frontend/src/components/tools/compare/hooks/useCompareRightRailButtons.tsx b/frontend/src/components/tools/compare/hooks/useCompareRightRailButtons.tsx index b3bb5f844..62d1ab27f 100644 --- a/frontend/src/components/tools/compare/hooks/useCompareRightRailButtons.tsx +++ b/frontend/src/components/tools/compare/hooks/useCompareRightRailButtons.tsx @@ -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(() => [ { @@ -66,26 +68,6 @@ export const useCompareRightRailButtons = ({ order: 10, onClick: toggleLayout, }, - { - id: 'compare-pan-mode', - section: 'top', - order: 12, - render: ({ disabled }: { disabled: boolean }) => ( - - setIsPanMode(!isPanMode)} - disabled={disabled} - aria-label={t('rightRail.panMode', 'Pan Mode')} - style={isPanMode ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined} - > - - - - ), - }, { id: 'compare-zoom-out', icon: , @@ -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, ]); };