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