Skip to content

Commit

Permalink
fix: link preview editor (#3335)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
2 people authored and sriramveeraghanta committed Jan 22, 2024
1 parent 59fb371 commit e6b31e2
Show file tree
Hide file tree
Showing 10 changed files with 431 additions and 8 deletions.
1 change: 1 addition & 0 deletions packages/editor/core/src/ui/extensions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export const CoreEditorExtensions = (
CustomKeymap,
ListKeymap,
TiptapLink.configure({
autolink: false,
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
HTMLAttributes: {
Expand Down
2 changes: 2 additions & 0 deletions packages/editor/document-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"react-dom": "18.2.0"
},
"dependencies": {
"@floating-ui/react": "^0.26.4",
"@plane/editor-core": "*",
"@plane/editor-extensions": "*",
"@plane/ui": "*",
Expand All @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
);
};
Original file line number Diff line number Diff line change
@@ -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>;
Original file line number Diff line number Diff line change
@@ -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>
);
};
Original file line number Diff line number Diff line change
@@ -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();
};
Loading

0 comments on commit e6b31e2

Please sign in to comment.