tiptapify 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +2 -2
  2. package/dist/tiptapify.css +1 -1
  3. package/dist/tiptapify.mjs +23328 -25129
  4. package/dist/tiptapify.umd.js +70 -72
  5. package/index.d.ts +19 -0
  6. package/package.json +1 -1
  7. package/src/components/Footer.vue +1 -1
  8. package/src/components/Tiptapify.vue +31 -10
  9. package/src/components/Toolbar/Index.vue +3 -4
  10. package/src/components/Toolbar/Items.vue +3 -3
  11. package/src/components/UI/HotkeyTooltip.test.ts +65 -0
  12. package/src/components/UI/HotkeyTooltip.vue +54 -0
  13. package/src/components/UI/Tooltip.vue +20 -0
  14. package/src/composables/useTiptapifyConfig.test.ts +66 -0
  15. package/src/composables/useTiptapifyConfig.ts +17 -0
  16. package/src/constants/defaults.ts +4 -2
  17. package/src/extensions/components/actions/redo/Button.vue +5 -9
  18. package/src/extensions/components/actions/undo/Button.vue +5 -9
  19. package/src/extensions/components/ai/Button.vue +7 -10
  20. package/src/extensions/components/ai/Dialog.vue +9 -13
  21. package/src/extensions/components/ai/backend.test.ts +314 -0
  22. package/src/extensions/components/ai/backend.ts +303 -0
  23. package/src/extensions/components/alignment/center/Button.vue +5 -9
  24. package/src/extensions/components/alignment/justify/Button.vue +5 -9
  25. package/src/extensions/components/alignment/left/Button.vue +5 -9
  26. package/src/extensions/components/alignment/right/Button.vue +5 -9
  27. package/src/extensions/components/format/bold/Button.vue +5 -9
  28. package/src/extensions/components/format/italic/Button.vue +5 -9
  29. package/src/extensions/components/format/strike/Button.vue +5 -9
  30. package/src/extensions/components/format/underline/Button.vue +5 -9
  31. package/src/extensions/components/formatExtra/code/Button.vue +5 -9
  32. package/src/extensions/components/formatExtra/codeBlock/Button.vue +5 -9
  33. package/src/extensions/components/formatExtra/quote/Button.vue +5 -9
  34. package/src/extensions/components/formatExtra/sub/Button.vue +5 -9
  35. package/src/extensions/components/formatExtra/sup/Button.vue +5 -9
  36. package/src/extensions/components/list/bullet/Button.vue +8 -13
  37. package/src/extensions/components/list/indent/Button.vue +5 -9
  38. package/src/extensions/components/list/numbered/Button.vue +5 -9
  39. package/src/extensions/components/list/outdent/Button.vue +5 -9
  40. package/src/extensions/components/list/task/Button.vue +5 -9
  41. package/src/extensions/components/media/charmap/Button.vue +5 -8
  42. package/src/extensions/components/media/charmap/Picker.vue +2 -3
  43. package/src/extensions/components/media/emoji/Button.vue +6 -9
  44. package/src/extensions/components/media/emoji/Picker.vue +2 -3
  45. package/src/extensions/components/media/iframe/Button.vue +6 -10
  46. package/src/extensions/components/media/iframe/IframeDialog.vue +3 -7
  47. package/src/extensions/components/media/image/Button.vue +6 -10
  48. package/src/extensions/components/media/image/ImageDialog.vue +5 -10
  49. package/src/extensions/components/media/link/Button.vue +7 -10
  50. package/src/extensions/components/media/link/LinkDialog.vue +3 -7
  51. package/src/extensions/components/media/table/Button.vue +10 -24
  52. package/src/extensions/components/media/table/ColumnActions.vue +4 -9
  53. package/src/extensions/components/media/table/RowActions.vue +4 -9
  54. package/src/extensions/components/media/video/Button.vue +6 -10
  55. package/src/extensions/components/media/video/VideoDialog.vue +3 -7
  56. package/src/extensions/components/misc/break/Button.vue +5 -9
  57. package/src/extensions/components/misc/formatClear/Button.vue +5 -9
  58. package/src/extensions/components/misc/fullscreen/Button.vue +5 -9
  59. package/src/extensions/components/misc/invisibleChar/Button.vue +5 -9
  60. package/src/extensions/components/misc/line/Button.vue +5 -9
  61. package/src/extensions/components/misc/preview/Button.vue +5 -9
  62. package/src/extensions/components/misc/source/Button.vue +6 -10
  63. package/src/extensions/components/misc/source/ShowSourceDialog.vue +4 -5
  64. package/src/extensions/components/style/color/Button.vue +5 -9
  65. package/src/extensions/components/style/fontFamily/Button.vue +5 -9
  66. package/src/extensions/components/style/fontSize/Button.vue +6 -7
  67. package/src/extensions/components/style/heading/Button.vue +6 -8
  68. package/src/extensions/components/style/highlight/Button.vue +5 -9
  69. package/src/extensions/components/style/lineHeight/Button.vue +5 -9
  70. package/src/index.ts +7 -0
  71. package/src/types/editor.ts +18 -1
  72. package/src/utils/hotkeys.test.ts +146 -0
  73. package/src/utils/hotkeys.ts +65 -0
