feat(#14234): add LaTeX Support In Block Editor (#16842)

* feat(#14234): add LaTeX Support In Block Editor

* refactor: improve assignment regex in evaluateExpression helper

* refactor: Remove mathematics extension from Block Editor

* refactor: Remove mathematics extension from Block Editor

* chore: Update npm dependencies

* feat: Add react-latex-next package for LaTeX support in Block Editor

* refactor: Update MathEquation extension in Block Editor

* feat: Add Math Equation slash command option to Block Editor

* fix: isEditing attribute should be updated when updateAttributes is called

* chore: enable input rule for link and math equation

* fix: link mark being removed when cancel operation is performed

* feat: add trailing node extension to improve user experience by having empty node at the end

* chore: improve link markdown support and link posting support

* chore: Update link and math equation input rules

* chore: Update link icon size in LinkPopup component

* chore: Refactor link handling in EditorSlots component

* chore: Update MathEquationComponent to toggle isEditing class when editing math equation

* feat: Add placeholder text for math equation input
This commit is contained in:
Sachin Chaurasiya 2024-07-01 12:06:43 +05:30 committed by GitHub
parent 9a31a35296
commit bf1dddedeb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 459 additions and 21 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -35,10 +35,6 @@ const EditorSlots = forwardRef<EditorSlotsRef, EditorSlotsProps>(
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<EditorSlotsRef, EditorSlotsProps>(
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,

View File

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

View File

@ -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<NodeViewProps> = ({
node,
updateAttributes,
}) => {
const { t } = useTranslation();
const inputRef = React.useRef<TextAreaRef>(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 (
<NodeViewWrapper className="block-math-equation">
<div
className={classNames('math-equation-wrapper', {
isediting: isEditing,
})}>
{isEditing ? (
<div className="math-equation-edit-input-wrapper">
<Input.TextArea
autoFocus
bordered={false}
className="math-equation-input"
defaultValue={equation}
placeholder='Enter your equation here. For example: "x^2 + y^2 = z^2"'
ref={inputRef}
rows={2}
/>
<Space direction="horizontal" size={8}>
<Button
icon={<CloseOutlined />}
size="small"
type="default"
onClick={() => setIsEditing(false)}
/>
<Button
icon={<CheckOutlined />}
size="small"
type="primary"
onClick={handleSaveEquation}
/>
</Space>
</div>
) : (
<Latex>{equation}</Latex>
)}
{!isEditing && (
<Tooltip
title={t('label.edit-entity', { entity: t('label.equation') })}>
<Button
className="edit-button"
icon={<EditIcon width={16} />}
size="small"
type="text"
onClick={() => setIsEditing(true)}
/>
</Tooltip>
)}
</div>
</NodeViewWrapper>
);
};

View File

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

View File

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

View File

@ -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<typeof markInputRule>[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<typeof markPasteRule>[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(),
};
},
}),
];

View File

@ -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) => {

View File

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

View File

@ -41,7 +41,7 @@ const LinkPopup: FC<LinkPopupProps> = ({
<Button
className="p-0"
href={href}
icon={<ExternalLinkIcon width={iconSize} />}
icon={<ExternalLinkIcon width={iconSize + 2} />}
target="_blank"
type="link"
/>

View File

@ -268,14 +268,18 @@
}
.link-popup {
align-items: center;
padding: 4px;
background: @white;
border: @global-border;
border-radius: 20px;
border-radius: @border-radius-base;
button,
a {
color: @text-color !important;
&:hover {
background-color: @hover-bg;
}
}
}

View File

@ -31,6 +31,8 @@ export const EDITOR_OPTIONS: Partial<EditorOptions> = {
'image',
'taskItem',
'callout',
'link',
'MathEquation',
],
parseOptions: {
preserveWhitespace: 'full',
@ -63,3 +65,9 @@ export const CALLOUT_CONTENT = {
export const CALL_OUT_REGEX = /^:::([A-Za-z]*)?$/;
export const CALL_OUT_INPUT_RULE_REGEX = /^::: $/;
export const LINK_INPUT_REGEX =
/(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["“](.+)["”])?\)$/i;
export const LINK_PASTE_REGEX =
/(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["“](.+)["”])?\)/gi;

View File

@ -429,6 +429,7 @@
"entity-type-plural": "{{entity}}-Typen",
"entity-version-detail-plural": "Details zu {{entity}}-Versionen",
"enum-value-plural": "Enum Values",
"equation": "Equation",
"error": "Error",
"error-plural": "Errors",
"event-publisher-plural": "Ereignisveröffentlicher",

View File

@ -429,6 +429,7 @@
"entity-type-plural": "{{entity}} Type",
"entity-version-detail-plural": "{{entity}} Version Details",
"enum-value-plural": "Enum Values",
"equation": "Equation",
"error": "Error",
"error-plural": "Errors",
"event-publisher-plural": "Event Publishers",

View File

@ -429,6 +429,7 @@
"entity-type-plural": "Tipo de {{entity}}",
"entity-version-detail-plural": "Detalles de versión de {{entity}}",
"enum-value-plural": "Valores de enumeración",
"equation": "Equation",
"error": "Error",
"error-plural": "Errores",
"event-publisher-plural": "Publicadores de eventos",

View File

@ -429,6 +429,7 @@
"entity-type-plural": "{{entity}} Types",
"entity-version-detail-plural": "Détails des Versions de {{entity}}",
"enum-value-plural": "Enum Values",
"equation": "Equation",
"error": "Error",
"error-plural": "Errors",
"event-publisher-plural": "Publicateurs d'Événements",

View File

@ -429,6 +429,7 @@
"entity-type-plural": "סוגי {{entity}}",
"entity-version-detail-plural": "גרסאות פרטי {{entity}}",
"enum-value-plural": "Enum Values",
"equation": "Equation",
"error": "Error",
"error-plural": "Errors",
"event-publisher-plural": "מפרסמי אירועים",

View File

@ -429,6 +429,7 @@
"entity-type-plural": "{{entity}} Type",
"entity-version-detail-plural": "{{entity}} Version Details",
"enum-value-plural": "Enum Values",
"equation": "Equation",
"error": "Error",
"error-plural": "Errors",
"event-publisher-plural": "イベントの作成者",

View File

@ -429,6 +429,7 @@
"entity-type-plural": "{{entity}}-type",
"entity-version-detail-plural": "{{entity}}-versie-details",
"enum-value-plural": "Enum Values",
"equation": "Equation",
"error": "Fout",
"error-plural": "Errors",
"event-publisher-plural": "Gebeurtenis-publisher",

View File

@ -429,6 +429,7 @@
"entity-type-plural": "Tipo de {{entity}}",
"entity-version-detail-plural": "Detalhes da Versão de {{entity}}",
"enum-value-plural": "Enum Values",
"equation": "Equation",
"error": "Error",
"error-plural": "Errors",
"event-publisher-plural": "Publicadores de Eventos",

View File

@ -429,6 +429,7 @@
"entity-type-plural": "Тип {{entity}}",
"entity-version-detail-plural": "{{entity}} Version Details",
"enum-value-plural": "Enum Values",
"equation": "Equation",
"error": "Error",
"error-plural": "Errors",
"event-publisher-plural": "Издатели события",

View File

@ -429,6 +429,7 @@
"entity-type-plural": "{{entity}}类型",
"entity-version-detail-plural": "{{entity}}版本详情",
"enum-value-plural": "Enum Values",
"equation": "Equation",
"error": "Error",
"error-plural": "Errors",
"event-publisher-plural": "事件发布者",

View File

@ -95,6 +95,7 @@ module.exports = {
path.resolve(__dirname, 'node_modules/react-toastify'),
path.resolve(__dirname, 'node_modules/quill-emoji'),
path.resolve(__dirname, 'node_modules/react-awesome-query-builder'),
path.resolve(__dirname, 'node_modules/katex'),
],
// May need to handle files outside the source code
// (from node_modules)

View File

@ -96,6 +96,7 @@ module.exports = {
path.resolve(__dirname, 'node_modules/react-toastify'),
path.resolve(__dirname, 'node_modules/quill-emoji'),
path.resolve(__dirname, 'node_modules/react-awesome-query-builder'),
path.resolve(__dirname, 'node_modules/katex'),
],
// May need to handle files outside the source code
// (from node_modules)

View File

@ -4258,6 +4258,11 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==
"@types/katex@^0.16.7":
version "0.16.7"
resolved "https://registry.yarnpkg.com/@types/katex/-/katex-0.16.7.tgz#03ab680ab4fa4fbc6cb46ecf987ecad5d8019868"
integrity sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==
"@types/lodash@^4.14.167":
version "4.14.172"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.172.tgz#aad774c28e7bfd7a67de25408e03ee5a8c3d028a"
@ -10241,6 +10246,13 @@ jwt-decode@^3.1.2:
resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59"
integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==
katex@^0.16.0, katex@^0.16.10:
version "0.16.10"
resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.10.tgz#6f81b71ac37ff4ec7556861160f53bc5f058b185"
integrity sha512-ZiqaC04tp2O5utMsl2TEZTXxa6WSC4yo0fv5ML++D3QZv/vx2Mct0mTlRx3O+uUkjfuAgOkzsCmq5MiUEsDDdA==
dependencies:
commander "^8.3.0"
kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
version "3.2.2"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
@ -12824,6 +12836,13 @@ react-is@^18.2.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
react-latex-next@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/react-latex-next/-/react-latex-next-3.0.0.tgz#3e347a13b1e701439b5fa52f75201bc86166854e"
integrity sha512-x70f1b1G7TronVigsRgKHKYYVUNfZk/3bciFyYX1lYLQH2y3/TXku3+5Vap8MDbJhtopePSYBsYWS6jhzIdz+g==
dependencies:
katex "^0.16.0"
react-lazylog@^4.5.3:
version "4.5.3"
resolved "https://registry.yarnpkg.com/react-lazylog/-/react-lazylog-4.5.3.tgz#289e24995b5599e75943556ac63f5e2c04d0001e"