previewzoomoverlay

This commit is contained in:
2025-10-26 21:01:48 +01:00
parent 518b79bec6
commit 40e45f3a01
4 changed files with 521 additions and 498 deletions
+127 -113
View File
@@ -9,6 +9,7 @@ import React, {
import { resolveDocumentAssetUrl, createAssetView } from './asset_manager';
import { useAssetNavigator } from './hooks/useAssetNavigator';
import { ArrowLeftIcon, ArrowRightIcon } from './ui/icons';
import PreviewZoomOverlay from './detail/PreviewZoomOverlay';
import { getReadableTextColor } from './utils/colors';
import './skeuomorphic_ws.css';
@@ -28,9 +29,6 @@ const resolveSizeKey = (doc) =>
const CARD_MIN = 240;
const CARD_MAX = 340;
const EMPTY_CARD_ASPECT = 1.4;
const ZOOM_FILL_RATIO = 0.99;
const ZOOM_MIN_SCALE = 1.05;
const ZOOM_MAX_SCALE = 5;
const TAG_REMOVE_DISTANCE = 160;
const DEBUG_DRAG = false;
@@ -52,6 +50,7 @@ const SkeuoPreviewCard = ({
getDocumentAsset,
navScale = 1,
prefetch = 3,
onNavigatorSnapshot,
}) => {
const navigator = useAssetNavigator({
document: doc,
@@ -62,6 +61,32 @@ const SkeuoPreviewCard = ({
});
const { currentUrl, cardinality, canGoPrev, canGoNext } = navigator;
const docId = doc?.id ?? null;
useEffect(() => {
if (!onNavigatorSnapshot || !docId) {
return undefined;
}
const snapshot = {
url: currentUrl || null,
alt: title,
canGoPrev,
canGoNext,
goPrev: navigator.goPrev,
goNext: navigator.goNext,
};
onNavigatorSnapshot(docId, snapshot);
return () => onNavigatorSnapshot(docId, null);
}, [
docId,
currentUrl,
title,
canGoPrev,
canGoNext,
navigator.goPrev,
navigator.goNext,
onNavigatorSnapshot,
]);
const hasPreview = Boolean(currentUrl);
const cardClasses = ['skeuo-item__card'];
if (!hasPreview) cardClasses.push('skeuo-item__card--empty');
@@ -303,7 +328,7 @@ const useDocumentDrag = ({
setDraggingId,
syncLayoutSnapshot,
canvasSize,
toggleZoom,
openOverlayForDoc,
}) => {
const dragStateRef = useRef(null);
@@ -330,7 +355,7 @@ const useDocumentDrag = ({
);
const handlePointerDown = useCallback(
(event, docId, { lockWhenZoomed = false } = {}) => {
(event, docId) => {
if (DEBUG_DRAG) {
console.log(
'[skeuo] handlePointerDown fired for doc',
@@ -376,7 +401,7 @@ const useDocumentDrag = ({
startY: event.clientY,
rotation: entry?.rotation ?? 0,
moved: false,
locked: Boolean(lockWhenZoomed),
locked: false,
width: docWidth,
height: docHeight,
scale: baseScale,
@@ -475,13 +500,13 @@ const useDocumentDrag = ({
const docId = state.docId;
finishDrag(event.pointerId);
if (!moved) {
toggleZoom(docId);
openOverlayForDoc(docId);
}
return;
}
finishDrag(event.pointerId);
},
[finishDrag, toggleZoom],
[finishDrag, openOverlayForDoc],
);
const handlePointerCancel = useCallback(
@@ -567,7 +592,8 @@ const SkeuomorphicWorkspace = ({
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
const [draggingId, setDraggingId] = useState(null);
const [zoomedId, setZoomedId] = useState(null);
const [overlayDocId, setOverlayDocId] = useState(null);
const [previewSnapshots, setPreviewSnapshots] = useState(() => new Map());
const [tagDropTargetId, setTagDropTargetId] = useState(null);
const [pendingTagDocId, setPendingTagDocId] = useState(null);
const [pendingRemovalTag, setPendingRemovalTag] = useState(null);
@@ -575,6 +601,36 @@ const SkeuomorphicWorkspace = ({
const pendingDocTagDragRef = useRef(null);
const docSizeMapRef = useRef(new Map());
const removalCursorActiveRef = useRef(false);
const handleNavigatorSnapshot = useCallback((docId, snapshot) => {
if (!docId) {
return;
}
setPreviewSnapshots((previous) => {
const prevSnapshot = previous.get(docId);
if (!snapshot) {
if (!previous.has(docId)) {
return previous;
}
const next = new Map(previous);
next.delete(docId);
return next;
}
const next = new Map(previous);
const sameSnapshot =
prevSnapshot &&
prevSnapshot.url === snapshot.url &&
prevSnapshot.alt === snapshot.alt &&
prevSnapshot.canGoPrev === snapshot.canGoPrev &&
prevSnapshot.canGoNext === snapshot.canGoNext &&
prevSnapshot.goPrev === snapshot.goPrev &&
prevSnapshot.goNext === snapshot.goNext;
if (sameSnapshot) {
return previous;
}
next.set(docId, snapshot);
return next;
});
}, []);
const activeTagSet = useMemo(() => {
if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) {
return new Set();
@@ -774,70 +830,35 @@ const SkeuomorphicWorkspace = ({
docSizeMapRef.current = new Map();
}, [items]);
const resolveZoomMetrics = useCallback(
(doc, cardWidth, cardHeight) => {
const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH;
const canvasHeight = canvasSize.height || DEFAULT_CANVAS_HEIGHT;
const safeCardWidth = cardWidth || CARD_MIN;
const safeCardHeight = cardHeight || CARD_MIN;
const usableWidth = Math.max(canvasWidth - CANVAS_PADDING * 2, safeCardWidth);
const usableHeight = Math.max(canvasHeight - CANVAS_PADDING * 2, safeCardHeight);
const viewportTargetWidth = usableWidth * ZOOM_FILL_RATIO;
const viewportTargetHeight = usableHeight * ZOOM_FILL_RATIO;
const overlayDisplay = useMemo(() => {
if (!overlayDocId) {
return null;
}
const snapshot = previewSnapshots.get(overlayDocId);
if (!snapshot || !snapshot.url) {
return null;
}
const doc = documentLookup.get(overlayDocId);
const alt = snapshot.alt || doc?.title || doc?.original_name || 'Document preview';
return {
url: snapshot.url,
alt,
canGoPrev: snapshot.canGoPrev,
canGoNext: snapshot.canGoNext,
goPrev: snapshot.goPrev,
goNext: snapshot.goNext,
};
}, [overlayDocId, previewSnapshots, documentLookup]);
let zoomWidth = safeCardWidth;
let zoomHeight = safeCardHeight;
const closeOverlay = useCallback(() => {
setOverlayDocId(null);
}, []);
const previewDims = doc ? resolvePreviewDimensions(doc) : null;
if (previewDims?.width && previewDims?.height) {
const previewWidth = previewDims.width;
const previewHeight = previewDims.height;
const widthScaleLimit = previewWidth > 0 ? viewportTargetWidth / previewWidth : 1;
const heightScaleLimit = previewHeight > 0 ? viewportTargetHeight / previewHeight : 1;
const scaleToFit = Math.min(1, widthScaleLimit || 1, heightScaleLimit || 1);
zoomWidth = previewWidth * scaleToFit;
zoomHeight = previewHeight * scaleToFit;
} else {
const rawScale = Math.min(
viewportTargetWidth / safeCardWidth,
viewportTargetHeight / safeCardHeight,
);
const boundedScale =
rawScale >= 1
? clamp(Math.max(rawScale, ZOOM_MIN_SCALE), ZOOM_MIN_SCALE, ZOOM_MAX_SCALE)
: rawScale;
zoomWidth = safeCardWidth * boundedScale;
zoomHeight = safeCardHeight * boundedScale;
}
if (!Number.isFinite(zoomWidth) || zoomWidth <= 0) {
zoomWidth = safeCardWidth;
}
if (!Number.isFinite(zoomHeight) || zoomHeight <= 0) {
zoomHeight = safeCardHeight;
}
zoomWidth = Math.max(zoomWidth, safeCardWidth);
zoomHeight = Math.max(zoomHeight, safeCardHeight);
const zoomTargetX = (canvasWidth - zoomWidth) / 2;
const zoomTargetY = (canvasHeight - zoomHeight) / 2;
const maxX = Math.max(CANVAS_PADDING, canvasWidth - zoomWidth - CANVAS_PADDING);
const maxY = Math.max(CANVAS_PADDING, canvasHeight - zoomHeight - CANVAS_PADDING);
const clampedX = clamp(zoomTargetX, CANVAS_PADDING, maxX);
const clampedY = clamp(zoomTargetY, CANVAS_PADDING, maxY);
const zoomCenterX = clampedX + zoomWidth / 2;
const zoomCenterY = clampedY + zoomHeight / 2;
return {
zoomWidth,
zoomHeight,
zoomCenterX,
zoomCenterY,
};
},
[canvasSize.width, canvasSize.height, resolvePreviewDimensions],
);
useEffect(() => {
if (overlayDocId && !documentLookup.has(overlayDocId)) {
setOverlayDocId(null);
}
}, [overlayDocId, documentLookup]);
const resolveBaseMetrics = useCallback(
(doc, cardWidth, cardHeight) => {
@@ -1009,17 +1030,19 @@ const SkeuomorphicWorkspace = ({
[syncLayoutSnapshot],
);
const toggleZoom = useCallback(
const openOverlayForDoc = useCallback(
(docId) => {
setZoomedId((current) => {
if (current === docId) {
return null;
}
bringToFront(docId);
return docId;
});
if (!docId) {
return;
}
const snapshot = previewSnapshots.get(docId);
if (!snapshot || !snapshot.url) {
return;
}
bringToFront(docId);
setOverlayDocId(docId);
},
[bringToFront],
[bringToFront, previewSnapshots],
);
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = useDocumentDrag({
layoutRef,
@@ -1031,7 +1054,7 @@ const SkeuomorphicWorkspace = ({
setDraggingId,
syncLayoutSnapshot,
canvasSize,
toggleZoom,
openOverlayForDoc,
});
const handleTagDragEnterDoc = useCallback(
@@ -1485,14 +1508,15 @@ const SkeuomorphicWorkspace = ({
);
return (
<div className="skeuo-shell">
<div
className="skeuo-canvas"
ref={containerRef}
onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop}
>
<>
<div className="skeuo-shell">
<div
className="skeuo-canvas"
ref={containerRef}
onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop}
>
{items.length === 0 ? (
<div className="skeuo-empty">
<p>No documents to show here yet. Drop files to make this space come alive.</p>
@@ -1513,31 +1537,17 @@ const SkeuomorphicWorkspace = ({
typeof layout.centerX === 'number' ? layout.centerX : fallbackLayout.centerX;
const layoutCenterY =
typeof layout.centerY === 'number' ? layout.centerY : fallbackLayout.centerY;
const isZoomed = zoomedId === doc.id;
const { zoomWidth, zoomHeight, zoomCenterX, zoomCenterY } = resolveZoomMetrics(
doc,
cardWidth,
cardHeight,
);
const targetCenterX = isZoomed ? zoomCenterX : layoutCenterX;
const targetCenterY = isZoomed ? zoomCenterY : layoutCenterY;
const rotation = isZoomed ? 0 : layout.rotation || 0;
const zoomScale = isZoomed
? Math.min(
1,
Number.isFinite(zoomWidth / baseWidth) ? zoomWidth / baseWidth : 1,
Number.isFinite(zoomHeight / baseHeight) ? zoomHeight / baseHeight : 1,
)
: baseScale;
const rotation = layout.rotation || 0;
const zoomScale = baseScale;
const transform = formatTransform(
Math.round(targetCenterX),
Math.round(targetCenterY),
Math.round(layoutCenterX),
Math.round(layoutCenterY),
rotation,
zoomScale,
);
const style = {
transform,
zIndex: isZoomed ? 9999 : layout.z ?? 1,
zIndex: layout.z ?? 1,
};
const bodyStyle = {
width: Math.round(baseWidth),
@@ -1558,7 +1568,6 @@ const SkeuomorphicWorkspace = ({
const dropPending = pendingTagDocId === doc.id;
const itemClasses = ['skeuo-item'];
if (dragging) itemClasses.push('is-dragging');
if (isZoomed) itemClasses.push('is-zoomed');
if (dropActive) itemClasses.push('is-tag-target');
if (dropPending) itemClasses.push('is-tag-pending');
if (!matchesFilter) itemClasses.push('is-filtered-out');
@@ -1579,9 +1588,7 @@ const SkeuomorphicWorkspace = ({
itemRefs.current.delete(doc.id);
}
}}
onPointerDown={(event) =>
handlePointerDown(event, doc.id, { lockWhenZoomed: isZoomed })
}
onPointerDown={(event) => handlePointerDown(event, doc.id)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
@@ -1603,6 +1610,7 @@ const SkeuomorphicWorkspace = ({
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
navScale={inverseTagScale}
onNavigatorSnapshot={handleNavigatorSnapshot}
/>
{tags.length > 0 && (
<div className="skeuo-item__tags" aria-hidden="true" style={tagsStyle}>
@@ -1648,8 +1656,14 @@ const SkeuomorphicWorkspace = ({
);
})
)}
</div>
</div>
</div>
<PreviewZoomOverlay
open={Boolean(overlayDisplay?.url)}
display={overlayDisplay}
onClose={closeOverlay}
/>
</>
);
};