Compare commits
9
Commits
d53f8f4585
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec68773179 | ||
|
|
d0a1a81ca2 | ||
|
|
39abc86e6e | ||
|
|
7d45dd8073 | ||
|
|
a65536e31e | ||
|
|
785a089518 | ||
|
|
6f101661c7 | ||
|
|
365af027b8 | ||
|
|
4f8d46a523 |
+44
-4
@@ -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; }
|
||||
@@ -128,9 +131,9 @@ input[type="range"] { accent-color: var(--green); }
|
||||
.history-heading p:not(.section-kicker) { margin: 8px 0 0; color: var(--muted); font-size: 12px; }
|
||||
.history-heading .search-box { width: min(330px, 100%); }
|
||||
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; padding-top: 18px; }
|
||||
.history-card { min-width: 0; padding: 16px; display: grid; grid-template-columns: 58px minmax(0,1fr); gap: 12px; border: 1px solid var(--line); background: var(--card); }
|
||||
.history-swatches { width: 58px; height: 58px; display: flex; overflow: hidden; border: 1px solid var(--ink); }
|
||||
.history-swatches i { min-width: 3px; }
|
||||
.history-card { min-width: 0; padding: 16px; display: grid; grid-template-columns: 86px minmax(0,1fr); gap: 13px; border: 1px solid var(--line); background: var(--card); }
|
||||
.history-thumbnail { width: 86px; height: 86px; display: grid; place-items: center; overflow: hidden; border: 1px solid var(--ink); background-color: #f8f6f0; background-image: linear-gradient(45deg, #dedbd3 25%, transparent 25%), linear-gradient(-45deg, #dedbd3 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #dedbd3 75%), linear-gradient(-45deg, transparent 75%, #dedbd3 75%); background-position: 0 0, 0 5px, 5px -5px, -5px 0; background-size: 10px 10px; }
|
||||
.history-thumbnail canvas { display: block; width: auto; height: auto; max-width: 100%; max-height: 100%; image-rendering: pixelated; }
|
||||
.history-card h3 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin: 3px 0 7px; font-size: 14px; }
|
||||
.history-card p { margin: 0; color: var(--muted); font-size: 10px; }
|
||||
.history-actions { grid-column: 1 / -1; display: flex; gap: 7px; justify-content: flex-end; }
|
||||
@@ -145,8 +148,23 @@ input[type="range"] { accent-color: var(--green); }
|
||||
.crop-dialog-header h2 { margin: 0; font-size: 22px; }
|
||||
.crop-dialog-header p:not(.section-kicker) { margin: 6px 0 0; color: var(--muted); font-size: 12px; }
|
||||
.crop-dialog-header > button { width: 38px; height: 38px; flex: 0 0 auto; border: 1px solid var(--line); background: white; cursor: pointer; font-size: 25px; line-height: 1; }
|
||||
.crop-mode-bar { min-height: 54px; padding: 8px 20px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--line); background: #f7f4ec; color: var(--muted); font-size: 11px; }
|
||||
.crop-mode-switch { display: inline-flex; border: 1px solid var(--ink); background: white; }
|
||||
.crop-mode-switch button { min-height: 34px; padding: 0 15px; border: 0; border-right: 1px solid var(--ink); background: transparent; cursor: pointer; font-size: 12px; }
|
||||
.crop-mode-switch button:last-child { border-right: 0; }
|
||||
.crop-mode-switch button.active { background: var(--green); color: white; font-weight: 700; }
|
||||
.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); }
|
||||
.crop-dialog-stage.can-move-selection { cursor: move; }
|
||||
.crop-dialog-stage.is-moving-selection { cursor: grabbing; }
|
||||
.crop-dialog-stage.is-resizing-selection { cursor: nwse-resize; }
|
||||
.crop-dialog-stage canvas { display: block; max-width: 100%; height: auto; pointer-events: none; }
|
||||
.crop-selection-mask { position: absolute; z-index: 1; background: rgba(10,15,13,.55); pointer-events: none; }
|
||||
.crop-selection-top { inset: 0 0 auto; }
|
||||
@@ -156,6 +174,16 @@ 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; }
|
||||
.handle-sw { left: 0; bottom: 0; transform: translate(-50%, 50%); cursor: nesw-resize; }
|
||||
.handle-se { right: 0; bottom: 0; transform: translate(50%, 50%); cursor: nwse-resize; }
|
||||
.handle-n { left: 50%; top: 0; transform: translate(-50%, -50%); cursor: ns-resize; }
|
||||
.handle-s { left: 50%; bottom: 0; transform: translate(-50%, 50%); cursor: ns-resize; }
|
||||
.handle-w { left: 0; top: 50%; transform: translate(-50%, -50%); cursor: ew-resize; }
|
||||
.handle-e { right: 0; top: 50%; transform: translate(50%, -50%); cursor: ew-resize; }
|
||||
.crop-dialog-actions { flex: 0 0 auto; min-height: 66px; padding: 12px 20px; display: flex; align-items: center; justify-content: flex-end; gap: 10px; border-top: 1px solid var(--line); }
|
||||
.crop-dialog-actions span { margin-right: auto; color: var(--muted); font-size: 11px; }
|
||||
.crop-dialog-actions button { min-height: 40px; padding: 0 16px; border: 1px solid var(--ink); background: white; cursor: pointer; font-size: 12px; }
|
||||
@@ -201,7 +229,19 @@ input[type="range"] { accent-color: var(--green); }
|
||||
.crop-dialog { width: 100vw; max-height: 100vh; height: 100vh; border: 0; box-shadow: none; }
|
||||
.crop-dialog-header { padding: 14px 16px; }
|
||||
.crop-dialog-header h2 { font-size: 18px; }
|
||||
.crop-mode-bar { padding: 8px 12px; flex-wrap: wrap; gap: 7px 10px; }
|
||||
.crop-mode-switch { flex: 1; }
|
||||
.crop-mode-switch button { flex: 1; padding: 0 8px; }
|
||||
.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; }
|
||||
|
||||
+746
-31
@@ -35,7 +35,23 @@ type CropRect = {
|
||||
height: number;
|
||||
};
|
||||
|
||||
type CropSelectionMode = "free" | "square";
|
||||
type CropDialogPurpose = "source" | "pattern-import";
|
||||
|
||||
type CropResizeHandle = "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se";
|
||||
|
||||
type CropSelectionDrag = {
|
||||
pointerId: number;
|
||||
mode: "create" | "move" | "resize";
|
||||
startX: number;
|
||||
startY: number;
|
||||
origin: CropRect;
|
||||
handle?: CropResizeHandle;
|
||||
};
|
||||
|
||||
const FULL_CROP: CropRect = { x: 0, y: 0, width: 1, height: 1 };
|
||||
// 现成图纸识别准确率仍需继续优化,暂时保留实现但关闭用户入口。
|
||||
const ENABLE_PATTERN_IMPORT = false;
|
||||
|
||||
function logUsage(event: Record<string, unknown>) {
|
||||
void fetch("/api/usage", {
|
||||
@@ -79,6 +95,17 @@ function createHistoryId() {
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function rowNumberToLetters(rowNumber: number) {
|
||||
let value = Math.max(1, Math.floor(rowNumber));
|
||||
let letters = "";
|
||||
while (value > 0) {
|
||||
value -= 1;
|
||||
letters = String.fromCharCode(65 + (value % 26)) + letters;
|
||||
value = Math.floor(value / 26);
|
||||
}
|
||||
return letters;
|
||||
}
|
||||
|
||||
function openHistoryDatabase() {
|
||||
return new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(HISTORY_DB_NAME, 1);
|
||||
@@ -160,6 +187,7 @@ const PALETTE: BeadColor[] = MARD_221.map(([code, hex]) => {
|
||||
const rgb: [number, number, number] = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
|
||||
return { code, name: `MARD ${MARD_SERIES_NAMES[code[0]]}`, hex, rgb, lab: rgbToOklab(rgb) };
|
||||
});
|
||||
const PALETTE_BY_CODE = new Map(PALETTE.map((color) => [color.code, color]));
|
||||
|
||||
const hexToRgb = (hex: string): [number, number, number] => [
|
||||
parseInt(hex.slice(1, 3), 16),
|
||||
@@ -167,6 +195,29 @@ const hexToRgb = (hex: string): [number, number, number] => [
|
||||
parseInt(hex.slice(5, 7), 16),
|
||||
];
|
||||
|
||||
function HistoryThumbnail({ entry }: { entry: SavedPattern }) {
|
||||
const thumbnailRef = useRef<HTMLCanvasElement | null>(null);
|
||||
useEffect(() => {
|
||||
const canvas = thumbnailRef.current;
|
||||
if (!canvas) return;
|
||||
canvas.width = Math.max(1, entry.width);
|
||||
canvas.height = Math.max(1, entry.height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
entry.codes.forEach((code, index) => {
|
||||
if (!code) return;
|
||||
const color = PALETTE_BY_CODE.get(code);
|
||||
if (!color) return;
|
||||
ctx.fillStyle = color.hex;
|
||||
ctx.fillRect(index % entry.width, Math.floor(index / entry.width), 1, 1);
|
||||
});
|
||||
}, [entry]);
|
||||
return <div className="history-thumbnail" aria-label={`${entry.name} 的完整格子图缩略图`}>
|
||||
<canvas ref={thumbnailRef} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function nearestColor(lab: Oklab, palette: BeadColor[]) {
|
||||
let closest = palette[0];
|
||||
let smallest = Number.POSITIVE_INFINITY;
|
||||
@@ -180,6 +231,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 }>();
|
||||
@@ -628,9 +904,29 @@ function drawFittedImage(
|
||||
const sourceHeight = crop.height * image.naturalHeight;
|
||||
const imageRatio = sourceWidth / sourceHeight;
|
||||
const boxRatio = width / height;
|
||||
const sourceCenterX = sourceX + sourceWidth / 2;
|
||||
const sourceCenterY = sourceY + sourceHeight / 2;
|
||||
if (fitMode === "cover") {
|
||||
let centeredSourceWidth = sourceWidth;
|
||||
let centeredSourceHeight = sourceHeight;
|
||||
if (imageRatio > boxRatio) centeredSourceWidth = sourceHeight * boxRatio;
|
||||
else centeredSourceHeight = sourceWidth / boxRatio;
|
||||
ctx.drawImage(
|
||||
image,
|
||||
sourceCenterX - centeredSourceWidth / 2,
|
||||
sourceCenterY - centeredSourceHeight / 2,
|
||||
centeredSourceWidth,
|
||||
centeredSourceHeight,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let drawWidth = width;
|
||||
let drawHeight = height;
|
||||
if ((fitMode === "cover" && imageRatio > boxRatio) || (fitMode === "contain" && imageRatio < boxRatio)) {
|
||||
if (imageRatio < boxRatio) {
|
||||
drawHeight = height;
|
||||
drawWidth = height * imageRatio;
|
||||
} else {
|
||||
@@ -639,6 +935,9 @@ function drawFittedImage(
|
||||
}
|
||||
const drawX = (width - drawWidth) / 2;
|
||||
const drawY = (height - drawHeight) / 2;
|
||||
// The selected area's center is always the grid's center. With odd grid
|
||||
// sizes it lands on the middle cell; with even sizes it lands on the
|
||||
// intersection of the four middle cells.
|
||||
ctx.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, drawX, drawY, drawWidth, drawHeight);
|
||||
}
|
||||
|
||||
@@ -682,7 +981,15 @@ export default function Home() {
|
||||
const [samplingStrategy, setSamplingStrategy] = useState<SamplingStrategy>("dominant");
|
||||
const [cropRect, setCropRect] = useState<CropRect>(FULL_CROP);
|
||||
const [draftCropRect, setDraftCropRect] = useState<CropRect>(FULL_CROP);
|
||||
const [cropSelectionMode, setCropSelectionMode] = useState<CropSelectionMode>("free");
|
||||
const [cropDialogOpen, setCropDialogOpen] = useState(false);
|
||||
const [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);
|
||||
@@ -696,12 +1003,22 @@ 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);
|
||||
const patternStageRef = useRef<HTMLDivElement | null>(null);
|
||||
const zoomRef = useRef(zoom);
|
||||
const pendingWheelZoomRef = useRef<{
|
||||
stage: HTMLDivElement;
|
||||
cursorX: number;
|
||||
cursorY: number;
|
||||
canvasX: number;
|
||||
canvasY: number;
|
||||
} | null>(null);
|
||||
const cropDialogStageRef = useRef<HTMLDivElement | null>(null);
|
||||
const cropSelectionDragRef = useRef<{ pointerId: number; startX: number; startY: number } | null>(null);
|
||||
const cropSelectionDragRef = useRef<CropSelectionDrag | null>(null);
|
||||
const patternDragRef = useRef<{
|
||||
pointerId: number;
|
||||
x: number;
|
||||
@@ -834,7 +1151,9 @@ export default function Home() {
|
||||
useEffect(() => {
|
||||
if (!cropDialogOpen) return;
|
||||
const canvas = cropDialogCanvasRef.current;
|
||||
const image = sourceImageRef.current;
|
||||
const image = cropDialogPurpose === "pattern-import"
|
||||
? patternImportImageRef.current
|
||||
: sourceImageRef.current;
|
||||
if (!canvas || !image) return;
|
||||
const maximumWidth = 1200;
|
||||
const maximumHeight = 760;
|
||||
@@ -844,7 +1163,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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cropDialogOpen) return;
|
||||
@@ -959,6 +1278,52 @@ export default function Home() {
|
||||
}
|
||||
}, [pixels, gridWidth, gridHeight, zoom, selectedCodes, onlySelected, showGrid, showCodes]);
|
||||
|
||||
useEffect(() => {
|
||||
zoomRef.current = zoom;
|
||||
const pending = pendingWheelZoomRef.current;
|
||||
if (!pending) return;
|
||||
pendingWheelZoomRef.current = null;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
const { stage } = pending;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const stageRect = stage.getBoundingClientRect();
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
const anchorX = canvasRect.left + canvasRect.width * pending.canvasX;
|
||||
const anchorY = canvasRect.top + canvasRect.height * pending.canvasY;
|
||||
stage.scrollLeft += anchorX - (stageRect.left + pending.cursorX);
|
||||
stage.scrollTop += anchorY - (stageRect.top + pending.cursorY);
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [zoom]);
|
||||
|
||||
useEffect(() => {
|
||||
const stage = patternStageRef.current;
|
||||
if (!stage) return;
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (pixels.length === 0 || event.deltaY === 0) return;
|
||||
event.preventDefault();
|
||||
const currentZoom = zoomRef.current;
|
||||
const nextZoom = Math.max(3, Math.min(42, currentZoom + (event.deltaY < 0 ? 1 : -1)));
|
||||
if (nextZoom === currentZoom) return;
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
||||
const cursorX = event.clientX - rect.left;
|
||||
const cursorY = event.clientY - rect.top;
|
||||
pendingWheelZoomRef.current = {
|
||||
stage,
|
||||
cursorX,
|
||||
cursorY,
|
||||
canvasX: canvasRect ? (event.clientX - canvasRect.left) / Math.max(1, canvasRect.width) : 0.5,
|
||||
canvasY: canvasRect ? (event.clientY - canvasRect.top) / Math.max(1, canvasRect.height) : 0.5,
|
||||
};
|
||||
zoomRef.current = nextZoom;
|
||||
setZoom(nextZoom);
|
||||
};
|
||||
stage.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => stage.removeEventListener("wheel", handleWheel);
|
||||
}, [pixels.length]);
|
||||
|
||||
const handleUpload = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -981,7 +1346,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);
|
||||
};
|
||||
@@ -997,57 +1393,216 @@ export default function Home() {
|
||||
};
|
||||
|
||||
const updateDraftCrop = (startX: number, startY: number, endX: number, endY: number) => {
|
||||
const targetRatio = Math.max(8, requestedWidth || 8) / Math.max(8, requestedHeight || 8);
|
||||
const image = sourceImageRef.current;
|
||||
if (!image) return;
|
||||
const normalizedRatio = targetRatio * image.naturalHeight / image.naturalWidth;
|
||||
const directionX = endX >= startX ? 1 : -1;
|
||||
const directionY = endY >= startY ? 1 : -1;
|
||||
let width = Math.abs(endX - startX);
|
||||
let height = Math.abs(endY - startY);
|
||||
if (width / Math.max(height, 0.0001) > normalizedRatio) width = height * normalizedRatio;
|
||||
else height = width / Math.max(normalizedRatio, 0.0001);
|
||||
if (cropSelectionMode === "square") {
|
||||
const image = cropDialogPurpose === "pattern-import"
|
||||
? patternImportImageRef.current
|
||||
: sourceImageRef.current;
|
||||
if (!image) return;
|
||||
const normalizedSquareRatio = image.naturalHeight / image.naturalWidth;
|
||||
if (width / Math.max(height, 0.0001) > normalizedSquareRatio) width = height * normalizedSquareRatio;
|
||||
else height = width / Math.max(normalizedSquareRatio, 0.0001);
|
||||
const availableWidth = directionX > 0 ? 1 - startX : startX;
|
||||
const availableHeight = directionY > 0 ? 1 - startY : startY;
|
||||
const scale = Math.min(1, availableWidth / Math.max(width, 0.0001), availableHeight / Math.max(height, 0.0001));
|
||||
width *= scale;
|
||||
height *= scale;
|
||||
}
|
||||
width = Math.max(0.01, Math.min(1, width));
|
||||
height = Math.max(0.01, Math.min(1, height));
|
||||
setDraftCropRect({
|
||||
x: directionX > 0 ? startX : startX - width,
|
||||
y: directionY > 0 ? startY : startY - height,
|
||||
width: Math.max(0.01, width),
|
||||
height: Math.max(0.01, height),
|
||||
x: Math.max(0, Math.min(1 - width, directionX > 0 ? startX : startX - width)),
|
||||
y: Math.max(0, Math.min(1 - height, directionY > 0 ? startY : startY - height)),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
};
|
||||
|
||||
const pointInsideCrop = (point: { x: number; y: number }) => (
|
||||
(draftCropRect.width < 0.995 || draftCropRect.height < 0.995)
|
||||
&&
|
||||
point.x >= draftCropRect.x
|
||||
&& point.x <= draftCropRect.x + draftCropRect.width
|
||||
&& point.y >= draftCropRect.y
|
||||
&& point.y <= draftCropRect.y + draftCropRect.height
|
||||
);
|
||||
|
||||
const chooseCropSelectionMode = (mode: CropSelectionMode) => {
|
||||
setCropSelectionMode(mode);
|
||||
if (mode !== "square") return;
|
||||
const image = cropDialogPurpose === "pattern-import"
|
||||
? patternImportImageRef.current
|
||||
: sourceImageRef.current;
|
||||
if (!image) return;
|
||||
const currentCenterX = draftCropRect.x + draftCropRect.width / 2;
|
||||
const currentCenterY = draftCropRect.y + draftCropRect.height / 2;
|
||||
const sideInPixels = Math.min(
|
||||
draftCropRect.width * image.naturalWidth,
|
||||
draftCropRect.height * image.naturalHeight,
|
||||
);
|
||||
const width = sideInPixels / image.naturalWidth;
|
||||
const height = sideInPixels / image.naturalHeight;
|
||||
setDraftCropRect({
|
||||
x: Math.max(0, Math.min(1 - width, currentCenterX - width / 2)),
|
||||
y: Math.max(0, Math.min(1 - height, currentCenterY - height / 2)),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
};
|
||||
|
||||
const resizeDraftCrop = (drag: CropSelectionDrag, point: { x: number; y: number }) => {
|
||||
const handle = drag.handle;
|
||||
if (!handle) return;
|
||||
const origin = drag.origin;
|
||||
if (cropSelectionMode === "square") {
|
||||
const anchorX = handle.includes("w") ? origin.x + origin.width : origin.x;
|
||||
const anchorY = handle.includes("n") ? origin.y + origin.height : origin.y;
|
||||
updateDraftCrop(anchorX, anchorY, point.x, point.y);
|
||||
return;
|
||||
}
|
||||
const minimumSize = 0.03;
|
||||
let left = origin.x;
|
||||
let top = origin.y;
|
||||
let right = origin.x + origin.width;
|
||||
let bottom = origin.y + origin.height;
|
||||
if (handle.includes("w")) left = Math.max(0, Math.min(right - minimumSize, point.x));
|
||||
if (handle.includes("e")) right = Math.min(1, Math.max(left + minimumSize, point.x));
|
||||
if (handle.includes("n")) top = Math.max(0, Math.min(bottom - minimumSize, point.y));
|
||||
if (handle.includes("s")) bottom = Math.min(1, Math.max(top + minimumSize, point.y));
|
||||
setDraftCropRect({ x: left, y: top, width: right - left, height: bottom - top });
|
||||
};
|
||||
|
||||
const handleCropSelectionDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
const point = cropPoint(event);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
cropSelectionDragRef.current = { pointerId: event.pointerId, startX: point.x, startY: point.y };
|
||||
setDraftCropRect({ x: point.x, y: point.y, width: 0.01, height: 0.01 });
|
||||
const resizeHandle = (event.target as HTMLElement).dataset.cropHandle as CropResizeHandle | undefined;
|
||||
if (resizeHandle) {
|
||||
cropSelectionDragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
mode: "resize",
|
||||
startX: point.x,
|
||||
startY: point.y,
|
||||
origin: draftCropRect,
|
||||
handle: resizeHandle,
|
||||
};
|
||||
event.currentTarget.classList.add("is-resizing-selection");
|
||||
return;
|
||||
}
|
||||
if (pointInsideCrop(point) && draftCropRect.width >= 0.03 && draftCropRect.height >= 0.03) {
|
||||
cropSelectionDragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
mode: "move",
|
||||
startX: point.x,
|
||||
startY: point.y,
|
||||
origin: draftCropRect,
|
||||
};
|
||||
event.currentTarget.classList.add("is-moving-selection");
|
||||
return;
|
||||
}
|
||||
cropSelectionDragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
mode: "create",
|
||||
startX: point.x,
|
||||
startY: point.y,
|
||||
origin: draftCropRect,
|
||||
};
|
||||
setDraftCropRect({ x: Math.min(0.99, point.x), y: Math.min(0.99, point.y), width: 0.01, height: 0.01 });
|
||||
};
|
||||
|
||||
const handleCropSelectionMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = cropSelectionDragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
const point = cropPoint(event);
|
||||
if (!drag) {
|
||||
event.currentTarget.classList.toggle("can-move-selection", pointInsideCrop(point));
|
||||
return;
|
||||
}
|
||||
if (drag.pointerId !== event.pointerId) return;
|
||||
if (drag.mode === "resize") {
|
||||
resizeDraftCrop(drag, point);
|
||||
return;
|
||||
}
|
||||
if (drag.mode === "move") {
|
||||
const x = Math.max(0, Math.min(1 - drag.origin.width, drag.origin.x + point.x - drag.startX));
|
||||
const y = Math.max(0, Math.min(1 - drag.origin.height, drag.origin.y + point.y - drag.startY));
|
||||
setDraftCropRect({ ...drag.origin, x, y });
|
||||
return;
|
||||
}
|
||||
updateDraftCrop(drag.startX, drag.startY, point.x, point.y);
|
||||
};
|
||||
|
||||
const handleCropSelectionEnd = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (cropSelectionDragRef.current?.pointerId === event.pointerId) cropSelectionDragRef.current = null;
|
||||
event.currentTarget.classList.remove("is-moving-selection");
|
||||
event.currentTarget.classList.remove("is-resizing-selection");
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
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);
|
||||
setStatus("取景区域已更新,点击“重新转换”生成图纸");
|
||||
convertImage(sourceImageRef.current, draftCropRect);
|
||||
};
|
||||
|
||||
const toggleColor = (code: string) => {
|
||||
@@ -1202,12 +1757,138 @@ export default function Home() {
|
||||
}, [history, historyQuery]);
|
||||
|
||||
const exportPng = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
if (!pixels.length) return;
|
||||
const longestSide = Math.max(gridWidth, gridHeight);
|
||||
const maximumExportSide = 8192;
|
||||
const preferredCell = longestSide <= 120 ? 56 : longestSide <= 180 ? 40 : 32;
|
||||
const cell = Math.max(24, Math.min(preferredCell, Math.floor((maximumExportSide - 72) / longestSide)));
|
||||
const ruler = Math.max(48, cell + 16);
|
||||
const gridPixelWidth = gridWidth * cell;
|
||||
const gridPixelHeight = gridHeight * cell;
|
||||
const canvasWidth = ruler + gridPixelWidth;
|
||||
const legendPadding = 18;
|
||||
const legendItemWidth = 128;
|
||||
const legendItemHeight = 42;
|
||||
const legendColumns = Math.max(1, Math.floor((canvasWidth - legendPadding * 2) / legendItemWidth));
|
||||
const legendRows = Math.ceil(usedColors.length / legendColumns);
|
||||
const legendHeight = usedColors.length > 0 ? legendPadding * 2 + legendRows * legendItemHeight : 0;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvasWidth;
|
||||
canvas.height = ruler + gridPixelHeight + legendHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = "#f0eee7";
|
||||
ctx.fillRect(ruler, 0, gridPixelWidth, ruler);
|
||||
ctx.fillRect(0, ruler, ruler, gridPixelHeight);
|
||||
ctx.strokeStyle = "rgba(32,38,36,.34)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.font = `700 ${Math.max(9, Math.floor(cell * 0.32))}px Arial`;
|
||||
ctx.fillStyle = "#45504b";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
for (let column = 0; column < gridWidth; column += 1) {
|
||||
const x = ruler + column * cell;
|
||||
ctx.strokeRect(x + 0.5, 0.5, cell - 1, ruler - 1);
|
||||
ctx.fillText(String(column + 1), x + cell / 2, ruler / 2);
|
||||
}
|
||||
for (let row = 0; row < gridHeight; row += 1) {
|
||||
const y = ruler + row * cell;
|
||||
ctx.strokeRect(0.5, y + 0.5, ruler - 1, cell - 1);
|
||||
ctx.fillText(rowNumberToLetters(row + 1), ruler / 2, y + cell / 2);
|
||||
}
|
||||
|
||||
pixels.forEach(({ color }, index) => {
|
||||
const column = index % gridWidth;
|
||||
const row = Math.floor(index / gridWidth);
|
||||
const x = ruler + column * cell;
|
||||
const y = ruler + row * cell;
|
||||
if (color) {
|
||||
ctx.fillStyle = color.hex;
|
||||
ctx.fillRect(x, y, cell, cell);
|
||||
const label = color.code;
|
||||
const [red, green, blue] = hexToRgb(color.hex);
|
||||
ctx.fillStyle = red * 0.299 + green * 0.587 + blue * 0.114 > 160 ? "#18211d" : "#ffffff";
|
||||
let fontSize = Math.max(8, Math.floor(cell * 0.31));
|
||||
ctx.font = `700 ${fontSize}px Arial`;
|
||||
while (fontSize > 8 && ctx.measureText(label).width > cell - 5) {
|
||||
fontSize -= 1;
|
||||
ctx.font = `700 ${fontSize}px Arial`;
|
||||
}
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(label, x + cell / 2, y + cell / 2);
|
||||
}
|
||||
ctx.strokeStyle = "rgba(32,38,36,.24)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x + 0.5, y + 0.5, cell - 1, cell - 1);
|
||||
});
|
||||
|
||||
ctx.strokeStyle = "rgba(32,38,36,.58)";
|
||||
ctx.lineWidth = 2;
|
||||
for (let column = 5; column < gridWidth; column += 5) {
|
||||
const x = ruler + column * cell;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, ruler);
|
||||
ctx.lineTo(x, ruler + gridPixelHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let row = 5; row < gridHeight; row += 5) {
|
||||
const y = ruler + row * cell;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ruler, y);
|
||||
ctx.lineTo(ruler + gridPixelWidth, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.strokeStyle = "#202624";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(ruler, ruler, gridPixelWidth, gridPixelHeight);
|
||||
|
||||
const legendTop = ruler + gridPixelHeight;
|
||||
if (usedColors.length > 0) {
|
||||
ctx.strokeStyle = "#d7d2c7";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, legendTop + 0.5);
|
||||
ctx.lineTo(canvasWidth, legendTop + 0.5);
|
||||
ctx.stroke();
|
||||
usedColors.forEach((color, index) => {
|
||||
const column = index % legendColumns;
|
||||
const row = Math.floor(index / legendColumns);
|
||||
const x = legendPadding + column * legendItemWidth;
|
||||
const y = legendTop + legendPadding + row * legendItemHeight;
|
||||
ctx.fillStyle = color.hex;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + 12, y + 13, 10, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "rgba(32,38,36,.28)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = "#202624";
|
||||
ctx.font = "700 13px Arial";
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(`${color.code} · ${color.count}颗`, x + 28, y + 13);
|
||||
});
|
||||
}
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
setStatus("PNG 生成失败,请减少图纸格数后重试");
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.download = `拼豆图纸-${gridWidth}x${gridHeight}.png`;
|
||||
link.href = canvas.toDataURL("image/png");
|
||||
link.href = url;
|
||||
link.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
setStatus(`高清 PNG 已生成:${canvas.width} × ${canvas.height}px,底部图例包含色号和用量`);
|
||||
logUsage({ event: "download_png", width: gridWidth, height: gridHeight, colors: usedColors.length, export_width: canvas.width, export_height: canvas.height });
|
||||
}, "image/png");
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1245,6 +1926,10 @@ 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>
|
||||
{ENABLE_PATTERN_IMPORT && <>
|
||||
<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>
|
||||
@@ -1274,6 +1959,7 @@ export default function Home() {
|
||||
<div
|
||||
className="canvas-stage"
|
||||
ref={patternStageRef}
|
||||
title="鼠标位于预览区时,可滚动滚轮缩放图纸"
|
||||
>
|
||||
<div className="canvas-wrap">
|
||||
<canvas
|
||||
@@ -1304,7 +1990,7 @@ export default function Home() {
|
||||
<label><input type="checkbox" checked={showGrid} onChange={(e) => setShowGrid(e.target.checked)} /> 网格</label>
|
||||
<label><input type="checkbox" checked={showCodes} onChange={(e) => setShowCodes(e.target.checked)} /> 格内编号</label>
|
||||
<button className="save-button" onClick={savePattern}>保存历史</button>
|
||||
<button onClick={exportPng}>下载 PNG</button>
|
||||
<button onClick={exportPng}>下载高清 PNG</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pattern-legend">
|
||||
@@ -1341,9 +2027,28 @@ export default function Home() {
|
||||
{cropDialogOpen && <div className="crop-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setCropDialogOpen(false); }}>
|
||||
<section className="crop-dialog" role="dialog" aria-modal="true" aria-labelledby="crop-dialog-title">
|
||||
<div className="crop-dialog-header">
|
||||
<div><p className="section-kicker">IMAGE CROP</p><h2 id="crop-dialog-title">框选需要转换的区域</h2><p>在大图上按住并拖动。选框比例为 {Math.max(8, requestedWidth || 8)} : {Math.max(8, requestedHeight || 8)}。</p></div>
|
||||
{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">
|
||||
{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">
|
||||
<div
|
||||
className="crop-dialog-stage"
|
||||
@@ -1352,20 +2057,33 @@ export default function Home() {
|
||||
onPointerMove={handleCropSelectionMove}
|
||||
onPointerUp={handleCropSelectionEnd}
|
||||
onPointerCancel={handleCropSelectionEnd}
|
||||
onPointerLeave={(event) => { if (!cropSelectionDragRef.current) event.currentTarget.classList.remove("can-move-selection"); }}
|
||||
>
|
||||
<canvas ref={cropDialogCanvasRef} aria-label="可框选的原始大图" />
|
||||
<div className="crop-selection-mask crop-selection-top" style={{ height: `${draftCropRect.y * 100}%` }} />
|
||||
<div className="crop-selection-mask crop-selection-left" style={{ top: `${draftCropRect.y * 100}%`, width: `${draftCropRect.x * 100}%`, height: `${draftCropRect.height * 100}%` }} />
|
||||
<div className="crop-selection-mask crop-selection-right" style={{ top: `${draftCropRect.y * 100}%`, left: `${(draftCropRect.x + draftCropRect.width) * 100}%`, right: 0, height: `${draftCropRect.height * 100}%` }} />
|
||||
<div className="crop-selection-mask crop-selection-bottom" style={{ top: `${(draftCropRect.y + draftCropRect.height) * 100}%` }} />
|
||||
<div className="crop-selection-box" style={{ left: `${draftCropRect.x * 100}%`, top: `${draftCropRect.y * 100}%`, width: `${draftCropRect.width * 100}%`, height: `${draftCropRect.height * 100}%` }} />
|
||||
<div className="crop-selection-box" style={{ left: `${draftCropRect.x * 100}%`, top: `${draftCropRect.y * 100}%`, width: `${draftCropRect.width * 100}%`, height: `${draftCropRect.height * 100}%` }}>
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="crop-dialog-actions">
|
||||
<button onClick={() => setDraftCropRect(FULL_CROP)}>选择整张图</button>
|
||||
<span>从左上角拖向右下角即可重新框选</span>
|
||||
<button onClick={() => { setCropSelectionMode("free"); setDraftCropRect(FULL_CROP); }}>选择整张图</button>
|
||||
<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>}
|
||||
@@ -1375,11 +2093,8 @@ export default function Home() {
|
||||
<div className="search-box"><span>⌕</span><input value={historyQuery} onChange={(e) => setHistoryQuery(e.target.value)} placeholder="按名称或 32x32 查询" /></div>
|
||||
</div>
|
||||
{filteredHistory.length ? <div className="history-grid">{filteredHistory.map((entry) => {
|
||||
const colorCounts = new Map<string, number>();
|
||||
entry.codes.forEach((code) => { if (code) colorCounts.set(code, (colorCounts.get(code) ?? 0) + 1); });
|
||||
const topCodes = [...colorCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6);
|
||||
return <article className="history-card" key={entry.id}>
|
||||
<div className="history-swatches">{topCodes.map(([code, count]) => <i key={code} style={{ backgroundColor: PALETTE.find((color) => color.code === code)?.hex, flexGrow: count }} />)}</div>
|
||||
<HistoryThumbnail entry={entry} />
|
||||
<div><h3>{entry.name}</h3><p>{entry.width} × {entry.height} · {new Date(entry.savedAt).toLocaleString("zh-CN", { hour12: false })}</p></div>
|
||||
<div className="history-actions"><button onClick={() => restorePattern(entry)}>打开</button><button onClick={() => removePattern(entry.id)}>删除</button></div>
|
||||
</article>;
|
||||
|
||||
+12
-2
@@ -1,4 +1,5 @@
|
||||
import { appendFile, createReadStream, existsSync, mkdirSync, statSync } from "node:fs";
|
||||
import { createReadStream, existsSync, mkdirSync, statSync } from "node:fs";
|
||||
import { appendFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { extname, join, normalize } from "node:path";
|
||||
@@ -9,6 +10,13 @@ const port = Number(process.env.APP_PORT || 3200);
|
||||
const dataDirectory = process.env.DATA_DIR || join(process.cwd(), "data");
|
||||
const usageLogPath = join(dataDirectory, "usage.jsonl");
|
||||
mkdirSync(dataDirectory, { recursive: true });
|
||||
|
||||
function formatChinaTimestamp(date) {
|
||||
return new Date(date.getTime() + 8 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.replace("Z", "+08:00");
|
||||
}
|
||||
|
||||
const mimeTypes = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
@@ -40,8 +48,10 @@ function writeUsageLog(request, response) {
|
||||
sendJson(response, 400, { error: "Invalid event" });
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
const entry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp: now.toISOString(),
|
||||
timestamp_cn: formatChinaTimestamp(now),
|
||||
event: input.event,
|
||||
client_hash: request.headers["x-forwarded-for"] || request.socket.remoteAddress
|
||||
? createHash("sha256").update(String(request.headers["x-forwarded-for"] || request.socket.remoteAddress)).digest("hex").slice(0, 16)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"name": "rolldown-runtime"
|
||||
},
|
||||
"app/page.tsx": {
|
||||
"file": "_next/static/chunks/page-CNQA87Lx.js",
|
||||
"file": "_next/static/chunks/page-DBRuBfy6.js",
|
||||
"name": "page",
|
||||
"src": "app/page.tsx",
|
||||
"isDynamicEntry": true,
|
||||
@@ -21,7 +21,7 @@
|
||||
]
|
||||
},
|
||||
"node_modules/vinext/dist/shims/layout-segment-context.js": {
|
||||
"file": "_next/static/chunks/layout-segment-context-Bf1cBuVr.js",
|
||||
"file": "_next/static/chunks/layout-segment-context-rAphBSzO.js",
|
||||
"name": "layout-segment-context",
|
||||
"src": "node_modules/vinext/dist/shims/layout-segment-context.js",
|
||||
"isDynamicEntry": true,
|
||||
@@ -32,7 +32,7 @@
|
||||
]
|
||||
},
|
||||
"virtual:vinext-app-browser-entry": {
|
||||
"file": "_next/static/chunks/index-DAoL2pdt.js",
|
||||
"file": "_next/static/chunks/index-VFVBI02U.js",
|
||||
"name": "index",
|
||||
"src": "virtual:vinext-app-browser-entry",
|
||||
"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.BD45GRB0.css" data-rsc-css-href="/_next/static/css/index.BD45GRB0.css" data-precedence="vite-rsc/importer-resources"/><link rel="modulepreload" fetchPriority="low" href="/_next/static/chunks/index-DAoL2pdt.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-DAoL2pdt.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-DAoL2pdt.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.BD45GRB0.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.BD45GRB0.css\",{\"rel\":\"stylesheet\",\"precedence\":\"vite-rsc/importer-resources\",\"href\":\"/_next/static/css/index.BD45GRB0.css\",\"data-rsc-css-href\":\"/_next/static/css/index.BD45GRB0.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>
|
||||
<!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-VFVBI02U.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-VFVBI02U.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-VFVBI02U.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-DAoL2pdt.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-VFVBI02U.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]
|
||||
8:I["9276801271d6",[],"AppRouterScrollTarget",1]
|
||||
9:I["593f344dc510",[],"RedirectBoundary",1]
|
||||
:HL["/_next/static/css/index.BD45GRB0.css","style" ]
|
||||
0:{"__route":"route:/","__interceptionContext":null,"__layoutIds":["layout:/"],"__rootLayout":"/","__sourcePage":"/page","page:/":"$L1","layout:/":[[[["$","link","css:/_next/static/css/index.BD45GRB0.css",{"rel":"stylesheet","precedence":"vite-rsc/importer-resources","href":"/_next/static/css/index.BD45GRB0.css","data-rsc-css-href":"/_next/static/css/index.BD45GRB0.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":"976d8e63-a1e8-4c46-b57a-876eef7ec109","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}}}
|
||||
: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.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":"feb6bb1f-6465-425f-8535-1fdc32d89efe","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]
|
||||
1:["$","$La",null,{"params":"$@b","searchParams":"$@c"}]
|
||||
b:{}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"appBrowserEntry": "_next/static/chunks/index-DAoL2pdt.js"
|
||||
"appBrowserEntry": "_next/static/chunks/index-VFVBI02U.js"
|
||||
}
|
||||
|
||||
@@ -1,91 +1,51 @@
|
||||
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";
|
||||
|
||||
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() {
|
||||
const workerUrl = new URL("../dist/server/index.js", import.meta.url);
|
||||
workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}`);
|
||||
const { default: worker } = await import(workerUrl.href);
|
||||
|
||||
return worker.fetch(
|
||||
new Request("http://localhost/", {
|
||||
headers: { accept: "text/html" },
|
||||
}),
|
||||
{
|
||||
ASSETS: {
|
||||
fetch: async () => new Response("Not found", { status: 404 }),
|
||||
},
|
||||
},
|
||||
{
|
||||
waitUntil() {},
|
||||
passThroughOnException() {},
|
||||
},
|
||||
new Request("http://localhost/", { headers: { accept: "text/html" } }),
|
||||
{ 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();
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
|
||||
|
||||
const html = await response.text();
|
||||
assert.match(html, developmentPreviewMeta);
|
||||
assert.match(html, /<title>Your site is taking shape<\/title>/i);
|
||||
assert.match(html, /Building your site/);
|
||||
assert.match(html, /Your site is taking shape/);
|
||||
assert.match(
|
||||
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"/);
|
||||
assert.match(html, /<title>豆格工坊|图片转拼豆图纸<\/title>/i);
|
||||
assert.doesNotMatch(html, /导入现成图纸/);
|
||||
assert.match(html, /MARD 221 基础色卡/);
|
||||
assert.match(html, /图片只在当前浏览器中处理/);
|
||||
assert.doesNotMatch(html, /codex-preview|Your site is taking shape/i);
|
||||
});
|
||||
|
||||
test("keeps the loading skeleton scoped and disposable", async () => {
|
||||
const [preview, css, page, layout, packageJson, files] = await Promise.all([
|
||||
readFile(new URL("SkeletonPreview.tsx", previewRoot), "utf8"),
|
||||
readFile(new URL("preview.css", previewRoot), "utf8"),
|
||||
test("keeps MARD pattern import wired to the crop and recognition flow", async () => {
|
||||
const [page, css, palette] = await Promise.all([
|
||||
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||
readdir(previewRoot),
|
||||
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/mard-palette.ts", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.deepEqual(files.sort(), ["SkeletonPreview.tsx", "preview.css"]);
|
||||
assert.match(preview, /from "react-loading-skeleton"/);
|
||||
assert.match(preview, /baseColor="#eceae7"/);
|
||||
assert.match(preview, /highlightColor="#f9f8f6"/);
|
||||
assert.match(preview, /duration=\{2\.8\}/);
|
||||
assert.match(preview, /sites-skeleton-search-placeholder/);
|
||||
assert.match(packageJson, /"react-loading-skeleton": "3\.5\.0"/);
|
||||
|
||||
const shellIndex = preview.indexOf('className="sites-skeleton-shell"');
|
||||
const statusIndex = preview.indexOf('className="sites-skeleton-status"');
|
||||
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)),
|
||||
);
|
||||
assert.match(page, /function importMardPatternImage\(/);
|
||||
assert.match(page, /const ENABLE_PATTERN_IMPORT = false/);
|
||||
assert.match(page, /recognizedCodes/);
|
||||
assert.match(page, /missingCodeAsEmpty/);
|
||||
assert.match(page, /没有识别到 MARD 编码的格子视为空白/);
|
||||
assert.match(page, /pattern-import-grid-overlay/);
|
||||
assert.match(page, /max="256"/);
|
||||
assert.match(page, /maximumExportSide = 8192/);
|
||||
assert.match(page, /color\.code.*color\.count.*颗/);
|
||||
assert.match(page, /下载高清 PNG/);
|
||||
assert.match(css, /\.pattern-import-grid-overlay/);
|
||||
assert.match(css, /@media \(max-width: 760px\)/);
|
||||
assert.match(palette, /export const MARD_221/);
|
||||
assert.match(palette, /A:\s*"#[0-9A-F]{6}/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user