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.
- package/README.md +1 -0
- package/dist/tiptapify.css +1 -1
- package/dist/tiptapify.mjs +54477 -48112
- package/dist/tiptapify.umd.js +68 -68
- package/index.d.ts +96 -0
- package/package.json +2 -1
- package/src/components/Footer.vue +12 -2
- package/src/components/ImageBubbleMenu.vue +139 -0
- package/src/components/MenuBubble.vue +60 -7
- package/src/components/SkinScope.test.ts +74 -0
- package/src/components/SkinScope.vue +18 -0
- package/src/components/Tiptapify.skin.test.ts +186 -0
- package/src/components/Tiptapify.vue +285 -74
- package/src/components/Toolbar/Index.test.ts +96 -0
- package/src/components/Toolbar/Index.vue +199 -14
- package/src/components/Toolbar/Items.test.ts +82 -2
- package/src/components/Toolbar/Items.vue +104 -19
- package/src/components/Toolbar/alignment.ts +5 -0
- package/src/components/UI/TiptapifyDialog.test.ts +132 -0
- package/src/components/UI/TiptapifyDialog.vue +128 -12
- package/src/components/editorExtensions.test.ts +10 -0
- package/src/components/editorExtensions.ts +34 -5
- package/src/components/index.ts +3 -2
- package/src/composables/useTiptapifyConfig.test.ts +25 -2
- package/src/composables/useTiptapifyConfig.ts +21 -2
- package/src/constants/defaults.ts +2 -1
- package/src/extensions/components/actions/redo/Button.vue +1 -1
- package/src/extensions/components/actions/undo/Button.vue +1 -1
- package/src/extensions/components/alignment/menu/Button.vue +72 -0
- package/src/extensions/components/list/bullet/Button.vue +35 -16
- package/src/extensions/components/list/numbered/Button.vue +19 -0
- package/src/extensions/components/list/styles/ListStyleButton.vue +156 -0
- package/src/extensions/components/list/styles/index.ts +76 -0
- package/src/extensions/components/list/styles/listStyle.test.ts +44 -0
- package/src/extensions/components/media/image/ImageDialog.vue +1 -1
- package/src/extensions/components/media/image/caption.test.ts +122 -0
- package/src/extensions/components/media/image/caption.ts +268 -0
- package/src/extensions/components/media/image/slider.test.ts +106 -0
- package/src/extensions/components/media/image/slider.ts +87 -0
- package/src/extensions/components/style/heading/Button.vue +45 -30
- package/src/i18n/locales/ar.json +22 -0
- package/src/i18n/locales/bg.json +22 -0
- package/src/i18n/locales/ch.json +22 -0
- package/src/i18n/locales/cz.json +22 -0
- package/src/i18n/locales/de.json +22 -0
- package/src/i18n/locales/el.json +22 -0
- package/src/i18n/locales/en.json +23 -1
- package/src/i18n/locales/es.json +22 -0
- package/src/i18n/locales/et.json +22 -0
- package/src/i18n/locales/fi.json +22 -0
- package/src/i18n/locales/fr.json +22 -0
- package/src/i18n/locales/he.json +22 -0
- package/src/i18n/locales/hi.json +22 -0
- package/src/i18n/locales/hr.json +22 -0
- package/src/i18n/locales/hu.json +22 -0
- package/src/i18n/locales/id.json +22 -0
- package/src/i18n/locales/it.json +22 -0
- package/src/i18n/locales/ja.json +22 -0
- package/src/i18n/locales/ko.json +22 -0
- package/src/i18n/locales/la.json +22 -0
- package/src/i18n/locales/lt.json +22 -0
- package/src/i18n/locales/nl.json +22 -0
- package/src/i18n/locales/no.json +22 -0
- package/src/i18n/locales/pl.json +22 -0
- package/src/i18n/locales/pt-BR.json +22 -0
- package/src/i18n/locales/pt.json +22 -0
- package/src/i18n/locales/ro.json +22 -0
- package/src/i18n/locales/ru.json +23 -1
- package/src/i18n/locales/se.json +22 -0
- package/src/i18n/locales/sk.json +22 -0
- package/src/i18n/locales/sl.json +22 -0
- package/src/i18n/locales/sr.json +22 -0
- package/src/i18n/locales/th.json +22 -0
- package/src/i18n/locales/tr.json +22 -0
- package/src/i18n/locales/uk.json +23 -1
- package/src/i18n/locales/vi.json +22 -0
- package/src/index.test.ts +88 -0
- package/src/index.ts +15 -0
- package/src/skins/__fixtures__/skin-baseline.json +805 -0
- package/src/skins/builtins.ts +81 -0
- package/src/skins/menu.css +87 -0
- package/src/skins/menu.ts +10 -0
- package/src/skins/registry.test.ts +180 -0
- package/src/skins/registry.ts +132 -0
- package/src/skins/tokens.reachability.test.ts +43 -0
- package/src/skins/tokens.ts +92 -0
- 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
|
-
|
|
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:${
|
|
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
|
-
<
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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>
|
package/src/i18n/locales/ar.json
CHANGED
|
@@ -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": "أعمدة",
|
package/src/i18n/locales/bg.json
CHANGED
|
@@ -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": "Колони",
|