diff --git a/app/editor/components/ComponentView.tsx b/app/editor/components/ComponentView.tsx index 7e21f7f62..ccf51b1fa 100644 --- a/app/editor/components/ComponentView.tsx +++ b/app/editor/components/ComponentView.tsx @@ -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; } diff --git a/app/index.tsx b/app/index.tsx index 1a9ec09ac..172338e39 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -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); } diff --git a/shared/editor/commands/insertFiles.ts b/shared/editor/commands/insertFiles.ts index 054a94045..898e5f8a7 100644 --- a/shared/editor/commands/insertFiles.ts +++ b/shared/editor/commands/insertFiles.ts @@ -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; 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 } }) ); diff --git a/shared/editor/components/Styles.ts b/shared/editor/components/Styles.ts index 359718653..2b0df7529 100644 --- a/shared/editor/components/Styles.ts +++ b/shared/editor/components/Styles.ts @@ -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; diff --git a/shared/editor/nodes/Image.tsx b/shared/editor/nodes/Image.tsx index 7b4bdd37e..29dc7c8c3 100644 --- a/shared/editor/nodes/Image.tsx +++ b/shared/editor/nodes/Image.tsx @@ -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) => { - 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 ( (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) => void; onDownload: (event: React.MouseEvent) => 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(); + 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 + ) => { + 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 (
- + {!dragging && ( + + )} { - // 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%"); - }, - }} + image={ + { + style, + src: sanitizeUrl(src) ?? "", + alt, + onLoad: (ev: React.SyntheticEvent) => { + // 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); + + 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 && ( + <> + + + + )} {props.children}
); }; +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} {