從零開始實現一個顏色選擇器(原生JavaScript實現)

夕水發表於2021-10-10

準備工作

專案目錄與檔案建立

首先,我們無需搭建專案的環境,我們還是直接用最簡單的方式,也就是引入的方式來建立這個專案,這樣也就方便了我們一邊編寫一邊測試。建立一個空目錄,命名為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檔案,如下所示:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>color-picker</title>
    <link rel="stylesheet" href="./color-picker.css" />
  </head>
  <body></body>
  <script src="./color-picker.js"></script>
</html>

做好這些準備工作之後,讓我們繼續下一步。

結構與佈局

模組分析

我們通過如下一張圖來分析我們要實現的模組,如下圖所示:

正如上圖所示,我們可以將一個顏色選擇器拆分成多個模組,所以我們大致得到了一個結構如下:

  • 顏色色塊
  • 顏色皮膚
  • 色調柱
  • 透明度柱
  • 輸入框
  • 清空與確定按鈕
  • 預定義顏色元素列表

這樣一來,我們可以清晰的看到整個顏色選擇器都有哪些模組。我們目前只需要考慮開發出基本的模組功能,然後後續就在基礎上開始進行擴充套件和完善。好的,讓我們繼續下一步,搭建頁面的基本結構。

色塊模組

通過分析,我們應該知道,色塊分成兩種情況,第一種就是有顏色值時,色塊應該是一個背景色為該顏色值的左右箭頭。就像如下圖所示:

而無顏色值,我們的色塊應該是如下圖所示:

如此一來,我們就確定了色塊的結構元素,如下:

<div class="ew-color-picker-box">
  <!-- 有顏色值,這裡我們並沒有使用任何圖示,用css來實現一個看起來就像下拉箭頭一樣 -->
  <div class="ew-color-picker-arrow">
    <div class="ew-color-picker-arrow-left">
      <div class="ew-color-picker-arrow-right"></div>
      <!-- 無顏色值 -->
      <div class="ew-color-picker-no">&times;</div>
    </div>
  </div>
</div>

這裡我們肯定是通過一個顏色值來確定使用哪一個結構的,這個後續我們再說。我們現在就先確定色塊的元素結構應該是如下這樣呢。當然這裡的類名也可以是自己隨便自定義。

tips:我這裡是為了有自己的特色,所以加了ew-字首名。如果你自己使用自己自定義的類名,那麼你後續編寫樣式和操作 DOM 元素的時候需要注意,要去更改。

還有注意&times;它是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:centeralign-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:這裡留一個懸念,為什麼要使用標準盒子模型。

到此為止,我們的容器元素就分析完成了,接下來開始編寫結構與樣式。

<div class="ew-color-picker">
  <!-- 當然裡面的結構後續再分析 -->
</div>
.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 個皮膚元素疊加出來就是這種效果。一個白色的背景加一個黑色的就能疊加看到我們想要的效果。
比如我們先來看看一個示例:

<div class="panel">
  <div class="white-panel"></div>
  <div class="black-panel"></div>
</div>
.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);
}

這裡可能又涉及到一個知識點,那就是漸變顏色,這裡就不做細講,感興趣的可檢視文件

所以我們的結構應該是如下:

<div class="ew-color-picker-content">
  <div class="ew-color-picker-panel">
    <div class="ew-color-picker-white-panel"></div>
    <div class="ew-color-picker-black-panel"></div>
  </div>
</div>

根據前面那個示例,我們很快就能寫出這個顏色皮膚了,不過我們還少了一個,也就是在顏色皮膚區域之內的拖動元素,或者我們可以稱之為遊標元素。

.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;的原因。

<div class="ew-color-picker-content">
  <div class="ew-color-picker-panel">
    <!-- 省略了一些內容,遊標元素新增 -->
    <div class="ew-color-picker-panel-cursor"></div>
  </div>
</div>

這裡需要注意了,遊標元素設定的寬高會影響我們後續計算,所以在這裡設定的寬高是多少,後續計算就要將它的寬高考慮在內,這個到後面會細講,現在,我們還是編寫該元素的樣式吧。

.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-radius50%即可以將一個元素變成圓。接下來就是陰影部分,這樣就實現了我們的小圓圈。當然我們不一定非要實現這樣的效果,但是為了還原顏色選擇器本身,也方便後續的計算,所以我們還是採用原本的樣式。

色階柱

