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; canvasSize: { width: number; height: number }; visibleDocIds: Set; draggingId: string | null; tagDropTargetId: string | null; pendingTagDocId: string | null; pendingRemovalTag: unknown; initialLoadDone: boolean; } type WorkspaceSubscriber = () => void; type DeskDocument = { id?: string | null } & Record; type EnsureDocumentSize = (doc: DeskDocument) => DocumentSize | null; type ResolveBaseMetrics = () => BaseMetrics; interface ItemRefs { current: Map; } interface WorkspaceEngineOptions { allowLayoutPersistence?: boolean; tenantId?: string | null; viewId?: string | null; } type UseSyncExternalStoreHook = ( 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; maxZ: number } => { const layout = new Map(); 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; layoutSnapshot: Map; persistedLayout: Map; layoutDirty: boolean; zCounter: number; canvasSize: { width: number; height: number }; visibleDocIds: Set; draggingId: string | null; tagDropTargetId: string | null; pendingTagDocId: string | null; pendingRemovalTag: unknown; activeDragDocIds: Set; pendingSnapshotSync: boolean; pendingPersistSync: boolean; persistDebounceId: number | null; items: DeskDocument[]; documentLookup: Map; ensureDocumentSize: EnsureDocumentSize; resolveBaseMetrics: ResolveBaseMetrics; snapshotCache: WorkspaceSnapshot; subscribers: Set; loadingPersisted: boolean; pendingPersistence: unknown; itemRefs: ItemRefs; inertiaAnimations: Map; 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): 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 = []): 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 { 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(); 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(); 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 { 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 }; }).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, }; }