tiptapify 0.2.4 → 0.2.5

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 (63) hide show
  1. package/README.md +1 -1
  2. package/dist/tiptapify.css +1 -1
  3. package/dist/tiptapify.mjs +84644 -65141
  4. package/dist/tiptapify.umd.js +86 -84
  5. package/index.d.ts +23 -2
  6. package/package.json +45 -45
  7. package/src/components/Tiptapify.vue +83 -1
  8. package/src/components/Toolbar/Index.vue +2 -2
  9. package/src/components/Toolbar/Items.vue +9 -2
  10. package/src/components/UI/TiptapifyDialog.vue +7 -1
  11. package/src/extensions/components/ai/Button.vue +1 -1
  12. package/src/extensions/components/ai/Dialog.vue +337 -27
  13. package/src/extensions/components/ai/charLimit.test.ts +55 -0
  14. package/src/extensions/components/ai/charLimit.ts +30 -0
  15. package/src/extensions/components/ai/characterCount.test.ts +27 -0
  16. package/src/extensions/components/media/iframe/Button.vue +1 -2
  17. package/src/extensions/components/media/iframe/IframeDialog.vue +36 -31
  18. package/src/extensions/components/media/image/Button.vue +1 -1
  19. package/src/extensions/components/media/link/Button.vue +1 -1
  20. package/src/extensions/components/media/link/LinkDialog.vue +19 -36
  21. package/src/extensions/components/media/video/Button.vue +1 -1
  22. package/src/extensions/components/media/video/VideoDialog.vue +17 -17
  23. package/src/extensions/components/misc/source/Button.vue +1 -1
  24. package/src/i18n/locales/ar.json +165 -159
  25. package/src/i18n/locales/bg.json +212 -0
  26. package/src/i18n/locales/ch.json +165 -159
  27. package/src/i18n/locales/cz.json +165 -159
  28. package/src/i18n/locales/de.json +165 -159
  29. package/src/i18n/locales/el.json +212 -0
  30. package/src/i18n/locales/en.json +166 -159
  31. package/src/i18n/locales/es.json +165 -159
  32. package/src/i18n/locales/et.json +212 -0
  33. package/src/i18n/locales/fi.json +165 -159
  34. package/src/i18n/locales/fr.json +165 -159
  35. package/src/i18n/locales/he.json +212 -0
  36. package/src/i18n/locales/hi.json +212 -0
  37. package/src/i18n/locales/hr.json +212 -0
  38. package/src/i18n/locales/hu.json +165 -159
  39. package/src/i18n/locales/id.json +212 -0
  40. package/src/i18n/locales/it.json +165 -159
  41. package/src/i18n/locales/ja.json +165 -159
  42. package/src/i18n/locales/ko.json +165 -159
  43. package/src/i18n/locales/la.json +165 -159
  44. package/src/i18n/locales/lt.json +165 -159
  45. package/src/i18n/locales/nl.json +165 -159
  46. package/src/i18n/locales/no.json +212 -0
  47. package/src/i18n/locales/pl.json +165 -159
  48. package/src/i18n/locales/pt-BR.json +212 -0
  49. package/src/i18n/locales/pt.json +165 -159
  50. package/src/i18n/locales/ro.json +212 -0
  51. package/src/i18n/locales/ru.json +165 -159
  52. package/src/i18n/locales/se.json +165 -159
  53. package/src/i18n/locales/sk.json +212 -0
  54. package/src/i18n/locales/sl.json +212 -0
  55. package/src/i18n/locales/sr.json +212 -0
  56. package/src/i18n/locales/th.json +165 -159
  57. package/src/i18n/locales/tr.json +165 -159
  58. package/src/i18n/locales/uk.json +165 -159
  59. package/src/i18n/locales/vi.json +165 -159
  60. package/src/index.ts +3 -0
  61. package/src/types/editor.ts +23 -2
  62. package/src/utils/validateHref.test.ts +68 -0
  63. package/src/utils/validateHref.ts +21 -0
@@ -6,15 +6,19 @@ import {
6
6
  TiptapifyAiEditorContext,
7
7
  TiptapifyAiMode,
8
8
  TiptapifyAiOpenAiResponse,
9
+ TiptapifyAiReasoningEffort,
9
10
  TiptapifyAiRequest,
10
11
  TiptapifyAiResponse,
11
12
  TiptapifyAiResolvedOptions,
13
+ TiptapifyAiStream,
12
14
  TiptapifyEditor,
13
15
  variantBtnTypes,
14
16
  variantFieldTypes
15
17
  } from '@tiptapify/types/editor'