接下來,我們來看一下色階柱也就是色調柱的實現。看到這個圖,我們應該可以很清晰的分出色階柱包含了 2 個部分,第一個部分就是柱形部分,稱之為 bar,第二個部分就是拖動滑塊部分,稱之為 thumb。然後我們外加一個容器元素用於包含色階柱和透明柱,所以我們可以確定色階柱的結構如下:

<!-- 容器元素 -->
<div class="ew-color-slider ew-is-vertical">
  <div class="ew-color-slider-bar">
    <div class="ew-color-slider-thumb"></div>
  </div>
</div>

然後我們來確定樣式的實現,首先整個色階柱是垂直佈局的,所以我們應該知道它就是有一個固定寬度,然後高度等價於顏色皮膚的矩形,它的背景色通過一種漸變色來實現,實際上就是紅橙黃綠青藍紫七種顏色的混合,也就類似彩虹。這每一種顏色都有不同的比例。其次我們還要知道滑塊部分是需要動態拖動的。在這裡我們可以想象得到色階柱可以是水平或者垂直佈局的,目前我們先實現垂直佈局(為了區分給容器元素加一個類名 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 值。所以我們得到結構如下所示:

<div class="ew-alpha-slider-bar">
  <!-- 背景圖 -->
  <div class="ew-alpha-slider-wrapper"></div>
  <!-- 背景色 -->
  <div class="ew-alpha-slider-bg"></div>
  <!-- 滑塊元素 -->
  <div class="ew-alpha-slider-thumb"></div>
</div>

在這裡,我們需要注意的一點就是背景色條的背景色是動態改變,這將在後面會講到。背景色條,我們同樣是通過線性漸變來實現的。讓我們來看看樣式吧:

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

好了,到目前為止,我們的透明度柱也就實現了,接下來我們來看輸入框的實現。

輸入框與按鈕

輸入框比較簡單,我想沒什麼好說的,這個輸入框也可以自定義,它的結構無非就是如下:

<input class="ew-color-input" />

它和清空與確定按鈕元素排在一行,因此我們用一個容器元素來包裹它們,結構應該如下:

<div class="ew-color-drop-container">
  <input class="ew-color-input" />
  <div class="ew-color-drop-btn-group">
    <button type="button" class="ew-color-drop-btn ew-color-clear">清空</button>
    <button type="button" class="ew-color-drop-btn ew-color-sure">確定</button>
  </div>
</div>

然後樣式也沒有什麼好分析的,都是一些基礎樣式,我們繼續編寫程式碼。如下:

.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 個子元素來分別代表四種情況的樣式。如下:

<div class="ew-pre-define-color-container">
  <div class="ew-pre-define-color" tabindex="0"></div>
  <div class="ew-pre-define-color ew-has-alpha" tabindex="1"></div>
  <div
    class="ew-pre-define-color ew-pre-define-color-disabled"
    tabindex="2"
  ></div>
  <div
    class="ew-pre-define-color ew-pre-define-color-active"
    tabindex="3"
  ></div>
</div>

接下來,我們來看樣式的實現:

.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 += `
            <div class="ew-pre-define-color${hasAlpha(renderColor)}${renderDisabled}" tabindex=${index}>
                <div class="ew-pre-define-color-item" style="background-color:${renderColor};"></div>
            </div>`;
        })
    };
    //開啟顏色選擇器的方框
    const colorBox = config.defaultColor ? `<div class="ew-color-picker-arrow" style="width:${this._private.boxSize.b_width};height:${this._private.boxSize.b_height};">
        <div class="ew-color-picker-arrow-left"></div>
        <div class="ew-color-picker-arrow-right"></div>
    </div>` : `<div class="ew-color-picker-no" style="width:${this._private.boxSize.b_width};height:${this._private.boxSize.b_height};line-height:${this._private.boxSize.b_height};">&times;</div>`;
    //透明度
    if (config.alpha) {
        alphaBar = `<div class="ew-alpha-slider-bar">
            <div class="ew-alpha-slider-wrapper"></div>
            <div class="ew-alpha-slider-bg"></div>
            <div class="ew-alpha-slider-thumb"></div>
        </div>`;
    }
    // hue
    if (config.hue) {
        hueBar = `<div class="ew-color-slider-bar"><div class="ew-color-slider-thumb"></div></div>`;
    }
    if (predefineColorHTML) {
        predefineHTML = `<div class="ew-pre-define-color-container">${predefineColorHTML}</div>`;
    }
    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 = `<div class="ew-color-picker-box ${boxDisabledClassName}" tabIndex="0" style="${boxStyle}">${colorBox}</div>`;
    }
    if (config.hasClear) {
        clearHTML = `<button class="ew-color-clear ew-color-drop-btn">${ config.clearText }</button>`;
    }
    if (config.hasSure) {
        sureHTML = `<button class="ew-color-sure ew-color-drop-btn">${ config.sureText }</button>`;
    }
    if (config.hasClear || config.hasSure) {
        btnGroupHTML = `<div class="ew-color-drop-btn-group">${clearHTML}${sureHTML}</div>`;
    }
    if (config.hasColorInput) {
        inputHTML = '<input type="text" class="ew-color-input">';
    }
    if (config.openChangeColorMode) {
        if (!config.alpha || !config.hue) return util.ewError(ERROR_VARIABLE.COLOR_MODE_ERROR);
        openChangeColorModeHTML = `<div class="ew-color-mode-container">
        <div class="ew-color-mode-up"></div>
        <div class="ew-color-mode-down"></div>
        </div>`;
        openChangeColorModeLabelHTML = `<label class="ew-color-mode-title">${this.colorMode[1]}</label>`;
    }
    if (config.hasColorInput || config.hasClear || config.hasSure) {
        dropHTML = config.openChangeColorMode ? `<div class="ew-color-drop-container ew-has-mode-container">
        ${openChangeColorModeLabelHTML}${inputHTML}${openChangeColorModeHTML}
        </div><div class="ew-color-drop-container">
        ${btnGroupHTML}
        </div>` : `<div class="ew-color-drop-container">
        ${inputHTML}${btnGroupHTML}
        </div>`;
    }
    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 = `<div class="ew-color-slider ew-is-horizontal">${ horizontalSliderHTML }</div>`
    }
    if(verticalSliderHTML){
        verticalSliderHTML = `<div class="ew-color-slider ew-is-vertical">${ verticalSliderHTML }</div>`;
    }
    //顏色選擇器
    const html = `${boxHTML}
        <div class="ew-color-picker">
            <div class="ew-color-picker-content">
                ${ verticalSliderHTML }
                <div class="ew-color-panel" style="background:red;">
                    <div class="ew-color-white-panel"></div>
                    <div class="ew-color-black-panel"></div>
                    <div class="ew-color-cursor"></div>
                </div>
            </div>
            ${ horizontalSliderHTML }
            ${dropHTML}
            ${predefineHTML}
        </div>`;
    element.setAttribute("color-picker-id",this._color_picker_uid);
    element.innerHTML = `<div class="ew-color-picker-container">${ html }</div>`;
    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來判斷是開啟還是關閉顏色選擇器,核心的就是openclose方法,兩者都接收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);
}

