---
url: /column-cod-toys/03-kaomoji-database.md
description: 上一章節我們完成了快捷鍵開關視窗功能，這一章節我們來實作功能選項部分吧！
---

![快樂堆積木的魚](/cod-toys.webp){.cover}

# CodToys - EP03：顏文字資料庫

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

## 開發

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

* 產生 Tray：用來查看狀態與開啟設定視窗
* 儲存設定檔：儲存使用者之 Notion API 資料
* 連接 Notion Database
* 模糊搜尋顏文字資料

### 產生 Tray

使用 [Tray 模組](https://www.electronjs.org/docs/latest/api/tray)，輕鬆完成。◝( •ω• )◟

同時將 `app` 的 `window-all-closed` 事件移到 `app.whenReady` 中，減少對於全域變數的依賴。

`electron\main.ts`

```ts
import path from 'node:path'
import process from 'node:process'
import {
  app,
  BrowserWindow,
  globalShortcut,
  Menu,
  shell,
  Tray,
} from 'electron'
import { version } from '../package.json'

function createTray(
  {
    mainWindow,
  }: {
    mainWindow: BrowserWindow;
  },
) {
  const tray = new Tray(
    path.join(__dirname, '../public/fish.ico'),
  )
  const contextMenu = Menu.buildFromTemplate([
    {
      label: '暫時停用',
      type: 'checkbox',
      click(item) {
        if (item.checked) {
          globalShortcut.unregisterAll()
        }
        else {
          initGlobalShortcut({ mainWindow })
        }
      },
    },
    {
      label: '詳細設定',
      submenu: [
        {
          label: '開啟設定視窗',
        },
      ],
    },
    {
      label: '關於',
      click: () => shell.openExternal('https://codlin.me/column-cod-toys/01-origin.html'),
    },
    { type: 'separator' },
    {
      label: '退出應用程式',
      click: () => app.quit(),
    },
  ])
  tray.setToolTip(`CodToys v${version}`)
  tray.setContextMenu(contextMenu)

  return tray
}

app.whenReady().then(async () => {
  const mainWindow = await createInputWindow()

  initGlobalShortcut({ mainWindow })

  initIpcMain({ configStore })

  const tray = createTray({ mainWindow })

  app.on('window-all-closed', () => { // [!code ++]
    if (process.platform !== 'darwin') { // [!code ++]
      app.quit()// [!code ++]
    }// [!code ++]

    globalShortcut.unregisterAll()// [!code ++]
    tray.destroy()// [!code ++]
  })// [!code ++]
})

app.on('window-all-closed', () => { // [!code --]
  if (process.platform !== 'darwin') { // [!code --]
    app.quit()// [!code --]
  }// [!code --]

  globalShortcut.unregisterAll()// [!code --]
})// [!code --]
```

現在會注意到系統托盤會出現魚的圖示，點擊右鍵會出現更多選單。

![create-tray](/cod-toys/create-tray.png)

### 儲存使用者設定

這裡使用 [electron-store](https://www.npmjs.com/package/electron-store) 來儲存使用者設定。

安裝套件後，第一步先來定義一下使用者設定型別並新增 `ConfigApi`，用於存取使用者設定。
`electron\electron-env.d.ts`

```ts
export interface MainApi {
  // ...
}

export interface UserConfig {
  kaomoji: {
    databaseId: string;
    token: string;
  };
}
export interface ConfigApi {
  get: () => Promise<UserConfig>;
  update: (data: Partial<UserConfig>) => Promise<void>;
  onUpdate: (callback: (config: UserConfig) => void) => void;
}

declare global {
  interface Window {
    main: MainApi;
    config: ConfigApi; // [!code ++]
  }
}

export { }
```

接著在 main process 初始化 store 並註冊 `ipcMain`。

這裡同時重構一下 `ipcMain` 的部分，window 相關控制改為針對發送的 window，不限定只能控制 `mainWindow`。

`electron\main.ts`

```ts{20-30,36-50,72}
import type { UserConfig } from './electron-env'
import {
  app,
  BrowserWindow,
  ipcMain,
  shell,
} from 'electron'
import Store from 'electron-store'

type ConfigStore = Store<{ config: UserConfig }>

function initIpcMain(
  {
    configStore,
  }: {
    configStore: ConfigStore;
  },
) {
  // main
  ipcMain.on('main:updateHeight', (event, height: number) => {
    const window = BrowserWindow.fromWebContents(event.sender)

    window?.setBounds({ height })
  })
  ipcMain.on('main:hideWindow', (event) => {
    const window = BrowserWindow.fromWebContents(event.sender)

    window?.setFocusable(false)
    window?.hide()
  })
  ipcMain.on('main:openExternal', (event, url: string) => {
    shell.openExternal(url)
  })

  // config
  ipcMain.handle('config:get', async () => {
    return configStore.get('config')
  })
  ipcMain.handle('config:update', async (event, config) => {
    const data = configStore.get('config')
    configStore.set('config', {
      ...data,
      ...config,
    })

    // 向所有視窗觸發 config:onUpdate 事件
    BrowserWindow.getAllWindows().forEach((window) => {
      window.webContents.send('config:onUpdate', config)
    })
  })
}

function createConfigStore(): ConfigStore { // [!code ++]
  const config: UserConfig = { // [!code ++]
    kaomoji: { // [!code ++]
      databaseId: '', // [!code ++]
      token: '', // [!code ++]
    }, // [!code ++]
  } // [!code ++]

  return new Store({ // [!code ++]
    name: 'config', // [!code ++]
    defaults: { config }, // [!code ++]
  }) // [!code ++]
} // [!code ++]

app.whenReady().then(async () => {
  const configStore = createConfigStore()

  // ...

  initIpcMain({ configStore })

  // ...
})
```

接著在 preload 實作 API。

`electron\preload.ts`

```ts
// ...

contextBridge.exposeInMainWorld('config', {
  get() {
    return ipcRenderer.invoke('config:get')
  },
  update(data: Partial<UserConfig>) {
    return ipcRenderer.invoke('config:update', data)
  },
  onUpdate(callback: (config: UserConfig) => void) {
    ipcRenderer.on('config:onUpdate', (event, config) => {
      callback(config)
    })
  },
})
```

現在我們可以儲存設定檔了，還需要一個頁面輸入資料才行。

### 開啟設定頁面

預計使用 Vue Router，由於 Electron 本身沒有 Web Server，所以沒辦法直接使用 History 模式，這裡我們將 router 改為 Hash 模式解決。

`src\main.ts`

```ts
import { createRouter, createWebHashHistory } from 'vue-router/auto'

// ...

const router = createRouter({
  history: createWebHashHistory(import.meta.env.BASE_URL), // [!code highlight]
  routes,
})

// ...
```

接著新增設定頁面吧。

`src\pages\kaomoji-config\index.vue`

```vue
<template>
  <q-form
    class="relative flex-col gap-6 p-6"
    @submit="handleSubmit"
  >
    <q-input
      v-model="form.databaseId"
      outlined
      label="databaseId"
    />

    <q-input
      v-model="form.token"
      outlined
      label="token"
    />

    <q-btn
      label="儲存"
      type="submit"
      unelevated
      color="primary"
    />

    <q-inner-loading :showing="isLoading" />
  </q-form>
</template>

<script setup lang="ts">
import type { UserConfig } from '../../../electron/electron-env'
import { useAsyncState } from '@vueuse/core'
import { useQuasar } from 'quasar'
import { clone } from 'remeda'
import { ref } from 'vue'
import { useConfigApi } from '../../composables/use-config-api'

const configApi = useConfigApi()
const $q = useQuasar()

document.title = '顏文字設定'

const form = ref<UserConfig['kaomoji']>({
  databaseId: '',
  token: '',
})

const {
  isLoading,
} = useAsyncState(
  () => configApi.get(),
  undefined,
  {
    onSuccess(data) {
      if (data) {
        form.value = data.kaomoji
      }
    },
  },
)

function handleSubmit() {
  // clone 處理，避免 Error: An object could not be cloned
  configApi.update(clone({
    kaomoji: form.value,
  })).then(() => {
    $q.notify({
      type: 'positive',
      message: '儲存成功',
    })
  }).catch(() => {
    $q.notify({
      type: 'negative',
      message: '儲存失敗',
    })
  })
}
</script>
```

現在讓我們透過 Tray 的選單開啟此設定頁面吧。

建立 `createConfigWindow` 用於開啟指定頁面並將目標 route path 加在 # 後面即可。

```ts
// ...

async function createConfigWindow(route: keyof RouteNamedMap) {
  const newWindow = new BrowserWindow({
    backgroundColor: '#fff',
    webPreferences: {
      preload: path.join(__dirname, './preload.js'),
    },
  })
  // 隱藏預設系統選單
  newWindow.setMenu(null)

  if (process.env.VITE_DEV_SERVER_URL) {
    await newWindow.loadURL(
      `${process.env.VITE_DEV_SERVER_URL}#${route}`,
    )
    // newWindow.webContents.openDevTools()
  }
  else {
    // loadFile 可以指定 hash
    await newWindow.loadFile('dist/index.html', {
      hash: route,
    })
  }

  return newWindow
}