@@ -1,17 +1,16 @@
1
1
  <script setup lang="ts">
2
2
  import { Editor } from '@tiptap/vue-3'
3
3
  import TiptapifyDialog from '@tiptapify/components/UI/TiptapifyDialog.vue'
4
- import defaults from '@tiptapify/constants/defaults'
5
- import { variantBtnTypes, variantFieldTypes } from '@tiptapify/types/editor'
6
- import { ref, watch, inject, Ref, PropType, useTemplateRef } from 'vue'
4
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
5
+ import { ref, watch, inject, Ref, useTemplateRef } from 'vue'
7
6
  import { ComposerTranslation } from 'vue-i18n'
8
7
 
9
8
  const props = defineProps({
10
9
  indent: { type: Number, default: 2 },
11
- variantBtn: { type: String as PropType<variantBtnTypes>, default() { return 'elevated' } },
12
- variantField: { type: String as PropType<variantFieldTypes>, default() { return defaults.variantField } },
13
10
  })
14
11
 
12
+ const { variantBtn, variantField } = useTiptapifyConfig()
13
+
15
14
  defineExpose({ showDialog })
16
15
 
17
16
  const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
@@ -3,17 +3,15 @@
3
3
  import * as mdi from '@mdi/js'
4
4
  import { Editor } from '@tiptap/vue-3'
5
5
  import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
6
+ import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
6
7
  import StyleColor from '@tiptapify/extensions/components/style/StyleColor.vue'
7
- import { variantBtnTypes } from '@tiptapify/types/editor'
8
- import { computed, inject, PropType, Ref } from 'vue'
8
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
9
+ import { computed, inject, Ref } from 'vue'
9
10
  import { ComposerTranslation } from 'vue-i18n'
10
11
  import { useTheme } from 'vuetify/framework'
11
12
 
12
- import defaults from '@tiptapify/constants/defaults'
13
13
 
14
- defineProps({
15
- variantBtn: { type: String as PropType<variantBtnTypes>, default() { return defaults.variantBtn } },
16
- })
14
+ const { variantBtn } = useTiptapifyConfig()
17
15
 
18
16
  const editor = inject('tiptapifyEditor') as Ref<Editor>
19
17
 
