Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "thothblueprint",
"description": "Free visual database design tool with drag-and-move editing, multi-format export, and migration generation for popular frameworks.",
"private": true,
"version": "0.0.7",
"version": "0.0.8",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
22 changes: 22 additions & 0 deletions public/whats-new.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
# What’s New in ThothBlueprint v0.0.8

This release focuses on improving relationship lines, offering manual layout adjustments, and fixing issues with duplicate relationships in complex diagrams.

## Highlights

- **Manual Adjustment of Relationship Lines:** You can now manually adjust the path of relationship lines to untangle complex diagrams and improve readability.
- **Duplicate Relationship Prevention:** Added validation to prevent the creation of duplicate relationships between table fields.

## New & Improved

- **Draggable Relationship Handles:** Selected edges now feature draggable handles, allowing for interactive position adjustments.
- **Reset Manual Adjustments:** Added a reset button in the inspector panel to quickly clear any manual position adjustments and revert to auto-routing.
- **Improved Edge Routing:** Edge path calculations now intelligently use your manual center points when available.
- **Duplicate Detection:** The diagram editor now detects both same-direction and reverse-direction duplicate connections by comparing source/target nodes and column handles.

## Bug Fixes

- Fixed an issue where duplicate relationships between tables could still be created by dragging connection points (Issue #37).

---

# What’s New in ThothBlueprint v0.0.7

This release brings major importer upgrades, MySQL PostgreSQL schema dump importer support, UI improvements, and performance optimizations tailored for large real‑world schemas.
Expand Down
107 changes: 103 additions & 4 deletions src/components/CustomEdge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import {
EdgeLabelRenderer,
type EdgeProps,
getSmoothStepPath,
useReactFlow,
} from "@xyflow/react";
import { useMemo } from "react";
import { useMemo, useCallback } from "react";
import React from "react";

const EdgeIndicator = ({
Expand Down Expand Up @@ -65,8 +66,36 @@ const getTotalPathLength = (pathData: string) => {
return pathNode.getTotalLength();
};

const EdgeHandle = ({
x,
y,
onMouseDown,
}: {
x: number;
y: number;
onMouseDown: (event: React.MouseEvent) => void;
}) => (
<div
style={{
position: "absolute",
transform: `translate(-50%, -50%) translate(${x}px,${y}px)`,
background: colors.HIGHLIGHT,
width: "12px",
height: "12px",
borderRadius: "50%",
cursor: "grab",
pointerEvents: "all",
zIndex: 1001,
border: "2px solid white",
}}
onMouseDown={onMouseDown}
className="nodrag nopan"
/>
);

function CustomEdge(props: EdgeProps) {
const {
id,
sourceX,
sourceY,
targetX,
Expand All @@ -75,31 +104,87 @@ function CustomEdge(props: EdgeProps) {
targetPosition,
data,
style = {},
selected,
} = props;

const { setEdges } = useReactFlow();

// Type assertion for the data property
const edgeData = data as EdgeData | undefined;
const [edgePath] = getSmoothStepPath({
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
borderRadius: 10,
...(edgeData?.centerX !== undefined ? { centerX: edgeData.centerX } : {}),
...(edgeData?.centerY !== undefined ? { centerY: edgeData.centerY } : {}),
});

const onHandleMouseDown = useCallback(
(event: React.MouseEvent) => {
event.stopPropagation();

const startX = event.clientX;
const startY = event.clientY;
const initialCenterX = edgeData?.centerX ?? labelX;
const initialCenterY = edgeData?.centerY ?? labelY;

const onPointerMove = (e: MouseEvent) => {
const dx = e.clientX - startX;
const dy = e.clientY - startY;

setEdges((edges) =>
edges.map((e) => {
if (e.id === id) {
return {
...e,
data: {
...e.data,
centerX: initialCenterX + dx,
centerY: initialCenterY + dy,
},
};
}
return e;
})
);
};

const onPointerUp = () => {
window.removeEventListener("mousemove", onPointerMove);
window.removeEventListener("mouseup", onPointerUp);
};

window.addEventListener("mousemove", onPointerMove);
window.addEventListener("mouseup", onPointerUp);
},
[edgeData, labelX, labelY, id, setEdges]
);

const { isHighlighted, relationship } = edgeData || {};

const { sourcePoint, targetPoint } = useMemo(() => {
const { sourcePoint, targetPoint, handlePoints } = useMemo(() => {
const pathLength = getTotalPathLength(edgePath);
const distance = 40;
const safeDistance = Math.min(distance, pathLength / 2 - 5);

const sp = getPointAlongPath(edgePath, safeDistance);
const tp = getPointAlongPath(edgePath, pathLength - safeDistance);

return { sourcePoint: sp, targetPoint: tp };
// Calculate multiple handle points
const handles = [];
if (pathLength > 0) {
// Add points at 25%, 50% (label position), and 75%
handles.push(getPointAlongPath(edgePath, pathLength * 0.25));
// Middle point is already labelX/labelY, but we can add it here too if we want uniformity
// handles.push({ x: labelX, y: labelY });
handles.push(getPointAlongPath(edgePath, pathLength * 0.75));
}

return { sourcePoint: sp, targetPoint: tp, handlePoints: handles };
}, [edgePath]);

let sourceLabel = "";
Expand Down Expand Up @@ -161,6 +246,19 @@ function CustomEdge(props: EdgeProps) {
label={targetLabel}
isHighlighted={isHighlighted ?? false}
/>
{selected && (
<>
<EdgeHandle x={labelX} y={labelY} onMouseDown={onHandleMouseDown} />
{handlePoints?.map((point, index) => (
<EdgeHandle
key={index}
x={point.x}
y={point.y}
onMouseDown={onHandleMouseDown}
/>
))}
</>
)}
</EdgeLabelRenderer>
</>
);
Expand All @@ -179,6 +277,7 @@ const MemoizedCustomEdge = React.memo(CustomEdge, (prevProps, nextProps) => {
prevProps.targetY === nextProps.targetY &&
prevProps.sourcePosition === nextProps.sourcePosition &&
prevProps.targetPosition === nextProps.targetPosition &&
prevProps.selected === nextProps.selected &&
// Compare edge data
JSON.stringify(prevProps.data) === JSON.stringify(nextProps.data) &&
// Compare style
Expand Down
22 changes: 16 additions & 6 deletions src/components/DiagramEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 CombinedNode, type ProcessedEdge, type ProcessedNode } from "@/lib/types";
import { DEFAULT_NODE_SPACING, DEFAULT_TABLE_HEIGHT, DEFAULT_TABLE_WIDTH, findNonOverlappingPosition, getCanvasDimensions, isNodeInLockedZone, isNodeInsideZone } from "@/lib/utils";
import { DEFAULT_NODE_SPACING, DEFAULT_TABLE_HEIGHT, DEFAULT_TABLE_WIDTH, findExistingRelationship, findNonOverlappingPosition, getCanvasDimensions, getColumnId, isNodeInLockedZone, isNodeInsideZone } from "@/lib/utils";
import { useStore, type StoreState } from "@/store/store";
import { showError } from "@/utils/toast";
import {
Expand Down Expand Up @@ -381,10 +381,6 @@ const DiagramEditor = forwardRef(
const { source, target, sourceHandle, targetHandle } = connection;
if (!source || !target || !sourceHandle || !targetHandle) return;

const getColumnId = (handleId: string) => {
const parts = handleId.split('-');
return parts.slice(0, -2).join('-');
};
const sourceNode = nodesMap.get(source);
const targetNode = nodesMap.get(target);
if (!sourceNode || !targetNode) return;
Expand All @@ -400,14 +396,28 @@ const DiagramEditor = forwardRef(
return;
}

// Check for duplicate relationships
const existingEdge = findExistingRelationship(
edges,
source,
target,
sourceHandle,
targetHandle
);

if (existingEdge) {
showError("This relationship already exists.");
return;
}

const newEdge: AppEdge = {
...connection,
id: `${source}-${target}-${sourceHandle}-${targetHandle}`,
type: "custom",
data: { relationship: relationshipTypes[1]?.value || DbRelationship.ONE_TO_MANY },
};
addEdgeToStore(newEdge);
}, [nodesMap, addEdgeToStore]);
}, [nodesMap, edges, addEdgeToStore]);

const onInit = useCallback((instance: ReactFlowInstance<ProcessedNode, ProcessedEdge>) => {
rfInstanceRef.current = instance;
Expand Down
21 changes: 21 additions & 0 deletions src/components/EdgeInspectorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,27 @@
</SelectContent>
</Select>
</div>
<div className="my-4">
<Label>Manual Adjustment</Label>
<Button
variant="outline"
size="sm"
className="w-full mt-2"
onClick={() => {
const { centerX, centerY, ...restData } = edge.data || {};

Check warning on line 117 in src/components/EdgeInspectorPanel.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

'centerY' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 117 in src/components/EdgeInspectorPanel.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

'centerX' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 117 in src/components/EdgeInspectorPanel.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

'centerY' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 117 in src/components/EdgeInspectorPanel.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

'centerX' is assigned a value but never used. Allowed unused vars must match /^_/u
updateEdge({
...edge,
data: {
...restData,
relationship: edge.data?.relationship || DbRelationship.ONE_TO_MANY
}
});
}}
disabled={isLocked || (edge.data?.centerX === undefined && edge.data?.centerY === undefined)}
>
Reset Position
</Button>
</div>
<Separator />
<div className="mt-6">
<AlertDialog>
Expand Down
16 changes: 15 additions & 1 deletion src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { DEFAULT_NODE_SPACING, DEFAULT_TABLE_HEIGHT, DEFAULT_TABLE_WIDTH, findNonOverlappingPosition, getCanvasDimensions } from "@/lib/utils";
import { DEFAULT_NODE_SPACING, DEFAULT_TABLE_HEIGHT, DEFAULT_TABLE_WIDTH, findExistingRelationship, findNonOverlappingPosition, getCanvasDimensions } from "@/lib/utils";
import { useStore, type StoreState } from "@/store/store";
import { showError, showSuccess } from "@/utils/toast";
import { type ReactFlowInstance } from "@xyflow/react";
Expand Down Expand Up @@ -343,6 +343,20 @@ export default function Layout({ onInstallAppRequest }: LayoutProps) {
const sourceHandle = `${sourceColumnId}-right-source`;
const targetHandle = `${targetColumnId}-left-target`;

// Check for duplicate relationships
const existingEdge = findExistingRelationship(
diagram.data.edges || [],
sourceNodeId,
targetNodeId,
sourceHandle,
targetHandle
);

if (existingEdge) {
showError("This relationship already exists.");
return;
}

const newEdge: AppEdge = {
id: `${sourceNodeId}-${targetNodeId}-${sourceHandle}-${targetHandle}`,
source: sourceNodeId,
Expand Down
4 changes: 4 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export type ProcessedEdge = Omit<AppEdge, "type"> & {
relationship: string;
isHighlighted: boolean;
isPositionLocked?: boolean;
centerX?: number;
centerY?: number;
};
};

Expand Down Expand Up @@ -123,6 +125,8 @@ export interface EdgeData extends Record<string, unknown> {
onDelete?: string;
onUpdate?: string;
isComposite?: boolean;
centerX?: number;
centerY?: number;
}

export interface Settings {
Expand Down
44 changes: 42 additions & 2 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type AppZoneNode, type CombinedNode } from "@/lib/types";
import { type AppEdge, type AppZoneNode, type CombinedNode } from "@/lib/types";
import { clsx, type ClassValue } from "clsx";
import { saveAs } from "file-saver";
import JSZip from "jszip";
Expand Down Expand Up @@ -322,4 +322,44 @@ export function uuid(): string {
// ignore crypto.randomUUID() errors, fallback will be used
}
return `uuid_${Math.random().toString(36).slice(2)}_${Date.now()}`;
}
}

export function getColumnId(handleId: string): string {
if (!handleId) return '';
const parts = handleId.split('-');
// Handles are typically format: "col_123-right-source" or "col_123-left-target"
// So we remove the last two parts to get the column ID
return parts.slice(0, -2).join('-');
}

export function findExistingRelationship(
edges: AppEdge[],
source: string,
target: string,
sourceHandle: string,
targetHandle: string
): AppEdge | undefined {
const newSourceColumnId = getColumnId(sourceHandle);
const newTargetColumnId = getColumnId(targetHandle);

return edges.find(edge => {
const edgeSourceColumnId = getColumnId(edge.sourceHandle || '');
const edgeTargetColumnId = getColumnId(edge.targetHandle || '');

// Check for same relationship (same direction)
const isSameDirection =
edge.source === source &&
edge.target === target &&
edgeSourceColumnId === newSourceColumnId &&
edgeTargetColumnId === newTargetColumnId;

// Check for same relationship (reverse direction)
const isReverseDirection =
edge.source === target &&
edge.target === source &&
edgeSourceColumnId === newTargetColumnId &&
edgeTargetColumnId === newSourceColumnId;

return isSameDirection || isReverseDirection;
});
}
Loading
Loading