// ...

function createTray(
  // ...
) {
  // ...
  const contextMenu = Menu.buildFromTemplate([
    // ...
    {
      label: '詳細設定',
      submenu: [
        {
          label: '開啟設定視窗',
          click: () => createConfigWindow('/kaomoji-config/'), // [!code ++]
        },
      ],
    },
    // ...
  ])
  // ...
}

// ...
```

:::tip
`RouteNamedMap` 是 `unplugin-vue-router` 自動產生的型別，用於定義所有頁面路由與參數。
:::

現在讓我們從 Tray 開啟設定頁面看看吧！(/≧▽≦)/

![kaomoji-config](/cod-toys/kaomoji-config.png)

...可以看到畫面相當簡潔有力，有機會再來慢慢美化吧。%( •̀ ω •́ )✧%

### 連接 Notion Database

現在讓我們透過 Notion API 來連接指定的 Database 吧！

首先需要先建立 Integrations，前往[設定頁面](https://www.notion.so/profile/integrations)並依照步驟建立。

1. 點擊畫面中的「+ New integration」
2. Associated workspace 選擇要使用的 Workspace
3. Type 選擇 Internal
4. 按下 Save

這樣就建立完成了！接著我們需要取得並調整一下 Token 設定。

在 Integrations 頁面中，點擊剛剛建立的 Integration，會進到設定頁面，讓我們限縮以下此 Token 權限，保持最小權限原則。

基本上就是只有 Read 權限，如下圖。

![notion-token-capabilities](/cod-toys/notion-token-capabilities.png)

複製 Internal Integration Secret 中的 token，並貼到設定頁面中。

接著來建立 Database。

1. 將原本預設的 Name 欄位改名為 value，內容為顏文字
2. 新增一個名為 tags 的多選欄位，用來提供篩選資料
3. 將 database 使用 open as page 開啟
4. 點擊最右上角的三個點，將 Connections 設定為剛剛建立的 Integration

完成後結果會如下圖。

![notion-connections](/cod-toys/notion-connections.png)

最後目前網頁的 URL 會是 `https://www.notion.so/{workspace}/{databaseId}?xxx`，將此 URL 中的 databaseId 複製下來，貼到設定頁面中後按下儲存即可。

