Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/extensions/rich-text/rich-text-kit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { RichTextImage } from './rich-text-image'
import { RichTextLink } from './rich-text-link'
import { RichTextOrderedList } from './rich-text-ordered-list'
import { RichTextStrikethrough } from './rich-text-strikethrough'
import { RichTextVideo } from './rich-text-video'

import type { Extensions } from '@tiptap/core'
import type { BlockquoteOptions } from '@tiptap/extension-blockquote'
Expand All @@ -52,6 +53,7 @@ import type { RichTextImageOptions } from './rich-text-image'
import type { RichTextLinkOptions } from './rich-text-link'
import type { RichTextOrderedListOptions } from './rich-text-ordered-list'
import type { RichTextStrikethroughOptions } from './rich-text-strikethrough'
import type { RichTextVideoOptions } from './rich-text-video'

/**
* The options available to customize the `RichTextKit` extension.
Expand Down Expand Up @@ -186,6 +188,11 @@ type RichTextKitOptions = {
* Set to `false` to disable the `Typography` extension.
*/
typography: false

/**
* Set options for the `Video` extension, or `false` to disable.
*/
video: Partial<RichTextVideoOptions> | false
}

/**
Expand Down Expand Up @@ -330,6 +337,10 @@ const RichTextKit = Extension.create<RichTextKitOptions>({
extensions.push(Typography)
}

if (this.options.video !== false) {
extensions.push(RichTextVideo.configure(this.options?.video))
}

Comment on lines +340 to +343
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if this is something that we want to be added to all our editors by default, forcing us to disable this extension for the ones that we don't want to provide support for (e.g., Todoist task name, where we already disable images, along with many others).

The alternative is to remove this from RichTextKit, export it, and let consumers include the extension or not. In other words, make this an optional "bonus" extension.

return extensions
},
})
Expand Down
318 changes: 318 additions & 0 deletions src/extensions/rich-text/rich-text-video.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,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 }
2 changes: 1 addition & 1 deletion src/helpers/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe('Helper: Schema', () => {
describe('#computeSchemaId', () => {
test('returns a string ID that matches the given editor schema', () => {
expect(computeSchemaId(getSchema([RichTextKit]))).toBe(
'link,bold,italic,boldAndItalics,strike,code,paragraph,blockquote,bulletList,codeBlock,doc,hardBreak,heading,horizontalRule,image,listItem,orderedList,text',
'link,bold,italic,boldAndItalics,strike,code,paragraph,blockquote,bulletList,codeBlock,doc,hardBreak,heading,horizontalRule,image,listItem,orderedList,text,video',
)
})
})
Expand Down
17 changes: 16 additions & 1 deletion src/helpers/unified.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isHastElementNode, isHastTextNode } from './unified'
import { isHastElementNode, isHastTextNode, isMdastNode } from './unified'

describe('Helper: Unified', () => {
describe('#isHastElementNode', () => {
Expand Down Expand Up @@ -30,4 +30,19 @@ describe('Helper: Unified', () => {
expect(isHastTextNode({ type: 'text' })).toBe(true)
})
})

describe('#isMdastNode', () => {
test('returns `false` when the given mdast node is NOT a node with the specified type name', () => {
expect(isMdastNode({ type: 'unknown' }, 'link')).toBe(false)
expect(isMdastNode({ type: 'unknown' }, 'paragraph')).toBe(false)
})

test('returns `true` when the given mdast node is a node of type `link`', () => {
expect(isMdastNode({ type: 'link' }, 'link')).toBe(true)
})

test('returns `true` when the given mdast node is a node of type `paragraph`', () => {
expect(isMdastNode({ type: 'paragraph' }, 'paragraph')).toBe(true)
})
})
})
Loading
Loading