Skip to content

如何渲染一个大纲

思维导图本质就是一颗树,所以你可以使用树组件来完成大纲的显示。

可以监听data_change事件来获取当前最新的思维导图数据:

js
mindMap.on('data_change', (data) => {
    // data数据是不带节点对象的纯数据
    // 如果你需要操作节点对象,可以使用mindMap.renderer.renderTree
    console.log(data, mindMap.renderer.renderTree)
})

通常点击了大纲的某个节点,会将画布定位到该节点并激活该节点,这可以这么做:

js
const node = data._node
mindMap.renderer.moveNodeToCenter(node)
node.active()

// 在v0.6.7+版本可以这么做:
mindMap.execCommand('GO_TARGET_NODE', node)// 或者传节点的uid

当在大纲树上编辑了某个节点的内容,需要同步到思维导图树上:

js
data._node.setText('xxx')

要插入兄弟节点或子节点可以这么操作:

js
mindMap.execCommand('INSERT_NODE', false)
mindMap.execCommand('INSERT_CHILD_NODE', false)

进阶

要实现一个功能完善的大纲并不容易,下面介绍一下包含定位、编辑、拖拽、删除、单独编辑功能的大纲实现。

ElementUI Tree组件为例。

实现监听data_change事件来刷新树数据:

js
import { nodeRichTextToTextWithWrap } from 'simple-mind-map/src/utils'

this.mindMap.on('data_change', () => {
    this.refresh()
})

{
    refresh() {
        let data = mindMap.getData()// 获取思维导图树数据
        data.root = true // 标记根节点
        // 遍历树,添加一些属性
        let walk = root => {
            // 如果是富文本节点,那么调用nodeRichTextToTextWithWrap方法将<p><span></span><p>形式的节点富文本内容转换成\n换行的文本
            const text = (root.data.richText
                ? nodeRichTextToTextWithWrap(root.data.text)
                : root.data.text
            ).replaceAll(/\n/g, '<br>')
            root.textCache = text // 保存一份修改前的数据,用于对比是否修改了
            root.label = text// 用于树组件渲染
            root.uid = root.data.uid// 用于树组件渲染
            if (root.children && root.children.length > 0) {
                root.children.forEach(item => {
                    walk(item)
                })
            }
        }
        walk(data)
        this.data = [data]// 赋值给树组件
    }
}

模板如下:

html
<el-tree
    ref="tree"
    node-key="uid"
    draggable
    default-expand-all
    :data="data"
    :highlight-current="true"
    :expand-on-click-node="false"
    :allow-drag="checkAllowDrag"
    @node-drop="onNodeDrop"
    @current-change="onCurrentChange"
    @mouseenter.native="isInTreArea = true"
    @mouseleave.native="isInTreArea = false"
>
    <span
        class="customNode"
        slot-scope="{ node, data }"
        :data-id="data.uid"
        @click="onClick(data)"
    >
        <span
            class="nodeEdit"
            contenteditable="true"
            :key="getKey()"
            @keydown.stop="onNodeInputKeydown($event, node)"
            @keyup.stop
            @blur="onBlur($event, node)"
            @paste="onPaste($event, node)"
            v-html="node.label"
        ></span>
    </span>
</el-tree>

定位节点

给节点绑定了一个click事件用于在画布内定位点击的节点,可以调用思维导图的相关方法实现:

js
// 激活当前节点且移动当前节点到画布中间
onClick(data) {
    // 根据uid知道思维导图节点对象
    const targetNode = this.mindMap.renderer.findNodeByUid(data.uid)
    // 如果当前已经是激活状态,那么上面都不做
    if (targetNode && targetNode.nodeData.data.isActive) return
    // 思维导图节点激活时默认会聚焦到内部创建的一个隐藏输入框中,`stopFocusOnNodeActive`方法是用于关闭这个特性,因为我们想把焦点留在大纲的输入框中
    this.mindMap.renderer.textEdit.stopFocusOnNodeActive()
    // 定位到目标节点
    this.mindMap.execCommand('GO_TARGET_NODE', data.uid, () => {
        // 定位完成后再开启前面关闭的特性
        this.mindMap.renderer.textEdit.openFocusOnNodeActive()
    })
}

编辑

我们通过自定义树节点内容渲染了一个contenteditable=true的标签用于输入文本,然后在blur事件中修改节点文本:

js
import { textToNodeRichTextWithWrap } from 'simple-mind-map/src/utils'

