mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-07-18 22:53:09 +00:00
feat(ui): add html to markdown conversion and vice versa in block editor (#13122)
* feat(ui): add html to markdown conversion and vice versa in block editor * chore: update mention suggestion logic * chore: add field support in entityLink * chore: set focus after setting the content * revert: chore: set focus after setting the content * chore: change the prop name * chore: add options to setContent * chore: move parsing option to constant * chore: add diff view custom node * chore: add custom extension for image * chore: address comment * chore: address comment
This commit is contained in:
parent
621afae8d4
commit
e08a3dc7ad
@ -15,29 +15,53 @@ import { EDITOR_OPTIONS } from 'constants/BlockEditor.constants';
|
||||
import { isEmpty, isNil } from 'lodash';
|
||||
import React, { FC, useEffect, useState } from 'react';
|
||||
import tippy, { Instance, Props } from 'tippy.js';
|
||||
import {
|
||||
getBackendFormat,
|
||||
getFrontEndFormat,
|
||||
HTMLToMarkdown,
|
||||
MarkdownToHTMLConverter,
|
||||
} from 'utils/FeedUtils';
|
||||
import './block-editor.less';
|
||||
import BubbleMenu from './BubbleMenu/BubbleMenu';
|
||||
import ImageModal, { ImageData } from './ImageModal/ImageModal';
|
||||
import LinkModal, { LinkData } from './LinkModal/LinkModal';
|
||||
import LinkPopup from './LinkPopup/LinkPopup';
|
||||
|
||||
export interface BlockEditorProps {
|
||||
// should be markdown string
|
||||
content?: string;
|
||||
editable?: boolean;
|
||||
// will be call with markdown content
|
||||
onChange?: (content: string) => void;
|
||||
}
|
||||
|
||||
const BlockEditor: FC<BlockEditorProps> = ({
|
||||
content = '',
|
||||
editable = true,
|
||||
onChange,
|
||||
}) => {
|
||||
const [isLinkModalOpen, setIsLinkModalOpen] = useState<boolean>(false);
|
||||
const [isImageModalOpen, setIsImageModalOpen] = useState<boolean>(false);
|
||||
|
||||
const editor = useEditor({
|
||||
...EDITOR_OPTIONS,
|
||||
onUpdate({ editor }) {
|
||||
const htmlContent = editor.getHTML();
|
||||
|
||||
const markdown = HTMLToMarkdown.turndown(htmlContent);
|
||||
|
||||
const backendFormat = getBackendFormat(markdown);
|
||||
|
||||
onChange?.(backendFormat);
|
||||
},
|
||||
});
|
||||
|
||||
const handleLinkToggle = () => {
|
||||
setIsLinkModalOpen((prev) => !prev);
|
||||
};
|
||||
const handleImageToggle = () => {
|
||||
setIsImageModalOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleLinkCancel = () => {
|
||||
handleLinkToggle();
|
||||
@ -132,6 +156,16 @@ const BlockEditor: FC<BlockEditorProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
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} />
|
||||
);
|
||||
@ -145,7 +179,10 @@ const BlockEditor: FC<BlockEditorProps> = ({
|
||||
// mentioned here https://github.com/ueberdosis/tiptap/issues/3764#issuecomment-1546854730
|
||||
setTimeout(() => {
|
||||
if (content !== undefined) {
|
||||
editor.commands.setContent(content);
|
||||
const htmlContent = MarkdownToHTMLConverter.makeHtml(
|
||||
getFrontEndFormat(content)
|
||||
);
|
||||
editor.commands.setContent(htmlContent);
|
||||
}
|
||||
});
|
||||
}, [content, editor]);
|
||||
@ -175,6 +212,13 @@ const BlockEditor: FC<BlockEditorProps> = ({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isImageModalOpen && (
|
||||
<ImageModal
|
||||
isOpen={isImageModalOpen}
|
||||
onCancel={handleImageToggle}
|
||||
onSave={handleAddImage}
|
||||
/>
|
||||
)}
|
||||
<div className="block-editor-wrapper">
|
||||
<EditorContent editor={editor} onMouseDown={handleLinkPopup} />
|
||||
{menus}
|
||||
|
@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2023 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { mergeAttributes, Node } from '@tiptap/core';
|
||||
|
||||
export default Node.create({
|
||||
name: 'diffView',
|
||||
content: 'inline*',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
class: {
|
||||
default: '',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'diff-view',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['diff-view', mergeAttributes(HTMLAttributes), 0];
|
||||
},
|
||||
});
|
@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright 2023 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { mergeAttributes, Node, nodeInputRule } from '@tiptap/core';
|
||||
import { IMAGE_INPUT_REGEX } from 'constants/BlockEditor.constants';
|
||||
|
||||
export interface ImageOptions {
|
||||
inline: boolean;
|
||||
allowBase64: boolean;
|
||||
HTMLAttributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
image: {
|
||||
/**
|
||||
* Add an image
|
||||
*/
|
||||
setImage: (options: {
|
||||
src: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
}) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Image = Node.create<ImageOptions>({
|
||||
name: 'image',
|
||||
selectable: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
inline: false,
|
||||
allowBase64: false,
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
inline() {
|
||||
return this.options.inline;
|
||||
},
|
||||
|
||||
group() {
|
||||
return this.options.inline ? 'inline' : 'block';
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
alt: {
|
||||
default: null,
|
||||
},
|
||||
title: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: this.options.allowBase64
|
||||
? 'img[src]'
|
||||
: 'img[src]:not([src^="data:"])',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'img',
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setImage:
|
||||
(options) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: options,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
nodeInputRule({
|
||||
find: IMAGE_INPUT_REGEX,
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
const [, , alt, src, title] = match;
|
||||
|
||||
return { src, alt, title };
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
@ -15,8 +15,9 @@ import tippy, { Instance, Props } from 'tippy.js';
|
||||
|
||||
import { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion';
|
||||
import { WILD_CARD_CHAR } from 'constants/char.constants';
|
||||
import { getTeamAndUserDetailsPath, getUserPath } from 'constants/constants';
|
||||
import { EntityUrlMapType, ENTITY_URL_MAP } from 'constants/Feeds.constants';
|
||||
import { getSearchedUsers, getUserSuggestions } from 'rest/miscAPI';
|
||||
import { buildMentionLink } from 'utils/FeedUtils';
|
||||
import { ExtensionRef } from '../BlockEditor.interface';
|
||||
import MentionList from './MentionList';
|
||||
|
||||
@ -31,10 +32,10 @@ export const mentionSuggestion = () => ({
|
||||
name: hit._source.name,
|
||||
label: hit._source.displayName,
|
||||
fqn: hit._source.fullyQualifiedName,
|
||||
href:
|
||||
hit._source.entityType === 'user'
|
||||
? getUserPath(hit._source.fullyQualifiedName ?? '')
|
||||
: getTeamAndUserDetailsPath(hit._source.fullyQualifiedName ?? ''),
|
||||
href: buildMentionLink(
|
||||
ENTITY_URL_MAP[hit._source.entityType as EntityUrlMapType],
|
||||
hit._source.name
|
||||
),
|
||||
type: hit._source.entityType,
|
||||
}));
|
||||
} else {
|
||||
@ -46,10 +47,10 @@ export const mentionSuggestion = () => ({
|
||||
name: hit._source.name,
|
||||
label: hit._source.displayName,
|
||||
fqn: hit._source.fullyQualifiedName,
|
||||
href:
|
||||
hit._source.entityType === 'user'
|
||||
? getUserPath(hit._source.fullyQualifiedName ?? '')
|
||||
: getTeamAndUserDetailsPath(hit._source.fullyQualifiedName ?? ''),
|
||||
href: buildMentionLink(
|
||||
ENTITY_URL_MAP[hit._source.entityType as EntityUrlMapType],
|
||||
hit._source.name
|
||||
),
|
||||
type: hit._source.entityType,
|
||||
}));
|
||||
}
|
||||
|
@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2023 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Form, FormProps, Input, Modal } from 'antd';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
export interface ImageData {
|
||||
src: string;
|
||||
}
|
||||
|
||||
export interface LinkModalProps {
|
||||
isOpen: boolean;
|
||||
onSave: (data: ImageData) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const ImageModal: FC<LinkModalProps> = ({ isOpen, onSave, onCancel }) => {
|
||||
const handleSubmit: FormProps<ImageData>['onFinish'] = (values) => {
|
||||
onSave(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="block-editor-image-modal"
|
||||
maskClosable={false}
|
||||
okButtonProps={{
|
||||
htmlType: 'submit',
|
||||
id: 'image-form',
|
||||
form: 'image-form',
|
||||
}}
|
||||
okText="Save"
|
||||
open={isOpen}
|
||||
onCancel={onCancel}>
|
||||
<Form
|
||||
data-testid="image-form"
|
||||
id="image-form"
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}>
|
||||
<Form.Item label="Image link" name="src">
|
||||
<Input autoFocus required />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageModal;
|
@ -32,6 +32,7 @@ const LinkModal: FC<LinkModalProps> = ({ isOpen, data, onSave, onCancel }) => {
|
||||
return (
|
||||
<Modal
|
||||
className="block-editor-link-modal"
|
||||
maskClosable={false}
|
||||
okButtonProps={{
|
||||
htmlType: 'submit',
|
||||
id: 'link-form',
|
||||
|
@ -232,7 +232,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.block-editor-link-modal {
|
||||
.block-editor-link-modal,
|
||||
.block-editor-image-modal {
|
||||
.ant-modal-content {
|
||||
.ant-modal-body {
|
||||
padding: 16px;
|
||||
|
@ -16,8 +16,10 @@ import Placeholder from '@tiptap/extension-placeholder';
|
||||
import TaskItem from '@tiptap/extension-task-item';
|
||||
import TaskList from '@tiptap/extension-task-list';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import DiffView from 'components/BlockEditor/Extensions/diffView';
|
||||
import { Hashtag } from 'components/BlockEditor/Extensions/hashtag';
|
||||
import { hashtagSuggestion } from 'components/BlockEditor/Extensions/hashtag/hashtagSuggestion';
|
||||
import { Image } from 'components/BlockEditor/Extensions/image';
|
||||
import { Mention } from 'components/BlockEditor/Extensions/mention';
|
||||
import { mentionSuggestion } from 'components/BlockEditor/Extensions/mention/mentionSuggestions';
|
||||
import slashCommand from 'components/BlockEditor/Extensions/slashCommand';
|
||||
@ -98,6 +100,11 @@ export const EDITOR_OPTIONS: Partial<EditorOptions> = {
|
||||
Hashtag.configure({
|
||||
suggestion: hashtagSuggestion(),
|
||||
}),
|
||||
DiffView,
|
||||
Image.configure({
|
||||
allowBase64: true,
|
||||
inline: true,
|
||||
}),
|
||||
],
|
||||
|
||||
enableInputRules: [
|
||||
@ -112,4 +119,10 @@ export const EDITOR_OPTIONS: Partial<EditorOptions> = {
|
||||
'orderedList',
|
||||
'strike',
|
||||
],
|
||||
parseOptions: {
|
||||
preserveWhitespace: 'full',
|
||||
},
|
||||
};
|
||||
|
||||
export const IMAGE_INPUT_REGEX =
|
||||
/(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/;
|
||||
|
@ -22,11 +22,13 @@ export const teamsLinkRegEx = /\((.+?\/\/.+?)\/(.+?\/.+?\/.+?)\/(.+?)\)/;
|
||||
export const entityLinkRegEx = /<#E::([^<>]+?)::([^<>]+?)>/g;
|
||||
export const entityRegex = /<#E::([^<>]+?)::([^<>]+?)\|(\[(.+?)?\]\((.+?)?\))>/;
|
||||
|
||||
export const entityUrlMap = {
|
||||
export const ENTITY_URL_MAP = {
|
||||
team: 'settings/members/teams',
|
||||
user: 'users',
|
||||
};
|
||||
|
||||
export type EntityUrlMapType = keyof typeof ENTITY_URL_MAP;
|
||||
|
||||
export const confirmStateInitialValue = {
|
||||
state: false,
|
||||
threadId: undefined,
|
||||
|
@ -109,7 +109,11 @@ export default class EntityLink {
|
||||
* @param string entityFqn
|
||||
* @returns entityLink
|
||||
*/
|
||||
static getEntityLink(entityType: string, entityFqn: string) {
|
||||
static getEntityLink(entityType: string, entityFqn: string, field?: string) {
|
||||
if (field) {
|
||||
return `<#E${ENTITY_LINK_SEPARATOR}${entityType}${ENTITY_LINK_SEPARATOR}${entityFqn}${ENTITY_LINK_SEPARATOR}${field}>`;
|
||||
}
|
||||
|
||||
return `<#E${ENTITY_LINK_SEPARATOR}${entityType}${ENTITY_LINK_SEPARATOR}${entityFqn}>`;
|
||||
}
|
||||
}
|
||||
|
@ -14,12 +14,9 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { DE_ACTIVE_COLOR } from 'constants/constants';
|
||||
import { t } from 'i18next';
|
||||
import { isUndefined } from 'lodash';
|
||||
import React from 'react';
|
||||
import { ReactComponent as IconComments } from '../assets/svg/comment.svg';
|
||||
import { entityUrlMap } from '../constants/Feeds.constants';
|
||||
import { ThreadType } from '../generated/entity/feed/thread';
|
||||
import { EntityReference } from '../generated/entity/teams/user';
|
||||
import { getEntityFeedLink } from './EntityUtils';
|
||||
|
||||
const iconsProps = {
|
||||
@ -58,19 +55,3 @@ export const getFieldThreadElement = (
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const getDefaultValue = (owner: EntityReference) => {
|
||||
const message = t('message.can-you-add-a-description');
|
||||
if (isUndefined(owner)) {
|
||||
return `${message}`;
|
||||
} else {
|
||||
const name = owner.name;
|
||||
const displayName = owner.displayName;
|
||||
const entityType = owner.type;
|
||||
const mention = `<a href=${`/${
|
||||
entityUrlMap[entityType as keyof typeof entityUrlMap]
|
||||
}/${name}`}>@${displayName}</a>`;
|
||||
|
||||
return `${mention} ${message}`;
|
||||
}
|
||||
};
|
||||
|
@ -42,7 +42,8 @@ import {
|
||||
entityLinkRegEx,
|
||||
EntityRegEx,
|
||||
entityRegex,
|
||||
entityUrlMap,
|
||||
EntityUrlMapType,
|
||||
ENTITY_URL_MAP,
|
||||
hashtagRegEx,
|
||||
linkRegEx,
|
||||
mentionRegEx,
|
||||
@ -219,7 +220,7 @@ export async function suggestions(
|
||||
id: hit._id,
|
||||
value: name,
|
||||
link: buildMentionLink(
|
||||
entityUrlMap[entityType as keyof typeof entityUrlMap],
|
||||
ENTITY_URL_MAP[entityType as EntityUrlMapType],
|
||||
hit._source.name
|
||||
),
|
||||
name: hit._source.name,
|
||||
@ -246,7 +247,7 @@ export async function suggestions(
|
||||
id: hit._id,
|
||||
value: name,
|
||||
link: buildMentionLink(
|
||||
entityUrlMap[entityType as keyof typeof entityUrlMap],
|
||||
ENTITY_URL_MAP[entityType as EntityUrlMapType],
|
||||
hit._source.name
|
||||
),
|
||||
name: hit._source.name,
|
||||
@ -352,7 +353,7 @@ export const getBackendFormat = (message: string) => {
|
||||
const hashtagList = [...new Set(getHashTagList(message) ?? [])];
|
||||
const mentionDetails = mentionList.map((m) => getEntityDetail(m) ?? []);
|
||||
const hashtagDetails = hashtagList.map((h) => getEntityDetail(h) ?? []);
|
||||
const urlEntries = Object.entries(entityUrlMap);
|
||||
const urlEntries = Object.entries(ENTITY_URL_MAP);
|
||||
|
||||
mentionList.forEach((m, i) => {
|
||||
const updatedDetails = mentionDetails[i].slice(-2);
|
||||
@ -594,6 +595,9 @@ export const entityDisplayName = (entityType: string, entityFQN: string) => {
|
||||
|
||||
export const MarkdownToHTMLConverter = new Showdown.Converter({
|
||||
strikethrough: true,
|
||||
tables: true,
|
||||
tasklists: true,
|
||||
simpleLineBreaks: true,
|
||||
});
|
||||
|
||||
export const getFeedPanelHeaderText = (
|
||||
|
Loading…
x
Reference in New Issue
Block a user