1247 lines
34 KiB
JavaScript
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,
|
|
};
|
|
}
|