Skip to content

Commit db3045b

Browse files
authored
feat: add tiptap editor component (#275)
- add tiptap editor component - add toggle button components - use tiptap component for description field in admin country view
1 parent 9524b95 commit db3045b

File tree

6 files changed

+678
-6
lines changed

6 files changed

+678
-6
lines changed

components/admin/country-dashboard-content.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { DateInputField } from "@/components/ui/blocks/date-input-field";
2121
import { NumberInputField } from "@/components/ui/blocks/number-input-field";
2222
import { SelectField, SelectItem } from "@/components/ui/blocks/select-field";
2323
import { TextInputField } from "@/components/ui/blocks/text-input-field";
24+
import { TiptapEditor } from "@/components/ui/blocks/tiptap-editor";
2425
import { Form } from "@/components/ui/form";
2526
import { FormError as FormErrorMessage } from "@/components/ui/form-error";
2627
import { FormSuccess as FormSuccessMessage } from "@/components/ui/form-success";
@@ -233,8 +234,8 @@ function CountryEditForm(props: CountryEditFormProps) {
233234
name="consortiumName"
234235
/>
235236

236-
<TextInputField
237-
defaultValue={country.description ?? undefined}
237+
<TiptapEditor
238+
defaultContent={country.description ?? undefined}
238239
label="Description"
239240
name="description"
240241
/>
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
"use client";
2+
3+
import { usePreventScroll } from "@react-aria/overlays";
4+
import { Bold } from "@tiptap/extension-bold";
5+
import { Document } from "@tiptap/extension-document";
6+
import { Link } from "@tiptap/extension-link";
7+
import { Paragraph } from "@tiptap/extension-paragraph";
8+
import { Text } from "@tiptap/extension-text";
9+
import { Placeholder } from "@tiptap/extensions";
10+
import { type Editor, EditorContent, useEditor, useEditorState } from "@tiptap/react";
11+
import DOMPurify from "dompurify";
12+
import { BoldIcon, LinkIcon, UnlinkIcon } from "lucide-react";
13+
import { Fragment, type ReactNode, useState } from "react";
14+
import { DialogTrigger, Group } from "react-aria-components";
15+
import { z } from "zod";
16+
17+
import { TextInputField } from "@/components/ui/blocks/text-input-field";
18+
import { Button } from "@/components/ui/button";
19+
import {
20+
Dialog,
21+
DialogCancelButton,
22+
DialogFooter,
23+
DialogHeader,
24+
DialogTitle,
25+
} from "@/components/ui/dialog";
26+
import { IconButton } from "@/components/ui/icon-button";
27+
import { IconToggleButton } from "@/components/ui/icon-toggle-button";
28+
import { Label } from "@/components/ui/label";
29+
import { Modal, ModalOverlay } from "@/components/ui/modal";
30+
import { variants } from "@/lib/styles";
31+
32+
const editorStyles = variants({
33+
base: [
34+
"block prose w-full min-w-0 min-h-20 appearance-none transition",
35+
"resize-none data-resizable:resize-y",
36+
"rounded-md px-3 py-1.5",
37+
"text-sm leading-normal text-neutral-950 placeholder:text-neutral-500 dark:text-neutral-0",
38+
"border border-neutral-950/10 hover:border-neutral-950/20 dark:border-neutral-0/10 dark:hover:border-neutral-0/20",
39+
"bg-neutral-0 dark:bg-neutral-0/5",
40+
"shadow-xs dark:shadow-none",
41+
"invalid:border-negative-500 invalid:shadow-negative-500/10 invalid:hover:border-negative-500 dark:invalid:border-negative-500 dark:invalid:hover:border-negative-500",
42+
"disabled:border-neutral-950/20 disabled:bg-neutral-950/5 disabled:opacity-50 disabled:shadow-none dark:disabled:border-neutral-0/15 dark:disabled:hover:border-neutral-0/15",
43+
"outline-solid outline-0 outline-neutral-950 invalid:outline-negative-500 focus:outline-1 focus-visible:outline-2 dark:outline-neutral-0 forced-colors:outline-[Highlight]",
44+
],
45+
});
46+
47+
interface TipTapLink {
48+
href?: string;
49+
target?: string;
50+
rel?: string;
51+
class?: string;
52+
[key: string]: unknown;
53+
}
54+
55+
interface TipTapEditorProps {
56+
defaultContent: string | null | undefined;
57+
label: string;
58+
name: string;
59+
}
60+
61+
const allowedProtocols = ["http:", "https:", "mailto:"];
62+
63+
export function TiptapEditor(props: TipTapEditorProps): ReactNode {
64+
const { defaultContent, label, name } = props;
65+
66+
const [isOpen, setIsOpen] = useState(false);
67+
68+
const [link, setLink] = useState<TipTapLink>({});
69+
70+
const handleChange = (isOpen: boolean) => {
71+
setIsOpen(isOpen);
72+
if (isOpen) {
73+
const url = editorState?.link as TipTapLink;
74+
setLink(url);
75+
}
76+
};
77+
78+
usePreventScroll({ isDisabled: isOpen });
79+
80+
const [content, setContent] = useState(defaultContent);
81+
const editor = useEditor({
82+
editorProps: {
83+
attributes: {
84+
"aria-label": "Rich Text Editor",
85+
class: editorStyles(),
86+
},
87+
},
88+
extensions: [
89+
Document,
90+
Paragraph,
91+
Text,
92+
Link.configure({
93+
openOnClick: false,
94+
autolink: true,
95+
defaultProtocol: "https",
96+
protocols: allowedProtocols.map((protocol) => {
97+
return protocol.replace(":", "");
98+
}),
99+
}),
100+
Bold,
101+
Placeholder.configure({
102+
emptyEditorClass: "before:content-[attr(data-placeholder)]",
103+
placeholder: `Enter ${label}...`,
104+
}),
105+
],
106+
content: defaultContent,
107+
onUpdate({ editor }) {
108+
setContent(DOMPurify.sanitize(editor.getHTML()));
109+
},
110+
111+
immediatelyRender: false,
112+
});
113+
114+
const editorState = useEditorState({
115+
editor,
116+
selector: (ctx) => {
117+
return {
118+
isBold: ctx.editor?.isActive("bold"),
119+
isLink: ctx.editor?.isActive("link"),
120+
link: ctx.editor?.getAttributes("link"),
121+
};
122+
},
123+
});
124+
125+
return (
126+
<>
127+
<Label>{label}</Label>
128+
<Group aria-label="rich text editor">
129+
<Group className="flex gap-x-1.5">
130+
<IconToggleButton
131+
aria-label={"toggle-bold"}
132+
isSelected={editorState?.isBold}
133+
onClick={() => {
134+
return editor?.chain().focus().toggleBold().run();
135+
}}
136+
variant="outline"
137+
>
138+
<BoldIcon aria-hidden={true} className="size-5 shrink-0" />
139+
</IconToggleButton>
140+
<DialogTrigger isOpen={isOpen} onOpenChange={handleChange}>
141+
<IconButton variant="outline">
142+
<LinkIcon aria-hidden={true} className="size-5 shrink-0" />
143+
<span className="sr-only">edit link</span>
144+
</IconButton>
145+
<EditLinkDialog editor={editor} link={link} />
146+
</DialogTrigger>
147+
<IconButton
148+
isDisabled={!editorState?.isLink}
149+
onClick={() => {
150+
return editor?.chain().focus().extendMarkRange("link").unsetLink().run();
151+
}}
152+
variant="outline"
153+
>
154+
<UnlinkIcon aria-hidden={true} className="size-5 shrink-0" />
155+
<span className="sr-only">remove link</span>
156+
</IconButton>
157+
</Group>
158+
<EditorContent className="mt-2" editor={editor} />
159+
<input name={name} type="hidden" value={String(content)} />
160+
</Group>
161+
</>
162+
);
163+
}
164+
165+
interface EditLinkDialogProps {
166+
editor: Editor | null;
167+
link: TipTapLink;
168+
}
169+
170+
function EditLinkDialog(props: EditLinkDialogProps): ReactNode {
171+
const { editor, link } = props;
172+
173+
const urlSchema = z
174+
.string()
175+
.url()
176+
.refine((url) => {
177+
try {
178+
const parsedURL = new URL(url);
179+
return allowedProtocols.includes(parsedURL.protocol);
180+
} catch {
181+
return false;
182+
}
183+
});
184+
185+
const isValidUrl = (url: string) => {
186+
return urlSchema.safeParse(url).success;
187+
};
188+
189+
const [url, setUrl] = useState<string | undefined>(link.href);
190+
191+
return (
192+
<ModalOverlay>
193+
<Modal isDismissable={true}>
194+
<Dialog>
195+
{({ close }) => {
196+
const handleUpdate = () => {
197+
if (url) {
198+
editor?.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
199+
}
200+
close();
201+
};
202+
203+
return (
204+
<Fragment>
205+
<DialogHeader>
206+
<DialogTitle>Edit Link</DialogTitle>
207+
</DialogHeader>
208+
209+
<div>
210+
<TextInputField
211+
autoFocus={true}
212+
defaultValue={link.href}
213+
description={`allowed protocols: ${allowedProtocols.join(", ")}`}
214+
isRequired={true}
215+
label="Link"
216+
name="link"
217+
onChange={(v) => {
218+
setUrl(v);
219+
}}
220+
/>
221+
</div>
222+
<DialogFooter>
223+
<DialogCancelButton>Cancel</DialogCancelButton>
224+
<Button isDisabled={!url || !isValidUrl(url)} onPress={handleUpdate}>
225+
Update
226+
</Button>
227+
</DialogFooter>
228+
</Fragment>
229+
);
230+
}}
231+
</Dialog>
232+
</Modal>
233+
</ModalOverlay>
234+
);
235+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"use client";
2+
3+
import type { ReactNode } from "react";
4+
5+
import { ToggleButton, type ToggleButtonProps } from "@/components/ui/toggle-button";
6+
7+
interface IconToggleButtonProps extends ToggleButtonProps {}
8+
9+
export function IconToggleButton(props: IconToggleButtonProps): ReactNode {
10+
const { children, ...rest } = props;
11+
12+
return (
13+
<ToggleButton {...rest} className="inline-grid aspect-square size-9 place-content-center p-1">
14+
{children}
15+
</ToggleButton>
16+
);
17+
}

components/ui/toggle-button.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"use client";
2+
3+
import {
4+
composeRenderProps,
5+
ToggleButton as AriaToggleButton,
6+
type ToggleButtonProps as AriaToggleButtonProps,
7+
} from "react-aria-components";
8+
9+
import { TouchTarget } from "@/components/ui/touch-target";
10+
import { type VariantProps, variants } from "@/lib/styles";
11+
12+
export const buttonStyles = variants({
13+
base: [
14+
"relative inline-flex cursor-default items-center justify-center gap-x-2 whitespace-nowrap transition",
15+
"rounded-md px-3 py-1.5",
16+
"text-sm font-medium leading-normal",
17+
"border",
18+
19+
"disabled:opacity-50",
20+
],
21+
variants: {
22+
variant: {
23+
solid: [
24+
"border-neutral-950/90 dark:border-neutral-0/5",
25+
"bg-neutral-900 text-neutral-0 dark:bg-neutral-600",
26+
"hover:bg-neutral-900/90 dark:hover:bg-neutral-600/90",
27+
"shadow-xs dark:shadow-none",
28+
"disabled:shadow-none",
29+
],
30+
outline: [
31+
"border-neutral-950/10 dark:border-neutral-0/15",
32+
"bg-transparent hover:bg-neutral-950/2.5 aria-pressed:bg-neutral-950/2.5 dark:hover:bg-neutral-0/2.5 dark:aria-pressed:bg-neutral-950/2.5",
33+
"text-neutral-950 dark:text-neutral-0",
34+
],
35+
plain: [
36+
"border-transparent",
37+
"hover:bg-neutral-950/5 pressed:bg-neutral-950/5",
38+
"text-neutral-950 dark:text-neutral-0",
39+
"dark:hover:bg-neutral-0/10 dark:pressed:bg-neutral-0/10",
40+
],
41+
},
42+
},
43+
defaultVariants: {
44+
variant: "solid",
45+
},
46+
});
47+
48+
export type ButtonStyles = VariantProps<typeof buttonStyles>;
49+
50+
export interface ToggleButtonProps extends AriaToggleButtonProps, ButtonStyles {}
51+
52+
export function ToggleButton(props: ToggleButtonProps) {
53+
const { children, className, variant, ...rest } = props;
54+
55+
return (
56+
<AriaToggleButton
57+
{...rest}
58+
className={composeRenderProps(className, (className, renderProps) => {
59+
return buttonStyles({ ...renderProps, className, variant });
60+
})}
61+
>
62+
{composeRenderProps(children, (children, _renderProps) => {
63+
return <TouchTarget>{children}</TouchTarget>;
64+
})}
65+
</AriaToggleButton>
66+
);
67+
}

package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,23 @@
6565
"@keystatic/core": "^0.5.48",
6666
"@keystatic/next": "^5.0.4",
6767
"@mdx-js/mdx": "^3.1.0",
68+
"@react-aria/overlays": "3.28.0",
6869
"@react-aria/utils": "^3.30.0",
6970
"@react-stately/data": "^3.13.2",
7071
"@sentry/nextjs": "^9.41.0",
72+
"@tiptap/extension-bold": "^3.5.1",
73+
"@tiptap/extension-document": "^3.5.1",
74+
"@tiptap/extension-link": "^3.5.1",
75+
"@tiptap/extension-paragraph": "^3.5.1",
76+
"@tiptap/extension-text": "^3.5.1",
77+
"@tiptap/extensions": "^3.5.1",
78+
"@tiptap/pm": "^3.5.1",
79+
"@tiptap/react": "^3.5.1",
7180
"bcrypt": "^6.0.0",
7281
"canvas-confetti": "^1.9.3",
7382
"client-only": "^0.0.1",
7483
"cva": "^1.0.0-beta.4",
84+
"dompurify": "^3.2.7",
7585
"dset": "^3.1.4",
7686
"fast-glob": "^3.3.3",
7787
"lucide-react": "^0.525.0",

0 commit comments

Comments
 (0)