tiptapify 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tiptapify.css +1 -1
- package/dist/tiptapify.mjs +54124 -48070
- package/dist/tiptapify.umd.js +59 -59
- package/index.d.ts +1 -0
- package/package.json +1 -1
- package/src/components/Footer.vue +12 -2
- package/src/components/ImageBubbleMenu.vue +130 -0
- package/src/components/MenuBubble.vue +36 -21
- package/src/components/Tiptapify.vue +189 -72
- package/src/components/Toolbar/Index.test.ts +96 -0
- package/src/components/Toolbar/Index.vue +225 -14
- package/src/components/Toolbar/Items.test.ts +49 -3
- package/src/components/Toolbar/Items.vue +25 -26
- package/src/components/Toolbar/alignment.ts +5 -0
- package/src/components/UI/TiptapifyDialog.vue +78 -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/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 +68 -0
- package/src/extensions/components/list/bullet/Button.vue +16 -1
- 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 +75 -35
- 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
|
@@ -17,6 +17,15 @@ defineExpose({ open, close })
|
|
|
17
17
|
const emits = defineEmits(['closeDialog'])
|
|
18
18
|
|
|
19
19
|
const showDialog = ref<boolean>(false)
|
|
20
|
+
|
|
21
|
+
const field = { variant: 'outlined', density: 'comfortable' }
|
|
22
|
+
const fieldDefaults = {
|
|
23
|
+
VTextField: field,
|
|
24
|
+
VTextarea: field,
|
|
25
|
+
VSelect: field,
|
|
26
|
+
VAutocomplete: field,
|
|
27
|
+
VCombobox: field,
|
|
28
|
+
}
|
|
20
29
|
const movableHandler = ref(null)
|
|
21
30
|
|
|
22
31
|
function open() {
|
|
@@ -141,48 +150,105 @@ onBeforeUnmount(() => {
|
|
|
141
150
|
|
|
142
151
|
<template>
|
|
143
152
|
<VDialog v-model="showDialog" :max-width="maxWidth" :fullscreen="fullscreen" @click:outside="emitClose">
|
|
144
|
-
<VCard>
|
|
153
|
+
<VCard class="tiptapify-dialog" :class="{ 'tiptapify-dialog--fullscreen': fullscreen }">
|
|
145
154
|
<VCardTitle
|
|
146
155
|
ref="movableHandler"
|
|
147
156
|
class="tiptapify-dialog-header"
|
|
148
157
|
:class="!fullscreen ? 'tiptapify-movable-handler' : ''"
|
|
149
158
|
style="user-select: none;"
|
|
150
159
|
>
|
|
151
|
-
<
|
|
160
|
+
<span class="tiptapify-dialog-title">
|
|
152
161
|
{{ title ?? t(`dialog.${module}.title`) }}
|
|
153
|
-
</
|
|
162
|
+
</span>
|
|
154
163
|
|
|
155
164
|
<VSpacer />
|
|
156
165
|
|
|
157
|
-
<VBtn
|
|
158
|
-
<VIcon size="
|
|
166
|
+
<VBtn class="tiptapify-dialog-close" variant="text" size="32" icon @click="emitClose">
|
|
167
|
+
<VIcon size="20" :icon="`mdiSvg:${mdi.mdiClose}`" />
|
|
159
168
|
</VBtn>
|
|
160
169
|
</VCardTitle>
|
|
161
170
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
171
|
+
<!-- Fields a dialog does not style itself get the light outlined look -->
|
|
172
|
+
<VDefaultsProvider :defaults="fieldDefaults">
|
|
173
|
+
<slot name="content" />
|
|
165
174
|
|
|
166
|
-
|
|
175
|
+
<slot name="actions" />
|
|
176
|
+
</VDefaultsProvider>
|
|
167
177
|
</VCard>
|
|
168
178
|
</VDialog>
|
|
169
179
|
</template>
|
|
170
180
|
|
|
171
181
|
<style lang="scss" scoped>
|
|
182
|
+
/* A light card in the spirit of Flux modals: generous padding, soft corners and shadow, no rules */
|
|
183
|
+
.tiptapify-dialog {
|
|
184
|
+
border-radius: 12px !important;
|
|
185
|
+
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.16), 0 2px 6px rgba(0, 0, 0, 0.06) !important;
|
|
186
|
+
|
|
187
|
+
&.tiptapify-dialog--fullscreen {
|
|
188
|
+
border-radius: 0 !important;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
:deep(.v-card-text) {
|
|
192
|
+
padding: 4px 20px !important;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
:deep(.v-field--variant-outlined) {
|
|
196
|
+
border-radius: 8px;
|
|
197
|
+
|
|
198
|
+
.v-field__outline__start {
|
|
199
|
+
border-start-start-radius: 8px;
|
|
200
|
+
border-end-start-radius: 8px;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.v-field__outline__end {
|
|
204
|
+
border-start-end-radius: 8px;
|
|
205
|
+
border-end-end-radius: 8px;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
:deep(.v-card-actions) {
|
|
210
|
+
gap: 8px;
|
|
211
|
+
padding: 16px 20px 20px !important;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
:deep(.v-card-actions .v-btn),
|
|
215
|
+
:deep(.tiptapify-dialog-actions .v-btn) {
|
|
216
|
+
min-height: 38px;
|
|
217
|
+
padding-inline: 16px;
|
|
218
|
+
border-radius: 8px;
|
|
219
|
+
font-weight: 500;
|
|
220
|
+
letter-spacing: normal;
|
|
221
|
+
text-transform: none;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
172
225
|
.tiptapify-dialog-header {
|
|
173
|
-
align-items: center;
|
|
174
226
|
display: flex;
|
|
227
|
+
align-items: center;
|
|
228
|
+
padding: 16px 12px 12px 20px !important;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.tiptapify-dialog-title {
|
|
232
|
+
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
|
233
|
+
font-size: 1.125rem;
|
|
234
|
+
font-weight: 600;
|
|
235
|
+
line-height: 1.4;
|
|
236
|
+
white-space: normal;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.tiptapify-dialog-close {
|
|
240
|
+
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
|
175
241
|
}
|
|
176
242
|
|
|
177
243
|
:deep(.v-overlay__content) {
|
|
178
244
|
position: fixed;
|
|
179
245
|
}
|
|
180
246
|
|
|
181
|
-
.tiptapify-movable-handler
|
|
247
|
+
.tiptapify-movable-handler {
|
|
182
248
|
cursor: move;
|
|
183
249
|
}
|
|
184
250
|
|
|
185
251
|
.tiptapify-dialog-close:hover {
|
|
186
252
|
cursor: pointer;
|
|
187
253
|
}
|
|
188
|
-
</style>
|
|
254
|
+
</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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/components/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Editor, useEditor } from '@tiptap/vue-3'
|
|
2
|
-
import { editorExtensions } from '@tiptapify/components/editorExtensions'
|
|
2
|
+
import { editorExtensions, TiptapifyImageOptions } from '@tiptapify/components/editorExtensions'
|
|
3
3
|
import { toolbarSections } from '@tiptapify/types/toolbarTypes'
|
|
4
4
|
import { SlashCommandsConfig } from '@tiptapify/types/slashCommandsTypes'
|
|
5
5
|
import { ShallowRef } from 'vue'
|
|
@@ -12,8 +12,9 @@ export function getTiptapEditor (
|
|
|
12
12
|
limit: number | null = null,
|
|
13
13
|
onUpdate: Function = () => {},
|
|
14
14
|
onCreate?: (editor: Editor) => void,
|
|
15
|
+
imageOptions?: TiptapifyImageOptions,
|
|
15
16
|
): ShallowRef<Editor | undefined> {
|
|
16
|
-
const extensions = editorExtensions(placeholder, slashCommands, customExtensions, limit)
|
|
17
|
+
const extensions = editorExtensions(placeholder, slashCommands, customExtensions, limit, imageOptions)
|
|
17
18
|
|
|
18
19
|
return useEditor({
|
|
19
20
|
content,
|
|
@@ -26,7 +26,7 @@ const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
|
|
|
26
26
|
@click="editor.chain().focus().redo().run()"
|
|
27
27
|
>
|
|
28
28
|
<Tooltip :label="t('action.redo')" hotkey="Mod-Shift-z" />
|
|
29
|
-
<BtnIcon :icon="`mdiSvg:${mdi.
|
|
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.
|
|
29
|
+
<BtnIcon :icon="`mdiSvg:${mdi.mdiUndoVariant}`" />
|
|
30
30
|
</VBtn>
|
|
31
31
|
</template>
|
|
32
32
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
|
|
3
|
+
import * as mdi from '@mdi/js'
|
|
4
|
+
import { Editor } from '@tiptap/vue-3'
|
|
5
|
+
import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
|
|
6
|
+
import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
|
|
7
|
+
import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
|
|
8
|
+
import { inject, PropType, Ref } from 'vue'
|
|
9
|
+
|
|
10
|
+
import { ComposerTranslation } from 'vue-i18n'
|
|
11
|
+
|
|
12
|
+
type Alignment = 'left' | 'center' | 'right' | 'justify'
|
|
13
|
+
|
|
14
|
+
const props = defineProps({
|
|
15
|
+
alignments: { type: Array as PropType<Alignment[]>, default: () => ['left', 'center', 'right', 'justify'] },
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const { variantBtn } = useTiptapifyConfig()
|
|
19
|
+
|
|
20
|
+
const editor = inject('tiptapifyEditor') as Ref<Editor>
|
|
21
|
+
|
|
22
|
+
const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
|
|
23
|
+
|
|
24
|
+
const alignmentOptions: Record<Alignment, { icon: string, hotkey: string }> = {
|
|
25
|
+
left: { icon: mdi.mdiFormatAlignLeft, hotkey: 'Mod-Shift-l' },
|
|
26
|
+
center: { icon: mdi.mdiFormatAlignCenter, hotkey: 'Mod-Shift-e' },
|
|
27
|
+
right: { icon: mdi.mdiFormatAlignRight, hotkey: 'Mod-Shift-r' },
|
|
28
|
+
justify: { icon: mdi.mdiFormatAlignJustify, hotkey: 'Mod-Shift-j' },
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A function, not a computed: `isActive` reads the editor state, which Vue does not track
|
|
32
|
+
function currentAlignmentIcon(): string {
|
|
33
|
+
const active = props.alignments.find(alignment => editor.value.isActive({ textAlign: alignment }))
|
|
34
|
+
|
|
35
|
+
return alignmentOptions[active ?? props.alignments[0] ?? 'left'].icon
|
|
36
|
+
}
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<VBtn
|
|
41
|
+
:id="`tiptapify-align-button-${editor.instanceId}`"
|
|
42
|
+
class="tiptapify-dropdown-button"
|
|
43
|
+
:variant="variantBtn"
|
|
44
|
+
height="32"
|
|
45
|
+
>
|
|
46
|
+
<Tooltip :label="t('alignment')" />
|
|
47
|
+
<BtnIcon :icon="`mdiSvg:${currentAlignmentIcon()}`" />
|
|
48
|
+
<BtnIcon :icon="`mdiSvg:${mdi.mdiChevronDown}`" class="tiptapify-dropdown-button__chevron" />
|
|
49
|
+
</VBtn>
|
|
50
|
+
|
|
51
|
+
<VMenu :activator="`#tiptapify-align-button-${editor.instanceId}`" content-class="tiptapify-menu">
|
|
52
|
+
<VList density="compact">
|
|
53
|
+
<VListItem
|
|
54
|
+
v-for="alignment in alignments"
|
|
55
|
+
:key="alignment"
|
|
56
|
+
link
|
|
57
|
+
:active="editor.isActive({ textAlign: alignment })"
|
|
58
|
+
@click="editor.chain().focus().toggleTextAlign(alignment).run()"
|
|
59
|
+
>
|
|
60
|
+
<Tooltip :label="t(`alignments.${alignment}`)" :hotkey="alignmentOptions[alignment].hotkey" />
|
|
61
|
+
<template #prepend>
|
|
62
|
+
<BtnIcon :icon="`mdiSvg:${alignmentOptions[alignment].icon}`" />
|
|
63
|
+
</template>
|
|
64
|
+
<VListItemTitle>{{ t(`alignments.${alignment}`) }}</VListItemTitle>
|
|
65
|
+
</VListItem>
|
|
66
|
+
</VList>
|
|
67
|
+
</VMenu>
|
|
68
|
+
</template>
|
|
@@ -8,8 +8,12 @@ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
|
|
|
8
8
|
import { computed, inject, ref, Ref, watch } from 'vue'
|
|
9
9
|
|
|
10
10
|
import { ComposerTranslation } from 'vue-i18n'
|
|
11
|
+
import ListStyleButton from '@tiptapify/extensions/components/list/styles/ListStyleButton.vue'
|
|
12
|
+
import { bulletListStyles } from '@tiptapify/extensions/components/list/styles'
|
|
11
13
|
|
|
12
14
|
const props = defineProps({
|
|
15
|
+
// The marker styles dropdown (`bulletList:styles`) instead of the separate list types below
|
|
16
|
+
withStyles: { type: Boolean, default: false },
|
|
13
17
|
withDisc: { type: Boolean, default: true },
|
|
14
18
|
withCircle: { type: Boolean, default: true },
|
|
15
19
|
withSquare: { type: Boolean, default: true }
|
|
@@ -83,7 +87,18 @@ watch(() => bulletLists.value, () => {
|
|
|
83
87
|
</script>
|
|
84
88
|
|
|
85
89
|
<template>
|
|
86
|
-
<
|
|
90
|
+
<ListStyleButton
|
|
91
|
+
v-if="withStyles"
|
|
92
|
+
list-type="bulletList"
|
|
93
|
+
:icon="mdi.mdiFormatListBulleted"
|
|
94
|
+
:label="t('lists.bullet')"
|
|
95
|
+
hotkey="Mod-Shift-8"
|
|
96
|
+
:presets="bulletListStyles"
|
|
97
|
+
:disabled="!editor.can().chain().focus().toggleBulletList().run()"
|
|
98
|
+
@toggle="editor.commands.toggleBulletList()"
|
|
99
|
+
/>
|
|
100
|
+
|
|
101
|
+
<VMenu v-else-if="isMenu" content-class="tiptapify-menu">
|
|
87
102
|
<template #activator="{ props: menuProps }">
|
|
88
103
|
<VBtn
|
|
89
104
|
:color="buttonActive ? 'primary' : ''"
|
|
@@ -8,6 +8,13 @@ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
|
|
|
8
8
|
import { computed, inject, Ref } from 'vue'
|
|
9
9
|
|
|
10
10
|
import { ComposerTranslation } from 'vue-i18n'
|
|
11
|
+
import ListStyleButton from '@tiptapify/extensions/components/list/styles/ListStyleButton.vue'
|
|
12
|
+
import { orderedListStyles } from '@tiptapify/extensions/components/list/styles'
|
|
13
|
+
|
|
14
|
+
defineProps({
|
|
15
|
+
// The numbering styles dropdown (`orderedList:styles`)
|
|
16
|
+
withStyles: { type: Boolean, default: false },
|
|
17
|
+
})
|
|
11
18
|
|
|
12
19
|
const { variantBtn } = useTiptapifyConfig()
|
|
13
20
|
|
|
@@ -25,7 +32,19 @@ const buttonDisabled = computed(() => {
|
|
|
25
32
|
</script>
|
|
26
33
|
|
|
27
34
|
<template>
|
|
35
|
+
<ListStyleButton
|
|
36
|
+
v-if="withStyles"
|
|
37
|
+
list-type="orderedList"
|
|
38
|
+
:icon="mdi.mdiFormatListNumbered"
|
|
39
|
+
:label="t('lists.numbered')"
|
|
40
|
+
hotkey="Mod-Shift-7"
|
|
41
|
+
:presets="orderedListStyles"
|
|
42
|
+
:disabled="buttonDisabled"
|
|
43
|
+
@toggle="editor.commands.toggleOrderedList()"
|
|
44
|
+
/>
|
|
45
|
+
|
|
28
46
|
<VBtn
|
|
47
|
+
v-else
|
|
29
48
|
:color="editor.isActive('orderedList') ? 'primary' : ''"
|
|
30
49
|
:disabled="buttonDisabled"
|
|
31
50
|
:variant="variantBtn"
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
|
|
3
|
+
import * as mdi from '@mdi/js'
|
|
4
|
+
import { Editor } from '@tiptap/vue-3'
|
|
5
|
+
import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
|
|
6
|
+
import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
|
|
7
|
+
import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
|
|
8
|
+
import { ListStylePreset, ListType } from '@tiptapify/extensions/components/list/styles'
|
|
9
|
+
import { inject, PropType, Ref } from 'vue'
|
|
10
|
+
import { ComposerTranslation } from 'vue-i18n'
|
|
11
|
+
|
|
12
|
+
// A list button with a dropdown of marker styles next to it, like TinyMCE's advlist
|
|
13
|
+
const props = defineProps({
|
|
14
|
+
listType: { type: String as PropType<ListType>, required: true },
|
|
15
|
+
icon: { type: String, required: true },
|
|
16
|
+
label: { type: String, required: true },
|
|
17
|
+
hotkey: { type: String, default: '' },
|
|
18
|
+
presets: { type: Array as PropType<ListStylePreset[]>, required: true },
|
|
19
|
+
disabled: { type: Boolean, default: false },
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const emit = defineEmits(['toggle'])
|
|
23
|
+
|
|
24
|
+
const { variantBtn } = useTiptapifyConfig()
|
|
25
|
+
|
|
26
|
+
const editor = inject('tiptapifyEditor') as Ref<Editor>
|
|
27
|
+
|
|
28
|
+
const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
|
|
29
|
+
|
|
30
|
+
function isPresetActive(preset: ListStylePreset): boolean {
|
|
31
|
+
return editor.value.isActive(props.listType) && (editor.value.getAttributes(props.listType).listStyleType ?? null) === preset.value
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function presetLabel(preset: ListStylePreset): string {
|
|
35
|
+
return t(`lists.styles.${(preset.value ?? 'default').replace('-', '_')}`)
|
|
36
|
+
}
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<div class="tiptapify-list-style-button">
|
|
41
|
+
<VBtn
|
|
42
|
+
:color="editor.isActive(listType) ? 'primary' : ''"
|
|
43
|
+
:disabled="disabled"
|
|
44
|
+
:variant="variantBtn"
|
|
45
|
+
size="32"
|
|
46
|
+
@click="emit('toggle')"
|
|
47
|
+
>
|
|
48
|
+
<Tooltip :label="label" :hotkey="hotkey" />
|
|
49
|
+
<BtnIcon :icon="`mdiSvg:${icon}`" />
|
|
50
|
+
</VBtn>
|
|
51
|
+
|
|
52
|
+
<VMenu content-class="tiptapify-menu">
|
|
53
|
+
<template #activator="{ props: menuProps }">
|
|
54
|
+
<VBtn
|
|
55
|
+
class="tiptapify-list-style-button__chevron"
|
|
56
|
+
:disabled="disabled"
|
|
57
|
+
:variant="variantBtn"
|
|
58
|
+
height="32"
|
|
59
|
+
width="16"
|
|
60
|
+
v-bind="menuProps"
|
|
61
|
+
>
|
|
62
|
+
<BtnIcon :icon="`mdiSvg:${mdi.mdiChevronDown}`" />
|
|
63
|
+
</VBtn>
|
|
64
|
+
</template>
|
|
65
|
+
|
|
66
|
+
<div class="tiptapify-list-style-grid">
|
|
67
|
+
<button
|
|
68
|
+
v-for="preset in presets"
|
|
69
|
+
:key="preset.value ?? 'default'"
|
|
70
|
+
type="button"
|
|
71
|
+
class="tiptapify-list-style-grid__item"
|
|
72
|
+
:class="{ 'tiptapify-list-style-grid__item--active': isPresetActive(preset) }"
|
|
73
|
+
:title="presetLabel(preset)"
|
|
74
|
+
:aria-label="presetLabel(preset)"
|
|
75
|
+
@click="editor.chain().focus().setListStyle(listType, preset.value).run()"
|
|
76
|
+
>
|
|
77
|
+
<span v-for="(marker, index) in preset.markers" :key="index" class="tiptapify-list-style-grid__line">
|
|
78
|
+
<span class="tiptapify-list-style-grid__marker">{{ marker }}</span>
|
|
79
|
+
<span class="tiptapify-list-style-grid__bar" />
|
|
80
|
+
</span>
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
</VMenu>
|
|
84
|
+
</div>
|
|
85
|
+
</template>
|
|
86
|
+
|
|
87
|
+
<style lang="scss" scoped>
|
|
88
|
+
.tiptapify-list-style-button {
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
|
|
92
|
+
:deep(.tiptapify-list-style-button__chevron) {
|
|
93
|
+
min-width: 16px !important;
|
|
94
|
+
padding: 0 !important;
|
|
95
|
+
|
|
96
|
+
.v-icon {
|
|
97
|
+
width: 16px;
|
|
98
|
+
height: 16px;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
</style>
|
|
103
|
+
|
|
104
|
+
<style lang="scss">
|
|
105
|
+
/* Teleported with the menu, so not scoped */
|
|
106
|
+
.tiptapify-list-style-grid {
|
|
107
|
+
display: grid;
|
|
108
|
+
grid-template-columns: repeat(3, 64px);
|
|
109
|
+
gap: 6px;
|
|
110
|
+
padding: 6px;
|
|
111
|
+
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
112
|
+
border-radius: 8px;
|
|
113
|
+
background: rgb(var(--v-theme-surface));
|
|
114
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.tiptapify-list-style-grid__item {
|
|
118
|
+
display: flex;
|
|
119
|
+
flex-direction: column;
|
|
120
|
+
gap: 4px;
|
|
121
|
+
padding: 8px 6px;
|
|
122
|
+
border: 1px solid transparent;
|
|
123
|
+
border-radius: 6px;
|
|
124
|
+
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
|
125
|
+
cursor: pointer;
|
|
126
|
+
|
|
127
|
+
&:hover {
|
|
128
|
+
background: rgba(var(--v-theme-on-surface), 0.05);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
&--active {
|
|
132
|
+
border-color: rgb(var(--v-theme-primary));
|
|
133
|
+
background: rgba(var(--v-theme-primary), 0.06);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.tiptapify-list-style-grid__line {
|
|
138
|
+
display: flex;
|
|
139
|
+
align-items: center;
|
|
140
|
+
gap: 4px;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.tiptapify-list-style-grid__marker {
|
|
144
|
+
min-width: 16px;
|
|
145
|
+
font-size: 10px;
|
|
146
|
+
line-height: 10px;
|
|
147
|
+
text-align: right;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.tiptapify-list-style-grid__bar {
|
|
151
|
+
flex: 1;
|
|
152
|
+
height: 4px;
|
|
153
|
+
border-radius: 2px;
|
|
154
|
+
background: rgba(var(--v-theme-on-surface), 0.18);
|
|
155
|
+
}
|
|
156
|
+
</style>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Extension } from '@tiptap/core'
|
|
2
|
+
|
|
3
|
+
export type ListType = 'bulletList' | 'orderedList'
|
|
4
|
+
|
|
5
|
+
export interface ListStylePreset {
|
|
6
|
+
// CSS `list-style-type`; null is the browser default (disc / decimal)
|
|
7
|
+
value: string | null
|
|
8
|
+
markers: [string, string, string]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const bulletListStyles: ListStylePreset[] = [
|
|
12
|
+
{ value: null, markers: ['●', '●', '●'] },
|
|
13
|
+
{ value: 'circle', markers: ['○', '○', '○'] },
|
|
14
|
+
{ value: 'square', markers: ['■', '■', '■'] },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
export const orderedListStyles: ListStylePreset[] = [
|
|
18
|
+
{ value: null, markers: ['1.', '2.', '3.'] },
|
|
19
|
+
{ value: 'lower-alpha', markers: ['a.', 'b.', 'c.'] },
|
|
20
|
+
{ value: 'lower-greek', markers: ['α.', 'β.', 'γ.'] },
|
|
21
|
+
{ value: 'lower-roman', markers: ['i.', 'ii.', 'iii.'] },
|
|
22
|
+
{ value: 'upper-alpha', markers: ['A.', 'B.', 'C.'] },
|
|
23
|
+
{ value: 'upper-roman', markers: ['I.', 'II.', 'III.'] },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
declare module '@tiptap/core' {
|
|
27
|
+
interface Commands<ReturnType> {
|
|
28
|
+
listStyle: {
|
|
29
|
+
// Turns the selection into a list of this type, if it is not one yet, with this marker style
|
|
30
|
+
setListStyle: (listType: ListType, listStyleType: string | null) => ReturnType
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The marker style of bullet and numbered lists, saved as inline `list-style-type` the way TinyMCE
|
|
37
|
+
* did, so lists styled there keep their style and every site renders it without extra CSS.
|
|
38
|
+
*/
|
|
39
|
+
export const ListStyle = Extension.create({
|
|
40
|
+
name: 'listStyle',
|
|
41
|
+
|
|
42
|
+
addGlobalAttributes() {
|
|
43
|
+
return [
|
|
44
|
+
{
|
|
45
|
+
types: ['bulletList', 'orderedList'],
|
|
46
|
+
attributes: {
|
|
47
|
+
listStyleType: {
|
|
48
|
+
default: null,
|
|
49
|
+
parseHTML: (element: HTMLElement) => element.style.listStyleType || null,
|
|
50
|
+
renderHTML: (attributes: Record<string, unknown>) => (
|
|
51
|
+
attributes.listStyleType ? { style: `list-style-type: ${attributes.listStyleType}` } : {}
|
|
52
|
+
),
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
addCommands() {
|
|
60
|
+
return {
|
|
61
|
+
setListStyle: (listType: ListType, listStyleType: string | null) => ({ editor, chain }) => {
|
|
62
|
+
const command = chain().focus()
|
|
63
|
+
|
|
64
|
+
if (!editor.isActive(listType)) {
|
|
65
|
+
if (listType === 'bulletList') {
|
|
66
|
+
command.toggleBulletList()
|
|
67
|
+
} else {
|
|
68
|
+
command.toggleOrderedList()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return command.updateAttributes(listType, { listStyleType }).run()
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { Editor } from '@tiptap/vue-3'
|
|
3
|
+
import { editorExtensions } from '@tiptapify/components/editorExtensions'
|
|
4
|
+
|
|
5
|
+
let editor: Editor | null = null
|
|
6
|
+
|
|
7
|
+
function createEditor(content: string) {
|
|
8
|
+
editor = new Editor({ element: document.createElement('div'), content, extensions: editorExtensions('', false, []) })
|
|
9
|
+
|
|
10
|
+
return editor
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('list styles', () => {
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
editor?.destroy()
|
|
16
|
+
editor = null
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('turns a paragraph into a numbered list with the chosen numbering', () => {
|
|
20
|
+
createEditor('<p>one</p>')
|
|
21
|
+
editor!.commands.setTextSelection(2)
|
|
22
|
+
|
|
23
|
+
editor!.commands.setListStyle('orderedList', 'lower-roman')
|
|
24
|
+
|
|
25
|
+
expect(editor!.getHTML()).toBe('<ol style="list-style-type: lower-roman;"><li><p>one</p></li></ol>')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('changes the marker of an existing bullet list and resets it to the default', () => {
|
|
29
|
+
createEditor('<ul><li><p>one</p></li></ul>')
|
|
30
|
+
editor!.commands.setTextSelection(3)
|
|
31
|
+
|
|
32
|
+
editor!.commands.setListStyle('bulletList', 'square')
|
|
33
|
+
expect(editor!.getHTML()).toContain('<ul style="list-style-type: square;">')
|
|
34
|
+
|
|
35
|
+
editor!.commands.setListStyle('bulletList', null)
|
|
36
|
+
expect(editor!.getHTML()).toBe('<ul><li><p>one</p></li></ul>')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('reads the list style TinyMCE saved', () => {
|
|
40
|
+
createEditor('<ol style="list-style-type: upper-alpha;"><li><p>one</p></li></ol>')
|
|
41
|
+
|
|
42
|
+
expect(editor!.getJSON().content![0].attrs).toMatchObject({ listStyleType: 'upper-alpha' })
|
|
43
|
+
})
|
|
44
|
+
})
|