Implement click-based connector creation mode (#105)

* fix: eliminate re-render loop causing export failures

- Remove onModelUpdated callback that triggered infinite re-render cycles
- Replace debounce hack with controlled useEffect-based export timing
- Ensure DOM stability before export execution to prevent null containerRef
- Add isExporting guard to prevent concurrent export operations

Resolves issue where image export functionality was completely broken due to
infinite re-rendering caused by onModelUpdated callback.

Fixes #84 
Fixes #103

* Update packages/fossflow-lib/src/components/ExportImageDialog/ExportImageDialog.tsx


* Update packages/fossflow-lib/src/components/ExportImageDialog/ExportImageDialog.tsx


* Update packages/fossflow-lib/src/components/ExportImageDialog/ExportImageDialog.tsx
This commit is contained in:
Stan
2025-08-25 11:34:45 +01:00
committed by GitHub
parent a72dd250c5
commit d78ccdb970
7 changed files with 268 additions and 63 deletions

View File

@@ -10,6 +10,12 @@ FossFLOW is a powerful, open-source Progressive Web App (PWA) for creating beaut
## Recent Updates (August 2025)
### Improved Connector Tool
- **Click-based Creation** - New default mode: click first node, then second node to connect
- **Drag Mode Option** - Original drag-and-drop still available via settings
- **Mode Selection** - Switch between click and drag modes in Settings → Connectors tab
- **Better Reliability** - Click mode provides more predictable connection creation
### Custom Icon Import
- **Import Your Own Icons** - Upload custom icons (PNG, JPG, SVG) to use in your diagrams
- **Automatic Scaling** - Icons are automatically scaled to consistent sizes for professional appearance
@@ -135,7 +141,11 @@ npm run publish:lib # Publish library to npm
- Drag and drop components from the library onto the canvas
- Or right-click on the grid and select "Add node"
2. **Connect Items**: Use connectors to show relationships
2. **Connect Items**:
- Select the Connector tool (press 'C' or click connector icon)
- **Click mode** (default): Click first node, then click second node
- **Drag mode** (optional): Click and drag from first to second node
- Switch modes in Settings → Connectors tab
3. **Save Your Work**:
- **Quick Save** - Saves to browser session

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Box, IconButton, Paper, Typography, useTheme } from '@mui/material';
import { Close as CloseIcon } from '@mui/icons-material';
import { useUiStateStore } from 'src/stores/uiStateStore';
const STORAGE_KEY = 'fossflow_connector_hint_dismissed';
@@ -10,6 +11,8 @@ interface Props {
export const ConnectorHintTooltip = ({ toolMenuRef }: Props) => {
const theme = useTheme();
const connectorInteractionMode = useUiStateStore((state) => state.connectorInteractionMode);
const mode = useUiStateStore((state) => state.mode);
const [isDismissed, setIsDismissed] = useState(true);
const [position, setPosition] = useState({ top: 16, right: 16 });
@@ -82,11 +85,29 @@ export const ConnectorHintTooltip = ({ toolMenuRef }: Props) => {
</IconButton>
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 600 }}>
Tip: Rerouting Connectors
{connectorInteractionMode === 'click' ? 'Tip: Creating Connectors' : 'Tip: Connector Tools'}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{connectorInteractionMode === 'click' ? (
<>
<strong>Click</strong> on the first node or point, then <strong>click</strong> on
the second node or point to create a connection.
{mode.type === 'CONNECTOR' && mode.isConnecting && (
<Box component="span" sx={{ display: 'block', mt: 1, color: 'primary.main' }}>
Now click on the target to complete the connection.
</Box>
)}
</>
) : (
<>
<strong>Drag</strong> from the first node to the second node to create a connection.
</>
)}
</Typography>
<Typography variant="body2" color="text.secondary">
To reroute a connector track, <strong>right-click</strong> on any point
To reroute a connector, <strong>left-click</strong> on any point
along the connector line and drag to create or move anchor points.
</Typography>
</Paper>

View File

@@ -0,0 +1,71 @@
import React from 'react';
import {
Box,
FormControl,
FormLabel,
RadioGroup,
FormControlLabel,
Radio,
Typography,
Paper
} from '@mui/material';
import { useUiStateStore } from 'src/stores/uiStateStore';
export const ConnectorSettings = () => {
const connectorInteractionMode = useUiStateStore((state) => state.connectorInteractionMode);
const setConnectorInteractionMode = useUiStateStore((state) => state.actions.setConnectorInteractionMode);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setConnectorInteractionMode(event.target.value as 'click' | 'drag');
};
return (
<Box>
<Typography variant="h6" gutterBottom>
Connector Settings
</Typography>
<Paper variant="outlined" sx={{ p: 2, mt: 2 }}>
<FormControl component="fieldset">
<FormLabel component="legend">Connection Creation Mode</FormLabel>
<RadioGroup
value={connectorInteractionMode}
onChange={handleChange}
sx={{ mt: 1 }}
>
<FormControlLabel
value="click"
control={<Radio />}
label={
<Box>
<Typography variant="body1">Click Mode (Recommended)</Typography>
<Typography variant="body2" color="text.secondary">
Click the first node, then click the second node to create a connection
</Typography>
</Box>
}
/>
<FormControlLabel
value="drag"
control={<Radio />}
label={
<Box>
<Typography variant="body1">Drag Mode</Typography>
<Typography variant="body2" color="text.secondary">
Click and drag from the first node to the second node
</Typography>
</Box>
}
sx={{ mt: 1 }}
/>
</RadioGroup>
</FormControl>
</Paper>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
Note: You can change this setting at any time. The selected mode will be used
when the Connector tool is active.
</Typography>
</Box>
);
};

