mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2025-12-23 22:48:57 -05:00
feat(ui): enhance custom color picker and fix docs (#169) thank you @non-stop-dev
* Update link to prioritized tasks document * Fix link to FOSSFLOW_TODO.md in CONTRIBUTING.md * Fix link to FOSSFLOW_TODO.md in CONTRIBUTING.md * feat: Introduce CustomColorInput component with hex input and eyedropper, and integrate it into ConnectorControls and RectangleControls. * feat: add custom color input component with eyedropper functionality * test: Add comprehensive tests for the CustomColorInput component and include jest-dom matchers in ColorSelector tests. * docs: Update encyclopedia and TODO list file references to FOSSFLOW_ prefix. * Update packages/fossflow-lib/src/components/ColorSelector/CustomColorInput.tsx Thanks Leo for confirming the Spanish translation, much appreciated!
This commit is contained in:
@@ -135,7 +135,7 @@ FossFLOW/
|
||||
- `bug` - Bug fixes
|
||||
- `enhancement` - New features
|
||||
|
||||
3. Check [ISOFLOW_TODO.md](./ISOFLOW_TODO.md) for prioritized tasks
|
||||
3. Check [FOSSFLOW_TODO.md](./FOSSFLOW_TODO.md) for prioritized tasks
|
||||
|
||||
### Types of Contributions
|
||||
|
||||
@@ -442,8 +442,8 @@ docker run -p 80:80 stnsmith/fossflow:latest
|
||||
|
||||
- **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)
|
||||
- **Code Encyclopedia**: See [FOSSFLOW_ENCYCLOPEDIA.md](./FOSSFLOW_ENCYCLOPEDIA.md)
|
||||
- **TODO List**: See [FOSSFLOW_TODO.md](./FOSSFLOW_TODO.md)
|
||||
|
||||
### Communication Guidelines
|
||||
|
||||
|
||||
22
package-lock.json
generated
22
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "fossflow-monorepo",
|
||||
"version": "1.5.1",
|
||||
"version": "1.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "fossflow-monorepo",
|
||||
"version": "1.5.1",
|
||||
"version": "1.7.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -2560,9 +2560,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
|
||||
"version": "3.14.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
|
||||
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -12476,9 +12476,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -20927,7 +20927,7 @@
|
||||
}
|
||||
},
|
||||
"packages/fossflow-app": {
|
||||
"version": "1.5.1",
|
||||
"version": "1.7.0",
|
||||
"dependencies": {
|
||||
"@isoflow/isopacks": "^0.0.10",
|
||||
"fossflow": "^1.1.0",
|
||||
@@ -20951,7 +20951,7 @@
|
||||
}
|
||||
},
|
||||
"packages/fossflow-backend": {
|
||||
"version": "1.5.1",
|
||||
"version": "1.7.0",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.6.1",
|
||||
@@ -20964,7 +20964,7 @@
|
||||
},
|
||||
"packages/fossflow-lib": {
|
||||
"name": "fossflow",
|
||||
"version": "1.5.1",
|
||||
"version": "1.7.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"diagramExists": "Ya existe un diagrama llamado \"{{name}}\" en esta sesión. Esto lo sobrescribirá. ¿Estás seguro de que deseas continuar?",
|
||||
"unsavedChanges": "Tienes cambios sin guardar. ¿Continuar cargando?",
|
||||
"createNewDiagram": "¿Crear un nuevo diagrama?",
|
||||
"unsavedChangesExport": "Tienes cambios sin guardar. Exporta tu diagrama primero para guardarlo. ¿Continuar?",
|
||||
"unsavedChangesExport": "Tienes cambios sin guardar. Exporta tu diagrama primero si no quieres perder los cambios o continúa para comenzar uno nuevo.",
|
||||
"confirmDelete": "¿Estás seguro de que deseas eliminar este diagrama?",
|
||||
"storageFull": "¡Almacenamiento lleno! Abriendo el gestor de almacenamiento...",
|
||||
"autoSaveFailed": "¡Almacenamiento lleno! Por favor usa el gestor de almacenamiento para liberar espacio.",
|
||||
|
||||
26
packages/fossflow-lib/dist/index.js
vendored
26
packages/fossflow-lib/dist/index.js
vendored
File diff suppressed because one or more lines are too long
@@ -0,0 +1,86 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, TextField, IconButton, Tooltip } from '@mui/material';
|
||||
import { Colorize as ColorizeIcon } from '@mui/icons-material';
|
||||
import { ColorPicker } from './ColorPicker';
|
||||
|
||||
interface EyeDropper {
|
||||
open: (options?: { signal?: AbortSignal }) => Promise<{ sRGBHex: string }>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
EyeDropper?: {
|
||||
new (): EyeDropper;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (color: string) => void;
|
||||
}
|
||||
|
||||
export const CustomColorInput = ({ value, onChange }: Props) => {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleEyeDropper = async () => {
|
||||
if (!window.EyeDropper) return;
|
||||
const eyeDropper = new window.EyeDropper();
|
||||
try {
|
||||
const result = await eyeDropper.open();
|
||||
onChange(result.sRGBHex);
|
||||
} catch (e) {
|
||||
// User canceled or failed
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalValue(newValue);
|
||||
// If it's a valid hex, update immediately
|
||||
if (/^#[0-9A-F]{6}$/i.test(newValue)) {
|
||||
onChange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
// On blur, if invalid, revert to prop value
|
||||
if (!/^#[0-9A-F]{6}$/i.test(localValue)) {
|
||||
setLocalValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const hasEyeDropper = typeof window !== 'undefined' && !!window.EyeDropper;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ColorPicker value={value} onChange={onChange} />
|
||||
<TextField
|
||||
value={localValue}
|
||||
onChange={handleTextChange}
|
||||
onBlur={handleBlur}
|
||||
variant="standard"
|
||||
size="small"
|
||||
InputProps={{
|
||||
disableUnderline: true,
|
||||
sx: {
|
||||
fontSize: '0.875rem',
|
||||
color: 'text.secondary',
|
||||
width: '80px'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{hasEyeDropper && (
|
||||
<Tooltip title="Pick color from screen">
|
||||
<IconButton onClick={handleEyeDropper} size="small">
|
||||
<ColorizeIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { ColorSelector } from '../ColorSelector';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { theme } from 'src/styles/theme';
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { CustomColorInput } from '../CustomColorInput';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { theme } from 'src/styles/theme';
|
||||
|
||||
// Mock ColorPicker since we don't need to test external library behavior
|
||||
jest.mock('../ColorPicker', () => ({
|
||||
ColorPicker: ({ value, onChange }: { value: string; onChange: (color: string) => void }) => (
|
||||
<div data-testid="color-picker" onClick={() => onChange('#FFFFFF')}>
|
||||
{value}
|
||||
</div>
|
||||
)
|
||||
}));
|
||||
|
||||
describe('CustomColorInput', () => {
|
||||
const defaultProps = {
|
||||
value: '#FF0000',
|
||||
onChange: jest.fn()
|
||||
};
|
||||
|
||||
const renderComponent = (props = {}) => {
|
||||
return render(
|
||||
<ThemeProvider theme={theme}>
|
||||
<CustomColorInput {...defaultProps} {...props} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders correctly with initial value', () => {
|
||||
renderComponent();
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
expect(input.value).toBe('#FF0000');
|
||||
expect(screen.getByTestId('color-picker')).toHaveTextContent('#FF0000');
|
||||
});
|
||||
|
||||
it('updates input value on change', () => {
|
||||
renderComponent();
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: '#00FF00' } });
|
||||
expect(input.value).toBe('#00FF00');
|
||||
});
|
||||
|
||||
it('calls onChange when valid hex is entered', () => {
|
||||
const onChange = jest.fn();
|
||||
renderComponent({ onChange });
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.change(input, { target: { value: '#00FF00' } });
|
||||
expect(onChange).toHaveBeenCalledWith('#00FF00');
|
||||
});
|
||||
|
||||
it('does not call onChange when invalid hex is entered', () => {
|
||||
const onChange = jest.fn();
|
||||
renderComponent({ onChange });
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'invalid' } });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reverts to prop value on blur if input is invalid', () => {
|
||||
renderComponent({ value: '#FF0000' });
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: 'invalid' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(input.value).toBe('#FF0000');
|
||||
});
|
||||
|
||||
it('keeps valid value on blur', () => {
|
||||
const onChange = jest.fn();
|
||||
renderComponent({ value: '#FF0000', onChange });
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: '#00FF00' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(input.value).toBe('#00FF00');
|
||||
expect(onChange).toHaveBeenCalledWith('#00FF00');
|
||||
});
|
||||
|
||||
it('updates local state when prop value changes', () => {
|
||||
const { rerender } = renderComponent({ value: '#FF0000' });
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
expect(input.value).toBe('#FF0000');
|
||||
|
||||
rerender(
|
||||
<ThemeProvider theme={theme}>
|
||||
<CustomColorInput {...defaultProps} value="#0000FF" />
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(input.value).toBe('#0000FF');
|
||||
});
|
||||
|
||||
describe('EyeDropper interaction', () => {
|
||||
beforeAll(() => {
|
||||
// Mock EyeDropper API
|
||||
Object.defineProperty(window, 'EyeDropper', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(() => ({
|
||||
open: jest.fn().mockResolvedValue({ sRGBHex: '#123456' })
|
||||
}))
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// @ts-ignore
|
||||
delete window.EyeDropper;
|
||||
});
|
||||
|
||||
it('renders eyedropper button when API is supported', () => {
|
||||
renderComponent();
|
||||
expect(screen.getByRole('button', { name: /pick color/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onChange with picked color', async () => {
|
||||
const onChange = jest.fn();
|
||||
renderComponent({ onChange });
|
||||
|
||||
const button = screen.getByRole('button', { name: /pick color/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(button);
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('#123456');
|
||||
});
|
||||
|
||||
it('handles EyeDropper cancellation gracefully', async () => {
|
||||
const onChange = jest.fn();
|
||||
// Mock rejection (user cancelled)
|
||||
(window.EyeDropper as any).mockImplementation(() => ({
|
||||
open: jest.fn().mockRejectedValue(new Error('Canceled'))
|
||||
}));
|
||||
|
||||
renderComponent({ onChange });
|
||||
|
||||
const button = screen.getByRole('button', { name: /pick color/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(button);
|
||||
});
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EyeDropper unsupported', () => {
|
||||
beforeAll(() => {
|
||||
// @ts-ignore
|
||||
window.EyeDropper = undefined;
|
||||
});
|
||||
|
||||
it('does not render eyedropper button when API is not supported', () => {
|
||||
renderComponent();
|
||||
expect(screen.queryByRole('button', { name: /pick color/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { useConnector } from 'src/hooks/useConnector';
|
||||
import { ColorSelector } from 'src/components/ColorSelector/ColorSelector';
|
||||
import { ColorPicker } from 'src/components/ColorSelector/ColorPicker';
|
||||
import { CustomColorInput } from 'src/components/ColorSelector/CustomColorInput';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { useScene } from 'src/hooks/useScene';
|
||||
import {
|
||||
@@ -317,17 +318,12 @@ export const ConnectorControls = ({ id }: Props) => {
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
{useCustomColor ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<ColorPicker
|
||||
value={connector.customColor || '#000000'}
|
||||
onChange={(color) => {
|
||||
updateConnector(connector.id, { customColor: color });
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{connector.customColor || '#000000'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<CustomColorInput
|
||||
value={connector.customColor || '#000000'}
|
||||
onChange={(color) => {
|
||||
updateConnector(connector.id, { customColor: color });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ColorSelector
|
||||
onChange={(color) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Box, IconButton as MUIIconButton, FormControlLabel, Switch, Typography
|
||||
import { useRectangle } from 'src/hooks/useRectangle';
|
||||
import { ColorSelector } from 'src/components/ColorSelector/ColorSelector';
|
||||
import { ColorPicker } from 'src/components/ColorSelector/ColorPicker';
|
||||
import { CustomColorInput } from 'src/components/ColorSelector/CustomColorInput';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { useScene } from 'src/hooks/useScene';
|
||||
import { Close as CloseIcon } from '@mui/icons-material';
|
||||
@@ -63,17 +64,12 @@ export const RectangleControls = ({ id }: Props) => {
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
{useCustomColor ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<ColorPicker
|
||||
value={rectangle.customColor || '#000000'}
|
||||
onChange={(color) => {
|
||||
updateRectangle(rectangle.id, { customColor: color });
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{rectangle.customColor || '#000000'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<CustomColorInput
|
||||
value={rectangle.customColor || '#000000'}
|
||||
onChange={(color) => {
|
||||
updateRectangle(rectangle.id, { customColor: color });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ColorSelector
|
||||
onChange={(color) => {
|
||||
|
||||
Reference in New Issue
Block a user