tiptapify 0.3.2 → 0.3.3

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 (68) hide show
  1. package/dist/tiptapify.css +1 -1
  2. package/dist/tiptapify.mjs +54029 -48016
  3. package/dist/tiptapify.umd.js +59 -59
  4. package/index.d.ts +1 -0
  5. package/package.json +1 -1
  6. package/src/components/Footer.vue +12 -2
  7. package/src/components/ImageBubbleMenu.vue +130 -0
  8. package/src/components/MenuBubble.vue +36 -21
  9. package/src/components/Tiptapify.vue +189 -72
  10. package/src/components/Toolbar/Index.test.ts +96 -0
  11. package/src/components/Toolbar/Index.vue +218 -14
  12. package/src/components/Toolbar/Items.test.ts +24 -0
  13. package/src/components/Toolbar/Items.vue +25 -26
  14. package/src/components/Toolbar/alignment.ts +5 -0
  15. package/src/components/UI/TiptapifyDialog.vue +78 -12
  16. package/src/components/editorExtensions.test.ts +10 -0
  17. package/src/components/editorExtensions.ts +34 -5
  18. package/src/components/index.ts +3 -2
  19. package/src/extensions/components/actions/redo/Button.vue +1 -1
  20. package/src/extensions/components/actions/undo/Button.vue +1 -1
  21. package/src/extensions/components/alignment/menu/Button.vue +68 -0
  22. package/src/extensions/components/list/bullet/Button.vue +16 -1
  23. package/src/extensions/components/list/numbered/Button.vue +19 -0
  24. package/src/extensions/components/list/styles/ListStyleButton.vue +156 -0
  25. package/src/extensions/components/list/styles/index.ts +76 -0
  26. package/src/extensions/components/list/styles/listStyle.test.ts +44 -0
  27. package/src/extensions/components/media/image/ImageDialog.vue +1 -1
  28. package/src/extensions/components/media/image/caption.test.ts +122 -0
  29. package/src/extensions/components/media/image/caption.ts +268 -0
  30. package/src/extensions/components/media/image/slider.test.ts +106 -0
  31. package/src/extensions/components/media/image/slider.ts +87 -0
  32. package/src/extensions/components/style/heading/Button.vue +22 -11
  33. package/src/i18n/locales/ar.json +22 -0
  34. package/src/i18n/locales/bg.json +22 -0
  35. package/src/i18n/locales/ch.json +22 -0
  36. package/src/i18n/locales/cz.json +22 -0
  37. package/src/i18n/locales/de.json +22 -0
  38. package/src/i18n/locales/el.json +22 -0
  39. package/src/i18n/locales/en.json +23 -1
  40. package/src/i18n/locales/es.json +22 -0
  41. package/src/i18n/locales/et.json +22 -0
  42. package/src/i18n/locales/fi.json +22 -0
  43. package/src/i18n/locales/fr.json +22 -0
  44. package/src/i18n/locales/he.json +22 -0
  45. package/src/i18n/locales/hi.json +22 -0
  46. package/src/i18n/locales/hr.json +22 -0
  47. package/src/i18n/locales/hu.json +22 -0
  48. package/src/i18n/locales/id.json +22 -0
  49. package/src/i18n/locales/it.json +22 -0
  50. package/src/i18n/locales/ja.json +22 -0
  51. package/src/i18n/locales/ko.json +22 -0
  52. package/src/i18n/locales/la.json +22 -0
  53. package/src/i18n/locales/lt.json +22 -0
  54. package/src/i18n/locales/nl.json +22 -0
  55. package/src/i18n/locales/no.json +22 -0
  56. package/src/i18n/locales/pl.json +22 -0
  57. package/src/i18n/locales/pt-BR.json +22 -0
  58. package/src/i18n/locales/pt.json +22 -0
  59. package/src/i18n/locales/ro.json +22 -0
  60. package/src/i18n/locales/ru.json +23 -1
  61. package/src/i18n/locales/se.json +22 -0
  62. package/src/i18n/locales/sk.json +22 -0
  63. package/src/i18n/locales/sl.json +22 -0
  64. package/src/i18n/locales/sr.json +22 -0
  65. package/src/i18n/locales/th.json +22 -0
  66. package/src/i18n/locales/tr.json +22 -0
  67. package/src/i18n/locales/uk.json +23 -1
  68. package/src/i18n/locales/vi.json +22 -0
