Skip to content

Commit d5325b9

Browse files
committed
feat: Add RichTextVideo extension for video playback
1 parent c9a25c2 commit d5325b9

File tree

16 files changed

+691
-21
lines changed

16 files changed

+691
-21
lines changed

src/extensions/rich-text/rich-text-kit.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { RichTextImage } from './rich-text-image'
3131
import { RichTextLink } from './rich-text-link'
3232
import { RichTextOrderedList } from './rich-text-ordered-list'
3333
import { RichTextStrikethrough } from './rich-text-strikethrough'
34+
import { RichTextVideo } from './rich-text-video'
3435

3536
import type { Extensions } from '@tiptap/core'
3637
import type { BlockquoteOptions } from '@tiptap/extension-blockquote'
@@ -52,6 +53,7 @@ import type { RichTextImageOptions } from './rich-text-image'
5253
import type { RichTextLinkOptions } from './rich-text-link'
5354
import type { RichTextOrderedListOptions } from './rich-text-ordered-list'
5455
import type { RichTextStrikethroughOptions } from './rich-text-strikethrough'
56+
import type { RichTextVideoOptions } from './rich-text-video'
5557

5658
/**
5759
* The options available to customize the `RichTextKit` extension.
@@ -186,6 +188,11 @@ type RichTextKitOptions = {
186188
* Set to `false` to disable the `Typography` extension.
187189
*/
188190
typography: false
191+
192+
/**
193+
* Set options for the `Video` extension, or `false` to disable.
194+
*/
195+
video: Partial<RichTextVideoOptions> | false
189196
}
190197

191198
/**
@@ -330,6 +337,10 @@ const RichTextKit = Extension.create<RichTextKitOptions>({
330337
extensions.push(Typography)
331338
}
332339

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

src/helpers/schema.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ describe('Helper: Schema', () => {
6161
describe('#computeSchemaId', () => {
6262
test('returns a string ID that matches the given editor schema', () => {
6363
expect(computeSchemaId(getSchema([RichTextKit]))).toBe(
64-
'link,bold,italic,boldAndItalics,strike,code,paragraph,blockquote,bulletList,codeBlock,doc,hardBreak,heading,horizontalRule,image,listItem,orderedList,text',
64+
'link,bold,italic,boldAndItalics,strike,code,paragraph,blockquote,bulletList,codeBlock,doc,hardBreak,heading,horizontalRule,image,listItem,orderedList,text,video',
6565
)
6666
})
6767
})

src/helpers/unified.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isHastElementNode, isHastTextNode } from './unified'
1+
import { isHastElementNode, isHastTextNode, isMdastNode } from './unified'
22

33
describe('Helper: Unified', () => {
44
describe('#isHastElementNode', () => {
@@ -30,4 +30,19 @@ describe('Helper: Unified', () => {
3030
expect(isHastTextNode({ type: 'text' })).toBe(true)
3131
})
3232
})
33+
34+
describe('#isMdastNode', () => {
35+
test('returns `false` when the given mdast node is NOT a node with the specified type name', () => {
36+
expect(isMdastNode({ type: 'unknown' }, 'link')).toBe(false)
37+
expect(isMdastNode({ type: 'unknown' }, 'paragraph')).toBe(false)
38+
})
39+
40+
test('returns `true` when the given mdast node is a node of type `link`', () => {
41+
expect(isMdastNode({ type: 'link' }, 'link')).toBe(true)
42+
})
43+
44+
test('returns `true` when the given mdast node is a node of type `paragraph`', () => {
45+
expect(isMdastNode({ type: 'paragraph' }, 'paragraph')).toBe(true)
46+
})
47+
})
3348
})

0 commit comments

Comments
 (0)