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

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

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 (
<Modal
className="block-editor-link-modal"
maskClosable={false}
okButtonProps={{
htmlType: 'submit',
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-body {
padding: 16px;

View File

@ -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+)["'])?\))$/;

View File

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

View File

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

View File

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

View File

@ -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 = (