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
@@ -0,0 +1,186 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { createApp, createSSRApp, h, nextTick, ref, Ref } from 'vue'
3
+ import { renderToString } from 'vue/server-renderer'
4
+ import { createVuetify } from 'vuetify'
5
+ import { createI18n } from 'vue-i18n'
6
+ import Tiptapify from '@tiptapify/components/Tiptapify.vue'
7
+ import { messages } from '@tiptapify/i18n'
8
+
9
+ function withPlugins (app: { use: (plugin: never) => unknown }) {
10
+ app.use(createVuetify() as never)
11
+ app.use(createI18n({ legacy: false, locale: 'en', fallbackLocale: 'en', messages }) as never)
12
+ }
13
+
14
+ async function renderShell (skin: unknown): Promise<string> {
15
+ const app = createSSRApp({ setup: () => () => h(Tiptapify, { skin }) })
16
+ withPlugins(app)
17
+
18
+ return renderToString(app)
19
+ }
20
+
21
+ // The opening tag of the editor root div, so assertions see the skin class and the token style
22
+ function rootTag (html: string): string {
23
+ const start = html.indexOf('tiptapify-editor-')
24
+
25
+ return html.slice(0, html.indexOf('>', start) + 1)
26
+ }
27
+
28
+ async function mountEditor (skinRef: Ref<unknown>) {
29
+ const app = createApp({ setup: () => () => h(Tiptapify, { skin: skinRef.value }) })
30
+ withPlugins(app)
31
+ const host = document.createElement('div')
32
+ document.body.appendChild(host)
33
+ app.mount(host)
34
+ await nextTick()
35
+ await nextTick()
36
+
37
+ return { app, host }
38
+ }
39
+
40
+ describe('Tiptapify skin resolution', () => {
41
+ afterEach(() => {
42
+ vi.restoreAllMocks()
43
+ })
44
+
45
+ it('renders the default skin class and emits no token vars', async () => {
46
+ const html = await renderShell('default')
47
+
48
+ expect(rootTag(html)).toContain('tiptapify-skin-default')
49
+ expect(rootTag(html)).not.toContain('--tf-')
50
+ })
51
+
52
+ it('renders the pastel skin class and emits its token overrides', async () => {
53
+ const html = await renderShell('pastel')
54
+
55
+ expect(rootTag(html)).toContain('tiptapify-skin-pastel')
56
+ expect(rootTag(html)).toContain('--tf-toolbar-bg:rgba(var(--v-theme-on-surface), 0.03)')
57
+ expect(rootTag(html)).toContain('--tf-group-shadow:none')
58
+ expect(rootTag(html)).toContain('--tf-button-radius:6px')
59
+ })
60
+
61
+ it('renders an inline skin object under tiptapify-skin-inline with merged tokens', async () => {
62
+ const html = await renderShell({ colors: { toolbarBg: 'rgb(1, 2, 3)' }, metrics: { buttonRadius: '9px' } })
63
+
64
+ expect(rootTag(html)).toContain('tiptapify-skin-inline')
65
+ expect(rootTag(html)).toContain('--tf-toolbar-bg:rgb(1, 2, 3)')
66
+ expect(rootTag(html)).toContain('--tf-button-radius:9px')
67
+ })
68
+
69
+ it('a named inline object renders under tiptapify-skin-{name}, not the inline name', async () => {
70
+ const html = await renderShell({
71
+ name: 'mint',
72
+ extends: 'pastel',
73
+ colors: { toolbarBg: 'rgba(0, 128, 0, 0.05)' },
74
+ })
75
+
76
+ expect(rootTag(html)).toContain('tiptapify-skin-mint')
77
+ expect(rootTag(html)).not.toContain('tiptapify-skin-inline')
78
+ expect(rootTag(html)).toContain('--tf-toolbar-bg:rgba(0, 128, 0, 0.05)')
79
+ })
80
+
81
+ it('falls back to the default skin for an unknown name', async () => {
82
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
83
+ const html = await renderShell('nope')
84
+
85
+ expect(rootTag(html)).toContain('tiptapify-skin-default')
86
+ expect(rootTag(html)).not.toContain('--tf-')
87
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('nope'))
88
+ })
89
+
90
+ it('re-resolves the skin when the prop changes, without re-rendering the editor', async () => {
91
+ const skin = ref<unknown>('default')
92
+ const { app, host } = await mountEditor(skin)
93
+
94
+ const root = host.querySelector('[id^=tiptapify-editor-]') as HTMLElement
95
+ expect(root.className).toContain('tiptapify-skin-default')
96
+
97
+ const proseMirror = host.querySelector('.ProseMirror')
98
+ expect(proseMirror).toBeTruthy()
99
+
100
+ skin.value = { colors: { toolbarBg: 'rgb(9, 9, 9)' } }
101
+ await nextTick()
102
+
103
+ expect(root.className).toContain('tiptapify-skin-inline')
104
+ expect(root.style.getPropertyValue('--tf-toolbar-bg')).toBe('rgb(9, 9, 9)')
105
+ // The editor DOM is the same node: a skin change never recreates the editor
106
+ expect(host.querySelector('.ProseMirror')).toBe(proseMirror)
107
+
108
+ app.unmount()
109
+ host.remove()
110
+ })
111
+
112
+ it('keeps the pastel toolbar quiet through the resolved skin name', async () => {
113
+ const skin = ref<unknown>('pastel')
114
+ const { app, host } = await mountEditor(skin)
115
+
116
+ const toolbar = host.querySelector('.v-toolbar')
117
+ expect(toolbar?.classList.contains('v-toolbar--flat')).toBe(true)
118
+
119
+ app.unmount()
120
+ host.remove()
121
+ })
122
+
123
+ it('the root carries all six shape classes: classic values for the default skin', async () => {
124
+ const tag = rootTag(await renderShell('default'))
125
+
126
+ expect(tag).toContain('tiptapify-shape-toolbar-raised')
127
+ expect(tag).toContain('tiptapify-shape-groups-box')
128
+ expect(tag).toContain('tiptapify-shape-buttons-vuetify')
129
+ expect(tag).toContain('tiptapify-shape-bubble-card')
130
+ expect(tag).toContain('tiptapify-shape-dialog-classic')
131
+ expect(tag).toContain('tiptapify-shape-menu-vuetify')
132
+ })
133
+
134
+ it('the pastel root carries the quiet shape classes and keeps the skin name class', async () => {
135
+ const tag = rootTag(await renderShell('pastel'))
136
+
137
+ expect(tag).toContain('tiptapify-skin-pastel')
138
+ expect(tag).toContain('tiptapify-shape-toolbar-flat')
139
+ expect(tag).toContain('tiptapify-shape-groups-flat')
140
+ expect(tag).toContain('tiptapify-shape-buttons-quiet')
141
+ expect(tag).toContain('tiptapify-shape-bubble-flat')
142
+ expect(tag).toContain('tiptapify-shape-dialog-soft')
143
+ expect(tag).toContain('tiptapify-shape-menu-soft')
144
+ expect(tag).not.toContain('tiptapify-shape-dialog-classic')
145
+ expect(tag).not.toContain('tiptapify-shape-buttons-vuetify')
146
+ })
147
+
148
+ it('an inline shape object lands on the root classes; the flags it omits stay classic', async () => {
149
+ const tag = rootTag(await renderShell({ buttons: 'quiet', dialog: 'soft' }))
150
+
151
+ expect(tag).toContain('tiptapify-skin-inline')
152
+ expect(tag).toContain('tiptapify-shape-buttons-quiet')
153
+ expect(tag).toContain('tiptapify-shape-dialog-soft')
154
+ expect(tag).toContain('tiptapify-shape-toolbar-raised')
155
+ expect(tag).toContain('tiptapify-shape-groups-box')
156
+ expect(tag).toContain('tiptapify-shape-bubble-card')
157
+ expect(tag).toContain('tiptapify-shape-menu-vuetify')
158
+ })
159
+
160
+ it('a custom skin extending pastel keeps the pastel shape classes under the inline name', async () => {
161
+ const tag = rootTag(await renderShell({ extends: 'pastel' }))
162
+
163
+ expect(tag).toContain('tiptapify-skin-inline')
164
+ expect(tag).toContain('tiptapify-shape-toolbar-flat')
165
+ expect(tag).toContain('tiptapify-shape-groups-flat')
166
+ expect(tag).toContain('tiptapify-shape-buttons-quiet')
167
+ expect(tag).toContain('tiptapify-shape-bubble-flat')
168
+ expect(tag).toContain('tiptapify-shape-dialog-soft')
169
+ expect(tag).toContain('tiptapify-shape-menu-soft')
170
+ })
171
+
172
+ it('the toolbar elevation follows the toolbar shape flag, not the skin name', async () => {
173
+ const skin = ref<unknown>({ toolbar: 'flat' })
174
+ const { app, host } = await mountEditor(skin)
175
+
176
+ expect(host.querySelector('.v-toolbar')?.classList.contains('v-toolbar--flat')).toBe(true)
177
+
178
+ skin.value = { toolbar: 'raised' }
179
+ await nextTick()
180
+
181
+ expect(host.querySelector('.v-toolbar')?.classList.contains('v-toolbar--flat')).toBe(false)
182
+
183
+ app.unmount()
184
+ host.remove()
185
+ })
186
+ })
@@ -6,14 +6,24 @@ import { SlashCommandsConfig } from '@tiptapify/types/slashCommandsTypes'
6
6
  import { computed, onBeforeUnmount, PropType, provide, ref, ShallowRef, toRef } from 'vue'
