自定义文件类型
文件树里的”新建”菜单和编辑区渲染都可以由插件定义:内置的白板(Excalidraw 画板)、思维导图就是 fileTypes 插件 —— 用户新建一个 .board 文件,编辑区整块交给插件的 iframe 渲染。本篇以白板(plugins/marketplace/whiteboard/)为底。
两种玩法:新类型 vs 认领内建
Section titled “两种玩法:新类型 vs 认领内建”"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 —— 见下面”保存数据”的瘦引用模式。
自行处理数据
Section titled “自行处理数据”读进来之后就是普通前端状态:内存模型随你(白板直接用 Excalidraw 的场景对象,思维导图是自己的树)。与宿主相关的只有一条边界纪律:
内存模型和落库格式分开想。内存里怎么方便怎么来;落库时序列化成你定义的稳定格式(含 version)。别把渲染库的内部对象原样 JSON.stringify 进文件 —— 渲染库升级、内部字段变了,你的存量文件就废了。白板的做法:serializeAsJSON 产出 Excalidraw 的公开序列化格式,再挂上自己的 files 引用表。
保存由你自驱(文件编辑器是常驻页面,不像字段编辑器那样一次 resolveModal 完事)。糙版防抖只有三行,但真实产品要一台保存状态机 —— 白板的完整逻辑值得整段搬走:
type SaveState = 'saved' | 'dirty' | 'saving'
let saveState: SaveState = 'saved'let sig = '' // 当前内容签名(JSON 串或哈希)let savedSig = '' // 最后一次成功落库的签名let inFlight = falselet 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 防并发写;“存完发现签名又变了”防丢笔画;失败自动重试防永久假脏。
大资产:瘦引用模式
Section titled “大资产:瘦引用模式”图片这类二进制别内联进文档 JSON。上传进宿主资产库拿 ref,文档里只存引用;读回时按需取字节:
// 用户贴了张图:字节 → 资产库,文档里只存 refconst { 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
Section titled “只接管渲染:fileRenderers”不想进「新建」菜单、只想接管某类已有文件的渲染,用轻一档的 fileRenderers:
"fileRenderers": [ { "fileType": "ai", "entry": "ui/index.html#/ai" }]同样的 context(nodeId) + docLoad/docSave 循环,只是没有新建入口。
whiteboard—— 新类型 + Excalidraw + 防抖保存状态机(ui/src/BoardApp.tsx,含”落库期间又画了”的处理);mindmap—— 同模式的思维导图;json-field—— 认领内建json类型(校验/格式化编辑器),与它的字段贡献共用一份 UI 构建。