[TOC]
# F_JS30 11-20
## 11 Custom HTML5 Video Player
一開始長這樣,什麼也沒有

先取得元素
```javascript!
const player = document.querySelector("video");
const video = player.querySelector(".viewer");
const progress = player.querySelector(".progress");
const progressBar = player.querySelector(".progress__filled");
const toggle = player.querySelector(".toggle");
const ranges = player.querySelectorAll(".player__slider");
const skipButtons = player.querySelectorAll("[data-skip]");
```
[play() method](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play)
`video.paused` 是一個屬性,是判斷影片是否正在暫停狀態
影片暫停, `video.paused` 會顯示 true ,反之顯示 false。作者透過這個來做一些判斷。
`console.dir(video)` 可以查看到此屬性:

先監聽`video`有click事件時,觸發影片切換播放或暫停
```javascript!
// 切換播放或暫停
function togglePlayer() {
const method = video.paused ? "play" : "pause";
video[method]();
}
video.addEventListener("click", togglePlayer);
```
再來監聽`video`是play事件、pause事件,都觸發按鈕的圖示,以及監聽按鈕是click事件,觸發切換影片播放或暫停
```javascript!
// 設定播放鈕圖示
function updateButton() {
const icon = this.paused ? "►" : "❚❚";
toggle.textContent = icon;
}
video.addEventListener("play", updateButton);
video.addEventListener("pause", updateButton);
toggle.addEventListener("click", togglePlayer);
```
再來製作skip button,一個是退後10秒,一個是往前25秒
`this.dataset.skip` 是字串,所以要轉成數字讓 `video.currentTime` 可以加。
```javascript!
// 設定skip按鈕
function skip() {
console.log(this.dataset.skip);
video.currentTime += parseFloat(this.dataset.skip);
}
skipButtons.forEach((button) => button.addEventListener("click", skip));
```
Q:為什麼用parseFloat轉成數字?是因為影片秒數是小數點關係嗎?

設定音量和播放速率
```html!
<input
type="range"
name="volume"
class="player__slider"
min="0"
max="1"
step="0.05"
value="1"
/>
<input
type="range"
name="playbackRate"
class="player__slider"
min="0.5"
max="2"
step="0.1"
value="1"
/>
```
作者特別將這兩個 range 的 name 設定跟 video 裡面的屬性一樣名字:`name="volume"`、`name="playbackRate"`


```javascript!
// 設定音量和播放速率
function handleRange() {
video[this.name] = this.value;
}
ranges.forEach((range) => range.addEventListener("change", handleRange));
ranges.forEach((range) => range.addEventListener("mousemove", handleRange));
```
最後是進度條了!
因為是用 flex-basis 來顯示播放進度位置,所以設計 flex-basis 的百分比對應影片時間
```css!
.progress__filled {
width: 50%;
background: #ffc600;
flex: 0;
flex-basis: 50%;
}
```
```javascript!
// 設定進度條顏色位置
function handleProgress() {
const percent = (video.currentTime / video.duration) * 100;
progressBar.style.flexBasis = `${percent}%`;
}
video.addEventListener("timeupdate", handleProgress);
```
作者說用 timeupdate 事件來觸發
根據影片播放不斷更新百分比

---
進度條也需要有點擊的方式
所以去找整個進度條的 offset
可以看到progress的是640px,如點擊一半就是 offetX 屬性為320的位置

透過console.log(e)來查看我click progress中的offsetX為多少

