tiptapify 0.3.5 → 0.4.1
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.
- package/dist/content.css +28 -0
- package/dist/tiptapify.css +1 -1
- package/dist/tiptapify.mjs +110770 -65623
- package/dist/tiptapify.umd.js +106 -89
- package/index.d.ts +37 -0
- package/package.json +3 -2
- package/src/components/ImageBubbleMenu.vue +5 -33
- package/src/components/MenuBubble.test.ts +120 -0
- package/src/components/MenuBubble.vue +124 -134
- package/src/components/MenuFloating.vue +48 -45
- package/src/components/Tiptapify.upload.test.ts +10 -0
- package/src/components/Tiptapify.vue +85 -45
- package/src/components/Toolbar/Index.vue +10 -0
- package/src/components/UI/BubbleRow.vue +41 -0
- package/src/components/UI/TiptapifyDialog.test.ts +37 -3
- package/src/components/UI/TiptapifyDialog.vue +15 -4
- package/src/components/editorExtensions.test.ts +9 -0
- package/src/components/editorExtensions.ts +21 -1
- package/src/composables/fakeUploadEndpoint.ts +65 -0
- package/src/composables/useTiptapifyConfig.ts +19 -0
- package/src/composables/useTiptapifyLinkPreview.test.ts +51 -0
- package/src/composables/useTiptapifyLinkPreview.ts +60 -0
- package/src/composables/useTiptapifyUpload.test.ts +96 -0
- package/src/composables/useTiptapifyUpload.ts +126 -0
- package/src/extensions/components/ai/Dialog.test.ts +335 -0
- package/src/extensions/components/ai/Dialog.vue +607 -310
- package/src/extensions/components/ai/backend.test.ts +1 -0
- package/src/extensions/components/ai/backend.ts +2 -1
- package/src/extensions/components/ai/context.test.ts +149 -0
- package/src/extensions/components/ai/context.ts +145 -0
- package/src/extensions/components/ai/diff.test.ts +65 -0
- package/src/extensions/components/ai/diff.ts +120 -0
- package/src/extensions/components/ai/errors.test.ts +45 -0
- package/src/extensions/components/ai/errors.ts +67 -0
- package/src/extensions/components/ai/result.test.ts +144 -0
- package/src/extensions/components/ai/result.ts +101 -0
- package/src/extensions/components/ai/thinking.test.ts +45 -0
- package/src/extensions/components/ai/thinking.ts +58 -0
- package/src/extensions/components/formatExtra/sub/Button.vue +1 -1
- package/src/extensions/components/formatExtra/sup/Button.vue +1 -1
- package/src/extensions/components/media/iframe/Button.vue +6 -7
- package/src/extensions/components/media/iframe/IframeDialog.test.ts +177 -0
- package/src/extensions/components/media/iframe/IframeDialog.vue +477 -105
- package/src/extensions/components/media/iframe/embed.test.ts +123 -0
- package/src/extensions/components/media/iframe/embed.ts +236 -0
- package/src/extensions/components/media/iframe/index.test.ts +123 -0
- package/src/extensions/components/media/iframe/index.ts +92 -10
- package/src/extensions/components/media/image/ImageDialog.vue +430 -132
- package/src/extensions/components/media/image/caption.test.ts +66 -1
- package/src/extensions/components/media/image/caption.ts +27 -1
- package/src/extensions/components/media/image/imageDialog.test.ts +210 -0
- package/src/extensions/components/media/image/imageDialog.ts +117 -0
- package/src/extensions/components/media/image/index.ts +2 -1
- package/src/extensions/components/media/link/Button.test.ts +96 -0
- package/src/extensions/components/media/link/Button.vue +6 -2
- package/src/extensions/components/media/link/LinkDialog.display.test.ts +363 -0
- package/src/extensions/components/media/link/LinkDialog.vue +687 -61
- package/src/extensions/components/media/link/index.ts +111 -6
- package/src/extensions/components/media/link/link.marks.test.ts +89 -0
- package/src/extensions/components/media/link/linkDialog.test.ts +296 -0
- package/src/extensions/components/media/link/linkDialog.ts +154 -0
- package/src/extensions/components/media/linkCard/PasteCardMenu.test.ts +133 -0
- package/src/extensions/components/media/linkCard/PasteCardMenu.vue +168 -0
- package/src/extensions/components/media/linkCard/index.test.ts +146 -0
- package/src/extensions/components/media/linkCard/index.ts +177 -0
- package/src/extensions/components/media/linkCard/linkCardDialog.test.ts +87 -0
- package/src/extensions/components/media/linkCard/linkCardDialog.ts +62 -0
- package/src/extensions/components/media/linkCard/linkPreview.test.ts +102 -0
- package/src/extensions/components/media/linkCard/linkPreview.ts +84 -0
- package/src/extensions/components/media/linkCard/makeLinkCard.test.ts +59 -0
- package/src/extensions/components/media/linkCard/makeLinkCard.ts +58 -0
- package/src/extensions/components/media/linkCard/pasteCard.test.ts +190 -0
- package/src/extensions/components/media/linkCard/pasteCard.ts +153 -0
- package/src/extensions/components/media/video/Button.vue +2 -1
- package/src/extensions/components/media/video/VideoDialog.test.ts +233 -0
- package/src/extensions/components/media/video/VideoDialog.vue +748 -59
- package/src/extensions/components/media/video/index.test.ts +154 -0
- package/src/extensions/components/media/video/index.ts +70 -5
- package/src/extensions/components/media/video/providers.test.ts +148 -0
- package/src/extensions/components/media/video/providers.ts +241 -0
- package/src/extensions/components/media/video/videoFile.test.ts +98 -0
- package/src/extensions/components/media/video/videoFile.ts +142 -0
- package/src/extensions/components/misc/fullscreen/Button.test.ts +55 -0
- package/src/extensions/components/misc/fullscreen/Button.vue +2 -2
- package/src/extensions/components/style/StyleColor.test.ts +255 -0
- package/src/extensions/components/style/StyleColor.vue +358 -208
- package/src/extensions/components/style/color/Button.vue +1 -3
- package/src/extensions/components/style/color/colors.test.ts +191 -0
- package/src/extensions/components/style/color/colors.ts +168 -0
- package/src/extensions/components/style/highlight/Button.vue +1 -3
- package/src/i18n/locales/ar.json +226 -23
- package/src/i18n/locales/bg.json +225 -22
- package/src/i18n/locales/ch.json +226 -23
- package/src/i18n/locales/cz.json +226 -23
- package/src/i18n/locales/de.json +226 -23
- package/src/i18n/locales/el.json +225 -22
- package/src/i18n/locales/en.json +225 -22
- package/src/i18n/locales/es.json +226 -23
- package/src/i18n/locales/et.json +225 -22
- package/src/i18n/locales/fi.json +226 -23
- package/src/i18n/locales/fr.json +226 -23
- package/src/i18n/locales/he.json +225 -22
- package/src/i18n/locales/hi.json +225 -22
- package/src/i18n/locales/hr.json +225 -22
- package/src/i18n/locales/hu.json +226 -23
- package/src/i18n/locales/id.json +225 -22
- package/src/i18n/locales/it.json +226 -23
- package/src/i18n/locales/ja.json +226 -23
- package/src/i18n/locales/ko.json +226 -23
- package/src/i18n/locales/la.json +226 -23
- package/src/i18n/locales/lt.json +226 -23
- package/src/i18n/locales/nl.json +226 -23
- package/src/i18n/locales/no.json +227 -24
- package/src/i18n/locales/pl.json +226 -23
- package/src/i18n/locales/pt-BR.json +225 -22
- package/src/i18n/locales/pt.json +226 -23
- package/src/i18n/locales/ro.json +225 -22
- package/src/i18n/locales/ru.json +227 -24
- package/src/i18n/locales/se.json +226 -23
- package/src/i18n/locales/sk.json +225 -22
- package/src/i18n/locales/sl.json +225 -22
- package/src/i18n/locales/sr.json +225 -22
- package/src/i18n/locales/th.json +226 -23
- package/src/i18n/locales/tr.json +226 -23
- package/src/i18n/locales/uk.json +227 -24
- package/src/i18n/locales/vi.json +226 -23
- package/src/index.ts +10 -0
- package/src/skins/registry.test.ts +1 -1
- package/src/skins/tokens.reachability.test.ts +1 -0
- package/src/skins/tokens.ts +3 -3
- package/src/types/editor.ts +40 -1
- package/src/utils/contentStyles.test.ts +116 -0
- package/src/utils/contentStyles.ts +175 -0
- package/src/utils/mediaSize.test.ts +22 -0
- package/src/utils/mediaSize.ts +27 -0
- package/src/utils/validateHref.test.ts +47 -2
- 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
|
+
})
|