From 6f101661c79d53ea4da4deda76abada2d9912c2d Mon Sep 17 00:00:00 2001 From: wuyanwanwu <104009119+wuyanwanwu@users.noreply.github.com.> Date: Fri, 14 Aug 2026 21:52:40 +0800 Subject: [PATCH] Add resizable mobile friendly crop selection --- app/globals.css | 24 +++ app/page.tsx | 200 +++++++++++++++--- out/.vite/manifest.json | 6 +- out/404.html | 4 +- .../{index-eEF_VN3S.js => index-DZFDXe5d.js} | 6 +- ....js => layout-segment-context-D_9uSw8l.js} | 2 +- out/_next/static/chunks/page-DuqyPTpB.js | 1 - out/_next/static/chunks/page-Dw0YwySe.js | 1 + out/_next/static/css/index.BD45GRB0.css | 2 - out/_next/static/css/index.ClBkKPWX.css | 2 + .../_buildManifest.js | 0 .../_ssgManifest.js | 0 out/index.html | 4 +- out/index.rsc | 4 +- out/vinext-client-entry-manifest.json | 2 +- 15 files changed, 216 insertions(+), 42 deletions(-) rename out/_next/static/chunks/{index-eEF_VN3S.js => index-DZFDXe5d.js} (99%) rename out/_next/static/chunks/{layout-segment-context-DVLKCRHe.js => layout-segment-context-D_9uSw8l.js} (74%) delete mode 100644 out/_next/static/chunks/page-DuqyPTpB.js create mode 100644 out/_next/static/chunks/page-Dw0YwySe.js delete mode 100644 out/_next/static/css/index.BD45GRB0.css create mode 100644 out/_next/static/css/index.ClBkKPWX.css rename out/_next/static/{af112f55-aa47-4e32-a883-6facb26a7955 => eb947aac-25ca-43f4-8a6a-f11561a12d64}/_buildManifest.js (100%) rename out/_next/static/{af112f55-aa47-4e32-a883-6facb26a7955 => eb947aac-25ca-43f4-8a6a-f11561a12d64}/_ssgManifest.js (100%) diff --git a/app/globals.css b/app/globals.css index ea6102b..bc44489 100644 --- a/app/globals.css +++ b/app/globals.css @@ -145,8 +145,17 @@ input[type="range"] { accent-color: var(--green); } .crop-dialog-header h2 { margin: 0; font-size: 22px; } .crop-dialog-header p:not(.section-kicker) { margin: 6px 0 0; color: var(--muted); font-size: 12px; } .crop-dialog-header > button { width: 38px; height: 38px; flex: 0 0 auto; border: 1px solid var(--line); background: white; cursor: pointer; font-size: 25px; line-height: 1; } +.crop-mode-bar { min-height: 54px; padding: 8px 20px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--line); background: #f7f4ec; color: var(--muted); font-size: 11px; } +.crop-mode-switch { display: inline-flex; border: 1px solid var(--ink); background: white; } +.crop-mode-switch button { min-height: 34px; padding: 0 15px; border: 0; border-right: 1px solid var(--ink); background: transparent; cursor: pointer; font-size: 12px; } +.crop-mode-switch button:last-child { border-right: 0; } +.crop-mode-switch button.active { background: var(--green); color: white; font-weight: 700; } +.crop-size-readout { margin-left: auto; font-variant-numeric: tabular-nums; } .crop-dialog-scroll { min-height: 0; flex: 1 1 auto; overflow: auto; padding: 18px; display: grid; place-items: center; background-color: #e9e5db; background-image: radial-gradient(#c8c3b8 .7px, transparent .7px); background-size: 12px 12px; } .crop-dialog-stage { position: relative; width: max-content; max-width: 100%; line-height: 0; cursor: crosshair; touch-action: none; user-select: none; box-shadow: 0 0 0 1px var(--ink); } +.crop-dialog-stage.can-move-selection { cursor: move; } +.crop-dialog-stage.is-moving-selection { cursor: grabbing; } +.crop-dialog-stage.is-resizing-selection { cursor: nwse-resize; } .crop-dialog-stage canvas { display: block; max-width: 100%; height: auto; pointer-events: none; } .crop-selection-mask { position: absolute; z-index: 1; background: rgba(10,15,13,.55); pointer-events: none; } .crop-selection-top { inset: 0 0 auto; } @@ -156,6 +165,15 @@ input[type="range"] { accent-color: var(--green); } .crop-selection-box::before, .crop-selection-box::after { content: ""; position: absolute; background: rgba(255,255,255,.62); } .crop-selection-box::before { left: 33.333%; top: 0; bottom: 0; width: 1px; box-shadow: calc(33.333% * 1) 0 0 rgba(255,255,255,.62); } .crop-selection-box::after { left: 0; right: 0; top: 33.333%; height: 1px; box-shadow: 0 calc(33.333% * 1) 0 rgba(255,255,255,.62); } +.crop-resize-handle { position: absolute; z-index: 4; width: 15px; height: 15px; display: block; border: 2px solid white; border-radius: 50%; background: var(--green); box-shadow: 0 1px 4px rgba(0,0,0,.48); pointer-events: auto; } +.handle-nw { left: 0; top: 0; transform: translate(-50%, -50%); cursor: nwse-resize; } +.handle-ne { right: 0; top: 0; transform: translate(50%, -50%); cursor: nesw-resize; } +.handle-sw { left: 0; bottom: 0; transform: translate(-50%, 50%); cursor: nesw-resize; } +.handle-se { right: 0; bottom: 0; transform: translate(50%, 50%); cursor: nwse-resize; } +.handle-n { left: 50%; top: 0; transform: translate(-50%, -50%); cursor: ns-resize; } +.handle-s { left: 50%; bottom: 0; transform: translate(-50%, 50%); cursor: ns-resize; } +.handle-w { left: 0; top: 50%; transform: translate(-50%, -50%); cursor: ew-resize; } +.handle-e { right: 0; top: 50%; transform: translate(50%, -50%); cursor: ew-resize; } .crop-dialog-actions { flex: 0 0 auto; min-height: 66px; padding: 12px 20px; display: flex; align-items: center; justify-content: flex-end; gap: 10px; border-top: 1px solid var(--line); } .crop-dialog-actions span { margin-right: auto; color: var(--muted); font-size: 11px; } .crop-dialog-actions button { min-height: 40px; padding: 0 16px; border: 1px solid var(--ink); background: white; cursor: pointer; font-size: 12px; } @@ -201,6 +219,12 @@ input[type="range"] { accent-color: var(--green); } .crop-dialog { width: 100vw; max-height: 100vh; height: 100vh; border: 0; box-shadow: none; } .crop-dialog-header { padding: 14px 16px; } .crop-dialog-header h2 { font-size: 18px; } + .crop-mode-bar { padding: 8px 12px; flex-wrap: wrap; gap: 7px 10px; } + .crop-mode-switch { flex: 1; } + .crop-mode-switch button { flex: 1; padding: 0 8px; } + .crop-size-readout { width: 100%; margin-left: 0; } + .crop-resize-handle { width: 28px; height: 28px; border-width: 3px; } + .crop-edge-handle { display: none; } .crop-dialog-scroll { padding: 10px; } .crop-dialog-actions { padding: 10px 12px; flex-wrap: wrap; } .crop-dialog-actions span { width: 100%; order: -1; } diff --git a/app/page.tsx b/app/page.tsx index 38327d7..c467496 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -35,6 +35,19 @@ type CropRect = { height: number; }; +type CropSelectionMode = "free" | "square"; + +type CropResizeHandle = "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se"; + +type CropSelectionDrag = { + pointerId: number; + mode: "create" | "move" | "resize"; + startX: number; + startY: number; + origin: CropRect; + handle?: CropResizeHandle; +}; + const FULL_CROP: CropRect = { x: 0, y: 0, width: 1, height: 1 }; function logUsage(event: Record) { @@ -639,9 +652,29 @@ function drawFittedImage( const sourceHeight = crop.height * image.naturalHeight; const imageRatio = sourceWidth / sourceHeight; const boxRatio = width / height; + const sourceCenterX = sourceX + sourceWidth / 2; + const sourceCenterY = sourceY + sourceHeight / 2; + if (fitMode === "cover") { + let centeredSourceWidth = sourceWidth; + let centeredSourceHeight = sourceHeight; + if (imageRatio > boxRatio) centeredSourceWidth = sourceHeight * boxRatio; + else centeredSourceHeight = sourceWidth / boxRatio; + ctx.drawImage( + image, + sourceCenterX - centeredSourceWidth / 2, + sourceCenterY - centeredSourceHeight / 2, + centeredSourceWidth, + centeredSourceHeight, + 0, + 0, + width, + height, + ); + return; + } let drawWidth = width; let drawHeight = height; - if ((fitMode === "cover" && imageRatio > boxRatio) || (fitMode === "contain" && imageRatio < boxRatio)) { + if (imageRatio < boxRatio) { drawHeight = height; drawWidth = height * imageRatio; } else { @@ -650,6 +683,9 @@ function drawFittedImage( } const drawX = (width - drawWidth) / 2; const drawY = (height - drawHeight) / 2; + // The selected area's center is always the grid's center. With odd grid + // sizes it lands on the middle cell; with even sizes it lands on the + // intersection of the four middle cells. ctx.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, drawX, drawY, drawWidth, drawHeight); } @@ -693,6 +729,7 @@ export default function Home() { const [samplingStrategy, setSamplingStrategy] = useState("dominant"); const [cropRect, setCropRect] = useState(FULL_CROP); const [draftCropRect, setDraftCropRect] = useState(FULL_CROP); + const [cropSelectionMode, setCropSelectionMode] = useState("free"); const [cropDialogOpen, setCropDialogOpen] = useState(false); const [pixels, setPixels] = useState([]); const [selectedCodes, setSelectedCodes] = useState>(new Set()); @@ -720,7 +757,7 @@ export default function Home() { canvasY: number; } | null>(null); const cropDialogStageRef = useRef(null); - const cropSelectionDragRef = useRef<{ pointerId: number; startX: number; startY: number } | null>(null); + const cropSelectionDragRef = useRef(null); const patternDragRef = useRef<{ pointerId: number; x: number; @@ -1062,46 +1099,147 @@ export default function Home() { }; 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; + if (cropSelectionMode === "square") { + const image = sourceImageRef.current; + if (!image) return; + const normalizedSquareRatio = image.naturalHeight / image.naturalWidth; + if (width / Math.max(height, 0.0001) > normalizedSquareRatio) width = height * normalizedSquareRatio; + else height = width / Math.max(normalizedSquareRatio, 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; + } + width = Math.max(0.01, Math.min(1, width)); + height = Math.max(0.01, Math.min(1, height)); 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), + x: Math.max(0, Math.min(1 - width, directionX > 0 ? startX : startX - width)), + y: Math.max(0, Math.min(1 - height, directionY > 0 ? startY : startY - height)), + width, + height, }); }; + const pointInsideCrop = (point: { x: number; y: number }) => ( + (draftCropRect.width < 0.995 || draftCropRect.height < 0.995) + && + point.x >= draftCropRect.x + && point.x <= draftCropRect.x + draftCropRect.width + && point.y >= draftCropRect.y + && point.y <= draftCropRect.y + draftCropRect.height + ); + + const chooseCropSelectionMode = (mode: CropSelectionMode) => { + setCropSelectionMode(mode); + if (mode !== "square") return; + const image = sourceImageRef.current; + if (!image) return; + const currentCenterX = draftCropRect.x + draftCropRect.width / 2; + const currentCenterY = draftCropRect.y + draftCropRect.height / 2; + const sideInPixels = Math.min( + draftCropRect.width * image.naturalWidth, + draftCropRect.height * image.naturalHeight, + ); + const width = sideInPixels / image.naturalWidth; + const height = sideInPixels / image.naturalHeight; + setDraftCropRect({ + x: Math.max(0, Math.min(1 - width, currentCenterX - width / 2)), + y: Math.max(0, Math.min(1 - height, currentCenterY - height / 2)), + width, + height, + }); + }; + + const resizeDraftCrop = (drag: CropSelectionDrag, point: { x: number; y: number }) => { + const handle = drag.handle; + if (!handle) return; + const origin = drag.origin; + if (cropSelectionMode === "square") { + const anchorX = handle.includes("w") ? origin.x + origin.width : origin.x; + const anchorY = handle.includes("n") ? origin.y + origin.height : origin.y; + updateDraftCrop(anchorX, anchorY, point.x, point.y); + return; + } + const minimumSize = 0.03; + let left = origin.x; + let top = origin.y; + let right = origin.x + origin.width; + let bottom = origin.y + origin.height; + if (handle.includes("w")) left = Math.max(0, Math.min(right - minimumSize, point.x)); + if (handle.includes("e")) right = Math.min(1, Math.max(left + minimumSize, point.x)); + if (handle.includes("n")) top = Math.max(0, Math.min(bottom - minimumSize, point.y)); + if (handle.includes("s")) bottom = Math.min(1, Math.max(top + minimumSize, point.y)); + setDraftCropRect({ x: left, y: top, width: right - left, height: bottom - top }); + }; + const handleCropSelectionDown = (event: React.PointerEvent) => { if (event.button !== 0) return; const point = cropPoint(event); event.currentTarget.setPointerCapture(event.pointerId); - 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 resizeHandle = (event.target as HTMLElement).dataset.cropHandle as CropResizeHandle | undefined; + if (resizeHandle) { + cropSelectionDragRef.current = { + pointerId: event.pointerId, + mode: "resize", + startX: point.x, + startY: point.y, + origin: draftCropRect, + handle: resizeHandle, + }; + event.currentTarget.classList.add("is-resizing-selection"); + return; + } + if (pointInsideCrop(point) && draftCropRect.width >= 0.03 && draftCropRect.height >= 0.03) { + cropSelectionDragRef.current = { + pointerId: event.pointerId, + mode: "move", + startX: point.x, + startY: point.y, + origin: draftCropRect, + }; + event.currentTarget.classList.add("is-moving-selection"); + return; + } + cropSelectionDragRef.current = { + pointerId: event.pointerId, + mode: "create", + startX: point.x, + startY: point.y, + origin: draftCropRect, + }; + setDraftCropRect({ x: Math.min(0.99, point.x), y: Math.min(0.99, point.y), width: 0.01, height: 0.01 }); }; const handleCropSelectionMove = (event: React.PointerEvent) => { const drag = cropSelectionDragRef.current; - if (!drag || drag.pointerId !== event.pointerId) return; const point = cropPoint(event); + if (!drag) { + event.currentTarget.classList.toggle("can-move-selection", pointInsideCrop(point)); + return; + } + if (drag.pointerId !== event.pointerId) return; + if (drag.mode === "resize") { + resizeDraftCrop(drag, point); + return; + } + if (drag.mode === "move") { + const x = Math.max(0, Math.min(1 - drag.origin.width, drag.origin.x + point.x - drag.startX)); + const y = Math.max(0, Math.min(1 - drag.origin.height, drag.origin.y + point.y - drag.startY)); + setDraftCropRect({ ...drag.origin, x, y }); + return; + } updateDraftCrop(drag.startX, drag.startY, point.x, point.y); }; const handleCropSelectionEnd = (event: React.PointerEvent) => { if (cropSelectionDragRef.current?.pointerId === event.pointerId) cropSelectionDragRef.current = null; + event.currentTarget.classList.remove("is-moving-selection"); + event.currentTarget.classList.remove("is-resizing-selection"); if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }; @@ -1112,7 +1250,7 @@ export default function Home() { } setCropRect(draftCropRect); setCropDialogOpen(false); - setStatus("取景区域已更新,点击“重新转换”生成图纸"); + convertImage(sourceImageRef.current, draftCropRect); }; const toggleColor = (code: string) => { @@ -1531,9 +1669,17 @@ export default function Home() { {cropDialogOpen &&
{ if (event.target === event.currentTarget) setCropDialogOpen(false); }}>
-

IMAGE CROP

框选需要转换的区域

在大图上按住并拖动。选框比例为 {Math.max(8, requestedWidth || 8)} : {Math.max(8, requestedHeight || 8)}。

+

IMAGE CROP

框选需要转换的区域

框内拖动可移动,控制点可调整大小;生成格子时以所选区域正中心为采样基准。

+
+ 选框形状 +
+ + +
+ 当前选框:{Math.round(draftCropRect.width * 100)}% × {Math.round(draftCropRect.height * 100)}% +
{ if (!cropSelectionDragRef.current) event.currentTarget.classList.remove("can-move-selection"); }} >
-
+
+ {(["nw", "ne", "sw", "se"] as CropResizeHandle[]).map((handle) =>