diff --git a/apps/backend/package.json b/apps/backend/package.json index 65828b1f..154c5848 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -30,6 +30,7 @@ "express": "^4.17.1", "express-rate-limit": "^6.9.0", "form-data": "^4.0.0", + "katex": "^0.16.10", "marked": "^7.0.3", "multer": "^1.4.3", "nodemon": "^3.0.1", diff --git a/apps/backend/server.js b/apps/backend/server.js index 09142fe6..90670aff 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -9,6 +9,7 @@ const uploadVideo = require('./src/routes/uploadVideo'); const downloadCertificate = require('./src/routes/downloadCertificate'); const downloadLesson = require('./src/routes/downloadLesson'); const downloadCourse = require('./src/routes/downloadCourse'); +const katex = require('./src/routes/katex'); // Express server const app = express(); @@ -32,6 +33,7 @@ app.use('/downloadCertificate', downloadCertificate); app.use('/downloadLesson', downloadLesson); app.use('/downloadCourse', downloadCourse); app.use('/uploadVideo', uploadVideo); +app.use('/katex', katex); app.get('/', (req, res) => { res.send('Welcome to ClassroomIO'); diff --git a/apps/backend/src/routes/katex.js b/apps/backend/src/routes/katex.js new file mode 100644 index 00000000..fde58017 --- /dev/null +++ b/apps/backend/src/routes/katex.js @@ -0,0 +1,24 @@ +const express = require('express'); +// https://ben.page/node-latex-to-html +const katex = require('katex'); + +const router = express.Router(); + +// localhost:3002/katex +router.get('/', async (req, res) => { + const original = req._parsedUrl.search; + + const latexString = original.replace('?', '').replaceAll('+', '+').replaceAll('&space;', ''); + console.log({ latexString }); + + const html = katex.renderToString(latexString, { + throwOnError: false, + output: 'mathml' + }); + + console.log({ html }); + + res.send(html); +}); + +module.exports = router; diff --git a/apps/dashboard/src/lib/components/TextEditor/index.svelte b/apps/dashboard/src/lib/components/TextEditor/index.svelte index 1cef0a8b..1485f9fc 100644 --- a/apps/dashboard/src/lib/components/TextEditor/index.svelte +++ b/apps/dashboard/src/lib/components/TextEditor/index.svelte @@ -2,6 +2,7 @@ import { PUBLIC_TINYMCE_API_KEY } from '$env/static/public'; import Editor from '@tinymce/tinymce-svelte'; import { globalStore } from '$lib/utils/store/app'; + import { addMathPlugin } from '$lib/utils/functions/tinymce/plugins'; export let id = ''; export let value: string | undefined = ''; @@ -13,12 +14,19 @@ const apiKey = PUBLIC_TINYMCE_API_KEY; - let tinmycEditor: any; let unmount = false; + function getTinymce() { + const getSink = () => { + return typeof window !== 'undefined' ? window : global; + }; + const sink = getSink(); + return sink && sink.tinymce ? sink.tinymce : null; + } + // editor configuration let conf = { - plugins: 'lists, link, emoticons, code, media', + plugins: 'lists, link, emoticons, code, media, eqneditor', toolbar: [ { name: 'history', items: ['undo', 'redo'] }, { name: 'styles', items: ['styles'] }, @@ -29,7 +37,8 @@ { name: 'link', items: ['link'] }, { name: 'insert', items: ['emoticons'] }, { name: 'code', items: ['code'] }, - { name: 'media', items: ['media'] } + { name: 'media', items: ['media'] }, + { name: 'eqneditor', items: ['eqneditor'] } ], lists_indent_on_tab: false, min_height: height, @@ -37,10 +46,13 @@ placeholder: placeholder, skin: 'oxide-dark', content_css: 'dark', + extended_valid_elements: '*[.*]', + setup: () => { + addMathPlugin(getTinymce()); + }, init_instance_callback: function (editor: any) { editorWindowRef = editor.iframeElement?.contentWindow; - tinmycEditor = editor; editor.on('Change', function () { const html = editor.getContent(); if (onChange) { diff --git a/apps/dashboard/src/lib/utils/functions/tinymce/plugins.ts b/apps/dashboard/src/lib/utils/functions/tinymce/plugins.ts new file mode 100644 index 00000000..1199b570 --- /dev/null +++ b/apps/dashboard/src/lib/utils/functions/tinymce/plugins.ts @@ -0,0 +1,169 @@ +import { PUBLIC_SERVER_URL } from '$env/static/public'; + + +var win, output, textarea; + +// Follow this guide to get source code: https://editor.codecogs.com/docs/3-Tiny_MCE_v6.php +export const addMathPlugin = (tinymce: any) => { + tinymce.PluginManager.add('eqneditor', function (editor, url) { + // Load necessary javascript for editor from CodeCogs + var sl = new tinymce.dom.ScriptLoader(); + sl.add('https://pub-19b235c5098f4ff1a6b1099331c8d00e.r2.dev/eqneditor.api.min.js'); + var loaded = sl.loadQueue(); + + // Load custom CSS + tinymce.DOM.loadCSS('https://pub-19b235c5098f4ff1a6b1099331c8d00e.r2.dev/eqneditor_1.css'); + + editor.ui.registry.addIcon( + 'logo', + ` + + + + + + + +` + ); + + editor.ui.registry.addIcon( + 'menu', + ` + + + + + + + + + ` + ); + + function showDialog(inp = '') { + var http = 'https:' == document.location.protocol ? 'https://' : 'http://'; + + win = editor.windowManager.open({ + title: 'Equation Editor', + size: 'medium', + body: { + type: 'panel', + items: [ + { + type: 'htmlpanel', + html: + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + } + ] + }, + buttons: [ + { + type: 'cancel', + name: 'closeButton', + text: 'Cancel' + }, + { + type: 'submit', + name: 'submitButton', + text: 'Ok', + buttonType: 'primary' + }, + { + type: 'custom', + name: 'logo', + text: '', + icon: 'logo', + align: 'start' + } + ], + onSubmit: async function (w) { + let content = output.exportAs('html'); + + // The generated content always points to https://latex.codecogs.com/svg.image, this means we have to always rely on their server to serve formulas. We have a dedicated endpoint for generating formulas on our backend service: GET `/katex`. We will need to manually update the URL in the generate HTML to point to our server: + // https://latex.codecogs.com/svg.image + + // 1. Get only the URL in the img tag generated by codecogs library + const srcRegex = /]+src\s*=\s*['"]([^'"]+)['"][^>]*>/i; + const srcMatch = content.match(srcRegex); + + if (srcMatch) { + // 2. Point the formula to our server + const customAPI = PUBLIC_SERVER_URL + '/katex' + const url = srcMatch[1]?.replaceAll('https://latex.codecogs.com/svg.image', customAPI); + + // 3. Fetch the HTML from the server + const response = await fetch(url); + const html = await response.text(); + + // 4. Set the content to the html + content = `