@@ -36,9 +34,7 @@ const selectedColor = computed(() => editor.value.getAttributes('textStyle').col
36
34
  :variant="variantBtn"
37
35
  size="32"
38
36
  >
39
- <VTooltip activator="parent">
40
- {{ t('style.color.text') }}
41
- </VTooltip>
37
+ <Tooltip :label="t('style.color.text')" />
42
38
 
43
39
  <BtnIcon :icon="`mdiSvg:${mdi.mdiFormatColorText}`" />
44
40
  <VIcon
@@ -3,17 +3,15 @@
3
3
  import * as mdi from '@mdi/js'
4
4
  import { Editor } from '@tiptap/vue-3'
5
5
  import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
6
+ import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
6
7
  import FontFamily from '@tiptapify/extensions/components/style/fontFamily/FontFamily.vue'
7
8
  import { fonts } from '@tiptapify/constants/style'
8
- import { variantBtnTypes } from '@tiptapify/types/editor'
9
- import { computed, inject, PropType, Ref } from 'vue'
9
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
10
+ import { computed, inject, Ref } from 'vue'
10
11
 
11
- import defaults from '@tiptapify/constants/defaults'
12
12
  import { ComposerTranslation } from 'vue-i18n'
13
13
 
14
- defineProps({
15
- variantBtn: { type: String as PropType<variantBtnTypes>, default() { return defaults.variantBtn } },
16
- })
14
+ const { variantBtn } = useTiptapifyConfig()
17
15
 
18
16
  const editor = inject('tiptapifyEditor') as Ref<Editor>
19
17
 
@@ -53,9 +51,7 @@ function getColor() {
53
51
  :variant="variantBtn"
54
52
  size="32"
55
53
  >
56
- <VTooltip activator="parent">
57
- {{ t('style.fontFamily') }}
58
- </VTooltip>
54
+ <Tooltip :label="t('style.fontFamily')" />
59
55
  <BtnIcon :icon="`mdiSvg:${mdi.mdiFormatFont}`" />
60
56
  </VBtn>
61
57
 
@@ -3,19 +3,20 @@
3
3
  import * as mdi from '@mdi/js'
4
4
  import { Editor } from '@tiptap/vue-3'
5
5
  import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
6
+ import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
6
7
  import FontSize from '@tiptapify/extensions/components/style/fontSize/FontSize.vue'
7
8
  import { fontSizes } from '@tiptapify/constants/style'
8
- import { variantBtnTypes } from '@tiptapify/types/editor'
9
- import { computed, inject, PropType, Ref } from 'vue'
9
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
10
+ import { computed, inject, Ref } from 'vue'
10
11
 
11
- import defaults from '@tiptapify/constants/defaults'
12
12
  import { ComposerTranslation } from 'vue-i18n'
13
13
 
14
14
  const props = defineProps({
15
15
  fontMeasure: { type: String, default () { return 'px' } },
16
- variantBtn: { type: String as PropType<variantBtnTypes>, default() { return defaults.variantBtn } },
17
16
  })
18
17
 
18
+ const { variantBtn } = useTiptapifyConfig()
19
+
19
20
  const editor = inject('tiptapifyEditor') as Ref<Editor>
20
21
 
21
22
  const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
@@ -54,9 +55,7 @@ function getColor() {
54
55
  :variant="variantBtn"
55
56
  size="32"
56
57
  >
57
- <VTooltip activator="parent">
58
- {{ t('style.fontSize') }}
59
- </VTooltip>
58
+ <Tooltip :label="t('style.fontSize')" />
60
59
  <BtnIcon :icon="`mdiSvg:${mdi.mdiFormatSize}`" />
61
60
  </VBtn>
62
61
 
@@ -4,17 +4,17 @@ import * as mdi from '@mdi/js'
4
4
  import { Level } from '@tiptap/extension-heading'
5
5
  import { Editor } from '@tiptap/vue-3'
6
6
  import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
7
- import { variantBtnTypes } from '@tiptapify/types/editor'
8
- import { inject, PropType, Ref } from 'vue'
9
-
10
- import defaults from '@tiptapify/constants/defaults'
7
+ import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
8
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
9
+ import { inject, Ref } from 'vue'
11
10
 
12
11
  const props = defineProps({
13
12
  customHeadingLevels: { type: Array<Level>, default: () => [] },
14
13
  withParagraph: { type: Boolean, default: () => true },
15
- variantBtn: { type: String as PropType<variantBtnTypes>, default() { return defaults.variantBtn } },
16
14
  })
17
15
 
16
+ const { variantBtn } = useTiptapifyConfig()
17
+
18
18
  interface MDIIcons { [key: string]: string }
19
19
  const mdiIcons = mdi as MDIIcons
20
20
 
@@ -35,9 +35,7 @@ setHeadingLevels(props.customHeadingLevels)
35
35
  size="32"
36
36
  @click="editor.chain().focus().redo().run()"
37
37
  >
38
- <VTooltip activator="parent">
39
- {{ t('style.heading') }}
40
- </VTooltip>
38
+ <Tooltip :label="t('style.heading')" hotkey="Mod-Alt-1..6" />
41
39
  <BtnIcon :icon="`mdiSvg:${mdi.mdiFormatHeaderPound}`" />
42
40
  </VBtn>
43
41
 
@@ -3,17 +3,15 @@
3
3
  import * as mdi from '@mdi/js'
4
4
  import { Editor } from '@tiptap/vue-3'
5
5
  import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
6
+ import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
6
7
  import StyleColor from '@tiptapify/extensions/components/style/StyleColor.vue'
7
- import { variantBtnTypes } from '@tiptapify/types/editor'
8
- import { computed, inject, PropType, Ref } from 'vue'
8
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
9
+ import { computed, inject, Ref } from 'vue'
9
10
  import { ComposerTranslation } from 'vue-i18n'
10
11
  import { useTheme } from 'vuetify/framework'
11
12
 
12
- import defaults from '@tiptapify/constants/defaults'
13
13
 
14
- defineProps({
15
- variantBtn: { type: String as PropType<variantBtnTypes>, default() { return defaults.variantBtn } },
16
- })
14
+ const { variantBtn } = useTiptapifyConfig()
17
15
 
18
16
  const editor = inject('tiptapifyEditor') as Ref<Editor>
19
17
 
@@ -37,9 +35,7 @@ const selectedColor = computed(() => editor.value.getAttributes('highlight').col
37
35
  :variant="variantBtn"
38
36
  size="32"
39
37
  >
40
- <VTooltip activator="parent">
41
- {{ t('style.color.highlight') }}
42
- </VTooltip>
38
+ <Tooltip :label="t('style.color.highlight')" hotkey="Mod-Shift-h" />
43
39
 
44
40
  <BtnIcon :icon="`mdiSvg:${mdi.mdiFormatColorFill}`" />
45
41
  <VIcon
@@ -3,17 +3,15 @@
3
3
  import * as mdi from '@mdi/js'
4
4
  import { Editor } from '@tiptap/vue-3'
5
5
  import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
6
+ import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
6
7
  import LineHeight from '@tiptapify/extensions/components/style/lineHeight/LineHeight.vue'
7
8
  import { lineHeights } from '@tiptapify/constants/style'
8
- import { variantBtnTypes } from '@tiptapify/types/editor'
9
- import { computed, inject, PropType, Ref } from 'vue'
9
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
10
+ import { computed, inject, Ref } from 'vue'
10
11
 
11
- import defaults from '@tiptapify/constants/defaults'
12
12
  import { ComposerTranslation } from 'vue-i18n'
13
13
 
14
- defineProps({
15
- variantBtn: { type: String as PropType<variantBtnTypes>, default() { return defaults.variantBtn } },
16
- })
14
+ const { variantBtn } = useTiptapifyConfig()
17
15
 
18
16
  const editor = inject('tiptapifyEditor') as Ref<Editor>
19
17
 
@@ -53,9 +51,7 @@ function getColor() {
53
51
  :variant="variantBtn"
54
52
  size="32"
55
53
  >
56
- <VTooltip activator="parent">
57
- {{ t('style.lineHeight') }}
58
- </VTooltip>
54
+ <Tooltip :label="t('style.lineHeight')" />
59
55
  <BtnIcon :icon="`mdiSvg:${mdi.mdiFormatLineHeight}`" />
60
56
  </VBtn>
61
57
 
package/src/index.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { Plugin } from 'vue'
2
2
  import Tiptapify from '@tiptapify/components/Tiptapify.vue'
3
3
  import TiptapifyDialog from '@tiptapify/components/UI/TiptapifyDialog.vue'
4
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
4
5
  import { TiptapifyFooterAlignment } from '@tiptapify/types/editor'
6
+ import { createAiBackendProvider } from '@tiptapify/extensions/components/ai/backend'
5
7
 
6
8
  import { Editor as TipTapEditor, nodeViewProps, NodeViewWrapper, VueNodeViewRenderer } from '@tiptap/vue-3'
7
9
  import { Node, Mark, markInputRule, markPasteRule, mergeAttributes } from '@tiptap/core'
@@ -37,6 +39,8 @@ export {
37
39
  VueNodeViewRenderer,
38
40
  Tiptapify,
39
41
  TiptapifyDialog,
42
+ useTiptapifyConfig,
43
+ createAiBackendProvider,
40
44
  Node,
41
45
  Mark,
42
46
  markInputRule,
@@ -44,9 +48,12 @@ export {
44
48
  mergeAttributes,
45
49
  }
46
50
 
51
+ export type { TiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
47
52
  export type { TiptapifyFooterAlignment }
48
53
  export type { CommandProps, InputRuleMatch, PasteRuleMatch }
49
54
  export type {
55
+ TiptapifyAiBackendProviderOptions,
56
+ TiptapifyAiBackendRequest,
50
57
  TiptapifyAiMode,
51
58
  TiptapifyAiOptions,
52
59
  TiptapifyAiChatMessage,
@@ -42,7 +42,7 @@ export type TiptapifyAiRequest = {
42
42
  [key: string]: unknown,
43
43
  }
44
44
 
45
- export type TiptapifyAiReasoningEffort = 'low' | 'medium' | 'high'
45
+ export type TiptapifyAiReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
46
46
 
47
47
  export type TiptapifyAiReasoningEffortOptions = {
48
48
  options: TiptapifyAiReasoningEffort[],
@@ -76,6 +76,21 @@ export type TiptapifyAiProvider = (
76
76
 
77
77
  export type TiptapifyAiTokenProvider = () => Promise<string | null> | string | null
78
78
 
79
+ export type TiptapifyAiBackendRequest = {
80
+ prompt: string,
81
+ instruction: string,
82
+ stream?: boolean,
83
+ thinking?: boolean,
84
+ reasoning_effort?: TiptapifyAiReasoningEffort,
85
+ model?: string,
86
+ }
87
+
88
+ export type TiptapifyAiBackendProviderOptions = {
89
+ endpoint: string,
90
+ headers?: Record<string, string>,
91
+ tokenProvider?: TiptapifyAiTokenProvider,
92
+ }
93
+
79
94
  export type TiptapifyAiStorage = {
80
95
  getItem: (key: string) => string | null | Promise<string | null>,
81
96
  setItem: (key: string, value: string) => void | Promise<void>,
@@ -84,6 +99,8 @@ export type TiptapifyAiStorage = {
84
99
 
85
100
  export type TiptapifyAiOptions = {
86
101
  aiProvider?: TiptapifyAiProvider,
102
+ aiEndpoint?: string,
103
+ aiHeaders?: Record<string, string>,
87
104
  model?: string,
88
105
  promptExamples?: TiptapifyAiPromptExample[],
89
106
  mode?: TiptapifyAiMode,
@@ -0,0 +1,146 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { hotkeyParts, isMac } from './hotkeys'
3
+
4
+ const MAC_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
5
+ const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
6
+ const WIN_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
7
+ const LINUX_UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
8
+
9
+ function mockUserAgent (userAgent: string) {
10
+ Object.defineProperty(window.navigator, 'userAgent', {
11
+ value: userAgent,
12
+ configurable: true,
13
+ })
14
+ }
15
+
16
+ function expectIcon (part: { icon?: string }): void {
17
+ expect(part.icon).toBeDefined()
18
+ expect(part.icon).toContain('mdiSvg:')
19
+ }
20
+
21
+ describe('isMac', () => {
22
+ afterEach(() => {
23
+ vi.unstubAllGlobals()
24
+ })
25
+
26
+ it('returns true for macOS user agent', () => {
27
+ mockUserAgent(MAC_UA)
28
+ expect(isMac()).toBe(true)
29
+ })
30
+
31
+ it('returns true for Apple mobile user agents', () => {
32
+ mockUserAgent(IPHONE_UA)
33
+ expect(isMac()).toBe(true)
34
+ })
35
+
36
+ it('returns false for Windows user agent', () => {
37
+ mockUserAgent(WIN_UA)
38
+ expect(isMac()).toBe(false)
39
+ })
40
+
41
+ it('returns false for Linux user agent', () => {
42
+ mockUserAgent(LINUX_UA)
43
+ expect(isMac()).toBe(false)
44
+ })
45
+
46
+ it('returns false when navigator is unavailable', () => {
47
+ vi.stubGlobal('navigator', undefined)
48
+ expect(isMac()).toBe(false)
49
+ })
50
+ })
51
+
52
+ describe('hotkeyParts', () => {
53
+ it('renders Mod as Ctrl text on Windows', () => {
54
+ mockUserAgent(WIN_UA)
55
+ expect(hotkeyParts('Mod-b')).toEqual([
56
+ { label: 'Ctrl' },
57
+ { label: 'B' },
58
+ ])
59
+ })
60
+
61
+ it('renders Mod as the Command icon on macOS', () => {
62
+ mockUserAgent(MAC_UA)
63
+ const parts = hotkeyParts('Mod-Shift-b')
64
+ expect(parts).toHaveLength(3)
65
+ expect(parts[0].label).toBe('Command')
66
+ expectIcon(parts[0])
67
+ expect(parts[1].label).toBe('Shift')
68
+ expectIcon(parts[1])
69
+ expect(parts[2]).toEqual({ label: 'B' })
70
+ })
71
+
72
+ it('renders Alt as text on Windows and Linux', () => {
73
+ mockUserAgent(WIN_UA)
74
+ expect(hotkeyParts('Mod-Alt-c')).toEqual([
75
+ { label: 'Ctrl' },
76
+ { label: 'Alt' },
77
+ { label: 'C' },
78
+ ])
79
+ mockUserAgent(LINUX_UA)
80
+ expect(hotkeyParts('Mod-Alt-c')).toEqual([
81
+ { label: 'Ctrl' },
82
+ { label: 'Alt' },
83
+ { label: 'C' },
84
+ ])
85
+ })
86
+
87
+ it('renders Alt as the Option icon on macOS', () => {
88
+ mockUserAgent(MAC_UA)
89
+ const parts = hotkeyParts('Mod-Alt-c')
90
+ expect(parts[0].label).toBe('Command')
91
+ expectIcon(parts[0])
92
+ expect(parts[1].label).toBe('Option')
93
+ expectIcon(parts[1])
94
+ expect(parts[2]).toEqual({ label: 'C' })
95
+ })
96
+
97
+ it('renders Enter as the return icon on macOS', () => {
98
+ mockUserAgent(MAC_UA)
99
+ const parts = hotkeyParts('Mod-Enter')
100
+ expect(parts[0].label).toBe('Command')
101
+ expectIcon(parts[0])
102
+ expect(parts[1].label).toBe('Enter')
103
+ expectIcon(parts[1])
104
+ })
105
+
106
+ it('renders Enter as text on Windows', () => {
107
+ mockUserAgent(WIN_UA)
108
+ expect(hotkeyParts('Mod-Enter')).toEqual([
109
+ { label: 'Ctrl' },
110
+ { label: 'Enter' },
111
+ ])
112
+ })
113
+
114
+ it('preserves modifier order', () => {
115
+ mockUserAgent(WIN_UA)
116
+ expect(hotkeyParts('Mod-Shift-s')).toEqual([
117
+ { label: 'Ctrl' },
118
+ { label: 'Shift' },
119
+ { label: 'S' },
120
+ ])
121
+ })
122
+
123
+ it('keeps punctuation keys as is', () => {
124
+ mockUserAgent(WIN_UA)
125
+ expect(hotkeyParts('Mod-,').map(part => part.label)).toEqual(['Ctrl', ','])
126
+ expect(hotkeyParts('Mod-.').map(part => part.label)).toEqual(['Ctrl', '.'])
127
+ })
128
+
129
+ it('renders numeric ranges with a dash', () => {
130
+ mockUserAgent(WIN_UA)
131
+ expect(hotkeyParts('Mod-Alt-1..6')).toEqual([
132
+ { label: 'Ctrl' },
133
+ { label: 'Alt' },
134
+ { label: '1–6' },
135
+ ])
136
+ })
137
+
138
+ it('renders digits as is', () => {
139
+ mockUserAgent(WIN_UA)
140
+ expect(hotkeyParts('Mod-Shift-8')).toEqual([
141
+ { label: 'Ctrl' },
142
+ { label: 'Shift' },
143
+ { label: '8' },
144
+ ])
145
+ })
146
+ })
@@ -0,0 +1,65 @@
1
+ import * as mdi from '@mdi/js'
2
+
3
+ export interface HotkeyPart {
4
+ icon?: string
5
+ label: string
6
+ }
7
+
8
+ /**
9
+ * Detects whether the current platform is macOS (or an Apple mobile device).
10
+ * Returns false in non-browser environments (SSR, tests).
11
+ */
12
+ export function isMac (): boolean {
13
+ if (typeof navigator === 'undefined') {
14
+ return false
15
+ }
16
+
17
+ return /mac|iphone|ipad|ipod/i.test(navigator.userAgent)
18
+ }
19
+
20
+ function partForKey (key: string, mac: boolean): HotkeyPart {
21
+ const normalized = key.toLowerCase()
22
+
23
+ if (normalized === 'mod') {
24
+ return mac
25
+ ? { label: 'Command', icon: `mdiSvg:${mdi.mdiAppleKeyboardCommand}` }
26
+ : { label: 'Ctrl' }
27
+ }
28
+ if (normalized === 'shift') {
29
+ return mac
30
+ ? { label: 'Shift', icon: `mdiSvg:${mdi.mdiAppleKeyboardShift}` }
31
+ : { label: 'Shift' }
32
+ }
33
+ if (normalized === 'alt') {
34
+ return mac
35
+ ? { label: 'Option', icon: `mdiSvg:${mdi.mdiAppleKeyboardOption}` }
36
+ : { label: 'Alt' }
37
+ }
38
+ if (normalized === 'enter') {
39
+ return mac
40
+ ? { label: 'Enter', icon: `mdiSvg:${mdi.mdiKeyboardReturn}` }
41
+ : { label: 'Enter' }
42
+ }
43
+
44
+ // numeric ranges in "1..6" format are rendered as "1–6"
45
+ const range = normalized.match(/^(\d+)\.\.(\d+)$/)
46
+ if (range) {
47
+ return { label: `${range[1]}–${range[2]}` }
48
+ }
49
+
50
+ return { label: key.toUpperCase() }
51
+ }
52
+
53
+ /**
54
+ * Converts a Tiptap-style shortcut string ("Mod-Shift-b", "Mod-Alt-1..6")
55
+ * into display parts for the current platform. On macOS modifier keys
56
+ * get their Apple keyboard glyphs (⌘ ⌥ ⇧ ⏎), on other platforms
57
+ * everything is rendered as text.
58
+ */
59
+ export function hotkeyParts (shortcut: string): HotkeyPart[] {
60
+ const mac = isMac()
61
+
62
+ return shortcut
63
+ .split('-')
64
+ .map(key => partForKey(key, mac))
65
+ }