Image resizing on the basis of diagram window size for Export as Image Option (#146)

* Image resizing on the basis of diagram window size for Export as Image option

* Restored the default original state, implemented an alternative approach by allowing users to crop via click and drag feature along with recrop button

Thanks to @F4tal1t
This commit is contained in:
Dibyendu Sahoo
2025-09-30 00:03:49 +05:30
committed by GitHub
parent 305a2b1691
commit c664cfc8b2
3 changed files with 430 additions and 45 deletions

View File

@@ -25,7 +25,7 @@ import {
generateGenericFilename,
modelFromModelStore
} from 'src/utils';
import { ModelStore } from 'src/types';
import { ModelStore, Size, Coords } from 'src/types';
import { useDiagramUtils } from 'src/hooks/useDiagramUtils';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { Isoflow } from 'src/Isoflow';
@@ -38,23 +38,36 @@ interface Props {
onClose: () => void;
}
interface CropArea {
x: number;
y: number;
width: number;
height: number;
}
export const ExportImageDialog = ({ onClose, quality = 1.5 }: Props) => {
const containerRef = useRef<HTMLDivElement>();
const cropCanvasRef = useRef<HTMLCanvasElement>(null);
const isExporting = useRef<boolean>(false);
const currentView = useUiStateStore((state) => {
return state.view;
});
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState<Coords | null>(null);
const currentView = useUiStateStore((state) => state.view);
const [imageData, setImageData] = React.useState<string>();
const [croppedImageData, setCroppedImageData] = useState<string>();
const [exportError, setExportError] = useState(false);
const { getUnprojectedBounds } = useDiagramUtils();
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
const uiStateActions = useUiStateStore((state) => state.actions);
const model = useModelStore((state): Omit<ModelStore, 'actions'> => {
return modelFromModelStore(state);
});
const unprojectedBounds = useMemo(() => {
// Crop states
const [cropToContent, setCropToContent] = useState(false);
const [cropArea, setCropArea] = useState<CropArea | null>(null);
const [isInCropMode, setIsInCropMode] = useState(false);
// Use original bounds for the base image
const bounds = useMemo(() => {
return getUnprojectedBounds();
}, [getUnprojectedBounds]);
@@ -71,7 +84,13 @@ export const ExportImageDialog = ({ onClose, quality = 1.5 }: Props) => {
}
isExporting.current = true;
exportAsImage(containerRef.current as HTMLDivElement)
const containerSize = {
width: bounds.width * quality,
height: bounds.height * quality
};
exportAsImage(containerRef.current as HTMLDivElement, containerSize)
.then((data) => {
setImageData(data);
isExporting.current = false;
@@ -81,8 +100,197 @@ export const ExportImageDialog = ({ onClose, quality = 1.5 }: Props) => {
setExportError(true);
isExporting.current = false;
});
}, [bounds, quality]);
// Crop the image based on selected area
const cropImage = useCallback((cropArea: CropArea, sourceImage: string) => {
return new Promise<string>((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
// Calculate the scaling factors between display canvas (500x300) and actual image
const displayCanvas = cropCanvasRef.current;
if (!displayCanvas) {
reject(new Error('Display canvas not found'));
return;
}
const scaleX = img.width / displayCanvas.width;
const scaleY = img.height / displayCanvas.height;
// Calculate the actual crop area in the source image coordinates
const actualCropArea = {
x: cropArea.x * scaleX,
y: cropArea.y * scaleY,
width: cropArea.width * scaleX,
height: cropArea.height * scaleY
};
// Set canvas size to the actual crop dimensions
canvas.width = actualCropArea.width;
canvas.height = actualCropArea.height;
if (ctx) {
// Draw the cropped portion from the source image
ctx.drawImage(
img,
actualCropArea.x, actualCropArea.y, actualCropArea.width, actualCropArea.height,
0, 0, actualCropArea.width, actualCropArea.height
);
resolve(canvas.toDataURL('image/png'));
} else {
reject(new Error('Could not get canvas context'));
}
};
img.onerror = () => reject(new Error('Failed to load image'));
img.src = sourceImage;
});
}, []);
// Handle crop area generation - only when not in crop mode (after applying)
useEffect(() => {
if (cropToContent && cropArea && imageData && !isInCropMode) {
cropImage(cropArea, imageData)
.then(setCroppedImageData)
.catch(console.error);
} else if (!cropToContent || !cropArea) {
setCroppedImageData(undefined);
}
}, [cropArea, imageData, cropToContent, cropImage, isInCropMode]);
// Mouse handlers for crop selection
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isInCropMode) return;
e.preventDefault();
const canvas = cropCanvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setDragStart({ x, y });
setIsDragging(true);
setCropArea(null);
}, [isInCropMode]);
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDragging || !dragStart || !isInCropMode) return;
e.preventDefault();
const canvas = cropCanvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const newCropArea: CropArea = {
x: Math.min(dragStart.x, x),
y: Math.min(dragStart.y, y),
width: Math.abs(x - dragStart.x),
height: Math.abs(y - dragStart.y)
};
setCropArea(newCropArea);
}, [isDragging, dragStart, isInCropMode]);
const handleMouseUp = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDragging) return;
e.preventDefault();
setIsDragging(false);
setDragStart(null);
}, [isDragging]);
// Add mouse leave handler to stop dragging when leaving canvas
const handleMouseLeave = useCallback(() => {
setIsDragging(false);
setDragStart(null);
}, []);
// Draw crop overlay
useEffect(() => {
const canvas = cropCanvasRef.current;
if (!canvas || !imageData) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const img = new Image();
img.onload = () => {
// Calculate scaling factors between canvas and actual image
const scaleX = img.width / canvas.width;
const scaleY = img.height / canvas.height;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw the image scaled to fit canvas
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Draw crop overlay if in crop mode
if (isInCropMode) {
// Semi-transparent overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Clear crop area and draw border only if there's a valid selection
if (cropArea && cropArea.width > 5 && cropArea.height > 5) {
// Clear the selected area (remove overlay)
ctx.clearRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
// Redraw the original image in the selected area
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Redraw the overlay everywhere except the selected area
ctx.save();
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
// Top area
if (cropArea.y > 0) {
ctx.fillRect(0, 0, canvas.width, cropArea.y);
}
// Bottom area
if (cropArea.y + cropArea.height < canvas.height) {
ctx.fillRect(0, cropArea.y + cropArea.height, canvas.width, canvas.height - (cropArea.y + cropArea.height));
}
// Left area
if (cropArea.x > 0) {
ctx.fillRect(0, cropArea.y, cropArea.x, cropArea.height);
}
// Right area
if (cropArea.x + cropArea.width < canvas.width) {
ctx.fillRect(cropArea.x + cropArea.width, cropArea.y, canvas.width - (cropArea.x + cropArea.width), cropArea.height);
}
ctx.restore();
// Draw crop border
ctx.strokeStyle = '#2196f3';
ctx.lineWidth = 2;
ctx.strokeRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
}
// Add instruction text only when no selection or dragging
if (!cropArea || cropArea.width <= 5 || cropArea.height <= 5) {
ctx.fillStyle = 'white';
ctx.font = '14px Arial';
ctx.textAlign = 'left';
ctx.fillText('Click and drag to select crop area', 10, 25);
}
}
};
img.src = imageData;
}, [imageData, isInCropMode, cropArea]);
const [showGrid, setShowGrid] = useState(false);
const handleShowGridChange = (checked: boolean) => {
setShowGrid(checked);
@@ -95,39 +303,73 @@ export const ExportImageDialog = ({ onClose, quality = 1.5 }: Props) => {
setBackgroundColor(color);
};
// Reset image data when options change and trigger export
useEffect(() => {
setImageData(undefined);
setExportError(false);
isExporting.current = false;
const timer = setTimeout(() => {
exportImage();
}, 100);
const handleCropToContentChange = (checked: boolean) => {
setCropToContent(checked);
if (checked) {
setIsInCropMode(true);
setCropArea(null);
setCroppedImageData(undefined);
setIsDragging(false);
setDragStart(null);
} else {
setIsInCropMode(false);
setCropArea(null);
setCroppedImageData(undefined);
setIsDragging(false);
setDragStart(null);
}
};
return () => clearTimeout(timer);
}, [showGrid, backgroundColor]);
const handleRecrop = () => {
setIsInCropMode(true);
setCropArea(null);
setCroppedImageData(undefined);
setIsDragging(false);
setDragStart(null);
};
const handleAcceptCrop = () => {
setIsInCropMode(false);
};
// Reset image data when non-crop options change
useEffect(() => {
if (!cropToContent) {
setImageData(undefined);
setExportError(false);
isExporting.current = false;
const timer = setTimeout(() => {
exportImage();
}, 200);
return () => clearTimeout(timer);
}
}, [showGrid, backgroundColor, exportImage, cropToContent]);
useEffect(() => {
const timer = setTimeout(() => {
exportImage();
}, 100);
return () => clearTimeout(timer);
}, []);
if (!imageData) {
const timer = setTimeout(() => {
exportImage();
}, 200);
return () => clearTimeout(timer);
}
}, [exportImage, imageData]);
const downloadFile = useCallback(() => {
if (!imageData) return;
const dataToDownload = croppedImageData || imageData;
if (!dataToDownload) return;
const data = base64ToBlob(
imageData.replace('data:image/png;base64,', ''),
dataToDownload.replace('data:image/png;base64,', ''),
'image/png;charset=utf-8'
);
downloadFileUtil(data, generateGenericFilename('png'));
}, [imageData]);
}, [imageData, croppedImageData]);
const displayImage = croppedImageData || imageData;
return (
<Dialog open onClose={onClose}>
<Dialog open onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>Export as image</DialogTitle>
<DialogContent>
<Stack spacing={2}>
@@ -158,8 +400,8 @@ export const ExportImageDialog = ({ onClose, quality = 1.5 }: Props) => {
left: 0
}}
style={{
width: unprojectedBounds.width * quality,
height: unprojectedBounds.height * quality
width: bounds.width * quality,
height: bounds.height * quality
}}
>
<Isoflow
@@ -191,18 +433,48 @@ export const ExportImageDialog = ({ onClose, quality = 1.5 }: Props) => {
</>
)}
<Stack alignItems="center" spacing={2}>
{imageData && (
<Box
component="img"
sx={{
maxWidth: '100%'
}}
style={{
width: unprojectedBounds.width
}}
src={imageData}
alt="preview"
/>
{displayImage && (
<Box sx={{ position: 'relative', maxWidth: '100%' }}>
{cropToContent && !croppedImageData ? (
<Box>
<canvas
ref={cropCanvasRef}
width={500}
height={300}
style={{
maxWidth: '100%',
maxHeight: '300px',
cursor: isInCropMode ? (isDragging ? 'grabbing' : 'crosshair') : 'default',
border: isInCropMode ? '2px solid #2196f3' : 'none',
userSelect: 'none'
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onContextMenu={(e) => e.preventDefault()}
/>
{isInCropMode && (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="primary">
Click and drag to select the area you want to export
</Typography>
</Box>
)}
</Box>
) : (
<Box
component="img"
sx={{
maxWidth: '100%',
maxHeight: '300px',
objectFit: 'contain'
}}
src={displayImage}
alt="preview"
/>
)}
</Box>
)}
<Box sx={{ width: '100%' }}>
<Box component="fieldset">
@@ -222,6 +494,18 @@ export const ExportImageDialog = ({ onClose, quality = 1.5 }: Props) => {
/>
}
/>
<FormControlLabel
label="Crop to content"
control={
<Checkbox
size="small"
checked={cropToContent}
onChange={(event) => {
handleCropToContentChange(event.target.checked);
}}
/>
}
/>
<FormControlLabel
label="Background color"
control={
@@ -232,14 +516,49 @@ export const ExportImageDialog = ({ onClose, quality = 1.5 }: Props) => {
}
/>
</Box>
{/* Crop controls */}
{cropToContent && imageData && (
<Box sx={{ mt: 2 }}>
{croppedImageData ? (
<Stack direction="row" spacing={1}>
<Button variant="outlined" size="small" onClick={handleRecrop}>
Recrop
</Button>
<Typography variant="caption" sx={{ alignSelf: 'center' }}>
Crop applied successfully
</Typography>
</Stack>
) : cropArea ? (
<Stack direction="row" spacing={1}>
<Button variant="contained" size="small" onClick={handleAcceptCrop}>
Apply Crop
</Button>
<Button variant="outlined" size="small" onClick={() => setCropArea(null)}>
Clear Selection
</Button>
</Stack>
) : isInCropMode ? (
<Typography variant="caption" color="text.secondary">
Select an area to crop, or uncheck "Crop to content" to use full image
</Typography>
) : null}
</Box>
)}
</Box>
{imageData && (
{displayImage && (
<Stack sx={{ width: '100%' }} alignItems="flex-end">
<Stack direction="row" spacing={2}>
<Button variant="text" onClick={onClose}>
Cancel
</Button>
<Button onClick={downloadFile}>Download as PNG</Button>
<Button
onClick={downloadFile}
disabled={cropToContent && isInCropMode && !croppedImageData}
>
Download as PNG
</Button>
</Stack>
</Stack>
)}

View File

@@ -3,6 +3,7 @@ import { useUiStateStore } from 'src/stores/uiStateStore';
import { Size, Coords } from 'src/types';
import {
getUnprojectedBounds as getUnprojectedBoundsUtil,
getVisualBounds as getVisualBoundsUtil,
getFitToViewParams as getFitToViewParamsUtil,
CoordsUtils
} from 'src/utils';
@@ -23,6 +24,10 @@ export const useDiagramUtils = () => {
return getUnprojectedBoundsUtil(scene.currentView);
}, [scene.currentView]);
const getVisualBounds = useCallback((): Size & Coords => {
return getVisualBoundsUtil(scene.currentView);
}, [scene.currentView]);
const getFitToViewParams = useCallback(
(viewportSize: Size) => {
return getFitToViewParamsUtil(scene.currentView, viewportSize);
@@ -42,6 +47,7 @@ export const useDiagramUtils = () => {
return {
getUnprojectedBounds,
getVisualBounds,
fitToView,
getFitToViewParams
};

View File

@@ -717,6 +717,66 @@ export const getProjectBounds = (
return corners;
};
export const getVisualBounds = (view: View, padding = 50) => {
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
// Collect actual content positions and find extremes
view.items.forEach((item) => {
const pos = getTilePosition({ tile: item.tile });
const itemSize = 50;
minX = Math.min(minX, pos.x - itemSize/2);
maxX = Math.max(maxX, pos.x + itemSize/2);
minY = Math.min(minY, pos.y - itemSize/2);
maxY = Math.max(maxY, pos.y + itemSize/2);
});
const connectors = view.connectors ?? [];
connectors.forEach((connector) => {
const path = getConnectorPath({ anchors: connector.anchors, view });
path.tiles.forEach((tile) => {
const globalTile = connectorPathTileToGlobal(tile, path.rectangle.from);
const pos = getTilePosition({ tile: globalTile });
minX = Math.min(minX, pos.x);
maxX = Math.max(maxX, pos.x);
minY = Math.min(minY, pos.y);
maxY = Math.max(maxY, pos.y);
});
});
const textBoxes = view.textBoxes ?? [];
textBoxes.forEach((textBox) => {
const pos = getTilePosition({ tile: textBox.tile });
const size = getTextBoxDimensions(textBox);
const endPos = getTilePosition({ tile: getTextBoxEndTile(textBox, size) });
minX = Math.min(minX, pos.x, endPos.x);
maxX = Math.max(maxX, pos.x, endPos.x);
minY = Math.min(minY, pos.y, endPos.y);
maxY = Math.max(maxY, pos.y, endPos.y);
});
const rectangles = view.rectangles ?? [];
rectangles.forEach((rectangle) => {
const fromPos = getTilePosition({ tile: rectangle.from });
const toPos = getTilePosition({ tile: rectangle.to });
minX = Math.min(minX, fromPos.x, toPos.x);
maxX = Math.max(maxX, fromPos.x, toPos.x);
minY = Math.min(minY, fromPos.y, toPos.y);
maxY = Math.max(maxY, fromPos.y, toPos.y);
});
if (minX === Infinity) {
return { x: 0, y: 0, width: 200, height: 200 };
}
// Create tight bounds around actual content extremes
return {
x: minX - padding,
y: minY - padding,
width: (maxX - minX) + (padding * 2),
height: (maxY - minY) + (padding * 2)
};
};
export const getUnprojectedBounds = (view: View) => {
const projectBounds = getProjectBounds(view);