fix: Refactor hover previews to reduce false positives (#6091)
This commit is contained in:
@@ -77,10 +77,13 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
|||||||
React.useState<HTMLAnchorElement | null>(null);
|
React.useState<HTMLAnchorElement | null>(null);
|
||||||
const previousCommentIds = React.useRef<string[]>();
|
const previousCommentIds = React.useRef<string[]>();
|
||||||
|
|
||||||
const handleLinkActive = React.useCallback((element: HTMLAnchorElement) => {
|
const handleLinkActive = React.useCallback(
|
||||||
|
(element: HTMLAnchorElement | null) => {
|
||||||
setActiveLink(element);
|
setActiveLink(element);
|
||||||
return false;
|
return false;
|
||||||
}, []);
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleLinkInactive = React.useCallback(() => {
|
const handleLinkInactive = React.useCallback(() => {
|
||||||
setActiveLink(null);
|
setActiveLink(null);
|
||||||
@@ -351,7 +354,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
|||||||
minHeight={props.editorStyle.paddingBottom}
|
minHeight={props.editorStyle.paddingBottom}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeLinkElement && !shareId && (
|
{!shareId && (
|
||||||
<HoverPreview
|
<HoverPreview
|
||||||
element={activeLinkElement}
|
element={activeLinkElement}
|
||||||
onClose={handleLinkInactive}
|
onClose={handleLinkInactive}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import useEventListener from "~/hooks/useEventListener";
|
|||||||
import useKeyDown from "~/hooks/useKeyDown";
|
import useKeyDown from "~/hooks/useKeyDown";
|
||||||
import useMobile from "~/hooks/useMobile";
|
import useMobile from "~/hooks/useMobile";
|
||||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||||
|
import usePrevious from "~/hooks/usePrevious";
|
||||||
import useRequest from "~/hooks/useRequest";
|
import useRequest from "~/hooks/useRequest";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { client } from "~/utils/ApiClient";
|
import { client } from "~/utils/ApiClient";
|
||||||
@@ -17,13 +18,14 @@ import HoverPreviewDocument from "./HoverPreviewDocument";
|
|||||||
import HoverPreviewLink from "./HoverPreviewLink";
|
import HoverPreviewLink from "./HoverPreviewLink";
|
||||||
import HoverPreviewMention from "./HoverPreviewMention";
|
import HoverPreviewMention from "./HoverPreviewMention";
|
||||||
|
|
||||||
const DELAY_OPEN = 500;
|
|
||||||
const DELAY_CLOSE = 600;
|
const DELAY_CLOSE = 600;
|
||||||
|
const POINTER_HEIGHT = 22;
|
||||||
|
const POINTER_WIDTH = 22;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** The HTML element that is being hovered over */
|
/** The HTML element that is being hovered over, or null if none. */
|
||||||
element: HTMLAnchorElement;
|
element: HTMLAnchorElement | null;
|
||||||
/** A callback on close of the hover preview */
|
/** A callback on close of the hover preview. */
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,16 +34,175 @@ enum Direction {
|
|||||||
DOWN,
|
DOWN,
|
||||||
}
|
}
|
||||||
|
|
||||||
const POINTER_HEIGHT = 22;
|
function HoverPreviewDesktop({ element, onClose }: Props) {
|
||||||
const POINTER_WIDTH = 22;
|
const url = element?.href || element?.dataset.url;
|
||||||
|
const previousUrl = usePrevious(url, true);
|
||||||
function HoverPreviewInternal({ element, onClose }: Props) {
|
|
||||||
const url = element.href || element.dataset.url;
|
|
||||||
const [isVisible, setVisible] = React.useState(false);
|
const [isVisible, setVisible] = React.useState(false);
|
||||||
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
|
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
|
||||||
const timerOpen = React.useRef<ReturnType<typeof setTimeout>>();
|
|
||||||
const cardRef = React.useRef<HTMLDivElement>(null);
|
const cardRef = React.useRef<HTMLDivElement>(null);
|
||||||
const stores = useStores();
|
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
|
||||||
|
useHoverPosition({
|
||||||
|
cardRef,
|
||||||
|
element,
|
||||||
|
isVisible,
|
||||||
|
});
|
||||||
|
|
||||||
|
const closePreview = React.useCallback(() => {
|
||||||
|
setVisible(false);
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const stopCloseTimer = React.useCallback(() => {
|
||||||
|
if (timerClose.current) {
|
||||||
|
clearTimeout(timerClose.current);
|
||||||
|
timerClose.current = undefined;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startCloseTimer = React.useCallback(() => {
|
||||||
|
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
|
||||||
|
}, [closePreview]);
|
||||||
|
|
||||||
|
// Open and close the preview when the element changes.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (element) {
|
||||||
|
setVisible(true);
|
||||||
|
} else {
|
||||||
|
startCloseTimer();
|
||||||
|
}
|
||||||
|
}, [startCloseTimer, element]);
|
||||||
|
|
||||||
|
// Close the preview on Escape, scroll, or click outside.
|
||||||
|
useOnClickOutside(cardRef, closePreview);
|
||||||
|
useKeyDown("Escape", closePreview);
|
||||||
|
useEventListener("scroll", closePreview, window, { capture: true });
|
||||||
|
|
||||||
|
// Ensure that the preview stays open while the user is hovering over the card.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const card = cardRef.current;
|
||||||
|
|
||||||
|
if (isVisible) {
|
||||||
|
if (card) {
|
||||||
|
card.addEventListener("mouseenter", stopCloseTimer);
|
||||||
|
card.addEventListener("mouseleave", startCloseTimer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (card) {
|
||||||
|
card.removeEventListener("mouseenter", stopCloseTimer);
|
||||||
|
card.removeEventListener("mouseleave", startCloseTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopCloseTimer();
|
||||||
|
};
|
||||||
|
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
|
||||||
|
|
||||||
|
const displayUrl = url ?? previousUrl;
|
||||||
|
|
||||||
|
if (!isVisible || !displayUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Portal>
|
||||||
|
<Position top={cardTop} left={cardLeft} ref={cardRef} aria-hidden>
|
||||||
|
<DataLoader url={displayUrl}>
|
||||||
|
{(data) => (
|
||||||
|
<Animate
|
||||||
|
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
|
||||||
|
animate={{ opacity: 1, y: 0, pointerEvents: "auto" }}
|
||||||
|
>
|
||||||
|
{data.type === UnfurlType.Mention ? (
|
||||||
|
<HoverPreviewMention
|
||||||
|
url={data.thumbnailUrl}
|
||||||
|
title={data.title}
|
||||||
|
info={data.meta.info}
|
||||||
|
color={data.meta.color}
|
||||||
|
/>
|
||||||
|
) : data.type === UnfurlType.Document ? (
|
||||||
|
<HoverPreviewDocument
|
||||||
|
id={data.meta.id}
|
||||||
|
url={data.url}
|
||||||
|
title={data.title}
|
||||||
|
description={data.description}
|
||||||
|
info={data.meta.info}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<HoverPreviewLink
|
||||||
|
url={data.url}
|
||||||
|
thumbnailUrl={data.thumbnailUrl}
|
||||||
|
title={data.title}
|
||||||
|
description={data.description}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Pointer
|
||||||
|
top={pointerTop}
|
||||||
|
left={pointerLeft}
|
||||||
|
direction={pointerDir}
|
||||||
|
/>
|
||||||
|
</Animate>
|
||||||
|
)}
|
||||||
|
</DataLoader>
|
||||||
|
</Position>
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataLoader({
|
||||||
|
url,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
children: (data: any) => React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { ui } = useStores();
|
||||||
|
const { data, request, loading } = useRequest(
|
||||||
|
React.useCallback(
|
||||||
|
() =>
|
||||||
|
client.post("/urls.unfurl", {
|
||||||
|
url,
|
||||||
|
documentId: ui.activeDocumentId,
|
||||||
|
}),
|
||||||
|
[url, ui.activeDocumentId]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (url) {
|
||||||
|
void request();
|
||||||
|
}
|
||||||
|
}, [url, request]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children(data)}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverPreview({ element, ...rest }: Props) {
|
||||||
|
const isMobile = useMobile();
|
||||||
|
if (isMobile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <HoverPreviewDesktop {...rest} element={element} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useHoverPosition({
|
||||||
|
cardRef,
|
||||||
|
element,
|
||||||
|
isVisible,
|
||||||
|
}: {
|
||||||
|
cardRef: React.RefObject<HTMLDivElement>;
|
||||||
|
element: HTMLAnchorElement | null;
|
||||||
|
isVisible: boolean;
|
||||||
|
}) {
|
||||||
const [cardLeft, setCardLeft] = React.useState(0);
|
const [cardLeft, setCardLeft] = React.useState(0);
|
||||||
const [cardTop, setCardTop] = React.useState(0);
|
const [cardTop, setCardTop] = React.useState(0);
|
||||||
const [pointerLeft, setPointerLeft] = React.useState(0);
|
const [pointerLeft, setPointerLeft] = React.useState(0);
|
||||||
@@ -49,7 +210,7 @@ function HoverPreviewInternal({ element, onClose }: Props) {
|
|||||||
const [pointerDir, setPointerDir] = React.useState(Direction.UP);
|
const [pointerDir, setPointerDir] = React.useState(Direction.UP);
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (isVisible && cardRef.current) {
|
if (isVisible && element && cardRef.current) {
|
||||||
const elem = element.getBoundingClientRect();
|
const elem = element.getBoundingClientRect();
|
||||||
const card = cardRef.current.getBoundingClientRect();
|
const card = cardRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
@@ -85,156 +246,9 @@ function HoverPreviewInternal({ element, onClose }: Props) {
|
|||||||
setCardLeft(cLeft);
|
setCardLeft(cLeft);
|
||||||
setPointerLeft(pLeft);
|
setPointerLeft(pLeft);
|
||||||
}
|
}
|
||||||
}, [isVisible, element]);
|
}, [isVisible, cardRef, element]);
|
||||||
|
|
||||||
const { data, request, loading } = useRequest(
|
return { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir };
|
||||||
React.useCallback(
|
|
||||||
() =>
|
|
||||||
client.post("/urls.unfurl", {
|
|
||||||
url,
|
|
||||||
documentId: stores.ui.activeDocumentId,
|
|
||||||
}),
|
|
||||||
[url, stores.ui.activeDocumentId]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (url) {
|
|
||||||
stopOpenTimer();
|
|
||||||
setVisible(false);
|
|
||||||
|
|
||||||
void request();
|
|
||||||
}
|
|
||||||
}, [url, request]);
|
|
||||||
|
|
||||||
const stopOpenTimer = () => {
|
|
||||||
if (timerOpen.current) {
|
|
||||||
clearTimeout(timerOpen.current);
|
|
||||||
timerOpen.current = undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const closePreview = React.useCallback(() => {
|
|
||||||
if (isVisible) {
|
|
||||||
stopOpenTimer();
|
|
||||||
setVisible(false);
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}, [isVisible, onClose]);
|
|
||||||
|
|
||||||
useOnClickOutside(cardRef, closePreview);
|
|
||||||
useKeyDown("Escape", closePreview);
|
|
||||||
useEventListener("scroll", closePreview, window, { capture: true });
|
|
||||||
|
|
||||||
const stopCloseTimer = React.useCallback(() => {
|
|
||||||
if (timerClose.current) {
|
|
||||||
clearTimeout(timerClose.current);
|
|
||||||
timerClose.current = undefined;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startOpenTimer = React.useCallback(() => {
|
|
||||||
if (!timerOpen.current) {
|
|
||||||
timerOpen.current = setTimeout(() => setVisible(true), DELAY_OPEN);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startCloseTimer = React.useCallback(() => {
|
|
||||||
stopOpenTimer();
|
|
||||||
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
|
|
||||||
}, [closePreview]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const card = cardRef.current;
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
startOpenTimer();
|
|
||||||
|
|
||||||
if (card) {
|
|
||||||
card.addEventListener("mouseenter", stopCloseTimer);
|
|
||||||
card.addEventListener("mouseleave", startCloseTimer);
|
|
||||||
}
|
|
||||||
|
|
||||||
element.addEventListener("mouseout", startCloseTimer);
|
|
||||||
element.addEventListener("mouseover", stopCloseTimer);
|
|
||||||
element.addEventListener("mouseover", startOpenTimer);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
element.removeEventListener("mouseout", startCloseTimer);
|
|
||||||
element.removeEventListener("mouseover", stopCloseTimer);
|
|
||||||
element.removeEventListener("mouseover", startOpenTimer);
|
|
||||||
|
|
||||||
if (card) {
|
|
||||||
card.removeEventListener("mouseenter", stopCloseTimer);
|
|
||||||
card.removeEventListener("mouseleave", startCloseTimer);
|
|
||||||
}
|
|
||||||
|
|
||||||
stopCloseTimer();
|
|
||||||
};
|
|
||||||
}, [element, startCloseTimer, data, startOpenTimer, stopCloseTimer]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <LoadingIndicator />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Portal>
|
|
||||||
<Position top={cardTop} left={cardLeft} aria-hidden>
|
|
||||||
{isVisible ? (
|
|
||||||
<Animate
|
|
||||||
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
|
|
||||||
animate={{ opacity: 1, y: 0, pointerEvents: "auto" }}
|
|
||||||
>
|
|
||||||
{data.type === UnfurlType.Mention ? (
|
|
||||||
<HoverPreviewMention
|
|
||||||
ref={cardRef}
|
|
||||||
url={data.thumbnailUrl}
|
|
||||||
title={data.title}
|
|
||||||
info={data.meta.info}
|
|
||||||
color={data.meta.color}
|
|
||||||
/>
|
|
||||||
) : data.type === UnfurlType.Document ? (
|
|
||||||
<HoverPreviewDocument
|
|
||||||
ref={cardRef}
|
|
||||||
id={data.meta.id}
|
|
||||||
url={data.url}
|
|
||||||
title={data.title}
|
|
||||||
description={data.description}
|
|
||||||
info={data.meta.info}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<HoverPreviewLink
|
|
||||||
ref={cardRef}
|
|
||||||
url={data.url}
|
|
||||||
thumbnailUrl={data.thumbnailUrl}
|
|
||||||
title={data.title}
|
|
||||||
description={data.description}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Pointer
|
|
||||||
top={pointerTop}
|
|
||||||
left={pointerLeft}
|
|
||||||
direction={pointerDir}
|
|
||||||
/>
|
|
||||||
</Animate>
|
|
||||||
) : null}
|
|
||||||
</Position>
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HoverPreview({ element, ...rest }: Props) {
|
|
||||||
const isMobile = useMobile();
|
|
||||||
if (isMobile) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <HoverPreviewInternal {...rest} element={element} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Animate = styled(m.div)`
|
const Animate = styled(m.div)`
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export type Props = {
|
|||||||
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
|
event: MouseEvent | React.MouseEvent<HTMLButtonElement>
|
||||||
) => void;
|
) => void;
|
||||||
/** Callback when user hovers on any link in the document */
|
/** Callback when user hovers on any link in the document */
|
||||||
onHoverLink?: (element: HTMLAnchorElement) => boolean;
|
onHoverLink?: (element: HTMLAnchorElement | null) => boolean;
|
||||||
/** Callback when user presses any key with document focused */
|
/** Callback when user presses any key with document focused */
|
||||||
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||||
/** Collection of embed types to render in the document */
|
/** Collection of embed types to render in the document */
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
export default function usePrevious<T>(value: T): T | void {
|
/**
|
||||||
|
* A hook to get the previous value of a variable.
|
||||||
|
*
|
||||||
|
* @param value The value to track.
|
||||||
|
* @param onlyTruthy Whether to include only truthy values.
|
||||||
|
* @returns The previous value of the variable.
|
||||||
|
*/
|
||||||
|
export default function usePrevious<T>(value: T, onlyTruthy = false): T | void {
|
||||||
const ref = React.useRef<T>();
|
const ref = React.useRef<T>();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (onlyTruthy && !value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
ref.current = value;
|
ref.current = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type RequestResponse<T> = {
|
|||||||
* A hook to make an API request and track its state within a component.
|
* A hook to make an API request and track its state within a component.
|
||||||
*
|
*
|
||||||
* @param requestFn The function to call to make the request, it should return a promise.
|
* @param requestFn The function to call to make the request, it should return a promise.
|
||||||
* @returns
|
* @returns An object containing the request state and a function to start the request.
|
||||||
*/
|
*/
|
||||||
export default function useRequest<T = unknown>(
|
export default function useRequest<T = unknown>(
|
||||||
requestFn: () => Promise<T>
|
requestFn: () => Promise<T>
|
||||||
|
|||||||
64
shared/editor/extensions/HoverPreviews.ts
Normal file
64
shared/editor/extensions/HoverPreviews.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Plugin } from "prosemirror-state";
|
||||||
|
import { EditorView } from "prosemirror-view";
|
||||||
|
import Extension from "../lib/Extension";
|
||||||
|
|
||||||
|
interface HoverPreviewsOptions {
|
||||||
|
/** Callback when a hover target is found or lost. */
|
||||||
|
onHoverLink?: (target: Element | null) => void;
|
||||||
|
|
||||||
|
/** Delay before the target is considered "hovered" and callback is triggered. */
|
||||||
|
delay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class HoverPreviews extends Extension {
|
||||||
|
get defaultOptions(): HoverPreviewsOptions {
|
||||||
|
return {
|
||||||
|
delay: 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return "hover-previews";
|
||||||
|
}
|
||||||
|
|
||||||
|
get plugins() {
|
||||||
|
const isHoverTarget = (target: Element | null, view: EditorView) =>
|
||||||
|
target instanceof HTMLElement &&
|
||||||
|
this.editor.elementRef.current?.contains(target) &&
|
||||||
|
(!view.editable || (view.editable && !view.hasFocus()));
|
||||||
|
|
||||||
|
let hoveringTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
props: {
|
||||||
|
handleDOMEvents: {
|
||||||
|
mouseover: (view: EditorView, event: MouseEvent) => {
|
||||||
|
const target = (event.target as HTMLElement)?.closest(
|
||||||
|
".use-hover-preview"
|
||||||
|
);
|
||||||
|
if (isHoverTarget(target, view)) {
|
||||||
|
if (this.options.onHoverLink) {
|
||||||
|
hoveringTimeout = setTimeout(() => {
|
||||||
|
this.options.onHoverLink?.(target);
|
||||||
|
}, this.options.delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
mouseout: (view: EditorView, event: MouseEvent) => {
|
||||||
|
const target = (event.target as HTMLElement)?.closest(
|
||||||
|
".use-hover-preview"
|
||||||
|
);
|
||||||
|
if (isHoverTarget(target, view)) {
|
||||||
|
clearTimeout(hoveringTimeout);
|
||||||
|
this.options.onHoverLink?.(null);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,7 +88,7 @@ export default class Link extends Mark {
|
|||||||
{
|
{
|
||||||
title: node.attrs.title,
|
title: node.attrs.title,
|
||||||
href: sanitizeUrl(node.attrs.href),
|
href: sanitizeUrl(node.attrs.href),
|
||||||
class: "text-link",
|
class: "use-hover-preview",
|
||||||
rel: "noopener noreferrer nofollow",
|
rel: "noopener noreferrer nofollow",
|
||||||
},
|
},
|
||||||
0,
|
0,
|
||||||
@@ -203,20 +203,6 @@ export default class Link extends Mark {
|
|||||||
props: {
|
props: {
|
||||||
decorations: (state: EditorState) => plugin.getState(state),
|
decorations: (state: EditorState) => plugin.getState(state),
|
||||||
handleDOMEvents: {
|
handleDOMEvents: {
|
||||||
mouseover: (view: EditorView, event: MouseEvent) => {
|
|
||||||
const target = (event.target as HTMLElement)?.closest("a");
|
|
||||||
if (
|
|
||||||
target instanceof HTMLAnchorElement &&
|
|
||||||
target.className.includes("text-link") &&
|
|
||||||
this.editor.elementRef.current?.contains(target) &&
|
|
||||||
(!view.editable || (view.editable && !view.hasFocus()))
|
|
||||||
) {
|
|
||||||
if (this.options.onHoverLink) {
|
|
||||||
return this.options.onHoverLink(target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
mousedown: (view: EditorView, event: MouseEvent) => {
|
mousedown: (view: EditorView, event: MouseEvent) => {
|
||||||
const target = (event.target as HTMLElement)?.closest("a");
|
const target = (event.target as HTMLElement)?.closest("a");
|
||||||
if (!(target instanceof HTMLAnchorElement) || event.button !== 0) {
|
if (!(target instanceof HTMLAnchorElement) || event.button !== 0) {
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import {
|
|||||||
NodeType,
|
NodeType,
|
||||||
Schema,
|
Schema,
|
||||||
} from "prosemirror-model";
|
} from "prosemirror-model";
|
||||||
import { Command, Plugin, TextSelection } from "prosemirror-state";
|
import { Command, TextSelection } from "prosemirror-state";
|
||||||
import { EditorView } from "prosemirror-view";
|
|
||||||
import { Primitive } from "utility-types";
|
import { Primitive } from "utility-types";
|
||||||
import Suggestion from "../extensions/Suggestion";
|
import Suggestion from "../extensions/Suggestion";
|
||||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||||
@@ -64,7 +63,7 @@ export default class Mention extends Suggestion {
|
|||||||
toDOM: (node) => [
|
toDOM: (node) => [
|
||||||
"span",
|
"span",
|
||||||
{
|
{
|
||||||
class: `${node.type.name}`,
|
class: `${node.type.name} use-hover-preview`,
|
||||||
id: node.attrs.id,
|
id: node.attrs.id,
|
||||||
"data-type": node.attrs.type,
|
"data-type": node.attrs.type,
|
||||||
"data-id": node.attrs.modelId,
|
"data-id": node.attrs.modelId,
|
||||||
@@ -81,31 +80,6 @@ export default class Mention extends Suggestion {
|
|||||||
return [mentionRule];
|
return [mentionRule];
|
||||||
}
|
}
|
||||||
|
|
||||||
get plugins(): Plugin[] {
|
|
||||||
return [
|
|
||||||
new Plugin({
|
|
||||||
props: {
|
|
||||||
handleDOMEvents: {
|
|
||||||
mouseover: (view: EditorView, event: MouseEvent) => {
|
|
||||||
const target = (event.target as HTMLElement)?.closest("span");
|
|
||||||
if (
|
|
||||||
target instanceof HTMLSpanElement &&
|
|
||||||
this.editor.elementRef.current?.contains(target) &&
|
|
||||||
target.className.includes("mention") &&
|
|
||||||
(!view.editable || (view.editable && !view.hasFocus()))
|
|
||||||
) {
|
|
||||||
if (this.options.onHoverLink) {
|
|
||||||
return this.options.onHoverLink(target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
commands({ type }: { type: NodeType; schema: Schema }) {
|
commands({ type }: { type: NodeType; schema: Schema }) {
|
||||||
return (attrs: Record<string, Primitive>): Command =>
|
return (attrs: Record<string, Primitive>): Command =>
|
||||||
(state, dispatch) => {
|
(state, dispatch) => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import ClipboardTextSerializer from "../extensions/ClipboardTextSerializer";
|
|||||||
import DateTime from "../extensions/DateTime";
|
import DateTime from "../extensions/DateTime";
|
||||||
import FindAndReplace from "../extensions/FindAndReplace";
|
import FindAndReplace from "../extensions/FindAndReplace";
|
||||||
import History from "../extensions/History";
|
import History from "../extensions/History";
|
||||||
|
import HoverPreviews from "../extensions/HoverPreviews";
|
||||||
import Keys from "../extensions/Keys";
|
import Keys from "../extensions/Keys";
|
||||||
import MaxLength from "../extensions/MaxLength";
|
import MaxLength from "../extensions/MaxLength";
|
||||||
import PasteHandler from "../extensions/PasteHandler";
|
import PasteHandler from "../extensions/PasteHandler";
|
||||||
@@ -113,6 +114,7 @@ export const richExtensions: Nodes = [
|
|||||||
MathBlock,
|
MathBlock,
|
||||||
PreventTab,
|
PreventTab,
|
||||||
FindAndReplace,
|
FindAndReplace,
|
||||||
|
HoverPreviews,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user