fix: Remove image float and positioning options in comments (#5014)

* cleanup

* Split Image into SimpleImage

* ts
This commit is contained in:
Tom Moor
2023-03-09 22:17:16 -05:00
committed by GitHub
parent 8fc4cb846a
commit e786888dfb
17 changed files with 764 additions and 609 deletions

View File

@@ -22,7 +22,7 @@ import {
} from "outline-icons"; } from "outline-icons";
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; 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 { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary"; import { Dictionary } from "~/hooks/useDictionary";
import { metaDisplay } from "~/utils/keyboard"; import { metaDisplay } from "~/utils/keyboard";

View File

@@ -1,14 +1,346 @@
import { DownloadIcon } from "outline-icons";
import type { EditorView } from "prosemirror-view";
import * as React from "react"; 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 = { type DragDirection = "left" | "right";
alt: string;
src: string; const Image = (
title?: string; props: ComponentProps & {
width?: number; onClick: (event: React.MouseEvent<HTMLDivElement>) => void;
height?: number; onDownload?: (event: React.MouseEvent<HTMLButtonElement>) => 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<DragDirection>();
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<HTMLDivElement>
) => {
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 (
<div contentEditable={false} className={className} style={containerStyle}>
<ImageWrapper
isFullWidth={isFullWidth}
className={isSelected || dragging ? "ProseMirror-selectednode" : ""}
onClick={dragging ? undefined : props.onClick}
style={widthStyle}
>
{!dragging && size.width > 60 && size.height > 60 && props.onDownload && (
<Button onClick={props.onDownload}>
<DownloadIcon color="currentColor" />
</Button>
)}
<ImageZoom zoomMargin={24}>
<img
style={widthStyle}
src={sanitizeUrl(src) ?? ""}
onLoad={(ev: React.SyntheticEvent<HTMLImageElement>) => {
// For some SVG's Firefox does not provide the naturalWidth, in this
// rare case we need to provide a default so that the image can be
// seen and is not sized to 0px
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,
}));
}
}}
/>
</ImageZoom>
{isEditable && !isFullWidth && isResizable && (
<>
<ResizeLeft
onPointerDown={handlePointerDown("left")}
$dragging={!!dragging}
/>
<ResizeRight
onPointerDown={handlePointerDown("right")}
$dragging={!!dragging}
/>
</>
)}
</ImageWrapper>
{isFullWidth && props.children
? React.cloneElement(props.children, { style: widthStyle })
: props.children}
</div>
);
}; };
export default function Image({ src, alt, ...rest }: Props) { export const Caption = styled.p`
return <img src={cdnPath(src)} alt={alt} {...rest} />; 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;

View File

@@ -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 <img src={cdnPath(src)} alt={alt} {...rest} />;
}

View File

@@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import Frame from "../components/Frame"; import Frame from "../components/Frame";
import Image from "../components/Image"; import Image from "../components/Img";
import { EmbedProps as Props } from "."; import { EmbedProps as Props } from ".";
function Diagrams(props: Props) { function Diagrams(props: Props) {

View File

@@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import Frame from "../components/Frame"; import Frame from "../components/Frame";
import Image from "../components/Image"; import Image from "../components/Img";
import { EmbedProps as Props } from "."; import { EmbedProps as Props } from ".";
function GoogleDocs(props: Props) { function GoogleDocs(props: Props) {

View File

@@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import Frame from "../components/Frame"; import Frame from "../components/Frame";
import Image from "../components/Image"; import Image from "../components/Img";
import { EmbedProps as Props } from "."; import { EmbedProps as Props } from ".";
function GoogleDrawings(props: Props) { function GoogleDrawings(props: Props) {

View File

@@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import Frame from "../components/Frame"; import Frame from "../components/Frame";
import Image from "../components/Image"; import Image from "../components/Img";
import { EmbedProps as Props } from "."; import { EmbedProps as Props } from ".";
function GoogleDrive(props: Props) { function GoogleDrive(props: Props) {

View File

@@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import Frame from "../components/Frame"; import Frame from "../components/Frame";
import Image from "../components/Image"; import Image from "../components/Img";
import { EmbedProps as Props } from "."; import { EmbedProps as Props } from ".";
function GoogleForms(props: Props) { function GoogleForms(props: Props) {

View File

@@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import Frame from "../components/Frame"; import Frame from "../components/Frame";
import Image from "../components/Image"; import Image from "../components/Img";
import { EmbedProps as Props } from "."; import { EmbedProps as Props } from ".";
function GoogleLookerStudio(props: Props) { function GoogleLookerStudio(props: Props) {

View File

@@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import Frame from "../components/Frame"; import Frame from "../components/Frame";
import Image from "../components/Image"; import Image from "../components/Img";
import { EmbedProps as Props } from "."; import { EmbedProps as Props } from ".";
function GoogleSheets(props: Props) { function GoogleSheets(props: Props) {

View File

@@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import Frame from "../components/Frame"; import Frame from "../components/Frame";
import Image from "../components/Image"; import Image from "../components/Img";
import { EmbedProps as Props } from "."; import { EmbedProps as Props } from ".";
function GoogleSlides(props: Props) { function GoogleSlides(props: Props) {

View File

@@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import Frame from "../components/Frame"; import Frame from "../components/Frame";
import Image from "../components/Image"; import Image from "../components/Img";
import { EmbedProps as Props } from "."; import { EmbedProps as Props } from ".";
function Grist(props: Props) { function Grist(props: Props) {

View File

@@ -4,7 +4,7 @@ import styled from "styled-components";
import { IntegrationType } from "../../types"; import { IntegrationType } from "../../types";
import type { IntegrationSettings } from "../../types"; import type { IntegrationSettings } from "../../types";
import { urlRegex } from "../../utils/urls"; import { urlRegex } from "../../utils/urls";
import Image from "../components/Image"; import Image from "../components/Img";
import Abstract from "./Abstract"; import Abstract from "./Abstract";
import Airtable from "./Airtable"; import Airtable from "./Airtable";
import Berrycast from "./Berrycast"; import Berrycast from "./Berrycast";

View File

@@ -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("<img")) {
return false;
}
const { tr } = view.state;
if (!tr.selection.empty) {
tr.deleteSelection();
}
const pos = tr.selection.from;
insertFiles(view, event, pos, files, options);
return true;
},
drop(view, event: DragEvent): boolean {
if (
(view.props.editable && !view.props.editable(view.state)) ||
!options.uploadFile
) {
return false;
}
// filter to only include image files
const files = getDataTransferFiles(event);
if (files.length === 0) {
return false;
}
// grab the position in the document for the cursor
const result = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (result) {
insertFiles(view, event, result.pos, files, options);
return true;
}
return false;
},
},
},
});
export default uploadPlugin;

View File

@@ -1,117 +1,14 @@
import Token from "markdown-it/lib/token"; import Token from "markdown-it/lib/token";
import { DownloadIcon } from "outline-icons";
import { InputRule } from "prosemirror-inputrules"; import { InputRule } from "prosemirror-inputrules";
import { Node as ProsemirrorNode, NodeSpec, NodeType } from "prosemirror-model"; import { Node as ProsemirrorNode, NodeSpec, NodeType } from "prosemirror-model";
import { import { NodeSelection, EditorState } from "prosemirror-state";
Plugin,
TextSelection,
NodeSelection,
EditorState,
} from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import * as React from "react"; import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { getDataTransferFiles, getEventFiles } from "../../utils/files";
import { sanitizeUrl } from "../../utils/urls"; import { sanitizeUrl } from "../../utils/urls";
import { AttachmentValidation } from "../../validations"; import { default as ImageComponent, Caption } from "../components/Image";
import insertFiles, { Options } from "../commands/insertFiles";
import ImageZoom from "../components/ImageZoom";
import { MarkdownSerializerState } from "../lib/markdown/serializer"; import { MarkdownSerializerState } from "../lib/markdown/serializer";
import uploadPlaceholderPlugin from "../lib/uploadPlaceholder";
import { ComponentProps, Dispatch } from "../types"; import { ComponentProps, Dispatch } from "../types";
import Node from "./Node"; import SimpleImage from "./SimpleImage";
/**
* 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 = /!\[(?<alt>[^\][]*?)]\((?<filename>[^\][]*?)(?=“|\))“?(?<layoutclass>[^\][”]+)?”?\)$/;
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("<img")) {
return false;
}
const { tr } = view.state;
if (!tr.selection.empty) {
tr.deleteSelection();
}
const pos = tr.selection.from;
insertFiles(view, event, pos, files, options);
return true;
},
drop(view, event: DragEvent): boolean {
if (
(view.props.editable && !view.props.editable(view.state)) ||
!options.uploadFile
) {
return false;
}
// filter to only include image files
const files = getDataTransferFiles(event);
if (files.length === 0) {
return false;
}
// grab the position in the document for the cursor
const result = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (result) {
insertFiles(view, event, result.pos, files, options);
return true;
}
return false;
},
},
},
});
const IMAGE_CLASSES = ["right-50", "left-50", "full-width"];
const imageSizeRegex = /\s=(\d+)?x(\d+)?$/; const imageSizeRegex = /\s=(\d+)?x(\d+)?$/;
type TitleAttributes = { type TitleAttributes = {
@@ -121,7 +18,7 @@ type TitleAttributes = {
height?: number; height?: number;
}; };
const getLayoutAndTitle = (tokenTitle: string): TitleAttributes => { const parseTitleAttribute = (tokenTitle: string): TitleAttributes => {
const attributes: TitleAttributes = { const attributes: TitleAttributes = {
layoutClass: undefined, layoutClass: undefined,
title: undefined, title: undefined,
@@ -132,7 +29,7 @@ const getLayoutAndTitle = (tokenTitle: string): TitleAttributes => {
return attributes; return attributes;
} }
IMAGE_CLASSES.map((className) => { ["right-50", "left-50", "full-width"].map((className) => {
if (tokenTitle.includes(className)) { if (tokenTitle.includes(className)) {
attributes.layoutClass = className; attributes.layoutClass = className;
tokenTitle = tokenTitle.replace(className, ""); tokenTitle = tokenTitle.replace(className, "");
@@ -169,13 +66,7 @@ const downloadImageNode = async (node: ProsemirrorNode) => {
document.body.removeChild(link); document.body.removeChild(link);
}; };
export default class Image extends Node { export default class Image extends SimpleImage {
options: Options;
get name() {
return "image";
}
get schema(): NodeSpec { get schema(): NodeSpec {
return { return {
inline: true, inline: true,
@@ -268,74 +159,6 @@ export default class Image extends Node {
}; };
} }
handleKeyDown = ({
node,
getPos,
}: {
node: ProsemirrorNode;
getPos: () => number;
}) => (event: React.KeyboardEvent<HTMLSpanElement>) => {
// 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<HTMLSpanElement>) => {
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 = ({ handleChangeSize = ({
node, node,
getPos, getPos,
@@ -364,14 +187,6 @@ export default class Image extends Node {
downloadImageNode(node); downloadImageNode(node);
}; };
handleMouseDown = (ev: React.MouseEvent<HTMLParagraphElement>) => {
if (document.activeElement !== ev.currentTarget) {
ev.preventDefault();
ev.stopPropagation();
ev.currentTarget.focus();
}
};
component = (props: ComponentProps) => { component = (props: ComponentProps) => {
return ( return (
<ImageComponent <ImageComponent
@@ -436,7 +251,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") || ""), ...parseTitleAttribute(token?.attrGet("title") || ""),
}; };
}, },
}; };
@@ -444,6 +259,7 @@ export default class Image extends Node {
commands({ type }: { type: NodeType }) { commands({ type }: { type: NodeType }) {
return { return {
...super.commands({ type }),
downloadImage: () => (state: EditorState) => { downloadImage: () => (state: EditorState) => {
if (!(state.selection instanceof NodeSelection)) { if (!(state.selection instanceof NodeSelection)) {
return false; return false;
@@ -458,10 +274,6 @@ export default class Image extends Node {
return true; return true;
}, },
deleteImage: () => (state: EditorState, dispatch: Dispatch) => {
dispatch(state.tr.deleteSelection());
return true;
},
alignRight: () => (state: EditorState, dispatch: Dispatch) => { alignRight: () => (state: EditorState, dispatch: Dispatch) => {
if (!(state.selection instanceof NodeSelection)) { if (!(state.selection instanceof NodeSelection)) {
return false; return false;
@@ -501,46 +313,6 @@ export default class Image extends Node {
dispatch(state.tr.setNodeMarkup(selection.from, undefined, attrs)); dispatch(state.tr.setNodeMarkup(selection.from, undefined, attrs));
return true; 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) => { alignCenter: () => (state: EditorState, dispatch: Dispatch) => {
if (!(state.selection instanceof NodeSelection)) { if (!(state.selection instanceof NodeSelection)) {
return false; return false;
@@ -550,28 +322,20 @@ export default class Image extends Node {
dispatch(state.tr.setNodeMarkup(selection.from, undefined, attrs)); dispatch(state.tr.setNodeMarkup(selection.from, undefined, attrs));
return true; return true;
}, },
createImage: (attrs: Record<string, any>) => (
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 }) { 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 = /!\[(?<alt>[^\][]*?)]\((?<filename>[^\][]*?)(?=“|\))“?(?<layoutclass>[^\][”]+)?”?\)$/;
return [ return [
new InputRule(IMAGE_INPUT_REGEX, (state, match, start, end) => { new InputRule(IMAGE_INPUT_REGEX, (state, match, start, end) => {
const [okay, alt, src, matchedTitle] = match; const [okay, alt, src, matchedTitle] = match;
@@ -584,7 +348,7 @@ export default class Image extends Node {
type.create({ type.create({
src, src,
alt, 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<HTMLDivElement>) => void;
onDownload: (event: React.MouseEvent<HTMLButtonElement>) => 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<DragDirection>();
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<HTMLDivElement>
) => {
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 (
<div contentEditable={false} className={className} style={containerStyle}>
<ImageWrapper
isFullWidth={isFullWidth}
className={isSelected || dragging ? "ProseMirror-selectednode" : ""}
onClick={dragging ? undefined : props.onClick}
style={widthStyle}
>
{!dragging && size.width > 60 && size.height > 60 && (
<Button onClick={props.onDownload}>
<DownloadIcon color="currentColor" />
</Button>
)}
<ImageZoom zoomMargin={24}>
<img
style={widthStyle}
src={sanitizeUrl(src) ?? ""}
onLoad={(ev: React.SyntheticEvent<HTMLImageElement>) => {
// For some SVG's Firefox does not provide the naturalWidth, in this
// rare case we need to provide a default so that the image can be
// seen and is not sized to 0px
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,
}));
}
}}
/>
</ImageZoom>
{isEditable && !isFullWidth && (
<>
<ResizeLeft
onPointerDown={handlePointerDown("left")}
$dragging={!!dragging}
/>
<ResizeRight
onPointerDown={handlePointerDown("right")}
$dragging={!!dragging}
/>
</>
)}
</ImageWrapper>
{isFullWidth
? React.cloneElement(props.children, { style: widthStyle })
: props.children}
</div>
);
};
const ResizeLeft = styled.div<{ $dragging: boolean }>`
cursor: ew-resize;
position: absolute;
left: -4px;
top: 0;
bottom: 0;
width: 8px;
user-select: none;
opacity: ${(props) => (props.$dragging ? 1 : 0)};
transition: opacity 150ms ease-in-out;
&:after {
content: "";
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
width: 6px;
height: 15%;
min-height: 20px;
border-radius: 4px;
background: ${(props) => props.theme.toolbarBackground};
box-shadow: 0 0 0 1px ${(props) => props.theme.toolbarItem};
opacity: 0.75;
}
`;
const ResizeRight = styled(ResizeLeft)`
left: initial;
right: -4px;
&:after {
left: initial;
right: 8px;
}
`;
const Button = styled.button`
position: absolute;
top: 8px;
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;
}
`;

View File

@@ -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<HTMLSpanElement>) => {
// 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<HTMLSpanElement>) => {
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<HTMLParagraphElement>) => {
if (document.activeElement !== ev.currentTarget) {
ev.preventDefault();
ev.stopPropagation();
ev.currentTarget.focus();
}
};
component = (props: ComponentProps) => {
return <ImageComponent {...props} onClick={this.handleSelect(props)} />;
};
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<string, any>) => (
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 = /!\[(?<alt>[^\][]*?)]\((?<filename>[^\][]*?)(?=“|\))“?(?<layoutclass>[^\][”]+)?”?\)$/;
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)];
}
}

View File

@@ -43,6 +43,7 @@ import Node from "./Node";
import Notice from "./Notice"; import Notice from "./Notice";
import OrderedList from "./OrderedList"; import OrderedList from "./OrderedList";
import Paragraph from "./Paragraph"; import Paragraph from "./Paragraph";
import SimpleImage from "./SimpleImage";
import Table from "./Table"; import Table from "./Table";
import TableCell from "./TableCell"; import TableCell from "./TableCell";
import TableHeadCell from "./TableHeadCell"; import TableHeadCell from "./TableHeadCell";
@@ -60,7 +61,7 @@ export const basicExtensions: Nodes = [
Paragraph, Paragraph,
Emoji, Emoji,
Text, Text,
Image, SimpleImage,
Bold, Bold,
Code, Code,
Italic, Italic,
@@ -83,7 +84,8 @@ export const basicExtensions: Nodes = [
* editors that need advanced formatting. * editors that need advanced formatting.
*/ */
export const richExtensions: Nodes = [ export const richExtensions: Nodes = [
...basicExtensions, ...basicExtensions.filter((n) => n !== SimpleImage),
Image,
HardBreak, HardBreak,
CodeBlock, CodeBlock,
CodeFence, CodeFence,