diff --git a/src/extensions/rich-text/rich-text-kit.ts b/src/extensions/rich-text/rich-text-kit.ts
index 80f6df45..4346a93c 100644
--- a/src/extensions/rich-text/rich-text-kit.ts
+++ b/src/extensions/rich-text/rich-text-kit.ts
@@ -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'
@@ -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.
@@ -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 | false
}
/**
@@ -330,6 +337,10 @@ const RichTextKit = Extension.create({
extensions.push(Typography)
}
+ if (this.options.video !== false) {
+ extensions.push(RichTextVideo.configure(this.options?.video))
+ }
+
return extensions
},
})
diff --git a/src/extensions/rich-text/rich-text-video.ts b/src/extensions/rich-text/rich-text-video.ts
new file mode 100644
index 00000000..3fdbe9a0
--- /dev/null
+++ b/src/extensions/rich-text/rich-text-video.ts
@@ -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
+
+/**
+ * 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 {
+ 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 &
+ Required>,
+ ) => 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
+
+ /**
+ * Renders the video node inline (e.g., ). 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
+
+ /**
+ * 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 `
![](https://octodex.github.com/images/octobiwan.jpg)![](https://octodex.github.com/images/octobiwan.jpg)
![Octobi Wan Catnobi](https://octodex.github.com/images/octobiwan.jpg "Octobi Wan Catnobi")
[![Octobi Wan Catnobi](https://octodex.github.com/images/octobiwan.jpg "Octobi Wan Catnobi")](https://octodex.github.com/octobiwan/)
Octobi Wan Catnobi: ![](https://octodex.github.com/images/octobiwan.jpg)
Octobi Wan Catnobi: ![](https://octodex.github.com/images/octobiwan.jpg) - These are not the droids you're looking for!
![](https://octodex.github.com/images/octobiwan.jpg) - These are not the droids you're looking for!
`)
})
+ test('videos HTML output is preserved', () => {
+ expect(htmlSerializer.serialize(MARKDOWN_INPUT_VIDEOS))
+ .toBe(`https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.invalid
https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.invalid https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.invalid
https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
+https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.invalid
+https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.invalid
Big Buck Bunny: https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
Big Buck Bunny: https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.invalid
Big Buck Bunny: https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 - The story of a giant rabbit with a heart bigger than himself.
Big Buck Bunny: https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.invalid - The story of a giant rabbit with a heart bigger than himself.
https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 - The story of a giant rabbit with a heart bigger than himself.
https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.invalid - The story of a giant rabbit with a heart bigger than himself.
`)
+ })
+
test('code HTML output is preserved', () => {
expect(htmlSerializer.serialize(MARKDOWN_INPUT_CODE)).toBe(
'At the command prompt, type `nano`.
``Use `code` in your Markdown file.``
',
diff --git a/src/serializers/html/html.ts b/src/serializers/html/html.ts
index 24f85bdf..95878d46 100644
--- a/src/serializers/html/html.ts
+++ b/src/serializers/html/html.ts
@@ -15,6 +15,7 @@ import { rehypeTaskList } from './plugins/rehype-task-list'
import { remarkAutolinkLiteral } from './plugins/remark-autolink-literal'
import { remarkDisableConstructs } from './plugins/remark-disable-constructs'
import { remarkStrikethrough } from './plugins/remark-strikethrough'
+import { remarkVideo } from './plugins/remark-video'
import type { Schema } from '@tiptap/pm/model'
@@ -113,6 +114,11 @@ function createHTMLSerializer(schema: Schema): HTMLSerializerReturnType {
unifiedProcessor.use(remarkAutolinkLiteral)
}
+ // Configure the unified processor with a custom plugin to add support for video nodes
+ if (schema.nodes.video) {
+ unifiedProcessor.use(remarkVideo, schema)
+ }
+
// Configure the unified processor with an official plugin to convert Markdown into HTML to
// support rehype (a tool that transforms HTML with plugins), followed by another official
// plugin to minify whitespace between tags (prevents line feeds from appearing as blank)
diff --git a/src/serializers/html/plugins/remark-video.ts b/src/serializers/html/plugins/remark-video.ts
new file mode 100644
index 00000000..dba76b61
--- /dev/null
+++ b/src/serializers/html/plugins/remark-video.ts
@@ -0,0 +1,76 @@
+import { visit } from 'unist-util-visit'
+
+import { REGEX_WEB_URL } from '../../../constants/regular-expressions'
+import { isMdastNode } from '../../../helpers/unified'
+
+import type { Schema } from '@tiptap/pm/model'
+import type { Node as MdastNode, Parent as MdastParent } from 'mdast'
+import type { Transformer } from 'unified'
+
+/**
+ * A URL validation regular expression for video URLs (matches a URL that ends
+ * with a video file extension supported by the HTML5 video element).
+ */
+const REGEX_VIDEO_URL = new RegExp(
+ `${REGEX_WEB_URL.source}\\.(?:mov|mp4|webm)$`,
+ REGEX_WEB_URL.flags,
+)
+
+/**
+ * Replaces a link node with a video element if the link URL is a valid video URL.
+ *
+ * @param parent The parent node of the link node to be replaced.
+ * @param index The index of the child link node to be replaced.
+ * @param src The URL of the video to be embedded in the video element.
+ */
+function replaceWithVideoElementIfVideoUrl(parent: MdastParent, index: number, src: string) {
+ if (REGEX_VIDEO_URL.test(src)) {
+ parent.children.splice(index, 1, {
+ type: 'text',
+ value: '',
+ data: {
+ hName: 'video',
+ hProperties: {
+ src,
+ },
+ },
+ })
+ }
+}
+
+/**
+ * A remark plugin to add support for video elements in Markdown by replacing link nodes with the
+ * HTML5 video element if the link URL is a valid video URL.
+ *
+ * @param schema The editor schema to be used for nodes and marks detection.
+ */
+function remarkVideo(schema: Schema): Transformer {
+ const allowInlineVideos = schema.nodes.video ? schema.nodes.video.spec.inline : false
+
+ return (...[tree]: Parameters): ReturnType => {
+ // If the editor supports inline videos, traverse the tree - testing for link nodes - and
+ // replace all link nodes with video elements if the link URL is a valid video URL.
+ if (allowInlineVideos) {
+ visit(tree, 'link', (node: MdastNode, index: number, parent: MdastParent) => {
+ if (isMdastNode(node, 'link')) {
+ replaceWithVideoElementIfVideoUrl(parent, index, node.url)
+ }
+ })
+ }
+ // Otherwise, traverse the tree - testing for paragraph nodes - and replace all paragraph
+ // nodes with a single link child with video elements if the link URL is a valid video URL.
+ else {
+ visit(tree, 'paragraph', (node: MdastNode, index: number, parent: MdastParent) => {
+ if (
+ isMdastNode(node, 'paragraph') &&
+ node.children.length === 1 &&
+ isMdastNode(node.children[0], 'link')
+ ) {
+ replaceWithVideoElementIfVideoUrl(parent, index, node.children[0].url)
+ }
+ })
+ }
+ }
+}
+
+export { remarkVideo }
diff --git a/src/serializers/markdown/markdown.test.ts b/src/serializers/markdown/markdown.test.ts
index d9f1a782..5298ed59 100644
--- a/src/serializers/markdown/markdown.test.ts
+++ b/src/serializers/markdown/markdown.test.ts
@@ -177,6 +177,8 @@ const HTML_INPUT_IMAGES = ` - These are not the droids you're looking for!
- These are not the droids you're looking for!
`
+const HTML_INPUT_VIDEOS = `
Big Buck Bunny:
Big Buck Bunny: - The story of a giant rabbit with a heart bigger than himself.
- The story of a giant rabbit with a heart bigger than himself.
`
+
const HTML_INPUT_CODE = `At the command prompt, type nano
.
Use \`code\` in your Markdown file.
`
@@ -581,6 +583,46 @@ Octobi Wan Catnobi: ![](https://octodex.github.com/images/octobiwan.jpg) - These
![](https://octodex.github.com/images/octobiwan.jpg) - These are not the droids you're looking for!`)
})
+ // FIXME: This is the correct implementation of the "Turndown version" below, however,
+ // the current serializer implementation based on Turndown cannot handle this case.
+ // To fix this, we should switch to a different Markdown serializer library, such as
+ // unifiedjs/remark (something we should consider anyway).
+ test.skip('videos Markdown output is correct', () => {
+ expect(markdownSerializer.serialize(HTML_INPUT_VIDEOS))
+ .toBe(`https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
+
+https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
+
+https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
+
+https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
+https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
+
+Big Buck Bunny: https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
+
+Big Buck Bunny: https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 - The story of a giant rabbit with a heart bigger than himself.
+
+https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 - The story of a giant rabbit with a heart bigger than himself.`)
+ })
+
+ test('videos Markdown output is correct (Turndown version)', () => {
+ expect(markdownSerializer.serialize(HTML_INPUT_VIDEOS))
+ .toBe(`https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
+
+https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
+
+https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
+
+https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
+https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
+
+Big Buck Bunny:https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
+
+Big Buck Bunny: https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4- The story of a giant rabbit with a heart bigger than himself.
+
+https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4- The story of a giant rabbit with a heart bigger than himself.`)
+ })
+
test('code Markdown output is correct', () => {
expect(markdownSerializer.serialize(HTML_INPUT_CODE)).toBe(
`At the command prompt, type \`nano\`.
diff --git a/src/serializers/markdown/markdown.ts b/src/serializers/markdown/markdown.ts
index 82f7f5d0..08effd23 100644
--- a/src/serializers/markdown/markdown.ts
+++ b/src/serializers/markdown/markdown.ts
@@ -9,6 +9,7 @@ import { paragraph } from './plugins/paragraph'
import { strikethrough } from './plugins/strikethrough'
import { suggestion } from './plugins/suggestion'
import { taskItem } from './plugins/task-item'
+import { video } from './plugins/video'
import type { Schema } from '@tiptap/pm/model'
@@ -154,6 +155,11 @@ function createMarkdownSerializer(schema: Schema): MarkdownSerializerReturnType
turndown.use(taskItem(schema.nodes.taskItem))
}
+ // Add a rule for `video` if the corresponding node exists in the schema
+ if (schema.nodes.video) {
+ turndown.use(video(schema.nodes.video))
+ }
+
// Add a custom rule for all suggestion nodes available in the schema
Object.values(schema.nodes)
.filter((node) => node.name.endsWith('Suggestion'))
diff --git a/src/serializers/markdown/plugins/video.ts b/src/serializers/markdown/plugins/video.ts
new file mode 100644
index 00000000..1ae34150
--- /dev/null
+++ b/src/serializers/markdown/plugins/video.ts
@@ -0,0 +1,25 @@
+import type { NodeType } from '@tiptap/pm/model'
+import type Turndown from 'turndown'
+
+/**
+ * A Turndown plugin which adds a custom rule for videos. This custom rule also disables support for
+ * Data URLs (URLs prefixed with the `data: scheme`), while displaying an explicit message in the
+ * Markdown output (for debugging and testing).
+ *
+ * @param nodeType The node object that matches this rule.
+ */
+function video(nodeType: NodeType): Turndown.Plugin {
+ return (turndown: Turndown) => {
+ turndown.addRule(nodeType.name, {
+ filter: 'video',
+ replacement(_, node) {
+ const src = String((node as Element).getAttribute('src'))
+
+ // Preserve Data URL image prefix with message about base64 being unsupported
+ return src.startsWith('data:') ? `${src.split(',')[0]},NOT_SUPPORTED` : src
+ },
+ })
+ }
+}
+
+export { video }
diff --git a/stories/typist-editor/decorators/typist-editor-decorator/typist-editor-decorator.module.css b/stories/typist-editor/decorators/typist-editor-decorator/typist-editor-decorator.module.css
index 0b904a86..15601e76 100644
--- a/stories/typist-editor/decorators/typist-editor-decorator/typist-editor-decorator.module.css
+++ b/stories/typist-editor/decorators/typist-editor-decorator/typist-editor-decorator.module.css
@@ -125,44 +125,44 @@ div + .editorContainer {
font-family: var(--storybook-theme-fontCode);
}
-:global(div[data-typist-editor] :is(img)) {
+:global(div[data-typist-editor] :is(img, video)) {
border-radius: var(--typist-editor-media-border-radius);
}
-:global(div[data-typist-editor] > :is(img)) {
+:global(div[data-typist-editor] > :is(img, video)) {
display: block;
}
-:global(div[data-typist-editor] > p > :is(img)) {
+:global(div[data-typist-editor] > p > :is(img, video)) {
display: inline-block;
}
-:global(div[data-typist-editor] > :is(.Typist-image)) {
+:global(div[data-typist-editor] > :is(.Typist-image, .Typist-video)) {
display: flex;
}
-:global(div[data-typist-editor] > p > :is(.Typist-image)) {
+:global(div[data-typist-editor] > p > :is(.Typist-image, .Typist-video)) {
display: inline-flex;
}
-:global(div[data-typist-editor] > :is(img)),
-:global(div[data-typist-editor] > :is(.Typist-image)) {
+:global(div[data-typist-editor] > :is(img, video)),
+:global(div[data-typist-editor] > :is(.Typist-image, .Typist-video)) {
margin-bottom: 1rem;
}
-:global(div[data-typist-editor] > :is(img)),
-:global(div[data-typist-editor] > :is(.Typist-image)),
-:global(div[data-typist-editor] > p > :is(img)),
-:global(div[data-typist-editor] > p > :is(.Typist-image)) {
+:global(div[data-typist-editor] > :is(img, video)),
+:global(div[data-typist-editor] > :is(.Typist-image, .Typist-video)),
+:global(div[data-typist-editor] > p > :is(img, video)),
+:global(div[data-typist-editor] > p > :is(.Typist-image, .Typist-video)) {
border-radius: var(--typist-editor-media-border-radius);
max-width: 480px;
width: fit-content;
}
-:global(div[data-typist-editor] > :is(img).ProseMirror-selectednode),
-:global(div[data-typist-editor] > :is(.Typist-image).ProseMirror-selectednode),
-:global(div[data-typist-editor] > p > :is(img).ProseMirror-selectednode),
-:global(div[data-typist-editor] > p > :is(.Typist-image).ProseMirror-selectednode) {
+:global(div[data-typist-editor] > :is(img, video).ProseMirror-selectednode),
+:global(div[data-typist-editor] > :is(.Typist-image, .Typist-video).ProseMirror-selectednode),
+:global(div[data-typist-editor] > p > :is(img, video).ProseMirror-selectednode),
+:global(div[data-typist-editor] > p > :is(.Typist-image, .Typist-video).ProseMirror-selectednode) {
outline: 2px solid var(--storybook-theme-colorSecondary);
}
diff --git a/stories/typist-editor/rich-text.stories.tsx b/stories/typist-editor/rich-text.stories.tsx
index 7b0a6343..b7296a4f 100644
--- a/stories/typist-editor/rich-text.stories.tsx
+++ b/stories/typist-editor/rich-text.stories.tsx
@@ -14,6 +14,7 @@ import { TypistEditorDecorator } from './decorators/typist-editor-decorator/typi
import { HashtagSuggestion } from './extensions/hashtag-suggestion'
import { MentionSuggestion } from './extensions/mention-suggestion'
import { RichTextImageWrapper } from './wrappers/rich-text-image-wrapper'
+import { RichTextVideoWrapper } from './wrappers/rich-text-video-wrapper'
import type { Meta, StoryObj } from '@storybook/react'
import type { Extensions } from '@tiptap/core'
@@ -48,7 +49,7 @@ export const Default: StoryObj = {
const [inlineAttachments, setInlineAttachments] = useState<{
[attachmentId: string]: {
- type: 'image'
+ type: 'image' | 'video'
progress: number
}
}>({})
@@ -92,7 +93,7 @@ export const Default: StoryObj = {
const updateInlineAttachmentAttributes =
inlineAttachments[attachmentId].type === 'image'
? commands?.updateImage
- : () => {}
+ : commands?.updateVideo
updateInlineAttachmentAttributes?.({
metadata: {
@@ -109,7 +110,7 @@ export const Default: StoryObj = {
const handleInlineFilePaste = useCallback(function handleInlineFilePaste(file: File) {
const fileType = file.type.split('/')[0]
- if (fileType !== 'image') {
+ if (fileType !== 'image' && fileType !== 'video') {
return
}
@@ -140,6 +141,11 @@ export const Default: StoryObj = {
src: String(fileReader.result),
metadata,
})
+ } else {
+ commands?.insertVideo({
+ src: String(fileReader.result),
+ metadata,
+ })
}
}
@@ -155,6 +161,10 @@ export const Default: StoryObj = {
NodeViewComponent: RichTextImageWrapper,
onImageFilePaste: handleInlineFilePaste,
},
+ video: {
+ NodeViewComponent: RichTextVideoWrapper,
+ onVideoFilePaste: handleInlineFilePaste,
+ },
}),
...COMMON_STORY_EXTENSIONS,
]
diff --git a/stories/typist-editor/wrappers/rich-text-video-wrapper.module.css b/stories/typist-editor/wrappers/rich-text-video-wrapper.module.css
new file mode 100644
index 00000000..a9bcd676
--- /dev/null
+++ b/stories/typist-editor/wrappers/rich-text-video-wrapper.module.css
@@ -0,0 +1,26 @@
+.richTextVideoWrapper {
+ display: grid;
+}
+
+.richTextVideoWrapper .videoAttachment,
+.richTextVideoWrapper .progressOverlay {
+ grid-area: 1 / 1;
+}
+
+.richTextVideoWrapper .videoAttachment {
+ width: 100%;
+ height: auto;
+}
+
+.richTextVideoWrapper .videoAttachment.noPointerEvents {
+ pointer-events: none;
+}
+
+.richTextVideoWrapper .progressOverlay {
+ background-color: var(--storybook-theme-appBg);
+ border-radius: calc(var(--storybook-theme-appBorderRadius) / 2);
+ place-self: end center;
+ width: 100%;
+ height: var(--video-upload-progress);
+ opacity: 0.75;
+}
diff --git a/stories/typist-editor/wrappers/rich-text-video-wrapper.tsx b/stories/typist-editor/wrappers/rich-text-video-wrapper.tsx
new file mode 100644
index 00000000..f444f853
--- /dev/null
+++ b/stories/typist-editor/wrappers/rich-text-video-wrapper.tsx
@@ -0,0 +1,55 @@
+import { Box } from '@doist/reactist'
+
+import classNames from 'classnames'
+
+import { NodeViewWrapper } from '../../../src'
+
+import styles from './rich-text-video-wrapper.module.css'
+
+import type { NodeViewProps, RichTextVideoAttributes, RichTextVideoOptions } from '../../../src'
+
+function RichTextVideoWrapper({ extension, node }: NodeViewProps) {
+ const {
+ autoplay,
+ controls,
+ loop,
+ muted,
+ HTMLAttributes: { class: className, ...videoAttributes },
+ } = extension.options as RichTextVideoOptions
+
+ const { metadata, src } = node.attrs as RichTextVideoAttributes
+ const { attachmentId, isUploadFailed = false, uploadProgress = 0 } = metadata || {}
+
+ const isAttachmentUploading = Boolean(attachmentId && !isUploadFailed && uploadProgress < 100)
+
+ const videoClasses = classNames(className, styles.videoAttachment, {
+ // Disallow player interaction during the uploading simulation
+ [styles.noPointerEvents]: isAttachmentUploading,
+ })
+
+ const progressOverlayStyle: React.CSSProperties = {
+ ['--video-upload-progress' as string]: `${100 - uploadProgress}%`,
+ }
+
+ return (
+
+
+ {isAttachmentUploading ? (
+
+ ) : null}
+
+ )
+}
+
+export { RichTextVideoWrapper }