[TOC]
# 21-Geolocation based Speedometer and Compass
使用定位來讓羅盤轉動跟顯示速率,但他介紹的`navigator.geolocation.watchPosition()`裡面拿到的`heading`跟`speed`都是`null`,所以我跟Chris去找了別的API來接接看,目前`deviceorientation`這個事件成功了!裡面的`alpha`只向南邊!!!
~~但目前還不知道原因,必須先用點擊事件觸發對裝置的請求~~(`DeviceOrientationEvent.requestPermission()`)才能使用`deviceorientation`跟`devicemotion`,~~然後跟Chris實驗的時候嘗試把請求拿掉又可以正常運作,一直到隔天就又失敗了,然後我又發一次請求,之後也不發請求就能用了,所以我還不確定他需要重新發請求的時機~~:
已經找到需要請求的時機了,只要safari關掉重開就需要請求:
```javascript=
const startBtn = document.querySelector("body");
startBtn.addEventListener("click", () => {
DeviceOrientationEvent.requestPermission().then(() => {
addEventListener("deviceorientation", (e) => {
console.log(`rotate(${e.alpha}}deg)`);
arrow.style.transform = `rotate(${e.alpha + 180}deg)`;
});
});
});
```
---
之後改成這樣也可以正常運作:
```javascript=
const arrow = document.querySelector(".arrow");
const speed = document.querySelector(".speed-value");
addEventListener("deviceorientation", (e) => {
console.log(`rotate(${e.alpha}}deg)`);
arrow.style.transform = `rotate(${e.alpha + 180}deg)`;
});
```
所以羅盤轉動就簡單將`arrow`做`transform: rotate()`就成功了!
另外也找到`devicemotion`可以得到更多資訊,`acceleration.x`跟`acceleration.y`分別代表水平與垂直的晃動力度偵測,可以做計步器~~
```javascript
function start() {
addEventListener("devicemotion", (event) => {
console.log(
Math.floor(event.acceleration.x),
Math.floor(event.acceleration.y)
);
});
}
```
[指南针WebApp
](https://simpleapples.com/2012/11/14/compass-web-app/)
[MDN-Window: deviceorientationabsolute event
](https://developer.mozilla.org/en-US/docs/Web/API/Window/deviceorientationabsolute_event)[MDN-Window: devicemotion event
](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicemotion_event)
好新好新

# 22-Follow Along Links
當游標移動到連結時白底跟著移動到連結上
選取所有的`<a>`標籤,然後建立`highlight`元素,
```javascript
const triggers = document.querySelectorAll("a");
const highlight = document.createElement("span");
highlight.classList.add("highlight");
document.body.append(highlight);
```
此時我們已經可以看到`highlight`元素了,但他現在還沒有尺寸所以看不出來:

我們需要辨識當下游標指到的連結大小,使用`getBoundingClientRect()`來獲取元素的尺寸位置資訊:
```javascript
function highlightIt() {
const linkPosition = this.getBoundingClientRect();
console.log(linkPosition);
}
triggers.forEach((a) =>
a.addEventListener("mouseenter", highlightIt)
);
```
> 影片中用`mouseenter`,我嘗試使用`mousemove`也可以達到效果,但差別就是觸發的次數,只要我在標籤上移動就會不斷觸發`mousemove`事件,但如果我用`mouseenter`只有接觸到標籤的瞬間觸發,直到鼠標移開後再次接觸到標籤才會再次觸發。

```javascript
function highlightIt() {
const linkPosition = this.getBoundingClientRect();
console.log(linkPosition);
highlight.style.width = `${linkPosition.width}px`;
highlight.style.height = `${linkPosition.height}px`;
}
```

這樣就成功根據指到的連結調整白底`highlight`的大小。
然後加上`transform: translate()`:
```javascript
function highlightIt() {
const linkPosition = this.getBoundingClientRect();
console.log(linkPosition);
highlight.style.width = `${linkPosition.width}px`;
highlight.style.height = `${linkPosition.height}px`;
highlight.style.transform = `translate(${linkPosition.left}px, ${linkPosition.top}px)`;
}
```
成功移動到連結所在的位置了!

但接下來捲動頁面就會遇到問題了,因為`transform`沒有跟著頁面捲動:

---
所以我們必須加上頁面捲動的數值`window.scrollX`跟`window.scrollY`:
```javascript=
const triggers = document.querySelectorAll("a");
const highlight = document.createElement("span");
highlight.classList.add("highlight");
document.body.append(highlight);
function highlightIt() {
const linkPosition = this.getBoundingClientRect();
highlight.style.width = `${linkPosition.width}px`;
highlight.style.height = `${linkPosition.height}px`;
highlight.style.transform = `translate(${linkPosition.left + window.scrollX}px, ${linkPosition.top + window.scrollY}px)`;
}
triggers.forEach((a) =>
a.addEventListener("mouseenter", highlightIt)
);
```
[MDN-Element: getBoundingClientRect() method
](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect)
# 23-Speech Synthesis
選擇語音模型,調整語速、音調及朗誦內容,播放語音
教材中已經選好元素且建立了`msg`物件:
```javascript
const msg = new SpeechSynthesisUtterance();
let voices = [];
const voicesDropdown = document.querySelector('[name="voice"]');
const options = document.querySelectorAll(
'[type="range"], [name="text"]'
);
const speakButton = document.querySelector("#speak");
const stopButton = document.querySelector("#stop");
```
觀察一下`msg`是幹嘛的,看起來是存放一些語音相關資訊:

[MDN-SpeechSynthesisUtterance](https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesisUtterance)
我們先把`textarea`的文字加到`msg`中:
```javascript
msg.text = document.querySelector("[name=text]").value;
```

再來要將瀏覽器支援的語音種類加入選單中:

```javascript
function populateVoices() {
voices = this.getVoices();
console.log(voices);
}
speechSynthesis.addEventListener("voiceschanged", populateVoices);
```
這邊我們註冊事件使用`voiceschange`,它會在瀏覽器支援的語音類型加載完成後觸發,然後我們才能使用`speechSynthesis.getVoices()`來取得所有支援語音的清單
[MDN-SpeechSynthesis: voiceschanged event
](https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesis/voiceschanged_event)

打開來就可以看到所有支援的語音,再來就是將他們加入選單中:
```javascript
function populateVoices() {
voices = this.getVoices();
const voicesOption = voices
.map(
(voice) =>
`<option value="${voice.name}">${voice.name} (${voice.lang})</option>`
)
.join("");
voicesDropdown.innerHTML = voicesOption;
}
```
將他們用`.map`改寫成`<option>`的代碼後放進`voicesDropdown`中:

成功放進選單了!
將選取到的項目放進`msg`的`voice`資訊中,然後執行`speak()`:
```javascript
function setVoice() {
msg.voice = voices.find((voice) => voice.name === this.value);
speak();
}
function speak() {
speechSynthesis.cancel();
speechSynthesis.speak(msg);
}
voicesDropdown.addEventListener("change", setVoice);
```
`speak`中`speechSynthesis.cancel()`是用來停止所有的語音播放,然後`speechSynthesis.speak(msg)`執行下一次的語音播放,如果沒有`cancel`的話你在還沒播放完當下的語音時就選取下一個語音,他會等當下的語音播放完才會放下一個,但我們想要選完馬上播放。
[MDN-SpeechSynthesis: cancel() method
](https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesis/cancel)[MDN-SpeechSynthesis: speak() method
](https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesis/speak)
設定速度和語調:
```javascript
function setOption() {
msg[this.name] = this.value;
speak();
}
options.forEach((option) =>
option.addEventListener("change", setOption)
);
```
一樣設定完執行`speak()`。
---
最後是播放跟暫停按鈕:
```javascript
function stopSpeaking() {
speechSynthesis.cancel();
}
speakButton.addEventListener("click", speak);
stopButton.addEventListener("click", stopSpeaking);
```
`speak()`前面寫過了,`stopSpeaking`是我自己寫的,影片作者是寫旗標的方式:
```javascript
function toggle(startOver = true) {
speechSynthesis.cancel();
if (startOver) {
speechSynthesis.speak(msg);
}
}
speakButton.addEventListener("click", toggle);
stopButton.addEventListener("click", () => toggle(false));
```
但之前Chris說他不喜歡旗標的方式,所以我才另外寫一個`stopSpeaking`,到時候再跟他討論一下。
# 24-Sticky Nav
目標:當`nav`捲動到頂部時`fixed`,且`logo`滑出。
先觀察一下`logo`為什麼看不到,看起來是因為`max-width: 0;`

關掉後確實顯示出來了:

所以我的想法是當捲動到`nav`頂端的時候將`nav`設定`position: fixed`,然後`logo`的`max-width`調成不為0:
```javascript=
const nav = document.querySelector("#main");
const logo = document.querySelector(".logo");
const navTop = nav.offsetTop;
function fixNav() {
if (navTop < window.scrollY) {
nav.style.position = `fixed`;
logo.style.maxWidth = `500px`;
} else {
nav.style.position = `relative`;
logo.style.maxWidth = `0`;
}
}
window.addEventListener("scroll", fixNav);
```
在捲回`nav`上方時就還原狀態。
然後就遇到跳動問題,當`nav`變成`fixed`的時候因為失去原本位置所以下方元素都往上跳了`nav`的高度,為了解決這個問題我們需要在`nav`變成`fixed`的同時將高度撐出來,所以我們在`body`加上`padding-top`然後設定跟`nav`的高度相同:
```javascript=
const nav = document.querySelector("#main");
const logo = document.querySelector(".logo");
const navTop = nav.offsetTop;
function fixNav() {
console.log(nav.offsetTop);
if (navTop < window.scrollY) {
nav.style.position = `fixed`;
logo.style.maxWidth = `500px`;
document.body.style.paddingTop = `${nav.offsetHeight}px`;
} else {
nav.style.position = `relative`;
logo.style.maxWidth = `0`;
document.body.style.paddingTop = `0`;
}
}
```
這樣好像就做完了。
再來看看影片怎麼做:
好像基本上差不多,但他在調整`nav`的`position`時另外寫了一個`class`叫`fixed-nav`,然後調整`logo`就用`.fixed-nav li.logo`調整,主要就是在`css`檔案中調整。
# 25-Event Capture, Propagation, Bubbling and Once
介紹捕捉、傳遞、冒泡跟單次事件

這邊結構是`one`包`two`包`three`,當我們在這三層都註冊`click`事件時,點到最裡面的`three`其他兩層也會一起觸發:
```javascript
const divs = document.querySelectorAll("div");
divs.forEach((div) =>
div.addEventListener("click", () => console.log("click!!"))
);
```

所以我點擊橘色區塊就觸發了三次,為什麼會這樣??這時候就要來觀察`event phase`事件類型:
```javascript
const divs = document.querySelectorAll("div");
divs.forEach((div) =>
div.addEventListener("click", (e) => console.log(e.eventPhase))
);
```

結果是2, 3, 3,更看不懂了
[參考資料](https://medium.com/itsems-frontend/javascript-event-bubbling-capturing-794cd2d01e61)
w3c裡面寫了:

捕捉階段=1
目標階段=2
冒泡階段=3
所以其實前面印出的eventPhase不完整,改成將所有階段印出來,使用到`addEventListener`的第三個參數:是否使用捕捉模式,預設是false:

我們將它改成true就是使用捕捉階段:
```javascript
const divs = document.querySelectorAll("div");
divs.forEach((div, index, divs) => {
divs[index].addEventListener(
"click",
(e) => {
console.log(
`capture div${index + 1}, eventPhase: `,
e.eventPhase
);
},
true
);
divs[index].addEventListener("click", (e) => {
console.log(
`bubbling div${index + 1}, eventPhase: `,
e.eventPhase
);
});
});
```

可以清楚觀察到,捕捉階段(eventPhase=1)穿過了`one`跟`two`,然後目標階段(eventPhase=2)在`three`,最後冒泡階段(eventPhase=3)又穿過了`two`跟`one`
所以看一下圖解:

所以我們現在可以觀察到如果都是click點擊事件,那他的事件就會從外層傳進去捕捉然後冒泡出來,每一層都會觸發click點擊事件,但我們可能只想觸發當下想點擊的元素本身,可以使用`stopPropagation()`來停止事件傳遞:
```javascript
divs[index].addEventListener(
"click",
(e) => {
console.log(
`capture div${index + 1}, eventPhase: `,
e.eventPhase
);
e.stopPropagation();
},
true
);
```
現在在捕捉模式加上`e.stopPropagation()`來停止事件傳播,結果會是:

因為我們點擊`three`區塊會先從最外層的`one`開始往內傳遞,但找到`one`的時候就停止傳遞了,所以不會一直進去觸發到`three`,現在換成在一般模式用`e.stopPropagation()`:
```javascript
divs[index].addEventListener(
"click",
(e) => {
console.log(
`capture div${index + 1}, eventPhase: `,
e.eventPhase
);
},
true
);
divs[index].addEventListener("click", (e) => {
console.log(
`bubbling div${index + 1}, eventPhase: `,
e.eventPhase
);
e.stopPropagation();
});
```

點擊`three`時在捕捉到`three`時準備要冒泡出去時停止傳遞了。
最後介紹`addEventListener()`的第三個參數也可以是`option`物件,裝`capture`跟`once`兩個設定,`capture`就跟剛剛講過的一樣預設是`false`使用非捕捉模式,`true`就是使用捕捉模式;另一個`once`就是指註冊一次這個事件,觸發一次之後就消失了:
```javascript
divs.forEach((div, index, divs) => {
divs[index].addEventListener(
"click",
(e) => {
console.log(
`capture div${index + 1}, eventPhase: `,
e.eventPhase
);
},
{ capture: true, once: true }
);
divs[index].addEventListener("click", (e) => {
console.log(
`bubbling div${index + 1}, eventPhase: `,
e.eventPhase
);
});
});
```
捕捉模式的事件設定`once: true`,我們來觀察一下發生什麼事。
點擊第一次很正常全部都執行了:

但第二次就變成沒有捕捉模式的狀態了:

實際上他就是在觸發事件後將這個事件移除掉,也可以用`removeEventListener()`來做到這件事,但我需要改寫一下...
```javascript
function logEventPhase(e) {
console.log(
`capture div.${this.classList.value}, eventPhase: `,
e.eventPhase
);
this.removeEventListener("click", logEventPhase, true);
}
divs.forEach((div, index, divs) => {
divs[index].addEventListener("click", logEventPhase, true);
divs[index].addEventListener("click", (e) => {
console.log(
`bubbling div${index + 1}, eventPhase: `,
e.eventPhase
);
});
});
```
:::info
因為原本用箭頭函式寫,如果`removeEventListener`也寫箭頭函式他跟原本註冊事件的箭頭函式式不同函式,所以就無法刪除對應的函式,我們需要將函式命名,想要remove的時候才有辦法指定到想刪除的函式。
:::
結束。
[MDN-EventTarget: addEventListener() method](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener)
# 26-Stripe Follow Along Dropdown
這章是在做滑鼠移動`<li>`上就在對應的位置顯示出對應的清單,並加上會跟著跑的白底動畫樣式:
首先取得所有的`<li>`(toggers),然後取得白底(background),在每個togger註冊mouseenter跟mouseleave事件,滑鼠移動到元素上時觸發`openDropdown`,滑鼠離開時觸發`removeDropdawn`。
完整程式碼:
```javascript=
const triggers = document.querySelectorAll(".cool > li");
const background = document.querySelector(".dropdownBackground");
const nav = document.querySelector(".top");
triggers.forEach((trigger) => {
trigger.addEventListener("mouseenter", openDropdown);
trigger.addEventListener("mouseleave", removeDropdown);
});
function openDropdown() {
this.classList.add("trigger-enter");
setTimeout(() => {
if (this.classList.contains("trigger-enter")) {
this.classList.add("trigger-enter-active");
}
}, 150);
background.classList.add("open");
const dropdown = this.querySelector(".dropdown");
const dropdownCoords = dropdown.getBoundingClientRect();
const navCoords = nav.getBoundingClientRect();
background.style.setProperty(
"width",
`${dropdownCoords.width}px`
);
background.style.setProperty(
"height",
`${dropdownCoords.height}px`
);
background.style.setProperty(
"transform",
`translate(${dropdownCoords.left - navCoords.left}px, ${
dropdownCoords.top - navCoords.top
}px)`
);
}
function removeDropdown() {
this.classList.remove("trigger-enter", "trigger-enter-active");
background.classList.remove("open");
}
```
看一下`openDropdown`做了什麼:
將碰到的這個元素的`class`加上`trigger-enter`,經過`150`毫秒判斷有沒有剛剛加上的`class`,如果有就再加上`trigger-enter-active`,讓其內容顯示出來
(這邊要等`150`毫秒是因為要等使用者移動到這個元素上一下下,確認他確定要看這個項目的內容,才將它顯示出來,主要也是要等待會的白色背景移動到定位才顯示內容)
所以接下來就是將白色背景加上`open`樣式,讓他打開:
```javascript=
function openDropdown() {
this.classList.add("trigger-enter");
setTimeout(() => {
if (this.classList.contains("trigger-enter")) {
this.classList.add("trigger-enter-active");
}
}, 150);
background.classList.add("open");
const dropdown = this.querySelector(".dropdown");
const dropdownCoords = dropdown.getBoundingClientRect();
const navCoords = nav.getBoundingClientRect();
background.style.setProperty(
"width",
`${dropdownCoords.width}px`
);
background.style.setProperty(
"height",
`${dropdownCoords.height}px`
);
background.style.setProperty(
"transform",
`translate(${dropdownCoords.left - navCoords.left}px, ${
dropdownCoords.top - navCoords.top
}px)`
);
}
```
接著要判斷當下的項目中,內容的大小,所以取得`this`裡的`dropdown`,並拿到他的邊界資料(`getBoundingClientRect()`),還有整個`nav`的邊界資料,最後將這些資料放到白色的背景中。
所以用`dropdownCoords`的寬跟高,位置要扣掉`nav`的`top`是因為白色背景也是包在`nav`裡面,所以`translate``dropdown`的`top`會包含到`nav`的`top`



有點亂XD,但就是`background`原本就在距離頂端`navCoords.top`的的位置,`dropdownCoords.top`也包含了`navCoords.top`,所以再往下移動`dropdownCoords.top`就會多算了一次`navCoords.top`,所以要扣掉!
接著就在滑鼠移開後移除剛剛加上的樣式就行了:
```javascript=
function removeDropdown() {
this.classList.remove("trigger-enter", "trigger-enter-active");
background.classList.remove("open");
}
```
這麼一來就完成了~