mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
* chore: Exhaustive deps turned to error * Refactor useDragResize * Refactor Lightbox to explicit state machine * feedback * ci
431 lines
13 KiB
TypeScript
431 lines
13 KiB
TypeScript
import { CrossIcon, DownloadIcon, GlobeIcon, ZoomInIcon } from "outline-icons";
|
|
import type { EditorView } from "prosemirror-view";
|
|
import * as React from "react";
|
|
import styled from "styled-components";
|
|
import { useTranslation } from "react-i18next";
|
|
import { find } from "es-toolkit/compat";
|
|
import Flex from "../../components/Flex";
|
|
import { s } from "../../styles";
|
|
import { isExternalUrl, sanitizeImageSrc } from "../../utils/urls";
|
|
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
|
import type { ComponentProps } from "../types";
|
|
import {
|
|
ResizeLeft,
|
|
ResizeRight,
|
|
ResizeTopLeft,
|
|
ResizeTopRight,
|
|
ResizeBottomLeft,
|
|
ResizeBottomRight,
|
|
} from "./ResizeHandle";
|
|
import useDragResize from "./hooks/useDragResize";
|
|
|
|
type Props = ComponentProps & {
|
|
/** Callback triggered when the image is clicked */
|
|
onClick: () => void;
|
|
/** Callback triggered when the download button is clicked */
|
|
onDownload?: (event: React.MouseEvent<HTMLButtonElement>) => Promise<void>;
|
|
/** Callback triggered when the zoom in button is clicked */
|
|
onZoomIn?: (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;
|
|
};
|
|
|
|
/** Images rendered smaller than this width are displayed as inline icons. */
|
|
export const InlineIconMaxWidth = 48;
|
|
|
|
type ImageClassNameOptions = {
|
|
/** Layout modifier, e.g. "full-width", "left-50". */
|
|
layoutClass?: string | null;
|
|
/** Rendered width of the image in pixels. */
|
|
width?: number | null;
|
|
/** Whether the image failed to load. */
|
|
error?: boolean;
|
|
};
|
|
|
|
/**
|
|
* Whether an image should render as an inline icon rather than a block. Small
|
|
* images are displayed inline with the surrounding text.
|
|
*
|
|
* @param options The image's layout, width, and error state.
|
|
* @returns True if the image should be rendered as an inline icon.
|
|
*/
|
|
export function isInlineImageIcon({
|
|
layoutClass,
|
|
width,
|
|
error,
|
|
}: ImageClassNameOptions): boolean {
|
|
return (
|
|
layoutClass !== "full-width" &&
|
|
!!width &&
|
|
width < InlineIconMaxWidth &&
|
|
!error
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Builds the className for an image node's container, including the layout and
|
|
* inline-icon modifiers. Shared by the live NodeView and the static HTML
|
|
* serializer so that exported documents render small images inline too.
|
|
*
|
|
* @param options The image's layout, width, and error state.
|
|
* @returns The space-separated className string.
|
|
*/
|
|
export function imageClassName(options: ImageClassNameOptions): string {
|
|
return [
|
|
"image",
|
|
options.layoutClass ? `image-${options.layoutClass}` : "",
|
|
isInlineImageIcon(options) ? "image-icon" : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
}
|
|
|
|
const Image = (props: Props) => {
|
|
const { isSelected, node, isEditable, onChangeSize, onClick } = props;
|
|
const { src, layoutClass } = node.attrs;
|
|
const { t } = useTranslation();
|
|
const [loaded, setLoaded] = React.useState(false);
|
|
const [error, setError] = React.useState(false);
|
|
const [isDownloading, setIsDownloading] = React.useState(false);
|
|
const [naturalWidth, setNaturalWidth] = React.useState(node.attrs.width);
|
|
const [naturalHeight, setNaturalHeight] = React.useState(node.attrs.height);
|
|
const lastTapTimeRef = React.useRef(0);
|
|
const ref = React.useRef<HTMLDivElement>(null);
|
|
const { width, height, handlePointerDown, handleDoubleClick, dragging } =
|
|
useDragResize({
|
|
width: node.attrs.width ?? naturalWidth,
|
|
height: node.attrs.height ?? naturalHeight,
|
|
naturalWidth,
|
|
naturalHeight,
|
|
onChangeSize,
|
|
ref,
|
|
});
|
|
|
|
const isFullWidth = layoutClass === "full-width";
|
|
const isInlineIcon = isInlineImageIcon({ layoutClass, width, error });
|
|
const isResizable = !!props.onChangeSize && !error && !isInlineIcon;
|
|
const isDownloadable = !!props.onDownload && !error;
|
|
|
|
const className = imageClassName({ layoutClass, width, error });
|
|
|
|
const sanitizedSrc = sanitizeImageSrc(src);
|
|
const linkMarkType = props.view.state.schema.marks.link;
|
|
const imgLink =
|
|
find(node.attrs.marks ?? [], (mark) => mark.type === linkMarkType.name)
|
|
?.attrs.href ||
|
|
// Coalescing to `undefined` to avoid empty string in href because empty string
|
|
// in href still shows pointer on hover and click navigates to nowhere
|
|
undefined;
|
|
const handleOpen = React.useCallback(() => {
|
|
window.open(sanitizedSrc, "_blank");
|
|
}, [sanitizedSrc]);
|
|
|
|
const widthStyle = isFullWidth
|
|
? { width: "var(--container-width)" }
|
|
: width
|
|
? { ["--image-width"]: `${width}px` }
|
|
: { width: "auto" };
|
|
|
|
const handleImageTouchStart = (ev: React.TouchEvent<HTMLDivElement>) => {
|
|
const currentTime = Date.now();
|
|
const timeSinceLastTap = currentTime - lastTapTimeRef.current;
|
|
|
|
if (timeSinceLastTap < 300 && isSelected) {
|
|
ev.preventDefault();
|
|
onClick();
|
|
}
|
|
|
|
lastTapTimeRef.current = currentTime;
|
|
};
|
|
|
|
const handleImageClick = (ev: React.MouseEvent<HTMLDivElement>) => {
|
|
if (!isEditable || isSelected) {
|
|
ev.preventDefault();
|
|
onClick();
|
|
}
|
|
};
|
|
|
|
const handleDownload = async (ev: React.MouseEvent<HTMLButtonElement>) => {
|
|
ev.preventDefault();
|
|
if (props.onDownload) {
|
|
setIsDownloading(true);
|
|
try {
|
|
await props.onDownload(ev);
|
|
} finally {
|
|
setIsDownloading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
const actions = [
|
|
isExternalUrl(src) && (
|
|
<Button key="open" onClick={handleOpen} aria-label={t("Open")}>
|
|
<GlobeIcon />
|
|
</Button>
|
|
),
|
|
imgLink && (
|
|
<Button
|
|
key="zoom"
|
|
// `mousedown` on ancestor `div.ProseMirror` was preventing the `onClick` handler from firing
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
onClick={props.onZoomIn}
|
|
aria-label={t("Zoom in")}
|
|
>
|
|
<ZoomInIcon />
|
|
</Button>
|
|
),
|
|
!isEditable && (
|
|
<Button
|
|
key="download"
|
|
onClick={handleDownload}
|
|
// `mousedown` on ancestor `div.ProseMirror` was preventing the `onClick` handler from firing
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
aria-label={t("Download")}
|
|
disabled={isDownloading}
|
|
>
|
|
<DownloadIcon />
|
|
</Button>
|
|
),
|
|
].filter(Boolean);
|
|
|
|
return (
|
|
<div contentEditable={false} className={className} ref={ref}>
|
|
<ImageWrapper
|
|
isFullWidth={isFullWidth}
|
|
$dragging={!!dragging}
|
|
className={
|
|
isSelected || dragging
|
|
? "image-wrapper ProseMirror-selectednode"
|
|
: "image-wrapper"
|
|
}
|
|
style={widthStyle}
|
|
>
|
|
{!dragging && width > 60 && isDownloadable && actions.length > 0 && (
|
|
<Actions>
|
|
{actions.map((action, index) => (
|
|
<React.Fragment key={index}>
|
|
{index > 0 && <Separator height={24} />}
|
|
{action}
|
|
</React.Fragment>
|
|
))}
|
|
</Actions>
|
|
)}
|
|
{error ? (
|
|
<Error className={EditorStyleHelper.imageHandle}>
|
|
<Flex gap={4} align="center">
|
|
<CrossIcon size={16} />
|
|
{width > 300 ? t("Image failed to load") : null}
|
|
</Flex>
|
|
</Error>
|
|
) : (
|
|
<a
|
|
href={imgLink}
|
|
// Do not show hover preview when the image is selected
|
|
className={!isSelected ? "use-hover-preview" : ""}
|
|
target="_blank"
|
|
rel="noopener noreferrer nofollow"
|
|
>
|
|
<img
|
|
className={EditorStyleHelper.imageHandle}
|
|
style={{
|
|
...widthStyle,
|
|
display: loaded ? "block" : "none",
|
|
}}
|
|
src={sanitizedSrc}
|
|
alt={node.attrs.alt || ""}
|
|
onError={() => {
|
|
setError(true);
|
|
setLoaded(true);
|
|
}}
|
|
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;
|
|
// When no width is set on the node the natural size is what the
|
|
// image is displayed at, so it feeds straight into useDragResize.
|
|
setNaturalWidth(nw);
|
|
setNaturalHeight(nh);
|
|
setLoaded(true);
|
|
}}
|
|
onClick={handleImageClick}
|
|
onTouchStart={handleImageTouchStart}
|
|
/>
|
|
</a>
|
|
)}
|
|
{!loaded && width && height && (
|
|
<img
|
|
alt=""
|
|
style={{
|
|
...widthStyle,
|
|
display: "block",
|
|
}}
|
|
src={`data:image/svg+xml;charset=UTF-8,${encodeURIComponent(
|
|
getPlaceholder(width, height)
|
|
)}`}
|
|
/>
|
|
)}
|
|
{isEditable && !isFullWidth && isResizable && (
|
|
<>
|
|
<ResizeLeft
|
|
onPointerDown={handlePointerDown("left")}
|
|
onDoubleClick={handleDoubleClick}
|
|
$dragging={!!dragging}
|
|
/>
|
|
<ResizeRight
|
|
onPointerDown={handlePointerDown("right")}
|
|
onDoubleClick={handleDoubleClick}
|
|
$dragging={!!dragging}
|
|
/>
|
|
<ResizeTopLeft
|
|
onPointerDown={handlePointerDown("topLeft")}
|
|
onDoubleClick={handleDoubleClick}
|
|
$dragging={!!dragging}
|
|
/>
|
|
<ResizeTopRight
|
|
onPointerDown={handlePointerDown("topRight")}
|
|
onDoubleClick={handleDoubleClick}
|
|
$dragging={!!dragging}
|
|
/>
|
|
<ResizeBottomLeft
|
|
onPointerDown={handlePointerDown("bottomLeft")}
|
|
onDoubleClick={handleDoubleClick}
|
|
$dragging={!!dragging}
|
|
/>
|
|
<ResizeBottomRight
|
|
onPointerDown={handlePointerDown("bottomRight")}
|
|
onDoubleClick={handleDoubleClick}
|
|
$dragging={!!dragging}
|
|
/>
|
|
</>
|
|
)}
|
|
</ImageWrapper>
|
|
{isInlineIcon
|
|
? null
|
|
: 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}" />`;
|
|
}
|
|
|
|
export const Error = styled(Flex)`
|
|
max-width: 100%;
|
|
color: ${s("textTertiary")};
|
|
font-size: 14px;
|
|
background: ${s("backgroundSecondary")};
|
|
border-radius: ${EditorStyleHelper.blockRadius};
|
|
height: 80px;
|
|
align-items: center;
|
|
justify-content: center;
|
|
user-select: none;
|
|
`;
|
|
|
|
const Actions = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 8px;
|
|
opacity: 0;
|
|
transition: opacity 150ms ease-in-out;
|
|
|
|
&:hover {
|
|
opacity: 1;
|
|
}
|
|
`;
|
|
|
|
const Button = styled.button`
|
|
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);
|
|
transition: opacity 150ms ease-in-out;
|
|
|
|
&:first-child:not(:last-child) {
|
|
border-top-right-radius: 0;
|
|
border-bottom-right-radius: 0;
|
|
}
|
|
|
|
&:last-child:not(:first-child) {
|
|
border-top-left-radius: 0;
|
|
border-bottom-left-radius: 0;
|
|
}
|
|
|
|
&:active {
|
|
transform: scale(0.98);
|
|
}
|
|
|
|
&:hover {
|
|
color: ${s("text")};
|
|
}
|
|
|
|
&:disabled {
|
|
opacity: 0.5;
|
|
cursor: wait;
|
|
pointer-events: none;
|
|
|
|
&:hover {
|
|
color: ${s("textSecondary")};
|
|
}
|
|
|
|
&:active {
|
|
transform: none;
|
|
}
|
|
}
|
|
`;
|
|
|
|
const ImageWrapper = styled.div<{ isFullWidth: boolean; $dragging: 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 || props.$dragging ? "0ms" : "150ms"};
|
|
transition-timing-function: ease-in-out;
|
|
overflow: visible;
|
|
|
|
img {
|
|
transition-property: width, height;
|
|
transition-duration: ${(props) =>
|
|
props.isFullWidth || props.$dragging ? "0ms" : "150ms"};
|
|
transition-timing-function: ease-in-out;
|
|
}
|
|
|
|
&:hover {
|
|
${Actions} {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
${ResizeLeft}, ${ResizeRight},
|
|
${ResizeTopLeft}, ${ResizeTopRight}, ${ResizeBottomLeft}, ${ResizeBottomRight} {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
`;
|
|
|
|
const Separator = styled.div<{ height?: number }>`
|
|
flex-shrink: 0;
|
|
width: 1px;
|
|
height: ${(props) => props.height || 28}px;
|
|
background: ${s("divider")};
|
|
`;
|
|
|
|
export default Image;
|