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:
Sachin Chaurasiya 2023-09-25 14:32:43 +05:30 committed by GitHub
parent 621afae8d4
commit e08a3dc7ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 297 additions and 36 deletions

View File

@ -15,29 +15,53 @@ import { EDITOR_OPTIONS } from 'constants/BlockEditor.constants';
import { isEmpty, isNil } from 'lodash'; import { isEmpty, isNil } from 'lodash';
import React, { FC, useEffect, useState } from 'react'; import React, { FC, useEffect, useState } from 'react';
import tippy, { Instance, Props } from 'tippy.js'; import tippy, { Instance, Props } from 'tippy.js';
import {
getBackendFormat,
getFrontEndFormat,
HTMLToMarkdown,
MarkdownToHTMLConverter,
} from 'utils/FeedUtils';
import './block-editor.less'; import './block-editor.less';
import BubbleMenu from './BubbleMenu/BubbleMenu'; import BubbleMenu from './BubbleMenu/BubbleMenu';
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 BlockEditorProps { export interface BlockEditorProps {
// should be markdown string
content?: string; content?: string;
editable?: boolean; editable?: boolean;
// will be call with markdown content
onChange?: (content: string) => void;
} }
const BlockEditor: FC<BlockEditorProps> = ({ const BlockEditor: FC<BlockEditorProps> = ({
content = '', content = '',
editable = true, editable = true,
onChange,
}) => { }) => {
const [isLinkModalOpen, setIsLinkModalOpen] = 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 }) {
const htmlContent = editor.getHTML();
const markdown = HTMLToMarkdown.turndown(htmlContent);
const backendFormat = getBackendFormat(markdown);
onChange?.(backendFormat);
},
}); });
const handleLinkToggle = () => { const handleLinkToggle = () => {
setIsLinkModalOpen((prev) => !prev); setIsLinkModalOpen((prev) => !prev);
}; };
const handleImageToggle = () => {
setIsImageModalOpen((prev) => !prev);
};
const handleLinkCancel = () => { const handleLinkCancel = () => {
handleLinkToggle(); 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) && ( const menus = !isNil(editor) && (
<BubbleMenu editor={editor} toggleLink={handleLinkToggle} /> <BubbleMenu editor={editor} toggleLink={handleLinkToggle} />
); );
@ -145,7 +179,10 @@ const BlockEditor: FC<BlockEditorProps> = ({
// mentioned here https://github.com/ueberdosis/tiptap/issues/3764#issuecomment-1546854730 // mentioned here https://github.com/ueberdosis/tiptap/issues/3764#issuecomment-1546854730
setTimeout(() => { setTimeout(() => {
if (content !== undefined) { if (content !== undefined) {
editor.commands.setContent(content); const htmlContent = MarkdownToHTMLConverter.makeHtml(
getFrontEndFormat(content)
);
editor.commands.setContent(htmlContent);
} }
}); });
}, [content, editor]); }, [content, editor]);
@ -175,6 +212,13 @@ const BlockEditor: FC<BlockEditorProps> = ({
} }
/> />
)} )}
{isImageModalOpen && (
<ImageModal
isOpen={isImageModalOpen}
onCancel={handleImageToggle}
onSave={handleAddImage}
/>
)}
<div className="block-editor-wrapper"> <div className="block-editor-wrapper">
<EditorContent editor={editor} onMouseDown={handleLinkPopup} /> <EditorContent editor={editor} onMouseDown={handleLinkPopup} />
{menus} {menus}

View File

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

View File

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

View File

