fix: rich text editor (#1008)
* fix: undo/redo, placeholder overlapping with text, horizontal cursor refractor: removed a lot of state-management that was not required * fix: forwarding ref to remirror for getting extra helper functions * fix: icon type error * fix: value type not supported error on page block * style: spacing, and UX for add link --------- Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
This commit is contained in:
parent
df96d40cfa
commit
4f2b106852
14 changed files with 3297 additions and 3386 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, forwardRef, useRef } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import dynamic from "next/dynamic";
|
||||
|
|
@ -35,6 +35,14 @@ 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} />
|
||||
);
|
||||
|
||||
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
|
||||
|
||||
export const GptAssistantModal: React.FC<Props> = ({
|
||||
isOpen,
|
||||
handleClose,
|
||||
|
|
@ -125,7 +133,7 @@ export const GptAssistantModal: React.FC<Props> = ({
|
|||
isOpen ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
{((content && content !== "") || htmlContent !== "<p></p>") && (
|
||||
{((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && (
|
||||
<div className="remirror-section text-sm">
|
||||
Content:
|
||||
<RemirrorRichTextEditor
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { FormProvider, useForm } from "react-hook-form";
|
|||
|
||||
// icons
|
||||
import { ArrowLeftIcon, ListBulletIcon } from "@heroicons/react/24/outline";
|
||||
import { CogIcon, CloudUploadIcon, UsersIcon, CheckIcon } from "components/icons";
|
||||
import { CogIcon, UsersIcon, CheckIcon } from "components/icons";
|
||||
|
||||
// services
|
||||
import jiraImporterService from "services/integration/jira.service";
|
||||
|
|
@ -40,7 +40,7 @@ import { IJiraImporterForm } from "types";
|
|||
const integrationWorkflowData: Array<{
|
||||
title: string;
|
||||
key: TJiraIntegrationSteps;
|
||||
icon: React.FC<React.SVGProps<SVGSVGElement>>;
|
||||
icon: React.FC<React.SVGProps<SVGSVGElement> & React.RefAttributes<SVGSVGElement>>;
|
||||
}> = [
|
||||
{
|
||||
title: "Configure",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,14 @@ const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor
|
|||
</Loader>
|
||||
),
|
||||
});
|
||||
import { IRemirrorRichTextEditor } from "components/rich-text-editor";
|
||||
|
||||
const WrappedRemirrorRichTextEditor = React.forwardRef<
|
||||
IRemirrorRichTextEditor,
|
||||
IRemirrorRichTextEditor
|
||||
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />);
|
||||
|
||||
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
|
||||
|
||||
const defaultValues: Partial<IIssueComment> = {
|
||||
comment_json: "",
|
||||
|
|
@ -41,6 +49,8 @@ export const AddComment: React.FC = () => {
|
|||
reset,
|
||||
} = useForm<IIssueComment>({ defaultValues });
|
||||
|
||||
const editorRef = React.useRef<any>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
|
|
@ -61,6 +71,7 @@ export const AddComment: React.FC = () => {
|
|||
.then(() => {
|
||||
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
||||
reset(defaultValues);
|
||||
editorRef.current?.clearEditor();
|
||||
})
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
|
|
@ -79,11 +90,12 @@ export const AddComment: React.FC = () => {
|
|||
name="comment_json"
|
||||
control={control}
|
||||
render={({ field: { value } }) => (
|
||||
<RemirrorRichTextEditor
|
||||
<WrappedRemirrorRichTextEditor
|
||||
value={value}
|
||||
onJSONChange={(jsonValue) => setValue("comment_json", jsonValue)}
|
||||
onHTMLChange={(htmlValue) => setValue("comment_html", htmlValue)}
|
||||
placeholder="Enter your comment..."
|
||||
ref={editorRef}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,15 @@ import type { IIssueComment } from "types";
|
|||
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { ssr: false });
|
||||
|
||||
import { IRemirrorRichTextEditor } from "components/rich-text-editor";
|
||||
|
||||
const WrappedRemirrorRichTextEditor = React.forwardRef<
|
||||
IRemirrorRichTextEditor,
|
||||
IRemirrorRichTextEditor
|
||||
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />);
|
||||
|
||||
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
|
||||
|
||||
type Props = {
|
||||
comment: IIssueComment;
|
||||
onSubmit: (comment: IIssueComment) => void;
|
||||
|
|
@ -27,6 +36,9 @@ type Props = {
|
|||
export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentDeletion }) => {
|
||||
const { user } = useUser();
|
||||
|
||||
const editorRef = React.useRef<any>(null);
|
||||
const showEditorRef = React.useRef<any>(null);
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const {
|
||||
|
|
@ -42,6 +54,10 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
|
|||
if (isSubmitting) return;
|
||||
setIsEditing(false);
|
||||
onSubmit(formData);
|
||||
console.log(formData);
|
||||
|
||||
editorRef.current?.setEditorValue(formData.comment_json);
|
||||
showEditorRef.current?.setEditorValue(formData.comment_json);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -85,41 +101,45 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
|
|||
</p>
|
||||
</div>
|
||||
<div className="issue-comments-section p-0">
|
||||
{isEditing ? (
|
||||
<form className="flex flex-col gap-2" onSubmit={handleSubmit(onEnter)}>
|
||||
<RemirrorRichTextEditor
|
||||
value={comment.comment_html}
|
||||
onBlur={(jsonValue, htmlValue) => {
|
||||
setValue("comment_json", jsonValue);
|
||||
setValue("comment_html", htmlValue);
|
||||
}}
|
||||
placeholder="Enter Your comment..."
|
||||
/>
|
||||
<div className="flex gap-1 self-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 hover:bg-green-500"
|
||||
>
|
||||
<CheckIcon className="h-3 w-3 text-green-500 duration-300 group-hover:text-white" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="group rounded border border-red-500 bg-red-500/20 p-2 shadow-md duration-300 hover:bg-red-500"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3 text-red-500 duration-300 group-hover:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<RemirrorRichTextEditor
|
||||
<form
|
||||
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 className="flex gap-1 self-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 hover:bg-green-500"
|
||||
>
|
||||
<CheckIcon className="h-3 w-3 text-green-500 duration-300 group-hover:text-white" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="group rounded border border-red-500 bg-red-500/20 p-2 shadow-md duration-300 hover:bg-red-500"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3 text-red-500 duration-300 group-hover:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className={`${isEditing ? "hidden" : ""}`}>
|
||||
<WrappedRemirrorRichTextEditor
|
||||
value={comment.comment_html}
|
||||
editable={false}
|
||||
noBorder
|
||||
customClassName="text-xs border border-brand-base bg-brand-base"
|
||||
ref={showEditorRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{user?.id === comment.actor && (
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import dynamic from "next/dynamic";
|
|||
import { Controller, useForm } from "react-hook-form";
|
||||
// contexts
|
||||
import { useProjectMyMembership } from "contexts/project-member.context";
|
||||
// hooks
|
||||
import useReloadConfirmations from "hooks/use-reload-confirmation";
|
||||
// components
|
||||
import { Loader, TextArea } from "components/ui";
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
|
|
@ -36,6 +38,8 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleFormS
|
|||
|
||||
const { memberRole } = useProjectMyMembership();
|
||||
|
||||
const { setShowAlert } = useReloadConfirmations();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
|
|
@ -82,7 +86,10 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleFormS
|
|||
useEffect(() => {
|
||||
if (!issue) return;
|
||||
|
||||
reset(issue);
|
||||
reset({
|
||||
...issue,
|
||||
description: issue.description,
|
||||
});
|
||||
}, [issue, reset]);
|
||||
|
||||
const isNotAllowed = memberRole.isGuest || memberRole.isViewer;
|
||||
|
|
@ -131,31 +138,42 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleFormS
|
|||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field: { value } }) => (
|
||||
<RemirrorRichTextEditor
|
||||
value={
|
||||
!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)}
|
||||
onBlur={() => {
|
||||
setIsSubmitting(true);
|
||||
handleSubmit(handleDescriptionFormSubmit)()
|
||||
.then(() => {
|
||||
setIsSubmitting(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
}}
|
||||
placeholder="Describe the issue..."
|
||||
editable={!isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
render={({ field: { value } }) => {
|
||||
if (!value || !watch("description_html")) return <></>;
|
||||
|
||||
return (
|
||||
<RemirrorRichTextEditor
|
||||
value={
|
||||
!value ||
|
||||
value === "" ||
|
||||
(typeof value === "object" && Object.keys(value).length === 0)
|
||||
? watch("description_html")
|
||||
: value
|
||||
}
|
||||
onJSONChange={(jsonValue) => {
|
||||
setShowAlert(true);
|
||||
setValue("description", jsonValue);
|
||||
}}
|
||||
onHTMLChange={(htmlValue) => {
|
||||
setShowAlert(true);
|
||||
setValue("description_html", htmlValue);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsSubmitting(true);
|
||||
handleSubmit(handleDescriptionFormSubmit)()
|
||||
.then(() => {
|
||||
setIsSubmitting(false);
|
||||
setShowAlert(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
}}
|
||||
placeholder="Describe the issue..."
|
||||
editable={!isNotAllowed}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={`absolute -bottom-8 right-0 text-sm text-brand-secondary ${
|
||||
|
|
|
|||
|
|
@ -53,7 +53,14 @@ const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor
|
|||
const defaultValues: Partial<IIssue> = {
|
||||
project: "",
|
||||
name: "",
|
||||
description: { type: "doc", content: [] },
|
||||
description: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
description_html: "<p></p>",
|
||||
estimate_point: null,
|
||||
state: "",
|
||||
|
|
@ -132,7 +139,14 @@ export const IssueForm: FC<IssueFormProps> = ({
|
|||
reset({
|
||||
...defaultValues,
|
||||
project: projectId,
|
||||
description: "",
|
||||
description: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
description_html: "<p></p>",
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@ type Props = {
|
|||
|
||||
const defaultValues = {
|
||||
name: "",
|
||||
description: { type: "doc", content: [] },
|
||||
description_html: "<p></p>",
|
||||
description: null,
|
||||
description_html: null,
|
||||
};
|
||||
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
|
|
@ -46,6 +46,14 @@ const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor
|
|||
</Loader>
|
||||
),
|
||||
});
|
||||
import { IRemirrorRichTextEditor } from "components/rich-text-editor";
|
||||
|
||||
const WrappedRemirrorRichTextEditor = React.forwardRef<
|
||||
IRemirrorRichTextEditor,
|
||||
IRemirrorRichTextEditor
|
||||
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />);
|
||||
|
||||
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
|
||||
|
||||
export const CreateUpdateBlockInline: React.FC<Props> = ({
|
||||
handleClose,
|
||||
|
|
@ -57,6 +65,8 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
|||
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
|
||||
const [gptAssistantModal, setGptAssistantModal] = useState(false);
|
||||
|
||||
const editorRef = React.useRef<any>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, pageId } = router.query;
|
||||
|
||||
|
|
@ -97,6 +107,7 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
|||
(prevData) => [...(prevData as IPageBlock[]), res],
|
||||
false
|
||||
);
|
||||
editorRef.current?.clearEditor();
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
|
|
@ -135,6 +146,7 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
|||
})
|
||||
.then((res) => {
|
||||
mutate(PAGE_BLOCKS_LIST(pageId as string));
|
||||
editorRef.current?.setEditorValue(res.description);
|
||||
if (data.issue && data.sync)
|
||||
issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, data.issue, {
|
||||
|
|
@ -200,8 +212,14 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
|||
reset({
|
||||
...defaultValues,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
description_html: data.description_html,
|
||||
description:
|
||||
!data.description || data.description === ""
|
||||
? {
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph" }],
|
||||
}
|
||||
: data.description,
|
||||
description_html: data.description_html ?? "<p></p>",
|
||||
});
|
||||
}, [reset, data, focus, setFocus]);
|
||||
|
||||
|
|
@ -254,21 +272,43 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
|||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field: { value } }) => (
|
||||
<RemirrorRichTextEditor
|
||||
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="Write something..."
|
||||
customClassName="text-sm"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
/>
|
||||
)}
|
||||
render={({ field: { value } }) => {
|
||||
if (!data)
|
||||
return (
|
||||
<WrappedRemirrorRichTextEditor
|
||||
value={value}
|
||||
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
|
||||
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
|
||||
placeholder="Write something..."
|
||||
customClassName="text-sm"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
ref={editorRef}
|
||||
/>
|
||||
);
|
||||
else if (!value || !watch("description_html"))
|
||||
return (
|
||||
<div className="h-32 w-full flex items-center justify-center text-brand-secondary text-sm" />
|
||||
);
|
||||
|
||||
return (
|
||||
<RemirrorRichTextEditor
|
||||
value={
|
||||
value && value !== "" && Object.keys(value).length > 0
|
||||
? value
|
||||
: watch("description_html") && watch("description_html") !== ""
|
||||
? watch("description_html")
|
||||
: { type: "doc", content: [{ type: "paragraph" }] }
|
||||
}
|
||||
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
|
||||
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
|
||||
placeholder="Write something..."
|
||||
customClassName="text-sm"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="m-2 mt-6 flex">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, FC, useState, useEffect } from "react";
|
||||
import { useCallback, useState, useImperativeHandle } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { InvalidContentHandler } from "remirror";
|
||||
import {
|
||||
BoldExtension,
|
||||
|
|
@ -27,14 +28,14 @@ import {
|
|||
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";
|
||||
// ui
|
||||
import { Spinner } from "components/ui";
|
||||
// components
|
||||
import { CustomFloatingToolbar } from "./toolbar/float-tool-tip";
|
||||
import { MentionAutoComplete } from "./mention-autocomplete";
|
||||
|
|
@ -53,12 +54,10 @@ export interface IRemirrorRichTextEditor {
|
|||
gptOption?: boolean;
|
||||
noBorder?: boolean;
|
||||
borderOnFocus?: boolean;
|
||||
forwardedRef?: any;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-duplicate-imports
|
||||
import { FloatingWrapper, FloatingToolbar } from "@remirror/react";
|
||||
|
||||
const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
|
||||
const RemirrorRichTextEditor: React.FC<IRemirrorRichTextEditor> = (props) => {
|
||||
const {
|
||||
placeholder,
|
||||
mentions = [],
|
||||
|
|
@ -73,11 +72,10 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
|
|||
gptOption = false,
|
||||
noBorder = false,
|
||||
borderOnFocus = true,
|
||||
forwardedRef,
|
||||
} = props;
|
||||
|
||||
const [imageLoader, setImageLoader] = useState(false);
|
||||
const [jsonValue, setJsonValue] = useState<any>();
|
||||
const [htmlValue, setHtmlValue] = useState<any>();
|
||||
const [disableToolbar, setDisableToolbar] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
|
@ -91,15 +89,11 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
|
|||
);
|
||||
|
||||
const uploadImageHandler = (value: any): any => {
|
||||
setImageLoader(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("asset", value[0].file);
|
||||
formData.append("attributes", JSON.stringify({}));
|
||||
|
||||
setImageLoader(true);
|
||||
|
||||
return [
|
||||
() =>
|
||||
new Promise(async (resolve, reject) => {
|
||||
|
|
@ -114,7 +108,6 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
|
|||
width: "100%",
|
||||
src: imageUrl,
|
||||
});
|
||||
setImageLoader(false);
|
||||
}),
|
||||
];
|
||||
} catch {
|
||||
|
|
@ -136,15 +129,25 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
|
|||
new CalloutExtension({ defaultType: "warn" }),
|
||||
new CodeBlockExtension(),
|
||||
new CodeExtension(),
|
||||
new PlaceholderExtension({ placeholder: placeholder || "Enter text..." }),
|
||||
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-full aspect-video bg-brand-surface-2 animate-pulse";
|
||||
return div;
|
||||
},
|
||||
}),
|
||||
new DropCursorExtension(),
|
||||
new StrikeExtension(),
|
||||
|
|
@ -156,38 +159,25 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
|
|||
}),
|
||||
new TableExtension(),
|
||||
],
|
||||
content: !value || (typeof value === "object" && Object.keys(value).length === 0) ? "" : value,
|
||||
content: value,
|
||||
selection: "start",
|
||||
stringHandler: "html",
|
||||
onError,
|
||||
});
|
||||
|
||||
const updateState = useCallback(
|
||||
(value: any) => {
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
clearEditor: () => {
|
||||
manager.view.updateState(manager.createState({ content: "", selection: "start" }));
|
||||
},
|
||||
setEditorValue: (value: any) => {
|
||||
manager.view.updateState(
|
||||
manager.createState({
|
||||
content:
|
||||
!value || (typeof value === "object" && Object.keys(value).length === 0) ? "" : value,
|
||||
selection: value === "" ? "start" : manager.view.state.selection,
|
||||
content: value,
|
||||
selection: "end",
|
||||
})
|
||||
);
|
||||
},
|
||||
[manager]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
updateState(value);
|
||||
}, [updateState, value]);
|
||||
|
||||
const handleJSONChange = (json: any) => {
|
||||
setJsonValue(json);
|
||||
onJSONChange(json);
|
||||
};
|
||||
|
||||
const handleHTMLChange = (value: string) => {
|
||||
setHtmlValue(value);
|
||||
onHTMLChange(value);
|
||||
};
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
|
|
@ -195,50 +185,51 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
|
|||
manager={manager}
|
||||
initialContent={state}
|
||||
classNames={[
|
||||
`p-4 relative focus:outline-none rounded-md focus:border-brand-base ${
|
||||
`p-3 relative focus:outline-none rounded-md focus:border-brand-base ${
|
||||
noBorder ? "" : "border border-brand-base"
|
||||
} ${
|
||||
borderOnFocus ? "focus:border border-brand-base" : "focus:border-0"
|
||||
} ${customClassName}`,
|
||||
]}
|
||||
editable={editable}
|
||||
onBlur={() => {
|
||||
onBlur(jsonValue, htmlValue);
|
||||
onBlur={(event) => {
|
||||
const html = event.helpers.getHTML();
|
||||
const json = event.helpers.getJSON();
|
||||
|
||||
setDisableToolbar(true);
|
||||
|
||||
onBlur(json, html);
|
||||
}}
|
||||
onFocus={() => setDisableToolbar(false)}
|
||||
>
|
||||
{(!value || value === "" || value?.content?.[0]?.content === undefined) &&
|
||||
!(typeof value === "string" && value.includes("<")) &&
|
||||
placeholder && (
|
||||
<p className="pointer-events-none absolute top-4 left-4 text-sm text-brand-secondary">
|
||||
{placeholder}
|
||||
</p>
|
||||
)}
|
||||
<EditorComponent />
|
||||
<div className="prose prose-brand max-w-full prose-p:my-1">
|
||||
<EditorComponent />
|
||||
</div>
|
||||
|
||||
{imageLoader && (
|
||||
<div className="p-4">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editable && (
|
||||
{editable && !disableToolbar && (
|
||||
<FloatingWrapper
|
||||
positioner="always"
|
||||
floatingLabel="Custom Floating Toolbar"
|
||||
renderOutsideEditor
|
||||
floatingLabel="Custom Floating Toolbar"
|
||||
>
|
||||
<FloatingToolbar className="z-[9999] overflow-hidden rounded">
|
||||
<CustomFloatingToolbar gptOption={gptOption} editorState={state} />
|
||||
<FloatingToolbar className="z-50 overflow-hidden rounded">
|
||||
<CustomFloatingToolbar
|
||||
gptOption={gptOption}
|
||||
editorState={state}
|
||||
setDisableToolbar={setDisableToolbar}
|
||||
/>
|
||||
</FloatingToolbar>
|
||||
</FloatingWrapper>
|
||||
)}
|
||||
|
||||
<MentionAutoComplete mentions={mentions} tags={tags} />
|
||||
{<OnChangeJSON onChange={handleJSONChange} />}
|
||||
{<OnChangeHTML onChange={handleHTMLChange} />}
|
||||
{<OnChangeJSON onChange={onJSONChange} />}
|
||||
{<OnChangeHTML onChange={onHTMLChange} />}
|
||||
</Remirror>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RemirrorRichTextEditor.displayName = "RemirrorRichTextEditor";
|
||||
|
||||
export default RemirrorRichTextEditor;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,16 @@
|
|||
import React, {
|
||||
ChangeEvent,
|
||||
HTMLProps,
|
||||
KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { createMarkPositioner, LinkExtension, ShortcutHandlerProps } from "remirror/extensions";
|
||||
// buttons
|
||||
import {
|
||||
ToggleBoldButton,
|
||||
|
|
@ -9,60 +22,295 @@ import {
|
|||
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>>;
|
||||
};
|
||||
|
||||
export const CustomFloatingToolbar: React.FC<Props> = ({ gptOption, editorState }) => {
|
||||
const active = useActive();
|
||||
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 (
|
||||
<div className="z-[99999] flex items-center gap-y-2 divide-x divide-brand-base rounded border border-brand-base bg-brand-surface-2 p-1 px-0.5 shadow-md">
|
||||
<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 && (
|
||||
<>
|
||||
<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-brand-base rounded border border-brand-base bg-brand-surface-2 p-1 px-0.5 shadow-md">
|
||||
<div className="flex items-center gap-y-2 divide-x divide-brand-base">
|
||||
<div className="flex items-center gap-x-1 px-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded py-1 px-1.5 text-xs hover:bg-brand-surface-1"
|
||||
onClick={() => console.log(editorState.selection.$anchor.nodeBefore)}
|
||||
>
|
||||
AI
|
||||
</button>
|
||||
<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-brand-surface-1"
|
||||
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 className="flex items-center gap-x-1 px-2">
|
||||
<ToggleCodeButton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue