-
Notifications
You must be signed in to change notification settings - Fork 15
feat: Add RichTextVideo
extension for video playback
#735
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
rfgamaral
wants to merge
1
commit into
main
Choose a base branch
from
ricardo/video-extension
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.