refine realtime canvas toolbar 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-03-08 00:29:06 +08:00
parent b2d53444ae
commit 356c46f480

View File

@ -2253,65 +2253,300 @@ export function RealtimeCanvasDialog({
<DialogDescription>左侧绘制草图,右侧实时预览。</DialogDescription>
</DialogHeader>
)}
{standalone ? (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-4 top-4 z-[60] h-9 w-9 rounded-full bg-background/85 shadow-sm"
onClick={() => onOpenChange(false)}
title="返回画布"
>
<X className="h-5 w-5" />
</Button>
) : null}
{/* Top Header - Minimal */}
<div className="absolute left-0 right-0 top-0 z-50 flex h-14 items-center justify-start 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/95 backdrop-blur-sm 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 className="h-4 w-[1px] bg-border" />
<Button
type="button"
size="sm"
variant="ghost"
className="h-8 rounded-full px-3 text-xs"
onClick={() => void saveCurrentProject()}
>
<Save className="mr-1.5 h-3.5 w-3.5" />
保存项目
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-8 rounded-full px-3 text-xs"
onClick={() => {
refreshSavedProjects();
setProjectPanelOpen((openState) => !openState);
}}
>
<FolderOpen className="mr-1.5 h-3.5 w-3.5" />
项目列表
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-8 rounded-full px-3 text-xs"
onClick={startBlankProject}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
新建空白
</Button>
<div className="absolute left-4 right-4 top-4 z-50 pointer-events-none">
<div className="pointer-events-auto rounded-[28px] border border-black/10 bg-white/96 shadow-[0_22px_70px_rgba(15,23,42,0.14)] backdrop-blur-sm">
<div className="flex flex-wrap items-center gap-2 px-3 py-3">
<div className="flex items-center gap-3 rounded-full border border-black/10 bg-zinc-50 px-4 py-2">
<span className="text-sm font-semibold text-zinc-900">Realtime Canvas</span>
<span
className={cn(
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium",
statusBadge.className
)}
>
<span
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>
<span className="hidden text-xs text-zinc-500 sm:inline">
延迟 {adaptiveThrottleMs}ms
</span>
</div>
<div className="flex flex-wrap items-center gap-1 rounded-full border border-black/10 bg-white px-1.5 py-1">
<Button
type="button"
size="sm"
variant="ghost"
className="h-9 rounded-full px-3 text-xs"
onClick={() => void saveCurrentProject()}
>
<Save className="mr-1.5 h-3.5 w-3.5" />
保存项目
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-9 rounded-full px-3 text-xs"
onClick={() => {
refreshSavedProjects();
setProjectPanelOpen((openState) => !openState);
}}
>
<FolderOpen className="mr-1.5 h-3.5 w-3.5" />
项目列表
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-9 rounded-full px-3 text-xs"
onClick={startBlankProject}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
新建空白
</Button>
{standalone ? (
<Button
type="button"
size="icon"
variant="ghost"
className="h-9 w-9 rounded-full"
onClick={() => onOpenChange(false)}
title="返回画布"
>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-1 rounded-full border border-black/10 bg-white px-1.5 py-1">
<Button
size="icon"
variant="ghost"
className={cn(
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100",
brushMode === "brush" && "bg-black text-white hover:bg-black"
)}
onClick={() => setBrushMode("brush")}
title="画笔"
>
<Paintbrush className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className={cn(
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100",
brushMode === "eraser" && "bg-black text-white hover:bg-black"
)}
onClick={() => setBrushMode("eraser")}
title="橡皮"
>
<Eraser className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className={cn(
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100",
brushMode === "rect" && "bg-black text-white hover:bg-black"
)}
onClick={() => setBrushMode("rect")}
title="矩形"
>
<Square className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className={cn(
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100",
brushMode === "circle" && "bg-black text-white hover:bg-black"
)}
onClick={() => setBrushMode("circle")}
title="圆形"
>
<CircleIcon className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className={cn(
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100",
brushMode === "line" && "bg-black text-white hover:bg-black"
)}
onClick={() => setBrushMode("line")}
title="直线"
>
<Slash className="h-5 w-5" />
</Button>
</div>
<div className="flex flex-wrap items-center gap-2 rounded-full border border-black/10 bg-zinc-50 px-3 py-2">
<button
type="button"
className="flex h-10 items-center gap-2 rounded-full border border-black/10 bg-white px-3 text-xs font-medium text-zinc-700 transition hover:bg-zinc-50"
onClick={() => colorInputRef.current?.click()}
title="选择画笔颜色"
>
<span
className="h-5 w-5 rounded-full border border-black/15 shadow-inner"
style={{ backgroundColor: brushColor }}
/>
颜色
</button>
<input
ref={colorInputRef}
type="color"
value={brushColor}
onChange={(event) => setBrushColor(event.target.value)}
className="sr-only"
title="自定义颜色"
/>
<span className="min-w-[64px] text-[11px] uppercase tracking-wide text-zinc-500">
{brushColor}
</span>
<div className="flex min-w-[210px] flex-1 items-center gap-3 rounded-full border border-black/10 bg-white px-3 py-2">
<span className="whitespace-nowrap text-xs font-medium text-zinc-600">
粗细 {brushSize}
</span>
<Slider
value={[brushSize]}
min={2}
max={50}
step={1}
onValueChange={([val]) => setBrushSize(val)}
className="min-w-[140px] flex-1 [&>span:first-child]:h-2.5 [&>span:first-child]:bg-zinc-200 [&>span:first-child]:shadow-inner [&>span:first-child>span]:bg-sky-500 [&>span:last-child]:h-5 [&>span:last-child]:w-5 [&>span:last-child]:border-2 [&>span:last-child]:border-sky-500 [&>span:last-child]:bg-white"
/>
</div>
<div className="flex items-center gap-1 rounded-full border border-black/10 bg-white px-2 py-2">
{CANVAS_BACKGROUND_PRESETS.map((color) => (
<button
key={color}
type="button"
className={cn(
"h-5 w-5 rounded-full border border-black/15 transition-all",
canvasBackground === color && "scale-110 ring-2 ring-black/20"
)}
style={{ backgroundColor: color }}
onClick={() => handleCanvasBackgroundChange(color)}
title="画布底色"
/>
))}
</div>
</div>
<div className="flex flex-wrap items-center gap-1 rounded-full border border-black/10 bg-white px-1.5 py-1">
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={() => uploadInputRef.current?.click()}
title="上传图片"
>
<Upload className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={clearSketch}
title="清空"
>
<Trash2 className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={undo}
disabled={!canUndo}
title="撤销"
>
<Undo2 className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={redo}
disabled={!canRedo}
title="重做"
>
<Redo2 className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={handleDownloadCanvas}
title="保存图片"
>
<Download className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={forceReconnect}
title="重连"
>
<RefreshCw className="h-5 w-5" />
</Button>
</div>
<div className="flex min-w-[280px] flex-1 flex-wrap items-center gap-2 rounded-[22px] border border-black/10 bg-zinc-50 px-2 py-2">
<div className="flex h-10 w-10 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, false)}
placeholder="输入指令,基于当前画布实时生成"
className="h-10 min-w-[180px] flex-1 border-0 bg-transparent px-2 text-sm shadow-none focus-visible:ring-0"
/>
<Button
size="sm"
className="h-10 rounded-xl px-4 font-medium"
onClick={triggerGenerate}
disabled={status === "connecting"}
>
{status === "connecting" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"生成"
)}
</Button>
<Button
size="sm"
variant="outline"
className="h-10 rounded-xl px-4 font-medium"
onClick={handleApplyToCanvas}
disabled={!previewUrl}
>
<Send className="mr-1.5 h-4 w-4" />
发送到画布
</Button>
<span className="ml-auto text-xs text-zinc-500">
{statusMessage}
</span>
</div>
</div>
</div>
</div>
{projectPanelOpen ? (
<div className="absolute left-6 top-16 z-40 w-[22rem] max-w-[calc(100vw-2rem)] rounded-[24px] border border-black/10 bg-white/95 p-4 shadow-2xl backdrop-blur-xl">
<div className="absolute left-4 top-28 z-40 w-[22rem] max-w-[calc(100vw-2rem)] rounded-[24px] border border-black/10 bg-white/95 p-4 shadow-2xl backdrop-blur-xl">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-zinc-900">项目列表</div>
@ -2450,7 +2685,7 @@ export function RealtimeCanvasDialog({
/>
</div>
</div>
<div className="absolute right-4 top-20 bottom-32 z-10 flex w-[min(16rem,42vw)] flex-col rounded-[22px] border border-black/10 bg-white/92 p-3 shadow-xl backdrop-blur-xl">
<div className="absolute left-3 top-28 bottom-4 z-10 flex w-[11.75rem] max-w-[38vw] flex-col rounded-[20px] border border-black/10 bg-white/96 p-3 shadow-xl">
<div className="flex items-center justify-between border-b border-black/10 pb-3">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-900">
<Layers3 className="h-4 w-4" />
@ -2597,6 +2832,7 @@ export function RealtimeCanvasDialog({
scheduleFlush(true);
}
}}
className="flex-1 [&>span:first-child]:h-2.5 [&>span:first-child]:bg-zinc-200 [&>span:first-child]:shadow-inner [&>span:first-child>span]:bg-sky-500 [&>span:last-child]:h-5 [&>span:last-child]:w-5 [&>span:last-child]:border-2 [&>span:last-child]:border-sky-500 [&>span:last-child]:bg-white"
/>
<Button
type="button"
@ -2656,254 +2892,14 @@ export function RealtimeCanvasDialog({
</div>
</div>
{/* Floating Toolbar - Top (Lovart-like) */}
<div className="absolute left-1/2 top-[72px] z-20 -translate-x-1/2 pointer-events-none">
<div className="pointer-events-auto flex items-center gap-1 rounded-[30px] border border-black/10 bg-white/90 p-2 shadow-xl backdrop-blur-xl">
<Button
size="icon"
variant="ghost"
className={cn(
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100",
brushMode === "brush" && "bg-black text-white hover:bg-black"
)}
onClick={() => setBrushMode("brush")}
title="画笔"
>
<Paintbrush className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className={cn(
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100",
brushMode === "eraser" && "bg-black text-white hover:bg-black"
)}
onClick={() => setBrushMode("eraser")}
title="橡皮"
>
<Eraser className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className={cn(
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100",
brushMode === "rect" && "bg-black text-white hover:bg-black"
)}
onClick={() => setBrushMode("rect")}
title="矩形"
>
<Square className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className={cn(
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100",
brushMode === "circle" && "bg-black text-white hover:bg-black"
)}
onClick={() => setBrushMode("circle")}
title="圆形"
>
<CircleIcon className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className={cn(
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100",
brushMode === "line" && "bg-black text-white hover:bg-black"
)}
onClick={() => setBrushMode("line")}
title="直线"
>
<Slash className="h-5 w-5" />
</Button>
<div className="mx-1 h-7 w-px bg-black/10" />
<div className="flex items-center gap-2 px-1">
<button
type="button"
className="flex h-10 items-center gap-2 rounded-full border border-black/10 bg-white px-3 text-xs font-medium text-zinc-700 transition hover:bg-zinc-50"
onClick={() => colorInputRef.current?.click()}
title="选择画笔颜色"
>
<span
className="h-5 w-5 rounded-full border border-black/15 shadow-inner"
style={{ backgroundColor: brushColor }}
/>
颜色
</button>
<input
ref={colorInputRef}
type="color"
value={brushColor}
onChange={(event) => setBrushColor(event.target.value)}
className="sr-only"
title="自定义颜色"
/>
<span className="min-w-[58px] text-[11px] uppercase tracking-wide text-zinc-400">
{brushColor}
</span>
</div>
<div className="mx-1 h-7 w-px bg-black/10" />
<div className="flex min-w-[132px] items-center gap-2 px-1">
<span className="whitespace-nowrap text-xs font-medium text-zinc-600">
粗细 {brushSize}
</span>
<Slider
value={[brushSize]}
min={2}
max={50}
step={1}
onValueChange={([val]) => setBrushSize(val)}
/>
</div>
<div className="mx-1 h-7 w-px bg-black/10" />
<div className="flex items-center gap-1">
{CANVAS_BACKGROUND_PRESETS.map((color) => (
<button
key={color}
type="button"
className={cn(
"h-4 w-4 rounded border border-black/15 transition-all",
canvasBackground === color && "scale-110 ring-2 ring-black/20"
)}
style={{ backgroundColor: color }}
onClick={() => handleCanvasBackgroundChange(color)}
title="画布底色"
/>
))}
</div>
<div className="mx-1 h-7 w-px bg-black/10" />
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={() => uploadInputRef.current?.click()}
title="上传图片"
>
<Upload className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={clearSketch}
title="清空"
>
<Trash2 className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={undo}
disabled={!canUndo}
title="撤销"
>
<Undo2 className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={redo}
disabled={!canRedo}
title="重做"
>
<Redo2 className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={triggerGenerate}
disabled={status === "connecting"}
title="生成"
>
{status === "connecting" ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Sparkles className="h-5 w-5" />
)}
</Button>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={handleDownloadCanvas}
title="保存"
>
<Download className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={forceReconnect}
title="重连"
>
<RefreshCw className="h-5 w-5" />
</Button>
</div>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleSketchUpload}
/>
</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, false)}
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={triggerGenerate}
disabled={status === 'connecting'}
>
{status === 'connecting' ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Generate'}
</Button>
<Button
size="sm"
variant="outline"
className="h-9 rounded-xl px-4 font-medium"
onClick={handleApplyToCanvas}
disabled={!previewUrl}
>
<Send className="h-4 w-4 mr-1.5" />
发送到画布
</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>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleSketchUpload}
/>
</>
);