![save-kaomoji-config](/cod-toys/save-kaomoji-config.png)

### 取得 Notion Database 資料

現在設定資料有了，讓我們串接 Notion API 吧。

安裝 `@notionhq/client` 套件來存取 Notion API 並加入取得資料邏輯。

`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="item, i in list"
        :key="i"
        class="w-full px-4 py-2"
        :action="() => copy(item.value)"
      >
        <div class="flex-1">
          {{ item.value }}
        </div>

        <div class="flex">
          <q-chip
            v-for="tag in item.tags"
            :key="tag"
            :label="tag"
          />
        </div>
      </feature-option>
    </template>
  </template>
</template>

<script setup lang="ts">
import type { UserConfig } from '../../../electron/electron-env'
import { Client } from '@notionhq/client'
import { useAsyncState } from '@vueuse/core'
import dayjs from 'dayjs'
import { get } from 'lodash-es'
import { pipe, reduce, take } from 'remeda'
import { computed, ref, shallowRef, triggerRef } from 'vue'
import FeatureOption from '../../components/feature-option.vue'
import { useConfigApi } from '../../composables/use-config-api'
import { useFeatureStore } from '../../stores/feature.store'

const configApi = useConfigApi()
const featureStore = useFeatureStore()

// ...

const {
  state: config,
  execute: refreshConfig,
} = useAsyncState(
  () => configApi.get(),
  undefined,
  { immediate: false },
)

let notionClient: Client | undefined
function initNotionClient(config: UserConfig) {
  notionClient = new Client({
    auth: config.kaomoji.token,
  })
}

const updatedAt = shallowRef(dayjs())
const notionData = shallowRef<any[]>([])
const startCursor = ref('')
const {
  isLoading: isDataLoading,
  execute: getData,
} = useAsyncState(
  async () => {
    const databaseId = config.value?.kaomoji.databaseId
    if (!databaseId) {
      throw new Error('尚未設定 databaseId')
    }

    return notionClient?.databases.query({
      database_id: databaseId,
      start_cursor: startCursor.value ? startCursor.value : undefined,
    })
  },
  undefined,
  {
    immediate: false,
    onSuccess(result) {
      notionData.value.push(...(result?.results ?? []))

      if (result?.has_more && result.next_cursor) {
        startCursor.value = result.next_cursor
        getData()
        return
      }

      triggerRef(notionData)
      updatedAt.value = dayjs()
    },
  },
)

function refreshData() {
  startCursor.value = ''
  notionData.value = []
  getData()
}

interface ListItem {
  value: string;
  tags: string[];
}
const list = computed(() => pipe(
  notionData.value,
  reduce((acc: ListItem[], result) => {
    acc.push({
      // @notionhq/client 的型別與實際資料有點出入，自行從回應判斷
      value: get(result, 'properties.value.title[0].plain_text', '') as string,
      tags: pipe(
        get(result, 'properties.tags.multi_select', []) as { name: string }[],
        (value) => {
          if (!value) {
            return []
          }

          return value.map((item) => item.name)
        },
      ),
    })

    return acc
  }, []),
  // 先顯示 5 個看看
  take(5),
))

configApi.onUpdate(async (config) => {
  init(config)
})

async function init(config?: UserConfig) {
  const _config = config ?? await refreshConfig()
  if (!_config) {
    return
  }

  initNotionClient(_config)
  refreshData()
}
init()
</script>
```

