From e2521424eb92e30e7d87aaf504510bdc772d7cec Mon Sep 17 00:00:00 2001 From: zh15728828264 <133273926+zh15728828264@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:05:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(create):=20=E5=88=9B=E4=BD=9C=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=20-=20=E6=94=AF=E6=8C=81=E5=8F=82=E8=80=83?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E5=B1=82=E5=8F=A0=E5=B1=95=E5=BC=80=E4=B8=8E?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并 PR #307。\n\n原作者:陈泽 <749959948@qq.com> --- web/src/pages/create/index.tsx | 127 ++++++++++++++++++++++--- web/src/styles/globals.css | 121 +++++++++++++++++++++++ web/test/create-library-button.test.ts | 31 +++++- 3 files changed, 261 insertions(+), 18 deletions(-) diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index c3ffd085a..fbdbb326a 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -1,6 +1,7 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type ReactNode, type RefObject } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type FocusEvent, type MouseEvent, type PointerEvent, type ReactNode, type RefObject } from "react"; import { App, Button, Drawer, Modal, Popover, Spin, Tooltip } from "antd"; -import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, WandSparkles, X } from "lucide-react"; +import { Reorder } from "motion/react"; +import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, WandSparkles, X } from "lucide-react"; import { Link } from "react-router"; import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown"; @@ -469,6 +470,10 @@ export default function CreatePage() { if (reference) setPrompt((current) => removeCreationReferenceTokens(current, [reference])); }; + const reorderAttachments = useCallback((next: CreationAttachment[]) => { + setAttachments(next); + }, []); + const submit = async (retryContext?: CreationRetryContext, retryLockKey?: string) => { const releaseRetryLock = () => { if (retryLockKey) retryPreparingRef.current.delete(retryLockKey); @@ -829,6 +834,7 @@ export default function CreatePage() { maxReferences, references: mentionReferences, onRemoveAttachment: removeAttachment, + onReorderAttachments: reorderAttachments, onOpenLibrary: () => setLibraryOpen(true), fileInputRef, onFileChange: handleFileChange, @@ -1064,24 +1070,20 @@ function CreationMediaPreviewModal({ url, type, onClose }: { url: string; type: return {url ? type === "video" ? ; } -function CreationAttachmentThumbnail({ item, primary = false, canAddMore = false, onPreview, onRemove, onAdd }: { +function CreationAttachmentThumbnail({ item, onPreview, onRemove }: { item: CreationAttachment; - primary?: boolean; - canAddMore?: boolean; onPreview: (type: "image" | "video", url: string) => void; onRemove: (id: string) => void; - onAdd?: () => void; }) { const kind = creationAttachmentKind(item); const previewable = kind === "image" || kind === "video"; const url = (kind === "video" ? item.url : item.previewUrl) || ""; - return
- - - {primary && canAddMore && onAdd ? : null} +
; } @@ -1096,6 +1098,7 @@ type ComposerProps = { maxReferences: number; references: CreationReference[]; onRemoveAttachment: (id: string) => void; + onReorderAttachments: (attachments: CreationAttachment[]) => void; onOpenLibrary: () => void; fileInputRef: RefObject; onFileChange: (event: ChangeEvent) => void; @@ -1126,6 +1129,10 @@ function CreationComposer(props: ComposerProps) { const [previewUrl, setPreviewUrl] = useState(""); const [previewType, setPreviewType] = useState<"image" | "video">("image"); const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false); + const attachmentTrackRef = useRef(null); + const cardDragRef = useRef<{ startX: number; startY: number; moved: boolean } | null>(null); + const suppressAttachmentClickRef = useRef(false); + const [trackState, setTrackState] = useState({ canScrollLeft: false, canScrollRight: false, isExpanded: false, isDragging: false }); const canSubmit = Boolean(props.prompt.trim()) && !props.busy; const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); const priceChannel = resolveModelChannel(props.config, props.model); @@ -1149,10 +1156,65 @@ function CreationComposer(props: ComposerProps) { const emptyPlaceholder = "输入你的镜头、画面或故事。也可以添加参考图开始创作"; const imageReferencesSupported = props.imageProfile.references.maxImages > 0; const referencesSupported = props.mode === "image" ? imageReferencesSupported : props.mode !== "video" || props.videoProfile.operations.includes("image_to_video"); - const [primaryAttachment, ...secondaryAttachments] = props.attachments; const canAddMoreReferences = referencesSupported && props.attachments.length < props.maxReferences; const imageSettingsSupported = props.imageProfile.size.parameter !== "none" || props.imageProfile.quality.supported || props.imageProfile.maxOutputs > 1; + const updateTrackScrollState = useCallback(() => { + const track = attachmentTrackRef.current; + if (!track) return; + setTrackState((current) => ({ + ...current, + canScrollLeft: track.scrollLeft > 1, + canScrollRight: track.scrollLeft + track.clientWidth < track.scrollWidth - 1, + })); + }, []); + const handleTrackMouseEnter = useCallback(() => { + if (window.matchMedia("(hover: hover)").matches) setTrackState((current) => ({ ...current, isExpanded: true })); + }, []); + const handleTrackMouseLeave = useCallback(() => { + if (!trackState.isDragging) setTrackState((current) => ({ ...current, isExpanded: false })); + }, [trackState.isDragging]); + const handleTrackFocus = useCallback(() => { + setTrackState((current) => ({ ...current, isExpanded: true })); + }, []); + const handleTrackBlur = useCallback((event: FocusEvent) => { + if (!trackState.isDragging && !event.currentTarget.contains(event.relatedTarget as Node | null)) { + setTrackState((current) => ({ ...current, isExpanded: false })); + } + }, [trackState.isDragging]); + useEffect(() => { + const touchOnly = window.matchMedia("(hover: none)").matches; + setTrackState((current) => ({ ...current, isExpanded: touchOnly || current.isDragging })); + updateTrackScrollState(); + }, [props.attachments.length, updateTrackScrollState]); + useEffect(() => { + const frame = window.requestAnimationFrame(updateTrackScrollState); + return () => window.cancelAnimationFrame(frame); + }, [trackState.isExpanded, updateTrackScrollState]); + const beginCardDrag = (event: PointerEvent) => { + if (event.button !== 0 || props.busy) return; + if ((event.target as HTMLElement).closest(".creation-reference-card-remove")) return; + cardDragRef.current = { startX: event.clientX, startY: event.clientY, moved: false }; + }; + const endCardDrag = (event: PointerEvent) => { + const drag = cardDragRef.current; + if (!drag) return; + cardDragRef.current = null; + if (drag.moved) { + suppressAttachmentClickRef.current = true; + window.setTimeout(() => { suppressAttachmentClickRef.current = false; }, 0); + } + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); + setTrackState((current) => ({ ...current, isDragging: false })); + }; + const moveCardDrag = (event: PointerEvent) => { + const drag = cardDragRef.current; + if (!drag || drag.moved) return; + if (Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) <= 4) return; + drag.moved = true; + setTrackState((current) => ({ ...current, isDragging: true, isExpanded: true })); + }; const previewAttachment = (type: "image" | "video", url: string) => { + if (suppressAttachmentClickRef.current || cardDragRef.current?.moved) return; setPreviewType(type); setPreviewUrl(url); }; @@ -1160,13 +1222,52 @@ function CreationComposer(props: ComposerProps) { if (!canOptimizePrompt) setPromptOptimizerOpen(false); }, [canOptimizePrompt]); + const scrollAttachmentTrack = (direction: -1 | 1) => { + const track = attachmentTrackRef.current; + if (!track) return; + track.scrollBy({ left: direction * Math.max(track.clientWidth * 0.72, 120), behavior: "smooth" }); + window.setTimeout(updateTrackScrollState, 180); + }; const composer =
- {primaryAttachment ? : }
- {secondaryAttachments.length ?
{secondaryAttachments.map((item) => )}
: null} + {props.attachments.length || canAddMoreReferences ?
+
+ {trackState.canScrollLeft ? : null} + + as="ul" + ref={attachmentTrackRef} + className={`creation-reference-track${trackState.isExpanded ? " is-expanded" : ""}${trackState.isDragging ? " is-dragging" : ""}${props.attachments.length ? "" : " is-empty"}`} + axis="x" + values={props.attachments} + onReorder={props.onReorderAttachments} + layoutScroll + role="list" + aria-label="参考内容轨道" + onScroll={updateTrackScrollState} + > + {props.attachments.map((item) => + key={item.id} + value={item} + layout="position" + drag={!props.busy} + className="creation-reference-stack-card" + onPointerDown={beginCardDrag} + onPointerMove={moveCardDrag} + onPointerUp={endCardDrag} + onPointerCancel={endCardDrag} + onDragStart={() => setTrackState((current) => ({ ...current, isDragging: true, isExpanded: true }))} + onDragEnd={() => setTrackState((current) => ({ ...current, isDragging: false, isExpanded: true }))} + > + + )} + {canAddMoreReferences ?
  • : null} + + {trackState.canScrollRight ? : null} +
    +
    : null}