Эта статья является третьей статьей в серии о визуальном перетаскивании.В предыдущих двух статьях были проанализированы технические принципы работы 17 функциональных точек:
- редактор
- пользовательский компонент
- тянуть
- Удалить компоненты, настроить уровни слоев
- приблизить
- отменить повторить
- Настройки свойств компонента
- адсорбция
- Предварительный просмотр и сохранение кода
- привязать событие
- Связать анимацию
- Импорт PSD
- режим телефона
- перетащите, чтобы повернуть
- копировать вставить вырезать
- Взаимодействие данных
- выпускать
Исходя из этого, в данной статье будут проанализированы технические принципы работы следующих функциональных точек:
- Объединение и разделение нескольких компонентов
- текстовый компонент
- Прямоугольный компонент
- замок в сборе
- горячая клавиша
- Линии сетки
- Еще один способ реализации снимков редактора
Если вы мало знаете о двух моих предыдущих статьях, я предлагаю вам сначала прочитать эти две статьи, а затем прочитать эту статью:
- Принципиальный анализ некоторых технических моментов библиотеки компонентов визуального перетаскивания
- Принципиальный анализ некоторых технических моментов библиотеки компонентов визуального перетаскивания (2)
Хотя моя библиотека компонентов с визуальным перетаскиванием — всего лишь ДЕМО, я сравнил некоторые готовые продукты на рынке (такие какprocesson,чернильный нож), что касается основных функций, то мой DEMO реализует большинство функций.
Если вам интересны low-code платформы, но вы ничего об этом не знаете. Настоятельно рекомендуется прочитать три мои статьи вместе с исходным кодом проекта, я уверен, что это определенно принесет вам много пользы. Также прикрепите проект, адрес онлайн DEMO:
18. Объединение и разделение нескольких компонентов
Существует относительно много технических моментов для объединения и разделения, в том числе следующие четыре:
- выбранная область
- Движение и вращение после комбинации
- Увеличение и уменьшение масштаба после комбинации
- Восстановление стилей подкомпонентов после разделения
выбранная область
Перед объединением нескольких компонентов их необходимо сначала выбрать. С помощью событий мыши можно легко отобразить выбранную область:
-
mousedown
записать координаты начальной точки -
mousemove
Вычислите текущую координату и координату начальной точки, чтобы получить движущуюся область - Если вы нажмете мышь и переместитесь в левый верхний угол, аналогично этой операции, вам нужно установить текущую координату в качестве координаты начальной точки, а затем вычислить область перемещения.
// 获取编辑器的位移信息
const rectInfo = this.editor.getBoundingClientRect()
this.editorX = rectInfo.x
this.editorY = rectInfo.y
const startX = e.clientX
const startY = e.clientY
this.start.x = startX - this.editorX
this.start.y = startY - this.editorY
// 展示选中区域
this.isShowArea = true
const move = (moveEvent) => {
this.width = Math.abs(moveEvent.clientX - startX)
this.height = Math.abs(moveEvent.clientY - startY)
if (moveEvent.clientX < startX) {
this.start.x = moveEvent.clientX - this.editorX
}
if (moveEvent.clientY < startY) {
this.start.y = moveEvent.clientY - this.editorY
}
}
существуетmouseup
Когда событие инициировано, необходимо вычислить информацию о размере смещения всех компонентов в выбранной области, чтобы получить минимальную область, которая может содержать все компоненты в области. Этот эффект показан на следующем рисунке:
Код для этого процесса расчета:
createGroup() {
// 获取选中区域的组件数据
const areaData = this.getSelectArea()
if (areaData.length <= 1) {
this.hideArea()
return
}
// 根据选中区域和区域中每个组件的位移信息来创建 Group 组件
// 要遍历选择区域的每个组件,获取它们的 left top right bottom 信息来进行比较
let top = Infinity, left = Infinity
let right = -Infinity, bottom = -Infinity
areaData.forEach(component => {
let style = {}
if (component.component == 'Group') {
component.propValue.forEach(item => {
const rectInfo = $(`#component${item.id}`).getBoundingClientRect()
style.left = rectInfo.left - this.editorX
style.top = rectInfo.top - this.editorY
style.right = rectInfo.right - this.editorX
style.bottom = rectInfo.bottom - this.editorY
if (style.left < left) left = style.left
if (style.top < top) top = style.top
if (style.right > right) right = style.right
if (style.bottom > bottom) bottom = style.bottom
})
} else {
style = getComponentRotatedStyle(component.style)
}
if (style.left < left) left = style.left
if (style.top < top) top = style.top
if (style.right > right) right = style.right
if (style.bottom > bottom) bottom = style.bottom
})
this.start.x = left
this.start.y = top
this.width = right - left
this.height = bottom - top
// 设置选中区域位移大小信息和区域内的组件数据
this.$store.commit('setAreaData', {
style: {
left,
top,
width: this.width,
height: this.height,
},
components: areaData,
})
},
getSelectArea() {
const result = []
// 区域起点坐标
const { x, y } = this.start
// 计算所有的组件数据,判断是否在选中区域内
this.componentData.forEach(component => {
if (component.isLock) return
const { left, top, width, height } = component.style
if (x <= left && y <= top && (left + width <= x + this.width) && (top + height <= y + this.height)) {
result.push(component)
}
})
// 返回在选中区域内的所有组件
return result
}
Кратко опишите логику обработки этого кода:
- использоватьgetBoundingClientRect()API браузера получает информацию о каждом компоненте в четырех направлениях относительно области просмотра браузера, то есть:
left
top
right
bottom
. - Сравнив четыре информации о каждом компоненте, получите значения самого левого, самого верхнего, самого правого и самого нижнего направлений выбранной области, чтобы получить минимальную область, которая может содержать все компоненты в области.
- Если в выбранной области уже есть
Group
Для составного компонента нужно рассчитывать вложенные в него компоненты, а не составной компонент.
Движение и вращение после комбинации
Чтобы облегчить операцию перемещения, вращения, увеличения и уменьшения масштаба нескольких компонентов вместе, я создал новыйGroup
Сочетание компонентов:
<template>
<div class="group">
<div>
<template v-for="item in propValue">
<component
class="component"
:is="item.component"
:style="item.groupStyle"
:propValue="item.propValue"
:key="item.id"
:id="'component' + item.id"
:element="item"
/>
</template>
</div>
</div>
</template>
<script>
import { getStyle } from '@/utils/style'
export default {
props: {
propValue: {
type: Array,
default: () => [],
},
element: {
type: Object,
},
},
created() {
const parentStyle = this.element.style
this.propValue.forEach(component => {
// component.groupStyle 的 top left 是相对于 group 组件的位置
// 如果已存在 component.groupStyle,说明已经计算过一次了。不需要再次计算
if (!Object.keys(component.groupStyle).length) {
const style = { ...component.style }
component.groupStyle = getStyle(style)
component.groupStyle.left = this.toPercent((style.left - parentStyle.left) / parentStyle.width)
component.groupStyle.top = this.toPercent((style.top - parentStyle.top) / parentStyle.height)
component.groupStyle.width = this.toPercent(style.width / parentStyle.width)
component.groupStyle.height = this.toPercent(style.height / parentStyle.height)
}
})
},
methods: {
toPercent(val) {
return val * 100 + '%'
},
},
}
</script>
<style lang="scss" scoped>
.group {
& > div {
position: relative;
width: 100%;
height: 100%;
.component {
position: absolute;
}
}
}
</style>
Group
Роль компонента состоит в том, чтобы поместить компоненты в область под ним и стать подкомпонентами. и создаетGroup
компонент, получить каждый дочерний компонент вGroup
Относительное смещение и относительный размер внутри компонентов:
created() {
const parentStyle = this.element.style
this.propValue.forEach(component => {
// component.groupStyle 的 top left 是相对于 group 组件的位置
// 如果已存在 component.groupStyle,说明已经计算过一次了。不需要再次计算
if (!Object.keys(component.groupStyle).length) {
const style = { ...component.style }
component.groupStyle = getStyle(style)
component.groupStyle.left = this.toPercent((style.left - parentStyle.left) / parentStyle.width)
component.groupStyle.top = this.toPercent((style.top - parentStyle.top) / parentStyle.height)
component.groupStyle.width = this.toPercent(style.width / parentStyle.width)
component.groupStyle.height = this.toPercent(style.height / parentStyle.height)
}
})
},
methods: {
toPercent(val) {
return val * 100 + '%'
},
},
То есть подкомпонентleft
top
width
height
и другие атрибуты преобразуются в%
Относительное значение в конце.
Почему бы не использовать абсолютные числа?
Если используются абсолютные значения, тоGroup
компоненты, за исключениемGroup
Помимо расчета свойств компонента, его также необходимо рассчитать для каждого из его подкомпонентов. а такжеGroup
Если подкомпонентов слишком много, при перемещении, увеличении и уменьшении масштаба объем вычислений будет очень большим, что может привести к зависанию страницы. Если вы измените его на относительное значение, вам нужно толькоGroup
Вычисляется один раз при создании. затем вGroup
Неважно, когда компонент движется или вращаетсяGroup
Подкомпонент вычисляет только сам себя.
Увеличение и уменьшение масштаба после комбинации
Увеличение и уменьшение масштаба после комбинации является большой проблемой, в основном из-за наличия угла поворота. Во-первых, давайте посмотрим на увеличение и уменьшение масштаба каждого подкомпонента, когда он не вращается:
Как видно из анимации, эффект идеальный. За размером каждого подкомпонента следуетGroup
Размер компонента меняется.
Теперь попробуйте добавить угол поворота дочернему компоненту и снова увидите эффект:
почему возникает эта проблема?
В основном из-за того, что компонент повернут или нет, егоtop
left
Свойства неизменны. Тогда есть проблема, хотя на самом деле компонентtop
left
width
height
Свойства не изменились. Но внешний вид изменился. Вот два одинаковых компонента: один без поворота и один повернутый на 45 градусов.
Видно, что кнопка после поворотаtop
left
width
height
Свойства не такие, как то, что мы видим по внешнему виду.
Давайте рассмотрим конкретный пример:
выше этоGroup
компонент, свойства его левого подкомпонента:
transform: rotate(-75.1967deg);
width: 51.2267%;
height: 32.2679%;
top: 33.8661%;
left: -10.6496%;
можно увидетьwidth
ценность51.2267%
, но с точки зрения внешнего вида на этот подкомпонент приходится больше всегоGroup
Одна треть ширины компонента. Так вот где проблема с приближением и отдалением это не нормально.
Возможное решение (не хотите видеть, можно пропустить)
Сначала я подумал, сначала вычислить его относительно области просмотра браузераtop
left
width
height
свойства, а затем вычислить эти свойства вGroup
Относительное значение компонента. Это можно сделать с помощьюgetBoundingClientRect()
Реализация API. До тех пор, пока доля каждого атрибута во внешности остается неизменной, поэтомуGroup
Когда компонент увеличивается и уменьшается, он использует угол поворота, чтобы использовать знание матрицы поворота (это подробно описано во второй части), чтобы получить его до того, как он будет повернут.top
left
width
height
Атрибуты. Таким образом, подкомпоненты можно динамически регулировать.
Но есть проблема, т.getBoundingClientRect()
API может получить только внешний вид компонентаtop
left
right
bottom
width
height
Атрибуты. Кроме угла еще параметров маловато, поэтому вычислить реальную составляющую невозможноtop
left
width
height
Атрибуты.
Как и на картинке выше, известно только происхождениеO(x,y)
w
h
А угол поворота, ширину и высоту кнопки рассчитать нельзя.
работоспособное решение
Я наткнулся на это случайно,Group
При увеличении и уменьшении масштаба компонента обнаруживается, что до тех пор, покаGroup
Соотношение ширины и высоты компонента, подкомпонента можно увеличивать и уменьшать в зависимости от пропорции. Итак, теперь проблема становитсякак сделатьGroup
Сохранение соотношения сторон при увеличении и уменьшении масштаба компонентов. Я нашел это в Интернетестатья, в котором подробно описано, как компонент поворота поддерживает соотношение сторон для увеличения и уменьшения масштаба, с примерами исходного кода.
Теперь я попытаюсь кратко описать, как сохранить соотношение сторон для увеличения и уменьшения масштаба вращающегося компонента (рекомендуется прочитать исходный текст). Ниже прямоугольник, который был повернут на угол, допустим, теперь он растянут путем перетаскивания его левой верхней точки.
первый шаг, рассчитать соотношение сторон компонента и координаты компонента при нажатии мыши (независимо от того, на сколько градусов он повернут, компонентtop
left
свойства остаются неизменными) и размер для вычисления центральной точки компонента:
// 组件宽高比
const proportion = style.width / style.height
const center = {
x: style.left + style.width / 2,
y: style.top + style.height / 2,
}
второй шаг,использоватьтекущие координаты кликаРассчитывается от центральной точки компонентатекущие координаты кликаКоординаты точки симметрии:
// 获取画布位移信息
const editorRectInfo = document.querySelector('#editor').getBoundingClientRect()
// 当前点击坐标
const curPoint = {
x: e.clientX - editorRectInfo.left,
y: e.clientY - editorRectInfo.top,
}
// 获取对称点的坐标
const symmetricPoint = {
x: center.x - (curPoint.x - center.x),
y: center.y - (curPoint.y - center.y),
}
третий шаг, при нажатии на левый верхний угол компонента для растяжения центральная точка нового компонента вычисляется по текущим координатам мыши в реальном времени и точке симметрии:
const curPositon = {
x: moveEvent.clientX - editorRectInfo.left,
y: moveEvent.clientY - editorRectInfo.top,
}
const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)
// 求两点之间的中点坐标
function getCenterPoint(p1, p2) {
return {
x: p1.x + ((p2.x - p1.x) / 2),
y: p1.y + ((p2.y - p1.y) / 2),
}
}
Поскольку компонент находится в повернутом состоянии, даже если вы знаетеxy
расстояние, и его нельзя рассчитать непосредственно на компоненте. В противном случае будут баги, а направление сдвига или приближения и отдаления будет неправильным. Поэтому нам нужно вычислить его без вращения компонента.
четвертый шаг, который можно рассчитать по известному углу поворота, новой центральной точке компонента и текущим координатам мыши в реальном времени.Текущие координаты мыши в реальном времени currentPosition
координаты без поворотаnewTopLeftPoint
. В то же время его также можно рассчитать на основе известного угла поворота, новой центральной точки компонента и точки симметрии.Симметрия компонентов sPoint
координаты без поворотаnewBottomRightPoint
.
Соответствующая формула расчета выглядит следующим образом:
/**
* 计算根据圆心旋转后的点的坐标
* @param {Object} point 旋转前的点坐标
* @param {Object} center 旋转中心
* @param {Number} rotate 旋转的角度
* @return {Object} 旋转后的坐标
* https://www.zhihu.com/question/67425734/answer/252724399 旋转矩阵公式
*/
export function calculateRotatedPointCoordinate(point, center, rotate) {
/**
* 旋转公式:
* 点a(x, y)
* 旋转中心c(x, y)
* 旋转后点n(x, y)
* 旋转角度θ tan ??
* nx = cosθ * (ax - cx) - sinθ * (ay - cy) + cx
* ny = sinθ * (ax - cx) + cosθ * (ay - cy) + cy
*/
return {
x: (point.x - center.x) * Math.cos(angleToRadian(rotate)) - (point.y - center.y) * Math.sin(angleToRadian(rotate)) + center.x,
y: (point.x - center.x) * Math.sin(angleToRadian(rotate)) + (point.y - center.y) * Math.cos(angleToRadian(rotate)) + center.y,
}
}
Приведенная выше формула предполагает знание матриц вращения в линейной алгебре, что слишком сложно для человека, не окончившего колледж. К счастью, я получил один от Zhihu.отвечатьПроцесс рассуждения этой формулы был найден в , и ниже приводится исходный текст ответа:
С помощью приведенных выше расчетных значений можно получить новое значение смещения компонента.top
left
и новый размер компонента. Соответствующий полный код выглядит следующим образом:
function calculateLeftTop(style, curPositon, pointInfo) {
const { symmetricPoint } = pointInfo
const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)
const newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate)
const newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)
const newWidth = newBottomRightPoint.x - newTopLeftPoint.x
const newHeight = newBottomRightPoint.y - newTopLeftPoint.y
if (newWidth > 0 && newHeight > 0) {
style.width = Math.round(newWidth)
style.height = Math.round(newHeight)
style.left = Math.round(newTopLeftPoint.x)
style.top = Math.round(newTopLeftPoint.y)
}
}
Теперь давайте посмотрим на увеличение вращения:
пятый шаг, поскольку сейчас нам нужно зафиксировать соотношение сторон для увеличения и уменьшения масштаба, нам нужно пересчитать координаты верхнего левого угла растянутой графики.
Вот названия нескольких фигур:
- Оригинальная графика: красная часть
- Новая графика: синие детали
- Исправленная графика: зеленая часть, то есть исправленная графика с добавленным правилом блокировки соотношения сторон
На четвертом шаге вычислите невращающуюся составляющуюnewTopLeftPoint
newBottomRightPoint
newWidth
newHeight
После этого нужно исходить из соотношения сторонproportion
для вычисления новой ширины или высоты.
На приведенном выше рисунке показан пример необходимости изменения высоты.Процесс расчета выглядит следующим образом:
if (newWidth / newHeight > proportion) {
newTopLeftPoint.x += Math.abs(newWidth - newHeight * proportion)
newWidth = newHeight * proportion
} else {
newTopLeftPoint.y += Math.abs(newHeight - newWidth / proportion)
newHeight = newWidth / proportion
}
Поскольку неповернутые координаты рассчитываются на основе координат до того, как ширина и высота не будут пропорционально уменьшены, после уменьшения ширины и высоты необходимо повернуть назад в соответствии с исходной центральной точкой, чтобы получить соответствующие координаты после уменьшенной ширины и высота поворачивается. Затем получите новую центральную точку с этой координатой и точкой симметрии и пересчитайте координаты до поворота.
Полный код после модификации выглядит следующим образом:
function calculateLeftTop(style, curPositon, proportion, needLockProportion, pointInfo) {
const { symmetricPoint } = pointInfo
let newCenterPoint = getCenterPoint(curPositon, symmetricPoint)
let newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate)
let newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)
let newWidth = newBottomRightPoint.x - newTopLeftPoint.x
let newHeight = newBottomRightPoint.y - newTopLeftPoint.y
if (needLockProportion) {
if (newWidth / newHeight > proportion) {
newTopLeftPoint.x += Math.abs(newWidth - newHeight * proportion)
newWidth = newHeight * proportion
} else {
newTopLeftPoint.y += Math.abs(newHeight - newWidth / proportion)
newHeight = newWidth / proportion
}
// 由于现在求的未旋转前的坐标是以没按比例缩减宽高前的坐标来计算的
// 所以缩减宽高后,需要按照原来的中心点旋转回去,获得缩减宽高并旋转后对应的坐标
// 然后以这个坐标和对称点获得新的中心点,并重新计算未旋转前的坐标
const rotatedTopLeftPoint = calculateRotatedPointCoordinate(newTopLeftPoint, newCenterPoint, style.rotate)
newCenterPoint = getCenterPoint(rotatedTopLeftPoint, symmetricPoint)
newTopLeftPoint = calculateRotatedPointCoordinate(rotatedTopLeftPoint, newCenterPoint, -style.rotate)
newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)
newWidth = newBottomRightPoint.x - newTopLeftPoint.x
newHeight = newBottomRightPoint.y - newTopLeftPoint.y
}
if (newWidth > 0 && newHeight > 0) {
style.width = Math.round(newWidth)
style.height = Math.round(newHeight)
style.left = Math.round(newTopLeftPoint.x)
style.top = Math.round(newTopLeftPoint.y)
}
}
Эффект увеличения и уменьшения масштаба при сохранении пропорций выглядит следующим образом:
когдаGroup
Когда компонент имеет повернутые подкомпоненты, необходимо только сохранить соотношение сторон для увеличения и уменьшения масштаба. Итак, созданиеGroup
Когда компонент используется, можно судить о том, имеет ли подкомпонент угол поворота. В противном случае нет необходимости поддерживать соотношение ширины для увеличения и уменьшения масштаба.
isNeedLockProportion() {
if (this.element.component != 'Group') return false
const ratates = [0, 90, 180, 360]
for (const component of this.element.propValue) {
if (!ratates.includes(mod360(parseInt(component.style.rotate)))) {
return true
}
}
return false
}
Восстановление стилей подкомпонентов после разделения
Объединение нескольких компонентов вместе — это только первый шаг, второй шаг — объединитьGroup
Компонент разделяется, и стили отдельных подкомпонентов восстанавливаются. Убедитесь, что свойства разделенных подкомпонентов не изменились.
Код расчета выглядит следующим образом:
// store
decompose({ curComponent, editor }) {
const parentStyle = { ...curComponent.style }
const components = curComponent.propValue
const editorRect = editor.getBoundingClientRect()
store.commit('deleteComponent')
components.forEach(component => {
decomposeComponent(component, editorRect, parentStyle)
store.commit('addComponent', { component })
})
}
// 将组合中的各个子组件拆分出来,并计算它们新的 style
export default function decomposeComponent(component, editorRect, parentStyle) {
// 子组件相对于浏览器视口的样式
const componentRect = $(`#component${component.id}`).getBoundingClientRect()
// 获取元素的中心点坐标
const center = {
x: componentRect.left - editorRect.left + componentRect.width / 2,
y: componentRect.top - editorRect.top + componentRect.height / 2,
}
component.style.rotate = mod360(component.style.rotate + parentStyle.rotate)
component.style.width = parseFloat(component.groupStyle.width) / 100 * parentStyle.width
component.style.height = parseFloat(component.groupStyle.height) / 100 * parentStyle.height
// 计算出元素新的 top left 坐标
component.style.left = center.x - component.style.width / 2
component.style.top = center.y - component.style.height / 2
component.groupStyle = {}
}
Логика обработки этого кода:
- траверс
Group
дочерние компоненты и восстановить их стили - использовать
getBoundingClientRect()
API для получения дочернего компонента относительно области просмотра браузераleft
top
width
height
Атрибуты. - Используйте эти четыре свойства для вычисления координат центральной точки дочернего компонента.
- из-за подкомпонентов
width
height
свойства относятся кGroup
компоненты, поэтому объедините их процентные значения иGroup
Умножьте, чтобы получить конкретное значение. - повторно использовать центральную точку
center(x, y)
Вычтите половину ширины и высоты дочернего компонента, чтобы получить егоleft
top
Атрибуты.
Пока объясняются комбинация и разделение.
19. Текстовые компоненты
текстовый компонентVText
Это было сделано раньше, но не идеально. Например, текст нельзя выделить. Теперь я переписал его для поддержки выбранной функции.
<template>
<div v-if="editMode == 'edit'" class="v-text" @keydown="handleKeydown" @keyup="handleKeyup">
<!-- tabindex >= 0 使得双击时聚集该元素 -->
<div :contenteditable="canEdit" :class="{ canEdit }" @dblclick="setEdit" :tabindex="element.id" @paste="clearStyle"
@mousedown="handleMousedown" @blur="handleBlur" ref="text" v-html="element.propValue" @input="handleInput"
:style="{ verticalAlign: element.style.verticalAlign }"
></div>
</div>
<div v-else class="v-text">
<div v-html="element.propValue" :style="{ verticalAlign: element.style.verticalAlign }"></div>
</div>
</template>
<script>
import { mapState } from 'vuex'
import { keycodes } from '@/utils/shortcutKey.js'
export default {
props: {
propValue: {
type: String,
require: true,
},
element: {
type: Object,
},
},
data() {
return {
canEdit: false,
ctrlKey: 17,
isCtrlDown: false,
}
},
computed: {
...mapState([
'editMode',
]),
},
methods: {
handleInput(e) {
this.$emit('input', this.element, e.target.innerHTML)
},
handleKeydown(e) {
if (e.keyCode == this.ctrlKey) {
this.isCtrlDown = true
} else if (this.isCtrlDown && this.canEdit && keycodes.includes(e.keyCode)) {
e.stopPropagation()
} else if (e.keyCode == 46) { // deleteKey
e.stopPropagation()
}
},
handleKeyup(e) {
if (e.keyCode == this.ctrlKey) {
this.isCtrlDown = false
}
},
handleMousedown(e) {
if (this.canEdit) {
e.stopPropagation()
}
},
clearStyle(e) {
e.preventDefault()
const clp = e.clipboardData
const text = clp.getData('text/plain') || ''
if (text !== '') {
document.execCommand('insertText', false, text)
}
this.$emit('input', this.element, e.target.innerHTML)
},
handleBlur(e) {
this.element.propValue = e.target.innerHTML || ' '
this.canEdit = false
},
setEdit() {
this.canEdit = true
// 全选
this.selectText(this.$refs.text)
},
selectText(element) {
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(element)
selection.removeAllRanges()
selection.addRange(range)
},
},
}
</script>
<style lang="scss" scoped>
.v-text {
width: 100%;
height: 100%;
display: table;
div {
display: table-cell;
width: 100%;
height: 100%;
outline: none;
}
.canEdit {
cursor: text;
height: 100%;
}
}
</style>
реконструированныйVText
Функции компонентов следующие:
- Дважды щелкните, чтобы начать редактирование.
- Выбранный текст поддерживается.
- Стиль для фильтрации текста при вставке.
- Автоматически увеличивать высоту текстового поля при переносе.
20. Компоненты прямоугольника
Компонент прямоугольника на самом деле является встроеннымVText
DIV для текстового компонента.
<template>
<div class="rect-shape">
<v-text :propValue="element.propValue" :element="element" />
</div>
</template>
<script>
export default {
props: {
element: {
type: Object,
},
},
}
</script>
<style lang="scss" scoped>
.rect-shape {
width: 100%;
height: 100%;
overflow: auto;
}
</style>
VText
Он обладает всеми функциями текстовой составляющей, и его можно произвольно увеличивать или уменьшать.
21. Запорные элементы
Запирающие компоненты в основном видныprocesson
А лезвие есть эта функция, поэтому я понял путь. Узел блокировки как специфические требования: не перемещать, масштабировать, вращать, копировать, пасту и т. Д., Только операция разблокировки.
Принцип его реализации не сложен:
- добавить пользовательский компонент
isLock
Свойство, указывающее, блокировать ли компонент. - Когда компонент щелкнут, в соответствии с
isLock
Этоtrue
чтобы скрыть восемь точек и значки вращения на компоненте. - Чтобы подчеркнуть, что компонент заблокирован, присвойте ему свойство непрозрачности и значок замка.
- Если компонент заблокирован, кнопка, соответствующая указанному выше требованию, неактивна и не может быть нажата.
Соответствующий код выглядит следующим образом:
export const commonAttr = {
animations: [],
events: {},
groupStyle: {}, // 当一个组件成为 Group 的子组件时使用
isLock: false, // 是否锁定组件
}
<el-button @click="decompose"
:disabled="!curComponent || curComponent.isLock || curComponent.component != 'Group'">拆分</el-button>
<el-button @click="lock" :disabled="!curComponent || curComponent.isLock">锁定</el-button>
<el-button @click="unlock" :disabled="!curComponent || !curComponent.isLock">解锁</el-button>
<template>
<div class="contextmenu" v-show="menuShow" :style="{ top: menuTop + 'px', left: menuLeft + 'px' }">
<ul @mouseup="handleMouseUp">
<template v-if="curComponent">
<template v-if="!curComponent.isLock">
<li @click="copy">复制</li>
<li @click="paste">粘贴</li>
<li @click="cut">剪切</li>
<li @click="deleteComponent">删除</li>
<li @click="lock">锁定</li>
<li @click="topComponent">置顶</li>
<li @click="bottomComponent">置底</li>
<li @click="upComponent">上移</li>
<li @click="downComponent">下移</li>
</template>
<li v-else @click="unlock">解锁</li>
</template>
<li v-else @click="paste">粘贴</li>
</ul>
</div>
</template>
22. Ярлыки
Основная цель поддержки горячих клавиш — повысить эффективность разработки, ведь кликать мышью не так быстро, как нажимать на клавиатуру. Функции, которые в настоящее время поддерживаются сочетаниями клавиш, следующие:
const ctrlKey = 17,
vKey = 86, // 粘贴
cKey = 67, // 复制
xKey = 88, // 剪切
yKey = 89, // 重做
zKey = 90, // 撤销
gKey = 71, // 组合
bKey = 66, // 拆分
lKey = 76, // 锁定
uKey = 85, // 解锁
sKey = 83, // 保存
pKey = 80, // 预览
dKey = 68, // 删除
deleteKey = 46, // 删除
eKey = 69 // 清空画布
Принцип реализации в основном заключается в использовании окна для глобального мониторинга ключевых событий и выполнении соответствующих операций при срабатывании квалифицированного ключа:
// 与组件状态无关的操作
const basemap = {
[vKey]: paste,
[yKey]: redo,
[zKey]: undo,
[sKey]: save,
[pKey]: preview,
[eKey]: clearCanvas,
}
// 组件锁定状态下可以执行的操作
const lockMap = {
...basemap,
[uKey]: unlock,
}
// 组件未锁定状态下可以执行的操作
const unlockMap = {
...basemap,
[cKey]: copy,
[xKey]: cut,
[gKey]: compose,
[bKey]: decompose,
[dKey]: deleteComponent,
[deleteKey]: deleteComponent,
[lKey]: lock,
}
let isCtrlDown = false
// 全局监听按键操作并执行相应命令
export function listenGlobalKeyDown() {
window.onkeydown = (e) => {
const { curComponent } = store.state
if (e.keyCode == ctrlKey) {
isCtrlDown = true
} else if (e.keyCode == deleteKey && curComponent) {
store.commit('deleteComponent')
store.commit('recordSnapshot')
} else if (isCtrlDown) {
if (!curComponent || !curComponent.isLock) {
e.preventDefault()
unlockMap[e.keyCode] && unlockMap[e.keyCode]()
} else if (curComponent && curComponent.isLock) {
e.preventDefault()
lockMap[e.keyCode] && lockMap[e.keyCode]()
}
}
}
window.onkeyup = (e) => {
if (e.keyCode == ctrlKey) {
isCtrlDown = false
}
}
}
Чтобы предотвратить конфликты с сочетаниями клавиш браузера по умолчанию, вам необходимо добавитьe.preventDefault()
.
23. Линии сетки
Функциональность сетки реализована с использованием SVG:
<template>
<svg class="grid" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="smallGrid" width="7.236328125" height="7.236328125" patternUnits="userSpaceOnUse">
<path
d="M 7.236328125 0 L 0 0 0 7.236328125"
fill="none"
stroke="rgba(207, 207, 207, 0.3)"
stroke-width="1">
</path>
</pattern>
<pattern id="grid" width="36.181640625" height="36.181640625" patternUnits="userSpaceOnUse">
<rect width="36.181640625" height="36.181640625" fill="url(#smallGrid)"></rect>
<path
d="M 36.181640625 0 L 0 0 0 36.181640625"
fill="none"
stroke="rgba(186, 186, 186, 0.5)"
stroke-width="1">
</path>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)"></rect>
</svg>
</template>
<style lang="scss" scoped>
.grid {
position: absolute;
top: 0;
left: 0;
}
</style>
Если вы мало что знаете о SVG, рекомендуется взглянуть на MDN.руководство.
24. Еще один редактор моментальных реализаций
В первой статье серии я проанализировал принцип реализации снепшотов.
snapshotData: [], // 编辑器快照数据
snapshotIndex: -1, // 快照索引
undo(state) {
if (state.snapshotIndex >= 0) {
state.snapshotIndex--
store.commit('setComponentData', deepCopy(state.snapshotData[state.snapshotIndex]))
}
},
redo(state) {
if (state.snapshotIndex < state.snapshotData.length - 1) {
state.snapshotIndex++
store.commit('setComponentData', deepCopy(state.snapshotData[state.snapshotIndex]))
}
},
setComponentData(state, componentData = []) {
Vue.set(state, 'componentData', componentData)
},
recordSnapshot(state) {
// 添加新的快照
state.snapshotData[++state.snapshotIndex] = deepCopy(state.componentData)
// 在 undo 过程中,添加新的快照时,要将它后面的快照清理掉
if (state.snapshotIndex < state.snapshotData.length - 1) {
state.snapshotData = state.snapshotData.slice(0, state.snapshotIndex + 1)
}
},
Используйте массив для хранения данных снимка редактора. Чтобы сохранить снимок, нужно продолжать выполнениеpush()
Действие, помещает текущие данные редактора вsnapshotData
массив и увеличить индекс моментального снимкаsnapshotIndex
.
Поскольку каждый раз, когда добавляется снимок, все данные компонентов текущего редактора помещаются вsnapshotData
, чем больше данных снимка сохраняется, тем больше памяти он занимает. Для этого есть два решения:
- Снимки ограничивают количество шагов, например, можно сохранить снимок данных 50 шагов.
- Сохранение моментального снимка сохраняет только различия.
Теперь подробно опишите второе решение.
Предполагая, что четыре компонента a b c d добавляются к холсту по очереди, в исходной реализации соответствующиеsnapshotData
Данные:
// snapshotData
[
[a],
[a, b],
[a, b, c],
[a, b, c, d],
]
Из вышеприведенного кода видно, что в каждом соседнем снимке различаются только одни данные. Таким образом, мы можем добавить поле типа к снимку каждого шага, чтобы указать, является ли операция добавлением или удалением.
Затем операция добавления четырех компонентов выше, соответствующиеsnapshotData
Данные:
// snapshotData
[
[{ type: 'add', value: a }],
[{ type: 'add', value: b }],
[{ type: 'add', value: c }],
[{ type: 'add', value: d }],
]
Если мы хотим удалить компонент c, тоsnapshotData
Данные станут:
// snapshotData
[
[{ type: 'add', value: a }],
[{ type: 'add', value: b }],
[{ type: 'add', value: c }],
[{ type: 'add', value: d }],
[{ type: 'remove', value: c }],
]
Как использовать текущие данные снимка??
Нам нужно просмотреть данные моментального снимка, чтобы сгенерировать данные компонентов редактора.componentData
. Предполагая, что приведенные выше данные выполняются на основеundo
Отменить действие:
// snapshotData
// 快照索引 snapshotIndex 此时为 3
[
[{ type: 'add', value: a }],
[{ type: 'add', value: b }],
[{ type: 'add', value: c }],
[{ type: 'add', value: d }],
[{ type: 'remove', value: c }],
]
-
snapshotData[0]
Типadd
, добавить компонент а вcomponentData
, В настоящее времяcomponentData
для[a]
- И так далее
[a, b]
[a, b, c]
[a, b, c, d]
Если на этот раз выполняетсяredo
повторить операцию, индекс моментального снимкаsnapshotIndex
становится 4. Соответствующий тип данных моментального снимкаtype: 'remove'
, чтобы удалить компонент c. Тогда данные массива[a, b, d]
.
Этот метод на самом деле является методом «время для пространства». Ни один из методов не идеален, вам решать, какой из них вы используете, я все еще использую первый метод.
Суммировать
С точки зрения конструкции колеса, это четвертое колесо, которое я построил на данный момент и которое относительно удовлетворительно.
Создание колеса — хороший способ повысить свой технический уровень, но вы должны построить осмысленное и сложное колесо, причем нужно построить только одно колесо одного типа. После сборки колеса вам все равно нужно написать резюме, и лучше всего вывести его в виде статьи, чтобы поделиться.
Визуальное перетаскивание серии статей:
- Принципиальный анализ некоторых технических моментов библиотеки компонентов визуального перетаскивания
- Принципиальный анализ некоторых технических моментов библиотеки компонентов визуального перетаскивания (2)
- Принципиальный анализ некоторых технических моментов библиотеки компонентов визуального перетаскивания (3)