This commit is contained in:
2025-11-06 14:43:43 +01:00
parent 28b9112781
commit 3f49e26d19
19 changed files with 3980 additions and 3366 deletions
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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,
-143
View File
@@ -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);
+146
View File
@@ -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
-17
View File
@@ -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;
+183
View File
@@ -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;
});
};
-202
View File
@@ -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;
+16
View File
@@ -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;
+176 -162
View File
@@ -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
+7
View File
@@ -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);
+70
View File
@@ -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'));
});