import { clamp, formatTransform } from './math.js'; import { fetchLayoutRecords, upsertLayoutRecords } from './db.js'; export const DESK_CANVAS_PADDING = 24; export const DESK_ROTATION_RANGE = 7; export const DESK_DEFAULT_CANVAS_WIDTH = 1024; export const DESK_DEFAULT_CANVAS_HEIGHT = 680; export const DESK_CARD_MIN = 240; export const DESK_CARD_MAX = 340; const DEFAULT_Z_START = 10; 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; export const applyDomTransform = ( node, { centerX, centerY, width, height, rotation = 0, scale = 1, zIndex, } = {}, ) => { 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, height) => { 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) => { if (Number.isFinite(scale) && scale > 0) { candidates.push(scale); } }; addCandidate(1); addCandidate(low); addCandidate(high); const best = candidates.reduce((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) => { 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) { 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, min, max) { const span = max - min; if (span <= 0) return min; const seed = seededRandom(seedKey); return min + seed * span; } function buildKey(docId, suffix) { return `${docId}::${suffix}`; } const signedDistanceToEdge = (edgeStart, edgeEnd, point) => (edgeEnd.x - edgeStart.x) * (point.y - edgeStart.y) - (edgeEnd.y - edgeStart.y) * (point.x - edgeStart.x); const iterateEdges = (polygon, callback) => { if (!Array.isArray(polygon) || polygon.length === 0) { return; } for (let index = 0; index < polygon.length; index += 1) { const current = polygon[index]; const next = polygon[(index + 1) % polygon.length]; if (callback(current, next, index) === false) { break; } } }; const forEachVertex = (polygon, callback) => { if (!Array.isArray(polygon) || polygon.length === 0) { return; } for (let index = 0; index < polygon.length; index += 1) { const current = polygon[index]; const prev = polygon[(index - 1 + polygon.length) % polygon.length]; if (callback(current, prev, index) === false) { break; } } }; const lineIntersection = (p1, p2, cp1, cp2) => { const A1 = p2.y - p1.y; const B1 = p1.x - p2.x; const C1 = A1 * p1.x + B1 * p1.y; const A2 = cp2.y - cp1.y; const B2 = cp1.x - cp2.x; const C2 = A2 * cp1.x + B2 * cp1.y; const det = A1 * B2 - A2 * B1; if (Math.abs(det) < 1e-6) { return { x: cp1.x, y: cp1.y }; } return { x: (B2 * C1 - B1 * C2) / det, y: (A1 * C2 - A2 * C1) / det, }; }; const clipPolygon = (subject, clipper) => { if (!Array.isArray(subject) || !subject.length) { return []; } let output = subject; iterateEdges(clipper, (cp1, cp2) => { const input = output; output = []; if (!Array.isArray(input) || !input.length) { return false; } forEachVertex(input, (current, prev) => { const currentInside = signedDistanceToEdge(cp1, cp2, current) >= 0; const prevInside = signedDistanceToEdge(cp1, cp2, prev) >= 0; if (currentInside) { if (!prevInside) { output.push(lineIntersection(prev, current, cp1, cp2)); } output.push(current); } else if (prevInside) { output.push(lineIntersection(prev, current, cp1, cp2)); } return true; }); return output.length > 0; }); return output; }; const isPointInsideConvex = (point, polygon) => { if (!polygon?.length) { return false; } let sign = 0; let inside = true; iterateEdges(polygon, (a, b) => { const cross = signedDistanceToEdge(a, b, point); if (cross === 0) { return true; } const currentSign = cross > 0 ? 1 : -1; if (sign === 0) { sign = currentSign; return true; } if (sign !== currentSign) { inside = false; return false; } return true; }); return inside; }; const polygonCentroid = (polygon) => { if (!polygon?.length) { return { x: 0, y: 0 }; } let area = 0; let cx = 0; let cy = 0; iterateEdges(polygon, (current, next) => { const cross = current.x * next.y - next.x * current.y; area += cross; cx += (current.x + next.x) * cross; cy += (current.y + next.y) * cross; }); if (Math.abs(area) < 1e-6) { let sumX = 0; let sumY = 0; forEachVertex(polygon, (point) => { sumX += point.x; sumY += point.y; }); return { x: sumX / polygon.length, y: sumY / polygon.length, }; } const areaFactor = 1 / (3 * area); return { x: cx * areaFactor, y: cy * areaFactor, }; }; const generateInitialLayout = ( entries, { canvasWidth, canvasHeight, padding, startZ = 0, rotationRange = DESK_ROTATION_RANGE, minSpacing = 48, shelfWidth = 0, }, ) => { 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 = []; const resolveBounds = (width, height) => { 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, y, radius) => { 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 { constructor({ allowLayoutPersistence = false, tenantId = null, viewId = null, } = {}) { 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.dragInProgress = false; 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.initialLoadDone = false; } updateConfig({ allowLayoutPersistence, tenantId, viewId }) { const allowChanged = typeof allowLayoutPersistence === 'boolean' && 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) { 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) { this.documentLookup = map instanceof Map ? map : new Map(); this.recalcVisibleDocIds(); } setEnsureDocumentSize(fn) { if (typeof fn === 'function') { this.ensureDocumentSize = fn; } } setResolveBaseMetrics(fn) { if (typeof fn === 'function') { this.resolveBaseMetrics = fn; } } setItemRefs(ref) { this.itemRefs = ref || { current: new Map() }; } setCanvasSize(size) { 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) { const normalized = docId != null ? String(docId) : null; if (this.draggingId === normalized) { return; } this.draggingId = normalized; this.emit(); } beginDrag(docIds = []) { this.dragInProgress = true; if (Array.isArray(docIds)) { this.activeDragDocIds = new Set(docIds.map((id) => (id != null ? String(id) : null)).filter(Boolean)); } else { this.activeDragDocIds.clear(); } } endDrag() { this.dragInProgress = false; this.activeDragDocIds.clear(); this.flushPendingLayoutOps(); } flushPendingLayoutOps() { if (this.pendingSnapshotSync) { this.syncLayoutSnapshot(); } if (this.pendingPersistSync) { this.persistLayoutSnapshot(); } } setTagDropTargetId(docId) { const normalized = docId != null ? String(docId) : null; if (this.tagDropTargetId === normalized) { return; } this.tagDropTargetId = normalized; this.emit(); } setPendingTagDocId(docId) { const normalized = docId != null ? String(docId) : null; if (this.pendingTagDocId === normalized) { return; } this.pendingTagDocId = normalized; this.emit(); } setPendingRemovalTag(payload) { if (payload === this.pendingRemovalTag) { return; } this.pendingRemovalTag = payload; this.emit(); } markLayoutDirty() { this.layoutDirty = true; } getLayout(docId) { if (docId == null) { return null; } const key = String(docId); return this.layout.get(key) || null; } updateLayoutEntry(docId, updater) { if (docId == null) { return; } const key = String(docId); const previous = this.layout.get(key) || null; const next = typeof updater === 'function' ? updater(previous || {}) : updater; if (!next) { this.layout.delete(key); } else { this.layout.set(key, next); } this.markLayoutDirty(); this.syncLayoutSnapshot(); this.persistLayoutSnapshot(); } bringToFront(docId) { 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, centerX, centerY, width, height, rotation, scale = 1, zIndex = null) { const key = docId != null ? String(docId) : null; if (!key) { return; } const node = this.itemRefs?.current?.get(key); applyDomTransform(node, { centerX, centerY, width, height, rotation, scale, zIndex, }); } finalizeGroupDrag(dragState) { 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; const centerY = item.currentCenterY ?? entry.centerY ?? dragState.originCenterY; const rotation = item.displayRotation ?? entry.rotation ?? 0; const nextEntry = { ...entry, centerX, centerY, rotation, }; 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(); } cancelInertiaAnimation(docId) { const key = docId != null ? String(docId) : null; if (!key) { return; } if (typeof window === 'undefined') { this.inertiaAnimations.delete(key); return; } const existing = this.inertiaAnimations.get(key); if (existing && typeof window.cancelAnimationFrame === 'function') { window.cancelAnimationFrame(existing.frameId); } this.inertiaAnimations.delete(key); } disposeInertiaAnimations() { if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') { this.inertiaAnimations.forEach((animation) => { if (animation?.frameId != null) { window.cancelAnimationFrame(animation.frameId); } }); } this.inertiaAnimations.clear(); } integrateRotation(simulationState, dt, torque = 0, dampingOverride = null) { 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 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; 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, ); const isSettled = Math.abs(angularVelocity) < SETTLE_ANGULAR_VELOCITY; return isSettled; } startInertiaAnimation(docId, baseState) { if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { return; } const key = docId != null ? String(docId) : null; if (!key) { return; } this.cancelInertiaAnimation(key); const now = typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now(); const simulationState = { ...baseState, docId: key, 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 = this.integrateRotation(simulationState, dt, 0); if (settled) { this.inertiaAnimations.delete(key); this.syncLayoutSnapshot(); this.persistLayoutSnapshot(); return; } simulationState.frameId = window.requestAnimationFrame(step); }; simulationState.frameId = window.requestAnimationFrame(step); this.inertiaAnimations.set(key, simulationState); } syncLayoutSnapshot() { if (this.dragInProgress) { this.pendingSnapshotSync = true; return; } this.pendingSnapshotSync = false; this.layoutSnapshot = new Map(this.layout); this.emit(); } async persistLayoutSnapshot() { 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); } }; if (typeof window !== 'undefined' && typeof window.setTimeout === 'function') { this.persistDebounceId = window.setTimeout(() => { this.persistDebounceId = null; void persistTask(); }, 100); } else { await persistTask(); } } ensureLayoutForItems() { 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 = []; 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 = typeof existing.centerX === 'number' ? existing.centerX : defaultCenterX; const prevCenterY = typeof 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() { const ensureSize = this.ensureDocumentSize; if (typeof ensureSize !== 'function') { return; } 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 = []; 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 = []; 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) { this.subscribers.add(listener); return () => { this.subscribers.delete(listener); }; } getSnapshot = () => this.snapshotCache; buildSnapshot() { 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() { this.snapshotCache = this.buildSnapshot(); this.subscribers.forEach((listener) => { try { listener(); } catch (error) { console.error('WorkspaceEngine listener failed', error); } }); } async loadPersistedLayout() { 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, useSyncExternalStoreHook) => { const useSyncExternalStore = useSyncExternalStoreHook; if (typeof useSyncExternalStore !== 'function') { throw new Error('useWorkspaceSnapshot requires useSyncExternalStore hook'); } return useSyncExternalStore( (listener) => engine.subscribe(listener), () => engine.getSnapshot(), () => engine.getSnapshot(), ); }; /* istanbul ignore next */ if (typeof module !== 'undefined' && module && module.exports) { module.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, }; }