diff --git a/index.html b/index.html index 61d2334f..8613d65d 100644 --- a/index.html +++ b/index.html @@ -1,7 +1,7 @@ 思绪思维导图
\ No newline at end of file + } \ No newline at end of file diff --git a/simple-mind-map/src/utils/index.js b/simple-mind-map/src/utils/index.js index 9c1745d0..90236598 100644 --- a/simple-mind-map/src/utils/index.js +++ b/simple-mind-map/src/utils/index.js @@ -541,7 +541,7 @@ export const getVisibleColorFromTheme = (themeConfig) => { } } -// 将

形式的节点富文本内容转换成
换行的文本 +// 将

形式的节点富文本内容转换成\n换行的文本 let nodeRichTextToTextWithWrapEl = null export const nodeRichTextToTextWithWrap = (html) => { if (!nodeRichTextToTextWithWrapEl) { diff --git a/web/src/pages/Doc/catalogList.js b/web/src/pages/Doc/catalogList.js index 640f7c8e..b14774f4 100644 --- a/web/src/pages/Doc/catalogList.js +++ b/web/src/pages/Doc/catalogList.js @@ -11,7 +11,7 @@ let langList = [ } ] let StartList = ['introduction', 'start', 'deploy', 'client', 'translate', 'changelog'] -let CourseList = new Array(21).fill(0).map((_, index) => { +let CourseList = new Array(22).fill(0).map((_, index) => { return 'course' + (index + 1) }) let APIList = [ diff --git a/web/src/pages/Doc/en/utils/index.md b/web/src/pages/Doc/en/utils/index.md index 94da9739..74135d4c 100644 --- a/web/src/pages/Doc/en/utils/index.md +++ b/web/src/pages/Doc/en/utils/index.md @@ -236,7 +236,7 @@ Determine whether a color is transparent. > v0.6.12+ -Convert the rich text content of nodes in the form of `

` into text wrapped in `
`. +Convert the rich text content of nodes in the form of `

` into text wrapped in `\n`. #### textToNodeRichTextWithWrap(html) diff --git a/web/src/pages/Doc/en/utils/index.vue b/web/src/pages/Doc/en/utils/index.vue index 52d8b6fd..0d3563b2 100644 --- a/web/src/pages/Doc/en/utils/index.vue +++ b/web/src/pages/Doc/en/utils/index.vue @@ -168,7 +168,7 @@ and copying the data of the data object, example:

v0.6.12+

-

Convert the rich text content of nodes in the form of <p><span></span><p> into text wrapped in <br>.

+

Convert the rich text content of nodes in the form of <p><span></span><p> into text wrapped in \n.

textToNodeRichTextWithWrap(html)

v0.6.12+

diff --git a/web/src/pages/Doc/routerList.js b/web/src/pages/Doc/routerList.js index 000b972e..578b9aa2 100644 --- a/web/src/pages/Doc/routerList.js +++ b/web/src/pages/Doc/routerList.js @@ -28,6 +28,7 @@ export default [ { path: 'course19', title: '插入和扩展节点图标' }, { path: 'course20', title: '如何自定义节点内容' }, { path: 'course21', title: '如何复制、剪切、粘贴' }, + { path: 'course22', title: '如何实现搜索、替换' }, { path: 'doExport', title: 'Export 插件' }, { path: 'drag', title: 'Drag插件' }, { path: 'introduction', title: '简介' }, diff --git a/web/src/pages/Doc/zh/course12/index.md b/web/src/pages/Doc/zh/course12/index.md index 3f245dcb..97a72afd 100644 --- a/web/src/pages/Doc/zh/course12/index.md +++ b/web/src/pages/Doc/zh/course12/index.md @@ -34,4 +34,269 @@ data._node.setText('xxx') ```js mindMap.execCommand('INSERT_NODE', false) mindMap.execCommand('INSERT_CHILD_NODE', false) -``` \ No newline at end of file +``` + +## 进阶 + +要实现一个功能完善的大纲并不容易,下面介绍一下包含定位、编辑、拖拽、删除、单独编辑功能的大纲实现。 + +以[ElementUI Tree组件](https://element.eleme.cn/#/zh-CN/component/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方法将

形式的节点富文本内容转换成\n换行的文本 + const text = (root.data.richText + ? nodeRichTextToTextWithWrap(root.data.text) + : root.data.text + ).replaceAll(/\n/g, '
') + 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 + + + + + +``` + +### 定位节点 + +给节点绑定了一个`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方法将
换行的文本转换成

形式的节点富文本内容 + // 第二个参数代表设置的是富文本内容 + // 第三个参数指定要重置富文本节点的样式 + 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 || window.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](https://github.com/wanglin2/mind-map/blob/main/web/src/pages/Edit/components/Outline.vue)。 \ No newline at end of file diff --git a/web/src/pages/Doc/zh/course12/index.vue b/web/src/pages/Doc/zh/course12/index.vue index 9025a6e1..e8b68667 100644 --- a/web/src/pages/Doc/zh/course12/index.vue +++ b/web/src/pages/Doc/zh/course12/index.vue @@ -24,6 +24,231 @@ mindMap.execCommand('GO_TARGET_NODE',

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

进阶

+

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

+

ElementUI Tree组件为例。

+

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

+
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]// 赋值给树组件
+    }
+}
+
+

模板如下:

+
<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事件用于在画布内定位点击的节点,可以调用思维导图的相关方法实现:

+
// 激活当前节点且移动当前节点到画布中间
+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事件中修改节点文本:

+
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属性传入一个判断方法:

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

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

+
// 拖拽结束事件
+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事件来保存当前高亮的树节点:

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

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

+
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事件来完成添加新节点的操作:

+
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标签的,但是一般我们都不希望用户粘贴这种内容,只允许粘贴纯文本,所以我们要拦截粘贴事件,处理一下用户粘贴的内容:

+
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 || window.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

diff --git a/web/src/pages/Doc/zh/course18/index.md b/web/src/pages/Doc/zh/course18/index.md index dd0f3981..8f89fe9a 100644 --- a/web/src/pages/Doc/zh/course18/index.md +++ b/web/src/pages/Doc/zh/course18/index.md @@ -1,5 +1,7 @@ # 如何持久化数据 +> 目前提供了一种新方式,可以参考[对接自己的存储服务](https://wanglin2.github.io/mind-map/#/doc/zh/deploy/%E5%AF%B9%E6%8E%A5%E8%87%AA%E5%B7%B1%E7%9A%84%E5%AD%98%E5%82%A8%E6%9C%8D%E5%8A%A1)。 + 在线`demo`的数据是存储在电脑本地的,也就是`localStorage`里,当然,你也可以存储到数据库中。 ## 保存数据 diff --git a/web/src/pages/Doc/zh/course18/index.vue b/web/src/pages/Doc/zh/course18/index.vue index 51eaeca0..63840324 100644 --- a/web/src/pages/Doc/zh/course18/index.vue +++ b/web/src/pages/Doc/zh/course18/index.vue @@ -1,6 +1,9 @@