跳转到内容

自定义文件类型

文件树里的”新建”菜单和编辑区渲染都可以由插件定义:内置的白板(Excalidraw 画板)、思维导图就是 fileTypes 插件 —— 用户新建一个 .board 文件,编辑区整块交给插件的 iframe 渲染。本篇以白板(plugins/marketplace/whiteboard/)为底。

"contributes": {
"fileTypes": [
{ "fileType": "xinghan.whiteboard::board",
"name": { "zh-CN": "画板", "en-US": "Whiteboard" },
"icon": "shapes",
"color": 15,
"suffix": "board",
"storage": "text",
"entry": "ui/index.html#/board" }
]
}
  • 新类型:fileType 用带命名空间的名字(惯例 <pluginId>::<name>,如 xinghan.whiteboard::board)——出现在「新建」菜单,图标色用色相角(0-360,宿主按亮暗主题自动算配色)或内建色板槽名,不收裸 hex;
  • 认领内建:fileType 与内建同名(如 json、markdown)= 升级替代 —— 新建入口替换内建条目,已有节点的渲染也由你接管;卸载后自动回归内建编辑器,节点零迁移(json-field 插件认领 json 就是活例)。

storage: 'text' 是当前唯一的存储形态:节点内容就是一段文本(画板的 JSON、导图的结构、markdown 原文……格式你自己定),宿主负责存取、历史与协同分发。

用户打开你类型的文件时,编辑区挂你的 entry iframe,context 带 nodeId(本 iframe 绑定的文件节点)。之后就是三件事:获取数据 → 自己处理 → 保存回去。下面逐个讲透,全部对照白板的真实实现。

内容是一个字符串(storage: 'text'),格式完全由你定 —— 惯例是 JSON。读取要处理三种情况:首次打开(空串)、正常内容、坏内容:

import { installContextBridge, getContext, onContextChange, ui } from '@xinghan/plugin-sdk/ui'
installContextBridge()
let nodeId = ''
onContextChange(init)
init()
async function init() {
const ctx = getContext()
if (nodeId || !ctx.nodeId) return // 已初始化 / context 还没到
nodeId = ctx.nodeId
const { content } = await ui.docLoad(nodeId)
let doc: MyDoc
if (!content) {
doc = { version: 1, shapes: [] } // 首次打开:给一份合法的空文档
} else {
try {
doc = migrate(JSON.parse(content)) // 老版本结构 → 当前版本
} catch {
// 坏内容(手改过/半次写入):千万别白屏 —— 展示原文+说明,让用户自救
renderCorrupt(content)
return
}
}
render(doc)
}

两条结构设计经验:

  • 文档里放 version 字段。格式以后一定会变,读入时 migrate() 把老结构升到当前版本 —— 宿主不做迁移,这是你的责任(fileTypes 的兄弟贡献 automationNodes 同一纪律);
  • 大资产不进文档文本。图片、音频这类二进制内联成 base64 会让每次自动保存全量推几 MB —— 见下面”保存数据”的瘦引用模式。

读进来之后就是普通前端状态:内存模型随你(白板直接用 Excalidraw 的场景对象,思维导图是自己的树)。与宿主相关的只有一条边界纪律:

内存模型和落库格式分开想。内存里怎么方便怎么来;落库时序列化成你定义的稳定格式(含 version)。别把渲染库的内部对象原样 JSON.stringify 进文件 —— 渲染库升级、内部字段变了,你的存量文件就废了。白板的做法:serializeAsJSON 产出 Excalidraw 的公开序列化格式,再挂上自己的 files 引用表。

保存由你自驱(文件编辑器是常驻页面,不像字段编辑器那样一次 resolveModal 完事)。糙版防抖只有三行,但真实产品要一台保存状态机 —— 白板的完整逻辑值得整段搬走:

type SaveState = 'saved' | 'dirty' | 'saving'
let saveState: SaveState = 'saved'
let sig = '' // 当前内容签名(JSON 串或哈希)
let savedSig = '' // 最后一次成功落库的签名
let inFlight = false
let timer: ReturnType<typeof setTimeout> | undefined
/** 每次编辑调它:标脏 + 800ms 防抖 */
function onDocChanged(doc: MyDoc) {
sig = JSON.stringify(doc)
if (sig === savedSig) { saveState = 'saved'; return } // undo 撤回到已存内容 → 不算脏
saveState = 'dirty'
clearTimeout(timer)
timer = setTimeout(() => void flushSave(doc), 800)
}
async function flushSave(doc: MyDoc) {
if (inFlight) return // 已在写:写完那次会自己续轮
if (sig === savedSig) return // 没有实际变化(纯平移/框选)→ 不写
const saving = sig
inFlight = true
saveState = 'saving'
try {
await ui.docSave(nodeId, JSON.stringify(doc))
savedSig = saving
if (sig === saving) saveState = 'saved'
else {
// 落库这几百毫秒里用户又画了 → 保持脏,续一轮,别丢笔画
saveState = 'dirty'
timer = setTimeout(() => void flushSave(latestDoc()), 800)
}
} catch (e) {
// 失败必须自己重试 —— 否则永远挂「未保存」;同时给用户一句人话
saveState = 'dirty'
timer = setTimeout(() => void flushSave(latestDoc()), 2000)
void ui.toast({ message: `保存失败:${(e as Error).message}`, type: 'error' })
} finally {
inFlight = false
}
}
// 切走/关闭前把挂起的保存立刻刷掉 —— 别让最后 800ms 的编辑蒸发
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') void flushSave(latestDoc())
})

状态机在防的四件事,缺一都会咬到用户:签名比对防无意义写入(纯视角平移不落库);inFlight 防并发写;“存完发现签名又变了”防丢笔画;失败自动重试防永久假脏。

图片这类二进制别内联进文档 JSON。上传进宿主资产库拿 ref,文档里只存引用;读回时按需取字节:

// 用户贴了张图:字节 → 资产库,文档里只存 ref
const { ref } = await ui.uploadAsset({
name: 'sketch.png', mime: 'image/png', dataBase64,
})
doc.images.push({ ref, width, height })
// 打开文件时:按 ref 取回字节渲染
const { dataBase64, mime } = await ui.readAsset({ ref })
img.src = `data:${mime};base64,${dataBase64}`

白板为此吃过亏才改的:base64 内联时 10 秒自动保存反复全量推几 MB。瘦引用后文档恒小,资产只传一次。

不想进「新建」菜单、只想接管某类已有文件的渲染,用轻一档的 fileRenderers:

"fileRenderers": [
{ "fileType": "ai", "entry": "ui/index.html#/ai" }
]

同样的 context(nodeId) + docLoad/docSave 循环,只是没有新建入口。

  • whiteboard —— 新类型 + Excalidraw + 防抖保存状态机(ui/src/BoardApp.tsx,含”落库期间又画了”的处理);
  • mindmap —— 同模式的思维导图;
  • json-field —— 认领内建 json 类型(校验/格式化编辑器),与它的字段贡献共用一份 UI 构建。