init
This commit is contained in:
commit
a948b80f4d
|
@ -0,0 +1,25 @@
|
||||||
|
/node_modules/
|
||||||
|
/.idea/.gitignore
|
||||||
|
/.idea/Alx.iml
|
||||||
|
/.idea/modules.xml
|
||||||
|
/.idea/vcs.xml
|
||||||
|
/dist/
|
||||||
|
/.idea/workspace.xml
|
||||||
|
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
/.idea/deployment.xml
|
||||||
|
/.idea/encodings.xml
|
||||||
|
/.idea/misc.xml
|
||||||
|
/dist.zip
|
||||||
|
/package-lock.json
|
|
@ -0,0 +1,4 @@
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
|
package.json
|
|
@ -0,0 +1,5 @@
|
||||||
|
semi: false
|
||||||
|
singleQuote: true
|
||||||
|
printWidth: 80
|
||||||
|
trailingComma: 'none'
|
||||||
|
arrowParens: 'avoid'
|
|
@ -0,0 +1,5 @@
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
|
package.json
|
||||||
|
public
|
|
@ -0,0 +1,5 @@
|
||||||
|
semi: false
|
||||||
|
singleQuote: true
|
||||||
|
printWidth: 80
|
||||||
|
trailingComma: 'none'
|
||||||
|
arrowParens: 'avoid'
|
|
@ -0,0 +1,15 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>在线小白板</title>
|
||||||
|
<link href="/libs/jsonTree.css" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script src="/libs/jsonTree.js"></script>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"name": "tiny_whiteboard_demo",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"format": "prettier --write ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"element-plus": "^2.1.6",
|
||||||
|
"vue": "^3.2.25"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^2.2.0",
|
||||||
|
"less": "^4.1.2",
|
||||||
|
"less-loader": "^10.2.0",
|
||||||
|
"prettier": "^2.7.1",
|
||||||
|
"unplugin-auto-import": "^0.6.6",
|
||||||
|
"unplugin-vue-components": "^0.18.5",
|
||||||
|
"vite": "^2.8.0"
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="11" height="22"><defs><linearGradient id="a"><stop offset="0"/><stop offset="1" stop-opacity="0"/></linearGradient><radialGradient xlink:href="#a" cx="9.739" cy="9.716" fx="9.739" fy="9.716" r="3.709" gradientUnits="userSpaceOnUse"/></defs><g stroke="#000" fill="none"><g transform="translate(-129.5 -333.862) translate(0 .188)"><rect transform="matrix(.962 0 0 .971 4.943 11.548)" ry="2" rx="2" y="332.362" x="130" height="10.337" width="10.432" opacity=".5"/><g><path d="M132 339.175h6" opacity=".5"/><path d="M135 336.175v6" opacity=".5"/></g></g><g transform="translate(-129.5 -333.862)"><rect width="10.432" height="10.337" x="130" y="332.362" rx="2" ry="2" transform="matrix(.962 0 0 .971 4.943 22.736)" opacity=".5"/><path d="M132 350.362h6" opacity=".5"/></g></g></svg>
|
After Width: | Height: | Size: 867 B |
|
@ -0,0 +1,107 @@
|
||||||
|
/*
|
||||||
|
* JSON Tree Viewer
|
||||||
|
* http://github.com/summerstyle/jsonTreeViewer
|
||||||
|
*
|
||||||
|
* Copyright 2017 Vera Lobacheva (http://iamvera.com)
|
||||||
|
* Released under the MIT license (LICENSE.txt)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Background for the tree. May use for <body> element */
|
||||||
|
.jsontree_bg {
|
||||||
|
background: #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles for the container of the tree (e.g. fonts, margins etc.) */
|
||||||
|
.jsontree_tree {
|
||||||
|
margin-left: 30px;
|
||||||
|
font-family: 'PT Mono', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles for a list of child nodes */
|
||||||
|
.jsontree_child-nodes {
|
||||||
|
display: none;
|
||||||
|
margin-left: 35px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
.jsontree_node_expanded > .jsontree_value-wrapper > .jsontree_value > .jsontree_child-nodes {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles for labels */
|
||||||
|
.jsontree_label-wrapper {
|
||||||
|
float: left;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.jsontree_label {
|
||||||
|
font-weight: normal;
|
||||||
|
vertical-align: top;
|
||||||
|
color: #000;
|
||||||
|
position: relative;
|
||||||
|
padding: 1px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.jsontree_node_marked > .jsontree_label-wrapper > .jsontree_label {
|
||||||
|
background: #fff2aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles for values */
|
||||||
|
.jsontree_value-wrapper {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.jsontree_node_complex > .jsontree_value-wrapper {
|
||||||
|
overflow: inherit;
|
||||||
|
}
|
||||||
|
.jsontree_value {
|
||||||
|
vertical-align: top;
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
.jsontree_value_null {
|
||||||
|
color: #777;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.jsontree_value_string {
|
||||||
|
color: #025900;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.jsontree_value_number {
|
||||||
|
color: #000E59;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.jsontree_value_boolean {
|
||||||
|
color: #600100;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles for active elements */
|
||||||
|
.jsontree_expand-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: -15px;
|
||||||
|
display: block;
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
background-image: url('icons.svg');
|
||||||
|
}
|
||||||
|
.jsontree_node_expanded > .jsontree_label-wrapper > .jsontree_label > .jsontree_expand-button {
|
||||||
|
background-position: 0 -11px;
|
||||||
|
}
|
||||||
|
.jsontree_show-more {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.jsontree_node_expanded > .jsontree_value-wrapper > .jsontree_value > .jsontree_show-more {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.jsontree_node_empty > .jsontree_label-wrapper > .jsontree_label > .jsontree_expand-button,
|
||||||
|
.jsontree_node_empty > .jsontree_value-wrapper > .jsontree_value > .jsontree_show-more {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.jsontree_node_complex > .jsontree_label-wrapper > .jsontree_label {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.jsontree_node_empty > .jsontree_label-wrapper > .jsontree_label {
|
||||||
|
cursor: default !important;
|
||||||
|
}
|
|
@ -0,0 +1,819 @@
|
||||||
|
/**
|
||||||
|
* JSON Tree library (a part of jsonTreeViewer)
|
||||||
|
* http://github.com/summerstyle/jsonTreeViewer
|
||||||
|
*
|
||||||
|
* Copyright 2017 Vera Lobacheva (http://iamvera.com)
|
||||||
|
* Released under the MIT license (LICENSE.txt)
|
||||||
|
*/
|
||||||
|
|
||||||
|
var jsonTree = (function() {
|
||||||
|
|
||||||
|
/* ---------- Utilities ---------- */
|
||||||
|
var utils = {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Returns js-"class" of value
|
||||||
|
*
|
||||||
|
* @param val {any type} - value
|
||||||
|
* @returns {string} - for example, "[object Function]"
|
||||||
|
*/
|
||||||
|
getClass : function(val) {
|
||||||
|
return Object.prototype.toString.call(val);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks for a type of value (for valid JSON data types).
|
||||||
|
* In other cases - throws an exception
|
||||||
|
*
|
||||||
|
* @param val {any type} - the value for new node
|
||||||
|
* @returns {string} ("object" | "array" | "null" | "boolean" | "number" | "string")
|
||||||
|
*/
|
||||||
|
getType : function(val) {
|
||||||
|
if (val === null) {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (typeof val) {
|
||||||
|
case 'number':
|
||||||
|
return 'number';
|
||||||
|
|
||||||
|
case 'string':
|
||||||
|
return 'string';
|
||||||
|
|
||||||
|
case 'boolean':
|
||||||
|
return 'boolean';
|
||||||
|
}
|
||||||
|
|
||||||
|
switch(utils.getClass(val)) {
|
||||||
|
case '[object Array]':
|
||||||
|
return 'array';
|
||||||
|
|
||||||
|
case '[object Object]':
|
||||||
|
return 'object';
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Bad type: ' + utils.getClass(val));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies for each item of list some function
|
||||||
|
* and checks for last element of the list
|
||||||
|
*
|
||||||
|
* @param obj {Object | Array} - a list or a dict with child nodes
|
||||||
|
* @param func {Function} - the function for each item
|
||||||
|
*/
|
||||||
|
forEachNode : function(obj, func) {
|
||||||
|
var type = utils.getType(obj),
|
||||||
|
isLast;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'array':
|
||||||
|
isLast = obj.length - 1;
|
||||||
|
|
||||||
|
obj.forEach(function(item, i) {
|
||||||
|
func(i, item, i === isLast);
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'object':
|
||||||
|
var keys = Object.keys(obj).sort();
|
||||||
|
|
||||||
|
isLast = keys.length - 1;
|
||||||
|
|
||||||
|
keys.forEach(function(item, i) {
|
||||||
|
func(item, obj[item], i === isLast);
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements the kind of an inheritance by
|
||||||
|
* using parent prototype and
|
||||||
|
* creating intermediate constructor
|
||||||
|
*
|
||||||
|
* @param Child {Function} - a child constructor
|
||||||
|
* @param Parent {Function} - a parent constructor
|
||||||
|
*/
|
||||||
|
inherits : (function() {
|
||||||
|
var F = function() {};
|
||||||
|
|
||||||
|
return function(Child, Parent) {
|
||||||
|
F.prototype = Parent.prototype;
|
||||||
|
Child.prototype = new F();
|
||||||
|
Child.prototype.constructor = Child;
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Checks for a valid type of root node*
|
||||||
|
*
|
||||||
|
* @param {any type} jsonObj - a value for root node
|
||||||
|
* @returns {boolean} - true for an object or an array, false otherwise
|
||||||
|
*/
|
||||||
|
isValidRoot : function(jsonObj) {
|
||||||
|
switch (utils.getType(jsonObj)) {
|
||||||
|
case 'object':
|
||||||
|
case 'array':
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends some object
|
||||||
|
*/
|
||||||
|
extend : function(targetObj, sourceObj) {
|
||||||
|
for (var prop in sourceObj) {
|
||||||
|
if (sourceObj.hasOwnProperty(prop)) {
|
||||||
|
targetObj[prop] = sourceObj[prop];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/* ---------- Node constructors ---------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The factory for creating nodes of defined type.
|
||||||
|
*
|
||||||
|
* ~~~ Node ~~~ is a structure element of an onject or an array
|
||||||
|
* with own label (a key of an object or an index of an array)
|
||||||
|
* and value of any json data type. The root object or array
|
||||||
|
* is a node without label.
|
||||||
|
* {...
|
||||||
|
* [+] "label": value,
|
||||||
|
* ...}
|
||||||
|
*
|
||||||
|
* Markup:
|
||||||
|
* <li class="jsontree_node [jsontree_node_expanded]">
|
||||||
|
* <span class="jsontree_label-wrapper">
|
||||||
|
* <span class="jsontree_label">
|
||||||
|
* <span class="jsontree_expand-button" />
|
||||||
|
* "label"
|
||||||
|
* </span>
|
||||||
|
* :
|
||||||
|
* </span>
|
||||||
|
* <(div|span) class="jsontree_value jsontree_value_(object|array|boolean|null|number|string)">
|
||||||
|
* ...
|
||||||
|
* </(div|span)>
|
||||||
|
* </li>
|
||||||
|
*
|
||||||
|
* @param label {string} - key name
|
||||||
|
* @param val {Object | Array | string | number | boolean | null} - a value of node
|
||||||
|
* @param isLast {boolean} - true if node is last in list of siblings
|
||||||
|
*
|
||||||
|
* @return {Node}
|
||||||
|
*/
|
||||||
|
function Node(label, val, isLast) {
|
||||||
|
var nodeType = utils.getType(val);
|
||||||
|
|
||||||
|
if (nodeType in Node.CONSTRUCTORS) {
|
||||||
|
return new Node.CONSTRUCTORS[nodeType](label, val, isLast);
|
||||||
|
} else {
|
||||||
|
throw new Error('Bad type: ' + utils.getClass(val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Node.CONSTRUCTORS = {
|
||||||
|
'boolean' : NodeBoolean,
|
||||||
|
'number' : NodeNumber,
|
||||||
|
'string' : NodeString,
|
||||||
|
'null' : NodeNull,
|
||||||
|
'object' : NodeObject,
|
||||||
|
'array' : NodeArray
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The constructor for simple types (string, number, boolean, null)
|
||||||
|
* {...
|
||||||
|
* [+] "label": value,
|
||||||
|
* ...}
|
||||||
|
* value = string || number || boolean || null
|
||||||
|
*
|
||||||
|
* Markup:
|
||||||
|
* <li class="jsontree_node">
|
||||||
|
* <span class="jsontree_label-wrapper">
|
||||||
|
* <span class="jsontree_label">"age"</span>
|
||||||
|
* :
|
||||||
|
* </span>
|
||||||
|
* <span class="jsontree_value jsontree_value_(number|boolean|string|null)">25</span>
|
||||||
|
* ,
|
||||||
|
* </li>
|
||||||
|
*
|
||||||
|
* @abstract
|
||||||
|
* @param label {string} - key name
|
||||||
|
* @param val {string | number | boolean | null} - a value of simple types
|
||||||
|
* @param isLast {boolean} - true if node is last in list of parent childNodes
|
||||||
|
*/
|
||||||
|
function _NodeSimple(label, val, isLast) {
|
||||||
|
if (this.constructor === _NodeSimple) {
|
||||||
|
throw new Error('This is abstract class');
|
||||||
|
}
|
||||||
|
|
||||||
|
var self = this,
|
||||||
|
el = document.createElement('li'),
|
||||||
|
labelEl,
|
||||||
|
template = function(label, val) {
|
||||||
|
var str = '\
|
||||||
|
<span class="jsontree_label-wrapper">\
|
||||||
|
<span class="jsontree_label">"' +
|
||||||
|
label +
|
||||||
|
'"</span> : \
|
||||||
|
</span>\
|
||||||
|
<span class="jsontree_value-wrapper">\
|
||||||
|
<span class="jsontree_value jsontree_value_' + self.type + '">' +
|
||||||
|
val +
|
||||||
|
'</span>' +
|
||||||
|
(!isLast ? ',' : '') +
|
||||||
|
'</span>';
|
||||||
|
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.label = label;
|
||||||
|
self.isComplex = false;
|
||||||
|
|
||||||
|
el.classList.add('jsontree_node');
|
||||||
|
el.innerHTML = template(label, val);
|
||||||
|
|
||||||
|
self.el = el;
|
||||||
|
|
||||||
|
labelEl = el.querySelector('.jsontree_label');
|
||||||
|
|
||||||
|
labelEl.addEventListener('click', function(e) {
|
||||||
|
if (e.altKey) {
|
||||||
|
self.toggleMarked();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
document.getSelection().removeAllRanges();
|
||||||
|
alert(self.getJSONPath());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_NodeSimple.prototype = {
|
||||||
|
constructor : _NodeSimple,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark node
|
||||||
|
*/
|
||||||
|
mark : function() {
|
||||||
|
this.el.classList.add('jsontree_node_marked');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmark node
|
||||||
|
*/
|
||||||
|
unmark : function() {
|
||||||
|
this.el.classList.remove('jsontree_node_marked');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark or unmark node
|
||||||
|
*/
|
||||||
|
toggleMarked : function() {
|
||||||
|
this.el.classList.toggle('jsontree_node_marked');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expands parent node of this node
|
||||||
|
*
|
||||||
|
* @param isRecursive {boolean} - if true, expands all parent nodes
|
||||||
|
* (from node to root)
|
||||||
|
*/
|
||||||
|
expandParent : function(isRecursive) {
|
||||||
|
if (!this.parent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.parent.expand();
|
||||||
|
this.parent.expandParent(isRecursive);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns JSON-path of this
|
||||||
|
*
|
||||||
|
* @param isInDotNotation {boolean} - kind of notation for returned json-path
|
||||||
|
* (by default, in bracket notation)
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getJSONPath : function(isInDotNotation) {
|
||||||
|
if (this.isRoot) {
|
||||||
|
return "$";
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentPath;
|
||||||
|
|
||||||
|
if (this.parent.type === 'array') {
|
||||||
|
currentPath = "[" + this.label + "]";
|
||||||
|
} else {
|
||||||
|
currentPath = isInDotNotation ? "." + this.label : "['" + this.label + "']";
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.parent.getJSONPath(isInDotNotation) + currentPath;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The constructor for boolean values
|
||||||
|
* {...
|
||||||
|
* [+] "label": boolean,
|
||||||
|
* ...}
|
||||||
|
* boolean = true || false
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
* @param label {string} - key name
|
||||||
|
* @param val {boolean} - value of boolean type, true or false
|
||||||
|
* @param isLast {boolean} - true if node is last in list of parent childNodes
|
||||||
|
*/
|
||||||
|
function NodeBoolean(label, val, isLast) {
|
||||||
|
this.type = "boolean";
|
||||||
|
|
||||||
|
_NodeSimple.call(this, label, val, isLast);
|
||||||
|
}
|
||||||
|
utils.inherits(NodeBoolean,_NodeSimple);
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The constructor for number values
|
||||||
|
* {...
|
||||||
|
* [+] "label": number,
|
||||||
|
* ...}
|
||||||
|
* number = 123
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
* @param label {string} - key name
|
||||||
|
* @param val {number} - value of number type, for example 123
|
||||||
|
* @param isLast {boolean} - true if node is last in list of parent childNodes
|
||||||
|
*/
|
||||||
|
function NodeNumber(label, val, isLast) {
|
||||||
|
this.type = "number";
|
||||||
|
|
||||||
|
_NodeSimple.call(this, label, val, isLast);
|
||||||
|
}
|
||||||
|
utils.inherits(NodeNumber,_NodeSimple);
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The constructor for string values
|
||||||
|
* {...
|
||||||
|
* [+] "label": string,
|
||||||
|
* ...}
|
||||||
|
* string = "abc"
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
* @param label {string} - key name
|
||||||
|
* @param val {string} - value of string type, for example "abc"
|
||||||
|
* @param isLast {boolean} - true if node is last in list of parent childNodes
|
||||||
|
*/
|
||||||
|
function NodeString(label, val, isLast) {
|
||||||
|
this.type = "string";
|
||||||
|
|
||||||
|
_NodeSimple.call(this, label, '"' + val + '"', isLast);
|
||||||
|
}
|
||||||
|
utils.inherits(NodeString,_NodeSimple);
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The constructor for null values
|
||||||
|
* {...
|
||||||
|
* [+] "label": null,
|
||||||
|
* ...}
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
* @param label {string} - key name
|
||||||
|
* @param val {null} - value (only null)
|
||||||
|
* @param isLast {boolean} - true if node is last in list of parent childNodes
|
||||||
|
*/
|
||||||
|
function NodeNull(label, val, isLast) {
|
||||||
|
this.type = "null";
|
||||||
|
|
||||||
|
_NodeSimple.call(this, label, val, isLast);
|
||||||
|
}
|
||||||
|
utils.inherits(NodeNull,_NodeSimple);
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The constructor for complex types (object, array)
|
||||||
|
* {...
|
||||||
|
* [+] "label": value,
|
||||||
|
* ...}
|
||||||
|
* value = object || array
|
||||||
|
*
|
||||||
|
* Markup:
|
||||||
|
* <li class="jsontree_node jsontree_node_(object|array) [expanded]">
|
||||||
|
* <span class="jsontree_label-wrapper">
|
||||||
|
* <span class="jsontree_label">
|
||||||
|
* <span class="jsontree_expand-button" />
|
||||||
|
* "label"
|
||||||
|
* </span>
|
||||||
|
* :
|
||||||
|
* </span>
|
||||||
|
* <div class="jsontree_value">
|
||||||
|
* <b>{</b>
|
||||||
|
* <ul class="jsontree_child-nodes" />
|
||||||
|
* <b>}</b>
|
||||||
|
* ,
|
||||||
|
* </div>
|
||||||
|
* </li>
|
||||||
|
*
|
||||||
|
* @abstract
|
||||||
|
* @param label {string} - key name
|
||||||
|
* @param val {Object | Array} - a value of complex types, object or array
|
||||||
|
* @param isLast {boolean} - true if node is last in list of parent childNodes
|
||||||
|
*/
|
||||||
|
function _NodeComplex(label, val, isLast) {
|
||||||
|
if (this.constructor === _NodeComplex) {
|
||||||
|
throw new Error('This is abstract class');
|
||||||
|
}
|
||||||
|
|
||||||
|
var self = this,
|
||||||
|
el = document.createElement('li'),
|
||||||
|
template = function(label, sym) {
|
||||||
|
var comma = (!isLast) ? ',' : '',
|
||||||
|
str = '\
|
||||||
|
<div class="jsontree_value-wrapper">\
|
||||||
|
<div class="jsontree_value jsontree_value_' + self.type + '">\
|
||||||
|
<b>' + sym[0] + '</b>\
|
||||||
|
<span class="jsontree_show-more">…</span>\
|
||||||
|
<ul class="jsontree_child-nodes"></ul>\
|
||||||
|
<b>' + sym[1] + '</b>' +
|
||||||
|
'</div>' + comma +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
if (label !== null) {
|
||||||
|
str = '\
|
||||||
|
<span class="jsontree_label-wrapper">\
|
||||||
|
<span class="jsontree_label">' +
|
||||||
|
'<span class="jsontree_expand-button"></span>' +
|
||||||
|
'"' + label +
|
||||||
|
'"</span> : \
|
||||||
|
</span>' + str;
|
||||||
|
}
|
||||||
|
|
||||||
|
return str;
|
||||||
|
},
|
||||||
|
childNodesUl,
|
||||||
|
labelEl,
|
||||||
|
moreContentEl,
|
||||||
|
childNodes = [];
|
||||||
|
|
||||||
|
self.label = label;
|
||||||
|
self.isComplex = true;
|
||||||
|
|
||||||
|
el.classList.add('jsontree_node');
|
||||||
|
el.classList.add('jsontree_node_complex');
|
||||||
|
el.innerHTML = template(label, self.sym);
|
||||||
|
|
||||||
|
childNodesUl = el.querySelector('.jsontree_child-nodes');
|
||||||
|
|
||||||
|
if (label !== null) {
|
||||||
|
labelEl = el.querySelector('.jsontree_label');
|
||||||
|
moreContentEl = el.querySelector('.jsontree_show-more');
|
||||||
|
|
||||||
|
labelEl.addEventListener('click', function(e) {
|
||||||
|
if (e.altKey) {
|
||||||
|
self.toggleMarked();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
document.getSelection().removeAllRanges();
|
||||||
|
alert(self.getJSONPath());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.toggle(e.ctrlKey || e.metaKey);
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
moreContentEl.addEventListener('click', function(e) {
|
||||||
|
self.toggle(e.ctrlKey || e.metaKey);
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
self.isRoot = false;
|
||||||
|
} else {
|
||||||
|
self.isRoot = true;
|
||||||
|
self.parent = null;
|
||||||
|
|
||||||
|
el.classList.add('jsontree_node_expanded');
|
||||||
|
}
|
||||||
|
|
||||||
|
self.el = el;
|
||||||
|
self.childNodes = childNodes;
|
||||||
|
self.childNodesUl = childNodesUl;
|
||||||
|
|
||||||
|
utils.forEachNode(val, function(label, node, isLast) {
|
||||||
|
self.addChild(new Node(label, node, isLast));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.isEmpty = !Boolean(childNodes.length);
|
||||||
|
if (self.isEmpty) {
|
||||||
|
el.classList.add('jsontree_node_empty');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.inherits(_NodeComplex, _NodeSimple);
|
||||||
|
|
||||||
|
utils.extend(_NodeComplex.prototype, {
|
||||||
|
constructor : _NodeComplex,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Add child node to list of child nodes
|
||||||
|
*
|
||||||
|
* @param child {Node} - child node
|
||||||
|
*/
|
||||||
|
addChild : function(child) {
|
||||||
|
this.childNodes.push(child);
|
||||||
|
this.childNodesUl.appendChild(child.el);
|
||||||
|
child.parent = this;
|
||||||
|
},
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Expands this list of node child nodes
|
||||||
|
*
|
||||||
|
* @param isRecursive {boolean} - if true, expands all child nodes
|
||||||
|
*/
|
||||||
|
expand : function(isRecursive){
|
||||||
|
if (this.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isRoot) {
|
||||||
|
this.el.classList.add('jsontree_node_expanded');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecursive) {
|
||||||
|
this.childNodes.forEach(function(item, i) {
|
||||||
|
if (item.isComplex) {
|
||||||
|
item.expand(isRecursive);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Collapses this list of node child nodes
|
||||||
|
*
|
||||||
|
* @param isRecursive {boolean} - if true, collapses all child nodes
|
||||||
|
*/
|
||||||
|
collapse : function(isRecursive) {
|
||||||
|
if (this.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isRoot) {
|
||||||
|
this.el.classList.remove('jsontree_node_expanded');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecursive) {
|
||||||
|
this.childNodes.forEach(function(item, i) {
|
||||||
|
if (item.isComplex) {
|
||||||
|
item.collapse(isRecursive);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Expands collapsed or collapses expanded node
|
||||||
|
*
|
||||||
|
* @param {boolean} isRecursive - Expand all child nodes if this node is expanded
|
||||||
|
* and collapse it otherwise
|
||||||
|
*/
|
||||||
|
toggle : function(isRecursive) {
|
||||||
|
if (this.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.el.classList.toggle('jsontree_node_expanded');
|
||||||
|
|
||||||
|
if (isRecursive) {
|
||||||
|
var isExpanded = this.el.classList.contains('jsontree_node_expanded');
|
||||||
|
|
||||||
|
this.childNodes.forEach(function(item, i) {
|
||||||
|
if (item.isComplex) {
|
||||||
|
item[isExpanded ? 'expand' : 'collapse'](isRecursive);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find child nodes that match some conditions and handle it
|
||||||
|
*
|
||||||
|
* @param {Function} matcher
|
||||||
|
* @param {Function} handler
|
||||||
|
* @param {boolean} isRecursive
|
||||||
|
*/
|
||||||
|
findChildren : function(matcher, handler, isRecursive) {
|
||||||
|
if (this.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.childNodes.forEach(function(item, i) {
|
||||||
|
if (matcher(item)) {
|
||||||
|
handler(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.isComplex && isRecursive) {
|
||||||
|
item.findChildren(matcher, handler, isRecursive);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The constructor for object values
|
||||||
|
* {...
|
||||||
|
* [+] "label": object,
|
||||||
|
* ...}
|
||||||
|
* object = {"abc": "def"}
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
* @param label {string} - key name
|
||||||
|
* @param val {Object} - value of object type, {"abc": "def"}
|
||||||
|
* @param isLast {boolean} - true if node is last in list of siblings
|
||||||
|
*/
|
||||||
|
function NodeObject(label, val, isLast) {
|
||||||
|
this.sym = ['{', '}'];
|
||||||
|
this.type = "object";
|
||||||
|
|
||||||
|
_NodeComplex.call(this, label, val, isLast);
|
||||||
|
}
|
||||||
|
utils.inherits(NodeObject,_NodeComplex);
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The constructor for array values
|
||||||
|
* {...
|
||||||
|
* [+] "label": array,
|
||||||
|
* ...}
|
||||||
|
* array = [1,2,3]
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
* @param label {string} - key name
|
||||||
|
* @param val {Array} - value of array type, [1,2,3]
|
||||||
|
* @param isLast {boolean} - true if node is last in list of siblings
|
||||||
|
*/
|
||||||
|
function NodeArray(label, val, isLast) {
|
||||||
|
this.sym = ['[', ']'];
|
||||||
|
this.type = "array";
|
||||||
|
|
||||||
|
_NodeComplex.call(this, label, val, isLast);
|
||||||
|
}
|
||||||
|
utils.inherits(NodeArray, _NodeComplex);
|
||||||
|
|
||||||
|
|
||||||
|
/* ---------- The tree constructor ---------- */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The constructor for json tree.
|
||||||
|
* It contains only one Node (Array or Object), without property name.
|
||||||
|
* CSS-styles of .tree define main tree styles like font-family,
|
||||||
|
* font-size and own margins.
|
||||||
|
*
|
||||||
|
* Markup:
|
||||||
|
* <ul class="jsontree_tree clearfix">
|
||||||
|
* {Node}
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
* @param jsonObj {Object | Array} - data for tree
|
||||||
|
* @param domEl {DOMElement} - DOM-element, wrapper for tree
|
||||||
|
*/
|
||||||
|
function Tree(jsonObj, domEl) {
|
||||||
|
this.wrapper = document.createElement('ul');
|
||||||
|
this.wrapper.className = 'jsontree_tree clearfix';
|
||||||
|
|
||||||
|
this.rootNode = null;
|
||||||
|
|
||||||
|
this.sourceJSONObj = jsonObj;
|
||||||
|
|
||||||
|
this.loadData(jsonObj);
|
||||||
|
this.appendTo(domEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
Tree.prototype = {
|
||||||
|
constructor : Tree,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill new data in current json tree
|
||||||
|
*
|
||||||
|
* @param {Object | Array} jsonObj - json-data
|
||||||
|
*/
|
||||||
|
loadData : function(jsonObj) {
|
||||||
|
if (!utils.isValidRoot(jsonObj)) {
|
||||||
|
alert('The root should be an object or an array');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sourceJSONObj = jsonObj;
|
||||||
|
|
||||||
|
this.rootNode = new Node(null, jsonObj, 'last');
|
||||||
|
this.wrapper.innerHTML = '';
|
||||||
|
this.wrapper.appendChild(this.rootNode.el);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends tree to DOM-element (or move it to new place)
|
||||||
|
*
|
||||||
|
* @param {DOMElement} domEl
|
||||||
|
*/
|
||||||
|
appendTo : function(domEl) {
|
||||||
|
domEl.appendChild(this.wrapper);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expands all tree nodes (objects or arrays) recursively
|
||||||
|
*
|
||||||
|
* @param {Function} filterFunc - 'true' if this node should be expanded
|
||||||
|
*/
|
||||||
|
expand : function(filterFunc) {
|
||||||
|
if (this.rootNode.isComplex) {
|
||||||
|
if (typeof filterFunc == 'function') {
|
||||||
|
this.rootNode.childNodes.forEach(function(item, i) {
|
||||||
|
if (item.isComplex && filterFunc(item)) {
|
||||||
|
item.expand();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.rootNode.expand('recursive');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapses all tree nodes (objects or arrays) recursively
|
||||||
|
*/
|
||||||
|
collapse : function() {
|
||||||
|
if (typeof this.rootNode.collapse === 'function') {
|
||||||
|
this.rootNode.collapse('recursive');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the source json-string (pretty-printed)
|
||||||
|
*
|
||||||
|
* @param {boolean} isPrettyPrinted - 'true' for pretty-printed string
|
||||||
|
* @returns {string} - for exemple, '{"a":2,"b":3}'
|
||||||
|
*/
|
||||||
|
toSourceJSON : function(isPrettyPrinted) {
|
||||||
|
if (!isPrettyPrinted) {
|
||||||
|
return JSON.stringify(this.sourceJSONObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
var DELIMETER = "[%^$#$%^%]",
|
||||||
|
jsonStr = JSON.stringify(this.sourceJSONObj, null, DELIMETER);
|
||||||
|
|
||||||
|
jsonStr = jsonStr.split("\n").join("<br />");
|
||||||
|
jsonStr = jsonStr.split(DELIMETER).join(" ");
|
||||||
|
|
||||||
|
return jsonStr;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all nodes that match some conditions and handle it
|
||||||
|
*/
|
||||||
|
findAndHandle : function(matcher, handler) {
|
||||||
|
this.rootNode.findChildren(matcher, handler, 'isRecursive');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmark all nodes
|
||||||
|
*/
|
||||||
|
unmarkAll : function() {
|
||||||
|
this.rootNode.findChildren(function(node) {
|
||||||
|
return true;
|
||||||
|
}, function(node) {
|
||||||
|
node.unmark();
|
||||||
|
}, 'isRecursive');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/* ---------- Public methods ---------- */
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Creates new tree by data and appends it to the DOM-element
|
||||||
|
*
|
||||||
|
* @param jsonObj {Object | Array} - json-data
|
||||||
|
* @param domEl {DOMElement} - the wrapper element
|
||||||
|
* @returns {Tree}
|
||||||
|
*/
|
||||||
|
create : function(jsonObj, domEl) {
|
||||||
|
return new Tree(jsonObj, domEl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,132 @@
|
||||||
|
<template>
|
||||||
|
<div class="colorPickerContainer">
|
||||||
|
<div class="content">
|
||||||
|
<el-popover
|
||||||
|
:placement="placement"
|
||||||
|
:width="200"
|
||||||
|
trigger="click"
|
||||||
|
:disabled="colorList.length <= 0"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<div class="colorPreview" :style="{ backgroundColor: color }"></div>
|
||||||
|
</template>
|
||||||
|
<div class="colorList">
|
||||||
|
<div
|
||||||
|
class="colorItem"
|
||||||
|
v-for="item in colorList"
|
||||||
|
:key="item"
|
||||||
|
:style="{ backgroundColor: item }"
|
||||||
|
@click="color = item"
|
||||||
|
>
|
||||||
|
<span v-if="!item">无</span>
|
||||||
|
<span v-if="item === 'transparent'">透明</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
<el-input v-model="color">
|
||||||
|
<template #prepend>{{ name }}</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
strokeColorList,
|
||||||
|
fillColorList,
|
||||||
|
backgroundColorList
|
||||||
|
} from '../constants'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
value: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: '颜色'
|
||||||
|
},
|
||||||
|
placement: {
|
||||||
|
type: String,
|
||||||
|
default: 'bottom'
|
||||||
|
},
|
||||||
|
showEmptySelect: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emits = defineEmits(['change'])
|
||||||
|
|
||||||
|
const color = ref(props.value)
|
||||||
|
watch(
|
||||||
|
() => {
|
||||||
|
return props.value
|
||||||
|
},
|
||||||
|
val => {
|
||||||
|
color.value = val
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const colorList = computed(() => {
|
||||||
|
let list = props.showEmptySelect ? [''] : []
|
||||||
|
switch (props.type) {
|
||||||
|
case 'stroke':
|
||||||
|
list.push(...strokeColorList)
|
||||||
|
break
|
||||||
|
case 'fill':
|
||||||
|
list.push(...fillColorList)
|
||||||
|
break
|
||||||
|
case 'background':
|
||||||
|
list.push(...backgroundColorList)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
watch(color, () => {
|
||||||
|
emits('change', color.value)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.colorPickerContainer {
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.colorPreview {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorList {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, auto);
|
||||||
|
grid-gap: 5px;
|
||||||
|
.colorItem {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,218 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="contextmenuContainer"
|
||||||
|
v-if="isShow"
|
||||||
|
:style="{ left: left + 'px', top: top + 'px' }"
|
||||||
|
>
|
||||||
|
<template v-if="isHasActiveElements">
|
||||||
|
<div
|
||||||
|
class="item"
|
||||||
|
:class="{ disabled: !canMoveLevel }"
|
||||||
|
@click="exec('moveUp')"
|
||||||
|
>
|
||||||
|
上移一层
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="item"
|
||||||
|
:class="{ disabled: !canMoveLevel }"
|
||||||
|
@click="exec('moveDown')"
|
||||||
|
>
|
||||||
|
下移一层
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="item"
|
||||||
|
:class="{ disabled: !canMoveLevel }"
|
||||||
|
@click="exec('moveTop')"
|
||||||
|
>
|
||||||
|
置于顶层
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="item"
|
||||||
|
:class="{ disabled: !canMoveLevel }"
|
||||||
|
@click="exec('moveBottom')"
|
||||||
|
>
|
||||||
|
置于底层
|
||||||
|
</div>
|
||||||
|
<div class="splitLine"></div>
|
||||||
|
<div class="item danger" @click="exec('del')">删除</div>
|
||||||
|
<div class="item" @click="exec('copy')">复制</div>
|
||||||
|
<div
|
||||||
|
class="item"
|
||||||
|
:class="{ disabled: groupStatus === 'disabled' }"
|
||||||
|
@click="exec(groupStatus)"
|
||||||
|
>
|
||||||
|
{{ groupBtnText }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="item" @click="exec('selectAll')">全部选中</div>
|
||||||
|
<div class="item" @click="exec('backToCenter')">回到中心</div>
|
||||||
|
<div class="item" @click="exec('fit')">显示全部</div>
|
||||||
|
<div class="item" @click="exec('resetZoom')">重置缩放</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
app: {
|
||||||
|
type: Object
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isShow = ref(false)
|
||||||
|
const left = ref(0)
|
||||||
|
const top = ref(0)
|
||||||
|
const isHasActiveElements = ref(false)
|
||||||
|
const canMoveLevel = ref(false)
|
||||||
|
const groupStatus = ref('disabled')
|
||||||
|
const groupBtnText = computed(() => {
|
||||||
|
return {
|
||||||
|
disabled: '编组',
|
||||||
|
dogroup: '编组',
|
||||||
|
ungroup: '取消编组'
|
||||||
|
}[groupStatus.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
const show = (e, activeElements) => {
|
||||||
|
isHasActiveElements.value = activeElements.length > 0
|
||||||
|
canMoveLevel.value = activeElements.length === 1
|
||||||
|
left.value = e.clientX + 10
|
||||||
|
top.value = e.clientY + 10
|
||||||
|
isShow.value = true
|
||||||
|
handleGroup(activeElements)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGroup = activeElements => {
|
||||||
|
let isGroup = true
|
||||||
|
activeElements.forEach(item => {
|
||||||
|
if (!item.hasGroup()) {
|
||||||
|
isGroup = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (isGroup) {
|
||||||
|
groupStatus.value = 'ungroup'
|
||||||
|
} else if (activeElements.length > 1) {
|
||||||
|
groupStatus.value = 'dogroup'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
isShow.value = false
|
||||||
|
left.value = 0
|
||||||
|
top.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
props.app.on('contextmenu', show)
|
||||||
|
|
||||||
|
document.body.addEventListener('click', hide)
|
||||||
|
|
||||||
|
const exec = command => {
|
||||||
|
switch (command) {
|
||||||
|
case 'moveUp':
|
||||||
|
props.app.moveUpCurrentElement()
|
||||||
|
break
|
||||||
|
case 'moveDown':
|
||||||
|
props.app.moveDownCurrentElement()
|
||||||
|
break
|
||||||
|
case 'moveTop':
|
||||||
|
props.app.moveTopCurrentElement()
|
||||||
|
break
|
||||||
|
case 'moveBottom':
|
||||||
|
props.app.moveBottomCurrentElement()
|
||||||
|
break
|
||||||
|
case 'del':
|
||||||
|
props.app.deleteCurrentElements()
|
||||||
|
break
|
||||||
|
case 'copy':
|
||||||
|
props.app.copyPasteCurrentElements()
|
||||||
|
break
|
||||||
|
case 'selectAll':
|
||||||
|
props.app.selectAll()
|
||||||
|
break
|
||||||
|
case 'backToCenter':
|
||||||
|
props.app.scrollToCenter()
|
||||||
|
break
|
||||||
|
case 'fit':
|
||||||
|
props.app.fit()
|
||||||
|
break
|
||||||
|
case 'resetZoom':
|
||||||
|
props.app.setZoom(1)
|
||||||
|
case 'dogroup':
|
||||||
|
props.app.dogroup()
|
||||||
|
break
|
||||||
|
case 'ungroup':
|
||||||
|
props.app.ungroup()
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// onMousedown(e) {
|
||||||
|
// if (e.which !== 3) {
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// this.mosuedownX = e.clientX;
|
||||||
|
// this.mosuedownY = e.clientY;
|
||||||
|
// this.isMousedown = true;
|
||||||
|
// }
|
||||||
|
// onMouseup(e) {
|
||||||
|
// if (!this.isMousedown) {
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// this.isMousedown = false;
|
||||||
|
// if (
|
||||||
|
// Math.abs(this.mosuedownX - e.clientX) > 3 ||
|
||||||
|
// Math.abs(this.mosuedownY - e.clientY) > 3
|
||||||
|
// ) {
|
||||||
|
// this.hide();
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// this.show2(e);
|
||||||
|
// },
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.contextmenuContainer {
|
||||||
|
position: fixed;
|
||||||
|
width: 161px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 4px 12px 0 hsla(0, 0%, 69%, 0.5);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding-top: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: PingFangSC-Regular, PingFang SC;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #1a1a1a;
|
||||||
|
|
||||||
|
.splitLine {
|
||||||
|
height: 1px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
height: 28px;
|
||||||
|
line-height: 28px;
|
||||||
|
padding-left: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
&.danger {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
&.disabled {
|
||||||
|
color: grey;
|
||||||
|
cursor: not-allowed;
|
||||||
|
&:hover {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,120 @@
|
||||||
|
// 描边颜色
|
||||||
|
export const strokeColorList = [
|
||||||
|
'#000000',
|
||||||
|
'#343a40',
|
||||||
|
'#495057',
|
||||||
|
'#c92a2a',
|
||||||
|
'#a61e4d',
|
||||||
|
'#862e9c',
|
||||||
|
'#5f3dc4',
|
||||||
|
'#364fc7',
|
||||||
|
'#1864ab',
|
||||||
|
'#0b7285',
|
||||||
|
'#087f5b',
|
||||||
|
'#2b8a3e',
|
||||||
|
'#5c940d',
|
||||||
|
'#e67700',
|
||||||
|
'#d9480f'
|
||||||
|
]
|
||||||
|
|
||||||
|
// 填充颜色
|
||||||
|
export const fillColorList = [
|
||||||
|
'transparent',
|
||||||
|
'#ced4da',
|
||||||
|
'#868e96',
|
||||||
|
'#fa5252',
|
||||||
|
'#e64980',
|
||||||
|
'#be4bdb',
|
||||||
|
'#7950f2',
|
||||||
|
'#4c6ef5',
|
||||||
|
'#228be6',
|
||||||
|
'#15aabf',
|
||||||
|
'#12b886',
|
||||||
|
'#40c057',
|
||||||
|
'#82c91e',
|
||||||
|
'#fab005',
|
||||||
|
'#fd7e14'
|
||||||
|
]
|
||||||
|
|
||||||
|
// 背景颜色
|
||||||
|
export const backgroundColorList = [
|
||||||
|
'rgb(255, 255, 255)',
|
||||||
|
'rgb(248, 249, 250)',
|
||||||
|
'rgb(241, 243, 245)',
|
||||||
|
'rgb(255, 245, 245)',
|
||||||
|
'rgb(255, 240, 246)',
|
||||||
|
'rgb(248, 240, 252)',
|
||||||
|
'rgb(243, 240, 255)',
|
||||||
|
'rgb(237, 242, 255)',
|
||||||
|
'rgb(231, 245, 255)',
|
||||||
|
'rgb(227, 250, 252)',
|
||||||
|
'rgb(230, 252, 245)',
|
||||||
|
'rgb(235, 251, 238)',
|
||||||
|
'rgb(244, 252, 227)',
|
||||||
|
'rgb(255, 249, 219)',
|
||||||
|
'rgb(255, 244, 230)'
|
||||||
|
]
|
||||||
|
|
||||||
|
// 字体列表
|
||||||
|
export const fontFamilyList = [
|
||||||
|
{
|
||||||
|
name: '微软雅黑',
|
||||||
|
value: '微软雅黑, Microsoft YaHei'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '宋体',
|
||||||
|
value: '宋体, SimSun, Songti SC'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '楷体',
|
||||||
|
value: '楷体, 楷体_GB2312, SimKai, STKaiti'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '黑体',
|
||||||
|
value: '黑体, SimHei, Heiti SC'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '隶书',
|
||||||
|
value: '隶书, SimLi'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Andale Mono',
|
||||||
|
value: 'andale mono'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Arial',
|
||||||
|
value: 'arial, helvetica, sans-serif'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'arialBlack',
|
||||||
|
value: 'arial black, avant garde'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Comic Sans Ms',
|
||||||
|
value: 'comic sans ms'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Impact',
|
||||||
|
value: 'impact, chicago'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Times New Roman',
|
||||||
|
value: 'times new roman'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Sans-Serif',
|
||||||
|
value: 'sans-serif'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'serif',
|
||||||
|
value: 'serif'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// 字号
|
||||||
|
export const fontSizeList = [10, 12, 16, 18, 24, 32, 48].map(item => {
|
||||||
|
return {
|
||||||
|
name: item,
|
||||||
|
value: item
|
||||||
|
}
|
||||||
|
})
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import AutoImport from 'unplugin-auto-import/vite'
|
||||||
|
import Components from 'unplugin-vue-components/vite'
|
||||||
|
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
base: '/tiny_whiteboard_demo/',
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
AutoImport({
|
||||||
|
resolvers: [ElementPlusResolver()]
|
||||||
|
}),
|
||||||
|
Components({
|
||||||
|
resolvers: [ElementPlusResolver()]
|
||||||
|
})
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
fs: {
|
||||||
|
// 可以为项目根目录的上一级提供服务
|
||||||
|
allow: ['..']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
|
@ -0,0 +1,132 @@
|
||||||
|
<template>
|
||||||
|
<div class="colorPickerContainer">
|
||||||
|
<div class="content">
|
||||||
|
<el-popover
|
||||||
|
:placement="placement"
|
||||||
|
:width="200"
|
||||||
|
trigger="click"
|
||||||
|
:disabled="colorList.length <= 0"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<div class="colorPreview" :style="{ backgroundColor: color }"></div>
|
||||||
|
</template>
|
||||||
|
<div class="colorList">
|
||||||
|
<div
|
||||||
|
class="colorItem"
|
||||||
|
v-for="item in colorList"
|
||||||
|
:key="item"
|
||||||
|
:style="{ backgroundColor: item }"
|
||||||
|
@click="color = item"
|
||||||
|
>
|
||||||
|
<span v-if="!item">无</span>
|
||||||
|
<span v-if="item === 'transparent'">透明</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
<el-input v-model="color">
|
||||||
|
<template #prepend>{{ name }}</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
strokeColorList,
|
||||||
|
fillColorList,
|
||||||
|
backgroundColorList
|
||||||
|
} from '../constants'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
value: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: '颜色'
|
||||||
|
},
|
||||||
|
placement: {
|
||||||
|
type: String,
|
||||||
|
default: 'bottom'
|
||||||
|
},
|
||||||
|
showEmptySelect: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emits = defineEmits(['change'])
|
||||||
|
|
||||||
|
const color = ref(props.value)
|
||||||
|
watch(
|
||||||
|
() => {
|
||||||
|
return props.value
|
||||||
|
},
|
||||||
|
val => {
|
||||||
|
color.value = val
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const colorList = computed(() => {
|
||||||
|
let list = props.showEmptySelect ? [''] : []
|
||||||
|
switch (props.type) {
|
||||||
|
case 'stroke':
|
||||||
|
list.push(...strokeColorList)
|
||||||
|
break
|
||||||
|
case 'fill':
|
||||||
|
list.push(...fillColorList)
|
||||||
|
break
|
||||||
|
case 'background':
|
||||||
|
list.push(...backgroundColorList)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
watch(color, () => {
|
||||||
|
emits('change', color.value)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.colorPickerContainer {
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.colorPreview {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorList {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, auto);
|
||||||
|
grid-gap: 5px;
|
||||||
|
.colorItem {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,218 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="contextmenuContainer"
|
||||||
|
v-if="isShow"
|
||||||
|
:style="{ left: left + 'px', top: top + 'px' }"
|
||||||
|
>
|
||||||
|
<template v-if="isHasActiveElements">
|
||||||
|
<div
|
||||||
|
class="item"
|
||||||
|
:class="{ disabled: !canMoveLevel }"
|
||||||
|
@click="exec('moveUp')"
|
||||||
|
>
|
||||||
|
上移一层
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="item"
|
||||||
|
:class="{ disabled: !canMoveLevel }"
|
||||||
|
@click="exec('moveDown')"
|
||||||
|
>
|
||||||
|
下移一层
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="item"
|
||||||
|
:class="{ disabled: !canMoveLevel }"
|
||||||
|
@click="exec('moveTop')"
|
||||||
|
>
|
||||||
|
置于顶层
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="item"
|
||||||
|
:class="{ disabled: !canMoveLevel }"
|
||||||
|
@click="exec('moveBottom')"
|
||||||
|
>
|
||||||
|
置于底层
|
||||||
|
</div>
|
||||||
|
<div class="splitLine"></div>
|
||||||
|
<div class="item danger" @click="exec('del')">删除</div>
|
||||||
|
<div class="item" @click="exec('copy')">复制</div>
|
||||||
|
<div
|
||||||
|
class="item"
|
||||||
|
:class="{ disabled: groupStatus === 'disabled' }"
|
||||||
|
@click="exec(groupStatus)"
|
||||||
|
>
|
||||||
|
{{ groupBtnText }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="item" @click="exec('selectAll')">全部选中</div>
|
||||||
|
<div class="item" @click="exec('backToCenter')">回到中心</div>
|
||||||
|
<div class="item" @click="exec('fit')">显示全部</div>
|
||||||
|
<div class="item" @click="exec('resetZoom')">重置缩放</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
app: {
|
||||||
|
type: Object
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isShow = ref(false)
|
||||||
|
const left = ref(0)
|
||||||
|
const top = ref(0)
|
||||||
|
const isHasActiveElements = ref(false)
|
||||||
|
const canMoveLevel = ref(false)
|
||||||
|
const groupStatus = ref('disabled')
|
||||||
|
const groupBtnText = computed(() => {
|
||||||
|
return {
|
||||||
|
disabled: '编组',
|
||||||
|
dogroup: '编组',
|
||||||
|
ungroup: '取消编组'
|
||||||
|
}[groupStatus.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
const show = (e, activeElements) => {
|
||||||
|
isHasActiveElements.value = activeElements.length > 0
|
||||||
|
canMoveLevel.value = activeElements.length === 1
|
||||||
|
left.value = e.clientX + 10
|
||||||
|
top.value = e.clientY + 10
|
||||||
|
isShow.value = true
|
||||||
|
handleGroup(activeElements)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGroup = activeElements => {
|
||||||
|
let isGroup = true
|
||||||
|
activeElements.forEach(item => {
|
||||||
|
if (!item.hasGroup()) {
|
||||||
|
isGroup = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (isGroup) {
|
||||||
|
groupStatus.value = 'ungroup'
|
||||||
|
} else if (activeElements.length > 1) {
|
||||||
|
groupStatus.value = 'dogroup'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
isShow.value = false
|
||||||
|
left.value = 0
|
||||||
|
top.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
props.app.on('contextmenu', show)
|
||||||
|
|
||||||
|
document.body.addEventListener('click', hide)
|
||||||
|
|
||||||
|
const exec = command => {
|
||||||
|
switch (command) {
|
||||||
|
case 'moveUp':
|
||||||
|
props.app.moveUpCurrentElement()
|
||||||
|
break
|
||||||
|
case 'moveDown':
|
||||||
|
props.app.moveDownCurrentElement()
|
||||||
|
break
|
||||||
|
case 'moveTop':
|
||||||
|
props.app.moveTopCurrentElement()
|
||||||
|
break
|
||||||
|
case 'moveBottom':
|
||||||
|
props.app.moveBottomCurrentElement()
|
||||||
|
break
|
||||||
|
case 'del':
|
||||||
|
props.app.deleteCurrentElements()
|
||||||
|
break
|
||||||
|
case 'copy':
|
||||||
|
props.app.copyPasteCurrentElements()
|
||||||
|
break
|
||||||
|
case 'selectAll':
|
||||||
|
props.app.selectAll()
|
||||||
|
break
|
||||||
|
case 'backToCenter':
|
||||||
|
props.app.scrollToCenter()
|
||||||
|
break
|
||||||
|
case 'fit':
|
||||||
|
props.app.fit()
|
||||||
|
break
|
||||||
|
case 'resetZoom':
|
||||||
|
props.app.setZoom(1)
|
||||||
|
case 'dogroup':
|
||||||
|
props.app.dogroup()
|
||||||
|
break
|
||||||
|
case 'ungroup':
|
||||||
|
props.app.ungroup()
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// onMousedown(e) {
|
||||||
|
// if (e.which !== 3) {
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// this.mosuedownX = e.clientX;
|
||||||
|
// this.mosuedownY = e.clientY;
|
||||||
|
// this.isMousedown = true;
|
||||||
|
// }
|
||||||
|
// onMouseup(e) {
|
||||||
|
// if (!this.isMousedown) {
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// this.isMousedown = false;
|
||||||
|
// if (
|
||||||
|
// Math.abs(this.mosuedownX - e.clientX) > 3 ||
|
||||||
|
// Math.abs(this.mosuedownY - e.clientY) > 3
|
||||||
|
// ) {
|
||||||
|
// this.hide();
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// this.show2(e);
|
||||||
|
// },
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.contextmenuContainer {
|
||||||
|
position: fixed;
|
||||||
|
width: 161px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 4px 12px 0 hsla(0, 0%, 69%, 0.5);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding-top: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: PingFangSC-Regular, PingFang SC;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #1a1a1a;
|
||||||
|
|
||||||
|
.splitLine {
|
||||||
|
height: 1px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
height: 28px;
|
||||||
|
line-height: 28px;
|
||||||
|
padding-left: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
&.danger {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
&.disabled {
|
||||||
|
color: grey;
|
||||||
|
cursor: not-allowed;
|
||||||
|
&:hover {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,120 @@
|
||||||
|
// 描边颜色
|
||||||
|
export const strokeColorList = [
|
||||||
|
'#000000',
|
||||||
|
'#343a40',
|
||||||
|
'#495057',
|
||||||
|
'#c92a2a',
|
||||||
|
'#a61e4d',
|
||||||
|
'#862e9c',
|
||||||
|
'#5f3dc4',
|
||||||
|
'#364fc7',
|
||||||
|
'#1864ab',
|
||||||
|
'#0b7285',
|
||||||
|
'#087f5b',
|
||||||
|
'#2b8a3e',
|
||||||
|
'#5c940d',
|
||||||
|
'#e67700',
|
||||||
|
'#d9480f'
|
||||||
|
]
|
||||||
|
|
||||||
|
// 填充颜色
|
||||||
|
export const fillColorList = [
|
||||||
|
'transparent',
|
||||||
|
'#ced4da',
|
||||||
|
'#868e96',
|
||||||
|
'#fa5252',
|
||||||
|
'#e64980',
|
||||||
|
'#be4bdb',
|
||||||
|
'#7950f2',
|
||||||
|
'#4c6ef5',
|
||||||
|
'#228be6',
|
||||||
|
'#15aabf',
|
||||||
|
'#12b886',
|
||||||
|
'#40c057',
|
||||||
|
'#82c91e',
|
||||||
|
'#fab005',
|
||||||
|
'#fd7e14'
|
||||||
|
]
|
||||||
|
|
||||||
|
// 背景颜色
|
||||||
|
export const backgroundColorList = [
|
||||||
|
'rgb(255, 255, 255)',
|
||||||
|
'rgb(248, 249, 250)',
|
||||||
|
'rgb(241, 243, 245)',
|
||||||
|
'rgb(255, 245, 245)',
|
||||||
|
'rgb(255, 240, 246)',
|
||||||
|
'rgb(248, 240, 252)',
|
||||||
|
'rgb(243, 240, 255)',
|
||||||
|
'rgb(237, 242, 255)',
|
||||||
|
'rgb(231, 245, 255)',
|
||||||
|
'rgb(227, 250, 252)',
|
||||||
|
'rgb(230, 252, 245)',
|
||||||
|
'rgb(235, 251, 238)',
|
||||||
|
'rgb(244, 252, 227)',
|
||||||
|
'rgb(255, 249, 219)',
|
||||||
|
'rgb(255, 244, 230)'
|
||||||
|
]
|
||||||
|
|
||||||
|
// 字体列表
|
||||||
|
export const fontFamilyList = [
|
||||||
|
{
|
||||||
|
name: '微软雅黑',
|
||||||
|
value: '微软雅黑, Microsoft YaHei'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '宋体',
|
||||||
|
value: '宋体, SimSun, Songti SC'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '楷体',
|
||||||
|
value: '楷体, 楷体_GB2312, SimKai, STKaiti'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '黑体',
|
||||||
|
value: '黑体, SimHei, Heiti SC'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '隶书',
|
||||||
|
value: '隶书, SimLi'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Andale Mono',
|
||||||
|
value: 'andale mono'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Arial',
|
||||||
|
value: 'arial, helvetica, sans-serif'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'arialBlack',
|
||||||
|
value: 'arial black, avant garde'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Comic Sans Ms',
|
||||||
|
value: 'comic sans ms'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Impact',
|
||||||
|
value: 'impact, chicago'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Times New Roman',
|
||||||
|
value: 'times new roman'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Sans-Serif',
|
||||||
|
value: 'sans-serif'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'serif',
|
||||||
|
value: 'serif'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// 字号
|
||||||
|
export const fontSizeList = [10, 12, 16, 18, 24, 32, 48].map(item => {
|
||||||
|
return {
|
||||||
|
name: item,
|
||||||
|
value: item
|
||||||
|
}
|
||||||
|
})
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "whiteboard_lyc",
|
||||||
|
"version": "0.0.3",
|
||||||
|
"description": "一个简单的在线白板",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "lyc",
|
||||||
|
"email": "ly_cheng2023@163.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"format": "prettier --write ."
|
||||||
|
},
|
||||||
|
"main": "dist/whiteboard_lyc.cjs.js",
|
||||||
|
"dependencies": {
|
||||||
|
"eventemitter3": "^4.0.7",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"prettier": "^2.7.1",
|
||||||
|
"vite": "^2.9.5"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
// 背景
|
||||||
|
export default class Background {
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置背景
|
||||||
|
set() {
|
||||||
|
if (this.app.state.backgroundColor) {
|
||||||
|
this.addBackgroundColor()
|
||||||
|
} else {
|
||||||
|
this.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加背景颜色
|
||||||
|
addBackgroundColor() {
|
||||||
|
this.app.container.style.backgroundColor = this.app.state.backgroundColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除背景
|
||||||
|
remove() {
|
||||||
|
this.app.container.style.backgroundColor = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在canvas内设置背景颜色,非css样式
|
||||||
|
canvasAddBackgroundColor(ctx, width, height, backgroundColor) {
|
||||||
|
// 背景颜色
|
||||||
|
ctx.save()
|
||||||
|
ctx.rect(0, 0, width, height)
|
||||||
|
ctx.fillStyle = backgroundColor
|
||||||
|
ctx.fill()
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { createCanvas } from './utils'
|
||||||
|
|
||||||
|
// 画布类
|
||||||
|
export default class Canvas {
|
||||||
|
constructor(width, height, opt) {
|
||||||
|
this.width = width
|
||||||
|
this.height = height
|
||||||
|
let { canvas, ctx } = createCanvas(width, height, opt)
|
||||||
|
this.el = canvas
|
||||||
|
this.ctx = ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除画布
|
||||||
|
clearCanvas() {
|
||||||
|
let { width, height } = this
|
||||||
|
this.ctx.clearRect(-width / 2, -height / 2, width, height)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,117 @@
|
||||||
|
// 坐标转换类
|
||||||
|
export default class Coordinate {
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加垂直滚动距离
|
||||||
|
addScrollY(y) {
|
||||||
|
return y + this.app.state.scrollY
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加水平滚动距离
|
||||||
|
addScrollX(x) {
|
||||||
|
return x + this.app.state.scrollX
|
||||||
|
}
|
||||||
|
|
||||||
|
// 减去垂直滚动距离
|
||||||
|
subScrollY(y) {
|
||||||
|
return y - this.app.state.scrollY
|
||||||
|
}
|
||||||
|
|
||||||
|
// 减去水平滚动距离
|
||||||
|
subScrollX(x) {
|
||||||
|
return x - this.app.state.scrollX
|
||||||
|
}
|
||||||
|
|
||||||
|
// 屏幕坐标转换成画布坐标
|
||||||
|
transformToCanvasCoordinate(x, y) {
|
||||||
|
x -= this.app.width / 2
|
||||||
|
y -= this.app.height / 2
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 画布转换转屏幕坐标
|
||||||
|
transformToScreenCoordinate(x, y) {
|
||||||
|
x += this.app.width / 2
|
||||||
|
y += this.app.height / 2
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 综合转换,屏幕坐标转画布坐标,再减去滚动值
|
||||||
|
transform(x, y) {
|
||||||
|
let t = this.transformToCanvasCoordinate(x, y)
|
||||||
|
return {
|
||||||
|
x: this.subScrollX(t.x),
|
||||||
|
y: this.subScrollY(t.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 相对窗口的坐标转换成相对容器的,用于当容器非全屏的时候
|
||||||
|
windowToContainer(x, y) {
|
||||||
|
return {
|
||||||
|
x: x - this.app.left,
|
||||||
|
y: y - this.app.top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 相对容器的坐标转换成相对窗口的,用于当容器非全屏的时候
|
||||||
|
containerToWindow(x, y) {
|
||||||
|
return {
|
||||||
|
x: x + this.app.left,
|
||||||
|
y: y + this.app.top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 屏幕坐标在应用画布缩放后的位置
|
||||||
|
scale(x, y) {
|
||||||
|
let { state } = this.app
|
||||||
|
// 屏幕坐标转画布坐标
|
||||||
|
let wp = this.transformToCanvasCoordinate(x, y)
|
||||||
|
let sp = this.transformToScreenCoordinate(
|
||||||
|
wp.x * state.scale,
|
||||||
|
wp.y * state.scale
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
x: sp.x,
|
||||||
|
y: sp.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 屏幕坐标在反向应用画布缩放后的位置
|
||||||
|
reverseScale(x, y) {
|
||||||
|
let { state } = this.app
|
||||||
|
// 屏幕坐标转画布坐标
|
||||||
|
let tp = this.transformToCanvasCoordinate(x, y)
|
||||||
|
let sp = this.transformToScreenCoordinate(
|
||||||
|
tp.x / state.scale,
|
||||||
|
tp.y / state.scale
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
x: sp.x,
|
||||||
|
y: sp.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 网格吸附
|
||||||
|
gridAdsorbent(x, y) {
|
||||||
|
let { gridConfig, showGrid } = this.app.state
|
||||||
|
if (!showGrid) {
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let gridSize = gridConfig.size
|
||||||
|
return {
|
||||||
|
x: x - (x % gridSize),
|
||||||
|
y: y - (y % gridSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { CORNERS, DRAG_ELEMENT_PARTS } from './constants'
|
||||||
|
|
||||||
|
// 鼠标样式类
|
||||||
|
export default class Cursor {
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app
|
||||||
|
this.currentType = 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置鼠标指针样式
|
||||||
|
set(type = 'default') {
|
||||||
|
this.currentType = type
|
||||||
|
let style = type
|
||||||
|
if (type === 'eraser') {
|
||||||
|
style = `url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAktJREFUWEfFlj+LFEEQxV+1GCiYmPgnEMVMAwM1VBQ/gIF46SnsdMuKYCRooiYaGJiI0jWB3ImBHuhFIhh4cOldYmJi4FcwX6a05XaZbWd7qnsObJhk6Kn366pX1UP4z4uG6o/H48OTyeQGgDNEtOK9/5wTcxCAc+6UiLwHcLol+piZH2khigHa4kT0BcDdFowaogggOvkHZr4WThy9V0FkA0QiK8wc6j9buRBZAFHwF8x8p6vWORBqgKjmT733D1JG00KoALTBYiDNd70AmiBDMpEEGCo+BUvFWQhgrT0CIPR3GDKqltJmgoiWvferYX8K4HkYLrshHmViU0S+1XV9OQlQVdUrIroFYImZ17SjNbVvNBpdMsZ8FZGNJEDHjB8MMRX/e+pUCWLDAHi4c6piiLZ4XNI5D0Tiz5j5nrU23GzFECnxOQ+0xUXkZV3Xt6f1LIXoE48BnojIfRFZret6OTZTLoRGfA7AWivhBTOnWlNVjh3xTwD29bXxTMxaG/5srv8ZPGvMvLSonfoy4Zy7KCIfARzsE48zEMQDRFhFEFVVnQewTkRHNeL/DCJrbTGEMeZ70zTrAE5qxTsn4QCIadWy7o1Ow2kgQrqJ6B2AEyLywxjzK3jYe885Yzvl+IXlcM5dEZG3AA7lpLsLLPk/0JUJ59xVEXkD4MBQ8eRt2JqCs0yEW4yILgDYsxviXV2wF8D+judYq0XDd1lGS3liVgLn3JaInM0xUOleItr23p+L74LXTdMcLw2a850x5qf3/qbKAzmBS/b+BpHvnTDlnKVDAAAAAElFTkSuQmCC) 10 10, auto`
|
||||||
|
}
|
||||||
|
this.app.container.style.cursor = style
|
||||||
|
}
|
||||||
|
|
||||||
|
// 隐藏鼠标指针
|
||||||
|
hide() {
|
||||||
|
this.set('none')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复位鼠标指针
|
||||||
|
reset() {
|
||||||
|
this.set()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置为 ✚ 字型
|
||||||
|
setCrosshair() {
|
||||||
|
this.set('crosshair')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置为 可移动 状态
|
||||||
|
setMove() {
|
||||||
|
this.set('move')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置为某个方向的可移动状态
|
||||||
|
setResize(dir) {
|
||||||
|
let type = ''
|
||||||
|
switch (dir) {
|
||||||
|
case DRAG_ELEMENT_PARTS.BODY:
|
||||||
|
type = 'move'
|
||||||
|
break
|
||||||
|
case DRAG_ELEMENT_PARTS.ROTATE:
|
||||||
|
type = 'grab'
|
||||||
|
break
|
||||||
|
case DRAG_ELEMENT_PARTS.TOP_LEFT_BTN:
|
||||||
|
type = 'nw-resize'
|
||||||
|
break
|
||||||
|
case DRAG_ELEMENT_PARTS.TOP_RIGHT_BTN:
|
||||||
|
type = 'ne-resize'
|
||||||
|
break
|
||||||
|
case DRAG_ELEMENT_PARTS.BOTTOM_RIGHT_BTN:
|
||||||
|
type = 'se-resize'
|
||||||
|
break
|
||||||
|
case DRAG_ELEMENT_PARTS.BOTTOM_LEFT_BTN:
|
||||||
|
type = 'sw-resize'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
this.set(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置为橡皮擦样式
|
||||||
|
setEraser() {
|
||||||
|
this.set('eraser')
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,482 @@
|
||||||
|
import Rectangle from './elements/Rectangle'
|
||||||
|
import Circle from './elements/Circle'
|
||||||
|
import Diamond from './elements/Diamond'
|
||||||
|
import Triangle from './elements/Triangle'
|
||||||
|
import Freedraw from './elements/Freedraw'
|
||||||
|
import Arrow from './elements/Arrow'
|
||||||
|
import Image from './elements/Image'
|
||||||
|
import Line from './elements/Line'
|
||||||
|
import Text from './elements/Text'
|
||||||
|
import {
|
||||||
|
getTowPointDistance,
|
||||||
|
throttle,
|
||||||
|
computedLineWidthBySpeed,
|
||||||
|
createImageObj
|
||||||
|
} from './utils'
|
||||||
|
import { DRAG_ELEMENT_PARTS } from './constants'
|
||||||
|
|
||||||
|
// 元素管理类
|
||||||
|
export default class Elements {
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app
|
||||||
|
// 所有元素
|
||||||
|
this.elementList = []
|
||||||
|
// 当前激活元素
|
||||||
|
this.activeElement = null
|
||||||
|
// 当前正在创建新元素
|
||||||
|
this.isCreatingElement = false
|
||||||
|
// 当前正在调整元素
|
||||||
|
this.isResizing = false
|
||||||
|
// 当前正在调整的元素
|
||||||
|
this.resizingElement = null
|
||||||
|
// 稍微缓解一下卡顿
|
||||||
|
this.handleResize = throttle(this.handleResize, this, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 序列化当前画布上的所有元素
|
||||||
|
serialize(stringify = false) {
|
||||||
|
let data = this.elementList.map(element => {
|
||||||
|
return element.serialize()
|
||||||
|
})
|
||||||
|
return stringify ? JSON.stringify(data) : data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前画布上的元素数量
|
||||||
|
getElementsNum() {
|
||||||
|
return this.elementList.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前画布上是否有元素
|
||||||
|
hasElements() {
|
||||||
|
return this.elementList.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加元素
|
||||||
|
addElement(element) {
|
||||||
|
this.elementList.push(element)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 向前添加元素
|
||||||
|
unshiftElement(element) {
|
||||||
|
this.elementList.unshift(element)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加元素到指定位置
|
||||||
|
insertElement(element, index) {
|
||||||
|
this.elementList.splice(index, 0, element)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除元素
|
||||||
|
deleteElement(element) {
|
||||||
|
let index = this.getElementIndex(element)
|
||||||
|
if (index !== -1) {
|
||||||
|
this.elementList.splice(index, 1)
|
||||||
|
if (element.isActive) {
|
||||||
|
this.cancelActiveElement(element)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除全部元素
|
||||||
|
deleteAllElements() {
|
||||||
|
this.activeElement = null
|
||||||
|
if(this.app.isMobile){
|
||||||
|
this.elementList = this.elementList.filter(item => item.style.elReadonly)
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
this.elementList = []
|
||||||
|
}
|
||||||
|
this.isCreatingElement = false
|
||||||
|
this.isResizing = false
|
||||||
|
this.resizingElement = null
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取元素在元素列表里的索引
|
||||||
|
getElementIndex(element) {
|
||||||
|
return this.elementList.findIndex(item => {
|
||||||
|
return item === element
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据元素数据创建元素
|
||||||
|
createElementsFromData(data) {
|
||||||
|
data.forEach(item => {
|
||||||
|
let element = this.pureCreateElement(item)
|
||||||
|
element.isActive = false
|
||||||
|
element.isCreating = false
|
||||||
|
this.addElement(element)
|
||||||
|
})
|
||||||
|
this.app.group.initIdToElementList(this.elementList)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否存在激活元素
|
||||||
|
hasActiveElement() {
|
||||||
|
return !!this.activeElement
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置激活元素
|
||||||
|
setActiveElement(element) {
|
||||||
|
this.cancelActiveElement()
|
||||||
|
this.activeElement = element
|
||||||
|
if (element) {
|
||||||
|
element.isActive = true
|
||||||
|
}
|
||||||
|
this.app.emit('activeElementChange', this.activeElement)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消当前激活元素
|
||||||
|
cancelActiveElement() {
|
||||||
|
if (!this.hasActiveElement()) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
this.activeElement.isActive = false
|
||||||
|
this.activeElement = null
|
||||||
|
this.app.emit('activeElementChange', this.activeElement)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否点击选中元素
|
||||||
|
checkIsHitElement(e) {
|
||||||
|
// 判断是否选中元素
|
||||||
|
let x = e.unGridClientX
|
||||||
|
let y = e.unGridClientY
|
||||||
|
// 从后往前遍历元素,默认认为新创建的元素在上一层
|
||||||
|
for (let i = this.elementList.length - 1; i >= 0; i--) {
|
||||||
|
let element = this.elementList[i]
|
||||||
|
if (element.isHit(x, y)) {
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 纯创建元素
|
||||||
|
pureCreateElement(opts = {}) {
|
||||||
|
switch (opts.type) {
|
||||||
|
case 'rectangle':
|
||||||
|
return new Rectangle(opts, this.app)
|
||||||
|
case 'diamond':
|
||||||
|
return new Diamond(opts, this.app)
|
||||||
|
case 'triangle':
|
||||||
|
return new Triangle(opts, this.app)
|
||||||
|
case 'circle':
|
||||||
|
return new Circle(opts, this.app)
|
||||||
|
case 'freedraw':
|
||||||
|
return new Freedraw(opts, this.app)
|
||||||
|
case 'image':
|
||||||
|
return new Image(opts, this.app)
|
||||||
|
case 'arrow':
|
||||||
|
return new Arrow(opts, this.app)
|
||||||
|
case 'line':
|
||||||
|
return new Line(opts, this.app)
|
||||||
|
case 'text':
|
||||||
|
return new Text(opts, this.app)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建元素
|
||||||
|
createElement(opts = {}, callback = () => {}, ctx = null, notActive) {
|
||||||
|
if (this.hasActiveElement() || this.isCreatingElement) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
let element = this.pureCreateElement(opts)
|
||||||
|
if (!element) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
this.addElement(element)
|
||||||
|
if (!notActive) {
|
||||||
|
this.setActiveElement(element)
|
||||||
|
}
|
||||||
|
this.isCreatingElement = true
|
||||||
|
callback.call(ctx, element)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制元素
|
||||||
|
copyElement(element, notActive = false, pos) {
|
||||||
|
return new Promise(async resolve => {
|
||||||
|
if (!element) {
|
||||||
|
return resolve()
|
||||||
|
}
|
||||||
|
let data = this.app.group.handleCopyElementData(element.serialize())
|
||||||
|
// 图片元素需要先加载图片
|
||||||
|
if (data.type === 'image') {
|
||||||
|
data.imageObj = await createImageObj(data.url)
|
||||||
|
}
|
||||||
|
this.createElement(
|
||||||
|
data,
|
||||||
|
element => {
|
||||||
|
this.app.group.handleCopyElement(element)
|
||||||
|
element.startResize(DRAG_ELEMENT_PARTS.BODY)
|
||||||
|
// 默认偏移原图形20像素
|
||||||
|
let ox = 20
|
||||||
|
let oy = 20
|
||||||
|
// 指定了具体坐标则使用具体坐标
|
||||||
|
if (pos) {
|
||||||
|
ox = pos.x - element.x - element.width / 2
|
||||||
|
oy = pos.y - element.y - element.height / 2
|
||||||
|
}
|
||||||
|
// 如果开启了网格,那么要坐标要吸附到网格
|
||||||
|
let gridAdsorbentPos = this.app.coordinate.gridAdsorbent(ox, oy)
|
||||||
|
element.resize(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
gridAdsorbentPos.x,
|
||||||
|
gridAdsorbentPos.y
|
||||||
|
)
|
||||||
|
element.isCreating = false
|
||||||
|
if (notActive) {
|
||||||
|
element.isActive = false
|
||||||
|
}
|
||||||
|
this.isCreatingElement = false
|
||||||
|
resolve(element)
|
||||||
|
},
|
||||||
|
this,
|
||||||
|
notActive
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正在创建类矩形元素
|
||||||
|
creatingRectangleLikeElement(type, x, y, offsetX, offsetY) {
|
||||||
|
this.createElement({
|
||||||
|
type,
|
||||||
|
x: x,
|
||||||
|
y: y,
|
||||||
|
width: offsetX,
|
||||||
|
height: offsetY
|
||||||
|
})
|
||||||
|
this.activeElement.updateSize(offsetX, offsetY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正在创建圆形元素
|
||||||
|
creatingCircle(x, y, e) {
|
||||||
|
this.createElement({
|
||||||
|
type: 'circle',
|
||||||
|
x: x,
|
||||||
|
y: y
|
||||||
|
})
|
||||||
|
let radius = getTowPointDistance(e.clientX, e.clientY, x, y)
|
||||||
|
this.activeElement.updateSize(radius, radius)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正在创建自由画笔元素
|
||||||
|
creatingFreedraw(e, event) {
|
||||||
|
this.createElement({
|
||||||
|
type: 'freedraw'
|
||||||
|
})
|
||||||
|
let element = this.activeElement
|
||||||
|
// 计算画笔粗细
|
||||||
|
let lineWidth = computedLineWidthBySpeed(
|
||||||
|
event.mouseSpeed,
|
||||||
|
element.lastLineWidth
|
||||||
|
)
|
||||||
|
element.lastLineWidth = lineWidth
|
||||||
|
element.addPoint(e.clientX, e.clientY, lineWidth)
|
||||||
|
// 绘制自由线不重绘,采用增量绘制,否则会卡顿
|
||||||
|
let { coordinate, ctx, state } = this.app
|
||||||
|
// 事件对象的坐标默认是加上了画布偏移量的,临时绘制的时候不需要,所以需要减去
|
||||||
|
let tfp = coordinate.transformToCanvasCoordinate(
|
||||||
|
coordinate.subScrollX(event.lastMousePos.x),
|
||||||
|
coordinate.subScrollY(event.lastMousePos.y)
|
||||||
|
)
|
||||||
|
let ttp = coordinate.transformToCanvasCoordinate(
|
||||||
|
coordinate.subScrollX(e.clientX),
|
||||||
|
coordinate.subScrollY(e.clientY)
|
||||||
|
)
|
||||||
|
ctx.save()
|
||||||
|
ctx.scale(state.scale, state.scale)
|
||||||
|
element.singleRender(tfp.x, tfp.y, ttp.x, ttp.y, lineWidth)
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正在创建图片元素
|
||||||
|
creatingImage(e, { width, height, imageObj, url, ratio }) {
|
||||||
|
// 吸附到网格,如果网格开启的话
|
||||||
|
let gp = this.app.coordinate.gridAdsorbent(
|
||||||
|
e.unGridClientX - width / 2,
|
||||||
|
e.unGridClientY - height / 2
|
||||||
|
)
|
||||||
|
this.createElement({
|
||||||
|
type: 'image',
|
||||||
|
x: gp.x,
|
||||||
|
y: gp.y,
|
||||||
|
url: url,
|
||||||
|
imageObj: imageObj,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
ratio: ratio
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正在编辑文本元素
|
||||||
|
editingText(element) {
|
||||||
|
if (element.type !== 'text') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
element.noRender = true
|
||||||
|
this.setActiveElement(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成文本元素的编辑
|
||||||
|
completeEditingText() {
|
||||||
|
let element = this.activeElement
|
||||||
|
if (!element || element.type !== 'text') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!element.text.trim()) {
|
||||||
|
// 没有输入则删除该文字元素
|
||||||
|
this.deleteElement(element)
|
||||||
|
this.setActiveElement(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
element.noRender = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成箭头元素的创建
|
||||||
|
completeCreateArrow(e) {
|
||||||
|
this.activeElement.addPoint(e.clientX, e.clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正在创建箭头元素
|
||||||
|
creatingArrow(x, y, e) {
|
||||||
|
this.createElement(
|
||||||
|
{
|
||||||
|
type: 'arrow',
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
},
|
||||||
|
element => {
|
||||||
|
element.addPoint(x, y)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
this.activeElement.updateFictitiousPoint(e.clientX, e.clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正在创建线段/折线元素
|
||||||
|
creatingLine(x, y, e, isSingle = false, notCreate = false) {
|
||||||
|
if (!notCreate) {
|
||||||
|
this.createElement(
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
isSingle
|
||||||
|
},
|
||||||
|
element => {
|
||||||
|
element.addPoint(x, y)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let element = this.activeElement
|
||||||
|
if (element) {
|
||||||
|
element.updateFictitiousPoint(e.clientX, e.clientY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成线段/折线元素的创建
|
||||||
|
completeCreateLine(e, completeCallback = () => {}) {
|
||||||
|
let element = this.activeElement
|
||||||
|
let x = e.clientX
|
||||||
|
let y = e.clientY
|
||||||
|
if (element && element.isSingle) {
|
||||||
|
// 单根线段模式,鼠标松开则代表绘制完成
|
||||||
|
element.addPoint(x, y)
|
||||||
|
completeCallback()
|
||||||
|
} else {
|
||||||
|
// 绘制折线模式,鼠标松开代表固定一个端点
|
||||||
|
this.createElement({
|
||||||
|
type: 'line',
|
||||||
|
isSingle: false
|
||||||
|
})
|
||||||
|
element = this.activeElement
|
||||||
|
element.addPoint(x, y)
|
||||||
|
element.updateFictitiousPoint(x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建元素完成
|
||||||
|
completeCreateElement() {
|
||||||
|
this.isCreatingElement = false
|
||||||
|
let element = this.activeElement
|
||||||
|
if (!element) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
// 由多个端点构成的元素需要根据端点计算外包围框
|
||||||
|
if (['freedraw', 'arrow', 'line'].includes(element.type)) {
|
||||||
|
element.updateMultiPointBoundingRect()
|
||||||
|
}
|
||||||
|
element.isCreating = false
|
||||||
|
this.app.emitChange()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为激活元素设置样式
|
||||||
|
setActiveElementStyle(style = {}) {
|
||||||
|
if (!this.hasActiveElement()) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
Object.keys(style).forEach(key => {
|
||||||
|
this.activeElement.style[key] = style[key]
|
||||||
|
if (key === 'fontSize' && this.activeElement.type === 'text') {
|
||||||
|
this.activeElement.updateTextSize()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测指定位置是否在元素调整手柄上
|
||||||
|
checkInResizeHand(x, y) {
|
||||||
|
// 按住了拖拽元素的某个部分
|
||||||
|
let element = this.activeElement
|
||||||
|
let hand = element.dragElement.checkPointInDragElementWhere(x, y)
|
||||||
|
if (hand) {
|
||||||
|
return {
|
||||||
|
element,
|
||||||
|
hand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否需要进行元素调整操作
|
||||||
|
checkIsResize(x, y, e) {
|
||||||
|
if (!this.hasActiveElement()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let res = this.checkInResizeHand(x, y)
|
||||||
|
if (res) {
|
||||||
|
this.isResizing = true
|
||||||
|
this.resizingElement = res.element
|
||||||
|
this.resizingElement.startResize(res.hand, e)
|
||||||
|
this.app.cursor.setResize(res.hand)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进行元素调整操作
|
||||||
|
handleResize(...args) {
|
||||||
|
if (!this.isResizing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.resizingElement.resize(...args)
|
||||||
|
this.app.render.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结束元素调整操作
|
||||||
|
endResize() {
|
||||||
|
this.isResizing = false
|
||||||
|
this.resizingElement.endResize()
|
||||||
|
this.resizingElement = null
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,214 @@
|
||||||
|
import { getTowPointDistance } from './utils'
|
||||||
|
import EventEmitter from 'eventemitter3'
|
||||||
|
|
||||||
|
// 事件类
|
||||||
|
export default class Event extends EventEmitter {
|
||||||
|
constructor(app) {
|
||||||
|
super()
|
||||||
|
this.app = app
|
||||||
|
this.coordinate = app.coordinate
|
||||||
|
|
||||||
|
// 鼠标是否按下
|
||||||
|
this.isMousedown = false
|
||||||
|
// 按下时的鼠标位置
|
||||||
|
this.mousedownPos = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
unGridClientX: 0,
|
||||||
|
unGridClientY: 0,
|
||||||
|
originClientX: 0,
|
||||||
|
originClientY: 0
|
||||||
|
}
|
||||||
|
// 鼠标当前位置和按下时位置的差值
|
||||||
|
this.mouseOffset = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
originX: 0,
|
||||||
|
originY: 0
|
||||||
|
}
|
||||||
|
// 记录上一时刻的鼠标位置
|
||||||
|
this.lastMousePos = {
|
||||||
|
x: 0,
|
||||||
|
y: 0
|
||||||
|
}
|
||||||
|
// 前一瞬间的鼠标移动距离
|
||||||
|
this.mouseDistance = 0
|
||||||
|
// 记录上一时刻的时间
|
||||||
|
this.lastMouseTime = Date.now()
|
||||||
|
// 前一瞬间的时间
|
||||||
|
this.mouseDuration = 0
|
||||||
|
// 前一瞬间的鼠标移动速度
|
||||||
|
this.mouseSpeed = 0
|
||||||
|
// 绑定事件
|
||||||
|
this.onMousedown = this.onMousedown.bind(this)
|
||||||
|
this.onMousemove = this.onMousemove.bind(this)
|
||||||
|
this.onMouseup = this.onMouseup.bind(this)
|
||||||
|
this.onDblclick = this.onDblclick.bind(this)
|
||||||
|
this.onMousewheel = this.onMousewheel.bind(this)
|
||||||
|
this.onKeydown = this.onKeydown.bind(this)
|
||||||
|
this.onKeyup = this.onKeyup.bind(this)
|
||||||
|
this.onPaste = this.onPaste.bind(this)
|
||||||
|
this.onContextmenu = this.onContextmenu.bind(this)
|
||||||
|
this.bindEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定canvas事件
|
||||||
|
bindEvent() {
|
||||||
|
this.app.container.addEventListener('mousedown', this.onMousedown)
|
||||||
|
this.app.container.addEventListener('mousemove', this.onMousemove)
|
||||||
|
this.app.container.addEventListener('mouseup', this.onMouseup)
|
||||||
|
|
||||||
|
this.app.container.addEventListener('touchstart', this.onMousedown)
|
||||||
|
this.app.container.addEventListener('touchmove', this.onMousemove)
|
||||||
|
this.app.container.addEventListener('touchend', this.onMouseup)
|
||||||
|
|
||||||
|
this.app.container.addEventListener('dblclick', this.onDblclick)
|
||||||
|
this.app.container.addEventListener('mousewheel', this.onMousewheel)
|
||||||
|
this.app.container.addEventListener('contextmenu', this.onContextmenu)
|
||||||
|
window.addEventListener('keydown', this.onKeydown)
|
||||||
|
window.addEventListener('keyup', this.onKeyup)
|
||||||
|
|
||||||
|
window.addEventListener("paste", this.onPaste)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解绑事件
|
||||||
|
unbindEvent() {
|
||||||
|
this.app.container.removeEventListener('mousedown', this.onMousedown)
|
||||||
|
this.app.container.removeEventListener('mousemove', this.onMousemove)
|
||||||
|
this.app.container.removeEventListener('mouseup', this.onMouseup)
|
||||||
|
|
||||||
|
this.app.container.removeEventListener('touchstart', this.onMousedown)
|
||||||
|
this.app.container.removeEventListener('touchmove', this.onMousemove)
|
||||||
|
this.app.container.removeEventListener('touchend', this.onMouseup)
|
||||||
|
|
||||||
|
this.app.container.removeEventListener('dblclick', this.onDblclick)
|
||||||
|
this.app.container.removeEventListener('mousewheel', this.onMousewheel)
|
||||||
|
this.app.container.removeEventListener('contextmenu', this.onContextmenu)
|
||||||
|
window.removeEventListener('keydown', this.onKeydown)
|
||||||
|
window.removeEventListener('keyup', this.onKeyup)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换事件对象e
|
||||||
|
// 1.将相当于浏览器窗口左上角的坐标转换成相对容器左上角
|
||||||
|
// 2.如果画布进行了缩放,那么鼠标坐标要反向进行缩放
|
||||||
|
// 3.x、y坐标加上了画布水平和垂直的滚动距离scrollX和scrollY
|
||||||
|
// 4.如果开启了网格,那么坐标要吸附到网格上
|
||||||
|
transformEvent(e) {
|
||||||
|
let { coordinate } = this.app
|
||||||
|
// 容器和窗口左上角存在距离时转换
|
||||||
|
let ex = e.clientX == undefined ? e.changedTouches[0].clientX : e.clientX
|
||||||
|
let ey = e.clientY == undefined ? e.changedTouches[0].clientY : e.clientY
|
||||||
|
let wp = coordinate.windowToContainer(ex, ey)
|
||||||
|
// console.log(wp.x)
|
||||||
|
// alert(wp.x)
|
||||||
|
// 元素缩放是*scale,所以视觉上我们点击到了元素,但是实际上元素的位置还是原来的x,y,所以鼠标的坐标需要/scale
|
||||||
|
let { x, y } = coordinate.reverseScale(wp.x, wp.y)
|
||||||
|
// 加上滚动偏移
|
||||||
|
x = coordinate.addScrollX(x)
|
||||||
|
y = coordinate.addScrollY(y)
|
||||||
|
// 保存未吸附到网格的坐标,用于位置检测等不需要吸附的场景
|
||||||
|
let unGridClientX = x
|
||||||
|
let unGridClientY = y
|
||||||
|
// 如果开启了网格,那么要坐标要吸附到网格
|
||||||
|
let gp = coordinate.gridAdsorbent(x, y)
|
||||||
|
let newEvent = {
|
||||||
|
originEvent: e,
|
||||||
|
unGridClientX,
|
||||||
|
unGridClientY,
|
||||||
|
clientX: gp.x,
|
||||||
|
clientY: gp.y // 向下滚动scroll值为正,而canvas坐标系向下为正,所以要造成元素向上滚动的效果显示的时候元素的y坐标需要减去scroll值,但是元素真实的y值并未改变,所以对于鼠标坐标来说需要加上scroll值,这样才能匹配元素真实的y坐标,水平方向也是一样的。
|
||||||
|
}
|
||||||
|
return newEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鼠标按下事件
|
||||||
|
onMousedown(e) {
|
||||||
|
e = this.transformEvent(e)
|
||||||
|
this.isMousedown = true
|
||||||
|
|
||||||
|
this.mousedownPos.x = e.clientX
|
||||||
|
this.mousedownPos.y = e.clientY
|
||||||
|
this.mousedownPos.unGridClientX = e.unGridClientX
|
||||||
|
this.mousedownPos.unGridClientY = e.unGridClientY
|
||||||
|
this.mousedownPos.originClientX = e.originEvent.clientX
|
||||||
|
this.mousedownPos.originClientY = e.originEvent.clientY
|
||||||
|
this.emit('mousedown', e, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鼠标移动事件
|
||||||
|
onMousemove(e) {
|
||||||
|
e = this.transformEvent(e)
|
||||||
|
let x = e.clientX
|
||||||
|
let y = e.clientY
|
||||||
|
// 鼠标按下状态
|
||||||
|
if (this.isMousedown) {
|
||||||
|
this.mouseOffset.x = x - this.mousedownPos.x
|
||||||
|
this.mouseOffset.y = y - this.mousedownPos.y
|
||||||
|
this.mouseOffset.originX =
|
||||||
|
e.originEvent.clientX - this.mousedownPos.originClientX
|
||||||
|
this.mouseOffset.originY =
|
||||||
|
e.originEvent.clientY - this.mousedownPos.originClientY
|
||||||
|
}
|
||||||
|
let curTime = Date.now()
|
||||||
|
// 距离上一次的时间
|
||||||
|
this.mouseDuration = curTime - this.lastMouseTime
|
||||||
|
// 距离上一次的距离
|
||||||
|
this.mouseDistance = getTowPointDistance(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
this.lastMousePos.x,
|
||||||
|
this.lastMousePos.y
|
||||||
|
)
|
||||||
|
// 鼠标移动速度
|
||||||
|
this.mouseSpeed = this.mouseDistance / this.mouseDuration
|
||||||
|
this.emit('mousemove', e, this)
|
||||||
|
// 更新变量
|
||||||
|
this.lastMouseTime = curTime
|
||||||
|
this.lastMousePos.x = x
|
||||||
|
this.lastMousePos.y = y
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鼠标松开事件
|
||||||
|
onMouseup(e) {
|
||||||
|
e = this.transformEvent(e)
|
||||||
|
// 复位
|
||||||
|
this.isMousedown = false
|
||||||
|
this.mousedownPos.x = 0
|
||||||
|
this.mousedownPos.y = 0
|
||||||
|
this.emit('mouseup', e, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 双击事件
|
||||||
|
onDblclick(e) {
|
||||||
|
e = this.transformEvent(e)
|
||||||
|
this.emit('dblclick', e, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鼠标滚动事件
|
||||||
|
onMousewheel(e) {
|
||||||
|
e = this.transformEvent(e)
|
||||||
|
this.emit('mousewheel', e.originEvent.wheelDelta < 0 ? 'down' : 'up')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右键菜单事件
|
||||||
|
onContextmenu(e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
e = this.transformEvent(e)
|
||||||
|
this.emit('contextmenu', e, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按键按下事件
|
||||||
|
onKeydown(e) {
|
||||||
|
this.emit('keydown', e, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按键松开事件
|
||||||
|
onKeyup(e) {
|
||||||
|
this.emit('keyup', e, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
onPaste(e){
|
||||||
|
this.emit('paste', e, this)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { createCanvas, getMultiElementRectInfo } from './utils'
|
||||||
|
|
||||||
|
// 导入导出
|
||||||
|
export default class Export {
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app
|
||||||
|
// 会把导出canvas绘制到页面上,方便测试
|
||||||
|
this.openTest = false
|
||||||
|
// 数据保存
|
||||||
|
this.saveState = {
|
||||||
|
scale: 0,
|
||||||
|
scrollX: 0,
|
||||||
|
scrollY: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示
|
||||||
|
show(canvas) {
|
||||||
|
if (this.openTest) {
|
||||||
|
canvas.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
background-color: #fff;
|
||||||
|
`
|
||||||
|
document.body.appendChild(canvas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取要导出的元素
|
||||||
|
getElementList(onlySelected = true) {
|
||||||
|
// 导出所有元素
|
||||||
|
if (!onlySelected) {
|
||||||
|
return this.app.elements.elementList
|
||||||
|
} else {
|
||||||
|
// 仅导出激活或选中的元素
|
||||||
|
let selectedElements = []
|
||||||
|
if (this.app.elements.activeElement) {
|
||||||
|
selectedElements.push(this.app.elements.activeElement)
|
||||||
|
} else if (this.app.selection.hasSelectionElements()) {
|
||||||
|
selectedElements = this.app.selection.getSelectionElements()
|
||||||
|
}
|
||||||
|
let res = this.app.elements.elementList.filter(element => {
|
||||||
|
return selectedElements.includes(element)
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出为图片
|
||||||
|
exportImage({
|
||||||
|
type = 'image/png',
|
||||||
|
renderBg = true,
|
||||||
|
useBlob = false,
|
||||||
|
paddingX = 10,
|
||||||
|
paddingY = 10,
|
||||||
|
onlySelected
|
||||||
|
} = {}) {
|
||||||
|
// 计算所有元素的外包围框
|
||||||
|
let { minx, maxx, miny, maxy } = getMultiElementRectInfo(
|
||||||
|
this.getElementList(onlySelected)
|
||||||
|
)
|
||||||
|
let width = maxx - minx + paddingX * 2
|
||||||
|
let height = maxy - miny + paddingY * 2
|
||||||
|
// 创建导出canvas
|
||||||
|
let { canvas, ctx } = createCanvas(width, height, {
|
||||||
|
noStyle: true,
|
||||||
|
noTranslate: true
|
||||||
|
})
|
||||||
|
this.show(canvas)
|
||||||
|
this.saveAppState()
|
||||||
|
this.changeAppState(minx - paddingX, miny - paddingY, ctx)
|
||||||
|
// 绘制背景颜色
|
||||||
|
if (renderBg && this.app.state.backgroundColor) {
|
||||||
|
this.app.background.canvasAddBackgroundColor(
|
||||||
|
ctx,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
this.app.state.backgroundColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 绘制元素到导出canvas
|
||||||
|
this.render(ctx, onlySelected)
|
||||||
|
this.recoveryAppState()
|
||||||
|
// 导出
|
||||||
|
if (useBlob) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob(blob => {
|
||||||
|
if (blob) {
|
||||||
|
resolve(blob)
|
||||||
|
} else {
|
||||||
|
reject()
|
||||||
|
}
|
||||||
|
}, type)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return canvas.toDataURL(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存app类当前状态数据
|
||||||
|
saveAppState() {
|
||||||
|
let { width, height, state, ctx } = this.app
|
||||||
|
this.saveState.width = width
|
||||||
|
this.saveState.height = height
|
||||||
|
this.saveState.scale = state.scale
|
||||||
|
this.saveState.scrollX = state.scrollX
|
||||||
|
this.saveState.scrollY = state.scrollY
|
||||||
|
this.saveState.ctx = ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// 临时修改app类状态数据
|
||||||
|
changeAppState(minx, miny, ctx) {
|
||||||
|
this.app.ctx = ctx
|
||||||
|
this.app.state.scale = 1
|
||||||
|
this.app.state.scrollX = 0
|
||||||
|
this.app.state.scrollY = 0
|
||||||
|
// 这里为什么要这么修改呢,原因是要把元素的坐标转换成当前导出画布的坐标,当前导出画布的坐标在左上角,比如一个元素的左上角原始坐标为(100,100),假设刚好minx和miny也是100,那么相当于元素的这个坐标要绘制到导出画布时的坐标应为(0,0),所以元素绘制到导出画布的坐标均需要减去minx,miny,而元素在绘制时都会调用this.app.coordinate.transform方法进行转换,这个方法里使用的是this.app.width和this.app.height,所以方便起见直接修改这两个属性。
|
||||||
|
this.app.width = minx * 2
|
||||||
|
this.app.height = miny * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复app类状态数据
|
||||||
|
recoveryAppState() {
|
||||||
|
let { width, height, scale, scrollX, scrollY, ctx } = this.saveState
|
||||||
|
this.app.state.scale = scale
|
||||||
|
this.app.state.scrollX = scrollX
|
||||||
|
this.app.state.scrollY = scrollY
|
||||||
|
this.app.width = width
|
||||||
|
this.app.height = height
|
||||||
|
this.app.ctx = ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制所有元素
|
||||||
|
render(ctx, onlySelected) {
|
||||||
|
ctx.save()
|
||||||
|
this.getElementList(onlySelected).forEach(element => {
|
||||||
|
if (element.noRender) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cacheActive = element.isActive
|
||||||
|
let cacheSelected = element.isSelected
|
||||||
|
// 临时修改元素的激活状态为非激活、非选中
|
||||||
|
element.isActive = false
|
||||||
|
element.isSelected = false
|
||||||
|
element.render()
|
||||||
|
element.isActive = cacheActive
|
||||||
|
element.isSelected = cacheSelected
|
||||||
|
})
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出为json数据
|
||||||
|
exportJson() {
|
||||||
|
return this.app.getData()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,155 @@
|
||||||
|
import Canvas from './Canvas'
|
||||||
|
|
||||||
|
// 网格
|
||||||
|
export default class Grid {
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app
|
||||||
|
this.canvas = null
|
||||||
|
this.ctx = null
|
||||||
|
|
||||||
|
this.init()
|
||||||
|
this.app.on('zoomChange', this.renderGrid, this)
|
||||||
|
this.app.on('scrollChange', this.renderGrid, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
init() {
|
||||||
|
if (this.canvas) {
|
||||||
|
this.app.container.removeChild(this.canvas.el)
|
||||||
|
}
|
||||||
|
let { width, height } = this.app
|
||||||
|
this.canvas = new Canvas(width, height, {
|
||||||
|
className: 'grid'
|
||||||
|
})
|
||||||
|
this.ctx = this.canvas.ctx
|
||||||
|
this.app.container.insertBefore(
|
||||||
|
this.canvas.el,
|
||||||
|
this.app.container.children[0]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制水平线
|
||||||
|
drawHorizontalLine(i) {
|
||||||
|
let { coordinate, width, state } = this.app
|
||||||
|
let _i = coordinate.subScrollY(i)
|
||||||
|
this.ctx.beginPath()
|
||||||
|
this.ctx.moveTo(-width / state.scale / 2, _i)
|
||||||
|
this.ctx.lineTo(width / state.scale / 2, _i)
|
||||||
|
this.ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染水平线
|
||||||
|
renderHorizontalLines() {
|
||||||
|
let { coordinate, height, state } = this.app
|
||||||
|
let { gridConfig, scale } = state
|
||||||
|
let maxBottom = 0
|
||||||
|
for (let i = -height / 2; i < height / 2; i += gridConfig.size) {
|
||||||
|
this.drawHorizontalLine(i)
|
||||||
|
maxBottom = i
|
||||||
|
}
|
||||||
|
// 向下滚时绘制上方超出的线
|
||||||
|
for (
|
||||||
|
let i = -height / 2 - gridConfig.size;
|
||||||
|
i > -coordinate.subScrollY(height / scale / 2);
|
||||||
|
i -= gridConfig.size
|
||||||
|
) {
|
||||||
|
this.drawHorizontalLine(i)
|
||||||
|
}
|
||||||
|
// 向上滚时绘制下方超出的线
|
||||||
|
for (
|
||||||
|
let i = maxBottom + gridConfig.size;
|
||||||
|
i < coordinate.addScrollY(height / scale / 2);
|
||||||
|
i += gridConfig.size
|
||||||
|
) {
|
||||||
|
this.drawHorizontalLine(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制重置线
|
||||||
|
drawVerticalLine(i) {
|
||||||
|
let { coordinate, height, state } = this.app
|
||||||
|
let _i = coordinate.subScrollX(i)
|
||||||
|
this.ctx.beginPath()
|
||||||
|
this.ctx.moveTo(_i, -height / state.scale / 2)
|
||||||
|
this.ctx.lineTo(_i, height / state.scale / 2)
|
||||||
|
this.ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染垂直线
|
||||||
|
renderVerticalLines() {
|
||||||
|
let { coordinate, width, state } = this.app
|
||||||
|
let { gridConfig, scale } = state
|
||||||
|
let maxRight = 0
|
||||||
|
for (let i = -width / 2; i < width / 2; i += gridConfig.size) {
|
||||||
|
this.drawVerticalLine(i)
|
||||||
|
maxRight = i
|
||||||
|
}
|
||||||
|
// 向右滚时绘制左方超出的线
|
||||||
|
for (
|
||||||
|
let i = -width / 2 - gridConfig.size;
|
||||||
|
i > -coordinate.subScrollX(width / scale / 2);
|
||||||
|
i -= gridConfig.size
|
||||||
|
) {
|
||||||
|
this.drawVerticalLine(i)
|
||||||
|
}
|
||||||
|
// 向左滚时绘制右方超出的线
|
||||||
|
for (
|
||||||
|
let i = maxRight + gridConfig.size;
|
||||||
|
i < coordinate.addScrollX(width / scale / 2);
|
||||||
|
i += gridConfig.size
|
||||||
|
) {
|
||||||
|
this.drawVerticalLine(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染网格
|
||||||
|
renderGrid() {
|
||||||
|
this.canvas.clearCanvas()
|
||||||
|
let { gridConfig, scale, showGrid } = this.app.state
|
||||||
|
if (!showGrid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.ctx.save()
|
||||||
|
this.ctx.scale(scale, scale)
|
||||||
|
this.ctx.strokeStyle = gridConfig.strokeStyle
|
||||||
|
this.ctx.lineWidth = gridConfig.lineWidth
|
||||||
|
|
||||||
|
// 水平
|
||||||
|
this.renderHorizontalLines()
|
||||||
|
|
||||||
|
// 垂直
|
||||||
|
this.renderVerticalLines()
|
||||||
|
|
||||||
|
this.ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示网格
|
||||||
|
showGrid() {
|
||||||
|
this.app.updateState({
|
||||||
|
showGrid: true
|
||||||
|
})
|
||||||
|
this.renderGrid()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 隐藏网格
|
||||||
|
hideGrid() {
|
||||||
|
this.app.updateState({
|
||||||
|
showGrid: false
|
||||||
|
})
|
||||||
|
this.canvas.clearCanvas()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新网格配置
|
||||||
|
updateGrid(config = {}) {
|
||||||
|
this.app.updateState({
|
||||||
|
gridConfig: {
|
||||||
|
...this.app.state.gridConfig,
|
||||||
|
...config
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (this.app.state.showGrid) {
|
||||||
|
this.hideGrid()
|
||||||
|
this.showGrid()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
import MultiSelectElement from './elements/MultiSelectElement'
|
||||||
|
|
||||||
|
// 编组/取消编组类
|
||||||
|
export default class Group {
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app
|
||||||
|
this.groupIdToElementList = {}
|
||||||
|
this.newGroupIdMap = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多选时渲染编组元素的多选框
|
||||||
|
render() {
|
||||||
|
Object.keys(this.groupIdToElementList).forEach(groupId => {
|
||||||
|
let group = this.groupIdToElementList[groupId]
|
||||||
|
let selected = group[0].isSelected
|
||||||
|
if (selected) {
|
||||||
|
let mElement = new MultiSelectElement(
|
||||||
|
{
|
||||||
|
type: 'multiSelectElement'
|
||||||
|
},
|
||||||
|
this.app
|
||||||
|
)
|
||||||
|
mElement.setSelectedElementList(group)
|
||||||
|
mElement.updateRect()
|
||||||
|
mElement.dragElement.onlyShowBody()
|
||||||
|
mElement.render()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存储到映射列表
|
||||||
|
setToMap(element) {
|
||||||
|
let groupId = element.getGroupId()
|
||||||
|
if (groupId) {
|
||||||
|
if (!this.groupIdToElementList[groupId]) {
|
||||||
|
this.groupIdToElementList[groupId] = []
|
||||||
|
}
|
||||||
|
this.groupIdToElementList[groupId].push(element)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化映射列表
|
||||||
|
initIdToElementList(elementList) {
|
||||||
|
this.groupIdToElementList = {}
|
||||||
|
elementList.forEach(element => {
|
||||||
|
this.setToMap(element)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理元素数据的复制
|
||||||
|
handleCopyElementData(data) {
|
||||||
|
if (data.groupId) {
|
||||||
|
if (this.newGroupIdMap[data.groupId]) {
|
||||||
|
data.groupId = this.newGroupIdMap[data.groupId]
|
||||||
|
} else {
|
||||||
|
data.groupId = this.newGroupIdMap[data.groupId] = uuidv4()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复位用于元素数据复制的存储对象
|
||||||
|
clearCopyMap() {
|
||||||
|
this.newGroupIdMap = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理元素对象的复制
|
||||||
|
handleCopyElement(element) {
|
||||||
|
this.setToMap(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编组
|
||||||
|
dogroup() {
|
||||||
|
if (
|
||||||
|
!this.app.selection.hasSelection ||
|
||||||
|
this.app.selection.multiSelectElement.selectedElementList.length <= 1
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let groupElement = this.app.selection.multiSelectElement.selectedElementList
|
||||||
|
let groupId = uuidv4()
|
||||||
|
this.groupIdToElementList[groupId] = groupElement
|
||||||
|
groupElement.forEach(element => {
|
||||||
|
element.setGroupId(groupId)
|
||||||
|
})
|
||||||
|
this.app.render.render()
|
||||||
|
this.app.emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消编组
|
||||||
|
ungroup() {
|
||||||
|
if (
|
||||||
|
!this.app.selection.hasSelection ||
|
||||||
|
this.app.selection.multiSelectElement.selectedElementList.length <= 1
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let groupElement = this.app.selection.multiSelectElement.selectedElementList
|
||||||
|
let groupId = groupElement[0].getGroupId()
|
||||||
|
this.groupIdToElementList[groupId] = []
|
||||||
|
delete this.groupIdToElementList[groupId]
|
||||||
|
groupElement.forEach(element => {
|
||||||
|
element.removeGroupId(groupId)
|
||||||
|
})
|
||||||
|
this.app.render.render()
|
||||||
|
this.app.emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据元素激活元素所在的组
|
||||||
|
setSelection(element) {
|
||||||
|
let groupId = element.getGroupId()
|
||||||
|
if (this.groupIdToElementList[groupId]) {
|
||||||
|
this.app.selection.selectElements(this.groupIdToElementList[groupId])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取和指定元素同一个组的所有元素
|
||||||
|
getGroupElements(element) {
|
||||||
|
let groupId = element.getGroupId()
|
||||||
|
return this.groupIdToElementList[groupId] || []
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { deepCopy } from './utils'
|
||||||
|
|
||||||
|
// 历史记录管理
|
||||||
|
export default class History {
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app
|
||||||
|
this.historyStack = []
|
||||||
|
this.length = 0
|
||||||
|
this.index = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加
|
||||||
|
add(data) {
|
||||||
|
let prev = this.length > 0 ? this.historyStack[this.length - 1] : null
|
||||||
|
let copyData = deepCopy(data)
|
||||||
|
if (copyData === prev) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.historyStack.push(copyData)
|
||||||
|
this.length++
|
||||||
|
this.index = this.length - 1
|
||||||
|
this.emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后退
|
||||||
|
undo() {
|
||||||
|
if (this.index <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.index--
|
||||||
|
this.shuttle()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前进
|
||||||
|
redo() {
|
||||||
|
if (this.index >= this.length - 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.index++
|
||||||
|
this.shuttle()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前进后退
|
||||||
|
async shuttle() {
|
||||||
|
let data = this.historyStack[this.index]
|
||||||
|
await this.app.setData(data, true)
|
||||||
|
this.emitChange()
|
||||||
|
this.app.emit('change', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空数据
|
||||||
|
clear() {
|
||||||
|
this.index = -1
|
||||||
|
this.length = 0
|
||||||
|
this.historyStack = []
|
||||||
|
this.emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发事件
|
||||||
|
emitChange() {
|
||||||
|
this.app.emit('shuttle', this.index, this.length)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,169 @@
|
||||||
|
import EventEmitter from 'eventemitter3'
|
||||||
|
export default class ImageEdit extends EventEmitter {
|
||||||
|
constructor(app) {
|
||||||
|
super()
|
||||||
|
this.app = app
|
||||||
|
this.el = null
|
||||||
|
this.isReady = false
|
||||||
|
this.isPaste = false
|
||||||
|
this.moveEvent = null
|
||||||
|
this.previewEl = null
|
||||||
|
this.imageData = null
|
||||||
|
this.maxWidth = 750
|
||||||
|
this.maxHeight = 450
|
||||||
|
this.maxRatio = this.maxWidth / this.maxHeight
|
||||||
|
this.onImageSelectChange = this.onImageSelectChange.bind(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复位
|
||||||
|
reset() {
|
||||||
|
if(this.el){
|
||||||
|
this.el.value = ''
|
||||||
|
if(this.previewEl){
|
||||||
|
document.body.removeChild(this.previewEl)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
this.isReady = false
|
||||||
|
this.isPaste = false
|
||||||
|
this.previewEl = null
|
||||||
|
this.imageData = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择图片
|
||||||
|
selectImage() {
|
||||||
|
if (!this.el) {
|
||||||
|
this.el = document.createElement('input')
|
||||||
|
this.el.type = 'file'
|
||||||
|
this.el.accept = 'image/*'
|
||||||
|
this.el.style.position = 'fixed'
|
||||||
|
this.el.style.left = '-999999px'
|
||||||
|
this.el.addEventListener('change', this.onImageSelectChange)
|
||||||
|
document.body.appendChild(this.el)
|
||||||
|
}
|
||||||
|
this.el.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新
|
||||||
|
updatePreviewElPos(x, y) {
|
||||||
|
let width = 100
|
||||||
|
let height = width / this.imageData.ratio
|
||||||
|
if (!this.previewEl) {
|
||||||
|
this.previewEl = document.createElement('div')
|
||||||
|
this.previewEl.style.position = 'fixed'
|
||||||
|
this.previewEl.style.width = width + 'px'
|
||||||
|
this.previewEl.style.height = height + 'px'
|
||||||
|
this.previewEl.style.backgroundImage = `url('${this.imageData.url}')`
|
||||||
|
this.previewEl.style.backgroundSize = 'cover'
|
||||||
|
this.previewEl.style.pointerEvents = 'none'
|
||||||
|
document.body.appendChild(this.previewEl)
|
||||||
|
}
|
||||||
|
let tp = this.app.coordinate.containerToWindow(x, y)
|
||||||
|
this.previewEl.style.left = tp.x - width / 2 + 'px'
|
||||||
|
this.previewEl.style.top = tp.y - height / 2 + 'px'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取图片宽高
|
||||||
|
async getImageSize(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let img = new Image()
|
||||||
|
img.setAttribute('crossOrigin', 'anonymous')
|
||||||
|
img.onload = () => {
|
||||||
|
let width = img.width
|
||||||
|
let height = img.height
|
||||||
|
// 图片过大,缩小宽高
|
||||||
|
let ratio = img.width / img.height
|
||||||
|
if (img.width > this.maxWidth || img.height > this.maxHeight) {
|
||||||
|
if (ratio > this.maxRatio) {
|
||||||
|
width = this.maxWidth
|
||||||
|
height = this.maxWidth / ratio
|
||||||
|
} else {
|
||||||
|
height = this.maxHeight
|
||||||
|
width = this.maxHeight * ratio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
imageObj: img,
|
||||||
|
size: {
|
||||||
|
width: width,
|
||||||
|
height: height
|
||||||
|
},
|
||||||
|
ratio
|
||||||
|
})
|
||||||
|
}
|
||||||
|
img.onerror = () => {
|
||||||
|
reject()
|
||||||
|
}
|
||||||
|
img.src = url
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片选择事件
|
||||||
|
async onImageSelectChange(e) {
|
||||||
|
let file = null;
|
||||||
|
if(e.target){
|
||||||
|
file = e.target.files[0]
|
||||||
|
this.isReady = true
|
||||||
|
this.isPaste = false
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
file = e
|
||||||
|
this.isReady = false
|
||||||
|
this.isPaste = true
|
||||||
|
}
|
||||||
|
let url = await this.getImageUrl(file)
|
||||||
|
let { imageObj, size, ratio } = await this.getImageSize(url)
|
||||||
|
this.imageData = {
|
||||||
|
url,
|
||||||
|
...size,
|
||||||
|
ratio,
|
||||||
|
imageObj
|
||||||
|
}
|
||||||
|
|
||||||
|
if(this.isPaste){
|
||||||
|
this.emit('imagePaste')
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
this.emit('imageSelectChange', this.imageData)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取图片url
|
||||||
|
async getImageUrl(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let reader = new FileReader()
|
||||||
|
reader.onloadend = function (e) {
|
||||||
|
if(file.size < 1048576){
|
||||||
|
// 小于1M
|
||||||
|
resolve(reader.result)
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
const img = new Image();
|
||||||
|
img.src = e.target.result;
|
||||||
|
|
||||||
|
img.onload = function () {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
let width = img.width;
|
||||||
|
let height = img.height;
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
const compressedDataUrl = canvas.toDataURL('image/jpeg', 0.8); // 压缩图片质量为0.8
|
||||||
|
resolve(compressedDataUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
reject()
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,147 @@
|
||||||
|
import { keyMap } from './utils/keyMap'
|
||||||
|
|
||||||
|
// 快捷按键、命令处理类
|
||||||
|
export default class KeyCommand {
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app
|
||||||
|
this.keyMap = keyMap
|
||||||
|
this.shortcutMap = {
|
||||||
|
//Enter: [fn]
|
||||||
|
}
|
||||||
|
this.bindEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定事件
|
||||||
|
bindEvent() {
|
||||||
|
this.app.event.on('keydown', this.onKeydown, this)
|
||||||
|
this.app.event.on('paste', this.onPaste, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解绑事件
|
||||||
|
unBindEvent() {
|
||||||
|
this.app.event.off('keydown', this.onKeydown)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按键事件
|
||||||
|
onKeydown(e) {
|
||||||
|
Object.keys(this.shortcutMap).forEach(key => {
|
||||||
|
if (this.checkKey(e, key)) {
|
||||||
|
e.stopPropagation()
|
||||||
|
// e.preventDefault()
|
||||||
|
this.shortcutMap[key].forEach(f => {
|
||||||
|
f.fn.call(f.ctx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户通过浏览器发起一个粘贴动作时触发
|
||||||
|
onPaste(e){
|
||||||
|
const clipboardData = e.clipboardData || window.clipboardData;
|
||||||
|
const items = clipboardData.items;
|
||||||
|
let file = null;
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const { kind, type } = items[i]
|
||||||
|
const isFile = kind == 'file' // 文件
|
||||||
|
const isImg = type.startsWith('image') // 图片类型
|
||||||
|
if (isFile && isImg) { file = items[i].getAsFile(); break; }
|
||||||
|
}
|
||||||
|
if(!file) return
|
||||||
|
this.app.imageEdit.onImageSelectChange(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查键值是否符合
|
||||||
|
checkKey(e, key) {
|
||||||
|
let o = this.getOriginEventCodeArr(e)
|
||||||
|
let k = this.getKeyCodeArr(key)
|
||||||
|
if (o.length !== k.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for (let i = 0; i < o.length; i++) {
|
||||||
|
let index = k.findIndex(item => {
|
||||||
|
return item === o[i]
|
||||||
|
})
|
||||||
|
if (index === -1) {
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
k.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取事件对象里的键值数组
|
||||||
|
getOriginEventCodeArr(e) {
|
||||||
|
let arr = []
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
arr.push(keyMap['Control'])
|
||||||
|
}
|
||||||
|
if (e.altKey) {
|
||||||
|
arr.push(keyMap['Alt'])
|
||||||
|
}
|
||||||
|
if (e.shiftKey) {
|
||||||
|
arr.push(keyMap['Shift'])
|
||||||
|
}
|
||||||
|
if (!arr.includes(e.keyCode)) {
|
||||||
|
arr.push(e.keyCode)
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取快捷键对应的键值数组
|
||||||
|
getKeyCodeArr(key) {
|
||||||
|
// 对xxx++情况特殊处理
|
||||||
|
key = key.replace(/\+\+/, '+add')
|
||||||
|
let keyArr = key.split(/\s*\+\s*/).map(item => {
|
||||||
|
return item === 'add' ? '+' : item
|
||||||
|
})
|
||||||
|
let arr = []
|
||||||
|
keyArr.forEach(item => {
|
||||||
|
arr.push(keyMap[item])
|
||||||
|
})
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加快捷键命令
|
||||||
|
* Enter
|
||||||
|
* Tab | Insert
|
||||||
|
* Shift + a
|
||||||
|
*/
|
||||||
|
addShortcut(key, fn, ctx) {
|
||||||
|
key.split(/\s*\|\s*/).forEach(item => {
|
||||||
|
if (this.shortcutMap[item]) {
|
||||||
|
this.shortcutMap[item].push({
|
||||||
|
fn,
|
||||||
|
ctx
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.shortcutMap[item] = [
|
||||||
|
{
|
||||||
|
fn,
|
||||||
|
ctx
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除快捷键命令
|
||||||
|
removeShortcut(key, fn) {
|
||||||
|
key.split(/\s*\|\s*/).forEach(item => {
|
||||||
|
if (this.shortcutMap[item]) {
|
||||||
|
if (fn) {
|
||||||
|
let index = this.shortcutMap[item].findIndex(f => {
|
||||||
|
return f.fn === fn
|
||||||
|
})
|
||||||
|
if (index !== -1) {
|
||||||
|
this.shortcutMap[item].splice(index, 1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.shortcutMap[item] = []
|
||||||
|
delete this.shortcutMap[item]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { throttle } from './utils'
|
||||||
|
import { keyMap } from './utils/keyMap'
|
||||||
|
|
||||||
|
// 模式
|
||||||
|
export default class Mode {
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app
|
||||||
|
// 保存拖动即将开始时的滚动偏移量
|
||||||
|
this.startScrollX = 0
|
||||||
|
this.startScrollY = 0
|
||||||
|
// 画布拖拽模式
|
||||||
|
this.isDragMode = false
|
||||||
|
// 稍微缓解一下卡顿
|
||||||
|
this.onMove = throttle(this.onMove, this, 16)
|
||||||
|
this.bindEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定事件
|
||||||
|
bindEvent() {
|
||||||
|
this.app.event.on('keydown', e => {
|
||||||
|
if (e.keyCode === keyMap.Space) {
|
||||||
|
this.isDragMode = true
|
||||||
|
this.app.cursor.set('grab')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.app.event.on('keyup', e => {
|
||||||
|
if (this.isDragMode) {
|
||||||
|
this.isDragMode = false
|
||||||
|
this.app.cursor.set('default')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置为编辑模式
|
||||||
|
setEditMode() {
|
||||||
|
this.app.cursor.set('default')
|
||||||
|
this.app.updateState({
|
||||||
|
readonly: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置为只读模式
|
||||||
|
setReadonlyMode() {
|
||||||
|
this.app.cursor.set('grab')
|
||||||
|
this.app.updateState({
|
||||||
|
readonly: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存当前的滚动偏移量
|
||||||
|
onStart() {
|
||||||
|
this.startScrollX = this.app.state.scrollX
|
||||||
|
this.startScrollY = this.app.state.scrollY
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新滚动偏移量并重新渲染
|
||||||
|
onMove(e, event) {
|
||||||
|
this.app.scrollTo(
|
||||||
|
this.startScrollX - event.mouseOffset.originX / this.app.state.scale,
|
||||||
|
this.startScrollY - event.mouseOffset.originY / this.app.state.scale
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结束拖拽
|
||||||
|
onEnd() {
|
||||||
|
this.startScrollX = 0
|
||||||
|
this.startScrollY = 0
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,385 @@
|
||||||
|
import { getMultiElementRectInfo } from './utils'
|
||||||
|
|
||||||
|
// 渲染类
|
||||||
|
export default class Render {
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app
|
||||||
|
// 将被复制的激活的元素
|
||||||
|
this.beingCopyActiveElement = null
|
||||||
|
// 将被复制的选中的元素
|
||||||
|
this.beingCopySelectedElements = []
|
||||||
|
this.registerShortcutKeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除画布
|
||||||
|
clearCanvas() {
|
||||||
|
let { width, height } = this.app
|
||||||
|
this.app.ctx.clearRect(-width / 2, -height / 2, width, height)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制所有元素
|
||||||
|
render() {
|
||||||
|
let { state } = this.app
|
||||||
|
// 清空画布
|
||||||
|
this.clearCanvas()
|
||||||
|
this.app.ctx.save()
|
||||||
|
// 整体缩放
|
||||||
|
this.app.ctx.scale(state.scale, state.scale)
|
||||||
|
// 渲染所有元素
|
||||||
|
this.app.elements.elementList.forEach(element => {
|
||||||
|
// 不需要渲染
|
||||||
|
if (element.noRender) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
element.render()
|
||||||
|
})
|
||||||
|
this.app.group.render()
|
||||||
|
this.app.ctx.restore()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册快捷键
|
||||||
|
registerShortcutKeys() {
|
||||||
|
// 删除当前激活元素
|
||||||
|
this.app.keyCommand.addShortcut('Del|Backspace', () => {
|
||||||
|
this.deleteCurrentElements()
|
||||||
|
})
|
||||||
|
// 复制元素
|
||||||
|
this.app.keyCommand.addShortcut('Control+c', () => {
|
||||||
|
this.copyCurrentElement()
|
||||||
|
})
|
||||||
|
// 剪切元素
|
||||||
|
this.app.keyCommand.addShortcut('Control+x', () => {
|
||||||
|
this.cutCurrentElement()
|
||||||
|
})
|
||||||
|
// 撤销
|
||||||
|
this.app.keyCommand.addShortcut('Control+z', () => {
|
||||||
|
this.app.history.undo()
|
||||||
|
})
|
||||||
|
// 重做
|
||||||
|
this.app.keyCommand.addShortcut('Control+y', () => {
|
||||||
|
this.app.history.redo()
|
||||||
|
})
|
||||||
|
// 粘贴元素
|
||||||
|
this.app.keyCommand.addShortcut('Control+v', () => {
|
||||||
|
this.pasteCurrentElement(true)
|
||||||
|
})
|
||||||
|
// 放大
|
||||||
|
this.app.keyCommand.addShortcut('Control++', () => {
|
||||||
|
this.zoomIn()
|
||||||
|
})
|
||||||
|
// 缩小
|
||||||
|
this.app.keyCommand.addShortcut('Control+-', () => {
|
||||||
|
this.zoomOut()
|
||||||
|
})
|
||||||
|
// 缩放以适应所有元素
|
||||||
|
this.app.keyCommand.addShortcut('Shift+1', () => {
|
||||||
|
this.fit()
|
||||||
|
})
|
||||||
|
// 全部选中
|
||||||
|
this.app.keyCommand.addShortcut('Control+a', () => {
|
||||||
|
this.selectAll()
|
||||||
|
})
|
||||||
|
// 重置缩放
|
||||||
|
this.app.keyCommand.addShortcut('Control+0', () => {
|
||||||
|
this.setZoom(1)
|
||||||
|
})
|
||||||
|
// 显示隐藏网格
|
||||||
|
this.app.keyCommand.addShortcut("Control+'", () => {
|
||||||
|
if (this.app.state.showGrid) {
|
||||||
|
this.app.grid.hideGrid()
|
||||||
|
} else {
|
||||||
|
this.app.grid.showGrid()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制当前激活或选中的元素
|
||||||
|
copyCurrentElement() {
|
||||||
|
// 当前存在激活元素
|
||||||
|
if (this.app.elements.activeElement) {
|
||||||
|
this.beingCopySelectedElements = []
|
||||||
|
this.beingCopyElement = this.app.elements.activeElement
|
||||||
|
} else if (this.app.selection.hasSelectionElements()) {
|
||||||
|
// 当前存在选中元素
|
||||||
|
this.beingCopyElement = null
|
||||||
|
this.beingCopySelectedElements = this.app.selection.getSelectionElements()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 剪切当前激活或选中的元素
|
||||||
|
cutCurrentElement() {
|
||||||
|
// 当前存在激活元素
|
||||||
|
if (this.app.elements.activeElement) {
|
||||||
|
this.copyCurrentElement()
|
||||||
|
this.deleteCurrentElements()
|
||||||
|
} else if (this.app.selection.hasSelectionElements()) {
|
||||||
|
// 当前存在选中元素
|
||||||
|
this.copyCurrentElement()
|
||||||
|
this.deleteCurrentElements()
|
||||||
|
this.app.selection.setMultiSelectElements(this.beingCopySelectedElements)
|
||||||
|
this.app.selection.emitChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 粘贴被复制或剪切的元素
|
||||||
|
pasteCurrentElement(useCurrentEventPos = false) {
|
||||||
|
let pos = null
|
||||||
|
// 使用当前鼠标所在的位置
|
||||||
|
if (useCurrentEventPos) {
|
||||||
|
let x = this.app.event.lastMousePos.x
|
||||||
|
let y = this.app.event.lastMousePos.y
|
||||||
|
pos = {
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.beingCopyElement) {
|
||||||
|
this.copyElement(this.beingCopyElement, false, pos)
|
||||||
|
} else if (this.beingCopySelectedElements.length > 0) {
|
||||||
|
this.app.selection.selectElements(this.beingCopySelectedElements)
|
||||||
|
this.app.selection.copySelectionElements(useCurrentEventPos ? pos : null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除元素
|
||||||
|
deleteElement(element) {
|
||||||
|
this.app.elements.deleteElement(element)
|
||||||
|
this.render()
|
||||||
|
this.app.emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制粘贴元素
|
||||||
|
async copyElement(element, notActive = false, pos) {
|
||||||
|
this.app.elements.cancelActiveElement()
|
||||||
|
await this.app.elements.copyElement(element, notActive, pos)
|
||||||
|
this.app.group.clearCopyMap()
|
||||||
|
this.render()
|
||||||
|
this.app.emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除当前激活元素
|
||||||
|
deleteActiveElement() {
|
||||||
|
if (!this.app.elements.hasActiveElement()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.deleteElement(this.app.elements.activeElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除当前激活或选中的元素
|
||||||
|
deleteCurrentElements() {
|
||||||
|
// 当前激活元素
|
||||||
|
this.deleteActiveElement()
|
||||||
|
// 当前选中元素
|
||||||
|
this.app.selection.deleteSelectedElements()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将当前元素上移一层
|
||||||
|
moveUpCurrentElement() {
|
||||||
|
this.moveLevelCurrentElement('up')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将当前元素下移一层
|
||||||
|
moveDownCurrentElement() {
|
||||||
|
this.moveLevelCurrentElement('down')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将当前元素置于顶层
|
||||||
|
moveTopCurrentElement() {
|
||||||
|
this.moveLevelCurrentElement('top')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将当前元素置于底层
|
||||||
|
moveBottomCurrentElement() {
|
||||||
|
this.moveLevelCurrentElement('bottom')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动当前元素的层级
|
||||||
|
moveLevelCurrentElement(level) {
|
||||||
|
let element = null
|
||||||
|
if (this.app.elements.hasActiveElement()) {
|
||||||
|
element = this.app.elements.activeElement
|
||||||
|
} else if (this.app.selection.getSelectionElements().length === 1) {
|
||||||
|
element = this.app.selection.getSelectionElements()[0]
|
||||||
|
}
|
||||||
|
if (!element) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let index = this.app.elements.getElementIndex(element)
|
||||||
|
this.app.elements.elementList.splice(index, 1)
|
||||||
|
if (level === 'up') {
|
||||||
|
this.app.elements.insertElement(element, index + 1)
|
||||||
|
} else if (level === 'down') {
|
||||||
|
this.app.elements.insertElement(element, index - 1)
|
||||||
|
} else if (level === 'top') {
|
||||||
|
this.app.elements.addElement(element)
|
||||||
|
} else if (level === 'bottom') {
|
||||||
|
this.app.elements.unshiftElement(element)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为激活元素设置样式
|
||||||
|
setActiveElementStyle(style = {}) {
|
||||||
|
if (!this.app.elements.hasActiveElement()) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
this.app.elements.setActiveElementStyle(style)
|
||||||
|
this.render()
|
||||||
|
if (!this.app.elements.isCreatingElement) {
|
||||||
|
this.app.emitChange()
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为当前激活或选中的元素设置样式
|
||||||
|
setCurrentElementsStyle(style = {}) {
|
||||||
|
this.setActiveElementStyle(style)
|
||||||
|
this.app.selection.setSelectedElementStyle(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消当前激活元素
|
||||||
|
cancelActiveElement() {
|
||||||
|
if (!this.app.elements.hasActiveElement()) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
this.app.elements.cancelActiveElement()
|
||||||
|
this.render()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新当前激活元素的位置
|
||||||
|
updateActiveElementPosition(x, y) {
|
||||||
|
if (!this.app.elements.hasActiveElement()) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
this.app.elements.activeElement.updatePos(x, y)
|
||||||
|
this.render()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新当前激活元素的尺寸
|
||||||
|
updateActiveElementSize(width, height) {
|
||||||
|
if (!this.app.elements.hasActiveElement()) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
this.app.elements.activeElement.updateSize(width, height)
|
||||||
|
this.render()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新当前激活元素的旋转角度
|
||||||
|
updateActiveElementRotate(rotate) {
|
||||||
|
if (!this.app.elements.hasActiveElement()) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
this.app.elements.activeElement.updateRotate(rotate)
|
||||||
|
this.render()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空元素
|
||||||
|
empty() {
|
||||||
|
this.app.elements.deleteAllElements()
|
||||||
|
this.render()
|
||||||
|
this.app.history.clear()
|
||||||
|
this.app.emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 放大
|
||||||
|
zoomIn(num = 0.1) {
|
||||||
|
this.app.updateState({
|
||||||
|
scale: this.app.state.scale + num
|
||||||
|
})
|
||||||
|
this.render()
|
||||||
|
this.app.emit('zoomChange', this.app.state.scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缩小
|
||||||
|
zoomOut(num = 0.1) {
|
||||||
|
this.app.updateState({
|
||||||
|
scale: this.app.state.scale - num > 0 ? this.app.state.scale - num : 0
|
||||||
|
})
|
||||||
|
this.render()
|
||||||
|
this.app.emit('zoomChange', this.app.state.scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置指定缩放值
|
||||||
|
setZoom(zoom) {
|
||||||
|
if (zoom < 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.app.updateState({
|
||||||
|
scale: zoom
|
||||||
|
})
|
||||||
|
this.render()
|
||||||
|
this.app.emit('zoomChange', this.app.state.scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缩放以适应所有元素
|
||||||
|
fit() {
|
||||||
|
if (!this.app.elements.hasElements()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.scrollToCenter()
|
||||||
|
// 计算所有元素的外包围框
|
||||||
|
let { minx, maxx, miny, maxy } = getMultiElementRectInfo(
|
||||||
|
this.app.elements.elementList
|
||||||
|
)
|
||||||
|
let width = maxx - minx
|
||||||
|
let height = maxy - miny
|
||||||
|
let maxScale = Math.min(this.app.width / width, this.app.height / height)
|
||||||
|
console.log(maxScale);
|
||||||
|
this.setZoom(maxScale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动至指定位置
|
||||||
|
scrollTo(scrollX, scrollY) {
|
||||||
|
this.app.updateState({
|
||||||
|
scrollX,
|
||||||
|
scrollY
|
||||||
|
})
|
||||||
|
this.render()
|
||||||
|
this.app.emit(
|
||||||
|
'scrollChange',
|
||||||
|
this.app.state.scrollX,
|
||||||
|
this.app.state.scrollY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动至中心,即回到所有元素的中心位置
|
||||||
|
scrollToCenter() {
|
||||||
|
if (!this.app.elements.hasElements()) {
|
||||||
|
this.scrollTo(0, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let { minx, maxx, miny, maxy } = getMultiElementRectInfo(
|
||||||
|
this.app.elements.elementList
|
||||||
|
)
|
||||||
|
let width = maxx - minx
|
||||||
|
let height = maxy - miny
|
||||||
|
this.scrollTo(
|
||||||
|
minx - (this.app.width - width) / 2,
|
||||||
|
miny - (this.app.height - height) / 2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制粘贴当前元素
|
||||||
|
copyPasteCurrentElements() {
|
||||||
|
this.copyCurrentElement()
|
||||||
|
this.pasteCurrentElement()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置背景颜色
|
||||||
|
setBackgroundColor(color) {
|
||||||
|
this.app.updateState({
|
||||||
|
backgroundColor: color
|
||||||
|
})
|
||||||
|
this.app.background.set()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中所有元素
|
||||||
|
selectAll() {
|
||||||
|
this.app.selection.selectElements(this.app.elements.elementList)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,317 @@
|
||||||
|
import { throttle, getElementCorners, getBoundingRect } from './utils'
|
||||||
|
import Rectangle from './elements/Rectangle'
|
||||||
|
import Canvas from './Canvas'
|
||||||
|
import Coordinate from './Coordinate'
|
||||||
|
import MultiSelectElement from './elements/MultiSelectElement'
|
||||||
|
import { DRAG_ELEMENT_PARTS } from './constants'
|
||||||
|
|
||||||
|
// 多选类
|
||||||
|
export default class Selection {
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app
|
||||||
|
this.canvas = null
|
||||||
|
this.ctx = null
|
||||||
|
// 当前是否正在创建选区中
|
||||||
|
this.creatingSelection = false
|
||||||
|
// 当前是否存在多选元素
|
||||||
|
this.hasSelection = false
|
||||||
|
// 当前是否正在调整被选中的元素
|
||||||
|
this.isResizing = false
|
||||||
|
this.state = this.app.state
|
||||||
|
this.width = this.app.width
|
||||||
|
this.height = this.app.height
|
||||||
|
this.coordinate = new Coordinate(this)
|
||||||
|
// 选区矩形
|
||||||
|
this.rectangle = new Rectangle(
|
||||||
|
{
|
||||||
|
type: 'rectangle',
|
||||||
|
style: {
|
||||||
|
strokeStyle: 'rgba(9,132,227,0.3)',
|
||||||
|
fillStyle: 'rgba(9,132,227,0.3)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
this
|
||||||
|
)
|
||||||
|
// 被选中的元素的虚拟元素,用于显示拖拽框
|
||||||
|
this.multiSelectElement = new MultiSelectElement(
|
||||||
|
{
|
||||||
|
type: 'multiSelectElement'
|
||||||
|
},
|
||||||
|
this
|
||||||
|
)
|
||||||
|
this.checkInNodes = throttle(this.checkInNodes, this, 500)
|
||||||
|
// 稍微缓解一下卡顿
|
||||||
|
this.handleResize = throttle(this.handleResize, this, 16)
|
||||||
|
this.init()
|
||||||
|
this.bindEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
init() {
|
||||||
|
if (this.canvas) {
|
||||||
|
this.app.container.removeChild(this.canvas.el)
|
||||||
|
}
|
||||||
|
this.width = this.app.width
|
||||||
|
this.height = this.app.height
|
||||||
|
// 创建canvas元素
|
||||||
|
this.canvas = new Canvas(this.width, this.height, {
|
||||||
|
className: 'selection'
|
||||||
|
})
|
||||||
|
this.ctx = this.canvas.ctx
|
||||||
|
this.app.container.appendChild(this.canvas.el)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听事件
|
||||||
|
bindEvent() {
|
||||||
|
this.app.on('change', () => {
|
||||||
|
this.state = this.app.state
|
||||||
|
this.multiSelectElement.updateElements(this.app.elements.elementList)
|
||||||
|
this.renderSelection()
|
||||||
|
})
|
||||||
|
this.app.on('scrollChange', () => {
|
||||||
|
this.renderSelection()
|
||||||
|
})
|
||||||
|
this.app.on('zoomChange', () => {
|
||||||
|
this.renderSelection()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鼠标按下
|
||||||
|
onMousedown(e, event) {
|
||||||
|
if (e.originEvent.which !== 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.creatingSelection = true
|
||||||
|
this.rectangle.updatePos(event.mousedownPos.x, event.mousedownPos.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鼠标移动
|
||||||
|
onMousemove(e, event) {
|
||||||
|
if (
|
||||||
|
Math.abs(event.mouseOffset.x) <= 10 &&
|
||||||
|
Math.abs(event.mouseOffset.y) <= 10
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.onMove(e, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鼠标松开
|
||||||
|
onMouseup() {
|
||||||
|
this.creatingSelection = false
|
||||||
|
this.rectangle.updateRect(0, 0, 0, 0)
|
||||||
|
// 判断是否有元素被选中
|
||||||
|
this.hasSelection = this.hasSelectionElements()
|
||||||
|
this.multiSelectElement.updateRect()
|
||||||
|
this.renderSelection()
|
||||||
|
this.emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复位
|
||||||
|
reset() {
|
||||||
|
this.setMultiSelectElements([])
|
||||||
|
this.hasSelection = false
|
||||||
|
this.renderSelection()
|
||||||
|
this.emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染
|
||||||
|
renderSelection() {
|
||||||
|
this.canvas.clearCanvas()
|
||||||
|
this.ctx.save()
|
||||||
|
this.ctx.scale(this.app.state.scale, this.app.state.scale)
|
||||||
|
this.rectangle.render()
|
||||||
|
this.multiSelectElement.render()
|
||||||
|
this.ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鼠标移动事件
|
||||||
|
onMove(e, event) {
|
||||||
|
this.rectangle.updateSize(event.mouseOffset.x, event.mouseOffset.y)
|
||||||
|
this.renderSelection()
|
||||||
|
this.checkInElements(e, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测在选区里的节点
|
||||||
|
checkInElements(e, event) {
|
||||||
|
let minx = Math.min(event.mousedownPos.x, e.clientX)
|
||||||
|
let miny = Math.min(event.mousedownPos.y, e.clientY)
|
||||||
|
let maxx = Math.max(event.mousedownPos.x, e.clientX)
|
||||||
|
let maxy = Math.max(event.mousedownPos.y, e.clientY)
|
||||||
|
let selectedElementList = []
|
||||||
|
this.app.elements.elementList.forEach(element => {
|
||||||
|
let _minx = Infinity
|
||||||
|
let _maxx = -Infinity
|
||||||
|
let _miny = Infinity
|
||||||
|
let _maxy = -Infinity
|
||||||
|
let endPointList = element.getEndpointList()
|
||||||
|
let rect = getBoundingRect(
|
||||||
|
endPointList.map(point => {
|
||||||
|
return [point.x, point.y]
|
||||||
|
}),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
rect.forEach(({ x, y }) => {
|
||||||
|
if (x < _minx) {
|
||||||
|
_minx = x
|
||||||
|
}
|
||||||
|
if (x > _maxx) {
|
||||||
|
_maxx = x
|
||||||
|
}
|
||||||
|
if (y < _miny) {
|
||||||
|
_miny = y
|
||||||
|
}
|
||||||
|
if (y > _maxy) {
|
||||||
|
_maxy = y
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (_minx >= minx && _maxx <= maxx && _miny >= miny && _maxy <= maxy) {
|
||||||
|
selectedElementList.push(element)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
let finalList = [...selectedElementList]
|
||||||
|
selectedElementList.forEach(item => {
|
||||||
|
if (item.hasGroup()) {
|
||||||
|
finalList.push(...this.app.group.getGroupElements(item))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
finalList = new Set(finalList)
|
||||||
|
finalList = Array.from(finalList)
|
||||||
|
this.setMultiSelectElements(finalList, true)
|
||||||
|
this.app.render.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测指定位置是否在元素调整手柄上
|
||||||
|
checkInResizeHand(x, y) {
|
||||||
|
return this.multiSelectElement.dragElement.checkPointInDragElementWhere(
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否需要进行元素调整操作
|
||||||
|
checkIsResize(x, y, e) {
|
||||||
|
if (!this.hasSelection) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let hand = this.multiSelectElement.dragElement.checkPointInDragElementWhere(
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
)
|
||||||
|
if (hand) {
|
||||||
|
this.isResizing = true
|
||||||
|
this.multiSelectElement.startResize(hand, e)
|
||||||
|
this.app.cursor.setResize(hand)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进行元素调整操作
|
||||||
|
handleResize(...args) {
|
||||||
|
if (!this.isResizing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.multiSelectElement.resize(...args)
|
||||||
|
this.app.render.render()
|
||||||
|
this.multiSelectElement.updateRect()
|
||||||
|
this.renderSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结束元素调整操作
|
||||||
|
endResize() {
|
||||||
|
this.isResizing = false
|
||||||
|
this.multiSelectElement.endResize()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为多选元素设置样式
|
||||||
|
setSelectedElementStyle(style = {}) {
|
||||||
|
if (!this.hasSelectionElements()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Object.keys(style).forEach(key => {
|
||||||
|
this.getSelectionElements().forEach(element => {
|
||||||
|
element.style[key] = style[key]
|
||||||
|
if (key === 'fontSize' && element.type === 'text') {
|
||||||
|
element.updateTextSize()
|
||||||
|
this.multiSelectElement.updateRect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
this.app.render.render()
|
||||||
|
this.app.emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除当前选中的元素
|
||||||
|
deleteSelectedElements() {
|
||||||
|
this.getSelectionElements().forEach(element => {
|
||||||
|
this.app.elements.deleteElement(element)
|
||||||
|
})
|
||||||
|
this.selectElements([])
|
||||||
|
this.app.emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前是否存在被选中元素
|
||||||
|
hasSelectionElements() {
|
||||||
|
return this.getSelectionElements().length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前被选中的元素
|
||||||
|
getSelectionElements() {
|
||||||
|
return this.multiSelectElement.selectedElementList
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制当前选中的元素
|
||||||
|
async copySelectionElements(pos) {
|
||||||
|
let task = this.getSelectionElements().map(element => {
|
||||||
|
return this.app.elements.copyElement(element, true)
|
||||||
|
})
|
||||||
|
this.app.group.clearCopyMap()
|
||||||
|
let elements = await Promise.all(task)
|
||||||
|
this.setMultiSelectElements(elements)
|
||||||
|
// 粘贴到指定位置
|
||||||
|
if (pos) {
|
||||||
|
this.multiSelectElement.startResize(DRAG_ELEMENT_PARTS.BODY)
|
||||||
|
let ox =
|
||||||
|
pos.x - this.multiSelectElement.x - this.multiSelectElement.width / 2
|
||||||
|
let oy =
|
||||||
|
pos.y - this.multiSelectElement.y - this.multiSelectElement.height / 2
|
||||||
|
// 如果开启了网格,那么要坐标要吸附到网格
|
||||||
|
let gridAdsorbentPos = this.app.coordinate.gridAdsorbent(ox, oy)
|
||||||
|
this.multiSelectElement.resize(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
gridAdsorbentPos.x,
|
||||||
|
gridAdsorbentPos.y
|
||||||
|
)
|
||||||
|
this.multiSelectElement.endResize()
|
||||||
|
this.multiSelectElement.updateRect()
|
||||||
|
}
|
||||||
|
this.app.render.render()
|
||||||
|
this.renderSelection()
|
||||||
|
this.app.emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中指定元素
|
||||||
|
selectElements(elements = []) {
|
||||||
|
this.hasSelection = elements.length > 0
|
||||||
|
this.setMultiSelectElements(elements)
|
||||||
|
this.app.render.render()
|
||||||
|
this.renderSelection()
|
||||||
|
this.emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置选中的元素
|
||||||
|
setMultiSelectElements(elements = [], notUpdateRect) {
|
||||||
|
this.multiSelectElement.setSelectedElementList(elements)
|
||||||
|
if (!notUpdateRect) {
|
||||||
|
this.multiSelectElement.updateRect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发多选元素变化事件
|
||||||
|
emitChange() {
|
||||||
|
this.app.emit('multiSelectChange', this.getSelectionElements())
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { getFontString, getTextElementSize } from './utils'
|
||||||
|
import EventEmitter from 'eventemitter3'
|
||||||
|
|
||||||
|
// 文字编辑类
|
||||||
|
export default class TextEdit extends EventEmitter {
|
||||||
|
constructor(app) {
|
||||||
|
super()
|
||||||
|
this.app = app
|
||||||
|
this.editable = null
|
||||||
|
this.isEditing = false
|
||||||
|
this.onTextInput = this.onTextInput.bind(this)
|
||||||
|
this.onTextBlur = this.onTextBlur.bind(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建文本输入框元素
|
||||||
|
crateTextInputEl() {
|
||||||
|
this.editable = document.createElement('textarea')
|
||||||
|
this.editable.dir = 'auto'
|
||||||
|
this.editable.tabIndex = 0
|
||||||
|
this.editable.wrap = 'off'
|
||||||
|
this.editable.className = 'textInput'
|
||||||
|
Object.assign(this.editable.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
'z-index': 99999,
|
||||||
|
display: 'block',
|
||||||
|
minHeight: '1em',
|
||||||
|
backfaceVisibility: 'hidden',
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
border: 0,
|
||||||
|
outline: 0,
|
||||||
|
resize: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
overflow: 'hidden',
|
||||||
|
whiteSpace: 'pre'
|
||||||
|
})
|
||||||
|
this.editable.addEventListener('input', this.onTextInput)
|
||||||
|
this.editable.addEventListener('blur', this.onTextBlur)
|
||||||
|
document.body.appendChild(this.editable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据当前文字元素的样式更新文本输入框的样式
|
||||||
|
updateTextInputStyle() {
|
||||||
|
let activeElement = this.app.elements.activeElement
|
||||||
|
if (!activeElement) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let { x, y, width, height, style, text, rotate } = activeElement
|
||||||
|
let { coordinate, state } = this.app
|
||||||
|
this.editable.value = text
|
||||||
|
x = coordinate.subScrollX(x)
|
||||||
|
y = coordinate.subScrollY(y)
|
||||||
|
// 屏幕坐标转画布坐标
|
||||||
|
let sp = coordinate.scale(x, y)
|
||||||
|
let tp = coordinate.containerToWindow(sp.x, sp.y)
|
||||||
|
let fontSize = style.fontSize * state.scale
|
||||||
|
let styles = {
|
||||||
|
font: getFontString(fontSize, style.fontFamily),
|
||||||
|
lineHeight: `${fontSize * style.lineHeightRatio}px`,
|
||||||
|
left: `${tp.x}px`,
|
||||||
|
top: `${tp.y}px`,
|
||||||
|
color: style.fillStyle,
|
||||||
|
width: Math.max(width, 100) * state.scale + 'px',
|
||||||
|
height: height * state.scale + 'px',
|
||||||
|
transform: `rotate(${rotate}deg)`,
|
||||||
|
opacity: style.globalAlpha
|
||||||
|
}
|
||||||
|
Object.assign(this.editable.style, styles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文本输入事件
|
||||||
|
onTextInput() {
|
||||||
|
let activeElement = this.app.elements.activeElement
|
||||||
|
if (!activeElement) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeElement.text = this.editable.value
|
||||||
|
let { width, height } = getTextElementSize(activeElement)
|
||||||
|
activeElement.width = width
|
||||||
|
activeElement.height = height
|
||||||
|
this.updateTextInputStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文本框失焦事件
|
||||||
|
onTextBlur() {
|
||||||
|
this.editable.style.display = 'none'
|
||||||
|
this.editable.value = ''
|
||||||
|
this.emit('blur')
|
||||||
|
this.isEditing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示文本编辑框
|
||||||
|
showTextEdit() {
|
||||||
|
if (!this.editable) {
|
||||||
|
this.crateTextInputEl()
|
||||||
|
} else {
|
||||||
|
this.editable.style.display = 'block'
|
||||||
|
}
|
||||||
|
this.updateTextInputStyle()
|
||||||
|
this.editable.focus()
|
||||||
|
this.editable.select()
|
||||||
|
this.isEditing = true
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
// 元素的四个角
|
||||||
|
export const CORNERS = {
|
||||||
|
TOP_LEFT: 'topLeft', // 左上角
|
||||||
|
TOP_RIGHT: 'topRight', // 右上角
|
||||||
|
BOTTOM_RIGHT: 'bottomRight', // 右下角
|
||||||
|
BOTTOM_LEFT: 'bottomLeft' // 左下角
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖拽元素的部位
|
||||||
|
export const DRAG_ELEMENT_PARTS = {
|
||||||
|
BODY: 'body',
|
||||||
|
ROTATE: 'rotate',
|
||||||
|
TOP_LEFT_BTN: 'topLeftBtn',
|
||||||
|
TOP_RIGHT_BTN: 'topRightBtn',
|
||||||
|
BOTTOM_RIGHT_BTN: 'bottomRightBtn',
|
||||||
|
BOTTOM_LEFT_BTN: 'bottomLeftBtn'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 距离10像素内都认为点击到了目标
|
||||||
|
export const HIT_DISTANCE = 10
|
|
@ -0,0 +1,48 @@
|
||||||
|
import BaseMultiPointElement from './BaseMultiPointElement'
|
||||||
|
import { drawArrow } from '../utils/draw'
|
||||||
|
import DragElement from './DragElement'
|
||||||
|
import { transformPointOnElement } from '../utils'
|
||||||
|
import { checkIsAtArrowEdge } from '../utils/checkHit'
|
||||||
|
|
||||||
|
// 箭头元素类
|
||||||
|
export default class Arrow extends BaseMultiPointElement {
|
||||||
|
constructor(...args) {
|
||||||
|
super(...args)
|
||||||
|
// 拖拽元素实例
|
||||||
|
this.dragElement = new DragElement(this, this.app)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染到画布
|
||||||
|
render() {
|
||||||
|
let { pointArr, fictitiousPoint } = this
|
||||||
|
this.warpRender(({ cx, cy }) => {
|
||||||
|
// 加上鼠标当前实时位置
|
||||||
|
let realtimePoint = []
|
||||||
|
if (pointArr.length > 0 && this.isCreating) {
|
||||||
|
let { x: fx, y: fy } = this.app.coordinate.transform(
|
||||||
|
fictitiousPoint.x - cx,
|
||||||
|
fictitiousPoint.y - cy
|
||||||
|
)
|
||||||
|
realtimePoint = [[fx, fy]]
|
||||||
|
}
|
||||||
|
drawArrow(
|
||||||
|
this.app.ctx,
|
||||||
|
pointArr
|
||||||
|
.map(point => {
|
||||||
|
// 屏幕坐标在左上角,画布坐标在中心,所以屏幕坐标要先转成画布坐标
|
||||||
|
let { x, y } = this.app.coordinate.transform(point[0], point[1])
|
||||||
|
return [x - cx, y - cy]
|
||||||
|
})
|
||||||
|
.concat(realtimePoint)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
// 激活时显示拖拽框
|
||||||
|
this.renderDragElement()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否被击中
|
||||||
|
isHit(x, y) {
|
||||||
|
let rp = transformPointOnElement(x, y, this)
|
||||||
|
return checkIsAtArrowEdge(this, rp)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,270 @@
|
||||||
|
import {
|
||||||
|
degToRad,
|
||||||
|
getRotatedPoint,
|
||||||
|
getElementCorners,
|
||||||
|
createNodeKey
|
||||||
|
} from '../utils'
|
||||||
|
import EventEmitter from 'eventemitter3'
|
||||||
|
|
||||||
|
// 基础元素类
|
||||||
|
export default class BaseElement extends EventEmitter {
|
||||||
|
constructor(opts = {}, app) {
|
||||||
|
super()
|
||||||
|
this.app = app
|
||||||
|
// 编组id
|
||||||
|
this.groupId = opts.groupId || ''
|
||||||
|
// 类型
|
||||||
|
this.type = opts.type || ''
|
||||||
|
// key
|
||||||
|
this.key = createNodeKey()
|
||||||
|
// 是否正在创建中
|
||||||
|
this.isCreating = true
|
||||||
|
// 是否被激活
|
||||||
|
this.isActive = true
|
||||||
|
// 是否被多选选中
|
||||||
|
this.isSelected = false
|
||||||
|
// 记录初始位置,用于拖动时
|
||||||
|
this.startX = 0
|
||||||
|
this.startY = 0
|
||||||
|
// 实时位置,该位置为元素的左上角坐标
|
||||||
|
this.x = opts.x || 0
|
||||||
|
this.y = opts.y || 0
|
||||||
|
// 宽高
|
||||||
|
this.width = opts.width || 0
|
||||||
|
this.height = opts.height || 0
|
||||||
|
// 记录初始角度,用于旋转时
|
||||||
|
this.startRotate = 0
|
||||||
|
// 角度
|
||||||
|
this.rotate = opts.rotate || 0
|
||||||
|
// 是否不需要渲染
|
||||||
|
this.noRender = false
|
||||||
|
// 样式
|
||||||
|
this.style = {
|
||||||
|
strokeStyle: '', // 线条颜色
|
||||||
|
fillStyle: '', // 填充颜色
|
||||||
|
lineWidth: 'small', // 线条宽度
|
||||||
|
lineDash: 0, // 线条虚线大小
|
||||||
|
globalAlpha: 1, // 透明度
|
||||||
|
...(opts.style || {})
|
||||||
|
}
|
||||||
|
// 拖拽元素实例
|
||||||
|
this.dragElement = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 序列化
|
||||||
|
serialize() {
|
||||||
|
return {
|
||||||
|
groupId: this.groupId,
|
||||||
|
type: this.type,
|
||||||
|
width: this.width,
|
||||||
|
height: this.height,
|
||||||
|
x: this.x,
|
||||||
|
y: this.y,
|
||||||
|
rotate: this.rotate,
|
||||||
|
style: {
|
||||||
|
...this.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染方法
|
||||||
|
render() {
|
||||||
|
throw new Error('子类需要实现该方法!')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置所属编组id
|
||||||
|
setGroupId(groupId) {
|
||||||
|
this.groupId = groupId
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所属组id
|
||||||
|
getGroupId() {
|
||||||
|
return this.groupId
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除所属组id
|
||||||
|
removeGroupId() {
|
||||||
|
this.groupId = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否存在编组
|
||||||
|
hasGroup() {
|
||||||
|
return !!this.groupId
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染拖拽元素
|
||||||
|
renderDragElement() {
|
||||||
|
if (this.isActive && !this.isCreating) {
|
||||||
|
this.dragElement.showAll()
|
||||||
|
this.dragElement.render()
|
||||||
|
} else if (this.isSelected) {
|
||||||
|
// 被多选选中
|
||||||
|
this.dragElement.onlyShowBody()
|
||||||
|
this.dragElement.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理样式数据
|
||||||
|
handleStyle(style) {
|
||||||
|
Object.keys(style).forEach(key => {
|
||||||
|
// 处理线条宽度
|
||||||
|
if (key === 'lineWidth') {
|
||||||
|
if (style[key] === 'small') {
|
||||||
|
style[key] = 2
|
||||||
|
} else if (style[key] === 'middle') {
|
||||||
|
style[key] = 4
|
||||||
|
} else if (style[key] === 'large') {
|
||||||
|
style[key] = 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (style[key] === '') {
|
||||||
|
if (
|
||||||
|
this.app.state[key] !== undefined &&
|
||||||
|
this.app.state[key] !== null &&
|
||||||
|
this.app.state[key] !== ''
|
||||||
|
) {
|
||||||
|
style[key] = this.app.state[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return style
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置绘图样式
|
||||||
|
setStyle(style = {}) {
|
||||||
|
let _style = this.handleStyle(style)
|
||||||
|
Object.keys(_style).forEach(key => {
|
||||||
|
// 处理虚线
|
||||||
|
if (key === 'lineDash') {
|
||||||
|
if (_style.lineDash > 0) {
|
||||||
|
this.app.ctx.setLineDash([_style.lineDash])
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
_style[key] !== undefined &&
|
||||||
|
_style[key] !== '' &&
|
||||||
|
_style[key] !== null
|
||||||
|
) {
|
||||||
|
this.app.ctx[key] = _style[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公共渲染操作
|
||||||
|
warpRender(renderFn) {
|
||||||
|
let { x, y, width, height, rotate, style } = this
|
||||||
|
// 坐标转换
|
||||||
|
let { x: tx, y: ty } = this.app.coordinate.transform(x, y)
|
||||||
|
// 移动画布中点到元素中心,否则旋转时中心点不对
|
||||||
|
let halfWidth = width / 2
|
||||||
|
let halfHeight = height / 2
|
||||||
|
let cx = tx + halfWidth
|
||||||
|
let cy = ty + halfHeight
|
||||||
|
this.app.ctx.save()
|
||||||
|
this.app.ctx.translate(cx, cy)
|
||||||
|
this.app.ctx.rotate(degToRad(rotate))
|
||||||
|
this.setStyle(style)
|
||||||
|
renderFn({
|
||||||
|
halfWidth,
|
||||||
|
halfHeight,
|
||||||
|
tx,
|
||||||
|
ty,
|
||||||
|
cx,
|
||||||
|
cy
|
||||||
|
})
|
||||||
|
this.app.ctx.restore()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存元素初始状态
|
||||||
|
saveState() {
|
||||||
|
let { rotate, x, y } = this
|
||||||
|
this.startRotate = rotate
|
||||||
|
this.startX = x
|
||||||
|
this.startY = y
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动元素
|
||||||
|
move(ox, oy) {
|
||||||
|
let { startX, startY } = this
|
||||||
|
this.x = startX + ox
|
||||||
|
this.y = startY + oy
|
||||||
|
this.emit('elementPositionChange', this.x, this.y)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新元素包围框
|
||||||
|
updateRect(x, y, width, height) {
|
||||||
|
this.updatePos(x, y)
|
||||||
|
this.updateSize(width, height)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新激活元素尺寸
|
||||||
|
updateSize(width, height) {
|
||||||
|
this.width = width
|
||||||
|
this.height = height
|
||||||
|
this.emit('elementSizeChange', this.width, this.height)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新激活元素坐标
|
||||||
|
updatePos(x, y) {
|
||||||
|
this.x = x
|
||||||
|
this.y = y
|
||||||
|
this.emit('elementPositionChange', this.x, this.y)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 偏移元素角度
|
||||||
|
offsetRotate(or) {
|
||||||
|
this.updateRotate(this.startRotate + or)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新元素角度
|
||||||
|
updateRotate(rotate) {
|
||||||
|
rotate = rotate % 360
|
||||||
|
if (rotate < 0) {
|
||||||
|
rotate = 360 + rotate
|
||||||
|
}
|
||||||
|
this.rotate = parseInt(rotate)
|
||||||
|
this.emit('elementRotateChange', this.rotate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据指定中心点旋转元素的各个点
|
||||||
|
rotateByCenter(rotate, cx, cy) {
|
||||||
|
this.offsetRotate(rotate)
|
||||||
|
let np = getRotatedPoint(this.startX, this.startY, cx, cy, rotate)
|
||||||
|
this.updatePos(np.x, np.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测元素是否被击中
|
||||||
|
isHit(x, y) {
|
||||||
|
throw new Error('子类需要实现该方法!')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始调整元素
|
||||||
|
startResize(resizeType, e) {
|
||||||
|
this.dragElement.startResize(resizeType, e)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结束调整元素操作
|
||||||
|
endResize() {
|
||||||
|
this.dragElement.endResize()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调整元素中
|
||||||
|
resize(...args) {
|
||||||
|
this.dragElement.handleResizeElement(...args)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取图形应用了旋转之后的端点列表
|
||||||
|
getEndpointList() {
|
||||||
|
return getElementCorners(this)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,134 @@
|
||||||
|
import {
|
||||||
|
getBoundingRect,
|
||||||
|
deepCopy,
|
||||||
|
getRotatedPoint,
|
||||||
|
getElementCenterPoint
|
||||||
|
} from '../utils'
|
||||||
|
import BaseElement from './BaseElement'
|
||||||
|
|
||||||
|
// 基础多个点的组件的元素类
|
||||||
|
export default class BaseMultiPointElement extends BaseElement {
|
||||||
|
constructor(opts = {}, app) {
|
||||||
|
super(opts, app)
|
||||||
|
// 记录初始点位,在拖动时
|
||||||
|
this.startPointArr = []
|
||||||
|
// 点位
|
||||||
|
this.pointArr = opts.pointArr || []
|
||||||
|
// 记录初始大小,用于缩放时
|
||||||
|
this.startWidth = 0
|
||||||
|
this.startHeight = 0
|
||||||
|
// 鼠标当前实时位置,用于在绘制时显示线段最后一个点到当前鼠标的虚拟连接线
|
||||||
|
this.fictitiousPoint = {
|
||||||
|
x: 0,
|
||||||
|
y: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 序列化
|
||||||
|
serialize() {
|
||||||
|
let base = super.serialize()
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
pointArr: [...this.pointArr]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加坐标,具有多个坐标数据的图形,如线段、自由线
|
||||||
|
addPoint(x, y, ...args) {
|
||||||
|
if (!Array.isArray(this.pointArr)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.pointArr.push([x, y, ...args])
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新元素包围框,用于具有多个坐标数据的图形
|
||||||
|
updateMultiPointBoundingRect() {
|
||||||
|
let rect = getBoundingRect(this.pointArr)
|
||||||
|
this.x = rect.x
|
||||||
|
this.y = rect.y
|
||||||
|
this.width = rect.width
|
||||||
|
this.height = rect.height
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新虚拟坐标点
|
||||||
|
updateFictitiousPoint(x, y) {
|
||||||
|
this.fictitiousPoint.x = x
|
||||||
|
this.fictitiousPoint.y = y
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存元素初始状态
|
||||||
|
saveState() {
|
||||||
|
let { rotate, x, y, width, height, pointArr } = this
|
||||||
|
this.startRotate = rotate
|
||||||
|
this.startX = x
|
||||||
|
this.startY = y
|
||||||
|
this.startPointArr = deepCopy(pointArr)
|
||||||
|
this.startWidth = width
|
||||||
|
this.startHeight = height
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动元素
|
||||||
|
move(ox, oy) {
|
||||||
|
this.pointArr = this.startPointArr.map(point => {
|
||||||
|
return [point[0] + ox, point[1] + oy, ...point.slice(2)]
|
||||||
|
})
|
||||||
|
let { startX, startY } = this
|
||||||
|
this.x = startX + ox
|
||||||
|
this.y = startY + oy
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新元素包围框
|
||||||
|
updateRect(x, y, width, height) {
|
||||||
|
let { startWidth, startHeight, startPointArr } = this
|
||||||
|
// 获取收缩比例
|
||||||
|
let scaleX = width / startWidth
|
||||||
|
let scaleY = height / startHeight
|
||||||
|
// 所有点位都进行同步缩放
|
||||||
|
this.pointArr = startPointArr.map(point => {
|
||||||
|
let nx = point[0] * scaleX
|
||||||
|
let ny = point[1] * scaleY
|
||||||
|
return [nx, ny, ...point.slice(2)]
|
||||||
|
})
|
||||||
|
// 放大后会偏移拖拽元素,所以计算一下元素的新包围框和拖拽元素包围框的差距,然后绘制时整体往回偏移
|
||||||
|
let rect = getBoundingRect(this.pointArr)
|
||||||
|
let offsetX = rect.x - x
|
||||||
|
let offsetY = rect.y - y
|
||||||
|
this.pointArr = this.pointArr.map(point => {
|
||||||
|
return [point[0] - offsetX, point[1] - offsetY, ...point.slice(2)]
|
||||||
|
})
|
||||||
|
this.updatePos(x, y)
|
||||||
|
this.updateSize(width, height)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据指定中心点旋转元素的各个点
|
||||||
|
rotateByCenter(rotate, cx, cy) {
|
||||||
|
this.pointArr = this.startPointArr.map(point => {
|
||||||
|
let np = getRotatedPoint(point[0], point[1], cx, cy, rotate)
|
||||||
|
return [np.x, np.y, ...point.slice(2)]
|
||||||
|
})
|
||||||
|
this.updateMultiPointBoundingRect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取图形应用了旋转之后的端点列表
|
||||||
|
getEndpointList() {
|
||||||
|
return this.pointArr.map(point => {
|
||||||
|
let center = getElementCenterPoint(this)
|
||||||
|
let np = getRotatedPoint(
|
||||||
|
point[0],
|
||||||
|
point[1],
|
||||||
|
center.x,
|
||||||
|
center.y,
|
||||||
|
this.rotate
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
x: np.x,
|
||||||
|
y: np.y
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
import BaseElement from './BaseElement'
|
||||||
|
import { drawCircle } from '../utils/draw'
|
||||||
|
import DragElement from './DragElement'
|
||||||
|
import { transformPointOnElement } from '../utils'
|
||||||
|
import { getCircleRadius, checkIsAtCircleEdge } from '../utils/checkHit'
|
||||||
|
|
||||||
|
// 正圆元素类
|
||||||
|
export default class Circle extends BaseElement {
|
||||||
|
constructor(...args) {
|
||||||
|
super(...args)
|
||||||
|
// 拖拽元素实例
|
||||||
|
this.dragElement = new DragElement(this, this.app, {
|
||||||
|
lockRatio: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染到画布
|
||||||
|
render() {
|
||||||
|
let { width, height } = this
|
||||||
|
this.warpRender(({ halfWidth, halfHeight }) => {
|
||||||
|
// 画布中心点修改了,所以元素的坐标也要相应修改
|
||||||
|
drawCircle(this.app.ctx, 0, 0, getCircleRadius(width, height), true)
|
||||||
|
})
|
||||||
|
// 激活时显示拖拽框
|
||||||
|
this.renderDragElement()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否被击中
|
||||||
|
isHit(x, y) {
|
||||||
|
let rp = transformPointOnElement(x, y, this)
|
||||||
|
return checkIsAtCircleEdge(this, rp)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,50 @@
|
||||||
|
import BaseElement from './BaseElement'
|
||||||
|
import { drawDiamond } from '../utils/draw'
|
||||||
|
import DragElement from './DragElement'
|
||||||
|
import {
|
||||||
|
transformPointOnElement,
|
||||||
|
getRotatedPoint,
|
||||||
|
getElementCenterPoint
|
||||||
|
} from '../utils'
|
||||||
|
import { checkIsAtDiamondEdge } from '../utils/checkHit'
|
||||||
|
|
||||||
|
// 菱形元素类
|
||||||
|
export default class Diamond extends BaseElement {
|
||||||
|
constructor(...args) {
|
||||||
|
super(...args)
|
||||||
|
// 拖拽元素实例
|
||||||
|
this.dragElement = new DragElement(this, this.app)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染到画布
|
||||||
|
render() {
|
||||||
|
let { width, height } = this
|
||||||
|
this.warpRender(({ halfWidth, halfHeight }) => {
|
||||||
|
// 画布中心点修改了,所以元素的坐标也要相应修改
|
||||||
|
drawDiamond(this.app.ctx, -halfWidth, -halfHeight, width, height, true)
|
||||||
|
})
|
||||||
|
// 激活时显示拖拽框
|
||||||
|
this.renderDragElement()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否被击中
|
||||||
|
isHit(x, y) {
|
||||||
|
let rp = transformPointOnElement(x, y, this)
|
||||||
|
return checkIsAtDiamondEdge(this, rp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取图形应用了旋转之后的端点列表
|
||||||
|
getEndpointList() {
|
||||||
|
let { x, y, width, height, rotate } = this
|
||||||
|
let points = [
|
||||||
|
[x + width / 2, y],
|
||||||
|
[x + width, y + height / 2],
|
||||||
|
[x + width / 2, y + height],
|
||||||
|
[x, y + height / 2]
|
||||||
|
]
|
||||||
|
let center = getElementCenterPoint(this)
|
||||||
|
return points.map(point => {
|
||||||
|
return getRotatedPoint(point[0], point[1], center.x, center.y, rotate)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,529 @@
|
||||||
|
import {
|
||||||
|
getTowPointDistance,
|
||||||
|
transformPointOnElement,
|
||||||
|
checkPointIsInRectangle,
|
||||||
|
getTowPointRotate,
|
||||||
|
getElementCenterPoint,
|
||||||
|
transformPointReverseRotate,
|
||||||
|
getElementRotatedCornerPoint,
|
||||||
|
getRotatedPoint
|
||||||
|
} from '../utils'
|
||||||
|
import { CORNERS, DRAG_ELEMENT_PARTS } from '../constants'
|
||||||
|
import BaseElement from './BaseElement'
|
||||||
|
import { drawRect, drawCircle } from '../utils/draw'
|
||||||
|
|
||||||
|
// 拖拽元素
|
||||||
|
export default class DragElement extends BaseElement {
|
||||||
|
constructor(element, app, opts = {}) {
|
||||||
|
super(
|
||||||
|
{
|
||||||
|
type: 'dragElement',
|
||||||
|
notNeedDragElement: true
|
||||||
|
},
|
||||||
|
app
|
||||||
|
)
|
||||||
|
|
||||||
|
this.opts = {
|
||||||
|
// 是否锁定长宽比
|
||||||
|
lockRatio: false,
|
||||||
|
...opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// 样式
|
||||||
|
this.style = {
|
||||||
|
strokeStyle: this.app.state.dragStrokeStyle, // 线条颜色
|
||||||
|
fillStyle: 'transparent', // 填充颜色
|
||||||
|
lineWidth: 'small', // 线条宽度
|
||||||
|
lineDash: 0, // 线条虚线大小
|
||||||
|
globalAlpha: 1 // 透明度
|
||||||
|
}
|
||||||
|
|
||||||
|
// 归属节点
|
||||||
|
this.element = element
|
||||||
|
|
||||||
|
// 和元素的距离
|
||||||
|
this.offset = 5
|
||||||
|
// 拖拽手柄尺寸
|
||||||
|
this.size = 10
|
||||||
|
|
||||||
|
// 当前正在进行何种调整操作
|
||||||
|
this.resizeType = ''
|
||||||
|
// 当前鼠标按住拖拽元素的点的对角点
|
||||||
|
this.diagonalPoint = {
|
||||||
|
x: 0,
|
||||||
|
y: 0
|
||||||
|
}
|
||||||
|
// 当前鼠标按下时的坐标和拖拽元素的点的坐标差值
|
||||||
|
this.mousedownPosAndElementPosOffset = {
|
||||||
|
x: 0,
|
||||||
|
y: 0
|
||||||
|
}
|
||||||
|
// 元素的长宽比
|
||||||
|
this.elementRatio = 0
|
||||||
|
// 隐藏的部分
|
||||||
|
this.hideParts = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置隐藏的部分
|
||||||
|
setHideParts(parts = []) {
|
||||||
|
this.hideParts = parts
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示所有部分
|
||||||
|
showAll() {
|
||||||
|
this.setHideParts([])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只显示主体部分
|
||||||
|
onlyShowBody() {
|
||||||
|
this.setHideParts([
|
||||||
|
DRAG_ELEMENT_PARTS.ROTATE,
|
||||||
|
DRAG_ELEMENT_PARTS.TOP_LEFT_BTN,
|
||||||
|
DRAG_ELEMENT_PARTS.TOP_RIGHT_BTN,
|
||||||
|
DRAG_ELEMENT_PARTS.BOTTOM_RIGHT_BTN,
|
||||||
|
DRAG_ELEMENT_PARTS.BOTTOM_LEFT_BTN
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新数据
|
||||||
|
update() {
|
||||||
|
this.x = this.element.x - this.offset
|
||||||
|
this.y = this.element.y - this.offset
|
||||||
|
this.width = this.element.width + this.offset * 2
|
||||||
|
this.height = this.element.height + this.offset * 2
|
||||||
|
this.rotate = this.element.rotate
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染
|
||||||
|
render() {
|
||||||
|
// 如果被编组了那么不显示组元素自身的拖拽框
|
||||||
|
if (this.element.hasGroup()) return
|
||||||
|
this.update()
|
||||||
|
let { width, height } = this
|
||||||
|
this.warpRender(({ halfWidth, halfHeight }) => {
|
||||||
|
// 主体
|
||||||
|
this.app.ctx.save()
|
||||||
|
if (!this.hideParts.includes(DRAG_ELEMENT_PARTS.BODY)) {
|
||||||
|
this.app.ctx.setLineDash([5])
|
||||||
|
drawRect(this.app.ctx, -halfWidth, -halfHeight, width, height)
|
||||||
|
this.app.ctx.restore()
|
||||||
|
}
|
||||||
|
// 左上角
|
||||||
|
if (!this.hideParts.includes(DRAG_ELEMENT_PARTS.TOP_LEFT_BTN)) {
|
||||||
|
drawRect(
|
||||||
|
this.app.ctx,
|
||||||
|
-halfWidth - this.size,
|
||||||
|
-halfHeight - this.size,
|
||||||
|
this.size,
|
||||||
|
this.size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 右上角
|
||||||
|
if (!this.hideParts.includes(DRAG_ELEMENT_PARTS.TOP_RIGHT_BTN)) {
|
||||||
|
drawRect(
|
||||||
|
this.app.ctx,
|
||||||
|
-halfWidth + this.element.width + this.size,
|
||||||
|
-halfHeight - this.size,
|
||||||
|
this.size,
|
||||||
|
this.size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 右下角
|
||||||
|
if (!this.hideParts.includes(DRAG_ELEMENT_PARTS.BOTTOM_RIGHT_BTN)) {
|
||||||
|
drawRect(
|
||||||
|
this.app.ctx,
|
||||||
|
-halfWidth + this.element.width + this.size,
|
||||||
|
-halfHeight + this.element.height + this.size,
|
||||||
|
this.size,
|
||||||
|
this.size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 左下角
|
||||||
|
if (!this.hideParts.includes(DRAG_ELEMENT_PARTS.BOTTOM_LEFT_BTN)) {
|
||||||
|
drawRect(
|
||||||
|
this.app.ctx,
|
||||||
|
-halfWidth - this.size,
|
||||||
|
-halfHeight + this.element.height + this.size,
|
||||||
|
this.size,
|
||||||
|
this.size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 旋转按钮
|
||||||
|
if (!this.hideParts.includes(DRAG_ELEMENT_PARTS.ROTATE)) {
|
||||||
|
drawCircle(
|
||||||
|
this.app.ctx,
|
||||||
|
-halfWidth + this.element.width / 2 + this.size / 2,
|
||||||
|
-halfHeight - this.size * 2,
|
||||||
|
this.size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测一个坐标在拖拽元素的哪个部分上
|
||||||
|
checkPointInDragElementWhere(x, y) {
|
||||||
|
let part = ''
|
||||||
|
// 坐标反向旋转元素的角度
|
||||||
|
let rp = transformPointOnElement(x, y, this.element)
|
||||||
|
// 在内部
|
||||||
|
if (checkPointIsInRectangle(rp.x, rp.y, this)) {
|
||||||
|
part = DRAG_ELEMENT_PARTS.BODY
|
||||||
|
} else if (
|
||||||
|
getTowPointDistance(
|
||||||
|
rp.x,
|
||||||
|
rp.y,
|
||||||
|
this.x + this.width / 2,
|
||||||
|
this.y - this.size * 2
|
||||||
|
) <= this.size
|
||||||
|
) {
|
||||||
|
// 在旋转按钮
|
||||||
|
part = DRAG_ELEMENT_PARTS.ROTATE
|
||||||
|
} else if (this._checkPointIsInBtn(rp.x, rp.y, CORNERS.TOP_LEFT)) {
|
||||||
|
// 在左上角伸缩手柄
|
||||||
|
part = DRAG_ELEMENT_PARTS.TOP_LEFT_BTN
|
||||||
|
} else if (this._checkPointIsInBtn(rp.x, rp.y, CORNERS.TOP_RIGHT)) {
|
||||||
|
// 在右上角伸缩手柄
|
||||||
|
part = DRAG_ELEMENT_PARTS.TOP_RIGHT_BTN
|
||||||
|
} else if (this._checkPointIsInBtn(rp.x, rp.y, CORNERS.BOTTOM_RIGHT)) {
|
||||||
|
// 在右下角伸缩手柄
|
||||||
|
part = DRAG_ELEMENT_PARTS.BOTTOM_RIGHT_BTN
|
||||||
|
} else if (this._checkPointIsInBtn(rp.x, rp.y, CORNERS.BOTTOM_LEFT)) {
|
||||||
|
// 在左下角伸缩手柄
|
||||||
|
part = DRAG_ELEMENT_PARTS.BOTTOM_LEFT_BTN
|
||||||
|
}
|
||||||
|
if (this.hideParts.includes(part)) {
|
||||||
|
part = ''
|
||||||
|
}
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测坐标是否在某个拖拽按钮内
|
||||||
|
_checkPointIsInBtn(x, y, dir) {
|
||||||
|
let _x = 0
|
||||||
|
let _y = 0
|
||||||
|
switch (dir) {
|
||||||
|
case CORNERS.TOP_LEFT:
|
||||||
|
_x = this.x - this.size
|
||||||
|
_y = this.y - this.size
|
||||||
|
break
|
||||||
|
case CORNERS.TOP_RIGHT:
|
||||||
|
_x = this.x + this.width
|
||||||
|
_y = this.y - this.size
|
||||||
|
break
|
||||||
|
case CORNERS.BOTTOM_RIGHT:
|
||||||
|
_x = this.x + this.width
|
||||||
|
_y = this.y + this.height
|
||||||
|
break
|
||||||
|
case CORNERS.BOTTOM_LEFT:
|
||||||
|
_x = this.x - this.size
|
||||||
|
_y = this.y + this.height
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return checkPointIsInRectangle(x, y, _x, _y, this.size, this.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始调整元素
|
||||||
|
startResize(resizeType, e) {
|
||||||
|
this.resizeType = resizeType
|
||||||
|
if (this.opts.lockRatio) {
|
||||||
|
this.elementRatio = this.element.width / this.element.height
|
||||||
|
}
|
||||||
|
if (resizeType === DRAG_ELEMENT_PARTS.BODY) {
|
||||||
|
// 按住了拖拽元素内部
|
||||||
|
this.element.saveState()
|
||||||
|
} else if (resizeType === DRAG_ELEMENT_PARTS.ROTATE) {
|
||||||
|
// 按住了拖拽元素的旋转按钮
|
||||||
|
this.element.saveState()
|
||||||
|
} else if (resizeType === DRAG_ELEMENT_PARTS.TOP_LEFT_BTN) {
|
||||||
|
// 按住了拖拽元素左上角拖拽手柄
|
||||||
|
this.handleDragMousedown(e, CORNERS.TOP_LEFT)
|
||||||
|
} else if (resizeType === DRAG_ELEMENT_PARTS.TOP_RIGHT_BTN) {
|
||||||
|
// 按住了拖拽元素右上角拖拽手柄
|
||||||
|
this.handleDragMousedown(e, CORNERS.TOP_RIGHT)
|
||||||
|
} else if (resizeType === DRAG_ELEMENT_PARTS.BOTTOM_RIGHT_BTN) {
|
||||||
|
// 按住了拖拽元素右下角拖拽手柄
|
||||||
|
this.handleDragMousedown(e, CORNERS.BOTTOM_RIGHT)
|
||||||
|
} else if (resizeType === DRAG_ELEMENT_PARTS.BOTTOM_LEFT_BTN) {
|
||||||
|
// 按住了拖拽元素左下角拖拽手柄
|
||||||
|
this.handleDragMousedown(e, CORNERS.BOTTOM_LEFT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结束调整元素操作
|
||||||
|
endResize() {
|
||||||
|
this.resizeType = ''
|
||||||
|
this.diagonalPoint = {
|
||||||
|
x: 0,
|
||||||
|
y: 0
|
||||||
|
}
|
||||||
|
this.mousedownPosAndElementPosOffset = {
|
||||||
|
x: 0,
|
||||||
|
y: 0
|
||||||
|
}
|
||||||
|
this.elementRatio = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理按下拖拽元素四个伸缩手柄事件
|
||||||
|
handleDragMousedown(e, corner) {
|
||||||
|
let centerPos = getElementCenterPoint(this.element)
|
||||||
|
let pos = getElementRotatedCornerPoint(this.element, corner)
|
||||||
|
// 对角点的坐标
|
||||||
|
this.diagonalPoint.x = 2 * centerPos.x - pos.x
|
||||||
|
this.diagonalPoint.y = 2 * centerPos.y - pos.y
|
||||||
|
// 鼠标按下位置和元素的左上角坐标差值
|
||||||
|
this.mousedownPosAndElementPosOffset.x = e.clientX - pos.x
|
||||||
|
this.mousedownPosAndElementPosOffset.y = e.clientY - pos.y
|
||||||
|
this.element.saveState()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调整元素
|
||||||
|
handleResizeElement(e, mx, my, offsetX, offsetY) {
|
||||||
|
let resizeType = this.resizeType
|
||||||
|
// 按住了拖拽元素内部
|
||||||
|
if (resizeType === DRAG_ELEMENT_PARTS.BODY) {
|
||||||
|
this.handleMoveElement(offsetX, offsetY)
|
||||||
|
} else if (resizeType === DRAG_ELEMENT_PARTS.ROTATE) {
|
||||||
|
// 按住了拖拽元素的旋转按钮
|
||||||
|
this.handleRotateElement(e, mx, my)
|
||||||
|
} else if (resizeType === DRAG_ELEMENT_PARTS.TOP_LEFT_BTN) {
|
||||||
|
// 按住左上角伸缩元素
|
||||||
|
this.handleStretchElement(
|
||||||
|
e,
|
||||||
|
(newCenter, rp) => {
|
||||||
|
return {
|
||||||
|
width: (newCenter.x - rp.x) * 2,
|
||||||
|
height: (newCenter.y - rp.y) * 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rp => {
|
||||||
|
return {
|
||||||
|
x: rp.x,
|
||||||
|
y: rp.y
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(newRatio, newRect) => {
|
||||||
|
let x = newRect.x
|
||||||
|
let y = newRect.y
|
||||||
|
if (newRatio > this.elementRatio) {
|
||||||
|
x = newRect.x + newRect.width - this.elementRatio * newRect.height
|
||||||
|
} else if (newRatio < this.elementRatio) {
|
||||||
|
y = newRect.y + (newRect.height - newRect.width / this.elementRatio)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else if (resizeType === DRAG_ELEMENT_PARTS.TOP_RIGHT_BTN) {
|
||||||
|
// 按住右上角伸缩元素
|
||||||
|
this.handleStretchElement(
|
||||||
|
e,
|
||||||
|
(newCenter, rp) => {
|
||||||
|
return {
|
||||||
|
width: (rp.x - newCenter.x) * 2,
|
||||||
|
height: (newCenter.y - rp.y) * 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(rp, newSize) => {
|
||||||
|
return {
|
||||||
|
x: rp.x - newSize.width,
|
||||||
|
y: rp.y
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(newRatio, newRect) => {
|
||||||
|
let x = newRect.x
|
||||||
|
let y = newRect.y
|
||||||
|
if (newRatio > this.elementRatio) {
|
||||||
|
x = newRect.x + this.elementRatio * newRect.height
|
||||||
|
} else if (newRatio < this.elementRatio) {
|
||||||
|
x = newRect.x + newRect.width
|
||||||
|
y = newRect.y + (newRect.height - newRect.width / this.elementRatio)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else if (resizeType === DRAG_ELEMENT_PARTS.BOTTOM_RIGHT_BTN) {
|
||||||
|
// 按住右下角伸缩元素
|
||||||
|
this.handleStretchElement(
|
||||||
|
e,
|
||||||
|
(newCenter, rp) => {
|
||||||
|
return {
|
||||||
|
width: (rp.x - newCenter.x) * 2,
|
||||||
|
height: (rp.y - newCenter.y) * 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(rp, newSize) => {
|
||||||
|
return {
|
||||||
|
x: rp.x - newSize.width,
|
||||||
|
y: rp.y - newSize.height
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(newRatio, newRect) => {
|
||||||
|
let x = newRect.x
|
||||||
|
let y = newRect.y
|
||||||
|
if (newRatio > this.elementRatio) {
|
||||||
|
x = newRect.x + this.elementRatio * newRect.height
|
||||||
|
y = newRect.y + newRect.height
|
||||||
|
} else if (newRatio < this.elementRatio) {
|
||||||
|
x = newRect.x + newRect.width
|
||||||
|
y = newRect.y + newRect.width / this.elementRatio
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else if (resizeType === DRAG_ELEMENT_PARTS.BOTTOM_LEFT_BTN) {
|
||||||
|
// 按住左下角伸缩元素
|
||||||
|
this.handleStretchElement(
|
||||||
|
e,
|
||||||
|
(newCenter, rp) => {
|
||||||
|
return {
|
||||||
|
width: (newCenter.x - rp.x) * 2,
|
||||||
|
height: (rp.y - newCenter.y) * 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(rp, newSize) => {
|
||||||
|
return {
|
||||||
|
x: rp.x,
|
||||||
|
y: rp.y - newSize.height
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(newRatio, newRect) => {
|
||||||
|
let x = newRect.x
|
||||||
|
let y = newRect.y
|
||||||
|
if (newRatio > this.elementRatio) {
|
||||||
|
x = newRect.x + newRect.width - this.elementRatio * newRect.height
|
||||||
|
y = newRect.y + newRect.height
|
||||||
|
} else if (newRatio < this.elementRatio) {
|
||||||
|
y = newRect.y + newRect.width / this.elementRatio
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动元素整体
|
||||||
|
handleMoveElement(offsetX, offsetY) {
|
||||||
|
this.element.move(offsetX, offsetY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 旋转元素
|
||||||
|
handleRotateElement(e, mx, my) {
|
||||||
|
// 获取元素中心点
|
||||||
|
let centerPos = getElementCenterPoint(this.element)
|
||||||
|
// 获取鼠标移动的角度
|
||||||
|
let rotate = getTowPointRotate(
|
||||||
|
centerPos.x,
|
||||||
|
centerPos.y,
|
||||||
|
e.clientX,
|
||||||
|
e.clientY,
|
||||||
|
mx,
|
||||||
|
my
|
||||||
|
)
|
||||||
|
this.element.offsetRotate(rotate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 伸缩计算
|
||||||
|
stretchCalc(x, y, calcSize, calcPos) {
|
||||||
|
// 新的中心点
|
||||||
|
let newCenter = {
|
||||||
|
x: (x + this.diagonalPoint.x) / 2,
|
||||||
|
y: (y + this.diagonalPoint.y) / 2
|
||||||
|
}
|
||||||
|
// 获取当前鼠标位置经新的中心点反向旋转元素的角度后的坐标
|
||||||
|
let rp = transformPointReverseRotate(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
newCenter.x,
|
||||||
|
newCenter.y,
|
||||||
|
this.element.rotate
|
||||||
|
)
|
||||||
|
// 计算新尺寸
|
||||||
|
let newSize = calcSize(newCenter, rp)
|
||||||
|
// 判断是否翻转了,不允许翻转
|
||||||
|
let isWidthReverse = false
|
||||||
|
if (newSize.width < 0) {
|
||||||
|
newSize.width = 0
|
||||||
|
isWidthReverse = true
|
||||||
|
}
|
||||||
|
let isHeightReverse = false
|
||||||
|
if (newSize.height < 0) {
|
||||||
|
newSize.height = 0
|
||||||
|
isHeightReverse = true
|
||||||
|
}
|
||||||
|
// 计算新位置
|
||||||
|
let newPos = calcPos(rp, newSize)
|
||||||
|
let newRect = {
|
||||||
|
x: newPos.x,
|
||||||
|
y: newPos.y,
|
||||||
|
width: newSize.width,
|
||||||
|
height: newSize.height
|
||||||
|
}
|
||||||
|
// 如果翻转了,那么位置保持为上一次的位置
|
||||||
|
if (isWidthReverse || isHeightReverse) {
|
||||||
|
newRect.x = this.element.x
|
||||||
|
newRect.y = this.element.y
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
newRect,
|
||||||
|
newCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 伸缩元素
|
||||||
|
handleStretchElement(e, calcSize, calcPos, fixPos) {
|
||||||
|
let actClientX = e.clientX - this.mousedownPosAndElementPosOffset.x
|
||||||
|
let actClientY = e.clientY - this.mousedownPosAndElementPosOffset.y
|
||||||
|
let { newRect, newCenter } = this.stretchCalc(
|
||||||
|
actClientX,
|
||||||
|
actClientY,
|
||||||
|
calcSize,
|
||||||
|
calcPos
|
||||||
|
)
|
||||||
|
// 修正新图形
|
||||||
|
if (this.opts.lockRatio) {
|
||||||
|
this.fixStretch(newRect, newCenter, calcSize, calcPos, fixPos)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 更新尺寸位置信息
|
||||||
|
this.element.updateRect(newRect.x, newRect.y, newRect.width, newRect.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 锁定长宽比时修正新图形
|
||||||
|
fixStretch(newRect, newCenter, calcSize, calcPos, fixPos) {
|
||||||
|
let newRatio = newRect.width / newRect.height
|
||||||
|
let fp = fixPos(newRatio, newRect)
|
||||||
|
// 修正的点旋转图形的角度
|
||||||
|
let rp = getRotatedPoint(
|
||||||
|
fp.x,
|
||||||
|
fp.y,
|
||||||
|
newCenter.x,
|
||||||
|
newCenter.y,
|
||||||
|
this.element.rotate
|
||||||
|
)
|
||||||
|
let fixNewRect = this.stretchCalc(rp.x, rp.y, calcSize, calcPos).newRect
|
||||||
|
// 不知道为什么刚拖动时会有宽高计算为0的情况
|
||||||
|
if (fixNewRect.width === 0 && fixNewRect.height === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 更新尺寸位置信息
|
||||||
|
this.element.updateRect(
|
||||||
|
fixNewRect.x,
|
||||||
|
fixNewRect.y,
|
||||||
|
fixNewRect.width,
|
||||||
|
fixNewRect.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
import BaseMultiPointElement from './BaseMultiPointElement'
|
||||||
|
import { drawLineSegment, drawFreeLine } from '../utils/draw'
|
||||||
|
import DragElement from './DragElement'
|
||||||
|
import { transformPointOnElement, deepCopy, getBoundingRect } from '../utils'
|
||||||
|
import { checkIsAtFreedrawLineEdge } from '../utils/checkHit'
|
||||||
|
|
||||||
|
// 自由画笔元素类
|
||||||
|
export default class Freedraw extends BaseMultiPointElement {
|
||||||
|
constructor(...args) {
|
||||||
|
super(...args)
|
||||||
|
// 拖拽元素实例
|
||||||
|
this.dragElement = new DragElement(this, this.app)
|
||||||
|
// 点位[x,y,speed]第三个数字为线宽
|
||||||
|
// 上一次的线宽
|
||||||
|
this.lastLineWidth = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染到画布
|
||||||
|
render() {
|
||||||
|
let { pointArr } = this
|
||||||
|
this.warpRender(({ cx, cy }) => {
|
||||||
|
drawFreeLine(this.app.ctx, pointArr, {
|
||||||
|
app: this.app,
|
||||||
|
cx,
|
||||||
|
cy
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// 激活时显示拖拽框
|
||||||
|
this.renderDragElement()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否被击中
|
||||||
|
isHit(x, y) {
|
||||||
|
let rp = transformPointOnElement(x, y, this)
|
||||||
|
return checkIsAtFreedrawLineEdge(this, rp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制单条线段
|
||||||
|
singleRender(mx, my, tx, ty, lineWidth) {
|
||||||
|
this.app.ctx.save()
|
||||||
|
this.setStyle(this.style)
|
||||||
|
drawLineSegment(this.app.ctx, mx, my, tx, ty, lineWidth)
|
||||||
|
this.app.ctx.restore()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
import BaseElement from './BaseElement'
|
||||||
|
import { drawImage } from '../utils/draw'
|
||||||
|
import DragElement from './DragElement'
|
||||||
|
import { transformPointOnElement } from '../utils'
|
||||||
|
import { checkIsAtRectangleInner } from '../utils/checkHit'
|
||||||
|
|
||||||
|
// 图片元素类
|
||||||
|
export default class Image extends BaseElement {
|
||||||
|
constructor(opts = {}, app) {
|
||||||
|
super(opts, app)
|
||||||
|
// 拖拽元素实例
|
||||||
|
this.dragElement = new DragElement(this, this.app, {
|
||||||
|
lockRatio: true
|
||||||
|
})
|
||||||
|
this.url = opts.url || ''
|
||||||
|
this.imageObj = opts.imageObj || null
|
||||||
|
this.ratio = opts.ratio || 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 序列化
|
||||||
|
serialize() {
|
||||||
|
let base = super.serialize()
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
url: this.url,
|
||||||
|
ratio: this.ratio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染到画布
|
||||||
|
render() {
|
||||||
|
let { width, height } = this
|
||||||
|
this.warpRender(({ halfWidth, halfHeight }) => {
|
||||||
|
drawImage(this.app.ctx, this, -halfWidth, -halfHeight, width, height)
|
||||||
|
})
|
||||||
|
// 激活时显示拖拽框
|
||||||
|
this.renderDragElement()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否被击中
|
||||||
|
isHit(x, y) {
|
||||||
|
let rp = transformPointOnElement(x, y, this)
|
||||||
|
return checkIsAtRectangleInner(this, rp)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,50 @@
|
||||||
|
import BaseMultiPointElement from './BaseMultiPointElement'
|
||||||
|
import { drawLine } from '../utils/draw'
|
||||||
|
import DragElement from './DragElement'
|
||||||
|
import { transformPointOnElement } from '../utils'
|
||||||
|
import { checkIsAtLineEdge } from '../utils/checkHit'
|
||||||
|
|
||||||
|
// 线段/折线元素类
|
||||||
|
export default class Line extends BaseMultiPointElement {
|
||||||
|
constructor(opts = {}, app) {
|
||||||
|
super(opts, app)
|
||||||
|
// 拖拽元素实例
|
||||||
|
this.dragElement = new DragElement(this, this.app)
|
||||||
|
// 是否是单线段,否则为多根线段组成的折线
|
||||||
|
this.isSingle = opts.isSingle
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染到画布
|
||||||
|
render() {
|
||||||
|
let { pointArr, fictitiousPoint } = this
|
||||||
|
this.warpRender(({ cx, cy }) => {
|
||||||
|
// 加上鼠标当前实时位置
|
||||||
|
let realtimePoint = []
|
||||||
|
if (pointArr.length > 0 && this.isCreating) {
|
||||||
|
let { x: fx, y: fy } = this.app.coordinate.transform(
|
||||||
|
fictitiousPoint.x - cx,
|
||||||
|
fictitiousPoint.y - cy
|
||||||
|
)
|
||||||
|
realtimePoint = [[fx, fy]]
|
||||||
|
}
|
||||||
|
drawLine(
|
||||||
|
this.app.ctx,
|
||||||
|
pointArr
|
||||||
|
.map(point => {
|
||||||
|
// 屏幕坐标在左上角,画布坐标在中心,所以屏幕坐标要先转成画布坐标
|
||||||
|
let { x, y } = this.app.coordinate.transform(point[0], point[1])
|
||||||
|
return [x - cx, y - cy]
|
||||||
|
})
|
||||||
|
.concat(realtimePoint)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
// 激活时显示拖拽框
|
||||||
|
this.renderDragElement()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否被击中
|
||||||
|
isHit(x, y) {
|
||||||
|
let rp = transformPointOnElement(x, y, this)
|
||||||
|
return checkIsAtLineEdge(this, rp)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,109 @@
|
||||||
|
import BaseElement from './BaseElement'
|
||||||
|
import DragElement from './DragElement'
|
||||||
|
import {
|
||||||
|
getMultiElementRectInfo,
|
||||||
|
getElementCenterPoint,
|
||||||
|
getTowPointRotate
|
||||||
|
} from '../utils'
|
||||||
|
|
||||||
|
// 用于多选情况下的虚拟元素类
|
||||||
|
export default class MultiSelectElement extends BaseElement {
|
||||||
|
constructor(opts = {}, app) {
|
||||||
|
super(opts, app)
|
||||||
|
// 拖拽元素实例
|
||||||
|
this.dragElement = new DragElement(this, this.app)
|
||||||
|
// 被选中的元素集合
|
||||||
|
this.selectedElementList = []
|
||||||
|
// 被选中元素整体的中心点
|
||||||
|
this.wholeCenterPos = { x: 0, y: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置选中元素
|
||||||
|
setSelectedElementList(list) {
|
||||||
|
this.selectedElementList.forEach(element => {
|
||||||
|
element.isSelected = false
|
||||||
|
})
|
||||||
|
this.selectedElementList = list
|
||||||
|
this.selectedElementList.forEach(element => {
|
||||||
|
element.isSelected = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤掉被删除的元素
|
||||||
|
updateElements(elements) {
|
||||||
|
let exists = []
|
||||||
|
this.selectedElementList.forEach(element => {
|
||||||
|
if (elements.includes(element)) {
|
||||||
|
exists.push(element)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.setSelectedElementList(exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算大小和位置
|
||||||
|
updateRect() {
|
||||||
|
if (this.selectedElementList.length <= 0) {
|
||||||
|
super.updateRect(0, 0, 0, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let { minx, maxx, miny, maxy } = getMultiElementRectInfo(
|
||||||
|
this.selectedElementList
|
||||||
|
)
|
||||||
|
super.updateRect(minx, miny, maxx - minx, maxy - miny)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始调整
|
||||||
|
startResize(...args) {
|
||||||
|
this.selectedElementList.forEach(element => {
|
||||||
|
if (args[0] === 'rotate') {
|
||||||
|
// 计算多选元素整体中心点
|
||||||
|
this.wholeCenterPos = getElementCenterPoint(this)
|
||||||
|
}
|
||||||
|
element.startResize(...args)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调整中
|
||||||
|
resize(...args) {
|
||||||
|
this.selectedElementList.forEach(element => {
|
||||||
|
if (element.dragElement.resizeType === 'rotate') {
|
||||||
|
// 旋转操作特殊处理
|
||||||
|
this.handleRotate(element, ...args)
|
||||||
|
} else {
|
||||||
|
element.resize(...args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 旋转元素
|
||||||
|
handleRotate(element, e, mx, my, offsetX, offsetY) {
|
||||||
|
// 获取鼠标移动的角度
|
||||||
|
let rotate = getTowPointRotate(
|
||||||
|
this.wholeCenterPos.x,
|
||||||
|
this.wholeCenterPos.y,
|
||||||
|
e.clientX,
|
||||||
|
e.clientY,
|
||||||
|
mx,
|
||||||
|
my
|
||||||
|
)
|
||||||
|
element.rotateByCenter(rotate, this.wholeCenterPos.x, this.wholeCenterPos.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结束调整
|
||||||
|
endResize() {
|
||||||
|
this.selectedElementList.forEach(element => {
|
||||||
|
element.endResize()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染到画布
|
||||||
|
render() {
|
||||||
|
// 显示拖拽框
|
||||||
|
if (this.selectedElementList.length > 0) {
|
||||||
|
if (this.width <= 0 || this.height <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.dragElement.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
import BaseElement from './BaseElement'
|
||||||
|
import { drawRect } from '../utils/draw'
|
||||||
|
import DragElement from './DragElement'
|
||||||
|
import { transformPointOnElement } from '../utils'
|
||||||
|
import { checkIsAtRectangleEdge } from '../utils/checkHit'
|
||||||
|
|
||||||
|
// 矩形元素类
|
||||||
|
export default class Rectangle extends BaseElement {
|
||||||
|
constructor(...args) {
|
||||||
|
super(...args)
|
||||||
|
// 拖拽元素实例
|
||||||
|
this.dragElement = new DragElement(this, this.app)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染到画布
|
||||||
|
render() {
|
||||||
|
let { width, height } = this
|
||||||
|
this.warpRender(({ halfWidth, halfHeight }) => {
|
||||||
|
// 画布中心点修改了,所以元素的坐标也要相应修改
|
||||||
|
drawRect(this.app.ctx, -halfWidth, -halfHeight, width, height, true)
|
||||||
|
})
|
||||||
|
// 激活时显示拖拽框
|
||||||
|
this.renderDragElement()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否被击中
|
||||||
|
isHit(x, y) {
|
||||||
|
let rp = transformPointOnElement(x, y, this)
|
||||||
|
return checkIsAtRectangleEdge(this, rp)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
import BaseElement from './BaseElement'
|
||||||
|
import { drawText } from '../utils/draw'
|
||||||
|
import DragElement from './DragElement'
|
||||||
|
import {
|
||||||
|
transformPointOnElement,
|
||||||
|
splitTextLines,
|
||||||
|
getTextElementSize
|
||||||
|
} from '../utils'
|
||||||
|
import { checkIsAtRectangleInner } from '../utils/checkHit'
|
||||||
|
|
||||||
|
// 文本元素类
|
||||||
|
export default class Text extends BaseElement {
|
||||||
|
constructor(opts = {}, app) {
|
||||||
|
super(opts, app)
|
||||||
|
// 拖拽元素实例
|
||||||
|
this.dragElement = new DragElement(this, this.app, {
|
||||||
|
lockRatio: true
|
||||||
|
})
|
||||||
|
this.text = opts.text || ''
|
||||||
|
this.style.fillStyle =
|
||||||
|
opts.style?.fillStyle || this.app.state.strokeStyle || '#000'
|
||||||
|
this.style.fontSize = opts.style?.fontSize || this.app.state.fontSize || 18
|
||||||
|
this.style.lineHeightRatio = opts.style?.lineHeightRatio || 1.5
|
||||||
|
this.style.fontFamily =
|
||||||
|
opts.style?.fontFamily ||
|
||||||
|
this.app.state.fontFamily ||
|
||||||
|
'微软雅黑, Microsoft YaHei'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 序列化
|
||||||
|
serialize() {
|
||||||
|
let base = super.serialize()
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
text: this.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染到画布
|
||||||
|
render() {
|
||||||
|
let { width, height } = this
|
||||||
|
this.warpRender(({ halfWidth, halfHeight }) => {
|
||||||
|
// 画布中心点修改了,所以元素的坐标也要相应修改
|
||||||
|
drawText(this.app.ctx, this, -halfWidth, -halfHeight, width, height)
|
||||||
|
})
|
||||||
|
// 激活时显示拖拽框
|
||||||
|
this.renderDragElement()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否被击中
|
||||||
|
isHit(x, y) {
|
||||||
|
let rp = transformPointOnElement(x, y, this)
|
||||||
|
return checkIsAtRectangleInner(this, rp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新包围框
|
||||||
|
updateRect(x, y, width, height) {
|
||||||
|
let { text, style } = this
|
||||||
|
// 新字号 = 新高度 / 行数
|
||||||
|
let fontSize = Math.floor(
|
||||||
|
height / splitTextLines(text).length / style.lineHeightRatio
|
||||||
|
)
|
||||||
|
this.style.fontSize = fontSize
|
||||||
|
super.updateRect(x, y, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字号改不了更新尺寸
|
||||||
|
updateTextSize() {
|
||||||
|
let { width, height } = getTextElementSize(this)
|
||||||
|
this.width = width
|
||||||
|
this.height = height
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
import BaseElement from './BaseElement'
|
||||||
|
import { drawTriangle } from '../utils/draw'
|
||||||
|
import DragElement from './DragElement'
|
||||||
|
import {
|
||||||
|
transformPointOnElement,
|
||||||
|
getElementCenterPoint,
|
||||||
|
getRotatedPoint
|
||||||
|
} from '../utils'
|
||||||
|
import { checkIsAtTriangleEdge } from '../utils/checkHit'
|
||||||
|
|
||||||
|
// 三角形元素类
|
||||||
|
export default class Triangle extends BaseElement {
|
||||||
|
constructor(...args) {
|
||||||
|
super(...args)
|
||||||
|
// 拖拽元素实例
|
||||||
|
this.dragElement = new DragElement(this, this.app)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染到画布
|
||||||
|
render() {
|
||||||
|
let { width, height } = this
|
||||||
|
this.warpRender(({ halfWidth, halfHeight }) => {
|
||||||
|
// 画布中心点修改了,所以元素的坐标也要相应修改
|
||||||
|
drawTriangle(this.app.ctx, -halfWidth, -halfHeight, width, height, true)
|
||||||
|
})
|
||||||
|
// 激活时显示拖拽框
|
||||||
|
this.renderDragElement()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否被击中
|
||||||
|
isHit(x, y) {
|
||||||
|
let rp = transformPointOnElement(x, y, this)
|
||||||
|
return checkIsAtTriangleEdge(this, rp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取图形应用了旋转之后的端点列表
|
||||||
|
getEndpointList() {
|
||||||
|
let { x, y, width, height, rotate } = this
|
||||||
|
let points = [
|
||||||
|
[x + width / 2, y],
|
||||||
|
[x + width, y + height],
|
||||||
|
[x, y + height]
|
||||||
|
]
|
||||||
|
let center = getElementCenterPoint(this)
|
||||||
|
return points.map(point => {
|
||||||
|
return getRotatedPoint(point[0], point[1], center.x, center.y, rotate)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
import Arrow from './Arrow'
|
||||||
|
import BaseElement from './BaseElement'
|
||||||
|
import BaseMultiPointElement from './BaseMultiPointElement'
|
||||||
|
import Circle from './Circle'
|
||||||
|
import Diamond from './Diamond'
|
||||||
|
import DragElement from './DragElement'
|
||||||
|
import Freedraw from './Freedraw'
|
||||||
|
import Image from './Image'
|
||||||
|
import Line from './Line'
|
||||||
|
import MultiSelectElement from './MultiSelectElement'
|
||||||
|
import Rectangle from './Rectangle'
|
||||||
|
import Text from './Text'
|
||||||
|
import Triangle from './Triangle'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
Arrow,
|
||||||
|
BaseElement,
|
||||||
|
BaseMultiPointElement,
|
||||||
|
Circle,
|
||||||
|
Diamond,
|
||||||
|
DragElement,
|
||||||
|
Freedraw,
|
||||||
|
Image,
|
||||||
|
Line,
|
||||||
|
MultiSelectElement,
|
||||||
|
Rectangle,
|
||||||
|
Text,
|
||||||
|
Triangle
|
||||||
|
}
|
|
@ -0,0 +1,687 @@
|
||||||
|
import EventEmitter from 'eventemitter3'
|
||||||
|
import {
|
||||||
|
createCanvas,
|
||||||
|
getTowPointDistance,
|
||||||
|
throttle,
|
||||||
|
createImageObj
|
||||||
|
} from './utils'
|
||||||
|
import * as utils from './utils'
|
||||||
|
import * as checkHit from './utils/checkHit'
|
||||||
|
import * as draw from './utils/draw'
|
||||||
|
import Coordinate from './Coordinate'
|
||||||
|
import Event from './Event'
|
||||||
|
import Elements from './Elements'
|
||||||
|
import ImageEdit from './ImageEdit'
|
||||||
|
import Cursor from './Cursor'
|
||||||
|
import TextEdit from './TextEdit'
|
||||||
|
import History from './History'
|
||||||
|
import Export from './Export'
|
||||||
|
import Background from './Background'
|
||||||
|
import Selection from './Selection'
|
||||||
|
import Grid from './Grid'
|
||||||
|
import Mode from './Mode'
|
||||||
|
import KeyCommand from './KeyCommand'
|
||||||
|
import Render from './Render'
|
||||||
|
import elements from './elements/index'
|
||||||
|
import Group from './Group'
|
||||||
|
|
||||||
|
// 主类
|
||||||
|
class TinyWhiteboard extends EventEmitter {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super()
|
||||||
|
// 参数
|
||||||
|
this.opts = opts
|
||||||
|
// 容器元素
|
||||||
|
this.container = opts.container
|
||||||
|
// 当前绘制类型
|
||||||
|
this.drawType = opts.drawType || 'selection'
|
||||||
|
// 对容器做一些必要检查
|
||||||
|
if (!this.container) {
|
||||||
|
throw new Error('缺少 container 参数!')
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!['absolute', 'fixed', 'relative'].includes(
|
||||||
|
window.getComputedStyle(this.container).position
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error('container元素需要设置定位!')
|
||||||
|
}
|
||||||
|
// 当前设备标识 默认空
|
||||||
|
this.isMobile = null
|
||||||
|
// 容器宽高位置信息
|
||||||
|
this.width = 0
|
||||||
|
this.height = 0
|
||||||
|
this.left = 0
|
||||||
|
this.top = 0
|
||||||
|
// 主要的渲染canvas元素
|
||||||
|
this.canvas = null
|
||||||
|
// canvas绘制上下文
|
||||||
|
this.ctx = null
|
||||||
|
// 画布状态
|
||||||
|
this.state = {
|
||||||
|
scale: 1, // 缩放
|
||||||
|
scrollX: 0, // 水平方向的滚动偏移量
|
||||||
|
scrollY: 0, // 垂直方向的滚动偏移量
|
||||||
|
scrollStep: 50, // 滚动步长
|
||||||
|
backgroundColor: '', // 背景颜色
|
||||||
|
strokeStyle: '#000000', // 默认线条颜色
|
||||||
|
fillStyle: 'transparent', // 默认填充颜色
|
||||||
|
fontFamily: '微软雅黑, Microsoft YaHei', // 默认文字字体
|
||||||
|
fontSize: 18, // 默认文字字号
|
||||||
|
dragStrokeStyle: '#666', // 选中元素的拖拽元素的默认线条颜色
|
||||||
|
showGrid: false, // 是否显示网格
|
||||||
|
readonly: false, // 是否是只读模式
|
||||||
|
gridConfig: {
|
||||||
|
size: 20, // 网格大小
|
||||||
|
strokeStyle: '#dfe0e1', // 网格线条颜色
|
||||||
|
lineWidth: 1 // 网格线条宽度
|
||||||
|
},
|
||||||
|
...(opts.state || {})
|
||||||
|
}
|
||||||
|
// 判断当前设备 pc or mobile
|
||||||
|
this.initEquipment()
|
||||||
|
// 初始化画布
|
||||||
|
this.initCanvas()
|
||||||
|
// 坐标转换类
|
||||||
|
this.coordinate = new Coordinate(this)
|
||||||
|
// 事件类
|
||||||
|
this.event = new Event(this)
|
||||||
|
this.event.on('mousedown', this.onMousedown, this)
|
||||||
|
this.event.on('mousemove', this.onMousemove, this)
|
||||||
|
this.event.on('mouseup', this.onMouseup, this)
|
||||||
|
|
||||||
|
this.event.on('touchstart', this.onMousedown, this)
|
||||||
|
this.event.on('touchmove', this.onMousemove, this)
|
||||||
|
this.event.on('touchend', this.onMouseup, this)
|
||||||
|
|
||||||
|
this.event.on('dblclick', this.onDblclick, this)
|
||||||
|
this.event.on('mousewheel', this.onMousewheel, this)
|
||||||
|
this.event.on('contextmenu', this.onContextmenu, this)
|
||||||
|
// 快捷键类
|
||||||
|
this.keyCommand = new KeyCommand(this)
|
||||||
|
// 图片选择类
|
||||||
|
this.imageEdit = new ImageEdit(this)
|
||||||
|
this.imageEdit.on('imageSelectChange', this.onImageSelectChange, this)
|
||||||
|
this.imageEdit.on('imagePaste', this.onImagePaste, this)
|
||||||
|
|
||||||
|
// 文字编辑类
|
||||||
|
this.textEdit = new TextEdit(this)
|
||||||
|
this.textEdit.on('blur', this.onTextInputBlur, this)
|
||||||
|
// 鼠标样式类
|
||||||
|
this.cursor = new Cursor(this)
|
||||||
|
// 历史记录管理类
|
||||||
|
this.history = new History(this)
|
||||||
|
// 导入导出类
|
||||||
|
this.export = new Export(this)
|
||||||
|
// 背景设置类
|
||||||
|
this.background = new Background(this)
|
||||||
|
// 多选类
|
||||||
|
this.selection = new Selection(this)
|
||||||
|
// 编组类
|
||||||
|
this.group = new Group(this)
|
||||||
|
// 网格类
|
||||||
|
this.grid = new Grid(this)
|
||||||
|
// 模式类
|
||||||
|
this.mode = new Mode(this)
|
||||||
|
// 元素管理类
|
||||||
|
this.elements = new Elements(this)
|
||||||
|
// 渲染类
|
||||||
|
this.render = new Render(this)
|
||||||
|
|
||||||
|
// 代理
|
||||||
|
this.proxy()
|
||||||
|
this.checkIsOnElement = throttle(this.checkIsOnElement, this)
|
||||||
|
|
||||||
|
this.emitChange()
|
||||||
|
this.helpUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 代理各个类的方法到实例上
|
||||||
|
proxy() {
|
||||||
|
// history类
|
||||||
|
;['undo', 'redo'].forEach(method => {
|
||||||
|
this[method] = this.history[method].bind(this.history)
|
||||||
|
})
|
||||||
|
// elements类
|
||||||
|
;[].forEach(method => {
|
||||||
|
this[method] = this.elements[method].bind(this.elements)
|
||||||
|
})
|
||||||
|
// 渲染类
|
||||||
|
;[
|
||||||
|
'deleteElement',
|
||||||
|
'setActiveElementStyle',
|
||||||
|
'setCurrentElementsStyle',
|
||||||
|
'cancelActiveElement',
|
||||||
|
'deleteActiveElement',
|
||||||
|
'deleteCurrentElements',
|
||||||
|
'empty',
|
||||||
|
'zoomIn',
|
||||||
|
'zoomOut',
|
||||||
|
'setZoom',
|
||||||
|
'scrollTo',
|
||||||
|
'scrollToCenter',
|
||||||
|
'copyPasteCurrentElements',
|
||||||
|
'setBackgroundColor',
|
||||||
|
'copyElement',
|
||||||
|
'copyCurrentElement',
|
||||||
|
'cutCurrentElement',
|
||||||
|
'pasteCurrentElement',
|
||||||
|
'updateActiveElementRotate',
|
||||||
|
'updateActiveElementSize',
|
||||||
|
'updateActiveElementPosition',
|
||||||
|
'moveBottomCurrentElement',
|
||||||
|
'moveTopCurrentElement',
|
||||||
|
'moveUpCurrentElement',
|
||||||
|
'moveDownCurrentElement',
|
||||||
|
'selectAll',
|
||||||
|
'fit'
|
||||||
|
].forEach(method => {
|
||||||
|
this[method] = this.render[method].bind(this.render)
|
||||||
|
})
|
||||||
|
// 导入导出类
|
||||||
|
;['exportImage', 'exportJson'].forEach(method => {
|
||||||
|
this[method] = this.export[method].bind(this.export)
|
||||||
|
})
|
||||||
|
// 多选类
|
||||||
|
;['setSelectedElementStyle'].forEach(method => {
|
||||||
|
this[method] = this.selection[method].bind(this.selection)
|
||||||
|
})
|
||||||
|
// 编组类
|
||||||
|
;['dogroup', 'ungroup'].forEach(method => {
|
||||||
|
this[method] = this.group[method].bind(this.group)
|
||||||
|
})
|
||||||
|
// 网格类
|
||||||
|
;['showGrid', 'hideGrid', 'updateGrid'].forEach(method => {
|
||||||
|
this[method] = this.grid[method].bind(this.grid)
|
||||||
|
})
|
||||||
|
// 模式类
|
||||||
|
;['setEditMode', 'setReadonlyMode'].forEach(method => {
|
||||||
|
this[method] = this.mode[method].bind(this.mode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取容器尺寸位置信息
|
||||||
|
getContainerRectInfo() {
|
||||||
|
let { width, height, left, top } = this.container.getBoundingClientRect()
|
||||||
|
this.width = width
|
||||||
|
this.height = height
|
||||||
|
this.left = left
|
||||||
|
this.top = top
|
||||||
|
}
|
||||||
|
|
||||||
|
// 必要的重新渲染
|
||||||
|
helpUpdate() {
|
||||||
|
// 设置背景
|
||||||
|
this.background.set()
|
||||||
|
// 设置网格
|
||||||
|
if (this.state.showGrid) {
|
||||||
|
this.grid.showGrid()
|
||||||
|
}
|
||||||
|
// 设置模式
|
||||||
|
if (this.state.readonly) {
|
||||||
|
this.setReadonlyMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置数据,包括状态数据及元素数据
|
||||||
|
async setData({ state = {}, elements = [] }, noEmitChange) {
|
||||||
|
this.state = state
|
||||||
|
// 图片需要预加载
|
||||||
|
for (let i = 0; i < elements.length; i++) {
|
||||||
|
if (elements[i].type === 'image') {
|
||||||
|
elements[i].imageObj = await createImageObj(elements[i].url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.helpUpdate()
|
||||||
|
this.elements.deleteAllElements().createElementsFromData(elements)
|
||||||
|
this.render.render()
|
||||||
|
if (!noEmitChange) {
|
||||||
|
this.emitChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备是PC 还是mobile
|
||||||
|
initEquipment() {
|
||||||
|
const isMobile =
|
||||||
|
/(iPhone|iPad|iPod|iOS|Android|Linux armv8l|Linux armv7l|Linux aarch64)/i.test(
|
||||||
|
navigator.userAgent
|
||||||
|
)
|
||||||
|
if (isMobile) {
|
||||||
|
this.isMobile = true
|
||||||
|
} else {
|
||||||
|
this.isMobile = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化画布
|
||||||
|
initCanvas() {
|
||||||
|
this.getContainerRectInfo()
|
||||||
|
// 删除旧的canvas元素
|
||||||
|
if (this.canvas) {
|
||||||
|
this.container.removeChild(this.canvas)
|
||||||
|
}
|
||||||
|
// 创建canvas元素
|
||||||
|
let { canvas, ctx } = createCanvas(this.width, this.height, {
|
||||||
|
className: 'main'
|
||||||
|
})
|
||||||
|
this.canvas = canvas
|
||||||
|
this.ctx = ctx
|
||||||
|
this.container.appendChild(this.canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 容器尺寸调整
|
||||||
|
resize() {
|
||||||
|
// 初始化canvas元素
|
||||||
|
this.initCanvas()
|
||||||
|
// 在新的画布上绘制元素
|
||||||
|
this.render.render()
|
||||||
|
// 多选画布重新初始化
|
||||||
|
this.selection.init()
|
||||||
|
// 网格画布重新初始化
|
||||||
|
this.grid.init()
|
||||||
|
// 重新判断是否渲染网格
|
||||||
|
this.grid.renderGrid()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新状态数据,只是更新状态数据,不会触发重新渲染,如有需要重新渲染或其他操作需要自行调用相关方法
|
||||||
|
updateState(data = {}) {
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
...data
|
||||||
|
}
|
||||||
|
this.emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新当前绘制类型
|
||||||
|
updateCurrentType(drawType) {
|
||||||
|
this.drawType = drawType
|
||||||
|
// 图形绘制类型
|
||||||
|
if (this.drawType === 'image') {
|
||||||
|
this.imageEdit.selectImage()
|
||||||
|
}
|
||||||
|
// 设置鼠标指针样式
|
||||||
|
// 开启橡皮擦模式
|
||||||
|
if (this.drawType === 'eraser') {
|
||||||
|
this.cursor.setEraser()
|
||||||
|
this.cancelActiveElement()
|
||||||
|
} else if (this.drawType !== 'selection' && this.drawType !== 'image') {
|
||||||
|
this.cursor.setCrosshair()
|
||||||
|
} else {
|
||||||
|
this.cursor.reset()
|
||||||
|
}
|
||||||
|
this.emit('currentTypeChange', this.drawType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取数据,包括状态数据及元素数据
|
||||||
|
getData() {
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
...this.state
|
||||||
|
},
|
||||||
|
elements: this.elements.serialize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片选择事件
|
||||||
|
onImageSelectChange() {
|
||||||
|
// this.cursor.hide()
|
||||||
|
let e = {
|
||||||
|
unGridClientX: this.width / 2,
|
||||||
|
unGridClientY: this.height / 2
|
||||||
|
}
|
||||||
|
this.elements.creatingImage(e, this.imageEdit.imageData)
|
||||||
|
this.completeCreateNewElement()
|
||||||
|
this.cursor.reset()
|
||||||
|
this.imageEdit.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 粘贴图片
|
||||||
|
onImagePaste() {
|
||||||
|
// this.imageEdit.moveEvent.
|
||||||
|
if (this.elements.activeElement) {
|
||||||
|
this.copyCurrentElement()
|
||||||
|
this.pasteCurrentElement()
|
||||||
|
} else {
|
||||||
|
this.elements.creatingImage(
|
||||||
|
this.imageEdit.moveEvent,
|
||||||
|
this.imageEdit.imageData
|
||||||
|
)
|
||||||
|
}
|
||||||
|
this.completeCreateNewElement()
|
||||||
|
this.cursor.reset()
|
||||||
|
this.imageEdit.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鼠标按下事件
|
||||||
|
onMousedown(e, event) {
|
||||||
|
if (this.state.readonly || this.mode.isDragMode) {
|
||||||
|
// 只读模式下即将进行整体拖动
|
||||||
|
this.mode.onStart()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.elements.isCreatingElement && !this.textEdit.isEditing) {
|
||||||
|
// 是否击中了某个元素
|
||||||
|
let hitElement = this.elements.checkIsHitElement(e)
|
||||||
|
if (this.isMobile) {
|
||||||
|
if (hitElement && hitElement.style.elReadonly) return
|
||||||
|
}
|
||||||
|
if (this.drawType === 'selection') {
|
||||||
|
// 当前是选择模式
|
||||||
|
// 当前存在激活元素
|
||||||
|
if (this.elements.hasActiveElement()) {
|
||||||
|
// 判断按下的位置是否是拖拽部位
|
||||||
|
let isResizing = this.elements.checkIsResize(
|
||||||
|
event.mousedownPos.unGridClientX,
|
||||||
|
event.mousedownPos.unGridClientY,
|
||||||
|
e
|
||||||
|
)
|
||||||
|
// 不在拖拽部位则将当前的激活元素替换成hitElement
|
||||||
|
if (!isResizing) {
|
||||||
|
this.elements.setActiveElement(hitElement)
|
||||||
|
this.render.render()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 当前没有激活元素
|
||||||
|
if (this.selection.hasSelection) {
|
||||||
|
// 当前存在多选元素,则判断按下的位置是否是多选元素的拖拽部位
|
||||||
|
let isResizing = this.selection.checkIsResize(
|
||||||
|
event.mousedownPos.unGridClientX,
|
||||||
|
event.mousedownPos.unGridClientY,
|
||||||
|
e
|
||||||
|
)
|
||||||
|
// 不在拖拽部位则复位多选,并将当前的激活元素替换成hitElement
|
||||||
|
if (!isResizing) {
|
||||||
|
this.selection.reset()
|
||||||
|
this.elements.setActiveElement(hitElement)
|
||||||
|
this.render.render()
|
||||||
|
}
|
||||||
|
} else if (hitElement) {
|
||||||
|
// 激活击中的元素
|
||||||
|
if (hitElement.hasGroup()) {
|
||||||
|
this.group.setSelection(hitElement)
|
||||||
|
this.onMousedown(e, event)
|
||||||
|
} else {
|
||||||
|
this.elements.setActiveElement(hitElement)
|
||||||
|
this.render.render()
|
||||||
|
this.onMousedown(e, event)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 上述条件都不符合则进行多选创建选区操作
|
||||||
|
this.selection.onMousedown(e, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (this.drawType === 'eraser') {
|
||||||
|
// 当前有击中元素
|
||||||
|
// 橡皮擦模式则删除该元素
|
||||||
|
this.deleteElement(hitElement)
|
||||||
|
} else if (this.drawType === 'text') {
|
||||||
|
this.selection.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鼠标移动事件
|
||||||
|
onMousemove(e, event) {
|
||||||
|
if (this.state.readonly || this.mode.isDragMode) {
|
||||||
|
if (event.isMousedown) {
|
||||||
|
// 只读模式下进行整体拖动
|
||||||
|
this.mode.onMove(e, event)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.imageEdit.moveEvent = e
|
||||||
|
|
||||||
|
// 鼠标按下状态
|
||||||
|
if (event.isMousedown) {
|
||||||
|
let mx = event.mousedownPos.x
|
||||||
|
let my = event.mousedownPos.y
|
||||||
|
let offsetX = Math.max(event.mouseOffset.x, 0)
|
||||||
|
let offsetY = Math.max(event.mouseOffset.y, 0)
|
||||||
|
// 选中模式
|
||||||
|
if (this.drawType === 'selection') {
|
||||||
|
if (this.selection.isResizing) {
|
||||||
|
// 多选调整元素中
|
||||||
|
this.selection.handleResize(
|
||||||
|
e,
|
||||||
|
mx,
|
||||||
|
my,
|
||||||
|
event.mouseOffset.x,
|
||||||
|
event.mouseOffset.y
|
||||||
|
)
|
||||||
|
} else if (this.selection.creatingSelection) {
|
||||||
|
// 多选创建选区中
|
||||||
|
this.selection.onMousemove(e, event)
|
||||||
|
} else {
|
||||||
|
// 检测是否是正常的激活元素的调整操作
|
||||||
|
this.elements.handleResize(
|
||||||
|
e,
|
||||||
|
mx,
|
||||||
|
my,
|
||||||
|
event.mouseOffset.x,
|
||||||
|
event.mouseOffset.y
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (['rectangle', 'diamond', 'triangle'].includes(this.drawType)) {
|
||||||
|
// 类矩形元素绘制模式
|
||||||
|
this.elements.creatingRectangleLikeElement(
|
||||||
|
this.drawType,
|
||||||
|
mx,
|
||||||
|
my,
|
||||||
|
offsetX,
|
||||||
|
offsetY
|
||||||
|
)
|
||||||
|
this.render.render()
|
||||||
|
} else if (this.drawType === 'circle') {
|
||||||
|
// 绘制圆形模式
|
||||||
|
this.elements.creatingCircle(mx, my, e)
|
||||||
|
this.render.render()
|
||||||
|
} else if (this.drawType === 'freedraw') {
|
||||||
|
// 自由画笔模式
|
||||||
|
this.elements.creatingFreedraw(e, event)
|
||||||
|
} else if (this.drawType === 'arrow') {
|
||||||
|
this.elements.creatingArrow(mx, my, e)
|
||||||
|
this.render.render()
|
||||||
|
} else if (this.drawType === 'line') {
|
||||||
|
if (getTowPointDistance(mx, my, e.clientX, e.clientY) > 3) {
|
||||||
|
this.elements.creatingLine(mx, my, e, true)
|
||||||
|
this.render.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 鼠标没有按下状态
|
||||||
|
// 图片放置中
|
||||||
|
if (this.imageEdit.isReady) {
|
||||||
|
this.cursor.reset()
|
||||||
|
// this.imageEdit.updatePreviewElPos(
|
||||||
|
// e.originEvent.clientX,
|
||||||
|
// e.originEvent.clientY
|
||||||
|
// )
|
||||||
|
} else if (this.drawType === 'selection') {
|
||||||
|
if (this.elements.hasActiveElement()) {
|
||||||
|
// 当前存在激活元素
|
||||||
|
// 检测是否划过激活元素的各个收缩手柄
|
||||||
|
let handData = ''
|
||||||
|
if (
|
||||||
|
(handData = this.elements.checkInResizeHand(
|
||||||
|
e.unGridClientX,
|
||||||
|
e.unGridClientY
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
this.cursor.setResize(handData.hand)
|
||||||
|
} else {
|
||||||
|
this.checkIsOnElement(e)
|
||||||
|
}
|
||||||
|
} else if (this.selection.hasSelection) {
|
||||||
|
// 多选中检测是否可进行调整元素
|
||||||
|
let hand = this.selection.checkInResizeHand(
|
||||||
|
e.unGridClientX,
|
||||||
|
e.unGridClientY
|
||||||
|
)
|
||||||
|
if (hand) {
|
||||||
|
this.cursor.setResize(hand)
|
||||||
|
} else {
|
||||||
|
this.checkIsOnElement(e)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 检测是否划过元素
|
||||||
|
this.checkIsOnElement(e)
|
||||||
|
}
|
||||||
|
} else if (this.drawType === 'line') {
|
||||||
|
// 线段绘制中
|
||||||
|
this.elements.creatingLine(null, null, e, false, true)
|
||||||
|
this.render.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否滑过元素
|
||||||
|
checkIsOnElement(e) {
|
||||||
|
let hitElement = this.elements.checkIsHitElement(e)
|
||||||
|
if (hitElement) {
|
||||||
|
this.cursor.setMove()
|
||||||
|
} else {
|
||||||
|
this.cursor.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复位当前类型到选择模式
|
||||||
|
resetCurrentType() {
|
||||||
|
if (this.drawType !== 'selection') {
|
||||||
|
this.drawType = 'selection'
|
||||||
|
this.emit('currentTypeChange', 'selection')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新元素完成
|
||||||
|
completeCreateNewElement() {
|
||||||
|
this.resetCurrentType()
|
||||||
|
this.elements.completeCreateElement()
|
||||||
|
this.render.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鼠标松开事件
|
||||||
|
onMouseup(e) {
|
||||||
|
if (this.state.readonly || this.mode.isDragMode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.drawType === 'text') {
|
||||||
|
// 文字编辑模式
|
||||||
|
if (!this.textEdit.isEditing) {
|
||||||
|
this.createTextElement(e)
|
||||||
|
this.resetCurrentType()
|
||||||
|
}
|
||||||
|
} else if (this.imageEdit.isReady) {
|
||||||
|
// 图片放置模式
|
||||||
|
// this.elements.creatingImage(e, this.imageEdit.imageData)
|
||||||
|
// this.completeCreateNewElement()
|
||||||
|
// this.cursor.reset()
|
||||||
|
// this.imageEdit.reset()
|
||||||
|
} else if (this.drawType === 'arrow') {
|
||||||
|
// 箭头绘制模式
|
||||||
|
this.elements.completeCreateArrow(e)
|
||||||
|
this.completeCreateNewElement()
|
||||||
|
} else if (this.drawType === 'line') {
|
||||||
|
this.elements.completeCreateLine(e, () => {
|
||||||
|
this.completeCreateNewElement()
|
||||||
|
})
|
||||||
|
this.render.render()
|
||||||
|
} else if (this.elements.isCreatingElement) {
|
||||||
|
// 正在创建元素中
|
||||||
|
if (this.drawType === 'freedraw') {
|
||||||
|
// 自由绘画模式可以连续绘制
|
||||||
|
this.elements.completeCreateElement()
|
||||||
|
this.elements.setActiveElement()
|
||||||
|
} else {
|
||||||
|
// 创建新元素完成
|
||||||
|
this.completeCreateNewElement()
|
||||||
|
}
|
||||||
|
} else if (this.elements.isResizing) {
|
||||||
|
// 调整元素操作结束
|
||||||
|
this.elements.endResize()
|
||||||
|
this.emitChange()
|
||||||
|
} else if (this.selection.creatingSelection) {
|
||||||
|
// 多选选区操作结束
|
||||||
|
this.selection.onMouseup(e)
|
||||||
|
} else if (this.selection.isResizing) {
|
||||||
|
// 多选元素调整结束
|
||||||
|
this.selection.endResize()
|
||||||
|
this.emitChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 双击事件
|
||||||
|
onDblclick(e) {
|
||||||
|
if (this.drawType === 'line') {
|
||||||
|
// 结束折线绘制
|
||||||
|
this.completeCreateNewElement()
|
||||||
|
} else {
|
||||||
|
// 是否击中了某个元素
|
||||||
|
let hitElement = this.elements.checkIsHitElement(e)
|
||||||
|
if (hitElement) {
|
||||||
|
// 编辑文字
|
||||||
|
if (hitElement.type === 'text') {
|
||||||
|
this.elements.editingText(hitElement)
|
||||||
|
this.render.render()
|
||||||
|
this.keyCommand.unBindEvent()
|
||||||
|
this.textEdit.showTextEdit()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 双击空白处新增文字
|
||||||
|
if (!this.textEdit.isEditing) {
|
||||||
|
this.createTextElement(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文本框失焦事件
|
||||||
|
onTextInputBlur() {
|
||||||
|
this.keyCommand.bindEvent()
|
||||||
|
this.elements.completeEditingText()
|
||||||
|
this.render.render()
|
||||||
|
this.emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建文本元素
|
||||||
|
createTextElement(e) {
|
||||||
|
this.elements.createElement({
|
||||||
|
type: 'text',
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY
|
||||||
|
})
|
||||||
|
this.keyCommand.unBindEvent()
|
||||||
|
this.textEdit.showTextEdit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鼠标滚动事件
|
||||||
|
onMousewheel(dir) {
|
||||||
|
let stepNum = this.state.scrollStep / this.state.scale
|
||||||
|
let step = dir === 'down' ? stepNum : -stepNum
|
||||||
|
this.scrollTo(this.state.scrollX, this.state.scrollY + step)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右键菜单事件
|
||||||
|
onContextmenu(e) {
|
||||||
|
let elements = []
|
||||||
|
if (this.elements.hasActiveElement()) {
|
||||||
|
elements = [this.elements.activeElement]
|
||||||
|
} else if (this.selection.hasSelectionElements()) {
|
||||||
|
elements = this.selection.getSelectionElements()
|
||||||
|
}
|
||||||
|
this.emit('contextmenu', e.originEvent, elements)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发更新事件
|
||||||
|
emitChange() {
|
||||||
|
let data = this.getData()
|
||||||
|
this.history.add(data)
|
||||||
|
this.emit('change', data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TinyWhiteboard.utils = utils
|
||||||
|
TinyWhiteboard.checkHit = checkHit
|
||||||
|
TinyWhiteboard.draw = draw
|
||||||
|
TinyWhiteboard.elements = elements
|
||||||
|
|
||||||
|
export default TinyWhiteboard
|
|
@ -0,0 +1,107 @@
|
||||||
|
import {
|
||||||
|
checkIsAtSegment,
|
||||||
|
getTowPointDistance,
|
||||||
|
checkPointIsInRectangle
|
||||||
|
} from './'
|
||||||
|
import { HIT_DISTANCE } from '../constants'
|
||||||
|
|
||||||
|
// 检测是否点击到折线上
|
||||||
|
export const checkIsAtMultiSegment = (segments, rp) => {
|
||||||
|
let res = false
|
||||||
|
segments.forEach(seg => {
|
||||||
|
if (res) return
|
||||||
|
if (checkIsAtSegment(rp.x, rp.y, ...seg, HIT_DISTANCE)) {
|
||||||
|
res = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否点击到矩形边缘
|
||||||
|
export const checkIsAtRectangleEdge = (element, rp) => {
|
||||||
|
let { x, y, width, height } = element
|
||||||
|
let segments = [
|
||||||
|
[x, y, x + width, y],
|
||||||
|
[x + width, y, x + width, y + height],
|
||||||
|
[x + width, y + height, x, y + height],
|
||||||
|
[x, y + height, x, y]
|
||||||
|
]
|
||||||
|
return checkIsAtMultiSegment(segments, rp) ? element : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否点击到矩形内部
|
||||||
|
export const checkIsAtRectangleInner = (element, rp) => {
|
||||||
|
return checkPointIsInRectangle(rp.x, rp.y, element) ? element : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据宽高计算圆的半径
|
||||||
|
export const getCircleRadius = (width, height) => {
|
||||||
|
return Math.min(Math.abs(width), Math.abs(height)) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否点击到圆的边缘
|
||||||
|
export const checkIsAtCircleEdge = (element, rp) => {
|
||||||
|
let { width, height, x, y } = element
|
||||||
|
let radius = getCircleRadius(width, height)
|
||||||
|
let dis = getTowPointDistance(rp.x, rp.y, x + radius, y + radius)
|
||||||
|
let onCircle = dis >= radius - HIT_DISTANCE && dis <= radius + HIT_DISTANCE
|
||||||
|
return onCircle ? element : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否点击到线段边缘
|
||||||
|
export const checkIsAtLineEdge = (element, rp) => {
|
||||||
|
let segments = []
|
||||||
|
let len = element.pointArr.length
|
||||||
|
let arr = element.pointArr
|
||||||
|
for (let i = 0; i < len - 1; i++) {
|
||||||
|
segments.push([...arr[i], ...arr[i + 1]])
|
||||||
|
}
|
||||||
|
return checkIsAtMultiSegment(segments, rp) ? element : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否点击到自由线段边缘
|
||||||
|
export const checkIsAtFreedrawLineEdge = (element, rp) => {
|
||||||
|
let res = null
|
||||||
|
element.pointArr.forEach(point => {
|
||||||
|
if (res) return
|
||||||
|
let dis = getTowPointDistance(rp.x, rp.y, point[0], point[1])
|
||||||
|
if (dis <= HIT_DISTANCE) {
|
||||||
|
res = element
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否点击到菱形边缘
|
||||||
|
export const checkIsAtDiamondEdge = (element, rp) => {
|
||||||
|
let { x, y, width, height } = element
|
||||||
|
let segments = [
|
||||||
|
[x + width / 2, y, x + width, y + height / 2],
|
||||||
|
[x + width, y + height / 2, x + width / 2, y + height],
|
||||||
|
[x + width / 2, y + height, x, y + height / 2],
|
||||||
|
[x, y + height / 2, x + width / 2, y]
|
||||||
|
]
|
||||||
|
return checkIsAtMultiSegment(segments, rp) ? element : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否点击到三角形边缘
|
||||||
|
export const checkIsAtTriangleEdge = (element, rp) => {
|
||||||
|
let { x, y, width, height } = element
|
||||||
|
let segments = [
|
||||||
|
[x + width / 2, y, x + width, y + height],
|
||||||
|
[x + width, y + height, x, y + height],
|
||||||
|
[x, y + height, x + width / 2, y]
|
||||||
|
]
|
||||||
|
return checkIsAtMultiSegment(segments, rp) ? element : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否点击到箭头边缘
|
||||||
|
export const checkIsAtArrowEdge = (element, rp) => {
|
||||||
|
let pointArr = element.pointArr
|
||||||
|
let x = pointArr[0][0]
|
||||||
|
let y = pointArr[0][1]
|
||||||
|
let tx = pointArr[pointArr.length - 1][0]
|
||||||
|
let ty = pointArr[pointArr.length - 1][1]
|
||||||
|
let segments = [[x, y, tx, ty]]
|
||||||
|
return checkIsAtMultiSegment(segments, rp) ? element : null
|
||||||
|
}
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { degToRad, radToDeg, getFontString, splitTextLines } from './'
|
||||||
|
|
||||||
|
// 图形绘制工具方法
|
||||||
|
|
||||||
|
// 绘制公共操作
|
||||||
|
export const drawWrap = (ctx, fn, fill = false) => {
|
||||||
|
ctx.beginPath()
|
||||||
|
fn()
|
||||||
|
ctx.stroke()
|
||||||
|
if (fill) {
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制矩形
|
||||||
|
export const drawRect = (ctx, x, y, width, height, fill = false) => {
|
||||||
|
drawWrap(ctx, () => {
|
||||||
|
ctx.rect(x, y, width, height)
|
||||||
|
if (fill) {
|
||||||
|
ctx.fillRect(x, y, width, height)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制菱形
|
||||||
|
export const drawDiamond = (ctx, x, y, width, height, fill = false) => {
|
||||||
|
drawWrap(
|
||||||
|
ctx,
|
||||||
|
() => {
|
||||||
|
ctx.moveTo(x + width / 2, y)
|
||||||
|
ctx.lineTo(x + width, y + height / 2)
|
||||||
|
ctx.lineTo(x + width / 2, y + height)
|
||||||
|
ctx.lineTo(x, y + height / 2)
|
||||||
|
ctx.closePath()
|
||||||
|
},
|
||||||
|
fill
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制三角形
|
||||||
|
export const drawTriangle = (ctx, x, y, width, height, fill = false) => {
|
||||||
|
drawWrap(
|
||||||
|
ctx,
|
||||||
|
() => {
|
||||||
|
ctx.moveTo(x + width / 2, y)
|
||||||
|
ctx.lineTo(x + width, y + height)
|
||||||
|
ctx.lineTo(x, y + height)
|
||||||
|
ctx.closePath()
|
||||||
|
},
|
||||||
|
fill
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制圆形
|
||||||
|
export const drawCircle = (ctx, x, y, r, fill = false) => {
|
||||||
|
drawWrap(
|
||||||
|
ctx,
|
||||||
|
() => {
|
||||||
|
ctx.arc(x, y, r, 0, 2 * Math.PI)
|
||||||
|
},
|
||||||
|
fill
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制折线
|
||||||
|
export const drawLine = (ctx, points) => {
|
||||||
|
drawWrap(ctx, () => {
|
||||||
|
let first = true
|
||||||
|
points.forEach(point => {
|
||||||
|
if (first) {
|
||||||
|
first = false
|
||||||
|
ctx.moveTo(point[0], point[1])
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(point[0], point[1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制箭头
|
||||||
|
export const drawArrow = (ctx, pointArr) => {
|
||||||
|
let x = pointArr[0][0]
|
||||||
|
let y = pointArr[0][1]
|
||||||
|
let tx = pointArr[pointArr.length - 1][0]
|
||||||
|
let ty = pointArr[pointArr.length - 1][1]
|
||||||
|
drawWrap(
|
||||||
|
ctx,
|
||||||
|
() => {
|
||||||
|
ctx.moveTo(x, y)
|
||||||
|
ctx.lineTo(tx, ty)
|
||||||
|
},
|
||||||
|
true
|
||||||
|
)
|
||||||
|
let l = 30
|
||||||
|
let deg = 30
|
||||||
|
let lineDeg = radToDeg(Math.atan2(ty - y, tx - x))
|
||||||
|
drawWrap(
|
||||||
|
ctx,
|
||||||
|
() => {
|
||||||
|
let plusDeg = deg - lineDeg
|
||||||
|
let _x = tx - l * Math.cos(degToRad(plusDeg))
|
||||||
|
let _y = ty + l * Math.sin(degToRad(plusDeg))
|
||||||
|
ctx.moveTo(_x, _y)
|
||||||
|
ctx.lineTo(tx, ty)
|
||||||
|
},
|
||||||
|
true
|
||||||
|
)
|
||||||
|
drawWrap(ctx, () => {
|
||||||
|
let plusDeg = 90 - lineDeg - deg
|
||||||
|
let _x = tx - l * Math.sin(degToRad(plusDeg))
|
||||||
|
let _y = ty - l * Math.cos(degToRad(plusDeg))
|
||||||
|
ctx.moveTo(_x, _y)
|
||||||
|
ctx.lineTo(tx, ty)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换自由线段的点
|
||||||
|
const transformFreeLinePoint = (point, opt) => {
|
||||||
|
// 屏幕坐标在左上角,画布坐标在中心,所以屏幕坐标要先转成画布坐标
|
||||||
|
let { x, y } = opt.app.coordinate.transform(point[0], point[1])
|
||||||
|
// 绘制前原点又由屏幕中心移动到了元素中心,所以还需要再转一次
|
||||||
|
return [x - opt.cx, y - opt.cy, ...point.slice(2)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制自由线段
|
||||||
|
export const drawFreeLine = (ctx, points, opt) => {
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
drawWrap(
|
||||||
|
ctx,
|
||||||
|
() => {
|
||||||
|
// 在这里转换可以减少一次额外的遍历
|
||||||
|
let point = transformFreeLinePoint(points[i], opt)
|
||||||
|
let nextPoint = transformFreeLinePoint(points[i + 1], opt)
|
||||||
|
drawLineSegment(
|
||||||
|
ctx,
|
||||||
|
point[0],
|
||||||
|
point[1],
|
||||||
|
nextPoint[0],
|
||||||
|
nextPoint[1],
|
||||||
|
nextPoint[2],
|
||||||
|
true
|
||||||
|
)
|
||||||
|
},
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制线段
|
||||||
|
export const drawLineSegment = (ctx, mx, my, tx, ty, lineWidth = 0) => {
|
||||||
|
drawWrap(ctx, () => {
|
||||||
|
if (lineWidth > 0) {
|
||||||
|
ctx.lineWidth = lineWidth
|
||||||
|
}
|
||||||
|
ctx.moveTo(mx, my)
|
||||||
|
ctx.lineTo(tx, ty)
|
||||||
|
ctx.lineCap = 'round'
|
||||||
|
ctx.lineJoin = 'round'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制文字
|
||||||
|
export const drawText = (ctx, textObj, x, y, width, height) => {
|
||||||
|
let { text, style } = textObj
|
||||||
|
let lineHeight = style.fontSize * style.lineHeightRatio
|
||||||
|
drawWrap(ctx, () => {
|
||||||
|
ctx.font = getFontString(style.fontSize, style.fontFamily)
|
||||||
|
ctx.textBaseline = 'middle'
|
||||||
|
let textArr = splitTextLines(text)
|
||||||
|
textArr.forEach((textRow, index) => {
|
||||||
|
ctx.fillText(textRow, x, y + (index * lineHeight + lineHeight / 2))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制图片
|
||||||
|
export const drawImage = (ctx, element, x, y, width, height) => {
|
||||||
|
drawWrap(ctx, () => {
|
||||||
|
let ratio = width / height
|
||||||
|
let showWidth = 0
|
||||||
|
let showHeight = 0
|
||||||
|
if (ratio > element.ratio) {
|
||||||
|
showHeight = height
|
||||||
|
showWidth = element.ratio * height
|
||||||
|
} else {
|
||||||
|
showWidth = width
|
||||||
|
showHeight = width / element.ratio
|
||||||
|
}
|
||||||
|
ctx.drawImage(element.imageObj, x, y, showWidth, showHeight)
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,432 @@
|
||||||
|
// 通用工具方法
|
||||||
|
|
||||||
|
// 创建canvas元素
|
||||||
|
export const createCanvas = (
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
opt = { noStyle: false, noTranslate: false, className: '' }
|
||||||
|
) => {
|
||||||
|
let canvas = document.createElement('canvas')
|
||||||
|
if (!opt.noStyle) {
|
||||||
|
canvas.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
`
|
||||||
|
}
|
||||||
|
if (opt.className) {
|
||||||
|
canvas.className = opt.className
|
||||||
|
}
|
||||||
|
// 获取绘图上下文
|
||||||
|
let ctx = canvas.getContext('2d')
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
// 画布原点移动到画布中心
|
||||||
|
if (!opt.noTranslate) {
|
||||||
|
ctx.translate(canvas.width / 2, canvas.height / 2)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
canvas,
|
||||||
|
ctx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算两点之间的距离
|
||||||
|
export const getTowPointDistance = (x1, y1, x2, y2) => {
|
||||||
|
return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算点到直线的距离
|
||||||
|
export const getPointToLineDistance = (x, y, x1, y1, x2, y2) => {
|
||||||
|
// 直线垂直于x轴
|
||||||
|
if (x1 === x2) {
|
||||||
|
return Math.abs(x - x1)
|
||||||
|
} else {
|
||||||
|
let B = 1
|
||||||
|
let A, C
|
||||||
|
A = (y1 - y2) / (x2 - x1)
|
||||||
|
C = 0 - B * y1 - A * x1
|
||||||
|
return Math.abs((A * x + B * y + C) / Math.sqrt(A * A + B * B))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否点击到了一条线段
|
||||||
|
export const checkIsAtSegment = (x, y, x1, y1, x2, y2, dis = 10) => {
|
||||||
|
if (getPointToLineDistance(x, y, x1, y1, x2, y2) > dis) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let dis1 = getTowPointDistance(x, y, x1, y1)
|
||||||
|
let dis2 = getTowPointDistance(x, y, x2, y2)
|
||||||
|
let dis3 = getTowPointDistance(x1, y1, x2, y2)
|
||||||
|
let max = Math.sqrt(dis * dis + dis3 * dis3)
|
||||||
|
if (dis1 <= max && dis2 <= max) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弧度转角度
|
||||||
|
export const radToDeg = rad => {
|
||||||
|
return rad * (180 / Math.PI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 角度转弧度
|
||||||
|
export const degToRad = deg => {
|
||||||
|
return deg * (Math.PI / 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算中心点相同的两个坐标相差的角度
|
||||||
|
export const getTowPointRotate = (cx, cy, tx, ty, fx, fy) => {
|
||||||
|
return radToDeg(Math.atan2(ty - cy, tx - cx) - Math.atan2(fy - cy, fx - cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取坐标经指定中心点旋转指定角度的坐标,顺时针还是逆时针rotate传正负即可
|
||||||
|
export const getRotatedPoint = (x, y, cx, cy, rotate) => {
|
||||||
|
let deg = radToDeg(Math.atan2(y - cy, x - cx))
|
||||||
|
let del = deg + rotate
|
||||||
|
let dis = getTowPointDistance(x, y, cx, cy)
|
||||||
|
return {
|
||||||
|
x: Math.cos(degToRad(del)) * dis + cx,
|
||||||
|
y: Math.sin(degToRad(del)) * dis + cy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取元素的中心点坐标
|
||||||
|
export const getElementCenterPoint = element => {
|
||||||
|
let { x, y, width, height } = element
|
||||||
|
return {
|
||||||
|
x: x + width / 2,
|
||||||
|
y: y + height / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 以指定中心点反向旋转坐标指定角度
|
||||||
|
export const transformPointReverseRotate = (x, y, cx, cy, rotate) => {
|
||||||
|
if (rotate !== 0) {
|
||||||
|
let rp = getRotatedPoint(x, y, cx, cy, -rotate)
|
||||||
|
x = rp.x
|
||||||
|
y = rp.y
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据元素是否旋转了处理鼠标坐标,如果元素旋转了,那么鼠标坐标要反向旋转回去
|
||||||
|
export const transformPointOnElement = (x, y, element) => {
|
||||||
|
let center = getElementCenterPoint(element)
|
||||||
|
return transformPointReverseRotate(x, y, center.x, center.y, element.rotate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取元素的四个角坐标
|
||||||
|
export const getElementCornerPoint = (element, dir) => {
|
||||||
|
let { x, y, width, height } = element
|
||||||
|
switch (dir) {
|
||||||
|
case 'topLeft':
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
}
|
||||||
|
case 'topRight':
|
||||||
|
return {
|
||||||
|
x: x + width,
|
||||||
|
y
|
||||||
|
}
|
||||||
|
case 'bottomRight':
|
||||||
|
return {
|
||||||
|
x: x + width,
|
||||||
|
y: y + height
|
||||||
|
}
|
||||||
|
case 'bottomLeft':
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y: y + height
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取元素旋转后的四个角坐标
|
||||||
|
export const getElementRotatedCornerPoint = (element, dir) => {
|
||||||
|
let center = getElementCenterPoint(element)
|
||||||
|
let dirPos = getElementCornerPoint(element, dir)
|
||||||
|
return getRotatedPoint(dirPos.x, dirPos.y, center.x, center.y, element.rotate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断一个坐标是否在一个矩形内
|
||||||
|
// 第三个参数可以直接传一个带有x、y、width、height的元素对象
|
||||||
|
export const checkPointIsInRectangle = (x, y, rx, ry, rw, rh) => {
|
||||||
|
if (typeof rx === 'object') {
|
||||||
|
let element = rx
|
||||||
|
rx = element.x
|
||||||
|
ry = element.y
|
||||||
|
rw = element.width
|
||||||
|
rh = element.height
|
||||||
|
}
|
||||||
|
return x >= rx && x <= rx + rw && y >= ry && y <= ry + rh
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取多个点的外包围框
|
||||||
|
export const getBoundingRect = (pointArr = [], returnCorners = false) => {
|
||||||
|
let minX = Infinity
|
||||||
|
let maxX = -Infinity
|
||||||
|
let minY = Infinity
|
||||||
|
let maxY = -Infinity
|
||||||
|
pointArr.forEach(point => {
|
||||||
|
let [x, y] = point
|
||||||
|
if (x < minX) {
|
||||||
|
minX = x
|
||||||
|
}
|
||||||
|
if (x > maxX) {
|
||||||
|
maxX = x
|
||||||
|
}
|
||||||
|
if (y < minY) {
|
||||||
|
minY = y
|
||||||
|
}
|
||||||
|
if (y > maxY) {
|
||||||
|
maxY = y
|
||||||
|
}
|
||||||
|
})
|
||||||
|
let x = minX
|
||||||
|
let y = minY
|
||||||
|
let width = maxX - minX
|
||||||
|
let height = maxY - minY
|
||||||
|
// 以四个角坐标方式返回
|
||||||
|
if (returnCorners) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: x + width,
|
||||||
|
y
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: x + width,
|
||||||
|
y: y + height
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x,
|
||||||
|
y: y + height
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单深拷贝
|
||||||
|
export const deepCopy = obj => {
|
||||||
|
return JSON.parse(JSON.stringify(obj))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拼接文字字体字号字符串
|
||||||
|
export const getFontString = (fontSize, fontFamily) => {
|
||||||
|
return `${fontSize}px ${fontFamily}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文本切割成行
|
||||||
|
export const splitTextLines = text => {
|
||||||
|
return text.replace(/\r\n?/g, '\n').split('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算文本的实际渲染宽度
|
||||||
|
let textCheckEl = null
|
||||||
|
export const getTextActWidth = (text, style) => {
|
||||||
|
if (!textCheckEl) {
|
||||||
|
textCheckEl = document.createElement('div')
|
||||||
|
textCheckEl.style.position = 'fixed'
|
||||||
|
textCheckEl.style.left = '-99999px'
|
||||||
|
document.body.appendChild(textCheckEl)
|
||||||
|
}
|
||||||
|
let { fontSize, fontFamily } = style
|
||||||
|
textCheckEl.innerText = text
|
||||||
|
textCheckEl.style.fontSize = fontSize + 'px'
|
||||||
|
textCheckEl.style.fontFamily = fontFamily
|
||||||
|
let { width } = textCheckEl.getBoundingClientRect()
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算固定宽度内能放下所有文字的最大字号
|
||||||
|
export const getMaxFontSizeInWidth = (text, width, style) => {
|
||||||
|
let fontSize = 12
|
||||||
|
while (
|
||||||
|
getTextActWidth(text, {
|
||||||
|
...style,
|
||||||
|
fontSize: fontSize + 1
|
||||||
|
}) < width
|
||||||
|
) {
|
||||||
|
fontSize++
|
||||||
|
}
|
||||||
|
return fontSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算换行文本的实际宽度
|
||||||
|
export const getWrapTextActWidth = element => {
|
||||||
|
let { text } = element
|
||||||
|
let textArr = splitTextLines(text)
|
||||||
|
let maxWidth = -Infinity
|
||||||
|
textArr.forEach(textRow => {
|
||||||
|
let width = getTextActWidth(textRow, element.style)
|
||||||
|
if (width > maxWidth) {
|
||||||
|
maxWidth = width
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return maxWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算换行文本的最长一行的文字数量
|
||||||
|
export const getWrapTextMaxRowTextNumber = text => {
|
||||||
|
let textArr = splitTextLines(text)
|
||||||
|
let maxNumber = -Infinity
|
||||||
|
textArr.forEach(textRow => {
|
||||||
|
if (textRow.length > maxNumber) {
|
||||||
|
maxNumber = textRow.length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return maxNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算一个文本元素的宽高
|
||||||
|
export const getTextElementSize = element => {
|
||||||
|
let { text, style } = element
|
||||||
|
let width = getWrapTextActWidth(element)
|
||||||
|
const lines = Math.max(splitTextLines(text).length, 1)
|
||||||
|
let lineHeight = style.fontSize * style.lineHeightRatio
|
||||||
|
let height = lines * lineHeight
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 节流函数
|
||||||
|
export const throttle = (fn, ctx, time = 100) => {
|
||||||
|
let timer = null
|
||||||
|
return (...args) => {
|
||||||
|
if (timer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
fn.call(ctx, ...args)
|
||||||
|
timer = null
|
||||||
|
}, time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据速度计算画笔粗细
|
||||||
|
export const computedLineWidthBySpeed = (
|
||||||
|
speed,
|
||||||
|
lastLineWidth,
|
||||||
|
baseLineWidth = 2
|
||||||
|
) => {
|
||||||
|
let lineWidth = 0
|
||||||
|
let maxLineWidth = baseLineWidth
|
||||||
|
let maxSpeed = 10
|
||||||
|
let minSpeed = 0.5
|
||||||
|
// 速度超快,那么直接使用最小的笔画
|
||||||
|
if (speed >= maxSpeed) {
|
||||||
|
lineWidth = baseLineWidth
|
||||||
|
} else if (speed <= minSpeed) {
|
||||||
|
// 速度超慢,那么直接使用最大的笔画
|
||||||
|
lineWidth = maxLineWidth + 1
|
||||||
|
} else {
|
||||||
|
// 中间速度,那么根据速度的比例来计算
|
||||||
|
lineWidth =
|
||||||
|
maxLineWidth - ((speed - minSpeed) / (maxSpeed - minSpeed)) * maxLineWidth
|
||||||
|
}
|
||||||
|
if (lastLineWidth === -1) {
|
||||||
|
lastLineWidth = maxLineWidth
|
||||||
|
}
|
||||||
|
// 最终的粗细为计算出来的一半加上上一次粗细的一半,防止两次粗细相差过大,出现明显突变
|
||||||
|
return lineWidth * (1 / 2) + lastLineWidth * (1 / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载文件
|
||||||
|
export const downloadFile = (file, fileName) => {
|
||||||
|
let a = document.createElement('a')
|
||||||
|
a.href = file
|
||||||
|
a.download = fileName
|
||||||
|
a.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取元素的四个角的坐标,应用了旋转之后的
|
||||||
|
export const getElementCorners = element => {
|
||||||
|
// 左上角
|
||||||
|
let topLeft = getElementRotatedCornerPoint(element, 'topLeft')
|
||||||
|
// 右上角
|
||||||
|
let topRight = getElementRotatedCornerPoint(element, 'topRight')
|
||||||
|
// 左下角
|
||||||
|
let bottomLeft = getElementRotatedCornerPoint(element, 'bottomLeft')
|
||||||
|
// 右下角
|
||||||
|
let bottomRight = getElementRotatedCornerPoint(element, 'bottomRight')
|
||||||
|
return [topLeft, topRight, bottomLeft, bottomRight]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取多个元素的最外层包围框信息
|
||||||
|
export const getMultiElementRectInfo = (elementList = []) => {
|
||||||
|
if (elementList.length <= 0) {
|
||||||
|
return {
|
||||||
|
minx: 0,
|
||||||
|
maxx: 0,
|
||||||
|
miny: 0,
|
||||||
|
maxy: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let minx = Infinity
|
||||||
|
let maxx = -Infinity
|
||||||
|
let miny = Infinity
|
||||||
|
let maxy = -Infinity
|
||||||
|
elementList.forEach(element => {
|
||||||
|
let pointList = element.getEndpointList()
|
||||||
|
pointList.forEach(({ x, y }) => {
|
||||||
|
if (x < minx) {
|
||||||
|
minx = x
|
||||||
|
}
|
||||||
|
if (x > maxx) {
|
||||||
|
maxx = x
|
||||||
|
}
|
||||||
|
if (y < miny) {
|
||||||
|
miny = y
|
||||||
|
}
|
||||||
|
if (y > maxy) {
|
||||||
|
maxy = y
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
minx,
|
||||||
|
maxx,
|
||||||
|
miny,
|
||||||
|
maxy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建图片对象
|
||||||
|
export const createImageObj = url => {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let img = new Image()
|
||||||
|
img.setAttribute('crossOrigin', 'anonymous')
|
||||||
|
img.onload = () => {
|
||||||
|
resolve(img)
|
||||||
|
}
|
||||||
|
img.onerror = () => {
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
img.src = url
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 元素的唯一key
|
||||||
|
let nodeKeyIndex = 0
|
||||||
|
export const createNodeKey = () => {
|
||||||
|
return nodeKeyIndex++
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,71 @@
|
||||||
|
const map = {
|
||||||
|
Backspace: 8,
|
||||||
|
Tab: 9,
|
||||||
|
Enter: 13,
|
||||||
|
|
||||||
|
Shift: 16,
|
||||||
|
Control: 17,
|
||||||
|
Alt: 18,
|
||||||
|
CapsLock: 20,
|
||||||
|
|
||||||
|
Esc: 27,
|
||||||
|
|
||||||
|
Space: 32,
|
||||||
|
|
||||||
|
PageUp: 33,
|
||||||
|
PageDown: 34,
|
||||||
|
End: 35,
|
||||||
|
Home: 36,
|
||||||
|
|
||||||
|
Insert: 45,
|
||||||
|
|
||||||
|
Left: 37,
|
||||||
|
Up: 38,
|
||||||
|
Right: 39,
|
||||||
|
Down: 40,
|
||||||
|
|
||||||
|
Del: 46,
|
||||||
|
|
||||||
|
NumLock: 144,
|
||||||
|
|
||||||
|
Cmd: 91,
|
||||||
|
CmdFF: 224,
|
||||||
|
F1: 112,
|
||||||
|
F2: 113,
|
||||||
|
F3: 114,
|
||||||
|
F4: 115,
|
||||||
|
F5: 116,
|
||||||
|
F6: 117,
|
||||||
|
F7: 118,
|
||||||
|
F8: 119,
|
||||||
|
F9: 120,
|
||||||
|
F10: 121,
|
||||||
|
F11: 122,
|
||||||
|
F12: 123,
|
||||||
|
|
||||||
|
'`': 192,
|
||||||
|
'=': 187,
|
||||||
|
'+': 187,
|
||||||
|
'-': 189,
|
||||||
|
"'": 222,
|
||||||
|
|
||||||
|
'/': 191,
|
||||||
|
'.': 190
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数字
|
||||||
|
for (let i = 0; i <= 9; i++) {
|
||||||
|
map[i] = i + 48
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字母
|
||||||
|
'abcdefghijklmnopqrstuvwxyz'.split('').forEach((n, index) => {
|
||||||
|
map[n] = index + 65
|
||||||
|
})
|
||||||
|
|
||||||
|
export const keyMap = map
|
||||||
|
|
||||||
|
export const isKey = (e, key) => {
|
||||||
|
let code = typeof e === 'object' ? e.keyCode : e
|
||||||
|
return map[key] === code
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
target: 'es2015',
|
||||||
|
lib: {
|
||||||
|
entry: path.resolve(__dirname, './src/index.js'),
|
||||||
|
formats: ['es', 'cjs']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/*
|
||||||
|
"main": "./dist/tiny-whiteboard.umd.js",
|
||||||
|
"module": "./dist/tiny-whiteboard.es.js",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/tiny-whiteboard.es.js",
|
||||||
|
"require": "./dist/tiny-whiteboard.umd.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
Loading…
Reference in New Issue