console
class MarkdownParser {
compile(nodes) {
let matchArr
let html = ''
for (let i = 0, len = nodes.length; i < len; i++) {
let text = ''
let index = 0
const node = nodes[i]
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.innerHTML === '<br>') {
html += `<div></div>`
while (nodes[i + 1].nodeType === Node.ELEMENT_NODE && nodes[i + 1].innerHTML === '<br>') {
i++
}
continue
}
index = node.dataset.index
text = node.textContent.trim()
} else {
text = node.nodeValue.trim()
}
matchArr = text.match(/^#\s/)
|| text.match(/^##\s/)
|| text.match(/^###\s/)
|| text.match(/^####\s/)
|| text.match(/^#####\s/)
|| text.match(/^######\s/)
|| text.match(/^\*{3,}/)
|| text.match(/^>/)
|| text.match(/^\*\s/)
|| text.match(/^\d*\.\s/)
|| text.match(/^```/)
|| text.match(/^\|.*\|/)
if (matchArr) {
let temp = ''
const re1 = /^>/
const re2 = /^\*\s/
const re3 = /^\d*\.\s/
const re4 = /^```/
const re5 = /^\|.*\|/
switch(matchArr[0]) {
case '# ':
html += `<h1 data-index="${index}">` + this.format(text.substring(2)) + '</h1>'
break
case '## ':
html += `<h2 data-index="${index}">` + this.format(text.substring(3)) + '</h2>'
break
case '### ':
html += `<h3 data-index="${index}">` + this.format(text.substring(4)) + '</h3>'
break
case '#### ':
html += `<h4 data-index="${index}">` + this.format(text.substring(5)) + '</h4>'
break
case '##### ':
html += `<h5 data-index="${index}">` + this.format(text.substring(6)) + '</h5>'
break
case '###### ':
html += `<h6 data-index="${index}">` + this.format(text.substring(7)) + '</h6>'
break
case text.match(/^\*{3,}/) && text.match(/^\*{3,}/)[0]:
html += text.replace(/^\*{3,}/g, '<hr>')
break
case '>':
while (i < len && nodes[i].textContent.match(re1)) {
const str = nodes[i].textContent
temp += '<div>' + this.format(str.slice(1)) + '</div>'
i++
}
i--
html += `<blockquote data-index="${index}">` + temp + '</blockquote>'
break
case '* ':
while (i < len && nodes[i].textContent?.match(re2)) {
const str = nodes[i].textContent
temp += `<li data-index="${nodes[i]?.dataset?.index}">` + this.format(str.slice(2)) + '</li>'
i++
}
i--
html += `<ul data-index="${index}">` + temp + '</ul>'
break
case text.match(/^\d*\.\s/) && text.match(/^\d*\.\s/)[0]:
while (i < len && nodes[i].textContent?.match(re3)) {
const str = nodes[i].textContent
temp += `<li data-index="${nodes[i]?.dataset?.index}">` + this.format(str.replace(/^\d*\.\s/, '')) + '</li>'
i++
}
i--
html += `<ol data-index="${index}">` + temp + '</ol>'
break
case '```':
i++
while (i < len && !re4.test(nodes[i].textContent)) {
temp += `<div data-index="${nodes[i]?.dataset?.index}">` + escapeHTML(nodes[i].textContent) + '</div>'
i++
}
html += `<pre data-index="${index}"><code>` + temp + '</code></pre>'
break
case text.match(/^\|.*\|/) && text.match(/^\|.*\|/)[0]:
let thRe = /^\[th\]/
let arr, j, jlen
while (i < len && re5.test(nodes[i].textContent)) {
arr = nodes[i].textContent.split('|')
temp += `<tr data-index="${nodes[i]?.dataset?.index}">`
for (j = 1, jlen = arr.length - 1; j < jlen; j++) {
if (thRe.test(arr[1])) {
temp += '<th>' + arr[j] + '</th>'
} else {
temp += '<td>' + arr[j] + '</td>'
}
}
temp += '</tr>'
temp = temp.replace('[th]', '')
i++
}
html += '<table>' + temp + '</table>'
break
}
} else if (text) {
html += `<div data-index="${index}">` + this.format(text) + '</div>'
}
}
return html
}
format(str) {
str = str.replace(/\s/g, ' ')
const bold = str.match(/\*{2}[^*].*?\*{2}/g)
if (bold) {
for (let i = 0, len = bold.length; i < len; i++) {
str = str.replace(bold[i], '<b>' + bold[i].substring(2, bold[i].length - 2) + '</b>')
}
}
const italic = str.match(/\*[^*].*?\*/g)
if (italic) {
for (let i = 0, len = italic.length; i < len; i++) {
str = str.replace(italic[i], '<i>' + italic[i].substring(1, italic[i].length - 1) + '</i>')
}
}
const code = str.match(/`[^`]*`/g)
if (code) {
for (let i = 0, len = code.length; i < len; i++) {
str = str.replace(code[i], '<code>' + code[i].substring(1, code[i].length - 1) + '</code>')
}
}
const img = str.match(/!\[.*\]\(.*\)/g)
const re1 = /\(.*\)/
const re2 = /\[.*\]/
if (img) {
for (let i = 0, len = img.length; i < len; i++) {
const url = img[i].match(re1)[0]
const title = img[i].match(re2)[0]
str = str.replace(img[i], '<img src=' + url.substring(1, url.length - 1) + ' alt=' + title.substring(1, title.length -1) + '>')
}
}
const a = str.match(/\[.*?\]\(.*?\)/g)
if (a) {
for (let i = 0, len = a.length; i < len; i++) {
const url = a[i].match(re1)[0]
const title = a[i].match(re2)[0]
str = str.replace(a[i], '<a href=' + url.substring(1, url.length - 1) + '>' + title.substring(1, title.length -1) + '</a>')
}
}
return escapeHTML2(str)
}
}
function escapeHTML(html) {
return html.replace(/</g, '<').replace(/>/g, '>')
}
function escapeHTML2(html) {
return html.replace(/<(\/)?script>/g, '<$1script>')
}
function $(selector) {
return document.querySelector(selector)
}
function changeCursorPosition(node) {
const selection = window.getSelection()
selection.removeAllRanges()
const range = document.createRange()
range.selectNode(node)
selection.addRange(range)
selection.collapseToEnd()
}
function clearTextStyle(e) {
e.preventDefault()
let text
const clp = (e.originalEvent || e).clipboardData
if (clp === undefined || clp === null) {
text = window.clipboardData.getData('text') || ''
if (text !== '') {
if (window.getSelection) {
var newNode = document.createElement('span')
newNode.innerHTML = text
window.getSelection().getRangeAt(0).insertNode(newNode)
} else {
document.selection.createRange().pasteHTML(text)
}
}
} else {
text = clp.getData('text/plain') || ''
if (text !== '') {
document.execCommand('insertText', false, text)
}
}
}
function throttle(delay) {
let waitForCallFunc
let canCall = true
return function helper(callback, ...args) {
if (!canCall) {
if (callback) waitForCallFunc = callback
return
}
callback(...args)
canCall = false
setTimeout(() => {
canCall = true
if (waitForCallFunc) {
helper(waitForCallFunc, ...args)
waitForCallFunc = null
}
}, delay)
}
}
function debounce(delay) {
let timer
return function(callback, ...args) {
clearTimeout(timer)
timer = setTimeout(() => callback.call(null, ...args), delay)
}
}
function getHeightToTop(dom) {
let height = dom.offsetTop
let parent = dom.offsetParent
while (parent) {
height += parent.offsetTop
parent = parent.offsetParent
}
return height
}
function isInScreen(dom) {
const { top, bottom } = dom.getBoundingClientRect()
return bottom >= 0 && top < window.innerHeight
}
function percentOfdomInScreen(dom) {
const { height, bottom } = dom.getBoundingClientRect()
if (bottom <= 0) return 0
if (bottom >= height) return 1
return bottom / height
}
function canNodeCalculate(node) {
return (
node.innerHTML
&& node.innerHTML !== '<br>'
&& !node.textContent.startsWith('```')
&& isInScreen(node)
&& percentOfdomInScreen(node) >= 0
)
}
const editor = $('#editor')
const showDom = $('#show-content')
const markdown = new MarkdownParser()
showDom.innerHTML = markdown.compile(editor.children)
function onInput() {
let index = 0
const data = Array.from(editor.children)
data.forEach(item => {
delete item.dataset.index
if (item.tagName !== 'BR' && item.innerText.trim() !== '') {
if (!item.children.length || (item.children.length === 1 && item.children[0].tagName === 'BR')) {
item.dataset.index = index++
return
}
const frag = document.createDocumentFragment()
Array.from(item.childNodes).forEach(e => {
if (e.nodeType === Node.TEXT_NODE) {
const div = document.createElement('div')
div.textContent = e.nodeValue
item.replaceChild(div, e)
div.dataset.index = index++
frag.appendChild(div)
} else if (item.tagName !== 'BR') {
e.dataset?.index && delete e.dataset.index
e.dataset.index = index++
frag.appendChild(e)
}
})
editor.replaceChild(frag, item)
changeCursorPosition(editor.querySelector(`[data-index="${index - 1}"]`))
}
})
showDom.innerHTML = markdown.compile(editor.childNodes)
}
const debounceFn = debounce(100)
editor.oninput = () => {
debounceFn(onInput)
}
editor.onpaste = (e) => {
clearTextStyle(e)
}
const canScroll = {
editor: true,
showDom: true,
}
const debounceFn2 = debounce(100)
const throttleFn = throttle(50)
editor.onscroll = () => {
if (!canScroll.editor) return
canScroll.showDom = false
throttleFn(onScroll, editor, showDom)
debounceFn2(resumeScroll)
}
showDom.onscroll = () => {
if (!canScroll.showDom) return
canScroll.editor = false
throttleFn(onScroll, showDom, editor)
debounceFn(resumeScroll)
}
function resumeScroll() {
canScroll.editor = true
canScroll.showDom = true
}
function onScroll(scrollContainer, ShowContainer) {
const scrollHeight = ShowContainer.scrollHeight
if (scrollContainer.offsetHeight + scrollContainer.scrollTop >= scrollContainer.scrollHeight) {
ShowContainer.scrollTo({ top: scrollHeight - ShowContainer.clientHeight })
return
}
if (scrollContainer.scrollTop === 0) {
ShowContainer.scrollTo({ top: 0 })
return
}
const nodes = Array.from(scrollContainer.children)
for (const node of nodes) {
if (canNodeCalculate(node)) {
if (node.tagName === 'PRE' || node.tagName === 'TABLE') {
if (hasPreElementInScrollContainerScroll(node, ShowContainer)) return
continue
}
const index = node.dataset.index
const dom = ShowContainer.querySelector(`[data-index="${index}"]`)
if (!dom) continue
const percent = percentOfdomInScreen(node)
const heightToTop = getHeightToTop(dom)
const domNeedHideHeight = dom.offsetHeight * (1 - percent)
ShowContainer.scrollTo({ top: heightToTop + domNeedHideHeight })
break
}
}
}
function hasPreElementInScrollContainerScroll(preElement, ShowContainer) {
for (const node of preElement.children[0].children) {
if (isInScreen(node) && percentOfdomInScreen(node) >= 0) {
const index = node.dataset.index
const dom = ShowContainer.querySelector(`[data-index="${index}"]`)
if (!dom) continue
const percent = percentOfdomInScreen(node)
const heightToTop = getHeightToTop(dom)
const domNeedHideHeight = dom.offsetHeight * (1 - percent)
ShowContainer.scrollTo({ top: heightToTop + domNeedHideHeight })
return true
}
}
return false
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>markdown 编辑器双屏同步滚动 demo5</title>
<link rel="stylesheet" href="index.css">
</head>
<body>
<div id="editor" contenteditable class="text-container">
<div data-index="0">本文是可视化拖拽系列的第三篇,之前的两篇文章一共对 17 个功能点的技术原理进行了分析:</div><div data-index="1">1. 编辑器</div><div data-index="2">2. 自定义组件</div><div data-index="3">3. 拖拽</div><div data-index="4">4. 删除组件、调整图层层级</div><div data-index="5">5. 放大缩小</div><div data-index="6">6. 撤消、重做</div><div data-index="7">7. 组件属性设置</div><div data-index="8">8. 吸附</div><div data-index="9">9. 预览、保存代码</div><div data-index="10">10. 绑定事件</div><div data-index="11">11. 绑定动画</div><div data-index="12">12. 导入 PSD</div><div data-index="13">13. 手机模式</div><div data-index="14">14. 拖拽旋转</div><div data-index="15">15. 复制粘贴剪切</div><div data-index="16">16. 数据交互</div><div data-index="17">17. 发布</div><div><br></div><div data-index="18">本文在此基础上,将对以下几个功能点的技术原理进行分析:</div><div><br></div><div data-index="19">18. 多个组件的组合和拆分</div><div data-index="20">19. 文本组件</div><div data-index="21">20. 矩形组件</div><div data-index="22">21. 锁定组件</div><div data-index="23">22. 快捷键</div><div data-index="24">23. 网格线</div><div data-index="25">24. 编辑器快照的另一种实现方式</div><div><br></div><div data-index="26">如果你对我之前的两篇文章不是很了解,建议先把这两篇文章看一遍,再来阅读此文:</div><div data-index="27">* [可视化拖拽组件库一些技术要点原理分析](https://juejin.cn/post/6908502083075325959)</div><div data-index="28">* [可视化拖拽组件库一些技术要点原理分析(二)](https://juejin.cn/post/6918881497264947207)</div><div><br></div><div data-index="29">虽然我这个可视化拖拽组件库只是一个 DEMO,但对比了一下市面上的一些现成产品(例如 [processon](https://www.processon.com/)、[墨刀](https://modao.cc/)),就基础功能来说,我这个 DEMO 实现了绝大部分的功能。</div><div><br></div><div data-index="30">如果你对于低代码平台有兴趣,但又不了解的话。强烈建议将我的三篇文章结合项目源码一起阅读,相信对你的收获绝对不小。另附上项目、在线 DEMO 地址:</div><div data-index="31">* [项目地址](https://github.com/woai3c/visual-drag-demo)</div><div data-index="32">* [在线 DEMO](https://woai3c.gitee.io/visual-drag-demo)</div><div><br></div><div data-index="33">## 18. 多个组件的组合和拆分</div><div data-index="34">组合和拆分的技术点相对来说比较多,共有以下 4 个:</div><div data-index="35">* 选中区域</div><div data-index="36">* 组合后的移动、旋转</div><div data-index="37">* 组合后的放大缩小</div><div data-index="38">* 拆分后子组件样式的恢复</div><div><br></div><div data-index="39">### 选中区域</div><div data-index="40">在将多个组件组合之前,需要先选中它们。利用鼠标事件可以很方便的将选中区域展示出来:</div><div><br></div><div data-index="41"></div><div><br></div><div data-index="42">1. `mousedown` 记录起点坐标</div><div data-index="43">2. `mousemove` 将当前坐标和起点坐标进行计算得出移动区域</div><div data-index="44">3. 如果按下鼠标后往左上方移动,类似于这种操作则需要将当前坐标设为起点坐标,再计算出移动区域</div><div data-index="45">```js</div><div data-index="46">// 获取编辑器的位移信息</div><div data-index="47">const rectInfo = this.editor.getBoundingClientRect()</div><div data-index="48">this.editorX = rectInfo.x</div><div data-index="49">this.editorY = rectInfo.y</div><div><br></div><div data-index="50">const startX = e.clientX</div><div data-index="51">const startY = e.clientY</div><div data-index="52">this.start.x = startX - this.editorX</div><div data-index="53">this.start.y = startY - this.editorY</div><div data-index="54">// 展示选中区域</div><div data-index="55">this.isShowArea = true</div><div><br></div><div data-index="56">const move = (moveEvent) => {</div><div data-index="57"> this.width = Math.abs(moveEvent.clientX - startX)</div><div data-index="58"> this.height = Math.abs(moveEvent.clientY - startY)</div><div data-index="59"> if (moveEvent.clientX < startX) {</div><div data-index="60"> this.start.x = moveEvent.clientX - this.editorX</div><div data-index="61"> }</div><div><br></div><div data-index="62"> if (moveEvent.clientY < startY) {</div><div data-index="63"> this.start.y = moveEvent.clientY - this.editorY</div><div data-index="64"> }</div><div data-index="65">}</div><div data-index="66">```</div><div data-index="67">在 `mouseup` 事件触发时,需要对选中区域内的所有组件的位移大小信息进行计算,得出一个能包含区域内所有组件的最小区域。这个效果如下图所示:</div><div><br></div><div data-index="68"></div><div><br></div><div data-index="69">这个计算过程的代码:</div><div data-index="70">```js</div><div data-index="71">createGroup() {</div><div data-index="72"> // 获取选中区域的组件数据</div><div data-index="73"> const areaData = this.getSelectArea()</div><div data-index="74"> if (areaData.length <= 1) {</div><div data-index="75"> this.hideArea()</div><div data-index="76"> return</div><div data-index="77"> }</div><div><br></div><div data-index="78"> // 根据选中区域和区域中每个组件的位移信息来创建 Group 组件</div><div data-index="79"> // 要遍历选择区域的每个组件,获取它们的 left top right bottom 信息来进行比较</div><div data-index="80"> let top = Infinity, left = Infinity</div><div data-index="81"> let right = -Infinity, bottom = -Infinity</div><div data-index="82"> areaData.forEach(component => {</div><div data-index="83"> let style = {}</div><div data-index="84"> if (component.component == 'Group') {</div><div data-index="85"> component.propValue.forEach(item => {</div><div data-index="86"> const rectInfo = $(`#component${item.id}`).getBoundingClientRect()</div><div data-index="87"> style.left = rectInfo.left - this.editorX</div><div data-index="88"> style.top = rectInfo.top - this.editorY</div><div data-index="89"> style.right = rectInfo.right - this.editorX</div><div data-index="90"> style.bottom = rectInfo.bottom - this.editorY</div><div><br></div><div data-index="91"> if (style.left < left) left = style.left</div><div data-index="92"> if (style.top < top) top = style.top</div><div data-index="93"> if (style.right > right) right = style.right</div><div data-index="94"> if (style.bottom > bottom) bottom = style.bottom</div><div data-index="95"> })</div><div data-index="96"> } else {</div><div data-index="97"> style = getComponentRotatedStyle(component.style)</div><div data-index="98"> }</div><div><br></div><div data-index="99"> if (style.left < left) left = style.left</div><div data-index="100"> if (style.top < top) top = style.top</div><div data-index="101"> if (style.right > right) right = style.right</div><div data-index="102"> if (style.bottom > bottom) bottom = style.bottom</div><div data-index="103"> })</div><div><br></div><div data-index="104"> this.start.x = left</div><div data-index="105"> this.start.y = top</div><div data-index="106"> this.width = right - left</div><div data-index="107"> this.height = bottom - top</div><div><span style="white-space:pre"> </span></div><div data-index="108"> // 设置选中区域位移大小信息和区域内的组件数据</div><div data-index="109"> this.$store.commit('setAreaData', {</div><div data-index="110"> style: {</div><div data-index="111"> left,</div><div data-index="112"> top,</div><div data-index="113"> width: this.width,</div><div data-index="114"> height: this.height,</div><div data-index="115"> },</div><div data-index="116"> components: areaData,</div><div data-index="117"> })</div><div data-index="118">},</div><div> </div><div data-index="119">getSelectArea() {</div><div data-index="120"> const result = []</div><div data-index="121"> // 区域起点坐标</div><div data-index="122"> const { x, y } = this.start</div><div data-index="123"> // 计算所有的组件数据,判断是否在选中区域内</div><div data-index="124"> this.componentData.forEach(component => {</div><div data-index="125"> if (component.isLock) return</div><div data-index="126"> const { left, top, width, height } = component.style</div><div data-index="127"> if (x <= left && y <= top && (left + width <= x + this.width) && (top + height <= y + this.height)) {</div><div data-index="128"> result.push(component)</div><div data-index="129"> }</div><div data-index="130"> })</div><div><span style="white-space:pre"> </span></div><div data-index="131"> // 返回在选中区域内的所有组件</div><div data-index="132"> return result</div><div data-index="133">}</div><div data-index="134">```</div><div data-index="135">简单描述一下这段代码的处理逻辑:</div><div data-index="136">1. 利用 [getBoundingClientRect()](https://developer.mozilla.org/zh-CN/docs/Web/API/Element/getBoundingClientRect) 浏览器 API 获取每个组件相对于浏览器视口四个方向上的信息,也就是 `left` `top` `right` `bottom`。</div><div data-index="137">2. 对比每个组件的这四个信息,取得选中区域的最左、最上、最右、最下四个方向的数值,从而得出一个能包含区域内所有组件的最小区域。</div><div data-index="138">3. 如果选中区域内已经有一个 `Group` 组合组件,则需要对它里面的子组件进行计算,而不是对组合组件进行计算。</div><div><br></div><div data-index="139">### 组合后的移动、旋转</div><div data-index="140">为了方便将多个组件一起进行移动、旋转、放大缩小等操作,我新创建了一个 `Group` 组合组件:</div><div data-index="141">```html</div><div data-index="142"><template></div><div data-index="143"> <div class="group"></div><div data-index="144"> <div></div><div data-index="145"> <template v-for="item in propValue"></div><div data-index="146"> <component</div><div data-index="147"> class="component"</div><div data-index="148"> :is="item.component"</div><div data-index="149"> :style="item.groupStyle"</div><div data-index="150"> :propValue="item.propValue"</div><div data-index="151"> :key="item.id"</div><div data-index="152"> :id="'component' + item.id"</div><div data-index="153"> :element="item"</div><div data-index="154"> /></div><div data-index="155"> </template></div><div data-index="156"> </div></div><div data-index="157"> </div></div><div data-index="158"></template></div><div><br></div><div data-index="159"><script></div><div data-index="160">import { getStyle } from '@/utils/style'</div><div><br></div><div data-index="161">export default {</div><div data-index="162"> props: {</div><div data-index="163"> propValue: {</div><div data-index="164"> type: Array,</div><div data-index="165"> default: () => [],</div><div data-index="166"> },</div><div data-index="167"> element: {</div><div data-index="168"> type: Object,</div><div data-index="169"> },</div><div data-index="170"> },</div><div data-index="171"> created() {</div><div data-index="172"> const parentStyle = this.element.style</div><div data-index="173"> this.propValue.forEach(component => {</div><div data-index="174"> // component.groupStyle 的 top left 是相对于 group 组件的位置</div><div data-index="175"> // 如果已存在 component.groupStyle,说明已经计算过一次了。不需要再次计算</div><div data-index="176"> if (!Object.keys(component.groupStyle).length) {</div><div data-index="177"> const style = { ...component.style }</div><div data-index="178"> component.groupStyle = getStyle(style)</div><div data-index="179"> component.groupStyle.left = this.toPercent((style.left - parentStyle.left) / parentStyle.width)</div><div data-index="180"> component.groupStyle.top = this.toPercent((style.top - parentStyle.top) / parentStyle.height)</div><div data-index="181"> component.groupStyle.width = this.toPercent(style.width / parentStyle.width)</div><div data-index="182"> component.groupStyle.height = this.toPercent(style.height / parentStyle.height)</div><div data-index="183"> }</div><div data-index="184"> })</div><div data-index="185"> },</div><div data-index="186"> methods: {</div><div data-index="187"> toPercent(val) {</div><div data-index="188"> return val * 100 + '%'</div><div data-index="189"> },</div><div data-index="190"> },</div><div data-index="191">}</div><div data-index="192"></script></div><div><br></div><div data-index="193"><style lang="scss" scoped></div><div data-index="194">.group {</div><div data-index="195"> & > div {</div><div data-index="196"> position: relative;</div><div data-index="197"> width: 100%;</div><div data-index="198"> height: 100%;</div><div><br></div><div data-index="199"> .component {</div><div data-index="200"> position: absolute;</div><div data-index="201"> }</div><div data-index="202"> }</div><div data-index="203">}</div><div data-index="204"></style></div><div data-index="205">```</div><div data-index="206">`Group` 组件的作用就是将区域内的组件放到它下面,成为子组件。并且在创建 `Group` 组件时,获取每个子组件在 `Group` 组件内的相对位移和相对大小:</div><div data-index="207">```js</div><div data-index="208">created() {</div><div data-index="209"> const parentStyle = this.element.style</div><div data-index="210"> this.propValue.forEach(component => {</div><div data-index="211"> // component.groupStyle 的 top left 是相对于 group 组件的位置</div><div data-index="212"> // 如果已存在 component.groupStyle,说明已经计算过一次了。不需要再次计算</div><div data-index="213"> if (!Object.keys(component.groupStyle).length) {</div><div data-index="214"> const style = { ...component.style }</div><div data-index="215"> component.groupStyle = getStyle(style)</div><div data-index="216"> component.groupStyle.left = this.toPercent((style.left - parentStyle.left) / parentStyle.width)</div><div data-index="217"> component.groupStyle.top = this.toPercent((style.top - parentStyle.top) / parentStyle.height)</div><div data-index="218"> component.groupStyle.width = this.toPercent(style.width / parentStyle.width)</div><div data-index="219"> component.groupStyle.height = this.toPercent(style.height / parentStyle.height)</div><div data-index="220"> }</div><div data-index="221"> })</div><div data-index="222">},</div><div data-index="223">methods: {</div><div data-index="224"> toPercent(val) {</div><div data-index="225"> return val * 100 + '%'</div><div data-index="226"> },</div><div data-index="227"> },</div><div data-index="228">```</div><div data-index="229">也就是将子组件的 `left` `top` `width` `height` 等属性转成以 `%` 结尾的相对数值。</div><div><br></div><div data-index="230">**为什么不使用绝对数值**?</div><div><br></div><div data-index="231">如果使用绝对数值,那么在移动 `Group` 组件时,除了对 `Group` 组件的属性进行计算外,还需要对它的每个子组件进行计算。并且 `Group` 包含子组件太多的话,在进行移动、放大缩小时,计算量会非常大,有可能会造成页面卡顿。如果改成相对数值,则只需要在 `Group` 创建时计算一次。然后在 `Group` 组件进行移动、旋转时也不用管 `Group` 的子组件,只对它自己计算即可。</div><div><br></div><div data-index="232"></div><div><br></div><div data-index="233">### 组合后的放大缩小</div><div data-index="234">组合后的放大缩小是个大问题,主要是因为有旋转角度的存在。首先来看一下各个子组件没旋转时的放大缩小:</div><div><br></div><div data-index="235"></div><div><br></div><div data-index="236">从动图可以看出,效果非常完美。各个子组件的大小是跟随 `Group` 组件的大小而改变的。</div><div><br></div><div data-index="237">现在试着给子组件加上旋转角度,再看一下效果:</div><div><br></div><div data-index="238"></div><div><br></div><div data-index="239">**为什么会出现这个问题**?</div><div><br></div><div data-index="240">主要是因为一个组件无论旋不旋转,它的 `top` `left` 属性都是不变的。这样就会有一个问题,虽然实际上组件的 `top` `left` `width` `height` 属性没有变化。但在外观上却发生了变化。下面是两个同样的组件:一个没旋转,一个旋转了 45 度。</div><div><br></div><div data-index="241"></div><div><br></div><div data-index="242">可以看出来旋转后按钮的 `top` `left` `width` `height` 属性和我们从外观上看到的是不一样的。</div><div><br></div><div data-index="243">接下来再看一个具体的示例:</div><div><br></div><div data-index="244"></div><div><br></div><div data-index="245">上面是一个 `Group` 组件,它左边的子组件属性为:</div><div data-index="246">```css</div><div data-index="247">transform: rotate(-75.1967deg);</div><div data-index="248">width: 51.2267%;</div><div data-index="249">height: 32.2679%;</div><div data-index="250">top: 33.8661%;</div><div data-index="251">left: -10.6496%;</div><div data-index="252">```</div><div data-index="253">可以看到 `width` 的值为 `51.2267%`,但从外观上来看,这个子组件最多占 `Group` 组件宽度的三分之一。所以这就是放大缩小不正常的问题所在。</div><div><br></div><div data-index="254">#### 一个不可行的解决方案(不想看的可以跳过)</div><div data-index="255">一开始我想的是,先算出它相对浏览器视口的 `top` `left` `width` `height` 属性,再算出这几个属性在 `Group` 组件上的相对数值。这可以通过 `getBoundingClientRect()` API 实现。只要维持外观上的各个属性占比不变,这样 `Group` 组件在放大缩小时,再通过旋转角度,利用旋转矩阵的知识(这一点在第二篇有详细描述)获取它未旋转前的 `top` `left` `width` `height` 属性。这样就可以做到子组件动态调整了。</div><div><br></div><div data-index="256">但是这有个问题,通过 `getBoundingClientRect()` API 只能获取组件外观上的 `top` `left` `right` `bottom` `width` `height` 属性。再加上一个角度,参数还是不够,所以无法计算出组件实际的 `top` `left` `width` `height` 属性。</div><div><br></div><div data-index="257"></div><div><br></div><div data-index="258">就像上面的这张图,只知道原点 `O(x,y)` `w` `h` 和旋转角度,无法算出按钮的宽高。</div><div><br></div><div data-index="259">#### 一个可行的解决方案</div><div data-index="260">这是无意中发现的,我在对 `Group` 组件进行放大缩小时,发现只要保持 `Group` 组件的宽高比例,子组件就能做到根据比例放大缩小。那么现在问题就转变成了**如何让 `Group` 组件放大缩小时保持宽高比例**。我在网上找到了这一篇[文章](https://github.com/shenhudong/snapping-demo/wiki/corner-handle),它详细描述了一个旋转组件如何保持宽高比来进行放大缩小,并配有源码示例。</div><div><br></div><div data-index="261">现在我尝试简单描述一下如何保持宽高比对一个旋转组件进行放大缩小(建议还是看看原文)。下面是一个已旋转一定角度的矩形,假设现在拖动它左上方的点进行拉伸。</div><div><br></div><div data-index="262"></div><div><br></div><div><br></div><div data-index="263">**第一步**,算出组件宽高比,以及按下鼠标时通过组件的坐标(无论旋转多少度,组件的 `top` `left` 属性不变)和大小算出组件中心点:</div><div data-index="264">```js</div><div data-index="265">// 组件宽高比</div><div data-index="266">const proportion = style.width / style.height</div><div> </div><div data-index="267">const center = {</div><div data-index="268"> x: style.left + style.width / 2,</div><div data-index="269"> y: style.top + style.height / 2,</div><div data-index="270">}</div><div data-index="271">```</div><div data-index="272">**第二步**,用**当前点击坐标**和组件中心点算出**当前点击坐标**的对称点坐标:</div><div data-index="273">```js</div><div data-index="274">// 获取画布位移信息</div><div data-index="275">const editorRectInfo = document.querySelector('#editor').getBoundingClientRect()</div><div><br></div><div data-index="276">// 当前点击坐标</div><div data-index="277">const curPoint = {</div><div data-index="278"> x: e.clientX - editorRectInfo.left,</div><div data-index="279"> y: e.clientY - editorRectInfo.top,</div><div data-index="280">}</div><div><br></div><div data-index="281">// 获取对称点的坐标</div><div data-index="282">const symmetricPoint = {</div><div data-index="283"> x: center.x - (curPoint.x - center.x),</div><div data-index="284"> y: center.y - (curPoint.y - center.y),</div><div data-index="285">}</div><div data-index="286">```</div><div data-index="287">**第三步**,摁住组件左上角进行拉伸时,通过当前鼠标实时坐标和对称点计算出新的组件中心点:</div><div data-index="288">```js</div><div data-index="289">const curPositon = {</div><div data-index="290"> x: moveEvent.clientX - editorRectInfo.left,</div><div data-index="291"> y: moveEvent.clientY - editorRectInfo.top,</div><div data-index="292">}</div><div><br></div><div data-index="293">const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)</div><div><br></div><div data-index="294">// 求两点之间的中点坐标</div><div data-index="295">function getCenterPoint(p1, p2) {</div><div data-index="296"> return {</div><div data-index="297"> x: p1.x + ((p2.x - p1.x) / 2),</div><div data-index="298"> y: p1.y + ((p2.y - p1.y) / 2),</div><div data-index="299"> }</div><div data-index="300">}</div><div data-index="301">```</div><div data-index="302">由于组件处于旋转状态,即使你知道了拉伸时移动的 `xy` 距离,也不能直接对组件进行计算。否则就会出现 BUG,移位或者放大缩小方向不正确。因此,我们需要在组件未旋转的情况下对其进行计算。</div><div><br></div><div data-index="303"></div><div><br></div><div data-index="304">**第四步**,根据已知的旋转角度、新的组件中心点、当前鼠标实时坐标可以算出**当前鼠标实时坐标** `currentPosition` 在未旋转时的坐标 `newTopLeftPoint`。同时也能根据已知的旋转角度、新的组件中心点、对称点算出**组件对称点** `sPoint` 在未旋转时的坐标 `newBottomRightPoint`。</div><div><br></div><div data-index="305">对应的计算公式如下:</div><div data-index="306">```js</div><div data-index="307">/**</div><div data-index="308"> * 计算根据圆心旋转后的点的坐标</div><div data-index="309"> * @param {Object} point 旋转前的点坐标</div><div data-index="310"> * @param {Object} center 旋转中心</div><div data-index="311"> * @param {Number} rotate 旋转的角度</div><div data-index="312"> * @return {Object} 旋转后的坐标</div><div data-index="313"> * https://www.zhihu.com/question/67425734/answer/252724399 旋转矩阵公式</div><div data-index="314"> */</div><div data-index="315">export function calculateRotatedPointCoordinate(point, center, rotate) {</div><div data-index="316"> /**</div><div data-index="317"> * 旋转公式:</div><div data-index="318"> * 点a(x, y)</div><div data-index="319"> * 旋转中心c(x, y)</div><div data-index="320"> * 旋转后点n(x, y)</div><div data-index="321"> * 旋转角度θ tan ??</div><div data-index="322"> * nx = cosθ * (ax - cx) - sinθ * (ay - cy) + cx</div><div data-index="323"> * ny = sinθ * (ax - cx) + cosθ * (ay - cy) + cy</div><div data-index="324"> */</div><div><br></div><div data-index="325"> return {</div><div data-index="326"> x: (point.x - center.x) * Math.cos(angleToRadian(rotate)) - (point.y - center.y) * Math.sin(angleToRadian(rotate)) + center.x,</div><div data-index="327"> y: (point.x - center.x) * Math.sin(angleToRadian(rotate)) + (point.y - center.y) * Math.cos(angleToRadian(rotate)) + center.y,</div><div data-index="328"> }</div><div data-index="329">}</div><div data-index="330">```</div><div data-index="331">上面的公式涉及到线性代数中旋转矩阵的知识,对于一个没上过大学的人来说,实在太难了。还好我从知乎上的一个[回答](https://www.zhihu.com/question/67425734/answer/252724399)中找到了这一公式的推理过程,下面是回答的原文:</div><div><br></div><div data-index="332"></div><div><br></div><div data-index="333"></div><div><br></div><div data-index="334">通过以上几个计算值,就可以得到组件新的位移值 `top` `left` 以及新的组件大小。对应的完整代码如下:</div><div data-index="335">```js</div><div data-index="336">function calculateLeftTop(style, curPositon, pointInfo) {</div><div data-index="337"> const { symmetricPoint } = pointInfo</div><div data-index="338"> const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)</div><div data-index="339"> const newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate)</div><div data-index="340"> const newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)</div><div> </div><div data-index="341"> const newWidth = newBottomRightPoint.x - newTopLeftPoint.x</div><div data-index="342"> const newHeight = newBottomRightPoint.y - newTopLeftPoint.y</div><div data-index="343"> if (newWidth > 0 && newHeight > 0) {</div><div data-index="344"> style.width = Math.round(newWidth)</div><div data-index="345"> style.height = Math.round(newHeight)</div><div data-index="346"> style.left = Math.round(newTopLeftPoint.x)</div><div data-index="347"> style.top = Math.round(newTopLeftPoint.y)</div><div data-index="348"> }</div><div data-index="349">}</div><div data-index="350">```</div><div data-index="351">现在再来看一下旋转后的放大缩小:</div><div><br></div><div data-index="352"></div><div><br></div><div data-index="353">**第五步**,由于我们现在需要的是锁定宽高比来进行放大缩小,所以需要重新计算拉伸后的图形的左上角坐标。</div><div><br></div><div data-index="354">这里先确定好几个形状的命名:</div><div data-index="355">* 原图形: 红色部分</div><div data-index="356">* 新图形: 蓝色部分</div><div data-index="357">* 修正图形: 绿色部分,即加上宽高比锁定规则的修正图形</div><div><br></div><div data-index="358"></div><div><br></div><div data-index="359">在第四步中算出组件未旋转前的 `newTopLeftPoint` `newBottomRightPoint` `newWidth` `newHeight` 后,需要根据宽高比 `proportion` 来算出新的宽度或高度。</div><div><br></div><div data-index="360"></div><div><br></div><div data-index="361">上图就是一个需要改变高度的示例,计算过程如下:</div><div data-index="362">```js</div><div data-index="363">if (newWidth / newHeight > proportion) {</div><div data-index="364"> newTopLeftPoint.x += Math.abs(newWidth - newHeight * proportion)</div><div data-index="365"> newWidth = newHeight * proportion</div><div data-index="366">} else {</div><div data-index="367"> newTopLeftPoint.y += Math.abs(newHeight - newWidth / proportion)</div><div data-index="368"> newHeight = newWidth / proportion</div><div data-index="369">}</div><div data-index="370">```</div><div><br></div><div data-index="371">由于现在求的未旋转前的坐标是以没按比例缩减宽高前的坐标来计算的,所以缩减宽高后,需要按照原来的中心点旋转回去,获得缩减宽高并旋转后对应的坐标。然后以这个坐标和对称点获得新的中心点,并重新计算未旋转前的坐标。</div><div><br></div><div data-index="372"></div><div><br></div><div data-index="373"></div><div><br></div><div data-index="374">经过修改后的完整代码如下:</div><div data-index="375">```js</div><div data-index="376">function calculateLeftTop(style, curPositon, proportion, needLockProportion, pointInfo) {</div><div data-index="377"> const { symmetricPoint } = pointInfo</div><div data-index="378"> let newCenterPoint = getCenterPoint(curPositon, symmetricPoint)</div><div data-index="379"> let newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate)</div><div data-index="380"> let newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)</div><div> </div><div data-index="381"> let newWidth = newBottomRightPoint.x - newTopLeftPoint.x</div><div data-index="382"> let newHeight = newBottomRightPoint.y - newTopLeftPoint.y</div><div><br></div><div data-index="383"> if (needLockProportion) {</div><div data-index="384"> if (newWidth / newHeight > proportion) {</div><div data-index="385"> newTopLeftPoint.x += Math.abs(newWidth - newHeight * proportion)</div><div data-index="386"> newWidth = newHeight * proportion</div><div data-index="387"> } else {</div><div data-index="388"> newTopLeftPoint.y += Math.abs(newHeight - newWidth / proportion)</div><div data-index="389"> newHeight = newWidth / proportion</div><div data-index="390"> }</div><div><br></div><div data-index="391"> // 由于现在求的未旋转前的坐标是以没按比例缩减宽高前的坐标来计算的</div><div data-index="392"> // 所以缩减宽高后,需要按照原来的中心点旋转回去,获得缩减宽高并旋转后对应的坐标</div><div data-index="393"> // 然后以这个坐标和对称点获得新的中心点,并重新计算未旋转前的坐标</div><div data-index="394"> const rotatedTopLeftPoint = calculateRotatedPointCoordinate(newTopLeftPoint, newCenterPoint, style.rotate)</div><div data-index="395"> newCenterPoint = getCenterPoint(rotatedTopLeftPoint, symmetricPoint)</div><div data-index="396"> newTopLeftPoint = calculateRotatedPointCoordinate(rotatedTopLeftPoint, newCenterPoint, -style.rotate)</div><div data-index="397"> newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)</div><div> </div><div data-index="398"> newWidth = newBottomRightPoint.x - newTopLeftPoint.x</div><div data-index="399"> newHeight = newBottomRightPoint.y - newTopLeftPoint.y</div><div data-index="400"> }</div><div><br></div><div data-index="401"> if (newWidth > 0 && newHeight > 0) {</div><div data-index="402"> style.width = Math.round(newWidth)</div><div data-index="403"> style.height = Math.round(newHeight)</div><div data-index="404"> style.left = Math.round(newTopLeftPoint.x)</div><div data-index="405"> style.top = Math.round(newTopLeftPoint.y)</div><div data-index="406"> }</div><div data-index="407">}</div><div data-index="408">```</div><div data-index="409">保持宽高比进行放大缩小的效果如下:</div><div><br></div><div data-index="410"></div><div><br></div><div data-index="411">当 `Group` 组件有旋转的子组件时,才需要保持宽高比进行放大缩小。所以在创建 `Group` 组件时可以判断一下子组件是否有旋转角度。如果没有,就不需要保持宽度比进行放大缩小。</div><div data-index="412">```js</div><div data-index="413">isNeedLockProportion() {</div><div data-index="414"> if (this.element.component != 'Group') return false</div><div data-index="415"> const ratates = [0, 90, 180, 360]</div><div data-index="416"> for (const component of this.element.propValue) {</div><div data-index="417"> if (!ratates.includes(mod360(parseInt(component.style.rotate)))) {</div><div data-index="418"> return true</div><div data-index="419"> }</div><div data-index="420"> }</div><div><br></div><div data-index="421"> return false</div><div data-index="422">}</div><div data-index="423">```</div><div data-index="424">### 拆分后子组件样式的恢复</div><div data-index="425">将多个组件组合在一起只是第一步,第二步是将 `Group` 组件进行拆分并恢复各个子组件的样式。保证拆分后的子组件在外观上的属性不变。</div><div><br></div><div data-index="426">计算代码如下:</div><div data-index="427">```js</div><div data-index="428">// store</div><div data-index="429">decompose({ curComponent, editor }) {</div><div data-index="430"> const parentStyle = { ...curComponent.style }</div><div data-index="431"> const components = curComponent.propValue</div><div data-index="432"> const editorRect = editor.getBoundingClientRect()</div><div><br></div><div data-index="433"> store.commit('deleteComponent')</div><div data-index="434"> components.forEach(component => {</div><div data-index="435"> decomposeComponent(component, editorRect, parentStyle)</div><div data-index="436"> store.commit('addComponent', { component })</div><div data-index="437"> })</div><div data-index="438">}</div><div> </div><div data-index="439">// 将组合中的各个子组件拆分出来,并计算它们新的 style</div><div data-index="440">export default function decomposeComponent(component, editorRect, parentStyle) {</div><div data-index="441"> // 子组件相对于浏览器视口的样式</div><div data-index="442"> const componentRect = $(`#component${component.id}`).getBoundingClientRect()</div><div data-index="443"> // 获取元素的中心点坐标</div><div data-index="444"> const center = {</div><div data-index="445"> x: componentRect.left - editorRect.left + componentRect.width / 2,</div><div data-index="446"> y: componentRect.top - editorRect.top + componentRect.height / 2,</div><div data-index="447"> }</div><div><br></div><div data-index="448"> component.style.rotate = mod360(component.style.rotate + parentStyle.rotate)</div><div data-index="449"> component.style.width = parseFloat(component.groupStyle.width) / 100 * parentStyle.width</div><div data-index="450"> component.style.height = parseFloat(component.groupStyle.height) / 100 * parentStyle.height</div><div data-index="451"> // 计算出元素新的 top left 坐标</div><div data-index="452"> component.style.left = center.x - component.style.width / 2</div><div data-index="453"> component.style.top = center.y - component.style.height / 2</div><div data-index="454"> component.groupStyle = {}</div><div data-index="455">}</div><div data-index="456">```</div><div data-index="457">这段代码的处理逻辑为:</div><div data-index="458">1. 遍历 `Group` 的子组件并恢复它们的样式</div><div data-index="459">2. 利用 `getBoundingClientRect()` API 获取子组件相对于浏览器视口的 `left` `top` `width` `height` 属性。</div><div data-index="460">3. 利用这四个属性计算出子组件的中心点坐标。</div><div data-index="461">4. 由于子组件的 `width` `height` 属性是相对于 `Group` 组件的,所以将它们的百分比值和 `Group` 相乘得出具体数值。</div><div data-index="462">5. 再用中心点 `center(x, y)` 减去子组件宽高的一半得出它的 `left` `top` 属性。</div><div><br></div><div data-index="463">至此,组合和拆分就讲解完了。</div><div><br></div><div data-index="464">## 19. 文本组件</div><div data-index="465">文本组件 `VText` 之前就已经实现过了,但不完美。例如无法对文字进行选中。现在我对它进行了重写,让它支持选中功能。</div><div data-index="466">```html</div><div data-index="467"><template></div><div data-index="468"> <div v-if="editMode == 'edit'" class="v-text" @keydown="handleKeydown" @keyup="handleKeyup"></div><div data-index="469"> <!-- tabindex >= 0 使得双击时聚集该元素 --></div><div data-index="470"> <div :contenteditable="canEdit" :class="{ canEdit }" @dblclick="setEdit" :tabindex="element.id" @paste="clearStyle"</div><div data-index="471"> @mousedown="handleMousedown" @blur="handleBlur" ref="text" v-html="element.propValue" @input="handleInput"</div><div data-index="472"> :style="{ verticalAlign: element.style.verticalAlign }"</div><div data-index="473"> ></div></div><div data-index="474"> </div></div><div data-index="475"> <div v-else class="v-text"></div><div data-index="476"> <div v-html="element.propValue" :style="{ verticalAlign: element.style.verticalAlign }"></div></div><div data-index="477"> </div></div><div data-index="478"></template></div><div><br></div><div data-index="479"><script></div><div data-index="480">import { mapState } from 'vuex'</div><div data-index="481">import { keycodes } from '@/utils/shortcutKey.js'</div><div><br></div><div data-index="482">export default {</div><div data-index="483"> props: {</div><div data-index="484"> propValue: {</div><div data-index="485"> type: String,</div><div data-index="486"> require: true,</div><div data-index="487"> },</div><div data-index="488"> element: {</div><div data-index="489"> type: Object,</div><div data-index="490"> },</div><div data-index="491"> },</div><div data-index="492"> data() {</div><div data-index="493"> return {</div><div data-index="494"> canEdit: false,</div><div data-index="495"> ctrlKey: 17,</div><div data-index="496"> isCtrlDown: false,</div><div data-index="497"> }</div><div data-index="498"> },</div><div data-index="499"> computed: {</div><div data-index="500"> ...mapState([</div><div data-index="501"> 'editMode',</div><div data-index="502"> ]),</div><div data-index="503"> },</div><div data-index="504"> methods: {</div><div data-index="505"> handleInput(e) {</div><div data-index="506"> this.$emit('input', this.element, e.target.innerHTML)</div><div data-index="507"> },</div><div><br></div><div data-index="508"> handleKeydown(e) {</div><div data-index="509"> if (e.keyCode == this.ctrlKey) {</div><div data-index="510"> this.isCtrlDown = true</div><div data-index="511"> } else if (this.isCtrlDown && this.canEdit && keycodes.includes(e.keyCode)) {</div><div data-index="512"> e.stopPropagation()</div><div data-index="513"> } else if (e.keyCode == 46) { // deleteKey</div><div data-index="514"> e.stopPropagation()</div><div data-index="515"> }</div><div data-index="516"> },</div><div><br></div><div data-index="517"> handleKeyup(e) {</div><div data-index="518"> if (e.keyCode == this.ctrlKey) {</div><div data-index="519"> this.isCtrlDown = false</div><div data-index="520"> }</div><div data-index="521"> },</div><div><br></div><div data-index="522"> handleMousedown(e) {</div><div data-index="523"> if (this.canEdit) {</div><div data-index="524"> e.stopPropagation()</div><div data-index="525"> }</div><div data-index="526"> },</div><div><br></div><div data-index="527"> clearStyle(e) {</div><div data-index="528"> e.preventDefault()</div><div data-index="529"> const clp = e.clipboardData</div><div data-index="530"> const text = clp.getData('text/plain') || ''</div><div data-index="531"> if (text !== '') {</div><div data-index="532"> document.execCommand('insertText', false, text)</div><div data-index="533"> }</div><div><br></div><div data-index="534"> this.$emit('input', this.element, e.target.innerHTML)</div><div data-index="535"> },</div><div><br></div><div data-index="536"> handleBlur(e) {</div><div data-index="537"> this.element.propValue = e.target.innerHTML || '&nbsp;'</div><div data-index="538"> this.canEdit = false</div><div data-index="539"> },</div><div><br></div><div data-index="540"> setEdit() {</div><div data-index="541"> this.canEdit = true</div><div data-index="542"> // 全选</div><div data-index="543"> this.selectText(this.$refs.text)</div><div data-index="544"> },</div><div><br></div><div data-index="545"> selectText(element) {</div><div data-index="546"> const selection = window.getSelection()</div><div data-index="547"> const range = document.createRange()</div><div data-index="548"> range.selectNodeContents(element)</div><div data-index="549"> selection.removeAllRanges()</div><div data-index="550"> selection.addRange(range)</div><div data-index="551"> },</div><div data-index="552"> },</div><div data-index="553">}</div><div data-index="554"></script></div><div><br></div><div data-index="555"><style lang="scss" scoped></div><div data-index="556">.v-text {</div><div data-index="557"> width: 100%;</div><div data-index="558"> height: 100%;</div><div data-index="559"> display: table;</div><div><br></div><div data-index="560"> div {</div><div data-index="561"> display: table-cell;</div><div data-index="562"> width: 100%;</div><div data-index="563"> height: 100%;</div><div data-index="564"> outline: none;</div><div data-index="565"> }</div><div><br></div><div data-index="566"> .canEdit {</div><div data-index="567"> cursor: text;</div><div data-index="568"> height: 100%;</div><div data-index="569"> }</div><div data-index="570">}</div><div data-index="571"></style></div><div data-index="572">```</div><div data-index="573">改造后的 `VText` 组件功能如下:</div><div data-index="574">1. 双击启动编辑。</div><div data-index="575">2. 支持选中文本。</div><div data-index="576">3. 粘贴时过滤掉文本的样式。</div><div data-index="577">4. 换行时自动扩充文本框的高度。</div><div><br></div><div data-index="578"></div><div><br></div><div data-index="579">## 20. 矩形组件</div><div data-index="580">矩形组件其实就是一个内嵌 `VText` 文本组件的一个 DIV。</div><div data-index="581">```html</div><div data-index="582"><template></div><div data-index="583"> <div class="rect-shape"></div><div data-index="584"> <v-text :propValue="element.propValue" :element="element" /></div><div data-index="585"> </div></div><div data-index="586"></template></div><div><br></div><div data-index="587"><script></div><div data-index="588">export default {</div><div data-index="589"> props: {</div><div data-index="590"> element: {</div><div data-index="591"> type: Object,</div><div data-index="592"> },</div><div data-index="593"> },</div><div data-index="594">}</div><div data-index="595"></script></div><div><br></div><div data-index="596"><style lang="scss" scoped></div><div data-index="597">.rect-shape {</div><div data-index="598"> width: 100%;</div><div data-index="599"> height: 100%;</div><div data-index="600"> overflow: auto;</div><div data-index="601">}</div><div data-index="602"></style></div><div data-index="603">```</div><div data-index="604">`VText` 文本组件有的功能它都有,并且可以任意放大缩小。</div><div><br></div><div data-index="605"></div><div><br></div><div data-index="606">## 21. 锁定组件</div><div data-index="607">锁定组件主要是看到 `processon` 和墨刀有这个功能,于是我顺便实现了。锁定组件的具体需求为:不能移动、放大缩小、旋转、复制、粘贴等,只能进行解锁操作。</div><div><br></div><div data-index="608">它的实现原理也不难:</div><div data-index="609">1. 在自定义组件上加一个 `isLock` 属性,表示是否锁定组件。</div><div data-index="610">2. 在点击组件时,根据 `isLock` 是否为 `true` 来隐藏组件上的八个点和旋转图标。</div><div data-index="611">3. 为了突出一个组件被锁定,给它加上透明度属性和一个锁的图标。</div><div data-index="612">4. 如果组件被锁定,置灰上面所说的需求对应的按钮,不能被点击。</div><div><br></div><div data-index="613">相关代码如下:</div><div data-index="614">```js</div><div data-index="615">export const commonAttr = {</div><div data-index="616"> animations: [],</div><div data-index="617"> events: {},</div><div data-index="618"> groupStyle: {}, // 当一个组件成为 Group 的子组件时使用</div><div data-index="619"> isLock: false, // 是否锁定组件</div><div data-index="620">}</div><div data-index="621">```</div><div data-index="622">```html</div><div data-index="623"><el-button @click="decompose" </div><div data-index="624">:disabled="!curComponent || curComponent.isLock || curComponent.component != 'Group'">拆分</el-button></div><div><br></div><div data-index="625"><el-button @click="lock" :disabled="!curComponent || curComponent.isLock">锁定</el-button></div><div data-index="626"><el-button @click="unlock" :disabled="!curComponent || !curComponent.isLock">解锁</el-button></div><div data-index="627">```</div><div data-index="628">```html</div><div data-index="629"><template></div><div data-index="630"> <div class="contextmenu" v-show="menuShow" :style="{ top: menuTop + 'px', left: menuLeft + 'px' }"></div><div data-index="631"> <ul @mouseup="handleMouseUp"></div><div data-index="632"> <template v-if="curComponent"></div><div data-index="633"> <template v-if="!curComponent.isLock"></div><div data-index="634"> <li @click="copy">复制</li></div><div data-index="635"> <li @click="paste">粘贴</li></div><div data-index="636"> <li @click="cut">剪切</li></div><div data-index="637"> <li @click="deleteComponent">删除</li></div><div data-index="638"> <li @click="lock">锁定</li></div><div data-index="639"> <li @click="topComponent">置顶</li></div><div data-index="640"> <li @click="bottomComponent">置底</li></div><div data-index="641"> <li @click="upComponent">上移</li></div><div data-index="642"> <li @click="downComponent">下移</li></div><div data-index="643"> </template></div><div data-index="644"> <li v-else @click="unlock">解锁</li></div><div data-index="645"> </template></div><div data-index="646"> <li v-else @click="paste">粘贴</li></div><div data-index="647"> </ul></div><div data-index="648"> </div></div><div data-index="649"></template></div><div data-index="650">```</div><div data-index="651"></div><div data-index="652">## 22. 快捷键</div><div data-index="653">支持快捷键主要是为了提升开发效率,用鼠标点点点毕竟没有按键盘快。目前快捷键支持的功能如下:</div><div data-index="654">```js</div><div data-index="655">const ctrlKey = 17, </div><div data-index="656"> vKey = 86, // 粘贴</div><div data-index="657"> cKey = 67, // 复制</div><div data-index="658"> xKey = 88, // 剪切</div><div><br></div><div data-index="659"> yKey = 89, // 重做</div><div data-index="660"> zKey = 90, // 撤销</div><div><br></div><div data-index="661"> gKey = 71, // 组合</div><div data-index="662"> bKey = 66, // 拆分</div><div><br></div><div data-index="663"> lKey = 76, // 锁定</div><div data-index="664"> uKey = 85, // 解锁</div><div><br></div><div data-index="665"> sKey = 83, // 保存</div><div data-index="666"> pKey = 80, // 预览</div><div data-index="667"> dKey = 68, // 删除</div><div data-index="668"> deleteKey = 46, // 删除</div><div data-index="669"> eKey = 69 // 清空画布</div><div data-index="670">```</div><div data-index="671">实现原理主要是利用 window 全局监听按键事件,在符合条件的按键触发时执行对应的操作:</div><div data-index="672">```js</div><div data-index="673">// 与组件状态无关的操作</div><div data-index="674">const basemap = {</div><div data-index="675"> [vKey]: paste,</div><div data-index="676"> [yKey]: redo,</div><div data-index="677"> [zKey]: undo,</div><div data-index="678"> [sKey]: save,</div><div data-index="679"> [pKey]: preview,</div><div data-index="680"> [eKey]: clearCanvas,</div><div data-index="681">}</div><div><br></div><div data-index="682">// 组件锁定状态下可以执行的操作</div><div data-index="683">const lockMap = {</div><div data-index="684"> ...basemap,</div><div data-index="685"> [uKey]: unlock,</div><div data-index="686">}</div><div><br></div><div data-index="687">// 组件未锁定状态下可以执行的操作</div><div data-index="688">const unlockMap = {</div><div data-index="689"> ...basemap,</div><div data-index="690"> [cKey]: copy,</div><div data-index="691"> [xKey]: cut,</div><div data-index="692"> [gKey]: compose,</div><div data-index="693"> [bKey]: decompose,</div><div data-index="694"> [dKey]: deleteComponent,</div><div data-index="695"> [deleteKey]: deleteComponent,</div><div data-index="696"> [lKey]: lock,</div><div data-index="697">}</div><div><br></div><div data-index="698">let isCtrlDown = false</div><div data-index="699">// 全局监听按键操作并执行相应命令</div><div data-index="700">export function listenGlobalKeyDown() {</div><div data-index="701"> window.onkeydown = (e) => {</div><div data-index="702"> const { curComponent } = store.state</div><div data-index="703"> if (e.keyCode == ctrlKey) {</div><div data-index="704"> isCtrlDown = true</div><div data-index="705"> } else if (e.keyCode == deleteKey && curComponent) {</div><div data-index="706"> store.commit('deleteComponent')</div><div data-index="707"> store.commit('recordSnapshot')</div><div data-index="708"> } else if (isCtrlDown) {</div><div data-index="709"> if (!curComponent || !curComponent.isLock) {</div><div data-index="710"> e.preventDefault()</div><div data-index="711"> unlockMap[e.keyCode] && unlockMap[e.keyCode]()</div><div data-index="712"> } else if (curComponent && curComponent.isLock) {</div><div data-index="713"> e.preventDefault()</div><div data-index="714"> lockMap[e.keyCode] && lockMap[e.keyCode]()</div><div data-index="715"> }</div><div data-index="716"> }</div><div data-index="717"> }</div><div><br></div><div data-index="718"> window.onkeyup = (e) => {</div><div data-index="719"> if (e.keyCode == ctrlKey) {</div><div data-index="720"> isCtrlDown = false</div><div data-index="721"> }</div><div data-index="722"> }</div><div data-index="723">}</div><div data-index="724">```</div><div data-index="725">为了防止和浏览器默认快捷键冲突,所以需要加上 `e.preventDefault()`。</div><div data-index="726">## 23. 网格线</div><div data-index="727">网格线功能使用 SVG 来实现:</div><div data-index="728">```html</div><div data-index="729"><template></div><div data-index="730"> <svg class="grid" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg"></div><div data-index="731"> <defs></div><div data-index="732"> <pattern id="smallGrid" width="7.236328125" height="7.236328125" patternUnits="userSpaceOnUse"></div><div data-index="733"> <path </div><div data-index="734"> d="M 7.236328125 0 L 0 0 0 7.236328125" </div><div data-index="735"> fill="none" </div><div data-index="736"> stroke="rgba(207, 207, 207, 0.3)" </div><div data-index="737"> stroke-width="1"></div><div data-index="738"> </path></div><div data-index="739"> </pattern></div><div data-index="740"> <pattern id="grid" width="36.181640625" height="36.181640625" patternUnits="userSpaceOnUse"></div><div data-index="741"> <rect width="36.181640625" height="36.181640625" fill="url(#smallGrid)"></rect></div><div data-index="742"> <path </div><div data-index="743"> d="M 36.181640625 0 L 0 0 0 36.181640625" </div><div data-index="744"> fill="none" </div><div data-index="745"> stroke="rgba(186, 186, 186, 0.5)" </div><div data-index="746"> stroke-width="1"></div><div data-index="747"> </path></div><div data-index="748"> </pattern></div><div data-index="749"> </defs></div><div data-index="750"> <rect width="100%" height="100%" fill="url(#grid)"></rect></div><div data-index="751"> </svg></div><div data-index="752"></template></div><div><br></div><div data-index="753"><style lang="scss" scoped></div><div data-index="754">.grid {</div><div data-index="755"> position: absolute;</div><div data-index="756"> top: 0;</div><div data-index="757"> left: 0;</div><div data-index="758">}</div><div data-index="759"></style></div><div data-index="760">```</div><div data-index="761">对 SVG 不太懂的,建议看一下 MDN 的[教程](https://developer.mozilla.org/zh-CN/docs/Web/SVG)。</div><div data-index="762">## 24. 编辑器快照的另一种实现方式</div><div data-index="763">在系列文章的第一篇中,我已经分析过快照的实现原理。</div><div><br></div><div data-index="764">```js</div><div data-index="765">snapshotData: [], // 编辑器快照数据</div><div data-index="766">snapshotIndex: -1, // 快照索引</div><div> </div><div data-index="767">undo(state) {</div><div data-index="768"> if (state.snapshotIndex >= 0) {</div><div data-index="769"> state.snapshotIndex--</div><div data-index="770"> store.commit('setComponentData', deepCopy(state.snapshotData[state.snapshotIndex]))</div><div data-index="771"> }</div><div data-index="772">},</div><div><br></div><div data-index="773">redo(state) {</div><div data-index="774"> if (state.snapshotIndex < state.snapshotData.length - 1) {</div><div data-index="775"> state.snapshotIndex++</div><div data-index="776"> store.commit('setComponentData', deepCopy(state.snapshotData[state.snapshotIndex]))</div><div data-index="777"> }</div><div data-index="778">},</div><div><br></div><div data-index="779">setComponentData(state, componentData = []) {</div><div data-index="780"> Vue.set(state, 'componentData', componentData)</div><div data-index="781">},</div><div><br></div><div data-index="782">recordSnapshot(state) {</div><div data-index="783"> // 添加新的快照</div><div data-index="784"> state.snapshotData[++state.snapshotIndex] = deepCopy(state.componentData)</div><div data-index="785"> // 在 undo 过程中,添加新的快照时,要将它后面的快照清理掉</div><div data-index="786"> if (state.snapshotIndex < state.snapshotData.length - 1) {</div><div data-index="787"> state.snapshotData = state.snapshotData.slice(0, state.snapshotIndex + 1)</div><div data-index="788"> }</div><div data-index="789">},</div><div data-index="790">```</div><div data-index="791">用一个数组来保存编辑器的快照数据。保存快照就是不停地执行 `push()` 操作,将当前的编辑器数据推入 `snapshotData` 数组,并增加快照索引 `snapshotIndex`。</div><div><br></div><div data-index="792">由于每一次添加快照都是将当前编辑器的所有组件数据推入 `snapshotData`,保存的快照数据越多占用的内存就越多。对此有两个解决方案:</div><div data-index="793">1. 限制快照步数,例如只能保存 50 步的快照数据。</div><div data-index="794">2. 保存快照只保存差异部分。</div><div><br></div><div data-index="795">**现在详细描述一下第二个解决方案**。</div><div><br></div><div data-index="796">假设依次往画布上添加 a b c d 四个组件,在原来的实现中,对应的 `snapshotData` 数据为:</div><div data-index="797">```js</div><div data-index="798">// snapshotData</div><div data-index="799">[</div><div data-index="800"> [a],</div><div data-index="801"> [a, b],</div><div data-index="802"> [a, b, c],</div><div data-index="803"> [a, b, c, d],</div><div data-index="804">]</div><div data-index="805">```</div><div data-index="806">从上面的代码可以发现,每一相邻的快照中,只有一个数据是不同的。所以我们可以为每一步的快照添加一个类型字段,用来表示此次操作是添加还是删除。</div><div><br></div><div data-index="807">那么上面添加四个组件的操作,所对应的 `snapshotData` 数据为:</div><div data-index="808">```</div><div data-index="809">// snapshotData</div><div data-index="810">[</div><div data-index="811"> [{ type: 'add', value: a }],</div><div data-index="812"> [{ type: 'add', value: b }],</div><div data-index="813"> [{ type: 'add', value: c }],</div><div data-index="814"> [{ type: 'add', value: d }],</div><div data-index="815">]</div><div data-index="816">```</div><div data-index="817">如果我们要删除 c 组件,那么 `snapshotData` 数据将变为:</div><div data-index="818">```</div><div data-index="819">// snapshotData</div><div data-index="820">[</div><div data-index="821"> [{ type: 'add', value: a }],</div><div data-index="822"> [{ type: 'add', value: b }],</div><div data-index="823"> [{ type: 'add', value: c }],</div><div data-index="824"> [{ type: 'add', value: d }],</div><div data-index="825"> [{ type: 'remove', value: c }],</div><div data-index="826">]</div><div data-index="827">```</div><div data-index="828">**那如何使用现在的快照数据呢**?</div><div><br></div><div data-index="829">我们需要遍历一遍快照数据,来生成编辑器的组件数据 `componentData`。假设在上面的数据基础上执行了 `undo` 撤销操作:</div><div data-index="830">```</div><div data-index="831">// snapshotData</div><div data-index="832">// 快照索引 snapshotIndex 此时为 3</div><div data-index="833">[</div><div data-index="834"> [{ type: 'add', value: a }],</div><div data-index="835"> [{ type: 'add', value: b }],</div><div data-index="836"> [{ type: 'add', value: c }],</div><div data-index="837"> [{ type: 'add', value: d }],</div><div data-index="838"> [{ type: 'remove', value: c }],</div><div data-index="839">]</div><div data-index="840">```</div><div data-index="841">1. `snapshotData[0]` 类型为 `add`,将组件 a 添加到 `componentData` 中,此时 `componentData` 为 `[a]`</div><div data-index="842">2. 依次类推 `[a, b]`</div><div data-index="843">3. `[a, b, c]`</div><div data-index="844">4. `[a, b, c, d]`</div><div><br></div><div data-index="845">如果这时执行 `redo` 重做操作,快照索引 `snapshotIndex` 变为 4。对应的快照数据类型为 `type: 'remove'`, 移除组件 c。则数组数据为 `[a, b, d]`。</div><div><br></div><div data-index="846">这种方法其实就是时间换空间,虽然每一次保存的快照数据只有一项,但每次都得遍历一遍所有的快照数据。两种方法都不完美,要使用哪种取决于你,目前我仍在使用第一种方法。</div><div><br></div><div data-index="847">## 总结</div><div data-index="848">从造轮子的角度来看,这是我目前造的第四个比较满意的轮子,其他三个为:</div><div data-index="849">* [nand2tetris](https://github.com/woai3c/nand2tetris)</div><div data-index="850">* [MIT6.828](https://github.com/woai3c/MIT6.828)</div><div data-index="851">* [mini-vue](https://github.com/woai3c/mini-vue)</div><div><br></div><div data-index="852">造轮子是一个很好的提升自己技术水平的方法,但造轮子一定要造有意义、有难度的轮子,并且同类型的轮子只造一个。造完轮子后,还需要写总结,最好输出成文章分享出去。</div><div><br></div><div data-index="853">## 参考资料</div><div data-index="854">* [snapping-demo](https://github.com/shenhudong/snapping-demo/wiki/corner-handle)</div><div data-index="855">* [processon](https://www.processon.com/)</div><div data-index="856">* [墨刀](https://modao.cc/)</div>
</div>
<div id="show-content" class="text-container markdown"></div>
</body>
</html>
html,
body {
height: 100%;
font-size: 0;
}
* {
margin: 0;
padding: 0;
font-family: 'Microsoft yahei';
}
body {
display: flex;
}
table {
border-collapse: collapse;
}
.text-container {
box-sizing: border-box;
width: 50%;
border: 1px solid #e6e6e6;
height: 100%;
font-size: 14px;
background: #f5f5f5;
padding: 10px;
line-height: 20px;
outline: none;
overflow: auto;
}
.text-container > div {
min-height: 20px;
}
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
::-webkit-scrollbar-thumb {
border-radius: 4px;
background-color: #ddd;
}
.markdown {
font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol;
font-size: 16px;
line-height: 1.5;
word-wrap: break-word;
background-color: #fff;
color: #24292f;
}
.markdown img {
max-width: 100%;
margin: 10px 0;
}
.markdown>:first-child {
margin-top: 0!important;
}
.markdown h1,
.markdown h2 {
border-bottom: 1px solid #eaecef;
padding-bottom: .3em;
}
.markdown h1,
.markdown h2,
.markdown h3,
.markdown h4,
.markdown h5,
.markdown h6 {
font-weight: 600;
line-height: 1.25;
margin-bottom: 16px;
margin-top: 24px;
}
.markdown div {
min-height: 20px;
}
code,
pre,
tt {
font-family: SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace;
font-size: 14px;
}
.markdown pre {
background-color: #f6f8fa;
border-radius: 3px;
font-size: 85%;
line-height: 1.45;
overflow: auto;
padding: 16px;
color: #24292e;
font-size: 14px;
font-family: SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace;
margin: 0;
margin-bottom: 1em;
}
.markdown code {
background-color: #f6f8fa;
border: 0;
display: inline;
line-height: inherit;
margin: 0;
max-width: auto;
overflow: visible;
padding: 0;
word-wrap: normal;
word-break: break-all;
}
.markdown blockquote {
background-color: #f6f8fa;
padding: 10px;
}
.markdown table {
display: block;
overflow: auto;
width: 100%;
margin-bottom: 15px;
}
.markdown table tr {
background-color: #fff;
}
.markdown table td,
.markdown table th {
border: 1px solid #dfe2e5;
padding: 6px 13px;
}
.markdown p {
margin-bottom: 1em;
}
.markdown li {
word-wrap: break-all;
}
.markdown ul li {
list-style: disc;
}
.markdown ol,
.markdown ul {
padding-left: 2em;
}
.markdown blockquote,
.markdown details,
.markdown dl,
.markdown ol,
.markdown p,
.markdown pre,
.markdown table,
.markdown ul {
margin-bottom: 16px;
margin-top: 0;
}
.markdown a {
color: #0366d6;
text-decoration: none;
}