CodToys - EP02:功能選項
上一章節我們完成了快捷鍵開關視窗功能,這一章節我們來實作功能選項部分吧!
開發
本章節預計會建立以下內容:
- 輸入候選:在輸入框輸入內容時,會顯示候選功能選項
- 功能選項:提供使用者選擇功能,且可以透過滑鼠或鍵盤選擇
- 建立「搜尋顏文字、Google 搜尋」功能
功能選項元件
第一步讓我們新增功能選項元件
src\components\feature-option.vue
<template>
<div
ref="optionRef"
class="flex items-center gap-2 duration-300"
:class="{ 'bg-primary/30': selected }"
@click="props.action()"
>
<q-icon
v-if="props.icon"
:name="props.icon"
size="1.5rem"
/>
<slot>
{{ props.text }}
</slot>
</div>
</template>
<script setup lang="ts">
import { useElementHover } from '@vueuse/core'
import { computed, ref } from 'vue'
interface Props {
icon?: string;
text?: string;
action?: () => void;
}
const props = withDefaults(defineProps<Props>(), {
action: () => () => { },
})
defineSlots<{
default?: () => unknown;
}>()
const optionRef = ref<HTMLDivElement>()
const isHover = useElementHover(optionRef)
const selected = computed(() => isHover.value)
</script>
此元件負責呈現可用的功能選項,並且可以透過 action
來設定點擊後的行為。
滑鼠 hover
或使用鍵盤選擇時,會變更背景色,這裡預留 selected
,晚點來實作鍵盤導航功能。
在使用 feature-option
元件前,先讓我們封裝一下目前的輸入框元件。
src\components\main-input.vue
<template>
<q-input
v-model="inputText"
placeholder="要來點甚麼?...(´,,•ω•,,)"
autofocus
outlined
square
>
<template #prepend>
<q-icon name="search" />
</template>
</q-input>
</template>
<script setup lang="ts">
const inputText = defineModel({ default: '' })
</script>
最後在首頁中引入 feature-option
與 main-input
元件。
src\pages\index.vue
<template>
<div class="flex-col">
<main-input v-model="inputText" />
<div class="flex-col">
<feature-option
text="選項 1"
class="p-4"
/>
<feature-option
text="選項 2"
class="p-4"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import FeatureOption from '../components/feature-option.vue'
import MainInput from '../components/main-input.vue'
const inputText = ref('')
</script>
看起來是不是乾淨很多呢?ヾ(◍'౪`◍)ノ゙
現在讓我們打開視窗。
...會發現「選項 2」被切掉啦!Σ(ˊДˋ;)
這是因為視窗高度沒有隨著內容增加而增加,讓我們來修正一下吧。
自動調整視窗高度
由於視窗由 main process 控制,我們需要讓視窗與 main process 通訊並調整視窗大小。
使用 preload.ts
來對 renderer process,也就是網頁視窗提供直通 main process 的 API 吧。
甚麼 process?(*´・д・)
Electron 基於 multi-process 架構,詳細請參考官方文件。
electron\preload.ts
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('main', {
updateHeight(height: number) {
return ipcRenderer.send('updateHeight', height)
},
hideWindow() {
return ipcRenderer.send('hideWindow')
},
})
ipcRenderer
模組提供了 send
方法,可以將事件發送到 main process。
contextBridge
模組則負責將 main
物件注入在 window 中,我們可以在網頁中使用 window.main
來呼叫 main process 的方法。
接著我們要在 main process 中接收這些事件,使用 ipcMain
模組來收聽事件,順便重構一下 main 程式碼。
electron\main.ts
import path from 'node:path'
import process from 'node:process'
import {
app,
BrowserWindow,
globalShortcut,
ipcMain,
screen,
} from 'electron'
async function createInputWindow() {
const display = screen.getPrimaryDisplay()
const newWindow = new BrowserWindow({
width: display.bounds.width / 3,
height: 100,
show: false,
backgroundColor: '#fff',
frame: false,
resizable: false,
webPreferences: {
preload: path.join(__dirname, './preload.js'),
},
})
// 隱藏預設系統選單
newWindow.setMenu(null)
// 失去焦點時自動隱藏視窗
newWindow.on('blur', () => {
newWindow.hide()
})
if (process.env.VITE_DEV_SERVER_URL) {
await newWindow.loadURL(process.env.VITE_DEV_SERVER_URL)
}
else {
await newWindow.loadFile('dist/index.html')
}
return newWindow
}
function initGlobalShortcut(mainWindow: BrowserWindow) {
const ret = globalShortcut.register('Ctrl+Space', () => {
if (mainWindow?.isVisible()) {
// focusable 設為 false,才可以讓焦點回到原本位置。例如正在輸入的編輯器
mainWindow.setFocusable(false)
mainWindow.hide()
return
}
const cursorPoint = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(cursorPoint)
// 設定滑鼠位置之視窗中間往上 1/3 的位置
const [width, height] = mainWindow.getSize()
if (!width || !height)
return
mainWindow?.setPosition(
Math.floor(display.bounds.x + display.bounds.width / 2 - width / 2),
Math.floor(display.bounds.y + display.bounds.height / 3 - height / 2),
)
mainWindow.setFocusable(true)
mainWindow.show()
})
if (!ret) {
console.error('registration failed')
}
}
function initIpcMain(mainWindow: BrowserWindow) {
ipcMain.on('updateHeight', (event, height: number) => {
mainWindow.setBounds({ height })
})
ipcMain.on('hideWindow', (event) => {
mainWindow.setFocusable(false)
mainWindow.hide()
})
}
app.whenReady().then(async () => {
const mainWindow = await createInputWindow()
initGlobalShortcut(mainWindow)
initIpcMain(mainWindow)
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
globalShortcut.unregisterAll()
})
現在我們需要在網頁中使用 mainApi
,既然是 TypeScript 專案,第一步當然是定義型別啦。
electron\electron-env.d.ts
export interface MainApi {
updateHeight: (height: number) => void;
hideWindow: () => void;
}
declare global {
interface Window {
main: MainApi;
}
}
export { }
接著把 contextBridge
模組注入的 main
物件透過 Composition API 形式提供,保留未來更動彈性。
src\composables\use-main-api.ts
/** 提供 Electron main process API */
export function useMainApi() {
return window.main
}
現在讓視窗根據內容高度自動調整,使用 useElementBounding
輕鬆完成!ヽ(●`∀´●)ノ
<template>
<div
ref="pageRef"
class="flex-col"
>
// ...
</div>
</template>
<script setup lang="ts">
const mainApi = useMainApi()
const inputText = ref('')
// 同步視窗與頁面高度
const pageRef = ref<HTMLDivElement>()
const { height } = useElementBounding(pageRef)
watchEffect(() => {
mainApi.updateHeight(height.value)
})
</script>
現在開啟視窗,可以看到這次選項 2 順利出現了。
最後我們把原本在 main.ts
中的「視窗 blur 時自動隱藏視窗」的邏輯移到首頁中,方便未來依頁面需求自定義。
electron\main.ts
// ...
async function createInputWindow() {
// ...
// 失去焦點時自動隱藏視窗
newWindow.on('blur', () => {
newWindow.hide()
})
// ...
}
// ...
所以網頁中要怎麼判斷視窗是否 blur?(´・ω・`)
其實很簡單,透過 useWindowFocus
輕鬆實現!
src\pages\index.vue
<template>
// ...
</template>
<script setup lang="ts">
// ...
// 視窗 blur 時,清空 inputText 並隱藏視窗
const focused = useWindowFocus()
whenever(
() => !focused.value,
() => {
inputText.value = ''
mainApi.hideWindow()
},
)
</script>
感恩 VueUse!讚嘆 VueUse!(*´∀`)~♥
鍵盤導航
選項有了,那就來實作鍵盤導航功能吧。
這裡讓 feature-option
元件註冊自己的 ID,透過 ID 選擇可用的 option。
建立一個 feature.store.ts
來管理並註冊 option。
src\stores\feature.store.ts
import { defineStore } from 'pinia'
import { pipe, sortBy } from 'remeda'
import { computed, ref, shallowRef, triggerRef } from 'vue'
interface OptionValue {
action: () => void;
}
export const useFeatureStore = defineStore('feature', () => {
const optionMap = shallowRef(new Map<string, OptionValue>())
const optionIdList = computed(() => pipe(
[...optionMap.value.keys()],
/** 依照數字排序,保證 option 順序 */
sortBy((id) => Number.parseInt(id.replace(/\D/g, ''), 10)),
))
function addOption(id: string, value: OptionValue) {
optionMap.value.set(id, value)
triggerRef(optionMap)
}
function removeOption(id: string) {
optionMap.value.delete(id)
triggerRef(optionMap)
}
const selectedOptionId = ref('')
function setOption(id: string) {
selectedOptionId.value = id
}
function nextOption() {
const first = optionIdList.value.at(0)
const index = optionIdList.value.indexOf(selectedOptionId.value)
if (index < 0 && first) {
selectedOptionId.value = first
return
}
const target = optionIdList.value[index + 1]
if (target || first) {
selectedOptionId.value = (target ?? first)!
}
}
function prevOption() {
const last = optionIdList.value.at(-1)
const index = optionIdList.value.indexOf(selectedOptionId.value)
if (index < 0 && last) {
selectedOptionId.value = last
return
}
const target = optionIdList.value[index - 1]
if (target || last) {
selectedOptionId.value = (target ?? last)!
}
}
const currentOption = computed(() => optionMap.value.get(selectedOptionId.value))
return {
optionIdList,
addOption,
removeOption,
selectedOptionId,
setOption,
nextOption,
prevOption,
currentOption,
}
})
feature.store
主要提供以下邏輯:
- 註冊、移除 option
- 設定或前後選擇 option
接著在 feature-option
新增邏輯:
- 註冊自己的 ID 與
action
onUnmounted
時移除 ID。selected
依照feature.store
資料判斷
src\components\feature-option.vue
<template>
// ...
</template>
<script setup lang="ts">
// ...
const featureStore = useFeatureStore()
const id = useId()
featureStore.addOption(id, {
action: props.action,
})
onUnmounted(() => {
featureStore.removeOption(id)
})
// ...
const selected = computed(
() => isHover.value || featureStore.selectedOptionId === id,
)
</script>
最後讓我們在 main-input
中註冊鍵盤事件,實現鍵盤選擇選項功能。
src\components\main-input.vue
<template>
<q-input
...
@keydown="handleKeydown"
>
// ...
</q-input>
</template>
<script setup lang="ts">
// ...
const keydownEventMap: Record<
/** key name
*
* https://developer.mozilla.org/zh-CN/docs/Web/API/UI_Events/Keyboard_event_key_values
*/
string,
(event: KeyboardEvent) => Promise<void>
> = {
async Escape() {
// 已清空則隱藏視窗
if (inputText.value === '') {
mainApi.hideWindow()
}
inputText.value = ''
},
async ArrowDown(event) {
event.preventDefault()
featureStore.nextOption()
},
async ArrowUp(event) {
event.preventDefault()
featureStore.prevOption()
},
async Enter() {
featureStore.currentOption?.action()
},
}
function handleKeydown(event: KeyboardEvent) {
keydownEventMap[event.key]?.(event)
}
</script>
現在我們可以透過鍵盤觸發以下功能:
- Escape 清空輸入框,若內容為空則隱藏視窗
- 上下鍵選擇選項
- Enter 執行選項。
越來越有模有樣了!✧⁑。٩(ˊᗜˋ*)و✧⁕。
新增功能卡片
接著讓我們新增功能卡片,功能卡片會自動根據使用者輸入內容提供可用選項。
可預期每個卡片都會有自己的專屬邏輯,所以我們將卡片元件獨立在 domains
資料夾中。
搜尋顏文字
src\domains\feature-card-kaomoji\index.vue
<template>
<template v-if="visible">
<feature-option
v-if="!isFeature"
class="p-4"
icon="emoticon"
text="輸入 @ 搜尋顏文字"
:action="() => setText('@')"
/>
<template v-else>
<feature-option
v-for="text, i in textList"
:key="i"
class="p-4"
:text
:action="() => copy(text)"
/>
</template>
</template>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import FeatureOption from '../../components/feature-option.vue'
import { useMainApi } from '../../composables/use-main-api'
const mainApi = useMainApi()
const inputText = defineModel({ default: '' })
const visible = computed(() => {
return !inputText.value || inputText.value.startsWith('@')
})
const isFeature = computed(() => inputText.value.startsWith('@'))
function setText(text: string) {
inputText.value = text
}
function copy(text: string) {
navigator.clipboard.writeText(text)
mainApi.hideWindow()
}
const textList = [
'(´▽`ʃ♡ƪ)',
'੭ ˙ᗜ˙ )੭',
'(*´∀`)~♥',
]
</script>
因為還沒連接資料庫,所以先暫時寫死 3 個顏文字測試功能。
接著在首頁中引入功能卡片。
src\pages\index.vue
<template>
<div>
// ...
<div class="flex-col">
<feature-card-kaomoji v-model="inputText" />
</div>
</div>
</template>
<script setup lang="ts">
import FeatureCardKaomoji from '../domains/feature-card-kaomoji/index.vue'
</script>
現在輸入 @
即可搜尋顏文字,而且選擇後可以直接複製到剪貼簿。
Google 搜尋
只有一個功能有點孤單,讓我們再新增一個可以快速開始 Google 搜尋的功能卡片吧!(/≧▽≦)/
這裡需要在 mainApi
新增一個 API,用來開啟瀏覽器。
electron\electron-env.d.ts
export interface MainApi {
// ...
openExternal: (url: string) => void;
}
// ...
electron\preload.ts
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('main', {
// ...
openExternal(url: string) {
return ipcRenderer.send('openExternal', url)
},
})
electron\main.ts
// ...
function initIpcMain(mainWindow: BrowserWindow) {
// ...
ipcMain.on('openExternal', (event, url: string) => {
shell.openExternal(url)
})
}
// ...
這裡使用了 shell
模組,可以讓我們開啟外部瀏覽器。
接著讓我們新增 feature-card-google
功能卡片元件。
src\domains\feature-card-google\index.vue
<template>
<feature-option
v-if="visible"
class="p-4"
icon="search"
text="在 Google 上搜尋"
:action
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import FeatureOption from '../../components/feature-option.vue'
import { useMainApi } from '../../composables/use-main-api'
const mainApi = useMainApi()
const inputText = defineModel({ default: '' })
const visible = computed(() => {
if (!inputText.value) {
return false
}
return !inputText.value.startsWith('@')
})
function action() {
const url = [
`https://www.google.com/search?q=`,
encodeURIComponent(inputText.value),
].join('')
mainApi.openExternal(url)
mainApi.hideWindow()
}
</script>
接著在首頁引入功能卡片。
不過每次新增功能卡片都要手動引入好麻煩,讓我們自動引入吧!( ´ ▽ ` )ノ
src\pages\index.vue
<template>
<div>
// ...
<div class="flex-col">
<component
:is="card"
v-for="card, key in featureCards"
:key
v-model="inputText"
/>
</div>
</div>
</template>
<script setup lang="ts">
const featureCards = import.meta.glob('../domains/feature-card-*/index.vue', {
import: 'default',
eager: true,
})
// ...
</script>
TIP
這裡使用了 Vite 的 glob 功能
現在可以輸入任何內容並開啟瀏覽器跳轉 Google 搜尋了!◝(≧∀≦)◟
核心功能基本上都完成了,感覺超讚!(≧∇≦)ノ
總結 🐟
以上程式碼可以在此取得
- 完成依照關鍵字候選功能選項
ipcRenderer
可以讓網頁與 main process 通訊- 完成搜尋顏文字與 Google 搜尋功能
下一章讓我們來實作連接 Notion Database 與模糊搜尋吧。(≖‿ゝ≖)✧
TIP
因為篇幅問題,某些部分簡單帶過。
若想看到更詳細的解釋,請不吝留言或寫信給我喔!(*´∀`)~♥