feat: Native video display (#5866)
This commit is contained in:
@@ -244,6 +244,8 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
|||||||
return triggerFilePick(
|
return triggerFilePick(
|
||||||
AttachmentValidation.imageContentTypes.join(", ")
|
AttachmentValidation.imageContentTypes.join(", ")
|
||||||
);
|
);
|
||||||
|
case "video":
|
||||||
|
return triggerFilePick("video/*");
|
||||||
case "attachment":
|
case "attachment":
|
||||||
return triggerFilePick("*");
|
return triggerFilePick("*");
|
||||||
case "embed":
|
case "embed":
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
MathIcon,
|
MathIcon,
|
||||||
DoneIcon,
|
DoneIcon,
|
||||||
|
EmbedIcon,
|
||||||
} from "outline-icons";
|
} from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
@@ -101,6 +102,12 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
|
|||||||
shortcut: `${metaDisplay} k`,
|
shortcut: `${metaDisplay} k`,
|
||||||
keywords: "link url uri href",
|
keywords: "link url uri href",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "video",
|
||||||
|
title: dictionary.video,
|
||||||
|
icon: <EmbedIcon />,
|
||||||
|
keywords: "mov avi upload player",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "attachment",
|
name: "attachment",
|
||||||
title: dictionary.file,
|
title: dictionary.file,
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ export default function useDictionary() {
|
|||||||
insertDateTime: t("Current date and time"),
|
insertDateTime: t("Current date and time"),
|
||||||
indent: t("Indent"),
|
indent: t("Indent"),
|
||||||
outdent: t("Outdent"),
|
outdent: t("Outdent"),
|
||||||
|
video: t("Video"),
|
||||||
|
untitled: t("Untitled"),
|
||||||
}),
|
}),
|
||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -148,7 +148,7 @@
|
|||||||
"natural-sort": "^1.0.0",
|
"natural-sort": "^1.0.0",
|
||||||
"node-fetch": "2.7.0",
|
"node-fetch": "2.7.0",
|
||||||
"nodemailer": "^6.9.4",
|
"nodemailer": "^6.9.4",
|
||||||
"outline-icons": "^2.4.0",
|
"outline-icons": "^2.5.0",
|
||||||
"oy-vey": "^0.12.0",
|
"oy-vey": "^0.12.0",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
"passport-google-oauth2": "^0.2.0",
|
"passport-google-oauth2": "^0.2.0",
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export default function init(app: Koa = new Koa(), server?: Server) {
|
|||||||
defaultSrc,
|
defaultSrc,
|
||||||
styleSrc,
|
styleSrc,
|
||||||
scriptSrc: [...scriptSrc, `'nonce-${ctx.state.cspNonce}'`],
|
scriptSrc: [...scriptSrc, `'nonce-${ctx.state.cspNonce}'`],
|
||||||
|
mediaSrc: ["*", "data:", "blob:"],
|
||||||
imgSrc: ["*", "data:", "blob:"],
|
imgSrc: ["*", "data:", "blob:"],
|
||||||
frameSrc: ["*", "data:"],
|
frameSrc: ["*", "data:"],
|
||||||
// Do not use connect-src: because self + websockets does not work in
|
// Do not use connect-src: because self + websockets does not work in
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
import * as Sentry from "@sentry/react";
|
import * as Sentry from "@sentry/react";
|
||||||
import invariant from "invariant";
|
|
||||||
import { NodeSelection } from "prosemirror-state";
|
import { NodeSelection } from "prosemirror-state";
|
||||||
import { EditorView } from "prosemirror-view";
|
import { EditorView } from "prosemirror-view";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import FileHelper from "../lib/FileHelper";
|
||||||
import uploadPlaceholderPlugin, {
|
import uploadPlaceholderPlugin, {
|
||||||
findPlaceholder,
|
findPlaceholder,
|
||||||
} from "../lib/uploadPlaceholder";
|
} from "../lib/uploadPlaceholder";
|
||||||
import findAttachmentById from "../queries/findAttachmentById";
|
import findAttachmentById from "../queries/findAttachmentById";
|
||||||
|
|
||||||
export type Options = {
|
export type Options = {
|
||||||
|
/** Dictionary object containing translation strings */
|
||||||
dictionary: any;
|
dictionary: any;
|
||||||
/** Set to true to force images to become attachments */
|
/** Set to true to force images and videos to become file attachments */
|
||||||
isAttachment?: boolean;
|
isAttachment?: boolean;
|
||||||
/** Set to true to replace any existing image at the users selection */
|
/** Set to true to replace any existing image at the users selection */
|
||||||
replaceExisting?: boolean;
|
replaceExisting?: boolean;
|
||||||
|
/** Callback fired to upload a file */
|
||||||
uploadFile?: (file: File) => Promise<string>;
|
uploadFile?: (file: File) => Promise<string>;
|
||||||
|
/** Callback fired when the user starts a file upload */
|
||||||
onFileUploadStart?: () => void;
|
onFileUploadStart?: () => void;
|
||||||
|
/** Callback fired when the user completes a file upload */
|
||||||
onFileUploadStop?: () => void;
|
onFileUploadStop?: () => void;
|
||||||
|
/** Callback fired when a toast needs to be displayed */
|
||||||
onShowToast: (message: string) => void;
|
onShowToast: (message: string) => void;
|
||||||
|
/** Attributes to overwrite */
|
||||||
attrs?: {
|
attrs?: {
|
||||||
/** Width to use when inserting image */
|
/** Width to use when inserting image */
|
||||||
width?: number;
|
width?: number;
|
||||||
@@ -44,19 +50,12 @@ const insertFiles = function (
|
|||||||
onShowToast,
|
onShowToast,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
invariant(
|
|
||||||
uploadFile,
|
|
||||||
"uploadFile callback must be defined to handle uploads."
|
|
||||||
);
|
|
||||||
|
|
||||||
// okay, we have some dropped files and a handler – lets stop this
|
// okay, we have some dropped files and a handler – lets stop this
|
||||||
// event going any further up the stack
|
// event going any further up the stack
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
// let the user know we're starting to process the files
|
// let the user know we're starting to process the files
|
||||||
if (onFileUploadStart) {
|
onFileUploadStart?.();
|
||||||
onFileUploadStart();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { schema } = view.state;
|
const { schema } = view.state;
|
||||||
|
|
||||||
@@ -66,7 +65,10 @@ const insertFiles = function (
|
|||||||
|
|
||||||
const filesToUpload = files.map((file) => ({
|
const filesToUpload = files.map((file) => ({
|
||||||
id: `upload-${uuidv4()}`,
|
id: `upload-${uuidv4()}`,
|
||||||
isImage: file.type.startsWith("image/") && !options.isAttachment,
|
isImage:
|
||||||
|
FileHelper.isImage(file) && !options.isAttachment && !!schema.nodes.image,
|
||||||
|
isVideo:
|
||||||
|
FileHelper.isVideo(file) && !options.isAttachment && !!schema.nodes.video,
|
||||||
file,
|
file,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -75,11 +77,6 @@ const insertFiles = function (
|
|||||||
const { tr } = view.state;
|
const { tr } = view.state;
|
||||||
|
|
||||||
if (upload.isImage) {
|
if (upload.isImage) {
|
||||||
// Skip if the editor does not support images.
|
|
||||||
if (!view.state.schema.nodes.image) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// insert a placeholder at this position, or mark an existing file as being
|
// insert a placeholder at this position, or mark an existing file as being
|
||||||
// replaced
|
// replaced
|
||||||
tr.setMeta(uploadPlaceholderPlugin, {
|
tr.setMeta(uploadPlaceholderPlugin, {
|
||||||
@@ -92,6 +89,18 @@ const insertFiles = function (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
view.dispatch(tr);
|
view.dispatch(tr);
|
||||||
|
} else if (upload.isVideo) {
|
||||||
|
// insert a placeholder at this position, or mark an existing file as being
|
||||||
|
// replaced
|
||||||
|
tr.setMeta(uploadPlaceholderPlugin, {
|
||||||
|
add: {
|
||||||
|
id: upload.id,
|
||||||
|
file: upload.file,
|
||||||
|
pos,
|
||||||
|
isVideo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
view.dispatch(tr);
|
||||||
} else if (!attachmentPlaceholdersSet) {
|
} else if (!attachmentPlaceholdersSet) {
|
||||||
// Skip if the editor does not support attachments.
|
// Skip if the editor does not support attachments.
|
||||||
if (!view.state.schema.nodes.attachment) {
|
if (!view.state.schema.nodes.attachment) {
|
||||||
@@ -108,7 +117,7 @@ const insertFiles = function (
|
|||||||
attachmentsToUpload.map((attachment) =>
|
attachmentsToUpload.map((attachment) =>
|
||||||
schema.nodes.attachment.create({
|
schema.nodes.attachment.create({
|
||||||
id: attachment.id,
|
id: attachment.id,
|
||||||
title: attachment.file.name ?? "Untitled",
|
title: attachment.file.name ?? dictionary.untitled,
|
||||||
size: attachment.file.size,
|
size: attachment.file.size,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -120,8 +129,8 @@ const insertFiles = function (
|
|||||||
// start uploading the file to the server. Using "then" syntax
|
// start uploading the file to the server. Using "then" syntax
|
||||||
// to allow all placeholders to be entered at once with the uploads
|
// to allow all placeholders to be entered at once with the uploads
|
||||||
// happening in the background in parallel.
|
// happening in the background in parallel.
|
||||||
uploadFile(upload.file)
|
uploadFile?.(upload.file)
|
||||||
.then((src) => {
|
.then(async (src) => {
|
||||||
if (upload.isImage) {
|
if (upload.isImage) {
|
||||||
const newImg = new Image();
|
const newImg = new Image();
|
||||||
newImg.onload = () => {
|
newImg.onload = () => {
|
||||||
@@ -161,6 +170,44 @@ const insertFiles = function (
|
|||||||
};
|
};
|
||||||
|
|
||||||
newImg.src = src;
|
newImg.src = src;
|
||||||
|
} else if (upload.isVideo) {
|
||||||
|
const result = findPlaceholder(view.state, upload.id);
|
||||||
|
|
||||||
|
// if the content around the placeholder has been deleted
|
||||||
|
// then forget about inserting this file
|
||||||
|
if (result === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [from, to] = result;
|
||||||
|
const dimensions = await FileHelper.getVideoDimensions(upload.file);
|
||||||
|
|
||||||
|
view.dispatch(
|
||||||
|
view.state.tr
|
||||||
|
.replaceWith(
|
||||||
|
from,
|
||||||
|
to || from,
|
||||||
|
schema.nodes.video.create({
|
||||||
|
src,
|
||||||
|
title: upload.file.name ?? dictionary.untitled,
|
||||||
|
width: dimensions.width,
|
||||||
|
height: dimensions.height,
|
||||||
|
...options.attrs,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.setMeta(uploadPlaceholderPlugin, { remove: { id: upload.id } })
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the users selection is still at the file then make sure to select
|
||||||
|
// the entire node once done. Otherwise, if the selection has moved
|
||||||
|
// elsewhere then we don't want to modify it
|
||||||
|
if (view.state.selection.from === from) {
|
||||||
|
view.dispatch(
|
||||||
|
view.state.tr.setSelection(
|
||||||
|
new NodeSelection(view.state.doc.resolve(from))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const result = findAttachmentById(view.state, upload.id);
|
const result = findAttachmentById(view.state, upload.id);
|
||||||
|
|
||||||
@@ -176,7 +223,7 @@ const insertFiles = function (
|
|||||||
to || from,
|
to || from,
|
||||||
schema.nodes.attachment.create({
|
schema.nodes.attachment.create({
|
||||||
href: src,
|
href: src,
|
||||||
title: upload.file.name ?? "Untitled",
|
title: upload.file.name ?? dictionary.untitled,
|
||||||
size: upload.file.size,
|
size: upload.file.size,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -198,7 +245,7 @@ const insertFiles = function (
|
|||||||
Sentry.captureException(error);
|
Sentry.captureException(error);
|
||||||
|
|
||||||
// cleanup the placeholder if there is a failure
|
// cleanup the placeholder if there is a failure
|
||||||
if (upload.isImage) {
|
if (upload.isImage || upload.isVideo) {
|
||||||
view.dispatch(
|
view.dispatch(
|
||||||
view.state.tr.setMeta(uploadPlaceholderPlugin, {
|
view.state.tr.setMeta(uploadPlaceholderPlugin, {
|
||||||
remove: { id: upload.id },
|
remove: { id: upload.id },
|
||||||
|
|||||||
85
shared/editor/components/Caption.tsx
Normal file
85
shared/editor/components/Caption.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import breakpoint from "styled-components-breakpoint";
|
||||||
|
import { s } from "../../styles";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Callback triggered when the caption is blurred */
|
||||||
|
onBlur: (event: React.FocusEvent<HTMLParagraphElement>) => void;
|
||||||
|
/** Callback triggered when keyboard is used within the caption */
|
||||||
|
onKeyDown: (event: React.KeyboardEvent<HTMLParagraphElement>) => void;
|
||||||
|
/** Whether the parent node is selected */
|
||||||
|
isSelected: boolean;
|
||||||
|
/** Placeholder text to display when the caption is empty */
|
||||||
|
placeholder: string;
|
||||||
|
/** Additional CSS styles to apply to the caption */
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component that renders a caption for an image or video.
|
||||||
|
*/
|
||||||
|
function Caption({ placeholder, children, isSelected, ...rest }: Props) {
|
||||||
|
const handlePaste = (event: React.ClipboardEvent<HTMLParagraphElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const text = event.clipboardData.getData("text/plain");
|
||||||
|
window.document.execCommand("insertText", false, text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = (ev: React.MouseEvent<HTMLParagraphElement>) => {
|
||||||
|
// always prevent clicks in caption from bubbling to the editor
|
||||||
|
ev.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Content
|
||||||
|
$isSelected={isSelected}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
className="caption"
|
||||||
|
tabIndex={-1}
|
||||||
|
role="textbox"
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
data-caption={placeholder}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Content>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Content = styled.p<{ $isSelected: boolean }>`
|
||||||
|
border: 0;
|
||||||
|
display: block;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: normal;
|
||||||
|
color: ${s("textSecondary")};
|
||||||
|
padding: 8px 0 4px;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
min-height: 1em;
|
||||||
|
outline: none;
|
||||||
|
background: none;
|
||||||
|
resize: none;
|
||||||
|
user-select: text;
|
||||||
|
margin: 0 !important;
|
||||||
|
cursor: text;
|
||||||
|
|
||||||
|
${breakpoint("tablet")`
|
||||||
|
font-size: 13px;
|
||||||
|
`};
|
||||||
|
|
||||||
|
&:empty:not(:focus) {
|
||||||
|
display: ${(props) => (props.$isSelected ? "block" : "none")}};
|
||||||
|
}
|
||||||
|
|
||||||
|
&:empty:before {
|
||||||
|
color: ${s("placeholder")};
|
||||||
|
content: attr(data-caption);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default Caption;
|
||||||
@@ -2,14 +2,13 @@ import { DownloadIcon } from "outline-icons";
|
|||||||
import type { EditorView } from "prosemirror-view";
|
import type { EditorView } from "prosemirror-view";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
|
||||||
import { s } from "../../styles";
|
import { s } from "../../styles";
|
||||||
import { sanitizeUrl } from "../../utils/urls";
|
import { sanitizeUrl } from "../../utils/urls";
|
||||||
import { ComponentProps } from "../types";
|
import { ComponentProps } from "../types";
|
||||||
import ImageZoom from "./ImageZoom";
|
import ImageZoom from "./ImageZoom";
|
||||||
import useComponentSize from "./useComponentSize";
|
import { ResizeLeft, ResizeRight } from "./ResizeHandle";
|
||||||
|
import useComponentSize from "./hooks/useComponentSize";
|
||||||
type DragDirection = "left" | "right";
|
import useDragResize from "./hooks/useDragResize";
|
||||||
|
|
||||||
type Props = ComponentProps & {
|
type Props = ComponentProps & {
|
||||||
/** Callback triggered when the image is clicked */
|
/** Callback triggered when the image is clicked */
|
||||||
@@ -24,19 +23,12 @@ type Props = ComponentProps & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Image = (props: Props) => {
|
const Image = (props: Props) => {
|
||||||
const { isSelected, node, isEditable } = props;
|
const { isSelected, node, isEditable, onChangeSize } = props;
|
||||||
const { src, layoutClass } = node.attrs;
|
const { src, layoutClass } = node.attrs;
|
||||||
const className = layoutClass ? `image image-${layoutClass}` : "image";
|
const className = layoutClass ? `image image-${layoutClass}` : "image";
|
||||||
const [loaded, setLoaded] = React.useState(false);
|
const [loaded, setLoaded] = React.useState(false);
|
||||||
const [naturalWidth, setNaturalWidth] = React.useState(node.attrs.width);
|
const [naturalWidth, setNaturalWidth] = React.useState(node.attrs.width);
|
||||||
const [naturalHeight, setNaturalHeight] = React.useState(node.attrs.height);
|
const [naturalHeight, setNaturalHeight] = React.useState(node.attrs.height);
|
||||||
const [size, setSize] = React.useState({
|
|
||||||
width: node.attrs.width ?? naturalWidth,
|
|
||||||
height: node.attrs.height ?? naturalHeight,
|
|
||||||
});
|
|
||||||
const [sizeAtDragStart, setSizeAtDragStart] = React.useState(size);
|
|
||||||
const [offset, setOffset] = React.useState(0);
|
|
||||||
const [dragging, setDragging] = React.useState<DragDirection>();
|
|
||||||
const documentBounds = useComponentSize(props.view.dom);
|
const documentBounds = useComponentSize(props.view.dom);
|
||||||
const containerBounds = useComponentSize(
|
const containerBounds = useComponentSize(
|
||||||
document.body.querySelector("#full-width-container")
|
document.body.querySelector("#full-width-container")
|
||||||
@@ -44,74 +36,24 @@ const Image = (props: Props) => {
|
|||||||
const maxWidth = layoutClass
|
const maxWidth = layoutClass
|
||||||
? documentBounds.width / 3
|
? documentBounds.width / 3
|
||||||
: documentBounds.width;
|
: documentBounds.width;
|
||||||
|
const { width, height, setSize, handlePointerDown, dragging } = useDragResize(
|
||||||
|
{
|
||||||
|
width: node.attrs.width ?? naturalWidth,
|
||||||
|
height: node.attrs.height ?? naturalHeight,
|
||||||
|
minWidth: documentBounds.width * 0.1,
|
||||||
|
maxWidth,
|
||||||
|
naturalWidth,
|
||||||
|
naturalHeight,
|
||||||
|
gridWidth: documentBounds.width / 10,
|
||||||
|
onChangeSize,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const isFullWidth = layoutClass === "full-width";
|
const isFullWidth = layoutClass === "full-width";
|
||||||
const isResizable = !!props.onChangeSize;
|
const isResizable = !!props.onChangeSize;
|
||||||
|
|
||||||
const constrainWidth = (width: number) => {
|
|
||||||
const minWidth = documentBounds.width * 0.1;
|
|
||||||
return Math.round(Math.min(maxWidth, Math.max(width, minWidth)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePointerMove = (event: PointerEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
let diff;
|
|
||||||
if (dragging === "left") {
|
|
||||||
diff = offset - event.pageX;
|
|
||||||
} else {
|
|
||||||
diff = event.pageX - offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
const grid = documentBounds.width / 10;
|
|
||||||
const newWidth = sizeAtDragStart.width + diff * 2;
|
|
||||||
const widthOnGrid = Math.round(newWidth / grid) * grid;
|
|
||||||
const constrainedWidth = constrainWidth(widthOnGrid);
|
|
||||||
|
|
||||||
const aspectRatio = naturalHeight / naturalWidth;
|
|
||||||
setSize({
|
|
||||||
width: constrainedWidth,
|
|
||||||
height: naturalWidth
|
|
||||||
? Math.round(constrainedWidth * aspectRatio)
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePointerUp = (event: PointerEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
setOffset(0);
|
|
||||||
setDragging(undefined);
|
|
||||||
props.onChangeSize?.(size);
|
|
||||||
|
|
||||||
document.removeEventListener("mousemove", handlePointerMove);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePointerDown =
|
|
||||||
(dragging: "left" | "right") =>
|
|
||||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
setSizeAtDragStart({
|
|
||||||
width: constrainWidth(size.width),
|
|
||||||
height: size.height,
|
|
||||||
});
|
|
||||||
setOffset(event.pageX);
|
|
||||||
setDragging(dragging);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
setSize(sizeAtDragStart);
|
|
||||||
setDragging(undefined);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (node.attrs.width && node.attrs.width !== size.width) {
|
if (node.attrs.width && node.attrs.width !== width) {
|
||||||
setSize({
|
setSize({
|
||||||
width: node.attrs.width,
|
width: node.attrs.width,
|
||||||
height: node.attrs.height,
|
height: node.attrs.height,
|
||||||
@@ -119,29 +61,9 @@ const Image = (props: Props) => {
|
|||||||
}
|
}
|
||||||
}, [node.attrs.width]);
|
}, [node.attrs.width]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!isResizable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dragging) {
|
|
||||||
document.body.style.cursor = "ew-resize";
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
document.addEventListener("pointermove", handlePointerMove);
|
|
||||||
document.addEventListener("pointerup", handlePointerUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.body.style.cursor = "initial";
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
document.removeEventListener("pointermove", handlePointerMove);
|
|
||||||
document.removeEventListener("pointerup", handlePointerUp);
|
|
||||||
};
|
|
||||||
}, [dragging, handlePointerMove, handlePointerUp, isResizable]);
|
|
||||||
|
|
||||||
const widthStyle = isFullWidth
|
const widthStyle = isFullWidth
|
||||||
? { width: containerBounds.width }
|
? { width: containerBounds.width }
|
||||||
: { width: size.width || "auto" };
|
: { width: width || "auto" };
|
||||||
|
|
||||||
const containerStyle = isFullWidth
|
const containerStyle = isFullWidth
|
||||||
? ({
|
? ({
|
||||||
@@ -161,14 +83,11 @@ const Image = (props: Props) => {
|
|||||||
onClick={dragging ? undefined : props.onClick}
|
onClick={dragging ? undefined : props.onClick}
|
||||||
style={widthStyle}
|
style={widthStyle}
|
||||||
>
|
>
|
||||||
{!dragging &&
|
{!dragging && width > 60 && props.onDownload && (
|
||||||
size.width > 60 &&
|
<Button onClick={props.onDownload}>
|
||||||
size.height > 60 &&
|
<DownloadIcon />
|
||||||
props.onDownload && (
|
</Button>
|
||||||
<Button onClick={props.onDownload}>
|
)}
|
||||||
<DownloadIcon />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<ImageZoom zoomMargin={24}>
|
<ImageZoom zoomMargin={24}>
|
||||||
<img
|
<img
|
||||||
style={{
|
style={{
|
||||||
@@ -194,14 +113,14 @@ const Image = (props: Props) => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{!loaded && size.width && size.height && (
|
{!loaded && width && height && (
|
||||||
<img
|
<img
|
||||||
style={{
|
style={{
|
||||||
...widthStyle,
|
...widthStyle,
|
||||||
display: "block",
|
display: "block",
|
||||||
}}
|
}}
|
||||||
src={`data:image/svg+xml;charset=UTF-8,${encodeURIComponent(
|
src={`data:image/svg+xml;charset=UTF-8,${encodeURIComponent(
|
||||||
getPlaceholder(size.width, size.height)
|
getPlaceholder(width, height)
|
||||||
)}`}
|
)}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -235,75 +154,6 @@ function getPlaceholder(width: number, height: number) {
|
|||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" />`;
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" />`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Caption = styled.p`
|
|
||||||
border: 0;
|
|
||||||
display: block;
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: normal;
|
|
||||||
color: ${s("textSecondary")};
|
|
||||||
padding: 8px 0 4px;
|
|
||||||
line-height: 16px;
|
|
||||||
text-align: center;
|
|
||||||
min-height: 1em;
|
|
||||||
outline: none;
|
|
||||||
background: none;
|
|
||||||
resize: none;
|
|
||||||
user-select: text;
|
|
||||||
margin: 0 !important;
|
|
||||||
cursor: text;
|
|
||||||
|
|
||||||
${breakpoint("tablet")`
|
|
||||||
font-size: 13px;
|
|
||||||
`};
|
|
||||||
|
|
||||||
&:empty:not(:focus) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:empty:before {
|
|
||||||
color: ${s("placeholder")};
|
|
||||||
content: attr(data-caption);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ResizeLeft = styled.div<{ $dragging: boolean }>`
|
|
||||||
cursor: ew-resize;
|
|
||||||
position: absolute;
|
|
||||||
left: -4px;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 8px;
|
|
||||||
user-select: none;
|
|
||||||
opacity: ${(props) => (props.$dragging ? 1 : 0)};
|
|
||||||
transition: opacity 150ms ease-in-out;
|
|
||||||
|
|
||||||
&:after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 8px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 6px;
|
|
||||||
height: 15%;
|
|
||||||
min-height: 20px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: ${s("menuBackground")};
|
|
||||||
box-shadow: 0 0 0 1px ${s("textSecondary")};
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ResizeRight = styled(ResizeLeft)`
|
|
||||||
left: initial;
|
|
||||||
right: -4px;
|
|
||||||
|
|
||||||
&:after {
|
|
||||||
left: initial;
|
|
||||||
right: 8px;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Button = styled.button`
|
const Button = styled.button`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
@@ -357,10 +207,6 @@ const ImageWrapper = styled.div<{ isFullWidth: boolean }>`
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.ProseMirror-selectednode + ${Caption} {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default Image;
|
export default Image;
|
||||||
|
|||||||
39
shared/editor/components/ResizeHandle.tsx
Normal file
39
shared/editor/components/ResizeHandle.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import styled from "styled-components";
|
||||||
|
import { s } from "../../styles";
|
||||||
|
|
||||||
|
export const ResizeLeft = styled.div<{ $dragging: boolean }>`
|
||||||
|
cursor: ew-resize;
|
||||||
|
position: absolute;
|
||||||
|
left: -4px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 8px;
|
||||||
|
user-select: none;
|
||||||
|
opacity: ${(props) => (props.$dragging ? 1 : 0)};
|
||||||
|
transition: opacity 150ms ease-in-out;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 6px;
|
||||||
|
height: 15%;
|
||||||
|
min-height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: ${s("menuBackground")};
|
||||||
|
box-shadow: 0 0 0 1px ${s("textSecondary")};
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ResizeRight = styled(ResizeLeft)`
|
||||||
|
left: initial;
|
||||||
|
right: -4px;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
left: initial;
|
||||||
|
right: 8px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -390,7 +390,8 @@ li {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image {
|
.image,
|
||||||
|
.video {
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -398,7 +399,8 @@ li {
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
||||||
img {
|
img,
|
||||||
|
video {
|
||||||
pointer-events: ${props.readOnly ? "initial" : "none"};
|
pointer-events: ${props.readOnly ? "initial" : "none"};
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -409,14 +411,20 @@ li {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.image.placeholder {
|
.image.placeholder,
|
||||||
|
.video.placeholder {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: ${props.theme.background};
|
background: ${props.theme.background};
|
||||||
margin-bottom: calc(28px + 1.2em);
|
margin-bottom: calc(28px + 1.2em);
|
||||||
|
|
||||||
img {
|
img,
|
||||||
|
video {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-replacement-uploading {
|
.image-replacement-uploading {
|
||||||
|
|||||||
115
shared/editor/components/Video.tsx
Normal file
115
shared/editor/components/Video.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { ComponentProps } from "../types";
|
||||||
|
import { ResizeLeft, ResizeRight } from "./ResizeHandle";
|
||||||
|
import useComponentSize from "./hooks/useComponentSize";
|
||||||
|
import useDragResize from "./hooks/useDragResize";
|
||||||
|
|
||||||
|
type Props = ComponentProps & {
|
||||||
|
/** Callback triggered when the video is resized */
|
||||||
|
onChangeSize?: (props: { width: number; height?: number }) => void;
|
||||||
|
children?: React.ReactElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Video(props: Props) {
|
||||||
|
const { isSelected, node, isEditable, children, onChangeSize } = props;
|
||||||
|
const [naturalWidth] = React.useState(node.attrs.width);
|
||||||
|
const [naturalHeight] = React.useState(node.attrs.height);
|
||||||
|
const documentBounds = useComponentSize(props.view.dom);
|
||||||
|
const isResizable = !!onChangeSize;
|
||||||
|
|
||||||
|
const { width, height, setSize, handlePointerDown, dragging } = useDragResize(
|
||||||
|
{
|
||||||
|
width: node.attrs.width ?? naturalWidth,
|
||||||
|
height: node.attrs.height ?? naturalHeight,
|
||||||
|
minWidth: documentBounds.width * 0.1,
|
||||||
|
maxWidth: documentBounds.width,
|
||||||
|
naturalWidth,
|
||||||
|
naturalHeight,
|
||||||
|
gridWidth: documentBounds.width / 10,
|
||||||
|
onChangeSize,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (node.attrs.width && node.attrs.width !== width) {
|
||||||
|
setSize({
|
||||||
|
width: node.attrs.width,
|
||||||
|
height: node.attrs.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [node.attrs.width]);
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
width: width || "auto",
|
||||||
|
maxHeight: height || "auto",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div contentEditable={false}>
|
||||||
|
<VideoWrapper
|
||||||
|
className={isSelected ? "ProseMirror-selectednode" : ""}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<StyledVideo
|
||||||
|
src={node.attrs.src}
|
||||||
|
title={node.attrs.title}
|
||||||
|
style={style}
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
{isEditable && isResizable && (
|
||||||
|
<>
|
||||||
|
<ResizeLeft
|
||||||
|
onPointerDown={handlePointerDown("left")}
|
||||||
|
$dragging={!!dragging}
|
||||||
|
/>
|
||||||
|
<ResizeRight
|
||||||
|
onPointerDown={handlePointerDown("right")}
|
||||||
|
$dragging={!!dragging}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</VideoWrapper>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledVideo = styled.video`
|
||||||
|
max-width: 100%;
|
||||||
|
background: ${(props) => props.theme.background};
|
||||||
|
color: ${(props) => props.theme.text} !important;
|
||||||
|
margin: -2px;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 0 0 1px ${(props) => props.theme.divider};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const VideoWrapper = styled.div`
|
||||||
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: default;
|
||||||
|
border-radius: 8px;
|
||||||
|
user-select: none;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
transition-property: width, max-height;
|
||||||
|
transition-duration: 150ms;
|
||||||
|
transition-timing-function: ease-in-out;
|
||||||
|
|
||||||
|
video {
|
||||||
|
transition-property: width, max-height;
|
||||||
|
transition-duration: 150ms;
|
||||||
|
transition-timing-function: ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
${ResizeLeft}, ${ResizeRight} {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
126
shared/editor/components/hooks/useDragResize.ts
Normal file
126
shared/editor/components/hooks/useDragResize.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
type DragDirection = "left" | "right";
|
||||||
|
|
||||||
|
type SizeState = { width: number; height?: number };
|
||||||
|
|
||||||
|
type ReturnValue = {
|
||||||
|
handlePointerDown: (
|
||||||
|
dragging: DragDirection
|
||||||
|
) => (event: React.PointerEvent<HTMLDivElement>) => void;
|
||||||
|
setSize: React.Dispatch<React.SetStateAction<SizeState>>;
|
||||||
|
dragging: boolean;
|
||||||
|
width: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onChangeSize?: undefined | ((size: SizeState) => void);
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
naturalWidth: number;
|
||||||
|
naturalHeight: number;
|
||||||
|
minWidth: number;
|
||||||
|
maxWidth: number;
|
||||||
|
gridWidth: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function useDragResize(props: Props): ReturnValue {
|
||||||
|
const [size, setSize] = React.useState<SizeState>({
|
||||||
|
width: props.width,
|
||||||
|
height: props.height,
|
||||||
|
});
|
||||||
|
const [offset, setOffset] = React.useState(0);
|
||||||
|
const [sizeAtDragStart, setSizeAtDragStart] = React.useState(size);
|
||||||
|
const [dragging, setDragging] = React.useState<DragDirection>();
|
||||||
|
const isResizable = !!props.onChangeSize;
|
||||||
|
|
||||||
|
const constrainWidth = (width: number) =>
|
||||||
|
Math.round(Math.min(props.maxWidth, Math.max(width, props.minWidth)));
|
||||||
|
|
||||||
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
let diff;
|
||||||
|
if (dragging === "left") {
|
||||||
|
diff = offset - event.pageX;
|
||||||
|
} else {
|
||||||
|
diff = event.pageX - offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newWidth = sizeAtDragStart.width + diff * 2;
|
||||||
|
const widthOnGrid =
|
||||||
|
Math.round(newWidth / props.gridWidth) * props.gridWidth;
|
||||||
|
const constrainedWidth = constrainWidth(widthOnGrid);
|
||||||
|
const aspectRatio = props.naturalHeight / props.naturalWidth;
|
||||||
|
|
||||||
|
setSize({
|
||||||
|
width: constrainedWidth,
|
||||||
|
height: props.naturalWidth
|
||||||
|
? Math.round(constrainedWidth * aspectRatio)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (event: PointerEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
setOffset(0);
|
||||||
|
setDragging(undefined);
|
||||||
|
props.onChangeSize?.(size);
|
||||||
|
|
||||||
|
document.removeEventListener("mousemove", handlePointerMove);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
setSize(sizeAtDragStart);
|
||||||
|
setDragging(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerDown =
|
||||||
|
(dragging: "left" | "right") =>
|
||||||
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setSizeAtDragStart({
|
||||||
|
width: constrainWidth(size.width),
|
||||||
|
height: size.height,
|
||||||
|
});
|
||||||
|
setOffset(event.pageX);
|
||||||
|
setDragging(dragging);
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isResizable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dragging) {
|
||||||
|
document.body.style.cursor = "ew-resize";
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
document.addEventListener("pointermove", handlePointerMove);
|
||||||
|
document.addEventListener("pointerup", handlePointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.cursor = "initial";
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
document.removeEventListener("pointermove", handlePointerMove);
|
||||||
|
document.removeEventListener("pointerup", handlePointerUp);
|
||||||
|
};
|
||||||
|
}, [dragging, handlePointerMove, handlePointerUp, isResizable]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handlePointerDown,
|
||||||
|
dragging: !!dragging,
|
||||||
|
setSize,
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Frame from "../components/Frame";
|
import Frame from "../components/Frame";
|
||||||
import useComponentSize from "../components/useComponentSize";
|
import useComponentSize from "../components/hooks/useComponentSize";
|
||||||
import { EmbedProps as Props } from ".";
|
import { EmbedProps as Props } from ".";
|
||||||
|
|
||||||
const URL_REGEX = /^https:\/\/(www\.)?berrycast.com\/conversations\/(.*)$/;
|
const URL_REGEX = /^https:\/\/(www\.)?berrycast.com\/conversations\/(.*)$/;
|
||||||
|
|||||||
42
shared/editor/lib/FileHelper.ts
Normal file
42
shared/editor/lib/FileHelper.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
export default class FileHelper {
|
||||||
|
/**
|
||||||
|
* Checks if a file is an image.
|
||||||
|
*
|
||||||
|
* @param file The file to check
|
||||||
|
* @returns True if the file is an image
|
||||||
|
*/
|
||||||
|
static isImage(file: File) {
|
||||||
|
return file.type.startsWith("image/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a file is a video.
|
||||||
|
*
|
||||||
|
* @param file The file to check
|
||||||
|
* @returns True if the file is an video
|
||||||
|
*/
|
||||||
|
static isVideo(file: File) {
|
||||||
|
return file.type.startsWith("video/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the dimensions of a video file.
|
||||||
|
*
|
||||||
|
* @param file The file to load the dimensions for
|
||||||
|
* @returns The dimensions of the video
|
||||||
|
*/
|
||||||
|
static getVideoDimensions(
|
||||||
|
file: File
|
||||||
|
): Promise<{ width: number; height: number }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.preload = "metadata";
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
window.URL.revokeObjectURL(video.src);
|
||||||
|
resolve({ width: video.videoWidth, height: video.videoHeight });
|
||||||
|
};
|
||||||
|
video.onerror = reject;
|
||||||
|
video.src = URL.createObjectURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
import { EditorState, Plugin } from "prosemirror-state";
|
import { EditorState, Plugin } from "prosemirror-state";
|
||||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||||
import * as React from "react";
|
|
||||||
import ReactDOM from "react-dom";
|
|
||||||
import FileExtension from "../components/FileExtension";
|
|
||||||
import { recreateTransform } from "./prosemirror-recreate-transform";
|
import { recreateTransform } from "./prosemirror-recreate-transform";
|
||||||
|
|
||||||
// based on the example at: https://prosemirror.net/examples/upload/
|
// based on the example at: https://prosemirror.net/examples/upload/
|
||||||
@@ -34,54 +31,49 @@ const uploadPlaceholder = new Plugin({
|
|||||||
const action = tr.getMeta(this);
|
const action = tr.getMeta(this);
|
||||||
|
|
||||||
if (action?.add) {
|
if (action?.add) {
|
||||||
if (action.add.replaceExisting) {
|
if (action.add.isImage) {
|
||||||
const $pos = tr.doc.resolve(action.add.pos);
|
if (action.add.replaceExisting) {
|
||||||
|
const $pos = tr.doc.resolve(action.add.pos);
|
||||||
|
|
||||||
if ($pos.nodeAfter?.type.name === "image") {
|
if ($pos.nodeAfter?.type.name === "image") {
|
||||||
const deco = Decoration.node(
|
const deco = Decoration.node(
|
||||||
$pos.pos,
|
$pos.pos,
|
||||||
$pos.pos + $pos.nodeAfter.nodeSize,
|
$pos.pos + $pos.nodeAfter.nodeSize,
|
||||||
{
|
{
|
||||||
class: "image-replacement-uploading",
|
class: "image-replacement-uploading",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: action.add.id,
|
id: action.add.id,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
set = set.add(tr.doc, [deco]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const element = document.createElement("div");
|
||||||
|
element.className = "image placeholder";
|
||||||
|
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = URL.createObjectURL(action.add.file);
|
||||||
|
|
||||||
|
element.appendChild(img);
|
||||||
|
|
||||||
|
const deco = Decoration.widget(action.add.pos, element, {
|
||||||
|
id: action.add.id,
|
||||||
|
});
|
||||||
set = set.add(tr.doc, [deco]);
|
set = set.add(tr.doc, [deco]);
|
||||||
}
|
}
|
||||||
} else if (action.add.isImage) {
|
}
|
||||||
|
|
||||||
|
if (action.add.isVideo) {
|
||||||
const element = document.createElement("div");
|
const element = document.createElement("div");
|
||||||
element.className = "image placeholder";
|
element.className = "video placeholder";
|
||||||
|
|
||||||
const img = document.createElement("img");
|
const video = document.createElement("video");
|
||||||
img.src = URL.createObjectURL(action.add.file);
|
video.src = URL.createObjectURL(action.add.file);
|
||||||
|
video.autoplay = false;
|
||||||
|
video.controls = false;
|
||||||
|
|
||||||
element.appendChild(img);
|
element.appendChild(video);
|
||||||
|
|
||||||
const deco = Decoration.widget(action.add.pos, element, {
|
|
||||||
id: action.add.id,
|
|
||||||
});
|
|
||||||
set = set.add(tr.doc, [deco]);
|
|
||||||
} else {
|
|
||||||
const element = document.createElement("div");
|
|
||||||
element.className = "attachment placeholder";
|
|
||||||
|
|
||||||
const icon = document.createElement("div");
|
|
||||||
icon.className = "icon";
|
|
||||||
|
|
||||||
const component = <FileExtension title={action.add.file.name} />;
|
|
||||||
ReactDOM.render(component, icon);
|
|
||||||
element.appendChild(icon);
|
|
||||||
|
|
||||||
const text = document.createElement("span");
|
|
||||||
text.innerText = action.add.file.name;
|
|
||||||
element.appendChild(text);
|
|
||||||
|
|
||||||
const status = document.createElement("span");
|
|
||||||
status.innerText = "Uploading…";
|
|
||||||
status.className = "status";
|
|
||||||
element.appendChild(status);
|
|
||||||
|
|
||||||
const deco = Decoration.widget(action.add.pos, element, {
|
const deco = Decoration.widget(action.add.pos, element, {
|
||||||
id: action.add.id,
|
id: action.add.id,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import toggleWrap from "../commands/toggleWrap";
|
|||||||
import FileExtension from "../components/FileExtension";
|
import FileExtension from "../components/FileExtension";
|
||||||
import Widget from "../components/Widget";
|
import Widget from "../components/Widget";
|
||||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||||
import attachmentsRule from "../rules/attachments";
|
import attachmentsRule from "../rules/links";
|
||||||
import { ComponentProps } from "../types";
|
import { ComponentProps } from "../types";
|
||||||
import Node from "./Node";
|
import Node from "./Node";
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import Token from "markdown-it/lib/token";
|
import Token from "markdown-it/lib/token";
|
||||||
import { InputRule } from "prosemirror-inputrules";
|
import { InputRule } from "prosemirror-inputrules";
|
||||||
import { Node as ProsemirrorNode, NodeSpec, NodeType } from "prosemirror-model";
|
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 * as React from "react";
|
||||||
import { sanitizeUrl } from "../../utils/urls";
|
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 { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||||
import { ComponentProps } from "../types";
|
import { ComponentProps } from "../types";
|
||||||
import SimpleImage from "./SimpleImage";
|
import SimpleImage from "./SimpleImage";
|
||||||
@@ -215,13 +221,58 @@ export default class Image extends SimpleImage {
|
|||||||
void downloadImageNode(node);
|
void downloadImageNode(node);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure only plain text can be pasted into input when pasting from another
|
handleCaptionKeyDown =
|
||||||
// rich text source.
|
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
|
||||||
handlePaste = (event: React.ClipboardEvent<HTMLSpanElement>) => {
|
(event: React.KeyboardEvent<HTMLParagraphElement>) => {
|
||||||
event.preventDefault();
|
// Pressing Enter in the caption field should move the cursor/selection
|
||||||
const text = event.clipboardData.getData("text/plain");
|
// below the image and create a new paragraph.
|
||||||
window.document.execCommand("insertText", false, text);
|
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) => (
|
component = (props: ComponentProps) => (
|
||||||
<ImageComponent
|
<ImageComponent
|
||||||
@@ -231,16 +282,10 @@ export default class Image extends SimpleImage {
|
|||||||
onChangeSize={this.handleChangeSize(props)}
|
onChangeSize={this.handleChangeSize(props)}
|
||||||
>
|
>
|
||||||
<Caption
|
<Caption
|
||||||
onPaste={this.handlePaste}
|
onBlur={this.handleCaptionBlur(props)}
|
||||||
onKeyDown={this.handleKeyDown(props)}
|
onKeyDown={this.handleCaptionKeyDown(props)}
|
||||||
onBlur={this.handleBlur(props)}
|
isSelected={props.isSelected}
|
||||||
onMouseDown={this.handleMouseDown}
|
placeholder={this.options.dictionary.imageCaptionPlaceholder}
|
||||||
className="caption"
|
|
||||||
tabIndex={-1}
|
|
||||||
role="textbox"
|
|
||||||
contentEditable
|
|
||||||
suppressContentEditableWarning
|
|
||||||
data-caption={this.options.dictionary.imageCaptionPlaceholder}
|
|
||||||
>
|
>
|
||||||
{props.node.attrs.alt}
|
{props.node.attrs.alt}
|
||||||
</Caption>
|
</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 =
|
handleSelect =
|
||||||
({ getPos }: { getPos: () => number }) =>
|
({ getPos }: { getPos: () => number }) =>
|
||||||
(event: React.MouseEvent) => {
|
(event: React.MouseEvent) => {
|
||||||
@@ -136,16 +87,6 @@ export default class SimpleImage extends Node {
|
|||||||
view.dispatch(transaction);
|
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) => (
|
component = (props: ComponentProps) => (
|
||||||
<ImageComponent {...props} onClick={this.handleSelect(props)} />
|
<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 TableHeadCell from "./TableHeadCell";
|
||||||
import TableRow from "./TableRow";
|
import TableRow from "./TableRow";
|
||||||
import Text from "./Text";
|
import Text from "./Text";
|
||||||
|
import Video from "./Video";
|
||||||
|
|
||||||
type Nodes = (typeof Node | typeof Mark | typeof Extension)[];
|
type Nodes = (typeof Node | typeof Mark | typeof Extension)[];
|
||||||
|
|
||||||
@@ -97,6 +98,7 @@ export const richExtensions: Nodes = [
|
|||||||
Embed,
|
Embed,
|
||||||
ListItem,
|
ListItem,
|
||||||
Attachment,
|
Attachment,
|
||||||
|
Video,
|
||||||
Notice,
|
Notice,
|
||||||
Heading,
|
Heading,
|
||||||
HorizontalRule,
|
HorizontalRule,
|
||||||
|
|||||||
@@ -28,13 +28,14 @@ function isAttachment(token: Token) {
|
|||||||
// internal
|
// internal
|
||||||
// external (public share are pre-signed and this is a reasonable way of detecting them)
|
// external (public share are pre-signed and this is a reasonable way of detecting them)
|
||||||
href?.startsWith("/api/attachments.redirect") ||
|
href?.startsWith("/api/attachments.redirect") ||
|
||||||
|
href?.startsWith("/api/files.get") ||
|
||||||
((href?.startsWith(env.AWS_S3_UPLOAD_BUCKET_URL) ||
|
((href?.startsWith(env.AWS_S3_UPLOAD_BUCKET_URL) ||
|
||||||
href?.startsWith(env.AWS_S3_ACCELERATE_URL)) &&
|
href?.startsWith(env.AWS_S3_ACCELERATE_URL)) &&
|
||||||
href?.includes("X-Amz-Signature"))
|
href?.includes("X-Amz-Signature"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function linksToAttachments(md: MarkdownIt) {
|
export default function linksToNodes(md: MarkdownIt) {
|
||||||
md.core.ruler.after("breaks", "attachments", (state) => {
|
md.core.ruler.after("breaks", "attachments", (state) => {
|
||||||
const tokens = state.tokens;
|
const tokens = state.tokens;
|
||||||
let insideLink;
|
let insideLink;
|
||||||
@@ -64,20 +65,29 @@ export default function linksToAttachments(md: MarkdownIt) {
|
|||||||
// converted to a file attachment
|
// converted to a file attachment
|
||||||
if (insideLink && isAttachment(insideLink)) {
|
if (insideLink && isAttachment(insideLink)) {
|
||||||
const { content } = current;
|
const { content } = current;
|
||||||
|
|
||||||
// convert to attachment token
|
|
||||||
const token = new Token("attachment", "a", 0);
|
|
||||||
token.attrSet("href", insideLink.attrGet("href") || "");
|
|
||||||
|
|
||||||
const parts = content.split(" ");
|
const parts = content.split(" ");
|
||||||
const size = parts.pop();
|
const size = parts.pop();
|
||||||
const title = parts.join(" ");
|
const title = parts.join(" ");
|
||||||
token.attrSet("size", size || "0");
|
|
||||||
token.attrSet("title", title);
|
if (size?.includes("x")) {
|
||||||
|
// convert to video
|
||||||
|
const token = new Token("video", "video", 0);
|
||||||
|
token.attrSet("src", insideLink.attrGet("href") || "");
|
||||||
|
token.attrSet("width", size.split("x")[0] || "0");
|
||||||
|
token.attrSet("height", size.split("x")[1] || "0");
|
||||||
|
token.attrSet("title", title);
|
||||||
|
tokens.splice(i - 1, 3, token);
|
||||||
|
} else {
|
||||||
|
// convert to attachment token
|
||||||
|
const token = new Token("attachment", "a", 0);
|
||||||
|
token.attrSet("href", insideLink.attrGet("href") || "");
|
||||||
|
token.attrSet("size", size || "0");
|
||||||
|
token.attrSet("title", title);
|
||||||
|
tokens.splice(i - 1, 3, token);
|
||||||
|
}
|
||||||
|
|
||||||
// delete the inline link – this makes the assumption that the
|
// delete the inline link – this makes the assumption that the
|
||||||
// attachment is the only thing in the para.
|
// attachment is the only thing in the para.
|
||||||
tokens.splice(i - 1, 3, token);
|
|
||||||
insideLink = null;
|
insideLink = null;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -345,6 +345,7 @@
|
|||||||
"Current date and time": "Current date and time",
|
"Current date and time": "Current date and time",
|
||||||
"Indent": "Indent",
|
"Indent": "Indent",
|
||||||
"Outdent": "Outdent",
|
"Outdent": "Outdent",
|
||||||
|
"Video": "Video",
|
||||||
"Could not import file": "Could not import file",
|
"Could not import file": "Could not import file",
|
||||||
"Unsubscribed from document": "Unsubscribed from document",
|
"Unsubscribed from document": "Unsubscribed from document",
|
||||||
"Account": "Account",
|
"Account": "Account",
|
||||||
|
|||||||
@@ -10163,10 +10163,10 @@ os-tmpdir@~1.0.2:
|
|||||||
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
|
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
|
||||||
integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==
|
integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==
|
||||||
|
|
||||||
outline-icons@^2.4.0:
|
outline-icons@^2.5.0:
|
||||||
version "2.4.0"
|
version "2.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-2.4.0.tgz#820b4585ebcd4db789077574b918436cdb26d30b"
|
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-2.5.0.tgz#ddb6125e3bc560d7bdf9787e9d78df379ed9b541"
|
||||||
integrity sha512-CdtyRrfwXPJWEALqpL01EbeHAR7XXwqUSSQV7+SK8u+2y1nzyYTZ6DD8dB0ledxRHI3+ErTFKcKrQK41hOxh9w==
|
integrity sha512-ktZEzlkQKCpzrlyxynJl+HFS8M/6RShKVUDnoL2cHIbEbiU+J4KnQBliNMmLProzuljgovOjDa8VXrT/QjK07w==
|
||||||
|
|
||||||
oy-vey@^0.12.0:
|
oy-vey@^0.12.0:
|
||||||
version "0.12.0"
|
version "0.12.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user