mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
change requests
This commit is contained in:
parent
cce19fbda3
commit
b1de286b02
@ -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>
|
||||
);
|
||||
|
||||
@ -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}`}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user