feat(create): 创作工作台 - 支持参考内容层叠展开与拖拽排序

合并 PR #307。\n\n原作者:陈泽 <749959948@qq.com>
This commit is contained in:
zh15728828264
2026-08-26 14:05:00 +08:00
committed by GitHub
parent 2f3a813ccb
commit e2521424eb
3 changed files with 261 additions and 18 deletions

View File

@@ -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 <Modal open={Boolean(url)} title={null} footer={null} centered destroyOnHidden width={type === "video" ? "min(1160px, calc(100vw - 32px))" : "min(980px, calc(100vw - 32px))"} onCancel={onClose} className="creation-media-preview-modal" styles={{ body: { padding: 0 } }}>{url ? type === "video" ? <video controls autoPlay className="creation-media-preview-video" src={url} /> : <img className="creation-media-preview-image" src={url} alt="媒体预览" /> : null}</Modal>;
}
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 <div className={primary ? "creation-chat-reference is-paper creation-chat-reference-media" : "creation-chat-attachment"}>
<button type="button" className={`creation-chat-attachment-preview${previewable ? "" : " is-file"}`} onClick={() => { if (previewable) onPreview(kind === "video" ? "video" : "image", url); }} aria-label={previewable ? `放大预览 ${item.name}` : item.name} disabled={previewable && !url}>
return <div className="creation-reference-card-content">
<button type="button" className={`creation-reference-card-preview${previewable ? "" : " is-file"}`} onClick={() => { if (previewable) onPreview(kind === "video" ? "video" : "image", url); }} aria-label={previewable ? `放大预览 ${item.name}` : item.name} disabled={previewable && !url}>
{kind === "video" ? <video src={item.url} poster={item.previewUrl !== item.url ? item.previewUrl : undefined} muted playsInline preload="metadata" aria-label={item.name} /> : kind === "image" ? <img src={item.previewUrl} alt={item.name} /> : <span className="creation-chat-file-icon">{kind === "audio" ? <Music2 /> : <FileText />}<em>{item.name}</em></span>}
{previewable ? <span aria-hidden="true"><Maximize2 /></span> : null}
</button>
<button type="button" className="creation-chat-attachment-remove" onClick={() => onRemove(item.id)} aria-label={`移除 ${item.name}`}><X /></button>
{primary && canAddMore && onAdd ? <Tooltip title="添加更多参考内容"><button type="button" className="creation-chat-reference-add" onClick={onAdd} aria-label="添加更多参考内容"><Plus /></button></Tooltip> : null}
<button type="button" className="creation-reference-card-remove" onClick={(event) => { event.stopPropagation(); onRemove(item.id); }} aria-label={`移除 ${item.name}`}><X /></button>
</div>;
}
@@ -1096,6 +1098,7 @@ type ComposerProps = {
maxReferences: number;
references: CreationReference[];
onRemoveAttachment: (id: string) => void;
onReorderAttachments: (attachments: CreationAttachment[]) => void;
onOpenLibrary: () => void;
fileInputRef: RefObject<HTMLInputElement | null>;
onFileChange: (event: ChangeEvent<HTMLInputElement>) => 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<HTMLUListElement>(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<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLElement>) => {
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 = <section className={`creation-chat-composer is-${props.variant}`}>
<div className="creation-chat-writing-surface">
<input ref={props.fileInputRef} type="file" hidden accept={creationUploadAccept(props.mode)} multiple onChange={props.onFileChange} />
{primaryAttachment ? <CreationAttachmentThumbnail item={primaryAttachment} primary canAddMore={canAddMoreReferences && !props.busy} onPreview={previewAttachment} onRemove={props.onRemoveAttachment} onAdd={props.onOpenLibrary} /> : <Tooltip title={!referencesSupported ? "当前模型不支持参考媒体" : "从素材库选择参考内容"}><button type="button" className="creation-chat-reference is-paper" onClick={props.onOpenLibrary} disabled={props.busy || !referencesSupported} aria-label="打开素材库选择参考内容"><Plus /><span>参考内容</span></button></Tooltip>}
<div className="creation-chat-editor">
<CanvasResourceMentionTextarea ref={props.composerFocusRef} value={props.prompt} references={props.references} mentionMenuWidth={400} sendOnEnter={false} onChange={props.setPrompt} onSubmit={props.onSubmit} containerClassName="creation-chat-mention-container" className="creation-chat-mention-editor creation-scrollbar" style={{ color: "var(--creation-text)" }} placeholder={props.placeholderOverride || (props.variant === "empty" ? emptyPlaceholder : placeholder)} aria-label="创作提示词,可使用 @ 引用当前参考内容或技能" spellCheck disabled={props.busy} />
{secondaryAttachments.length ? <div className="creation-chat-attachment-strip">{secondaryAttachments.map((item) => <CreationAttachmentThumbnail key={item.id} item={item} onPreview={previewAttachment} onRemove={props.onRemoveAttachment} />)}</div> : null}
{props.attachments.length || canAddMoreReferences ? <div className="creation-reference-track-wrapper">
<div className="creation-reference-stack-shell" onMouseEnter={handleTrackMouseEnter} onMouseLeave={handleTrackMouseLeave} onFocus={handleTrackFocus} onBlur={handleTrackBlur}>
{trackState.canScrollLeft ? <button type="button" className="creation-reference-track-button is-left" onClick={() => scrollAttachmentTrack(-1)} aria-label="向左浏览参考内容" title="向左浏览参考内容"><ChevronLeft aria-hidden="true" /></button> : null}
<Reorder.Group<CreationAttachment[]>
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) => <Reorder.Item<CreationAttachment>
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 }))}
>
<CreationAttachmentThumbnail item={item} onPreview={previewAttachment} onRemove={props.onRemoveAttachment} />
</Reorder.Item>)}
{canAddMoreReferences ? <li className="creation-reference-add-slot"><Tooltip title="添加更多参考内容"><button type="button" className="creation-reference-add-button" onClick={props.onOpenLibrary} disabled={props.busy} aria-label="添加更多参考内容"><Plus aria-hidden="true" /></button></Tooltip></li> : null}
</Reorder.Group>
{trackState.canScrollRight ? <button type="button" className="creation-reference-track-button is-right" onClick={() => scrollAttachmentTrack(1)} aria-label="向右浏览参考内容" title="向右浏览参考内容"><ChevronRight aria-hidden="true" /></button> : null}
</div>
</div> : null}
</div>
</div>
<footer className="creation-chat-dock">

View File

@@ -9372,6 +9372,127 @@ html:not(.dark) .app-workspace-shell.is-creation-workspace .app-workspace-stage
.creation-chat-reference-add:focus-visible { outline: var(--stroke-2) solid var(--control-focus-ring); outline-offset: var(--space-half); }
.creation-chat-composer .creation-chat-writing-surface { grid-template-columns: 62px minmax(0, 1fr); gap: 16px; }
.creation-home .creation-chat-composer.is-empty .creation-chat-writing-surface,
.creation-home .creation-chat-composer.is-thread .creation-chat-writing-surface {
grid-template-columns: minmax(0, 1fr);
gap: 0;
}
.creation-home .creation-reference-track-wrapper { position: relative; min-width: 0; margin: -2px 0 var(--space-2); }
.creation-home .creation-reference-stack-shell { position: relative; width: fit-content; min-width: 0; max-width: 100%; }
.creation-home .creation-reference-track {
display: flex;
width: fit-content;
min-width: 0;
max-width: 100%;
align-items: flex-start;
gap: 0;
margin: 0;
list-style: none;
padding: var(--space-1) var(--space-half);
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-behavior: smooth;
scrollbar-width: none;
touch-action: pan-x;
user-select: none;
}
.creation-home .creation-reference-track::-webkit-scrollbar { display: none; }
.creation-home .creation-reference-track.is-expanded { gap: var(--space-2); }
.creation-home .creation-reference-track.is-dragging { cursor: grabbing; scroll-behavior: auto; }
.creation-home .creation-reference-track:not(.is-dragging):not(.is-expanded) { cursor: default; }
.creation-home .creation-reference-track.is-expanded:not(.is-dragging) { cursor: grab; }
.creation-home .creation-reference-stack-card {
position: relative;
display: block;
width: 58px;
height: 77px;
flex: 0 0 58px;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
transition: margin-left var(--motion-state) var(--motion-ease-out), box-shadow var(--motion-dur-fast-calc) ease;
}
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card { margin-left: -48px; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(1) { --stack-rotate: -7deg; margin-left: 0; z-index: 4; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(2) { --stack-rotate: 2deg; z-index: 3; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(3) { --stack-rotate: -5deg; z-index: 2; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(4),
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(n+5) { --stack-rotate: 3deg; z-index: 1; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(n+5) { display: none; }
.creation-home .creation-reference-track.is-expanded .creation-reference-stack-card { width: 52px; height: 69px; flex-basis: 52px; margin-left: 0; z-index: auto; }
.creation-home .creation-reference-card-content {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border: var(--stroke-1) solid var(--creation-border);
border-radius: var(--r-sm);
background: var(--creation-surface-muted);
transform: rotate(var(--stack-rotate, 0deg));
transform-origin: center bottom;
transition: transform var(--motion-state) var(--motion-ease-out), box-shadow var(--motion-dur-fast-calc) ease;
}
.creation-home .creation-reference-track.is-expanded .creation-reference-card-content { transform: rotate(0deg); }
.creation-home .creation-reference-stack-card[data-framer-dragging="true"] { margin-left: 0; box-shadow: var(--elevation-overlay); z-index: var(--z-popover); }
.creation-home .creation-reference-stack-card[data-framer-dragging="true"] .creation-reference-card-content { transform: rotate(0deg) scale(1.05); }
.creation-home .creation-reference-card-preview { display: block; width: 100%; height: 100%; border: 0; background: transparent; padding: 0; cursor: zoom-in; }
.creation-home .creation-reference-card-preview img,
.creation-home .creation-reference-card-preview video { width: 100%; height: 100%; object-fit: cover; transition: transform var(--motion-dur-fast-calc) var(--motion-ease-out); }
.creation-home .creation-reference-card-preview:hover img,
.creation-home .creation-reference-card-preview:hover video { transform: scale(1.04); }
.creation-home .creation-reference-card-preview > span { position: absolute; right: var(--space-1); bottom: var(--space-1); display: grid; width: var(--space-5); height: var(--space-5); place-items: center; border-radius: var(--r-xs); background: color-mix(in srgb, var(--color-neutral-950) 72%, transparent); color: var(--color-neutral-0); opacity: 0; transition: opacity var(--motion-dur-fast-calc) ease; }
.creation-home .creation-reference-card-preview:hover > span,
.creation-home .creation-reference-card-preview:focus-visible > span { opacity: 1; }
.creation-home .creation-reference-card-preview:focus-visible { outline: var(--stroke-2) solid var(--control-focus-ring); outline-offset: calc(-1 * var(--stroke-2)); }
.creation-home .creation-reference-card-remove { position: absolute; top: var(--space-1); right: var(--space-1); z-index: 1; display: grid; width: var(--space-5); height: var(--space-5); place-items: center; border: 0; border-radius: var(--r-xs); background: color-mix(in srgb, var(--color-neutral-950) 76%, transparent); color: var(--color-neutral-0); padding: 0; opacity: 0; transition: opacity var(--motion-dur-fast-calc) ease, background-color var(--motion-dur-fast-calc) ease; }
.creation-home .creation-reference-stack-card:hover .creation-reference-card-remove,
.creation-home .creation-reference-card-remove:focus-visible { opacity: 1; }
.creation-home .creation-reference-card-remove:hover { background: var(--status-error); }
.creation-home .creation-reference-card-remove svg { width: var(--space-3); height: var(--space-3); }
.creation-home .creation-reference-add-slot { display: flex; flex: 0 0 auto; list-style: none; }
.creation-home .creation-reference-add-button { display: grid; width: 58px; height: 77px; flex: 0 0 58px; place-items: center; border: var(--stroke-1) dashed var(--creation-border); border-radius: var(--r-sm); background: transparent; color: var(--creation-muted); padding: 0; transition: border-color var(--motion-dur-fast-calc) ease, background-color var(--motion-dur-fast-calc) ease, color var(--motion-dur-fast-calc) ease, width var(--motion-state) var(--motion-ease-out), height var(--motion-state) var(--motion-ease-out); }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-add-slot { margin-left: var(--space-2); }
.creation-home .creation-reference-track.is-empty .creation-reference-add-slot { margin-left: 0; }
.creation-home .creation-reference-track.is-expanded .creation-reference-add-button { width: 52px; height: 69px; flex-basis: 52px; }
.creation-home .creation-reference-add-button:hover:not(:disabled) { border-color: var(--creation-text); background: var(--creation-surface-hover); color: var(--creation-text); }
.creation-home .creation-reference-add-button:disabled { cursor: wait; opacity: .5; }
.creation-home .creation-reference-add-button svg { width: var(--space-5); height: var(--space-5); }
.creation-home .creation-reference-track-button { position: absolute; z-index: 1; top: 50%; display: grid; width: var(--space-6); height: var(--space-6); place-items: center; border: var(--stroke-1) solid color-mix(in srgb, var(--color-neutral-0) 12%, transparent); border-radius: var(--r-xs); background: color-mix(in srgb, var(--color-neutral-950) 76%, transparent); color: var(--color-neutral-0); padding: 0; transform: translateY(-50%); transition: background-color var(--motion-dur-fast-calc) ease; }
.creation-home .creation-reference-track-button:hover { background: color-mix(in srgb, var(--color-neutral-950) 92%, transparent); }
.creation-home .creation-reference-track-button:focus-visible { outline: var(--stroke-2) solid var(--control-focus-ring); outline-offset: var(--space-half); }
.creation-home .creation-reference-track-button.is-left { left: var(--space-1); }
.creation-home .creation-reference-track-button.is-right { right: var(--space-1); }
.creation-home .creation-reference-track-button svg { width: var(--space-4); height: var(--space-4); }
html:not(.dark) .creation-home .creation-reference-track-button { border-color: rgba(28, 32, 37, .12); background: rgba(255, 255, 255, .9); color: #272c32; box-shadow: var(--elevation-overlay); }
html:not(.dark) .creation-home .creation-reference-add-button:hover:not(:disabled) { background: rgba(28, 32, 37, .055); }
@media (max-width: 680px) {
.creation-home .creation-reference-track-wrapper { margin-bottom: var(--space-2); }
.creation-home .creation-reference-stack-card { width: 46px; height: 61px; flex-basis: 46px; }
.creation-home .creation-reference-track.is-expanded .creation-reference-stack-card { width: 42px; height: 56px; flex-basis: 42px; }
.creation-home .creation-reference-add-button { width: 46px; height: 61px; flex-basis: 46px; }
.creation-home .creation-reference-track.is-expanded .creation-reference-add-button { width: 42px; height: 56px; flex-basis: 42px; }
.creation-home .creation-reference-track-button { width: var(--space-5); height: var(--space-5); }
.creation-home .creation-reference-track-button.is-left { left: 0; }
.creation-home .creation-reference-track-button.is-right { right: 0; }
}
@media (hover: none) {
.creation-home .creation-reference-track { gap: var(--space-2); }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card { display: block; margin-left: 0; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(n+5) { display: block; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-card-content { transform: rotate(0deg); }
.creation-home .creation-reference-stack-card { width: 52px; height: 69px; flex-basis: 52px; margin-left: 0; }
.creation-home .creation-reference-add-button { width: 52px; height: 69px; flex-basis: 52px; }
}
@media (prefers-reduced-motion: reduce) {
.creation-home .creation-reference-stack-card,
.creation-home .creation-reference-add-button { transition: box-shadow var(--motion-dur-fast-calc) ease; }
}
.creation-custom-value {
display: flex;
height: 28px;

View File

@@ -54,12 +54,33 @@ describe("creation library button", () => {
expect(canvasSource).toContain("onClick={() => onInsert(reference)}");
});
test("promotes the first reference into the primary reference slot", () => {
test("参考内容层叠轨道支持折叠、展开和 Reorder 排序", () => {
const source = readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8");
const styles = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8");
expect(source).toContain("const [primaryAttachment, ...secondaryAttachments] = props.attachments");
expect(source).toContain("<CreationAttachmentThumbnail item={primaryAttachment} primary");
expect(source).toContain("secondaryAttachments.map((item) => <CreationAttachmentThumbnail");
expect(source).toContain('className={primary ? "creation-chat-reference is-paper creation-chat-reference-media" : "creation-chat-attachment"}');
expect(source).toContain('import { Reorder } from "motion/react"');
expect(source).toContain("<Reorder.Group");
expect(source).toContain('axis="x"');
expect(source).toContain("values={props.attachments}");
expect(source).toContain("onReorder={props.onReorderAttachments}");
expect(source).toContain("<Reorder.Item");
expect(source).toContain('layout="position"');
expect(source).toContain("isExpanded");
expect(source).toContain("handleTrackMouseEnter");
expect(source).toContain("handleTrackMouseLeave");
expect(source).toContain("handleTrackFocus");
expect(source).toContain("handleTrackBlur");
expect(source).toContain("creation-reference-track-wrapper");
expect(source).toContain("creation-reference-stack-card");
expect(source).toContain("creation-reference-add-button");
expect(source).toContain("creation-reference-track-button");
expect(source).toContain("CanvasPromptOptimizerDrawer");
expect(source).toContain("promptOptimizerOpen");
expect(source).toContain("provider={props.promptOptimizerProvider}");
expect(styles).toContain(".creation-reference-track");
expect(styles).toContain(".creation-reference-stack-card");
expect(styles).toContain("--stack-rotate: -7deg");
expect(styles).toContain(".creation-reference-track.is-expanded");
expect(styles).toContain("@media (hover: none)");
});
});