feat(editor): render LaTeX formulas (#197)

This commit is contained in:
tianma-if committed 2026-08-09 15:45:11 +08:00
1 parent 27a3470302
commit ef3b81aaa5
24 files changed
+1935 -1011

No files matched your search

@@ -54,6 +54,18 @@ enum TipTapContentSource: Sendable {
}
static func markdownIsStructurallyRicher(_ markdown: String, thanJSON json: String) -> Bool {
let markdownHasMath = markdown.range(
of: #"(?s)(?<!\\)\$\$.*?(?<!\\)\$\$|(?<!\\)\$(?!\$|\d)[^\n$]+?(?<!\\)\$"#,
options: .regularExpression
) != nil
let jsonHasMath = json.contains(#""type":"inlineMath""#)
|| json.contains(#""type": "inlineMath""#)
|| json.contains(#""type":"blockMath""#)
|| json.contains(#""type": "blockMath""#)
if markdownHasMath && !jsonHasMath {
return true
}
let md = structureScore(markdown: markdown)
let js = structureScore(json: json)
return md >= 2 && md > js
@@ -68,6 +80,7 @@ enum TipTapContentSource: Sendable {
if markdown.range(of: #"(?m)^>\s+\S"#, options: .regularExpression) != nil { score += 1 }
if markdown.contains("**") || markdown.contains("__") { score += 1 }
if markdown.contains("![") { score += 1 }
if markdown.contains("$$") { score += 3 }
return score
}
@@ -81,6 +94,7 @@ enum TipTapContentSource: Sendable {
if json.contains("\"blockquote\"") { score += 1 }
if json.contains("\"bold\"") || json.contains("\"italic\"") { score += 1 }
if json.contains("\"type\":\"image\"") || json.contains("\"type\": \"image\"") { score += 1 }
if json.contains("\"inlineMath\"") || json.contains("\"blockMath\"") { score += 3 }
return score
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -8,11 +8,13 @@
"@tiptap/core": "3.27.1",
"@tiptap/extension-code-block": "3.27.1",
"@tiptap/extension-image": "3.27.1",
"@tiptap/extension-mathematics": "3.27.1",
"@tiptap/extension-placeholder": "3.27.1",
"@tiptap/extension-table": "3.27.1",
"@tiptap/markdown": "3.27.1",
"@tiptap/pm": "3.27.1",
"@tiptap/starter-kit": "3.27.1",
"katex": "0.16.22",
"mermaid": "^11.16.0",
},
"devDependencies": {
@@ -175,6 +177,8 @@
"@tiptap/extension-list-keymap": ["@tiptap/extension-list-keymap@3.29.2", "", { "peerDependencies": { "@tiptap/extension-list": "3.29.2" } }, "sha512-R+3k8OLnxdCH7Xy9ieOwUt5m2Je74u8mikothGmsYVO2Zyq48fIbmZ+X6RBPCu7DBOI2FIUhHEFbKQeDWvDNmA=="],
"@tiptap/extension-mathematics": ["@tiptap/extension-mathematics@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1", "katex": "^0.16.4" } }, "sha512-LClcd0f5LNHObq4fyBoHuZCWWtVjdHA5UdCpWCSdegC3kcuNhZQp/uey8mzPZXKUb73XDYvzjdhFz2UCRHH1Lw=="],
"@tiptap/extension-ordered-list": ["@tiptap/extension-ordered-list@3.29.2", "", { "peerDependencies": { "@tiptap/extension-list": "3.29.2" } }, "sha512-ndCunC+UsYOpkOtL7vGnDz21UNa45WUlcO9wMT1fbuYow2QnRhsuMlCWENXI52YPPARWuQ0RDgN7q6TaxPERBg=="],
"@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2" } }, "sha512-7qJj5YTr11vvjNgjDN1ypOfwTovc0QOCYcit/rskeuVgnmQZOZQzC/BbyKLLG7UGnpRLemU/mEGbW9pAqjAXkQ=="],
@@ -371,7 +375,7 @@
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
"katex": ["katex@0.16.22", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg=="],
"khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="],
@@ -473,6 +477,8 @@
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
"mermaid/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
+2
View File
@@ -12,11 +12,13 @@
"@tiptap/core": "3.27.1",
"@tiptap/extension-code-block": "3.27.1",
"@tiptap/extension-image": "3.27.1",
"@tiptap/extension-mathematics": "3.27.1",
"@tiptap/extension-placeholder": "3.27.1",
"@tiptap/extension-table": "3.27.1",
"@tiptap/markdown": "3.27.1",
"@tiptap/pm": "3.27.1",
"@tiptap/starter-kit": "3.27.1",
"katex": "0.16.22",
"mermaid": "^11.16.0"
},
"devDependencies": {
+32 -8
View File
@@ -1,4 +1,5 @@
import "./styles.css";
import "katex/dist/katex.min.css";
import { Editor, mergeAttributes, Node } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
@@ -8,6 +9,7 @@ import { TableKit } from "@tiptap/extension-table";
import { Markdown } from "@tiptap/markdown";
import { NodeSelection } from "@tiptap/pm/state";
import mermaid from "mermaid";
import { createEdgeEverMathematics } from "./mathematics";
/** Keep in sync with packages/shared MergeDivider (iOS bundle cannot import monorepo shared). */
const MERGE_DIVIDER_MARKDOWN_MARKER = "<!-- edgeever:merge-divider -->";
@@ -603,6 +605,7 @@ function buildExtensions(placeholder: string) {
codeBlock: false,
}),
MergeDivider,
...createEdgeEverMathematics(),
CodeBlock.configure({
languageClassPrefix: "language-",
}),
@@ -768,15 +771,37 @@ function handleResourcePointer(event: Event, kind: "click" | "contextmenu"): boo
editorEl.addEventListener("touchcancel", clear, { passive: true });
})();
const LITERAL_DOLLAR_PLACEHOLDER = "\uE000edgeever-dollar\uE001";
const protectLiteralDollarPairs = (value: unknown): unknown => {
if (!value || typeof value !== "object") return value;
const node = value as { type?: unknown; text?: unknown; content?: unknown };
if (node.type === "text" && typeof node.text === "string") {
const dollarCount = Array.from(node.text).filter((character) => character === "$").length;
return dollarCount >= 2
? { ...node, text: node.text.replaceAll("$", LITERAL_DOLLAR_PLACEHOLDER) }
: value;
}
return Array.isArray(node.content)
? { ...node, content: node.content.map(protectLiteralDollarPairs) }
: value;
};
const serializeEditorMarkdown = (ed: Editor) => {
const manager = (ed.storage as { markdown?: { manager?: { serialize?: (doc: unknown) => string } } })
.markdown?.manager;
if (manager?.serialize) {
return manager.serialize(protectLiteralDollarPairs(ed.getJSON())).replaceAll(LITERAL_DOLLAR_PLACEHOLDER, "\\$");
}
return typeof ed.getMarkdown === "function"
? ed.getMarkdown()
: ed.getText({ blockSeparator: "\n\n" });
};
function emitChange(ed: Editor) {
try {
const contentJson = JSON.stringify(ed.getJSON());
// @tiptap/markdown storage
const storage = ed.storage as { markdown?: { getMarkdown?: () => string } };
const contentMarkdown =
storage.markdown?.getMarkdown?.() ??
// fallback: plain text
ed.getText({ blockSeparator: "\n\n" });
const contentMarkdown = serializeEditorMarkdown(ed);
post({ type: "change", contentMarkdown, contentJson });
} catch (error) {
post({ type: "error", message: error instanceof Error ? error.message : String(error) });
@@ -926,8 +951,7 @@ const api: EdgeEverEditorAPI = {
},
getMarkdown() {
const storage = editor.storage as { markdown?: { getMarkdown?: () => string } };
return storage.markdown?.getMarkdown?.() ?? editor.getText({ blockSeparator: "\n\n" });
return serializeEditorMarkdown(editor);
},
getDocument() {
+58
View File
@@ -0,0 +1,58 @@
import { BlockMath, InlineMath } from "@tiptap/extension-mathematics";
const isEscaped = (source: string, index: number) => {
let backslashCount = 0;
for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) {
backslashCount += 1;
}
return backslashCount % 2 === 1;
};
const findClosingDelimiter = (source: string, delimiter: "$" | "$$", from: number) => {
for (let index = from; index <= source.length - delimiter.length; index += 1) {
if (source.startsWith(delimiter, index) && !isEscaped(source, index)) return index;
}
return -1;
};
const EdgeEverInlineMath = InlineMath.extend({
markdownTokenizer: {
name: "inlineMath",
level: "inline",
start: (source: string) => source.indexOf("$"),
tokenize: (source: string) => {
if (!source.startsWith("$") || source.startsWith("$$")) return undefined;
const closingIndex = findClosingDelimiter(source, "$", 1);
if (closingIndex < 0 || source[closingIndex + 1] === "$") return undefined;
const raw = source.slice(0, closingIndex + 1);
const latex = source.slice(1, closingIndex).trim();
if (!latex || latex.includes("\n")) return undefined;
if (/^\d+(?:[.,]\d+)?$/u.test(latex)) return { type: "text", raw, text: raw };
return { type: "inlineMath", raw, latex };
},
},
});
const EdgeEverBlockMath = BlockMath.extend({
markdownTokenizer: {
name: "blockMath",
level: "block",
start: (source: string) => source.indexOf("$$"),
tokenize: (source: string) => {
if (!source.startsWith("$$") || source.startsWith("$$$")) return undefined;
const closingIndex = findClosingDelimiter(source, "$$", 2);
if (closingIndex < 0) return undefined;
const latex = source.slice(2, closingIndex).trim();
if (!latex) return undefined;
return { type: "blockMath", raw: source.slice(0, closingIndex + 2), latex };
},
},
});
const katexOptions = { throwOnError: false, strict: "warn" as const, trust: false };
/** Keep in sync with packages/shared/src/mathematics.ts. */
export const createEdgeEverMathematics = () => [
EdgeEverBlockMath.configure({ katexOptions }),
EdgeEverInlineMath.configure({ katexOptions }),
];
+21
View File
@@ -13,6 +13,27 @@
--placeholder: #94a3b8;
}
.tiptap-mathematics-render[data-type="block-math"] {
max-width: 100%;
margin: 1rem 0;
overflow-x: auto;
overflow-y: hidden;
padding: 0.25rem 0;
text-align: center;
-webkit-overflow-scrolling: touch;
}
.inline-math-error,
.block-math-error {
color: #be123c;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
html[data-theme="dark"] .inline-math-error,
html[data-theme="dark"] .block-math-error {
color: #fda4af;
}
html[data-theme="dark"] {
--text: #f8fafc;
--muted: #cbd5e1;
@@ -109,6 +109,14 @@ final class TipTapContentSourceTests: XCTestCase {
XCTAssertTrue(d.useJSON, "simple image+text notes should keep JSON for order fidelity")
}
func testEditorAndViewerRecoverMathMissingFromLegacyJSON() {
let markdown = "Euler: $e^{i\\pi}+1=0$."
let flatJSON = #"{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Euler: $e^{i\\pi}+1=0$."}]}]}"#
XCTAssertFalse(TipTapContentSource.resolve(mode: .editor, documentJSON: flatJSON, markdown: markdown).useJSON)
XCTAssertFalse(TipTapContentSource.resolve(mode: .viewer, documentJSON: flatJSON, markdown: markdown).useJSON)
}
/// Live WKWebView: packaged TipTap must turn Markdown into real DOM structure.
@MainActor
func testPackagedEditorSetMarkdownRendersHeadingsListsAndBold() async throws {
@@ -169,6 +177,12 @@ final class TipTapContentSourceTests: XCTestCase {
```js
console.log(1)
```
Euler: $e^{i\\pi}+1=0$.
$$
\\frac{a}{b}
$$
"""
let b64 = Data(sample.utf8).base64EncodedString()
_ = try await eval(webView, """
@@ -189,6 +203,7 @@ final class TipTapContentSourceTests: XCTestCase {
let strong = try await evalInt(webView, "document.querySelectorAll('strong,b').length")
let li = try await evalInt(webView, "document.querySelectorAll('li').length")
let pre = try await evalInt(webView, "document.querySelectorAll('pre').length")
let math = try await evalInt(webView, "document.querySelectorAll('.tiptap-mathematics-render .katex').length")
let rawLeak = try await evalBool(
webView,
"document.body.innerText.indexOf('## 标题渲染') >= 0 && document.querySelectorAll('h1,h2,h3').length === 0"
@@ -198,6 +213,7 @@ final class TipTapContentSourceTests: XCTestCase {
XCTAssertGreaterThanOrEqual(strong, 1, "bold markdown must render as strong/b")
XCTAssertGreaterThanOrEqual(li, 2, "list items must render")
XCTAssertGreaterThanOrEqual(pre, 1, "fenced code must render as pre")
XCTAssertEqual(math, 2, "inline and block LaTeX must render through KaTeX")
XCTAssertFalse(rawLeak, "must not show raw '## heading' as plain text without heading nodes")
// Contrast: flattened JSON path must NOT be what viewer policy selects.
+1
View File
@@ -40,6 +40,7 @@
"expo-sqlite": "~57.0.1",
"expo-status-bar": "latest",
"expo-updates": "~57.0.8",
"katex": "0.16.22",
"mermaid": "^11.16.0",
"react": "19.2.3",
"react-dom": "19.2.3",
@@ -1,6 +1,7 @@
'use dom';
import "mermaid/dist/mermaid.min.js";
import "katex/dist/katex.min.css";
import { renderMermaidSVG, THEMES } from "beautiful-mermaid";
import Image from "@tiptap/extension-image";
import CodeBlock from "@tiptap/extension-code-block";
@@ -9,7 +10,7 @@ import { TableKit } from "@tiptap/extension-table";
import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import * as Clipboard from "expo-clipboard";
import { MEMO_CONTENT_STYLE, MergeDivider, getImageReferrerPolicy, getResourceIdFromUrl, type TiptapDoc } from "@edgeever/shared";
import { createEdgeEverMathematics, MEMO_CONTENT_STYLE, MergeDivider, getImageReferrerPolicy, getResourceIdFromUrl, type TiptapDoc } from "@edgeever/shared";
import {
DEFAULT_IMAGE_WIDTH_PERCENT,
IMAGE_WIDTH_PRESETS,
@@ -404,6 +405,7 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
extensions: [
StarterKit.configure({ codeBlock: false, link: { openOnClick: false } }),
MergeDivider,
...createEdgeEverMathematics(),
mermaidCodeBlockExtension,
protectedImageExtension,
TableKit.configure({
@@ -1792,6 +1794,8 @@ const getEditorStyles = (theme: "light" | "dark", options?: { viewer?: boolean }
.edgeever-editor-content pre { max-width: 100%; overflow-x: auto; border-radius: 10px; padding: 14px 90px 14px 14px; background: #0f172a; color: #e2e8f0; font-size: 0.9rem; }
.edgeever-editor-content code { border-radius: 4px; padding: 2px 4px; background: ${theme === "dark" ? "#1e293b" : "#f1f5f9"}; font-size: 0.9em; }
.edgeever-editor-content pre code { padding: 0; background: transparent; font-size: inherit; }
.edgeever-editor-content .tiptap-mathematics-render[data-type="block-math"] { max-width: 100%; margin: 16px 0; overflow-x: auto; overflow-y: hidden; padding: 4px 0; text-align: center; -webkit-overflow-scrolling: touch; }
.edgeever-editor-content .inline-math-error, .edgeever-editor-content .block-math-error { color: ${theme === "dark" ? "#fda4af" : "#be123c"}; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
/* External hyperlinks (match Web default ProseMirror). Attachment chips override below. */
.edgeever-editor-content a {
color: ${theme === "dark" ? "#86efac" : "#00751f"};
+3
View File
@@ -8,6 +8,7 @@ import Placeholder from "@tiptap/extension-placeholder";
import { TableKit } from "@tiptap/extension-table";
import { useTranslation } from "react-i18next";
import * as m from "motion/react-m";
import "katex/dist/katex.min.css";
import {
ChevronLeft,
ChevronRight,
@@ -84,6 +85,7 @@ import { cn, formatDateTime, parseTagsText } from "@/lib/utils";
import { EDITOR_CONTENT_MAX_WIDTH, EDITOR_CONTENT_MAX_WIDTH_COLLAPSED } from "@/lib/workspace-ui";
import {
countMemoCharacters,
createEdgeEverMathematics,
docToMarkdown,
MEMO_CONTENT_STYLE,
markdownToDoc,
@@ -1025,6 +1027,7 @@ const RichEditorPane = ({
}),
EdgeEverCodeBlock.configure({ lowlight: codeBlockLowlight, defaultLanguage: "plaintext" }),
MergeDivider,
...createEdgeEverMathematics(),
ThemeBlock,
ResizableImage.configure({
allowBase64: false,
@@ -1,3 +1,4 @@
import "katex/dist/katex.min.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { EditorContent, useEditor } from "@tiptap/react";
@@ -6,7 +7,7 @@ import Image from "@tiptap/extension-image";
import { mergeAttributes } from "@tiptap/core";
import Placeholder from "@tiptap/extension-placeholder";
import { TableKit } from "@tiptap/extension-table";
import { createExcerpt, docToMarkdown, docToText, emptyDoc, getImageReferrerPolicy, MergeDivider, type MemoDetail, type MemoEditSession, type Notebook, type TiptapDoc } from "@edgeever/shared";
import { createEdgeEverMathematics, createExcerpt, docToMarkdown, docToText, emptyDoc, getImageReferrerPolicy, MergeDivider, type MemoDetail, type MemoEditSession, type Notebook, type TiptapDoc } from "@edgeever/shared";
import { getMobileEditorInputAttributes, getMobileEditorPlaceholder } from "@edgeever/shared/mobile-editor";
import {
MobileEditorFallback,
@@ -188,6 +189,7 @@ export const MobileStandaloneTiptapEditor = ({
StarterKit.configure({ codeBlock: false }),
EdgeEverCodeBlock.configure({ lowlight: codeBlockLowlight, defaultLanguage: "plaintext" }),
MergeDivider,
...createEdgeEverMathematics(),
ThemeBlock,
ProtectedExternalImage.configure({
allowBase64: false,
@@ -1,3 +1,4 @@
import "katex/dist/katex.min.css";
import { Node, mergeAttributes } from "@tiptap/core";
import Image from "@tiptap/extension-image";
import { TableKit } from "@tiptap/extension-table";
@@ -12,6 +13,7 @@ import { api } from "@/lib/api";
import { EdgeEverCodeBlock, codeBlockLowlight } from "@/lib/code-block";
import {
parseImageWidth,
createEdgeEverMathematics,
getImageReferrerPolicy,
MergeDivider,
resolveMemoContentDoc,
@@ -79,6 +81,7 @@ const SharedDocument = ({ share, token }: { share: PublicMemoShare; token: strin
StarterKit.configure({ codeBlock: false, link: { openOnClick: true } }),
EdgeEverCodeBlock.configure({ lowlight: codeBlockLowlight, defaultLanguage: "plaintext" }),
MergeDivider,
...createEdgeEverMathematics(),
SharedThemeBlock,
SharedImage.configure({ allowBase64: false, inline: false }),
TableKit.configure({ table: { renderWrapper: true } }),
+2 -1
View File
@@ -7,6 +7,7 @@ import {
type NotePrintPayload,
} from "@/lib/note-print";
import { getMessageTargetOrigin } from "@/lib/app-page-path";
import katexStyles from "katex/dist/katex.min.css?inline";
import printStyles from "@/styles/note-print.css?inline";
import "@fontsource-variable/noto-sans-sc/wght.css";
import "@/styles/note-print-screen.css";
@@ -128,7 +129,7 @@ const renderPreview = async (payload: NotePrintPayload) => {
const previewer = new Previewer();
await previewer.preview(
source,
[{ [window.location.href]: printStyles }],
[{ [window.location.href]: `${katexStyles}\n${printStyles}` }],
preview
);
await document.fonts.ready;
+24
View File
@@ -5,6 +5,30 @@
@tailwind components;
@tailwind utilities;
.ProseMirror .tiptap-mathematics-render[data-type="block-math"] {
max-width: 100%;
margin: 1rem 0;
overflow-x: auto;
overflow-y: hidden;
padding: 0.25rem 0;
text-align: center;
}
.ProseMirror .tiptap-mathematics-render--editable {
cursor: text;
}
.ProseMirror .inline-math-error,
.ProseMirror .block-math-error {
color: #be123c;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
:root.dark .ProseMirror .inline-math-error,
:root.dark .ProseMirror .block-math-error {
color: #fda4af;
}
@layer base {
:root {
--background: 80 11% 96%;
+7
View File
@@ -21,6 +21,13 @@
box-sizing: border-box;
}
.edgeever-print-content .tiptap-mathematics-render[data-type="block-math"] {
max-width: 100%;
margin: 5mm 0;
overflow: hidden;
text-align: center;
}
.edgeever-print-document {
color: #172033;
font-family: "Noto Sans SC Variable", sans-serif;
+16 -6
View File
@@ -34,6 +34,7 @@
"fflate": "^0.8.3",
"hono": "latest",
"i18next": "^26.3.4",
"katex": "0.16.22",
"lucide-react": "latest",
"marked": "^18.0.5",
"react": "latest",
@@ -78,7 +79,7 @@
},
"apps/desktop": {
"name": "@edgeever/desktop",
"version": "1.12.7",
"version": "1.13.3",
"dependencies": {
"electron-updater": "^6.6.2",
},
@@ -130,6 +131,7 @@
"expo-sqlite": "~57.0.1",
"expo-status-bar": "latest",
"expo-updates": "~57.0.8",
"katex": "0.16.22",
"mermaid": "^11.16.0",
"react": "19.2.3",
"react-dom": "19.2.3",
@@ -209,9 +211,11 @@
"dependencies": {
"@tiptap/core": "3.27.1",
"@tiptap/extension-image": "3.27.1",
"@tiptap/extension-mathematics": "3.27.1",
"@tiptap/extension-table": "3.27.1",
"@tiptap/markdown": "3.27.1",
"@tiptap/starter-kit": "3.27.1",
"katex": "0.16.22",
},
},
},
@@ -729,7 +733,7 @@
"@expo/sudo-prompt": ["@expo/sudo-prompt@9.3.2", "", {}, "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw=="],
"@expo/ui": ["@expo/ui@57.0.8", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0", "vaul": "^1.1.2" }, "peerDependencies": { "@babel/core": "*", "expo": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-worklets": "*" }, "optionalPeers": ["@babel/core", "react-dom", "react-native-worklets"] }, "sha512-aR17CRM45k4JyFahX4Xn2b6ti3aiddC4SAzvsm3iUCVacQA6tGmClu012rYNeVXhJ96csX2o7EEymfzYqgJNxw=="],
"@expo/ui": ["@expo/ui@57.0.9", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0", "vaul": "^1.1.2" }, "peerDependencies": { "@babel/core": "*", "expo": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-worklets": "*" }, "optionalPeers": ["@babel/core", "react-dom", "react-native-worklets"] }, "sha512-VIxvk5ncgylBj2vrIP1iLaMc3XmYucKbf0hIcg3qx9l2anB9JzaYnH7cvVgNU3RfwV8R9m/tA7lX9BP7D8uMQw=="],
"@expo/vector-icons": ["@expo/vector-icons@15.1.1", "", { "peerDependencies": { "expo-font": ">=14.0.4", "react": "*", "react-native": "*" } }, "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw=="],
@@ -1239,6 +1243,8 @@
"@tiptap/extension-list-keymap": ["@tiptap/extension-list-keymap@3.27.1", "", { "peerDependencies": { "@tiptap/extension-list": "3.27.1" } }, "sha512-OIMZNlzPSO8WRd4ic73Fxckzl4N1tesjjLL2XApaNA/uMpO0LoF6WSRPAWv+Z24Wp92ARRJAnRP7iZoI5+Jxig=="],
"@tiptap/extension-mathematics": ["@tiptap/extension-mathematics@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1", "katex": "^0.16.4" } }, "sha512-LClcd0f5LNHObq4fyBoHuZCWWtVjdHA5UdCpWCSdegC3kcuNhZQp/uey8mzPZXKUb73XDYvzjdhFz2UCRHH1Lw=="],
"@tiptap/extension-ordered-list": ["@tiptap/extension-ordered-list@3.27.1", "", { "peerDependencies": { "@tiptap/extension-list": "3.27.1" } }, "sha512-GYrKqD//9nHJ2r80uXqbDMzRnFpGzbaEQRTSGaO/SH7DvXWFMow8evkOdjQ7PCQO07jNjJo75+A85Jwu3Ov3AA=="],
"@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-7K7eo1gruOgAsnbK+GCV23AUVUI0cL1bTig8HaPneoFMVbig7vddk8jNLKBWO8TXVbG7TuHdnDN4F98vdtwh5Q=="],
@@ -1553,7 +1559,7 @@
"babel-plugin-transform-flow-enums": ["babel-plugin-transform-flow-enums@0.0.2", "", { "dependencies": { "@babel/plugin-syntax-flow": "^7.12.1" } }, "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ=="],
"babel-preset-expo": ["babel-preset-expo@57.0.5", "", { "dependencies": { "@babel/generator": "^7.20.5", "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.28.6", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@babel/plugin-transform-react-pure-annotations": "^7.27.1", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-plugin-codegen": "0.86.2", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.36.0", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "expo-widgets": "^57.0.7", "react-refresh": ">=0.14.0 <1.0.0" }, "optionalPeers": ["@babel/runtime", "expo", "expo-widgets"] }, "sha512-sz9ZBTiUAlu5P9VORVNKoeAaY2qKFQRC6eQV5Y8aMkqcorvXKEv97UeCkFMLmMBEPKA+QeWImREKkX9/H21WQA=="],
"babel-preset-expo": ["babel-preset-expo@57.0.6", "", { "dependencies": { "@babel/generator": "^7.20.5", "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.28.6", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@babel/plugin-transform-react-pure-annotations": "^7.27.1", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-plugin-codegen": "0.86.2", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.36.0", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "expo-widgets": "^57.0.8", "react-refresh": ">=0.14.0 <1.0.0" }, "optionalPeers": ["@babel/runtime", "expo", "expo-widgets"] }, "sha512-ASyy0iP7yPQtq2QaMEnZG4O7YQ/x4sTMguk7PZcow2OHFnWsBpX6v+UCmh/uwCzGfD1q93jDQEuACWwWx4kSrw=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
@@ -2119,7 +2125,7 @@
"expo-modules-jsi": ["expo-modules-jsi@57.0.3", "", { "peerDependencies": { "react-native": "*" } }, "sha512-B+iXJCC5OIXjFKkLLSY2DZ8BGS+hC3PBTrALpV43pHqhXqqZrRH3uTEylZgsoKQO8N8tjmjAua2ucTFHtr1o0w=="],
"expo-router": ["expo-router@57.0.9", "", { "dependencies": { "@expo/log-box": "^57.0.2", "@expo/metro-runtime": "^57.0.8", "@expo/schema-utils": "^57.0.2", "@expo/ui": "^57.0.8", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "client-only": "^0.0.1", "color": "^4.2.3", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-glass-effect": "^57.0.1", "expo-server": "^57.0.1", "expo-symbols": "^57.0.1", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-is": "^19.1.0", "react-native-drawer-layout": "^4.2.2", "react-native-screens": "^4.26.0", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "standard-navigation": "^0.0.5", "vaul": "^1.1.2" }, "peerDependencies": { "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^57.0.8", "expo-linking": "^57.0.4", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-jPBwHmKmBfzCLUuKgFf+I/UhFkxvS3J2WF5MQKcuupC8XzQPBAqdzbxmcPIDY3VIVabHw7WnhwzsROWA74t1LA=="],
"expo-router": ["expo-router@57.0.11", "", { "dependencies": { "@expo/log-box": "^57.0.2", "@expo/metro-runtime": "^57.0.8", "@expo/schema-utils": "^57.0.2", "@expo/ui": "^57.0.9", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "client-only": "^0.0.1", "color": "^4.2.3", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-glass-effect": "^57.0.1", "expo-server": "^57.0.1", "expo-symbols": "^57.0.2", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-is": "^19.1.0", "react-native-drawer-layout": "^4.2.2", "react-native-screens": "^4.26.0", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "standard-navigation": "^0.0.5", "vaul": "^1.1.2" }, "peerDependencies": { "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^57.0.9", "expo-linking": "^57.0.5", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-kE2hz4lLkddZ94vN4nmbq5aXeo0t6FaZ8xJn/Hyn8/dQPsGlvDK0j4ZVayEUUdNmkNHsBVihgf1bl/2rCsYEzA=="],
"expo-secure-store": ["expo-secure-store@57.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-tLa1VmSadOq19mA/dwkl99RbHyjLE0T1qqBYMY3/OsguZTI+rlrDy/DDJjupqlVtmr95hD7o1pYqx5aL+B4YMA=="],
@@ -2135,7 +2141,7 @@
"expo-structured-headers": ["expo-structured-headers@57.0.0", "", {}, "sha512-//t9UNPbJSEysc2x4VKJG/u7Osvv5DYJWsET5bqt/B+qcD1by/JXvSQzX3Q/YAgA96xFPontrz6OAPLbO4JKEA=="],
"expo-symbols": ["expo-symbols@57.0.1", "", { "dependencies": { "@expo-google-fonts/material-symbols": "^0.4.1", "sf-symbols-typescript": "^2.0.0" }, "peerDependencies": { "expo": "*", "expo-font": "*", "react": "*", "react-native": "*" } }, "sha512-8Zf+a83OywV0vf1NUtSKpNqKcULmO0GTI+zfFnGYl7SLDH9FjL5RcEZoy6CHvCgq2KDrQF21pl3r7Tb4ItPscw=="],
"expo-symbols": ["expo-symbols@57.0.2", "", { "dependencies": { "@expo-google-fonts/material-symbols": "^0.4.1", "sf-symbols-typescript": "^2.0.0" }, "peerDependencies": { "expo": "*", "expo-font": "*", "react": "*", "react-native": "*" } }, "sha512-qZ0iqOflm5lZGwRsQ5Y8sDksw3GAUKwHSX1bJBoocXf7gu14vafIXXYWte+JT9VfXAUGyKodJhLHP/GoOrcNWg=="],
"expo-updates": ["expo-updates@57.0.8", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/plist": "^0.8.1", "@expo/spawn-async": "^1.8.0", "arg": "^4.1.0", "chalk": "^4.1.2", "debug": "^4.3.4", "expo-eas-client": "~57.0.1", "expo-manifests": "~57.0.1", "expo-structured-headers": "~57.0.0", "expo-updates-interface": "~57.0.1", "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", "nullthrows": "^1.1.1", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*", "expo-dev-client": "*", "react": "*", "react-native": "*" }, "optionalPeers": ["expo-dev-client"], "bin": { "expo-updates": "bin/cli.js" } }, "sha512-vHQYneTGoPZbA/3dtQlfrWQMyRjqO4t0g2K97Z3VX0K8GSTcANzTPe1MDYncGf7cU/nBIShgWXx7qSbLICEVdQ=="],
@@ -2567,7 +2573,7 @@
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
"katex": ["katex@0.16.22", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
@@ -4323,6 +4329,8 @@
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"mermaid/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
"metro/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
@@ -4815,6 +4823,8 @@
"log-symbols/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="],
"mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="],
"metro/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
+1
View File
@@ -125,6 +125,7 @@
"fflate": "^0.8.3",
"hono": "latest",
"i18next": "^26.3.4",
"katex": "0.16.22",
"lucide-react": "latest",
"marked": "^18.0.5",
"react": "latest",
+3 -1
View File
@@ -14,8 +14,10 @@
"dependencies": {
"@tiptap/core": "3.27.1",
"@tiptap/extension-image": "3.27.1",
"@tiptap/extension-mathematics": "3.27.1",
"@tiptap/extension-table": "3.27.1",
"@tiptap/markdown": "3.27.1",
"@tiptap/starter-kit": "3.27.1"
"@tiptap/starter-kit": "3.27.1",
"katex": "0.16.22"
}
}
+41
View File
@@ -141,6 +141,47 @@ describe("Mermaid Markdown conversion", () => {
});
});
describe("LaTeX Markdown conversion", () => {
const markdown = "Euler: $e^{i\\pi}+1=0$.\n\n$$\n\\frac{a}{b}\n$$";
test("round-trips inline and block formula nodes", () => {
const doc = markdownToDoc(markdown);
expect(doc.content[0]?.content?.[1]).toMatchObject({
type: "inlineMath",
attrs: { latex: "e^{i\\pi}+1=0" },
});
expect(doc.content[1]).toMatchObject({
type: "blockMath",
attrs: { latex: "\\frac{a}{b}" },
});
expect(docToMarkdown(doc)).toBe(markdown);
expect(docToText(doc)).toContain("e^{i\\pi}+1=0");
expect(docToText(doc)).toContain("\\frac{a}{b}");
});
test("keeps currency and escaped dollar pairs as literal text", () => {
const doc = markdownToDoc("Price: $100$; literal: \\$x$.");
const serialized = docToMarkdown(doc);
expect(doc.content[0]?.content?.some((node) => node.type === "inlineMath")).toBe(false);
expect(serialized).toBe("Price: \\$100\\$; literal: \\$x\\$.");
expect(markdownToDoc(serialized)).toEqual(doc);
});
test("recovers formula nodes omitted by an older JSON schema", () => {
const legacyDoc = markdownToDoc("Euler: $e^{i\\pi}+1=0$.");
legacyDoc.content[0] = {
type: "paragraph",
content: [{ type: "text", text: "Euler: $e^{i\\pi}+1=0$." }],
};
const resolved = resolveMemoContentDoc(legacyDoc, markdown);
expect(resolved.content[0]?.content?.[1]?.type).toBe("inlineMath");
expect(resolved.content[1]?.type).toBe("blockMath");
});
});
describe("Theme block compatibility", () => {
test("keeps themed blocks in the richer JSON document when Markdown is also present", () => {
const doc = {
+49 -2
View File
@@ -3,6 +3,17 @@ import { TableKit } from "@tiptap/extension-table";
import { Markdown, MarkdownManager } from "@tiptap/markdown";
import StarterKit from "@tiptap/starter-kit";
import { MergeDivider, MERGE_DIVIDER_NODE_TYPE } from "./merge-divider";
import {
BLOCK_MATH_NODE_TYPE,
createEdgeEverMathematics,
INLINE_MATH_NODE_TYPE,
} from "./mathematics";
export {
BLOCK_MATH_NODE_TYPE,
createEdgeEverMathematics,
INLINE_MATH_NODE_TYPE,
} from "./mathematics";
export {
MergeDivider,
@@ -63,6 +74,7 @@ const markdownManager = new MarkdownManager({
TableKit,
Image,
MergeDivider,
...createEdgeEverMathematics(),
Markdown.configure({
markedOptions: { gfm: true },
}),
@@ -102,7 +114,9 @@ export const resolveMemoContentDoc = (
!contentMarkdown?.trim() ||
docContainsNodeType(currentDoc, "table") ||
docContainsNodeType(currentDoc, "edgeeverThemeBlock") ||
docContainsNodeType(currentDoc, MERGE_DIVIDER_NODE_TYPE)
docContainsNodeType(currentDoc, MERGE_DIVIDER_NODE_TYPE) ||
docContainsNodeType(currentDoc, BLOCK_MATH_NODE_TYPE) ||
docContainsNodeType(currentDoc, INLINE_MATH_NODE_TYPE)
) {
return currentDoc;
}
@@ -114,6 +128,8 @@ export const resolveMemoContentDoc = (
// JSON document. Also recover merge dividers when only Markdown still has them.
return docContainsNodeType(markdownDoc, "table")
|| docContainsNodeType(markdownDoc, MERGE_DIVIDER_NODE_TYPE)
|| docContainsNodeType(markdownDoc, BLOCK_MATH_NODE_TYPE)
|| docContainsNodeType(markdownDoc, INLINE_MATH_NODE_TYPE)
|| !docToText(currentDoc)
? markdownDoc
: currentDoc;
@@ -184,6 +200,13 @@ export const docToText = (doc: unknown): string => {
}
}
if (current.type === BLOCK_MATH_NODE_TYPE || current.type === INLINE_MATH_NODE_TYPE) {
const latex = getStringAttr(current.attrs, "latex");
if (latex) {
pieces.push(latex);
}
}
if (Array.isArray(current.content)) {
for (const child of current.content) {
walk(child);
@@ -254,7 +277,31 @@ export const docToMarkdown = (doc: unknown): string => {
return "";
}
return markdownManager.serialize(stripEditorOnlyNodes(doc) as Parameters<typeof markdownManager.serialize>[0]);
const serializableDoc = protectLiteralDollarPairs(stripEditorOnlyNodes(doc));
return markdownManager
.serialize(serializableDoc as Parameters<typeof markdownManager.serialize>[0])
.replaceAll(LITERAL_DOLLAR_PLACEHOLDER, "\\$");
};
const LITERAL_DOLLAR_PLACEHOLDER = "\uE000edgeever-dollar\uE001";
/** Preserve dollar pairs that are text rather than inline-math nodes. */
const protectLiteralDollarPairs = (value: unknown): unknown => {
if (!value || typeof value !== "object") {
return value;
}
const node = value as { type?: unknown; text?: unknown; content?: unknown };
if (node.type === "text" && typeof node.text === "string") {
const dollarCount = Array.from(node.text).filter((character) => character === "$").length;
return dollarCount >= 2
? { ...node, text: node.text.replaceAll("$", LITERAL_DOLLAR_PLACEHOLDER) }
: value;
}
return Array.isArray(node.content)
? { ...node, content: node.content.map(protectLiteralDollarPairs) }
: value;
};
/**
+98
View File
@@ -0,0 +1,98 @@
import { BlockMath, InlineMath } from "@tiptap/extension-mathematics";
export const BLOCK_MATH_NODE_TYPE = "blockMath" as const;
export const INLINE_MATH_NODE_TYPE = "inlineMath" as const;
const isEscaped = (source: string, index: number) => {
let backslashCount = 0;
for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) {
backslashCount += 1;
}
return backslashCount % 2 === 1;
};
const findClosingDelimiter = (source: string, delimiter: "$" | "$$", from: number) => {
for (let index = from; index <= source.length - delimiter.length; index += 1) {
if (source.startsWith(delimiter, index) && !isEscaped(source, index)) {
return index;
}
}
return -1;
};
const EdgeEverInlineMath = InlineMath.extend({
markdownTokenizer: {
name: INLINE_MATH_NODE_TYPE,
level: "inline",
start: (source: string) => source.indexOf("$"),
tokenize: (source: string) => {
if (!source.startsWith("$") || source.startsWith("$$")) {
return undefined;
}
const closingIndex = findClosingDelimiter(source, "$", 1);
if (closingIndex < 0 || source[closingIndex + 1] === "$") {
return undefined;
}
const raw = source.slice(0, closingIndex + 1);
const latex = source.slice(1, closingIndex).trim();
if (!latex || latex.includes("\n")) {
return undefined;
}
// A dollar-wrapped number is overwhelmingly likely to be currency. Consume
// it as text so its closing delimiter cannot start a later math token.
if (/^\d+(?:[.,]\d+)?$/u.test(latex)) {
return { type: "text", raw, text: raw };
}
return {
type: INLINE_MATH_NODE_TYPE,
raw,
latex,
};
},
},
});
const EdgeEverBlockMath = BlockMath.extend({
markdownTokenizer: {
name: BLOCK_MATH_NODE_TYPE,
level: "block",
start: (source: string) => source.indexOf("$$"),
tokenize: (source: string) => {
if (!source.startsWith("$$") || source.startsWith("$$$")) {
return undefined;
}
const closingIndex = findClosingDelimiter(source, "$$", 2);
if (closingIndex < 0) {
return undefined;
}
const latex = source.slice(2, closingIndex).trim();
if (!latex) {
return undefined;
}
return {
type: BLOCK_MATH_NODE_TYPE,
raw: source.slice(0, closingIndex + 2),
latex,
};
},
},
});
const katexOptions = {
throwOnError: false,
strict: "warn" as const,
trust: false,
};
/** Fresh extension instances for each TipTap editor or Markdown manager. */
export const createEdgeEverMathematics = () => [
EdgeEverBlockMath.configure({ katexOptions }),
EdgeEverInlineMath.configure({ katexOptions }),
];
+19
View File
@@ -60,4 +60,23 @@ const answer = 42;
expect(doc.content.map((node) => node.type)).toEqual(["codeBlock", "image"]);
expect(docToMarkdown(doc)).toBe(markdown);
});
test("parses and serializes inline and block LaTeX", () => {
const markdown = `Euler: $e^{i\\pi}+1=0$.
$$
\\frac{a}{b}
$$`;
const doc = markdownToDoc(markdown);
expect(doc.content[0]?.content?.[1]).toMatchObject({
type: "inlineMath",
attrs: { latex: "e^{i\\pi}+1=0" },
});
expect(doc.content[1]).toMatchObject({
type: "blockMath",
attrs: { latex: "\\frac{a}{b}" },
});
expect(docToMarkdown(doc)).toBe(markdown);
});
});