# Markmap.js

markmap 全名 markdown mindmap。当前记录版本,0.16.0。

它是 Markdown 和 思维导图的组合。通过解析 Markdown 的内容并提取其内在的层次结构并呈现交互式思维导图,又称为标记图。

# 它的出现

说来话长,短话长说。

正赶大模型火热,markdown 从记录笔记的语法忽然变成了接口返回的数据格式,让人着实有点头大。

产品同学提了一个很合理的需求:要把大模型返回的 markdown 数据变成思维导图,并亲切的送上了“人家的项目”都有了思维导图的鼓励。

于是乎,在流下激动的泪水后。markmap 在不经我同意的情况下闯进了我的生活...🙄🙄🙄

我打开了人家的官网 (opens new window),看到了人家的文档,没错就是这么简洁。

An Image

文档页面更简洁,先放一张图片,这个图片是 json options 的 API。请记住这个名字:jsonOptions An Image

# 它的工作流程

yarn add markmap-common markmap-lib markmap-toolbar markmap-view

子模块说明

markmap-common 其他markmap子模块库使用的常见类型和实用函数

markmap-libMarkdown 转换为 Markmap 使用的数据

markmap-viewmarkmap-lib 转换的数据渲染成标记图

markmap-toolbar 为思维导图创建工具栏

基本的渲染流程是,使用 markmap-lib 把 Markdown 数据预处理为结构化数据,然后再使用 markmap-view 将数据渲染为交互式的 SVG。

附上一张工作流程图

An Image

# Markmap-lib

yarn add markmap-lib

它负责解析 Markdown 并且创建一个节点树,返回根节点和一个功能对象。这个功能对象包含了解析期间活跃的节点属性。

# 数据转化 API

将 Markdown 数据转化成 markmap 可渲染的数据的方式如下:

import { Transformer } from "markmap-lib";

const transformer = new Transformer();

// 1. transform Markdown
const { root, features } = transformer.transform(markdownData);

// 2. get assets
// either get assets required by used features
const { styles, scripts } = transformer.getUsedAssets(features);

// or get all possible assets that could be used later
const { styles, scripts } = transformer.getAssets();

通过 markmap-lib 的处理,我们就有了可以渲染思维导图的数据。

它还有使用插件的方式,没有使用经验,参考官网。

# 数据转化示例

来看一段 React 代码,针对以下 markdown 数据解析后的数据结构

import { useState, useRef, useEffect } from "react";
import { Transformer } from "markmap-lib";
import { Markmap } from "markmap-view";
import "markmap-toolbar/dist/style.css";

const initValue = `# markmap
## beautiful
  ### beautiful country
  ### 漂亮国
## useful
  ### useful person
  ### 有用的人
  ### 舔狗
## easy
  ### easy man
  ### 简单的人
  ### 沙雕
## interactive
  ### interactive OS
`;

const Mindmap = () => {
  const [value, setValue] = useState("");
  const refSvg = useRef(null);

  useEffect(() => {
    if (refSvg.current && value) {
      const mm = Markmap.create(refSvg.current);
      if (!mm) return;

      const transformData = transformer.transform(value);
      console.log("root", transformData);
      const { root } = transformData;
      mm.setData(root);
    }
  }, [value]);

  useEffect(() => {
    const timer = setTimeout(() => {
      setValue(initValue);
      clearTimeout(timer);
    }, 1000);
  }, []);

  return (
    <div className="mindmap-page">
      <svg ref={refSvg} />
    </div>
  );
};

An Image

  • content 是原始数据
  • contentLineOffset 不清楚,看命名应该是 svg 连接线的尺寸
  • features 就是上面说的活跃节点的节点属性
  • root 就是结构化数据了,最终渲染也就是用的这个结构化数据。

# 稍微拆解一下 root

An Image

它主要的结构字段如下:

