feat: introduce a new layered spatial workspace architecture design, implement initial card positioning logic, and add container size observation for the layout system.
This commit is contained in:
@@ -90,6 +90,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
|
||||
viewId,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isLayoutReady, setIsLayoutReady] = useState(false);
|
||||
|
||||
const items = useMemo(() => {
|
||||
return entries
|
||||
@@ -103,6 +104,24 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
|
||||
const layoutStore = useMemo(() => new LayoutStore(), []);
|
||||
const layoutRef = useRef<Map<string, LayoutCard>>(new Map());
|
||||
|
||||
// Update container size in store
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect;
|
||||
layoutStore.setContainerSize(width, height);
|
||||
if (width > 0 && height > 0) {
|
||||
setIsLayoutReady(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [layoutStore]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tenantId && viewId) {
|
||||
layoutStore.loadLayout(String(tenantId), viewId);
|
||||
@@ -191,7 +210,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{items.map((doc, index) => {
|
||||
{isLayoutReady && items.map((doc, index) => {
|
||||
const docId = doc.id ? String(doc.id) : `temp-${index}`;
|
||||
const isSelected = selectedDocumentIds.includes(docId);
|
||||
const size = ensureDocumentSize(doc);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { constrainDimensions } from './utils/layoutUtils';
|
||||
import { constrainDimensions, getInitialPosition, CONTAINER_PADDING } from './utils/layoutUtils';
|
||||
import { fetchLayoutRecords, upsertLayoutRecords } from './db';
|
||||
|
||||
export interface LayoutCardState {
|
||||
@@ -23,18 +23,25 @@ export class LayoutCard implements LayoutCardState {
|
||||
|
||||
private _innerRadius: number = 0;
|
||||
private _outerRadius: number = 0;
|
||||
private _centerX: number = 0;
|
||||
private _centerY: number = 0;
|
||||
public store: LayoutStore;
|
||||
public dragStartX: number = 0;
|
||||
public dragStartY: number = 0;
|
||||
public isDirty: boolean = false;
|
||||
public intendedX: number = 0;
|
||||
public intendedY: number = 0;
|
||||
private dragOffset: { x: number, y: number } | null = null;
|
||||
|
||||
constructor(id: string, store: LayoutStore, initialData: Partial<LayoutCardState> = {}, ref: HTMLElement | null = null) {
|
||||
this.id = id;
|
||||
this.store = store;
|
||||
Object.assign(this, initialData);
|
||||
this.intendedX = this.x;
|
||||
this.intendedY = this.y;
|
||||
this.ref = ref;
|
||||
this.recalculateRadii();
|
||||
this.recalculateCenters();
|
||||
}
|
||||
|
||||
setRef(ref: HTMLElement | null) {
|
||||
@@ -48,11 +55,33 @@ export class LayoutCard implements LayoutCardState {
|
||||
this.dragStartY = this.y;
|
||||
}
|
||||
|
||||
getConstrainedPosition(x: number, y: number): { x: number, y: number } {
|
||||
const rad = (this.rotation * Math.PI) / 180;
|
||||
const sin = Math.abs(Math.sin(rad));
|
||||
const cos = Math.abs(Math.cos(rad));
|
||||
|
||||
const rotatedWidth = this.width * cos + this.height * sin;
|
||||
const rotatedHeight = this.width * sin + this.height * cos;
|
||||
|
||||
const minX = CONTAINER_PADDING + (rotatedWidth - this.width) / 2;
|
||||
const maxX = this.store.containerWidth - CONTAINER_PADDING - this.width - (rotatedWidth - this.width) / 2;
|
||||
|
||||
const minY = CONTAINER_PADDING + (rotatedHeight - this.height) / 2;
|
||||
const maxY = this.store.containerHeight - CONTAINER_PADDING - this.height - (rotatedHeight - this.height) / 2;
|
||||
|
||||
const newX = Math.max(minX, Math.min(x, maxX));
|
||||
const newY = Math.max(minY, Math.min(y, maxY));
|
||||
|
||||
return { x: newX, y: newY };
|
||||
}
|
||||
|
||||
continueDrag(delta: { x: number, y: number }) {
|
||||
if (!this.dragOffset) return;
|
||||
|
||||
const newX = this.x + delta.x;
|
||||
const newY = this.y + delta.y;
|
||||
const targetX = this.x + delta.x;
|
||||
const targetY = this.y + delta.y;
|
||||
|
||||
const { x: newX, y: newY } = this.getConstrainedPosition(targetX, targetY);
|
||||
|
||||
this.update({
|
||||
x: newX,
|
||||
@@ -64,12 +93,22 @@ export class LayoutCard implements LayoutCardState {
|
||||
this.dragOffset = null;
|
||||
}
|
||||
|
||||
update(changes: Partial<LayoutCardState>, options: { markDirty?: boolean } = {}) {
|
||||
update(changes: Partial<LayoutCardState>, options: { markDirty?: boolean, isConstraintUpdate?: boolean } = {}) {
|
||||
Object.assign(this, changes);
|
||||
|
||||
if (!options.isConstraintUpdate) {
|
||||
if (changes.x !== undefined) this.intendedX = changes.x;
|
||||
if (changes.y !== undefined) this.intendedY = changes.y;
|
||||
}
|
||||
|
||||
if (changes.width !== undefined || changes.height !== undefined) {
|
||||
this.recalculateRadii();
|
||||
}
|
||||
|
||||
if (changes.x !== undefined || changes.y !== undefined || changes.width !== undefined || changes.height !== undefined) {
|
||||
this.recalculateCenters();
|
||||
}
|
||||
|
||||
if (options.markDirty) {
|
||||
this.isDirty = true;
|
||||
}
|
||||
@@ -167,6 +206,11 @@ export class LayoutCard implements LayoutCardState {
|
||||
this._outerRadius = Math.sqrt(this.width * this.width + this.height * this.height) / 2;
|
||||
}
|
||||
|
||||
private recalculateCenters() {
|
||||
this._centerX = this.x + this.width / 2;
|
||||
this._centerY = this.y + this.height / 2;
|
||||
}
|
||||
|
||||
private rafId: number | null = null;
|
||||
|
||||
private applyTransform() {
|
||||
@@ -226,11 +270,21 @@ export class LayoutCard implements LayoutCardState {
|
||||
get outerRadius(): number {
|
||||
return this._outerRadius;
|
||||
}
|
||||
|
||||
get centerX(): number {
|
||||
return this._centerX;
|
||||
}
|
||||
|
||||
get centerY(): number {
|
||||
return this._centerY;
|
||||
}
|
||||
}
|
||||
|
||||
export class LayoutStore {
|
||||
items = new Map<string, LayoutCard>();
|
||||
zCounter = 100;
|
||||
containerWidth: number = 0;
|
||||
containerHeight: number = 0;
|
||||
|
||||
private savedLayouts = new Map<string, { x: number, y: number, rotation: number, z: number }>();
|
||||
private tenantId: string | null = null;
|
||||
@@ -263,26 +317,33 @@ export class LayoutStore {
|
||||
if (z >= this.zCounter) {
|
||||
this.zCounter = z + 1;
|
||||
}
|
||||
|
||||
card = new LayoutCard(id, this, {
|
||||
x,
|
||||
y,
|
||||
rotation,
|
||||
width,
|
||||
height,
|
||||
z
|
||||
}, ref);
|
||||
} else {
|
||||
// Apply defaults if not provided
|
||||
x = Math.random() * 500;
|
||||
y = Math.random() * 500;
|
||||
rotation = Math.random() * 10 - 5;
|
||||
z = this.zCounter++;
|
||||
}
|
||||
// Create card with temporary position
|
||||
card = new LayoutCard(id, this, {
|
||||
width,
|
||||
height,
|
||||
z: this.zCounter++
|
||||
}, ref);
|
||||
|
||||
card = new LayoutCard(id, this, {
|
||||
x,
|
||||
y,
|
||||
rotation,
|
||||
width,
|
||||
height,
|
||||
z
|
||||
}, ref);
|
||||
// Calculate initial position using the card instance
|
||||
const { x: initX, y: initY } = getInitialPosition(
|
||||
this.containerWidth,
|
||||
this.containerHeight,
|
||||
card,
|
||||
Array.from(this.items.values())
|
||||
);
|
||||
|
||||
// If it was a saved layout, it's not dirty.
|
||||
if (!saved) {
|
||||
card.isDirty = true;
|
||||
// Update card with calculated position
|
||||
card.update({ x: initX, y: initY }, { markDirty: true });
|
||||
}
|
||||
|
||||
this.items.set(id, card);
|
||||
@@ -302,6 +363,21 @@ export class LayoutStore {
|
||||
this.items.delete(id);
|
||||
}
|
||||
|
||||
setContainerSize(width: number, height: number) {
|
||||
this.containerWidth = width;
|
||||
this.containerHeight = height;
|
||||
this.relayout();
|
||||
}
|
||||
|
||||
relayout() {
|
||||
for (const card of this.items.values()) {
|
||||
const { x, y } = card.getConstrainedPosition(card.intendedX, card.intendedY);
|
||||
if (x !== card.x || y !== card.y) {
|
||||
card.update({ x, y }, { markDirty: false, isConstraintUpdate: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getCardsInCircle(x: number, y: number, radius: number): LayoutCard[] {
|
||||
const result: LayoutCard[] = [];
|
||||
for (const card of this.items.values()) {
|
||||
@@ -386,6 +462,9 @@ export class LayoutStore {
|
||||
card.update(saved, { markDirty: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure everything is within bounds
|
||||
this.relayout();
|
||||
}
|
||||
|
||||
async saveLayout() {
|
||||
@@ -396,8 +475,8 @@ export class LayoutStore {
|
||||
|
||||
const entries = dirtyCards.map(card => ({
|
||||
documentId: card.id,
|
||||
centerX: card.x,
|
||||
centerY: card.y,
|
||||
centerX: card.intendedX,
|
||||
centerY: card.intendedY,
|
||||
rotation: card.rotation,
|
||||
zIndex: card.z,
|
||||
updatedAt: Date.now()
|
||||
|
||||
@@ -1,38 +1,6 @@
|
||||
export interface CardBounds {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
}
|
||||
import type { LayoutCard } from '../LayoutSystem';
|
||||
|
||||
export interface ComputeBoundsOptions {
|
||||
width: number;
|
||||
height: number;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
padding: number;
|
||||
shelfWidth?: number;
|
||||
}
|
||||
|
||||
export const computeCardBounds = ({
|
||||
width,
|
||||
height,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
padding,
|
||||
shelfWidth = 0,
|
||||
}: ComputeBoundsOptions): CardBounds => {
|
||||
const halfW = width / 2;
|
||||
const halfH = height / 2;
|
||||
const shelfOffset = Math.max(shelfWidth, 0);
|
||||
|
||||
return {
|
||||
minX: padding + halfW,
|
||||
maxX: Math.max(padding + halfW, canvasWidth - shelfOffset - padding - halfW),
|
||||
minY: padding + halfH,
|
||||
maxY: Math.max(padding + halfH, canvasHeight - padding - halfH),
|
||||
};
|
||||
};
|
||||
export const CONTAINER_PADDING = 18;
|
||||
|
||||
export const constrainDimensions = (width: number, height: number, maxDimension: number) => {
|
||||
if (width <= maxDimension && height <= maxDimension) {
|
||||
@@ -52,3 +20,68 @@ export const constrainDimensions = (width: number, height: number, maxDimension:
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getInitialPosition = (
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
card: LayoutCard,
|
||||
_existingCards: LayoutCard[] = []
|
||||
): { x: number, y: number } => {
|
||||
// Mitchell's Best-Candidate Algorithm (Monte Carlo)
|
||||
const K = 20; // Number of candidates to test
|
||||
let bestCandidate = { x: 0, y: 0 };
|
||||
let bestScore = -Infinity;
|
||||
|
||||
// Padding to keep cards inside
|
||||
const padding = CONTAINER_PADDING;
|
||||
|
||||
// Calculate safe bounds for top-left corner
|
||||
const minX = padding;
|
||||
const maxX = Math.max(padding, containerWidth - card.width - padding);
|
||||
const minY = padding;
|
||||
const maxY = Math.max(padding, containerHeight - card.height - padding);
|
||||
|
||||
const newCardRadius = card.outerRadius;
|
||||
const halfWidth = card.width / 2;
|
||||
const halfHeight = card.height / 2;
|
||||
|
||||
for (let i = 0; i < K; i++) {
|
||||
const x = minX + Math.random() * (maxX - minX);
|
||||
const y = minY + Math.random() * (maxY - minY);
|
||||
|
||||
const cx = x + halfWidth;
|
||||
const cy = y + halfHeight;
|
||||
|
||||
// Distance to nearest edge
|
||||
const distEdge = Math.min(
|
||||
x, // Left
|
||||
containerWidth - (x + card.width), // Right
|
||||
y, // Top
|
||||
containerHeight - (y + card.height) // Bottom
|
||||
);
|
||||
|
||||
// Distance to nearest neighbor
|
||||
let minNeighborDist = Infinity;
|
||||
for (const other of _existingCards) {
|
||||
const dx = cx - other.centerX;
|
||||
const dy = cy - other.centerY;
|
||||
const distSq = dx * dx + dy * dy;
|
||||
|
||||
const radiiSum = newCardRadius + other.outerRadius;
|
||||
const distToEdge = distSq - radiiSum * radiiSum;
|
||||
|
||||
if (distToEdge < minNeighborDist) {
|
||||
minNeighborDist = distToEdge;
|
||||
}
|
||||
}
|
||||
|
||||
const score = Math.min(distEdge, minNeighborDist);
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestCandidate = { x, y };
|
||||
}
|
||||
}
|
||||
|
||||
return bestCandidate;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user