fix: Link preview and search should work on collection descriptions (#3355)
This commit is contained in:
@@ -1,15 +1,23 @@
|
|||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { deburr, sortBy } from "lodash";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Optional } from "utility-types";
|
import { Optional } from "utility-types";
|
||||||
import embeds from "@shared/editor/embeds";
|
import embeds from "@shared/editor/embeds";
|
||||||
|
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||||
import { isInternalUrl } from "@shared/utils/urls";
|
import { isInternalUrl } from "@shared/utils/urls";
|
||||||
|
import Document from "~/models/Document";
|
||||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||||
|
import HoverPreview from "~/components/HoverPreview";
|
||||||
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
|
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
|
||||||
import useDictionary from "~/hooks/useDictionary";
|
import useDictionary from "~/hooks/useDictionary";
|
||||||
|
import useStores from "~/hooks/useStores";
|
||||||
import useToasts from "~/hooks/useToasts";
|
import useToasts from "~/hooks/useToasts";
|
||||||
|
import { NotFoundError } from "~/utils/errors";
|
||||||
import { uploadFile } from "~/utils/files";
|
import { uploadFile } from "~/utils/files";
|
||||||
import history from "~/utils/history";
|
import history from "~/utils/history";
|
||||||
import { isModKey } from "~/utils/keyboard";
|
import { isModKey } from "~/utils/keyboard";
|
||||||
import { isHash } from "~/utils/urls";
|
import { isHash } from "~/utils/urls";
|
||||||
|
import DocumentBreadcrumb from "./DocumentBreadcrumb";
|
||||||
|
|
||||||
const LazyLoadedEditor = React.lazy(
|
const LazyLoadedEditor = React.lazy(
|
||||||
() =>
|
() =>
|
||||||
@@ -38,8 +46,74 @@ export type Props = Optional<
|
|||||||
|
|
||||||
function Editor(props: Props, ref: React.Ref<SharedEditor>) {
|
function Editor(props: Props, ref: React.Ref<SharedEditor>) {
|
||||||
const { id, shareId } = props;
|
const { id, shareId } = props;
|
||||||
|
const { documents } = useStores();
|
||||||
const { showToast } = useToasts();
|
const { showToast } = useToasts();
|
||||||
const dictionary = useDictionary();
|
const dictionary = useDictionary();
|
||||||
|
const [
|
||||||
|
activeLinkEvent,
|
||||||
|
setActiveLinkEvent,
|
||||||
|
] = React.useState<MouseEvent | null>(null);
|
||||||
|
|
||||||
|
const handleLinkActive = React.useCallback((event: MouseEvent) => {
|
||||||
|
setActiveLinkEvent(event);
|
||||||
|
return false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLinkInactive = React.useCallback(() => {
|
||||||
|
setActiveLinkEvent(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: <DocumentBreadcrumb document={document} onlyText />,
|
||||||
|
url: document.url,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
(document) =>
|
||||||
|
deburr(document.title)
|
||||||
|
.toLowerCase()
|
||||||
|
.startsWith(deburr(term).toLowerCase())
|
||||||
|
? -1
|
||||||
|
: 1
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[documents]
|
||||||
|
);
|
||||||
|
|
||||||
const onUploadFile = React.useCallback(
|
const onUploadFile = React.useCallback(
|
||||||
async (file: File) => {
|
async (file: File) => {
|
||||||
@@ -87,6 +161,7 @@ function Editor(props: Props, ref: React.Ref<SharedEditor>) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary reloadOnChunkMissing>
|
<ErrorBoundary reloadOnChunkMissing>
|
||||||
|
<>
|
||||||
<LazyLoadedEditor
|
<LazyLoadedEditor
|
||||||
ref={ref}
|
ref={ref}
|
||||||
uploadFile={onUploadFile}
|
uploadFile={onUploadFile}
|
||||||
@@ -94,10 +169,20 @@ function Editor(props: Props, ref: React.Ref<SharedEditor>) {
|
|||||||
embeds={embeds}
|
embeds={embeds}
|
||||||
dictionary={dictionary}
|
dictionary={dictionary}
|
||||||
{...props}
|
{...props}
|
||||||
|
onHoverLink={handleLinkActive}
|
||||||
onClickLink={onClickLink}
|
onClickLink={onClickLink}
|
||||||
|
onSearchLink={handleSearchLink}
|
||||||
placeholder={props.placeholder || ""}
|
placeholder={props.placeholder || ""}
|
||||||
defaultValue={props.defaultValue || ""}
|
defaultValue={props.defaultValue || ""}
|
||||||
/>
|
/>
|
||||||
|
{activeLinkEvent && !shareId && (
|
||||||
|
<HoverPreview
|
||||||
|
node={activeLinkEvent.target as HTMLAnchorElement}
|
||||||
|
event={activeLinkEvent}
|
||||||
|
onClose={handleLinkInactive}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ function HoverPreviewInternal({ node, onClose }: Props) {
|
|||||||
|
|
||||||
function HoverPreview({ node, ...rest }: Props) {
|
function HoverPreview({ node, ...rest }: Props) {
|
||||||
const isMobile = useMobile();
|
const isMobile = useMobile();
|
||||||
if (!isMobile) {
|
if (isMobile) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ const Margin = styled.div`
|
|||||||
|
|
||||||
const CardContent = styled.div`
|
const CardContent = styled.div`
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-height: 350px;
|
max-height: 20em;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
import { formatDistanceToNow } from "date-fns";
|
|
||||||
import invariant from "invariant";
|
import invariant from "invariant";
|
||||||
import { deburr, sortBy } from "lodash";
|
|
||||||
import { observable } from "mobx";
|
import { observable } from "mobx";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { RouteComponentProps, StaticContext } from "react-router";
|
import { RouteComponentProps, StaticContext } from "react-router";
|
||||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
|
||||||
import { isInternalUrl } from "@shared/utils/urls";
|
|
||||||
import RootStore from "~/stores/RootStore";
|
import RootStore from "~/stores/RootStore";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Revision from "~/models/Revision";
|
import Revision from "~/models/Revision";
|
||||||
import Error404 from "~/scenes/Error404";
|
import Error404 from "~/scenes/Error404";
|
||||||
import ErrorOffline from "~/scenes/ErrorOffline";
|
import ErrorOffline from "~/scenes/ErrorOffline";
|
||||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
|
||||||
import withStores from "~/components/withStores";
|
import withStores from "~/components/withStores";
|
||||||
import { NavigationNode } from "~/types";
|
import { NavigationNode } from "~/types";
|
||||||
import { NotFoundError, OfflineError } from "~/utils/errors";
|
import { NotFoundError, OfflineError } from "~/utils/errors";
|
||||||
@@ -100,55 +95,6 @@ class DataLoader extends React.Component<Props> {
|
|||||||
return this.isEditRoute || this.props.auth?.team?.collaborativeEditing;
|
return this.isEditRoute || this.props.auth?.team?.collaborativeEditing;
|
||||||
}
|
}
|
||||||
|
|
||||||
onSearchLink = async (term: string) => {
|
|
||||||
if (isInternalUrl(term)) {
|
|
||||||
// search for exact internal document
|
|
||||||
const slug = parseDocumentSlug(term);
|
|
||||||
if (!slug) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const document = await this.props.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 this.props.documents.searchTitles(term);
|
|
||||||
|
|
||||||
return sortBy(
|
|
||||||
results.map((document: Document) => {
|
|
||||||
return {
|
|
||||||
title: document.title,
|
|
||||||
subtitle: <DocumentBreadcrumb document={document} onlyText />,
|
|
||||||
url: document.url,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
(document) =>
|
|
||||||
deburr(document.title)
|
|
||||||
.toLowerCase()
|
|
||||||
.startsWith(deburr(term).toLowerCase())
|
|
||||||
? -1
|
|
||||||
: 1
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
onCreateLink = async (title: string) => {
|
onCreateLink = async (title: string) => {
|
||||||
const document = this.document;
|
const document = this.document;
|
||||||
invariant(document, "document must be loaded to create link");
|
invariant(document, "document must be loaded to create link");
|
||||||
@@ -277,7 +223,6 @@ class DataLoader extends React.Component<Props> {
|
|||||||
!abilities.update ||
|
!abilities.update ||
|
||||||
document.isArchived ||
|
document.isArchived ||
|
||||||
!!revisionId,
|
!!revisionId,
|
||||||
onSearchLink: this.onSearchLink,
|
|
||||||
onCreateLink: this.onCreateLink,
|
onCreateLink: this.onCreateLink,
|
||||||
sharedTree: this.sharedTree,
|
sharedTree: this.sharedTree,
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { RefHandle } from "~/components/ContentEditable";
|
|||||||
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
|
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
|
||||||
import Editor, { Props as EditorProps } from "~/components/Editor";
|
import Editor, { Props as EditorProps } from "~/components/Editor";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import HoverPreview from "~/components/HoverPreview";
|
|
||||||
import {
|
import {
|
||||||
documentHistoryUrl,
|
documentHistoryUrl,
|
||||||
documentUrl,
|
documentUrl,
|
||||||
@@ -38,10 +37,6 @@ type Props = Omit<EditorProps, "extensions"> & {
|
|||||||
* and support for hover previews of internal links.
|
* and support for hover previews of internal links.
|
||||||
*/
|
*/
|
||||||
function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||||
const [
|
|
||||||
activeLinkEvent,
|
|
||||||
setActiveLinkEvent,
|
|
||||||
] = React.useState<MouseEvent | null>(null);
|
|
||||||
const titleRef = React.useRef<RefHandle>(null);
|
const titleRef = React.useRef<RefHandle>(null);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const match = useRouteMatch();
|
const match = useRouteMatch();
|
||||||
@@ -58,15 +53,6 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
|||||||
}
|
}
|
||||||
}, [ref]);
|
}, [ref]);
|
||||||
|
|
||||||
const handleLinkActive = React.useCallback((event: MouseEvent) => {
|
|
||||||
setActiveLinkEvent(event);
|
|
||||||
return false;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLinkInactive = React.useCallback(() => {
|
|
||||||
setActiveLinkEvent(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleGoToNextInput = React.useCallback(
|
const handleGoToNextInput = React.useCallback(
|
||||||
(insertParagraph: boolean) => {
|
(insertParagraph: boolean) => {
|
||||||
if (insertParagraph && ref.current) {
|
if (insertParagraph && ref.current) {
|
||||||
@@ -123,7 +109,6 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
autoFocus={!!title && !props.defaultValue}
|
autoFocus={!!title && !props.defaultValue}
|
||||||
placeholder={t("Type '/' to insert, or start writing…")}
|
placeholder={t("Type '/' to insert, or start writing…")}
|
||||||
onHoverLink={handleLinkActive}
|
|
||||||
scrollTo={window.location.hash}
|
scrollTo={window.location.hash}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
shareId={shareId}
|
shareId={shareId}
|
||||||
@@ -132,13 +117,6 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
|||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
{!readOnly && <ClickablePadding onClick={focusAtEnd} grow />}
|
{!readOnly && <ClickablePadding onClick={focusAtEnd} grow />}
|
||||||
{activeLinkEvent && !shareId && (
|
|
||||||
<HoverPreview
|
|
||||||
node={activeLinkEvent.target as HTMLAnchorElement}
|
|
||||||
event={activeLinkEvent}
|
|
||||||
onClose={handleLinkInactive}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{children}
|
{children}
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user