# 文本节点与 Markdown(v1.87,v1.89 补预览与去重) > 对象:`dsh-aifisher-canvas` v1.89.0 > 用户原话(2026-11-01):「现在画布里的文本节点不兼容 .md 格式的文件,导入 .md 文件内容 > 格式完全不正确。可以做一个能直接拖入 .md 文件到画布生成文本节点」。 > 后续(同日):「现在能直接拖入.md文件了,但是会生成两个一样的节点;还有做一个文本节点 > 全屏预览的功能」。 ## 一、两份数据,一条铁律 文本节点落库时永远成对写两个字段(见 `vendor/.../text/textDocument.ts`): | 字段 | 内容 | 谁在用 | |---|---|---| | `textContent` | **纯文本镜像**:块之间 `\n`、表格列 `\t`、没有 `#` `**` `|` 这些记号 | 生成请求的提示词、`canvas_control` 的 read、摘要/合规检查 | | `textRichContent` | `{ version: 1, sourceText, document }`,`document` 是 tiptap/ProseMirror JSON | 画布编辑器与节点卡上的渲染 | 铁律:**两者必须由 `createTextDocumentPatch(document)` 一起产出**。它先过 `safeDocument()` 做 schema 校验,再用 `plainText()` 算出镜像,所以 `sourceText` 与 `textContent` 天然一致; 只要手工拼其中一半,`resolveTextDocument()` 就会判定「富文档与正文对不上」并退回纯段落 —— 表现出来就是「格式又丢了」。 `document` 允许的节点(`safeDocument` 白名单,改动前先看它): `paragraph` `heading(1–3)` `bulletList` `orderedList(attr.start)` `listItem` `blockquote` `codeBlock` `horizontalRule` `table` `tableRow` `tableCell` `tableHeader` `text` `hardBreak`; marks 只有 `bold` `italic` `underline` `strike` `code`。没有 link、没有 image。 ## 二、拖入(新增行为) `src/client/canvas/textDrop.ts`,由 `bootstrap.ts` 的 `installCanvasTextFileDrop` 装配。 - **在 window 捕获阶段**接 `dragover` / `drop`。画布的 `onDrop` 走 React 冒泡,捕获先到, 所以能先 `preventDefault + stopPropagation`,不会先弹一句「无法识别的文件类型」 (`classifyMedia()` 只认图片/视频/音频)。 - **只接管画布范围内**的拖入:`target.closest('#canvas-background, .af-canvas-mount')`, 且**躲开别人的拖放区**(`[data-fisherai-workflow-manager]` 工作流库面板 —— 那里拖 `.json` 是导入 ComfyUI 工作流;以及任何 `` / `role="dialog"` 浮层)。拖到会话栏 / 设置页 / 系统别处一律放行。 - **扩展名**:`md markdown mdown mkd mkdn mdx mdwn mdtxt mdtext` 走 Markdown 解析; `txt text log csv tsv json jsonl yaml yml srt vtt` 按纯文本逐行成段。其它文件不接管。 - **单份上限 2 MB**;空文件给一条底部提示(`[data-fisherai-text-drop-toast]`),不建空节点; 一次拖多份时按 36px 斜向错开;同一次拖里混了图片/视频则文本照建、另给一句「非文本文件 请单独拖一次」(那几份不能上传——事件已经被我们 stopPropagation 了,明说比静默吞掉好)。 - **落点换算与节点搜索同一套**:`#canvas-viewport-content` 的 `translate3d(x,y,0) scale(z)` 就是屏幕 → 世界的映射,`#canvas-background` 的 rect 是原点。节点以指针为**中心** (画布侧建节点桥统一按 `nodeWidth/nodeHeight` 反推左上角)。 - **建节点走 `fisherai:add-canvas-node`**(与 Shift+A 搜索同一个出口),detail 上额外带 `textPatch: { textContent, textRichContent }`;vendor 的建节点桥先 `createCanvasNode`, 再走同一个 `updateCanvasNode` 把正文贴上去 —— 与节点里手改是同一条写路径。 - 排查读数:`window.__FISHERAI_CANVAS_TEXT_DROP__` (`drops` / `created` / `skipped` / `duplicateEvents` / `duplicateFiles` / `repeatGestures` / `lastFileCount` / `lastFile` / `lastKind` / `lastChars` / `lastError` / `lastPoint`)。 ### 一个手势只落一个节点(v1.89) 用户 2026-11-01 报「拖一次生成两个一样的节点」。三条可能的路,各挡一层;判重容器一律挂在 **window** 上(`__FISHERAI_CANVAS_TEXT_DROP_GUARD__` / `__FISHERAI_CANVAS_TEXT_DROP_REPEATS__`), 不是模块变量 —— 万一产物被加载了两份,两边看到的也是同一份记录: | 病灶 | 挡法 | 读数 | |---|---|---| | 同一个 `drop` 事件被两个监听器各处理一遍 | 事件级 `WeakSet` 认领,第二个人直接退出 | `duplicateEvents` | | `dataTransfer.files` 里同一份文件出现两次(压缩包视图 / 带虚拟文件的拖放源) | 按 `名字+大小+lastModified` 折叠 | `duplicateFiles` | | 同一个手势派发出两个 `drop` 事件(对象不同、内容一样) | 600ms 内「文件身份 + 落点(8px)」同签名跳过 | `repeatGestures` | 最后一层在 vendor 的建节点桥里(v1.89 补丁):detail 可以带 `createKey`,**只有带它的调用** 才参与去重(10s 窗口,`__FISHERAI_CANVAS_CREATE_DUPES__` 计数)—— Shift+A、`canvas_control` 批量建节点这些入口不带它,仍然允许「同一位置、同样内容建两个」。 ## 三、全屏预览(v1.89) `src/client/canvas/textPreview.ts`,由 `bootstrap.ts` 的 `installCanvasTextPreview` 装配; 样式在 `styles.ts` 里随画布那份 `