Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: link preview editor #3335

Merged
merged 10 commits into from
Jan 10, 2024
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
Loading