import { formatDistanceToNow } from "date-fns"; import { deburr, sortBy } from "lodash"; import { observer } from "mobx-react"; import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model"; import { TextSelection } from "prosemirror-state"; import * as React from "react"; import { mergeRefs } from "react-merge-refs"; import { Optional } from "utility-types"; import insertFiles from "@shared/editor/commands/insertFiles"; import { Heading } from "@shared/editor/lib/getHeadings"; import { AttachmentPreset } from "@shared/types"; import { getDataTransferFiles } from "@shared/utils/files"; import parseDocumentSlug from "@shared/utils/parseDocumentSlug"; import { isInternalUrl } from "@shared/utils/urls"; import { AttachmentValidation } from "@shared/validations"; import Document from "~/models/Document"; import ClickablePadding from "~/components/ClickablePadding"; import ErrorBoundary from "~/components/ErrorBoundary"; import HoverPreview from "~/components/HoverPreview"; import type { Props as EditorProps, Editor as SharedEditor } from "~/editor"; import useDictionary from "~/hooks/useDictionary"; import useEmbeds from "~/hooks/useEmbeds"; import useStores from "~/hooks/useStores"; import useToasts from "~/hooks/useToasts"; import { NotFoundError } from "~/utils/errors"; import { uploadFile } from "~/utils/files"; import history from "~/utils/history"; import { isModKey } from "~/utils/keyboard"; import { sharedDocumentPath } from "~/utils/routeHelpers"; import { isHash } from "~/utils/urls"; import DocumentBreadcrumb from "./DocumentBreadcrumb"; const LazyLoadedEditor = React.lazy(() => import("~/editor")); export type Props = Optional< EditorProps, | "placeholder" | "defaultValue" | "onClickLink" | "embeds" | "dictionary" | "onShowToast" | "extensions" > & { shareId?: string | undefined; embedsDisabled?: boolean; onHeadingsChange?: (headings: Heading[]) => void; onSynced?: () => Promise; onPublish?: (event: React.MouseEvent) => any; bottomPadding?: string; }; function Editor(props: Props, ref: React.RefObject | null) { const { id, shareId, onChange, onHeadingsChange } = props; const { documents, auth } = useStores(); const { showToast } = useToasts(); const dictionary = useDictionary(); const embeds = useEmbeds(!shareId); const localRef = React.useRef(); const preferences = auth.user?.preferences; const previousHeadings = React.useRef(null); const [ activeLinkElement, setActiveLink, ] = React.useState(null); const handleLinkActive = React.useCallback((element: HTMLAnchorElement) => { setActiveLink(element); return false; }, []); const handleLinkInactive = React.useCallback(() => { setActiveLink(null); }, []); const handleSearchLink = React.useCallback( async (term: string) => { if (isInternalUrl(term)) { // search for exact internal document const slug = parseDocumentSlug(term); if (!slug) { return []; } try { const document = await documents.fetch(slug); const time = formatDistanceToNow(Date.parse(document.updatedAt), { addSuffix: true, }); return [ { title: document.title, subtitle: `Updated ${time}`, url: document.url, }, ]; } catch (error) { // NotFoundError could not find document for slug if (!(error instanceof NotFoundError)) { throw error; } } } // default search for anything that doesn't look like a URL const results = await documents.searchTitles(term); return sortBy( results.map((document: Document) => { return { title: document.title, subtitle: , url: document.url, }; }), (document) => deburr(document.title) .toLowerCase() .startsWith(deburr(term).toLowerCase()) ? -1 : 1 ); }, [documents] ); const onUploadFile = React.useCallback( async (file: File) => { const result = await uploadFile(file, { documentId: id, preset: AttachmentPreset.DocumentAttachment, }); 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; } } // Link to our own API should be opened in a new tab, not in the app if (navigateTo.startsWith("/api/")) { window.open(href, "_blank"); return; } // If we're navigating to an internal document link then prepend the // share route to the URL so that the document is loaded in context if (shareId && navigateTo.includes("/doc/")) { navigateTo = sharedDocumentPath(shareId, navigateTo); } history.push(navigateTo); } else if (href) { window.open(href, "_blank"); } }, [shareId] ); const focusAtEnd = React.useCallback(() => { localRef?.current?.focusAtEnd(); }, [localRef]); const handleDrop = React.useCallback( (event: React.DragEvent) => { event.preventDefault(); event.stopPropagation(); const files = getDataTransferFiles(event); const view = localRef?.current?.view; if (!view) { return; } // Find a valid position at the end of the document to insert our content const pos = TextSelection.near( view.state.doc.resolve(view.state.doc.nodeSize - 2) ).from; // If there are no files in the drop event attempt to parse the html // as a fragment and insert it at the end of the document if (files.length === 0) { const text = event.dataTransfer.getData("text/html") || event.dataTransfer.getData("text/plain"); const dom = new DOMParser().parseFromString(text, "text/html"); view.dispatch( view.state.tr.insert( pos, ProsemirrorDOMParser.fromSchema(view.state.schema).parse(dom) ) ); return; } // Insert all files as attachments if any of the files are not images. const isAttachment = files.some( (file) => !AttachmentValidation.imageContentTypes.includes(file.type) ); insertFiles(view, event, pos, files, { uploadFile: onUploadFile, onFileUploadStart: props.onFileUploadStart, onFileUploadStop: props.onFileUploadStop, onShowToast: showToast, dictionary, isAttachment, }); }, [ localRef, props.onFileUploadStart, props.onFileUploadStop, dictionary, onUploadFile, showToast, ] ); // see: https://stackoverflow.com/a/50233827/192065 const handleDragOver = React.useCallback( (event: React.DragEvent) => { event.stopPropagation(); event.preventDefault(); }, [] ); // Calculate if headings have changed and trigger callback if so const updateHeadings = React.useCallback(() => { if (onHeadingsChange) { const headings = localRef?.current?.getHeadings(); if ( headings && headings.map((h) => h.level + h.title).join("") !== previousHeadings.current?.map((h) => h.level + h.title).join("") ) { previousHeadings.current = headings; onHeadingsChange(headings); } } }, [localRef, onHeadingsChange]); const handleChange = React.useCallback( (event) => { onChange?.(event); updateHeadings(); }, [onChange, updateHeadings] ); const handleRefChanged = React.useCallback( (node: SharedEditor | null) => { if (node) { updateHeadings(); } }, [updateHeadings] ); return ( <> {props.bottomPadding && !props.readOnly && ( )} {activeLinkElement && !shareId && ( )} ); } export default observer(React.forwardRef(Editor));