{
  "content": "markmap", // 当前节点的内容
  "children": [ // 当前节点的字节点,字节点数据结构和当前节点一样
    // ...
  ],
  "payload": {
    "lines": "0,1" // 连线序列
  },
  "state": { // 当前节点的具体信息
    "depth": 1, // 当前节点在节点树中的层级
    "id": 1, // 当前节点的 id
    "el": div, // 当前节点渲染内容的dom标签
    "path": "1", // 当前节点的路径信息或是层级
    "size": [69, 20], // 当前节点内容渲染的 width 和 height
    "key": "1markmap",
    "x0": 0, // 当前节点内容下的线的x轴起始点
    "y0": 0 // 当前节点内容下的线的y轴起始点
  }
}
  1. content 是当前这一级的文本内容

  2. children 一个数据,表示的是字节点,每个字节点的数据结构和父级一样

  3. payload 当前来看只是一个描述连线序列的 json

  4. state 包含了当前节点的具体信息

    • depth 是当前节点在节点树中的等级。
      一级节点
        二级节点1
          三节节点1
          ...
        二级节点2
    
    • el 标识渲染内容的 dom 是 div 标签

    • path 是路径信息,就是每条连线上的 data-path 属性值

    • size 渲染内容的宽高,widthheight

    • x0、y0 画线的起始点,一级节点就是 0 0 开始的,来看下二级的第一个节点: An Image

    • id 从整体数据结构以及递归的函数执行机制中来看,应该表示的是当前节点创建的顺序。

      最初个人的理解以为,渲染级别应该是:

        一级节点 id=1
          二级节点1 id=2
            二级节点的第一个节点 id=2-1
            二级节点的第二个节点 id=2-2
          二级节点2 id=3
            二级节点第一个节点 id=3-1
            二级节点第二个节点 id=3-2
          二级节点3 id=4
      

      但实际上最重的 id 排序是这样的:

        一级节点 id=1
          二级节点1 id=2
            二级节点的第一个节点 id=3
            二级节点的第二个节点 id=4
          二级节点2 id=5
            二级节点第一个节点 id=6
            二级节点第二个节点 id=7
          二级节点3 id=8
            ....
      

      我想这种设置方式应该是为了便于 svg 连线数据的方便计算,每一个节点的 payload.line 属性的值都是 上一级节点 id + 当前节点的 id 拼成的字符串。

      如下,二级节点第一个节点的 payload.lines 就是 1,2

      {
        "content": "markmap",
        "children": [
          {
            "content": "beautiful",
            "children": [
              //...
            ],
            "payload": {
              "lines": "1,2" // 上一级节点 id,本级节点id
            },
            "state": {
              "depth": 2,
              "id": 2,
              "el": {},
              "path": "1.2",
              "size": [65, 20],
              "key": "1.2beautiful",
              "x0": -101.25,
              "y0": 165
            }
          }
        ],
        "payload": {
          "lines": "0,1"
        },
        "state": {
          "depth": 1,
          "id": 1,
          "el": {},
          "path": "1",
          "size": [69, 20],
          "key": "1markmap",
          "x0": 0,
          "y0": 0
        }
      }
      

      而一级节点和二级节点第一个的连线有一个 data-path 标识就是 1.2 An Image

通过上面拆解,markmap-lib 已经将 mardown 数据转化成了结构化数据。如何使用这些数据把它们变成图形,就需要 markmap-view 登场了。

# Markmap-view

yarn add markmap-view

# 渲染思维导图流程

# 1. 首先,需要在 dom 中创建一个 SVG 标签,可以直接用行间样式指明 SVG 标签的宽高。

// umd 用法
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<script src="https://cdn.jsdelivr.net/npm/markmap-view"></script>

// 声明标签
<svg id="markmap" style="width: 800px; height: 800px"></svg>

假设此时的我们已经从 markmap-lib 中获得了 { root } 节点和相关资源 { styles, scripts }

# 2. 两种方式引入 markmap-view

