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 编辑器双屏同步滚动 demo4</title>
<link rel="stylesheet" href="index.css">
</head>
<body>
<div id="editor" contenteditable class="text-container">
<div><br></div><div data-index="0">本文是对[《可视化拖拽组件库一些技术要点原理分析》](https://juejin.cn/post/6908502083075325959)的补充。上一篇文章主要讲解了以下几个功能点:</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><br></div><div data-index="14">现在这篇文章会在此基础上再补充 4 个功能点,分别是:</div><div data-index="15">* 拖拽旋转</div><div data-index="16">* 复制粘贴剪切</div><div data-index="17">* 数据交互</div><div data-index="18">* 发布</div><div><br></div><div data-index="19">和上篇文章一样,我已经将新功能的代码更新到了 github:</div><div data-index="20">* [github 项目地址](https://github.com/woai3c/visual-drag-demo)</div><div data-index="21">* [在线预览](https://woai3c.github.io/visual-drag-demo)</div><div><br></div><div data-index="22">**友善提醒**:建议结合源码一起阅读,效果更好(这个 DEMO 使用的是 Vue 技术栈)。</div><div><br></div><div data-index="23">## 14. 拖拽旋转</div><div data-index="24">在写上一篇文章时,原来的 DEMO 已经可以支持旋转功能了。但是这个旋转功能还有很多不完善的地方:</div><div data-index="25">1. 不支持拖拽旋转。</div><div data-index="26">2. 旋转后的放大缩小不正确。</div><div data-index="27">3. 旋转后的自动吸附不正确。</div><div data-index="28">4. 旋转后八个可伸缩点的光标不正确。</div><div><br></div><div data-index="29">这一小节,我们将逐一解决这四个问题。</div><div><br></div><div data-index="30">### 拖拽旋转</div><div data-index="31">拖拽旋转需要使用 [Math.atan2()](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Math/atan2) 函数。</div><div><br></div><div data-index="32">>Math.atan2() 返回从原点(0,0)到(x,y)点的线段与x轴正方向之间的平面角度(弧度值),也就是Math.atan2(y,x)。Math.atan2(y,x)中的y和x都是相对于圆点(0,0)的距离。</div><div><br></div><div data-index="33">简单的说就是以组件中心点为原点 `(centerX,centerY)`,用户按下鼠标时的坐标设为 `(startX,startY)`,鼠标移动时的坐标设为 `(curX,curY)`。旋转角度可以通过 `(startX,startY)` 和 `(curX,curY)` 计算得出。</div><div><br></div><div data-index="34"></div><div><br></div><div data-index="35">那我们如何得到从点 `(startX,startY)` 到点 `(curX,curY)` 之间的旋转角度呢?</div><div><br></div><div data-index="36">**第一步**,鼠标点击时的坐标设为 `(startX,startY)`:</div><div data-index="37">```js</div><div data-index="38">const startY = e.clientY</div><div data-index="39">const startX = e.clientX</div><div data-index="40">```</div><div data-index="41">**第二步**,算出组件中心点:</div><div data-index="42">```js</div><div data-index="43">// 获取组件中心点位置</div><div data-index="44">const rect = this.$el.getBoundingClientRect()</div><div data-index="45">const centerX = rect.left + rect.width / 2</div><div data-index="46">const centerY = rect.top + rect.height / 2</div><div data-index="47">```</div><div data-index="48">**第三步**,按住鼠标移动时的坐标设为 `(curX,curY)`:</div><div data-index="49">```js</div><div data-index="50">const curX = moveEvent.clientX</div><div data-index="51">const curY = moveEvent.clientY</div><div data-index="52">```</div><div data-index="53">**第四步**,分别算出 `(startX,startY)` 和 `(curX,curY)` 对应的角度,再将它们相减得出旋转的角度。另外,还需要注意的就是 `Math.atan2()` 方法的返回值是一个弧度,因此还需要将弧度转化为角度。所以完整的代码为:</div><div data-index="54">```js</div><div data-index="55">// 旋转前的角度</div><div data-index="56">const rotateDegreeBefore = Math.atan2(startY - centerY, startX - centerX) / (Math.PI / 180)</div><div data-index="57">// 旋转后的角度</div><div data-index="58">const rotateDegreeAfter = Math.atan2(curY - centerY, curX - centerX) / (Math.PI / 180)</div><div data-index="59">// 获取旋转的角度值, startRotate 为初始角度值</div><div data-index="60">pos.rotate = startRotate + rotateDegreeAfter - rotateDegreeBefore</div><div data-index="61">```</div><div data-index="62"></div><div><br></div><div data-index="63">### 放大缩小</div><div data-index="64">组件旋转后的放大缩小会有 BUG。</div><div><br></div><div data-index="65"></div><div><br></div><div data-index="66">从上图可以看到,放大缩小时会发生移位。另外伸缩的方向和我们拖动的方向也不对。造成这一 BUG 的原因是:当初设计放大缩小功能没有考虑到旋转的场景。所以无论旋转多少角度,放大缩小仍然是按没旋转时计算的。</div><div><br></div><div data-index="67">下面再看一个具体的示例:</div><div><br></div><div data-index="68"></div><div><br></div><div data-index="69">从上图可以看出,在没有旋转时,按住顶点往上拖动,只需用 `y2 - y1` 就可以得出拖动距离 `s`。这时将组件原来的高度加上 `s` 就能得出新的高度,同时将组件的 `top`、`left` 属性更新。</div><div><br></div><div data-index="70"></div><div><br></div><div data-index="71">现在旋转 180 度,如果这时拖住顶点往下拖动,我们期待的结果是组件高度增加。但这时计算的方式和原来没旋转时是一样的,所以结果和我们期待的相反,组件的高度将会变小(如果不理解这个现象,可以想像一下没有旋转的那张图,按住顶点往下拖动)。</div><div><br></div><div data-index="72"></div><div><br></div><div data-index="73">如何解决这个问题呢?我从 github 上的一个项目 [snapping-demo](https://github.com/shenhudong/snapping-demo/wiki/corner-handle) 找到了解决方案:将放大缩小和旋转角度关联起来。</div><div><br></div><div data-index="74">#### 解决方案</div><div><br></div><div data-index="75">下面是一个已旋转一定角度的矩形,假设现在拖动它左上方的点进行拉伸。</div><div><br></div><div data-index="76"></div><div><br></div><div data-index="77">现在我们将一步步分析如何得出拉伸后的组件的正确大小和位移。</div><div><br></div><div data-index="78">**第一步**,按下鼠标时通过组件的坐标(无论旋转多少度,组件的 `top` `left` 属性不变)和大小算出组件中心点:</div><div data-index="79">```js</div><div data-index="80">const center = {</div><div data-index="81"> x: style.left + style.width / 2,</div><div data-index="82"> y: style.top + style.height / 2,</div><div data-index="83">}</div><div data-index="84">```</div><div data-index="85">**第二步**,用**当前点击坐标**和组件中心点算出**当前点击坐标**的对称点坐标:</div><div data-index="86">```js</div><div data-index="87">// 获取画布位移信息</div><div data-index="88">const editorRectInfo = document.querySelector('#editor').getBoundingClientRect()</div><div><br></div><div data-index="89">// 当前点击坐标</div><div data-index="90">const curPoint = {</div><div data-index="91"> x: e.clientX - editorRectInfo.left,</div><div data-index="92"> y: e.clientY - editorRectInfo.top,</div><div data-index="93">}</div><div><br></div><div data-index="94">// 获取对称点的坐标</div><div data-index="95">const symmetricPoint = {</div><div data-index="96"> x: center.x - (curPoint.x - center.x),</div><div data-index="97"> y: center.y - (curPoint.y - center.y),</div><div data-index="98">}</div><div data-index="99">```</div><div data-index="100">**第三步**,摁住组件左上角进行拉伸时,通过当前鼠标实时坐标和对称点计算出新的组件中心点:</div><div data-index="101">```js</div><div data-index="102">const curPositon = {</div><div data-index="103"> x: moveEvent.clientX - editorRectInfo.left,</div><div data-index="104"> y: moveEvent.clientY - editorRectInfo.top,</div><div data-index="105">}</div><div><br></div><div data-index="106">const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)</div><div><br></div><div data-index="107">// 求两点之间的中点坐标</div><div data-index="108">function getCenterPoint(p1, p2) {</div><div data-index="109"> return {</div><div data-index="110"> x: p1.x + ((p2.x - p1.x) / 2),</div><div data-index="111"> y: p1.y + ((p2.y - p1.y) / 2),</div><div data-index="112"> }</div><div data-index="113">}</div><div data-index="114">```</div><div data-index="115">由于组件处于旋转状态,即使你知道了拉伸时移动的 `xy` 距离,也不能直接对组件进行计算。否则就会出现 BUG,移位或者放大缩小方向不正确。因此,我们需要在组件未旋转的情况下对其进行计算。</div><div><br></div><div data-index="116"></div><div><br></div><div data-index="117">**第四步**,根据已知的旋转角度、新的组件中心点、当前鼠标实时坐标可以算出**当前鼠标实时坐标** `currentPosition` 在未旋转时的坐标 `newTopLeftPoint`。同时也能根据已知的旋转角度、新的组件中心点、对称点算出**组件对称点** `sPoint` 在未旋转时的坐标 `newBottomRightPoint`。</div><div><br></div><div data-index="118">对应的计算公式如下:</div><div data-index="119">```js</div><div data-index="120">/**</div><div data-index="121"> * 计算根据圆心旋转后的点的坐标</div><div data-index="122"> * @param {Object} point 旋转前的点坐标</div><div data-index="123"> * @param {Object} center 旋转中心</div><div data-index="124"> * @param {Number} rotate 旋转的角度</div><div data-index="125"> * @return {Object} 旋转后的坐标</div><div data-index="126"> * https://www.zhihu.com/question/67425734/answer/252724399 旋转矩阵公式</div><div data-index="127"> */</div><div data-index="128">export function calculateRotatedPointCoordinate(point, center, rotate) {</div><div data-index="129"> /**</div><div data-index="130"> * 旋转公式:</div><div data-index="131"> * 点a(x, y)</div><div data-index="132"> * 旋转中心c(x, y)</div><div data-index="133"> * 旋转后点n(x, y)</div><div data-index="134"> * 旋转角度θ tan ??</div><div data-index="135"> * nx = cosθ * (ax - cx) - sinθ * (ay - cy) + cx</div><div data-index="136"> * ny = sinθ * (ax - cx) + cosθ * (ay - cy) + cy</div><div data-index="137"> */</div><div><br></div><div data-index="138"> return {</div><div data-index="139"> x: (point.x - center.x) * Math.cos(angleToRadian(rotate)) - (point.y - center.y) * Math.sin(angleToRadian(rotate)) + center.x,</div><div data-index="140"> y: (point.x - center.x) * Math.sin(angleToRadian(rotate)) + (point.y - center.y) * Math.cos(angleToRadian(rotate)) + center.y,</div><div data-index="141"> }</div><div data-index="142">}</div><div data-index="143">```</div><div data-index="144">上面的公式涉及到线性代数中旋转矩阵的知识,对于一个没上过大学的人来说,实在太难了。还好我从知乎上的一个[回答](https://www.zhihu.com/question/67425734/answer/252724399)中找到了这一公式的推理过程,下面是回答的原文:</div><div><br></div><div data-index="145"></div><div><br></div><div data-index="146"></div><div><br></div><div data-index="147">通过以上几个计算值,就可以得到组件新的位移值 `top` `left` 以及新的组件大小。对应的完整代码如下:</div><div data-index="148">```js</div><div data-index="149">function calculateLeftTop(style, curPositon, pointInfo) {</div><div data-index="150"> const { symmetricPoint } = pointInfo</div><div data-index="151"> const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)</div><div data-index="152"> const newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate)</div><div data-index="153"> const newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)</div><div> </div><div data-index="154"> const newWidth = newBottomRightPoint.x - newTopLeftPoint.x</div><div data-index="155"> const newHeight = newBottomRightPoint.y - newTopLeftPoint.y</div><div data-index="156"> if (newWidth > 0 && newHeight > 0) {</div><div data-index="157"> style.width = Math.round(newWidth)</div><div data-index="158"> style.height = Math.round(newHeight)</div><div data-index="159"> style.left = Math.round(newTopLeftPoint.x)</div><div data-index="160"> style.top = Math.round(newTopLeftPoint.y)</div><div data-index="161"> }</div><div data-index="162">}</div><div data-index="163">```</div><div data-index="164">现在再来看一下旋转后的放大缩小:</div><div><br></div><div data-index="165"></div><div><br></div><div data-index="166">### 自动吸附</div><div data-index="167">自动吸附是根据组件的四个属性 `top` `left` `width` `height` 计算的,在将组件进行旋转后,这些属性的值是不会变的。所以无论组件旋转多少度,吸附时仍然按未旋转时计算。这样就会有一个问题,虽然实际上组件的 `top` `left` `width` `height` 属性没有变化。但在外观上却发生了变化。下面是两个同样的组件:一个没旋转,一个旋转了 45 度。</div><div><br></div><div data-index="168"></div><div><br></div><div data-index="169">可以看出来旋转后按钮的 `height` 属性和我们从外观上看到的高度是不一样的,所以在这种情况下就出现了吸附不正确的 BUG。</div><div><br></div><div data-index="170"></div><div><br></div><div data-index="171">#### 解决方案</div><div data-index="172">如何解决这个问题?我们需要拿组件旋转后的大小及位移来做吸附对比。也就是说不要拿组件实际的属性来对比,而是拿我们看到的大小和位移做对比。</div><div><br></div><div data-index="173"></div><div><br></div><div data-index="174">从上图可以看出,旋转后的组件在 x 轴上的投射长度为两条红线长度之和。这两条红线的长度可以通过正弦和余弦算出,左边的红线用正弦计算,右边的红线用余弦计算:</div><div data-index="175">```js</div><div data-index="176">const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)</div><div data-index="177">```</div><div data-index="178">同理,高度也是一样:</div><div data-index="179">```js</div><div data-index="180">const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)</div><div data-index="181">```</div><div data-index="182">新的宽度和高度有了,再根据组件原有的 `top` `left` 属性,可以得出组件旋转后新的 `top` `left` 属性。下面附上完整代码:</div><div data-index="183">```js</div><div data-index="184">translateComponentStyle(style) {</div><div data-index="185"> style = { ...style }</div><div data-index="186"> if (style.rotate != 0) {</div><div data-index="187"> const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)</div><div data-index="188"> const diffX = (style.width - newWidth) / 2</div><div data-index="189"> style.left += diffX</div><div data-index="190"> style.right = style.left + newWidth</div><div><br></div><div data-index="191"> const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)</div><div data-index="192"> const diffY = (newHeight - style.height) / 2</div><div data-index="193"> style.top -= diffY</div><div data-index="194"> style.bottom = style.top + newHeight</div><div><br></div><div data-index="195"> style.width = newWidth</div><div data-index="196"> style.height = newHeight</div><div data-index="197"> } else {</div><div data-index="198"> style.bottom = style.top + style.height</div><div data-index="199"> style.right = style.left + style.width</div><div data-index="200"> }</div><div><br></div><div data-index="201"> return style</div><div data-index="202">}</div><div data-index="203">```</div><div data-index="204">经过修复后,吸附也可以正常显示了。</div><div><br></div><div data-index="205"></div><div><br></div><div><br></div><div data-index="206">### 光标</div><div data-index="207">光标和可拖动的方向不对,是因为八个点的光标是固定设置的,没有随着角度变化而变化。</div><div><br></div><div data-index="208"></div><div><br></div><div data-index="209">#### 解决方案</div><div data-index="210">由于 `360 / 8 = 45`,所以可以为每一个方向分配 45 度的范围,每个范围对应一个光标。同时为每个方向设置一个初始角度,也就是未旋转时组件每个方向对应的角度。</div><div><br></div><div data-index="211"></div><div><br></div><div data-index="212">```js</div><div data-index="213">pointList: ['lt', 't', 'rt', 'r', 'rb', 'b', 'lb', 'l'], // 八个方向</div><div data-index="214">initialAngle: { // 每个点对应的初始角度</div><div data-index="215"> lt: 0,</div><div data-index="216"> t: 45,</div><div data-index="217"> rt: 90,</div><div data-index="218"> r: 135,</div><div data-index="219"> rb: 180,</div><div data-index="220"> b: 225,</div><div data-index="221"> lb: 270,</div><div data-index="222"> l: 315,</div><div data-index="223">},</div><div data-index="224">angleToCursor: [ // 每个范围的角度对应的光标</div><div data-index="225"> { start: 338, end: 23, cursor: 'nw' },</div><div data-index="226"> { start: 23, end: 68, cursor: 'n' },</div><div data-index="227"> { start: 68, end: 113, cursor: 'ne' },</div><div data-index="228"> { start: 113, end: 158, cursor: 'e' },</div><div data-index="229"> { start: 158, end: 203, cursor: 'se' },</div><div data-index="230"> { start: 203, end: 248, cursor: 's' },</div><div data-index="231"> { start: 248, end: 293, cursor: 'sw' },</div><div data-index="232"> { start: 293, end: 338, cursor: 'w' },</div><div data-index="233">],</div><div data-index="234">cursors: {},</div><div data-index="235">```</div><div data-index="236">计算方式也很简单:</div><div data-index="237">1. 假设现在组件已旋转了一定的角度 a。</div><div data-index="238">2. 遍历八个方向,用每个方向的初始角度 + a 得出现在的角度 b。</div><div data-index="239">3. 遍历 `angleToCursor` 数组,看看 b 在哪一个范围中,然后将对应的光标返回。</div><div><br></div><div data-index="240">经常上面三个步骤就可以计算出组件旋转后正确的光标方向。具体的代码如下:</div><div data-index="241">```js</div><div data-index="242">getCursor() {</div><div data-index="243"> const { angleToCursor, initialAngle, pointList, curComponent } = this</div><div data-index="244"> const rotate = (curComponent.style.rotate + 360) % 360 // 防止角度有负数,所以 + 360</div><div data-index="245"> const result = {}</div><div data-index="246"> let lastMatchIndex = -1 // 从上一个命中的角度的索引开始匹配下一个,降低时间复杂度</div><div data-index="247"> pointList.forEach(point => {</div><div data-index="248"> const angle = (initialAngle[point] + rotate) % 360</div><div data-index="249"> const len = angleToCursor.length</div><div data-index="250"> while (true) {</div><div data-index="251"> lastMatchIndex = (lastMatchIndex + 1) % len</div><div data-index="252"> const angleLimit = angleToCursor[lastMatchIndex]</div><div data-index="253"> if (angle < 23 || angle >= 338) {</div><div data-index="254"> result[point] = 'nw-resize'</div><div data-index="255"> return</div><div data-index="256"> }</div><div><br></div><div data-index="257"> if (angleLimit.start <= angle && angle < angleLimit.end) {</div><div data-index="258"> result[point] = angleLimit.cursor + '-resize'</div><div data-index="259"> return</div><div data-index="260"> }</div><div data-index="261"> }</div><div data-index="262"> })</div><div><br></div><div data-index="263"> return result</div><div data-index="264">},</div><div data-index="265">```</div><div data-index="266"></div><div><br></div><div data-index="267">从上面的动图可以看出来,现在八个方向上的光标是可以正确显示的。</div><div data-index="268">## 15. 复制粘贴剪切</div><div data-index="269">相对于拖拽旋转功能,复制粘贴就比较简单了。</div><div data-index="270">```js</div><div data-index="271">const ctrlKey = 17, vKey = 86, cKey = 67, xKey = 88</div><div data-index="272">let isCtrlDown = false</div><div><br></div><div data-index="273">window.onkeydown = (e) => {</div><div data-index="274"> if (e.keyCode == ctrlKey) {</div><div data-index="275"> isCtrlDown = true</div><div data-index="276"> } else if (isCtrlDown && e.keyCode == cKey) {</div><div data-index="277"> this.$store.commit('copy')</div><div data-index="278"> } else if (isCtrlDown && e.keyCode == vKey) {</div><div data-index="279"> this.$store.commit('paste')</div><div data-index="280"> } else if (isCtrlDown && e.keyCode == xKey) {</div><div data-index="281"> this.$store.commit('cut')</div><div data-index="282"> }</div><div data-index="283">}</div><div><br></div><div data-index="284">window.onkeyup = (e) => {</div><div data-index="285"> if (e.keyCode == ctrlKey) {</div><div data-index="286"> isCtrlDown = false</div><div data-index="287"> }</div><div data-index="288">}</div><div data-index="289">```</div><div data-index="290">监听用户的按键操作,在按下特定按键时触发对应的操作。</div><div><br></div><div data-index="291">### 复制操作</div><div data-index="292">在 vuex 中使用 `copyData` 来表示复制的数据。当用户按下 `ctrl + c` 时,将当前组件数据深拷贝到 `copyData`。</div><div data-index="293">```js</div><div data-index="294">copy(state) {</div><div data-index="295"> state.copyData = {</div><div data-index="296"> data: deepCopy(state.curComponent),</div><div data-index="297"> index: state.curComponentIndex,</div><div data-index="298"> }</div><div data-index="299">},</div><div data-index="300">```</div><div data-index="301">同时需要将当前组件在组件数据中的索引记录起来,在剪切中要用到。</div><div data-index="302">### 粘贴操作</div><div data-index="303">```js</div><div data-index="304">paste(state, isMouse) {</div><div data-index="305"> if (!state.copyData) {</div><div data-index="306"> toast('请选择组件')</div><div data-index="307"> return</div><div data-index="308"> }</div><div><br></div><div data-index="309"> const data = state.copyData.data</div><div><br></div><div data-index="310"> if (isMouse) {</div><div data-index="311"> data.style.top = state.menuTop</div><div data-index="312"> data.style.left = state.menuLeft</div><div data-index="313"> } else {</div><div data-index="314"> data.style.top += 10</div><div data-index="315"> data.style.left += 10</div><div data-index="316"> }</div><div><br></div><div data-index="317"> data.id = generateID()</div><div data-index="318"> store.commit('addComponent', { component: data })</div><div data-index="319"> store.commit('recordSnapshot')</div><div data-index="320"> state.copyData = null</div><div data-index="321">},</div><div data-index="322">```</div><div data-index="323">粘贴时,如果是按键操作 `ctrl+v`。则将组件的 `top` `left` 属性加 10,以免和原来的组件重叠在一起。如果是使用鼠标右键执行粘贴操作,则将复制的组件放到鼠标点击处。 </div><div><br></div><div data-index="324">### 剪切操作</div><div data-index="325">```js</div><div data-index="326">cut(state) {</div><div data-index="327"> if (!state.curComponent) {</div><div data-index="328"> toast('请选择组件')</div><div data-index="329"> return</div><div data-index="330"> }</div><div><br></div><div data-index="331"> if (state.copyData) {</div><div data-index="332"> store.commit('addComponent', { component: state.copyData.data, index: state.copyData.index })</div><div data-index="333"> if (state.curComponentIndex >= state.copyData.index) {</div><div data-index="334"> // 如果当前组件索引大于等于插入索引,需要加一,因为当前组件往后移了一位</div><div data-index="335"> state.curComponentIndex++</div><div data-index="336"> }</div><div data-index="337"> }</div><div><br></div><div data-index="338"> store.commit('copy')</div><div data-index="339"> store.commit('deleteComponent')</div><div data-index="340">},</div><div data-index="341">```</div><div data-index="342">剪切操作本质上还是复制,只不过在执行复制后,需要将当前组件删除。为了避免用户执行剪切操作后,不执行粘贴操作,而是继续执行剪切。这时就需要将原先剪切的数据进行恢复。所以复制数据中记录的索引就起作用了,可以通过索引将原来的数据恢复到原来的位置中。</div><div><br></div><div data-index="343">### 右键操作</div><div data-index="344">右键操作和按键操作是一样的,一个功能两种触发途径。</div><div data-index="345">```html</div><div data-index="346"><li @click="copy" v-show="curComponent">复制</li></div><div data-index="347"><li @click="paste">粘贴</li></div><div data-index="348"><li @click="cut" v-show="curComponent">剪切</li></div><div><br></div><div data-index="349">cut() {</div><div data-index="350"> this.$store.commit('cut')</div><div data-index="351">},</div><div><br></div><div data-index="352">copy() {</div><div data-index="353"> this.$store.commit('copy')</div><div data-index="354">},</div><div><br></div><div data-index="355">paste() {</div><div data-index="356"> this.$store.commit('paste', true)</div><div data-index="357">},</div><div data-index="358">```</div><div data-index="359">## 16. 数据交互</div><div data-index="360">### 方式一</div><div data-index="361">提前写好一系列 ajax 请求API,点击组件时按需选择 API,选好 API 再填参数。例如下面这个组件,就展示了如何使用 ajax 请求向后台交互:</div><div data-index="362">```html</div><div data-index="363"><template></div><div data-index="364"> <div>{{ propValue.data }}</div></div><div data-index="365"></template></div><div><br></div><div data-index="366"><script></div><div data-index="367">export default {</div><div data-index="368"> // propValue: {</div><div data-index="369"> // api: {</div><div data-index="370"> // request: a,</div><div data-index="371"> // params,</div><div data-index="372"> // },</div><div data-index="373"> // data: null</div><div data-index="374"> // }</div><div data-index="375"> props: {</div><div data-index="376"> propValue: {</div><div data-index="377"> type: Object,</div><div data-index="378"> default: () => {},</div><div data-index="379"> },</div><div data-index="380"> },</div><div data-index="381"> created() {</div><div data-index="382"> this.propValue.api.request(this.propValue.api.params).then(res => {</div><div data-index="383"> this.propValue.data = res.data</div><div data-index="384"> })</div><div data-index="385"> },</div><div data-index="386">}</div><div data-index="387"></script></div><div data-index="388">```</div><div data-index="389">### 方式二</div><div data-index="390">方式二适合纯展示的组件,例如有一个报警组件,可以根据后台传来的数据显示对应的颜色。在编辑页面的时候,可以通过 ajax 向后台请求页面能够使用的 websocket 数据:</div><div data-index="391">```js</div><div data-index="392">const data = ['status', 'text'...]</div><div data-index="393">```</div><div data-index="394">然后再为不同的组件添加上不同的属性。例如有 a 组件,它绑定的属性为 `status`。</div><div data-index="395">```</div><div data-index="396">// 组件能接收的数据</div><div data-index="397">props: {</div><div data-index="398"> propValue: {</div><div data-index="399"> type: String,</div><div data-index="400"> },</div><div data-index="401"> element: {</div><div data-index="402"> type: Object,</div><div data-index="403"> },</div><div data-index="404"> wsKey: {</div><div data-index="405"> type: String,</div><div data-index="406"> default: '',</div><div data-index="407"> },</div><div data-index="408">},</div><div data-index="409">```</div><div data-index="410">在组件中通过 `wsKey` 获取这个绑定的属性。等页面发布后或者预览时,通过 weboscket 向后台请求全局数据放在 vuex 上。组件就可以通过 `wsKey` 访问数据了。</div><div data-index="411">```html</div><div data-index="412"><template></div><div data-index="413"> <div>{{ wsData[wsKey] }}</div></div><div data-index="414"></template></div><div><br></div><div data-index="415"><script></div><div data-index="416">import { mapState } from 'vuex'</div><div><br></div><div data-index="417">export default {</div><div data-index="418"> props: {</div><div data-index="419"> propValue: {</div><div data-index="420"> type: String,</div><div data-index="421"> },</div><div data-index="422"> element: {</div><div data-index="423"> type: Object,</div><div data-index="424"> },</div><div data-index="425"> wsKey: {</div><div data-index="426"> type: String,</div><div data-index="427"> default: '',</div><div data-index="428"> },</div><div data-index="429"> },</div><div data-index="430"> computed: mapState([</div><div data-index="431"> 'wsData',</div><div data-index="432"> ]),</div><div data-index="433"></script></div><div data-index="434">```</div><div><br></div><div data-index="435">和后台交互的方式有很多种,不仅仅包括上面两种,我在这里仅提供一些思路,以供参考。</div><div data-index="436">## 17. 发布</div><div data-index="437">页面发布有两种方式:一是将组件数据渲染为一个单独的 HTML 页面;二是从本项目中抽取出一个最小运行时 runtime 作为一个单独的项目。</div><div><br></div><div data-index="438">这里说一下第二种方式,本项目中的最小运行时其实就是预览页面加上自定义组件。将这些代码提取出来作为一个项目单独打包。发布页面时将组件数据以 JSON 的格式传给服务端,同时为每个页面生成一个唯一 ID。</div><div><br></div><div data-index="439">假设现在有三个页面,发布页面生成的 ID 为 a、b、c。访问页面时只需要把 ID 带上,这样就可以根据 ID 获取每个页面对应的组件数据。</div><div data-index="440">```js</div><div data-index="441">www.test.com/?id=a</div><div data-index="442">www.test.com/?id=c</div><div data-index="443">www.test.com/?id=b</div><div data-index="444">```</div><div data-index="445">### 按需加载</div><div data-index="446">如果自定义组件过大,例如有数十个甚至上百个。这时可以将自定义组件用 `import` 的方式导入,做到按需加载,减少首屏渲染时间:</div><div data-index="447">```js</div><div data-index="448">import Vue from 'vue'</div><div><br></div><div data-index="449">const components = [</div><div data-index="450"> 'Picture',</div><div data-index="451"> 'VText',</div><div data-index="452"> 'VButton',</div><div data-index="453">]</div><div><br></div><div data-index="454">components.forEach(key => {</div><div data-index="455"> Vue.component(key, () => import(`@/custom-component/${key}`))</div><div data-index="456">})</div><div data-index="457">```</div><div data-index="458">### 按版本发布</div><div data-index="459">自定义组件有可能会有更新的情况。例如原来的组件使用了大半年,现在有功能变更,为了不影响原来的页面。建议在发布时带上组件的版本号:</div><div data-index="460">```</div><div data-index="461">- v-text</div><div data-index="462"> - v1.vue</div><div data-index="463"> - v2.vue</div><div data-index="464">```</div><div data-index="465">例如 `v-text` 组件有两个版本,在左侧组件列表区使用时就可以带上版本号:</div><div data-index="466">```js</div><div data-index="467">{</div><div data-index="468"> component: 'v-text',</div><div data-index="469"> version: 'v1'</div><div data-index="470"> ...</div><div data-index="471">}</div><div data-index="472">```</div><div data-index="473">这样导入组件时就可以根据组件版本号进行导入:</div><div data-index="474">```js</div><div data-index="475">import Vue from 'vue'</div><div data-index="476">import componentList from '@/custom-component/component-list`</div><div><br></div><div data-index="477">componentList.forEach(component => {</div><div data-index="478"> Vue.component(component.name, () => import(`@/custom-component/${component.name}/${component.version}`))</div><div data-index="479">})</div><div data-index="480">```</div><div><br></div><div data-index="481">## 参考资料</div><div data-index="482">* [Math](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Math)</div><div data-index="483">* [通过Math.atan2 计算角度](https://www.jianshu.com/p/9817e267925a)</div><div data-index="484">* [为什么矩阵能用来表示角的旋转?](https://www.zhihu.com/question/67425734/answer/252724399)</div><div data-index="485">* [snapping-demo](https://github.com/shenhudong/snapping-demo/wiki/corner-handle)</div><div data-index="486">* [vue-next-drag](https://github.com/lycHub/vue-next-drag)</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;
}