@@ -17,7 +17,6 @@ import { HardBreak } from '@tiptap/extension-hard-break'
17
17
  import { HorizontalRule } from '@tiptap/extension-horizontal-rule'
18
18
  import { Typography } from '@tiptap/extension-typography'
19
19
  import { Highlight } from '@tiptap/extension-highlight'
20
- import { Image } from '@tiptap/extension-image'
21
20
  import { Youtube } from '@tiptap/extension-youtube'
22
21
  import { Superscript } from '@tiptap/extension-superscript'
23
22
  import { Subscript } from '@tiptap/extension-subscript'
@@ -29,7 +28,10 @@ import { InvisibleCharacters } from '@tiptap/extension-invisible-characters'
29
28
 
30
29
  import { default as Iframe } from '@tiptapify/extensions/components/media/iframe'
31
30
  import { BulletListCircle, BulletListSquare } from '@tiptapify/extensions/components/list/bullet'
31
+ import { ListStyle } from '@tiptapify/extensions/components/list/styles'
32
32
  import { TiptapifyImage } from '@tiptapify/extensions/components/media/image'
33
+ import { ImageWithCaption } from '@tiptapify/extensions/components/media/image/caption'
34
+ import { ImageSlider } from '@tiptapify/extensions/components/media/image/slider'
33
35
  import { TiptapifyLink } from '@tiptapify/extensions/components/media/link'
34
36
  import { TiptapifyVideo } from '@tiptapify/extensions/components/media/video'
35
37
  import CodeBlockComponent from '@tiptapify/extensions/components/CodeBlockComponent.vue'
@@ -52,7 +54,22 @@ const lowlight = createLowlight(common)
52
54
  // register language example
53
55
  // lowlight.register('ts', ts)
54
56
 
