Files
papercrate/frontend/src/desktop/workspaceEngine.js
T
2025-11-10 14:46:34 +01:00

1247 lines
34 KiB
JavaScript

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,
};
}