From f4fd9dae5f5ec3eface1452950e58b9c8efb14fe Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 28 Sep 2023 20:28:09 -0400 Subject: [PATCH] feat: Native video display (#5866) --- app/editor/components/SuggestionsMenu.tsx | 2 + app/editor/menus/block.tsx | 7 + app/hooks/useDictionary.ts | 2 + package.json | 2 +- server/services/web.ts | 1 + shared/editor/commands/insertFiles.ts | 89 ++++++-- shared/editor/components/Caption.tsx | 85 ++++++++ shared/editor/components/Image.tsx | 206 +++--------------- shared/editor/components/ResizeHandle.tsx | 39 ++++ shared/editor/components/Styles.ts | 16 +- shared/editor/components/Video.tsx | 115 ++++++++++ .../{ => hooks}/useComponentSize.ts | 0 .../editor/components/hooks/useDragResize.ts | 126 +++++++++++ shared/editor/embeds/Berrycast.tsx | 2 +- shared/editor/lib/FileHelper.ts | 42 ++++ shared/editor/lib/uploadPlaceholder.tsx | 82 ++++--- shared/editor/nodes/Attachment.tsx | 2 +- shared/editor/nodes/Image.tsx | 83 +++++-- shared/editor/nodes/SimpleImage.tsx | 59 ----- shared/editor/nodes/Video.tsx | 185 ++++++++++++++++ shared/editor/nodes/index.ts | 2 + .../editor/rules/{attachments.ts => links.ts} | 28 ++- shared/i18n/locales/en_US/translation.json | 1 + yarn.lock | 8 +- 24 files changed, 840 insertions(+), 344 deletions(-) create mode 100644 shared/editor/components/Caption.tsx create mode 100644 shared/editor/components/ResizeHandle.tsx create mode 100644 shared/editor/components/Video.tsx rename shared/editor/components/{ => hooks}/useComponentSize.ts (100%) create mode 100644 shared/editor/components/hooks/useDragResize.ts create mode 100644 shared/editor/lib/FileHelper.ts create mode 100644 shared/editor/nodes/Video.tsx rename shared/editor/rules/{attachments.ts => links.ts} (71%) diff --git a/app/editor/components/SuggestionsMenu.tsx b/app/editor/components/SuggestionsMenu.tsx index 03f39d04a..4c97f29cb 100644 --- a/app/editor/components/SuggestionsMenu.tsx +++ b/app/editor/components/SuggestionsMenu.tsx @@ -244,6 +244,8 @@ function SuggestionsMenu(props: Props) { return triggerFilePick( AttachmentValidation.imageContentTypes.join(", ") ); + case "video": + return triggerFilePick("video/*"); case "attachment": return triggerFilePick("*"); case "embed": diff --git a/app/editor/menus/block.tsx b/app/editor/menus/block.tsx index d9404e6ba..f795fc56d 100644 --- a/app/editor/menus/block.tsx +++ b/app/editor/menus/block.tsx @@ -20,6 +20,7 @@ import { CalendarIcon, MathIcon, DoneIcon, + EmbedIcon, } from "outline-icons"; import * as React from "react"; import styled from "styled-components"; @@ -101,6 +102,12 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] { shortcut: `${metaDisplay} k`, keywords: "link url uri href", }, + { + name: "video", + title: dictionary.video, + icon: , + keywords: "mov avi upload player", + }, { name: "attachment", title: dictionary.file, diff --git a/app/hooks/useDictionary.ts b/app/hooks/useDictionary.ts index 5d359bc0b..6472c88cc 100644 --- a/app/hooks/useDictionary.ts +++ b/app/hooks/useDictionary.ts @@ -83,6 +83,8 @@ export default function useDictionary() { insertDateTime: t("Current date and time"), indent: t("Indent"), outdent: t("Outdent"), + video: t("Video"), + untitled: t("Untitled"), }), [t] ); diff --git a/package.json b/package.json index f16f0c7f9..92b6ec86b 100644 --- a/package.json +++ b/package.json @@ -148,7 +148,7 @@ "natural-sort": "^1.0.0", "node-fetch": "2.7.0", "nodemailer": "^6.9.4", - "outline-icons": "^2.4.0", + "outline-icons": "^2.5.0", "oy-vey": "^0.12.0", "passport": "^0.6.0", "passport-google-oauth2": "^0.2.0", diff --git a/server/services/web.ts b/server/services/web.ts index 44f97b194..1fc015d7e 100644 --- a/server/services/web.ts +++ b/server/services/web.ts @@ -110,6 +110,7 @@ export default function init(app: Koa = new Koa(), server?: Server) { defaultSrc, styleSrc, scriptSrc: [...scriptSrc, `'nonce-${ctx.state.cspNonce}'`], + mediaSrc: ["*", "data:", "blob:"], imgSrc: ["*", "data:", "blob:"], frameSrc: ["*", "data:"], // Do not use connect-src: because self + websockets does not work in diff --git a/shared/editor/commands/insertFiles.ts b/shared/editor/commands/insertFiles.ts index e1b2715c9..4fa0bfc45 100644 --- a/shared/editor/commands/insertFiles.ts +++ b/shared/editor/commands/insertFiles.ts @@ -1,23 +1,29 @@ import * as Sentry from "@sentry/react"; -import invariant from "invariant"; import { NodeSelection } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { v4 as uuidv4 } from "uuid"; +import FileHelper from "../lib/FileHelper"; import uploadPlaceholderPlugin, { findPlaceholder, } from "../lib/uploadPlaceholder"; import findAttachmentById from "../queries/findAttachmentById"; export type Options = { + /** Dictionary object containing translation strings */ 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; /** Set to true to replace any existing image at the users selection */ replaceExisting?: boolean; + /** Callback fired to upload a file */ uploadFile?: (file: File) => Promise; + /** Callback fired when the user starts a file upload */ onFileUploadStart?: () => void; + /** Callback fired when the user completes a file upload */ onFileUploadStop?: () => void; + /** Callback fired when a toast needs to be displayed */ onShowToast: (message: string) => void; + /** Attributes to overwrite */ attrs?: { /** Width to use when inserting image */ width?: number; @@ -44,19 +50,12 @@ const insertFiles = function ( onShowToast, } = options; - invariant( - uploadFile, - "uploadFile callback must be defined to handle uploads." - ); - // okay, we have some dropped files and a handler – lets stop this // event going any further up the stack event.preventDefault(); // let the user know we're starting to process the files - if (onFileUploadStart) { - onFileUploadStart(); - } + onFileUploadStart?.(); const { schema } = view.state; @@ -66,7 +65,10 @@ const insertFiles = function ( const filesToUpload = files.map((file) => ({ 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, })); @@ -75,11 +77,6 @@ const insertFiles = function ( const { tr } = view.state; 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 // replaced tr.setMeta(uploadPlaceholderPlugin, { @@ -92,6 +89,18 @@ const insertFiles = function ( }, }); 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) { // Skip if the editor does not support attachments. if (!view.state.schema.nodes.attachment) { @@ -108,7 +117,7 @@ const insertFiles = function ( attachmentsToUpload.map((attachment) => schema.nodes.attachment.create({ id: attachment.id, - title: attachment.file.name ?? "Untitled", + title: attachment.file.name ?? dictionary.untitled, size: attachment.file.size, }) ) @@ -120,8 +129,8 @@ const insertFiles = function ( // start uploading the file to the server. Using "then" syntax // to allow all placeholders to be entered at once with the uploads // happening in the background in parallel. - uploadFile(upload.file) - .then((src) => { + uploadFile?.(upload.file) + .then(async (src) => { if (upload.isImage) { const newImg = new Image(); newImg.onload = () => { @@ -161,6 +170,44 @@ const insertFiles = function ( }; 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 { const result = findAttachmentById(view.state, upload.id); @@ -176,7 +223,7 @@ const insertFiles = function ( to || from, schema.nodes.attachment.create({ href: src, - title: upload.file.name ?? "Untitled", + title: upload.file.name ?? dictionary.untitled, size: upload.file.size, }) ) @@ -198,7 +245,7 @@ const insertFiles = function ( Sentry.captureException(error); // cleanup the placeholder if there is a failure - if (upload.isImage) { + if (upload.isImage || upload.isVideo) { view.dispatch( view.state.tr.setMeta(uploadPlaceholderPlugin, { remove: { id: upload.id }, diff --git a/shared/editor/components/Caption.tsx b/shared/editor/components/Caption.tsx new file mode 100644 index 000000000..361a89dba --- /dev/null +++ b/shared/editor/components/Caption.tsx @@ -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) => void; + /** Callback triggered when keyboard is used within the caption */ + onKeyDown: (event: React.KeyboardEvent) => 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) => { + event.preventDefault(); + const text = event.clipboardData.getData("text/plain"); + window.document.execCommand("insertText", false, text); + }; + + const handleMouseDown = (ev: React.MouseEvent) => { + // always prevent clicks in caption from bubbling to the editor + ev.stopPropagation(); + }; + + return ( + + {children} + + ); +} + +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; diff --git a/shared/editor/components/Image.tsx b/shared/editor/components/Image.tsx index 15c945edc..8f7a83a6e 100644 --- a/shared/editor/components/Image.tsx +++ b/shared/editor/components/Image.tsx @@ -2,14 +2,13 @@ import { DownloadIcon } from "outline-icons"; import type { EditorView } from "prosemirror-view"; import * as React from "react"; import styled from "styled-components"; -import breakpoint from "styled-components-breakpoint"; import { s } from "../../styles"; import { sanitizeUrl } from "../../utils/urls"; import { ComponentProps } from "../types"; import ImageZoom from "./ImageZoom"; -import useComponentSize from "./useComponentSize"; - -type DragDirection = "left" | "right"; +import { ResizeLeft, ResizeRight } from "./ResizeHandle"; +import useComponentSize from "./hooks/useComponentSize"; +import useDragResize from "./hooks/useDragResize"; type Props = ComponentProps & { /** Callback triggered when the image is clicked */ @@ -24,19 +23,12 @@ type Props = ComponentProps & { }; const Image = (props: Props) => { - const { isSelected, node, isEditable } = props; + const { isSelected, node, isEditable, onChangeSize } = props; const { src, layoutClass } = node.attrs; const className = layoutClass ? `image image-${layoutClass}` : "image"; const [loaded, setLoaded] = React.useState(false); const [naturalWidth, setNaturalWidth] = React.useState(node.attrs.width); 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(); const documentBounds = useComponentSize(props.view.dom); const containerBounds = useComponentSize( document.body.querySelector("#full-width-container") @@ -44,74 +36,24 @@ const Image = (props: Props) => { const maxWidth = layoutClass ? documentBounds.width / 3 : 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 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) => { - 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(() => { - if (node.attrs.width && node.attrs.width !== size.width) { + if (node.attrs.width && node.attrs.width !== width) { setSize({ width: node.attrs.width, height: node.attrs.height, @@ -119,29 +61,9 @@ const Image = (props: Props) => { } }, [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 ? { width: containerBounds.width } - : { width: size.width || "auto" }; + : { width: width || "auto" }; const containerStyle = isFullWidth ? ({ @@ -161,14 +83,11 @@ const Image = (props: Props) => { onClick={dragging ? undefined : props.onClick} style={widthStyle} > - {!dragging && - size.width > 60 && - size.height > 60 && - props.onDownload && ( - - )} + {!dragging && width > 60 && props.onDownload && ( + + )} { } }} /> - {!loaded && size.width && size.height && ( + {!loaded && width && height && ( )} @@ -235,75 +154,6 @@ function getPlaceholder(width: number, height: number) { return ``; } -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` position: absolute; top: 8px; @@ -357,10 +207,6 @@ const ImageWrapper = styled.div<{ isFullWidth: boolean }>` opacity: 1; } } - - &.ProseMirror-selectednode + ${Caption} { - display: block; - } `; export default Image; diff --git a/shared/editor/components/ResizeHandle.tsx b/shared/editor/components/ResizeHandle.tsx new file mode 100644 index 000000000..ab30f1456 --- /dev/null +++ b/shared/editor/components/ResizeHandle.tsx @@ -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; + } +`; diff --git a/shared/editor/components/Styles.ts b/shared/editor/components/Styles.ts index 78f233a9c..79890b4ea 100644 --- a/shared/editor/components/Styles.ts +++ b/shared/editor/components/Styles.ts @@ -390,7 +390,8 @@ li { position: relative; } -.image { +.image, +.video { line-height: 0; text-align: center; max-width: 100%; @@ -398,7 +399,8 @@ li { position: relative; z-index: 1; - img { + img, + video { pointer-events: ${props.readOnly ? "initial" : "none"}; display: inline-block; max-width: 100%; @@ -409,14 +411,20 @@ li { } } -.image.placeholder { +.image.placeholder, +.video.placeholder { position: relative; background: ${props.theme.background}; margin-bottom: calc(28px + 1.2em); - img { + img, + video { opacity: 0.5; } + + video { + border-radius: 8px; + } } .image-replacement-uploading { diff --git a/shared/editor/components/Video.tsx b/shared/editor/components/Video.tsx new file mode 100644 index 000000000..a0e4ad65f --- /dev/null +++ b/shared/editor/components/Video.tsx @@ -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 ( +
+ + + {isEditable && isResizable && ( + <> + + + + )} + + {children} +
+ ); +} + +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; + } + } +`; diff --git a/shared/editor/components/useComponentSize.ts b/shared/editor/components/hooks/useComponentSize.ts similarity index 100% rename from shared/editor/components/useComponentSize.ts rename to shared/editor/components/hooks/useComponentSize.ts diff --git a/shared/editor/components/hooks/useDragResize.ts b/shared/editor/components/hooks/useDragResize.ts new file mode 100644 index 000000000..0d41374d5 --- /dev/null +++ b/shared/editor/components/hooks/useDragResize.ts @@ -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) => void; + setSize: React.Dispatch>; + 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({ + width: props.width, + height: props.height, + }); + const [offset, setOffset] = React.useState(0); + const [sizeAtDragStart, setSizeAtDragStart] = React.useState(size); + const [dragging, setDragging] = React.useState(); + 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) => { + 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, + }; +} diff --git a/shared/editor/embeds/Berrycast.tsx b/shared/editor/embeds/Berrycast.tsx index f42d66096..befad4cdc 100644 --- a/shared/editor/embeds/Berrycast.tsx +++ b/shared/editor/embeds/Berrycast.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import Frame from "../components/Frame"; -import useComponentSize from "../components/useComponentSize"; +import useComponentSize from "../components/hooks/useComponentSize"; import { EmbedProps as Props } from "."; const URL_REGEX = /^https:\/\/(www\.)?berrycast.com\/conversations\/(.*)$/; diff --git a/shared/editor/lib/FileHelper.ts b/shared/editor/lib/FileHelper.ts new file mode 100644 index 000000000..b7665295c --- /dev/null +++ b/shared/editor/lib/FileHelper.ts @@ -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); + }); + } +} diff --git a/shared/editor/lib/uploadPlaceholder.tsx b/shared/editor/lib/uploadPlaceholder.tsx index 67361deeb..5c55fa60f 100644 --- a/shared/editor/lib/uploadPlaceholder.tsx +++ b/shared/editor/lib/uploadPlaceholder.tsx @@ -1,8 +1,5 @@ import { EditorState, Plugin } from "prosemirror-state"; 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"; // based on the example at: https://prosemirror.net/examples/upload/ @@ -34,54 +31,49 @@ const uploadPlaceholder = new Plugin({ const action = tr.getMeta(this); if (action?.add) { - if (action.add.replaceExisting) { - const $pos = tr.doc.resolve(action.add.pos); + if (action.add.isImage) { + if (action.add.replaceExisting) { + const $pos = tr.doc.resolve(action.add.pos); - if ($pos.nodeAfter?.type.name === "image") { - const deco = Decoration.node( - $pos.pos, - $pos.pos + $pos.nodeAfter.nodeSize, - { - class: "image-replacement-uploading", - }, - { - id: action.add.id, - } - ); + if ($pos.nodeAfter?.type.name === "image") { + const deco = Decoration.node( + $pos.pos, + $pos.pos + $pos.nodeAfter.nodeSize, + { + class: "image-replacement-uploading", + }, + { + 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]); } - } else if (action.add.isImage) { + } + + if (action.add.isVideo) { const element = document.createElement("div"); - element.className = "image placeholder"; + element.className = "video placeholder"; - const img = document.createElement("img"); - img.src = URL.createObjectURL(action.add.file); + const video = document.createElement("video"); + video.src = URL.createObjectURL(action.add.file); + video.autoplay = false; + video.controls = false; - element.appendChild(img); - - 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 = ; - 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); + element.appendChild(video); const deco = Decoration.widget(action.add.pos, element, { id: action.add.id, diff --git a/shared/editor/nodes/Attachment.tsx b/shared/editor/nodes/Attachment.tsx index c137bcde7..b7b3eb606 100644 --- a/shared/editor/nodes/Attachment.tsx +++ b/shared/editor/nodes/Attachment.tsx @@ -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"; diff --git a/shared/editor/nodes/Image.tsx b/shared/editor/nodes/Image.tsx index 5b825b3b2..952a5a81d 100644 --- a/shared/editor/nodes/Image.tsx +++ b/shared/editor/nodes/Image.tsx @@ -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) => { - 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) => { + // 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) => { + 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) => ( {props.node.attrs.alt} diff --git a/shared/editor/nodes/SimpleImage.tsx b/shared/editor/nodes/SimpleImage.tsx index b83be2a20..dbf102842 100644 --- a/shared/editor/nodes/SimpleImage.tsx +++ b/shared/editor/nodes/SimpleImage.tsx @@ -76,55 +76,6 @@ export default class SimpleImage extends Node { }; } - handleKeyDown = - ({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) => - (event: React.KeyboardEvent) => { - // 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) => { - 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) => { - // 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) => ( ); diff --git a/shared/editor/nodes/Video.tsx b/shared/editor/nodes/Video.tsx new file mode 100644 index 000000000..80a30e82e --- /dev/null +++ b/shared/editor/nodes/Video.tsx @@ -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) => { + // 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) => { + 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) => ( + + + {props.node.attrs.title} + + + ); + + commands({ type }: { type: NodeType }) { + return (attrs: Record) => 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), + }), + }; + } +} diff --git a/shared/editor/nodes/index.ts b/shared/editor/nodes/index.ts index 48c2a90cb..0634028cb 100644 --- a/shared/editor/nodes/index.ts +++ b/shared/editor/nodes/index.ts @@ -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, diff --git a/shared/editor/rules/attachments.ts b/shared/editor/rules/links.ts similarity index 71% rename from shared/editor/rules/attachments.ts rename to shared/editor/rules/links.ts index 5ceb92228..296a1dca6 100644 --- a/shared/editor/rules/attachments.ts +++ b/shared/editor/rules/links.ts @@ -28,13 +28,14 @@ function isAttachment(token: Token) { // internal // external (public share are pre-signed and this is a reasonable way of detecting them) href?.startsWith("/api/attachments.redirect") || + href?.startsWith("/api/files.get") || ((href?.startsWith(env.AWS_S3_UPLOAD_BUCKET_URL) || href?.startsWith(env.AWS_S3_ACCELERATE_URL)) && href?.includes("X-Amz-Signature")) ); } -export default function linksToAttachments(md: MarkdownIt) { +export default function linksToNodes(md: MarkdownIt) { md.core.ruler.after("breaks", "attachments", (state) => { const tokens = state.tokens; let insideLink; @@ -64,20 +65,29 @@ export default function linksToAttachments(md: MarkdownIt) { // converted to a file attachment if (insideLink && isAttachment(insideLink)) { 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 size = parts.pop(); 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 // attachment is the only thing in the para. - tokens.splice(i - 1, 3, token); insideLink = null; break; } diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 4a1ce9700..5c64b637f 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -345,6 +345,7 @@ "Current date and time": "Current date and time", "Indent": "Indent", "Outdent": "Outdent", + "Video": "Video", "Could not import file": "Could not import file", "Unsubscribed from document": "Unsubscribed from document", "Account": "Account", diff --git a/yarn.lock b/yarn.lock index d98af3503..d9e8ec607 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10163,10 +10163,10 @@ os-tmpdir@~1.0.2: resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== -outline-icons@^2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-2.4.0.tgz#820b4585ebcd4db789077574b918436cdb26d30b" - integrity sha512-CdtyRrfwXPJWEALqpL01EbeHAR7XXwqUSSQV7+SK8u+2y1nzyYTZ6DD8dB0ledxRHI3+ErTFKcKrQK41hOxh9w== +outline-icons@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-2.5.0.tgz#ddb6125e3bc560d7bdf9787e9d78df379ed9b541" + integrity sha512-ktZEzlkQKCpzrlyxynJl+HFS8M/6RShKVUDnoL2cHIbEbiU+J4KnQBliNMmLProzuljgovOjDa8VXrT/QjK07w== oy-vey@^0.12.0: version "0.12.0"