@ -15,8 +15,9 @@ import tippy, { Instance, Props } from 'tippy.js';
import { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion'; import { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion';
import { WILD_CARD_CHAR } from 'constants/char.constants'; 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 { getSearchedUsers, getUserSuggestions } from 'rest/miscAPI';
import { buildMentionLink } from 'utils/FeedUtils';
import { ExtensionRef } from '../BlockEditor.interface'; import { ExtensionRef } from '../BlockEditor.interface';
import MentionList from './MentionList'; import MentionList from './MentionList';
@ -31,10 +32,10 @@ export const mentionSuggestion = () => ({
name: hit._source.name, name: hit._source.name,
label: hit._source.displayName, label: hit._source.displayName,
fqn: hit._source.fullyQualifiedName, fqn: hit._source.fullyQualifiedName,
href: href: buildMentionLink(
hit._source.entityType === 'user' ENTITY_URL_MAP[hit._source.entityType as EntityUrlMapType],
? getUserPath(hit._source.fullyQualifiedName ?? '') hit._source.name
: getTeamAndUserDetailsPath(hit._source.fullyQualifiedName ?? ''), ),
type: hit._source.entityType, type: hit._source.entityType,
})); }));
} else { } else {
@ -46,10 +47,10 @@ export const mentionSuggestion = () => ({
name: hit._source.name, name: hit._source.name,
label: hit._source.displayName, label: hit._source.displayName,
fqn: hit._source.fullyQualifiedName, fqn: hit._source.fullyQualifiedName,
href: href: buildMentionLink(
hit._source.entityType === 'user' ENTITY_URL_MAP[hit._source.entityType as EntityUrlMapType],
? getUserPath(hit._source.fullyQualifiedName ?? '') hit._source.name
: getTeamAndUserDetailsPath(hit._source.fullyQualifiedName ?? ''), ),
type: hit._source.entityType, type: hit._source.entityType,
})); }));
} }

View File

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

View File

