fix: link preview editor (#3335)

* feat: added link preview plugin in document editor

* fix: readonly editor page renderer css

* fix: autolink issue with links

* chore: added floating UI

* feat: added link preview components

* feat: added floating UI to page renderer for link previews

* feat: added actionCompleteHandler to page renderer

* chore: Lock file changes

* fix: regex security error

* chore: updated radix with lucid icons

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
This commit is contained in:
Henit Chobisa 2024-01-10 18:18:09 +05:30 committed by sriram veeraghanta
parent 59fb371e3d
commit e6b31e2550
10 changed files with 431 additions and 8 deletions

View File

@ -71,6 +71,7 @@ export const CoreEditorExtensions = (
CustomKeymap,
ListKeymap,
TiptapLink.configure({
autolink: false,
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
HTMLAttributes: {

View File

@ -28,6 +28,7 @@
"react-dom": "18.2.0"
},
"dependencies": {
"@floating-ui/react": "^0.26.4",
"@plane/editor-core": "*",
"@plane/editor-extensions": "*",
"@plane/ui": "*",
@ -36,6 +37,7 @@
"@tiptap/pm": "^2.1.13",
"@tiptap/suggestion": "^2.1.13",
"eslint-config-next": "13.2.4",
"lucide-react": "^0.309.0",
"react-popper": "^2.3.0",
"tippy.js": "^6.3.7",
"uuid": "^9.0.1"

View File

@ -0,0 +1,148 @@
import { isValidHttpUrl } from "@plane/editor-core";
import { Node } from "@tiptap/pm/model";
import { Link2Off } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { LinkViewProps } from "./link-view";
const InputView = ({
label,
defaultValue,
placeholder,
onChange,
}: {
label: string;
defaultValue: string;
placeholder: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) => (
<div className="flex flex-col gap-1">
<label className="inline-block font-semibold text-xs text-custom-text-400">{label}</label>
<input
placeholder={placeholder}
onClick={(e) => {
e.stopPropagation();
}}
className="w-[280px] outline-none bg-custom-background-90 text-custom-text-900 text-sm"
defaultValue={defaultValue}
onChange={onChange}
/>
</div>
);
export const LinkEditView = ({
viewProps,
}: {
viewProps: LinkViewProps;
switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void;
}) => {
const { editor, from, to } = viewProps;
const [positionRef, setPositionRef] = useState({ from: from, to: to });
const [localUrl, setLocalUrl] = useState(viewProps.url);
const linkRemoved = useRef<Boolean>();
const getText = (from: number, to: number) => {
const text = editor.state.doc.textBetween(from, to, "\n");
return text;
};
const isValidUrl = (urlString: string) => {
var urlPattern = new RegExp(
"^(https?:\\/\\/)?" + // validate protocol
"([\\w-]+\\.)+[\\w-]{2,}" + // validate domain name
"|((\\d{1,3}\\.){3}\\d{1,3})" + // validate IP (v4) address
"(\\:\\d+)?(\\/[-\\w.%]+)*" + // validate port and path
"(\\?[;&\\w.%=-]*)?" + // validate query string
"(\\#[-\\w]*)?$", // validate fragment locator
"i"
);
const regexTest = urlPattern.test(urlString);
const urlTest = isValidHttpUrl(urlString); // Ensure you have defined isValidHttpUrl
return regexTest && urlTest;
};
const handleUpdateLink = (url: string) => {
setLocalUrl(url);
};
useEffect(
() => () => {
if (linkRemoved.current) return;
const url = isValidUrl(localUrl) ? localUrl : viewProps.url;
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
editor.view.dispatch(editor.state.tr.addMark(from, to, editor.schema.marks.link.create({ href: url })));
},
[localUrl]
);
const handleUpdateText = (text: string) => {
if (text === "") {
return;
}
const node = editor.view.state.doc.nodeAt(from) as Node;
if (!node) return;
const marks = node.marks;
if (!marks) return;
editor.chain().setTextSelection(from).run();
editor.chain().deleteRange({ from: positionRef.from, to: positionRef.to }).run();
editor.chain().insertContent(text).run();
editor
.chain()
.setTextSelection({
from: from,
to: from + text.length,
})
.run();
setPositionRef({ from: from, to: from + text.length });
marks.forEach((mark) => {
editor.chain().setMark(mark.type.name, mark.attrs).run();
});
};
const removeLink = () => {
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
linkRemoved.current = true;
viewProps.onActionCompleteHandler({
title: "Link successfully removed",
message: "The link was removed from the text.",
type: "success",
});
viewProps.closeLinkView();
};
return (
<div
onKeyDown={(e) => e.key === "Enter" && viewProps.closeLinkView()}
className="shadow-md rounded p-2 flex flex-col gap-3 bg-custom-background-90 border-custom-border-100 border-2"
>
<InputView
label={"URL"}
placeholder={"Enter or paste URL"}
defaultValue={localUrl}
onChange={(e) => handleUpdateLink(e.target.value)}
/>
<InputView
label={"Text"}
placeholder={"Enter Text to display"}
defaultValue={getText(from, to)}
onChange={(e) => handleUpdateText(e.target.value)}
/>
<div className="mb-1 bg-custom-border-300 h-[1px] w-full gap-2" />
<div className="flex text-sm text-custom-text-800 gap-2 items-center">
<Link2Off size={14} className="inline-block" />
<button onClick={() => removeLink()} className="cursor-pointer">
Remove Link
</button>
</div>
</div>
);
};

View File

@ -0,0 +1,9 @@
import { LinkViewProps } from "./link-view";
export const LinkInputView = ({
viewProps,
switchView,
}: {
viewProps: LinkViewProps;
switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void;
}) => <p>LinkInputView</p>;

View File

@ -0,0 +1,52 @@
import { Copy, GlobeIcon, Link2Off, PencilIcon } from "lucide-react";
import { LinkViewProps } from "./link-view";
export const LinkPreview = ({
viewProps,
switchView,
}: {
viewProps: LinkViewProps;
switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void;
}) => {
const { editor, from, to, url } = viewProps;
const removeLink = () => {
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
viewProps.onActionCompleteHandler({
title: "Link successfully removed",
message: "The link was removed from the text.",
type: "success",
});
viewProps.closeLinkView();
};
const copyLinkToClipboard = () => {
navigator.clipboard.writeText(url);
viewProps.onActionCompleteHandler({
title: "Link successfully copied",
message: "The link was copied to the clipboard.",
type: "success",
});
viewProps.closeLinkView();
};
return (
<div className="absolute left-0 top-0 max-w-max">
<div className="shadow-md items-center rounded p-2 flex gap-3 bg-custom-background-90 border-custom-border-100 border-2 text-custom-text-300 text-xs">
<GlobeIcon size={14} className="inline-block" />
<p>{url.length > 40 ? url.slice(0, 40) + "..." : url}</p>
<div className="flex gap-2">
<button onClick={copyLinkToClipboard} className="cursor-pointer">
<Copy size={14} className="inline-block" />
</button>
<button onClick={() => switchView("LinkEditView")} className="cursor-pointer">
<PencilIcon size={14} className="inline-block" />
</button>
<button onClick={removeLink} className="cursor-pointer">
<Link2Off size={14} className="inline-block" />
</button>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,48 @@
import { Editor } from "@tiptap/react";
import { CSSProperties, useEffect, useState } from "react";
import { LinkEditView } from "./link-edit-view";
import { LinkInputView } from "./link-input-view";
import { LinkPreview } from "./link-preview";
export interface LinkViewProps {
view?: "LinkPreview" | "LinkEditView" | "LinkInputView";
editor: Editor;
from: number;
to: number;
url: string;
closeLinkView: () => void;
onActionCompleteHandler: (action: {
title: string;
message: string;
type: "success" | "error" | "warning" | "info";
}) => void;
}
export const LinkView = (props: LinkViewProps & { style: CSSProperties }) => {
const [currentView, setCurrentView] = useState(props.view ?? "LinkInputView");
const [prevFrom, setPrevFrom] = useState(props.from);
const switchView = (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => {
setCurrentView(view);
};
useEffect(() => {
if (props.from !== prevFrom) {
setCurrentView("LinkPreview");
setPrevFrom(props.from);
}
}, []);
const renderView = () => {
switch (currentView) {
case "LinkPreview":
return <LinkPreview viewProps={props} switchView={switchView} />;
case "LinkEditView":
return <LinkEditView viewProps={props} switchView={switchView} />;
case "LinkInputView":
return <LinkInputView viewProps={props} switchView={switchView} />;
}
};
return renderView();
};

View File

@ -1,12 +1,30 @@
import { EditorContainer, EditorContentWrapper } from "@plane/editor-core";
import { Editor } from "@tiptap/react";
import { useState } from "react";
import { Node } from "@tiptap/pm/model";
import { EditorView } from "@tiptap/pm/view";
import { Editor, ReactRenderer } from "@tiptap/react";
import { useCallback, useRef, useState } from "react";
import { DocumentDetails } from "src/types/editor-types";
import { LinkView, LinkViewProps } from "./links/link-view";
import {
autoUpdate,
computePosition,
flip,
hide,
shift,
useDismiss,
useFloating,
useInteractions,
} from "@floating-ui/react";
type IPageRenderer = {
documentDetails: DocumentDetails;
updatePageTitle: (title: string) => Promise<void>;
editor: Editor;
onActionCompleteHandler: (action: {
title: string;
message: string;
type: "success" | "error" | "warning" | "info";
}) => void;
editorClassNames: string;
editorContentCustomClassNames?: string;
readonly: boolean;
@ -29,6 +47,23 @@ export const PageRenderer = (props: IPageRenderer) => {
const [pageTitle, setPagetitle] = useState(documentDetails.title);
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
const [isOpen, setIsOpen] = useState(false);
const [coordinates, setCoordinates] = useState<{ x: number; y: number }>();
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 debouncedUpdatePageTitle = debounce(updatePageTitle, 300);
const handlePageTitleChange = (title: string) => {
@ -36,8 +71,101 @@ export const PageRenderer = (props: IPageRenderer) => {
debouncedUpdatePageTitle(title);
};
const [cleanup, setcleanup] = useState(() => () => {});
const floatingElementRef = useRef<HTMLElement | null>(null);
const closeLinkView = () => {
setIsOpen(false);
};
const switchLinkView = (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => {
if (!linkViewProps) return;
setLinkViewProps({
...linkViewProps,
view: view,
});
};
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({
onActionCompleteHandler: props.onActionCompleteHandler,
closeLinkView: closeLinkView,
view: "LinkPreview",
url: href,
editor: editor,
from: pos,
to: pos + node.nodeSize,
});
});
});
setcleanup(cleanupFunc);
},
[editor, cleanup]
);
return (
<div className="w-full pb-64 pl-7 pt-5">
<div className="w-full pb-64 pl-7 pt-5 page-renderer">
{!readonly ? (
<input
onChange={(e) => handlePageTitleChange(e.target.value)}
@ -52,11 +180,20 @@ export const PageRenderer = (props: IPageRenderer) => {
disabled
/>
)}
<div className="flex h-full w-full flex-col pr-5">
<div className="flex relative h-full w-full flex-col pr-5 editor-renderer" onMouseOver={handleLinkHover}>
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
</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>
);
};

View File

@ -151,6 +151,7 @@ const DocumentEditor = ({
</div>
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)]">
<PageRenderer
onActionCompleteHandler={onActionCompleteHandler}
readonly={false}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}

View File

@ -109,8 +109,9 @@ const DocumentReadOnlyEditor = ({
<div className="sticky top-0 h-full w-56 flex-shrink-0 lg:w-80">
<SummarySideBar editor={editor} markings={markings} sidePeekVisible={sidePeekVisible} />
</div>
<div className="h-full w-full">
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)]">
<PageRenderer
onActionCompleteHandler={onActionCompleteHandler}
updatePageTitle={() => Promise.resolve()}
readonly={true}
editor={editor}

View File

@ -1358,14 +1358,23 @@
"@floating-ui/core" "^1.4.2"
"@floating-ui/utils" "^0.1.3"
"@floating-ui/react-dom@^2.0.4":
"@floating-ui/react-dom@^2.0.3", "@floating-ui/react-dom@^2.0.4":
version "2.0.4"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.4.tgz#b076fafbdfeb881e1d86ae748b7ff95150e9f3ec"
integrity sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ==
dependencies:
"@floating-ui/dom" "^1.5.1"
"@floating-ui/utils@^0.1.3":
"@floating-ui/react@^0.26.4":
version "0.26.4"
resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.26.4.tgz#7626667d2dabc80e2696b500df7f1a348d7ec7a8"
integrity sha512-pRiEz+SiPyfTcckAtLkEf3KJ/sUbB4X4fWMcDm27HT2kfAq+dH+hMc2VoOkNaGpDE35a2PKo688ugWeHaToL3g==
dependencies:
"@floating-ui/react-dom" "^2.0.3"
"@floating-ui/utils" "^0.1.5"
tabbable "^6.0.1"
"@floating-ui/utils@^0.1.3", "@floating-ui/utils@^0.1.5":
version "0.1.6"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.6.tgz#22958c042e10b67463997bd6ea7115fe28cbcaf9"
integrity sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==
@ -1919,6 +1928,11 @@
"@radix-ui/react-primitive" "1.0.0"
"@radix-ui/react-use-callback-ref" "1.0.0"
"@radix-ui/react-icons@^1.3.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-icons/-/react-icons-1.3.0.tgz#c61af8f323d87682c5ca76b856d60c2312dbcb69"
integrity sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw==
"@radix-ui/react-id@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.0.0.tgz#8d43224910741870a45a8c9d092f25887bb6d11e"
@ -2793,7 +2807,7 @@
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@18.2.42", "@types/react@^18.2.42":
"@types/react@*", "@types/react@^18.2.42":
version "18.2.42"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.42.tgz#6f6b11a904f6d96dda3c2920328a97011a00aba7"
integrity sha512-c1zEr96MjakLYus/wPnuWDo1/zErfdU9rNsIGmE+NV71nx88FG9Ttgo5dqorXTu/LImX2f63WBP986gJkMPNbA==
@ -6198,6 +6212,11 @@ lucide-react@^0.294.0:
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.294.0.tgz#dc406e1e7e2f722cf93218fe5b31cf3c95778817"
integrity sha512-V7o0/VECSGbLHn3/1O67FUgBwWB+hmzshrgDVRJQhMh8uj5D3HBuIvhuAmQTtlupILSplwIZg5FTc4tTKMA2SA==
lucide-react@^0.309.0:
version "0.309.0"
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.309.0.tgz#7369893cb4b074a0a0b1d3acdc6fd9a8bdb5add1"
integrity sha512-zNVPczuwFrCfksZH3zbd1UDE6/WYhYAdbe2k7CImVyPAkXLgIwbs6eXQ4loigqDnUFjyFYCI5jZ1y10Kqal0dg==
magic-string@^0.25.0, magic-string@^0.25.7:
version "0.25.9"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c"
@ -8323,6 +8342,11 @@ swr@^2.1.3, swr@^2.2.2:
client-only "^0.0.1"
use-sync-external-store "^1.2.0"
tabbable@^6.0.1:
version "6.2.0"
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97"
integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==
table@^6.0.9:
version "6.8.1"
resolved "https://registry.yarnpkg.com/table/-/table-6.8.1.tgz#ea2b71359fe03b017a5fbc296204471158080bdf"