Add crop controls and larger patterns

This commit is contained in:
wuyanwanwu
2026-08-14 00:01:53 +08:00
parent 6037bde760
commit 76cc35b709
15 changed files with 128 additions and 47 deletions
+103 -27
View File
@@ -71,6 +71,36 @@ function nearestColor(rgb: [number, number, number], palette: BeadColor[]) {
return closest;
}
function drawFittedImage(
ctx: CanvasRenderingContext2D,
image: HTMLImageElement,
width: number,
height: number,
fitMode: "cover" | "contain",
cropZoom: number,
cropX: number,
cropY: number,
) {
const imageRatio = image.naturalWidth / image.naturalHeight;
const boxRatio = width / height;
let drawWidth = width;
let drawHeight = height;
if ((fitMode === "cover" && imageRatio > boxRatio) || (fitMode === "contain" && imageRatio < boxRatio)) {
drawHeight = height;
drawWidth = height * imageRatio;
} else {
drawWidth = width;
drawHeight = width / imageRatio;
}
drawWidth *= cropZoom;
drawHeight *= cropZoom;
const overflowX = Math.max(0, (drawWidth - width) / 2);
const overflowY = Math.max(0, (drawHeight - height) / 2);
const drawX = (width - drawWidth) / 2 + cropX * overflowX;
const drawY = (height - drawHeight) / 2 + cropY * overflowY;
ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight);
}
function makeDemoImage() {
const canvas = document.createElement("canvas");
canvas.width = 640;
@@ -102,10 +132,15 @@ function makeDemoImage() {
export default function Home() {
const [sourceUrl, setSourceUrl] = useState("");
const [sourceName, setSourceName] = useState("示例:田野小屋");
const [requestedWidth, setRequestedWidth] = useState(32);
const [requestedHeight, setRequestedHeight] = useState(32);
const [gridWidth, setGridWidth] = useState(32);
const [gridHeight, setGridHeight] = useState(32);
const [colorLimit, setColorLimit] = useState(18);
const [fitMode, setFitMode] = useState<"cover" | "contain">("cover");
const [cropZoom, setCropZoom] = useState(1);
const [cropX, setCropX] = useState(0);
const [cropY, setCropY] = useState(0);
const [pixels, setPixels] = useState<Pixel[]>([]);
const [selectedCodes, setSelectedCodes] = useState<Set<string>>(new Set());
const [onlySelected, setOnlySelected] = useState(false);
@@ -118,7 +153,10 @@ export default function Home() {
const [historyQuery, setHistoryQuery] = useState("");
const [status, setStatus] = useState("示例图已准备好,可以直接转换");
const sourceImageRef = useRef<HTMLImageElement | null>(null);
const cropCanvasRef = useRef<HTMLCanvasElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const cropPreviewRef = useRef<HTMLDivElement | null>(null);
const cropDragRef = useRef<{ pointerId: number; x: number; y: number; cropX: number; cropY: number } | null>(null);
useEffect(() => {
const saved = localStorage.getItem("bead-pattern-history");
@@ -128,28 +166,20 @@ export default function Home() {
});
}, []);
const convertImage = (image = sourceImageRef.current) => {
const convertImage = (
image = sourceImageRef.current,
crop = { zoom: cropZoom, x: cropX, y: cropY },
) => {
if (!image) return;
const width = Math.max(8, Math.min(128, gridWidth));
const height = Math.max(8, Math.min(128, gridHeight));
const width = Math.max(8, Math.min(256, requestedWidth));
const height = Math.max(8, Math.min(256, requestedHeight));
const sample = document.createElement("canvas");
sample.width = width;
sample.height = height;
const ctx = sample.getContext("2d", { willReadFrequently: true })!;
ctx.fillStyle = "#f7f5ed";
ctx.fillRect(0, 0, width, height);
const imageRatio = image.naturalWidth / image.naturalHeight;
const boxRatio = width / height;
let drawWidth = width;
let drawHeight = height;
if ((fitMode === "cover" && imageRatio > boxRatio) || (fitMode === "contain" && imageRatio < boxRatio)) {
drawHeight = height;
drawWidth = height * imageRatio;
} else {
drawWidth = width;
drawHeight = width / imageRatio;
}
ctx.drawImage(image, (width - drawWidth) / 2, (height - drawHeight) / 2, drawWidth, drawHeight);
drawFittedImage(ctx, image, width, height, fitMode, crop.zoom, crop.x, crop.y);
const data = ctx.getImageData(0, 0, width, height).data;
const initialMatches: BeadColor[] = [];
const counts = new Map<string, number>();
@@ -167,6 +197,8 @@ export default function Home() {
});
setGridWidth(width);
setGridHeight(height);
setRequestedWidth(width);
setRequestedHeight(height);
setPixels(converted);
setSelectedCodes(new Set());
setStatus(`已转换为 ${width} × ${height},共 ${width * height} 颗拼豆`);
@@ -185,6 +217,23 @@ export default function Home() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const canvas = cropCanvasRef.current;
const image = sourceImageRef.current;
if (!canvas || !image) return;
const targetWidth = Math.max(8, Math.min(256, requestedWidth || 8));
const targetHeight = Math.max(8, Math.min(256, requestedHeight || 8));
const ratio = targetWidth / targetHeight;
const previewWidth = ratio >= 1 ? 600 : Math.max(120, Math.round(600 * ratio));
const previewHeight = ratio >= 1 ? Math.max(120, Math.round(600 / ratio)) : 600;
canvas.width = previewWidth;
canvas.height = previewHeight;
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]);
const usedColors = useMemo(() => {
const counts = new Map<string, number>();
pixels.forEach(({ color }) => counts.set(color.code, (counts.get(color.code) ?? 0) + 1));
@@ -221,15 +270,16 @@ export default function Home() {
ctx.fillStyle = "#56605b";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const labelEvery = cell < 10 ? 10 : cell < 16 ? 5 : 1;
for (let column = 0; column < gridWidth; column++) {
const x = ruler + column * cell;
ctx.strokeRect(x + 0.3, 0.3, cell - 0.6, ruler - 0.6);
ctx.fillText(String(column + 1), x + cell / 2, ruler / 2);
if (column === 0 || (column + 1) % labelEvery === 0) ctx.fillText(String(column + 1), x + cell / 2, ruler / 2);
}
for (let row = 0; row < gridHeight; row++) {
const y = ruler + row * cell;
ctx.strokeRect(0.3, y + 0.3, ruler - 0.6, cell - 0.6);
ctx.fillText(String(row + 1), ruler / 2, y + cell / 2);
if (row === 0 || (row + 1) % labelEvery === 0) ctx.fillText(String(row + 1), ruler / 2, y + cell / 2);
}
pixels.forEach(({ color }, index) => {
const x = ruler + (index % gridWidth) * cell;
@@ -274,12 +324,35 @@ export default function Home() {
sourceImageRef.current = image;
setSourceUrl(url);
setSourceName(file.name);
setCropZoom(1);
setCropX(0);
setCropY(0);
setStatus("图片已载入,点击“重新转换”生成图纸");
convertImage(image);
convertImage(image, { zoom: 1, x: 0, y: 0 });
};
image.src = url;
};
const handleCropPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
cropDragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, cropX, cropY };
};
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 handleCropPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (cropDragRef.current?.pointerId === event.pointerId) cropDragRef.current = null;
};
const toggleColor = (code: string) => {
setSelectedCodes((current) => {
const next = new Set(current);
@@ -339,6 +412,8 @@ export default function Home() {
setPixels(entry.codes.map((code) => ({ color: colorMap.get(code) ?? PALETTE[0] })));
setGridWidth(entry.width);
setGridHeight(entry.height);
setRequestedWidth(entry.width);
setRequestedHeight(entry.height);
setColorLimit(entry.colorLimit);
setSourceName(entry.name);
setSelectedCodes(new Set());
@@ -384,19 +459,20 @@ 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>
<label className="upload-card">
{/* The source may be a local blob URL, so framework image optimization is not applicable. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
{sourceUrl ? <img src={sourceUrl} alt="待转换的原图预览" /> : <span className="upload-placeholder"></span>}
<span className="upload-overlay"></span>
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={handleUpload} />
</label>
<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" />
<label className="upload-overlay"><input type="file" accept="image/png,image/jpeg,image/webp" onChange={handleUpload} /></label>
</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>
<div className="field-row">
<label><span></span><input type="number" min="8" max="128" value={gridWidth} onChange={(e) => setGridWidth(Number(e.target.value))} /></label>
<label><span></span><input type="number" min="8" max="256" value={requestedWidth} onChange={(e) => setRequestedWidth(Number(e.target.value))} /></label>
<span className="times">×</span>
<label><span></span><input type="number" min="8" max="128" value={gridHeight} onChange={(e) => setGridHeight(Number(e.target.value))} /></label>
<label><span></span><input type="number" min="8" max="256" value={requestedHeight} onChange={(e) => setRequestedHeight(Number(e.target.value))} /></label>
</div>
<label className="field"><span>使 <b>{colorLimit}</b></span><input type="range" min="2" max="64" value={colorLimit} onChange={(e) => setColorLimit(Number(e.target.value))} /></label>