Skip to content

CodToys - EP02:功能選項

快樂堆積木的魚

上一章節我們完成了快捷鍵開關視窗功能,這一章節我們來實作功能選項部分吧!

開發

本章節預計會建立以下內容:

  • 輸入候選:在輸入框輸入內容時,會顯示候選功能選項
  • 功能選項:提供使用者選擇功能,且可以透過滑鼠或鍵盤選擇
  • 建立「搜尋顏文字、Google 搜尋」功能

功能選項元件

第一步讓我們新增功能選項元件

src\components\feature-option.vue

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

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-optionmain-input 元件。

src\pages\index.vue

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>

看起來是不是乾淨很多呢?ヾ(◍'౪`◍)ノ゙

現在讓我們打開視窗。

feature-option

...會發現「選項 2」被切掉啦!Σ(ˊДˋ;)

這是因為視窗高度沒有隨著內容增加而增加,讓我們來修正一下吧。

自動調整視窗高度

由於視窗由 main process 控制,我們需要讓視窗與 main process 通訊並調整視窗大小。

使用 preload.ts 來對 renderer process,也就是網頁視窗提供直通 main process 的 API 吧。

甚麼 process?(*´・д・)

Electron 基於 multi-process 架構,詳細請參考官方文件

electron\preload.ts

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

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

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

ts
/** 提供 Electron main process API */
export function useMainApi() {
  return window.main
}

現在讓視窗根據內容高度自動調整,使用 useElementBounding 輕鬆完成!ヽ(●`∀´●)ノ

vue
<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 順利出現了。

window-auto-resize


最後我們把原本在 main.ts 中的「視窗 blur 時自動隱藏視窗」的邏輯移到首頁中,方便未來依頁面需求自定義。

electron\main.ts

ts
// ...
async function createInputWindow() {
  // ...

  // 失去焦點時自動隱藏視窗
  newWindow.on('blur', () => { 
    newWindow.hide() 
  }) 

  // ...
}
// ...

所以網頁中要怎麼判斷視窗是否 blur?(´・ω・`)

其實很簡單,透過 useWindowFocus 輕鬆實現!

src\pages\index.vue

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

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

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

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 執行選項。

keyboard-selection-option

越來越有模有樣了!✧⁑。٩(ˊᗜˋ*)و✧⁕。

新增功能卡片

接著讓我們新增功能卡片,功能卡片會自動根據使用者輸入內容提供可用選項。

可預期每個卡片都會有自己的專屬邏輯,所以我們將卡片元件獨立在 domains 資料夾中。

搜尋顏文字

src\domains\feature-card-kaomoji\index.vue

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

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>

現在輸入 @ 即可搜尋顏文字,而且選擇後可以直接複製到剪貼簿。

select-and-copy-kaomoji

Google 搜尋

只有一個功能有點孤單,讓我們再新增一個可以快速開始 Google 搜尋的功能卡片吧!(/≧▽≦)/

這裡需要在 mainApi 新增一個 API,用來開啟瀏覽器。

electron\electron-env.d.ts

ts
export interface MainApi {
  // ...
  openExternal: (url: string) => void;
}

// ...

electron\preload.ts

ts
import { contextBridge, ipcRenderer } from 'electron'

contextBridge.exposeInMainWorld('main', {
  // ...
  openExternal(url: string) {
    return ipcRenderer.send('openExternal', url)
  },
})

electron\main.ts

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

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

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 搜尋了!◝(≧∀≦)◟

google-search

核心功能基本上都完成了,感覺超讚!(≧∇≦)ノ

總結 🐟

以上程式碼可以在此取得

  • 完成依照關鍵字候選功能選項
  • ipcRenderer 可以讓網頁與 main process 通訊
  • 完成搜尋顏文字與 Google 搜尋功能

下一章讓我們來實作連接 Notion Database 與模糊搜尋吧。(≖‿ゝ≖)✧

TIP

因為篇幅問題,某些部分簡單帶過。

若想看到更詳細的解釋,請不吝留言或寫信給我喔!(*´∀`)~♥