Skip to content

Commit 86845e1

Browse files
authored
Merge pull request #98 from refactor-group/add-link-to-coaching-notes-editor
Add link to coaching notes editor
2 parents 6872abe + 155f97f commit 86845e1

File tree

9 files changed

+313
-71
lines changed

9 files changed

+313
-71
lines changed

package-lock.json

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@tiptap/extension-collaboration-cursor": "^2.11.5",
3939
"@tiptap/extension-heading": "^2.10.4",
4040
"@tiptap/extension-highlight": "^2.10.4",
41+
"@tiptap/extension-link": "^2.11.7",
4142
"@tiptap/extension-list-item": "^2.10.4",
4243
"@tiptap/extension-ordered-list": "^2.10.4",
4344
"@tiptap/extension-underline": "^2.10.4",

src/app/coaching-sessions/[id]/page.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,14 @@
22

33
import { Separator } from "@/components/ui/separator";
44
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
5-
import { Textarea } from "@/components/ui/textarea";
65

7-
import { useState } from "react";
86
import { useAuthStore } from "@/lib/providers/auth-store-provider";
97

108
import { siteConfig } from "@/site.config";
119
import { CoachingSessionTitle } from "@/components/ui/coaching-sessions/coaching-session-title";
1210
import { OverarchingGoalContainer } from "@/components/ui/coaching-sessions/overarching-goal-container";
1311
import { CoachingNotes } from "@/components/ui/coaching-sessions/coaching-notes";
1412

15-
import { LockClosedIcon } from "@radix-ui/react-icons";
1613
import CoachingSessionSelector from "@/components/ui/coaching-session-selector";
1714
import { useRouter } from "next/navigation";
1815
import { useCoachingRelationshipStateStore } from "@/lib/providers/coaching-relationship-state-store-provider";

src/components/ui/coaching-sessions/coaching-notes.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,21 @@ const CoachingNotes = () => {
115115
class:
116116
"tiptap ProseMirror shadow appearance-none lg:min-h-[440px] sm:min-h-[200px] md:min-h-[350px] rounded w-full py-2 px-3 bg-inherit text-black dark:text-white text-sm mt-0 md:mt-3 leading-tight focus:outline-none focus:shadow-outline",
117117
},
118+
handleDOMEvents: {
119+
click: (view, event) => {
120+
const target = event.target as HTMLElement;
121+
122+
// Check if the clicked element is an <a> tag and Shift is pressed
123+
if (target.tagName === "A" && event.shiftKey) {
124+
event.preventDefault(); // Prevent default link behavior
125+
window
126+
.open(target.getAttribute("href") || "", "_blank")
127+
?.focus();
128+
return true; // Stop event propagation
129+
}
130+
return false; // Allow other handlers to process the event
131+
},
132+
},
118133
}}
119134
slotBefore={<Toolbar />}
120135
/>
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import Link from "@tiptap/extension-link";
2+
3+
const LinkWithTitle = Link.extend({
4+
addAttributes() {
5+
return {
6+
...this.parent?.(),
7+
title: {
8+
default: null,
9+
renderHTML: (attributes) => {
10+
if (!attributes.href) {
11+
return {};
12+
}
13+
return {
14+
title: `Right-click to open ${attributes.href}`,
15+
};
16+
},
17+
},
18+
};
19+
},
20+
});
21+
22+
export const ConfiguredLink = LinkWithTitle.configure({
23+
openOnClick: false,
24+
autolink: true,
25+
defaultProtocol: "https",
26+
protocols: ["http", "https"],
27+
isAllowedUri: (url, ctx) => {
28+
try {
29+
// construct URL
30+
const parsedUrl = url.includes(":")
31+
? new URL(url)
32+
: new URL(`${ctx.defaultProtocol}://${url}`);
33+
34+
// use default validation
35+
if (!ctx.defaultValidate(parsedUrl.href)) {
36+
return false;
37+
}
38+
39+
// all checks have passed
40+
return true;
41+
} catch {
42+
return false;
43+
}
44+
},
45+
shouldAutoLink: (url) => {
46+
try {
47+
// construct URL
48+
const parsedUrl = url.includes(":")
49+
? new URL(url)
50+
: new URL(`https://${url}`);
51+
52+
return !!parsedUrl;
53+
} catch {
54+
return false;
55+
}
56+
},
57+
});

