feat: refine detail generation copy strategy and canvas layout
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-22 04:08:59 +08:00
parent 080328fbeb
commit 0fa4a14ac6
5 changed files with 1129 additions and 800 deletions

View File

@ -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,

View File

@ -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

View File

@ -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>

View File

@ -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>