Skip to content

Commit

Permalink
fix: make floating link generic and use it for all editors
Browse files Browse the repository at this point in the history
  • Loading branch information
Palanikannan1437 committed Feb 5, 2025
1 parent 89d1926 commit e6492e0
Show file tree
Hide file tree
Showing 13 changed files with 352 additions and 216 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,5 @@ deploy/selfhost/plane-app/
## Storybook
*storybook.log
output.css

dev-editor
159 changes: 18 additions & 141 deletions packages/editor/src/core/components/editors/document/page-renderer.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,6 @@
import { useCallback, useRef, useState } from "react";
import {
autoUpdate,
computePosition,
flip,
hide,
shift,
useDismiss,
useFloating,
useInteractions,
} from "@floating-ui/react";
import { Node } from "@tiptap/pm/model";
import { EditorView } from "@tiptap/pm/view";
import { Editor, ReactRenderer } from "@tiptap/react";
import { Editor } from "@tiptap/react";
// components
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
import { LinkView, LinkViewProps } from "@/components/links";
import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus";
// types
import { TAIHandler, TDisplayConfig } from "@/types";
Expand All @@ -31,133 +17,24 @@ type IPageRenderer = {

export const PageRenderer = (props: IPageRenderer) => {
const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
// states
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
const [isOpen, setIsOpen] = useState(false);
const [coordinates, setCoordinates] = useState<{ x: number; y: number }>();
const [cleanup, setCleanup] = useState(() => () => {});

const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
middleware: [flip(), shift(), hide({ strategy: "referenceHidden" })],
whileElementsMounted: autoUpdate,
});

const dismiss = useDismiss(context, {
ancestorScroll: true,
});

const { getFloatingProps } = useInteractions([dismiss]);

const floatingElementRef = useRef<HTMLElement | null>(null);

const closeLinkView = () => setIsOpen(false);

const handleLinkHover = useCallback(
(event: React.MouseEvent) => {
if (!editor) return;
const target = event.target as HTMLElement;
const view = editor.view as EditorView;

if (!target || !view) return;
const pos = view.posAtDOM(target, 0);
if (!pos || pos < 0) return;

if (target.nodeName !== "A") return;

const node = view.state.doc.nodeAt(pos) as Node;
if (!node || !node.isAtom) return;

// we need to check if any of the marks are links
const marks = node.marks;

if (!marks) return;

const linkMark = marks.find((mark) => mark.type.name === "link");

if (!linkMark) return;

if (floatingElementRef.current) {
floatingElementRef.current?.remove();
}

if (cleanup) cleanup();

const href = linkMark.attrs.href;
const componentLink = new ReactRenderer(LinkView, {
props: {
view: "LinkPreview",
url: href,
editor: editor,
from: pos,
to: pos + node.nodeSize,
},
editor,
});

const referenceElement = target as HTMLElement;
const floatingElement = componentLink.element as HTMLElement;

floatingElementRef.current = floatingElement;

const cleanupFunc = autoUpdate(referenceElement, floatingElement, () => {
computePosition(referenceElement, floatingElement, {
placement: "bottom",
middleware: [
flip(),
shift(),
hide({
strategy: "referenceHidden",
}),
],
}).then(({ x, y }) => {
setCoordinates({ x: x - 300, y: y - 50 });
setIsOpen(true);
setLinkViewProps({
closeLinkView: closeLinkView,
view: "LinkPreview",
url: href,
editor: editor,
from: pos,
to: pos + node.nodeSize,
});
});
});

setCleanup(cleanupFunc);
},
[editor, cleanup]
);

