From 4cc622cfa9d525294b4876e27677f1f5f1a61842 Mon Sep 17 00:00:00 2001 From: Zach Gavin <9595970+zgavin1@users.noreply.github.com> Date: Wed, 2 Apr 2025 20:47:20 -0500 Subject: [PATCH 1/8] wip adding link button with dialog for text and url inputs --- package-lock.json | 24 ++ package.json | 1 + src/app/coaching-sessions/[id]/page.tsx | 3 - .../coaching-notes/extensions.tsx | 2 + .../coaching-notes/toolbar.tsx | 221 ++++++++++++------ src/styles/styles.scss | 15 ++ 6 files changed, 195 insertions(+), 71 deletions(-) diff --git a/package-lock.json b/package-lock.json index 49330d7..0f677c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "@tiptap/extension-collaboration-cursor": "^2.11.5", "@tiptap/extension-heading": "^2.10.4", "@tiptap/extension-highlight": "^2.10.4", + "@tiptap/extension-link": "^2.11.7", "@tiptap/extension-list-item": "^2.10.4", "@tiptap/extension-ordered-list": "^2.10.4", "@tiptap/extension-underline": "^2.10.4", @@ -2557,6 +2558,23 @@ "@tiptap/core": "^2.7.0" } }, + "node_modules/@tiptap/extension-link": { + "version": "2.11.7", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.11.7.tgz", + "integrity": "sha512-qKIowE73aAUrnQCIifYP34xXOHOsZw46cT/LBDlb0T60knVfQoKVE4ku08fJzAV+s6zqgsaaZ4HVOXkQYLoW7g==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, "node_modules/@tiptap/extension-list-item": { "version": "2.11.7", "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.11.7.tgz", @@ -6208,6 +6226,12 @@ "uc.micro": "^2.0.0" } }, + "node_modules/linkifyjs": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.2.0.tgz", + "integrity": "sha512-pCj3PrQyATaoTYKHrgWRF3SJwsm61udVh+vuls/Rl6SptiDhgE7ziUIudAedRY9QEfynmM7/RmLEfPUyw1HPCw==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", diff --git a/package.json b/package.json index 58038a2..362481d 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@tiptap/extension-collaboration-cursor": "^2.11.5", "@tiptap/extension-heading": "^2.10.4", "@tiptap/extension-highlight": "^2.10.4", + "@tiptap/extension-link": "^2.11.7", "@tiptap/extension-list-item": "^2.10.4", "@tiptap/extension-ordered-list": "^2.10.4", "@tiptap/extension-underline": "^2.10.4", diff --git a/src/app/coaching-sessions/[id]/page.tsx b/src/app/coaching-sessions/[id]/page.tsx index 788e6e4..3861482 100644 --- a/src/app/coaching-sessions/[id]/page.tsx +++ b/src/app/coaching-sessions/[id]/page.tsx @@ -2,9 +2,7 @@ import { Separator } from "@/components/ui/separator"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Textarea } from "@/components/ui/textarea"; -import { useState } from "react"; import { useAuthStore } from "@/lib/providers/auth-store-provider"; import { siteConfig } from "@/site.config"; @@ -12,7 +10,6 @@ import { CoachingSessionTitle } from "@/components/ui/coaching-sessions/coaching import { OverarchingGoalContainer } from "@/components/ui/coaching-sessions/overarching-goal-container"; import { CoachingNotes } from "@/components/ui/coaching-sessions/coaching-notes"; -import { LockClosedIcon } from "@radix-ui/react-icons"; import CoachingSessionSelector from "@/components/ui/coaching-session-selector"; import { useRouter } from "next/navigation"; import { useCoachingRelationshipStateStore } from "@/lib/providers/coaching-relationship-state-store-provider"; diff --git a/src/components/ui/coaching-sessions/coaching-notes/extensions.tsx b/src/components/ui/coaching-sessions/coaching-notes/extensions.tsx index 6abd9a2..b897b9d 100644 --- a/src/components/ui/coaching-sessions/coaching-notes/extensions.tsx +++ b/src/components/ui/coaching-sessions/coaching-notes/extensions.tsx @@ -11,6 +11,7 @@ import Paragraph from "@tiptap/extension-paragraph"; import Strike from "@tiptap/extension-strike"; import Text from "@tiptap/extension-text"; import Underline from "@tiptap/extension-underline"; +import Link from "@tiptap/extension-link"; import Collaboration from "@tiptap/extension-collaboration"; import { ReactNodeViewRenderer } from "@tiptap/react"; import CodeBlock from "@/components/ui/coaching-sessions/code-block"; @@ -43,6 +44,7 @@ export const Extensions = ( Strike, Text, Underline, + Link, Collaboration.configure({ document: doc, }), diff --git a/src/components/ui/coaching-sessions/coaching-notes/toolbar.tsx b/src/components/ui/coaching-sessions/coaching-notes/toolbar.tsx index 843b951..e0a9de3 100644 --- a/src/components/ui/coaching-sessions/coaching-notes/toolbar.tsx +++ b/src/components/ui/coaching-sessions/coaching-notes/toolbar.tsx @@ -12,85 +12,170 @@ import { ListOrdered, Strikethrough, Braces, + Link, } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { useState } from "react"; export const Toolbar = () => { const { editor } = useCurrentEditor(); + const [isLinkDialogOpen, setIsLinkDialogOpen] = useState(false); + const [linkUrl, setLinkUrl] = useState(""); + const [linkText, setLinkText] = useState(""); + if (!editor) { return null; } + const handleLinkButtonClick = () => { + editor.isActive("link") + ? editor.chain().focus().toggleLink({ href: linkUrl }).run() + : setIsLinkDialogOpen(true); + }; + return ( -
- editor.chain().focus().toggleBold().run()} - isActive={editor.isActive("bold")} - icon={} - title="Bold (Ctrl+B)" - /> - editor.chain().focus().toggleItalic().run()} - isActive={editor.isActive("italic")} - icon={} - title="Italic (Ctrl+I)" - /> - editor.chain().focus().toggleUnderline().run()} - isActive={editor.isActive("underline")} - icon={} - title="Underline (Ctrl+U)" - /> - editor.chain().focus().toggleStrike().run()} - isActive={editor.isActive("strike")} - icon={} - title="Strike Through" - /> - editor.chain().focus().toggleHighlight().run()} - isActive={editor.isActive("highlight")} - icon={} - title="Highlight Text" - /> - editor.chain().focus().toggleHeading({ level: 1 }).run()} - isActive={editor.isActive("heading", { level: 1 })} - icon={} - title="Heading 1" - /> - editor.chain().focus().toggleHeading({ level: 2 }).run()} - isActive={editor.isActive("heading", { level: 2 })} - icon={} - title="Heading 2" - /> - editor.chain().focus().toggleHeading({ level: 3 }).run()} - isActive={editor.isActive("heading", { level: 3 })} - icon={} - title="Heading 3" - /> - editor.chain().focus().toggleBulletList().run()} - isActive={editor.isActive("bulletList")} - icon={} - title="Bullet List" - /> - editor.chain().focus().toggleOrderedList().run()} - isActive={editor.isActive("orderedList")} - icon={} - title="Ordered List" - /> - editor.chain().focus().toggleCodeBlock().run()} - isActive={editor.isActive("codeBlock")} - icon={} - title="Code Block" - /> -
+ <> +
+ editor.chain().focus().toggleBold().run()} + isActive={editor.isActive("bold")} + icon={} + title="Bold (Ctrl+B)" + /> + editor.chain().focus().toggleItalic().run()} + isActive={editor.isActive("italic")} + icon={} + title="Italic (Ctrl+I)" + /> + editor.chain().focus().toggleUnderline().run()} + isActive={editor.isActive("underline")} + icon={} + title="Underline (Ctrl+U)" + /> + editor.chain().focus().toggleStrike().run()} + isActive={editor.isActive("strike")} + icon={} + title="Strike Through" + /> + editor.chain().focus().toggleHighlight().run()} + isActive={editor.isActive("highlight")} + icon={} + title="Highlight Text" + /> + + editor.chain().focus().toggleHeading({ level: 1 }).run() + } + isActive={editor.isActive("heading", { level: 1 })} + icon={} + title="Heading 1" + /> + + editor.chain().focus().toggleHeading({ level: 2 }).run() + } + isActive={editor.isActive("heading", { level: 2 })} + icon={} + title="Heading 2" + /> + + editor.chain().focus().toggleHeading({ level: 3 }).run() + } + isActive={editor.isActive("heading", { level: 3 })} + icon={} + title="Heading 3" + /> + editor.chain().focus().toggleBulletList().run()} + isActive={editor.isActive("bulletList")} + icon={} + title="Bullet List" + /> + editor.chain().focus().toggleOrderedList().run()} + isActive={editor.isActive("orderedList")} + icon={} + title="Ordered List" + /> + editor.chain().focus().toggleCodeBlock().run()} + isActive={editor.isActive("codeBlock")} + icon={} + title="Code Block" + /> + handleLinkButtonClick()} + isActive={editor.isActive("link")} + icon={} + title="Link" + /> +
+ + + + + Insert Link + +
+
+ + ) => + setLinkText(e.target.value) + } + className="col-span-3" + /> +
+
+ + ) => + setLinkUrl(e.target.value) + } + className="col-span-3" + /> +
+
+ + + +
+
+ ); }; diff --git a/src/styles/styles.scss b/src/styles/styles.scss index c320abb..2dcfcb5 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -143,6 +143,21 @@ border-top: 1px solid var(--gray-2); margin: 2rem 0; } + + a { + color: #0000EE; + text-decoration: underline; + } + /* Link styles */ + a { + text-decoration: underline; + color: #0000EE; + cursor: pointer; + + &:hover { + color: var(--purple-contrast); + } + } } /* Give a remote user a caret */ From 00b4dc48e54c07a185a5e215e3e774637780cec9 Mon Sep 17 00:00:00 2001 From: Zach Gavin <9595970+zgavin1@users.noreply.github.com> Date: Thu, 3 Apr 2025 11:36:33 -0500 Subject: [PATCH 2/8] Get links working in editor --- .../coaching-notes/extensions.tsx | 72 ++++++++++++++- .../coaching-notes/link-dialog.tsx | 90 +++++++++++++++++++ .../coaching-notes/toolbar.tsx | 75 ++-------------- 3 files changed, 168 insertions(+), 69 deletions(-) create mode 100644 src/components/ui/coaching-sessions/coaching-notes/link-dialog.tsx diff --git a/src/components/ui/coaching-sessions/coaching-notes/extensions.tsx b/src/components/ui/coaching-sessions/coaching-notes/extensions.tsx index b897b9d..6d707be 100644 --- a/src/components/ui/coaching-sessions/coaching-notes/extensions.tsx +++ b/src/components/ui/coaching-sessions/coaching-notes/extensions.tsx @@ -44,7 +44,77 @@ export const Extensions = ( Strike, Text, Underline, - Link, + Link.configure({ + openOnClick: false, + autolink: true, + defaultProtocol: "https", + protocols: ["http", "https"], + isAllowedUri: (url, ctx) => { + try { + // construct URL + const parsedUrl = url.includes(":") + ? new URL(url) + : new URL(`${ctx.defaultProtocol}://${url}`); + + // use default validation + if (!ctx.defaultValidate(parsedUrl.href)) { + return false; + } + + // disallowed protocols + const disallowedProtocols = ["ftp", "file", "mailto"]; + const protocol = parsedUrl.protocol.replace(":", ""); + + if (disallowedProtocols.includes(protocol)) { + return false; + } + + // only allow protocols specified in ctx.protocols + const allowedProtocols = ctx.protocols.map((p) => + typeof p === "string" ? p : p.scheme + ); + + if (!allowedProtocols.includes(protocol)) { + return false; + } + + // disallowed domains + const disallowedDomains = [ + "example-phishing.com", + "malicious-site.net", + ]; + const domain = parsedUrl.hostname; + + if (disallowedDomains.includes(domain)) { + return false; + } + + // all checks have passed + return true; + } catch { + return false; + } + }, + shouldAutoLink: (url) => { + try { + // construct URL + const parsedUrl = url.includes(":") + ? new URL(url) + : new URL(`https://${url}`); + + // only auto-link if the domain is not in the disallowed list + const disallowedDomains = [ + "example-no-autolink.com", + "another-no-autolink.com", + ]; + const domain = parsedUrl.hostname; + + return !disallowedDomains.includes(domain); + } catch { + return false; + } + }, + }), Collaboration.configure({ document: doc, }), diff --git a/src/components/ui/coaching-sessions/coaching-notes/link-dialog.tsx b/src/components/ui/coaching-sessions/coaching-notes/link-dialog.tsx new file mode 100644 index 0000000..a5671f0 --- /dev/null +++ b/src/components/ui/coaching-sessions/coaching-notes/link-dialog.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { Editor } from "@tiptap/core"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; + +interface LinkDialogProps { + editor: Editor; + isOpen: boolean; + onOpenChange: (open: boolean) => void; +} + +export const LinkDialog = ({ + editor, + isOpen, + onOpenChange, +}: LinkDialogProps) => { + const [linkUrl, setLinkUrl] = React.useState(""); + + React.useEffect(() => { + if (isOpen) { + setLinkUrl(editor.getAttributes("link").href || ""); + } + }, [isOpen, editor]); + + const setLink = React.useCallback(() => { + // cancelled + if (linkUrl === null) { + return; + } + + // empty + if (linkUrl === "") { + editor.chain().focus().extendMarkRange("link").unsetLink().run(); + onOpenChange(false); + return; + } + + // update link + try { + editor + .chain() + .focus() + .extendMarkRange("link") + .setLink({ href: linkUrl }) + .run(); + setLinkUrl(""); + onOpenChange(false); + } catch (e) { + console.error("Error inserting link:", e); + } + }, [editor, linkUrl, onOpenChange]); + + return ( + + + + Insert Link + + Insert a url for a link in your document. + + +
+ + ) => + setLinkUrl(e.target.value) + } + className="col-span-5" + /> +
+ + + +
+
+ ); +}; diff --git a/src/components/ui/coaching-sessions/coaching-notes/toolbar.tsx b/src/components/ui/coaching-sessions/coaching-notes/toolbar.tsx index e0a9de3..3cb8ab0 100644 --- a/src/components/ui/coaching-sessions/coaching-notes/toolbar.tsx +++ b/src/components/ui/coaching-sessions/coaching-notes/toolbar.tsx @@ -15,35 +15,17 @@ import { Link, } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; import { useState } from "react"; +import { LinkDialog } from "./link-dialog"; export const Toolbar = () => { const { editor } = useCurrentEditor(); - const [isLinkDialogOpen, setIsLinkDialogOpen] = useState(false); - const [linkUrl, setLinkUrl] = useState(""); - const [linkText, setLinkText] = useState(""); if (!editor) { return null; } - const handleLinkButtonClick = () => { - editor.isActive("link") - ? editor.chain().focus().toggleLink({ href: linkUrl }).run() - : setIsLinkDialogOpen(true); - }; - return ( <>
@@ -120,61 +102,18 @@ export const Toolbar = () => { title="Code Block" /> handleLinkButtonClick()} + onClick={() => setIsLinkDialogOpen(true)} isActive={editor.isActive("link")} icon={} title="Link" />
- - - - Insert Link - -
-
- - ) => - setLinkText(e.target.value) - } - className="col-span-3" - /> -
-
- - ) => - setLinkUrl(e.target.value) - } - className="col-span-3" - /> -
-
- - - -
-
+ ); }; From 27cbb151c4a766a7b23e632150be722c71ba38f5 Mon Sep 17 00:00:00 2001 From: Zach Gavin <9595970+zgavin1@users.noreply.github.com> Date: Thu, 3 Apr 2025 11:45:54 -0500 Subject: [PATCH 3/8] Comment out some placeholder logic for link extension --- .../coaching-notes/extensions.tsx | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/components/ui/coaching-sessions/coaching-notes/extensions.tsx b/src/components/ui/coaching-sessions/coaching-notes/extensions.tsx index 6d707be..512c307 100644 --- a/src/components/ui/coaching-sessions/coaching-notes/extensions.tsx +++ b/src/components/ui/coaching-sessions/coaching-notes/extensions.tsx @@ -45,7 +45,7 @@ export const Extensions = ( Text, Underline, Link.configure({ - openOnClick: false, + openOnClick: true, autolink: true, defaultProtocol: "https", protocols: ["http", "https"], @@ -61,33 +61,33 @@ export const Extensions = ( return false; } - // disallowed protocols - const disallowedProtocols = ["ftp", "file", "mailto"]; - const protocol = parsedUrl.protocol.replace(":", ""); + // Placeholder for future disallowed domains if we want to add any + // const disallowedProtocols = ["ftp", "file", "mailto"]; + // const protocol = parsedUrl.protocol.replace(":", ""); - if (disallowedProtocols.includes(protocol)) { - return false; - } + // if (disallowedProtocols.includes(protocol)) { + // return false; + // } - // only allow protocols specified in ctx.protocols - const allowedProtocols = ctx.protocols.map((p) => - typeof p === "string" ? p : p.scheme - ); + // // only allow protocols specified in ctx.protocols + // const allowedProtocols = ctx.protocols.map((p) => + // typeof p === "string" ? p : p.scheme + // ); - if (!allowedProtocols.includes(protocol)) { - return false; - } + // if (!allowedProtocols.includes(protocol)) { + // return false; + // } - // disallowed domains - const disallowedDomains = [ - "example-phishing.com", - "malicious-site.net", - ]; - const domain = parsedUrl.hostname; + // Placeholder for future disallowed domains if we want to add any + // const disallowedDomains = [ + // "example-phishing.com", + // "malicious-site.net", + // ]; + // const domain = parsedUrl.hostname; - if (disallowedDomains.includes(domain)) { - return false; - } + // if (disallowedDomains.includes(domain)) { + // return false; + // } // all checks have passed return true; From 1cb23032d835dfc31fa2a9161a8c4fcaf7c16129 Mon Sep 17 00:00:00 2001 From: Zach Gavin <9595970+zgavin1@users.noreply.github.com> Date: Mon, 7 Apr 2025 19:36:29 -0500 Subject: [PATCH 4/8] progress on linking improvements and review comments --- .../coaching-notes/extensions.tsx | 2 +- .../coaching-notes/link-dialog.tsx | 1 + .../coaching-notes/toolbar.tsx | 20 +++++++++++++++++-- src/styles/styles.scss | 6 +----- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/components/ui/coaching-sessions/coaching-notes/extensions.tsx b/src/components/ui/coaching-sessions/coaching-notes/extensions.tsx index 512c307..d56876b 100644 --- a/src/components/ui/coaching-sessions/coaching-notes/extensions.tsx +++ b/src/components/ui/coaching-sessions/coaching-notes/extensions.tsx @@ -45,7 +45,7 @@ export const Extensions = ( Text, Underline, Link.configure({ - openOnClick: true, + openOnClick: false, autolink: true, defaultProtocol: "https", protocols: ["http", "https"], diff --git a/src/components/ui/coaching-sessions/coaching-notes/link-dialog.tsx b/src/components/ui/coaching-sessions/coaching-notes/link-dialog.tsx index a5671f0..dc3abe9 100644 --- a/src/components/ui/coaching-sessions/coaching-notes/link-dialog.tsx +++ b/src/components/ui/coaching-sessions/coaching-notes/link-dialog.tsx @@ -51,6 +51,7 @@ export const LinkDialog = ({ .focus() .extendMarkRange("link") .setLink({ href: linkUrl }) + .updateAttributes("link", { title: linkUrl }) .run(); setLinkUrl(""); onOpenChange(false); diff --git a/src/components/ui/coaching-sessions/coaching-notes/toolbar.tsx b/src/components/ui/coaching-sessions/coaching-notes/toolbar.tsx index 3cb8ab0..dc471b5 100644 --- a/src/components/ui/coaching-sessions/coaching-notes/toolbar.tsx +++ b/src/components/ui/coaching-sessions/coaching-notes/toolbar.tsx @@ -1,4 +1,4 @@ -import type React from "react"; +import React, { useEffect } from "react"; import { useCurrentEditor } from "@tiptap/react"; import { Bold, @@ -25,6 +25,18 @@ export const Toolbar = () => { if (!editor) { return null; } + // Add keyboard shortcut handler + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + setIsLinkDialogOpen(true); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, []); return ( <> @@ -105,7 +117,11 @@ export const Toolbar = () => { onClick={() => setIsLinkDialogOpen(true)} isActive={editor.isActive("link")} icon={} - title="Link" + title={ + editor.isActive("link") + ? "Update link (Ctrl + k)" + : "Insert link (Ctrl + k)" + } /> diff --git a/src/styles/styles.scss b/src/styles/styles.scss index 2dcfcb5..9533925 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -144,11 +144,7 @@ margin: 2rem 0; } - a { - color: #0000EE; - text-decoration: underline; - } - /* Link styles */ + /* Editor Link styles */ a { text-decoration: underline; color: #0000EE; From c4fa96efe93f2eea7ecf0409381077a05cb07efc Mon Sep 17 00:00:00 2001 From: Zach Gavin <9595970+zgavin1@users.noreply.github.com> Date: Mon, 7 Apr 2025 20:21:57 -0500 Subject: [PATCH 5/8] Add default protocol, basic validation --- .../extended-link-extension.tsx | 92 +++++++++++++++++++ .../coaching-notes/extensions.tsx | 73 +-------------- .../coaching-notes/link-dialog.tsx | 13 ++- 3 files changed, 102 insertions(+), 76 deletions(-) create mode 100644 src/components/ui/coaching-sessions/coaching-notes/extended-link-extension.tsx diff --git a/src/components/ui/coaching-sessions/coaching-notes/extended-link-extension.tsx b/src/components/ui/coaching-sessions/coaching-notes/extended-link-extension.tsx new file mode 100644 index 0000000..972621b --- /dev/null +++ b/src/components/ui/coaching-sessions/coaching-notes/extended-link-extension.tsx @@ -0,0 +1,92 @@ +import Link from "@tiptap/extension-link"; + +const LinkWithTitle = Link.extend({ + addAttributes() { + return { + ...this.parent?.(), + title: { + default: null, + renderHTML: (attributes) => { + if (!attributes.href) { + return {}; + } + return { + title: `Right-click to open ${attributes.href}`, + }; + }, + }, + }; + }, +}); + +export const ConfiguredLink = LinkWithTitle.configure({ + openOnClick: false, + autolink: true, + defaultProtocol: "https", + protocols: ["http", "https"], + isAllowedUri: (url, ctx) => { + try { + // construct URL + const parsedUrl = url.includes(":") + ? new URL(url) + : new URL(`${ctx.defaultProtocol}://${url}`); + + // use default validation + if (!ctx.defaultValidate(parsedUrl.href)) { + return false; + } + + // Placeholder for future disallowed domains if we want to add any + // const disallowedProtocols = ["ftp", "file", "mailto"]; + // const protocol = parsedUrl.protocol.replace(":", ""); + + // if (disallowedProtocols.includes(protocol)) { + // return false; + // } + + // // only allow protocols specified in ctx.protocols + // const allowedProtocols = ctx.protocols.map((p) => + // typeof p === "string" ? p : p.scheme + // ); + + // if (!allowedProtocols.includes(protocol)) { + // return false; + // } + + // Placeholder for future disallowed domains if we want to add any + // const disallowedDomains = [ + // "example-phishing.com", + // "malicious-site.net", + // ]; + // const domain = parsedUrl.hostname; + + // if (disallowedDomains.includes(domain)) { + // return false; + // } + + // all checks have passed + return true; + } catch { + return false; + } + }, + shouldAutoLink: (url) => { + try { + // construct URL + const parsedUrl = url.includes(":") + ? new URL(url) + : new URL(`https://${url}`); + + // only auto-link if the domain is not in the disallowed list + const disallowedDomains = [ + "example-no-autolink.com", + "another-no-autolink.com", + ]; + const domain = parsedUrl.hostname; + + return !disallowedDomains.includes(domain); + } catch { + return false; + } + }, +}); diff --git a/src/components/ui/coaching-sessions/coaching-notes/extensions.tsx b/src/components/ui/coaching-sessions/coaching-notes/extensions.tsx index d56876b..d2f172c 100644 --- a/src/components/ui/coaching-sessions/coaching-notes/extensions.tsx +++ b/src/components/ui/coaching-sessions/coaching-notes/extensions.tsx @@ -19,6 +19,7 @@ import { createLowlight } from "lowlight"; import { all } from "lowlight"; import CollaborationCursor from "@tiptap/extension-collaboration-cursor"; import { TiptapCollabProvider } from "@hocuspocus/provider"; +import { ConfiguredLink } from "./extended-link-extension"; // Initialize lowlight with all languages const lowlight = createLowlight(all); @@ -44,77 +45,7 @@ export const Extensions = ( Strike, Text, Underline, - Link.configure({ - openOnClick: false, - autolink: true, - defaultProtocol: "https", - protocols: ["http", "https"], - isAllowedUri: (url, ctx) => { - try { - // construct URL - const parsedUrl = url.includes(":") - ? new URL(url) - : new URL(`${ctx.defaultProtocol}://${url}`); - - // use default validation - if (!ctx.defaultValidate(parsedUrl.href)) { - return false; - } - - // Placeholder for future disallowed domains if we want to add any - // const disallowedProtocols = ["ftp", "file", "mailto"]; - // const protocol = parsedUrl.protocol.replace(":", ""); - - // if (disallowedProtocols.includes(protocol)) { - // return false; - // } - - // // only allow protocols specified in ctx.protocols - // const allowedProtocols = ctx.protocols.map((p) => - // typeof p === "string" ? p : p.scheme - // ); - - // if (!allowedProtocols.includes(protocol)) { - // return false; - // } - - // Placeholder for future disallowed domains if we want to add any - // const disallowedDomains = [ - // "example-phishing.com", - // "malicious-site.net", - // ]; - // const domain = parsedUrl.hostname; - - // if (disallowedDomains.includes(domain)) { - // return false; - // } - - // all checks have passed - return true; - } catch { - return false; - } - }, - shouldAutoLink: (url) => { - try { - // construct URL - const parsedUrl = url.includes(":") - ? new URL(url) - : new URL(`https://${url}`); - - // only auto-link if the domain is not in the disallowed list - const disallowedDomains = [ - "example-no-autolink.com", - "another-no-autolink.com", - ]; - const domain = parsedUrl.hostname; - - return !disallowedDomains.includes(domain); - } catch { - return false; - } - }, - }), + ConfiguredLink, Collaboration.configure({ document: doc, }), diff --git a/src/components/ui/coaching-sessions/coaching-notes/link-dialog.tsx b/src/components/ui/coaching-sessions/coaching-notes/link-dialog.tsx index dc3abe9..b998c99 100644 --- a/src/components/ui/coaching-sessions/coaching-notes/link-dialog.tsx +++ b/src/components/ui/coaching-sessions/coaching-notes/link-dialog.tsx @@ -46,12 +46,17 @@ export const LinkDialog = ({ // update link try { + const linkWithProtocol = /^https?:\/\//.test(linkUrl) + ? linkUrl + : `https://${linkUrl}`; + + // basic validation for a real url + const urlHref = new URL(linkWithProtocol).toString(); editor .chain() .focus() .extendMarkRange("link") - .setLink({ href: linkUrl }) - .updateAttributes("link", { title: linkUrl }) + .setLink({ href: urlHref }) .run(); setLinkUrl(""); onOpenChange(false); @@ -65,9 +70,7 @@ export const LinkDialog = ({ Insert Link - - Insert a url for a link in your document. - + Insert URL for your link
From 1f2bb17cc4709538ad6da47bae1df9659f70fd34 Mon Sep 17 00:00:00 2001 From: Zach Gavin <9595970+zgavin1@users.noreply.github.com> Date: Sat, 12 Apr 2025 11:51:13 -0500 Subject: [PATCH 7/8] add editor focus condition for link shortcut behavior --- .../ui/coaching-sessions/coaching-notes/toolbar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ui/coaching-sessions/coaching-notes/toolbar.tsx b/src/components/ui/coaching-sessions/coaching-notes/toolbar.tsx index 702b8c9..ae0e03a 100644 --- a/src/components/ui/coaching-sessions/coaching-notes/toolbar.tsx +++ b/src/components/ui/coaching-sessions/coaching-notes/toolbar.tsx @@ -28,7 +28,7 @@ export const Toolbar = () => { // Add keyboard shortcut handler useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "k") { + if ((e.metaKey || e.ctrlKey) && e.key === "k" && editor?.isFocused) { e.preventDefault(); setIsLinkDialogOpen(true); } @@ -36,7 +36,7 @@ export const Toolbar = () => { document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, []); + }, [editor]); return ( <> From 155f97f0793dc8d1f42537f65de6eab16f560617 Mon Sep 17 00:00:00 2001 From: Zach Gavin <9595970+zgavin1@users.noreply.github.com> Date: Sat, 12 Apr 2025 17:21:37 -0500 Subject: [PATCH 8/8] fix react error --- .../ui/coaching-sessions/coaching-notes/toolbar.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/ui/coaching-sessions/coaching-notes/toolbar.tsx b/src/components/ui/coaching-sessions/coaching-notes/toolbar.tsx index ae0e03a..8a59eff 100644 --- a/src/components/ui/coaching-sessions/coaching-notes/toolbar.tsx +++ b/src/components/ui/coaching-sessions/coaching-notes/toolbar.tsx @@ -22,10 +22,6 @@ export const Toolbar = () => { const { editor } = useCurrentEditor(); const [isLinkDialogOpen, setIsLinkDialogOpen] = useState(false); - if (!editor) { - return null; - } - // Add keyboard shortcut handler useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "k" && editor?.isFocused) { @@ -38,6 +34,11 @@ export const Toolbar = () => { return () => document.removeEventListener("keydown", handleKeyDown); }, [editor]); + if (!editor) { + return null; + } + // Add keyboard shortcut handler + return ( <>