添加艾特角色功能

This commit is contained in:
Azmat@qq.com
2026-09-17 18:31:29 +08:00
parent 5df038c635
commit abccf4393a
18 changed files with 2769 additions and 182 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ const SIDEBAR_COLLAPSED_KEY = "airshelf:sidebar-collapsed";
// 全局命令面板(Ctrl K / 点搜索框)—— 忠实搬设计稿 SHELL_COMMANDS,href 改成真路由导航
type Command = { id: string; group: string; label: string; sub: string; page: Page; icon: string; key?: string };
const SHELL_COMMANDS: Command[] = [
{ id: "omni-create", group: "导航", label: "全能创作", sub: "预设工作流 + 对话式 Agent", page: "omniCreate", icon: "sparkles", key: "O" },
{ id: "omni-create", group: "导航", label: "全能创作", sub: "预设工作流 + 对话式创作", page: "omniCreate", icon: "sparkles", key: "O" },
{ id: "omni-history", group: "导航", label: "创作历史", sub: "查看与继续独立会话", page: "omniHistory", icon: "history", key: "H" },
{ id: "products", group: "导航", label: "商品库", sub: "管理 SKU、商品图册、卖点信息", page: "products", icon: "package", key: "P" },
{ id: "projects", group: "导航", label: "视频创作", sub: "从商品或参考视频出发,选择生产方式", page: "projects", icon: "clapperboard", key: "V" },
@@ -0,0 +1,374 @@
.omni-asset-picker-layer {
padding: 24px;
}
.omni-asset-picker.modal {
width: min(880px, calc(100vw - 48px));
max-width: 880px;
height: min(680px, calc(100vh - 48px));
}
.omni-asset-picker .modal-h {
min-height: 76px;
}
.omni-asset-picker-head-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.omni-asset-picker-head-actions .icon-btn {
width: 32px;
height: 32px;
}
.omni-asset-picker-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) 136px;
gap: 12px;
padding: 16px 24px 0;
}
.omni-asset-picker-search {
position: relative;
display: block;
}
.omni-asset-picker-search > svg {
position: absolute;
top: 10px;
left: 12px;
z-index: 1;
width: 16px;
height: 16px;
color: var(--black-alpha-48);
pointer-events: none;
}
.omni-asset-picker-search .input {
padding-left: 38px;
}
.omni-asset-picker-sort {
min-width: 0;
}
.omni-asset-picker-body.modal-b {
padding-top: 16px;
}
.omni-asset-picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
gap: 14px;
}
.omni-asset-picker-grid.is-loading {
pointer-events: none;
}
.omni-asset-picker-skeleton {
min-width: 0;
padding: 8px;
border-radius: var(--r-md);
background: var(--background-lighter);
box-shadow: inset 0 0 0 1px var(--border-faint);
}
.omni-asset-picker-skeleton-thumb {
width: 100%;
display: block;
aspect-ratio: 1;
border-radius: var(--r-md);
background: var(--black-alpha-4);
}
.omni-asset-picker.is-character .omni-asset-picker-skeleton-thumb {
aspect-ratio: 3 / 4;
}
.omni-asset-picker-skeleton-name,
.omni-asset-picker-skeleton-meta {
height: 10px;
display: block;
margin-top: 10px;
border-radius: var(--r-sm);
background: var(--black-alpha-7);
}
.omni-asset-picker-skeleton-name {
width: 72%;
}
.omni-asset-picker-skeleton-meta {
width: 44%;
height: 7px;
margin-top: 7px;
background: var(--black-alpha-4);
}
.omni-asset-picker-card {
min-width: 0;
padding: 8px;
border: 0;
border-radius: var(--r-md);
color: var(--accent-black);
background: var(--background-lighter);
box-shadow: inset 0 0 0 1px var(--border-faint);
text-align: left;
cursor: pointer;
transition: background var(--t-base), box-shadow var(--t-base);
}
.omni-asset-picker-card:hover {
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--black-alpha-24);
}
.omni-asset-picker-card.is-selected {
background: var(--heat-12);
box-shadow: inset 0 0 0 1px var(--heat);
}
.omni-asset-picker-card:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--heat-40);
}
.omni-asset-picker-thumb {
position: relative;
display: block;
width: 100%;
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--r-md);
background: var(--black-alpha-4);
}
.omni-asset-picker.is-character .omni-asset-picker-thumb {
aspect-ratio: 3 / 4;
}
.omni-asset-picker-thumb > img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.omni-asset-picker-fallback {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: var(--black-alpha-32);
}
.omni-asset-picker-fallback svg {
width: 24px;
height: 24px;
}
.omni-asset-picker-check {
position: absolute;
top: 8px;
right: 8px;
width: 22px;
height: 22px;
display: grid;
place-items: center;
border-radius: var(--r-pill);
color: var(--surface);
background: var(--heat);
}
.omni-asset-picker-check svg {
width: 13px;
height: 13px;
}
.omni-asset-picker-card-name {
display: block;
margin-top: 9px;
overflow: hidden;
color: var(--accent-black);
font-size: 13px;
font-weight: 500;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.omni-asset-picker-card > .mono {
display: block;
margin-top: 3px;
color: var(--black-alpha-48);
font-size: 10.5px;
}
.omni-asset-picker-state {
min-height: 360px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--black-alpha-48);
text-align: center;
}
.omni-asset-picker-state > svg {
width: 22px;
height: 22px;
color: var(--heat);
}
.omni-asset-picker-state strong {
margin-top: 4px;
color: var(--accent-black);
font-size: 14px;
font-weight: 500;
}
.omni-asset-picker-state > span:not(.omni-asset-picker-state-icon) {
font-size: 12px;
}
.omni-asset-picker-state .btn {
margin-top: 10px;
}
.omni-asset-picker-state-icon {
width: 48px;
height: 48px;
display: grid;
place-items: center;
border-radius: var(--r-md);
color: var(--heat);
background: var(--heat-12);
}
.omni-asset-picker-state-icon svg {
width: 20px;
height: 20px;
}
.omni-asset-picker .modal-f {
align-items: center;
}
.omni-asset-picker-selection {
margin-right: auto;
overflow: hidden;
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: .04em;
text-overflow: ellipsis;
white-space: nowrap;
}
.omni-asset-upload-pane.modal-b {
display: grid;
grid-template-columns: minmax(280px, 1.2fr) minmax(240px, .8fr);
align-items: start;
gap: 24px;
padding: 24px;
}
.omni-asset-dropzone {
width: 100%;
min-height: 340px;
display: grid;
place-items: center;
overflow: hidden;
padding: 20px;
border: 1px dashed var(--black-alpha-24);
border-radius: var(--r-md);
color: var(--black-alpha-56);
background: var(--background-lighter);
cursor: pointer;
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
}
.omni-asset-dropzone:hover {
border-color: var(--heat-40);
color: var(--heat);
background: var(--heat-12);
}
.omni-asset-dropzone:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--heat-40);
}
.omni-asset-dropzone.has-preview {
background: var(--black-alpha-4);
}
.omni-asset-dropzone-prompt {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.omni-asset-dropzone-prompt > svg {
width: 28px;
height: 28px;
margin-bottom: 4px;
}
.omni-asset-dropzone-prompt strong {
color: var(--accent-black);
font-size: 14px;
font-weight: 500;
}
.omni-asset-dropzone-prompt small {
color: var(--black-alpha-48);
font-size: 12px;
font-weight: 400;
}
.omni-asset-upload-thumb {
width: 100%;
height: 100%;
max-height: 380px;
object-fit: contain;
}
.omni-asset-upload-field {
margin: 0;
}
@media (max-width: 720px) {
.omni-asset-picker-layer {
padding: 12px;
}
.omni-asset-picker.modal {
width: calc(100vw - 24px);
height: calc(100vh - 24px);
}
.omni-asset-picker-toolbar,
.omni-asset-upload-pane.modal-b {
grid-template-columns: 1fr;
}
.omni-asset-picker-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.omni-asset-dropzone {
min-height: 240px;
}
.omni-asset-picker-selection {
display: none;
}
}
@@ -0,0 +1,413 @@
import { useEffect, useId, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
Box,
Check,
LoaderCircle,
Plus,
Search,
Upload,
UserRound,
X,
} from "lucide-react";
import { api } from "../api";
import type { CreationRef } from "../types";
import "./asset-select-modal.css";
export type AssetModalType = "product" | "character";
interface AssetSelectModalProps {
open: boolean;
type: AssetModalType;
onClose: () => void;
onSelect: (ref: CreationRef) => void;
onNotify?: (type: "success" | "error" | "info", text: string) => void;
}
const assetLibraryCache = new Map<string, CreationRef[]>();
function assetLibraryKey(type: AssetModalType, query: string) {
return `${type}:${query.trim().toLocaleLowerCase("zh-CN")}`;
}
export function AssetSelectModal({
open,
type,
onClose,
onSelect,
onNotify,
}: AssetSelectModalProps) {
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [items, setItems] = useState<CreationRef[]>([]);
const [loadedKey, setLoadedKey] = useState("");
const [loading, setLoading] = useState(false);
const [selectedRef, setSelectedRef] = useState<CreationRef | null>(null);
const [sortOrder, setSortOrder] = useState<"recent" | "name">("recent");
const [isAddingNew, setIsAddingNew] = useState(false);
const [newName, setNewName] = useState("");
const [newFile, setNewFile] = useState<File | null>(null);
const [newPreview, setNewPreview] = useState("");
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const notifyRef = useRef(onNotify);
const nameInputId = useId();
const titleId = useId();
notifyRef.current = onNotify;
const titleText = type === "product" ? "商品" : "角色";
const title = type === "product" ? "商品库" : "角色库";
const subtitle = type === "product" ? "[ PRODUCT · SELECT ]" : "[ CHARACTER · SELECT ]";
const requestKey = assetLibraryKey(type, debouncedQuery);
const cachedItems = assetLibraryCache.get(requestKey);
const currentItems = loadedKey === requestKey ? items : (cachedItems || []);
const hasCurrentData = loadedKey === requestKey || Boolean(cachedItems);
const visibleItems = useMemo(() => {
if (sortOrder === "recent") return currentItems;
return [...currentItems].sort((left, right) => left.name.localeCompare(right.name, "zh-CN"));
}, [currentItems, sortOrder]);
useEffect(() => {
if (!open) {
setIsAddingNew(false);
setSearchQuery("");
setSelectedRef(null);
setSortOrder("recent");
setNewFile(null);
setNewPreview("");
setNewName("");
return;
}
}, [open]);
useEffect(() => {
if (!open) {
setDebouncedQuery("");
return;
}
const query = searchQuery.trim();
if (!query) {
setDebouncedQuery("");
return;
}
const timer = window.setTimeout(() => setDebouncedQuery(query), 180);
return () => window.clearTimeout(timer);
}, [open, searchQuery]);
useEffect(() => {
if (!open) return;
const cached = assetLibraryCache.get(requestKey);
if (cached) {
setItems(cached);
setLoadedKey(requestKey);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setSelectedRef(null);
const types: CreationRef["type"][] = type === "product" ? ["product"] : ["model", "character"];
void api
.searchMentions({ q: debouncedQuery, types, limit: 20 })
.then((response) => {
if (cancelled) return;
const results = response.results || [];
assetLibraryCache.set(requestKey, results);
setItems(results);
setLoadedKey(requestKey);
})
.catch((error) => {
if (cancelled) return;
assetLibraryCache.set(requestKey, []);
setItems([]);
setLoadedKey(requestKey);
notifyRef.current?.("error", (error as Error).message || "加载失败");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [debouncedQuery, open, requestKey, type]);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape" || uploading) return;
event.preventDefault();
onClose();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [onClose, open, uploading]);
useEffect(() => {
return () => {
if (newPreview) URL.revokeObjectURL(newPreview);
};
}, [newPreview]);
const handleFilePick = (file: File) => {
if (!file.type.startsWith("image/")) {
notifyRef.current?.("info", "请选择图片文件");
return;
}
setNewFile(file);
setNewName((current) => current.trim() || file.name.replace(/\.[^/.]+$/, ""));
setNewPreview(URL.createObjectURL(file));
};
const handleUploadSubmit = async () => {
if (!newFile || uploading) {
if (!newFile) notifyRef.current?.("info", `请先选择${titleText}图片`);
return;
}
const name = newName.trim() || newFile.name.replace(/\.[^/.]+$/, "");
setUploading(true);
try {
let finalRef: CreationRef;
if (type === "character") {
const form = new FormData();
form.append("file", newFile);
form.append("name", name);
const model = await api.uploadModel(form);
finalRef = {
type: "model",
id: model.id,
name: model.name,
cover: model.portrait,
};
} else {
const form = new FormData();
form.append("file", newFile);
const uploaded = await api.uploadFreeVideoRef(form);
const product = await api.createProduct({
title: name,
cover_asset: uploaded.asset_id,
images: [{ asset: uploaded.asset_id, sort_order: 0, is_primary: true }],
});
finalRef = {
type: "product",
id: product.id,
name: product.title,
cover: product.cover_preview_url || uploaded.thumb_url || uploaded.url,
};
}
notifyRef.current?.("success", `已新增${titleText}${finalRef.name}`);
for (const key of assetLibraryCache.keys()) {
if (key.startsWith(`${type}:`)) assetLibraryCache.delete(key);
}
onSelect(finalRef);
onClose();
} catch (error) {
notifyRef.current?.("error", (error as Error).message || `新增${titleText}失败`);
} finally {
setUploading(false);
}
};
if (!open) return null;
return (
<div
className="modal-bg modal-priority show omni-asset-picker-layer"
onMouseDown={(event) => {
if (event.target === event.currentTarget && !uploading) onClose();
}}
>
<section
className={`modal omni-asset-picker${type === "character" ? " is-character" : " is-product"}`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<span className="corner-tr" />
<span className="corner-bl" />
<header className="modal-h">
<span className="ic-m" aria-hidden="true">
{type === "product" ? <Box /> : <UserRound />}
</span>
<div className="ti" id={titleId}>
{isAddingNew ? `新增${titleText}` : title}
<span>{isAddingNew ? "[ UPLOAD · CREATE ]" : subtitle}</span>
</div>
<div className="omni-asset-picker-head-actions">
{isAddingNew ? (
<button type="button" className="btn btn-sm" disabled={uploading} onClick={() => setIsAddingNew(false)}>
<ArrowLeft />
</button>
) : (
<button type="button" className="btn btn-sm" onClick={() => setIsAddingNew(true)}>
<Plus /> {titleText}
</button>
)}
<button type="button" className="icon-btn" disabled={uploading} onClick={onClose} aria-label="关闭">
<X />
</button>
</div>
</header>
{isAddingNew ? (
<>
<div className="modal-b omni-asset-upload-pane">
<button
type="button"
className={`omni-asset-dropzone${newPreview ? " has-preview" : ""}`}
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{newPreview ? (
<img src={newPreview} alt={`${titleText}图片预览`} className="omni-asset-upload-thumb" />
) : (
<span className="omni-asset-dropzone-prompt">
<Upload />
<strong>{titleText}</strong>
<small> JPGPNGWebP · </small>
</span>
)}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
hidden
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) handleFilePick(file);
}}
/>
<div className="field omni-asset-upload-field">
<label className="field-label" htmlFor={nameInputId}>{titleText}</label>
<input
id={nameInputId}
className="input"
type="text"
placeholder={`输入${titleText}名称`}
value={newName}
disabled={uploading}
onChange={(event) => setNewName(event.target.value)}
/>
<span className="field-hint">
{type === "product" ? "创建后会进入商品库,可继续补充卖点与规格。" : "创建后会进入角色库,后续镜头可持续锁定同一人物。"}
</span>
</div>
</div>
<footer className="modal-f">
<button type="button" className="btn" disabled={uploading} onClick={() => setIsAddingNew(false)}></button>
<button type="button" className="btn btn-primary" disabled={!newFile || uploading} onClick={handleUploadSubmit}>
{uploading ? <><LoaderCircle className="spin" /> </> : `创建并使用${titleText}`}
</button>
</footer>
</>
) : (
<>
<div className="omni-asset-picker-toolbar">
<label className="omni-asset-picker-search">
<Search aria-hidden="true" />
<input
className="input"
type="search"
placeholder={`搜索已有${titleText}`}
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
</label>
<select
className="select omni-asset-picker-sort"
aria-label="排序方式"
value={sortOrder}
onChange={(event) => setSortOrder(event.target.value as "recent" | "name")}
>
<option value="recent"></option>
<option value="name"></option>
</select>
</div>
<div className="modal-b omni-asset-picker-body" aria-busy={loading || !hasCurrentData}>
{!hasCurrentData ? (
<div className="omni-asset-picker-grid is-loading" role="status" aria-label={`正在加载${titleText}`}>
{Array.from({ length: 10 }, (_, index) => (
<div className="omni-asset-picker-skeleton" key={index} aria-hidden="true">
<span className="omni-asset-picker-skeleton-thumb" />
<span className="omni-asset-picker-skeleton-name" />
<span className="omni-asset-picker-skeleton-meta" />
</div>
))}
</div>
) : visibleItems.length === 0 ? (
<div className="omni-asset-picker-state">
<span className="omni-asset-picker-state-icon" aria-hidden="true">
{type === "product" ? <Box /> : <UserRound />}
</span>
<strong>{searchQuery ? `没有找到“${searchQuery}` : `暂时没有${titleText}`}</strong>
<span>{searchQuery ? "换个关键词试试" : `可以先新增一个${titleText}`}</span>
{!searchQuery ? (
<button type="button" className="btn btn-primary" onClick={() => setIsAddingNew(true)}>
<Plus /> {titleText}
</button>
) : null}
</div>
) : (
<div className="omni-asset-picker-grid">
{visibleItems.map((item) => {
const isSelected = selectedRef?.type === item.type && selectedRef.id === item.id;
const displayName = item.name.split(" · ")[0];
return (
<button
type="button"
key={`${item.type}:${item.id}`}
className={`omni-asset-picker-card${isSelected ? " is-selected" : ""}`}
aria-pressed={isSelected}
onClick={() => setSelectedRef(item)}
onDoubleClick={() => {
onSelect(item);
onClose();
}}
>
<span className="omni-asset-picker-thumb">
{item.cover ? (
<img src={item.cover} alt="" />
) : (
<span className="omni-asset-picker-fallback">
{type === "product" ? <Box /> : <UserRound />}
</span>
)}
{isSelected ? <span className="omni-asset-picker-check"><Check /></span> : null}
</span>
<span className="omni-asset-picker-card-name" title={displayName}>{displayName}</span>
<span className="mono">// {item.type === "product" ? "商品" : item.type === "model" ? "角色库" : "角色素材"}</span>
</button>
);
})}
</div>
)}
</div>
<footer className="modal-f">
<span className="omni-asset-picker-selection">
{selectedRef ? `已选择:${selectedRef.name.split(" · ")[0]}` : `选择一个${titleText}后添加`}
</span>
<button type="button" className="btn" onClick={onClose}></button>
<button
type="button"
className="btn btn-primary"
disabled={!selectedRef}
onClick={() => {
if (!selectedRef) return;
onSelect(selectedRef);
onClose();
}}
>
{titleText}
</button>
</footer>
</>
)}
</section>
</div>
);
}
@@ -0,0 +1,151 @@
.rich-mention-editor {
width: 100%;
min-width: 0;
overflow-x: hidden;
overflow-y: auto;
border: 0;
outline: 0;
color: var(--accent-black);
background: transparent;
font-size: 14px;
line-height: 1.65;
white-space: pre-wrap;
overflow-wrap: anywhere;
cursor: text;
}
.rich-mention-editor:empty::before {
color: var(--black-alpha-48);
content: attr(data-placeholder);
pointer-events: none;
}
.rich-mention-editor[aria-disabled="true"] {
color: var(--black-alpha-48);
cursor: not-allowed;
}
.rich-mention-token {
position: relative;
width: 32px;
min-width: 32px;
max-width: 32px;
height: 32px;
min-height: 32px;
max-height: 32px;
aspect-ratio: 1;
display: inline-grid;
place-items: center;
overflow: visible;
margin: 0 3px;
border-radius: var(--r-md);
color: var(--black-alpha-56);
background: var(--background-lighter);
box-shadow: inset 0 0 0 1px var(--border-faint);
vertical-align: middle;
user-select: none;
}
.rich-mention-editor .rich-mention-token > img,
.rich-mention-fallback {
width: 100%;
min-width: 100%;
max-width: 100%;
height: 100%;
min-height: 100%;
max-height: 100%;
display: block;
border-radius: var(--r-md);
}
.rich-mention-editor .rich-mention-token > img {
position: absolute;
inset: 0;
object-fit: cover;
}
.rich-mention-fallback {
display: grid;
place-items: center;
font-size: 11px;
font-weight: 500;
}
.rich-mention-preview {
position: fixed;
top: 0;
left: 0;
z-index: 80;
width: 264px;
min-width: 264px;
max-width: 264px;
display: none;
gap: 10px;
padding: 12px;
border-radius: var(--r-md);
color: var(--accent-black);
background: var(--surface-raised);
box-shadow: var(--shadow-floating);
opacity: 0;
pointer-events: none;
text-align: left;
transform: translateY(-4px);
transition: opacity var(--t-base), transform var(--t-base);
}
.rich-mention-token:hover .rich-mention-preview,
.rich-mention-token:focus-within .rich-mention-preview {
display: grid;
opacity: 1;
transform: translateY(0);
}
.rich-mention-preview-head {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.rich-mention-preview-head strong {
overflow: hidden;
font-size: 13px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.rich-mention-preview-head small {
flex: 0 0 auto;
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 10.5px;
}
.rich-mention-preview-media {
width: 240px;
min-width: 240px;
max-width: 240px;
height: 240px;
min-height: 240px;
max-height: 240px;
display: grid;
place-items: center;
overflow: hidden;
border-radius: var(--r-md);
color: var(--black-alpha-48);
background: var(--background-lighter);
font-size: 13px;
}
.rich-mention-preview-media img {
width: 100%;
min-width: 100%;
max-width: 100%;
height: 100%;
min-height: 100%;
max-height: 100%;
display: block;
object-fit: cover;
}
@@ -0,0 +1,336 @@
import {
forwardRef,
type ClipboardEvent,
type FormEvent,
type KeyboardEvent,
type PointerEvent as ReactPointerEvent,
useEffect,
useImperativeHandle,
useRef,
} from "react";
import type { CreationRef } from "../types";
import "./rich-mention-editor.css";
const TYPE_LABELS: Record<CreationRef["type"], string> = {
asset: "素材",
character: "角色",
model: "角色",
product: "商品",
scene: "场景",
};
function shortName(name: string) {
return name.split(" · ")[0].trim();
}
export function getMentionFreeText(value: string, refs: CreationRef[]) {
const mentionNames = Array.from(
new Set(refs.map((ref) => shortName(ref.name)).filter(Boolean)),
).sort((a, b) => b.length - a.length);
let text = value;
mentionNames.forEach((name) => {
text = text.split(`@${name}`).join(" ");
});
return text
.replace(/[\u200B-\u200D\uFEFF]/g, "")
.replace(/\s+/g, " ")
.trim();
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function serializeNode(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) return node.textContent || "";
if (!(node instanceof HTMLElement)) {
return Array.from(node.childNodes).map(serializeNode).join("");
}
if (node.dataset.mentionName) return `@${node.dataset.mentionName}`;
if (node.tagName === "BR") return "\n";
const text = Array.from(node.childNodes).map(serializeNode).join("");
if ((node.tagName === "DIV" || node.tagName === "P") && node.nextSibling && !text.endsWith("\n")) {
return `${text}\n`;
}
return text;
}
function serializeEditor(root: HTMLElement) {
return Array.from(root.childNodes)
.map(serializeNode)
.join("")
.replace(/\u00a0/g, " ");
}
function createMentionToken(ref: CreationRef) {
const name = shortName(ref.name) || TYPE_LABELS[ref.type];
const token = document.createElement("span");
token.className = "rich-mention-token";
token.contentEditable = "false";
token.dataset.mentionName = name;
token.dataset.refKey = `${ref.type}:${ref.id}`;
token.setAttribute("aria-label", `${TYPE_LABELS[ref.type]}${name}`);
token.title = `${TYPE_LABELS[ref.type]}${name}`;
if (ref.cover) {
const image = document.createElement("img");
image.src = ref.cover;
image.alt = "";
token.append(image);
} else {
const fallback = document.createElement("span");
fallback.className = "rich-mention-fallback";
fallback.textContent = TYPE_LABELS[ref.type].slice(0, 1);
token.append(fallback);
}
const preview = document.createElement("span");
preview.className = "rich-mention-preview";
preview.setAttribute("role", "tooltip");
const head = document.createElement("span");
head.className = "rich-mention-preview-head";
const title = document.createElement("strong");
title.textContent = name;
const type = document.createElement("small");
type.textContent = TYPE_LABELS[ref.type];
head.append(title, type);
const media = document.createElement("span");
media.className = "rich-mention-preview-media";
if (ref.cover) {
const image = document.createElement("img");
image.src = ref.cover;
image.alt = name;
media.append(image);
} else {
media.textContent = TYPE_LABELS[ref.type];
}
preview.append(head, media);
token.append(preview);
return token;
}
function renderValue(root: HTMLElement, value: string, refs: CreationRef[]) {
root.replaceChildren();
if (!value) return;
const byName = new Map<string, CreationRef>();
refs.forEach((ref) => {
const name = shortName(ref.name);
if (name && !byName.has(name)) byName.set(name, ref);
});
const names = Array.from(byName.keys()).sort((a, b) => b.length - a.length);
if (!names.length) {
root.append(document.createTextNode(value));
return;
}
const matcher = new RegExp(`@(${names.map(escapeRegExp).join("|")})`, "g");
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = matcher.exec(value))) {
if (match.index > cursor) root.append(document.createTextNode(value.slice(cursor, match.index)));
const ref = byName.get(match[1]);
root.append(ref ? createMentionToken(ref) : document.createTextNode(match[0]));
cursor = match.index + match[0].length;
}
if (cursor < value.length) root.append(document.createTextNode(value.slice(cursor)));
}
function placeCaretAtEnd(root: HTMLElement) {
const range = document.createRange();
range.selectNodeContents(root);
range.collapse(false);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
export type RichMentionEditorHandle = {
focus: () => void;
focusAtEnd: () => void;
insertMention: (ref: CreationRef, replaceTrigger?: boolean) => void;
};
type RichMentionEditorProps = {
id?: string;
value: string;
refs: CreationRef[];
placeholder: string;
ariaLabel?: string;
className?: string;
disabled?: boolean;
submitOnEnter?: boolean;
onChange: (value: string) => void;
onAtTrigger?: () => void;
onSubmit?: () => void;
};
export const RichMentionEditor = forwardRef<RichMentionEditorHandle, RichMentionEditorProps>(
function RichMentionEditor(
{
id,
value,
refs,
placeholder,
ariaLabel = "创作描述",
className = "",
disabled = false,
submitOnEnter = false,
onChange,
onAtTrigger,
onSubmit,
},
forwardedRef,
) {
const rootRef = useRef<HTMLDivElement>(null);
const signatureRef = useRef("");
const changeRef = useRef(onChange);
const atTriggerRef = useRef(onAtTrigger);
const submitRef = useRef(onSubmit);
changeRef.current = onChange;
atTriggerRef.current = onAtTrigger;
submitRef.current = onSubmit;
const emitChange = () => {
const root = rootRef.current;
if (!root) return;
const next = serializeEditor(root);
const isVisuallyEmpty = !next
.replace(/[\u200B-\u200D\uFEFF]/g, "")
.trim();
if (isVisuallyEmpty) root.replaceChildren();
changeRef.current(isVisuallyEmpty ? "" : next);
};
useImperativeHandle(forwardedRef, () => ({
focus: () => rootRef.current?.focus(),
focusAtEnd: () => {
const root = rootRef.current;
if (!root) return;
root.focus();
placeCaretAtEnd(root);
},
insertMention: (ref, replaceTrigger = false) => {
const root = rootRef.current;
if (!root || disabled) return;
root.focus();
const selection = window.getSelection();
const range = selection?.rangeCount ? selection.getRangeAt(0) : document.createRange();
if (!root.contains(range.commonAncestorContainer)) {
range.selectNodeContents(root);
range.collapse(false);
}
if (replaceTrigger && range.collapsed && range.startContainer.nodeType === Node.TEXT_NODE) {
const text = range.startContainer.textContent || "";
const trigger = text.slice(0, range.startOffset).match(/@\S*$/)?.[0];
if (trigger) range.setStart(range.startContainer, range.startOffset - trigger.length);
}
const before = document.createRange();
before.selectNodeContents(root);
before.setEnd(range.startContainer, range.startOffset);
const prefix = serializeNode(before.cloneContents());
const fragment = document.createDocumentFragment();
if (prefix && !/\s$/.test(prefix)) fragment.append(document.createTextNode(" "));
fragment.append(createMentionToken(ref));
const tail = document.createTextNode(" ");
fragment.append(tail);
range.deleteContents();
range.insertNode(fragment);
range.setStartAfter(tail);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
emitChange();
},
}), [disabled]);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const signature = refs.map((ref) => `${ref.type}:${ref.id}:${ref.name}:${ref.cover || ""}`).join("|");
const current = serializeEditor(root);
if (current === value && signature === signatureRef.current) return;
const focused = document.activeElement === root;
renderValue(root, value, refs);
signatureRef.current = signature;
if (focused) placeCaretAtEnd(root);
}, [refs, value]);
const handleInput = (event: FormEvent<HTMLDivElement>) => {
emitChange();
const native = event.nativeEvent as InputEvent;
if (native.data === "@") atTriggerRef.current?.();
};
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
event.preventDefault();
const text = event.clipboardData.getData("text/plain");
const selection = window.getSelection();
if (!selection?.rangeCount) return;
const range = selection.getRangeAt(0);
range.deleteContents();
const node = document.createTextNode(text);
range.insertNode(node);
range.setStartAfter(node);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
emitChange();
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (submitOnEnter && event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
submitRef.current?.();
}
};
const positionPreview = (event: ReactPointerEvent<HTMLDivElement>) => {
const root = rootRef.current;
const target = event.target as Element;
const token = target.closest<HTMLElement>(".rich-mention-token");
if (!root || !token || !root.contains(token)) return;
const preview = token.querySelector<HTMLElement>(".rich-mention-preview");
if (!preview) return;
const tokenRect = token.getBoundingClientRect();
const previewHeight = preview.offsetHeight || 302;
const previewWidth = preview.offsetWidth || 244;
const left = Math.min(Math.max(12, tokenRect.left), window.innerWidth - previewWidth - 12);
const hasRoomBelow = window.innerHeight - tokenRect.bottom >= previewHeight + 20;
const desiredTop = hasRoomBelow
? tokenRect.bottom + 8
: Math.max(12, tokenRect.top - previewHeight - 8);
const top = Math.min(
Math.max(12, desiredTop),
Math.max(12, window.innerHeight - previewHeight - 12),
);
preview.style.left = `${left}px`;
preview.style.top = `${top}px`;
preview.dataset.side = hasRoomBelow ? "below" : "above";
};
return (
<div
id={id}
ref={rootRef}
className={`rich-mention-editor${className ? ` ${className}` : ""}`}
contentEditable={!disabled}
suppressContentEditableWarning
role="textbox"
aria-label={ariaLabel}
aria-multiline="true"
aria-disabled={disabled}
data-placeholder={placeholder}
onInput={handleInput}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onPointerOver={positionPreview}
/>
);
},
);