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 +3 -2
- package/src/components/Combobox/Combobox.story.vue +300 -0
- package/src/components/TabButtons/TabButtons.vue +1 -1
- package/src/components/TextEditor/TextEditor.vue +173 -205
- package/src/components/TextEditor/extensions/markdown-paste-extension.ts +59 -0
- package/src/components/TextEditor/types.ts +23 -0
- package/src/directives/onOutsideClick.ts +36 -0
- package/src/directives/visibility.ts +40 -0
- package/src/index.ts +3 -3
- package/src/utils/frappeRequest.js +10 -0
- package/src/utils/{markdown.js → markdown.ts} +9 -11
- package/src/utils/pageMeta.ts +114 -0
- package/src/directives/onOutsideClick.js +0 -34
- package/src/directives/visibility.js +0 -24
- package/src/utils/pageMeta.js +0 -55
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "frappe-ui",
|
|
3
|
-
"version": "0.1.
|
|
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,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
|
-
<
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
watch(
|
|
124
|
+
() => props.editable,
|
|
125
|
+
(value) => {
|
|
126
|
+
if (editor.value) {
|
|
127
|
+
editor.value.setEditable(value)
|
|
132
128
|
}
|
|
133
129
|
},
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
214
|
+
onFocus: ({ editor, event }) => {
|
|
215
|
+
emit('focus', event)
|
|
148
216
|
},
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
83
|
-
export { default as visibilityDirective } from './directives/visibility
|
|
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
|
|
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
|
|
1
|
+
import { marked } from 'marked'
|
|
2
2
|
|
|
3
|
-
export function markdownToHTML(text) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
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
|
-
}
|
package/src/utils/pageMeta.js
DELETED
|
@@ -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
|
-
}
|