```javascript!
// 設定進度條可以點擊要的位置
function scrub(e) {
const scrubTime = (e.offsetX / progress.offsetWidth) * video.duration;
video.currentTime = scrubTime;
}
progress.addEventListener("click", scrub);
progress.addEventListener("mousemove", scrub);
```
但影片跟不上mousemove位置,所以需要再寫條件判斷
```javascript!
let mousedown = false;
progress.addEventListener("click", scrub);
progress.addEventListener("mousemove", (e) => mousedown && scrub(e));
progress.addEventListener("mousedown", () => (mousedown = true));
progress.addEventListener("mouseup", () => (mousedown = false));
```
`(e) => mousedown && scrub(e)` 這寫法特別??
Q:offsetWidth是蝦咪?元素的完整可見寬度
Q:progress.offsetWidth為什麼不是this.offsetWidth?
完整程式碼:
```javascript!
// 取得元素
const player = document.querySelector(".player");
const video = player.querySelector(".viewer");
const progress = player.querySelector(".progress");
const progressBar = player.querySelector(".progress__filled");
const toggle = player.querySelector(".toggle");
const ranges = player.querySelectorAll(".player__slider");
const skipButtons = player.querySelectorAll("[data-skip]");
// 切換播放或暫停
function togglePlayer() {
const method = video.paused ? "play" : "pause";
video[method]();
}
// 設定播放鈕圖示
function updateButton() {
const icon = this.paused ? "►" : "❚❚";
toggle.textContent = icon;
}
// 設定skip按鈕
function skip() {
video.currentTime += parseFloat(this.dataset.skip);
}
// 設定音量和播放速率
function handleRange() {
video[this.name] = this.value;
}
// 設定進度條顏色位置
function handleProgress() {
const percent = (video.currentTime / video.duration) * 100;
progressBar.style.flexBasis = `${percent}%`;
}
// 設定進度條可以點擊要的位置
function scrub(e) {
const scrubTime = (e.offsetX / progress.offsetWidth) * video.duration;
video.currentTime = scrubTime;
}
// 連接監聽
video.addEventListener("click", togglePlayer);
video.addEventListener("play", updateButton);
video.addEventListener("pause", updateButton);
video.addEventListener("timeupdate", handleProgress);
toggle.addEventListener("click", togglePlayer);
skipButtons.forEach((button) => button.addEventListener("click", skip));
ranges.forEach((range) => range.addEventListener("change", handleRange));
ranges.forEach((range) => range.addEventListener("mousemove", handleRange));
let mousedown = false;
progress.addEventListener("click", scrub);
progress.addEventListener("mousemove", (e) => mousedown && scrub(e));
progress.addEventListener("mousedown", () => (mousedown = true));
progress.addEventListener("mouseup", () => (mousedown = false));
```
## 12 JavaScript KONAMI CODE!
輸入一段特定字串之後出現特定的畫面,稱為 key senquence。
把輸入的key,push到 pressed 陣列
```javascript!
window.addEventListener("keyup", (e) => {
pressed.push(e.key);
// 用來設計pressed的陣列長度都不會超過secretCode的長度
console.log(pressed);
```

### [Array.prototype.splice()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice)
作者為了要讓 pressed 陣列長度都不會超過secretCode的長度
```javascript!
pressed.splice(
-secretCode.length - 1,
pressed.length - secretCode.length
);
```
- `-secretCode.length - 1` :意思是從 pressed 陣列最後面往前數到-5的位置,因為`secretCode.length`長度為4,只要長度超過4,就刪除
- `pressed.length - secretCode.length`:這個位置是要刪除元素數量。所以長度超過 `secretCode.length` 的元素都是多餘元素,讓 pressed 的長度都保持不超過 `secretCode.length`
- 結果會是`splice(-5,1)`,表示從陣列的倒數第5個元素開始,刪除1個元素
那既然都是刪除最前面一個字,為什麼不直接寫0就好?
測試這樣寫也沒問題XD
```javascript!
pressed.splice(0, pressed.length - secretCode.length);
```
可以看到第5個開始,就開始刪除最前面的元素,維持陣列內是4個元素,直到符合secretCode字串就執行之後的程式碼。

完整程式碼:
```javascript!
const pressed = [];
const secretCode = "fang";
window.addEventListener("keyup", (e) => {
pressed.push(e.key);
// 用來設計pressed的陣列長度都不會超過secretCode的長度
pressed.splice(
-secretCode.length - 1,
pressed.length - secretCode.length
);
if (pressed.join("").includes(secretCode)) cornify_add();
});
// cornify.js檔案是作者另外引入的,會執行裡面的事情
```

## 13 Vanilla JavaScript Slide In on Scroll
使用 scroll 事件,會發現他不斷觸發
例如將此網頁從頭滾動到底部,就觸發了 scroll 事件179次

