diff --git a/.gitignore b/.gitignore index 2fa56e49..3802c17d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +documentation.json \ No newline at end of file diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 00000000..0e30ad95 --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,343 @@ +# Contributing to Isoflow + +Thank you for your interest in contributing to Isoflow! This guide will help you get started with contributing to the project. + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Getting Started](#getting-started) +- [Development Setup](#development-setup) +- [Project Structure](#project-structure) +- [How to Contribute](#how-to-contribute) +- [Development Workflow](#development-workflow) +- [Coding Standards](#coding-standards) +- [Testing](#testing) +- [Submitting Changes](#submitting-changes) +- [Community](#community) + +## Code of Conduct + +By participating in this project, you agree to abide by our Code of Conduct: + +- **Be respectful**: Treat everyone with respect. No harassment, discrimination, or inappropriate behavior. +- **Be collaborative**: Work together to resolve conflicts and assume good intentions. +- **Be patient**: Remember that everyone has different levels of experience. +- **Be welcoming**: Help new contributors feel welcome and supported. + +## Getting Started + +### Prerequisites + +- Node.js (v16 or higher) +- npm or yarn +- Git +- A code editor (VS Code recommended) + +### Quick Start + +1. Fork the repository on GitHub +2. Clone your fork: + ```bash + git clone https://github.com/YOUR_USERNAME/isoflow.git + cd isoflow + ``` +3. Install dependencies: + ```bash + cd isoflow + npm install + ``` +4. Start the development server: + ```bash + npm start + ``` +5. Open http://localhost:3000 in your browser + +## Development Setup + +### IDE Setup (VS Code) + +Recommended extensions: +- ESLint +- Prettier +- TypeScript and JavaScript Language Features + +### Environment Setup + +1. **Install dependencies**: + ```bash + npm install + ``` + +2. **Available scripts**: + ```bash + npm start # Start development server + npm run dev # Watch mode for library development + npm run build # Production build + npm test # Run tests + npm run lint # Check for linting errors + npm run lint:fix # Auto-fix linting errors + ``` + +## Project Structure + +For detailed project structure, see [ISOFLOW_ENCYCLOPEDIA.md](./ISOFLOW_ENCYCLOPEDIA.md). Key directories: + +``` +isoflow/ +├── src/ +│ ├── components/ # React components +│ ├── stores/ # State management (Zustand) +│ ├── hooks/ # Custom React hooks +│ ├── interaction/ # User interaction handling +│ ├── types/ # TypeScript types +│ └── utils/ # Utility functions +├── docs/ # Documentation site +└── webpack/ # Build configurations +``` + +## How to Contribute + +### Finding Issues to Work On + +1. Check the [Issues](https://github.com/stan-smith/isoflow/issues) page +2. Look for issues labeled: + - `good first issue` - Great for newcomers + - `help wanted` - Community help needed + - `bug` - Bug fixes + - `enhancement` - New features + +3. Check [ISOFLOW_TODO.md](./ISOFLOW_TODO.md) for prioritized tasks + +### Types of Contributions + +We welcome all types of contributions: + +- **Bug fixes**: Help us squash bugs +- **Features**: Implement new functionality +- **Documentation**: Improve docs, add examples +- **Tests**: Increase test coverage +- **UI/UX improvements**: Make Isoflow better to use +- **Performance**: Optimize code for better performance + +## Development Workflow + +### 1. Create a Branch + +```bash +git checkout -b feature/your-feature-name +# or +git checkout -b fix/bug-description +``` + +Branch naming conventions: +- `feature/` - New features +- `fix/` - Bug fixes +- `docs/` - Documentation updates +- `refactor/` - Code refactoring +- `test/` - Test additions/updates + +### 2. Make Your Changes + +- Write clean, readable code +- Follow existing patterns in the codebase +- Add comments for complex logic +- Update tests if needed +- Update documentation if needed + +### 3. Test Your Changes + +```bash +# Run tests +npm test + +# Run linting +npm run lint + +# Test in development +npm start +``` + +### 4. Commit Your Changes + +We follow conventional commits: + +```bash +git commit -m "feat: add undo/redo functionality" +git commit -m "fix: prevent menu from opening during drag" +git commit -m "docs: update installation instructions" +``` + +Commit types: +- `feat`: New feature +- `fix`: Bug fix +- `docs`: Documentation changes +- `style`: Code style changes (formatting, etc.) +- `refactor`: Code changes that neither fix bugs nor add features +- `test`: Adding or updating tests +- `chore`: Maintenance tasks + +## Coding Standards + +### TypeScript + +- Use TypeScript for all new code +- Avoid `any` types +- Define interfaces for component props +- Use meaningful variable and function names + +Example: +```typescript +interface NodeProps { + id: string; + position: { x: number; y: number }; + icon: IconType; + isSelected?: boolean; +} + +const Node: React.FC = ({ id, position, icon, isSelected = false }) => { + // Component implementation +}; +``` + +### React + +- Use functional components with hooks +- Keep components focused and small +- Use custom hooks for reusable logic +- Memoize expensive computations + +### State Management + +- Use Zustand stores appropriately: + - `modelStore`: Business data + - `sceneStore`: Visual state + - `uiStateStore`: UI state +- Keep actions pure and predictable + +### Styling + +- Use Material-UI components when possible +- Follow existing styling patterns +- Use theme variables for colors +- Ensure responsive design + +## Testing + +### Running Tests + +```bash +npm test # Run all tests +npm test -- --watch # Watch mode +npm test -- --coverage # Coverage report +``` + +### Writing Tests + +- Write tests for new features +- Update tests when changing existing code +- Test edge cases and error scenarios +- Use meaningful test descriptions + +Example: +```typescript +describe('useIsoProjection', () => { + it('should convert tile coordinates to screen coordinates', () => { + const { tileToScreen } = useIsoProjection(); + const result = tileToScreen({ x: 1, y: 1 }); + expect(result).toEqual({ x: 100, y: 50 }); + }); +}); +``` + +## Submitting Changes + +### Pull Request Process + +1. **Update your fork**: + ```bash + git remote add upstream https://github.com/markmanx/isoflow.git + git fetch upstream + git checkout main + git merge upstream/main + ``` + +2. **Push your branch**: + ```bash + git push origin feature/your-feature-name + ``` + +3. **Create Pull Request**: + - Go to GitHub and create a PR from your branch + - Fill out the PR template + - Link related issues + - Add screenshots/GIFs for UI changes + +### PR Title Format + +Follow the same convention as commits: +- `feat: Add undo/redo functionality` +- `fix: Prevent menu from opening during drag` + +### PR Description Template + +```markdown +## Description +Brief description of changes + +## Related Issue +Fixes #123 + +## Type of Change +- [ ] Bug fix +- [ ] New feature +- [ ] Documentation update +- [ ] Performance improvement + +## Testing +- [ ] Tests pass locally +- [ ] Added new tests +- [ ] Manual testing completed + +## Screenshots (if applicable) +Add screenshots or GIFs here +``` + +### Code Review + +- Be open to feedback +- Respond to review comments +- Make requested changes promptly +- Ask questions if something is unclear + +## Community + +### Getting Help + +- **GitHub Issues**: For bugs and feature requests +- **Discussions**: For questions and ideas +- **Code Encyclopedia**: See [ISOFLOW_ENCYCLOPEDIA.md](./ISOFLOW_ENCYCLOPEDIA.md) +- **TODO List**: See [ISOFLOW_TODO.md](./ISOFLOW_TODO.md) + +### Communication Guidelines + +- Be clear and concise +- Provide context and examples +- Search existing issues before creating new ones +- Use issue templates when available + +## Recognition + +Contributors will be recognized in: +- The project README +- Release notes +- Contributors list on GitHub + +## License + +By contributing to Isoflow, you agree that your contributions will be licensed under the Unlicense License. + +--- + +Thank you for contributing to OpenFLOW! Your efforts help make this project better for everyone. If you have any questions, don't hesitate to ask in the issues or discussions. + +-S \ No newline at end of file diff --git a/ISOFLOW_ENCYCLOPEDIA.md b/ISOFLOW_ENCYCLOPEDIA.md new file mode 100644 index 00000000..d8ab8219 --- /dev/null +++ b/ISOFLOW_ENCYCLOPEDIA.md @@ -0,0 +1,360 @@ +# Isoflow Codebase Encyclopedia + +## Overview + +Isoflow is an open-source React component for drawing isometric network diagrams. This encyclopedia provides a comprehensive guide to navigating and understanding the codebase structure, making it easy to locate specific functionality and understand the architecture. + +## Table of Contents + +1. [Project Structure](#project-structure) +2. [Core Architecture](#core-architecture) +3. [State Management](#state-management) +4. [Component Organization](#component-organization) +5. [Key Technologies](#key-technologies) +6. [Build System](#build-system) +7. [Testing Structure](#testing-structure) +8. [Development Workflow](#development-workflow) + +## Project Structure + +``` +isoflow/ +├── src/ # Source code +│ ├── Isoflow.tsx # Main component entry point +│ ├── index.tsx # Development entry point +│ ├── config.ts # Configuration constants +│ ├── components/ # React components +│ ├── stores/ # State management (Zustand) +│ ├── hooks/ # Custom React hooks +│ ├── types/ # TypeScript type definitions +│ ├── schemas/ # Zod validation schemas +│ ├── interaction/ # Interaction handling +│ ├── utils/ # Utility functions +│ ├── assets/ # Static assets +│ └── styles/ # Styling (theme, global styles) +├── docs/ # Documentation website (Nextra) +├── webpack/ # Webpack configurations +├── package.json # Dependencies and scripts +└── tsconfig.json # TypeScript configuration +``` + +## Core Architecture + +### Entry Points + +- **`src/index.tsx`**: Development mode entry with examples +- **`src/Isoflow.tsx`**: Main component for library usage +- **`src/index-docker.tsx`**: Docker-specific entry point + +### Provider Hierarchy + +```typescript + + // Core data model + // Visual state + // UI interaction state + + // Canvas rendering + // UI controls + + + + + +``` + +### Data Flow + +1. **Model Data** → Items, Views, Icons, Colors +2. **Scene Data** → Connector paths, Text box sizes +3. **UI State** → Zoom, Pan, Selection, Mode + +## State Management + +### 1. ModelStore (`src/stores/modelStore.tsx`) + +**Purpose**: Core business data + +**Key Data**: +- `items`: Diagram elements (nodes) +- `views`: Different diagram perspectives +- `icons`: Available icon library +- `colors`: Color palette + +**Location**: `/src/stores/modelStore.tsx` +**Types**: `/src/types/model.ts` + +### 2. SceneStore (`src/stores/sceneStore.tsx`) + +**Purpose**: Visual/rendering state + +**Key Data**: +- `connectors`: Path and position data +- `textBoxes`: Size information + +**Location**: `/src/stores/sceneStore.tsx` +**Types**: `/src/types/scene.ts` + +### 3. UiStateStore (`src/stores/uiStateStore.tsx`) + +**Purpose**: User interface state + +**Key Data**: +- `zoom`: Current zoom level +- `scroll`: Viewport position +- `mode`: Interaction mode +- `editorMode`: Edit/readonly state + +**Location**: `/src/stores/uiStateStore.tsx` +**Types**: `/src/types/ui.ts` + +## Component Organization + +### Core Components + +#### Renderer (`src/components/Renderer/`) +- **Purpose**: Main canvas rendering +- **Key Files**: + - `Renderer.tsx`: Container component +- **Renders**: All visual layers + +#### UiOverlay (`src/components/UiOverlay/`) +- **Purpose**: UI controls overlay +- **Key Files**: + - `UiOverlay.tsx`: Control panel container + +#### SceneLayer (`src/components/SceneLayer/`) +- **Purpose**: Transformable layer wrapper +- **Uses**: GSAP for animations +- **Key Files**: + - `SceneLayer.tsx`: Transform container + +### Scene Layers (`src/components/SceneLayers/`) + +#### Nodes (`/Nodes/`) +- **Purpose**: Render diagram nodes/icons +- **Key Files**: + - `Node.tsx`: Individual node component + - `Nodes.tsx`: Node collection renderer +- **Icon Types**: + - `IsometricIcon.tsx`: 3D-style icons + - `NonIsometricIcon.tsx`: Flat icons + +#### Connectors (`/Connectors/`) +- **Purpose**: Lines between nodes +- **Key Files**: + - `Connector.tsx`: Individual connector + - `Connectors.tsx`: Connector collection + +#### Rectangles (`/Rectangles/`) +- **Purpose**: Background shapes/regions +- **Key Files**: + - `Rectangle.tsx`: Individual rectangle + - `Rectangles.tsx`: Rectangle collection + +#### TextBoxes (`/TextBoxes/`) +- **Purpose**: Text annotations +- **Key Files**: + - `TextBox.tsx`: Individual text box + - `TextBoxes.tsx`: Text box collection + +### UI Components + +#### MainMenu (`src/components/MainMenu/`) +- **Purpose**: Application menu +- **Features**: Open, Export, Clear + +#### ToolMenu (`src/components/ToolMenu/`) +- **Purpose**: Drawing tools palette +- **Tools**: Select, Pan, Add Icon, Draw Rectangle, Add Text + +#### ItemControls (`src/components/ItemControls/`) +- **Purpose**: Property panels for selected items +- **Subdirectories**: + - `/NodeControls/`: Node properties + - `/ConnectorControls/`: Connector properties + - `/RectangleControls/`: Rectangle properties + - `/TextBoxControls/`: Text properties + - `/IconSelectionControls/`: Icon picker + +#### TransformControlsManager (`src/components/TransformControlsManager/`) +- **Purpose**: Selection and manipulation handles +- **Key Files**: + - `TransformAnchor.tsx`: Resize handles + - `NodeTransformControls.tsx`: Node-specific controls + +### Other Components + +- **Grid** (`/Grid/`): Isometric grid overlay +- **Cursor** (`/Cursor/`): Custom cursor display +- **ContextMenu** (`/ContextMenu/`): Right-click menus +- **ZoomControls** (`/ZoomControls/`): Zoom in/out buttons +- **ColorSelector** (`/ColorSelector/`): Color picker UI +- **ExportImageDialog** (`/ExportImageDialog/`): Export to PNG dialog + +## Key Technologies + +### Core Framework +- **React** (^18.2.0): UI framework +- **TypeScript** (^5.1.6): Type safety +- **Zustand** (^4.3.3): State management +- **Immer** (^10.0.2): Immutable updates + +### UI Libraries +- **Material-UI** (@mui/material ^5.11.10): Component library +- **Emotion** (@emotion/react): CSS-in-JS styling + +### Graphics & Animation +- **Paper.js** (^0.12.17): Vector graphics +- **GSAP** (^3.11.4): Animations +- **Pathfinding** (^0.4.18): Connector routing + +### Validation & Forms +- **Zod** (3.22.2): Schema validation +- **React Hook Form** (^7.43.2): Form handling + +### Build Tools +- **Webpack** (^5.76.2): Module bundler +- **Jest** (^29.5.0): Testing framework + +## Build System + +### Webpack Configurations + +- **Development**: `/webpack/dev.config.js` + - Hot reload enabled + - Source maps + - Development server + +- **Production**: `/webpack/prod.config.js` + - Minification + - Optimizations + - Library output + +- **Docker**: `/webpack/docker.config.js` + - Docker-specific build + +### NPM Scripts + +```bash +npm start # Development server +npm run dev # Watch mode +npm run build # Production build +npm test # Run tests +npm run lint # TypeScript + ESLint +npm run lint:fix # Auto-fix issues +``` + +## Testing Structure + +### Test Files Location +- Unit tests: `__tests__` directories +- Test utilities: `/src/fixtures/` + +### Key Test Areas +- `/src/schemas/__tests__/`: Schema validation +- `/src/stores/reducers/__tests__/`: State logic +- `/src/utils/__tests__/`: Utility functions + +## Development Workflow + +### 1. Configuration (`src/config.ts`) + +**Key Constants**: +- `TILE_SIZE`: Base tile dimensions +- `DEFAULT_ZOOM`: Initial zoom level +- `DEFAULT_FONT_SIZE`: Text defaults +- `INITIAL_DATA`: Default model state + +### 2. Hooks Directory (`src/hooks/`) + +**Common Hooks**: +- `useScene.ts`: Merged scene data +- `useModelItem.ts`: Individual item access +- `useConnector.ts`: Connector management +- `useIsoProjection.ts`: Coordinate conversion +- `useDiagramUtils.ts`: Diagram operations + +### 3. Interaction System (`src/interaction/`) + +**Main File**: `useInteractionManager.ts` + +**Interaction Modes** (`/modes/`): +- `Cursor.ts`: Selection mode +- `Pan.ts`: Canvas panning +- `PlaceIcon.ts`: Icon placement +- `Connector.ts`: Drawing connections +- `DragItems.ts`: Moving elements +- `Rectangle/`: Rectangle tools +- `TextBox.ts`: Text editing + +### 4. Utilities (`src/utils/`) + +**Key Utilities**: +- `CoordsUtils.ts`: Coordinate calculations +- `SizeUtils.ts`: Size computations +- `renderer.ts`: Rendering helpers +- `model.ts`: Model manipulation +- `pathfinder.ts`: Connector routing + +### 5. Type System (`src/types/`) + +**Core Types**: +- `model.ts`: Business data types +- `scene.ts`: Visual state types +- `ui.ts`: Interface types +- `common.ts`: Shared types +- `interactions.ts`: Interaction types + +### 6. Schema Validation (`src/schemas/`) + +**Validation Schemas**: +- `model.ts`: Model validation +- `connector.ts`: Connector validation +- `rectangle.ts`: Rectangle validation +- `textBox.ts`: Text box validation +- `views.ts`: View validation + +## Navigation Quick Reference + +### Need to modify... + +**Icons?** → `/src/components/ItemControls/IconSelectionControls/` +**Node rendering?** → `/src/components/SceneLayers/Nodes/` +**Connector drawing?** → `/src/components/SceneLayers/Connectors/` +**Zoom behavior?** → `/src/stores/uiStateStore.tsx` + `/src/components/ZoomControls/` +**Grid display?** → `/src/components/Grid/` +**Export functionality?** → `/src/components/ExportImageDialog/` +**Color picker?** → `/src/components/ColorSelector/` +**Context menus?** → `/src/components/ContextMenu/` +**Keyboard shortcuts?** → `/src/interaction/useInteractionManager.ts` +**Tool selection?** → `/src/components/ToolMenu/` +**Selection handles?** → `/src/components/TransformControlsManager/` + +### Want to understand... + +**How items are positioned?** → `/src/hooks/useIsoProjection.ts` +**How connectors find paths?** → `/src/utils/pathfinder.ts` +**How state updates work?** → `/src/stores/reducers/` +**How validation works?** → `/src/schemas/` +**Available icons?** → `/src/fixtures/icons.ts` +**Default configurations?** → `/src/config.ts` + +## Key Files Reference + +| Purpose | File Path | +|---------|-----------| +| Main entry | `/src/Isoflow.tsx` | +| Configuration | `/src/config.ts` | +| Model types | `/src/types/model.ts` | +| UI state types | `/src/types/ui.ts` | +| Model store | `/src/stores/modelStore.tsx` | +| Scene store | `/src/stores/sceneStore.tsx` | +| UI store | `/src/stores/uiStateStore.tsx` | +| Main renderer | `/src/components/Renderer/Renderer.tsx` | +| UI overlay | `/src/components/UiOverlay/UiOverlay.tsx` | +| Interaction manager | `/src/interaction/useInteractionManager.ts` | +| Coordinate utils | `/src/utils/CoordsUtils.ts` | +| Public API hook | `/src/hooks/useIsoflow.ts` | + +This encyclopedia serves as a comprehensive guide to the Isoflow codebase. Use the table of contents and quick references to efficiently navigate to the areas you need to modify or understand. \ No newline at end of file diff --git a/ISOFLOW_TODO.md b/ISOFLOW_TODO.md new file mode 100644 index 00000000..915889d8 --- /dev/null +++ b/ISOFLOW_TODO.md @@ -0,0 +1,139 @@ +# Isoflow TODO List + +Based on GitHub issues and community feedback. Each item includes relevant codebase locations for implementation. + +## High Priority - Core Functionality + +### 1. Implement Undo/Redo System +**Issue**: Undo functionality is mentioned in README but not working +**Priority**: HIGH +**Relevant Codebase Areas**: +- `/src/stores/modelStore.tsx` - Need to add history tracking +- `/src/stores/reducers/` - Modify reducers to support undo stack +- `/src/interaction/useInteractionManager.ts` - Add keyboard shortcuts (Ctrl+Z/Ctrl+Y) +- `/src/components/MainMenu/MainMenu.tsx` - Add undo/redo menu items +- Consider using a library like `zustand-middleware-immer` for undo/redo support + +### 2. Fix Tool Selection Persistence +**Issue**: Tool bar resets to select tool after every workspace click +**Priority**: HIGH +**Relevant Codebase Areas**: +- `/src/components/ToolMenu/ToolMenu.tsx` - Tool selection logic +- `/src/stores/uiStateStore.tsx` - Look at `setMode()` function +- `/src/interaction/modes/` - Each mode's exit behavior +- `/src/interaction/useInteractionManager.ts` - Mode switching logic + +### 3. Improve Connection Drawing UX +**Issue**: Connections require right-click (not intuitive), no instructions +**Priority**: HIGH +**Relevant Codebase Areas**: +- `/src/interaction/modes/Connector.ts` - Connection drawing logic +- `/src/components/SceneLayers/Connectors/Connector.tsx` - Visual feedback +- `/src/components/ContextMenu/ContextMenu.tsx` - Right-click behavior +- Consider adding visual hints or changing to left-click drag + +## Medium Priority - UX Improvements + +### 4. Reduce Item Addition Friction +**Issue**: Adding items requires 3+ clicks +**Priority**: MEDIUM +**Suggestions**: +- Double-click empty cell to open icon picker +- Drag-and-drop from icon panel +- Quick-add shortcuts +**Relevant Codebase Areas**: +- `/src/interaction/modes/PlaceIcon.ts` - Icon placement logic +- `/src/components/ItemControls/IconSelectionControls/` - Icon picker UI +- `/src/interaction/useInteractionManager.ts` - Add double-click handler +- `/src/components/DragAndDrop/DragAndDrop.tsx` - Potential drag-drop implementation + +### 5. Fix Automatic Menu Opening +**Issue**: Menu opens automatically when clicking component, opens on drag +**Priority**: MEDIUM +**Suggestions**: +- Add gear icon or three-dots menu trigger +- Prevent menu on drag operations +**Relevant Codebase Areas**: +- `/src/components/ItemControls/ItemControlsManager.tsx` - Control when panel shows +- `/src/stores/uiStateStore.tsx` - `setItemControls()` function +- `/src/interaction/modes/Cursor.ts` - Selection behavior +- `/src/interaction/modes/DragItems.ts` - Drag detection + +### 6. Add Menu Trigger for Empty Cells +**Issue**: Should be able to open add menu when clicking empty cell +**Priority**: MEDIUM +**Relevant Codebase Areas**: +- `/src/interaction/modes/Cursor.ts` - Click on empty space handling +- `/src/components/ContextMenu/ContextMenu.tsx` - Context menu for empty space +- `/src/components/UiOverlay/UiOverlay.tsx` - UI overlay management + +## Low Priority - Documentation & Project Setup + +### 7. Create CONTRIBUTORS.md +**Issue**: Need contribution guidelines +**Priority**: LOW +**Content to Include**: +- Development setup instructions +- Code style guidelines +- PR process +- Testing requirements +- Architecture overview (link to encyclopedia) + +### 8. Create Project TODO/Roadmap +**Issue**: Contributors don't know where to start +**Priority**: LOW +**Suggestions**: +- Public roadmap +- Good first issues +- Feature requests board + +## Implementation Notes + +### State Management Considerations +Most features will require modifications to: +1. **Model Store** (`/src/stores/modelStore.tsx`) - For undo/redo history +2. **UI State Store** (`/src/stores/uiStateStore.tsx`) - For mode persistence +3. **Interaction Manager** (`/src/interaction/useInteractionManager.ts`) - For new interactions + +### Testing Requirements +Each feature should include: +- Unit tests in `__tests__` directories +- Integration tests for interaction flows +- Update to `/docs/` if behavior changes + +### Design Patterns to Follow +1. Use existing interaction mode pattern in `/src/interaction/modes/` +2. Follow component structure in `/src/components/` +3. Use Zustand actions pattern for state updates +4. Maintain TypeScript strict typing + +## Quick Wins (Good First Issues) + +1. **Add keyboard shortcut hints** + - Location: `/src/components/ToolMenu/ToolMenu.tsx` + - Add tooltips showing shortcuts + +2. **Fix context menu on drag** + - Location: `/src/interaction/modes/DragItems.ts` + - Add flag to prevent menu during drag + +3. **Add connection instructions** + - Location: `/src/components/ToolMenu/ToolMenu.tsx` or status bar + - Show hint when connector tool selected + +## Architecture Decisions Needed + +1. **Undo/Redo Implementation** + - Option A: Middleware approach with Zustand + - Option B: Command pattern with action history + - Option C: Immer patches for efficient history + +2. **Tool Persistence** + - Option A: Keep tool selected until explicitly changed + - Option B: Smart mode - persist for multiple operations + - Option C: User preference setting + +3. **Connection UX** + - Option A: Left-click drag from node to node + - Option B: Click source, then click target + - Option C: Keep right-click but add visual hints \ No newline at end of file diff --git a/README.md b/README.md index bc54008f..ff3a099e 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,15 @@ -![readme-header](https://user-images.githubusercontent.com/1769678/223572353-788d5d38-cd28-40fa-96cd-9d29226f7e4b.png) +# Isoflow - Modified for FossFLOW -