55
- export function editorExtensions (placeholder: string, slashCommands: SlashCommandsConfig, customExtensions: toolbarSections, limit: number | null = null) {
57
+ export interface TiptapifyImageOptions {
58
+ captionPlaceholder: string
59
+ // Images in a row from which the site shows a slider; 0 disables the slider grid
60
+ sliderMinImages: number
61
+ sliderLabel: string
62
+ }
63
+
64
+ const defaultImageOptions: TiptapifyImageOptions = { captionPlaceholder: '', sliderMinImages: 0, sliderLabel: '' }
65
+
66
+ export function editorExtensions (
67
+ placeholder: string,
68
+ slashCommands: SlashCommandsConfig,
69
+ customExtensions: toolbarSections,
70
+ limit: number | null = null,
71
+ imageOptions: TiptapifyImageOptions = defaultImageOptions,
72
+ ) {
56
73
  const characterCount = limit && limit > 0 ? CharacterCount.configure({ limit }) : CharacterCount
57
74
 
58
75
  const extensions = [
@@ -65,7 +82,12 @@ export function editorExtensions (placeholder: string, slashCommands: SlashComma
65
82
  Bold,
66
83
  Italic,
67
84
  Strike,
68
- Link,
85
+ // A click on a link in the text selects it for editing instead of opening it
86
+ Link.configure({
87
+ openOnClick: false,
88
+ enableClickSelection: true,
89
+ defaultProtocol: 'https',
90
+ }),
69
91
  Blockquote,
70
92
  OrderedList,
71
93
  BulletList,
@@ -83,11 +105,17 @@ export function editorExtensions (placeholder: string, slashCommands: SlashComma
83
105
  openOnClick: false,
84
106
  defaultProtocol: 'https'
85
107
  }),
86
- Image.configure({
108
+ ImageWithCaption.configure({
87
109
  resize: {
88
110
  enabled: true,
89
111
  alwaysPreserveAspectRatio: true,
112
+ directions: ['top', 'right', 'bottom', 'left', 'top-left', 'top-right', 'bottom-left', 'bottom-right'],
90
113
  },
114
+ captionPlaceholder: imageOptions.captionPlaceholder,
115
+ }),
116
+ ImageSlider.configure({
117
+ minImages: imageOptions.sliderMinImages,
118
+ label: imageOptions.sliderLabel,
91
119
  }),
92
120
  Youtube.configure({
93
121
  controls: true,
@@ -117,7 +145,8 @@ export function editorExtensions (placeholder: string, slashCommands: SlashComma
117
145
  visible: false,
118
146
  }),
119
147
  BulletListCircle,
120
- BulletListSquare
148
+ BulletListSquare,
149
+ ListStyle,
121
150
  ]
122
151
 
123
152
  if (slashCommands !== false) {
@@ -1,5 +1,5 @@
1
1
  import { Editor, useEditor } from '@tiptap/vue-3'
2
- import { editorExtensions } from '@tiptapify/components/editorExtensions'
2
+ import { editorExtensions, TiptapifyImageOptions } from '@tiptapify/components/editorExtensions'
3
3
  import { toolbarSections } from '@tiptapify/types/toolbarTypes'
4
4
  import { SlashCommandsConfig } from '@tiptapify/types/slashCommandsTypes'
5
5
  import { ShallowRef } from 'vue'
@@ -12,8 +12,9 @@ export function getTiptapEditor (
12
12
  limit: number | null = null,
13
13
  onUpdate: Function = () => {},
14
14
  onCreate?: (editor: Editor) => void,
15
+ imageOptions?: TiptapifyImageOptions,
15
16
  ): ShallowRef<Editor | undefined> {
16
- const extensions = editorExtensions(placeholder, slashCommands, customExtensions, limit)
17
+ const extensions = editorExtensions(placeholder, slashCommands, customExtensions, limit, imageOptions)
17
18
 
18
19
  return useEditor({
19
20
  content,
@@ -26,7 +26,7 @@ const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
26
26
  @click="editor.chain().focus().redo().run()"
27
27
  >
28
28
  <Tooltip :label="t('action.redo')" hotkey="Mod-Shift-z" />
29
- <BtnIcon :icon="`mdiSvg:${mdi.mdiRedo}`" />
29
+ <BtnIcon :icon="`mdiSvg:${mdi.mdiRedoVariant}`" />
30
30
  </VBtn>
31
31
  </template>
32
32
 
@@ -26,7 +26,7 @@ const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
26
26
  @click="editor.chain().focus().undo().run()"
27
27
  >
28
28
  <Tooltip :label="t('action.undo')" hotkey="Mod-z" />
29
- <BtnIcon :icon="`mdiSvg:${mdi.mdiUndo}`" />
29
+ <BtnIcon :icon="`mdiSvg:${mdi.mdiUndoVariant}`" />
30
30
  </VBtn>
31
31
  </template>
32
32
 
@@ -0,0 +1,68 @@
1
+ <script lang="ts" setup>
2
+
3
+ import * as mdi from '@mdi/js'
4
+ import { Editor } from '@tiptap/vue-3'
5
+ import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
6
+ import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
7
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
8
+ import { inject, PropType, Ref } from 'vue'
9
+
10
+ import { ComposerTranslation } from 'vue-i18n'
11
+
12
+ type Alignment = 'left' | 'center' | 'right' | 'justify'
13
+
14
+ const props = defineProps({
15
+ alignments: { type: Array as PropType<Alignment[]>, default: () => ['left', 'center', 'right', 'justify'] },
16
+ })
17
+
18
+ const { variantBtn } = useTiptapifyConfig()
19
+
20
+ const editor = inject('tiptapifyEditor') as Ref<Editor>
21
+
22
+ const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
23
+
24
+ const alignmentOptions: Record<Alignment, { icon: string, hotkey: string }> = {
25
+ left: { icon: mdi.mdiFormatAlignLeft, hotkey: 'Mod-Shift-l' },
26
+ center: { icon: mdi.mdiFormatAlignCenter, hotkey: 'Mod-Shift-e' },
27
+ right: { icon: mdi.mdiFormatAlignRight, hotkey: 'Mod-Shift-r' },
28
+ justify: { icon: mdi.mdiFormatAlignJustify, hotkey: 'Mod-Shift-j' },
29
+ }
30
+
31
+ // A function, not a computed: `isActive` reads the editor state, which Vue does not track
32
+ function currentAlignmentIcon(): string {
33
+ const active = props.alignments.find(alignment => editor.value.isActive({ textAlign: alignment }))
34
+
35
+ return alignmentOptions[active ?? props.alignments[0] ?? 'left'].icon
36
+ }
37
+ </script>
38
+
39
+ <template>
40
+ <VBtn
41
+ :id="`tiptapify-align-button-${editor.instanceId}`"
42
+ class="tiptapify-dropdown-button"
43
+ :variant="variantBtn"
44
+ height="32"
45
+ >
46
+ <Tooltip :label="t('alignment')" />
47
+ <BtnIcon :icon="`mdiSvg:${currentAlignmentIcon()}`" />
48
+ <BtnIcon :icon="`mdiSvg:${mdi.mdiChevronDown}`" class="tiptapify-dropdown-button__chevron" />
49
+ </VBtn>
50
+
51
+ <VMenu :activator="`#tiptapify-align-button-${editor.instanceId}`" content-class="tiptapify-menu">
52
+ <VList density="compact">
53
+ <VListItem
54
+ v-for="alignment in alignments"
55
+ :key="alignment"
56
+ link
57
+ :active="editor.isActive({ textAlign: alignment })"
58
+ @click="editor.chain().focus().toggleTextAlign(alignment).run()"
59
+ >
60
+ <Tooltip :label="t(`alignments.${alignment}`)" :hotkey="alignmentOptions[alignment].hotkey" />
61
+ <template #prepend>
62
+ <BtnIcon :icon="`mdiSvg:${alignmentOptions[alignment].icon}`" />
63
+ </template>
64
+ <VListItemTitle>{{ t(`alignments.${alignment}`) }}</VListItemTitle>
65
+ </VListItem>
66
+ </VList>
67
+ </VMenu>
68
+ </template>
@@ -8,8 +8,12 @@ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
8
8
  import { computed, inject, ref, Ref, watch } from 'vue'
9
9
 
10
10
  import { ComposerTranslation } from 'vue-i18n'
11
+ import ListStyleButton from '@tiptapify/extensions/components/list/styles/ListStyleButton.vue'
12
+ import { bulletListStyles } from '@tiptapify/extensions/components/list/styles'
11
13
 
12
14
  const props = defineProps({
15
+ // The marker styles dropdown (`bulletList:styles`) instead of the separate list types below
16
+ withStyles: { type: Boolean, default: false },
13
17
  withDisc: { type: Boolean, default: true },
14
18
  withCircle: { type: Boolean, default: true },
15
19
  withSquare: { type: Boolean, default: true }
@@ -83,7 +87,18 @@ watch(() => bulletLists.value, () => {
83
87
  </script>
84
88
 
85
89
  <template>
86
- <VMenu v-if="isMenu">
90
+ <ListStyleButton
91
+ v-if="withStyles"
92
+ list-type="bulletList"
93
+ :icon="mdi.mdiFormatListBulleted"
94
+ :label="t('lists.bullet')"
95
+ hotkey="Mod-Shift-8"
96
+ :presets="bulletListStyles"
97
+ :disabled="!editor.can().chain().focus().toggleBulletList().run()"
98
+ @toggle="editor.commands.toggleBulletList()"
99
+ />
100
+
101
+ <VMenu v-else-if="isMenu" content-class="tiptapify-menu">
87
102
  <template #activator="{ props: menuProps }">
88
103
  <VBtn
89
104
  :color="buttonActive ? 'primary' : ''"
@@ -8,6 +8,13 @@ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
8
8
  import { computed, inject, Ref } from 'vue'
9
9
 
10
10
  import { ComposerTranslation } from 'vue-i18n'
11
+ import ListStyleButton from '@tiptapify/extensions/components/list/styles/ListStyleButton.vue'
12
+ import { orderedListStyles } from '@tiptapify/extensions/components/list/styles'
13
+
14
+ defineProps({
15
+ // The numbering styles dropdown (`orderedList:styles`)
16
+ withStyles: { type: Boolean, default: false },
17
+ })
11
18
 
12
19
  const { variantBtn } = useTiptapifyConfig()
13
20
 
@@ -25,7 +32,19 @@ const buttonDisabled = computed(() => {
25
32
  </script>
26
33
 
27
34
  <template>
35
+ <ListStyleButton
36
+ v-if="withStyles"
37
+ list-type="orderedList"
38
+ :icon="mdi.mdiFormatListNumbered"
39
+ :label="t('lists.numbered')"
40
+ hotkey="Mod-Shift-7"
41
+ :presets="orderedListStyles"
42
+ :disabled="buttonDisabled"
43
+ @toggle="editor.commands.toggleOrderedList()"
44
+ />
45
+
28
46
  <VBtn
47
+ v-else
29
48
  :color="editor.isActive('orderedList') ? 'primary' : ''"
30
49
  :disabled="buttonDisabled"
31
50
  :variant="variantBtn"
@@ -0,0 +1,156 @@
1
+ <script lang="ts" setup>
2
+
3
+ import * as mdi from '@mdi/js'
4
+ import { Editor } from '@tiptap/vue-3'
5
+ import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
6
+ import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
7
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
8
+ import { ListStylePreset, ListType } from '@tiptapify/extensions/components/list/styles'
9
+ import { inject, PropType, Ref } from 'vue'
10
+ import { ComposerTranslation } from 'vue-i18n'
11
+
12
+ // A list button with a dropdown of marker styles next to it, like TinyMCE's advlist
13
+ const props = defineProps({
14
+ listType: { type: String as PropType<ListType>, required: true },
15
+ icon: { type: String, required: true },
16
+ label: { type: String, required: true },
17
+ hotkey: { type: String, default: '' },
18
+ presets: { type: Array as PropType<ListStylePreset[]>, required: true },
19
+ disabled: { type: Boolean, default: false },
20
+ })
21
+
22
+ const emit = defineEmits(['toggle'])
23
+
24
+ const { variantBtn } = useTiptapifyConfig()
25
+
26
+ const editor = inject('tiptapifyEditor') as Ref<Editor>
27
+
28
+ const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
29
+
30
+ function isPresetActive(preset: ListStylePreset): boolean {
31
+ return editor.value.isActive(props.listType) && (editor.value.getAttributes(props.listType).listStyleType ?? null) === preset.value
32
+ }
33
+
34
+ function presetLabel(preset: ListStylePreset): string {
35
+ return t(`lists.styles.${(preset.value ?? 'default').replace('-', '_')}`)
36
+ }
37
+ </script>
38
+
39
+ <template>
40
+ <div class="tiptapify-list-style-button">
41
+ <VBtn
42
+ :color="editor.isActive(listType) ? 'primary' : ''"
43
+ :disabled="disabled"
44
+ :variant="variantBtn"
45
+ size="32"
46
+ @click="emit('toggle')"
47
+ >
48
+ <Tooltip :label="label" :hotkey="hotkey" />
49
+ <BtnIcon :icon="`mdiSvg:${icon}`" />
50
+ </VBtn>
51
+
52
+ <VMenu content-class="tiptapify-menu">
53
+ <template #activator="{ props: menuProps }">
54
+ <VBtn
55
+ class="tiptapify-list-style-button__chevron"
56
+ :disabled="disabled"
57
+ :variant="variantBtn"
58
+ height="32"
59
+ width="16"
60
+ v-bind="menuProps"
61
+ >
62
+ <BtnIcon :icon="`mdiSvg:${mdi.mdiChevronDown}`" />
63
+ </VBtn>
64
+ </template>
65
+
66
+ <div class="tiptapify-list-style-grid">
67
+ <button
68
+ v-for="preset in presets"
69
+ :key="preset.value ?? 'default'"
70
+ type="button"
71
+ class="tiptapify-list-style-grid__item"
72
+ :class="{ 'tiptapify-list-style-grid__item--active': isPresetActive(preset) }"
73
+ :title="presetLabel(preset)"
74
+ :aria-label="presetLabel(preset)"
75
+ @click="editor.chain().focus().setListStyle(listType, preset.value).run()"
76
+ >
77
+ <span v-for="(marker, index) in preset.markers" :key="index" class="tiptapify-list-style-grid__line">
78
+ <span class="tiptapify-list-style-grid__marker">{{ marker }}</span>
79
+ <span class="tiptapify-list-style-grid__bar" />
80
+ </span>
81
+ </button>
82
+ </div>
83
+ </VMenu>
84
+ </div>
85
+ </template>
86
+
87
+ <style lang="scss" scoped>
88
+ .tiptapify-list-style-button {
89
+ display: flex;
90
+ align-items: center;
91
+
92
+ :deep(.tiptapify-list-style-button__chevron) {
93
+ min-width: 16px !important;
94
+ padding: 0 !important;
95
+
96
+ .v-icon {
97
+ width: 16px;
98
+ height: 16px;
99
+ }
100
+ }
101
+ }
102
+ </style>
103
+
104
+ <style lang="scss">
105
+ /* Teleported with the menu, so not scoped */
106
+ .tiptapify-list-style-grid {
107
+ display: grid;
108
+ grid-template-columns: repeat(3, 64px);
109
+ gap: 6px;
110
+ padding: 6px;
111
+ border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
112
+ border-radius: 8px;
113
+ background: rgb(var(--v-theme-surface));
114
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
115
+ }
116
+
117
+ .tiptapify-list-style-grid__item {
118
+ display: flex;
119
+ flex-direction: column;
120
+ gap: 4px;
121
+ padding: 8px 6px;
122
+ border: 1px solid transparent;
123
+ border-radius: 6px;
124
+ color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
125
+ cursor: pointer;
126
+
127
+ &:hover {
128
+ background: rgba(var(--v-theme-on-surface), 0.05);
129
+ }
130
+
131
+ &--active {
132
+ border-color: rgb(var(--v-theme-primary));
133
+ background: rgba(var(--v-theme-primary), 0.06);
134
+ }
135
+ }
136
+
137
+ .tiptapify-list-style-grid__line {
138
+ display: flex;
139
+ align-items: center;
140
+ gap: 4px;
141
+ }
142
+
143
+ .tiptapify-list-style-grid__marker {
144
+ min-width: 16px;
145
+ font-size: 10px;
146
+ line-height: 10px;
147
+ text-align: right;
148
+ }
149
+
150
+ .tiptapify-list-style-grid__bar {
151
+ flex: 1;
152
+ height: 4px;
153
+ border-radius: 2px;
154
+ background: rgba(var(--v-theme-on-surface), 0.18);
155
+ }
156
+ </style>
@@ -0,0 +1,76 @@
1
+ import { Extension } from '@tiptap/core'
2
+
3
+ export type ListType = 'bulletList' | 'orderedList'
4
+
5
+ export interface ListStylePreset {
6
+ // CSS `list-style-type`; null is the browser default (disc / decimal)
7
+ value: string | null
8
+ markers: [string, string, string]
9
+ }
10
+
11
+ export const bulletListStyles: ListStylePreset[] = [
12
+ { value: null, markers: ['●', '●', '●'] },
13
+ { value: 'circle', markers: ['○', '○', '○'] },
14
+ { value: 'square', markers: ['■', '■', '■'] },
15
+ ]
16
+
17
+ export const orderedListStyles: ListStylePreset[] = [
18
+ { value: null, markers: ['1.', '2.', '3.'] },
19
+ { value: 'lower-alpha', markers: ['a.', 'b.', 'c.'] },
20
+ { value: 'lower-greek', markers: ['α.', 'β.', 'γ.'] },
21
+ { value: 'lower-roman', markers: ['i.', 'ii.', 'iii.'] },
22
+ { value: 'upper-alpha', markers: ['A.', 'B.', 'C.'] },
23
+ { value: 'upper-roman', markers: ['I.', 'II.', 'III.'] },
24
+ ]
25
+
26
+ declare module '@tiptap/core' {
27
+ interface Commands<ReturnType> {
28
+ listStyle: {
29
+ // Turns the selection into a list of this type, if it is not one yet, with this marker style
30
+ setListStyle: (listType: ListType, listStyleType: string | null) => ReturnType
31
+ }
32
+ }
33
+ }
34
+
35
+ /**
36
+ * The marker style of bullet and numbered lists, saved as inline `list-style-type` the way TinyMCE
37
+ * did, so lists styled there keep their style and every site renders it without extra CSS.
38
+ */
39
+ export const ListStyle = Extension.create({
40
+ name: 'listStyle',
41
+
42
+ addGlobalAttributes() {
43
+ return [
44
+ {
45
+ types: ['bulletList', 'orderedList'],
46
+ attributes: {
47
+ listStyleType: {
48
+ default: null,
49
+ parseHTML: (element: HTMLElement) => element.style.listStyleType || null,
50
+ renderHTML: (attributes: Record<string, unknown>) => (
51
+ attributes.listStyleType ? { style: `list-style-type: ${attributes.listStyleType}` } : {}
52
+ ),
53
+ },
54
+ },
55
+ },
56
+ ]
57
+ },
58
+
59
+ addCommands() {
60
+ return {
61
+ setListStyle: (listType: ListType, listStyleType: string | null) => ({ editor, chain }) => {
62
+ const command = chain().focus()
63
+
64
+ if (!editor.isActive(listType)) {
65
+ if (listType === 'bulletList') {
66
+ command.toggleBulletList()
67
+ } else {
68
+ command.toggleOrderedList()
69
+ }
70
+ }
71
+
72
+ return command.updateAttributes(listType, { listStyleType }).run()
73
+ },
74
+ }
75
+ },
76
+ })
@@ -0,0 +1,44 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { Editor } from '@tiptap/vue-3'
3
+ import { editorExtensions } from '@tiptapify/components/editorExtensions'
4
+
5
+ let editor: Editor | null = null
6
+
7
+ function createEditor(content: string) {
8
+ editor = new Editor({ element: document.createElement('div'), content, extensions: editorExtensions('', false, []) })
9
+
10
+ return editor
11
+ }
12
+
13
+ describe('list styles', () => {
14
+ afterEach(() => {
15
+ editor?.destroy()
16
+ editor = null
17
+ })
18
+
19
+ it('turns a paragraph into a numbered list with the chosen numbering', () => {
20
+ createEditor('<p>one</p>')
21
+ editor!.commands.setTextSelection(2)
22
+
23
+ editor!.commands.setListStyle('orderedList', 'lower-roman')
24
+
25
+ expect(editor!.getHTML()).toBe('<ol style="list-style-type: lower-roman;"><li><p>one</p></li></ol>')
26
+ })
27
+
28
+ it('changes the marker of an existing bullet list and resets it to the default', () => {
29
+ createEditor('<ul><li><p>one</p></li></ul>')
30
+ editor!.commands.setTextSelection(3)
31
+
32
+ editor!.commands.setListStyle('bulletList', 'square')
33
+ expect(editor!.getHTML()).toContain('<ul style="list-style-type: square;">')
34
+
35
+ editor!.commands.setListStyle('bulletList', null)
36
+ expect(editor!.getHTML()).toBe('<ul><li><p>one</p></li></ul>')
37
+ })
38
+
39
+ it('reads the list style TinyMCE saved', () => {
40
+ createEditor('<ol style="list-style-type: upper-alpha;"><li><p>one</p></li></ol>')
41
+
42
+ expect(editor!.getJSON().content![0].attrs).toMatchObject({ listStyleType: 'upper-alpha' })
43
+ })
44
+ })
@@ -150,7 +150,7 @@ onUnmounted(() => {
150
150
  <VTextField
151
151
  v-model="attrs.title"
152
152
  density="comfortable"
153
- :label="t('dialog.image.title')"
153
+ :label="t('dialog.image.caption')"
154
154
  />
155
155
  </div>
156
156
  </div>
@@ -0,0 +1,122 @@
1
+ import { afterEach, describe, expect, it, vi } 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 { TiptapifyImage } from '@tiptapify/extensions/components/media/image'
7
+ import { ImageWithCaption } from '@tiptapify/extensions/components/media/image/caption'
8
+
9
+ let editor: Editor | null = null
10
+
11
+ function createEditor(content: string) {
12
+ editor = new Editor({
13
+ element: document.createElement('div'),
14
+ content,
15
+ extensions: [
16
+ Document,
17
+ Paragraph,
18
+ Text,
19
+ TiptapifyImage,
20
+ ImageWithCaption.configure({
21
+ resize: { enabled: true, alwaysPreserveAspectRatio: true },
22
+ captionPlaceholder: 'Add a caption',
23
+ }),
24
+ ],
25
+ })
26
+
27
+ return editor
28
+ }
29
+
30
+ function caption(): HTMLInputElement {
31
+ return editor!.view.dom.querySelector('.tiptapify-image-caption') as HTMLInputElement
32
+ }
33
+
34
+ function typeCaption(value: string) {
35
+ caption().value = value
36
+ caption().dispatchEvent(new Event('input'))
37
+ }
38
+
39
+ describe('image caption', () => {
40
+ afterEach(() => {
41
+ editor?.destroy()
42
+ editor = null
43
+ })
44
+
45
+ it('shows the image title as the caption under the image', () => {
46
+ createEditor('<img src="https://example.com/a.png" title="Kyiv at night">')
47
+
48
+ expect(caption().value).toBe('Kyiv at night')
49
+ expect(caption().placeholder).toBe('Add a caption')
50
+ })
51
+
52
+ it('saves the typed caption as the figure caption', () => {
53
+ createEditor('<img src="https://example.com/a.png">')
54
+
55
+ typeCaption('Kyiv at night')
56
+
57
+ expect(editor!.getHTML()).toBe('<figure><img src="https://example.com/a.png"><figcaption>Kyiv at night</figcaption></figure>')
58
+ })
59
+
60
+ it('saves an image without a caption as a figure without figcaption', () => {
61
+ createEditor('<img src="https://example.com/a.png" title="Kyiv at night">')
62
+
63
+ typeCaption(' ')
64
+
65
+ expect(editor!.getHTML()).toBe('<figure><img src="https://example.com/a.png"></figure>')
66
+ })
67
+
68
+ it('loads a figure with its caption and image attributes', () => {
69
+ createEditor('<figure><img src="https://example.com/a.png" alt="Kyiv" width="640"><figcaption> Kyiv at night </figcaption></figure>')
70
+
71
+ expect(caption().value).toBe('Kyiv at night')
72
+ expect(editor!.getJSON().content![0].attrs).toMatchObject({ src: 'https://example.com/a.png', alt: 'Kyiv', width: 640, title: 'Kyiv at night' })
73
+ })
74
+
75
+ it('keeps consecutive figures as separate images', () => {
76
+ createEditor('<figure><img src="https://example.com/a.png"></figure><figure><img src="https://example.com/b.png"></figure>')
77
+
78
+ expect(editor!.getJSON().content!.map(node => node.type)).toEqual(['image', 'image'])
79
+ })
80
+
81
+ it('updates the caption when the title is changed elsewhere', () => {
82
+ createEditor('<img src="https://example.com/a.png">')
83
+
84
+ editor!.chain().setNodeSelection(0).updateAttributes('image', { title: 'From the dialog' }).run()
85
+
86
+ expect(caption().value).toBe('From the dialog')
87
+ })
88
+
89
+ it('opens the image dialog for this image on double click', () => {
90
+ createEditor('<p>text</p><img src="https://example.com/a.png" alt="Kyiv">')
91
+ const listener = vi.fn()
92
+ window.addEventListener('tiptapify-show-tiptapifyImage', listener)
93
+
94
+ editor!.view.dom.querySelector('[data-resize-wrapper]')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
95
+
96
+ window.removeEventListener('tiptapify-show-tiptapifyImage', listener)
97
+ expect(listener).toHaveBeenCalledOnce()
98
+ expect(listener.mock.calls[0][0].detail.image).toMatchObject({ src: 'https://example.com/a.png', alt: 'Kyiv' })
99
+ })
100
+
101
+ it('moves the cursor to the text after the image on Enter', () => {
102
+ createEditor('<img src="https://example.com/a.png"><p>after</p>')
103
+
104
+ caption().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
105
+
106
+ const { $from } = editor!.state.selection
107
+ expect($from.parent.textContent).toBe('after')
108
+ })
109
+
110
+ it('floats the image with the align attribute', () => {
111
+ createEditor('<img src="https://example.com/a.png" style="float: right">')
112
+ const nodeView = editor!.view.nodeDOM(0) as HTMLElement
113
+
114
+ expect(nodeView.dataset.align).toBe('right')
115
+ expect(editor!.getHTML()).toBe('<figure><img src="https://example.com/a.png" style="float: right;"></figure>')
116
+
117
+ editor!.chain().setNodeSelection(0).updateAttributes('image', { align: null }).run()
118
+
119
+ expect(nodeView.dataset.align).toBeUndefined()
120
+ expect(editor!.getHTML()).toBe('<figure><img src="https://example.com/a.png"></figure>')
121
+ })
122
+ })