mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2025-12-24 06:58:48 -05:00
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:
12
README.md
12
README.md
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
Reference in New Issue
Block a user