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 实现真正的混合编辑 */} {/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}
<div <div
className={cn( className={cn(
"relative px-4 pt-3 pb-12", "relative px-3 pt-3 pb-12",
isDetailGenerationMode ? "min-h-[72px]" : "min-h-[60px]" isDetailGenerationMode ? "min-h-[72px]" : "min-h-[60px]"
)} )}
> >
@ -10207,79 +10207,81 @@ export function CanvasChatPanel({
) : null} ) : null}
</div> </div>
) : null} ) : null}
<div <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">
ref={textareaRef as any} <div
contentEditable={!isSubmitting} ref={textareaRef as any}
suppressContentEditableWarning contentEditable={!isSubmitting}
className={cn( suppressContentEditableWarning
"min-h-[40px] max-h-[120px] overflow-y-auto text-sm outline-none empty:before:content-[attr(data-placeholder)]", className={cn(
isVideoWorkspace ? "empty:before:text-muted-foreground/70" : "text-[#111827] empty:before:text-[#9aa1ad] dark:text-zinc-100 dark:empty:before:text-zinc-500" "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) => { data-placeholder={composerPlaceholder}
// 从 contenteditable 中提取纯文本(排除附件标签文本) onInput={(e) => {
const target = e.target as HTMLDivElement; // 从 contenteditable 中提取纯文本(排除附件标签文本)
const clone = target.cloneNode(true) as HTMLDivElement; const target = e.target as HTMLDivElement;
clone.querySelectorAll("[data-attachment-url]").forEach((el) => el.remove()); const clone = target.cloneNode(true) as HTMLDivElement;
const textContent = (clone.textContent || "") clone.querySelectorAll("[data-attachment-url]").forEach((el) => el.remove());
.replace(/\u200b/g, " ") const textContent = (clone.textContent || "")
.replace(/\s+/g, " ") .replace(/\u200b/g, " ")
.trim(); .replace(/\s+/g, " ")
setInput(textContent); .trim();
if ((!isVideoModel || videoFrameSlots.length === 0) && !hasAgentUploadSlots) { setInput(textContent);
syncAttachmentsWithEditor(target); if ((!isVideoModel || videoFrameSlots.length === 0) && !hasAgentUploadSlots) {
} syncAttachmentsWithEditor(target);
}} }
onKeyDown={(e) => { }}
if (e.key === 'Enter' && !e.shiftKey) { onKeyDown={(e) => {
e.preventDefault(); if (e.key === 'Enter' && !e.shiftKey) {
// 🆕 详情模式下,Enter 键默认不直接发送,防止误触。除非使用 Meta+Enter 或 Ctrl+Enter(这里先不处理组合键,仅禁用单键发送) e.preventDefault();
// 或者如果用户习惯了 Enter 发送,可以保持,但考虑到详情生成成本较高,也许需要确认? // 🆕 详情模式下,Enter 键默认不直接发送,防止误触。除非使用 Meta+Enter 或 Ctrl+Enter(这里先不处理组合键,仅禁用单键发送)
// 暂且保持一致,如果用户在详情模式下按 Enter,也触发 handleSend(内部会做校验) // 或者如果用户习惯了 Enter 发送,可以保持,但考虑到详情生成成本较高,也许需要确认?
handleSend(); // 暂且保持一致,如果用户在详情模式下按 Enter,也触发 handleSend(内部会做校验)
} handleSend();
// 处理 Backspace 删除图片标签(只删除聊天框中的标签,不影响画布) }
if (e.key === 'Backspace' && (!isVideoModel || videoFrameSlots.length === 0) && !hasAgentUploadSlots) { // 处理 Backspace 删除图片标签(只删除聊天框中的标签,不影响画布)
const selection = window.getSelection(); if (e.key === 'Backspace' && (!isVideoModel || videoFrameSlots.length === 0) && !hasAgentUploadSlots) {
if (selection && selection.rangeCount > 0) { const selection = window.getSelection();
const range = selection.getRangeAt(0); if (selection && selection.rangeCount > 0) {
if (range.collapsed && range.startOffset === 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')) { const prevSibling = range.startContainer.previousSibling as HTMLElement;
e.preventDefault(); if (prevSibling && prevSibling.getAttribute?.('data-attachment-url')) {
e.stopPropagation(); // 阻止事件冒泡,防止触发全局删除 e.preventDefault();
// 使用 URL 来删除附件,更可靠 e.stopPropagation(); // 阻止事件冒泡,防止触发全局删除
removeAttachmentByTagElement(prevSibling); // 使用 URL 来删除附件,更可靠
prevSibling.remove(); removeAttachmentByTagElement(prevSibling);
prevSibling.remove();
}
} }
} }
} }
} }}
}} onPaste={(e) => {
onPaste={(e) => { const clipboardItems = Array.from(e.clipboardData?.items || []);
const clipboardItems = Array.from(e.clipboardData?.items || []); const imageFiles = clipboardItems
const imageFiles = clipboardItems .filter((item) => item.type.startsWith("image/"))
.filter((item) => item.type.startsWith("image/")) .map((item) => item.getAsFile())
.map((item) => item.getAsFile()) .filter((file): file is File => Boolean(file));
.filter((file): file is File => Boolean(file));
if (imageFiles.length > 0) {
e.preventDefault();
void handlePastedImages(imageFiles);
return;
}
if (imageFiles.length > 0) {
e.preventDefault(); e.preventDefault();
void handlePastedImages(imageFiles); const text = e.clipboardData.getData('text/plain');
return; document.execCommand('insertText', false, text);
} }}
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
e.preventDefault(); />
const text = e.clipboardData.getData('text/plain'); </div>
document.execCommand('insertText', false, text);
}}
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
/>
</div> </div>
{hasAgentUploadSlots && ( {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"> <div className="flex flex-row gap-2 overflow-x-auto scrollbar-hide">
{agentUploadSlots.map((slot, index) => { {agentUploadSlots.map((slot, index) => {
const slotAttachment = attachments[index]; const slotAttachment = attachments[index];
@ -10347,7 +10349,7 @@ export function CanvasChatPanel({
)} )}
{isVideoModel && videoFrameSlots.length > 0 && ( {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"> <div className="flex flex-row gap-2">
{videoFrameSlots.map((label, index) => { {videoFrameSlots.map((label, index) => {
const slotAttachment = attachments[index]; const slotAttachment = attachments[index];
@ -10417,10 +10419,7 @@ export function CanvasChatPanel({
{/* Bottom Toolbar */} {/* Bottom Toolbar */}
<div <div
className={cn( className={cn(
"absolute left-2 bottom-2 right-2 flex items-center justify-between gap-3", "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)]"
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"
)} )}
> >
<div className="flex min-w-0 flex-1 items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2">
@ -10463,13 +10462,13 @@ export function CanvasChatPanel({
{!isWorkspacePresentation ? ( {!isWorkspacePresentation ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
className={cn( className={cn(
"relative h-8 cursor-pointer rounded-full px-3 text-sm font-medium", "relative h-8 cursor-pointer rounded-full px-3 text-sm font-medium",
isVideoWorkspace isVideoWorkspace
? "border border-border/60 bg-background text-foreground hover:bg-background/90" ? "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" : 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" ? "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" : activeComposerMode === "detail"
@ -10649,9 +10648,9 @@ export function CanvasChatPanel({
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn( 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 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" : "hover:bg-muted dark:bg-white/8 dark:text-zinc-200 dark:hover:bg-white/12 dark:hover:text-white"
)} )}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
@ -10680,7 +10679,7 @@ export function CanvasChatPanel({
</div> </div>
</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 <ImageParamsBar
modelName={activeComposerMode === "agent" ? agentDisplayName : currentModel?.displayName} modelName={activeComposerMode === "agent" ? agentDisplayName : currentModel?.displayName}
initialParams={imageParams} initialParams={imageParams}
@ -10695,7 +10694,7 @@ export function CanvasChatPanel({
)} )}
</div> </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" ? ( {!isWorkspacePresentation && activeComposerMode === "image" ? (
<Button <Button
variant="ghost" variant="ghost"
@ -10751,7 +10750,7 @@ export function CanvasChatPanel({
className={cn( className={cn(
"h-8 w-8 rounded-full p-0 justify-center [&>svg]:hidden", "h-8 w-8 rounded-full p-0 justify-center [&>svg]:hidden",
isVideoWorkspace 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" : "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} 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-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" : "bg-muted text-muted-foreground hover:bg-muted/80"
: canSend : 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" : "bg-muted text-muted-foreground hover:bg-muted/80"
)} )}
onClick={handleSend} onClick={handleSend}