feat: Native video display (#5866)

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

View File

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

View File

@@ -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<DragDirection>();
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<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(() => {
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 && (
<Button onClick={props.onDownload}>
<DownloadIcon />
</Button>
)}
{!dragging && width > 60 && props.onDownload && (
<Button onClick={props.onDownload}>
<DownloadIcon />
</Button>
)}
<ImageZoom zoomMargin={24}>
<img
style={{
@@ -194,14 +113,14 @@ const Image = (props: Props) => {
}
}}
/>
{!loaded && size.width && size.height && (
{!loaded && width && height && (
<img
style={{
...widthStyle,
display: "block",
}}
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}" />`;
}
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;

View 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;
}
`;

View File

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

View 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;
}
}
`;

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