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,
|
viewId,
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isLayoutReady, setIsLayoutReady] = useState(false);
|
||||||
|
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
return entries
|
return entries
|
||||||
@@ -103,6 +104,24 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
|
|||||||
const layoutStore = useMemo(() => new LayoutStore(), []);
|
const layoutStore = useMemo(() => new LayoutStore(), []);
|
||||||
const layoutRef = useRef<Map<string, LayoutCard>>(new Map());
|
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(() => {
|
useEffect(() => {
|
||||||
if (tenantId && viewId) {
|
if (tenantId && viewId) {
|
||||||
layoutStore.loadLayout(String(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 docId = doc.id ? String(doc.id) : `temp-${index}`;
|
||||||
const isSelected = selectedDocumentIds.includes(docId);
|
const isSelected = selectedDocumentIds.includes(docId);
|
||||||
const size = ensureDocumentSize(doc);
|
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';
|
import { fetchLayoutRecords, upsertLayoutRecords } from './db';
|
||||||
|
|
||||||
export interface LayoutCardState {
|
export interface LayoutCardState {
|
||||||
@@ -23,18 +23,25 @@ export class LayoutCard implements LayoutCardState {
|
|||||||
|
|
||||||
private _innerRadius: number = 0;
|
private _innerRadius: number = 0;
|
||||||
private _outerRadius: number = 0;
|
private _outerRadius: number = 0;
|
||||||
|
private _centerX: number = 0;
|
||||||
|
private _centerY: number = 0;
|
||||||
public store: LayoutStore;
|
public store: LayoutStore;
|
||||||
public dragStartX: number = 0;
|
public dragStartX: number = 0;
|
||||||
public dragStartY: number = 0;
|
public dragStartY: number = 0;
|
||||||
public isDirty: boolean = false;
|
public isDirty: boolean = false;
|
||||||
|
public intendedX: number = 0;
|
||||||
|
public intendedY: number = 0;
|
||||||
private dragOffset: { x: number, y: number } | null = null;
|
private dragOffset: { x: number, y: number } | null = null;
|
||||||
|
|
||||||
constructor(id: string, store: LayoutStore, initialData: Partial<LayoutCardState> = {}, ref: HTMLElement | null = null) {
|
constructor(id: string, store: LayoutStore, initialData: Partial<LayoutCardState> = {}, ref: HTMLElement | null = null) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.store = store;
|
this.store = store;
|
||||||
Object.assign(this, initialData);
|
Object.assign(this, initialData);
|
||||||
|
this.intendedX = this.x;
|
||||||
|
this.intendedY = this.y;
|
||||||
this.ref = ref;
|
this.ref = ref;
|
||||||
this.recalculateRadii();
|
this.recalculateRadii();
|
||||||
|
this.recalculateCenters();
|
||||||
}
|
}
|
||||||
|
|
||||||
setRef(ref: HTMLElement | null) {
|
setRef(ref: HTMLElement | null) {
|
||||||
@@ -48,11 +55,33 @@ export class LayoutCard implements LayoutCardState {
|
|||||||
this.dragStartY = this.y;
|
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 }) {
|
continueDrag(delta: { x: number, y: number }) {
|
||||||
if (!this.dragOffset) return;
|
if (!this.dragOffset) return;
|
||||||
|
|
||||||
const newX = this.x + delta.x;
|
const targetX = this.x + delta.x;
|
||||||
const newY = this.y + delta.y;
|
const targetY = this.y + delta.y;
|
||||||
|
|
||||||
|
const { x: newX, y: newY } = this.getConstrainedPosition(targetX, targetY);
|
||||||
|
|
||||||
this.update({
|
this.update({
|
||||||
x: newX,
|
x: newX,
|
||||||
@@ -64,12 +93,22 @@ export class LayoutCard implements LayoutCardState {
|
|||||||
this.dragOffset = null;
|
this.dragOffset = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
update(changes: Partial<LayoutCardState>, options: { markDirty?: boolean } = {}) {
|
update(changes: Partial<LayoutCardState>, options: { markDirty?: boolean, isConstraintUpdate?: boolean } = {}) {
|
||||||
Object.assign(this, changes);
|
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) {
|
if (changes.width !== undefined || changes.height !== undefined) {
|
||||||
this.recalculateRadii();
|
this.recalculateRadii();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (changes.x !== undefined || changes.y !== undefined || changes.width !== undefined || changes.height !== undefined) {
|
||||||
|
this.recalculateCenters();
|
||||||
|
}
|
||||||
|
|
||||||
if (options.markDirty) {
|
if (options.markDirty) {
|
||||||
this.isDirty = true;
|
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;
|
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 rafId: number | null = null;
|
||||||
|
|
||||||
private applyTransform() {
|
private applyTransform() {
|
||||||
@@ -226,11 +270,21 @@ export class LayoutCard implements LayoutCardState {
|
|||||||
get outerRadius(): number {
|
get outerRadius(): number {
|
||||||
return this._outerRadius;
|
return this._outerRadius;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get centerX(): number {
|
||||||
|
return this._centerX;
|
||||||
|
}
|
||||||
|
|
||||||
|
get centerY(): number {
|
||||||
|
return this._centerY;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LayoutStore {
|
export class LayoutStore {
|
||||||
items = new Map<string, LayoutCard>();
|
items = new Map<string, LayoutCard>();
|
||||||
zCounter = 100;
|
zCounter = 100;
|
||||||
|
containerWidth: number = 0;
|
||||||
|
containerHeight: number = 0;
|
||||||
|
|
||||||
private savedLayouts = new Map<string, { x: number, y: number, rotation: number, z: number }>();
|
private savedLayouts = new Map<string, { x: number, y: number, rotation: number, z: number }>();
|
||||||
private tenantId: string | null = null;
|
private tenantId: string | null = null;
|
||||||
@@ -263,26 +317,33 @@ export class LayoutStore {
|
|||||||
if (z >= this.zCounter) {
|
if (z >= this.zCounter) {
|
||||||
this.zCounter = z + 1;
|
this.zCounter = z + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
card = new LayoutCard(id, this, {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
rotation,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
z
|
||||||
|
}, ref);
|
||||||
} else {
|
} else {
|
||||||
// Apply defaults if not provided
|
// Create card with temporary position
|
||||||
x = Math.random() * 500;
|
card = new LayoutCard(id, this, {
|
||||||
y = Math.random() * 500;
|
width,
|
||||||
rotation = Math.random() * 10 - 5;
|
height,
|
||||||
z = this.zCounter++;
|
z: this.zCounter++
|
||||||
}
|
}, ref);
|
||||||
|
|
||||||
card = new LayoutCard(id, this, {
|
// Calculate initial position using the card instance
|
||||||
x,
|
const { x: initX, y: initY } = getInitialPosition(
|
||||||
y,
|
this.containerWidth,
|
||||||
rotation,
|
this.containerHeight,
|
||||||
width,
|
card,
|
||||||
height,
|
Array.from(this.items.values())
|
||||||
z
|
);
|
||||||
}, ref);
|
|
||||||
|
|
||||||
// If it was a saved layout, it's not dirty.
|
// Update card with calculated position
|
||||||
if (!saved) {
|
card.update({ x: initX, y: initY }, { markDirty: true });
|
||||||
card.isDirty = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.items.set(id, card);
|
this.items.set(id, card);
|
||||||
@@ -302,6 +363,21 @@ export class LayoutStore {
|
|||||||
this.items.delete(id);
|
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[] {
|
getCardsInCircle(x: number, y: number, radius: number): LayoutCard[] {
|
||||||
const result: LayoutCard[] = [];
|
const result: LayoutCard[] = [];
|
||||||
for (const card of this.items.values()) {
|
for (const card of this.items.values()) {
|
||||||
@@ -386,6 +462,9 @@ export class LayoutStore {
|
|||||||
card.update(saved, { markDirty: false });
|
card.update(saved, { markDirty: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure everything is within bounds
|
||||||
|
this.relayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveLayout() {
|
async saveLayout() {
|
||||||
@@ -396,8 +475,8 @@ export class LayoutStore {
|
|||||||
|
|
||||||
const entries = dirtyCards.map(card => ({
|
const entries = dirtyCards.map(card => ({
|
||||||
documentId: card.id,
|
documentId: card.id,
|
||||||
centerX: card.x,
|
centerX: card.intendedX,
|
||||||
centerY: card.y,
|
centerY: card.intendedY,
|
||||||
rotation: card.rotation,
|
rotation: card.rotation,
|
||||||
zIndex: card.z,
|
zIndex: card.z,
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
|
|||||||
@@ -1,38 +1,6 @@
|
|||||||
export interface CardBounds {
|
import type { LayoutCard } from '../LayoutSystem';
|
||||||
minX: number;
|
|
||||||
maxX: number;
|
|
||||||
minY: number;
|
|
||||||
maxY: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ComputeBoundsOptions {
|
export const CONTAINER_PADDING = 18;
|
||||||
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 constrainDimensions = (width: number, height: number, maxDimension: number) => {
|
export const constrainDimensions = (width: number, height: number, maxDimension: number) => {
|
||||||
if (width <= maxDimension && height <= maxDimension) {
|
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