Replace crop zoom with image selection dialog

This commit is contained in:
wuyanwanwu
2026-08-14 02:04:36 +08:00
parent e028ecc9c8
commit d53f8f4585
15 changed files with 214 additions and 73 deletions
+163 -52
View File
@@ -28,6 +28,15 @@ type ColorCluster = {
type SamplingStrategy = "smooth" | "dominant";
type CropRect = {
x: number;
y: number;
width: number;
height: number;
};
const FULL_CROP: CropRect = { x: 0, y: 0, width: 1, height: 1 };
function logUsage(event: Record<string, unknown>) {
void fetch("/api/usage", {
method: "POST",
@@ -400,9 +409,7 @@ function sampleDominantRegions(
width: number,
height: number,
fitMode: "cover" | "contain",
cropZoom: number,
cropX: number,
cropY: number,
crop: CropRect,
) {
// A mildly center-weighted 3x3 vote keeps hard object boundaries clean.
// Broad gradients are preserved later when the MARD palette is selected,
@@ -420,7 +427,7 @@ function sampleDominantRegions(
const ctx = sample.getContext("2d", { willReadFrequently: true })!;
ctx.clearRect(0, 0, sample.width, sample.height);
ctx.imageSmoothingEnabled = false;
drawFittedImage(ctx, image, sample.width, sample.height, fitMode, cropZoom, cropX, cropY);
drawFittedImage(ctx, image, sample.width, sample.height, fitMode, crop);
const source = ctx.getImageData(0, 0, sample.width, sample.height).data;
const result = new Uint8ClampedArray(width * height * 4);
const confidence = new Float32Array(width * height);
@@ -613,11 +620,13 @@ function drawFittedImage(
width: number,
height: number,
fitMode: "cover" | "contain",
cropZoom: number,
cropX: number,
cropY: number,
crop: CropRect,
) {
const imageRatio = image.naturalWidth / image.naturalHeight;
const sourceX = crop.x * image.naturalWidth;
const sourceY = crop.y * image.naturalHeight;
const sourceWidth = crop.width * image.naturalWidth;
const sourceHeight = crop.height * image.naturalHeight;
const imageRatio = sourceWidth / sourceHeight;
const boxRatio = width / height;
let drawWidth = width;
let drawHeight = height;
@@ -628,15 +637,9 @@ function drawFittedImage(
drawWidth = width;
drawHeight = width / imageRatio;
}
drawWidth *= cropZoom;
drawHeight *= cropZoom;
// Allow an object at the source-image edge to be dragged into the crop
// center. The exposed area intentionally becomes transparent/no-bead.
const panRangeX = drawWidth / 2;
const panRangeY = drawHeight / 2;
const drawX = (width - drawWidth) / 2 + cropX * panRangeX;
const drawY = (height - drawHeight) / 2 + cropY * panRangeY;
ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight);
const drawX = (width - drawWidth) / 2;
const drawY = (height - drawHeight) / 2;
ctx.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, drawX, drawY, drawWidth, drawHeight);
}
function makeDemoImage() {
@@ -677,9 +680,9 @@ export default function Home() {
const [colorLimit, setColorLimit] = useState(64);
const [fitMode, setFitMode] = useState<"cover" | "contain">("contain");
const [samplingStrategy, setSamplingStrategy] = useState<SamplingStrategy>("dominant");
const [cropZoom, setCropZoom] = useState(1);
const [cropX, setCropX] = useState(0);
const [cropY, setCropY] = useState(0);
const [cropRect, setCropRect] = useState<CropRect>(FULL_CROP);
const [draftCropRect, setDraftCropRect] = useState<CropRect>(FULL_CROP);
const [cropDialogOpen, setCropDialogOpen] = useState(false);
const [pixels, setPixels] = useState<Pixel[]>([]);
const [selectedCodes, setSelectedCodes] = useState<Set<string>>(new Set());
const [onlySelected, setOnlySelected] = useState(false);
@@ -694,10 +697,11 @@ export default function Home() {
const sourceImageRef = useRef<HTMLImageElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const cropCanvasRef = useRef<HTMLCanvasElement | null>(null);
const cropDialogCanvasRef = useRef<HTMLCanvasElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const patternStageRef = useRef<HTMLDivElement | null>(null);
const cropPreviewRef = useRef<HTMLDivElement | null>(null);
const cropDragRef = useRef<{ pointerId: number; x: number; y: number; cropX: number; cropY: number } | null>(null);
const cropDialogStageRef = useRef<HTMLDivElement | null>(null);
const cropSelectionDragRef = useRef<{ pointerId: number; startX: number; startY: number } | null>(null);
const patternDragRef = useRef<{
pointerId: number;
x: number;
@@ -734,7 +738,7 @@ export default function Home() {
const convertImage = (
image = sourceImageRef.current,
crop = { zoom: cropZoom, x: cropX, y: cropY },
crop = cropRect,
recordUsage = true,
) => {
if (!image) return;
@@ -743,7 +747,7 @@ export default function Home() {
let data: Uint8ClampedArray;
let dominantConfidence: Float32Array | null = null;
if (samplingStrategy === "dominant") {
const sampled = sampleDominantRegions(image, width, height, fitMode, crop.zoom, crop.x, crop.y);
const sampled = sampleDominantRegions(image, width, height, fitMode, crop);
data = sampled.data;
dominantConfidence = sampled.confidence;
} else {
@@ -752,7 +756,7 @@ export default function Home() {
sample.height = height;
const ctx = sample.getContext("2d", { willReadFrequently: true })!;
ctx.clearRect(0, 0, width, height);
drawFittedImage(ctx, image, width, height, fitMode, crop.zoom, crop.x, crop.y);
drawFittedImage(ctx, image, width, height, fitMode, crop);
data = ctx.getImageData(0, 0, width, height).data;
}
const { pixelKeys, clusters, binClusters } = mergePerceptualColors(data);
@@ -803,7 +807,7 @@ export default function Home() {
image.onload = () => {
sourceImageRef.current = image;
setSourceUrl(demo);
convertImage(image, undefined, false);
convertImage(image, FULL_CROP, false);
};
image.src = demo;
// This intentionally runs once to create the initial example.
@@ -824,8 +828,37 @@ export default function Home() {
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "#f7f5ed";
ctx.fillRect(0, 0, previewWidth, previewHeight);
drawFittedImage(ctx, image, previewWidth, previewHeight, fitMode, cropZoom, cropX, cropY);
}, [sourceUrl, requestedWidth, requestedHeight, fitMode, cropZoom, cropX, cropY]);
drawFittedImage(ctx, image, previewWidth, previewHeight, fitMode, cropRect);
}, [sourceUrl, requestedWidth, requestedHeight, fitMode, cropRect]);
useEffect(() => {
if (!cropDialogOpen) return;
const canvas = cropDialogCanvasRef.current;
const image = sourceImageRef.current;
if (!canvas || !image) return;
const maximumWidth = 1200;
const maximumHeight = 760;
const scale = Math.min(maximumWidth / image.naturalWidth, maximumHeight / image.naturalHeight, 1);
canvas.width = Math.max(1, Math.round(image.naturalWidth * scale));
canvas.height = Math.max(1, Math.round(image.naturalHeight * scale));
const ctx = canvas.getContext("2d")!;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
}, [cropDialogOpen, sourceUrl]);
useEffect(() => {
if (!cropDialogOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setCropDialogOpen(false);
};
window.addEventListener("keydown", closeOnEscape);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", closeOnEscape);
};
}, [cropDialogOpen]);
const usedColors = useMemo(() => {
const counts = new Map<string, number>();
@@ -940,33 +973,81 @@ export default function Home() {
sourceImageRef.current = image;
setSourceUrl(url);
setSourceName(file.name);
setCropZoom(1);
setCropX(0);
setCropY(0);
setCropRect(FULL_CROP);
setDraftCropRect(FULL_CROP);
setStatus("图片已载入,点击“重新转换”生成图纸");
convertImage(image, { zoom: 1, x: 0, y: 0 });
convertImage(image, FULL_CROP);
};
image.src = url;
};
const handleCropPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
const openCropDialog = () => {
setDraftCropRect(cropRect);
setCropDialogOpen(true);
};
const cropPoint = (event: React.PointerEvent<HTMLDivElement>) => {
const stage = cropDialogStageRef.current;
if (!stage) return { x: 0, y: 0 };
const rect = stage.getBoundingClientRect();
return {
x: Math.max(0, Math.min(1, (event.clientX - rect.left) / Math.max(1, rect.width))),
y: Math.max(0, Math.min(1, (event.clientY - rect.top) / Math.max(1, rect.height))),
};
};
const updateDraftCrop = (startX: number, startY: number, endX: number, endY: number) => {
const targetRatio = Math.max(8, requestedWidth || 8) / Math.max(8, requestedHeight || 8);
const image = sourceImageRef.current;
if (!image) return;
const normalizedRatio = targetRatio * image.naturalHeight / image.naturalWidth;
const directionX = endX >= startX ? 1 : -1;
const directionY = endY >= startY ? 1 : -1;
let width = Math.abs(endX - startX);
let height = Math.abs(endY - startY);
if (width / Math.max(height, 0.0001) > normalizedRatio) width = height * normalizedRatio;
else height = width / Math.max(normalizedRatio, 0.0001);
const availableWidth = directionX > 0 ? 1 - startX : startX;
const availableHeight = directionY > 0 ? 1 - startY : startY;
const scale = Math.min(1, availableWidth / Math.max(width, 0.0001), availableHeight / Math.max(height, 0.0001));
width *= scale;
height *= scale;
setDraftCropRect({
x: directionX > 0 ? startX : startX - width,
y: directionY > 0 ? startY : startY - height,
width: Math.max(0.01, width),
height: Math.max(0.01, height),
});
};
const handleCropSelectionDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
const point = cropPoint(event);
event.currentTarget.setPointerCapture(event.pointerId);
cropDragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, cropX, cropY };
cropSelectionDragRef.current = { pointerId: event.pointerId, startX: point.x, startY: point.y };
setDraftCropRect({ x: point.x, y: point.y, width: 0.01, height: 0.01 });
};
const handleCropPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const drag = cropDragRef.current;
const preview = cropPreviewRef.current;
if (!drag || drag.pointerId !== event.pointerId || !preview) return;
const rect = preview.getBoundingClientRect();
const nextX = drag.cropX + ((event.clientX - drag.x) / Math.max(1, rect.width)) * 2;
const nextY = drag.cropY + ((event.clientY - drag.y) / Math.max(1, rect.height)) * 2;
setCropX(Math.max(-1, Math.min(1, nextX)));
setCropY(Math.max(-1, Math.min(1, nextY)));
const handleCropSelectionMove = (event: React.PointerEvent<HTMLDivElement>) => {
const drag = cropSelectionDragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
const point = cropPoint(event);
updateDraftCrop(drag.startX, drag.startY, point.x, point.y);
};
const handleCropPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (cropDragRef.current?.pointerId === event.pointerId) cropDragRef.current = null;
const handleCropSelectionEnd = (event: React.PointerEvent<HTMLDivElement>) => {
if (cropSelectionDragRef.current?.pointerId === event.pointerId) cropSelectionDragRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
};
const confirmCrop = () => {
if (draftCropRect.width < 0.03 || draftCropRect.height < 0.03) {
setStatus("框选区域太小,请在大图上拖出更大的选框");
return;
}
setCropRect(draftCropRect);
setCropDialogOpen(false);
setStatus("取景区域已更新,点击“重新转换”生成图纸");
};
const toggleColor = (code: string) => {
@@ -1151,21 +1232,19 @@ export default function Home() {
<section className="workspace" aria-label="拼豆图纸转换工具">
<aside className="control-panel">
<div className="panel-heading"><span>01</span><div><h2></h2><p></p></div></div>
<div className="upload-card crop-preview" style={{ aspectRatio: `${Math.max(8, requestedWidth || 8)} / ${Math.max(8, requestedHeight || 8)}` }} ref={cropPreviewRef} onPointerDown={handleCropPointerDown} onPointerMove={handleCropPointerMove} onPointerUp={handleCropPointerUp} onPointerCancel={handleCropPointerUp}>
<canvas ref={cropCanvasRef} aria-label="转换区域预览,可拖动调整取景" />
<span className="crop-frame" aria-hidden="true" />
<div className="upload-card crop-preview" style={{ aspectRatio: `${Math.max(8, requestedWidth || 8)} / ${Math.max(8, requestedHeight || 8)}` }}>
<canvas ref={cropCanvasRef} aria-label="当前取景区域预览" />
<button
type="button"
className="upload-overlay"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); fileInputRef.current?.click(); }}
onClick={() => fileInputRef.current?.click()}
></button>
<input ref={fileInputRef} className="file-input" type="file" accept="image/png,image/jpeg,image/webp" onChange={handleUpload} />
</div>
<p className="file-name" title={sourceName}>{sourceName}</p>
<label className="field crop-zoom"><span> <b>{cropZoom.toFixed(1)}×</b></span><input type="range" min="1" max="5" step="0.1" value={cropZoom} onChange={(e) => setCropZoom(Number(e.target.value))} /></label>
<button className="reset-crop" onClick={() => { setCropZoom(1); setCropX(0); setCropY(0); }}></button>
<button className="crop-select-button" onClick={openCropDialog}></button>
<button className="reset-crop" onClick={() => { setCropRect(FULL_CROP); setDraftCropRect(FULL_CROP); setStatus("已恢复使用整张图片"); }}>使</button>
<div className="field-row">
<label><span></span><input type="number" min="8" max="256" value={requestedWidth} onChange={(e) => setRequestedWidth(Number(e.target.value))} /></label>
@@ -1259,6 +1338,38 @@ export default function Home() {
</aside>
</section>
{cropDialogOpen && <div className="crop-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setCropDialogOpen(false); }}>
<section className="crop-dialog" role="dialog" aria-modal="true" aria-labelledby="crop-dialog-title">
<div className="crop-dialog-header">
<div><p className="section-kicker">IMAGE CROP</p><h2 id="crop-dialog-title"></h2><p> {Math.max(8, requestedWidth || 8)} : {Math.max(8, requestedHeight || 8)}</p></div>
<button aria-label="关闭取景窗口" onClick={() => setCropDialogOpen(false)}>×</button>
</div>
<div className="crop-dialog-scroll">
<div
className="crop-dialog-stage"
ref={cropDialogStageRef}
onPointerDown={handleCropSelectionDown}
onPointerMove={handleCropSelectionMove}
onPointerUp={handleCropSelectionEnd}
onPointerCancel={handleCropSelectionEnd}
>
<canvas ref={cropDialogCanvasRef} aria-label="可框选的原始大图" />
<div className="crop-selection-mask crop-selection-top" style={{ height: `${draftCropRect.y * 100}%` }} />
<div className="crop-selection-mask crop-selection-left" style={{ top: `${draftCropRect.y * 100}%`, width: `${draftCropRect.x * 100}%`, height: `${draftCropRect.height * 100}%` }} />
<div className="crop-selection-mask crop-selection-right" style={{ top: `${draftCropRect.y * 100}%`, left: `${(draftCropRect.x + draftCropRect.width) * 100}%`, right: 0, height: `${draftCropRect.height * 100}%` }} />
<div className="crop-selection-mask crop-selection-bottom" style={{ top: `${(draftCropRect.y + draftCropRect.height) * 100}%` }} />
<div className="crop-selection-box" style={{ left: `${draftCropRect.x * 100}%`, top: `${draftCropRect.y * 100}%`, width: `${draftCropRect.width * 100}%`, height: `${draftCropRect.height * 100}%` }} />
</div>
</div>
<div className="crop-dialog-actions">
<button onClick={() => setDraftCropRect(FULL_CROP)}></button>
<span></span>
<button onClick={() => setCropDialogOpen(false)}></button>
<button className="confirm" disabled={draftCropRect.width < 0.03 || draftCropRect.height < 0.03} onClick={confirmCrop}>使</button>
</div>
</section>
</div>}
<section className="history-section" aria-labelledby="history-title">
<div className="history-heading"><div><p className="section-kicker">LOCAL PATTERN ARCHIVE</p><h2 id="history-title"></h2><p> 30 </p></div>
<div className="search-box"><span></span><input value={historyQuery} onChange={(e) => setHistoryQuery(e.target.value)} placeholder="按名称或 32x32 查询" /></div>