Cindy Desktop 中 vendored drawio 查看器的集成、更新与源码级实现解析

发布时间:2026/10/26 1:45:28
Cindy Desktop 中 vendored drawio 查看器的集成、更新与源码级实现解析

【免费下载链接】cindy

Consider it done. The open-source AI agent that works out of the box · 想到,就能做到。开源、开箱即用的 AI Agent。

项目地址:https://gitcode.com/GitHub_Trending/cind/cindy
点击查看免费下载

Cindy 桌面端的文件浏览器支持直接预览.drawio图形文件,而这一能力依赖仓库内apps/desktop/src/renderer/vendor/drawio/目录中随包分发的 drawio viewer。本文以该目录的 README 为主线,完整讲解 vendor 的边界与许可条款、版本号的自动解析方式、更新操作流程,并顺着 DrawioPreview.tsx 与 FileBodyView.tsx 的调用链,说明离线加载、data-mxgraph参数、零宽挂载陷阱以及与 Electron CSP 的相互制约。读完可以独立完成一次 viewer 升级,并能在预览失败时按源码路径定位问题。

为什么要以 vendor 方式内置 viewer

README 给出的定位非常明确:这是"用于在工作区文件浏览里预览.drawio文件的官方 viewer 脚本,直接 vendor 进仓库以避免运行时从外网拉取"。

这一决策同时满足三个约束:

  1. 离线可用:桌面端不能假设用户此刻能访问公网;DrawioPreview.tsx的头部注释也重申了"viewer 离线工作(vendor 目录里有,Vite 通过?url出 URL),不联网"。
  2. 可审计的版本与许可:文件随安装包分发,版本与许可证必须写进第三方声明,因此版本号需要可被脚本解析,而不是靠人工记忆。
  3. 不污染主包体积:viewer 本体约 3.6MB(磁盘上viewer-static.min.js实测约 3.7MB),若被静态 import 会直接进入 renderer 主 bundle;因此实现上改为首次使用时动态注入<script>。

目录构成与许可边界

vendor 目录只有三个文件,职责边界清晰:

文件作用是否随包分发
viewer-static.min.js上游发布产物,drawio 的只读 viewer 脚本是(renderer 资源)
LICENSEApache-2.0 全文,Copyright (c) JGraph Ltd是(合规材料)
README.md来源、版本、许可、更新方式与使用入口说明仓库内文档

README 记录的元信息可以直接在仓库中复核:

  • 来源:上游 drawio 项目的发布产物viewer-static.min.js。
  • 版本:30.0.4,"以 min.js 内嵌的VERSION字符串为准"。在 bundle 中确实存在mxClient={VERSION:"30.0.4",...}这样的内联常量,这也是自动化脚本唯一可靠的版本来源。
  • License:Apache-2.0,见同目录LICENSE。
  • 附加限制说明:上游对其 icon set / stencil / 模板素材另有"未经书面许可不得用于 Atlassian 产品"的附加条款;本目录只 vendor viewer代码本体,不包含也不分发那些素材,因此该限制对本产品不适用。

这条"素材 vs 代码本体"的区分是合规上最容易被忽略的一点。仓库把它同时写进了两处:README 的说明段,以及生成脚本中bundledComponent追加的免责文字——"Only the viewer JavaScript (viewer-static.min.js) is redistributed. Upstream icon sets / stencils / templates carry an additional no-Atlassian-use restriction; none of those assets are redistributed by this product."(见 generate-third-party-notices.mjs)。

需要特别提醒:因为不随包分发 stencil 与 icon 素材,某些依赖上游素材库的自定义形状在预览时可能只显示基础图形。这是"只分发代码本体"的直接后果,而不是 viewer 加载失败。

版本号如何被自动解析进第三方声明

README 明确写了"版本号自动从 min.js 解析,无需手改"。这一承诺由 scripts/generate-third-party-notices.mjs 实现,逻辑很短但很关键:

