【弃案】在博客中插入思维导图

首先检索了常见的可交互思维导图功能:

  • 可放大、缩小
  • 点击节点可折叠
  • 支持多种格式的数据导入(如 Markdown、JSON……)

从这些中选取了两个较为不错的思维导图选择:

前者基于 d3.js 渲染,支持右向拓展的思维导图。色彩丰富,支持 Markdown 输入,社区较为活跃。
后者则是百度脑图的引擎,相比较之下可能不太好看,但是功能强大,使用百度内部的 kity 渲染,但官方已弃坑。

博客渲染思路

无论是前端 Markdown 渲染还是后端 Markdown 渲染,原理上都只是将渲染内容原封不动渲染为 HTML(使用特定标签标记),而后页面前端检索所有该标签,并重新渲染该标签内容。

以前端 Markdown 渲染使用的 markdown-it 为例,只需要实现一个针对代码块的监听插件即可,它会将对应的内容渲染至<div class="mindmap-svg">。(在这里使用列表来支持多种触发标签,为了处理后续可能存在的多种输入格式(如输入 Markdown 渲染思维导图或直接输入 JSON 渲染思维导图)

const mindmapPlugin = (md) => {
  const temp = md.renderer.rules.fence.bind(md.renderer.rules);

  md.renderer.rules.fence = (tokens, idx, options, env, slf) => {
    const token = tokens[idx];
    const { ids = ['mindmap'] } = options;

    if (ids.indexOf(token.info) !== -1) {
      try {
        const data = token.content.trim();
        return `<div class="mindmap-svg" type="${token.info}">${data}</div>`;
      } catch (ex) {
        return `<pre>${ex}</pre>`;
      }
    }

    return temp(tokens, idx, options, env, slf);
  };
};

export { mindmapPlugin };

接下来只需要在渲染页面的时候,检索所有的mindmap-svg类即可

resetMindMap() {
  const mindmaps = document.getElementsByClassName('mindmap-svg');
  for (var i = 0; i < mindmaps.length; ++i) {
    try {
      const mindmap = mindmaps[i];
      if (!mindmap.getAttribute('rendered')) {
        const text = mindmap.innerHTML;
        console.log(mindmap);
        console.log(text);
        mindmap.setAttribute('rendered', '1');
        ReactDOM.render(<MindMap markdown={text} width="100%" height="50vh" />, mindmap);
      }
    } catch (e) {
      console.error(e);
    }
  }
}

markmap 组件

官方推荐的是前端引入 d3.js 和 markmap-view.js,不过在这里直接从依赖里引入也只是让包稍微大了一点而已。同时解决了可能存在的引入顺序问题。(如果判断是否存在思维导图标签再引入 d3.js,可能会存在引入问题)

import React from 'react';
import Head from 'next/head';

import { ComponentProps } from '@/utils/component';

import { transform } from 'markmap-lib';
import { Markmap } from 'markmap-view';

export declare type MindMapProps = ComponentProps<{
  markdown?: string;
  width?: React.ReactText;
  height?: React.ReactText;
}>;

export function MarkMap(props: MindMapProps) {
  const { markdown = '', ...restProps } = props;
  const ref = React.useRef<SVGSVGElement>();
  React.useEffect(() => {
    try {
      const { root, features } = transform(markdown.trim());
      var el = Markmap.create(ref.current, { autoFit: false }, root);
    } catch (e) {
      console.error(e);
    }
    return () => {
      el.styleNode.interrupt();
      el.g.interrupt();
      el.svg.interrupt();
    };
  }, [markdown]);
  return <svg ref={ref} {...restProps}></svg>;
}

整体思路就是渲染出一个<svg>标签,在渲染结束后初始化 Markmap 对象,并绑定上述的标签。在组件销毁时,对内部的标签进行处理。

尽管看上去没有问题,但是在销毁时,d3.js 本身的动画等待并未结束,因此可能会发生组件已销毁,但是动画还在持续,导致找不到对应的组件的错误。尽管有人提到可以使用.interrupt()解决,但是测试似乎仍然不太有效(不过也有可能是函数组件的问题,如果换成对象组件,可能会有更为严格的组件生命周期)
相关讨论见 d3/d3-timer#32 Saving handle to call cancelAnimationFrame is required. #32

整体来说,除去上述可能会报错的问题外,这个组件体验还是很不错的,如果后续有精力可以尝试进一步想办法解决。

kityminder

作为百度的良心产品,百度脑图支持的功能可以说是非常不错了(相对于其他的前端思维导图渲染)。不过默认不支持滑轮缩放,因此需要自己去实现(这里自己监听了下滚轮操作,效果勉强能用)

由于这时一个上古项目,而且本身是要求浏览器引入的,因此导入的 NodeJS 包仍然需要从window上获取对应的变量。剩下的操作与之前的类似。只是不太确定为什么会存在ref.current=undefined的情况。

export function KityMinder(props: MindMapProps) {
  const { markdown = '', ...restProps } = props;
  const ref = React.useRef<HTMLDivElement>();

  const minder = React.useMemo(() => {
    var kityminder = (window as any).kityminder;
    if (!kityminder) {
      require('kity');
      require('kityminder-core');
      kityminder = (window as any).kityminder;
    }
    if (!!ref.current) {
      ref.current.innerHTML = '';
      var minder = new kityminder.Minder({ renderTo: ref.current });
    } else {
      var minder = new kityminder.Minder();
    }
    return minder;
  }, []);
  React.useEffect(() => {
    minder.renderTo(ref.current);
  }, [ref]);
  React.useEffect(() => {
    try {
      minder.importData('markdown', markdown);
      minder.disable();
      minder.execCommand('hand');
      minder.on('mousewheel', (e) => {
        const minder = e.minder;
        if (e.originEvent.wheelDelta > 0) {
          minder.zoom(e.minder.getPaper().getViewPort().zoom * 100 + 5);
        } else {
          minder.zoom(Math.max(1, e.minder.getPaper().getViewPort().zoom * 100 - 5));
        }
        e.preventDefault();
        return false;
      });
      (window as any).mm = minder;
    } catch (e) {
      console.error(e);
    }
  }, [minder, markdown]);
  return <div ref={ref} {...restProps}></div>;
}

export default KityMinder;

这个组件实现下来,起码没有奇怪的报错,但是由于其本身并没有实现放大缩小、触摸移动,可能需要较多的工作量处理这些东西。

总结

当然,最终导致我放弃这两个的原因是,他们都会减慢渲染速度。由于 JS 本身单线程,他们的渲染耗时将会直接导致编辑框打不进去字。或许是时候把分块渲染提入日程,不过暂时没有较好的实现思路。