feat: refine detail generation copy strategy and canvas layout
This commit is contained in:
parent
080328fbeb
commit
0fa4a14ac6
@ -179,6 +179,25 @@ const AGENT_PLANNING_PHRASE_PATTERNS = [
|
||||
/第一屏展示啥/gi,
|
||||
];
|
||||
|
||||
const TITLE_META_TERMS = [
|
||||
"核心价值",
|
||||
"场景痛点",
|
||||
"方案拆解",
|
||||
"细节证明",
|
||||
"信任收口",
|
||||
"卖点延展",
|
||||
"解决方案",
|
||||
"价值延展",
|
||||
"证据背书",
|
||||
];
|
||||
|
||||
function hasPlanningTitleMeta(value: string): boolean {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) return false;
|
||||
if (/第\s*\d+\s*屏|screen\s*\d+/i.test(text)) return true;
|
||||
return TITLE_META_TERMS.some((term) => text.includes(term));
|
||||
}
|
||||
|
||||
function stripAgentPlanningPhrases(value: string): string {
|
||||
let result = value;
|
||||
for (const pattern of AGENT_PLANNING_PHRASE_PATTERNS) {
|
||||
@ -187,6 +206,49 @@ function stripAgentPlanningPhrases(value: string): string {
|
||||
return result.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function desensitizeAudienceCopy(value: string, language: "zh" | "en"): string {
|
||||
let result = String(value || "");
|
||||
if (!result) return result;
|
||||
|
||||
if (language === "en") {
|
||||
result = result
|
||||
.replace(/\b\d{1,2}\s*(?:-|~|to)\s*\d{1,2}\s*years?\s*old\b/gi, "target users")
|
||||
.replace(
|
||||
/\b(?:for|toward|targeting)\s+[^,.;\n]{0,48}\b(?:women|men|female|male|audience|users?)\b/gi,
|
||||
"for target scenario users"
|
||||
)
|
||||
.replace(/\b(?:primary audience|target audience)\s*:\s*[^,.;\n]+/gi, "target scenario users");
|
||||
} else {
|
||||
result = result
|
||||
.replace(/\d{1,2}\s*(?:-|~|~|到|至)\s*\d{1,2}\s*岁(?:[男女]性)?/g, "目标用户")
|
||||
.replace(
|
||||
/(?:面向|针对)[^,。;\n]{0,40}(?:人群|用户|女性|男性|宝妈|白领|学生|新手)/g,
|
||||
"围绕目标场景用户"
|
||||
)
|
||||
.replace(
|
||||
/(?:主购买人群|目标人群|适用人群)\s*[::]\s*[^,。;\n]+/g,
|
||||
"目标场景用户"
|
||||
);
|
||||
}
|
||||
|
||||
return result.replace(/\s+/g, " ").replace(/,{2,}/g, ",").trim();
|
||||
}
|
||||
|
||||
const GENERIC_FLUFF_WORDS_ZH = [
|
||||
"高级感",
|
||||
"轻奢",
|
||||
"超好用",
|
||||
"巨能装",
|
||||
"适合所有场景",
|
||||
"适合所有人",
|
||||
];
|
||||
|
||||
function visibleTitleLength(value: string): number {
|
||||
return value
|
||||
.replace(/[^\p{L}\p{N}]+/gu, "")
|
||||
.trim().length;
|
||||
}
|
||||
|
||||
function buildTypographySpec(language: "zh" | "en") {
|
||||
if (language === "en") {
|
||||
return {
|
||||
@ -448,8 +510,8 @@ function buildFallbackScreens(params: {
|
||||
const screenTypeSequence: DetailScreenType[] = ["B", "A", "B", "C", "B"];
|
||||
const defaultTitles =
|
||||
language === "en"
|
||||
? ["Core Value", "Pain Resonance", "Solution Logic", "Detail Proof", "Trust Closure"]
|
||||
: ["核心价值", "场景痛点", "方案拆解", "细节证明", "信任收口"];
|
||||
? ["Long-Sit Comfort", "Waist Support", "One-Step Adjust", "Stable Structure", "Verified Trust"]
|
||||
: ["久坐不累", "贴腰承托", "一键调节", "稳固支撑", "安心可证"];
|
||||
|
||||
return Array.from({ length: count }).map((_, index) => {
|
||||
const screenType =
|
||||
@ -466,16 +528,16 @@ function buildFallbackScreens(params: {
|
||||
? "Bundle Cross-sell"
|
||||
: "搭配推荐"
|
||||
: language === "en"
|
||||
? defaultTitles[index] || `Selling Point Extension ${index + 1}`
|
||||
: defaultTitles[index] || "卖点延展";
|
||||
? defaultTitles[index] || `Comfort Upgrade ${index + 1}`
|
||||
: defaultTitles[index] || "舒适升级";
|
||||
const subtitle =
|
||||
isBundleScreen
|
||||
? language === "en"
|
||||
? `Show product pairing with ${bundleImageCount} optional companion SKU(s) to increase basket size.`
|
||||
: `联动${bundleImageCount}张搭配产品参考图,强化关联销售与加购动机。`
|
||||
: language === "en"
|
||||
? `For ${brief.targetAudience}, solve "${coreNeed}" in ${mainScenario}.`
|
||||
: `面向${brief.targetAudience},在${mainScenario}场景解决“${coreNeed}”。`;
|
||||
? `Solve "${coreNeed}" in ${mainScenario} with clear benefit-focused messaging.`
|
||||
: `在${mainScenario}场景解决“${coreNeed}”,突出结果导向表达。`;
|
||||
const copy =
|
||||
isBundleScreen
|
||||
? language === "en"
|
||||
@ -529,7 +591,8 @@ function buildFallbackScreens(params: {
|
||||
`E-commerce detail frame ${index + 1}, objective: ${objective}.`,
|
||||
`Fixed vertical canvas 790x1300 pixels.`,
|
||||
`Keep exact product identity consistent across all frames.`,
|
||||
`Main scene: ${mainScenario}; audience: ${brief.targetAudience}; core demand: ${coreNeed}.`,
|
||||
`Main scene: ${mainScenario}; core demand: ${coreNeed}.`,
|
||||
"Audience profile is internal strategy only; do not expose explicit age/gender labels in visible copy.",
|
||||
`Positioning statement: ${positioning}.`,
|
||||
`Global style: ${globalStyle.designLanguage}.`,
|
||||
`Typography: ${globalStyle.typography.fontFamily}; ${globalStyle.typography.title}; ${globalStyle.typography.subtitle}; ${globalStyle.typography.body}.`,
|
||||
@ -551,7 +614,8 @@ function buildFallbackScreens(params: {
|
||||
`电商详情页第${index + 1}屏,目标:${objective}。`,
|
||||
"画布固定竖版 790x1300 像素。",
|
||||
"全套保持同一产品外观一致(结构、材质、颜色、比例、Logo)。",
|
||||
`主场景:${mainScenario};人群:${brief.targetAudience};核心需求:${coreNeed}。`,
|
||||
`主场景:${mainScenario};核心需求:${coreNeed}。`,
|
||||
"人群画像仅用于内部策略,画面可见文案不得直写年龄/性别标签。",
|
||||
`一句话定位:${positioning}。`,
|
||||
`全局风格:${globalStyle.designLanguage}。`,
|
||||
`字体规范:${globalStyle.typography.fontFamily};${globalStyle.typography.title};${globalStyle.typography.subtitle};${globalStyle.typography.body}。`,
|
||||
@ -627,7 +691,8 @@ function buildFallbackPlan(params: {
|
||||
|
||||
function sanitizeScreen(
|
||||
raw: unknown,
|
||||
fallback: DetailPlanScreen
|
||||
fallback: DetailPlanScreen,
|
||||
language: "zh" | "en"
|
||||
): DetailPlanScreen {
|
||||
const source = (raw || {}) as Record<string, unknown>;
|
||||
const safeText = (value: unknown, defaultValue: string) => {
|
||||
@ -636,7 +701,10 @@ function sanitizeScreen(
|
||||
};
|
||||
|
||||
const fabeSource = (source.fabe || {}) as Record<string, unknown>;
|
||||
const visualPrompt = safeText(source.visualPrompt, fallback.visualPrompt);
|
||||
const visualPrompt = desensitizeAudienceCopy(
|
||||
safeText(source.visualPrompt, fallback.visualPrompt),
|
||||
language
|
||||
);
|
||||
const normalizeTitle = (value: string) => {
|
||||
const cleaned = stripAgentPlanningPhrases(
|
||||
value
|
||||
@ -646,7 +714,9 @@ function sanitizeScreen(
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
);
|
||||
return cleaned || fallback.title;
|
||||
if (!cleaned) return fallback.title;
|
||||
if (hasPlanningTitleMeta(cleaned)) return fallback.title;
|
||||
return cleaned;
|
||||
};
|
||||
const normalizeSubtitle = (value: string) =>
|
||||
stripAgentPlanningPhrases(
|
||||
@ -656,9 +726,10 @@ function sanitizeScreen(
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
);
|
||||
const normalizeCopy = (value: string) => stripAgentPlanningPhrases(value) || fallback.copy;
|
||||
const normalizeCopy = (value: string) =>
|
||||
desensitizeAudienceCopy(stripAgentPlanningPhrases(value) || fallback.copy, language);
|
||||
const normalizeLayoutNote = (value: string) =>
|
||||
stripAgentPlanningPhrases(value) || fallback.layoutNote;
|
||||
desensitizeAudienceCopy(stripAgentPlanningPhrases(value) || fallback.layoutNote, language);
|
||||
const rawScreenType = String(source.screenType || "").trim().toUpperCase();
|
||||
const screenType: DetailScreenType =
|
||||
rawScreenType === "A" || rawScreenType === "B" || rawScreenType === "C"
|
||||
@ -667,19 +738,23 @@ function sanitizeScreen(
|
||||
return {
|
||||
index: fallback.index,
|
||||
screenType,
|
||||
objective: safeText(source.objective, fallback.objective),
|
||||
title: normalizeTitle(safeText(source.title, fallback.title)),
|
||||
subtitle: normalizeSubtitle(safeText(source.subtitle, fallback.subtitle)) || fallback.subtitle,
|
||||
objective: desensitizeAudienceCopy(safeText(source.objective, fallback.objective), language),
|
||||
title: desensitizeAudienceCopy(normalizeTitle(safeText(source.title, fallback.title)), language),
|
||||
subtitle:
|
||||
desensitizeAudienceCopy(
|
||||
normalizeSubtitle(safeText(source.subtitle, fallback.subtitle)) || fallback.subtitle,
|
||||
language
|
||||
) || fallback.subtitle,
|
||||
copy: normalizeCopy(safeText(source.copy, fallback.copy)),
|
||||
layoutNote: normalizeLayoutNote(safeText(source.layoutNote, fallback.layoutNote)),
|
||||
useModel:
|
||||
typeof source.useModel === "boolean" ? source.useModel : fallback.useModel,
|
||||
modelReason: safeText(source.modelReason, fallback.modelReason),
|
||||
modelReason: desensitizeAudienceCopy(safeText(source.modelReason, fallback.modelReason), language),
|
||||
bundleShowcase:
|
||||
typeof source.bundleShowcase === "boolean"
|
||||
? source.bundleShowcase
|
||||
: fallback.bundleShowcase,
|
||||
bundleReason: safeText(source.bundleReason, fallback.bundleReason),
|
||||
bundleReason: desensitizeAudienceCopy(safeText(source.bundleReason, fallback.bundleReason), language),
|
||||
visualPrompt: visualPrompt.includes("790x1300")
|
||||
? visualPrompt
|
||||
: `${visualPrompt} 画布固定 790x1300 像素。`,
|
||||
@ -695,11 +770,12 @@ function sanitizeScreen(
|
||||
function sanitizePlannedScreens(
|
||||
raw: unknown,
|
||||
fallbackScreens: DetailPlanScreen[],
|
||||
language: "zh" | "en"
|
||||
): DetailPlanScreen[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return fallbackScreens;
|
||||
}
|
||||
return fallbackScreens.map((fallback, index) => sanitizeScreen(raw[index], fallback));
|
||||
return fallbackScreens.map((fallback, index) => sanitizeScreen(raw[index], fallback, language));
|
||||
}
|
||||
|
||||
function sanitizeGlobalStyle(
|
||||
@ -781,9 +857,33 @@ function evaluatePlanQuality(params: {
|
||||
issues.push("存在分镜FABE不完整。");
|
||||
}
|
||||
|
||||
const hasScreenIndexInTitle = screens.some((screen) => /第\s*\d+\s*屏|screen\s*\d+/i.test(screen.title));
|
||||
if (hasScreenIndexInTitle) {
|
||||
issues.push("主标题不应出现“第X屏/Screen X”类标记。");
|
||||
const hasMetaTitle = screens.some((screen) => hasPlanningTitleMeta(screen.title));
|
||||
if (hasMetaTitle) {
|
||||
issues.push("主标题不应使用规划节奏词(如“方案拆解/细节证明/信任收口”等)。");
|
||||
}
|
||||
|
||||
const titleLengthOutOfRange = screens.some((screen) => {
|
||||
const len = visibleTitleLength(screen.title);
|
||||
return len < 4 || len > 10;
|
||||
});
|
||||
if (titleLengthOutOfRange) {
|
||||
issues.push("存在分镜主标题不在4-10字区间。");
|
||||
}
|
||||
|
||||
const subtitleTooLong = screens.some((screen) => {
|
||||
const normalized = screen.subtitle.replace(/\s+/g, "");
|
||||
return normalized.length > 38 || /[。!?.!?].+[。!?.!?]/.test(screen.subtitle);
|
||||
});
|
||||
if (subtitleTooLong) {
|
||||
issues.push("存在分镜副标题过长或多句,建议保持一条结果句。");
|
||||
}
|
||||
|
||||
const hasGenericFluff = screens.some((screen) => {
|
||||
const fullText = `${screen.title} ${screen.subtitle} ${screen.copy}`;
|
||||
return GENERIC_FLUFF_WORDS_ZH.some((word) => fullText.includes(word));
|
||||
});
|
||||
if (hasGenericFluff) {
|
||||
issues.push("存在空泛形容词(如“高级感/轻奢/超好用”),需改为可感知结果。");
|
||||
}
|
||||
|
||||
if (hasModelImage) {
|
||||
@ -855,6 +955,8 @@ async function runDualDirectorPlanPreflight(params: {
|
||||
"Role A: Art Director (visual hierarchy, typography, spacing rhythm, composition).",
|
||||
"Role B: Marketing Director (pain point to benefit logic, trust proof, conversion clarity).",
|
||||
"Forbidden wording in title/subtitle/copy: \"Screen X\", \"This screen shows\", any planning meta phrasing.",
|
||||
"Title must not use planning rhythm terms like: core value / pain resonance / solution logic / detail proof / trust closure.",
|
||||
"Do not expose explicit age/gender labels in visible copy (e.g., \"25-35 female\"); rewrite to scenario-based wording.",
|
||||
"Must keep canvas 790x1300, unified typography hierarchy, and consistent style.",
|
||||
params.hasModelImage
|
||||
? "Model image is provided: only use where needed, keep identity consistent."
|
||||
@ -875,6 +977,8 @@ async function runDualDirectorPlanPreflight(params: {
|
||||
"角色A:平面设计总监(视觉层级、字体统一、留白节奏、构图平衡)。",
|
||||
"角色B:营销总监(痛点到买点转译、证据可信度、转化路径清晰度)。",
|
||||
"禁用表达:主标题/副标题/正文中禁止出现“第X屏”“本屏展示”“这一屏”等规划词。",
|
||||
"禁用表达:主标题禁止出现“核心价值/场景痛点/方案拆解/细节证明/信任收口/卖点延展”等节奏词。",
|
||||
"禁用表达:对外文案不得直写具体年龄/性别标签(如“25-35岁女性”),必须改为场景化表达。",
|
||||
"必须维持 790x1300 固定规格、全套统一字体层级与统一风格。",
|
||||
params.hasModelImage
|
||||
? "已提供模特图:仅在必要分镜使用且保持同一人物一致性。"
|
||||
@ -940,7 +1044,7 @@ async function runDualDirectorPlanPreflight(params: {
|
||||
rewriteApplied = true;
|
||||
}
|
||||
if (Array.isArray(parsed?.revisedScreens) && parsed.revisedScreens.length > 0) {
|
||||
candidateScreens = sanitizePlannedScreens(parsed.revisedScreens, params.screens);
|
||||
candidateScreens = sanitizePlannedScreens(parsed.revisedScreens, params.screens, params.language);
|
||||
rewriteApplied = true;
|
||||
}
|
||||
|
||||
@ -1099,16 +1203,23 @@ export async function POST(request: Request) {
|
||||
`5) 结构遵循“黄金五屏原则”:屏1-2核心卖点,屏3-4差异强化,屏5证据背书;更多屏作为延展。\n` +
|
||||
`6) 每屏都要有 FABE 闭环(B利益/F特征/A优势/E证据),且文案符合电商转化表达。\n` +
|
||||
`7) 文案用词需贴近用户语言,不写空泛形容词,不泄露系统流程。\n` +
|
||||
`7.1) 必须执行“卖点→用户任务(JTBD)→买点句”转译,买点句优先用“不用___也能___ / 在___场景也能___”句式。\n` +
|
||||
`7.2) 核心需求最多保留3-5条(1条必备 + 1-2条期待 + 1条魅力),避免页面过载。\n` +
|
||||
`7.3) 每个核心卖点需覆盖“体验/社交/实用”三维中的至少2维,并落地到 copy 或 layoutNote。\n` +
|
||||
`8) 对每屏输出 useModel 与 modelReason:useModel=true 表示该屏必须融入固定模特图人物;false 表示该屏禁止出现人物。\n` +
|
||||
`9) 对每屏输出 screenType(A/B/C):A=技术公信,B=场景共鸣,C=细节佐证。\n` +
|
||||
`9.1) 前3屏默认节奏:屏1=场景痛点共鸣(B),屏2=买点总括,屏3=证据验证(对比/实测/口碑/认证)。\n` +
|
||||
`10) 主标题必须是卖点语义,不得出现“第1屏/Screen 1”等编号标识,建议4-10字。\n` +
|
||||
`10.1) 主标题禁止使用规划节奏词:核心价值/场景痛点/方案拆解/细节证明/信任收口/卖点延展。\n` +
|
||||
`11) 在 globalStyle.typography 中写明明确字号与字重(示例:主标题64px/700,副标题34px/600,正文24px/500),并在每屏 layoutNote 里落实排版层级。\n` +
|
||||
`11.1) 副标题必须为单句结果句,禁止写成长段说明。\n` +
|
||||
`12) 对外文案禁止直写具体年龄/性别标签(如“25-35岁女性”),改为场景化表达(如“久坐办公场景用户”)。\n` +
|
||||
(hasModelImage
|
||||
? `12) 已提供模特图:涉及人物场景时必须复用同一模特(人脸、发型、体型、肤色与服装风格一致),不得替换其他人物。\n`
|
||||
: `12) 未提供模特图:所有分镜均设置 useModel=false。\n`) +
|
||||
? `13) 已提供模特图:涉及人物场景时必须复用同一模特(人脸、发型、体型、肤色与服装风格一致),不得替换其他人物。\n`
|
||||
: `13) 未提供模特图:所有分镜均设置 useModel=false。\n`) +
|
||||
(hasBundleImages
|
||||
? `13) 已提供搭配产品图:必须新增且仅新增1屏“场景化产品搭配展示”,该屏设定 bundleShowcase=true,并在 bundleReason 说明搭配策略;其余屏设为 bundleShowcase=false。\n`
|
||||
: `13) 未提供搭配产品图:所有分镜均设置 bundleShowcase=false。\n`) +
|
||||
? `14) 已提供搭配产品图:必须新增且仅新增1屏“场景化产品搭配展示”,该屏设定 bundleShowcase=true,并在 bundleReason 说明搭配策略;其余屏设为 bundleShowcase=false。\n`
|
||||
: `14) 未提供搭配产品图:所有分镜均设置 bundleShowcase=false。\n`) +
|
||||
`输出格式:\n` +
|
||||
`{"summary":"简述","globalStyle":{"designLanguage":"一句话风格方向","typography":{"fontFamily":"字体家族","title":"主标题规范","subtitle":"副标题规范","body":"正文规范"},"layout":{"grid":"栅格","safeArea":"安全区","rhythm":"留白节奏"},"palette":"配色规范"},"screens":[{"index":1,"screenType":"B","objective":"本屏目标","title":"4-8字标题","subtitle":"一句副标题","copy":"正文文案","layoutNote":"排版说明","useModel":false,"modelReason":"是否需要人物及原因","bundleShowcase":false,"bundleReason":"是否为搭配展示页及原因","fabe":{"b":"利益","f":"特征","a":"优势","e":"证据"},"visualPrompt":"用于生图的完整提示词,必须包含790x1300与统一字体排版"}]}`,
|
||||
temperature: 0.25,
|
||||
@ -1147,7 +1258,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const parsed = extractJsonBlock<{ summary?: unknown; globalStyle?: unknown; screens?: unknown }>(text || "");
|
||||
const globalStyleInitial = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle);
|
||||
const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens).map((item) =>
|
||||
const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens, language).map((item) =>
|
||||
hasModelImage
|
||||
? item
|
||||
: {
|
||||
@ -1220,9 +1331,12 @@ export async function POST(request: Request) {
|
||||
};
|
||||
const prompts = screens.map((item) => item.visualPrompt);
|
||||
const summaryRaw = String(parsed?.summary || "").trim();
|
||||
const summary = !shouldFallback
|
||||
? summaryRaw || fallback.summary
|
||||
: `${summaryRaw || fallback.summary}\n双总监预审未通过,已自动回退到稳定分镜模板。`;
|
||||
const summary = desensitizeAudienceCopy(
|
||||
!shouldFallback
|
||||
? summaryRaw || fallback.summary
|
||||
: `${summaryRaw || fallback.summary}\n双总监预审未通过,已自动回退到稳定分镜模板。`,
|
||||
language
|
||||
);
|
||||
|
||||
const response: DetailPlanResponse = {
|
||||
summary,
|
||||
|
||||
279
app/globals.css
279
app/globals.css
@ -98,52 +98,55 @@
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
--animate-caret-blink: caret-blink 1.25s ease-out infinite;
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
@keyframes caret-blink {
|
||||
0%,
|
||||
70%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
20%,
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
--color-sidebar: var(--sidebar-background);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
/* utility classes */
|
||||
@utility container {
|
||||
margin-inline: auto;
|
||||
padding-inline: 2rem;
|
||||
@media (width >= --theme(--breakpoint-sm)) {
|
||||
max-width: none;
|
||||
}
|
||||
@media (width >= 1400px) {
|
||||
max-width: 1400px;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
@ -156,200 +159,24 @@
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
border-color: var(--border, currentColor);
|
||||
}
|
||||
}
|
||||
|
||||
@utility text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
@utility -webkit-overflow-scrolling-touch {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
@utility touch-pan-y {
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
@utility overscroll-behavior-contain {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
.step {
|
||||
counter-increment: step;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
.step:before {
|
||||
@apply absolute w-9 h-9 bg-muted rounded-full font-mono font-medium text-center text-base inline-flex items-center justify-center -indent-px border-4 border-background;
|
||||
@apply ml-[-50px] mt-[-4px];
|
||||
content: counter(step);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
* {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
*[class^="text-"] {
|
||||
color: transparent;
|
||||
@apply rounded-md bg-foreground/20 select-none animate-pulse;
|
||||
}
|
||||
|
||||
.skeleton-bg {
|
||||
@apply bg-foreground/10;
|
||||
}
|
||||
|
||||
.skeleton-div {
|
||||
@apply bg-foreground/20 animate-pulse;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cm-editor,
|
||||
.cm-gutters {
|
||||
@apply bg-background! dark:bg-zinc-800! outline-hidden! selection:bg-zinc-900!;
|
||||
}
|
||||
|
||||
.ͼo.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,
|
||||
.ͼo.cm-selectionBackground,
|
||||
.ͼo.cm-content::selection {
|
||||
@apply bg-zinc-200! dark:bg-zinc-900!;
|
||||
}
|
||||
|
||||
.cm-activeLine,
|
||||
.cm-activeLineGutter {
|
||||
@apply bg-transparent!;
|
||||
}
|
||||
|
||||
.cm-activeLine {
|
||||
@apply rounded-r-sm!;
|
||||
}
|
||||
|
||||
.cm-lineNumbers {
|
||||
@apply min-w-7;
|
||||
}
|
||||
|
||||
.cm-foldGutter {
|
||||
@apply min-w-3;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
a[href],
|
||||
summary,
|
||||
button:not(:disabled),
|
||||
label[for],
|
||||
[role="button"],
|
||||
[role="link"],
|
||||
[role="menuitem"],
|
||||
[role="option"],
|
||||
[role="radio"],
|
||||
[role="switch"],
|
||||
[role="tab"],
|
||||
[role="checkbox"],
|
||||
[data-radix-collection-item],
|
||||
input[type="checkbox"],
|
||||
input[type="radio"],
|
||||
input[type="range"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
[aria-disabled="true"],
|
||||
[data-disabled] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-lineNumbers .cm-activeLineGutter {
|
||||
@apply rounded-l-sm!;
|
||||
}
|
||||
|
||||
.suggestion-highlight {
|
||||
@apply bg-blue-200 hover:bg-blue-300 dark:hover:bg-blue-400/50 dark:text-blue-50 dark:bg-blue-500/40;
|
||||
}
|
||||
|
||||
/* minimal scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: --alpha(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* firefox scrollbar styling */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* AI 思考动画 */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
.pattern-grid-lg {
|
||||
background-image: linear-gradient(to right, currentColor 1px, transparent 1px),
|
||||
linear-gradient(to bottom, currentColor 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -11,7 +11,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Eraser,
|
||||
@ -23,6 +23,14 @@ import {
|
||||
Trash2,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
Download,
|
||||
Image as ImageIcon,
|
||||
MoreHorizontal,
|
||||
Undo2,
|
||||
Redo2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@ -152,6 +160,14 @@ export function RealtimeCanvasDialog({
|
||||
const metricsQueueRef = useRef<any[]>([]);
|
||||
const sessionIdRef = useRef(`rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
||||
const hasLoadedInitialImageRef = useRef(false);
|
||||
|
||||
// History Management
|
||||
const historyRef = useRef<ImageData[]>([]);
|
||||
const historyIndexRef = useRef(-1);
|
||||
const cursorRef = useRef<HTMLDivElement>(null);
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
|
||||
const [config, setConfig] = useState<RealtimeConfig>(defaultRealtimeConfig);
|
||||
const [status, setStatus] = useState<RealtimeStatus>("idle");
|
||||
const [statusMessage, setStatusMessage] = useState("等待连接");
|
||||
@ -198,44 +214,31 @@ export function RealtimeCanvasDialog({
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const resetCanvas = useCallback(() => {
|
||||
const saveHistory = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!ctx) return;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.fillStyle = "#d8d8db";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.restore();
|
||||
|
||||
// If we are not at the end of history, truncate future history
|
||||
if (historyIndexRef.current < historyRef.current.length - 1) {
|
||||
historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1);
|
||||
}
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
historyRef.current.push(imageData);
|
||||
historyIndexRef.current = historyRef.current.length - 1;
|
||||
|
||||
// Limit history size
|
||||
if (historyRef.current.length > 50) {
|
||||
historyRef.current.shift();
|
||||
historyIndexRef.current--;
|
||||
}
|
||||
|
||||
setCanUndo(historyIndexRef.current > 0);
|
||||
setCanRedo(false);
|
||||
}, []);
|
||||
|
||||
const drawInitialImage = useCallback(async () => {
|
||||
if (!open || !initialImageUrl || hasLoadedInitialImageRef.current) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
image.onload = () => resolve();
|
||||
image.onerror = () => reject(new Error("加载初始图片失败"));
|
||||
image.src = initialImageUrl;
|
||||
});
|
||||
|
||||
const scale = Math.min(canvas.width / image.width, canvas.height / image.height);
|
||||
const drawWidth = image.width * scale;
|
||||
const drawHeight = image.height * scale;
|
||||
const offsetX = (canvas.width - drawWidth) / 2;
|
||||
const offsetY = (canvas.height - drawHeight) / 2;
|
||||
|
||||
ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
|
||||
hasLoadedInitialImageRef.current = true;
|
||||
dirtyRef.current = true;
|
||||
}, [initialImageUrl, open]);
|
||||
|
||||
const flushFrame = useCallback(
|
||||
async (force = false) => {
|
||||
if (!open) return;
|
||||
@ -309,6 +312,82 @@ export function RealtimeCanvasDialog({
|
||||
[adaptiveThrottleMs, flushFrame]
|
||||
);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (historyIndexRef.current <= 0) return;
|
||||
|
||||
historyIndexRef.current--;
|
||||
const imageData = historyRef.current[historyIndexRef.current];
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
|
||||
if (canvas && ctx && imageData) {
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
setCanUndo(historyIndexRef.current > 0);
|
||||
setCanRedo(historyIndexRef.current < historyRef.current.length - 1);
|
||||
scheduleFlush(true);
|
||||
}
|
||||
}, [scheduleFlush]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (historyIndexRef.current >= historyRef.current.length - 1) return;
|
||||
|
||||
historyIndexRef.current++;
|
||||
const imageData = historyRef.current[historyIndexRef.current];
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
|
||||
if (canvas && ctx && imageData) {
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
setCanUndo(historyIndexRef.current > 0);
|
||||
setCanRedo(historyIndexRef.current < historyRef.current.length - 1);
|
||||
scheduleFlush(true);
|
||||
}
|
||||
}, [scheduleFlush]);
|
||||
|
||||
const resetCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.fillStyle = "#d8d8db";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.restore();
|
||||
|
||||
// Clear history and save initial state
|
||||
historyRef.current = [];
|
||||
historyIndexRef.current = -1;
|
||||
saveHistory();
|
||||
}, [saveHistory]);
|
||||
|
||||
const drawInitialImage = useCallback(async () => {
|
||||
if (!open || !initialImageUrl || hasLoadedInitialImageRef.current) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
image.onload = () => resolve();
|
||||
image.onerror = () => reject(new Error("加载初始图片失败"));
|
||||
image.src = initialImageUrl;
|
||||
});
|
||||
|
||||
const scale = Math.min(canvas.width / image.width, canvas.height / image.height);
|
||||
const drawWidth = image.width * scale;
|
||||
const drawHeight = image.height * scale;
|
||||
const offsetX = (canvas.width - drawWidth) / 2;
|
||||
const offsetY = (canvas.height - drawHeight) / 2;
|
||||
|
||||
ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
|
||||
hasLoadedInitialImageRef.current = true;
|
||||
dirtyRef.current = true;
|
||||
saveHistory(); // Save initial image state
|
||||
}, [initialImageUrl, open, saveHistory]);
|
||||
|
||||
const initializeCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const wrap = canvasWrapRef.current;
|
||||
@ -376,16 +455,38 @@ export function RealtimeCanvasDialog({
|
||||
[getCanvasPoint]
|
||||
);
|
||||
|
||||
const handlePointerLeave = useCallback(() => {
|
||||
if (cursorRef.current) {
|
||||
cursorRef.current.style.opacity = '0';
|
||||
}
|
||||
pointerDownRef.current = false;
|
||||
lastPointRef.current = null;
|
||||
scheduleFlush(true);
|
||||
}, [scheduleFlush]);
|
||||
|
||||
const handlePointerEnter = useCallback(() => {
|
||||
if (cursorRef.current) {
|
||||
cursorRef.current.style.opacity = '1';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
if (!pointerDownRef.current) return;
|
||||
// Update cursor
|
||||
const point = getCanvasPoint(event);
|
||||
if (point && cursorRef.current) {
|
||||
cursorRef.current.style.transform = `translate(${point.x - brushSize / 2}px, ${point.y - brushSize / 2}px)`;
|
||||
cursorRef.current.style.width = `${brushSize}px`;
|
||||
cursorRef.current.style.height = `${brushSize}px`;
|
||||
}
|
||||
|
||||
if (!pointerDownRef.current) return;
|
||||
if (!point || !lastPointRef.current) return;
|
||||
drawStroke(lastPointRef.current, point);
|
||||
lastPointRef.current = point;
|
||||
scheduleFlush();
|
||||
},
|
||||
[drawStroke, getCanvasPoint, scheduleFlush]
|
||||
[drawStroke, getCanvasPoint, scheduleFlush, brushSize]
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
@ -397,11 +498,37 @@ export function RealtimeCanvasDialog({
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
saveHistory(); // Save state after stroke
|
||||
scheduleFlush(true);
|
||||
},
|
||||
[scheduleFlush]
|
||||
[scheduleFlush, saveHistory]
|
||||
);
|
||||
|
||||
// Shortcuts
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Undo/Redo
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
redo();
|
||||
} else {
|
||||
undo();
|
||||
}
|
||||
}
|
||||
// Brush Size
|
||||
if (e.key === '[') {
|
||||
setBrushSize(prev => Math.max(2, prev - 2));
|
||||
}
|
||||
if (e.key === ']') {
|
||||
setBrushSize(prev => Math.min(50, prev + 2));
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, undo, redo]);
|
||||
|
||||
const uploadPreviewToCanvas = useCallback(async () => {
|
||||
if (!previewUrl) {
|
||||
toast.error("请先完成一帧实时预览");
|
||||
@ -612,167 +739,215 @@ export function RealtimeCanvasDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="h-[90vh] w-[96vw] max-w-[1540px] overflow-hidden rounded-[24px] border border-zinc-200/80 bg-[#f2f3f5] p-0 shadow-2xl">
|
||||
<DialogHeader className="border-b border-zinc-200/80 bg-white/70 px-6 py-4 backdrop-blur">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<DialogTitle className="text-[24px] font-semibold tracking-tight text-zinc-900">
|
||||
实时画布
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 text-[13px] text-zinc-500">
|
||||
左侧绘制草图,右侧实时渲染,整体风格与无限画布保持一致
|
||||
</DialogDescription>
|
||||
<div className="mt-2 text-[12px] text-zinc-500">{statusMessage}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center gap-1.5 rounded-full px-3 text-[12px] font-medium",
|
||||
statusBadge.className
|
||||
)}
|
||||
<DialogContent className="h-[95vh] w-[98vw] max-w-[1800px] overflow-hidden rounded-[20px] border-0 bg-background p-0 shadow-2xl sm:rounded-[20px] md:rounded-[24px]">
|
||||
{/* Top Header - Minimal */}
|
||||
<div className="absolute left-0 right-0 top-0 z-50 flex h-14 items-center justify-between px-6 bg-gradient-to-b from-black/20 to-transparent pointer-events-none">
|
||||
<div className="pointer-events-auto flex items-center gap-3 rounded-full bg-background/80 backdrop-blur-md px-4 py-1.5 border border-border/50 shadow-sm">
|
||||
<span className="font-semibold text-sm">Realtime Canvas</span>
|
||||
<div className="h-4 w-[1px] bg-border" />
|
||||
<span className={cn("flex items-center gap-1.5 text-xs font-medium", statusBadge.className, "bg-transparent p-0")}>
|
||||
<div className={cn("h-2 w-2 rounded-full", status === "live" ? "bg-emerald-500 animate-pulse" : status === "error" ? "bg-rose-500" : "bg-zinc-400")} />
|
||||
{statusBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full bg-background/50 hover:bg-background/80 backdrop-blur-md text-foreground/80 hover:text-foreground transition-colors"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{status === "error" ? <WifiOff className="h-3.5 w-3.5" /> : <Wifi className="h-3.5 w-3.5" />}
|
||||
{statusBadge.label}
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Canvas Area - Split View */}
|
||||
<div className="relative flex h-full w-full bg-[#f4f4f5] dark:bg-[#09090b]">
|
||||
{/* Left: Input Canvas */}
|
||||
<div className="relative flex-1 flex flex-col items-center justify-center overflow-hidden border-r border-border/10">
|
||||
<div ref={canvasWrapRef} className="relative h-full w-full flex items-center justify-center bg-white dark:bg-[#18181b]">
|
||||
<div className="absolute inset-0 pattern-grid-lg opacity-[0.03] pointer-events-none" />
|
||||
|
||||
<div className="relative">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="touch-none shadow-sm block cursor-none"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
/>
|
||||
{/* Custom Brush Cursor */}
|
||||
<div
|
||||
ref={cursorRef}
|
||||
className="pointer-events-none absolute top-0 left-0 rounded-full border border-zinc-500/50 bg-zinc-500/10 backdrop-invert transition-opacity duration-75 z-10"
|
||||
style={{
|
||||
width: brushSize,
|
||||
height: brushSize,
|
||||
opacity: 0,
|
||||
willChange: 'transform, width, height'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-4 left-4 pointer-events-none z-10">
|
||||
<span className="text-xs font-medium text-zinc-400 bg-black/5 px-2 py-1 rounded-md backdrop-blur-sm">INPUT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Output Preview */}
|
||||
<div className="relative flex-1 flex flex-col items-center justify-center overflow-hidden bg-zinc-50 dark:bg-[#0c0c0e]">
|
||||
<div className="relative h-full w-full flex items-center justify-center">
|
||||
{previewUrl ? (
|
||||
<img src={previewUrl} alt="实时预览" className="max-h-full max-w-full object-contain shadow-sm" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-zinc-400 gap-3">
|
||||
{status === "connecting" ? (
|
||||
<>
|
||||
<Loader2 className="h-8 w-8 animate-spin opacity-50" />
|
||||
<span className="text-sm">Connecting...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-8 w-8 opacity-20" />
|
||||
<span className="text-sm">Start drawing to generate</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute top-4 right-4 pointer-events-none">
|
||||
<span className="text-xs font-medium text-zinc-400 bg-black/5 px-2 py-1 rounded-md backdrop-blur-sm">OUTPUT</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Toolbar - Left (Tools) */}
|
||||
<div className="absolute left-6 top-1/2 -translate-y-1/2 flex flex-col gap-4 z-20 pointer-events-none">
|
||||
<div className="pointer-events-auto flex flex-col gap-2 p-2 bg-background/90 backdrop-blur-xl border border-border/50 shadow-xl rounded-2xl">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-10 w-10 rounded-xl hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
onClick={undo}
|
||||
disabled={!canUndo}
|
||||
title="Undo (Cmd+Z)"
|
||||
>
|
||||
<Undo2 className="h-5 w-5 opacity-70" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-10 w-10 rounded-xl hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
onClick={redo}
|
||||
disabled={!canRedo}
|
||||
title="Redo (Cmd+Shift+Z)"
|
||||
>
|
||||
<Redo2 className="h-5 w-5 opacity-70" />
|
||||
</Button>
|
||||
|
||||
<div className="h-[1px] bg-border/50 mx-2 my-1" />
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant={brushMode === "brush" ? "secondary" : "ghost"}
|
||||
className={cn("h-10 w-10 rounded-xl transition-all", brushMode === "brush" && "bg-primary text-primary-foreground shadow-sm")}
|
||||
onClick={() => setBrushMode("brush")}
|
||||
title="Brush"
|
||||
>
|
||||
<Paintbrush className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={brushMode === "eraser" ? "secondary" : "ghost"}
|
||||
className={cn("h-10 w-10 rounded-xl transition-all", brushMode === "eraser" && "bg-primary text-primary-foreground shadow-sm")}
|
||||
onClick={() => setBrushMode("eraser")}
|
||||
>
|
||||
<Eraser className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="h-[1px] bg-border/50 mx-2 my-1" />
|
||||
|
||||
<div className="flex flex-col items-center gap-2 py-2">
|
||||
<span className="text-[10px] font-medium text-muted-foreground">{brushSize}px</span>
|
||||
<Slider
|
||||
orientation="vertical"
|
||||
value={[brushSize]}
|
||||
min={2}
|
||||
max={50}
|
||||
step={1}
|
||||
onValueChange={([val]) => setBrushSize(val)}
|
||||
className="h-24 w-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-[1px] bg-border/50 mx-2 my-1" />
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-10 w-10 rounded-xl hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={clearSketch}
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Toolbar - Bottom (Prompt) */}
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 w-full max-w-2xl px-4 z-20 pointer-events-none">
|
||||
<div className="pointer-events-auto flex flex-col gap-2 p-1.5 bg-background/80 backdrop-blur-xl border border-border/50 shadow-2xl rounded-[24px]">
|
||||
<div className="flex items-center gap-2 pl-2 pr-1.5 py-1">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<Input
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onBlur={() => scheduleFlush(true)}
|
||||
placeholder="Describe what you want to create..."
|
||||
className="flex-1 h-10 border-0 bg-transparent focus-visible:ring-0 px-2 text-base shadow-none"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9 rounded-xl px-4 font-medium"
|
||||
onClick={() => scheduleFlush(true)}
|
||||
disabled={status === 'connecting'}
|
||||
>
|
||||
{status === 'connecting' ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Generate'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-[10px] text-white/50 bg-black/40 px-3 py-1 rounded-full backdrop-blur-sm">
|
||||
{statusMessage} • Latency: {adaptiveThrottleMs}ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Toolbar - Right (Actions) */}
|
||||
<div className="absolute right-6 top-6 flex flex-col gap-3 z-20 pointer-events-none">
|
||||
<div className="pointer-events-auto flex flex-col gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={forceReconnect}
|
||||
className="h-9 rounded-xl border-zinc-300 bg-white px-3"
|
||||
className="h-10 px-4 rounded-full shadow-lg bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
onClick={uploadPreviewToCanvas}
|
||||
disabled={!previewUrl || isApplying}
|
||||
>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
重连
|
||||
{isApplying ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
||||
Send to Canvas
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col px-5 pb-5 pt-4">
|
||||
<div className="grid min-h-0 flex-1 grid-cols-2 gap-4">
|
||||
<div className="flex min-h-0 flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white/95 shadow-sm">
|
||||
<div className="flex h-12 items-center justify-between border-b border-zinc-200 px-4">
|
||||
<span className="text-[15px] font-medium text-zinc-800">草图画布</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant={brushMode === "brush" ? "default" : "outline"}
|
||||
className="h-8 w-8 rounded-lg"
|
||||
onClick={() => setBrushMode("brush")}
|
||||
>
|
||||
<Paintbrush className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant={brushMode === "eraser" ? "default" : "outline"}
|
||||
className="h-8 w-8 rounded-lg"
|
||||
onClick={() => setBrushMode("eraser")}
|
||||
>
|
||||
<Eraser className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-8 w-8 rounded-lg"
|
||||
onClick={clearSketch}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={canvasWrapRef} className="min-h-0 flex-1 bg-[#d0d1d4] p-3">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="h-full w-full touch-none rounded-xl border border-zinc-300/80 bg-[#d0d1d4]"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-12 items-center gap-3 border-t border-zinc-200 px-4">
|
||||
<Label htmlFor="rt-brush-size" className="text-[12px] font-medium text-zinc-500">
|
||||
画笔粗细
|
||||
</Label>
|
||||
<input
|
||||
id="rt-brush-size"
|
||||
type="range"
|
||||
min={2}
|
||||
max={30}
|
||||
step={1}
|
||||
value={brushSize}
|
||||
onChange={(event) => setBrushSize(Number(event.target.value))}
|
||||
className="h-2 w-40 accent-zinc-800"
|
||||
/>
|
||||
<span className="text-[12px] text-zinc-500">{brushSize}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white/95 shadow-sm">
|
||||
<div className="flex h-12 items-center justify-between border-b border-zinc-200 px-4">
|
||||
<span className="text-[15px] font-medium text-zinc-800">实时预览</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg border-zinc-300 bg-white px-3 text-[12px]"
|
||||
onClick={() => scheduleFlush(true)}
|
||||
>
|
||||
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden bg-[#d0d1d4] p-3">
|
||||
<div className="h-full w-full overflow-hidden rounded-xl border border-zinc-300/80 bg-[#d0d1d4]">
|
||||
{previewUrl ? (
|
||||
<img src={previewUrl} alt="实时预览" className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-[13px] text-zinc-500">
|
||||
{status === "connecting" ? (
|
||||
<span className="inline-flex items-center rounded-full bg-white/70 px-3 py-1.5">
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
正在等待首帧...
|
||||
</span>
|
||||
) : (
|
||||
<span>开始绘制后将显示实时预览</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-2xl border border-zinc-200 bg-white/95 p-3 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
onBlur={() => scheduleFlush(true)}
|
||||
placeholder="输入提示词,例如:将草图渲染为高端工业设计风格..."
|
||||
className="h-11 flex-1 rounded-xl border-zinc-300 bg-white text-[15px]"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="h-11 rounded-xl px-5"
|
||||
disabled={!previewUrl || isApplying}
|
||||
onClick={uploadPreviewToCanvas}
|
||||
variant="secondary"
|
||||
className="h-10 px-4 rounded-full shadow-md bg-background/80 backdrop-blur-md hover:bg-background"
|
||||
onClick={forceReconnect}
|
||||
>
|
||||
{isApplying ? (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-1.5 h-4 w-4" />
|
||||
)}
|
||||
发送到画布
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Reconnect
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 text-[12px] text-zinc-500">
|
||||
绘制会自动触发实时渲染,当前节流间隔 {adaptiveThrottleMs}ms。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -8,17 +8,29 @@ import { cn } from "@/lib/utils"
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
>(({ className, orientation = "horizontal", ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
"relative flex touch-none select-none items-center",
|
||||
orientation === "horizontal" ? "w-full" : "h-full flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
<SliderPrimitive.Track
|
||||
className={cn(
|
||||
"relative grow overflow-hidden rounded-full bg-secondary",
|
||||
orientation === "horizontal" ? "h-2 w-full" : "h-full w-2"
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
className={cn(
|
||||
"absolute bg-primary",
|
||||
orientation === "horizontal" ? "h-full" : "w-full"
|
||||
)}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user