tiptapify 0.3.4 → 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 +110820 -65669
  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 +72 -2
  137. package/src/utils/validateHref.ts +57 -13
@@ -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('sub') ? 'primary' : ''"
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('sup') ? 'primary' : ''"
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, ref, Ref, useTemplateRef } from 'vue'
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.isActive('iframe') ? 'primary' : ''"
24
+ :color="selectedIframe(editor) ? 'primary' : ''"
26
25
  :variant="variantBtn"
27
26
  size="32"
28
- @click="dialog?.open()"
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 ref="dialog" />
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
+ })