![get-kaomoji-database-data](/cod-toys/get-kaomoji-database-data.png)

資料進來了！( ´ ▽ \` )ﾉ

接著新增更新用的按鈕，讓使用者可以手動更新資料。

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

```vue
<template>
  <template v-if="visible">
    <feature-option
      v-if="!isFeature"
      ...
    />

    <template v-else>
      <feature-option
        v-for="item, i in list"
        :key="i"
        ...
      >
        ...
      </feature-option>

      <feature-option
        class="relative w-full bg-primary/10 p-4"
        :action="() => refreshData()"
      >
        <div class="w-full flex items-center justify-around">
          <span class="flex-1">
            更新資料
          </span>
          <span class="flex-1 text-right text-xs text-gray-500">
            共 {{ list.length }} 筆，最後更新於 {{ updatedAt.format('YYYY/MM/DD HH:mm:ss') }}
          </span>
        </div>

        <q-inner-loading :showing="isDataLoading" />
      </feature-option>
    </template>
  </template>
</template>
```

![refresh-kaomoji-database-data](/cod-toys/refresh-kaomoji-database-data.gif)

由於資料可能很多，最後讓我們實現分頁功能吧！

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

```vue
<template>
  <template v-if="visible">
    <feature-option
      v-if="!isFeature"
    />

    <template v-else>
      <!-- WARNING --> // [!code highlight]
      <div
        :key="pagination.page"
        class="flex-col"
      >
        <feature-option
          v-if="nextPageVisible"
          class="w-full px-4 py-2"
          :action="() => nextPage()"
        >
          <span class="flex-1">
            下一頁 ({{ pagination.page + 1 }}/{{ totalPages }})
          </span>
        </feature-option>

        ...
      </div>
    </template>
  </template>
</template>

<script setup lang="ts">
import { chunk, pipe } from 'remeda'
import { computed, nextTick, ref } from 'vue'
import FeatureOption from '../../components/feature-option.vue'

// ...

const pagination = ref({
  page: 0,
  itemsPerPage: 5,
})
watch(inputText, () => {
  pagination.value.page = 0
})
const totalPages = computed(() => Math.ceil(list.value.length / pagination.value.itemsPerPage))

const paginationList = computed(() => pipe(
  list.value,
  chunk(pagination.value.itemsPerPage),
  (data) => data[pagination.value.page] ?? [],
))

const nextPageVisible = computed(() => totalPages.value > 1)
async function nextPage() {
  pagination.value.page += 1
  pagination.value.page %= totalPages.value

  await nextTick()
  featureStore.setOption(featureStore.optionIdList[0] ?? '')
}

configApi.onUpdate(async (config) => {
  init(config)
})

// ...
</script>
```

::: warning
程式碼中有個 `<!-- WARNING -->` 部分，`:key="pagination.page"` 是為了在換頁時強制重新渲染，才能保證所有 option 順序一致，這個方法會有不必要的性能損號，未來再來改進。
:::

![pagination-kaomoji-data](/cod-toys/pagination-kaomoji-data.gif)

所有的資料都進來了！✧⁑｡٩(ˊᗜˋ\*)و✧⁕｡

### 模糊過濾

最後讓我們來實作模糊過濾功能吧！

我們希望即使只有部分文字符合，也能候選最接近的資料。

這裡使用 [fuse.js](https://fusejs.io/) 實作模糊過濾功能。

這個部份很單純，困難的部分 `fuse.js` 已經幫我們處理好了，我們只要爽爽用就行惹。%ψ(｀∇´)ψ%

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

```vue
// ...

<script setup lang="ts">
// ...

const fuseInstance = new Fuse(list.value, {
  keys: ['tags'],
})
watch(list, (value) => {
  fuseInstance.setCollection(value)
})

const filteredList = computed(() => {
  if (inputText.value === '@') {
    return list.value
  }

  const text = pipe(
    inputText.value,
    (value) => {
      if (value.startsWith('@')) {
        return value.slice(1)
      }

      return value
    },
    (value) => value.trim(),
  )

  const result = fuseInstance.search(text)

  return pipe(
    result,
    map(prop('item')),
  )
})

// ...

const totalPages = computed(() => Math.ceil(
  list.value.length / pagination.value.itemsPerPage, // [!code --]
  filteredList.value.length / pagination.value.itemsPerPage, // [!code ++]
))

const paginationList = computed(() => pipe(
  list.value, // [!code --]
  filteredList.value, // [!code ++]
  chunk(pagination.value.itemsPerPage),
  (data) => data[pagination.value.page] ?? [],
))

// ...
</script>
```

![fuse-search](/cod-toys/fuse-search.gif)

就這麼簡單。(\*´∀\`)~♥

現在讓我們完整的展示一次功能。

* 輸入 @ 可以開始搜尋顏文字
* 沒有匹配任何功能時，則開啟 Google 搜尋

![phase-1-completed](/cod-toys/phase-1-completed.gif)

感覺超讚！✧⁑｡٩(ˊᗜˋ\*)و✧⁕｡

## 改進與重構

實際上使用後發現有些地方需要改進，讓我們來改進一下吧！%੭ ˙ᗜ˙ )੭%

### 取消滑鼠 hover 效果

滑鼠 hover 效果會讓使用者混淆目前選項，狠心刪除。%ԅ( ˘ω˘ԅ)%

`src\components\feature-option.vue`

```vue
// ...

<script setup lang="ts">
// ...
const optionRef = ref<HTMLDivElement>()
const isHover = useElementHover(optionRef) // [!code --]
const selected = computed( // [!code --]
  () => isHover.value || featureStore.selectedOptionId === id, // [!code --]
) // [!code --]
const selected = computed(() => featureStore.selectedOptionId === id) // [!code ++]
</script>
```

### feature-option 重複使用時，選擇順序錯誤問題

剛剛提到 `<!-- WARNING -->` 強制更新部分，這裡我們來改進一下。

`featureStore` 註冊 option 時，多提供元件 y 座標位置，只要根據 y 座標排序，選項順序就會同步自然順序了。

`src\stores\feature.store.ts`

```ts
import { defineStore } from 'pinia'
import { map, pipe, sortBy } from 'remeda'
import { computed, shallowRef } from 'vue'

interface OptionValue {
  y: number; // [!code ++]
  action: () => void;
}

export const useFeatureStore = defineStore('feature', () => {
  const optionMap = shallowRef(new Map<string, OptionValue>())
  const optionIdList = computed(() => pipe(
    [...optionMap.value.keys()], // [!code --]
    /** 依照數字排序，保證 option 順序 */ // [!code --]
    sortBy((id) => Number.parseInt(id.replace(/\D/g, ''), 10)), // [!code --]
    [...optionMap.value.entries()], // [!code ++]
    /** 依照畫面 y 座標排序，保證 option 自然順序 */// [!code ++]
    sortBy((data) => data[1].y), // [!code ++]
    map(([id]) => id), // [!code ++]
  ))

  // ...
})
```

feature-option 元件註冊時提供 y 座標。

`src\components\feature-option.vue`

```vue
// ...

<script setup lang="ts">
// ...

const { y } = useElementBounding(optionRef)

featureStore.addOption(id, { // [!code --]
  action: props.action, // [!code --]
}) // [!code --]

watch(y, (value) => { // [!code ++]
  featureStore.addOption(id, { // [!code ++]
    y: value, // [!code ++]
    action: props.action, // [!code ++]
  }) // [!code ++]
}) // [!code ++]
// ...
</script>
```

### 數字鍵盤快速選擇

一直按上下鍵有點麻煩，加個使用數字鍵快速選擇功能。( •̀ ω •́ )✧

使用 VueUse 的 `useMagicKeys` 輕鬆實現。%(´,,•ω•,,)%

開始前先讓我們整理、重構一下內容，讓元件內的程式碼更內聚一點。

元件與資料可以獨立存在，所以首先抽離資料部分。

`src\domains\feature-card-kaomoji\use-kaomoji-data.ts`

```ts
// ...

export function useKaomojiData(
  inputText: Ref<string>,
) {
  const configApi = useConfigApi()
  const featureStore = useFeatureStore()

  const {
    state: config,
    execute: refreshConfig,
  } = useAsyncState(
    // ...
  )

  let notionClient: Client | undefined
  function initNotionClient(config: UserConfig) {
    // ...
  }

  const updatedAt = shallowRef(dayjs())
  const notionData = shallowRef<any[]>([])
  const startCursor = ref('')
  const {
    isLoading,
    execute: getData,
  } = useAsyncState(
    // ...
  )

  function refreshData() {
    // ...
  }

  interface ListItem {
    value: string;
    tags: string[];
  }
  const list = computed(() => pipe(
    // ...
  ))

  const fuseInstance = new Fuse(list.value, {
    keys: ['tags'],
  })
  watch(list, (value) => {
    fuseInstance.setCollection(value)
  })

  const filteredList = computed(() => {
    // ...
  })

  const pagination = ref({
    page: 0,
    itemsPerPage: 5,
  })
  watch(inputText, () => {
    pagination.value.page = 0
  })
  const totalPages = computed(() => Math.ceil(
    filteredList.value.length / pagination.value.itemsPerPage,
  ))

  const paginationList = computed(() => pipe(
    // ...
  ))

  async function nextPage() {
    // ...
  }

  configApi.onUpdate(async (config) => {
    init(config)
  })

  async function init(config?: UserConfig) {
    // ...
  }

  return {
    isLoading,
    updatedAt,

    list,

    pagination,
    paginationList,
    totalPages,
    nextPage,

    init,
    refreshData,
  }
}
```

基本上就是把資料的部份抽離，所以具體內容就不贅述了。

現在元件內部變得相當簡潔。

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

```vue
<template>
  ...
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useKaomojiData } from './use-kaomoji-data'

