mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2025-12-23 22:48:57 -05:00
feat: Add custom icon import functionality with automatic scaling
- Allow users to import custom icons (PNG, JPG, SVG) with folder/file selection - Add isometric/flat toggle for imported icons to control 3D vs 2D display - Implement automatic image scaling to ensure consistent icon sizes (128x128px) - Scale both up and down as needed while maintaining aspect ratio - Store imported icons in "imported" collection with unique naming - Fix icon persistence across all save/load operations including server storage - Ensure exported JSON includes all imported icon data for portability - Smart detection of complete vs partial icon sets when loading from storage
This commit is contained in:
@@ -60,13 +60,34 @@ function App() {
|
||||
];
|
||||
|
||||
|
||||
const [diagramData, setDiagramData] = useState<DiagramData>({
|
||||
title: 'Untitled Diagram',
|
||||
icons: icons, // Keep full icon set for FossFLOW
|
||||
colors: defaultColors,
|
||||
items: [],
|
||||
views: [],
|
||||
fitToScreen: true
|
||||
const [diagramData, setDiagramData] = useState<DiagramData>(() => {
|
||||
// Initialize with last opened data if available
|
||||
const lastOpenedData = localStorage.getItem('fossflow-last-opened-data');
|
||||
if (lastOpenedData) {
|
||||
try {
|
||||
const data = JSON.parse(lastOpenedData);
|
||||
const importedIcons = (data.icons || []).filter((icon: any) => icon.collection === 'imported');
|
||||
const mergedIcons = [...icons, ...importedIcons];
|
||||
return {
|
||||
...data,
|
||||
icons: mergedIcons,
|
||||
colors: data.colors?.length ? data.colors : defaultColors,
|
||||
fitToScreen: data.fitToScreen !== false
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Failed to load last opened data:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Default state if no saved data
|
||||
return {
|
||||
title: 'Untitled Diagram',
|
||||
icons: icons,
|
||||
colors: defaultColors,
|
||||
items: [],
|
||||
views: [],
|
||||
fitToScreen: true
|
||||
};
|
||||
});
|
||||
|
||||
// Check for server storage availability
|
||||
@@ -83,35 +104,24 @@ function App() {
|
||||
setDiagrams(JSON.parse(savedDiagrams));
|
||||
}
|
||||
|
||||
// Load last opened diagram
|
||||
// Load last opened diagram metadata (data is already loaded in state initialization)
|
||||
const lastOpenedId = localStorage.getItem('fossflow-last-opened');
|
||||
const lastOpenedData = localStorage.getItem('fossflow-last-opened-data');
|
||||
|
||||
if (lastOpenedId && lastOpenedData) {
|
||||
if (lastOpenedId && savedDiagrams) {
|
||||
try {
|
||||
const data = JSON.parse(lastOpenedData);
|
||||
// Always include full icon set
|
||||
const dataWithIcons = {
|
||||
...data,
|
||||
icons: icons // Replace with full icon set
|
||||
};
|
||||
setDiagramData(dataWithIcons);
|
||||
setCurrentModel(dataWithIcons);
|
||||
|
||||
// Find and set the diagram metadata
|
||||
if (savedDiagrams) {
|
||||
const allDiagrams = JSON.parse(savedDiagrams);
|
||||
const lastDiagram = allDiagrams.find((d: SavedDiagram) => d.id === lastOpenedId);
|
||||
if (lastDiagram) {
|
||||
setCurrentDiagram(lastDiagram);
|
||||
setDiagramName(lastDiagram.name);
|
||||
}
|
||||
const allDiagrams = JSON.parse(savedDiagrams);
|
||||
const lastDiagram = allDiagrams.find((d: SavedDiagram) => d.id === lastOpenedId);
|
||||
if (lastDiagram) {
|
||||
setCurrentDiagram(lastDiagram);
|
||||
setDiagramName(lastDiagram.name);
|
||||
// Also set currentModel to match diagramData
|
||||
setCurrentModel(diagramData);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to restore last diagram:', e);
|
||||
console.error('Failed to restore last diagram metadata:', e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [diagramData]);
|
||||
|
||||
// Save diagrams to localStorage whenever they change
|
||||
useEffect(() => {
|
||||
@@ -153,10 +163,13 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
// Construct save data WITHOUT icons (they're loaded separately)
|
||||
// Construct save data - include only imported icons
|
||||
const importedIcons = (currentModel?.icons || diagramData.icons || [])
|
||||
.filter(icon => icon.collection === 'imported');
|
||||
|
||||
const savedData = {
|
||||
title: diagramName,
|
||||
icons: [], // Don't save icons with diagram
|
||||
icons: importedIcons, // Save only imported icons with diagram
|
||||
colors: currentModel?.colors || diagramData.colors || [],
|
||||
items: currentModel?.items || diagramData.items || [],
|
||||
views: currentModel?.views || diagramData.views || [],
|
||||
@@ -208,10 +221,12 @@ function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always ensure icons are present when loading
|
||||
// Merge imported icons with default icon set
|
||||
const importedIcons = (diagram.data.icons || []).filter((icon: any) => icon.collection === 'imported');
|
||||
const mergedIcons = [...icons, ...importedIcons];
|
||||
const dataWithIcons = {
|
||||
...diagram.data,
|
||||
icons: icons // Replace with full icon set
|
||||
icons: mergedIcons
|
||||
};
|
||||
|
||||
setCurrentDiagram(diagram);
|
||||
@@ -270,39 +285,57 @@ function App() {
|
||||
|
||||
const handleModelUpdated = (model: any) => {
|
||||
// Store the current model state whenever it updates
|
||||
// Model update received
|
||||
// The model from Isoflow contains the COMPLETE state including all icons
|
||||
|
||||
// Deep merge the model update with our current state
|
||||
// This handles both complete and partial updates
|
||||
setCurrentModel((prevModel: DiagramData | null) => {
|
||||
const merged = {
|
||||
// Start with previous model or diagram data
|
||||
...(prevModel || diagramData),
|
||||
// Override with any new data from the model update
|
||||
...model,
|
||||
// Ensure we always have required fields
|
||||
title: model.title || prevModel?.title || diagramData.title || diagramName || 'Untitled',
|
||||
// Keep icons in the data structure for FossFLOW to work
|
||||
icons: icons, // Always use full icon set
|
||||
colors: model.colors || prevModel?.colors || diagramData.colors || [],
|
||||
// These fields likely come from the model update
|
||||
items: model.items !== undefined ? model.items : (prevModel?.items || diagramData.items || []),
|
||||
views: model.views !== undefined ? model.views : (prevModel?.views || diagramData.views || []),
|
||||
fitToScreen: true
|
||||
};
|
||||
setHasUnsavedChanges(true);
|
||||
return merged;
|
||||
});
|
||||
// Simply store the complete model as-is since it has everything
|
||||
const updatedModel = {
|
||||
title: model.title || diagramName || 'Untitled',
|
||||
icons: model.icons || [], // This already includes ALL icons (default + imported)
|
||||
colors: model.colors || defaultColors,
|
||||
items: model.items || [],
|
||||
views: model.views || [],
|
||||
fitToScreen: true
|
||||
};
|
||||
|
||||
setCurrentModel(updatedModel);
|
||||
setDiagramData(updatedModel);
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
|
||||
const exportDiagram = () => {
|
||||
// For export, DO include icons so the file is self-contained
|
||||
// Use the most recent model data - prefer currentModel as it gets updated by handleModelUpdated
|
||||
const modelToExport = currentModel || diagramData;
|
||||
|
||||
// Get ALL icons from the current model (which includes both default and imported)
|
||||
const allModelIcons = modelToExport.icons || [];
|
||||
|
||||
// For safety, also check diagramData for any imported icons not in currentModel
|
||||
const diagramImportedIcons = (diagramData.icons || []).filter(icon => icon.collection === 'imported');
|
||||
|
||||
// Create a map to deduplicate icons by ID, preferring the ones from currentModel
|
||||
const iconMap = new Map();
|
||||
|
||||
// First add all icons from the model (includes defaults + imported)
|
||||
allModelIcons.forEach(icon => {
|
||||
iconMap.set(icon.id, icon);
|
||||
});
|
||||
|
||||
// Then add any imported icons from diagramData that might be missing
|
||||
diagramImportedIcons.forEach(icon => {
|
||||
if (!iconMap.has(icon.id)) {
|
||||
iconMap.set(icon.id, icon);
|
||||
}
|
||||
});
|
||||
|
||||
// Get all unique icons
|
||||
const allIcons = Array.from(iconMap.values());
|
||||
|
||||
const exportData = {
|
||||
title: diagramName || currentModel?.title || diagramData.title || 'Exported Diagram',
|
||||
icons: icons, // Include ALL icons for portability
|
||||
colors: currentModel?.colors || diagramData.colors || [],
|
||||
items: currentModel?.items || diagramData.items || [],
|
||||
views: currentModel?.views || diagramData.views || [],
|
||||
title: diagramName || modelToExport.title || 'Exported Diagram',
|
||||
icons: allIcons, // Include ALL icons (default + imported) for portability
|
||||
colors: modelToExport.colors || [],
|
||||
items: modelToExport.items || [],
|
||||
views: modelToExport.views || [],
|
||||
fitToScreen: true
|
||||
};
|
||||
|
||||
@@ -331,11 +364,13 @@ function App() {
|
||||
const content = e.target?.result as string;
|
||||
const parsedData = JSON.parse(content);
|
||||
|
||||
// Merge imported data with our icons
|
||||
// Merge imported icons with default icon set
|
||||
const importedIcons = (parsedData.icons || []).filter((icon: any) => icon.collection === 'imported');
|
||||
const mergedIcons = [...icons, ...importedIcons];
|
||||
const mergedData: DiagramData = {
|
||||
...parsedData,
|
||||
title: parsedData.title || 'Imported Diagram',
|
||||
icons: icons, // Always use app icons
|
||||
icons: mergedIcons, // Merge default and imported icons
|
||||
colors: parsedData.colors?.length ? parsedData.colors : defaultColors,
|
||||
fitToScreen: parsedData.fitToScreen !== false
|
||||
};
|
||||
@@ -375,10 +410,30 @@ function App() {
|
||||
|
||||
const handleDiagramManagerLoad = (id: string, data: any) => {
|
||||
// Load diagram from server storage
|
||||
// Server storage contains ALL icons (including imported), so use them directly
|
||||
const loadedIcons = data.icons || [];
|
||||
|
||||
// Check if we have all default icons in the loaded data
|
||||
const hasAllDefaults = icons.every(defaultIcon =>
|
||||
loadedIcons.some((loadedIcon: any) => loadedIcon.id === defaultIcon.id)
|
||||
);
|
||||
|
||||
// If the saved data has all icons, use it as-is
|
||||
// Otherwise, merge imported icons with defaults (for backward compatibility)
|
||||
let finalIcons;
|
||||
if (hasAllDefaults) {
|
||||
// Server saved all icons, use them directly
|
||||
finalIcons = loadedIcons;
|
||||
} else {
|
||||
// Old format or session storage - merge imported with defaults
|
||||
const importedIcons = loadedIcons.filter((icon: any) => icon.collection === 'imported');
|
||||
finalIcons = [...icons, ...importedIcons];
|
||||
}
|
||||
|
||||
const mergedData: DiagramData = {
|
||||
...data,
|
||||
title: data.title || data.name || 'Loaded Diagram',
|
||||
icons: icons, // Always use app icons
|
||||
icons: finalIcons,
|
||||
colors: data.colors?.length ? data.colors : defaultColors,
|
||||
fitToScreen: data.fitToScreen !== false
|
||||
};
|
||||
@@ -402,9 +457,13 @@ function App() {
|
||||
if (!currentModel || !hasUnsavedChanges || !currentDiagram) return;
|
||||
|
||||
const autoSaveTimer = setTimeout(() => {
|
||||
// Include imported icons in auto-save
|
||||
const importedIcons = (currentModel?.icons || diagramData.icons || [])
|
||||
.filter(icon => icon.collection === 'imported');
|
||||
|
||||
const savedData = {
|
||||
title: diagramName || currentDiagram.name,
|
||||
icons: [], // Don't save icons in auto-save
|
||||
icons: importedIcons, // Save imported icons in auto-save
|
||||
colors: currentModel.colors || [],
|
||||
items: currentModel.items || [],
|
||||
views: currentModel.views || [],
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { Stack, Alert, IconButton as MUIIconButton, Box } from '@mui/material';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { Stack, Alert, IconButton as MUIIconButton, Box, Button, FormControlLabel, Checkbox, Typography } from '@mui/material';
|
||||
import { ControlsContainer } from 'src/components/ItemControls/components/ControlsContainer';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { useModelStore } from 'src/stores/modelStore';
|
||||
import { Icon } from 'src/types';
|
||||
import { Section } from 'src/components/ItemControls/components/Section';
|
||||
import { Searchbox } from 'src/components/ItemControls/IconSelectionControls/Searchbox';
|
||||
import { useIconFiltering } from 'src/hooks/useIconFiltering';
|
||||
import { useIconCategories } from 'src/hooks/useIconCategories';
|
||||
import { Close as CloseIcon } from '@mui/icons-material';
|
||||
import { Close as CloseIcon, FileUpload as FileUploadIcon } from '@mui/icons-material';
|
||||
import { Icons } from './Icons';
|
||||
import { IconGrid } from './IconGrid';
|
||||
import { generateId } from 'src/utils';
|
||||
|
||||
export const IconSelectionControls = () => {
|
||||
const uiStateActions = useUiStateStore((state) => {
|
||||
@@ -18,8 +20,13 @@ export const IconSelectionControls = () => {
|
||||
const mode = useUiStateStore((state) => {
|
||||
return state.mode;
|
||||
});
|
||||
const iconCategoriesState = useUiStateStore((state) => state.iconCategoriesState);
|
||||
const modelActions = useModelStore((state) => state.actions);
|
||||
const currentIcons = useModelStore((state) => state.icons);
|
||||
const { setFilter, filteredIcons, filter } = useIconFiltering();
|
||||
const { iconCategories } = useIconCategories();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [treatAsIsometric, setTreatAsIsometric] = useState(true);
|
||||
|
||||
const onMouseDown = useCallback(
|
||||
(icon: Icon) => {
|
||||
@@ -34,6 +41,127 @@ export const IconSelectionControls = () => {
|
||||
[mode, uiStateActions]
|
||||
);
|
||||
|
||||
const handleImportClick = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const newIcons: Icon[] = [];
|
||||
const existingNames = new Set(currentIcons.map(icon => icon.name.toLowerCase()));
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
|
||||
// Check if file is an image
|
||||
if (!file.type.startsWith('image/')) {
|
||||
console.warn(`Skipping non-image file: ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate unique name
|
||||
let baseName = file.name.replace(/\.[^/.]+$/, ''); // Remove extension
|
||||
let finalName = baseName;
|
||||
let counter = 1;
|
||||
|
||||
while (existingNames.has(finalName.toLowerCase())) {
|
||||
finalName = `${baseName}_${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
existingNames.add(finalName.toLowerCase());
|
||||
|
||||
// Load and scale the image
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
const originalDataUrl = e.target?.result as string;
|
||||
|
||||
// For SVG files, use as-is since they scale naturally
|
||||
if (file.type === 'image/svg+xml') {
|
||||
resolve(originalDataUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// For raster images, scale them to fit in a square bounding box
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// Create canvas for scaling
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
resolve(originalDataUrl); // Fallback to original
|
||||
return;
|
||||
}
|
||||
|
||||
// Use a square target size for consistent display
|
||||
// This ensures all icons have the same bounding box
|
||||
const TARGET_SIZE = 128; // Square size for consistency
|
||||
|
||||
// Calculate scaling to fit within square while maintaining aspect ratio
|
||||
// Remove the upper limit (1) to allow upscaling of small images
|
||||
const scale = Math.min(TARGET_SIZE / img.width, TARGET_SIZE / img.height);
|
||||
const scaledWidth = img.width * scale;
|
||||
const scaledHeight = img.height * scale;
|
||||
|
||||
// Set canvas to square size
|
||||
canvas.width = TARGET_SIZE;
|
||||
canvas.height = TARGET_SIZE;
|
||||
|
||||
// Clear canvas with transparent background
|
||||
ctx.clearRect(0, 0, TARGET_SIZE, TARGET_SIZE);
|
||||
|
||||
// Calculate position to center the image in the square
|
||||
const x = (TARGET_SIZE - scaledWidth) / 2;
|
||||
const y = (TARGET_SIZE - scaledHeight) / 2;
|
||||
|
||||
// Enable image smoothing for better quality
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
// Draw scaled and centered image
|
||||
ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
|
||||
|
||||
// Convert to data URL (using PNG for transparency)
|
||||
resolve(canvas.toDataURL('image/png'));
|
||||
};
|
||||
img.onerror = () => reject(new Error('Failed to load image'));
|
||||
img.src = originalDataUrl;
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
newIcons.push({
|
||||
id: generateId(),
|
||||
name: finalName,
|
||||
url: dataUrl,
|
||||
collection: 'imported',
|
||||
isIsometric: treatAsIsometric // Use user's preference
|
||||
});
|
||||
}
|
||||
|
||||
if (newIcons.length > 0) {
|
||||
// Add new icons to the model
|
||||
const updatedIcons = [...currentIcons, ...newIcons];
|
||||
modelActions.set({ icons: updatedIcons });
|
||||
|
||||
// Update icon categories to include imported collection
|
||||
const hasImported = iconCategoriesState.some(cat => cat.id === 'imported');
|
||||
if (!hasImported) {
|
||||
uiStateActions.setIconCategoriesState([
|
||||
...iconCategoriesState,
|
||||
{ id: 'imported', isExpanded: true }
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset input
|
||||
event.target.value = '';
|
||||
}, [currentIcons, modelActions, iconCategoriesState, uiStateActions, treatAsIsometric]);
|
||||
|
||||
return (
|
||||
<ControlsContainer
|
||||
header={
|
||||
@@ -68,6 +196,47 @@ export const IconSelectionControls = () => {
|
||||
<Box sx={{ marginTop: '8px' }}>
|
||||
<Searchbox value={filter} onChange={setFilter} />
|
||||
</Box>
|
||||
<Box sx={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 1,
|
||||
p: 1.5,
|
||||
backgroundColor: '#f5f5f5'
|
||||
}}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<FileUploadIcon />}
|
||||
onClick={handleImportClick}
|
||||
fullWidth
|
||||
>
|
||||
Import Icons
|
||||
</Button>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={treatAsIsometric}
|
||||
onChange={(e) => setTreatAsIsometric(e.target.checked)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2">
|
||||
Treat as isometric (3D view)
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mt: 1, ml: 0 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
Uncheck for flat icons (logos, UI elements)
|
||||
</Typography>
|
||||
</Box>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Alert severity="info">
|
||||
You can drag and drop any item below onto the canvas.
|
||||
</Alert>
|
||||
|
||||
Reference in New Issue
Block a user