Skip to content

CodToys - EP03:顏文字資料庫

快樂堆積木的魚

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

開發

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

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

產生 Tray

使用 Tray 模組,輕鬆完成。◝( •ω• )◟

同時將 appwindow-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', () => { 
    if (process.platform !== 'darwin') { 
      app.quit()
    }

    globalShortcut.unregisterAll()
    tray.destroy()
  })
})

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

  globalShortcut.unregisterAll()
})

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

create-tray

儲存使用者設定

這裡使用 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; 
  }
}

export { }

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

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

electron\main.ts

ts
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 { 
  const config: UserConfig = { 
    kaomoji: { 
      databaseId: '', 
      token: '', 
    }, 
  } 

  return new Store({ 
    name: 'config', 
    defaults: { config }, 
  }) 
} 

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), 
  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/'), 
        },
      ],
    },
    // ...
  ])
  // ...
}

// ...

TIP

RouteNamedMapunplugin-vue-router 自動產生的型別,用於定義所有頁面路由與參數。

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

kaomoji-config

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

連接 Notion Database

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

首先需要先建立 Integrations,前往設定頁面並依照步驟建立。

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

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

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

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

notion-token-capabilities

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

接著來建立 Database。

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

完成後結果會如下圖。

notion-connections

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

save-kaomoji-config

取得 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

資料進來了!( ´ ▽ ` )ノ

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

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

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

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

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

    <template v-else>
      <!-- WARNING -->
      <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

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

模糊過濾

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

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

這裡使用 fuse.js 實作模糊過濾功能。

這個部份很單純,困難的部分 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, 
  filteredList.value.length / pagination.value.itemsPerPage, 
))

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

// ...
</script>

fuse-search

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

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

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

phase-1-completed

感覺超讚!✧⁑。٩(ˊᗜˋ*)و✧⁕。

改進與重構

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

取消滑鼠 hover 效果

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

src\components\feature-option.vue

vue
// ...

<script setup lang="ts">
// ...
const optionRef = ref<HTMLDivElement>()
const isHover = useElementHover(optionRef) 
const selected = computed( 
  () => isHover.value || featureStore.selectedOptionId === id, 
) 
const selected = computed(() => featureStore.selectedOptionId === id) 
</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; 
  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)), 
    [...optionMap.value.entries()], 
    /** 依照畫面 y 座標排序,保證 option 自然順序 */
    sortBy((data) => data[1].y), 
    map(([id]) => id), 
  ))

  // ...
})

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

src\components\feature-option.vue

vue
// ...

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

const { y } = useElementBounding(optionRef)

featureStore.addOption(id, { 
  action: props.action, 
}) 

watch(y, (value) => { 
  featureStore.addOption(id, { 
    y: value, 
    action: props.action, 
  }) 
}) 
// ...
</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

總結 🐟

以上程式碼可以在此取得

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

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

TIP

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

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