7
7
  import { default as Toolbar } from '@tiptapify/components/Toolbar/Index.vue'
8
8
  import { Editor, EditorContent } from '@tiptap/vue-3'
9
- import { TiptapifyAiOptions, TiptapifyAiResolvedOptions, TiptapifyEditor, TiptapifyFooterAlignment, variantBtnTypes, variantFieldTypes } from '@tiptapify/types/editor'
9
+ import {
10
+ TiptapifyAiOptions,
11
+ TiptapifyAiResolvedOptions,
12
+ TiptapifyEditor,
13
+ TiptapifyFooterAlignment,
14
+ TiptapifySkinInput,
15
+ variantBtnTypes,
16
+ variantFieldTypes
17
+ } from '@tiptapify/types/editor'
10
18
  import MenuBubble from '@tiptapify/components/MenuBubble.vue'
11
19
  import MenuFloating from '@tiptapify/components/MenuFloating.vue'
20
+ import ImageBubbleMenu from '@tiptapify/components/ImageBubbleMenu.vue'
12
21
 
13
22
  import { useI18n } from 'vue-i18n'
14
23
 
15
24
  import { getTiptapEditor } from '@tiptapify/components/index'
16
25
  import { TIPTAPIFY_CONFIG_KEY } from '@tiptapify/composables/useTiptapifyConfig'
