From e786888dfb3b1f80bcef1f33852df799ed558121 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 9 Mar 2023 22:17:16 -0500 Subject: [PATCH] fix: Remove image float and positioning options in comments (#5014) * cleanup * Split Image into SimpleImage * ts --- app/editor/menus/block.tsx | 2 +- shared/editor/components/Image.tsx | 352 +++++++++++- shared/editor/components/Img.tsx | 14 + shared/editor/embeds/Diagrams.tsx | 2 +- shared/editor/embeds/GoogleDocs.tsx | 2 +- shared/editor/embeds/GoogleDrawings.tsx | 2 +- shared/editor/embeds/GoogleDrive.tsx | 2 +- shared/editor/embeds/GoogleForms.tsx | 2 +- shared/editor/embeds/GoogleLookerStudio.tsx | 2 +- shared/editor/embeds/GoogleSheets.tsx | 2 +- shared/editor/embeds/GoogleSlides.tsx | 2 +- shared/editor/embeds/Grist.tsx | 2 +- shared/editor/embeds/index.tsx | 2 +- shared/editor/lib/uploadPlugin.ts | 84 +++ shared/editor/nodes/Image.tsx | 605 +------------------- shared/editor/nodes/SimpleImage.tsx | 290 ++++++++++ shared/editor/nodes/index.ts | 6 +- 17 files changed, 764 insertions(+), 609 deletions(-) create mode 100644 shared/editor/components/Img.tsx create mode 100644 shared/editor/lib/uploadPlugin.ts create mode 100644 shared/editor/nodes/SimpleImage.tsx diff --git a/app/editor/menus/block.tsx b/app/editor/menus/block.tsx index 42269b5e4..3d954b274 100644 --- a/app/editor/menus/block.tsx +++ b/app/editor/menus/block.tsx @@ -22,7 +22,7 @@ import { } from "outline-icons"; import * as React from "react"; import styled from "styled-components"; -import Image from "@shared/editor/components/Image"; +import Image from "@shared/editor/components/Img"; import { MenuItem } from "@shared/editor/types"; import { Dictionary } from "~/hooks/useDictionary"; import { metaDisplay } from "~/utils/keyboard"; diff --git a/shared/editor/components/Image.tsx b/shared/editor/components/Image.tsx index 29b0b60a5..b6efa902e 100644 --- a/shared/editor/components/Image.tsx +++ b/shared/editor/components/Image.tsx @@ -1,14 +1,346 @@ +import { DownloadIcon } from "outline-icons"; +import type { EditorView } from "prosemirror-view"; import * as React from "react"; -import { cdnPath } from "../../utils/urls"; +import styled from "styled-components"; +import breakpoint from "styled-components-breakpoint"; +import { sanitizeUrl } from "@shared/utils/urls"; +import { ComponentProps } from "../types"; +import ImageZoom from "./ImageZoom"; -type Props = { - alt: string; - src: string; - title?: string; - width?: number; - height?: number; +type DragDirection = "left" | "right"; + +const Image = ( + props: ComponentProps & { + onClick: (event: React.MouseEvent) => void; + onDownload?: (event: React.MouseEvent) => void; + onChangeSize?: (props: { width: number; height?: number }) => void; + children?: React.ReactElement; + view: EditorView; + } +) => { + const { isSelected, node, isEditable } = props; + const { src, layoutClass } = node.attrs; + const className = layoutClass ? `image image-${layoutClass}` : "image"; + const [contentWidth, setContentWidth] = React.useState( + () => document.body.querySelector("#full-width-container")?.clientWidth || 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, setDocumentWidth] = React.useState( + props.view?.dom.clientWidth || 0 + ); + const maxWidth = layoutClass ? documentWidth / 3 : documentWidth; + const isFullWidth = layoutClass === "full-width"; + const isResizable = !!props.onChangeSize; + + React.useLayoutEffect(() => { + if (!isResizable) { + return; + } + + const handleResize = () => { + const contentWidth = + document.body.querySelector("#full-width-container")?.clientWidth || 0; + setContentWidth(contentWidth); + setDocumentWidth(props.view?.dom.clientWidth || 0); + }; + + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, [props.view, isResizable]); + + const constrainWidth = (width: number) => { + const minWidth = documentWidth * 0.1; + return Math.round(Math.min(maxWidth, Math.max(width, minWidth))); + }; + + const handlePointerMove = (event: PointerEvent) => { + event.preventDefault(); + + let diff; + if (dragging === "left") { + diff = offset - event.pageX; + } else { + diff = event.pageX - offset; + } + + const grid = documentWidth / 10; + const newWidth = sizeAtDragStart.width + diff * 2; + const widthOnGrid = Math.round(newWidth / grid) * grid; + const constrainedWidth = constrainWidth(widthOnGrid); + + const aspectRatio = naturalHeight / naturalWidth; + setSize({ + width: constrainedWidth, + height: naturalWidth + ? Math.round(constrainedWidth * aspectRatio) + : undefined, + }); + }; + + const handlePointerUp = (event: PointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + + setOffset(0); + setDragging(undefined); + props.onChangeSize?.(size); + + document.removeEventListener("mousemove", handlePointerMove); + }; + + const handlePointerDown = (dragging: "left" | "right") => ( + event: React.PointerEvent + ) => { + event.preventDefault(); + event.stopPropagation(); + setSizeAtDragStart({ + width: constrainWidth(size.width), + height: size.height, + }); + 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 (!isResizable) { + return; + } + + if (dragging) { + document.body.style.cursor = "ew-resize"; + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("pointermove", handlePointerMove); + document.addEventListener("pointerup", handlePointerUp); + } + + return () => { + document.body.style.cursor = "initial"; + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("pointerup", handlePointerUp); + }; + }, [dragging, handlePointerMove, handlePointerUp, isResizable]); + + const widthStyle = isFullWidth + ? { width: contentWidth } + : { width: size.width || "auto" }; + + const containerStyle = isFullWidth + ? ({ + "--offset": `${-(contentWidth - documentWidth) / 2}px`, + } as React.CSSProperties) + : undefined; + + return ( +
+ + {!dragging && size.width > 60 && size.height > 60 && props.onDownload && ( + + )} + + ) => { + // 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, + })); + } + }} + /> + + {isEditable && !isFullWidth && isResizable && ( + <> + + + + )} + + {isFullWidth && props.children + ? React.cloneElement(props.children, { style: widthStyle }) + : props.children} +
+ ); }; -export default function Image({ src, alt, ...rest }: Props) { - return {alt}; -} +export const Caption = styled.p` + border: 0; + display: block; + font-style: italic; + font-weight: normal; + color: ${(props) => props.theme.textSecondary}; + padding: 8px 0 4px; + line-height: 16px; + text-align: center; + min-height: 1em; + outline: none; + background: none; + resize: none; + user-select: text; + margin: 0 !important; + cursor: text; + + ${breakpoint("tablet")` + font-size: 13px; + `}; + + &:empty:not(:focus) { + display: none; + } + + &:empty:before { + color: ${(props) => props.theme.placeholder}; + content: attr(data-caption); + pointer-events: none; + } +`; + +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; + right: 8px; + border: 0; + margin: 0; + padding: 0; + border-radius: 4px; + background: ${(props) => props.theme.background}; + color: ${(props) => props.theme.textSecondary}; + width: 24px; + height: 24px; + display: inline-block; + cursor: var(--pointer); + opacity: 0; + transition: opacity 150ms ease-in-out; + + &:active { + transform: scale(0.98); + } + + &:hover { + color: ${(props) => props.theme.text}; + opacity: 1; + } +`; + +const ImageWrapper = styled.div<{ isFullWidth: 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 ? "0ms" : "150ms")}; + transition-timing-function: ease-in-out; + overflow: hidden; + + img { + transition-property: width, height; + transition-duration: ${(props) => (props.isFullWidth ? "0ms" : "150ms")}; + transition-timing-function: ease-in-out; + } + + &:hover { + ${Button} { + opacity: 0.9; + } + + ${ResizeLeft}, ${ResizeRight} { + opacity: 1; + } + } + + &.ProseMirror-selectednode + ${Caption} { + display: block; + } +`; + +export default Image; diff --git a/shared/editor/components/Img.tsx b/shared/editor/components/Img.tsx new file mode 100644 index 000000000..66cb9ae97 --- /dev/null +++ b/shared/editor/components/Img.tsx @@ -0,0 +1,14 @@ +import * as React from "react"; +import { cdnPath } from "../../utils/urls"; + +type Props = { + alt: string; + src: string; + title?: string; + width?: number; + height?: number; +}; + +export default function Img({ src, alt, ...rest }: Props) { + return {alt}; +} diff --git a/shared/editor/embeds/Diagrams.tsx b/shared/editor/embeds/Diagrams.tsx index 27793f720..484340f36 100644 --- a/shared/editor/embeds/Diagrams.tsx +++ b/shared/editor/embeds/Diagrams.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import Frame from "../components/Frame"; -import Image from "../components/Image"; +import Image from "../components/Img"; import { EmbedProps as Props } from "."; function Diagrams(props: Props) { diff --git a/shared/editor/embeds/GoogleDocs.tsx b/shared/editor/embeds/GoogleDocs.tsx index 05e04fc4b..4cd833ff6 100644 --- a/shared/editor/embeds/GoogleDocs.tsx +++ b/shared/editor/embeds/GoogleDocs.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import Frame from "../components/Frame"; -import Image from "../components/Image"; +import Image from "../components/Img"; import { EmbedProps as Props } from "."; function GoogleDocs(props: Props) { diff --git a/shared/editor/embeds/GoogleDrawings.tsx b/shared/editor/embeds/GoogleDrawings.tsx index 34e6513ea..3f1659092 100644 --- a/shared/editor/embeds/GoogleDrawings.tsx +++ b/shared/editor/embeds/GoogleDrawings.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import Frame from "../components/Frame"; -import Image from "../components/Image"; +import Image from "../components/Img"; import { EmbedProps as Props } from "."; function GoogleDrawings(props: Props) { diff --git a/shared/editor/embeds/GoogleDrive.tsx b/shared/editor/embeds/GoogleDrive.tsx index 59627a56d..e17e4ac5b 100644 --- a/shared/editor/embeds/GoogleDrive.tsx +++ b/shared/editor/embeds/GoogleDrive.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import Frame from "../components/Frame"; -import Image from "../components/Image"; +import Image from "../components/Img"; import { EmbedProps as Props } from "."; function GoogleDrive(props: Props) { diff --git a/shared/editor/embeds/GoogleForms.tsx b/shared/editor/embeds/GoogleForms.tsx index 9379836ca..906824589 100644 --- a/shared/editor/embeds/GoogleForms.tsx +++ b/shared/editor/embeds/GoogleForms.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import Frame from "../components/Frame"; -import Image from "../components/Image"; +import Image from "../components/Img"; import { EmbedProps as Props } from "."; function GoogleForms(props: Props) { diff --git a/shared/editor/embeds/GoogleLookerStudio.tsx b/shared/editor/embeds/GoogleLookerStudio.tsx index 4f9f60c4f..b105889f3 100644 --- a/shared/editor/embeds/GoogleLookerStudio.tsx +++ b/shared/editor/embeds/GoogleLookerStudio.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import Frame from "../components/Frame"; -import Image from "../components/Image"; +import Image from "../components/Img"; import { EmbedProps as Props } from "."; function GoogleLookerStudio(props: Props) { diff --git a/shared/editor/embeds/GoogleSheets.tsx b/shared/editor/embeds/GoogleSheets.tsx index 5a7873111..de0122c38 100644 --- a/shared/editor/embeds/GoogleSheets.tsx +++ b/shared/editor/embeds/GoogleSheets.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import Frame from "../components/Frame"; -import Image from "../components/Image"; +import Image from "../components/Img"; import { EmbedProps as Props } from "."; function GoogleSheets(props: Props) { diff --git a/shared/editor/embeds/GoogleSlides.tsx b/shared/editor/embeds/GoogleSlides.tsx index d1614e29d..067ad5a01 100644 --- a/shared/editor/embeds/GoogleSlides.tsx +++ b/shared/editor/embeds/GoogleSlides.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import Frame from "../components/Frame"; -import Image from "../components/Image"; +import Image from "../components/Img"; import { EmbedProps as Props } from "."; function GoogleSlides(props: Props) { diff --git a/shared/editor/embeds/Grist.tsx b/shared/editor/embeds/Grist.tsx index a10af4068..5f6c1972e 100644 --- a/shared/editor/embeds/Grist.tsx +++ b/shared/editor/embeds/Grist.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import Frame from "../components/Frame"; -import Image from "../components/Image"; +import Image from "../components/Img"; import { EmbedProps as Props } from "."; function Grist(props: Props) { diff --git a/shared/editor/embeds/index.tsx b/shared/editor/embeds/index.tsx index a7bb36cb9..bf82214da 100644 --- a/shared/editor/embeds/index.tsx +++ b/shared/editor/embeds/index.tsx @@ -4,7 +4,7 @@ import styled from "styled-components"; import { IntegrationType } from "../../types"; import type { IntegrationSettings } from "../../types"; import { urlRegex } from "../../utils/urls"; -import Image from "../components/Image"; +import Image from "../components/Img"; import Abstract from "./Abstract"; import Airtable from "./Airtable"; import Berrycast from "./Berrycast"; diff --git a/shared/editor/lib/uploadPlugin.ts b/shared/editor/lib/uploadPlugin.ts new file mode 100644 index 000000000..1bb646ba9 --- /dev/null +++ b/shared/editor/lib/uploadPlugin.ts @@ -0,0 +1,84 @@ +import { Plugin } from "prosemirror-state"; +import { getDataTransferFiles } from "@shared/utils/files"; +import insertFiles, { Options } from "../commands/insertFiles"; + +const uploadPlugin = (options: Options) => + new Plugin({ + props: { + handleDOMEvents: { + paste(view, event: ClipboardEvent): boolean { + if ( + (view.props.editable && !view.props.editable(view.state)) || + !options.uploadFile + ) { + return false; + } + + if (!event.clipboardData) { + return false; + } + + // check if we actually pasted any files + const files = Array.prototype.slice + .call(event.clipboardData.items) + .filter((dt: DataTransferItem) => dt.kind !== "string") + .map((dt: DataTransferItem) => dt.getAsFile()) + .filter(Boolean); + + if (files.length === 0) { + return false; + } + + // When copying from Microsoft Office product the clipboard contains + // an image version of the content, check if there is also text and + // use that instead in this scenario. + const html = event.clipboardData.getData("text/html"); + + // Fallback to default paste behavior if the clipboard contains HTML + // Even if there is an image, it's likely to be a screenshot from eg + // Microsoft Suite / Apple Numbers – and not the original content. + if (html.length && !html.includes(" [, "Lorem", "image.jpg"] - * ![](image.jpg "class") -> [, "", "image.jpg", "small"] - * ![Lorem](image.jpg "class") -> [, "Lorem", "image.jpg", "small"] - */ -const IMAGE_INPUT_REGEX = /!\[(?[^\][]*?)]\((?[^\][]*?)(?=“|\))“?(?[^\][”]+)?”?\)$/; - -const uploadPlugin = (options: Options) => - new Plugin({ - props: { - handleDOMEvents: { - paste(view, event: ClipboardEvent): boolean { - if ( - (view.props.editable && !view.props.editable(view.state)) || - !options.uploadFile - ) { - return false; - } - - if (!event.clipboardData) { - return false; - } - - // check if we actually pasted any files - const files = Array.prototype.slice - .call(event.clipboardData.items) - .filter((dt: DataTransferItem) => dt.kind !== "string") - .map((dt: DataTransferItem) => dt.getAsFile()) - .filter(Boolean); - - if (files.length === 0) { - return false; - } - - // When copying from Microsoft Office product the clipboard contains - // an image version of the content, check if there is also text and - // use that instead in this scenario. - const html = event.clipboardData.getData("text/html"); - - // Fallback to default paste behavior if the clipboard contains HTML - // Even if there is an image, it's likely to be a screenshot from eg - // Microsoft Suite / Apple Numbers – and not the original content. - if (html.length && !html.includes(" { +const parseTitleAttribute = (tokenTitle: string): TitleAttributes => { const attributes: TitleAttributes = { layoutClass: undefined, title: undefined, @@ -132,7 +29,7 @@ const getLayoutAndTitle = (tokenTitle: string): TitleAttributes => { return attributes; } - IMAGE_CLASSES.map((className) => { + ["right-50", "left-50", "full-width"].map((className) => { if (tokenTitle.includes(className)) { attributes.layoutClass = className; tokenTitle = tokenTitle.replace(className, ""); @@ -169,13 +66,7 @@ const downloadImageNode = async (node: ProsemirrorNode) => { document.body.removeChild(link); }; -export default class Image extends Node { - options: Options; - - get name() { - return "image"; - } - +export default class Image extends SimpleImage { get schema(): NodeSpec { return { inline: true, @@ -268,74 +159,6 @@ export default class Image extends Node { }; } - handleKeyDown = ({ - node, - getPos, - }: { - node: ProsemirrorNode; - getPos: () => number; - }) => (event: React.KeyboardEvent) => { - // Pressing Enter in the caption field should move the cursor/selection - // below the image - if (event.key === "Enter") { - event.preventDefault(); - - const { view } = this.editor; - const $pos = view.state.doc.resolve(getPos() + node.nodeSize); - view.dispatch( - view.state.tr.setSelection(new TextSelection($pos)).split($pos.pos) - ); - view.focus(); - return; - } - - // Pressing Backspace in an an empty caption field should remove the entire - // image, leaving an empty paragraph - if (event.key === "Backspace" && event.currentTarget.innerText === "") { - const { view } = this.editor; - const $pos = view.state.doc.resolve(getPos()); - const tr = view.state.tr.setSelection(new NodeSelection($pos)); - view.dispatch(tr.deleteSelection()); - view.focus(); - return; - } - }; - - handleBlur = ({ - node, - getPos, - }: { - node: ProsemirrorNode; - getPos: () => number; - }) => (event: React.FocusEvent) => { - const caption = event.currentTarget.innerText; - if (caption === node.attrs.alt) { - return; - } - - const { view } = this.editor; - const { tr } = view.state; - - // update meta on object - const pos = getPos(); - const transaction = tr.setNodeMarkup(pos, undefined, { - ...node.attrs, - alt: caption, - }); - view.dispatch(transaction); - }; - - handleSelect = ({ getPos }: { getPos: () => number }) => ( - event: React.MouseEvent - ) => { - event.preventDefault(); - - const { view } = this.editor; - const $pos = view.state.doc.resolve(getPos()); - const transaction = view.state.tr.setSelection(new NodeSelection($pos)); - view.dispatch(transaction); - }; - handleChangeSize = ({ node, getPos, @@ -364,14 +187,6 @@ export default class Image extends Node { downloadImageNode(node); }; - handleMouseDown = (ev: React.MouseEvent) => { - if (document.activeElement !== ev.currentTarget) { - ev.preventDefault(); - ev.stopPropagation(); - ev.currentTarget.focus(); - } - }; - component = (props: ComponentProps) => { return ( (state: EditorState) => { if (!(state.selection instanceof NodeSelection)) { return false; @@ -458,10 +274,6 @@ export default class Image extends Node { return true; }, - deleteImage: () => (state: EditorState, dispatch: Dispatch) => { - dispatch(state.tr.deleteSelection()); - return true; - }, alignRight: () => (state: EditorState, dispatch: Dispatch) => { if (!(state.selection instanceof NodeSelection)) { return false; @@ -501,46 +313,6 @@ export default class Image extends Node { dispatch(state.tr.setNodeMarkup(selection.from, undefined, attrs)); return true; }, - replaceImage: () => (state: EditorState) => { - if (!(state.selection instanceof NodeSelection)) { - return false; - } - const { view } = this.editor; - const { node } = state.selection; - const { - uploadFile, - onFileUploadStart, - onFileUploadStop, - onShowToast, - } = this.editor.props; - - if (!uploadFile) { - 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"; - inputElement.accept = AttachmentValidation.imageContentTypes.join(", "); - inputElement.onchange = (event) => { - const files = getEventFiles(event); - insertFiles(view, event, state.selection.from, files, { - uploadFile, - onFileUploadStart, - onFileUploadStop, - onShowToast, - dictionary: this.options.dictionary, - replaceExisting: true, - width: node.attrs.width, - }); - }; - inputElement.click(); - return true; - }, alignCenter: () => (state: EditorState, dispatch: Dispatch) => { if (!(state.selection instanceof NodeSelection)) { return false; @@ -550,28 +322,20 @@ export default class Image extends Node { dispatch(state.tr.setNodeMarkup(selection.from, undefined, attrs)); return true; }, - createImage: (attrs: Record) => ( - state: EditorState, - dispatch: Dispatch - ) => { - const { selection } = state; - const position = - selection instanceof TextSelection - ? selection.$cursor?.pos - : selection.$to.pos; - if (position === undefined) { - return false; - } - - const node = type.create(attrs); - const transaction = state.tr.insert(position, node); - dispatch(transaction); - return true; - }, }; } inputRules({ type }: { type: NodeType }) { + /** + * Matches following attributes in Markdown-typed image: [, alt, src, class] + * + * Example: + * ![Lorem](image.jpg) -> [, "Lorem", "image.jpg"] + * ![](image.jpg "class") -> [, "", "image.jpg", "small"] + * ![Lorem](image.jpg "class") -> [, "Lorem", "image.jpg", "small"] + */ + const IMAGE_INPUT_REGEX = /!\[(?[^\][]*?)]\((?[^\][]*?)(?=“|\))“?(?[^\][”]+)?”?\)$/; + return [ new InputRule(IMAGE_INPUT_REGEX, (state, match, start, end) => { const [okay, alt, src, matchedTitle] = match; @@ -584,7 +348,7 @@ export default class Image extends Node { type.create({ src, alt, - ...getLayoutAndTitle(matchedTitle), + ...parseTitleAttribute(matchedTitle), }) ); } @@ -593,335 +357,4 @@ export default class Image extends Node { }), ]; } - - get plugins() { - return [uploadPlaceholderPlugin, uploadPlugin(this.options)]; - } } - -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.ReactElement; - view: EditorView; - } -) => { - const { isSelected, node, isEditable } = props; - const { src, layoutClass } = node.attrs; - const className = layoutClass ? `image image-${layoutClass}` : "image"; - const [contentWidth, setContentWidth] = React.useState( - () => document.body.querySelector("#full-width-container")?.clientWidth || 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, setDocumentWidth] = React.useState( - props.view?.dom.clientWidth || 0 - ); - const maxWidth = layoutClass ? documentWidth / 3 : documentWidth; - const isFullWidth = layoutClass === "full-width"; - - React.useLayoutEffect(() => { - const handleResize = () => { - const contentWidth = - document.body.querySelector("#full-width-container")?.clientWidth || 0; - setContentWidth(contentWidth); - setDocumentWidth(props.view?.dom.clientWidth || 0); - }; - - window.addEventListener("resize", handleResize); - return () => { - window.removeEventListener("resize", handleResize); - }; - }, [props.view]); - - const constrainWidth = (width: number) => { - const minWidth = documentWidth * 0.1; - return Math.round(Math.min(maxWidth, Math.max(width, minWidth))); - }; - - const handlePointerMove = (event: PointerEvent) => { - event.preventDefault(); - - let diff; - if (dragging === "left") { - diff = offset - event.pageX; - } else { - diff = event.pageX - offset; - } - - const grid = documentWidth / 10; - const newWidth = sizeAtDragStart.width + diff * 2; - const widthOnGrid = Math.round(newWidth / grid) * grid; - const constrainedWidth = constrainWidth(widthOnGrid); - - const aspectRatio = naturalHeight / naturalWidth; - setSize({ - width: constrainedWidth, - height: naturalWidth - ? Math.round(constrainedWidth * aspectRatio) - : undefined, - }); - }; - - const handlePointerUp = (event: PointerEvent) => { - event.preventDefault(); - event.stopPropagation(); - - setOffset(0); - setDragging(undefined); - props.onChangeSize(size); - - document.removeEventListener("mousemove", handlePointerMove); - }; - - const handlePointerDown = (dragging: "left" | "right") => ( - event: React.PointerEvent - ) => { - event.preventDefault(); - event.stopPropagation(); - setSizeAtDragStart({ - width: constrainWidth(size.width), - height: size.height, - }); - 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("pointermove", handlePointerMove); - document.addEventListener("pointerup", handlePointerUp); - } - - return () => { - document.body.style.cursor = "initial"; - document.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("pointermove", handlePointerMove); - document.removeEventListener("pointerup", handlePointerUp); - }; - }, [dragging, handlePointerMove, handlePointerUp]); - - const widthStyle = isFullWidth - ? { width: contentWidth } - : { width: size.width || "auto" }; - - const containerStyle = isFullWidth - ? ({ - "--offset": `${-(contentWidth - documentWidth) / 2}px`, - } as React.CSSProperties) - : undefined; - - return ( -
- - {!dragging && size.width > 60 && size.height > 60 && ( - - )} - - ) => { - // 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, - })); - } - }} - /> - - {isEditable && !isFullWidth && ( - <> - - - - )} - - {isFullWidth - ? React.cloneElement(props.children, { style: widthStyle }) - : 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; - right: 8px; - border: 0; - margin: 0; - padding: 0; - border-radius: 4px; - background: ${(props) => props.theme.background}; - color: ${(props) => props.theme.textSecondary}; - width: 24px; - height: 24px; - display: inline-block; - cursor: var(--pointer); - opacity: 0; - transition: opacity 150ms ease-in-out; - - &:active { - transform: scale(0.98); - } - - &:hover { - color: ${(props) => props.theme.text}; - opacity: 1; - } -`; - -const Caption = styled.p` - border: 0; - display: block; - font-style: italic; - font-weight: normal; - color: ${(props) => props.theme.textSecondary}; - padding: 8px 0 4px; - line-height: 16px; - text-align: center; - min-height: 1em; - outline: none; - background: none; - resize: none; - user-select: text; - margin: 0 !important; - cursor: text; - - ${breakpoint("tablet")` - font-size: 13px; - `}; - - &:empty:not(:focus) { - display: none; - } - - &:empty:before { - color: ${(props) => props.theme.placeholder}; - content: attr(data-caption); - pointer-events: none; - } -`; - -const ImageWrapper = styled.div<{ isFullWidth: 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 ? "0ms" : "150ms")}; - transition-timing-function: ease-in-out; - overflow: hidden; - - img { - transition-property: width, height; - transition-duration: ${(props) => (props.isFullWidth ? "0ms" : "150ms")}; - transition-timing-function: ease-in-out; - } - - &:hover { - ${Button} { - opacity: 0.9; - } - - ${ResizeLeft}, ${ResizeRight} { - opacity: 1; - } - } - - &.ProseMirror-selectednode + ${Caption} { - display: block; - } -`; diff --git a/shared/editor/nodes/SimpleImage.tsx b/shared/editor/nodes/SimpleImage.tsx new file mode 100644 index 000000000..a3153c0fa --- /dev/null +++ b/shared/editor/nodes/SimpleImage.tsx @@ -0,0 +1,290 @@ +import Token from "markdown-it/lib/token"; +import { InputRule } from "prosemirror-inputrules"; +import { Node as ProsemirrorNode, NodeSpec, NodeType } from "prosemirror-model"; +import { TextSelection, NodeSelection, EditorState } from "prosemirror-state"; +import * as React from "react"; +import { getEventFiles } from "../../utils/files"; +import { sanitizeUrl } from "../../utils/urls"; +import { AttachmentValidation } from "../../validations"; +import insertFiles, { Options } from "../commands/insertFiles"; +import { default as ImageComponent } from "../components/Image"; +import { MarkdownSerializerState } from "../lib/markdown/serializer"; +import uploadPlaceholderPlugin from "../lib/uploadPlaceholder"; +import uploadPlugin from "../lib/uploadPlugin"; +import { ComponentProps, Dispatch } from "../types"; +import Node from "./Node"; + +export default class SimpleImage extends Node { + options: Options; + + get name() { + return "image"; + } + + get schema(): NodeSpec { + return { + inline: true, + attrs: { + src: { + default: "", + }, + alt: { + default: null, + }, + }, + content: "text*", + marks: "", + group: "inline", + selectable: true, + draggable: true, + parseDOM: [ + { + tag: "div[class~=image]", + getAttrs: (dom: HTMLDivElement) => { + const img = dom.getElementsByTagName("img")[0]; + return { + src: img?.getAttribute("src"), + alt: img?.getAttribute("alt"), + title: img?.getAttribute("title"), + }; + }, + }, + { + tag: "img", + getAttrs: (dom: HTMLImageElement) => { + return { + src: dom.getAttribute("src"), + alt: dom.getAttribute("alt"), + title: dom.getAttribute("title"), + }; + }, + }, + ], + toDOM: (node) => { + return [ + "div", + { + class: "image", + }, + [ + "img", + { + ...node.attrs, + src: sanitizeUrl(node.attrs.src), + contentEditable: "false", + }, + ], + ]; + }, + }; + } + + handleKeyDown = ({ + node, + getPos, + }: { + node: ProsemirrorNode; + getPos: () => number; + }) => (event: React.KeyboardEvent) => { + // Pressing Enter in the caption field should move the cursor/selection + // below the image + if (event.key === "Enter") { + event.preventDefault(); + + const { view } = this.editor; + const $pos = view.state.doc.resolve(getPos() + node.nodeSize); + view.dispatch( + view.state.tr.setSelection(new TextSelection($pos)).split($pos.pos) + ); + view.focus(); + return; + } + + // Pressing Backspace in an an empty caption field should remove the entire + // image, leaving an empty paragraph + if (event.key === "Backspace" && event.currentTarget.innerText === "") { + const { view } = this.editor; + const $pos = view.state.doc.resolve(getPos()); + const tr = view.state.tr.setSelection(new NodeSelection($pos)); + view.dispatch(tr.deleteSelection()); + view.focus(); + return; + } + }; + + handleBlur = ({ + node, + getPos, + }: { + node: ProsemirrorNode; + getPos: () => number; + }) => (event: React.FocusEvent) => { + const caption = event.currentTarget.innerText; + if (caption === node.attrs.alt) { + return; + } + + const { view } = this.editor; + const { tr } = view.state; + + // update meta on object + const pos = getPos(); + const transaction = tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + alt: caption, + }); + view.dispatch(transaction); + }; + + handleSelect = ({ getPos }: { getPos: () => number }) => ( + event: React.MouseEvent + ) => { + event.preventDefault(); + + const { view } = this.editor; + const $pos = view.state.doc.resolve(getPos()); + const transaction = view.state.tr.setSelection(new NodeSelection($pos)); + view.dispatch(transaction); + }; + + handleMouseDown = (ev: React.MouseEvent) => { + if (document.activeElement !== ev.currentTarget) { + ev.preventDefault(); + ev.stopPropagation(); + ev.currentTarget.focus(); + } + }; + + component = (props: ComponentProps) => { + return ; + }; + + toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { + state.write( + " ![" + + state.esc((node.attrs.alt || "").replace("\n", "") || "", false) + + "](" + + state.esc(node.attrs.src || "", false) + + ")" + ); + } + + parseMarkdown() { + return { + node: "image", + getAttrs: (token: Token) => { + return { + src: token.attrGet("src"), + alt: + (token?.children && + token.children[0] && + token.children[0].content) || + null, + }; + }, + }; + } + + commands({ type }: { type: NodeType }) { + return { + deleteImage: () => (state: EditorState, dispatch: Dispatch) => { + dispatch(state.tr.deleteSelection()); + return true; + }, + replaceImage: () => (state: EditorState) => { + if (!(state.selection instanceof NodeSelection)) { + return false; + } + const { view } = this.editor; + const { node } = state.selection; + const { + uploadFile, + onFileUploadStart, + onFileUploadStop, + onShowToast, + } = this.editor.props; + + if (!uploadFile) { + 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"; + inputElement.accept = AttachmentValidation.imageContentTypes.join(", "); + inputElement.onchange = (event) => { + const files = getEventFiles(event); + insertFiles(view, event, state.selection.from, files, { + uploadFile, + onFileUploadStart, + onFileUploadStop, + onShowToast, + dictionary: this.options.dictionary, + replaceExisting: true, + width: node.attrs.width, + }); + }; + inputElement.click(); + return true; + }, + createImage: (attrs: Record) => ( + state: EditorState, + dispatch: Dispatch + ) => { + const { selection } = state; + const position = + selection instanceof TextSelection + ? selection.$cursor?.pos + : selection.$to.pos; + if (position === undefined) { + return false; + } + + const node = type.create(attrs); + const transaction = state.tr.insert(position, node); + dispatch(transaction); + return true; + }, + }; + } + + inputRules({ type }: { type: NodeType }) { + /** + * Matches following attributes in Markdown-typed image: [, alt, src, class] + * + * Example: + * ![Lorem](image.jpg) -> [, "Lorem", "image.jpg"] + * ![](image.jpg "class") -> [, "", "image.jpg", "small"] + * ![Lorem](image.jpg "class") -> [, "Lorem", "image.jpg", "small"] + */ + const IMAGE_INPUT_REGEX = /!\[(?[^\][]*?)]\((?[^\][]*?)(?=“|\))“?(?[^\][”]+)?”?\)$/; + + return [ + new InputRule(IMAGE_INPUT_REGEX, (state, match, start, end) => { + const [okay, alt, src] = match; + const { tr } = state; + + if (okay) { + tr.replaceWith( + start - 1, + end, + type.create({ + src, + alt, + }) + ); + } + + return tr; + }), + ]; + } + + get plugins() { + return [uploadPlaceholderPlugin, uploadPlugin(this.options)]; + } +} diff --git a/shared/editor/nodes/index.ts b/shared/editor/nodes/index.ts index ccc5cf697..8becd9d01 100644 --- a/shared/editor/nodes/index.ts +++ b/shared/editor/nodes/index.ts @@ -43,6 +43,7 @@ import Node from "./Node"; import Notice from "./Notice"; import OrderedList from "./OrderedList"; import Paragraph from "./Paragraph"; +import SimpleImage from "./SimpleImage"; import Table from "./Table"; import TableCell from "./TableCell"; import TableHeadCell from "./TableHeadCell"; @@ -60,7 +61,7 @@ export const basicExtensions: Nodes = [ Paragraph, Emoji, Text, - Image, + SimpleImage, Bold, Code, Italic, @@ -83,7 +84,8 @@ export const basicExtensions: Nodes = [ * editors that need advanced formatting. */ export const richExtensions: Nodes = [ - ...basicExtensions, + ...basicExtensions.filter((n) => n !== SimpleImage), + Image, HardBreak, CodeBlock, CodeFence,