From 15b44284d60a7ee88da3e1f1ee3462acdc1f8af8 Mon Sep 17 00:00:00 2001 From: Phyo Htet Arkar Date: Sun, 30 Jun 2024 14:42:06 +0630 Subject: [PATCH] feat: add mathematics extension --- .../components/tailwind/advanced-editor.tsx | 3 + apps/web/components/tailwind/extensions.ts | 11 ++ .../tailwind/selectors/math-selector.tsx | 35 ++++ packages/headless/package.json | 2 + packages/headless/src/extensions/index.ts | 2 + .../headless/src/extensions/mathematics.ts | 179 ++++++++++++++++++ pnpm-lock.yaml | 25 +++ 7 files changed, 257 insertions(+) create mode 100644 apps/web/components/tailwind/selectors/math-selector.tsx create mode 100644 packages/headless/src/extensions/mathematics.ts diff --git a/apps/web/components/tailwind/advanced-editor.tsx b/apps/web/components/tailwind/advanced-editor.tsx index 4b269c620..526681f21 100644 --- a/apps/web/components/tailwind/advanced-editor.tsx +++ b/apps/web/components/tailwind/advanced-editor.tsx @@ -17,6 +17,7 @@ import { defaultExtensions } from "./extensions"; import { ColorSelector } from "./selectors/color-selector"; import { LinkSelector } from "./selectors/link-selector"; import { NodeSelector } from "./selectors/node-selector"; +import { MathSelector } from "./selectors/math-selector"; import { Separator } from "./ui/separator"; import { handleImageDrop, handleImagePaste } from "novel/plugins"; @@ -126,6 +127,8 @@ const TailwindAdvancedEditor = () => { + + diff --git a/apps/web/components/tailwind/extensions.ts b/apps/web/components/tailwind/extensions.ts index bf104fc66..01a9c1ed9 100644 --- a/apps/web/components/tailwind/extensions.ts +++ b/apps/web/components/tailwind/extensions.ts @@ -13,6 +13,7 @@ import { Twitter, UpdatedImage, Youtube, + Mathematics, } from "novel/extensions"; import { UploadImagesPlugin } from "novel/plugins"; @@ -130,6 +131,15 @@ const twitter = Twitter.configure({ inline: false, }); +const mathematics = Mathematics.configure({ + HTMLAttributes: { + class: cx("text-foreground rounded p-1 hover:bg-accent cursor-pointer"), + }, + katexOptions: { + throwOnError: false, + }, +}); + const characterCount = CharacterCount.configure(); export const defaultExtensions = [ @@ -145,6 +155,7 @@ export const defaultExtensions = [ codeBlockLowlight, youtube, twitter, + mathematics, characterCount, GlobalDragHandle, ]; diff --git a/apps/web/components/tailwind/selectors/math-selector.tsx b/apps/web/components/tailwind/selectors/math-selector.tsx new file mode 100644 index 000000000..e7be29c8d --- /dev/null +++ b/apps/web/components/tailwind/selectors/math-selector.tsx @@ -0,0 +1,35 @@ +import { Button } from "@/components/tailwind/ui/button"; +import { cn } from "@/lib/utils"; +import { SigmaIcon } from "lucide-react"; +import { useEditor } from "novel"; + +export const MathSelector = () => { + const { editor } = useEditor(); + + if (!editor) return null; + + return ( + + ); +}; diff --git a/packages/headless/package.json b/packages/headless/package.json index a8196823a..15723fdde 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -70,6 +70,7 @@ "react-markdown": "^8.0.7", "react-moveable": "^0.56.0", "react-tweet": "^3.2.1", + "katex": "^0.16.10", "tippy.js": "^6.3.7", "tiptap-extension-global-drag-handle": "^0.1.7", "tiptap-markdown": "^0.8.9", @@ -77,6 +78,7 @@ }, "devDependencies": { "@biomejs/biome": "^1.7.2", + "@types/katex": "^0.16.7", "@types/react": "^18.2.55", "@types/react-dom": "18.2.19", "tsconfig": "workspace:*", diff --git a/packages/headless/src/extensions/index.ts b/packages/headless/src/extensions/index.ts index febcc2dc2..0d37822b0 100644 --- a/packages/headless/src/extensions/index.ts +++ b/packages/headless/src/extensions/index.ts @@ -14,6 +14,7 @@ import { Markdown } from "tiptap-markdown"; import CustomKeymap from "./custom-keymap"; import { ImageResizer } from "./image-resizer"; import { Twitter } from "./twitter"; +import { Mathematics } from "./mathematics"; import UpdatedImage from "./updated-image"; import CharacterCount from "@tiptap/extension-character-count"; @@ -82,6 +83,7 @@ export { simpleExtensions, Youtube, Twitter, + Mathematics, CharacterCount, GlobalDragHandle, }; diff --git a/packages/headless/src/extensions/mathematics.ts b/packages/headless/src/extensions/mathematics.ts new file mode 100644 index 000000000..1f6e5b285 --- /dev/null +++ b/packages/headless/src/extensions/mathematics.ts @@ -0,0 +1,179 @@ +import 'katex/dist/katex.min.css'; + +import { Node, mergeAttributes } from "@tiptap/core"; +import { EditorState } from "@tiptap/pm/state"; +import katex, { type KatexOptions } from "katex"; + +export interface MathematicsOptions { + /** + * By default LaTeX decorations can render when mathematical expressions are not inside a code block. + * @param state - EditorState + * @param pos - number + * @returns boolean + */ + shouldRender: (state: EditorState, pos: number) => boolean; + + /** + * @see https://katex.org/docs/options.html + */ + katexOptions?: KatexOptions; + + HTMLAttributes: Record; +} + +declare module "@tiptap/core" { + interface Commands { + LatexCommand: { + + /** + * Set selection to a LaTex symbol + */ + setLatex: ({ latex }: { latex: string }) => ReturnType; + + /** + * Unset a LaTex symbol + */ + unsetLatex: () => ReturnType; + + }; + } +} + +/** + * This extension adds support for mathematical symbols with LaTex expression. + * + * @see https://katex.org/ + */ +export const Mathematics = Node.create({ + name: "math", + inline: true, + group: "inline", + atom: true, + selectable: true, + marks: "", + + addAttributes() { + return { + latex: "", + }; + }, + + addOptions() { + return { + shouldRender: (state, pos) => { + const $pos = state.doc.resolve(pos); + + if (!$pos.parent.isTextblock) { + return false; + } + + return $pos.parent.type.name !== "codeBlock"; + }, + katexOptions: { + throwOnError: false, + }, + HTMLAttributes: {}, + }; + }, + + addCommands() { + return { + setLatex: + ({ latex }) => + ({ chain, state }) => { + if (!latex) { + return false; + } + const { from, to, $anchor } = state.selection; + + if (!this.options.shouldRender(state, $anchor.pos)) { + return false; + } + + return chain() + .insertContentAt( + { from: from, to: to }, + { + type: "math", + attrs: { + latex: latex, + }, + } + ) + .setTextSelection({ from: from, to: from + 1 }) + .run(); + }, + unsetLatex: + () => + ({ editor, state, chain }) => { + const latex = editor.getAttributes(this.name).latex; + if (typeof latex !== "string") { + return false; + } + + const { from, to } = state.selection; + + return chain() + .command(({ tr }) => { + tr.insertText(latex, from, to); + return true; + }) + .setTextSelection({ + from: from, + to: from + latex.length, + }) + .run(); + }, + }; + }, + + parseHTML() { + return [{ tag: `span[data-type="${this.name}"]` }]; + }, + + renderHTML({ node, HTMLAttributes }) { + const latex = node.attrs["latex"] ?? ""; + return [ + "span", + mergeAttributes(HTMLAttributes, { + "data-type": this.name, + }), + latex, + ]; + }, + + renderText({ node }) { + return node.attrs["latex"] ?? ""; + }, + + addNodeView() { + return ({ node, HTMLAttributes, getPos, editor }) => { + const dom = document.createElement("span"); + const latex: string = node.attrs["latex"] ?? ""; + + Object.entries(this.options.HTMLAttributes).forEach(([key, value]) => { + dom.setAttribute(key, value); + }); + + Object.entries(HTMLAttributes).forEach(([key, value]) => { + dom.setAttribute(key, value); + }); + + dom.addEventListener("click", (evt) => { + if (editor.isEditable && typeof getPos === "function") { + const pos = getPos(); + const nodeSize = node.nodeSize; + editor.commands.setTextSelection({ from: pos, to: pos + nodeSize }); + } + }); + + dom.contentEditable = "false"; + + dom.innerHTML = katex.renderToString(latex, this.options.katexOptions); + + return { + dom: dom, + }; + }; + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cea4370c..36fbee0fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,6 +219,9 @@ importers: jotai: specifier: ^2.6.4 version: 2.6.4(@types/react@18.2.55)(react@18.2.0) + katex: + specifier: ^0.16.10 + version: 0.16.10 react: specifier: ^18.0.0 version: 18.2.0 @@ -250,6 +253,9 @@ importers: '@biomejs/biome': specifier: ^1.7.2 version: 1.7.2 + '@types/katex': + specifier: ^0.16.7 + version: 0.16.7 '@types/react': specifier: ^18.2.55 version: 18.2.55 @@ -1446,6 +1452,9 @@ packages: '@types/jsdom@20.0.1': resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + '@types/katex@0.16.7': + resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==} + '@types/linkify-it@3.0.5': resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==} @@ -1854,6 +1863,10 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} @@ -2534,6 +2547,10 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + katex@0.16.10: + resolution: {integrity: sha512-ZiqaC04tp2O5utMsl2TEZTXxa6WSC4yo0fv5ML++D3QZv/vx2Mct0mTlRx3O+uUkjfuAgOkzsCmq5MiUEsDDdA==} + hasBin: true + keycode@2.2.1: resolution: {integrity: sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==} @@ -5252,6 +5269,8 @@ snapshots: '@types/tough-cookie': 4.0.5 parse5: 7.1.2 + '@types/katex@0.16.7': {} + '@types/linkify-it@3.0.5': {} '@types/markdown-it@12.2.3': @@ -5682,6 +5701,8 @@ snapshots: commander@4.1.1: {} + commander@8.3.0: {} + crelt@1.0.6: {} cross-spawn@5.1.0: @@ -6423,6 +6444,10 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + katex@0.16.10: + dependencies: + commander: 8.3.0 + keycode@2.2.1: {} keycon@1.4.0: