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
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
pnpm-workspace.yaml
|
||||
|
||||
.npmrc
|
||||
|
@ -28,13 +28,13 @@ export const ChangeInterfaceTheme: React.FC<Props> = ({ setIsPaletteOpen }) => {
|
||||
|
||||
setTheme(newTheme);
|
||||
|
||||
mutateUser((prevData) => {
|
||||
mutateUser((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
theme: {
|
||||
...prevData.theme,
|
||||
...prevData?.theme,
|
||||
theme: newTheme,
|
||||
},
|
||||
};
|
||||
|
@ -1,11 +1,8 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// hooks
|
||||
import useTheme from "hooks/use-theme";
|
||||
import useToast from "hooks/use-toast";
|
||||
import useUser from "hooks/use-user";
|
||||
// components
|
||||
@ -26,8 +23,10 @@ import inboxService from "services/inbox.service";
|
||||
import { INBOX_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
// mobx store
|
||||
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 [isPaletteOpen, setIsPaletteOpen] = useState(false);
|
||||
@ -47,13 +46,12 @@ export const CommandPalette: React.FC = () => {
|
||||
const { user } = useUser();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
const { toggleCollapsed } = useTheme();
|
||||
|
||||
const { data: issueDetails } = useSWR(
|
||||
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
|
||||
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
|
||||
);
|
||||
|
||||
@ -78,55 +76,52 @@ export const CommandPalette: React.FC = () => {
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(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 (
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
(e.target as Element).classList?.contains("remirror-editor")
|
||||
(e.target as Element).classList?.contains("ProseMirror")
|
||||
)
|
||||
return;
|
||||
|
||||
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
|
||||
|
||||
if (!key) return;
|
||||
|
||||
const keyPressed = key.toLowerCase();
|
||||
|
||||
const cmdClicked = ctrlKey || metaKey;
|
||||
|
||||
if (cmdClicked) {
|
||||
if (keyPressed === "k") {
|
||||
e.preventDefault();
|
||||
setIsPaletteOpen(true);
|
||||
} else if (keyPressed === "c" && altKey) {
|
||||
e.preventDefault();
|
||||
copyIssueUrlToClipboard();
|
||||
} else if (keyPressed === "b") {
|
||||
e.preventDefault();
|
||||
toggleCollapsed();
|
||||
if (cmdClicked) {
|
||||
if (keyPressed === "k") {
|
||||
e.preventDefault();
|
||||
setIsPaletteOpen(true);
|
||||
} else if (keyPressed === "c" && altKey) {
|
||||
e.preventDefault();
|
||||
copyIssueUrlToClipboard();
|
||||
} else if (keyPressed === "b") {
|
||||
e.preventDefault();
|
||||
store.theme.setSidebarCollapsed(!store?.theme?.sidebarCollapsed);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
} 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(() => {
|
||||
@ -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 dynamic from "next/dynamic";
|
||||
|
||||
// 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 { IIssue, IPageBlock } from "types";
|
||||
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
@ -32,17 +32,11 @@ type FormData = {
|
||||
task: string;
|
||||
};
|
||||
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
import { IRemirrorRichTextEditor } from "components/rich-text-editor";
|
||||
|
||||
const WrappedRemirrorRichTextEditor = forwardRef<IRemirrorRichTextEditor, IRemirrorRichTextEditor>(
|
||||
(props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />
|
||||
const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>(
|
||||
(props, ref) => <Tiptap {...props} forwardedRef={ref} />
|
||||
);
|
||||
|
||||
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
|
||||
TiptapEditor.displayName = "TiptapEditor";
|
||||
|
||||
export const GptAssistantModal: React.FC<Props> = ({
|
||||
isOpen,
|
||||
@ -151,10 +145,10 @@ export const GptAssistantModal: React.FC<Props> = ({
|
||||
}`}
|
||||
>
|
||||
{((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && (
|
||||
<div className="remirror-section text-sm">
|
||||
<div id="tiptap-container" className="text-sm">
|
||||
Content:
|
||||
<WrappedRemirrorRichTextEditor
|
||||
value={htmlContent ?? <p>{content}</p>}
|
||||
<TiptapEditor
|
||||
value={htmlContent ?? `<p>${content}</p>`}
|
||||
customClassName="-m-3"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
@ -166,7 +160,7 @@ export const GptAssistantModal: React.FC<Props> = ({
|
||||
{response !== "" && (
|
||||
<div className="page-block-section text-sm">
|
||||
Response:
|
||||
<RemirrorRichTextEditor
|
||||
<Tiptap
|
||||
value={`<p>${response}</p>`}
|
||||
customClassName="-mx-3 -my-3"
|
||||
noBorder
|
||||
|
@ -125,7 +125,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
);
|
||||
} else {
|
||||
mutateIssues(
|
||||
(prevData) =>
|
||||
(prevData: any) =>
|
||||
handleIssuesMutation(
|
||||
formData,
|
||||
groupTitle ?? "",
|
||||
|
@ -108,7 +108,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
);
|
||||
} else {
|
||||
mutateIssues(
|
||||
(prevData) =>
|
||||
(prevData: any) =>
|
||||
handleIssuesMutation(
|
||||
formData,
|
||||
groupTitle ?? "",
|
||||
|
@ -38,10 +38,10 @@ export const CyclesListGanttChartView: FC<Props> = ({ cycles, mutateCycles }) =>
|
||||
const handleCycleUpdate = (cycle: ICycle, payload: IBlockUpdateData) => {
|
||||
if (!workspaceSlug || !user) return;
|
||||
|
||||
mutateCycles((prevData) => {
|
||||
mutateCycles((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const newList = prevData.map((p) => ({
|
||||
const newList = prevData.map((p: any) => ({
|
||||
...p,
|
||||
...(p.id === cycle.id
|
||||
? {
|
||||
|
@ -47,7 +47,7 @@ export const SingleEstimate: React.FC<Props> = ({
|
||||
estimate: estimate.id,
|
||||
};
|
||||
|
||||
mutateProjectDetails((prevData) => {
|
||||
mutateProjectDetails((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return { ...prevData, estimate: estimate.id };
|
||||
|
@ -15,10 +15,10 @@ export const updateGanttIssue = (
|
||||
) => {
|
||||
if (!issue || !workspaceSlug || !user) return;
|
||||
|
||||
mutate((prevData: IIssue[]) => {
|
||||
mutate((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const newList = prevData.map((p) => ({
|
||||
const newList = prevData.map((p: any) => ({
|
||||
...p,
|
||||
...(p.id === issue.id ? payload : {}),
|
||||
}));
|
||||
|
@ -72,8 +72,8 @@ export const InboxActionHeader = () => {
|
||||
false
|
||||
);
|
||||
mutateInboxIssues(
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((i) =>
|
||||
(prevData: any) =>
|
||||
(prevData ?? []).map((i: any) =>
|
||||
i.bridge_id === inboxIssueId
|
||||
? { ...i, issue_inbox: [{ ...i.issue_inbox[0], ...data }] }
|
||||
: i
|
||||
|
@ -54,7 +54,10 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
|
||||
const handleCommentDelete = async (commentId: string) => {
|
||||
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
|
||||
.deleteIssueComment(
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
@ -12,28 +11,18 @@ import issuesServices from "services/issues.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Loader, SecondaryButton } from "components/ui";
|
||||
import { SecondaryButton } from "components/ui";
|
||||
// types
|
||||
import type { ICurrentUserResponse, IIssueComment } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
|
||||
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<Loader className="mb-5">
|
||||
<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<
|
||||
IRemirrorRichTextEditor,
|
||||
IRemirrorRichTextEditor
|
||||
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />);
|
||||
|
||||
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
|
||||
TiptapEditor.displayName = "TiptapEditor";
|
||||
|
||||
const defaultValues: Partial<IIssueComment> = {
|
||||
comment_json: "",
|
||||
@ -51,6 +40,7 @@ export const AddComment: React.FC<Props> = ({ issueId, user, disabled = false })
|
||||
handleSubmit,
|
||||
control,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { isSubmitting },
|
||||
reset,
|
||||
} = useForm<IIssueComment>({ defaultValues });
|
||||
@ -97,17 +87,26 @@ export const AddComment: React.FC<Props> = ({ issueId, user, disabled = false })
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="issue-comments-section">
|
||||
<div id="tiptap-container" className="issue-comments-section">
|
||||
<Controller
|
||||
name="comment_json"
|
||||
name="comment_html"
|
||||
control={control}
|
||||
render={({ field: { value } }) => (
|
||||
<WrappedRemirrorRichTextEditor
|
||||
value={value}
|
||||
onJSONChange={(jsonValue) => setValue("comment_json", jsonValue)}
|
||||
onHTMLChange={(htmlValue) => setValue("comment_html", htmlValue)}
|
||||
placeholder="Enter your comment..."
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TiptapEditor
|
||||
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 dynamic from "next/dynamic";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// icons
|
||||
@ -15,17 +13,13 @@ import { CommentReaction } from "components/issues";
|
||||
import { timeAgo } from "helpers/date-time.helper";
|
||||
// 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";
|
||||
|
||||
const WrappedRemirrorRichTextEditor = React.forwardRef<
|
||||
IRemirrorRichTextEditor,
|
||||
IRemirrorRichTextEditor
|
||||
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />);
|
||||
|
||||
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
|
||||
TiptapEditor.displayName = "TiptapEditor";
|
||||
|
||||
type Props = {
|
||||
comment: IIssueComment;
|
||||
@ -45,6 +39,7 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
setFocus,
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<IIssueComment>({
|
||||
defaultValues: comment,
|
||||
@ -56,8 +51,8 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
|
||||
|
||||
onSubmit(formData);
|
||||
|
||||
editorRef.current?.setEditorValue(formData.comment_json);
|
||||
showEditorRef.current?.setEditorValue(formData.comment_json);
|
||||
editorRef.current?.setEditorValue(formData.comment_html);
|
||||
showEditorRef.current?.setEditorValue(formData.comment_html);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@ -106,15 +101,18 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
|
||||
className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}
|
||||
onSubmit={handleSubmit(onEnter)}
|
||||
>
|
||||
<WrappedRemirrorRichTextEditor
|
||||
value={comment.comment_html}
|
||||
onBlur={(jsonValue, htmlValue) => {
|
||||
setValue("comment_json", jsonValue);
|
||||
setValue("comment_html", htmlValue);
|
||||
}}
|
||||
placeholder="Enter Your comment..."
|
||||
ref={editorRef}
|
||||
/>
|
||||
<div id="tiptap-container">
|
||||
<TiptapEditor
|
||||
ref={editorRef}
|
||||
value={watch("comment_html")}
|
||||
debouncedUpdatesEnabled={false}
|
||||
customClassName="min-h-[50px] p-3"
|
||||
onChange={(comment_json: Object, comment_html: string) => {
|
||||
setValue("comment_json", comment_json);
|
||||
setValue("comment_html", comment_html);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 self-end">
|
||||
<button
|
||||
type="submit"
|
||||
@ -133,14 +131,12 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
|
||||
</div>
|
||||
</form>
|
||||
<div className={`${isEditing ? "hidden" : ""}`}>
|
||||
<WrappedRemirrorRichTextEditor
|
||||
<TiptapEditor
|
||||
ref={showEditorRef}
|
||||
value={comment.comment_html}
|
||||
editable={false}
|
||||
noBorder
|
||||
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
||||
ref={showEditorRef}
|
||||
/>
|
||||
|
||||
<CommentReaction projectId={comment.project} commentId={comment.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,23 +1,16 @@
|
||||
import { FC, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// hooks
|
||||
import useReloadConfirmations from "hooks/use-reload-confirmation";
|
||||
// components
|
||||
import { Loader, TextArea } from "components/ui";
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<Loader>
|
||||
<Loader.Item height="12rem" width="100%" />
|
||||
</Loader>
|
||||
),
|
||||
});
|
||||
import { TextArea } from "components/ui";
|
||||
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
import Tiptap from "components/tiptap";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
export interface IssueDescriptionFormValues {
|
||||
name: string;
|
||||
@ -40,7 +33,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
handleFormSubmit,
|
||||
isAllowed,
|
||||
}) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
|
||||
const [characterLimit, setCharacterLimit] = useState(false);
|
||||
|
||||
const { setShowAlert } = useReloadConfirmations();
|
||||
@ -63,7 +56,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
|
||||
const handleDescriptionFormSubmit = useCallback(
|
||||
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({
|
||||
name: formData.name ?? "",
|
||||
@ -74,6 +67,14 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
[handleFormSubmit]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting === "submitted") {
|
||||
setTimeout(async () => {
|
||||
setIsSubmitting("saved");
|
||||
}, 2000);
|
||||
}
|
||||
}, [isSubmitting]);
|
||||
|
||||
// reset form values
|
||||
useEffect(() => {
|
||||
if (!issue) return;
|
||||
@ -83,6 +84,12 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
});
|
||||
}, [issue, reset]);
|
||||
|
||||
const debouncedTitleSave = useDebouncedCallback(async () => {
|
||||
setTimeout(async () => {
|
||||
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
|
||||
}, 500);
|
||||
}, 1000);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
@ -92,11 +99,10 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
placeholder="Enter issue name"
|
||||
register={register}
|
||||
onFocus={() => setCharacterLimit(true)}
|
||||
onBlur={() => {
|
||||
onChange={(e) => {
|
||||
setCharacterLimit(false);
|
||||
|
||||
setIsSubmitting(true);
|
||||
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting(false));
|
||||
setIsSubmitting("submitting");
|
||||
debouncedTitleSave();
|
||||
}}
|
||||
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"
|
||||
@ -106,9 +112,8 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
{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">
|
||||
<span
|
||||
className={`${
|
||||
watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : ""
|
||||
}`}
|
||||
className={`${watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : ""
|
||||
}`}
|
||||
>
|
||||
{watch("name").length}
|
||||
</span>
|
||||
@ -117,47 +122,41 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<span>{errors.name ? errors.name.message : null}</span>
|
||||
<div className="relative">
|
||||
<div id="tiptap-container" className="relative">
|
||||
<Controller
|
||||
name="description"
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { value } }) => {
|
||||
render={({ field: { value, onChange } }) => {
|
||||
if (!value && !watch("description_html")) return <></>;
|
||||
|
||||
return (
|
||||
<RemirrorRichTextEditor
|
||||
<Tiptap
|
||||
value={
|
||||
!value ||
|
||||
value === "" ||
|
||||
(typeof value === "object" && Object.keys(value).length === 0)
|
||||
value === "" ||
|
||||
(typeof value === "object" && Object.keys(value).length === 0)
|
||||
? watch("description_html")
|
||||
: value
|
||||
}
|
||||
onJSONChange={(jsonValue) => {
|
||||
setShowAlert(true);
|
||||
setValue("description", jsonValue);
|
||||
debouncedUpdatesEnabled={true}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
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 bottom-1 right-1 text-xs text-custom-text-200 bg-custom-background-100 p-3 z-10">
|
||||
Saving...
|
||||
</div>
|
||||
)}
|
||||
<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'}`}>
|
||||
{isSubmitting === 'submitting' ? 'Saving...' : 'Saved'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React, { FC, useState, useEffect, useRef } from "react";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// react-hook-form
|
||||
@ -36,24 +35,14 @@ import {
|
||||
import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types";
|
||||
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
|
||||
// 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<
|
||||
IRemirrorRichTextEditor,
|
||||
IRemirrorRichTextEditor
|
||||
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />);
|
||||
|
||||
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
|
||||
TiptapEditor.displayName = "TiptapEditor";
|
||||
|
||||
const defaultValues: Partial<IIssue> = {
|
||||
project: "",
|
||||
@ -344,7 +333,7 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
</div>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && (
|
||||
<div className="relative">
|
||||
<div id="tiptap-container" className="relative">
|
||||
<div className="flex justify-end">
|
||||
{issueName && issueName !== "" && (
|
||||
<button
|
||||
@ -374,21 +363,30 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
<Controller
|
||||
name="description"
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { value } }) => (
|
||||
<WrappedRemirrorRichTextEditor
|
||||
value={
|
||||
!value || (typeof value === "object" && Object.keys(value).length === 0)
|
||||
? watch("description_html")
|
||||
: value
|
||||
}
|
||||
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
|
||||
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
|
||||
placeholder="Description"
|
||||
ref={editorRef}
|
||||
/>
|
||||
)}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
if (!value && !watch("description_html")) return <></>;
|
||||
|
||||
return (
|
||||
<TiptapEditor
|
||||
ref={editorRef}
|
||||
debouncedUpdatesEnabled={false}
|
||||
value={
|
||||
!value ||
|
||||
value === "" ||
|
||||
(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
|
||||
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
|
||||
? () =>
|
||||
issuesService.subIssues(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueDetails.parent ?? ""
|
||||
)
|
||||
issuesService.subIssues(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueDetails.parent ?? ""
|
||||
)
|
||||
: null
|
||||
);
|
||||
const siblingIssuesList = siblingIssues?.sub_issues.filter((i) => i.id !== issueDetails.id);
|
||||
@ -97,9 +97,8 @@ export const IssueMainContent: React.FC<Props> = ({
|
||||
<CustomMenu.MenuItem
|
||||
key={issue.id}
|
||||
renderAs="a"
|
||||
href={`/${workspaceSlug}/projects/${projectId as string}/issues/${
|
||||
issue.id
|
||||
}`}
|
||||
href={`/${workspaceSlug}/projects/${projectId as string}/issues/${issue.id
|
||||
}`}
|
||||
className="flex items-center gap-2 py-2"
|
||||
>
|
||||
<LayerDiagonalIcon className="h-4 w-4" />
|
||||
|
@ -85,7 +85,7 @@ export const SidebarLabelSelect: React.FC<Props> = ({
|
||||
.then((res) => {
|
||||
reset(defaultValues);
|
||||
|
||||
issueLabelMutate((prevData) => [...(prevData ?? []), res], false);
|
||||
issueLabelMutate((prevData: any) => [...(prevData ?? []), res], false);
|
||||
|
||||
submitChanges({ labels_list: [...(issueDetails?.labels ?? []), res.id] });
|
||||
|
||||
|
@ -49,8 +49,8 @@ export const LabelsListModal: React.FC<Props> = ({ isOpen, handleClose, parent,
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
mutate(
|
||||
(prevData) =>
|
||||
prevData?.map((l) => {
|
||||
(prevData: any) =>
|
||||
prevData?.map((l: any) => {
|
||||
if (l.id === label.id) return { ...l, parent: parent?.id ?? "" };
|
||||
|
||||
return l;
|
||||
|
@ -42,10 +42,10 @@ export const ModulesListGanttChartView: FC<Props> = ({ modules, mutateModules })
|
||||
const handleModuleUpdate = (module: IModule, payload: IBlockUpdateData) => {
|
||||
if (!workspaceSlug || !user) return;
|
||||
|
||||
mutateModules((prevData) => {
|
||||
mutateModules((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const newList = prevData.map((p) => ({
|
||||
const newList = prevData.map((p: any) => ({
|
||||
...p,
|
||||
...(p.id === module.id
|
||||
? {
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
@ -18,11 +17,12 @@ import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { GptAssistantModal } from "components/core";
|
||||
// ui
|
||||
import { Loader, PrimaryButton, SecondaryButton, TextArea } from "components/ui";
|
||||
import { PrimaryButton, SecondaryButton, TextArea } from "components/ui";
|
||||
// types
|
||||
import { ICurrentUserResponse, IPageBlock } from "types";
|
||||
// fetch-keys
|
||||
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
|
||||
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
|
||||
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
@ -39,22 +39,11 @@ const defaultValues = {
|
||||
description_html: null,
|
||||
};
|
||||
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<Loader className="mx-4 mt-6">
|
||||
<Loader.Item height="100px" 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<
|
||||
IRemirrorRichTextEditor,
|
||||
IRemirrorRichTextEditor
|
||||
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />);
|
||||
|
||||
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
|
||||
TiptapEditor.displayName = "TiptapEditor";
|
||||
|
||||
export const CreateUpdateBlockInline: React.FC<Props> = ({
|
||||
handleClose,
|
||||
@ -295,25 +284,27 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
||||
maxLength={255}
|
||||
/>
|
||||
</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
|
||||
name="description"
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { value } }) => {
|
||||
render={({ field: { value, onChange } }) => {
|
||||
if (!data)
|
||||
return (
|
||||
<WrappedRemirrorRichTextEditor
|
||||
value={{
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph" }],
|
||||
}}
|
||||
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
|
||||
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
|
||||
placeholder="Write something..."
|
||||
<TiptapEditor
|
||||
ref={editorRef}
|
||||
value={"<p></p>"}
|
||||
debouncedUpdatesEnabled={false}
|
||||
customClassName="text-sm"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
ref={editorRef}
|
||||
onChange={(description: Object, description_html: string) => {
|
||||
onChange(description_html);
|
||||
setValue("description", description);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
else if (!value || !watch("description_html"))
|
||||
@ -322,7 +313,8 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<WrappedRemirrorRichTextEditor
|
||||
<TiptapEditor
|
||||
ref={editorRef}
|
||||
value={
|
||||
value && value !== "" && Object.keys(value).length > 0
|
||||
? value
|
||||
@ -330,13 +322,14 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
||||
? watch("description_html")
|
||||
: { type: "doc", content: [{ type: "paragraph" }] }
|
||||
}
|
||||
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
|
||||
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
|
||||
placeholder="Write something..."
|
||||
debouncedUpdatesEnabled={false}
|
||||
customClassName="text-sm"
|
||||
noBorder
|
||||
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 dynamic from "next/dynamic";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// ui
|
||||
@ -16,16 +14,6 @@ type Props = {
|
||||
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 = {
|
||||
name: "",
|
||||
description: "",
|
||||
|
@ -19,7 +19,6 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
// components
|
||||
import { GptAssistantModal } from "components/core";
|
||||
import { CreateUpdateBlockInline } from "components/pages";
|
||||
import RemirrorRichTextEditor, { IRemirrorRichTextEditor } from "components/rich-text-editor";
|
||||
// ui
|
||||
import { CustomMenu, TextArea } from "components/ui";
|
||||
// icons
|
||||
@ -39,6 +38,7 @@ import { copyTextToClipboard } from "helpers/string.helper";
|
||||
import { ICurrentUserResponse, IIssue, IPageBlock, IProject } from "types";
|
||||
// fetch-keys
|
||||
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
|
||||
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
|
||||
|
||||
type Props = {
|
||||
block: IPageBlock;
|
||||
@ -48,12 +48,12 @@ type Props = {
|
||||
user: ICurrentUserResponse | undefined;
|
||||
};
|
||||
|
||||
const WrappedRemirrorRichTextEditor = React.forwardRef<
|
||||
IRemirrorRichTextEditor,
|
||||
IRemirrorRichTextEditor
|
||||
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />);
|
||||
const TiptapEditor = React.forwardRef<
|
||||
ITiptapRichTextEditor,
|
||||
ITiptapRichTextEditor
|
||||
>((props, ref) => <Tiptap {...props} forwardedRef={ref} />);
|
||||
|
||||
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
|
||||
TiptapEditor.displayName = "TiptapEditor";
|
||||
|
||||
export const SinglePageBlock: React.FC<Props> = ({
|
||||
block,
|
||||
@ -328,9 +328,8 @@ export const SinglePageBlock: React.FC<Props> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`group relative w-full rounded bg-custom-background-80 text-custom-text-200 ${
|
||||
snapshot.isDragging ? "bg-custom-background-100 p-4 shadow" : ""
|
||||
}`}
|
||||
className={`group relative w-full rounded bg-custom-background-80 text-custom-text-200 ${snapshot.isDragging ? "bg-custom-background-100 p-4 shadow" : ""
|
||||
}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
@ -344,9 +343,8 @@ export const SinglePageBlock: React.FC<Props> = ({
|
||||
</button>
|
||||
<div
|
||||
ref={actionSectionRef}
|
||||
className={`absolute top-4 right-2 hidden items-center gap-2 bg-custom-background-80 pl-4 group-hover:!flex ${
|
||||
isMenuActive ? "!flex" : ""
|
||||
}`}
|
||||
className={`absolute top-4 right-2 hidden items-center gap-2 bg-custom-background-80 pl-4 group-hover:!flex ${isMenuActive ? "!flex" : ""
|
||||
}`}
|
||||
>
|
||||
{block.issue && block.sync && (
|
||||
<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
|
||||
type="button"
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 ${
|
||||
iAmFeelingLucky ? "cursor-wait" : ""
|
||||
}`}
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 ${iAmFeelingLucky ? "cursor-wait" : ""
|
||||
}`}
|
||||
onClick={handleAutoGenerateDescription}
|
||||
disabled={iAmFeelingLucky}
|
||||
>
|
||||
@ -458,18 +455,17 @@ export const SinglePageBlock: React.FC<Props> = ({
|
||||
|
||||
{showBlockDetails
|
||||
? block.description_html.length > 7 && (
|
||||
<WrappedRemirrorRichTextEditor
|
||||
value={block.description_html}
|
||||
customClassName="text-sm"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
/>
|
||||
)
|
||||
: block.description_stripped.length > 0 && (
|
||||
<p className="mt-3 text-sm font-normal text-custom-text-200 h-5 truncate">
|
||||
{block.description_stripped}
|
||||
</p>
|
||||
)}
|
||||
<TiptapEditor
|
||||
value={block.description_html}
|
||||
customClassName="text-sm min-h-[150px]"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
/>
|
||||
) : block.description_stripped.length > 0 && (
|
||||
<p className="mt-3 text-sm font-normal text-custom-text-200 h-5 truncate">
|
||||
{block.description_stripped}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<GptAssistantModal
|
||||
|
@ -110,7 +110,7 @@ export const ProfileIssuesView = () => {
|
||||
|
||||
draggedItem[groupByProperty] = destinationGroup;
|
||||
|
||||
mutateProfileIssues((prevData) => {
|
||||
mutateProfileIssues((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
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 { collapsed: sidebarCollapse, toggleCollapsed } = useTheme();
|
||||
|
||||
useOutsideClickDetector(helpOptionsRef, () => setIsNeedHelpOpen(false));
|
||||
|
||||
return (
|
||||
|
@ -122,7 +122,7 @@ export const InboxViewContextProvider: React.FC<{ children: React.ReactNode }> =
|
||||
},
|
||||
};
|
||||
|
||||
mutateInboxDetails((prevData) => {
|
||||
mutateInboxDetails((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
@ -156,7 +156,7 @@ export const InboxViewContextProvider: React.FC<{ children: React.ReactNode }> =
|
||||
filters: { ...initialState.filters },
|
||||
};
|
||||
|
||||
mutateInboxDetails((prevData) => {
|
||||
mutateInboxDetails((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
|
@ -401,7 +401,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
|
||||
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
mutateMyViewProps((prevData) => {
|
||||
mutateMyViewProps((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
@ -432,7 +432,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
|
||||
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
mutateMyViewProps((prevData) => {
|
||||
mutateMyViewProps((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
@ -463,7 +463,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
|
||||
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
mutateMyViewProps((prevData) => {
|
||||
mutateMyViewProps((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
@ -494,7 +494,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
|
||||
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
mutateMyViewProps((prevData) => {
|
||||
mutateMyViewProps((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
@ -525,7 +525,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
|
||||
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
mutateMyViewProps((prevData) => {
|
||||
mutateMyViewProps((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
@ -647,7 +647,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
|
||||
user
|
||||
);
|
||||
} else {
|
||||
mutateMyViewProps((prevData) => {
|
||||
mutateMyViewProps((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
|
@ -120,7 +120,7 @@ const UserNotificationContextProvider: React.FC<{
|
||||
const handleReadMutation = (action: "read" | "unread") => {
|
||||
const notificationCountNumber = action === "read" ? -1 : 1;
|
||||
|
||||
mutateNotificationCount((prev) => {
|
||||
mutateNotificationCount((prev: any) => {
|
||||
if (!prev) return prev;
|
||||
|
||||
const notificationType: keyof NotificationCount =
|
||||
@ -143,8 +143,8 @@ const UserNotificationContextProvider: React.FC<{
|
||||
notifications?.find((notification) => notification.id === notificationId)?.read_at !== null;
|
||||
|
||||
notificationsMutate(
|
||||
(previousNotifications) =>
|
||||
previousNotifications?.map((notification) =>
|
||||
(previousNotifications: any) =>
|
||||
previousNotifications?.map((notification: any) =>
|
||||
notification.id === notificationId
|
||||
? { ...notification, read_at: isRead ? null : new Date() }
|
||||
: notification
|
||||
@ -199,7 +199,8 @@ const UserNotificationContextProvider: React.FC<{
|
||||
});
|
||||
} else {
|
||||
notificationsMutate(
|
||||
(prev) => prev?.filter((prevNotification) => prevNotification.id !== notificationId),
|
||||
(prev: any) =>
|
||||
prev?.filter((prevNotification: any) => prevNotification.id !== notificationId),
|
||||
false
|
||||
);
|
||||
await userNotificationServices
|
||||
@ -222,8 +223,8 @@ const UserNotificationContextProvider: React.FC<{
|
||||
null;
|
||||
|
||||
notificationsMutate(
|
||||
(previousNotifications) =>
|
||||
previousNotifications?.map((notification) =>
|
||||
(previousNotifications: any) =>
|
||||
previousNotifications?.map((notification: any) =>
|
||||
notification.id === notificationId
|
||||
? { ...notification, snoozed_till: isSnoozed ? null : new Date(dateTime!) }
|
||||
: notification
|
||||
|
@ -56,7 +56,7 @@ const useCommentReaction = (
|
||||
user.user
|
||||
);
|
||||
|
||||
mutateCommentReactions((prev) => [...(prev || []), data]);
|
||||
mutateCommentReactions((prev: any) => [...(prev || []), data]);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -69,8 +69,8 @@ const useCommentReaction = (
|
||||
if (!workspaceSlug || !projectId || !commendId) return;
|
||||
|
||||
mutateCommentReactions(
|
||||
(prevData) =>
|
||||
prevData?.filter((r) => r.actor !== user?.user?.id || r.reaction !== reaction) || [],
|
||||
(prevData: any) =>
|
||||
prevData?.filter((r: any) => r.actor !== user?.user?.id || r.reaction !== reaction) || [],
|
||||
false
|
||||
);
|
||||
|
||||
|
@ -64,7 +64,7 @@ const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => {
|
||||
|
||||
if (issueProperties && projectId) {
|
||||
mutateIssueProperties(
|
||||
(prev) =>
|
||||
(prev: any) =>
|
||||
({
|
||||
...prev,
|
||||
properties: { ...prev?.properties, [key]: !prev?.properties?.[key] },
|
||||
|
@ -56,7 +56,7 @@ const useIssueReaction = (
|
||||
user.user
|
||||
);
|
||||
|
||||
mutateReaction((prev) => [...(prev || []), data]);
|
||||
mutateReaction((prev: any) => [...(prev || []), data]);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -69,8 +69,8 @@ const useIssueReaction = (
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
mutateReaction(
|
||||
(prevData) =>
|
||||
prevData?.filter((r) => r.actor !== user?.user?.id || r.reaction !== reaction) || [],
|
||||
(prevData: any) =>
|
||||
prevData?.filter((r: any) => r.actor !== user?.user?.id || r.reaction !== reaction) || [],
|
||||
false
|
||||
);
|
||||
|
||||
|
@ -75,7 +75,7 @@ const useUserNotification = () => {
|
||||
const handleReadMutation = (action: "read" | "unread") => {
|
||||
const notificationCountNumber = action === "read" ? -1 : 1;
|
||||
|
||||
mutateNotificationCount((prev) => {
|
||||
mutateNotificationCount((prev: any) => {
|
||||
if (!prev) return prev;
|
||||
|
||||
const notificationType: keyof NotificationCount =
|
||||
@ -93,18 +93,18 @@ const useUserNotification = () => {
|
||||
};
|
||||
|
||||
const mutateNotification = (notificationId: string, value: Object) => {
|
||||
notificationMutate((previousNotifications) => {
|
||||
notificationMutate((previousNotifications: any) => {
|
||||
if (!previousNotifications) return previousNotifications;
|
||||
|
||||
const notificationIndex = Math.floor(
|
||||
previousNotifications
|
||||
.map((d) => d.results)
|
||||
.map((d: any) => d.results)
|
||||
.flat()
|
||||
.findIndex((notification) => notification.id === notificationId) / PER_PAGE
|
||||
.findIndex((notification: any) => notification.id === notificationId) / PER_PAGE
|
||||
);
|
||||
|
||||
let notificationIndexInPage = previousNotifications[notificationIndex].results.findIndex(
|
||||
(notification) => notification.id === notificationId
|
||||
(notification: any) => notification.id === notificationId
|
||||
);
|
||||
|
||||
if (notificationIndexInPage === -1) return previousNotifications;
|
||||
@ -126,18 +126,18 @@ const useUserNotification = () => {
|
||||
};
|
||||
|
||||
const removeNotification = (notificationId: string) => {
|
||||
notificationMutate((previousNotifications) => {
|
||||
notificationMutate((previousNotifications: any) => {
|
||||
if (!previousNotifications) return previousNotifications;
|
||||
|
||||
const notificationIndex = Math.floor(
|
||||
previousNotifications
|
||||
.map((d) => d.results)
|
||||
.map((d: any) => d.results)
|
||||
.flat()
|
||||
.findIndex((notification) => notification.id === notificationId) / PER_PAGE
|
||||
.findIndex((notification: any) => notification.id === notificationId) / PER_PAGE
|
||||
);
|
||||
|
||||
let notificationIndexInPage = previousNotifications[notificationIndex].results.findIndex(
|
||||
(notification) => notification.id === notificationId
|
||||
(notification: any) => notification.id === notificationId
|
||||
);
|
||||
|
||||
if (notificationIndexInPage === -1) return previousNotifications;
|
||||
|
@ -11,6 +11,8 @@
|
||||
"dependencies": {
|
||||
"@blueprintjs/core": "^4.16.3",
|
||||
"@blueprintjs/popover2": "^1.13.3",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@headlessui/react": "^1.7.3",
|
||||
"@heroicons/react": "^2.0.12",
|
||||
"@jitsu/nextjs": "^3.1.5",
|
||||
@ -23,21 +25,37 @@
|
||||
"@nivo/line": "0.80.0",
|
||||
"@nivo/pie": "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",
|
||||
"@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/react-datepicker": "^4.8.0",
|
||||
"axios": "^1.1.3",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"highlight.js": "^11.8.0",
|
||||
"js-cookie": "^3.0.1",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"mobx": "^6.10.0",
|
||||
"mobx-react-lite": "^4.0.3",
|
||||
"lowlight": "^2.9.0",
|
||||
"lucide-react": "^0.263.1",
|
||||
"next": "12.3.2",
|
||||
"next-pwa": "^5.6.0",
|
||||
"next-themes": "^0.2.1",
|
||||
@ -50,10 +68,14 @@
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.38.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"remirror": "^2.0.23",
|
||||
"sharp": "^0.32.1",
|
||||
"sonner": "^0.6.2",
|
||||
"swr": "^2.1.3",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.6",
|
||||
"tiptap-markdown": "^0.8.2",
|
||||
"tlds": "^1.238.0",
|
||||
"use-debounce": "^9.0.4",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -76,5 +98,8 @@
|
||||
"tailwindcss": "^3.1.6",
|
||||
"tsconfig": "*",
|
||||
"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";
|
||||
// components
|
||||
import { ActivityIcon, ActivityMessage } from "components/core";
|
||||
import RemirrorRichTextEditor from "components/rich-text-editor";
|
||||
// icons
|
||||
import { ArrowTopRightOnSquareIcon, ChatBubbleLeftEllipsisIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
@ -27,6 +26,17 @@ const ProfileActivity = () => {
|
||||
|
||||
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 (
|
||||
<WorkspaceAuthorizationLayout
|
||||
breadcrumbs={
|
||||
@ -45,183 +55,173 @@ const ProfileActivity = () => {
|
||||
</div>
|
||||
<SettingsNavbar profilePage />
|
||||
</div>
|
||||
{userActivity ? (
|
||||
userActivity.results.length > 0 ? (
|
||||
<div>
|
||||
<ul role="list" className="-mb-4">
|
||||
{userActivity.results.map((activityItem: any, activityIdx: number) => {
|
||||
if (activityItem.field === "comment") {
|
||||
return (
|
||||
<div key={activityItem.id} className="mt-2">
|
||||
<div className="relative flex items-start space-x-3">
|
||||
<div className="relative px-1">
|
||||
{activityItem.field ? (
|
||||
activityItem.new_value === "restore" && (
|
||||
<Icon iconName="history" className="text-sm text-custom-text-200" />
|
||||
)
|
||||
) : activityItem.actor_detail.avatar &&
|
||||
activityItem.actor_detail.avatar !== "" ? (
|
||||
<img
|
||||
src={activityItem.actor_detail.avatar}
|
||||
alt={activityItem.actor_detail.display_name}
|
||||
height={30}
|
||||
width={30}
|
||||
className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
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)}
|
||||
</div>
|
||||
)}
|
||||
{userActivity && userActivity.results.length > 0 && (
|
||||
<div>
|
||||
<ul role="list" className="-mb-4">
|
||||
{userActivity.results.map((activityItem: any, activityIdx: number) => {
|
||||
if (activityItem.field === "comment") {
|
||||
return (
|
||||
<div key={activityItem.id} className="mt-2">
|
||||
<div className="relative flex items-start space-x-3">
|
||||
<div className="relative px-1">
|
||||
{activityItem.field ? (
|
||||
activityItem.new_value === "restore" && (
|
||||
<Icon iconName="history" className="text-sm text-custom-text-200" />
|
||||
)
|
||||
) : activityItem.actor_detail.avatar &&
|
||||
activityItem.actor_detail.avatar !== "" ? (
|
||||
<img
|
||||
src={activityItem.actor_detail.avatar}
|
||||
alt={activityItem.actor_detail.display_name}
|
||||
height={30}
|
||||
width={30}
|
||||
className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
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)}
|
||||
</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">
|
||||
<ChatBubbleLeftEllipsisIcon
|
||||
className="h-3.5 w-3.5 text-custom-text-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<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
|
||||
className="h-3.5 w-3.5 text-custom-text-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</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 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 className="issue-comments-section p-0">
|
||||
<RemirrorRichTextEditor
|
||||
value={
|
||||
activityItem.new_value && activityItem.new_value !== ""
|
||||
<div className="issue-comments-section p-0">
|
||||
{/* // TODO: Check these styles */}
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
activityItem?.new_value !== ""
|
||||
? activityItem.new_value
|
||||
: activityItem.old_value
|
||||
}
|
||||
editable={false}
|
||||
noBorder
|
||||
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
||||
/>
|
||||
</div>
|
||||
: activityItem.old_value,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const message =
|
||||
activityItem.verb === "created" &&
|
||||
activityItem.field !== "cycles" &&
|
||||
activityItem.field !== "modules" &&
|
||||
activityItem.field !== "attachment" &&
|
||||
activityItem.field !== "link" &&
|
||||
activityItem.field !== "estimate" ? (
|
||||
<span className="text-custom-text-200">
|
||||
created{" "}
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${activityItem.project}/issues/${activityItem.issue}`}
|
||||
>
|
||||
<a className="inline-flex items-center hover:underline">
|
||||
this issue. <ArrowTopRightOnSquareIcon className="ml-1 h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Link>
|
||||
</span>
|
||||
) : activityItem.field ? (
|
||||
<ActivityMessage activity={activityItem} showIssue />
|
||||
) : (
|
||||
"created the issue."
|
||||
);
|
||||
const message =
|
||||
activityItem.verb === "created" &&
|
||||
activityItem.field !== "cycles" &&
|
||||
activityItem.field !== "modules" &&
|
||||
activityItem.field !== "attachment" &&
|
||||
activityItem.field !== "link" &&
|
||||
activityItem.field !== "estimate" ? (
|
||||
<span className="text-custom-text-200">
|
||||
created{" "}
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${activityItem.project}/issues/${activityItem.issue}`}
|
||||
>
|
||||
<a className="inline-flex items-center hover:underline">
|
||||
this issue. <ArrowTopRightOnSquareIcon className="ml-1 h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Link>
|
||||
</span>
|
||||
) : activityItem.field ? (
|
||||
<ActivityMessage activity={activityItem} showIssue />
|
||||
) : (
|
||||
"created the issue."
|
||||
);
|
||||
|
||||
if ("field" in activityItem && activityItem.field !== "updated_by") {
|
||||
return (
|
||||
<li key={activityItem.id}>
|
||||
<div className="relative pb-1">
|
||||
{userActivity.results.length > 1 &&
|
||||
activityIdx !== userActivity.results.length - 1 ? (
|
||||
<span
|
||||
className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-custom-background-80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative flex items-start space-x-2">
|
||||
<>
|
||||
<div>
|
||||
<div className="relative px-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">
|
||||
{activityItem.field ? (
|
||||
activityItem.new_value === "restore" ? (
|
||||
<Icon
|
||||
iconName="history"
|
||||
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"
|
||||
if ("field" in activityItem && activityItem.field !== "updated_by") {
|
||||
return (
|
||||
<li key={activityItem.id}>
|
||||
<div className="relative pb-1">
|
||||
{userActivity.results.length > 1 &&
|
||||
activityIdx !== userActivity.results.length - 1 ? (
|
||||
<span
|
||||
className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-custom-background-80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative flex items-start space-x-2">
|
||||
<>
|
||||
<div>
|
||||
<div className="relative px-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">
|
||||
{activityItem.field ? (
|
||||
activityItem.new_value === "restore" ? (
|
||||
<Icon
|
||||
iconName="history"
|
||||
className="text-sm text-custom-text-200"
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
<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
|
||||
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 className="min-w-0 flex-1 py-3">
|
||||
<div className="text-xs text-custom-text-200 break-words">
|
||||
{activityItem.field === "archived_at" &&
|
||||
activityItem.new_value !== "restore" ? (
|
||||
<span className="text-gray font-medium">Plane</span>
|
||||
) : activityItem.actor_detail.is_bot ? (
|
||||
<span className="text-gray font-medium">
|
||||
{activityItem.actor_detail.first_name} Bot
|
||||
</span>
|
||||
) : (
|
||||
<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)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 py-3">
|
||||
<div className="text-xs text-custom-text-200 break-words">
|
||||
{activityItem.field === "archived_at" &&
|
||||
activityItem.new_value !== "restore" ? (
|
||||
<span className="text-gray font-medium">Plane</span>
|
||||
) : activityItem.actor_detail.is_bot ? (
|
||||
<span className="text-gray font-medium">
|
||||
{activityItem.actor_detail.first_name} Bot
|
||||
</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>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<Loader className="space-y-5">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</WorkspaceAuthorizationLayout>
|
||||
|
@ -77,7 +77,7 @@ const Profile: NextPage = () => {
|
||||
await userService
|
||||
.updateUser(payload)
|
||||
.then((res) => {
|
||||
mutateUser((prevData) => {
|
||||
mutateUser((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return { ...prevData, ...res };
|
||||
@ -112,7 +112,7 @@ const Profile: NextPage = () => {
|
||||
title: "Success!",
|
||||
message: "Profile picture removed successfully.",
|
||||
});
|
||||
mutateUser((prevData) => {
|
||||
mutateUser((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
return { ...prevData, avatar: "" };
|
||||
}, false);
|
||||
|
@ -45,6 +45,7 @@ const defaultValues = {
|
||||
const IssueDetailsPage: NextPage = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
// console.log(workspaceSlug, "workspaceSlug")
|
||||
|
||||
const { user } = useUserAuth();
|
||||
|
||||
|
@ -629,17 +629,19 @@ const SinglePage: NextPage = () => {
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{pageBlocks.map((block, index) => (
|
||||
<SinglePageBlock
|
||||
key={block.id}
|
||||
block={block}
|
||||
projectDetails={projectDetails}
|
||||
showBlockDetails={showBlock}
|
||||
index={index}
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
<>
|
||||
{pageBlocks.map((block, index) => (
|
||||
<SinglePageBlock
|
||||
key={block.id}
|
||||
block={block}
|
||||
projectDetails={projectDetails}
|
||||
showBlockDetails={showBlock}
|
||||
index={index}
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</StrictModeDroppable>
|
||||
|
@ -139,7 +139,7 @@ const MembersSettings: NextPage = () => {
|
||||
selectedRemoveMember
|
||||
);
|
||||
mutateMembers(
|
||||
(prevData) => prevData?.filter((item: any) => item.id !== selectedRemoveMember),
|
||||
(prevData: any) => prevData?.filter((item: any) => item.id !== selectedRemoveMember),
|
||||
false
|
||||
);
|
||||
}
|
||||
@ -150,7 +150,8 @@ const MembersSettings: NextPage = () => {
|
||||
selectedInviteRemoveMember
|
||||
);
|
||||
mutateInvitations(
|
||||
(prevData) => prevData?.filter((item: any) => item.id !== selectedInviteRemoveMember),
|
||||
(prevData: any) =>
|
||||
prevData?.filter((item: any) => item.id !== selectedInviteRemoveMember),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
@ -139,14 +139,15 @@ const MembersSettings: NextPage = () => {
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
mutateMembers((prevData) =>
|
||||
prevData?.filter((item) => item.id !== selectedRemoveMember)
|
||||
mutateMembers((prevData: any) =>
|
||||
prevData?.filter((item: any) => item.id !== selectedRemoveMember)
|
||||
);
|
||||
});
|
||||
}
|
||||
if (selectedInviteRemoveMember) {
|
||||
mutateInvitations(
|
||||
(prevData) => prevData?.filter((item) => item.id !== selectedInviteRemoveMember),
|
||||
(prevData: any) =>
|
||||
prevData?.filter((item: any) => item.id !== selectedInviteRemoveMember),
|
||||
false
|
||||
);
|
||||
workspaceService
|
||||
@ -262,8 +263,8 @@ const MembersSettings: NextPage = () => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
mutateMembers(
|
||||
(prevData) =>
|
||||
prevData?.map((m) =>
|
||||
(prevData: any) =>
|
||||
prevData?.map((m: any) =>
|
||||
m.id === member.id ? { ...m, role: value } : m
|
||||
),
|
||||
false
|
||||
|
@ -1,5 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"tailwindcss/nesting": {},
|
||||
tailwindcss: {},
|
||||
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> {
|
||||
const lastIndex = assetUrl.lastIndexOf("/");
|
||||
const assetId = assetUrl.substring(lastIndex + 1);
|
||||
@ -50,7 +58,6 @@ class FileServices extends APIService {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async uploadUserFile(file: FormData): Promise<any> {
|
||||
return this.mediaUpload(`/api/users/file-assets/`, file)
|
||||
.then((response) => response?.data)
|
||||
|
@ -1,11 +1,96 @@
|
||||
.empty-node::after {
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: rgb(var(--color-text-400));
|
||||
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: 15px;
|
||||
margin-left: 1px;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@ -31,66 +116,13 @@
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.ProseMirror-icon {
|
||||
display: inline-block;
|
||||
line-height: 0.8;
|
||||
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;
|
||||
.fadeIn {
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease-in;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-disabled.ProseMirror-icon {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
.fadeOut {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-out;
|
||||
}
|
||||
|
||||
.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"],
|
||||
},
|
||||
},
|
||||
plugins: [require("@tailwindcss/typography")],
|
||||
plugins: [
|
||||
require("tailwindcss-animate"),
|
||||
require("@tailwindcss/typography")
|
||||
],
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user