// drawio viewer — vendored 进仓库的 .drawio 文件预览脚本(renderer 资源随包分发) try { const drawioDir = path.join(DESKTOP_DIR, "src", "renderer", "vendor", "drawio"); const drawioLicense = normalizeNoticeText( fs.readFileSync(path.join(drawioDir, "LICENSE"), "utf8"), ); const drawioVersion = /VERSION:"([\d.]+)"/.exec( fs.readFileSync(path.join(drawioDir, "viewer-static.min.js"), "utf8"), )?.[1] || "vendored"; entries.push( bundledComponent({ name: "drawio viewer (vendored)", version: drawioVersion, license: "Apache-2.0", licenseText: drawioLicense + "\n\nOnly the viewer JavaScript (viewer-static.min.js) is redistributed. " + "Upstream icon sets / stencils / templates carry an additional no-Atlassian-use " + "restriction; none of those assets are redistributed by this product.", }), ); } catch { /* vendored 目录被移除时自动跳过 */ }

从源码可以读出几个工程约束:

  • 版本提取是正则而非 JSON 解析:/VERSION:"([\d.]+)"/匹配 bundle 里的VERSION:"30.0.4"。若上游改变压缩产物中的这段形态(例如改成单引号或变量间接赋值),正则失配会静默回退为字符串"vendored",声明仍能生成但版本信息退化。升级后务必检查生成的声明文本。
  • 失败即跳过:整个块包在try/catch中,LICENSE或 min.js 缺失时不抛错,只跳过该条目。这意味着如果有人误删 vendor 目录,构建不会失败,但产物会缺少一条许可声明——需要靠 review 或声明文件的 diff 发现。
  • 许可全文被规范化后内嵌:normalizeNoticeText负责统一换行与空白,最终写入docs/legal/notices/下的声明与 SBOM 文件,生成物头部标注"本文件由 scripts/generate-third-party-notices.mjs 自动生成,请勿手改"。

当前仓库中已提交的声明可以直接印证结果,例如 docs/legal/notices/THIRD-PARTY-NOTICES.txt 与各平台文件desktop-macos.txt、desktop-win.txt、desktop-linux.txt均包含:

drawio viewer (vendored) 30.0.4 License: Apache-2.0

SPDX 清单 docs/legal/notices/sbom/desktop-linux.spdx.json 中同样有"name": "drawio viewer (vendored)"条目。至于 vendored 资源的许可归口说明,见 docs/legal/README.md 中把vendor/drawio/LICENSE归类为"vendored 前端资源的上游许可证,随资源分发"。

更新流程:替换文件 + 重新生成声明

README 的更新方式只有两句,但落地时建议按下面四步执行,避免只完成一半:

第一步:取新版产物。从上游仓库或 diagrams.net 发布产物中获取新版viewer-static.min.js。

第二步:覆盖同名文件。用新文件覆盖apps/desktop/src/renderer/vendor/drawio/viewer-static.min.js。注意保持文件名不变——DrawioPreview.tsx通过固定路径@/vendor/drawio/viewer-static.min.js?url引用它,改名会直接导致构建期解析失败。若上游 LICENSE 有变更,同步更新同目录LICENSE。

第三步:重新生成第三方声明。

pnpm licenses:generate

该命令在根 package.json 中定义为node scripts/generate-third-party-notices.mjs,会重建docs/legal/notices/下的声明文本与 SBOM。版本号无需手改。

第四步:核对与回归。建议至少确认三件事:

# 1) 新产物里确实内嵌了版本串(正则的来源) grep -o 'VERSION:"[0-9.]*"' apps/desktop/src/renderer/vendor/drawio/viewer-static.min.js | head -1 # 2) 声明中版本已被刷新 grep -n "drawio viewer (vendored)" docs/legal/notices/THIRD-PARTY-NOTICES.txt # 3) 声明相关测试仍通过 node --test scripts/__tests__/third-party-notices.test.mjs

第 1 步的输出应与声明中的版本一致(当前为30.0.4);若第 1 步为空,说明新版压缩形态不再匹配正则,声明会退化成vendored,此时应更新生成脚本中的正则而不是手改声明文件。

使用入口:从文件树到预览组件的调用链

