Files
paint-ui/components/modals/canvas-resize-dialog.tsx
Sebastian Krüger 5f389c7b71 feat(phase-13): implement canvas resize dialog with multiple methods
Add comprehensive canvas resize functionality with three resize methods.

Features:
- Canvas Size dialog accessible from Image menu
- Three resize methods:
  * Scale: Stretch/shrink content to fit new size
  * Crop: Crop or center content without scaling
  * Expand: Expand canvas, center existing content
- Maintain aspect ratio toggle with link/unlink button
- Dimension validation (1-10000 pixels)
- Real-time preview of new dimensions
- Applies to all layers simultaneously
- Toast notifications for success/errors

Changes:
- Created components/modals/canvas-resize-dialog.tsx
- Added "Canvas Size..." menu item to Image menu
- Integrated with useLayerStore for layer updates
- Uses toast utility for user feedback
- Validates dimensions before applying
- Supports linked/unlinked aspect ratio

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 20:09:52 +01:00

239 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useCallback } from 'react';
import { X, Link, Unlink } from 'lucide-react';
import { useLayerStore, useCanvasStore } from '@/store';
import { toast } from '@/lib/toast-utils';
interface CanvasResizeDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function CanvasResizeDialog({ isOpen, onClose }: CanvasResizeDialogProps) {
const { layers } = useLayerStore();
// Get current canvas dimensions from first layer
const currentWidth = layers[0]?.width || 800;
const currentHeight = layers[0]?.height || 600;
const [width, setWidth] = useState(currentWidth);
const [height, setHeight] = useState(currentHeight);
const [maintainAspectRatio, setMaintainAspectRatio] = useState(true);
const [resizeMethod, setResizeMethod] = useState<'scale' | 'crop' | 'expand'>('scale');
const aspectRatio = currentWidth / currentHeight;
const handleWidthChange = useCallback(
(newWidth: number) => {
setWidth(newWidth);
if (maintainAspectRatio) {
setHeight(Math.round(newWidth / aspectRatio));
}
},
[maintainAspectRatio, aspectRatio]
);
const handleHeightChange = useCallback(
(newHeight: number) => {
setHeight(newHeight);
if (maintainAspectRatio) {
setWidth(Math.round(newHeight * aspectRatio));
}
},
[maintainAspectRatio, aspectRatio]
);
const handleApply = useCallback(() => {
if (width < 1 || height < 1 || width > 10000 || height > 10000) {
toast.error('Width and height must be between 1 and 10000 pixels');
return;
}
// Apply resize based on method
const { updateLayer } = useLayerStore.getState();
layers.forEach((layer) => {
if (!layer.canvas) return;
const newCanvas = document.createElement('canvas');
newCanvas.width = width;
newCanvas.height = height;
const ctx = newCanvas.getContext('2d');
if (!ctx) return;
switch (resizeMethod) {
case 'scale':
// Scale the content to fit new size
ctx.drawImage(layer.canvas, 0, 0, width, height);
break;
case 'crop':
// Crop/center the content
const offsetX = Math.max(0, (width - layer.width) / 2);
const offsetY = Math.max(0, (height - layer.height) / 2);
ctx.drawImage(layer.canvas, offsetX, offsetY);
break;
case 'expand':
// Expand canvas, center existing content
const centerX = (width - layer.width) / 2;
const centerY = (height - layer.height) / 2;
ctx.drawImage(layer.canvas, centerX, centerY);
break;
}
updateLayer(layer.id, {
canvas: newCanvas,
width,
height,
});
});
toast.success(`Canvas resized to ${width} × ${height}px`);
onClose();
}, [width, height, resizeMethod, layers, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-card border border-border rounded-lg shadow-lg w-full max-w-md">
{/* Header */}
<div className="flex items-center justify-between border-b border-border p-4">
<h2 className="text-lg font-semibold text-foreground">Canvas Size</h2>
<button
onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Current Size */}
<div>
<p className="text-sm text-muted-foreground">
Current size: {currentWidth} × {currentHeight}px
</p>
</div>
{/* New Dimensions */}
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex-1">
<label className="text-sm font-medium text-foreground">Width (px)</label>
<input
type="number"
min="1"
max="10000"
value={width}
onChange={(e) => handleWidthChange(Number(e.target.value))}
className="w-full mt-1 px-3 py-2 border border-border rounded-md bg-background text-foreground"
/>
</div>
<button
onClick={() => setMaintainAspectRatio(!maintainAspectRatio)}
className="mt-6 p-2 text-muted-foreground hover:text-foreground transition-colors"
title={maintainAspectRatio ? 'Unlink aspect ratio' : 'Link aspect ratio'}
>
{maintainAspectRatio ? (
<Link className="h-4 w-4" />
) : (
<Unlink className="h-4 w-4" />
)}
</button>
<div className="flex-1">
<label className="text-sm font-medium text-foreground">Height (px)</label>
<input
type="number"
min="1"
max="10000"
value={height}
onChange={(e) => handleHeightChange(Number(e.target.value))}
className="w-full mt-1 px-3 py-2 border border-border rounded-md bg-background text-foreground"
/>
</div>
</div>
</div>
{/* Resize Method */}
<div>
<label className="text-sm font-medium text-foreground">Resize Method</label>
<div className="mt-2 space-y-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="resizeMethod"
value="scale"
checked={resizeMethod === 'scale'}
onChange={() => setResizeMethod('scale')}
className="text-primary"
/>
<div>
<p className="text-sm text-foreground">Scale</p>
<p className="text-xs text-muted-foreground">
Stretch/shrink content to fit new size
</p>
</div>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="resizeMethod"
value="crop"
checked={resizeMethod === 'crop'}
onChange={() => setResizeMethod('crop')}
className="text-primary"
/>
<div>
<p className="text-sm text-foreground">Crop</p>
<p className="text-xs text-muted-foreground">
Crop or center content without scaling
</p>
</div>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="resizeMethod"
value="expand"
checked={resizeMethod === 'expand'}
onChange={() => setResizeMethod('expand')}
className="text-primary"
/>
<div>
<p className="text-sm text-foreground">Expand</p>
<p className="text-xs text-muted-foreground">
Expand canvas, center existing content
</p>
</div>
</label>
</div>
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-2 border-t border-border p-4">
<button
onClick={onClose}
className="px-4 py-2 text-sm bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/80 transition-colors"
>
Cancel
</button>
<button
onClick={handleApply}
className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
>
Apply
</button>
</div>
</div>
</div>
);
}