feat: Add advanced pan controls with configurable options

Implements multiple pan methods to improve canvas navigation:

Mouse controls:
- Click and drag on empty area
- Middle mouse button pan
- Right click pan (disables context menu when enabled)
- Ctrl + click pan
- Alt + click pan

Keyboard controls:
- Arrow keys navigation
- WASD keys navigation
- IJKL keys navigation
- Adjustable pan speed (5-50 pixels)

All options are individually configurable through the Settings dialog
with a dedicated Pan Controls tab. Default settings enable empty area
drag and arrow keys for intuitive behavior.

Also updates README.md to document recent feature additions.

Closes #25 (click + drag on empty area)
Related to improved UX requests
This commit is contained in:
Stan
2025-08-14 12:07:58 +01:00
parent ef258dff17
commit 83c9b3aed2
10 changed files with 450 additions and 26 deletions

View File

@@ -7,21 +7,25 @@ FossFLOW is a powerful, open-source Progressive Web App (PWA) for creating beaut
- **📝 [FOSSFLOW_TODO.md](https://github.com/stan-smith/FossFLOW/blob/main/ISOFLOW_TODO.md)** - Current issues and roadmap with codebase mappings, most gripes are with the isoflow library itself.
- **🤝 [CONTRIBUTORS.md](https://github.com/stan-smith/FossFLOW/blob/main/CONTRIBUTORS.md)** - How to contribute to the project.
## Recent Updates (August 2025)
## Recent Updates (August 2025)
### Enhanced Interaction Features
- **Configurable Hotkeys** - Three profiles (QWERTY, SMNRCT, None) for tool selection with visual indicators
- **Advanced Pan Controls** - Multiple pan methods including empty area drag, middle/right click, modifier keys (Ctrl/Alt), and keyboard navigation (Arrow/WASD/IJKL)
- **Toggle Connector Arrows** - Option to show/hide arrows on individual connectors
- **Persistent Tool Selection** - Connector tool remains active after creating connections
- **Settings Dialog** - Centralized configuration for hotkeys and pan controls
### Docker & CI/CD Improvements
- **Automated Docker Builds** - GitHub Actions workflow for automatic Docker Hub deployment on commits
- **Multi-architecture Support** - Docker images for both `linux/amd64` and `linux/arm64`
- **Pre-built Images** - Available at `stnsmith/fossflow:latest`
### Monorepo Architecture
We've successfully migrated from separate repositories to a unified monorepo structure, making development and contribution significantly easier:
- **Single repository** for both the library (`fossflow-lib`) and application (`fossflow-app`)
- **Single repository** for both library and application
- **NPM Workspaces** for streamlined dependency management
- **Instant library updates** available in the app during development
- **Unified build process** with `npm run build` at the root
### 🐳 Docker & Deployment Improvements
- **Multi-architecture Docker support** - Images now support both `linux/amd64` and `linux/arm64`
- **Docker Hub integration** - Pre-built images available at `stnsmith/fossflow:latest`
- **Simple deployment** - Just run `docker compose up` to deploy
- **Production-ready** - Nginx-based serving with optimized builds
### UI Fixes
- Fixed Quill editor toolbar icons display issue
- Resolved React key warnings in context menus

View File

@@ -0,0 +1,154 @@
import React from 'react';
import {
Box,
Typography,
FormControlLabel,
Switch,
Slider,
Paper,
Divider
} from '@mui/material';
import { useUiStateStore } from 'src/stores/uiStateStore';
export const PanSettings = () => {
const panSettings = useUiStateStore((state) => state.panSettings);
const setPanSettings = useUiStateStore((state) => state.actions.setPanSettings);
const handleToggle = (setting: keyof typeof panSettings) => {
if (typeof panSettings[setting] === 'boolean') {
setPanSettings({
...panSettings,
[setting]: !panSettings[setting]
});
}
};
const handleSpeedChange = (value: number) => {
setPanSettings({
...panSettings,
keyboardPanSpeed: value
});
};
return (
<Box sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Pan Settings
</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Mouse Pan Options
</Typography>
<FormControlLabel
control={
<Switch
checked={panSettings.emptyAreaClickPan}
onChange={() => handleToggle('emptyAreaClickPan')}
/>
}
label="Click and drag on empty area"
/>
<FormControlLabel
control={
<Switch
checked={panSettings.middleClickPan}
onChange={() => handleToggle('middleClickPan')}
/>
}
label="Middle click and drag"
/>
<FormControlLabel
control={
<Switch
checked={panSettings.rightClickPan}
onChange={() => handleToggle('rightClickPan')}
/>
}
label="Right click and drag"
/>
<FormControlLabel
control={
<Switch
checked={panSettings.ctrlClickPan}
onChange={() => handleToggle('ctrlClickPan')}
/>
}
label="Ctrl + click and drag"
/>
<FormControlLabel
control={
<Switch
checked={panSettings.altClickPan}
onChange={() => handleToggle('altClickPan')}
/>
}
label="Alt + click and drag"
/>
</Paper>
<Paper sx={{ p: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Keyboard Pan Options
</Typography>
<FormControlLabel
control={
<Switch
checked={panSettings.arrowKeysPan}
onChange={() => handleToggle('arrowKeysPan')}
/>
}
label="Arrow keys"
/>
<FormControlLabel
control={
<Switch
checked={panSettings.wasdPan}
onChange={() => handleToggle('wasdPan')}
/>
}
label="WASD keys"
/>
<FormControlLabel
control={
<Switch
checked={panSettings.ijklPan}
onChange={() => handleToggle('ijklPan')}
/>
}
label="IJKL keys"
/>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" gutterBottom>
Keyboard Pan Speed
</Typography>
<Box sx={{ px: 2 }}>
<Slider
value={panSettings.keyboardPanSpeed}
onChange={(_, value) => handleSpeedChange(value as number)}
min={5}
max={50}
step={5}
marks
valueLabelDisplay="auto"
/>
</Box>
</Paper>
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}>
Note: Pan options work in addition to the dedicated Pan tool
</Typography>
</Box>
);
};

View File

@@ -1,19 +1,24 @@
import React from 'react';
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
IconButton
IconButton,
Tabs,
Tab,
Box
} from '@mui/material';
import { Close as CloseIcon } from '@mui/icons-material';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { HotkeySettings } from '../HotkeySettings/HotkeySettings';
import { PanSettings } from '../PanSettings/PanSettings';
export const SettingsDialog = () => {
const dialog = useUiStateStore((state) => state.dialog);
const setDialog = useUiStateStore((state) => state.actions.setDialog);
const [tabValue, setTabValue] = useState(0);
const isOpen = dialog === 'SETTINGS';
@@ -21,11 +26,15 @@ export const SettingsDialog = () => {
setDialog(null);
};
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
};
return (
<Dialog
open={isOpen}
onClose={handleClose}
maxWidth="sm"
maxWidth="md"
fullWidth
>
<DialogTitle>
@@ -44,7 +53,15 @@ export const SettingsDialog = () => {
</IconButton>
</DialogTitle>
<DialogContent dividers>
<HotkeySettings />
<Tabs value={tabValue} onChange={handleTabChange} sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tab label="Hotkeys" />
<Tab label="Pan Controls" />
</Tabs>
<Box sx={{ mt: 2 }}>
{tabValue === 0 && <HotkeySettings />}
{tabValue === 1 && <PanSettings />}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Close</Button>

View File

@@ -0,0 +1,33 @@
export interface PanSettings {
// Mouse pan options
middleClickPan: boolean;
rightClickPan: boolean;
ctrlClickPan: boolean;
altClickPan: boolean;
emptyAreaClickPan: boolean;
// Keyboard pan options
arrowKeysPan: boolean;
wasdPan: boolean;
ijklPan: boolean;
// Pan speed
keyboardPanSpeed: number;
}
export const DEFAULT_PAN_SETTINGS: PanSettings = {
// Mouse options - start with common defaults
middleClickPan: true,
rightClickPan: false,
ctrlClickPan: false,
altClickPan: false,
emptyAreaClickPan: true,
// Keyboard options
arrowKeysPan: true,
wasdPan: false,
ijklPan: false,
// Pan speed (pixels per key press)
keyboardPanSpeed: 20
};

View File

@@ -28,12 +28,8 @@ export const Pan: ModeActions = {
setWindowCursor('grabbing');
},
mouseup: ({ uiState }) => {
if (uiState.mode.type !== 'PAN') return;
setWindowCursor('grab');
// Always revert to CURSOR mode after panning
uiState.actions.setMode({
type: 'CURSOR',
showCursor: true,
mousedownItem: null
});
// Note: Mode switching is now handled by usePanHandlers
}
};

View File

@@ -17,6 +17,7 @@ import { Connector } from './modes/Connector';
import { Pan } from './modes/Pan';
import { PlaceIcon } from './modes/PlaceIcon';
import { TextBox } from './modes/TextBox';
import { usePanHandlers } from './usePanHandlers';
const modes: { [k in string]: ModeActions } = {
CURSOR: Cursor,
@@ -55,6 +56,7 @@ export const useInteractionManager = () => {
const { size: rendererSize } = useResizeObserver(uiState.rendererEl);
const { undo, redo, canUndo, canRedo } = useHistory();
const { createTextBox } = scene;
const { handleMouseDown: handlePanMouseDown, handleMouseUp: handlePanMouseUp } = usePanHandlers();
// Keyboard shortcuts for undo/redo
useEffect(() => {
@@ -165,6 +167,14 @@ export const useInteractionManager = () => {
(e: SlimMouseEvent) => {
if (!rendererRef.current) return;
// Check pan handlers first
if (e.type === 'mousedown' && handlePanMouseDown(e)) {
return;
}
if (e.type === 'mouseup' && handlePanMouseUp(e)) {
return;
}
const mode = modes[uiState.mode.type];
const modeFunction = getModeFunction(mode, e);
@@ -207,13 +217,18 @@ export const useInteractionManager = () => {
modeFunction(baseState);
reducerTypeRef.current = uiState.mode.type;
},
[model, scene, uiState, rendererSize]
[model, scene, uiState, rendererSize, handlePanMouseDown, handlePanMouseUp]
);
const onContextMenu = useCallback(
(e: SlimMouseEvent) => {
e.preventDefault();
// Don't show context menu if right-click pan is enabled
if (uiState.panSettings.rightClickPan) {
return;
}
const itemAtTile = getItemAtTile({
tile: uiState.mouse.position.tile,
scene
@@ -232,7 +247,7 @@ export const useInteractionManager = () => {
});
}
},
[uiState.mouse, scene, uiState.actions]
[uiState.mouse, scene, uiState.actions, uiState.panSettings]
);
useEffect(() => {
@@ -245,7 +260,8 @@ export const useInteractionManager = () => {
...e,
clientX: Math.floor(e.touches[0].clientX),
clientY: Math.floor(e.touches[0].clientY),
type: 'mousedown'
type: 'mousedown',
button: 0
});
};
@@ -254,7 +270,8 @@ export const useInteractionManager = () => {
...e,
clientX: Math.floor(e.touches[0].clientX),
clientY: Math.floor(e.touches[0].clientY),
type: 'mousemove'
type: 'mousemove',
button: 0
});
};
@@ -263,7 +280,8 @@ export const useInteractionManager = () => {
...e,
clientX: 0,
clientY: 0,
type: 'mouseup'
type: 'mouseup',
button: 0
});
};

View File

@@ -0,0 +1,194 @@
import { useCallback, useEffect, useRef } from 'react';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { CoordsUtils, getItemAtTile } from 'src/utils';
import { useScene } from 'src/hooks/useScene';
import { SlimMouseEvent } from 'src/types';
export const usePanHandlers = () => {
const uiState = useUiStateStore((state) => state);
const scene = useScene();
const isPanningRef = useRef(false);
const panMethodRef = useRef<string | null>(null);
// Helper to start panning
const startPan = useCallback((method: string) => {
if (uiState.mode.type !== 'PAN') {
isPanningRef.current = true;
panMethodRef.current = method;
uiState.actions.setMode({
type: 'PAN',
showCursor: false
});
}
}, [uiState.mode.type, uiState.actions]);
// Helper to end panning
const endPan = useCallback(() => {
if (isPanningRef.current) {
isPanningRef.current = false;
panMethodRef.current = null;
uiState.actions.setMode({
type: 'CURSOR',
showCursor: true,
mousedownItem: null
});
}
}, [uiState.actions]);
// Check if click is on empty area
const isEmptyArea = useCallback((e: SlimMouseEvent): boolean => {
if (!uiState.rendererEl || e.target !== uiState.rendererEl) return false;
const itemAtTile = getItemAtTile({
tile: uiState.mouse.position.tile,
scene
});
return !itemAtTile;
}, [uiState.rendererEl, uiState.mouse.position.tile, scene]);
// Enhanced mouse down handler
const handleMouseDown = useCallback((e: SlimMouseEvent): boolean => {
const panSettings = uiState.panSettings;
// Middle click pan
if (panSettings.middleClickPan && e.button === 1) {
e.preventDefault();
startPan('middle');
return true;
}
// Right click pan
if (panSettings.rightClickPan && e.button === 2) {
e.preventDefault();
startPan('right');
return true;
}
// Ctrl + click pan
if (panSettings.ctrlClickPan && e.ctrlKey && e.button === 0) {
e.preventDefault();
startPan('ctrl');
return true;
}
// Alt + click pan
if (panSettings.altClickPan && e.altKey && e.button === 0) {
e.preventDefault();
startPan('alt');
return true;
}
// Empty area click pan
if (panSettings.emptyAreaClickPan && e.button === 0 && isEmptyArea(e)) {
startPan('empty');
return true;
}
return false;
}, [uiState.panSettings, startPan, isEmptyArea]);
// Enhanced mouse up handler
const handleMouseUp = useCallback((e: SlimMouseEvent): boolean => {
if (isPanningRef.current) {
endPan();
return true;
}
return false;
}, [endPan]);
// Keyboard pan handler
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't handle if typing in input fields
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.contentEditable === 'true' ||
target.closest('.ql-editor')
) {
return;
}
const panSettings = uiState.panSettings;
const speed = panSettings.keyboardPanSpeed;
let dx = 0;
let dy = 0;
// Arrow keys
if (panSettings.arrowKeysPan) {
if (e.key === 'ArrowUp') {
dy = speed;
e.preventDefault();
} else if (e.key === 'ArrowDown') {
dy = -speed;
e.preventDefault();
} else if (e.key === 'ArrowLeft') {
dx = speed;
e.preventDefault();
} else if (e.key === 'ArrowRight') {
dx = -speed;
e.preventDefault();
}
}
// WASD keys
if (panSettings.wasdPan) {
const key = e.key.toLowerCase();
if (key === 'w') {
dy = speed;
e.preventDefault();
} else if (key === 's') {
dy = -speed;
e.preventDefault();
} else if (key === 'a') {
dx = speed;
e.preventDefault();
} else if (key === 'd') {
dx = -speed;
e.preventDefault();
}
}
// IJKL keys
if (panSettings.ijklPan) {
const key = e.key.toLowerCase();
if (key === 'i') {
dy = speed;
e.preventDefault();
} else if (key === 'k') {
dy = -speed;
e.preventDefault();
} else if (key === 'j') {
dx = speed;
e.preventDefault();
} else if (key === 'l') {
dx = -speed;
e.preventDefault();
}
}
// Apply pan if any movement
if (dx !== 0 || dy !== 0) {
const newPosition = CoordsUtils.add(
uiState.scroll.position,
{ x: dx, y: dy }
);
uiState.actions.setScroll({
position: newPosition,
offset: uiState.scroll.offset
});
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [uiState.panSettings, uiState.scroll, uiState.actions]);
return {
handleMouseDown,
handleMouseUp,
isPanning: isPanningRef.current
};
};

View File

@@ -9,6 +9,7 @@ import {
import { UiStateStore } from 'src/types';
import { INITIAL_UI_STATE } from 'src/config';
import { DEFAULT_HOTKEY_PROFILE } from 'src/config/hotkeys';
import { DEFAULT_PAN_SETTINGS } from 'src/config/panSettings';
const initialState = () => {
return createStore<UiStateStore>((set, get) => {
@@ -32,6 +33,7 @@ const initialState = () => {
itemControls: null,
enableDebugTools: false,
hotkeyProfile: DEFAULT_HOTKEY_PROFILE,
panSettings: DEFAULT_PAN_SETTINGS,
actions: {
setView: (view) => {
set({ view });
@@ -96,6 +98,9 @@ const initialState = () => {
},
setHotkeyProfile: (hotkeyProfile) => {
set({ hotkeyProfile });
},
setPanSettings: (panSettings) => {
set({ panSettings });
}
}
};

View File

@@ -22,7 +22,7 @@ export type BoundingBox = [Coords, Coords, Coords, Coords];
export type SlimMouseEvent = Pick<
MouseEvent,
'clientX' | 'clientY' | 'target' | 'type' | 'preventDefault'
'clientX' | 'clientY' | 'target' | 'type' | 'preventDefault' | 'button' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey'
>;
export const EditorModeEnum = {

View File

@@ -2,6 +2,7 @@ import { Coords, EditorModeEnum, MainMenuOptions } from './common';
import { Icon } from './model';
import { ItemReference } from './scene';
import { HotkeyProfile } from 'src/config/hotkeys';
import { PanSettings } from 'src/config/panSettings';
interface AddItemControls {
type: 'ADD_ITEM';
@@ -151,6 +152,7 @@ export interface UiState {
rendererEl: HTMLDivElement | null;
enableDebugTools: boolean;
hotkeyProfile: HotkeyProfile;
panSettings: PanSettings;
}
export interface UiStateActions {
@@ -172,6 +174,7 @@ export interface UiStateActions {
setRendererEl: (el: HTMLDivElement) => void;
setEnableDebugTools: (enabled: boolean) => void;
setHotkeyProfile: (profile: HotkeyProfile) => void;
setPanSettings: (settings: PanSettings) => void;
}
export type UiStateStore = UiState & {