fix: Remove image float and positioning options in comments (#5014)
* cleanup * Split Image into SimpleImage * ts
This commit is contained in:
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
14
shared/editor/components/Img.tsx
Normal file
14
shared/editor/components/Img.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
84
shared/editor/lib/uploadPlugin.ts
Normal file
84
shared/editor/lib/uploadPlugin.ts
Normal 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;
|
||||||
@@ -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"]
|
|
||||||
*  -> [, "", "image.jpg", "small"]
|
|
||||||
*  -> [, "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"]
|
||||||
|
*  -> [, "", "image.jpg", "small"]
|
||||||
|
*  -> [, "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;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|||||||
290
shared/editor/nodes/SimpleImage.tsx
Normal file
290
shared/editor/nodes/SimpleImage.tsx
Normal 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(
|
||||||
|
"  +
|
||||||
|
")"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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"]
|
||||||
|
*  -> [, "", "image.jpg", "small"]
|
||||||
|
*  -> [, "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)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user