Files
outline/app/editor/extensions/HoverPreviews.tsx
T
Tom Moor 8c716b173a chore: Update editor generics (#12247)
* chore: Update editor generics

* fix: Address PR review on editor generics

- Restore null-guard on Link click handler so anchors aren't inert when no onClickLink is provided
- Mark onClickLink optional in LinkOptions and openLink command to match runtime
- Remove dead `collapsed` option from HeadingOptions
- Make ToggleBlock dictionary optional and restore optional-chained access for server-side schema instantiation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 18:54:27 -04:00

125 lines
3.6 KiB
TypeScript

import { action, observable } from "mobx";
import { Plugin } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
import Extension from "@shared/editor/lib/Extension";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import stores from "~/stores";
import HoverPreview from "~/components/HoverPreview";
import env from "~/env";
/**
* Options for the HoverPreviews extension.
*/
interface HoverPreviewsOptions {
/** Delay in milliseconds before the target is considered "hovered" and the preview is shown. */
delay: number;
}
export default class HoverPreviews extends Extension<HoverPreviewsOptions> {
state: {
activeLinkElement: HTMLElement | null;
unfurlId: string | null;
dataLoading: boolean;
} = observable({
activeLinkElement: null,
unfurlId: null,
dataLoading: false,
});
get defaultOptions(): HoverPreviewsOptions {
return {
delay: 600,
};
}
get name() {
return "hover-previews";
}
get allowInReadOnly() {
return true;
}
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)) {
hoveringTimeout = setTimeout(
action(async () => {
const element = target as HTMLElement;
const url =
element?.getAttribute("href") || element?.dataset.url;
const documentId = parseDocumentSlug(
window.location.pathname
);
if (url) {
const transformedUrl = url.startsWith("/")
? env.URL + url
: url;
this.state.dataLoading = true;
const unfurl = await stores.unfurls.fetchUnfurl({
url: transformedUrl,
documentId,
});
if (unfurl) {
this.state.activeLinkElement = element;
this.state.unfurlId = transformedUrl;
} else {
this.state.activeLinkElement = null;
}
this.state.dataLoading = false;
}
}),
this.options.delay
);
}
return false;
},
mouseout: action((view: EditorView, event: MouseEvent) => {
const target = (event.target as HTMLElement)?.closest(
".use-hover-preview"
);
if (isHoverTarget(target, view)) {
clearTimeout(hoveringTimeout);
this.state.activeLinkElement = null;
}
return false;
}),
},
},
}),
];
}
widget = () => (
<HoverPreview
element={this.state.activeLinkElement}
unfurlId={this.state.unfurlId}
dataLoading={this.state.dataLoading}
onClose={action(() => {
this.state.activeLinkElement = null;
this.state.unfurlId = null;
})}
/>
);
}