tiptapify 0.3.5 → 0.4.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 (137) hide show
  1. package/dist/content.css +28 -0
  2. package/dist/tiptapify.css +1 -1
  3. package/dist/tiptapify.mjs +110770 -65623
  4. package/dist/tiptapify.umd.js +106 -89
  5. package/index.d.ts +34 -0
  6. package/package.json +3 -2
  7. package/src/components/ImageBubbleMenu.vue +5 -33
  8. package/src/components/MenuBubble.test.ts +120 -0
  9. package/src/components/MenuBubble.vue +124 -134
  10. package/src/components/MenuFloating.vue +48 -45
  11. package/src/components/Tiptapify.upload.test.ts +10 -0
  12. package/src/components/Tiptapify.vue +85 -45
  13. package/src/components/Toolbar/Index.vue +10 -0
  14. package/src/components/UI/BubbleRow.vue +41 -0
  15. package/src/components/UI/TiptapifyDialog.test.ts +37 -3
  16. package/src/components/UI/TiptapifyDialog.vue +15 -4
  17. package/src/components/editorExtensions.test.ts +9 -0
  18. package/src/components/editorExtensions.ts +21 -1
  19. package/src/composables/fakeUploadEndpoint.ts +65 -0
  20. package/src/composables/useTiptapifyConfig.ts +19 -0
  21. package/src/composables/useTiptapifyLinkPreview.test.ts +51 -0
  22. package/src/composables/useTiptapifyLinkPreview.ts +60 -0
  23. package/src/composables/useTiptapifyUpload.test.ts +96 -0
  24. package/src/composables/useTiptapifyUpload.ts +126 -0
  25. package/src/extensions/components/ai/Dialog.test.ts +335 -0
  26. package/src/extensions/components/ai/Dialog.vue +607 -310
  27. package/src/extensions/components/ai/backend.test.ts +1 -0
  28. package/src/extensions/components/ai/backend.ts +2 -1
  29. package/src/extensions/components/ai/context.test.ts +149 -0
  30. package/src/extensions/components/ai/context.ts +145 -0
  31. package/src/extensions/components/ai/diff.test.ts +65 -0
  32. package/src/extensions/components/ai/diff.ts +120 -0
  33. package/src/extensions/components/ai/errors.test.ts +45 -0
  34. package/src/extensions/components/ai/errors.ts +67 -0
  35. package/src/extensions/components/ai/result.test.ts +144 -0
  36. package/src/extensions/components/ai/result.ts +101 -0
  37. package/src/extensions/components/ai/thinking.test.ts +45 -0
  38. package/src/extensions/components/ai/thinking.ts +58 -0
  39. package/src/extensions/components/formatExtra/sub/Button.vue +1 -1
  40. package/src/extensions/components/formatExtra/sup/Button.vue +1 -1
  41. package/src/extensions/components/media/iframe/Button.vue +6 -7
  42. package/src/extensions/components/media/iframe/IframeDialog.test.ts +177 -0
  43. package/src/extensions/components/media/iframe/IframeDialog.vue +477 -105
  44. package/src/extensions/components/media/iframe/embed.test.ts +123 -0
  45. package/src/extensions/components/media/iframe/embed.ts +236 -0
  46. package/src/extensions/components/media/iframe/index.test.ts +123 -0
  47. package/src/extensions/components/media/iframe/index.ts +92 -10
  48. package/src/extensions/components/media/image/ImageDialog.vue +430 -132
  49. package/src/extensions/components/media/image/caption.test.ts +66 -1
  50. package/src/extensions/components/media/image/caption.ts +27 -1
  51. package/src/extensions/components/media/image/imageDialog.test.ts +200 -0
  52. package/src/extensions/components/media/image/imageDialog.ts +114 -0
  53. package/src/extensions/components/media/image/index.ts +2 -1
  54. package/src/extensions/components/media/link/Button.test.ts +96 -0
  55. package/src/extensions/components/media/link/Button.vue +6 -2
  56. package/src/extensions/components/media/link/LinkDialog.display.test.ts +363 -0
  57. package/src/extensions/components/media/link/LinkDialog.vue +687 -61
  58. package/src/extensions/components/media/link/index.ts +111 -6
  59. package/src/extensions/components/media/link/link.marks.test.ts +89 -0
  60. package/src/extensions/components/media/link/linkDialog.test.ts +296 -0
  61. package/src/extensions/components/media/link/linkDialog.ts +154 -0
  62. package/src/extensions/components/media/linkCard/PasteCardMenu.test.ts +133 -0
  63. package/src/extensions/components/media/linkCard/PasteCardMenu.vue +168 -0
  64. package/src/extensions/components/media/linkCard/index.test.ts +146 -0
  65. package/src/extensions/components/media/linkCard/index.ts +177 -0
  66. package/src/extensions/components/media/linkCard/linkCardDialog.test.ts +87 -0
  67. package/src/extensions/components/media/linkCard/linkCardDialog.ts +62 -0
  68. package/src/extensions/components/media/linkCard/linkPreview.test.ts +102 -0
  69. package/src/extensions/components/media/linkCard/linkPreview.ts +84 -0
  70. package/src/extensions/components/media/linkCard/makeLinkCard.test.ts +59 -0
  71. package/src/extensions/components/media/linkCard/makeLinkCard.ts +58 -0
  72. package/src/extensions/components/media/linkCard/pasteCard.test.ts +190 -0
  73. package/src/extensions/components/media/linkCard/pasteCard.ts +153 -0
  74. package/src/extensions/components/media/video/Button.vue +2 -1
  75. package/src/extensions/components/media/video/VideoDialog.test.ts +233 -0
  76. package/src/extensions/components/media/video/VideoDialog.vue +748 -59
  77. package/src/extensions/components/media/video/index.test.ts +154 -0
  78. package/src/extensions/components/media/video/index.ts +70 -5
  79. package/src/extensions/components/media/video/providers.test.ts +148 -0
  80. package/src/extensions/components/media/video/providers.ts +241 -0
  81. package/src/extensions/components/media/video/videoFile.test.ts +98 -0
  82. package/src/extensions/components/media/video/videoFile.ts +142 -0
  83. package/src/extensions/components/misc/fullscreen/Button.test.ts +55 -0
  84. package/src/extensions/components/misc/fullscreen/Button.vue +2 -2
  85. package/src/extensions/components/style/StyleColor.test.ts +255 -0
  86. package/src/extensions/components/style/StyleColor.vue +358 -208
  87. package/src/extensions/components/style/color/Button.vue +1 -3
  88. package/src/extensions/components/style/color/colors.test.ts +191 -0
  89. package/src/extensions/components/style/color/colors.ts +168 -0
  90. package/src/extensions/components/style/highlight/Button.vue +1 -3
  91. package/src/i18n/locales/ar.json +226 -23
  92. package/src/i18n/locales/bg.json +225 -22
  93. package/src/i18n/locales/ch.json +226 -23
  94. package/src/i18n/locales/cz.json +226 -23
  95. package/src/i18n/locales/de.json +226 -23
  96. package/src/i18n/locales/el.json +225 -22
  97. package/src/i18n/locales/en.json +225 -22
  98. package/src/i18n/locales/es.json +226 -23
  99. package/src/i18n/locales/et.json +225 -22
  100. package/src/i18n/locales/fi.json +226 -23
  101. package/src/i18n/locales/fr.json +226 -23
  102. package/src/i18n/locales/he.json +225 -22
  103. package/src/i18n/locales/hi.json +225 -22
  104. package/src/i18n/locales/hr.json +225 -22
  105. package/src/i18n/locales/hu.json +226 -23
  106. package/src/i18n/locales/id.json +225 -22
  107. package/src/i18n/locales/it.json +226 -23
  108. package/src/i18n/locales/ja.json +226 -23
  109. package/src/i18n/locales/ko.json +226 -23
  110. package/src/i18n/locales/la.json +226 -23
  111. package/src/i18n/locales/lt.json +226 -23
  112. package/src/i18n/locales/nl.json +226 -23
  113. package/src/i18n/locales/no.json +227 -24
  114. package/src/i18n/locales/pl.json +226 -23
  115. package/src/i18n/locales/pt-BR.json +225 -22
  116. package/src/i18n/locales/pt.json +226 -23
  117. package/src/i18n/locales/ro.json +225 -22
  118. package/src/i18n/locales/ru.json +227 -24
  119. package/src/i18n/locales/se.json +226 -23
  120. package/src/i18n/locales/sk.json +225 -22
  121. package/src/i18n/locales/sl.json +225 -22
  122. package/src/i18n/locales/sr.json +225 -22
  123. package/src/i18n/locales/th.json +226 -23
  124. package/src/i18n/locales/tr.json +226 -23
  125. package/src/i18n/locales/uk.json +227 -24
  126. package/src/i18n/locales/vi.json +226 -23
  127. package/src/index.ts +8 -0
  128. package/src/skins/registry.test.ts +1 -1
  129. package/src/skins/tokens.reachability.test.ts +1 -0
  130. package/src/skins/tokens.ts +3 -3
  131. package/src/types/editor.ts +40 -1
  132. package/src/utils/contentStyles.test.ts +116 -0
  133. package/src/utils/contentStyles.ts +175 -0
  134. package/src/utils/mediaSize.test.ts +22 -0
  135. package/src/utils/mediaSize.ts +27 -0
  136. package/src/utils/validateHref.test.ts +47 -2
  137. package/src/utils/validateHref.ts +47 -7
