feat: Native video display (#5866)
This commit is contained in:
@@ -11,7 +11,7 @@ import toggleWrap from "../commands/toggleWrap";
|
||||
import FileExtension from "../components/FileExtension";
|
||||
import Widget from "../components/Widget";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import attachmentsRule from "../rules/attachments";
|
||||
import attachmentsRule from "../rules/links";
|
||||
import { ComponentProps } from "../types";
|
||||
import Node from "./Node";
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import Token from "markdown-it/lib/token";
|
||||
import { InputRule } from "prosemirror-inputrules";
|
||||
import { Node as ProsemirrorNode, NodeSpec, NodeType } from "prosemirror-model";
|
||||
import { NodeSelection, Plugin, Command } from "prosemirror-state";
|
||||
import {
|
||||
NodeSelection,
|
||||
Plugin,
|
||||
Command,
|
||||
TextSelection,
|
||||
} from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { sanitizeUrl } from "../../utils/urls";
|
||||
import { default as ImageComponent, Caption } from "../components/Image";
|
||||
import Caption from "../components/Caption";
|
||||
import ImageComponent from "../components/Image";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import { ComponentProps } from "../types";
|
||||
import SimpleImage from "./SimpleImage";
|
||||
@@ -215,13 +221,58 @@ export default class Image extends SimpleImage {
|
||||
void downloadImageNode(node);
|
||||
};
|
||||
|
||||
// Ensure only plain text can be pasted into input when pasting from another
|
||||
// rich text source.
|
||||
handlePaste = (event: React.ClipboardEvent<HTMLSpanElement>) => {
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
window.document.execCommand("insertText", false, text);
|
||||
};
|
||||
handleCaptionKeyDown =
|
||||
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
|
||||
(event: React.KeyboardEvent<HTMLParagraphElement>) => {
|
||||
// Pressing Enter in the caption field should move the cursor/selection
|
||||
// below the image and create a new paragraph.
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
|
||||
const { view } = this.editor;
|
||||
const $pos = view.state.doc.resolve(getPos() + node.nodeSize);
|
||||
view.dispatch(
|
||||
view.state.tr
|
||||
.setSelection(TextSelection.near($pos))
|
||||
.split($pos.pos)
|
||||
.scrollIntoView()
|
||||
);
|
||||
view.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pressing Backspace in an an empty caption field focused the image.
|
||||
if (event.key === "Backspace" && event.currentTarget.innerText === "") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const { view } = this.editor;
|
||||
const $pos = view.state.doc.resolve(getPos());
|
||||
const tr = view.state.tr.setSelection(new NodeSelection($pos));
|
||||
view.dispatch(tr);
|
||||
view.focus();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
handleCaptionBlur =
|
||||
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
|
||||
(event: React.FocusEvent<HTMLParagraphElement>) => {
|
||||
const caption = event.currentTarget.innerText;
|
||||
if (caption === node.attrs.alt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { view } = this.editor;
|
||||
const { tr } = view.state;
|
||||
|
||||
// update meta on object
|
||||
const pos = getPos();
|
||||
const transaction = tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
alt: caption,
|
||||
});
|
||||
view.dispatch(transaction);
|
||||
};
|
||||
|
||||
component = (props: ComponentProps) => (
|
||||
<ImageComponent
|
||||
@@ -231,16 +282,10 @@ export default class Image extends SimpleImage {
|
||||
onChangeSize={this.handleChangeSize(props)}
|
||||
>
|
||||
<Caption
|
||||
onPaste={this.handlePaste}
|
||||
onKeyDown={this.handleKeyDown(props)}
|
||||
onBlur={this.handleBlur(props)}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
className="caption"
|
||||
tabIndex={-1}
|
||||
role="textbox"
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
data-caption={this.options.dictionary.imageCaptionPlaceholder}
|
||||
onBlur={this.handleCaptionBlur(props)}
|
||||
onKeyDown={this.handleCaptionKeyDown(props)}
|
||||
isSelected={props.isSelected}
|
||||
placeholder={this.options.dictionary.imageCaptionPlaceholder}
|
||||
>
|
||||
{props.node.attrs.alt}
|
||||
</Caption>
|
||||
|
||||
@@ -76,55 +76,6 @@ export default class SimpleImage extends Node {
|
||||
};
|
||||
}
|
||||
|
||||
handleKeyDown =
|
||||
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
|
||||
(event: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||
// Pressing Enter in the caption field should move the cursor/selection
|
||||
// below the image
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
|
||||
const { view } = this.editor;
|
||||
const $pos = view.state.doc.resolve(getPos() + node.nodeSize);
|
||||
view.dispatch(
|
||||
view.state.tr.setSelection(new TextSelection($pos)).split($pos.pos)
|
||||
);
|
||||
view.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pressing Backspace in an an empty caption field should remove the entire
|
||||
// image, leaving an empty paragraph
|
||||
if (event.key === "Backspace" && event.currentTarget.innerText === "") {
|
||||
const { view } = this.editor;
|
||||
const $pos = view.state.doc.resolve(getPos());
|
||||
const tr = view.state.tr.setSelection(new NodeSelection($pos));
|
||||
view.dispatch(tr.deleteSelection());
|
||||
view.focus();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
handleBlur =
|
||||
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
|
||||
(event: React.FocusEvent<HTMLSpanElement>) => {
|
||||
const caption = event.currentTarget.innerText;
|
||||
if (caption === node.attrs.alt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { view } = this.editor;
|
||||
const { tr } = view.state;
|
||||
|
||||
// update meta on object
|
||||
const pos = getPos();
|
||||
const transaction = tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
alt: caption,
|
||||
});
|
||||
view.dispatch(transaction);
|
||||
};
|
||||
|
||||
handleSelect =
|
||||
({ getPos }: { getPos: () => number }) =>
|
||||
(event: React.MouseEvent) => {
|
||||
@@ -136,16 +87,6 @@ export default class SimpleImage extends Node {
|
||||
view.dispatch(transaction);
|
||||
};
|
||||
|
||||
handleMouseDown = (ev: React.MouseEvent<HTMLParagraphElement>) => {
|
||||
// always prevent clicks in caption from bubbling to the editor
|
||||
ev.stopPropagation();
|
||||
|
||||
if (document.activeElement !== ev.currentTarget) {
|
||||
ev.preventDefault();
|
||||
ev.currentTarget.focus();
|
||||
}
|
||||
};
|
||||
|
||||
component = (props: ComponentProps) => (
|
||||
<ImageComponent {...props} onClick={this.handleSelect(props)} />
|
||||
);
|
||||
|
||||
185
shared/editor/nodes/Video.tsx
Normal file
185
shared/editor/nodes/Video.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import Token from "markdown-it/lib/token";
|
||||
import { NodeSpec, NodeType, Node as ProsemirrorNode } from "prosemirror-model";
|
||||
import { NodeSelection, TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { Primitive } from "utility-types";
|
||||
import { sanitizeUrl } from "../../utils/urls";
|
||||
import toggleWrap from "../commands/toggleWrap";
|
||||
import Caption from "../components/Caption";
|
||||
import VideoComponent from "../components/Video";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import attachmentsRule from "../rules/links";
|
||||
import { ComponentProps } from "../types";
|
||||
import Node from "./Node";
|
||||
|
||||
export default class Video extends Node {
|
||||
get name() {
|
||||
return "video";
|
||||
}
|
||||
|
||||
get rulePlugins() {
|
||||
return [attachmentsRule];
|
||||
}
|
||||
|
||||
get schema(): NodeSpec {
|
||||
return {
|
||||
attrs: {
|
||||
id: {
|
||||
default: null,
|
||||
},
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
width: {
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
},
|
||||
title: {},
|
||||
},
|
||||
group: "block",
|
||||
defining: true,
|
||||
atom: true,
|
||||
parseDOM: [
|
||||
{
|
||||
priority: 100,
|
||||
tag: "video",
|
||||
getAttrs: (dom: HTMLAnchorElement) => ({
|
||||
id: dom.id,
|
||||
title: dom.getAttribute("title"),
|
||||
src: dom.getAttribute("src"),
|
||||
width: parseInt(dom.getAttribute("width") ?? "", 10),
|
||||
height: parseInt(dom.getAttribute("height") ?? "", 10),
|
||||
}),
|
||||
},
|
||||
],
|
||||
toDOM: (node) => [
|
||||
"video",
|
||||
{
|
||||
id: node.attrs.id,
|
||||
src: sanitizeUrl(node.attrs.src),
|
||||
controls: true,
|
||||
width: node.attrs.width,
|
||||
height: node.attrs.height,
|
||||
},
|
||||
node.attrs.title,
|
||||
],
|
||||
toPlainText: (node) => node.attrs.title,
|
||||
};
|
||||
}
|
||||
|
||||
handleSelect =
|
||||
({ getPos }: { getPos: () => number }) =>
|
||||
() => {
|
||||
const { view } = this.editor;
|
||||
const $pos = view.state.doc.resolve(getPos());
|
||||
const transaction = view.state.tr.setSelection(new NodeSelection($pos));
|
||||
view.dispatch(transaction);
|
||||
};
|
||||
|
||||
handleChangeSize =
|
||||
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
|
||||
({ width, height }: { width: number; height?: number }) => {
|
||||
const { view } = this.editor;
|
||||
const { tr } = view.state;
|
||||
|
||||
const pos = getPos();
|
||||
const transaction = tr
|
||||
.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
.setMeta("addToHistory", true);
|
||||
const $pos = transaction.doc.resolve(getPos());
|
||||
view.dispatch(transaction.setSelection(new NodeSelection($pos)));
|
||||
};
|
||||
|
||||
handleCaptionKeyDown =
|
||||
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
|
||||
(event: React.KeyboardEvent<HTMLParagraphElement>) => {
|
||||
// Pressing Enter in the caption field should move the cursor/selection
|
||||
// below the video
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
|
||||
const { view } = this.editor;
|
||||
const $pos = view.state.doc.resolve(getPos() + node.nodeSize);
|
||||
view.dispatch(
|
||||
view.state.tr.setSelection(TextSelection.near($pos)).scrollIntoView()
|
||||
);
|
||||
view.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pressing Backspace in an an empty caption field focuses the video.
|
||||
if (event.key === "Backspace" && event.currentTarget.innerText === "") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const { view } = this.editor;
|
||||
const $pos = view.state.doc.resolve(getPos());
|
||||
const tr = view.state.tr.setSelection(new NodeSelection($pos));
|
||||
view.dispatch(tr);
|
||||
view.focus();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
handleCaptionBlur =
|
||||
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
|
||||
(event: React.FocusEvent<HTMLParagraphElement>) => {
|
||||
const caption = event.currentTarget.innerText;
|
||||
if (caption === node.attrs.title) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { view } = this.editor;
|
||||
const { tr } = view.state;
|
||||
|
||||
// update meta on object
|
||||
const pos = getPos();
|
||||
const transaction = tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
title: caption,
|
||||
});
|
||||
view.dispatch(transaction);
|
||||
};
|
||||
|
||||
component = (props: ComponentProps) => (
|
||||
<VideoComponent {...props} onChangeSize={this.handleChangeSize(props)}>
|
||||
<Caption
|
||||
onBlur={this.handleCaptionBlur(props)}
|
||||
onKeyDown={this.handleCaptionKeyDown(props)}
|
||||
isSelected={props.isSelected}
|
||||
placeholder={this.options.dictionary.imageCaptionPlaceholder}
|
||||
>
|
||||
{props.node.attrs.title}
|
||||
</Caption>
|
||||
</VideoComponent>
|
||||
);
|
||||
|
||||
commands({ type }: { type: NodeType }) {
|
||||
return (attrs: Record<string, Primitive>) => toggleWrap(type, attrs);
|
||||
}
|
||||
|
||||
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
|
||||
state.ensureNewLine();
|
||||
state.write(
|
||||
`[${node.attrs.title} ${node.attrs.width}x${node.attrs.height}](${node.attrs.src})\n\n`
|
||||
);
|
||||
state.ensureNewLine();
|
||||
}
|
||||
|
||||
parseMarkdown() {
|
||||
return {
|
||||
node: "video",
|
||||
getAttrs: (tok: Token) => ({
|
||||
src: tok.attrGet("src"),
|
||||
title: tok.attrGet("title"),
|
||||
width: parseInt(tok.attrGet("width") ?? "", 10),
|
||||
height: parseInt(tok.attrGet("height") ?? "", 10),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import TableCell from "./TableCell";
|
||||
import TableHeadCell from "./TableHeadCell";
|
||||
import TableRow from "./TableRow";
|
||||
import Text from "./Text";
|
||||
import Video from "./Video";
|
||||
|
||||
type Nodes = (typeof Node | typeof Mark | typeof Extension)[];
|
||||
|
||||
@@ -97,6 +98,7 @@ export const richExtensions: Nodes = [
|
||||
Embed,
|
||||
ListItem,
|
||||
Attachment,
|
||||
Video,
|
||||
Notice,
|
||||
Heading,
|
||||
HorizontalRule,
|
||||
|
||||
Reference in New Issue
Block a user