[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) 好新好新 ![image](https://hackmd.io/_uploads/Hk1gBb2iC.png) # 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`元素了,但他現在還沒有尺寸所以看不出來: ![image](https://hackmd.io/_uploads/BkTvMtFjC.png) 我們需要辨識當下游標指到的連結大小,使用`getBoundingClientRect()`來獲取元素的尺寸位置資訊: ```javascript function highlightIt() { const linkPosition = this.getBoundingClientRect(); console.log(linkPosition); } triggers.forEach((a) => a.addEventListener("mouseenter", highlightIt) ); ``` > 影片中用`mouseenter`,我嘗試使用`mousemove`也可以達到效果,但差別就是觸發的次數,只要我在標籤上移動就會不斷觸發`mousemove`事件,但如果我用`mouseenter`只有接觸到標籤的瞬間觸發,直到鼠標移開後再次接觸到標籤才會再次觸發。 ![image](https://hackmd.io/_uploads/Hyj96OKjR.png) ```javascript function highlightIt() { const linkPosition = this.getBoundingClientRect(); console.log(linkPosition); highlight.style.width = `${linkPosition.width}px`; highlight.style.height = `${linkPosition.height}px`; } ``` ![image](https://hackmd.io/_uploads/rknlCuFiR.png) 這樣就成功根據指到的連結調整白底`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)`; } ``` 成功移動到連結所在的位置了! ![image](https://hackmd.io/_uploads/H10mAOtoR.png) 但接下來捲動頁面就會遇到問題了,因為`transform`沒有跟著頁面捲動: ![image](https://hackmd.io/_uploads/BkCMRdYsR.png) --- 所以我們必須加上頁面捲動的數值`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`是幹嘛的,看起來是存放一些語音相關資訊: ![image](https://hackmd.io/_uploads/HJvb9DFiA.png) [MDN-SpeechSynthesisUtterance](https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesisUtterance) 我們先把`textarea`的文字加到`msg`中: ```javascript msg.text = document.querySelector("[name=text]").value; ``` ![image](https://hackmd.io/_uploads/HkxaADKo0.png) 再來要將瀏覽器支援的語音種類加入選單中: ![image](https://hackmd.io/_uploads/rJJ1MOtsR.png) ```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)![image](https://hackmd.io/_uploads/BJhaMOKsR.png) ![image](https://hackmd.io/_uploads/r1Nk7dto0.png) 打開來就可以看到所有支援的語音,再來就是將他們加入選單中: ```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`中: ![image](https://hackmd.io/_uploads/S1dKXOtiC.png) 成功放進選單了! 將選取到的項目放進`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;` ![image](https://hackmd.io/_uploads/r1gg_25iA.png) 關掉後確實顯示出來了: ![image](https://hackmd.io/_uploads/B1k-d3qjC.png) 所以我的想法是當捲動到`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 介紹捕捉、傳遞、冒泡跟單次事件 ![image](https://hackmd.io/_uploads/ryBvfZkhA.png) 這邊結構是`one`包`two`包`three`,當我們在這三層都註冊`click`事件時,點到最裡面的`three`其他兩層也會一起觸發: ```javascript const divs = document.querySelectorAll("div"); divs.forEach((div) => div.addEventListener("click", () => console.log("click!!")) ); ``` ![image](https://hackmd.io/_uploads/SkJ8Xbk2C.png) 所以我點擊橘色區塊就觸發了三次,為什麼會這樣??這時候就要來觀察`event phase`事件類型: ```javascript const divs = document.querySelectorAll("div"); divs.forEach((div) => div.addEventListener("click", (e) => console.log(e.eventPhase)) ); ``` ![image](https://hackmd.io/_uploads/HkC14bynC.png) 結果是2, 3, 3,更看不懂了 [參考資料](https://medium.com/itsems-frontend/javascript-event-bubbling-capturing-794cd2d01e61) w3c裡面寫了: ![image](https://hackmd.io/_uploads/SJh64bynC.png) 捕捉階段=1 目標階段=2 冒泡階段=3 所以其實前面印出的eventPhase不完整,改成將所有階段印出來,使用到`addEventListener`的第三個參數:是否使用捕捉模式,預設是false: ![image](https://hackmd.io/_uploads/H1z8Yb120.png) 我們將它改成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 ); }); }); ``` ![image](https://hackmd.io/_uploads/rJd9VcMn0.png) 可以清楚觀察到,捕捉階段(eventPhase=1)穿過了`one`跟`two`,然後目標階段(eventPhase=2)在`three`,最後冒泡階段(eventPhase=3)又穿過了`two`跟`one` 所以看一下圖解: ![image](https://hackmd.io/_uploads/ry-7qZkhR.png) 所以我們現在可以觀察到如果都是click點擊事件,那他的事件就會從外層傳進去捕捉然後冒泡出來,每一層都會觸發click點擊事件,但我們可能只想觸發當下想點擊的元素本身,可以使用`stopPropagation()`來停止事件傳遞: ```javascript divs[index].addEventListener( "click", (e) => { console.log( `capture div${index + 1}, eventPhase: `, e.eventPhase ); e.stopPropagation(); }, true ); ``` 現在在捕捉模式加上`e.stopPropagation()`來停止事件傳播,結果會是: ![image](https://hackmd.io/_uploads/Syxr8cG30.png) 因為我們點擊`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(); }); ``` ![image](https://hackmd.io/_uploads/HyABdcGnC.png) 點擊`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`,我們來觀察一下發生什麼事。 點擊第一次很正常全部都執行了: ![image](https://hackmd.io/_uploads/S1RZ8pznR.png) 但第二次就變成沒有捕捉模式的狀態了: ![image](https://hackmd.io/_uploads/SkaVI6M2C.png) 實際上他就是在觸發事件後將這個事件移除掉,也可以用`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` ![image](https://hackmd.io/_uploads/SkcCQNoK1e.png) ![image](https://hackmd.io/_uploads/rkH5mNiKyx.png) ![image](https://hackmd.io/_uploads/Sy37SEoY1x.png) 有點亂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"); } ``` 這麼一來就完成了~