准备工作
项目目录与文件创建
首先,我们无需搭建项目的环境,我们还是直接用最简单的方式,也就是引入的方式来创建这个项目,这样也就方便了我们一边编写一边测试。创建一个空目录,命名为ColorPicker
,创建一个js
文件,即color-picker.js
,然后创建一个index.html
文件以及创建一个样式文件color-picker.css
。现在你应该可以看到你的项目目录是如下所示:
ColorPicker
│ index.html
│ color-picker.js
│ color-picker.css
在你的index.html
中,初始化html
文档结构,然后引入这个color-picker.js
文件,如下所示:
color-picker
做好这些准备工作之后,让我们继续下一步。
结构与布局
模块分析
我们通过如下一张图来分析我们要实现的模块,如下图所示:
正如上图所示,我们可以将一个颜色选择器拆分成多个模块,所以我们大致得到了一个结构如下:
- 颜色色块
- 颜色面板
- 色调柱
- 透明度柱
- 输入框
- 清空与确定按钮
- 预定义颜色元素列表
这样一来,我们可以清晰的看到整个颜色选择器都有哪些模块。我们目前只需要考虑开发出基本的模块功能,然后后续就在基础上开始进行扩展和完善。好的,让我们继续下一步,搭建页面的基本结构。
色块模块
通过分析,我们应该知道,色块分成两种情况,第一种就是有颜色值时,色块应该是一个背景色为该颜色值的左右箭头。就像如下图所示:
而无颜色值,我们的色块应该是如下图所示:
如此一来,我们就确定了色块的结构元素,如下:
×
这里我们肯定是通过一个颜色值来确定使用哪一个结构的,这个后续我们再说。我们现在就先确定色块的元素结构应该是如下这样呢。当然这里的类名也可以是自己随便自定义。
tips:我这里是为了有自己的特色,所以加了
ew-
前缀名。如果你自己使用自己自定义的类名,那么你后续编写样式和操作 DOM 元素的时候需要注意,要去更改。
还有注意×
它是HTML字符实体
,我们只需要知道它最终会显示为X
就行了,这里不会去细讲,欲了解更多 HTML 字符实体知识,可以前往HTML 字符实体
查看。
接下来,让我们完成色块的样式编写。我们先完成最外层的盒子元素。可以看到,最外层的它会有一个自定义的宽高,然后就是一个边框,其它的就没有什么了,这样一来,我们就知道了该编写什么样的CSS
代码。这里我们还是采用本身写好的样式。我们做个记录:
- 色块盒子的边框颜色为
#dcdee2
- 色块盒子的字体颜色为
#535353
- 色块盒子有
4px
的圆角 - 色块盒子有上下
4px
的内间距,7px
的左右内间距 - 色块盒子有
14px
的字体大小 - 色块盒子有
1.5
的行高,注意没有单位
tips:1.5 倍行高是一个相对值,它是根据浏览器设置的字体大小来决定的,例如浏览器字体大小为 16px,那么 1.5 倍行高就是 16px * 1.5 = 24px 的行高
看到以上几点要求,我们应该知道,我们要采用哪个CSS
属性来实现,脑海中要有一个清晰的认识。
.ew-color-picker-box {
/* 边框颜色为#dcdee2 */
border: 1px solid #dcdee2;
/* 边框有4px的圆角 */
border-radius: 4px;
/* 4px的上下内间距,7px的左右内间距 */
padding: 4px 7px;
}
最外层的盒子元素的样式,我们已经编写完成了,接下来,我们开始编写没有颜色值的时候的一个样式。实际上它和最外层的色块盒子样式差不多,唯一需要注意的就是,我们后续将通过js
来设置它的宽高以及行高了。因为它是动态改变的,不过这里我们可以先固定一个值,然后后续再做更改。
.ew-color-picker-box > .ew-color-box-no {
width: 40px;
height: 40px;
font-size: 20px;
line-height: 40px;
color: #5e535f;
border: 1px solid #e2dfe2;
border-radius: 2px;
}
接下来就是实现有颜色值的样式了,这个要有一点难度,难点在于我们如何去实现一个类似下拉框箭头一样的下箭头。我们通过分析页面结构元素,不难看出,实际上我们这里的下箭头很明显是通过两个元素来拼凑成的,也就是说一个元素只是一根旋转了 45deg 的横线,同样的道理,另一个元素无非是旋转的方向相反罢了。并且我们可以看到这两根横线是垂直水平居中的,这里,我们肯定很快就想到了弹性盒子布局,只需要两个属性就可以让元素垂直水平居中。即justify-content:center
与align-items:center
这两个属性。所以,经过这样一分析,我们这里的实现就不难了。
2D 坐标系
3D 坐标系
如下所示:
.ew-color-picker-box-arrow {
display: flex;
justify-content: center;
align-items: center;
width: 40px;
height: 40px;
margin: auto;
z-index: 3;
}
.ew-color-picker-box-arrow-left {
width: 12px;
height: 1px;
display: inline-block;
background-color: #fff;
position: relative;
transform: rotate(45deg);
}
.ew-color-picker-box-arrow-right {
width: 12px;
height: 1px;
display: inline-block;
background-color: #fff;
position: relative;
transform: rotate(-45deg);
right: 3px;
}
如此一来,色块模块的页面结构和样式就这样被我们完成了,让我们继续。
颜色面板
颜色面板也是整个颜色选择器中最难的部分,现在我们来分析一下结构。首先,我们可以看到,它有一个容器元素,这个容器元素有点阴影效果,背景色是白色。这里需要知道的一个知识点就是盒子模型,也就是box-sizing
属性,它有 2 个属性值:content-box,border-box
。事实上在实际开发中,我们用到最多的是border-box
。我们来看文档box-sizing。
通过文档描述,我们知道了这个属性的意思。那么这里这个颜色面板容器元素的盒子模型我们就需要注意了,在这里,它是标准盒子模型
,也就是我们只是单独包含内容的宽高就行了。因此,我们总结如下:
- 1px 的实线边框#ebeeff
- 盒子模型为标准盒子模型
- 阴影效果文档
- 7px 的内边距
- 5px 的圆角
tips:这里留一个悬念,为什么要使用标准盒子模型。
到此为止,我们的容器元素就分析完成了,接下来开始编写结构与样式。
.ew-color-picker {
min-width: 320px;
box-sizing: content-box;
border: 1px solid #ebeeff;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
border-radius: 5px;
z-index: 10;
padding: 7px;
text-align: left;
}
现在我们再来确定容器元素中都有哪些元素,首先是一个颜色面板,颜色面板又包含一个容器元素,我们可以看到,颜色面板很像是三种背景色叠加出来的效果,不用怀疑,大胆的说,是的没错,就是三种背景色叠加出来的,所以我们就需要一个容器元素,然后容器元素里面又包含 2 个面板元素,容器元素的背景色加上 2 个面板元素叠加出来就是这种效果。一个白色的背景加一个黑色的就能叠加看到我们想要的效果。
比如我们先来看看一个示例:
.panel {
width: 280px;
height: 180px;
position: relative;
border: 1px solid #fff;
background-color: rgb(255, 166, 0);
}
.panel > div.white-panel,
.panel > div.black-panel {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
}
.white-panel {
background: linear-gradient(90deg, #fff, rgba(255, 255, 255, 0));
}
.black-panel {
background: linear-gradient(0deg, #000, transparent);
}
这里可能又涉及到一个知识点,那就是渐变颜色,这里就不做细讲,感兴趣的可查看文档。
所以我们的结构应该是如下:
根据前面那个示例,我们很快就能写出这个颜色面板了,不过我们还少了一个,也就是在颜色面板区域之内的拖动元素,或者我们可以称之为游标元素。
.ew-color-picker-panel {
width: 280px;
height: 180px;
position: relative;
border: 1px solid #fff;
background-color: rgb(255, 166, 0);
cursor: pointer;
}
.ew-color-picker-panel > div.ew-color-picker-white-panel,
.ew-color-picker-panel > div.ew-color-picker-black-panel {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
}
.ew-color-picker-white-panel {
background: linear-gradient(90deg, #fff, rgba(255, 255, 255, 0));
}
.ew-color-picker-black-panel {
background: linear-gradient(0deg, #000, transparent);
}
好了,现在我可以回答之前那个留下的问题了,为什么要使用标准盒子模型而不是 IE 标准盒子模型。这是因为这里我们会通过 js 动态去计算游标元素拖动的距离,如果是 IE 标准盒子模型,则会考虑边框的大小以及间距的大小,这无疑给我们计算拖动距离增加了难度,所以为了简便化,我们使用的是标准盒子模型。
现在我们再来加上这个游标元素吧,因为它是在颜色面板内动态改变的,通常我们要让一个元素在父元素当中进行移动,那么我们很明显就想到了子元素使用绝对定位,父元素加一个除了静态定位static
以外的定位,通常我们用相对定位,这里也不例外。这也就是我们给.ew-color-picker-panel
添加一个相对定位position: relative;
的原因。
这里需要注意了,游标元素设置的宽高会影响我们后续计算,所以在这里设置的宽高是多少,后续计算就要将它的宽高考虑在内,这个到后面会细讲,现在,我们还是编写该元素的样式吧。
.ew-color-picker-panel-cursor {
width: 4px;
height: 4px;
border-radius: 50%;
position: absolute;
left: 100%;
top: 0;
transform: translate(-4px, -4px);
box-shadow: 0 0 0 3px #fff, inset 0 0 2px 2px rgb(0 0 0 / 40%),
/*等价于rgba(0,0,0,0.4)*/ 0 0 2px 3px rgb(0 0 0 / 50%); /*等价于rgba(0,0,0,0.5)*/
cursor: default;
}
游标元素,我们看起来就像是一个小圆圈,所以我们给的宽高不是很多,只有 4px,既然是圆,我们都知道可以使用border-radius
为50%
即可以将一个元素变成圆。接下来就是阴影部分,这样就实现了我们的小圆圈。当然我们不一定非要实现这样的效果,但是为了还原颜色选择器本身,也方便后续的计算,所以我们还是采用原本的样式。
色阶柱
接下来,我们来看一下色阶柱也就是色调柱的实现。看到这个图,我们应该可以很清晰的分出色阶柱包含了 2 个部分,第一个部分就是柱形部分,称之为 bar,第二个部分就是拖动滑块部分,称之为 thumb。然后我们外加一个容器元素用于包含色阶柱和透明柱,所以我们可以确定色阶柱的结构如下:
然后我们来确定样式的实现,首先整个色阶柱是垂直布局的,所以我们应该知道它就是有一个固定宽度,然后高度等价于颜色面板的矩形,它的背景色通过一种渐变色来实现,实际上就是红橙黄绿青蓝紫七种颜色的混合,也就类似彩虹。这每一种颜色都有不同的比例。其次我们还要知道滑块部分是需要动态拖动的。在这里我们可以想象得到色阶柱可以是水平或者垂直布局的,目前我们先实现垂直布局(为了区分给容器元素加一个类名 ew-is-vertical)。所以滑块的动态改变部分应该是 top 值。现在我们来看样式:
.ew-color-slider,
.ew-color-slider-bar {
position: relative;
}
.ew-color-slider.ew-is-vertical {
width: 28px;
height: 100%;
cursor: pointer;
float: right;
}
.ew-color-slider.ew-is-vertical .ew-color-slider-bar {
width: 12px;
height: 100%;
float: left;
margin-left: 3px;
background: linear-gradient(
180deg,
#f00 0,
#ff0 17%,
#0f0 33%,
#0ff 50%,
#00f 67%,
#f0f 83%,
#f00
);
}
.ew-color-slider-thumb {
background-color: #fff;
border-radius: 4px;
position: absolute;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
border: 1px solid #dcdee2;
left: 0;
top: 0;
box-sizing: border-box;
position: absolute;
}
到目前为止,我们色阶柱就算是实现了,接下来来看透明度柱的实现。
透明度柱
透明度柱的实现原理跟色阶柱很相似,首先我们可以看到透明度柱会有一个透明的背景,这个背景很显然是一个图片,其次它还会有一个背景色条,取决于当且色阶柱处于哪种色调,然后同样还是与色阶柱一样有一个滑块,同样也是有垂直布局和水平布局,改变 top 值。所以我们得到结构如下所示:
在这里,我们需要注意的一点就是背景色条的背景色是动态改变,这将在后面会讲到。背景色条,我们同样是通过线性渐变来实现的。让我们来看看样式吧:
.ew-alpha-slider-bar {
width: 12px;
height: 100%;
float: left;
position: relative;
}
.ew-alpha-slider-wrapper,
.ew-alpha-slider-bg {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
}
.ew-alpha-slider-bar.ew-is-vertical .ew-alpha-slider-bg {
/* 这里先暂时写死 */
background: linear-gradient(
to top,
rgba(255, 0, 0, 0) 0%,
rgba(255, 0, 0) 100%
);
}
.ew-alpha-slider-wrapper {
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==");
}
.ew-alpha-slider-thumb {
background-color: #fff;
border-radius: 4px;
position: absolute;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
border: 1px solid #dcdee2;
left: 0;
top: 0;
box-sizing: border-box;
position: absolute;
}
好了,到目前为止,我们的透明度柱也就实现了,接下来我们来看输入框的实现。
输入框与按钮
输入框比较简单,我想没什么好说的,这个输入框也可以自定义,它的结构无非就是如下:
它和清空与确定按钮元素排在一行,因此我们用一个容器元素来包裹它们,结构应该如下:
然后样式也没有什么好分析的,都是一些基础样式,我们继续编写代码。如下:
.ew-color-drop-container {
margin-top: 6px;
padding-top: 4px;
min-height: 28px;
border-top: 1px solid #cdcdcd;
position: relative;
}
.ew-color-input {
display: inline-block;
padding: 8px 12px;
border: 1px solid #e9ebee;
border-radius: 4px;
outline: none;
width: 160px;
height: 28px;
line-height: 28px;
border: 1px solid #dcdfe6;
padding: 0 5px;
-webkit-transition: border-color 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
transition: border-color 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
border-radius: 5px;
background-color: #fff;
}
.ew-color-drop-btn-group {
position: absolute;
right: 0;
top: 5px;
}
.ew-color-drop-btn {
padding: 5px;
font-size: 12px;
border-radius: 3px;
-webkit-transition: 0.1s;
transition: 0.1s;
font-weight: 500;
margin: 0;
white-space: nowrap;
color: #606266;
border: 1px solid #dcdfe6;
letter-spacing: 1px;
text-align: center;
cursor: pointer;
}
.ew-color-clear {
color: #4096ef;
border-color: transparent;
background-color: transparent;
padding-left: 0;
padding-right: 0;
}
.ew-color-clear:hover {
color: #66b1ff;
}
.ew-color-sure {
margin-left: 10px;
}
.ew-color-sure {
border-color: #4096ef;
color: #4096ef;
}
输入框和按钮我们就已经完成了,接下来我们再来看预定义颜色元素呢。
预定义颜色
预定义颜色元素实现起来也比较简单,就是一个容器元素,然后包含多个子元素,可能稍微难一点的就是子元素的样式我们分为四种情况,第一种就是默认的样式,第二种就是禁止点击的样式,除此之外,我们还加了一个颜色透明度之间的区别,然后最后就是选中样式。不多说,我们可以先写 4 个子元素来分别代表四种情况的样式。如下:
接下来,我们来看样式的实现:
.ew-pre-define-color-container {
width: 280px;
font-size: 12px;
margin-top: 8px;
}
.ew-pre-define-color-container::after {
content: "";
display: table;
height: 0;
visibility: hidden;
clear: both;
}
.ew-pre-define-color-container .ew-pre-define-color {
margin: 0 0 8px 8px;
width: 20px;
height: 20px;
border-radius: 4px;
border: 1px solid #9b979b;
cursor: pointer;
float: left;
}
.ew-pre-define-color-container .ew-pre-define-color:hover {
opacity: 0.8;
}
.ew-pre-define-color-active {
box-shadow: 0 0 3px 2px #409eff;
}
.ew-pre-define-color:nth-child(10n + 1) {
margin-left: 0;
}
.ew-pre-define-color.ew-has-alpha {
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==");
}
.ew-pre-define-color.ew-pre-define-color-disabled {
cursor: not-allowed;
}
样式和布局就到此结束了,接下来才是我们的重点,也就是实现颜色选择器的功能。
JavaScript
工具方法
首先用一个空对象来管理工具方法。如下:
const util = Object.create(null);
然后有如下方法:
const util = Object.create(null);
const _toString = Object.prototype.toString;
let addMethod = (instance, method, func) => {
instance.prototype[method] = func;
return instance;
};
["Number", "String", "Function", "Undefined", "Boolean"].forEach(
(type) => (util["is" + type] = (value) => typeof value === type.toLowerCase())
);
util.addMethod = addMethod;
["Object", "Array", "RegExp"].forEach(
(type) =>
(util["isDeep" + type] = (value) =>
_toString.call(value).slice(8, -1).toLowerCase() === type.toLowerCase())
);
util.isShallowObject = (value) =>
typeof value === "object" && !util.isNull(value);
util["ewObjToArray"] = (value) =>
util.isShallowObject(value) ? Array.prototype.slice.call(value) : value;
util.isNull = (value) => value === null;
util.ewAssign = function (target) {
if (util.isNull(target)) return;
const _ = Object(target);
for (let j = 1, len = arguments.length; j < len; j += 1) {
const source = arguments[j];
if (source) {
for (let key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
_[key] = source[key];
}
}
}
}
return _;
};
util.addClass = (el, className) => el.classList.add(className);
util.removeClass = (el, className) => el.classList.remove(className);
util.hasClass = (el, className) => {
let _hasClass = (value) =>
new RegExp(" " + el.className + " ").test(" " + value + " ");
if (util.isDeepArray(className)) {
return className.some((name) => _hasClass(name));
} else {
return _hasClass(className);
}
};
util["setCss"] = (el, prop, value) => el.style.setProperty(prop, value);
util.setSomeCss = (el, propValue = []) => {
if (propValue.length) {
propValue.forEach((p) => util.setCss(el, p.prop, p.value));
}
};
util.isDom = (el) =>
util.isShallowObject(HTMLElement)
? el instanceof HTMLElement
: (el &&
util.isShallowObject(el) &&
el.nodeType === 1 &&
util.isString(el.nodeName)) ||
el instanceof HTMLCollection ||
el instanceof NodeList;
util.ewError = (value) =>
console.error("[ewColorPicker warn]\n" + new Error(value));
util.ewWarn = (value) => console.warn("[ewColorPicker warn]\n" + value);
util.deepCloneObjByJSON = (obj) => JSON.parse(JSON.stringify(obj));
util.deepCloneObjByRecursion = function f(obj) {
if (!util.isShallowObject(obj)) return;
let cloneObj = util.isDeepArray(obj) ? [] : {};
for (let k in obj) {
cloneObj[k] = util.isShallowObject(obj[k]) ? f(obj[k]) : obj[k];
}
return cloneObj;
};
util.getCss = (el, prop) => window.getComputedStyle(el, null)[prop];
util.$ = (ident) => {
if (!ident) return null;
return document[
ident.indexOf("#") > -1 ? "querySelector" : "querySelectorAll"
](ident);
};
util["on"] = (element, type, handler, useCapture = false) => {
if (element && type && handler) {
element.addEventListener(type, handler, useCapture);
}
};
util["off"] = (element, type, handler, useCapture = false) => {
if (element && type && handler) {
element.removeEventListener(type, handler, useCapture);
}
};
util["getRect"] = (el) => el.getBoundingClientRect();
util["baseClickOutSide"] = (element, isUnbind = true, callback) => {
const mouseHandler = (event) => {
const rect = util.getRect(element);
const target = event.target;
if (!target) return;
const targetRect = util.getRect(target);
if (
targetRect.x >= rect.x &&
targetRect.y >= rect.y &&
targetRect.width <= rect.width &&
targetRect.height <= rect.height
)
return;
if (util.isFunction(callback)) callback();
if (isUnbind) {
// 延迟解除绑定
setTimeout(() => {
util.off(document, util.eventType[0], mouseHandler);
}, 0);
}
};
util.on(document, util.eventType[0], mouseHandler);
};
util["clickOutSide"] = (context, config, callback) => {
const mouseHandler = (event) => {
const rect = util.getRect(context.$Dom.picker);
let boxRect = null;
if (config.hasBox) {
boxRect = util.getRect(context.$Dom.box);
}
const target = event.target;
if (!target) return;
const targetRect = util.getRect(target);
// 利用rect来判断用户点击的地方是否在颜色选择器面板区域之内
if (config.hasBox) {
if (
targetRect.x >= rect.x &&
targetRect.y >= rect.y &&
targetRect.width <= rect.width
)
return;
// 如果点击的是盒子元素
if (
targetRect.x >= boxRect.x &&
targetRect.y >= boxRect.y &&
targetRect.width <= boxRect.width &&
targetRect.height <= boxRect.height
)
return;
callback();
} else {
if (
targetRect.x >= rect.x &&
targetRect.y >= rect.y &&
targetRect.width <= rect.width &&
targetRect.height <= rect.height
)
return;
callback();
}
setTimeout(() => {
util.off(document, util.eventType[0], mouseHandler);
}, 0);
};
util.on(document, util.eventType[0], mouseHandler);
};
util["createUUID"] = () =>
(Math.random() * 10000000).toString(16).substr(0, 4) +
"-" +
new Date().getTime() +
"-" +
Math.random().toString().substr(2, 5);
util.removeAllSpace = (value) => value.replace(/\s+/g, "");
util.isJQDom = (dom) =>
typeof window.jQuery !== "undefined" && dom instanceof jQuery;
//the event
util.eventType = navigator.userAgent.match(/(iPhone|iPod|Android|ios)/i)
? ["touchstart", "touchmove", "touchend"]
: ["mousedown", "mousemove", "mouseup"];
动画函数的封装
const animation = {};
function TimerManager() {
this.timers = [];
this.args = [];
this.isTimerRun = false;
}
TimerManager.makeTimerManage = function (element) {
const elementTimerManage = element.TimerManage;
if (!elementTimerManage || elementTimerManage.constructor !== TimerManager) {
element.TimerManage = new TimerManager();
}
};
const methods = [
{
method: "add",
func: function (timer, args) {
this.timers.push(timer);
this.args.push(args);
this.timerRun();
},
},
{
method: "timerRun",
func: function () {
if (!this.isTimerRun) {
let timer = this.timers.shift(),
args = this.args.shift();
if (timer && args) {
this.isTimerRun = true;
timer(args[0], args[1]);
}
}
},
},
{
method: "next",
func: function () {
this.isTimerRun = false;
this.timerRun();
},
},
];
methods.forEach((method) =>
util.addMethod(TimerManager, method.method, method.func)
);
function runNext(element) {
const elementTimerManage = element.TimerManage;
if (elementTimerManage && elementTimerManage.constructor === TimerManager) {
elementTimerManage.next();
}
}
function registerMethods(type, element, time) {
let transition = "";
if (type.indexOf("slide") > -1) {
transition = "height" + time + " ms";
util.setCss(element, "overflow", "hidden");
upAndDown();
} else {
transition = "opacity" + time + " ms";
inAndOut();
}
util.setCss(element, "transition", transition);
function upAndDown() {
const isDown = type.toLowerCase().indexOf("down") > -1;
if (isDown) util.setCss(element, "display", "block");
const getPropValue = function (item, prop) {
let v = util.getCss(item, prop);
return util.removeAllSpace(v).length ? parseInt(v) : Number(v);
};
const elementChildHeight = [].reduce.call(
element.children,
(res, item) => {
res +=
item.offsetHeight +
getPropValue(item, "margin-top") +
getPropValue(item, "margin-bottom");
return res;
},
0
);
let totalHeight = Math.max(element.offsetHeight, elementChildHeight + 10);
let currentHeight = isDown ? 0 : totalHeight;
let unit = totalHeight / (time / 10);
if (isDown) util.setCss(element, "height", "0px");
let timer = setInterval(() => {
currentHeight = isDown ? currentHeight + unit : currentHeight - unit;
util.setCss(element, "height", currentHeight + "px");
if (currentHeight >= totalHeight || currentHeight <= 0) {
clearInterval(timer);
util.setCss(element, "height", totalHeight + "px");
runNext(element);
}
if (!isDown && currentHeight <= 0) {
util.setCss(element, "display", "none");
util.setCss(element, "height", "0");
}
}, 10);
}
function inAndOut() {
const isIn = type.toLowerCase().indexOf("in") > -1;
let timer = null;
let unit = (1 * 100) / (time / 10);
let curAlpha = isIn ? 0 : 100;
util.setSomeCss(element, [
{
prop: "display",
value: isIn ? "none" : "block",
},
{
prop: "opacity",
value: isIn ? 0 : 1,
},
]);
let handleFade = function () {
curAlpha = isIn ? curAlpha + unit : curAlpha - unit;
if (element.style.display === "none" && isIn)
util.setCss(element, "display", "block");
util.setCss(element, "opacity", (curAlpha / 100).toFixed(2));
if (curAlpha >= 100 || curAlpha <= 0) {
if (timer) clearTimeout(timer);
runNext(element);
if (curAlpha <= 0) util.setCss(element, "display", "none");
util.setCss(element, "opacity", curAlpha >= 100 ? 1 : 0);
} else {
timer = setTimeout(handleFade, 10);
}
};
handleFade();
}
}
["slideUp", "slideDown", "fadeIn", "fadeOut"].forEach((method) => {
animation[method] = function (element) {
TimerManager.makeTimerManage(element);
element.TimerManage.add(function (element, time) {
return registerMethods(method, element, time);
}, arguments);
};
});
一些颜色操作的算法
const colorRegExp = /^#([0-9a-fA-f]{3}|[0-9a-fA-f]{6})$/;
// RGB color
const colorRegRGB =
/[rR][gG][Bb][Aa]?[\(]([\s]*(2[0-4][0-9]|25[0-5]|[01]?[0-9][0-9]?),){2}[\s]*(2[0-4][0-9]|25[0-5]|[01]?[0-9][0-9]?),?[\s]*(0\.\d{1,2}|1|0)?[\)]{1}/g;
// RGBA color
const colorRegRGBA =
/^[rR][gG][Bb][Aa][\(]([\\s]*(2[0-4][0-9]|25[0-5]|[01]?[0-9][0-9]?)[\\s]*,){3}[\\s]*(1|1.0|0|0?.[0-9]{1,2})[\\s]*[\)]{1}$/;
// hsl color
const colorRegHSL =
/^[hH][Ss][Ll][\(]([\\s]*(2[0-9][0-9]|360|3[0-5][0-9]|[01]?[0-9][0-9]?)[\\s]*,)([\\s]*((100|[0-9][0-9]?)%|0)[\\s]*,)([\\s]*((100|[0-9][0-9]?)%|0)[\\s]*)[\)]$/;
// HSLA color
const colorRegHSLA =
/^[hH][Ss][Ll][Aa][\(]([\\s]*(2[0-9][0-9]|360|3[0-5][0-9]|[01]?[0-9][0-9]?)[\\s]*,)([\\s]*((100|[0-9][0-9]?)%|0)[\\s]*,){2}([\\s]*(1|1.0|0|0?.[0-9]{1,2})[\\s]*)[\)]$/;
/**
* hex to rgba
* @param {*} hex
* @param {*} alpha
*/
function colorHexToRgba(hex, alpha) {
let a = alpha || 1,
hColor = hex.toLowerCase(),
hLen = hex.length,
rgbaColor = [];
if (hex && colorRegExp.test(hColor)) {
//the hex length may be 4 or 7,contained the symbol of #
if (hLen === 4) {
let hSixColor = "#";
for (let i = 1; i < hLen; i++) {
let sColor = hColor.slice(i, i + 1);
hSixColor += sColor.concat(sColor);
}
hColor = hSixColor;
}
for (let j = 1, len = hColor.length; j < len; j += 2) {
rgbaColor.push(parseInt("0X" + hColor.slice(j, j + 2), 16));
}
return util.removeAllSpace("rgba(" + rgbaColor.join(",") + "," + a + ")");
} else {
return util.removeAllSpace(hColor);
}
}
/**
* rgba to hex
* @param {*} rgba
*/
function colorRgbaToHex(rgba) {
const hexObject = { 10: "A", 11: "B", 12: "C", 13: "D", 14: "E", 15: "F" },
hexColor = function (value) {
value = Math.min(Math.round(value), 255);
const high = Math.floor(value / 16),
low = value % 16;
return "" + (hexObject[high] || high) + (hexObject[low] || low);
};
const value = "#";
if (/rgba?/.test(rgba)) {
let values = rgba
.replace(/rgba?\(/, "")
.replace(/\)/, "")
.replace(/[\s+]/g, "")
.split(","),
color = "";
values.map((value, index) => {
if (index <= 2) {
color += hexColor(value);
}
});
return util.removeAllSpace(value + color);
}
}
/**
* hsva to rgba
* @param {*} hsva
* @param {*} alpha
*/
function colorHsvaToRgba(hsva, alpha) {
let r,
g,
b,
a = hsva.a; //rgba(r,g,b,a)
let h = hsva.h,
s = (hsva.s * 255) / 100,
v = (hsva.v * 255) / 100; //hsv(h,s,v)
if (s === 0) {
r = g = b = v;
} else {
let t = v,
p = ((255 - s) * v) / 255,
q = ((t - p) * (h % 60)) / 60;
if (h === 360) {
r = t;
g = b = 0;
} else if (h < 60) {
r = t;
g = p + q;
b = p;
} else if (h < 120) {
r = t - q;
g = t;
b = p;
} else if (h < 180) {
r = p;
g = t;
b = p + q;
} else if (h < 240) {
r = p;
g = t - q;
b = t;
} else if (h < 300) {
r = p + q;
g = p;
b = t;
} else if (h < 360) {
r = t;
g = p;
b = t - q;
} else {
r = g = b = 0;
}
}
if (alpha >= 0 || alpha <= 1) a = alpha;
return util.removeAllSpace(
"rgba(" +
Math.ceil(r) +
"," +
Math.ceil(g) +
"," +
Math.ceil(b) +
"," +
a +
")"
);
}
/**
* hsla to rgba
* 换算公式:https://zh.wikipedia.org/wiki/HSL%E5%92%8CHSV%E8%89%B2%E5%BD%A9%E7%A9%BA%E9%97%B4#%E4%BB%8EHSL%E5%88%B0RGB%E7%9A%84%E8%BD%AC%E6%8D%A2
* @param {*} hsla
*/
function colorHslaToRgba(hsla) {
let h = hsla.h,
s = hsla.s / 100,
l = hsla.l / 100,
a = hsla.a;
let r, g, b;
if (s === 0) {
r = g = b = l;
} else {
let compareRGB = (p, q, t) => {
if (t > 1) t = t - 1;
if (t < 0) t = t + 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * 6 * (2 / 3 - t);
return p;
};
let q = l >= 0.5 ? l + s - l * s : l * (1 + s),
p = 2 * l - q,
k = h / 360;
r = compareRGB(p, q, k + 1 / 3);
g = compareRGB(p, q, k);
b = compareRGB(p, q, k - 1 / 3);
}
return util.removeAllSpace(
`rgba(${Math.ceil(r * 255)},${Math.ceil(g * 255)},${Math.ceil(
b * 255
)},${a})`
);
}
/**
* rgba to hsla
* 换算公式:https://zh.wikipedia.org/wiki/HSL%E5%92%8CHSV%E8%89%B2%E5%BD%A9%E7%A9%BA%E9%97%B4#%E4%BB%8EHSL%E5%88%B0RGB%E7%9A%84%E8%BD%AC%E6%8D%A2
* @param {*} rgba
*/
function colorRgbaToHsla(rgba) {
const rgbaArr = rgba
.slice(rgba.indexOf("(") + 1, rgba.lastIndexOf(")"))
.split(",");
let a = rgbaArr.length < 4 ? 1 : Number(rgbaArr[3]);
let r = parseInt(rgbaArr[0]) / 255,
g = parseInt(rgbaArr[1]) / 255,
b = parseInt(rgbaArr[2]) / 255;
let max = Math.max(r, g, b),
min = Math.min(r, g, b);
let h,
s,
l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g >= b ? 0 : 6);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
}
return {
colorStr: util.removeAllSpace(
"hsla(" +
Math.ceil(h * 60) +
"," +
Math.ceil(s * 100) +
"%," +
Math.ceil(l * 100) +
"%," +
a +
")"
),
colorObj: {
h,
s,
l,
a,
},
};
}
/**
* rgba to hsva
* @param {*} rgba
*/
function colorRgbaToHsva(rgba) {
const rgbaArr = rgba
.slice(rgba.indexOf("(") + 1, rgba.lastIndexOf(")"))
.split(",");
let a = rgbaArr.length < 4 ? 1 : Number(rgbaArr[3]);
let r = parseInt(rgbaArr[0]) / 255,
g = parseInt(rgbaArr[1]) / 255,
b = parseInt(rgbaArr[2]) / 255;
let h, s, v;
let min = Math.min(r, g, b);
let max = (v = Math.max(r, g, b));
let diff = max - min;
if (max === 0) {
s = 0;
} else {
s = 1 - min / max;
}
if (max === min) {
h = 0;
} else {
switch (max) {
case r:
h = (g - b) / diff + (g < b ? 6 : 0);
break;
case g:
h = 2.0 + (b - r) / diff;
break;
case b:
h = 4.0 + (r - g) / diff;
break;
}
h = h * 60;
}
s = s * 100;
v = v * 100;
return {
h,
s,
v,
a,
};
}
/*
* 任意色值(甚至是CSS颜色关键字)转换为RGBA颜色的方法
* 此方法IE9+浏览器支持,基于DOM特性实现
* @param {*} color
*/
function colorToRgba(color) {
const div = document.createElement("div");
util.setCss(div, "background-color", color);
document.body.appendChild(div);
const c = util.getCss(div, "background-color");
document.body.removeChild(div);
let isAlpha = c.match(/,/g) && c.match(/,/g).length > 2;
let result = isAlpha
? c
: c.slice(0, 2) + "ba" + c.slice(3, c.length - 1) + ", 1)";
return util.removeAllSpace(result);
}
/**
* 判断是否是合格的颜色值
* @param {*} color
*/
function isValidColor(color) {
// https://developer.mozilla.org/zh-CN/docs/Web/CSS/color_value#%E8%89%B2%E5%BD%A9%E5%85%B3%E9%94%AE%E5%AD%97
let isTransparent = color === "transparent";
return (
colorRegExp.test(color) ||
colorRegRGB.test(color) ||
colorRegRGBA.test(color) ||
colorRegHSL.test(color) ||
colorRegHSLA.test(color) ||
(colorToRgba(color) !== "rgba(0,0,0,0)" && !isTransparent) ||
isTransparent
);
}
/**
*
* @param {*} color
* @returns
*/
function isAlphaColor(color) {
return (
colorRegRGB.test(color) ||
colorRegRGBA.test(color) ||
colorRegHSL.test(color) ||
colorRegHSLA.test(color)
);
}
工具方法这些我们已经完成了,接下来就是正式完成我们的主线功能逻辑了。
构造函数的定义
首先当然是完成我们的构造函数呢,我们把一个颜色选择器看做是一个构造实例,也因此,我们创建一个构造函数。
function ewColorPicker(options){
//主要逻辑
}
好的,接下来,让我们完成第一步,校验用户传入的参数,我们分为2种情况,第一种是如果用户传入的是一个DOM元素字符串或者是一个DOM元素,那么我们就要定义一个默认的配置对象,如果用户传入的是一个自定义的对象,那么我们将不采取默认对象。在校验之前,我们先思考一下可能需要处理的错误情况,也就是说假如用户传入的参数不符合规则,我们是不是需要返回一些错误提示给用户知道,现在让我们来定义一下这些错误规则吧。如下所示:
const NOT_DOM_ELEMENTS = ['html','head','meta','title','link','style','script','body'];
const ERROR_VARIABLE = {
DOM_OBJECT_ERROR:'can not find the element by el property,make sure to pass a correct value!',
DOM_ERROR:'can not find the element,make sure to pass a correct param!',
CONFIG_SIZE_ERROR:'the value must be a string which is one of the normal,medium,small,mini,or must be an object and need to contain width or height property!',
DOM_NOT_ERROR:'Do not pass these elements: ' + NOT_DOM_ELEMENTS.join(',') + ' as a param,pass the correct element such as div!',
PREDEFINE_COLOR_ERROR:'"predefineColor" is a array that is need to contain color value!',
CONSTRUCTOR_ERROR:'ewColorPicker is a constructor and should be called with the new keyword!',
DEFAULT_COLOR_ERROR:'the "defaultColor" is not an invalid color,make sure to use the correct color!'
};
这些校验错误都是常量,不允许被修改的,所以我们用大写字母来表示。接下来我们就需要在构造函数里做一个校验了。
配置属性的定义与校验
1.校验是否是实例化
判断new.target
就可以了,如下所示:
if(util.isUndefined(new.target))return ewError(ERROR_VARIABLE.CONSTRUCTOR_ERROR);
2.定义一个函数startInit,在这个函数里对具体的属性做判断。如下所示:
function startInit(context,options){
let initOptions = initConfig(config);
if(!initOptions)return;
// 缓存配置对象属性
context.config = initOptions.config;
//定义私有属性
context._private = {
boxSize: {
b_width: null,
b_height: null
},
pickerFlag: false,
colorValue: "",
};
// 在初始化之前所作的操作
context.beforeInit(initOptions.element,initOptions.config,initOptions.error);
}
接下来,我们来看initConfig函数,如下所示:
export function initConfig(config){
// 默认的配置对象属性
const defaultConfig = { ...colorPickerConfig };
let element,error,mergeConfig = null;
//如果第二个参数传的是字符串,或DOM对象,则初始化默认的配置
if (util.isString(config) || util.isDom(config) || util.isJQDom(config)) {
mergeConfig = defaultConfig;
element = util.isJQDom(config) ? config.get(0) : config;
error = ERROR_VARIABLE.DOM_ERROR;
} //如果是对象,则自定义配置,自定义配置选项如下:
else if (util.isDeepObject(config) && (util.isString(config.el) || util.isDom(config.el) || util.isJQDom(config.el))) {
mergeConfig = util.ewAssign(defaultConfig, config);
element = util.isJQDom(config.el) ? config.el.get(0) : config.el;
error = ERROR_VARIABLE.DOM_OBJECT_ERROR;
} else {
if(util.isDeepObject(config)){
error = ERROR_VARIABLE.DOM_OBJECT_ERROR;
}else{
error = ERROR_VARIABLE.DOM_ERROR;
}
}
return {
element,
config:mergeConfig,
error
}
}
然后我们来看看默认的配置对象属性:
export const emptyFun = function () { };
const baseDefaultConfig = {
alpha: false,
size: "normal",
predefineColor: [],
disabled: false,
defaultColor: "",
pickerAnimation: "height",
pickerAnimationTime:200,
sure: emptyFun,
clear: emptyFun,
togglePicker: emptyFun,
changeColor: emptyFun,
isClickOutside: true,
}
接下来,我们来看beforeInit函数,如下所示:
function beforeInit(element, config, errorText) {
let ele = util.isDom(element) ? element : util.isString(element) ? util.$(element) : util.isJQDom(element) ? element.get(0) : null;
if (!ele) return util.ewError(errorText);
ele = ele.length ? ele[0] : ele;
if (!ele.tagName) return util.ewError(errorText);
if (!isNotDom(ele)) {
if(!this._color_picker_uid){
this._color_picker_uid = util.createUUID();
}
this.init(ele, config);
}
}
其中,isNotDom方法,我们先定义好:
const isNotDom = ele => {
if (NOT_DOM_ELEMENTS.indexOf(ele.tagName.toLowerCase()) > -1) {
util.ewError(ERROR_VARIABLE.DOM_NOT_ERROR);
return true;
}
return false;
}
最后,我们来看init函数,如下所示:
function init(element, config) {
let b_width, b_height;
//自定义颜色选择器的类型
if (util.isString(config.size)) {
switch (config.size) {
case 'normal':
b_width = b_height = '40px';
break;
case 'medium':
b_width = b_height = '36px';
break;
case 'small':
b_width = b_height = '32px';
break;
case 'mini':
b_width = b_height = '28px';
break;
default:
b_width = b_height = '40px';
break;
}
} else if (util.isDeepObject(config.size)) {
b_width = config.size.width && (util.isNumber(config.size.width) || util.isString(config.size.width)) ? (parseInt(config.size.width) <= 25 ? 25 : parseInt(config.size.width))+ 'px' : '40px';
b_height = config.size.height && (util.isNumber(config.size.height) || util.isString(config.size.height)) ? (parseInt(config.size.height) <= 25 ? 25 : parseInt(config.size.height)) + 'px' : '40px';
} else {
return util.ewError(ERROR_VARIABLE.CONFIG_SIZE_ERROR);
}
this._private.boxSize.b_width = b_width;
this._private.boxSize.b_height = b_height;
//渲染选择器
this.render(element, config);
}
如此一来,我们的初始化的工作才算是完成,回顾一下,我们在初始化的时候做了哪些操作。我总结如下:
- 定义了一些错误的常量,用于提示。
- 验证用户传入的参数,分为2种情况,第一种是字符串或者DOM元素,第二种是自定义对象,其中必须指定el属性为一个DOM元素。
- 定义了默认配置对象,定义了一些私有变量。
- 对色块盒子的大小做了一次规范化。
接下来,就是我们实际渲染一个颜色选择器的渲染函数,即render函数。
render函数
render函数的核心思路非常的简单,实际上就是创建一堆元素,然后添加到元素当中去。只不过我们需要注意几点,例如预定义颜色数组,默认颜色值,以及色块盒子的大小,还有就是alpha柱的显隐。如下所示:
ewColorPicker.prototype.render = function(element,config){
let predefineColorHTML = '',
alphaBar = '',
hueBar = '',
predefineHTML = '',
boxDisabledClassName = '',
boxBackground = '',
boxHTML = '',
clearHTML = '',
sureHTML = '',
inputHTML = '',
btnGroupHTML = '',
dropHTML = '',
openChangeColorModeHTML = '',
openChangeColorModeLabelHTML = '',
horizontalSliderHTML = '',
verticalSliderHTML = '';
const p_c = config.predefineColor;
if (!util.isDeepArray(p_c)) return util.ewError(ERROR_VARIABLE.PREDEFINE_COLOR_ERROR);
if (p_c.length) {
p_c.map((color,index) => {
let isValidColorString = util.isString(color) && isValidColor(color);
let isValidColorObj = util.isDeepObject(color) && color.hasOwnProperty('color') && isValidColor(color.color);
let renderColor = isValidColorString ? color : isValidColorObj ? color.color : '';
let renderDisabled = isValidColorObj ? setPredefineDisabled(color.disabled) : '';
predefineColorHTML += `
`;
})
};
//打开颜色选择器的方框
const colorBox = config.defaultColor ? `
` : `×`;
//透明度
if (config.alpha) {
alphaBar = ` `;
}
// hue
if (config.hue) {
hueBar = ` `;
}
if (predefineColorHTML) {
predefineHTML = `${predefineColorHTML}`;
}
if (config.disabled || config.boxDisabled) boxDisabledClassName = 'ew-color-picker-box-disabled';
if (config.defaultColor){
if(!isValidColor(config.defaultColor)){
return util.ewError(ERROR_VARIABLE.DEFAULT_COLOR_ERROR)
}else{
config.defaultColor = colorToRgba(config.defaultColor);
}
};
this._private.color = config.defaultColor;
if (!config.disabled && this._private.color) boxBackground = `background:${this._private.color}`;
// 盒子样式
const boxStyle = `width:${this._private.boxSize.b_width};height:${this._private.boxSize.b_height};${boxBackground}`;
if (config.hasBox) {
boxHTML = `${colorBox}`;
}
if (config.hasClear) {
clearHTML = ``;
}
if (config.hasSure) {
sureHTML = ``;
}
if (config.hasClear || config.hasSure) {
btnGroupHTML = `${clearHTML}${sureHTML}`;
}
if (config.hasColorInput) {
inputHTML = '';
}
if (config.openChangeColorMode) {
if (!config.alpha || !config.hue) return util.ewError(ERROR_VARIABLE.COLOR_MODE_ERROR);
openChangeColorModeHTML = `
`;
openChangeColorModeLabelHTML = ``;
}
if (config.hasColorInput || config.hasClear || config.hasSure) {
dropHTML = config.openChangeColorMode ? `
${openChangeColorModeLabelHTML}${inputHTML}${openChangeColorModeHTML}
${btnGroupHTML}
` : `
${inputHTML}${btnGroupHTML}
`;
}
this.isAlphaHorizontal = config.alphaDirection === 'horizontal';
this.isHueHorizontal = config.hueDirection === 'horizontal';
if(this.isAlphaHorizontal && this.isHueHorizontal){
horizontalSliderHTML = hueBar + alphaBar;
}else if(!this.isAlphaHorizontal && !this.isHueHorizontal){
verticalSliderHTML = alphaBar + hueBar;
}else{
if(this.isHueHorizontal){
horizontalSliderHTML = hueBar;
verticalSliderHTML = alphaBar;
} else{
horizontalSliderHTML = alphaBar;
verticalSliderHTML = hueBar;
}
}
if(horizontalSliderHTML){
horizontalSliderHTML = ` `
}
if(verticalSliderHTML){
verticalSliderHTML = ` `;
}
//颜色选择器
const html = `${boxHTML}
${ verticalSliderHTML }
${ horizontalSliderHTML }
${dropHTML}
${predefineHTML}
`;
element.setAttribute("color-picker-id",this._color_picker_uid);
element.innerHTML = `${ html }`;
this.startMain(element, config);
}
startMain函数
接下来,我们来看看我们要实现哪些逻辑。首先我们需要确定一个初始值的颜色对象,用hsva来表示,我们创建一个initColor函数,代码如下所示:
function initColor(context, config) {
if (config.defaultColor) {
context.hsvaColor = colorRegRGBA.test(config.defaultColor) ? colorRgbaToHsva(config.defaultColor) : colorRgbaToHsva(colorToRgba(config.defaultColor));
} else {
context.hsvaColor = {
h: 0,
s: 100,
v: 100,
a: 1
};
}
}
这是我们要实现的第一个逻辑,也就是初始化颜色值,这个颜色值对象将贯穿整个颜色选择器实例,所有的逻辑更改也会围绕它展开。接下来,我们再内部存储一些DOM元素或者一些私有对象属性以及用户传入的配置对象,这样可以方便我们之后操作。
现在我们再来分析一下,我们可以大致得到主要的逻辑有:
- 初始化一些后续需要操作的DOM元素与颜色值以及面板的left与top偏移
- 预定义颜色逻辑
- 初始化颜色面板的动画逻辑
- 色块盒子的处理逻辑
- 输入框逻辑
- 禁用逻辑
- 点击目标区域之外关闭颜色面板的逻辑
- 清空按钮与确定按钮的逻辑
- 颜色面板的点击逻辑与颜色面板的元素拖拽逻辑
我们接下来将围绕这几种逻辑一起展开。如下所示:
// 初始化逻辑
let scope = this;
this.$Dom = Object.create(null);
this.$Dom.rootElement = ele;
this.$Dom.picker = getELByClass(ele, 'ew-color-picker');
this.$Dom.pickerPanel = getELByClass(ele, 'ew-color-panel');
this.$Dom.pickerCursor = getELByClass(ele, 'ew-color-cursor');
this.$Dom.verticalSlider = getELByClass(ele, 'ew-is-vertical');
// 清空按钮逻辑
this.$Dom.pickerClear = getELByClass(ele, 'ew-color-clear');
this.$Dom.hueBar = getELByClass(ele, 'ew-color-slider-bar');
this.$Dom.hueThumb = getELByClass(ele, 'ew-color-slider-thumb');
this.$Dom.preDefineItem = getELByClass(ele, 'ew-pre-define-color', true);
this.$Dom.box = getELByClass(ele, 'ew-color-picker-box');
// 输入框逻辑
this.$Dom.pickerInput = getELByClass(ele, 'ew-color-input');
// 确定按钮逻辑
this.$Dom.pickerSure = getELByClass(ele, 'ew-color-sure');
initColor(this, config);
//初始化面板的left偏移和top偏移
const panelWidth = this.panelWidth = parseInt(util.getCss(this.$Dom.pickerPanel, 'width'));
const panelHeight = this.panelHeight = parseInt(util.getCss(this.$Dom.pickerPanel, 'height'));
const rect = util.getRect(ele);
this.panelLeft = rect.left;
this.panelTop = rect.top + rect.height;
接着我们开始初始化预定义颜色逻辑:
// 预定义颜色逻辑
if (this.$Dom.preDefineItem.length) {
initPreDefineHandler(util.ewObjToArray(this.$Dom.preDefineItem), scope);
}
function initPreDefineHandler(items, context) {
// get the siblings
const siblings = el => Array.prototype.filter.call(el.parentElement.children, child => child !== el);
items.map(item => {
const clickHandler = event => {
util.addClass(item, 'ew-pre-define-color-active');
siblings(item).forEach(sibling => util.removeClass(sibling, 'ew-pre-define-color-active'))
const bgColor = util.getCss(event.target, 'background-color');
context.hsvaColor = colorRgbaToHsva(bgColor);
setColorValue(context, context.panelWidth, context.panelHeight, true);
changeElementColor(context);
};
const blurHandler = event => util.removeClass(event.target, 'ew-pre-define-color-active');
[{ type: "click", handler: clickHandler }, { type: "blur", handler: blurHandler }].forEach(t => {
if (!context.config.disabled && util.ewObjToArray(item.classList).indexOf('ew-pre-define-color-disabled') === -1) {
util.on(item, t.type, t.handler);
}
});
})
}
然后我们开始初始化动画逻辑:
initAnimation(scope);
function initAnimation(context) {
//颜色选择器打开的动画初始设置
const expression = getAnimationType(context);
util.setCss(context.$Dom.picker, (expression ? 'display' : 'opacity'), (expression ? 'none' : 0))
let pickerWidth = 0, sliderWidth = 0, sliderHeight = 0;
let isVerticalAlpha = !context.isAlphaHorizontal;
let isVerticalHue = !context.isHueHorizontal;
let isHue = context.config.hue;
let isAlpha = context.config.alpha;
if (isAlpha && isHue && isVerticalAlpha && isVerticalHue) {
pickerWidth = 320;
sliderWidth = 28;
} else if (isVerticalAlpha && isAlpha && (!isVerticalHue || !isHue) || (isVerticalHue && isHue && (!isVerticalAlpha || !isAlpha))) {
pickerWidth = 300;
sliderWidth = sliderHeight = 14;
} else {
pickerWidth = 280;
sliderHeight = isAlpha && isHue && !isVerticalHue && !isVerticalAlpha ? 30 : 14;
}
util.setCss(context.$Dom.picker, 'min-width', pickerWidth + 'px');
if (context.$Dom.horizontalSlider) {
util.setCss(context.$Dom.horizontalSlider, 'height', sliderHeight + 'px');
}
if (context.$Dom.verticalSlider) {
util.setCss(context.$Dom.verticalSlider, 'width', sliderWidth + 'px');
}
}
接下来,就是我们的一些功能逻辑了,让我们一一来实现吧,首先我们需要的实现的是点击色块打开或者关闭颜色选择器面板。如下所示:
// 色块
if (!config.disabled){
util.on(this.$Dom.box, 'click', () => handlePicker(ele, scope, (flag) => {
if (flag && scope.config.isClickOutside) {
initColor(this, config);
setColorValue(scope, scope.panelWidth, scope.panelHeight, false);
handleClickOutSide(scope, scope.config);
}
}));
}
这里的逻辑也不复杂,就是判断是否禁用,然后为盒子元素添加点击事件,在这里核心的功能就是handlePicker
方法,我们可以看到传入3个参数,第一个参数为当前根容器元素,第二个参数则是当前执行上下文对象,第三个参数则是一个回调函数,用来做一些细节处理。setColorValue
方法暂时先不作说明,而initColor
方法我们前面已经讲过,handleClickOutSide
方法我们将在讲完handlePicker
方法之后再做介绍,现在让我们先来看一下handlePicker
这个方法吧。
export function handlePicker(el, scope,callback) {
scope._private.pickerFlag = !scope._private.pickerFlag;
openAndClose(scope);
initColor(scope, scope.config);
setColorValue(scope, scope.panelWidth, scope.panelHeight, false);
if (util.isFunction(scope.config.togglePicker)){
scope.config.togglePicker(el, scope._private.pickerFlag,scope);
}
if(util.isFunction(callback))callback(scope._private.pickerFlag);
}
可以看到,这个方法的核心操作是改变颜色选择器的状态,最重要的就是openAndClose
方法呢,让我们一起来看一下吧,
export function openAndClose(scope) {
const time = scope.config.pickerAnimationTime;
scope._private.pickerFlag ? open(getAnimationType(scope), scope.$Dom.picker,time) : close(getAnimationType(scope), scope.$Dom.picker,time);
}
export function getAnimationType(scope) {
return scope.config.pickerAnimation;
}
这个方法就是获取动画执行时间,然后根据pickerFlag
来判断是开启还是关闭颜色选择器,核心的就是open
与close
方法,两者都接收3个参数,第一个则是动画的类型,第二个则是颜色选择器面板元素,第三个则是动画执行时间。我们分别来看一下:
1.open方法
export function open(expression, picker,time = 200) {
time = time > 10000 ? 10000 : time;
let animation = '';
switch(expression){
case 'opacity':
animation = 'fadeIn';
break;
default:
animation = 'slideDown';
}
return ani[animation](picker, time);
}
2.close方法
export function close(expression, picker,time = 200) {
time = time > 10000 ? 10000 : time;
let animation = '';
switch(expression){
case 'opacity':
animation = 'fadeOut';
break;
default:
animation = 'slideUp';
}
return ani[animation](picker, time);
}
可以看到,我们再open
与close
方法内部对时间做了一次限制处理,然后判断动画类型来决定调用哪种动画来实现颜色选择器的开启和关闭。到这里,我们还少实现了一个方法,那就是handleClickOutSide
,让我们来一起看一下这个方法的实现:
export function handleClickOutSide(context, config) {
util.clickOutSide(context, config, () => {
if (context._private.pickerFlag) {
context._private.pickerFlag = false;
closePicker(getAnimationType(config.pickerAnimation), context.$Dom.picker,config.pickerAnimationTime);
}
});
}
可以看到,我们主要是对颜色选择器面板如果处于开启状态做的一个操作,也就是点击不包含盒子元素区域以外的空间,我们都要关闭颜色选择器面板。这里设计到如何去实现判断我们的鼠标点击是在元素的区域之外呢?有2种方式来实现,第一种判断我们点击的DOM元素是否是颜色选择器元素以及其子元素节点即可,也就是说我们只需要判断我们点击的元素如果是颜色选择器面板容器元素或者是其子元素,我们都不能关闭颜色选择器,并且当然颜色选择器面板还要处于开启中的状态。另一种就是通过坐标值的计算,判断鼠标点击的坐标区间是否在颜色选择器面板的坐标区域内,这里我们采用第二种实现方式,让我们一起来看一下吧。
util["clickOutSide"] = (context, config, callback) => {
const mouseHandler = (event) => {
const rect = util.getRect(context.$Dom.picker);
const boxRect = util.getRect(context.$Dom.box);
const target = event.target;
if (!target) return;
const targetRect = util.getRect(target);
// 利用rect来判断用户点击的地方是否在颜色选择器面板区域之内
if (targetRect.x >= rect.x && targetRect.y >= rect.y && targetRect.width <= rect.width) return;
// 如果点击的是盒子元素
if (targetRect.x >= boxRect.x && targetRect.y >= boxRect.y && targetRect.width <= boxRect.width && targetRect.height <= boxRect.height) return;
callback();
setTimeout(() => {
util.off(document, util.eventType[0], mouseHandler);
}, 0);
}
util.on(document, util.eventType[0], mouseHandler);
}
可以看到,我们是通过比较x与y坐标的大小从而确定是否点击的区域属于颜色选择器面板区域,从而确定颜色选择器的关闭状态。当然这也是我们默认会调用的,当然我们也提供了一个可选项来确定是否可以通过点击元素区域之外的空间关闭颜色选择器面板。如下:
if (config.isClickOutside) {
handleClickOutSide(this, config);
}
代码不复杂,很容易就理解了。接下来,我们来看alpha
透明度的逻辑的实现。如下:
if (!config.disabled) {
this.bindEvent(this.$Dom.alphaBarThumb, (scope, el, x, y) => changeAlpha(scope, y));
util.on(this.$Dom.alphaBar, 'click', event => changeAlpha(scope, event.y));
}
可以看到,我们这里首先需要判断是否禁用,然后我们需要2种方式给透明度柱子添加事件逻辑,第一种就是拖拽透明度柱子的滑块元素所触发的拖拽事件,第二种则是点击透明度柱子的事件,这其中涉及到了一个changeAlpha
事件。我们来看一下:
export function changeAlpha(context, position) {
let value = setAlphaHuePosition(context.$Dom.alphaBar,context.$Dom.alphaBarThumb,position);
let currentValue = value.barPosition - value.barThumbPosition <= 0 ? 0 : value.barPosition - value.barThumbPosition;
let alpha = context.isAlphaHorizontal ? 1 - currentValue / value.barPosition : currentValue / value.barPosition;
context.hsvaColor.a = alpha >= 1 ? 1 : alpha.toFixed(2);
changeElementColor(context, true);
}
这个方法又涉及到了2个方法setAlphaHuePosition
与changeElementColor
。我们分别来看一下:
function setAlphaHuePosition(bar,thumb,position){
const positionProp = 'y';
const barProp = 'top';
const barPosition = bar.offsetHeight,
barRect = util.getRect(bar);
const barThumbPosition = Math.max(0,Math.min(position - barRect[positionProp],barPosition));
util.setCss(thumb,barProp,barThumbPosition +'px');
return {
barPosition,
barThumbPosition
}
}
可以看到,这里我们主要的逻辑操作就是规范化样式处理,也就是说我们拖动滑块改变的是垂直方向上的top偏移(未来会考虑加入水平方向也就是left偏移),所以单独抽取出来做一个公共的方法,这个top
偏移会有一个最大值与最小值的比较。接下来,我们来看changeElementColor
方法的实现:
export function changeElementColor(scope, isAlpha) {
const color = colorHsvaToRgba(scope.hsvaColor);
let newColor = isAlpha || scope.config.alpha ? color : colorRgbaToHex(color);
scope.$Dom.pickerInput.value = newColor;
scope.prevInputValue = newColor;
changeAlphaBar(scope);
if (util.isFunction(scope.config.changeColor))scope.config.changeColor(newColor);
}
显然这个方法的核心目的就是处理颜色值的改变,我们有2个参数,第一个参数则是当前上下文,第二个参数用于判断透明度柱是否开启。先利用colorHsvaToRgba
方法将当前的颜色值转换成rgba
颜色,然后判断如果开启了透明度柱,则不需要进行转换,否则就需要转换成hex
颜色模式,然后我们把新的颜色值传给input
元素。并且缓存了一下这个颜色值,然后这里需要注意一下,如果改变了颜色值,则有可能透明度会改变,因此,需要再次调用changeAlphaBar
方法来改变透明度柱的功能。最后我们暴露了一个changeColor
方法接口给用户使用。
前面还提到了一个bindEvent
方法,我们接下来来看一下这个bindEvent
方法的实现。如下:
export function bindEvent(el, callback, bool) {
const context = this;
const callResult = event => {
context.moveX = util.eventType[0].indexOf('touch') > -1 ? event.changedTouches[0].clientX : event.clientX;
context.moveY = util.eventType[0].indexOf('touch') > -1 ? event.changedTouches[0].clientY : event.clientY;
bool ? callback(context, context.moveX, context.moveY) : callback(context, el, context.moveX, context.moveY);
}
const handler = () => {
const moveFn = e => { e.preventDefault(); callResult(e); }
const upFn = () => {
util.off(document, util.eventType[1], moveFn);
util.off(document, util.eventType[2], upFn);
}
util.on(document, util.eventType[1], moveFn);
util.on(document, util.eventType[2], upFn);
}
util.on(el, util.eventType[0], handler);
}
这个方法的核心就是在PC端监听onmousedown,onmousemove,onmouseup
事件,在移动端监听touchstart,touchmove,touchend
事件并将当前上下文,x
坐标以及y
坐标回调出去。
接下来,让我们继续。我们来实现hue色调柱的逻辑,它的逻辑和透明度柱很相似。
if (!config.disabled) {
//hue的点击事件
util.on(this.$Dom.hueBar, 'click', event => changeHue(scope, event.y))
//hue 轨道的拖拽事件
this.bindEvent(this.$Dom.hueBarThumb, (scope, el, x, y) => changeHue(scope, y));
}
可以看到,我们同样是判断是否禁用,然后给色调柱添加点击事件以及给hue滑块添加拖拽事件。这里也就核心实现了一个changeHue
方法。让我们来看一下吧。
export function changeHue(context, position) {
const { $Dom:{ hueBar,hueThumb,pickerPanel },_private:{hsvaColor}} = context;
let value = setAlphaHuePosition(hueBar, hueThumb, position);
const { barThumbPosition,barPosition } = value;
context.hsvaColor.h = cloneColor(hsvaColor).h = parseInt(360 * barThumbPosition / barPosition);
util.setCss(pickerPanel, 'background', colorRgbaToHex(colorHsvaToRgba(cloneColor(context.hsvaColor))));
changeElementColor(context);
}
这个方法,我们首先同样是获取到一个值,由前面的颜色算法我们应该知道,色彩的角度限制在0~360之间,然后我们通过360 * barThumbPosition / barPosition得到了色彩也就是h的相关值。然后我们需要修改颜色面板的背景样式。然后调用changeElementColor
方法(这个在前面已经讲过)。前面我们遗留了一个方法,叫做changeAlphaBar
,让我们来看一下这个方法做了什么。
export function changeAlphaBar(scope) {
if (!scope.$Dom.alphaBarBg) return;
let position = 'to top';
util.setCss(scope.$Dom.alphaBarBg, 'background', 'linear-gradient('+ position +',' + colorHsvaToRgba(scope.hsvaColor,0) + ' 0%,' + colorHsvaToRgba(scope.hsvaColor,1) + ' 100%)');
}
可以看到,实际上我们就是对透明度柱的背景色做了一个修改。由于我们的透明度柱子不一定存在(因为由用户自定义是否显示),所以这里我们是需要做一个判断的。
接下来,让我们继续来实现一下颜色面板
组件的相关逻辑功能。其实它的逻辑与透明度柱和色彩柱一样,都是分为拖拽和点击。如下所示:
//颜色面板点击事件
util.on(this.$Dom.pickerPanel, 'click', event => onClickPanel(scope, event));
//颜色面板拖拽元素拖拽事件
this.bindEvent(this.$Dom.pickerCursor, (scope, el, x, y) => {
const left = Math.max(0, Math.min(x - scope._private.panelLeft, panelWidth));
const top = Math.max(0, Math.min(y - scope._private.panelTop, panelHeight));
changeCursorColor(scope, left + 4, top + 4, panelWidth, panelHeight);
});
我们先来看点击逻辑,同样的是监听面板的点击事件,然后调用onClickPanel
方法,我们来看一下这个方法的实现。
export function onClickPanel(scope, eve) {
if (eve.target !== scope.$Dom.pickerCursor) {
//临界值处理
const moveX = eve.layerX;
const moveY = eve.layerY;
const { _private:{ panelWidth,panelHeight }} = context;
const left = moveX >= panelWidth - 1 ? panelWidth : moveX <= 0 ? 0 : moveX;
const top = moveY >= panelHeight - 2 ? panelHeight : moveY <= 0 ? 0 : moveY;
changeCursorColor(scope, left + 4, top + 4,panelWidth,panelHeight)
}
}
可以看到,我们所做的操作就是获取一个x坐标和y坐标,然后去设置拖拽游标
的left和top偏移,这里会有临界值的处理。稍微宽度减1和高度减2是做一层偏差处理。然后再次调用changeCursorColor
方法,我们继续来看这个方法的实现。
export function changeCursorColor(scope, left, top, panelWidth, panelHeight) {
util.setSomeCss(scope.$Dom.pickerCursor, [{ prop: 'left', value: left + 'px' }, { prop: 'top', value: top + 'px' }])
const s = parseInt(100 * (left - 4) / panelWidth);
const v = parseInt(100 * (panelHeight - (top - 4)) / panelHeight);
//需要减去本身的宽高来做判断
scope.hsvaColor.s = s > 100 ? 100 : s < 0 ? 0 : s;
scope.hsvaColor.v = v > 100 ? 100 : v < 0 ? 0 : v;
changeElementColor(scope);
}
可以看到这个方法我们所做的操作就是设置游标元素的偏移量,以及它的偏移量所代表的的就是hsva颜色模式中的s和v,然后我们再次调用changeElementColor
方法就可以改变颜色值了。
让我们继续看清空按钮的事件逻辑,如下所示:
util.on(this.$Dom.pickerClear, 'click', () => onClearColor(scope));
也就是添加点击事件的监听,然后再事件的回调函数中调用onClearColor
方法,接下来,我们看onClearColor
方法。如下所示:
export function onClearColor(scope) {
scope._private.pickerFlag = false;
closePicker(getAnimationType(scope.config.pickerAnimation),scope.$Dom.picker,scope.config.pickerAnimationTime);
scope.config.defaultColor = scope._private.color = "";
scope.config.clear(scope.config.defaultColor, scope);
}
可以看到我们所做的操作比较简单,就是重置颜色选择器开启状态,然后调用关闭颜色选择器方法关闭颜色选择器,然后重置我们的颜色,再回调一个clear
方法接口给用户使用。同样的道理,我们的确定按钮的逻辑也就是如此了。如下所示:
util.on(this.$Dom.pickerSure, 'click', () => onSureColor(scope));
也就是添加点击事件的监听,然后再事件的回调函数中调用onSureColor
方法,接下来,我们看onSureColor
方法。如下所示:
export function onSureColor(scope) {
const result = scope.config.alpha ? colorHsvaToRgba(scope._private.hsvaColor) : colorRgbaToHex(colorHsvaToRgba(scope._private.hsvaColor));
scope._private.pickerFlag = false;
closePicker(getAnimationType(scope.config.pickerAnimation),scope.$Dom.picker,scope.config.pickerAnimationTime);
scope.config.defaultColor = scope._private.color = result;
changeElementColor(scope);
scope.config.sure(result, scope);
}
可以看到这个操作的逻辑也比较简单,类似于清空按钮的逻辑,我们不外乎需要设置颜色值,然后回调一个sure
方法给用户,这个方法回调两个参数,第一个参数为当前选中的颜色值,第二个参数则是当前上下文对象。另外,我们还需要调用changeElementColor
方法来改变颜色值。
接下来,让我们继续来实现一下input
框的相关逻辑功能,这也是我们的最后一个逻辑。首先我们需要确定的就是,当input
框移开焦点的时候,就意味着更改颜色值。所以我们监听它的移开焦点事件,然后额外封装了一个方法。当然在这之前,我们先需要监听禁用逻辑,如下所示:
// 禁用逻辑
if (config.disabled) {
if (!util.hasClass(this.$Dom.pickerInput, 'ew-input-disabled')) {
util.addClass(this.$Dom.pickerInput,'ew-input-disabled');
}
if (!util.hasClass(this.$Dom.picker, 'ew-color-picker-disabled')) {
util.addClass(this.$Dom.picker,'ew-color-picker-disabled');
}
this.$Dom.pickerInput.disabled = true;
return false;
}
可以看到,以上的逻辑,我们就是判断用户是否传入了disabled
属性,然后判断input
元素是否还有我们自定义的禁用类名ew-input-disabled
,如果没有则添加该类名,同样的,我们为picker
也做相同的逻辑,最后我们将input
元素的disabled
属性设置为true
。接下来我们来看blur
事件的实现:
util.on(this.$Dom.pickerInput, 'blur', event => onInputColor(scope, event.target.value));
这段代码很简单,就是添加监听事件,接下来,我们来看onInputColor
方法的实现。如下:
export function onInputColor(scope, value) {
if (!isValidColor(value)) return;
// 两者相等,说明用户没有更改颜色
if (util.removeAllSpace(scope.prevInputValue) === util.removeAllSpace(value))return;
let color = scope.config.alpha ? colorRgbaToHsva(value) : colorRgbaToHsva(colorHexToRgba(value));
scope.hsvaColor = color;
setColorValue(scope, scope.panelWidth, scope.panelHeight,true);
}
这段代码的逻辑也不复杂,首先判断输入框的值是否是合格的颜色值或者判断当前值和我们缓存的值是否相同,如果不是合格的颜色值或者与缓存的值相同则不作任何操作。然后我们再根据是否开启了透明度柱来判断是否需要调用colorHexToRgba
方法来将颜色值转换成rgba
颜色,然后再使用colorRgbaToHsva
方法来将颜色值转换成hsva
的颜色。然后再赋值。最后再调用setColorValue
方法来赋值。接下来,我们就来看setColorValue
方法的实现。如下:
export function setColorValue(context, panelWidth, panelHeight,boxChange) {
changeElementColor(context);
context._private.prevInputValue = context.$Dom.pickerInput.value;
let sliderBarHeight = 0;
let l = parseInt(context.hsvaColor.s * panelWidth / 100),
t = parseInt(panelHeight - context.hsvaColor.v * panelHeight / 100);
[
{
el: context.$Dom.pickerCursor,
prop: 'left',
value: l + 4 + 'px'
},
{
el: context.$Dom.pickerCursor,
prop: 'top',
value: t + 4 + 'px'
},
{
el: context.$Dom.pickerPanel,
prop: 'background',
value: colorRgbaToHex(colorHsvaToRgba(cloneColor(context.hsvaColor)))
}
].forEach(item => util.setCss(item.el, item.prop, item.value));
getSliderBarPosition(context.$Dom.hueBar,(position,prop) => {
util.setCss(context.$Dom.hueThumb, prop, parseInt(context.hsvaColor.h * position / 360) + 'px');
});
if (context.config.alpha) {
getSliderBarPosition(context.$Dom.alphaBar,(position,prop) => {
util.setCss(context.$Dom.alphaBarThumb, prop, position - context.hsvaColor.a * position + 'px');
});
}
}
export function getSliderBarPosition(bar,callback){
let sliderPosition = bar.offsetHeight;
let sliderProp = 'top';
callback(sliderPosition,sliderProp);
}
这个方法的实现稍微有点复杂,实际上这个方法在前面我们已经用到过,只是没有讲解。接下来,让我们来一一分析这个方法到底做了什么。首先,调用了changeElementColor
方法赋值,其次缓存当前的输入框的颜色值,然后计算颜色面板游标元素的left和top偏移量,然后分别设置它们,再然后设置颜色面板的背景色。以及设置色彩柱的偏移量。如果透明度柱子存在,则也要设置透明度柱子的偏移量。
到目前为止,我们所要实现的颜色选择器的基本功能就已经完成,接下来,我们来对我们的文档做一个总结。我们从分析每一个颜色选择器的模块开始,对应的结构及样式我们都是一一分析了,然后再细化到每一个功能。每一个颜色选择器的模块如下:
- 颜色色块
- 颜色面板
- 色调柱
- 透明度柱
- 输入框
- 清空与确定按钮
- 预定义颜色元素列表
再然后,我们对照每一个模块去一一实现它们的功能。在这些功能中,我们学到了哪些东西呢?
- 闭包。(也就是说我们在某一个作用域中访问其它作用域中的变量。例如:bindEvent方法的实现)
- 定时器。 (如动画函数的实现)
- 颜色转换算法。
- 正则表达式。
- 面向对象的编程。
- 如何实现点击目标区域之外的逻辑功能
当然还有很多,细细品味下来,我们应该知道远远不止如此,但是我们的文档确实到此为止了,后续应该还会有扩展。让我们后面再见,感谢大家的观看,祝大家能够学习愉快。