diff --git a/app/globals.css b/app/globals.css index c46134c..f74e1a1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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%; } diff --git a/app/page.tsx b/app/page.tsx index c26337f..9ab6f29 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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([]); const [selectedCodes, setSelectedCodes] = useState>(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(null); + const cropCanvasRef = useRef(null); const canvasRef = useRef(null); + const cropPreviewRef = useRef(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(); @@ -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(); 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) => { + event.currentTarget.setPointerCapture(event.pointerId); + cropDragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, cropX, cropY }; + }; + + const handleCropPointerMove = (event: React.PointerEvent) => { + 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) => { + 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() {