From f11372091ab82101ded3b59aa8aa4ccec636cb89 Mon Sep 17 00:00:00 2001 From: PerretWilliam Date: Fri, 5 Jun 2026 12:54:34 +0200 Subject: [PATCH] feat: add inline KaTeX math support --- src-tauri/src/lib.rs | 19 + src/App.css | 9 +- src/components/editor/Editor.tsx | 490 ++++++++++++++++-------- src/components/editor/MathExtensions.ts | 120 ++++-- src/components/editor/SlashCommand.tsx | 10 + src/lib/plainText.ts | 5 + src/lib/shortcuts.ts | 1 + 7 files changed, 467 insertions(+), 187 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 04f54a80..bae64766 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -547,6 +547,25 @@ fn strip_markdown(text: &str) -> String { } } + // Remove block math ($$...$$) and inline math ($...$), but only if not starting with a digit (to avoid stripping prices like $19.99) + let block_math_re = regex::Regex::new(r"\$\$([\s\S]+?)\$\$").unwrap(); + result = block_math_re.replace_all(&result, "$1").to_string(); + let inline_math_re = regex::Regex::new(r"(^|[^\w$])\$([^\$\n]+)\$").unwrap(); + result = inline_math_re + .replace_all(&result, |caps: ®ex::Captures<'_>| { + let latex = &caps[2]; + if latex + .chars() + .next() + .is_some_and(|ch| ch.is_ascii_digit()) + { + caps.get(0).map(|m| m.as_str()).unwrap_or("").to_string() + } else { + format!("{}{}", &caps[1], latex) + } + }) + .to_string(); + // Remove images ![alt](url) - must come before links let img_re = regex::Regex::new(r"!\[([^\]]*)\]\([^)]+\)").unwrap(); result = img_re.replace_all(&result, "$1").to_string(); diff --git a/src/App.css b/src/App.css index 3b3904d9..49dd591b 100644 --- a/src/App.css +++ b/src/App.css @@ -569,6 +569,11 @@ html.dark { color: var(--color-text); } +.prose .tiptap-mathematics-render[data-type="inline-math"] { + display: inline-block; + vertical-align: 0.01em; +} + .prose .tiptap-mathematics-render[data-type="block-math"] { display: block; margin: 0.75rem 0; @@ -579,7 +584,9 @@ html.dark { } .prose - .tiptap-mathematics-render[data-type="block-math"].ProseMirror-selectednode { + .tiptap-mathematics-render[data-type="block-math"].ProseMirror-selectednode, +.prose + .tiptap-mathematics-render[data-type="inline-math"].ProseMirror-selectednode { outline: 2px solid var(--color-bg-emphasis); outline-offset: 2px; border-radius: 0.25rem; diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index dcf07161..188bbfdc 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -31,6 +31,7 @@ import { PluginKey, TextSelection, } from "@tiptap/pm/state"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import tippy, { type Instance as TippyInstance } from "tippy.js"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { openUrl } from "@tauri-apps/plugin-opener"; @@ -68,7 +69,12 @@ import { SlashCommand } from "./SlashCommand"; import { Wikilink, type WikilinkStorage } from "./Wikilink"; import { WikilinkSuggestion } from "./WikilinkSuggestion"; import { EditorWidthHandles } from "./EditorWidthHandle"; -import { ScratchBlockMath, normalizeBlockMath } from "./MathExtensions"; +import { + ScratchBlockMath, + ScratchInlineMath, + normalizeBlockMath, + normalizeInlineMath, +} from "./MathExtensions"; import { cn } from "../../lib/utils"; import { plainTextFromMarkdown } from "../../lib/plainText"; import { Button, IconButton, ToolbarButton, Tooltip } from "../ui"; @@ -147,6 +153,36 @@ function focusAndSelectTitle(editor: TiptapEditor): boolean { return true; } +function getMathNodeRect( + editor: TiptapEditor, + pos: number, + node: ProseMirrorNode, +): DOMRect { + const nodeDom = editor.view.nodeDOM(pos); + if (nodeDom instanceof HTMLElement) { + return nodeDom.getBoundingClientRect(); + } + + const start = editor.view.coordsAtPos(pos); + const end = editor.view.coordsAtPos(pos + node.nodeSize); + const left = Math.min(start.left, end.left); + const top = Math.min(start.top, end.top); + const right = Math.max(start.right, end.right); + const bottom = Math.max(start.bottom, end.bottom); + + return { + width: Math.max(2, right - left), + height: Math.max(20, bottom - top), + top, + left, + right, + bottom, + x: left, + y: top, + toJSON: () => ({}), + } as DOMRect; +} + // Standard number-field shortcuts for KaTeX (shared between inline and block math) const katexMacros: Record = { "\\R": "\\mathbb{R}", @@ -583,7 +619,7 @@ export function Editor({ const searchInputRef = useRef(null); const saveTimeoutRef = useRef(null); const linkPopupRef = useRef(null); - const blockMathPopupRef = useRef(null); + const mathPopupRef = useRef(null); const isLoadingRef = useRef(false); const scrollContainerRef = useRef(null); const editorRef = useRef(null); @@ -779,91 +815,38 @@ export function Editor({ }, 500); }, [saveImmediately, getMarkdown, currentNote?.id]); - const closeBlockMathPopup = useCallback(() => { - if (blockMathPopupRef.current) { - blockMathPopupRef.current.destroy(); - blockMathPopupRef.current = null; + const closeMathPopup = useCallback(() => { + if (mathPopupRef.current) { + mathPopupRef.current.destroy(); + mathPopupRef.current = null; } }, []); - const handleEditBlockMath = useCallback( - (pos: number) => { - const currentEditor = editorRef.current; - if (!currentEditor) return; - + const openMathPopup = useCallback( + ( + currentEditor: TiptapEditor, + anchorRect: DOMRect, + initialLatex: string, + onSubmit: (latex: string) => void, + onCancel: () => void, + ) => { if (linkPopupRef.current) { linkPopupRef.current.destroy(); linkPopupRef.current = null; } - closeBlockMathPopup(); - - const node = currentEditor.state.doc.nodeAt(pos); - if (!node || node.type.name !== "blockMath") { - return; - } - - const virtualElement = { - getBoundingClientRect: () => { - const nodeDom = currentEditor.view.nodeDOM(pos); - if (nodeDom instanceof HTMLElement) { - return nodeDom.getBoundingClientRect(); - } - - const start = currentEditor.view.coordsAtPos(pos); - const end = currentEditor.view.coordsAtPos(pos + node.nodeSize); - const left = Math.min(start.left, end.left); - const top = Math.min(start.top, end.top); - const right = Math.max(start.right, end.right); - const bottom = Math.max(start.bottom, end.bottom); - - return { - width: Math.max(2, right - left), - height: Math.max(20, bottom - top), - top, - left, - right, - bottom, - x: left, - y: top, - toJSON: () => ({}), - } as DOMRect; - }, - }; + closeMathPopup(); const component = new ReactRenderer(BlockMathEditor, { props: { - initialLatex: String(node.attrs.latex ?? ""), - onSubmit: (latex: string) => { - const trimmed = latex.trim(); - if (!trimmed) { - toast.error("Please enter a formula."); - return; - } - currentEditor - .chain() - .focus() - .updateBlockMath({ pos, latex: trimmed }) - .setTextSelection(pos + node.nodeSize) - .run(); - closeBlockMathPopup(); - }, - onCancel: () => { - // Move cursor after the node instead of restoring the NodeSelection, - // which would re-trigger native DOM selection highlight bleed - currentEditor - .chain() - .focus() - .setTextSelection(pos + node.nodeSize) - .run(); - closeBlockMathPopup(); - }, + initialLatex, + onSubmit, + onCancel, }, editor: currentEditor, }); - blockMathPopupRef.current = tippy(document.body, { - getReferenceClientRect: () => - virtualElement.getBoundingClientRect() as DOMRect, + mathPopupRef.current = tippy(document.body, { + getReferenceClientRect: () => anchorRect, appendTo: () => document.body, content: component.element, showOnCreate: true, @@ -873,17 +856,106 @@ export function Editor({ offset: [0, 8], onDestroy: () => { component.destroy(); + mathPopupRef.current = null; }, }); }, - [closeBlockMathPopup], + [closeMathPopup], + ); + + const handleEditBlockMath = useCallback( + (pos: number) => { + const currentEditor = editorRef.current; + if (!currentEditor) return; + + const node = currentEditor.state.doc.nodeAt(pos); + if (!node || node.type.name !== "blockMath") { + return; + } + + const anchorRect = getMathNodeRect(currentEditor, pos, node); + openMathPopup( + currentEditor, + anchorRect, + String(node.attrs.latex ?? ""), + (latex: string) => { + const trimmed = latex.trim(); + if (!trimmed) { + toast.error("Please enter a formula."); + return; + } + + currentEditor + .chain() + .focus() + .updateBlockMath({ pos, latex: trimmed }) + .setTextSelection(pos + node.nodeSize) + .run(); + closeMathPopup(); + }, + () => { + // Move cursor after the node instead of restoring the NodeSelection, + // which would re-trigger native DOM selection highlight bleed + currentEditor + .chain() + .focus() + .setTextSelection(pos + node.nodeSize) + .run(); + closeMathPopup(); + }, + ); + }, + [closeMathPopup, openMathPopup], + ); + + const handleEditInlineMath = useCallback( + (pos: number) => { + const currentEditor = editorRef.current; + if (!currentEditor) return; + + const node = currentEditor.state.doc.nodeAt(pos); + if (!node || node.type.name !== "inlineMath") { + return; + } + + const anchorRect = getMathNodeRect(currentEditor, pos, node); + openMathPopup( + currentEditor, + anchorRect, + String(node.attrs.latex ?? ""), + (latex: string) => { + const trimmed = latex.trim(); + if (!trimmed) { + toast.error("Please enter a formula."); + return; + } + + currentEditor + .chain() + .focus() + .updateInlineMath({ pos, latex: trimmed }) + .setTextSelection(pos + node.nodeSize) + .run(); + closeMathPopup(); + }, + () => { + currentEditor + .chain() + .focus() + .setTextSelection(pos + node.nodeSize) + .run(); + closeMathPopup(); + }, + ); + }, + [closeMathPopup, openMathPopup], ); const handleAddBlockMath = useCallback(() => { const currentEditor = editorRef.current; if (!currentEditor) return; - closeBlockMathPopup(); + closeMathPopup(); if (linkPopupRef.current) { linkPopupRef.current.destroy(); linkPopupRef.current = null; @@ -928,7 +1000,7 @@ export function Editor({ const targetRange = { from, to }; const hasSelection = from !== to; - const virtualElement = { + const anchorRect = { getBoundingClientRect: () => { if (hasSelection) { const startPos = currentEditor.view.domAtPos(from); @@ -961,87 +1033,183 @@ export function Editor({ }, }; - const component = new ReactRenderer(BlockMathEditor, { - props: { - initialLatex, - onSubmit: (latex: string) => { - const normalizedLatex = latex.trim(); - if (!normalizedLatex) { - toast.error("Please enter a formula."); - return; - } + openMathPopup( + currentEditor, + anchorRect.getBoundingClientRect() as DOMRect, + initialLatex, + (latex: string) => { + const normalizedLatex = latex.trim(); + if (!normalizedLatex) { + toast.error("Please enter a formula."); + return; + } - const inserted = currentEditor - .chain() - .focus() - .insertContentAt(targetRange, { - type: "blockMath", - attrs: { latex: normalizedLatex }, - }) - .command(({ state, tr, dispatch }) => { - if (!dispatch) return true; - - const { $to } = tr.selection; - if ($to.nodeAfter?.isTextblock) { - tr.setSelection(TextSelection.create(tr.doc, $to.pos + 1)); + const inserted = currentEditor + .chain() + .focus() + .insertContentAt(targetRange, { + type: "blockMath", + attrs: { latex: normalizedLatex }, + }) + .command(({ state, tr, dispatch }) => { + if (!dispatch) return true; + + const { $to } = tr.selection; + if ($to.nodeAfter?.isTextblock) { + tr.setSelection(TextSelection.create(tr.doc, $to.pos + 1)); + tr.scrollIntoView(); + return true; + } + + const paragraphType = + state.schema.nodes.paragraph ?? + $to.parent.type.contentMatch.defaultType; + const paragraphNode = paragraphType?.create(); + const insertPos = $to.nodeAfter ? $to.pos : $to.end(); + + if (paragraphNode) { + const $insertPos = tr.doc.resolve(insertPos); + if ( + $insertPos.parent.canReplaceWith( + $insertPos.index(), + $insertPos.index(), + paragraphNode.type, + ) + ) { + tr.insert(insertPos, paragraphNode); + tr.setSelection(TextSelection.create(tr.doc, insertPos + 1)); tr.scrollIntoView(); return true; } + } - const paragraphType = - state.schema.nodes.paragraph ?? - $to.parent.type.contentMatch.defaultType; - const paragraphNode = paragraphType?.create(); - const insertPos = $to.nodeAfter ? $to.pos : $to.end(); - - if (paragraphNode) { - const $insertPos = tr.doc.resolve(insertPos); - if ( - $insertPos.parent.canReplaceWith( - $insertPos.index(), - $insertPos.index(), - paragraphNode.type, - ) - ) { - tr.insert(insertPos, paragraphNode); - tr.setSelection(TextSelection.create(tr.doc, insertPos + 1)); - tr.scrollIntoView(); - return true; - } - } + tr.scrollIntoView(); + return true; + }) + .run(); - tr.scrollIntoView(); - return true; - }) - .run(); + if (inserted) { + closeMathPopup(); + } + }, + () => { + currentEditor.commands.focus(); + closeMathPopup(); + }, + ); + }, [closeMathPopup, handleEditBlockMath, openMathPopup]); - if (inserted) { - closeBlockMathPopup(); + const handleAddInlineMath = useCallback(() => { + const currentEditor = editorRef.current; + if (!currentEditor) return; + + closeMathPopup(); + if (linkPopupRef.current) { + linkPopupRef.current.destroy(); + linkPopupRef.current = null; + } + + const { selection, doc } = currentEditor.state; + const { from, to, empty, $from } = selection; + + if ( + selection instanceof NodeSelection && + selection.node.type.name === "inlineMath" + ) { + handleEditInlineMath(from); + return; + } + + if (!empty) { + const selectedNode = doc.nodeAt(from); + if ( + selectedNode?.type.name === "inlineMath" && + from + selectedNode.nodeSize === to + ) { + handleEditInlineMath(from); + return; + } + } + + if (empty) { + const nodeBefore = $from.nodeBefore; + if (nodeBefore?.type.name === "inlineMath") { + handleEditInlineMath(from - nodeBefore.nodeSize); + return; + } + const nodeAfter = $from.nodeAfter; + if (nodeAfter?.type.name === "inlineMath") { + handleEditInlineMath(from); + return; + } + } + + const selectedText = empty ? "" : doc.textBetween(from, to, "\n"); + const initialLatex = normalizeInlineMath(selectedText); + const targetRange = { from, to }; + const hasSelection = from !== to; + + const anchorRect = { + getBoundingClientRect: () => { + if (hasSelection) { + const startPos = currentEditor.view.domAtPos(from); + const endPos = currentEditor.view.domAtPos(to); + + if (startPos && endPos) { + try { + const range = document.createRange(); + range.setStart(startPos.node, startPos.offset); + range.setEnd(endPos.node, endPos.offset); + return range.getBoundingClientRect(); + } catch (error) { + console.error("Inline math range creation failed:", error); + } } - }, - onCancel: () => { - currentEditor.commands.focus(); - closeBlockMathPopup(); - }, + } + + const coords = currentEditor.view.coordsAtPos(from); + return { + width: 2, + height: 20, + top: coords.top, + left: coords.left, + right: coords.right, + bottom: coords.bottom, + x: coords.left, + y: coords.top, + toJSON: () => ({}), + } as DOMRect; }, - editor: currentEditor, - }); + }; - blockMathPopupRef.current = tippy(document.body, { - getReferenceClientRect: () => - virtualElement.getBoundingClientRect() as DOMRect, - appendTo: () => document.body, - content: component.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: "bottom-start", - offset: [0, 8], - onDestroy: () => { - component.destroy(); + openMathPopup( + currentEditor, + anchorRect.getBoundingClientRect() as DOMRect, + initialLatex, + (latex: string) => { + const normalizedLatex = latex.trim(); + if (!normalizedLatex) { + toast.error("Please enter a formula."); + return; + } + + currentEditor + .chain() + .focus() + .insertContentAt(targetRange, { + type: "inlineMath", + attrs: { latex: normalizedLatex }, + }) + .setTextSelection(targetRange.from + 1) + .run(); + closeMathPopup(); }, - }); - }, [closeBlockMathPopup, handleEditBlockMath]); + () => { + currentEditor.commands.focus(); + closeMathPopup(); + }, + ); + }, [closeMathPopup, handleEditInlineMath, openMathPopup]); const editor = useEditor({ textDirection, @@ -1127,6 +1295,16 @@ export function Editor({ handleEditBlockMath(pos); }, }), + ScratchInlineMath.configure({ + katexOptions: { + throwOnError: false, + displayMode: false, + macros: katexMacros, + }, + onClick: (_node, pos) => { + handleEditInlineMath(pos); + }, + }), ], editorProps: { attributes: { @@ -1219,7 +1397,7 @@ export function Editor({ // Check if text looks like markdown (has common markdown patterns) const markdownPatterns = - /^#{1,6}\s|^\s*[-*+]\s|^\s*\d+\.\s|^\s*>\s|```|^\s*\[.*\]\(.*\)|^\s*!\[|\*\*.*\*\*|__.*__|~~.*~~|^\s*[-*_]{3,}\s*$|^\|.+\||\$\$[\s\S]+?\$\$/m; + /^#{1,6}\s|^\s*[-*+]\s|^\s*\d+\.\s|^\s*>\s|```|^\s*\[.*\]\(.*\)|^\s*!\[|\*\*.*\*\*|__.*__|~~.*~~|^\s*[-*_]{3,}\s*$|\|.+\||\$\$[\s\S]+?\$\$|(^|[^\w$])\$(?!\d)([^$\n]+?)\$(?!\$)/m; if (!markdownPatterns.test(text)) { // Not markdown, let TipTap handle it normally return false; @@ -1544,8 +1722,8 @@ export function Editor({ if (linkPopupRef.current) { linkPopupRef.current.destroy(); } - if (blockMathPopupRef.current) { - blockMathPopupRef.current.destroy(); + if (mathPopupRef.current) { + mathPopupRef.current.destroy(); } }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -1555,8 +1733,8 @@ export function Editor({ const handleAddLink = useCallback(() => { if (!editor) return; - // Close block math popup if open (popups are mutually exclusive) - closeBlockMathPopup(); + // Close math popup if open (popups are mutually exclusive) + closeMathPopup(); // Destroy existing popup if any if (linkPopupRef.current) { @@ -1676,7 +1854,7 @@ export function Editor({ component.destroy(); }, }); - }, [editor, closeBlockMathPopup]); + }, [editor, closeMathPopup]); // Image handler const handleAddImage = useCallback(async () => { @@ -1727,6 +1905,14 @@ export function Editor({ window.removeEventListener("slash-command-block-math", handler); }, [handleAddBlockMath]); + // Listen for slash command inline math insertion + useEffect(() => { + const handler = () => handleAddInlineMath(); + window.addEventListener("slash-command-inline-math", handler); + return () => + window.removeEventListener("slash-command-inline-math", handler); + }, [handleAddInlineMath]); + // Keyboard shortcut for Cmd+K to add link (only when editor is focused) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { diff --git a/src/components/editor/MathExtensions.ts b/src/components/editor/MathExtensions.ts index c37e41e0..06f29b16 100644 --- a/src/components/editor/MathExtensions.ts +++ b/src/components/editor/MathExtensions.ts @@ -1,6 +1,7 @@ import { InputRule } from "@tiptap/core"; -import { BlockMath } from "@tiptap/extension-mathematics"; -import { Plugin, PluginKey, NodeSelection } from "@tiptap/pm/state"; +import { BlockMath, InlineMath } from "@tiptap/extension-mathematics"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { NodeSelection, Plugin, PluginKey } from "@tiptap/pm/state"; export function normalizeBlockMath(value: string): string { const trimmed = value.trim(); @@ -8,6 +9,54 @@ export function normalizeBlockMath(value: string): string { return (match?.[1] ?? trimmed).trim(); } +export function normalizeInlineMath(value: string): string { + const trimmed = value.trim(); + const match = trimmed.match(/^\$([\s\S]*?)\$$/); + return (match?.[1] ?? trimmed).trim(); +} + +function createMathSelectionPlugin( + typeName: "blockMath" | "inlineMath", + onClick?: (node: ProseMirrorNode, pos: number) => void, +) { + return new Plugin({ + key: new PluginKey(`${typeName}Selection`), + view() { + return { + // Clear native DOM selection when a math node is selected. + // ProseMirror's NodeSelection can otherwise leave a browser highlight + // bleeding beyond the atom node. + update(view) { + const { selection } = view.state; + if ( + selection instanceof NodeSelection && + selection.node.type.name === typeName + ) { + window.getSelection()?.removeAllRanges(); + } + }, + }; + }, + props: { + // Open the editor on Enter or Space when a math node is selected. + handleKeyDown(view, event) { + if (event.key !== "Enter" && event.key !== " ") return false; + const { selection } = view.state; + if ( + selection instanceof NodeSelection && + selection.node.type.name === typeName && + onClick + ) { + event.preventDefault(); + onClick(selection.node, selection.from); + return true; + } + return false; + }, + }, + }); +} + export const ScratchBlockMath = BlockMath.extend({ addInputRules() { return [ @@ -28,44 +77,47 @@ export const ScratchBlockMath = BlockMath.extend({ }, addProseMirrorPlugins() { - const onClick = this.options.onClick; + return [createMathSelectionPlugin("blockMath", this.options.onClick)]; + }, +}); + +export const ScratchInlineMath = InlineMath.extend({ + addInputRules() { return [ - new Plugin({ - key: new PluginKey("blockMathSelection"), - view() { + new InputRule({ + find: (text) => { + const match = text.match( + /(^|[^\w$])\$(?!\d)([^$\n]+?)\$(?!\$)$/, + ); + if (!match) return null; + + const latex = (match[2] ?? "").trim(); + if (!latex) return null; + + const prefix = match[1] ?? ""; return { - // Clear native DOM selection when a blockMath node is selected. - // ProseMirror's NodeSelection sets a DOM selection that spans all - // content before the atom node, causing a visible highlight bleed. - update(view) { - const { selection } = view.state; - if ( - selection instanceof NodeSelection && - selection.node.type.name === "blockMath" - ) { - window.getSelection()?.removeAllRanges(); - } - }, + index: (match.index ?? 0) + prefix.length, + text: match[0].slice(prefix.length), + data: { latex }, }; }, - props: { - // Open editor on Enter or Space when a blockMath node is selected - handleKeyDown(view, event) { - if (event.key !== "Enter" && event.key !== " ") return false; - const { selection } = view.state; - if ( - selection instanceof NodeSelection && - selection.node.type.name === "blockMath" && - onClick - ) { - event.preventDefault(); - onClick(selection.node, selection.from); - return true; - } - return false; - }, + handler: ({ state, range, match }) => { + const latex = String(match.data?.latex ?? "").trim(); + if (!latex) return; + + state.tr.replaceWith( + range.from, + range.to, + state.schema.nodes.inlineMath.create({ latex }), + ); }, }), ]; }, + + addProseMirrorPlugins() { + return [ + createMathSelectionPlugin("inlineMath", this.options.onClick), + ]; + }, }); diff --git a/src/components/editor/SlashCommand.tsx b/src/components/editor/SlashCommand.tsx index 370a242a..8f262fc5 100644 --- a/src/components/editor/SlashCommand.tsx +++ b/src/components/editor/SlashCommand.tsx @@ -143,6 +143,16 @@ const SLASH_COMMANDS: SlashCommandItem[] = [ window.dispatchEvent(new CustomEvent("slash-command-block-math")); }, }, + { + title: "Inline Math", + description: "Inline math expression", + icon: , + aliases: ["inline math", "latex"], + command: (editor) => { + editor.chain().focus().run(); + window.dispatchEvent(new CustomEvent("slash-command-inline-math")); + }, + }, { title: "Horizontal Rule", description: "Visual divider", diff --git a/src/lib/plainText.ts b/src/lib/plainText.ts index c446db88..0217e06b 100644 --- a/src/lib/plainText.ts +++ b/src/lib/plainText.ts @@ -21,6 +21,11 @@ export function plainTextFromMarkdown(markdown: string): string { text = text.replace(/!\[(.*?)\]\([^)]*\)/g, "$1"); text = text.replace(/\[(.+?)\]\([^)]*\)/g, "$1"); text = text.replace(/`([^`]+)`/g, "$1"); + text = text.replace(/\$\$([\s\S]+?)\$\$/g, "$1"); + text = text.replace( + /(^|[^\w$])\$(?!\d)([^$\n]+?)\$(?!\$)/g, + (_match, prefix: string, latex: string) => `${prefix}${latex}`, + ); text = text.replace(/\*\*(.+?)\*\*/g, "$1"); text = text.replace(/(?