26
+ import { resolveSkin } from '@tiptapify/skins/registry'
17
27
  import { createAiBackendProvider } from '@tiptapify/extensions/components/ai/backend'
18
28
 
19
29
  import Footer from '@tiptapify/components/Footer.vue'
@@ -49,6 +59,10 @@ const props = defineProps({
49
59
  loading: { type: Boolean, default() { return false } },
50
60
  loadingColor: { type: String, default() { return 'default' } },
51
61
  loadingHeight: { type: String, default() { return '1px' } },
62
+ // From this many images in a row the editor shows them as a slider grid; 0 turns it off
63
+ imageSlider: { type: Number, default() { return 0 } },
64
+ // The skin of the editor chrome: a registered name ('default', 'pastel') or an inline skin object
65
+ skin: { type: [String, Object] as PropType<TiptapifySkinInput>, default() { return defaults.skin } },
52
66
  })
53
67
 
54
68
  const emit = defineEmits(['update:modelValue', 'editor-ready', 'content-changed'])
@@ -135,14 +149,46 @@ const editor: ShallowRef<Editor | undefined> = getTiptapEditor(
135
149
  getJSON: () => editorInstance.getJSON(),
136
150
  })
137
151
  },
152
+ {
153
+ captionPlaceholder: t('dialog.image.caption_placeholder'),
154
+ sliderMinImages: props.imageSlider,
155
+ sliderLabel: t('media.slider'),
156
+ },
138
157
  )
139
158
 
140
159
  provide('tiptapifyEditor', editor)
160
+
161
+ const scrollContainer = ref<HTMLElement | null>(null)
162
+ provide('tiptapifyScrollContainer', scrollContainer)
141
163
  provide('tiptapifyI18n', { t })
142
164
  provide('tiptapifyAi', tiptapifyAi)
