Initial MARD pixel bead pattern web
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export type ChatGPTUser = {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
};
|
||||
|
||||
const USER_ID_HEADER = "oai-authenticated-user-id";
|
||||
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
|
||||
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
|
||||
const USER_FULL_NAME_ENCODING_HEADER =
|
||||
"oai-authenticated-user-full-name-encoding";
|
||||
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
|
||||
const SIGN_IN_PATH = "/signin-with-chatgpt";
|
||||
const SIGN_OUT_PATH = "/signout-with-chatgpt";
|
||||
const CALLBACK_PATH = "/callback";
|
||||
|
||||
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
|
||||
const requestHeaders = await headers();
|
||||
const userId = requestHeaders.get(USER_ID_HEADER);
|
||||
const email = requestHeaders.get(USER_EMAIL_HEADER);
|
||||
if (!userId || !email) return null;
|
||||
|
||||
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
|
||||
const fullName =
|
||||
encodedFullName &&
|
||||
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
|
||||
? safeDecodeURIComponent(encodedFullName)
|
||||
: null;
|
||||
|
||||
return {
|
||||
userId,
|
||||
displayName: fullName ?? email,
|
||||
email,
|
||||
fullName,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireChatGPTUser(
|
||||
returnTo: string,
|
||||
): Promise<ChatGPTUser> {
|
||||
const user = await getChatGPTUser();
|
||||
if (user) return user;
|
||||
|
||||
redirect(chatGPTSignInPath(returnTo));
|
||||
}
|
||||
|
||||
export function chatGPTSignInPath(returnTo: string): string {
|
||||
const safeReturnTo = safeRelativeReturnPath(returnTo);
|
||||
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
|
||||
}
|
||||
|
||||
export function chatGPTSignOutPath(returnTo = "/"): string {
|
||||
const safeReturnTo = safeRelativeReturnPath(returnTo);
|
||||
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
|
||||
}
|
||||
|
||||
function safeRelativeReturnPath(value: string): string {
|
||||
if (!value.startsWith("/") || value.startsWith("//")) return "/";
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value, "https://app.local");
|
||||
} catch {
|
||||
return "/";
|
||||
}
|
||||
if (url.origin !== "https://app.local") return "/";
|
||||
if (isReservedAuthPath(url.pathname)) return "/";
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
function isReservedAuthPath(pathname: string): boolean {
|
||||
return (
|
||||
pathname === SIGN_IN_PATH ||
|
||||
pathname === SIGN_OUT_PATH ||
|
||||
pathname === CALLBACK_PATH
|
||||
);
|
||||
}
|
||||
|
||||
function safeDecodeURIComponent(value: string): string | null {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--ink: #202724;
|
||||
--muted: #69716d;
|
||||
--paper: #f5f1e8;
|
||||
--card: #fffdf8;
|
||||
--line: #d9d5ca;
|
||||
--green: #1f6b4d;
|
||||
--green-dark: #164b39;
|
||||
--lime: #cbe568;
|
||||
--orange: #ee7b43;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { margin: 0; background: var(--paper); color: var(--ink); font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif; }
|
||||
button, input, select { font: inherit; }
|
||||
button, label, input[type="range"] { -webkit-tap-highlight-color: transparent; }
|
||||
button { color: inherit; }
|
||||
|
||||
.topbar { height: 72px; padding: 0 clamp(20px, 5vw, 80px); display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); background: rgba(245, 241, 232, .92); position: sticky; top: 0; z-index: 20; backdrop-filter: blur(12px); }
|
||||
.brand { display: flex; align-items: center; gap: 11px; color: var(--ink); text-decoration: none; font-size: 20px; font-weight: 800; letter-spacing: .04em; }
|
||||
.brand-mark { display: grid; grid-template-columns: repeat(2, 8px); gap: 3px; rotate: -8deg; }
|
||||
.brand-mark i { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: inset -1px -1px 0 rgba(0,0,0,.15); }
|
||||
.brand-mark i:nth-child(2) { background: var(--orange); }
|
||||
.brand-mark i:nth-child(3) { background: var(--lime); }
|
||||
.top-note { color: var(--muted); font-size: 13px; letter-spacing: .08em; }
|
||||
|
||||
.hero { max-width: 1560px; margin: 0 auto; padding: clamp(42px, 7vw, 92px) clamp(20px, 5vw, 80px) clamp(38px, 6vw, 72px); display: flex; align-items: flex-end; justify-content: space-between; gap: 48px; }
|
||||
.eyebrow, .section-kicker { margin: 0 0 14px; font-size: 11px; color: var(--green); font-weight: 800; letter-spacing: .2em; }
|
||||
.hero h1 { margin: 0; font-family: Georgia, "Songti SC", serif; font-size: clamp(40px, 6.3vw, 88px); line-height: 1.04; letter-spacing: -.055em; font-weight: 500; }
|
||||
.hero h1 em { color: var(--green); font-style: normal; }
|
||||
.hero-copy { max-width: 720px; margin: 28px 0 0; color: var(--muted); font-size: clamp(15px, 1.5vw, 18px); line-height: 1.8; }
|
||||
.hero-badge { flex: 0 0 auto; width: 150px; height: 150px; border: 1px solid var(--ink); border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; rotate: 6deg; background: var(--lime); box-shadow: 6px 6px 0 var(--ink); }
|
||||
.hero-badge strong { font-family: Georgia, serif; font-size: 26px; }
|
||||
.hero-badge span { margin-top: 5px; font-size: 11px; }
|
||||
|
||||
.workspace { max-width: 1760px; margin: 0 auto 80px; display: grid; grid-template-columns: minmax(240px, 300px) minmax(500px, 1fr) minmax(260px, 330px); border-top: 1px solid var(--ink); border-bottom: 1px solid var(--ink); background: var(--card); }
|
||||
.control-panel, .color-panel { min-width: 0; padding: 28px; }
|
||||
.control-panel { border-right: 1px solid var(--line); }
|
||||
.color-panel { border-left: 1px solid var(--line); display: flex; flex-direction: column; max-height: 790px; }
|
||||
.panel-heading { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 24px; }
|
||||
.panel-heading > span { width: 32px; height: 32px; flex: 0 0 auto; display: grid; place-items: center; border: 1px solid var(--ink); border-radius: 50%; font: 700 11px Georgia, serif; }
|
||||
.panel-heading h2, .pattern-toolbar h2 { margin: 0; font-size: 18px; }
|
||||
.panel-heading p { margin: 5px 0 0; color: var(--muted); font-size: 12px; }
|
||||
|
||||
.upload-card { height: 170px; display: block; position: relative; overflow: hidden; cursor: pointer; border: 1px dashed #a5a59e; background: #ebe7dd; }
|
||||
.upload-card img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.upload-card input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.upload-overlay { position: absolute; right: 10px; bottom: 10px; padding: 7px 10px; color: white; background: rgba(22, 75, 57, .86); font-size: 12px; }
|
||||
.file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin: 9px 0 24px; color: var(--muted); font-size: 11px; }
|
||||
.field-row { display: grid; grid-template-columns: 1fr auto 1fr; align-items: end; gap: 8px; }
|
||||
.field-row label, .field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.field-row label span, .field > span { color: var(--muted); font-size: 12px; }
|
||||
.field-row input, .field select { width: 100%; height: 43px; border: 1px solid var(--line); border-radius: 0; background: white; padding: 0 10px; outline: none; }
|
||||
.field-row input:focus, .field select:focus, .search-box:focus-within { border-color: var(--green); box-shadow: 0 0 0 2px rgba(31,107,77,.11); }
|
||||
.times { padding-bottom: 11px; color: var(--muted); }
|
||||
.field { margin-top: 22px; }
|
||||
.field > span { display: flex; justify-content: space-between; }
|
||||
.field b { color: var(--green); }
|
||||
input[type="range"] { accent-color: var(--green); }
|
||||
.primary-button { width: 100%; min-height: 48px; margin-top: 24px; border: 1px solid var(--ink); background: var(--green); color: white; cursor: pointer; font-weight: 700; box-shadow: 3px 3px 0 var(--ink); transition: transform .15s, box-shadow .15s; }
|
||||
.primary-button:hover { transform: translate(2px,2px); box-shadow: 1px 1px 0 var(--ink); }
|
||||
.privacy-note, .palette-note { margin: 16px 0 0; color: var(--muted); font-size: 11px; line-height: 1.6; }
|
||||
|
||||
.pattern-panel { min-width: 0; display: flex; flex-direction: column; background-color: #f1eee6; background-image: radial-gradient(#cbc6ba 0.7px, transparent .7px); background-size: 12px 12px; }
|
||||
.pattern-toolbar { min-height: 90px; padding: 22px 28px; display: flex; justify-content: space-between; align-items: center; gap: 24px; background: var(--card); border-bottom: 1px solid var(--line); }
|
||||
.pattern-toolbar .section-kicker { margin-bottom: 5px; }
|
||||
.zoom-control { display: flex; align-items: center; gap: 7px; }
|
||||
.zoom-control button { width: 36px; height: 36px; border: 1px solid var(--line); background: white; cursor: pointer; font-size: 20px; }
|
||||
.zoom-control input { width: 95px; }
|
||||
.zoom-control output { width: 38px; color: var(--muted); font-size: 11px; text-align: right; }
|
||||
.canvas-stage { min-height: 610px; flex: 1; overflow: auto; display: grid; place-items: center; padding: 36px; touch-action: pan-x pan-y; }
|
||||
.canvas-wrap { width: max-content; line-height: 0; position: relative; border: 1px solid var(--ink); box-shadow: 10px 10px 0 rgba(32,39,36,.13); background: white; }
|
||||
.canvas-wrap canvas { cursor: crosshair; image-rendering: pixelated; }
|
||||
.pixel-tooltip { position: absolute; z-index: 5; width: max-content; max-width: 260px; padding: 9px 11px; display: grid; grid-template-columns: 14px auto; align-items: center; gap: 3px 8px; color: white; background: rgba(22, 30, 27, .94); border: 1px solid rgba(255,255,255,.2); box-shadow: 4px 4px 0 rgba(0,0,0,.2); border-radius: 3px; line-height: 1.35; pointer-events: none; }
|
||||
.tooltip-swatch { width: 14px; height: 14px; grid-row: 1 / 3; border-radius: 50%; border: 1px solid rgba(255,255,255,.6); }
|
||||
.pixel-tooltip strong { font-size: 11px; }
|
||||
.pixel-tooltip small { color: #dce4df; font-size: 10px; }
|
||||
.pattern-footer { min-height: 62px; padding: 12px 22px; background: var(--card); border-top: 1px solid var(--line); display: flex; align-items: center; gap: 9px; color: var(--muted); font-size: 11px; }
|
||||
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 3px rgba(31,107,77,.14); }
|
||||
.view-options { margin-left: auto; display: flex; align-items: center; gap: 14px; }
|
||||
.view-options label { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||
.view-options button { padding: 8px 11px; border: 1px solid var(--ink); background: var(--lime); cursor: pointer; font-weight: 700; }
|
||||
.view-options .save-button { background: white; }
|
||||
.pattern-legend { padding: 18px 22px 22px; background: var(--card); border-top: 1px solid var(--line); }
|
||||
.legend-heading { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||||
.legend-heading strong { font-size: 13px; }
|
||||
.legend-heading span { color: var(--muted); font-size: 10px; }
|
||||
.legend-list { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.legend-list button { min-height: 34px; padding: 5px 8px 5px 6px; display: flex; align-items: center; gap: 5px; border: 1px solid var(--line); background: white; cursor: pointer; font-size: 10px; }
|
||||
.legend-list button.active { border-color: var(--ink); background: #f0f6df; box-shadow: 2px 2px 0 var(--ink); }
|
||||
.legend-list i { width: 20px; height: 20px; border-radius: 3px; border: 1px solid rgba(32,39,36,.25); }
|
||||
.legend-list span { color: var(--muted); }
|
||||
.legend-list b { padding-left: 5px; border-left: 1px solid var(--line); }
|
||||
|
||||
.search-box { height: 43px; display: flex; align-items: center; gap: 8px; padding: 0 12px; border: 1px solid var(--line); background: white; }
|
||||
.search-box span { font-size: 22px; }
|
||||
.search-box input { min-width: 0; width: 100%; border: 0; outline: 0; background: transparent; font-size: 12px; }
|
||||
.selection-tools { min-height: 52px; display: flex; align-items: center; justify-content: space-between; gap: 8px; border-bottom: 1px solid var(--line); }
|
||||
.switch-row { display: flex; align-items: center; gap: 7px; cursor: pointer; font-size: 11px; }
|
||||
.switch-row input { position: absolute; opacity: 0; }
|
||||
.switch { width: 30px; height: 17px; border-radius: 12px; background: #bbb; position: relative; transition: .2s; }
|
||||
.switch::after { content: ""; position: absolute; width: 13px; height: 13px; left: 2px; top: 2px; border-radius: 50%; background: white; transition: .2s; }
|
||||
.switch-row input:checked + .switch { background: var(--green); }
|
||||
.switch-row input:checked + .switch::after { translate: 13px 0; }
|
||||
.selection-tools button { border: 0; background: transparent; color: var(--green); cursor: pointer; font-size: 11px; }
|
||||
.color-list { min-height: 220px; overflow-y: auto; margin: 8px -8px 0; padding: 0 8px; scrollbar-width: thin; }
|
||||
.color-item { width: 100%; min-height: 58px; display: grid; grid-template-columns: 34px minmax(0,1fr) auto; align-items: center; gap: 10px; padding: 8px; border: 1px solid transparent; border-bottom-color: #ebe7dd; background: transparent; cursor: pointer; text-align: left; }
|
||||
.color-item:hover { background: #f7f4ed; }
|
||||
.color-item.active { border-color: var(--ink); background: #f0f6df; box-shadow: 2px 2px 0 var(--ink); }
|
||||
.swatch { width: 30px; height: 30px; border: 1px solid rgba(32,39,36,.28); border-radius: 50%; box-shadow: inset -2px -2px 0 rgba(0,0,0,.08); }
|
||||
.color-meta { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.color-meta strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.color-meta small { color: var(--muted); font: 9px ui-monospace, monospace; }
|
||||
.color-item > b { font: 700 16px Georgia, serif; text-align: right; }
|
||||
.color-item > b small { margin-left: 2px; color: var(--muted); font: 9px sans-serif; }
|
||||
|
||||
.history-section { max-width: 1560px; margin: 0 auto 90px; padding: 0 clamp(20px, 5vw, 80px); }
|
||||
.history-heading { display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; padding-bottom: 22px; border-bottom: 1px solid var(--ink); }
|
||||
.history-heading h2 { margin: 0; font: 500 clamp(30px, 4vw, 50px) Georgia, "Songti SC", serif; }
|
||||
.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 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; }
|
||||
.history-actions button { min-height: 34px; padding: 0 14px; border: 1px solid var(--ink); background: white; cursor: pointer; font-size: 11px; }
|
||||
.history-actions button:first-child { background: var(--lime); font-weight: 700; }
|
||||
.history-empty { margin-top: 18px; padding: 44px 20px; color: var(--muted); text-align: center; border: 1px dashed var(--line); background: rgba(255,253,248,.5); font-size: 13px; }
|
||||
|
||||
@media (max-width: 1160px) {
|
||||
.workspace { grid-template-columns: 260px minmax(500px, 1fr); }
|
||||
.color-panel { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--ink); max-height: none; }
|
||||
.color-list { display: grid; grid-template-columns: repeat(3, 1fr); max-height: 300px; gap: 0 12px; }
|
||||
.history-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.topbar { height: 60px; padding: 0 18px; }
|
||||
.top-note { display: none; }
|
||||
.hero { padding: 38px 20px 44px; align-items: flex-start; }
|
||||
.hero h1 { font-size: clamp(39px, 12vw, 58px); letter-spacing: -.06em; }
|
||||
.hero-copy { margin-top: 20px; }
|
||||
.hero-badge { display: none; }
|
||||
.workspace { margin-bottom: 0; grid-template-columns: 1fr; border-bottom: 0; }
|
||||
.control-panel { border-right: 0; border-bottom: 1px solid var(--ink); padding: 24px 20px; }
|
||||
.upload-card { height: 210px; }
|
||||
.pattern-panel { min-height: 620px; }
|
||||
.pattern-toolbar { align-items: flex-start; flex-direction: column; padding: 20px; }
|
||||
.zoom-control { width: 100%; }
|
||||
.zoom-control input { flex: 1; }
|
||||
.canvas-stage { min-height: 440px; display: block; padding: 24px; }
|
||||
.canvas-wrap { margin: auto; }
|
||||
.pattern-footer { align-items: flex-start; flex-wrap: wrap; padding: 14px 18px; }
|
||||
.view-options { width: 100%; margin: 5px 0 0; justify-content: space-between; }
|
||||
.view-options button { min-height: 40px; }
|
||||
.color-panel { grid-column: auto; padding: 24px 20px 38px; }
|
||||
.color-list { display: block; max-height: 430px; }
|
||||
.legend-heading, .history-heading { align-items: stretch; flex-direction: column; }
|
||||
.legend-list { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 7px; }
|
||||
.legend-list button { flex: 0 0 auto; }
|
||||
.history-section { margin: 52px auto; }
|
||||
.history-heading .search-box { width: 100%; }
|
||||
.history-grid { grid-template-columns: 1fr; }
|
||||
input, select, button { font-size: 16px; }
|
||||
.search-box input, .color-item, .selection-tools button { font-size: 12px; }
|
||||
}
|
||||
|
||||
@media (max-width: 370px) {
|
||||
.view-options { gap: 6px; }
|
||||
.view-options label { font-size: 10px; }
|
||||
.canvas-stage { padding: 16px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; transition: none !important; } }
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "豆格工坊|图片转拼豆图纸",
|
||||
description: "在浏览器中把图片转换为拼豆像素图纸,按色号、名称或像素格筛选颜色。",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return <html lang="zh-CN"><body>{children}</body></html>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// MARD 221 基础色卡(A/B/C/D/E/F/G/H/M 系列)。
|
||||
// HEX 为屏幕匹配用近似值,实体颜色会受到批次、光线与屏幕影响。
|
||||
const SERIES: Record<string, string> = {
|
||||
A: "#FAF4C8 #FFFFD5 #FEFF8B #FBED56 #F4D738 #FEAC4C #FE8B4C #FFDA45 #FF995B #F77C31 #FFDD99 #FE9F72 #FFC365 #FD543D #FFF365 #FFFF9F #FFE36E #FEBE7D #FD7C72 #FFD568 #FFE395 #F4F57D #E6C9B7 #F7F8A2 #FFD67D #FFC830",
|
||||
B: "#E6EE31 #63F347 #9EF780 #5DE035 #35E352 #65E2A6 #3DAF80 #1C9C4F #27523A #95D3C2 #5D722A #166F41 #CAEB7B #ADE946 #2E5132 #C5ED9C #9BB13A #E6EE49 #24B88C #C2F0CC #156A6B #0B3C43 #303A21 #EEFCA5 #4E846D #8D7A35 #CCE1AF #9EE5B9 #C5E254 #E2FCB1 #B0E792 #9CAB5A",
|
||||
C: "#E8FFE7 #A9F9FC #A0E2FB #41CCFF #01ACEB #50AAF0 #3677D2 #0F54C0 #324BCA #3EBCE2 #28DDDE #1C334D #CDE8FF #D5FDFF #22C4C6 #1557A8 #04D1F6 #1D3344 #1887A2 #176DAF #BEDDFF #67B4BE #C8E2FF #7CC4FF #A9E5E5 #3CAED8 #D3DFFA #BBCFED #34488E",
|
||||
D: "#AEB4F2 #858EDD #2F54AF #182A84 #B843C5 #AC7BDE #8854B3 #E2D3FF #D5B9F8 #361851 #B9BAE1 #DE9AD4 #B90095 #8B279B #2F1F90 #E3E1EE #C4D4F6 #A45EC7 #D8C3D7 #9C32B2 #9A009B #333A95 #EBDAFC #7786E5 #494FC7 #DFC2F8",
|
||||
E: "#FDD3CC #FEC0DF #FFB7E7 #E8649E #F551A2 #F13D74 #C63478 #FFDBE9 #E970CC #D33793 #FCDDD2 #F78FC3 #B5006D #FFD1BA #F8C7C9 #FFF3EB #FFE2EA #FFC7DB #FEBAD5 #D8C7D1 #BD9DA1 #B785A1 #937A8D #E1BCE8",
|
||||
F: "#FD957B #FC3D46 #F74941 #FC283C #E7002F #943630 #971937 #BC0028 #E2677A #8A4526 #5A2121 #FD4E6A #F35744 #FFA9AD #D30022 #FEC2A6 #E69C79 #D37C46 #C1444A #CD9391 #F7B4C6 #FDC0D0 #F67E66 #E698AA #E54B4F",
|
||||
G: "#FFE2CE #FFC4AA #F4C3A5 #E1B383 #EDB045 #E99C17 #9D5B3E #753832 #E6B483 #D98C39 #E0C593 #FFC890 #B7714A #8D614C #FCF9E0 #F2D9BA #78524B #FFE4CC #E07935 #A94023 #B88558",
|
||||
H: "#FDFBFF #FEFFFF #B6B1BA #89858C #48464E #2F2B2F #000000 #E7D6DB #EDEDED #EEE9EA #CECDD5 #FFF5ED #F5ECD2 #CFD7D3 #98A6A8 #1D1414 #F1EDED #FFFDF0 #F6EFE2 #949FA3 #FFFBE1 #CACAD4 #9A9D94",
|
||||
M: "#BCC6B8 #8AA386 #697D80 #E3D2BC #D0CCAA #B0A782 #B4A497 #B38281 #A58767 #C5B2BC #9F7594 #644749 #D19066 #C77362 #757D78",
|
||||
};
|
||||
|
||||
export const MARD_221: ReadonlyArray<readonly [string, string]> = Object.entries(SERIES)
|
||||
.flatMap(([series, colors]) => colors.split(" ").map((hex, index) => [series + (index + 1), hex] as const));
|
||||
+487
@@ -0,0 +1,487 @@
|
||||
"use client";
|
||||
|
||||
import { ChangeEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { MARD_221 } from "./mard-palette";
|
||||
|
||||
type BeadColor = {
|
||||
code: string;
|
||||
name: string;
|
||||
hex: string;
|
||||
rgb: [number, number, number];
|
||||
};
|
||||
|
||||
type Pixel = {
|
||||
color: BeadColor;
|
||||
};
|
||||
|
||||
type HoveredPixel = {
|
||||
row: number;
|
||||
column: number;
|
||||
color: BeadColor;
|
||||
left: number;
|
||||
top: number;
|
||||
};
|
||||
|
||||
type SavedPattern = {
|
||||
id: string;
|
||||
name: string;
|
||||
savedAt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
colorLimit: number;
|
||||
codes: string[];
|
||||
};
|
||||
|
||||
const MARD_SERIES_NAMES: Record<string, string> = {
|
||||
A: "黄橙系", B: "绿色系", C: "蓝青系", D: "紫蓝系", E: "粉红系",
|
||||
F: "红色系", G: "肤棕系", H: "黑白灰系", M: "莫兰迪系",
|
||||
};
|
||||
|
||||
const PALETTE: BeadColor[] = MARD_221.map(([code, hex]) => ({
|
||||
code,
|
||||
name: `MARD ${MARD_SERIES_NAMES[code[0]]}`,
|
||||
hex,
|
||||
rgb: [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)],
|
||||
}));
|
||||
|
||||
const hexToRgb = (hex: string): [number, number, number] => [
|
||||
parseInt(hex.slice(1, 3), 16),
|
||||
parseInt(hex.slice(3, 5), 16),
|
||||
parseInt(hex.slice(5, 7), 16),
|
||||
];
|
||||
|
||||
function colorDistance(a: [number, number, number], b: [number, number, number]) {
|
||||
const meanR = (a[0] + b[0]) / 2;
|
||||
const r = a[0] - b[0];
|
||||
const g = a[1] - b[1];
|
||||
const blue = a[2] - b[2];
|
||||
return (2 + meanR / 256) * r * r + 4 * g * g + (2 + (255 - meanR) / 256) * blue * blue;
|
||||
}
|
||||
|
||||
function nearestColor(rgb: [number, number, number], palette: BeadColor[]) {
|
||||
let closest = palette[0];
|
||||
let smallest = Number.POSITIVE_INFINITY;
|
||||
for (const color of palette) {
|
||||
const distance = colorDistance(rgb, color.rgb);
|
||||
if (distance < smallest) {
|
||||
smallest = distance;
|
||||
closest = color;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
}
|
||||
|
||||
function makeDemoImage() {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 640;
|
||||
canvas.height = 640;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, 640);
|
||||
sky.addColorStop(0, "#bfe8f1");
|
||||
sky.addColorStop(0.65, "#fff2ce");
|
||||
sky.addColorStop(1, "#f6c783");
|
||||
ctx.fillStyle = sky;
|
||||
ctx.fillRect(0, 0, 640, 640);
|
||||
ctx.fillStyle = "#f6c742";
|
||||
ctx.beginPath(); ctx.arc(488, 134, 70, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = "#58a95b";
|
||||
ctx.beginPath(); ctx.moveTo(0, 455); ctx.quadraticCurveTo(135, 310, 300, 455); ctx.quadraticCurveTo(480, 285, 640, 435); ctx.lineTo(640, 640); ctx.lineTo(0, 640); ctx.fill();
|
||||
ctx.fillStyle = "#27865a";
|
||||
ctx.beginPath(); ctx.moveTo(0, 535); ctx.quadraticCurveTo(180, 390, 350, 520); ctx.quadraticCurveTo(505, 410, 640, 505); ctx.lineTo(640, 640); ctx.lineTo(0, 640); ctx.fill();
|
||||
ctx.fillStyle = "#f7f5ed";
|
||||
ctx.fillRect(245, 390, 152, 126);
|
||||
ctx.fillStyle = "#d9383a";
|
||||
ctx.beginPath(); ctx.moveTo(218, 405); ctx.lineTo(321, 323); ctx.lineTo(424, 405); ctx.closePath(); ctx.fill();
|
||||
ctx.fillStyle = "#754a32";
|
||||
ctx.fillRect(300, 447, 43, 69);
|
||||
ctx.fillStyle = "#79cbe1";
|
||||
ctx.fillRect(258, 415, 39, 37); ctx.fillRect(350, 415, 34, 37);
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [sourceUrl, setSourceUrl] = useState("");
|
||||
const [sourceName, setSourceName] = useState("示例:田野小屋");
|
||||
const [gridWidth, setGridWidth] = useState(32);
|
||||
const [gridHeight, setGridHeight] = useState(32);
|
||||
const [colorLimit, setColorLimit] = useState(18);
|
||||
const [fitMode, setFitMode] = useState<"cover" | "contain">("cover");
|
||||
const [pixels, setPixels] = useState<Pixel[]>([]);
|
||||
const [selectedCodes, setSelectedCodes] = useState<Set<string>>(new Set());
|
||||
const [onlySelected, setOnlySelected] = useState(false);
|
||||
const [zoom, setZoom] = useState(24);
|
||||
const [showGrid, setShowGrid] = useState(true);
|
||||
const [showCodes, setShowCodes] = useState(true);
|
||||
const [query, setQuery] = useState("");
|
||||
const [hoveredPixel, setHoveredPixel] = useState<HoveredPixel | null>(null);
|
||||
const [history, setHistory] = useState<SavedPattern[]>([]);
|
||||
const [historyQuery, setHistoryQuery] = useState("");
|
||||
const [status, setStatus] = useState("示例图已准备好,可以直接转换");
|
||||
const sourceImageRef = useRef<HTMLImageElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("bead-pattern-history");
|
||||
if (!saved) return;
|
||||
queueMicrotask(() => {
|
||||
try { setHistory(JSON.parse(saved)); } catch { localStorage.removeItem("bead-pattern-history"); }
|
||||
});
|
||||
}, []);
|
||||
|
||||
const convertImage = (image = sourceImageRef.current) => {
|
||||
if (!image) return;
|
||||
const width = Math.max(8, Math.min(128, gridWidth));
|
||||
const height = Math.max(8, Math.min(128, gridHeight));
|
||||
const sample = document.createElement("canvas");
|
||||
sample.width = width;
|
||||
sample.height = height;
|
||||
const ctx = sample.getContext("2d", { willReadFrequently: true })!;
|
||||
ctx.fillStyle = "#f7f5ed";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
const imageRatio = image.naturalWidth / image.naturalHeight;
|
||||
const boxRatio = width / height;
|
||||
let drawWidth = width;
|
||||
let drawHeight = height;
|
||||
if ((fitMode === "cover" && imageRatio > boxRatio) || (fitMode === "contain" && imageRatio < boxRatio)) {
|
||||
drawHeight = height;
|
||||
drawWidth = height * imageRatio;
|
||||
} else {
|
||||
drawWidth = width;
|
||||
drawHeight = width / imageRatio;
|
||||
}
|
||||
ctx.drawImage(image, (width - drawWidth) / 2, (height - drawHeight) / 2, drawWidth, drawHeight);
|
||||
const data = ctx.getImageData(0, 0, width, height).data;
|
||||
const initialMatches: BeadColor[] = [];
|
||||
const counts = new Map<string, number>();
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const match = nearestColor([data[i], data[i + 1], data[i + 2]], PALETTE);
|
||||
initialMatches.push(match);
|
||||
counts.set(match.code, (counts.get(match.code) ?? 0) + 1);
|
||||
}
|
||||
const limitedPalette = [...PALETTE]
|
||||
.sort((a, b) => (counts.get(b.code) ?? 0) - (counts.get(a.code) ?? 0))
|
||||
.slice(0, Math.max(2, Math.min(colorLimit, PALETTE.length)));
|
||||
const converted = initialMatches.map((match, index) => {
|
||||
const rgb: [number, number, number] = [data[index * 4], data[index * 4 + 1], data[index * 4 + 2]];
|
||||
return { color: limitedPalette.some((c) => c.code === match.code) ? match : nearestColor(rgb, limitedPalette) };
|
||||
});
|
||||
setGridWidth(width);
|
||||
setGridHeight(height);
|
||||
setPixels(converted);
|
||||
setSelectedCodes(new Set());
|
||||
setStatus(`已转换为 ${width} × ${height},共 ${width * height} 颗拼豆`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const demo = makeDemoImage();
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
sourceImageRef.current = image;
|
||||
setSourceUrl(demo);
|
||||
convertImage(image);
|
||||
};
|
||||
image.src = demo;
|
||||
// This intentionally runs once to create the initial example.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const usedColors = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
pixels.forEach(({ color }) => counts.set(color.code, (counts.get(color.code) ?? 0) + 1));
|
||||
return PALETTE.filter((color) => counts.has(color.code))
|
||||
.map((color) => ({ ...color, count: counts.get(color.code)! }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}, [pixels]);
|
||||
|
||||
const filteredColors = useMemo(() => {
|
||||
const key = query.trim().toLowerCase();
|
||||
return usedColors.filter((color) => !key || color.code.toLowerCase().includes(key) || color.name.includes(key) || color.hex.toLowerCase().includes(key));
|
||||
}, [query, usedColors]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || pixels.length === 0) return;
|
||||
const cell = zoom;
|
||||
const ruler = Math.max(22, cell);
|
||||
const ratio = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = (gridWidth * cell + ruler) * ratio;
|
||||
canvas.height = (gridHeight * cell + ruler) * ratio;
|
||||
canvas.style.width = `${gridWidth * cell + ruler}px`;
|
||||
canvas.style.height = `${gridHeight * cell + ruler}px`;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.scale(ratio, ratio);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, gridWidth * cell + ruler, gridHeight * cell + ruler);
|
||||
ctx.fillStyle = "#f0eee7";
|
||||
ctx.fillRect(ruler, 0, gridWidth * cell, ruler);
|
||||
ctx.fillRect(0, ruler, ruler, gridHeight * cell);
|
||||
ctx.strokeStyle = "rgba(32,38,36,.25)";
|
||||
ctx.lineWidth = 0.6;
|
||||
ctx.font = `600 ${Math.max(7, Math.min(10, cell * 0.35))}px Arial`;
|
||||
ctx.fillStyle = "#56605b";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
for (let column = 0; column < gridWidth; column++) {
|
||||
const x = ruler + column * cell;
|
||||
ctx.strokeRect(x + 0.3, 0.3, cell - 0.6, ruler - 0.6);
|
||||
ctx.fillText(String(column + 1), x + cell / 2, ruler / 2);
|
||||
}
|
||||
for (let row = 0; row < gridHeight; row++) {
|
||||
const y = ruler + row * cell;
|
||||
ctx.strokeRect(0.3, y + 0.3, ruler - 0.6, cell - 0.6);
|
||||
ctx.fillText(String(row + 1), ruler / 2, y + cell / 2);
|
||||
}
|
||||
pixels.forEach(({ color }, index) => {
|
||||
const x = ruler + (index % gridWidth) * cell;
|
||||
const y = ruler + Math.floor(index / gridWidth) * cell;
|
||||
const hasSelection = selectedCodes.size > 0;
|
||||
const isSelected = selectedCodes.has(color.code);
|
||||
if (onlySelected && hasSelection && !isSelected) {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
} else {
|
||||
ctx.fillStyle = color.hex;
|
||||
ctx.globalAlpha = hasSelection && !isSelected ? 0.12 : 1;
|
||||
ctx.fillRect(x, y, cell, cell);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
if (showGrid) {
|
||||
ctx.strokeStyle = hasSelection && !isSelected ? "rgba(32,38,36,.07)" : "rgba(32,38,36,.22)";
|
||||
ctx.lineWidth = 0.6;
|
||||
ctx.strokeRect(x + 0.3, y + 0.3, cell - 0.6, cell - 0.6);
|
||||
}
|
||||
if (showCodes && cell >= 22 && (!hasSelection || isSelected)) {
|
||||
const [r, g, b] = hexToRgb(color.hex);
|
||||
ctx.fillStyle = r * 0.299 + g * 0.587 + b * 0.114 > 160 ? "#1f2925" : "#ffffff";
|
||||
ctx.font = `600 ${Math.max(7, cell * 0.28)}px Arial`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(color.code, x + cell / 2, y + cell / 2);
|
||||
}
|
||||
});
|
||||
}, [pixels, gridWidth, gridHeight, zoom, selectedCodes, onlySelected, showGrid, showCodes]);
|
||||
|
||||
const handleUpload = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
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 (sourceUrl.startsWith("blob:")) URL.revokeObjectURL(sourceUrl);
|
||||
sourceImageRef.current = image;
|
||||
setSourceUrl(url);
|
||||
setSourceName(file.name);
|
||||
setStatus("图片已载入,点击“重新转换”生成图纸");
|
||||
convertImage(image);
|
||||
};
|
||||
image.src = url;
|
||||
};
|
||||
|
||||
const toggleColor = (code: string) => {
|
||||
setSelectedCodes((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(code)) next.delete(code); else next.add(code);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCanvasClick = (event: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const ruler = Math.max(22, zoom);
|
||||
const x = Math.floor((event.clientX - rect.left - ruler) / zoom);
|
||||
const y = Math.floor((event.clientY - rect.top - ruler) / zoom);
|
||||
const pixel = pixels[y * gridWidth + x];
|
||||
if (x >= 0 && y >= 0 && x < gridWidth && y < gridHeight && pixel) toggleColor(pixel.color.code);
|
||||
};
|
||||
|
||||
const handleCanvasMove = (event: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const ruler = Math.max(22, zoom);
|
||||
const column = Math.floor((event.clientX - rect.left - ruler) / zoom);
|
||||
const row = Math.floor((event.clientY - rect.top - ruler) / zoom);
|
||||
const pixel = pixels[row * gridWidth + column];
|
||||
if (column < 0 || row < 0 || column >= gridWidth || row >= gridHeight || !pixel) {
|
||||
setHoveredPixel(null);
|
||||
return;
|
||||
}
|
||||
setHoveredPixel({ row: row + 1, column: column + 1, color: pixel.color, left: event.clientX - rect.left + 14, top: event.clientY - rect.top + 14 });
|
||||
};
|
||||
|
||||
const persistHistory = (next: SavedPattern[]) => {
|
||||
setHistory(next);
|
||||
localStorage.setItem("bead-pattern-history", JSON.stringify(next));
|
||||
};
|
||||
|
||||
const savePattern = () => {
|
||||
if (!pixels.length) return;
|
||||
const entry: SavedPattern = {
|
||||
id: crypto.randomUUID(),
|
||||
name: sourceName.replace(/\.[^.]+$/, "") || "未命名图纸",
|
||||
savedAt: new Date().toISOString(),
|
||||
width: gridWidth,
|
||||
height: gridHeight,
|
||||
colorLimit,
|
||||
codes: pixels.map((pixel) => pixel.color.code),
|
||||
};
|
||||
persistHistory([entry, ...history].slice(0, 30));
|
||||
setStatus("图纸已保存到本机历史记录");
|
||||
};
|
||||
|
||||
const restorePattern = (entry: SavedPattern) => {
|
||||
const colorMap = new Map(PALETTE.map((color) => [color.code, color]));
|
||||
setPixels(entry.codes.map((code) => ({ color: colorMap.get(code) ?? PALETTE[0] })));
|
||||
setGridWidth(entry.width);
|
||||
setGridHeight(entry.height);
|
||||
setColorLimit(entry.colorLimit);
|
||||
setSourceName(entry.name);
|
||||
setSelectedCodes(new Set());
|
||||
setStatus(`已恢复历史图纸:${entry.name}`);
|
||||
document.getElementById("pattern-preview")?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
const removePattern = (id: string) => persistHistory(history.filter((entry) => entry.id !== id));
|
||||
|
||||
const filteredHistory = useMemo(() => {
|
||||
const key = historyQuery.trim().toLowerCase();
|
||||
return history.filter((entry) => !key || entry.name.toLowerCase().includes(key) || `${entry.width}x${entry.height}`.includes(key));
|
||||
}, [history, historyQuery]);
|
||||
|
||||
const exportPng = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const link = document.createElement("a");
|
||||
link.download = `拼豆图纸-${gridWidth}x${gridHeight}.png`;
|
||||
link.href = canvas.toDataURL("image/png");
|
||||
link.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header className="topbar">
|
||||
<a className="brand" href="#top" aria-label="豆格工坊首页">
|
||||
<span className="brand-mark" aria-hidden="true"><i /><i /><i /><i /></span>
|
||||
<span>豆格工坊</span>
|
||||
</a>
|
||||
<span className="top-note">图片转拼豆图纸 · 本地处理</span>
|
||||
</header>
|
||||
|
||||
<section className="hero" id="top">
|
||||
<div>
|
||||
<p className="eyebrow">PIXEL BEAD STUDIO</p>
|
||||
<h1>把喜欢的图片,<br /><em>变成一格一格的快乐。</em></h1>
|
||||
<p className="hero-copy">上传图片,选择图纸大小和颜色数量;点击任意像素格,就能单独查看该颜色需要摆放的区域。</p>
|
||||
</div>
|
||||
<div className="hero-badge"><strong>{gridWidth} × {gridHeight}</strong><span>当前图纸格数</span></div>
|
||||
</section>
|
||||
|
||||
<section className="workspace" aria-label="拼豆图纸转换工具">
|
||||
<aside className="control-panel">
|
||||
<div className="panel-heading"><span>01</span><div><h2>图片与规格</h2><p>先决定图纸需要多少格</p></div></div>
|
||||
<label className="upload-card">
|
||||
{/* The source may be a local blob URL, so framework image optimization is not applicable. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{sourceUrl ? <img src={sourceUrl} alt="待转换的原图预览" /> : <span className="upload-placeholder">+</span>}
|
||||
<span className="upload-overlay">更换图片</span>
|
||||
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={handleUpload} />
|
||||
</label>
|
||||
<p className="file-name" title={sourceName}>{sourceName}</p>
|
||||
|
||||
<div className="field-row">
|
||||
<label><span>横向格数</span><input type="number" min="8" max="128" value={gridWidth} onChange={(e) => setGridWidth(Number(e.target.value))} /></label>
|
||||
<span className="times">×</span>
|
||||
<label><span>纵向格数</span><input type="number" min="8" max="128" value={gridHeight} onChange={(e) => setGridHeight(Number(e.target.value))} /></label>
|
||||
</div>
|
||||
|
||||
<label className="field"><span>最多使用颜色 <b>{colorLimit}</b></span><input type="range" min="2" max="64" value={colorLimit} onChange={(e) => setColorLimit(Number(e.target.value))} /></label>
|
||||
<label className="field"><span>图片适应方式</span><select value={fitMode} onChange={(e) => setFitMode(e.target.value as "cover" | "contain")}><option value="cover">裁剪铺满</option><option value="contain">完整显示</option></select></label>
|
||||
<button className="primary-button" onClick={() => convertImage()}>重新转换</button>
|
||||
<p className="privacy-note">图片只在当前浏览器中处理,不会上传到服务器。</p>
|
||||
</aside>
|
||||
|
||||
<section className="pattern-panel" id="pattern-preview">
|
||||
<div className="pattern-toolbar">
|
||||
<div><p className="section-kicker">02 / 图纸预览</p><h2>点击格子,筛选颜色</h2></div>
|
||||
<div className="zoom-control" aria-label="图纸缩放">
|
||||
<button onClick={() => setZoom((z) => Math.max(8, z - 2))} aria-label="缩小">−</button>
|
||||
<input aria-label="缩放比例" type="range" min="8" max="42" value={zoom} onChange={(e) => setZoom(Number(e.target.value))} />
|
||||
<button onClick={() => setZoom((z) => Math.min(42, z + 2))} aria-label="放大">+</button>
|
||||
<output>{zoom}px</output>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="canvas-stage">
|
||||
<div className="canvas-wrap">
|
||||
<canvas ref={canvasRef} onClick={handleCanvasClick} onMouseMove={handleCanvasMove} onMouseLeave={() => setHoveredPixel(null)} aria-label="转换后的拼豆像素图,点击格子可选择颜色" />
|
||||
{hoveredPixel && <div className="pixel-tooltip" style={{ left: hoveredPixel.left, top: hoveredPixel.top }}>
|
||||
<span className="tooltip-swatch" style={{ backgroundColor: hoveredPixel.color.hex }} />
|
||||
<strong>第 {hoveredPixel.row} 行 · 第 {hoveredPixel.column} 列</strong>
|
||||
<small>{hoveredPixel.color.code} · {hoveredPixel.color.name} · {hoveredPixel.color.hex}</small>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pattern-footer">
|
||||
<span className="status-dot" /> <span>{status}</span>
|
||||
<div className="view-options">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pattern-legend">
|
||||
<div className="legend-heading"><strong>本图所需颜色与用量</strong><span>按数量从多到少,共 {usedColors.length} 色</span></div>
|
||||
<div className="legend-list">
|
||||
{usedColors.map((color) => <button key={color.code} className={selectedCodes.has(color.code) ? "active" : ""} onClick={() => toggleColor(color.code)}>
|
||||
<i style={{ backgroundColor: color.hex }} /><strong>{color.code}</strong><span>{color.name}</span><b>{color.count} 颗</b>
|
||||
</button>)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="color-panel">
|
||||
<div className="panel-heading"><span>03</span><div><h2>分色查看</h2><p>{usedColors.length} 种颜色 · {pixels.length} 颗</p></div></div>
|
||||
<div className="search-box"><span>⌕</span><input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="输入 A6、MARD A 或 #FEAC4C" /></div>
|
||||
<div className="selection-tools">
|
||||
<label className="switch-row"><input type="checkbox" checked={onlySelected} onChange={(e) => setOnlySelected(e.target.checked)} /><span className="switch" /><span>只显示选中颜色</span></label>
|
||||
{selectedCodes.size > 0 && <button onClick={() => setSelectedCodes(new Set())}>清除 {selectedCodes.size} 项</button>}
|
||||
</div>
|
||||
<div className="color-list">
|
||||
{filteredColors.map((color) => {
|
||||
const active = selectedCodes.has(color.code);
|
||||
return <button key={color.code} className={`color-item ${active ? "active" : ""}`} onClick={() => toggleColor(color.code)} aria-pressed={active}>
|
||||
<span className="swatch" style={{ backgroundColor: color.hex }} />
|
||||
<span className="color-meta"><strong>{color.code} · {color.name}</strong><small>{color.hex}</small></span>
|
||||
<b>{color.count}<small>颗</small></b>
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
<p className="palette-note"><strong>MARD 221 基础色卡</strong>:默认使用 A/B/C/D/E/F/G/H/M 九个系列。屏幕色值仅用于近似匹配,实体豆颜色会受批次、光线和屏幕影响。</p>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section className="history-section" aria-labelledby="history-title">
|
||||
<div className="history-heading"><div><p className="section-kicker">LOCAL PATTERN ARCHIVE</p><h2 id="history-title">历史图纸</h2><p>历史保存在当前浏览器,最多保留 30 份。</p></div>
|
||||
<div className="search-box"><span>⌕</span><input value={historyQuery} onChange={(e) => setHistoryQuery(e.target.value)} placeholder="按名称或 32x32 查询" /></div>
|
||||
</div>
|
||||
{filteredHistory.length ? <div className="history-grid">{filteredHistory.map((entry) => {
|
||||
const colorCounts = new Map<string, number>();
|
||||
entry.codes.forEach((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>
|
||||
<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>;
|
||||
})}</div> : <div className="history-empty">还没有符合条件的历史图纸。转换完成后点击“保存历史”即可在这里查询。</div>}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user