Skip to content

自定义节点:灵感卡片 ​

本例添加一个可编辑标题、带“正在构思 / 准备行动”状态的卡片节点。绘制使用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接入一样,在客户端动态导入此文件。

定义、注册和创建 ​

  1. NodeCreateOptionsMap登记创建参数,CustomNodeFields登记节点的具名业务状态。
  2. IdeaCardType.createContent()返回纯数据,不生成DOM,也不自己提交事务。
  3. slots声明标题的编辑语义,compose()将节点转换成声明式图元。
  4. IdeaCardPlugin.install()通过公开上下文注册类型。
  5. 宿主把插件传给Editor构造参数,再通过createNode()创建节点。

标题存入textBlocks,状态存入ideaCard.status。不要把两者再复制到data或额外保存一份派生标题。

状态更新与历史 ​

源码中的setIdeaStatus(editor, id, status)通过公开文档命令更新状态,撤销重做与保存导出会处理这次变化。compose()根据状态推导圆点和标签,不直接给节点赋值。

示例是固定尺寸卡片,标题过长时省略。需要自动高度、多列排版或复杂交互时,再实现对应的测量与输入协议。本例没有增加全局快捷键或跨插件依赖。

导入与资源 ​

恢复包含此类型的文档前,先安装同一节点插件。TypeScript模块扩展提供编译期类型,不能替代对外部业务字段的运行时校验;来自业务接口的状态值应先验证再调用语义命令。

本例只注册节点类型,资源由插件上下文管理。若后续增加订阅、计时器或异步请求,需要在卸载时解绑、取消并释放。

可组合的画布能力,从一个想法开始。