${html}

 

`; + } else { + console.error("No src attribute found in the img tag."); + } + + tinymce.activeEditor.execCommand('mceInsertContent', false, content); + textarea.pushToHistory(); + w.close(); + }, + onCancel: function (w) { + w.close(); + }, + onAction: function (w, dets) { + if (dets.name == 'logo') { + window.open(http + 'editor.codecogs.com', '_blank')?.focus(); + } + } + }); + + // Ensure EqnEditor js is loaded before continuing + loaded.then(() => { + output = new EqEditor.Output('output'); + textarea = EqEditor.TextArea.link('latexInput') + .addOutput(output) + .addHistoryMenu(new EqEditor.History('history')); + EqEditor.Toolbar.link('toolbar').addTextArea(textarea); + + var nodes = document.getElementById('history')?.childNodes || []; + for (var i = 0; i < nodes.length; i++) { + nodes[i].style.padding = 'revert'; + } + + textarea.clear(); + textarea.insert(inp.replace(/&space;/g, ' '), inp.length); + }); + } + + editor.ui.registry.addButton('eqneditor', { + title: 'Equation', + icon: 'menu', + tooltip: 'Insert Equation', + onAction: () => { + showDialog(); + } + // stateSelector: 'img[src*="latex"]' + }); + + editor.on('dblclick', function (e) { + if (e.target.nodeName.toLowerCase() == 'img') { + var sName = e.target.src.match(/(gif|svg)\.image\?(.*)/); + if (sName != null) showDialog(sName[2]); + } + }); + + return { + getMetadata: () => ({ + name: 'EqnEditor', + url: 'https://editor.codecogs.com/docs/3-Tiny_MCE_v4x.php' + }) + }; + }); +}; diff --git a/apps/dashboard/src/routes/courses/[id]/lessons/+layout.svelte b/apps/dashboard/src/routes/courses/[id]/lessons/+layout.svelte new file mode 100644 index 00000000..0dfdd249 --- /dev/null +++ b/apps/dashboard/src/routes/courses/[id]/lessons/+layout.svelte @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 382784cf..f8a6197d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: form-data: specifier: ^4.0.0 version: 4.0.0 + katex: + specifier: ^0.16.10 + version: 0.16.10 marked: specifier: ^7.0.3 version: 7.0.5 @@ -1586,7 +1589,7 @@ packages: '@babel/traverse': 7.23.4 '@babel/types': 7.23.4 convert-source-map: 1.9.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -1609,7 +1612,7 @@ packages: '@babel/traverse': 7.23.4 '@babel/types': 7.23.4 convert-source-map: 2.0.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -1766,7 +1769,7 @@ packages: '@babel/core': 7.22.9 '@babel/helper-compilation-targets': 7.22.15 '@babel/helper-plugin-utils': 7.22.5 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4 lodash.debounce: 4.0.8 resolve: 1.22.8 transitivePeerDependencies: @@ -3081,7 +3084,7 @@ packages: '@babel/helper-split-export-declaration': 7.22.6 '@babel/parser': 7.23.4 '@babel/types': 7.23.4 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -5749,7 +5752,7 @@ packages: vite: ^4.0.0 dependencies: '@sveltejs/vite-plugin-svelte': 2.5.3(svelte@4.1.2)(vite@4.4.8) - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4 svelte: 4.1.2 vite: 4.4.8(@types/node@18.11.10)(sass@1.64.2) transitivePeerDependencies: @@ -5763,7 +5766,7 @@ packages: vite: ^4.0.0 dependencies: '@sveltejs/vite-plugin-svelte-inspector': 1.0.4(@sveltejs/vite-plugin-svelte@2.5.3)(svelte@4.1.2)(vite@4.4.8) - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4 deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.5 @@ -6542,7 +6545,7 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} dependencies: - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4 transitivePeerDependencies: - supports-color dev: false @@ -8353,6 +8356,17 @@ packages: ms: 2.1.3 dev: true + /debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + /debug@4.3.4(supports-color@5.5.0): resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} engines: {node: '>=6.0'} @@ -10449,7 +10463,7 @@ packages: engines: {node: '>= 6'} dependencies: agent-base: 6.0.2 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4 transitivePeerDependencies: - supports-color dev: false @@ -10976,7 +10990,7 @@ packages: resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} engines: {node: '>=10'} dependencies: - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4 istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -11552,6 +11566,13 @@ packages: resolution: {integrity: sha512-0H4miIAWZYpnpg7oD/Y/PBb77ISSHAETif5xK9EnwIgYCO6oC8ErkJxDumMUTR44shSOwptRIArRuvNuvN/hOw==} dev: true + /katex@0.16.10: + resolution: {integrity: sha512-ZiqaC04tp2O5utMsl2TEZTXxa6WSC4yo0fv5ML++D3QZv/vx2Mct0mTlRx3O+uUkjfuAgOkzsCmq5MiUEsDDdA==} + hasBin: true + dependencies: + commander: 8.3.0 + dev: false + /katex@0.16.9: resolution: {integrity: sha512-fsSYjWS0EEOwvy81j3vRA8TEAhQhKiqO+FQaKWp0m39qwOzHVBgAUBIXWj1pB+O2W3fIpNa6Y9KSKCVbfPhyAQ==} hasBin: true