Skip to content

Commit d3fe5c3

Browse files
authored
fix(app): context aware keyboard shortcut commands (#16)
* fix(app): 12 context aware keyboard shortcut command This pull request is intended to fix the keyboard shortcut command conflict between Tiptap editor's ( Cmd + b for bold ) and Shadcn's Sidebar ( Cmd + b for collapsing the sidebar ) by creating a new composable named useSmartShortCut that enables us to define keyboard shortcuts in a context aware manner that doesn't affect nor trigger keyboard shortcuts that share the same keys and that are defined in a global context. Closes: 12 * chore: remove unused shortcuts import from Tiptap components
1 parent e6fd6a7 commit d3fe5c3

File tree

7 files changed

+147
-18
lines changed

7 files changed

+147
-18
lines changed

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,4 @@ jobs:
2222

2323
- run: npx changelogithub
2424
env:
25-
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
25+
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}

app/components/tiptap/TiptapKeyboardShortcuts.vue

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,11 @@
11
<script setup lang="ts">
22
import type { HTMLAttributes } from 'vue'
33
import { cn } from '@/lib/utils'
4+
import { shortcuts } from './tiptapTreeUtils'
45
56
const props = defineProps<{
67
class?: HTMLAttributes['class']
78
}>()
8-
9-
const shortcuts = [
10-
{ keys: ['Ctrl', 'B'], description: 'Bold' },
11-
{ keys: ['Ctrl', 'I'], description: 'Italic' },
12-
{ keys: ['Ctrl', 'U'], description: 'Underline' },
13-
{ keys: ['Ctrl', '`'], description: 'Code' },
14-
{ keys: ['Ctrl', 'Alt', '1-6'], description: 'Heading 1-6' },
15-
{ keys: ['Ctrl', 'Shift', '8'], description: 'Bullet list' },
16-
{ keys: ['Ctrl', 'Shift', '9'], description: 'Ordered list' },
17-
{ keys: ['Tab'], description: 'Indent' },
18-
{ keys: ['Shift', 'Tab'], description: 'Outdent' },
19-
{ keys: ['Ctrl', 'Z'], description: 'Undo' },
20-
{ keys: ['Ctrl', 'Y'], description: 'Redo' },
21-
]
229
</script>
2310

2411
<template>

app/components/tiptap/TiptapProvider.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { computed, onMounted, ref, watch } from 'vue'
44
import { provideTiptapContext } from '.'
55
66
const props = defineProps<{
7-
editor: Editor | null
7+
editor: Editor | undefined
88
}>()
99
1010
// State management for the Tiptap context

