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.
Files changed (137) hide show
  1. package/dist/content.css +28 -0
  2. package/dist/tiptapify.css +1 -1
  3. package/dist/tiptapify.mjs +110770 -65623
  4. package/dist/tiptapify.umd.js +106 -89
  5. package/index.d.ts +37 -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 +210 -0
  52. package/src/extensions/components/media/image/imageDialog.ts +117 -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 +10 -0
  128. package/src/skins/registry.test.ts +1 -1
  129. package/src/skins/tokens.reachability.test.ts +1 -0
  130. package/src/skins/tokens.ts +3 -3
  131. package/src/types/editor.ts +40 -1
  132. package/src/utils/contentStyles.test.ts +116 -0
  133. package/src/utils/contentStyles.ts +175 -0
  134. package/src/utils/mediaSize.test.ts +22 -0
  135. package/src/utils/mediaSize.ts +27 -0
  136. package/src/utils/validateHref.test.ts +47 -2
  137. package/src/utils/validateHref.ts +47 -7
@@ -176,6 +176,7 @@ describe('createAiBackendProvider', () => {
176
176
  const provider = createAiBackendProvider({ endpoint: '/api' })
177
177
 
178
178
  await expect(provider(createRequest(), createContext())).rejects.toThrow('Model is busy')
179
+ await expect(provider(createRequest(), createContext())).rejects.toMatchObject({ status: 500 })
179
180
  })
180
181
 