View File

@@ -14,6 +14,7 @@ import { Close as CloseIcon } from '@mui/icons-material';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { HotkeySettings } from '../HotkeySettings/HotkeySettings';
import { PanSettings } from '../PanSettings/PanSettings';
import { ConnectorSettings } from '../ConnectorSettings/ConnectorSettings';
export const SettingsDialog = () => {
const dialog = useUiStateStore((state) => state.dialog);
@@ -56,11 +57,13 @@ export const SettingsDialog = () => {
<Tabs value={tabValue} onChange={handleTabChange} sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tab label="Hotkeys" />
<Tab label="Pan Controls" />
<Tab label="Connectors" />
</Tabs>
<Box sx={{ mt: 2 }}>
{tabValue === 0 && <HotkeySettings />}
{tabValue === 1 && <PanSettings />}
{tabValue === 2 && <ConnectorSettings />}
</Box>
</DialogContent>
<DialogActions>

View File

@@ -23,86 +23,172 @@ export const Connector: ModeActions = {
)
return;
const connector = getItemByIdOrThrow(
scene.currentView.connectors ?? [],
uiState.mode.id
);
// Only update connector position in drag mode or when connecting in click mode
if (uiState.connectorInteractionMode === 'drag' || uiState.mode.isConnecting) {
const connector = getItemByIdOrThrow(
scene.currentView.connectors ?? [],
uiState.mode.id
);
const itemAtTile = getItemAtTile({
tile: uiState.mouse.position.tile,
scene
});
if (itemAtTile?.type === 'ITEM') {
const newConnector = produce(connector.value, (draft) => {
draft.anchors[1] = { id: generateId(), ref: { item: itemAtTile.id } };
const itemAtTile = getItemAtTile({
tile: uiState.mouse.position.tile,
scene
});
scene.updateConnector(uiState.mode.id, newConnector);
} else {
const newConnector = produce(connector.value, (draft) => {
draft.anchors[1] = {
id: generateId(),
ref: { tile: uiState.mouse.position.tile }
};
});
if (itemAtTile?.type === 'ITEM') {
const newConnector = produce(connector.value, (draft) => {
draft.anchors[1] = { id: generateId(), ref: { item: itemAtTile.id } };
});
scene.updateConnector(uiState.mode.id, newConnector);
scene.updateConnector(uiState.mode.id, newConnector);
} else {
const newConnector = produce(connector.value, (draft) => {
draft.anchors[1] = {
id: generateId(),
ref: { tile: uiState.mouse.position.tile }
};
});
scene.updateConnector(uiState.mode.id, newConnector);
}
}
},
mousedown: ({ uiState, scene, isRendererInteraction }) => {
if (uiState.mode.type !== 'CONNECTOR' || !isRendererInteraction) return;
const newConnector: ConnectorI = {
id: generateId(),
color: scene.colors[0].id,
anchors: []
};
const itemAtTile = getItemAtTile({
tile: uiState.mouse.position.tile,
scene
});
if (itemAtTile && itemAtTile.type === 'ITEM') {
newConnector.anchors = [
{ id: generateId(), ref: { item: itemAtTile.id } },
{ id: generateId(), ref: { item: itemAtTile.id } }
];
if (uiState.connectorInteractionMode === 'click') {
// Click mode: handle first and second clicks
if (!uiState.mode.startAnchor) {
// First click: store the start position
const startAnchor = itemAtTile?.type === 'ITEM'
? { itemId: itemAtTile.id }
: { tile: uiState.mouse.position.tile };
// Create a connector but don't finalize it yet
const newConnector: ConnectorI = {
id: generateId(),
color: scene.colors[0].id,
anchors: []
};
if (itemAtTile && itemAtTile.type === 'ITEM') {
newConnector.anchors = [
{ id: generateId(), ref: { item: itemAtTile.id } },
{ id: generateId(), ref: { item: itemAtTile.id } }
];
} else {
newConnector.anchors = [
{ id: generateId(), ref: { tile: uiState.mouse.position.tile } },
{ id: generateId(), ref: { tile: uiState.mouse.position.tile } }
];
}
scene.createConnector(newConnector);
uiState.actions.setMode({
type: 'CONNECTOR',
showCursor: true,
id: newConnector.id,
startAnchor,
isConnecting: true
});
} else {
// Second click: complete the connection
if (uiState.mode.id) {
const connector = getItemByIdOrThrow(scene.connectors, uiState.mode.id);
// Update the second anchor to the click position
const newConnector = produce(connector.value, (draft) => {
if (itemAtTile?.type === 'ITEM') {
draft.anchors[1] = { id: generateId(), ref: { item: itemAtTile.id } };
} else {
draft.anchors[1] = {
id: generateId(),
ref: { tile: uiState.mouse.position.tile }
};
}
});
scene.updateConnector(uiState.mode.id, newConnector);
// Validate the connection
const firstAnchor = newConnector.anchors[0];
const lastAnchor = newConnector.anchors[newConnector.anchors.length - 1];
if (
connector.value.path.tiles.length < 2 ||
!(firstAnchor.ref.item && lastAnchor.ref.item)
) {
scene.deleteConnector(uiState.mode.id);
}
// Reset for next connection
uiState.actions.setMode({
type: 'CONNECTOR',
showCursor: true,
id: null,
startAnchor: undefined,
isConnecting: false
});
}
}
} else {
newConnector.anchors = [
{ id: generateId(), ref: { tile: uiState.mouse.position.tile } },
{ id: generateId(), ref: { tile: uiState.mouse.position.tile } }
];
// Drag mode: original behavior
const newConnector: ConnectorI = {
id: generateId(),
color: scene.colors[0].id,
anchors: []
};
if (itemAtTile && itemAtTile.type === 'ITEM') {
newConnector.anchors = [
{ id: generateId(), ref: { item: itemAtTile.id } },
{ id: generateId(), ref: { item: itemAtTile.id } }
];
} else {
newConnector.anchors = [
{ id: generateId(), ref: { tile: uiState.mouse.position.tile } },
{ id: generateId(), ref: { tile: uiState.mouse.position.tile } }
];
}
scene.createConnector(newConnector);
uiState.actions.setMode({
type: 'CONNECTOR',
showCursor: true,
id: newConnector.id
});
}
scene.createConnector(newConnector);
uiState.actions.setMode({
type: 'CONNECTOR',
showCursor: true,
id: newConnector.id
});
},
mouseup: ({ uiState, scene }) => {
if (uiState.mode.type !== 'CONNECTOR' || !uiState.mode.id) return;
const connector = getItemByIdOrThrow(scene.connectors, uiState.mode.id);
const firstAnchor = connector.value.anchors[0];
const lastAnchor =
connector.value.anchors[connector.value.anchors.length - 1];
// Only handle mouseup for drag mode
if (uiState.connectorInteractionMode === 'drag') {
const connector = getItemByIdOrThrow(scene.connectors, uiState.mode.id);
const firstAnchor = connector.value.anchors[0];
const lastAnchor =
connector.value.anchors[connector.value.anchors.length - 1];
if (
connector.value.path.tiles.length < 2 ||
!(firstAnchor.ref.item && lastAnchor.ref.item)
) {
scene.deleteConnector(uiState.mode.id);
if (
connector.value.path.tiles.length < 2 ||
!(firstAnchor.ref.item && lastAnchor.ref.item)
) {
scene.deleteConnector(uiState.mode.id);
}
uiState.actions.setMode({
type: 'CONNECTOR',
showCursor: true,
id: null
});
}
uiState.actions.setMode({
type: 'CONNECTOR',
showCursor: true,
id: null
});
// Click mode handles completion in mousedown (second click)
}
};
};

View File

@@ -34,6 +34,7 @@ const initialState = () => {
enableDebugTools: false,
hotkeyProfile: DEFAULT_HOTKEY_PROFILE,
panSettings: DEFAULT_PAN_SETTINGS,
connectorInteractionMode: 'click', // Default to click mode
actions: {
setView: (view) => {
set({ view });
@@ -101,6 +102,9 @@ const initialState = () => {
},
setPanSettings: (panSettings) => {
set({ panSettings });
},
setConnectorInteractionMode: (connectorInteractionMode) => {
set({ connectorInteractionMode });
}
}
};

View File

@@ -59,6 +59,12 @@ export interface ConnectorMode {
type: 'CONNECTOR';
showCursor: boolean;
id: string | null;
// For click-based connection mode
startAnchor?: {
tile?: Coords;
itemId?: string;
};
isConnecting?: boolean;
}
export interface DrawRectangleMode {
@@ -136,6 +142,8 @@ export const LayerOrderingActionOptions = {
export type LayerOrderingAction = keyof typeof LayerOrderingActionOptions;
export type ConnectorInteractionMode = 'click' | 'drag';
export interface UiState {
view: string;
mainMenuOptions: MainMenuOptions;
@@ -153,6 +161,7 @@ export interface UiState {
enableDebugTools: boolean;
hotkeyProfile: HotkeyProfile;
panSettings: PanSettings;
connectorInteractionMode: ConnectorInteractionMode;
}
export interface UiStateActions {
@@ -175,6 +184,7 @@ export interface UiStateActions {
setEnableDebugTools: (enabled: boolean) => void;
setHotkeyProfile: (profile: HotkeyProfile) => void;
setPanSettings: (settings: PanSettings) => void;
setConnectorInteractionMode: (mode: ConnectorInteractionMode) => void;
}
export type UiStateStore = UiState & {