This article is the third in the visual drag and drop series. The previous two articles analyzed the technical principles of 17 function points:
- editor
- Custom components
- Drag
- Delete components, adjust layer levels
- Zoom in and out
- Undo, redo
- Component property settings
- adsorbent
- Preview and save code
- Binding event
- Bind animation
- Import PSD
- Mobile mode
- Drag rotation
- Copy paste cut
- Data interaction
- release
On this basis, this paper will analyze the technical principles of the following function points:
- Combination and splitting of multiple components
- Text component
- Rectangular component
- Locking assembly
- Shortcut key
- Grid line
- Another implementation of editor snapshot
If you don't know much about my previous two articles, I suggest you read them first and then read this article:
- Analysis of some technical points and principles of visual drag and drop component library
- Principle analysis of some technical points of visual drag and drop component library (2)
Although my visual drag and drop component library is only a DEMO, it compares some ready-made products on the market (for example processon,Ink knife ), in terms of basic functions, my DEMO implements most of the functions.
If you are interested in low code platforms but don't understand them. I strongly recommend reading my three articles together with the project source code. I believe it will be a great harvest for you. The project and online DEMO address are also attached:
18. Combination and splitting of multiple components
There are relatively many technical points for combination and splitting, including the following four:
- Selected area
- Movement and rotation after combination
- Zoom in and out after combination
- Recovery of sub component styles after splitting
Selected area
You need to select multiple components before combining them. The selected area can be easily displayed by using mouse events:
- mousedown records the starting point coordinates
- mousemove calculates the current coordinates and the starting point coordinates to obtain the moving area
- If you press the mouse and move to the upper left, similar to this operation, you need to set the current coordinate as the starting point coordinate, and then calculate the moving area
// Get the displacement information of the editor 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 // Show selected area 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 } }
When the mouseup event is triggered, the displacement information of all components in the selected area needs to be calculated to obtain a minimum area that can contain all components in the area. This effect is shown in the figure below:
Code of this calculation process:
createGroup() { // Get the component data of the selected area const areaData = this.getSelectArea() if (areaData.length <= 1) { this.hideArea() return } // Create a Group component based on the displacement information of the selected area and each component in the area // To traverse each component of the selection area, get their left top right bottom information for comparison 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 // Set the displacement information of the selected area and the component data in the area this.$store.commit('setAreaData', { style: { left, top, width: this.width, height: this.height, }, components: areaData, }) }, getSelectArea() { const result = [] // Area start coordinates const { x, y } = this.start // Calculate all component data and judge whether it is in the selected area 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) } }) // Returns all components in the selected area return result }
Briefly describe the processing logic of this Code:
- utilize getBoundingClientRect() The browser API obtains the information of each component in four directions relative to the browser viewport, that is, left top right bottom.
- Compare the four information of each component and obtain the values of the leftmost, uppermost, rightmost and lowermost directions of the selected area, so as to obtain a minimum area that can contain all components in the area.
- If there is already a Group composite component in the selected area, you need to calculate its sub components instead of the composite components.
Movement and rotation after combination
To facilitate moving, rotating, zooming in and out of multiple components together, I created a new Group composite component:
<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. The top left of groupstyle is the position relative to the group component // If a component.already exists Groupstyle indicates that it has been calculated once. There is no need to calculate again 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>
The function of Group component is to put the components in the region under it and become sub components. When creating a Group component, obtain the relative displacement and relative size of each sub component in the Group component:
created() { const parentStyle = this.element.style this.propValue.forEach(component => { // component. The top left of groupstyle is the position relative to the group component // If a component.already exists Groupstyle indicates that it has been calculated once. There is no need to calculate again 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 + '%' }, },
That is, the attributes such as left, top, width and height of the subcomponent are converted into relative values ending in%.
Why not use absolute values?
If absolute values are used, when moving a Group component, you need to calculate not only the properties of the Group component, but also each of its subcomponents. And if the Group contains too many sub components, the amount of calculation will be very large when moving and zooming in and out, which may cause page jamming. If it is changed to a relative value, it only needs to be calculated once when the Group is created. Then, when the Group component moves and rotates, it does not need to care about the sub components of the Group, but only calculate it by itself.
Zoom in and out after combination
Zooming in and out after combination is a big problem, mainly because of the existence of rotation angle. First, let's take a look at the zoom in and zoom out of each sub component without rotation:
As can be seen from the following figure, the effect is very perfect. The size of each sub component changes with the size of the Group component.
Now try to add the rotation angle to the subassembly and see the effect:
Why is this problem?
This is mainly because the top left attribute of a component remains unchanged whether it is rotated or not. In this way, there will be a problem, although in fact, the top left width height attribute of the component has not changed. But the appearance has changed. Here are two identical components: one does not rotate and the other rotates 45 degrees.
It can be seen that the top left width height attribute of the rotated button is different from what we can see from the appearance.
Next, let's look at a specific example:
The above is a Group component, and the attributes of its sub components on the left are:
transform: rotate(-75.1967deg); width: 51.2267%; height: 32.2679%; top: 33.8661%; left: -10.6496%;
You can see that the value of width is 51.2267%, but from the appearance, this sub component accounts for up to one third of the width of the Group component. So that's the problem with zooming in and out.
An infeasible solution (you can skip what you don't want to see)
At first, I wanted to calculate the top left width height attribute of the browser viewport, and then calculate the relative values of these attributes on the Group component. This can be achieved through the getBoundingClientRect() API. As long as the proportion of each attribute on the appearance remains unchanged, when the Group component is zoomed in and out, it can obtain the top left width height attribute before it is rotated by using the knowledge of rotation matrix through the rotation angle (which is described in detail in Chapter 2). In this way, the sub components can be adjusted dynamically.
However, there is a problem. Only the top left right bottom width height attribute on the component appearance can be obtained through the getBoundingClientRect() API. In addition, the parameters are not enough, so the actual top left width height attribute of the component cannot be calculated.
Just like the figure above, we only know the origin O(x,y) w h and rotation angle, but we can't calculate the width and height of the button.
A feasible solution
This was discovered by accident. When I zoomed in and out of the Group component, I found that as long as the width height ratio of the Group component is maintained, the sub component can be zoomed in and out according to the scale. Now the problem turns to how to keep the width height ratio when the Group component is zoomed in and out. I found this one on the Internet article , it describes in detail how a rotating component maintains the aspect ratio to zoom in and out, and is equipped with source code examples.
Now I'll try to briefly describe how to keep the aspect ratio of a rotating component to zoom in and out. Below is a rectangle that has been rotated by a certain angle. Suppose you drag the point at the top left of it to stretch.
The first step is to calculate the width height ratio of the component, and calculate the center point of the component through the coordinates (no matter how many degrees of rotation, the top left attribute of the component remains unchanged) and size of the component when pressing the mouse:
// Component aspect ratio const proportion = style.width / style.height const center = { x: style.left + style.width / 2, y: style.top + style.height / 2, }
The second step is to calculate the symmetrical point coordinates of the current click coordinates with the current click coordinates and the component center point:
// Get canvas displacement information const editorRectInfo = document.querySelector('#editor').getBoundingClientRect() // Current click coordinates const curPoint = { x: e.clientX - editorRectInfo.left, y: e.clientY - editorRectInfo.top, } // Get the coordinates of the symmetry point const symmetricPoint = { x: center.x - (curPoint.x - center.x), y: center.y - (curPoint.y - center.y), }
Step 3: when pressing the upper left corner of the component for stretching, calculate the new component center point through the current mouse real-time coordinates and symmetry points:
const curPositon = { x: moveEvent.clientX - editorRectInfo.left, y: moveEvent.clientY - editorRectInfo.top, } const newCenterPoint = getCenterPoint(curPositon, symmetricPoint) // Find the midpoint coordinate between two points function getCenterPoint(p1, p2) { return { x: p1.x + ((p2.x - p1.x) / 2), y: p1.y + ((p2.y - p1.y) / 2), } }
Because the component is rotating, you can't calculate the component directly even if you know the xy distance to move during stretching. Otherwise, a BUG will appear, and the direction of shifting or zooming in and out is incorrect. Therefore, we need to calculate the component without rotation.
Step 4: according to the known rotation angle, the new component center point and the current mouse real-time coordinates, the coordinates of the current mouse real-time coordinate currentPosition when it is not rotated can be calculated. When the symmetrical point and the symmetrical point of the bottomnewpoint component are not known, they can also be calculated according to the coordinates of the new rotation point of the bottomnewpoint component.
The corresponding calculation formula is as follows:
/** * Calculate the coordinates of the point rotated according to the center of the circle * @param {Object} point Point coordinates before rotation * @param {Object} center Center of rotation * @param {Number} rotate Angle of rotation * @return {Object} Coordinates after rotation * https://www.zhihu.com/question/67425734/answer/252724399 Rotation matrix formula */ export function calculateRotatedPointCoordinate(point, center, rotate) { /** * Rotation formula: * Point a(x, y) * Rotation center c(x, y) * Point n(x, y) after rotation * Rotation angle θ 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, } }
The above formula involves the knowledge of rotation matrix in linear algebra, which is too difficult for a person who has not attended college. Fortunately, I never knew one of them answer The reasoning process of this formula is found in the following is the original answer:
Through the above calculation values, the new displacement value top left and the new component size of the component can be obtained. The corresponding complete code is as follows:
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) } }
Now let's look at the zoom in and zoom out after rotation:
Step 5: since what we need now is to lock the aspect ratio to zoom in and out, we need to recalculate the coordinates of the upper left corner of the stretched figure.
Here we first determine the names of several shapes:
- Original drawing: red part
- New graphics: blue section
- Correction figure: the green part, that is, the correction figure with the locking rule of aspect ratio
In step 4, after calculating the newtopleftpoint, newbottomlightpoint, newwidth and newheight before the component is rotated, it is necessary to calculate the new width or height according to the aspect ratio proportion.
The above figure is an example where the height needs to be changed. The calculation process is as follows:
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 }
Since the coordinates before rotation are calculated based on the coordinates before the width and height are not reduced proportionally, after the width and height are reduced, it is necessary to rotate back according to the original center point to obtain the corresponding coordinates after the width and height are reduced and rotated. Then obtain a new center point with this coordinate and symmetry point, and recalculate the coordinates before rotation.
The modified complete code is as follows:
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 } // Because the coordinates before rotation are calculated based on the coordinates before the width and height are not reduced proportionally // Therefore, after reducing the width and height, you need to rotate back according to the original center point to obtain the corresponding coordinates after reducing the width and height and rotating // Then obtain a new center point with this coordinate and symmetry point, and recalculate the coordinates before rotation 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) } }
The effect of keeping the aspect ratio to zoom in and out is as follows:
When the Group component has rotating sub components, it is necessary to maintain the aspect ratio to zoom in and out. Therefore, when creating a Group component, you can judge whether a sub component has a rotation angle. If not, there is no need to maintain the width ratio to zoom in and out.
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 }
Recovery of sub component styles after splitting
Combining multiple components is only the first step. The second step is to split the Group component and restore the style of each sub component. Ensure that the appearance properties of the split sub components remain unchanged.
The calculation code is as follows:
// 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 }) }) } // Split the sub components in the combination and calculate their new style s export default function decomposeComponent(component, editorRect, parentStyle) { // The style of the subcomponent relative to the browser viewport const componentRect = $(`#component${component.id}`).getBoundingClientRect() // Gets the coordinates of the center point of the element 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 // Calculate the new top left coordinates of the element component.style.left = center.x - component.style.width / 2 component.style.top = center.y - component.style.height / 2 component.groupStyle = {} }
The processing logic of this code is:
- Traverse the subcomponents of the Group and restore their styles
- Use the getBoundingClientRect() API to obtain the left top width height attribute of the sub component relative to the browser viewport.
- Use these four attributes to calculate the center point coordinates of the sub components.
- Since the width height attribute of sub components is relative to the Group component, their percentage value is multiplied by the Group to obtain the specific value.
- Then subtract half of the width and height of the sub component from the center(x, y) to obtain its left top attribute.
At this point, the combination and split are explained.
19. Text component
The text component VText has been implemented before, but it is not perfect. For example, text cannot be selected. Now I have rewritten it to support the selected function.
<template> <div v-if="editMode == 'edit'" class="v-text" @keydown="handleKeydown" @keyup="handleKeyup"> <!-- tabindex >= 0 Causes the element to be aggregated when double clicked --> <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 // Select all 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>
The functions of the modified VText components are as follows:
- Double click to start editing.
- The selected text is supported.
- Filter out the style of text when pasting.
- Automatically expand the height of the text box when wrapping.
20. Rectangular assembly
The rectangular component is actually a DIV with embedded VText text component.
<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 text component has some functions, and can be enlarged or reduced arbitrarily.
21. Locking assembly
The locking component mainly saw that processon and ink knife had this function, so I realized it by the way. The specific requirements of locking components are: they cannot be moved, zoomed in and out, rotated, copied, pasted, etc. they can only be unlocked.
Its implementation principle is not difficult:
- Add an isLock attribute on the custom component to indicate whether to lock the component.
- When clicking a component, hide the eight points and rotation icons on the component according to whether isLock is true or not.
- To highlight that a component is locked, add a transparency attribute and a lock icon to it.
- If the component is locked, the button corresponding to the above requirements will be grayed out and cannot be clicked.
Relevant codes are as follows:
export const commonAttr = { animations: [], events: {}, groupStyle: {}, // Used when a component becomes a sub component of a Group isLock: false, // Is the component locked }
<el-button @click="decompose" :disabled="!curComponent || curComponent.isLock || curComponent.component != 'Group'">split</el-button> <el-button @click="lock" :disabled="!curComponent || curComponent.isLock">locking</el-button> <el-button @click="unlock" :disabled="!curComponent || !curComponent.isLock">Unlock</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">copy</li> <li @click="paste">paste</li> <li @click="cut">shear</li> <li @click="deleteComponent">delete</li> <li @click="lock">locking</li> <li @click="topComponent">Topping</li> <li @click="bottomComponent">Bottom setting</li> <li @click="upComponent">Move up</li> <li @click="downComponent">Move down</li> </template> <li v-else @click="unlock">Unlock</li> </template> <li v-else @click="paste">paste</li> </ul> </div> </template>
22. Shortcut keys
Supporting shortcut keys is mainly to improve development efficiency. After all, clicking with the mouse is not as fast as pressing the keyboard. At present, the functions supported by shortcut keys are as follows:
const ctrlKey = 17, vKey = 86, // paste cKey = 67, // copy xKey = 88, // shear yKey = 89, // redo zKey = 90, // revoke gKey = 71, // combination bKey = 66, // split lKey = 76, // locking uKey = 85, // Unlock sKey = 83, // preservation pKey = 80, // preview dKey = 68, // delete deleteKey = 46, // delete eKey = 69 // Empty canvas
The implementation principle is mainly to use window to monitor key events globally and perform corresponding operations when qualified keys are triggered:
// Actions independent of component status const basemap = { [vKey]: paste, [yKey]: redo, [zKey]: undo, [sKey]: save, [pKey]: preview, [eKey]: clearCanvas, } // Actions that can be performed when the component is locked const lockMap = { ...basemap, [uKey]: unlock, } // Actions that can be performed when the component is unlocked const unlockMap = { ...basemap, [cKey]: copy, [xKey]: cut, [gKey]: compose, [bKey]: decompose, [dKey]: deleteComponent, [deleteKey]: deleteComponent, [lKey]: lock, } let isCtrlDown = false // Monitor key operations globally and execute corresponding commands 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 } } }
In order to prevent conflicts with the browser default shortcut keys, you need to add e.preventDefault().
23. Gridlines
The gridline function is implemented using 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>
If you don't know much about SVG, I suggest you take a look at MDN course.
24. Another implementation of editor snapshot
In the first article in the series, I have analyzed the implementation principle of snapshot.
snapshotData: [], // Editor snapshot data snapshotIndex: -1, // Snapshot index 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) { // Add a new snapshot state.snapshotData[++state.snapshotIndex] = deepCopy(state.componentData) // In the undo process, when adding a new snapshot, clean up the snapshots behind it if (state.snapshotIndex < state.snapshotData.length - 1) { state.snapshotData = state.snapshotData.slice(0, state.snapshotIndex + 1) } },
Use an array to save the snapshot data of the editor. To save a snapshot is to continuously execute the push() operation, push the current editor data into the snapshotData array, and increase the snapshot index snapshotIndex.
Because every time you add a snapshot, you push all the component data of the current editor into snapshotData. The more snapshot data you save, the more memory you occupy. There are two solutions:
- Limit the number of snapshot steps. For example, only 50 steps of snapshot data can be saved.
- Saving a snapshot saves only the difference part.
Now describe the second solution in detail.
Suppose four components a, B, C and D are added to the canvas in turn. In the original implementation, the corresponding snapshotData data is:
// snapshotData [ [a], [a, b], [a, b, c], [a, b, c, d], ]
It can be found from the above code that only one data is different in each adjacent snapshot. Therefore, we can add a type field for the snapshot of each step to indicate whether to add or delete this operation.
For the above operation of adding four components, the corresponding snapshotData data is:
// snapshotData [ [{ type: 'add', value: a }], [{ type: 'add', value: b }], [{ type: 'add', value: c }], [{ type: 'add', value: d }], ]
If we want to delete the c component, the snapshotData data will become:
// snapshotData [ [{ type: 'add', value: a }], [{ type: 'add', value: b }], [{ type: 'add', value: c }], [{ type: 'add', value: d }], [{ type: 'remove', value: c }], ]
How to use the current snapshot data?
We need to traverse the snapshot data to generate the component data of the editor. Suppose undo is performed based on the above data:
// snapshotData // The snapshot index is now 3 [ [{ type: 'add', value: a }], [{ type: 'add', value: b }], [{ type: 'add', value: c }], [{ type: 'add', value: d }], [{ type: 'remove', value: c }], ]
- The type of snapshotData[0] is add. Add component A to componentData. At this time, componentData is [a]
- And so on [a, b]
- [a, b, c]
- [a, b, c, d]
If redo redo is performed at this time, the snapshot index will change to 4. The corresponding snapshot data type is type: 'remove', which removes component c. Then the array data is [a, b, d].
This method is actually time for space. Although there is only one snapshot data saved each time, you have to traverse all snapshot data each time. Neither method is perfect. Which one to use depends on you. At present, I am still using the first method.
summary
From the point of view of wheel making, this is the fourth wheel I am satisfied with. The other three are:
Making wheels is a good way to improve your technical level, but making wheels must make meaningful and difficult wheels, and only one wheel of the same type can be made. After making the wheel, you still need to write a summary. It's best to output it into an article and share it.