This commit is contained in:
lyc 2024-06-27 16:48:09 +08:00
commit a948b80f4d
61 changed files with 13591 additions and 0 deletions

25
.gitignore vendored Normal file
View File

@ -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

4
.prettierignore Normal file
View File

@ -0,0 +1,4 @@
dist
node_modules
package-lock.json
package.json

5
.prettierrc Normal file
View File

@ -0,0 +1,5 @@
semi: false
singleQuote: true
printWidth: 80
trailingComma: 'none'
arrowParens: 'avoid'

1315
README.md Normal file

File diff suppressed because it is too large Load Diff

5
app/.prettierignore Normal file
View File

@ -0,0 +1,5 @@
dist
node_modules
package-lock.json
package.json
public

5
app/.prettierrc Normal file
View File

@ -0,0 +1,5 @@
semi: false
singleQuote: true
printWidth: 80
trailingComma: 'none'
arrowParens: 'avoid'

15
app/index.html Normal file
View File

@ -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>

2527
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
app/package.json Normal file
View File

@ -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"
}
}

BIN
app/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -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

View File

@ -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;
}

819
app/public/libs/jsonTree.js Normal file
View File

@ -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">&hellip;</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("&nbsp;&nbsp;&nbsp;&nbsp;");
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);
}
};
})();

1042
app/src/App.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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>

View File

@ -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>

120
app/src/constants.js Normal file
View File

@ -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
}
})

4
app/src/main.js Normal file
View File

@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

25
app/vite.config.js Normal file
View File

@ -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: ['..']
}
}
})

132
components/ColorPicker.vue Normal file
View File

@ -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>

218
components/Contextmenu.vue Normal file
View File

@ -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>

120
constants.js Normal file
View File

@ -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
}
})

1047
index.vue Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@ -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"
}
}

35
src/Background.js Normal file
View File

@ -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()
}
}

18
src/Canvas.js Normal file
View File

@ -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)
}
}

117
src/Coordinate.js Normal file
View File

@ -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)
}
}
}

72
src/Cursor.js Normal file
View File

@ -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')
}
}

482
src/Elements.js Normal file
View File

@ -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
}
}

214
src/Event.js Normal file
View File

@ -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)
}
}

159
src/Export.js Normal file
View File

@ -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()
}
}

155
src/Grid.js Normal file
View File

@ -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()
}
}
}

123
src/Group.js Normal file
View File

@ -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] || []
}
}

63
src/History.js Normal file
View File

@ -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)
}
}

169
src/ImageEdit.js Normal file
View File

@ -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);
})
}
}

147
src/KeyCommand.js Normal file
View 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]
}
}
})
}
}

69
src/Mode.js Normal file
View File

@ -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
}
}

385
src/Render.js Normal file
View File

@ -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)
}
}

317
src/Selection.js Normal file
View File

@ -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())
}
}

104
src/TextEdit.js Normal file
View File

@ -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
}
}

20
src/constants.js Normal file
View File

@ -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

48
src/elements/Arrow.js Normal file
View File

@ -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)
}
}

270
src/elements/BaseElement.js Normal file
View File

@ -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)
}
}

View File

@ -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
}
})
}
}

33
src/elements/Circle.js Normal file
View File

@ -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)
}
}

50
src/elements/Diamond.js Normal file
View File

@ -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)
})
}
}

529
src/elements/DragElement.js Normal file
View File

@ -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
)
}
}

45
src/elements/Freedraw.js Normal file
View File

@ -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()
}
}

45
src/elements/Image.js Normal file
View File

@ -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)
}
}

50
src/elements/Line.js Normal file
View File

@ -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)
}
}

View File

@ -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()
}
}
}

31
src/elements/Rectangle.js Normal file
View File

@ -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)
}
}

73
src/elements/Text.js Normal file
View File

@ -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
}
}

49
src/elements/Triangle.js Normal file
View File

@ -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)
})
}
}

29
src/elements/index.js Normal file
View File

@ -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
}

687
src/index.js Normal file
View File

@ -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

107
src/utils/checkHit.js Normal file
View File

@ -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
}

191
src/utils/draw.js Normal file
View File

@ -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)
})
}

432
src/utils/index.js Normal file
View File

@ -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++
}

71
src/utils/keyMap.js Normal file
View File

@ -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
}

24
vite.config.js Normal file
View File

@ -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"
}
}
*/