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:
Stan
2025-08-17 21:09:02 +01:00
parent 2310b85995
commit dd80e86de2
2 changed files with 296 additions and 68 deletions

View File

@@ -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 || [],

View File

@@ -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>