typescript
This commit is contained in:
+67
-53
@@ -1,27 +1,45 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { CSSProperties, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
|
||||
import { clamp } from '../utils/math';
|
||||
|
||||
type PreviewNavigator = {
|
||||
url: string;
|
||||
alt?: string;
|
||||
canGoPrev?: boolean;
|
||||
canGoNext?: boolean;
|
||||
goPrev?: () => void;
|
||||
goNext?: () => void;
|
||||
};
|
||||
|
||||
interface PreviewZoomOverlayProps {
|
||||
open?: boolean;
|
||||
display?: PreviewNavigator | null;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
type NaturalSize = { width: number | null; height: number | null };
|
||||
type FocusPoint = { xRatio: number; yRatio: number } | null;
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const PreviewZoomOverlay = ({
|
||||
const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
|
||||
open = false,
|
||||
display = null,
|
||||
onClose = noop,
|
||||
}) => {
|
||||
const portalTarget = document.body;
|
||||
const portalTarget = typeof document !== 'undefined' ? document.body : null;
|
||||
const [isNativeScale, setIsNativeScale] = useState(false);
|
||||
const [naturalSize, setNaturalSize] = useState({ width: null, height: null });
|
||||
const [naturalSize, setNaturalSize] = useState<NaturalSize>({ width: null, height: null });
|
||||
const [renderBackdrop, setRenderBackdrop] = useState(false);
|
||||
const [isBackdropVisible, setBackdropVisible] = useState(false);
|
||||
const [displaySnapshot, setDisplaySnapshot] = useState(null);
|
||||
const scrollRef = useRef(null);
|
||||
const imageRef = useRef(null);
|
||||
const focusRef = useRef(null);
|
||||
const previouslyFocusedRef = useRef(null);
|
||||
const visibilityTimerRef = useRef(null);
|
||||
const displayTimerRef = useRef(null);
|
||||
const [displaySnapshot, setDisplaySnapshot] = useState<PreviewNavigator | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const focusRef = useRef<FocusPoint>(null);
|
||||
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
||||
const visibilityTimerRef = useRef<number | null>(null);
|
||||
const displayTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (display?.url) {
|
||||
@@ -55,7 +73,7 @@ const PreviewZoomOverlay = ({
|
||||
}
|
||||
|
||||
setBackdropVisible(false);
|
||||
visibilityTimerRef.current = setTimeout(() => {
|
||||
visibilityTimerRef.current = window.setTimeout(() => {
|
||||
setRenderBackdrop(false);
|
||||
}, 260);
|
||||
|
||||
@@ -117,6 +135,9 @@ const PreviewZoomOverlay = ({
|
||||
}, [open, isNativeScale, naturalSize.width, naturalSize.height]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (!open) {
|
||||
previouslyFocusedRef.current?.focus?.();
|
||||
previouslyFocusedRef.current = null;
|
||||
@@ -124,7 +145,7 @@ const PreviewZoomOverlay = ({
|
||||
}
|
||||
|
||||
const active = document.activeElement;
|
||||
previouslyFocusedRef.current = active?.focus ? active : null;
|
||||
previouslyFocusedRef.current = active instanceof HTMLElement ? active : null;
|
||||
}, [open]);
|
||||
|
||||
const activeDisplay = open && display?.url ? display : displaySnapshot;
|
||||
@@ -154,7 +175,7 @@ const PreviewZoomOverlay = ({
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [renderBackdrop, activeDisplay?.url]);
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!open) {
|
||||
@@ -203,7 +224,7 @@ const PreviewZoomOverlay = ({
|
||||
}
|
||||
};
|
||||
|
||||
const toggleZoomAtPoint = (clientX, clientY) => {
|
||||
const toggleZoomAtPoint = (clientX: number, clientY: number) => {
|
||||
const img = imageRef.current;
|
||||
setIsNativeScale((current) => {
|
||||
if (!current && img) {
|
||||
@@ -221,7 +242,7 @@ const PreviewZoomOverlay = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleImageClick = (event) => {
|
||||
const handleImageClick = (event: React.MouseEvent<HTMLImageElement>) => {
|
||||
event.stopPropagation();
|
||||
toggleZoomAtPoint(event.clientX ?? 0, event.clientY ?? 0);
|
||||
};
|
||||
@@ -232,11 +253,7 @@ const PreviewZoomOverlay = ({
|
||||
|
||||
const effectiveDisplay = activeDisplay;
|
||||
const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext);
|
||||
const stageClassName = [
|
||||
'preview-zoom__stage',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const stageClassName = 'preview-zoom__stage';
|
||||
|
||||
const containerClassName = [
|
||||
'preview-zoom__scroll',
|
||||
@@ -252,7 +269,7 @@ const PreviewZoomOverlay = ({
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const imageStyle = isNativeScale
|
||||
const imageStyle: CSSProperties = isNativeScale
|
||||
? {
|
||||
cursor: 'zoom-out',
|
||||
width: naturalSize.width ? `${naturalSize.width}px` : 'auto',
|
||||
@@ -277,37 +294,34 @@ const PreviewZoomOverlay = ({
|
||||
aria-label="Enlarged document preview"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={stageClassName}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
className={containerClassName}
|
||||
ref={scrollRef}
|
||||
tabIndex={-1}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
src={effectiveDisplay.url}
|
||||
alt={effectiveDisplay.alt || 'Document preview'}
|
||||
className="preview-zoom__image"
|
||||
ref={imageRef}
|
||||
draggable={false}
|
||||
onLoad={(event) => {
|
||||
setNaturalSize({
|
||||
width: event.currentTarget.naturalWidth || null,
|
||||
height: event.currentTarget.naturalHeight || null,
|
||||
});
|
||||
}}
|
||||
onClick={handleImageClick}
|
||||
style={imageStyle}
|
||||
/>
|
||||
</div>
|
||||
{navVisible ? (
|
||||
<div className="preview-zoom__nav">
|
||||
<button
|
||||
type="button"
|
||||
className="preview-zoom__nav-button"
|
||||
<div className={stageClassName} onKeyDown={handleKeyDown}>
|
||||
<div
|
||||
className={containerClassName}
|
||||
ref={scrollRef}
|
||||
tabIndex={-1}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
src={effectiveDisplay.url}
|
||||
alt={effectiveDisplay.alt || 'Document preview'}
|
||||
className="preview-zoom__image"
|
||||
ref={imageRef}
|
||||
draggable={false}
|
||||
onLoad={(event) => {
|
||||
setNaturalSize({
|
||||
width: event.currentTarget.naturalWidth || null,
|
||||
height: event.currentTarget.naturalHeight || null,
|
||||
});
|
||||
}}
|
||||
onClick={handleImageClick}
|
||||
style={imageStyle}
|
||||
/>
|
||||
</div>
|
||||
{navVisible ? (
|
||||
<div className="preview-zoom__nav">
|
||||
<button
|
||||
type="button"
|
||||
className="preview-zoom__nav-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (effectiveDisplay?.canGoPrev && effectiveDisplay?.goPrev) {
|
||||
+71
-1
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||
import { useDetailPanel } from '../app/useDetailPanel';
|
||||
import {
|
||||
@@ -6,6 +7,75 @@ import {
|
||||
getRowId,
|
||||
isDocumentRowKey,
|
||||
} from '../app/appLayoutUtils';
|
||||
import type { DocumentInfoPanelProps } from '../documents/DocumentInfoPanel';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
interface DocumentLike {
|
||||
id?: Identifier;
|
||||
folder_id?: Identifier | 'root';
|
||||
title?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderNode {
|
||||
id: Identifier | 'root';
|
||||
name?: string;
|
||||
parentId?: Identifier | 'root';
|
||||
}
|
||||
|
||||
type PreviewEntry = {
|
||||
url?: string;
|
||||
canGoPrev?: boolean;
|
||||
canGoNext?: boolean;
|
||||
goPrev?: () => void;
|
||||
goNext?: () => void;
|
||||
} | null;
|
||||
|
||||
interface UseDetailWorkspaceArgs {
|
||||
documents: DocumentLike[];
|
||||
searchResults?: DocumentLike[] | null;
|
||||
previewDocuments?: Map<Identifier, DocumentLike> | null;
|
||||
selectionOrder: string[];
|
||||
selectedDocumentIds: Identifier[];
|
||||
documentLookup: Map<Identifier, DocumentLike>;
|
||||
folderNodes: Map<Identifier | 'root', FolderNode>;
|
||||
ensureFolderData: (folderId: Identifier | 'root', options?: { force?: boolean; includeDocuments?: boolean }) => Promise<void>;
|
||||
detailPanelControlRef: MutableRefObject<{ open?: (args?: { documentIds?: Identifier[] }) => void; close?: () => void } | null>;
|
||||
detailFolderFetchRef: MutableRefObject<Set<Identifier | 'root'>>;
|
||||
previewEntries: Map<Identifier, PreviewEntry>;
|
||||
previewDocumentId?: Identifier | null;
|
||||
activePreviewId?: Identifier | null;
|
||||
openDocumentPreview?: (args: { documentIds: Identifier[] }) => void;
|
||||
handleDocumentTitleUpdate?: (docId: Identifier, title: string) => Promise<boolean> | boolean;
|
||||
handleDocumentIssuedUpdate?: (docId: Identifier, issued: number | null) => Promise<boolean> | boolean;
|
||||
handleDocumentTagAdd?: (doc: DocumentLike, value: string, context?: { option?: unknown }) => void;
|
||||
handleTagRemove?: (...args: unknown[]) => void;
|
||||
ensureAssetUrl?: (...args: unknown[]) => unknown;
|
||||
getDocumentAsset?: (...args: unknown[]) => unknown;
|
||||
ensurePreviewData?: (docId: Identifier, options?: Record<string, unknown>) => Promise<DocumentLike | null | undefined>;
|
||||
correspondents?: unknown[];
|
||||
handleCorrespondentAdd?: (...args: unknown[]) => void;
|
||||
handleCorrespondentRemove?: (...args: unknown[]) => void;
|
||||
resolveApiPath?: (path: string) => string;
|
||||
selectFolder?: (folderId?: Identifier | 'root') => void;
|
||||
tags?: unknown[];
|
||||
tagLookupById?: Map<Identifier, unknown> | null;
|
||||
}
|
||||
|
||||
interface UseDetailWorkspaceResult {
|
||||
detailPanelProps: DocumentInfoPanelProps;
|
||||
detailPanelOpen: boolean;
|
||||
openDetailPanel: ReturnType<typeof useDetailPanel>['openDetailPanel'];
|
||||
closeDetailPanel: ReturnType<typeof useDetailPanel>['closeDetailPanel'];
|
||||
handleDetailPanelClose: () => void;
|
||||
inspectDocument: (docId: Identifier | null) => void;
|
||||
previewActive: boolean;
|
||||
previewWorkspaceDocument: DocumentLike | null;
|
||||
previewWorkspaceEntry: PreviewEntry;
|
||||
resolveThumbnailUrlForDoc: (doc: DocumentLike | null) => string | null;
|
||||
resolveFolderPath: (folderId?: Identifier | 'root') => Array<{ id: Identifier | 'root'; name: string }>;
|
||||
}
|
||||
|
||||
const useDetailWorkspace = ({
|
||||
documents,
|
||||
@@ -36,7 +106,7 @@ const useDetailWorkspace = ({
|
||||
selectFolder,
|
||||
tags,
|
||||
tagLookupById,
|
||||
}) => {
|
||||
}: UseDetailWorkspaceArgs): UseDetailWorkspaceResult => {
|
||||
const orderedSelectedDocuments = useMemo(() => {
|
||||
const ordered = [];
|
||||
const seen = new Set();
|
||||
Reference in New Issue
Block a user