tiptapify 0.3.2 → 0.3.4

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 (87) hide show
  1. package/README.md +1 -0
  2. package/dist/tiptapify.css +1 -1
  3. package/dist/tiptapify.mjs +54477 -48112
  4. package/dist/tiptapify.umd.js +68 -68
  5. package/index.d.ts +96 -0
  6. package/package.json +2 -1
  7. package/src/components/Footer.vue +12 -2
  8. package/src/components/ImageBubbleMenu.vue +139 -0
  9. package/src/components/MenuBubble.vue +60 -7
  10. package/src/components/SkinScope.test.ts +74 -0
  11. package/src/components/SkinScope.vue +18 -0
  12. package/src/components/Tiptapify.skin.test.ts +186 -0
  13. package/src/components/Tiptapify.vue +285 -74
  14. package/src/components/Toolbar/Index.test.ts +96 -0
  15. package/src/components/Toolbar/Index.vue +199 -14
  16. package/src/components/Toolbar/Items.test.ts +82 -2
  17. package/src/components/Toolbar/Items.vue +104 -19
  18. package/src/components/Toolbar/alignment.ts +5 -0
  19. package/src/components/UI/TiptapifyDialog.test.ts +132 -0
  20. package/src/components/UI/TiptapifyDialog.vue +128 -12
  21. package/src/components/editorExtensions.test.ts +10 -0
  22. package/src/components/editorExtensions.ts +34 -5
  23. package/src/components/index.ts +3 -2
  24. package/src/composables/useTiptapifyConfig.test.ts +25 -2
  25. package/src/composables/useTiptapifyConfig.ts +21 -2
  26. package/src/constants/defaults.ts +2 -1
  27. package/src/extensions/components/actions/redo/Button.vue +1 -1
  28. package/src/extensions/components/actions/undo/Button.vue +1 -1
  29. package/src/extensions/components/alignment/menu/Button.vue +72 -0
  30. package/src/extensions/components/list/bullet/Button.vue +35 -16
  31. package/src/extensions/components/list/numbered/Button.vue +19 -0
  32. package/src/extensions/components/list/styles/ListStyleButton.vue +156 -0
  33. package/src/extensions/components/list/styles/index.ts +76 -0
  34. package/src/extensions/components/list/styles/listStyle.test.ts +44 -0
  35. package/src/extensions/components/media/image/ImageDialog.vue +1 -1
  36. package/src/extensions/components/media/image/caption.test.ts +122 -0
  37. package/src/extensions/components/media/image/caption.ts +268 -0
  38. package/src/extensions/components/media/image/slider.test.ts +106 -0
  39. package/src/extensions/components/media/image/slider.ts +87 -0
  40. package/src/extensions/components/style/heading/Button.vue +45 -30
  41. package/src/i18n/locales/ar.json +22 -0
  42. package/src/i18n/locales/bg.json +22 -0
  43. package/src/i18n/locales/ch.json +22 -0
  44. package/src/i18n/locales/cz.json +22 -0
  45. package/src/i18n/locales/de.json +22 -0
  46. package/src/i18n/locales/el.json +22 -0
  47. package/src/i18n/locales/en.json +23 -1
  48. package/src/i18n/locales/es.json +22 -0
  49. package/src/i18n/locales/et.json +22 -0
  50. package/src/i18n/locales/fi.json +22 -0
  51. package/src/i18n/locales/fr.json +22 -0
  52. package/src/i18n/locales/he.json +22 -0
  53. package/src/i18n/locales/hi.json +22 -0
  54. package/src/i18n/locales/hr.json +22 -0
  55. package/src/i18n/locales/hu.json +22 -0
  56. package/src/i18n/locales/id.json +22 -0
  57. package/src/i18n/locales/it.json +22 -0
  58. package/src/i18n/locales/ja.json +22 -0
  59. package/src/i18n/locales/ko.json +22 -0
  60. package/src/i18n/locales/la.json +22 -0
  61. package/src/i18n/locales/lt.json +22 -0
  62. package/src/i18n/locales/nl.json +22 -0
  63. package/src/i18n/locales/no.json +22 -0
  64. package/src/i18n/locales/pl.json +22 -0
  65. package/src/i18n/locales/pt-BR.json +22 -0
  66. package/src/i18n/locales/pt.json +22 -0
  67. package/src/i18n/locales/ro.json +22 -0
  68. package/src/i18n/locales/ru.json +23 -1
  69. package/src/i18n/locales/se.json +22 -0
  70. package/src/i18n/locales/sk.json +22 -0
  71. package/src/i18n/locales/sl.json +22 -0
  72. package/src/i18n/locales/sr.json +22 -0
  73. package/src/i18n/locales/th.json +22 -0
  74. package/src/i18n/locales/tr.json +22 -0
  75. package/src/i18n/locales/uk.json +23 -1
  76. package/src/i18n/locales/vi.json +22 -0
  77. package/src/index.test.ts +88 -0
  78. package/src/index.ts +15 -0
  79. package/src/skins/__fixtures__/skin-baseline.json +805 -0
  80. package/src/skins/builtins.ts +81 -0
  81. package/src/skins/menu.css +87 -0
  82. package/src/skins/menu.ts +10 -0
  83. package/src/skins/registry.test.ts +180 -0
  84. package/src/skins/registry.ts +132 -0
  85. package/src/skins/tokens.reachability.test.ts +43 -0
  86. package/src/skins/tokens.ts +92 -0
  87. package/src/types/editor.ts +99 -0
