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