// ...

function copy(text: string) {
  // ...
}

const {
  isLoading,
  updatedAt,
  pagination,
  paginationList,
  list,
  nextPage,
  totalPages,
  refreshData,
  init,
} = useKaomojiData(inputText)
init()

const nextPageVisible = computed(() => totalPages.value > 1)
</script>
```

現在讓我們在 `featureStore` 新增一個 method。

`src\stores\feature.store.ts`

```ts
// ...
export const useFeatureStore = defineStore('feature', () => {
  // ...
  function setOptionByIndex(index: number) {
    selectedOptionId.value = optionIdList.value[index] ?? ''
  }
  // ...

  return {
    // ...
    setOptionByIndex,
  }
})
```

最後在元件中使用 `useMagicKeys` 並加上快捷鍵提示吧。੭ ˙ᗜ˙ )੭

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

```vue
<template>
  <template v-if="visible">
    <feature-option
      v-if="!isFeature"
      ...
    />

    <template v-else>
      <feature-option
        v-if="nextPageVisible"
        ...
      >
        <q-chip
          v-if="isFeature"
          label="Ctrl+1"
          square
          outline
          color="primary"
        />
        <span class="flex-1">
          下一頁 ({{ pagination.page + 1 }}/{{ totalPages }})
        </span>
      </feature-option>

      <feature-option
        v-for="item, i in paginationList"
        :key="i"
        ...
      >
        <q-chip
          v-if="isFeature"
          :label="`Ctrl+${i + textStartIndex}`"
          square
          outline
          color="primary"
        />

        ...
      </feature-option>

      ...
    </template>
  </template>
