Skip to content

Commit fac9454

Browse files
committed
feat: Add RichTextVideo extension for video playback
1 parent e9cc43b commit fac9454

File tree

16 files changed

+692
-21
lines changed

16 files changed

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