AI 应用前端开发技能库 - 思维导图生成

288 阅读4分钟

使用模型分析文章并生成大纲、根据目标拆解成思维导图,这些需求都离不开思维导图展示、编辑和 AIGC 的结合。这篇文章就来讲一下前端方面如何实现这个需求。首先来看一下效果:

动画.gif

需求也很简单,用户输入文本或者文件,模型根据输入生成对应的思维导图。这个做之前很多人都觉得会很难,其实不然。wanglin2/mind-map: 一个还算强大的Web思维导图。 直接当一个纯粹的调包侠就完事了。

具体实现

模型生成思维导图时,一般都是会返回一个 markdown,其中按照不同级别的标题来区分节点。例如上图的思维导图就是如下格式的 markdown:

# 黑神话悟空

## 游戏背景

### 以《西游记》为基础

### 融合中国传统文化元素

## 开发团队

### Game Science

### 经验丰富的开发者

## 发行信息

### 预计发行日期

### 支持多平台

## 玩家期待

### 高度自由的探索

### 深入的剧情体验

## 市场反响

### 备受期待的动作角色扮演游戏

### 吸引全球玩家关注

而 simple-mind-map(就是上面提到的 wanglin2/mind-map)就恰好支持 markdown 格式的导入,所以我们可以直接封装好一个组件,接受一个字符串类型的 value 然后将其渲染成思维导图。完整组件代码在文末,需要自取。

1、依赖导入

除了本体之外需要三个插件:markdown 解析器、导出插件、xmind 导出插件。然后简单注册一下就行:

import MindMap from 'simple-mind-map';
import markdown from 'simple-mind-map/src/parse/markdown';
import Export from 'simple-mind-map/src/plugins/Export';
import ExportXMind from 'simple-mind-map/src/plugins/ExportXMind';

MindMap.usePlugin(Export);
MindMap.usePlugin(ExportXMind);

2、MindMap 初始化

const mindMapRef = useRef<MindMap | null>(null);
const [activeNodes, setActiveNodes] = useState<any[]>([]);

useEffect(() => {
  const mindMap = new MindMap({
    el: document.getElementById('mindMapContainer'),
    mousewheelAction: 'zoom',
    themeConfig: {
      lineStyle: 'curve',
      lineWidth: 2,
      rootLineKeepSameInCurve: true,
      rootLineStartPositionKeepSameInCurve: true,
    },
    data: { data: {}, children: [] },
  } as any);

  mindMap.on('node_active', (node, activeNodeList) => {
    setActiveNodes(activeNodeList);
  });

  mindMapRef.current = mindMap;
  updateMdContent(props.value);

  return () => mindMap.destroy();
}, []);

初始化的时候记得绑定事件回调,因为这个组件是没有外围 UI 的,也就是说那种工具栏需要我们自己来实现,所以要通过 node_active 事件通知 react 要渲染对应的 UI 了。

其中 updateMdContent 就是调下解析器:

const updateMdContent = async (mdContent: string) => {
  const data = await markdown.transformMarkdownTo(mdContent);
  mindMapRef.current.setData(data);
};

3、更新思维导图

当 props.value 更新的时候我们需要用新的 value 重新触发一次渲染:

import { useDebounceEffect } from 'ahooks';

useDebounceEffect(
  () => {
    if (!mindMapRef.current) return;
    updateMdContent(props.value);
  },
  [props.value],
  { wait: 500 },
);

这里注意要加一个防抖,我本地测试下来发现过于频繁的更新会导致渲染异常,就像下面这样:

image.png

是的你没有眼花,就是渲染了两边叠在一起了。

4、节点编辑操作

想要增删节点也是直接调 api 即可:

// 增加同级
mindMapRef.current?.execCommand('INSERT_NODE')
// 增加子级
mindMapRef.current?.execCommand('INSERT_CHILD_NODE')
// 删除节点
mindMapRef.current?.execCommand('REMOVE_NODE')

完整代码

下面这个是基于 react + antd,能直接拿来使用的组件。注意里边还包含了三种导出,因为也很简单就不再重复讲了。

