work
This commit is contained in:
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "webpack serve --mode development --open",
|
"dev": "webpack serve --mode development --open",
|
||||||
"build": "webpack --mode production",
|
"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": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@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 { SidebarExpandIcon } from '../ui/icons';
|
||||||
import { createDocumentsSurface } from '../documents/DocumentsPanel';
|
import { createDocumentsSurface } from '../documents/DocumentsPanel';
|
||||||
import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel';
|
import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel';
|
||||||
import { createDesktopSurface } from '../DesktopWorkspace';
|
import { createDesktopSurface } from '../desktop/DesktopWorkspace';
|
||||||
|
|
||||||
export const useWorkspaceSurface = ({
|
export const useWorkspaceSurface = ({
|
||||||
sidebarCollapsed,
|
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);
|
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 { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useDesktopContext } from './context';
|
import { preventAll, safeInvoke } from './events';
|
||||||
import { preventAll } from './events';
|
import { clamp } from './math';
|
||||||
import { clamp, formatTransform } from './math';
|
|
||||||
import usePointerTap from '../ui/usePointerTap';
|
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_PX = 4;
|
||||||
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
|
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
|
||||||
const EDGE_COLLISION_THRESHOLD = 0.5;
|
const EDGE_COLLISION_THRESHOLD = 0.5;
|
||||||
|
|
||||||
const useDocumentDrag = () => {
|
const useDocumentDrag = (options = {}) => {
|
||||||
const {
|
const {
|
||||||
|
engine,
|
||||||
layoutRef,
|
layoutRef,
|
||||||
|
dragTransformsRef,
|
||||||
itemRefs,
|
itemRefs,
|
||||||
documentLookup,
|
documentLookup,
|
||||||
ensureDocumentSize,
|
ensureDocumentSize,
|
||||||
@@ -28,42 +29,22 @@ const useDocumentDrag = () => {
|
|||||||
onDocumentStackSelect,
|
onDocumentStackSelect,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
markLayoutDirty,
|
markLayoutDirty,
|
||||||
} = useDesktopContext();
|
} = options;
|
||||||
|
|
||||||
const markLayoutDirtyRef = useRef(markLayoutDirty);
|
const {
|
||||||
useEffect(() => {
|
canvasPadding = 24,
|
||||||
markLayoutDirtyRef.current = markLayoutDirty;
|
defaultCanvasWidth = 1024,
|
||||||
}, [markLayoutDirty]);
|
defaultCanvasHeight = 680,
|
||||||
|
debugDrag = false,
|
||||||
const syncLayoutSnapshotRef = useRef(syncLayoutSnapshot);
|
} = settings || {};
|
||||||
useEffect(() => {
|
|
||||||
syncLayoutSnapshotRef.current = syncLayoutSnapshot;
|
|
||||||
}, [syncLayoutSnapshot]);
|
|
||||||
|
|
||||||
const physicsRef = useRef(null);
|
|
||||||
if (!physicsRef.current) {
|
|
||||||
physicsRef.current = createDragPhysics({
|
|
||||||
layoutRef,
|
|
||||||
itemRefs,
|
|
||||||
markLayoutDirtyRef,
|
|
||||||
syncLayoutSnapshotRef,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
physicsRef.current?.dispose?.();
|
engine?.disposeInertiaAnimations?.();
|
||||||
},
|
},
|
||||||
[],
|
[engine],
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
|
||||||
applyTransform,
|
|
||||||
finalizeGroupDrag,
|
|
||||||
cancelInertiaAnimation,
|
|
||||||
startInertiaAnimation,
|
|
||||||
} = physicsRef.current;
|
|
||||||
|
|
||||||
const tapHandler = usePointerTap({
|
const tapHandler = usePointerTap({
|
||||||
delay: 220,
|
delay: 220,
|
||||||
onSingle: ({ data, event }) => {
|
onSingle: ({ data, event }) => {
|
||||||
@@ -87,31 +68,77 @@ const useDocumentDrag = () => {
|
|||||||
openOverlayForDoc(data.docId, data.originInfo);
|
openOverlayForDoc(data.docId, data.originInfo);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const dragStateRef = useRef(null);
|
const dragStateRef = useRef(null);
|
||||||
const { canvasPadding, defaultCanvasWidth, defaultCanvasHeight, debugDrag } = settings;
|
|
||||||
|
|
||||||
const finishDrag = useCallback(
|
const setDragTransform = useCallback((docKey, transform) => {
|
||||||
(pointerId) => {
|
if (!docKey) {
|
||||||
const state = dragStateRef.current;
|
return;
|
||||||
if (!state || state.pointerId !== pointerId) {
|
}
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const capturedTarget = state.capturedTarget;
|
const previous = layoutRef.current.get(key) || {};
|
||||||
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
|
layoutRef.current.set(key, {
|
||||||
try {
|
...previous,
|
||||||
capturedTarget.releasePointerCapture(pointerId);
|
centerX: transform.centerX,
|
||||||
} catch (error) {
|
centerY: transform.centerY,
|
||||||
if (debugDrag) {
|
rotation: transform.rotation ?? previous.rotation ?? 0,
|
||||||
console.warn('[desk] releasePointerCapture failed', error);
|
});
|
||||||
|
});
|
||||||
|
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;
|
dragStateRef.current = null;
|
||||||
setDraggingId((current) => (current === state.docId ? null : current));
|
setDraggingId(null);
|
||||||
syncLayoutSnapshot();
|
engine?.endDrag?.();
|
||||||
|
if (clearTransforms) {
|
||||||
|
clearDragTransforms();
|
||||||
|
}
|
||||||
|
if (shouldSync) {
|
||||||
|
syncLayoutSnapshot(true);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[debugDrag, setDraggingId, syncLayoutSnapshot],
|
[clearDragTransforms, debugDrag, engine, setDraggingId, syncLayoutSnapshot],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePointerDown = useCallback(
|
const handlePointerDown = useCallback(
|
||||||
@@ -136,7 +163,7 @@ const useDocumentDrag = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelInertiaAnimation(docId);
|
engine?.cancelInertiaAnimation?.(docKey);
|
||||||
|
|
||||||
const doc = documentLookup.get(docKey);
|
const doc = documentLookup.get(docKey);
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
@@ -193,7 +220,7 @@ const useDocumentDrag = () => {
|
|||||||
if (isGroupDrag) {
|
if (isGroupDrag) {
|
||||||
groupDocIds.forEach((id) => {
|
groupDocIds.forEach((id) => {
|
||||||
if (id !== docKey) {
|
if (id !== docKey) {
|
||||||
cancelInertiaAnimation(id);
|
engine?.cancelInertiaAnimation?.(id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -300,11 +327,13 @@ const useDocumentDrag = () => {
|
|||||||
const hasStackSource = Array.isArray(stackDocIdsOption) && stackDocIdsOption.length > 1;
|
const hasStackSource = Array.isArray(stackDocIdsOption) && stackDocIdsOption.length > 1;
|
||||||
|
|
||||||
dragStateRef.current = {
|
dragStateRef.current = {
|
||||||
docId,
|
docId: docKey,
|
||||||
docKey,
|
docKey,
|
||||||
pointerId: event.pointerId,
|
pointerId: event.pointerId,
|
||||||
originCenterX: centerX,
|
originCenterX: centerX,
|
||||||
originCenterY: centerY,
|
originCenterY: centerY,
|
||||||
|
currentCenterX: centerX,
|
||||||
|
currentCenterY: centerY,
|
||||||
startX: event.clientX,
|
startX: event.clientX,
|
||||||
startY: event.clientY,
|
startY: event.clientY,
|
||||||
rotation: entry?.rotation ?? 0,
|
rotation: entry?.rotation ?? 0,
|
||||||
@@ -334,7 +363,26 @@ const useDocumentDrag = () => {
|
|||||||
stackReplace,
|
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) {
|
if (isGroupDrag) {
|
||||||
groupItems.forEach((item) => {
|
groupItems.forEach((item) => {
|
||||||
@@ -344,22 +392,23 @@ const useDocumentDrag = () => {
|
|||||||
const node = itemRefs.current.get(item.docId);
|
const node = itemRefs.current.get(item.docId);
|
||||||
if (node) {
|
if (node) {
|
||||||
item.displayRotation = item.initialRotation;
|
item.displayRotation = item.initialRotation;
|
||||||
node.style.transform = formatTransform(
|
applyDomTransform(node, {
|
||||||
item.currentCenterX - item.width / 2,
|
centerX: item.currentCenterX,
|
||||||
item.currentCenterY - item.height / 2,
|
centerY: item.currentCenterY,
|
||||||
item.displayRotation,
|
width: item.width,
|
||||||
1,
|
height: item.height,
|
||||||
);
|
rotation: item.displayRotation ?? 0,
|
||||||
|
scale: 1,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
}, [
|
||||||
[
|
|
||||||
bringToFront,
|
bringToFront,
|
||||||
canvasPadding,
|
canvasPadding,
|
||||||
cancelInertiaAnimation,
|
containerRef,
|
||||||
containerRef,
|
documentLookup,
|
||||||
documentLookup,
|
engine,
|
||||||
ensureDocumentSize,
|
ensureDocumentSize,
|
||||||
layoutRef,
|
layoutRef,
|
||||||
resolveBaseMetrics,
|
resolveBaseMetrics,
|
||||||
@@ -367,8 +416,9 @@ const useDocumentDrag = () => {
|
|||||||
setDraggingId,
|
setDraggingId,
|
||||||
debugDrag,
|
debugDrag,
|
||||||
itemRefs,
|
itemRefs,
|
||||||
],
|
clearDragTransforms,
|
||||||
);
|
setDragTransform,
|
||||||
|
]);
|
||||||
|
|
||||||
const handlePointerMove = useCallback(
|
const handlePointerMove = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
@@ -411,14 +461,11 @@ const useDocumentDrag = () => {
|
|||||||
}
|
}
|
||||||
state.moved = true;
|
state.moved = true;
|
||||||
if (
|
if (
|
||||||
state.isGroup
|
!state.stackSelectionApplied
|
||||||
&& !state.stackSelectionApplied
|
|
||||||
&& Array.isArray(state.stackDocIds)
|
&& Array.isArray(state.stackDocIds)
|
||||||
&& state.stackDocIds.length > 0
|
&& state.stackDocIds.length > 0
|
||||||
) {
|
) {
|
||||||
if (typeof onDocumentStackSelect === 'function') {
|
safeInvoke(onDocumentStackSelect, state.stackDocIds, event, { replace: state.stackReplace });
|
||||||
onDocumentStackSelect(state.stackDocIds, event, { replace: state.stackReplace });
|
|
||||||
}
|
|
||||||
state.stackSelectionApplied = true;
|
state.stackSelectionApplied = true;
|
||||||
}
|
}
|
||||||
if (!state.groupElevated) {
|
if (!state.groupElevated) {
|
||||||
@@ -431,11 +478,8 @@ const useDocumentDrag = () => {
|
|||||||
return aZ - bZ;
|
return aZ - bZ;
|
||||||
});
|
});
|
||||||
|
|
||||||
sortedGroup.forEach((id) => {
|
sortedGroup.forEach((id) => bringToFront(id));
|
||||||
bringToFront(id);
|
bringToFront(state.docKey);
|
||||||
});
|
|
||||||
|
|
||||||
bringToFront(state.docId);
|
|
||||||
state.groupElevated = true;
|
state.groupElevated = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -456,22 +500,8 @@ const useDocumentDrag = () => {
|
|||||||
const centerX = clamp(desiredCenterX, minCenterX, maxCenterX);
|
const centerX = clamp(desiredCenterX, minCenterX, maxCenterX);
|
||||||
const centerY = clamp(desiredCenterY, minCenterY, maxCenterY);
|
const centerY = clamp(desiredCenterY, minCenterY, maxCenterY);
|
||||||
|
|
||||||
const primaryEntry = layoutRef.current.get(state.docKey) || {};
|
state.currentCenterX = centerX;
|
||||||
const primaryRotation = state.rotation ?? primaryEntry.rotation ?? 0;
|
state.currentCenterY = centerY;
|
||||||
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.groupItems.forEach((item) => {
|
state.groupItems.forEach((item) => {
|
||||||
const isPrimary = item.docId === state.docKey;
|
const isPrimary = item.docId === state.docKey;
|
||||||
@@ -508,25 +538,18 @@ const useDocumentDrag = () => {
|
|||||||
item.displayRotation += (item.targetRotation - item.displayRotation) * rotationBlend;
|
item.displayRotation += (item.targetRotation - item.displayRotation) * rotationBlend;
|
||||||
}
|
}
|
||||||
|
|
||||||
markLayoutDirty?.();
|
const payload = {
|
||||||
|
|
||||||
const entryItem = layoutRef.current.get(item.docId) || {};
|
|
||||||
layoutRef.current.set(item.docId, {
|
|
||||||
...entryItem,
|
|
||||||
centerX: item.currentCenterX,
|
centerX: item.currentCenterX,
|
||||||
centerY: item.currentCenterY,
|
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(
|
setDragTransform(item.docId, payload);
|
||||||
item.docId,
|
const node = itemRefs.current.get(item.docId);
|
||||||
item.currentCenterX,
|
applyDomTransform(node, payload);
|
||||||
item.currentCenterY,
|
|
||||||
item.width,
|
|
||||||
item.height,
|
|
||||||
item.displayRotation ?? entryItem.rotation ?? 0,
|
|
||||||
isPrimary ? state.dragScale || 1 : 1,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
state.lastClientX = event.clientX;
|
state.lastClientX = event.clientX;
|
||||||
@@ -538,21 +561,15 @@ const useDocumentDrag = () => {
|
|||||||
? performance.now()
|
? performance.now()
|
||||||
: Date.now();
|
: Date.now();
|
||||||
|
|
||||||
recalcVisibleDocIds();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state.locked) {
|
if (state.locked) {
|
||||||
if (debugDrag) {
|
if (debugDrag) {
|
||||||
console.log('[desk] handlePointerMove: locked drag for doc', state.docId);
|
console.log('[desk] handlePointerMove: locked drag for doc', state.docKey);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = layoutRef.current.get(state.docId);
|
|
||||||
if (!entry) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deltaX = event.clientX - state.startX;
|
const deltaX = event.clientX - state.startX;
|
||||||
const deltaY = event.clientY - state.startY;
|
const deltaY = event.clientY - state.startY;
|
||||||
|
|
||||||
@@ -572,7 +589,8 @@ const useDocumentDrag = () => {
|
|||||||
const pointerCanvasX = event.clientX - containerLeft;
|
const pointerCanvasX = event.clientX - containerLeft;
|
||||||
const pointerCanvasY = event.clientY - containerTop;
|
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 rotationRad = (rotationDeg * Math.PI) / 180;
|
||||||
const cosRot = Math.cos(rotationRad);
|
const cosRot = Math.cos(rotationRad);
|
||||||
const sinRot = Math.sin(rotationRad);
|
const sinRot = Math.sin(rotationRad);
|
||||||
@@ -581,8 +599,12 @@ const useDocumentDrag = () => {
|
|||||||
const rotatedOffsetY =
|
const rotatedOffsetY =
|
||||||
state.localPointerOffsetX * sinRot + state.localPointerOffsetY * cosRot;
|
state.localPointerOffsetX * sinRot + state.localPointerOffsetY * cosRot;
|
||||||
|
|
||||||
const previousCenterX = Number.isFinite(entry.centerX) ? entry.centerX : state.originCenterX;
|
const previousCenterX = Number.isFinite(state.currentCenterX)
|
||||||
const previousCenterY = Number.isFinite(entry.centerY) ? entry.centerY : state.originCenterY;
|
? state.currentCenterX
|
||||||
|
: state.originCenterX;
|
||||||
|
const previousCenterY = Number.isFinite(state.currentCenterY)
|
||||||
|
? state.currentCenterY
|
||||||
|
: state.originCenterY;
|
||||||
|
|
||||||
const absCos = Math.abs(cosRot);
|
const absCos = Math.abs(cosRot);
|
||||||
const absSin = Math.abs(sinRot);
|
const absSin = Math.abs(sinRot);
|
||||||
@@ -614,7 +636,7 @@ const useDocumentDrag = () => {
|
|||||||
if (distanceSquared < DRAG_HYSTERESIS_SQUARED) {
|
if (distanceSquared < DRAG_HYSTERESIS_SQUARED) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bringToFront(state.docId);
|
bringToFront(state.docKey);
|
||||||
state.moved = true;
|
state.moved = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,28 +653,21 @@ const useDocumentDrag = () => {
|
|||||||
currentCenterY = previousCenterY;
|
currentCenterY = previousCenterY;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = { ...entry, centerX: currentCenterX, centerY: currentCenterY };
|
state.currentCenterX = currentCenterX;
|
||||||
layoutRef.current.set(state.docId, updated);
|
state.currentCenterY = currentCenterY;
|
||||||
|
|
||||||
applyTransform(
|
const transformPayload = {
|
||||||
state.docId,
|
centerX: currentCenterX,
|
||||||
currentCenterX,
|
centerY: currentCenterY,
|
||||||
currentCenterY,
|
rotation: rotationDeg,
|
||||||
state.width,
|
width: state.width,
|
||||||
state.height,
|
height: state.height,
|
||||||
rotationDeg,
|
scale: state.dragScale || 1,
|
||||||
state.dragScale || 1,
|
};
|
||||||
);
|
|
||||||
|
|
||||||
const primaryNode = itemRefs.current.get(state.docId);
|
setDragTransform(state.docKey, transformPayload);
|
||||||
if (primaryNode) {
|
const primaryNode = itemRefs.current.get(state.docKey);
|
||||||
primaryNode.style.transform = formatTransform(
|
applyDomTransform(primaryNode, transformPayload);
|
||||||
currentCenterX - state.width / 2,
|
|
||||||
currentCenterY - state.height / 2,
|
|
||||||
rotationDeg,
|
|
||||||
state.dragScale || 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const offsetX = pointerCanvasX - currentCenterX;
|
const offsetX = pointerCanvasX - currentCenterX;
|
||||||
const offsetY = pointerCanvasY - currentCenterY;
|
const offsetY = pointerCanvasY - currentCenterY;
|
||||||
@@ -663,8 +678,6 @@ const useDocumentDrag = () => {
|
|||||||
const pointerInsideCard =
|
const pointerInsideCard =
|
||||||
Math.abs(pointerLocalX) <= halfWidth && Math.abs(pointerLocalY) <= halfHeight;
|
Math.abs(pointerLocalX) <= halfWidth && Math.abs(pointerLocalY) <= halfHeight;
|
||||||
|
|
||||||
markLayoutDirty?.();
|
|
||||||
|
|
||||||
const currentTimestamp =
|
const currentTimestamp =
|
||||||
typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)
|
typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)
|
||||||
? event.timeStamp
|
? event.timeStamp
|
||||||
@@ -696,9 +709,8 @@ const useDocumentDrag = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (debugDrag) {
|
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,
|
bringToFront,
|
||||||
@@ -710,11 +722,9 @@ const useDocumentDrag = () => {
|
|||||||
containerRef,
|
containerRef,
|
||||||
layoutRef,
|
layoutRef,
|
||||||
itemRefs,
|
itemRefs,
|
||||||
applyTransform,
|
|
||||||
recalcVisibleDocIds,
|
|
||||||
debugDrag,
|
debugDrag,
|
||||||
onDocumentStackSelect,
|
onDocumentStackSelect,
|
||||||
markLayoutDirty,
|
setDragTransform,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -727,13 +737,15 @@ const useDocumentDrag = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.isGroup) {
|
if (state.isGroup) {
|
||||||
finalizeGroupDrag(state);
|
engine?.finalizeGroupDrag?.(state);
|
||||||
finishDrag(event.pointerId);
|
commitActiveDragTransforms(state.groupDocIds);
|
||||||
|
finishDrag(event.pointerId, { shouldSync: true });
|
||||||
recalcVisibleDocIds();
|
recalcVisibleDocIds();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.moved) {
|
if (state.moved) {
|
||||||
|
commitActiveDragTransforms([state.docKey]);
|
||||||
const inertiaState = {
|
const inertiaState = {
|
||||||
restRotation: state.restRotation,
|
restRotation: state.restRotation,
|
||||||
dynamicRotation: state.dynamicRotation,
|
dynamicRotation: state.dynamicRotation,
|
||||||
@@ -743,13 +755,13 @@ const useDocumentDrag = () => {
|
|||||||
height: state.height,
|
height: state.height,
|
||||||
dragScale: state.dragScale || 1,
|
dragScale: state.dragScale || 1,
|
||||||
};
|
};
|
||||||
const docId = state.docId;
|
const docId = state.docKey;
|
||||||
finishDrag(event.pointerId);
|
finishDrag(event.pointerId, { shouldSync: true });
|
||||||
startInertiaAnimation(docId, inertiaState);
|
engine?.startInertiaAnimation?.(docId, inertiaState);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const docId = state.docId;
|
const docId = state.docKey;
|
||||||
const metaPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
const metaPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
||||||
if (!metaPressed) {
|
if (!metaPressed) {
|
||||||
bringToFront(docId);
|
bringToFront(docId);
|
||||||
@@ -771,10 +783,10 @@ const useDocumentDrag = () => {
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
bringToFront,
|
bringToFront,
|
||||||
|
commitActiveDragTransforms,
|
||||||
documentLookup,
|
documentLookup,
|
||||||
|
engine,
|
||||||
finishDrag,
|
finishDrag,
|
||||||
finalizeGroupDrag,
|
|
||||||
startInertiaAnimation,
|
|
||||||
recalcVisibleDocIds,
|
recalcVisibleDocIds,
|
||||||
tapHandler,
|
tapHandler,
|
||||||
],
|
],
|
||||||
@@ -785,12 +797,14 @@ const useDocumentDrag = () => {
|
|||||||
const state = dragStateRef.current;
|
const state = dragStateRef.current;
|
||||||
if (state && state.pointerId === event.pointerId && state.moved) {
|
if (state && state.pointerId === event.pointerId && state.moved) {
|
||||||
if (state.isGroup) {
|
if (state.isGroup) {
|
||||||
finalizeGroupDrag(state);
|
engine?.finalizeGroupDrag?.(state);
|
||||||
finishDrag(event.pointerId);
|
commitActiveDragTransforms(state.groupDocIds);
|
||||||
|
finishDrag(event.pointerId, { shouldSync: true });
|
||||||
recalcVisibleDocIds();
|
recalcVisibleDocIds();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commitActiveDragTransforms([state.docKey]);
|
||||||
const inertiaState = {
|
const inertiaState = {
|
||||||
restRotation: state.restRotation,
|
restRotation: state.restRotation,
|
||||||
dynamicRotation: state.dynamicRotation,
|
dynamicRotation: state.dynamicRotation,
|
||||||
@@ -800,14 +814,14 @@ const useDocumentDrag = () => {
|
|||||||
height: state.height,
|
height: state.height,
|
||||||
dragScale: state.dragScale || 1,
|
dragScale: state.dragScale || 1,
|
||||||
};
|
};
|
||||||
const docId = state.docId;
|
const docId = state.docKey;
|
||||||
finishDrag(event.pointerId);
|
finishDrag(event.pointerId, { shouldSync: true });
|
||||||
startInertiaAnimation(docId, inertiaState);
|
engine?.startInertiaAnimation?.(docId, inertiaState);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
finishDrag(event.pointerId);
|
finishDrag(event.pointerId);
|
||||||
},
|
},
|
||||||
[finalizeGroupDrag, finishDrag, recalcVisibleDocIds, startInertiaAnimation],
|
[commitActiveDragTransforms, engine, finishDrag, recalcVisibleDocIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -416,6 +416,13 @@ button.danger:hover:not([disabled]) {
|
|||||||
color: var(--fg);
|
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 .icon-button.ghost,
|
||||||
.panel-header button.icon-button.ghost {
|
.panel-header button.icon-button.ghost {
|
||||||
color: var(--muted);
|
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