mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-09-26 17:34:41 +00:00
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:
parent
6060c3975d
commit
8d90b54d49
@ -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;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user