diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 199b53997..633b73c26 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -325,18 +325,28 @@ function $selectionOrRootEnd() { * * 认不出的引用 part(provider 的 `toReference` 解析不出来)退回它的 token 文本, * 绝不静默丢掉——粘贴进来的内容一个字符都不会凭空消失。 + * + * 返回「这次到底插进去没有」:调用方据此决定要不要接管这次粘贴,插入为空时必须放行 + * 编辑器的默认粘贴,否则这段文字两边都不管。 */ function $insertContentAtSelection( content: readonly DirectCodexUserContentPart[], providers: readonly ReferenceProvider[], -) { +): boolean { // 每插一段都重新取一次选区:插入会移动光标,上一轮拿到的那个 RangeSelection 会过期。 - if (!$selectionOrRootEnd()) return; + if (!$selectionOrRootEnd()) return false; + let inserted = false; content.forEach((part) => { if (part.type === 'input_text') { part.text.split('\n').forEach((line, index) => { - if (index > 0) $selectionOrRootEnd()?.insertParagraph(); - if (line) $selectionOrRootEnd()?.insertText(line); + if (index > 0) { + $selectionOrRootEnd()?.insertParagraph(); + inserted = true; + } + if (line) { + $selectionOrRootEnd()?.insertText(line); + inserted = true; + } }); return; } @@ -345,12 +355,17 @@ function $insertContentAtSelection( $selectionOrRootEnd()?.insertNodes([ $createResourceReferenceNode(reference), ]); + inserted = true; return; } // 解析不出的 part 已经在上游被摘掉了 token,这里必须把文本补回去,否则这段内容会静默消失。 const token = mentionTokenFromPart(providers, part); - if (token) $selectionOrRootEnd()?.insertText(token); + if (token) { + $selectionOrRootEnd()?.insertText(token); + inserted = true; + } }); + return inserted; } /** @@ -567,9 +582,9 @@ function ResourceReferenceEditor({ ); }, [editor, multiline]); - // 粘贴解析:只有「粘贴文本里真的解析出了引用 token」时才接管,其余一律返回 false 放行 - // Lexical 的默认导入——同 namespace 复制出来的 Lexical payload 本来就能还原真 chip, - // 不含 token 的纯文本、图片文件粘贴也都保持原行为。 + // 粘贴解析:只有「粘贴文本里真的解析出了引用 token」且「这一整段真的插进了正文」时才接管, + // 其余一律返回 false 放行 Lexical 的默认导入——同 namespace 复制出来的 Lexical payload + // 本来就能还原真 chip,不含 token 的纯文本、图片文件粘贴也都保持原行为。 // // 接管时整段文本在一次 update 内插入(与默认粘贴同样打 PASTE_TAG),所以一次 Ctrl+Z 就整体 // 回退;token 之外的每个字符逐字保留(换行落成段落分隔,与默认纯文本粘贴同形状)。 @@ -592,14 +607,22 @@ function ResourceReferenceEditor({ ); const content = buildContentFromPastedText(text, references); if (!content) return false; - event.preventDefault(); + // 先真的插进去,再决定接管这次粘贴:插入为空(取不到选区)时必须放行默认粘贴, + // 否则这段文字既没进我们的插入、又被 preventDefault 挡掉了默认导入,静默消失。 + // 编辑器已经在一次更新里时 `editor.update` 会把回调排队,这时 `ranSync` 仍是 false, + // 按原口径先接管,等队列里的那次插入落地。 + let ranSync = false; + let inserted = false; editor.update( () => { - $insertContentAtSelection(content, providersRef.current); + ranSync = true; + inserted = $insertContentAtSelection(content, providersRef.current); }, // 与编辑器默认粘贴同一口径:粘贴是它自己的一条撤销记录。 { tag: PASTE_TAG }, ); + if (ranSync && !inserted) return false; + event.preventDefault(); return true; }, COMMAND_PRIORITY_CRITICAL, diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index f57cbf43e..b91a0eee4 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -39,7 +39,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 宁可不成芯片也不能认错:同名多候选(同一个 token 对应多条引用身份)一律按文本保留;未命中的 token 静默保留,不提示、也不猜文件名或路径。 - 名字为空的引用(token 只剩一个裸触发符)同样不参与解析:裸 `@` / `$` 会在正文里匹配到任何一处触发符,认下来就是把无关文字错认成引用。显示名一侧另有兜底(见下节「词干为空的素材名回退 `asset.id`」),这条是解析侧的最后一道。 - 附件与运行画面区域的 token(`@附件名` / `@区域标签`)不参与解析——这两类引用没有候选,身份来自文件与运行记录,纯文本重建不出来,所以粘贴时按文本保留。 -- 只有真的解析出引用时才接管粘贴:同 namespace 的 Lexical 负载(跨输入区复制芯片)、不含 token 的纯文本、图片文件粘贴都继续走编辑器默认导入,现有行为不变。接管时整段文本在一次编辑更新内插入,一次 Ctrl+Z 就是一次撤销。 +- 只有真的解析出引用、并且这一整段真的插进了正文时才接管粘贴:同 namespace 的 Lexical 负载(跨输入区复制芯片)、不含 token 的纯文本、图片文件粘贴都继续走编辑器默认导入,现有行为不变。取不到选区时插入为空,这时也放行默认粘贴,粘贴的文字不会「两边都不管」。接管时整段文本在一次编辑更新内插入,一次 Ctrl+Z 就是一次撤销。 - 候选来源按「模糊 / 精确」分两个口:菜单走 `fuzzyLookup(query)`(包含匹配 + 截断到候选上限),粘贴解析走 `lookup()`(就绪的全量候选,不模糊、不截断)。 - Skill 目录是应用级异步读取,仍然只在用户第一次敲出 `$` 时读:冷启动时粘贴 `$名称` 就按字面文本保留(粘贴不会为了解析去提前读盘,也不会等待目录),用户敲过一次 `$` 之后粘贴即可重建芯片。