Files
papercrate/frontend/src/desktop/workspaceEngine.ts
T

1628 lines
46 KiB
TypeScript

import { clamp, formatTransform, toNumber } from '../utils/math';
import {
Point,
Polygon,
clipPolygon,
isPointInsideConvex,
polygonCentroid,
iterateEdges,
forEachVertex,
} from './utils/geometry';
import { computeCardBounds } from './utils/layoutUtils';
import { fetchLayoutRecords, upsertLayoutRecords } from './db';
import {
ANGULAR_DAMPING,
CARD_BASE_WEIGHT_GRAMS,
CARD_PAGE_WEIGHT_GRAMS,
DEFAULT_Z_START,
DESK_CANVAS_PADDING,
DESK_CARD_MAX,
DESK_CARD_MIN,
DESK_DEFAULT_CANVAS_HEIGHT,
DESK_DEFAULT_CANVAS_WIDTH,
DESK_ROTATION_RANGE,
MAX_ANGULAR_VELOCITY,
MAX_DYNAMIC_ROTATION,
MAX_TIMESTEP,
MIN_TIMESTEP,
SETTLE_ANGULAR_VELOCITY,
TORQUE_TO_ACCELERATION,
} from '../constants/desktop';
import type { DocumentId } from '../types/identifiers';
type TenantId = import('../types/identifiers').TenantId;
export {
ANGULAR_DAMPING,
CARD_BASE_WEIGHT_GRAMS,
CARD_PAGE_WEIGHT_GRAMS,
DESK_CANVAS_PADDING,
DESK_CARD_MAX,
DESK_CARD_MIN,
DESK_DEFAULT_CANVAS_HEIGHT,
DESK_DEFAULT_CANVAS_WIDTH,
DESK_ROTATION_RANGE,
MAX_ANGULAR_VELOCITY,
MAX_DYNAMIC_ROTATION,
MAX_TIMESTEP,
MIN_TIMESTEP,
SETTLE_ANGULAR_VELOCITY,
TORQUE_TO_ACCELERATION,
};
interface TransformOptions {
centerX?: number;
centerY?: number;
width?: number;
height?: number;
rotation?: number;
scale?: number;
zIndex?: number | null;
}
interface CardDimensions {
width: number;
height: number;
}
interface LayoutEntry {
centerX: number;
centerY: number;
rotation: number;
z: number;
width?: number;
height?: number;
}
interface LayoutGenerationEntry {
id: string;
width: number;
height: number;
seedKey: string;
}
interface LayoutGenerationOptions {
canvasWidth: number;
canvasHeight: number;
padding: number;
startZ?: number;
rotationRange?: number;
minSpacing?: number;
shelfWidth?: number;
}
interface DocumentSize {
width: number;
height: number;
}
interface BaseMetrics {
baseWidth: number;
baseHeight: number;
baseScale: number;
}
export interface InertiaSimulationState {
docId: DocumentId;
restRotation: number;
rotation: number;
dynamicRotation: number;
angularVelocity: number;
width: number;
height: number;
dragScale?: number;
lastTimestamp: number;
frameId?: number;
massGrams?: number;
}
export interface DragGroupItem {
docId: string;
width: number;
height: number;
currentCenterX: number;
currentCenterY: number;
baseOffsetX: number;
baseOffsetY: number;
initialRotation: number;
targetRotation: number;
displayRotation: number;
angularVelocity: number;
dynamicRotation: number;
massGrams: number;
}
export interface ActiveDragSession {
pointerId: number;
startX: number;
startY: number;
lastClientX: number;
lastClientY: number;
docKey: string;
isGroup: boolean;
activeDocIds: string[];
originCenterX: number;
originCenterY: number;
currentCenterX: number;
currentCenterY: number;
rotation: number;
restRotation: number;
dynamicRotation: number;
angularVelocity: number;
moved: boolean;
width: number;
height: number;
dragScale: number;
baseScale: number;
lastTimestamp: number;
localPointerOffsetX: number;
localPointerOffsetY: number;
containerRectLeft: number;
containerRectTop: number;
groupItems: DragGroupItem[];
groupElevated: boolean;
stackSelectionApplied: boolean;
massGrams: number;
pointerRadiusScale: number;
lastPointerCanvasX: number;
lastPointerCanvasY: number;
}
export type InteractionState =
| { type: 'idle' }
| { type: 'dragging'; session: ActiveDragSession };
interface WorkspaceSnapshot {
layout: Map<DocumentId, LayoutEntry>;
canvasSize: { width: number; height: number };
visibleDocIds: Set<DocumentId>;
draggingId: string | null;
tagDropTargetId: string | null;
pendingTagDocId: string | null;
pendingRemovalTag: unknown;
initialLoadDone: boolean;
}
type WorkspaceSubscriber = () => void;
type DeskDocument = { id?: string | null } & Record<string, unknown>;
type EnsureDocumentSize = (doc: DeskDocument) => DocumentSize | null;
type ResolveBaseMetrics = () => BaseMetrics;
interface ItemRefs {
current: Map<string, HTMLElement | null>;
}
interface WorkspaceEngineOptions {
allowLayoutPersistence?: boolean;
tenantId?: string | null;
viewId?: string | null;
}
type UseSyncExternalStoreHook = <State>(
subscribe: (listener: () => void) => () => void,
getSnapshot: () => State,
getServerSnapshot: () => State,
) => State;
const getMassScale = (massGrams?: number): number => {
if (!Number.isFinite(massGrams) || Number(massGrams) <= 0) {
return 1;
}
const normalized = Math.max(Number(massGrams), CARD_BASE_WEIGHT_GRAMS) / CARD_BASE_WEIGHT_GRAMS;
return Math.max(normalized, 1);
};
export const applyDomTransform = (
node: HTMLElement | null,
{
centerX,
centerY,
width,
height,
rotation = 0,
scale = 1,
zIndex,
}: TransformOptions = {},
): void => {
if (!node) {
return;
}
const w = Number(width) || 0;
const h = Number(height) || 0;
const cx = Number(centerX) || 0;
const cy = Number(centerY) || 0;
const originX = cx - w / 2;
const originY = cy - h / 2;
node.style.transform = formatTransform(originX, originY, rotation || 0, scale || 1);
if (zIndex != null && node.style.zIndex !== String(zIndex)) {
node.style.zIndex = String(zIndex);
}
};
export const clampCardDimensions = (width: number, height: number): CardDimensions | null => {
const w = Number(width);
const h = Number(height);
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
return null;
}
const low = Math.max(DESK_CARD_MIN / w, DESK_CARD_MIN / h);
const high = Math.min(DESK_CARD_MAX / w, DESK_CARD_MAX / h);
const candidates = [];
const addCandidate = (scale: number) => {
if (Number.isFinite(scale) && scale > 0) {
candidates.push(scale);
}
};
addCandidate(1);
addCandidate(low);
addCandidate(high);
const best = candidates.reduce<{ scale: number; violation: number; deviation: number } | null>((acc, scale) => {
const scaledWidth = w * scale;
const scaledHeight = h * scale;
const violation = Math.max(
Math.max(DESK_CARD_MIN - scaledWidth, 0),
Math.max(scaledWidth - DESK_CARD_MAX, 0),
Math.max(DESK_CARD_MIN - scaledHeight, 0),
Math.max(scaledHeight - DESK_CARD_MAX, 0),
);
const deviation = Math.abs(scale - 1);
if (!acc || violation < acc.violation || (violation === acc.violation && deviation < acc.deviation)) {
return { scale, violation, deviation };
}
return acc;
}, null);
const scale = best ? best.scale : 1;
return {
width: Math.round(w * scale),
height: Math.round(h * scale),
};
};
export const computeFallbackCardSize = (docId: DocumentId): CardDimensions | null => {
const baseSeed = seededRandom(`${docId}:fallback-size`);
const aspectSeed = seededRandom(`${docId}:fallback-aspect`);
const width = DESK_CARD_MIN + baseSeed * (DESK_CARD_MAX - DESK_CARD_MIN);
const isPortrait = aspectSeed < 0.5;
const normalizedSeed = isPortrait ? aspectSeed / 0.5 : (aspectSeed - 0.5) / 0.5;
const aspectRange = 0.75;
const aspect = isPortrait
? 1 + normalizedSeed * aspectRange
: 1 / (1 + normalizedSeed * aspectRange);
const height = width * aspect;
return clampCardDimensions(width, height);
};
function seededRandom(input: unknown): number {
const text = String(input);
let hash = 2166136261;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0) / 4294967295;
}
function randomRangeFromSeed(seedKey: string, min: number, max: number): number {
const span = max - min;
if (span <= 0) return min;
const seed = seededRandom(seedKey);
return min + seed * span;
}
function buildKey(docId: DocumentId, suffix: string): string {
return `${docId}::${suffix}`;
}
const generateInitialLayout = (
entries: LayoutGenerationEntry[],
{
canvasWidth,
canvasHeight,
padding,
startZ = 0,
rotationRange = DESK_ROTATION_RANGE,
minSpacing = 48,
shelfWidth = 0,
}: LayoutGenerationOptions,
): { layout: Map<string, LayoutEntry>; maxZ: number } => {
const layout = new Map<string, LayoutEntry>();
let currentZ = startZ;
let maxZ = startZ;
if (!entries.length) {
return { layout, maxZ };
}
const shelfOffset = Math.max(shelfWidth, 0);
const spacingBuffer = Math.max(minSpacing, 0);
const placed: Array<{ x: number; y: number; radius: number }> = [];
const resolveBounds = (width: number, height: number) => {
const halfWidth = width / 2;
const halfHeight = height / 2;
return {
minCenterX: padding + halfWidth,
maxCenterX: Math.max(
padding + halfWidth,
canvasWidth - shelfOffset - padding - halfWidth,
),
minCenterY: padding + halfHeight,
maxCenterY: Math.max(padding + halfHeight, canvasHeight - padding - halfHeight),
};
};
const evaluateCandidateSpacing = (x: number, y: number, radius: number) => {
if (!placed.length) {
return Number.POSITIVE_INFINITY;
}
let best = Number.POSITIVE_INFINITY;
for (let i = 0; i < placed.length; i += 1) {
const item = placed[i];
const dx = item.x - x;
const dy = item.y - y;
const distance = Math.sqrt(dx * dx + dy * dy) - item.radius - radius - spacingBuffer;
if (distance < best) {
best = distance;
}
}
return best;
};
entries.forEach((entry) => {
const width = Number(entry.width) || 0;
const height = Number(entry.height) || 0;
if (!entry.id || width <= 0 || height <= 0) {
return;
}
const { minCenterX, maxCenterX, minCenterY, maxCenterY } = resolveBounds(width, height);
const radius = Math.sqrt(width * width + height * height) / 2;
let bestScore = -Infinity;
let bestX = (minCenterX + maxCenterX) / 2;
let bestY = (minCenterY + maxCenterY) / 2;
const samplesPerAxis = 14;
for (let gx = 0; gx < samplesPerAxis; gx += 1) {
const fracX = (gx + 0.5) / samplesPerAxis;
for (let gy = 0; gy < samplesPerAxis; gy += 1) {
const fracY = (gy + 0.5) / samplesPerAxis;
const candidateX = minCenterX + fracX * (maxCenterX - minCenterX);
const candidateY = minCenterY + fracY * (maxCenterY - minCenterY);
const edgeSpacing = Math.min(
candidateX - minCenterX,
maxCenterX - candidateX,
candidateY - minCenterY,
maxCenterY - candidateY,
) - spacingBuffer * 0.5;
if (edgeSpacing <= 0) {
continue;
}
const neighborSpacing = evaluateCandidateSpacing(candidateX, candidateY, radius);
const score = Math.min(edgeSpacing, neighborSpacing);
if (score > bestScore) {
bestScore = score;
bestX = candidateX;
bestY = candidateY;
}
}
}
const centerX = clamp(bestX, minCenterX, maxCenterX);
const centerY = clamp(bestY, minCenterY, maxCenterY);
const rotation = randomRangeFromSeed(
buildKey(entry.id, 'rotation'),
-rotationRange,
rotationRange,
);
currentZ += 1;
layout.set(entry.id, {
centerX,
centerY,
rotation,
z: currentZ,
width,
height,
});
maxZ = Math.max(maxZ, currentZ);
placed.push({ x: centerX, y: centerY, radius });
});
return { layout, maxZ };
};
export class WorkspaceEngine {
allowLayoutPersistence: boolean;
tenantId: TenantId | null;
viewId: string | null;
layout: Map<DocumentId, LayoutEntry>;
layoutSnapshot: Map<DocumentId, LayoutEntry>;
persistedLayout: Map<DocumentId, LayoutEntry>;
layoutDirty: boolean;
zCounter: number;
canvasSize: { width: number; height: number };
visibleDocIds: Set<DocumentId>;
draggingId: string | null;
tagDropTargetId: string | null;
pendingTagDocId: string | null;
pendingRemovalTag: unknown;
activeDragDocIds: Set<DocumentId>;
pendingSnapshotSync: boolean;
pendingPersistSync: boolean;
persistDebounceId: number | null;
items: DeskDocument[];
documentLookup: Map<string, DeskDocument>;
ensureDocumentSize: EnsureDocumentSize;
resolveBaseMetrics: ResolveBaseMetrics;
snapshotCache: WorkspaceSnapshot;
subscribers: Set<WorkspaceSubscriber>;
loadingPersisted: boolean;
pendingPersistence: unknown;
itemRefs: ItemRefs;
inertiaAnimations: Map<string, InertiaSimulationState>;
state: InteractionState;
initialLoadDone: boolean;
constructor({
allowLayoutPersistence = false,
tenantId = null,
viewId = null,
}: WorkspaceEngineOptions = {}) {
this.allowLayoutPersistence = allowLayoutPersistence;
this.tenantId = tenantId;
this.viewId = viewId;
this.layout = new Map();
this.layoutSnapshot = new Map();
this.persistedLayout = new Map();
this.layoutDirty = false;
this.zCounter = DEFAULT_Z_START;
this.canvasSize = { width: 0, height: 0 };
this.visibleDocIds = new Set();
this.draggingId = null;
this.tagDropTargetId = null;
this.pendingTagDocId = null;
this.pendingRemovalTag = null;
this.activeDragDocIds = new Set();
this.pendingSnapshotSync = false;
this.pendingPersistSync = false;
this.persistDebounceId = null;
this.pendingSnapshotSync = false;
this.pendingPersistSync = false;
this.items = [];
this.documentLookup = new Map();
this.ensureDocumentSize = () => null;
this.resolveBaseMetrics = () => ({ baseWidth: 0, baseHeight: 0, baseScale: 1 });
this.snapshotCache = this.buildSnapshot();
this.subscribers = new Set();
this.loadingPersisted = false;
this.pendingPersistence = null;
this.itemRefs = { current: new Map() };
this.inertiaAnimations = new Map();
this.state = { type: 'idle' };
this.initialLoadDone = false;
}
updateConfig({ allowLayoutPersistence, tenantId, viewId }: WorkspaceEngineOptions): void {
const allowChanged =
allowLayoutPersistence !== undefined
&& allowLayoutPersistence !== this.allowLayoutPersistence;
const tenantChanged = tenantId !== undefined && tenantId !== this.tenantId;
const viewChanged = viewId !== undefined && viewId !== this.viewId;
if (!allowChanged && !tenantChanged && !viewChanged) {
if (
this.allowLayoutPersistence
&& this.tenantId
&& this.viewId
&& !this.initialLoadDone
&& !this.loadingPersisted
) {
this.loadPersistedLayout();
}
return;
}
if (allowChanged) {
this.allowLayoutPersistence = allowLayoutPersistence;
}
if (tenantChanged) {
this.tenantId = tenantId;
}
if (viewChanged) {
this.viewId = viewId;
}
if (!this.allowLayoutPersistence) {
this.persistedLayout = new Map();
this.layoutDirty = false;
this.emit();
return;
}
if (!this.tenantId || !this.viewId) {
return;
}
if (!this.initialLoadDone) {
this.loadPersistedLayout();
}
}
setItems(items: DeskDocument[] | null): void {
const normalized = Array.isArray(items) ? items : [];
this.items = normalized;
const canGenerateLayoutImmediately =
!this.allowLayoutPersistence
|| !this.tenantId
|| !this.viewId
|| this.initialLoadDone;
if (canGenerateLayoutImmediately) {
this.ensureLayoutForItems();
}
this.recalcVisibleDocIds();
}
setDocumentLookup(map: Map<string, DeskDocument>): void {
this.documentLookup = map instanceof Map ? map : new Map();
this.recalcVisibleDocIds();
}
setEnsureDocumentSize(fn: EnsureDocumentSize): void {
this.ensureDocumentSize = fn;
}
setResolveBaseMetrics(fn: ResolveBaseMetrics): void {
this.resolveBaseMetrics = fn;
}
setItemRefs(ref: ItemRefs | null): void {
this.itemRefs = ref || { current: new Map() };
}
setCanvasSize(size: { width?: number | null; height?: number | null }): void {
const width = Number(size?.width) || 0;
const height = Number(size?.height) || 0;
if (this.canvasSize.width === width && this.canvasSize.height === height) {
return;
}
this.canvasSize = { width, height };
this.ensureLayoutForItems();
this.recalcVisibleDocIds();
this.emit();
}
setDraggingId(docId: DocumentId | null): void {
const normalized = docId != null ? String(docId) : null;
if (this.draggingId === normalized) {
return;
}
this.draggingId = normalized;
this.emit();
}
get dragInProgress(): boolean {
return this.state.type === 'dragging';
}
get activeDragSession(): ActiveDragSession | null {
return this.state.type === 'dragging' ? this.state.session : null;
}
beginDrag(docIds: Array<string | null> = []): void {
// Legacy method support or internal helper
// If we are starting a drag, we should transition state
// But this method was used to set flags.
// We'll keep it for now but it might be redundant if startDragSession handles everything.
// Let's make it a no-op or just update activeDragDocIds if we were keeping them separate,
// but we are trying to move to state machine.
// If called externally, it might be an issue.
// Assuming startDragSession is the main entry point now.
}
endDrag(): void {
if (this.state.type === 'dragging') {
this.state = { type: 'idle' };
this.activeDragDocIds.clear(); // Keep this for now if used elsewhere
this.setDraggingId(null);
this.flushPendingLayoutOps();
}
}
startDragSession(session: ActiveDragSession): void {
this.state = { type: 'dragging', session };
// Update legacy/derived state if needed
if (Array.isArray(session.activeDocIds)) {
this.activeDragDocIds = new Set(session.activeDocIds.map((id) => (id != null ? String(id) : null)).filter(Boolean));
} else {
this.activeDragDocIds.clear();
}
this.setDraggingId(session.docKey);
// Initial transform application
session.groupItems.forEach((item) => {
if (item.docId === session.docKey) {
return;
}
const node = this.itemRefs.current.get(item.docId);
if (node) {
const itemEntry = this.layout.get(item.docId) || null;
applyDomTransform(node, {
centerX: item.currentCenterX,
centerY: item.currentCenterY,
width: item.width,
height: item.height,
rotation: item.displayRotation ?? 0,
scale: 1,
zIndex: itemEntry?.z,
});
}
});
}
updateDragSession(
pointerId: number,
clientX: number,
clientY: number,
timestamp: number
): void {
if (this.state.type !== 'dragging') {
return;
}
const state = this.state.session;
if (state.pointerId !== pointerId) {
return;
}
const previousTimestamp = state.lastTimestamp ?? timestamp;
let dt = (timestamp - previousTimestamp) / 1000;
if (!Number.isFinite(dt) || dt <= 0) {
dt = MIN_TIMESTEP;
}
dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP);
state.lastClientX = clientX;
state.lastClientY = clientY;
state.lastTimestamp = timestamp;
const pointerCanvasX = clientX - state.containerRectLeft;
const pointerCanvasY = clientY - state.containerRectTop;
// Helper for updating angular velocity based on pointer movement
const updatePointerAngularVelocity = (
pX: number,
pY: number,
cX: number,
cY: number,
dtSec: number,
targetState: ActiveDragSession | DragGroupItem = state
) => {
if (!Number.isFinite(dtSec) || dtSec <= 0) {
return;
}
const leverX = pX - cX;
const leverY = pY - cY;
if (!Number.isFinite(leverX) || !Number.isFinite(leverY)) {
return;
}
const prevCanvasX = Number.isFinite(state.lastPointerCanvasX)
? state.lastPointerCanvasX
: pX;
const prevCanvasY = Number.isFinite(state.lastPointerCanvasY)
? state.lastPointerCanvasY
: pY;
const velocityCanvasX = (pX - prevCanvasX) / dtSec;
const velocityCanvasY = (pY - prevCanvasY) / dtSec;
if (!Number.isFinite(velocityCanvasX) || !Number.isFinite(velocityCanvasY)) {
return;
}
const torque = leverX * velocityCanvasY - leverY * velocityCanvasX;
const influenceRadius = Math.max(targetState.width, targetState.height) / 2 || 1;
const radiusScale = clamp(Math.hypot(leverX, leverY) / influenceRadius, 0.2, 2.5);
if (targetState === state) {
state.pointerRadiusScale = radiusScale;
}
const torqueResponse = 0.0025 * radiusScale;
const angularVelocityDeg = clamp(
torque * torqueResponse,
-MAX_ANGULAR_VELOCITY,
MAX_ANGULAR_VELOCITY,
);
const mass = Math.max(targetState.massGrams || CARD_BASE_WEIGHT_GRAMS, CARD_BASE_WEIGHT_GRAMS);
const massScale = Math.max(mass / CARD_BASE_WEIGHT_GRAMS, 1);
targetState.angularVelocity = angularVelocityDeg / massScale;
};
// Helper for applying dynamic rotation
const applyDynamicRotation = (
dtSec: number,
targetState: ActiveDragSession | DragGroupItem = state,
dampingFactor = 0.94
) => {
if (!Number.isFinite(dtSec) || dtSec <= 0) {
return;
}
const radiusInfluence = clamp(state.pointerRadiusScale || 1, 0.3, 3);
const response = 1.1 * radiusInfluence;
let nextDynamic = (targetState.dynamicRotation || 0) + (targetState.angularVelocity || 0) * dtSec * response;
nextDynamic = clamp(nextDynamic, -MAX_DYNAMIC_ROTATION, MAX_DYNAMIC_ROTATION);
const adjustedDamping = Math.pow(dampingFactor, 1 / Math.max(radiusInfluence, 0.8));
targetState.dynamicRotation = nextDynamic * adjustedDamping;
if (targetState === state) {
state.rotation = state.restRotation + state.dynamicRotation;
} else {
const item = targetState as DragGroupItem;
item.displayRotation = (item.initialRotation || 0) + item.dynamicRotation;
}
};
const deltaX = clientX - state.startX;
const deltaY = clientY - state.startY;
if (!state.moved) {
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
// We need DRAG_HYSTERESIS_SQUARED here, but it's not imported.
// Assuming 4*4 = 16 for now or we should import it.
// Let's use a safe default if not available, but ideally we import it.
// Checking imports... it was in useDocumentDrag.ts import from constants.
// I should add it to imports in workspaceEngine.ts if not present.
// For now I'll use 16.
if (distanceSquared < 16) {
return;
}
state.moved = true;
if (!state.stackSelectionApplied) {
state.stackSelectionApplied = true;
}
if (!state.groupElevated) {
state.activeDocIds.forEach((id) => this.bringToFront(id));
state.groupElevated = true;
}
}
const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH;
const canvasHeight = this.canvasSize.height || DESK_DEFAULT_CANVAS_HEIGHT;
state.groupItems.forEach((item) => {
updatePointerAngularVelocity(
pointerCanvasX,
pointerCanvasY,
item.currentCenterX,
item.currentCenterY,
dt,
item
);
applyDynamicRotation(dt, item, 0.96);
const gravitationDecay = 0.92;
item.baseOffsetX = (item.baseOffsetX || 0) * gravitationDecay;
item.baseOffsetY = (item.baseOffsetY || 0) * gravitationDecay;
if (Math.abs(item.baseOffsetX) < 0.5) item.baseOffsetX = 0;
if (Math.abs(item.baseOffsetY) < 0.5) item.baseOffsetY = 0;
const targetX = pointerCanvasX - state.localPointerOffsetX + (item.baseOffsetX || 0);
const targetY = pointerCanvasY - state.localPointerOffsetY + (item.baseOffsetY || 0);
const smoothing = 0.18;
const stackFriction = 0.85;
const effectiveSmoothing = smoothing * stackFriction;
item.currentCenterX += (targetX - item.currentCenterX) * effectiveSmoothing;
item.currentCenterY += (targetY - item.currentCenterY) * effectiveSmoothing;
const bounds = computeCardBounds({
width: item.width,
height: item.height,
canvasWidth,
canvasHeight,
padding: DESK_CANVAS_PADDING,
});
item.currentCenterX = clamp(item.currentCenterX, bounds.minX, bounds.maxX);
item.currentCenterY = clamp(item.currentCenterY, bounds.minY, bounds.maxY);
const entry = this.layout.get(item.docId) || null;
const payload = {
centerX: item.currentCenterX,
centerY: item.currentCenterY,
rotation: item.displayRotation ?? 0,
width: item.width,
height: item.height,
scale: item.docId === state.docKey ? state.dragScale || 1 : 1,
zIndex: entry?.z,
};
this.applyTransform(
item.docId,
payload.centerX,
payload.centerY,
payload.width,
payload.height,
payload.rotation,
payload.scale,
payload.zIndex
);
});
state.lastPointerCanvasX = pointerCanvasX;
state.lastPointerCanvasY = pointerCanvasY;
}
flushPendingLayoutOps(): void {
if (this.pendingSnapshotSync) {
this.syncLayoutSnapshot();
}
if (this.pendingPersistSync) {
this.persistLayoutSnapshot();
}
}
setTagDropTargetId(docId: DocumentId | null): void {
const normalized = docId != null ? String(docId) : null;
if (this.tagDropTargetId === normalized) {
return;
}
this.tagDropTargetId = normalized;
this.emit();
}
setPendingTagDocId(docId: DocumentId | null): void {
const normalized = docId != null ? String(docId) : null;
if (this.pendingTagDocId === normalized) {
return;
}
this.pendingTagDocId = normalized;
this.emit();
}
setPendingRemovalTag(payload: unknown): void {
if (payload === this.pendingRemovalTag) {
return;
}
this.pendingRemovalTag = payload;
this.emit();
}
markLayoutDirty(): void {
this.layoutDirty = true;
}
getLayout(docId: DocumentId | null): LayoutEntry | null {
if (docId == null) {
return null;
}
const key = String(docId);
return this.layout.get(key) || null;
}
updateLayoutEntry(
docId: DocumentId | null,
updater: (previous: LayoutEntry | null) => LayoutEntry | null,
): void {
if (docId == null) {
return;
}
const key = String(docId);
const previous = this.layout.get(key) || null;
const next = updater(previous);
if (!next) {
this.layout.delete(key);
} else {
this.layout.set(key, next);
}
this.markLayoutDirty();
this.syncLayoutSnapshot();
this.persistLayoutSnapshot();
}
bringToFront(docId: DocumentId | null): void {
if (docId == null) {
return;
}
const key = String(docId);
const entry = this.layout.get(key);
if (!entry) {
return;
}
this.zCounter += 1;
this.layout.set(key, { ...entry, z: this.zCounter });
this.markLayoutDirty();
this.syncLayoutSnapshot();
this.persistLayoutSnapshot();
this.recalcVisibleDocIds();
}
applyTransform(
docId: DocumentId | null,
centerX: number,
centerY: number,
width: number,
height: number,
rotation: number,
scale = 1,
zIndex: number | null = null,
): void {
const key = docId != null ? String(docId) : null;
if (!key) {
return;
}
const node = this.itemRefs.current.get(key);
if (node) {
applyDomTransform(node, {
centerX,
centerY,
width,
height,
rotation,
scale,
zIndex,
});
}
}
finalizeGroupDrag(): void {
if (this.state.type !== 'dragging') {
return;
}
const dragState = this.state.session;
if (!dragState?.groupItems) {
return;
}
dragState.groupItems.forEach((item) => {
if (!item) {
return;
}
const key = item.docId != null ? String(item.docId) : null;
if (!key) {
return;
}
const entry = this.layout.get(key);
const centerX = item.currentCenterX ?? entry?.centerX ?? dragState.originCenterX ?? 0;
const centerY = item.currentCenterY ?? entry?.centerY ?? dragState.originCenterY ?? 0;
const rotation = item.displayRotation ?? entry?.rotation ?? 0;
const nextEntry: LayoutEntry = {
centerX,
centerY,
rotation,
z: entry?.z ?? this.zCounter,
width: entry?.width ?? item.width,
height: entry?.height ?? item.height,
};
this.layout.set(key, nextEntry);
this.applyTransform(
key,
centerX,
centerY,
item.width,
item.height,
rotation,
key === dragState.docKey ? dragState.dragScale || 1 : 1,
nextEntry.z,
);
});
this.markLayoutDirty();
this.syncLayoutSnapshot();
this.persistLayoutSnapshot();
this.endDrag();
}
cancelInertiaAnimation(docId: DocumentId | null): void {
const key = docId != null ? String(docId) : null;
if (!key) {
return;
}
const existing = this.inertiaAnimations.get(key);
if (existing?.frameId != null) {
window.cancelAnimationFrame(existing.frameId);
}
this.inertiaAnimations.delete(key);
}
disposeInertiaAnimations(): void {
this.inertiaAnimations.forEach((animation) => {
if (animation?.frameId != null) {
window.cancelAnimationFrame(animation.frameId);
}
});
this.inertiaAnimations.clear();
}
integrateRotation(
simulationState: InertiaSimulationState,
dt: number,
torque = 0,
dampingOverride: number | null = null,
): boolean {
const key = simulationState.docId != null ? String(simulationState.docId) : null;
if (!key) {
return true;
}
const entry = this.layout.get(key);
if (!entry) {
return true;
}
const centerX = Number(entry.centerX);
const centerY = Number(entry.centerY);
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
return true;
}
const massScale = getMassScale(simulationState.massGrams);
const torqueAcceleration = torque * TORQUE_TO_ACCELERATION;
let angularVelocity = simulationState.angularVelocity + torqueAcceleration * dt;
const maxAngularVelocity = MAX_ANGULAR_VELOCITY / massScale;
angularVelocity = clamp(angularVelocity, -maxAngularVelocity, maxAngularVelocity);
const dampingConstant = Number.isFinite(dampingOverride)
? Number(dampingOverride)
: ANGULAR_DAMPING;
const dampingFactor = Math.exp(-dampingConstant * dt);
angularVelocity *= dampingFactor;
let dynamicRotation = simulationState.dynamicRotation + angularVelocity * dt;
const dynamicLimit = MAX_DYNAMIC_ROTATION / Math.sqrt(massScale);
if (dynamicRotation > dynamicLimit) {
dynamicRotation = dynamicLimit;
angularVelocity = Math.min(angularVelocity, 0);
} else if (dynamicRotation < -dynamicLimit) {
dynamicRotation = -dynamicLimit;
angularVelocity = Math.max(angularVelocity, 0);
}
const isSettled = Math.abs(angularVelocity) < (SETTLE_ANGULAR_VELOCITY * 0.6)
|| Math.abs(dynamicRotation) < (MAX_DYNAMIC_ROTATION * 0.05);
if (isSettled) {
simulationState.angularVelocity = 0;
simulationState.dynamicRotation = 0;
simulationState.rotation = simulationState.restRotation;
} else {
simulationState.angularVelocity = angularVelocity;
simulationState.dynamicRotation = dynamicRotation;
simulationState.rotation = simulationState.restRotation + dynamicRotation;
if (Math.sign(simulationState.angularVelocity) !== Math.sign(angularVelocity)) {
simulationState.angularVelocity = angularVelocity;
}
}
const rotation = simulationState.rotation;
const nextEntry = { ...entry, rotation };
this.layout.set(key, nextEntry);
this.markLayoutDirty();
this.applyTransform(
key,
centerX,
centerY,
simulationState.width,
simulationState.height,
rotation,
simulationState.dragScale || 1,
nextEntry.z,
);
return isSettled;
}
startInertiaAnimation(docId: DocumentId | null, baseState: InertiaSimulationState): void {
const raf = window.requestAnimationFrame;
if (!raf) {
return;
}
const key = docId != null ? String(docId) : null;
if (!key) {
return;
}
this.cancelInertiaAnimation(key);
const now = performance?.now ? performance.now() : Date.now();
const simulationState = {
...baseState,
docId: key,
dragScale: baseState.dragScale || 1,
lastTimestamp: now,
massGrams: Number.isFinite(baseState.massGrams)
? Math.max(Number(baseState.massGrams), CARD_BASE_WEIGHT_GRAMS)
: CARD_BASE_WEIGHT_GRAMS,
};
const step = (timestamp: number) => {
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 = this.integrateRotation(simulationState, dt, 0);
if (settled) {
this.inertiaAnimations.delete(key);
this.syncLayoutSnapshot();
this.persistLayoutSnapshot();
return;
}
simulationState.frameId = raf(step);
};
simulationState.frameId = raf(step);
this.inertiaAnimations.set(key, simulationState);
}
syncLayoutSnapshot(): void {
if (this.dragInProgress) {
this.pendingSnapshotSync = true;
return;
}
this.pendingSnapshotSync = false;
this.layoutSnapshot = new Map(this.layout);
this.emit();
}
async persistLayoutSnapshot(): Promise<void> {
if (this.dragInProgress) {
this.pendingPersistSync = true;
return;
}
if (!this.allowLayoutPersistence || !this.tenantId || !this.viewId) {
this.pendingPersistSync = false;
return;
}
if (!this.layoutDirty && !this.pendingPersistSync) {
return;
}
this.pendingPersistSync = false;
this.layoutDirty = false;
if (this.persistDebounceId) {
clearTimeout(this.persistDebounceId);
this.persistDebounceId = null;
}
const snapshotSource = this.layoutSnapshot && this.layoutSnapshot.size
? this.layoutSnapshot
: this.layout;
const snapshot = new Map(snapshotSource);
const merged = new Map(this.persistedLayout);
snapshot.forEach((entry, docId) => {
if (!docId || !entry) {
return;
}
const centerX = Number(entry.centerX);
const centerY = Number(entry.centerY);
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
return;
}
const rotation = Number.isFinite(Number(entry.rotation)) ? Number(entry.rotation) : 0;
const z = Number.isFinite(Number(entry.z)) ? Number(entry.z) : undefined;
merged.set(docId, { centerX, centerY, rotation, z });
});
this.persistedLayout = merged;
const records = [];
merged.forEach((entry, docId) => {
if (!docId || !entry) {
return;
}
records.push({
documentId: docId,
centerX: entry.centerX,
centerY: entry.centerY,
rotation: entry.rotation ?? 0,
zIndex: entry.z ?? 0,
});
});
const persistTask = async () => {
try {
await upsertLayoutRecords({ tenantId: this.tenantId, viewId: this.viewId, entries: records });
} catch (error) {
console.warn('[desk] Failed to persist layout snapshot', error);
}
};
this.persistDebounceId = window.setTimeout(() => {
this.persistDebounceId = null;
void persistTask();
}, 100);
}
ensureLayoutForItems(): void {
const persistenceReady = !this.allowLayoutPersistence || !this.tenantId || !this.viewId || this.initialLoadDone;
const canvasReady = Boolean(this.canvasSize.width && this.canvasSize.height);
const sizesReady = !this.items.some((doc) => !this.ensureDocumentSize(doc));
if (!persistenceReady) {
return;
}
if (!canvasReady) {
return;
}
if (!this.items.length) {
if (this.layout.size) {
this.layout = new Map();
this.syncLayoutSnapshot();
}
return;
}
if (!sizesReady) {
return;
}
const next = new Map<DocumentId, LayoutEntry>();
let maxZ = this.zCounter;
const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH;
const canvasHeight = this.canvasSize.height || DESK_DEFAULT_CANVAS_HEIGHT;
const docsNeedingLayout: LayoutGenerationEntry[] = [];
const currentEntries = new Map(this.layout);
this.items.forEach((doc) => {
if (!doc?.id) {
return;
}
const docKey = String(doc.id);
const sizeInfo = this.ensureDocumentSize(doc);
if (!sizeInfo) {
return;
}
const { width: docWidth, height: docHeight } = sizeInfo;
const halfWidth = docWidth / 2;
const halfHeight = docHeight / 2;
const minCenterX = DESK_CANVAS_PADDING + halfWidth;
const maxCenterX = Math.max(minCenterX, canvasWidth - DESK_CANVAS_PADDING - halfWidth);
const minCenterY = DESK_CANVAS_PADDING + halfHeight;
const maxCenterY = Math.max(minCenterY, canvasHeight - DESK_CANVAS_PADDING - halfHeight);
const persisted = this.persistedLayout.get(docKey);
const currentEntry = currentEntries.get(docKey) || null;
let existing = persisted || currentEntry;
if (existing) {
const defaultCenterX = (minCenterX + maxCenterX) / 2;
const defaultCenterY = (minCenterY + maxCenterY) / 2;
const prevCenterX = Number.isFinite(existing.centerX)
? Number(existing.centerX)
: defaultCenterX;
const prevCenterY = Number.isFinite(existing.centerY)
? Number(existing.centerY)
: defaultCenterY;
const centerX = clamp(prevCenterX, minCenterX, maxCenterX);
const centerY = clamp(prevCenterY, minCenterY, maxCenterY);
const rotation = existing.rotation ?? 0;
const z = existing.z ?? maxZ;
maxZ = Math.max(maxZ, z);
next.set(docKey, { centerX, centerY, rotation, z, width: docWidth, height: docHeight });
return;
}
docsNeedingLayout.push({
id: docKey,
width: docWidth,
height: docHeight,
seedKey: docKey,
});
});
if (docsNeedingLayout.length) {
const { layout: generatedLayout, maxZ: updatedMaxZ } = generateInitialLayout(
docsNeedingLayout,
{
canvasWidth,
canvasHeight,
padding: DESK_CANVAS_PADDING,
startZ: maxZ,
rotationRange: DESK_ROTATION_RANGE,
minSpacing: 48,
shelfWidth: 0,
},
);
generatedLayout.forEach((entry, docId) => {
next.set(docId, entry);
});
maxZ = Math.max(maxZ, updatedMaxZ);
}
this.layout = next;
this.zCounter = Math.max(this.zCounter, maxZ);
this.syncLayoutSnapshot();
this.persistLayoutSnapshot();
this.recalcVisibleDocIds();
}
recalcVisibleDocIds(): void {
const ensureSize = this.ensureDocumentSize;
const layoutMap = this.layout;
const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH;
const canvasHeight = this.canvasSize.height || DESK_DEFAULT_CANVAS_HEIGHT;
if (!layoutMap.size || canvasWidth <= 0 || canvasHeight <= 0) {
if (this.visibleDocIds.size) {
this.visibleDocIds = new Set();
this.emit();
}
return;
}
const viewport = [
{ x: 0, y: 0 },
{ x: canvasWidth, y: 0 },
{ x: canvasWidth, y: canvasHeight },
{ x: 0, y: canvasHeight },
];
const entries: Array<{ key: string; z: number; polygon: Polygon }> = [];
layoutMap.forEach((entry, docKey) => {
if (!docKey) {
return;
}
const doc = this.documentLookup.get(docKey);
if (!doc) {
return;
}
const sizeInfo = ensureSize(doc);
if (!sizeInfo) {
return;
}
const { width: cardWidth, height: cardHeight } = sizeInfo;
const rotationDeg = Number(entry?.rotation) || 0;
const rotationRad = (rotationDeg * Math.PI) / 180;
const cosRot = Math.cos(rotationRad);
const sinRot = Math.sin(rotationRad);
const halfWidth = cardWidth / 2;
const halfHeight = cardHeight / 2;
const localCorners = [
{ x: -halfWidth, y: -halfHeight },
{ x: halfWidth, y: -halfHeight },
{ x: halfWidth, y: halfHeight },
{ x: -halfWidth, y: halfHeight },
];
const centerX = entry?.centerX ?? DESK_CANVAS_PADDING + cardWidth / 2;
const centerY = entry?.centerY ?? DESK_CANVAS_PADDING + cardHeight / 2;
const corners = localCorners.map(({ x, y }) => ({
x: centerX + x * cosRot - y * sinRot,
y: centerY + x * sinRot + y * cosRot,
}));
const clipped = clipPolygon(corners, viewport);
if (!clipped.length) {
return;
}
entries.push({
key: docKey,
z: entry?.z ?? 0,
polygon: clipped,
});
});
if (!entries.length) {
if (this.visibleDocIds.size) {
this.visibleDocIds = new Set();
this.emit();
}
return;
}
entries.sort((a, b) => (b.z || 0) - (a.z || 0));
const visiblePolygons: Polygon[] = [];
const result = new Set<DocumentId>();
entries.forEach(({ key, polygon }) => {
if (polygon.length < 3) {
return;
}
let fullyCovered = true;
for (let i = 0; i < polygon.length; i += 1) {
const point = polygon[i];
const inside = visiblePolygons.some((poly) => isPointInsideConvex(point, poly));
if (!inside) {
fullyCovered = false;
break;
}
}
if (fullyCovered) {
const centroid = polygonCentroid(polygon);
if (!visiblePolygons.some((poly) => isPointInsideConvex(centroid, poly))) {
fullyCovered = false;
}
}
if (!fullyCovered) {
result.add(key);
visiblePolygons.push(polygon);
}
});
const sameSize = result.size === this.visibleDocIds.size;
if (sameSize) {
let identical = true;
result.forEach((id) => {
if (!this.visibleDocIds.has(id)) {
identical = false;
}
});
if (identical) {
this.visibleDocIds.forEach((id) => {
if (!result.has(id)) {
identical = false;
}
});
}
if (identical) {
return;
}
}
this.visibleDocIds = result;
this.emit();
}
subscribe(listener: WorkspaceSubscriber): () => void {
this.subscribers.add(listener);
return () => {
this.subscribers.delete(listener);
};
}
getSnapshot = (): WorkspaceSnapshot => this.snapshotCache;
buildSnapshot(): WorkspaceSnapshot {
return {
layout: this.layoutSnapshot,
canvasSize: this.canvasSize,
visibleDocIds: this.visibleDocIds,
draggingId: this.draggingId,
tagDropTargetId: this.tagDropTargetId,
pendingTagDocId: this.pendingTagDocId,
pendingRemovalTag: this.pendingRemovalTag,
initialLoadDone: this.initialLoadDone,
};
}
emit(): void {
this.snapshotCache = this.buildSnapshot();
this.subscribers.forEach((listener) => {
try {
listener();
} catch (error) {
console.error('WorkspaceEngine listener failed', error);
}
});
}
async loadPersistedLayout(): Promise<void> {
if (!this.allowLayoutPersistence || !this.tenantId || !this.viewId) {
return;
}
if (this.loadingPersisted || this.initialLoadDone) {
return;
}
this.loadingPersisted = true;
try {
const records = await fetchLayoutRecords({ tenantId: this.tenantId, viewId: this.viewId });
const map = new Map();
records.forEach((record) => {
if (!record || !record.documentId) {
return;
}
map.set(String(record.documentId), {
centerX: Number(record.centerX) || 0,
centerY: Number(record.centerY) || 0,
rotation: Number(record.rotation) || 0,
z: Number(record.zIndex) || 0,
});
});
this.persistedLayout = map;
this.layoutDirty = false;
if (records.length) {
const maxZ = records.reduce((acc, record) => Math.max(acc, Number(record.zIndex) || 0), DEFAULT_Z_START);
this.zCounter = Math.max(this.zCounter, maxZ);
}
this.layout = new Map(map);
this.layoutSnapshot = new Map(this.layout);
this.ensureLayoutForItems();
this.initialLoadDone = true;
this.emit();
} catch (error) {
console.warn('[desk] Failed to load persisted layout', error);
} finally {
this.loadingPersisted = false;
if (!this.initialLoadDone) {
this.initialLoadDone = true;
if (!this.layout.size) {
this.ensureLayoutForItems();
}
this.emit();
}
}
}
}
export const useWorkspaceSnapshot = (
engine: WorkspaceEngine,
useSyncExternalStoreHook: UseSyncExternalStoreHook,
): WorkspaceSnapshot => {
const useSyncExternalStore = useSyncExternalStoreHook;
if (!useSyncExternalStore) {
throw new Error('useWorkspaceSnapshot requires useSyncExternalStore hook');
}
return useSyncExternalStore(
(listener) => engine.subscribe(listener),
() => engine.getSnapshot(),
() => engine.getSnapshot(),
);
};
/* istanbul ignore next */
const commonJsModule = (globalThis as typeof globalThis & {
module?: { exports?: Record<string, unknown> };
}).module;
if (commonJsModule?.exports) {
commonJsModule.exports = {
WorkspaceEngine,
DESK_CANVAS_PADDING,
DESK_ROTATION_RANGE,
DESK_DEFAULT_CANVAS_WIDTH,
DESK_DEFAULT_CANVAS_HEIGHT,
DESK_CARD_MIN,
DESK_CARD_MAX,
MIN_TIMESTEP,
MAX_TIMESTEP,
MAX_DYNAMIC_ROTATION,
MAX_ANGULAR_VELOCITY,
ANGULAR_DAMPING,
TORQUE_TO_ACCELERATION,
SETTLE_ANGULAR_VELOCITY,
clampCardDimensions,
computeFallbackCardSize,
useWorkspaceSnapshot,
};
}