chore(ui): focus editor when wrapper area is captured (#13525)

* chore(ui): focus editor when wrapper area is captured

* chore: use ref approach for handling editor focus
This commit is contained in:
Sachin Chaurasiya 2023-10-11 21:54:31 +05:30 committed by GitHub
parent 6060c3975d
commit 8d90b54d49
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

View File

@ -12,7 +12,12 @@
*/ */
import { Editor, EditorContent, ReactRenderer, useEditor } from '@tiptap/react'; import { Editor, EditorContent, ReactRenderer, useEditor } from '@tiptap/react';
import { isEmpty, isNil } from 'lodash'; import { isEmpty, isNil } from 'lodash';
import React, { FC, useEffect, useState } from 'react'; import React, {
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from 'react';
import tippy, { Instance, Props } from 'tippy.js'; import tippy, { Instance, Props } from 'tippy.js';
import { EDITOR_OPTIONS } from '../../constants/BlockEditor.constants'; import { EDITOR_OPTIONS } from '../../constants/BlockEditor.constants';
import { import {
@ -27,6 +32,9 @@ import ImageModal, { ImageData } from './ImageModal/ImageModal';
import LinkModal, { LinkData } from './LinkModal/LinkModal'; import LinkModal, { LinkData } from './LinkModal/LinkModal';
import LinkPopup from './LinkPopup/LinkPopup'; import LinkPopup from './LinkPopup/LinkPopup';
export interface BlockEditorRef {
onFocus: () => void;
}
export interface BlockEditorProps { export interface BlockEditorProps {
// should be markdown string // should be markdown string
content?: string; content?: string;
@ -35,196 +43,208 @@ export interface BlockEditorProps {
onChange?: (content: string) => void; onChange?: (content: string) => void;
} }
const BlockEditor: FC<BlockEditorProps> = ({ const BlockEditor = forwardRef<BlockEditorRef, BlockEditorProps>(
content = '', ({ content = '', editable = true, onChange }, ref) => {
editable = true, const [isLinkModalOpen, setIsLinkModalOpen] = useState<boolean>(false);
onChange, const [isImageModalOpen, setIsImageModalOpen] = useState<boolean>(false);
}) => {
const [isLinkModalOpen, setIsLinkModalOpen] = useState<boolean>(false);
const [isImageModalOpen, setIsImageModalOpen] = useState<boolean>(false);
const editor = useEditor({ const editor = useEditor({
...EDITOR_OPTIONS, ...EDITOR_OPTIONS,
onUpdate({ editor }) { onUpdate({ editor }) {
const htmlContent = editor.getHTML(); const htmlContent = editor.getHTML();
const markdown = HTMLToMarkdown.turndown(htmlContent); const markdown = HTMLToMarkdown.turndown(htmlContent);
const backendFormat = getBackendFormat(markdown); const backendFormat = getBackendFormat(markdown);
onChange?.(backendFormat); onChange?.(backendFormat);
}, },
});
const handleLinkToggle = () => {
setIsLinkModalOpen((prev) => !prev);
};
const handleImageToggle = () => {
setIsImageModalOpen((prev) => !prev);
};
const handleLinkCancel = () => {
handleLinkToggle();
if (!isNil(editor)) {
editor?.chain().blur().run();
}
};
const handleLinkSave = (values: LinkData, op: 'edit' | 'add') => {
if (isNil(editor)) {
return;
}
// set the link
if (op === 'edit') {
editor
?.chain()
.focus()
.extendMarkRange('link')
.updateAttributes('link', {
href: values.href,
})
.run();
}
if (op === 'add') {
editor?.chain().focus().setLink({ href: values.href }).run();
}
// move cursor at the end
editor?.chain().selectTextblockEnd().run();
// close the modal
handleLinkToggle();
};
const handleUnlink = () => {
if (isNil(editor)) {
return;
}
editor?.chain().focus().extendMarkRange('link').unsetLink().run();
// move cursor at the end
editor?.chain().selectTextblockEnd().run();
};
const handleLinkPopup = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
let popup: Instance<Props>[] = [];
let component: ReactRenderer;
const target = e.target as HTMLElement;
const dataType = target.getAttribute('data-type');
if (['mention', 'hashtag'].includes(dataType ?? '')) {
return;
}
if (target.nodeName === 'A') {
const href = target.getAttribute('href');
component = new ReactRenderer(LinkPopup, {
editor: editor as Editor,
props: {
href,
handleLinkToggle: () => {
handleLinkToggle();
if (!isEmpty(popup)) {
popup[0].hide();
}
},
handleUnlink: () => {
handleUnlink();
if (!isEmpty(popup)) {
popup[0].hide();
}
},
},
});
popup = tippy('body', {
getReferenceClientRect: () => target.getBoundingClientRect(),
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'top',
hideOnClick: true,
});
} else {
if (!isEmpty(popup)) {
popup[0].hide();
}
}
};
const handleAddImage = (values: ImageData) => {
if (isNil(editor)) {
return;
}
editor.chain().focus().setImage({ src: values.src }).run();
handleImageToggle();
};
const menus = !isNil(editor) && (
<BubbleMenu editor={editor} toggleLink={handleLinkToggle} />
);
useEffect(() => {
if (isNil(editor) || editor.isDestroyed || content === undefined) {
return;
}
// We use setTimeout to avoid any flushSync console errors as
// mentioned here https://github.com/ueberdosis/tiptap/issues/3764#issuecomment-1546854730
setTimeout(() => {
if (content !== undefined) {
const htmlContent = MarkdownToHTMLConverter.makeHtml(
getFrontEndFormat(content)
);
editor.commands.setContent(htmlContent);
}
}); });
}, [content, editor]);
useEffect(() => { const handleLinkToggle = () => {
if (isNil(editor) || editor.isDestroyed || editor.isEditable === editable) { setIsLinkModalOpen((prev) => !prev);
return; };
} const handleImageToggle = () => {
setIsImageModalOpen((prev) => !prev);
};
// We use setTimeout to avoid any flushSync console errors as const handleLinkCancel = () => {
// mentioned here https://github.com/ueberdosis/tiptap/issues/3764#issuecomment-1546854730 handleLinkToggle();
setTimeout(() => editor.setEditable(editable)); if (!isNil(editor)) {
}, [editable, editor]); editor?.chain().blur().run();
}
};
return ( const handleLinkSave = (values: LinkData, op: 'edit' | 'add') => {
<> if (isNil(editor)) {
{isLinkModalOpen && ( return;
<LinkModal }
data={{ href: editor?.getAttributes('link').href }} // set the link
isOpen={isLinkModalOpen} if (op === 'edit') {
onCancel={handleLinkCancel} editor
onSave={(values) => ?.chain()
handleLinkSave( .focus()
values, .extendMarkRange('link')
editor?.getAttributes('link').href ? 'edit' : 'add' .updateAttributes('link', {
) href: values.href,
} })
/> .run();
)} }
{isImageModalOpen && (
<ImageModal if (op === 'add') {
isOpen={isImageModalOpen} editor?.chain().focus().setLink({ href: values.href }).run();
onCancel={handleImageToggle} }
onSave={handleAddImage}
/> // move cursor at the end
)} editor?.chain().selectTextblockEnd().run();
<div className="block-editor-wrapper">
<EditorContent editor={editor} onMouseDown={handleLinkPopup} /> // close the modal
{menus} handleLinkToggle();
</div> };
</>
); const handleUnlink = () => {
}; if (isNil(editor)) {
return;
}
editor?.chain().focus().extendMarkRange('link').unsetLink().run();
// move cursor at the end
editor?.chain().selectTextblockEnd().run();
};
const handleLinkPopup = (
e: React.MouseEvent<HTMLDivElement, MouseEvent>
) => {
let popup: Instance<Props>[] = [];
let component: ReactRenderer;
const target = e.target as HTMLElement;
const dataType = target.getAttribute('data-type');
if (['mention', 'hashtag'].includes(dataType ?? '')) {
return;
}
if (target.nodeName === 'A') {
const href = target.getAttribute('href');
component = new ReactRenderer(LinkPopup, {
editor: editor as Editor,
props: {
href,
handleLinkToggle: () => {
handleLinkToggle();
if (!isEmpty(popup)) {
popup[0].hide();
}
},
handleUnlink: () => {
handleUnlink();
if (!isEmpty(popup)) {
popup[0].hide();
}
},
},
});
popup = tippy('body', {
getReferenceClientRect: () => target.getBoundingClientRect(),
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'top',
hideOnClick: true,
});
} else {
if (!isEmpty(popup)) {
popup[0].hide();
}
}
};
const handleAddImage = (values: ImageData) => {
if (isNil(editor)) {
return;
}
editor.chain().focus().setImage({ src: values.src }).run();
handleImageToggle();
};
useImperativeHandle(ref, () => ({
onFocus() {
if (!isNil(editor) && !editor.isFocused) {
editor.commands.focus('end');
}
},
}));
const menus = !isNil(editor) && (
<BubbleMenu editor={editor} toggleLink={handleLinkToggle} />
);
useEffect(() => {
if (isNil(editor) || editor.isDestroyed || content === undefined) {
return;
}
// We use setTimeout to avoid any flushSync console errors as
// mentioned here https://github.com/ueberdosis/tiptap/issues/3764#issuecomment-1546854730
setTimeout(() => {
if (content !== undefined) {
const htmlContent = MarkdownToHTMLConverter.makeHtml(
getFrontEndFormat(content)
);
editor.commands.setContent(htmlContent);
}
});
}, [content, editor]);
useEffect(() => {
if (
isNil(editor) ||
editor.isDestroyed ||
editor.isEditable === editable
) {
return;
}
// We use setTimeout to avoid any flushSync console errors as
// mentioned here https://github.com/ueberdosis/tiptap/issues/3764#issuecomment-1546854730
setTimeout(() => editor.setEditable(editable));
}, [editable, editor]);
return (
<>
{isLinkModalOpen && (
<LinkModal
data={{ href: editor?.getAttributes('link').href }}
isOpen={isLinkModalOpen}
onCancel={handleLinkCancel}
onSave={(values) =>
handleLinkSave(
values,
editor?.getAttributes('link').href ? 'edit' : 'add'
)
}
/>
)}
{isImageModalOpen && (
<ImageModal
isOpen={isImageModalOpen}
onCancel={handleImageToggle}
onSave={handleAddImage}
/>
)}
<div className="block-editor-wrapper">
<EditorContent editor={editor} onMouseDown={handleLinkPopup} />
{menus}
</div>
</>
);
}
);
export default BlockEditor; export default BlockEditor;