feat: Image resizing (#4368)

* wip

* works

* wip

* refactor

* Support replacing image and retain width
fix: Copy paste does not retain size

* cleanup

* fix: Cannot resize past 100%
fix: Borders to edges on unresized images

* Handle Escape key while dragging

* fix: Embeds and images dont render when edit state changes
fix: Small animation regression
This commit is contained in:
Tom Moor
2022-11-02 18:40:37 -07:00
committed by GitHub
parent 6f8d01df21
commit 5e17b24869
5 changed files with 311 additions and 46 deletions

View File

@@ -59,6 +59,7 @@ export default class ComponentView {
this.renderElement();
window.addEventListener("theme-changed", this.renderElement);
window.addEventListener("location-changed", this.renderElement);
}
renderElement = () => {
@@ -107,9 +108,11 @@ export default class ComponentView {
}
destroy() {
window.removeEventListener("theme-changed", this.renderElement);
window.removeEventListener("location-changed", this.renderElement);
if (this.dom) {
ReactDOM.unmountComponentAtNode(this.dom);
window.removeEventListener("theme-changed", this.renderElement);
}
this.dom = null;
}

View File

@@ -24,6 +24,12 @@ import { initSentry } from "./utils/sentry";
initI18n();
const element = window.document.getElementById("root");
history.listen(() => {
requestAnimationFrame(() =>
window.dispatchEvent(new Event("location-changed"))
);
});
if (env.SENTRY_DSN) {
initSentry(history);
}

View File

@@ -13,6 +13,8 @@ export type Options = {
isAttachment?: boolean;
/** Set to true to replace any existing image at the users selection */
replaceExisting?: boolean;
/** Width to use when inserting image */
width?: number;
uploadFile?: (file: File) => Promise<string>;
onFileUploadStart?: () => void;
onFileUploadStop?: () => void;
@@ -112,7 +114,7 @@ const insertFiles = function (
.replaceWith(
from,
to || from,
schema.nodes.image.create({ src })
schema.nodes.image.create({ src, width: options.width })
)
.setMeta(uploadPlaceholderPlugin, { remove: { id } })
);

View File

@@ -137,12 +137,16 @@ li {
text-align: center;
max-width: 100%;
clear: both;
position: relative;
z-index: 1;
img {
pointer-events: ${props.readOnly ? "initial" : "none"};
display: inline-block;
max-width: 100%;
max-height: 75vh;
transition-property: width, height;
transition-duration: 150ms;
transition-timing-function: ease-in-out;
}
.ProseMirror-selectednode img {
@@ -168,7 +172,7 @@ li {
.image-right-50 {
float: right;
width: 50%;
width: 33.3%;
margin-left: 2em;
margin-bottom: 1em;
clear: initial;
@@ -176,7 +180,7 @@ li {
.image-left-50 {
float: left;
width: 50%;
width: 33.3%;
margin-right: 2em;
margin-bottom: 1em;
clear: initial;

View File

@@ -8,8 +8,9 @@ import {
NodeSelection,
EditorState,
} from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import ImageZoom from "react-medium-image-zoom";
import ImageZoom, { ImageZoom_Image } from "react-medium-image-zoom";
import styled from "styled-components";
import { getDataTransferFiles, getEventFiles } from "../../utils/files";
import { sanitizeUrl } from "../../utils/urls";
@@ -98,20 +99,43 @@ const uploadPlugin = (options: Options) =>
});
const IMAGE_CLASSES = ["right-50", "left-50"];
const imageSizeRegex = /\s=(\d+)?x(\d+)?$/;
const getLayoutAndTitle = (tokenTitle: string | null) => {
type TitleAttributes = {
layoutClass?: string;
title?: string;
width?: number;
height?: number;
};
const getLayoutAndTitle = (tokenTitle: string): TitleAttributes => {
const attributes: TitleAttributes = {
layoutClass: undefined,
title: undefined,
width: undefined,
height: undefined,
};
if (!tokenTitle) {
return {};
return attributes;
}
if (IMAGE_CLASSES.includes(tokenTitle)) {
return {
layoutClass: tokenTitle,
};
} else {
return {
title: tokenTitle,
};
attributes.layoutClass = tokenTitle;
IMAGE_CLASSES.map((className) => {
tokenTitle = tokenTitle.replace(className, "");
});
}
const match = tokenTitle.match(imageSizeRegex);
if (match) {
attributes.width = parseInt(match[1], 10);
attributes.height = parseInt(match[2], 10);
tokenTitle = tokenTitle.replace(imageSizeRegex, "");
}
attributes.title = tokenTitle;
return attributes;
};
const downloadImageNode = async (node: ProsemirrorNode) => {
@@ -144,6 +168,12 @@ export default class Image extends Node {
inline: true,
attrs: {
src: {},
width: {
default: undefined,
},
height: {
default: undefined,
},
alt: {
default: null,
},
@@ -170,10 +200,15 @@ export default class Image extends Node {
const layoutClass = layoutClassMatched
? layoutClassMatched[1]
: null;
const width = img.getAttribute("width");
const height = img.getAttribute("height");
return {
src: img?.getAttribute("src"),
alt: img?.getAttribute("alt"),
title: img?.getAttribute("title"),
width: width ? parseInt(width, 10) : undefined,
height: height ? parseInt(height, 10) : undefined,
layoutClass,
};
},
@@ -181,10 +216,14 @@ export default class Image extends Node {
{
tag: "img",
getAttrs: (dom: HTMLImageElement) => {
const width = dom.getAttribute("width");
const height = dom.getAttribute("height");
return {
src: dom.getAttribute("src"),
alt: dom.getAttribute("alt"),
title: dom.getAttribute("title"),
width: width ? parseInt(width, 10) : undefined,
height: height ? parseInt(height, 10) : undefined,
};
},
},
@@ -203,6 +242,8 @@ export default class Image extends Node {
{
...node.attrs,
src: sanitizeUrl(node.attrs.src),
width: node.attrs.width,
height: node.attrs.height,
contentEditable: "false",
},
],
@@ -252,10 +293,8 @@ export default class Image extends Node {
node: ProsemirrorNode;
getPos: () => number;
}) => (event: React.FocusEvent<HTMLSpanElement>) => {
const alt = event.currentTarget.innerText;
const { src, title, layoutClass } = node.attrs;
if (alt === node.attrs.alt) {
const caption = event.currentTarget.innerText;
if (caption === node.attrs.alt) {
return;
}
@@ -265,10 +304,8 @@ export default class Image extends Node {
// update meta on object
const pos = getPos();
const transaction = tr.setNodeMarkup(pos, undefined, {
src,
alt,
title,
layoutClass,
...node.attrs,
alt: caption,
});
view.dispatch(transaction);
};
@@ -284,6 +321,26 @@ export default class Image extends Node {
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,
});
const $pos = transaction.doc.resolve(getPos());
view.dispatch(transaction.setSelection(new NodeSelection($pos)));
};
handleDownload = ({ node }: { node: ProsemirrorNode }) => (
event: React.MouseEvent
) => {
@@ -304,8 +361,10 @@ export default class Image extends Node {
return (
<ImageComponent
{...props}
view={this.editor.view}
onClick={this.handleSelect(props)}
onDownload={this.handleDownload(props)}
onChangeSize={this.handleChangeSize(props)}
>
<Caption
onKeyDown={this.handleKeyDown(props)}
@@ -330,10 +389,23 @@ export default class Image extends Node {
state.esc((node.attrs.alt || "").replace("\n", "") || "", false) +
"](" +
state.esc(node.attrs.src || "", false);
let size = "";
if (node.attrs.width || node.attrs.height) {
size = ` =${state.esc(
node.attrs.width ? String(node.attrs.width) : "",
false
)}x${state.esc(
node.attrs.height ? String(node.attrs.height) : "",
false
)}`;
}
if (node.attrs.layoutClass) {
markdown += ' "' + state.esc(node.attrs.layoutClass, false) + '"';
markdown += ' "' + state.esc(node.attrs.layoutClass, false) + size + '"';
} else if (node.attrs.title) {
markdown += ' "' + state.esc(node.attrs.title, false) + '"';
markdown += ' "' + state.esc(node.attrs.title, false) + size + '"';
} else if (size) {
markdown += ' "' + size + '"';
}
markdown += ")";
state.write(markdown);
@@ -350,7 +422,7 @@ export default class Image extends Node {
token.children[0] &&
token.children[0].content) ||
null,
...getLayoutAndTitle(token?.attrGet("title")),
...getLayoutAndTitle(token?.attrGet("title") || ""),
};
},
};
@@ -403,7 +475,11 @@ export default class Image extends Node {
return true;
},
replaceImage: () => (state: EditorState) => {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
const { view } = this.editor;
const { node } = state.selection;
const {
uploadFile,
onFileUploadStart,
@@ -415,6 +491,10 @@ export default class Image extends Node {
throw new Error("uploadFile prop is required to replace images");
}
if (node.type.name !== "image") {
return false;
}
// create an input element and click to trigger picker
const inputElement = document.createElement("input");
inputElement.type = "file";
@@ -428,6 +508,7 @@ export default class Image extends Node {
onShowToast,
dictionary: this.options.dictionary,
replaceExisting: true,
width: node.attrs.width,
});
};
inputElement.click();
@@ -491,40 +572,153 @@ export default class Image extends Node {
}
}
type DragDirection = "left" | "right";
const ImageComponent = (
props: ComponentProps & {
onClick: (event: React.MouseEvent<HTMLDivElement>) => void;
onDownload: (event: React.MouseEvent<HTMLButtonElement>) => void;
onChangeSize: (props: { width: number; height?: number }) => void;
children: React.ReactNode;
view: EditorView;
}
) => {
const { theme, isSelected, node } = props;
const { theme, isSelected, node, isEditable } = props;
const { alt, src, layoutClass } = node.attrs;
const className = layoutClass ? `image image-${layoutClass}` : "image";
const [width, setWidth] = React.useState(0);
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 documentWidth = props.view?.dom.clientWidth;
const maxWidth = layoutClass ? documentWidth / 3 : documentWidth;
const handleMouseMove = (event: MouseEvent) => {
event.preventDefault();
let diff;
if (dragging === "left") {
diff = offset - event.pageX;
} else {
diff = event.pageX - offset;
}
const grid = documentWidth / 10;
const minWidth = naturalWidth * 0.1;
const newWidth = sizeAtDragStart.width + diff * 2;
const widthOnGrid = Math.round(newWidth / grid) * grid;
const constrainedWidth = Math.round(
Math.min(maxWidth, Math.max(widthOnGrid, minWidth))
);
const aspectRatio = naturalHeight / naturalWidth;
setSize({
width: constrainedWidth,
height: naturalWidth
? Math.round(constrainedWidth * aspectRatio)
: undefined,
});
};
const handleMouseUp = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
setOffset(0);
setDragging(undefined);
props.onChangeSize(size);
document.removeEventListener("mousemove", handleMouseMove);
};
const handleMouseDown = (dragging: "left" | "right") => (
event: React.MouseEvent<HTMLDivElement>
) => {
event.preventDefault();
event.stopPropagation();
setSizeAtDragStart(size);
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) {
setSize({
width: node.attrs.width,
height: node.attrs.height,
});
}
}, [node.attrs.width]);
React.useEffect(() => {
if (dragging) {
document.body.style.cursor = "ew-resize";
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}
return () => {
document.body.style.cursor = "initial";
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [dragging, handleMouseMove, handleMouseUp]);
const style = { width: size.width || "auto" };
return (
<div contentEditable={false} className={className}>
<ImageWrapper
className={isSelected ? "ProseMirror-selectednode" : ""}
onClick={props.onClick}
style={{ width }}
className={isSelected || dragging ? "ProseMirror-selectednode" : ""}
onClick={dragging ? undefined : props.onClick}
style={style}
>
{!dragging && (
<Button onClick={props.onDownload}>
<DownloadIcon color="currentColor" />
</Button>
)}
<ImageZoom
image={{
image={
{
style,
src: sanitizeUrl(src) ?? "",
alt,
// @ts-expect-error type is incorrect, allows spreading all img props
onLoad: (ev) => {
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
setWidth(ev.target.naturalWidth || "50%");
const nw = (ev.target as HTMLImageElement).naturalWidth || 300;
const nh = (ev.target as HTMLImageElement).naturalHeight;
setNaturalWidth(nw);
setNaturalHeight(nh);
if (!node.attrs.width) {
setSize((state) => ({
...state,
width: nw,
}));
}
},
}}
} as ImageZoom_Image
}
defaultStyles={{
overlay: {
backgroundColor: theme.background,
@@ -532,12 +726,61 @@ const ImageComponent = (
}}
shouldRespectMaxDimension
/>
{isEditable && (
<>
<ResizeLeft
onMouseDown={handleMouseDown("left")}
$dragging={!!dragging}
/>
<ResizeRight
onMouseDown={handleMouseDown("right")}
$dragging={!!dragging}
/>
</>
)}
</ImageWrapper>
{props.children}
</div>
);
};
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: ${(props) => props.theme.toolbarBackground};
box-shadow: 0 0 0 1px ${(props) => props.theme.toolbarItem};
opacity: 0.75;
}
`;
const ResizeRight = styled(ResizeLeft)`
left: initial;
right: -4px;
&:after {
left: initial;
right: 8px;
}
`;
const Button = styled.button`
position: absolute;
top: 8px;
@@ -553,7 +796,7 @@ const Button = styled.button`
display: inline-block;
cursor: var(--pointer);
opacity: 0;
transition: opacity 100ms ease-in-out;
transition: opacity 150ms ease-in-out;
&:active {
transform: scale(0.98);
@@ -600,11 +843,18 @@ const ImageWrapper = styled.div`
margin-left: auto;
margin-right: auto;
max-width: 100%;
transition-property: width, height;
transition-duration: 150ms;
transition-timing-function: ease-in-out;
&:hover {
${Button} {
opacity: 0.9;
}
${ResizeLeft}, ${ResizeRight} {
opacity: 1;
}
}
&.ProseMirror-selectednode + ${Caption} {