feat: Native video display (#5866)

This commit is contained in:
Tom Moor
2023-09-28 20:28:09 -04:00
committed by GitHub
parent bd06e03b1e
commit f4fd9dae5f
24 changed files with 840 additions and 344 deletions

View File

@@ -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";

View File

@@ -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>

View File

@@ -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)} />
);

View 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),
}),
};
}
}

View File

@@ -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,