-
Notifications
You must be signed in to change notification settings - Fork 16
Expand file tree
/
Copy pathrich-text-video.ts
More file actions
318 lines (284 loc) · 10.4 KB
/
Copy pathrich-text-video.ts
File metadata and controls
318 lines (284 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
import { mergeAttributes, Node, nodeInputRule } from '@tiptap/core'
import { Plugin, PluginKey, Selection } from '@tiptap/pm/state'
import { ReactNodeViewRenderer } from '@tiptap/react'
import { REGEX_WEB_URL } from '../../constants/regular-expressions'
import type { NodeView } from '@tiptap/pm/view'
import type { NodeViewProps } from '@tiptap/react'
/**
* The properties that describe `RichTextVideo` node attributes.
*/
type RichTextVideoAttributes = {
/**
* Additional metadata about a video attachment upload.
*/
metadata?: {
/**
* A unique ID for the video attachment.
*/
attachmentId: string
/**
* Specifies if the video attachment failed to upload.
*/
isUploadFailed: boolean
/**
* The upload progress for the video attachment.
*/
uploadProgress: number
}
} & Pick<HTMLVideoElement, 'src'>
/**
* Augment the official `@tiptap/core` module with extra commands, relevant for this extension, so
* that the compiler knows about them.
*/
declare module '@tiptap/core' {
interface Commands<ReturnType> {
richTextVideo: {
/**
* Inserts an video into the editor with the given attributes.
*/
insertVideo: (attributes: RichTextVideoAttributes) => ReturnType
/**
* Updates the attributes for an existing image in the editor.
*/
updateVideo: (
attributes: Partial<RichTextVideoAttributes> &
Required<Pick<RichTextVideoAttributes, 'metadata'>>,
) => ReturnType
}
}
}
/**
* The options available to customize the `RichTextVideo` extension.
*/
type RichTextVideoOptions = {
/**
* A list of accepted MIME types for videos pasting.
*/
acceptedVideoMimeTypes: string[]
/**
* Whether to automatically start playback of the video as soon as the player is loaded. Its
* default value is `false`, meaning that the video will not start playing automatically.
*/
autoplay: boolean
/**
* Whether to browser will offer controls to allow the user to control video playback, including
* volume, seeking, and pause/resume playback. Its default value is `true`, meaning that the
* browser will offer playback controls.
*/
controls: boolean
/**
* A list of options the browser should consider when determining which controls to show for the video element.
* The value is a space-separated list of tokens, which are case-insensitive.
*
* @example 'nofullscreen nodownload noremoteplayback'
* @see https://wicg.github.io/controls-list/explainer.html
*
* Unfortunatelly, both Firefox and Safari do not support this attribute.
*
* @see https://caniuse.com/mdn-html_elements_video_controlslist
*/
controlsList: string
/**
* Custom HTML attributes that should be added to the rendered HTML tag.
*/
HTMLAttributes: Record<string, string>
/**
* Renders the video node inline (e.g., <p><video src="doist.mp4"></p>). Its default value is
* `false`, meaning that videos are on the same level as paragraphs.
*/
inline: boolean
/**
* Whether to automatically seek back to the start upon reaching the end of the video. Its
* default value is `false`, meaning that the video will stop playing when it reaches the end.
*/
loop: boolean
/**
* Whether the audio will be initially silenced. Its default value is `false`, meaning that the
* audio will be played when the video is played.
*/
muted: boolean
/**
* A React component to render inside the interactive node view.
*/
NodeViewComponent?: React.ComponentType<NodeViewProps>
/**
* The event handler that is fired when a video file is pasted.
*/
onVideoFilePaste?: (file: File) => void
}
/**
* The input regex for Markdown video links (i.e. that end with a supported video file extension).
*/
const inputRegex = new RegExp(
`(?:^|\\s)${REGEX_WEB_URL.source}\\.(?:mov|mp4|webm)$`,
REGEX_WEB_URL.flags,
)
/**
* The `RichTextVideo` extension adds support to render `<video>` HTML tags with video pasting
* capabilities, and also adds the ability to pass additional metadata about a video attachment
* upload. By default, videos are blocks; if you want to render videos inline with text, set the
* `inline` option to `true`.
*/
const RichTextVideo = Node.create<RichTextVideoOptions>({
name: 'video',
addOptions() {
return {
acceptedVideoMimeTypes: ['video/mp4', 'video/quicktime', 'video/webm'],
autoplay: false,
controls: true,
controlsList: '',
HTMLAttributes: {},
inline: false,
loop: false,
muted: false,
}
},
inline() {
return this.options.inline
},
group() {
return this.options.inline ? 'inline' : 'block'
},
addAttributes() {
return {
src: {
default: null,
},
metadata: {
default: null,
rendered: false,
},
}
},
parseHTML() {
return [
{
tag: 'video[src]',
},
]
},
renderHTML({ HTMLAttributes }) {
const { options } = this
return [
'video',
mergeAttributes(
options.HTMLAttributes,
HTMLAttributes,
// For most attributes, we use `undefined` instead of `false` to not render the
// attribute at all, otherwise they will be interpreted as `true` by the browser
// ref: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/video
{
autoplay: options.autoplay ? true : undefined,
controls: options.controls ? true : undefined,
controlslist: options.controlsList.length ? options.controlsList : undefined,
loop: options.loop ? true : undefined,
muted: options.muted ? true : undefined,
playsinline: true,
},
),
]
},
addCommands() {
const { name: nodeTypeName } = this
return {
...this.parent?.(),
insertVideo(attributes) {
return ({ editor, commands }) => {
const selectionAtEnd = Selection.atEnd(editor.state.doc)
return commands.insertContent([
{
type: nodeTypeName,
attrs: attributes,
},
// Insert a blank paragraph after the video when at the end of the document
...(editor.state.selection.to === selectionAtEnd.to
? [{ type: 'paragraph' }]
: []),
])
}
},
updateVideo(attributes) {
return ({ commands }) => {
return commands.command(({ tr }) => {
tr.doc.descendants((node, position) => {
const { metadata } = node.attrs as {
metadata: RichTextVideoAttributes['metadata']
}
// Update the video attributes to the corresponding node
if (
node.type.name === nodeTypeName &&
metadata?.attachmentId === attributes.metadata?.attachmentId
) {
tr.setNodeMarkup(position, node.type, {
...node.attrs,
...attributes,
})
}
})
return true
})
}
},
}
},
addNodeView() {
const { NodeViewComponent } = this.options
// Do not add a node view if component was not specified
if (!NodeViewComponent) {
return () => ({}) as NodeView
}
// Render the node view with the provided React component
return ReactNodeViewRenderer(NodeViewComponent, {
as: 'div',
className: `Typist-${this.type.name}`,
})
},
addProseMirrorPlugins() {
const { acceptedVideoMimeTypes, onVideoFilePaste } = this.options
return [
new Plugin({
key: new PluginKey(this.name),
props: {
handleDOMEvents: {
paste: (_, event) => {
// Do not handle the event if we don't have a callback
if (!onVideoFilePaste) {
return false
}
const pastedFiles = Array.from(event.clipboardData?.files || [])
// Do not handle the event if no files were pasted
if (pastedFiles.length === 0) {
return false
}
let wasPasteHandled = false
// Invoke the callback for every pasted file that is an accepted video type
pastedFiles.forEach((pastedFile) => {
if (acceptedVideoMimeTypes.includes(pastedFile.type)) {
onVideoFilePaste(pastedFile)
wasPasteHandled = true
}
})
// Suppress the default handling behaviour if at least one video was handled
return wasPasteHandled
},
},
},
}),
]
},
addInputRules() {
return [
nodeInputRule({
find: inputRegex,
type: this.type,
getAttributes(match) {
return {
src: match[0],
}
},
}),
]
},
})
export { RichTextVideo }
export type { RichTextVideoAttributes, RichTextVideoOptions }