// load with <script>
const { markmap } = window;
const { Markmap, loadCSS, loadJS } = markmap;

// or as ESM
import { Markmap, loadCSS, loadJS } from "markmap-view";

# 3. 渲染思维导图到 SVG 元素上

// 1. load assets
if (styles) loadCSS(styles);
if (scripts) loadJS(scripts, { getMarkmap: () => markmap });

// 2. create markmap
// `options` 是可选的,没有也可以传一个 `undefined`
// 这个函数会返回一个 Markmap 实例
Markmap.create("#markmap", options, root);

注意:第一个参数 #markmap 也可以是一个实际的 SVG dom 对象,例如:

const svgEl = document.querySelector("#markmap");
Markmap.create(svgEl, options, data); // -> returns a Markmap instance

# 简单拆解一下

先来看一下 Markmap 的类型声明

export declare class Markmap {
  options: IMarkmapOptions;
  state: IMarkmapState;
  svg: ID3SVGElement;
  styleNode: d3.Selection<
    HTMLStyleElement,
    FlextreeNode<INode>,
    HTMLElement,
    FlextreeNode<INode>
  >;
  g: d3.Selection<
    SVGGElement,
    FlextreeNode<INode>,
    HTMLElement,
    FlextreeNode<INode>
  >;
  zoom: d3.ZoomBehavior<SVGElement, FlextreeNode<INode>>;
  revokers: (() => void)[];
  private imgCache;
  private debouncedRefresh;
  constructor(
    svg: string | SVGElement | ID3SVGElement,
    opts?: Partial<IMarkmapOptions>
  );
  getStyleContent(): string;
  updateStyle(): void;
  handleZoom: (e: any) => void;
  handlePan: (e: WheelEvent) => void;
  toggleNode(data: INode, recursive?: boolean): void;
  handleClick: (e: MouseEvent, d: FlextreeNode<INode>) => void;
  initializeData(node: INode): void;
  private _checkImages;
  private _loadImage;
  setOptions(opts?: Partial<IMarkmapOptions>): void;
  setData(data?: IPureNode | null, opts?: Partial<IMarkmapOptions>): void;
  renderData(originData?: INode): void;
  transition<T extends d3.BaseType, U, P extends d3.BaseType, Q>(
    sel: d3.Selection<T, U, P, Q>
  ): d3.Transition<T, U, P, Q>;
  // Fit the content to the viewport. —— 使内容适合视口。
  fit(): Promise<void>;
  findElement(node: INode):
    | {
        data: FlextreeNode<INode>;
        g: SVGGElement;
      }
    | undefined;
  // Pan the content to make the provided node visible in the viewport.
  // 平移内容以使提供的节点在视口中可见。
  ensureView(
    node: INode,
    padding: Partial<IPadding> | undefined
  ): Promise<void>;
  // Scale content with it pinned at the center of the viewport.
  // 缩放固定在视口中心的内容。
  rescale(scale: number): Promise<void>;
  destroy(): void;
  static create(
    svg: string | SVGElement | ID3SVGElement,
    opts?: Partial<IMarkmapOptions>,
    data?: IPureNode | null
  ): Markmap;
}

# 1. 对于一个 Markmap 配置 jsonOptions 的时机有四次。

  • new Markmap() 时,可以传入 opts 参数
  • Markmap.create() 方法调用时,这也是官网提供的示例的方式,可以传入 opts。它的内部其实也就是 new Markmap() 同时返回 Markmap 实例。
  • setOptions(opts) 通过调用设置配置方法,这个方法仅用于设置配置项。后面要通过再主动调用 renderData 等不同方法去执行更新
  • setData(data, opts) 调用 setData 方法后,会连续性调用 setOptionsthis.state.data 更新数据、 initializeData(data) 方法、updateStyle() 方法以及 renderData() 方法。

