Add resizable mobile friendly crop selection

This commit is contained in:
wuyanwanwu
2026-08-14 21:52:40 +08:00
parent 365af027b8
commit 6f101661c7
15 changed files with 216 additions and 42 deletions
+24
View File
@@ -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; }
+175 -25
View File
@@ -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<string, unknown>) {
@@ -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<SamplingStrategy>("dominant");
const [cropRect, setCropRect] = useState<CropRect>(FULL_CROP);
const [draftCropRect, setDraftCropRect] = useState<CropRect>(FULL_CROP);
const [cropSelectionMode, setCropSelectionMode] = useState<CropSelectionMode>("free");
const [cropDialogOpen, setCropDialogOpen] = useState(false);
const [pixels, setPixels] = useState<Pixel[]>([]);
const [selectedCodes, setSelectedCodes] = useState<Set<string>>(new Set());
@@ -720,7 +757,7 @@ export default function Home() {
canvasY: number;
} | null>(null);
const cropDialogStageRef = useRef<HTMLDivElement | null>(null);
const cropSelectionDragRef = useRef<{ pointerId: number; startX: number; startY: number } | null>(null);
const cropSelectionDragRef = useRef<CropSelectionDrag | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 && <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>
<div><p className="section-kicker">IMAGE CROP</p><h2 id="crop-dialog-title"></h2><p></p></div>
<button aria-label="关闭取景窗口" onClick={() => setCropDialogOpen(false)}>×</button>
</div>
<div className="crop-mode-bar">
<span></span>
<div className="crop-mode-switch" role="group" aria-label="选框形状">
<button className={cropSelectionMode === "free" ? "active" : ""} aria-pressed={cropSelectionMode === "free"} onClick={() => chooseCropSelectionMode("free")}></button>
<button className={cropSelectionMode === "square" ? "active" : ""} aria-pressed={cropSelectionMode === "square"} onClick={() => chooseCropSelectionMode("square")}></button>
</div>
<span className="crop-size-readout">{Math.round(draftCropRect.width * 100)}% × {Math.round(draftCropRect.height * 100)}%</span>
</div>
<div className="crop-dialog-scroll">
<div
className="crop-dialog-stage"
@@ -1542,18 +1688,22 @@ export default function Home() {
onPointerMove={handleCropSelectionMove}
onPointerUp={handleCropSelectionEnd}
onPointerCancel={handleCropSelectionEnd}
onPointerLeave={(event) => { if (!cropSelectionDragRef.current) event.currentTarget.classList.remove("can-move-selection"); }}
>
<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 className="crop-selection-box" style={{ left: `${draftCropRect.x * 100}%`, top: `${draftCropRect.y * 100}%`, width: `${draftCropRect.width * 100}%`, height: `${draftCropRect.height * 100}%` }}>
{(["nw", "ne", "sw", "se"] as CropResizeHandle[]).map((handle) => <i key={handle} className={`crop-resize-handle handle-${handle}`} data-crop-handle={handle} aria-hidden="true" />)}
{cropSelectionMode === "free" && (["n", "s", "e", "w"] as CropResizeHandle[]).map((handle) => <i key={handle} className={`crop-resize-handle crop-edge-handle handle-${handle}`} data-crop-handle={handle} aria-hidden="true" />)}
</div>
</div>
</div>
<div className="crop-dialog-actions">
<button onClick={() => setDraftCropRect(FULL_CROP)}></button>
<span></span>
<button onClick={() => { setCropSelectionMode("free"); 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>