import { parseGameCreationAppAssetKind } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import type { ResourceReference } from '../../features/project-workspace/resourceReferences'; import { type ProjectResource, projectResourceAssetCategory, } from './resourceProjectionModel'; /** 对话栏在屏幕坐标里的矩形(拖动落点的唯一判据)。 */ export type ResourceCardReferenceDropRect = { left: number; top: number; width: number; height: number; }; /** * 量对话栏的落点矩形:没有元素、或元素还没量出尺寸(0 x 0,jsdom 与折叠态)时返回 null。 * * 零面积矩形必须当「没有落点」:命中测试是 contains,一个 0 x 0 的矩形会把任何点都判成 * 在里面,于是画布内正常拖动松手也会被误判成「拖到对话」,排版写入被整段跳过。 */ export function resourceCardReferenceDropRect( element: Element | null, ): ResourceCardReferenceDropRect | null { if (!element) return null; const rect = element.getBoundingClientRect(); if (!(rect.width > 0) || !(rect.height > 0)) { return null; } return { left: rect.left, top: rect.top, width: rect.width, height: rect.height, }; } export function resourceCardReferenceDropContains( rect: ResourceCardReferenceDropRect, clientX: number, clientY: number, ) { return ( clientX >= rect.left && clientX <= rect.left + rect.width && clientY >= rect.top && clientY <= rect.top + rect.height ); } /** * 两次落点状态是否**逐值相同**。 * * 指针在对话栏上移动时每帧都会算出一个新的落点对象;只看「有没有落点」会让每一帧都 `setState` * 一次、把工作台整棵子树重渲染一遍(`pointermove` 热路径上这是实打实的浪费)。带上这一条, * 只有条数或对话栏矩形真的变了才重新渲染。 */ export function sameResourceCardReferenceDrop( left: { count: number; rect: ResourceCardReferenceDropRect }, right: { count: number; rect: ResourceCardReferenceDropRect }, ) { return ( left.count === right.count && left.rect.left === right.rect.left && left.rect.top === right.rect.top && left.rect.width === right.rect.width && left.rect.height === right.rect.height ); } /** * 拖到对话要插入的引用。 * * 与资源卡工具条那枚「引用」按钮**同一口径**:只认已登记到 manifest 的素材 * (manifestAssetId),来源标 resource-card,kind / 分类 / 标签全部取自资源投影, * 于是同一个素材无论从按钮还是从拖拽进来,草稿里的引用是同一枚(去重键也跟着走)。 * 未登记的卡(任务产物、附件、Agent 回执等)在这里被过滤掉,调用方按「一条都没有」给原因。 * * 顺序即插入顺序:movedResourceIds 是本次拖动真正参与位移的那批(第 0 项是被按住的那张), * 多选后整批引用、单选只引用按住的那张,与拖动位移的语义同源。 */ export function resourceCardReferenceDropReferences( movedResourceIds: readonly string[], resources: readonly ProjectResource[], ): ResourceReference[] { if (movedResourceIds.length === 0) { return []; } const byId = new Map(resources.map((resource) => [resource.id, resource])); const references: ResourceReference[] = []; for (const resourceId of movedResourceIds) { const resource = byId.get(resourceId); if (!resource?.manifestAssetId) { continue; } references.push({ type: 'resource', resourceId: resource.manifestAssetId, kind: parseGameCreationAppAssetKind( resource.subtype, 'project-development.resource-card-reference-drop', ), mediaType: resource.mediaType, label: resource.label, category: projectResourceAssetCategory(resource), tags: resource.assetTags ?? [], source: 'resource-card', }); } return references; } /** * 落点提示文案。条数为 0 时不写「引用 0 项」,而是把「为什么一条也引用不了」说清楚: * 用户此时已经拖到对话上了,静默什么都不发生会被读成功能坏了。 */ export function resourceCardReferenceDropHintLabel(count: number) { return count > 0 ? `松手即可 @ 引用 ${count} 项素材` : '这些素材还没登记为项目资源,不能 @ 引用'; }