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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pimelon-ui",
3
- "version": "0.1.124",
3
+ "version": "0.1.126",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "main": "./src/index.js",
6
6
  "scripts": {
@@ -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: openDialog }"></slot>
3
- <Dialog
4
- :options="{ title: 'Add Image' }"
5
- v-model="addImageDialog.show"
6
- @after-leave="reset"
7
- >
8
- <template #body-content>
9
- <label
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 fileToBase64 from '../../utils/file-to-base64'
40
- import Dialog from '../Dialog.vue'
41
- import { Button } from '../Button'
11
+ <script setup lang="ts">
12
+ import { useTemplateRef } from 'vue'
42
13
 
43
- export default {
44
- name: 'InsertImage',
45
- props: ['editor'],
46
- expose: ['openDialog'],
47
- data() {
48
- return {
49
- addImageDialog: { url: '', file: null, show: false },
50
- }
51
- },
52
- components: { Button, Dialog },
53
- methods: {
54
- openDialog() {
55
- this.addImageDialog.show = true
56
- },
57
- onImageSelect(e) {
58
- let file = e.target.files[0]
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 Image from './image-extension'
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
- Image,
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: ![alt](src "title")
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
- }