diff --git a/docs/pages/examples.vue b/docs/pages/examples.vue index c5a9aeee..1d225f51 100644 --- a/docs/pages/examples.vue +++ b/docs/pages/examples.vue @@ -128,6 +128,16 @@ +
+
+ Copy text to clipboard: +
+
+ + +
+
+
Context menu: @@ -230,6 +240,7 @@ diff --git a/package.json b/package.json index 534a0345..115d6a7b 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@tailwindcss/typography": "^0.5.9", "@vueuse/core": "^9.13.0", "@vueuse/integrations": "^9.13.0", + "@vueuse/math": "^9.13.0", "defu": "^6.1.2", "fuse.js": "^6.6.2", "lodash-es": "^4.17.21", diff --git a/src/module.ts b/src/module.ts index f36c563d..a5d6f4e9 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,4 +1,4 @@ -import { defineNuxtModule, installModule, addComponentsDir, addImportsDir, addTemplate, addPlugin, createResolver } from '@nuxt/kit' +import { defineNuxtModule, installModule, addComponentsDir, addImportsDir, addTemplate, createResolver } from '@nuxt/kit' import { defu } from 'defu' import colors from 'tailwindcss/colors.js' import type { Config } from 'tailwindcss' @@ -155,9 +155,6 @@ export default defineNuxtModule({ cssPath: resolve(runtimeDir, 'tailwind.css') }) - addPlugin(resolve(runtimeDir, 'plugins', 'toast.client')) - addPlugin(resolve(runtimeDir, 'plugins', 'clipboard.client')) - addComponentsDir({ path: resolve(runtimeDir, 'components', 'elements'), prefix, diff --git a/src/runtime/components/overlays/Notifications.vue b/src/runtime/components/overlays/Notifications.vue index c20beaad..88495db9 100644 --- a/src/runtime/components/overlays/Notifications.vue +++ b/src/runtime/components/overlays/Notifications.vue @@ -9,7 +9,7 @@ v-bind="notification" :class="notification.click && 'cursor-pointer'" @click="notification.click && notification.click(notification)" - @close="$toast.removeNotification(notification.id)" + @close="toast.removeNotification(notification.id)" />
@@ -18,10 +18,11 @@ diff --git a/src/runtime/composables/defineShortcuts.ts b/src/runtime/composables/defineShortcuts.ts new file mode 100644 index 00000000..f36c7f2c --- /dev/null +++ b/src/runtime/composables/defineShortcuts.ts @@ -0,0 +1,109 @@ +import type { Ref, ComputedRef } from 'vue' +import { logicAnd, logicNot } from '@vueuse/math' +import { onMounted, onBeforeUnmount } from 'vue' +import { useShortcuts } from './useShortcuts' + +export interface ShortcutConfig { + handler: Function + usingInput?: string | boolean + whenever?: Ref[] +} + +export interface ShortcutsConfig { + [key: string]: ShortcutConfig | Function +} + +interface Shortcut { + handler: Function + condition: ComputedRef + // KeyboardEvent attributes + key: string + ctrlKey: boolean + metaKey: boolean + shiftKey: boolean + altKey: boolean + // code?: string + // keyCode?: number +} + +export const defineShortcuts = (config: ShortcutsConfig) => { + const { macOS, usingInput } = useShortcuts() + + let shortcuts: Shortcut[] = [] + + const onKeyDown = (e: KeyboardEvent) => { + const alphabeticalKey = /^[a-z]{1}$/.test(e.key) + + for (const shortcut of shortcuts) { + if (e.key.toLowerCase() !== shortcut.key) { continue } + if (e.metaKey !== shortcut.metaKey) { continue } + if (e.ctrlKey !== shortcut.ctrlKey) { continue } + // shift modifier is only checked in combination with alphabetical keys + // (shift with non-alphabetical keys would change the key) + if (alphabeticalKey && e.shiftKey !== shortcut.shiftKey) { continue } + // alt modifier changes the combined key anyways + // if (e.altKey !== shortcut.altKey) { continue } + + if (shortcut.condition.value) { + e.preventDefault() + shortcut.handler() + } + return + } + } + + onMounted(() => { + // Map config to full detailled shortcuts + shortcuts = Object.entries(config).map(([key, shortcutConfig]) => { + if (!shortcutConfig) { + return null + } + + // Parse key and modifiers + const keySplit = key.toLowerCase().split('_').map(k => k) + let shortcut: Partial = { + key: keySplit.filter(k => !['meta', 'ctrl', 'shift', 'alt'].includes(k)).join('_'), + metaKey: keySplit.includes('meta'), + ctrlKey: keySplit.includes('ctrl'), + shiftKey: keySplit.includes('shift'), + altKey: keySplit.includes('alt') + } + + // Convert Meta to Ctrl for non-MacOS + if (!macOS.value && shortcut.metaKey && !shortcut.ctrlKey) { + shortcut.metaKey = false + shortcut.ctrlKey = true + } + + // Retrieve handler function + if (typeof shortcutConfig === 'function') { + shortcut.handler = shortcutConfig + } else if (typeof shortcutConfig === 'object') { + shortcut = { ...shortcut, handler: shortcutConfig.handler } + } + + if (!shortcut.handler) { + // eslint-disable-next-line no-console + console.trace('[Shortcut] Invalid value') + return null + } + + // Create shortcut computed + const conditions = [] + if (!(shortcutConfig as ShortcutConfig).usingInput) { + conditions.push(logicNot(usingInput)) + } else if (typeof (shortcutConfig as ShortcutConfig).usingInput === 'string') { + conditions.push(computed(() => usingInput.value === (shortcutConfig as ShortcutConfig).usingInput)) + } + shortcut.condition = logicAnd(...conditions, ...((shortcutConfig as ShortcutConfig).whenever || [])) + + return shortcut as Shortcut + }).filter(Boolean) as Shortcut[] + + document.addEventListener('keydown', onKeyDown) + }) + + onBeforeUnmount(() => { + document.removeEventListener('keydown', onKeyDown) + }) +} diff --git a/src/runtime/plugins/clipboard.client.ts b/src/runtime/composables/useCopyToClipboard.ts similarity index 71% rename from src/runtime/plugins/clipboard.client.ts rename to src/runtime/composables/useCopyToClipboard.ts index 20b7ce58..0b62d008 100644 --- a/src/runtime/plugins/clipboard.client.ts +++ b/src/runtime/composables/useCopyToClipboard.ts @@ -1,8 +1,9 @@ import { useClipboard } from '@vueuse/core' -import { defineNuxtPlugin } from '#app' +import { useToast } from './useToast' -export default defineNuxtPlugin((nuxtApp) => { +export function useCopyToClipboard () { const { copy: copyToClipboard, isSupported } = useClipboard() + const toast = useToast() function copy (text: string, success: { title?: string, description?: string } = {}, failure: { title?: string, description?: string } = {}) { if (!isSupported) { @@ -14,9 +15,9 @@ export default defineNuxtPlugin((nuxtApp) => { return } - nuxtApp.$toast.success(success) + toast.success(success) }, function (e) { - nuxtApp.$toast.error({ + toast.error({ ...failure, description: failure.description || e.message }) @@ -24,10 +25,6 @@ export default defineNuxtPlugin((nuxtApp) => { } return { - provide: { - clipboard: { - copy - } - } + copy } -}) +} diff --git a/src/runtime/composables/useShortcuts.ts b/src/runtime/composables/useShortcuts.ts new file mode 100644 index 00000000..80031eb0 --- /dev/null +++ b/src/runtime/composables/useShortcuts.ts @@ -0,0 +1,32 @@ +import { createSharedComposable, useActiveElement } from '@vueuse/core' +import { ref, computed, onMounted } from 'vue' + +export const _useShortcuts = () => { + const macOS = computed(() => process.client && navigator && navigator.userAgent && navigator.userAgent.match(/Macintosh;/)) + + const metaSymbol = ref(' ') + + const activeElement = useActiveElement() + const usingInput = computed(() => { + const usingInput = !!(activeElement.value?.tagName === 'INPUT' || activeElement.value?.tagName === 'TEXTAREA' || activeElement.value?.contentEditable === 'true') + + if (usingInput) { + return ((activeElement.value as any)?.name as string) || true + } + + return false + }) + + onMounted(() => { + metaSymbol.value = macOS.value ? '⌘' : 'Ctrl' + }) + + return { + macOS, + metaSymbol, + activeElement, + usingInput + } +} + +export const useShortcuts = createSharedComposable(_useShortcuts) diff --git a/src/runtime/composables/useToast.ts b/src/runtime/composables/useToast.ts new file mode 100644 index 00000000..75a93464 --- /dev/null +++ b/src/runtime/composables/useToast.ts @@ -0,0 +1,41 @@ +import type { ToastNotification } from '../types' +import { useState } from '#imports' + +export function useToast () { + const notifications = useState('notifications', () => []) + + function addNotification (notification: Partial) { + const body = { + id: new Date().getTime().toString(), + ...notification + } + + const index = notifications.value.findIndex((n: ToastNotification) => n.id === body.id) + if (index === -1) { + notifications.value.push(body as ToastNotification) + } + + return body + } + + function removeNotification (id: string) { + notifications.value = notifications.value.filter((n: ToastNotification) => n.id !== id) + } + + const success = (notification: Partial = {}) => addNotification({ type: 'success', ...notification }) + + const info = (notification: Partial = {}) => addNotification({ type: 'info', ...notification }) + + const warning = (notification: Partial = {}) => addNotification({ type: 'warning', ...notification }) + + const error = (notification: Partial) => addNotification({ type: 'error', title: 'An error occurred!', ...notification }) + + return { + addNotification, + removeNotification, + success, + info, + warning, + error + } +} diff --git a/src/runtime/plugins/toast.client.ts b/src/runtime/plugins/toast.client.ts deleted file mode 100644 index 43c9366a..00000000 --- a/src/runtime/plugins/toast.client.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { defineNuxtPlugin, useState } from '#app' -import type { ToastNotification } from '../types' - -export default defineNuxtPlugin(() => { - const notifications = useState('notifications', () => []) - - function addNotification (notification: Partial) { - const body = { - id: new Date().getTime().toString(), - ...notification - } - - const index = notifications.value.findIndex((n: ToastNotification) => n.id === body.id) - if (index === -1) { - notifications.value.push(body as ToastNotification) - } - - return body - } - - function removeNotification (id: string) { - notifications.value = notifications.value.filter((n: ToastNotification) => n.id !== id) - } - - return { - provide: { - toast: { - addNotification, - removeNotification, - success (notification: Partial = {}) { - return addNotification({ type: 'success', ...notification }) - }, - info (notification: Partial = {}) { - return addNotification({ type: 'info', ...notification }) - }, - warning (notification: Partial = {}) { - return addNotification({ type: 'warning', ...notification }) - }, - error (notification: Partial) { - return addNotification({ type: 'error', title: 'An error occurred!', ...notification }) - } - } - } - } -}) diff --git a/src/runtime/types/toast.d.ts b/src/runtime/types/toast.d.ts index 01512e8b..33a95e8e 100644 --- a/src/runtime/types/toast.d.ts +++ b/src/runtime/types/toast.d.ts @@ -14,10 +14,3 @@ export interface ToastNotification { click?: Function callback?: Function } - -export interface ToastPlugin { - addNotification: (notification: Partial) => Notification - removeNotification: (id: string) => void - success: (options: { title?: string, description?: string }) => void - error: (options: { title?: string, description?: string }) => void -} diff --git a/yarn.lock b/yarn.lock index 07a57f85..b8d01b68 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1827,6 +1827,14 @@ "@vueuse/shared" "9.13.0" vue-demi "*" +"@vueuse/math@^9.13.0": + version "9.13.0" + resolved "https://registry.yarnpkg.com/@vueuse/math/-/math-9.13.0.tgz#3b4890dd80035b923195a725e2af73470a16bddf" + integrity sha512-FE2n8J1AfBb4dNvNyE6wS+l87XDcC/y3/037AmrwonsGD5QwJJl6rGr57idszs3PXTuEYcEkDysHLxstSxbQEg== + dependencies: + "@vueuse/shared" "9.13.0" + vue-demi "*" + "@vueuse/metadata@9.13.0": version "9.13.0" resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-9.13.0.tgz#bc25a6cdad1b1a93c36ce30191124da6520539ff"