自定义节点:灵感卡片
本例添加一个可编辑标题、带“正在构思 / 准备行动”状态的卡片节点。绘制使用Core声明式图元,不依赖DOM或Leafer原生图元。
免费体验,点击后加载;修改只保留在当前页面。
点击加载后,可以拖动卡片、双击编辑标题、撤销编辑或重置示例。
完整源码
将下面内容保存为custom-node.ts。除快速开始中的依赖外,还需在自己的工程显式声明@skynote/core依赖;安装状态仍以Beta目标说明为准。
ts
import {
createTextBlockPrimitive,
resolveTextBlockStyle,
type CanvasPluginContext,
type CanvasPrimitive,
type NodeCreateContent,
type NodeEntity,
type NodeTextSlotDefinition,
type NodeTypeDefinition,
type SkyNotePlugin
} from '@skynote/core';
import { SkyNoteEditor } from '@skynote/editor';
import { LeaferAdapter } from '@skynote/renderer-leafer';
export type IdeaStatus = 'draft' | 'ready';
export interface IdeaCardOptions {
title?: string;
status?: IdeaStatus;
}
declare module '@skynote/core' {
interface CustomNodeFields {
ideaCard?: { status: IdeaStatus };
}
interface NodeCreateOptionsMap {
'example/idea-card': IdeaCardOptions;
}
}
export class IdeaCardType implements NodeTypeDefinition {
readonly type = 'example/idea-card';
readonly label = '灵感卡片';
readonly defaultData = {};
readonly slots: NodeTextSlotDefinition[] = [
{
slot: 'title',
label: '标题',
defaultStyle: { fontSize: 18, color: '$text', textOverflow: 'ellipsis' }
}
];
createContent(options: IdeaCardOptions): NodeCreateContent {
return {
width: 240,
height: 128,
ideaCard: { status: options.status ?? 'draft' },
textBlocks: [{ id: 'title', slot: 'title', runs: [{ text: options.title ?? '一个新想法' }] }]
};
}
compose(node: NodeEntity): CanvasPrimitive[] {
const ready = node.ideaCard?.status === 'ready';
const title = node.textBlocks?.find((block) => block.slot === 'title');
return [
{
kind: 'rect',
x: 0,
y: 0,
width: node.width,
height: node.height,
fill: '$nodeFill',
stroke: '$nodeStroke',
strokeWidth: 1,
radius: 12
},
...(title
? [
createTextBlockPrimitive(title, resolveTextBlockStyle(node, title, this), {
x: 16,
y: 20,
width: node.width - 32,
height: 48
})
]
: []),
{ kind: 'circle', cx: 22, cy: node.height - 26, r: 4, fill: ready ? '$teal' : '$yellow' },
{
kind: 'text',
x: 36,
y: node.height - 35,
width: node.width - 52,
height: 22,
runs: [{ text: ready ? '准备行动' : '正在构思' }],
style: { fontSize: 13, color: '$text' }
}
];
}
}
export class IdeaCardPlugin implements SkyNotePlugin {
readonly id = 'example/idea-card';
readonly name = '灵感卡片';
readonly version = '0.1.0';
install(ctx: CanvasPluginContext): void {
ctx.registerNodeType(new IdeaCardType());
}
}
export function setIdeaStatus(editor: SkyNoteEditor<HTMLElement>, id: string, status: IdeaStatus): void {
editor.engine.documentStore.updateNodes([id], { ideaCard: { status } });
}
export function mountCustomExample(container: HTMLElement): SkyNoteEditor<HTMLElement> {
const editor = new SkyNoteEditor<HTMLElement>({ renderer: new LeaferAdapter(), plugins: [new IdeaCardPlugin()] });
try {
editor.mount(container);
editor.engine.documentStore.batch(() => {
const draft = editor.createNode({
nodeType: 'example/idea-card',
x: 0,
y: 0,
options: { title: '让零散想法有个家', status: 'draft' }
});
const ready = editor.createNode({
nodeType: 'example/idea-card',
x: 290,
y: 70,
options: { title: '把下一步变成行动', status: 'ready' }
});
if (!draft || !ready) throw new Error('无法创建自定义卡片');
}, '创建自定义节点示例');
editor.engine.documentStore.clearHistory();
editor.fitContent();
return editor;
} catch (error) {
editor.destroy();
throw error;
}
}容器进入DOM后调用mountCustomExample(container),退出时调用返回实例的destroy()。SSR项目应与Vue接入一样,在客户端动态导入此文件。
定义、注册和创建
NodeCreateOptionsMap登记创建参数,CustomNodeFields登记节点的具名业务状态。IdeaCardType.createContent()返回纯数据,不生成DOM,也不自己提交事务。slots声明标题的编辑语义,compose()将节点转换成声明式图元。IdeaCardPlugin.install()通过公开上下文注册类型。- 宿主把插件传给Editor构造参数,再通过
createNode()创建节点。
标题存入textBlocks,状态存入ideaCard.status。不要把两者再复制到data或额外保存一份派生标题。
状态更新与历史
源码中的setIdeaStatus(editor, id, status)通过公开文档命令更新状态,撤销重做与保存导出会处理这次变化。compose()根据状态推导圆点和标签,不直接给节点赋值。
示例是固定尺寸卡片,标题过长时省略。需要自动高度、多列排版或复杂交互时,再实现对应的测量与输入协议。本例没有增加全局快捷键或跨插件依赖。
导入与资源
恢复包含此类型的文档前,先安装同一节点插件。TypeScript模块扩展提供编译期类型,不能替代对外部业务字段的运行时校验;来自业务接口的状态值应先验证再调用语义命令。
本例只注册节点类型,资源由插件上下文管理。若后续增加订阅、计时器或异步请求,需要在卸载时解绑、取消并释放。