- Online playground | - Developer docs | - Github | - Discord | - Docker image -

+A React component for drawing network diagrams. +Please direct your TODOs to this repository. -
-

A React component for drawing network diagrams.

-
+## Documentation -
+- **📖 [ISOFLOW_ENCYCLOPEDIA.md](https://github.com/stan-smith/isoflow/blob/main/ISOFLOW_ENCYCLOPEDIA.md)** - Comprehensive guide to the codebase structure and navigation +- **📝 [ISOFLOW_TODO.md](https://github.com/stan-smith/isoflow/blob/main/ISOFLOW_TODO.md)** - Current issues and roadmap with codebase mappings +- **🤝 [CONTRIBUTORS.md](https://github.com/stan-smith/isoflow/blob/main/CONTRIBUTORS.md)** - How to contribute to the project -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -![CircleCI](https://circleci.com/gh/markmanx/isoflow.svg?style=shield) +## Quick Links -
- -## About Isoflow Community Edition -Isoflow is an open-core project. We offer the [Isoflow Community Edition](https://github.com/markmanx/isoflow) as fully-functional, open-source software under the MIT license. In addition, we also support our development efforts by offering **Isoflow Pro** with additional features for commercial use. You can read more about the differences between Pro and the Community Edition [here](https://isoflow.io/pro-vs-community-edition). - -## Key features -- **Drag-and-drop editor** - Express your architecture with icons, regions and connectors. -- **Extensible icon system** - Create your own icon library, or use plugins for existing libraries including AWS, Azure, GCP, Kubernetes, and more. -- **Export options** - Export diagrams as code or images. - -## Quick start - -Install both the editor and isopacks from [npm](https://www.npmjs.com/package/isoflow): - -- `npm install isoflow @isoflow/isopacks` - -See our [documentation](https://isoflow.io/docs) for more information. - -## Professional support -For professional support, please consider purchasing a license for Isoflow Pro. Isoflow Pro includes additional features and support options. For more information, visit [isoflow.io](https://isoflow.io). - -## Found a bug or need support? -Please report bugs and issues [here](https://github.com/markmanx/isoflow/issues), or on our [Discord server](https://discord.gg/QYPkvZth7D). \ No newline at end of file +- [Online Playground](https://codesandbox.io/p/sandbox/github/markmanx/isoflow) +- [Developer Docs](https://isoflow.io/docs) diff --git a/package-lock.json b/package-lock.json index 351718bc..58398572 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "isoflow", - "version": "1.0.11", + "version": "1.1.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "isoflow", - "version": "1.0.11", + "version": "1.1.1", "license": "MIT", "dependencies": { "@emotion/react": "^11.10.6", diff --git a/src/components/ItemControls/ConnectorControls/ConnectorControls.tsx b/src/components/ItemControls/ConnectorControls/ConnectorControls.tsx index c495896f..a6e9d7d9 100644 --- a/src/components/ItemControls/ConnectorControls/ConnectorControls.tsx +++ b/src/components/ItemControls/ConnectorControls/ConnectorControls.tsx @@ -20,6 +20,11 @@ export const ConnectorControls = ({ id }: Props) => { const connector = useConnector(id); const { updateConnector, deleteConnector } = useScene(); + // If connector doesn't exist, return null + if (!connector) { + return null; + } + return (
diff --git a/src/components/ItemControls/NodeControls/NodeControls.tsx b/src/components/ItemControls/NodeControls/NodeControls.tsx index db1f8a2a..28b7a063 100644 --- a/src/components/ItemControls/NodeControls/NodeControls.tsx +++ b/src/components/ItemControls/NodeControls/NodeControls.tsx @@ -35,12 +35,17 @@ export const NodeControls = ({ id }: Props) => { const viewItem = useViewItem(id); const modelItem = useModelItem(id); const { iconCategories } = useIconCategories(); - const { icon } = useIcon(modelItem.icon); + const { icon } = useIcon(modelItem?.icon || ''); const onSwitchMode = useCallback((newMode: Mode) => { setMode(newMode); }, []); + // If items don't exist, return null (component will unmount) + if (!viewItem || !modelItem) { + return null; + } + return ( { const modelItem = useModelItem(node.id); + if (!modelItem) { + return null; + } + return ( <>
diff --git a/src/components/ItemControls/RectangleControls/RectangleControls.tsx b/src/components/ItemControls/RectangleControls/RectangleControls.tsx index 9354ba83..7305c92c 100644 --- a/src/components/ItemControls/RectangleControls/RectangleControls.tsx +++ b/src/components/ItemControls/RectangleControls/RectangleControls.tsx @@ -19,6 +19,11 @@ export const RectangleControls = ({ id }: Props) => { const rectangle = useRectangle(id); const { updateRectangle, deleteRectangle } = useScene(); + // If rectangle doesn't exist, return null + if (!rectangle) { + return null; + } + return (
diff --git a/src/components/ItemControls/TextBoxControls/TextBoxControls.tsx b/src/components/ItemControls/TextBoxControls/TextBoxControls.tsx index f55f6c27..a764bb58 100644 --- a/src/components/ItemControls/TextBoxControls/TextBoxControls.tsx +++ b/src/components/ItemControls/TextBoxControls/TextBoxControls.tsx @@ -27,6 +27,11 @@ export const TextBoxControls = ({ id }: Props) => { const textBox = useTextBox(id); const { updateTextBox, deleteTextBox } = useScene(); + // If textBox doesn't exist, return null + if (!textBox) { + return null; + } + return (
diff --git a/src/components/MainMenu/MainMenu.tsx b/src/components/MainMenu/MainMenu.tsx index 7bf05803..bc6a3cd2 100644 --- a/src/components/MainMenu/MainMenu.tsx +++ b/src/components/MainMenu/MainMenu.tsx @@ -7,7 +7,9 @@ import { DataObject as ExportJsonIcon, ImageOutlined as ExportImageIcon, FolderOpen as FolderOpenIcon, - DeleteOutline as DeleteOutlineIcon + DeleteOutline as DeleteOutlineIcon, + Undo as UndoIcon, + Redo as RedoIcon } from '@mui/icons-material'; import { UiElement } from 'src/components/UiElement/UiElement'; import { IconButton } from 'src/components/IconButton/IconButton'; @@ -15,6 +17,7 @@ import { useUiStateStore } from 'src/stores/uiStateStore'; import { exportAsJSON, modelFromModelStore } from 'src/utils'; import { useInitialDataManager } from 'src/hooks/useInitialDataManager'; import { useModelStore } from 'src/stores/modelStore'; +import { useHistory } from 'src/hooks/useHistory'; import { MenuItem } from './MenuItem'; export const MainMenu = () => { @@ -32,6 +35,7 @@ export const MainMenu = () => { return state.actions; }); const initialDataManager = useInitialDataManager(); + const { undo, redo, canUndo, canRedo, clearHistory } = useHistory(); const onToggleMenu = useCallback( (event: React.MouseEvent) => { @@ -64,6 +68,7 @@ export const MainMenu = () => { fileReader.onload = async (e) => { const modelData = JSON.parse(e.target?.result as string); load(modelData); + clearHistory(); // Clear history when loading new model }; fileReader.readAsText(file); @@ -72,7 +77,7 @@ export const MainMenu = () => { await fileInput.click(); uiStateActions.setIsMainMenuOpen(false); - }, [uiStateActions, load]); + }, [uiStateActions, load, clearHistory]); const onExportAsJSON = useCallback(async () => { exportAsJSON(model); @@ -88,8 +93,19 @@ export const MainMenu = () => { const onClearCanvas = useCallback(() => { clear(); + clearHistory(); // Clear history when clearing canvas uiStateActions.setIsMainMenuOpen(false); - }, [uiStateActions, clear]); + }, [uiStateActions, clear, clearHistory]); + + const handleUndo = useCallback(() => { + undo(); + uiStateActions.setIsMainMenuOpen(false); + }, [undo, uiStateActions]); + + const handleRedo = useCallback(() => { + redo(); + uiStateActions.setIsMainMenuOpen(false); + }, [redo, uiStateActions]); const sectionVisibility = useMemo(() => { return { @@ -133,6 +149,26 @@ export const MainMenu = () => { }} > + {/* Undo/Redo Section */} + } + disabled={!canUndo} + > + Undo + + + } + disabled={!canRedo} + > + Redo + + + {(canUndo || canRedo) && sectionVisibility.actions && } + + {/* File Actions */} {mainMenuOptions.includes('ACTION.OPEN') && ( }> Open diff --git a/src/components/MainMenu/MenuItem.tsx b/src/components/MainMenu/MenuItem.tsx index da0e2cfc..966c21e2 100644 --- a/src/components/MainMenu/MenuItem.tsx +++ b/src/components/MainMenu/MenuItem.tsx @@ -5,12 +5,18 @@ export interface Props { onClick?: () => void; Icon?: React.ReactNode; children: string | React.ReactNode; + disabled?: boolean; } -export const MenuItem = ({ onClick, Icon, children }: Props) => { +export const MenuItem = ({ + onClick, + Icon, + children, + disabled = false +}: Props) => { return ( - - {Icon} + + {Icon} {children} ); diff --git a/src/components/SceneLayers/Connectors/Connector.tsx b/src/components/SceneLayers/Connectors/Connector.tsx index 70bb44ae..6a7d4440 100644 --- a/src/components/SceneLayers/Connectors/Connector.tsx +++ b/src/components/SceneLayers/Connectors/Connector.tsx @@ -23,6 +23,11 @@ export const Connector = ({ connector: _connector, isSelected }: Props) => { const color = useColor(_connector.color); const { currentView } = useScene(); const connector = useConnector(_connector.id); + + if (!connector || !color) { + return null; + } + const { css, pxSize } = useIsoProjection({ ...connector.path.rectangle }); diff --git a/src/components/SceneLayers/Nodes/Node/Node.tsx b/src/components/SceneLayers/Nodes/Node/Node.tsx index c3a32976..430057de 100644 --- a/src/components/SceneLayers/Nodes/Node/Node.tsx +++ b/src/components/SceneLayers/Nodes/Node/Node.tsx @@ -19,7 +19,7 @@ interface Props { export const Node = ({ node, order }: Props) => { const modelItem = useModelItem(node.id); - const { iconComponent } = useIcon(modelItem.icon); + const { iconComponent } = useIcon(modelItem?.icon); const position = useMemo(() => { return getTilePosition({ @@ -30,13 +30,19 @@ export const Node = ({ node, order }: Props) => { const description = useMemo(() => { if ( + !modelItem || modelItem.description === undefined || modelItem.description === MARKDOWN_EMPTY_VALUE ) return null; return modelItem.description; - }, [modelItem.description]); + }, [modelItem?.description]); + + // If modelItem doesn't exist, don't render the node + if (!modelItem) { + return null; + } return ( { top: position.y }} > - {(modelItem.name || description) && ( + {(modelItem?.name || description) && ( ['rectangles'][0]; export const Rectangle = ({ from, to, color: colorId }: Props) => { const color = useColor(colorId); + if (!color) { + return null; + } + return ( { const { createTextBox } = useScene(); + const { undo, redo, canUndo, canRedo } = useHistory(); const mode = useUiStateStore((state) => { return state.mode; }); @@ -27,6 +31,14 @@ export const ToolMenu = () => { return state.mouse.position.tile; }); + const handleUndo = useCallback(() => { + undo(); + }, [undo]); + + const handleRedo = useCallback(() => { + redo(); + }, [redo]); + const createTextBoxProxy = useCallback(() => { const textBoxId = generateId(); @@ -46,6 +58,23 @@ export const ToolMenu = () => { return ( + {/* Undo/Redo Section */} + } + onClick={handleUndo} + disabled={!canUndo} + /> + } + onClick={handleRedo} + disabled={!canRedo} + /> + + + + {/* Main Tools */} } diff --git a/src/components/TransformControlsManager/NodeTransformControls.tsx b/src/components/TransformControlsManager/NodeTransformControls.tsx index 30ca4a62..88bf50a4 100644 --- a/src/components/TransformControlsManager/NodeTransformControls.tsx +++ b/src/components/TransformControlsManager/NodeTransformControls.tsx @@ -9,5 +9,9 @@ interface Props { export const NodeTransformControls = ({ id }: Props) => { const node = useViewItem(id); + if (!node) { + return null; + } + return ; }; diff --git a/src/components/TransformControlsManager/RectangleTransformControls.tsx b/src/components/TransformControlsManager/RectangleTransformControls.tsx index db762600..b71644fe 100644 --- a/src/components/TransformControlsManager/RectangleTransformControls.tsx +++ b/src/components/TransformControlsManager/RectangleTransformControls.tsx @@ -16,6 +16,7 @@ export const RectangleTransformControls = ({ id }: Props) => { const onAnchorMouseDown = useCallback( (key: AnchorPosition) => { + if (!rectangle) return; uiStateActions.setMode({ type: 'RECTANGLE.TRANSFORM', id: rectangle.id, @@ -23,9 +24,13 @@ export const RectangleTransformControls = ({ id }: Props) => { showCursor: true }); }, - [rectangle.id, uiStateActions] + [rectangle?.id, uiStateActions] ); + if (!rectangle) { + return null; + } + return ( { const textBox = useTextBox(id); const to = useMemo(() => { + if (!textBox) return { x: 0, y: 0 }; return getTextBoxEndTile(textBox, textBox.size); }, [textBox]); + if (!textBox) { + return null; + } + return ; }; diff --git a/src/hooks/useColor.ts b/src/hooks/useColor.ts index a2fae3e3..b0f258a6 100644 --- a/src/hooks/useColor.ts +++ b/src/hooks/useColor.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { getItemByIdOrThrow } from 'src/utils'; +import { getItemById } from 'src/utils'; import { useScene } from 'src/hooks/useScene'; export const useColor = (colorId?: string) => { @@ -7,14 +7,11 @@ export const useColor = (colorId?: string) => { const color = useMemo(() => { if (colorId === undefined) { - if (colors.length > 0) { - return colors[0]; - } - - throw new Error('No colors available.'); + return colors.length > 0 ? colors[0] : null; } - return getItemByIdOrThrow(colors, colorId).value; + const item = getItemById(colors, colorId); + return item ? item.value : null; }, [colorId, colors]); return color; diff --git a/src/hooks/useConnector.ts b/src/hooks/useConnector.ts index 9f8fed43..19d9eddd 100644 --- a/src/hooks/useConnector.ts +++ b/src/hooks/useConnector.ts @@ -1,12 +1,13 @@ import { useMemo } from 'react'; -import { getItemByIdOrThrow } from 'src/utils'; +import { getItemById } from 'src/utils'; import { useScene } from 'src/hooks/useScene'; export const useConnector = (id: string) => { const { connectors } = useScene(); const connector = useMemo(() => { - return getItemByIdOrThrow(connectors, id).value; + const item = getItemById(connectors, id); + return item ? item.value : null; }, [connectors, id]); return connector; diff --git a/src/hooks/useHistory.ts b/src/hooks/useHistory.ts new file mode 100644 index 00000000..b9bad61e --- /dev/null +++ b/src/hooks/useHistory.ts @@ -0,0 +1,129 @@ +import { useCallback, useRef } from 'react'; +import { useModelStore } from 'src/stores/modelStore'; +import { useSceneStore } from 'src/stores/sceneStore'; + +export const useHistory = () => { + // Track if we're in a transaction to prevent nested history saves + const transactionInProgress = useRef(false); + + // Get store actions + const modelActions = useModelStore((state) => { + return state?.actions; + }); + const sceneActions = useSceneStore((state) => { + return state?.actions; + }); + + // Get history state + const modelCanUndo = useModelStore((state) => { + return state?.actions?.canUndo?.() ?? false; + }); + const sceneCanUndo = useSceneStore((state) => { + return state?.actions?.canUndo?.() ?? false; + }); + const modelCanRedo = useModelStore((state) => { + return state?.actions?.canRedo?.() ?? false; + }); + const sceneCanRedo = useSceneStore((state) => { + return state?.actions?.canRedo?.() ?? false; + }); + + // Derived values + const canUndo = modelCanUndo || sceneCanUndo; + const canRedo = modelCanRedo || sceneCanRedo; + + // Transaction wrapper - groups multiple operations into single history entry + const transaction = useCallback( + (operations: () => void) => { + if (!modelActions || !sceneActions) return; + + // Prevent nested transactions + if (transactionInProgress.current) { + operations(); + return; + } + + // Save current state before transaction + modelActions.saveToHistory(); + sceneActions.saveToHistory(); + + // Mark transaction as in progress + transactionInProgress.current = true; + + try { + // Execute all operations without saving intermediate history + operations(); + } finally { + // Always reset transaction state + transactionInProgress.current = false; + } + + // Note: We don't save after transaction - the final state is already current + }, + [modelActions, sceneActions] + ); + + const undo = useCallback(() => { + if (!modelActions || !sceneActions) return false; + + let undoPerformed = false; + + // Try to undo model first, then scene + if (modelActions.canUndo()) { + undoPerformed = modelActions.undo() || undoPerformed; + } + if (sceneActions.canUndo()) { + undoPerformed = sceneActions.undo() || undoPerformed; + } + + return undoPerformed; + }, [modelActions, sceneActions]); + + const redo = useCallback(() => { + if (!modelActions || !sceneActions) return false; + + let redoPerformed = false; + + // Try to redo model first, then scene + if (modelActions.canRedo()) { + redoPerformed = modelActions.redo() || redoPerformed; + } + if (sceneActions.canRedo()) { + redoPerformed = sceneActions.redo() || redoPerformed; + } + + return redoPerformed; + }, [modelActions, sceneActions]); + + const saveToHistory = useCallback(() => { + // Don't save during transactions + if (transactionInProgress.current) { + return; + } + + if (!modelActions || !sceneActions) return; + + modelActions.saveToHistory(); + sceneActions.saveToHistory(); + }, [modelActions, sceneActions]); + + const clearHistory = useCallback(() => { + if (!modelActions || !sceneActions) return; + + modelActions.clearHistory(); + sceneActions.clearHistory(); + }, [modelActions, sceneActions]); + + return { + undo, + redo, + canUndo, + canRedo, + saveToHistory, + clearHistory, + transaction, + isInTransaction: () => { + return transactionInProgress.current; + } + }; +}; diff --git a/src/hooks/useIcon.tsx b/src/hooks/useIcon.tsx index 80889670..915c3604 100644 --- a/src/hooks/useIcon.tsx +++ b/src/hooks/useIcon.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useEffect } from 'react'; import { useModelStore } from 'src/stores/modelStore'; -import { getItemByIdOrThrow } from 'src/utils'; +import { getItemById } from 'src/utils'; import { IsometricIcon } from 'src/components/SceneLayers/Nodes/Node/IconTypes/IsometricIcon'; import { NonIsometricIcon } from 'src/components/SceneLayers/Nodes/Node/IconTypes/NonIsometricIcon'; import { DEFAULT_ICON } from 'src/config'; @@ -14,7 +14,8 @@ export const useIcon = (id: string | undefined) => { const icon = useMemo(() => { if (!id) return DEFAULT_ICON; - return getItemByIdOrThrow(icons, id).value; + const item = getItemById(icons, id); + return item ? item.value : DEFAULT_ICON; }, [icons, id]); useEffect(() => { diff --git a/src/hooks/useModelItem.ts b/src/hooks/useModelItem.ts index 914fb6c3..a9abbb84 100644 --- a/src/hooks/useModelItem.ts +++ b/src/hooks/useModelItem.ts @@ -1,15 +1,16 @@ import { useMemo } from 'react'; import { ModelItem } from 'src/types'; import { useModelStore } from 'src/stores/modelStore'; -import { getItemByIdOrThrow } from 'src/utils'; +import { getItemById } from 'src/utils'; -export const useModelItem = (id: string): ModelItem => { +export const useModelItem = (id: string): ModelItem | null => { const model = useModelStore((state) => { return state; }); const modelItem = useMemo(() => { - return getItemByIdOrThrow(model.items, id).value; + const item = getItemById(model.items, id); + return item ? item.value : null; }, [id, model.items]); return modelItem; diff --git a/src/hooks/useRectangle.ts b/src/hooks/useRectangle.ts index 087b122d..4926ed12 100644 --- a/src/hooks/useRectangle.ts +++ b/src/hooks/useRectangle.ts @@ -1,12 +1,13 @@ import { useMemo } from 'react'; -import { getItemByIdOrThrow } from 'src/utils'; +import { getItemById } from 'src/utils'; import { useScene } from 'src/hooks/useScene'; export const useRectangle = (id: string) => { const { rectangles } = useScene(); const rectangle = useMemo(() => { - return getItemByIdOrThrow(rectangles, id).value; + const item = getItemById(rectangles, id); + return item ? item.value : null; }, [rectangles, id]); return rectangle; diff --git a/src/hooks/useScene.ts b/src/hooks/useScene.ts index 85795f1d..9ac5a3f2 100644 --- a/src/hooks/useScene.ts +++ b/src/hooks/useScene.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useRef } from 'react'; import { ModelItem, ViewItem, @@ -24,30 +24,56 @@ export const useScene = () => { const model = useModelStore((state) => { return state; }); - const scene = useSceneStore((state) => { return state; }); - const currentViewId = useUiStateStore((state) => { return state.view; }); + const transactionInProgress = useRef(false); const currentView = useMemo(() => { - return getItemByIdOrThrow(model.views, currentViewId).value; - }, [currentViewId, model.views]); + // Handle case where view doesn't exist yet or stores aren't initialized + if (!model?.views || !currentViewId) { + return { + id: '', + name: 'Default View', + items: [], + connectors: [], + rectangles: [], + textBoxes: [] + }; + } + + try { + return getItemByIdOrThrow(model.views, currentViewId).value; + } catch (error) { + // console.warn(`View "${currentViewId}" not found, using fallback`); + // Return first available view or empty view + return ( + model.views[0] || { + id: currentViewId, + name: 'Default View', + items: [], + connectors: [], + rectangles: [], + textBoxes: [] + } + ); + } + }, [currentViewId, model?.views]); const items = useMemo(() => { return currentView.items ?? []; }, [currentView.items]); const colors = useMemo(() => { - return model.colors; - }, [model.colors]); + return model?.colors ?? []; + }, [model?.colors]); const connectors = useMemo(() => { return (currentView.connectors ?? []).map((connector) => { - const sceneConnector = scene.connectors[connector.id]; + const sceneConnector = scene?.connectors?.[connector.id]; return { ...CONNECTOR_DEFAULTS, @@ -55,7 +81,7 @@ export const useScene = () => { ...sceneConnector }; }); - }, [currentView.connectors, scene.connectors]); + }, [currentView.connectors, scene?.connectors]); const rectangles = useMemo(() => { return (currentView.rectangles ?? []).map((rectangle) => { @@ -68,7 +94,7 @@ export const useScene = () => { const textBoxes = useMemo(() => { return (currentView.textBoxes ?? []).map((textBox) => { - const sceneTextBox = scene.textBoxes[textBox.id]; + const sceneTextBox = scene?.textBoxes?.[textBox.id]; return { ...TEXTBOX_DEFAULTS, @@ -76,61 +102,135 @@ export const useScene = () => { ...sceneTextBox }; }); - }, [currentView.textBoxes, scene.textBoxes]); + }, [currentView.textBoxes, scene?.textBoxes]); const getState = useCallback(() => { return { - model: model.actions.get(), - scene: scene.actions.get() + model: { + version: model?.version ?? '', + title: model?.title ?? '', + description: model?.description, + colors: model?.colors ?? [], + icons: model?.icons ?? [], + items: model?.items ?? [], + views: model?.views ?? [] + }, + scene: { + connectors: scene?.connectors ?? {}, + textBoxes: scene?.textBoxes ?? {} + } }; - }, [model.actions, scene.actions]); + }, [model, scene]); const setState = useCallback( (newState: State) => { - model.actions.set(newState.model); - scene.actions.set(newState.scene); + if (model?.actions?.set && scene?.actions?.set) { + model.actions.set(newState.model, true); // Skip history since we're managing it here + scene.actions.set(newState.scene, true); // Skip history since we're managing it here + } }, - [model.actions, scene.actions] + [model?.actions, scene?.actions] ); + const saveToHistoryBeforeChange = useCallback(() => { + // Prevent multiple saves during grouped operations + if (transactionInProgress.current) { + return; + } + + model?.actions?.saveToHistory?.(); + scene?.actions?.saveToHistory?.(); + }, [model?.actions, scene?.actions]); + const createModelItem = useCallback( (newModelItem: ModelItem) => { + if (!model?.actions || !scene?.actions) return getState(); + + if (!transactionInProgress.current) { + saveToHistoryBeforeChange(); + } + const newState = reducers.createModelItem(newModelItem, getState()); setState(newState); + return newState; // Return the new state for chaining }, - [getState, setState] + [ + getState, + setState, + saveToHistoryBeforeChange, + model?.actions, + scene?.actions + ] ); const updateModelItem = useCallback( (id: string, updates: Partial) => { + if (!model?.actions || !scene?.actions) return; + + saveToHistoryBeforeChange(); const newState = reducers.updateModelItem(id, updates, getState()); setState(newState); }, - [getState, setState] + [ + getState, + setState, + saveToHistoryBeforeChange, + model?.actions, + scene?.actions + ] ); const deleteModelItem = useCallback( (id: string) => { + if (!model?.actions || !scene?.actions) return; + + saveToHistoryBeforeChange(); const newState = reducers.deleteModelItem(id, getState()); setState(newState); }, - [getState, setState] + [ + getState, + setState, + saveToHistoryBeforeChange, + model?.actions, + scene?.actions + ] ); const createViewItem = useCallback( - (newViewItem: ViewItem) => { + (newViewItem: ViewItem, currentState?: State) => { + if (!model?.actions || !scene?.actions || !currentViewId) return; + + if (!transactionInProgress.current) { + saveToHistoryBeforeChange(); + } + + // Use provided state or get current state + const stateToUse = currentState || getState(); + const newState = reducers.view({ action: 'CREATE_VIEWITEM', payload: newViewItem, - ctx: { viewId: currentViewId, state: getState() } + ctx: { viewId: currentViewId, state: stateToUse } }); setState(newState); + return newState; }, - [getState, setState, currentViewId] + [ + getState, + setState, + currentViewId, + saveToHistoryBeforeChange, + model?.actions, + scene?.actions + ] ); const updateViewItem = useCallback( (id: string, updates: Partial) => { + if (!model?.actions || !scene?.actions || !currentViewId) return; + + saveToHistoryBeforeChange(); const newState = reducers.view({ action: 'UPDATE_VIEWITEM', payload: { id, ...updates }, @@ -138,11 +238,21 @@ export const useScene = () => { }); setState(newState); }, - [getState, setState, currentViewId] + [ + getState, + setState, + currentViewId, + saveToHistoryBeforeChange, + model?.actions, + scene?.actions + ] ); const deleteViewItem = useCallback( (id: string) => { + if (!model?.actions || !scene?.actions || !currentViewId) return; + + saveToHistoryBeforeChange(); const newState = reducers.view({ action: 'DELETE_VIEWITEM', payload: id, @@ -150,11 +260,21 @@ export const useScene = () => { }); setState(newState); }, - [getState, setState, currentViewId] + [ + getState, + setState, + currentViewId, + saveToHistoryBeforeChange, + model?.actions, + scene?.actions + ] ); const createConnector = useCallback( (newConnector: Connector) => { + if (!model?.actions || !scene?.actions || !currentViewId) return; + + saveToHistoryBeforeChange(); const newState = reducers.view({ action: 'CREATE_CONNECTOR', payload: newConnector, @@ -162,11 +282,21 @@ export const useScene = () => { }); setState(newState); }, - [getState, setState, currentViewId] + [ + getState, + setState, + currentViewId, + saveToHistoryBeforeChange, + model?.actions, + scene?.actions + ] ); const updateConnector = useCallback( (id: string, updates: Partial) => { + if (!model?.actions || !scene?.actions || !currentViewId) return; + + saveToHistoryBeforeChange(); const newState = reducers.view({ action: 'UPDATE_CONNECTOR', payload: { id, ...updates }, @@ -174,11 +304,21 @@ export const useScene = () => { }); setState(newState); }, - [getState, setState, currentViewId] + [ + getState, + setState, + currentViewId, + saveToHistoryBeforeChange, + model?.actions, + scene?.actions + ] ); const deleteConnector = useCallback( (id: string) => { + if (!model?.actions || !scene?.actions || !currentViewId) return; + + saveToHistoryBeforeChange(); const newState = reducers.view({ action: 'DELETE_CONNECTOR', payload: id, @@ -186,11 +326,21 @@ export const useScene = () => { }); setState(newState); }, - [getState, setState, currentViewId] + [ + getState, + setState, + currentViewId, + saveToHistoryBeforeChange, + model?.actions, + scene?.actions + ] ); const createTextBox = useCallback( (newTextBox: TextBox) => { + if (!model?.actions || !scene?.actions || !currentViewId) return; + + saveToHistoryBeforeChange(); const newState = reducers.view({ action: 'CREATE_TEXTBOX', payload: newTextBox, @@ -198,11 +348,21 @@ export const useScene = () => { }); setState(newState); }, - [getState, setState, currentViewId] + [ + getState, + setState, + currentViewId, + saveToHistoryBeforeChange, + model?.actions, + scene?.actions + ] ); const updateTextBox = useCallback( (id: string, updates: Partial) => { + if (!model?.actions || !scene?.actions || !currentViewId) return; + + saveToHistoryBeforeChange(); const newState = reducers.view({ action: 'UPDATE_TEXTBOX', payload: { id, ...updates }, @@ -210,11 +370,21 @@ export const useScene = () => { }); setState(newState); }, - [getState, setState, currentViewId] + [ + getState, + setState, + currentViewId, + saveToHistoryBeforeChange, + model?.actions, + scene?.actions + ] ); const deleteTextBox = useCallback( (id: string) => { + if (!model?.actions || !scene?.actions || !currentViewId) return; + + saveToHistoryBeforeChange(); const newState = reducers.view({ action: 'DELETE_TEXTBOX', payload: id, @@ -222,11 +392,21 @@ export const useScene = () => { }); setState(newState); }, - [getState, setState, currentViewId] + [ + getState, + setState, + currentViewId, + saveToHistoryBeforeChange, + model?.actions, + scene?.actions + ] ); const createRectangle = useCallback( (newRectangle: Rectangle) => { + if (!model?.actions || !scene?.actions || !currentViewId) return; + + saveToHistoryBeforeChange(); const newState = reducers.view({ action: 'CREATE_RECTANGLE', payload: newRectangle, @@ -234,11 +414,21 @@ export const useScene = () => { }); setState(newState); }, - [getState, setState, currentViewId] + [ + getState, + setState, + currentViewId, + saveToHistoryBeforeChange, + model?.actions, + scene?.actions + ] ); const updateRectangle = useCallback( (id: string, updates: Partial) => { + if (!model?.actions || !scene?.actions || !currentViewId) return; + + saveToHistoryBeforeChange(); const newState = reducers.view({ action: 'UPDATE_RECTANGLE', payload: { id, ...updates }, @@ -246,11 +436,21 @@ export const useScene = () => { }); setState(newState); }, - [getState, setState, currentViewId] + [ + getState, + setState, + currentViewId, + saveToHistoryBeforeChange, + model?.actions, + scene?.actions + ] ); const deleteRectangle = useCallback( (id: string) => { + if (!model?.actions || !scene?.actions || !currentViewId) return; + + saveToHistoryBeforeChange(); const newState = reducers.view({ action: 'DELETE_RECTANGLE', payload: id, @@ -258,11 +458,21 @@ export const useScene = () => { }); setState(newState); }, - [getState, setState, currentViewId] + [ + getState, + setState, + currentViewId, + saveToHistoryBeforeChange, + model?.actions, + scene?.actions + ] ); const changeLayerOrder = useCallback( (action: LayerOrderingAction, item: ItemReference) => { + if (!model?.actions || !scene?.actions || !currentViewId) return; + + saveToHistoryBeforeChange(); const newState = reducers.view({ action: 'CHANGE_LAYER_ORDER', payload: { action, item }, @@ -270,7 +480,73 @@ export const useScene = () => { }); setState(newState); }, - [getState, setState, currentViewId] + [ + getState, + setState, + currentViewId, + saveToHistoryBeforeChange, + model?.actions, + scene?.actions + ] + ); + + const transaction = useCallback( + (operations: () => void) => { + if (!model?.actions || !scene?.actions) return; + + // Prevent nested transactions + if (transactionInProgress.current) { + operations(); + return; + } + + // Save state before transaction + saveToHistoryBeforeChange(); + + // Mark transaction as in progress + transactionInProgress.current = true; + + try { + // Execute all operations without saving intermediate history + operations(); + } finally { + // Always reset transaction state + transactionInProgress.current = false; + } + }, + [saveToHistoryBeforeChange, model?.actions, scene?.actions] + ); + + const placeIcon = useCallback( + (params: { modelItem: ModelItem; viewItem: ViewItem }) => { + if (!model?.actions || !scene?.actions) return; + + // Save history before the transaction + saveToHistoryBeforeChange(); + + // Mark transaction as in progress + transactionInProgress.current = true; + + try { + // Create model item first and get the updated state + const stateAfterModelItem = createModelItem(params.modelItem); + + // Create view item using the updated state + if (stateAfterModelItem) { + createViewItem(params.viewItem, stateAfterModelItem); + } + } finally { + // Always reset transaction state + transactionInProgress.current = false; + } + }, + [ + createModelItem, + createViewItem, + saveToHistoryBeforeChange, + model?.actions, + scene?.actions + ] ); return { @@ -295,6 +571,8 @@ export const useScene = () => { createRectangle, updateRectangle, deleteRectangle, - changeLayerOrder + changeLayerOrder, + transaction, + placeIcon }; }; diff --git a/src/hooks/useTextBox.ts b/src/hooks/useTextBox.ts index 15a48271..8d76b779 100644 --- a/src/hooks/useTextBox.ts +++ b/src/hooks/useTextBox.ts @@ -1,12 +1,13 @@ import { useMemo } from 'react'; -import { getItemByIdOrThrow } from 'src/utils'; +import { getItemById } from 'src/utils'; import { useScene } from 'src/hooks/useScene'; export const useTextBox = (id: string) => { const { textBoxes } = useScene(); const textBox = useMemo(() => { - return getItemByIdOrThrow(textBoxes, id).value; + const item = getItemById(textBoxes, id); + return item ? item.value : null; }, [textBoxes, id]); return textBox; diff --git a/src/hooks/useViewItem.ts b/src/hooks/useViewItem.ts index 1f707291..74f55fea 100644 --- a/src/hooks/useViewItem.ts +++ b/src/hooks/useViewItem.ts @@ -1,12 +1,13 @@ import { useMemo } from 'react'; -import { getItemByIdOrThrow } from 'src/utils'; +import { getItemById } from 'src/utils'; import { useScene } from 'src/hooks/useScene'; export const useViewItem = (id: string) => { const { items } = useScene(); const viewItem = useMemo(() => { - return getItemByIdOrThrow(items, id).value; + const item = getItemById(items, id); + return item ? item.value : null; }, [items, id]); return viewItem; diff --git a/src/interaction/modes/PlaceIcon.ts b/src/interaction/modes/PlaceIcon.ts index f5d11e83..2db53b65 100644 --- a/src/interaction/modes/PlaceIcon.ts +++ b/src/interaction/modes/PlaceIcon.ts @@ -29,16 +29,17 @@ export const PlaceIcon: ModeActions = { if (uiState.mode.id !== null) { const modelItemId = generateId(); - scene.createModelItem({ - id: modelItemId, - name: 'Untitled', - icon: uiState.mode.id - }); - - scene.createViewItem({ - ...VIEW_ITEM_DEFAULTS, - id: modelItemId, - tile: uiState.mouse.position.tile + scene.placeIcon({ + modelItem: { + id: modelItemId, + name: 'Untitled', + icon: uiState.mode.id + }, + viewItem: { + ...VIEW_ITEM_DEFAULTS, + id: modelItemId, + tile: uiState.mouse.position.tile + } }); } diff --git a/src/interaction/useInteractionManager.ts b/src/interaction/useInteractionManager.ts index 6a65b0bc..6f71c0b4 100644 --- a/src/interaction/useInteractionManager.ts +++ b/src/interaction/useInteractionManager.ts @@ -5,6 +5,7 @@ import { ModeActions, State, SlimMouseEvent } from 'src/types'; import { getMouse, getItemAtTile } from 'src/utils'; import { useResizeObserver } from 'src/hooks/useResizeObserver'; import { useScene } from 'src/hooks/useScene'; +import { useHistory } from 'src/hooks/useHistory'; import { Cursor } from './modes/Cursor'; import { DragItems } from './modes/DragItems'; import { DrawRectangle } from './modes/Rectangle/DrawRectangle'; @@ -17,7 +18,6 @@ import { TextBox } from './modes/TextBox'; const modes: { [k in string]: ModeActions } = { CURSOR: Cursor, DRAG_ITEMS: DragItems, - // TODO: Adopt this notation for all modes (i.e. {node.type}.{action}) 'RECTANGLE.DRAW': DrawRectangle, 'RECTANGLE.TRANSFORM': TransformRectangle, CONNECTOR: Connector, @@ -50,6 +50,48 @@ export const useInteractionManager = () => { }); const scene = useScene(); const { size: rendererSize } = useResizeObserver(uiState.rendererEl); + const { undo, redo, canUndo, canRedo } = useHistory(); + + // Keyboard shortcuts for undo/redo + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Don't handle shortcuts when typing in input fields + const target = e.target as HTMLElement; + if ( + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.contentEditable === 'true' || + target.closest('.ql-editor') // Quill editor + ) { + return; + } + + const isCtrlOrCmd = e.ctrlKey || e.metaKey; + + if (isCtrlOrCmd && e.key.toLowerCase() === 'z' && !e.shiftKey) { + e.preventDefault(); + if (canUndo) { + undo(); + } + } + + if ( + isCtrlOrCmd && + (e.key.toLowerCase() === 'y' || + (e.key.toLowerCase() === 'z' && e.shiftKey)) + ) { + e.preventDefault(); + if (canRedo) { + redo(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + return window.removeEventListener('keydown', handleKeyDown); + }; + }, [undo, redo, canUndo, canRedo]); const onMouseEvent = useCallback( (e: SlimMouseEvent) => { diff --git a/src/standaloneExports.ts b/src/standaloneExports.ts index 5c72074f..325e6828 100644 --- a/src/standaloneExports.ts +++ b/src/standaloneExports.ts @@ -5,4 +5,4 @@ export * as reducers from 'src/stores/reducers'; export { INITIAL_DATA, INITIAL_SCENE_STATE } from 'src/config'; export * from 'src/schemas'; export type { IsoflowProps, InitialData } from 'src/types'; -export type * from 'src/types/model'; +export * from 'src/types/model'; diff --git a/src/stores/modelStore.tsx b/src/stores/modelStore.tsx index 6c0f5c94..ba947ec5 100644 --- a/src/stores/modelStore.tsx +++ b/src/stores/modelStore.tsx @@ -1,15 +1,160 @@ import React, { createContext, useRef, useContext } from 'react'; import { createStore, useStore } from 'zustand'; -import { ModelStore } from 'src/types'; +import { ModelStore, Model } from 'src/types'; import { INITIAL_DATA } from 'src/config'; +export interface HistoryState { + past: Model[]; + present: Model; + future: Model[]; + maxHistorySize: number; +} + +export interface ModelStoreWithHistory extends Omit { + history: HistoryState; + actions: { + get: () => ModelStoreWithHistory; + set: (model: Partial, skipHistory?: boolean) => void; + undo: () => boolean; + redo: () => boolean; + canUndo: () => boolean; + canRedo: () => boolean; + saveToHistory: () => void; + clearHistory: () => void; + }; +} + +const MAX_HISTORY_SIZE = 50; + +const createHistoryState = (initialModel: Model): HistoryState => { + return { + past: [], + present: initialModel, + future: [], + maxHistorySize: MAX_HISTORY_SIZE + }; +}; + +const extractModelData = (state: ModelStoreWithHistory): Model => { + return { + version: state.version, + title: state.title, + description: state.description, + colors: state.colors, + icons: state.icons, + items: state.items, + views: state.views + }; +}; + const initialState = () => { - return createStore((set, get) => { + return createStore((set, get) => { + const initialModel = { ...INITIAL_DATA }; + + const saveToHistory = () => { + set((state) => { + const currentModel = extractModelData(state); + const newPast = [...state.history.past, state.history.present]; + + // Limit history size to prevent memory issues + if (newPast.length > state.history.maxHistorySize) { + newPast.shift(); + } + + return { + ...state, + history: { + ...state.history, + past: newPast, + present: currentModel, + future: [] // Clear future when new action is performed + } + }; + }); + }; + + const undo = (): boolean => { + const { history } = get(); + if (history.past.length === 0) return false; + + const previous = history.past[history.past.length - 1]; + const newPast = history.past.slice(0, history.past.length - 1); + + set((state) => { + return { + ...previous, + history: { + ...state.history, + past: newPast, + present: previous, + future: [state.history.present, ...state.history.future] + } + }; + }); + + return true; + }; + + const redo = (): boolean => { + const { history } = get(); + if (history.future.length === 0) return false; + + const next = history.future[0]; + const newFuture = history.future.slice(1); + + set((state) => { + return { + ...next, + history: { + ...state.history, + past: [...state.history.past, state.history.present], + present: next, + future: newFuture + } + }; + }); + + return true; + }; + + const canUndo = () => { + return get().history.past.length > 0; + }; + const canRedo = () => { + return get().history.future.length > 0; + }; + + const clearHistory = () => { + const currentState = get(); + const currentModel = extractModelData(currentState); + + set((state) => { + return { + ...state, + history: createHistoryState(currentModel) + }; + }); + }; + return { - ...INITIAL_DATA, + ...initialModel, + history: createHistoryState(initialModel), actions: { get, - set + set: (updates: Partial, skipHistory = false) => { + if (!skipHistory) { + saveToHistory(); + } + set((state) => { + return { ...state, ...updates }; + }); + }, + undo, + redo, + canUndo, + canRedo, + saveToHistory, + clearHistory } }; }); @@ -23,8 +168,6 @@ interface ProviderProps { children: React.ReactNode; } -// TODO: Typings below are pretty gnarly due to the way Zustand works. -// see https://github.com/pmndrs/zustand/discussions/1180#discussioncomment-3439061 export const ModelProvider = ({ children }: ProviderProps) => { const storeRef = useRef>(); @@ -40,7 +183,7 @@ export const ModelProvider = ({ children }: ProviderProps) => { }; export function useModelStore( - selector: (state: ModelStore) => T, + selector: (state: ModelStoreWithHistory) => T, equalityFn?: (left: T, right: T) => boolean ) { const store = useContext(ModelContext); @@ -50,6 +193,5 @@ export function useModelStore( } const value = useStore(store, selector, equalityFn); - return value; } diff --git a/src/stores/sceneStore.tsx b/src/stores/sceneStore.tsx index f4e162b0..9a73ddfc 100644 --- a/src/stores/sceneStore.tsx +++ b/src/stores/sceneStore.tsx @@ -1,15 +1,157 @@ import React, { createContext, useRef, useContext } from 'react'; import { createStore, useStore } from 'zustand'; -import { SceneStore } from 'src/types'; +import { SceneStore, Scene } from 'src/types'; + +export interface SceneHistoryState { + past: Scene[]; + present: Scene; + future: Scene[]; + maxHistorySize: number; +} + +export interface SceneStoreWithHistory extends Omit { + history: SceneHistoryState; + actions: { + get: () => SceneStoreWithHistory; + set: (scene: Partial, skipHistory?: boolean) => void; + undo: () => boolean; + redo: () => boolean; + canUndo: () => boolean; + canRedo: () => boolean; + saveToHistory: () => void; + clearHistory: () => void; + }; +} + +const MAX_HISTORY_SIZE = 50; + +const createSceneHistoryState = (initialScene: Scene): SceneHistoryState => { + return { + past: [], + present: initialScene, + future: [], + maxHistorySize: MAX_HISTORY_SIZE + }; +}; + +const extractSceneData = (state: SceneStoreWithHistory): Scene => { + return { + connectors: state.connectors, + textBoxes: state.textBoxes + }; +}; const initialState = () => { - return createStore((set, get) => { - return { + return createStore((set, get) => { + const initialScene: Scene = { connectors: {}, - textBoxes: {}, + textBoxes: {} + }; + + const saveToHistory = () => { + set((state) => { + const currentScene = extractSceneData(state); + const newPast = [...state.history.past, state.history.present]; + + // Limit history size + if (newPast.length > state.history.maxHistorySize) { + newPast.shift(); + } + + return { + ...state, + history: { + ...state.history, + past: newPast, + present: currentScene, + future: [] + } + }; + }); + }; + + const undo = (): boolean => { + const { history } = get(); + if (history.past.length === 0) return false; + + const previous = history.past[history.past.length - 1]; + const newPast = history.past.slice(0, history.past.length - 1); + + set((state) => { + return { + ...previous, + history: { + ...state.history, + past: newPast, + present: previous, + future: [state.history.present, ...state.history.future] + } + }; + }); + + return true; + }; + + const redo = (): boolean => { + const { history } = get(); + if (history.future.length === 0) return false; + + const next = history.future[0]; + const newFuture = history.future.slice(1); + + set((state) => { + return { + ...next, + history: { + ...state.history, + past: [...state.history.past, state.history.present], + present: next, + future: newFuture + } + }; + }); + + return true; + }; + + const canUndo = () => { + return get().history.past.length > 0; + }; + const canRedo = () => { + return get().history.future.length > 0; + }; + + const clearHistory = () => { + const currentState = get(); + const currentScene = extractSceneData(currentState); + + set((state) => { + return { + ...state, + history: createSceneHistoryState(currentScene) + }; + }); + }; + + return { + ...initialScene, + history: createSceneHistoryState(initialScene), actions: { get, - set + set: (updates: Partial, skipHistory = false) => { + if (!skipHistory) { + saveToHistory(); + } + set((state) => { + return { ...state, ...updates }; + }); + }, + undo, + redo, + canUndo, + canRedo, + saveToHistory, + clearHistory } }; }); @@ -23,8 +165,6 @@ interface ProviderProps { children: React.ReactNode; } -// TODO: Typings below are pretty gnarly due to the way Zustand works. -// see https://github.com/pmndrs/zustand/discussions/1180#discussioncomment-3439061 export const SceneProvider = ({ children }: ProviderProps) => { const storeRef = useRef>(); @@ -40,7 +180,7 @@ export const SceneProvider = ({ children }: ProviderProps) => { }; export function useSceneStore( - selector: (state: SceneStore) => T, + selector: (state: SceneStoreWithHistory) => T, equalityFn?: (left: T, right: T) => boolean ) { const store = useContext(SceneContext); @@ -50,6 +190,5 @@ export function useSceneStore( } const value = useStore(store, selector, equalityFn); - return value; } diff --git a/src/types/model.ts b/src/types/model.ts index e1ba58d0..3052d7f4 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -39,3 +39,13 @@ export type ModelStore = Model & { set: StoreApi['setState']; }; }; + +export type { + ModelStoreWithHistory, + HistoryState as ModelHistoryState +} from 'src/stores/modelStore'; + +export type { + SceneStoreWithHistory, + SceneHistoryState +} from 'src/stores/sceneStore'; diff --git a/src/utils/common.ts b/src/utils/common.ts index a64fb33a..582de8a5 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -94,6 +94,21 @@ export function getItemByIdOrThrow( return { value: values[index], index }; } +export function getItemById( + values: T[], + id: string +): { value: T; index: number } | null { + const index = values.findIndex((val) => { + return val.id === id; + }); + + if (index === -1) { + return null; + } + + return { value: values[index], index }; +} + export function getItemByIndexOrThrow(items: T[], index: number): T { const item = items[index]; diff --git a/webpack/prod.config.js b/webpack/prod.config.js index f5ef5e01..66517421 100644 --- a/webpack/prod.config.js +++ b/webpack/prod.config.js @@ -26,7 +26,11 @@ module.exports = { commonjs2: 'react-dom', amd: 'ReactDOM', root: 'ReactDOM' - } + }, + '@mui/material': '@mui/material', + '@mui/icons-material': '@mui/icons-material', + '@emotion/react': '@emotion/react', + '@emotion/styled': '@emotion/styled' }, module: { rules: [