diff --git a/CHANGELOG.md b/CHANGELOG.md index 58364012..402e5b46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- Enabling latex mathematical equations in forums + ## [1.2.5] - 2022-10-14 ### Fixed diff --git a/src/ashley/defaults.py b/src/ashley/defaults.py index 49e2a0f1..a3e3496a 100644 --- a/src/ashley/defaults.py +++ b/src/ashley/defaults.py @@ -6,7 +6,7 @@ from draftjs_exporter.defaults import STYLE_MAP as DEFAULT_STYLE_MAP from draftjs_exporter.dom import DOM -from ashley.editor.decorators import emoji, image, link, mention +from ashley.editor.decorators import emoji, image, inlinetex, link, mention _FORUM_ROLE_ADMINISTRATOR = "administrator" _FORUM_ROLE_INSTRUCTOR = "instructor" @@ -95,6 +95,7 @@ "emoji": emoji, "mention": mention, "IMAGE": image, + "INLINETEX": inlinetex, }, "composite_decorators": [], "block_map": DEFAULT_BLOCK_MAP, diff --git a/src/ashley/editor/decorators.py b/src/ashley/editor/decorators.py index f88515bc..0b498121 100644 --- a/src/ashley/editor/decorators.py +++ b/src/ashley/editor/decorators.py @@ -93,3 +93,10 @@ def image(props): ) return None + + +def inlinetex(props): + """ + Decorator for the `INLINETEX` entity in Draft.js ContentState. + """ + return DOM.create_element("span", {"class": "latex"}, props.get("tex", None)) diff --git a/src/frontend/jest.config.js b/src/frontend/jest.config.js index bbe1d649..7425417c 100644 --- a/src/frontend/jest.config.js +++ b/src/frontend/jest.config.js @@ -4,5 +4,7 @@ module.exports = { setupFilesAfterEnv: ['./testSetup.ts', 'regenerator-runtime/runtime'], testMatch: [__dirname + '/js/**/*.spec.+(ts|tsx|js)'], testURL: 'https://localhost', - transformIgnorePatterns: ['node_modules/(?!(lodash-es)/)'], + transformIgnorePatterns: [ + '/node_modules/(?!(' + 'lodash-es|' + 'draft-js-latex-plugin' + ')/)', + ], }; diff --git a/src/frontend/js/ashley.ts b/src/frontend/js/ashley.ts index 265e0ba7..273ba096 100644 --- a/src/frontend/js/ashley.ts +++ b/src/frontend/js/ashley.ts @@ -1,9 +1,11 @@ import { renderEmojis } from './utils/emojis'; import { renderHighlight } from './utils/highlight'; +import { renderLatex } from './utils/latex'; // expose some modules to the global window object document.addEventListener('DOMContentLoaded', (event) => { renderEmojis(); renderHighlight(); + renderLatex(); }); diff --git a/src/frontend/js/components/AshleyEditor/index.spec.tsx b/src/frontend/js/components/AshleyEditor/index.spec.tsx index 4591f937..2ba54e10 100644 --- a/src/frontend/js/components/AshleyEditor/index.spec.tsx +++ b/src/frontend/js/components/AshleyEditor/index.spec.tsx @@ -1,5 +1,5 @@ import { createEvent } from '@testing-library/dom'; -import { render, fireEvent, screen } from '@testing-library/react'; +import { render, fireEvent, screen, within } from '@testing-library/react'; import user from '@testing-library/user-event'; import React from 'react'; import { IntlProvider } from 'react-intl'; @@ -25,6 +25,9 @@ describe('AshleyEditor', () => { }; afterEach(() => { jest.resetAllMocks(); + if (target) { + target.value = ''; + } }); // add input target, it's value is linked with editor content and needed for all the tests @@ -181,6 +184,53 @@ describe('AshleyEditor', () => { ); }); + it('creates a block of type inlinetex', () => { + render( + + + , + ); + expect(screen.queryByRole('figure')).not.toBeInTheDocument(); + + // TEXBLOCK are recognized + target.value = BlockMapFactory([ + { + type: 'atomic', + text: 'displaystylesum_{i=1}^{k+1}i', + data: { + tex: '', + type: 'TEXBLOCK', + }, + }, + ]); + + render( + + + , + ); + const figure = screen.getByRole('figure'); + const latex = within(figure).getByRole('textbox'); + expect(latex).toBeInTheDocument(); + }); + + it('generates a block of type inlinetex when `$` key is pressed', () => { + const { container } = render( + + + , + ); + + expect(screen.queryAllByRole('textbox').length).toEqual(1); + // select the draft-js editor + const editorNode = container.querySelector('.public-DraftEditor-content')!; + + fireEvent.keyDown(editorNode, { key: '$', code: '221', charCode: 36 }); + expect(screen.queryAllByRole('textbox').length).toEqual(2); + const latex = screen.queryAllByRole('textbox')[1]; + expect(latex).toHaveClass('TeXInput'); + }); + it('renders the editor with a list of users to mention', () => { // load the editor with no list of users to mention render( diff --git a/src/frontend/js/components/AshleyEditor/index.tsx b/src/frontend/js/components/AshleyEditor/index.tsx index 619937d0..b442a45e 100644 --- a/src/frontend/js/components/AshleyEditor/index.tsx +++ b/src/frontend/js/components/AshleyEditor/index.tsx @@ -31,7 +31,6 @@ import { BlockquoteButton, CodeBlockButton, } from '@draft-js-plugins/buttons'; -import createCodeEditorPlugin from '../../draftjs-plugins/code-editor'; import { ImageAdd } from './ImageAdd'; import createAlignmentPlugin from '@draft-js-plugins/alignment'; import createFocusPlugin from '@draft-js-plugins/focus'; @@ -39,6 +38,7 @@ import createResizeablePlugin from '@draft-js-plugins/resizeable'; import createBlockDndPlugin from '@draft-js-plugins/drag-n-drop'; import { useIntl } from 'react-intl'; import { messagesEditor } from './messages'; +import { getLaTeXPlugin } from 'draft-js-latex-plugin'; interface MyEditorProps { autofocus?: boolean; @@ -67,7 +67,6 @@ export const AshleyEditor = (props: MyEditorProps) => { emojiPlugin, linkPlugin, toolbarPlugin, - codeEditorPlugin, blockDndPlugin, alignmentPlugin, focusPlugin, @@ -85,7 +84,6 @@ export const AshleyEditor = (props: MyEditorProps) => { }, }), toolbarPlugin: createToolbarPlugin(), - codeEditorPlugin: createCodeEditorPlugin(), blockDndPlugin: createBlockDndPlugin(), alignmentPlugin: createAlignmentPlugin(), focusPlugin: createFocusPlugin(), @@ -107,7 +105,9 @@ export const AshleyEditor = (props: MyEditorProps) => { const [{ imagePlugin }] = useState({ imagePlugin: createImagePlugin({ decorator }), }); - + const [{ LaTeXPlugin }] = useState({ + LaTeXPlugin: getLaTeXPlugin({}), + }); const { AlignmentTool } = alignmentPlugin; useEffect(() => { @@ -169,12 +169,12 @@ export const AshleyEditor = (props: MyEditorProps) => { toolbarPlugin, emojiPlugin, linkPlugin, - codeEditorPlugin, blockDndPlugin, focusPlugin, alignmentPlugin, resizeablePlugin, imagePlugin, + LaTeXPlugin, ]; if (props.mentions) { @@ -183,6 +183,7 @@ export const AshleyEditor = (props: MyEditorProps) => { }); const [open, setOpen] = useState(true); const [suggestions, setSuggestions] = useState(props.mentions); + const onSearchChange = useCallback(({ value }: { value: string }) => { setSuggestions(defaultSuggestionsFilter(value, props.mentions!)); }, []); diff --git a/src/frontend/js/types/libs/draft-js-latex-plugin/draft-js-latex-plugin.d.ts b/src/frontend/js/types/libs/draft-js-latex-plugin/draft-js-latex-plugin.d.ts new file mode 100644 index 00000000..ed780c6c --- /dev/null +++ b/src/frontend/js/types/libs/draft-js-latex-plugin/draft-js-latex-plugin.d.ts @@ -0,0 +1 @@ +declare module 'draft-js-latex-plugin'; diff --git a/src/frontend/js/utils/latex.ts b/src/frontend/js/utils/latex.ts new file mode 100644 index 00000000..d380ef12 --- /dev/null +++ b/src/frontend/js/utils/latex.ts @@ -0,0 +1,9 @@ +import katex from 'katex'; + +export const renderLatex = () => { + document.querySelectorAll('span.latex').forEach((math) => { + if (math.textContent) { + math.innerHTML = katex.renderToString(math.textContent); + } + }); +}; diff --git a/src/frontend/package.json b/src/frontend/package.json index b8bedfaa..492fe760 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -47,6 +47,7 @@ "cljs-merge": "1.1.1", "css-loader": "5.2.4", "fetch-mock": "9.11.0", + "file-loader": "6.2.0", "intl-pluralrules": "1.2.2", "jest": "26.6.3", "lodash-es": "4.17.21", @@ -77,14 +78,17 @@ "@formatjs/intl-relativetimeformat": "8.1.6", "@fortawesome/fontawesome-free": "5.15.3", "@testing-library/user-event": "13.1.9", + "@types/katex": "0.14.0", "bootstrap": "4.6.0", "core-js": "3.11.1", "draft-js": "0.11.7", "draft-js-emoji-plugin": "2.1.3", + "draft-js-latex-plugin": "0.1.2", "draft-js-mention-plugin": "3.1.5", "emojione": "4.5.0", "es6-shim": "0.35.6", "highlight.js": "10.7.2", + "katex": "0.16.4", "mdn-polyfills": "5.20.0", "react": "17.0.2", "react-autosuggest": "10.1.0", diff --git a/src/frontend/scss/_main.scss b/src/frontend/scss/_main.scss index 379e1f77..359b2cd3 100644 --- a/src/frontend/scss/_main.scss +++ b/src/frontend/scss/_main.scss @@ -20,6 +20,10 @@ @import '@draft-js-plugins/mention/lib/plugin'; @import '@draft-js-plugins/static-toolbar/lib/plugin'; +//latex code style +@import 'draft-js-latex-plugin/lib/styles'; +@import 'katex/dist/katex'; + //highlight code style @import 'highlight.js/styles/github'; diff --git a/src/frontend/webpack.config.js b/src/frontend/webpack.config.js index 1d48cbc7..7579bfbc 100644 --- a/src/frontend/webpack.config.js +++ b/src/frontend/webpack.config.js @@ -34,6 +34,10 @@ module.exports = () => { module: { rules: [ + { + test: /\.(woff|woff2|ttf|eot|png|jpg|svg|gif)$/i, + use: ['file-loader'], + }, { test: /\.css$/, use: ['style-loader', 'css-loader'] }, { test: new RegExp(`(${babelCompileDeps.join('|')}.*)`), diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 60add8ff..bba97a55 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -1565,6 +1565,11 @@ resolved "https://registry.yarnpkg.com/@types/json-stable-stringify/-/json-stable-stringify-1.0.32.tgz#121f6917c4389db3923640b2e68de5fa64dda88e" integrity sha512-q9Q6+eUEGwQkv4Sbst3J4PNgDOvpuVuKj79Hl/qnmBMEIPzB5QoFRUtjcgcg2xNUZyYUGXBk5wYIBKHt0A+Mxw== +"@types/katex@0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@types/katex/-/katex-0.14.0.tgz#b84c0afc3218069a5ad64fe2a95321881021b5fe" + integrity sha512-+2FW2CcT0K3P+JMR8YG846bmDwplKUTsWgT2ENwdQ1UdVfRk3GQrh6Mi4sTopy30gI8Uau5CEqHTDZ6YvWIUPA== + "@types/lodash-es@4.17.4": version "4.17.4" resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.4.tgz#b2e440d2bf8a93584a9fd798452ec497986c9b97" @@ -2727,6 +2732,11 @@ commander@^2.12.1, commander@^2.20.0: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== +commander@^8.0.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" + integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== + commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" @@ -3029,18 +3039,6 @@ delegates@^1.0.0: resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= -detect-indent@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208" - integrity sha1-920GQ1LN9Docts5hnE7jqUdd4gg= - dependencies: - repeating "^2.0.0" - -detect-newline@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2" - integrity sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I= - detect-newline@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" @@ -3099,6 +3097,11 @@ draft-js-emoji-plugin@2.1.3: react-icons "^2.2.6" to-style "^1.3.3" +draft-js-latex-plugin@0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/draft-js-latex-plugin/-/draft-js-latex-plugin-0.1.2.tgz#49f8619a69d3dd838bed1462ce30f7d0a5812da0" + integrity sha512-6Qeaw2PJGuIzZ/NgjAPPgf0HqMXhkoUSIyXglm2vs/smUIFdei77mV5ggvvk2DL2zgYwUBbHmBzx0RhkEjHJsQ== + draft-js-mention-plugin@3.1.5: version "3.1.5" resolved "https://registry.yarnpkg.com/draft-js-mention-plugin/-/draft-js-mention-plugin-3.1.5.tgz#5ee76fc4b4d1e1b2ef996fcec84ffe5565c8d1a9" @@ -3174,11 +3177,6 @@ end-of-stream@^1.1.0: dependencies: once "^1.4.0" -ends-with@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/ends-with/-/ends-with-0.2.0.tgz#2f9da98d57a50cfda4571ce4339000500f4e6b8a" - integrity sha1-L52pjVelDP2kVxzkM5AAUA9Oa4o= - enhanced-resolve@^5.8.0: version "5.8.2" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.8.2.tgz#15ddc779345cbb73e97c611cd00c01c1e7bf4d8b" @@ -3516,6 +3514,14 @@ fetch-mock@9.11.0: querystring "^0.2.0" whatwg-url "^6.5.0" +file-loader@6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-6.2.0.tgz#baef7cf8e1840df325e4390b4484879480eebe4d" + integrity sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw== + dependencies: + loader-utils "^2.0.0" + schema-utils "^3.0.0" + fill-range@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" @@ -3996,11 +4002,6 @@ ignore-by-default@^1.0.1: resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" integrity sha1-SMptcvbGo68Aqa1K5odr44ieKwk= -immutable@3.x: - version "3.8.2" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" - integrity sha1-wkOZUUVbs5kT2vKBN28VMOEErfM= - immutable@~3.7.4: version "3.7.6" resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.7.6.tgz#13b4d3cb12befa15482a26fe1b2ebae640071e4b" @@ -4185,11 +4186,6 @@ is-extglob@^2.1.1: resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= -is-finite@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.1.0.tgz#904135c77fb42c0641d6aa1bcdbc4daa8da082f3" - integrity sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w== - is-fullwidth-code-point@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" @@ -4890,6 +4886,13 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" +katex@0.16.4: + version "0.16.4" + resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.4.tgz#87021bc3bbd80586ef715aeb476794cba6a49ad4" + integrity sha512-WudRKUj8yyBeVDI4aYMNxhx5Vhh2PjpzQw1GRu/LVGqL4m1AxwD1GcUp0IMbdJaf5zsjtj8ghP0DOQRYhroNkw== + dependencies: + commander "^8.0.0" + keyv@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" @@ -6167,13 +6170,6 @@ repeat-string@^1.6.1: resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= -repeating@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda" - integrity sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo= - dependencies: - is-finite "^1.0.0" - request@^2.88.0, request@^2.88.2: version "2.88.2" resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" diff --git a/tests/ashley/draftjs_exporter/test_draftjs_exporter_decorator_latex.py b/tests/ashley/draftjs_exporter/test_draftjs_exporter_decorator_latex.py new file mode 100644 index 00000000..1335493e --- /dev/null +++ b/tests/ashley/draftjs_exporter/test_draftjs_exporter_decorator_latex.py @@ -0,0 +1,39 @@ +from django.test import TestCase +from draftjs_exporter.dom import DOM + +from ashley.editor.decorators import inlinetex + + +class TestInlinetexDecorator(TestCase): + """Test custom inlinetex decorator for draftjs_exporter""" + + def test_custom_decorator_inlinetex_ok(self): + """ + check custom decorator ‘inlinetex‘ returns expected html + """ + + tex = "\left.\frac{x^3}{3}\right|_0^1" # noqa: W605 + self.assertEqual( + DOM.render(DOM.create_element(inlinetex, {"tex": tex})), + f'{tex}', + ) + + def test_custom_decorator_inlinetex_empty(self): + """ + check custom decorator ‘inlinetex‘ returns expected html even when + the content is empty + """ + self.assertEqual( + DOM.render(DOM.create_element(inlinetex, {"tex": ""})), + '', + ) + + def test_custom_decorator_inlinetex_no_maths(self): + """ + check custom decorator ‘inlinetex‘ returns expected html even when + the content is not a formula but a regular string + """ + self.assertEqual( + DOM.render(DOM.create_element(inlinetex, {"tex": "a common string"})), + 'a common string', + )