feat(canvas): 创作引用与上传 - 支持素材预览并补齐上传状态反馈
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, Maximize2, Music2, Pencil, SlidersHorizontal, Square, UserRound, Video } from "lucide-react";
|
||||
import { Button, Modal, Tooltip } from "antd";
|
||||
import { Button, Image as AntImage, Modal, Tooltip } from "antd";
|
||||
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { configuredModelMatchesCapability, defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
@@ -372,30 +372,63 @@ function modeDisplayName(mode: CanvasNodeGenerationMode) {
|
||||
|
||||
function ConnectedReferenceShelf({ references, theme, onInsert }: { references: CanvasResourceReference[]; theme: CanvasTheme; onInsert: (reference: CanvasResourceReference) => void }) {
|
||||
const activeReferences = references.filter((item) => item.active && item.kind !== "skill");
|
||||
const [imagePreview, setImagePreview] = useState<CanvasResourceReference | null>(null);
|
||||
if (!activeReferences.length) return null;
|
||||
|
||||
return (
|
||||
<div className="thin-scrollbar flex h-[42px] shrink-0 min-w-0 items-center gap-1.5 overflow-x-auto px-2.5 pt-1.5" role="group" aria-label="已连接素材">
|
||||
{activeReferences.map((reference, index) => (
|
||||
<button
|
||||
key={reference.id}
|
||||
type="button"
|
||||
className="group relative size-[34px] shrink-0 overflow-hidden rounded-md text-left transition hover:-translate-y-0.5 hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 motion-reduce:hover:translate-y-0"
|
||||
style={{ background: theme.toolbar.itemHover, color: theme.node.text, outlineColor: theme.node.activeStroke, boxShadow: `0 4px 14px ${theme.spatial.shadow}` }}
|
||||
title={`插入 @${reference.label}`}
|
||||
aria-label={`插入 @${reference.label}`}
|
||||
onClick={() => onInsert(reference)}
|
||||
>
|
||||
<span className="block size-full overflow-hidden rounded-md">
|
||||
<ReferenceThumbnail reference={reference} />
|
||||
</span>
|
||||
<span className="absolute left-0.5 top-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-[var(--fs-micro)] font-semibold text-white backdrop-blur-sm">{index + 1}</span>
|
||||
<span className="absolute bottom-0.5 right-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-white backdrop-blur-sm">
|
||||
<AtSign className="size-2" />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="thin-scrollbar flex h-[42px] shrink-0 min-w-0 items-center gap-1.5 overflow-x-auto px-2.5 pt-1.5" role="group" aria-label="已连接素材">
|
||||
{activeReferences.map((reference, index) => {
|
||||
const canPreview = (reference.kind === "image" || reference.kind === "character") && Boolean(reference.previewUrl);
|
||||
return (
|
||||
<span key={reference.id} className="relative size-[34px] shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className={`group relative size-full overflow-hidden rounded-md text-left transition hover:-translate-y-0.5 hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 motion-reduce:hover:translate-y-0${canPreview ? " cursor-zoom-in" : ""}`}
|
||||
style={{ background: theme.toolbar.itemHover, color: theme.node.text, outlineColor: theme.node.activeStroke, boxShadow: `0 4px 14px ${theme.spatial.shadow}` }}
|
||||
title={canPreview ? `预览 ${reference.title}` : `插入 @${reference.label}`}
|
||||
aria-label={canPreview ? `预览 ${reference.title}` : `插入 @${reference.label}`}
|
||||
onClick={() => canPreview ? setImagePreview(reference) : onInsert(reference)}
|
||||
>
|
||||
<span className="block size-full overflow-hidden rounded-md">
|
||||
<ReferenceThumbnail reference={reference} />
|
||||
</span>
|
||||
<span className="absolute left-0.5 top-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-[var(--fs-micro)] font-semibold text-white backdrop-blur-sm">{index + 1}</span>
|
||||
{canPreview ? <span className="absolute bottom-0.5 left-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-white backdrop-blur-sm"><Maximize2 className="size-2" /></span> : null}
|
||||
{!canPreview ? <span className="absolute bottom-0.5 right-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-white backdrop-blur-sm"><AtSign className="size-2" /></span> : null}
|
||||
</button>
|
||||
{canPreview ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute bottom-0.5 right-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-white backdrop-blur-sm transition hover:bg-black/85 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1"
|
||||
style={{ outlineColor: theme.node.activeStroke }}
|
||||
title={`插入 @${reference.label}`}
|
||||
aria-label={`插入 @${reference.label}`}
|
||||
onClick={() => onInsert(reference)}
|
||||
>
|
||||
<AtSign className="size-2" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{imagePreview?.previewUrl ? (
|
||||
<AntImage
|
||||
src={imagePreview.previewUrl}
|
||||
alt={imagePreview.title || imagePreview.label}
|
||||
style={{ display: "none" }}
|
||||
preview={{
|
||||
open: true,
|
||||
movable: true,
|
||||
minScale: 0.5,
|
||||
maxScale: 12,
|
||||
scaleStep: 0.25,
|
||||
onOpenChange: (open) => !open && setImagePreview(null),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode, type RefObject } from "react";
|
||||
import localforage from "localforage";
|
||||
import { App, Drawer, Modal, Popover, Spin, Tooltip } from "antd";
|
||||
import { ArrowUp, Check, ChevronDown, Clock3, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, Maximize2, MessageSquareText, Music2, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, Upload, X } from "lucide-react";
|
||||
import { ArrowUp, Check, ChevronDown, Clock3, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, Upload, X } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Link } from "react-router";
|
||||
|
||||
@@ -299,6 +299,7 @@ export default function CreatePage() {
|
||||
}));
|
||||
const assetIds = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []);
|
||||
const failed = settled.filter((entry) => entry.status === "rejected");
|
||||
if (assetIds.length) toast.success(`${assetIds.length} 个素材已上传到素材库并自动选中`);
|
||||
if (failed.length) toast.error(`${failed.length} 个素材上传失败,请重试`);
|
||||
return assetIds;
|
||||
};
|
||||
@@ -575,6 +576,7 @@ function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, o
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadingCount, setUploadingCount] = useState(0);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const mediaAssets = useMemo(() => assets.filter((asset): asset is Extract<Asset, { kind: "image" | "video" }> => asset.kind === "image" || asset.kind === "video"), [assets]);
|
||||
const categories = useMemo(() => ["all", ...Array.from(new Set(mediaAssets.map((asset) => asset.category || "other")))], [mediaAssets]);
|
||||
@@ -600,6 +602,9 @@ function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, o
|
||||
};
|
||||
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!event.target.files || uploading) return;
|
||||
const fileCount = event.target.files.length;
|
||||
if (!fileCount) return;
|
||||
setUploadingCount(fileCount);
|
||||
setUploading(true);
|
||||
try {
|
||||
const assetIds = await onUpload(event.target.files);
|
||||
@@ -607,19 +612,20 @@ function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, o
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
setUploading(false);
|
||||
setUploadingCount(0);
|
||||
}
|
||||
};
|
||||
const selectedAssets = mediaAssets.filter((asset) => selected.has(asset.id) && (mode === "video" || asset.kind === "image"));
|
||||
const count = category === "all" ? mediaAssets.length : mediaAssets.filter((asset) => (asset.category || "other") === category).length;
|
||||
|
||||
return <Modal open={open} footer={null} title={null} destroyOnHidden onCancel={onClose} width="min(980px, calc(100vw - 24px))" className="creation-asset-library-modal" styles={{ container: { padding: 0 }, body: { padding: 0 } }}>
|
||||
return <Modal open={open} footer={null} title={null} destroyOnHidden closable={!uploading} maskClosable={!uploading} keyboard={!uploading} onCancel={() => { if (!uploading) onClose(); }} width="min(980px, calc(100vw - 24px))" className="creation-asset-library-modal" styles={{ container: { padding: 0 }, body: { padding: 0 } }}>
|
||||
<div className="creation-library-shell">
|
||||
<div className="creation-library-toolbar"><div className="creation-library-toolbar-title"><span>参考内容</span><strong>素材库</strong></div><div className="creation-library-search"><Search /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索素材名称或标签" aria-label="搜索素材" /></div><span className="creation-library-toolbar-count">已选 {selectedAssets.length} · {count} 个素材</span></div>
|
||||
<div className="creation-library-body">
|
||||
<nav className="creation-library-categories" aria-label="素材分类">{categories.map((item) => <button key={item} type="button" className={category === item ? "is-active" : ""} onClick={() => setCategory(item)}><span>{creationAssetCategoryLabels[item] || "其他"}</span><em>{item === "all" ? mediaAssets.length : mediaAssets.filter((asset) => (asset.category || "other") === item).length}</em></button>)}</nav>
|
||||
<div className="creation-library-grid-wrap"><div className="creation-library-grid">{visibleAssets.length ? visibleAssets.map((asset) => <CreationLibraryCard key={asset.id} asset={asset} selected={selected.has(asset.id)} disabled={mode !== "video" && asset.kind === "video"} onToggle={() => toggle(asset)} />) : <div className="creation-library-empty"><FolderOpen /><strong>这个分类还没有素材</strong><span>换个分类,或从底部上传一份新素材。</span></div>}</div></div>
|
||||
</div>
|
||||
<footer className="creation-library-footer"><input ref={uploadInputRef} type="file" hidden accept={mode === "video" ? "image/*,video/*" : "image/*"} multiple onChange={handleUpload} /><button type="button" className="creation-library-upload" onClick={() => uploadInputRef.current?.click()} disabled={uploading}><Upload /><span><strong>{uploading ? "正在上传素材" : "找不到素材,手动上传"}</strong><small>支持图片{mode === "video" ? "和视频" : ""},可一次选择多个文件</small></span></button><div className="creation-library-actions"><button type="button" onClick={onClose}>取消</button><button type="button" className="is-primary" disabled={!selectedAssets.length} onClick={() => onConfirm(selectedAssets)}><Check />使用已选素材{selectedAssets.length ? `(${selectedAssets.length})` : ""}</button></div></footer>
|
||||
<footer className="creation-library-footer"><input ref={uploadInputRef} type="file" hidden accept={mode === "video" ? "image/*,video/*" : "image/*"} multiple onChange={handleUpload} /><button type="button" className="creation-library-upload" onClick={() => uploadInputRef.current?.click()} disabled={uploading} aria-busy={uploading} aria-live="polite">{uploading ? <LoaderCircle className="animate-spin" /> : <Upload />}<span><strong>{uploading ? `正在上传 ${uploadingCount} 个素材` : "上传新素材"}</strong><small>{uploading ? "正在保存到素材库,完成后会自动选中" : `支持图片${mode === "video" ? "和视频" : ""},上传后保存到素材库`}</small></span></button><div className="creation-library-actions"><button type="button" onClick={onClose} disabled={uploading}>取消</button><button type="button" className="is-primary" disabled={uploading || !selectedAssets.length} onClick={() => onConfirm(selectedAssets)}><Check />使用已选素材{selectedAssets.length ? `(${selectedAssets.length})` : ""}</button></div></footer>
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
@@ -748,6 +754,8 @@ type ComposerProps = {
|
||||
};
|
||||
|
||||
function CreationComposer(props: ComposerProps) {
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||||
const canSubmit = Boolean(props.prompt.trim()) && !props.busy;
|
||||
const placeholder = props.mode === "text"
|
||||
? "描述你的故事、角色或想继续讨论的创意"
|
||||
@@ -764,7 +772,11 @@ function CreationComposer(props: ComposerProps) {
|
||||
<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 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.variant === "empty" ? emptyPlaceholder : placeholder} aria-label="创作提示词,可使用 @ 引用当前参考内容或技能" spellCheck disabled={props.busy} />
|
||||
{props.attachments.length ? <div className="creation-chat-attachment-strip">{props.attachments.map((item) => <div key={item.id} className="creation-chat-attachment">{isVideoAttachment(item) ? <video src={item.url} poster={item.previewUrl !== item.url ? item.previewUrl : undefined} muted playsInline preload="metadata" aria-label={item.name} /> : <img src={item.previewUrl} alt={item.name} /> }<button type="button" onClick={() => props.onRemoveAttachment(item.id)} aria-label={`移除 ${item.name}`}><X /></button></div>)}</div> : null}
|
||||
{props.attachments.length ? <div className="creation-chat-attachment-strip">{props.attachments.map((item) => {
|
||||
const isVideo = isVideoAttachment(item);
|
||||
const url = isVideo ? item.url : item.previewUrl;
|
||||
return <div key={item.id} className="creation-chat-attachment"><button type="button" className="creation-chat-attachment-preview" onClick={() => { setPreviewType(isVideo ? "video" : "image"); setPreviewUrl(url); }} aria-label={`放大预览 ${item.name}`} disabled={!url}>{isVideo ? <video src={item.url} poster={item.previewUrl !== item.url ? item.previewUrl : undefined} muted playsInline preload="metadata" aria-label={item.name} /> : <img src={item.previewUrl} alt={item.name} />}<span aria-hidden="true"><Maximize2 /></span></button><button type="button" className="creation-chat-attachment-remove" onClick={() => props.onRemoveAttachment(item.id)} aria-label={`移除 ${item.name}`}><X /></button></div>;
|
||||
})}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<footer className="creation-chat-dock">
|
||||
@@ -781,6 +793,7 @@ function CreationComposer(props: ComposerProps) {
|
||||
</div>
|
||||
{props.busy ? <button type="button" className="creation-chat-submit is-stopping" onClick={props.onStop} aria-label="停止生成"><Square className="size-3.5 fill-current" /></button> : <button type="button" className="creation-chat-submit" disabled={!canSubmit} onClick={props.onSubmit} aria-label="发送"><ArrowUp className="size-4" /></button>}
|
||||
</footer>
|
||||
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
||||
</section>;
|
||||
}
|
||||
|
||||
|
||||
@@ -2809,9 +2809,14 @@
|
||||
|
||||
.creation-chat-attachment-strip { display: flex; gap: 7px; padding-top: 6px; overflow-x: auto; }
|
||||
.creation-chat-attachment { position: relative; width: 42px; height: 42px; flex: 0 0 auto; overflow: hidden; border: 1px solid var(--creation-border); border-radius: 8px; }
|
||||
.creation-chat-attachment img, .creation-chat-attachment video { width: 100%; height: 100%; object-fit: cover; }
|
||||
.creation-chat-attachment button { position: absolute; top: 2px; right: 2px; display: grid; width: 16px; height: 16px; place-items: center; border-radius: 50%; background: rgba(0, 0, 0, .7); color: white; opacity: 0; transition: opacity 140ms ease; }
|
||||
.creation-chat-attachment:hover button, .creation-chat-attachment button:focus-visible { opacity: 1; }
|
||||
.creation-chat-attachment-preview { display: block; width: 100%; height: 100%; border: 0; background: transparent; padding: 0; cursor: zoom-in; }
|
||||
.creation-chat-attachment-preview img, .creation-chat-attachment-preview video { width: 100%; height: 100%; object-fit: cover; transition: transform var(--motion-instant) var(--motion-ease-out); }
|
||||
.creation-chat-attachment-preview > span { position: absolute; bottom: 2px; left: 2px; display: grid; width: 16px; height: 16px; place-items: center; border-radius: 50%; background: rgba(0, 0, 0, .7); color: white; opacity: 0; transition: opacity var(--motion-instant) ease; }
|
||||
.creation-chat-attachment-preview:hover img, .creation-chat-attachment-preview:hover video { transform: scale(1.025); }
|
||||
.creation-chat-attachment-preview:hover > span, .creation-chat-attachment-preview:focus-visible > span { opacity: 1; }
|
||||
.creation-chat-attachment-preview:focus-visible { outline: 2px solid var(--creation-accent); outline-offset: -2px; }
|
||||
.creation-chat-attachment-remove { position: absolute; top: 2px; right: 2px; display: grid; width: 16px; height: 16px; place-items: center; border: 0; border-radius: 50%; background: rgba(0, 0, 0, .7); color: white; opacity: 0; transition: opacity var(--motion-instant) ease; }
|
||||
.creation-chat-attachment:hover .creation-chat-attachment-remove, .creation-chat-attachment-remove:focus-visible { opacity: 1; }
|
||||
.creation-chat-attachment button svg { width: 10px; height: 10px; }
|
||||
|
||||
.creation-chat-dock {
|
||||
@@ -6573,7 +6578,7 @@ html:not(.dark) .creation-user-message-reference { background: rgba(28, 32, 37,
|
||||
.creation-media-actions a:hover, .creation-media-actions button:hover { color: var(--creation-text); }
|
||||
.creation-media-actions svg { width: 13px; height: 13px; }
|
||||
|
||||
.creation-media-preview-modal .ant-modal-content { overflow: hidden; border: 0; border-radius: 12px; background: #111215; padding: 0; }
|
||||
.creation-media-preview-modal .ant-modal-container { overflow: hidden; border: 0; border-radius: 12px; background: #111215; padding: 0; }
|
||||
.creation-media-preview-modal .ant-modal-close { color: #fff; }
|
||||
.creation-media-preview-image, .creation-media-preview-video { display: block; max-width: 100%; max-height: 82vh; margin: 0 auto; object-fit: contain; }
|
||||
.creation-media-preview-video { width: 100%; background: #050505; }
|
||||
@@ -6581,7 +6586,7 @@ html:not(.dark) .creation-user-message-reference { background: rgba(28, 32, 37,
|
||||
html:not(.dark) .creation-user-message { background: rgba(28, 32, 37, .07); }
|
||||
html:not(.dark) .creation-message-content pre,
|
||||
html:not(.dark) .creation-video-result,
|
||||
html:not(.dark) .creation-media-preview-modal .ant-modal-content { background: #1f2227; }
|
||||
html:not(.dark) .creation-media-preview-modal .ant-modal-container { background: #1f2227; }
|
||||
html:not(.dark) .creation-message-content code { background: rgba(28, 32, 37, .08); }
|
||||
|
||||
@keyframes creation-intro-emphasis {
|
||||
@@ -7214,7 +7219,8 @@ body.app-spatial-overlays :where(.ant-modal-mask, .ant-drawer-mask) {
|
||||
.creation-library-empty span { font-size: var(--fs-micro); }
|
||||
.creation-library-footer { display: flex; align-items: center; gap: 16px; border-top: 1px solid var(--border); padding: 14px 18px; }
|
||||
.creation-library-upload { display: flex; min-height: 58px; flex: 1; align-items: center; gap: 12px; border: 1px dashed color-mix(in srgb, var(--workspace-accent) 55%, var(--border)); border-radius: var(--r-md); background: color-mix(in srgb, var(--workspace-accent) 7%, transparent); padding: 0 16px; color: var(--foreground); text-align: left; transition: background-color 140ms ease, border-color 140ms ease; }
|
||||
.creation-library-upload:hover { border-color: var(--workspace-accent); background: color-mix(in srgb, var(--workspace-accent) 12%, transparent); }
|
||||
.creation-library-upload:hover:not(:disabled) { border-color: var(--workspace-accent); background: color-mix(in srgb, var(--workspace-accent) 12%, transparent); }
|
||||
.creation-library-upload:disabled { cursor: wait; opacity: .72; }
|
||||
.creation-library-upload > svg { width: 21px; height: 21px; color: var(--workspace-accent); }
|
||||
.creation-library-upload span { display: flex; flex-direction: column; gap: 3px; }
|
||||
.creation-library-upload strong { font-size: var(--fs-label); font-weight: 650; }
|
||||
|
||||
@@ -24,5 +24,19 @@ describe("creation library button", () => {
|
||||
expect(source.slice(uploadStart, uploadEnd)).not.toContain("setAttachments");
|
||||
expect(source).toContain("onUpload={uploadLibraryAssets}");
|
||||
expect(source).not.toContain("onUpload={() => fileInputRef.current?.click()}");
|
||||
expect(source).toContain("上传后保存到素材库");
|
||||
expect(source).toContain("正在保存到素材库,完成后会自动选中");
|
||||
expect(source).toContain("个素材已上传到素材库并自动选中");
|
||||
});
|
||||
|
||||
test("previews prompt reference images without removing them", () => {
|
||||
const createSource = readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8");
|
||||
const canvasSource = readFileSync(resolve(import.meta.dir, "../src/components/canvas/canvas-node-prompt-panel.tsx"), "utf8");
|
||||
|
||||
expect(createSource).toContain('className="creation-chat-attachment-preview"');
|
||||
expect(createSource).toContain("<CreationMediaPreviewModal url={previewUrl} type={previewType}");
|
||||
expect(canvasSource).toContain("canPreview ? setImagePreview(reference) : onInsert(reference)");
|
||||
expect(canvasSource).toContain("<AntImage");
|
||||
expect(canvasSource).toContain("onClick={() => onInsert(reference)}");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user