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

View File

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

View File

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

View File

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

View File

@@ -8,8 +8,9 @@ import {
NodeSelection, NodeSelection,
EditorState, EditorState,
} from "prosemirror-state"; } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import * as React from "react"; 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 styled from "styled-components";
import { getDataTransferFiles, getEventFiles } from "../../utils/files"; import { getDataTransferFiles, getEventFiles } from "../../utils/files";
import { sanitizeUrl } from "../../utils/urls"; import { sanitizeUrl } from "../../utils/urls";
@@ -98,20 +99,43 @@ const uploadPlugin = (options: Options) =>
}); });
const IMAGE_CLASSES = ["right-50", "left-50"]; 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) { if (!tokenTitle) {
return {}; return attributes;
} }
if (IMAGE_CLASSES.includes(tokenTitle)) { if (IMAGE_CLASSES.includes(tokenTitle)) {
return { attributes.layoutClass = tokenTitle;
layoutClass: tokenTitle, IMAGE_CLASSES.map((className) => {
}; tokenTitle = tokenTitle.replace(className, "");
} else { });
return {
title: tokenTitle,
};
} }
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) => { const downloadImageNode = async (node: ProsemirrorNode) => {
@@ -144,6 +168,12 @@ export default class Image extends Node {
inline: true, inline: true,
attrs: { attrs: {
src: {}, src: {},
width: {
default: undefined,
},
height: {
default: undefined,
},
alt: { alt: {
default: null, default: null,
}, },
@@ -170,10 +200,15 @@ export default class Image extends Node {
const layoutClass = layoutClassMatched const layoutClass = layoutClassMatched
? layoutClassMatched[1] ? layoutClassMatched[1]
: null; : null;
const width = img.getAttribute("width");
const height = img.getAttribute("height");
return { return {
src: img?.getAttribute("src"), src: img?.getAttribute("src"),
alt: img?.getAttribute("alt"), alt: img?.getAttribute("alt"),
title: img?.getAttribute("title"), title: img?.getAttribute("title"),
width: width ? parseInt(width, 10) : undefined,
height: height ? parseInt(height, 10) : undefined,
layoutClass, layoutClass,
}; };
}, },
@@ -181,10 +216,14 @@ export default class Image extends Node {
{ {
tag: "img", tag: "img",
getAttrs: (dom: HTMLImageElement) => { getAttrs: (dom: HTMLImageElement) => {
const width = dom.getAttribute("width");
const height = dom.getAttribute("height");
return { return {
src: dom.getAttribute("src"), src: dom.getAttribute("src"),
alt: dom.getAttribute("alt"), alt: dom.getAttribute("alt"),
title: dom.getAttribute("title"), 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, ...node.attrs,
src: sanitizeUrl(node.attrs.src), src: sanitizeUrl(node.attrs.src),
width: node.attrs.width,
height: node.attrs.height,
contentEditable: "false", contentEditable: "false",
}, },
], ],
@@ -252,10 +293,8 @@ export default class Image extends Node {
node: ProsemirrorNode; node: ProsemirrorNode;
getPos: () => number; getPos: () => number;
}) => (event: React.FocusEvent<HTMLSpanElement>) => { }) => (event: React.FocusEvent<HTMLSpanElement>) => {
const alt = event.currentTarget.innerText; const caption = event.currentTarget.innerText;
const { src, title, layoutClass } = node.attrs; if (caption === node.attrs.alt) {
if (alt === node.attrs.alt) {
return; return;
} }
@@ -265,10 +304,8 @@ export default class Image extends Node {
// update meta on object // update meta on object
const pos = getPos(); const pos = getPos();
const transaction = tr.setNodeMarkup(pos, undefined, { const transaction = tr.setNodeMarkup(pos, undefined, {
src, ...node.attrs,
alt, alt: caption,
title,
layoutClass,
}); });
view.dispatch(transaction); view.dispatch(transaction);
}; };
@@ -284,6 +321,26 @@ export default class Image extends Node {
view.dispatch(transaction); 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 }) => ( handleDownload = ({ node }: { node: ProsemirrorNode }) => (
event: React.MouseEvent event: React.MouseEvent
) => { ) => {
@@ -304,8 +361,10 @@ export default class Image extends Node {
return ( return (
<ImageComponent <ImageComponent
{...props} {...props}
view={this.editor.view}
onClick={this.handleSelect(props)} onClick={this.handleSelect(props)}
onDownload={this.handleDownload(props)} onDownload={this.handleDownload(props)}
onChangeSize={this.handleChangeSize(props)}
> >
<Caption <Caption
onKeyDown={this.handleKeyDown(props)} 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.alt || "").replace("\n", "") || "", false) +
"](" + "](" +
state.esc(node.attrs.src || "", 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) { if (node.attrs.layoutClass) {
markdown += ' "' + state.esc(node.attrs.layoutClass, false) + '"'; markdown += ' "' + state.esc(node.attrs.layoutClass, false) + size + '"';
} else if (node.attrs.title) { } 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 += ")"; markdown += ")";
state.write(markdown); state.write(markdown);
@@ -350,7 +422,7 @@ export default class Image extends Node {
token.children[0] && token.children[0] &&
token.children[0].content) || token.children[0].content) ||
null, null,
...getLayoutAndTitle(token?.attrGet("title")), ...getLayoutAndTitle(token?.attrGet("title") || ""),
}; };
}, },
}; };
@@ -403,7 +475,11 @@ export default class Image extends Node {
return true; return true;
}, },
replaceImage: () => (state: EditorState) => { replaceImage: () => (state: EditorState) => {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
const { view } = this.editor; const { view } = this.editor;
const { node } = state.selection;
const { const {
uploadFile, uploadFile,
onFileUploadStart, onFileUploadStart,
@@ -415,6 +491,10 @@ export default class Image extends Node {
throw new Error("uploadFile prop is required to replace images"); 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 // create an input element and click to trigger picker
const inputElement = document.createElement("input"); const inputElement = document.createElement("input");
inputElement.type = "file"; inputElement.type = "file";
@@ -428,6 +508,7 @@ export default class Image extends Node {
onShowToast, onShowToast,
dictionary: this.options.dictionary, dictionary: this.options.dictionary,
replaceExisting: true, replaceExisting: true,
width: node.attrs.width,
}); });
}; };
inputElement.click(); inputElement.click();
@@ -491,40 +572,153 @@ export default class Image extends Node {
} }
} }
type DragDirection = "left" | "right";
const ImageComponent = ( const ImageComponent = (
props: ComponentProps & { props: ComponentProps & {
onClick: (event: React.MouseEvent<HTMLDivElement>) => void; onClick: (event: React.MouseEvent<HTMLDivElement>) => void;
onDownload: (event: React.MouseEvent<HTMLButtonElement>) => void; onDownload: (event: React.MouseEvent<HTMLButtonElement>) => void;
onChangeSize: (props: { width: number; height?: number }) => void;
children: React.ReactNode; children: React.ReactNode;
view: EditorView;
} }
) => { ) => {
const { theme, isSelected, node } = props; const { theme, isSelected, node, isEditable } = props;
const { alt, src, layoutClass } = node.attrs; const { alt, src, layoutClass } = node.attrs;
const className = layoutClass ? `image image-${layoutClass}` : "image"; 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 ( return (
<div contentEditable={false} className={className}> <div contentEditable={false} className={className}>
<ImageWrapper <ImageWrapper
className={isSelected ? "ProseMirror-selectednode" : ""} className={isSelected || dragging ? "ProseMirror-selectednode" : ""}
onClick={props.onClick} onClick={dragging ? undefined : props.onClick}
style={{ width }} style={style}
> >
<Button onClick={props.onDownload}> {!dragging && (
<DownloadIcon color="currentColor" /> <Button onClick={props.onDownload}>
</Button> <DownloadIcon color="currentColor" />
</Button>
)}
<ImageZoom <ImageZoom
image={{ image={
src: sanitizeUrl(src) ?? "", {
alt, style,
// @ts-expect-error type is incorrect, allows spreading all img props src: sanitizeUrl(src) ?? "",
onLoad: (ev) => { alt,
// For some SVG's Firefox does not provide the naturalWidth, in this onLoad: (ev: React.SyntheticEvent<HTMLImageElement>) => {
// rare case we need to provide a default so that the image can be // For some SVG's Firefox does not provide the naturalWidth, in this
// seen and is not sized to 0px // rare case we need to provide a default so that the image can be
setWidth(ev.target.naturalWidth || "50%"); // 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);
if (!node.attrs.width) {
setSize((state) => ({
...state,
width: nw,
}));
}
},
} as ImageZoom_Image
}
defaultStyles={{ defaultStyles={{
overlay: { overlay: {
backgroundColor: theme.background, backgroundColor: theme.background,
@@ -532,12 +726,61 @@ const ImageComponent = (
}} }}
shouldRespectMaxDimension shouldRespectMaxDimension
/> />
{isEditable && (
<>
<ResizeLeft
onMouseDown={handleMouseDown("left")}
$dragging={!!dragging}
/>
<ResizeRight
onMouseDown={handleMouseDown("right")}
$dragging={!!dragging}
/>
</>
)}
</ImageWrapper> </ImageWrapper>
{props.children} {props.children}
</div> </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` const Button = styled.button`
position: absolute; position: absolute;
top: 8px; top: 8px;
@@ -553,7 +796,7 @@ const Button = styled.button`
display: inline-block; display: inline-block;
cursor: var(--pointer); cursor: var(--pointer);
opacity: 0; opacity: 0;
transition: opacity 100ms ease-in-out; transition: opacity 150ms ease-in-out;
&:active { &:active {
transform: scale(0.98); transform: scale(0.98);
@@ -600,11 +843,18 @@ const ImageWrapper = styled.div`
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
max-width: 100%; max-width: 100%;
transition-property: width, height;
transition-duration: 150ms;
transition-timing-function: ease-in-out;
&:hover { &:hover {
${Button} { ${Button} {
opacity: 0.9; opacity: 0.9;
} }
${ResizeLeft}, ${ResizeRight} {
opacity: 1;
}
} }
&.ProseMirror-selectednode + ${Caption} { &.ProseMirror-selectednode + ${Caption} {