@ -32,6 +32,7 @@ const LinkModal: FC<LinkModalProps> = ({ isOpen, data, onSave, onCancel }) => {
return ( return (
<Modal <Modal
className="block-editor-link-modal" className="block-editor-link-modal"
maskClosable={false}
okButtonProps={{ okButtonProps={{
htmlType: 'submit', htmlType: 'submit',
id: 'link-form', id: 'link-form',

View File

@ -232,7 +232,8 @@
} }
} }
.block-editor-link-modal { .block-editor-link-modal,
.block-editor-image-modal {
.ant-modal-content { .ant-modal-content {
.ant-modal-body { .ant-modal-body {
padding: 16px; padding: 16px;

View File

@ -16,8 +16,10 @@ import Placeholder from '@tiptap/extension-placeholder';
import TaskItem from '@tiptap/extension-task-item'; import TaskItem from '@tiptap/extension-task-item';
import TaskList from '@tiptap/extension-task-list'; import TaskList from '@tiptap/extension-task-list';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import DiffView from 'components/BlockEditor/Extensions/diffView';
import { Hashtag } from 'components/BlockEditor/Extensions/hashtag'; import { Hashtag } from 'components/BlockEditor/Extensions/hashtag';
import { hashtagSuggestion } from 'components/BlockEditor/Extensions/hashtag/hashtagSuggestion'; import { hashtagSuggestion } from 'components/BlockEditor/Extensions/hashtag/hashtagSuggestion';
import { Image } from 'components/BlockEditor/Extensions/image';
import { Mention } from 'components/BlockEditor/Extensions/mention'; import { Mention } from 'components/BlockEditor/Extensions/mention';
import { mentionSuggestion } from 'components/BlockEditor/Extensions/mention/mentionSuggestions'; import { mentionSuggestion } from 'components/BlockEditor/Extensions/mention/mentionSuggestions';
import slashCommand from 'components/BlockEditor/Extensions/slashCommand'; import slashCommand from 'components/BlockEditor/Extensions/slashCommand';
@ -98,6 +100,11 @@ export const EDITOR_OPTIONS: Partial<EditorOptions> = {
Hashtag.configure({ Hashtag.configure({
suggestion: hashtagSuggestion(), suggestion: hashtagSuggestion(),
}), }),
DiffView,
Image.configure({
allowBase64: true,
inline: true,
}),
], ],
enableInputRules: [ enableInputRules: [
@ -112,4 +119,10 @@ export const EDITOR_OPTIONS: Partial<EditorOptions> = {
'orderedList', 'orderedList',
'strike', 'strike',
], ],
parseOptions: {
preserveWhitespace: 'full',
},
}; };
export const IMAGE_INPUT_REGEX =
/(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/;

View File

@ -22,11 +22,13 @@ export const teamsLinkRegEx = /\((.+?\/\/.+?)\/(.+?\/.+?\/.+?)\/(.+?)\)/;
export const entityLinkRegEx = /<#E::([^<>]+?)::([^<>]+?)>/g; export const entityLinkRegEx = /<#E::([^<>]+?)::([^<>]+?)>/g;
export const entityRegex = /<#E::([^<>]+?)::([^<>]+?)\|(\[(.+?)?\]\((.+?)?\))>/; export const entityRegex = /<#E::([^<>]+?)::([^<>]+?)\|(\[(.+?)?\]\((.+?)?\))>/;
export const entityUrlMap = { export const ENTITY_URL_MAP = {
team: 'settings/members/teams', team: 'settings/members/teams',
user: 'users', user: 'users',
}; };
export type EntityUrlMapType = keyof typeof ENTITY_URL_MAP;
export const confirmStateInitialValue = { export const confirmStateInitialValue = {
state: false, state: false,
threadId: undefined, threadId: undefined,

View File

@ -109,7 +109,11 @@ export default class EntityLink {
* @param string entityFqn * @param string entityFqn
* @returns entityLink * @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}>`; return `<#E${ENTITY_LINK_SEPARATOR}${entityType}${ENTITY_LINK_SEPARATOR}${entityFqn}>`;
} }
} }

View File

@ -14,12 +14,9 @@
import { Tooltip } from 'antd'; import { Tooltip } from 'antd';
import { DE_ACTIVE_COLOR } from 'constants/constants'; import { DE_ACTIVE_COLOR } from 'constants/constants';
import { t } from 'i18next'; import { t } from 'i18next';
import { isUndefined } from 'lodash';
import React from 'react'; import React from 'react';
import { ReactComponent as IconComments } from '../assets/svg/comment.svg'; import { ReactComponent as IconComments } from '../assets/svg/comment.svg';
import { entityUrlMap } from '../constants/Feeds.constants';
import { ThreadType } from '../generated/entity/feed/thread'; import { ThreadType } from '../generated/entity/feed/thread';
import { EntityReference } from '../generated/entity/teams/user';
import { getEntityFeedLink } from './EntityUtils'; import { getEntityFeedLink } from './EntityUtils';
const iconsProps = { const iconsProps = {
@ -58,19 +55,3 @@ export const getFieldThreadElement = (
</Tooltip> </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}`;
}
};

View File

@ -42,7 +42,8 @@ import {
entityLinkRegEx, entityLinkRegEx,
EntityRegEx, EntityRegEx,
entityRegex, entityRegex,
entityUrlMap, EntityUrlMapType,
ENTITY_URL_MAP,
hashtagRegEx, hashtagRegEx,
linkRegEx, linkRegEx,
mentionRegEx, mentionRegEx,
@ -219,7 +220,7 @@ export async function suggestions(
id: hit._id, id: hit._id,
value: name, value: name,
link: buildMentionLink( link: buildMentionLink(
entityUrlMap[entityType as keyof typeof entityUrlMap], ENTITY_URL_MAP[entityType as EntityUrlMapType],
hit._source.name hit._source.name
), ),
name: hit._source.name, name: hit._source.name,
@ -246,7 +247,7 @@ export async function suggestions(
id: hit._id, id: hit._id,
value: name, value: name,
link: buildMentionLink( link: buildMentionLink(
entityUrlMap[entityType as keyof typeof entityUrlMap], ENTITY_URL_MAP[entityType as EntityUrlMapType],
hit._source.name hit._source.name
), ),
name: hit._source.name, name: hit._source.name,
@ -352,7 +353,7 @@ export const getBackendFormat = (message: string) => {
const hashtagList = [...new Set(getHashTagList(message) ?? [])]; const hashtagList = [...new Set(getHashTagList(message) ?? [])];
const mentionDetails = mentionList.map((m) => getEntityDetail(m) ?? []); const mentionDetails = mentionList.map((m) => getEntityDetail(m) ?? []);
const hashtagDetails = hashtagList.map((h) => getEntityDetail(h) ?? []); const hashtagDetails = hashtagList.map((h) => getEntityDetail(h) ?? []);
const urlEntries = Object.entries(entityUrlMap); const urlEntries = Object.entries(ENTITY_URL_MAP);
mentionList.forEach((m, i) => { mentionList.forEach((m, i) => {
const updatedDetails = mentionDetails[i].slice(-2); const updatedDetails = mentionDetails[i].slice(-2);
@ -594,6 +595,9 @@ export const entityDisplayName = (entityType: string, entityFQN: string) => {
export const MarkdownToHTMLConverter = new Showdown.Converter({ export const MarkdownToHTMLConverter = new Showdown.Converter({
strikethrough: true, strikethrough: true,
tables: true,
tasklists: true,
simpleLineBreaks: true,
}); });
export const getFeedPanelHeaderText = ( export const getFeedPanelHeaderText = (