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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "0.1.128",
3
+ "version": "0.1.129",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "main": "./src/index.ts",
6
6
  "scripts": {
@@ -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
- <div>
3
- <div
4
- v-if="items.length"
5
- class="min-w-40 rounded-lg border bg-surface-white p-1 text-base shadow-lg"
6
- >
7
- <button
8
- v-for="(item, index) in items"
9
- :key="index"
10
- :class="[
11
- index === selectedIndex ? 'bg-surface-gray-2' : '',
12
- 'flex w-full items-center whitespace-nowrap rounded-md px-2 py-2 text-sm text-ink-gray-9',
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
- export default {
26
- props: {
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
- <style>
83
- .emoji-suggestions {
84
- background: #fff;
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
- .emoji-item {
93
- display: flex;
94
- align-items: center;
95
- padding: 0.5rem;
96
- border-radius: 0.3rem;
97
- cursor: pointer;
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
- .emoji-item.is-selected {
101
- background: #eee;
102
- }
35
+ const suggestionList = ref<InstanceType<typeof SuggestionList> | null>(null)
103
36
 
104
- .emoji {
105
- margin-right: 0.5rem;
37
+ const selectItem = (item: SuggestionItem) => {
38
+ if (item) {
39
+ props.command({ emoji: item.emoji })
40
+ }
106
41
  }
107
42
 
108
- .name {
109
- font-size: 0.9rem;
43
+ const onKeyDown = ({ event }: { event: KeyboardEvent }) => {
44
+ return suggestionList.value?.onKeyDown({ event }) ?? false
110
45
  }
111
- </style>
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 Video from './video-extension'
31
- import Link from '@tiptap/extension-link'
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
- imageUploadFunction: {
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.imageUploadFunction,
171
+ uploadFunction: this.uploadFunction,
171
172
  }),
172
173
  ImageViewerExtension,
173
- Video,
174
- Link.configure({
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:first-child::before {
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, Selection } from 'prosemirror-state'
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, slice) => {
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, oldState, newState) {
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