feat : Tiptap integration (#1832)

* remirror instances commented out to avoid prosemirror conflicts

* styles migrated for remirror to tiptap transition

* added bubblemenu support with extensions

* fixed css for task lists and code with syntax highlighting

* added support for slash command

* fixed bubble menu to match styles and added better seperation in UI

* saving with debounce logic added and it's stored in backend

* added migration support by updating to html

* Image uploads done

* improved file structure and delete image function implemented

* Integrated tiptap with Issue Modal

* added additional props and Tiptap Integration with Comments

* added tiptap integration with user activity feeds

* added ref control support and bubble menu support for readonly editor

* added tiptap support for plane pages

* added tiptap support to gpt assistant modal (yet to be tested)

* removed remirror instances and cleaned up code

* improved code structure for extracting props in Tiptap

* fixing ts errors for next build

* fixing node ts error for Horizontal Rule

* added ts fix for node types

* temp fix

* temp fix

* added min height for issue description in modal

* added resolutions to prosemirror-model version

* trying pnpm overrides

* explicitly added prosemirror deps

* bugfixes

* removed extra gap at the top and moved saved indicator to the bottom

* fix: slash command scroll position

* chore: update custom css variables

* matched theme colours

* fixed gpt-assistant modal

* updated yarn lock

* added debounced updates for the title and removed saved state after timeout

* added css animations for saved state

* build fixes and remove remirror instances

* minor commenting fixes

---------

Co-authored-by: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
This commit is contained in:
sriram veeraghanta 2023-08-15 15:04:46 +05:30 committed by GitHub
parent daa8f7d79b
commit e1ae0d3b56
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 2275 additions and 3827 deletions

4
.gitignore vendored
View File

@ -70,4 +70,6 @@ package-lock.json
# lock files # lock files
package-lock.json package-lock.json
pnpm-lock.yaml pnpm-lock.yaml
pnpm-workspace.yaml pnpm-workspace.yaml
.npmrc

View File

@ -28,13 +28,13 @@ export const ChangeInterfaceTheme: React.FC<Props> = ({ setIsPaletteOpen }) => {
setTheme(newTheme); setTheme(newTheme);
mutateUser((prevData) => { mutateUser((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
return { return {
...prevData, ...prevData,
theme: { theme: {
...prevData.theme, ...prevData?.theme,
theme: newTheme, theme: newTheme,
}, },
}; };

View File

@ -1,11 +1,8 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
// hooks // hooks
import useTheme from "hooks/use-theme";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
// components // components
@ -26,8 +23,10 @@ import inboxService from "services/inbox.service";
import { INBOX_LIST, ISSUE_DETAILS } from "constants/fetch-keys"; import { INBOX_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
// mobx store // mobx store
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
import { observable } from "mobx";
import { observer } from "mobx-react-lite";
export const CommandPalette: React.FC = () => { export const CommandPalette: React.FC = observer(() => {
const store: any = useMobxStore(); const store: any = useMobxStore();
const [isPaletteOpen, setIsPaletteOpen] = useState(false); const [isPaletteOpen, setIsPaletteOpen] = useState(false);
@ -47,13 +46,12 @@ export const CommandPalette: React.FC = () => {
const { user } = useUser(); const { user } = useUser();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { toggleCollapsed } = useTheme();
const { data: issueDetails } = useSWR( const { data: issueDetails } = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null, workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
workspaceSlug && projectId && issueId workspaceSlug && projectId && issueId
? () => ? () =>
issuesService.retrieve(workspaceSlug as string, projectId as string, issueId as string) issuesService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
: null : null
); );
@ -78,55 +76,52 @@ export const CommandPalette: React.FC = () => {
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
if (!key) return;
const keyPressed = key.toLowerCase();
const cmdClicked = ctrlKey || metaKey;
// if on input, textarea or editor, don't do anything // if on input, textarea or editor, don't do anything
if ( if (
e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement || e.target instanceof HTMLInputElement ||
(e.target as Element).classList?.contains("remirror-editor") (e.target as Element).classList?.contains("ProseMirror")
) )
return; return;
const { key, ctrlKey, metaKey, altKey, shiftKey } = e; if (cmdClicked) {
if (keyPressed === "k") {
if (!key) return; e.preventDefault();
setIsPaletteOpen(true);
const keyPressed = key.toLowerCase(); } else if (keyPressed === "c" && altKey) {
e.preventDefault();
const cmdClicked = ctrlKey || metaKey; copyIssueUrlToClipboard();
} else if (keyPressed === "b") {
if (cmdClicked) { e.preventDefault();
if (keyPressed === "k") { store.theme.setSidebarCollapsed(!store?.theme?.sidebarCollapsed);
e.preventDefault(); }
setIsPaletteOpen(true); } else {
} else if (keyPressed === "c" && altKey) { if (keyPressed === "c") {
e.preventDefault(); setIsIssueModalOpen(true);
copyIssueUrlToClipboard(); } else if (keyPressed === "p") {
} else if (keyPressed === "b") { setIsProjectModalOpen(true);
e.preventDefault(); } else if (keyPressed === "v") {
toggleCollapsed(); setIsCreateViewModalOpen(true);
} else if (keyPressed === "d") {
setIsCreateUpdatePageModalOpen(true);
} else if (keyPressed === "h") {
setIsShortcutsModalOpen(true);
} else if (keyPressed === "q") {
setIsCreateCycleModalOpen(true);
} else if (keyPressed === "m") {
setIsCreateModuleModalOpen(true);
} else if (keyPressed === "backspace" || keyPressed === "delete") {
e.preventDefault();
setIsBulkDeleteIssuesModalOpen(true);
}
} }
} else {
if (keyPressed === "c") {
setIsIssueModalOpen(true);
} else if (keyPressed === "p") {
setIsProjectModalOpen(true);
} else if (keyPressed === "v") {
setIsCreateViewModalOpen(true);
} else if (keyPressed === "d") {
setIsCreateUpdatePageModalOpen(true);
} else if (keyPressed === "h") {
setIsShortcutsModalOpen(true);
} else if (keyPressed === "q") {
setIsCreateCycleModalOpen(true);
} else if (keyPressed === "m") {
setIsCreateModuleModalOpen(true);
} else if (keyPressed === "backspace" || keyPressed === "delete") {
e.preventDefault();
setIsBulkDeleteIssuesModalOpen(true);
}
}
}, },
[copyIssueUrlToClipboard, toggleCollapsed] [copyIssueUrlToClipboard]
); );
useEffect(() => { useEffect(() => {
@ -201,4 +196,4 @@ export const CommandPalette: React.FC = () => {
/> />
</> </>
); );
}; })

View File

@ -1,7 +1,6 @@
import { useEffect, useState, forwardRef, useRef } from "react"; import React, { useEffect, useState, forwardRef, useRef } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import dynamic from "next/dynamic";
// react-hook-form // react-hook-form
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@ -15,6 +14,7 @@ import useUserAuth from "hooks/use-user-auth";
import { Input, PrimaryButton, SecondaryButton } from "components/ui"; import { Input, PrimaryButton, SecondaryButton } from "components/ui";
import { IIssue, IPageBlock } from "types"; import { IIssue, IPageBlock } from "types";
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
handleClose: () => void; handleClose: () => void;
@ -32,17 +32,11 @@ type FormData = {
task: string; task: string;
}; };
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>(
ssr: false, (props, ref) => <Tiptap {...props} forwardedRef={ref} />
});
import { IRemirrorRichTextEditor } from "components/rich-text-editor";
const WrappedRemirrorRichTextEditor = forwardRef<IRemirrorRichTextEditor, IRemirrorRichTextEditor>(
(props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />
); );
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor"; TiptapEditor.displayName = "TiptapEditor";
export const GptAssistantModal: React.FC<Props> = ({ export const GptAssistantModal: React.FC<Props> = ({
isOpen, isOpen,
@ -151,10 +145,10 @@ export const GptAssistantModal: React.FC<Props> = ({
}`} }`}
> >
{((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && ( {((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && (
<div className="remirror-section text-sm"> <div id="tiptap-container" className="text-sm">
Content: Content:
<WrappedRemirrorRichTextEditor <TiptapEditor
value={htmlContent ?? <p>{content}</p>} value={htmlContent ?? `<p>${content}</p>`}
customClassName="-m-3" customClassName="-m-3"
noBorder noBorder
borderOnFocus={false} borderOnFocus={false}
@ -166,7 +160,7 @@ export const GptAssistantModal: React.FC<Props> = ({
{response !== "" && ( {response !== "" && (
<div className="page-block-section text-sm"> <div className="page-block-section text-sm">
Response: Response:
<RemirrorRichTextEditor <Tiptap
value={`<p>${response}</p>`} value={`<p>${response}</p>`}
customClassName="-mx-3 -my-3" customClassName="-mx-3 -my-3"
noBorder noBorder

View File

@ -125,7 +125,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
); );
} else { } else {
mutateIssues( mutateIssues(
(prevData) => (prevData: any) =>
handleIssuesMutation( handleIssuesMutation(
formData, formData,
groupTitle ?? "", groupTitle ?? "",

View File

@ -108,7 +108,7 @@ export const SingleListIssue: React.FC<Props> = ({
); );
} else { } else {
mutateIssues( mutateIssues(
(prevData) => (prevData: any) =>
handleIssuesMutation( handleIssuesMutation(
formData, formData,
groupTitle ?? "", groupTitle ?? "",

View File

@ -38,10 +38,10 @@ export const CyclesListGanttChartView: FC<Props> = ({ cycles, mutateCycles }) =>
const handleCycleUpdate = (cycle: ICycle, payload: IBlockUpdateData) => { const handleCycleUpdate = (cycle: ICycle, payload: IBlockUpdateData) => {
if (!workspaceSlug || !user) return; if (!workspaceSlug || !user) return;
mutateCycles((prevData) => { mutateCycles((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
const newList = prevData.map((p) => ({ const newList = prevData.map((p: any) => ({
...p, ...p,
...(p.id === cycle.id ...(p.id === cycle.id
? { ? {

View File

@ -47,7 +47,7 @@ export const SingleEstimate: React.FC<Props> = ({
estimate: estimate.id, estimate: estimate.id,
}; };
mutateProjectDetails((prevData) => { mutateProjectDetails((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
return { ...prevData, estimate: estimate.id }; return { ...prevData, estimate: estimate.id };

View File

@ -15,10 +15,10 @@ export const updateGanttIssue = (
) => { ) => {
if (!issue || !workspaceSlug || !user) return; if (!issue || !workspaceSlug || !user) return;
mutate((prevData: IIssue[]) => { mutate((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
const newList = prevData.map((p) => ({ const newList = prevData.map((p: any) => ({
...p, ...p,
...(p.id === issue.id ? payload : {}), ...(p.id === issue.id ? payload : {}),
})); }));

View File

@ -72,8 +72,8 @@ export const InboxActionHeader = () => {
false false
); );
mutateInboxIssues( mutateInboxIssues(
(prevData) => (prevData: any) =>
(prevData ?? []).map((i) => (prevData ?? []).map((i: any) =>
i.bridge_id === inboxIssueId i.bridge_id === inboxIssueId
? { ...i, issue_inbox: [{ ...i.issue_inbox[0], ...data }] } ? { ...i, issue_inbox: [{ ...i.issue_inbox[0], ...data }] }
: i : i

View File

@ -54,7 +54,10 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
const handleCommentDelete = async (commentId: string) => { const handleCommentDelete = async (commentId: string) => {
if (!workspaceSlug || !projectId || !issueId) return; if (!workspaceSlug || !projectId || !issueId) return;
mutateIssueActivities((prevData) => prevData?.filter((p) => p.id !== commentId), false); mutateIssueActivities(
(prevData: any) => prevData?.filter((p: any) => p.id !== commentId),
false
);
await issuesService await issuesService
.deleteIssueComment( .deleteIssueComment(

View File

@ -1,7 +1,6 @@
import React from "react"; import React from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import dynamic from "next/dynamic";
import { mutate } from "swr"; import { mutate } from "swr";
@ -12,28 +11,18 @@ import issuesServices from "services/issues.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// ui // ui
import { Loader, SecondaryButton } from "components/ui"; import { SecondaryButton } from "components/ui";
// types // types
import type { ICurrentUserResponse, IIssueComment } from "types"; import type { ICurrentUserResponse, IIssueComment } from "types";
// fetch-keys // fetch-keys
import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>(
ssr: false, (props, ref) => <Tiptap {...props} forwardedRef={ref} />
loading: () => ( );
<Loader className="mb-5">
<Loader.Item height="12rem" width="100%" />
</Loader>
),
});
import { IRemirrorRichTextEditor } from "components/rich-text-editor";
const WrappedRemirrorRichTextEditor = React.forwardRef< TiptapEditor.displayName = "TiptapEditor";
IRemirrorRichTextEditor,
IRemirrorRichTextEditor
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />);
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
const defaultValues: Partial<IIssueComment> = { const defaultValues: Partial<IIssueComment> = {
comment_json: "", comment_json: "",
@ -51,6 +40,7 @@ export const AddComment: React.FC<Props> = ({ issueId, user, disabled = false })
handleSubmit, handleSubmit,
control, control,
setValue, setValue,
watch,
formState: { isSubmitting }, formState: { isSubmitting },
reset, reset,
} = useForm<IIssueComment>({ defaultValues }); } = useForm<IIssueComment>({ defaultValues });
@ -97,17 +87,26 @@ export const AddComment: React.FC<Props> = ({ issueId, user, disabled = false })
return ( return (
<div> <div>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="issue-comments-section"> <div id="tiptap-container" className="issue-comments-section">
<Controller <Controller
name="comment_json" name="comment_html"
control={control} control={control}
render={({ field: { value } }) => ( render={({ field: { value, onChange } }) => (
<WrappedRemirrorRichTextEditor <TiptapEditor
value={value}
onJSONChange={(jsonValue) => setValue("comment_json", jsonValue)}
onHTMLChange={(htmlValue) => setValue("comment_html", htmlValue)}
placeholder="Enter your comment..."
ref={editorRef} ref={editorRef}
value={
!value ||
value === "" ||
(typeof value === "object" && Object.keys(value).length === 0)
? watch("comment_html")
: value
}
customClassName="p-3 min-h-[50px]"
debouncedUpdatesEnabled={false}
onChange={(comment_json: Object, comment_html: string) => {
onChange(comment_html);
setValue("comment_json", comment_json);
}}
/> />
)} )}
/> />

View File

@ -1,7 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import dynamic from "next/dynamic";
// react-hook-form // react-hook-form
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
// icons // icons
@ -15,17 +13,13 @@ import { CommentReaction } from "components/issues";
import { timeAgo } from "helpers/date-time.helper"; import { timeAgo } from "helpers/date-time.helper";
// types // types
import type { IIssueComment } from "types"; import type { IIssueComment } from "types";
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { ssr: false }); const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>(
(props, ref) => <Tiptap {...props} forwardedRef={ref} />
);
import { IRemirrorRichTextEditor } from "components/rich-text-editor"; TiptapEditor.displayName = "TiptapEditor";
const WrappedRemirrorRichTextEditor = React.forwardRef<
IRemirrorRichTextEditor,
IRemirrorRichTextEditor
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />);
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
type Props = { type Props = {
comment: IIssueComment; comment: IIssueComment;
@ -45,6 +39,7 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
formState: { isSubmitting }, formState: { isSubmitting },
handleSubmit, handleSubmit,
setFocus, setFocus,
watch,
setValue, setValue,
} = useForm<IIssueComment>({ } = useForm<IIssueComment>({
defaultValues: comment, defaultValues: comment,
@ -56,8 +51,8 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
onSubmit(formData); onSubmit(formData);
editorRef.current?.setEditorValue(formData.comment_json); editorRef.current?.setEditorValue(formData.comment_html);
showEditorRef.current?.setEditorValue(formData.comment_json); showEditorRef.current?.setEditorValue(formData.comment_html);
}; };
useEffect(() => { useEffect(() => {
@ -106,15 +101,18 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`} className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}
onSubmit={handleSubmit(onEnter)} onSubmit={handleSubmit(onEnter)}
> >
<WrappedRemirrorRichTextEditor <div id="tiptap-container">
value={comment.comment_html} <TiptapEditor
onBlur={(jsonValue, htmlValue) => { ref={editorRef}
setValue("comment_json", jsonValue); value={watch("comment_html")}
setValue("comment_html", htmlValue); debouncedUpdatesEnabled={false}
}} customClassName="min-h-[50px] p-3"
placeholder="Enter Your comment..." onChange={(comment_json: Object, comment_html: string) => {
ref={editorRef} setValue("comment_json", comment_json);
/> setValue("comment_html", comment_html);
}}
/>
</div>
<div className="flex gap-1 self-end"> <div className="flex gap-1 self-end">
<button <button
type="submit" type="submit"
@ -133,14 +131,12 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
</div> </div>
</form> </form>
<div className={`${isEditing ? "hidden" : ""}`}> <div className={`${isEditing ? "hidden" : ""}`}>
<WrappedRemirrorRichTextEditor <TiptapEditor
ref={showEditorRef}
value={comment.comment_html} value={comment.comment_html}
editable={false} editable={false}
noBorder
customClassName="text-xs border border-custom-border-200 bg-custom-background-100" customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
ref={showEditorRef}
/> />
<CommentReaction projectId={comment.project} commentId={comment.id} /> <CommentReaction projectId={comment.project} commentId={comment.id} />
</div> </div>
</div> </div>

View File

@ -1,23 +1,16 @@
import { FC, useCallback, useEffect, useState } from "react"; import { FC, useCallback, useEffect, useState } from "react";
import dynamic from "next/dynamic";
// react-hook-form // react-hook-form
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// hooks // hooks
import useReloadConfirmations from "hooks/use-reload-confirmation"; import useReloadConfirmations from "hooks/use-reload-confirmation";
// components // components
import { Loader, TextArea } from "components/ui"; import { TextArea } from "components/ui";
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
ssr: false,
loading: () => (
<Loader>
<Loader.Item height="12rem" width="100%" />
</Loader>
),
});
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
import Tiptap from "components/tiptap";
import { useDebouncedCallback } from "use-debounce";
export interface IssueDescriptionFormValues { export interface IssueDescriptionFormValues {
name: string; name: string;
@ -40,7 +33,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
handleFormSubmit, handleFormSubmit,
isAllowed, isAllowed,
}) => { }) => {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
const [characterLimit, setCharacterLimit] = useState(false); const [characterLimit, setCharacterLimit] = useState(false);
const { setShowAlert } = useReloadConfirmations(); const { setShowAlert } = useReloadConfirmations();
@ -63,7 +56,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
const handleDescriptionFormSubmit = useCallback( const handleDescriptionFormSubmit = useCallback(
async (formData: Partial<IIssue>) => { async (formData: Partial<IIssue>) => {
if (!formData.name || formData.name.length === 0 || formData.name.length > 255) return; if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return;
await handleFormSubmit({ await handleFormSubmit({
name: formData.name ?? "", name: formData.name ?? "",
@ -74,6 +67,14 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
[handleFormSubmit] [handleFormSubmit]
); );
useEffect(() => {
if (isSubmitting === "submitted") {
setTimeout(async () => {
setIsSubmitting("saved");
}, 2000);
}
}, [isSubmitting]);
// reset form values // reset form values
useEffect(() => { useEffect(() => {
if (!issue) return; if (!issue) return;
@ -83,6 +84,12 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
}); });
}, [issue, reset]); }, [issue, reset]);
const debouncedTitleSave = useDebouncedCallback(async () => {
setTimeout(async () => {
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
}, 500);
}, 1000);
return ( return (
<div className="relative"> <div className="relative">
<div className="relative"> <div className="relative">
@ -92,11 +99,10 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
placeholder="Enter issue name" placeholder="Enter issue name"
register={register} register={register}
onFocus={() => setCharacterLimit(true)} onFocus={() => setCharacterLimit(true)}
onBlur={() => { onChange={(e) => {
setCharacterLimit(false); setCharacterLimit(false);
setIsSubmitting("submitting");
setIsSubmitting(true); debouncedTitleSave();
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting(false));
}} }}
required={true} required={true}
className="min-h-10 block w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-2 text-xl outline-none ring-0 focus:ring-1 focus:ring-custom-primary" className="min-h-10 block w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-2 text-xl outline-none ring-0 focus:ring-1 focus:ring-custom-primary"
@ -106,9 +112,8 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
{characterLimit && ( {characterLimit && (
<div className="pointer-events-none absolute bottom-1 right-1 z-[2] rounded bg-custom-background-100 text-custom-text-200 p-0.5 text-xs"> <div className="pointer-events-none absolute bottom-1 right-1 z-[2] rounded bg-custom-background-100 text-custom-text-200 p-0.5 text-xs">
<span <span
className={`${ className={`${watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : ""
watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : "" }`}
}`}
> >
{watch("name").length} {watch("name").length}
</span> </span>
@ -117,47 +122,41 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
)} )}
</div> </div>
<span>{errors.name ? errors.name.message : null}</span> <span>{errors.name ? errors.name.message : null}</span>
<div className="relative"> <div id="tiptap-container" className="relative">
<Controller <Controller
name="description" name="description_html"
control={control} control={control}
render={({ field: { value } }) => { render={({ field: { value, onChange } }) => {
if (!value && !watch("description_html")) return <></>; if (!value && !watch("description_html")) return <></>;
return ( return (
<RemirrorRichTextEditor <Tiptap
value={ value={
!value || !value ||
value === "" || value === "" ||
(typeof value === "object" && Object.keys(value).length === 0) (typeof value === "object" && Object.keys(value).length === 0)
? watch("description_html") ? watch("description_html")
: value : value
} }
onJSONChange={(jsonValue) => { debouncedUpdatesEnabled={true}
setShowAlert(true); setIsSubmitting={setIsSubmitting}
setValue("description", jsonValue); customClassName="min-h-[150px]"
editorContentCustomClassNames="pb-9"
onChange={(description: Object, description_html: string) => {
setIsSubmitting("submitting");
onChange(description_html);
setValue("description", description);
handleSubmit(handleDescriptionFormSubmit)().finally(() => {
setIsSubmitting("submitted");
});
}} }}
onHTMLChange={(htmlValue) => {
setShowAlert(true);
setValue("description_html", htmlValue);
}}
onBlur={() => {
setIsSubmitting(true);
handleSubmit(handleDescriptionFormSubmit)()
.then(() => setShowAlert(false))
.finally(() => setIsSubmitting(false));
}}
placeholder="Description"
editable={isAllowed}
/> />
); );
}} }}
/> />
{isSubmitting && ( <div className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${isSubmitting === 'saved' ? 'fadeOut' : 'fadeIn'}`}>
<div className="absolute bottom-1 right-1 text-xs text-custom-text-200 bg-custom-background-100 p-3 z-10"> {isSubmitting === 'submitting' ? 'Saving...' : 'Saved'}
Saving... </div>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@ -1,6 +1,5 @@
import React, { FC, useState, useEffect, useRef } from "react"; import React, { FC, useState, useEffect, useRef } from "react";
import dynamic from "next/dynamic";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// react-hook-form // react-hook-form
@ -36,24 +35,14 @@ import {
import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline";
// types // types
import type { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types"; import type { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types";
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
// rich-text-editor // rich-text-editor
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
ssr: false,
loading: () => (
<Loader className="mt-4">
<Loader.Item height="12rem" width="100%" />
</Loader>
),
});
import { IRemirrorRichTextEditor } from "components/rich-text-editor"; const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>(
(props, ref) => <Tiptap {...props} forwardedRef={ref} />
);
const WrappedRemirrorRichTextEditor = React.forwardRef< TiptapEditor.displayName = "TiptapEditor";
IRemirrorRichTextEditor,
IRemirrorRichTextEditor
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />);
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
const defaultValues: Partial<IIssue> = { const defaultValues: Partial<IIssue> = {
project: "", project: "",
@ -344,7 +333,7 @@ export const IssueForm: FC<IssueFormProps> = ({
</div> </div>
)} )}
{(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && ( {(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && (
<div className="relative"> <div id="tiptap-container" className="relative">
<div className="flex justify-end"> <div className="flex justify-end">
{issueName && issueName !== "" && ( {issueName && issueName !== "" && (
<button <button
@ -374,21 +363,30 @@ export const IssueForm: FC<IssueFormProps> = ({
</button> </button>
</div> </div>
<Controller <Controller
name="description" name="description_html"
control={control} control={control}
render={({ field: { value } }) => ( render={({ field: { value, onChange } }) => {
<WrappedRemirrorRichTextEditor if (!value && !watch("description_html")) return <></>;
value={
!value || (typeof value === "object" && Object.keys(value).length === 0) return (
? watch("description_html") <TiptapEditor
: value ref={editorRef}
} debouncedUpdatesEnabled={false}
onJSONChange={(jsonValue) => setValue("description", jsonValue)} value={
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)} !value ||
placeholder="Description" value === "" ||
ref={editorRef} (typeof value === "object" && Object.keys(value).length === 0)
/> ? watch("description_html")
)} : value
}
customClassName="min-h-[150px]"
onChange={(description: Object, description_html: string) => {
onChange(description_html);
setValue("description", description);
}}
/>
);
}}
/> />
<GptAssistantModal <GptAssistantModal
isOpen={gptAssistantModal} isOpen={gptAssistantModal}

View File

@ -50,11 +50,11 @@ export const IssueMainContent: React.FC<Props> = ({
workspaceSlug && projectId && issueDetails?.parent ? SUB_ISSUES(issueDetails.parent) : null, workspaceSlug && projectId && issueDetails?.parent ? SUB_ISSUES(issueDetails.parent) : null,
workspaceSlug && projectId && issueDetails?.parent workspaceSlug && projectId && issueDetails?.parent
? () => ? () =>
issuesService.subIssues( issuesService.subIssues(
workspaceSlug as string, workspaceSlug as string,
projectId as string, projectId as string,
issueDetails.parent ?? "" issueDetails.parent ?? ""
) )
: null : null
); );
const siblingIssuesList = siblingIssues?.sub_issues.filter((i) => i.id !== issueDetails.id); const siblingIssuesList = siblingIssues?.sub_issues.filter((i) => i.id !== issueDetails.id);
@ -97,9 +97,8 @@ export const IssueMainContent: React.FC<Props> = ({
<CustomMenu.MenuItem <CustomMenu.MenuItem
key={issue.id} key={issue.id}
renderAs="a" renderAs="a"
href={`/${workspaceSlug}/projects/${projectId as string}/issues/${ href={`/${workspaceSlug}/projects/${projectId as string}/issues/${issue.id
issue.id }`}
}`}
className="flex items-center gap-2 py-2" className="flex items-center gap-2 py-2"
> >
<LayerDiagonalIcon className="h-4 w-4" /> <LayerDiagonalIcon className="h-4 w-4" />

View File

@ -85,7 +85,7 @@ export const SidebarLabelSelect: React.FC<Props> = ({
.then((res) => { .then((res) => {
reset(defaultValues); reset(defaultValues);
issueLabelMutate((prevData) => [...(prevData ?? []), res], false); issueLabelMutate((prevData: any) => [...(prevData ?? []), res], false);
submitChanges({ labels_list: [...(issueDetails?.labels ?? []), res.id] }); submitChanges({ labels_list: [...(issueDetails?.labels ?? []), res.id] });

View File

@ -49,8 +49,8 @@ export const LabelsListModal: React.FC<Props> = ({ isOpen, handleClose, parent,
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
mutate( mutate(
(prevData) => (prevData: any) =>
prevData?.map((l) => { prevData?.map((l: any) => {
if (l.id === label.id) return { ...l, parent: parent?.id ?? "" }; if (l.id === label.id) return { ...l, parent: parent?.id ?? "" };
return l; return l;

View File

@ -42,10 +42,10 @@ export const ModulesListGanttChartView: FC<Props> = ({ modules, mutateModules })
const handleModuleUpdate = (module: IModule, payload: IBlockUpdateData) => { const handleModuleUpdate = (module: IModule, payload: IBlockUpdateData) => {
if (!workspaceSlug || !user) return; if (!workspaceSlug || !user) return;
mutateModules((prevData) => { mutateModules((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
const newList = prevData.map((p) => ({ const newList = prevData.map((p: any) => ({
...p, ...p,
...(p.id === module.id ...(p.id === module.id
? { ? {

View File

@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import dynamic from "next/dynamic";
import { mutate } from "swr"; import { mutate } from "swr";
@ -18,11 +17,12 @@ import useToast from "hooks/use-toast";
// components // components
import { GptAssistantModal } from "components/core"; import { GptAssistantModal } from "components/core";
// ui // ui
import { Loader, PrimaryButton, SecondaryButton, TextArea } from "components/ui"; import { PrimaryButton, SecondaryButton, TextArea } from "components/ui";
// types // types
import { ICurrentUserResponse, IPageBlock } from "types"; import { ICurrentUserResponse, IPageBlock } from "types";
// fetch-keys // fetch-keys
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys"; import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
type Props = { type Props = {
handleClose: () => void; handleClose: () => void;
@ -39,22 +39,11 @@ const defaultValues = {
description_html: null, description_html: null,
}; };
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>(
ssr: false, (props, ref) => <Tiptap {...props} forwardedRef={ref} />
loading: () => ( );
<Loader className="mx-4 mt-6">
<Loader.Item height="100px" width="100%" />
</Loader>
),
});
import { IRemirrorRichTextEditor } from "components/rich-text-editor";
const WrappedRemirrorRichTextEditor = React.forwardRef< TiptapEditor.displayName = "TiptapEditor";
IRemirrorRichTextEditor,
IRemirrorRichTextEditor
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />);
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
export const CreateUpdateBlockInline: React.FC<Props> = ({ export const CreateUpdateBlockInline: React.FC<Props> = ({
handleClose, handleClose,
@ -295,25 +284,27 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
maxLength={255} maxLength={255}
/> />
</div> </div>
<div className="page-block-section relative -mt-2 text-custom-text-200"> <div
id="tiptap-container"
className="page-block-section relative -mt-2 text-custom-text-200"
>
<Controller <Controller
name="description" name="description_html"
control={control} control={control}
render={({ field: { value } }) => { render={({ field: { value, onChange } }) => {
if (!data) if (!data)
return ( return (
<WrappedRemirrorRichTextEditor <TiptapEditor
value={{ ref={editorRef}
type: "doc", value={"<p></p>"}
content: [{ type: "paragraph" }], debouncedUpdatesEnabled={false}
}}
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
placeholder="Write something..."
customClassName="text-sm" customClassName="text-sm"
noBorder noBorder
borderOnFocus={false} borderOnFocus={false}
ref={editorRef} onChange={(description: Object, description_html: string) => {
onChange(description_html);
setValue("description", description);
}}
/> />
); );
else if (!value || !watch("description_html")) else if (!value || !watch("description_html"))
@ -322,7 +313,8 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
); );
return ( return (
<WrappedRemirrorRichTextEditor <TiptapEditor
ref={editorRef}
value={ value={
value && value !== "" && Object.keys(value).length > 0 value && value !== "" && Object.keys(value).length > 0
? value ? value
@ -330,13 +322,14 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
? watch("description_html") ? watch("description_html")
: { type: "doc", content: [{ type: "paragraph" }] } : { type: "doc", content: [{ type: "paragraph" }] }
} }
onJSONChange={(jsonValue) => setValue("description", jsonValue)} debouncedUpdatesEnabled={false}
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
placeholder="Write something..."
customClassName="text-sm" customClassName="text-sm"
noBorder noBorder
borderOnFocus={false} borderOnFocus={false}
ref={editorRef} onChange={(description: Object, description_html: string) => {
onChange(description_html);
setValue("description", description);
}}
/> />
); );
}} }}

View File

@ -1,7 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import dynamic from "next/dynamic";
// react-hook-form // react-hook-form
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
// ui // ui
@ -16,16 +14,6 @@ type Props = {
data?: IPage | null; data?: IPage | null;
}; };
// rich-text-editor
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
ssr: false,
loading: () => (
<Loader>
<Loader.Item height="12rem" width="100%" />
</Loader>
),
});
const defaultValues = { const defaultValues = {
name: "", name: "",
description: "", description: "",

View File

@ -19,7 +19,6 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components // components
import { GptAssistantModal } from "components/core"; import { GptAssistantModal } from "components/core";
import { CreateUpdateBlockInline } from "components/pages"; import { CreateUpdateBlockInline } from "components/pages";
import RemirrorRichTextEditor, { IRemirrorRichTextEditor } from "components/rich-text-editor";
// ui // ui
import { CustomMenu, TextArea } from "components/ui"; import { CustomMenu, TextArea } from "components/ui";
// icons // icons
@ -39,6 +38,7 @@ import { copyTextToClipboard } from "helpers/string.helper";
import { ICurrentUserResponse, IIssue, IPageBlock, IProject } from "types"; import { ICurrentUserResponse, IIssue, IPageBlock, IProject } from "types";
// fetch-keys // fetch-keys
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys"; import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
type Props = { type Props = {
block: IPageBlock; block: IPageBlock;
@ -48,12 +48,12 @@ type Props = {
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
}; };
const WrappedRemirrorRichTextEditor = React.forwardRef< const TiptapEditor = React.forwardRef<
IRemirrorRichTextEditor, ITiptapRichTextEditor,
IRemirrorRichTextEditor ITiptapRichTextEditor
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />); >((props, ref) => <Tiptap {...props} forwardedRef={ref} />);
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor"; TiptapEditor.displayName = "TiptapEditor";
export const SinglePageBlock: React.FC<Props> = ({ export const SinglePageBlock: React.FC<Props> = ({
block, block,
@ -328,9 +328,8 @@ export const SinglePageBlock: React.FC<Props> = ({
</div> </div>
) : ( ) : (
<div <div
className={`group relative w-full rounded bg-custom-background-80 text-custom-text-200 ${ className={`group relative w-full rounded bg-custom-background-80 text-custom-text-200 ${snapshot.isDragging ? "bg-custom-background-100 p-4 shadow" : ""
snapshot.isDragging ? "bg-custom-background-100 p-4 shadow" : "" }`}
}`}
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...provided.draggableProps}
> >
@ -344,9 +343,8 @@ export const SinglePageBlock: React.FC<Props> = ({
</button> </button>
<div <div
ref={actionSectionRef} ref={actionSectionRef}
className={`absolute top-4 right-2 hidden items-center gap-2 bg-custom-background-80 pl-4 group-hover:!flex ${ className={`absolute top-4 right-2 hidden items-center gap-2 bg-custom-background-80 pl-4 group-hover:!flex ${isMenuActive ? "!flex" : ""
isMenuActive ? "!flex" : "" }`}
}`}
> >
{block.issue && block.sync && ( {block.issue && block.sync && (
<div className="flex flex-shrink-0 cursor-default items-center gap-1 rounded py-1 px-1.5 text-xs"> <div className="flex flex-shrink-0 cursor-default items-center gap-1 rounded py-1 px-1.5 text-xs">
@ -360,9 +358,8 @@ export const SinglePageBlock: React.FC<Props> = ({
)} )}
<button <button
type="button" type="button"
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 ${ className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 ${iAmFeelingLucky ? "cursor-wait" : ""
iAmFeelingLucky ? "cursor-wait" : "" }`}
}`}
onClick={handleAutoGenerateDescription} onClick={handleAutoGenerateDescription}
disabled={iAmFeelingLucky} disabled={iAmFeelingLucky}
> >
@ -458,18 +455,17 @@ export const SinglePageBlock: React.FC<Props> = ({
{showBlockDetails {showBlockDetails
? block.description_html.length > 7 && ( ? block.description_html.length > 7 && (
<WrappedRemirrorRichTextEditor <TiptapEditor
value={block.description_html} value={block.description_html}
customClassName="text-sm" customClassName="text-sm min-h-[150px]"
noBorder noBorder
borderOnFocus={false} borderOnFocus={false}
/> />
) ) : block.description_stripped.length > 0 && (
: block.description_stripped.length > 0 && ( <p className="mt-3 text-sm font-normal text-custom-text-200 h-5 truncate">
<p className="mt-3 text-sm font-normal text-custom-text-200 h-5 truncate"> {block.description_stripped}
{block.description_stripped} </p>
</p> )}
)}
</div> </div>
</div> </div>
<GptAssistantModal <GptAssistantModal

View File

@ -110,7 +110,7 @@ export const ProfileIssuesView = () => {
draggedItem[groupByProperty] = destinationGroup; draggedItem[groupByProperty] = destinationGroup;
mutateProfileIssues((prevData) => { mutateProfileIssues((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
const sourceGroupArray = [...groupedIssues[sourceGroup]]; const sourceGroupArray = [...groupedIssues[sourceGroup]];

View File

@ -1,236 +0,0 @@
import { useCallback, useState, useImperativeHandle } from "react";
import { useRouter } from "next/router";
import { InvalidContentHandler } from "remirror";
import {
BoldExtension,
ItalicExtension,
CalloutExtension,
PlaceholderExtension,
CodeBlockExtension,
CodeExtension,
HistoryExtension,
LinkExtension,
UnderlineExtension,
HeadingExtension,
OrderedListExtension,
ListItemExtension,
BulletListExtension,
ImageExtension,
DropCursorExtension,
StrikeExtension,
MentionAtomExtension,
FontSizeExtension,
} from "remirror/extensions";
import {
Remirror,
useRemirror,
EditorComponent,
OnChangeJSON,
OnChangeHTML,
FloatingToolbar,
FloatingWrapper,
} from "@remirror/react";
import { TableExtension } from "@remirror/extension-react-tables";
// tlds
import tlds from "tlds";
// services
import fileService from "services/file.service";
// components
import { CustomFloatingToolbar } from "./toolbar/float-tool-tip";
import { MentionAutoComplete } from "./mention-autocomplete";
export interface IRemirrorRichTextEditor {
placeholder?: string;
mentions?: any[];
tags?: any[];
onBlur?: (jsonValue: any, htmlValue: any) => void;
onJSONChange?: (jsonValue: any) => void;
onHTMLChange?: (htmlValue: any) => void;
value?: any;
showToolbar?: boolean;
editable?: boolean;
customClassName?: string;
gptOption?: boolean;
noBorder?: boolean;
borderOnFocus?: boolean;
forwardedRef?: any;
}
const RemirrorRichTextEditor: React.FC<IRemirrorRichTextEditor> = (props) => {
const {
placeholder,
mentions = [],
tags = [],
onBlur = () => {},
onJSONChange = () => {},
onHTMLChange = () => {},
value = "",
showToolbar = true,
editable = true,
customClassName,
gptOption = false,
noBorder = false,
borderOnFocus = true,
forwardedRef,
} = props;
const [disableToolbar, setDisableToolbar] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
// remirror error handler
const onError: InvalidContentHandler = useCallback(
({ json, invalidContent, transformers }: any) =>
// Automatically remove all invalid nodes and marks.
transformers.remove(json, invalidContent),
[]
);
const uploadImageHandler = (value: any): any => {
try {
const formData = new FormData();
formData.append("asset", value[0].file);
formData.append("attributes", JSON.stringify({}));
return [
() =>
new Promise(async (resolve, reject) => {
const imageUrl = await fileService
.uploadFile(workspaceSlug as string, formData)
.then((response) => response.asset);
resolve({
align: "left",
alt: "Not Found",
height: "100%",
width: "35%",
src: imageUrl,
});
}),
];
} catch {
return [];
}
};
// remirror manager
const { manager, state } = useRemirror({
extensions: () => [
new BoldExtension(),
new ItalicExtension(),
new UnderlineExtension(),
new HeadingExtension({ levels: [1, 2, 3] }),
new FontSizeExtension({ defaultSize: "16", unit: "px" }),
new OrderedListExtension(),
new ListItemExtension(),
new BulletListExtension({ enableSpine: true }),
new CalloutExtension({ defaultType: "warn" }),
new CodeBlockExtension(),
new CodeExtension(),
new PlaceholderExtension({
placeholder: placeholder || "Enter text...",
emptyNodeClass: "empty-node",
}),
new HistoryExtension(),
new LinkExtension({
autoLink: true,
autoLinkAllowedTLDs: tlds,
selectTextOnClick: true,
defaultTarget: "_blank",
}),
new ImageExtension({
enableResizing: true,
uploadHandler: uploadImageHandler,
createPlaceholder() {
const div = document.createElement("div");
div.className =
"w-[35%] aspect-video bg-custom-background-80 text-custom-text-200 animate-pulse";
return div;
},
}),
new DropCursorExtension(),
new StrikeExtension(),
new MentionAtomExtension({
matchers: [
{ name: "at", char: "@" },
{ name: "tag", char: "#" },
],
}),
new TableExtension(),
],
content: value,
selection: "start",
stringHandler: "html",
onError,
});
useImperativeHandle(forwardedRef, () => ({
clearEditor: () => {
manager.view.updateState(manager.createState({ content: "", selection: "start" }));
},
setEditorValue: (value: any) => {
manager.view.updateState(
manager.createState({
content: value,
selection: "end",
})
);
},
}));
return (
<div className="relative">
<Remirror
manager={manager}
initialContent={state}
classNames={[
`p-3 relative focus:outline-none rounded-md focus:border-custom-border-200 ${
noBorder ? "" : "border border-custom-border-200"
} ${
borderOnFocus ? "focus:border border-custom-border-200" : "focus:border-0"
} ${customClassName}`,
]}
editable={editable}
onBlur={(event) => {
const html = event.helpers.getHTML();
const json = event.helpers.getJSON();
setDisableToolbar(true);
onBlur(json, html);
}}
onFocus={() => setDisableToolbar(false)}
>
<div className="prose prose-brand max-w-full prose-p:my-1">
<EditorComponent />
</div>
{editable && !disableToolbar && (
<FloatingWrapper
positioner="always"
renderOutsideEditor
floatingLabel="Custom Floating Toolbar"
>
<FloatingToolbar className="z-50 overflow-hidden rounded">
<CustomFloatingToolbar
gptOption={gptOption}
editorState={state}
setDisableToolbar={setDisableToolbar}
/>
</FloatingToolbar>
</FloatingWrapper>
)}
<MentionAutoComplete mentions={mentions} tags={tags} />
{<OnChangeJSON onChange={onJSONChange} />}
{<OnChangeHTML onChange={onHTMLChange} />}
</Remirror>
</div>
);
};
RemirrorRichTextEditor.displayName = "RemirrorRichTextEditor";
export default RemirrorRichTextEditor;

View File

@ -1,64 +0,0 @@
import { useState, useEffect, FC } from "react";
// remirror imports
import { cx } from "@remirror/core";
import { useMentionAtom, MentionAtomNodeAttributes, FloatingWrapper } from "@remirror/react";
// export const;
export interface IMentionAutoComplete {
mentions?: any[];
tags?: any[];
}
export const MentionAutoComplete: FC<IMentionAutoComplete> = (props) => {
const { mentions = [], tags = [] } = props;
// states
const [options, setOptions] = useState<MentionAtomNodeAttributes[]>([]);
const { state, getMenuProps, getItemProps, indexIsHovered, indexIsSelected } = useMentionAtom({
items: options,
});
useEffect(() => {
if (!state) {
return;
}
const searchTerm = state.query.full.toLowerCase();
let filteredOptions: MentionAtomNodeAttributes[] = [];
if (state.name === "tag") {
filteredOptions = tags.filter((tag) => tag?.label.toLowerCase().includes(searchTerm));
} else if (state.name === "at") {
filteredOptions = mentions.filter((user) => user?.label.toLowerCase().includes(searchTerm));
}
filteredOptions = filteredOptions.sort().slice(0, 5);
setOptions(filteredOptions);
}, [state, mentions, tags]);
const enabled = Boolean(state);
return (
<FloatingWrapper positioner="cursor" enabled={enabled} placement="bottom-start">
<div {...getMenuProps()} className="suggestions">
{enabled &&
options.map((user, index) => {
const isHighlighted = indexIsSelected(index);
const isHovered = indexIsHovered(index);
return (
<div
key={user.id}
className={cx("suggestion", isHighlighted && "highlighted", isHovered && "hovered")}
{...getItemProps({
item: user,
index,
})}
>
{user.label}
</div>
);
})}
</div>
</FloatingWrapper>
);
};

View File

@ -1,145 +0,0 @@
import React, { useEffect, useState } from "react";
import { TableExtension } from "@remirror/extension-react-tables";
import {
EditorComponent,
ReactComponentExtension,
Remirror,
TableComponents,
tableControllerPluginKey,
ThemeProvider,
useCommands,
useRemirror,
useRemirrorContext,
} from "@remirror/react";
import type { AnyExtension } from "remirror";
const CommandMenu: React.FC = () => {
const { createTable, ...commands } = useCommands();
return (
<div>
<p>commands:</p>
<p
style={{
display: "flex",
flexDirection: "column",
justifyItems: "flex-start",
alignItems: "flex-start",
}}
>
<button
onMouseDown={(event) => event.preventDefault()}
data-testid="btn-3-3"
onClick={() => createTable({ rowsCount: 3, columnsCount: 3, withHeaderRow: false })}
>
insert a 3*3 table
</button>
<button
onMouseDown={(event) => event.preventDefault()}
data-testid="btn-3-3-headers"
onClick={() => createTable({ rowsCount: 3, columnsCount: 3, withHeaderRow: true })}
>
insert a 3*3 table with headers
</button>
<button
onMouseDown={(event) => event.preventDefault()}
data-testid="btn-4-10"
onClick={() => createTable({ rowsCount: 10, columnsCount: 4, withHeaderRow: false })}
>
insert a 4*10 table
</button>
<button
onMouseDown={(event) => event.preventDefault()}
data-testid="btn-3-30"
onClick={() => createTable({ rowsCount: 30, columnsCount: 3, withHeaderRow: false })}
>
insert a 3*30 table
</button>
<button
onMouseDown={(event) => event.preventDefault()}
data-testid="btn-8-100"
onClick={() => createTable({ rowsCount: 100, columnsCount: 8, withHeaderRow: false })}
>
insert a 8*100 table
</button>
<button
onMouseDown={(event) => event.preventDefault()}
onClick={() => commands.addTableColumnAfter()}
>
add a column after the current one
</button>
<button
onMouseDown={(event) => event.preventDefault()}
onClick={() => commands.addTableRowBefore()}
>
add a row before the current one
</button>
<button
onMouseDown={(event) => event.preventDefault()}
onClick={() => commands.deleteTable()}
>
delete the table
</button>
</p>
</div>
);
};
const ProsemirrorDocData: React.FC = () => {
const ctx = useRemirrorContext({ autoUpdate: false });
const [jsonPluginState, setJsonPluginState] = useState("");
const [jsonDoc, setJsonDoc] = useState("");
const { addHandler, view } = ctx;
useEffect(() => {
addHandler("updated", () => {
setJsonDoc(JSON.stringify(view.state.doc.toJSON(), null, 2));
const pluginStateValues = tableControllerPluginKey.getState(view.state)?.values;
setJsonPluginState(
JSON.stringify({ ...pluginStateValues, tableNodeResult: "hidden" }, null, 2)
);
});
}, [addHandler, view]);
return (
<div>
<p>tableControllerPluginKey.getState(view.state)</p>
<pre style={{ fontSize: "12px", lineHeight: "12px" }}>
<code>{jsonPluginState}</code>
</pre>
<p>view.state.doc.toJSON()</p>
<pre style={{ fontSize: "12px", lineHeight: "12px" }}>
<code>{jsonDoc}</code>
</pre>
</div>
);
};
const Table = ({
children,
extensions,
}: {
children?: React.ReactElement;
extensions: () => AnyExtension[];
}): JSX.Element => {
const { manager, state } = useRemirror({ extensions });
return (
<ThemeProvider>
<Remirror manager={manager} initialContent={state}>
<EditorComponent />
<TableComponents />
<CommandMenu />
<ProsemirrorDocData />
{children}
</Remirror>
</ThemeProvider>
);
};
const Basic = (): JSX.Element => <Table extensions={defaultExtensions} />;
const defaultExtensions = () => [new ReactComponentExtension(), new TableExtension()];
export default Basic;

View File

@ -1,316 +0,0 @@
import React, {
ChangeEvent,
HTMLProps,
KeyboardEvent,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createMarkPositioner, LinkExtension, ShortcutHandlerProps } from "remirror/extensions";
// buttons
import {
ToggleBoldButton,
ToggleItalicButton,
ToggleUnderlineButton,
ToggleStrikeButton,
ToggleOrderedListButton,
ToggleBulletListButton,
ToggleCodeButton,
ToggleHeadingButton,
useActive,
CommandButton,
useAttrs,
useChainedCommands,
useCurrentSelection,
useExtensionEvent,
useUpdateReason,
} from "@remirror/react";
import { EditorState } from "remirror";
type Props = {
gptOption?: boolean;
editorState: Readonly<EditorState>;
setDisableToolbar: React.Dispatch<React.SetStateAction<boolean>>;
};
const useLinkShortcut = () => {
const [linkShortcut, setLinkShortcut] = useState<ShortcutHandlerProps | undefined>();
const [isEditing, setIsEditing] = useState(false);
useExtensionEvent(
LinkExtension,
"onShortcut",
useCallback(
(props) => {
if (!isEditing) {
setIsEditing(true);
}
return setLinkShortcut(props);
},
[isEditing]
)
);
return { linkShortcut, isEditing, setIsEditing };
};
const useFloatingLinkState = () => {
const chain = useChainedCommands();
const { isEditing, linkShortcut, setIsEditing } = useLinkShortcut();
const { to, empty } = useCurrentSelection();
const url = (useAttrs().link()?.href as string) ?? "";
const [href, setHref] = useState<string>(url);
// A positioner which only shows for links.
const linkPositioner = useMemo(() => createMarkPositioner({ type: "link" }), []);
const onRemove = useCallback(() => chain.removeLink().focus().run(), [chain]);
const updateReason = useUpdateReason();
useLayoutEffect(() => {
if (!isEditing) {
return;
}
if (updateReason.doc || updateReason.selection) {
setIsEditing(false);
}
}, [isEditing, setIsEditing, updateReason.doc, updateReason.selection]);
useEffect(() => {
setHref(url);
}, [url]);
const submitHref = useCallback(() => {
setIsEditing(false);
const range = linkShortcut ?? undefined;
if (href === "") {
chain.removeLink();
} else {
chain.updateLink({ href, auto: false }, range);
}
chain.focus(range?.to ?? to).run();
}, [setIsEditing, linkShortcut, chain, href, to]);
const cancelHref = useCallback(() => {
setIsEditing(false);
}, [setIsEditing]);
const clickEdit = useCallback(() => {
if (empty) {
chain.selectLink();
}
setIsEditing(true);
}, [chain, empty, setIsEditing]);
return useMemo(
() => ({
href,
setHref,
linkShortcut,
linkPositioner,
isEditing,
setIsEditing,
clickEdit,
onRemove,
submitHref,
cancelHref,
}),
[
href,
linkShortcut,
linkPositioner,
isEditing,
clickEdit,
onRemove,
submitHref,
cancelHref,
setIsEditing,
]
);
};
const DelayAutoFocusInput = ({
autoFocus,
setDisableToolbar,
...rest
}: HTMLProps<HTMLInputElement> & {
setDisableToolbar: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!autoFocus) {
return;
}
setDisableToolbar(false);
const frame = window.requestAnimationFrame(() => {
inputRef.current?.focus();
});
return () => {
window.cancelAnimationFrame(frame);
};
}, [autoFocus, setDisableToolbar]);
useEffect(() => {
setDisableToolbar(false);
}, [setDisableToolbar]);
return (
<>
<label htmlFor="link-input" className="text-sm">
Add Link
</label>
<input
ref={inputRef}
{...rest}
onKeyDown={(e) => {
if (rest.onKeyDown) rest.onKeyDown(e);
setDisableToolbar(false);
}}
className={`${rest.className} mt-1`}
onFocus={() => {
setDisableToolbar(false);
}}
onBlur={() => {
setDisableToolbar(true);
}}
/>
</>
);
};
export const CustomFloatingToolbar: React.FC<Props> = ({
gptOption,
editorState,
setDisableToolbar,
}) => {
const { isEditing, setIsEditing, clickEdit, onRemove, submitHref, href, setHref, cancelHref } =
useFloatingLinkState();
const active = useActive();
const activeLink = active.link();
const handleClickEdit = useCallback(() => {
clickEdit();
}, [clickEdit]);
return (
<div className="z-[99999] flex flex-col items-center gap-y-2 divide-x divide-y divide-custom-border-200 rounded border border-custom-border-200 bg-custom-background-80 p-1 px-0.5 shadow-md">
<div className="flex items-center gap-y-2 divide-x divide-custom-border-200">
<div className="flex items-center gap-x-1 px-2">
<ToggleHeadingButton
attrs={{
level: 1,
}}
/>
<ToggleHeadingButton
attrs={{
level: 2,
}}
/>
<ToggleHeadingButton
attrs={{
level: 3,
}}
/>
</div>
<div className="flex items-center gap-x-1 px-2">
<ToggleBoldButton />
<ToggleItalicButton />
<ToggleUnderlineButton />
<ToggleStrikeButton />
</div>
<div className="flex items-center gap-x-1 px-2">
<ToggleOrderedListButton />
<ToggleBulletListButton />
</div>
{gptOption && (
<div className="flex items-center gap-x-1 px-2">
<button
type="button"
className="rounded py-1 px-1.5 text-xs hover:bg-custom-background-90"
onClick={() => console.log(editorState.selection.$anchor.nodeBefore)}
>
AI
</button>
</div>
)}
<div className="flex items-center gap-x-1 px-2">
<ToggleCodeButton />
</div>
{activeLink ? (
<div className="flex items-center gap-x-1 px-2">
<CommandButton
commandName="openLink"
onSelect={() => {
window.open(href, "_blank");
}}
icon="externalLinkFill"
enabled
/>
<CommandButton
commandName="updateLink"
onSelect={handleClickEdit}
icon="pencilLine"
enabled
/>
<CommandButton commandName="removeLink" onSelect={onRemove} icon="linkUnlink" enabled />
</div>
) : (
<CommandButton
commandName="updateLink"
onSelect={() => {
if (isEditing) {
setIsEditing(false);
} else {
handleClickEdit();
}
}}
icon="link"
enabled
active={isEditing}
/>
)}
</div>
{isEditing && (
<div className="p-2 w-full">
<DelayAutoFocusInput
autoFocus
placeholder="Paste your link here..."
id="link-input"
setDisableToolbar={setDisableToolbar}
className="w-full px-2 py-0.5"
onChange={(e: ChangeEvent<HTMLInputElement>) => setHref(e.target.value)}
value={href}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
const { code } = e;
if (code === "Enter") {
submitHref();
}
if (code === "Escape") {
cancelHref();
}
}}
/>
</div>
)}
</div>
);
};

View File

@ -1,57 +0,0 @@
// remirror
import { useCommands, useActive } from "@remirror/react";
// ui
import { CustomMenu } from "components/ui";
const HeadingControls = () => {
const { toggleHeading, focus } = useCommands();
const active = useActive();
return (
<div className="flex items-center gap-1">
<CustomMenu
width="lg"
label={`${
active.heading({ level: 1 })
? "Heading 1"
: active.heading({ level: 2 })
? "Heading 2"
: active.heading({ level: 3 })
? "Heading 3"
: "Normal text"
}`}
>
<CustomMenu.MenuItem
onClick={() => {
toggleHeading({ level: 1 });
focus();
}}
className={`${active.heading({ level: 1 }) ? "bg-indigo-50" : ""}`}
>
Heading 1
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
toggleHeading({ level: 2 });
focus();
}}
className={`${active.heading({ level: 2 }) ? "bg-indigo-50" : ""}`}
>
Heading 2
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
toggleHeading({ level: 3 });
focus();
}}
className={`${active.heading({ level: 3 }) ? "bg-indigo-50" : ""}`}
>
Heading 3
</CustomMenu.MenuItem>
</CustomMenu>
</div>
);
};
export default HeadingControls;

View File

@ -1,35 +0,0 @@
// buttons
import {
ToggleBoldButton,
ToggleItalicButton,
ToggleUnderlineButton,
ToggleStrikeButton,
ToggleOrderedListButton,
ToggleBulletListButton,
RedoButton,
UndoButton,
} from "@remirror/react";
// headings
import HeadingControls from "./heading-controls";
export const RichTextToolbar: React.FC = () => (
<div className="flex items-center gap-y-2 divide-x">
<div className="flex items-center gap-x-1 px-2">
<RedoButton />
<UndoButton />
</div>
<div className="px-2">
<HeadingControls />
</div>
<div className="flex items-center gap-x-1 px-2">
<ToggleBoldButton />
<ToggleItalicButton />
<ToggleUnderlineButton />
<ToggleStrikeButton />
</div>
<div className="flex items-center gap-x-1 px-2">
<ToggleOrderedListButton />
<ToggleBulletListButton />
</div>
</div>
);

View File

@ -1,215 +0,0 @@
import React, {
ChangeEvent,
HTMLProps,
KeyboardEvent,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createMarkPositioner, LinkExtension, ShortcutHandlerProps } from "remirror/extensions";
import {
CommandButton,
FloatingToolbar,
FloatingWrapper,
useActive,
useAttrs,
useChainedCommands,
useCurrentSelection,
useExtensionEvent,
useUpdateReason,
} from "@remirror/react";
const useLinkShortcut = () => {
const [linkShortcut, setLinkShortcut] = useState<ShortcutHandlerProps | undefined>();
const [isEditing, setIsEditing] = useState(false);
useExtensionEvent(
LinkExtension,
"onShortcut",
useCallback(
(props) => {
if (!isEditing) {
setIsEditing(true);
}
return setLinkShortcut(props);
},
[isEditing]
)
);
return { linkShortcut, isEditing, setIsEditing };
};
const useFloatingLinkState = () => {
const chain = useChainedCommands();
const { isEditing, linkShortcut, setIsEditing } = useLinkShortcut();
const { to, empty } = useCurrentSelection();
const url = (useAttrs().link()?.href as string) ?? "";
const [href, setHref] = useState<string>(url);
// A positioner which only shows for links.
const linkPositioner = useMemo(() => createMarkPositioner({ type: "link" }), []);
const onRemove = useCallback(() => chain.removeLink().focus().run(), [chain]);
const updateReason = useUpdateReason();
useLayoutEffect(() => {
if (!isEditing) {
return;
}
if (updateReason.doc || updateReason.selection) {
setIsEditing(false);
}
}, [isEditing, setIsEditing, updateReason.doc, updateReason.selection]);
useEffect(() => {
setHref(url);
}, [url]);
const submitHref = useCallback(() => {
setIsEditing(false);
const range = linkShortcut ?? undefined;
if (href === "") {
chain.removeLink();
} else {
chain.updateLink({ href, auto: false }, range);
}
chain.focus(range?.to ?? to).run();
}, [setIsEditing, linkShortcut, chain, href, to]);
const cancelHref = useCallback(() => {
setIsEditing(false);
}, [setIsEditing]);
const clickEdit = useCallback(() => {
if (empty) {
chain.selectLink();
}
setIsEditing(true);
}, [chain, empty, setIsEditing]);
return useMemo(
() => ({
href,
setHref,
linkShortcut,
linkPositioner,
isEditing,
clickEdit,
onRemove,
submitHref,
cancelHref,
}),
[href, linkShortcut, linkPositioner, isEditing, clickEdit, onRemove, submitHref, cancelHref]
);
};
const DelayAutoFocusInput = ({ autoFocus, ...rest }: HTMLProps<HTMLInputElement>) => {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!autoFocus) {
return;
}
const frame = window.requestAnimationFrame(() => {
inputRef.current?.focus();
});
return () => {
window.cancelAnimationFrame(frame);
};
}, [autoFocus]);
return <input ref={inputRef} {...rest} />;
};
export const FloatingLinkToolbar = () => {
const { isEditing, linkPositioner, clickEdit, onRemove, submitHref, href, setHref, cancelHref } =
useFloatingLinkState();
const active = useActive();
const activeLink = active.link();
const { empty } = useCurrentSelection();
const handleClickEdit = useCallback(() => {
clickEdit();
}, [clickEdit]);
const linkEditButtons = activeLink ? (
<>
<CommandButton
commandName="openLink"
onSelect={() => {
window.open(href, "_blank");
}}
icon="externalLinkFill"
enabled
/>
<CommandButton
commandName="updateLink"
onSelect={handleClickEdit}
icon="pencilLine"
enabled
/>
<CommandButton commandName="removeLink" onSelect={onRemove} icon="linkUnlink" enabled />
</>
) : (
<CommandButton commandName="updateLink" onSelect={handleClickEdit} icon="link" enabled />
);
return (
<>
{!isEditing && (
<FloatingToolbar className="rounded bg-custom-background-80 p-1 shadow-lg">
{linkEditButtons}
</FloatingToolbar>
)}
{!isEditing && empty && (
<FloatingToolbar
positioner={linkPositioner}
className="rounded bg-custom-background-80 p-1 shadow-lg"
>
{linkEditButtons}
</FloatingToolbar>
)}
<FloatingWrapper
positioner="always"
placement="bottom"
enabled={isEditing}
renderOutsideEditor
>
<DelayAutoFocusInput
autoFocus
placeholder="Enter link..."
onChange={(e: ChangeEvent<HTMLInputElement>) => setHref(e.target.value)}
value={href}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
const { code } = e;
if (code === "Enter") {
submitHref();
}
if (code === "Escape") {
cancelHref();
}
}}
/>
</FloatingWrapper>
</>
);
};

View File

@ -1,55 +0,0 @@
import { useCommands } from "@remirror/react";
export const TableControls = () => {
const { createTable, ...commands } = useCommands();
return (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => createTable({ rowsCount: 3, columnsCount: 3, withHeaderRow: false })}
className="rounded p-1 hover:bg-custom-background-90"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="icon icon-tabler icon-tabler-table"
width="18"
height="18"
viewBox="0 0 24 24"
stroke="#2c3e50"
fill="none"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<rect x="4" y="4" width="16" height="16" rx="2" />
<line x1="4" y1="10" x2="20" y2="10" />
<line x1="10" y1="4" x2="10" y2="20" />
</svg>
</button>
<button
type="button"
onClick={() => commands.deleteTable()}
className="rounded p-1 hover:bg-custom-background-90"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="icon icon-tabler icon-tabler-trash"
width="18"
height="18"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="#2c3e50"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="4" y1="7" x2="20" y2="7" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12" />
<path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3" />
</svg>
</button>
</div>
);
};

View File

@ -0,0 +1,115 @@
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react";
import { FC, useState } from "react";
import { BoldIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react";
import { NodeSelector } from "./node-selector";
import { LinkSelector } from "./link-selector";
import { cn } from "../utils";
export interface BubbleMenuItem {
name: string;
isActive: () => boolean;
command: () => void;
icon: typeof BoldIcon;
}
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const items: BubbleMenuItem[] = [
{
name: "bold",
isActive: () => props.editor.isActive("bold"),
command: () => props.editor.chain().focus().toggleBold().run(),
icon: BoldIcon,
},
{
name: "italic",
isActive: () => props.editor.isActive("italic"),
command: () => props.editor.chain().focus().toggleItalic().run(),
icon: ItalicIcon,
},
{
name: "underline",
isActive: () => props.editor.isActive("underline"),
command: () => props.editor.chain().focus().toggleUnderline().run(),
icon: UnderlineIcon,
},
{
name: "strike",
isActive: () => props.editor.isActive("strike"),
command: () => props.editor.chain().focus().toggleStrike().run(),
icon: StrikethroughIcon,
},
{
name: "code",
isActive: () => props.editor.isActive("code"),
command: () => props.editor.chain().focus().toggleCode().run(),
icon: CodeIcon,
},
];
const bubbleMenuProps: EditorBubbleMenuProps = {
...props,
shouldShow: ({ editor }) => {
if (!editor.isEditable) {
return false;
}
if (editor.isActive("image")) {
return false;
}
return editor.view.state.selection.content().size > 0;
},
tippyOptions: {
moveTransition: "transform 0.15s ease-out",
onHidden: () => {
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
},
},
};
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
return (
<BubbleMenu
{...bubbleMenuProps}
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
>
<NodeSelector
editor={props.editor}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsLinkSelectorOpen(false);
}}
/>
<LinkSelector
editor={props.editor}
isOpen={isLinkSelectorOpen}
setIsOpen={() => {
setIsLinkSelectorOpen(!isLinkSelectorOpen);
setIsNodeSelectorOpen(false);
}}
/>
<div className="flex">
{items.map((item, index) => (
<button
key={index}
onClick={item.command}
className={cn("p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors", {
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
})}
>
<item.icon
className={cn("h-4 w-4", {
"text-custom-text-100": item.isActive(),
})}
/>
</button>
))}
</div>
</BubbleMenu>
);
};

View File

@ -0,0 +1,73 @@
import { Editor } from "@tiptap/core";
import { Check, Trash } from "lucide-react";
import { Dispatch, FC, SetStateAction, useEffect, useRef } from "react";
import { cn } from "../utils";
interface LinkSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current && inputRef.current?.focus();
});
return (
<div className="relative">
<button
className={cn("flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100", { "bg-custom-background-100": isOpen })}
onClick={() => {
setIsOpen(!isOpen);
}}
>
<p className="text-base"></p>
<p
className={cn("underline underline-offset-4", {
"text-custom-text-100": editor.isActive("link"),
})}
>
Link
</p>
</button>
{isOpen && (
<form
onSubmit={(e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const input = form.elements[0] as HTMLInputElement;
editor.chain().focus().setLink({ href: input.value }).run();
setIsOpen(false);
}}
className="fixed top-full z-[99999] mt-1 flex w-60 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 dow-xl animate-in fade-in slide-in-from-top-1"
>
<input
ref={inputRef}
type="url"
placeholder="Paste a link"
className="flex-1 bg-custom-background-100 border border-custom-primary-300 p-1 text-sm outline-none placeholder:text-custom-text-400"
defaultValue={editor.getAttributes("link").href || ""}
/>
{editor.getAttributes("link").href ? (
<button
className="flex items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
onClick={() => {
editor.chain().focus().unsetLink().run();
setIsOpen(false);
}}
>
<Trash className="h-4 w-4" />
</button>
) : (
<button className="flex items-center rounded-sm p-1 text-custom-text-300 transition-all hover:bg-custom-background-90">
<Check className="h-4 w-4" />
</button>
)}
</form>
)}
</div>
);
};

View File

@ -0,0 +1,125 @@
import { Editor } from "@tiptap/core";
import {
Check,
ChevronDown,
Heading1,
Heading2,
Heading3,
TextQuote,
ListOrdered,
TextIcon,
Code,
CheckSquare,
} from "lucide-react";
import { Dispatch, FC, SetStateAction } from "react";
import { BubbleMenuItem } from "../bubble-menu";
import { cn } from "../utils";
interface NodeSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
const items: BubbleMenuItem[] = [
{
name: "Text",
icon: TextIcon,
command: () => editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
isActive: () =>
editor.isActive("paragraph") &&
!editor.isActive("bulletList") &&
!editor.isActive("orderedList"),
},
{
name: "H1",
icon: Heading1,
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => editor.isActive("heading", { level: 1 }),
},
{
name: "H2",
icon: Heading2,
command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => editor.isActive("heading", { level: 2 }),
},
{
name: "H3",
icon: Heading3,
command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: () => editor.isActive("heading", { level: 3 }),
},
{
name: "To-do List",
icon: CheckSquare,
command: () => editor.chain().focus().toggleTaskList().run(),
isActive: () => editor.isActive("taskItem"),
},
{
name: "Bullet List",
icon: ListOrdered,
command: () => editor.chain().focus().toggleBulletList().run(),
isActive: () => editor.isActive("bulletList"),
},
{
name: "Numbered List",
icon: ListOrdered,
command: () => editor.chain().focus().toggleOrderedList().run(),
isActive: () => editor.isActive("orderedList"),
},
{
name: "Quote",
icon: TextQuote,
command: () =>
editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
isActive: () => editor.isActive("blockquote"),
},
{
name: "Code",
icon: Code,
command: () => editor.chain().focus().toggleCodeBlock().run(),
isActive: () => editor.isActive("codeBlock"),
},
];
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
name: "Multiple",
};
return (
<div className="relative h-full">
<button
className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5"
onClick={() => setIsOpen(!isOpen)}
>
<span>{activeItem?.name}</span>
<ChevronDown className="h-4 w-4" />
</button>
{isOpen && (
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 p-1 shadow-xl animate-in fade-in slide-in-from-top-1">
{items.map((item, index) => (
<button
key={index}
onClick={() => {
item.command();
setIsOpen(false);
}}
className={cn("flex items-center justify-between rounded-sm px-2 py-1 text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100", { "bg-custom-primary-100/5 text-custom-text-100": activeItem.name === item.name })}
>
<div className="flex items-center space-x-2">
<div className="rounded-sm border border-custom-border-300 p-1">
<item.icon className="h-3 w-3" />
</div>
<span>{item.name}</span>
</div>
{activeItem.name === item.name && <Check className="h-4 w-4" />}
</button>
))}
</section>
)}
</div>
);
};

View File

@ -0,0 +1,142 @@
import StarterKit from "@tiptap/starter-kit";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import TiptapLink from "@tiptap/extension-link";
import TiptapImage from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import TiptapUnderline from "@tiptap/extension-underline";
import TextStyle from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import { Markdown } from "tiptap-markdown";
import Highlight from "@tiptap/extension-highlight";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { lowlight } from "lowlight/lib/core";
import SlashCommand from "../slash-command";
import { InputRule } from "@tiptap/core";
import ts from "highlight.js/lib/languages/typescript";
import "highlight.js/styles/github-dark.css";
import UploadImagesPlugin from "../plugins/upload-image";
import UniqueID from "@tiptap-pro/extension-unique-id";
lowlight.registerLanguage("ts", ts);
const CustomImage = TiptapImage.extend({
addProseMirrorPlugins() {
return [UploadImagesPlugin()];
},
});
export const TiptapExtensions = [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
},
},
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
},
},
blockquote: {
HTMLAttributes: {
class: "border-l-4 border-custom-border-300",
},
},
code: {
HTMLAttributes: {
class:
"rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
},
codeBlock: false,
horizontalRule: false,
dropcursor: {
color: "#DBEAFE",
width: 2,
},
gapcursor: false,
}),
CodeBlockLowlight.configure({
lowlight,
}),
HorizontalRule.extend({
addInputRules() {
return [
new InputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
handler: ({ state, range, commands }) => {
commands.splitBlock();
const attributes = {};
const { tr } = state;
const start = range.from;
const end = range.to;
// @ts-ignore
tr.replaceWith(start - 1, end, this.type.create(attributes));
},
}),
];
},
}).configure({
HTMLAttributes: {
class: "mb-6 border-t border-custom-border-300",
},
}),
TiptapLink.configure({
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
CustomImage.configure({
allowBase64: true,
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
return "Press '/' for commands...";
},
includeChildren: true,
}),
UniqueID.configure({
types: ["image"],
}),
SlashCommand,
TiptapUnderline,
TextStyle,
Color,
Highlight.configure({
multicolor: true,
}),
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
}),
];

View File

@ -0,0 +1,138 @@
// @ts-nocheck
import { useEditor, EditorContent, Editor } from "@tiptap/react";
import { useDebouncedCallback } from "use-debounce";
import { EditorBubbleMenu } from "./bubble-menu";
import { TiptapExtensions } from "./extensions";
import { TiptapEditorProps } from "./props";
import { Node } from "@tiptap/pm/model";
import { Editor as CoreEditor } from "@tiptap/core";
import { useCallback, useImperativeHandle, useRef } from "react";
import { EditorState } from "@tiptap/pm/state";
import fileService from "services/file.service";
export interface ITiptapRichTextEditor {
value: string;
noBorder?: boolean;
borderOnFocus?: boolean;
customClassName?: string;
editorContentCustomClassNames?: string;
onChange?: (json: any, html: string) => void;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
editable?: boolean;
forwardedRef?: any;
debouncedUpdatesEnabled?: boolean;
}
const Tiptap = (props: ITiptapRichTextEditor) => {
const {
onChange,
debouncedUpdatesEnabled,
forwardedRef,
editable,
setIsSubmitting,
editorContentCustomClassNames,
value,
noBorder,
borderOnFocus,
customClassName,
} = props;
const editor = useEditor({
editable: editable ?? true,
editorProps: TiptapEditorProps,
extensions: TiptapExtensions,
content: value,
onUpdate: async ({ editor }) => {
// for instant feedback loop
setIsSubmitting?.("submitting");
checkForNodeDeletions(editor);
if (debouncedUpdatesEnabled) {
debouncedUpdates({ onChange, editor });
} else {
onChange?.(editor.getJSON(), editor.getHTML());
}
},
});
const editorRef: React.MutableRefObject<Editor | null> = useRef(null);
useImperativeHandle(forwardedRef, () => ({
clearEditor: () => {
editorRef.current?.commands.clearContent();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content);
},
}));
const previousState = useRef<EditorState>();
const onNodeDeleted = useCallback(async (node: Node) => {
if (node.type.name === "image") {
const assetUrlWithWorkspaceId = new URL(node.attrs.src).pathname.substring(1);
const resStatus = await fileService.deleteImage(assetUrlWithWorkspaceId);
if (resStatus === 204) {
console.log("file deleted successfully");
}
}
}, []);
const checkForNodeDeletions = useCallback(
(editor: CoreEditor) => {
const prevNodesById: Record<string, Node> = {};
previousState.current?.doc.forEach((node) => {
if (node.attrs.id) {
prevNodesById[node.attrs.id] = node;
}
});
const nodesById: Record<string, Node> = {};
editor.state?.doc.forEach((node) => {
if (node.attrs.id) {
nodesById[node.attrs.id] = node;
}
});
previousState.current = editor.state;
for (const [id, node] of Object.entries(prevNodesById)) {
if (nodesById[id] === undefined) {
onNodeDeleted(node);
}
}
},
[onNodeDeleted]
);
const debouncedUpdates = useDebouncedCallback(async ({ onChange, editor }) => {
setTimeout(async () => {
if (onChange) {
onChange(editor.getJSON(), editor.getHTML());
}
}, 500);
}, 1000);
const editorClassNames = `relative w-full max-w-screen-lg sm:rounded-lg sm:shadow-lg mt-2 p-3 relative focus:outline-none rounded-md
${noBorder ? '' : 'border border-custom-border-200'
} ${borderOnFocus ? 'focus:border border-custom-border-300' : 'focus:border-0'
} ${customClassName}`;
if (!editor) return null;
editorRef.current = editor;
return (
<div
onClick={() => {
editor?.chain().focus().run();
}}
className={`tiptap-editor-container ${editorClassNames}`}
>
{editor && <EditorBubbleMenu editor={editor} />}
<div className={`${editorContentCustomClassNames}`}>
<EditorContent editor={editor} />
</div>
</div>
);
};
export default Tiptap;

View File

@ -0,0 +1,120 @@
// @ts-nocheck
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
import fileService from "services/file.service";
const uploadKey = new PluginKey("upload-image");
const UploadImagesPlugin = () =>
new Plugin({
key: uploadKey,
state: {
init() {
return DecorationSet.empty;
},
apply(tr, set) {
set = set.map(tr.mapping, tr.doc);
// See if the transaction adds or removes any placeholders
const action = tr.getMeta(uploadKey);
if (action && action.add) {
const { id, pos, src } = action.add;
const placeholder = document.createElement("div");
placeholder.setAttribute("class", "img-placeholder");
const image = document.createElement("img");
image.setAttribute(
"class",
"opacity-10 rounded-lg border border-custom-border-300",
);
image.src = src;
placeholder.appendChild(image);
const deco = Decoration.widget(pos + 1, placeholder, {
id,
});
set = set.add(tr.doc, [deco]);
} else if (action && action.remove) {
set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id));
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
});
export default UploadImagesPlugin;
function findPlaceholder(state: EditorState, id: {}) {
const decos = uploadKey.getState(state);
const found = decos.find(
undefined,
undefined,
(spec: { id: number | undefined }) => spec.id == id
);
return found.length ? found[0].from : null;
}
export async function startImageUpload(file: File, view: EditorView, pos: number) {
if (!file.type.includes("image/")) {
return;
} else if (file.size / 1024 / 1024 > 20) {
return;
}
const id = {};
const tr = view.state.tr;
if (!tr.selection.empty) tr.deleteSelection();
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
tr.setMeta(uploadKey, {
add: {
id,
pos,
src: reader.result,
},
});
view.dispatch(tr);
};
const src = await UploadImageHandler(file);
const { schema } = view.state;
pos = findPlaceholder(view.state, id);
if (pos == null) return;
const imageSrc = typeof src === "object" ? reader.result : src;
const node = schema.nodes.image.create({ src: imageSrc });
const transaction = view.state.tr
.replaceWith(pos, pos, node)
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
}
const UploadImageHandler = (file: File): Promise<string> => {
try {
const formData = new FormData();
formData.append("asset", file);
formData.append("attributes", JSON.stringify({}));
return new Promise(async (resolve, reject) => {
const imageUrl = await fileService
.uploadFile("plane", formData)
.then((response) => response.asset);
const image = new Image();
image.src = imageUrl;
image.onload = () => {
resolve(imageUrl);
};
});
} catch (error) {
console.log(error);
return Promise.reject(error);
}
};

View File

@ -0,0 +1,56 @@
import { EditorProps } from "@tiptap/pm/view";
import { startImageUpload } from "./plugins/upload-image";
export const TiptapEditorProps: EditorProps = {
attributes: {
class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`,
},
handleDOMEvents: {
keydown: (_view, event) => {
// prevent default event listeners from firing when slash command is active
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
const slashCommand = document.querySelector("#slash-command");
if (slashCommand) {
return true;
}
}
},
},
handlePaste: (view, event) => {
if (
event.clipboardData &&
event.clipboardData.files &&
event.clipboardData.files[0]
) {
event.preventDefault();
const file = event.clipboardData.files[0];
const pos = view.state.selection.from;
startImageUpload(file, view, pos);
return true;
}
return false;
},
handleDrop: (view, event, _slice, moved) => {
if (
!moved &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files[0]
) {
event.preventDefault();
const file = event.dataTransfer.files[0];
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
// here we deduct 1 from the pos or else the image will create an extra node
if (coordinates) {
startImageUpload(file, view, coordinates.pos - 1);
}
return true;
}
return false;
},
};

View File

@ -0,0 +1,337 @@
import React, { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react";
import { Editor, Range, Extension } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion";
import { ReactRenderer } from "@tiptap/react";
import tippy from "tippy.js";
import {
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Text,
TextQuote,
Code,
MinusSquare,
CheckSquare,
ImageIcon,
} from "lucide-react";
import { startImageUpload } from "../plugins/upload-image";
import { cn } from "../utils";
interface CommandItemProps {
title: string;
description: string;
icon: ReactNode;
}
interface CommandProps {
editor: Editor;
range: Range;
}
const Command = Extension.create({
name: "slash-command",
addOptions() {
return {
suggestion: {
char: "/",
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
props.command({ editor, range });
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
const getSuggestionItems = ({ query }: { query: string }) =>
[
{
title: "Text",
description: "Just start typing with plain text.",
searchTerms: ["p", "paragraph"],
icon: <Text size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run();
},
},
{
title: "Heading 1",
description: "Big section heading.",
searchTerms: ["title", "big", "large"],
icon: <Heading1 size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
},
},
{
title: "Heading 2",
description: "Medium section heading.",
searchTerms: ["subtitle", "medium"],
icon: <Heading2 size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
},
},
{
title: "Heading 3",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading3 size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
},
},
{
title: "To-do List",
description: "Track tasks with a to-do list.",
searchTerms: ["todo", "task", "list", "check", "checkbox"],
icon: <CheckSquare size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
},
{
title: "Bullet List",
description: "Create a simple bullet list.",
searchTerms: ["unordered", "point"],
icon: <List size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
},
{
title: "Divider",
description: "Visually divide blocks",
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
icon: <MinusSquare size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
{
title: "Numbered List",
description: "Create a list with numbering.",
searchTerms: ["ordered"],
icon: <ListOrdered size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
},
},
{
title: "Quote",
description: "Capture a quote.",
searchTerms: ["blockquote"],
icon: <TextQuote size={18} />,
command: ({ editor, range }: CommandProps) =>
editor
.chain()
.focus()
.deleteRange(range)
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run(),
},
{
title: "Code",
description: "Capture a code snippet.",
searchTerms: ["codeblock"],
icon: <Code size={18} />,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: "Image",
description: "Upload an image from your computer.",
searchTerms: ["photo", "picture", "media"],
icon: <ImageIcon size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).run();
// upload image
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];
const pos = editor.view.state.selection.from;
startImageUpload(file, editor.view, pos);
}
};
input.click();
},
},
].filter((item) => {
if (typeof query === "string" && query.length > 0) {
const search = query.toLowerCase();
return (
item.title.toLowerCase().includes(search) ||
item.description.toLowerCase().includes(search) ||
(item.searchTerms && item.searchTerms.some((term: string) => term.includes(search)))
);
}
return true;
});
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
const containerHeight = container.offsetHeight;
const itemHeight = item ? item.offsetHeight : 0;
const top = item.offsetTop;
const bottom = top + itemHeight;
if (top < container.scrollTop) {
container.scrollTop -= container.scrollTop - top + 5;
} else if (bottom > containerHeight + container.scrollTop) {
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
}
};
const CommandList = ({
items,
command,
}: {
items: CommandItemProps[];
command: any;
editor: any;
range: any;
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = useCallback(
(index: number) => {
const item = items[index];
if (item) {
command(item);
}
},
[command, items]
);
useEffect(() => {
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
const onKeyDown = (e: KeyboardEvent) => {
if (navigationKeys.includes(e.key)) {
e.preventDefault();
if (e.key === "ArrowUp") {
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
return true;
}
if (e.key === "ArrowDown") {
setSelectedIndex((selectedIndex + 1) % items.length);
return true;
}
if (e.key === "Enter") {
selectItem(selectedIndex);
return true;
}
return false;
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [items, selectedIndex, setSelectedIndex, selectItem]);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
const commandListContainer = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const container = commandListContainer?.current;
const item = container?.children[selectedIndex] as HTMLElement;
if (item && container) updateScrollView(container, item);
}, [selectedIndex]);
return items.length > 0 ? (
<div
id="slash-command"
ref={commandListContainer}
className="z-50 fixed h-auto max-h-[330px] w-72 overflow-y-auto rounded-md border border-custom-border-300 bg-custom-background-100 px-1 py-2 shadow-md transition-all"
>
{items.map((item: CommandItemProps, index: number) => (
<button
className={cn(`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100`, { "bg-custom-primary-100/5 text-custom-text-100": index === selectedIndex })}
key={index}
onClick={() => selectItem(index)}
>
<div>
<p className="font-medium">{item.title}</p>
<p className="text-xs text-custom-text-300">{item.description}</p>
</div>
</button>
))}
</div>
) : null;
};
const renderItems = () => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
const container = document.querySelector("#tiptap-container") as HTMLElement;
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
component = new ReactRenderer(CommandList, {
props,
editor: props.editor,
});
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => container,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
component?.updateProps(props);
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
// @ts-ignore
return component?.ref?.onKeyDown(props);
},
onExit: () => {
popup?.[0].destroy();
component?.destroy();
},
};
};
const SlashCommand = Command.configure({
suggestion: {
items: getSuggestionItems,
render: renderItems,
},
});
export default SlashCommand;

View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -49,8 +49,6 @@ export const WorkspaceHelpSection: React.FC<WorkspaceHelpSectionProps> = ({ setS
const helpOptionsRef = useRef<HTMLDivElement | null>(null); const helpOptionsRef = useRef<HTMLDivElement | null>(null);
const { collapsed: sidebarCollapse, toggleCollapsed } = useTheme();
useOutsideClickDetector(helpOptionsRef, () => setIsNeedHelpOpen(false)); useOutsideClickDetector(helpOptionsRef, () => setIsNeedHelpOpen(false));
return ( return (

View File

@ -122,7 +122,7 @@ export const InboxViewContextProvider: React.FC<{ children: React.ReactNode }> =
}, },
}; };
mutateInboxDetails((prevData) => { mutateInboxDetails((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
return { return {
@ -156,7 +156,7 @@ export const InboxViewContextProvider: React.FC<{ children: React.ReactNode }> =
filters: { ...initialState.filters }, filters: { ...initialState.filters },
}; };
mutateInboxDetails((prevData) => { mutateInboxDetails((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
return { return {

View File

@ -401,7 +401,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
mutateMyViewProps((prevData) => { mutateMyViewProps((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
return { return {
@ -432,7 +432,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
mutateMyViewProps((prevData) => { mutateMyViewProps((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
return { return {
@ -463,7 +463,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
mutateMyViewProps((prevData) => { mutateMyViewProps((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
return { return {
@ -494,7 +494,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
mutateMyViewProps((prevData) => { mutateMyViewProps((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
return { return {
@ -525,7 +525,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
mutateMyViewProps((prevData) => { mutateMyViewProps((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
return { return {
@ -647,7 +647,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
user user
); );
} else { } else {
mutateMyViewProps((prevData) => { mutateMyViewProps((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
return { return {

View File

@ -120,7 +120,7 @@ const UserNotificationContextProvider: React.FC<{
const handleReadMutation = (action: "read" | "unread") => { const handleReadMutation = (action: "read" | "unread") => {
const notificationCountNumber = action === "read" ? -1 : 1; const notificationCountNumber = action === "read" ? -1 : 1;
mutateNotificationCount((prev) => { mutateNotificationCount((prev: any) => {
if (!prev) return prev; if (!prev) return prev;
const notificationType: keyof NotificationCount = const notificationType: keyof NotificationCount =
@ -143,8 +143,8 @@ const UserNotificationContextProvider: React.FC<{
notifications?.find((notification) => notification.id === notificationId)?.read_at !== null; notifications?.find((notification) => notification.id === notificationId)?.read_at !== null;
notificationsMutate( notificationsMutate(
(previousNotifications) => (previousNotifications: any) =>
previousNotifications?.map((notification) => previousNotifications?.map((notification: any) =>
notification.id === notificationId notification.id === notificationId
? { ...notification, read_at: isRead ? null : new Date() } ? { ...notification, read_at: isRead ? null : new Date() }
: notification : notification
@ -199,7 +199,8 @@ const UserNotificationContextProvider: React.FC<{
}); });
} else { } else {
notificationsMutate( notificationsMutate(
(prev) => prev?.filter((prevNotification) => prevNotification.id !== notificationId), (prev: any) =>
prev?.filter((prevNotification: any) => prevNotification.id !== notificationId),
false false
); );
await userNotificationServices await userNotificationServices
@ -222,8 +223,8 @@ const UserNotificationContextProvider: React.FC<{
null; null;
notificationsMutate( notificationsMutate(
(previousNotifications) => (previousNotifications: any) =>
previousNotifications?.map((notification) => previousNotifications?.map((notification: any) =>
notification.id === notificationId notification.id === notificationId
? { ...notification, snoozed_till: isSnoozed ? null : new Date(dateTime!) } ? { ...notification, snoozed_till: isSnoozed ? null : new Date(dateTime!) }
: notification : notification

View File

@ -56,7 +56,7 @@ const useCommentReaction = (
user.user user.user
); );
mutateCommentReactions((prev) => [...(prev || []), data]); mutateCommentReactions((prev: any) => [...(prev || []), data]);
}; };
/** /**
@ -69,8 +69,8 @@ const useCommentReaction = (
if (!workspaceSlug || !projectId || !commendId) return; if (!workspaceSlug || !projectId || !commendId) return;
mutateCommentReactions( mutateCommentReactions(
(prevData) => (prevData: any) =>
prevData?.filter((r) => r.actor !== user?.user?.id || r.reaction !== reaction) || [], prevData?.filter((r: any) => r.actor !== user?.user?.id || r.reaction !== reaction) || [],
false false
); );

View File

@ -64,7 +64,7 @@ const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => {
if (issueProperties && projectId) { if (issueProperties && projectId) {
mutateIssueProperties( mutateIssueProperties(
(prev) => (prev: any) =>
({ ({
...prev, ...prev,
properties: { ...prev?.properties, [key]: !prev?.properties?.[key] }, properties: { ...prev?.properties, [key]: !prev?.properties?.[key] },

View File

@ -56,7 +56,7 @@ const useIssueReaction = (
user.user user.user
); );
mutateReaction((prev) => [...(prev || []), data]); mutateReaction((prev: any) => [...(prev || []), data]);
}; };
/** /**
@ -69,8 +69,8 @@ const useIssueReaction = (
if (!workspaceSlug || !projectId || !issueId) return; if (!workspaceSlug || !projectId || !issueId) return;
mutateReaction( mutateReaction(
(prevData) => (prevData: any) =>
prevData?.filter((r) => r.actor !== user?.user?.id || r.reaction !== reaction) || [], prevData?.filter((r: any) => r.actor !== user?.user?.id || r.reaction !== reaction) || [],
false false
); );

View File

@ -75,7 +75,7 @@ const useUserNotification = () => {
const handleReadMutation = (action: "read" | "unread") => { const handleReadMutation = (action: "read" | "unread") => {
const notificationCountNumber = action === "read" ? -1 : 1; const notificationCountNumber = action === "read" ? -1 : 1;
mutateNotificationCount((prev) => { mutateNotificationCount((prev: any) => {
if (!prev) return prev; if (!prev) return prev;
const notificationType: keyof NotificationCount = const notificationType: keyof NotificationCount =
@ -93,18 +93,18 @@ const useUserNotification = () => {
}; };
const mutateNotification = (notificationId: string, value: Object) => { const mutateNotification = (notificationId: string, value: Object) => {
notificationMutate((previousNotifications) => { notificationMutate((previousNotifications: any) => {
if (!previousNotifications) return previousNotifications; if (!previousNotifications) return previousNotifications;
const notificationIndex = Math.floor( const notificationIndex = Math.floor(
previousNotifications previousNotifications
.map((d) => d.results) .map((d: any) => d.results)
.flat() .flat()
.findIndex((notification) => notification.id === notificationId) / PER_PAGE .findIndex((notification: any) => notification.id === notificationId) / PER_PAGE
); );
let notificationIndexInPage = previousNotifications[notificationIndex].results.findIndex( let notificationIndexInPage = previousNotifications[notificationIndex].results.findIndex(
(notification) => notification.id === notificationId (notification: any) => notification.id === notificationId
); );
if (notificationIndexInPage === -1) return previousNotifications; if (notificationIndexInPage === -1) return previousNotifications;
@ -126,18 +126,18 @@ const useUserNotification = () => {
}; };
const removeNotification = (notificationId: string) => { const removeNotification = (notificationId: string) => {
notificationMutate((previousNotifications) => { notificationMutate((previousNotifications: any) => {
if (!previousNotifications) return previousNotifications; if (!previousNotifications) return previousNotifications;
const notificationIndex = Math.floor( const notificationIndex = Math.floor(
previousNotifications previousNotifications
.map((d) => d.results) .map((d: any) => d.results)
.flat() .flat()
.findIndex((notification) => notification.id === notificationId) / PER_PAGE .findIndex((notification: any) => notification.id === notificationId) / PER_PAGE
); );
let notificationIndexInPage = previousNotifications[notificationIndex].results.findIndex( let notificationIndexInPage = previousNotifications[notificationIndex].results.findIndex(
(notification) => notification.id === notificationId (notification: any) => notification.id === notificationId
); );
if (notificationIndexInPage === -1) return previousNotifications; if (notificationIndexInPage === -1) return previousNotifications;

View File

@ -11,6 +11,8 @@
"dependencies": { "dependencies": {
"@blueprintjs/core": "^4.16.3", "@blueprintjs/core": "^4.16.3",
"@blueprintjs/popover2": "^1.13.3", "@blueprintjs/popover2": "^1.13.3",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@headlessui/react": "^1.7.3", "@headlessui/react": "^1.7.3",
"@heroicons/react": "^2.0.12", "@heroicons/react": "^2.0.12",
"@jitsu/nextjs": "^3.1.5", "@jitsu/nextjs": "^3.1.5",
@ -23,21 +25,37 @@
"@nivo/line": "0.80.0", "@nivo/line": "0.80.0",
"@nivo/pie": "0.80.0", "@nivo/pie": "0.80.0",
"@nivo/scatterplot": "0.80.0", "@nivo/scatterplot": "0.80.0",
"@remirror/core": "^2.0.11",
"@remirror/extension-react-tables": "^2.2.11",
"@remirror/pm": "^2.0.3",
"@remirror/react": "^2.0.24",
"@sentry/nextjs": "^7.36.0", "@sentry/nextjs": "^7.36.0",
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.9",
"@tiptap-pro/extension-unique-id": "^2.1.0",
"@tiptap/extension-code-block-lowlight": "^2.0.4",
"@tiptap/extension-color": "^2.0.4",
"@tiptap/extension-highlight": "^2.0.4",
"@tiptap/extension-horizontal-rule": "^2.0.4",
"@tiptap/extension-image": "^2.0.4",
"@tiptap/extension-link": "^2.0.4",
"@tiptap/extension-placeholder": "^2.0.4",
"@tiptap/extension-task-item": "^2.0.4",
"@tiptap/extension-task-list": "^2.0.4",
"@tiptap/extension-text-style": "^2.0.4",
"@tiptap/extension-underline": "^2.0.4",
"@tiptap/pm": "^2.0.4",
"@tiptap/react": "^2.0.4",
"@tiptap/starter-kit": "^2.0.4",
"@tiptap/suggestion": "^2.0.4",
"@types/lodash.debounce": "^4.0.7", "@types/lodash.debounce": "^4.0.7",
"@types/react-datepicker": "^4.8.0", "@types/react-datepicker": "^4.8.0",
"axios": "^1.1.3", "axios": "^1.1.3",
"clsx": "^2.0.0",
"cmdk": "^0.2.0", "cmdk": "^0.2.0",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"highlight.js": "^11.8.0",
"js-cookie": "^3.0.1", "js-cookie": "^3.0.1",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"mobx": "^6.10.0", "mobx": "^6.10.0",
"mobx-react-lite": "^4.0.3", "mobx-react-lite": "^4.0.3",
"lowlight": "^2.9.0",
"lucide-react": "^0.263.1",
"next": "12.3.2", "next": "12.3.2",
"next-pwa": "^5.6.0", "next-pwa": "^5.6.0",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
@ -50,10 +68,14 @@
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-hook-form": "^7.38.0", "react-hook-form": "^7.38.0",
"react-markdown": "^8.0.7", "react-markdown": "^8.0.7",
"remirror": "^2.0.23",
"sharp": "^0.32.1", "sharp": "^0.32.1",
"sonner": "^0.6.2",
"swr": "^2.1.3", "swr": "^2.1.3",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.6",
"tiptap-markdown": "^0.8.2",
"tlds": "^1.238.0", "tlds": "^1.238.0",
"use-debounce": "^9.0.4",
"uuid": "^9.0.0" "uuid": "^9.0.0"
}, },
"devDependencies": { "devDependencies": {
@ -76,5 +98,8 @@
"tailwindcss": "^3.1.6", "tailwindcss": "^3.1.6",
"tsconfig": "*", "tsconfig": "*",
"typescript": "4.7.4" "typescript": "4.7.4"
},
"resolutions": {
"prosemirror-model": "1.18.1"
} }
} }

View File

@ -10,7 +10,6 @@ import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
import SettingsNavbar from "layouts/settings-navbar"; import SettingsNavbar from "layouts/settings-navbar";
// components // components
import { ActivityIcon, ActivityMessage } from "components/core"; import { ActivityIcon, ActivityMessage } from "components/core";
import RemirrorRichTextEditor from "components/rich-text-editor";
// icons // icons
import { ArrowTopRightOnSquareIcon, ChatBubbleLeftEllipsisIcon } from "@heroicons/react/24/outline"; import { ArrowTopRightOnSquareIcon, ChatBubbleLeftEllipsisIcon } from "@heroicons/react/24/outline";
// ui // ui
@ -27,6 +26,17 @@ const ProfileActivity = () => {
const { data: userActivity } = useSWR(USER_ACTIVITY, () => userService.getUserActivity()); const { data: userActivity } = useSWR(USER_ACTIVITY, () => userService.getUserActivity());
if (!userActivity) {
return (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
);
}
return ( return (
<WorkspaceAuthorizationLayout <WorkspaceAuthorizationLayout
breadcrumbs={ breadcrumbs={
@ -45,183 +55,173 @@ const ProfileActivity = () => {
</div> </div>
<SettingsNavbar profilePage /> <SettingsNavbar profilePage />
</div> </div>
{userActivity ? ( {userActivity && userActivity.results.length > 0 && (
userActivity.results.length > 0 ? ( <div>
<div> <ul role="list" className="-mb-4">
<ul role="list" className="-mb-4"> {userActivity.results.map((activityItem: any, activityIdx: number) => {
{userActivity.results.map((activityItem: any, activityIdx: number) => { if (activityItem.field === "comment") {
if (activityItem.field === "comment") { return (
return ( <div key={activityItem.id} className="mt-2">
<div key={activityItem.id} className="mt-2"> <div className="relative flex items-start space-x-3">
<div className="relative flex items-start space-x-3"> <div className="relative px-1">
<div className="relative px-1"> {activityItem.field ? (
{activityItem.field ? ( activityItem.new_value === "restore" && (
activityItem.new_value === "restore" && ( <Icon iconName="history" className="text-sm text-custom-text-200" />
<Icon iconName="history" className="text-sm text-custom-text-200" /> )
) ) : activityItem.actor_detail.avatar &&
) : activityItem.actor_detail.avatar && activityItem.actor_detail.avatar !== "" ? (
activityItem.actor_detail.avatar !== "" ? ( <img
<img src={activityItem.actor_detail.avatar}
src={activityItem.actor_detail.avatar} alt={activityItem.actor_detail.display_name}
alt={activityItem.actor_detail.display_name} height={30}
height={30} width={30}
width={30} className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white"
className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white" />
/> ) : (
) : ( <div
<div className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`}
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`} >
> {activityItem.actor_detail.display_name?.charAt(0)}
{activityItem.actor_detail.display_name?.charAt(0)} </div>
</div> )}
)}
<span className="ring-6 flex h-7 w-7 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white"> <span className="ring-6 flex h-7 w-7 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white">
<ChatBubbleLeftEllipsisIcon <ChatBubbleLeftEllipsisIcon
className="h-3.5 w-3.5 text-custom-text-200" className="h-3.5 w-3.5 text-custom-text-200"
aria-hidden="true" aria-hidden="true"
/> />
</span> </span>
</div>
<div className="min-w-0 flex-1">
<div>
<div className="text-xs">
{activityItem.actor_detail.is_bot
? activityItem.actor_detail.first_name + " Bot"
: activityItem.actor_detail.display_name}
</div>
<p className="mt-0.5 text-xs text-custom-text-200">
Commented {timeAgo(activityItem.created_at)}
</p>
</div> </div>
<div className="min-w-0 flex-1"> <div className="issue-comments-section p-0">
<div> {/* // TODO: Check these styles */}
<div className="text-xs"> <div
{activityItem.actor_detail.is_bot dangerouslySetInnerHTML={{
? activityItem.actor_detail.first_name + " Bot" __html:
: activityItem.actor_detail.display_name} activityItem?.new_value !== ""
</div>
<p className="mt-0.5 text-xs text-custom-text-200">
Commented {timeAgo(activityItem.created_at)}
</p>
</div>
<div className="issue-comments-section p-0">
<RemirrorRichTextEditor
value={
activityItem.new_value && activityItem.new_value !== ""
? activityItem.new_value ? activityItem.new_value
: activityItem.old_value : activityItem.old_value,
} }}
editable={false} />
noBorder
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
); </div>
} );
}
const message = const message =
activityItem.verb === "created" && activityItem.verb === "created" &&
activityItem.field !== "cycles" && activityItem.field !== "cycles" &&
activityItem.field !== "modules" && activityItem.field !== "modules" &&
activityItem.field !== "attachment" && activityItem.field !== "attachment" &&
activityItem.field !== "link" && activityItem.field !== "link" &&
activityItem.field !== "estimate" ? ( activityItem.field !== "estimate" ? (
<span className="text-custom-text-200"> <span className="text-custom-text-200">
created{" "} created{" "}
<Link <Link
href={`/${workspaceSlug}/projects/${activityItem.project}/issues/${activityItem.issue}`} href={`/${workspaceSlug}/projects/${activityItem.project}/issues/${activityItem.issue}`}
> >
<a className="inline-flex items-center hover:underline"> <a className="inline-flex items-center hover:underline">
this issue. <ArrowTopRightOnSquareIcon className="ml-1 h-3.5 w-3.5" /> this issue. <ArrowTopRightOnSquareIcon className="ml-1 h-3.5 w-3.5" />
</a> </a>
</Link> </Link>
</span> </span>
) : activityItem.field ? ( ) : activityItem.field ? (
<ActivityMessage activity={activityItem} showIssue /> <ActivityMessage activity={activityItem} showIssue />
) : ( ) : (
"created the issue." "created the issue."
); );
if ("field" in activityItem && activityItem.field !== "updated_by") { if ("field" in activityItem && activityItem.field !== "updated_by") {
return ( return (
<li key={activityItem.id}> <li key={activityItem.id}>
<div className="relative pb-1"> <div className="relative pb-1">
{userActivity.results.length > 1 && {userActivity.results.length > 1 &&
activityIdx !== userActivity.results.length - 1 ? ( activityIdx !== userActivity.results.length - 1 ? (
<span <span
className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-custom-background-80" className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-custom-background-80"
aria-hidden="true" aria-hidden="true"
/> />
) : null} ) : null}
<div className="relative flex items-start space-x-2"> <div className="relative flex items-start space-x-2">
<> <>
<div> <div>
<div className="relative px-1.5"> <div className="relative px-1.5">
<div className="mt-1.5"> <div className="mt-1.5">
<div className="ring-6 flex h-7 w-7 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white"> <div className="ring-6 flex h-7 w-7 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white">
{activityItem.field ? ( {activityItem.field ? (
activityItem.new_value === "restore" ? ( activityItem.new_value === "restore" ? (
<Icon <Icon
iconName="history" iconName="history"
className="text-sm text-custom-text-200" className="text-sm text-custom-text-200"
/>
) : (
<ActivityIcon activity={activityItem} />
)
) : activityItem.actor_detail.avatar &&
activityItem.actor_detail.avatar !== "" ? (
<img
src={activityItem.actor_detail.avatar}
alt={activityItem.actor_detail.display_name}
height={24}
width={24}
className="rounded-full"
/> />
) : ( ) : (
<div <ActivityIcon activity={activityItem} />
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white`} )
> ) : activityItem.actor_detail.avatar &&
{activityItem.actor_detail.display_name?.charAt(0)} activityItem.actor_detail.avatar !== "" ? (
</div> <img
)} src={activityItem.actor_detail.avatar}
</div> alt={activityItem.actor_detail.display_name}
height={24}
width={24}
className="rounded-full"
/>
) : (
<div
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white`}
>
{activityItem.actor_detail.display_name?.charAt(0)}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
<div className="min-w-0 flex-1 py-3"> </div>
<div className="text-xs text-custom-text-200 break-words"> <div className="min-w-0 flex-1 py-3">
{activityItem.field === "archived_at" && <div className="text-xs text-custom-text-200 break-words">
activityItem.new_value !== "restore" ? ( {activityItem.field === "archived_at" &&
<span className="text-gray font-medium">Plane</span> activityItem.new_value !== "restore" ? (
) : activityItem.actor_detail.is_bot ? ( <span className="text-gray font-medium">Plane</span>
<span className="text-gray font-medium"> ) : activityItem.actor_detail.is_bot ? (
{activityItem.actor_detail.first_name} Bot <span className="text-gray font-medium">
</span> {activityItem.actor_detail.first_name} Bot
) : (
<Link
href={`/${workspaceSlug}/profile/${activityItem.actor_detail.id}`}
>
<a className="text-gray font-medium">
{activityItem.actor_detail.display_name}
</a>
</Link>
)}{" "}
{message}{" "}
<span className="whitespace-nowrap">
{timeAgo(activityItem.created_at)}
</span> </span>
</div> ) : (
<Link
href={`/${workspaceSlug}/profile/${activityItem.actor_detail.id}`}
>
<a className="text-gray font-medium">
{activityItem.actor_detail.display_name}
</a>
</Link>
)}{" "}
{message}{" "}
<span className="whitespace-nowrap">
{timeAgo(activityItem.created_at)}
</span>
</div> </div>
</> </div>
</div> </>
</div> </div>
</li> </div>
); </li>
} );
})} }
</ul> })}
</div> </ul>
) : null </div>
) : (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)} )}
</div> </div>
</WorkspaceAuthorizationLayout> </WorkspaceAuthorizationLayout>

View File

@ -77,7 +77,7 @@ const Profile: NextPage = () => {
await userService await userService
.updateUser(payload) .updateUser(payload)
.then((res) => { .then((res) => {
mutateUser((prevData) => { mutateUser((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
return { ...prevData, ...res }; return { ...prevData, ...res };
@ -112,7 +112,7 @@ const Profile: NextPage = () => {
title: "Success!", title: "Success!",
message: "Profile picture removed successfully.", message: "Profile picture removed successfully.",
}); });
mutateUser((prevData) => { mutateUser((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
return { ...prevData, avatar: "" }; return { ...prevData, avatar: "" };
}, false); }, false);

View File

@ -45,6 +45,7 @@ const defaultValues = {
const IssueDetailsPage: NextPage = () => { const IssueDetailsPage: NextPage = () => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query; const { workspaceSlug, projectId, issueId } = router.query;
// console.log(workspaceSlug, "workspaceSlug")
const { user } = useUserAuth(); const { user } = useUserAuth();

View File

@ -629,17 +629,19 @@ const SinglePage: NextPage = () => {
ref={provided.innerRef} ref={provided.innerRef}
{...provided.droppableProps} {...provided.droppableProps}
> >
{pageBlocks.map((block, index) => ( <>
<SinglePageBlock {pageBlocks.map((block, index) => (
key={block.id} <SinglePageBlock
block={block} key={block.id}
projectDetails={projectDetails} block={block}
showBlockDetails={showBlock} projectDetails={projectDetails}
index={index} showBlockDetails={showBlock}
user={user} index={index}
/> user={user}
))} />
{provided.placeholder} ))}
{provided.placeholder}
</>
</div> </div>
)} )}
</StrictModeDroppable> </StrictModeDroppable>

View File

@ -139,7 +139,7 @@ const MembersSettings: NextPage = () => {
selectedRemoveMember selectedRemoveMember
); );
mutateMembers( mutateMembers(
(prevData) => prevData?.filter((item: any) => item.id !== selectedRemoveMember), (prevData: any) => prevData?.filter((item: any) => item.id !== selectedRemoveMember),
false false
); );
} }
@ -150,7 +150,8 @@ const MembersSettings: NextPage = () => {
selectedInviteRemoveMember selectedInviteRemoveMember
); );
mutateInvitations( mutateInvitations(
(prevData) => prevData?.filter((item: any) => item.id !== selectedInviteRemoveMember), (prevData: any) =>
prevData?.filter((item: any) => item.id !== selectedInviteRemoveMember),
false false
); );
} }

View File

@ -139,14 +139,15 @@ const MembersSettings: NextPage = () => {
}); });
}) })
.finally(() => { .finally(() => {
mutateMembers((prevData) => mutateMembers((prevData: any) =>
prevData?.filter((item) => item.id !== selectedRemoveMember) prevData?.filter((item: any) => item.id !== selectedRemoveMember)
); );
}); });
} }
if (selectedInviteRemoveMember) { if (selectedInviteRemoveMember) {
mutateInvitations( mutateInvitations(
(prevData) => prevData?.filter((item) => item.id !== selectedInviteRemoveMember), (prevData: any) =>
prevData?.filter((item: any) => item.id !== selectedInviteRemoveMember),
false false
); );
workspaceService workspaceService
@ -262,8 +263,8 @@ const MembersSettings: NextPage = () => {
if (!workspaceSlug) return; if (!workspaceSlug) return;
mutateMembers( mutateMembers(
(prevData) => (prevData: any) =>
prevData?.map((m) => prevData?.map((m: any) =>
m.id === member.id ? { ...m, role: value } : m m.id === member.id ? { ...m, role: value } : m
), ),
false false

View File

@ -1,5 +1,6 @@
module.exports = { module.exports = {
plugins: { plugins: {
"tailwindcss/nesting": {},
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },

View File

@ -40,6 +40,14 @@ class FileServices extends APIService {
}); });
} }
async deleteImage(assetUrlWithWorkspaceId: string): Promise<any> {
return this.delete(`/api/workspaces/file-assets/${assetUrlWithWorkspaceId}/`)
.then((response) => response?.status)
.catch((error) => {
throw error?.response?.data;
});
}
async deleteFile(workspaceId: string, assetUrl: string): Promise<any> { async deleteFile(workspaceId: string, assetUrl: string): Promise<any> {
const lastIndex = assetUrl.lastIndexOf("/"); const lastIndex = assetUrl.lastIndexOf("/");
const assetId = assetUrl.substring(lastIndex + 1); const assetId = assetUrl.substring(lastIndex + 1);
@ -50,7 +58,6 @@ class FileServices extends APIService {
throw error?.response?.data; throw error?.response?.data;
}); });
} }
async uploadUserFile(file: FormData): Promise<any> { async uploadUserFile(file: FormData): Promise<any> {
return this.mediaUpload(`/api/users/file-assets/`, file) return this.mediaUpload(`/api/users/file-assets/`, file)
.then((response) => response?.data) .then((response) => response?.data)

View File

@ -1,11 +1,96 @@
.empty-node::after { .ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder); content: attr(data-placeholder);
float: left;
color: rgb(var(--color-text-400)); color: rgb(var(--color-text-400));
position: absolute;
pointer-events: none; pointer-events: none;
top: 15px; height: 0;
margin-left: 1px; }
.ProseMirror .is-empty::before {
content: attr(data-placeholder);
float: left;
color: rgb(var(--color-text-400));
pointer-events: none;
height: 0;
}
/* Custom image styles */
.ProseMirror img {
transition: filter 0.1s ease-in-out;
&:hover {
cursor: pointer;
filter: brightness(90%);
}
&.ProseMirror-selectednode {
outline: 3px solid #5abbf7;
filter: brightness(90%);
}
}
/* Custom TODO list checkboxes shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */
ul[data-type="taskList"] li > label {
margin-right: 0.2rem;
user-select: none;
}
@media screen and (max-width: 768px) {
ul[data-type="taskList"] li > label {
margin-right: 0.5rem;
}
}
ul[data-type="taskList"] li > label input[type="checkbox"] {
-webkit-appearance: none;
appearance: none;
background-color: rgb(var(--color-background-100));
margin: 0;
cursor: pointer;
width: 1.2rem;
height: 1.2rem;
position: relative;
border: 2px solid rgb(var(--color-text-100));
margin-right: 0.3rem;
display: grid;
place-content: center;
&:hover {
background-color: rgb(var(--color-background-80));
}
&:active {
background-color: rgb(var(--color-background-90));
}
&::before {
content: "";
width: 0.65em;
height: 0.65em;
transform: scale(0);
transition: 120ms transform ease-in-out;
box-shadow: inset 1em 1em;
transform-origin: center;
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
&:checked::before {
transform: scale(1);
}
}
ul[data-type="taskList"] li[data-checked="true"] > div > p {
color: rgb(var(--color-text-200));
text-decoration: line-through;
text-decoration-thickness: 2px;
}
/* Overwrite tippy-box original max-width */
.tippy-box {
max-width: 400px !important;
} }
.ProseMirror { .ProseMirror {
@ -31,66 +116,13 @@
-moz-appearance: textfield; -moz-appearance: textfield;
} }
.ProseMirror-icon { .fadeIn {
display: inline-block; opacity: 1;
line-height: 0.8; transition: opacity 0.3s ease-in;
vertical-align: -2px;
color: #666;
cursor: pointer;
margin: 0 3px;
padding: 3px 8px;
border-radius: 3px;
border: 1px solid transparent;
transition: background 50ms ease-in-out;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
} }
.ProseMirror-menu-disabled.ProseMirror-icon { .fadeOut {
opacity: 0.3; opacity: 0;
cursor: default; transition: opacity 0.2s ease-out;
pointer-events: none;
} }
.ProseMirror-icon svg {
fill: currentColor;
height: 1em;
}
.ProseMirror-icon span {
vertical-align: text-top;
}
.remirror-editor-wrapper .remirror-editor {
min-height: 150px;
}
.issue-comments-section .remirror-editor-wrapper .remirror-editor,
.page-block-section .remirror-editor-wrapper .remirror-editor {
min-height: 50px;
}
.remirror-section .remirror-editor-wrapper .remirror-editor {
min-height: 0 !important;
}
.remirror-editor-wrapper {
padding-top: 8px;
}
.MuiButtonBase-root {
border: none !important;
border-radius: 0.25rem !important;
padding: 0.25rem !important;
}
.MuiButtonBase-root svg {
fill: rgb(var(--color-text-100)) !important;
}
.MuiButtonBase-root.Mui-selected,
.MuiButtonBase-root:hover {
background-color: rgb(var(--color-background-100)) !important;
}

View File

@ -182,5 +182,8 @@ module.exports = {
custom: ["Inter", "sans-serif"], custom: ["Inter", "sans-serif"],
}, },
}, },
plugins: [require("@tailwindcss/typography")], plugins: [
require("tailwindcss-animate"),
require("@tailwindcss/typography")
],
}; };

2683
yarn.lock

File diff suppressed because it is too large Load Diff