frappe-ui 0.1.162 → 0.1.164

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,11 +1,12 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "0.1.162",
3
+ "version": "0.1.164",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "main": "./src/index.ts",
6
6
  "type": "module",
7
7
  "scripts": {
8
8
  "test": "vitest --run",
9
+ "type-check": "tsc --noEmit",
9
10
  "prettier": "yarn prettier -w ./src",
10
11
  "bump-and-release": "yarn test && git pull --rebase origin main && yarn version --patch && git push && git push --tags",
11
12
  "dev": "vite",
@@ -62,6 +63,7 @@
62
63
  "idb-keyval": "^6.2.0",
63
64
  "lowlight": "^3.3.0",
64
65
  "lucide-static": "^0.479.0",
66
+ "marked": "^15.0.12",
65
67
  "ora": "5.4.1",
66
68
  "prettier": "^3.3.2",
67
69
  "prosemirror-model": "^1.25.1",
@@ -69,7 +71,6 @@
69
71
  "prosemirror-view": "^1.39.2",
70
72
  "radix-vue": "^1.5.3",
71
73
  "reka-ui": "^2.0.2",
72
- "showdown": "^2.1.0",
73
74
  "socket.io-client": "^4.5.1",
74
75
  "tippy.js": "^6.3.7",
75
76
  "typescript": "^5.0.2",
@@ -0,0 +1,300 @@
1
+ <script setup lang="ts">
2
+ import { ref, reactive } from 'vue'
3
+ import Combobox from './Combobox.vue'
4
+
5
+ const simpleValue = ref('')
6
+ const objectValue = ref('')
7
+ const iconValue = ref('')
8
+ const groupedValue = ref('')
9
+ const disabledValue = ref('')
10
+ const preselectedValue = ref('john-doe')
11
+ const multipleSimpleValue = ref([])
12
+ const multipleObjectValue = ref([])
13
+ const multipleGroupedValue = ref(['apple', 'carrot'])
14
+ const complexObjectValue = ref(null)
15
+ const selectedOption = ref(null)
16
+
17
+ // Complex objects for displayValue demo
18
+ const complexObjects = [
19
+ {
20
+ label: 'John Doe (Admin)',
21
+ value: 'john-doe',
22
+ email: 'john@example.com',
23
+ role: 'Admin',
24
+ },
25
+ {
26
+ label: 'Jane Smith (User)',
27
+ value: 'jane-smith',
28
+ email: 'jane@example.com',
29
+ role: 'User',
30
+ },
31
+ {
32
+ label: 'Bob Johnson (Manager)',
33
+ value: 'bob-johnson',
34
+ email: 'bob@example.com',
35
+ role: 'Manager',
36
+ },
37
+ {
38
+ label: 'Alice Brown (User)',
39
+ value: 'alice-brown',
40
+ email: 'alice@example.com',
41
+ role: 'User',
42
+ },
43
+ ]
44
+
45
+ const simpleOptions = [
46
+ 'John Doe',
47
+ 'Jane Doe',
48
+ 'John Smith',
49
+ 'Jane Smith',
50
+ 'John Wayne',
51
+ 'Jane Wayne',
52
+ 'Alice Johnson',
53
+ 'Bob Wilson',
54
+ 'Charlie Brown',
55
+ 'Diana Prince',
56
+ ]
57
+
58
+ const objectOptions = [
59
+ { label: 'John Doe', value: 'john-doe' },
60
+ { label: 'Jane Doe', value: 'jane-doe' },
61
+ { label: 'John Smith', value: 'john-smith' },
62
+ { label: 'Jane Smith', value: 'jane-smith', disabled: true },
63
+ { label: 'John Wayne', value: 'john-wayne' },
64
+ { label: 'Jane Wayne', value: 'jane-wayne' },
65
+ { label: 'Alice Johnson', value: 'alice-johnson' },
66
+ { label: 'Bob Wilson', value: 'bob-wilson' },
67
+ ]
68
+
69
+ const optionsWithIcons = [
70
+ { label: 'Dashboard', value: 'dashboard', icon: '📊' },
71
+ { label: 'Projects', value: 'projects', icon: '📁' },
72
+ { label: 'Tasks', value: 'tasks', icon: '✅' },
73
+ { label: 'Calendar', value: 'calendar', icon: '📅' },
74
+ { label: 'Reports', value: 'reports', icon: '📈' },
75
+ { label: 'Settings', value: 'settings', icon: '⚙️' },
76
+ ]
77
+
78
+ const groupedOptions = [
79
+ {
80
+ group: 'Fruits',
81
+ options: [
82
+ { label: 'Apple', value: 'apple', icon: '🍎' },
83
+ { label: 'Banana', value: 'banana', icon: '🍌' },
84
+ { label: 'Orange', value: 'orange', icon: '🍊' },
85
+ { label: 'Grape', value: 'grape', icon: '🍇' },
86
+ ],
87
+ },
88
+ {
89
+ group: 'Vegetables',
90
+ options: [
91
+ { label: 'Carrot', value: 'carrot', icon: '🥕' },
92
+ { label: 'Broccoli', value: 'broccoli', icon: '🥦' },
93
+ { label: 'Tomato', value: 'tomato', icon: '🍅' },
94
+ { label: 'Lettuce', value: 'lettuce', icon: '🥬' },
95
+ ],
96
+ },
97
+ {
98
+ group: 'Proteins',
99
+ options: [
100
+ { label: 'Chicken', value: 'chicken', icon: '🍗' },
101
+ { label: 'Fish', value: 'fish', icon: '🐟' },
102
+ { label: 'Beef', value: 'beef', icon: '🥩' },
103
+ { label: 'Tofu', value: 'tofu', icon: '🪤', disabled: true },
104
+ ],
105
+ },
106
+ ]
107
+
108
+ const state = reactive({
109
+ disabled: false,
110
+ placeholder: 'Select an option...',
111
+ showCancel: true,
112
+ })
113
+ </script>
114
+
115
+ <template>
116
+ <Story title="Combobox" :layout="{ type: 'grid', width: 400 }">
117
+ <Variant title="Simple String Options">
118
+ <div class="p-4">
119
+ <label class="block text-sm font-medium mb-2">Simple Options</label>
120
+ <Combobox
121
+ :options="simpleOptions"
122
+ v-model="simpleValue"
123
+ :placeholder="state.placeholder"
124
+ :disabled="state.disabled"
125
+ :show-cancel="state.showCancel"
126
+ @update:selectedOption="selectedOption = $event"
127
+ />
128
+ <div class="mt-2 text-sm text-gray-600">
129
+ Selected: {{ simpleValue || 'None' }}
130
+ </div>
131
+ </div>
132
+ </Variant>
133
+
134
+ <Variant title="Object Options">
135
+ <div class="p-4">
136
+ <label class="block text-sm font-medium mb-2">Object Options</label>
137
+ <Combobox
138
+ :options="objectOptions"
139
+ v-model="objectValue"
140
+ :placeholder="state.placeholder"
141
+ :disabled="state.disabled"
142
+ :show-cancel="state.showCancel"
143
+ />
144
+ <div class="mt-2 text-sm text-gray-600">
145
+ Selected: {{ objectValue || 'None' }}
146
+ </div>
147
+ </div>
148
+ </Variant>
149
+
150
+ <Variant title="Options with Icons">
151
+ <div class="p-4">
152
+ <label class="block text-sm font-medium mb-2">Options with Icons</label>
153
+ <Combobox
154
+ :options="optionsWithIcons"
155
+ v-model="iconValue"
156
+ :placeholder="state.placeholder"
157
+ :disabled="state.disabled"
158
+ />
159
+ <div class="mt-2 text-sm text-gray-600">
160
+ Selected: {{ iconValue || 'None' }}
161
+ </div>
162
+ </div>
163
+ </Variant>
164
+
165
+ <Variant title="Grouped Options">
166
+ <div class="p-4">
167
+ <label class="block text-sm font-medium mb-2">Grouped Options</label>
168
+ <Combobox
169
+ :options="groupedOptions"
170
+ v-model="groupedValue"
171
+ :placeholder="state.placeholder"
172
+ :disabled="state.disabled"
173
+ />
174
+ <div class="mt-2 text-sm text-gray-600">
175
+ Selected: {{ groupedValue || 'None' }}
176
+ </div>
177
+ </div>
178
+ </Variant>
179
+
180
+ <Variant title="Disabled State">
181
+ <div class="p-4">
182
+ <label class="block text-sm font-medium mb-2">Disabled Combobox</label>
183
+ <Combobox
184
+ :options="simpleOptions"
185
+ v-model="disabledValue"
186
+ placeholder="This is disabled"
187
+ :disabled="true"
188
+ />
189
+ </div>
190
+ </Variant>
191
+
192
+ <Variant title="Pre-selected Value">
193
+ <div class="p-4">
194
+ <label class="block text-sm font-medium mb-2">Pre-selected Value</label>
195
+ <Combobox
196
+ :options="objectOptions"
197
+ v-model="preselectedValue"
198
+ :placeholder="state.placeholder"
199
+ :disabled="state.disabled"
200
+ />
201
+ <div class="mt-2 text-sm text-gray-600">
202
+ Selected: {{ preselectedValue || 'None' }}
203
+ </div>
204
+ </div>
205
+ </Variant>
206
+
207
+ <Variant title="Multiple Selection - Simple">
208
+ <div class="p-4">
209
+ <label class="block text-sm font-medium mb-2"
210
+ >Multiple Simple Options</label
211
+ >
212
+ <Combobox
213
+ :options="simpleOptions"
214
+ v-model="multipleSimpleValue"
215
+ :placeholder="state.placeholder"
216
+ :disabled="state.disabled"
217
+ :show-cancel="state.showCancel"
218
+ :multiple="true"
219
+ />
220
+ <div class="mt-2 text-sm text-gray-600">
221
+ Selected:
222
+ {{
223
+ multipleSimpleValue.length > 0
224
+ ? multipleSimpleValue.join(', ')
225
+ : 'None'
226
+ }}
227
+ </div>
228
+ </div>
229
+ </Variant>
230
+
231
+ <Variant title="Multiple Selection - Objects">
232
+ <div class="p-4">
233
+ <label class="block text-sm font-medium mb-2"
234
+ >Multiple Object Options</label
235
+ >
236
+ <Combobox
237
+ :options="objectOptions"
238
+ v-model="multipleObjectValue"
239
+ :placeholder="state.placeholder"
240
+ :disabled="state.disabled"
241
+ :multiple="true"
242
+ />
243
+ <div class="mt-2 text-sm text-gray-600">
244
+ Selected:
245
+ {{
246
+ multipleObjectValue.length > 0
247
+ ? multipleObjectValue.join(', ')
248
+ : 'None'
249
+ }}
250
+ </div>
251
+ </div>
252
+ </Variant>
253
+
254
+ <Variant title="Multiple Selection - Grouped">
255
+ <div class="p-4">
256
+ <label class="block text-sm font-medium mb-2"
257
+ >Multiple Grouped Options</label
258
+ >
259
+ <Combobox
260
+ :options="groupedOptions"
261
+ v-model="multipleGroupedValue"
262
+ :placeholder="state.placeholder"
263
+ :disabled="state.disabled"
264
+ :multiple="true"
265
+ />
266
+ <div class="mt-2 text-sm text-gray-600">
267
+ Selected:
268
+ {{
269
+ multipleGroupedValue.length > 0
270
+ ? multipleGroupedValue.join(', ')
271
+ : 'None'
272
+ }}
273
+ </div>
274
+ </div>
275
+ </Variant>
276
+
277
+ <Variant title="Complex Objects with Display Value">
278
+ <div class="p-4">
279
+ <label class="block text-sm font-medium mb-2">Complex Objects</label>
280
+ <Combobox
281
+ :options="complexObjects"
282
+ v-model="complexObjectValue"
283
+ :display-value="(obj) => (obj ? `${obj.label} - ${obj.email}` : '')"
284
+ :placeholder="state.placeholder"
285
+ :disabled="state.disabled"
286
+ :show-cancel="state.showCancel"
287
+ />
288
+ <div class="mt-2 text-sm text-gray-600">
289
+ Selected: {{ complexObjectValue || 'None' }}
290
+ </div>
291
+ </div>
292
+ </Variant>
293
+
294
+ <template #controls>
295
+ <HstText v-model="state.placeholder" title="Placeholder" />
296
+ <HstCheckbox v-model="state.disabled" title="Disabled" />
297
+ <HstCheckbox v-model="state.showCancel" title="Show Cancel Button" />
298
+ </template>
299
+ </Story>
300
+ </template>
@@ -1,6 +1,6 @@
1
1
  <template>
2
2
  <RadioGroup v-model="value">
3
- <div class="flex space-x-1 rounded bg-surface-gray-2 p-0.5 text-sm">
3
+ <div class="flex space-x-1 rounded bg-surface-gray-2 p-[1px] text-sm">
4
4
  <RadioGroupOption
5
5
  as="template"
6
6
  v-for="button in buttons"
@@ -1,9 +1,9 @@
1
1
  <template>
2
2
  <div
3
- class="relative w-full"
4
- :class="$attrs.class"
5
- :style="$attrs.style"
6
3
  v-if="editor"
4
+ class="relative w-full"
5
+ :class="attrsClass"
6
+ :style="attrsStyle"
7
7
  >
8
8
  <TextEditorBubbleMenu :buttons="bubbleMenu" :options="bubbleMenuOptions" />
9
9
  <TextEditorFixedMenu
@@ -13,14 +13,27 @@
13
13
  <TextEditorFloatingMenu :buttons="floatingMenu" />
14
14
  <slot name="top" />
15
15
  <slot name="editor" :editor="editor">
16
- <editor-content :editor="editor" />
16
+ <EditorContent :editor="editor" />
17
17
  </slot>
18
18
  <slot name="bottom" />
19
19
  </div>
20
20
  </template>
21
21
 
22
- <script lang="ts">
23
- import { normalizeClass, computed, PropType } from 'vue'
22
+ <script setup lang="ts">
23
+ import {
24
+ normalizeClass,
25
+ normalizeStyle,
26
+ computed,
27
+ watch,
28
+ onMounted,
29
+ onBeforeUnmount,
30
+ provide,
31
+ ref,
32
+ useAttrs,
33
+ } from 'vue'
34
+
35
+ defineOptions({ inheritAttrs: false })
36
+
24
37
  import { Editor, EditorContent, VueNodeViewRenderer } from '@tiptap/vue-3'
25
38
  import StarterKit from '@tiptap/starter-kit'
26
39
  import Placeholder from '@tiptap/extension-placeholder'
@@ -46,12 +59,12 @@ import TextEditorBubbleMenu from './TextEditorBubbleMenu.vue'
46
59
  import TextEditorFloatingMenu from './TextEditorFloatingMenu.vue'
47
60
  import EmojiExtension from './extensions/emoji/emoji-extension'
48
61
  import SlashCommands from './extensions/slash-commands/slash-commands-extension'
49
- import { detectMarkdown, markdownToHTML } from '../../utils/markdown'
50
- import { DOMParser } from 'prosemirror-model'
62
+ import { MarkdownPasteExtension } from './extensions/markdown-paste-extension'
51
63
  import { TagNode, TagExtension } from './extensions/tag/tag-extension'
52
64
  import { Heading } from './extensions/heading/heading'
53
65
  import { ImageGroup } from './extensions/image-group/image-group-extension'
54
66
  import { useFileUpload } from '../../utils/useFileUpload'
67
+ import { TextEditorEmits, TextEditorProps } from './types'
55
68
 
56
69
  const lowlight = createLowlight(common)
57
70
 
@@ -61,214 +74,169 @@ function defaultUploadFunction(file: File) {
61
74
  return fileUpload.upload(file)
62
75
  }
63
76
 
64
- export default {
65
- name: 'TextEditor',
66
- inheritAttrs: false,
67
- components: {
68
- EditorContent,
69
- TextEditorFixedMenu,
70
- TextEditorBubbleMenu,
71
- TextEditorFloatingMenu,
72
- },
73
- props: {
74
- content: {
75
- type: String,
76
- default: null,
77
- },
78
- placeholder: {
79
- type: [String, Function],
80
- default: '',
81
- },
82
- editorClass: {
83
- type: [String, Array, Object],
84
- default: '',
85
- },
86
- editable: {
87
- type: Boolean,
88
- default: true,
89
- },
90
- bubbleMenu: {
91
- type: [Boolean, Array],
92
- default: false,
93
- },
94
- bubbleMenuOptions: {
95
- type: Object,
96
- default: () => ({}),
97
- },
98
- fixedMenu: {
99
- type: [Boolean, Array],
100
- default: false,
101
- },
102
- floatingMenu: {
103
- type: [Boolean, Array],
104
- default: false,
105
- },
106
- extensions: {
107
- type: Array,
108
- default: () => [],
109
- },
110
- starterkitOptions: {
111
- type: Object,
112
- default: () => ({}),
113
- },
114
- mentions: {
115
- type: Array,
116
- default: () => [],
117
- },
118
- tags: {
119
- type: Array,
120
- default: () => [],
121
- },
122
- uploadFunction: {
123
- type: Function as PropType<typeof defaultUploadFunction>,
124
- default: defaultUploadFunction,
77
+ const props = withDefaults(defineProps<TextEditorProps>(), {
78
+ content: null,
79
+ placeholder: '',
80
+ editorClass: '',
81
+ editable: true,
82
+ bubbleMenu: false,
83
+ bubbleMenuOptions: () => ({}),
84
+ fixedMenu: false,
85
+ floatingMenu: false,
86
+ extensions: () => [],
87
+ starterkitOptions: () => ({}),
88
+ mentions: () => [],
89
+ tags: () => [],
90
+ })
91
+
92
+ const emit = defineEmits<TextEditorEmits>()
93
+
94
+ const editor = ref<Editor | null>(null)
95
+
96
+ const attrs = useAttrs()
97
+ const attrsClass = computed(() => normalizeClass(attrs.class))
98
+ const attrsStyle = computed(() => normalizeStyle(attrs.style))
99
+
100
+ const editorProps = computed(() => {
101
+ return {
102
+ attributes: {
103
+ class: normalizeClass([
104
+ 'prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2',
105
+ props.editorClass,
106
+ ]),
125
107
  },
108
+ }
109
+ })
110
+
111
+ watch(
112
+ () => props.content,
113
+ (val) => {
114
+ if (editor.value) {
115
+ let currentHTML = editor.value.getHTML()
116
+ if (currentHTML !== val) {
117
+ editor.value.commands.setContent(val)
118
+ }
119
+ }
126
120
  },
127
- emits: ['change', 'focus', 'blur'],
128
- expose: ['editor'],
129
- provide() {
130
- return {
131
- editor: computed(() => this.editor),
121
+ )
122
+
123
+ watch(
124
+ () => props.editable,
125
+ (value) => {
126
+ if (editor.value) {
127
+ editor.value.setEditable(value)
132
128
  }
133
129
  },
134
- data() {
135
- return {
136
- editor: null,
130
+ )
131
+
132
+ watch(
133
+ editorProps,
134
+ (value) => {
135
+ if (editor.value) {
136
+ editor.value.setOptions({
137
+ editorProps: value,
138
+ })
137
139
  }
138
140
  },
139
- watch: {
140
- content(val) {
141
- let currentHTML = this.editor.getHTML()
142
- if (currentHTML !== val) {
143
- this.editor.commands.setContent(val)
144
- }
141
+ { deep: true },
142
+ )
143
+
144
+ onMounted(() => {
145
+ editor.value = new Editor({
146
+ content: props.content || null,
147
+ editorProps: editorProps.value,
148
+ editable: props.editable,
149
+ extensions: [
150
+ StarterKit.configure({
151
+ ...props.starterkitOptions,
152
+ codeBlock: false,
153
+ heading: false,
154
+ }),
155
+ Heading.configure({
156
+ ...(typeof props.starterkitOptions?.heading === 'object' &&
157
+ props.starterkitOptions.heading !== null
158
+ ? props.starterkitOptions.heading
159
+ : {}),
160
+ }),
161
+ Table.configure({
162
+ resizable: true,
163
+ }),
164
+ TableRow,
165
+ TableHeader,
166
+ TableCell,
167
+ Typography,
168
+ TextAlign.configure({
169
+ types: ['heading', 'paragraph'],
170
+ }),
171
+ TextStyle,
172
+ NamedColorExtension,
173
+ NamedHighlightExtension,
174
+ CodeBlockLowlight.extend({
175
+ addNodeView() {
176
+ return VueNodeViewRenderer(CodeBlockComponent)
177
+ },
178
+ }).configure({ lowlight }),
179
+ ImageExtension.configure({
180
+ uploadFunction: props.uploadFunction || defaultUploadFunction,
181
+ }),
182
+ ImageGroup.configure({
183
+ uploadFunction: props.uploadFunction || defaultUploadFunction,
184
+ }),
185
+ ImageViewerExtension,
186
+ VideoExtension.configure({
187
+ uploadFunction: props.uploadFunction || defaultUploadFunction,
188
+ }),
189
+ LinkExtension.configure({
190
+ openOnClick: false,
191
+ }),
192
+ Placeholder.configure({
193
+ placeholder:
194
+ typeof props.placeholder === 'function'
195
+ ? props.placeholder
196
+ : () => props.placeholder as string,
197
+ }),
198
+ configureMention(props.mentions),
199
+ EmojiExtension,
200
+ SlashCommands,
201
+ TagNode,
202
+ TagExtension.configure({
203
+ tags: () => props.tags,
204
+ }),
205
+ MarkdownPasteExtension.configure({
206
+ enabled: true,
207
+ showConfirmation: true,
208
+ }),
209
+ ...(props.extensions || []),
210
+ ],
211
+ onUpdate: ({ editor }) => {
212
+ emit('change', editor.getHTML())
145
213
  },
146
- editable(value) {
147
- this.editor.setEditable(value)
214
+ onFocus: ({ editor, event }) => {
215
+ emit('focus', event)
148
216
  },
149
- editorProps: {
150
- deep: true,
151
- handler(value) {
152
- if (this.editor) {
153
- this.editor.setOptions({
154
- editorProps: value,
155
- })
156
- }
157
- },
217
+ onBlur: ({ editor, event }) => {
218
+ emit('blur', event)
158
219
  },
159
- },
160
- mounted() {
161
- this.editor = new Editor({
162
- content: this.content || null,
163
- editorProps: this.editorProps,
164
- editable: this.editable,
165
- extensions: [
166
- StarterKit.configure({
167
- ...this.starterkitOptions,
168
- codeBlock: false,
169
- heading: false,
170
- }),
171
- Heading.configure({
172
- ...(typeof this.starterkitOptions?.heading === 'object' &&
173
- this.starterkitOptions.heading !== null
174
- ? this.starterkitOptions.heading
175
- : {}),
176
- }),
177
- Table.configure({
178
- resizable: true,
179
- }),
180
- TableRow,
181
- TableHeader,
182
- TableCell,
183
- Typography,
184
- TextAlign.configure({
185
- types: ['heading', 'paragraph'],
186
- }),
187
- TextStyle,
188
- NamedColorExtension,
189
- NamedHighlightExtension,
190
- CodeBlockLowlight.extend({
191
- addNodeView() {
192
- return VueNodeViewRenderer(CodeBlockComponent)
193
- },
194
- }).configure({ lowlight }),
195
- ImageExtension.configure({
196
- uploadFunction: this.uploadFunction,
197
- }),
198
- ImageGroup.configure({
199
- uploadFunction: this.uploadFunction,
200
- }),
201
- ImageViewerExtension,
202
- VideoExtension.configure({
203
- uploadFunction: this.uploadFunction,
204
- }),
205
- LinkExtension.configure({
206
- openOnClick: false,
207
- }),
208
- Placeholder.configure({
209
- placeholder:
210
- typeof this.placeholder === 'function'
211
- ? this.placeholder
212
- : () => this.placeholder,
213
- }),
214
- configureMention(this.mentions),
215
- EmojiExtension,
216
- SlashCommands,
217
- TagNode,
218
- TagExtension.configure({
219
- tags: () => this.tags,
220
- }),
221
- ...(this.extensions || []),
222
- ],
223
- onUpdate: ({ editor }) => {
224
- this.$emit('change', editor.getHTML())
225
- },
226
- onFocus: ({ editor, event }) => {
227
- this.$emit('focus', event)
228
- },
229
- onBlur: ({ editor, event }) => {
230
- this.$emit('blur', event)
231
- },
232
- })
233
- },
234
- beforeUnmount() {
235
- this.editor.destroy()
236
- this.editor = null
237
- },
238
- computed: {
239
- editorProps() {
240
- return {
241
- attributes: {
242
- class: normalizeClass([
243
- 'prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2',
244
- this.editorClass,
245
- ]),
246
- },
247
- clipboardTextParser: (text, $context) => {
248
- if (!detectMarkdown(text)) return
249
- if (
250
- !confirm(
251
- 'Do you want to convert markdown content to HTML before pasting?',
252
- )
253
- )
254
- return
220
+ })
221
+ })
255
222
 
256
- let dom = document.createElement('div')
257
- dom.innerHTML = markdownToHTML(text)
258
- let parser =
259
- this.editor.view.someProp('clipboardParser') ||
260
- this.editor.view.someProp('domParser') ||
261
- DOMParser.fromSchema(this.editor.schema)
262
- return parser.parseSlice(dom, {
263
- preserveWhitespace: true,
264
- context: $context,
265
- })
266
- },
267
- }
268
- },
269
- },
270
- }
223
+ onBeforeUnmount(() => {
224
+ if (editor.value) {
225
+ editor.value.destroy()
226
+ editor.value = null
227
+ }
228
+ })
229
+
230
+ provide(
231
+ 'editor',
232
+ computed(() => editor.value),
233
+ )
234
+
235
+ defineExpose({
236
+ editor,
237
+ })
271
238
  </script>
239
+
272
240
  <style>
273
241
  @import './extensions/color/color-styles.css';
274
242
  @import './extensions/highlight/highlight-styles.css';
@@ -0,0 +1,59 @@
1
+ import { Extension } from '@tiptap/core'
2
+ import { Plugin, PluginKey } from '@tiptap/pm/state'
3
+ import { DOMParser } from '@tiptap/pm/model'
4
+ import { detectMarkdown, markdownToHTML } from '../../../utils/markdown'
5
+
6
+ export interface MarkdownPasteOptions {
7
+ enabled: boolean
8
+ showConfirmation: boolean
9
+ }
10
+
11
+ export const MarkdownPasteExtension = Extension.create<MarkdownPasteOptions>({
12
+ name: 'markdownPaste',
13
+
14
+ addOptions() {
15
+ return {
16
+ enabled: true,
17
+ showConfirmation: true,
18
+ }
19
+ },
20
+
21
+ addProseMirrorPlugins() {
22
+ return [
23
+ new Plugin({
24
+ key: new PluginKey('markdownPaste'),
25
+ props: {
26
+ handlePaste: (view, event, slice) => {
27
+ if (!this.options.enabled) return false
28
+
29
+ const text = event.clipboardData?.getData('text/plain')
30
+ if (!text) return false
31
+
32
+ if (!detectMarkdown(text)) return false
33
+
34
+ if (this.options.showConfirmation) {
35
+ const shouldConvert = confirm(
36
+ 'Do you want to convert markdown content to HTML before pasting?',
37
+ )
38
+ if (!shouldConvert) return false
39
+ }
40
+
41
+ const htmlContent = markdownToHTML(text)
42
+ const tempDiv = document.createElement('div')
43
+ tempDiv.innerHTML = htmlContent
44
+
45
+ const parser = DOMParser.fromSchema(view.state.schema)
46
+ const parsedSlice = parser.parseSlice(tempDiv, {
47
+ preserveWhitespace: true,
48
+ })
49
+
50
+ const tr = view.state.tr.replaceSelection(parsedSlice)
51
+ view.dispatch(tr)
52
+
53
+ return true
54
+ },
55
+ },
56
+ }),
57
+ ]
58
+ },
59
+ })
@@ -0,0 +1,23 @@
1
+ import { type UploadedFile } from '../../utils/useFileUpload'
2
+
3
+ export interface TextEditorProps {
4
+ content?: string | null
5
+ placeholder?: string | (() => string)
6
+ editorClass?: string | string[] | object
7
+ editable?: boolean
8
+ bubbleMenu?: boolean | any[]
9
+ bubbleMenuOptions?: object
10
+ fixedMenu?: boolean | any[]
11
+ floatingMenu?: boolean | any[]
12
+ extensions?: any[]
13
+ starterkitOptions?: any
14
+ mentions?: any[]
15
+ tags?: any[]
16
+ uploadFunction?: (file: File) => Promise<UploadedFile>
17
+ }
18
+
19
+ export interface TextEditorEmits {
20
+ change: [content: string]
21
+ focus: [event: FocusEvent]
22
+ blur: [event: FocusEvent]
23
+ }
@@ -0,0 +1,36 @@
1
+ import type { DirectiveBinding, VNode } from 'vue'
2
+
3
+ const instanceMap = new Map<Element, (e: Event) => void>()
4
+
5
+ function onDocumentClick(e: Event, el: Element, fn?: (e: Event) => void): void {
6
+ const target = e.target as Element
7
+ if (el !== target && !el.contains(target)) {
8
+ fn?.(e)
9
+ }
10
+ }
11
+
12
+ export default {
13
+ beforeMount(el: Element, binding: DirectiveBinding, vnode: VNode): void {
14
+ const fn = binding.value as (e: Event) => void
15
+ const clickHandler = function (e: Event) {
16
+ onDocumentClick(e, el, fn)
17
+ }
18
+
19
+ removeHandlerIfPresent(el)
20
+ instanceMap.set(el, clickHandler)
21
+ document.addEventListener('click', clickHandler)
22
+ },
23
+ unmounted(el: Element): void {
24
+ removeHandlerIfPresent(el)
25
+ },
26
+ }
27
+
28
+ function removeHandlerIfPresent(el: Element): void {
29
+ const clickHandler = instanceMap.get(el)
30
+ if (!clickHandler) {
31
+ return
32
+ }
33
+
34
+ instanceMap.delete(el)
35
+ document.removeEventListener('click', clickHandler)
36
+ }
@@ -0,0 +1,40 @@
1
+ import { nextTick } from 'vue'
2
+ import type { DirectiveBinding, VNode } from 'vue'
3
+
4
+ interface VisibilityElement extends Element {
5
+ _visibility_observer?: IntersectionObserver
6
+ }
7
+
8
+ export default {
9
+ beforeMount(
10
+ el: VisibilityElement,
11
+ binding: DirectiveBinding,
12
+ vnode: VNode,
13
+ ): void {
14
+ const fn = binding.value as (
15
+ visible: boolean,
16
+ entry: IntersectionObserverEntry,
17
+ ) => void
18
+ if (!fn) return
19
+
20
+ const observer = new IntersectionObserver(
21
+ (entries: IntersectionObserverEntry[]) => {
22
+ const entry = entries[0]
23
+ const visible = entry.isIntersecting && entry.intersectionRatio > 0
24
+ fn(visible, entry)
25
+ },
26
+ )
27
+
28
+ nextTick(() => {
29
+ observer.observe(el)
30
+ })
31
+
32
+ el._visibility_observer = observer
33
+ },
34
+ unmounted(el: VisibilityElement): void {
35
+ if (el._visibility_observer) {
36
+ el._visibility_observer.disconnect()
37
+ delete el._visibility_observer
38
+ }
39
+ },
40
+ }
package/src/index.ts CHANGED
@@ -79,15 +79,15 @@ export { default as FunnelChart } from './components/Charts/FunnelChart.vue'
79
79
  export { default as ECharts } from './components/Charts/ECharts.vue'
80
80
 
81
81
  // directives
82
- export { default as onOutsideClickDirective } from './directives/onOutsideClick.js'
83
- export { default as visibilityDirective } from './directives/visibility.js'
82
+ export { default as onOutsideClickDirective } from './directives/onOutsideClick'
83
+ export { default as visibilityDirective } from './directives/visibility'
84
84
 
85
85
  // utilities
86
86
  export { default as call, createCall } from './utils/call.js'
87
87
  export { default as debounce } from './utils/debounce'
88
88
  export { default as fileToBase64 } from './utils/file-to-base64'
89
89
  export { default as FileUploadHandler } from './utils/fileUploadHandler'
90
- export { usePageMeta } from './utils/pageMeta.js'
90
+ export { usePageMeta } from './utils/pageMeta'
91
91
  export { dayjsLocal, dayjs } from './utils/dayjs'
92
92
 
93
93
  // data-fetching, resources
@@ -1,3 +1,4 @@
1
+ import { getConfig } from './config'
1
2
  import { request } from './request'
2
3
 
3
4
  export function frappeRequest(options) {
@@ -47,6 +48,15 @@ export function frappeRequest(options) {
47
48
  console.warn('Error printing debug messages', e)
48
49
  }
49
50
  }
51
+
52
+ if (data._server_messages) {
53
+ let onMessageHandler =
54
+ getConfig('serverMessagesHandler') || options.onServerMessages || null
55
+ if (onMessageHandler) {
56
+ onMessageHandler(JSON.parse(data?._server_messages) || [])
57
+ }
58
+ }
59
+
50
60
  return data.message
51
61
  } else {
52
62
  let errorResponse = await response.text()
@@ -1,17 +1,15 @@
1
- import showdown from 'showdown'
1
+ import { marked } from 'marked'
2
2
 
3
- export function markdownToHTML(text) {
4
- const converter = new showdown.Converter()
5
- converter.setFlavor('github')
6
- return converter.makeHtml(text)
3
+ export function markdownToHTML(text: string): string {
4
+ // Use synchronous marked.parse
5
+ return marked.parse(text, {
6
+ gfm: true,
7
+ breaks: true,
8
+ async: false,
9
+ }) as string
7
10
  }
8
11
 
9
- export function htmlToMarkdown(text) {
10
- const converter = new showdown.Converter()
11
- return converter.makeMarkdown(text)
12
- }
13
-
14
- export function detectMarkdown(text) {
12
+ export function detectMarkdown(text: string): boolean {
15
13
  const lines = text.split('\n')
16
14
  const markdown = lines.filter(
17
15
  (line) =>
@@ -0,0 +1,114 @@
1
+ import {
2
+ watch,
3
+ getCurrentInstance,
4
+ onBeforeUnmount,
5
+ type App,
6
+ type WatchStopHandle,
7
+ } from 'vue'
8
+
9
+ interface PageMeta {
10
+ title?: string
11
+ emoji?: string
12
+ icon?: string
13
+ }
14
+
15
+ type PageMetaFunction = () => PageMeta | null | undefined
16
+ type StopWatcherFunction = () => void
17
+
18
+ let faviconRef: HTMLLinkElement | null = null
19
+ let defaultFavIcon: string | null = null
20
+
21
+ function initializeFavicon(): void {
22
+ if (typeof window !== 'undefined' && !faviconRef) {
23
+ faviconRef = document.querySelector('link[rel="icon"]')
24
+ defaultFavIcon = faviconRef?.href || null
25
+ }
26
+ }
27
+
28
+ export function usePageMeta(fn: PageMetaFunction): StopWatcherFunction {
29
+ // Initialize favicon if we're on the client
30
+ if (typeof window !== 'undefined') {
31
+ initializeFavicon()
32
+ }
33
+
34
+ const stopWatcher: WatchStopHandle = watch(
35
+ () => {
36
+ try {
37
+ return fn()
38
+ } catch (error) {
39
+ if (process.env.NODE_ENV === 'development') {
40
+ console.warn('Failed to parse pageMeta in', fn)
41
+ console.error(error)
42
+ }
43
+ return null
44
+ }
45
+ },
46
+ (pageMeta: PageMeta | null | undefined) => {
47
+ // Only execute on client side
48
+ if (typeof window === 'undefined') return
49
+ if (!pageMeta) return
50
+
51
+ if (pageMeta.title) {
52
+ document.title = pageMeta.title
53
+ }
54
+
55
+ // Ensure favicon ref is initialized
56
+ if (!faviconRef) initializeFavicon()
57
+
58
+ if (pageMeta.emoji) {
59
+ const href = `data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>${pageMeta.emoji}</text></svg>`
60
+ if (faviconRef) faviconRef.href = href
61
+ } else if (pageMeta.icon) {
62
+ if (faviconRef) faviconRef.href = pageMeta.icon
63
+ } else {
64
+ if (faviconRef && defaultFavIcon) faviconRef.href = defaultFavIcon
65
+ }
66
+ },
67
+ {
68
+ immediate: true,
69
+ deep: true,
70
+ },
71
+ )
72
+
73
+ // Auto-cleanup if called within a component (like <script setup>)
74
+ const instance = getCurrentInstance()
75
+ if (instance) {
76
+ onBeforeUnmount(stopWatcher)
77
+ }
78
+
79
+ return stopWatcher
80
+ }
81
+
82
+ interface PageMetaPlugin {
83
+ install(app: App): void
84
+ }
85
+
86
+ export default {
87
+ install(app: App): void {
88
+ app.mixin(createMixin())
89
+ },
90
+ } as PageMetaPlugin
91
+
92
+ interface ComponentWithPageMeta {
93
+ $options: {
94
+ pageMeta?: PageMetaFunction
95
+ }
96
+ _pageMetaStopWatcher?: StopWatcherFunction
97
+ }
98
+
99
+ function createMixin() {
100
+ return {
101
+ mounted(this: ComponentWithPageMeta): void {
102
+ if (this.$options.pageMeta) {
103
+ const fn = this.$options.pageMeta.bind(this)
104
+ this._pageMetaStopWatcher = usePageMeta(fn)
105
+ }
106
+ },
107
+ beforeUnmount(this: ComponentWithPageMeta): void {
108
+ if (this._pageMetaStopWatcher) {
109
+ this._pageMetaStopWatcher()
110
+ this._pageMetaStopWatcher = undefined
111
+ }
112
+ },
113
+ }
114
+ }
@@ -1,34 +0,0 @@
1
- const instanceMap = new Map()
2
-
3
- function onDocumentClick(e, el, fn) {
4
- let target = e.target
5
- if (el !== target && !el.contains(target)) {
6
- fn?.(e)
7
- }
8
- }
9
-
10
- export default {
11
- beforeMount(el, binding) {
12
- const fn = binding.value
13
- const clickHandler = function (e) {
14
- onDocumentClick(e, el, fn)
15
- }
16
-
17
- removeHandlerIfPresent(el)
18
- instanceMap.set(el, clickHandler)
19
- document.addEventListener('click', clickHandler)
20
- },
21
- unmounted(el) {
22
- removeHandlerIfPresent(el)
23
- },
24
- }
25
-
26
- function removeHandlerIfPresent(el) {
27
- const clickHandler = instanceMap.get(el)
28
- if (!clickHandler) {
29
- return
30
- }
31
-
32
- instanceMap.delete(el)
33
- document.removeEventListener('click', clickHandler)
34
- }
@@ -1,24 +0,0 @@
1
- import { nextTick } from 'vue'
2
-
3
- export default {
4
- beforeMount(el, binding, vnode) {
5
- let fn = binding.value
6
- if (!fn) return
7
-
8
- let observer = new IntersectionObserver((entries) => {
9
- let entry = entries[0]
10
- let visible = entry.isIntersecting && entry.intersectionRatio > 0
11
- fn(visible, entry)
12
- })
13
- nextTick(() => {
14
- observer.observe(el)
15
- })
16
- el._visibility_observer = observer
17
- },
18
- unmounted(el) {
19
- if (el._visibility_observer) {
20
- el._visibility_observer.disconnect()
21
- delete el._visibility_observer
22
- }
23
- },
24
- }
@@ -1,55 +0,0 @@
1
- import { watch } from 'vue'
2
-
3
- let faviconRef = document.querySelector('link[rel="icon"]')
4
- let defaultFavIcon = faviconRef?.href
5
-
6
- export function usePageMeta(fn) {
7
- watch(
8
- () => {
9
- try {
10
- return fn()
11
- } catch (error) {
12
- if (process.env.NODE_ENV === 'development') {
13
- console.warn('Failed to parse pageMeta in', fn)
14
- console.error(error)
15
- }
16
- return null
17
- }
18
- },
19
- (pageMeta) => {
20
- if (!pageMeta) return
21
- if (pageMeta.title) {
22
- document.title = pageMeta.title
23
- }
24
- if (pageMeta.emoji) {
25
- let href = `data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>${pageMeta.emoji}</text></svg>`
26
- faviconRef.href = href
27
- } else if (pageMeta.icon) {
28
- faviconRef.href = pageMeta.icon
29
- } else {
30
- faviconRef.href = defaultFavIcon
31
- }
32
- },
33
- {
34
- immediate: true,
35
- deep: true,
36
- },
37
- )
38
- }
39
-
40
- export default {
41
- install(app) {
42
- app.mixin(createMixin())
43
- },
44
- }
45
-
46
- function createMixin() {
47
- return {
48
- created() {
49
- if (this.$options.pageMeta) {
50
- let fn = this.$options.pageMeta.bind(this)
51
- usePageMeta(fn)
52
- }
53
- },
54
- }
55
- }