@@ -2,6 +2,7 @@ import { default as AlignmentLeftButton } from '@tiptapify/extensions/components
2
2
  import { default as AlignmentCenterButton } from '@tiptapify/extensions/components/alignment/center/Button.vue'
3
3
  import { default as AlignmentRightButton } from '@tiptapify/extensions/components/alignment/right/Button.vue'
4
4
  import { default as AlignmentJustifyButton } from '@tiptapify/extensions/components/alignment/justify/Button.vue'
5
+ import { default as AlignmentMenuButton } from '@tiptapify/extensions/components/alignment/menu/Button.vue'
5
6
  import { markRaw } from 'vue'
6
7
 
7
8
  export default {
@@ -24,5 +25,9 @@ export default {
24
25
  name: 'justify',
25
26
  component: markRaw(AlignmentJustifyButton),
26
27
  },
28
+ {
29
+ name: 'align',
30
+ component: markRaw(AlignmentMenuButton),
31
+ },
27
32
  ]
28
33
  }
@@ -0,0 +1,132 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { App, computed, createApp, h, nextTick, provide, Ref, ref } from 'vue'
3
+ import { createVuetify } from 'vuetify'
4
+ import { VTextField } from 'vuetify/components'
5
+ import TiptapifyDialog from '@tiptapify/components/UI/TiptapifyDialog.vue'
6
+ import { TIPTAPIFY_CONFIG_KEY } from '@tiptapify/composables/useTiptapifyConfig'
7
+ import { resolveSkin } from '@tiptapify/skins/registry'
8
+
9
+ // jsdom has no `visualViewport` global; Vuetify's overlay positioning reads it,
10
+ // and the `visualViewport?.` guards do not protect against an undeclared identifier
11
+ if (!('visualViewport' in globalThis)) {
12
+ ;(globalThis as Record<string, unknown>).visualViewport = {
13
+ scale: 1,
14
+ offsetLeft: 0,
15
+ offsetTop: 0,
16
+ addEventListener: () => {},
17
+ removeEventListener: () => {},
18
+ }
19
+ }
20
+
21
+ // The dialog teleports to the overlay root, so its skin markup rides the shape:
22
+ // the `tiptapify-dialog--soft` carrier, the divider under the title, the close
23
+ // variant and the field defaults all key off `shape.dialog`, not the skin name.
24
+ const openApps: { app: App, host: HTMLElement }[] = []
25
+
26
+ async function openDialog (skinInput: unknown): Promise<HTMLElement> {
27
+ const config = resolveSkin(skinInput)
28
+ const dialog: Ref<{ open: () => void } | null> = ref(null)
29
+
30
+ const app = createApp({
31
+ setup () {
32
+ provide(TIPTAPIFY_CONFIG_KEY, {
33
+ variantBtn: ref('flat'),
34
+ variantField: ref('outlined'),
35
+ skin: ref(skinInput),
36
+ skinName: computed(() => config.name),
37
+ tokens: computed(() => config.tokens),
38
+ shape: computed(() => config.shape),
39
+ })
40
+ provide('tiptapifyI18n', { t: (key: string) => key })
41
+
42
+ return () => h(TiptapifyDialog, { ref: dialog, title: 'Probe dialog', module: 'link' }, {
43
+ content: () => h('div', { class: 'probe-content' }, [h(VTextField, { label: 'Field' })]),
44
+ })
45
+ },
46
+ })
47
+ app.use(createVuetify())
48
+
49
+ const host = document.createElement('div')
50
+ document.body.appendChild(host)
51
+ app.mount(host)
52
+ openApps.push({ app, host })
53
+ await nextTick()
54
+
55
+ dialog.value?.open()
56
+ for (let i = 0; i < 10; i++) {
57
+ await nextTick()
58
+ if (document.querySelector('.tiptapify-dialog')) {
59
+ break
60
+ }
61
+ }
62
+
63
+ const card = document.querySelector<HTMLElement>('.tiptapify-dialog')
64
+ if (!card) {
65
+ throw new Error('dialog card did not render')
66
+ }
67
+
68
+ return card
69
+ }
70
+
71
+ function disposeDialogs () {
72
+ for (const { app, host } of openApps.splice(0)) {
73
+ app.unmount()
74
+ host.remove()
75
+ }
76
+ document.body.innerHTML = ''
77
+ }
78
+
79
+ afterEach(disposeDialogs)
80
+
81
+ describe('TiptapifyDialog shape wiring', () => {
82
+ it('the classic dialog keeps the divider, the elevated close and no soft carrier', async () => {
83
+ const card = await openDialog('default')
84
+
85
+ expect(card.classList.contains('tiptapify-dialog--soft')).toBe(false)
86
+ expect(card.classList.contains('tiptapify-dialog--pastel')).toBe(false)
87
+
88
+ const header = card.querySelector('.tiptapify-dialog-header')
89
+ expect(header?.nextElementSibling?.classList.contains('v-divider')).toBe(true)
90
+
91
+ const close = card.querySelector('.tiptapify-dialog-close')
92
+ expect([...close?.classList ?? []].join(' ')).toContain('v-btn--variant-elevated')
93
+
94
+ const input = card.querySelector('.v-input')
95
+ expect([...input?.classList ?? []].join(' ')).toContain('v-input--density-default')
96
+ })
97
+
98
+ it('the pastel dialog carries tiptapify-dialog--soft, no divider, a text close and soft field defaults', async () => {
99
+ const card = await openDialog('pastel')
100
+
101
+ expect(card.classList.contains('tiptapify-dialog--soft')).toBe(true)
102
+
103
+ const header = card.querySelector('.tiptapify-dialog-header')
104
+ expect(header?.nextElementSibling?.classList.contains('v-divider')).toBe(false)
105
+
106
+ const close = card.querySelector('.tiptapify-dialog-close')
107
+ expect([...close?.classList ?? []].join(' ')).toContain('v-btn--variant-text')
108
+
109
+ // VDefaultsProvider: the soft dialog gives unstyled fields the comfortable outlined defaults
110
+ const input = card.querySelector('.v-input')
111
+ expect([...input?.classList ?? []].join(' ')).toContain('v-input--density-comfortable')
112
+ })
113
+
114
+ it('the soft carrier rides the dialog flag, not the name: a custom skin extending pastel keeps it', async () => {
115
+ const card = await openDialog({ extends: 'pastel' })
116
+
117
+ expect(card.classList.contains('tiptapify-dialog--soft')).toBe(true)
118
+ expect(card.classList.contains('tiptapify-dialog--pastel')).toBe(false)
119
+ })
120
+
121
+ it('an inline dialog flag alone drives the carrier and the divider', async () => {
122
+ const soft = await openDialog({ dialog: 'soft' })
123
+ expect(soft.classList.contains('tiptapify-dialog--soft')).toBe(true)
124
+ expect(soft.querySelector('.tiptapify-dialog-header')?.nextElementSibling?.classList.contains('v-divider')).toBe(false)
125
+
126
+ disposeDialogs()
127
+
128
+ const classic = await openDialog({ dialog: 'classic' })
129
+ expect(classic.classList.contains('tiptapify-dialog--soft')).toBe(false)
130
+ expect(classic.querySelector('.tiptapify-dialog-header')?.nextElementSibling?.classList.contains('v-divider')).toBe(true)
131
+ })
132
+ })
@@ -1,8 +1,9 @@
1
1
  <script lang="ts" setup>
