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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "0.1.160",
3
+ "version": "0.1.161",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "main": "./src/index.ts",
6
6
  "type": "module",
@@ -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 file = target.files?.[0]
28
- if (file) {
29
- props.editor.chain().focus().uploadImage(file).run()
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 CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
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 VideoExtension from './video-extension'
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: (file: File) => {
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: any) => {
418
- return getImageDimensions(uploadedImage.src)
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: any) => {
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.src,
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>