pimelon-ui 0.1.124 → 0.1.126
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/ImageNodeView.vue +115 -0
- package/src/components/TextEditor/InsertImage.vue +26 -72
- package/src/components/TextEditor/TextEditor.vue +8 -2
- package/src/components/TextEditor/image-extension.ts +424 -0
- package/src/utils/useFileUpload.ts +223 -0
- package/src/components/TextEditor/image-extension.js +0 -152
package/package.json
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
|
|
3
|
+
import LoadingIndicator from '../LoadingIndicator.vue'
|
|
4
|
+
import { ref, onMounted } from 'vue'
|
|
5
|
+
import ErrorMessage from '../ErrorMessage.vue'
|
|
6
|
+
|
|
7
|
+
const props = defineProps(nodeViewProps)
|
|
8
|
+
|
|
9
|
+
function selectImage() {
|
|
10
|
+
props.editor.commands.setNodeSelection(props.getPos())
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const caption = ref(props.node.attrs.alt || '')
|
|
14
|
+
const isEditable = ref(false)
|
|
15
|
+
|
|
16
|
+
onMounted(() => {
|
|
17
|
+
isEditable.value = props.editor.isEditable
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
props.editor.on('update', () => {
|
|
21
|
+
isEditable.value = props.editor.isEditable
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
function updateCaption(event: Event) {
|
|
25
|
+
const newCaption = (event.target as HTMLInputElement).value
|
|
26
|
+
caption.value = newCaption
|
|
27
|
+
props.updateAttributes({ alt: newCaption })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function handleKeydown(event: KeyboardEvent) {
|
|
31
|
+
if (
|
|
32
|
+
event.key === 'Enter' ||
|
|
33
|
+
event.key === 'Escape' ||
|
|
34
|
+
event.key === 'ArrowDown'
|
|
35
|
+
) {
|
|
36
|
+
event.preventDefault()
|
|
37
|
+
setCursorAfterImage()
|
|
38
|
+
}
|
|
39
|
+
if (event.key === 'ArrowUp') {
|
|
40
|
+
event.preventDefault()
|
|
41
|
+
setCursorBeforeImage()
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function setCursorAt(pos: number) {
|
|
46
|
+
props.editor.commands.focus()
|
|
47
|
+
props.editor.chain().setTextSelection(pos).scrollIntoView().run()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function setCursorAfterImage() {
|
|
51
|
+
const pos = props.getPos() + props.node.nodeSize
|
|
52
|
+
setCursorAt(pos)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function setCursorBeforeImage() {
|
|
56
|
+
const pos = props.getPos()
|
|
57
|
+
setCursorAt(pos - 1)
|
|
58
|
+
}
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<template>
|
|
62
|
+
<NodeViewWrapper>
|
|
63
|
+
<div class="relative overflow-hidden not-prose my-6">
|
|
64
|
+
<div class="relative">
|
|
65
|
+
<img
|
|
66
|
+
v-if="node.attrs.src"
|
|
67
|
+
class="rounded-[2px]"
|
|
68
|
+
:src="node.attrs.src"
|
|
69
|
+
:alt="node.attrs.alt || ''"
|
|
70
|
+
:width="node.attrs.width"
|
|
71
|
+
:height="node.attrs.height"
|
|
72
|
+
@click="selectImage"
|
|
73
|
+
/>
|
|
74
|
+
|
|
75
|
+
<!-- Loading indicator overlay -->
|
|
76
|
+
<div
|
|
77
|
+
v-if="node.attrs.loading"
|
|
78
|
+
class="inset-0 absolute flex items-center justify-center z-10"
|
|
79
|
+
>
|
|
80
|
+
<div
|
|
81
|
+
class="bg-gray-900/80 p-2 inset-0 leading-none rounded-sm flex flex-col items-center justify-center gap-2"
|
|
82
|
+
>
|
|
83
|
+
<div class="flex items-center gap-2">
|
|
84
|
+
<LoadingIndicator class="text-gray-100 size-4" />
|
|
85
|
+
<span class="text-gray-100">Uploading...</span>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<!-- Selection overlay -->
|
|
91
|
+
<div
|
|
92
|
+
class="absolute pointer-events-none inset-0 rounded-[2px] bg-black/20 dark:bg-white/20 z-5 transition-opacity"
|
|
93
|
+
:class="{
|
|
94
|
+
'opacity-100': selected,
|
|
95
|
+
'opacity-0': !selected,
|
|
96
|
+
}"
|
|
97
|
+
></div>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<input
|
|
101
|
+
v-if="(isEditable || node.attrs.alt) && !node.attrs.error"
|
|
102
|
+
v-model="caption"
|
|
103
|
+
class="w-full text-center bg-transparent text-sm text-ink-gray-6 h-7 border-none focus:ring-0 placeholder-ink-gray-4"
|
|
104
|
+
placeholder="Add caption"
|
|
105
|
+
:disabled="!isEditable"
|
|
106
|
+
@change="updateCaption"
|
|
107
|
+
@keydown="handleKeydown"
|
|
108
|
+
/>
|
|
109
|
+
|
|
110
|
+
<div v-if="node.attrs.error" class="w-full py-1.5">
|
|
111
|
+
<ErrorMessage :message="`Upload Failed: ${node.attrs.error}`" />
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</NodeViewWrapper>
|
|
115
|
+
</template>
|
|
@@ -1,77 +1,31 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<slot v-bind="{ onClick:
|
|
3
|
-
<
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class="relative cursor-pointer rounded-lg bg-surface-gray-2 py-1 focus-within:bg-surface-gray-3 hover:bg-surface-gray-3"
|
|
11
|
-
>
|
|
12
|
-
<input
|
|
13
|
-
type="file"
|
|
14
|
-
class="w-full opacity-0"
|
|
15
|
-
@change="onImageSelect"
|
|
16
|
-
accept="image/*"
|
|
17
|
-
/>
|
|
18
|
-
<span class="absolute inset-0 select-none px-2 py-1 text-base">
|
|
19
|
-
{{ addImageDialog.file ? 'Select another image' : 'Select an image' }}
|
|
20
|
-
</span>
|
|
21
|
-
</label>
|
|
22
|
-
<img
|
|
23
|
-
v-if="addImageDialog.url"
|
|
24
|
-
:src="addImageDialog.url"
|
|
25
|
-
class="mt-2 w-full rounded-lg"
|
|
26
|
-
/>
|
|
27
|
-
</template>
|
|
28
|
-
<template #actions>
|
|
29
|
-
<div class="flex gap-2">
|
|
30
|
-
<Button variant="solid" @click="addImage(addImageDialog.url)">
|
|
31
|
-
Insert Image
|
|
32
|
-
</Button>
|
|
33
|
-
<Button @click="reset"> Cancel </Button>
|
|
34
|
-
</div>
|
|
35
|
-
</template>
|
|
36
|
-
</Dialog>
|
|
2
|
+
<slot v-bind="{ onClick: openFileSelector }"></slot>
|
|
3
|
+
<input
|
|
4
|
+
ref="fileInput"
|
|
5
|
+
type="file"
|
|
6
|
+
class="hidden"
|
|
7
|
+
@change="onImageSelect"
|
|
8
|
+
accept="image/*"
|
|
9
|
+
/>
|
|
37
10
|
</template>
|
|
38
|
-
<script>
|
|
39
|
-
import
|
|
40
|
-
import Dialog from '../Dialog.vue'
|
|
41
|
-
import { Button } from '../Button'
|
|
11
|
+
<script setup lang="ts">
|
|
12
|
+
import { useTemplateRef } from 'vue'
|
|
42
13
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (!file) {
|
|
60
|
-
return
|
|
61
|
-
}
|
|
62
|
-
this.addImageDialog.file = file
|
|
63
|
-
fileToBase64(file).then((base64) => {
|
|
64
|
-
this.addImageDialog.url = base64
|
|
65
|
-
})
|
|
66
|
-
},
|
|
67
|
-
addImage(src) {
|
|
68
|
-
if (!src) return
|
|
69
|
-
this.editor.chain().focus().setImage({ src }).run()
|
|
70
|
-
this.reset()
|
|
71
|
-
},
|
|
72
|
-
reset() {
|
|
73
|
-
this.addImageDialog = this.$options.data().addImageDialog
|
|
74
|
-
},
|
|
75
|
-
},
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
editor: any
|
|
16
|
+
}>()
|
|
17
|
+
|
|
18
|
+
const fileInput = useTemplateRef('fileInput')
|
|
19
|
+
|
|
20
|
+
function openFileSelector() {
|
|
21
|
+
fileInput.value?.click()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function onImageSelect(e: Event) {
|
|
25
|
+
const target = e.target as HTMLInputElement
|
|
26
|
+
const file = target.files?.[0]
|
|
27
|
+
if (file) {
|
|
28
|
+
props.editor.chain().focus().uploadImage(file).run()
|
|
29
|
+
}
|
|
76
30
|
}
|
|
77
31
|
</script>
|
|
@@ -25,7 +25,7 @@ import Table from '@tiptap/extension-table'
|
|
|
25
25
|
import TableCell from '@tiptap/extension-table-cell'
|
|
26
26
|
import TableHeader from '@tiptap/extension-table-header'
|
|
27
27
|
import TableRow from '@tiptap/extension-table-row'
|
|
28
|
-
import
|
|
28
|
+
import ImageExtension from './image-extension'
|
|
29
29
|
import Video from './video-extension'
|
|
30
30
|
import Link from '@tiptap/extension-link'
|
|
31
31
|
import Typography from '@tiptap/extension-typography'
|
|
@@ -99,6 +99,10 @@ export default {
|
|
|
99
99
|
type: Array,
|
|
100
100
|
default: () => [],
|
|
101
101
|
},
|
|
102
|
+
imageUploadFunction: {
|
|
103
|
+
type: Function,
|
|
104
|
+
default: () => null,
|
|
105
|
+
},
|
|
102
106
|
},
|
|
103
107
|
emits: ['change', 'focus', 'blur'],
|
|
104
108
|
expose: ['editor'],
|
|
@@ -161,7 +165,9 @@ export default {
|
|
|
161
165
|
return VueNodeViewRenderer(CodeBlockComponent)
|
|
162
166
|
},
|
|
163
167
|
}).configure({ lowlight }),
|
|
164
|
-
|
|
168
|
+
ImageExtension.configure({
|
|
169
|
+
uploadFunction: this.imageUploadFunction,
|
|
170
|
+
}),
|
|
165
171
|
Video,
|
|
166
172
|
Link.configure({
|
|
167
173
|
openOnClick: false,
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Node as NodeExtension,
|
|
3
|
+
nodeInputRule,
|
|
4
|
+
mergeAttributes,
|
|
5
|
+
} from '@tiptap/core'
|
|
6
|
+
import { VueNodeViewRenderer } from '@tiptap/vue-3'
|
|
7
|
+
import ImageNodeView from './ImageNodeView.vue'
|
|
8
|
+
import { Plugin, Selection } from 'prosemirror-state'
|
|
9
|
+
import { EditorView } from 'prosemirror-view'
|
|
10
|
+
import { Node } from '@tiptap/pm/model'
|
|
11
|
+
import fileToBase64 from '../../utils/file-to-base64'
|
|
12
|
+
|
|
13
|
+
export interface ImageOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Function to handle image uploads
|
|
16
|
+
* @default null
|
|
17
|
+
*/
|
|
18
|
+
uploadFunction:
|
|
19
|
+
| ((file: File) => Promise<{ src: string; [key: string]: any }>)
|
|
20
|
+
| null
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* HTML attributes to add to the image element
|
|
24
|
+
* @default {}
|
|
25
|
+
*/
|
|
26
|
+
HTMLAttributes: Record<string, any>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SetImageOptions {
|
|
30
|
+
src: string
|
|
31
|
+
alt?: string
|
|
32
|
+
title?: string
|
|
33
|
+
width?: string | number | null
|
|
34
|
+
height?: string | number | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
declare module '@tiptap/core' {
|
|
38
|
+
interface Commands<ReturnType> {
|
|
39
|
+
image: {
|
|
40
|
+
/**
|
|
41
|
+
* Insert an image
|
|
42
|
+
*/
|
|
43
|
+
setImage: (options: SetImageOptions) => ReturnType
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Upload and insert an image
|
|
47
|
+
*/
|
|
48
|
+
uploadImage: (file: File) => ReturnType
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Matches markdown image syntax: 
|
|
55
|
+
*/
|
|
56
|
+
export const inputRegex =
|
|
57
|
+
/(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/
|
|
58
|
+
|
|
59
|
+
export default NodeExtension.create<ImageOptions>({
|
|
60
|
+
name: 'image',
|
|
61
|
+
|
|
62
|
+
group: 'block',
|
|
63
|
+
draggable: true,
|
|
64
|
+
selectable: true,
|
|
65
|
+
|
|
66
|
+
addAttributes() {
|
|
67
|
+
return {
|
|
68
|
+
src: { default: null },
|
|
69
|
+
alt: { default: null },
|
|
70
|
+
title: { default: null },
|
|
71
|
+
width: { default: null },
|
|
72
|
+
height: { default: null },
|
|
73
|
+
loading: {
|
|
74
|
+
default: false,
|
|
75
|
+
parseHTML: () => false,
|
|
76
|
+
},
|
|
77
|
+
uploadId: {
|
|
78
|
+
default: null,
|
|
79
|
+
parseHTML: () => null,
|
|
80
|
+
},
|
|
81
|
+
error: {
|
|
82
|
+
default: null,
|
|
83
|
+
parseHTML: () => null,
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
parseHTML() {
|
|
89
|
+
return [
|
|
90
|
+
{
|
|
91
|
+
tag: 'img[src]',
|
|
92
|
+
getAttrs: (node) => {
|
|
93
|
+
if (typeof node === 'string') return {}
|
|
94
|
+
const element = node as HTMLElement
|
|
95
|
+
return {
|
|
96
|
+
src: element.getAttribute('src'),
|
|
97
|
+
alt: element.getAttribute('alt'),
|
|
98
|
+
title: element.getAttribute('title'),
|
|
99
|
+
width: element.getAttribute('width'),
|
|
100
|
+
height: element.getAttribute('height'),
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
]
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
renderHTML({ HTMLAttributes }) {
|
|
108
|
+
return [
|
|
109
|
+
'img',
|
|
110
|
+
mergeAttributes(this.options.HTMLAttributes || {}, HTMLAttributes),
|
|
111
|
+
]
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
addNodeView() {
|
|
115
|
+
return VueNodeViewRenderer(ImageNodeView)
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
addOptions() {
|
|
119
|
+
return {
|
|
120
|
+
uploadFunction: null,
|
|
121
|
+
HTMLAttributes: {},
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
addCommands() {
|
|
126
|
+
return {
|
|
127
|
+
setImage:
|
|
128
|
+
(attributes: SetImageOptions) =>
|
|
129
|
+
({ commands, editor }) => {
|
|
130
|
+
const result = commands.insertContent({
|
|
131
|
+
type: this.name,
|
|
132
|
+
attrs: attributes,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// Calculate and update dimensions if successful
|
|
136
|
+
if (result && attributes.src) {
|
|
137
|
+
// Find the newly inserted node
|
|
138
|
+
findImageNodeBySource(editor.view, attributes.src, (node, pos) => {
|
|
139
|
+
updateNodeWithDimensions(
|
|
140
|
+
attributes.src,
|
|
141
|
+
editor.view,
|
|
142
|
+
pos,
|
|
143
|
+
node.attrs,
|
|
144
|
+
)
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return result
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
uploadImage:
|
|
152
|
+
(file: File) =>
|
|
153
|
+
({ editor }) => {
|
|
154
|
+
return uploadImage(file, editor.view, null, this.options)
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
addInputRules() {
|
|
160
|
+
return [
|
|
161
|
+
nodeInputRule({
|
|
162
|
+
find: inputRegex,
|
|
163
|
+
type: this.type,
|
|
164
|
+
getAttributes: (match) => {
|
|
165
|
+
const [, , alt, src, title] = match
|
|
166
|
+
return { src, alt, title }
|
|
167
|
+
},
|
|
168
|
+
}),
|
|
169
|
+
]
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
addProseMirrorPlugins() {
|
|
173
|
+
const extensionThis = this
|
|
174
|
+
|
|
175
|
+
return [
|
|
176
|
+
new Plugin({
|
|
177
|
+
props: {
|
|
178
|
+
handleDOMEvents: {
|
|
179
|
+
drop: (view, event) => {
|
|
180
|
+
const hasFiles = event.dataTransfer?.files?.length
|
|
181
|
+
|
|
182
|
+
if (!hasFiles || !extensionThis.options.uploadFunction) {
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const images = Array.from(event.dataTransfer.files).filter(
|
|
187
|
+
(file) => /image/i.test(file.type),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if (images.length === 0) {
|
|
191
|
+
return false
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
event.preventDefault()
|
|
195
|
+
|
|
196
|
+
// Set selection to drop position
|
|
197
|
+
const coordinates = view.posAtCoords({
|
|
198
|
+
left: event.clientX,
|
|
199
|
+
top: event.clientY,
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
let pos: number | null = null
|
|
203
|
+
if (coordinates) {
|
|
204
|
+
pos = coordinates.pos
|
|
205
|
+
const transaction = view.state.tr.setSelection(
|
|
206
|
+
Selection.near(view.state.doc.resolve(pos)),
|
|
207
|
+
)
|
|
208
|
+
view.dispatch(transaction)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
images.forEach((file) => {
|
|
212
|
+
uploadImage(file, view, pos, extensionThis.options)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
return true
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
handlePaste: (view, event, slice) => {
|
|
220
|
+
if (!extensionThis.options.uploadFunction) {
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const clipboardItems = event.clipboardData?.items
|
|
225
|
+
if (!clipboardItems || clipboardItems.length === 0) {
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const images: File[] = []
|
|
230
|
+
|
|
231
|
+
for (let i = 0; i < clipboardItems.length; i++) {
|
|
232
|
+
const item = clipboardItems[i]
|
|
233
|
+
if (item.kind === 'file' && item.type.indexOf('image/') !== -1) {
|
|
234
|
+
const file = item.getAsFile()
|
|
235
|
+
if (file) {
|
|
236
|
+
images.push(file)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (images.length === 0) {
|
|
242
|
+
return false
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
event.preventDefault()
|
|
246
|
+
|
|
247
|
+
images.forEach((file) => {
|
|
248
|
+
uploadImage(file, view, null, extensionThis.options)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
return true
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
appendTransaction(transactions, oldState, newState) {
|
|
256
|
+
const newImageNodes: { node: Node; pos: number }[] = []
|
|
257
|
+
|
|
258
|
+
if (transactions.some((tr) => tr.docChanged)) {
|
|
259
|
+
newState.doc.descendants((node, pos) => {
|
|
260
|
+
if (
|
|
261
|
+
node.type.name === 'image' &&
|
|
262
|
+
node.attrs.src &&
|
|
263
|
+
(!node.attrs.width || !node.attrs.height) &&
|
|
264
|
+
!node.attrs.loading
|
|
265
|
+
) {
|
|
266
|
+
newImageNodes.push({ node, pos })
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (newImageNodes.length === 0) return null
|
|
272
|
+
|
|
273
|
+
newImageNodes.forEach(({ node, pos }) => {
|
|
274
|
+
const editor = extensionThis.editor
|
|
275
|
+
if (editor) {
|
|
276
|
+
updateNodeWithDimensions(
|
|
277
|
+
node.attrs.src,
|
|
278
|
+
editor.view,
|
|
279
|
+
pos,
|
|
280
|
+
node.attrs,
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
return null
|
|
286
|
+
},
|
|
287
|
+
}),
|
|
288
|
+
]
|
|
289
|
+
},
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
function uploadImage(
|
|
293
|
+
file: File,
|
|
294
|
+
view: EditorView,
|
|
295
|
+
pos: number | null | undefined,
|
|
296
|
+
options: Record<string, any>,
|
|
297
|
+
): boolean {
|
|
298
|
+
if (!options.uploadFunction) {
|
|
299
|
+
console.error('uploadFunction option is not provided')
|
|
300
|
+
return false
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const uploadId = `upload-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
|
304
|
+
|
|
305
|
+
fileToBase64(file)
|
|
306
|
+
.then((base64Result: string) => {
|
|
307
|
+
const node = view.state.schema.nodes.image.create({
|
|
308
|
+
loading: true,
|
|
309
|
+
uploadId,
|
|
310
|
+
src: base64Result, // Base64 preview while uploading
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
const tr = view.state.tr
|
|
314
|
+
|
|
315
|
+
if (pos != null) {
|
|
316
|
+
tr.insert(pos, node)
|
|
317
|
+
} else {
|
|
318
|
+
tr.replaceSelectionWith(node)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
view.dispatch(tr)
|
|
322
|
+
|
|
323
|
+
return options.uploadFunction(file)
|
|
324
|
+
})
|
|
325
|
+
.then((uploadedImage: any) => {
|
|
326
|
+
return getImageDimensions(uploadedImage.src)
|
|
327
|
+
.then((dimensions) => {
|
|
328
|
+
return {
|
|
329
|
+
...uploadedImage,
|
|
330
|
+
width: dimensions.width,
|
|
331
|
+
height: dimensions.height,
|
|
332
|
+
}
|
|
333
|
+
})
|
|
334
|
+
.catch(() => {
|
|
335
|
+
return uploadedImage
|
|
336
|
+
})
|
|
337
|
+
})
|
|
338
|
+
.then((uploadedImage: any) => {
|
|
339
|
+
const transaction = view.state.tr
|
|
340
|
+
|
|
341
|
+
view.state.doc.descendants((node, pos) => {
|
|
342
|
+
if (node.type.name === 'image' && node.attrs.uploadId === uploadId) {
|
|
343
|
+
transaction.setNodeMarkup(pos, undefined, {
|
|
344
|
+
...node.attrs,
|
|
345
|
+
src: uploadedImage.src,
|
|
346
|
+
width: uploadedImage.width || node.attrs.width,
|
|
347
|
+
height: uploadedImage.height || node.attrs.height,
|
|
348
|
+
loading: false,
|
|
349
|
+
})
|
|
350
|
+
return false // Stop traversal after finding our node
|
|
351
|
+
}
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
view.dispatch(transaction)
|
|
355
|
+
})
|
|
356
|
+
.catch((error: Error) => {
|
|
357
|
+
console.error('Image upload failed:', error)
|
|
358
|
+
|
|
359
|
+
const transaction = view.state.tr
|
|
360
|
+
|
|
361
|
+
view.state.doc.descendants((node, pos) => {
|
|
362
|
+
if (node.type.name === 'image' && node.attrs.uploadId === uploadId) {
|
|
363
|
+
transaction.setNodeMarkup(pos, undefined, {
|
|
364
|
+
...node.attrs,
|
|
365
|
+
loading: false,
|
|
366
|
+
error: error.message || 'Failed to upload image',
|
|
367
|
+
})
|
|
368
|
+
return false // Stop traversal after finding our node
|
|
369
|
+
}
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
view.dispatch(transaction)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
return true
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function findImageNodeBySource(
|
|
379
|
+
view: EditorView,
|
|
380
|
+
src: string,
|
|
381
|
+
callback: (node: Node, pos: number) => void,
|
|
382
|
+
) {
|
|
383
|
+
view.state.doc.descendants((node, pos) => {
|
|
384
|
+
if (node.type.name === 'image' && node.attrs.src === src) {
|
|
385
|
+
callback(node, pos)
|
|
386
|
+
return false // Stop traversal after finding our node
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function updateNodeWithDimensions(
|
|
392
|
+
src: string,
|
|
393
|
+
view: EditorView,
|
|
394
|
+
pos: number,
|
|
395
|
+
attrs: any,
|
|
396
|
+
) {
|
|
397
|
+
getImageDimensions(src)
|
|
398
|
+
.then((dimensions) => {
|
|
399
|
+
const transaction = view.state.tr.setNodeMarkup(pos, undefined, {
|
|
400
|
+
...attrs,
|
|
401
|
+
width: dimensions.width,
|
|
402
|
+
height: dimensions.height,
|
|
403
|
+
})
|
|
404
|
+
view.dispatch(transaction)
|
|
405
|
+
})
|
|
406
|
+
.catch((error) => {
|
|
407
|
+
console.error('Failed to get image dimensions:', error)
|
|
408
|
+
})
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function getImageDimensions(
|
|
412
|
+
src: string,
|
|
413
|
+
): Promise<{ width: number; height: number }> {
|
|
414
|
+
return new Promise((resolve, reject) => {
|
|
415
|
+
const img = new Image()
|
|
416
|
+
img.onload = () =>
|
|
417
|
+
resolve({
|
|
418
|
+
width: img.naturalWidth,
|
|
419
|
+
height: img.naturalHeight,
|
|
420
|
+
})
|
|
421
|
+
img.onerror = reject
|
|
422
|
+
img.src = src
|
|
423
|
+
})
|
|
424
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { reactive, computed } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface UploadOptions {
|
|
4
|
+
private?: boolean
|
|
5
|
+
folder?: string
|
|
6
|
+
file_url?: string
|
|
7
|
+
doctype?: string
|
|
8
|
+
docname?: string
|
|
9
|
+
fieldname?: string
|
|
10
|
+
method?: string
|
|
11
|
+
type?: string
|
|
12
|
+
upload_endpoint?: string
|
|
13
|
+
optimize?: boolean
|
|
14
|
+
max_width?: number
|
|
15
|
+
max_height?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface UploadState {
|
|
19
|
+
uploading: boolean
|
|
20
|
+
progress: number
|
|
21
|
+
uploaded: number
|
|
22
|
+
total: number
|
|
23
|
+
error: any | null
|
|
24
|
+
result: any | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useFileUpload() {
|
|
28
|
+
const state = reactive<UploadState>({
|
|
29
|
+
uploading: false,
|
|
30
|
+
progress: 0,
|
|
31
|
+
uploaded: 0,
|
|
32
|
+
total: 0,
|
|
33
|
+
error: null,
|
|
34
|
+
result: null,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// Function to reset the state
|
|
38
|
+
const reset = () => {
|
|
39
|
+
state.uploading = false
|
|
40
|
+
state.progress = 0
|
|
41
|
+
state.uploaded = 0
|
|
42
|
+
state.total = 0
|
|
43
|
+
state.error = null
|
|
44
|
+
state.result = null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Computed values for convenience
|
|
48
|
+
const isUploading = computed(() => state.uploading)
|
|
49
|
+
const progress = computed(() => state.progress)
|
|
50
|
+
const error = computed(() => state.error)
|
|
51
|
+
const result = computed(() => state.result)
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
upload: (file: File) => upload(file, {}, state, reset),
|
|
55
|
+
reset,
|
|
56
|
+
state,
|
|
57
|
+
isUploading,
|
|
58
|
+
progress,
|
|
59
|
+
error,
|
|
60
|
+
result,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function upload(
|
|
65
|
+
file: File | null,
|
|
66
|
+
options: UploadOptions = {},
|
|
67
|
+
state: UploadState,
|
|
68
|
+
reset: () => void,
|
|
69
|
+
) {
|
|
70
|
+
reset()
|
|
71
|
+
state.uploading = true
|
|
72
|
+
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const xhr = new XMLHttpRequest()
|
|
75
|
+
|
|
76
|
+
// Set up event listeners
|
|
77
|
+
xhr.upload.addEventListener('loadstart', () => {
|
|
78
|
+
state.uploading = true
|
|
79
|
+
state.error = null
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
xhr.upload.addEventListener('progress', (e) => {
|
|
83
|
+
if (e.lengthComputable) {
|
|
84
|
+
state.uploaded = e.loaded
|
|
85
|
+
state.total = e.total
|
|
86
|
+
state.progress = Math.round((e.loaded / e.total) * 100)
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
xhr.upload.addEventListener('load', () => {
|
|
91
|
+
state.progress = 100
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
xhr.addEventListener('error', (error) => {
|
|
95
|
+
state.uploading = false
|
|
96
|
+
state.error = 'Upload failed'
|
|
97
|
+
reject('Upload failed')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
xhr.onreadystatechange = () => {
|
|
101
|
+
if (xhr.readyState == XMLHttpRequest.DONE) {
|
|
102
|
+
let error
|
|
103
|
+
if (xhr.status === 200) {
|
|
104
|
+
let r = null
|
|
105
|
+
try {
|
|
106
|
+
r = JSON.parse(xhr.responseText)
|
|
107
|
+
} catch (e) {
|
|
108
|
+
r = xhr.responseText
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const result = r.message || r
|
|
112
|
+
state.result = result
|
|
113
|
+
resolve(result)
|
|
114
|
+
} else if (xhr.status === 403) {
|
|
115
|
+
error = JSON.parse(xhr.responseText)
|
|
116
|
+
} else {
|
|
117
|
+
try {
|
|
118
|
+
error = JSON.parse(xhr.responseText)
|
|
119
|
+
} catch (e) {
|
|
120
|
+
error = 'Upload failed'
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (error) {
|
|
125
|
+
let exception
|
|
126
|
+
let errorParts = [
|
|
127
|
+
[error.exc_type, error._error_message].filter(Boolean).join(' '),
|
|
128
|
+
]
|
|
129
|
+
if (error.exc) {
|
|
130
|
+
exception = error.exc
|
|
131
|
+
try {
|
|
132
|
+
exception = JSON.parse(exception)[0]
|
|
133
|
+
console.log(exception)
|
|
134
|
+
// eslint-disable-next-line no-empty
|
|
135
|
+
} catch (e) {}
|
|
136
|
+
}
|
|
137
|
+
let e = new Error(errorParts.join('\n'))
|
|
138
|
+
let messages = error._server_messages
|
|
139
|
+
? JSON.parse(error._server_messages)
|
|
140
|
+
: []
|
|
141
|
+
messages = messages
|
|
142
|
+
.map((m: string) => {
|
|
143
|
+
try {
|
|
144
|
+
return JSON.parse(m).message
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return m
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
.filter(Boolean)
|
|
150
|
+
if (!messages.length) {
|
|
151
|
+
messages = error._error_message
|
|
152
|
+
? [error._error_message]
|
|
153
|
+
: ['Internal Server Error']
|
|
154
|
+
}
|
|
155
|
+
e.message = messages.join('\n')
|
|
156
|
+
state.error = e
|
|
157
|
+
reject(e)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
state.uploading = false
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const uploadEndpoint = options.upload_endpoint || '/api/method/upload_file'
|
|
165
|
+
xhr.open('POST', uploadEndpoint, true)
|
|
166
|
+
xhr.setRequestHeader('Accept', 'application/json')
|
|
167
|
+
|
|
168
|
+
if (window.csrf_token && window.csrf_token !== '{{ csrf_token }}') {
|
|
169
|
+
xhr.setRequestHeader('X-Melon-CSRF-Token', window.csrf_token)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const formData = new FormData()
|
|
173
|
+
if (file) {
|
|
174
|
+
formData.append('file', file, file.name)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
formData.append('is_private', options.private ? '1' : '0')
|
|
178
|
+
formData.append('folder', options.folder || 'Home')
|
|
179
|
+
|
|
180
|
+
if (options.file_url) {
|
|
181
|
+
formData.append('file_url', options.file_url)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (options.doctype) {
|
|
185
|
+
formData.append('doctype', options.doctype)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (options.docname) {
|
|
189
|
+
formData.append('docname', options.docname)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (options.fieldname) {
|
|
193
|
+
formData.append('fieldname', options.fieldname)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (options.method) {
|
|
197
|
+
formData.append('method', options.method)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (options.type) {
|
|
201
|
+
formData.append('type', options.type)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (options.optimize) {
|
|
205
|
+
formData.append('optimize', '1')
|
|
206
|
+
if (options.max_width) {
|
|
207
|
+
formData.append('max_width', options.max_width.toString())
|
|
208
|
+
}
|
|
209
|
+
if (options.max_height) {
|
|
210
|
+
formData.append('max_height', options.max_height.toString())
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
xhr.send(formData)
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Add the Window interface for typescript
|
|
219
|
+
declare global {
|
|
220
|
+
interface Window {
|
|
221
|
+
csrf_token?: string
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
// Plugin adapted from the following examples:
|
|
2
|
-
// - https://github.com/ueberdosis/tiptap/blob/main/packages/extension-image/src/image.ts
|
|
3
|
-
// - https://gist.github.com/slava-vishnyakov/16076dff1a77ddaca93c4bccd4ec4521
|
|
4
|
-
|
|
5
|
-
import { mergeAttributes, Node, nodeInputRule } from '@tiptap/core'
|
|
6
|
-
import { Plugin } from 'prosemirror-state'
|
|
7
|
-
import fileToBase64 from '../../utils/file-to-base64'
|
|
8
|
-
|
|
9
|
-
export const inputRegex =
|
|
10
|
-
/(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/
|
|
11
|
-
|
|
12
|
-
export default Node.create({
|
|
13
|
-
name: 'image',
|
|
14
|
-
addOptions() {
|
|
15
|
-
return {
|
|
16
|
-
inline: false,
|
|
17
|
-
HTMLAttributes: {},
|
|
18
|
-
}
|
|
19
|
-
},
|
|
20
|
-
inline() {
|
|
21
|
-
return this.options.inline
|
|
22
|
-
},
|
|
23
|
-
group() {
|
|
24
|
-
return this.options.inline ? 'inline' : 'block'
|
|
25
|
-
},
|
|
26
|
-
draggable: true,
|
|
27
|
-
addAttributes() {
|
|
28
|
-
return {
|
|
29
|
-
src: {
|
|
30
|
-
default: null,
|
|
31
|
-
},
|
|
32
|
-
alt: {
|
|
33
|
-
default: null,
|
|
34
|
-
},
|
|
35
|
-
title: {
|
|
36
|
-
default: null,
|
|
37
|
-
},
|
|
38
|
-
}
|
|
39
|
-
},
|
|
40
|
-
parseHTML() {
|
|
41
|
-
return [
|
|
42
|
-
{
|
|
43
|
-
tag: 'img[src]',
|
|
44
|
-
},
|
|
45
|
-
]
|
|
46
|
-
},
|
|
47
|
-
renderHTML({ HTMLAttributes }) {
|
|
48
|
-
return ['img', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]
|
|
49
|
-
},
|
|
50
|
-
|
|
51
|
-
addCommands() {
|
|
52
|
-
return {
|
|
53
|
-
setImage:
|
|
54
|
-
(options) =>
|
|
55
|
-
({ commands }) => {
|
|
56
|
-
return commands.insertContent({
|
|
57
|
-
type: this.name,
|
|
58
|
-
attrs: options,
|
|
59
|
-
})
|
|
60
|
-
},
|
|
61
|
-
}
|
|
62
|
-
},
|
|
63
|
-
|
|
64
|
-
addInputRules() {
|
|
65
|
-
return [
|
|
66
|
-
nodeInputRule({
|
|
67
|
-
find: inputRegex,
|
|
68
|
-
type: this.type,
|
|
69
|
-
getAttributes: (match) => {
|
|
70
|
-
const [, , alt, src, title] = match
|
|
71
|
-
|
|
72
|
-
return { src, alt, title }
|
|
73
|
-
},
|
|
74
|
-
}),
|
|
75
|
-
]
|
|
76
|
-
},
|
|
77
|
-
|
|
78
|
-
addProseMirrorPlugins() {
|
|
79
|
-
return [dropImagePlugin()]
|
|
80
|
-
},
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
const dropImagePlugin = () => {
|
|
84
|
-
return new Plugin({
|
|
85
|
-
props: {
|
|
86
|
-
handlePaste(view, event, slice) {
|
|
87
|
-
const items = Array.from(event.clipboardData?.items || [])
|
|
88
|
-
const { schema } = view.state
|
|
89
|
-
|
|
90
|
-
items.forEach((item) => {
|
|
91
|
-
const image = item.getAsFile()
|
|
92
|
-
if (!image) return
|
|
93
|
-
|
|
94
|
-
if (item.type.indexOf('image') === 0) {
|
|
95
|
-
event.preventDefault()
|
|
96
|
-
|
|
97
|
-
fileToBase64(image).then((base64) => {
|
|
98
|
-
const node = schema.nodes.image.create({
|
|
99
|
-
src: base64,
|
|
100
|
-
})
|
|
101
|
-
const transaction = view.state.tr.replaceSelectionWith(node)
|
|
102
|
-
view.dispatch(transaction)
|
|
103
|
-
})
|
|
104
|
-
}
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
return false
|
|
108
|
-
},
|
|
109
|
-
handleDOMEvents: {
|
|
110
|
-
drop: (view, event) => {
|
|
111
|
-
const hasFiles =
|
|
112
|
-
event.dataTransfer &&
|
|
113
|
-
event.dataTransfer.files &&
|
|
114
|
-
event.dataTransfer.files.length
|
|
115
|
-
|
|
116
|
-
if (!hasFiles) {
|
|
117
|
-
return false
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const images = Array.from(event.dataTransfer?.files ?? []).filter(
|
|
121
|
-
(file) => /image/i.test(file.type),
|
|
122
|
-
)
|
|
123
|
-
|
|
124
|
-
if (images.length === 0) {
|
|
125
|
-
return false
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
event.preventDefault()
|
|
129
|
-
|
|
130
|
-
const { schema } = view.state
|
|
131
|
-
const coordinates = view.posAtCoords({
|
|
132
|
-
left: event.clientX,
|
|
133
|
-
top: event.clientY,
|
|
134
|
-
})
|
|
135
|
-
if (!coordinates) return false
|
|
136
|
-
|
|
137
|
-
images.forEach(async (image) => {
|
|
138
|
-
fileToBase64(image).then((base64) => {
|
|
139
|
-
const node = schema.nodes.image.create({
|
|
140
|
-
src: base64,
|
|
141
|
-
})
|
|
142
|
-
const transaction = view.state.tr.insert(coordinates.pos, node)
|
|
143
|
-
view.dispatch(transaction)
|
|
144
|
-
})
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
return true
|
|
148
|
-
},
|
|
149
|
-
},
|
|
150
|
-
},
|
|
151
|
-
})
|
|
152
|
-
}
|