# 2. 常用的思维导图交互方式

  • rescale(number) 缩放视口中的内容,markmap-toolbar 中放大缩小调用的也是该方法。
  • fit() 确保思维导图大小适配当前设置的思维导图的视口,markmap-toolbar 中调用的就是这个方法。
  • handleZoom 这个方法在源码里根据配置 zoom 来绑定滚轮缩放思维导图
  • handlePan 这个方法在源码根据配置 pan 来绑定滚轮平移思维导图

# Markmap-toolbar

yarn add markmap-toolbar

# 1. 假设我们创建了一个名为 mm 的 markmap 实例对象。

有两种方法引入 markmap-toolbar

// load with <script>
const { markmap } = window;
const { Toolbar } = markmap;

// or as ESM
import { Toolbar } from "markmap-toolbar";

# 2. 创建一个工具栏,并把它附加到 markmap 的 dom 对象中

const { el } = Toolbar.create(mm);
el.style.position = "absolute";
el.style.bottom = "0.5rem";
el.style.right = "0.5rem";

// `container` could be the element that contains both the markmap and the toolbar
container.append(el);

# 更多

官网对每一个子模块的 API 或 方法描述的比不是那么清晰,可能是因为代码没有那么复杂。

这里以 markmap-toolbar 来补充说明,我在项目里是这么使用 markmap-toolbar 的:

import { Toolbar } from 'markmap-toolbar';

/**
 * @param {mm} markmap.create() 创建的 markmap 实例
 * @param {wrapper} renderToolbar 要渲染到的html元素
*/
renderToolbar(mm, refToolbar.current as HTMLDivElement);

const renderToolbar = (mm: Markmap, wrapper: HTMLElement) => {
  while (wrapper?.firstChild) wrapper.firstChild.remove();
  if (mm && wrapper) {
    const toolbar = new Toolbar();
    toolbar.setBrand(false); // 隐藏 toolbar 中 markmap 的logo和url
    toolbar.attach(mm);
    toolbar.setItems(['zoomIn', 'zoomOut', 'fit']); // 重新设置默认的功能模块

    wrapper.append(toolbar.render());
  }
};

从源码里可以看到 markmap-toolbar 中的 Toolbar 是一个 class 类。 它里面有很多静态数据,比如默认要展示工具(放大、缩小、适配窗口以及是否展示 markmap 的 logo 及链接)

这里附一张官方示例的结果图,你会发现长得不是那么好看。 An Image

如果调整这些配置,就需要调用它内部提供的各种静态方法处理,看源代码是最佳的方式。也可以部分参考上面的代码。

# Markmap-cli

# 作用

在本地的终端工具中使用 markmap 命令行

# npx 直接调用

npx markmap-cli markmap.md

# 全局安装

npm install -g markmap-cli

markmap markmap.md

在对 源 markmap.md 文件更新时,也可以开启热更新模式。即:开启监视模式。

markmap -w markmap.md

# JSON Options

到这里,就开始聊一聊开头说的 jsonOptions 配置。

Markmap 有它自己的配置选项,可以在创建或创建之后进行传递。常见的写法如下:

Markmap.create(svg, markmapOptions, data);

但是,但是,markmapOptions 是一个级别较低对象,包含复杂的逻辑,很难序列化,所以它是不可移植的。

jsonOptions 的存在是为了可移植性考虑的。但是它只能代表 markmapOptions 的子集。

所以,使用 jsonOptions 需要将它通过一个函数转换成 markmapOptions

import { deriveOptions } from "markmap-view";

const markmapOptions = deriveOptions(jsonOptions);

# color

类型: string | string[],默认值是这个 d3.schemeCategory10 (opens new window)

注释: 从代码层面来看,color 是一个函数,返回一个字符串,并不是一个 string[] 类型

