Files
outline/app/components/Editor.tsx
2021-12-12 18:44:32 -08:00

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);