165
+ // The skin prop resolves through the registry; computed refs so a prop change
166
+ // re-resolves tokens and shape without an editor re-render
167
+ const resolvedSkin = computed(() => resolveSkin(props.skin))
168
+
169
+ // The shape classes: `.tiptapify-shape-{flag}-{value}` for all six flags, so CSS gates on
170
+ // shape (not on the skin name); templates read the computed refs. The name class stays
171
+ // as the escape hatch for consumer CSS.
172
+ const shapeClasses = computed(() => {
173
+ const shape = resolvedSkin.value.shape
174
+
175
+ return [
176
+ `tiptapify-shape-toolbar-${shape.toolbar}`,
177
+ `tiptapify-shape-groups-${shape.groups}`,
178
+ `tiptapify-shape-buttons-${shape.buttons}`,
179
+ `tiptapify-shape-bubble-${shape.bubble}`,
180
+ `tiptapify-shape-dialog-${shape.dialog}`,
181
+ `tiptapify-shape-menu-${shape.menu}`,
182
+ ].join(' ')
183
+ })
184
+
143
185
  provide(TIPTAPIFY_CONFIG_KEY, {
144
186
  variantBtn: toRef(props, 'variantBtn'),
145
187
  variantField: toRef(props, 'variantField'),
188
+ skin: toRef(props, 'skin'),
189
+ skinName: computed(() => resolvedSkin.value.name),
190
+ tokens: computed(() => resolvedSkin.value.tokens),
191
+ shape: computed(() => resolvedSkin.value.shape),
146
192
  })
147
193
 
148
194
  editor.value?.chain().setFontFamily(props.defaultFontFamily).run()
