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:
Dakshesh Jain 2023-05-11 02:15:49 +05:30 committed by GitHub
parent df96d40cfa
commit 4f2b106852
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 3297 additions and 3386 deletions

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useState, forwardRef, useRef } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
@ -35,6 +35,14 @@ const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor
ssr: false, 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> = ({ export const GptAssistantModal: React.FC<Props> = ({
isOpen, isOpen,
handleClose, handleClose,
@ -125,7 +133,7 @@ export const GptAssistantModal: React.FC<Props> = ({
isOpen ? "block" : "hidden" isOpen ? "block" : "hidden"
}`} }`}
> >
{((content && content !== "") || htmlContent !== "<p></p>") && ( {((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && (
<div className="remirror-section text-sm"> <div className="remirror-section text-sm">
Content: Content:
<RemirrorRichTextEditor <RemirrorRichTextEditor

View File

@ -13,7 +13,7 @@ import { FormProvider, useForm } from "react-hook-form";
// icons // icons
import { ArrowLeftIcon, ListBulletIcon } from "@heroicons/react/24/outline"; import { ArrowLeftIcon, ListBulletIcon } from "@heroicons/react/24/outline";
import { CogIcon, CloudUploadIcon, UsersIcon, CheckIcon } from "components/icons"; import { CogIcon, UsersIcon, CheckIcon } from "components/icons";
// services // services
import jiraImporterService from "services/integration/jira.service"; import jiraImporterService from "services/integration/jira.service";
@ -40,7 +40,7 @@ import { IJiraImporterForm } from "types";
const integrationWorkflowData: Array<{ const integrationWorkflowData: Array<{
title: string; title: string;
key: TJiraIntegrationSteps; key: TJiraIntegrationSteps;
icon: React.FC<React.SVGProps<SVGSVGElement>>; icon: React.FC<React.SVGProps<SVGSVGElement> & React.RefAttributes<SVGSVGElement>>;
}> = [ }> = [
{ {
title: "Configure", title: "Configure",

View File

@ -26,6 +26,14 @@ const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor
</Loader> </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> = { const defaultValues: Partial<IIssueComment> = {
comment_json: "", comment_json: "",
@ -41,6 +49,8 @@ export const AddComment: React.FC = () => {
reset, reset,
} = useForm<IIssueComment>({ defaultValues }); } = useForm<IIssueComment>({ defaultValues });
const editorRef = React.useRef<any>(null);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query; const { workspaceSlug, projectId, issueId } = router.query;
@ -61,6 +71,7 @@ export const AddComment: React.FC = () => {
.then(() => { .then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
reset(defaultValues); reset(defaultValues);
editorRef.current?.clearEditor();
}) })
.catch(() => .catch(() =>
setToastAlert({ setToastAlert({
@ -79,11 +90,12 @@ export const AddComment: React.FC = () => {
name="comment_json" name="comment_json"
control={control} control={control}
render={({ field: { value } }) => ( render={({ field: { value } }) => (
<RemirrorRichTextEditor <WrappedRemirrorRichTextEditor
value={value} value={value}
onJSONChange={(jsonValue) => setValue("comment_json", jsonValue)} onJSONChange={(jsonValue) => setValue("comment_json", jsonValue)}
onHTMLChange={(htmlValue) => setValue("comment_html", htmlValue)} onHTMLChange={(htmlValue) => setValue("comment_html", htmlValue)}
placeholder="Enter your comment..." placeholder="Enter your comment..."
ref={editorRef}
/> />
)} )}
/> />

View File

@ -18,6 +18,15 @@ import type { IIssueComment } from "types";
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { ssr: false }); 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 = { type Props = {
comment: IIssueComment; comment: IIssueComment;
onSubmit: (comment: IIssueComment) => void; onSubmit: (comment: IIssueComment) => void;
@ -27,6 +36,9 @@ type Props = {
export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentDeletion }) => { export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentDeletion }) => {
const { user } = useUser(); const { user } = useUser();
const editorRef = React.useRef<any>(null);
const showEditorRef = React.useRef<any>(null);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const { const {
@ -42,6 +54,10 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
if (isSubmitting) return; if (isSubmitting) return;
setIsEditing(false); setIsEditing(false);
onSubmit(formData); onSubmit(formData);
console.log(formData);
editorRef.current?.setEditorValue(formData.comment_json);
showEditorRef.current?.setEditorValue(formData.comment_json);
}; };
useEffect(() => { useEffect(() => {
@ -85,15 +101,18 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
</p> </p>
</div> </div>
<div className="issue-comments-section p-0"> <div className="issue-comments-section p-0">
{isEditing ? ( <form
<form className="flex flex-col gap-2" onSubmit={handleSubmit(onEnter)}> className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}
<RemirrorRichTextEditor onSubmit={handleSubmit(onEnter)}
>
<WrappedRemirrorRichTextEditor
value={comment.comment_html} value={comment.comment_html}
onBlur={(jsonValue, htmlValue) => { onBlur={(jsonValue, htmlValue) => {
setValue("comment_json", jsonValue); setValue("comment_json", jsonValue);
setValue("comment_html", htmlValue); setValue("comment_html", htmlValue);
}} }}
placeholder="Enter Your comment..." placeholder="Enter Your comment..."
ref={editorRef}
/> />
<div className="flex gap-1 self-end"> <div className="flex gap-1 self-end">
<button <button
@ -112,14 +131,15 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
</button> </button>
</div> </div>
</form> </form>
) : ( <div className={`${isEditing ? "hidden" : ""}`}>
<RemirrorRichTextEditor <WrappedRemirrorRichTextEditor
value={comment.comment_html} value={comment.comment_html}
editable={false} editable={false}
noBorder noBorder
customClassName="text-xs border border-brand-base bg-brand-base" customClassName="text-xs border border-brand-base bg-brand-base"
ref={showEditorRef}
/> />
)} </div>
</div> </div>
</div> </div>
{user?.id === comment.actor && ( {user?.id === comment.actor && (

View File

@ -6,6 +6,8 @@ import dynamic from "next/dynamic";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// contexts // contexts
import { useProjectMyMembership } from "contexts/project-member.context"; import { useProjectMyMembership } from "contexts/project-member.context";
// hooks
import useReloadConfirmations from "hooks/use-reload-confirmation";
// components // components
import { Loader, TextArea } from "components/ui"; import { Loader, TextArea } from "components/ui";
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
@ -36,6 +38,8 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleFormS
const { memberRole } = useProjectMyMembership(); const { memberRole } = useProjectMyMembership();
const { setShowAlert } = useReloadConfirmations();
const { const {
handleSubmit, handleSubmit,
watch, watch,
@ -82,7 +86,10 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleFormS
useEffect(() => { useEffect(() => {
if (!issue) return; if (!issue) return;
reset(issue); reset({
...issue,
description: issue.description,
});
}, [issue, reset]); }, [issue, reset]);
const isNotAllowed = memberRole.isGuest || memberRole.isViewer; const isNotAllowed = memberRole.isGuest || memberRole.isViewer;
@ -131,7 +138,10 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleFormS
<Controller <Controller
name="description" name="description"
control={control} control={control}
render={({ field: { value } }) => ( render={({ field: { value } }) => {
if (!value || !watch("description_html")) return <></>;
return (
<RemirrorRichTextEditor <RemirrorRichTextEditor
value={ value={
!value || !value ||
@ -140,13 +150,20 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleFormS
? watch("description_html") ? watch("description_html")
: value : value
} }
onJSONChange={(jsonValue) => setValue("description", jsonValue)} onJSONChange={(jsonValue) => {
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)} setShowAlert(true);
setValue("description", jsonValue);
}}
onHTMLChange={(htmlValue) => {
setShowAlert(true);
setValue("description_html", htmlValue);
}}
onBlur={() => { onBlur={() => {
setIsSubmitting(true); setIsSubmitting(true);
handleSubmit(handleDescriptionFormSubmit)() handleSubmit(handleDescriptionFormSubmit)()
.then(() => { .then(() => {
setIsSubmitting(false); setIsSubmitting(false);
setShowAlert(false);
}) })
.catch(() => { .catch(() => {
setIsSubmitting(false); setIsSubmitting(false);
@ -155,7 +172,8 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleFormS
placeholder="Describe the issue..." placeholder="Describe the issue..."
editable={!isNotAllowed} editable={!isNotAllowed}
/> />
)} );
}}
/> />
<div <div
className={`absolute -bottom-8 right-0 text-sm text-brand-secondary ${ className={`absolute -bottom-8 right-0 text-sm text-brand-secondary ${

View File

@ -53,7 +53,14 @@ const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor
const defaultValues: Partial<IIssue> = { const defaultValues: Partial<IIssue> = {
project: "", project: "",
name: "", name: "",
description: { type: "doc", content: [] }, description: {
type: "doc",
content: [
{
type: "paragraph",
},
],
},
description_html: "<p></p>", description_html: "<p></p>",
estimate_point: null, estimate_point: null,
state: "", state: "",
@ -132,7 +139,14 @@ export const IssueForm: FC<IssueFormProps> = ({
reset({ reset({
...defaultValues, ...defaultValues,
project: projectId, project: projectId,
description: "", description: {
type: "doc",
content: [
{
type: "paragraph",
},
],
},
description_html: "<p></p>", description_html: "<p></p>",
}); });
}; };

View File

@ -34,8 +34,8 @@ type Props = {
const defaultValues = { const defaultValues = {
name: "", name: "",
description: { type: "doc", content: [] }, description: null,
description_html: "<p></p>", description_html: null,
}; };
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
@ -46,6 +46,14 @@ const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor
</Loader> </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> = ({ export const CreateUpdateBlockInline: React.FC<Props> = ({
handleClose, handleClose,
@ -57,6 +65,8 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
const [gptAssistantModal, setGptAssistantModal] = useState(false); const [gptAssistantModal, setGptAssistantModal] = useState(false);
const editorRef = React.useRef<any>(null);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, pageId } = router.query; const { workspaceSlug, projectId, pageId } = router.query;
@ -97,6 +107,7 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
(prevData) => [...(prevData as IPageBlock[]), res], (prevData) => [...(prevData as IPageBlock[]), res],
false false
); );
editorRef.current?.clearEditor();
}) })
.catch(() => { .catch(() => {
setToastAlert({ setToastAlert({
@ -135,6 +146,7 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
}) })
.then((res) => { .then((res) => {
mutate(PAGE_BLOCKS_LIST(pageId as string)); mutate(PAGE_BLOCKS_LIST(pageId as string));
editorRef.current?.setEditorValue(res.description);
if (data.issue && data.sync) if (data.issue && data.sync)
issuesService issuesService
.patchIssue(workspaceSlug as string, projectId as string, data.issue, { .patchIssue(workspaceSlug as string, projectId as string, data.issue, {
@ -200,8 +212,14 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
reset({ reset({
...defaultValues, ...defaultValues,
name: data.name, name: data.name,
description: data.description, description:
description_html: data.description_html, !data.description || data.description === ""
? {
type: "doc",
content: [{ type: "paragraph" }],
}
: data.description,
description_html: data.description_html ?? "<p></p>",
}); });
}, [reset, data, focus, setFocus]); }, [reset, data, focus, setFocus]);
@ -254,12 +272,33 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
<Controller <Controller
name="description" name="description"
control={control} control={control}
render={({ field: { value } }) => ( 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 <RemirrorRichTextEditor
value={ value={
!value || (typeof value === "object" && Object.keys(value).length === 0) value && value !== "" && Object.keys(value).length > 0
? value
: watch("description_html") && watch("description_html") !== ""
? watch("description_html") ? watch("description_html")
: value : { type: "doc", content: [{ type: "paragraph" }] }
} }
onJSONChange={(jsonValue) => setValue("description", jsonValue)} onJSONChange={(jsonValue) => setValue("description", jsonValue)}
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)} onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
@ -268,7 +307,8 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
noBorder noBorder
borderOnFocus={false} borderOnFocus={false}
/> />
)} );
}}
/> />
<div className="m-2 mt-6 flex"> <div className="m-2 mt-6 flex">
<button <button

View File

@ -1,5 +1,6 @@
import { useCallback, FC, useState, useEffect } from "react"; import { useCallback, useState, useImperativeHandle } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { InvalidContentHandler } from "remirror"; import { InvalidContentHandler } from "remirror";
import { import {
BoldExtension, BoldExtension,
@ -27,14 +28,14 @@ import {
EditorComponent, EditorComponent,
OnChangeJSON, OnChangeJSON,
OnChangeHTML, OnChangeHTML,
FloatingToolbar,
FloatingWrapper,
} from "@remirror/react"; } from "@remirror/react";
import { TableExtension } from "@remirror/extension-react-tables"; import { TableExtension } from "@remirror/extension-react-tables";
// tlds // tlds
import tlds from "tlds"; import tlds from "tlds";
// services // services
import fileService from "services/file.service"; import fileService from "services/file.service";
// ui
import { Spinner } from "components/ui";
// components // components
import { CustomFloatingToolbar } from "./toolbar/float-tool-tip"; import { CustomFloatingToolbar } from "./toolbar/float-tool-tip";
import { MentionAutoComplete } from "./mention-autocomplete"; import { MentionAutoComplete } from "./mention-autocomplete";
@ -53,12 +54,10 @@ export interface IRemirrorRichTextEditor {
gptOption?: boolean; gptOption?: boolean;
noBorder?: boolean; noBorder?: boolean;
borderOnFocus?: boolean; borderOnFocus?: boolean;
forwardedRef?: any;
} }
// eslint-disable-next-line no-duplicate-imports const RemirrorRichTextEditor: React.FC<IRemirrorRichTextEditor> = (props) => {
import { FloatingWrapper, FloatingToolbar } from "@remirror/react";
const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
const { const {
placeholder, placeholder,
mentions = [], mentions = [],
@ -73,11 +72,10 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
gptOption = false, gptOption = false,
noBorder = false, noBorder = false,
borderOnFocus = true, borderOnFocus = true,
forwardedRef,
} = props; } = props;
const [imageLoader, setImageLoader] = useState(false); const [disableToolbar, setDisableToolbar] = useState(false);
const [jsonValue, setJsonValue] = useState<any>();
const [htmlValue, setHtmlValue] = useState<any>();
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
@ -91,15 +89,11 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
); );
const uploadImageHandler = (value: any): any => { const uploadImageHandler = (value: any): any => {
setImageLoader(true);
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append("asset", value[0].file); formData.append("asset", value[0].file);
formData.append("attributes", JSON.stringify({})); formData.append("attributes", JSON.stringify({}));
setImageLoader(true);
return [ return [
() => () =>
new Promise(async (resolve, reject) => { new Promise(async (resolve, reject) => {
@ -114,7 +108,6 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
width: "100%", width: "100%",
src: imageUrl, src: imageUrl,
}); });
setImageLoader(false);
}), }),
]; ];
} catch { } catch {
@ -136,15 +129,25 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
new CalloutExtension({ defaultType: "warn" }), new CalloutExtension({ defaultType: "warn" }),
new CodeBlockExtension(), new CodeBlockExtension(),
new CodeExtension(), new CodeExtension(),
new PlaceholderExtension({ placeholder: placeholder || "Enter text..." }), new PlaceholderExtension({
placeholder: placeholder || "Enter text...",
emptyNodeClass: "empty-node",
}),
new HistoryExtension(), new HistoryExtension(),
new LinkExtension({ new LinkExtension({
autoLink: true, autoLink: true,
autoLinkAllowedTLDs: tlds, autoLinkAllowedTLDs: tlds,
selectTextOnClick: true,
defaultTarget: "_blank",
}), }),
new ImageExtension({ new ImageExtension({
enableResizing: true, enableResizing: true,
uploadHandler: uploadImageHandler, 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 DropCursorExtension(),
new StrikeExtension(), new StrikeExtension(),
@ -156,38 +159,25 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
}), }),
new TableExtension(), new TableExtension(),
], ],
content: !value || (typeof value === "object" && Object.keys(value).length === 0) ? "" : value, content: value,
selection: "start", selection: "start",
stringHandler: "html", stringHandler: "html",
onError, onError,
}); });
const updateState = useCallback( useImperativeHandle(forwardedRef, () => ({
(value: any) => { clearEditor: () => {
manager.view.updateState(manager.createState({ content: "", selection: "start" }));
},
setEditorValue: (value: any) => {
manager.view.updateState( manager.view.updateState(
manager.createState({ manager.createState({
content: content: value,
!value || (typeof value === "object" && Object.keys(value).length === 0) ? "" : value, selection: "end",
selection: value === "" ? "start" : manager.view.state.selection,
}) })
); );
}, },
[manager] }));
);
useEffect(() => {
updateState(value);
}, [updateState, value]);
const handleJSONChange = (json: any) => {
setJsonValue(json);
onJSONChange(json);
};
const handleHTMLChange = (value: string) => {
setHtmlValue(value);
onHTMLChange(value);
};
return ( return (
<div className="relative"> <div className="relative">
@ -195,50 +185,51 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
manager={manager} manager={manager}
initialContent={state} initialContent={state}
classNames={[ 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" noBorder ? "" : "border border-brand-base"
} ${ } ${
borderOnFocus ? "focus:border border-brand-base" : "focus:border-0" borderOnFocus ? "focus:border border-brand-base" : "focus:border-0"
} ${customClassName}`, } ${customClassName}`,
]} ]}
editable={editable} editable={editable}
onBlur={() => { onBlur={(event) => {
onBlur(jsonValue, htmlValue); 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) && <div className="prose prose-brand max-w-full prose-p:my-1">
!(typeof value === "string" && value.includes("<")) &&
placeholder && (
<p className="pointer-events-none absolute top-4 left-4 text-sm text-brand-secondary">
{placeholder}
</p>
)}
<EditorComponent /> <EditorComponent />
{imageLoader && (
<div className="p-4">
<Spinner />
</div> </div>
)}
{editable && ( {editable && !disableToolbar && (
<FloatingWrapper <FloatingWrapper
positioner="always" positioner="always"
floatingLabel="Custom Floating Toolbar"
renderOutsideEditor renderOutsideEditor
floatingLabel="Custom Floating Toolbar"
> >
<FloatingToolbar className="z-[9999] overflow-hidden rounded"> <FloatingToolbar className="z-50 overflow-hidden rounded">
<CustomFloatingToolbar gptOption={gptOption} editorState={state} /> <CustomFloatingToolbar
gptOption={gptOption}
editorState={state}
setDisableToolbar={setDisableToolbar}
/>
</FloatingToolbar> </FloatingToolbar>
</FloatingWrapper> </FloatingWrapper>
)} )}
<MentionAutoComplete mentions={mentions} tags={tags} /> <MentionAutoComplete mentions={mentions} tags={tags} />
{<OnChangeJSON onChange={handleJSONChange} />} {<OnChangeJSON onChange={onJSONChange} />}
{<OnChangeHTML onChange={handleHTMLChange} />} {<OnChangeHTML onChange={onHTMLChange} />}
</Remirror> </Remirror>
</div> </div>
); );
}; };
RemirrorRichTextEditor.displayName = "RemirrorRichTextEditor";
export default RemirrorRichTextEditor; export default RemirrorRichTextEditor;

View File

@ -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 // buttons
import { import {
ToggleBoldButton, ToggleBoldButton,
@ -9,19 +22,195 @@ import {
ToggleCodeButton, ToggleCodeButton,
ToggleHeadingButton, ToggleHeadingButton,
useActive, useActive,
CommandButton,
useAttrs,
useChainedCommands,
useCurrentSelection,
useExtensionEvent,
useUpdateReason,
} from "@remirror/react"; } from "@remirror/react";
import { EditorState } from "remirror"; import { EditorState } from "remirror";
type Props = { type Props = {
gptOption?: boolean; gptOption?: boolean;
editorState: Readonly<EditorState>; editorState: Readonly<EditorState>;
setDisableToolbar: React.Dispatch<React.SetStateAction<boolean>>;
}; };
export const CustomFloatingToolbar: React.FC<Props> = ({ gptOption, editorState }) => { const useLinkShortcut = () => {
const active = useActive(); 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 ( 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"> <>
<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"> <div className="flex items-center gap-x-1 px-2">
<ToggleHeadingButton <ToggleHeadingButton
attrs={{ attrs={{
@ -63,6 +252,65 @@ export const CustomFloatingToolbar: React.FC<Props> = ({ gptOption, editorState
<div className="flex items-center gap-x-1 px-2"> <div className="flex items-center gap-x-1 px-2">
<ToggleCodeButton /> <ToggleCodeButton />
</div> </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> </div>
); );
}; };

View File

@ -0,0 +1,28 @@
import { useCallback, useEffect, useState } from "react";
const useReloadConfirmations = (message?: string) => {
const [showAlert, setShowAlert] = useState(false);
const handleBeforeUnload = useCallback(
(event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = "";
return message ?? "Are you sure you want to leave?";
},
[message]
);
useEffect(() => {
if (!showAlert) {
window.removeEventListener("beforeunload", handleBeforeUnload);
return;
}
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [handleBeforeUnload, showAlert]);
return { setShowAlert };
};
export default useReloadConfirmations;

View File

@ -19,6 +19,7 @@
"@remirror/pm": "^2.0.3", "@remirror/pm": "^2.0.3",
"@remirror/react": "^2.0.24", "@remirror/react": "^2.0.24",
"@sentry/nextjs": "^7.36.0", "@sentry/nextjs": "^7.36.0",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash.debounce": "^4.0.7", "@types/lodash.debounce": "^4.0.7",
"@types/react-datepicker": "^4.8.0", "@types/react-datepicker": "^4.8.0",
"axios": "^1.1.3", "axios": "^1.1.3",
@ -61,7 +62,6 @@
"eslint-config-next": "12.2.2", "eslint-config-next": "12.2.2",
"postcss": "^8.4.14", "postcss": "^8.4.14",
"prettier": "^2.8.7", "prettier": "^2.8.7",
"prettier-plugin-tailwindcss": "^0.2.7",
"tailwindcss": "^3.1.6", "tailwindcss": "^3.1.6",
"tsconfig": "*", "tsconfig": "*",
"typescript": "4.7.4" "typescript": "4.7.4"

View File

@ -1,205 +1,59 @@
.ProseMirror { .empty-node::after {
position: relative; content: attr(data-placeholder);
color: #ccc;
font-style: italic;
position: absolute;
pointer-events: none;
top: 15px;
margin-left: 1px;
z-index: -1;
} }
.ProseMirror { .ProseMirror {
position: relative;
word-wrap: break-word; word-wrap: break-word;
white-space: pre-wrap; white-space: pre-wrap;
white-space: break-spaces; -moz-tab-size: 4;
-webkit-font-variant-ligatures: none; tab-size: 4;
font-variant-ligatures: none; -webkit-user-select: text;
font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */ -moz-user-select: text;
} -ms-user-select: text;
user-select: text;
.ProseMirror pre {
white-space: pre-wrap;
}
.ProseMirror li {
position: relative;
}
.ProseMirror-hideselection *::selection {
background: transparent;
}
.ProseMirror-hideselection *::-moz-selection {
background: transparent;
}
.ProseMirror-hideselection {
caret-color: transparent;
}
.ProseMirror-selectednode {
outline: 2px solid #8cf;
}
/* Make sure li selections wrap around markers */
li.ProseMirror-selectednode {
outline: none; outline: none;
} cursor: text;
line-height: 1.2;
li.ProseMirror-selectednode:after { font-family: inherit;
content: ""; font-size: 14px;
position: absolute; color: inherit;
left: -32px;
right: -2px;
top: -2px;
bottom: -2px;
border: 2px solid #8cf;
pointer-events: none;
}
/* Protect against generic img rules */
img.ProseMirror-separator {
display: inline !important;
border: none !important;
margin: 0 !important;
}
.ProseMirror-textblock-dropdown {
min-width: 3em;
}
.ProseMirror-menu {
margin: 0 -4px;
line-height: 1;
}
.ProseMirror-tooltip .ProseMirror-menu {
width: -webkit-fit-content;
width: fit-content;
white-space: pre;
}
.ProseMirror-menuitem {
margin-right: 3px;
display: inline-block;
}
.ProseMirror-menuseparator {
border-right: 1px solid #ddd;
margin-right: 3px;
}
.ProseMirror-menu-dropdown,
.ProseMirror-menu-dropdown-menu {
font-size: 90%;
white-space: nowrap;
}
.ProseMirror-menu-dropdown {
vertical-align: 1px;
cursor: pointer;
position: relative;
padding-right: 15px;
}
.ProseMirror-menu-dropdown-wrap {
padding: 1px 0 1px 4px;
display: inline-block;
position: relative;
}
.ProseMirror-menu-dropdown:after {
content: "";
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid currentColor;
opacity: 0.6;
position: absolute;
right: 4px;
top: calc(50% - 2px);
}
.ProseMirror-menu-dropdown-menu,
.ProseMirror-menu-submenu {
position: absolute;
background: white;
color: #666;
border: 1px solid #aaa;
padding: 2px;
}
.ProseMirror-menu-dropdown-menu {
z-index: 15;
min-width: 6em;
}
.ProseMirror-menu-dropdown-item {
cursor: pointer;
padding: 2px 8px 2px 4px;
}
.ProseMirror-menu-dropdown-item:hover {
background: #f2f2f2;
}
.ProseMirror-menu-submenu-wrap {
position: relative;
margin-right: -4px;
}
.ProseMirror-menu-submenu-label:after {
content: "";
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 4px solid currentColor;
opacity: 0.6;
position: absolute;
right: 4px;
top: calc(50% - 4px);
}
.ProseMirror-menu-submenu {
display: none;
min-width: 4em;
left: 100%;
top: -3px;
}
.ProseMirror-menu-active {
background: #eee;
border-radius: 4px;
}
.ProseMirror-menu-disabled {
opacity: 0.3;
}
.ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu,
.ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu {
display: block;
}
.ProseMirror-menubar {
border-top-left-radius: inherit;
border-top-right-radius: inherit;
position: relative;
min-height: 1em;
color: #666;
padding: 1px 6px;
top: 0;
left: 0;
right: 0;
border-bottom: 1px solid silver;
background: white;
z-index: 10;
-moz-box-sizing: border-box; -moz-box-sizing: border-box;
box-sizing: border-box; box-sizing: border-box;
overflow: visible; appearance: textfield;
-webkit-appearance: textfield;
-moz-appearance: textfield;
} }
.ProseMirror-icon { .ProseMirror-icon {
display: inline-block; display: inline-block;
line-height: 0.8; line-height: 0.8;
vertical-align: -2px; /* Compensate for padding */ vertical-align: -2px;
padding: 2px 8px; color: #666;
cursor: pointer; cursor: pointer;
margin: 0 3px;
padding: 3px 8px;
border-radius: 3px;
border: 1px solid transparent;
transition: background 50ms ease-in-out;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
} }
.ProseMirror-menu-disabled.ProseMirror-icon { .ProseMirror-menu-disabled.ProseMirror-icon {
opacity: 0.3;
cursor: default; cursor: default;
pointer-events: none;
} }
.ProseMirror-icon svg { .ProseMirror-icon svg {
@ -210,149 +64,6 @@ img.ProseMirror-separator {
.ProseMirror-icon span { .ProseMirror-icon span {
vertical-align: text-top; vertical-align: text-top;
} }
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
}
.ProseMirror-gapcursor:after {
content: "";
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid black;
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
}
@keyframes ProseMirror-cursor-blink {
to {
visibility: hidden;
}
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
/* Add space around the hr to make clicking it easier */
.ProseMirror-example-setup-style hr {
padding: 2px 10px;
border: none;
margin: 1em 0;
}
.ProseMirror-example-setup-style hr:after {
content: "";
display: block;
height: 1px;
background-color: silver;
line-height: 2px;
}
.ProseMirror ul,
.ProseMirror ol {
padding-left: 30px;
}
.ProseMirror blockquote {
padding-left: 1em;
border-left: 3px solid #eee;
margin-left: 0;
margin-right: 0;
}
.ProseMirror-example-setup-style img {
cursor: default;
}
.ProseMirror-prompt {
background: white;
padding: 5px 10px 5px 15px;
border: 1px solid silver;
position: fixed;
border-radius: 3px;
z-index: 11;
box-shadow: -0.5px 2px 5px rgba(0, 0, 0, 0.2);
}
.ProseMirror-prompt h5 {
margin: 0;
font-weight: normal;
font-size: 100%;
color: #444;
}
.ProseMirror-prompt input[type="text"],
.ProseMirror-prompt textarea {
background: #eee;
border: none;
outline: none;
}
.ProseMirror-prompt input[type="text"] {
padding: 0 4px;
}
.ProseMirror-prompt-close {
position: absolute;
left: 2px;
top: 1px;
color: #666;
border: none;
background: transparent;
padding: 0;
}
.ProseMirror-prompt-close:after {
content: "✕";
font-size: 12px;
}
.ProseMirror-invalid {
background: #ffc;
border: 1px solid #cc7;
border-radius: 4px;
padding: 5px 10px;
position: absolute;
min-width: 10em;
}
.ProseMirror-prompt-buttons {
margin-top: 5px;
display: none;
}
#editor,
.editor {
background: white;
color: black;
background-clip: padding-box;
border-radius: 4px;
border: 2px solid rgba(0, 0, 0, 0.2);
padding: 5px 0;
margin-bottom: 23px;
}
.ProseMirror p:first-child,
.ProseMirror h1:first-child,
.ProseMirror h2:first-child,
.ProseMirror h3:first-child,
.ProseMirror h4:first-child,
.ProseMirror h5:first-child,
.ProseMirror h6:first-child {
margin-top: 10px;
}
.ProseMirror {
padding: 8px 8px 4px 14px;
line-height: 1.2;
outline: none;
}
.ProseMirror p {
margin-bottom: 1em;
}
.remirror-editor-wrapper .remirror-editor { .remirror-editor-wrapper .remirror-editor {
min-height: 150px; min-height: 150px;
@ -371,76 +82,16 @@ img.ProseMirror-separator {
padding-top: 8px; padding-top: 8px;
} }
/* heading styling */
.remirror-editor-wrapper h1 {
font-size: 2rem;
}
.remirror-editor-wrapper h2 {
font-size: 1.5rem;
}
.remirror-editor-wrapper h3 {
font-size: 1.17rem;
}
/* end heading styling */
/* list styling */
.remirror-editor-wrapper ul {
list-style-type: disc;
}
.remirror-editor-wrapper ol {
list-style-type: decimal;
}
/* end list styling */
/* table styling */
.remirror-editor-wrapper table {
border-collapse: collapse;
}
.remirror-editor-wrapper table td {
border: 1px solid #000;
}
/* end table styling */
/* link styling */
.remirror-floating-popover {
z-index: 20 !important;
}
.remirror-floating-popover input {
font-size: 0.75rem;
border-radius: 5px;
padding: 5px;
border: 1px solid #a8a6a6;
box-shadow: 1px 1px 5px #c0bebe;
outline: none;
}
.remirror-editor-wrapper a {
color: blue;
text-decoration: underline;
}
/* end link styling */
/* format buttons styling */
.MuiButtonBase-root { .MuiButtonBase-root {
border: none !important; border: none !important;
border-radius: 0.25rem !important; border-radius: 0.25rem !important;
padding: 0.25rem !important; padding: 0.25rem !important;
} }
.MuiButtonBase-root:hover {
background-color: rgb(229 231 235);
}
.MuiButtonBase-root svg { .MuiButtonBase-root svg {
fill: #000 !important; fill: rgb(var(--color-text-base)) !important;
} }
.MuiButtonBase-root.Mui-selected { .MuiButtonBase-root.Mui-selected, .MuiButtonBase-root:hover {
background-color: rgb(229 231 235) !important; background-color: rgb(var(--color-bg-base)) !important;
} }
/* end format buttons styling */

View File

@ -6,6 +6,10 @@ function withOpacity(variableName) {
}; };
} }
function convertToRGB(variableName) {
return `rgb(var(${variableName}))`;
}
module.exports = { module.exports = {
darkMode: "class", darkMode: "class",
content: ["./pages/**/*.tsx", "./components/**/*.tsx", "./layouts/**/*.tsx", "./ui/**/*.tsx"], content: ["./pages/**/*.tsx", "./components/**/*.tsx", "./layouts/**/*.tsx", "./ui/**/*.tsx"],
@ -49,9 +53,32 @@ module.exports = {
"100%": { right: "0" }, "100%": { right: "0" },
}, },
}, },
typography: ({ theme }) => ({
brand: {
css: {
"--tw-prose-body": `${convertToRGB("--color-text-base")}`,
"--tw-prose-p": `${convertToRGB("--color-text-base")}`,
"--tw-prose-headings": `${convertToRGB("--color-text-base")}`,
"--tw-prose-lead": `${convertToRGB("--color-text-base")}`,
"--tw-prose-links": `${convertToRGB("--color-accent")}`,
"--tw-prose-bold": `${convertToRGB("--color-text-base")}`,
"--tw-prose-counters": `${convertToRGB("--color-text-base")}`,
"--tw-prose-bullets": `${convertToRGB("--color-text-base")}`,
"--tw-prose-hr": `${convertToRGB("--color-text-base")}`,
"--tw-prose-quotes": `${convertToRGB("--color-text-base")}`,
"--tw-prose-quote-borders": `${convertToRGB("--color-border")}`,
"--tw-prose-code": `${convertToRGB("--color-text-base")}`,
"--tw-prose-pre-code": `${convertToRGB("--color-text-base")}`,
"--tw-prose-pre-bg": `${convertToRGB("--color-bg-base")}`,
"--tw-prose-th-borders": `${convertToRGB("--color-border")}`,
"--tw-prose-td-borders": `${convertToRGB("--color-border")}`,
},
},
}),
}, },
fontFamily: { fontFamily: {
custom: ["Inter", "sans-serif"], custom: ["Inter", "sans-serif"],
}, },
}, },
plugins: [require("@tailwindcss/typography")],
}; };

5476
yarn.lock

File diff suppressed because it is too large Load Diff