fix(canvas): 创作参考内容 - 修复图片预览并将模型价格移至发送按钮

This commit is contained in:
ddCat
2026-09-01 19:55:07 +08:00
parent b8c500cb40
commit 2832fee9c1
5 changed files with 59 additions and 24 deletions

View File

@@ -226,7 +226,7 @@ export function ModelPicker({
theme={theme}
creationVariant={creationVariant}
showConfiguredModelName={showConfiguredModelName}
showPrice={creditsEnabled}
showPrice={showSelectedPrice && creditsEnabled}
disabledReason={disabledReason}
/>
{selected ? <Check className="canvas-model-picker-option-check" style={{ color: theme.node.activeStroke }} /> : null}

View File

@@ -1,6 +1,7 @@
import type { UploadedFile } from "@/services/file-storage";
import type { UploadedImage } from "@/services/image-storage";
import { canvasVideoAssetPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { resolveResourceUrl } from "@/services/api/resources";
import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types";
import type { Asset, AudioAsset, ImageAsset, NewAsset } from "@/stores/use-asset-store";
import type { ReferenceImage } from "@/types/image";
@@ -151,7 +152,7 @@ export function creationAttachmentFromDocument(file: File, uploaded: UploadedFil
}
export function creationAttachmentFromAsset(asset: ImageAsset): CreationAttachment {
const url = asset.data.dataUrl || asset.coverUrl;
const url = resolveResourceUrl(asset.data.storageKey, asset.data.dataUrl || asset.coverUrl);
return {
id: `asset:${asset.id}`,
name: asset.title || "素材图片",

View File

@@ -1,13 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type PointerEvent, type ReactNode, type RefObject } from "react";
import { App, Button, Drawer, Modal, Popover, Spin, Tooltip } from "antd";
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, Minimize2, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, WandSparkles, X } from "lucide-react";
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, 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";
import { GenerationToolCard, type GenerationToolStatus } from "@/components/ai/generation-tool-card";
import { MessageReasoning } from "@/components/ai/message-reasoning";
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
import { CanvasPromptOptimizerDrawer } from "@/components/canvas/canvas-prompt-optimizer-drawer";
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
@@ -29,6 +30,7 @@ import { isGenerationTaskCancelled, runBackendGenerationTask, runBackendGenerati
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center";
import { isLocalDreaminaWaitStopped, localDreaminaCancellationMessage } from "@/services/local-dreamina-task-projection";
import { resolveResourceUrl } from "@/services/api/resources";
import { uploadMediaFile } from "@/services/file-storage";
import { uploadImage } from "@/services/image-storage";
import { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync";
@@ -1086,7 +1088,9 @@ function CreationUserMessage({ item }: { item: CreationMessage }) {
const kind = creationAttachmentKind(attachment);
const previewable = kind === "image" || kind === "video";
const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || "";
return <button key={attachment.id} type="button" className={!previewable ? "is-file" : undefined} onClick={() => { if (!previewable) return; setPreviewType(kind === "video" ? "video" : "image"); setPreviewUrl(kind === "video" ? attachment.url || "" : url); }} aria-label={previewable ? `预览 ${attachment.name || "附件"}` : attachment.name || "附件"} disabled={previewable && !url}>{kind === "video" ? <video src={attachment.url || ""} poster={url !== attachment.url ? url : undefined} muted playsInline preload="metadata" /> : kind === "image" ? <img src={url} alt={attachment.name || "附件"} width={44} height={44} loading="lazy" /> : kind === "audio" ? <Music2 /> : <FileText />}{previewable ? <span aria-hidden="true"><Maximize2 /></span> : null}</button>;
const imageUrl = kind === "image" ? resolveResourceUrl(attachment.storageKey, url) : "";
const previewUrl = kind === "image" ? imageUrl : url;
return <button key={attachment.id} type="button" className={!previewable ? "is-file" : undefined} onClick={() => { if (!previewable) return; setPreviewType(kind === "video" ? "video" : "image"); setPreviewUrl(kind === "video" ? attachment.url || "" : previewUrl); }} aria-label={previewable ? `预览 ${attachment.name || "附件"}` : attachment.name || "附件"} disabled={previewable && !previewUrl}>{kind === "video" ? <video src={attachment.url || ""} poster={url !== attachment.url ? url : undefined} muted playsInline preload="metadata" /> : kind === "image" ? <CachedResourceImage storageKey={attachment.storageKey} src={imageUrl} alt={attachment.name || "附件"} width={44} height={44} loading="lazy" decoding="async" /> : kind === "audio" ? <Music2 /> : <FileText />}{previewable ? <span aria-hidden="true"><Maximize2 /></span> : null}</button>;
})}</div> : null}
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
</article>;
@@ -1117,7 +1121,8 @@ function CreationMediaPending({ mode, ratio }: { mode: CreationMode; ratio?: str
function CreationMessageReferences({ references }: { references: CreationReference[] }) {
return <div className="creation-user-message-references" aria-label="本次引用">{references.map((reference) => {
const Icon = reference.kind === "skill" ? Sparkles : reference.kind === "image" ? ImageIcon : reference.kind === "video" ? Film : reference.kind === "audio" ? Music2 : FileText;
return <span key={reference.id} className="creation-user-message-reference">{reference.previewUrl && reference.kind === "video" ? <video src={reference.previewUrl} muted playsInline preload="metadata" aria-label={reference.label} /> : reference.previewUrl && reference.kind === "image" ? <img src={reference.previewUrl} alt="" /> : <Icon />}<span>{reference.label}</span></span>;
const imageUrl = reference.kind === "image" ? resolveResourceUrl(reference.storageKey, reference.previewUrl) : reference.previewUrl;
return <span key={reference.id} className="creation-user-message-reference">{imageUrl && reference.kind === "video" ? <video src={imageUrl} muted playsInline preload="metadata" aria-label={reference.label} /> : imageUrl && reference.kind === "image" ? <CachedResourceImage storageKey={reference.storageKey} src={imageUrl} alt="" loading="lazy" decoding="async" /> : <Icon />}<span>{reference.label}</span></span>;
})}</div>;
}
@@ -1133,9 +1138,11 @@ function CreationAttachmentThumbnail({ item, onPreview, onRemove }: {
const kind = creationAttachmentKind(item);
const previewable = kind === "image" || kind === "video";
const url = (kind === "video" ? item.url : item.previewUrl) || "";
const content = 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>;
const imageUrl = kind === "image" ? resolveResourceUrl(item.storageKey, item.previewUrl) : "";
const previewUrl = kind === "image" ? imageUrl : url;
const content = kind === "video" ? <video src={item.url} poster={item.previewUrl !== item.url ? item.previewUrl : undefined} muted playsInline preload="metadata" aria-label={item.name} /> : kind === "image" ? <CachedResourceImage storageKey={item.storageKey} src={imageUrl} alt={item.name} loading="lazy" decoding="async" fallback={<span className="creation-chat-file-icon"><ImageIcon /></span>} /> : <span className="creation-chat-file-icon">{kind === "audio" ? <Music2 /> : <FileText />}<em>{item.name}</em></span>;
return <div className="creation-reference-card-content">
{previewable ? <button type="button" className="creation-reference-card-preview" onClick={() => onPreview(kind === "video" ? "video" : "image", url)} aria-label={`放大预览 ${item.name}`} disabled={!url}>{content}<span aria-hidden="true"><Maximize2 /></span></button> : <div className="creation-reference-card-preview is-file" aria-label={item.name}>{content}</div>}
{previewable ? <button type="button" className="creation-reference-card-preview" onClick={() => onPreview(kind === "video" ? "video" : "image", previewUrl)} aria-label={`放大预览 ${item.name}`} disabled={!previewUrl}>{content}<span aria-hidden="true"><Maximize2 /></span></button> : <div className="creation-reference-card-preview is-file" aria-label={item.name}>{content}</div>}
<button type="button" className="creation-reference-card-remove" onPointerDownCapture={(event) => event.stopPropagation()} onMouseDownCapture={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onRemove(item.id); }} aria-label={`移除 ${item.name}`}><X /></button>
</div>;
}
@@ -1194,7 +1201,8 @@ function CreationComposer(props: ComposerProps) {
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 [trackState, setTrackState] = useState({ canScrollLeft: false, canScrollRight: false, isExpanded: true, isDragging: false });
const previousAttachmentCountRef = useRef(props.attachments.length);
const interactionBusy = props.busy || props.referenceReplacementBusy;
const canSubmit = Boolean(props.prompt.trim()) && !interactionBusy;
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
@@ -1244,7 +1252,10 @@ function CreationComposer(props: ComposerProps) {
if (!isExpanded) setReferenceFilter("all");
}, []);
useEffect(() => {
const hadAttachments = previousAttachmentCountRef.current > 0;
if (!props.attachments.length) setReferencePanelExpanded(false);
else if (!hadAttachments) setReferencePanelExpanded(true);
previousAttachmentCountRef.current = props.attachments.length;
updateTrackScrollState();
}, [props.attachments.length, setReferencePanelExpanded, updateTrackScrollState]);
useEffect(() => {
@@ -1405,9 +1416,8 @@ function CreationComposer(props: ComposerProps) {
<span>优化</span>
</button>
</Tooltip> : null}
<Tooltip title="从本机上传附件"><button type="button" className="creation-chat-control" onClick={() => props.fileInputRef.current?.click()} disabled={interactionBusy || !referencesSupported} aria-label="从本机上传附件"><Paperclip /><span>附件</span></button></Tooltip>
<Tooltip title={!referencesSupported ? "当前模型不支持参考媒体" : "从素材库选择参考内容"}><button type="button" className="creation-chat-control" onClick={props.onOpenLibrary} disabled={interactionBusy || !referencesSupported} aria-label="打开素材库选择参考内容"><FolderOpen /><span>素材库</span></button></Tooltip>
<ModelPicker config={props.config} value={props.model} onChange={props.onModelChange} capability={props.mode} requirements={props.modelRequirements} className="creation-model-picker" placeholder={`选择${modeLabels[props.mode]}模型`} showSelectedPrice variant="creation" />
<Tooltip title="从本机上传附件"><button type="button" className="creation-chat-control" onClick={() => props.fileInputRef.current?.click()} disabled={interactionBusy || !referencesSupported} aria-label="从本机上传附件"><Paperclip /><span>附件</span></button></Tooltip>
<ModelPicker config={props.config} value={props.model} onChange={props.onModelChange} capability={props.mode} requirements={props.modelRequirements} className="creation-model-picker" placeholder={`选择${modeLabels[props.mode]}模型`} showSelectedPrice={false} variant="creation" />
{props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? <GenerationSettingsMenu {...props} /> : null}
{props.mode === "video" ? <DurationMenu profile={props.videoProfile} seconds={props.seconds} onChange={props.setSeconds} /> : null}
</div>
@@ -1723,7 +1733,9 @@ function StoryboardBriefAttachments({ attachments }: { attachments: CreationAtta
const kind = creationAttachmentKind(attachment);
const previewable = kind === "image" || kind === "video";
const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || "";
return <button key={attachment.id} type="button" className={!previewable ? "is-file" : undefined} onClick={() => { if (!previewable) return; setPreviewType(kind === "video" ? "video" : "image"); setPreviewUrl(kind === "video" ? attachment.url || "" : url); }} aria-label={previewable ? `预览 ${attachment.name || "附件"}` : attachment.name || "附件"} disabled={previewable && !url}>{kind === "video" ? <video src={attachment.url || ""} poster={url !== attachment.url ? url : undefined} muted playsInline preload="metadata" /> : kind === "image" ? <img src={url} alt={attachment.name || "附件"} width={44} height={44} loading="lazy" /> : kind === "audio" ? <Music2 /> : <FileText />}{previewable ? <span aria-hidden="true"><Maximize2 /></span> : null}</button>;
const imageUrl = kind === "image" ? resolveResourceUrl(attachment.storageKey, url) : "";
const previewUrl = kind === "image" ? imageUrl : url;
return <button key={attachment.id} type="button" className={!previewable ? "is-file" : undefined} onClick={() => { if (!previewable) return; setPreviewType(kind === "video" ? "video" : "image"); setPreviewUrl(kind === "video" ? attachment.url || "" : previewUrl); }} aria-label={previewable ? `预览 ${attachment.name || "附件"}` : attachment.name || "附件"} disabled={previewable && !previewUrl}>{kind === "video" ? <video src={attachment.url || ""} poster={url !== attachment.url ? url : undefined} muted playsInline preload="metadata" /> : kind === "image" ? <CachedResourceImage storageKey={attachment.storageKey} src={imageUrl} alt={attachment.name || "附件"} width={44} height={44} loading="lazy" decoding="async" /> : kind === "audio" ? <Music2 /> : <FileText />}{previewable ? <span aria-hidden="true"><Maximize2 /></span> : null}</button>;
})}</div><CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} /></>;
}

View File

@@ -10561,13 +10561,15 @@ html:not(.dark) .app-workspace-shell.is-creation-workspace .app-workspace-stage
.creation-home .creation-reference-panel { width: fit-content; min-width: 0; max-width: 100%; margin: -2px 0 var(--space-2); }
.creation-home .creation-reference-panel.is-expanded { width: 100%; }
.creation-home .creation-reference-panel-header { display: flex; min-height: var(--space-8); flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-2) var(--space-3); margin-bottom: var(--space-2); }
.creation-home .creation-reference-filter-tabs { display: flex; min-width: 0; flex: 1 1 auto; align-items: center; gap: var(--space-1); overflow-x: auto; scrollbar-width: none; }
.creation-home .creation-reference-filter-tabs { display: flex; min-width: 0; flex: 1 1 auto; align-items: center; gap: var(--space-3); overflow-x: auto; scrollbar-width: none; }
.creation-home .creation-reference-filter-tabs::-webkit-scrollbar { display: none; }
.creation-home .creation-reference-filter-tabs button,
.creation-home .creation-reference-panel-actions button { min-height: var(--space-7); border: 0; border-radius: var(--r-md); background: transparent; color: var(--creation-muted); padding: 0 var(--space-3); font-size: var(--fs-caption); line-height: 1; white-space: nowrap; transition: background-color var(--motion-dur-fast-calc) ease, color var(--motion-dur-fast-calc) ease; }
.creation-home .creation-reference-panel-actions button { min-height: var(--space-7); border: 0; border-radius: 0; background: transparent; color: var(--creation-muted); padding: 0 2px; font-size: var(--fs-caption); line-height: 1; white-space: nowrap; transition: color var(--motion-dur-fast-calc) ease, opacity var(--motion-dur-fast-calc) ease; }
.creation-home .creation-reference-filter-tabs button:hover,
.creation-home .creation-reference-filter-tabs button.is-active,
.creation-home .creation-reference-panel-actions button:hover:not(:disabled) { background: var(--creation-surface-hover); color: var(--creation-text); }
.creation-home .creation-reference-panel-actions button:hover:not(:disabled) { color: var(--creation-text); }
.creation-home .creation-reference-filter-tabs button { position: relative; }
.creation-home .creation-reference-filter-tabs button.is-active::after { position: absolute; right: 2px; bottom: -1px; left: 2px; height: 2px; border-radius: var(--r-full); background: var(--creation-text); content: ""; }
.creation-home .creation-reference-filter-tabs button:focus-visible,
.creation-home .creation-reference-panel-actions button:focus-visible,
.creation-home .creation-reference-panel-expand:focus-visible { outline: var(--stroke-2) solid var(--control-focus-ring); outline-offset: var(--space-half); }
@@ -10646,9 +10648,10 @@ html:not(.dark) .app-workspace-shell.is-creation-workspace .app-workspace-stage
.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 > span:not(.cached-resource-image-shell) { 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:not(.cached-resource-image-shell),
.creation-home .creation-reference-card-preview:focus-visible > span:not(.cached-resource-image-shell) { opacity: 1; }
.creation-home .creation-reference-card-preview > .cached-resource-image-shell { position: static; display: grid; width: 100%; height: 100%; place-items: center; }
.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,
@@ -10844,10 +10847,14 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
.creation-user-message-references { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
.creation-user-message-reference { display: inline-flex; min-width: 0; max-width: 180px; height: 25px; align-items: center; gap: 5px; border-radius: 6px; background: rgba(255, 255, 255, .075); padding: 0 7px; color: var(--creation-muted); font-size: 10px; line-height: 1; }
.creation-user-message-reference img, .creation-user-message-reference video { width: 17px; height: 17px; flex: 0 0 auto; border-radius: 4px; object-fit: cover; }
.creation-user-message-reference .cached-resource-image-shell { display: block; width: 17px; height: 17px; flex: 0 0 17px; overflow: hidden; border-radius: 4px; }
.creation-user-message-reference .cached-resource-image-shell > img { display: block; width: 100%; height: 100%; object-fit: cover; }
.creation-user-message-reference svg { width: 12px; height: 12px; flex: 0 0 auto; }
.creation-user-message-reference span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
html:not(.dark) .creation-user-message-reference { background: rgba(28, 32, 37, .07); }
.creation-user-message-attachments button { position: relative; width: 44px; height: 44px; flex: 0 0 auto; overflow: hidden; border: 0; border-radius: 7px; background: transparent; padding: 0; }
.creation-user-message-attachments button > .cached-resource-image-shell { display: block; width: 100%; height: 100%; }
.creation-user-message-attachments button > .cached-resource-image-shell > img { display: block; width: 100%; height: 100%; object-fit: cover; }
.creation-user-message-attachments button:focus-visible { outline: 2px solid var(--creation-text); outline-offset: 2px; }
.creation-user-message-attachments button > span { position: absolute; right: 3px; bottom: 3px; display: grid; width: 17px; height: 17px; place-items: center; border-radius: 5px; background: rgba(0, 0, 0, .68); color: #fff; opacity: 0; transition: opacity 140ms ease; }
.creation-user-message-attachments button:hover > span,

View File

@@ -9,7 +9,7 @@ function compactSource(source: string) {
}
describe("creation library button", () => {
test("places a library control beside the generation mode picker", () => {
test("keeps library selection in the reference area instead of the bottom dock", () => {
const source = readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8");
const dockStart = source.indexOf('<footer className="creation-chat-dock">');
const dockEnd = source.indexOf("</footer>", dockStart);
@@ -19,13 +19,14 @@ describe("creation library button", () => {
const dockSource = compactSource(source.slice(dockStart, dockEnd));
const modePickerIndex = dockSource.indexOf("<ModePicker mode={props.mode}");
const attachmentIndex = dockSource.indexOf('aria-label="从本机上传附件"');
const libraryIndex = dockSource.indexOf('aria-label="打开素材库选择参考内容"');
expect(modePickerIndex).toBeGreaterThanOrEqual(0);
expect(attachmentIndex).toBeGreaterThan(modePickerIndex);
expect(libraryIndex).toBeGreaterThan(attachmentIndex);
expect(dockSource).toContain("onClick={props.onOpenLibrary}");
expect(dockSource).toContain("disabled={interactionBusy || !referencesSupported}");
expect(dockSource).not.toContain('aria-label="打开素材库选择参考内容"');
expect(source).toContain("onClick={props.onOpenLibrary}");
expect(source).toContain("creation-reference-add-button");
expect(source).toContain('showSelectedPrice={false} variant="creation"');
expect(source).toContain("canvas-node-composer-submit-cost");
});
test("uploads from the library without adding a reference before confirmation", () => {
@@ -65,7 +66,9 @@ describe("creation library button", () => {
expect(source).toContain('axis="x"');
expect(source).toContain("values={visibleAttachments}");
expect(source).toContain("onReorder={reorderVisibleAttachments}");
expect(source).toContain('className="creation-reference-card-remove" onPointerDownCapture={(event) => event.stopPropagation()}');
expect(source).toContain('className="creation-reference-card-remove"');
expect(source).toContain("onPointerDownCapture");
expect(source).toContain("event.stopPropagation()");
expect(source).toContain("<Reorder.Item");
expect(source).toContain('layout="position"');
expect(source).toContain("isExpanded");
@@ -99,6 +102,18 @@ describe("creation library button", () => {
expect(styles).toContain("@media (hover: none)");
expect(styles).toContain(".creation-reference-card-remove { opacity: 1; }");
expect(styles).not.toContain(".creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(n+5) { display: block; }");
expect(source).toContain("isExpanded: true");
expect(source).toContain("else if (!hadAttachments) setReferencePanelExpanded(true)");
expect(styles).toContain("creation-reference-filter-tabs button.is-active::after");
});
test("resolves remote asset images from their stable resource key", () => {
const assets = readFileSync(resolve(import.meta.dir, "../src/pages/create/creation-assets.ts"), "utf8");
const createSource = readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8");
expect(assets).toContain("resolveResourceUrl(asset.data.storageKey");
expect(createSource).toContain("<CachedResourceImage storageKey={item.storageKey}");
expect(createSource).toContain("resolveResourceUrl(item.storageKey, item.previewUrl)");
});
test("首个、中间和末尾参考内容都按稳定 id 独立删除并保留顺序", () => {