Add MARD pattern image import

This commit is contained in:
wuyanwanwu
2026-08-14 22:31:53 +08:00
parent 785a089518
commit a65536e31e
16 changed files with 416 additions and 99 deletions
+17 -1
View File
@@ -52,7 +52,10 @@ button { color: inherit; }
.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-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; }
.reset-crop { width: 100%; margin: 7px 0 0; padding: 7px; border: 1px solid var(--line); background: white; cursor: pointer; color: var(--muted); font-size: 11px; }
.pattern-import-button { width: 100%; margin: 7px 0 20px; padding: 9px; border: 1px solid #b6532d; background: #fff5ec; color: #8f3f20; cursor: pointer; font-size: 12px; font-weight: 700; }
.pattern-import-button:hover { background: #fee5d1; }
.pattern-import-file { display: none; }
.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; }
@@ -150,6 +153,12 @@ input[type="range"] { accent-color: var(--green); }
.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; }
.pattern-import-dimensions { display: flex; align-items: flex-end; gap: 8px; }
.pattern-import-dimensions label { display: grid; gap: 3px; color: var(--muted); font-size: 10px; }
.pattern-import-dimensions input { width: 82px; height: 34px; padding: 0 8px; border: 1px solid var(--line); background: white; color: var(--ink); font-size: 12px; }
.pattern-import-dimensions b { padding-bottom: 9px; color: var(--muted); font-weight: 400; }
.pattern-import-empty-option { display: flex; align-items: center; gap: 6px; color: var(--ink); cursor: pointer; }
.pattern-import-empty-option input { accent-color: var(--green); }
.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); }
@@ -165,6 +174,7 @@ 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); }
.pattern-import-grid-overlay { position: absolute; z-index: 3; inset: 0; display: block; pointer-events: none; background-image: linear-gradient(to right, rgba(255,102,0,.66) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,102,0,.66) 1px, transparent 1px); box-shadow: inset -1px -1px 0 rgba(255,102,0,.66); }
.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; }
@@ -222,10 +232,16 @@ input[type="range"] { accent-color: var(--green); }
.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; }
.pattern-import-dimensions { width: 100%; }
.pattern-import-dimensions label { flex: 1; }
.pattern-import-dimensions input { width: 100%; min-height: 42px; }
.pattern-import-empty-option { width: 100%; min-height: 34px; }
.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-stage { width: 100%; max-width: 100%; }
.crop-dialog-stage canvas { width: 100%; max-width: 100%; height: auto; }
.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; }
+358 -13
View File
@@ -36,6 +36,7 @@ type CropRect = {
};
type CropSelectionMode = "free" | "square";
type CropDialogPurpose = "source" | "pattern-import";
type CropResizeHandle = "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se";
@@ -228,6 +229,231 @@ function nearestColor(lab: Oklab, palette: BeadColor[]) {
return closest;
}
type NormalizedGlyph = {
pixels: Uint8Array;
count: number;
};
let mardCodeTemplates: Array<{ code: string; glyph: NormalizedGlyph }> | null = null;
function normalizeGlyph(mask: Uint8Array, width: number, height: number, targetWidth = 32, targetHeight = 16): NormalizedGlyph | null {
let left = width;
let right = -1;
let top = height;
let bottom = -1;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (!mask[y * width + x]) continue;
left = Math.min(left, x);
right = Math.max(right, x);
top = Math.min(top, y);
bottom = Math.max(bottom, y);
}
}
if (right < left || bottom < top) return null;
const sourceWidth = right - left + 1;
const sourceHeight = bottom - top + 1;
const scale = Math.min((targetWidth - 2) / sourceWidth, (targetHeight - 2) / sourceHeight);
const drawWidth = Math.max(1, Math.round(sourceWidth * scale));
const drawHeight = Math.max(1, Math.round(sourceHeight * scale));
const offsetX = Math.floor((targetWidth - drawWidth) / 2);
const offsetY = Math.floor((targetHeight - drawHeight) / 2);
const pixels = new Uint8Array(targetWidth * targetHeight);
let count = 0;
for (let y = 0; y < drawHeight; y++) {
for (let x = 0; x < drawWidth; x++) {
const sourceX = left + Math.min(sourceWidth - 1, Math.floor(x / scale));
const sourceY = top + Math.min(sourceHeight - 1, Math.floor(y / scale));
if (!mask[sourceY * width + sourceX]) continue;
pixels[(offsetY + y) * targetWidth + offsetX + x] = 1;
count += 1;
}
}
return count >= 3 ? { pixels, count } : null;
}
function buildMardCodeTemplates() {
if (mardCodeTemplates) return mardCodeTemplates;
const canvas = document.createElement("canvas");
canvas.width = 96;
canvas.height = 48;
const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
mardCodeTemplates = PALETTE.map((color) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#000000";
ctx.font = "700 28px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(color.code, canvas.width / 2, canvas.height / 2);
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
const mask = new Uint8Array(canvas.width * canvas.height);
for (let index = 0; index < mask.length; index++) {
const offset = index * 4;
const luminance = data[offset] * 0.299 + data[offset + 1] * 0.587 + data[offset + 2] * 0.114;
if (luminance < 150) mask[index] = 1;
}
return { code: color.code, glyph: normalizeGlyph(mask, canvas.width, canvas.height)! };
});
return mardCodeTemplates;
}
function glyphSimilarity(first: NormalizedGlyph, second: NormalizedGlyph) {
let intersection = 0;
for (let index = 0; index < first.pixels.length; index++) {
if (first.pixels[index] && second.pixels[index]) intersection += 1;
}
return (2 * intersection) / Math.max(1, first.count + second.count);
}
function dominantCellBackground(data: Uint8ClampedArray, canvasWidth: number, startX: number, startY: number, cellSize: number) {
const bins = new Map<number, { count: number; red: number; green: number; blue: number; alpha: number }>();
const inset = Math.max(2, Math.floor(cellSize * 0.12));
for (let y = inset; y < cellSize - inset; y++) {
for (let x = inset; x < cellSize - inset; x++) {
const nx = x / cellSize;
const ny = y / cellSize;
if (nx > 0.34 && nx < 0.66 && ny > 0.27 && ny < 0.73) continue;
const index = ((startY + y) * canvasWidth + startX + x) * 4;
const alpha = data[index + 3];
const key = alpha < TRANSPARENT_ALPHA_THRESHOLD
? -1
: ((data[index] >> 4) << 8) | ((data[index + 1] >> 4) << 4) | (data[index + 2] >> 4);
const bin = bins.get(key) ?? { count: 0, red: 0, green: 0, blue: 0, alpha: 0 };
bin.count += 1;
bin.red += data[index];
bin.green += data[index + 1];
bin.blue += data[index + 2];
bin.alpha += alpha;
bins.set(key, bin);
}
}
const dominant = [...bins.values()].sort((a, b) => b.count - a.count)[0];
if (!dominant) return { rgb: [255, 255, 255] as [number, number, number], alpha: 0 };
return {
rgb: [
Math.round(dominant.red / dominant.count),
Math.round(dominant.green / dominant.count),
Math.round(dominant.blue / dominant.count),
] as [number, number, number],
alpha: Math.round(dominant.alpha / dominant.count),
};
}
function extractCellGlyph(
data: Uint8ClampedArray,
canvasWidth: number,
startX: number,
startY: number,
cellSize: number,
background: [number, number, number],
) {
const mask = new Uint8Array(cellSize * cellSize);
const marginX = Math.max(2, Math.floor(cellSize * 0.12));
const marginY = Math.max(2, Math.floor(cellSize * 0.16));
for (let y = marginY; y < cellSize - marginY; y++) {
for (let x = marginX; x < cellSize - marginX; x++) {
const index = ((startY + y) * canvasWidth + startX + x) * 4;
if (data[index + 3] < TRANSPARENT_ALPHA_THRESHOLD) continue;
const difference = Math.hypot(
data[index] - background[0],
data[index + 1] - background[1],
data[index + 2] - background[2],
);
if (difference >= 72) mask[y * cellSize + x] = 1;
}
}
return normalizeGlyph(mask, cellSize, cellSize);
}
function recognizeMardCode(glyph: NormalizedGlyph, backgroundLab: Oklab) {
let bestCode = "";
let bestScore = 0;
const candidates = buildMardCodeTemplates()
.map((template) => ({ template, distance: oklabDistance(backgroundLab, PALETTE_BY_CODE.get(template.code)!.lab) }))
.sort((first, second) => first.distance - second.distance)
.slice(0, 24);
for (const { template, distance } of candidates) {
const rawScore = glyphSimilarity(glyph, template.glyph);
const colorPenalty = Math.min(0.16, distance * 0.35);
const score = rawScore - colorPenalty;
if (score > bestScore) {
bestScore = score;
bestCode = template.code;
}
}
return { code: bestCode, score: bestScore };
}
function importMardPatternImage(
image: HTMLImageElement,
crop: CropRect,
width: number,
height: number,
missingCodeAsEmpty: boolean,
) {
const maximumSide = 4096;
const cellSize = Math.max(10, Math.min(36, Math.floor(maximumSide / Math.max(width, height))));
const canvas = document.createElement("canvas");
canvas.width = width * cellSize;
canvas.height = height * cellSize;
const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
ctx.imageSmoothingEnabled = true;
ctx.drawImage(
image,
crop.x * image.naturalWidth,
crop.y * image.naturalHeight,
crop.width * image.naturalWidth,
crop.height * image.naturalHeight,
0,
0,
canvas.width,
canvas.height,
);
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
const pixels: Pixel[] = [];
let recognizedCodes = 0;
let colorMatches = 0;
let emptyCells = 0;
const recognitionCache = new Map<string, { code: string; score: number }>();
for (let row = 0; row < height; row++) {
for (let column = 0; column < width; column++) {
const startX = column * cellSize;
const startY = row * cellSize;
const background = dominantCellBackground(data, canvas.width, startX, startY, cellSize);
const backgroundLab = rgbToOklab(background.rgb);
const glyph = extractCellGlyph(data, canvas.width, startX, startY, cellSize, background.rgb);
let recognized: { code: string; score: number } | null = null;
if (glyph) {
let key = "";
for (let index = 0; index < glyph.pixels.length; index++) key += glyph.pixels[index] ? "1" : "0";
recognized = recognitionCache.get(key) ?? recognizeMardCode(glyph, backgroundLab);
recognitionCache.set(key, recognized);
}
if (recognized && recognized.score >= 0.58 && PALETTE_BY_CODE.has(recognized.code)) {
pixels.push({ color: PALETTE_BY_CODE.get(recognized.code)! });
recognizedCodes += 1;
continue;
}
if (missingCodeAsEmpty) {
pixels.push({ color: null });
emptyCells += 1;
continue;
}
const chroma = Math.hypot(backgroundLab[1], backgroundLab[2]);
if (background.alpha < TRANSPARENT_ALPHA_THRESHOLD || (!glyph && backgroundLab[0] > 0.93 && chroma < 0.025)) {
pixels.push({ color: null });
emptyCells += 1;
continue;
}
pixels.push({ color: nearestColor(backgroundLab, PALETTE) });
colorMatches += 1;
}
}
return { pixels, recognizedCodes, colorMatches, emptyCells };
}
function mergePerceptualColors(data: Uint8ClampedArray) {
const pixelKeys: Array<number | null> = [];
const histogram = new Map<number, { count: number; red: number; green: number; blue: number }>();
@@ -755,6 +981,13 @@ export default function Home() {
const [draftCropRect, setDraftCropRect] = useState<CropRect>(FULL_CROP);
const [cropSelectionMode, setCropSelectionMode] = useState<CropSelectionMode>("free");
const [cropDialogOpen, setCropDialogOpen] = useState(false);
const [cropDialogPurpose, setCropDialogPurpose] = useState<CropDialogPurpose>("source");
const [patternImportWidth, setPatternImportWidth] = useState(100);
const [patternImportHeight, setPatternImportHeight] = useState(100);
const [missingCodeAsEmpty, setMissingCodeAsEmpty] = useState(true);
const [patternImportName, setPatternImportName] = useState("");
const [patternImportUrl, setPatternImportUrl] = useState("");
const [patternImportBusy, setPatternImportBusy] = useState(false);
const [pixels, setPixels] = useState<Pixel[]>([]);
const [selectedCodes, setSelectedCodes] = useState<Set<string>>(new Set());
const [onlySelected, setOnlySelected] = useState(false);
@@ -768,6 +1001,8 @@ export default function Home() {
const [status, setStatus] = useState("示例图已准备好,可以直接转换");
const sourceImageRef = useRef<HTMLImageElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const patternImportInputRef = useRef<HTMLInputElement | null>(null);
const patternImportImageRef = useRef<HTMLImageElement | null>(null);
const cropCanvasRef = useRef<HTMLCanvasElement | null>(null);
const cropDialogCanvasRef = useRef<HTMLCanvasElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -791,6 +1026,9 @@ export default function Home() {
moved: boolean;
} | null>(null);
const suppressCanvasClickRef = useRef(false);
const cropDialogImage = cropDialogPurpose === "pattern-import"
? patternImportImageRef.current
: sourceImageRef.current;
useEffect(() => {
logUsage({ event: "page_view" });
@@ -914,7 +1152,7 @@ export default function Home() {
useEffect(() => {
if (!cropDialogOpen) return;
const canvas = cropDialogCanvasRef.current;
const image = sourceImageRef.current;
const image = cropDialogImage;
if (!canvas || !image) return;
const maximumWidth = 1200;
const maximumHeight = 760;
@@ -924,7 +1162,7 @@ export default function Home() {
const ctx = canvas.getContext("2d")!;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
}, [cropDialogOpen, sourceUrl]);
}, [cropDialogOpen, cropDialogPurpose, sourceUrl, patternImportUrl, cropDialogImage]);
useEffect(() => {
if (!cropDialogOpen) return;
@@ -1107,7 +1345,38 @@ export default function Home() {
image.src = url;
};
const handlePatternImportUpload = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
setStatus("请选择 JPG、PNG 或 WebP 图纸图片");
return;
}
const url = URL.createObjectURL(file);
const image = new Image();
image.onload = () => {
if (patternImportUrl.startsWith("blob:") && patternImportUrl !== sourceUrl) URL.revokeObjectURL(patternImportUrl);
patternImportImageRef.current = image;
setPatternImportUrl(url);
setPatternImportName(file.name);
setPatternImportWidth(gridWidth);
setPatternImportHeight(gridHeight);
setCropDialogPurpose("pattern-import");
setCropSelectionMode("free");
setDraftCropRect(FULL_CROP);
setCropDialogOpen(true);
setStatus("图纸图片已载入,请框选实际网格并填写行列数");
};
image.onerror = () => {
URL.revokeObjectURL(url);
setStatus("图纸图片读取失败,请换一张图片重试");
};
image.src = url;
};
const openCropDialog = () => {
setCropDialogPurpose("source");
setDraftCropRect(cropRect);
setCropDialogOpen(true);
};
@@ -1128,7 +1397,7 @@ export default function Home() {
let width = Math.abs(endX - startX);
let height = Math.abs(endY - startY);
if (cropSelectionMode === "square") {
const image = sourceImageRef.current;
const image = cropDialogImage;
if (!image) return;
const normalizedSquareRatio = image.naturalHeight / image.naturalWidth;
if (width / Math.max(height, 0.0001) > normalizedSquareRatio) width = height * normalizedSquareRatio;
@@ -1161,7 +1430,7 @@ export default function Home() {
const chooseCropSelectionMode = (mode: CropSelectionMode) => {
setCropSelectionMode(mode);
if (mode !== "square") return;
const image = sourceImageRef.current;
const image = cropDialogImage;
if (!image) return;
const currentCenterX = draftCropRect.x + draftCropRect.width / 2;
const currentCenterY = draftCropRect.y + draftCropRect.height / 2;
@@ -1267,11 +1536,65 @@ export default function Home() {
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
};
const confirmPatternImport = async () => {
const image = patternImportImageRef.current;
const width = Math.max(1, Math.min(256, Math.round(patternImportWidth)));
const height = Math.max(1, Math.min(256, Math.round(patternImportHeight)));
if (!image) {
setStatus("请先选择要导入的图纸图片");
return;
}
if (draftCropRect.width < 0.03 || draftCropRect.height < 0.03) {
setStatus("网格区域太小,请重新框选");
return;
}
setPatternImportBusy(true);
setStatus("正在读取 MARD 编码并匹配格子底色…");
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
try {
const result = importMardPatternImage(image, draftCropRect, width, height, missingCodeAsEmpty);
const actualColors = new Set(result.pixels.flatMap(({ color }) => color ? [color.code] : [])).size;
const beadCount = result.pixels.reduce((total, { color }) => total + (color ? 1 : 0), 0);
setPixels(result.pixels);
setGridWidth(width);
setGridHeight(height);
setRequestedWidth(width);
setRequestedHeight(height);
setSelectedCodes(new Set());
setCropRect(draftCropRect);
sourceImageRef.current = image;
setSourceUrl(patternImportUrl);
setSourceName(`导入图纸:${patternImportName}`);
setCropDialogOpen(false);
setStatus(`已导入 ${width} × ${height} · 读取编码 ${result.recognizedCodes} 格 · 底色匹配 ${result.colorMatches} 格 · 空白 ${result.emptyCells}`);
logUsage({
event: "pattern_import",
width,
height,
recognized_codes: result.recognizedCodes,
color_matches: result.colorMatches,
transparent_cells: result.emptyCells,
actual_colors: actualColors,
bead_count: beadCount,
missing_code_as_empty: missingCodeAsEmpty,
});
window.requestAnimationFrame(() => document.getElementById("pattern-preview")?.scrollIntoView({ behavior: "smooth", block: "start" }));
} catch {
setStatus("图纸识别失败,请确认选框与网格行列准确后重试");
} finally {
setPatternImportBusy(false);
}
};
const confirmCrop = () => {
if (draftCropRect.width < 0.03 || draftCropRect.height < 0.03) {
setStatus("框选区域太小,请在大图上拖出更大的选框");
return;
}
if (cropDialogPurpose === "pattern-import") {
void confirmPatternImport();
return;
}
setCropRect(draftCropRect);
setCropDialogOpen(false);
convertImage(sourceImageRef.current, draftCropRect);
@@ -1596,6 +1919,8 @@ export default function Home() {
<button className="crop-select-button" onClick={openCropDialog}></button>
<button className="reset-crop" onClick={() => { setCropRect(FULL_CROP); setDraftCropRect(FULL_CROP); setStatus("已恢复使用整张图片"); }}>使</button>
<button className="pattern-import-button" onClick={() => patternImportInputRef.current?.click()}></button>
<input ref={patternImportInputRef} className="file-input pattern-import-file" type="file" accept="image/png,image/jpeg,image/webp" onChange={handlePatternImportUpload} />
<div className="field-row">
<label><span></span><input type="number" min="8" max="256" value={requestedWidth} onChange={(e) => setRequestedWidth(Number(e.target.value))} /></label>
@@ -1607,7 +1932,7 @@ export default function Home() {
<label className="field"><span></span><select value={samplingStrategy} onChange={(e) => setSamplingStrategy(e.target.value as SamplingStrategy)}><option value="dominant"></option><option value="smooth"></option></select></label>
<label className="field"><span></span><select value={fitMode} onChange={(e) => setFitMode(e.target.value as "cover" | "contain")}><option value="contain"></option><option value="cover"></option></select></label>
<button className="primary-button" onClick={() => convertImage()}></button>
<p className="privacy-note"></p>
<p className="privacy-note"></p>
</aside>
<section className="pattern-panel" id="pattern-preview">
@@ -1693,15 +2018,26 @@ 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></p></div>
{cropDialogPurpose === "pattern-import"
? <div><p className="section-kicker">MARD PATTERN IMPORT</p><h2 id="crop-dialog-title"> MARD </h2><p></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>
{cropDialogPurpose === "pattern-import" ? <>
<div className="pattern-import-dimensions">
<label><span></span><input aria-label="导入图纸横向列数" type="number" min="1" max="256" value={patternImportWidth} onChange={(event) => setPatternImportWidth(Number(event.target.value))} /></label>
<b>×</b>
<label><span></span><input aria-label="导入图纸纵向行数" type="number" min="1" max="256" value={patternImportHeight} onChange={(event) => setPatternImportHeight(Number(event.target.value))} /></label>
</div>
<label className="pattern-import-empty-option"><input type="checkbox" checked={missingCodeAsEmpty} onChange={(event) => setMissingCodeAsEmpty(event.target.checked)} /> MARD </label>
</> : <>
<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">
@@ -1720,6 +2056,11 @@ export default function Home() {
<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}%` }}>
{cropDialogPurpose === "pattern-import" && patternImportWidth > 0 && patternImportHeight > 0 && <i
className="pattern-import-grid-overlay"
style={{ backgroundSize: `${100 / patternImportWidth}% 100%, 100% ${100 / patternImportHeight}%` }}
aria-hidden="true"
/>}
{(["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>
@@ -1727,9 +2068,13 @@ export default function Home() {
</div>
<div className="crop-dialog-actions">
<button onClick={() => { setCropSelectionMode("free"); setDraftCropRect(FULL_CROP); }}></button>
<span></span>
<span>{cropDialogPurpose === "pattern-import" ? "对齐越准确,格内编码与底色识别越可靠" : "框外拖动可重选,框内拖动可移动,控制点可缩放"}</span>
<button onClick={() => setCropDialogOpen(false)}></button>
<button className="confirm" disabled={draftCropRect.width < 0.03 || draftCropRect.height < 0.03} onClick={confirmCrop}>使</button>
<button
className="confirm"
disabled={patternImportBusy || draftCropRect.width < 0.03 || draftCropRect.height < 0.03 || (cropDialogPurpose === "pattern-import" && (patternImportWidth < 1 || patternImportWidth > 256 || patternImportHeight < 1 || patternImportHeight > 256))}
onClick={confirmCrop}
>{cropDialogPurpose === "pattern-import" ? (patternImportBusy ? "正在识别…" : "识别并导入") : "使用此区域"}</button>
</div>
</section>
</div>}