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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } })
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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} {
|
||||||
|
|||||||
Reference in New Issue
Block a user