Replace crop zoom with image selection dialog
This commit is contained in:
+34
-4
@@ -48,12 +48,10 @@ button { color: inherit; }
|
||||
.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 .file-input { display: 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; }
|
||||
.crop-preview { cursor: default; }
|
||||
.upload-overlay { position: absolute; z-index: 3; right: 10px; bottom: 10px; padding: 7px 10px; border: 0; color: white; background: rgba(22, 75, 57, .9); cursor: pointer; 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; }
|
||||
.crop-select-button { width: 100%; margin-top: 0; padding: 10px; border: 1px solid var(--ink); background: var(--lime); cursor: pointer; font-size: 12px; font-weight: 700; }
|
||||
.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; }
|
||||
@@ -140,6 +138,30 @@ input[type="range"] { accent-color: var(--green); }
|
||||
.history-actions button:first-child { background: var(--lime); font-weight: 700; }
|
||||
.history-empty { margin-top: 18px; padding: 44px 20px; color: var(--muted); text-align: center; border: 1px dashed var(--line); background: rgba(255,253,248,.5); font-size: 13px; }
|
||||
|
||||
.crop-dialog-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 24px; background: rgba(17,24,21,.76); backdrop-filter: blur(5px); }
|
||||
.crop-dialog { width: min(1280px, 96vw); max-height: 94vh; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--ink); background: var(--card); box-shadow: 10px 10px 0 rgba(0,0,0,.32); }
|
||||
.crop-dialog-header { flex: 0 0 auto; padding: 18px 22px; display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; border-bottom: 1px solid var(--line); }
|
||||
.crop-dialog-header .section-kicker { margin-bottom: 5px; }
|
||||
.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-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 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; }
|
||||
.crop-selection-left { left: 0; }
|
||||
.crop-selection-bottom { inset: auto 0 0; bottom: 0; }
|
||||
.crop-selection-box { position: absolute; z-index: 2; border: 2px solid white; outline: 1px solid rgba(0,0,0,.75); box-shadow: inset 0 0 0 1px rgba(0,0,0,.18); pointer-events: none; }
|
||||
.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-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; }
|
||||
.crop-dialog-actions .confirm { background: var(--green); color: white; font-weight: 700; }
|
||||
.crop-dialog-actions .confirm:disabled { opacity: .42; cursor: not-allowed; }
|
||||
|
||||
@media (max-width: 1160px) {
|
||||
.workspace { grid-template-columns: 260px minmax(500px, 1fr); }
|
||||
.color-panel { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--ink); max-height: none; }
|
||||
@@ -175,6 +197,14 @@ input[type="range"] { accent-color: var(--green); }
|
||||
.history-section { margin: 52px auto; }
|
||||
.history-heading .search-box { width: 100%; }
|
||||
.history-grid { grid-template-columns: 1fr; }
|
||||
.crop-dialog-backdrop { padding: 0; }
|
||||
.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-dialog-scroll { padding: 10px; }
|
||||
.crop-dialog-actions { padding: 10px 12px; flex-wrap: wrap; }
|
||||
.crop-dialog-actions span { width: 100%; order: -1; }
|
||||
.crop-dialog-actions button { flex: 1; padding: 0 10px; }
|
||||
input, select, button { font-size: 16px; }
|
||||
.search-box input, .color-item, .selection-tools button { font-size: 12px; }
|
||||
}
|
||||
|
||||
+163
-52
@@ -28,6 +28,15 @@ type ColorCluster = {
|
||||
|
||||
type SamplingStrategy = "smooth" | "dominant";
|
||||
|
||||
type CropRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const FULL_CROP: CropRect = { x: 0, y: 0, width: 1, height: 1 };
|
||||
|
||||
function logUsage(event: Record<string, unknown>) {
|
||||
void fetch("/api/usage", {
|
||||
method: "POST",
|
||||
@@ -400,9 +409,7 @@ function sampleDominantRegions(
|
||||
width: number,
|
||||
height: number,
|
||||
fitMode: "cover" | "contain",
|
||||
cropZoom: number,
|
||||
cropX: number,
|
||||
cropY: number,
|
||||
crop: CropRect,
|
||||
) {
|
||||
// A mildly center-weighted 3x3 vote keeps hard object boundaries clean.
|
||||
// Broad gradients are preserved later when the MARD palette is selected,
|
||||
@@ -420,7 +427,7 @@ function sampleDominantRegions(
|
||||
const ctx = sample.getContext("2d", { willReadFrequently: true })!;
|
||||
ctx.clearRect(0, 0, sample.width, sample.height);
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
drawFittedImage(ctx, image, sample.width, sample.height, fitMode, cropZoom, cropX, cropY);
|
||||
drawFittedImage(ctx, image, sample.width, sample.height, fitMode, crop);
|
||||
const source = ctx.getImageData(0, 0, sample.width, sample.height).data;
|
||||
const result = new Uint8ClampedArray(width * height * 4);
|
||||
const confidence = new Float32Array(width * height);
|
||||
@@ -613,11 +620,13 @@ function drawFittedImage(
|
||||
width: number,
|
||||
height: number,
|
||||
fitMode: "cover" | "contain",
|
||||
cropZoom: number,
|
||||
cropX: number,
|
||||
cropY: number,
|
||||
crop: CropRect,
|
||||
) {
|
||||
const imageRatio = image.naturalWidth / image.naturalHeight;
|
||||
const sourceX = crop.x * image.naturalWidth;
|
||||
const sourceY = crop.y * image.naturalHeight;
|
||||
const sourceWidth = crop.width * image.naturalWidth;
|
||||
const sourceHeight = crop.height * image.naturalHeight;
|
||||
const imageRatio = sourceWidth / sourceHeight;
|
||||
const boxRatio = width / height;
|
||||
let drawWidth = width;
|
||||
let drawHeight = height;
|
||||
@@ -628,15 +637,9 @@ function drawFittedImage(
|
||||
drawWidth = width;
|
||||
drawHeight = width / imageRatio;
|
||||
}
|
||||
drawWidth *= cropZoom;
|
||||
drawHeight *= cropZoom;
|
||||
// Allow an object at the source-image edge to be dragged into the crop
|
||||
// center. The exposed area intentionally becomes transparent/no-bead.
|
||||
const panRangeX = drawWidth / 2;
|
||||
const panRangeY = drawHeight / 2;
|
||||
const drawX = (width - drawWidth) / 2 + cropX * panRangeX;
|
||||
const drawY = (height - drawHeight) / 2 + cropY * panRangeY;
|
||||
ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
||||
const drawX = (width - drawWidth) / 2;
|
||||
const drawY = (height - drawHeight) / 2;
|
||||
ctx.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, drawX, drawY, drawWidth, drawHeight);
|
||||
}
|
||||
|
||||
function makeDemoImage() {
|
||||
@@ -677,9 +680,9 @@ export default function Home() {
|
||||
const [colorLimit, setColorLimit] = useState(64);
|
||||
const [fitMode, setFitMode] = useState<"cover" | "contain">("contain");
|
||||
const [samplingStrategy, setSamplingStrategy] = useState<SamplingStrategy>("dominant");
|
||||
const [cropZoom, setCropZoom] = useState(1);
|
||||
const [cropX, setCropX] = useState(0);
|
||||
const [cropY, setCropY] = useState(0);
|
||||
const [cropRect, setCropRect] = useState<CropRect>(FULL_CROP);
|
||||
const [draftCropRect, setDraftCropRect] = useState<CropRect>(FULL_CROP);
|
||||
const [cropDialogOpen, setCropDialogOpen] = useState(false);
|
||||
const [pixels, setPixels] = useState<Pixel[]>([]);
|
||||
const [selectedCodes, setSelectedCodes] = useState<Set<string>>(new Set());
|
||||
const [onlySelected, setOnlySelected] = useState(false);
|
||||
@@ -694,10 +697,11 @@ export default function Home() {
|
||||
const sourceImageRef = useRef<HTMLImageElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const cropCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const cropDialogCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const patternStageRef = useRef<HTMLDivElement | null>(null);
|
||||
const cropPreviewRef = useRef<HTMLDivElement | null>(null);
|
||||
const cropDragRef = useRef<{ pointerId: number; x: number; y: number; cropX: number; cropY: number } | null>(null);
|
||||
const cropDialogStageRef = useRef<HTMLDivElement | null>(null);
|
||||
const cropSelectionDragRef = useRef<{ pointerId: number; startX: number; startY: number } | null>(null);
|
||||
const patternDragRef = useRef<{
|
||||
pointerId: number;
|
||||
x: number;
|
||||
@@ -734,7 +738,7 @@ export default function Home() {
|
||||
|
||||
const convertImage = (
|
||||
image = sourceImageRef.current,
|
||||
crop = { zoom: cropZoom, x: cropX, y: cropY },
|
||||
crop = cropRect,
|
||||
recordUsage = true,
|
||||
) => {
|
||||
if (!image) return;
|
||||
@@ -743,7 +747,7 @@ export default function Home() {
|
||||
let data: Uint8ClampedArray;
|
||||
let dominantConfidence: Float32Array | null = null;
|
||||
if (samplingStrategy === "dominant") {
|
||||
const sampled = sampleDominantRegions(image, width, height, fitMode, crop.zoom, crop.x, crop.y);
|
||||
const sampled = sampleDominantRegions(image, width, height, fitMode, crop);
|
||||
data = sampled.data;
|
||||
dominantConfidence = sampled.confidence;
|
||||
} else {
|
||||
@@ -752,7 +756,7 @@ export default function Home() {
|
||||
sample.height = height;
|
||||
const ctx = sample.getContext("2d", { willReadFrequently: true })!;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
drawFittedImage(ctx, image, width, height, fitMode, crop.zoom, crop.x, crop.y);
|
||||
drawFittedImage(ctx, image, width, height, fitMode, crop);
|
||||
data = ctx.getImageData(0, 0, width, height).data;
|
||||
}
|
||||
const { pixelKeys, clusters, binClusters } = mergePerceptualColors(data);
|
||||
@@ -803,7 +807,7 @@ export default function Home() {
|
||||
image.onload = () => {
|
||||
sourceImageRef.current = image;
|
||||
setSourceUrl(demo);
|
||||
convertImage(image, undefined, false);
|
||||
convertImage(image, FULL_CROP, false);
|
||||
};
|
||||
image.src = demo;
|
||||
// This intentionally runs once to create the initial example.
|
||||
@@ -824,8 +828,37 @@ export default function Home() {
|
||||
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]);
|
||||
drawFittedImage(ctx, image, previewWidth, previewHeight, fitMode, cropRect);
|
||||
}, [sourceUrl, requestedWidth, requestedHeight, fitMode, cropRect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cropDialogOpen) return;
|
||||
const canvas = cropDialogCanvasRef.current;
|
||||
const image = sourceImageRef.current;
|
||||
if (!canvas || !image) return;
|
||||
const maximumWidth = 1200;
|
||||
const maximumHeight = 760;
|
||||
const scale = Math.min(maximumWidth / image.naturalWidth, maximumHeight / image.naturalHeight, 1);
|
||||
canvas.width = Math.max(1, Math.round(image.naturalWidth * scale));
|
||||
canvas.height = Math.max(1, Math.round(image.naturalHeight * scale));
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
}, [cropDialogOpen, sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cropDialogOpen) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setCropDialogOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [cropDialogOpen]);
|
||||
|
||||
const usedColors = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
@@ -940,33 +973,81 @@ export default function Home() {
|
||||
sourceImageRef.current = image;
|
||||
setSourceUrl(url);
|
||||
setSourceName(file.name);
|
||||
setCropZoom(1);
|
||||
setCropX(0);
|
||||
setCropY(0);
|
||||
setCropRect(FULL_CROP);
|
||||
setDraftCropRect(FULL_CROP);
|
||||
setStatus("图片已载入,点击“重新转换”生成图纸");
|
||||
convertImage(image, { zoom: 1, x: 0, y: 0 });
|
||||
convertImage(image, FULL_CROP);
|
||||
};
|
||||
image.src = url;
|
||||
};
|
||||
|
||||
const handleCropPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const openCropDialog = () => {
|
||||
setDraftCropRect(cropRect);
|
||||
setCropDialogOpen(true);
|
||||
};
|
||||
|
||||
const cropPoint = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const stage = cropDialogStageRef.current;
|
||||
if (!stage) return { x: 0, y: 0 };
|
||||
const rect = stage.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.max(0, Math.min(1, (event.clientX - rect.left) / Math.max(1, rect.width))),
|
||||
y: Math.max(0, Math.min(1, (event.clientY - rect.top) / Math.max(1, rect.height))),
|
||||
};
|
||||
};
|
||||
|
||||
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;
|
||||
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),
|
||||
});
|
||||
};
|
||||
|
||||
const handleCropSelectionDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
const point = cropPoint(event);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
cropDragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, cropX, cropY };
|
||||
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 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 handleCropSelectionMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = cropSelectionDragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
const point = cropPoint(event);
|
||||
updateDraftCrop(drag.startX, drag.startY, point.x, point.y);
|
||||
};
|
||||
|
||||
const handleCropPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (cropDragRef.current?.pointerId === event.pointerId) cropDragRef.current = null;
|
||||
const handleCropSelectionEnd = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (cropSelectionDragRef.current?.pointerId === event.pointerId) cropSelectionDragRef.current = null;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const confirmCrop = () => {
|
||||
if (draftCropRect.width < 0.03 || draftCropRect.height < 0.03) {
|
||||
setStatus("框选区域太小,请在大图上拖出更大的选框");
|
||||
return;
|
||||
}
|
||||
setCropRect(draftCropRect);
|
||||
setCropDialogOpen(false);
|
||||
setStatus("取景区域已更新,点击“重新转换”生成图纸");
|
||||
};
|
||||
|
||||
const toggleColor = (code: string) => {
|
||||
@@ -1151,21 +1232,19 @@ 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>
|
||||
<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" />
|
||||
<div className="upload-card crop-preview" style={{ aspectRatio: `${Math.max(8, requestedWidth || 8)} / ${Math.max(8, requestedHeight || 8)}` }}>
|
||||
<canvas ref={cropCanvasRef} aria-label="当前取景区域预览" />
|
||||
<button
|
||||
type="button"
|
||||
className="upload-overlay"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => { event.stopPropagation(); fileInputRef.current?.click(); }}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>更换图片</button>
|
||||
<input ref={fileInputRef} className="file-input" type="file" accept="image/png,image/jpeg,image/webp" onChange={handleUpload} />
|
||||
</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>
|
||||
<button className="crop-select-button" onClick={openCropDialog}>选择取景区域</button>
|
||||
<button className="reset-crop" onClick={() => { setCropRect(FULL_CROP); setDraftCropRect(FULL_CROP); setStatus("已恢复使用整张图片"); }}>使用整张图片</button>
|
||||
|
||||
<div className="field-row">
|
||||
<label><span>横向格数</span><input type="number" min="8" max="256" value={requestedWidth} onChange={(e) => setRequestedWidth(Number(e.target.value))} /></label>
|
||||
@@ -1259,6 +1338,38 @@ export default function Home() {
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
{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>
|
||||
<button aria-label="关闭取景窗口" onClick={() => setCropDialogOpen(false)}>×</button>
|
||||
</div>
|
||||
<div className="crop-dialog-scroll">
|
||||
<div
|
||||
className="crop-dialog-stage"
|
||||
ref={cropDialogStageRef}
|
||||
onPointerDown={handleCropSelectionDown}
|
||||
onPointerMove={handleCropSelectionMove}
|
||||
onPointerUp={handleCropSelectionEnd}
|
||||
onPointerCancel={handleCropSelectionEnd}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<div className="crop-dialog-actions">
|
||||
<button onClick={() => 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>
|
||||
</section>
|
||||
</div>}
|
||||
|
||||
<section className="history-section" aria-labelledby="history-title">
|
||||
<div className="history-heading"><div><p className="section-kicker">LOCAL PATTERN ARCHIVE</p><h2 id="history-title">历史图纸</h2><p>历史保存在当前浏览器,最多保留 30 份。</p></div>
|
||||
<div className="search-box"><span>⌕</span><input value={historyQuery} onChange={(e) => setHistoryQuery(e.target.value)} placeholder="按名称或 32x32 查询" /></div>
|
||||
|
||||
Reference in New Issue
Block a user