Skip to content

Add link to coaching notes editor #98

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

Merged
merged 9 commits into from
Apr 12, 2025
24 changes: 24 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 0 additions & 3 deletions src/app/coaching-sessions/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,14 @@

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";
import { CoachingSessionTitle } from "@/components/ui/coaching-sessions/coaching-session-title";
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";
Expand Down
15 changes: 15 additions & 0 deletions src/components/ui/coaching-sessions/coaching-notes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,21 @@ const CoachingNotes = () => {
class:
"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",
},
handleDOMEvents: {
click: (view, event) => {
const target = event.target as HTMLElement;

// Check if the clicked element is an <a> tag and Shift is pressed
if (target.tagName === "A" && event.shiftKey) {
event.preventDefault(); // Prevent default link behavior
window
.open(target.getAttribute("href") || "", "_blank")
?.focus();
return true; // Stop event propagation
}
return false; // Allow other handlers to process the event
},
},
}}
slotBefore={<Toolbar />}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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;
}

// 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}`);

return !!parsedUrl;
} catch {
return false;
}
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,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);

Expand All @@ -43,6 +44,7 @@ export const Extensions = (
Strike,
Text,
Underline,
ConfiguredLink,
Collaboration.configure({
document: doc,
}),
Expand Down
94 changes: 94 additions & 0 deletions src/components/ui/coaching-sessions/coaching-notes/link-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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 {
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: urlHref })
.run();
setLinkUrl("");
onOpenChange(false);
} catch (e) {
console.error("Error inserting link:", e);
}
}, [editor, linkUrl, onOpenChange]);

return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Insert Link</DialogTitle>
<DialogDescription>Insert URL for your link</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-6 gap-4 items-center">
<Label htmlFor="url" className="text-right">
URL
</Label>
<Input
id="url"
value={linkUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setLinkUrl(e.target.value)
}
className="col-span-5"
/>
</div>
<DialogFooter>
<Button onClick={setLink}>Submit</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
Loading