可以看到,我們再openclose方法內部對時間做了一次限制處理,然後判斷動畫型別來決定呼叫哪種動畫來實現顏色選擇器的開啟和關閉。到這裡,我們還少實現了一個方法,那就是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個方法setAlphaHuePositionchangeElementColor。我們分別來看一下:

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偏移量,然後分別設定它們,再然後設定顏色皮膚的背景色。以及設定色彩柱的偏移量。如果透明度柱子存在,則也要設定透明度柱子的偏移量。

到目前為止,我們所要實現的顏色選擇器的基本功能就已經完成,接下來,我們來對我們的文件做一個總結。我們從分析每一個顏色選擇器的模組開始,對應的結構及樣式我們都是一一分析了,然後再細化到每一個功能。每一個顏色選擇器的模組如下:

  • 顏色色塊
  • 顏色皮膚
  • 色調柱
  • 透明度柱
  • 輸入框
  • 清空與確定按鈕
  • 預定義顏色元素列表

再然後,我們對照每一個模組去一一實現它們的功能。在這些功能中,我們學到了哪些東西呢?

  1. 閉包。(也就是說我們在某一個作用域中訪問其它作用域中的變數。例如:bindEvent方法的實現)
  2. 定時器。 (如動畫函式的實現)
  3. 顏色轉換演算法。
  4. 正規表示式。
  5. 物件導向的程式設計。
  6. 如何實現點選目標區域之外的邏輯功能

當然還有很多,細細品味下來,我們應該知道遠遠不止如此,但是我們的文件確實到此為止了,後續應該還會有擴充套件。讓我們後面再見,感謝大家的觀看,祝大家能夠學習愉快。

如果覺得本文不夠詳細,可以檢視視訊課程,感謝支援。當然你也可以檢視原始碼

相關文章