Add resizable mobile friendly crop selection
This commit is contained in:
+175
-25
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user