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,144 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { Editor } from '@tiptap/core'
|
|
3
|
+
import { Bold } from '@tiptap/extension-bold'
|
|
4
|
+
import { Document } from '@tiptap/extension-document'
|
|
5
|
+
import { Link } from '@tiptap/extension-link'
|
|
6
|
+
import { Paragraph } from '@tiptap/extension-paragraph'
|
|
7
|
+
import { Text } from '@tiptap/extension-text'
|
|
8
|
+
import { exceedsLimit } from './charLimit'
|
|
9
|
+
import {
|
|
10
|
+
aiActions,
|
|
11
|
+
AiApplyAction,
|
|
12
|
+
applyAiResult,
|
|
13
|
+
countedText,
|
|
14
|
+
parseAiResult,
|
|
15
|
+
plainText,
|
|
16
|
+
positionBelow,
|
|
17
|
+
projectedCharacters,
|
|
18
|
+
renderAiResult,
|
|
19
|
+
} from './result'
|
|
20
|
+
|
|
21
|
+
let editor: Editor | null = null
|
|
22
|
+
|
|
23
|
+
function createEditor(content: string) {
|
|
24
|
+
editor = new Editor({
|
|
25
|
+
element: document.createElement('div'),
|
|
26
|
+
content,
|
|
27
|
+
extensions: [Document, Paragraph, Text, Bold, Link],
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
return editor
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
editor?.destroy()
|
|
35
|
+
editor = null
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('aiActions', () => {
|
|
39
|
+
it('replaces or goes below a selection, inserts or appends without one', () => {
|
|
40
|
+
expect(aiActions(true)).toEqual(['replace', 'below'])
|
|
41
|
+
expect(aiActions(false)).toEqual(['insert', 'append'])
|
|
42
|
+
expect(aiActions(true, 'insert')).toEqual(['replace', 'below'])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('keeps a configured append mode as the primary action', () => {
|
|
46
|
+
expect(aiActions(false, 'append')).toEqual(['append', 'insert'])
|
|
47
|
+
expect(aiActions(true, 'append')).toEqual(['append', 'replace'])
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('parseAiResult and renderAiResult', () => {
|
|
52
|
+
it('renders HTML through the schema, without scripts, handlers or unsafe links', () => {
|
|
53
|
+
const { schema } = createEditor('<p></p>')
|
|
54
|
+
const fragment = parseAiResult('<p onclick="x()">Hi <b>there</b><script>alert(1)</script> <a href="javascript:alert(1)">link</a></p>', schema)
|
|
55
|
+
const target = document.createElement('div')
|
|
56
|
+
|
|
57
|
+
renderAiResult(fragment, schema, target)
|
|
58
|
+
|
|
59
|
+
expect(target.innerHTML).toBe('<p>Hi <strong>there</strong> link</p>')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('keeps plain text and its line breaks as they will be inserted', () => {
|
|
63
|
+
const { schema } = createEditor('<p></p>')
|
|
64
|
+
const fragment = parseAiResult('First line\n\nSecond', schema)
|
|
65
|
+
|
|
66
|
+
expect(plainText(fragment)).toBe('First line\n\nSecond')
|
|
67
|
+
expect(countedText(fragment)).toBe('First line\n\nSecond')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('counts paragraphs of a result without separators, like the CharacterCount extension', () => {
|
|
71
|
+
const { schema } = createEditor('<p></p>')
|
|
72
|
+
const fragment = parseAiResult('<p>ab</p><p>cd</p>', schema)
|
|
73
|
+
|
|
74
|
+
expect(countedText(fragment)).toBe('abcd')
|
|
75
|
+
expect(plainText(fragment)).toBe('ab\ncd')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('is empty for an empty result', () => {
|
|
79
|
+
const { schema } = createEditor('<p></p>')
|
|
80
|
+
|
|
81
|
+
expect(parseAiResult('', schema).size).toBe(0)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('projectedCharacters', () => {
|
|
86
|
+
it('agrees with the limit check of the dialog', () => {
|
|
87
|
+
const { state } = createEditor('<p>Hello world</p>')
|
|
88
|
+
const range = { from: 1, to: 6 }
|
|
89
|
+
const cases: Array<[AiApplyAction, number]> = [['replace', 15], ['insert', 10], ['below', 10], ['append', 10]]
|
|
90
|
+
|
|
91
|
+
for (const [action, length] of cases) {
|
|
92
|
+
const text = 'x'.repeat(length)
|
|
93
|
+
const projected = projectedCharacters(state.doc, text, action, range)
|
|
94
|
+
const mode = action === 'replace' ? 'replace' : 'insert'
|
|
95
|
+
|
|
96
|
+
expect(projected > 20).toBe(exceedsLimit(text, { doc: state.doc, limit: 20, mode, range }))
|
|
97
|
+
expect(projected > 21).toBe(exceedsLimit(text, { doc: state.doc, limit: 21, mode, range }))
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('applyAiResult', () => {
|
|
103
|
+
it('replaces the selected range', () => {
|
|
104
|
+
const instance = createEditor('<p>Hello world</p>')
|
|
105
|
+
instance.commands.setTextSelection({ from: 7, to: 12 })
|
|
106
|
+
|
|
107
|
+
applyAiResult(instance, 'replace', 'there', { from: 7, to: 12 })
|
|
108
|
+
|
|
109
|
+
expect(instance.getHTML()).toBe('<p>Hello there</p>')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('inserts below the block of the selection, keeping it', () => {
|
|
113
|
+
const instance = createEditor('<p>Hello world</p><p>Last</p>')
|
|
114
|
+
|
|
115
|
+
applyAiResult(instance, 'below', 'New text', { from: 7, to: 12 })
|
|
116
|
+
|
|
117
|
+
expect(instance.getHTML()).toBe('<p>Hello world</p><p>New text</p><p>Last</p>')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('inserts HTML below the selection', () => {
|
|
121
|
+
const instance = createEditor('<p>Hello</p>')
|
|
122
|
+
|
|
123
|
+
applyAiResult(instance, 'below', '<p><b>Bold</b></p>', { from: 1, to: 6 })
|
|
124
|
+
|
|
125
|
+
expect(instance.getHTML()).toBe('<p>Hello</p><p><strong>Bold</strong></p>')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('inserts at the cursor and appends to the end', () => {
|
|
129
|
+
const instance = createEditor('<p>Hello</p><p>World</p>')
|
|
130
|
+
instance.commands.setTextSelection(6)
|
|
131
|
+
|
|
132
|
+
applyAiResult(instance, 'insert', '!', null)
|
|
133
|
+
applyAiResult(instance, 'append', '<p>End</p>', null)
|
|
134
|
+
|
|
135
|
+
expect(instance.getHTML()).toBe('<p>Hello!</p><p>World</p><p>End</p>')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('finds the position below a nested block at the top level', () => {
|
|
139
|
+
const { state } = createEditor('<p>One</p><p>Two</p>')
|
|
140
|
+
|
|
141
|
+
expect(positionBelow(state.doc, 2)).toBe(5)
|
|
142
|
+
expect(positionBelow(state.doc, state.doc.content.size)).toBe(state.doc.content.size)
|
|
143
|
+
})
|
|
144
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createNodeFromContent } from '@tiptap/core'
|
|
2
|
+
import type { Editor } from '@tiptap/core'
|
|
3
|
+
import { DOMSerializer, Fragment } from '@tiptap/pm/model'
|
|
4
|
+
import type { Node, Schema } from '@tiptap/pm/model'
|
|
5
|
+
import type { TiptapifyAiMode } from '@tiptapify/types/editor'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where a result goes: `replace` the selection, `below` it (after its block), `insert`
|
|
9
|
+
* at the cursor or `append` to the end of the document
|
|
10
|
+
*/
|
|
11
|
+
export type AiApplyAction = 'replace' | 'below' | 'insert' | 'append'
|
|
12
|
+
|
|
13
|
+
export type AiRange = { from: number, to: number }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The primary and the secondary action of the dialog. With a selection the result
|
|
17
|
+
* replaces it or goes below it; without one it goes to the cursor or the end. A
|
|
18
|
+
* configured `append` mode keeps appending as the primary action.
|
|
19
|
+
*/
|
|
20
|
+
export function aiActions(hasSelection: boolean, configuredMode?: TiptapifyAiMode): [AiApplyAction, AiApplyAction] {
|
|
21
|
+
if (configuredMode === 'append') {
|
|
22
|
+
return ['append', hasSelection ? 'replace' : 'insert']
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return hasSelection ? ['replace', 'below'] : ['insert', 'append']
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The result as the editor will insert it: `insertContent` parses the string as HTML
|
|
30
|
+
* (plain text stays text, whitespace kept), so parsing it with the editor schema
|
|
31
|
+
* gives the exact content, without anything the schema does not know (scripts, event
|
|
32
|
+
* attributes, unsafe links)
|
|
33
|
+
*/
|
|
34
|
+
export function parseAiResult(content: string, schema: Schema): Fragment {
|
|
35
|
+
if (!content) {
|
|
36
|
+
return Fragment.empty
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const parsed = createNodeFromContent(content, schema, { parseOptions: { preserveWhitespace: 'full' } })
|
|
41
|
+
|
|
42
|
+
return parsed instanceof Fragment ? parsed : Fragment.from(parsed)
|
|
43
|
+
} catch {
|
|
44
|
+
return Fragment.from(schema.text(content))
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The text of the result counted like the CharacterCount extension counts the document */
|
|
49
|
+
export function countedText(fragment: Fragment): string {
|
|
50
|
+
return fragment.textBetween(0, fragment.size, undefined, ' ')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The text of the result with block boundaries as new lines, to compare it with the selected text */
|
|
54
|
+
export function plainText(fragment: Fragment): string {
|
|
55
|
+
return fragment.textBetween(0, fragment.size, '\n')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** DOM of the parsed result for the preview, built by the schema's own `renderHTML` */
|
|
59
|
+
export function renderAiResult(fragment: Fragment, schema: Schema, target: HTMLElement) {
|
|
60
|
+
try {
|
|
61
|
+
target.replaceChildren(DOMSerializer.fromSchema(schema).serializeFragment(fragment, { document: target.ownerDocument }))
|
|
62
|
+
} catch {
|
|
63
|
+
target.textContent = plainText(fragment)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The document length after the action, counted like the CharacterCount extension:
|
|
69
|
+
* replacing frees the characters of the selected range
|
|
70
|
+
*/
|
|
71
|
+
export function projectedCharacters(doc: Node, resultText: string, action: AiApplyAction, range: AiRange | null): number {
|
|
72
|
+
const current = doc.textBetween(0, doc.content.size, undefined, ' ').length
|
|
73
|
+
const removed = action === 'replace' && range ? doc.textBetween(range.from, range.to, undefined, ' ').length : 0
|
|
74
|
+
|
|
75
|
+
return current - removed + resultText.length
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Right after the top-level block that holds `pos` (a position between blocks stays as is) */
|
|
79
|
+
export function positionBelow(doc: Node, pos: number): number {
|
|
80
|
+
const $pos = doc.resolve(pos)
|
|
81
|
+
|
|
82
|
+
return $pos.depth > 0 ? $pos.after(1) : pos
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function applyAiResult(editor: Editor, action: AiApplyAction, content: string, range: AiRange | null) {
|
|
86
|
+
const chain = editor.chain().focus()
|
|
87
|
+
|
|
88
|
+
if (action === 'replace' && range) {
|
|
89
|
+
chain.deleteRange(range)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (action === 'append') {
|
|
93
|
+
chain.insertContentAt(editor.state.doc.content.size, content)
|
|
94
|
+
} else if (action === 'below') {
|
|
95
|
+
chain.insertContentAt(positionBelow(editor.state.doc, range?.to ?? editor.state.selection.to), content)
|
|
96
|
+
} else {
|
|
97
|
+
chain.insertContent(content)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return chain.run()
|
|
101
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { TiptapifyAiReasoningEffort, TiptapifyAiRequest } from '@tiptapify/types/editor'
|
|
3
|
+
import { applyThinking, initialThinkingChoice, thinkingChoices } from './thinking'
|
|
4
|
+
|
|
5
|
+
function request(extra: Partial<TiptapifyAiRequest> = {}): TiptapifyAiRequest {
|
|
6
|
+
return { messages: [], ...extra }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('thinking choices', () => {
|
|
10
|
+
it('offers nothing when thinking is not configured, even with effort options', () => {
|
|
11
|
+
expect(thinkingChoices({ reasoningEffort: { options: ['low'] } })).toEqual([])
|
|
12
|
+
expect(initialThinkingChoice({})).toBeNull()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('offers off and on for plain thinking', () => {
|
|
16
|
+
expect(thinkingChoices({ thinking: true })).toEqual(['off', 'on'])
|
|
17
|
+
expect(initialThinkingChoice({ thinking: true })).toBe('on')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('adds the effort options without the "default" entry', () => {
|
|
21
|
+
const config = { thinking: true, reasoningEffort: { options: ['default', 'low', 'high'] as TiptapifyAiReasoningEffort[] } }
|
|
22
|
+
|
|
23
|
+
expect(thinkingChoices(config)).toEqual(['off', 'on', 'low', 'high'])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('starts at the configured default effort only when it is an option', () => {
|
|
27
|
+
expect(initialThinkingChoice({ thinking: true, reasoningEffort: { options: ['low', 'high'], default: 'high' } })).toBe('high')
|
|
28
|
+
expect(initialThinkingChoice({ thinking: true, reasoningEffort: { options: ['low'], default: 'high' } })).toBe('on')
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('applyThinking', () => {
|
|
33
|
+
it('sends an explicit effort instead of enable_thinking', () => {
|
|
34
|
+
expect(applyThinking(request({ enable_thinking: true }), 'medium')).toEqual({ messages: [], reasoning_effort: 'medium' })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('sends enable_thinking for on and off, dropping a configured effort', () => {
|
|
38
|
+
expect(applyThinking(request({ reasoning_effort: 'low' }), 'on')).toEqual({ messages: [], enable_thinking: true })
|
|
39
|
+
expect(applyThinking(request(), 'off')).toEqual({ messages: [], enable_thinking: false })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('turns thinking off when it is not supported', () => {
|
|
43
|
+
expect(applyThinking(request({ enable_thinking: true }), null)).toEqual({ messages: [], enable_thinking: false })
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TiptapifyAiReasoningEffort,
|
|
3
|
+
TiptapifyAiReasoningEffortOptions,
|
|
4
|
+
TiptapifyAiRequest,
|
|
5
|
+
} from '@tiptapify/types/editor'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The one thinking control of the dialog: thinking off, on (the provider's default
|
|
9
|
+
* effort), or on with an explicit reasoning effort
|
|
10
|
+
*/
|
|
11
|
+
export type AiThinkingChoice = 'off' | 'on' | TiptapifyAiReasoningEffort
|
|
12
|
+
|
|
13
|
+
export type AiThinkingConfig = {
|
|
14
|
+
thinking?: boolean,
|
|
15
|
+
reasoningEffort?: TiptapifyAiReasoningEffortOptions,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function effortOptions(config: AiThinkingConfig): TiptapifyAiReasoningEffort[] {
|
|
19
|
+
const options = config.reasoningEffort?.options
|
|
20
|
+
|
|
21
|
+
return Array.isArray(options) ? options.filter(option => (option as string) !== 'default') : []
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** No choices when thinking is not configured: the effort applies only with thinking on */
|
|
25
|
+
export function thinkingChoices(config: AiThinkingConfig): AiThinkingChoice[] {
|
|
26
|
+
return config.thinking === true ? ['off', 'on', ...effortOptions(config)] : []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Thinking starts on, at the configured default effort when it is one of the options */
|
|
30
|
+
export function initialThinkingChoice(config: AiThinkingConfig): AiThinkingChoice | null {
|
|
31
|
+
if (config.thinking !== true) {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const preferred = config.reasoningEffort?.default
|
|
36
|
+
|
|
37
|
+
return preferred && effortOptions(config).includes(preferred) ? preferred : 'on'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isEffort(choice: AiThinkingChoice | null): choice is TiptapifyAiReasoningEffort {
|
|
41
|
+
return choice !== null && choice !== 'off' && choice !== 'on'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Sets the thinking fields of the request: an explicit effort goes as `reasoning_effort`
|
|
46
|
+
* (and `enable_thinking` is dropped), otherwise `enable_thinking` says whether thinking is on
|
|
47
|
+
*/
|
|
48
|
+
export function applyThinking(request: TiptapifyAiRequest, choice: AiThinkingChoice | null): TiptapifyAiRequest {
|
|
49
|
+
if (isEffort(choice)) {
|
|
50
|
+
delete request.enable_thinking
|
|
51
|
+
request.reasoning_effort = choice
|
|
52
|
+
} else {
|
|
53
|
+
delete request.reasoning_effort
|
|
54
|
+
request.enable_thinking = choice === 'on'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return request
|
|
58
|
+
}
|
|
@@ -19,7 +19,7 @@ const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
|
|
|
19
19
|
|
|
20
20
|
<template>
|
|
21
21
|
<VBtn
|
|
22
|
-
:color="editor.isActive('
|
|
22
|
+
:color="editor.isActive('subscript') ? 'primary' : ''"
|
|
23
23
|
:disabled="!editor.can().chain().focus().toggleSubscript().run()"
|
|
24
24
|
:variant="variantBtn"
|
|
25
25
|
size="32"
|
|
@@ -19,7 +19,7 @@ const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
|
|
|
19
19
|
|
|
20
20
|
<template>
|
|
21
21
|
<VBtn
|
|
22
|
-
:color="editor.isActive('
|
|
22
|
+
:color="editor.isActive('superscript') ? 'primary' : ''"
|
|
23
23
|
:disabled="!editor.can().chain().focus().toggleSuperscript().run()"
|
|
24
24
|
:variant="variantBtn"
|
|
25
25
|
size="32"
|
|
@@ -4,8 +4,9 @@ import { Editor } from '@tiptap/vue-3'
|
|
|
4
4
|
import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
|
|
5
5
|
import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
|
|
6
6
|
import IframeDialog from '@tiptapify/extensions/components/media/iframe/IframeDialog.vue'
|
|
7
|
+
import { selectedIframe } from '@tiptapify/extensions/components/media/iframe'
|
|
7
8
|
import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
|
|
8
|
-
import { inject,
|
|
9
|
+
import { inject, Ref } from 'vue'
|
|
9
10
|
|
|
10
11
|
import { ComposerTranslation } from 'vue-i18n'
|
|
11
12
|
|
|
@@ -15,25 +16,23 @@ const editor = inject('tiptapifyEditor') as Ref<Editor>
|
|
|
15
16
|
|
|
16
17
|
const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
|
|
17
18
|
|
|
18
|
-
const dialog = useTemplateRef('dialog')
|
|
19
|
-
|
|
20
19
|
const icon = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M1 12h9.8L8.3 9.5l1.4-1.4l4.9 4.9l-4.9 4.9l-1.4-1.4l2.5-2.5H1zM21 2H3c-1.1 0-2 .9-2 2v6.1h2V6h18v14H3v-4H1v4c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2"/></svg>'
|
|
21
20
|
</script>
|
|
22
21
|
|
|
23
22
|
<template>
|
|
24
23
|
<VBtn
|
|
25
|
-
:color="editor
|
|
24
|
+
:color="selectedIframe(editor) ? 'primary' : ''"
|
|
26
25
|
:variant="variantBtn"
|
|
27
26
|
size="32"
|
|
28
|
-
@click="
|
|
27
|
+
@click="editor.commands.showIframe()"
|
|
29
28
|
>
|
|
30
29
|
<Tooltip :label="t('media.iframe')" />
|
|
31
30
|
<BtnIcon :icon="icon" />
|
|
32
31
|
</VBtn>
|
|
33
32
|
|
|
34
|
-
<IframeDialog
|
|
33
|
+
<IframeDialog />
|
|
35
34
|
</template>
|
|
36
35
|
|
|
37
36
|
<style lang="scss" scoped>
|
|
38
37
|
|
|
39
|
-
</style>
|
|
38
|
+
</style>
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { App, 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 { Paragraph } from '@tiptap/extension-paragraph'
|
|
7
|
+
import { Text } from '@tiptap/extension-text'
|
|
8
|
+
import Iframe from '@tiptapify/extensions/components/media/iframe'
|
|
9
|
+
import IframeDialog from '@tiptapify/extensions/components/media/iframe/IframeDialog.vue'
|
|
10
|
+
|
|
11
|
+
// jsdom has no `visualViewport`; Vuetify's overlay positioning reads it
|
|
12
|
+
if (!('visualViewport' in globalThis)) {
|
|
13
|
+
;(globalThis as Record<string, unknown>).visualViewport = {
|
|
14
|
+
scale: 1,
|
|
15
|
+
offsetLeft: 0,
|
|
16
|
+
offsetTop: 0,
|
|
17
|
+
addEventListener: () => {},
|
|
18
|
+
removeEventListener: () => {},
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let app: App | null = null
|
|
23
|
+
let editor: Editor | null = null
|
|
24
|
+
|
|
25
|
+
async function flush() {
|
|
26
|
+
for (let i = 0; i < 5; i++) {
|
|
27
|
+
await nextTick()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The translation shows the key and its params, for the assertions
|
|
32
|
+
function t(key: string, params?: Record<string, unknown>) {
|
|
33
|
+
return params && Object.keys(params).length ? `${key} ${JSON.stringify(params)}` : key
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function openDialog(content: string, selectFirst = false): Promise<HTMLElement> {
|
|
37
|
+
editor = new Editor({
|
|
38
|
+
element: document.createElement('div'),
|
|
39
|
+
content,
|
|
40
|
+
extensions: [Document, Paragraph, Text, Iframe],
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
app = createApp({
|
|
44
|
+
setup() {
|
|
45
|
+
provide('tiptapifyEditor', shallowRef(editor))
|
|
46
|
+
provide('tiptapifyI18n', { t })
|
|
47
|
+
|
|
48
|
+
return () => h(IframeDialog)
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
app.use(createVuetify())
|
|
52
|
+
app.mount(document.body.appendChild(document.createElement('div')))
|
|
53
|
+
await flush()
|
|
54
|
+
|
|
55
|
+
if (selectFirst) {
|
|
56
|
+
editor.commands.setNodeSelection(0)
|
|
57
|
+
}
|
|
58
|
+
editor.commands.showIframe()
|
|
59
|
+
await flush()
|
|
60
|
+
|
|
61
|
+
return document.querySelector<HTMLElement>('.tiptapify-dialog')!
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function type(input: HTMLInputElement | HTMLTextAreaElement, value: string) {
|
|
65
|
+
input.value = value
|
|
66
|
+
input.dispatchEvent(new Event('input'))
|
|
67
|
+
await flush()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function submit(card: HTMLElement) {
|
|
71
|
+
card.querySelector('form')!.requestSubmit()
|
|
72
|
+
await flush()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buttonWithText(card: HTMLElement, text: string): HTMLButtonElement {
|
|
76
|
+
return [...card.querySelectorAll('button')].find(button => button.textContent?.trim() === text) as HTMLButtonElement
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
app?.unmount()
|
|
81
|
+
editor?.destroy()
|
|
82
|
+
app = null
|
|
83
|
+
editor = null
|
|
84
|
+
document.body.innerHTML = ''
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('embed dialog', () => {
|
|
88
|
+
it('reads a pasted code, previews it and inserts a responsive lazy embed', async () => {
|
|
89
|
+
const card = await openDialog('<p></p>')
|
|
90
|
+
|
|
91
|
+
await type(
|
|
92
|
+
card.querySelector('textarea')!,
|
|
93
|
+
'<iframe src="https://maps.example.com/embed?pb=1" width="600" height="450" title="Office" allowfullscreen></iframe>',
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
expect(card.textContent).toContain('dialog.iframe.code_parsed')
|
|
97
|
+
expect(card.textContent).toContain('dialog.iframe.parsed_size')
|
|
98
|
+
expect(card.textContent).toContain('dialog.iframe.size_responsive_hint {"ratio":"600 × 450"}')
|
|
99
|
+
expect(card.querySelector<HTMLInputElement>('input[type="text"]')!.value).toBe('Office')
|
|
100
|
+
|
|
101
|
+
const preview = card.querySelector('.tiptapify-iframe-dialog__preview iframe')!
|
|
102
|
+
expect(preview.getAttribute('src')).toBe('https://maps.example.com/embed?pb=1')
|
|
103
|
+
expect(preview.getAttribute('sandbox')).toContain('allow-scripts')
|
|
104
|
+
|
|
105
|
+
await submit(card)
|
|
106
|
+
|
|
107
|
+
expect(editor!.state.doc.firstChild!.attrs).toMatchObject({
|
|
108
|
+
src: 'https://maps.example.com/embed?pb=1',
|
|
109
|
+
title: 'Office',
|
|
110
|
+
ratio: 'auto',
|
|
111
|
+
width: 600,
|
|
112
|
+
height: 450,
|
|
113
|
+
loading: 'lazy',
|
|
114
|
+
allowfullscreen: true,
|
|
115
|
+
border: false,
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('asks for the protocol and keeps the dialog open', async () => {
|
|
120
|
+
const card = await openDialog('<p></p>')
|
|
121
|
+
|
|
122
|
+
await type(card.querySelector('textarea')!, 'maps.example.com/embed')
|
|
123
|
+
expect(card.querySelector('.tiptapify-iframe-dialog__preview iframe')).toBeNull()
|
|
124
|
+
|
|
125
|
+
await submit(card)
|
|
126
|
+
|
|
127
|
+
expect(card.textContent).toContain('dialog.iframe.src_protocol')
|
|
128
|
+
expect(document.querySelector('.tiptapify-dialog')).not.toBeNull()
|
|
129
|
+
expect(editor!.getHTML()).toBe('<p></p>')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('rejects a code that is not a single iframe at once', async () => {
|
|
133
|
+
const card = await openDialog('<p></p>')
|
|
134
|
+
|
|
135
|
+
await type(card.querySelector('textarea')!, '<blockquote class="twitter-tweet"></blockquote><script src="https://x.example/w.js"></script>')
|
|
136
|
+
|
|
137
|
+
expect(card.textContent).toContain('dialog.iframe.code_invalid')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('inserts a ratio preset without a size', async () => {
|
|
141
|
+
const card = await openDialog('<p></p>')
|
|
142
|
+
|
|
143
|
+
await type(card.querySelector('textarea')!, 'https://a.example/embed')
|
|
144
|
+
buttonWithText(card, '4:3').click()
|
|
145
|
+
await flush()
|
|
146
|
+
await submit(card)
|
|
147
|
+
|
|
148
|
+
expect(editor!.state.doc.firstChild!.attrs).toMatchObject({ ratio: '4:3', width: null, height: null })
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('edits old content with a size as custom, in place', async () => {
|
|
152
|
+
const card = await openDialog('<iframe src="https://a.example/embed" width="640" height="360" frameborder="0"></iframe>', true)
|
|
153
|
+
|
|
154
|
+
expect(card.textContent).toContain('dialog.iframe.title_edit')
|
|
155
|
+
expect(card.querySelector('textarea')!.value).toBe('https://a.example/embed')
|
|
156
|
+
|
|
157
|
+
const [width, height] = card.querySelectorAll<HTMLInputElement>('input[type="number"]')
|
|
158
|
+
expect([width.value, height.value]).toEqual(['640', '360'])
|
|
159
|
+
|
|
160
|
+
await type(width, '800')
|
|
161
|
+
expect(height.value).toBe('450')
|
|
162
|
+
|
|
163
|
+
await submit(card)
|
|
164
|
+
|
|
165
|
+
expect(editor!.state.doc.childCount).toBe(1)
|
|
166
|
+
expect(editor!.state.doc.firstChild!.attrs).toMatchObject({ src: 'https://a.example/embed', ratio: null, width: 800, height: 450, loading: null })
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('removes the edited embed', async () => {
|
|
170
|
+
const card = await openDialog('<iframe src="https://a.example/embed"></iframe><p>Text</p>', true)
|
|
171
|
+
|
|
172
|
+
buttonWithText(card, 'dialog.remove').click()
|
|
173
|
+
await flush()
|
|
174
|
+
|
|
175
|
+
expect(editor!.getHTML()).not.toContain('iframe')
|
|
176
|
+
})
|
|
177
|
+
})
|