From 9a1272a3a4fec3550717331f02ad2c897de069ce Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Tue, 23 Sep 2025 02:46:59 +0300 Subject: [PATCH 1/7] fix: Fix table overlap issue when creating multiple tables --- src/components/DiagramEditor.tsx | 7 ++- src/components/Layout.tsx | 10 +++- src/lib/utils.ts | 95 ++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 5 deletions(-) diff --git a/src/components/DiagramEditor.tsx b/src/components/DiagramEditor.tsx index 1e4f309..133610a 100644 --- a/src/components/DiagramEditor.tsx +++ b/src/components/DiagramEditor.tsx @@ -7,7 +7,7 @@ import { import { tableColors } from "@/lib/colors"; import { colors, DbRelationship, relationshipTypes } from "@/lib/constants"; import { type AppEdge, type AppNode, type AppNoteNode, type AppZoneNode, type ProcessedEdge, type ProcessedNode } from "@/lib/types"; -import { isNodeInLockedZone } from "@/lib/utils"; +import { findNonOverlappingPosition, isNodeInLockedZone } from "@/lib/utils"; import { useStore, type StoreState } from "@/store/store"; import { showError } from "@/utils/toast"; import { @@ -233,10 +233,13 @@ const DiagramEditor = forwardRef( if (!diagram) return; const visibleNodes = diagram.data.nodes.filter((n: AppNode) => !n.data.isDeleted) || []; const tableName = `new_table_${visibleNodes.length + 1}`; + const defaultPosition = { x: position.x - 144, y: position.y - 50 }; + const nonOverlappingPosition = findNonOverlappingPosition(visibleNodes, defaultPosition); + const newNode: AppNode = { id: `${tableName}-${+new Date()}`, type: "table", - position: { x: position.x - 144, y: position.y - 50 }, + position: nonOverlappingPosition, data: { label: tableName, color: tableColors[Math.floor(Math.random() * tableColors.length)] ?? colors.DEFAULT_TABLE_COLOR, diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 360f8de..065b43e 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -3,6 +3,7 @@ import { useSidebarState } from "@/hooks/use-sidebar-state"; import { tableColors } from "@/lib/colors"; import { colors, KeyboardShortcuts } from "@/lib/constants"; import { type AppNode, type AppNoteNode, type AppZoneNode, type ProcessedEdge, type ProcessedNode } from "@/lib/types"; +import { findNonOverlappingPosition } from "@/lib/utils"; import { useStore, type StoreState } from "@/store/store"; import { showSuccess } from "@/utils/toast"; import { type ReactFlowInstance } from "@xyflow/react"; @@ -149,16 +150,19 @@ export default function Layout({ onInstallAppRequest }: LayoutProps) { const handleCreateTable = (tableName: string) => { if (!diagram) return; - let position = { x: 200, y: 200 }; + let defaultPosition = { x: 200, y: 200 }; if (rfInstance) { const flowPosition = rfInstance.screenToFlowPosition({ x: window.innerWidth * 0.6, y: window.innerHeight / 2 }); - position = { x: flowPosition.x - 144, y: flowPosition.y - 50 }; + defaultPosition = { x: flowPosition.x - 144, y: flowPosition.y - 50 }; } + const visibleNodes = diagram.data.nodes.filter((n: AppNode) => !n.data.isDeleted) || []; + const nonOverlappingPosition = findNonOverlappingPosition(visibleNodes, defaultPosition); + const newNode: AppNode = { id: `${tableName}-${+new Date()}`, type: "table", - position, + position: nonOverlappingPosition, data: { label: tableName, color: tableColors[Math.floor(Math.random() * tableColors.length)] ?? colors.DEFAULT_TABLE_COLOR, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ea65a3d..2fedc20 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -111,3 +111,98 @@ export function isNodeInLockedZone( (zone) => zone.data.isLocked && isNodeInsideZone(node, zone) ); } + +// Check if two rectangles overlap +function doRectanglesOverlap( + rect1: { x: number; y: number; width: number; height: number }, + rect2: { x: number; y: number; width: number; height: number } +): boolean { + return !( + rect1.x + rect1.width <= rect2.x || + rect2.x + rect2.width <= rect1.x || + rect1.y + rect1.height <= rect2.y || + rect2.y + rect2.height <= rect1.y + ); +} + +// Find a non-overlapping position for a new table +export function findNonOverlappingPosition( + existingNodes: CombinedNode[], + preferredPosition: { x: number; y: number }, + nodeWidth: number = 288, + nodeHeight: number = 100, + spacing: number = 50 +): { x: number; y: number } { + const newRect = { + x: preferredPosition.x, + y: preferredPosition.y, + width: nodeWidth, + height: nodeHeight, + }; + + // Check if the default position overlaps with any existing node + const hasOverlap = existingNodes.some((node) => { + if (!node.position) return false; + + const existingRect = { + x: node.position.x, + y: node.position.y, + width: node.width || (node.type === "table" ? 288 : node.type === "note" ? 192 : 300), + height: node.height || (node.type === "table" ? 100 : node.type === "note" ? 192 : 300), + }; + + return doRectanglesOverlap(newRect, existingRect); + }); + + if (!hasOverlap) { + return preferredPosition; + } + + // Try positions in a spiral pattern around the preferred position + const maxAttempts = 20; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const angle = (attempt * 0.5) * Math.PI; // Golden angle approximation + const radius = attempt * spacing; + + const offsetX = Math.cos(angle) * radius; + const offsetY = Math.sin(angle) * radius; + + const candidatePosition = { + x: preferredPosition.x + offsetX, + y: preferredPosition.y + offsetY, + }; + + const candidateRect = { + x: candidatePosition.x, + y: candidatePosition.y, + width: nodeWidth, + height: nodeHeight, + }; + + const hasCandidateOverlap = existingNodes.some((node) => { + if (!node.position) return false; + + const existingRect = { + x: node.position.x, + y: node.position.y, + width: node.width || (node.type === "table" ? 288 : node.type === "note" ? 192 : 300), + height: node.height || (node.type === "table" ? 100 : node.type === "note" ? 192 : 300), + }; + + return doRectanglesOverlap(candidateRect, existingRect); + }); + + if (!hasCandidateOverlap) { + return candidatePosition; + } + } + + // Fallback: return a position far from existing nodes + const maxX = Math.max(...existingNodes.map(n => n.position?.x || 0), 0); + const maxY = Math.max(...existingNodes.map(n => n.position?.y || 0), 0); + + return { + x: maxX + spacing * 2, + y: maxY + spacing * 2, + }; +} \ No newline at end of file From 94043f51b1c23c23b52209e40136bae342211dc5 Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Tue, 23 Sep 2025 18:21:41 +0300 Subject: [PATCH 2/7] feat: allow overlap when there isnt anymore free space left --- src/lib/utils.ts | 112 ++++++++++++++++++++++++++--------------------- 1 file changed, 62 insertions(+), 50 deletions(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 2fedc20..ddfabd2 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -133,14 +133,7 @@ export function findNonOverlappingPosition( nodeHeight: number = 100, spacing: number = 50 ): { x: number; y: number } { - const newRect = { - x: preferredPosition.x, - y: preferredPosition.y, - width: nodeWidth, - height: nodeHeight, - }; - - // Check if the default position overlaps with any existing node + // Check if preferred position is free const hasOverlap = existingNodes.some((node) => { if (!node.position) return false; @@ -151,6 +144,13 @@ export function findNonOverlappingPosition( height: node.height || (node.type === "table" ? 100 : node.type === "note" ? 192 : 300), }; + const newRect = { + x: preferredPosition.x, + y: preferredPosition.y, + width: nodeWidth, + height: nodeHeight, + }; + return doRectanglesOverlap(newRect, existingRect); }); @@ -158,51 +158,63 @@ export function findNonOverlappingPosition( return preferredPosition; } - // Try positions in a spiral pattern around the preferred position - const maxAttempts = 20; - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - const angle = (attempt * 0.5) * Math.PI; // Golden angle approximation - const radius = attempt * spacing; - - const offsetX = Math.cos(angle) * radius; - const offsetY = Math.sin(angle) * radius; - - const candidatePosition = { - x: preferredPosition.x + offsetX, - y: preferredPosition.y + offsetY, - }; - - const candidateRect = { - x: candidatePosition.x, - y: candidatePosition.y, - width: nodeWidth, - height: nodeHeight, - }; - - const hasCandidateOverlap = existingNodes.some((node) => { - if (!node.position) return false; - - const existingRect = { - x: node.position.x, - y: node.position.y, - width: node.width || (node.type === "table" ? 288 : node.type === "note" ? 192 : 300), - height: node.height || (node.type === "table" ? 100 : node.type === "note" ? 192 : 300), + // try positions in a grid around the preferred position + const gridSize = nodeWidth + spacing; + + for (let distance = 1; distance <= 10; distance++) { + const positions = [ + { x: preferredPosition.x + distance * gridSize, y: preferredPosition.y }, + { x: preferredPosition.x - distance * gridSize, y: preferredPosition.y }, + { x: preferredPosition.x, y: preferredPosition.y + distance * gridSize }, + { x: preferredPosition.x, y: preferredPosition.y - distance * gridSize }, + { x: preferredPosition.x + distance * gridSize, y: preferredPosition.y + distance * gridSize }, + { x: preferredPosition.x - distance * gridSize, y: preferredPosition.y - distance * gridSize }, + { x: preferredPosition.x + distance * gridSize, y: preferredPosition.y - distance * gridSize }, + { x: preferredPosition.x - distance * gridSize, y: preferredPosition.y + distance * gridSize }, + ]; + + for (const candidate of positions) { + const candidateRect = { + x: candidate.x, + y: candidate.y, + width: nodeWidth, + height: nodeHeight, }; - return doRectanglesOverlap(candidateRect, existingRect); - }); - - if (!hasCandidateOverlap) { - return candidatePosition; + const hasCandidateOverlap = existingNodes.some((node) => { + if (!node.position) return false; + + const existingRect = { + x: node.position.x, + y: node.position.y, + width: node.width || (node.type === "table" ? 288 : node.type === "note" ? 192 : 300), + height: node.height || (node.type === "table" ? 100 : node.type === "note" ? 192 : 300), + }; + + return doRectanglesOverlap(candidateRect, existingRect); + }); + + if (!hasCandidateOverlap) { + return candidate; + } } } - - // Fallback: return a position far from existing nodes - const maxX = Math.max(...existingNodes.map(n => n.position?.x || 0), 0); - const maxY = Math.max(...existingNodes.map(n => n.position?.y || 0), 0); - return { - x: maxX + spacing * 2, - y: maxY + spacing * 2, - }; + // If all else fails, overlap on the last added table + const lastAddedNode = existingNodes + .filter(node => node.position) + .sort((a, b) => { + const orderA = typeof a.data.order === 'number' ? a.data.order : 0; + const orderB = typeof b.data.order === 'number' ? b.data.order : 0; + return orderB - orderA; + })[0]; + + if (lastAddedNode && lastAddedNode.position) { + return { + x: lastAddedNode.position.x + 20, + y: lastAddedNode.position.y + 20, + }; + } + + return preferredPosition; } \ No newline at end of file From 60302018ffdee96d3003c5d75ce6c06b64ea7d3a Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Sat, 11 Oct 2025 14:01:57 +0300 Subject: [PATCH 3/7] chore: use constant for max search distance --- src/lib/utils.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ddfabd2..26251e7 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -158,10 +158,11 @@ export function findNonOverlappingPosition( return preferredPosition; } - // try positions in a grid around the preferred position + const MAX_SEARCH_DISTANCE = 10; const gridSize = nodeWidth + spacing; - for (let distance = 1; distance <= 10; distance++) { + // try positions in a grid around the preferred position + for (let distance = 1; distance <= MAX_SEARCH_DISTANCE; distance++) { const positions = [ { x: preferredPosition.x + distance * gridSize, y: preferredPosition.y }, { x: preferredPosition.x - distance * gridSize, y: preferredPosition.y }, From e1f8b861e1abdb4b1f037b435e59adf77b4786c0 Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Sat, 11 Oct 2025 14:04:57 +0300 Subject: [PATCH 4/7] chore: use constant for overlap offset --- src/lib/utils.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 26251e7..ec143e7 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -211,9 +211,10 @@ export function findNonOverlappingPosition( })[0]; if (lastAddedNode && lastAddedNode.position) { + const OVERLAP_OFFSET = 20; return { - x: lastAddedNode.position.x + 20, - y: lastAddedNode.position.y + 20, + x: lastAddedNode.position.x + OVERLAP_OFFSET, + y: lastAddedNode.position.y + OVERLAP_OFFSET, }; } From 098dedca5d8fd1016b5d13a8df9523f9e43735ad Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Sat, 11 Oct 2025 14:10:14 +0300 Subject: [PATCH 5/7] chore: use variables for constants --- src/lib/utils.ts | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ec143e7..b50df35 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,6 +4,16 @@ import { saveAs } from "file-saver"; import JSZip from "jszip"; import { twMerge } from "tailwind-merge"; +const MAX_SEARCH_DISTANCE = 10; +const OVERLAP_OFFSET = 20; +const DEFAULT_TABLE_WIDTH = 288; +const DEFAULT_TABLE_HEIGHT = 100; +const DEFAULT_NOTE_WIDTH = 192; +const DEFAULT_NOTE_HEIGHT = 192; +const DEFAULT_ZONE_WIDTH = 300; +const DEFAULT_ZONE_HEIGHT = 300; +const DEFAULT_NODE_SPACING = 50; + export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } @@ -56,13 +66,13 @@ export function isNodeInsideZone( // Get node dimensions (use defaults if not specified) const nodeWidth = node.width || - (node.type === "table" ? 288 : node.type === "note" ? 192 : 300); + (node.type === "table" ? DEFAULT_TABLE_WIDTH : node.type === "note" ? DEFAULT_NOTE_WIDTH : DEFAULT_ZONE_WIDTH); const nodeHeight = node.height || - (node.type === "table" ? 100 : node.type === "note" ? 192 : 300); + (node.type === "table" ? DEFAULT_TABLE_HEIGHT : node.type === "note" ? DEFAULT_NOTE_HEIGHT : DEFAULT_ZONE_HEIGHT); - const zoneWidth = zone.width || 300; - const zoneHeight = zone.height || 300; + const zoneWidth = zone.width || DEFAULT_ZONE_WIDTH; + const zoneHeight = zone.height || DEFAULT_ZONE_HEIGHT; // Check if all four corners of the node are inside the zone const topLeft = { x: node.position.x, y: node.position.y }; @@ -129,9 +139,9 @@ function doRectanglesOverlap( export function findNonOverlappingPosition( existingNodes: CombinedNode[], preferredPosition: { x: number; y: number }, - nodeWidth: number = 288, - nodeHeight: number = 100, - spacing: number = 50 + nodeWidth: number = DEFAULT_TABLE_WIDTH, + nodeHeight: number = DEFAULT_TABLE_HEIGHT, + spacing: number = DEFAULT_NODE_SPACING ): { x: number; y: number } { // Check if preferred position is free const hasOverlap = existingNodes.some((node) => { @@ -140,8 +150,8 @@ export function findNonOverlappingPosition( const existingRect = { x: node.position.x, y: node.position.y, - width: node.width || (node.type === "table" ? 288 : node.type === "note" ? 192 : 300), - height: node.height || (node.type === "table" ? 100 : node.type === "note" ? 192 : 300), + width: node.width || (node.type === "table" ? DEFAULT_TABLE_WIDTH : node.type === "note" ? DEFAULT_NOTE_WIDTH : DEFAULT_ZONE_WIDTH), + height: node.height || (node.type === "table" ? DEFAULT_TABLE_HEIGHT : node.type === "note" ? DEFAULT_NOTE_HEIGHT : DEFAULT_ZONE_HEIGHT), }; const newRect = { @@ -158,7 +168,6 @@ export function findNonOverlappingPosition( return preferredPosition; } - const MAX_SEARCH_DISTANCE = 10; const gridSize = nodeWidth + spacing; // try positions in a grid around the preferred position @@ -188,8 +197,8 @@ export function findNonOverlappingPosition( const existingRect = { x: node.position.x, y: node.position.y, - width: node.width || (node.type === "table" ? 288 : node.type === "note" ? 192 : 300), - height: node.height || (node.type === "table" ? 100 : node.type === "note" ? 192 : 300), + width: node.width || (node.type === "table" ? DEFAULT_TABLE_WIDTH : node.type === "note" ? DEFAULT_NOTE_WIDTH : DEFAULT_ZONE_WIDTH), + height: node.height || (node.type === "table" ? DEFAULT_TABLE_HEIGHT : node.type === "note" ? DEFAULT_NOTE_HEIGHT : DEFAULT_ZONE_HEIGHT), }; return doRectanglesOverlap(candidateRect, existingRect); @@ -211,7 +220,6 @@ export function findNonOverlappingPosition( })[0]; if (lastAddedNode && lastAddedNode.position) { - const OVERLAP_OFFSET = 20; return { x: lastAddedNode.position.x + OVERLAP_OFFSET, y: lastAddedNode.position.y + OVERLAP_OFFSET, From d19ed5cf72cb7a0c1b5472671bf211cf1a894183 Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Sat, 11 Oct 2025 14:40:16 +0300 Subject: [PATCH 6/7] feat: add view point boundary awareness --- src/components/DiagramEditor.tsx | 12 ++- src/components/Layout.tsx | 12 ++- src/lib/utils.ts | 145 +++++++++++++++++++------------ 3 files changed, 110 insertions(+), 59 deletions(-) diff --git a/src/components/DiagramEditor.tsx b/src/components/DiagramEditor.tsx index d84c592..f0bf208 100644 --- a/src/components/DiagramEditor.tsx +++ b/src/components/DiagramEditor.tsx @@ -7,7 +7,7 @@ import { import { tableColors } from "@/lib/colors"; import { colors, DbRelationship, relationshipTypes } from "@/lib/constants"; import { type AppEdge, type AppNode, type AppNoteNode, type AppZoneNode, type ProcessedEdge, type ProcessedNode } from "@/lib/types"; -import { findNonOverlappingPosition, isNodeInLockedZone } from "@/lib/utils"; +import { findNonOverlappingPosition, isNodeInLockedZone, DEFAULT_TABLE_WIDTH, DEFAULT_TABLE_HEIGHT, DEFAULT_NODE_SPACING, getCanvasDimensions } from "@/lib/utils"; import { useStore, type StoreState } from "@/store/store"; import { showError } from "@/utils/toast"; import { @@ -272,7 +272,15 @@ const DiagramEditor = forwardRef( const visibleNodes = diagram.data.nodes.filter((n: AppNode) => !n.data.isDeleted) || []; const tableName = `new_table_${visibleNodes.length + 1}`; const defaultPosition = { x: position.x - 144, y: position.y - 50 }; - const nonOverlappingPosition = findNonOverlappingPosition(visibleNodes, defaultPosition); + const canvasDimensions = getCanvasDimensions(); + const viewportBounds = rfInstanceRef.current ? { + x: rfInstanceRef.current.getViewport().x, + y: rfInstanceRef.current.getViewport().y, + width: canvasDimensions.width, + height: canvasDimensions.height, + zoom: rfInstanceRef.current.getViewport().zoom + } : undefined; + const nonOverlappingPosition = findNonOverlappingPosition(visibleNodes, defaultPosition, DEFAULT_TABLE_WIDTH, DEFAULT_TABLE_HEIGHT, DEFAULT_NODE_SPACING, viewportBounds); const newNode: AppNode = { id: `${tableName}-${+new Date()}`, diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 9f969cc..b0b048f 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -3,7 +3,7 @@ import { useSidebarState } from "@/hooks/use-sidebar-state"; import { tableColors } from "@/lib/colors"; import { colors, KeyboardShortcuts } from "@/lib/constants"; import { ElementType, type AppEdge, type AppNode, type AppNoteNode, type AppZoneNode, type ProcessedEdge, type ProcessedNode } from "@/lib/types"; -import { findNonOverlappingPosition } from "@/lib/utils"; +import { findNonOverlappingPosition, DEFAULT_TABLE_WIDTH, DEFAULT_TABLE_HEIGHT, DEFAULT_NODE_SPACING, getCanvasDimensions } from "@/lib/utils"; import { useStore, type StoreState } from "@/store/store"; import { showError, showSuccess } from "@/utils/toast"; import { type ReactFlowInstance } from "@xyflow/react"; @@ -209,7 +209,15 @@ export default function Layout({ onInstallAppRequest }: LayoutProps) { } const visibleNodes = diagram.data.nodes.filter((n: AppNode) => !n.data.isDeleted) || []; - const nonOverlappingPosition = findNonOverlappingPosition(visibleNodes, defaultPosition); + const canvasDimensions = getCanvasDimensions(); + const viewportBounds = rfInstance ? { + x: rfInstance.getViewport().x, + y: rfInstance.getViewport().y, + width: canvasDimensions.width, + height: canvasDimensions.height, + zoom: rfInstance.getViewport().zoom + } : undefined; + const nonOverlappingPosition = findNonOverlappingPosition(visibleNodes, defaultPosition, DEFAULT_TABLE_WIDTH, DEFAULT_TABLE_HEIGHT, DEFAULT_NODE_SPACING, viewportBounds); const newNode: AppNode = { id: `${tableName}-${+new Date()}`, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index b50df35..86b7dc5 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,15 +4,32 @@ import { saveAs } from "file-saver"; import JSZip from "jszip"; import { twMerge } from "tailwind-merge"; -const MAX_SEARCH_DISTANCE = 10; -const OVERLAP_OFFSET = 20; -const DEFAULT_TABLE_WIDTH = 288; -const DEFAULT_TABLE_HEIGHT = 100; -const DEFAULT_NOTE_WIDTH = 192; -const DEFAULT_NOTE_HEIGHT = 192; -const DEFAULT_ZONE_WIDTH = 300; -const DEFAULT_ZONE_HEIGHT = 300; -const DEFAULT_NODE_SPACING = 50; +export const MAX_SEARCH_DISTANCE = 10; +export const OVERLAP_OFFSET = 20; +export const DEFAULT_TABLE_WIDTH = 288; +export const DEFAULT_TABLE_HEIGHT = 100; +export const DEFAULT_NOTE_WIDTH = 192; +export const DEFAULT_NOTE_HEIGHT = 192; +export const DEFAULT_ZONE_WIDTH = 300; +export const DEFAULT_ZONE_HEIGHT = 300; +export const DEFAULT_NODE_SPACING = 50; + +export function getCanvasDimensions(): { width: number; height: number } { + const reactFlowViewport = document.querySelector('.react-flow__viewport'); + if (reactFlowViewport) { + const rect = reactFlowViewport.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + } + + const diagramPanel = document.querySelector('[data-panel-id]'); + if (diagramPanel) { + const rect = diagramPanel.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + } + + // fallback: use window dimensions + return { width: window.innerWidth, height: window.innerHeight }; +} export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -135,42 +152,62 @@ function doRectanglesOverlap( ); } -// Find a non-overlapping position for a new table +function isPositionInViewport( + position: { x: number; y: number }, + nodeWidth: number, + nodeHeight: number, + viewportBounds?: { x: number; y: number; width: number; height: number; zoom: number } +): boolean { + if (!viewportBounds) return true; + + // Convert viewport bounds to flow coordinates + const viewportX = -viewportBounds.x / viewportBounds.zoom; + const viewportY = -viewportBounds.y / viewportBounds.zoom; + const viewportWidth = viewportBounds.width / viewportBounds.zoom; + const viewportHeight = viewportBounds.height / viewportBounds.zoom; + + // Check if the node (with some padding) fits within the viewport + const padding = DEFAULT_NODE_SPACING; + return ( + position.x >= viewportX + padding && + position.x + nodeWidth <= viewportX + viewportWidth - padding && + position.y >= viewportY + padding && + position.y + nodeHeight <= viewportY + viewportHeight - padding + ); +} + export function findNonOverlappingPosition( existingNodes: CombinedNode[], preferredPosition: { x: number; y: number }, nodeWidth: number = DEFAULT_TABLE_WIDTH, nodeHeight: number = DEFAULT_TABLE_HEIGHT, - spacing: number = DEFAULT_NODE_SPACING + spacing: number = DEFAULT_NODE_SPACING, + viewportBounds?: { x: number; y: number; width: number; height: number; zoom: number } ): { x: number; y: number } { - // Check if preferred position is free - const hasOverlap = existingNodes.some((node) => { - if (!node.position) return false; - - const existingRect = { - x: node.position.x, - y: node.position.y, - width: node.width || (node.type === "table" ? DEFAULT_TABLE_WIDTH : node.type === "note" ? DEFAULT_NOTE_WIDTH : DEFAULT_ZONE_WIDTH), - height: node.height || (node.type === "table" ? DEFAULT_TABLE_HEIGHT : node.type === "note" ? DEFAULT_NOTE_HEIGHT : DEFAULT_ZONE_HEIGHT), - }; - - const newRect = { - x: preferredPosition.x, - y: preferredPosition.y, - width: nodeWidth, - height: nodeHeight, - }; + + const isValidPosition = (position: { x: number; y: number }): boolean => { + const hasOverlap = existingNodes.some((node) => { + if (!node.position) return false; + + const existingRect = { + x: node.position.x, + y: node.position.y, + width: node.width || (node.type === "table" ? DEFAULT_TABLE_WIDTH : node.type === "note" ? DEFAULT_NOTE_WIDTH : DEFAULT_ZONE_WIDTH), + height: node.height || (node.type === "table" ? DEFAULT_TABLE_HEIGHT : node.type === "note" ? DEFAULT_NOTE_HEIGHT : DEFAULT_ZONE_HEIGHT), + }; - return doRectanglesOverlap(newRect, existingRect); - }); + const newRect = { x: position.x, y: position.y, width: nodeWidth, height: nodeHeight }; + return doRectanglesOverlap(newRect, existingRect); + }); + + return !hasOverlap && isPositionInViewport(position, nodeWidth, nodeHeight, viewportBounds); + }; - if (!hasOverlap) { + if (isValidPosition(preferredPosition)) { return preferredPosition; } const gridSize = nodeWidth + spacing; - - // try positions in a grid around the preferred position for (let distance = 1; distance <= MAX_SEARCH_DISTANCE; distance++) { const positions = [ { x: preferredPosition.x + distance * gridSize, y: preferredPosition.y }, @@ -184,33 +221,31 @@ export function findNonOverlappingPosition( ]; for (const candidate of positions) { - const candidateRect = { - x: candidate.x, - y: candidate.y, - width: nodeWidth, - height: nodeHeight, - }; - - const hasCandidateOverlap = existingNodes.some((node) => { - if (!node.position) return false; - - const existingRect = { - x: node.position.x, - y: node.position.y, - width: node.width || (node.type === "table" ? DEFAULT_TABLE_WIDTH : node.type === "note" ? DEFAULT_NOTE_WIDTH : DEFAULT_ZONE_WIDTH), - height: node.height || (node.type === "table" ? DEFAULT_TABLE_HEIGHT : node.type === "note" ? DEFAULT_NOTE_HEIGHT : DEFAULT_ZONE_HEIGHT), - }; - - return doRectanglesOverlap(candidateRect, existingRect); - }); - - if (!hasCandidateOverlap) { + if (isValidPosition(candidate)) { return candidate; } } } - // If all else fails, overlap on the last added table + // If viewport is full, allow overlap but keep within viewport + if (viewportBounds) { + const viewportX = -viewportBounds.x / viewportBounds.zoom; + const viewportY = -viewportBounds.y / viewportBounds.zoom; + const viewportWidth = viewportBounds.width / viewportBounds.zoom; + const viewportHeight = viewportBounds.height / viewportBounds.zoom; + + // Try center of viewport first + const centerPosition = { + x: viewportX + viewportWidth / 2 - nodeWidth / 2, + y: viewportY + viewportHeight / 2 - nodeHeight / 2 + }; + + if (isPositionInViewport(centerPosition, nodeWidth, nodeHeight, viewportBounds)) { + return centerPosition; + } + } + + // Final fallback: overlap on last added table const lastAddedNode = existingNodes .filter(node => node.position) .sort((a, b) => { From dd28374dd2b35c07463368306628f16c1f1cf59e Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Sat, 11 Oct 2025 14:55:28 +0300 Subject: [PATCH 7/7] _ --- src/lib/utils.ts | 62 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 86b7dc5..9c2a07e 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,7 +4,7 @@ import { saveAs } from "file-saver"; import JSZip from "jszip"; import { twMerge } from "tailwind-merge"; -export const MAX_SEARCH_DISTANCE = 10; +export const MAX_SEARCH_DISTANCE = 20; export const OVERLAP_OFFSET = 20; export const DEFAULT_TABLE_WIDTH = 288; export const DEFAULT_TABLE_HEIGHT = 100; @@ -15,16 +15,21 @@ export const DEFAULT_ZONE_HEIGHT = 300; export const DEFAULT_NODE_SPACING = 50; export function getCanvasDimensions(): { width: number; height: number } { - const reactFlowViewport = document.querySelector('.react-flow__viewport'); - if (reactFlowViewport) { - const rect = reactFlowViewport.getBoundingClientRect(); - return { width: rect.width, height: rect.height }; - } + const selectors = [ + '.react-flow__viewport', + '.react-flow', + '[data-panel-id]', + '.react-flow__renderer' + ]; - const diagramPanel = document.querySelector('[data-panel-id]'); - if (diagramPanel) { - const rect = diagramPanel.getBoundingClientRect(); - return { width: rect.width, height: rect.height }; + for (const selector of selectors) { + const element = document.querySelector(selector); + if (element) { + const rect = element.getBoundingClientRect(); + if (rect.width > 100 && rect.height > 100) { + return { width: rect.width, height: rect.height }; + } + } } // fallback: use window dimensions @@ -227,14 +232,47 @@ export function findNonOverlappingPosition( } } - // If viewport is full, allow overlap but keep within viewport + // If viewport is full, try systematic search across entire viewport if (viewportBounds) { const viewportX = -viewportBounds.x / viewportBounds.zoom; const viewportY = -viewportBounds.y / viewportBounds.zoom; const viewportWidth = viewportBounds.width / viewportBounds.zoom; const viewportHeight = viewportBounds.height / viewportBounds.zoom; - // Try center of viewport first + // Try systematic grid search across the entire viewport + const gridSpacing = nodeWidth + spacing; + const maxX = Math.floor(viewportWidth / gridSpacing); + const maxY = Math.floor(viewportHeight / gridSpacing); + + for (let x = 0; x < maxX; x++) { + for (let y = 0; y < maxY; y++) { + const candidate = { + x: viewportX + x * gridSpacing + spacing, + y: viewportY + y * gridSpacing + spacing + }; + + // Check if position has no overlap (allow overlap with viewport bounds) + const hasOverlap = existingNodes.some((node) => { + if (!node.position) return false; + + const existingRect = { + x: node.position.x, + y: node.position.y, + width: node.width || (node.type === "table" ? DEFAULT_TABLE_WIDTH : node.type === "note" ? DEFAULT_NOTE_WIDTH : DEFAULT_ZONE_WIDTH), + height: node.height || (node.type === "table" ? DEFAULT_TABLE_HEIGHT : node.type === "note" ? DEFAULT_NOTE_HEIGHT : DEFAULT_ZONE_HEIGHT), + }; + + const newRect = { x: candidate.x, y: candidate.y, width: nodeWidth, height: nodeHeight }; + return doRectanglesOverlap(newRect, existingRect); + }); + + if (!hasOverlap && isPositionInViewport(candidate, nodeWidth, nodeHeight, viewportBounds)) { + return candidate; + } + } + } + + // Try center of viewport as last resort before overlap const centerPosition = { x: viewportX + viewportWidth / 2 - nodeWidth / 2, y: viewportY + viewportHeight / 2 - nodeHeight / 2