export interface IMarkmapOptions {
  id?: string;
  autoFit: boolean;
  color: (node: INode) => string; // 这个 color 就是一个字符串
  duration: number;
  embedGlobalCSS: boolean;
  fitRatio: number;
  maxWidth: number;
  nodeMinHeight: number;
  paddingX: number;
  scrollForPan: boolean;
  spacingHorizontal: number;
  spacingVertical: number;
  initialExpandLevel: number;
  zoom: boolean;
  pan: boolean;
  toggleRecursively: boolean;
  style?: (id: string) => string;
}

作用: 用作每个节点的分支和圆圈颜色的颜色列表。

# colorFreezeLevel

类型: number,默认值是 0

作用: 冻结指定级别分支的颜色。“冻结”的意思就是指后续所有的子分支都将使用它们祖先节点在冻结级别的颜色。

默认值 0 的意思就是不冻结。

# duration

类型: number,默认值是 500

作用: 折叠或展开节点的时间

# maxWidth

类型: number,默认值是 0

作用: 每个节点内容的最大宽度,0 表示没有限制。

# initialExpandLevel

类型: number,默认值是 -1

作用: 初始渲染时扩展的最大节点级别。-1 表示扩展所有的级别。

# extraJs

类型: string[],默认值是 none

作用: JavaScript URL 列表。这对于添加更多功能(例如 Katex 插件)很有用。

# extraCss

类型: string[],默认值是 none

作用: CSS URL 列表。这对于添加更多功能(例如 Katex 插件)很有用。

# zoom

类型: boolean,默认值是 true

作用: 是否允许缩放标记图

# pan

类型: boolean,默认值是 true

作用: 是否允许平移标记图

# htmlParser

类型: object

作用: 该选项将传递给内部的 HTML 解析器。例如可以覆盖要显示的元素的默认选择器。

# 官网示例

# 在 React 中的用法

import React, { useState, useRef, useEffect } from 'react';
import { Transformer } from 'markmap-lib';
import { Markmap } from 'markmap-view';
import { Toolbar } from 'markmap-toolbar';
import 'markmap-toolbar/dist/style.css';

const transformer = new Transformer();
const initValue = `# markmap

- beautiful
- useful
- easy
- interactive
`;

function renderToolbar(mm: Markmap, wrapper: HTMLElement) {
  while (wrapper?.firstChild) wrapper.firstChild.remove();
  if (mm && wrapper) {
    const toolbar = new Toolbar();
    toolbar.attach(mm);
    // Register custom buttons
    toolbar.register({
      id: 'alert',
      title: 'Click to show an alert',
      content: 'Alert',
      onClick: () => alert('You made it!'),
    });
    toolbar.setItems([...Toolbar.defaultItems, 'alert']);
    wrapper.append(toolbar.render());
  }
}

export default function MarkmapHooks() {
  const [value, setValue] = useState(initValue);
  // Ref for SVG element
  const refSvg = useRef<SVGSVGElement>();
  // Ref for markmap object
  const refMm = useRef<Markmap>();
  // Ref for toolbar wrapper
  const refToolbar = useRef<HTMLDivElement>();

  useEffect(() => {
    // Create markmap and save to refMm
    const mm = Markmap.create(refSvg.current);
    refMm.current = mm;
    renderToolbar(refMm.current, refToolbar.current);
  }, [refSvg.current]);

  useEffect(() => {
    // Update data for markmap once value is changed
    const mm = refMm.current;
    if (!mm) return;
    const { root } = transformer.transform(value);
    mm.setData(root);
    mm.fit();
  }, [refMm.current, value]);

  const handleChange = (e) => {
    setValue(e.target.value);
  };

  return (
    <React.Fragment>
      <div className="flex-1">
        <textarea
          className="w-full h-full border border-gray-400"
          value={value}
          onChange={handleChange}
        />
      </div>
      <svg className="flex-1" ref={refSvg} />
      <div className="absolute bottom-1 right-1" ref={refToolbar}></div>
    </React.Fragment>
  );
}

得到的结果就是这个,其中 Alert 就是代码里添加的扩展功能。 An Image