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
|
@@ -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
|
-
|
|
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
|
+
}
|