2
2
 
3
3
  import * as mdi from '@mdi/js'
4
- import { inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
4
+ import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
5
5
  import { ComposerTranslation } from 'vue-i18n'
6
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
6
7
 
7
8
  const props = defineProps({
8
9
  module: String,
@@ -13,10 +14,27 @@ const props = defineProps({
13
14
 
14
15
  const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
15
16
 
17
+ // The dialog is teleported to the overlay root, so the skin reaches it by injection,
18
+ // not by the editor's DOM
19
+ const { shape, tokens } = useTiptapifyConfig()
20
+
21
+ // The soft dialog shape drives every dialog site (carrier class, close variant, field defaults):
22
+ // one computed keeps the template readable (task-7 cleanup)
23
+ const dialogSoft = computed(() => shape.value.dialog === 'soft')
24
+
16
25
  defineExpose({ open, close })
17
26
  const emits = defineEmits(['closeDialog'])
18
27
 
19
28
  const showDialog = ref<boolean>(false)
29
+
30
+ const field = { variant: 'outlined', density: 'comfortable' }
31
+ const fieldDefaults = {
32
+ VTextField: field,
33
+ VTextarea: field,
34
+ VSelect: field,
35
+ VAutocomplete: field,
36
+ VCombobox: field,
37
+ }
20
38
  const movableHandler = ref(null)
21
39
 
22
40
  function open() {
@@ -141,48 +159,146 @@ onBeforeUnmount(() => {
141
159
 
142
160
  <template>
143
161
  <VDialog v-model="showDialog" :max-width="maxWidth" :fullscreen="fullscreen" @click:outside="emitClose">
144
- <VCard>
162
+ <VCard
163
+ class="tiptapify-dialog"
164
+ :class="{ 'tiptapify-dialog--soft': dialogSoft, 'tiptapify-dialog--fullscreen': fullscreen }"
165
+ :style="tokens"
166
+ >
145
167
  <VCardTitle
146
168
  ref="movableHandler"
147
169
  class="tiptapify-dialog-header"
148
170
  :class="!fullscreen ? 'tiptapify-movable-handler' : ''"
149
171
  style="user-select: none;"
150
172
  >
151
- <VLabel>
173
+ <span class="tiptapify-dialog-title">
152
174
  {{ title ?? t(`dialog.${module}.title`) }}
153
- </VLabel>
175
+ </span>
154
176
 
155
177
  <VSpacer />
156
178
 
157
- <VBtn density="compact" class="tiptapify-dialog-close" variant="elevated" elevation="4" icon @click="emitClose">
158
- <VIcon size="x-small" :icon="`mdiSvg:${mdi.mdiClose}`" />
179
+ <VBtn
180
+ class="tiptapify-dialog-close"
181
+ :variant="dialogSoft ? 'text' : 'elevated'"
182
+ :elevation="dialogSoft ? undefined : 4"
183
+ :density="dialogSoft ? 'default' : 'compact'"
184
+ :size="dialogSoft ? '32' : undefined"
185
+ icon
186
+ @click="emitClose"
187
+ >
188
+ <VIcon :size="dialogSoft ? 20 : 'x-small'" :icon="`mdiSvg:${mdi.mdiClose}`" />
159
189
  </VBtn>
160
190
  </VCardTitle>
161
191
 
162
- <VDivider />
192
+ <VDivider v-if="!dialogSoft" />
163
193
 
164
- <slot name="content" />
194
+ <!-- In the soft dialog shape, fields a dialog does not style itself get the light outlined look -->
195
+ <VDefaultsProvider :defaults="dialogSoft ? fieldDefaults : {}">
196
+ <slot name="content" />
165
197
 
166
- <slot name="actions" />
198
+ <slot name="actions" />
199
+ </VDefaultsProvider>
167
200
  </VCard>
168
201
  </VDialog>
169
202
  </template>
170
203
 
171
204
  <style lang="scss" scoped>
205
+ /*
206
+ * Dialog skin rules: the base block reads the dialog tokens with
207
+ * the classic fallbacks; the soft block (carrier `tiptapify-dialog--soft`, teleported
208
+ * surfaces cannot ride the root's shape classes) reads the same tokens with the soft
209
+ * shape's base as fallback. The VCard itself carries the token `:style` (SkinScope step),
210
+ * so the tokens reach every rule here.
211
+ * The `!important`s stay exactly where Vuetify paints the pixel with an INLINE
212
+ * style (the card radius/shadow via the elevation/rounded composables, the content
213
+ * padding via density) — no selector can outrank an inline style, and these are the
214
+ * base-commit set, no additions. The header and actions padding keep the base-commit
215
+ * !importants dropped: Vuetify paints them as plain class rules, and the doubled
216
+ * scoped carrier outranks them (proven by the fixture gate).
217
+ */
172
218
  .tiptapify-dialog-header {
173
- align-items: center;
174
219
  display: flex;
220
+ align-items: center;
221
+ }
222
+
223
+ /* The classic title paints like a VLabel: a light, roomy label */
224
+ .tiptapify-dialog-title {
225
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
226
+ font-size: var(--tf-dialog-title-size, 1.25rem);
227
+ font-weight: var(--tf-dialog-title-weight, 400);
228
+ line-height: 1.75;
229
+ white-space: normal;
175
230
  }
176
231
 
177
232
  :deep(.v-overlay__content) {
178
233
  position: fixed;
179
234
  }
180
235
 
181
- .tiptapify-movable-handler, :deep(.tiptapify-movable-handler > *) {
236
+ .tiptapify-movable-handler {
182
237
  cursor: move;
183
238
  }
184
239
 
185
240
  .tiptapify-dialog-close:hover {
186
241
  cursor: pointer;
187
242
  }
188
- </style>
243
+
244
+ /**
245
+ * Soft dialog shape: a light card in the spirit of Flux modals: generous padding,
246
+ * soft corners and shadow, no rules
247
+ */
248
+ .tiptapify-dialog--soft {
249
+ border-radius: var(--tf-dialog-radius, 12px) !important;
250
+ box-shadow: var(--tf-dialog-shadow, 0 20px 48px rgba(0, 0, 0, 0.16), 0 2px 6px rgba(0, 0, 0, 0.06)) !important;
251
+
252
+ &.tiptapify-dialog--fullscreen {
253
+ border-radius: 0 !important;
254
+ }
255
+
256
+ :deep(.v-card-text) {
257
+ padding: var(--tf-dialog-content-padding, 4px 20px) !important;
258
+ }
259
+
260
+ :deep(.v-field--variant-outlined) {
261
+ border-radius: var(--tf-dialog-field-radius, 8px);
262
+
263
+ .v-field__outline__start {
264
+ border-start-start-radius: var(--tf-dialog-field-radius, 8px);
265
+ border-end-start-radius: var(--tf-dialog-field-radius, 8px);
266
+ }
267
+
268
+ .v-field__outline__end {
269
+ border-start-end-radius: var(--tf-dialog-field-radius, 8px);
270
+ border-end-end-radius: var(--tf-dialog-field-radius, 8px);
271
+ }
272
+ }
273
+
274
+ :deep(.v-card-actions) {
275
+ gap: 8px;
276
+ padding: var(--tf-dialog-actions-padding, 16px 20px 20px);
277
+ }
278
+
279
+ :deep(.v-card-actions .v-btn),
280
+ :deep(.tiptapify-dialog-actions .v-btn) {
281
+ min-height: var(--tf-dialog-action-btn-min-height, 38px);
282
+ padding-inline: 16px;
283
+ border-radius: var(--tf-dialog-action-btn-radius, 8px);
284
+ font-weight: 500;
285
+ letter-spacing: normal;
286
+ text-transform: none;
287
+ }
288
+ }
289
+
290
+ .tiptapify-dialog--soft .tiptapify-dialog-header {
291
+ padding: var(--tf-dialog-header-padding, 16px 12px 12px 20px);
292
+ }
293
+
294
+ .tiptapify-dialog--soft .tiptapify-dialog-title {
295
+ color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
296
+ font-size: var(--tf-dialog-title-size, 1.125rem);
297
+ font-weight: var(--tf-dialog-title-weight, 600);
298
+ line-height: 1.4;
299
+ }
300
+
301
+ .tiptapify-dialog--soft .tiptapify-dialog-close {
302
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
303
+ }
304
+ </style>
@@ -0,0 +1,10 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { editorExtensions } from '@tiptapify/components/editorExtensions'
3
+
4
+ describe('editor extensions', () => {
5
+ it('selects a link on click instead of opening it', () => {
6
+ const link = editorExtensions('', false, []).find(extension => extension.name === 'link')
7
+
8
+ expect(link?.options).toMatchObject({ openOnClick: false, enableClickSelection: true })
9
+ })
10
+ })
@@ -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,
@@ -1,9 +1,10 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { createSSRApp, h, provide, ref } from 'vue'
2
+ import { computed, createSSRApp, h, provide, ref } from 'vue'
3
3
  import { renderToString } from 'vue/server-renderer'
4
4
  import { createVuetify } from 'vuetify'
5
5
  import BoldButton from '@tiptapify/extensions/components/format/bold/Button.vue'
6
6
  import { TIPTAPIFY_CONFIG_KEY, useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
7
+ import { resolveSkin } from '@tiptapify/skins/registry'
7
8
 
8
9
  const editor = {
9
10
  isActive: () => false,
@@ -13,10 +14,16 @@ const editor = {
13
14
 
14
15
  const Consumer = {
15
16
  setup () {
16
- const { variantBtn, variantField } = useTiptapifyConfig()
17
+ const { variantBtn, variantField, skin, skinName, tokens, shape } = useTiptapifyConfig()
17
18
  return () => h('span', [
18
19
  h('span', { id: 'variant-btn' }, variantBtn.value),
19
20
  h('span', { id: 'variant-field' }, variantField.value),
21
+ h('span', { id: 'skin' }, typeof skin.value === 'string' ? skin.value : ''),
22
+ h('span', { id: 'skin-name' }, skinName.value),
23
+ h('span', { id: 'token-count' }, String(Object.keys(tokens.value).length)),
24
+ h('span', { id: 'token-button-radius' }, tokens.value['--tf-button-radius'] ?? ''),
25
+ h('span', { id: 'shape-toolbar' }, shape.value.toolbar),
26
+ h('span', { id: 'shape-buttons' }, shape.value.buttons),
20
27
  ])
21
28
  },
22
29
  }
@@ -25,9 +32,14 @@ function render (root: () => any, withConfig: boolean) {
25
32
  const app = createSSRApp({
26
33
  setup () {
27
34
  if (withConfig) {
35
+ const pastel = resolveSkin('pastel')
28
36
  provide(TIPTAPIFY_CONFIG_KEY, {
29
37
  variantBtn: ref('tonal'),
30
38
  variantField: ref('outlined'),
39
+ skin: ref('pastel'),
40
+ skinName: computed(() => pastel.name),
41
+ tokens: computed(() => pastel.tokens),
42
+ shape: computed(() => pastel.shape),
31
43
  })
32
44
  }
33
45
  provide('tiptapifyEditor', ref(editor))
@@ -46,6 +58,11 @@ describe('useTiptapifyConfig', () => {
46
58
 
47
59
  expect(html).toContain('<span id="variant-btn">tonal</span>')
48
60
  expect(html).toContain('<span id="variant-field">outlined</span>')
61
+ expect(html).toContain('<span id="skin">pastel</span>')
62
+ expect(html).toContain('<span id="skin-name">pastel</span>')
63
+ expect(html).toContain('<span id="token-button-radius">6px</span>')
64
+ expect(html).toContain('<span id="shape-toolbar">flat</span>')
65
+ expect(html).toContain('<span id="shape-buttons">quiet</span>')
49
66
  })
50
67
 
51
68
  it('falls back to defaults when no provider is present', async () => {
@@ -53,6 +70,12 @@ describe('useTiptapifyConfig', () => {
53
70
 
54
71
  expect(html).toContain('<span id="variant-btn">flat</span>')
55
72
  expect(html).toContain('<span id="variant-field">solo</span>')
73
+ expect(html).toContain('<span id="skin">default</span>')
74
+ // The fallback resolves the default skin: classic shape, no emitted tokens
75
+ expect(html).toContain('<span id="skin-name">default</span>')
76
+ expect(html).toContain('<span id="token-count">0</span>')
77
+ expect(html).toContain('<span id="shape-toolbar">raised</span>')
78
+ expect(html).toContain('<span id="shape-buttons">vuetify</span>')
56
79
  })
57
80
 
58
81
  it('is consumed by toolbar extension buttons', async () => {
@@ -1,17 +1,36 @@
1
- import { computed, inject, InjectionKey, Ref } from 'vue'
1
+ import { computed, ComputedRef, inject, InjectionKey, Ref } from 'vue'
2
2
  import defaults from '@tiptapify/constants/defaults'
3
- import { variantBtnTypes, variantFieldTypes } from '@tiptapify/types/editor'
3
+ import { TiptapifyShape, TiptapifySkinInput, variantBtnTypes, variantFieldTypes } from '@tiptapify/types/editor'
4
+ // Importing the builtins registers them on load, so resolution works without consumer setup
5
+ import '@tiptapify/skins/builtins'
6
+ import { resolveSkin } from '@tiptapify/skins/registry'
4
7
 
5
8
  export interface TiptapifyConfig {
6
9
  variantBtn: Ref<variantBtnTypes>
7
10
  variantField: Ref<variantFieldTypes>
11
+ // the raw `skin` prop: a registered name (string) or an inline skin object
12
+ skin: Ref<TiptapifySkinInput>
13
+ // the resolved skin name (inline objects without a name resolve to 'inline')
14
+ skinName: ComputedRef<string>
15
+ // css variable name -> value; only overrides, the css fallbacks carry the base
16
+ tokens: ComputedRef<Record<string, string>>
17
+ shape: ComputedRef<Required<TiptapifyShape>>
8
18
  }
9
19
 
10
20
  export const TIPTAPIFY_CONFIG_KEY: InjectionKey<TiptapifyConfig> = Symbol('tiptapifyConfig')
11
21
 
22
+ // Components outside an editor (docs playground) resolve the default skin:
23
+ // classic shape, and no emitted tokens — the css fallbacks are the base values.
24
+ // Resolved once at module load: the fallback never changes at runtime
25
+ const defaultSkin = resolveSkin(defaults.skin)
26
+
12
27
  export function useTiptapifyConfig(): TiptapifyConfig {
13
28
  return inject(TIPTAPIFY_CONFIG_KEY, {
14
29
  variantBtn: computed(() => defaults.variantBtn),
15
30
  variantField: computed(() => defaults.variantField),
31
+ skin: computed(() => defaults.skin),
32
+ skinName: computed(() => defaultSkin.name),
33
+ tokens: computed(() => defaultSkin.tokens),
34
+ shape: computed(() => defaultSkin.shape),
16
35
  })
17
36
  }
@@ -1,6 +1,7 @@
1
- import { variantBtnTypes, variantFieldTypes } from '@tiptapify/types/editor'
1
+ import { TiptapifySkin, variantBtnTypes, variantFieldTypes } from '@tiptapify/types/editor'
2
2
 
3
3
  export default {
4
4
  variantBtn: 'flat' as variantBtnTypes,
5
5
  variantField: 'solo' as variantFieldTypes,
6
+ skin: 'default' as TiptapifySkin,
6
7
  }
@@ -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,72 @@
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 SkinScope from '@tiptapify/components/SkinScope.vue'
7
+ import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
8
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
9
+ import { menuContentClass } from '@tiptapify/skins/menu'
10
+ import { inject, PropType, Ref } from 'vue'
11
+
12
+ import { ComposerTranslation } from 'vue-i18n'
13
+
14
+ type Alignment = 'left' | 'center' | 'right' | 'justify'
15
+
16
+ const props = defineProps({
17
+ alignments: { type: Array as PropType<Alignment[]>, default: () => ['left', 'center', 'right', 'justify'] },
18
+ })
19
+
20
+ const { variantBtn, shape } = useTiptapifyConfig()
21
+
22
+ const editor = inject('tiptapifyEditor') as Ref<Editor>
23
+
24
+ const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
25
+
26
+ const alignmentOptions: Record<Alignment, { icon: string, hotkey: string }> = {
27
+ left: { icon: mdi.mdiFormatAlignLeft, hotkey: 'Mod-Shift-l' },
28
+ center: { icon: mdi.mdiFormatAlignCenter, hotkey: 'Mod-Shift-e' },
29
+ right: { icon: mdi.mdiFormatAlignRight, hotkey: 'Mod-Shift-r' },
30
+ justify: { icon: mdi.mdiFormatAlignJustify, hotkey: 'Mod-Shift-j' },
31
+ }
32
+
33
+ // A function, not a computed: `isActive` reads the editor state, which Vue does not track
34
+ function currentAlignmentIcon(): string {
35
+ const active = props.alignments.find(alignment => editor.value.isActive({ textAlign: alignment }))
36
+
37
+ return alignmentOptions[active ?? props.alignments[0] ?? 'left'].icon
38
+ }
39
+ </script>
40
+
41
+ <template>
42
+ <VBtn
43
+ :id="`tiptapify-align-button-${editor.instanceId}`"
44
+ class="tiptapify-dropdown-button"
45
+ :variant="variantBtn"
46
+ height="32"
47
+ >
48
+ <Tooltip :label="t('alignment')" />
49
+ <BtnIcon :icon="`mdiSvg:${currentAlignmentIcon()}`" />
50
+ <BtnIcon :icon="`mdiSvg:${mdi.mdiChevronDown}`" class="tiptapify-dropdown-button__chevron" />
51
+ </VBtn>
52
+
53
+ <VMenu :activator="`#tiptapify-align-button-${editor.instanceId}`" :content-class="menuContentClass(shape.menu === 'soft')">
54
+ <SkinScope>
55
+ <VList density="compact">
56
+ <VListItem
57
+ v-for="alignment in alignments"
58
+ :key="alignment"
59
+ link
60
+ :active="editor.isActive({ textAlign: alignment })"
61
+ @click="editor.chain().focus().toggleTextAlign(alignment).run()"
62
+ >
63
+ <Tooltip :label="t(`alignments.${alignment}`)" :hotkey="alignmentOptions[alignment].hotkey" />
64
+ <template #prepend>
65
+ <BtnIcon :icon="`mdiSvg:${alignmentOptions[alignment].icon}`" />
66
+ </template>
67
+ <VListItemTitle>{{ t(`alignments.${alignment}`) }}</VListItemTitle>
68
+ </VListItem>
69
+ </VList>
70
+ </SkinScope>
71
+ </VMenu>
72
+ </template>