typescript
This commit is contained in:
+65
-9
@@ -4,7 +4,46 @@ import { resolveCorrespondents } from '../documents/correspondents';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import { preventAll } from './events';
|
||||
|
||||
const DesktopDocumentCard = ({
|
||||
type DocumentLike = {
|
||||
id?: string | number;
|
||||
title?: string;
|
||||
tags?: Array<{ id?: string | number; label?: string; color?: string | null }>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
interface PendingRemovalTag {
|
||||
docId?: string | number;
|
||||
tagId?: string | number;
|
||||
}
|
||||
|
||||
interface DesktopDocumentCardProps {
|
||||
doc: DocumentLike;
|
||||
style?: React.CSSProperties;
|
||||
shouldLoad?: boolean;
|
||||
dragging?: boolean;
|
||||
matchesFilter?: boolean;
|
||||
tagTargetActive?: boolean;
|
||||
tagTargetPending?: boolean;
|
||||
selected?: boolean;
|
||||
docTagTokens?: string;
|
||||
ensureAssetUrl?: (...args: any[]) => Promise<unknown>;
|
||||
getDocumentAsset?: (...args: any[]) => unknown;
|
||||
handleNavigatorSnapshot?: (...args: any[]) => void;
|
||||
cardPointerHandlers?: React.HTMLAttributes<HTMLDivElement>;
|
||||
onDocumentOpen?: (id: string | number) => void;
|
||||
onTagDragEnter?: (event: React.DragEvent<HTMLDivElement>, docId: string | number) => void;
|
||||
onTagDragOver?: (event: React.DragEvent<HTMLDivElement>, docId: string | number) => void;
|
||||
onTagDragLeave?: (event: React.DragEvent<HTMLDivElement>, docId: string | number) => void;
|
||||
onTagDrop?: (event: React.DragEvent<HTMLDivElement>, doc: DocumentLike) => void;
|
||||
onDocTagPointerDown?: (event: React.PointerEvent<HTMLElement>, doc: DocumentLike, tag: any) => void;
|
||||
onDocTagDragStart?: (event: React.DragEvent<HTMLElement>, doc: DocumentLike, tag: any) => void;
|
||||
onDocTagDrag?: (event: React.DragEvent<HTMLElement>) => void;
|
||||
onDocTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
|
||||
pendingRemovalTag?: PendingRemovalTag | null;
|
||||
registerNode?: (node: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
||||
doc,
|
||||
style,
|
||||
shouldLoad,
|
||||
@@ -52,12 +91,29 @@ const DesktopDocumentCard = ({
|
||||
data-doc-id={doc.id}
|
||||
data-tag-ids={dataTagIds}
|
||||
aria-hidden={ariaHidden}
|
||||
ref={registerNode}
|
||||
ref={registerNode ?? undefined}
|
||||
{...cardPointerHandlers}
|
||||
onDragEnter={(event) => onTagDragEnter(event, doc.id)}
|
||||
onDragOver={(event) => onTagDragOver(event, doc.id)}
|
||||
onDragLeave={(event) => onTagDragLeave(event, doc.id)}
|
||||
onDrop={(event) => onTagDrop(event, doc)}
|
||||
onDragEnter={(event) => {
|
||||
if (doc?.id == null) {
|
||||
return;
|
||||
}
|
||||
onTagDragEnter?.(event, doc.id);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (doc?.id == null) {
|
||||
return;
|
||||
}
|
||||
onTagDragOver?.(event, doc.id);
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
if (doc?.id == null) {
|
||||
return;
|
||||
}
|
||||
onTagDragLeave?.(event, doc.id);
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
onTagDrop?.(event, doc);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
preventAll(event);
|
||||
@@ -107,11 +163,11 @@ const DesktopDocumentCard = ({
|
||||
draggable
|
||||
data-desk-tag-chip="true"
|
||||
onPointerDownCapture={(event) => {
|
||||
onDocTagPointerDown(event, doc, tag);
|
||||
onDocTagPointerDown?.(event, doc, tag);
|
||||
}}
|
||||
onDragStart={(event) => onDocTagDragStart(event, doc, tag)}
|
||||
onDragStart={(event) => onDocTagDragStart?.(event, doc, tag)}
|
||||
onDrag={onDocTagDrag}
|
||||
onDragEnd={(event) => onDocTagDragEnd(event)}
|
||||
onDragEnd={(event) => onDocTagDragEnd?.(event)}
|
||||
>
|
||||
<span className="tag-chip__label">{tag.label}</span>
|
||||
</span>
|
||||
+52
-5
@@ -1,8 +1,56 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import type { JSX } from 'react';
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
|
||||
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
||||
import { preventAll } from './events';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
interface DocumentLike {
|
||||
id?: Identifier;
|
||||
title?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AssetLike {
|
||||
id?: Identifier;
|
||||
cardinality?: number;
|
||||
url?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
objects?: Array<Record<string, unknown>>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type EnsureAssetUrl = (
|
||||
documentId: Identifier,
|
||||
asset: AssetLike,
|
||||
options?: { start?: number; limit?: number; [key: string]: unknown },
|
||||
) => Promise<unknown>;
|
||||
|
||||
type GetDocumentAsset = (document: DocumentLike | null | undefined, assetType: string) => AssetLike | null | undefined;
|
||||
|
||||
interface NavigatorSnapshot {
|
||||
url: string | null;
|
||||
alt?: string;
|
||||
canGoPrev: boolean;
|
||||
canGoNext: boolean;
|
||||
goPrev?: () => void;
|
||||
goNext?: () => void;
|
||||
ordinal: number;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
}
|
||||
|
||||
interface DesktopPreviewCardProps {
|
||||
doc: DocumentLike | null;
|
||||
title?: string;
|
||||
ensureAssetUrl?: EnsureAssetUrl | null;
|
||||
getDocumentAsset: GetDocumentAsset;
|
||||
prefetch?: number;
|
||||
onNavigatorSnapshot?: (docId: Identifier, snapshot: NavigatorSnapshot | null) => void;
|
||||
shouldLoad?: boolean;
|
||||
}
|
||||
|
||||
const DesktopPreviewCard = ({
|
||||
doc,
|
||||
title,
|
||||
@@ -11,7 +59,7 @@ const DesktopPreviewCard = ({
|
||||
prefetch = 3,
|
||||
onNavigatorSnapshot,
|
||||
shouldLoad = true,
|
||||
}) => {
|
||||
}: DesktopPreviewCardProps): JSX.Element => {
|
||||
const navigator = useAssetNavigator({
|
||||
document: doc,
|
||||
assetType: 'preview',
|
||||
@@ -23,8 +71,8 @@ const DesktopPreviewCard = ({
|
||||
const { currentUrl, cardinality, canGoPrev, canGoNext, currentMetadata, ordinal } = navigator;
|
||||
const docId = doc?.id ?? null;
|
||||
|
||||
const metadataWidth = Number(currentMetadata?.width);
|
||||
const metadataHeight = Number(currentMetadata?.height);
|
||||
const metadataWidth = Number((currentMetadata as { width?: number } | null)?.width);
|
||||
const metadataHeight = Number((currentMetadata as { height?: number } | null)?.height);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onNavigatorSnapshot || !docId) {
|
||||
@@ -143,4 +191,3 @@ const DesktopPreviewCard = ({
|
||||
};
|
||||
|
||||
export default DesktopPreviewCard;
|
||||
|
||||
+231
-49
@@ -28,9 +28,168 @@ import '../styles/workspace/workspace-layout.css';
|
||||
import '../styles/workspace/workspace-items.css';
|
||||
import '../styles/workspace/workspace-cards.css';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
type TagLike = { id?: Identifier | null; label?: string; color?: string | null } | null | undefined;
|
||||
|
||||
export interface DeskDocument {
|
||||
id?: Identifier | null;
|
||||
title?: string;
|
||||
tags?: TagLike[] | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface NavigatorSnapshot {
|
||||
url: string | null;
|
||||
alt?: string | null;
|
||||
canGoPrev?: boolean;
|
||||
canGoNext?: boolean;
|
||||
goPrev?: () => void;
|
||||
goNext?: () => void;
|
||||
ordinal?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
}
|
||||
|
||||
interface OverlayOriginTransform {
|
||||
rotation: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
baseWidth: number;
|
||||
baseHeight: number;
|
||||
}
|
||||
|
||||
interface OverlayDisplay extends NavigatorSnapshot {
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface DocumentSizeInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
source?: 'snapshot' | 'metadata' | 'fallback';
|
||||
}
|
||||
|
||||
interface PreviewMetadataEntry {
|
||||
docId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface DragTransformOverride {
|
||||
centerX?: number;
|
||||
centerY?: number;
|
||||
rotation?: number;
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
interface DragSettings {
|
||||
canvasPadding: number;
|
||||
defaultCanvasWidth: number;
|
||||
defaultCanvasHeight: number;
|
||||
debugDrag?: boolean;
|
||||
}
|
||||
|
||||
interface LayoutEntry {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
z: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
type WorkspaceSnapshotState = {
|
||||
layout: Map<string, LayoutEntry>;
|
||||
canvasSize: { width: number; height: number };
|
||||
visibleDocIds: Set<string>;
|
||||
draggingId: string | null;
|
||||
tagDropTargetId: string | null;
|
||||
pendingTagDocId: string | null;
|
||||
pendingRemovalTag: unknown;
|
||||
initialLoadDone: boolean;
|
||||
};
|
||||
|
||||
interface DesktopWorkspaceProps {
|
||||
documents?: DeskDocument[];
|
||||
searchResults?: DeskDocument[] | null;
|
||||
onDocumentOpen?: (docId: Identifier | null, options?: Record<string, unknown>) => void;
|
||||
onInspectDocument?: (...args: unknown[]) => void;
|
||||
onEntryPointer?: (...args: unknown[]) => void;
|
||||
onDocumentStackSelect?: (docIds: Identifier[]) => void;
|
||||
onPromoteSelection?: (...args: unknown[]) => void;
|
||||
onAssignTagToDocument?: (...args: unknown[]) => void;
|
||||
onRemoveTagFromDocument?: (...args: unknown[]) => void;
|
||||
ensureAssetUrl?: (...args: unknown[]) => Promise<unknown> | unknown;
|
||||
getDocumentAsset?: (...args: unknown[]) => unknown;
|
||||
activeTagIds?: Array<Identifier | null | undefined>;
|
||||
selectedDocumentIds?: Identifier[];
|
||||
onClearSelection?: () => void;
|
||||
detailPanelOpen?: boolean;
|
||||
onCloseDetailPanel?: () => void;
|
||||
tenantId?: Identifier | null;
|
||||
viewId?: string | null;
|
||||
}
|
||||
|
||||
interface DesktopWorkspaceViewProps {
|
||||
engine: WorkspaceEngine;
|
||||
items: DeskDocument[];
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
handleCanvasDragOver: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||
handleCanvasDragLeave: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||
handleCanvasDrop: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||
ensureDocumentSize: (doc: DeskDocument | null | undefined) => DocumentSizeInfo | null;
|
||||
layoutSnapshot: Map<string, LayoutEntry>;
|
||||
layoutRef: React.MutableRefObject<Map<string, LayoutEntry>>;
|
||||
dragTransformsRef: React.MutableRefObject<Map<string, DragTransformOverride>>;
|
||||
itemRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||||
visibleDocIds: Set<string>;
|
||||
draggingId: string | null;
|
||||
tagDropTargetId: string | null;
|
||||
pendingTagDocId: string | null;
|
||||
pendingRemovalTag: unknown;
|
||||
onDocumentOpen?: DesktopWorkspaceProps['onDocumentOpen'];
|
||||
ensureAssetUrl?: DesktopWorkspaceProps['ensureAssetUrl'];
|
||||
getDocumentAsset?: DesktopWorkspaceProps['getDocumentAsset'];
|
||||
handleNavigatorSnapshot: (docId: Identifier | null, snapshot: NavigatorSnapshot | null) => void;
|
||||
activeTagSet: Set<string>;
|
||||
handleTagDragEnterDoc: (...args: unknown[]) => void;
|
||||
handleTagDragOverDoc: (...args: unknown[]) => void;
|
||||
handleTagDragLeaveDoc: (...args: unknown[]) => void;
|
||||
handleTagDropOnDoc: (...args: unknown[]) => void;
|
||||
handleDocTagPointerDown: (...args: unknown[]) => void;
|
||||
handleDocTagDragStart: (...args: unknown[]) => void;
|
||||
handleDocTagDrag: (...args: unknown[]) => void;
|
||||
handleDocTagDragEnd: (...args: unknown[]) => void;
|
||||
overlayDisplay: OverlayDisplay | null;
|
||||
closeOverlay: () => void;
|
||||
overlayOriginRect: DOMRect | null;
|
||||
overlayOriginTransform: OverlayOriginTransform | null;
|
||||
onEntryPointer?: DesktopWorkspaceProps['onEntryPointer'];
|
||||
onDocumentStackSelect?: DesktopWorkspaceProps['onDocumentStackSelect'];
|
||||
onPromoteSelection?: DesktopWorkspaceProps['onPromoteSelection'];
|
||||
selectedDocumentIds: Identifier[];
|
||||
onClearSelection?: DesktopWorkspaceProps['onClearSelection'];
|
||||
detailPanelOpen: boolean;
|
||||
onCloseDetailPanel?: DesktopWorkspaceProps['onCloseDetailPanel'];
|
||||
documentLookup: Map<string, DeskDocument>;
|
||||
resolveBaseMetrics: (doc: DeskDocument | null | undefined, cardWidth: number, cardHeight: number) => {
|
||||
baseWidth: number;
|
||||
baseHeight: number;
|
||||
baseScale: number;
|
||||
};
|
||||
bringToFront: (docId: Identifier | null | undefined) => void;
|
||||
setDraggingId: (value: string | null) => void;
|
||||
canvasSize: { width: number; height: number };
|
||||
openOverlayForDoc: (docId: Identifier | null | undefined, originInfo?: OverlayOriginTransform | null) => void;
|
||||
recalcVisibleDocIds: () => void;
|
||||
dragSettings: DragSettings;
|
||||
onInspectDocument?: DesktopWorkspaceProps['onInspectDocument'];
|
||||
markLayoutDirty: () => void;
|
||||
}
|
||||
|
||||
const DEBUG_DRAG = false;
|
||||
const DEBUG_FOCUS = false;
|
||||
const DesktopWorkspace = ({
|
||||
const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
|
||||
documents = [],
|
||||
searchResults = null,
|
||||
onDocumentOpen,
|
||||
@@ -50,28 +209,35 @@ const DesktopWorkspace = ({
|
||||
tenantId = null,
|
||||
viewId = 'default',
|
||||
}) => {
|
||||
const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]);
|
||||
const items = useMemo<DeskDocument[]>(
|
||||
() => (searchResults ? searchResults : documents),
|
||||
[documents, searchResults],
|
||||
);
|
||||
|
||||
const allowLayoutPersistence = Boolean(tenantId && viewId && viewId.startsWith('folder:'));
|
||||
|
||||
const containerRef = useRef(null);
|
||||
const itemRefs = useRef(new Map());
|
||||
const dragTransformsRef = useRef(new Map());
|
||||
const [overlayDocId, setOverlayDocId] = useState(null);
|
||||
const [overlayOriginRect, setOverlayOriginRect] = useState(null);
|
||||
const [overlayOriginTransform, setOverlayOriginTransform] = useState(null);
|
||||
const [previewSnapshots, setPreviewSnapshots] = useState(() => new Map());
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const itemRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||
const dragTransformsRef = useRef<Map<string, DragTransformOverride>>(new Map());
|
||||
const [overlayDocId, setOverlayDocId] = useState<string | null>(null);
|
||||
const [overlayOriginRect, setOverlayOriginRect] = useState<DOMRect | null>(null);
|
||||
const [overlayOriginTransform, setOverlayOriginTransform] = useState<OverlayOriginTransform | null>(
|
||||
null,
|
||||
);
|
||||
const [previewSnapshots, setPreviewSnapshots] = useState<Map<string, NavigatorSnapshot>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const [docSizeVersion, setDocSizeVersion] = useState(0);
|
||||
const docSizeMapRef = useRef(new Map());
|
||||
const ensureDocumentSize = useCallback((doc) => {
|
||||
const docSizeMapRef = useRef<Map<string, DocumentSizeInfo>>(new Map());
|
||||
const ensureDocumentSize = useCallback((doc: DeskDocument | null | undefined): DocumentSizeInfo | null => {
|
||||
if (!doc?.id) {
|
||||
return null;
|
||||
}
|
||||
return docSizeMapRef.current.get(String(doc.id)) || null;
|
||||
}, []);
|
||||
const previewMetadata = usePreviewMetadata(items, getDocumentAsset, ensureAssetUrl);
|
||||
const documentLookup = useMemo(() => {
|
||||
const map = new Map();
|
||||
const documentLookup = useMemo<Map<string, DeskDocument>>(() => {
|
||||
const map = new Map<string, DeskDocument>();
|
||||
items.forEach((doc) => {
|
||||
const key = doc?.id != null ? String(doc.id) : null;
|
||||
if (key) {
|
||||
@@ -81,7 +247,7 @@ const DesktopWorkspace = ({
|
||||
return map;
|
||||
}, [items]);
|
||||
|
||||
const engineRef = useRef(null);
|
||||
const engineRef = useRef<WorkspaceEngine | null>(null);
|
||||
if (!engineRef.current) {
|
||||
engineRef.current = new WorkspaceEngine({
|
||||
allowLayoutPersistence,
|
||||
@@ -89,7 +255,7 @@ const DesktopWorkspace = ({
|
||||
viewId,
|
||||
});
|
||||
}
|
||||
const engine = engineRef.current;
|
||||
const engine = engineRef.current as WorkspaceEngine;
|
||||
|
||||
useEffect(() => {
|
||||
engine.updateConfig({ allowLayoutPersistence, tenantId, viewId });
|
||||
@@ -115,7 +281,7 @@ const DesktopWorkspace = ({
|
||||
engine.setEnsureDocumentSize(ensureDocumentSize);
|
||||
}, [engine, ensureDocumentSize]);
|
||||
|
||||
const workspaceSnapshot = useWorkspaceSnapshot(engine, useSyncExternalStore);
|
||||
const workspaceSnapshot = useWorkspaceSnapshot(engine, useSyncExternalStore) as WorkspaceSnapshotState;
|
||||
const {
|
||||
layout: layoutSnapshot,
|
||||
canvasSize,
|
||||
@@ -141,10 +307,10 @@ const DesktopWorkspace = ({
|
||||
engine.setItemRefs(itemRefs);
|
||||
}, [engine, itemRefs]);
|
||||
|
||||
const layoutRef = useRef(layoutSnapshot);
|
||||
layoutRef.current = engine.layout;
|
||||
const layoutRef = useRef<Map<string, LayoutEntry>>(layoutSnapshot);
|
||||
layoutRef.current = engine.layout as Map<string, LayoutEntry>;
|
||||
|
||||
const bringToFront = useCallback((docId) => {
|
||||
const bringToFront = useCallback((docId: Identifier | null | undefined) => {
|
||||
engine.bringToFront(docId);
|
||||
}, [engine]);
|
||||
|
||||
@@ -156,11 +322,11 @@ const DesktopWorkspace = ({
|
||||
engine.recalcVisibleDocIds();
|
||||
}, [engine]);
|
||||
|
||||
const setDraggingId = useCallback((value) => {
|
||||
const setDraggingId = useCallback((value: string | number | null) => {
|
||||
engine.setDraggingId(value);
|
||||
}, [engine]);
|
||||
|
||||
const applySnapshotDimensions = useCallback((docKey, snapshot) => {
|
||||
const applySnapshotDimensions = useCallback((docKey: string, snapshot: NavigatorSnapshot | null) => {
|
||||
const width = Number(snapshot?.width);
|
||||
const height = Number(snapshot?.height);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
@@ -180,7 +346,7 @@ const DesktopWorkspace = ({
|
||||
setDocSizeVersion((value) => value + 1);
|
||||
}, []);
|
||||
const handleNavigatorSnapshot = useCallback(
|
||||
(docId, snapshot) => {
|
||||
(docId: Identifier | null, snapshot: NavigatorSnapshot | null) => {
|
||||
const docKey = docId != null ? String(docId) : null;
|
||||
if (!docKey) {
|
||||
return;
|
||||
@@ -222,11 +388,11 @@ const DesktopWorkspace = ({
|
||||
},
|
||||
[applySnapshotDimensions],
|
||||
);
|
||||
const activeTagSet = useMemo(() => {
|
||||
const activeTagSet = useMemo<Set<string>>(() => {
|
||||
if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) {
|
||||
return new Set();
|
||||
}
|
||||
const set = new Set();
|
||||
const set = new Set<string>();
|
||||
activeTagIds.forEach((id) => {
|
||||
if (id != null) {
|
||||
set.add(String(id));
|
||||
@@ -251,20 +417,20 @@ const DesktopWorkspace = ({
|
||||
|
||||
commitSize();
|
||||
|
||||
if (!('ResizeObserver' in window)) {
|
||||
if (typeof window.ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', commitSize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', commitSize);
|
||||
};
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(commitSize);
|
||||
const observer = new window.ResizeObserver(commitSize);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [engine]);
|
||||
|
||||
const resolvePreviewDimensions = useCallback(
|
||||
(doc) => {
|
||||
(doc: DeskDocument | null | undefined): PreviewMetadataEntry | null => {
|
||||
if (!doc?.id) {
|
||||
return null;
|
||||
}
|
||||
@@ -299,7 +465,7 @@ const DesktopWorkspace = ({
|
||||
const focusTarget = () => {
|
||||
try {
|
||||
canvas.focus({ preventScroll: true });
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
if (DEBUG_FOCUS) {
|
||||
void error;
|
||||
}
|
||||
@@ -386,7 +552,7 @@ const DesktopWorkspace = ({
|
||||
}
|
||||
}, [items, previewMetadata]);
|
||||
|
||||
const overlayDisplay = useMemo(() => {
|
||||
const overlayDisplay = useMemo<OverlayDisplay | null>(() => {
|
||||
if (!overlayDocId) {
|
||||
return null;
|
||||
}
|
||||
@@ -395,7 +561,7 @@ const DesktopWorkspace = ({
|
||||
return null;
|
||||
}
|
||||
const doc = documentLookup.get(overlayDocId);
|
||||
const alt = snapshot.alt || doc?.title;
|
||||
const alt = snapshot.alt || (doc?.title as string | undefined);
|
||||
return {
|
||||
url: snapshot.url,
|
||||
alt,
|
||||
@@ -421,7 +587,7 @@ const DesktopWorkspace = ({
|
||||
}, [overlayDocId, documentLookup]);
|
||||
|
||||
const resolveBaseMetrics = useCallback(
|
||||
(doc, cardWidth, cardHeight) => {
|
||||
(doc: DeskDocument | null | undefined, cardWidth: number, cardHeight: number) => {
|
||||
const previewDims = doc ? resolvePreviewDimensions(doc) : null;
|
||||
if (previewDims?.width && previewDims?.height) {
|
||||
const baseWidth = Math.max(previewDims.width, cardWidth);
|
||||
@@ -452,7 +618,7 @@ const DesktopWorkspace = ({
|
||||
}, [draggingId, items, setDraggingId]);
|
||||
|
||||
const openOverlayForDoc = useCallback(
|
||||
(docId, originInfo = null) => {
|
||||
(docId: Identifier | null | undefined, originInfo: OverlayOriginTransform | null = null) => {
|
||||
if (!docId) {
|
||||
return;
|
||||
}
|
||||
@@ -462,7 +628,9 @@ const DesktopWorkspace = ({
|
||||
return;
|
||||
}
|
||||
const container = itemRefs.current.get(docKey);
|
||||
const imageNode = container?.querySelector?.('.desk-item__card img');
|
||||
const imageNode = container
|
||||
? container.querySelector<HTMLImageElement>('.desk-item__card img')
|
||||
: null;
|
||||
if (!container || !imageNode) {
|
||||
return;
|
||||
}
|
||||
@@ -514,7 +682,7 @@ const DesktopWorkspace = ({
|
||||
],
|
||||
);
|
||||
|
||||
const dragSettings = useMemo(
|
||||
const dragSettings = useMemo<DragSettings>(
|
||||
() => ({
|
||||
canvasPadding: DESK_CANVAS_PADDING,
|
||||
defaultCanvasWidth: DESK_DEFAULT_CANVAS_WIDTH,
|
||||
@@ -524,7 +692,7 @@ const DesktopWorkspace = ({
|
||||
[],
|
||||
);
|
||||
|
||||
const viewProps = useMemo(
|
||||
const viewProps = useMemo<DesktopWorkspaceViewProps>(
|
||||
() => ({
|
||||
engine,
|
||||
items,
|
||||
@@ -633,7 +801,7 @@ const DesktopWorkspace = ({
|
||||
return <DesktopWorkspaceView {...viewProps} />;
|
||||
};
|
||||
|
||||
const DesktopWorkspaceView = ({
|
||||
function DesktopWorkspaceView({
|
||||
engine,
|
||||
items,
|
||||
containerRef,
|
||||
@@ -684,7 +852,7 @@ const DesktopWorkspaceView = ({
|
||||
onInspectDocument,
|
||||
markLayoutDirty,
|
||||
dragTransformsRef,
|
||||
}) => {
|
||||
}: DesktopWorkspaceViewProps) {
|
||||
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } =
|
||||
useDocumentDrag({
|
||||
engine,
|
||||
@@ -705,7 +873,12 @@ const DesktopWorkspaceView = ({
|
||||
onDocumentStackSelect,
|
||||
selectedDocumentIds,
|
||||
markLayoutDirty,
|
||||
});
|
||||
}) as {
|
||||
handlePointerDown: React.PointerEventHandler<HTMLElement>;
|
||||
handlePointerMove: React.PointerEventHandler<HTMLElement>;
|
||||
handlePointerUp: React.PointerEventHandler<HTMLElement>;
|
||||
handlePointerCancel: React.PointerEventHandler<HTMLElement>;
|
||||
};
|
||||
|
||||
const { getCardPointerHandlers, handleShellKeyDown, focusShell } = useDeskPointer({
|
||||
containerRef,
|
||||
@@ -725,7 +898,11 @@ const DesktopWorkspaceView = ({
|
||||
detailPanelOpen,
|
||||
onCloseDetailPanel,
|
||||
openOverlayForDoc,
|
||||
});
|
||||
}) as {
|
||||
getCardPointerHandlers: (doc: DeskDocument) => React.HTMLAttributes<HTMLDivElement>;
|
||||
handleShellKeyDown: React.KeyboardEventHandler<HTMLDivElement>;
|
||||
focusShell: () => void;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
focusShell();
|
||||
@@ -745,7 +922,7 @@ const DesktopWorkspaceView = ({
|
||||
|
||||
|
||||
|
||||
const allSizesReady = items.every((doc) => ensureDocumentSize(doc));
|
||||
const allSizesReady = items.every((doc) => Boolean(ensureDocumentSize(doc)));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -780,7 +957,7 @@ const DesktopWorkspaceView = ({
|
||||
<p>No documents to show here yet. Drop files to make this space come alive.</p>
|
||||
</div>
|
||||
) : (
|
||||
items.map((doc) => {
|
||||
items.map((doc, index) => {
|
||||
const sizeInfo = ensureDocumentSize(doc);
|
||||
if (!sizeInfo) {
|
||||
return null;
|
||||
@@ -797,10 +974,12 @@ const DesktopWorkspaceView = ({
|
||||
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
|
||||
return null;
|
||||
}
|
||||
const resolvedCenterX = centerX as number;
|
||||
const resolvedCenterY = centerY as number;
|
||||
const rotation = dragOverride?.rotation ?? layout?.rotation ?? 0;
|
||||
const scale = dragOverride?.scale ?? 1;
|
||||
const originX = centerX - cardWidth / 2;
|
||||
const originY = centerY - cardHeight / 2;
|
||||
const originX = resolvedCenterX - cardWidth / 2;
|
||||
const originY = resolvedCenterY - cardHeight / 2;
|
||||
const transform = formatTransform(
|
||||
Math.round(originX),
|
||||
Math.round(originY),
|
||||
@@ -816,16 +995,19 @@ const DesktopWorkspaceView = ({
|
||||
const shouldLoad = docKey ? visibleDocIds.has(docKey) : false;
|
||||
const dragging = docKey ? draggingId === docKey : false;
|
||||
const docTagKeys = Array.isArray(doc?.tags)
|
||||
? doc.tags.map((tag) => (tag ? tag.id : null)).filter(Boolean)
|
||||
? doc.tags
|
||||
.map((tag) => (tag?.id != null ? String(tag.id) : null))
|
||||
.filter((id): id is string => Boolean(id))
|
||||
: [];
|
||||
const matchesFilter =
|
||||
activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key));
|
||||
const dropActive = docKey ? tagDropTargetId === docKey : false;
|
||||
const dropPending = docKey ? pendingTagDocId === docKey : false;
|
||||
const isSelected = selectedDocumentIds.includes(doc.id);
|
||||
const docId = doc?.id ?? null;
|
||||
const isSelected = docId != null ? selectedDocumentIds.includes(docId) : false;
|
||||
const docTagTokens = docTagKeys.join(' ');
|
||||
const cardPointerHandlers = getCardPointerHandlers(doc);
|
||||
const registerNode = (node) => {
|
||||
const cardPointerHandlers = getCardPointerHandlers(doc) as React.HTMLAttributes<HTMLDivElement>;
|
||||
const registerNode = (node: HTMLDivElement | null) => {
|
||||
if (!docKey) {
|
||||
return;
|
||||
}
|
||||
@@ -838,7 +1020,7 @@ const DesktopWorkspaceView = ({
|
||||
|
||||
return (
|
||||
<DesktopDocumentCard
|
||||
key={doc.id}
|
||||
key={docKey ?? `desk-doc-${index}`}
|
||||
doc={doc}
|
||||
style={style}
|
||||
shouldLoad={shouldLoad}
|
||||
@@ -878,6 +1060,6 @@ const DesktopWorkspaceView = ({
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default DesktopWorkspace;
|
||||
+26
-2
@@ -1,10 +1,34 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import SelectionFloatingActions from '../documents/SelectionFloatingActions';
|
||||
import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel';
|
||||
import createWorkspaceSurfaceConfig from '../documents/workspaceHeader';
|
||||
import DocumentViewerPanel from '../preview/DocumentViewerPanel';
|
||||
import DesktopWorkspace from './DesktopWorkspace';
|
||||
|
||||
type WorkspaceProps = Record<string, any>;
|
||||
|
||||
interface Breadcrumb {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
label?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface DetailProps extends Record<string, any> {
|
||||
onClose?: () => void;
|
||||
onOpenPreview?: () => void;
|
||||
tags?: unknown;
|
||||
}
|
||||
|
||||
interface CreateDesktopSurfaceArgs {
|
||||
workspaceProps?: WorkspaceProps | null;
|
||||
renderSidebarToggle?: () => ReactNode;
|
||||
parentBreadcrumb?: Breadcrumb | null;
|
||||
onNavigateParent?: () => void;
|
||||
detailProps?: DetailProps | null;
|
||||
detailOpen?: boolean;
|
||||
}
|
||||
|
||||
const createDesktopSurface = ({
|
||||
workspaceProps,
|
||||
renderSidebarToggle,
|
||||
@@ -12,7 +36,7 @@ const createDesktopSurface = ({
|
||||
onNavigateParent,
|
||||
detailProps = null,
|
||||
detailOpen = false,
|
||||
}) => {
|
||||
}: CreateDesktopSurfaceArgs) => {
|
||||
if (!workspaceProps) {
|
||||
return null;
|
||||
}
|
||||
@@ -2,15 +2,15 @@ const DB_NAME = 'papercrate_desk';
|
||||
const DB_VERSION = 1;
|
||||
const LAYOUT_STORE = 'layouts';
|
||||
|
||||
const currentDbPromise = { value: null };
|
||||
const currentDbPromise: { value: Promise<IDBDatabase | null> | null } = { value: null };
|
||||
|
||||
const openDatabase = () => {
|
||||
const openDatabase = (): Promise<IDBDatabase> => {
|
||||
if (currentDbPromise.value) {
|
||||
return currentDbPromise.value;
|
||||
return currentDbPromise.value as Promise<IDBDatabase>;
|
||||
}
|
||||
|
||||
currentDbPromise.value = new Promise((resolve, reject) => {
|
||||
const dbApi = window.indexedDB;
|
||||
const dbApi = typeof window !== 'undefined' ? window.indexedDB : null;
|
||||
if (!dbApi) {
|
||||
reject(new Error('IndexedDB not available'));
|
||||
return;
|
||||
@@ -39,51 +39,56 @@ const openDatabase = () => {
|
||||
};
|
||||
});
|
||||
|
||||
return currentDbPromise.value;
|
||||
return currentDbPromise.value as Promise<IDBDatabase>;
|
||||
};
|
||||
|
||||
const requestToPromise = (request, defaultValue) => new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => {
|
||||
const { result } = request;
|
||||
resolve(result ?? defaultValue);
|
||||
};
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error('IndexedDB request failed'));
|
||||
};
|
||||
});
|
||||
const requestToPromise = <T>(request: IDBRequest<T>, defaultValue: T): Promise<T> =>
|
||||
new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => {
|
||||
const { result } = request;
|
||||
resolve(result ?? defaultValue);
|
||||
};
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error('IndexedDB request failed'));
|
||||
};
|
||||
});
|
||||
|
||||
const iterateCursor = (request, iteratee) => new Promise((resolve, reject) => {
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = event.target.result;
|
||||
if (!cursor) {
|
||||
const iterateCursor = (request: IDBRequest<IDBCursorWithValue | null>, iteratee: (cursor: IDBCursorWithValue) => void) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
|
||||
if (!cursor) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
iteratee(cursor);
|
||||
cursor.continue();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error('IndexedDB cursor failed'));
|
||||
};
|
||||
});
|
||||
|
||||
const transactionComplete = (transaction: IDBTransaction) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
iteratee(cursor);
|
||||
cursor.continue();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error('IndexedDB cursor failed'));
|
||||
};
|
||||
});
|
||||
};
|
||||
transaction.onerror = () => {
|
||||
reject(transaction.error || new Error('IndexedDB transaction failed'));
|
||||
};
|
||||
transaction.onabort = () => {
|
||||
reject(transaction.error || new Error('IndexedDB transaction aborted'));
|
||||
};
|
||||
});
|
||||
|
||||
const transactionComplete = (transaction) => new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => {
|
||||
resolve();
|
||||
};
|
||||
transaction.onerror = () => {
|
||||
reject(transaction.error || new Error('IndexedDB transaction failed'));
|
||||
};
|
||||
transaction.onabort = () => {
|
||||
reject(transaction.error || new Error('IndexedDB transaction aborted'));
|
||||
};
|
||||
});
|
||||
type TransactionMode = 'readonly' | 'readwrite' | 'versionchange';
|
||||
|
||||
const withStore = async (mode, handler) => {
|
||||
const withStore = async <T>(mode: TransactionMode, handler: (store: IDBObjectStore, tx: IDBTransaction) => Promise<T> | T): Promise<T> => {
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction(LAYOUT_STORE, mode);
|
||||
const store = transaction.objectStore(LAYOUT_STORE);
|
||||
@@ -101,13 +106,24 @@ const withStore = async (mode, handler) => {
|
||||
try {
|
||||
await done;
|
||||
} catch {
|
||||
// noop – prefer original error
|
||||
// ignore
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchLayoutRecords = async ({ tenantId, viewId }) => {
|
||||
interface LayoutRecord {
|
||||
tenantId: string | number;
|
||||
viewId: string | number;
|
||||
documentId: string | number;
|
||||
centerX?: number;
|
||||
centerY?: number;
|
||||
rotation?: number;
|
||||
zIndex?: number;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export const fetchLayoutRecords = async ({ tenantId, viewId }: { tenantId?: string | number; viewId?: string | number }): Promise<LayoutRecord[]> => {
|
||||
if (!tenantId || !viewId) {
|
||||
return [];
|
||||
}
|
||||
@@ -123,7 +139,7 @@ export const fetchLayoutRecords = async ({ tenantId, viewId }) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => {
|
||||
export const upsertLayoutRecords = async ({ tenantId, viewId, entries }: { tenantId?: string | number; viewId?: string | number; entries?: Array<{ documentId?: string | number; centerX?: number; centerY?: number; rotation?: number; zIndex?: number; updatedAt?: number }> }) => {
|
||||
if (!tenantId || !viewId || !Array.isArray(entries) || !entries.length) {
|
||||
return;
|
||||
}
|
||||
@@ -144,7 +160,7 @@ export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => {
|
||||
rotation: Number(entry.rotation) || 0,
|
||||
zIndex: Number(entry.zIndex) || 0,
|
||||
updatedAt: entry.updatedAt || timestamp,
|
||||
});
|
||||
} satisfies LayoutRecord);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -152,7 +168,7 @@ export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteTenantLayouts = async (tenantId) => {
|
||||
export const deleteTenantLayouts = async (tenantId?: string | number) => {
|
||||
if (!tenantId) {
|
||||
return;
|
||||
}
|
||||
@@ -169,15 +185,17 @@ export const deleteTenantLayouts = async (tenantId) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const closeDeskDatabase = () => {
|
||||
export const closeDeskDatabase = (): void => {
|
||||
if (!currentDbPromise.value) {
|
||||
return;
|
||||
}
|
||||
currentDbPromise.value = currentDbPromise.value.then((db) => {
|
||||
try {
|
||||
db.close();
|
||||
} catch (error) {
|
||||
console.warn('[desk] Failed to close IndexedDB', error);
|
||||
if (db) {
|
||||
try {
|
||||
db.close();
|
||||
} catch (error) {
|
||||
console.warn('[desk] Failed to close IndexedDB', error);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -1,4 +1,9 @@
|
||||
export const preventAll = (event) => {
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
|
||||
type PointerLikeEvent = MouseEvent & { pageX?: number; pageY?: number };
|
||||
type PreventableEvent = Event | ReactPointerEvent | { preventDefault?: () => void; stopPropagation?: () => void };
|
||||
|
||||
export const preventAll = (event?: PreventableEvent | null): void => {
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
@@ -14,9 +19,15 @@ export const preventAll = (event) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const safeInvoke = (fn, ...args) => fn?.(...args);
|
||||
type AnyFn = (...args: unknown[]) => unknown;
|
||||
|
||||
export const getPointerPosition = (event, { fallbackToPage = true } = {}) => {
|
||||
export const safeInvoke = <Fn extends AnyFn>(fn: Fn | null | undefined, ...args: Parameters<Fn>): ReturnType<Fn> | undefined =>
|
||||
(fn ? fn(...args) : undefined);
|
||||
|
||||
export const getPointerPosition = (
|
||||
event?: PointerLikeEvent | null,
|
||||
{ fallbackToPage = true }: { fallbackToPage?: boolean } = {},
|
||||
): { x: number; y: number } => {
|
||||
if (!event) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
+29
-5
@@ -1,8 +1,32 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createAssetView } from '../../asset_manager';
|
||||
|
||||
const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => {
|
||||
const [metadataMap, setMetadataMap] = useState(() => new Map());
|
||||
interface DocumentLike {
|
||||
id?: string | number;
|
||||
current_version?: unknown;
|
||||
tags?: unknown;
|
||||
}
|
||||
|
||||
interface AssetLike {
|
||||
id?: string | number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface PreviewMetadataEntry {
|
||||
docId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type GetDocumentAsset = (doc: DocumentLike, type: string) => AssetLike | null | undefined;
|
||||
type EnsureAssetUrl = (docId: string | number, asset: AssetLike, options?: { start?: number; limit?: number }) => Promise<AssetLike | null | undefined>;
|
||||
|
||||
const usePreviewMetadata = (
|
||||
documents: DocumentLike[] | null | undefined,
|
||||
getDocumentAsset?: GetDocumentAsset,
|
||||
ensureAssetUrl?: EnsureAssetUrl,
|
||||
) => {
|
||||
const [metadataMap, setMetadataMap] = useState<Map<string, PreviewMetadataEntry>>(() => new Map());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -14,19 +38,19 @@ const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => {
|
||||
};
|
||||
}
|
||||
|
||||
const fetchMetadataForDoc = async (doc) => {
|
||||
const fetchMetadataForDoc = async (doc: DocumentLike) => {
|
||||
if (!doc?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const docId = String(doc.id);
|
||||
const resolveAsset = (type) => getDocumentAsset?.(doc, type) ?? null;
|
||||
const resolveAsset = (type: string) => getDocumentAsset?.(doc, type) ?? null;
|
||||
|
||||
let asset = resolveAsset('preview') || resolveAsset('thumbnail');
|
||||
let view = createAssetView(asset);
|
||||
let metadata = view.getPrimaryMetadata();
|
||||
|
||||
const hasDimensions = (meta) =>
|
||||
const hasDimensions = (meta: { width?: number | string; height?: number | string } | null | undefined) =>
|
||||
Number.isFinite(Number(meta?.width)) &&
|
||||
Number.isFinite(Number(meta?.height)) &&
|
||||
Number(meta.width) > 0 &&
|
||||
@@ -1,4 +0,0 @@
|
||||
export { clamp } from '../utils/math';
|
||||
|
||||
export const formatTransform = (x, y, rotation = 0, scale = 1) =>
|
||||
`translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
|
||||
@@ -0,0 +1,8 @@
|
||||
export { clamp } from '../utils/math';
|
||||
|
||||
export const formatTransform = (
|
||||
x: number,
|
||||
y: number,
|
||||
rotation = 0,
|
||||
scale = 1,
|
||||
): string => `translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
|
||||
+55
-8
@@ -1,4 +1,4 @@
|
||||
import { safeInvoke } from '../events.js';
|
||||
import { safeInvoke } from '../events';
|
||||
|
||||
export const CLICK_ACTIONS = {
|
||||
selectSingle: 'selectSingle',
|
||||
@@ -6,20 +6,52 @@ export const CLICK_ACTIONS = {
|
||||
addCard: 'addCard',
|
||||
addStack: 'addStack',
|
||||
none: 'none',
|
||||
};
|
||||
} as const;
|
||||
|
||||
export const DRAG_ACTIONS = {
|
||||
dragSelectSingle: 'dragSelectSingle',
|
||||
dragSelection: 'dragSelection',
|
||||
dragSelectStack: 'dragSelectStack',
|
||||
none: 'none',
|
||||
};
|
||||
} as const;
|
||||
|
||||
export type ClickAction = (typeof CLICK_ACTIONS)[keyof typeof CLICK_ACTIONS];
|
||||
export type DragAction = (typeof DRAG_ACTIONS)[keyof typeof DRAG_ACTIONS];
|
||||
|
||||
export const STACK_HIT_EPSILON = 4;
|
||||
export const POINTER_DRAG_THRESHOLD_SQUARED = 16;
|
||||
export const LONG_PRESS_DURATION_MS = 450;
|
||||
|
||||
export const withinThreshold = (dx, dy, thresholdSquared) => (dx * dx + dy * dy) <= thresholdSquared;
|
||||
export const withinThreshold = (dx: number, dy: number, thresholdSquared: number): boolean => (dx * dx + dy * dy) <= thresholdSquared;
|
||||
|
||||
interface PointerIntentArgs {
|
||||
doc: { id: string | number };
|
||||
entryDescriptor: unknown;
|
||||
selectedDocumentIds: Array<string | number>;
|
||||
metaKey: boolean;
|
||||
pointerButton?: number;
|
||||
pointerType?: string;
|
||||
stackHits?: string[] | null;
|
||||
}
|
||||
|
||||
export interface PointerIntent {
|
||||
docId: string | number;
|
||||
entryDescriptor: unknown;
|
||||
pointerType?: string;
|
||||
pointerButton?: number;
|
||||
selectedAtDown: boolean;
|
||||
selectionCountAtDown: number;
|
||||
metaKey: boolean;
|
||||
clickAction: ClickAction;
|
||||
dragAction: DragAction;
|
||||
stackDocIdsForDrag: string[] | null;
|
||||
stackDocIdsForClick: string[] | null;
|
||||
stackReplaceOnClick: boolean;
|
||||
stackReplaceOnDrag: boolean;
|
||||
clickSelectionApplied: boolean;
|
||||
stackSelectionApplied: boolean;
|
||||
longPressTriggered: boolean;
|
||||
}
|
||||
|
||||
export const createPointerIntent = ({
|
||||
doc,
|
||||
@@ -29,7 +61,7 @@ export const createPointerIntent = ({
|
||||
pointerButton,
|
||||
pointerType,
|
||||
stackHits,
|
||||
}) => {
|
||||
}: PointerIntentArgs): PointerIntent => {
|
||||
const alreadySelected = selectedDocumentIds.includes(doc.id);
|
||||
const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0;
|
||||
|
||||
@@ -74,7 +106,12 @@ export const createPointerIntent = ({
|
||||
};
|
||||
};
|
||||
|
||||
export const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => {
|
||||
export const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDocumentStackSelect }: {
|
||||
intent: PointerIntent;
|
||||
event?: unknown;
|
||||
onEntryPointer?: (descriptor: unknown, event?: unknown) => void;
|
||||
onDocumentStackSelect?: (docIds: string[], event?: unknown, options?: { replace?: boolean }) => void;
|
||||
}) => {
|
||||
switch (intent.clickAction) {
|
||||
case CLICK_ACTIONS.selectSingle:
|
||||
case CLICK_ACTIONS.addCard:
|
||||
@@ -100,7 +137,12 @@ export const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDoc
|
||||
}
|
||||
};
|
||||
|
||||
export const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => {
|
||||
export const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocumentStackSelect }: {
|
||||
intent: PointerIntent;
|
||||
event?: unknown;
|
||||
onEntryPointer?: (descriptor: unknown, event?: unknown) => void;
|
||||
onDocumentStackSelect?: (docIds: string[], event?: unknown, options?: { replace?: boolean }) => void;
|
||||
}) => {
|
||||
if (!intent || intent.clickSelectionApplied) {
|
||||
return;
|
||||
}
|
||||
@@ -108,7 +150,12 @@ export const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocume
|
||||
applyClickPlanImmediately({ intent, event, onEntryPointer, onDocumentStackSelect });
|
||||
};
|
||||
|
||||
export const applyLongPressSelection = ({ intent, stackDocIds, syntheticEvent, onDocumentStackSelect }) => {
|
||||
export const applyLongPressSelection = ({ intent, stackDocIds, syntheticEvent, onDocumentStackSelect }: {
|
||||
intent: PointerIntent;
|
||||
stackDocIds?: string[] | null;
|
||||
syntheticEvent?: unknown;
|
||||
onDocumentStackSelect?: (docIds: string[], event?: unknown, options?: { replace?: boolean }) => void;
|
||||
}) => {
|
||||
if (!intent) {
|
||||
return;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
finalizeClickSelection,
|
||||
withinThreshold,
|
||||
} from './pointerUtils';
|
||||
import { getPointerPosition, safeInvoke } from '../events.js';
|
||||
import { getPointerPosition, safeInvoke } from '../events';
|
||||
|
||||
const buildEntryDescriptor = (docId) => ({
|
||||
type: 'document',
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { getPointerPosition, preventAll, safeInvoke } from '../events.js';
|
||||
import { getPointerPosition, preventAll, safeInvoke } from '../events';
|
||||
import {
|
||||
isTagTransferEvent,
|
||||
parseTagTransferPayload,
|
||||
|
||||
+79
-15
@@ -1,11 +1,75 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
type DocumentEntry = { id?: Identifier } & Record<string, unknown>;
|
||||
|
||||
type WorkspaceViewMode = 'desk' | 'grid' | 'list' | string;
|
||||
|
||||
type DeskDocumentStackSelectHandler = (docIds: Identifier[]) => void;
|
||||
|
||||
type ResolveRowKey = (id: Identifier) => Identifier | string | null;
|
||||
|
||||
type ApplySelection = (
|
||||
keys: Array<Identifier | string>,
|
||||
options: { anchor?: Identifier | string | null; interactedKeys?: Array<Identifier | string> },
|
||||
) => void;
|
||||
|
||||
interface UseDeskWorkspacePropsArgs {
|
||||
documents?: DocumentEntry[];
|
||||
searchResults?: DocumentEntry[];
|
||||
breadcrumbs?: unknown[];
|
||||
currentFolderName?: string | null;
|
||||
documentsViewMode?: WorkspaceViewMode;
|
||||
handleDocumentsViewModeChange?: (mode: WorkspaceViewMode) => void;
|
||||
handleDeskExit?: () => void;
|
||||
refreshCurrentFolder?: () => Promise<void> | void;
|
||||
inspectDocument?: (doc: DocumentEntry) => void;
|
||||
handleEntryPointerCore?: (...args: unknown[]) => void;
|
||||
promoteSelectionOrder?: (...args: unknown[]) => void;
|
||||
currentTenantId?: Identifier | null;
|
||||
selectedDocumentIds?: Identifier[];
|
||||
selectedFolderIds?: Identifier[];
|
||||
clearDocumentSelection?: () => void;
|
||||
detailPanelOpen?: boolean;
|
||||
handleDetailPanelClose?: () => void;
|
||||
resolveThumbnailUrlForDoc?: (doc: DocumentEntry) => string | null;
|
||||
handleDocumentTagDrop?: (...args: unknown[]) => void;
|
||||
handleTagRemove?: (...args: unknown[]) => void;
|
||||
ensureAssetUrl?: (...args: unknown[]) => void;
|
||||
getDocumentAsset?: (...args: unknown[]) => unknown;
|
||||
activeTagFilters?: Identifier[];
|
||||
handleDeleteSelection?: () => void;
|
||||
tags?: unknown[];
|
||||
correspondents?: unknown[];
|
||||
documentLookup?: unknown;
|
||||
tagLookupById?: unknown;
|
||||
handleBulkTagAddFromDetail?: (...args: unknown[]) => void;
|
||||
handleBulkTagRemoveFromDetail?: (...args: unknown[]) => void;
|
||||
handleBulkCorrespondentAdd?: (...args: unknown[]) => void;
|
||||
handleBulkCorrespondentRemove?: (...args: unknown[]) => void;
|
||||
handleBulkSelectionReanalyze?: (...args: unknown[]) => void;
|
||||
folderOptions?: unknown[];
|
||||
moveDocumentsToFolder?: (...args: unknown[]) => void;
|
||||
searchIncludeDescendants?: boolean;
|
||||
toggleSearchIncludeDescendants?: () => void;
|
||||
selectedEntries?: Array<Identifier | string>;
|
||||
selectionAnchorRef: { current: Identifier | string | null };
|
||||
applySelection: ApplySelection;
|
||||
resolveDocumentRowKey: ResolveRowKey;
|
||||
showingSearchResults?: boolean;
|
||||
searchQuery?: string;
|
||||
activeCorrespondentFilters?: Identifier[];
|
||||
selectedFolder?: Identifier | string | null;
|
||||
openDetailPanel?: (args: { documentIds: Identifier[] }) => void;
|
||||
}
|
||||
|
||||
const useDeskWorkspaceProps = ({
|
||||
documents,
|
||||
searchResults,
|
||||
breadcrumbs,
|
||||
currentFolderName,
|
||||
documentsViewMode,
|
||||
documentsViewMode = 'desk',
|
||||
handleDocumentsViewModeChange,
|
||||
handleDeskExit,
|
||||
refreshCurrentFolder,
|
||||
@@ -23,7 +87,7 @@ const useDeskWorkspaceProps = ({
|
||||
handleTagRemove,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
activeTagFilters,
|
||||
activeTagFilters = [],
|
||||
handleDeleteSelection,
|
||||
tags,
|
||||
correspondents,
|
||||
@@ -38,17 +102,17 @@ const useDeskWorkspaceProps = ({
|
||||
moveDocumentsToFolder,
|
||||
searchIncludeDescendants,
|
||||
toggleSearchIncludeDescendants,
|
||||
selectedEntries,
|
||||
selectedEntries = [],
|
||||
selectionAnchorRef,
|
||||
applySelection,
|
||||
resolveDocumentRowKey,
|
||||
showingSearchResults,
|
||||
searchQuery,
|
||||
activeCorrespondentFilters,
|
||||
showingSearchResults = false,
|
||||
searchQuery = '',
|
||||
activeCorrespondentFilters = [],
|
||||
selectedFolder,
|
||||
openDetailPanel,
|
||||
}) => {
|
||||
const handleDeskDocumentStackSelect = useCallback(
|
||||
}: UseDeskWorkspacePropsArgs) => {
|
||||
const handleDeskDocumentStackSelect: DeskDocumentStackSelectHandler = useCallback(
|
||||
(docIds) => {
|
||||
if (!Array.isArray(docIds) || docIds.length === 0) {
|
||||
return;
|
||||
@@ -56,7 +120,7 @@ const useDeskWorkspaceProps = ({
|
||||
|
||||
const rowKeys = docIds
|
||||
.map((id) => resolveDocumentRowKey(id))
|
||||
.filter(Boolean);
|
||||
.filter((value): value is Identifier | string => Boolean(value));
|
||||
|
||||
if (!rowKeys.length) {
|
||||
return;
|
||||
@@ -69,9 +133,9 @@ const useDeskWorkspaceProps = ({
|
||||
}
|
||||
});
|
||||
|
||||
const anchor = rowKeys[0]
|
||||
const anchor = (rowKeys[0]
|
||||
|| selectionAnchorRef.current
|
||||
|| nextKeys[nextKeys.length - 1];
|
||||
|| nextKeys[nextKeys.length - 1]) as Identifier | string | null;
|
||||
|
||||
applySelection(nextKeys, {
|
||||
anchor,
|
||||
@@ -82,13 +146,13 @@ const useDeskWorkspaceProps = ({
|
||||
);
|
||||
|
||||
const handleDeskDocumentOpen = useCallback(
|
||||
(docId, { useSelection = false } = {}) => {
|
||||
(docId: Identifier | undefined, { useSelection = false }: { useSelection?: boolean } = {}) => {
|
||||
const selectionDocIds = Array.isArray(selectedDocumentIds)
|
||||
? selectedDocumentIds
|
||||
: [];
|
||||
let targetIds = [];
|
||||
let targetIds: Identifier[] = [];
|
||||
|
||||
if ((useSelection || selectionDocIds.includes(docId)) && selectionDocIds.length) {
|
||||
if ((useSelection || selectionDocIds.includes(docId as Identifier)) && selectionDocIds.length) {
|
||||
targetIds = selectionDocIds;
|
||||
} else if (selectionDocIds.length) {
|
||||
targetIds = selectionDocIds;
|
||||
@@ -100,7 +164,7 @@ const useDeskWorkspaceProps = ({
|
||||
return;
|
||||
}
|
||||
|
||||
openDetailPanel({ documentIds: targetIds });
|
||||
openDetailPanel?.({ documentIds: targetIds });
|
||||
},
|
||||
[openDetailPanel, selectedDocumentIds],
|
||||
);
|
||||
+203
-36
@@ -1,26 +1,179 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type MutableRefObject,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { preventAll, safeInvoke } from './events';
|
||||
import { clamp } from './math';
|
||||
import usePointerTap from '../ui/usePointerTap';
|
||||
import { MIN_TIMESTEP, MAX_TIMESTEP, applyDomTransform } from './workspaceEngine';
|
||||
import {
|
||||
MIN_TIMESTEP,
|
||||
MAX_TIMESTEP,
|
||||
applyDomTransform,
|
||||
type WorkspaceEngine,
|
||||
} from './workspaceEngine';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
interface DocumentLike {
|
||||
id?: Identifier | null;
|
||||
title?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DocumentSizeInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface LayoutEntry {
|
||||
centerX?: number;
|
||||
centerY?: number;
|
||||
rotation?: number;
|
||||
z?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface DragTransform {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
type EngineDragState = Parameters<WorkspaceEngine['finalizeGroupDrag']>[0];
|
||||
type EngineGroupItem = NonNullable<EngineDragState['groupItems']>[number];
|
||||
|
||||
interface DragGroupItemInternal extends EngineGroupItem {
|
||||
baseOffsetX?: number;
|
||||
baseOffsetY?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
targetRotation?: number;
|
||||
}
|
||||
|
||||
type EnsureDocumentSizeFn = (doc: DocumentLike | null | undefined) => DocumentSizeInfo | null;
|
||||
|
||||
type ResolveBaseMetricsFn = (
|
||||
doc: DocumentLike | null | undefined,
|
||||
width: number,
|
||||
height: number,
|
||||
) => { baseWidth: number; baseHeight: number; baseScale: number };
|
||||
|
||||
interface DragSettings {
|
||||
canvasPadding?: number;
|
||||
defaultCanvasWidth?: number;
|
||||
defaultCanvasHeight?: number;
|
||||
debugDrag?: boolean;
|
||||
}
|
||||
|
||||
interface PointerDownOptions {
|
||||
stackDocIds?: Array<Identifier | null | undefined>;
|
||||
stackSelectionApplied?: boolean;
|
||||
wasSelected?: boolean;
|
||||
modifierActive?: boolean;
|
||||
stackReplace?: boolean;
|
||||
}
|
||||
|
||||
interface UseDocumentDragOptions {
|
||||
engine?: WorkspaceEngine | null;
|
||||
layoutRef: MutableRefObject<Map<string, LayoutEntry>>;
|
||||
dragTransformsRef: MutableRefObject<Map<string, DragTransform>>;
|
||||
itemRefs: MutableRefObject<Map<string, HTMLElement | null>>;
|
||||
documentLookup: Map<string, DocumentLike>;
|
||||
ensureDocumentSize: EnsureDocumentSizeFn;
|
||||
resolveBaseMetrics: ResolveBaseMetricsFn;
|
||||
bringToFront: (docId: Identifier | null | undefined) => void;
|
||||
setDraggingId: (docKey: string | null) => void;
|
||||
canvasSize: { width: number; height: number };
|
||||
openOverlayForDoc?: (
|
||||
docId: Identifier | null | undefined,
|
||||
originInfo?: { rotation: number; scale: number; width: number; height: number },
|
||||
) => void;
|
||||
recalcVisibleDocIds: () => void;
|
||||
settings?: DragSettings;
|
||||
containerRef?: RefObject<HTMLElement>;
|
||||
onInspectDocument?: (docId: Identifier | null | undefined, event?: PointerEvent | ReactPointerEvent) => void;
|
||||
onDocumentStackSelect?: (
|
||||
docIds: Identifier[],
|
||||
event: PointerEvent | ReactPointerEvent,
|
||||
options?: { replace?: boolean },
|
||||
) => void;
|
||||
selectedDocumentIds?: Array<Identifier | null | undefined>;
|
||||
markLayoutDirty?: () => void;
|
||||
}
|
||||
|
||||
type EngineInertiaState = Parameters<WorkspaceEngine['startInertiaAnimation']>[1];
|
||||
|
||||
interface DragStateInternal extends EngineDragState {
|
||||
docId: string;
|
||||
docKey: string;
|
||||
pointerId: number;
|
||||
originCenterX: number;
|
||||
originCenterY: number;
|
||||
currentCenterX: number;
|
||||
currentCenterY: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
rotation: number;
|
||||
restRotation: number;
|
||||
dynamicRotation: number;
|
||||
angularVelocity: number;
|
||||
moved: boolean;
|
||||
locked: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
dragScale: number;
|
||||
baseScale: number;
|
||||
capturedTarget: HTMLElement | null;
|
||||
lastClientX: number;
|
||||
lastClientY: number;
|
||||
lastTimestamp: number;
|
||||
localPointerOffsetX: number;
|
||||
localPointerOffsetY: number;
|
||||
containerRectLeft: number;
|
||||
containerRectTop: number;
|
||||
isGroup: boolean;
|
||||
activeDocIds: string[];
|
||||
groupItems: DragGroupItemInternal[];
|
||||
groupElevated: boolean;
|
||||
stackDocIds: string[] | null;
|
||||
stackSelectionApplied: boolean;
|
||||
stackReplace: boolean;
|
||||
}
|
||||
|
||||
type PointerEventLike = PointerEvent | ReactPointerEvent<HTMLElement>;
|
||||
|
||||
interface DragTapMetadata {
|
||||
docId: Identifier | null;
|
||||
originInfo?: { rotation: number; scale: number; width: number; height: number };
|
||||
docTitle: string;
|
||||
}
|
||||
|
||||
const DRAG_HYSTERESIS_PX = 4;
|
||||
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
|
||||
const EDGE_COLLISION_THRESHOLD = 0.5;
|
||||
|
||||
const getEventTargetElement = (event) => {
|
||||
const getEventTargetElement = (event?: PointerEventLike | null): Element | null => {
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
const ElementCtor = window.Element;
|
||||
const ElementCtor = typeof window !== 'undefined' ? window.Element : null;
|
||||
if (!ElementCtor) {
|
||||
return null;
|
||||
}
|
||||
const candidate = event.target || (event.nativeEvent ? event.nativeEvent.target : null);
|
||||
const nativeEvent = 'nativeEvent' in event ? (event as ReactPointerEvent).nativeEvent : null;
|
||||
const candidate = (event.target as Element | null) || (nativeEvent ? (nativeEvent.target as Element | null) : null);
|
||||
return candidate instanceof ElementCtor ? candidate : null;
|
||||
};
|
||||
|
||||
const useDocumentDrag = (options = {}) => {
|
||||
const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
||||
const {
|
||||
engine,
|
||||
layoutRef,
|
||||
@@ -35,13 +188,16 @@ const useDocumentDrag = (options = {}) => {
|
||||
openOverlayForDoc,
|
||||
recalcVisibleDocIds,
|
||||
settings,
|
||||
containerRef,
|
||||
containerRef: providedContainerRef,
|
||||
onInspectDocument,
|
||||
onDocumentStackSelect,
|
||||
selectedDocumentIds,
|
||||
selectedDocumentIds = [],
|
||||
markLayoutDirty,
|
||||
} = options;
|
||||
|
||||
const fallbackContainerRef = useRef<HTMLElement | null>(null);
|
||||
const containerRef = providedContainerRef ?? fallbackContainerRef;
|
||||
|
||||
const {
|
||||
canvasPadding = 24,
|
||||
defaultCanvasWidth = 1024,
|
||||
@@ -56,23 +212,23 @@ const useDocumentDrag = (options = {}) => {
|
||||
[engine],
|
||||
);
|
||||
|
||||
const tapHandler = usePointerTap({
|
||||
const tapHandler = usePointerTap<DragTapMetadata>({
|
||||
delay: 220,
|
||||
onSingle: () => {},
|
||||
onDouble: ({ data, event }) => {
|
||||
if (!data || !data.docId) {
|
||||
if (!data?.docId) {
|
||||
return;
|
||||
}
|
||||
if (event?.altKey) {
|
||||
openOverlayForDoc(data.docId, data.originInfo);
|
||||
openOverlayForDoc?.(data.docId, data.originInfo);
|
||||
return;
|
||||
}
|
||||
onInspectDocument?.(data.docId, event);
|
||||
},
|
||||
});
|
||||
const dragStateRef = useRef(null);
|
||||
const dragStateRef = useRef<DragStateInternal | null>(null);
|
||||
|
||||
const setDragTransform = useCallback((docKey, transform) => {
|
||||
const setDragTransform = useCallback((docKey: Identifier | null | undefined, transform: DragTransform | null) => {
|
||||
if (!docKey) {
|
||||
return;
|
||||
}
|
||||
@@ -80,7 +236,11 @@ const useDocumentDrag = (options = {}) => {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
map.set(String(docKey), transform);
|
||||
if (transform) {
|
||||
map.set(String(docKey), transform);
|
||||
} else {
|
||||
map.delete(String(docKey));
|
||||
}
|
||||
}, [dragTransformsRef]);
|
||||
|
||||
const clearDragTransforms = useCallback(() => {
|
||||
@@ -91,13 +251,15 @@ const useDocumentDrag = (options = {}) => {
|
||||
map.clear();
|
||||
}, [dragTransformsRef]);
|
||||
|
||||
const commitActiveDragTransforms = useCallback((docIds = null) => {
|
||||
const commitActiveDragTransforms = useCallback((docIds: Array<Identifier | null | undefined> | null = null) => {
|
||||
const map = dragTransformsRef?.current;
|
||||
if (!map || !map.size) {
|
||||
return;
|
||||
}
|
||||
const keys = Array.isArray(docIds) && docIds.length
|
||||
? docIds.map((id) => (id != null ? String(id) : null)).filter(Boolean)
|
||||
? docIds
|
||||
.map((id) => (id != null ? String(id) : null))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
: Array.from(map.keys());
|
||||
keys.forEach((key) => {
|
||||
const transform = map.get(key);
|
||||
@@ -116,7 +278,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
}, [dragTransformsRef, layoutRef, markLayoutDirty]);
|
||||
|
||||
const finishDrag = useCallback(
|
||||
(pointerId, { clearTransforms = true } = {}) => {
|
||||
(pointerId: number, { clearTransforms = true }: { clearTransforms?: boolean } = {}) => {
|
||||
const state = dragStateRef.current;
|
||||
if (state && state.pointerId === pointerId) {
|
||||
const capturedTarget = state.capturedTarget;
|
||||
@@ -141,7 +303,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event, docIdInput, options = {}) => {
|
||||
(event: PointerEventLike, docIdInput?: Identifier | null, options: PointerDownOptions = {}) => {
|
||||
const targetElement = getEventTargetElement(event);
|
||||
if (targetElement?.closest && targetElement.closest('[data-desk-tag-chip="true"]')) {
|
||||
return;
|
||||
@@ -165,7 +327,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
const stackDocIdsOption = Array.isArray(stackDocIdsOptionRaw)
|
||||
? stackDocIdsOptionRaw
|
||||
.map((value) => (value != null ? String(value) : null))
|
||||
.filter(Boolean)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
: null;
|
||||
const stackSelectionAppliedInitial = Boolean(options?.stackSelectionApplied);
|
||||
const wasSelectedAtPointerDown = Boolean(options?.wasSelected);
|
||||
@@ -173,8 +335,10 @@ const useDocumentDrag = (options = {}) => {
|
||||
options?.modifierActive ?? Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
|
||||
const stackReplace = Boolean(options?.stackReplace);
|
||||
|
||||
let selectionIds = Array.isArray(selectedDocumentIds)
|
||||
? selectedDocumentIds.map((id) => String(id))
|
||||
let selectionIds: string[] = Array.isArray(selectedDocumentIds)
|
||||
? selectedDocumentIds
|
||||
.map((id) => (id != null ? String(id) : null))
|
||||
.filter((id): id is string => Boolean(id))
|
||||
: [];
|
||||
|
||||
if (!stackDocIdsOption && !pointerModifierActive && !wasSelectedAtPointerDown) {
|
||||
@@ -196,9 +360,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
selectionIds = [...selectionIds, docKey];
|
||||
}
|
||||
|
||||
selectionIds = selectionIds
|
||||
.map((id) => String(id))
|
||||
.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id));
|
||||
selectionIds = selectionIds.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id));
|
||||
|
||||
if (!selectionIds.includes(docKey)) {
|
||||
selectionIds.unshift(docKey);
|
||||
@@ -266,7 +428,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
const containerRect = containerRef?.current?.getBoundingClientRect?.() || null;
|
||||
const containerRect = containerRef.current?.getBoundingClientRect?.() || null;
|
||||
const containerLeft = containerRect?.left || 0;
|
||||
const containerTop = containerRect?.top || 0;
|
||||
const pointerCanvasX = event.clientX - containerLeft;
|
||||
@@ -280,7 +442,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial;
|
||||
const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial;
|
||||
|
||||
const groupItems = selectionIds.map((id) => {
|
||||
const groupItems: DragGroupItemInternal[] = selectionIds.map((id) => {
|
||||
const itemDoc = documentLookup.get(id);
|
||||
const itemSize = ensureDocumentSize(itemDoc) || sizeInfo;
|
||||
const itemWidth = itemSize.width || docWidth;
|
||||
@@ -307,7 +469,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
initialRotation,
|
||||
displayRotation: initialRotation,
|
||||
targetRotation,
|
||||
};
|
||||
} satisfies DragGroupItemInternal;
|
||||
});
|
||||
|
||||
const eventTimestamp =
|
||||
@@ -354,9 +516,12 @@ const useDocumentDrag = (options = {}) => {
|
||||
stackDocIds: hasStackSource ? stackDocIdsOption : null,
|
||||
stackSelectionApplied: stackSelectionAppliedInitial || !hasStackSource,
|
||||
stackReplace,
|
||||
};
|
||||
} satisfies DragStateInternal;
|
||||
|
||||
const state = dragStateRef.current;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearDragTransforms();
|
||||
state.groupItems.forEach((item) => {
|
||||
@@ -416,7 +581,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
]);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event) => {
|
||||
(event: PointerEventLike) => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state) {
|
||||
return;
|
||||
@@ -427,7 +592,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
preventAll(event);
|
||||
|
||||
if (state.isGroup) {
|
||||
const containerRect = containerRef?.current?.getBoundingClientRect?.();
|
||||
const containerRect = containerRef.current?.getBoundingClientRect?.();
|
||||
if (containerRect) {
|
||||
state.containerRectLeft = containerRect.left;
|
||||
state.containerRectTop = containerRect.top;
|
||||
@@ -449,7 +614,9 @@ const useDocumentDrag = (options = {}) => {
|
||||
&& Array.isArray(state.stackDocIds)
|
||||
&& state.stackDocIds.length > 0
|
||||
) {
|
||||
safeInvoke(onDocumentStackSelect, state.stackDocIds, event, { replace: state.stackReplace });
|
||||
safeInvoke(onDocumentStackSelect, state.stackDocIds as Identifier[], event, {
|
||||
replace: state.stackReplace,
|
||||
});
|
||||
state.stackSelectionApplied = true;
|
||||
}
|
||||
if (!state.groupElevated) {
|
||||
@@ -561,7 +728,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
const halfWidth = docWidth / 2;
|
||||
const halfHeight = docHeight / 2;
|
||||
|
||||
const containerRect = containerRef?.current?.getBoundingClientRect?.();
|
||||
const containerRect = containerRef.current?.getBoundingClientRect?.();
|
||||
if (containerRect) {
|
||||
state.containerRectLeft = containerRect.left;
|
||||
state.containerRectTop = containerRect.top;
|
||||
@@ -712,7 +879,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(event) => {
|
||||
(event: PointerEventLike) => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state || state.pointerId !== event.pointerId) {
|
||||
finishDrag(event.pointerId);
|
||||
@@ -729,7 +896,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
|
||||
if (state.moved) {
|
||||
commitActiveDragTransforms([state.docKey]);
|
||||
const inertiaState = {
|
||||
const inertiaState: EngineInertiaState = {
|
||||
restRotation: state.restRotation,
|
||||
dynamicRotation: state.dynamicRotation,
|
||||
angularVelocity: state.angularVelocity,
|
||||
@@ -776,7 +943,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
);
|
||||
|
||||
const handlePointerCancel = useCallback(
|
||||
(event) => {
|
||||
(event: PointerEventLike) => {
|
||||
const state = dragStateRef.current;
|
||||
if (state && state.pointerId === event.pointerId && state.moved) {
|
||||
if (state.isGroup) {
|
||||
@@ -788,7 +955,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
}
|
||||
|
||||
commitActiveDragTransforms([state.docKey]);
|
||||
const inertiaState = {
|
||||
const inertiaState: EngineInertiaState = {
|
||||
restRotation: state.restRotation,
|
||||
dynamicRotation: state.dynamicRotation,
|
||||
angularVelocity: state.angularVelocity,
|
||||
+295
-75
@@ -1,5 +1,131 @@
|
||||
import { clamp, formatTransform } from './math.js';
|
||||
import { fetchLayoutRecords, upsertLayoutRecords } from './db.js';
|
||||
import { clamp, formatTransform } from './math';
|
||||
import { fetchLayoutRecords, upsertLayoutRecords } from './db';
|
||||
|
||||
type DocumentId = string;
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
type Polygon = Point[];
|
||||
|
||||
interface TransformOptions {
|
||||
centerX?: number;
|
||||
centerY?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
rotation?: number;
|
||||
scale?: number;
|
||||
zIndex?: number | null;
|
||||
}
|
||||
|
||||
interface CardDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface LayoutEntry {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
z: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface LayoutGenerationEntry {
|
||||
id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
seedKey: string;
|
||||
}
|
||||
|
||||
interface LayoutGenerationOptions {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
padding: number;
|
||||
startZ?: number;
|
||||
rotationRange?: number;
|
||||
minSpacing?: number;
|
||||
shelfWidth?: number;
|
||||
}
|
||||
|
||||
interface DocumentSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface BaseMetrics {
|
||||
baseWidth: number;
|
||||
baseHeight: number;
|
||||
baseScale: number;
|
||||
}
|
||||
|
||||
interface DragGroupItem {
|
||||
docId?: string | number | null;
|
||||
width: number;
|
||||
height: number;
|
||||
currentCenterX?: number;
|
||||
currentCenterY?: number;
|
||||
displayRotation?: number;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
docKey?: string | null;
|
||||
dragScale?: number;
|
||||
originCenterX?: number;
|
||||
originCenterY?: number;
|
||||
groupItems?: DragGroupItem[] | null;
|
||||
}
|
||||
|
||||
interface InertiaSimulationState {
|
||||
docId: string;
|
||||
restRotation: number;
|
||||
rotation: number;
|
||||
dynamicRotation: number;
|
||||
angularVelocity: number;
|
||||
width: number;
|
||||
height: number;
|
||||
dragScale?: number;
|
||||
lastTimestamp: number;
|
||||
frameId?: number;
|
||||
}
|
||||
|
||||
interface WorkspaceSnapshot {
|
||||
layout: Map<DocumentId, LayoutEntry>;
|
||||
canvasSize: { width: number; height: number };
|
||||
visibleDocIds: Set<DocumentId>;
|
||||
draggingId: string | null;
|
||||
tagDropTargetId: string | null;
|
||||
pendingTagDocId: string | null;
|
||||
pendingRemovalTag: unknown;
|
||||
initialLoadDone: boolean;
|
||||
}
|
||||
|
||||
type WorkspaceSubscriber = () => void;
|
||||
|
||||
type DeskDocument = { id?: string | number | null } & Record<string, unknown>;
|
||||
|
||||
type EnsureDocumentSize = (doc: DeskDocument) => DocumentSize | null | undefined;
|
||||
|
||||
type ResolveBaseMetrics = () => BaseMetrics;
|
||||
|
||||
interface ItemRefs {
|
||||
current: Map<string, HTMLElement | null>;
|
||||
}
|
||||
|
||||
interface WorkspaceEngineOptions {
|
||||
allowLayoutPersistence?: boolean;
|
||||
tenantId?: string | null;
|
||||
viewId?: string | null;
|
||||
}
|
||||
|
||||
type UseSyncExternalStoreHook = <State>(
|
||||
subscribe: (listener: () => void) => () => void,
|
||||
getSnapshot: () => State,
|
||||
getServerSnapshot: () => State,
|
||||
) => State;
|
||||
|
||||
export const DESK_CANVAS_PADDING = 24;
|
||||
export const DESK_ROTATION_RANGE = 7;
|
||||
@@ -18,7 +144,7 @@ export const TORQUE_TO_ACCELERATION = 0.006;
|
||||
export const SETTLE_ANGULAR_VELOCITY = 1.2;
|
||||
|
||||
export const applyDomTransform = (
|
||||
node,
|
||||
node: HTMLElement | null | undefined,
|
||||
{
|
||||
centerX,
|
||||
centerY,
|
||||
@@ -27,8 +153,8 @@ export const applyDomTransform = (
|
||||
rotation = 0,
|
||||
scale = 1,
|
||||
zIndex,
|
||||
} = {},
|
||||
) => {
|
||||
}: TransformOptions = {},
|
||||
): void => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
@@ -44,7 +170,7 @@ export const applyDomTransform = (
|
||||
}
|
||||
};
|
||||
|
||||
export const clampCardDimensions = (width, height) => {
|
||||
export const clampCardDimensions = (width: number, height: number): CardDimensions | null => {
|
||||
const w = Number(width);
|
||||
const h = Number(height);
|
||||
|
||||
@@ -56,7 +182,7 @@ export const clampCardDimensions = (width, height) => {
|
||||
const high = Math.min(DESK_CARD_MAX / w, DESK_CARD_MAX / h);
|
||||
|
||||
const candidates = [];
|
||||
const addCandidate = (scale) => {
|
||||
const addCandidate = (scale: number) => {
|
||||
if (Number.isFinite(scale) && scale > 0) {
|
||||
candidates.push(scale);
|
||||
}
|
||||
@@ -66,7 +192,7 @@ export const clampCardDimensions = (width, height) => {
|
||||
addCandidate(low);
|
||||
addCandidate(high);
|
||||
|
||||
const best = candidates.reduce((acc, scale) => {
|
||||
const best = candidates.reduce<{ scale: number; violation: number; deviation: number } | null>((acc, scale) => {
|
||||
const scaledWidth = w * scale;
|
||||
const scaledHeight = h * scale;
|
||||
const violation = Math.max(
|
||||
@@ -89,7 +215,7 @@ export const clampCardDimensions = (width, height) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const computeFallbackCardSize = (docId) => {
|
||||
export const computeFallbackCardSize = (docId: string | number): CardDimensions | null => {
|
||||
const baseSeed = seededRandom(`${docId}:fallback-size`);
|
||||
const aspectSeed = seededRandom(`${docId}:fallback-aspect`);
|
||||
|
||||
@@ -105,7 +231,7 @@ export const computeFallbackCardSize = (docId) => {
|
||||
return clampCardDimensions(width, height);
|
||||
};
|
||||
|
||||
function seededRandom(input) {
|
||||
function seededRandom(input: unknown): number {
|
||||
const text = String(input);
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
@@ -115,22 +241,25 @@ function seededRandom(input) {
|
||||
return (hash >>> 0) / 4294967295;
|
||||
}
|
||||
|
||||
function randomRangeFromSeed(seedKey, min, max) {
|
||||
function randomRangeFromSeed(seedKey: string, min: number, max: number): number {
|
||||
const span = max - min;
|
||||
if (span <= 0) return min;
|
||||
const seed = seededRandom(seedKey);
|
||||
return min + seed * span;
|
||||
}
|
||||
|
||||
function buildKey(docId, suffix) {
|
||||
function buildKey(docId: string | number, suffix: string): string {
|
||||
return `${docId}::${suffix}`;
|
||||
}
|
||||
|
||||
const signedDistanceToEdge = (edgeStart, edgeEnd, point) =>
|
||||
const signedDistanceToEdge = (edgeStart: Point, edgeEnd: Point, point: Point): number =>
|
||||
(edgeEnd.x - edgeStart.x) * (point.y - edgeStart.y)
|
||||
- (edgeEnd.y - edgeStart.y) * (point.x - edgeStart.x);
|
||||
|
||||
const iterateEdges = (polygon, callback) => {
|
||||
const iterateEdges = (
|
||||
polygon: Polygon,
|
||||
callback: (current: Point, next: Point, index: number) => boolean | void,
|
||||
): void => {
|
||||
if (!Array.isArray(polygon) || polygon.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -143,7 +272,10 @@ const iterateEdges = (polygon, callback) => {
|
||||
}
|
||||
};
|
||||
|
||||
const forEachVertex = (polygon, callback) => {
|
||||
const forEachVertex = (
|
||||
polygon: Polygon,
|
||||
callback: (current: Point, previous: Point, index: number) => boolean | void,
|
||||
): void => {
|
||||
if (!Array.isArray(polygon) || polygon.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -156,7 +288,7 @@ const forEachVertex = (polygon, callback) => {
|
||||
}
|
||||
};
|
||||
|
||||
const lineIntersection = (p1, p2, cp1, cp2) => {
|
||||
const lineIntersection = (p1: Point, p2: Point, cp1: Point, cp2: Point): Point => {
|
||||
const A1 = p2.y - p1.y;
|
||||
const B1 = p1.x - p2.x;
|
||||
const C1 = A1 * p1.x + B1 * p1.y;
|
||||
@@ -175,7 +307,7 @@ const lineIntersection = (p1, p2, cp1, cp2) => {
|
||||
};
|
||||
};
|
||||
|
||||
const clipPolygon = (subject, clipper) => {
|
||||
const clipPolygon = (subject: Polygon, clipper: Polygon): Polygon => {
|
||||
if (!Array.isArray(subject) || !subject.length) {
|
||||
return [];
|
||||
}
|
||||
@@ -204,7 +336,7 @@ const clipPolygon = (subject, clipper) => {
|
||||
return output;
|
||||
};
|
||||
|
||||
const isPointInsideConvex = (point, polygon) => {
|
||||
const isPointInsideConvex = (point: Point, polygon: Polygon): boolean => {
|
||||
if (!polygon?.length) {
|
||||
return false;
|
||||
}
|
||||
@@ -229,7 +361,7 @@ const isPointInsideConvex = (point, polygon) => {
|
||||
return inside;
|
||||
};
|
||||
|
||||
const polygonCentroid = (polygon) => {
|
||||
const polygonCentroid = (polygon: Polygon): Point => {
|
||||
if (!polygon?.length) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
@@ -261,7 +393,7 @@ const polygonCentroid = (polygon) => {
|
||||
};
|
||||
};
|
||||
const generateInitialLayout = (
|
||||
entries,
|
||||
entries: LayoutGenerationEntry[],
|
||||
{
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
@@ -270,9 +402,9 @@ const generateInitialLayout = (
|
||||
rotationRange = DESK_ROTATION_RANGE,
|
||||
minSpacing = 48,
|
||||
shelfWidth = 0,
|
||||
},
|
||||
) => {
|
||||
const layout = new Map();
|
||||
}: LayoutGenerationOptions,
|
||||
): { layout: Map<string, LayoutEntry>; maxZ: number } => {
|
||||
const layout = new Map<string, LayoutEntry>();
|
||||
let currentZ = startZ;
|
||||
let maxZ = startZ;
|
||||
|
||||
@@ -282,9 +414,9 @@ const generateInitialLayout = (
|
||||
|
||||
const shelfOffset = Math.max(shelfWidth, 0);
|
||||
const spacingBuffer = Math.max(minSpacing, 0);
|
||||
const placed = [];
|
||||
const placed: Array<{ x: number; y: number; radius: number }> = [];
|
||||
|
||||
const resolveBounds = (width, height) => {
|
||||
const resolveBounds = (width: number, height: number) => {
|
||||
const halfWidth = width / 2;
|
||||
const halfHeight = height / 2;
|
||||
return {
|
||||
@@ -298,7 +430,7 @@ const generateInitialLayout = (
|
||||
};
|
||||
};
|
||||
|
||||
const evaluateCandidateSpacing = (x, y, radius) => {
|
||||
const evaluateCandidateSpacing = (x: number, y: number, radius: number) => {
|
||||
if (!placed.length) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
@@ -381,11 +513,71 @@ const generateInitialLayout = (
|
||||
};
|
||||
|
||||
export class WorkspaceEngine {
|
||||
allowLayoutPersistence: boolean;
|
||||
|
||||
tenantId: string | null;
|
||||
|
||||
viewId: string | null;
|
||||
|
||||
layout: Map<DocumentId, LayoutEntry>;
|
||||
|
||||
layoutSnapshot: Map<DocumentId, LayoutEntry>;
|
||||
|
||||
persistedLayout: Map<DocumentId, LayoutEntry>;
|
||||
|
||||
layoutDirty: boolean;
|
||||
|
||||
zCounter: number;
|
||||
|
||||
canvasSize: { width: number; height: number };
|
||||
|
||||
visibleDocIds: Set<DocumentId>;
|
||||
|
||||
draggingId: string | null;
|
||||
|
||||
tagDropTargetId: string | null;
|
||||
|
||||
pendingTagDocId: string | null;
|
||||
|
||||
pendingRemovalTag: unknown;
|
||||
|
||||
dragInProgress: boolean;
|
||||
|
||||
activeDragDocIds: Set<DocumentId>;
|
||||
|
||||
pendingSnapshotSync: boolean;
|
||||
|
||||
pendingPersistSync: boolean;
|
||||
|
||||
persistDebounceId: number | null;
|
||||
|
||||
items: DeskDocument[];
|
||||
|
||||
documentLookup: Map<string, DeskDocument>;
|
||||
|
||||
ensureDocumentSize: EnsureDocumentSize;
|
||||
|
||||
resolveBaseMetrics: ResolveBaseMetrics;
|
||||
|
||||
snapshotCache: WorkspaceSnapshot;
|
||||
|
||||
subscribers: Set<WorkspaceSubscriber>;
|
||||
|
||||
loadingPersisted: boolean;
|
||||
|
||||
pendingPersistence: unknown;
|
||||
|
||||
itemRefs: ItemRefs;
|
||||
|
||||
inertiaAnimations: Map<string, InertiaSimulationState>;
|
||||
|
||||
initialLoadDone: boolean;
|
||||
|
||||
constructor({
|
||||
allowLayoutPersistence = false,
|
||||
tenantId = null,
|
||||
viewId = null,
|
||||
} = {}) {
|
||||
}: WorkspaceEngineOptions = {}) {
|
||||
this.allowLayoutPersistence = allowLayoutPersistence;
|
||||
this.tenantId = tenantId;
|
||||
this.viewId = viewId;
|
||||
@@ -424,7 +616,7 @@ export class WorkspaceEngine {
|
||||
this.initialLoadDone = false;
|
||||
}
|
||||
|
||||
updateConfig({ allowLayoutPersistence, tenantId, viewId }) {
|
||||
updateConfig({ allowLayoutPersistence, tenantId, viewId }: WorkspaceEngineOptions): void {
|
||||
const allowChanged =
|
||||
typeof allowLayoutPersistence === 'boolean'
|
||||
&& allowLayoutPersistence !== this.allowLayoutPersistence;
|
||||
@@ -470,7 +662,7 @@ export class WorkspaceEngine {
|
||||
}
|
||||
}
|
||||
|
||||
setItems(items) {
|
||||
setItems(items: DeskDocument[] | null | undefined): void {
|
||||
const normalized = Array.isArray(items) ? items : [];
|
||||
this.items = normalized;
|
||||
const canGenerateLayoutImmediately =
|
||||
@@ -484,28 +676,28 @@ export class WorkspaceEngine {
|
||||
this.recalcVisibleDocIds();
|
||||
}
|
||||
|
||||
setDocumentLookup(map) {
|
||||
setDocumentLookup(map: Map<string, DeskDocument>): void {
|
||||
this.documentLookup = map instanceof Map ? map : new Map();
|
||||
this.recalcVisibleDocIds();
|
||||
}
|
||||
|
||||
setEnsureDocumentSize(fn) {
|
||||
setEnsureDocumentSize(fn: EnsureDocumentSize): void {
|
||||
if (typeof fn === 'function') {
|
||||
this.ensureDocumentSize = fn;
|
||||
}
|
||||
}
|
||||
|
||||
setResolveBaseMetrics(fn) {
|
||||
setResolveBaseMetrics(fn: ResolveBaseMetrics): void {
|
||||
if (typeof fn === 'function') {
|
||||
this.resolveBaseMetrics = fn;
|
||||
}
|
||||
}
|
||||
|
||||
setItemRefs(ref) {
|
||||
setItemRefs(ref: ItemRefs | null | undefined): void {
|
||||
this.itemRefs = ref || { current: new Map() };
|
||||
}
|
||||
|
||||
setCanvasSize(size) {
|
||||
setCanvasSize(size: { width?: number | null; height?: number | null }): void {
|
||||
const width = Number(size?.width) || 0;
|
||||
const height = Number(size?.height) || 0;
|
||||
if (this.canvasSize.width === width && this.canvasSize.height === height) {
|
||||
@@ -517,7 +709,7 @@ export class WorkspaceEngine {
|
||||
this.emit();
|
||||
}
|
||||
|
||||
setDraggingId(docId) {
|
||||
setDraggingId(docId: string | number | null): void {
|
||||
const normalized = docId != null ? String(docId) : null;
|
||||
if (this.draggingId === normalized) {
|
||||
return;
|
||||
@@ -526,7 +718,7 @@ export class WorkspaceEngine {
|
||||
this.emit();
|
||||
}
|
||||
|
||||
beginDrag(docIds = []) {
|
||||
beginDrag(docIds: Array<string | number | null> = []): void {
|
||||
this.dragInProgress = true;
|
||||
if (Array.isArray(docIds)) {
|
||||
this.activeDragDocIds = new Set(docIds.map((id) => (id != null ? String(id) : null)).filter(Boolean));
|
||||
@@ -535,13 +727,13 @@ export class WorkspaceEngine {
|
||||
}
|
||||
}
|
||||
|
||||
endDrag() {
|
||||
endDrag(): void {
|
||||
this.dragInProgress = false;
|
||||
this.activeDragDocIds.clear();
|
||||
this.flushPendingLayoutOps();
|
||||
}
|
||||
|
||||
flushPendingLayoutOps() {
|
||||
flushPendingLayoutOps(): void {
|
||||
if (this.pendingSnapshotSync) {
|
||||
this.syncLayoutSnapshot();
|
||||
}
|
||||
@@ -550,7 +742,7 @@ export class WorkspaceEngine {
|
||||
}
|
||||
}
|
||||
|
||||
setTagDropTargetId(docId) {
|
||||
setTagDropTargetId(docId: string | number | null): void {
|
||||
const normalized = docId != null ? String(docId) : null;
|
||||
if (this.tagDropTargetId === normalized) {
|
||||
return;
|
||||
@@ -559,7 +751,7 @@ export class WorkspaceEngine {
|
||||
this.emit();
|
||||
}
|
||||
|
||||
setPendingTagDocId(docId) {
|
||||
setPendingTagDocId(docId: string | number | null): void {
|
||||
const normalized = docId != null ? String(docId) : null;
|
||||
if (this.pendingTagDocId === normalized) {
|
||||
return;
|
||||
@@ -568,7 +760,7 @@ export class WorkspaceEngine {
|
||||
this.emit();
|
||||
}
|
||||
|
||||
setPendingRemovalTag(payload) {
|
||||
setPendingRemovalTag(payload: unknown): void {
|
||||
if (payload === this.pendingRemovalTag) {
|
||||
return;
|
||||
}
|
||||
@@ -576,11 +768,11 @@ export class WorkspaceEngine {
|
||||
this.emit();
|
||||
}
|
||||
|
||||
markLayoutDirty() {
|
||||
markLayoutDirty(): void {
|
||||
this.layoutDirty = true;
|
||||
}
|
||||
|
||||
getLayout(docId) {
|
||||
getLayout(docId: string | number | null): LayoutEntry | null {
|
||||
if (docId == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -588,13 +780,20 @@ export class WorkspaceEngine {
|
||||
return this.layout.get(key) || null;
|
||||
}
|
||||
|
||||
updateLayoutEntry(docId, updater) {
|
||||
updateLayoutEntry(
|
||||
docId: string | number | null,
|
||||
updater:
|
||||
| LayoutEntry
|
||||
| null
|
||||
| undefined
|
||||
| ((previous: LayoutEntry | null) => LayoutEntry | null | undefined),
|
||||
): void {
|
||||
if (docId == null) {
|
||||
return;
|
||||
}
|
||||
const key = String(docId);
|
||||
const previous = this.layout.get(key) || null;
|
||||
const next = typeof updater === 'function' ? updater(previous || {}) : updater;
|
||||
const next = typeof updater === 'function' ? updater(previous) : updater;
|
||||
if (!next) {
|
||||
this.layout.delete(key);
|
||||
} else {
|
||||
@@ -605,7 +804,7 @@ export class WorkspaceEngine {
|
||||
this.persistLayoutSnapshot();
|
||||
}
|
||||
|
||||
bringToFront(docId) {
|
||||
bringToFront(docId: string | number | null): void {
|
||||
if (docId == null) {
|
||||
return;
|
||||
}
|
||||
@@ -622,7 +821,16 @@ export class WorkspaceEngine {
|
||||
this.recalcVisibleDocIds();
|
||||
}
|
||||
|
||||
applyTransform(docId, centerX, centerY, width, height, rotation, scale = 1, zIndex = null) {
|
||||
applyTransform(
|
||||
docId: string | number | null,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
rotation: number,
|
||||
scale = 1,
|
||||
zIndex: number | null = null,
|
||||
): void {
|
||||
const key = docId != null ? String(docId) : null;
|
||||
if (!key) {
|
||||
return;
|
||||
@@ -639,7 +847,7 @@ export class WorkspaceEngine {
|
||||
});
|
||||
}
|
||||
|
||||
finalizeGroupDrag(dragState) {
|
||||
finalizeGroupDrag(dragState: DragState): void {
|
||||
if (!dragState?.groupItems) {
|
||||
return;
|
||||
}
|
||||
@@ -652,16 +860,18 @@ export class WorkspaceEngine {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const entry = this.layout.get(key) || {};
|
||||
const centerX = item.currentCenterX ?? entry.centerX ?? dragState.originCenterX;
|
||||
const centerY = item.currentCenterY ?? entry.centerY ?? dragState.originCenterY;
|
||||
const rotation = item.displayRotation ?? entry.rotation ?? 0;
|
||||
const entry = this.layout.get(key);
|
||||
const centerX = item.currentCenterX ?? entry?.centerX ?? dragState.originCenterX ?? 0;
|
||||
const centerY = item.currentCenterY ?? entry?.centerY ?? dragState.originCenterY ?? 0;
|
||||
const rotation = item.displayRotation ?? entry?.rotation ?? 0;
|
||||
|
||||
const nextEntry = {
|
||||
...entry,
|
||||
const nextEntry: LayoutEntry = {
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
z: entry?.z ?? this.zCounter,
|
||||
width: entry?.width ?? item.width,
|
||||
height: entry?.height ?? item.height,
|
||||
};
|
||||
|
||||
this.layout.set(key, nextEntry);
|
||||
@@ -683,7 +893,7 @@ export class WorkspaceEngine {
|
||||
this.persistLayoutSnapshot();
|
||||
}
|
||||
|
||||
cancelInertiaAnimation(docId) {
|
||||
cancelInertiaAnimation(docId: string | number | null): void {
|
||||
const key = docId != null ? String(docId) : null;
|
||||
if (!key) {
|
||||
return;
|
||||
@@ -695,7 +905,7 @@ export class WorkspaceEngine {
|
||||
this.inertiaAnimations.delete(key);
|
||||
}
|
||||
|
||||
disposeInertiaAnimations() {
|
||||
disposeInertiaAnimations(): void {
|
||||
this.inertiaAnimations.forEach((animation) => {
|
||||
if (animation?.frameId != null) {
|
||||
window.cancelAnimationFrame(animation.frameId);
|
||||
@@ -704,7 +914,12 @@ export class WorkspaceEngine {
|
||||
this.inertiaAnimations.clear();
|
||||
}
|
||||
|
||||
integrateRotation(simulationState, dt, torque = 0, dampingOverride = null) {
|
||||
integrateRotation(
|
||||
simulationState: InertiaSimulationState,
|
||||
dt: number,
|
||||
torque = 0,
|
||||
dampingOverride: number | null = null,
|
||||
): boolean {
|
||||
const key = simulationState.docId != null ? String(simulationState.docId) : null;
|
||||
if (!key) {
|
||||
return true;
|
||||
@@ -761,7 +976,7 @@ export class WorkspaceEngine {
|
||||
return isSettled;
|
||||
}
|
||||
|
||||
startInertiaAnimation(docId, baseState) {
|
||||
startInertiaAnimation(docId: string | number | null, baseState: InertiaSimulationState): void {
|
||||
const raf = window.requestAnimationFrame;
|
||||
if (!raf) {
|
||||
return;
|
||||
@@ -782,7 +997,7 @@ export class WorkspaceEngine {
|
||||
lastTimestamp: now,
|
||||
};
|
||||
|
||||
const step = (timestamp) => {
|
||||
const step = (timestamp: number) => {
|
||||
const safeTimestamp = Number.isFinite(timestamp) ? timestamp : now + 16;
|
||||
const previous = simulationState.lastTimestamp;
|
||||
let dt = (safeTimestamp - previous) / 1000;
|
||||
@@ -806,7 +1021,7 @@ export class WorkspaceEngine {
|
||||
this.inertiaAnimations.set(key, simulationState);
|
||||
}
|
||||
|
||||
syncLayoutSnapshot() {
|
||||
syncLayoutSnapshot(): void {
|
||||
if (this.dragInProgress) {
|
||||
this.pendingSnapshotSync = true;
|
||||
return;
|
||||
@@ -816,7 +1031,7 @@ export class WorkspaceEngine {
|
||||
this.emit();
|
||||
}
|
||||
|
||||
async persistLayoutSnapshot() {
|
||||
async persistLayoutSnapshot(): Promise<void> {
|
||||
if (this.dragInProgress) {
|
||||
this.pendingPersistSync = true;
|
||||
return;
|
||||
@@ -883,7 +1098,7 @@ export class WorkspaceEngine {
|
||||
}, 100);
|
||||
}
|
||||
|
||||
ensureLayoutForItems() {
|
||||
ensureLayoutForItems(): void {
|
||||
const persistenceReady = !this.allowLayoutPersistence || !this.tenantId || !this.viewId || this.initialLoadDone;
|
||||
const canvasReady = Boolean(this.canvasSize.width && this.canvasSize.height);
|
||||
const sizesReady = !this.items.some((doc) => !this.ensureDocumentSize(doc));
|
||||
@@ -907,11 +1122,11 @@ export class WorkspaceEngine {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new Map();
|
||||
const next = new Map<DocumentId, LayoutEntry>();
|
||||
let maxZ = this.zCounter;
|
||||
const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH;
|
||||
const canvasHeight = this.canvasSize.height || DESK_DEFAULT_CANVAS_HEIGHT;
|
||||
const docsNeedingLayout = [];
|
||||
const docsNeedingLayout: LayoutGenerationEntry[] = [];
|
||||
|
||||
const currentEntries = new Map(this.layout);
|
||||
|
||||
@@ -984,7 +1199,7 @@ export class WorkspaceEngine {
|
||||
this.recalcVisibleDocIds();
|
||||
}
|
||||
|
||||
recalcVisibleDocIds() {
|
||||
recalcVisibleDocIds(): void {
|
||||
const ensureSize = this.ensureDocumentSize;
|
||||
if (typeof ensureSize !== 'function') {
|
||||
return;
|
||||
@@ -1009,7 +1224,7 @@ export class WorkspaceEngine {
|
||||
{ x: 0, y: canvasHeight },
|
||||
];
|
||||
|
||||
const entries = [];
|
||||
const entries: Array<{ key: string; z: number; polygon: Polygon }> = [];
|
||||
layoutMap.forEach((entry, docKey) => {
|
||||
if (!docKey) {
|
||||
return;
|
||||
@@ -1062,8 +1277,8 @@ export class WorkspaceEngine {
|
||||
|
||||
entries.sort((a, b) => (b.z || 0) - (a.z || 0));
|
||||
|
||||
const visiblePolygons = [];
|
||||
const result = new Set();
|
||||
const visiblePolygons: Polygon[] = [];
|
||||
const result = new Set<DocumentId>();
|
||||
|
||||
entries.forEach(({ key, polygon }) => {
|
||||
if (polygon.length < 3) {
|
||||
@@ -1117,16 +1332,16 @@ export class WorkspaceEngine {
|
||||
this.emit();
|
||||
}
|
||||
|
||||
subscribe(listener) {
|
||||
subscribe(listener: WorkspaceSubscriber): () => void {
|
||||
this.subscribers.add(listener);
|
||||
return () => {
|
||||
this.subscribers.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
getSnapshot = () => this.snapshotCache;
|
||||
getSnapshot = (): WorkspaceSnapshot => this.snapshotCache;
|
||||
|
||||
buildSnapshot() {
|
||||
buildSnapshot(): WorkspaceSnapshot {
|
||||
return {
|
||||
layout: this.layoutSnapshot,
|
||||
canvasSize: this.canvasSize,
|
||||
@@ -1139,7 +1354,7 @@ export class WorkspaceEngine {
|
||||
};
|
||||
}
|
||||
|
||||
emit() {
|
||||
emit(): void {
|
||||
this.snapshotCache = this.buildSnapshot();
|
||||
this.subscribers.forEach((listener) => {
|
||||
try {
|
||||
@@ -1150,7 +1365,7 @@ export class WorkspaceEngine {
|
||||
});
|
||||
}
|
||||
|
||||
async loadPersistedLayout() {
|
||||
async loadPersistedLayout(): Promise<void> {
|
||||
if (!this.allowLayoutPersistence || !this.tenantId || !this.viewId) {
|
||||
return;
|
||||
}
|
||||
@@ -1198,7 +1413,10 @@ export class WorkspaceEngine {
|
||||
}
|
||||
}
|
||||
|
||||
export const useWorkspaceSnapshot = (engine, useSyncExternalStoreHook) => {
|
||||
export const useWorkspaceSnapshot = (
|
||||
engine: WorkspaceEngine,
|
||||
useSyncExternalStoreHook: UseSyncExternalStoreHook,
|
||||
): WorkspaceSnapshot => {
|
||||
const useSyncExternalStore = useSyncExternalStoreHook;
|
||||
if (typeof useSyncExternalStore !== 'function') {
|
||||
throw new Error('useWorkspaceSnapshot requires useSyncExternalStore hook');
|
||||
@@ -1211,7 +1429,9 @@ export const useWorkspaceSnapshot = (engine, useSyncExternalStoreHook) => {
|
||||
};
|
||||
|
||||
/* istanbul ignore next */
|
||||
const commonJsModule = globalThis?.module;
|
||||
const commonJsModule = (globalThis as typeof globalThis & {
|
||||
module?: { exports?: Record<string, unknown> };
|
||||
}).module;
|
||||
if (commonJsModule?.exports) {
|
||||
commonJsModule.exports = {
|
||||
WorkspaceEngine,
|
||||
Reference in New Issue
Block a user