Compare commits

..
9 Commits
17 changed files with 855 additions and 130 deletions
+44 -4
View File
@@ -52,7 +52,10 @@ button { color: inherit; }
.upload-overlay { position: absolute; z-index: 3; right: 10px; bottom: 10px; padding: 7px 10px; border: 0; color: white; background: rgba(22, 75, 57, .9); cursor: pointer; font-size: 12px; } .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; }
@@ -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 p:not(.section-kicker) { margin: 8px 0 0; color: var(--muted); font-size: 12px; }
.history-heading .search-box { width: min(330px, 100%); } .history-heading .search-box { width: min(330px, 100%); }
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; padding-top: 18px; } .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-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-swatches { width: 58px; height: 58px; display: flex; overflow: hidden; border: 1px solid var(--ink); } .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-swatches i { min-width: 3px; } .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 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-card p { margin: 0; color: var(--muted); font-size: 10px; }
.history-actions { grid-column: 1 / -1; display: flex; gap: 7px; justify-content: flex-end; } .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 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 p:not(.section-kicker) { margin: 6px 0 0; color: var(--muted); font-size: 12px; }
.crop-dialog-header > button { width: 38px; height: 38px; flex: 0 0 auto; border: 1px solid var(--line); background: white; cursor: pointer; font-size: 25px; line-height: 1; } .crop-dialog-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-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); }
.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-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-mask { position: absolute; z-index: 1; background: rgba(10,15,13,.55); pointer-events: none; }
.crop-selection-top { inset: 0 0 auto; } .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, .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; }
.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 { 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 span { margin-right: auto; color: var(--muted); font-size: 11px; }
.crop-dialog-actions button { min-height: 40px; padding: 0 16px; border: 1px solid var(--ink); background: white; cursor: pointer; font-size: 12px; } .crop-dialog-actions 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 { width: 100vw; max-height: 100vh; height: 100vh; border: 0; box-shadow: none; }
.crop-dialog-header { padding: 14px 16px; } .crop-dialog-header { padding: 14px 16px; }
.crop-dialog-header h2 { font-size: 18px; } .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-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; }
+746 -31
View File
@@ -35,7 +35,23 @@ type CropRect = {
height: number; 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 FULL_CROP: CropRect = { x: 0, y: 0, width: 1, height: 1 };
// 现成图纸识别准确率仍需继续优化,暂时保留实现但关闭用户入口。
const ENABLE_PATTERN_IMPORT = false;
function logUsage(event: Record<string, unknown>) { function logUsage(event: Record<string, unknown>) {
void fetch("/api/usage", { void fetch("/api/usage", {
@@ -79,6 +95,17 @@ function createHistoryId() {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; 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() { function openHistoryDatabase() {
return new Promise<IDBDatabase>((resolve, reject) => { return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(HISTORY_DB_NAME, 1); 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)]; 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) }; 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] => [ const hexToRgb = (hex: string): [number, number, number] => [
parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(1, 3), 16),
@@ -167,6 +195,29 @@ const hexToRgb = (hex: string): [number, number, number] => [
parseInt(hex.slice(5, 7), 16), 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[]) { function nearestColor(lab: Oklab, palette: BeadColor[]) {
let closest = palette[0]; let closest = palette[0];
let smallest = Number.POSITIVE_INFINITY; let smallest = Number.POSITIVE_INFINITY;
@@ -180,6 +231,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 }>();
@@ -628,9 +904,29 @@ function drawFittedImage(
const sourceHeight = crop.height * image.naturalHeight; const sourceHeight = crop.height * image.naturalHeight;
const imageRatio = sourceWidth / sourceHeight; const imageRatio = sourceWidth / sourceHeight;
const boxRatio = width / height; 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 drawWidth = width;
let drawHeight = height; let drawHeight = height;
if ((fitMode === "cover" && imageRatio > boxRatio) || (fitMode === "contain" && imageRatio < boxRatio)) { if (imageRatio < boxRatio) {
drawHeight = height; drawHeight = height;
drawWidth = height * imageRatio; drawWidth = height * imageRatio;
} else { } else {
@@ -639,6 +935,9 @@ function drawFittedImage(
} }
const drawX = (width - drawWidth) / 2; const drawX = (width - drawWidth) / 2;
const drawY = (height - drawHeight) / 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); 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 [samplingStrategy, setSamplingStrategy] = useState<SamplingStrategy>("dominant");
const [cropRect, setCropRect] = useState<CropRect>(FULL_CROP); const [cropRect, setCropRect] = useState<CropRect>(FULL_CROP);
const [draftCropRect, setDraftCropRect] = useState<CropRect>(FULL_CROP); const [draftCropRect, setDraftCropRect] = useState<CropRect>(FULL_CROP);
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);
@@ -696,12 +1003,22 @@ 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);
const patternStageRef = useRef<HTMLDivElement | 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 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<{ const patternDragRef = useRef<{
pointerId: number; pointerId: number;
x: number; x: number;
@@ -834,7 +1151,9 @@ 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 = cropDialogPurpose === "pattern-import"
? patternImportImageRef.current
: sourceImageRef.current;
if (!canvas || !image) return; if (!canvas || !image) return;
const maximumWidth = 1200; const maximumWidth = 1200;
const maximumHeight = 760; const maximumHeight = 760;
@@ -844,7 +1163,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]);
useEffect(() => { useEffect(() => {
if (!cropDialogOpen) return; if (!cropDialogOpen) return;
@@ -959,6 +1278,52 @@ export default function Home() {
} }
}, [pixels, gridWidth, gridHeight, zoom, selectedCodes, onlySelected, showGrid, showCodes]); }, [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 handleUpload = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
@@ -981,7 +1346,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);
}; };
@@ -997,57 +1393,216 @@ export default function Home() {
}; };
const updateDraftCrop = (startX: number, startY: number, endX: number, endY: number) => { 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 directionX = endX >= startX ? 1 : -1;
const directionY = endY >= startY ? 1 : -1; const directionY = endY >= startY ? 1 : -1;
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 (width / Math.max(height, 0.0001) > normalizedRatio) width = height * normalizedRatio; if (cropSelectionMode === "square") {
else height = width / Math.max(normalizedRatio, 0.0001); 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 availableWidth = directionX > 0 ? 1 - startX : startX;
const availableHeight = directionY > 0 ? 1 - startY : startY; const availableHeight = directionY > 0 ? 1 - startY : startY;
const scale = Math.min(1, availableWidth / Math.max(width, 0.0001), availableHeight / Math.max(height, 0.0001)); const scale = Math.min(1, availableWidth / Math.max(width, 0.0001), availableHeight / Math.max(height, 0.0001));
width *= scale; width *= scale;
height *= scale; height *= scale;
}
width = Math.max(0.01, Math.min(1, width));
height = Math.max(0.01, Math.min(1, height));
setDraftCropRect({ setDraftCropRect({
x: directionX > 0 ? startX : startX - width, x: Math.max(0, Math.min(1 - width, directionX > 0 ? startX : startX - width)),
y: directionY > 0 ? startY : startY - height, y: Math.max(0, Math.min(1 - height, directionY > 0 ? startY : startY - height)),
width: Math.max(0.01, width), width,
height: Math.max(0.01, height), 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>) => { const handleCropSelectionDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return; if (event.button !== 0) return;
const point = cropPoint(event); const point = cropPoint(event);
event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId);
cropSelectionDragRef.current = { pointerId: event.pointerId, startX: point.x, startY: point.y }; const resizeHandle = (event.target as HTMLElement).dataset.cropHandle as CropResizeHandle | undefined;
setDraftCropRect({ x: point.x, y: point.y, width: 0.01, height: 0.01 }); 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 handleCropSelectionMove = (event: React.PointerEvent<HTMLDivElement>) => {
const drag = cropSelectionDragRef.current; const drag = cropSelectionDragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
const point = cropPoint(event); 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); updateDraftCrop(drag.startX, drag.startY, point.x, point.y);
}; };
const handleCropSelectionEnd = (event: React.PointerEvent<HTMLDivElement>) => { const handleCropSelectionEnd = (event: React.PointerEvent<HTMLDivElement>) => {
if (cropSelectionDragRef.current?.pointerId === event.pointerId) cropSelectionDragRef.current = null; 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); 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);
setStatus("取景区域已更新,点击“重新转换”生成图纸"); convertImage(sourceImageRef.current, draftCropRect);
}; };
const toggleColor = (code: string) => { const toggleColor = (code: string) => {
@@ -1202,12 +1757,138 @@ export default function Home() {
}, [history, historyQuery]); }, [history, historyQuery]);
const exportPng = () => { const exportPng = () => {
const canvas = canvasRef.current; if (!pixels.length) return;
if (!canvas) 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"); const link = document.createElement("a");
link.download = `拼豆图纸-${gridWidth}x${gridHeight}.png`; link.download = `拼豆图纸-${gridWidth}x${gridHeight}.png`;
link.href = canvas.toDataURL("image/png"); link.href = url;
link.click(); 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 ( return (
@@ -1245,6 +1926,10 @@ 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>
{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"> <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>
@@ -1274,6 +1959,7 @@ export default function Home() {
<div <div
className="canvas-stage" className="canvas-stage"
ref={patternStageRef} ref={patternStageRef}
title="鼠标位于预览区时,可滚动滚轮缩放图纸"
> >
<div className="canvas-wrap"> <div className="canvas-wrap">
<canvas <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={showGrid} onChange={(e) => setShowGrid(e.target.checked)} /> </label>
<label><input type="checkbox" checked={showCodes} onChange={(e) => setShowCodes(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 className="save-button" onClick={savePattern}></button>
<button onClick={exportPng}> PNG</button> <button onClick={exportPng}> PNG</button>
</div> </div>
</div> </div>
<div className="pattern-legend"> <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); }}> {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> {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> <button aria-label="关闭取景窗口" onClick={() => setCropDialogOpen(false)}>×</button>
</div> </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-scroll">
<div <div
className="crop-dialog-stage" className="crop-dialog-stage"
@@ -1352,20 +2057,33 @@ export default function Home() {
onPointerMove={handleCropSelectionMove} onPointerMove={handleCropSelectionMove}
onPointerUp={handleCropSelectionEnd} onPointerUp={handleCropSelectionEnd}
onPointerCancel={handleCropSelectionEnd} onPointerCancel={handleCropSelectionEnd}
onPointerLeave={(event) => { if (!cropSelectionDragRef.current) event.currentTarget.classList.remove("can-move-selection"); }}
> >
<canvas ref={cropDialogCanvasRef} aria-label="可框选的原始大图" /> <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-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-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-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" />)}
{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> </div>
<div className="crop-dialog-actions"> <div className="crop-dialog-actions">
<button onClick={() => 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>}
@@ -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 className="search-box"><span></span><input value={historyQuery} onChange={(e) => setHistoryQuery(e.target.value)} placeholder="按名称或 32x32 查询" /></div>
</div> </div>
{filteredHistory.length ? <div className="history-grid">{filteredHistory.map((entry) => { {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}> 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><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> <div className="history-actions"><button onClick={() => restorePattern(entry)}></button><button onClick={() => removePattern(entry.id)}></button></div>
</article>; </article>;
+12 -2
View File
@@ -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 { createHash } from "node:crypto";
import { createServer } from "node:http"; import { createServer } from "node:http";
import { extname, join, normalize } from "node:path"; 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 dataDirectory = process.env.DATA_DIR || join(process.cwd(), "data");
const usageLogPath = join(dataDirectory, "usage.jsonl"); const usageLogPath = join(dataDirectory, "usage.jsonl");
mkdirSync(dataDirectory, { recursive: true }); mkdirSync(dataDirectory, { recursive: true });
function formatChinaTimestamp(date) {
return new Date(date.getTime() + 8 * 60 * 60 * 1000)
.toISOString()
.replace("Z", "+08:00");
}
const mimeTypes = { const mimeTypes = {
".css": "text/css; charset=utf-8", ".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8", ".html": "text/html; charset=utf-8",
@@ -40,8 +48,10 @@ function writeUsageLog(request, response) {
sendJson(response, 400, { error: "Invalid event" }); sendJson(response, 400, { error: "Invalid event" });
return; return;
} }
const now = new Date();
const entry = { const entry = {
timestamp: new Date().toISOString(), timestamp: now.toISOString(),
timestamp_cn: formatChinaTimestamp(now),
event: input.event, event: input.event,
client_hash: request.headers["x-forwarded-for"] || request.socket.remoteAddress 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) ? createHash("sha256").update(String(request.headers["x-forwarded-for"] || request.socket.remoteAddress)).digest("hex").slice(0, 16)
+3 -3
View File
@@ -11,7 +11,7 @@
"name": "rolldown-runtime" "name": "rolldown-runtime"
}, },
"app/page.tsx": { "app/page.tsx": {
"file": "_next/static/chunks/page-CNQA87Lx.js", "file": "_next/static/chunks/page-DBRuBfy6.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-Bf1cBuVr.js", "file": "_next/static/chunks/layout-segment-context-rAphBSzO.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-DAoL2pdt.js", "file": "_next/static/chunks/index-VFVBI02U.js",
"name": "index", "name": "index",
"src": "virtual:vinext-app-browser-entry", "src": "virtual:vinext-app-browser-entry",
"isEntry": true, "isEntry": true,
+2 -2
View File
@@ -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" /> <!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,&quot;Segoe UI&quot;,Roboto,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;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> </head><body><div style="font-family:system-ui,&quot;Segoe UI&quot;,Roboto,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;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>
File diff suppressed because one or more lines are too long
@@ -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
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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.BD45GRB0.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.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}}} 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] a:I["6efdf509a785",[],"default",1]
1:["$","$La",null,{"params":"$@b","searchParams":"$@c"}] 1:["$","$La",null,{"params":"$@b","searchParams":"$@c"}]
b:{} b:{}
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"appBrowserEntry": "_next/static/chunks/index-DAoL2pdt.js" "appBrowserEntry": "_next/static/chunks/index-VFVBI02U.js"
} }
+28 -68
View File
@@ -1,91 +1,51 @@
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.doesNotMatch(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 its 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, /const ENABLE_PATTERN_IMPORT = false/);
assert.match(preview, /baseColor="#eceae7"/); assert.match(page, /recognizedCodes/);
assert.match(preview, /highlightColor="#f9f8f6"/); assert.match(page, /missingCodeAsEmpty/);
assert.match(preview, /duration=\{2\.8\}/); assert.match(page, /没有识别到 MARD 编码的格子视为空白/);
assert.match(preview, /sites-skeleton-search-placeholder/); assert.match(page, /pattern-import-grid-overlay/);
assert.match(packageJson, /"react-loading-skeleton": "3\.5\.0"/); assert.match(page, /max="256"/);
assert.match(page, /maximumExportSide = 8192/);
const shellIndex = preview.indexOf('className="sites-skeleton-shell"'); assert.match(page, /color\.code.*color\.count.*颗/);
const statusIndex = preview.indexOf('className="sites-skeleton-status"'); assert.match(page, /下载高清 PNG/);
assert.ok(shellIndex >= 0 && statusIndex > shellIndex); assert.match(css, /\.pattern-import-grid-overlay/);
assert.match(css, /position:\s*fixed/); assert.match(css, /@media \(max-width: 760px\)/);
assert.match(css, /inset:\s*0/); assert.match(palette, /export const MARD_221/);
assert.match(css, /opacity:\s*0\.52/); assert.match(palette, /A:\s*"#[0-9A-F]{6}/);
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)),
);
}); });