frappe-ui 0.0.46 → 0.0.49

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.0.46",
3
+ "version": "0.0.49",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "main": "./src/index.js",
6
6
  "scripts": {
@@ -22,17 +22,19 @@
22
22
  "@popperjs/core": "^2.11.2",
23
23
  "@tailwindcss/forms": "^0.4.0",
24
24
  "@tailwindcss/typography": "^0.5.0",
25
- "@tiptap/extension-image": "^2.0.0-beta.27",
26
- "@tiptap/extension-link": "^2.0.0-beta.37",
27
- "@tiptap/extension-placeholder": "^2.0.0-beta.48",
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",
28
29
  "@tiptap/extension-text-align": "^2.0.0-beta.31",
29
- "@tiptap/starter-kit": "^2.0.0-beta.183",
30
- "@tiptap/vue-3": "^2.0.0-beta.90",
30
+ "@tiptap/starter-kit": "^2.0.0-beta.191",
31
+ "@tiptap/vue-3": "^2.0.0-beta.96",
31
32
  "autoprefixer": "^10.4.2",
32
33
  "feather-icons": "^4.28.0",
33
34
  "postcss": "^8.4.5",
34
35
  "socket.io-client": "^4.5.1",
35
- "tailwindcss": "^3.0.12"
36
+ "tailwindcss": "^3.0.12",
37
+ "tippy.js": "^6.3.7"
36
38
  },
37
39
  "devDependencies": {
38
40
  "husky": ">=6",
@@ -77,6 +77,7 @@ export default {
77
77
  },
78
78
  type: {
79
79
  type: String,
80
+ default: 'text',
80
81
  validator(value) {
81
82
  let isValid = [
82
83
  'text',
@@ -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
- <Dialog
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.addImageDialog.show = true
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 top-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 '@tiptap/extension-image'
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
- 'content',
75
- 'placeholder',
76
- 'editorClass',
77
- 'editable',
78
- 'fixedMenu',
79
- 'bubbleMenu',
80
- 'floatingMenu',
81
- 'extensions',
82
- 'starterkitOptions',
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,15 @@ export default {
119
145
  TextAlign.configure({
120
146
  types: ['heading', 'paragraph'],
121
147
  }),
122
- Image.configure({
123
- allowBase64: true,
124
- }),
148
+ Image,
125
149
  Link,
126
150
  Placeholder.configure({
127
- placeholder: this.placeholder || 'Write something...',
151
+ showOnlyWhenEditable: false,
152
+ placeholder: () => {
153
+ return this.placeholder
154
+ },
128
155
  }),
156
+ configureMention(this.mentions),
129
157
  ...(this.extensions || []),
130
158
  ],
131
159
  onUpdate: ({ editor }) => {
@@ -248,4 +276,8 @@ function createEditorButton(option) {
248
276
  pointer-events: none;
249
277
  height: 0;
250
278
  }
279
+ .mention {
280
+ font-weight: 600;
281
+ box-decoration-break: clone;
282
+ }
251
283
  </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
+ }
@@ -18,10 +18,14 @@
18
18
  </div>
19
19
  <div>
20
20
  <slot>
21
- <p class="text-base font-medium text-gray-900">
21
+ <p
22
+ v-if="title"
23
+ class="text-base font-medium text-gray-900"
24
+ :class="{ 'mb-1': text }"
25
+ >
22
26
  {{ title }}
23
27
  </p>
24
- <p class="mt-1 text-base text-gray-600">
28
+ <p v-if="text" class="text-base text-gray-600">
25
29
  {{ text }}
26
30
  </p>
27
31
  </slot>
@@ -24,7 +24,7 @@ export default {
24
24
  default: 0.5,
25
25
  },
26
26
  placement: {
27
- default: 'bottom-start',
27
+ default: 'bottom',
28
28
  },
29
29
  text: {
30
30
  type: String,
@@ -0,0 +1,9 @@
1
+ export default (file) => {
2
+ return new Promise((resolve) => {
3
+ let reader = new FileReader()
4
+ reader.onloadend = () => {
5
+ resolve(reader.result)
6
+ }
7
+ reader.readAsDataURL(file)
8
+ })
9
+ }
@@ -1,7 +1,7 @@
1
1
  module.exports = {
2
2
  getProxyOptions({ port }) {
3
3
  return {
4
- '^/(app|api|assets|files)': {
4
+ '^/(app|login|api|assets|files)': {
5
5
  target: `http://localhost:${port}`,
6
6
  ws: true,
7
7
  router: function (req) {