Skip to content

Commit a57c37c

Browse files
[PE-304] feat: make floating link generic and use it for all editors (#6552)
* fix: make floating link generic and use it for all editors * fix: link component behaviour with selected text fixed and storage is now typed * chore: link view seperated * fix: editor link edit view across multiple links resets now * fix: link view container * fix: cleaning up * fix: url validation
1 parent 65a0530 commit a57c37c

32 files changed

+458
-330
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ deploy/selfhost/plane-app/
8888
*storybook.log
8989
output.css
9090

91+
dev-editor
9192
# Redis
9293
*.rdb
93-
*.rdb.gz
94+
*.rdb.gz
Lines changed: 18 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,6 @@
1-
import { useCallback, useRef, useState } from "react";
2-
import {
3-
autoUpdate,
4-
computePosition,
5-
flip,
6-
hide,
7-
shift,
8-
useDismiss,
9-
useFloating,
10-
useInteractions,
11-
} from "@floating-ui/react";
12-
import { Node } from "@tiptap/pm/model";
13-
import { EditorView } from "@tiptap/pm/view";
14-
import { Editor, ReactRenderer } from "@tiptap/react";
1+
import { Editor } from "@tiptap/react";
152
// components
163
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
17-
import { LinkView, LinkViewProps } from "@/components/links";
184
import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus";
195
// types
206
import { TAIHandler, TDisplayConfig } from "@/types";
@@ -31,133 +17,24 @@ type IPageRenderer = {
3117

3218
export const PageRenderer = (props: IPageRenderer) => {
3319
const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
34-
// states
35-
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
36-
const [isOpen, setIsOpen] = useState(false);
37-
const [coordinates, setCoordinates] = useState<{ x: number; y: number }>();
38-
const [cleanup, setCleanup] = useState(() => () => {});
39-
40-
const { refs, floatingStyles, context } = useFloating({
41-
open: isOpen,
42-
onOpenChange: setIsOpen,
43-
middleware: [flip(), shift(), hide({ strategy: "referenceHidden" })],
44-
whileElementsMounted: autoUpdate,
45-
});
46-
47-
const dismiss = useDismiss(context, {
48-
ancestorScroll: true,
49-
});
50-
51-
const { getFloatingProps } = useInteractions([dismiss]);
52-
53-
const floatingElementRef = useRef<HTMLElement | null>(null);
54-
55-
const closeLinkView = () => setIsOpen(false);
56-
57-
const handleLinkHover = useCallback(
58-
(event: React.MouseEvent) => {
59-
if (!editor) return;
60-
const target = event.target as HTMLElement;
61-
const view = editor.view as EditorView;
62-
63-
if (!target || !view) return;
64-
const pos = view.posAtDOM(target, 0);
65-
if (!pos || pos < 0) return;
66-
67-
if (target.nodeName !== "A") return;
68-
69-
const node = view.state.doc.nodeAt(pos) as Node;
70-
if (!node || !node.isAtom) return;
71-
72-
// we need to check if any of the marks are links
73-
const marks = node.marks;
74-
75-
if (!marks) return;
76-
77-
const linkMark = marks.find((mark) => mark.type.name === "link");
78-
79-
if (!linkMark) return;
80-
81-
if (floatingElementRef.current) {
82-
floatingElementRef.current?.remove();
83-
}
84-
85-
if (cleanup) cleanup();
86-
87-
const href = linkMark.attrs.href;
88-
const componentLink = new ReactRenderer(LinkView, {
89-
props: {
90-
view: "LinkPreview",
91-
url: href,
92-
editor: editor,
93-
from: pos,
94-
to: pos + node.nodeSize,
95-
},
96-
editor,
97-
});
98-
99-
const referenceElement = target as HTMLElement;
100-
const floatingElement = componentLink.element as HTMLElement;
101-
102-
floatingElementRef.current = floatingElement;
103-
104-
const cleanupFunc = autoUpdate(referenceElement, floatingElement, () => {
105-
computePosition(referenceElement, floatingElement, {
106-
placement: "bottom",
107-
middleware: [
108-
flip(),
109-
shift(),
110-
hide({
111-
strategy: "referenceHidden",
112-
}),
113-
],
114-
}).then(({ x, y }) => {
115-
setCoordinates({ x: x - 300, y: y - 50 });
116-
setIsOpen(true);
117-
setLinkViewProps({
118-
closeLinkView: closeLinkView,
119-
view: "LinkPreview",
120-
url: href,
121-
editor: editor,
122-
from: pos,
123-
to: pos + node.nodeSize,
124-
});
125-
});
126-
});
127-
128-
setCleanup(cleanupFunc);
129-
},
130-
[editor, cleanup]
131-
);
13220

13321
return (
134-
<>
135-
<div className="frame-renderer flex-grow w-full" onMouseOver={handleLinkHover}>
136-
<EditorContainer
137-
displayConfig={displayConfig}
138-
editor={editor}
139-
editorContainerClassName={editorContainerClassName}
140-
id={id}
141-
>
142-
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
143-
{editor.isEditable && (
144-
<div>
145-
{bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
146-
<BlockMenu editor={editor} />
147-
<AIFeaturesMenu menu={aiHandler?.menu} />
148-
</div>
149-
)}
150-
</EditorContainer>
151-
</div>
152-
{isOpen && linkViewProps && coordinates && (
153-
<div
154-
style={{ ...floatingStyles, left: `${coordinates.x}px`, top: `${coordinates.y}px` }}
155-
className="absolute"
156-
ref={refs.setFloating}
157-
>
158-
<LinkView {...linkViewProps} style={floatingStyles} {...getFloatingProps()} />
159-
</div>
160-
)}
161-
</>
22+
<div className="frame-renderer flex-grow w-full">
23+
<EditorContainer
24+
displayConfig={displayConfig}
25+
editor={editor}
26+
editorContainerClassName={editorContainerClassName}
27+
id={id}
28+
>
29+
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
30+
{editor.isEditable && (
31+
<>
32+
{bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
33+
<BlockMenu editor={editor} />
34+
<AIFeaturesMenu menu={aiHandler?.menu} />
35+
</>
36+
)}
37+
</EditorContainer>
38+
</div>
16239
);
16340
};

packages/editor/src/core/components/editors/editor-container.tsx

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
11
import { Editor } from "@tiptap/react";
2-
import { FC, ReactNode } from "react";
2+
import { FC, ReactNode, useRef } from "react";
33
// plane utils
44
import { cn } from "@plane/utils";
55
// constants
66
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
77
// types
88
import { TDisplayConfig } from "@/types";
9+
// components
10+
import { LinkViewContainer } from "./link-view-container";
911

1012
interface EditorContainerProps {
1113
children: ReactNode;
1214
displayConfig: TDisplayConfig;
13-
editor: Editor | null;
15+
editor: Editor;
1416
editorContainerClassName: string;
1517
id: string;
1618
}
1719

1820
export const EditorContainer: FC<EditorContainerProps> = (props) => {
1921
const { children, displayConfig, editor, editorContainerClassName, id } = props;
22+
const containerRef = useRef<HTMLDivElement>(null);
2023

2124
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
2225
if (event.target !== event.currentTarget) return;
@@ -66,22 +69,26 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
6669
};
6770

6871
return (
69-
<div
70-
id={`editor-container-${id}`}
71-
onClick={handleContainerClick}
72-
onMouseLeave={handleContainerMouseLeave}
73-
className={cn(
74-
`editor-container cursor-text relative line-spacing-${displayConfig.lineSpacing ?? DEFAULT_DISPLAY_CONFIG.lineSpacing}`,
75-
{
76-
"active-editor": editor?.isFocused && editor?.isEditable,
77-
"wide-layout": displayConfig.wideLayout,
78-
},
79-
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
80-
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
81-
editorContainerClassName
82-
)}
83-
>
84-
{children}
85-
</div>
72+
<>
73+
<div
74+
ref={containerRef}
75+
id={`editor-container-${id}`}
76+
onClick={handleContainerClick}
77+
onMouseLeave={handleContainerMouseLeave}
78+
className={cn(
79+
`editor-container cursor-text relative line-spacing-${displayConfig.lineSpacing ?? DEFAULT_DISPLAY_CONFIG.lineSpacing}`,
80+
{
81+
"active-editor": editor?.isFocused && editor?.isEditable,
82+
"wide-layout": displayConfig.wideLayout,
83+
},
84+
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
85+
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
86+
editorContainerClassName
87+
)}
88+
>
89+
{children}
90+
<LinkViewContainer editor={editor} containerRef={containerRef} />
91+
</div>
92+
</>
8693
);
8794
};

packages/editor/src/core/components/editors/editor-content.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { FC, ReactNode } from "react";
21
import { Editor, EditorContent } from "@tiptap/react";
2+
import { FC, ReactNode } from "react";
33

44
interface EditorContentProps {
55
children?: ReactNode;
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { autoUpdate, flip, hide, shift, useDismiss, useFloating, useInteractions } from "@floating-ui/react";
2+
import { Editor, useEditorState } from "@tiptap/react";
3+
import { FC, useCallback, useEffect, useState } from "react";
4+
// components
5+
import { LinkView, LinkViewProps } from "@/components/links";
6+
7+
interface LinkViewContainerProps {
8+
editor: Editor;
9+
containerRef: React.RefObject<HTMLDivElement>;
10+
}
11+
12+
export const LinkViewContainer: FC<LinkViewContainerProps> = ({ editor, containerRef }) => {
13+
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
14+
const [isOpen, setIsOpen] = useState(false);
15+
const [virtualElement, setVirtualElement] = useState<any>(null);
16+
17+
const editorState = useEditorState({
18+
editor,
19+
selector: ({ editor }: { editor: Editor }) => ({
20+
linkExtensionStorage: editor.storage.link,
21+
}),
22+
});
23+
24+
const { refs, floatingStyles, context } = useFloating({
25+
open: isOpen,
26+
onOpenChange: setIsOpen,
27+
elements: {
28+
reference: virtualElement,
29+
},
30+
middleware: [
31+
flip({
32+
fallbackPlacements: ["top", "bottom"],
33+
}),
34+
shift({
35+
padding: 5,
36+
}),
37+
hide(),
38+
],
39+
whileElementsMounted: autoUpdate,
40+
placement: "bottom-start",
41+
});
42+
43+
const dismiss = useDismiss(context);
44+
45+
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]);
46+
47+
const handleLinkHover = useCallback(
48+
(event: MouseEvent) => {
49+
if (!editor || editorState.linkExtensionStorage.isBubbleMenuOpen) return;
50+
51+
// Find the closest anchor tag from the event target
52+
const target = (event.target as HTMLElement)?.closest("a");
53+
if (!target) return;
54+
55+
const referenceProps = getReferenceProps();
56+
Object.entries(referenceProps).forEach(([key, value]) => {
57+
target.setAttribute(key, value as string);
58+
});
59+
60+
const view = editor.view;
61+
if (!view) return;
62+
63+
try {
64+
const pos = view.posAtDOM(target, 0);
65+
if (pos === undefined || pos < 0) return;
66+
67+
const node = view.state.doc.nodeAt(pos);
68+
if (!node) return;
69+
70+
const linkMark = node.marks?.find((mark) => mark.type.name === "link");
71+
if (!linkMark) return;
72+
73+
setVirtualElement(target);
74+
75+
// Only update if not already open or if hovering over a different link
76+
if (!isOpen || (linkViewProps && (linkViewProps.from !== pos || linkViewProps.to !== pos + node.nodeSize))) {
77+
setLinkViewProps({
78+
view: "LinkPreview", // Always start with preview for new links
79+
url: linkMark.attrs.href,
80+
text: node.text || "",
81+
editor: editor,
82+
from: pos,
83+
to: pos + node.nodeSize,
84+
closeLinkView: () => {
85+
setIsOpen(false);
86+
editorState.linkExtensionStorage.isPreviewOpen = false;
87+
},
88+
});
89+
setIsOpen(true);
90+
}
91+
} catch (error) {
92+
console.error("Error handling link hover:", error);
93+
}
94+
},
95+
[editor, editorState.linkExtensionStorage, getReferenceProps, isOpen, linkViewProps]
96+
);
97+
98+
// Set up event listeners
99+
useEffect(() => {
100+
const container = containerRef.current;
101+
if (!container) return;
102+
103+
container.addEventListener("mouseover", handleLinkHover);
104+
105+
return () => {
106+
container.removeEventListener("mouseover", handleLinkHover);
107+
};
108+
}, [handleLinkHover]);
109+
110+
// Close link view when bubble menu opens
111+
useEffect(() => {
112+
if (editorState.linkExtensionStorage.isBubbleMenuOpen && isOpen) {
113+
setIsOpen(false);
114+
}
115+
}, [editorState.linkExtensionStorage, isOpen]);
116+
117+
return (
118+
<>
119+
{isOpen && linkViewProps && virtualElement && (
120+
<div ref={refs.setFloating} style={{ ...floatingStyles, zIndex: 100 }} {...getFloatingProps()}>
121+
<LinkView {...linkViewProps} style={floatingStyles} />
122+
</div>
123+
)}
124+
</>
125+
);
126+
};
Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
11
export * from "./link-edit-view";
2-
export * from "./link-input-view";
32
export * from "./link-preview";
43
export * from "./link-view";

0 commit comments

Comments
 (0)