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"
+ />
+
+
+
+ >
);
};
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 (
+
+ );
+};
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"
/>
-
+
>
);
};
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 (
<>