@@ -0,0 +1,98 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { Editor } from '@tiptap/core'
3
+ import { Document } from '@tiptap/extension-document'
4
+ import { Paragraph } from '@tiptap/extension-paragraph'
5
+ import { Text } from '@tiptap/extension-text'
6
+ import { VideoFile } from '@tiptapify/extensions/components/media/video/videoFile'
7
+
8
+ let editor: Editor | null = null
9
+
10
+ function createEditor(content: string) {
11
+ editor = new Editor({
12
+ element: document.createElement('div'),
13
+ content,
14
+ extensions: [Document, Paragraph, Text, VideoFile],
15
+ })
16
+
17
+ return editor
18
+ }
19
+
20
+ function videoAttrs() {
21
+ return editor!.state.doc.firstChild!.attrs
22
+ }
23
+
24
+ describe('video file node', () => {
25
+ afterEach(() => {
26
+ editor?.destroy()
27
+ editor = null
28
+ })
29
+
30
+ it('renders a responsive video with controls by default', () => {
31
+ createEditor('')
32
+ editor!.commands.setContent('')
33
+ editor!.commands.setVideoFile({ src: 'https://cdn.example.com/a.mp4' })
34
+
35
+ expect(editor!.getHTML()).toContain(
36
+ '<video src="https://cdn.example.com/a.mp4" controls="" playsinline="" preload="metadata"></video>',
37
+ )
38
+ })
39
+
40
+ it('does not play by itself in the editor, while the saved video keeps autoplay', () => {
41
+ createEditor('<video src="https://cdn.example.com/a.mp4" autoplay muted controls></video>')
42
+
43
+ const video = editor!.view.dom.querySelector('video')!
44
+ expect(video.hasAttribute('autoplay')).toBe(false)
45
+ expect(video.getAttribute('src')).toBe('https://cdn.example.com/a.mp4')
46
+ expect(editor!.getHTML()).toContain('autoplay=""')
47
+ })
48
+
49
+ it('renders every option and parses it back', () => {
50
+ createEditor('')
51
+ editor!.commands.setVideoFile({
52
+ src: 'https://cdn.example.com/a.mp4',
53
+ poster: 'https://cdn.example.com/a.jpg',
54
+ controls: false,
55
+ autoplay: true,
56
+ loop: true,
57
+ start: 42,
58
+ width: 640,
59
+ height: 360,
60
+ })
61
+
62
+ const html = editor!.getHTML()
63
+
64
+ expect(html).toContain('src="https://cdn.example.com/a.mp4#t=42"')
65
+ expect(html).toContain('poster="https://cdn.example.com/a.jpg"')
66
+ expect(html).toContain('autoplay="" muted=""')
67
+ expect(html).toContain('loop=""')
68
+ expect(html).toContain('width="640" height="360"')
69
+ expect(html).not.toContain('controls')
70
+
71
+ editor!.destroy()
72
+ createEditor(html)
73
+
74
+ expect(videoAttrs()).toEqual({
75
+ src: 'https://cdn.example.com/a.mp4',
76
+ poster: 'https://cdn.example.com/a.jpg',
77
+ controls: false,
78
+ autoplay: true,
79
+ loop: true,
80
+ start: 42,
81
+ width: 640,
82
+ height: 360,
83
+ })
84
+ expect(editor!.getHTML()).toBe(html)
85
+ })
86
+
87
+ it('parses a video without options', () => {
88
+ createEditor('<video src="/a.mp4"></video>')
89
+
90
+ expect(videoAttrs()).toMatchObject({ src: '/a.mp4', controls: false, autoplay: false, loop: false, start: 0, width: null })
91
+ })
92
+
93
+ it('reads the file from a <source> child', () => {
94
+ createEditor('<video controls><source src="/a.webm#t=7" type="video/webm"></video>')
95
+
96
+ expect(videoAttrs()).toMatchObject({ src: '/a.webm', start: 7, controls: true })
97
+ })
98
+ })
@@ -0,0 +1,142 @@
1
+ import { Node } from '@tiptap/core'
2
+
3
+ export type VideoFileAttrs = {
4
+ src: string,
5
+ poster?: string | null,
6
+ controls?: boolean,
7
+ // Autoplay renders `autoplay muted`: browsers only autoplay muted videos
8
+ autoplay?: boolean,
9
+ loop?: boolean,
10
+ // Seconds, 0 for none; stored on the src as the `#t=` media fragment
11
+ start?: number,
12
+ // null for a responsive (full width, 16:9) video
13
+ width?: number | null,
14
+ height?: number | null,
15
+ }
16
+
17
+ declare module '@tiptap/core' {
18
+ interface Commands<ReturnType> {
19
+ video: {
20
+ setVideoFile: (attrs: VideoFileAttrs) => ReturnType
21
+ }
22
+ }
23
+ }
24
+
25
+ function sourceOf(element: HTMLElement): string | null {
26
+ return element.getAttribute('src') ?? element.querySelector('source[src]')?.getAttribute('src') ?? null
27
+ }
28
+
29
+ /**
30
+ * Splits a `#t=<seconds>` media fragment off a video URL.
31
+ */
32
+ export function splitStartFragment(url: string | null): { src: string | null, start: number } {
33
+ const match = url?.match(/^(.*?)#t=(?:npt:)?(\d+(?:\.\d+)?)(?:,[\d.]*)?$/)
34
+
35
+ if (!url || !match) {
36
+ return { src: url, start: 0 }
37
+ }
38
+
39
+ return { src: match[1], start: Math.floor(Number(match[2])) }
40
+ }
41
+
42
+ function parseDimension(element: HTMLElement, name: string): number | null {
43
+ const value = Number.parseInt(element.getAttribute(name) ?? '', 10)
44
+
45
+ return value > 0 ? value : null
46
+ }
47
+
48
+ /**
49
+ * An uploaded (or linked) video file, played by the browser's own `<video>` player.
50
+ */
51
+ export const VideoFile = Node.create({
52
+ name: 'video',
53
+
54
+ group: 'block',
55
+
56
+ atom: true,
57
+
58
+ draggable: true,
59
+
60
+ addAttributes() {
61
+ return {
62
+ src: {
63
+ default: null,
64
+ parseHTML: element => splitStartFragment(sourceOf(element)).src,
65
+ renderHTML: attributes => ({
66
+ src: attributes.start ? `${attributes.src}#t=${attributes.start}` : attributes.src,
67
+ }),
68
+ },
69
+ start: {
70
+ default: 0,
71
+ parseHTML: element => splitStartFragment(sourceOf(element)).start,
72
+ rendered: false,
73
+ },
74
+ poster: {
75
+ default: null,
76
+ },
77
+ controls: {
78
+ default: true,
79
+ parseHTML: element => element.hasAttribute('controls'),
80
+ renderHTML: attributes => (attributes.controls ? { controls: '' } : {}),
81
+ },
82
+ autoplay: {
83
+ default: false,
84
+ parseHTML: element => element.hasAttribute('autoplay'),
85
+ renderHTML: attributes => (attributes.autoplay ? { autoplay: '', muted: '' } : {}),
86
+ },
87
+ loop: {
88
+ default: false,
89
+ parseHTML: element => element.hasAttribute('loop'),
90
+ renderHTML: attributes => (attributes.loop ? { loop: '' } : {}),
91
+ },
92
+ width: {
93
+ default: null,
94
+ parseHTML: element => parseDimension(element, 'width'),
95
+ },
96
+ height: {
97
+ default: null,
98
+ parseHTML: element => parseDimension(element, 'height'),
99
+ },
100
+ }
101
+ },
102
+
103
+ parseHTML() {
104
+ return [
105
+ { tag: 'video[src]' },
106
+ // A video with its file in a <source> child
107
+ { tag: 'video', getAttrs: element => (element.querySelector('source[src]') ? null : false) },
108
+ ]
109
+ },
110
+
111
+ renderHTML({ HTMLAttributes }) {
112
+ return ['video', { ...HTMLAttributes, playsinline: '', preload: 'metadata' }]
113
+ },
114
+
115
+ // In the editor the video never starts by itself (autoplay is for the published page), and its block
116
+ // takes the clicks to be selected while the player takes none (see the editor styles)
117
+ addNodeView() {
118
+ return ({ HTMLAttributes }) => {
119
+ const dom = document.createElement('div')
120
+ dom.className = 'tiptapify-video'
121
+
122
+ const video = document.createElement('video')
123
+ const attributes = { ...HTMLAttributes, playsinline: '', preload: 'metadata' }
124
+
125
+ for (const [name, value] of Object.entries(attributes)) {
126
+ if (name !== 'autoplay' && value !== null && value !== undefined) {
127
+ video.setAttribute(name, String(value))
128
+ }
129
+ }
130
+
131
+ dom.append(video)
132
+
133
+ return { dom }
134
+ }
135
+ },
136
+
137
+ addCommands() {
138
+ return {
139
+ setVideoFile: attrs => ({ commands }) => commands.insertContent({ type: this.name, attrs }),
140
+ }
141
+ },
142
+ })
@@ -0,0 +1,55 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { App, createApp, h, nextTick } from 'vue'
3
+ import { createVuetify } from 'vuetify'
4
+ import { createI18n } from 'vue-i18n'
5
+ import Tiptapify from '@tiptapify/components/Tiptapify.vue'
6
+ import { messages } from '@tiptapify/i18n'
7
+
8
+ // jsdom has no `visualViewport` nor `ResizeObserver`; Vuetify's overlays read them
9
+ if (!('visualViewport' in globalThis)) {
10
+ ;(globalThis as Record<string, unknown>).visualViewport = {
11
+ scale: 1, offsetLeft: 0, offsetTop: 0, addEventListener: () => {}, removeEventListener: () => {},
12
+ }
13
+ }
14
+ if (!('ResizeObserver' in globalThis)) {
15
+ ;(globalThis as Record<string, unknown>).ResizeObserver = class {
16
+ observe() {}
17
+ unobserve() {}
18
+ disconnect() {}
19
+ }
20
+ }
21
+
22
+ let app: App | null = null
23
+
24
+ async function flush() {
25
+ for (let i = 0; i < 8; i++) {
26
+ await nextTick()
27
+ }
28
+ }
29
+
30
+ afterEach(() => {
31
+ app?.unmount()
32
+ app = null
33
+ document.body.innerHTML = ''
34
+ })
35
+
36
+ describe('fullscreen', () => {
37
+ it('keeps the skin of the editor: its container carries the root classes and tokens', async () => {
38
+ app = createApp({ setup: () => () => h(Tiptapify, { skin: 'pastel', items: ['fullscreen'] }) })
39
+ app.use(createVuetify())
40
+ app.use(createI18n({ legacy: false, locale: 'en', fallbackLocale: 'en', messages }))
41
+ app.mount(document.body.appendChild(document.createElement('div')))
42
+ await flush()
43
+
44
+ const root = document.querySelector<HTMLElement>('[id^="tiptapify-editor-"]')!
45
+ document.querySelector<HTMLButtonElement>('.tiptapify-toolbar .v-btn')!.click()
46
+ await flush()
47
+
48
+ const container = document.querySelector<HTMLElement>('[id^="tiptapify-editor-fullscreen-"]')!
49
+ expect(container.classList).toContain('tiptapify-skin-pastel')
50
+ expect(container.classList).toContain('tiptapify-shape-groups-flat')
51
+ expect([...container.classList]).toEqual([...root.classList])
52
+ expect(container.getAttribute('style')).toBe(root.getAttribute('style'))
53
+ expect(container.querySelector('.tiptapify-frame')).not.toBeNull()
54
+ })
55
+ })
@@ -9,7 +9,7 @@ import { inject, nextTick, ref, Ref } from 'vue'
9
9
 
10
10
  import { ComposerTranslation } from 'vue-i18n'
11
11
 
12
- const { variantBtn } = useTiptapifyConfig()
12
+ const { variantBtn, rootClasses, tokens } = useTiptapifyConfig()
13
13
 
14
14
  const editor = inject('tiptapifyEditor') as Ref<Editor>
15
15
 
@@ -54,7 +54,7 @@ async function changeEditorContainer(source: string, target: string) {
54
54
 
55
55
  <VDialog v-model="showDialog" fullscreen @close="dialogClose()" @update:model-value="!showDialog ? dialogClose() : ''">
56
56
  <VCard>
57
- <div :id="`tiptapify-editor-fullscreen-${editor?.instanceId}`" />
57
+ <div :id="`tiptapify-editor-fullscreen-${editor?.instanceId}`" :class="rootClasses" :style="tokens" />
58
58
  </VCard>
59
59
  </VDialog>
60
60
  </template>
@@ -0,0 +1,255 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { App, computed, createApp, h, nextTick, provide, shallowRef } from 'vue'
3
+ import { createVuetify } from 'vuetify'
4
+ import { Editor } from '@tiptap/vue-3'
5
+ import { Document } from '@tiptap/extension-document'
6
+ import { Highlight } from '@tiptap/extension-highlight'
7
+ import { Paragraph } from '@tiptap/extension-paragraph'
8
+ import { Text } from '@tiptap/extension-text'
9
+ import { TextStyleKit } from '@tiptap/extension-text-style'
10
+ import { UndoRedo } from '@tiptap/extensions'
11
+ import StyleColor from '@tiptapify/extensions/components/style/StyleColor.vue'
12
+ import { ColorKind, loadRecent } from '@tiptapify/extensions/components/style/color/colors'
13
+
14
+ // jsdom has no `visualViewport`; Vuetify's overlay positioning reads it
15
+ if (!('visualViewport' in globalThis)) {
16
+ ;(globalThis as Record<string, unknown>).visualViewport = {
17
+ scale: 1,
18
+ offsetLeft: 0,
19
+ offsetTop: 0,
20
+ addEventListener: () => {},
21
+ removeEventListener: () => {},
22
+ }
23
+ }
24
+
25
+ // Nor `ResizeObserver`, which the menu's positioning reads
26
+ if (!('ResizeObserver' in globalThis)) {
27
+ ;(globalThis as Record<string, unknown>).ResizeObserver = class {
28
+ observe() {}
29
+ unobserve() {}
30
+ disconnect() {}
31
+ }
32
+ }
33
+
34
+ let app: App | null = null
35
+ let editor: Editor | null = null
36
+
37
+ async function flush() {
38
+ for (let i = 0; i < 5; i++) {
39
+ await nextTick()
40
+ }
41
+ }
42
+
43
+ async function openPicker(kind: ColorKind, content = '<p>Hello</p>'): Promise<HTMLElement> {
44
+ const instance = new Editor({
45
+ element: document.createElement('div'),
46
+ content,
47
+ extensions: [Document, Paragraph, Text, TextStyleKit, Highlight.configure({ multicolor: true }), UndoRedo],
48
+ })
49
+ instance.commands.selectAll()
50
+ editor = instance
51
+
52
+ app = createApp({
53
+ setup() {
54
+ provide('tiptapifyEditor', shallowRef(instance))
55
+ provide('tiptapifyI18n', { t: (key: string) => key })
56
+
57
+ const color = computed(() => String(instance.getAttributes(kind === 'text' ? 'textStyle' : 'highlight').color ?? ''))
58
+
59
+ return () => [
60
+ h('button', { id: 'color-activator' }, 'open'),
61
+ h(StyleColor, { kind, activator: '#color-activator', color: color.value }),
62
+ ]
63
+ },
64
+ })
65
+ app.use(createVuetify())
66
+ app.mount(document.body.appendChild(document.createElement('div')))
67
+ await flush()
68
+
69
+ document.querySelector<HTMLElement>('#color-activator')!.click()
70
+ await flush()
71
+
72
+ return document.querySelector<HTMLElement>('.tiptapify-color')!
73
+ }
74
+
75
+ function swatch(popover: HTMLElement, label: string): HTMLButtonElement {
76
+ return popover.querySelector<HTMLButtonElement>(`.tiptapify-color__swatch[aria-label="${label}"]`)!
77
+ }
78
+
79
+ function textColor(): string | undefined {
80
+ return editor!.getAttributes('textStyle').color
81
+ }
82
+
83
+ function isOpen(): boolean {
84
+ return !!document.querySelector('.tiptapify-color')?.closest('.v-overlay--active')
85
+ }
86
+
87
+ async function key(element: HTMLElement, name: string) {
88
+ element.dispatchEvent(new KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true }))
89
+ await flush()
90
+ }
91
+
92
+ async function typeHex(popover: HTMLElement, value: string): Promise<HTMLInputElement> {
93
+ const input = popover.querySelector<HTMLInputElement>('.tiptapify-color__hex input')!
94
+ input.value = value
95
+ input.dispatchEvent(new Event('input'))
96
+ await flush()
97
+
98
+ return input
99
+ }
100
+
101
+ afterEach(() => {
102
+ app?.unmount()
103
+ app = null
104
+ editor?.destroy()
105
+ editor = null
106
+ document.body.innerHTML = ''
107
+ localStorage.clear()
108
+ delete (globalThis as Record<string, unknown>).EyeDropper
109
+ vi.restoreAllMocks()
110
+ })
111
+
112
+ describe('StyleColor', () => {
113
+ it('previews a hovered color outside undo and applies a click as one undo step', async () => {
114
+ const popover = await openPicker('text', '<p><span style="color: #1d4ed8">Hello</span></p>')
115
+ const red = swatch(popover, 'style.color.names.dark_red')
116
+
117
+ expect(swatch(popover, 'style.color.names.dark_blue').getAttribute('aria-pressed')).toBe('true')
118
+
119
+ red.dispatchEvent(new MouseEvent('mouseenter'))
120
+ expect(textColor()).toBe('#b91c1c')
121
+ expect(editor!.can().undo()).toBe(false)
122
+
123
+ red.dispatchEvent(new MouseEvent('mouseleave'))
124
+ expect(textColor()).toBe('#1d4ed8')
125
+
126
+ red.dispatchEvent(new MouseEvent('mouseenter'))
127
+ red.click()
128
+ await flush()
129
+
130
+ expect(textColor()).toBe('#b91c1c')
131
+ expect(isOpen()).toBe(false)
132
+ expect(loadRecent('text')).toEqual(['#b91c1c'])
133
+
134
+ editor!.commands.undo()
135
+ expect(textColor()).toBe('#1d4ed8')
136
+ })
137
+
138
+ it('removes the color with the first cell', async () => {
139
+ const popover = await openPicker('text', '<p><span style="color: #1d4ed8">Hello</span></p>')
140
+
141
+ swatch(popover, 'style.color.default').click()
142
+ await flush()
143
+
144
+ expect(textColor()).toBeFalsy()
145
+ })
146
+
147
+ it('shows recent colors per kind', async () => {
148
+ localStorage.setItem('tiptapify:recent-colors:highlight', JSON.stringify(['#123456']))
149
+ const popover = await openPicker('highlight')
150
+
151
+ expect(popover.textContent).toContain('style.color.recent')
152
+ swatch(popover, '#123456').click()
153
+ await flush()
154
+
155
+ expect(editor!.getAttributes('highlight').color).toBe('#123456')
156
+ })
157
+
158
+ it('moves through the swatches with arrow keys, and Esc restores the original color', async () => {
159
+ const popover = await openPicker('text', '<p><span style="color: #1d4ed8">Hello</span></p>')
160
+ const blue = swatch(popover, 'style.color.names.blue')
161
+ blue.focus()
162
+
163
+ await key(blue, 'ArrowRight')
164
+ expect(document.activeElement).toBe(swatch(popover, 'style.color.names.purple'))
165
+ expect(textColor()).toBe('#a855f7')
166
+
167
+ await key(document.activeElement as HTMLElement, 'ArrowUp')
168
+ expect(textColor()).toBe('#7e22ce')
169
+
170
+ await key(document.activeElement as HTMLElement, 'Escape')
171
+ expect(isOpen()).toBe(false)
172
+ expect(textColor()).toBe('#1d4ed8')
173
+ expect(editor!.can().undo()).toBe(false)
174
+ })
175
+
176
+ it('applies a focused swatch with Enter', async () => {
177
+ const popover = await openPicker('highlight')
178
+ const amber = swatch(popover, 'style.color.names.amber')
179
+ amber.focus()
180
+
181
+ await key(amber, 'Enter')
182
+
183
+ expect(editor!.getAttributes('highlight').color).toBe('#fcd34d')
184
+ expect(isOpen()).toBe(false)
185
+ })
186
+
187
+ it('applies a typed hex color and keeps an invalid one in the field', async () => {
188
+ const popover = await openPicker('text')
189
+
190
+ let input = await typeHex(popover, '#abz')
191
+ await key(input, 'Enter')
192
+ expect(isOpen()).toBe(true)
193
+ expect(popover.textContent).toContain('style.color.hex_invalid')
194
+ expect(textColor()).toBeFalsy()
195
+
196
+ input = await typeHex(popover, '#abc')
197
+ expect(textColor()).toBe('#aabbcc')
198
+ // Not the message text: Vuetify 4 keeps it in the DOM while its leave transition runs
199
+ expect(popover.querySelector('.tiptapify-color__hex')!.classList).not.toContain('v-input--error')
200
+
201
+ await key(input, 'Enter')
202
+ expect(isOpen()).toBe(false)
203
+ expect(textColor()).toBe('#aabbcc')
204
+
205
+ editor!.commands.undo()
206
+ expect(textColor()).toBeFalsy()
207
+ })
208
+
209
+ it('drops a typed color when Esc closes the popover', async () => {
210
+ const popover = await openPicker('text')
211
+ const input = await typeHex(popover, '#abc')
212
+ input.focus()
213
+
214
+ await key(input, 'Escape')
215
+ input.dispatchEvent(new FocusEvent('blur'))
216
+ await flush()
217
+
218
+ expect(isOpen()).toBe(false)
219
+ expect(textColor()).toBeFalsy()
220
+ expect(editor!.can().undo()).toBe(false)
221
+ })
222
+
223
+ it('offers the eyedropper only where the browser has one', async () => {
224
+ let popover = await openPicker('text')
225
+ expect(popover.querySelector('.tiptapify-color__eyedropper')).toBeNull()
226
+
227
+ app!.unmount()
228
+ document.body.innerHTML = ''
229
+ const open = vi.fn().mockResolvedValue({ sRGBHex: '#0F766E' })
230
+ ;(globalThis as Record<string, unknown>).EyeDropper = class {
231
+ open = open
232
+ }
233
+
234
+ popover = await openPicker('text')
235
+ popover.querySelector<HTMLElement>('.tiptapify-color__eyedropper')!.click()
236
+ await flush()
237
+
238
+ expect(open).toHaveBeenCalled()
239
+ expect(textColor()).toBe('#0f766e')
240
+ })
241
+
242
+ it('expands the custom color picker inside the popover', async () => {
243
+ const popover = await openPicker('text')
244
+ const toggle = popover.querySelector<HTMLElement>('.tiptapify-color__custom-toggle')!
245
+ // jsdom draws no canvas
246
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null)
247
+
248
+ toggle.click()
249
+ await flush()
250
+
251
+ expect(toggle.getAttribute('aria-expanded')).toBe('true')
252
+ expect(popover.querySelector('.v-color-picker')).not.toBeNull()
253
+ expect(isOpen()).toBe(true)
254
+ })
255
+ })