Принципиальный анализ некоторых технических моментов библиотеки компонентов визуального перетаскивания (3)

внешний интерфейс JavaScript

Эта статья является третьей статьей в серии о визуальном перетаскивании.В предыдущих двух статьях были проанализированы технические принципы работы 17 функциональных точек:

  1. редактор
  2. пользовательский компонент
  3. тянуть
  4. Удалить компоненты, настроить уровни слоев
  5. приблизить
  6. отменить повторить
  7. Настройки свойств компонента
  8. адсорбция
  9. Предварительный просмотр и сохранение кода
  10. привязать событие
  11. Связать анимацию
  12. Импорт PSD
  13. режим телефона
  14. перетащите, чтобы повернуть
  15. копировать вставить вырезать
  16. Взаимодействие данных
  17. выпускать

Исходя из этого, в данной статье будут проанализированы технические принципы работы следующих функциональных точек:

  1. Объединение и разделение нескольких компонентов
  2. текстовый компонент
  3. Прямоугольный компонент
  4. замок в сборе
  5. горячая клавиша
  6. Линии сетки
  7. Еще один способ реализации снимков редактора

Если вы мало знаете о двух моих предыдущих статьях, я предлагаю вам сначала прочитать эти две статьи, а затем прочитать эту статью:

Хотя моя библиотека компонентов с визуальным перетаскиванием — всего лишь ДЕМО, я сравнил некоторые готовые продукты на рынке (такие какprocesson,чернильный нож), что касается основных функций, то мой DEMO реализует большинство функций.

Если вам интересны low-code платформы, но вы ничего об этом не знаете. Настоятельно рекомендуется прочитать три мои статьи вместе с исходным кодом проекта, я уверен, что это определенно принесет вам много пользы. Также прикрепите проект, адрес онлайн DEMO:

18. Объединение и разделение нескольких компонентов

Существует относительно много технических моментов для объединения и разделения, в том числе следующие четыре:

  • выбранная область
  • Движение и вращение после комбинации
  • Увеличение и уменьшение масштаба после комбинации
  • Восстановление стилей подкомпонентов после разделения

выбранная область

Перед объединением нескольких компонентов их необходимо сначала выбрать. С помощью событий мыши можно легко отобразить выбранную область:

  1. mousedownзаписать координаты начальной точки
  2. mousemoveВычислите текущую координату и координату начальной точки, чтобы получить движущуюся область
  3. Если вы нажмете мышь и переместитесь в левый верхний угол, аналогично этой операции, вам нужно установить текущую координату в качестве координаты начальной точки, а затем вычислить область перемещения.
// 获取编辑器的位移信息
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
}

Кратко опишите логику обработки этого кода:

  1. использоватьgetBoundingClientRect()API браузера получает информацию о каждом компоненте в четырех направлениях относительно области просмотра браузера, то есть:left top right bottom.
  2. Сравнив четыре информации о каждом компоненте, получите значения самого левого, самого верхнего, самого правого и самого нижнего направлений выбранной области, чтобы получить минимальную область, которая может содержать все компоненты в области.
  3. Если в выбранной области уже есть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 = {}
}

Логика обработки этого кода:

  1. траверсGroupдочерние компоненты и восстановить их стили
  2. использоватьgetBoundingClientRect()API для получения дочернего компонента относительно области просмотра браузераleft top width heightАтрибуты.
  3. Используйте эти четыре свойства для вычисления координат центральной точки дочернего компонента.
  4. из-за подкомпонентовwidth heightсвойства относятся кGroupкомпоненты, поэтому объедините их процентные значения иGroupУмножьте, чтобы получить конкретное значение.
  5. повторно использовать центральную точку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 || '&nbsp;'
            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Функции компонентов следующие:

  1. Дважды щелкните, чтобы начать редактирование.
  2. Выбранный текст поддерживается.
  3. Стиль для фильтрации текста при вставке.
  4. Автоматически увеличивать высоту текстового поля при переносе.

20. Компоненты прямоугольника

Компонент прямоугольника на самом деле является встроеннымVTextDIV для текстового компонента.

<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А лезвие есть эта функция, поэтому я понял путь. Узел блокировки как специфические требования: не перемещать, масштабировать, вращать, копировать, пасту и т. Д., Только операция разблокировки.

Принцип его реализации не сложен:

  1. добавить пользовательский компонентisLockСвойство, указывающее, блокировать ли компонент.
  2. Когда компонент щелкнут, в соответствии сisLockЭтоtrueчтобы скрыть восемь точек и значки вращения на компоненте.
  3. Чтобы подчеркнуть, что компонент заблокирован, присвойте ему свойство непрозрачности и значок замка.
  4. Если компонент заблокирован, кнопка, соответствующая указанному выше требованию, неактивна и не может быть нажата.

Соответствующий код выглядит следующим образом:

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, чем больше данных снимка сохраняется, тем больше памяти он занимает. Для этого есть два решения:

  1. Снимки ограничивают количество шагов, например, можно сохранить снимок данных 50 шагов.
  2. Сохранение моментального снимка сохраняет только различия.

Теперь подробно опишите второе решение.

Предполагая, что четыре компонента 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 }],
]
  1. snapshotData[0]Типadd, добавить компонент а вcomponentData, В настоящее времяcomponentDataдля[a]
  2. И так далее[a, b]
  3. [a, b, c]
  4. [a, b, c, d]

Если на этот раз выполняетсяredoповторить операцию, индекс моментального снимкаsnapshotIndexстановится 4. Соответствующий тип данных моментального снимкаtype: 'remove', чтобы удалить компонент c. Тогда данные массива[a, b, d].

Этот метод на самом деле является методом «время для пространства». Ни один из методов не идеален, вам решать, какой из них вы используете, я все еще использую первый метод.

Суммировать

С точки зрения конструкции колеса, это четвертое колесо, которое я построил на данный момент и которое относительно удовлетворительно.

Создание колеса — хороший способ повысить свой технический уровень, но вы должны построить осмысленное и сложное колесо, причем нужно построить только одно колесо одного типа. После сборки колеса вам все равно нужно написать резюме, и лучше всего вывести его в виде статьи, чтобы поделиться.

Визуальное перетаскивание серии статей:

использованная литература