2 Commits
Author SHA1 Message Date
nils e351638e52 frontend 2025-10-28 12:34:43 +01:00
nils 90a642c1c5 desktop view 2025-10-28 12:17:20 +01:00
5 changed files with 370 additions and 136 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
/* Skeuomorphic workspace styles */ /* Desktop workspace styles */
.skeuo-main { .skeuo-main {
flex: 1; flex: 1;
display: flex; display: flex;
+239 -83
View File
@@ -32,7 +32,6 @@ const resolveSizeKey = (doc) =>
const CARD_MIN = 240; const CARD_MIN = 240;
const CARD_MAX = 340; const CARD_MAX = 340;
const EMPTY_CARD_ASPECT = 1.4;
const TAG_REMOVE_DISTANCE = 160; const TAG_REMOVE_DISTANCE = 160;
const DEBUG_DRAG = false; const DEBUG_DRAG = false;
@@ -83,7 +82,7 @@ const resolveTagKey = (tag) => {
return key != null ? String(key) : null; return key != null ? String(key) : null;
}; };
const SkeuoPreviewCard = ({ const DesktopPreviewCard = ({
doc, doc,
title, title,
ensureAssetUrl, ensureAssetUrl,
@@ -359,6 +358,87 @@ const cleanupPreview = (previewNode) => {
} }
}; };
const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => {
const [metadataMap, setMetadataMap] = useState(() => new Map());
useEffect(() => {
let cancelled = false;
const docs = Array.isArray(documents) ? documents : [];
if (!docs.length) {
setMetadataMap(new Map());
return () => {
cancelled = true;
};
}
const fetchMetadataForDoc = async (doc) => {
if (!doc?.id) {
return null;
}
const docId = String(doc.id);
const resolveAsset = (type) => (typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, type) : null);
let asset = resolveAsset('preview') || resolveAsset('thumbnail');
let view = createAssetView(asset);
let metadata = view.getPrimaryMetadata();
const hasDimensions = (meta) =>
Number.isFinite(Number(meta?.width)) && Number.isFinite(Number(meta?.height)) &&
Number(meta.width) > 0 && Number(meta.height) > 0;
if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) {
try {
const ensured = await ensureAssetUrl(doc.id, asset, { start: 1, limit: 1 });
if (ensured) {
asset = ensured;
view = createAssetView(asset);
metadata = view.getPrimaryMetadata();
}
} catch (
// eslint-disable-next-line no-empty
error
) {}
}
if (!hasDimensions(metadata)) {
return null;
}
const width = Number(metadata.width);
const height = Number(metadata.height);
if (!Number.isFinite(width) || !Number.isFinite(height)) {
return null;
}
return [docId, { width, height }];
};
Promise.all(docs.map((doc) => fetchMetadataForDoc(doc)))
.then((entries) => {
if (cancelled) return;
const next = new Map();
entries.forEach((entry) => {
if (entry) {
next.set(entry[0], entry[1]);
}
});
setMetadataMap(next);
})
.catch(() => {
if (!cancelled) {
setMetadataMap(new Map());
}
});
return () => {
cancelled = true;
};
}, [documents, getDocumentAsset, ensureAssetUrl]);
return metadataMap;
};
const seededRandom = (input) => { const seededRandom = (input) => {
const text = String(input); const text = String(input);
let hash = 2166136261; let hash = 2166136261;
@@ -379,50 +459,66 @@ const randomRangeFromSeed = (seedKey, min, max) => {
const buildKey = (docId, suffix) => `${docId}::${suffix}`; const buildKey = (docId, suffix) => `${docId}::${suffix}`;
const clampCardDimensions = (width, height) => { const clampCardDimensions = (width, height) => {
let w = Number(width); const w = Number(width);
let h = Number(height); const h = Number(height);
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
return null; return null;
} }
const scaleToFit = (maxWidth, maxHeight, currentWidth, currentHeight) => { const low = Math.max(CARD_MIN / w, CARD_MIN / h);
if (currentWidth <= maxWidth && currentHeight <= maxHeight) { const high = Math.min(CARD_MAX / w, CARD_MAX / h);
return 1;
const candidates = [];
const addCandidate = (scale) => {
if (Number.isFinite(scale) && scale > 0) {
candidates.push(scale);
} }
return Math.min(maxWidth / currentWidth, maxHeight / currentHeight);
}; };
const scaleToFill = (minWidth, minHeight, currentWidth, currentHeight) => { addCandidate(1);
if (currentWidth >= minWidth && currentHeight >= minHeight) { addCandidate(low);
return 1; addCandidate(high);
}
return Math.max(minWidth / currentWidth, minHeight / currentHeight);
};
// Step 1: scale down so both dimensions fit within the maximum bounds. const best = candidates.reduce((acc, scale) => {
const downScale = scaleToFit(CARD_MAX, CARD_MAX, w, h); const scaledWidth = w * scale;
w *= downScale; const scaledHeight = h * scale;
h *= downScale; const violation = Math.max(
Math.max(CARD_MIN - scaledWidth, 0),
// Step 2: if the card is too small, scale up (preserving ratio). Math.max(scaledWidth - CARD_MAX, 0),
const upScale = scaleToFill(CARD_MIN, CARD_MIN, w, h); Math.max(CARD_MIN - scaledHeight, 0),
w *= upScale; Math.max(scaledHeight - CARD_MAX, 0),
h *= upScale; );
const deviation = Math.abs(scale - 1);
// Step 3: Re-apply upper bounds if the scale-up pushed us over. if (!acc || violation < acc.violation || (violation === acc.violation && deviation < acc.deviation)) {
if (w > CARD_MAX || h > CARD_MAX) { return { scale, violation, deviation };
const adjust = scaleToFit(CARD_MAX, CARD_MAX, w, h);
w *= adjust;
h *= adjust;
} }
return acc;
}, null);
const scale = best ? best.scale : 1;
return { return {
width: w, width: Math.round(w * scale),
height: h, height: Math.round(h * scale),
}; };
}; };
const computeFallbackCardSize = (docId) => {
const baseSeed = seededRandom(`${docId}:fallback-size`);
const aspectSeed = seededRandom(`${docId}:fallback-aspect`);
const width = CARD_MIN + baseSeed * (CARD_MAX - CARD_MIN);
const isPortrait = aspectSeed < 0.5;
const normalizedSeed = isPortrait ? aspectSeed / 0.5 : (aspectSeed - 0.5) / 0.5;
const aspectRange = 0.75; // keeps generated ratio pleasant but varied
const aspect = isPortrait
? 1 + normalizedSeed * aspectRange
: 1 / (1 + normalizedSeed * aspectRange);
const height = width * aspect;
return clampCardDimensions(width, height);
};
const DesktopWorkspace = ({ const DesktopWorkspace = ({
documents = [], documents = [],
searchResults = null, searchResults = null,
@@ -459,6 +555,7 @@ const DesktopWorkspace = ({
const docSizeMapRef = useRef(new Map()); const docSizeMapRef = useRef(new Map());
const documentLookupRef = useRef(new Map()); const documentLookupRef = useRef(new Map());
const removalCursorActiveRef = useRef(false); const removalCursorActiveRef = useRef(false);
const previewMetadata = usePreviewMetadata(items, getDocumentAsset, ensureAssetUrl);
const documentLookup = useMemo(() => { const documentLookup = useMemo(() => {
const map = new Map(); const map = new Map();
items.forEach((doc) => { items.forEach((doc) => {
@@ -473,7 +570,26 @@ const DesktopWorkspace = ({
useEffect(() => { useEffect(() => {
documentLookupRef.current = documentLookup; documentLookupRef.current = documentLookup;
}, [documentLookup]); }, [documentLookup]);
const applySnapshotDimensions = useCallback(() => {}, []);
const applySnapshotDimensions = useCallback((docKey, snapshot) => {
const width = Number(snapshot?.width);
const height = Number(snapshot?.height);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return;
}
const normalized = clampCardDimensions(width, height);
if (!normalized) {
return;
}
const existing = docSizeMapRef.current.get(docKey);
if (existing && existing.width === normalized.width && existing.height === normalized.height) {
return;
}
const next = new Map(docSizeMapRef.current);
next.set(docKey, { ...normalized, source: 'snapshot' });
docSizeMapRef.current = next;
setDocSizeVersion((value) => value + 1);
}, []);
const handleNavigatorSnapshot = useCallback( const handleNavigatorSnapshot = useCallback(
(docId, snapshot) => { (docId, snapshot) => {
const docKey = docId != null ? String(docId) : null; const docKey = docId != null ? String(docId) : null;
@@ -530,29 +646,14 @@ const DesktopWorkspace = ({
return set; return set;
}, [activeTagIds]); }, [activeTagIds]);
const resolvePreviewAsset = useCallback(
(doc) => {
if (!doc) return null;
return getDocumentAsset(doc, 'thumbnail');
},
[getDocumentAsset],
);
const resolvePreviewDimensions = useCallback( const resolvePreviewDimensions = useCallback(
(doc) => { (doc) => {
if (!doc) return null; if (!doc?.id) {
const asset = resolvePreviewAsset(doc);
const view = createAssetView(asset);
const metadata = view.getPrimaryMetadata() || {};
const width = metadata?.width;
const height = metadata?.height;
if (typeof width === 'number' && typeof height === 'number') {
return { width, height };
}
return null; return null;
}
return previewMetadata.get(String(doc.id)) || null;
}, },
[resolvePreviewAsset], [previewMetadata],
); );
useEffect(() => { useEffect(() => {
@@ -661,37 +762,60 @@ const DesktopWorkspace = ({
}, [updateRemovalCursor]); }, [updateRemovalCursor]);
const ensureDocumentSize = useCallback((doc) => { const ensureDocumentSize = useCallback((doc) => {
const key = resolveSizeKey(doc); if (!doc?.id) {
const cache = docSizeMapRef.current.get(key); return null;
if (cache) {
return cache;
} }
const intrinsic = resolvePreviewDimensions(doc); return docSizeMapRef.current.get(String(doc.id)) || null;
let normalized = null; }, []);
if (intrinsic?.width && intrinsic?.height) {
normalized = clampCardDimensions(intrinsic.width, intrinsic.height);
}
if (!normalized) {
const seed = seededRandom(`${key}:size`);
let width = CARD_MIN + seed * (Math.min(CARD_MAX, CARD_MAX / EMPTY_CARD_ASPECT) - CARD_MIN);
width = clamp(width, CARD_MIN, Math.min(CARD_MAX, CARD_MAX / EMPTY_CARD_ASPECT));
const height = width * EMPTY_CARD_ASPECT;
normalized = clampCardDimensions(width, height);
}
if (!normalized) {
normalized = { width: CARD_MIN, height: CARD_MIN };
}
docSizeMapRef.current.set(key, normalized);
return normalized;
}, [resolvePreviewDimensions]);
useEffect(() => { useEffect(() => {
docSizeMapRef.current = new Map(); const current = docSizeMapRef.current;
const next = new Map(current);
const itemKeys = new Set(items.filter((doc) => doc?.id != null).map((doc) => String(doc.id)));
let changed = false;
items.forEach((doc) => {
if (!doc?.id) {
return;
}
const key = String(doc.id);
const existing = next.get(key) || null;
const meta = previewMetadata.get(key);
if (meta) {
const normalized = clampCardDimensions(meta.width, meta.height);
if (normalized) {
if (existing?.source === 'snapshot') {
return;
}
if (!existing || existing.width !== normalized.width || existing.height !== normalized.height || existing.source !== 'metadata') {
next.set(key, { ...normalized, source: 'metadata' });
changed = true;
}
return;
}
}
if (!existing) {
const fallback = computeFallbackCardSize(key);
if (fallback) {
next.set(key, { ...fallback, source: 'fallback' });
changed = true;
}
}
});
current.forEach((_, key) => {
if (!itemKeys.has(key)) {
next.delete(key);
changed = true;
}
});
if (changed) {
docSizeMapRef.current = next;
setDocSizeVersion((value) => value + 1); setDocSizeVersion((value) => value + 1);
}, [items]); }
}, [items, previewMetadata]);
const overlayDisplay = useMemo(() => { const overlayDisplay = useMemo(() => {
if (!overlayDocId) { if (!overlayDocId) {
@@ -867,7 +991,11 @@ const recalcVisibleDocIds = useCallback(() => {
if (!doc) { if (!doc) {
return; return;
} }
const { width: cardWidth, height: cardHeight } = ensureDocumentSize(doc); const sizeInfo = ensureDocumentSize(doc);
if (!sizeInfo) {
return;
}
const { width: cardWidth, height: cardHeight } = sizeInfo;
const rotationDeg = Number(entry?.rotation) || 0; const rotationDeg = Number(entry?.rotation) || 0;
const rotationRad = (rotationDeg * Math.PI) / 180; const rotationRad = (rotationDeg * Math.PI) / 180;
const cosRot = Math.cos(rotationRad); const cosRot = Math.cos(rotationRad);
@@ -1015,6 +1143,11 @@ const syncLayoutSnapshot = useCallback(() => {
return; return;
} }
const missingSizes = items.some((doc) => !ensureDocumentSize(doc));
if (missingSizes) {
return;
}
const previous = layoutRef.current; const previous = layoutRef.current;
const next = new Map(); const next = new Map();
let maxZ = zCounterRef.current; let maxZ = zCounterRef.current;
@@ -1023,7 +1156,11 @@ const syncLayoutSnapshot = useCallback(() => {
const docsNeedingLayout = []; const docsNeedingLayout = [];
items.forEach((doc) => { items.forEach((doc) => {
const { width: docWidth, height: docHeight } = ensureDocumentSize(doc); const sizeInfo = ensureDocumentSize(doc);
if (!sizeInfo) {
return;
}
const { width: docWidth, height: docHeight } = sizeInfo;
const halfWidth = docWidth / 2; const halfWidth = docWidth / 2;
const halfHeight = docHeight / 2; const halfHeight = docHeight / 2;
@@ -1141,7 +1278,11 @@ const syncLayoutSnapshot = useCallback(() => {
if (!originTransform) { if (!originTransform) {
const entry = layoutRef.current.get(docId) || null; const entry = layoutRef.current.get(docId) || null;
const doc = documentLookup.get(docKey) || null; const doc = documentLookup.get(docKey) || null;
const { width: cardWidth, height: cardHeight } = ensureDocumentSize(doc); const sizeInfo = ensureDocumentSize(doc);
if (!sizeInfo) {
return;
}
const { width: cardWidth, height: cardHeight } = sizeInfo;
const { baseWidth, baseHeight, baseScale } = resolveBaseMetrics(doc, cardWidth, cardHeight); const { baseWidth, baseHeight, baseScale } = resolveBaseMetrics(doc, cardWidth, cardHeight);
const effectiveWidth = baseWidth * baseScale; const effectiveWidth = baseWidth * baseScale;
const effectiveHeight = baseHeight * baseScale; const effectiveHeight = baseHeight * baseScale;
@@ -1620,6 +1761,7 @@ const syncLayoutSnapshot = useCallback(() => {
handleCanvasDragLeave, handleCanvasDragLeave,
handleCanvasDrop, handleCanvasDrop,
layoutSnapshot, layoutSnapshot,
docSizeVersion,
visibleDocIds, visibleDocIds,
draggingId, draggingId,
tagDropTargetId, tagDropTargetId,
@@ -1669,6 +1811,7 @@ const syncLayoutSnapshot = useCallback(() => {
items, items,
layoutRef, layoutRef,
layoutSnapshot, layoutSnapshot,
docSizeVersion,
onDocumentOpen, onDocumentOpen,
openOverlayForDoc, openOverlayForDoc,
overlayDisplay, overlayDisplay,
@@ -1726,11 +1869,17 @@ const DesktopWorkspaceView = () => {
closeOverlay, closeOverlay,
overlayOriginRect, overlayOriginRect,
overlayOriginTransform, overlayOriginTransform,
docSizeVersion,
} = useDesktopContext(); } = useDesktopContext();
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } =
useDocumentDrag(); useDocumentDrag();
const allSizesReady = useMemo(
() => items.every((doc) => ensureDocumentSize(doc)),
[items, ensureDocumentSize, docSizeVersion],
);
return ( return (
<> <>
<div className="skeuo-shell"> <div className="skeuo-shell">
@@ -1741,13 +1890,20 @@ const DesktopWorkspaceView = () => {
onDragLeave={handleCanvasDragLeave} onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop} onDrop={handleCanvasDrop}
> >
{items.length === 0 ? ( {!allSizesReady ? (
<div className="skeuo-empty">
<p>Loading previews</p>
</div>
) : items.length === 0 ? (
<div className="skeuo-empty"> <div className="skeuo-empty">
<p>No documents to show here yet. Drop files to make this space come alive.</p> <p>No documents to show here yet. Drop files to make this space come alive.</p>
</div> </div>
) : ( ) : (
items.map((doc) => { items.map((doc) => {
const sizeInfo = ensureDocumentSize(doc); const sizeInfo = ensureDocumentSize(doc);
if (!sizeInfo) {
return null;
}
const { width: cardWidth, height: cardHeight } = sizeInfo; const { width: cardWidth, height: cardHeight } = sizeInfo;
const layout = layoutSnapshot.get(doc.id) ?? layoutRef.current.get(doc.id); const layout = layoutSnapshot.get(doc.id) ?? layoutRef.current.get(doc.id);
if ( if (
@@ -1824,7 +1980,7 @@ const DesktopWorkspaceView = () => {
}} }}
> >
<div className="skeuo-item__body"> <div className="skeuo-item__body">
<SkeuoPreviewCard <DesktopPreviewCard
doc={doc} doc={doc}
title={title} title={title}
ensureAssetUrl={ensureAssetUrl} ensureAssetUrl={ensureAssetUrl}
+18 -15
View File
@@ -3,6 +3,9 @@ import { useDesktopContext } from './context';
import { preventAll } from './events'; import { preventAll } from './events';
import { clamp, formatTransform } from './math'; import { clamp, formatTransform } from './math';
const DRAG_HYSTERESIS_PX = 4;
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
const useDocumentDrag = () => { const useDocumentDrag = () => {
const { const {
layoutRef, layoutRef,
@@ -59,7 +62,6 @@ const useDocumentDrag = () => {
); );
} }
preventAll(event); preventAll(event);
const entry = layoutRef.current.get(docId) || null;
const docKey = docId != null ? String(docId) : null; const docKey = docId != null ? String(docId) : null;
const doc = docKey ? documentLookup.get(docKey) : null; const doc = docKey ? documentLookup.get(docKey) : null;
const { width: docWidth, height: docHeight } = ensureDocumentSize(doc); const { width: docWidth, height: docHeight } = ensureDocumentSize(doc);
@@ -68,6 +70,7 @@ const useDocumentDrag = () => {
Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1; Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1;
const defaultCenterX = canvasPadding + docWidth / 2; const defaultCenterX = canvasPadding + docWidth / 2;
const defaultCenterY = canvasPadding + docHeight / 2; const defaultCenterY = canvasPadding + docHeight / 2;
const entry = layoutRef.current.get(docId) || null;
const centerX = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX; const centerX = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX;
const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY; const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY;
@@ -75,7 +78,6 @@ const useDocumentDrag = () => {
layoutRef.current.set(docId, { ...entry, centerX, centerY }); layoutRef.current.set(docId, { ...entry, centerX, centerY });
} }
bringToFront(docId);
const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null; const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
if (capturedTarget && typeof capturedTarget.setPointerCapture === 'function') { if (capturedTarget && typeof capturedTarget.setPointerCapture === 'function') {
try { try {
@@ -166,14 +168,14 @@ const useDocumentDrag = () => {
const clampedCenterX = clamp(nextCenterX, minCenterX, maxCenterX); const clampedCenterX = clamp(nextCenterX, minCenterX, maxCenterX);
const clampedCenterY = clamp(nextCenterY, minCenterY, maxCenterY); const clampedCenterY = clamp(nextCenterY, minCenterY, maxCenterY);
const prevCenterX = typeof entry.centerX === 'number' ? entry.centerX : state.originCenterX; if (!state.moved) {
const prevCenterY = typeof entry.centerY === 'number' ? entry.centerY : state.originCenterY; const distanceSquared = deltaX * deltaX + deltaY * deltaY;
if (Math.abs(clampedCenterX - prevCenterX) < 0.5 && Math.abs(clampedCenterY - prevCenterY) < 0.5) { if (distanceSquared < DRAG_HYSTERESIS_SQUARED) {
if (debugDrag) {
console.log('[skeuo] handlePointerMove: movement under threshold for doc', state.docId);
}
return; return;
} }
bringToFront(state.docId);
state.moved = true;
}
const updated = { ...entry, centerX: clampedCenterX, centerY: clampedCenterY }; const updated = { ...entry, centerX: clampedCenterX, centerY: clampedCenterY };
layoutRef.current.set(state.docId, updated); layoutRef.current.set(state.docId, updated);
@@ -187,7 +189,6 @@ const useDocumentDrag = () => {
state.dragScale || 1, state.dragScale || 1,
); );
} }
state.moved = true;
if (debugDrag) { if (debugDrag) {
console.log('[skeuo] handlePointerMove: moved doc', state.docId, 'to', clampedCenterX, clampedCenterY); console.log('[skeuo] handlePointerMove: moved doc', state.docId, 'to', clampedCenterX, clampedCenterY);
} }
@@ -210,24 +211,26 @@ const useDocumentDrag = () => {
(event) => { (event) => {
const state = dragStateRef.current; const state = dragStateRef.current;
if (state && state.pointerId === event.pointerId) { if (state && state.pointerId === event.pointerId) {
const moved = Boolean(state.moved); if (state.moved) {
const docId = state.docId;
const shouldOpen = !moved && event.detail >= 2;
finishDrag(event.pointerId); finishDrag(event.pointerId);
if (shouldOpen) { return;
}
const docId = state.docId;
bringToFront(docId);
const originInfo = { const originInfo = {
rotation: state.rotation || 0, rotation: state.rotation || 0,
scale: state.baseScale || 1, scale: state.baseScale || 1,
width: state.width, width: state.width,
height: state.height, height: state.height,
}; };
finishDrag(event.pointerId);
openOverlayForDoc(docId, originInfo); openOverlayForDoc(docId, originInfo);
}
return; return;
} }
finishDrag(event.pointerId); finishDrag(event.pointerId);
}, },
[finishDrag, openOverlayForDoc], [bringToFront, finishDrag, openOverlayForDoc],
); );
const handlePointerCancel = useCallback( const handlePointerCancel = useCallback(
+93 -27
View File
@@ -25,10 +25,68 @@ const PreviewZoomOverlay = ({
const portalTarget = ensureDocumentRoot(); const portalTarget = ensureDocumentRoot();
const [isNativeScale, setIsNativeScale] = useState(false); const [isNativeScale, setIsNativeScale] = useState(false);
const [naturalSize, setNaturalSize] = useState({ width: null, height: null }); const [naturalSize, setNaturalSize] = useState({ width: null, height: null });
const [renderBackdrop, setRenderBackdrop] = useState(false);
const [isBackdropVisible, setBackdropVisible] = useState(false);
const [displaySnapshot, setDisplaySnapshot] = useState(null);
const scrollRef = useRef(null); const scrollRef = useRef(null);
const imageRef = useRef(null); const imageRef = useRef(null);
const focusRef = useRef(null); const focusRef = useRef(null);
const previouslyFocusedRef = useRef(null); const previouslyFocusedRef = useRef(null);
const visibilityTimerRef = useRef(null);
const displayTimerRef = useRef(null);
useEffect(() => {
if (display?.url) {
setDisplaySnapshot(display);
}
}, [display]);
useEffect(() => {
if (visibilityTimerRef.current) {
clearTimeout(visibilityTimerRef.current);
visibilityTimerRef.current = null;
}
if (displayTimerRef.current) {
cancelAnimationFrame(displayTimerRef.current);
displayTimerRef.current = null;
}
if (open && display?.url) {
setRenderBackdrop(true);
displayTimerRef.current = requestAnimationFrame(() => {
displayTimerRef.current = requestAnimationFrame(() => {
setBackdropVisible(true);
});
});
return () => {
if (displayTimerRef.current) {
cancelAnimationFrame(displayTimerRef.current);
displayTimerRef.current = null;
}
};
}
setBackdropVisible(false);
visibilityTimerRef.current = setTimeout(() => {
setRenderBackdrop(false);
}, 260);
return () => {
if (visibilityTimerRef.current) {
clearTimeout(visibilityTimerRef.current);
visibilityTimerRef.current = null;
}
};
}, [open, display?.url]);
useEffect(() => () => {
if (visibilityTimerRef.current) {
clearTimeout(visibilityTimerRef.current);
}
if (displayTimerRef.current) {
cancelAnimationFrame(displayTimerRef.current);
}
}, []);
useEffect(() => { useEffect(() => {
setIsNativeScale(false); setIsNativeScale(false);
@@ -75,7 +133,7 @@ const PreviewZoomOverlay = ({
previouslyFocusedRef.current.focus(); previouslyFocusedRef.current.focus();
} }
previouslyFocusedRef.current = null; previouslyFocusedRef.current = null;
return undefined; return;
} }
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
@@ -86,24 +144,24 @@ const PreviewZoomOverlay = ({
previouslyFocusedRef.current = null; previouslyFocusedRef.current = null;
} }
} }
}, [open]);
const scrollEl = scrollRef.current; const activeDisplay = open && display?.url ? display : displaySnapshot;
if (!scrollEl) {
useEffect(() => {
if (!renderBackdrop || !activeDisplay?.url) {
return undefined; return undefined;
} }
const frame = requestAnimationFrame(() => { const frame = requestAnimationFrame(() => {
scrollEl.focus(); const scrollEl = scrollRef.current;
if (scrollEl && typeof scrollEl.focus === 'function') {
scrollEl.focus({ preventScroll: true });
}
}); });
return () => { return () => cancelAnimationFrame(frame);
cancelAnimationFrame(frame); }, [renderBackdrop, activeDisplay?.url]);
if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') {
previouslyFocusedRef.current.focus();
previouslyFocusedRef.current = null;
}
};
}, [open]);
const handleKeyDown = (event) => { const handleKeyDown = (event) => {
event.stopPropagation(); event.stopPropagation();
@@ -119,26 +177,27 @@ const PreviewZoomOverlay = ({
} }
if (event.key === 'ArrowLeft') { if (event.key === 'ArrowLeft') {
if (display?.canGoPrev && display?.goPrev) { if (activeDisplay?.canGoPrev && activeDisplay?.goPrev) {
event.preventDefault(); event.preventDefault();
display.goPrev(); activeDisplay.goPrev();
} }
return; return;
} }
if (event.key === 'ArrowRight') { if (event.key === 'ArrowRight') {
if (display?.canGoNext && display?.goNext) { if (activeDisplay?.canGoNext && activeDisplay?.goNext) {
event.preventDefault(); event.preventDefault();
display.goNext(); activeDisplay.goNext();
} }
} }
}; };
if (!open || !display?.url || !portalTarget) { if (!renderBackdrop || !activeDisplay?.url || !portalTarget) {
return null; return null;
} }
const navVisible = Boolean(display?.canGoPrev || display?.canGoNext); const effectiveDisplay = activeDisplay;
const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext);
const stageClassName = [ const stageClassName = [
'preview-zoom__stage', 'preview-zoom__stage',
] ]
@@ -152,6 +211,13 @@ const PreviewZoomOverlay = ({
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
const backdropClassName = [
'preview-zoom-backdrop',
isBackdropVisible ? 'preview-zoom-backdrop--visible' : '',
]
.filter(Boolean)
.join(' ');
const imageStyle = isNativeScale const imageStyle = isNativeScale
? { ? {
cursor: 'zoom-out', cursor: 'zoom-out',
@@ -169,7 +235,7 @@ const PreviewZoomOverlay = ({
return createPortal( return createPortal(
( (
<div <div
className="preview-zoom-backdrop" className={backdropClassName}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Enlarged document preview" aria-label="Enlarged document preview"
@@ -186,8 +252,8 @@ const PreviewZoomOverlay = ({
tabIndex={-1} tabIndex={-1}
> >
<img <img
src={display.url} src={effectiveDisplay.url}
alt={display.alt || 'Document preview'} alt={effectiveDisplay.alt || 'Document preview'}
className="preview-zoom__image" className="preview-zoom__image"
ref={imageRef} ref={imageRef}
draggable={false} draggable={false}
@@ -227,12 +293,12 @@ const PreviewZoomOverlay = ({
className="preview-zoom__nav-button" className="preview-zoom__nav-button"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
if (display?.canGoPrev && display?.goPrev) { if (effectiveDisplay?.canGoPrev && effectiveDisplay?.goPrev) {
display.goPrev(); effectiveDisplay.goPrev();
} }
}} }}
aria-label="Previous preview" aria-label="Previous preview"
disabled={!display?.canGoPrev} disabled={!effectiveDisplay?.canGoPrev}
> >
<ArrowLeftIcon /> <ArrowLeftIcon />
</button> </button>
@@ -241,12 +307,12 @@ const PreviewZoomOverlay = ({
className="preview-zoom__nav-button" className="preview-zoom__nav-button"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
if (display?.canGoNext && display?.goNext) { if (effectiveDisplay?.canGoNext && effectiveDisplay?.goNext) {
display.goNext(); effectiveDisplay.goNext();
} }
}} }}
aria-label="Next preview" aria-label="Next preview"
disabled={!display?.canGoNext} disabled={!effectiveDisplay?.canGoNext}
> >
<ArrowRightIcon /> <ArrowRightIcon />
</button> </button>
+10 -1
View File
@@ -244,13 +244,22 @@ button.danger:hover:not([disabled]) {
.preview-zoom-backdrop { .preview-zoom-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: var(--overlay-backdrop); background: rgba(15, 23, 42, 0);
transition: background 0.25s ease, opacity 0.25s ease;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 2rem; padding: 2rem;
z-index: 3000; z-index: 3000;
cursor: zoom-out; cursor: zoom-out;
opacity: 0;
pointer-events: none;
}
.preview-zoom-backdrop--visible {
opacity: 1;
background: var(--overlay-backdrop);
pointer-events: auto;
} }
.preview-zoom__stage { .preview-zoom__stage {