2ede253b39
Replace the internal single-node clipboard with real clipboard paste: the prompt composer uploads pasted image files as references, and the canvas pastes clipboard images as nodes and clipboard text as text nodes anchored at the pointer, reading from the async clipboard API on the context-menu paste action. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
416 lines
15 KiB
TypeScript
416 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
ArrowUp,
|
|
Bot,
|
|
Check,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Globe2,
|
|
Image as ImageIcon,
|
|
ImagePlus,
|
|
Library,
|
|
Paperclip,
|
|
Plus,
|
|
Sparkles,
|
|
Square,
|
|
Video,
|
|
X
|
|
} from "lucide-react";
|
|
import { forwardRef, type ReactNode, useMemo, useRef, useState } from "react";
|
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { useI18n } from "@/i18n/i18n";
|
|
import { PromptComposer, type PromptComposerHandle, type UploadedReferenceImage, type CanvasAnnotation, type AnnotationPreviewRequest } from "@/ui/components/PromptComposer";
|
|
|
|
export type ComposerMode = "agent" | "image";
|
|
export type ComposerImageQuality = "auto" | "high" | "medium" | "low";
|
|
export type ComposerImageRatio = "1:1" | "3:2" | "2:3" | "4:3" | "3:4" | "9:16" | "1:1-2k" | "16:9-2k" | "9:16-2k" | "16:9-4k" | "9:16-4k" | "auto";
|
|
|
|
export type ComposerImageSettings = {
|
|
quality: ComposerImageQuality;
|
|
ratio: ComposerImageRatio;
|
|
width: number;
|
|
height: number;
|
|
count: number;
|
|
};
|
|
|
|
export type CanvasComposerModel = {
|
|
id: string;
|
|
label: string;
|
|
description?: string;
|
|
duration?: string;
|
|
credits: number;
|
|
};
|
|
|
|
export type CanvasAgentComposerVariant = "panel" | "floating";
|
|
|
|
export type CanvasAgentComposerProps = {
|
|
text: string;
|
|
mode: ComposerMode;
|
|
models: CanvasComposerModel[];
|
|
selectedModel: CanvasComposerModel;
|
|
settings: ComposerImageSettings;
|
|
webSearchEnabled: boolean;
|
|
generating: boolean;
|
|
variant?: CanvasAgentComposerVariant;
|
|
references?: UploadedReferenceImage[];
|
|
annotations?: CanvasAnnotation[];
|
|
previewSlot?: ReactNode;
|
|
onTextChange: (text: string) => void;
|
|
onModeChange: (mode: ComposerMode) => void;
|
|
onSettingsChange: (settings: ComposerImageSettings) => void;
|
|
onModelChange: (modelId: string) => void;
|
|
onWebSearchChange: (enabled: boolean) => void;
|
|
onUploadFile: (file: File | null) => void | Promise<void>;
|
|
onReferencesChange: (references: UploadedReferenceImage[]) => void;
|
|
onReferenceRemoved: (reference: UploadedReferenceImage) => void;
|
|
onAnnotationsChange?: (annotations: CanvasAnnotation[]) => void;
|
|
onAnnotationPreviewChange?: (preview: AnnotationPreviewRequest | null) => void;
|
|
onSubmit: () => void;
|
|
onStop?: () => void;
|
|
};
|
|
|
|
const qualityOptions: Array<{ id: ComposerImageQuality; labelKey: string }> = [
|
|
{ id: "auto", labelKey: "qualityAuto" },
|
|
{ id: "high", labelKey: "qualityHigh" },
|
|
{ id: "medium", labelKey: "qualityMedium" },
|
|
{ id: "low", labelKey: "qualityLow" }
|
|
];
|
|
|
|
export const composerRatioOptions: Array<{ id: ComposerImageRatio; label: string; width: number; height: number }> = [
|
|
{ id: "1:1", label: "1:1", width: 1024, height: 1024 },
|
|
{ id: "3:2", label: "3:2", width: 1536, height: 1024 },
|
|
{ id: "2:3", label: "2:3", width: 1024, height: 1536 },
|
|
{ id: "4:3", label: "4:3", width: 1365, height: 1024 },
|
|
{ id: "3:4", label: "3:4", width: 1024, height: 1365 },
|
|
{ id: "9:16", label: "9:16", width: 1024, height: 1820 },
|
|
{ id: "1:1-2k", label: "1:1(2k)", width: 2048, height: 2048 },
|
|
{ id: "16:9-2k", label: "16:9(2k)", width: 2048, height: 1152 },
|
|
{ id: "9:16-2k", label: "9:16(2k)", width: 1152, height: 2048 },
|
|
{ id: "16:9-4k", label: "16:9(4k)", width: 4096, height: 2304 },
|
|
{ id: "9:16-4k", label: "9:16(4k)", width: 2304, height: 4096 },
|
|
{ id: "auto", label: "auto", width: 1024, height: 1024 }
|
|
];
|
|
|
|
export const defaultComposerImageSettings: ComposerImageSettings = {
|
|
quality: "auto",
|
|
ratio: "1:1",
|
|
width: 1024,
|
|
height: 1024,
|
|
count: 1
|
|
};
|
|
|
|
export function composerImageSize(settings: ComposerImageSettings) {
|
|
return `${Math.max(1, Math.round(settings.width))}x${Math.max(1, Math.round(settings.height))}`;
|
|
}
|
|
|
|
export function composerSettingsSummary(settings: ComposerImageSettings) {
|
|
const ratioLabel = composerRatioOptions.find((option) => option.id === settings.ratio)?.label ?? settings.ratio;
|
|
return `${qualityLabel(settings.quality)} · ${ratioLabel} · ${settings.count} img`;
|
|
}
|
|
|
|
function qualityLabel(quality: ComposerImageQuality) {
|
|
if (quality === "high") return "高";
|
|
if (quality === "medium") return "中";
|
|
if (quality === "low") return "低";
|
|
return "自动";
|
|
}
|
|
|
|
function hasComposerPromptText(value: string) {
|
|
return value.replace(/\u00a0/g, " ").trim().length > 0;
|
|
}
|
|
|
|
export const CanvasAgentComposer = forwardRef<PromptComposerHandle, CanvasAgentComposerProps>(function CanvasAgentComposer(
|
|
{
|
|
text,
|
|
mode,
|
|
models,
|
|
selectedModel,
|
|
settings,
|
|
webSearchEnabled,
|
|
generating,
|
|
variant = "panel",
|
|
references,
|
|
annotations,
|
|
previewSlot,
|
|
onTextChange,
|
|
onModeChange,
|
|
onSettingsChange,
|
|
onModelChange,
|
|
onWebSearchChange,
|
|
onUploadFile,
|
|
onReferencesChange,
|
|
onReferenceRemoved,
|
|
onAnnotationsChange,
|
|
onAnnotationPreviewChange,
|
|
onSubmit,
|
|
onStop
|
|
},
|
|
ref
|
|
) {
|
|
const { t } = useI18n();
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
const [assetMenuOpen, setAssetMenuOpen] = useState(false);
|
|
const [modeMenuOpen, setModeMenuOpen] = useState(false);
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
const [modelOpen, setModelOpen] = useState(false);
|
|
const [upgradeVisible, setUpgradeVisible] = useState(true);
|
|
const sendCost = useMemo(() => Math.max(1, selectedModel.credits * Math.max(1, settings.count)), [selectedModel.credits, settings.count]);
|
|
const modeLabel = mode === "image" ? t("imageMode") : t("agentShort");
|
|
const ModeIcon = mode === "image" ? ImagePlus : Bot;
|
|
const canSubmit = hasComposerPromptText(text);
|
|
|
|
const patchSettings = (patch: Partial<ComposerImageSettings>) => {
|
|
onSettingsChange({ ...settings, ...patch });
|
|
};
|
|
|
|
const selectRatio = (ratio: ComposerImageRatio) => {
|
|
const nextRatio = composerRatioOptions.find((option) => option.id === ratio) ?? composerRatioOptions[0];
|
|
patchSettings({ ratio, width: nextRatio.width, height: nextRatio.height });
|
|
};
|
|
|
|
const composerCard = (
|
|
<div id={variant === "panel" ? "agent-chat-footer" : undefined} className={`assistant-composer ${mode === "image" ? "image-mode" : "agent-mode-active"} ${variant === "floating" ? "floating-composer" : ""}`}>
|
|
{mode === "image"}
|
|
<PromptComposer
|
|
ref={ref}
|
|
text={text}
|
|
placeholder={t("canvasPromptPlaceholder")}
|
|
ariaLabel={t("canvasPromptPlaceholder")}
|
|
className="compact"
|
|
references={references}
|
|
annotations={annotations}
|
|
removeLabel={t("removeReference")}
|
|
onTextChange={onTextChange}
|
|
onReferencesChange={onReferencesChange}
|
|
onReferenceRemoved={onReferenceRemoved}
|
|
onAnnotationsChange={onAnnotationsChange}
|
|
onAnnotationPreviewChange={onAnnotationPreviewChange}
|
|
onPasteFile={onUploadFile}
|
|
onSubmit={onSubmit}
|
|
/>
|
|
{previewSlot}
|
|
<div className="assistant-composer-toolbar">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
hidden
|
|
onChange={(event) => {
|
|
const file = event.target.files?.[0] ?? null;
|
|
event.currentTarget.value = "";
|
|
void onUploadFile(file);
|
|
setAssetMenuOpen(false);
|
|
}}
|
|
/>
|
|
<DropdownMenu open={assetMenuOpen} onOpenChange={setAssetMenuOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button className="composer-icon-button plus-button" type="button" aria-label={t("addAsset")}>
|
|
<Plus size={21} />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="composer-menu-popover composer-asset-popover" align="start" side="top" sideOffset={10}>
|
|
<DropdownMenuItem onSelect={() => fileInputRef.current?.click()}>
|
|
<Paperclip size={18} />
|
|
<span>{t("uploadFile")}</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem disabled>
|
|
<Library size={18} />
|
|
<span>{t("chooseFromLibrary")}</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className="composer-switch-row"
|
|
onSelect={(event) => {
|
|
event.preventDefault();
|
|
onWebSearchChange(!webSearchEnabled);
|
|
}}
|
|
>
|
|
<Globe2 size={18} />
|
|
<span>{t("webSearch")}</span>
|
|
<Switch className="composer-web-switch" checked={webSearchEnabled} onClick={(event) => event.stopPropagation()} onCheckedChange={onWebSearchChange} />
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<DropdownMenu open={modeMenuOpen} onOpenChange={setModeMenuOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button className={`composer-mode-button ${modeMenuOpen ? "open" : ""}`} type="button" aria-label={t("agentMode")}>
|
|
<ModeIcon size={18} />
|
|
<span>{modeLabel}</span>
|
|
{modeMenuOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="composer-menu-popover composer-mode-popover" align="start" side="top" sideOffset={10}>
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
onModeChange("agent");
|
|
setModeMenuOpen(false);
|
|
}}
|
|
>
|
|
<Bot size={18} />
|
|
<span>{t("agentShort")}</span>
|
|
{mode === "agent" && <Check size={16} />}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
onModeChange("image");
|
|
setModeMenuOpen(false);
|
|
}}
|
|
>
|
|
<ImagePlus size={18} />
|
|
<span>{t("imageMode")}</span>
|
|
{mode === "image" && <Check size={16} />}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem disabled>
|
|
<Video size={18} />
|
|
<span>{t("modelVideoTab")}</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{mode === "image" && (
|
|
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button className="composer-settings-chip" type="button" aria-label={t("imageSettings")}>
|
|
{composerSettingsSummary(settings)}
|
|
{settingsOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="composer-settings-popover" align="end" sideOffset={12}>
|
|
<ComposerSettingsPanel settings={settings} onPatch={patchSettings} onSelectRatio={selectRatio} />
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
|
|
<div className="composer-spacer" />
|
|
<DropdownMenu open={modelOpen} onOpenChange={setModelOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button className="composer-model-button" type="button" aria-label={t("modelSelection")}>
|
|
<Sparkles size={18} />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="composer-model-popover" align="end" side="top" sideOffset={12}>
|
|
{models.map((model) => (
|
|
<DropdownMenuItem
|
|
key={model.id}
|
|
onSelect={() => {
|
|
onModelChange(model.id);
|
|
setModelOpen(false);
|
|
}}
|
|
>
|
|
<Sparkles size={17} />
|
|
<span>{model.label}</span>
|
|
<small>⚡{model.credits}</small>
|
|
{model.id === selectedModel.id && <Check size={16} />}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<button
|
|
className={`send-button small ${generating ? "is-stopping" : ""} ${mode === "image" ? "with-cost" : ""}`}
|
|
type="button"
|
|
onClick={generating ? onStop : onSubmit}
|
|
disabled={!generating && !canSubmit}
|
|
aria-label={generating ? t("stopGeneration") : t("send")}
|
|
>
|
|
{generating ? (
|
|
<Square size={15} fill="currentColor" />
|
|
) : mode === "image" ? (
|
|
<span>⚡{sendCost}</span>
|
|
) : (
|
|
<ArrowUp size={21} />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
if (variant === "floating") return composerCard;
|
|
|
|
return (
|
|
<div className={`assistant-composer-stack ${upgradeVisible ? "has-upgrade" : ""}`}>
|
|
{upgradeVisible && (
|
|
<div id="agent-chat-banner" className="composer-upgrade">
|
|
<div className="composer-upgrade-row">
|
|
<span className="composer-upgrade-icon" aria-hidden="true">
|
|
<Sparkles size={16} />
|
|
</span>
|
|
<span className="composer-upgrade-copy">{t("unlockModels")}</span>
|
|
<button type="button" aria-label={t("close")} onClick={() => setUpgradeVisible(false)}>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{composerCard}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export function ComposerSettingsPanel({
|
|
settings,
|
|
onPatch,
|
|
onSelectRatio
|
|
}: {
|
|
settings: ComposerImageSettings;
|
|
onPatch: (patch: Partial<ComposerImageSettings>) => void;
|
|
onSelectRatio: (ratio: ComposerImageRatio) => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
|
|
return (
|
|
<div className="composer-settings-panel">
|
|
<section>
|
|
<h4>{t("quality")}</h4>
|
|
<div className="composer-quality-control">
|
|
{qualityOptions.map((option) => (
|
|
<button key={option.id} type="button" className={settings.quality === option.id ? "selected" : ""} onClick={() => onPatch({ quality: option.id })}>
|
|
{t(option.labelKey)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
<section>
|
|
<h4>{t("imageSizeLabel")}</h4>
|
|
<div className="composer-dimensions">
|
|
<label>
|
|
<span>W</span>
|
|
<input value={settings.width} inputMode="numeric" onChange={(event) => onPatch({ width: Number(event.target.value) || 1024, ratio: "auto" })} />
|
|
</label>
|
|
<i aria-hidden="true">⌁</i>
|
|
<label>
|
|
<span>H</span>
|
|
<input value={settings.height} inputMode="numeric" onChange={(event) => onPatch({ height: Number(event.target.value) || 1024, ratio: "auto" })} />
|
|
</label>
|
|
</div>
|
|
<div className="composer-ratio-grid">
|
|
{composerRatioOptions.map((option) => (
|
|
<button key={option.id} type="button" className={settings.ratio === option.id ? "selected" : ""} onClick={() => onSelectRatio(option.id)}>
|
|
<RatioGlyph ratio={option.id} />
|
|
<span>{option.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
<section>
|
|
<h4>Image</h4>
|
|
<div className="composer-count-grid">
|
|
{Array.from({ length: 10 }, (_, index) => index + 1).map((count) => (
|
|
<button key={count} type="button" className={settings.count === count ? "selected" : ""} onClick={() => onPatch({ count })}>
|
|
{count} img
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RatioGlyph({ ratio }: { ratio: ComposerImageRatio }) {
|
|
const portrait = ratio.startsWith("2:3") || ratio.startsWith("3:4") || ratio.startsWith("9:16");
|
|
const wide = ratio.startsWith("3:2") || ratio.startsWith("4:3") || ratio.startsWith("16:9");
|
|
return <i className={`ratio-glyph ${portrait ? "portrait" : wide ? "wide" : ""}`} aria-hidden="true" />;
|
|
}
|