Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e351638e52 | ||
|
|
90a642c1c5 |
@@ -1,4 +1,4 @@
|
|||||||
/* Skeuomorphic workspace styles */
|
/* Desktop workspace styles */
|
||||||
.skeuo-main {
|
.skeuo-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
const best = candidates.reduce((acc, scale) => {
|
||||||
|
const scaledWidth = w * scale;
|
||||||
|
const scaledHeight = h * scale;
|
||||||
|
const violation = Math.max(
|
||||||
|
Math.max(CARD_MIN - scaledWidth, 0),
|
||||||
|
Math.max(scaledWidth - CARD_MAX, 0),
|
||||||
|
Math.max(CARD_MIN - scaledHeight, 0),
|
||||||
|
Math.max(scaledHeight - CARD_MAX, 0),
|
||||||
|
);
|
||||||
|
const deviation = Math.abs(scale - 1);
|
||||||
|
if (!acc || violation < acc.violation || (violation === acc.violation && deviation < acc.deviation)) {
|
||||||
|
return { scale, violation, deviation };
|
||||||
}
|
}
|
||||||
return Math.max(minWidth / currentWidth, minHeight / currentHeight);
|
return acc;
|
||||||
};
|
}, null);
|
||||||
|
|
||||||
// Step 1: scale down so both dimensions fit within the maximum bounds.
|
|
||||||
const downScale = scaleToFit(CARD_MAX, CARD_MAX, w, h);
|
|
||||||
w *= downScale;
|
|
||||||
h *= downScale;
|
|
||||||
|
|
||||||
// Step 2: if the card is too small, scale up (preserving ratio).
|
|
||||||
const upScale = scaleToFill(CARD_MIN, CARD_MIN, w, h);
|
|
||||||
w *= upScale;
|
|
||||||
h *= upScale;
|
|
||||||
|
|
||||||
// Step 3: Re-apply upper bounds if the scale-up pushed us over.
|
|
||||||
if (w > CARD_MAX || h > CARD_MAX) {
|
|
||||||
const adjust = scaleToFit(CARD_MAX, CARD_MAX, w, h);
|
|
||||||
w *= adjust;
|
|
||||||
h *= adjust;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return null;
|
||||||
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 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;
|
||||||
setDocSizeVersion((value) => value + 1);
|
const next = new Map(current);
|
||||||
}, [items]);
|
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);
|
||||||
|
}
|
||||||
|
}, [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}
|
||||||
|
|||||||
@@ -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,13 +168,13 @@ 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) {
|
return;
|
||||||
console.log('[skeuo] handlePointerMove: movement under threshold for doc', state.docId);
|
|
||||||
}
|
}
|
||||||
return;
|
bringToFront(state.docId);
|
||||||
|
state.moved = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = { ...entry, centerX: clampedCenterX, centerY: clampedCenterY };
|
const updated = { ...entry, centerX: clampedCenterX, centerY: clampedCenterY };
|
||||||
@@ -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;
|
finishDrag(event.pointerId);
|
||||||
const shouldOpen = !moved && event.detail >= 2;
|
return;
|
||||||
finishDrag(event.pointerId);
|
|
||||||
if (shouldOpen) {
|
|
||||||
const originInfo = {
|
|
||||||
rotation: state.rotation || 0,
|
|
||||||
scale: state.baseScale || 1,
|
|
||||||
width: state.width,
|
|
||||||
height: state.height,
|
|
||||||
};
|
|
||||||
openOverlayForDoc(docId, originInfo);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const docId = state.docId;
|
||||||
|
bringToFront(docId);
|
||||||
|
const originInfo = {
|
||||||
|
rotation: state.rotation || 0,
|
||||||
|
scale: state.baseScale || 1,
|
||||||
|
width: state.width,
|
||||||
|
height: state.height,
|
||||||
|
};
|
||||||
|
finishDrag(event.pointerId);
|
||||||
|
openOverlayForDoc(docId, originInfo);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
finishDrag(event.pointerId);
|
finishDrag(event.pointerId);
|
||||||
},
|
},
|
||||||
[finishDrag, openOverlayForDoc],
|
[bringToFront, finishDrag, openOverlayForDoc],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePointerCancel = useCallback(
|
const handlePointerCancel = useCallback(
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user