previewzoomoverlay
This commit is contained in:
+127
-113
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user