frappe-ui 0.1.128 → 0.1.129
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 +1 -1
- package/src/components/TextEditor/EditLink.vue +74 -0
- package/src/components/TextEditor/EmojiList.vue +39 -100
- package/src/components/TextEditor/SlashCommandsList.vue +47 -0
- package/src/components/TextEditor/SuggestionList.vue +134 -0
- package/src/components/TextEditor/TextEditor.vue +11 -8
- package/src/components/TextEditor/image-extension.ts +25 -3
- package/src/components/TextEditor/link-extension.ts +162 -0
- package/src/components/TextEditor/slash-commands-extension.ts +265 -0
- package/src/components/TextEditor/video-extension.ts +166 -0
- package/src/components/TextEditor/video-extension.js +0 -61
package/package.json
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="p-2 flex min-w-72 items-center gap-2 bg-surface-white shadow-xl rounded-lg"
|
|
4
|
+
>
|
|
5
|
+
<TextInput
|
|
6
|
+
ref="input"
|
|
7
|
+
type="text"
|
|
8
|
+
class="w-full"
|
|
9
|
+
placeholder="https://example.com"
|
|
10
|
+
v-model="_href"
|
|
11
|
+
@keydown.enter="submitLink"
|
|
12
|
+
@keydown.esc="$emit('close')"
|
|
13
|
+
/>
|
|
14
|
+
<div class="shrink-0 flex items-center gap-2">
|
|
15
|
+
<Tooltip text="Submit" placement="top">
|
|
16
|
+
<Button label="Submit" @click="submitLink">
|
|
17
|
+
<template #icon><LucideCheck class="size-4" /></template>
|
|
18
|
+
</Button>
|
|
19
|
+
</Tooltip>
|
|
20
|
+
<Tooltip text="Remove link" placement="top">
|
|
21
|
+
<Button label="Remove link" @click="$emit('updateHref', '')">
|
|
22
|
+
<template #icon><LucideX class="size-4" /></template>
|
|
23
|
+
</Button>
|
|
24
|
+
</Tooltip>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<script setup lang="ts">
|
|
30
|
+
import { onMounted, ref, useTemplateRef, defineEmits } from 'vue'
|
|
31
|
+
import Button from '../Button/Button.vue'
|
|
32
|
+
import TextInput from '../TextInput.vue'
|
|
33
|
+
import Tooltip from '../Tooltip/Tooltip.vue'
|
|
34
|
+
|
|
35
|
+
const props = defineProps<{
|
|
36
|
+
show: boolean
|
|
37
|
+
href: string
|
|
38
|
+
onClose: () => void
|
|
39
|
+
onUpdateHref: (href: string) => void
|
|
40
|
+
}>()
|
|
41
|
+
|
|
42
|
+
const emit = defineEmits<{
|
|
43
|
+
(e: 'updateHref', href: string): void
|
|
44
|
+
(e: 'close'): void
|
|
45
|
+
}>()
|
|
46
|
+
|
|
47
|
+
const _href = ref(props.href)
|
|
48
|
+
const input = useTemplateRef('input')
|
|
49
|
+
|
|
50
|
+
// Simple URL validation regex
|
|
51
|
+
const isValidUrl = (url: string) => {
|
|
52
|
+
if (!url) return true
|
|
53
|
+
const regex =
|
|
54
|
+
/^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/i
|
|
55
|
+
return regex.test(url)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const submitLink = () => {
|
|
59
|
+
if (isValidUrl(_href.value)) {
|
|
60
|
+
emit('updateHref', _href.value)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
onMounted(() => {
|
|
65
|
+
if (props.show) {
|
|
66
|
+
setTimeout(() => {
|
|
67
|
+
if (input.value?.el) {
|
|
68
|
+
input.value.el.focus()
|
|
69
|
+
input.value.el.select()
|
|
70
|
+
}
|
|
71
|
+
}, 0)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
</script>
|
|
@@ -1,111 +1,50 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
]"
|
|
14
|
-
@click="selectItem(index)"
|
|
15
|
-
@mouseover="selectedIndex = index"
|
|
16
|
-
>
|
|
17
|
-
<span class="mr-2">{{ item.emoji }}</span>
|
|
18
|
-
<span>{{ item.name }}</span>
|
|
19
|
-
</button>
|
|
20
|
-
</div>
|
|
21
|
-
</div>
|
|
2
|
+
<SuggestionList
|
|
3
|
+
ref="suggestionList"
|
|
4
|
+
:items="items"
|
|
5
|
+
:command="selectItem"
|
|
6
|
+
item-class="py-2"
|
|
7
|
+
>
|
|
8
|
+
<template #default="{ item }">
|
|
9
|
+
<span class="mr-2">{{ item.emoji }}</span>
|
|
10
|
+
<span>{{ item.name }}</span>
|
|
11
|
+
</template>
|
|
12
|
+
</SuggestionList>
|
|
22
13
|
</template>
|
|
23
14
|
|
|
24
|
-
<script>
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
items: {
|
|
28
|
-
type: Array,
|
|
29
|
-
required: true,
|
|
30
|
-
},
|
|
31
|
-
command: {
|
|
32
|
-
type: Function,
|
|
33
|
-
required: true,
|
|
34
|
-
},
|
|
35
|
-
},
|
|
36
|
-
data() {
|
|
37
|
-
return {
|
|
38
|
-
selectedIndex: 0,
|
|
39
|
-
}
|
|
40
|
-
},
|
|
41
|
-
watch: {
|
|
42
|
-
items() {
|
|
43
|
-
this.selectedIndex = 0
|
|
44
|
-
},
|
|
45
|
-
},
|
|
46
|
-
methods: {
|
|
47
|
-
onKeyDown({ event }) {
|
|
48
|
-
if (event.key === 'ArrowUp') {
|
|
49
|
-
this.upHandler()
|
|
50
|
-
return true
|
|
51
|
-
}
|
|
52
|
-
if (event.key === 'ArrowDown') {
|
|
53
|
-
this.downHandler()
|
|
54
|
-
return true
|
|
55
|
-
}
|
|
56
|
-
if (event.key === 'Enter') {
|
|
57
|
-
this.enterHandler()
|
|
58
|
-
return true
|
|
59
|
-
}
|
|
60
|
-
return false
|
|
61
|
-
},
|
|
62
|
-
upHandler() {
|
|
63
|
-
this.selectedIndex =
|
|
64
|
-
(this.selectedIndex + this.items.length - 1) % this.items.length
|
|
65
|
-
},
|
|
66
|
-
downHandler() {
|
|
67
|
-
this.selectedIndex = (this.selectedIndex + 1) % this.items.length
|
|
68
|
-
},
|
|
69
|
-
enterHandler() {
|
|
70
|
-
this.selectItem(this.selectedIndex)
|
|
71
|
-
},
|
|
72
|
-
selectItem(index) {
|
|
73
|
-
const item = this.items[index]
|
|
74
|
-
if (item) {
|
|
75
|
-
this.command({ emoji: item.emoji })
|
|
76
|
-
}
|
|
77
|
-
},
|
|
78
|
-
},
|
|
79
|
-
}
|
|
80
|
-
</script>
|
|
15
|
+
<script setup lang="ts">
|
|
16
|
+
import { ref, type PropType } from 'vue'
|
|
17
|
+
import SuggestionList, { type SuggestionItem } from './SuggestionList.vue'
|
|
81
18
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
border-radius: 0.5rem;
|
|
86
|
-
box-shadow:
|
|
87
|
-
0 0 0 1px rgba(0, 0, 0, 0.05),
|
|
88
|
-
0px 10px 20px rgba(0, 0, 0, 0.1);
|
|
89
|
-
padding: 0.2rem;
|
|
19
|
+
interface EmojiItem extends SuggestionItem {
|
|
20
|
+
name: string
|
|
21
|
+
emoji: string
|
|
90
22
|
}
|
|
91
23
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
24
|
+
const props = defineProps({
|
|
25
|
+
items: {
|
|
26
|
+
type: Array as PropType<EmojiItem[]>,
|
|
27
|
+
required: true,
|
|
28
|
+
},
|
|
29
|
+
command: {
|
|
30
|
+
type: Function as PropType<(params: { emoji: string }) => void>,
|
|
31
|
+
required: true,
|
|
32
|
+
},
|
|
33
|
+
})
|
|
99
34
|
|
|
100
|
-
|
|
101
|
-
background: #eee;
|
|
102
|
-
}
|
|
35
|
+
const suggestionList = ref<InstanceType<typeof SuggestionList> | null>(null)
|
|
103
36
|
|
|
104
|
-
|
|
105
|
-
|
|
37
|
+
const selectItem = (item: SuggestionItem) => {
|
|
38
|
+
if (item) {
|
|
39
|
+
props.command({ emoji: item.emoji })
|
|
40
|
+
}
|
|
106
41
|
}
|
|
107
42
|
|
|
108
|
-
|
|
109
|
-
|
|
43
|
+
const onKeyDown = ({ event }: { event: KeyboardEvent }) => {
|
|
44
|
+
return suggestionList.value?.onKeyDown({ event }) ?? false
|
|
110
45
|
}
|
|
111
|
-
|
|
46
|
+
|
|
47
|
+
defineExpose({
|
|
48
|
+
onKeyDown,
|
|
49
|
+
})
|
|
50
|
+
</script>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<SuggestionList
|
|
3
|
+
ref="suggestionList"
|
|
4
|
+
:items="items"
|
|
5
|
+
:command="command"
|
|
6
|
+
container-class="min-w-48"
|
|
7
|
+
item-class="h-7"
|
|
8
|
+
:show-no-results="true"
|
|
9
|
+
>
|
|
10
|
+
<template #default="{ item }">
|
|
11
|
+
<component :is="item.icon" v-if="item.icon" class="mr-2 h-4 w-4" />
|
|
12
|
+
<div v-else class="mr-2 h-4 w-4"></div>
|
|
13
|
+
<span>{{ item.title }}</span>
|
|
14
|
+
</template>
|
|
15
|
+
</SuggestionList>
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<script setup lang="ts">
|
|
19
|
+
import { ref, type PropType, type Component } from 'vue'
|
|
20
|
+
import SuggestionList, { type SuggestionItem } from './SuggestionList.vue'
|
|
21
|
+
|
|
22
|
+
interface CommandItem extends SuggestionItem {
|
|
23
|
+
title: string
|
|
24
|
+
icon?: Component
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const props = defineProps({
|
|
28
|
+
items: {
|
|
29
|
+
type: Array as PropType<CommandItem[]>,
|
|
30
|
+
required: true,
|
|
31
|
+
},
|
|
32
|
+
command: {
|
|
33
|
+
type: Function as PropType<(item: SuggestionItem) => void>,
|
|
34
|
+
required: true,
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const suggestionList = ref<InstanceType<typeof SuggestionList> | null>(null)
|
|
39
|
+
|
|
40
|
+
const onKeyDown = ({ event }: { event: KeyboardEvent }) => {
|
|
41
|
+
return suggestionList.value?.onKeyDown({ event }) ?? false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
defineExpose({
|
|
45
|
+
onKeyDown,
|
|
46
|
+
})
|
|
47
|
+
</script>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
v-if="items.length"
|
|
4
|
+
ref="container"
|
|
5
|
+
class="relative max-h-[300px] min-w-40 overflow-y-auto rounded-lg bg-surface-white p-1 text-base shadow-lg"
|
|
6
|
+
:class="containerClass"
|
|
7
|
+
>
|
|
8
|
+
<button
|
|
9
|
+
v-for="(item, index) in items"
|
|
10
|
+
:key="index"
|
|
11
|
+
:ref="
|
|
12
|
+
(el) => {
|
|
13
|
+
if (el) itemRefs[index] = el as HTMLButtonElement
|
|
14
|
+
}
|
|
15
|
+
"
|
|
16
|
+
:class="[
|
|
17
|
+
'flex w-full items-center whitespace-nowrap rounded-md px-2 py-1.5 text-sm text-ink-gray-9',
|
|
18
|
+
index === selectedIndex ? 'bg-surface-gray-2' : '',
|
|
19
|
+
itemClass,
|
|
20
|
+
]"
|
|
21
|
+
@click="selectItem(index)"
|
|
22
|
+
@mouseover="selectedIndex = index"
|
|
23
|
+
>
|
|
24
|
+
<slot :item="item" :index="index">
|
|
25
|
+
<span>{{ item.title || item.name }}</span>
|
|
26
|
+
</slot>
|
|
27
|
+
</button>
|
|
28
|
+
<div
|
|
29
|
+
v-if="!items.length && showNoResults"
|
|
30
|
+
class="px-3 py-1.5 text-sm text-ink-gray-5"
|
|
31
|
+
>
|
|
32
|
+
No results
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</template>
|
|
36
|
+
|
|
37
|
+
<script setup lang="ts">
|
|
38
|
+
import { ref, watch, type PropType, nextTick, onBeforeUpdate } from 'vue'
|
|
39
|
+
|
|
40
|
+
export interface SuggestionItem {
|
|
41
|
+
[key: string]: any
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const props = defineProps({
|
|
45
|
+
items: {
|
|
46
|
+
type: Array as PropType<SuggestionItem[]>,
|
|
47
|
+
required: true,
|
|
48
|
+
},
|
|
49
|
+
command: {
|
|
50
|
+
type: Function as PropType<(item: SuggestionItem) => void>,
|
|
51
|
+
required: true,
|
|
52
|
+
},
|
|
53
|
+
containerClass: {
|
|
54
|
+
type: String,
|
|
55
|
+
default: '',
|
|
56
|
+
},
|
|
57
|
+
itemClass: {
|
|
58
|
+
type: String,
|
|
59
|
+
default: '',
|
|
60
|
+
},
|
|
61
|
+
showNoResults: {
|
|
62
|
+
type: Boolean,
|
|
63
|
+
default: false,
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const selectedIndex = ref(0)
|
|
68
|
+
const container = ref<HTMLDivElement | null>(null)
|
|
69
|
+
const itemRefs = ref<HTMLButtonElement[]>([])
|
|
70
|
+
|
|
71
|
+
onBeforeUpdate(() => {
|
|
72
|
+
itemRefs.value = []
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const scrollIntoView = () => {
|
|
76
|
+
nextTick(() => {
|
|
77
|
+
const selectedElement = itemRefs.value[selectedIndex.value]
|
|
78
|
+
if (selectedElement) {
|
|
79
|
+
selectedElement.scrollIntoView({ block: 'nearest' })
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const selectItem = (index: number) => {
|
|
85
|
+
const item = props.items[index]
|
|
86
|
+
if (item) {
|
|
87
|
+
props.command(item)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const onKeyDown = ({ event }: { event: KeyboardEvent }) => {
|
|
92
|
+
if (!props.items.length) return false
|
|
93
|
+
|
|
94
|
+
if (event.key === 'ArrowUp') {
|
|
95
|
+
upHandler()
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
98
|
+
if (event.key === 'ArrowDown') {
|
|
99
|
+
downHandler()
|
|
100
|
+
return true
|
|
101
|
+
}
|
|
102
|
+
if (event.key === 'Enter') {
|
|
103
|
+
enterHandler()
|
|
104
|
+
return true
|
|
105
|
+
}
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const upHandler = () => {
|
|
110
|
+
selectedIndex.value =
|
|
111
|
+
(selectedIndex.value + props.items.length - 1) % props.items.length
|
|
112
|
+
scrollIntoView()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const downHandler = () => {
|
|
116
|
+
selectedIndex.value = (selectedIndex.value + 1) % props.items.length
|
|
117
|
+
scrollIntoView()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const enterHandler = () => {
|
|
121
|
+
selectItem(selectedIndex.value)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
watch(
|
|
125
|
+
() => props.items,
|
|
126
|
+
() => {
|
|
127
|
+
selectedIndex.value = 0
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
defineExpose({
|
|
132
|
+
onKeyDown,
|
|
133
|
+
})
|
|
134
|
+
</script>
|
|
@@ -27,8 +27,8 @@ import TableHeader from '@tiptap/extension-table-header'
|
|
|
27
27
|
import TableRow from '@tiptap/extension-table-row'
|
|
28
28
|
import ImageExtension from './image-extension'
|
|
29
29
|
import ImageViewerExtension from './image-viewer-extension'
|
|
30
|
-
import
|
|
31
|
-
import
|
|
30
|
+
import VideoExtension from './video-extension'
|
|
31
|
+
import LinkExtension from './link-extension'
|
|
32
32
|
import Typography from '@tiptap/extension-typography'
|
|
33
33
|
import TextStyle from '@tiptap/extension-text-style'
|
|
34
34
|
import Highlight from '@tiptap/extension-highlight'
|
|
@@ -41,6 +41,7 @@ import TextEditorFixedMenu from './TextEditorFixedMenu.vue'
|
|
|
41
41
|
import TextEditorBubbleMenu from './TextEditorBubbleMenu.vue'
|
|
42
42
|
import TextEditorFloatingMenu from './TextEditorFloatingMenu.vue'
|
|
43
43
|
import EmojiExtension from './emoji-extension'
|
|
44
|
+
import SlashCommands from './slash-commands-extension'
|
|
44
45
|
import { detectMarkdown, markdownToHTML } from '../../utils/markdown'
|
|
45
46
|
import { DOMParser } from 'prosemirror-model'
|
|
46
47
|
|
|
@@ -100,7 +101,7 @@ export default {
|
|
|
100
101
|
type: Array,
|
|
101
102
|
default: () => [],
|
|
102
103
|
},
|
|
103
|
-
|
|
104
|
+
uploadFunction: {
|
|
104
105
|
type: Function,
|
|
105
106
|
default: () => null,
|
|
106
107
|
},
|
|
@@ -167,15 +168,16 @@ export default {
|
|
|
167
168
|
},
|
|
168
169
|
}).configure({ lowlight }),
|
|
169
170
|
ImageExtension.configure({
|
|
170
|
-
uploadFunction: this.
|
|
171
|
+
uploadFunction: this.uploadFunction,
|
|
171
172
|
}),
|
|
172
173
|
ImageViewerExtension,
|
|
173
|
-
|
|
174
|
-
|
|
174
|
+
VideoExtension.configure({
|
|
175
|
+
uploadFunction: this.uploadFunction,
|
|
176
|
+
}),
|
|
177
|
+
LinkExtension.configure({
|
|
175
178
|
openOnClick: false,
|
|
176
179
|
}),
|
|
177
180
|
Placeholder.configure({
|
|
178
|
-
showOnlyWhenEditable: false,
|
|
179
181
|
placeholder:
|
|
180
182
|
typeof this.placeholder === 'function'
|
|
181
183
|
? this.placeholder
|
|
@@ -183,6 +185,7 @@ export default {
|
|
|
183
185
|
}),
|
|
184
186
|
configureMention(this.mentions),
|
|
185
187
|
EmojiExtension,
|
|
188
|
+
SlashCommands,
|
|
186
189
|
...(this.extensions || []),
|
|
187
190
|
],
|
|
188
191
|
onUpdate: ({ editor }) => {
|
|
@@ -247,7 +250,7 @@ export default {
|
|
|
247
250
|
}
|
|
248
251
|
|
|
249
252
|
/* Placeholder */
|
|
250
|
-
.ProseMirror:not(.ProseMirror-focused) p.is-editor-empty
|
|
253
|
+
.ProseMirror:not(.ProseMirror-focused) p.is-editor-empty::before {
|
|
251
254
|
content: attr(data-placeholder);
|
|
252
255
|
float: left;
|
|
253
256
|
color: var(--ink-gray-4);
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
} from '@tiptap/core'
|
|
6
6
|
import { VueNodeViewRenderer } from '@tiptap/vue-3'
|
|
7
7
|
import ImageNodeView from './ImageNodeView.vue'
|
|
8
|
-
import { Plugin
|
|
8
|
+
import { Plugin } from 'prosemirror-state'
|
|
9
9
|
import { EditorView } from 'prosemirror-view'
|
|
10
10
|
import { Node } from '@tiptap/pm/model'
|
|
11
11
|
import fileToBase64 from '../../utils/file-to-base64'
|
|
@@ -46,6 +46,11 @@ declare module '@tiptap/core' {
|
|
|
46
46
|
* Upload and insert an image
|
|
47
47
|
*/
|
|
48
48
|
uploadImage: (file: File) => ReturnType
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Select an image file using the file picker and upload it
|
|
52
|
+
*/
|
|
53
|
+
selectAndUploadImage: () => ReturnType
|
|
49
54
|
}
|
|
50
55
|
}
|
|
51
56
|
}
|
|
@@ -153,6 +158,23 @@ export default NodeExtension.create<ImageOptions>({
|
|
|
153
158
|
({ editor }) => {
|
|
154
159
|
return uploadImage(file, editor.view, null, this.options)
|
|
155
160
|
},
|
|
161
|
+
|
|
162
|
+
selectAndUploadImage:
|
|
163
|
+
() =>
|
|
164
|
+
({ editor }) => {
|
|
165
|
+
const input = document.createElement('input')
|
|
166
|
+
input.type = 'file'
|
|
167
|
+
input.accept = 'image/*'
|
|
168
|
+
input.onchange = (event) => {
|
|
169
|
+
const target = event.target as HTMLInputElement
|
|
170
|
+
if (target.files && target.files.length) {
|
|
171
|
+
const file = target.files[0]
|
|
172
|
+
editor.commands.uploadImage(file)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
input.click()
|
|
176
|
+
return true
|
|
177
|
+
},
|
|
156
178
|
}
|
|
157
179
|
},
|
|
158
180
|
|
|
@@ -216,7 +238,7 @@ export default NodeExtension.create<ImageOptions>({
|
|
|
216
238
|
},
|
|
217
239
|
},
|
|
218
240
|
|
|
219
|
-
handlePaste: (view, event
|
|
241
|
+
handlePaste: (view, event) => {
|
|
220
242
|
if (!extensionThis.options.uploadFunction) {
|
|
221
243
|
return false
|
|
222
244
|
}
|
|
@@ -252,7 +274,7 @@ export default NodeExtension.create<ImageOptions>({
|
|
|
252
274
|
},
|
|
253
275
|
},
|
|
254
276
|
|
|
255
|
-
appendTransaction(transactions,
|
|
277
|
+
appendTransaction(transactions, _, newState) {
|
|
256
278
|
const newImageNodes: { node: Node; pos: number }[] = []
|
|
257
279
|
|
|
258
280
|
if (transactions.some((tr) => tr.docChanged)) {
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import Link from '@tiptap/extension-link'
|
|
2
|
+
import { createApp, h } from 'vue'
|
|
3
|
+
import EditLink from './EditLink.vue'
|
|
4
|
+
import tippy from 'tippy.js'
|
|
5
|
+
|
|
6
|
+
export const LinkExtension = Link.extend({
|
|
7
|
+
addOptions() {
|
|
8
|
+
return {
|
|
9
|
+
...this.parent?.(),
|
|
10
|
+
openOnClick: false,
|
|
11
|
+
autolink: true,
|
|
12
|
+
defaultProtocol: 'https',
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
addKeyboardShortcuts() {
|
|
17
|
+
return {
|
|
18
|
+
'Mod-k': () => {
|
|
19
|
+
const editor = this.editor
|
|
20
|
+
const { state } = editor
|
|
21
|
+
const { from, to } = state.selection
|
|
22
|
+
const { doc } = state
|
|
23
|
+
|
|
24
|
+
if (from === to) {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const existingHref = editor.getAttributes('link').href || ''
|
|
29
|
+
|
|
30
|
+
openLinkEditor(existingHref, editor.view.dom)
|
|
31
|
+
.then((href) => {
|
|
32
|
+
if (href === null) {
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (href === '') {
|
|
37
|
+
editor
|
|
38
|
+
.chain()
|
|
39
|
+
.focus()
|
|
40
|
+
.extendMarkRange('link')
|
|
41
|
+
.unsetLink()
|
|
42
|
+
.setTextSelection(to)
|
|
43
|
+
.command(({ tr }) => {
|
|
44
|
+
tr.setStoredMarks([])
|
|
45
|
+
return true
|
|
46
|
+
})
|
|
47
|
+
.run()
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let chain = editor
|
|
52
|
+
.chain()
|
|
53
|
+
.focus()
|
|
54
|
+
.extendMarkRange('link')
|
|
55
|
+
.setLink({ href })
|
|
56
|
+
.setTextSelection(to)
|
|
57
|
+
.command(({ tr }) => {
|
|
58
|
+
tr.setStoredMarks([])
|
|
59
|
+
return true
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const posAfterLink = to
|
|
63
|
+
const charAfter =
|
|
64
|
+
posAfterLink < doc.content.size
|
|
65
|
+
? doc.textBetween(posAfterLink, posAfterLink + 1)
|
|
66
|
+
: null
|
|
67
|
+
|
|
68
|
+
if (charAfter === null || charAfter !== ' ') {
|
|
69
|
+
chain = chain.insertContent(' ')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
chain.run()
|
|
73
|
+
})
|
|
74
|
+
.catch(() => {})
|
|
75
|
+
|
|
76
|
+
return true
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
function openLinkEditor(href: string, anchor: HTMLElement): Promise<string> {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const container = document.createElement('div')
|
|
85
|
+
document.body.appendChild(container)
|
|
86
|
+
|
|
87
|
+
let virtualReference: {
|
|
88
|
+
getBoundingClientRect: () => DOMRect | { [key: string]: any }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const selection = window.getSelection()
|
|
92
|
+
if (selection && selection.rangeCount > 0) {
|
|
93
|
+
const range = selection.getRangeAt(0)
|
|
94
|
+
const rect = range.getBoundingClientRect()
|
|
95
|
+
|
|
96
|
+
virtualReference = {
|
|
97
|
+
getBoundingClientRect: () => ({
|
|
98
|
+
width: 0,
|
|
99
|
+
height: 0,
|
|
100
|
+
top: rect.top,
|
|
101
|
+
right: rect.left + rect.width / 2,
|
|
102
|
+
bottom: rect.top,
|
|
103
|
+
left: rect.left + rect.width / 2,
|
|
104
|
+
x: rect.left + rect.width / 2,
|
|
105
|
+
y: rect.top,
|
|
106
|
+
toJSON: () => {},
|
|
107
|
+
}),
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
virtualReference = {
|
|
111
|
+
getBoundingClientRect: () => anchor.getBoundingClientRect(),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const app = createApp({
|
|
116
|
+
render() {
|
|
117
|
+
return h(EditLink, {
|
|
118
|
+
show: true,
|
|
119
|
+
href,
|
|
120
|
+
onClose: () => {
|
|
121
|
+
destroy()
|
|
122
|
+
reject('Link editing cancelled')
|
|
123
|
+
},
|
|
124
|
+
onUpdateHref: (newHref: string) => {
|
|
125
|
+
destroy()
|
|
126
|
+
resolve(newHref)
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
app.mount(container)
|
|
133
|
+
|
|
134
|
+
const tippyInstance = tippy(anchor, {
|
|
135
|
+
getReferenceClientRect: () => virtualReference.getBoundingClientRect(),
|
|
136
|
+
content: container,
|
|
137
|
+
trigger: 'manual',
|
|
138
|
+
interactive: true,
|
|
139
|
+
appendTo: document.body,
|
|
140
|
+
placement: 'top',
|
|
141
|
+
arrow: false,
|
|
142
|
+
theme: 'link-editor',
|
|
143
|
+
maxWidth: 'none',
|
|
144
|
+
onHidden() {
|
|
145
|
+
destroy()
|
|
146
|
+
reject('Link editing cancelled')
|
|
147
|
+
},
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
function destroy() {
|
|
151
|
+
setTimeout(() => {
|
|
152
|
+
tippyInstance.destroy()
|
|
153
|
+
app.unmount()
|
|
154
|
+
container.remove()
|
|
155
|
+
}, 0)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
tippyInstance.show()
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export default LinkExtension
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { Extension, Editor, Range } from '@tiptap/core'
|
|
2
|
+
import { VueRenderer } from '@tiptap/vue-3'
|
|
3
|
+
import Suggestion, { SuggestionProps } from '@tiptap/suggestion'
|
|
4
|
+
import { PluginKey } from 'prosemirror-state'
|
|
5
|
+
import tippy, { Instance as TippyInstance, Props as TippyProps } from 'tippy.js'
|
|
6
|
+
import SlashCommandsList from './SlashCommandsList.vue'
|
|
7
|
+
import { Component } from 'vue'
|
|
8
|
+
|
|
9
|
+
import Heading1 from '~icons/lucide/heading-1'
|
|
10
|
+
import Heading2 from '~icons/lucide/heading-2'
|
|
11
|
+
import Pilcrow from '~icons/lucide/pilcrow'
|
|
12
|
+
import Heading3 from '~icons/lucide/heading-3'
|
|
13
|
+
import List from '~icons/lucide/list'
|
|
14
|
+
import ListOrdered from '~icons/lucide/list-ordered'
|
|
15
|
+
import Code from '~icons/lucide/code'
|
|
16
|
+
import Quote from '~icons/lucide/quote'
|
|
17
|
+
import Image from '~icons/lucide/image'
|
|
18
|
+
import Video from '~icons/lucide/video'
|
|
19
|
+
import Link from '~icons/lucide/link'
|
|
20
|
+
import Minus from '~icons/lucide/minus'
|
|
21
|
+
import Table from '~icons/lucide/table-2'
|
|
22
|
+
|
|
23
|
+
export const SlashCommandSuggestionKey = new PluginKey<any>(
|
|
24
|
+
'slashCommandSuggestion',
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
interface Command {
|
|
28
|
+
title: string
|
|
29
|
+
icon: Component
|
|
30
|
+
command: (props: CommandExecutionProps) => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type CommandExecutionProps = {
|
|
34
|
+
editor: Editor
|
|
35
|
+
range: Range
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const getCommands = (): Command[] => [
|
|
39
|
+
{
|
|
40
|
+
title: 'Heading 1',
|
|
41
|
+
icon: Heading1,
|
|
42
|
+
command: ({ editor, range }: CommandExecutionProps) => {
|
|
43
|
+
editor
|
|
44
|
+
.chain()
|
|
45
|
+
.focus()
|
|
46
|
+
.deleteRange(range)
|
|
47
|
+
.setNode('heading', { level: 1 })
|
|
48
|
+
.run()
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
title: 'Heading 2',
|
|
53
|
+
icon: Heading2,
|
|
54
|
+
command: ({ editor, range }: CommandExecutionProps) => {
|
|
55
|
+
editor
|
|
56
|
+
.chain()
|
|
57
|
+
.focus()
|
|
58
|
+
.deleteRange(range)
|
|
59
|
+
.setNode('heading', { level: 2 })
|
|
60
|
+
.run()
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
title: 'Heading 3',
|
|
65
|
+
icon: Heading3,
|
|
66
|
+
command: ({ editor, range }: CommandExecutionProps) => {
|
|
67
|
+
editor
|
|
68
|
+
.chain()
|
|
69
|
+
.focus()
|
|
70
|
+
.deleteRange(range)
|
|
71
|
+
.setNode('heading', { level: 3 })
|
|
72
|
+
.run()
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
title: 'Bullet List',
|
|
77
|
+
icon: List,
|
|
78
|
+
command: ({ editor, range }: CommandExecutionProps) => {
|
|
79
|
+
editor.chain().focus().deleteRange(range).toggleBulletList().run()
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
title: 'Ordered List',
|
|
84
|
+
icon: ListOrdered,
|
|
85
|
+
command: ({ editor, range }: CommandExecutionProps) => {
|
|
86
|
+
editor.chain().focus().deleteRange(range).toggleOrderedList().run()
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
title: 'Code Block',
|
|
91
|
+
icon: Code,
|
|
92
|
+
command: ({ editor, range }: CommandExecutionProps) => {
|
|
93
|
+
editor.chain().focus().deleteRange(range).toggleCodeBlock().run()
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
title: 'Blockquote',
|
|
98
|
+
icon: Quote,
|
|
99
|
+
command: ({ editor, range }: CommandExecutionProps) => {
|
|
100
|
+
editor.chain().focus().deleteRange(range).toggleBlockquote().run()
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
title: 'Image',
|
|
105
|
+
icon: Image,
|
|
106
|
+
command: ({ editor, range }: CommandExecutionProps) => {
|
|
107
|
+
editor.chain().focus().deleteRange(range).selectAndUploadImage().run()
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
title: 'Video',
|
|
112
|
+
icon: Video,
|
|
113
|
+
command: ({ editor, range }: CommandExecutionProps) => {
|
|
114
|
+
editor.chain().focus().deleteRange(range).selectAndUploadVideo().run()
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
title: 'Link',
|
|
119
|
+
icon: Link,
|
|
120
|
+
command: ({ editor, range }: CommandExecutionProps) => {
|
|
121
|
+
editor.chain().focus().deleteRange(range).setLink({ href: '' }).run()
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
title: 'Horizontal Rule',
|
|
126
|
+
icon: Minus,
|
|
127
|
+
command: ({ editor, range }: CommandExecutionProps) => {
|
|
128
|
+
editor.chain().focus().deleteRange(range).setHorizontalRule().run()
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
title: 'Table',
|
|
133
|
+
icon: Table,
|
|
134
|
+
command: ({ editor, range }: CommandExecutionProps) => {
|
|
135
|
+
editor
|
|
136
|
+
.chain()
|
|
137
|
+
.focus()
|
|
138
|
+
.deleteRange(range)
|
|
139
|
+
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
|
140
|
+
.run()
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
title: 'Paragraph',
|
|
145
|
+
icon: Pilcrow,
|
|
146
|
+
command: ({ editor, range }: CommandExecutionProps) => {
|
|
147
|
+
editor.chain().focus().deleteRange(range).setNode('paragraph').run()
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
type CommandProps = SuggestionProps<Command>
|
|
153
|
+
|
|
154
|
+
export const SlashCommands = Extension.create({
|
|
155
|
+
name: 'slashCommands',
|
|
156
|
+
|
|
157
|
+
addOptions() {
|
|
158
|
+
return {
|
|
159
|
+
suggestion: {
|
|
160
|
+
char: '/',
|
|
161
|
+
pluginKey: SlashCommandSuggestionKey,
|
|
162
|
+
items: ({ query }: { query: string }): Command[] => {
|
|
163
|
+
const commands = getCommands()
|
|
164
|
+
return commands.filter((item) =>
|
|
165
|
+
item.title.toLowerCase().startsWith(query.toLowerCase()),
|
|
166
|
+
)
|
|
167
|
+
},
|
|
168
|
+
command: ({
|
|
169
|
+
editor,
|
|
170
|
+
range,
|
|
171
|
+
props,
|
|
172
|
+
}: {
|
|
173
|
+
editor: Editor
|
|
174
|
+
range: Range
|
|
175
|
+
props: Command
|
|
176
|
+
}) => {
|
|
177
|
+
props.command({ editor, range })
|
|
178
|
+
},
|
|
179
|
+
render: () => {
|
|
180
|
+
let component: VueRenderer | null
|
|
181
|
+
let popup: TippyInstance[] | null
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
onStart: (props: CommandProps) => {
|
|
185
|
+
component = new VueRenderer(SlashCommandsList, {
|
|
186
|
+
props,
|
|
187
|
+
editor: props.editor,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
if (!props.clientRect || !component.element) {
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const tippyOptions: Partial<TippyProps> = {
|
|
195
|
+
getReferenceClientRect: props.clientRect as () => DOMRect,
|
|
196
|
+
appendTo: () => document.body,
|
|
197
|
+
content: component.element,
|
|
198
|
+
showOnCreate: true,
|
|
199
|
+
interactive: true,
|
|
200
|
+
trigger: 'manual',
|
|
201
|
+
placement: 'bottom-start',
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
popup = tippy('body', tippyOptions)
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
onUpdate(props: CommandProps) {
|
|
208
|
+
component?.updateProps(props)
|
|
209
|
+
|
|
210
|
+
if (!props.clientRect) {
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (popup && popup[0]) {
|
|
215
|
+
popup[0].setProps({
|
|
216
|
+
getReferenceClientRect: props.clientRect as () => DOMRect,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
onKeyDown(props: { event: KeyboardEvent }): boolean {
|
|
222
|
+
if (props.event.key === 'Escape') {
|
|
223
|
+
if (popup && popup[0]) {
|
|
224
|
+
popup[0].hide()
|
|
225
|
+
}
|
|
226
|
+
return true
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (
|
|
230
|
+
component &&
|
|
231
|
+
component.ref &&
|
|
232
|
+
typeof (component.ref as any).onKeyDown === 'function'
|
|
233
|
+
) {
|
|
234
|
+
return (component.ref as any).onKeyDown(props)
|
|
235
|
+
}
|
|
236
|
+
return false
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
onExit() {
|
|
240
|
+
if (popup && popup[0]) {
|
|
241
|
+
popup[0].destroy()
|
|
242
|
+
}
|
|
243
|
+
if (component) {
|
|
244
|
+
component.destroy()
|
|
245
|
+
}
|
|
246
|
+
popup = null
|
|
247
|
+
component = null
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
addProseMirrorPlugins() {
|
|
256
|
+
return [
|
|
257
|
+
Suggestion<Command>({
|
|
258
|
+
editor: this.editor,
|
|
259
|
+
...this.options.suggestion,
|
|
260
|
+
}),
|
|
261
|
+
]
|
|
262
|
+
},
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
export default SlashCommands
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { Node, mergeAttributes, Editor } from '@tiptap/core'
|
|
2
|
+
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
|
3
|
+
import { EditorView } from 'prosemirror-view'
|
|
4
|
+
|
|
5
|
+
export interface VideoOptions {
|
|
6
|
+
uploadFunction:
|
|
7
|
+
| ((file: File) => Promise<{ src: string; [key: string]: any }>)
|
|
8
|
+
| null
|
|
9
|
+
HTMLAttributes: Record<string, any>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SetVideoOptions {
|
|
13
|
+
src: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
declare module '@tiptap/core' {
|
|
17
|
+
interface Commands<ReturnType> {
|
|
18
|
+
video: {
|
|
19
|
+
setVideo: (options: SetVideoOptions) => ReturnType
|
|
20
|
+
uploadVideo: (file: File) => ReturnType
|
|
21
|
+
selectAndUploadVideo: () => ReturnType
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default Node.create<VideoOptions>({
|
|
27
|
+
name: 'video',
|
|
28
|
+
group: 'block',
|
|
29
|
+
selectable: true,
|
|
30
|
+
draggable: true,
|
|
31
|
+
atom: true,
|
|
32
|
+
|
|
33
|
+
addOptions() {
|
|
34
|
+
return {
|
|
35
|
+
uploadFunction: null,
|
|
36
|
+
HTMLAttributes: {},
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
addAttributes() {
|
|
41
|
+
return {
|
|
42
|
+
src: {
|
|
43
|
+
default: null,
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
parseHTML() {
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
tag: 'video',
|
|
52
|
+
},
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
renderHTML({ HTMLAttributes }) {
|
|
57
|
+
return [
|
|
58
|
+
'video',
|
|
59
|
+
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
|
60
|
+
controls: '',
|
|
61
|
+
}),
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
addCommands() {
|
|
66
|
+
return {
|
|
67
|
+
setVideo:
|
|
68
|
+
(options: SetVideoOptions) =>
|
|
69
|
+
({ commands }) => {
|
|
70
|
+
return commands.insertContent({
|
|
71
|
+
type: this.name,
|
|
72
|
+
attrs: options,
|
|
73
|
+
})
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
uploadVideo:
|
|
77
|
+
(file: File) =>
|
|
78
|
+
({ editor }) => {
|
|
79
|
+
const pos = editor.state.selection.from
|
|
80
|
+
return uploadVideoInternal(file, editor.view, pos, this.options)
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
selectAndUploadVideo:
|
|
84
|
+
() =>
|
|
85
|
+
({ editor }) => {
|
|
86
|
+
if (!this.options.uploadFunction) {
|
|
87
|
+
console.error('uploadFunction option is not provided for videos.')
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const input = document.createElement('input')
|
|
92
|
+
input.type = 'file'
|
|
93
|
+
input.accept = 'video/*'
|
|
94
|
+
input.onchange = (event) => {
|
|
95
|
+
const target = event.target as HTMLInputElement
|
|
96
|
+
if (target.files && target.files.length) {
|
|
97
|
+
const file = target.files[0]
|
|
98
|
+
editor.commands.uploadVideo(file)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
input.click()
|
|
102
|
+
return true
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
addNodeView() {
|
|
108
|
+
return ({ editor, node }: { editor: Editor; node: ProseMirrorNode }) => {
|
|
109
|
+
const div = document.createElement('div')
|
|
110
|
+
div.className =
|
|
111
|
+
'relative aspect-w-16 aspect-h-9' +
|
|
112
|
+
(editor.isEditable ? ' cursor-pointer' : '')
|
|
113
|
+
|
|
114
|
+
const video = document.createElement('video')
|
|
115
|
+
if (editor.isEditable) {
|
|
116
|
+
video.className = 'pointer-events-none'
|
|
117
|
+
}
|
|
118
|
+
video.src = node.attrs.src
|
|
119
|
+
video.setAttribute('controls', '')
|
|
120
|
+
|
|
121
|
+
if (editor.isEditable) {
|
|
122
|
+
let videoPill = document.createElement('div')
|
|
123
|
+
videoPill.className =
|
|
124
|
+
'absolute top-0 right-0 text-xs m-2 bg-surface-gray-6 text-ink-white px-2 py-1 rounded-md'
|
|
125
|
+
videoPill.innerHTML = 'Video'
|
|
126
|
+
div.append(videoPill)
|
|
127
|
+
}
|
|
128
|
+
div.append(video)
|
|
129
|
+
return {
|
|
130
|
+
dom: div,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
function uploadVideoInternal(
|
|
137
|
+
file: File,
|
|
138
|
+
view: EditorView,
|
|
139
|
+
pos: number | null | undefined,
|
|
140
|
+
options: Record<string, any>,
|
|
141
|
+
): boolean {
|
|
142
|
+
if (!options.uploadFunction) {
|
|
143
|
+
console.error('uploadFunction option is not provided for videos.')
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
options
|
|
148
|
+
.uploadFunction(file)
|
|
149
|
+
.then((uploadedVideo: { src: string }) => {
|
|
150
|
+
const { schema } = view.state
|
|
151
|
+
const node = schema.nodes.video.create({ src: uploadedVideo.src })
|
|
152
|
+
|
|
153
|
+
const transaction = view.state.tr
|
|
154
|
+
if (pos != null) {
|
|
155
|
+
transaction.insert(pos, node)
|
|
156
|
+
} else {
|
|
157
|
+
transaction.replaceSelectionWith(node)
|
|
158
|
+
}
|
|
159
|
+
view.dispatch(transaction)
|
|
160
|
+
})
|
|
161
|
+
.catch((error: Error) => {
|
|
162
|
+
console.error('Video upload failed:', error)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
return true
|
|
166
|
+
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { Node, mergeAttributes } from '@tiptap/core'
|
|
2
|
-
// Inspired by this blog: https://www.codemzy.com/blog/tiptap-video-embed-extension
|
|
3
|
-
|
|
4
|
-
const Video = Node.create({
|
|
5
|
-
name: 'video',
|
|
6
|
-
group: 'block',
|
|
7
|
-
selectable: true,
|
|
8
|
-
draggable: true,
|
|
9
|
-
atom: true,
|
|
10
|
-
|
|
11
|
-
addAttributes() {
|
|
12
|
-
return {
|
|
13
|
-
src: {
|
|
14
|
-
default: null,
|
|
15
|
-
},
|
|
16
|
-
}
|
|
17
|
-
},
|
|
18
|
-
|
|
19
|
-
parseHTML() {
|
|
20
|
-
return [
|
|
21
|
-
{
|
|
22
|
-
tag: 'video',
|
|
23
|
-
},
|
|
24
|
-
]
|
|
25
|
-
},
|
|
26
|
-
|
|
27
|
-
renderHTML({ HTMLAttributes }) {
|
|
28
|
-
return ['video', mergeAttributes(HTMLAttributes)]
|
|
29
|
-
},
|
|
30
|
-
|
|
31
|
-
addNodeView() {
|
|
32
|
-
return ({ editor, node }) => {
|
|
33
|
-
const div = document.createElement('div')
|
|
34
|
-
div.className =
|
|
35
|
-
'relative aspect-w-16 aspect-h-9' +
|
|
36
|
-
(editor.isEditable ? ' cursor-pointer' : '')
|
|
37
|
-
|
|
38
|
-
const video = document.createElement('video')
|
|
39
|
-
if (editor.isEditable) {
|
|
40
|
-
video.className = 'pointer-events-none'
|
|
41
|
-
video.controls = true
|
|
42
|
-
}
|
|
43
|
-
video.src = node.attrs.src
|
|
44
|
-
if (!editor.isEditable) {
|
|
45
|
-
video.setAttribute('controls', '')
|
|
46
|
-
} else {
|
|
47
|
-
let videoPill = document.createElement('div')
|
|
48
|
-
videoPill.className =
|
|
49
|
-
'absolute top-0 right-0 text-xs m-2 bg-surface-gray-6 text-ink-white px-2 py-1 rounded-md'
|
|
50
|
-
videoPill.innerHTML = 'Video'
|
|
51
|
-
div.append(videoPill)
|
|
52
|
-
}
|
|
53
|
-
div.append(video)
|
|
54
|
-
return {
|
|
55
|
-
dom: div,
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
},
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
export default Video
|