src/components/ui/coaching-sessions/coaching-notes/extensions.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { createLowlight } from "lowlight";
1818
import { all } from "lowlight";
1919
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
2020
import { TiptapCollabProvider } from "@hocuspocus/provider";
21+
import { ConfiguredLink } from "./extended-link-extension";
2122
// Initialize lowlight with all languages
2223
const lowlight = createLowlight(all);
2324

@@ -43,6 +44,7 @@ export const Extensions = (
4344
Strike,
4445
Text,
4546
Underline,
47+
ConfiguredLink,
4648
Collaboration.configure({
4749
document: doc,
4850
}),
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import React from "react";
2+
import { Editor } from "@tiptap/core";
3+
import {
4+
Dialog,
5+
DialogContent,
6+
DialogDescription,
7+
DialogFooter,
8+
DialogHeader,
9+
DialogTitle,
10+
} from "@/components/ui/dialog";
11+
import { Label } from "@/components/ui/label";
12+
import { Input } from "@/components/ui/input";
13+
import { Button } from "@/components/ui/button";
14+
15+
interface LinkDialogProps {
16+
editor: Editor;
17+
isOpen: boolean;
18+
onOpenChange: (open: boolean) => void;
19+
}
20+
21+
export const LinkDialog = ({
22+
editor,
23+
isOpen,
24+
onOpenChange,
25+
}: LinkDialogProps) => {
26+
const [linkUrl, setLinkUrl] = React.useState("");
27+
28+
React.useEffect(() => {
29+
if (isOpen) {
30+
setLinkUrl(editor.getAttributes("link").href || "");
31+
}
32+
}, [isOpen, editor]);
33+
34+
const setLink = React.useCallback(() => {
35+
// cancelled
36+
if (linkUrl === null) {
37+
return;
38+
}
39+
40+
// empty
41+
if (linkUrl === "") {
42+
editor.chain().focus().extendMarkRange("link").unsetLink().run();
43+
onOpenChange(false);
44+
return;
45+
}
46+
47+
// update link
48+
try {
49+
const linkWithProtocol = /^https?:\/\//.test(linkUrl)
50+
? linkUrl
51+
: `https://${linkUrl}`;
52+
53+
// basic validation for a real url
54+
const urlHref = new URL(linkWithProtocol).toString();
55+
editor
56+
.chain()
57+
.focus()
58+
.extendMarkRange("link")
59+
.setLink({ href: urlHref })
60+
.run();
61+
setLinkUrl("");
62+
onOpenChange(false);
63+
} catch (e) {
64+
console.error("Error inserting link:", e);
65+
}
66+
}, [editor, linkUrl, onOpenChange]);
67+
68+
return (
69+
<Dialog open={isOpen} onOpenChange={onOpenChange}>
70+
<DialogContent>
71+
<DialogHeader>
72+
<DialogTitle>Insert Link</DialogTitle>
73+
<DialogDescription>Insert URL for your link</DialogDescription>
74+
</DialogHeader>
75+
<div className="grid grid-cols-6 gap-4 items-center">
76+
<Label htmlFor="url" className="text-right">
77+
URL
78+
</Label>
79+
<Input
80+
id="url"
81+
value={linkUrl}
82+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
83+
setLinkUrl(e.target.value)
84+
}
85+
className="col-span-5"
86+
/>
87+
</div>
88+
<DialogFooter>
89+
<Button onClick={setLink}>Submit</Button>
90+
</DialogFooter>
91+
</DialogContent>
92+
</Dialog>
93+
);
94+
};

0 commit comments

Comments
 (0)