Feat/click connector mode (#106)

* 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

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

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fixed connectors

---------

Co-authored-by: Manfred Michaelis <mm@michm.de>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Stan <stanleylsmith@pm.me>
This commit is contained in:
Stan
2025-08-25 14:24:55 +00:00
committed by GitHub
parent d78ccdb970
commit ea0bce0d28
5 changed files with 162 additions and 38 deletions

View File

@@ -25,8 +25,13 @@ root.render(
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
// Register service worker for PWA functionality
serviceWorkerRegistration.register({
onSuccess: () => console.log('Service worker registered successfully'),
onUpdate: () => console.log('Service worker update available')
});
// Service worker registration - only in production for PWA functionality
if (process.env.NODE_ENV === 'production') {
serviceWorkerRegistration.register({
onSuccess: () => console.log('Service worker registered successfully'),
onUpdate: () => console.log('Service worker update available')
});
} else {
// Disable service worker in development to avoid cache issues
serviceWorkerRegistration.unregister();
}

View File

@@ -0,0 +1,97 @@
import React, { useState, useEffect, useRef } from 'react';
import { Box, Paper, Typography, Fade } from '@mui/material';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useScene } from 'src/hooks/useScene';
export const ConnectorEmptySpaceTooltip = () => {
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
const mode = useUiStateStore((state) => state.mode);
const mouse = useUiStateStore((state) => state.mouse);
const { connectors } = useScene();
const previousModeRef = useRef(mode);
const shownForConnectorRef = useRef<string | null>(null);
useEffect(() => {
const previousMode = previousModeRef.current;
// Detect when we transition from isConnecting to not isConnecting (connection completed)
if (
mode.type === 'CONNECTOR' &&
previousMode.type === 'CONNECTOR' &&
previousMode.isConnecting &&
!mode.isConnecting &&
!mode.id // After connection is complete, id is set to null
) {
// Find the most recently created connector
const latestConnector = connectors[connectors.length - 1];
if (latestConnector && latestConnector.id !== shownForConnectorRef.current) {
// Check if either end is connected to empty space (tile reference)
const hasEmptySpaceConnection = latestConnector.anchors.some(
anchor => anchor.ref.tile && !anchor.ref.item
);
if (hasEmptySpaceConnection) {
// Show tooltip near the mouse position
setTooltipPosition({
x: mouse.position.screen.x,
y: mouse.position.screen.y
});
setShowTooltip(true);
shownForConnectorRef.current = latestConnector.id;
// Auto-hide after 12 seconds
const timer = setTimeout(() => {
setShowTooltip(false);
}, 12000);
return () => clearTimeout(timer);
}
}
}
// Hide tooltip when switching away from connector mode
if (mode.type !== 'CONNECTOR') {
setShowTooltip(false);
}
previousModeRef.current = mode;
}, [mode, connectors, mouse.position.screen]);
// Remove the click handler - tooltip should persist
// It will only hide after timeout or mode change
if (!showTooltip) {
return null;
}
return (
<Fade in={showTooltip} timeout={300}>
<Box
sx={{
position: 'fixed',
left: Math.min(tooltipPosition.x + 20, window.innerWidth - 350),
top: Math.min(tooltipPosition.y - 60, window.innerHeight - 100),
zIndex: 1400, // Above most UI elements
pointerEvents: 'none' // Don't block interactions
}}
>
<Paper
elevation={4}
sx={{
p: 2,
maxWidth: 320,
backgroundColor: 'background.paper',
borderLeft: '4px solid',
borderLeftColor: 'info.main'
}}
>
<Typography variant="body2">
To connect this connector to a node, <strong>left-click on the end of the connector</strong> and drag it to the desired node.
</Typography>
</Paper>
</Box>
</Fade>
);
};

View File

@@ -107,6 +107,18 @@ export const ExportImageDialog = ({ onClose, quality = 1.5 }: Props) => {
return () => clearTimeout(timer);
}, [showGrid, backgroundColor]);
const downloadFile = useCallback(() => {
if (!imageData) return;
const data = base64ToBlob(
imageData.replace('data:image/png;base64,', ''),
'image/png;charset=utf-8'
);
downloadFileUtil(data, generateGenericFilename('png'));
}, [imageData]);
>>>>>>> 10145c9 (fix: eliminate re-render loop causing export failures)
useEffect(() => {
const timer = setTimeout(() => {
exportImage();

View File

@@ -19,6 +19,7 @@ import { ExportImageDialog } from '../ExportImageDialog/ExportImageDialog';
import { HelpDialog } from '../HelpDialog/HelpDialog';
import { SettingsDialog } from '../SettingsDialog/SettingsDialog';
import { ConnectorHintTooltip } from '../ConnectorHintTooltip/ConnectorHintTooltip';
import { ConnectorEmptySpaceTooltip } from '../ConnectorEmptySpaceTooltip/ConnectorEmptySpaceTooltip';
const ToolsEnum = {
MAIN_MENU: 'MAIN_MENU',
@@ -245,6 +246,7 @@ export const UiOverlay = () => {
{/* Show connector hint tooltip only in editable mode */}
{editorMode === EditorModeEnum.EDITABLE && <ConnectorHintTooltip toolMenuRef={toolMenuRef} />}
{editorMode === EditorModeEnum.EDITABLE && <ConnectorEmptySpaceTooltip />}
<SceneLayer>
<Box ref={contextMenuAnchorRef} />

View File

@@ -23,12 +23,20 @@ export const Connector: ModeActions = {
)
return;
// TypeScript type guard - we know mode is CONNECTOR type here
const connectorMode = uiState.mode;
// 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
if (uiState.connectorInteractionMode === 'drag' || connectorMode.isConnecting) {
// Try to find the connector - it might not exist yet
const connectorItem = (scene.currentView.connectors ?? []).find(
c => c.id === connectorMode.id
);
// If connector doesn't exist yet, return early
if (!connectorItem) {
return;
}
const itemAtTile = getItemAtTile({
tile: uiState.mouse.position.tile,
@@ -36,20 +44,20 @@ export const Connector: ModeActions = {
});
if (itemAtTile?.type === 'ITEM') {
const newConnector = produce(connector.value, (draft) => {
const newConnector = produce(connectorItem, (draft) => {
draft.anchors[1] = { id: generateId(), ref: { item: itemAtTile.id } };
});
scene.updateConnector(uiState.mode.id, newConnector);
scene.updateConnector(connectorMode.id!, newConnector);
} else {
const newConnector = produce(connector.value, (draft) => {
const newConnector = produce(connectorItem, (draft) => {
draft.anchors[1] = {
id: generateId(),
ref: { tile: uiState.mouse.position.tile }
};
});
scene.updateConnector(uiState.mode.id, newConnector);
scene.updateConnector(connectorMode.id!, newConnector);
}
}
},
@@ -99,11 +107,28 @@ export const Connector: ModeActions = {
});
} else {
// Second click: complete the connection
if (uiState.mode.id) {
const connector = getItemByIdOrThrow(scene.connectors, uiState.mode.id);
// We already checked mode.type === 'CONNECTOR' above
const currentMode = uiState.mode;
if (currentMode.id) {
// Try to find the connector - it might not exist
const connector = (scene.currentView.connectors ?? []).find(
c => c.id === currentMode.id
);
// If connector doesn't exist, reset mode and return
if (!connector) {
uiState.actions.setMode({
type: 'CONNECTOR',
showCursor: true,
id: null,
startAnchor: undefined,
isConnecting: false
});
return;
}
// Update the second anchor to the click position
const newConnector = produce(connector.value, (draft) => {
const newConnector = produce(connector, (draft) => {
if (itemAtTile?.type === 'ITEM') {
draft.anchors[1] = { id: generateId(), ref: { item: itemAtTile.id } };
} else {
@@ -114,18 +139,10 @@ export const Connector: ModeActions = {
}
});
scene.updateConnector(uiState.mode.id, newConnector);
scene.updateConnector(currentMode.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);
}
// Don't delete connectors to empty space - they're valid
// Only validate minimum path length will be handled by the update
// Reset for next connection
uiState.actions.setMode({
@@ -171,17 +188,8 @@ export const Connector: ModeActions = {
// 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);
}
// Don't delete connectors to empty space - they're valid
// Validation is handled in the reducer layer
uiState.actions.setMode({
type: 'CONNECTOR',