16
- import { computed, ComputedRef, inject, PropType, Ref, ref, useTemplateRef } from 'vue'
18
+ import { computed, ComputedRef, inject, nextTick, PropType, Ref, ref, useTemplateRef, watch } from 'vue'
19
+ import * as mdi from '@mdi/js'
17
20
  import { ComposerTranslation } from 'vue-i18n'
21
+ import { exceedsLimit } from './charLimit'
18
22
 
19
23
  defineProps({
20
24
  variantBtn: { type: String as PropType<variantBtnTypes>, default() { return defaults.variantBtn } },
@@ -28,31 +32,128 @@ const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
28
32
  const dialog = useTemplateRef('dialog')
29
33
  const prompt = ref('')
30
34
  const result = ref('')
35
+ const reasoning = ref('')
36
+ const reasoningPanel = ref<number[]>([])
37
+ const reasoningTextEl = ref<HTMLElement | null>(null)
31
38
  const error = ref('')
32
39
  const loading = ref(false)
40
+ const requestController = ref<AbortController | null>(null)
33
41
  const requestRange = ref<{ from: number, to: number } | null>(null)
34
42
  const requestMode = ref<TiptapifyAiMode>('insert')
43
+ const thinkingEnabled = ref(false)
44
+ const reasoningEffort = ref<'default' | TiptapifyAiReasoningEffort | null>(null)
45
+
46
+ watch(loading, (value) => {
47
+ if (value) {
48
+ reasoningPanel.value = [0]
49
+ }
50
+ })
51
+
52
+ watch(reasoning, () => {
53
+ nextTick(() => {
54
+ const element = reasoningTextEl.value
55
+ if (element) {
56
+ element.scrollTop = element.scrollHeight
57
+ }
58
+ })
59
+ })
35
60
 
36
61
  const aiProvider = computed(() => ai?.value ? ai.value.aiProvider : undefined)
37
62
  const promptExamples = computed(() => ai?.value ? ai.value.promptExamples : [])
63
+ const reasoningEffortOptions = computed<TiptapifyAiReasoningEffort[]>(() => {
64
+ const configured = ai?.value?.reasoningEffort
65
+ if (!configured || !Array.isArray(configured.options)) {
66
+ return []
67
+ }
68
+
69
+ return configured.options.filter(option => option !== 'default')
70
+ })
71
+ const reasoningEffortItems = computed<Array<'default' | TiptapifyAiReasoningEffort>>(() => {
72
+ return ['default', ...reasoningEffortOptions.value]
73
+ })
74
+ const reasoningEffortSupported = computed(() => reasoningEffortOptions.value.length > 0)
75
+ const thinkingSupported = computed(() => ai?.value?.thinking === true)
76
+ const showReasoning = computed(() => ai?.value?.showReasoning === true)
77
+ const showReasoningEffort = computed(() => thinkingSupported.value && reasoningEffortSupported.value && thinkingEnabled.value)
78
+ const useReasoningEffort = computed(() => {
79
+ return thinkingSupported.value
80
+ && thinkingEnabled.value
81
+ && reasoningEffortSupported.value
82
+ && reasoningEffort.value !== null
83
+ && reasoningEffort.value !== 'default'
84
+ })
38
85
  const isGenerateDisabled = computed(() => !aiProvider.value || !prompt.value.trim() || loading.value)
39
- const isApplyDisabled = computed(() => !result.value.trim() || loading.value)
86
+ const characterCountLimit = computed(() => {
87
+ const limit = getCharacterCountExtension()?.options?.limit
88
+ return typeof limit === 'number' ? limit : null
89
+ })
90
+ const limitExceeded = computed(() => {
91
+ const limit = characterCountLimit.value
92
+ if (limit === null || !result.value) {
93
+ return false
94
+ }
95
+
96
+ return exceedsLimit(result.value, {
97
+ doc: editor.value.state.doc,
98
+ limit,
99
+ mode: requestMode.value,
100
+ range: requestRange.value,
101
+ })
102
+ })
103
+ // A result that exceeds the character limit cannot be inserted (the
104
+ // CharacterCount extension rejects it), so the apply action (and its
105
+ // alternative modes) is disabled until the result is shortened.
106
+ const isApplyDisabled = computed(() => !result.value.trim() || loading.value || limitExceeded.value)
107
+ const actionLabel = computed(() => t(`ai.${requestMode.value}`))
108
+ const modeOptions: TiptapifyAiMode[] = ['insert', 'replace', 'append']
109
+ const alternativeModes = computed(() => modeOptions.filter(mode => mode !== requestMode.value))
110
+
111
+ function applyInMode(mode: TiptapifyAiMode) {
112
+ if (isApplyDisabled.value) {
113
+ return
114
+ }
115
+
116
+ requestMode.value = mode
117
+ apply()
118
+ }
40
119
 
41
120
  defineExpose({ showDialog })
42
121
 
122
+ function resetReasoningEffort() {
123
+ const configured = ai?.value?.reasoningEffort
124
+ const options = reasoningEffortOptions.value
125
+
126
+ if (options.length === 0) {
127
+ reasoningEffort.value = null
128
+ return
129
+ }
130
+
131
+ reasoningEffort.value = configured?.default && options.includes(configured.default)
132
+ ? configured.default
133
+ : 'default'
134
+ }
135
+
43
136
  function showDialog() {
44
137
  prompt.value = ai?.value && ai.value.defaultPrompt ? ai.value.defaultPrompt : ''
45
138
  result.value = ''
139
+ reasoning.value = ''
46
140
  error.value = ''
47
141
  requestRange.value = null
48
- requestMode.value = 'insert'
142
+ const { from, to } = editor.value.state.selection
143
+ requestMode.value = ai.value?.mode ?? (getSelectedText(from, to) ? 'replace' : 'insert')
144
+ thinkingEnabled.value = true
145
+ resetReasoningEffort()
49
146
 
50
147
  dialog.value?.open()
51
148
  }
52
149
 
53
150
  function close() {
151
+ requestController.value?.abort()
152
+ requestController.value = null
153
+
54
154
  prompt.value = ''
55
155
  result.value = ''
156
+ reasoning.value = ''
56
157
  error.value = ''
57
158
  requestRange.value = null
58
159
  requestMode.value = 'insert'
@@ -60,6 +161,10 @@ function close() {
60
161
  dialog.value?.close()
61
162
  }
62
163
 
164
+ function stop() {
165
+ requestController.value?.abort()
166
+ }
167
+
63
168
  function applyExample(examplePrompt: string) {
64
169
  prompt.value = examplePrompt
65
170
  }
@@ -75,7 +180,7 @@ function getSelectedText(from: number, to: number) {
75
180
  function buildDefaultMessages(context: TiptapifyAiEditorContext) {
76
181
  const systemPrompt = ai.value && ai.value.systemPrompt
77
182
  ? ai.value.systemPrompt
78
- : 'You are an assistant inside a rich text editor. Return only the final text to insert.'
183
+ : 'You are an AI writing assistant inside a rich text editor. Return only the final text to insert.'
79
184
  const contextText = context.selectedText || context.text
80
185
  const contextLabel = context.selectedText ? 'Selected text' : 'Editor text'
81
186
 
@@ -95,10 +200,12 @@ function buildDefaultMessages(context: TiptapifyAiEditorContext) {
95
200
 
96
201
  function buildOpenAiRequest(context: TiptapifyAiEditorContext): TiptapifyAiRequest {
97
202
  const options = ai.value || {}
203
+ const chatCompletionOptions = options.chatCompletionOptions ?? {}
204
+ const defaultStream = typeof chatCompletionOptions.stream === 'boolean' ? chatCompletionOptions.stream : false
98
205
  const request: TiptapifyAiRequest = {
99
- ...options.chatCompletionOptions,
206
+ ...chatCompletionOptions,
100
207
  messages: options.createMessages ? options.createMessages(context) : buildDefaultMessages(context),
101
- stream: false,
208
+ stream: options.stream ?? defaultStream,
102
209
  }
103
210
 
104
211
  if (options.model) {
@@ -109,6 +216,15 @@ function buildOpenAiRequest(context: TiptapifyAiEditorContext): TiptapifyAiReque
109
216
  request.temperature = options.temperature
110
217
  }
111
218
 
219
+ if (useReasoningEffort.value) {
220
+ delete request.enable_thinking
221
+ request.reasoning_effort = reasoningEffort.value
222
+ } else {
223
+ delete request.reasoning_effort
224
+
225
+ request.enable_thinking = thinkingSupported.value && thinkingEnabled.value
226
+ }
227
+
112
228
  return request
113
229
  }
114
230
 
@@ -141,12 +257,15 @@ async function generate() {
141
257
 
142
258
  error.value = ''
143
259
  result.value = ''
260
+ reasoning.value = ''
144
261
  loading.value = true
145
262
 
263
+ const controller = new AbortController()
264
+ requestController.value = controller
265
+
146
266
  const { from, to } = editor.value.state.selection
147
267
  const selectedText = getSelectedText(from, to)
148
268
  requestRange.value = selectedText ? { from, to } : null
149
- requestMode.value = ai.value ? ai.value.mode ?? (selectedText ? 'replace' : 'insert') : 'insert'
150
269
 
151
270
  const context: TiptapifyAiEditorContext = {
152
271
  prompt: prompt.value.trim(),
@@ -158,27 +277,51 @@ async function generate() {
158
277
  }
159
278
  const request = buildOpenAiRequest(context)
160
279
 
280
+ const stream: TiptapifyAiStream = {
281
+ signal: controller.signal,
282
+ onChunk: (chunk: string) => {
283
+ if (chunk && !controller.signal.aborted) {
284
+ result.value += chunk
285
+ }
286
+ },
287
+ onReasoning: (chunk: string) => {
288
+ if (chunk && !controller.signal.aborted) {
289
+ reasoning.value += chunk
290
+ }
291
+ },
292
+ }
293
+
161
294
  try {
162
- const response = await aiProvider.value(request, context)
163
- result.value = getResponseContent(response)
295
+ const response = await aiProvider.value(request, context, stream)
296
+ const content = getResponseContent(response)
297
+
298
+ if (!controller.signal.aborted && content) {
299
+ result.value = content
300
+ }
164
301
  } catch (err) {
165
- error.value = err instanceof Error ? err.message : t('ai.error')
302
+ if (!controller.signal.aborted) {
303
+ error.value = err instanceof Error ? err.message : t('ai.error')
304
+ }
166
305
  } finally {
167
306
  loading.value = false
168
307
  }
169
308
  }
170
309
 
310
+ function getCharacterCountExtension() {
311
+ return editor.value.options.extensions?.find(item => item.name === 'characterCount')
312
+ }
313
+
171
314
  function apply() {
172
315
  if (isApplyDisabled.value) {
173
316
  return
174
317
  }
175
318
 
176
319
  const chain = editor.value.chain().focus()
177
- if (requestRange.value) {
320
+ if (requestMode.value === 'replace' && requestRange.value) {
178
321
  chain.deleteRange(requestRange.value)
179
322
  }
180
323
 
181
- if (!requestRange.value && requestMode.value === 'append') {
324
+ if (requestMode.value === 'append') {
182
325
  chain.insertContentAt(editor.value.state.doc.content.size, result.value).run()
183
326
  } else {
184
327
  chain.insertContent(result.value).run()
@@ -197,19 +340,9 @@ function apply() {
197
340
  {{ t('ai.unavailable') }}
198
341
  </VAlert>
199
342
 
200
- <VTextarea
201
- v-model="prompt"
202
- :label="t('ai.prompt')"
203
- :variant="variantField"
204
- :disabled="loading || !aiProvider"
205
- rows="4"
206
- auto-grow
207
- autofocus
208
- />
209
-
210
343
  <div v-if="promptExamples.length" class="mb-4">
211
344
  <VLabel class="mb-2 d-block">
212
- {{ t('ai.examples') }}
345
+ {{ t('ai.quick_prompts') }}
213
346
  </VLabel>
214
347
  <div class="d-flex flex-wrap ga-2">
215
348
  <VChip
@@ -224,16 +357,83 @@ function apply() {
224
357
  </div>
225
358
  </div>
226
359
 
360
+ <VTextarea
361
+ v-model="prompt"
362
+ :label="t('ai.prompt')"
363
+ :variant="variantField"
364
+ :disabled="loading || !aiProvider"
365
+ rows="4"
366
+ auto-grow
367
+ autofocus
368
+ />
369
+
370
+ <div v-if="thinkingSupported" class="ai-thinking-toggle d-flex align-center mb-4">
371
+ <VIcon size="x-small" class="mr-2" :icon="`mdiSvg:${mdi.mdiBrain}`" />
372
+ <VLabel>{{ t('ai.thinking') }}</VLabel>
373
+ <VSwitch
374
+ v-model="thinkingEnabled"
375
+ :disabled="loading || useReasoningEffort"
376
+ density="compact"
377
+ hide-details
378
+ color="primary"
379
+ class="ai-thinking-toggle__switch"
380
+ />
381
+ </div>
382
+
383
+ <div v-if="showReasoningEffort" class="ai-reasoning-effort d-flex align-center mb-4">
384
+ <VIcon size="x-small" class="mr-2" :icon="`mdiSvg:${mdi.mdiGauge}`" />
385
+ <VLabel>{{ t('ai.reasoning_effort') }}</VLabel>
386
+ <VSelect
387
+ v-model="reasoningEffort"
388
+ :items="reasoningEffortItems"
389
+ :disabled="loading"
390
+ :variant="variantField"
391
+ density="compact"
392
+ hide-details
393
+ class="ai-reasoning-effort__select"
394
+ />
395
+ </div>
396
+
397
+ <VExpansionPanels v-if="showReasoning && reasoning" v-model="reasoningPanel" variant="default" class="ai-reasoning mb-4">
398
+ <VExpansionPanel :value="0">
399
+ <template #title>
400
+ <VIcon size="x-small" :icon="`mdiSvg:${mdi.mdiBrain}`" />
401
+ <span class="ml-2">{{ t('ai.thinking') }}</span>
402
+ </template>
403
+ <template #text>
404
+ <pre ref="reasoningTextEl" class="ai-reasoning__text">{{ reasoning }}</pre>
405
+ </template>
406
+ </VExpansionPanel>
407
+ </VExpansionPanels>
408
+
227
409
  <VTextarea
228
410
  v-model="result"
229
411
  :label="t('ai.result')"
230
412
  :variant="variantField"
231
- :loading="loading"
232
- :disabled="loading"
413
+ :loading="loading && !result"
414
+ :readonly="loading"
233
415
  rows="5"
234
416
  auto-grow
235
417
  />
236
418
 
419
+ <VLabel
420
+ v-if="characterCountLimit !== null && result"
421
+ class="ai-char-count d-block"
422
+ :class="{ 'ai-char-count--exceeded': limitExceeded }"
423
+ >
424
+ {{ result.length }} / {{ characterCountLimit }}
425
+ </VLabel>
426
+
427
+ <VAlert
428
+ v-if="limitExceeded"
429
+ type="error"
430
+ variant="tonal"
431
+ density="compact"
432
+ class="ai-limit-alert mt-4"
433
+ >
434
+ {{ t('ai.limit_exceeded') }}
435
+ </VAlert>
436
+
237
437
  <VAlert v-if="loading" type="info" variant="tonal" density="compact" class="mt-4">
238
438
  {{ t('ai.loading') }}
239
439
  </VAlert>
@@ -253,10 +453,120 @@ function apply() {
253
453
  <VBtn color="primary" :variant="variantBtn" :disabled="isGenerateDisabled" :loading="loading" @click="generate">
254
454
  {{ t('ai.generate') }}
255
455
  </VBtn>
256
- <VBtn color="primary" :variant="variantBtn" :disabled="isApplyDisabled" @click="apply">
257
- {{ t('ai.apply') }}
456
+ <VBtn v-if="loading" color="warning" :variant="variantBtn" @click="stop">
457
+ {{ t('ai.stop') }}
258
458
  </VBtn>
459
+
460
+ <VBtnGroup
461
+ :class="{ 'tiptapify-btn-group--elevated': variantBtn === 'elevated' && !isApplyDisabled }"
462
+ density="compact"
463
+ :variant="variantBtn"
464
+ divided
465
+ >
466
+ <VBtn
467
+ color="primary"
468
+ :variant="variantBtn"
469
+ :disabled="isApplyDisabled"
470
+ @click="apply"
471
+ >
472
+ {{ t('ai.insert') }}
473
+ </VBtn>
474
+
475
+ <VMenu>
476
+ <template #activator="{ props }">
477
+ <VBtn
478
+ v-bind="{ ...props, minWidth: 30 }"
479
+ color="primary"
480
+ :disabled="isApplyDisabled"
481
+ :variant="variantBtn"
482
+ >
483
+ <VIcon :icon="`mdiSvg:${mdi.mdiDotsVertical}`" />
484
+ </VBtn>
485
+ </template>
486
+ <VList>
487
+ <VListItem
488
+ v-for="mode in alternativeModes"
489
+ :key="mode"
490
+ @click="applyInMode(mode)"
491
+ >
492
+ <VListItemTitle>{{ t(`ai.${mode}`) }}</VListItemTitle>
493
+ </VListItem>
494
+ </VList>
495
+ </VMenu>
496
+ </VBtnGroup>
259
497
  </VCardActions>
260
498
  </template>
261
499
  </TiptapifyDialog>
262
500
  </template>
501
+
502
+ <style lang="scss" scoped>
503
+ .tiptapify-btn-group--elevated {
504
+ box-shadow:
505
+ 0 3px 1px -2px var(--v-shadow-key-umbra-opacity, rgba(0, 0, 0, 0.2)),
506
+ 0 2px 2px 0 var(--v-shadow-key-penumbra-opacity, rgba(0, 0, 0, 0.14)),
507
+ 0 1px 5px 0 var(--v-shadow-key-ambient-opacity, rgba(0, 0, 0, 0.12));
508
+ }
509
+
510
+ .ai-thinking-toggle {
511
+ &__switch {
512
+ flex: 0 0 auto;
513
+ margin-left: auto;
514
+
515
+ --v-switch-scale: 1;
516
+ --v-switch-track-height: 14px;
517
+ --v-switch-thumb-height: 20px;
518
+ --v-switch-thumb-width: 20px;
519
+ }
520
+ }
521
+
522
+ .ai-reasoning-effort {
523
+ &__select {
524
+ flex: 0 0 auto;
525
+ margin-left: auto;
526
+
527
+ :deep(.v-input) {
528
+ width: 140px;
529
+ }
530
+ }
531
+ }
532
+
533
+ .ai-reasoning {
534
+ :deep(.v-expansion-panel-title) {
535
+ color: var(--gray-5);
536
+ font-size: .8125rem;
537
+ font-weight: 500;
538
+ padding: .625rem .75rem;
539
+ }
540
+
541
+ :deep(.v-expansion-panel-title__icon) {
542
+ color: var(--gray-4);
543
+ }
544
+ }
545
+
546
+ .ai-reasoning__text {
547
+ color: var(--gray-5);
548
+ font-family: 'JetBrainsMono', monospace;
549
+ font-size: .75rem;
550
+ line-height: 1.4;
551
+ margin: 0;
552
+ max-height: 200px;
553
+ overflow: auto;
554
+ padding: .25rem .75rem .625rem;
555
+ white-space: pre-wrap;
556
+ word-break: break-word;
557
+ }
558
+
559
+ .ai-char-count {
560
+ font-size: .75rem;
561
+ margin-top: .25rem;
562
+ text-align: right;
563
+
564
+ &--exceeded {
565
+ color: var(--red-soft);
566
+ }
567
+ }
568
+
569
+ .ai-limit-alert {
570
+ color: var(--red-soft, #B24A40) !important;
571
+ }
572
+ </style>
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { Document } from '@tiptap/extension-document'
3
+ import { Editor } from '@tiptap/vue-3'
4
+ import { Paragraph } from '@tiptap/extension-paragraph'
5
+ import { Text } from '@tiptap/extension-text'
6
+
7
+ import { exceedsLimit } from './charLimit'
8
+
9
+ function createDoc(content: string) {
10
+ const editor = new Editor({
11
+ content,
12
+ extensions: [Document, Paragraph, Text],
13
+ })
14
+
15
+ return { doc: editor.state.doc, destroy: () => editor.destroy() }
16
+ }
17
+
18
+ describe('exceedsLimit', () => {
19
+ it('is false when the text fits the budget', () => {
20
+ const { doc, destroy } = createDoc('<p>hello</p>') // 5 characters, budget 5
21
+
22
+ expect(exceedsLimit('abcd', { doc, limit: 10, mode: 'insert' })).toBe(false)
23
+
24
+ destroy()
25
+ })
26
+
27
+ it('is true when the text would exceed the limit', () => {
28
+ const { doc, destroy } = createDoc('<p>hello</p>') // 5 characters, budget 5
29
+
30
+ expect(exceedsLimit('abcdefgh', { doc, limit: 10, mode: 'insert' })).toBe(true)
31
+ expect(exceedsLimit('abcdefgh', { doc, limit: 10, mode: 'append' })).toBe(true)
32
+
33
+ destroy()
34
+ })
35
+
36
+ it('accounts for the freed range in replace mode', () => {
37
+ const { doc, destroy } = createDoc('<p>hello</p>') // 5 characters
38
+
39
+ // Replacing "hello" (1..6) frees 5 characters → budget 10, "1234567890" fits
40
+ expect(exceedsLimit('1234567890', { doc, limit: 10, mode: 'replace', range: { from: 1, to: 6 } })).toBe(false)
41
+
42
+ // Without the range the same text would exceed the limit
43
+ expect(exceedsLimit('1234567890', { doc, limit: 10, mode: 'insert' })).toBe(true)
44
+
45
+ destroy()
46
+ })
47
+
48
+ it('is true when the document is already at the limit', () => {
49
+ const { doc, destroy } = createDoc('<p>1234567890</p>') // 10 characters, budget 0
50
+
51
+ expect(exceedsLimit('x', { doc, limit: 10, mode: 'insert' })).toBe(true)
52
+
53
+ destroy()
54
+ })
55
+ })
@@ -0,0 +1,30 @@
1
+ import type { Node } from '@tiptap/pm/model'
2
+ import type { TiptapifyAiMode } from '@tiptapify/types/editor'
3
+
4
+ export type CharLimitOptions = {
5
+ doc: Node
6
+ limit: number
7
+ mode: TiptapifyAiMode
8
+ range?: { from: number, to: number } | null
9
+ }
10
+
11
+ /**
12
+ * Whether inserting `text` would push the document past `limit` characters,
13
+ * counted the same way as the CharacterCount extension (textSize mode, default
14
+ * counter: the length of the document text with a single space per block boundary).
15
+ *
16
+ * In replace mode the characters of the selected `range` are freed by the
17
+ * removal that precedes the insert, so they are added back to the budget.
18
+ */
19
+ export function exceedsLimit(text: string, options: CharLimitOptions): boolean {
20
+ const { doc, limit, mode, range } = options
21
+ const currentChars = doc.textBetween(0, doc.content.size, undefined, ' ').length
22
+
23
+ let removedChars = 0
24
+ if (mode === 'replace' && range) {
25
+ removedChars = doc.textBetween(range.from, range.to, undefined, ' ').length
26
+ }
27
+
28
+ const budget = limit - currentChars + removedChars
29
+ return text.length > budget
30
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { Editor } from '@tiptap/vue-3'
3
+ import { Document } from '@tiptap/extension-document'
4
+ import { Paragraph } from '@tiptap/extension-paragraph'
5
+ import { Text } from '@tiptap/extension-text'
6
+ import { CharacterCount } from '@tiptap/extensions'
7
+
8
+ function createEditor() {
9
+ return new Editor({
10
+ content: '<p>hello</p>',
11
+ extensions: [Document, Paragraph, Text, CharacterCount.configure({ limit: 100 })],
12
+ })
13
+ }
14
+
15
+ describe('character count limit', () => {
16
+ it('rejects an insert that would exceed the character limit', () => {
17
+ const editor = createEditor()
18
+ const before = JSON.stringify(editor.getJSON())
19
+
20
+ editor.chain().focus().insertContent('a'.repeat(200)).run()
21
+
22
+ // The CharacterCount filterTransaction rejects the transaction, so the doc is unchanged
23
+ expect(JSON.stringify(editor.getJSON())).toBe(before)
24
+
25
+ editor.destroy()
26
+ })
27
+ })
@@ -35,8 +35,7 @@ const icon = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" vie
35
35
  <BtnIcon :icon="icon" />
36
36
  </VBtn>
37
37
 
38
- <IframeDialog ref="dialog" />
39
- <!-- Dialog here -->
38
+ <IframeDialog ref="dialog" :variant-btn="variantBtn" />
40
39
  </template>
41
40
 
42
41
  <style lang="scss" scoped>