return (
<>
<div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}>
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
>
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
{editor.isEditable && (
<div>
{bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
<BlockMenu editor={editor} />
<AIFeaturesMenu menu={aiHandler?.menu} />
</div>
)}
</EditorContainer>
</div>
{isOpen && linkViewProps && coordinates && (
<div
style={{ ...floatingStyles, left: `${coordinates.x}px`, top: `${coordinates.y}px` }}
className="absolute"
ref={refs.setFloating}
>
<LinkView {...linkViewProps} style={floatingStyles} {...getFloatingProps()} />
</div>
)}
</>
<div className="frame-renderer flex-grow w-full -mx-5">
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
>
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
{editor.isEditable && (
<div>
{bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
<BlockMenu editor={editor} />
<AIFeaturesMenu menu={aiHandler?.menu} />
</div>
)}
</EditorContainer>
</div>
);
};
144 changes: 127 additions & 17 deletions packages/editor/src/core/components/editors/editor-container.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { FC, ReactNode } from "react";
import { Editor } from "@tiptap/react";
import { autoUpdate, flip, hide, shift, useDismiss, useFloating, useInteractions } from "@floating-ui/react";
import { Node } from "@tiptap/pm/model";
import { EditorView } from "@tiptap/pm/view";
import { Editor, useEditorState } from "@tiptap/react";
import { FC, ReactNode, useCallback, useEffect, useState } from "react";
// plane utils
import { cn } from "@plane/utils";
// components
import { LinkView, LinkViewProps } from "@/components/links";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// types
Expand All @@ -17,6 +22,103 @@ interface EditorContainerProps {

export const EditorContainer: FC<EditorContainerProps> = (props) => {
const { children, displayConfig, editor, editorContainerClassName, id } = props;
// states for link hover functionality
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
const [isOpen, setIsOpen] = useState(false);
const [virtualElement, setVirtualElement] = useState<HTMLElement | null>(null);

const editorState = useEditorState({
editor,
selector: ({ editor }: { editor: Editor }) => ({
openLink: editor.storage.image?.openLink,
linkPosition: editor.storage.image?.linkPosition,
}),
});

useEffect(() => {
if (editorState.openLink) {
setIsOpen(true);
if (editorState.linkPosition) {
const element = editor?.view.domAtPos(editorState.linkPosition)?.node as HTMLElement;
setVirtualElement(element);
}
setLinkViewProps({
url: "",
view: "LinkEditView",
editor: editor,
from: editorState.linkPosition.from,
to: editorState.linkPosition.to,
closeLinkView: () => {
setIsOpen(false);
if (editor) editor.storage.image.openLink = false;
},
});
} else {
setIsOpen(false);
}
}, [editorState.openLink, editorState.linkPosition, editor]);

const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
elements: {
reference: virtualElement,
},
middleware: [
flip({
fallbackPlacements: ["top", "bottom"],
}),
shift({
padding: 5,
}),
hide(),
],
whileElementsMounted: autoUpdate,
placement: "bottom-start",
});

const dismiss = useDismiss(context);
const { getFloatingProps } = useInteractions([dismiss]);

const handleLinkHover = useCallback(
(event: React.MouseEvent) => {
if (!editor || editorState.openLink) return; // Don't handle hover if link edit is open
const target = event.target as HTMLElement;
const view = editor.view as EditorView;

if (!target || !view) return;
const pos = view.posAtDOM(target, 0);
if (!pos || pos < 0) return;

if (target.nodeName !== "A") return;

const node = view.state.doc.nodeAt(pos) as Node;
if (!node || !node.isAtom) return;

const marks = node.marks;
if (!marks) return;

const linkMark = marks.find((mark) => mark.type.name === "link");
if (!linkMark) return;

setVirtualElement(target);

setLinkViewProps({
view: "LinkPreview",
url: linkMark.attrs.href,
editor: editor,
from: pos,
to: pos + node.nodeSize,
closeLinkView: () => {
setIsOpen(false);
editor.storage.image.openLink = false;
},
});

setIsOpen(true);
},
[editor, editorState.openLink]
);

const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (event.target !== event.currentTarget) return;
Expand Down Expand Up @@ -66,21 +168,29 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
};

return (
<div
id={`editor-container-${id}`}
onClick={handleContainerClick}
onMouseLeave={handleContainerMouseLeave}
className={cn(
"editor-container cursor-text relative",
{
"active-editor": editor?.isFocused && editor?.isEditable,
},
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
editorContainerClassName
<>
<div
id={`editor-container-${id}`}
onClick={handleContainerClick}
onMouseLeave={handleContainerMouseLeave}
onMouseOver={handleLinkHover}
className={cn(
"editor-container cursor-text relative",
{
"active-editor": editor?.isFocused && editor?.isEditable,
},
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
editorContainerClassName
)}
>
{children}
</div>
{isOpen && linkViewProps && virtualElement && (
<div ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()}>
<LinkView {...linkViewProps} style={floatingStyles} />
</div>
)}
>
{children}
</div>
</>
);
};
Loading

0 comments on commit e6492e0

Please sign in to comment.