Add MARD pattern image import
This commit is contained in:
+17
-1
@@ -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; }
|
.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; }
|
.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; }
|
.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 { 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, .field { display: flex; flex-direction: column; gap: 8px; }
|
||||||
.field-row label span, .field > span { color: var(--muted); font-size: 12px; }
|
.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 { 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:last-child { border-right: 0; }
|
||||||
.crop-mode-switch button.active { background: var(--green); color: white; font-weight: 700; }
|
.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-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-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 { 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, .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::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-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; }
|
.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-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-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-bar { padding: 8px 12px; flex-wrap: wrap; gap: 7px 10px; }
|
||||||
.crop-mode-switch { flex: 1; }
|
.crop-mode-switch { flex: 1; }
|
||||||
.crop-mode-switch button { flex: 1; padding: 0 8px; }
|
.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-size-readout { width: 100%; margin-left: 0; }
|
||||||
.crop-resize-handle { width: 28px; height: 28px; border-width: 3px; }
|
.crop-resize-handle { width: 28px; height: 28px; border-width: 3px; }
|
||||||
.crop-edge-handle { display: none; }
|
.crop-edge-handle { display: none; }
|
||||||
.crop-dialog-scroll { padding: 10px; }
|
.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 { padding: 10px 12px; flex-wrap: wrap; }
|
||||||
.crop-dialog-actions span { width: 100%; order: -1; }
|
.crop-dialog-actions span { width: 100%; order: -1; }
|
||||||
.crop-dialog-actions button { flex: 1; padding: 0 10px; }
|
.crop-dialog-actions button { flex: 1; padding: 0 10px; }
|
||||||
|
|||||||
+353
-8
@@ -36,6 +36,7 @@ type CropRect = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type CropSelectionMode = "free" | "square";
|
type CropSelectionMode = "free" | "square";
|
||||||
|
type CropDialogPurpose = "source" | "pattern-import";
|
||||||
|
|
||||||
type CropResizeHandle = "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se";
|
type CropResizeHandle = "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se";
|
||||||
|
|
||||||
@@ -228,6 +229,231 @@ function nearestColor(lab: Oklab, palette: BeadColor[]) {
|
|||||||
return closest;
|
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) {
|
function mergePerceptualColors(data: Uint8ClampedArray) {
|
||||||
const pixelKeys: Array<number | null> = [];
|
const pixelKeys: Array<number | null> = [];
|
||||||
const histogram = new Map<number, { count: number; red: number; green: number; blue: number }>();
|
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 [draftCropRect, setDraftCropRect] = useState<CropRect>(FULL_CROP);
|
||||||
const [cropSelectionMode, setCropSelectionMode] = useState<CropSelectionMode>("free");
|
const [cropSelectionMode, setCropSelectionMode] = useState<CropSelectionMode>("free");
|
||||||
const [cropDialogOpen, setCropDialogOpen] = useState(false);
|
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 [pixels, setPixels] = useState<Pixel[]>([]);
|
||||||
const [selectedCodes, setSelectedCodes] = useState<Set<string>>(new Set());
|
const [selectedCodes, setSelectedCodes] = useState<Set<string>>(new Set());
|
||||||
const [onlySelected, setOnlySelected] = useState(false);
|
const [onlySelected, setOnlySelected] = useState(false);
|
||||||
@@ -768,6 +1001,8 @@ export default function Home() {
|
|||||||
const [status, setStatus] = useState("示例图已准备好,可以直接转换");
|
const [status, setStatus] = useState("示例图已准备好,可以直接转换");
|
||||||
const sourceImageRef = useRef<HTMLImageElement | null>(null);
|
const sourceImageRef = useRef<HTMLImageElement | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement | 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 cropCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const cropDialogCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
const cropDialogCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
@@ -791,6 +1026,9 @@ export default function Home() {
|
|||||||
moved: boolean;
|
moved: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const suppressCanvasClickRef = useRef(false);
|
const suppressCanvasClickRef = useRef(false);
|
||||||
|
const cropDialogImage = cropDialogPurpose === "pattern-import"
|
||||||
|
? patternImportImageRef.current
|
||||||
|
: sourceImageRef.current;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logUsage({ event: "page_view" });
|
logUsage({ event: "page_view" });
|
||||||
@@ -914,7 +1152,7 @@ export default function Home() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!cropDialogOpen) return;
|
if (!cropDialogOpen) return;
|
||||||
const canvas = cropDialogCanvasRef.current;
|
const canvas = cropDialogCanvasRef.current;
|
||||||
const image = sourceImageRef.current;
|
const image = cropDialogImage;
|
||||||
if (!canvas || !image) return;
|
if (!canvas || !image) return;
|
||||||
const maximumWidth = 1200;
|
const maximumWidth = 1200;
|
||||||
const maximumHeight = 760;
|
const maximumHeight = 760;
|
||||||
@@ -924,7 +1162,7 @@ export default function Home() {
|
|||||||
const ctx = canvas.getContext("2d")!;
|
const ctx = canvas.getContext("2d")!;
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||||
}, [cropDialogOpen, sourceUrl]);
|
}, [cropDialogOpen, cropDialogPurpose, sourceUrl, patternImportUrl, cropDialogImage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!cropDialogOpen) return;
|
if (!cropDialogOpen) return;
|
||||||
@@ -1107,7 +1345,38 @@ export default function Home() {
|
|||||||
image.src = url;
|
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 = () => {
|
const openCropDialog = () => {
|
||||||
|
setCropDialogPurpose("source");
|
||||||
setDraftCropRect(cropRect);
|
setDraftCropRect(cropRect);
|
||||||
setCropDialogOpen(true);
|
setCropDialogOpen(true);
|
||||||
};
|
};
|
||||||
@@ -1128,7 +1397,7 @@ export default function Home() {
|
|||||||
let width = Math.abs(endX - startX);
|
let width = Math.abs(endX - startX);
|
||||||
let height = Math.abs(endY - startY);
|
let height = Math.abs(endY - startY);
|
||||||
if (cropSelectionMode === "square") {
|
if (cropSelectionMode === "square") {
|
||||||
const image = sourceImageRef.current;
|
const image = cropDialogImage;
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
const normalizedSquareRatio = image.naturalHeight / image.naturalWidth;
|
const normalizedSquareRatio = image.naturalHeight / image.naturalWidth;
|
||||||
if (width / Math.max(height, 0.0001) > normalizedSquareRatio) width = height * normalizedSquareRatio;
|
if (width / Math.max(height, 0.0001) > normalizedSquareRatio) width = height * normalizedSquareRatio;
|
||||||
@@ -1161,7 +1430,7 @@ export default function Home() {
|
|||||||
const chooseCropSelectionMode = (mode: CropSelectionMode) => {
|
const chooseCropSelectionMode = (mode: CropSelectionMode) => {
|
||||||
setCropSelectionMode(mode);
|
setCropSelectionMode(mode);
|
||||||
if (mode !== "square") return;
|
if (mode !== "square") return;
|
||||||
const image = sourceImageRef.current;
|
const image = cropDialogImage;
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
const currentCenterX = draftCropRect.x + draftCropRect.width / 2;
|
const currentCenterX = draftCropRect.x + draftCropRect.width / 2;
|
||||||
const currentCenterY = draftCropRect.y + draftCropRect.height / 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);
|
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 = () => {
|
const confirmCrop = () => {
|
||||||
if (draftCropRect.width < 0.03 || draftCropRect.height < 0.03) {
|
if (draftCropRect.width < 0.03 || draftCropRect.height < 0.03) {
|
||||||
setStatus("框选区域太小,请在大图上拖出更大的选框");
|
setStatus("框选区域太小,请在大图上拖出更大的选框");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (cropDialogPurpose === "pattern-import") {
|
||||||
|
void confirmPatternImport();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCropRect(draftCropRect);
|
setCropRect(draftCropRect);
|
||||||
setCropDialogOpen(false);
|
setCropDialogOpen(false);
|
||||||
convertImage(sourceImageRef.current, draftCropRect);
|
convertImage(sourceImageRef.current, draftCropRect);
|
||||||
@@ -1596,6 +1919,8 @@ export default function Home() {
|
|||||||
|
|
||||||
<button className="crop-select-button" onClick={openCropDialog}>选择取景区域</button>
|
<button className="crop-select-button" onClick={openCropDialog}>选择取景区域</button>
|
||||||
<button className="reset-crop" onClick={() => { setCropRect(FULL_CROP); setDraftCropRect(FULL_CROP); setStatus("已恢复使用整张图片"); }}>使用整张图片</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">
|
<div className="field-row">
|
||||||
<label><span>横向格数</span><input type="number" min="8" max="256" value={requestedWidth} onChange={(e) => setRequestedWidth(Number(e.target.value))} /></label>
|
<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={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>
|
<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>
|
<button className="primary-button" onClick={() => convertImage()}>重新转换</button>
|
||||||
<p className="privacy-note">图片只在当前浏览器中处理,不会上传到服务器。</p>
|
<p className="privacy-note">原图与现成图纸都只在当前浏览器中处理,不会上传到服务器。</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section className="pattern-panel" id="pattern-preview">
|
<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); }}>
|
{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">
|
<section className="crop-dialog" role="dialog" aria-modal="true" aria-labelledby="crop-dialog-title">
|
||||||
<div className="crop-dialog-header">
|
<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>
|
<button aria-label="关闭取景窗口" onClick={() => setCropDialogOpen(false)}>×</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="crop-mode-bar">
|
<div className="crop-mode-bar">
|
||||||
|
{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>
|
<span>选框形状</span>
|
||||||
<div className="crop-mode-switch" role="group" aria-label="选框形状">
|
<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 === "free" ? "active" : ""} aria-pressed={cropSelectionMode === "free"} onClick={() => chooseCropSelectionMode("free")}>自由矩形</button>
|
||||||
<button className={cropSelectionMode === "square" ? "active" : ""} aria-pressed={cropSelectionMode === "square"} onClick={() => chooseCropSelectionMode("square")}>正方形</button>
|
<button className={cropSelectionMode === "square" ? "active" : ""} aria-pressed={cropSelectionMode === "square"} onClick={() => chooseCropSelectionMode("square")}>正方形</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>}
|
||||||
<span className="crop-size-readout">当前选框:{Math.round(draftCropRect.width * 100)}% × {Math.round(draftCropRect.height * 100)}%</span>
|
<span className="crop-size-readout">当前选框:{Math.round(draftCropRect.width * 100)}% × {Math.round(draftCropRect.height * 100)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="crop-dialog-scroll">
|
<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-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-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}%` }}>
|
||||||
|
{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" />)}
|
{(["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" />)}
|
{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>
|
||||||
@@ -1727,9 +2068,13 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="crop-dialog-actions">
|
<div className="crop-dialog-actions">
|
||||||
<button onClick={() => { setCropSelectionMode("free"); setDraftCropRect(FULL_CROP); }}>选择整张图</button>
|
<button onClick={() => { setCropSelectionMode("free"); setDraftCropRect(FULL_CROP); }}>选择整张图</button>
|
||||||
<span>框外拖动可重选,框内拖动可移动,控制点可缩放</span>
|
<span>{cropDialogPurpose === "pattern-import" ? "对齐越准确,格内编码与底色识别越可靠" : "框外拖动可重选,框内拖动可移动,控制点可缩放"}</span>
|
||||||
<button onClick={() => setCropDialogOpen(false)}>取消</button>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>}
|
</div>}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"name": "rolldown-runtime"
|
"name": "rolldown-runtime"
|
||||||
},
|
},
|
||||||
"app/page.tsx": {
|
"app/page.tsx": {
|
||||||
"file": "_next/static/chunks/page-_Oyr6H78.js",
|
"file": "_next/static/chunks/page-CZiDr8AX.js",
|
||||||
"name": "page",
|
"name": "page",
|
||||||
"src": "app/page.tsx",
|
"src": "app/page.tsx",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/vinext/dist/shims/layout-segment-context.js": {
|
"node_modules/vinext/dist/shims/layout-segment-context.js": {
|
||||||
"file": "_next/static/chunks/layout-segment-context-T1W3MOgS.js",
|
"file": "_next/static/chunks/layout-segment-context-BKsVwj6c.js",
|
||||||
"name": "layout-segment-context",
|
"name": "layout-segment-context",
|
||||||
"src": "node_modules/vinext/dist/shims/layout-segment-context.js",
|
"src": "node_modules/vinext/dist/shims/layout-segment-context.js",
|
||||||
"isDynamicEntry": true,
|
"isDynamicEntry": true,
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"virtual:vinext-app-browser-entry": {
|
"virtual:vinext-app-browser-entry": {
|
||||||
"file": "_next/static/chunks/index-DZQMDmd9.js",
|
"file": "_next/static/chunks/index-CRBC3ESD.js",
|
||||||
"name": "index",
|
"name": "index",
|
||||||
"src": "virtual:vinext-app-browser-entry",
|
"src": "virtual:vinext-app-browser-entry",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
|
|||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/index.DYq6BwQf.css" data-rsc-css-href="/_next/static/css/index.DYq6BwQf.css" data-precedence="vite-rsc/importer-resources"/><link rel="modulepreload" fetchPriority="low" href="/_next/static/chunks/index-DZQMDmd9.js"/><script src="/_next/static/chunks/rolldown-runtime-C60lm6uB.js" type="module" async=""></script><script src="/_next/static/chunks/framework-BgSIrAUN.js" type="module" async=""></script><meta name="robots" content="noindex"/><title>豆格工坊|图片转拼豆图纸</title><meta name="description" content="在浏览器中把图片转换为拼豆像素图纸,按色号、名称或像素格筛选颜色。"/><title>404: This page could not be found.</title><script>Object.assign(((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]}),{params:{},nav:{"pathname":"/__vinext_nonexistent_for_404__","searchParams":[]}})</script><link rel="modulepreload" href="/_next/static/chunks/index-DZQMDmd9.js" />
|
<!DOCTYPE html><html lang="zh-CN"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/index.B4qFKCfT.css" data-rsc-css-href="/_next/static/css/index.B4qFKCfT.css" data-precedence="vite-rsc/importer-resources"/><link rel="modulepreload" fetchPriority="low" href="/_next/static/chunks/index-CRBC3ESD.js"/><script src="/_next/static/chunks/rolldown-runtime-C60lm6uB.js" type="module" async=""></script><script src="/_next/static/chunks/framework-BgSIrAUN.js" type="module" async=""></script><meta name="robots" content="noindex"/><title>豆格工坊|图片转拼豆图纸</title><meta name="description" content="在浏览器中把图片转换为拼豆像素图纸,按色号、名称或像素格筛选颜色。"/><title>404: This page could not be found.</title><script>Object.assign(((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]}),{params:{},nav:{"pathname":"/__vinext_nonexistent_for_404__","searchParams":[]}})</script><link rel="modulepreload" href="/_next/static/chunks/index-CRBC3ESD.js" />
|
||||||
</head><body><div style="font-family:system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";height:100vh;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center"><div><style>body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}</style><h1 class="next-error-h1" style="display:inline-block;margin:0 20px 0 0;padding:0 23px 0 0;font-size:24px;font-weight:500;vertical-align:top;line-height:49px">404</h1><div style="display:inline-block"><h2 style="font-size:14px;font-weight:400;line-height:49px;margin:0">This page could not be found.</h2></div></div></div><script type="module" src="/_next/static/chunks/index-DZQMDmd9.js" id="_R_" async=""></script><script>((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]}).rsc.push("1:\"$Sreact.fragment\"\n")</script><script>((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]}).rsc.push(":HL[\"/_next/static/css/index.DYq6BwQf.css\",\"style\" ]\n")</script><script>((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]}).rsc.push("0:{\"__route\":\"route:/__vinext_nonexistent_for_404__\",\"__interceptionContext\":null,\"__layoutIds\":[],\"__rootLayout\":null,\"route:/__vinext_nonexistent_for_404__\":[[[\"$\",\"link\",\"css:/_next/static/css/index.DYq6BwQf.css\",{\"rel\":\"stylesheet\",\"precedence\":\"vite-rsc/importer-resources\",\"href\":\"/_next/static/css/index.DYq6BwQf.css\",\"data-rsc-css-href\":\"/_next/static/css/index.DYq6BwQf.css\"}],\"$undefined\"],[\"$\",\"html\",null,{\"lang\":\"zh-CN\",\"children\":[\"$\",\"body\",null,{\"children\":[[\"$\",\"meta\",\"charset\",{\"charSet\":\"utf-8\"}],[\"$\",\"meta\",\"robots\",{\"name\":\"robots\",\"content\":\"noindex\"}],[\"$\",\"$1\",\"metadata\",{\"children\":[[\"$\",\"title\",\"0\",{\"children\":\"豆格工坊|图片转拼豆图纸\"}],[\"$\",\"meta\",\"1\",{\"name\":\"description\",\"content\":\"在浏览器中把图片转换为拼豆像素图纸,按色号、名称或像素格筛选颜色。\"}]]}],[\"$\",\"$1\",\"viewport\",{\"children\":[[\"$\",\"meta\",\"0\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}]]}],[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":404}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]]]}]}]]}\n")</script><script>((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]}).done=true</script></body></html>
|
</head><body><div style="font-family:system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";height:100vh;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center"><div><style>body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}</style><h1 class="next-error-h1" style="display:inline-block;margin:0 20px 0 0;padding:0 23px 0 0;font-size:24px;font-weight:500;vertical-align:top;line-height:49px">404</h1><div style="display:inline-block"><h2 style="font-size:14px;font-weight:400;line-height:49px;margin:0">This page could not be found.</h2></div></div></div><script type="module" src="/_next/static/chunks/index-CRBC3ESD.js" id="_R_" async=""></script><script>((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]}).rsc.push("1:\"$Sreact.fragment\"\n")</script><script>((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]}).rsc.push(":HL[\"/_next/static/css/index.B4qFKCfT.css\",\"style\" ]\n")</script><script>((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]}).rsc.push("0:{\"__route\":\"route:/__vinext_nonexistent_for_404__\",\"__interceptionContext\":null,\"__layoutIds\":[],\"__rootLayout\":null,\"route:/__vinext_nonexistent_for_404__\":[[[\"$\",\"link\",\"css:/_next/static/css/index.B4qFKCfT.css\",{\"rel\":\"stylesheet\",\"precedence\":\"vite-rsc/importer-resources\",\"href\":\"/_next/static/css/index.B4qFKCfT.css\",\"data-rsc-css-href\":\"/_next/static/css/index.B4qFKCfT.css\"}],\"$undefined\"],[\"$\",\"html\",null,{\"lang\":\"zh-CN\",\"children\":[\"$\",\"body\",null,{\"children\":[[\"$\",\"meta\",\"charset\",{\"charSet\":\"utf-8\"}],[\"$\",\"meta\",\"robots\",{\"name\":\"robots\",\"content\":\"noindex\"}],[\"$\",\"$1\",\"metadata\",{\"children\":[[\"$\",\"title\",\"0\",{\"children\":\"豆格工坊|图片转拼豆图纸\"}],[\"$\",\"meta\",\"1\",{\"name\":\"description\",\"content\":\"在浏览器中把图片转换为拼豆像素图纸,按色号、名称或像素格筛选颜色。\"}]]}],[\"$\",\"$1\",\"viewport\",{\"children\":[[\"$\",\"meta\",\"0\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}]]}],[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":404}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]]]}]}]]}\n")</script><script>((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]}).done=true</script></body></html>
|
||||||
+3
-3
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
|||||||
import{r as e}from"./rolldown-runtime-C60lm6uB.js";import{r as t}from"./framework-BgSIrAUN.js";import{t as n}from"./index-DZQMDmd9.js";var r=e(t(),1),i=new Map;function a(e,t){return e?{...e,...t}:t}function o({providerId:e,segmentMap:t,children:o}){let s=(0,r.useRef)(null),c=n(),l=a(s.current??(e?i.get(e)??null:null),t);return(0,r.useEffect)(()=>{s.current=l,e&&i.set(e,l)},[l,e]),c?(0,r.createElement)(c.Provider,{value:l},o):o}export{o as LayoutSegmentProvider,a as mergeLayoutSegmentMap};
|
import{r as e}from"./rolldown-runtime-C60lm6uB.js";import{r as t}from"./framework-BgSIrAUN.js";import{t as n}from"./index-CRBC3ESD.js";var r=e(t(),1),i=new Map;function a(e,t){return e?{...e,...t}:t}function o({providerId:e,segmentMap:t,children:o}){let s=(0,r.useRef)(null),c=n(),l=a(s.current??(e?i.get(e)??null:null),t);return(0,r.useEffect)(()=>{s.current=l,e&&i.set(e,l)},[l,e]),c?(0,r.createElement)(c.Provider,{value:l},o):o}export{o as LayoutSegmentProvider,a as mergeLayoutSegmentMap};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
7:I["8c0f216c4604",[],"Slot",1]
|
7:I["8c0f216c4604",[],"Slot",1]
|
||||||
8:I["9276801271d6",[],"AppRouterScrollTarget",1]
|
8:I["9276801271d6",[],"AppRouterScrollTarget",1]
|
||||||
9:I["593f344dc510",[],"RedirectBoundary",1]
|
9:I["593f344dc510",[],"RedirectBoundary",1]
|
||||||
:HL["/_next/static/css/index.DYq6BwQf.css","style" ]
|
:HL["/_next/static/css/index.B4qFKCfT.css","style" ]
|
||||||
0:{"__route":"route:/","__interceptionContext":null,"__layoutIds":["layout:/"],"__rootLayout":"/","__sourcePage":"/page","page:/":"$L1","layout:/":[[[["$","link","css:/_next/static/css/index.DYq6BwQf.css",{"rel":"stylesheet","precedence":"vite-rsc/importer-resources","href":"/_next/static/css/index.DYq6BwQf.css","data-rsc-css-href":"/_next/static/css/index.DYq6BwQf.css"}],"$undefined"],["$","html",null,{"lang":"zh-CN","children":["$","body",null,{"children":["$","$L2",null,{}]}]}]],null],"route:/":[[["$","meta",null,{"charSet":"utf-8"}],[["$","title","0",{"children":"豆格工坊|图片转拼豆图纸"}],["$","meta","1",{"name":"description","content":"在浏览器中把图片转换为拼豆像素图纸,按色号、名称或像素格筛选颜色。"}]],[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]],["$","$L3",null,{"fallback":"$4","children":["$","$L5",null,{"fallback":"$4","children":["$","$L6",null,{"providerId":"layout:/","segmentMap":{"children":[]},"children":["$","$L7",null,{"id":"layout:/","parallelSlots":"$undefined","children":["$","$L8",null,{"children":["$","$L9",null,{"children":[["$","$L6",null,{"providerId":"page:/","segmentMap":{"children":["__PAGE__"]},"children":["$","$L7",null,{"id":"page:/"}]}],null]}]}]}]}]}]}],null,null],"__layoutFlags":{"layout:/":"s"},"__artifactCompatibility":{"schemaVersion":1,"graphVersion":"app-route-graph:1177fd80f83fa7b0","deploymentVersion":"38181f96-3d96-4263-aa86-5802d2c534fc","appElementsSchemaVersion":1,"rscPayloadSchemaVersion":1,"rootBoundaryId":"/","renderEpoch":null},"__renderObservation":{"schemaVersion":1,"output":{"kind":"app-rsc","mountedSlotsFingerprint":null,"renderEpoch":null,"rootBoundaryId":"/","routeId":"route:/"},"completeness":"partial","boundaryOutcome":{"kind":"unknown"},"requestApis":[{"kind":"connection","status":"unknown"},{"kind":"cookies","status":"unknown"},{"kind":"draftMode","status":"unknown"},{"kind":"headers","status":"unknown"},{"kind":"params","status":"unknown"},{"kind":"searchParams","status":"unknown"}],"dynamicFetches":[],"cacheTags":["/","_N_T_/","_N_T_/index","_N_T_/layout","_N_T_/page"],"pathTags":["/"],"cacheability":"unknown","downgrade":{"target":"freshRender","reasons":[{"code":"CP_DOWNGRADE_CACHEABILITY_UNKNOWN","target":"freshRender"},{"code":"CP_DOWNGRADE_INCOMPLETE_OBSERVATION","completeness":"partial","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"connection","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"cookies","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"draftMode","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"headers","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"params","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"searchParams","target":"freshRender"}],"fallback":{"kind":"breakerFallback","code":"CP_PRIVATE_DYNAMIC_DOWNGRADE","mode":"renderFresh","scope":"affectedOutput","fields":{"reasonCodes":["CP_DOWNGRADE_CACHEABILITY_UNKNOWN","CP_DOWNGRADE_INCOMPLETE_OBSERVATION","CP_DOWNGRADE_UNKNOWN_REQUEST_API","CP_DOWNGRADE_UNKNOWN_REQUEST_API","CP_DOWNGRADE_UNKNOWN_REQUEST_API","CP_DOWNGRADE_UNKNOWN_REQUEST_API","CP_DOWNGRADE_UNKNOWN_REQUEST_API","CP_DOWNGRADE_UNKNOWN_REQUEST_API"],"target":"freshRender"}},"isPublicCacheCandidate":false}}}
|
0:{"__route":"route:/","__interceptionContext":null,"__layoutIds":["layout:/"],"__rootLayout":"/","__sourcePage":"/page","page:/":"$L1","layout:/":[[[["$","link","css:/_next/static/css/index.B4qFKCfT.css",{"rel":"stylesheet","precedence":"vite-rsc/importer-resources","href":"/_next/static/css/index.B4qFKCfT.css","data-rsc-css-href":"/_next/static/css/index.B4qFKCfT.css"}],"$undefined"],["$","html",null,{"lang":"zh-CN","children":["$","body",null,{"children":["$","$L2",null,{}]}]}]],null],"route:/":[[["$","meta",null,{"charSet":"utf-8"}],[["$","title","0",{"children":"豆格工坊|图片转拼豆图纸"}],["$","meta","1",{"name":"description","content":"在浏览器中把图片转换为拼豆像素图纸,按色号、名称或像素格筛选颜色。"}]],[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]],["$","$L3",null,{"fallback":"$4","children":["$","$L5",null,{"fallback":"$4","children":["$","$L6",null,{"providerId":"layout:/","segmentMap":{"children":[]},"children":["$","$L7",null,{"id":"layout:/","parallelSlots":"$undefined","children":["$","$L8",null,{"children":["$","$L9",null,{"children":[["$","$L6",null,{"providerId":"page:/","segmentMap":{"children":["__PAGE__"]},"children":["$","$L7",null,{"id":"page:/"}]}],null]}]}]}]}]}]}],null,null],"__layoutFlags":{"layout:/":"s"},"__artifactCompatibility":{"schemaVersion":1,"graphVersion":"app-route-graph:1177fd80f83fa7b0","deploymentVersion":"9950d562-b31f-4727-8327-2dd57c54da0f","appElementsSchemaVersion":1,"rscPayloadSchemaVersion":1,"rootBoundaryId":"/","renderEpoch":null},"__renderObservation":{"schemaVersion":1,"output":{"kind":"app-rsc","mountedSlotsFingerprint":null,"renderEpoch":null,"rootBoundaryId":"/","routeId":"route:/"},"completeness":"partial","boundaryOutcome":{"kind":"unknown"},"requestApis":[{"kind":"connection","status":"unknown"},{"kind":"cookies","status":"unknown"},{"kind":"draftMode","status":"unknown"},{"kind":"headers","status":"unknown"},{"kind":"params","status":"unknown"},{"kind":"searchParams","status":"unknown"}],"dynamicFetches":[],"cacheTags":["/","_N_T_/","_N_T_/index","_N_T_/layout","_N_T_/page"],"pathTags":["/"],"cacheability":"unknown","downgrade":{"target":"freshRender","reasons":[{"code":"CP_DOWNGRADE_CACHEABILITY_UNKNOWN","target":"freshRender"},{"code":"CP_DOWNGRADE_INCOMPLETE_OBSERVATION","completeness":"partial","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"connection","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"cookies","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"draftMode","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"headers","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"params","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"searchParams","target":"freshRender"}],"fallback":{"kind":"breakerFallback","code":"CP_PRIVATE_DYNAMIC_DOWNGRADE","mode":"renderFresh","scope":"affectedOutput","fields":{"reasonCodes":["CP_DOWNGRADE_CACHEABILITY_UNKNOWN","CP_DOWNGRADE_INCOMPLETE_OBSERVATION","CP_DOWNGRADE_UNKNOWN_REQUEST_API","CP_DOWNGRADE_UNKNOWN_REQUEST_API","CP_DOWNGRADE_UNKNOWN_REQUEST_API","CP_DOWNGRADE_UNKNOWN_REQUEST_API","CP_DOWNGRADE_UNKNOWN_REQUEST_API","CP_DOWNGRADE_UNKNOWN_REQUEST_API"],"target":"freshRender"}},"isPublicCacheCandidate":false}}}
|
||||||
a:I["6efdf509a785",[],"default",1]
|
a:I["6efdf509a785",[],"default",1]
|
||||||
1:["$","$La",null,{"params":"$@b","searchParams":"$@c"}]
|
1:["$","$La",null,{"params":"$@b","searchParams":"$@c"}]
|
||||||
b:{}
|
b:{}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"appBrowserEntry": "_next/static/chunks/index-DZQMDmd9.js"
|
"appBrowserEntry": "_next/static/chunks/index-CRBC3ESD.js"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +1,47 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { access, readFile, readdir } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
const developmentPreviewMeta =
|
|
||||||
/<meta(?=[^>]*\bname=["']codex-preview["'])(?=[^>]*\bcontent=["']development["'])[^>]*>/i;
|
|
||||||
const templateRoot = new URL("../", import.meta.url);
|
|
||||||
const previewRoot = new URL("../app/_sites-preview/", import.meta.url);
|
|
||||||
|
|
||||||
async function render() {
|
async function render() {
|
||||||
const workerUrl = new URL("../dist/server/index.js", import.meta.url);
|
const workerUrl = new URL("../dist/server/index.js", import.meta.url);
|
||||||
workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}`);
|
workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}`);
|
||||||
const { default: worker } = await import(workerUrl.href);
|
const { default: worker } = await import(workerUrl.href);
|
||||||
|
|
||||||
return worker.fetch(
|
return worker.fetch(
|
||||||
new Request("http://localhost/", {
|
new Request("http://localhost/", { headers: { accept: "text/html" } }),
|
||||||
headers: { accept: "text/html" },
|
{ ASSETS: { fetch: async () => new Response("Not found", { status: 404 }) } },
|
||||||
}),
|
{ waitUntil() {}, passThroughOnException() {} },
|
||||||
{
|
|
||||||
ASSETS: {
|
|
||||||
fetch: async () => new Response("Not found", { status: 404 }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
waitUntil() {},
|
|
||||||
passThroughOnException() {},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
test("server-renders the starter loading skeleton", async () => {
|
test("server-renders the pixel bead application shell", async () => {
|
||||||
const response = await render();
|
const response = await render();
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
|
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
|
||||||
|
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
assert.match(html, developmentPreviewMeta);
|
assert.match(html, /<title>豆格工坊|图片转拼豆图纸<\/title>/i);
|
||||||
assert.match(html, /<title>Your site is taking shape<\/title>/i);
|
assert.match(html, /导入现成图纸/);
|
||||||
assert.match(html, /Building your site/);
|
assert.match(html, /MARD 221 基础色卡/);
|
||||||
assert.match(html, /Your site is taking shape/);
|
assert.match(html, /原图与现成图纸都只在当前浏览器中处理/);
|
||||||
assert.match(
|
assert.doesNotMatch(html, /codex-preview|Your site is taking shape/i);
|
||||||
html,
|
|
||||||
/Your first version will appear here automatically when it’s ready\./,
|
|
||||||
);
|
|
||||||
assert.doesNotMatch(html, /Codex/);
|
|
||||||
assert.match(html, /react-loading-skeleton/);
|
|
||||||
assert.match(html, /role="status"/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps the loading skeleton scoped and disposable", async () => {
|
test("keeps MARD pattern import wired to the crop and recognition flow", async () => {
|
||||||
const [preview, css, page, layout, packageJson, files] = await Promise.all([
|
const [page, css, palette] = await Promise.all([
|
||||||
readFile(new URL("SkeletonPreview.tsx", previewRoot), "utf8"),
|
|
||||||
readFile(new URL("preview.css", previewRoot), "utf8"),
|
|
||||||
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
readFile(new URL("../app/mard-palette.ts", import.meta.url), "utf8"),
|
||||||
readdir(previewRoot),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.deepEqual(files.sort(), ["SkeletonPreview.tsx", "preview.css"]);
|
assert.match(page, /function importMardPatternImage\(/);
|
||||||
assert.match(preview, /from "react-loading-skeleton"/);
|
assert.match(page, /recognizedCodes/);
|
||||||
assert.match(preview, /baseColor="#eceae7"/);
|
assert.match(page, /missingCodeAsEmpty/);
|
||||||
assert.match(preview, /highlightColor="#f9f8f6"/);
|
assert.match(page, /没有识别到 MARD 编码的格子视为空白/);
|
||||||
assert.match(preview, /duration=\{2\.8\}/);
|
assert.match(page, /pattern-import-grid-overlay/);
|
||||||
assert.match(preview, /sites-skeleton-search-placeholder/);
|
assert.match(page, /max="256"/);
|
||||||
assert.match(packageJson, /"react-loading-skeleton": "3\.5\.0"/);
|
assert.match(css, /\.pattern-import-grid-overlay/);
|
||||||
|
assert.match(css, /@media \(max-width: 760px\)/);
|
||||||
const shellIndex = preview.indexOf('className="sites-skeleton-shell"');
|
assert.match(palette, /export const MARD_221/);
|
||||||
const statusIndex = preview.indexOf('className="sites-skeleton-status"');
|
assert.match(palette, /A:\s*"#[0-9A-F]{6}/);
|
||||||
assert.ok(shellIndex >= 0 && statusIndex > shellIndex);
|
|
||||||
assert.match(css, /position:\s*fixed/);
|
|
||||||
assert.match(css, /inset:\s*0/);
|
|
||||||
assert.match(css, /opacity:\s*0\.52/);
|
|
||||||
assert.match(css, /prefers-reduced-motion:\s*reduce/);
|
|
||||||
assert.doesNotMatch(css, /#020617|canvas|pets|progress/i);
|
|
||||||
assert.doesNotMatch(
|
|
||||||
preview,
|
|
||||||
/loading-spinner|status-mark|status-progress|canvas|cookie|random/i,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(page, /export const metadata:\s*Metadata/);
|
|
||||||
assert.match(page, /"codex-preview": "development"/);
|
|
||||||
assert.match(page, /<SkeletonPreview \/>/);
|
|
||||||
assert.match(layout, /title:\s*"Starter Project"/);
|
|
||||||
assert.doesNotMatch(layout, /codex-preview|_sites-preview|themeColor|\bViewport\b/);
|
|
||||||
assert.doesNotMatch(css, /(^|\s)(html|body)\s*\{/m);
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
access(new URL("public/_sites-preview", templateRoot)),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user