mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
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:
parent
daa8f7d79b
commit
e1ae0d3b56
4
.gitignore
vendored
4
.gitignore
vendored
@ -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
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -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 = () => {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
})
|
@ -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
|
||||||
|
@ -125,7 +125,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
mutateIssues(
|
mutateIssues(
|
||||||
(prevData) =>
|
(prevData: any) =>
|
||||||
handleIssuesMutation(
|
handleIssuesMutation(
|
||||||
formData,
|
formData,
|
||||||
groupTitle ?? "",
|
groupTitle ?? "",
|
||||||
|
@ -108,7 +108,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
mutateIssues(
|
mutateIssues(
|
||||||
(prevData) =>
|
(prevData: any) =>
|
||||||
handleIssuesMutation(
|
handleIssuesMutation(
|
||||||
formData,
|
formData,
|
||||||
groupTitle ?? "",
|
groupTitle ?? "",
|
||||||
|
@ -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
|
||||||
? {
|
? {
|
||||||
|
@ -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 };
|
||||||
|
@ -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 : {}),
|
||||||
}));
|
}));
|
||||||
|
@ -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
|
||||||
|
@ -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(
|
||||||
|
@ -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);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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}
|
||||||
|
@ -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" />
|
||||||
|
@ -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] });
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
? {
|
? {
|
||||||
|
@ -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);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
@ -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: "",
|
||||||
|
@ -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
|
||||||
|
@ -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]];
|
||||||
|
@ -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;
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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;
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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;
|
|
@ -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>
|
|
||||||
);
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
115
apps/app/components/tiptap/bubble-menu/index.tsx
Normal file
115
apps/app/components/tiptap/bubble-menu/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
73
apps/app/components/tiptap/bubble-menu/link-selector.tsx
Normal file
73
apps/app/components/tiptap/bubble-menu/link-selector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
125
apps/app/components/tiptap/bubble-menu/node-selector.tsx
Normal file
125
apps/app/components/tiptap/bubble-menu/node-selector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
142
apps/app/components/tiptap/extensions/index.tsx
Normal file
142
apps/app/components/tiptap/extensions/index.tsx
Normal 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,
|
||||||
|
}),
|
||||||
|
];
|
138
apps/app/components/tiptap/index.tsx
Normal file
138
apps/app/components/tiptap/index.tsx
Normal 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;
|
120
apps/app/components/tiptap/plugins/upload-image.tsx
Normal file
120
apps/app/components/tiptap/plugins/upload-image.tsx
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
56
apps/app/components/tiptap/props.tsx
Normal file
56
apps/app/components/tiptap/props.tsx
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
337
apps/app/components/tiptap/slash-command/index.tsx
Normal file
337
apps/app/components/tiptap/slash-command/index.tsx
Normal 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;
|
6
apps/app/components/tiptap/utils.ts
Normal file
6
apps/app/components/tiptap/utils.ts
Normal 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));
|
||||||
|
}
|
@ -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 (
|
||||||
|
@ -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 {
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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] },
|
||||||
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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);
|
||||||
|
@ -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();
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
|
"tailwindcss/nesting": {},
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
|
@ -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)
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
|
@ -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")
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user