</template>

<script setup lang="ts">
// ...

const nextPageVisible = computed(() => totalPages.value > 1)

const textStartIndex = computed(() => nextPageVisible.value ? 2 : 1)

const { current } = useMagicKeys()

watch(current, (keySet) => {
  // 僅使用此功能才作用，避免干擾其他功能
  if (!isFeature.value || keySet.size === 0)
    return

  if (!keySet.has('control'))
    return

  /** 1~6 */
  const numberKey = [...keySet].find((key) => key.match(/^[1-6]$/))
  if (numberKey) {
    const index = Number(numberKey) - 1
    featureStore.setOptionByIndex(index)
    featureStore.currentOption?.action()
  }
}, { deep: true })
</script>
```

現在我們可以很快速地選擇目標顏文字了！( •̀ ω •́ )✧

![shortcut-key-selection-options](/cod-toys/shortcut-key-selection-options.gif)

## 總結 🐟

以上程式碼可以[在此取得](https://github.com/Codfisher/side-project-cod-toys/tree/feat/kaomoji-database)

* 串接 Notion API，完成顏文字資料庫功能
* 抽離、重構程式碼，讓程式碼更具內聚性
* 加入快捷鍵功能，讓使用者更快速選擇顏文字

感覺還可以加入很多很有趣的功能，歡迎大家留言或寫信許願喔！(´▽\`ʃ♡ƪ)

::: tip
因為篇幅問題，某些部分簡單帶過。

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