vue2 手寫思維導圖編輯器,支持圖片和節點拖拽(2)
彈框模塊
DigitalXmindDialog.vue
<template>
<el-dialog
:title="title"
width="1200px"
class="auth-dialog"
top="5%"
:append-to-body="true"
:lock-scroll="false"
:close-on-click-modal="false"
:visible.sync="visible"
:closed="hide"
>
<VueXmind
v-if="visible"
ref="vueXmindDialogRef"
:nodeData="xmindData.tree"
:styleData="xmindData.theme"
></VueXmind>
<div slot="footer" class="center-dialog-footer">
<el-button class="btn btn-gray" @click="visible = false">取消</el-button>
<el-button class="btn btn-shadow-pay" @click="onConfirm">確認</el-button>
</div>
</el-dialog>
</template>
<script>
import VueXmind from '@/components/VueXmind/index'
export default {
components: { VueXmind },
props: {
title: {
type: String,
default: '在線導圖編輯-創建',
},
},
data() {
return {
visible: false,
currenNode: null,
currentId: '',
xmindData: {},
saveLoading: false,
}
},
methods: {
async onConfirm() {
if (this.$refs.vueXmindDialogRef && !this.saveLoading) {
try {
this.saveLoading = true
let data = await this.$refs.vueXmindDialogRef.saveXmindData()
// 1表格 2圖片 3思維導圖
let params = {
bookExamModuleId: this.currenNode.id,
analysisType: 3,
analysisDetail: encodeURIComponent(JSON.stringify(data.data)),
picture: data.imgUrl,
}
if (this.currentId) {
params.id = this.currentId
}
this.$emit('updateNodeContent', params)
this.saveLoading = false
} catch (e) {}
this.hide()
} else {
console.log('在處理中')
}
},
hide() {
this.visible = false
},
show(node, snode) {
this.currenNode = node
this.saveLoading = false
// console.log(node, snode)
if (snode) {
this.currentId = snode.id
try {
this.xmindData = JSON.parse(
decodeURIComponent(snode.analysisDetail),
)
this.visible = true
} catch (e) {
console.log(e)
this.xmindData = {}
this.visible = true
}
} else {
this.xmindData = {}
this.currentId = ''
this.visible = true
}
},
},
}
</script>
<style scoped lang="scss"></style>
VueXmind/index.vue
<template>
<div>
<div class="cp-xmind-controller">
<div class="control-left">
<span @click="onClickAddMainNode">添加同級節點</span>
<span @click="onClickAddNode">添加子級節點</span>
<span @click="deleteSelectNode" title="快捷鍵 Delete">刪除節點</span>
<span @click="openLatexEdit">插入/編輯公式</span>
</div>
<div class="control-right">
<span @click="onSetSceneTheme">主題設置 (配色選擇)</span>
<span @click="reSizeBox">排版優化</span>
<!-- <span @click="savePhoto">保存圖片</span> -->
</div>
</div>
<div class="xmind-node-panel-segment">
<div class="xmind-node-box">
<div
v-if="treeData"
class="xmind-node-content"
ref="xmindNodeBoxRef"
:style="treeBoxStyle"
@dragover="onGragMove"
@dblclick="onSelectlayer"
>
<XmindSvgLine
:node="treeData"
:dwidth="currentTheme.dwidth"
:canvasSize="canvasSize"
:lineStyle="currentTheme.lineStyle"
:drogDate="drogDate"
:startDrag="startDrag"
:dragNodeId="dragNodeId"
></XmindSvgLine>
<XmindNode
:node="treeData"
ref="xmindsvgRef"
:dwidth="currentTheme.dwidth"
:boxStyle="currentTheme.boxStyle"
:boxColorStyle="currentTheme.boxColorStyle"
:currentId="currentId"
:dragNodeId="dragNodeId"
@updateNodeEnd="updateNodeEnd"
@selectNodeId="changeSelectNode"
></XmindNode>
</div>
</div>
<div class="xmind-node-attribute">
<NodeAttributes
:node="currentNode"
:currentId="currentId"
@selectTheme="onSelectTheme"
@updateAttribute="onUpdateAttribute"
></NodeAttributes>
</div>
</div>
<!-- <img v-if="imgUrl" :src="imgUrl" /> -->
<latex-edit-dialog
ref="latexEditDialogRef"
@updateNodeContent="onUpdateNodeContent"
></latex-edit-dialog>
</div>
</template>
<script>
import { API } from '@/api/config'
import XmindNode from './XmindNode'
import XmindSvgLine from './XmindSvgLine'
import NodeAttributes from './NodeAttributes/index'
import LatexEditDialog from './components/latex-edit-dialog'
import XmindStyleMixins from './mixins/XmindStyleMixins'
import XmindDateSaveMixins from './mixins/XmindDateSaveMixins'
import KeyboardShortcutMixins from './mixins/KeyboardShortcutMixins'
import {
executeXmindNodePosition,
CalculateDragUtil,
selectNodeByTreeNode,
} from './util'
import html2canvas from 'html2canvas'
export default {
components: { XmindNode, XmindSvgLine, NodeAttributes, LatexEditDialog },
mixins: [XmindStyleMixins, XmindDateSaveMixins, KeyboardShortcutMixins],
watch: {
treeData() {
if (this.treeData) {
if (this.currentId) {
this.currentNode = selectNodeByTreeNode(
this.treeData,
this.currentId,
)
}
}
},
},
props: {
nodeData: {
type: Object,
},
styleData: {
type: Object,
},
},
data() {
return {
imgUrl: '',
DemoData: {
data: '中心主題',
w: 94,
h: 42,
children: [
{ w: 70, h: 40, data: '分支主題', children: [] },
{ w: 70, h: 40, data: '分支主題', children: [] },
],
},
// 防抖 更新
updateThrottle: null,
}
},
destroyed() {
this.updateThrottle.cancel()
document.ondragend = null
document.ondragstart = null
},
mounted() {
this.updateThrottle = _.throttle(this.calculateDragExecute, 200)
// 監聽 鼠標松開的時候
document.ondragend = (e) => {
this.onEndDragNode()
}
document.ondragstart = (ev) => {
if (ev.target.id) {
this.onStartDragNode(ev.target.id)
}
}
if (this.styleData) {
this.initStyleData(this.styleData)
}
if (this.nodeData) {
this.updateTreeList(this.nodeData)
} else {
this.updateTreeList(this.DemoData)
}
this.$nextTick(() => {
this.reSizeBox()
})
},
methods: {
savePhoto() {
html2canvas(this.$refs.xmindNodeBoxRef, { useCORS: true }).then(
(canvas) => {
let dataURL = canvas.toDataURL('image/png')
this.imgUrl = dataURL
if (this.imgUrl !== '') {
this.dialogTableVisible = true
}
},
)
},
getXmindPicture() {
return new Promise((resolve, reject) => {
html2canvas(this.$refs.xmindNodeBoxRef, { useCORS: true }).then(
(canvas) => {
let dataURL = canvas.toDataURL('image/png')
resolve(dataURL)
},
() => {
resolve('')
},
)
})
},
// 保存數據
async saveXmindData() {
this.currentId = ''
await this.cpsleep(10)
let imgUrl = await this.getXmindPicture()
let params = { base64File: imgUrl }
imgUrl = await this.apiPost(API.CONFIG_UPLOAD_BASE64FILE, params).then(
(res) => {
if (this.checkoutRes(res)) {
return res.data
} else {
return ''
}
},
() => {
return ''
},
)
// 上傳圖片 獲取 url
return {
imgUrl: imgUrl,
data: {
tree: this.treeData,
theme: this.currentTheme,
},
}
},
openLatexEdit(e) {
if (this.currentId) {
let cnode = selectNodeByTreeNode(this.treeData, this.currentId)
if (cnode) {
this.$refs.latexEditDialogRef.show(cnode.data)
}
} else {
this.showMessage('請選擇要修改的內容')
}
},
onGragMove(ev) {
// 獲取 應該加到那個 node 上面
let pos = { x: ev.layerX, y: ev.layerY, id: this.dragNodeId }
this.updateThrottle(pos)
ev.preventDefault()
},
calculateDragExecute(pos) {
let dropDate = CalculateDragUtil.calculateDragOverNode(
this.treeData,
pos,
)
if (dropDate) {
this.drogDate = dropDate
} else {
this.currentId = ''
this.drogDate = null
}
},
changeSelectNode(node) {
this.currentId = node.id
this.currentNode = node
},
onSelectlayer() {
this.currentId = ''
},
updateTreeList(data) {
// 渲染除 node 的位置
if (data) {
executeXmindNodePosition(
data,
this.currentTheme.dwidth,
this.currentTheme.dheight,
this.currentTheme.scaneBox,
)
this.treeData = data
}
},
updateNodeEnd() {
this.$nextTick(() => {
let datas = JSON.parse(JSON.stringify(this.treeData))
this.updateTreeList(datas)
})
},
reSizeBox() {
if (this.$refs.xmindsvgRef) {
this.$refs.xmindsvgRef.reSizeBox()
this.$nextTick(() => {
let datas = JSON.parse(JSON.stringify(this.treeData))
this.updateTreeList(datas)
})
}
},
},
}
</script>
<style scoped lang="scss">
.xmind-node-box {
height: 600px;
overflow: auto;
background-color: #e4e4e4;
}
.xmind-node-content {
background-color: #fff;
position: relative;
}
.cp-xmind-controller {
padding: 10px;
display: flex;
.control-left {
flex: 1;
}
.control-right {
flex: 1;
text-align: right;
}
span {
display: inline-block;
border: 1px solid $color-theme;
padding: 10px;
border-radius: 6px;
margin-left: 20px;
cursor: pointer;
user-select: none;
}
}
.xmind-node-panel-segment {
display: flex;
.xmind-node-box {
flex: 1;
}
.xmind-node-attribute {
width: 200px;
}
}
</style>
節點數據
XmindNode.vue
<template>
<span :style="{ opacity: dragNodeId === node.id ? 0.5 : 1 }">
<div
class="vue-xmind-node"
:class="{ topnode: currentId === node.id }"
:style="nodeBoxStyle"
:ref="'xmindbox' + node.id + 'ref'"
@click.stop="onSelectCurrent(node)"
>
<div class="vue-xmind-content">
<div
:id="node.id"
:draggable="node.level !== 1 && !contenteditable"
class="vue-xmind-name"
:class="{ 'vue-xmind-edit': contenteditable }"
:style="nodeBoxNameStyle"
:ref="'xmindboxname' + node.id + 'ref'"
:contenteditable="contenteditable"
@blur.stop="onBlurSaveDate"
@keydown="inputChecked"
v-html="node.data"
@dblclick.stop="openEditContent"
></div>
<div class="mouse-zoom-move" v-if="currentId === node.id">
<mouse-direction-move
id="x"
height="100%"
@translation="onTranslation"
@translationEnd="onTranslationEnd"
>
<div class="mouse-zoom-box"></div>
</mouse-direction-move>
</div>
</div>
</div>
<span
class="vue-xmind-children"
v-if="node.children && node.children.length"
>
<XmindNode
v-for="item in node.children"
:key="item.id"
:level="level + 1"
:ref="'xmind' + node.id"
:node="item"
:dwidth="dwidth"
:boxStyle="boxStyle"
:boxColorStyle="boxColorStyle"
:currentId="currentId"
:dragNodeId="dragNodeId"
@updateNodeEnd="updateNodeEnd"
@selectNodeId="onSelectCurrent"
></XmindNode>
</span>
</span>
</template>
<script>
import { getLevelNodeDwidth, getLevelListDate } from './util'
import MouseDirectionMove from './components/mouse-direction-move'
export default {
components: { MouseDirectionMove },
props: {
node: {
type: Object,
default: () => {
return {}
},
},
level: {
type: Number,
default: 1,
},
dragNodeId: String,
currentId: String,
dwidth: Array,
boxStyle: Object,
boxColorStyle: Object,
},
name: 'XmindNode',
watch: {
node() {
this.customWidth = this.node.ctw || 0
},
},
computed: {
lineWidth() {
return getLevelNodeDwidth(this.level, this.dwidth)
},
nodeBoxNameStyle() {
let padding = [this.boxStyle.ptb + 'px', this.boxStyle.plr + 'px'].join(
' ',
)
let level = this.node.level
let cwidth = 'auto'
let lineHeight = getLevelListDate(level, this.boxStyle.lineSize) + 'px'
let fontSize = getLevelListDate(level, this.boxStyle.fontSize) + 'px'
let fontColor = getLevelListDate(level, this.boxColorStyle.fontColor)
let borderColor = getLevelListDate(
level,
this.boxColorStyle.borderColor,
)
let backgroundColor = getLevelListDate(
level,
this.boxColorStyle.backgroundColor,
)
if (this.customWidth) {
cwidth = this.customWidth + 'px'
}
let borderRadius =
getLevelListDate(level, this.boxColorStyle.borderRadius) + 'px'
if (this.currentId === this.node.id) {
return {
padding: padding,
lineHeight: lineHeight,
fontSize: fontSize,
color: fontColor,
backgroundColor: backgroundColor,
borderColor: '#ff0000',
borderRadius: borderRadius,
width: cwidth,
}
} else {
return {
padding: padding,
lineHeight: lineHeight,
fontSize: fontSize,
color: fontColor,
backgroundColor: backgroundColor,
borderColor: borderColor || backgroundColor,
borderRadius: borderRadius,
width: cwidth,
}
}
},
nodeBoxStyle() {
return {
transform:
'translate3d(' + this.node.x + 'px,' + this.node.y + 'px,0)',
}
},
},
data() {
return {
contenteditable: false,
customWidth: 0,
}
},
created() {
this.customWidth = this.node.ctw || 0
},
methods: {
onTranslationEnd() {
this.node.ctw = this.customWidth
this.$nextTick(() => {
this.updateNodeBox()
this.updateNodeEnd()
})
},
onTranslation(t) {
if (!this.customWidth) {
this.customWidth = this.node.w
}
if (t.x + this.customWidth >= 40) {
this.customWidth += t.x
}
},
onSelectCurrent(data) {
this.$emit('selectNodeId', data)
},
inputChecked(e) {
// Backspace鍵8 F5鍵116 37~40方向箭頭 Del鍵46
// if (e.keyCode === 8) {
// return
// }
// if (e.keyCode === 13) {
// e.preventDefault()
// }
},
openEditContent(node) {
if (this.contenteditable) return
let content = this.$refs['xmindboxname' + this.node.id + 'ref']
this.contenteditable = true
this.$nextTick(() => {
if (content) {
if (content[0]) {
content[0].focus()
this.getInputSelection(content[0])
} else {
content.focus()
this.getInputSelection(content)
}
}
})
},
/**
* 獲取輸入的光標到字符串最后一位
* @param {obj} obj
*/
getInputSelection(obj) {
// 處理光標問題
if (window.getSelection) {
// ie11 10 9 ff safari
// obj.focus(); //解決ff不獲取焦點無法定位問題
let range = window.getSelection() // 創建range
range.selectAllChildren(obj) // range 選擇obj下所有子內容
range.collapseToEnd() // 光標移至最后
} else if (document.selection) {
// ie10 9 8 7 6 5
let range = document.selection.createRange() // 創建選擇對象
// var range = document.body.createTextRange();
range.moveToElementText(obj) // range定位到obj
range.collapse(false) // 光標移至最后
range.select()
}
},
onBlurSaveDate(e) {
this.contenteditable = false
this.node.data = e.target.innerHTML || '內容'
this.$nextTick(() => {
this.updateNodeBox()
this.updateNodeEnd()
})
},
updateNodeEnd() {
this.$emit('updateNodeEnd')
},
updateNodeBox() {
let nodeel = this.$refs['xmindbox' + this.node.id + 'ref']
if (nodeel) {
this.node.h = nodeel.offsetHeight
this.node.w = nodeel.offsetWidth
}
},
reSizeBox() {
if (this.node.children && this.node.children.length) {
let els = this.$refs['xmind' + this.node.id]
if (els) {
els.forEach((item) => {
if (item.reSizeBox) {
item.reSizeBox()
}
})
}
}
this.updateNodeBox()
},
},
}
</script>
<style scoped lang="scss">
.node-box-edit {
display: inline-block;
position: absolute;
top: 0;
left: 0;
z-index: 99;
}
.vue-xmind-node {
display: inline-block;
position: absolute;
top: 0;
left: 0;
&.topnode {
z-index: 999;
}
}
.vue-xmind-name {
border-radius: 6px;
user-select: none;
box-sizing: border-box;
word-break: break-word;
border: 2px solid #ffffff;
&.vue-xmind-edit {
box-shadow: 0px 0px 10px 0px rgba(42, 77, 138, 0.6);
}
/deep/ img {
-webkit-user-drag: none;
}
}
.mouse-zoom-move {
position: absolute;
top: 0;
bottom: 0;
right: 0px;
display: inline-block;
cursor: w-resize;
.mouse-zoom-box {
display: inline-block;
// background: #ff0000;
width: 10px;
height: 100%;
border-radius: 5px;
}
}
.vue-xmind-content {
display: inline-block;
position: relative;
}
</style>
XmindSvgLine.vue
svg 背景先邏輯
<template>
<svg :width="canvasSize.w + 'px'" :height="canvasSize.h + 'px'">
<g>
<path
v-for="(item, index) in pathList"
:key="index"
:d="item.path"
:stroke="item.stroke"
:stroke-width="item.strokeWidth"
fill="none"
/>
</g>
<g v-if="drogDate && startDrag">
<path
:d="drogLine.path"
stroke="$color-theme"
stroke-width="1"
fill="none"
/>
<rect
width="30"
height="15"
rx="5"
ry="5"
:x="drogRect.x"
:y="drogRect.y - 7"
style="fill: $color-theme"
/>
</g>
</svg>
</template>
<script>
import { executeXmindNodeLineDate, getSpaceNodePath } from './util'
export default {
props: {
node: {
type: Object,
default: () => {
return {}
},
},
lineStyle: Object,
dragNodeId: String,
canvasSize: Object,
drogDate: Object,
dwidth: Array,
startDrag: Boolean,
},
data() {
return {
pathList: [],
drogRect: { x: 0, y: 0 },
drogLine: {
path: '',
},
}
},
watch: {
node() {
this.updateSvgLine()
},
lineStyle() {
this.updateSvgLine()
},
drogDate(v) {
if (v && v.dpos) {
this.drogRect.x = v.dpos[0]
this.drogRect.y = v.dpos[1]
let line = {
tx: v.dpos[0],
ty: v.dpos[1],
x: v.dpos[2],
y: v.dpos[3],
}
this.drogLine.path = getSpaceNodePath(line, this.lineStyle.type)
}
},
},
mounted() {
this.updateSvgLine()
},
methods: {
updateSvgLine() {
// console.log('updateSvgLine', this.node)
if (this.node.cw) {
this.pathList = executeXmindNodeLineDate(this.node, this.lineStyle)
} else {
this.pathList = []
}
},
},
}
</script>
<style scoped lang="scss"></style>