Files
outline/shared/editor/components/Image.tsx
Tom Moor 23606dad1d Move image zooming back to unvendorized lib (#6980)
* Move image zooming back to unvendorized lib

* refactor

* perf: Avoid mounting zoom dialog until interacted

* Add captions to lightbox

* lightbox
2024-06-03 17:26:25 -07:00

190 lines
5.6 KiB
TypeScript

import { DownloadIcon } from "outline-icons";
import type { EditorView } from "prosemirror-view";
import * as React from "react";
import styled from "styled-components";
import { s } from "../../styles";
import { sanitizeUrl } from "../../utils/urls";
import { ComponentProps } from "../types";
import { ImageZoom } from "./ImageZoom";
import { ResizeLeft, ResizeRight } from "./ResizeHandle";
import useDragResize from "./hooks/useDragResize";
type Props = ComponentProps & {
/** Callback triggered when the image is clicked */
onClick: (event: React.MouseEvent<HTMLDivElement>) => void;
/** Callback triggered when the download button is clicked */
onDownload?: (event: React.MouseEvent<HTMLButtonElement>) => void;
/** Callback triggered when the image is resized */
onChangeSize?: (props: { width: number; height?: number }) => void;
/** The editor view */
view: EditorView;
children?: React.ReactElement;
};
const Image = (props: 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 ref = React.useRef<HTMLDivElement>(null);
const { width, height, setSize, handlePointerDown, dragging } = useDragResize(
{
width: node.attrs.width ?? naturalWidth,
height: node.attrs.height ?? naturalHeight,
naturalWidth,
naturalHeight,
gridSnap: 5,
onChangeSize,
ref,
}
);
const isFullWidth = layoutClass === "full-width";
const isResizable = !!props.onChangeSize;
React.useEffect(() => {
if (node.attrs.width && node.attrs.width !== width) {
setSize({
width: node.attrs.width,
height: node.attrs.height,
});
}
}, [node.attrs.width]);
const widthStyle = isFullWidth
? { width: "var(--container-width)" }
: { width: width || "auto" };
return (
<div contentEditable={false} className={className} ref={ref}>
<ImageWrapper
isFullWidth={isFullWidth}
className={isSelected || dragging ? "ProseMirror-selectednode" : ""}
onClick={dragging ? undefined : props.onClick}
style={widthStyle}
>
{!dragging && width > 60 && props.onDownload && (
<Button onClick={props.onDownload}>
<DownloadIcon />
</Button>
)}
<ImageZoom caption={props.node.attrs.alt}>
<img
style={{
...widthStyle,
display: loaded ? "block" : "none",
}}
src={sanitizeUrl(src) ?? ""}
onLoad={(ev: React.SyntheticEvent<HTMLImageElement>) => {
// For some SVG's Firefox does not provide the naturalWidth, in this
// rare case we need to provide a default so that the image can be
// seen and is not sized to 0px
const nw = (ev.target as HTMLImageElement).naturalWidth || 300;
const nh = (ev.target as HTMLImageElement).naturalHeight;
setNaturalWidth(nw);
setNaturalHeight(nh);
setLoaded(true);
if (!node.attrs.width) {
setSize((state) => ({
...state,
width: nw,
}));
}
}}
/>
{!loaded && width && height && (
<img
style={{
...widthStyle,
display: "block",
}}
src={`data:image/svg+xml;charset=UTF-8,${encodeURIComponent(
getPlaceholder(width, height)
)}`}
/>
)}
</ImageZoom>
{isEditable && !isFullWidth && isResizable && (
<>
<ResizeLeft
onPointerDown={handlePointerDown("left")}
$dragging={!!dragging}
/>
<ResizeRight
onPointerDown={handlePointerDown("right")}
$dragging={!!dragging}
/>
</>
)}
</ImageWrapper>
{isFullWidth && props.children
? React.cloneElement(props.children, { style: widthStyle })
: props.children}
</div>
);
};
function getPlaceholder(width: number, height: number) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" />`;
}
const Button = styled.button`
position: absolute;
top: 8px;
right: 8px;
border: 0;
margin: 0;
padding: 0;
border-radius: 4px;
background: ${s("background")};
color: ${s("textSecondary")};
width: 24px;
height: 24px;
display: inline-block;
cursor: var(--pointer);
opacity: 0;
transition: opacity 150ms ease-in-out;
&:active {
transform: scale(0.98);
}
&:hover {
color: ${s("text")};
opacity: 1;
}
`;
const ImageWrapper = styled.div<{ isFullWidth: boolean }>`
line-height: 0;
position: relative;
margin-left: auto;
margin-right: auto;
max-width: ${(props) => (props.isFullWidth ? "initial" : "100%")};
transition-property: width, height;
transition-duration: ${(props) => (props.isFullWidth ? "0ms" : "150ms")};
transition-timing-function: ease-in-out;
overflow: hidden;
img {
transition-property: width, height;
transition-duration: ${(props) => (props.isFullWidth ? "0ms" : "150ms")};
transition-timing-function: ease-in-out;
}
&:hover {
${Button} {
opacity: 0.9;
}
${ResizeLeft}, ${ResizeRight} {
opacity: 1;
}
}
`;
export default Image;