Add crop controls and larger patterns
This commit is contained in:
+8
-3
@@ -45,11 +45,16 @@ button { color: inherit; }
|
||||
.panel-heading h2, .pattern-toolbar h2 { margin: 0; font-size: 18px; }
|
||||
.panel-heading p { margin: 5px 0 0; color: var(--muted); font-size: 12px; }
|
||||
|
||||
.upload-card { height: 170px; display: block; position: relative; overflow: hidden; cursor: pointer; border: 1px dashed #a5a59e; background: #ebe7dd; }
|
||||
.upload-card img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.upload-card { width: 100%; min-height: 100px; max-height: 260px; display: block; position: relative; overflow: hidden; border: 1px dashed #a5a59e; background: #ebe7dd; }
|
||||
.upload-card canvas { width: 100%; height: 100%; display: block; pointer-events: none; }
|
||||
.upload-card input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.crop-preview { cursor: grab; touch-action: none; }
|
||||
.crop-preview:active { cursor: grabbing; }
|
||||
.crop-frame { position: absolute; inset: 0; border: 2px solid rgba(255,255,255,.85); box-shadow: inset 0 0 0 1px rgba(20,30,25,.22); pointer-events: none; }
|
||||
.upload-overlay { position: absolute; right: 10px; bottom: 10px; padding: 7px 10px; color: white; background: rgba(22, 75, 57, .86); font-size: 12px; }
|
||||
.file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin: 9px 0 24px; color: var(--muted); font-size: 11px; }
|
||||
.crop-zoom { margin-top: 0; }
|
||||
.reset-crop { width: 100%; margin: 7px 0 20px; padding: 7px; border: 1px solid var(--line); background: white; cursor: pointer; color: var(--muted); font-size: 11px; }
|
||||
.field-row { display: grid; grid-template-columns: 1fr auto 1fr; align-items: end; gap: 8px; }
|
||||
.field-row label, .field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.field-row label span, .field > span { color: var(--muted); font-size: 12px; }
|
||||
@@ -149,7 +154,7 @@ input[type="range"] { accent-color: var(--green); }
|
||||
.hero-badge { display: none; }
|
||||
.workspace { margin-bottom: 0; grid-template-columns: 1fr; border-bottom: 0; }
|
||||
.control-panel { border-right: 0; border-bottom: 1px solid var(--ink); padding: 24px 20px; }
|
||||
.upload-card { height: 210px; }
|
||||
.upload-card { height: auto; min-height: 160px; }
|
||||
.pattern-panel { min-height: 620px; }
|
||||
.pattern-toolbar { align-items: flex-start; flex-direction: column; padding: 20px; }
|
||||
.zoom-control { width: 100%; }
|
||||
|
||||
+103
-27
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user