-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
59fb371
commit e6b31e2
Showing
10 changed files
with
431 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
148 changes: 148 additions & 0 deletions
148
packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
9 changes: 9 additions & 0 deletions
9
packages/editor/document-editor/src/ui/components/links/link-input-view.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>; |
52 changes: 52 additions & 0 deletions
52
packages/editor/document-editor/src/ui/components/links/link-preview.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
48 changes: 48 additions & 0 deletions
48
packages/editor/document-editor/src/ui/components/links/link-view.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}; |
Oops, something went wrong.