frappe-ui 0.0.45 → 0.0.48
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 +19 -8
- package/src/components/Autocomplete.vue +34 -35
- package/src/components/FeatherIcon.vue +1 -1
- package/src/components/Popover.vue +5 -1
- package/src/components/TextEditor/InsertImage.vue +72 -0
- package/src/components/TextEditor/MentionList.vue +93 -0
- package/src/components/TextEditor/Menu.vue +4 -55
- package/src/components/TextEditor/TextEditor.vue +51 -22
- package/src/components/TextEditor/image-extension.js +152 -0
- package/src/components/TextEditor/mention.js +72 -0
- package/src/components/Tooltip.vue +35 -0
- package/src/index.js +1 -0
- package/src/utils/file-to-base64.js +9 -0
- package/src/utils/resources.js +19 -9
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "frappe-ui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.48",
|
|
4
4
|
"description": "A set of components and utilities for rapid UI development",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "npx prettier --check ./src",
|
|
8
|
-
"prettier": "npx prettier -w ./src"
|
|
8
|
+
"prettier": "npx prettier -w ./src",
|
|
9
|
+
"prepare": "husky install"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
12
|
"src"
|
|
@@ -21,16 +22,26 @@
|
|
|
21
22
|
"@popperjs/core": "^2.11.2",
|
|
22
23
|
"@tailwindcss/forms": "^0.4.0",
|
|
23
24
|
"@tailwindcss/typography": "^0.5.0",
|
|
24
|
-
"@tiptap/extension-image": "^2.0.0-beta.
|
|
25
|
-
"@tiptap/extension-link": "^2.0.0-beta.
|
|
26
|
-
"@tiptap/extension-
|
|
25
|
+
"@tiptap/extension-image": "^2.0.0-beta.30",
|
|
26
|
+
"@tiptap/extension-link": "^2.0.0-beta.43",
|
|
27
|
+
"@tiptap/extension-mention": "^2.0.0-beta.102",
|
|
28
|
+
"@tiptap/extension-placeholder": "^2.0.0-beta.53",
|
|
27
29
|
"@tiptap/extension-text-align": "^2.0.0-beta.31",
|
|
28
|
-
"@tiptap/starter-kit": "^2.0.0-beta.
|
|
29
|
-
"@tiptap/vue-3": "^2.0.0-beta.
|
|
30
|
+
"@tiptap/starter-kit": "^2.0.0-beta.191",
|
|
31
|
+
"@tiptap/vue-3": "^2.0.0-beta.96",
|
|
30
32
|
"autoprefixer": "^10.4.2",
|
|
31
33
|
"feather-icons": "^4.28.0",
|
|
32
34
|
"postcss": "^8.4.5",
|
|
33
35
|
"socket.io-client": "^4.5.1",
|
|
34
|
-
"tailwindcss": "^3.0.12"
|
|
36
|
+
"tailwindcss": "^3.0.12",
|
|
37
|
+
"tippy.js": "^6.3.7"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"husky": ">=6",
|
|
41
|
+
"lint-staged": ">=10",
|
|
42
|
+
"prettier": "2.7.1"
|
|
43
|
+
},
|
|
44
|
+
"lint-staged": {
|
|
45
|
+
"*.{js,css,md,vue}": "prettier --write"
|
|
35
46
|
}
|
|
36
47
|
}
|
|
@@ -1,31 +1,23 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<Combobox v-model="selectedValue" nullable>
|
|
2
|
+
<Combobox v-model="selectedValue" nullable v-slot="{ open: isComboboxOpen }">
|
|
3
3
|
<Popover class="w-full">
|
|
4
4
|
<template #target="{ open: openPopover }">
|
|
5
|
-
<div class="
|
|
6
|
-
<
|
|
7
|
-
|
|
8
|
-
class="
|
|
9
|
-
|
|
10
|
-
@change="
|
|
11
|
-
(e) => {
|
|
12
|
-
query = e.target.value
|
|
13
|
-
openPopover()
|
|
14
|
-
}
|
|
15
|
-
"
|
|
16
|
-
@focus="
|
|
5
|
+
<div class="w-full">
|
|
6
|
+
<ComboboxButton
|
|
7
|
+
class="flex items-center justify-between w-full py-1.5 pl-3 pr-2 rounded-md bg-gray-100"
|
|
8
|
+
:class="{ 'rounded-b-none': isComboboxOpen }"
|
|
9
|
+
@click="
|
|
17
10
|
() => {
|
|
18
11
|
openPopover()
|
|
19
|
-
toggleCombobox(true)
|
|
20
12
|
}
|
|
21
13
|
"
|
|
22
|
-
@keydown="toggleCombobox(true)"
|
|
23
|
-
autocomplete="off"
|
|
24
|
-
v-bind="$attrs"
|
|
25
|
-
/>
|
|
26
|
-
<ComboboxButton
|
|
27
|
-
class="absolute inset-y-0 right-0 flex items-center pr-2"
|
|
28
14
|
>
|
|
15
|
+
<span class="text-base" v-if="selectedValue">
|
|
16
|
+
{{ displayValue(selectedValue) }}
|
|
17
|
+
</span>
|
|
18
|
+
<span class="text-base text-gray-500" v-else>
|
|
19
|
+
{{ placeholder || '' }}
|
|
20
|
+
</span>
|
|
29
21
|
<FeatherIcon
|
|
30
22
|
name="chevron-down"
|
|
31
23
|
class="w-4 h-4 text-gray-500"
|
|
@@ -36,12 +28,27 @@
|
|
|
36
28
|
</template>
|
|
37
29
|
<template #body>
|
|
38
30
|
<ComboboxOptions
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
]"
|
|
43
|
-
:static="true"
|
|
31
|
+
class="px-1.5 pb-1.5 bg-white rounded-md shadow-md rounded-t-none max-h-[11rem] overflow-y-auto"
|
|
32
|
+
static
|
|
33
|
+
v-show="isComboboxOpen"
|
|
44
34
|
>
|
|
35
|
+
<div
|
|
36
|
+
class="flex items-st items-stretch space-x-1.5 sticky top-0 pt-1.5 mb-1.5 bg-white"
|
|
37
|
+
>
|
|
38
|
+
<ComboboxInput
|
|
39
|
+
class="w-full placeholder-gray-500 form-input"
|
|
40
|
+
type="text"
|
|
41
|
+
@change="
|
|
42
|
+
(e) => {
|
|
43
|
+
query = e.target.value
|
|
44
|
+
}
|
|
45
|
+
"
|
|
46
|
+
:value="query"
|
|
47
|
+
autocomplete="off"
|
|
48
|
+
placeholder="Search by keyword"
|
|
49
|
+
/>
|
|
50
|
+
<Button icon="x" @click="selectedValue = null" />
|
|
51
|
+
</div>
|
|
45
52
|
<ComboboxOption
|
|
46
53
|
as="template"
|
|
47
54
|
v-for="option in filteredOptions"
|
|
@@ -81,8 +88,7 @@ import Popover from './Popover.vue'
|
|
|
81
88
|
|
|
82
89
|
export default {
|
|
83
90
|
name: 'Autocomplete',
|
|
84
|
-
|
|
85
|
-
props: ['modelValue', 'options'],
|
|
91
|
+
props: ['modelValue', 'options', 'placeholder'],
|
|
86
92
|
emits: ['update:modelValue', 'change'],
|
|
87
93
|
components: {
|
|
88
94
|
Popover,
|
|
@@ -94,7 +100,6 @@ export default {
|
|
|
94
100
|
},
|
|
95
101
|
data() {
|
|
96
102
|
return {
|
|
97
|
-
showCombobox: false,
|
|
98
103
|
query: '',
|
|
99
104
|
}
|
|
100
105
|
},
|
|
@@ -107,7 +112,7 @@ export default {
|
|
|
107
112
|
return this.valuePropPassed ? this.$attrs.value : this.modelValue
|
|
108
113
|
},
|
|
109
114
|
set(val) {
|
|
110
|
-
|
|
115
|
+
this.query = ''
|
|
111
116
|
this.$emit(this.valuePropPassed ? 'change' : 'update:modelValue', val)
|
|
112
117
|
},
|
|
113
118
|
},
|
|
@@ -130,12 +135,6 @@ export default {
|
|
|
130
135
|
}
|
|
131
136
|
return option?.label
|
|
132
137
|
},
|
|
133
|
-
toggleCombobox(value) {
|
|
134
|
-
value = Boolean(value)
|
|
135
|
-
if (this.showCombobox !== value) {
|
|
136
|
-
this.showCombobox = value
|
|
137
|
-
}
|
|
138
|
-
},
|
|
139
138
|
},
|
|
140
139
|
}
|
|
141
140
|
</script>
|
|
@@ -208,10 +208,13 @@ export default {
|
|
|
208
208
|
this.isOpen = false
|
|
209
209
|
},
|
|
210
210
|
onMouseover() {
|
|
211
|
+
this.mouseover = true
|
|
211
212
|
if (this.trigger === 'hover') {
|
|
212
213
|
if (this.hoverDelay) {
|
|
213
214
|
this.hoverTimer = setTimeout(() => {
|
|
214
|
-
this.
|
|
215
|
+
if (this.mouseover) {
|
|
216
|
+
this.open()
|
|
217
|
+
}
|
|
215
218
|
}, Number(this.hoverDelay) * 1000)
|
|
216
219
|
} else {
|
|
217
220
|
this.open()
|
|
@@ -219,6 +222,7 @@ export default {
|
|
|
219
222
|
}
|
|
220
223
|
},
|
|
221
224
|
onMouseleave() {
|
|
225
|
+
this.mouseover = false
|
|
222
226
|
if (this.hoverTimer) {
|
|
223
227
|
clearTimeout(this.hoverTimer)
|
|
224
228
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<slot v-bind="{ openDialog, resetAddImage }"></slot>
|
|
3
|
+
<Dialog
|
|
4
|
+
:options="{ title: 'Add Image' }"
|
|
5
|
+
v-model="addImageDialog.show"
|
|
6
|
+
@after-leave="resetAddImage"
|
|
7
|
+
>
|
|
8
|
+
<template #body-content>
|
|
9
|
+
<label
|
|
10
|
+
class="relative py-1 bg-gray-100 rounded-lg cursor-pointer focus-within:bg-gray-200 hover:bg-gray-200"
|
|
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 px-2 py-1 text-base select-none">
|
|
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="w-full mt-2 rounded-lg"
|
|
26
|
+
/>
|
|
27
|
+
</template>
|
|
28
|
+
<template #actions>
|
|
29
|
+
<Button appearance="primary" @click="addImage(addImageDialog.url)">
|
|
30
|
+
Insert Image
|
|
31
|
+
</Button>
|
|
32
|
+
</template>
|
|
33
|
+
</Dialog>
|
|
34
|
+
</template>
|
|
35
|
+
<script>
|
|
36
|
+
import fileToBase64 from '../../utils/file-to-base64'
|
|
37
|
+
import Dialog from '../Dialog.vue'
|
|
38
|
+
|
|
39
|
+
export default {
|
|
40
|
+
name: 'InsertImage',
|
|
41
|
+
props: ['editor'],
|
|
42
|
+
expose: ['openDialog'],
|
|
43
|
+
data() {
|
|
44
|
+
return {
|
|
45
|
+
addImageDialog: { url: '', file: null, show: false },
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
components: { Dialog },
|
|
49
|
+
methods: {
|
|
50
|
+
openDialog() {
|
|
51
|
+
this.addImageDialog.show = true
|
|
52
|
+
},
|
|
53
|
+
onImageSelect(e) {
|
|
54
|
+
let file = e.target.files[0]
|
|
55
|
+
if (!file) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
this.addImageDialog.file = file
|
|
59
|
+
fileToBase64(file).then((base64) => {
|
|
60
|
+
this.addImageDialog.url = base64
|
|
61
|
+
})
|
|
62
|
+
},
|
|
63
|
+
addImage(src) {
|
|
64
|
+
this.editor.chain().focus().setImage({ src }).run()
|
|
65
|
+
this.resetAddImage()
|
|
66
|
+
},
|
|
67
|
+
resetAddImage() {
|
|
68
|
+
this.addImageDialog = { show: false, url: null, file: null }
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
</script>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
v-if="items.length"
|
|
4
|
+
class="p-1 text-base bg-white border rounded-lg shadow-lg min-w-40"
|
|
5
|
+
>
|
|
6
|
+
<button
|
|
7
|
+
:class="[
|
|
8
|
+
index === selectedIndex ? 'bg-gray-100' : 'text-gray-900',
|
|
9
|
+
'whitespace-nowrap flex rounded-md items-center w-full px-2 py-2 text-sm',
|
|
10
|
+
]"
|
|
11
|
+
v-for="(item, index) in items"
|
|
12
|
+
:key="index"
|
|
13
|
+
@click="selectItem(index)"
|
|
14
|
+
@mouseover="selectedIndex = index"
|
|
15
|
+
>
|
|
16
|
+
{{ item.label }}
|
|
17
|
+
</button>
|
|
18
|
+
</div>
|
|
19
|
+
</template>
|
|
20
|
+
|
|
21
|
+
<script>
|
|
22
|
+
export default {
|
|
23
|
+
props: {
|
|
24
|
+
items: {
|
|
25
|
+
type: Array,
|
|
26
|
+
required: true,
|
|
27
|
+
},
|
|
28
|
+
command: {
|
|
29
|
+
type: Function,
|
|
30
|
+
required: true,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
data() {
|
|
34
|
+
return {
|
|
35
|
+
selectedIndex: 0,
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
watch: {
|
|
39
|
+
items() {
|
|
40
|
+
this.selectedIndex = 0
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
methods: {
|
|
44
|
+
onKeyDown({ event }) {
|
|
45
|
+
if (event.key === 'ArrowUp') {
|
|
46
|
+
this.upHandler()
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
if (event.key === 'ArrowDown') {
|
|
50
|
+
this.downHandler()
|
|
51
|
+
return true
|
|
52
|
+
}
|
|
53
|
+
if (event.key === 'Enter') {
|
|
54
|
+
this.enterHandler()
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
return false
|
|
58
|
+
},
|
|
59
|
+
upHandler() {
|
|
60
|
+
this.selectedIndex =
|
|
61
|
+
(this.selectedIndex + this.items.length - 1) % this.items.length
|
|
62
|
+
},
|
|
63
|
+
downHandler() {
|
|
64
|
+
this.selectedIndex = (this.selectedIndex + 1) % this.items.length
|
|
65
|
+
},
|
|
66
|
+
enterHandler() {
|
|
67
|
+
this.selectItem(this.selectedIndex)
|
|
68
|
+
},
|
|
69
|
+
selectItem(index) {
|
|
70
|
+
const item = this.items[index]
|
|
71
|
+
if (item) {
|
|
72
|
+
this.command({ id: item.value, label: item.label })
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<style>
|
|
80
|
+
.item {
|
|
81
|
+
display: block;
|
|
82
|
+
margin: 0;
|
|
83
|
+
width: 100%;
|
|
84
|
+
text-align: left;
|
|
85
|
+
background: transparent;
|
|
86
|
+
border-radius: 0.4rem;
|
|
87
|
+
border: 1px solid transparent;
|
|
88
|
+
padding: 0.2rem 0.4rem;
|
|
89
|
+
}
|
|
90
|
+
.item.is-selected {
|
|
91
|
+
border-color: #000;
|
|
92
|
+
}
|
|
93
|
+
</style>
|
|
@@ -67,43 +67,12 @@
|
|
|
67
67
|
</Button>
|
|
68
68
|
</template>
|
|
69
69
|
</Dialog>
|
|
70
|
-
<
|
|
71
|
-
:options="{ title: 'Add Image' }"
|
|
72
|
-
v-model="addImageDialog.show"
|
|
73
|
-
@after-leave="resetAddImage"
|
|
74
|
-
>
|
|
75
|
-
<template #body-content>
|
|
76
|
-
<label
|
|
77
|
-
class="relative py-1 bg-gray-100 rounded-lg cursor-pointer focus-within:bg-gray-200 hover:bg-gray-200"
|
|
78
|
-
>
|
|
79
|
-
<input
|
|
80
|
-
type="file"
|
|
81
|
-
class="w-full opacity-0"
|
|
82
|
-
@change="onImageSelect"
|
|
83
|
-
accept="image/*"
|
|
84
|
-
/>
|
|
85
|
-
<span class="absolute inset-0 px-2 py-1 text-base select-none">
|
|
86
|
-
{{
|
|
87
|
-
addImageDialog.file ? 'Select another image' : 'Select an image'
|
|
88
|
-
}}
|
|
89
|
-
</span>
|
|
90
|
-
</label>
|
|
91
|
-
<img
|
|
92
|
-
v-if="addImageDialog.url"
|
|
93
|
-
:src="addImageDialog.url"
|
|
94
|
-
class="w-full mt-2 rounded-lg"
|
|
95
|
-
/>
|
|
96
|
-
</template>
|
|
97
|
-
<template #actions>
|
|
98
|
-
<Button appearance="primary" @click="addImage(addImageDialog.url)">
|
|
99
|
-
Insert Image
|
|
100
|
-
</Button>
|
|
101
|
-
</template>
|
|
102
|
-
</Dialog>
|
|
70
|
+
<InsertImage ref="insertImage" :editor="editor" />
|
|
103
71
|
</div>
|
|
104
72
|
</template>
|
|
105
73
|
<script>
|
|
106
74
|
import { Popover, Dialog, Input, Button } from 'frappe-ui'
|
|
75
|
+
import InsertImage from './InsertImage.vue'
|
|
107
76
|
export default {
|
|
108
77
|
name: 'TipTapMenu',
|
|
109
78
|
props: ['editor', 'buttons'],
|
|
@@ -112,11 +81,11 @@ export default {
|
|
|
112
81
|
Dialog,
|
|
113
82
|
Input,
|
|
114
83
|
Button,
|
|
84
|
+
InsertImage,
|
|
115
85
|
},
|
|
116
86
|
data() {
|
|
117
87
|
return {
|
|
118
88
|
setLinkDialog: { url: '', show: false },
|
|
119
|
-
addImageDialog: { url: '', file: null, show: false },
|
|
120
89
|
}
|
|
121
90
|
},
|
|
122
91
|
methods: {
|
|
@@ -128,7 +97,7 @@ export default {
|
|
|
128
97
|
this.setLinkDialog.url = existingURL
|
|
129
98
|
}
|
|
130
99
|
} else if (button.label === 'Image') {
|
|
131
|
-
this.
|
|
100
|
+
this.$refs.insertImage.openDialog()
|
|
132
101
|
} else {
|
|
133
102
|
button.action(this.editor)
|
|
134
103
|
}
|
|
@@ -150,26 +119,6 @@ export default {
|
|
|
150
119
|
this.setLinkDialog.show = false
|
|
151
120
|
this.setLinkDialog.url = ''
|
|
152
121
|
},
|
|
153
|
-
onImageSelect(e) {
|
|
154
|
-
let file = e.target.files[0]
|
|
155
|
-
if (!file) {
|
|
156
|
-
return
|
|
157
|
-
}
|
|
158
|
-
this.addImageDialog.file = file
|
|
159
|
-
let reader = new FileReader()
|
|
160
|
-
reader.onloadend = () => {
|
|
161
|
-
let base64string = reader.result
|
|
162
|
-
this.addImageDialog.url = base64string
|
|
163
|
-
}
|
|
164
|
-
reader.readAsDataURL(file)
|
|
165
|
-
},
|
|
166
|
-
addImage(src) {
|
|
167
|
-
this.editor.chain().focus().setImage({ src }).run()
|
|
168
|
-
this.resetAddImage()
|
|
169
|
-
},
|
|
170
|
-
resetAddImage() {
|
|
171
|
-
this.addImageDialog = { show: false, url: null, file: null }
|
|
172
|
-
},
|
|
173
122
|
},
|
|
174
123
|
}
|
|
175
124
|
</script>
|
|
@@ -41,12 +41,6 @@
|
|
|
41
41
|
</button>
|
|
42
42
|
</FloatingMenu>
|
|
43
43
|
<editor-content :editor="editor" />
|
|
44
|
-
<span
|
|
45
|
-
v-if="!content"
|
|
46
|
-
class="absolute inset-y-0 text-base text-gray-500 pointer-events-none"
|
|
47
|
-
>
|
|
48
|
-
{{ placeholder }}
|
|
49
|
-
</span>
|
|
50
44
|
</div>
|
|
51
45
|
</template>
|
|
52
46
|
|
|
@@ -55,8 +49,9 @@ import { Editor, EditorContent, BubbleMenu, FloatingMenu } from '@tiptap/vue-3'
|
|
|
55
49
|
import StarterKit from '@tiptap/starter-kit'
|
|
56
50
|
import Placeholder from '@tiptap/extension-placeholder'
|
|
57
51
|
import TextAlign from '@tiptap/extension-text-align'
|
|
58
|
-
import Image from '
|
|
52
|
+
import Image from './image-extension'
|
|
59
53
|
import Link from '@tiptap/extension-link'
|
|
54
|
+
import configureMention from './mention'
|
|
60
55
|
import Menu from './Menu.vue'
|
|
61
56
|
import commands from './commands'
|
|
62
57
|
import { normalizeClass } from 'vue'
|
|
@@ -70,17 +65,48 @@ export default {
|
|
|
70
65
|
FloatingMenu,
|
|
71
66
|
Menu,
|
|
72
67
|
},
|
|
73
|
-
props:
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
68
|
+
props: {
|
|
69
|
+
content: {
|
|
70
|
+
type: String,
|
|
71
|
+
default: null,
|
|
72
|
+
},
|
|
73
|
+
placeholder: {
|
|
74
|
+
type: String,
|
|
75
|
+
default: '',
|
|
76
|
+
},
|
|
77
|
+
editorClass: {
|
|
78
|
+
type: [String, Array, Object],
|
|
79
|
+
default: '',
|
|
80
|
+
},
|
|
81
|
+
editable: {
|
|
82
|
+
type: Boolean,
|
|
83
|
+
default: true,
|
|
84
|
+
},
|
|
85
|
+
bubbleMenu: {
|
|
86
|
+
type: [Boolean, Array],
|
|
87
|
+
default: false,
|
|
88
|
+
},
|
|
89
|
+
fixedMenu: {
|
|
90
|
+
type: [Boolean, Array],
|
|
91
|
+
default: false,
|
|
92
|
+
},
|
|
93
|
+
floatingMenu: {
|
|
94
|
+
type: [Boolean, Array],
|
|
95
|
+
default: false,
|
|
96
|
+
},
|
|
97
|
+
extensions: {
|
|
98
|
+
type: Array,
|
|
99
|
+
default: () => [],
|
|
100
|
+
},
|
|
101
|
+
starterkitOptions: {
|
|
102
|
+
type: Object,
|
|
103
|
+
default: () => ({}),
|
|
104
|
+
},
|
|
105
|
+
mentions: {
|
|
106
|
+
type: Array,
|
|
107
|
+
default: () => [],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
84
110
|
emits: ['change'],
|
|
85
111
|
expose: ['editor'],
|
|
86
112
|
data() {
|
|
@@ -119,13 +145,12 @@ export default {
|
|
|
119
145
|
TextAlign.configure({
|
|
120
146
|
types: ['heading', 'paragraph'],
|
|
121
147
|
}),
|
|
122
|
-
Image
|
|
123
|
-
allowBase64: true,
|
|
124
|
-
}),
|
|
148
|
+
Image,
|
|
125
149
|
Link,
|
|
126
150
|
Placeholder.configure({
|
|
127
|
-
placeholder: this.placeholder
|
|
151
|
+
placeholder: this.placeholder,
|
|
128
152
|
}),
|
|
153
|
+
configureMention(this.mentions),
|
|
129
154
|
...(this.extensions || []),
|
|
130
155
|
],
|
|
131
156
|
onUpdate: ({ editor }) => {
|
|
@@ -248,4 +273,8 @@ function createEditorButton(option) {
|
|
|
248
273
|
pointer-events: none;
|
|
249
274
|
height: 0;
|
|
250
275
|
}
|
|
276
|
+
.mention {
|
|
277
|
+
font-weight: 600;
|
|
278
|
+
box-decoration-break: clone;
|
|
279
|
+
}
|
|
251
280
|
</style>
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import tippy from 'tippy.js'
|
|
2
|
+
import { VueRenderer } from '@tiptap/vue-3'
|
|
3
|
+
import Mention from '@tiptap/extension-mention'
|
|
4
|
+
import MentionList from './MentionList.vue'
|
|
5
|
+
|
|
6
|
+
export default function configureMention(options) {
|
|
7
|
+
return Mention.configure({
|
|
8
|
+
HTMLAttributes: {
|
|
9
|
+
class: 'mention',
|
|
10
|
+
},
|
|
11
|
+
suggestion: getSuggestionOptions(options),
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getSuggestionOptions(options) {
|
|
16
|
+
return {
|
|
17
|
+
items: ({ query }) => {
|
|
18
|
+
return options
|
|
19
|
+
.filter((item) =>
|
|
20
|
+
item.label.toLowerCase().startsWith(query.toLowerCase())
|
|
21
|
+
)
|
|
22
|
+
.slice(0, 5)
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
render: () => {
|
|
26
|
+
let component
|
|
27
|
+
let popup
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
onStart: (props) => {
|
|
31
|
+
component = new VueRenderer(MentionList, {
|
|
32
|
+
props,
|
|
33
|
+
editor: props.editor,
|
|
34
|
+
})
|
|
35
|
+
if (!props.clientRect) {
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
popup = tippy('body', {
|
|
39
|
+
getReferenceClientRect: props.clientRect,
|
|
40
|
+
appendTo: () => document.body,
|
|
41
|
+
content: component.element,
|
|
42
|
+
showOnCreate: true,
|
|
43
|
+
interactive: true,
|
|
44
|
+
trigger: 'manual',
|
|
45
|
+
placement: 'bottom-start',
|
|
46
|
+
})
|
|
47
|
+
},
|
|
48
|
+
onUpdate(props) {
|
|
49
|
+
component.updateProps(props)
|
|
50
|
+
if (!props.clientRect) {
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
popup[0].setProps({
|
|
54
|
+
getReferenceClientRect: props.clientRect,
|
|
55
|
+
})
|
|
56
|
+
},
|
|
57
|
+
onKeyDown(props) {
|
|
58
|
+
if (props.event.key === 'Escape') {
|
|
59
|
+
popup[0].hide()
|
|
60
|
+
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
return component.ref?.onKeyDown(props)
|
|
64
|
+
},
|
|
65
|
+
onExit() {
|
|
66
|
+
popup[0].destroy()
|
|
67
|
+
component.destroy()
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Popover trigger="hover" :hoverDelay="hoverDelay" :placement="placement">
|
|
3
|
+
<template #target>
|
|
4
|
+
<slot />
|
|
5
|
+
</template>
|
|
6
|
+
<template #body>
|
|
7
|
+
<slot name="body">
|
|
8
|
+
<div
|
|
9
|
+
class="px-2 py-1 text-xs text-white bg-gray-800 border border-gray-100 rounded-lg shadow-xl"
|
|
10
|
+
>
|
|
11
|
+
{{ text }}
|
|
12
|
+
</div>
|
|
13
|
+
</slot>
|
|
14
|
+
</template>
|
|
15
|
+
</Popover>
|
|
16
|
+
</template>
|
|
17
|
+
<script>
|
|
18
|
+
import Popover from './Popover.vue'
|
|
19
|
+
export default {
|
|
20
|
+
name: 'Tooltip',
|
|
21
|
+
components: { Popover },
|
|
22
|
+
props: {
|
|
23
|
+
hoverDelay: {
|
|
24
|
+
default: 0.5,
|
|
25
|
+
},
|
|
26
|
+
placement: {
|
|
27
|
+
default: 'bottom',
|
|
28
|
+
},
|
|
29
|
+
text: {
|
|
30
|
+
type: String,
|
|
31
|
+
default: '',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
</script>
|
package/src/index.js
CHANGED
|
@@ -23,6 +23,7 @@ export { default as Resource } from './components/Resource.vue'
|
|
|
23
23
|
export { default as Spinner } from './components/Spinner.vue'
|
|
24
24
|
export { default as SuccessMessage } from './components/SuccessMessage.vue'
|
|
25
25
|
export { default as TextEditor } from './components/TextEditor'
|
|
26
|
+
export { default as Tooltip } from './components/Tooltip.vue'
|
|
26
27
|
|
|
27
28
|
// directives
|
|
28
29
|
export { default as onOutsideClickDirective } from './directives/onOutsideClick.js'
|
package/src/utils/resources.js
CHANGED
|
@@ -201,6 +201,7 @@ export function createDocumentResource(options, vm) {
|
|
|
201
201
|
doctype: options.doctype,
|
|
202
202
|
name: options.name,
|
|
203
203
|
doc: null,
|
|
204
|
+
auto: true,
|
|
204
205
|
get: createResource(
|
|
205
206
|
{
|
|
206
207
|
method: 'frappe.client.get',
|
|
@@ -238,6 +239,8 @@ export function createDocumentResource(options, vm) {
|
|
|
238
239
|
onSuccess() {
|
|
239
240
|
out.doc = null
|
|
240
241
|
options.delete?.onSuccess?.call(vm, data)
|
|
242
|
+
// delete from list resources
|
|
243
|
+
deleteRowInListResource(out.doctype, out.name)
|
|
241
244
|
},
|
|
242
245
|
onError: options.delete?.onError,
|
|
243
246
|
},
|
|
@@ -295,8 +298,6 @@ export function createDocumentResource(options, vm) {
|
|
|
295
298
|
return doc
|
|
296
299
|
}
|
|
297
300
|
|
|
298
|
-
// fetch the doc
|
|
299
|
-
out.get.fetch()
|
|
300
301
|
// cache
|
|
301
302
|
documentCache[cacheKey] = out
|
|
302
303
|
return out
|
|
@@ -323,6 +324,7 @@ export function createListResource(options, vm, getResource) {
|
|
|
323
324
|
data: null,
|
|
324
325
|
next,
|
|
325
326
|
hasNextPage: true,
|
|
327
|
+
auto: true,
|
|
326
328
|
list: createResource(
|
|
327
329
|
{
|
|
328
330
|
method: 'frappe.client.get_list',
|
|
@@ -451,7 +453,6 @@ export function createListResource(options, vm, getResource) {
|
|
|
451
453
|
out.order_by = updatedOptions.order_by
|
|
452
454
|
out.start = updatedOptions.start
|
|
453
455
|
out.limit = updatedOptions.limit
|
|
454
|
-
out.list.fetch()
|
|
455
456
|
}
|
|
456
457
|
|
|
457
458
|
function transform(data) {
|
|
@@ -481,9 +482,6 @@ export function createListResource(options, vm, getResource) {
|
|
|
481
482
|
out.list.fetch()
|
|
482
483
|
}
|
|
483
484
|
|
|
484
|
-
// fetch list
|
|
485
|
-
out.list.fetch()
|
|
486
|
-
|
|
487
485
|
if (cacheKey) {
|
|
488
486
|
// cache
|
|
489
487
|
listCache[cacheKey] = out
|
|
@@ -515,6 +513,18 @@ function updateRowInListResource(doctype, doc) {
|
|
|
515
513
|
}
|
|
516
514
|
}
|
|
517
515
|
|
|
516
|
+
function deleteRowInListResource(doctype, docname) {
|
|
517
|
+
let resources = listResources[doctype] || []
|
|
518
|
+
for (let resource of resources) {
|
|
519
|
+
if (resource.originalData) {
|
|
520
|
+
resource.originalData = resource.originalData.filter(
|
|
521
|
+
(row) => row.name !== docname
|
|
522
|
+
)
|
|
523
|
+
resource.data = resource.transform(resource.originalData)
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
518
528
|
function revertRowInListResource(doctype, doc) {
|
|
519
529
|
let resources = listResources[doctype] || []
|
|
520
530
|
for (let resource of resources) {
|
|
@@ -593,7 +603,7 @@ let createMixin = (mixinOptions) => ({
|
|
|
593
603
|
resource.update(updatedOptions)
|
|
594
604
|
}
|
|
595
605
|
if (resource && resource.auto) {
|
|
596
|
-
resource.
|
|
606
|
+
resource.reload()
|
|
597
607
|
}
|
|
598
608
|
},
|
|
599
609
|
{
|
|
@@ -607,8 +617,8 @@ let createMixin = (mixinOptions) => ({
|
|
|
607
617
|
mixinOptions.getResource
|
|
608
618
|
)
|
|
609
619
|
this._resources[key] = resource
|
|
610
|
-
if (resource.auto) {
|
|
611
|
-
resource.
|
|
620
|
+
if (resource && resource.auto) {
|
|
621
|
+
resource.reload()
|
|
612
622
|
}
|
|
613
623
|
}
|
|
614
624
|
}
|