work
This commit is contained in:
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "webpack serve --mode development --open",
|
||||
"build": "webpack --mode production",
|
||||
"lint": "eslint src --ext .js,.jsx"
|
||||
"lint": "eslint src --ext .js,.jsx",
|
||||
"test:engine": "node --test tests/workspaceEngine.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import React, { useCallback, useMemo } from 'react';
|
||||
import { SidebarExpandIcon } from '../ui/icons';
|
||||
import { createDocumentsSurface } from '../documents/DocumentsPanel';
|
||||
import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel';
|
||||
import { createDesktopSurface } from '../DesktopWorkspace';
|
||||
import { createDesktopSurface } from '../desktop/DesktopWorkspace';
|
||||
|
||||
export const useWorkspaceSurface = ({
|
||||
sidebarCollapsed,
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
const DB_NAME = 'papercrate_desk';
|
||||
const DB_VERSION = 1;
|
||||
const LAYOUT_STORE = 'layouts';
|
||||
|
||||
const currentDbPromise = { value: null };
|
||||
|
||||
const openDatabase = () => {
|
||||
if (currentDbPromise.value) {
|
||||
return currentDbPromise.value;
|
||||
}
|
||||
|
||||
currentDbPromise.value = new Promise((resolve, reject) => {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
reject(new Error('IndexedDB not available'));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(LAYOUT_STORE)) {
|
||||
const store = db.createObjectStore(LAYOUT_STORE, {
|
||||
keyPath: ['tenantId', 'viewId', 'documentId'],
|
||||
});
|
||||
store.createIndex('tenantViewIdx', ['tenantId', 'viewId'], { unique: false });
|
||||
store.createIndex('tenantIdx', 'tenantId', { unique: false });
|
||||
store.createIndex('updatedIdx', 'updatedAt', { unique: false });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error('Failed to open IndexedDB'));
|
||||
};
|
||||
});
|
||||
|
||||
return currentDbPromise.value;
|
||||
};
|
||||
|
||||
export const fetchLayoutRecords = async ({ tenantId, viewId }) => {
|
||||
if (!tenantId || !viewId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction(LAYOUT_STORE, 'readonly');
|
||||
const store = transaction.objectStore(LAYOUT_STORE);
|
||||
const index = store.index('tenantViewIdx');
|
||||
const request = index.getAll([tenantId, viewId]);
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result || []);
|
||||
request.onerror = () => reject(request.error || new Error('Failed to fetch layout records'));
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[desk] Failed to read layout records', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => {
|
||||
if (!tenantId || !viewId || !Array.isArray(entries) || !entries.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction(LAYOUT_STORE, 'readwrite');
|
||||
const store = transaction.objectStore(LAYOUT_STORE);
|
||||
const timestamp = Date.now();
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (!entry || !entry.documentId) {
|
||||
return;
|
||||
}
|
||||
store.put({
|
||||
tenantId,
|
||||
viewId,
|
||||
documentId: entry.documentId,
|
||||
centerX: Number(entry.centerX) || 0,
|
||||
centerY: Number(entry.centerY) || 0,
|
||||
rotation: Number(entry.rotation) || 0,
|
||||
zIndex: Number(entry.zIndex) || 0,
|
||||
updatedAt: entry.updatedAt || timestamp,
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('Failed to persist layout records'));
|
||||
transaction.onabort = () => reject(transaction.error || new Error('Layout transaction aborted'));
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[desk] Failed to upsert layout records', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteTenantLayouts = async (tenantId) => {
|
||||
if (!tenantId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction(LAYOUT_STORE, 'readwrite');
|
||||
const store = transaction.objectStore(LAYOUT_STORE);
|
||||
const index = store.index('tenantIdx');
|
||||
const request = index.openCursor(tenantId);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = event.target.result;
|
||||
if (cursor) {
|
||||
cursor.delete();
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
request.onerror = () => reject(request.error || new Error('Failed to delete tenant layouts'));
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[desk] Failed to clean tenant layouts', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const closeDeskDatabase = () => {
|
||||
if (!currentDbPromise.value) {
|
||||
return;
|
||||
}
|
||||
currentDbPromise.value = currentDbPromise.value.then((db) => {
|
||||
try {
|
||||
db.close();
|
||||
} catch (error) {
|
||||
console.warn('[desk] Failed to close IndexedDB', error);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import DesktopPreviewCard from './DesktopPreviewCard';
|
||||
import { resolveCorrespondents } from '../documents/correspondents';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import { preventAll } from './events';
|
||||
|
||||
const DesktopDocumentCard = ({
|
||||
doc,
|
||||
style,
|
||||
shouldLoad,
|
||||
dragging,
|
||||
matchesFilter,
|
||||
tagTargetActive,
|
||||
tagTargetPending,
|
||||
selected,
|
||||
docTagTokens,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
handleNavigatorSnapshot,
|
||||
cardPointerHandlers,
|
||||
onDocumentOpen,
|
||||
onTagDragEnter,
|
||||
onTagDragOver,
|
||||
onTagDragLeave,
|
||||
onTagDrop,
|
||||
onDocTagPointerDown,
|
||||
onDocTagDragStart,
|
||||
onDocTagDrag,
|
||||
onDocTagDragEnd,
|
||||
pendingRemovalTag,
|
||||
registerNode,
|
||||
}) => {
|
||||
const correspondents = useMemo(() => resolveCorrespondents(doc), [doc]);
|
||||
const tags = Array.isArray(doc?.tags) ? doc.tags : [];
|
||||
|
||||
const itemClasses = ['desk-item'];
|
||||
if (dragging) itemClasses.push('is-dragging');
|
||||
if (tagTargetActive) itemClasses.push('is-tag-target');
|
||||
if (tagTargetPending) itemClasses.push('is-tag-pending');
|
||||
if (!matchesFilter) itemClasses.push('is-filtered-out');
|
||||
if (selected) itemClasses.push('is-selected');
|
||||
|
||||
const ariaHidden = matchesFilter ? undefined : 'true';
|
||||
const dataTagIds = docTagTokens || undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
className={itemClasses.join(' ')}
|
||||
style={style}
|
||||
role="button"
|
||||
data-doc-id={doc.id}
|
||||
data-tag-ids={dataTagIds}
|
||||
aria-hidden={ariaHidden}
|
||||
ref={registerNode}
|
||||
{...cardPointerHandlers}
|
||||
onDragEnter={(event) => onTagDragEnter(event, doc.id)}
|
||||
onDragOver={(event) => onTagDragOver(event, doc.id)}
|
||||
onDragLeave={(event) => onTagDragLeave(event, doc.id)}
|
||||
onDrop={(event) => onTagDrop(event, doc)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
preventAll(event);
|
||||
onDocumentOpen?.(doc.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="desk-item__body">
|
||||
<DesktopPreviewCard
|
||||
doc={doc}
|
||||
title={doc.title}
|
||||
ensureAssetUrl={ensureAssetUrl}
|
||||
getDocumentAsset={getDocumentAsset}
|
||||
onNavigatorSnapshot={handleNavigatorSnapshot}
|
||||
shouldLoad={shouldLoad}
|
||||
/>
|
||||
{correspondents.length > 0 && (
|
||||
<div className="desk-item__correspondents" aria-hidden="true">
|
||||
{correspondents.map((correspondent) => (
|
||||
<span
|
||||
key={correspondent.key}
|
||||
className="badge desk-correspondent-chip"
|
||||
title={correspondent.name}
|
||||
>
|
||||
<span className="desk-correspondent-chip__label">{correspondent.name}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{tags.length > 0 && (
|
||||
<div className="desk-item__tags" aria-hidden="true">
|
||||
{tags.map((tag) => {
|
||||
if (pendingRemovalTag && pendingRemovalTag.docId === doc.id && pendingRemovalTag.tagId === tag.id) {
|
||||
return null;
|
||||
}
|
||||
const colorStyle = getTagColorStyle(tag.color);
|
||||
const pendingRemoval =
|
||||
pendingRemovalTag && pendingRemovalTag.docId === doc.id && pendingRemovalTag.tagId === tag.id;
|
||||
const tagClasses = ['badge', 'tag-chip', 'tag-chip--draggable'];
|
||||
if (pendingRemoval) tagClasses.push('tag-chip--tear-pending');
|
||||
return (
|
||||
<span
|
||||
key={tag.id}
|
||||
className={tagClasses.join(' ')}
|
||||
style={colorStyle || undefined}
|
||||
title={tag.label}
|
||||
draggable
|
||||
onPointerDown={(event) => onDocTagPointerDown(event, doc, tag)}
|
||||
onDragStart={(event) => onDocTagDragStart(event, doc, tag)}
|
||||
onDrag={onDocTagDrag}
|
||||
onDragEnd={(event) => onDocTagDragEnd(event)}
|
||||
>
|
||||
<span className="tag-chip__label">{tag.label}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(DesktopDocumentCard);
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
|
||||
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
||||
import { preventAll } from './events';
|
||||
|
||||
const DesktopPreviewCard = ({
|
||||
doc,
|
||||
title,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
prefetch = 3,
|
||||
onNavigatorSnapshot,
|
||||
shouldLoad = true,
|
||||
}) => {
|
||||
const navigator = useAssetNavigator({
|
||||
document: doc,
|
||||
assetType: 'preview',
|
||||
ensureAssetUrl: shouldLoad ? ensureAssetUrl : null,
|
||||
getAsset: getDocumentAsset,
|
||||
prefetch,
|
||||
});
|
||||
|
||||
const { currentUrl, cardinality, canGoPrev, canGoNext, currentMetadata, ordinal } = navigator;
|
||||
const docId = doc?.id ?? null;
|
||||
|
||||
const metadataWidth = Number(currentMetadata?.width);
|
||||
const metadataHeight = Number(currentMetadata?.height);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onNavigatorSnapshot || !docId) {
|
||||
return undefined;
|
||||
}
|
||||
const snapshot = {
|
||||
url: currentUrl || null,
|
||||
alt: title,
|
||||
canGoPrev,
|
||||
canGoNext,
|
||||
goPrev: navigator.goPrev,
|
||||
goNext: navigator.goNext,
|
||||
ordinal,
|
||||
width: Number.isFinite(metadataWidth) && metadataWidth > 0 ? metadataWidth : null,
|
||||
height: Number.isFinite(metadataHeight) && metadataHeight > 0 ? metadataHeight : null,
|
||||
};
|
||||
onNavigatorSnapshot(docId, snapshot);
|
||||
return () => onNavigatorSnapshot(docId, null);
|
||||
}, [
|
||||
docId,
|
||||
currentUrl,
|
||||
title,
|
||||
canGoPrev,
|
||||
canGoNext,
|
||||
ordinal,
|
||||
metadataWidth,
|
||||
metadataHeight,
|
||||
navigator.goPrev,
|
||||
navigator.goNext,
|
||||
onNavigatorSnapshot,
|
||||
]);
|
||||
|
||||
const hasPreview = Boolean(currentUrl);
|
||||
const cardClasses = ['desk-item__card'];
|
||||
if (!hasPreview) cardClasses.push('desk-item__card--empty');
|
||||
const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cardClasses.join(' ')}
|
||||
onDragStart={(event) => {
|
||||
if (event instanceof DragEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasPreview ? (
|
||||
<img
|
||||
src={currentUrl}
|
||||
alt={title}
|
||||
draggable={false}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
/>
|
||||
) : (
|
||||
<div className="desk-item__empty">
|
||||
<div className="desk-item__placeholder">DOC</div>
|
||||
<div className="desk-item__title" title={title}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showNav ? (
|
||||
<div className="desk-card__nav">
|
||||
<button
|
||||
type="button"
|
||||
className="desk-card__nav-button"
|
||||
onClick={(event) => {
|
||||
preventAll(event);
|
||||
navigator.goPrev();
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
preventAll(event);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
preventAll(event);
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
preventAll(event);
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
preventAll(event);
|
||||
}}
|
||||
disabled={!canGoPrev}
|
||||
aria-label="Previous preview"
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="desk-card__nav-button"
|
||||
onClick={(event) => {
|
||||
preventAll(event);
|
||||
navigator.goNext();
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
preventAll(event);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
preventAll(event);
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
preventAll(event);
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
preventAll(event);
|
||||
}}
|
||||
disabled={!canGoNext}
|
||||
aria-label="Next preview"
|
||||
>
|
||||
<ArrowRightIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DesktopPreviewCard;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +0,0 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
|
||||
const DesktopContext = createContext(null);
|
||||
|
||||
export const DesktopProvider = ({ value, children }) => (
|
||||
<DesktopContext.Provider value={value}>{children}</DesktopContext.Provider>
|
||||
);
|
||||
|
||||
export const useDesktopContext = () => {
|
||||
const context = useContext(DesktopContext);
|
||||
if (!context) {
|
||||
throw new Error('useDesktopContext must be used within a DesktopProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default DesktopContext;
|
||||
@@ -0,0 +1,183 @@
|
||||
const DB_NAME = 'papercrate_desk';
|
||||
const DB_VERSION = 1;
|
||||
const LAYOUT_STORE = 'layouts';
|
||||
|
||||
const currentDbPromise = { value: null };
|
||||
|
||||
const openDatabase = () => {
|
||||
if (currentDbPromise.value) {
|
||||
return currentDbPromise.value;
|
||||
}
|
||||
|
||||
currentDbPromise.value = new Promise((resolve, reject) => {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
reject(new Error('IndexedDB not available'));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(LAYOUT_STORE)) {
|
||||
const store = db.createObjectStore(LAYOUT_STORE, {
|
||||
keyPath: ['tenantId', 'viewId', 'documentId'],
|
||||
});
|
||||
store.createIndex('tenantViewIdx', ['tenantId', 'viewId'], { unique: false });
|
||||
store.createIndex('tenantIdx', 'tenantId', { unique: false });
|
||||
store.createIndex('updatedIdx', 'updatedAt', { unique: false });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error('Failed to open IndexedDB'));
|
||||
};
|
||||
});
|
||||
|
||||
return currentDbPromise.value;
|
||||
};
|
||||
|
||||
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 iterateCursor = (request, iteratee) => new Promise((resolve, reject) => {
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = event.target.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) => 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'));
|
||||
};
|
||||
});
|
||||
|
||||
const withStore = async (mode, handler) => {
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction(LAYOUT_STORE, mode);
|
||||
const store = transaction.objectStore(LAYOUT_STORE);
|
||||
const done = transactionComplete(transaction);
|
||||
try {
|
||||
const result = await handler(store, transaction);
|
||||
await done;
|
||||
return result;
|
||||
} catch (error) {
|
||||
try {
|
||||
transaction.abort();
|
||||
} catch (abortError) {
|
||||
console.warn('[desk] Failed to abort transaction', abortError);
|
||||
}
|
||||
try {
|
||||
await done;
|
||||
} catch (suppressed) {
|
||||
// noop – prefer original error
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchLayoutRecords = async ({ tenantId, viewId }) => {
|
||||
if (!tenantId || !viewId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return await withStore('readonly', (store) => {
|
||||
const index = store.index('tenantViewIdx');
|
||||
return requestToPromise(index.getAll([tenantId, viewId]), []);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[desk] Failed to read layout records', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => {
|
||||
if (!tenantId || !viewId || !Array.isArray(entries) || !entries.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await withStore('readwrite', (store) => {
|
||||
const timestamp = Date.now();
|
||||
entries.forEach((entry) => {
|
||||
if (!entry || !entry.documentId) {
|
||||
return;
|
||||
}
|
||||
store.put({
|
||||
tenantId,
|
||||
viewId,
|
||||
documentId: entry.documentId,
|
||||
centerX: Number(entry.centerX) || 0,
|
||||
centerY: Number(entry.centerY) || 0,
|
||||
rotation: Number(entry.rotation) || 0,
|
||||
zIndex: Number(entry.zIndex) || 0,
|
||||
updatedAt: entry.updatedAt || timestamp,
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[desk] Failed to upsert layout records', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteTenantLayouts = async (tenantId) => {
|
||||
if (!tenantId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await withStore('readwrite', (store) => {
|
||||
const index = store.index('tenantIdx');
|
||||
const request = index.openCursor(tenantId);
|
||||
return iterateCursor(request, (cursor) => {
|
||||
cursor.delete();
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[desk] Failed to clean tenant layouts', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const closeDeskDatabase = () => {
|
||||
if (!currentDbPromise.value) {
|
||||
return;
|
||||
}
|
||||
currentDbPromise.value = currentDbPromise.value.then((db) => {
|
||||
try {
|
||||
db.close();
|
||||
} catch (error) {
|
||||
console.warn('[desk] Failed to close IndexedDB', error);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
@@ -1,202 +0,0 @@
|
||||
import { clamp, formatTransform } from './math';
|
||||
|
||||
export const MIN_TIMESTEP = 1 / 120;
|
||||
export const MAX_TIMESTEP = 1 / 20;
|
||||
export const MAX_DYNAMIC_ROTATION = 4;
|
||||
export const MAX_ANGULAR_VELOCITY = 180;
|
||||
export const ANGULAR_DAMPING = 11;
|
||||
export const TORQUE_TO_ACCELERATION = 0.006;
|
||||
export const SETTLE_ANGULAR_VELOCITY = 1.2;
|
||||
|
||||
const callRef = (ref) => {
|
||||
const handler = ref?.current;
|
||||
if (typeof handler === 'function') {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
export const createDragPhysics = ({
|
||||
layoutRef,
|
||||
itemRefs,
|
||||
markLayoutDirtyRef,
|
||||
syncLayoutSnapshotRef,
|
||||
}) => {
|
||||
const inertiaAnimations = new Map();
|
||||
|
||||
const applyTransform = (docId, centerX, centerY, width, height, rotation, scale = 1) => {
|
||||
const node = itemRefs.current.get(docId);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
node.style.transform = formatTransform(
|
||||
centerX - width / 2,
|
||||
centerY - height / 2,
|
||||
rotation,
|
||||
scale,
|
||||
);
|
||||
};
|
||||
|
||||
const finalizeGroupDrag = (dragState) => {
|
||||
if (!dragState?.groupItems) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragState.groupItems.forEach((item) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entryItem = layoutRef.current.get(item.docId) || {};
|
||||
const centerX = item.currentCenterX ?? entryItem.centerX ?? dragState.originCenterX;
|
||||
const centerY = item.currentCenterY ?? entryItem.centerY ?? dragState.originCenterY;
|
||||
const rotation = item.displayRotation ?? entryItem.rotation ?? 0;
|
||||
|
||||
layoutRef.current.set(item.docId, {
|
||||
...entryItem,
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
});
|
||||
|
||||
applyTransform(
|
||||
item.docId,
|
||||
centerX,
|
||||
centerY,
|
||||
item.width,
|
||||
item.height,
|
||||
rotation,
|
||||
item.docId === dragState.docKey ? dragState.dragScale || 1 : 1,
|
||||
);
|
||||
});
|
||||
|
||||
callRef(markLayoutDirtyRef);
|
||||
};
|
||||
|
||||
const cancelInertiaAnimation = (docId) => {
|
||||
if (typeof window === 'undefined') {
|
||||
inertiaAnimations.delete(docId);
|
||||
return;
|
||||
}
|
||||
const existing = inertiaAnimations.get(docId);
|
||||
if (existing && typeof window.cancelAnimationFrame === 'function') {
|
||||
window.cancelAnimationFrame(existing.frameId);
|
||||
}
|
||||
inertiaAnimations.delete(docId);
|
||||
};
|
||||
|
||||
const integrateRotation = (simulationState, dt, torque = 0, dampingOverride = null) => {
|
||||
const { docId } = simulationState;
|
||||
const entry = layoutRef.current.get(docId);
|
||||
if (!entry) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const centerX = Number(entry.centerX);
|
||||
const centerY = Number(entry.centerY);
|
||||
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const torqueAcceleration = torque * TORQUE_TO_ACCELERATION;
|
||||
let angularVelocity = simulationState.angularVelocity + torqueAcceleration * dt;
|
||||
angularVelocity = clamp(angularVelocity, -MAX_ANGULAR_VELOCITY, MAX_ANGULAR_VELOCITY);
|
||||
|
||||
const dampingConstant = typeof dampingOverride === 'number' ? dampingOverride : ANGULAR_DAMPING;
|
||||
const dampingFactor = Math.exp(-dampingConstant * dt);
|
||||
angularVelocity *= dampingFactor;
|
||||
|
||||
let dynamicRotation = simulationState.dynamicRotation + angularVelocity * dt;
|
||||
if (dynamicRotation > MAX_DYNAMIC_ROTATION) {
|
||||
dynamicRotation = MAX_DYNAMIC_ROTATION;
|
||||
angularVelocity = Math.min(angularVelocity, 0);
|
||||
} else if (dynamicRotation < -MAX_DYNAMIC_ROTATION) {
|
||||
dynamicRotation = -MAX_DYNAMIC_ROTATION;
|
||||
angularVelocity = Math.max(angularVelocity, 0);
|
||||
}
|
||||
|
||||
simulationState.angularVelocity = angularVelocity;
|
||||
simulationState.dynamicRotation = dynamicRotation;
|
||||
simulationState.rotation = simulationState.restRotation + dynamicRotation;
|
||||
|
||||
const rotation = simulationState.rotation;
|
||||
layoutRef.current.set(docId, { ...entry, rotation });
|
||||
callRef(markLayoutDirtyRef);
|
||||
|
||||
const node = itemRefs.current.get(docId);
|
||||
if (node) {
|
||||
node.style.transform = formatTransform(
|
||||
centerX - simulationState.width / 2,
|
||||
centerY - simulationState.height / 2,
|
||||
rotation,
|
||||
simulationState.dragScale || 1,
|
||||
);
|
||||
}
|
||||
|
||||
const isSettled = Math.abs(angularVelocity) < SETTLE_ANGULAR_VELOCITY;
|
||||
return isSettled;
|
||||
};
|
||||
|
||||
const startInertiaAnimation = (docId, baseState) => {
|
||||
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelInertiaAnimation(docId);
|
||||
|
||||
const now =
|
||||
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
|
||||
const simulationState = {
|
||||
...baseState,
|
||||
docId,
|
||||
dragScale: baseState.dragScale || 1,
|
||||
lastTimestamp: now,
|
||||
};
|
||||
|
||||
const step = (timestamp) => {
|
||||
const safeTimestamp = Number.isFinite(timestamp) ? timestamp : now + 16;
|
||||
const previous = simulationState.lastTimestamp;
|
||||
let dt = (safeTimestamp - previous) / 1000;
|
||||
if (!Number.isFinite(dt) || dt <= 0) {
|
||||
dt = MIN_TIMESTEP;
|
||||
}
|
||||
dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP);
|
||||
simulationState.lastTimestamp = safeTimestamp;
|
||||
|
||||
const settled = integrateRotation(simulationState, dt, 0);
|
||||
if (settled) {
|
||||
inertiaAnimations.delete(docId);
|
||||
callRef(syncLayoutSnapshotRef);
|
||||
return;
|
||||
}
|
||||
simulationState.frameId = window.requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
simulationState.frameId = window.requestAnimationFrame(step);
|
||||
inertiaAnimations.set(docId, simulationState);
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
|
||||
inertiaAnimations.forEach((animation) => {
|
||||
if (animation?.frameId != null) {
|
||||
window.cancelAnimationFrame(animation.frameId);
|
||||
}
|
||||
});
|
||||
}
|
||||
inertiaAnimations.clear();
|
||||
};
|
||||
|
||||
return {
|
||||
applyTransform,
|
||||
finalizeGroupDrag,
|
||||
cancelInertiaAnimation,
|
||||
integrateRotation,
|
||||
startInertiaAnimation,
|
||||
dispose,
|
||||
};
|
||||
};
|
||||
|
||||
export default createDragPhysics;
|
||||
@@ -13,3 +13,19 @@ export const preventAll = (event) => {
|
||||
console.warn('[events] stopPropagation failed', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const safeInvoke = (fn, ...args) => (typeof fn === 'function' ? fn(...args) : undefined);
|
||||
|
||||
export const getPointerPosition = (event, { fallbackToPage = true } = {}) => {
|
||||
if (!event) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
const clientX = Number.isFinite(event.clientX) ? event.clientX : null;
|
||||
const clientY = Number.isFinite(event.clientY) ? event.clientY : null;
|
||||
const pageX = fallbackToPage && Number.isFinite(event.pageX) ? event.pageX : null;
|
||||
const pageY = fallbackToPage && Number.isFinite(event.pageY) ? event.pageY : null;
|
||||
return {
|
||||
x: clientX ?? pageX ?? 0,
|
||||
y: clientY ?? pageY ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { safeInvoke } from '../events.js';
|
||||
|
||||
export const CLICK_ACTIONS = {
|
||||
selectSingle: 'selectSingle',
|
||||
openDetail: 'openDetail',
|
||||
addCard: 'addCard',
|
||||
addStack: 'addStack',
|
||||
none: 'none',
|
||||
};
|
||||
|
||||
export const DRAG_ACTIONS = {
|
||||
dragSelectSingle: 'dragSelectSingle',
|
||||
dragSelection: 'dragSelection',
|
||||
dragSelectStack: 'dragSelectStack',
|
||||
none: 'none',
|
||||
};
|
||||
|
||||
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 createPointerIntent = ({
|
||||
doc,
|
||||
entryDescriptor,
|
||||
selectedDocumentIds,
|
||||
metaKey,
|
||||
pointerButton,
|
||||
pointerType,
|
||||
stackHits,
|
||||
}) => {
|
||||
const alreadySelected = selectedDocumentIds.includes(doc.id);
|
||||
const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0;
|
||||
|
||||
let clickAction = CLICK_ACTIONS.none;
|
||||
let dragAction = DRAG_ACTIONS.none;
|
||||
|
||||
if (metaKey) {
|
||||
clickAction = CLICK_ACTIONS.addStack;
|
||||
dragAction = DRAG_ACTIONS.dragSelectStack;
|
||||
} else if (alreadySelected) {
|
||||
clickAction = CLICK_ACTIONS.openDetail;
|
||||
dragAction = selectionCount > 1 ? DRAG_ACTIONS.dragSelection : DRAG_ACTIONS.dragSelectSingle;
|
||||
} else {
|
||||
clickAction = CLICK_ACTIONS.selectSingle;
|
||||
dragAction = DRAG_ACTIONS.dragSelectSingle;
|
||||
}
|
||||
|
||||
const stackList = Array.isArray(stackHits) && stackHits.length > 0
|
||||
? stackHits.slice()
|
||||
: [String(doc.id)];
|
||||
|
||||
const stackDocIdsForDrag = dragAction === DRAG_ACTIONS.dragSelectStack ? stackList : null;
|
||||
const stackDocIdsForClick = clickAction === CLICK_ACTIONS.addStack ? stackList : null;
|
||||
|
||||
return {
|
||||
docId: doc.id,
|
||||
entryDescriptor,
|
||||
pointerType,
|
||||
pointerButton,
|
||||
selectedAtDown: alreadySelected,
|
||||
selectionCountAtDown: selectionCount,
|
||||
metaKey,
|
||||
clickAction,
|
||||
dragAction,
|
||||
stackDocIdsForDrag,
|
||||
stackDocIdsForClick,
|
||||
stackReplaceOnClick: clickAction === CLICK_ACTIONS.addStack,
|
||||
stackReplaceOnDrag: dragAction === DRAG_ACTIONS.dragSelectStack,
|
||||
clickSelectionApplied: false,
|
||||
stackSelectionApplied: false,
|
||||
longPressTriggered: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => {
|
||||
switch (intent.clickAction) {
|
||||
case CLICK_ACTIONS.selectSingle:
|
||||
case CLICK_ACTIONS.addCard:
|
||||
safeInvoke(onEntryPointer, intent.entryDescriptor, event);
|
||||
intent.clickSelectionApplied = true;
|
||||
break;
|
||||
case CLICK_ACTIONS.addStack:
|
||||
if (Array.isArray(intent.stackDocIdsForClick) && intent.stackDocIdsForClick.length > 0) {
|
||||
safeInvoke(
|
||||
onDocumentStackSelect,
|
||||
intent.stackDocIdsForClick,
|
||||
event,
|
||||
{ replace: intent.stackReplaceOnClick },
|
||||
);
|
||||
intent.clickSelectionApplied = true;
|
||||
intent.stackSelectionApplied = true;
|
||||
}
|
||||
break;
|
||||
case CLICK_ACTIONS.openDetail:
|
||||
default:
|
||||
intent.clickSelectionApplied = true;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
export const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => {
|
||||
if (!intent || intent.clickSelectionApplied) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyClickPlanImmediately({ intent, event, onEntryPointer, onDocumentStackSelect });
|
||||
};
|
||||
|
||||
export const applyLongPressSelection = ({ intent, stackDocIds, syntheticEvent, onDocumentStackSelect }) => {
|
||||
if (!intent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stackCopy = Array.isArray(stackDocIds) && stackDocIds.length > 0
|
||||
? stackDocIds.slice()
|
||||
: [intent.docId];
|
||||
|
||||
safeInvoke(onDocumentStackSelect, stackCopy, syntheticEvent, { replace: true });
|
||||
|
||||
intent.clickAction = CLICK_ACTIONS.addStack;
|
||||
intent.dragAction = DRAG_ACTIONS.dragSelectStack;
|
||||
intent.stackDocIdsForClick = stackCopy;
|
||||
intent.stackDocIdsForDrag = stackCopy;
|
||||
intent.stackReplaceOnClick = true;
|
||||
intent.stackReplaceOnDrag = true;
|
||||
intent.clickSelectionApplied = true;
|
||||
intent.stackSelectionApplied = true;
|
||||
intent.longPressTriggered = true;
|
||||
};
|
||||
@@ -0,0 +1,425 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {
|
||||
CLICK_ACTIONS,
|
||||
LONG_PRESS_DURATION_MS,
|
||||
POINTER_DRAG_THRESHOLD_SQUARED,
|
||||
STACK_HIT_EPSILON,
|
||||
applyClickPlanImmediately,
|
||||
applyLongPressSelection,
|
||||
createPointerIntent,
|
||||
finalizeClickSelection,
|
||||
withinThreshold,
|
||||
} from './pointerUtils';
|
||||
import { getPointerPosition, safeInvoke } from '../events.js';
|
||||
|
||||
const buildEntryDescriptor = (docId) => ({
|
||||
type: 'document',
|
||||
id: docId,
|
||||
key: `document:${docId}`,
|
||||
});
|
||||
|
||||
export const useDeskPointer = ({
|
||||
containerRef,
|
||||
items,
|
||||
layoutRef,
|
||||
ensureDocumentSize,
|
||||
activeTagSet,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
handlePointerCancel,
|
||||
onEntryPointer,
|
||||
onDocumentStackSelect,
|
||||
onPromoteSelection,
|
||||
onDocumentOpen,
|
||||
selectedDocumentIds,
|
||||
onClearSelection,
|
||||
detailPanelOpen,
|
||||
onCloseDetailPanel,
|
||||
}) => {
|
||||
const pointerIntentRef = useRef(null);
|
||||
const pointerStartRef = useRef({ x: 0, y: 0 });
|
||||
const pointerMovedRef = useRef(false);
|
||||
const longPressTimerRef = useRef(null);
|
||||
const longPressActiveRef = useRef(false);
|
||||
|
||||
const resetLongPressState = useCallback(() => {
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
}
|
||||
longPressActiveRef.current = false;
|
||||
}, []);
|
||||
|
||||
const resolveStackDocIds = useCallback(
|
||||
(event, targetDocId = null) => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !event) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const pointerCanvasX = event.clientX - rect.left;
|
||||
const pointerCanvasY = event.clientY - rect.top;
|
||||
|
||||
if (!Number.isFinite(pointerCanvasX) || !Number.isFinite(pointerCanvasY)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
|
||||
items.forEach((doc) => {
|
||||
if (!doc?.id) {
|
||||
return;
|
||||
}
|
||||
const docKey = String(doc.id);
|
||||
const layout = layoutRef.current.get(docKey);
|
||||
if (!layout) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sizeInfo = ensureDocumentSize(doc);
|
||||
if (!sizeInfo) {
|
||||
return;
|
||||
}
|
||||
const { width, height } = sizeInfo;
|
||||
if (!width || !height) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTagSet.size) {
|
||||
const docTagKeys = Array.isArray(doc.tags)
|
||||
? doc.tags.map((tag) => (tag ? tag.id : null)).filter(Boolean)
|
||||
: [];
|
||||
if (!docTagKeys.some((key) => activeTagSet.has(key))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const centerX = Number(layout.centerX);
|
||||
const centerY = Number(layout.centerY);
|
||||
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rotationDeg = Number(layout.rotation) || 0;
|
||||
const rotationRad = (rotationDeg * Math.PI) / 180;
|
||||
const dx = pointerCanvasX - centerX;
|
||||
const dy = pointerCanvasY - centerY;
|
||||
const cosRotation = Math.cos(-rotationRad);
|
||||
const sinRotation = Math.sin(-rotationRad);
|
||||
const localX = dx * cosRotation - dy * sinRotation;
|
||||
const localY = dx * sinRotation + dy * cosRotation;
|
||||
const halfWidth = width / 2;
|
||||
const halfHeight = height / 2;
|
||||
|
||||
const containsPointer =
|
||||
Math.abs(localX) <= halfWidth + STACK_HIT_EPSILON
|
||||
&& Math.abs(localY) <= halfHeight + STACK_HIT_EPSILON;
|
||||
|
||||
candidates.push({
|
||||
id: docKey,
|
||||
z: Number.isFinite(layout.z) ? layout.z : 0,
|
||||
centerX,
|
||||
centerY,
|
||||
width,
|
||||
height,
|
||||
halfWidth,
|
||||
halfHeight,
|
||||
containsPointer,
|
||||
});
|
||||
});
|
||||
|
||||
const pointerCandidates = candidates.filter((candidate) => candidate.containsPointer);
|
||||
if (!pointerCandidates.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sortedByZ = [...pointerCandidates].sort((a, b) => (b.z ?? 0) - (a.z ?? 0));
|
||||
const targetKey = targetDocId != null ? String(targetDocId) : sortedByZ[0].id;
|
||||
|
||||
const primary = sortedByZ.find((candidate) => candidate.id === targetKey) || sortedByZ[0];
|
||||
if (!primary) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const radius = Math.max(Math.min(primary.halfWidth, primary.halfHeight) * 1.2, 6);
|
||||
const radiusSquared = radius * radius;
|
||||
|
||||
const selected = candidates
|
||||
.filter((candidate) => {
|
||||
if (!candidate?.id) {
|
||||
return false;
|
||||
}
|
||||
const dx = candidate.centerX - primary.centerX;
|
||||
const dy = candidate.centerY - primary.centerY;
|
||||
return dx * dx + dy * dy <= radiusSquared + 1e-4;
|
||||
})
|
||||
.sort((a, b) => (b.z ?? 0) - (a.z ?? 0));
|
||||
|
||||
if (targetKey) {
|
||||
const targetIndex = selected.findIndex((entry) => entry.id === targetKey);
|
||||
if (targetIndex > 0) {
|
||||
const [targetEntry] = selected.splice(targetIndex, 1);
|
||||
selected.unshift(targetEntry);
|
||||
}
|
||||
}
|
||||
|
||||
return selected
|
||||
.map((candidate) => candidate.id)
|
||||
.filter((id, index, array) => array.indexOf(id) === index);
|
||||
},
|
||||
[activeTagSet, containerRef, ensureDocumentSize, items, layoutRef],
|
||||
);
|
||||
|
||||
const scheduleLongPress = useCallback(
|
||||
({ doc, modifierActive, pointerType }) => {
|
||||
if (modifierActive || pointerType !== 'touch') {
|
||||
longPressActiveRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
longPressActiveRef.current = true;
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
longPressTimerRef.current = window.setTimeout(() => {
|
||||
if (!longPressActiveRef.current || pointerMovedRef.current) {
|
||||
resetLongPressState();
|
||||
return;
|
||||
}
|
||||
|
||||
const intent = pointerIntentRef.current;
|
||||
if (!intent || intent.docId !== doc.id) {
|
||||
resetLongPressState();
|
||||
return;
|
||||
}
|
||||
|
||||
const syntheticEvent = {
|
||||
clientX: pointerStartRef.current.x,
|
||||
clientY: pointerStartRef.current.y,
|
||||
};
|
||||
const stackHits = resolveStackDocIds(syntheticEvent, doc.id);
|
||||
applyLongPressSelection({
|
||||
intent,
|
||||
stackDocIds: stackHits,
|
||||
syntheticEvent,
|
||||
onDocumentStackSelect,
|
||||
});
|
||||
pointerIntentRef.current = intent;
|
||||
resetLongPressState();
|
||||
}, LONG_PRESS_DURATION_MS);
|
||||
},
|
||||
[onDocumentStackSelect, resolveStackDocIds, resetLongPressState],
|
||||
);
|
||||
|
||||
useEffect(() => () => resetLongPressState(), [resetLongPressState]);
|
||||
|
||||
const handleCardPointerDown = useCallback(
|
||||
(event, doc) => {
|
||||
if (!doc?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
pointerStartRef.current = getPointerPosition(event, { fallbackToPage: false });
|
||||
pointerMovedRef.current = false;
|
||||
resetLongPressState();
|
||||
|
||||
const pointerButton = typeof event.button === 'number' ? event.button : 0;
|
||||
const pointerType = typeof event.pointerType === 'string' ? event.pointerType : '';
|
||||
const metaKey = (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey;
|
||||
const modifierActive = Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
|
||||
|
||||
const entryDescriptor = buildEntryDescriptor(doc.id);
|
||||
const stackHits = metaKey ? resolveStackDocIds(event, doc.id) : null;
|
||||
|
||||
const intent = createPointerIntent({
|
||||
doc,
|
||||
entryDescriptor,
|
||||
selectedDocumentIds,
|
||||
metaKey,
|
||||
pointerButton,
|
||||
pointerType,
|
||||
stackHits,
|
||||
});
|
||||
|
||||
if (intent.selectedAtDown) {
|
||||
safeInvoke(onPromoteSelection, doc.id, event);
|
||||
}
|
||||
|
||||
applyClickPlanImmediately({
|
||||
intent,
|
||||
event,
|
||||
onEntryPointer,
|
||||
onDocumentStackSelect,
|
||||
});
|
||||
|
||||
pointerIntentRef.current = intent;
|
||||
|
||||
handlePointerDown(event, doc.id, {
|
||||
stackDocIds: intent.stackDocIdsForDrag,
|
||||
stackSelectionApplied: intent.stackSelectionApplied,
|
||||
wasSelected: intent.selectedAtDown,
|
||||
modifierActive,
|
||||
stackReplace: intent.stackReplaceOnDrag,
|
||||
});
|
||||
|
||||
scheduleLongPress({
|
||||
doc,
|
||||
modifierActive,
|
||||
pointerType,
|
||||
});
|
||||
},
|
||||
[
|
||||
handlePointerDown,
|
||||
onPromoteSelection,
|
||||
onEntryPointer,
|
||||
onDocumentStackSelect,
|
||||
resolveStackDocIds,
|
||||
resetLongPressState,
|
||||
scheduleLongPress,
|
||||
selectedDocumentIds,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCardPointerMove = useCallback(
|
||||
(event) => {
|
||||
const start = pointerStartRef.current;
|
||||
const { x, y } = getPointerPosition(event, { fallbackToPage: false });
|
||||
const dx = x - start.x;
|
||||
const dy = y - start.y;
|
||||
if (!withinThreshold(dx, dy, POINTER_DRAG_THRESHOLD_SQUARED)) {
|
||||
pointerMovedRef.current = true;
|
||||
resetLongPressState();
|
||||
}
|
||||
handlePointerMove(event);
|
||||
},
|
||||
[handlePointerMove, resetLongPressState],
|
||||
);
|
||||
|
||||
const handleCardPointerUp = useCallback(
|
||||
(event, doc) => {
|
||||
const pointerState = pointerIntentRef.current;
|
||||
const pointerMoved = pointerMovedRef.current;
|
||||
|
||||
resetLongPressState();
|
||||
handlePointerUp(event);
|
||||
|
||||
if (!pointerMoved && pointerState) {
|
||||
finalizeClickSelection({
|
||||
intent: pointerState,
|
||||
event,
|
||||
onEntryPointer,
|
||||
onDocumentStackSelect,
|
||||
});
|
||||
|
||||
if (
|
||||
pointerState.clickAction === CLICK_ACTIONS.openDetail
|
||||
&& !pointerState.longPressTriggered
|
||||
&& pointerState.docId === doc.id
|
||||
) {
|
||||
const expectedButton = typeof pointerState.pointerButton === 'number'
|
||||
? pointerState.pointerButton
|
||||
: 0;
|
||||
const releasedButton = typeof event.button === 'number'
|
||||
? event.button
|
||||
: expectedButton;
|
||||
const isPrimaryRelease = expectedButton === 0 && releasedButton === 0;
|
||||
const stillSelected = Array.isArray(selectedDocumentIds)
|
||||
&& selectedDocumentIds.includes(doc.id);
|
||||
if (isPrimaryRelease && stillSelected) {
|
||||
const useSelection = pointerState.selectedAtDown && pointerState.selectionCountAtDown > 0;
|
||||
safeInvoke(onDocumentOpen, doc.id, { useSelection });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pointerIntentRef.current = null;
|
||||
pointerMovedRef.current = false;
|
||||
},
|
||||
[
|
||||
handlePointerUp,
|
||||
onDocumentOpen,
|
||||
onDocumentStackSelect,
|
||||
onEntryPointer,
|
||||
resetLongPressState,
|
||||
selectedDocumentIds,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCardPointerCancel = useCallback(
|
||||
(event) => {
|
||||
pointerMovedRef.current = false;
|
||||
resetLongPressState();
|
||||
pointerIntentRef.current = null;
|
||||
handlePointerCancel(event);
|
||||
},
|
||||
[handlePointerCancel, resetLongPressState],
|
||||
);
|
||||
|
||||
const getCardPointerHandlers = useCallback(
|
||||
(doc) => ({
|
||||
onPointerDown: (event) => handleCardPointerDown(event, doc),
|
||||
onPointerMove: handleCardPointerMove,
|
||||
onPointerUp: (event) => handleCardPointerUp(event, doc),
|
||||
onPointerCancel: handleCardPointerCancel,
|
||||
}),
|
||||
[
|
||||
handleCardPointerCancel,
|
||||
handleCardPointerDown,
|
||||
handleCardPointerMove,
|
||||
handleCardPointerUp,
|
||||
],
|
||||
);
|
||||
|
||||
const handleShellKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (!event || event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { key } = event;
|
||||
if (key !== ' ' && key !== 'Space' && key !== 'Spacebar') {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLElement) {
|
||||
const tagName = target.tagName ? target.tagName.toLowerCase() : '';
|
||||
if (
|
||||
target.isContentEditable
|
||||
|| tagName === 'input'
|
||||
|| tagName === 'textarea'
|
||||
|| tagName === 'select'
|
||||
|| tagName === 'button'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(selectedDocumentIds) && selectedDocumentIds.length > 0) {
|
||||
event.preventDefault();
|
||||
onClearSelection?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (detailPanelOpen) {
|
||||
event.preventDefault();
|
||||
safeInvoke(onCloseDetailPanel);
|
||||
}
|
||||
},
|
||||
[detailPanelOpen, onClearSelection, onCloseDetailPanel, selectedDocumentIds],
|
||||
);
|
||||
|
||||
return {
|
||||
getCardPointerHandlers,
|
||||
handleShellKeyDown,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDeskPointer;
|
||||
@@ -0,0 +1,351 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { getPointerPosition, preventAll, safeInvoke } from '../events.js';
|
||||
import {
|
||||
isTagTransferEvent,
|
||||
parseTagTransferPayload,
|
||||
writeTagTransferData,
|
||||
} from '../../documents/tagTransfer';
|
||||
|
||||
const TAG_REMOVE_DISTANCE = 160;
|
||||
const DEBUG_DROP = true;
|
||||
|
||||
const createDragPreview = (node, clientX, clientY) => {
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
const rect = node.getBoundingClientRect();
|
||||
const safeClientX = Number.isFinite(clientX) ? clientX : rect.left + rect.width / 2;
|
||||
const safeClientY = Number.isFinite(clientY) ? clientY : rect.top + rect.height / 2;
|
||||
const offsetX = Math.min(Math.max(safeClientX - rect.left, 0), rect.width);
|
||||
const offsetY = Math.min(Math.max(safeClientY - rect.top, 0), rect.height);
|
||||
const clone = node.cloneNode(true);
|
||||
clone.style.position = 'absolute';
|
||||
clone.style.top = '-9999px';
|
||||
clone.style.left = '-9999px';
|
||||
clone.style.pointerEvents = 'none';
|
||||
clone.style.opacity = '1';
|
||||
clone.style.transform = 'none';
|
||||
document.body.appendChild(clone);
|
||||
return { clone, offsetX, offsetY };
|
||||
};
|
||||
|
||||
const cleanupPreview = (previewNode) => {
|
||||
if (previewNode && previewNode.parentNode) {
|
||||
previewNode.parentNode.removeChild(previewNode);
|
||||
}
|
||||
};
|
||||
|
||||
export const useDeskTagInteractions = ({
|
||||
engine,
|
||||
onAssignTagToDocument,
|
||||
onRemoveTagFromDocument,
|
||||
requestCanvasFocus,
|
||||
}) => {
|
||||
const draggingTagRef = useRef(null);
|
||||
const pendingDocTagDragRef = useRef(null);
|
||||
const removalCursorActiveRef = useRef(false);
|
||||
|
||||
const updateRemovalCursor = useCallback((active) => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (removalCursorActiveRef.current === active) {
|
||||
return;
|
||||
}
|
||||
const body = document.body;
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
removalCursorActiveRef.current = active;
|
||||
if (active) {
|
||||
body.classList.add('desk-cursor-remove');
|
||||
} else {
|
||||
body.classList.remove('desk-cursor-remove');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
updateRemovalCursor(false);
|
||||
},
|
||||
[updateRemovalCursor],
|
||||
);
|
||||
|
||||
const isTagTransfer = useCallback((event) => isTagTransferEvent(event), []);
|
||||
|
||||
const handleTagDragEnd = useCallback(() => {
|
||||
updateRemovalCursor(false);
|
||||
engine.setTagDropTargetId(null);
|
||||
}, [engine, updateRemovalCursor]);
|
||||
|
||||
const finalizeTagDrag = useCallback(
|
||||
(dropEffect = 'none') => {
|
||||
const state = draggingTagRef.current;
|
||||
if (!state) {
|
||||
updateRemovalCursor(false);
|
||||
return;
|
||||
}
|
||||
|
||||
draggingTagRef.current = null;
|
||||
|
||||
const node = state.element;
|
||||
const showNode = () => {
|
||||
if (node instanceof HTMLElement) {
|
||||
node.classList.remove('is-drag-hidden');
|
||||
}
|
||||
};
|
||||
const scheduleShowNode = () => {
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
window.requestAnimationFrame(showNode);
|
||||
} else {
|
||||
setTimeout(showNode, 0);
|
||||
}
|
||||
};
|
||||
|
||||
cleanupPreview(state.previewClone);
|
||||
|
||||
const shouldRemove =
|
||||
!state.dropHandled
|
||||
&& dropEffect === 'none'
|
||||
&& state.sourceDocId
|
||||
&& (state.distance || 0) >= TAG_REMOVE_DISTANCE;
|
||||
|
||||
if (!shouldRemove) {
|
||||
scheduleShowNode();
|
||||
updateRemovalCursor(false);
|
||||
return;
|
||||
}
|
||||
|
||||
updateRemovalCursor(false);
|
||||
engine.setPendingRemovalTag({ docId: state.sourceDocId, tagId: state.tagId });
|
||||
const removePromise = safeInvoke(onRemoveTagFromDocument, state.sourceDocId, state.tagId);
|
||||
if (!removePromise || typeof removePromise.then !== 'function') {
|
||||
scheduleShowNode();
|
||||
engine.setPendingRemovalTag(null);
|
||||
updateRemovalCursor(false);
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
await removePromise;
|
||||
if (DEBUG_DROP) {
|
||||
console.log('[desk] finalizeTagDrag -> removed tag due to fling');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to remove tag after drag', error);
|
||||
scheduleShowNode();
|
||||
} finally {
|
||||
engine.setPendingRemovalTag(null);
|
||||
}
|
||||
})();
|
||||
},
|
||||
[engine, onRemoveTagFromDocument, updateRemovalCursor],
|
||||
);
|
||||
|
||||
const handleDocTagPointerDown = useCallback((event, doc, tag) => {
|
||||
event.stopPropagation();
|
||||
if (!doc || !tag) {
|
||||
pendingDocTagDragRef.current = null;
|
||||
return;
|
||||
}
|
||||
const { x: startX, y: startY } = getPointerPosition(event);
|
||||
pendingDocTagDragRef.current = {
|
||||
docId: doc.id,
|
||||
tagId: tag.id,
|
||||
startX,
|
||||
startY,
|
||||
};
|
||||
updateRemovalCursor(false);
|
||||
}, [updateRemovalCursor]);
|
||||
|
||||
const markActiveTagDropHandled = useCallback((tagId, sourceDocId = null) => {
|
||||
const state = draggingTagRef.current;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
if (state.tagId !== tagId) {
|
||||
return;
|
||||
}
|
||||
if (sourceDocId && state.sourceDocId !== sourceDocId) {
|
||||
return;
|
||||
}
|
||||
state.dropHandled = true;
|
||||
}, []);
|
||||
|
||||
const runTagHoverTransition = useCallback(
|
||||
(event, docId, { applyTarget = false, applyPending = false, updateCursor = true } = {}) => {
|
||||
if (!isTagTransfer(event)) {
|
||||
return;
|
||||
}
|
||||
preventAll(event);
|
||||
if (updateCursor) {
|
||||
updateRemovalCursor(false);
|
||||
}
|
||||
const stringId = docId != null ? String(docId) : null;
|
||||
if (applyTarget) {
|
||||
engine.setTagDropTargetId(stringId);
|
||||
}
|
||||
if (applyPending) {
|
||||
engine.setPendingTagDocId(stringId);
|
||||
}
|
||||
},
|
||||
[engine, isTagTransfer, updateRemovalCursor],
|
||||
);
|
||||
|
||||
const handleTagDragEnterDoc = useCallback(
|
||||
(event, docId) => runTagHoverTransition(event, docId, { applyTarget: true }),
|
||||
[runTagHoverTransition],
|
||||
);
|
||||
|
||||
const handleTagDragOverDoc = useCallback(
|
||||
(event, docId) => runTagHoverTransition(event, docId, { applyTarget: true, applyPending: true }),
|
||||
[runTagHoverTransition],
|
||||
);
|
||||
|
||||
const handleTagDragLeaveDoc = useCallback(
|
||||
(event) => runTagHoverTransition(event, null, { applyTarget: true, applyPending: true }),
|
||||
[runTagHoverTransition],
|
||||
);
|
||||
|
||||
const handleCanvasDragOver = useCallback(
|
||||
(event) => runTagHoverTransition(event, null),
|
||||
[runTagHoverTransition],
|
||||
);
|
||||
|
||||
const handleCanvasDragLeave = useCallback(
|
||||
(event) => runTagHoverTransition(event, null, { applyTarget: true, applyPending: true }),
|
||||
[runTagHoverTransition],
|
||||
);
|
||||
|
||||
const handleCanvasDrop = useCallback(
|
||||
(event) => runTagHoverTransition(event, null, { applyTarget: true, applyPending: true }),
|
||||
[runTagHoverTransition],
|
||||
);
|
||||
|
||||
const handleTagDropOnDoc = useCallback(
|
||||
(event, doc) => {
|
||||
if (!doc) {
|
||||
return;
|
||||
}
|
||||
if (!isTagTransfer(event)) {
|
||||
return;
|
||||
}
|
||||
preventAll(event);
|
||||
engine.setTagDropTargetId(null);
|
||||
engine.setPendingTagDocId(null);
|
||||
|
||||
const payload = parseTagTransferPayload(event);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
markActiveTagDropHandled(payload.tagId, payload.sourceDocId);
|
||||
|
||||
if (payload.sourceDocId === doc.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestCanvasFocus?.();
|
||||
|
||||
void safeInvoke(onAssignTagToDocument, doc.id, payload.tagId, { sourceDocId: payload.sourceDocId });
|
||||
},
|
||||
[engine, isTagTransfer, markActiveTagDropHandled, onAssignTagToDocument, requestCanvasFocus],
|
||||
);
|
||||
|
||||
const handleDocTagDragStart = useCallback(
|
||||
(event, doc, tag) => {
|
||||
if (!event?.dataTransfer || !doc || !tag) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
writeTagTransferData(event.dataTransfer, { docId: doc.id, tagId: tag.id });
|
||||
|
||||
const { x: pointerX, y: pointerY } = getPointerPosition(event, { fallbackToPage: false });
|
||||
const { clone, offsetX, offsetY } = createDragPreview(event.currentTarget, pointerX, pointerY) || {};
|
||||
if (clone && typeof event.dataTransfer.setDragImage === 'function') {
|
||||
event.dataTransfer.setDragImage(clone, offsetX || 0, offsetY || 0);
|
||||
}
|
||||
|
||||
const element = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||
if (element) {
|
||||
element.classList.add('is-drag-hidden');
|
||||
}
|
||||
|
||||
draggingTagRef.current = {
|
||||
element,
|
||||
previewClone: clone,
|
||||
sourceDocId: doc.id,
|
||||
tagId: tag.id,
|
||||
initialX: pointerX,
|
||||
initialY: pointerY,
|
||||
distance: 0,
|
||||
dropHandled: false,
|
||||
};
|
||||
|
||||
updateRemovalCursor(false);
|
||||
},
|
||||
[updateRemovalCursor],
|
||||
);
|
||||
|
||||
const handleDocTagDrag = useCallback((event) => {
|
||||
const state = draggingTagRef.current;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
const { x, y } = getPointerPosition(event);
|
||||
const dx = x - (state.initialX || 0);
|
||||
const dy = y - (state.initialY || 0);
|
||||
state.distance = Math.sqrt(dx * dx + dy * dy);
|
||||
if (state.distance >= TAG_REMOVE_DISTANCE) {
|
||||
updateRemovalCursor(true);
|
||||
} else {
|
||||
updateRemovalCursor(false);
|
||||
}
|
||||
}, [updateRemovalCursor]);
|
||||
|
||||
const handleDocTagDragEnd = useCallback(
|
||||
(event) => {
|
||||
finalizeTagDrag(event?.dataTransfer?.dropEffect || 'none');
|
||||
const state = draggingTagRef.current;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
const element = state.element;
|
||||
if (element) {
|
||||
element.classList.remove('is-drag-hidden');
|
||||
}
|
||||
cleanupPreview(state.previewClone);
|
||||
draggingTagRef.current = null;
|
||||
},
|
||||
[finalizeTagDrag],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
draggingTagRef.current = null;
|
||||
pendingDocTagDragRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
handleTagDragEnterDoc,
|
||||
handleTagDragOverDoc,
|
||||
handleTagDragLeaveDoc,
|
||||
handleTagDropOnDoc,
|
||||
handleDocTagPointerDown,
|
||||
handleDocTagDragStart,
|
||||
handleDocTagDrag,
|
||||
handleDocTagDragEnd,
|
||||
handleTagDragEnd,
|
||||
markActiveTagDropHandled,
|
||||
handleCanvasDragOver,
|
||||
handleCanvasDragLeave,
|
||||
handleCanvasDrop,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDeskTagInteractions;
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useDesktopContext } from './context';
|
||||
import { preventAll } from './events';
|
||||
import { clamp, formatTransform } from './math';
|
||||
import { preventAll, safeInvoke } from './events';
|
||||
import { clamp } from './math';
|
||||
import usePointerTap from '../ui/usePointerTap';
|
||||
import createDragPhysics, { MIN_TIMESTEP, MAX_TIMESTEP } from './dragPhysics';
|
||||
import { MIN_TIMESTEP, MAX_TIMESTEP, applyDomTransform } from './workspaceEngine';
|
||||
|
||||
const DRAG_HYSTERESIS_PX = 4;
|
||||
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
|
||||
const EDGE_COLLISION_THRESHOLD = 0.5;
|
||||
|
||||
const useDocumentDrag = () => {
|
||||
const useDocumentDrag = (options = {}) => {
|
||||
const {
|
||||
engine,
|
||||
layoutRef,
|
||||
dragTransformsRef,
|
||||
itemRefs,
|
||||
documentLookup,
|
||||
ensureDocumentSize,
|
||||
@@ -28,42 +29,22 @@ const useDocumentDrag = () => {
|
||||
onDocumentStackSelect,
|
||||
selectedDocumentIds,
|
||||
markLayoutDirty,
|
||||
} = useDesktopContext();
|
||||
} = options;
|
||||
|
||||
const markLayoutDirtyRef = useRef(markLayoutDirty);
|
||||
useEffect(() => {
|
||||
markLayoutDirtyRef.current = markLayoutDirty;
|
||||
}, [markLayoutDirty]);
|
||||
|
||||
const syncLayoutSnapshotRef = useRef(syncLayoutSnapshot);
|
||||
useEffect(() => {
|
||||
syncLayoutSnapshotRef.current = syncLayoutSnapshot;
|
||||
}, [syncLayoutSnapshot]);
|
||||
|
||||
const physicsRef = useRef(null);
|
||||
if (!physicsRef.current) {
|
||||
physicsRef.current = createDragPhysics({
|
||||
layoutRef,
|
||||
itemRefs,
|
||||
markLayoutDirtyRef,
|
||||
syncLayoutSnapshotRef,
|
||||
});
|
||||
}
|
||||
const {
|
||||
canvasPadding = 24,
|
||||
defaultCanvasWidth = 1024,
|
||||
defaultCanvasHeight = 680,
|
||||
debugDrag = false,
|
||||
} = settings || {};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
physicsRef.current?.dispose?.();
|
||||
engine?.disposeInertiaAnimations?.();
|
||||
},
|
||||
[],
|
||||
[engine],
|
||||
);
|
||||
|
||||
const {
|
||||
applyTransform,
|
||||
finalizeGroupDrag,
|
||||
cancelInertiaAnimation,
|
||||
startInertiaAnimation,
|
||||
} = physicsRef.current;
|
||||
|
||||
const tapHandler = usePointerTap({
|
||||
delay: 220,
|
||||
onSingle: ({ data, event }) => {
|
||||
@@ -87,31 +68,77 @@ const useDocumentDrag = () => {
|
||||
openOverlayForDoc(data.docId, data.originInfo);
|
||||
},
|
||||
});
|
||||
|
||||
const dragStateRef = useRef(null);
|
||||
const { canvasPadding, defaultCanvasWidth, defaultCanvasHeight, debugDrag } = settings;
|
||||
|
||||
const finishDrag = useCallback(
|
||||
(pointerId) => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state || state.pointerId !== pointerId) {
|
||||
const setDragTransform = useCallback((docKey, transform) => {
|
||||
if (!docKey) {
|
||||
return;
|
||||
}
|
||||
const map = dragTransformsRef?.current;
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
map.set(String(docKey), transform);
|
||||
}, [dragTransformsRef]);
|
||||
|
||||
const clearDragTransforms = useCallback(() => {
|
||||
const map = dragTransformsRef?.current;
|
||||
if (!map || typeof map.clear !== 'function') {
|
||||
return;
|
||||
}
|
||||
map.clear();
|
||||
}, [dragTransformsRef]);
|
||||
|
||||
const commitActiveDragTransforms = useCallback((docIds = 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)
|
||||
: Array.from(map.keys());
|
||||
keys.forEach((key) => {
|
||||
const transform = map.get(key);
|
||||
if (!transform) {
|
||||
return;
|
||||
}
|
||||
const capturedTarget = state.capturedTarget;
|
||||
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
|
||||
try {
|
||||
capturedTarget.releasePointerCapture(pointerId);
|
||||
} catch (error) {
|
||||
if (debugDrag) {
|
||||
console.warn('[desk] releasePointerCapture failed', error);
|
||||
const previous = layoutRef.current.get(key) || {};
|
||||
layoutRef.current.set(key, {
|
||||
...previous,
|
||||
centerX: transform.centerX,
|
||||
centerY: transform.centerY,
|
||||
rotation: transform.rotation ?? previous.rotation ?? 0,
|
||||
});
|
||||
});
|
||||
markLayoutDirty?.();
|
||||
}, [dragTransformsRef, layoutRef, markLayoutDirty]);
|
||||
|
||||
const finishDrag = useCallback(
|
||||
(pointerId, { shouldSync = false, clearTransforms = true } = {}) => {
|
||||
const state = dragStateRef.current;
|
||||
if (state && state.pointerId === pointerId) {
|
||||
const capturedTarget = state.capturedTarget;
|
||||
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
|
||||
try {
|
||||
capturedTarget.releasePointerCapture(pointerId);
|
||||
} catch (error) {
|
||||
if (debugDrag) {
|
||||
console.warn('[desk] releasePointerCapture failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dragStateRef.current = null;
|
||||
setDraggingId((current) => (current === state.docId ? null : current));
|
||||
syncLayoutSnapshot();
|
||||
setDraggingId(null);
|
||||
engine?.endDrag?.();
|
||||
if (clearTransforms) {
|
||||
clearDragTransforms();
|
||||
}
|
||||
if (shouldSync) {
|
||||
syncLayoutSnapshot(true);
|
||||
}
|
||||
},
|
||||
[debugDrag, setDraggingId, syncLayoutSnapshot],
|
||||
[clearDragTransforms, debugDrag, engine, setDraggingId, syncLayoutSnapshot],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
@@ -136,7 +163,7 @@ const useDocumentDrag = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelInertiaAnimation(docId);
|
||||
engine?.cancelInertiaAnimation?.(docKey);
|
||||
|
||||
const doc = documentLookup.get(docKey);
|
||||
if (!doc) {
|
||||
@@ -193,7 +220,7 @@ const useDocumentDrag = () => {
|
||||
if (isGroupDrag) {
|
||||
groupDocIds.forEach((id) => {
|
||||
if (id !== docKey) {
|
||||
cancelInertiaAnimation(id);
|
||||
engine?.cancelInertiaAnimation?.(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -300,11 +327,13 @@ const useDocumentDrag = () => {
|
||||
const hasStackSource = Array.isArray(stackDocIdsOption) && stackDocIdsOption.length > 1;
|
||||
|
||||
dragStateRef.current = {
|
||||
docId,
|
||||
docId: docKey,
|
||||
docKey,
|
||||
pointerId: event.pointerId,
|
||||
originCenterX: centerX,
|
||||
originCenterY: centerY,
|
||||
currentCenterX: centerX,
|
||||
currentCenterY: centerY,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
rotation: entry?.rotation ?? 0,
|
||||
@@ -334,7 +363,26 @@ const useDocumentDrag = () => {
|
||||
stackReplace,
|
||||
};
|
||||
|
||||
setDraggingId(docId);
|
||||
const state = dragStateRef.current;
|
||||
|
||||
clearDragTransforms();
|
||||
state.groupItems.forEach((item) => {
|
||||
if (!item?.docId) {
|
||||
return;
|
||||
}
|
||||
setDragTransform(item.docId, {
|
||||
centerX: item.currentCenterX,
|
||||
centerY: item.currentCenterY,
|
||||
rotation: item.displayRotation ?? item.initialRotation ?? 0,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
scale: item.docId === state.docKey ? state.dragScale || 1 : 1,
|
||||
});
|
||||
});
|
||||
|
||||
engine?.beginDrag?.(state.groupDocIds);
|
||||
|
||||
setDraggingId(docKey);
|
||||
|
||||
if (isGroupDrag) {
|
||||
groupItems.forEach((item) => {
|
||||
@@ -344,22 +392,23 @@ const useDocumentDrag = () => {
|
||||
const node = itemRefs.current.get(item.docId);
|
||||
if (node) {
|
||||
item.displayRotation = item.initialRotation;
|
||||
node.style.transform = formatTransform(
|
||||
item.currentCenterX - item.width / 2,
|
||||
item.currentCenterY - item.height / 2,
|
||||
item.displayRotation,
|
||||
1,
|
||||
);
|
||||
applyDomTransform(node, {
|
||||
centerX: item.currentCenterX,
|
||||
centerY: item.currentCenterY,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
rotation: item.displayRotation ?? 0,
|
||||
scale: 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
}, [
|
||||
bringToFront,
|
||||
canvasPadding,
|
||||
cancelInertiaAnimation,
|
||||
containerRef,
|
||||
documentLookup,
|
||||
canvasPadding,
|
||||
containerRef,
|
||||
documentLookup,
|
||||
engine,
|
||||
ensureDocumentSize,
|
||||
layoutRef,
|
||||
resolveBaseMetrics,
|
||||
@@ -367,8 +416,9 @@ const useDocumentDrag = () => {
|
||||
setDraggingId,
|
||||
debugDrag,
|
||||
itemRefs,
|
||||
],
|
||||
);
|
||||
clearDragTransforms,
|
||||
setDragTransform,
|
||||
]);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event) => {
|
||||
@@ -411,14 +461,11 @@ const useDocumentDrag = () => {
|
||||
}
|
||||
state.moved = true;
|
||||
if (
|
||||
state.isGroup
|
||||
&& !state.stackSelectionApplied
|
||||
!state.stackSelectionApplied
|
||||
&& Array.isArray(state.stackDocIds)
|
||||
&& state.stackDocIds.length > 0
|
||||
) {
|
||||
if (typeof onDocumentStackSelect === 'function') {
|
||||
onDocumentStackSelect(state.stackDocIds, event, { replace: state.stackReplace });
|
||||
}
|
||||
safeInvoke(onDocumentStackSelect, state.stackDocIds, event, { replace: state.stackReplace });
|
||||
state.stackSelectionApplied = true;
|
||||
}
|
||||
if (!state.groupElevated) {
|
||||
@@ -431,11 +478,8 @@ const useDocumentDrag = () => {
|
||||
return aZ - bZ;
|
||||
});
|
||||
|
||||
sortedGroup.forEach((id) => {
|
||||
bringToFront(id);
|
||||
});
|
||||
|
||||
bringToFront(state.docId);
|
||||
sortedGroup.forEach((id) => bringToFront(id));
|
||||
bringToFront(state.docKey);
|
||||
state.groupElevated = true;
|
||||
}
|
||||
}
|
||||
@@ -456,22 +500,8 @@ const useDocumentDrag = () => {
|
||||
const centerX = clamp(desiredCenterX, minCenterX, maxCenterX);
|
||||
const centerY = clamp(desiredCenterY, minCenterY, maxCenterY);
|
||||
|
||||
const primaryEntry = layoutRef.current.get(state.docKey) || {};
|
||||
const primaryRotation = state.rotation ?? primaryEntry.rotation ?? 0;
|
||||
layoutRef.current.set(state.docKey, {
|
||||
...primaryEntry,
|
||||
centerX,
|
||||
centerY,
|
||||
});
|
||||
const primaryNode = itemRefs.current.get(state.docId);
|
||||
if (primaryNode) {
|
||||
primaryNode.style.transform = formatTransform(
|
||||
centerX - docWidth / 2,
|
||||
centerY - docHeight / 2,
|
||||
primaryRotation,
|
||||
state.dragScale || 1,
|
||||
);
|
||||
}
|
||||
state.currentCenterX = centerX;
|
||||
state.currentCenterY = centerY;
|
||||
|
||||
state.groupItems.forEach((item) => {
|
||||
const isPrimary = item.docId === state.docKey;
|
||||
@@ -508,25 +538,18 @@ const useDocumentDrag = () => {
|
||||
item.displayRotation += (item.targetRotation - item.displayRotation) * rotationBlend;
|
||||
}
|
||||
|
||||
markLayoutDirty?.();
|
||||
|
||||
const entryItem = layoutRef.current.get(item.docId) || {};
|
||||
layoutRef.current.set(item.docId, {
|
||||
...entryItem,
|
||||
const payload = {
|
||||
centerX: item.currentCenterX,
|
||||
centerY: item.currentCenterY,
|
||||
rotation: item.displayRotation ?? entryItem.rotation ?? 0,
|
||||
});
|
||||
rotation: item.displayRotation ?? 0,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
scale: isPrimary ? state.dragScale || 1 : 1,
|
||||
};
|
||||
|
||||
applyTransform(
|
||||
item.docId,
|
||||
item.currentCenterX,
|
||||
item.currentCenterY,
|
||||
item.width,
|
||||
item.height,
|
||||
item.displayRotation ?? entryItem.rotation ?? 0,
|
||||
isPrimary ? state.dragScale || 1 : 1,
|
||||
);
|
||||
setDragTransform(item.docId, payload);
|
||||
const node = itemRefs.current.get(item.docId);
|
||||
applyDomTransform(node, payload);
|
||||
});
|
||||
|
||||
state.lastClientX = event.clientX;
|
||||
@@ -538,21 +561,15 @@ const useDocumentDrag = () => {
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
|
||||
recalcVisibleDocIds();
|
||||
return;
|
||||
}
|
||||
if (state.locked) {
|
||||
if (debugDrag) {
|
||||
console.log('[desk] handlePointerMove: locked drag for doc', state.docId);
|
||||
console.log('[desk] handlePointerMove: locked drag for doc', state.docKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = layoutRef.current.get(state.docId);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = event.clientX - state.startX;
|
||||
const deltaY = event.clientY - state.startY;
|
||||
|
||||
@@ -572,7 +589,8 @@ const useDocumentDrag = () => {
|
||||
const pointerCanvasX = event.clientX - containerLeft;
|
||||
const pointerCanvasY = event.clientY - containerTop;
|
||||
|
||||
const rotationDeg = entry?.rotation ?? 0;
|
||||
const entry = layoutRef.current.get(state.docKey) || {};
|
||||
const rotationDeg = state.rotation ?? entry.rotation ?? 0;
|
||||
const rotationRad = (rotationDeg * Math.PI) / 180;
|
||||
const cosRot = Math.cos(rotationRad);
|
||||
const sinRot = Math.sin(rotationRad);
|
||||
@@ -581,8 +599,12 @@ const useDocumentDrag = () => {
|
||||
const rotatedOffsetY =
|
||||
state.localPointerOffsetX * sinRot + state.localPointerOffsetY * cosRot;
|
||||
|
||||
const previousCenterX = Number.isFinite(entry.centerX) ? entry.centerX : state.originCenterX;
|
||||
const previousCenterY = Number.isFinite(entry.centerY) ? entry.centerY : state.originCenterY;
|
||||
const previousCenterX = Number.isFinite(state.currentCenterX)
|
||||
? state.currentCenterX
|
||||
: state.originCenterX;
|
||||
const previousCenterY = Number.isFinite(state.currentCenterY)
|
||||
? state.currentCenterY
|
||||
: state.originCenterY;
|
||||
|
||||
const absCos = Math.abs(cosRot);
|
||||
const absSin = Math.abs(sinRot);
|
||||
@@ -614,7 +636,7 @@ const useDocumentDrag = () => {
|
||||
if (distanceSquared < DRAG_HYSTERESIS_SQUARED) {
|
||||
return;
|
||||
}
|
||||
bringToFront(state.docId);
|
||||
bringToFront(state.docKey);
|
||||
state.moved = true;
|
||||
}
|
||||
|
||||
@@ -631,28 +653,21 @@ const useDocumentDrag = () => {
|
||||
currentCenterY = previousCenterY;
|
||||
}
|
||||
|
||||
const updated = { ...entry, centerX: currentCenterX, centerY: currentCenterY };
|
||||
layoutRef.current.set(state.docId, updated);
|
||||
state.currentCenterX = currentCenterX;
|
||||
state.currentCenterY = currentCenterY;
|
||||
|
||||
applyTransform(
|
||||
state.docId,
|
||||
currentCenterX,
|
||||
currentCenterY,
|
||||
state.width,
|
||||
state.height,
|
||||
rotationDeg,
|
||||
state.dragScale || 1,
|
||||
);
|
||||
const transformPayload = {
|
||||
centerX: currentCenterX,
|
||||
centerY: currentCenterY,
|
||||
rotation: rotationDeg,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
scale: state.dragScale || 1,
|
||||
};
|
||||
|
||||
const primaryNode = itemRefs.current.get(state.docId);
|
||||
if (primaryNode) {
|
||||
primaryNode.style.transform = formatTransform(
|
||||
currentCenterX - state.width / 2,
|
||||
currentCenterY - state.height / 2,
|
||||
rotationDeg,
|
||||
state.dragScale || 1,
|
||||
);
|
||||
}
|
||||
setDragTransform(state.docKey, transformPayload);
|
||||
const primaryNode = itemRefs.current.get(state.docKey);
|
||||
applyDomTransform(primaryNode, transformPayload);
|
||||
|
||||
const offsetX = pointerCanvasX - currentCenterX;
|
||||
const offsetY = pointerCanvasY - currentCenterY;
|
||||
@@ -663,8 +678,6 @@ const useDocumentDrag = () => {
|
||||
const pointerInsideCard =
|
||||
Math.abs(pointerLocalX) <= halfWidth && Math.abs(pointerLocalY) <= halfHeight;
|
||||
|
||||
markLayoutDirty?.();
|
||||
|
||||
const currentTimestamp =
|
||||
typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)
|
||||
? event.timeStamp
|
||||
@@ -696,9 +709,8 @@ const useDocumentDrag = () => {
|
||||
}
|
||||
|
||||
if (debugDrag) {
|
||||
console.log('[desk] handlePointerMove: moved doc', state.docId, 'to', currentCenterX, currentCenterY);
|
||||
console.log('[desk] handlePointerMove: moved doc', state.docKey, 'to', currentCenterX, currentCenterY);
|
||||
}
|
||||
recalcVisibleDocIds();
|
||||
},
|
||||
[
|
||||
bringToFront,
|
||||
@@ -710,11 +722,9 @@ const useDocumentDrag = () => {
|
||||
containerRef,
|
||||
layoutRef,
|
||||
itemRefs,
|
||||
applyTransform,
|
||||
recalcVisibleDocIds,
|
||||
debugDrag,
|
||||
onDocumentStackSelect,
|
||||
markLayoutDirty,
|
||||
setDragTransform,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -727,13 +737,15 @@ const useDocumentDrag = () => {
|
||||
}
|
||||
|
||||
if (state.isGroup) {
|
||||
finalizeGroupDrag(state);
|
||||
finishDrag(event.pointerId);
|
||||
engine?.finalizeGroupDrag?.(state);
|
||||
commitActiveDragTransforms(state.groupDocIds);
|
||||
finishDrag(event.pointerId, { shouldSync: true });
|
||||
recalcVisibleDocIds();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.moved) {
|
||||
commitActiveDragTransforms([state.docKey]);
|
||||
const inertiaState = {
|
||||
restRotation: state.restRotation,
|
||||
dynamicRotation: state.dynamicRotation,
|
||||
@@ -743,13 +755,13 @@ const useDocumentDrag = () => {
|
||||
height: state.height,
|
||||
dragScale: state.dragScale || 1,
|
||||
};
|
||||
const docId = state.docId;
|
||||
finishDrag(event.pointerId);
|
||||
startInertiaAnimation(docId, inertiaState);
|
||||
const docId = state.docKey;
|
||||
finishDrag(event.pointerId, { shouldSync: true });
|
||||
engine?.startInertiaAnimation?.(docId, inertiaState);
|
||||
return;
|
||||
}
|
||||
|
||||
const docId = state.docId;
|
||||
const docId = state.docKey;
|
||||
const metaPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
||||
if (!metaPressed) {
|
||||
bringToFront(docId);
|
||||
@@ -771,10 +783,10 @@ const useDocumentDrag = () => {
|
||||
},
|
||||
[
|
||||
bringToFront,
|
||||
commitActiveDragTransforms,
|
||||
documentLookup,
|
||||
engine,
|
||||
finishDrag,
|
||||
finalizeGroupDrag,
|
||||
startInertiaAnimation,
|
||||
recalcVisibleDocIds,
|
||||
tapHandler,
|
||||
],
|
||||
@@ -785,12 +797,14 @@ const useDocumentDrag = () => {
|
||||
const state = dragStateRef.current;
|
||||
if (state && state.pointerId === event.pointerId && state.moved) {
|
||||
if (state.isGroup) {
|
||||
finalizeGroupDrag(state);
|
||||
finishDrag(event.pointerId);
|
||||
engine?.finalizeGroupDrag?.(state);
|
||||
commitActiveDragTransforms(state.groupDocIds);
|
||||
finishDrag(event.pointerId, { shouldSync: true });
|
||||
recalcVisibleDocIds();
|
||||
return;
|
||||
}
|
||||
|
||||
commitActiveDragTransforms([state.docKey]);
|
||||
const inertiaState = {
|
||||
restRotation: state.restRotation,
|
||||
dynamicRotation: state.dynamicRotation,
|
||||
@@ -800,14 +814,14 @@ const useDocumentDrag = () => {
|
||||
height: state.height,
|
||||
dragScale: state.dragScale || 1,
|
||||
};
|
||||
const docId = state.docId;
|
||||
finishDrag(event.pointerId);
|
||||
startInertiaAnimation(docId, inertiaState);
|
||||
const docId = state.docKey;
|
||||
finishDrag(event.pointerId, { shouldSync: true });
|
||||
engine?.startInertiaAnimation?.(docId, inertiaState);
|
||||
return;
|
||||
}
|
||||
finishDrag(event.pointerId);
|
||||
},
|
||||
[finalizeGroupDrag, finishDrag, recalcVisibleDocIds, startInertiaAnimation],
|
||||
[commitActiveDragTransforms, engine, finishDrag, recalcVisibleDocIds],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -416,6 +416,13 @@ button.danger:hover:not([disabled]) {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.panel-header .icon-button.active:hover:not([disabled]),
|
||||
.panel-header button.active:hover:not([disabled]),
|
||||
.panel-header a.icon-button.active:hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.panel-header .icon-button.ghost,
|
||||
.panel-header button.icon-button.ghost {
|
||||
color: var(--muted);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
WorkspaceEngine,
|
||||
DESK_CANVAS_PADDING,
|
||||
} = require('../src/desktop/workspaceEngine.js');
|
||||
|
||||
const makeEngine = () => {
|
||||
const engine = new WorkspaceEngine();
|
||||
engine.setEnsureDocumentSize(() => ({ width: 200, height: 200 }));
|
||||
engine.setCanvasSize({ width: 1200, height: 800 });
|
||||
return engine;
|
||||
};
|
||||
|
||||
test('syncLayoutSnapshot clones from layout map', () => {
|
||||
const engine = makeEngine();
|
||||
engine.setItems([{ id: 'doc-1' }]);
|
||||
engine.ensureLayoutForItems();
|
||||
engine.syncLayoutSnapshot();
|
||||
|
||||
const firstSnapshot = engine.getSnapshot();
|
||||
assert(firstSnapshot.layout instanceof Map);
|
||||
assert(firstSnapshot.layout.get('doc-1'));
|
||||
|
||||
engine.layout.set('doc-1', {
|
||||
centerX: DESK_CANVAS_PADDING + 150,
|
||||
centerY: DESK_CANVAS_PADDING + 150,
|
||||
rotation: 0,
|
||||
width: 200,
|
||||
height: 200,
|
||||
});
|
||||
engine.syncLayoutSnapshot();
|
||||
|
||||
const secondSnapshot = engine.getSnapshot();
|
||||
assert.notStrictEqual(secondSnapshot.layout, engine.layout);
|
||||
assert.equal(secondSnapshot.layout.get('doc-1').centerX, engine.layout.get('doc-1').centerX);
|
||||
});
|
||||
|
||||
test('recalcVisibleDocIds respects viewport bounds', () => {
|
||||
const engine = makeEngine();
|
||||
engine.items = [{ id: 'visible' }, { id: 'hidden' }];
|
||||
engine.setDocumentLookup(new Map([
|
||||
['visible', { id: 'visible' }],
|
||||
['hidden', { id: 'hidden' }],
|
||||
]));
|
||||
|
||||
engine.layout = new Map([
|
||||
['visible', {
|
||||
centerX: DESK_CANVAS_PADDING + 150,
|
||||
centerY: DESK_CANVAS_PADDING + 150,
|
||||
rotation: 0,
|
||||
width: 200,
|
||||
height: 200,
|
||||
}],
|
||||
['hidden', {
|
||||
centerX: -500,
|
||||
centerY: -500,
|
||||
rotation: 0,
|
||||
width: 200,
|
||||
height: 200,
|
||||
}],
|
||||
]);
|
||||
engine.layoutSnapshot = new Map(engine.layout);
|
||||
|
||||
engine.recalcVisibleDocIds();
|
||||
|
||||
const snapshot = engine.getSnapshot();
|
||||
assert(snapshot.visibleDocIds.has('visible'));
|
||||
assert(!snapshot.visibleDocIds.has('hidden'));
|
||||
});
|
||||
Reference in New Issue
Block a user