diff --git a/openmetadata-ui/src/main/resources/ui/package.json b/openmetadata-ui/src/main/resources/ui/package.json index 8c092d98d00..7866e84fc1c 100644 --- a/openmetadata-ui/src/main/resources/ui/package.json +++ b/openmetadata-ui/src/main/resources/ui/package.json @@ -96,6 +96,7 @@ "i18next": "^21.10.0", "i18next-browser-languagedetector": "^6.1.6", "jwt-decode": "^3.1.2", + "katex": "^0.16.10", "less": "^4.1.3", "less-loader": "^11.0.0", "lodash": "^4.17.21", @@ -119,6 +120,7 @@ "react-grid-layout": "^1.4.2", "react-helmet-async": "^1.3.0", "react-i18next": "^11.18.6", + "react-latex-next": "^3.0.0", "react-lazylog": "^4.5.3", "react-oidc": "^1.0.3", "react-papaparse": "^4.1.0", @@ -169,6 +171,7 @@ "@types/diff": "^5.0.2", "@types/dompurify": "^3.0.5", "@types/jest": "^26.0.23", + "@types/katex": "^0.16.7", "@types/lodash": "^4.14.167", "@types/luxon": "^3.0.1", "@types/moment": "^2.13.0", diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/img/ic-format-math-equation.png b/openmetadata-ui/src/main/resources/ui/src/assets/img/ic-format-math-equation.png new file mode 100644 index 00000000000..a2f38aa0ffe Binary files /dev/null and b/openmetadata-ui/src/main/resources/ui/src/assets/img/ic-format-math-equation.png differ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/EditorSlots.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/EditorSlots.tsx index 176a02bcfd2..7ecc0f78c1d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/EditorSlots.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/EditorSlots.tsx @@ -35,10 +35,6 @@ const EditorSlots = forwardRef( const handleLinkCancel = () => { handleLinkToggle(); - if (!isNil(editor)) { - editor.chain().focus().extendMarkRange('link').unsetLink().run(); - editor.chain().blur().run(); - } }; const handleLinkSave = (values: LinkData, op: 'edit' | 'add') => { @@ -98,8 +94,11 @@ const EditorSlots = forwardRef( return; } - if (target.nodeName === 'A') { - const href = target.getAttribute('href'); + + const closestElement = target.closest('a'); + if (target.nodeName === 'A' || closestElement) { + const href = + target.getAttribute('href') || closestElement?.getAttribute('href'); component = new ReactRenderer(LinkPopup, { editor: editor as Editor, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/MathEquation/MathEquation.ts b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/MathEquation/MathEquation.ts new file mode 100644 index 00000000000..d0cbcbdebb3 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/MathEquation/MathEquation.ts @@ -0,0 +1,95 @@ +/* + * Copyright 2024 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 { InputRule, mergeAttributes, Node, nodePasteRule } from '@tiptap/core'; +import { ReactNodeViewRenderer } from '@tiptap/react'; +import { MathEquationComponent } from './MathEquationComponent'; + +export default Node.create({ + name: 'MathEquation', + group: 'block', + + atom: true, + + addAttributes() { + return { + math_equation: { + default: '', + }, + isEditing: { + default: false, + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'block-math-equation', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ['block-math-equation', mergeAttributes(HTMLAttributes)]; + }, + + addNodeView() { + return ReactNodeViewRenderer(MathEquationComponent); + }, + + addInputRules() { + return [ + new InputRule({ + find: new RegExp(`\\$\\$((?:\\.|[^\\$]|\\$)+?)\\$\\$`, 'g'), + handler: (props) => { + const latex = props.match[0]; + + props + .chain() + .focus() + .deleteRange(props.range) + .insertContent({ + type: 'MathEquation', + attrs: { + math_equation: latex, + }, + }) + .run(); + }, + }), + ]; + }, + + addPasteRules() { + return [ + nodePasteRule({ + find: new RegExp(`\\$((?:\\.|[^\\$]|\\$)+?)\\$$`, 'g'), + type: this.type, + getAttributes: (match) => { + return { + math_equation: match[0], + }; + }, + }), + nodePasteRule({ + find: new RegExp(`\\$\\$((?:\\.|[^\\$]|\\$)+?)\\$\\$`, 'g'), + type: this.type, + getAttributes: (match) => { + return { + math_equation: match[0], + }; + }, + }), + ]; + }, +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/MathEquation/MathEquationComponent.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/MathEquation/MathEquationComponent.tsx new file mode 100644 index 00000000000..b8aac3b5ad7 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/MathEquation/MathEquationComponent.tsx @@ -0,0 +1,96 @@ +/* + * Copyright 2024 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 { CheckOutlined, CloseOutlined } from '@ant-design/icons'; +import { NodeViewProps, NodeViewWrapper } from '@tiptap/react'; +import { Button, Input, Space, Tooltip } from 'antd'; +import { TextAreaRef } from 'antd/lib/input/TextArea'; +import classNames from 'classnames'; +import 'katex/dist/katex.min.css'; +import React, { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import Latex from 'react-latex-next'; +import { ReactComponent as EditIcon } from '../../../../assets/svg/edit-new.svg'; +import './math-equation.less'; + +export const MathEquationComponent: FC = ({ + node, + updateAttributes, +}) => { + const { t } = useTranslation(); + const inputRef = React.useRef(null); + const equation = node.attrs.math_equation; + + const [isEditing, setIsEditing] = React.useState( + Boolean(node.attrs.isEditing) + ); + + const handleSaveEquation = () => { + updateAttributes({ + math_equation: + inputRef.current?.resizableTextArea?.textArea.value ?? equation, + isEditing: false, + }); + setIsEditing(false); + }; + + return ( + +
+ {isEditing ? ( +
+ + +
+ ) : ( + {equation} + )} + {!isEditing && ( + +
+
+ ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/MathEquation/math-equation.less b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/MathEquation/math-equation.less new file mode 100644 index 00000000000..e5c089528e2 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/MathEquation/math-equation.less @@ -0,0 +1,57 @@ +/* + * Copyright 2024 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. + */ +.math-equation-wrapper { + display: flex; + gap: 8px; + flex-wrap: wrap; + background: transparent; + justify-content: space-between; + align-items: center; + padding: 8px; + border-radius: 4px; + .edit-button { + opacity: 0; + padding: 4px; + } + &:hover { + .edit-button { + opacity: 1; + } + } + + .math-equation-edit-input-wrapper { + width: 100%; + display: flex; + justify-content: space-between; + } + + &.isediting { + background-color: black; + .edit-button, + .math-equation-input { + color: white; + } + } +} + +.react-renderer.node-MathEquation.ProseMirror-selectednode { + background-color: black; + .math-equation-wrapper { + .edit-button { + color: white; + } + } + &.has-focus { + color: white; + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/index.ts b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/index.ts index da6f7d8adb3..74c796f93d6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/index.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/index.ts @@ -28,11 +28,13 @@ import { Hashtag } from './hashtag'; import { hashtagSuggestion } from './hashtag/hashtagSuggestion'; import { Image } from './image/image'; import { LinkExtension } from './link'; +import MathEquation from './MathEquation/MathEquation'; import { Mention } from './mention'; import { mentionSuggestion } from './mention/mentionSuggestions'; import slashCommand from './slash-command'; import { getSuggestionItems } from './slash-command/items'; import renderItems from './slash-command/renderItems'; +import { TrailingNode } from './trailing-node'; export const extensions = [ StarterKit.configure({ @@ -144,4 +146,6 @@ export const extensions = [ 'data-om-table-cell': 'om-table-cell', }, }), + MathEquation, + TrailingNode, ]; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/link.ts b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/link.ts index 18429e57c94..3282e1527de 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/link.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/link.ts @@ -10,8 +10,45 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { markInputRule, markPasteRule } from '@tiptap/core'; +import { + InputRule, + markInputRule, + markPasteRule, + PasteRule, +} from '@tiptap/core'; import TipTapLinkExtension from '@tiptap/extension-link'; +import { + LINK_INPUT_REGEX, + LINK_PASTE_REGEX, +} from '../../../constants/BlockEditor.constants'; + +const linkInputRule = (config: Parameters[0]) => { + const defaultMarkInputRule = markInputRule(config); + + return new InputRule({ + find: config.find, + handler(props) { + const { tr } = props.state; + + defaultMarkInputRule.handler(props); + tr.setMeta('preventAutolink', true); + }, + }); +}; + +const linkPasteRule = (config: Parameters[0]) => { + const defaultMarkPasteRule = markPasteRule(config); + + return new PasteRule({ + find: config.find, + handler(props) { + const { tr } = props.state; + + defaultMarkPasteRule.handler(props); + tr.setMeta('preventAutolink', true); + }, + }); +}; export const LinkExtension = TipTapLinkExtension.extend({ addAttributes() { @@ -100,13 +137,15 @@ export const LinkExtension = TipTapLinkExtension.extend({ }, addInputRules() { return [ - markInputRule({ - find: /\[(.*?)\]\((https?:\/\/[^\s)]+)\)/, + ...(this.parent?.() ?? []), + linkInputRule({ + find: LINK_INPUT_REGEX, type: this.type, - getAttributes: (match) => { - const [, text, href] = match; - - return { 'data-textcontent': text, href }; + getAttributes(match) { + return { + title: match.pop()?.trim(), + href: match.pop()?.trim(), + }; }, }), ]; @@ -114,13 +153,14 @@ export const LinkExtension = TipTapLinkExtension.extend({ addPasteRules() { return [ ...(this.parent?.() ?? []), - markPasteRule({ - find: /\[(.*?)\]\((https?:\/\/[^\s)]+)\)/, + linkPasteRule({ + find: LINK_PASTE_REGEX, type: this.type, - getAttributes: (match) => { - const [, text, href] = match; - - return { 'data-textcontent': text, href }; + getAttributes(match) { + return { + title: match.pop()?.trim(), + href: match.pop()?.trim(), + }; }, }), ]; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/slash-command/items.ts b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/slash-command/items.ts index d14b87ce554..0b41c1307af 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/slash-command/items.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/slash-command/items.ts @@ -12,6 +12,7 @@ */ import { Editor, Range } from '@tiptap/core'; import HashtagImage from '../../../../assets/img/ic-format-hashtag.png'; +import MathEquationImage from '../../../../assets/img/ic-format-math-equation.png'; import BulletListImage from '../../../../assets/img/ic-slash-bullet-list.png'; import DividerImage from '../../../../assets/img/ic-slash-divider.png'; import H1Image from '../../../../assets/img/ic-slash-h1.png'; @@ -232,6 +233,27 @@ export const getSuggestionItems = (props: { type: SuggestionItemType.ADVANCED_BLOCKS, imgSrc: IconTable, }, + { + title: 'Math Equation', + description: 'Add a math equation', + searchTerms: ['math', 'equation', 'latex', 'katex'], + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .insertContent({ + type: 'MathEquation', + attrs: { + isEditing: true, + math_equation: '', + }, + }) + .run(); + }, + type: SuggestionItemType.ADVANCED_BLOCKS, + imgSrc: MathEquationImage, + }, ]; const filteredItems = suggestionItems.filter((item) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/trailing-node.ts b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/trailing-node.ts new file mode 100644 index 00000000000..96666a9d1f6 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/Extensions/trailing-node.ts @@ -0,0 +1,79 @@ +/* + * Copyright 2024 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 { Extension } from '@tiptap/core'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function nodeEqualsType({ types, node }: any) { + return ( + (Array.isArray(types) && types.includes(node.type)) || node.type === types + ); +} + +export interface TrailingNodeOptions { + node: string; + notAfter: string[]; +} + +export const TrailingNode = Extension.create({ + name: 'trailingNode', + + addOptions() { + return { + node: 'paragraph', + notAfter: ['paragraph'], + }; + }, + + addProseMirrorPlugins() { + const plugin = new PluginKey(this.name); + const disabledNodes = Object.entries(this.editor.schema.nodes) + .map(([, value]) => value) + .filter((node) => this.options.notAfter.includes(node.name)); + + return [ + new Plugin({ + key: plugin, + appendTransaction: (_, __, state) => { + const { doc, tr, schema } = state; + const shouldInsertNodeAtEnd = plugin.getState(state); + const endPosition = doc.content.size; + const type = schema.nodes[this.options.node]; + + if (!shouldInsertNodeAtEnd) { + return; + } + + // eslint-disable-next-line consistent-return + return tr.insert(endPosition, type.create()); + }, + state: { + init: (_, state) => { + const lastNode = state.tr.doc.lastChild; + + return !nodeEqualsType({ node: lastNode, types: disabledNodes }); + }, + apply: (tr, value) => { + if (!tr.docChanged) { + return value; + } + + const lastNode = tr.doc.lastChild; + + return !nodeEqualsType({ node: lastNode, types: disabledNodes }); + }, + }, + }), + ]; + }, +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/LinkPopup/LinkPopup.tsx b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/LinkPopup/LinkPopup.tsx index 2c8c9d6b6fc..eccc9815806 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/LinkPopup/LinkPopup.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/BlockEditor/LinkPopup/LinkPopup.tsx @@ -41,7 +41,7 @@ const LinkPopup: FC = ({