241 lines
5.5 KiB
TypeScript
241 lines
5.5 KiB
TypeScript
import { lighten } from "polished";
|
|
import * as React from "react";
|
|
import { Props as EditorProps } from "rich-markdown-editor";
|
|
import { EmbedDescriptor } from "rich-markdown-editor/dist/types";
|
|
import styled, { useTheme } from "styled-components";
|
|
import { Optional } from "utility-types";
|
|
import embeds from "@shared/embeds";
|
|
import { light } from "@shared/theme";
|
|
import ErrorBoundary from "~/components/ErrorBoundary";
|
|
import Tooltip from "~/components/Tooltip";
|
|
import useDictionary from "~/hooks/useDictionary";
|
|
import useMediaQuery from "~/hooks/useMediaQuery";
|
|
import useToasts from "~/hooks/useToasts";
|
|
import history from "~/utils/history";
|
|
import { isModKey } from "~/utils/keyboard";
|
|
import { uploadFile } from "~/utils/uploadFile";
|
|
import { isInternalUrl, isHash } from "~/utils/urls";
|
|
|
|
const RichMarkdownEditor = React.lazy(
|
|
() =>
|
|
import(
|
|
/* webpackChunkName: "rich-markdown-editor" */
|
|
"rich-markdown-editor"
|
|
)
|
|
);
|
|
|
|
const EMPTY_ARRAY: EmbedDescriptor[] = [];
|
|
|
|
export type Props = Optional<
|
|
EditorProps,
|
|
"placeholder" | "defaultValue" | "tooltip" | "onClickLink" | "embeds"
|
|
> & {
|
|
shareId?: string | undefined;
|
|
disableEmbeds?: boolean;
|
|
grow?: boolean;
|
|
onSynced?: () => Promise<void>;
|
|
onPublish?: (event: React.MouseEvent) => any;
|
|
};
|
|
|
|
function Editor(props: Props, ref: React.Ref<any>) {
|
|
const { id, shareId } = props;
|
|
const theme = useTheme();
|
|
const { showToast } = useToasts();
|
|
const isPrinting = useMediaQuery("print");
|
|
const dictionary = useDictionary();
|
|
|
|
const onUploadImage = React.useCallback(
|
|
async (file: File) => {
|
|
const result = await uploadFile(file, {
|
|
documentId: id,
|
|
});
|
|
return result.url;
|
|
},
|
|
[id]
|
|
);
|
|
|
|
const onClickLink = React.useCallback(
|
|
(href: string, event: MouseEvent) => {
|
|
// on page hash
|
|
if (isHash(href)) {
|
|
window.location.href = href;
|
|
return;
|
|
}
|
|
|
|
if (isInternalUrl(href) && !isModKey(event) && !event.shiftKey) {
|
|
// relative
|
|
let navigateTo = href;
|
|
|
|
// probably absolute
|
|
if (href[0] !== "/") {
|
|
try {
|
|
const url = new URL(href);
|
|
navigateTo = url.pathname + url.hash;
|
|
} catch (err) {
|
|
navigateTo = href;
|
|
}
|
|
}
|
|
|
|
if (shareId) {
|
|
navigateTo = `/share/${shareId}${navigateTo}`;
|
|
}
|
|
|
|
history.push(navigateTo);
|
|
} else if (href) {
|
|
window.open(href, "_blank");
|
|
}
|
|
},
|
|
[shareId]
|
|
);
|
|
|
|
const onShowToast = React.useCallback(
|
|
(message: string) => {
|
|
showToast(message);
|
|
},
|
|
[showToast]
|
|
);
|
|
|
|
return (
|
|
<ErrorBoundary reloadOnChunkMissing>
|
|
<StyledEditor
|
|
ref={ref}
|
|
uploadImage={onUploadImage}
|
|
onShowToast={onShowToast}
|
|
embeds={props.disableEmbeds ? EMPTY_ARRAY : embeds}
|
|
dictionary={dictionary}
|
|
{...props}
|
|
tooltip={EditorTooltip}
|
|
onClickLink={onClickLink}
|
|
placeholder={props.placeholder || ""}
|
|
defaultValue={props.defaultValue || ""}
|
|
theme={isPrinting ? light : theme}
|
|
/>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
|
|
const StyledEditor = styled(RichMarkdownEditor)<{ grow?: boolean }>`
|
|
flex-grow: ${(props) => (props.grow ? 1 : 0)};
|
|
justify-content: start;
|
|
|
|
> div {
|
|
background: transparent;
|
|
}
|
|
|
|
& * {
|
|
box-sizing: content-box;
|
|
}
|
|
|
|
.notice-block.tip,
|
|
.notice-block.warning {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.heading-anchor {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.heading-name {
|
|
pointer-events: none;
|
|
display: block;
|
|
position: relative;
|
|
top: -60px;
|
|
visibility: hidden;
|
|
}
|
|
|
|
.heading-name:first-child,
|
|
.heading-name:first-child + .ProseMirror-yjs-cursor {
|
|
& + h1,
|
|
& + h2,
|
|
& + h3,
|
|
& + h4 {
|
|
margin-top: 0;
|
|
}
|
|
}
|
|
|
|
p {
|
|
a {
|
|
color: ${(props) => props.theme.text};
|
|
border-bottom: 1px solid ${(props) => lighten(0.5, props.theme.text)};
|
|
text-decoration: none !important;
|
|
font-weight: 500;
|
|
|
|
&:hover {
|
|
border-bottom: 1px solid ${(props) => props.theme.text};
|
|
text-decoration: none;
|
|
}
|
|
}
|
|
}
|
|
|
|
.ProseMirror {
|
|
& > .ProseMirror-yjs-cursor {
|
|
display: none;
|
|
}
|
|
|
|
.ProseMirror-yjs-cursor {
|
|
position: relative;
|
|
margin-left: -1px;
|
|
margin-right: -1px;
|
|
border-left: 1px solid black;
|
|
border-right: 1px solid black;
|
|
height: 1em;
|
|
word-break: normal;
|
|
|
|
&:after {
|
|
content: "";
|
|
display: block;
|
|
position: absolute;
|
|
left: -8px;
|
|
right: -8px;
|
|
top: 0;
|
|
bottom: 0;
|
|
}
|
|
> div {
|
|
opacity: 0;
|
|
transition: opacity 100ms ease-in-out;
|
|
position: absolute;
|
|
top: -1.8em;
|
|
font-size: 13px;
|
|
background-color: rgb(250, 129, 0);
|
|
font-style: normal;
|
|
line-height: normal;
|
|
user-select: none;
|
|
white-space: nowrap;
|
|
color: white;
|
|
padding: 2px 6px;
|
|
font-weight: 500;
|
|
border-radius: 4px;
|
|
pointer-events: none;
|
|
left: -1px;
|
|
}
|
|
|
|
&:hover {
|
|
> div {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
&.show-cursor-names .ProseMirror-yjs-cursor > div {
|
|
opacity: 1;
|
|
}
|
|
`;
|
|
|
|
type TooltipProps = {
|
|
children: React.ReactNode;
|
|
tooltip: string;
|
|
};
|
|
|
|
const EditorTooltip = ({ children, tooltip, ...props }: TooltipProps) => (
|
|
<Tooltip offset="0, 16" delay={150} tooltip={tooltip} {...props}>
|
|
<TooltipContent>{children}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
|
|
const TooltipContent = styled.span`
|
|
outline: none;
|
|
`;
|
|
|
|
export default React.forwardRef<typeof Editor, Props>(Editor);
|