diff --git a/src/DiagramEditor.tsx b/src/DiagramEditor.tsx index ac92f4f..40f17b3 100644 --- a/src/DiagramEditor.tsx +++ b/src/DiagramEditor.tsx @@ -77,9 +77,9 @@ interface Box { const SNAP_THRESHOLD = 6; /** - * Compares the dragged box's edges/center against every other box on the same - * level and, for the first close-enough match on each axis, snaps to it and - * records a guide line to draw across the canvas. + * Compares the dragged box's center against every other box's center on the + * same level and, for the first close-enough match on each axis, snaps to it + * and records a guide line to draw across the canvas. */ function computeSnapAndGuides(dragged: Box, others: Box[]): { x: number; y: number; guides: AlignmentGuide[] } { const guides: AlignmentGuide[] = []; @@ -87,36 +87,24 @@ function computeSnapAndGuides(dragged: Box, others: Box[]): { x: number; y: numb let snappedY = dragged.y; let matchedX = false; let matchedY = false; + const draggedCenterX = dragged.x + dragged.width / 2; + const draggedCenterY = dragged.y + dragged.height / 2; for (const other of others) { if (!matchedX) { - const xCandidates: [number, number][] = [ - [dragged.x, other.x], - [dragged.x + dragged.width, other.x + other.width], - [dragged.x + dragged.width / 2, other.x + other.width / 2], - ]; - for (const [dValue, oValue] of xCandidates) { - if (Math.abs(dValue - oValue) <= SNAP_THRESHOLD) { - snappedX = dragged.x + (oValue - dValue); - guides.push({ axis: 'x', position: oValue }); - matchedX = true; - break; - } + const otherCenterX = other.x + other.width / 2; + if (Math.abs(draggedCenterX - otherCenterX) <= SNAP_THRESHOLD) { + snappedX = dragged.x + (otherCenterX - draggedCenterX); + guides.push({ axis: 'x', position: otherCenterX }); + matchedX = true; } } if (!matchedY) { - const yCandidates: [number, number][] = [ - [dragged.y, other.y], - [dragged.y + dragged.height, other.y + other.height], - [dragged.y + dragged.height / 2, other.y + other.height / 2], - ]; - for (const [dValue, oValue] of yCandidates) { - if (Math.abs(dValue - oValue) <= SNAP_THRESHOLD) { - snappedY = dragged.y + (oValue - dValue); - guides.push({ axis: 'y', position: oValue }); - matchedY = true; - break; - } + const otherCenterY = other.y + other.height / 2; + if (Math.abs(draggedCenterY - otherCenterY) <= SNAP_THRESHOLD) { + snappedY = dragged.y + (otherCenterY - draggedCenterY); + guides.push({ axis: 'y', position: otherCenterY }); + matchedY = true; } } } @@ -307,8 +295,21 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { ); if (isHistoryWorthy) pushHistory(nodes, edges); const next = applyNodeChanges(adjustedChanges, nodes); + + // React Flow's own keyboard delete dispatches node and edge removal as + // separate change events that race through the same debounced + // emitChange — filtering edges here too (redundantly with onEdgesChange) + // guarantees a removed node never leaves a dangling edge behind. + const removedIds = new Set( + filteredChanges.filter((change) => change.type === 'remove').map((change) => change.id), + ); + const nextEdges = removedIds.size + ? edges.filter((edge) => !removedIds.has(edge.source) && !removedIds.has(edge.target)) + : edges; + setNodes(next); - emitChange(next, edges); + if (nextEdges !== edges) setEdges(nextEdges); + emitChange(next, nextEdges); }, [nodes, edges, emitChange, pushHistory, alignmentGuides], ); @@ -327,7 +328,10 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { const onConnect: OnConnect = useCallback( (connection) => { pushHistory(nodes, edges); - const next = addEdge({ ...connection, type: DEFAULT_EDGE_TYPE, markerEnd: DEFAULT_MARKER_END }, edges); + const next = addEdge( + { ...connection, id: crypto.randomUUID(), type: DEFAULT_EDGE_TYPE, markerEnd: DEFAULT_MARKER_END }, + edges, + ); setEdges(next); emitChange(nodes, next); }, @@ -397,7 +401,7 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { const addNode = useCallback( (kind: NodeKind) => { nodeCounter.current += 1; - const id = `${kind}-${nodeCounter.current}`; + const id = crypto.randomUUID(); const center = getViewportCenter(); const fanOut = nodeCounter.current; const size = DEFAULT_NODE_SIZE[kind]; @@ -431,7 +435,7 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { const source = clipboardRef.current; if (!source) return; nodeCounter.current += 1; - const newId = `${source.type}-${nodeCounter.current}`; + const newId = crypto.randomUUID(); const { parentId: _sourceParentId, selected: _sourceSelected, ...restSource } = source; const newNode: Node = { ...restSource, diff --git a/vite.config.ts b/vite.config.ts index f4af98c..453b132 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,11 +1,18 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' // https://vite.dev/config/ -export default defineConfig({ - // Needed when the build is served from a subpath (e.g. local/diagram-app/ - // inside the Bitrix docroot) instead of domain root — see - // docs/bitrix-integration.md §7. - base: process.env.VITE_BASE_PATH || '/', - plugins: [react()], +export default defineConfig(({ mode }) => { + // VITE_BASE_PATH is read here (config/Node context), so it needs an explicit + // loadEnv call — unlike VITE_BITRIX_ORIGIN, which Vite injects into + // import.meta.env automatically because it's only read from app code. + const env = loadEnv(mode, process.cwd(), '') + + return { + // Needed when the build is served from a subpath (e.g. local/diagram-app/ + // inside the Bitrix docroot) instead of domain root — see + // docs/bitrix-integration.md §7. + base: env.VITE_BASE_PATH || '/', + plugins: [react()], + } })