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 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

View File

@ -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",

View File

@ -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}
/>
)}
/>

View File

@ -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 && (

View File

@ -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 ${

View File

@ -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>",
});
};

View File

@ -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

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 { 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;

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
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>
);
};

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

View File

@ -1,205 +1,59 @@
.ProseMirror {
position: relative;
.empty-node::after {
content: attr(data-placeholder);
color: #ccc;
font-style: italic;
position: absolute;
pointer-events: none;
top: 15px;
margin-left: 1px;
z-index: -1;
}
.ProseMirror {
position: relative;
word-wrap: break-word;
white-space: pre-wrap;
white-space: break-spaces;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */
}
.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 {
-moz-tab-size: 4;
tab-size: 4;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
outline: none;
}
li.ProseMirror-selectednode:after {
content: "";
position: absolute;
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;
cursor: text;
line-height: 1.2;
font-family: inherit;
font-size: 14px;
color: inherit;
-moz-box-sizing: border-box;
box-sizing: border-box;
overflow: visible;
appearance: textfield;
-webkit-appearance: textfield;
-moz-appearance: textfield;
}
.ProseMirror-icon {
display: inline-block;
line-height: 0.8;
vertical-align: -2px; /* Compensate for padding */
padding: 2px 8px;
vertical-align: -2px;
color: #666;
cursor: pointer;
margin: 0 3px;
padding: 3px 8px;
border-radius: 3px;
border: 1px solid transparent;
transition: background 50ms ease-in-out;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
.ProseMirror-menu-disabled.ProseMirror-icon {
opacity: 0.3;
cursor: default;
pointer-events: none;
}
.ProseMirror-icon svg {
@ -210,149 +64,6 @@ img.ProseMirror-separator {
.ProseMirror-icon span {
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 {
min-height: 150px;
@ -371,76 +82,16 @@ img.ProseMirror-separator {
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 {
border: none !important;
border-radius: 0.25rem !important;
padding: 0.25rem !important;
}
.MuiButtonBase-root:hover {
background-color: rgb(229 231 235);
}
.MuiButtonBase-root svg {
fill: #000 !important;
fill: rgb(var(--color-text-base)) !important;
}
.MuiButtonBase-root.Mui-selected {
background-color: rgb(229 231 235) !important;
.MuiButtonBase-root.Mui-selected, .MuiButtonBase-root:hover {
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 = {
darkMode: "class",
content: ["./pages/**/*.tsx", "./components/**/*.tsx", "./layouts/**/*.tsx", "./ui/**/*.tsx"],
@ -49,9 +53,32 @@ module.exports = {
"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: {
custom: ["Inter", "sans-serif"],
},
},
plugins: [require("@tailwindcss/typography")],
};

5476
yarn.lock

File diff suppressed because it is too large Load Diff