181
182
  it('throws a status error when the error body is not JSON', async () => {
@@ -144,7 +144,8 @@ async function createBackendError(response: Response): Promise<Error> {
144
144
  // Keep the raw body
145
145
  }
146
146
 
147
- return new Error(message || `Request failed with status ${response.status}`)
147
+ // The status lets the dialog explain the error (see errors.ts)
148
+ return Object.assign(new Error(message || `Request failed with status ${response.status}`), { status: response.status })
148
149
  }
149
150
 
150
151
  function parseBackendResponse(text: string): TiptapifyAiResponse | TiptapifyAiOpenAiResponse | string {
@@ -0,0 +1,149 @@
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 { countWords, describeAiContext, excerpt, selectedTextOf, textAround, wholeWordRange } from './context'
7
+
8
+ let editor: Editor | null = null
9
+
10
+ function docOf(content: string) {
11
+ editor = new Editor({ content, extensions: [Document, Paragraph, Text] })
12
+
13
+ return editor.state.doc
14
+ }
15
+
16
+ afterEach(() => {
17
+ editor?.destroy()
18
+ editor = null
19
+ })
20
+
21
+ describe('excerpt', () => {
22
+ it('keeps short text whole on one line', () => {
23
+ expect(excerpt(' Hello\n world ')).toBe('Hello world')
24
+ })
25
+
26
+ it('cuts after the first words', () => {
27
+ expect(excerpt('one two three four five six seven eight')).toBe('one two three four five six…')
28
+ })
29
+
30
+ it('cuts a very long word run by characters', () => {
31
+ expect(excerpt('a'.repeat(100), 6, 10)).toBe(`${'a'.repeat(10)}…`)
32
+ })
33
+ })
34
+
35
+ describe('countWords', () => {
36
+ it('counts words separated by any whitespace', () => {
37
+ expect(countWords('')).toBe(0)
38
+ expect(countWords(' one\ttwo\n\nthree ')).toBe(3)
39
+ })
40
+ })
41
+
42
+ describe('describeAiContext', () => {
43
+ it('describes a selection over two paragraphs', () => {
44
+ const doc = docOf('<p>Hello world</p><p>Second part</p>')
45
+ // From inside the first paragraph to the middle of the second
46
+ const info = describeAiContext(doc, 7, 20)
47
+
48
+ expect(info).toEqual({
49
+ kind: 'selection',
50
+ paragraphs: 2,
51
+ characters: 'world'.length + 'Second'.length,
52
+ excerpt: 'world Second',
53
+ })
54
+ })
55
+
56
+ it('does not count a paragraph the selection only touches at its start', () => {
57
+ const doc = docOf('<p>Hello</p><p>World</p>')
58
+ // From the start of "Hello" to the start of "World"
59
+ expect(describeAiContext(doc, 1, 8).paragraphs).toBe(1)
60
+ })
61
+
62
+ it('falls back to the whole text without a selection, as the request does', () => {
63
+ const doc = docOf('<p>Hello</p>')
64
+
65
+ expect(describeAiContext(doc, 3, 3).kind).toBe('document')
66
+ expect(selectedTextOf(doc, 3, 3)).toBe('')
67
+ })
68
+
69
+ it('is only the cursor in an empty document', () => {
70
+ expect(describeAiContext(docOf('<p></p>'), 1, 1).kind).toBe('cursor')
71
+ })
72
+
73
+ it('sends the selected blocks separated by new lines', () => {
74
+ const doc = docOf('<p>Hello</p><p>World</p>')
75
+
76
+ expect(selectedTextOf(doc, 0, doc.content.size)).toBe('Hello\nWorld')
77
+ })
78
+ })
79
+
80
+ describe('wholeWordRange', () => {
81
+ // Positions of `<p>…</p>`: the text starts at 1
82
+ const text = 'The talks has entered a new stage.'
83
+ const at = (part: string) => 1 + text.indexOf(part)
84
+
85
+ function selected(from: number, to: number) {
86
+ const doc = docOf(`<p>${text}</p>`)
87
+ const range = wholeWordRange(doc, from, to)
88
+
89
+ return doc.textBetween(range.from, range.to)
90
+ }
91
+
92
+ it('takes the whole word a selection ends inside of', () => {
93
+ expect(selected(1, at('ew stage'))).toBe('The talks has entered a new')
94
+ })
95
+
96
+ it('takes the whole word a selection starts inside of', () => {
97
+ expect(selected(at('alks'), at(' has'))).toBe('talks')
98
+ })
99
+
100
+ it('keeps a selection on word boundaries', () => {
101
+ expect(selected(at('talks'), at(' has'))).toBe('talks')
102
+ expect(selected(at(' has'), at(' entered'))).toBe(' has')
103
+ expect(selected(at('stage'), at('.') + 1)).toBe('stage.')
104
+ })
105
+
106
+ it('widens a selection inside one word to the word', () => {
107
+ expect(selected(at('nter'), at('red'))).toBe('entered')
108
+ })
109
+
110
+ it('keeps an apostrophe inside a word', () => {
111
+ const doc = docOf('<p>We don\'t stop</p>')
112
+ const range = wholeWordRange(doc, 1, 6)
113
+
114
+ expect(doc.textBetween(range.from, range.to)).toBe('We don\'t')
115
+ })
116
+
117
+ it('leaves a collapsed selection and one across paragraphs on boundaries alone', () => {
118
+ const doc = docOf('<p>one two</p><p>three four</p>')
119
+
120
+ expect(wholeWordRange(doc, 3, 3)).toEqual({ from: 3, to: 3 })
121
+ expect(doc.textBetween(...Object.values(wholeWordRange(doc, 6, 13)) as [number, number], '\n')).toBe('two\nthree')
122
+ })
123
+ })
124
+
125
+ describe('textAround', () => {
126
+ it('gives the text of the selection\'s paragraph before and after it', () => {
127
+ const doc = docOf('<p>First one.</p><p>The talks has entered a new stage.</p><p>Last one.</p>')
128
+ const from = 13 + 'The talks '.length
129
+ const to = from + 'has'.length
130
+
131
+ expect(doc.textBetween(from, to)).toBe('has')
132
+ expect(textAround(doc, from, to)).toEqual({ before: 'The talks ', after: ' entered a new stage.' })
133
+ })
134
+
135
+ it('gives nothing around a whole paragraph or a collapsed selection', () => {
136
+ const doc = docOf('<p>one two</p>')
137
+
138
+ expect(textAround(doc, 1, 8)).toEqual({ before: '', after: '' })
139
+ expect(textAround(doc, 3, 3)).toEqual({ before: '', after: '' })
140
+ })
141
+
142
+ it('takes at most 500 characters on each side, the nearest ones', () => {
143
+ const doc = docOf(`<p>${'a'.repeat(600)}X${'b'.repeat(600)}</p>`)
144
+ const { before, after } = textAround(doc, 601, 602)
145
+
146
+ expect(before).toBe('a'.repeat(500))
147
+ expect(after).toBe('b'.repeat(500))
148
+ })
149
+ })
@@ -0,0 +1,145 @@
1
+ import type { Node } from '@tiptap/pm/model'
2
+
3
+ /**
4
+ * What a request works on: the selected text, or (no selection) the whole document with the result
5
+ * going to the cursor; an empty document gives only the cursor.
6
+ */
7
+ export type AiContextKind = 'selection' | 'document' | 'cursor'
8
+
9
+ export type AiContextInfo = {
10
+ kind: AiContextKind,
11
+ paragraphs: number,
12
+ characters: number,
13
+ excerpt: string,
14
+ }
15
+
16
+ const EXCERPT_WORDS = 6
17
+ const EXCERPT_CHARS = 60
18
+
19
+ export function words(text: string): string[] {
20
+ return text.split(/\s+/).filter(Boolean)
21
+ }
22
+
23
+ export function countWords(text: string): number {
24
+ return words(text).length
25
+ }
26
+
27
+ /** The first words of `text` on one line, with an ellipsis when something is cut off */
28
+ export function excerpt(text: string, maxWords = EXCERPT_WORDS, maxChars = EXCERPT_CHARS): string {
29
+ const all = words(text)
30
+ let value = all.slice(0, maxWords).join(' ')
31
+ let cut = all.length > maxWords
32
+
33
+ if (value.length > maxChars) {
34
+ value = value.slice(0, maxChars).trimEnd()
35
+ cut = true
36
+ }
37
+
38
+ return cut ? `${value}…` : value
39
+ }
40
+
41
+ // A letter, a mark or a digit, and an apostrophe inside a word (don't, l'homme)
42
+ const WORD_CHAR = /[\p{L}\p{M}\p{N}_'’]/u
43
+
44
+ // One character per position of the text block's content: text as is, an inline leaf (image, break) as one char
45
+ const LEAF_CHAR = '\uFFFC'
46
+
47
+ function isWordAt(text: string, index: number): boolean {
48
+ return index >= 0 && index < text.length && WORD_CHAR.test(text[index])
49
+ }
50
+
51
+ /**
52
+ * The selection widened to whole words: a selection that starts or ends inside a word takes the whole word,
53
+ * or the result replacing it would leave the rest of the word behind.
54
+ * A collapsed selection, or one already on word boundaries, stays as is.
55
+ */
56
+ export function wholeWordRange(doc: Node, from: number, to: number): { from: number, to: number } {
57
+ if (from === to) {
58
+ return { from, to }
59
+ }
60
+
61
+ const widen = (pos: number, side: -1 | 1): number => {
62
+ const $pos = doc.resolve(pos)
63
+ if (!$pos.parent.isTextblock) {
64
+ return pos
65
+ }
66
+
67
+ const text = $pos.parent.textBetween(0, $pos.parent.content.size, undefined, LEAF_CHAR)
68
+ let offset = $pos.parentOffset
69
+ if (!isWordAt(text, offset - 1) || !isWordAt(text, offset)) {
70
+ return pos
71
+ }
72
+
73
+ while (side < 0 ? isWordAt(text, offset - 1) : isWordAt(text, offset)) {
74
+ offset += side
75
+ }
76
+
77
+ return $pos.start() + offset
78
+ }
79
+
80
+ return { from: widen(from, -1), to: widen(to, 1) }
81
+ }
82
+
83
+ const AROUND_CHARS = 500
84
+
85
+ /**
86
+ * The text of the selection's paragraphs before and after it, at most `AROUND_CHARS` on each side (cut at the
87
+ * far end).
88
+ */
89
+ export function textAround(doc: Node, from: number, to: number): { before: string, after: string } {
90
+ if (from === to) {
91
+ return { before: '', after: '' }
92
+ }
93
+
94
+ const $from = doc.resolve(from)
95
+ const $to = doc.resolve(to)
96
+ const before = $from.parent.isTextblock ? doc.textBetween($from.start(), from, '\n') : ''
97
+ const after = $to.parent.isTextblock ? doc.textBetween(to, $to.end(), '\n') : ''
98
+
99
+ return { before: before.slice(-AROUND_CHARS), after: after.slice(0, AROUND_CHARS) }
100
+ }
101
+
102
+ /**
103
+ * The same text the dialog sends as `selectedText` (block boundaries as new lines):
104
+ * an empty string means the request falls back to the whole document
105
+ */
106
+ export function selectedTextOf(doc: Node, from: number, to: number): string {
107
+ return from === to ? '' : doc.textBetween(from, to, '\n')
108
+ }
109
+
110
+ export function describeAiContext(doc: Node, from: number, to: number): AiContextInfo {
111
+ const selectedText = selectedTextOf(doc, from, to)
112
+
113
+ if (!selectedText) {
114
+ return {
115
+ kind: doc.textContent.trim() ? 'document' : 'cursor',
116
+ paragraphs: 0,
117
+ characters: 0,
118
+ excerpt: '',
119
+ }
120
+ }
121
+
122
+ // A text block counts only when some of its text is selected: a selection that
123
+ // ends at the very start of the next paragraph does not count that paragraph
124
+ let paragraphs = 0
125
+ doc.nodesBetween(from, to, (node, pos) => {
126
+ if (!node.isTextblock) {
127
+ return true
128
+ }
129
+
130
+ const start = Math.max(from, pos + 1)
131
+ const end = Math.min(to, pos + 1 + node.content.size)
132
+ if (end > start) {
133
+ paragraphs++
134
+ }
135
+
136
+ return false
137
+ })
138
+
139
+ return {
140
+ kind: 'selection',
141
+ paragraphs: Math.max(paragraphs, 1),
142
+ characters: doc.textBetween(from, to, '').length,
143
+ excerpt: excerpt(selectedText),
144
+ }
145
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { changePercent, diffWords, formatChangePercent } from './diff'
3
+
4
+ function render(original: string, revised: string) {
5
+ return diffWords(original, revised)
6
+ .map(({ type, text }) => (type === 'equal' ? text : type === 'insert' ? `[+${text}]` : `[-${text}]`))
7
+ .join('')
8
+ }
9
+
10
+ describe('diffWords', () => {
11
+ it('marks a replaced word as a deletion followed by an insertion', () => {
12
+ expect(render('the quick fox', 'the slow fox')).toBe('the [-quick ][+slow ]fox')
13
+ })
14
+
15
+ it('marks added and removed words', () => {
16
+ expect(render('a b c', 'a c d')).toBe('a [-b ]c [+d]')
17
+ })
18
+
19
+ it('keeps the revised text when only the equal parts are joined with insertions', () => {
20
+ const revised = 'Completely new wording here'
21
+ const segments = diffWords('Some old text', revised)
22
+
23
+ expect(segments.filter(s => s.type !== 'delete').map(s => s.text).join('')).toBe(revised)
24
+ expect(segments.filter(s => s.type !== 'insert').map(s => s.text.trim()).join(' ')).toBe('Some old text')
25
+ })
26
+
27
+ it('keeps a space before a deleted last word', () => {
28
+ expect(render('fox jumps', 'fox')).toBe('fox [-jumps]')
29
+ })
30
+
31
+ it('compares words regardless of the whitespace around them', () => {
32
+ expect(diffWords('one\ntwo', ' one two ')).toEqual([{ type: 'equal', text: 'one two' }])
33
+ })
34
+
35
+ it('handles empty sides', () => {
36
+ expect(diffWords('', 'new text')).toEqual([{ type: 'insert', text: 'new text' }])
37
+ expect(diffWords('old', '')).toEqual([{ type: 'delete', text: 'old' }])
38
+ expect(diffWords('', '')).toEqual([])
39
+ })
40
+
41
+ it('falls back to a whole delete and insert for a very large change', () => {
42
+ const original = Array.from({ length: 1200 }, (_, i) => `a${i}`).join(' ')
43
+ const revised = Array.from({ length: 1200 }, (_, i) => `b${i}`).join(' ')
44
+
45
+ expect(diffWords(original, revised).map(s => s.type)).toEqual(['delete', 'insert'])
46
+ })
47
+ })
48
+
49
+ describe('changePercent', () => {
50
+ it('is the length change against the original in whole percent', () => {
51
+ expect(changePercent('1234567890', '12345')).toBe(-50)
52
+ expect(changePercent('abc', 'abcd')).toBe(33)
53
+ expect(changePercent(' abc ', 'abc')).toBe(0)
54
+ })
55
+
56
+ it('is null without an original', () => {
57
+ expect(changePercent(' ', 'text')).toBeNull()
58
+ })
59
+
60
+ it('formats with a sign', () => {
61
+ expect(formatChangePercent(12)).toBe('+12%')
62
+ expect(formatChangePercent(-8)).toBe('−8%')
63
+ expect(formatChangePercent(0)).toBe('0%')
64
+ })
65
+ })
@@ -0,0 +1,120 @@
1
+ export type DiffSegment = {
2
+ type: 'equal' | 'insert' | 'delete',
3
+ text: string,
4
+ }
5
+
6
+ // Above this many LCS cells (≈ 1000 × 1000 changed words) the middle part is shown
7
+ // as a plain delete + insert instead of a word-level diff, to keep the dialog responsive
8
+ const MAX_CELLS = 1_000_000
9
+
10
+ function tokenize(text: string): string[] {
11
+ return text.trim().match(/\S+\s*/g) ?? []
12
+ }
13
+
14
+ function word(token: string): string {
15
+ return token.trimEnd()
16
+ }
17
+
18
+ /**
19
+ * A word diff of `revised` against `original`: words with their trailing whitespace,
20
+ * the longest common subsequence after the common prefix and suffix are cut off.
21
+ * Adjacent segments of one type are merged; deletions come before insertions.
22
+ */
23
+ export function diffWords(original: string, revised: string): DiffSegment[] {
24
+ const a = tokenize(original)
25
+ const b = tokenize(revised)
26
+ const segments: DiffSegment[] = []
27
+
28
+ function push(type: DiffSegment['type'], text: string) {
29
+ const last = segments[segments.length - 1]
30
+ if (last?.type === type) {
31
+ last.text += text
32
+ } else {
33
+ segments.push({ type, text })
34
+ }
35
+ }
36
+
37
+ // An equal word keeps the whitespace after it from either side, so a deletion
38
+ // right after the last word of the revision does not stick to it
39
+ function pushEqual(i: number, j: number) {
40
+ push('equal', /\s$/.test(b[j]) ? b[j] : a[i])
41
+ }
42
+
43
+ let start = 0
44
+ while (start < a.length && start < b.length && word(a[start]) === word(b[start])) {
45
+ start++
46
+ }
47
+
48
+ let endA = a.length
49
+ let endB = b.length
50
+ while (endA > start && endB > start && word(a[endA - 1]) === word(b[endB - 1])) {
51
+ endA--
52
+ endB--
53
+ }
54
+
55
+ for (let k = 0; k < start; k++) {
56
+ pushEqual(k, k)
57
+ }
58
+
59
+ const n = endA - start
60
+ const m = endB - start
61
+
62
+ if (n * m > MAX_CELLS) {
63
+ a.slice(start, endA).forEach(token => push('delete', token))
64
+ b.slice(start, endB).forEach(token => push('insert', token))
65
+ } else {
66
+ // lengths[i * (m + 1) + j]: the LCS length of a[start + i..endA) and b[start + j..endB)
67
+ const lengths = new Uint32Array((n + 1) * (m + 1))
68
+ for (let i = n - 1; i >= 0; i--) {
69
+ for (let j = m - 1; j >= 0; j--) {
70
+ lengths[i * (m + 1) + j] = word(a[start + i]) === word(b[start + j])
71
+ ? lengths[(i + 1) * (m + 1) + j + 1] + 1
72
+ : Math.max(lengths[(i + 1) * (m + 1) + j], lengths[i * (m + 1) + j + 1])
73
+ }
74
+ }
75
+
76
+ let i = 0
77
+ let j = 0
78
+ while (i < n && j < m) {
79
+ if (word(a[start + i]) === word(b[start + j])) {
80
+ pushEqual(start + i, start + j)
81
+ i++
82
+ j++
83
+ } else if (lengths[(i + 1) * (m + 1) + j] >= lengths[i * (m + 1) + j + 1]) {
84
+ push('delete', a[start + i++])
85
+ } else {
86
+ push('insert', b[start + j++])
87
+ }
88
+ }
89
+ while (i < n) {
90
+ push('delete', a[start + i++])
91
+ }
92
+ while (j < m) {
93
+ push('insert', b[start + j++])
94
+ }
95
+ }
96
+
97
+ for (let k = 0; k < a.length - endA; k++) {
98
+ pushEqual(endA + k, endB + k)
99
+ }
100
+
101
+ return segments
102
+ }
103
+
104
+ /** How much longer (positive) or shorter (negative) `revised` is, in whole percent; null without an original */
105
+ export function changePercent(original: string, revised: string): number | null {
106
+ const before = original.trim().length
107
+ if (!before) {
108
+ return null
109
+ }
110
+
111
+ return Math.round((revised.trim().length - before) / before * 100)
112
+ }
113
+
114
+ export function formatChangePercent(percent: number): string {
115
+ if (percent > 0) {
116
+ return `+${percent}%`
117
+ }
118
+
119
+ return percent < 0 ? `−${-percent}%` : '0%'
120
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { aiErrorKind, aiErrorDetail } from '@tiptapify/extensions/components/ai/errors'
3
+
4
+ const withStatus = (status: number, message = '') => Object.assign(new Error(message), { status })
5
+
6
+ describe('AI errors', () => {
7
+ it.each([
8
+ ['Chrome', new TypeError('Failed to fetch')],
9
+ ['Firefox', new TypeError('NetworkError when attempting to fetch resource.')],
10
+ ['Safari', new TypeError('Load failed')],
11
+ ['Node', new TypeError('fetch failed')],
12
+ ])('reads a failed fetch (%s) as the service being unreachable or blocked by CORS', (_, error) => {
13
+ expect(aiErrorKind(error)).toBe('network')
14
+ expect(aiErrorDetail(error)).toBe('')
15
+ })
16
+
17
+ it.each([
18
+ [401, 'auth'],
19
+ [403, 'auth'],
20
+ [404, 'not_found'],
21
+ [408, 'timeout'],
22
+ [429, 'rate_limit'],
23
+ [500, 'unavailable'],
24
+ [503, 'unavailable'],
25
+ [504, 'timeout'],
26
+ [400, 'request'],
27
+ ])('reads status %i as %s', (status, kind) => {
28
+ expect(aiErrorKind(withStatus(status as number))).toBe(kind)
29
+ })
30
+
31
+ it('reads a timeout of the request', () => {
32
+ expect(aiErrorKind(new DOMException('The operation timed out.', 'TimeoutError'))).toBe('timeout')
33
+ })
34
+
35
+ it('keeps the message of the service as the detail, not the generic status text', () => {
36
+ expect(aiErrorDetail(withStatus(401, 'missing or wrong API key'))).toBe('missing or wrong API key')
37
+ expect(aiErrorDetail(withStatus(503, 'Request failed with status 503'))).toBe('')
38
+ })
39
+
40
+ it('shows any other error of a custom provider as it is', () => {
41
+ expect(aiErrorKind(new Error('Quota of the project is used up'))).toBe('other')
42
+ expect(aiErrorDetail(new Error('Quota of the project is used up'))).toBe('Quota of the project is used up')
43
+ expect(aiErrorKind('weird')).toBe('other')
44
+ })
45
+ })
@@ -0,0 +1,67 @@
1
+ // What went wrong with an AI request, to explain it in the dialog (`ai.errors.<kind>`)
2
+ export type AiErrorKind = 'network' | 'auth' | 'not_found' | 'timeout' | 'rate_limit' | 'unavailable' | 'request' | 'other'
3
+
4
+ // The messages browsers give a fetch that never got a response: the service is down, unreachable, or CORS
5
+ // blocked the answer (the browser hides which; its console tells)
6
+ const FAILED_FETCH = /^(failed to fetch|networkerror when attempting to fetch resource\.?|load failed|fetch failed)$/i
7
+
8
+ // The text backend.ts gives a failed response without a message of its own
9
+ const STATUS_ONLY = /^Request failed with status \d+$/
10
+
11
+ function statusOf(error: unknown): number | null {
12
+ const status = (error as { status?: unknown } | null)?.status
13
+
14
+ return typeof status === 'number' ? status : null
15
+ }
16
+
17
+ /**
18
+ * The kind of a failed AI request: a failed fetch, the HTTP status of the response (backend.ts sets
19
+ * `status` on its errors; a custom `aiProvider` may throw an error with `status` too), or a timeout.
20
+ */
21
+ export function aiErrorKind(error: unknown): AiErrorKind {
22
+ // A DOMException (AbortSignal.timeout) is not an Error everywhere
23
+ if ((error as { name?: unknown } | null)?.name === 'TimeoutError') {
24
+ return 'timeout'
25
+ }
26
+
27
+ if (error instanceof TypeError && FAILED_FETCH.test(error.message.trim())) {
28
+ return 'network'
29
+ }
30
+
31
+ const status = statusOf(error)
32
+ if (status === null) {
33
+ return 'other'
34
+ }
35
+
36
+ if (status === 401 || status === 403) {
37
+ return 'auth'
38
+ }
39
+
40
+ if (status === 404) {
41
+ return 'not_found'
42
+ }
43
+
44
+ if (status === 408 || status === 504) {
45
+ return 'timeout'
46
+ }
47
+
48
+ if (status === 429) {
49
+ return 'rate_limit'
50
+ }
51
+
52
+ return status >= 500 ? 'unavailable' : 'request'
53
+ }
54
+
55
+ /**
56
+ * The message of the service (or of a custom provider) shown after the explanation; empty when it only
57
+ * repeats the status or the browser's network message.
58
+ */
59
+ export function aiErrorDetail(error: unknown): string {
60
+ if (!(error instanceof Error) || aiErrorKind(error) === 'network') {
61
+ return ''
62
+ }
63
+
64
+ const message = error.message.trim()
65
+
66
+ return STATUS_ONLY.test(message) ? '' : message
67
+ }