app/components/tiptap/tiptapTreeUtils.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,17 @@ export function editorNodesToTree(nodes: any[]): TiptapTreeItem[] {
159159
depth: node.depth || 0,
160160
}))
161161
}
162+
163+
export const shortcuts = [
164+
{ keys: ['Ctrl', 'B'], description: 'Bold' },
165+
{ keys: ['Ctrl', 'I'], description: 'Italic' },
166+
{ keys: ['Ctrl', 'U'], description: 'Underline' },
167+
{ keys: ['Ctrl', '`'], description: 'Code' },
168+
{ keys: ['Ctrl', 'Alt', '1-6'], description: 'Heading 1-6' },
169+
{ keys: ['Ctrl', 'Shift', '8'], description: 'Bullet list' },
170+
{ keys: ['Ctrl', 'Shift', '9'], description: 'Ordered list' },
171+
{ keys: ['Tab'], description: 'Indent' },
172+
{ keys: ['Shift', 'Tab'], description: 'Outdent' },
173+
{ keys: ['Ctrl', 'Z'], description: 'Undo' },
174+
{ keys: ['Ctrl', 'Y'], description: 'Redo' },
175+
]
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import type { Ref } from 'vue'
2+
import { onMounted, onUnmounted, ref } from 'vue'
3+
4+
interface SmartShortcutOptions {
5+
// Array of keys that make up the shortcut
6+
keys: string[]
7+
// CSS selector for the context in which the shortcut should work
8+
contextSelector: string
9+
// Callback function to run when shortcut is triggered
10+
onTrigger: (e: KeyboardEvent) => void
11+
// Whether to prevent default browser behavior
12+
preventDefault?: boolean
13+
}
14+
15+
/**
16+
* Prevents conflicts with other global shortcuts
17+
*/
18+
export function useSmartShortcut(options: SmartShortcutOptions): { keysPressed: Ref<Set<string>> } {
19+
const {
20+
keys,
21+
contextSelector,
22+
onTrigger,
23+
preventDefault = true,
24+
} = options
25+
26+
const keysPressed = ref<Set<string>>(new Set())
27+
const controller = new AbortController()
28+
29+
const normalizeKey = (key: string): string => {
30+
const keyMap: Record<string, string> = {
31+
Ctrl: 'Control',
32+
Cmd: 'Meta',
33+
Alt: 'Alt',
34+
Shift: 'Shift',
35+
}
36+
return keyMap[key] || key
37+
}
38+
39+
const normalizedKeys = keys.map(k =>
40+
normalizeKey(k.charAt(0).toUpperCase() + k.slice(1).toLowerCase()),
41+
)
42+
43+
const handleKeyDown = (e: KeyboardEvent): void => {
44+
const key = e.key === ' ' ? 'Space' : e.key
45+
const currentKey = key.length === 1 ? key.toLowerCase() : key
46+
47+
// Track pressed key
48+
keysPressed.value.add(currentKey)
49+
50+
// Build currently pressed keys
51+
const activeKeys = new Set<string>()
52+
if (e.ctrlKey)
53+
activeKeys.add('Control')
54+
if (e.metaKey)
55+
activeKeys.add('Meta')
56+
if (e.altKey)
57+
activeKeys.add('Alt')
58+
if (e.shiftKey)
59+
activeKeys.add('Shift')
60+
activeKeys.add(currentKey)
61+
62+
// Check if the exact combination is matched
63+
const isMatch
64+
= normalizedKeys.length === activeKeys.size
65+
&& normalizedKeys.every(k => activeKeys.has(k) || activeKeys.has(k.toLowerCase()))
66+
67+
if (isMatch) {
68+
const targetElement = e.target as HTMLElement
69+
const isInContext = targetElement.closest(contextSelector) !== null
70+
if (isInContext) {
71+
e.stopPropagation()
72+
if (preventDefault)
73+
e.preventDefault()
74+
onTrigger(e)
75+
}
76+
}
77+
}
78+
79+
const handleKeyUp = (e: KeyboardEvent): void => {
80+
const key = e.key === ' ' ? 'Space' : e.key
81+
const currentKey = key.length === 1 ? key.toLowerCase() : key
82+
keysPressed.value.delete(currentKey)
83+
84+
// Also clear modifier keys when they're lifted
85+
if (!e.ctrlKey)
86+
keysPressed.value.delete('Control')
87+
if (!e.metaKey)
88+
keysPressed.value.delete('Meta')
89+
if (!e.altKey)
90+
keysPressed.value.delete('Alt')
91+
if (!e.shiftKey)
92+
keysPressed.value.delete('Shift')
93+
}
94+
95+
onMounted(() => {
96+
document.addEventListener('keydown', handleKeyDown, { signal: controller.signal, capture: true })
97+
document.addEventListener('keyup', handleKeyUp, { signal: controller.signal, capture: true })
98+
window.addEventListener('blur', () => keysPressed.value.clear(), { signal: controller.signal })
99+
})
100+
101+
onUnmounted(() => {
102+
controller.abort()
103+
})
104+
105+
return { keysPressed }
106+
}

app/layouts/tiptap-layout.vue

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@ import {
1414
SidebarProvider,
1515
SidebarTrigger,
1616
} from '@/components/ui/sidebar'
17+
import { useSmartShortcut } from '@/composables/useSmartShortCut'
1718
import CharacterCount from '@tiptap/extension-character-count'
1819
import Link from '@tiptap/extension-link'
1920
import Placeholder from '@tiptap/extension-placeholder'
2021
import StarterKit from '@tiptap/starter-kit'
2122
import { useEditor } from '@tiptap/vue-3'
22-
2323
// Editor content
2424
const content = ref('')
2525
@@ -51,6 +51,28 @@ onBeforeUnmount(() => {
5151
}
5252
})
5353
54+
useSmartShortcut({
55+
keys: ['Meta', 'b'], // MacOS
56+
contextSelector: '.tiptap-editor-content',
57+
onTrigger: () => {
58+
if (tiptapEditor.value?.chain().focus()) {
59+
tiptapEditor.value.chain().focus().toggleBold().run()
60+
}
61+
},
62+
preventDefault: true,
63+
})
64+
65+
useSmartShortcut({
66+
keys: ['Control', 'b'], // Window / Linux
67+
contextSelector: '.tiptap-editor-content',
68+
onTrigger: () => {
69+
if (tiptapEditor.value?.chain().focus()) {
70+
tiptapEditor.value.chain().focus().toggleBold().run()
71+
}
72+
},
73+
preventDefault: true,
74+
})
75+
5476
// Navigation items
5577
const navigationItems = [
5678
{ name: 'Github Repo', icon: 'mdi:github', to: 'https://github.com/productdevbook/tiptap-shadcn-vue' },

app/pages/tiptap.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const isMobile = useMediaQuery('(max-width: 768px)')
2222
</div>
2323

2424
<!-- Editor Content Area -->
25-
<div class="flex-1 overflow-auto">
25+
<div class="flex-1 overflow-auto tiptap-editor-content">
2626
<TiptapContent placeholder="Start writing..." />
2727
</div>
2828

0 commit comments

Comments
 (0)