style: refresh canvas composer UI to mirror Lovart dialog

This commit is contained in:
Your Name 2026-06-09 13:56:20 +08:00
parent 6b90b25d5c
commit 4a3342c795

View File

@ -9883,7 +9883,7 @@ export function CanvasChatPanel({
{/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}
<div
className={cn(
"relative px-4 pt-3 pb-12",
"relative px-3 pt-3 pb-12",
isDetailGenerationMode ? "min-h-[72px]" : "min-h-[60px]"
)}
>
@ -10207,79 +10207,81 @@ export function CanvasChatPanel({
) : null}
</div>
) : null}
<div
ref={textareaRef as any}
contentEditable={!isSubmitting}
suppressContentEditableWarning
className={cn(
"min-h-[40px] max-h-[120px] overflow-y-auto text-sm outline-none empty:before:content-[attr(data-placeholder)]",
isVideoWorkspace ? "empty:before:text-muted-foreground/70" : "text-[#111827] empty:before:text-[#9aa1ad] dark:text-zinc-100 dark:empty:before:text-zinc-500"
)}
data-placeholder={composerPlaceholder}
onInput={(e) => {
// 从 contenteditable 中提取纯文本(排除附件标签文本)
const target = e.target as HTMLDivElement;
const clone = target.cloneNode(true) as HTMLDivElement;
clone.querySelectorAll("[data-attachment-url]").forEach((el) => el.remove());
const textContent = (clone.textContent || "")
.replace(/\u200b/g, " ")
.replace(/\s+/g, " ")
.trim();
setInput(textContent);
if ((!isVideoModel || videoFrameSlots.length === 0) && !hasAgentUploadSlots) {
syncAttachmentsWithEditor(target);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
// 🆕 详情模式下,Enter 键默认不直接发送,防止误触。除非使用 Meta+Enter 或 Ctrl+Enter(这里先不处理组合键,仅禁用单键发送)
// 或者如果用户习惯了 Enter 发送,可以保持,但考虑到详情生成成本较高,也许需要确认?
// 暂且保持一致,如果用户在详情模式下按 Enter,也触发 handleSend(内部会做校验)
handleSend();
}
// 处理 Backspace 删除图片标签(只删除聊天框中的标签,不影响画布)
if (e.key === 'Backspace' && (!isVideoModel || videoFrameSlots.length === 0) && !hasAgentUploadSlots) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
if (range.collapsed && range.startOffset === 0) {
// 检查前一个兄弟节点是否是图片标签
const prevSibling = range.startContainer.previousSibling as HTMLElement;
if (prevSibling && prevSibling.getAttribute?.('data-attachment-url')) {
e.preventDefault();
e.stopPropagation(); // 阻止事件冒泡,防止触发全局删除
// 使用 URL 来删除附件,更可靠
removeAttachmentByTagElement(prevSibling);
prevSibling.remove();
<div className="rounded-[22px] border border-[#d7deeb] bg-[#f8fafc] px-4 py-2.5 shadow-[0_12px_24px_-18px_rgba(15,23,42,0.35)] transition-colors focus-within:border-[#a6c8ff] focus-within:bg-white dark:border-zinc-600 dark:bg-zinc-900/60 dark:focus-within:bg-zinc-900">
<div
ref={textareaRef as any}
contentEditable={!isSubmitting}
suppressContentEditableWarning
className={cn(
"min-h-[40px] max-h-[120px] overflow-y-auto text-sm leading-6 outline-none empty:before:content-[attr(data-placeholder)]",
isVideoWorkspace ? "empty:before:text-muted-foreground/70" : "text-[#111827] empty:before:text-[#9aa1ad] dark:text-zinc-100 dark:empty:before:text-zinc-500"
)}
data-placeholder={composerPlaceholder}
onInput={(e) => {
// 从 contenteditable 中提取纯文本(排除附件标签文本)
const target = e.target as HTMLDivElement;
const clone = target.cloneNode(true) as HTMLDivElement;
clone.querySelectorAll("[data-attachment-url]").forEach((el) => el.remove());
const textContent = (clone.textContent || "")
.replace(/\u200b/g, " ")
.replace(/\s+/g, " ")
.trim();
setInput(textContent);
if ((!isVideoModel || videoFrameSlots.length === 0) && !hasAgentUploadSlots) {
syncAttachmentsWithEditor(target);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
// 🆕 详情模式下,Enter 键默认不直接发送,防止误触。除非使用 Meta+Enter 或 Ctrl+Enter(这里先不处理组合键,仅禁用单键发送)
// 或者如果用户习惯了 Enter 发送,可以保持,但考虑到详情生成成本较高,也许需要确认?
// 暂且保持一致,如果用户在详情模式下按 Enter,也触发 handleSend(内部会做校验)
handleSend();
}
// 处理 Backspace 删除图片标签(只删除聊天框中的标签,不影响画布)
if (e.key === 'Backspace' && (!isVideoModel || videoFrameSlots.length === 0) && !hasAgentUploadSlots) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
if (range.collapsed && range.startOffset === 0) {
// 检查前一个兄弟节点是否是图片标签
const prevSibling = range.startContainer.previousSibling as HTMLElement;
if (prevSibling && prevSibling.getAttribute?.('data-attachment-url')) {
e.preventDefault();
e.stopPropagation(); // 阻止事件冒泡,防止触发全局删除
// 使用 URL 来删除附件,更可靠
removeAttachmentByTagElement(prevSibling);
prevSibling.remove();
}
}
}
}
}
}}
onPaste={(e) => {
const clipboardItems = Array.from(e.clipboardData?.items || []);
const imageFiles = clipboardItems
.filter((item) => item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
}}
onPaste={(e) => {
const clipboardItems = Array.from(e.clipboardData?.items || []);
const imageFiles = clipboardItems
.filter((item) => item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
if (imageFiles.length > 0) {
e.preventDefault();
void handlePastedImages(imageFiles);
return;
}
if (imageFiles.length > 0) {
e.preventDefault();
void handlePastedImages(imageFiles);
return;
}
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
}}
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
/>
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
}}
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
/>
</div>
</div>
{hasAgentUploadSlots && (
<div className="mb-11 px-4 pb-1">
<div className="mb-11 px-3 pb-1">
<div className="flex flex-row gap-2 overflow-x-auto scrollbar-hide">
{agentUploadSlots.map((slot, index) => {
const slotAttachment = attachments[index];
@ -10347,7 +10349,7 @@ export function CanvasChatPanel({
)}
{isVideoModel && videoFrameSlots.length > 0 && (
<div className="mb-11 px-4 pb-1">
<div className="mb-11 px-3 pb-1">
<div className="flex flex-row gap-2">
{videoFrameSlots.map((label, index) => {
const slotAttachment = attachments[index];
@ -10417,10 +10419,7 @@ export function CanvasChatPanel({
{/* Bottom Toolbar */}
<div
className={cn(
"absolute left-2 bottom-2 right-2 flex items-center justify-between gap-3",
isVideoWorkspace &&
"rounded-[24px] border border-border/60 bg-background/88 px-3 py-2 shadow-sm backdrop-blur-xl supports-[backdrop-filter]:bg-background/80",
!isVideoWorkspace && "px-1"
"absolute inset-x-2 bottom-2 z-10 flex items-center justify-between gap-3 rounded-[24px] border border-[#d7deeb] bg-[#fbfdff]/95 px-3 py-2 shadow-[0_10px_28px_-20px_rgba(15,23,42,0.32)] backdrop-blur-xl supports-[backdrop-filter]:bg-[#fbfdff]/90 dark:border-zinc-700 dark:bg-zinc-900/90 dark:shadow-[0_10px_28px_-20px_rgba(2,6,23,0.7)]"
)}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
@ -10463,13 +10462,13 @@ export function CanvasChatPanel({
{!isWorkspacePresentation ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className={cn(
"relative h-8 cursor-pointer rounded-full px-3 text-sm font-medium",
isVideoWorkspace
? "border border-border/60 bg-background text-foreground hover:bg-background/90"
<Button
type="button"
variant="ghost"
className={cn(
"relative h-8 cursor-pointer rounded-full px-3 text-sm font-medium",
isVideoWorkspace
? "border border-[#cfd8e6] bg-[#f3f7ff] text-[#2f4f9e] shadow-[0_2px_8px_-4px_rgba(47,79,158,0.35)] hover:bg-[#e8f0ff] dark:border-zinc-500/60 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
: activeComposerMode === "video"
? "border border-blue-500 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:border-blue-400/40 dark:bg-blue-500/15 dark:text-blue-200 dark:hover:bg-blue-500/20"
: activeComposerMode === "detail"
@ -10649,9 +10648,9 @@ export function CanvasChatPanel({
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 rounded-full text-muted-foreground transition-colors hover:text-foreground mr-1",
"h-8 w-8 rounded-full text-muted-foreground transition-colors hover:text-foreground mr-1 border border-transparent hover:border-[#cfd8e6] hover:bg-[#eef4ff] dark:hover:border-zinc-500 dark:hover:bg-zinc-700/70",
isVideoWorkspace
? "border border-border/60 bg-background hover:bg-background/90"
? "bg-background"
: "hover:bg-muted dark:bg-white/8 dark:text-zinc-200 dark:hover:bg-white/12 dark:hover:text-white"
)}
onClick={() => fileInputRef.current?.click()}
@ -10680,7 +10679,7 @@ export function CanvasChatPanel({
</div>
</div>
) : (
<div className="min-w-0 shrink pl-1 border-l border-border/40 dark:border-white/10 ml-1">
<div className="min-w-0 shrink pl-2 border-l border-[#d9e1f3]/70 dark:border-white/10 ml-1">
<ImageParamsBar
modelName={activeComposerMode === "agent" ? agentDisplayName : currentModel?.displayName}
initialParams={imageParams}
@ -10695,7 +10694,7 @@ export function CanvasChatPanel({
)}
</div>
<div className="flex shrink-0 items-center gap-2 pl-2 border-l border-border/40 dark:border-white/10">
<div className="flex shrink-0 items-center gap-2 pl-2 border-l border-[#d9e1f3]/70 dark:border-white/10">
{!isWorkspacePresentation && activeComposerMode === "image" ? (
<Button
variant="ghost"
@ -10751,7 +10750,7 @@ export function CanvasChatPanel({
className={cn(
"h-8 w-8 rounded-full p-0 justify-center [&>svg]:hidden",
isVideoWorkspace
? "border border-border/60 bg-background"
? "border border-[#cfd8e6] bg-white/85 text-zinc-700 hover:bg-white dark:border-white/20 dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-100"
: "border border-[#cfd4dc] bg-white dark:border-white/20 dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-100"
)}
title={getToolbarItem("chatModelSelector").label}
@ -10834,7 +10833,7 @@ export function CanvasChatPanel({
? "bg-gradient-to-r from-violet-600 to-fuchsia-600 text-white shadow-md hover:shadow-lg hover:opacity-90 dark:from-violet-500 dark:to-fuchsia-500"
: "bg-muted text-muted-foreground hover:bg-muted/80"
: canSend
? "bg-black text-white hover:bg-black/90 dark:bg-white dark:text-black dark:hover:bg-white/90"
? "bg-[#2f80ff] text-white shadow-sm shadow-[#2f80ff]/30 hover:bg-[#2570e8]"
: "bg-muted text-muted-foreground hover:bg-muted/80"
)}
onClick={handleSend}