import {
  DeleteOutlined,
  DownloadOutlined,
  PartitionOutlined,
  PlusOutlined,
} from '@ant-design/icons';
import { useDebounceEffect } from 'ahooks';
import { Button, Dropdown, Space } from 'antd';
import { useRef, useEffect, FC, useState } from 'react';
import MindMap from 'simple-mind-map';
import markdown from 'simple-mind-map/src/parse/markdown';
import Export from 'simple-mind-map/src/plugins/Export';
import ExportXMind from 'simple-mind-map/src/plugins/ExportXMind';

MindMap.usePlugin(Export);
MindMap.usePlugin(ExportXMind);

interface SimpleMindMapRendererProps {
  value: string;
}

export function downloadDataUrl(url: string, fileName: string) {
  const link = document.createElement('a');
  link.href = url;
  link.download = fileName;

  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);

  URL.revokeObjectURL(url);
}

export const SimpleMindMapRenderer: FC<SimpleMindMapRendererProps> = (props) => {
  const mindMapRef = useRef<MindMap | null>(null);
  const [activeNodes, setActiveNodes] = useState<any[]>([]);

  const updateMdContent = async (mdContent: string) => {
    const data = await markdown.transformMarkdownTo(mdContent);
    mindMapRef.current.setData(data);
  };

  useEffect(() => {
    const mindMap = new MindMap({
      el: document.getElementById('mindMapContainer'),
      mousewheelAction: 'zoom',
      themeConfig: {
        lineStyle: 'curve',
        lineWidth: 2,
        rootLineKeepSameInCurve: true,
        rootLineStartPositionKeepSameInCurve: true,
      },
      data: {
        data: {},
        children: [],
      },
    } as any);

    mindMap.on('node_active', (node, activeNodeList) => {
      setActiveNodes(activeNodeList);
    });

    // mindMap.setMode('readonly');

    mindMapRef.current = mindMap;
    updateMdContent(props.value);

    return () => {
      mindMap.destroy();
    };
  }, []);

  useDebounceEffect(
    () => {
      if (!mindMapRef.current) return;
      updateMdContent(props.value);
    },
    [props.value],
    { wait: 500 },
  );

  const exportBtns = [
    {
      key: 'exportPng',
      label: '导出为 PNG',
      onClick: async () => {
        const url = await (mindMapRef.current as any).doExport.png('思维导图');
        downloadDataUrl(url, `思维导图.png`);
      },
    },
    {
      key: 'exportMd',
      label: '导出为 Markdown',
      onClick: async () => {
        const url = await (mindMapRef.current as any).doExport.md();
        downloadDataUrl(url, `思维导图.md`);
      },
    },
    {
      key: 'exportXMind',
      label: '导出为 XMind',
      onClick: async () => {
        const url = await (mindMapRef.current as any).doExport.xmind('思维导图');
        downloadDataUrl(url, `思维导图.xmind`);
      },
    },
  ];

  return (
    <>
      <div className='absolute top-2 left-2'>
        <Space>
          <Dropdown menu={{ items: exportBtns }} placement='bottom'>
            <Button size='small' icon={<DownloadOutlined />}>
              导出
            </Button>
          </Dropdown>
          {activeNodes.length > 0 && (
            <>
              <Button
                size='small'
                icon={<PlusOutlined />}
                onClick={() => mindMapRef.current?.execCommand('INSERT_NODE')}
              >
                新增同级
              </Button>
              <Button
                size='small'
                icon={<PartitionOutlined />}
                onClick={() => mindMapRef.current?.execCommand('INSERT_CHILD_NODE')}
              >
                新增子级
              </Button>
              <Button
                size='small'
                danger
                icon={<DeleteOutlined />}
                onClick={() => mindMapRef.current?.execCommand('REMOVE_NODE')}
              >
                删除节点
              </Button>
            </>
          )}
        </Space>
      </div>
      <div id='mindMapContainer' className='h-full w-full'></div>
    </>
  );
};

至此功能的核心实现就基本完成了,如果你对前端 AI 领域的其他开发技能感兴趣,可以来看一下我写的相关系列文章 AI 应用前端开发技能库