透過 debounce 函式,讓整個事件觸發幾次就好
作者也是上網找 debounce 函式套用而已
```javascript!
function debounce(func, wait = 20, immediate = true) {
var timeout;
return function () {
var context = this,
args = arguments;
var later = function () {
timeout = null;
if (!immediate) func.apply(context, args);
};
var callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
}
function checkSlide(e) {
console.count(e);
}
const slideImages = document.querySelectorAll(".slide-in");
window.addEventListener("scroll", debounce(checkSlide)); // 透過debounce減少checkSlide觸發次數
```
可以看到同樣都是從頭滾動到底部,觸發次數只有13次

[debounce 防抖函式](https://www.explainthis.io/zh-hant/swe/debounce)
再來設計當滾動到此圖片的高度50%,就增加圖片的動畫滑入
[window.innerHeight](https://developer.mozilla.org/en-US/docs/Web/API/Window/innerHeight):是視窗高度
[window.scrollY](https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollY):是瀏覽器頂部向下滾動多少
好難懂作者的計算方式。。。。先照抄
完整程式碼:
```javascript!
function debounce(func, wait = 20, immediate = true) {
var timeout;
return function () {
var context = this,
args = arguments;
var later = function () {
timeout = null;
if (!immediate) func.apply(context, args);
};
var callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
}
const slideImages = document.querySelectorAll(".slide-in");
function checkSlide() {
slideImages.forEach((slideImage) => {
// 判斷視窗底部到達圖片中線
const slideInAt =
window.scrollY + window.innerHeight - slideImage.height / 2;
// 用來判斷視窗是否滾動過圖片的底部
const imageBottom = slideImage.offsetTop + slideImage.height;
// 判斷圖片的中線是否到視窗
const isHalfShown = slideInAt > slideImage.offsetTop;
// 判斷圖片是否還未滾出視窗
const isNotScrolledPast = window.scrollY < imageBottom;
if (isHalfShown && isNotScrolledPast) {
slideImage.classList.add("active");
} else {
slideImage.classList.remove("active");
}
});
}
window.addEventListener("scroll", debounce(checkSlide));
```
## 14 JavaScript Fundamentals: Reference VS Copy
這邊講「傳值」概念
```javascript!
let age = 100;
let age2 = age;
console.log(age, age2); // 100 100
age = 200;
console.log(age, age2); // 200 100
let name = "fang";
let name2 = name;
console.log(name, name2); // fang fang
name = "Vicky";
console.log(name, name2); // Vicky fang
```
這邊講「傳址」概念
```javascript!
const players = ["Wes", "Sarah", "Ryan", "Poppy"];
const team = players;
team[3] = "Peter";
console.log(team); // ["Wes","Sarah","Ryan","Peter"]
console.log(players); // ["Wes","Sarah","Ryan","Peter"]
```
複製array的方法(不會有傳參考)
1. slice方法,shallow copy一個陣列,這樣就不會改到原陣列
```javascript!
const players = ["Wes", "Sarah", "Ryan", "Poppy"];
const result = players.slice();
console.log(result); // ["Wes", "Sarah", "Ryan", "Poppy"]
result[3] = "Vicky";
console.log(result); // ["Wes", "Sarah", "Ryan", "Vicky"] 索引值3的值改成"Vicky"
console.log(players); // ["Wes", "Sarah", "Ryan", "Poppy"] 值沒有因為result變動
```
2. concat()
```javascript!
const players = ["Wes", "Sarah", "Ryan", "Poppy"];
const copyArray = [].concat(players);
console.log(copyArray); // ["Wes", "Sarah", "Ryan", "Poppy"]
```
3. ...展開運算子
```javascript!
const players = ["Wes", "Sarah", "Ryan", "Poppy"];
const result = [...players];
console.log(result); // ["Wes", "Sarah", "Ryan", "Poppy"]
```
4. Array.from()
```javascript!
const players = ["Wes", "Sarah", "Ryan", "Poppy"];
const result3 = Array.from(players);
console.log(result); // ["Wes", "Sarah", "Ryan", "Poppy"]
```
物件也有傳址
```javascript!
const person = {
name: "Wes Bos",
age: 80,
};
const answer = Object.assign({}, person, { address: "Tainan" });
console.log(answer); // {name: 'Wes Bos', age: 80, address: 'Tainan'}
console.log(person); // {name: 'Wes Bos', age: 80}
```
Object.assign 只能對第一層屬性shallow copy ,第二層還是會被修改
```javascript!
const family = {
parents: {
dad: "David",
mom: "Esther",
},
country: "Israel",
familyNumber: 2,
};
// shallow copy
const dev = Object.assign({}, family);
dev.parents.mom = "Maria";
console.log(family);
console.log(dev);
```

如需要deep copy,須使用JSON.stringify先轉成字串,再用JSON.parse轉回物件,這樣修改第二層之後的內容都不會動到原物件
```javascript!
const dev2 = JSON.parse(JSON.stringify(family));
dev2.parents.dad = "Peter";
console.log(family);
console.log(dev2);
```
- 使用JSON.stringify(family),回傳物件的字串
```
'{"parents":{"dad":"David","mom":"Maria"},"country":"Israel","familyNumber":2}'
```
- 再使用JSON.parse(),把物件的字串解析,轉回成物件

修改物件第二層資料:
```javascript!
dev2.parents.dad = "Peter";
console.log(family);
console.log(dev2);
```
family的資料不會被改動

## 15 How LocalStorage and Event Delegation work
```html!
<div class="wrapper">
<h2>LOCAL TAPAS</h2>
<p></p>
<ul class="plates">
<li>Loading Tapas...</li>
</ul>
<form class="add-items">
<input type="text" name="item" placeholder="Item Name" required />
<input type="submit" value="+ Add Item" />
</form>
</div>
```
```javascript!
const addItems = document.querySelector(".add-items");
const plates = document.querySelector(".plates");
const itemList = JSON.parse(localStorage.getItem("food")) || [];
populateList(itemList, plates);
// 新增品項
function addItem(event) {
event.preventDefault();
const inputText = this.querySelector("[name=item]").value;
const item = {
inputText,
done: false,
};
itemList.push(item);
populateList(itemList, plates);
localStorage.setItem("food", JSON.stringify(itemList));
this.reset(); // 表單回初始狀態
}
```
Q1: `document.querySelector` 的 `document` 是什麼?
`document` 是window物件之一,代表整個HTML文件(DOM)。
```javascript!
const addItems = document.querySelector(".add-items");
```
所以這段程式碼意思是,JS會從HTML文件中找尋class為 `add-items` 的元素並存入變數 `addItems` 。如有符合就回傳第一個找到的元素,如沒有則回傳`null`。
當縮小到特定範圍了,就可以直接在該元素使用`querySelector`,不需要再用`document.querySelector()`,因為JS只會在該元素內尋找,而不是整個`document`,會更有效率。
例如:
```javascript!
const addItems = document.querySelector(".add-items");
const text = addItems.querySelector("input[name=item]"); // 確保抓取到<input>,可加上標籤名稱
// 寫法等同
document.querySelector(".add-items").querySelector("input[name=item]")
```
Q2: 為什麼不是監聽 `input[type="text"]` 按鈕就好?而是監聽整個 `<form>` 表單?
- 監聽 `<form>` 的 `submit`事件,確保觸發表單提交的方式都被攔截並處理,例如:輸入後可以直接按enter鍵提交。
- 監聽 `<form>` 會比針對單一 `input[type="submit"]` 更靈活,例如:表單新增其他元素提交,就不需修改程式碼。
### event delegation 事件委派
因為事件傳遞機制是先捕捉後冒泡,所以不管點擊任何li都會回到ul身上,所以把listener放在ul,透過父節點統一處理子節點的事件就是事件委派。
[Huli - DOM 的事件傳遞機制:捕獲與冒泡](https://blog.techbridge.cc/2017/07/15/javascript-event-propagation/)
Q3: 這裡的this指向誰?
```javascript!
addItems.addEventListener("submit", function(e){
console.log(this); // ?
const text = this.querySelector("[name=item]").value;
});
```
- 當使用普通函式function時,this指向「觸發事件的元素」,就是`addItems`,也就是`<form>`,當提交表單時,addItem函式才會被執行。
> 測試輸入abc,並提交,this印出`<form>`內容

- 使用箭頭函式,因為父層this是什麼,箭頭函式的this就是什麼
```javascript!
addItems.addEventListener("submit",(e)=>{
console.log(this); // 這裡的`this`是指向`window`
});
```
Q4: 事件處理的參數(e / event)?
是==event物件(事件物件)==,當事件發生時,瀏覽器自動把event物件傳進事件處理函式,如不需使用到event物件,則可以省略不寫。
### event.preventDefault()
為了阻止瀏覽器的預設行為。
瀏覽器默認情況下,當表單提交時會刷新頁面,或將數據發送到server端 ; 不是所有事件都有預設行為。
[Event: preventDefault() method](https://developer.mozilla.org/en-US/docs/Web/API/Event/preventDefault)
```javascript!
function addItem(e){
console.log(e);
}
```
查找 event 物件中的 preventDefault 方法,從`SubmitEvent物件`沿著原型在`Event物件`找到了~
[SubmitEvent](https://developer.mozilla.org/en-US/docs/Web/API/SubmitEvent)
[Event](https://developer.mozilla.org/en-US/docs/Web/API/Event)


### EventTarget
> Element, and its children, as well as Document and Window, are the most common event targets, but other objects can be event targets, too.
> 最常見的eventTarget,例如:element和其子元素、document、window
EventTarget 的實例方法:
- `EventTarget.addEventListener()`
- `EventTarget.removeEventListener()`
- `EventTarget.dispatchEvent()`
```javascript!
const addItems = document.querySelector(".add-items");
addItems.addEventListener("submit", function(e){
console.log(this);
});
```
因為 `document.querySelector(".add-items")` 會回傳一個 HTMLElement,HTMLElement 繼承自 Element,Element 又繼承自 Node,Node 最終繼承自 EventTarget。所以平常使用`document.querySelector(".add-items")` 才可以使用 EventTarget的方法!
`(addEventListener/removeEventListener/dispatchEvent)`
### reset()
將表單欄位初始狀態
### 物件縮寫
- 屬性縮寫:屬性與變數名稱相同,可直接寫變數名稱
```javascript!
let name = "David";
let age = 18;
// 一般寫法
const person = {
name:name,
age:age
}
// 屬性縮寫
const person = {name,age}; // {name: 'David', age: 18}
```
試著輸入noodles和pizza並按新增,印出資料如下:

```javascript!
function addItem(){
// ...
populateList(itemList, plates);
}
// 渲染HTML
function populateList(itemList, plates) {
plates.innerHTML = itemList
.map((plate, index) => {
return `
<li>
<input type="checkbox" id="item${index}" data-index=${index} ${
plate.done ? "checked" : ""
}/>
<label for="item${index}">${plate.inputText}</label>
</li>
`;
})
.join("");
}
```
物件`done:false` 是什麼作用?A:之後用來判斷checkbox是否checked
在`<input>`裡面寫是否checked的判斷
```javascript!
${plate.done ? "checked" : ""}
```
Q5: 為什麼要寫三元判斷?A:用來判斷每個品項的狀態是否打勾,因為當重新渲染頁面時,有打勾的就維持。
渲染HTML出來後長這樣:

因為刷新頁面後,剛剛輸入的內容都不會保留,所以需要出動 `localStorage` 本地儲存。
### localStorage
可將文本儲存在瀏覽器的儲存空間
[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage)
1. 是`window`裡面的物件

2. 在devTools的 `application` 可以查看

3. 只能儲存`String`,如果不是字串,會自己轉型,例如Object轉成`"[Object Object]"`。使用`JSON.stringify`把物件轉字串
> The keys and the values stored with localStorage are always in the UTF-16 string format, which uses two bytes per character. As with objects, integer keys are automatically converted to strings.
4. 語法
```javascript!
// key和value分別對應local Storage的key和value,可自訂
localStorage.setItem("key","value"); // 新增資料
localStorage.getItem("key"); // 讀取資料
localStorage.removeItem("key"); // 移除資料
```
---
```javascript!
const items = JSON.parse(localStorage.getItem("items")) || [];
function addItem(){
// ...
localStorage.setItem("items", JSON.stringify(items));
}
populateList(items, itemsList);
```

刷新頁面,資料都不會消失(但會發現原本打勾的又沒打勾了)

Q6:`JSON.parse(localStorage.getItem("food"))`拿到不是物件嗎?這樣怎麼運作array methods?
A: `JSON.parse(localStorage.getItem("food"))` 解析出來的值會是一個 `array` !! (自己看錯XD)
截圖確認


頁面加載的時候,檢查`const items = JSON.parse(localStorage.getItem("items")) || [];`是否有東西,沒東西就給空陣列
---
最後因為頁面每次加載後,有 checked 的會變成沒打勾狀態,所以新增 toggleDone 函式處理。
### event.target
因為每次點擊勾選時,都會同時兩個 pointerEvent 出現,印出來看是 `<label>` 和 `<input type="checkbox">`


所以用 `event.target` 方式,假如不是 `"input"` 的就跳過。所以確保選到`<input>`標籤。
運用加在 `<input type="checkbox">` 的 `data-index=${index}`,將itemList陣列的每一個元素的done做反向(true->false或 false->true)
完成後把結果儲存在localStorage,並將結果渲染至頁面。
```javascript!
// 切換勾選狀態
function toggleDone(event) {
if (!event.target.matches("input")) return;
const el = event.target;
const index = el.dataset.index;
itemList[index].done = !itemList[index].done;
localStorage.setItem("food", JSON.stringify(itemList));
populateList(itemList, plates);
}
```
[target property](https://developer.mozilla.org/en-US/docs/Web/API/Event/target)
完整程式碼:
```javascript!
const addItems = document.querySelector(".add-items");
const plates = document.querySelector(".plates");
const itemList = JSON.parse(localStorage.getItem("food")) || [];
populateList(itemList, plates);
// 新增品項
function addItem(event) {
event.preventDefault();
const inputText = this.querySelector("[name=item]").value;
const item = {
inputText,
done: false,
};
itemList.push(item);
populateList(itemList, plates);
localStorage.setItem("food", JSON.stringify(itemList));
this.reset();
}
// 渲染畫面
function populateList(itemList, plates) {
plates.innerHTML = itemList
.map((plate, index) => {
return `
<li>
<input type="checkbox" id="item${index}" data-index=${index} ${
plate.done ? "checked" : ""
}/>
<label for="item${index}">${plate.inputText}</label>
</li>
`;
})
.join("");
}
// 切換勾選狀態
function toggleDone(event) {
if (!event.target.matches("input")) return;
const el = event.target;
const index = el.dataset.index;
itemList[index].done = !itemList[index].done;
localStorage.setItem("food", JSON.stringify(itemList));
populateList(itemList, plates);
}
addItems.addEventListener("submit", addItem);
plates.addEventListener("click", toggleDone);
```
## 16 CSS Text Shadow on Mouse Move Effect
offsetWidth / offsetHeight是元素實際顯示的寬度/高度
包含:width、padding、border,但不包含margin
```javascript!
const hero = document.querySelector(".hero");
const text = hero.querySelector("h1");
function addShadow(e) {
// div的實際顯示寬高
const { offsetWidth: width, offsetHeight: height } = hero;
// mousemove事件的offset
let { offsetX: x, offsetY: y } = e;
// 計算滑鼠在div的相對位置
const xWalk = x / width;
const yWalk = y / height;
console.log(xWalk, yWalk);
}
hero.addEventListener("mousemove", addShadow);
```
橘色框是div,紅色框是h1

可以看到,當滑鼠滑到h1時,因為h1是div裡面的元素,offset會變成h1去計算,為了讓滑鼠移動不會因為裡面有元素而重新計算,判斷如下:
```javascript!
if (this !== e.target) {
x += e.target.offsetLeft;
y += e.target.offsetTop;
}
```
offsetLeft與offsetTop的理解圖:

補充:
用`clientX / clientY`就可以避免元素不同的offset問題!不用再寫if判斷(灑花~~~)
```javascript!
function addShadow(e) {
// div的實際顯示寬高
const { offsetWidth: width, offsetHeight: height } = hero;
const { clientX: currentX, clientY: currentY } = e;
}
```
設定陰影的偏移量,作者設定100px,表示偏移量為 -50px~50px ,左上角為 `(-50,-50)`,右下角為 `(50,50)`
用Math.round取四捨五入
```javascript!
const xWalk = Math.round((currentX / width) * walk - walk / 2);
const yWalk = Math.round((currentY / height) * walk - walk / 2);
```
最後加上text也就是h1的陰影樣式,可以加上多個,設定不同方向的陰影
```javascript!
text.style.textShadow = `
${xWalk}px ${yWalk}px 0 pink,
${xWalk * -1}px ${yWalk}px 0 aqua,
${xWalk}px ${yWalk * -1}px 0 gray
`;
```

## 17 Sorting Band Names without articles