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