README 只授权一个入口:"apps/desktop/src/renderer/features/cc-agent/workdir-browse/DrawioPreview.tsx—— 首次打开.drawio文件时动态注入<script>加载(约 3.6MB,不进 main bundle),后续复用。业务方不要直接 import 本目录。"

"业务方不要直接 import"是硬性约束:min.js 是 UMD 风格的全局脚本,没有 ES 模块导出,直接import既拿不到GraphViewer,还会把它打进主包。正确姿势只有经过DrawioPreview这一层封装。

文件类型识别

是否走 drawio 分支由 lib/drawioExt.ts 判定:

export function isDrawioPath(filePath: string): boolean { const lastSlash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')); const name = filePath.slice(lastSlash + 1); const dot = name.lastIndexOf('.'); const ext = dot < 0 ? '' : name.slice(dot).toLowerCase(); return ext === '.drawio'; }

三个细节值得注意:

  • 同时处理/与\,因此 Windows 路径与 POSIX 路径都能识别;
  • 先剥离目录再找扩展名,避免目录名中带点号造成误判(例如a.b/notes不会被当成有扩展名);
  • 大小写不敏感(toLowerCase()),.DRAWIO同样命中。

文本分支截流而非二进制分支

FileBodyView.tsx 中的关键实现是:.drawio文件本质是 XML 文本(无 NULL 字节),因此内容读取会落在text分支;而用户期望看到的是图形而不是原始 XML,所以在进入PlaintextEditor/ markdown 渲染之前先把它"截走":

if (isDrawioPath(relPath)) { return ( <DrawioPreview workdir={workdir} relPath={relPath} xmlContent={content.content} size={content.size} mtimeMs={content.mtimeMs} /> ); }

源码注释同时说明了为什么在binary分支刻意不处理.drawio:"理论上加密 drawio 才会进 binary,极罕见,退回UnrenderablePlaceholder即可。"

外部拖拽文件同样受支持

dropExternalFile.ts 把 drawio 与文本、图片、PDF 一起列入可预览类型:

export function isDroppedFilePreviewSupported(filePath: string): boolean { return ( isTextPreviewSupported(filePath) || isImagePath(filePath) || isPdfPath(filePath) || isDrawioPath(filePath) ); }

这解释了为什么从系统文件管理器拖入一个.drawio也能直接预览——它复用同一套isDrawioPath判定,避免了"文件树里能预览、拖进来不能预览"的行为割裂。

渲染实现剖析

DrawioPreview的核心是一个useEffect加一个模块级 Promise。拆开看有五个关键设计点。

1. 懒加载与模块级 Promise 复用

// 模块级 promise 复用 —— 第一次注入 script 就开始 race,后续 mount 共享同一个 // resolve。失败时清空,允许下一次重试(网络盘读不到 / SHA 损坏等极端场景)。 let viewerLoadPromise: Promise<DrawioGraphViewer> | null = null; function loadViewerOnce(): Promise<DrawioGraphViewer> { if (window.GraphViewer) return Promise.resolve(window.GraphViewer); if (viewerLoadPromise) return viewerLoadPromise; viewerLoadPromise = new Promise<DrawioGraphViewer>((resolve, reject) => { const script = document.createElement('script'); script.src = viewerUrl; script.async = true; script.onload = () => { if (window.GraphViewer) { resolve(window.GraphViewer); } else { viewerLoadPromise = null; reject(new Error('drawio viewer loaded but GraphViewer is undefined')); } }; script.onerror = () => { viewerLoadPromise = null; reject(new Error('failed to load drawio viewer-static.min.js')); }; document.head.appendChild(script); }); return viewerLoadPromise; }

设计意图逐条对应:

  • script.src = viewerUrl中的viewerUrl来自import viewerUrl from '@/vendor/drawio/viewer-static.min.js?url'。Vite 的?url后缀把文件作为独立资源发射并返回其 URL,而不是把源码内联进模块图——这就是"不进 main bundle"的实现方式。顺带说明,vite.renderer.config.ts 中自定义的assetsInlineLimit只对*-worklet.js返回false,其余资产维持默认阈值行为;3.6MB 的脚本远超阈值,本来也不会被内联。
  • if (window.GraphViewer)是幂等短路:多次挂载不会重复插脚本。
  • viewerLoadPromise作为模块级缓存,让并发打开多个.drawio标签页时共享同一次加载。
  • 失败时把缓存置null,使下一次打开可以重试——注释举的例子是网络盘读不到、SHA 损坏等极端场景。
  • onload之后仍要再检查window.GraphViewer:脚本加载成功不等于全局对象就位,这种情况会明确抛drawio viewer loaded but GraphViewer is undefined,而不是留一个空白画布。

2.data-mxgraph参数:逐项说明

viewer 的对外契约是"一个带data-mxgraphJSON 的容器 div",DrawioPreview按此构造:

const host = document.createElement('div'); host.className = 'mxgraph'; host.style.maxWidth = '100%'; host.setAttribute( 'data-mxgraph', JSON.stringify({ xml: xmlContent, highlight: '#0000ff', nav: true, resize: true, 'dark-mode': 'auto', 'check-visible-state': false, }), ); container.appendChild(host); viewer.createViewerForElement(host);
参数取值作用与影响
xml文件原文(由父层useFileContent读出)图形数据源。直接内联,避免预览器二次读取文件
highlight'#0000ff'高亮色(用于搜索/定位等交互反馈)
navtrue打开平移、缩放等导航交互
resizetrue容器尺寸变化时自适应重排
dark-mode'auto'跟随 OS/应用主题;注释说明"浅色仍是浅图,深色用对应反色"
check-visible-statefalse关键项,见下一节

3.check-visible-state: false:React 动态挂载下的零宽陷阱

源码给出了这个参数存在的真实原因,值得完整引用其含义:

drawio 默认会检查host.offsetWidth,0 宽时挂MutationObserver等 parent attribute 变化才渲染。React 动态挂载 + flex 布局下,host 刚挂上去就是 0 宽,parent attribute 也不会再变,observer 永远等不到 → viewer 静默卡住不渲染。设为false让 drawio 立即渲染,布局到位后 resize handler 会自适应宽度。

也就是说,这个参数是"避免永久白屏"的必需项,而非性能优化。它同时解释了为什么外层容器用了flex min-h-full items-center justify-center p-6且内层ref容器是max-w-full——布局由 React 侧负责,绘制时机由 viewer 侧立即触发,两者解耦。

4. 用createViewerForElement而不是processElements

类型声明里对两个 API 的差异写得很直白:

interface DrawioGraphViewer { /** drawio 源码里这个参数是 class name 字符串(默认 'mxgraph'),不是 scope 元素。 * 传 DOM 元素会被 toString 成 "[object HTMLDivElement]" → 找不到任何元素 → 静默失败。 */ processElements: (className?: string) => void; /** 给单个已含>useEffect(() => { let cancelled = false; const container = containerRef.current; if (!container) return; container.replaceChildren(); setState({ kind: 'loading' }); (async () => { try { const viewer = await loadViewerOnce(); if (cancelled) return; // ...构造 host 并渲染 if (!cancelled) setState({ kind: 'rendered' }); } catch (err) { if (cancelled) return; log.warn('drawio render failed', { relPath, error: String(err) }); setState({ kind: 'error', message: String(err) }); } })(); return () => { cancelled = true; }; }, [workdir, relPath, xmlContent]);

要点:

  • 清理旧画布:container.replaceChildren()在每次 effect 执行时先清空,避免切换文件后残留上一张图。
  • 异步取消:cancelled标志防止组件卸载或快速切换文件后对旧实例setState。
  • 重渲染触发条件:依赖数组是[workdir, relPath, xmlContent],因此文件内容变化(外部编辑保存)会触发重绘,而size/mtimeMs只用于降级占位卡展示,不参与绘制。
  • 降级路径:出错时渲染 UnrenderablePlaceholder,透传workdir、relPath、size、mtimeMs,让用户仍能通过"在系统中打开"等方式处理文件,而不是只看到一个报错。
  • 已知限制:replaceChildren()直接丢弃旧 DOM,没有调用 viewer 的销毁接口;源码中createViewerForElement的返回类型也只是unknown。从代码结构看,频繁切换多个大型.drawio文件时可能存在 viewer 内部对象留存,这属于可观察的推断而非已确认的问题。

加载态还有一处 i18n 文案:ccAgent.workdirBrowse.fileBody.drawioLoading,在 zh-CN/common.json 中为"正在加载 draw.io 预览…",同时提供了defaultValue兜底,因此即使 locale 缺键也不会露出 key 名。

为什么不 fetch 文件:xdt-file://与 CORS

DrawioPreview.tsx的头部注释解释了它为什么坚持"只接收xmlContentprop",而不是像图片预览那样自己去读文件:

文件内容(XML 文本)由父层FileBodyView经useFileContent已读好,直接传xmlContentprop。不走fetch(xdt-file://...)—— 该协议corsEnabled: false,fetch API 会被跨源拦掉(TypeError: Failed to fetch),同时也省一次冗余读取。

仓库里可以找到对应的协议注册证据:schemePrivileges.test.ts 断言各自定义 scheme(含xdt-file)的权限集合为standard: true、secure: true、supportFetchAPI: true、bypassCSP: false、corsEnabled: false。其中corsEnabled: false正是 renderer 侧跨源fetch被拒的原因;测试注释也提到该组合对xdt-model(<model-viewer>fetch)与xdt-file(FBXLoader / drawio viewer fetch)是"load-bearing"的。

这里有一个值得注意的实现取向差异:协议层保留了supportFetchAPI: true,但 drawio 预览的实际代码路径选择完全绕开fetch,改由已经读过内容的上层直接传字符串。好处是一石二鸟——既避开 CORS 限制,又省掉一次磁盘或远端读取。这也解释了为什么DrawioPreviewProps里xmlContent: string是必填而非可选。

与 Electron 安全边界的相互作用

内置一个 3.6MB 的第三方脚本,不只是打包问题,还牵动 CSP。

script-src中的'unsafe-eval'

apps/desktop/src/main/security/csp.ts 明确记录了这条权衡:

const scriptSrc = isDev ? ["'self'", "'unsafe-inline'", "'unsafe-eval'", "'wasm-unsafe-eval'"] : ["'self'", "'unsafe-eval'", "'wasm-unsafe-eval'"];

注释说明:vendored drawio viewer "calls real JS eval() from withincreateViewerForElementand runs in THIS renderer; without 'unsafe-eval' any.drawiofile preview deterministically throws EvalError and degrades to the unrenderable placeholder"。同时强调这是有意为之的取舍——'unsafe-eval'只让代码能到达已存在的eval()sink,而注入内联脚本与远程脚本这两条主路径在 prod 中依然被堵死:script-src中没有'unsafe-inline',也没有远程源。

对应的回归断言在 csp.test.ts:

it('script-src blocks inline + remote script (the primary XSS hardening) but allows eval for vendored drawio', () => { const d = parsePolicy(buildContentSecurityPolicy(PROD_CTX)); expect(d['script-src']).toEqual(["'self'", "'unsafe-eval'", "'wasm-unsafe-eval'"]); expect(d['script-src']).not.toContain("'unsafe-inline'"); expect(d['script-src']).toContain("'unsafe-eval'"); });

这条测试是"升级 viewer 时不要顺手删'unsafe-eval'"的护栏:一旦删除,.drawio预览会稳定失败并降级为占位卡,而其他功能看起来一切正常,很容易被误判为 viewer 版本问题。源码注释还写明了后续收紧方向:把 drawio 移入独立<iframe sandbox>并配置自有 CSP,从而让 prod 可以移除'unsafe-eval'。

'self'与"内置"的双向约束

prod 的script-src只有'self',没有远程源。这与 vendor 决策互为因果:脚本必须与 renderer 同源,因此不能改回"运行时从 CDN 拉取",否则会被 CSP 直接拦截。

style-src中的字体域

同一份 CSP 的style-src包含https://fonts.googleapis.com,注释写的原因是"drawio viewer loads Google Font stylesheets for sketch-font diagrams"。这意味着在完全离线的环境下,手绘风格(sketch)图元的字体会回退,但样式表加载失败不会阻断图形绘制。定位这类"图能出来、字体不对"的反馈时,可以先区分是离线字体回退还是 viewer 未加载——后者会走UnrenderablePlaceholder,前者不会。

常见问题排查表

现象首要怀疑点源码定位
打开.drawio显示原始 XML走了 text 分支,isDrawioPath未命中drawioExt.ts、FileBodyView.tsx
预览区域空白但无报错check-visible-state为真导致零宽不渲染;或误用processElements传了 DOM 元素DrawioPreview.tsx
控制台出现EvalErrorCSPscript-src缺少'unsafe-eval'csp.ts、csp.test.ts
提示 "drawio viewer loaded but GraphViewer is undefined"脚本加载成功但全局对象未就位(产物异常/被拦截)loadViewerOnce的onload分支
提示 "failed to load drawio viewer-static.min.js"资源 URL 失效或文件缺失?url导入与 vendor 目录
TypeError: Failed to fetch试图对xdt-file://做fetchschemePrivileges.test.ts、DrawioPreview.tsx头部注释
声明中版本变成vendored新版 bundle 不匹配VERSION:"x.y.z"正则generate-third-party-notices.mjs
部分自定义形状不显示未分发上游 stencil / icon 素材README 的附加限制说明
手绘字体样式异常离线无法访问fonts.googleapis.comcsp.ts 的style-src

排查时的通用起点是DrawioPreview的日志:失败分支会调用log.warn('drawio render failed', { relPath, error }),logger 名称为drawio,可据此在日志中过滤。

维护清单:改动这类代码时该守住什么

  1. 保持唯一入口:外部只经DrawioPreview,不要让业务组件直接importvendor 目录(README 明确要求)。若需要在别处预览 drawio,复用isDrawioPath+ 组件,而不是再写一套 loader。
  2. 坚持?url懒加载:任何改成静态import的写法都会把 3.6MB 塞进主包,违背 README 记录的设计意图。
  3. 坚持传入xmlContent:不要再引入fetch(xdt-file://...),corsEnabled: false决定了它必然失败。
  4. 升级后三件事:核对VERSION串、跑pnpm licenses:generate并检查声明 diff、手动打开一个含手绘风格与自定义形状的.drawio验证渲染。
  5. 不要删'unsafe-eval':除非完成 iframe 隔离方案,否则会直接破坏预览功能。

延伸阅读

  • vendor 目录说明与许可边界:apps/desktop/src/renderer/vendor/drawio/README.md、apps/desktop/src/renderer/vendor/drawio/LICENSE
  • 预览组件与类型声明:apps/desktop/src/renderer/features/cc-agent/workdir-browse/DrawioPreview.tsx
  • 文件类型判定与路由:apps/desktop/src/renderer/features/cc-agent/workdir-browse/lib/drawioExt.ts、apps/desktop/src/renderer/features/cc-agent/workdir-browse/FileBodyView.tsx
  • 拖拽入口:apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/dropExternalFile.ts
  • 合规生成与产物:scripts/generate-third-party-notices.mjs、docs/legal/notices/THIRD-PARTY-NOTICES.txt、docs/legal/README.md
  • 安全边界:apps/desktop/src/main/security/csp.ts、apps/desktop/src/main/__tests__/schemePrivileges.test.ts

【免费下载链接】cindy

Consider it done. The open-source AI agent that works out of the box · 想到,就能做到。开源、开箱即用的 AI Agent。

项目地址:https://gitcode.com/GitHub_Trending/cind/cindy
点击查看免费下载
上一篇:Minimal Mistakes 单页布局加宽指南:`classes: wide` 的配置方法与底层实现原理
下一篇:Biome Markdown 格式化器如何对齐有序列表项内的缩进内容:CommonMark Example 249 测试用例深度解析

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考