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,268 @@
1
+ import { Image, ImageOptions } from '@tiptap/extension-image'
2
+ import { Editor, fromString, mergeAttributes, NodeViewRendererProps } from '@tiptap/core'
3
+ import { Node as ProseMirrorNode } from '@tiptap/pm/model'
4
+ import { NodeSelection, TextSelection } from '@tiptap/pm/state'
5
+
6
+ export type ImageAlign = 'left' | 'right'
7
+
8
+ declare module '@tiptap/core' {
9
+ interface Commands<ReturnType> {
10
+ imageWithCaption: {
11
+ // Swaps the selected image with the image right before (-1) or after (1) it, as slides of a slider
12
+ moveImage: (direction: -1 | 1) => ReturnType
13
+ }
14
+ }
15
+ }
16
+
17
+ export interface ImageWithCaptionOptions extends ImageOptions {
18
+ captionPlaceholder: string
19
+ }
20
+
21
+ /**
22
+ * The image with its caption typed right under it, saved as
23
+ * `<figure><img src alt><figcaption>caption</figcaption></figure>`. In the document the caption is
24
+ * the image `title` attribute, so a legacy `<img title>` loads with its title as the caption.
25
+ * The image floats left or right with `align`, saved as `float` in the image style (what sites read
26
+ * from TinyMCE images); without it the image is centered. A double click opens the image dialog.
27
+ */
28
+ export const ImageWithCaption = Image.extend<ImageWithCaptionOptions>({
29
+ addOptions() {
30
+ return {
31
+ ...this.parent?.() as ImageOptions,
32
+ captionPlaceholder: '',
33
+ }
34
+ },
35
+
36
+ addAttributes() {
37
+ return {
38
+ ...this.parent?.(),
39
+ align: {
40
+ default: null,
41
+ parseHTML: (element: HTMLElement) => (['left', 'right'].includes(element.style.float) ? element.style.float : null),
42
+ // Rendered by renderHTML onto the image only: in the editor the node view floats the whole figure
43
+ renderHTML: () => ({}),
44
+ },
45
+ }
46
+ },
47
+
48
+ parseHTML() {
49
+ const { editor, name, options } = this
50
+
51
+ return [
52
+ {
53
+ tag: 'figure',
54
+ getAttrs: (figure: HTMLElement) => {
55
+ const img = figure.querySelector('img[src]')
56
+ if (!img || (!options.allowBase64 && img.getAttribute('src')!.startsWith('data:'))) {
57
+ return false
58
+ }
59
+
60
+ const attrs = parseImageAttributes(editor as Editor, name, img as HTMLElement)
61
+ const caption = figure.querySelector('figcaption')?.textContent?.trim()
62
+
63
+ return { ...attrs, title: caption || attrs.title || null }
64
+ },
65
+ },
66
+ ...(this.parent?.() ?? []),
67
+ ]
68
+ },
69
+
70
+ renderHTML({ node, HTMLAttributes }) {
71
+ const align = node.attrs.align as ImageAlign | null
72
+ const { title, ...imageAttributes } = mergeAttributes(
73
+ this.options.HTMLAttributes,
74
+ HTMLAttributes,
75
+ align ? { style: `float: ${align}` } : {},
76
+ )
77
+ const figure: [string, Record<string, unknown>, ...unknown[]] = ['figure', {}, ['img', imageAttributes]]
78
+ if (title) {
79
+ figure.push(['figcaption', {}, String(title)])
80
+ }
81
+
82
+ return figure as never
83
+ },
84
+
85
+ addCommands() {
86
+ return {
87
+ ...this.parent?.(),
88
+ moveImage: (direction: -1 | 1) => ({ state, tr, dispatch }) => {
89
+ const { selection } = state
90
+ if (!(selection instanceof NodeSelection) || selection.node.type.name !== this.name) {
91
+ return false
92
+ }
93
+
94
+ const $image = state.doc.resolve(selection.from)
95
+ const siblingIndex = $image.index() + direction
96
+ if (siblingIndex < 0 || siblingIndex >= $image.parent.childCount) {
97
+ return false
98
+ }
99
+
100
+ const sibling = $image.parent.child(siblingIndex)
101
+ if (sibling.type !== selection.node.type) {
102
+ return false
103
+ }
104
+
105
+ if (dispatch) {
106
+ const image = selection.node
107
+ if (direction < 0) {
108
+ const from = selection.from - sibling.nodeSize
109
+ tr.replaceWith(from, selection.to, [image, sibling])
110
+ tr.setSelection(NodeSelection.create(tr.doc, from))
111
+ } else {
112
+ tr.replaceWith(selection.from, selection.to + sibling.nodeSize, [sibling, image])
113
+ tr.setSelection(NodeSelection.create(tr.doc, selection.from + sibling.nodeSize))
114
+ }
115
+ dispatch(tr.scrollIntoView())
116
+ }
117
+
118
+ return true
119
+ },
120
+ }
121
+ },
122
+
123
+ addNodeView() {
124
+ const createResizableView = this.parent?.()
125
+ if (!createResizableView) {
126
+ return null
127
+ }
128
+
129
+ const { captionPlaceholder } = this.options
130
+
131
+ return (props: NodeViewRendererProps) => {
132
+ const nodeView = createResizableView(props) as ReturnType<typeof createResizableView> & {
133
+ dom: HTMLElement
134
+ update: (node: ProseMirrorNode, ...args: unknown[]) => boolean
135
+ stopEvent?: (event: Event) => boolean
136
+ ignoreMutation?: (mutation: MutationRecord) => boolean
137
+ }
138
+ const editor = props.editor as Editor
139
+ const getPos = props.getPos as () => number | undefined
140
+ let node = props.node
141
+
142
+ const caption = document.createElement('input')
143
+ caption.type = 'text'
144
+ caption.className = 'tiptapify-image-caption'
145
+ caption.size = 1
146
+ caption.placeholder = captionPlaceholder
147
+ caption.value = node.attrs.title ?? ''
148
+ caption.disabled = !editor.isEditable
149
+
150
+
151
+ const wrapper = nodeView.dom.querySelector('[data-resize-wrapper]') ?? nodeView.dom
152
+ const syncAlign = (align: ImageAlign | null) => {
153
+ if (align) {
154
+ nodeView.dom.dataset.align = align
155
+ } else {
156
+ delete nodeView.dom.dataset.align
157
+ }
158
+ }
159
+ syncAlign(node.attrs.align)
160
+
161
+ // The aspect ratio lets CSS cap a tall image by its height without distorting it
162
+ const img = nodeView.dom.querySelector('img') as HTMLImageElement
163
+ const setAspectRatio = () => {
164
+ if (img.naturalWidth && img.naturalHeight) {
165
+ nodeView.dom.style.setProperty('--tiptapify-image-ratio', String(img.naturalWidth / img.naturalHeight))
166
+ }
167
+ }
168
+ img.addEventListener('load', setAspectRatio)
169
+ setAspectRatio()
170
+ nodeView.dom.classList.add('tiptapify-image')
171
+ nodeView.dom.append(caption)
172
+
173
+ const openDialog = () => {
174
+ const pos = getPos()
175
+ if (pos === undefined) {
176
+ return
177
+ }
178
+
179
+ // The dialog reads the selected image, so the selection must be applied before it opens
180
+ editor.commands.setNodeSelection(pos)
181
+ editor.commands.showTiptapifyImage()
182
+ }
183
+
184
+ const saveCaption = () => {
185
+ const pos = getPos()
186
+ const title = caption.value.trim() === '' ? null : caption.value
187
+ if (pos === undefined || title === (node.attrs.title ?? null)) {
188
+ return
189
+ }
190
+
191
+ editor.view.dispatch(editor.state.tr.setNodeAttribute(pos, 'title', title))
192
+ }
193
+
194
+ // Enter leaves the caption for the text after the image
195
+ const leaveCaption = () => {
196
+ const pos = getPos()
197
+ if (pos === undefined) {
198
+ return
199
+ }
200
+
201
+ const after = pos + node.nodeSize
202
+ const { state } = editor
203
+ const selection = TextSelection.near(state.doc.resolve(Math.min(after, state.doc.content.size)))
204
+ editor.view.dispatch(state.tr.setSelection(selection))
205
+ editor.view.focus()
206
+ }
207
+
208
+ caption.addEventListener('input', saveCaption)
209
+ caption.addEventListener('keydown', (event: KeyboardEvent) => {
210
+ if (event.key === 'Enter' || event.key === 'Escape') {
211
+ event.preventDefault()
212
+ leaveCaption()
213
+ }
214
+ })
215
+ // Selecting caption text with the mouse must not start dragging the whole image
216
+ caption.addEventListener('mousedown', () => {
217
+ nodeView.dom.draggable = false
218
+ })
219
+ caption.addEventListener('blur', () => {
220
+ nodeView.dom.draggable = true
221
+ })
222
+
223
+ wrapper.addEventListener('dblclick', openDialog)
224
+
225
+ const updateResizableView = nodeView.update.bind(nodeView)
226
+ nodeView.update = (updatedNode: ProseMirrorNode, ...args: unknown[]) => {
227
+ const updated = updateResizableView(updatedNode, ...args)
228
+ if (updated) {
229
+ node = updatedNode
230
+ const title = updatedNode.attrs.title ?? ''
231
+ if (caption.value !== title && document.activeElement !== caption) {
232
+ caption.value = title
233
+ }
234
+ caption.disabled = !editor.isEditable
235
+ syncAlign(updatedNode.attrs.align)
236
+ }
237
+
238
+ return updated
239
+ }
240
+ nodeView.stopEvent = (event: Event) => caption.contains(event.target as Node)
241
+ nodeView.ignoreMutation = (mutation: MutationRecord) => caption.contains(mutation.target)
242
+ || (mutation.type === 'attributes' && mutation.target === nodeView.dom)
243
+
244
+ return nodeView
245
+ }
246
+ },
247
+ })
248
+
249
+ /**
250
+ * The image attributes of a `<figure>` are read from its `<img>`, each one the way its extension
251
+ * reads it from an `<img>` (global attributes such as a media id included).
252
+ */
253
+ function parseImageAttributes(editor: Editor, typeName: string, img: HTMLElement): Record<string, unknown> {
254
+ const attributes: Record<string, unknown> = {}
255
+
256
+ for (const { type, name, attribute } of editor.extensionManager.attributes) {
257
+ if (type !== typeName) {
258
+ continue
259
+ }
260
+
261
+ const value = attribute.parseHTML ? attribute.parseHTML(img) : fromString(img.getAttribute(name))
262
+ if (value !== null && value !== undefined) {
263
+ attributes[name] = value
264
+ }
265
+ }
266
+
267
+ return attributes
268
+ }
@@ -0,0 +1,106 @@
1
+ import { afterEach, 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 { Gapcursor } from '@tiptap/extensions'
7
+ import { TiptapifyImage } from '@tiptapify/extensions/components/media/image'
8
+ import { ImageWithCaption } from '@tiptapify/extensions/components/media/image/caption'
9
+ import { findImageRuns, ImageSlider } from '@tiptapify/extensions/components/media/image/slider'
10
+
11
+ let editor: Editor | null = null
12
+
13
+ function createEditor(content: string, minImages = 3) {
14
+ editor = new Editor({
15
+ element: document.createElement('div'),
16
+ content,
17
+ extensions: [
18
+ Document,
19
+ Paragraph,
20
+ Text,
21
+ Gapcursor,
22
+ TiptapifyImage,
23
+ ImageWithCaption.configure({ resize: { enabled: true, alwaysPreserveAspectRatio: true } }),
24
+ ImageSlider.configure({ minImages, label: 'Slider' }),
25
+ ],
26
+ })
27
+
28
+ return editor
29
+ }
30
+
31
+ const img = (name: string) => `<img src="https://example.com/${name}.png">`
32
+
33
+ function slideLabels(): Array<string | null> {
34
+ return [...editor!.view.dom.querySelectorAll('.tiptapify-image')]
35
+ .map(image => (image.classList.contains('tiptapify-slide') ? image.getAttribute('data-slide-label') : null))
36
+ }
37
+
38
+ describe('image slider', () => {
39
+ afterEach(() => {
40
+ editor?.destroy()
41
+ editor = null
42
+ })
43
+
44
+ it('finds runs of images with nothing between them', () => {
45
+ createEditor(`${img('a')}${img('b')}${img('c')}<p>text</p>${img('d')}${img('e')}`)
46
+
47
+ expect(findImageRuns(editor!.state.doc, 'image', 2).map(run => run.length)).toEqual([3, 2])
48
+ expect(findImageRuns(editor!.state.doc, 'image', 3).map(run => run.length)).toEqual([3])
49
+ })
50
+
51
+ it('numbers the slides of a run that reaches the minimum', () => {
52
+ createEditor(`<p>text</p>${img('a')}${img('b')}${img('c')}${img('d')}<p>text</p>${img('e')}${img('f')}`)
53
+
54
+ expect(slideLabels()).toEqual(['Slider · 1/4', '2', '3', '4', null, null])
55
+ })
56
+
57
+ it('uses the configured minimum', () => {
58
+ createEditor(`${img('a')}${img('b')}<p>text</p>${img('c')}`, 2)
59
+
60
+ expect(slideLabels()).toEqual(['Slider · 1/2', '2', null])
61
+ })
62
+
63
+ it('stops the slider when text splits the run', () => {
64
+ createEditor(`${img('a')}${img('b')}${img('c')}`)
65
+ editor!.chain().insertContentAt(editor!.state.doc.child(0).nodeSize, '<p>text</p>').run()
66
+
67
+ expect(slideLabels()).toEqual([null, null, null])
68
+ })
69
+
70
+ it('keeps the slides while an image is selected', () => {
71
+ createEditor(`${img('a')}${img('b')}${img('c')}`)
72
+ editor!.commands.setNodeSelection(0)
73
+ editor!.commands.focus()
74
+
75
+ expect(slideLabels()).toEqual(['Slider · 1/3', '2', '3'])
76
+ })
77
+
78
+ it('does nothing when the minimum is below two', () => {
79
+ createEditor(`${img('a')}${img('b')}${img('c')}`, 0)
80
+
81
+ expect(slideLabels()).toEqual([null, null, null])
82
+ })
83
+
84
+ it('swaps a slide with the next and the previous one', () => {
85
+ createEditor(`${img('a')}${img('b')}${img('c')}`)
86
+ const sources = () => editor!.getJSON().content!.map(node => node.attrs!.src.slice(-5))
87
+
88
+ editor!.commands.setNodeSelection(0)
89
+ expect(editor!.commands.moveImage(1)).toBe(true)
90
+ expect(sources()).toEqual(['b.png', 'a.png', 'c.png'])
91
+ expect(editor!.getAttributes('image').src).toContain('a.png')
92
+
93
+ expect(editor!.commands.moveImage(-1)).toBe(true)
94
+ expect(sources()).toEqual(['a.png', 'b.png', 'c.png'])
95
+ })
96
+
97
+ it('does not move an image past the end of its run', () => {
98
+ createEditor(`<p>text</p>${img('a')}${img('b')}`)
99
+ const first = editor!.state.doc.child(0).nodeSize
100
+
101
+ editor!.commands.setNodeSelection(first)
102
+
103
+ expect(editor!.can().moveImage(-1)).toBe(false)
104
+ expect(editor!.can().moveImage(1)).toBe(true)
105
+ })
106
+ })
@@ -0,0 +1,87 @@
1
+ import { Extension } from '@tiptap/core'
2
+ import { Node as ProseMirrorNode } from '@tiptap/pm/model'
3
+ import { Plugin, PluginKey } from '@tiptap/pm/state'
4
+ import { Decoration, DecorationSet } from '@tiptap/pm/view'
5
+
6
+ export interface ImageSliderOptions {
7
+ // How many images in a row make a slider; below 2 the extension does nothing
8
+ minImages: number
9
+ label: string
10
+ }
11
+
12
+ export type ImageRun = Array<{ from: number, to: number }>
13
+
14
+ const name = 'tiptapifyImageSlider'
15
+
16
+ /**
17
+ * Runs of at least `minImages` top-level images with nothing between them.
18
+ */
19
+ export function findImageRuns(doc: ProseMirrorNode, typeName: string, minImages: number): ImageRun[] {
20
+ const runs: ImageRun[] = []
21
+ let run: ImageRun = []
22
+
23
+ const flush = () => {
24
+ if (run.length >= minImages) {
25
+ runs.push(run)
26
+ }
27
+ run = []
28
+ }
29
+
30
+ doc.forEach((node, offset) => {
31
+ if (node.type.name !== typeName) {
32
+ flush()
33
+
34
+ return
35
+ }
36
+
37
+ run.push({ from: offset, to: offset + node.nodeSize })
38
+ })
39
+ flush()
40
+
41
+ return runs
42
+ }
43
+
44
+ /**
45
+ * Images in a row that a site shows as a slider: the editor lays them out as a numbered grid
46
+ * (the `tiptapify-slide` classes) so the author sees the slider while writing.
47
+ */
48
+ export const ImageSlider = Extension.create<ImageSliderOptions>({
49
+ name,
50
+
51
+ addOptions() {
52
+ return {
53
+ minImages: 0,
54
+ label: 'Slider',
55
+ }
56
+ },
57
+
58
+ addProseMirrorPlugins() {
59
+ const { minImages, label } = this.options
60
+ if (minImages < 2) {
61
+ return []
62
+ }
63
+
64
+ const decorate = (doc: ProseMirrorNode) => DecorationSet.create(
65
+ doc,
66
+ findImageRuns(doc, 'image', minImages).flatMap(run => run.map(({ from, to }, index) => Decoration.node(from, to, {
67
+ class: index === 0 ? 'tiptapify-slide tiptapify-slide--first' : 'tiptapify-slide',
68
+ 'data-slide-label': index === 0 ? `${label} · 1/${run.length}` : String(index + 1),
69
+ }))),
70
+ )
71
+
72
+ return [
73
+ new Plugin({
74
+ key: new PluginKey(name),
75
+ state: {
76
+ init: (_, { doc }) => decorate(doc),
77
+ apply: (tr, decorations) => (tr.docChanged ? decorate(tr.doc) : decorations),
78
+ },
79
+ props: {
80
+ decorations(state) {
81
+ return this.getState(state)
82
+ },
83
+ },
84
+ }),
85
+ ]
86
+ },
87
+ })
@@ -4,8 +4,10 @@ import * as mdi from '@mdi/js'
4
4
  import { Level } from '@tiptap/extension-heading'
5
5
  import { Editor } from '@tiptap/vue-3'
6
6
  import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
7
+ import SkinScope from '@tiptapify/components/SkinScope.vue'
7
8
  import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
8
9
  import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
10
+ import { menuContentClass } from '@tiptapify/skins/menu'
9
11
  import { inject, Ref } from 'vue'
10
12
 
11
13
  const props = defineProps({
@@ -14,7 +16,7 @@ const props = defineProps({
14
16
  inline: { type: Boolean, default: () => false },
15
17
  })
16
18
 
17
- const { variantBtn } = useTiptapifyConfig()
19
+ const { variantBtn, shape } = useTiptapifyConfig()
18
20
 
19
21
  interface MDIIcons { [key: string]: string }
20
22
  const mdiIcons = mdi as MDIIcons
@@ -26,6 +28,13 @@ const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
26
28
  import { headingLevels, setHeadingLevels } from '@tiptapify/constants/style'
27
29
  import { ComposerTranslation } from 'vue-i18n'
28
30
  setHeadingLevels(props.customHeadingLevels)
31
+
32
+ // A function, not a computed: `isActive` reads the editor state, which Vue does not track
33
+ function currentStyleIcon(): string {
34
+ const activeLevel = headingLevels.value.find(level => editor.value.isActive('heading', { level }))
35
+
36
+ return activeLevel ? `mdiFormatHeader${activeLevel}` : 'mdiFormatParagraph'
37
+ }
29
38
  </script>
30
39
 
31
40
  <template>
@@ -56,44 +65,50 @@ setHeadingLevels(props.customHeadingLevels)
56
65
  <template v-else>
57
66
  <VBtn
58
67
  :id="`tiptapify-heading-button-${editor.instanceId}`"
68
+ class="tiptapify-dropdown-button"
59
69
  :color="editor.isActive('heading') ? 'primary' : ''"
60
70
  :variant="variantBtn"
61
- size="32"
62
- @click="editor.chain().focus().redo().run()"
71
+ height="32"
63
72
  >
64
73
  <Tooltip :label="t('style.heading')" hotkey="Mod-Alt-1..6" />
65
- <BtnIcon :icon="`mdiSvg:${mdi.mdiFormatHeaderPound}`" />
74
+ <BtnIcon :icon="`mdiSvg:${mdiIcons[currentStyleIcon()]}`" />
75
+ <BtnIcon :icon="`mdiSvg:${mdi.mdiChevronDown}`" class="tiptapify-dropdown-button__chevron" />
66
76
  </VBtn>
67
77
 
68
- <VMenu :activator="`#tiptapify-heading-button-${editor.instanceId}`">
69
- <VList density="compact">
70
- <VListItem
71
- v-if="withParagraph"
72
- link
73
- :color="editor.isActive('paragraph') ? 'primary' : ''"
74
- @click="editor.chain().focus().setParagraph().run()"
75
- >
76
- <Tooltip :label="t('style.paragraph')" hotkey="Mod-Alt-0" />
77
- <VListItemTitle class="d-flex justify-center align-center">
78
- <BtnIcon :icon="`mdiSvg:${mdiIcons['mdiFormatParagraph']}`" />
79
- </VListItemTitle>
80
- </VListItem>
81
- <VListItem
82
- v-for="headingLevel in headingLevels"
83
- :key="headingLevel"
84
- :color="editor.isActive('heading', { level: headingLevel }) ? 'primary' : ''"
85
- @click="editor.chain().focus().toggleHeading({ level: headingLevel }).run()"
86
- >
87
- <Tooltip :label="t(`style.headings.h${headingLevel}`)" :hotkey="`Mod-Alt-${headingLevel}`" />
88
- <VListItemTitle class="d-flex justify-center align-center">
89
- <BtnIcon :icon="`mdiSvg:${mdiIcons[`mdiFormatHeader${headingLevel}`]}`" />
90
- </VListItemTitle>
91
- </VListItem>
92
- </VList>
78
+ <VMenu :activator="`#tiptapify-heading-button-${editor.instanceId}`" :content-class="menuContentClass(shape.menu === 'soft')">
79
+ <SkinScope>
80
+ <VList density="compact">
81
+ <VListItem
82
+ v-if="withParagraph"
83
+ link
84
+ :active="editor.isActive('paragraph')"
85
+ @click="editor.chain().focus().setParagraph().run()"
86
+ >
87
+ <Tooltip :label="t('style.paragraph')" hotkey="Mod-Alt-0" />
88
+ <template #prepend>
89
+ <BtnIcon :icon="`mdiSvg:${mdiIcons['mdiFormatParagraph']}`" />
90
+ </template>
91
+ <VListItemTitle>{{ t('style.paragraph') }}</VListItemTitle>
92
+ </VListItem>
93
+ <VListItem
94
+ v-for="headingLevel in headingLevels"
95
+ :key="headingLevel"
96
+ link
97
+ :active="editor.isActive('heading', { level: headingLevel })"
98
+ @click="editor.chain().focus().toggleHeading({ level: headingLevel }).run()"
99
+ >
100
+ <Tooltip :label="t(`style.headings.h${headingLevel}`)" :hotkey="`Mod-Alt-${headingLevel}`" />
101
+ <template #prepend>
102
+ <BtnIcon :icon="`mdiSvg:${mdiIcons[`mdiFormatHeader${headingLevel}`]}`" />
103
+ </template>
104
+ <VListItemTitle>{{ t(`style.headings.h${headingLevel}`) }}</VListItemTitle>
105
+ </VListItem>
106
+ </VList>
107
+ </SkinScope>
93
108
  </VMenu>
94
109
  </template>
95
110
  </template>
96
111
 
97
112
  <style lang="scss" scoped>
98
113
 
99
- </style>
114
+ </style>
@@ -65,6 +65,8 @@
65
65
  },
66
66
  "image": {
67
67
  "alt": "نص بديل",
68
+ "caption": "التعليق",
69
+ "caption_placeholder": "أضف تعليقًا…",
68
70
  "dialog_title": "إضافة/تعديل صورة",
69
71
  "height": "الارتفاع",
70
72
  "keep_ratio": "الحفاظ على نسبة العرض إلى الارتفاع",
@@ -121,6 +123,16 @@
121
123
  "indent": "زيادة المسافة البادئة",
122
124
  "numbered": "قائمة مرقمة",
123
125
  "outdent": "تقليل المسافة البادئة",
126
+ "styles": {
127
+ "circle": "Circle",
128
+ "default": "Default",
129
+ "lower_alpha": "Lower alpha",
130
+ "lower_greek": "Lower Greek",
131
+ "lower_roman": "Lower Roman",
132
+ "square": "Square",
133
+ "upper_alpha": "Upper alpha",
134
+ "upper_roman": "Upper Roman"
135
+ },
124
136
  "task": "قائمة مهام"
125
137
  },
126
138
  "media": {
@@ -161,7 +173,17 @@
161
173
  },
162
174
  "iframe": "إطار مضمن",
163
175
  "image": "صورة",
176
+ "image_toolbar": {
177
+ "align_left": "Align left",
178
+ "align_right": "Align right",
179
+ "delete": "Remove",
180
+ "download": "Download",
181
+ "edit": "Edit or replace",
182
+ "move_back": "Move one slide back",
183
+ "move_forward": "Move one slide forward"
184
+ },
164
185
  "link": "رابط خارجي",
186
+ "slider": "شرائح",
165
187
  "tables": {
166
188
  "col": "عمود",
167
189
  "cols": "أعمدة",
@@ -65,6 +65,8 @@
65
65
  },
66
66
  "image": {
67
67
  "alt": "Алтернативен текст",
68
+ "caption": "Надпис",
69
+ "caption_placeholder": "Добавете надпис…",
68
70
  "dialog_title": "Добави/редактирай изображение",
69
71
  "height": "Височина",
70
72
  "keep_ratio": "Пазене на пропорцията",
@@ -121,6 +123,16 @@
121
123
  "indent": "Увеличаване на отстъпа на елемент от списъка",
122
124
  "numbered": "Нумериран списък",
123
125
  "outdent": "Намаляване на отстъпа на елемент от списъка",
126
+ "styles": {
127
+ "circle": "Circle",
128
+ "default": "Default",
129
+ "lower_alpha": "Lower alpha",
130
+ "lower_greek": "Lower Greek",
131
+ "lower_roman": "Lower Roman",
132
+ "square": "Square",
133
+ "upper_alpha": "Upper alpha",
134
+ "upper_roman": "Upper Roman"
135
+ },
124
136
  "task": "Списък със задачи"
125
137
  },
126
138
  "media": {
@@ -161,7 +173,17 @@
161
173
  },
162
174
  "iframe": "iframe",
163
175
  "image": "Изображение",
176
+ "image_toolbar": {
177
+ "align_left": "Align left",
178
+ "align_right": "Align right",
179
+ "delete": "Remove",
180
+ "download": "Download",
181
+ "edit": "Edit or replace",
182
+ "move_back": "Move one slide back",
183
+ "move_forward": "Move one slide forward"
184
+ },
164
185
  "link": "Външен линк",
186
+ "slider": "Слайдер",
165
187
  "tables": {
166
188
  "col": "Колона",
167
189
  "cols": "Колони",