@@ -162,8 +208,12 @@ onBeforeUnmount(() => {
162
208
  </script>
163
209
 
164
210
  <template>
165
- <div :id="`tiptapify-editor-${editor?.instanceId}`">
166
- <div :class="`border border-t-0 rounded-t-${rounded} rounded-b-${rounded}`">
211
+ <div
212
+ :id="`tiptapify-editor-${editor?.instanceId}`"
213
+ :class="[resolvedSkin.className, shapeClasses]"
214
+ :style="resolvedSkin.tokens"
215
+ >
216
+ <div :class="`tiptapify-frame rounded-${rounded}`">
167
217
  <template v-if="toolbar">
168
218
  <Toolbar
169
219
  v-if="editor"
@@ -179,11 +229,13 @@ onBeforeUnmount(() => {
179
229
 
180
230
  <VProgressLinear v-model="loadingProgress" :color="loadingColor" :height="loadingHeight" :indeterminate="loading" />
181
231
 
182
- <div class="pa-2 tiptapify-container resizable" :style="computeResizableHeight" @click="onContainerClick">
232
+ <div ref="scrollContainer" class="pa-2 tiptapify-container resizable" :style="computeResizableHeight" @click="onContainerClick">
183
233
  <MenuFloating v-if="floatingMenu" :variant="variantBtn" :theme="currentTheme" />
184
234
 
185
235
  <MenuBubble v-if="bubbleMenu" :variant="variantBtn" :theme="currentTheme" />
186
236
 
237
+ <ImageBubbleMenu />
238
+
187
239
  <EditorContent :editor="editor" class="tiptapify-editor" />
188
240
  </div>
189
241
 
@@ -232,12 +284,38 @@ onBeforeUnmount(() => {
232
284
  --border: 1px solid var(--gray-2);
233
285
  }
234
286
 
287
+ /*
288
+ * The frame: one rule, skin-agnostic, reading the frame tokens (classic fallbacks).
289
+ * The classic shape keeps `border-top: 0`: the raised toolbar sits on the frame's top
290
+ * edge, so no line is needed there. The per-side deltas of the flat shape (full border,
291
+ * bottom edge at 1.6x border opacity) are a property of that shape, not of any one
292
+ * skin, so the gate is the toolbar shape class. `--tf-frame-border` is the shorthand
293
+ * for all sides; the bottom emphasis composes from Vuetify's border-opacity (it is a
294
+ * shading rule, not a catalog token).
295
+ */
296
+ .tiptapify-frame {
297
+ border: var(--tf-frame-border, 1px solid rgba(var(--v-border-color), var(--v-border-opacity)));
298
+ border-top: 0;
299
+ background: var(--tf-frame-bg, transparent);
300
+ box-shadow: var(--tf-frame-shadow, none);
301
+ }
302
+
303
+ .tiptapify-shape-toolbar-flat .tiptapify-frame {
304
+ /* A flat toolbar lives inside the frame: the top edge closes like the other sides... */
305
+ border-top: var(--tf-frame-border, 1px solid rgba(var(--v-border-color), var(--v-border-opacity)));
306
+
307
+ /* ...and the bottom edge carries the frame's visual weight (1.6x border opacity) */
308
+ border-bottom-color: rgba(var(--v-border-color), calc(var(--v-border-opacity) * 1.6));
309
+ }
310
+
235
311
  .resizable {
236
312
  resize: vertical;
237
313
  overflow: auto;
238
314
  }
239
315
 
240
316
  .tiptapify-container {
317
+ /* The containing block of the bubble menus, so they scroll with the text they belong to */
318
+ position: relative;
241
319
  display: flex;
242
320
  flex-direction: column;
243
321
  }
@@ -281,6 +359,8 @@ onBeforeUnmount(() => {
281
359
 
282
360
  /* List styles */
283
361
  ul, ol {
362
+ /* Next to a floated image the markers stay out of it */
363
+ display: flow-root;
284
364
  padding: 0 1rem;
285
365
  margin: 1.25rem 1rem 1.25rem 0.4rem;
286
366
 
@@ -518,91 +598,211 @@ onBeforeUnmount(() => {
518
598
  display: block;
519
599
  }
520
600
 
521
- [data-node="image"].has-focus {
522
- [data-resize-handle] {
523
- position: absolute;
524
- background: rgba(0, 0, 0, 0.5);
525
- border: 1px solid rgba(255, 255, 255, 0.8);
526
- border-radius: 2px;
527
- z-index: 10;
601
+ /* Image with the caption under it (see extensions/components/media/image/caption.ts) */
602
+ .tiptapify-image {
603
+ --tiptapify-image-max-height: 400px;
528
604
 
529
- &:hover {
530
- background: rgba(0, 0, 0, 0.8);
531
- }
605
+ flex-direction: column;
606
+ gap: 4px;
607
+ width: fit-content;
608
+ max-width: 100%;
609
+ margin: 1rem 0;
532
610
 
533
- /* Corner handles */
534
- &[data-resize-handle='top-left'],
535
- &[data-resize-handle='top-right'],
536
- &[data-resize-handle='bottom-left'],
537
- &[data-resize-handle='bottom-right'] {
538
- width: 8px;
539
- height: 8px;
540
- }
611
+ /* Never wider than the text, never taller than the max height: big photos stay out of the way */
612
+ img {
613
+ max-width: 100%;
614
+ height: auto !important;
615
+ margin: 0;
616
+ }
541
617
 
542
- &[data-resize-handle='top-left'] {
543
- top: -4px;
544
- left: -4px;
545
- cursor: nwse-resize;
546
- }
618
+ [data-resize-wrapper] {
619
+ max-width: min(100%, calc(var(--tiptapify-image-max-height) * var(--tiptapify-image-ratio, 100)));
620
+ border-radius: var(--tf-image-radius, 0.125rem);
621
+ outline: 1px solid transparent;
622
+ transition: outline-color 0.15s;
623
+ }
547
624
 
548
- &[data-resize-handle='top-right'] {
549
- top: -4px;
550
- right: -4px;
551
- cursor: nesw-resize;
552
- }
625
+ /* One block: hovering outlines it, a click selects it and shows its toolbar */
626
+ img {
627
+ cursor: pointer;
628
+ border-radius: var(--tf-image-radius, 0px);
629
+ background: var(--tf-image-bg, transparent);
630
+ }
553
631
 
554
- &[data-resize-handle='bottom-left'] {
555
- bottom: -4px;
556
- left: -4px;
557
- cursor: nesw-resize;
558
- }
632
+ &.ProseMirror-selectednode [data-resize-wrapper] {
633
+ outline: var(--tf-image-outline, 1px solid rgba(0, 0, 0, 0.25));
634
+ }
559
635
 
560
- &[data-resize-handle='bottom-right'] {
561
- bottom: -4px;
562
- right: -4px;
563
- cursor: nwse-resize;
564
- }
636
+ /* Centered unless floated; a floated image lets the text wrap around it */
637
+ &:not([data-align]):not(.tiptapify-slide) {
638
+ align-items: center;
639
+ margin-inline: auto;
565
640
 
566
- /* Edge handles */
567
- &[data-resize-handle='top'],
568
- &[data-resize-handle='bottom'] {
569
- height: 6px;
570
- left: 8px;
571
- right: 8px;
641
+ .tiptapify-image-caption {
642
+ text-align: center;
572
643
  }
644
+ }
573
645
 
574
- &[data-resize-handle='top'] {
575
- top: -3px;
576
- cursor: ns-resize;
577
- }
646
+ &[data-align='left'],
647
+ &[data-align='right'] {
648
+ max-width: 50%;
649
+ margin-top: 0.25rem;
650
+ }
578
651
 
579
- &[data-resize-handle='bottom'] {
580
- bottom: -3px;
581
- cursor: ns-resize;
582
- }
652
+ &[data-align='left'] {
653
+ float: left;
654
+ margin-right: 1.25rem;
655
+ }
583
656
 
584
- &[data-resize-handle='left'],
585
- &[data-resize-handle='right'] {
586
- width: 6px;
587
- top: 8px;
588
- bottom: 8px;
589
- }
657
+ &[data-align='right'] {
658
+ float: right;
659
+ margin-left: 1.25rem;
660
+ }
661
+ }
590
662
 
591
- &[data-resize-handle='left'] {
592
- left: -3px;
593
- cursor: ew-resize;
594
- }
663
+ /* Images in a row shown as a slider (extensions/components/media/image/slider.ts): a numbered grid */
664
+ .tiptapify-image.tiptapify-slide {
665
+ position: relative;
666
+ display: inline-flex !important;
667
+ box-sizing: border-box;
668
+ width: calc(100% / 3);
669
+ margin: 0;
670
+ padding: 8px 6px;
671
+ vertical-align: top;
672
+ background: rgba(var(--v-theme-primary), 0.06);
595
673
 
596
- &[data-resize-handle='right'] {
597
- right: -3px;
598
- cursor: ew-resize;
599
- }
674
+ [data-resize-wrapper] {
675
+ width: 100%;
676
+ }
677
+
678
+ img {
679
+ width: 100% !important;
680
+ max-width: none;
681
+ aspect-ratio: 4 / 3;
682
+ object-fit: cover;
683
+ }
684
+
685
+ [data-resize-handle] {
686
+ display: none !important;
687
+ }
688
+
689
+ &::before {
690
+ position: absolute;
691
+ top: 14px;
692
+ left: 12px;
693
+ z-index: 1;
694
+ padding: 0 6px;
695
+ border-radius: 4px;
696
+ background: rgba(0, 0, 0, 0.6);
697
+ color: #fff;
698
+ content: attr(data-slide-label);
699
+ font-size: 12px;
700
+ font-weight: 600;
701
+ line-height: 20px;
702
+ pointer-events: none;
703
+ }
704
+ }
705
+
706
+ &:has(.tiptapify-slide) {
707
+ container-type: inline-size;
708
+ }
709
+
710
+ /* The caret between two slides: a vertical line at the edge of the slide before it */
711
+ .tiptapify-slide + .ProseMirror-gapcursor:has(+ .tiptapify-slide) {
712
+ display: inline-block;
713
+
714
+ &::after {
715
+ top: 8px;
716
+ width: 0;
717
+ height: calc((100cqw / 3 - 12px) * 0.75);
718
+ border-top: 0;
719
+ border-left: 2px solid currentColor;
720
+ }
721
+ }
722
+
723
+ .tiptapify-image-caption {
724
+ width: 100%;
725
+ min-width: 0;
726
+ padding: 2px 0 3px;
727
+ border: 0;
728
+ border-bottom: 1px dashed transparent;
729
+ background: transparent;
730
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
731
+ font: inherit;
732
+ font-size: 0.875em;
733
+ outline: none;
734
+ transition: border-color 0.15s;
735
+
736
+ &::placeholder {
737
+ color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
738
+ }
739
+
740
+ &:hover:not(:disabled),
741
+ &:focus {
742
+ border-bottom-color: rgba(var(--v-border-color), 0.4);
743
+ }
744
+
745
+ &:disabled:placeholder-shown {
746
+ display: none;
600
747
  }
748
+ }
601
749
 
602
- [data-resize-state='true'] [data-resize-wrapper] {
603
- outline: 1px solid rgba(0, 0, 0, 0.25);
604
- border-radius: 0.125rem;
750
+ /* Resize handles of a selected image: dark squares at the corners and edge middles */
751
+ [data-node='image'] [data-resize-handle] {
752
+ display: none;
753
+ }
754
+
755
+ [data-node='image'].ProseMirror-selectednode [data-resize-handle] {
756
+ position: absolute;
757
+ z-index: 10;
758
+ display: block;
759
+ width: 10px;
760
+ height: 10px;
761
+ border: var(--tf-handle-border, none);
762
+ border-radius: var(--tf-handle-radius, 2px);
763
+ background: var(--tf-handle-bg, rgba(0, 0, 0, 0.5));
764
+ box-shadow: var(--tf-handle-shadow, none);
765
+ transition: background-color 0.15s;
766
+
767
+ /* Longhand on purpose: the hover needle in the measurement bundle reads
768
+ * `background-color` from CSSOM, and a shorthand carrying var() has no
769
+ * retrievable longhand (pending-substitution value).
770
+ */
771
+ &:hover {
772
+ background-color: var(--tf-handle-bg-hover, rgba(0, 0, 0, 0.8));
605
773
  }
774
+
775
+ &[data-resize-handle='top-left'] { top: -5px !important; left: -5px !important; cursor: nwse-resize; }
776
+ &[data-resize-handle='top-right'] { top: -5px !important; right: -5px !important; left: auto !important; cursor: nesw-resize; }
777
+ &[data-resize-handle='bottom-left'] { bottom: -5px !important; left: -5px !important; top: auto !important; cursor: nesw-resize; }
778
+ &[data-resize-handle='bottom-right'] { right: -5px !important; bottom: -5px !important; top: auto !important; left: auto !important; cursor: nwse-resize; }
779
+ &[data-resize-handle='top'] { top: -5px !important; left: calc(50% - 5px) !important; right: auto !important; cursor: ns-resize; }
780
+ &[data-resize-handle='bottom'] { bottom: -5px !important; top: auto !important; left: calc(50% - 5px) !important; right: auto !important; cursor: ns-resize; }
781
+ &[data-resize-handle='left'] { left: -5px !important; top: calc(50% - 5px) !important; bottom: auto !important; cursor: ew-resize; }
782
+ &[data-resize-handle='right'] { right: -5px !important; left: auto !important; top: calc(50% - 5px) !important; bottom: auto !important; cursor: ew-resize; }
783
+ }
784
+ }
785
+
786
+ /*
787
+ * The quiet image chrome (shape: `buttons: quiet`): the outline around an image becomes an
788
+ * inset 2px ring (the classic flush 1px hairline is the base rule's fallback) and hovering
789
+ * tints it. Gated on the buttons shape because that is the flag of the 0.3.3 quiet look this
790
+ * structure belongs to; the colors stay tokens (`--tf-image-outline*`), the structural
791
+ * width/offset pair is the shape's base.
792
+ */
793
+ .tiptapify-shape-buttons-quiet .tiptap .tiptapify-image {
794
+ [data-resize-wrapper] {
795
+ outline: 2px solid transparent;
796
+ outline-offset: 2px;
797
+ }
798
+
799
+ &:hover [data-resize-wrapper] {
800
+ outline-color: var(--tf-image-outline-hover, rgba(var(--v-theme-primary), 0.35));
801
+ }
802
+
803
+ &.ProseMirror-selectednode [data-resize-wrapper] {
804
+ outline: var(--tf-image-outline, 2px solid rgb(var(--v-theme-primary)));
805
+ outline-offset: 2px;
606
806
  }
607
807
  }
608
808
 
@@ -622,7 +822,7 @@ onBeforeUnmount(() => {
622
822
  */
623
823
  .tiptapify-dialog-grid {
624
824
  display: grid;
625
- gap: 24px;
825
+ gap: var(--tf-dialog-grid-gap, 24px);
626
826
  grid-template-columns: repeat(12, minmax(0, 1fr));
627
827
 
628
828
  > .tiptapify-dialog-col {
@@ -649,6 +849,17 @@ onBeforeUnmount(() => {
649
849
  }
650
850
  }
651
851
 
852
+ /*
853
+ * The soft dialog grid.
854
+ *
855
+ * The dialog is teleported to the overlay root, so it has no shape class
856
+ * ancestor: the shape reaches it through the dialog's own modifier class,
857
+ * the same way every other soft dialog style applies.
858
+ */
859
+ .tiptapify-dialog--soft .tiptapify-dialog-grid {
860
+ gap: var(--tf-dialog-grid-gap, 16px);
861
+ }
862
+
652
863
  .tiptapify-dialog-actions {
653
864
  display: flex;
654
865
  flex-wrap: wrap;