typescript

This commit is contained in:
2025-11-13 01:07:55 +01:00
parent b812d748ea
commit ada089c05b
147 changed files with 7427 additions and 2534 deletions
@@ -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>
@@ -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;
@@ -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;
@@ -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 };
}
@@ -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 &&
-4
View File
@@ -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})`;
+8
View File
@@ -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})`;
@@ -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,
@@ -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],
);
@@ -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,
@@ -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,