diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index 4ae55f00c..396d0a821 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -71,6 +71,7 @@ export const CoreEditorExtensions = ( CustomKeymap, ListKeymap, TiptapLink.configure({ + autolink: false, protocols: ["http", "https"], validate: (url) => isValidHttpUrl(url), HTMLAttributes: { diff --git a/packages/editor/document-editor/package.json b/packages/editor/document-editor/package.json index 72dfab954..21d610751 100644 --- a/packages/editor/document-editor/package.json +++ b/packages/editor/document-editor/package.json @@ -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" diff --git a/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx b/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx new file mode 100644 index 000000000..136d04e01 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx @@ -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) => void; +}) => ( +
+ + { + e.stopPropagation(); + }} + className="w-[280px] outline-none bg-custom-background-90 text-custom-text-900 text-sm" + defaultValue={defaultValue} + onChange={onChange} + /> +
+); + +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(); + + 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 ( +
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" + > + handleUpdateLink(e.target.value)} + /> + handleUpdateText(e.target.value)} + /> +
+
+ + +
+
+ ); +}; diff --git a/packages/editor/document-editor/src/ui/components/links/link-input-view.tsx b/packages/editor/document-editor/src/ui/components/links/link-input-view.tsx new file mode 100644 index 000000000..fa73adbe1 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/links/link-input-view.tsx @@ -0,0 +1,9 @@ +import { LinkViewProps } from "./link-view"; + +export const LinkInputView = ({ + viewProps, + switchView, +}: { + viewProps: LinkViewProps; + switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void; +}) =>

LinkInputView

; diff --git a/packages/editor/document-editor/src/ui/components/links/link-preview.tsx b/packages/editor/document-editor/src/ui/components/links/link-preview.tsx new file mode 100644 index 000000000..ff3fd0263 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/links/link-preview.tsx @@ -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 ( +
+
+ +

{url.length > 40 ? url.slice(0, 40) + "..." : url}

+
+ + + +
+
+
+ ); +}; diff --git a/packages/editor/document-editor/src/ui/components/links/link-view.tsx b/packages/editor/document-editor/src/ui/components/links/link-view.tsx new file mode 100644 index 000000000..f1d22a68e --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/links/link-view.tsx @@ -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 ; + case "LinkEditView": + return ; + case "LinkInputView": + return ; + } + }; + + return renderView(); +}; diff --git a/packages/editor/document-editor/src/ui/components/page-renderer.tsx b/packages/editor/document-editor/src/ui/components/page-renderer.tsx index c2d001abe..1bda353b8 100644 --- a/packages/editor/document-editor/src/ui/components/page-renderer.tsx +++ b/packages/editor/document-editor/src/ui/components/page-renderer.tsx @@ -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; 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(); + 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(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 ( -
+
{!readonly ? ( handlePageTitleChange(e.target.value)} @@ -52,11 +180,20 @@ export const PageRenderer = (props: IPageRenderer) => { disabled /> )} -
+
+ {isOpen && linkViewProps && coordinates && ( +
+ +
+ )}
); }; diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index df3554024..34aa54c50 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -151,6 +151,7 @@ const DocumentEditor = ({
-
+
Promise.resolve()} readonly={true} editor={editor} diff --git a/yarn.lock b/yarn.lock index 8d7ada2d9..aea383166 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"