// 失去焦点更新节点文本
onBlur(e, node) {
    // 节点数据没有修改那么什么也不用做
    if (node.data.textCache === e.target.innerHTML) {
        return
    }
    // 根据是否是富文本模式获取不同的文本数据
    const richText = node.data.data.richText
    const text = richText ? e.target.innerHTML : e.target.innerText
    const targetNode = this.mindMap.renderer.findNodeByUid(node.data.uid)
    if (!targetNode) return
    if (richText) {
        // 如果是富文本节点,那么需要先调用textToNodeRichTextWithWrap方法将<br>换行的文本转换成<p><span></span><p>形式的节点富文本内容
        // 第二个参数代表设置的是富文本内容
        // 第三个参数指定要重置富文本节点的样式
        targetNode.setText(textToNodeRichTextWithWrap(text), true, true)
    } else {
        targetNode.setText(text)
    }
}

拖拽

设置了draggable属性即可开启拖拽,首先根节点是不允许拖拽的,所以通过allow-drag属性传入一个判断方法:

js
// 根节点不允许拖拽
checkAllowDrag(node) {
    return !node.data.root
}

然后监听拖拽完成事件node-drop来实现画布内节点的调整:

js
// 拖拽结束事件
onNodeDrop(data, target, position) {
    // 被拖拽的节点
    const node = this.mindMap.renderer.findNodeByUid(data.data.uid)
    // 拖拽到的目标节点
    const targetNode = this.mindMap.renderer.findNodeByUid(target.data.uid)
    if (!node || !targetNode) {
        return
    }
    // 根据不同拖拽的情况调用不同的方法
    switch (position) {
        case 'before':
            this.mindMap.execCommand('INSERT_BEFORE', node, targetNode)
            break
        case 'after':
            this.mindMap.execCommand('INSERT_AFTER', node, targetNode)
            break
        case 'inner':
            this.mindMap.execCommand('MOVE_NODE_TO', node, targetNode)
            break
        default:
            break
    }
}

删除节点

首先通过树组件的current-change事件来保存当前高亮的树节点:

js
// 当前选中的树节点变化事件
onCurrentChange(data) {
    this.currentData = data
}

然后通过监听keydown事件来完成删除节点的操作:

js
window.addEventListener('keydown', this.onKeyDown)

// 删除节点
onKeyDown(e) {
    if ([46, 8].includes(e.keyCode) && this.currentData) {
        e.stopPropagation()
        // 处理当前正在编辑节点内容时删除的情况
        this.mindMap.renderer.textEdit.hideEditTextBox()
        const node = this.mindMap.renderer.findNodeByUid(this.currentData.uid)
        if (node && !node.isRoot) {
            // 首先从树里删除
            this.$refs.tree.remove(this.currentData)
            // 然后从画布里删除
            this.mindMap.execCommand('REMOVE_NODE', [node])
        }
    }
}

创建新节点

通过监听节点内容编辑框的keydown事件来完成添加新节点的操作:

js
import { createUid } from 'simple-mind-map/src/utils'

// 节点输入区域按键事件
onNodeInputKeydown(e) {
    // 回车键添加同级节点
    if (e.keyCode === 13 && !e.shiftKey) {
        e.preventDefault()
        this.insertNode()
    }
    // tab键添加子节点
    if (e.keyCode === 9) {
        e.preventDefault()
        this.insertChildNode()
    }
}

// 插入兄弟节点
insertNode() {
    this.mindMap.execCommand('INSERT_NODE', false, [], {
        uid: createUid()
    })
}

// 插入下级节点
insertChildNode() {
    this.mindMap.execCommand('INSERT_CHILD_NODE', false, [], {
        uid: createUid()
    })
}

拦截输入框的粘贴操作

为什么要拦截输入框的粘贴操作,因为用户可能粘贴的是富文本内容,也就是带html标签的,但是一般我们都不希望用户粘贴这种内容,只允许粘贴纯文本,所以我们要拦截粘贴事件,处理一下用户粘贴的内容:

js
import { getTextFromHtml } from 'simple-mind-map/src/utils'

// 拦截粘贴事件
onPaste(e) {
    e.preventDefault()
    const selection = window.getSelection()
    if (!selection.rangeCount) return
    selection.deleteFromDocument()// 删除当前选区,也就是如果当前用户在输入框中选择了一些文本,会被删除
    // 从剪贴板里取出文本数据
    let text = e.clipboardData.getData('text')
    // 调用库提供的getTextFromHtml方法去除格式
    text = getTextFromHtml(text)
    // 去除换行
    text = text.replaceAll(/\n/g, '')
    // 创建文本节点添加到当前选区
    const node = document.createTextNode(text)
    selection.getRangeAt(0).insertNode(node)
    selection.collapseToEnd()
}

到这里基本功能就都完成了,是不是觉得挺简单的?核心原理和操作确实很简单,麻烦的是各种情况和冲突的处理,比如焦点的冲突、快捷键的冲突、操作的时间顺序等等,所以务必先阅读一下完整的源码Outline.vue

MIT License.