frappe-ui 0.1.160 → 0.1.161
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/package.json +1 -1
- package/src/components/TextEditor/InsertImage.vue +27 -5
- package/src/components/TextEditor/TextEditor.vue +32 -27
- package/src/components/TextEditor/extensions/image/image-extension.ts +11 -9
- package/src/components/TextEditor/extensions/image-group/ImageGroupNodeView.vue +216 -0
- package/src/components/TextEditor/extensions/image-group/ImageGroupUploadDialog.vue +738 -0
- package/src/components/TextEditor/extensions/image-group/image-group-extension.ts +115 -0
- package/src/components/TextEditor/video-extension.ts +4 -5
- package/src/index.ts +2 -2
- package/src/utils/config.ts +36 -0
- package/src/utils/{dayjs.js → dayjs.ts} +4 -4
- package/src/utils/useFileUpload.ts +23 -4
- package/src/utils/config.js +0 -9
package/package.json
CHANGED
|
@@ -6,17 +6,29 @@
|
|
|
6
6
|
class="hidden"
|
|
7
7
|
@change="onImageSelect"
|
|
8
8
|
accept="image/*"
|
|
9
|
+
multiple
|
|
10
|
+
/>
|
|
11
|
+
<ImageGroupUploadDialog
|
|
12
|
+
v-if="showModal"
|
|
13
|
+
mode="new"
|
|
14
|
+
v-model="showModal"
|
|
15
|
+
v-model:files="selectedFiles"
|
|
16
|
+
:editor="editor"
|
|
17
|
+
@close="handleCancel"
|
|
9
18
|
/>
|
|
10
19
|
</template>
|
|
11
20
|
<script setup lang="ts">
|
|
12
|
-
import { useTemplateRef } from 'vue'
|
|
21
|
+
import { ref, useTemplateRef } from 'vue'
|
|
13
22
|
import type { Editor } from '@tiptap/vue-3'
|
|
23
|
+
import ImageGroupUploadDialog from './extensions/image-group/ImageGroupUploadDialog.vue'
|
|
14
24
|
|
|
15
25
|
const props = defineProps<{
|
|
16
26
|
editor: Editor
|
|
17
27
|
}>()
|
|
18
28
|
|
|
19
|
-
const fileInput = useTemplateRef('fileInput')
|
|
29
|
+
const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
|
|
30
|
+
const showModal = ref(false)
|
|
31
|
+
const selectedFiles = ref<File[]>([])
|
|
20
32
|
|
|
21
33
|
function openFileSelector() {
|
|
22
34
|
fileInput.value?.click()
|
|
@@ -24,9 +36,19 @@ function openFileSelector() {
|
|
|
24
36
|
|
|
25
37
|
function onImageSelect(e: Event) {
|
|
26
38
|
const target = e.target as HTMLInputElement
|
|
27
|
-
const
|
|
28
|
-
if (
|
|
29
|
-
|
|
39
|
+
const files = target.files
|
|
40
|
+
if (files && files.length > 0) {
|
|
41
|
+
if (files.length === 1) {
|
|
42
|
+
props.editor.chain().focus().uploadImage(files[0]).run()
|
|
43
|
+
} else {
|
|
44
|
+
selectedFiles.value = Array.from(files)
|
|
45
|
+
showModal.value = true
|
|
46
|
+
}
|
|
30
47
|
}
|
|
31
48
|
}
|
|
49
|
+
|
|
50
|
+
function handleCancel() {
|
|
51
|
+
showModal.value = false
|
|
52
|
+
selectedFiles.value = []
|
|
53
|
+
}
|
|
32
54
|
</script>
|
|
@@ -20,40 +20,47 @@
|
|
|
20
20
|
</template>
|
|
21
21
|
|
|
22
22
|
<script lang="ts">
|
|
23
|
-
import
|
|
23
|
+
import { normalizeClass, computed, PropType } from 'vue'
|
|
24
|
+
import { Editor, EditorContent, VueNodeViewRenderer } from '@tiptap/vue-3'
|
|
25
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
24
26
|
import Placeholder from '@tiptap/extension-placeholder'
|
|
27
|
+
import TextAlign from '@tiptap/extension-text-align'
|
|
25
28
|
import Table from '@tiptap/extension-table'
|
|
26
29
|
import TableCell from '@tiptap/extension-table-cell'
|
|
27
30
|
import TableHeader from '@tiptap/extension-table-header'
|
|
28
31
|
import TableRow from '@tiptap/extension-table-row'
|
|
29
|
-
import TextAlign from '@tiptap/extension-text-align'
|
|
30
|
-
import TextStyle from '@tiptap/extension-text-style'
|
|
31
|
-
import Typography from '@tiptap/extension-typography'
|
|
32
|
-
import StarterKit from '@tiptap/starter-kit'
|
|
33
|
-
import { Editor, EditorContent, VueNodeViewRenderer } from '@tiptap/vue-3'
|
|
34
|
-
import { common, createLowlight } from 'lowlight'
|
|
35
|
-
import { DOMParser } from 'prosemirror-model'
|
|
36
|
-
import { computed, normalizeClass } from 'vue'
|
|
37
|
-
import { detectMarkdown, markdownToHTML } from '../../utils/markdown'
|
|
38
|
-
import { useFileUpload } from '../../utils/useFileUpload'
|
|
39
|
-
import CodeBlockComponent from './CodeBlockComponent.vue'
|
|
40
|
-
import NamedColorExtension from './extensions/color'
|
|
41
|
-
import EmojiExtension from './extensions/emoji/emoji-extension'
|
|
42
|
-
import { Heading } from './extensions/heading/heading'
|
|
43
|
-
import NamedHighlightExtension from './extensions/highlight'
|
|
44
32
|
import { ImageExtension } from './extensions/image'
|
|
45
|
-
import SlashCommands from './extensions/slash-commands/slash-commands-extension'
|
|
46
|
-
import { TagExtension, TagNode } from './extensions/tag/tag-extension'
|
|
47
33
|
import ImageViewerExtension from './image-viewer-extension'
|
|
34
|
+
import VideoExtension from './video-extension'
|
|
48
35
|
import LinkExtension from './link-extension'
|
|
36
|
+
import Typography from '@tiptap/extension-typography'
|
|
37
|
+
import TextStyle from '@tiptap/extension-text-style'
|
|
38
|
+
import NamedColorExtension from './extensions/color'
|
|
39
|
+
import NamedHighlightExtension from './extensions/highlight'
|
|
40
|
+
import { common, createLowlight } from 'lowlight'
|
|
41
|
+
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
|
|
42
|
+
import CodeBlockComponent from './CodeBlockComponent.vue'
|
|
49
43
|
import configureMention from './mention'
|
|
50
|
-
import TextEditorBubbleMenu from './TextEditorBubbleMenu.vue'
|
|
51
44
|
import TextEditorFixedMenu from './TextEditorFixedMenu.vue'
|
|
45
|
+
import TextEditorBubbleMenu from './TextEditorBubbleMenu.vue'
|
|
52
46
|
import TextEditorFloatingMenu from './TextEditorFloatingMenu.vue'
|
|
53
|
-
import
|
|
47
|
+
import EmojiExtension from './extensions/emoji/emoji-extension'
|
|
48
|
+
import SlashCommands from './extensions/slash-commands/slash-commands-extension'
|
|
49
|
+
import { detectMarkdown, markdownToHTML } from '../../utils/markdown'
|
|
50
|
+
import { DOMParser } from 'prosemirror-model'
|
|
51
|
+
import { TagNode, TagExtension } from './extensions/tag/tag-extension'
|
|
52
|
+
import { Heading } from './extensions/heading/heading'
|
|
53
|
+
import { ImageGroup } from './extensions/image-group/image-group-extension'
|
|
54
|
+
import { useFileUpload } from '../../utils/useFileUpload'
|
|
54
55
|
|
|
55
56
|
const lowlight = createLowlight(common)
|
|
56
57
|
|
|
58
|
+
function defaultUploadFunction(file: File) {
|
|
59
|
+
// useFileUpload is frappe specific
|
|
60
|
+
let fileUpload = useFileUpload()
|
|
61
|
+
return fileUpload.upload(file)
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
export default {
|
|
58
65
|
name: 'TextEditor',
|
|
59
66
|
inheritAttrs: false,
|
|
@@ -113,13 +120,8 @@ export default {
|
|
|
113
120
|
default: () => [],
|
|
114
121
|
},
|
|
115
122
|
uploadFunction: {
|
|
116
|
-
type: Function
|
|
117
|
-
default:
|
|
118
|
-
let fileUpload = useFileUpload()
|
|
119
|
-
return fileUpload.upload(file).then((fileDoc: any) => {
|
|
120
|
-
return { src: fileDoc.file_url }
|
|
121
|
-
})
|
|
122
|
-
},
|
|
123
|
+
type: Function as PropType<typeof defaultUploadFunction>,
|
|
124
|
+
default: defaultUploadFunction,
|
|
123
125
|
},
|
|
124
126
|
},
|
|
125
127
|
emits: ['change', 'focus', 'blur'],
|
|
@@ -193,6 +195,9 @@ export default {
|
|
|
193
195
|
ImageExtension.configure({
|
|
194
196
|
uploadFunction: this.uploadFunction,
|
|
195
197
|
}),
|
|
198
|
+
ImageGroup.configure({
|
|
199
|
+
uploadFunction: this.uploadFunction,
|
|
200
|
+
}),
|
|
196
201
|
ImageViewerExtension,
|
|
197
202
|
VideoExtension.configure({
|
|
198
203
|
uploadFunction: this.uploadFunction,
|
|
@@ -9,15 +9,14 @@ import { Plugin, Selection, Transaction, EditorState } from 'prosemirror-state'
|
|
|
9
9
|
import { EditorView } from 'prosemirror-view'
|
|
10
10
|
import { Node } from '@tiptap/pm/model'
|
|
11
11
|
import { fileToBase64 } from '../../../../index'
|
|
12
|
+
import { UploadedFile } from '../../../../utils/useFileUpload'
|
|
12
13
|
|
|
13
14
|
export interface ImageExtensionOptions {
|
|
14
15
|
/**
|
|
15
16
|
* Function to handle image uploads
|
|
16
17
|
* @default null
|
|
17
18
|
*/
|
|
18
|
-
uploadFunction:
|
|
19
|
-
| ((file: File) => Promise<{ src: string; [key: string]: any }>)
|
|
20
|
-
| null
|
|
19
|
+
uploadFunction: ((file: File) => Promise<UploadedFile>) | null
|
|
21
20
|
|
|
22
21
|
/**
|
|
23
22
|
* HTML attributes to add to the image element
|
|
@@ -414,27 +413,30 @@ function uploadImageBase(
|
|
|
414
413
|
|
|
415
414
|
return options.uploadFunction(file)
|
|
416
415
|
})
|
|
417
|
-
.then((uploadedImage:
|
|
418
|
-
return getImageDimensions(uploadedImage.
|
|
416
|
+
.then((uploadedImage: UploadedFile) => {
|
|
417
|
+
return getImageDimensions(uploadedImage.file_url)
|
|
419
418
|
.then((dimensions) => {
|
|
420
419
|
return {
|
|
421
420
|
...uploadedImage,
|
|
422
421
|
width: dimensions.width,
|
|
423
422
|
height: dimensions.height,
|
|
424
|
-
}
|
|
423
|
+
} as UploadedFile & { width: number; height: number }
|
|
425
424
|
})
|
|
426
425
|
.catch(() => {
|
|
427
|
-
return uploadedImage
|
|
426
|
+
return uploadedImage as UploadedFile & {
|
|
427
|
+
width: number
|
|
428
|
+
height: number
|
|
429
|
+
}
|
|
428
430
|
})
|
|
429
431
|
})
|
|
430
|
-
.then((uploadedImage
|
|
432
|
+
.then((uploadedImage) => {
|
|
431
433
|
const transaction = view.state.tr
|
|
432
434
|
|
|
433
435
|
view.state.doc.descendants((node, pos) => {
|
|
434
436
|
if (node.type.name === 'image' && node.attrs.uploadId === uploadId) {
|
|
435
437
|
transaction.setNodeMarkup(pos, undefined, {
|
|
436
438
|
...node.attrs,
|
|
437
|
-
src: uploadedImage.
|
|
439
|
+
src: uploadedImage.file_url,
|
|
438
440
|
width: uploadedImage.width || node.attrs.width,
|
|
439
441
|
height: uploadedImage.height || node.attrs.height,
|
|
440
442
|
loading: false,
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<NodeViewWrapper>
|
|
3
|
+
<div class="w-full">
|
|
4
|
+
<div v-if="isEditable" class="flex items-center mb-2 gap-2">
|
|
5
|
+
<Button @click="edit">
|
|
6
|
+
<template #prefix>
|
|
7
|
+
<LucideEdit class="size-4" />
|
|
8
|
+
</template>
|
|
9
|
+
Edit
|
|
10
|
+
</Button>
|
|
11
|
+
<Select
|
|
12
|
+
:options="selectOptions"
|
|
13
|
+
v-model="internalColumns"
|
|
14
|
+
size="sm"
|
|
15
|
+
variant="subtle"
|
|
16
|
+
class="w-28"
|
|
17
|
+
/>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="grid gap-px" :style="gridStyle">
|
|
20
|
+
<div
|
|
21
|
+
v-for="(img, idx) in images"
|
|
22
|
+
:key="img.attrs.src + idx"
|
|
23
|
+
class="relative aspect-square w-full h-full overflow-hidden bg-surface-white group"
|
|
24
|
+
>
|
|
25
|
+
<button
|
|
26
|
+
v-if="isEditable"
|
|
27
|
+
type="button"
|
|
28
|
+
class="absolute top-1 right-1 z-10 bg-white/80 hover:bg-white rounded-full p-1 shadow transition-opacity opacity-0 group-hover:opacity-100 focus:opacity-100"
|
|
29
|
+
aria-label="Remove image"
|
|
30
|
+
@click.stop="removeImage(idx)"
|
|
31
|
+
>
|
|
32
|
+
<LucideX class="w-4 h-4 text-gray-700" />
|
|
33
|
+
</button>
|
|
34
|
+
<img
|
|
35
|
+
:src="img.attrs.src"
|
|
36
|
+
:alt="img.attrs.alt || ''"
|
|
37
|
+
class="object-cover w-full h-full not-prose cursor-pointer rounded-[2px]"
|
|
38
|
+
v-if="!isEditable"
|
|
39
|
+
@click="openViewer(idx)"
|
|
40
|
+
/>
|
|
41
|
+
<img
|
|
42
|
+
v-else
|
|
43
|
+
:src="img.attrs.src"
|
|
44
|
+
:alt="img.attrs.alt || ''"
|
|
45
|
+
class="object-cover w-full h-full not-prose rounded-[2px]"
|
|
46
|
+
/>
|
|
47
|
+
|
|
48
|
+
<!-- Caption overlay (visible when there's alt text) -->
|
|
49
|
+
<div
|
|
50
|
+
v-if="img.attrs.alt"
|
|
51
|
+
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent rounded-b-[2px] opacity-0 group-hover:opacity-100 transition-opacity"
|
|
52
|
+
>
|
|
53
|
+
<div class="p-2">
|
|
54
|
+
<div class="text-white text-xs truncate" :title="img.attrs.alt">
|
|
55
|
+
{{ img.attrs.alt }}
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
<ImageViewerModal
|
|
62
|
+
v-if="showViewer"
|
|
63
|
+
v-model:show="showViewer"
|
|
64
|
+
:images="viewerImages"
|
|
65
|
+
:initialIndex="viewerIndex"
|
|
66
|
+
/>
|
|
67
|
+
<ImageGroupUploadDialog
|
|
68
|
+
v-if="showEditModal"
|
|
69
|
+
v-model="showEditModal"
|
|
70
|
+
:files="editFiles"
|
|
71
|
+
:editor="props.editor"
|
|
72
|
+
mode="edit"
|
|
73
|
+
:existingImages="existingImages"
|
|
74
|
+
:initialColumns="columns"
|
|
75
|
+
@close="handleEditModalClose"
|
|
76
|
+
@save="handleEditSave"
|
|
77
|
+
/>
|
|
78
|
+
<slot />
|
|
79
|
+
</div>
|
|
80
|
+
</NodeViewWrapper>
|
|
81
|
+
</template>
|
|
82
|
+
|
|
83
|
+
<script setup lang="ts">
|
|
84
|
+
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
|
85
|
+
import { NodeViewWrapper, type Editor } from '@tiptap/vue-3'
|
|
86
|
+
import type { NodeViewProps } from '@tiptap/vue-3'
|
|
87
|
+
import LucideX from '~icons/lucide/x'
|
|
88
|
+
import LucideEdit from '~icons/lucide/edit'
|
|
89
|
+
import Button from '../../../Button/Button.vue'
|
|
90
|
+
import Select from '../../../Select/Select.vue'
|
|
91
|
+
import ImageViewerModal from '../../ImageViewerModal.vue'
|
|
92
|
+
import ImageGroupUploadDialog from './ImageGroupUploadDialog.vue'
|
|
93
|
+
|
|
94
|
+
const props = defineProps<NodeViewProps & { editor: Editor }>()
|
|
95
|
+
|
|
96
|
+
const columns = computed(() => props.node.attrs.columns || 4)
|
|
97
|
+
const images = computed(() => props.node.content.content || [])
|
|
98
|
+
const gridStyle = computed(() => ({
|
|
99
|
+
gridTemplateColumns: `repeat(${columns.value}, minmax(0, 1fr))`,
|
|
100
|
+
}))
|
|
101
|
+
const isEditable = ref(props.editor.isEditable)
|
|
102
|
+
|
|
103
|
+
const selectOptions = [
|
|
104
|
+
{ label: '2 columns', value: '2' },
|
|
105
|
+
{ label: '3 columns', value: '3' },
|
|
106
|
+
{ label: '4 columns', value: '4' },
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
const internalColumns = computed({
|
|
110
|
+
get: () => String(columns.value),
|
|
111
|
+
set: (val) => props.updateAttributes({ columns: +val }),
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const showViewer = ref(false)
|
|
115
|
+
const viewerIndex = ref(0)
|
|
116
|
+
const viewerImages = computed(() =>
|
|
117
|
+
images.value.map((img: any) => ({
|
|
118
|
+
src: img.attrs.src,
|
|
119
|
+
alt: img.attrs.alt || '',
|
|
120
|
+
})),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const showEditModal = ref(false)
|
|
124
|
+
const editFiles = ref<File[]>([])
|
|
125
|
+
|
|
126
|
+
interface ExistingImage {
|
|
127
|
+
src: string
|
|
128
|
+
alt: string
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const existingImages = computed(() =>
|
|
132
|
+
images.value.map((img: any) => ({
|
|
133
|
+
src: img.attrs.src,
|
|
134
|
+
alt: img.attrs.alt || '',
|
|
135
|
+
})),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
onMounted(() => {
|
|
139
|
+
const updateEditable = () => {
|
|
140
|
+
isEditable.value = props.editor.isEditable
|
|
141
|
+
}
|
|
142
|
+
props.editor.on('update', updateEditable)
|
|
143
|
+
onUnmounted(() => {
|
|
144
|
+
props.editor.off('update', updateEditable)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
function edit() {
|
|
149
|
+
editFiles.value = []
|
|
150
|
+
showEditModal.value = true
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function handleEditModalClose() {
|
|
154
|
+
showEditModal.value = false
|
|
155
|
+
editFiles.value = []
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function handleEditSave(data: {
|
|
159
|
+
images: ExistingImage[]
|
|
160
|
+
columns: number
|
|
161
|
+
}) {
|
|
162
|
+
// Update the node with the final images and columns from the modal
|
|
163
|
+
props.editor.commands.command(({ tr, state }) => {
|
|
164
|
+
const pos = props.getPos()
|
|
165
|
+
if (typeof pos === 'number') {
|
|
166
|
+
const node = state.doc.nodeAt(pos)
|
|
167
|
+
if (node && node.type.name === 'imageGroup') {
|
|
168
|
+
const newContent = data.images.map((img) =>
|
|
169
|
+
state.schema.nodes.image.create({ src: img.src, alt: img.alt }),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
const newAttrs = { ...node.attrs, columns: data.columns }
|
|
173
|
+
const newNode = node.type.create(newAttrs, newContent)
|
|
174
|
+
tr.replaceWith(pos, pos + node.nodeSize, newNode)
|
|
175
|
+
return true
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return false
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
showEditModal.value = false
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function openViewer(idx: number) {
|
|
185
|
+
if (props.editor.isEditable) return
|
|
186
|
+
viewerIndex.value = idx
|
|
187
|
+
showViewer.value = true
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function removeImage(idx: number) {
|
|
191
|
+
// Remove the image at the specified index and update the node
|
|
192
|
+
const newImages = images.value.slice()
|
|
193
|
+
newImages.splice(idx, 1)
|
|
194
|
+
if (newImages.length === 0) {
|
|
195
|
+
// Remove the whole node if no images left
|
|
196
|
+
props.editor.commands.deleteNode('imageGroup')
|
|
197
|
+
} else {
|
|
198
|
+
// Update the node content with remaining images
|
|
199
|
+
props.updateAttributes({})
|
|
200
|
+
props.editor.commands.command(({ tr, state }) => {
|
|
201
|
+
const pos = props.getPos()
|
|
202
|
+
if (typeof pos === 'number') {
|
|
203
|
+
const node = state.doc.nodeAt(pos)
|
|
204
|
+
if (node && node.type.name === 'imageGroup') {
|
|
205
|
+
const newContent = newImages.map((img) =>
|
|
206
|
+
state.schema.nodes.image.create(img.attrs),
|
|
207
|
+
)
|
|
208
|
+
tr.replaceWith(pos + 1, pos + 1 + node.content.size, newContent)
|
|
209
|
+
return true
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return false
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
</script>
|