diff --git a/components/notes/InlineMarkdownEditor.test.tsx b/components/notes/InlineMarkdownEditor.test.tsx index 2963118c7..a28a5d0f6 100644 --- a/components/notes/InlineMarkdownEditor.test.tsx +++ b/components/notes/InlineMarkdownEditor.test.tsx @@ -39,22 +39,27 @@ test("source undo history coalesces only adjacent edits of the same typing kind" }); test("math language detection does not mistake plain text blocks for TeX", () => { - assert.equal(isNoteMathLanguageLabel("math"), false); - assert.equal(isNoteMathLanguageLabel("Math (LaTeX)"), false); + assert.equal(isNoteMathLanguageLabel("math"), true); + assert.equal(isNoteMathLanguageLabel("Math (LaTeX)"), true); assert.equal(isNoteMathLanguageLabel("language-latex"), true); assert.equal(isNoteMathLanguageLabel("language-tex highlighted"), true); assert.equal(isNoteMathLanguageLabel("latex"), true); assert.equal(isNoteMathLanguageLabel("tex"), true); - assert.equal(isNoteMathLanguageLabel("公式"), false); + assert.equal(isNoteMathLanguageLabel("katex"), true); + assert.equal(isNoteMathLanguageLabel("formula"), true); + assert.equal(isNoteMathLanguageLabel("公式"), true); assert.equal(isNoteMathLanguageLabel("text"), false); assert.equal(isNoteMathLanguageLabel("plaintext"), false); assert.equal(isNoteMathLanguageLabel("typescript"), false); assert.equal(shouldRenderNoteMathFormula("Plain text"), false); assert.equal(shouldRenderNoteMathFormula("plaintext"), false); assert.equal(shouldRenderNoteMathFormula(""), false); - assert.equal(shouldRenderNoteMathFormula("math"), false); + assert.equal(shouldRenderNoteMathFormula("math"), true); assert.equal(shouldRenderNoteMathFormula("latex"), true); assert.equal(shouldRenderNoteMathFormula("tex"), true); + assert.equal(shouldRenderNoteMathFormula("katex"), true); + assert.equal(shouldRenderNoteMathFormula("plaintext", "$$ x + y = z $$"), true); + assert.equal(shouldRenderNoteMathFormula("plaintext", "hello world"), false); }); test("live note decoration scans are debounced while preview mounts stay immediate", () => { @@ -368,8 +373,7 @@ test("note editor registers a code block editor for pasted fenced code", () => { /codeBlockPlugin\([^)]*\),\s*codeMirrorPlugin\(\{\s*codeBlockLanguages:/s, ); assert.match(source, /codeMirrorExtensions:\s*NOTE_CODE_MIRROR_EXTENSIONS/); - assert.match(source, /syntaxHighlighting\(noteCodeHighlightStyle\)/); -}); + assert.match(source, /syntaxHighlighting\(noteCodeHighlightStyle\)/); assert.match(source, /tooltips\(\{\s*parent:\s*typeof document !== "undefined" \? document\.body : undefined,\s*\}\)/);}); test("note editor enables image plugin for remote markdown images", () => { const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8"); @@ -510,26 +514,30 @@ test("note code block active line is highlighted only while focused", () => { styles, /\.netcatty-mdx-editor\s+\.cm-editor:focus-within\s+\.cm-activeLine,\s*\.netcatty-mdx-editor\s+\.cm-editor:focus-within\s+\.cm-activeLineGutter\s*\{[^}]*background:\s*hsl\(var\(--primary\)\s*\/\s*0\.08\)/s, ); -}); - -test("note code block frame is borderless and language picker is compact", () => { - const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8"); - assert.match( styles, - /\.netcatty-mdx-editor\s+\[class\*="_codeMirrorWrapper_"\]\s*\{[^}]*border:\s*0\s*!important;[^}]*background:\s*transparent\s*!important;[^}]*padding:\s*0\s*!important;/s, + /\.cm-tooltip,\s*\.netcatty-mdx-editor\s+\.cm-tooltip\s*\{[^}]*background:\s*hsl\(var\(--popover\)\)/s, + ); + assert.match( + styles, + /\.cm-tooltip\.cm-tooltip-autocomplete\s*>\s*ul\s*\{[^}]*background:\s*hsl\(var\(--popover\)\)/s, ); assert.match( styles, - /\.netcatty-mdx-editor\s+\.cm-editor\s*\{[^}]*border:\s*0\s*!important;[^}]*background:\s*transparent\s*!important;/s, + /\.cm-tooltip\s+\.cm-completionMatchedText,\s*\.netcatty-mdx-editor\s+\.cm-tooltip\s+\.cm-completionMatchedText\s*\{[^}]*color:\s*inherit\s*!important;[^}]*font-weight:\s*700\s*!important;[^}]*text-decoration:\s*underline\s*!important;/s, ); +}); + +test("note code block frame is borderless and language picker is compact", () => { + const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8"); + assert.match( styles, - /\.netcatty-mdx-content\s+pre\s*\{[^}]*border:\s*0;[^}]*background:\s*transparent;[^}]*padding:\s*0;/s, + /\.netcatty-mdx-editor\s+\[class\*="_codeMirrorWrapper_"\]\s*\{[^}]*border:\s*0\s*!important;[^}]*padding:\s*0\.2rem\s+0\s*!important;/s, ); assert.match( styles, - /\.netcatty-note-code-copy\s*\{[^}]*border:\s*0\s*!important;[^}]*background:\s*transparent\s*!important;[^}]*box-shadow:\s*none\s*!important;/s, + /\.netcatty-mdx-editor\s+\.cm-editor\s*\{[^}]*border:\s*0\s*!important;/s, ); assert.match( styles, @@ -569,7 +577,7 @@ test("note code block frame is borderless and language picker is compact", () => ); assert.match( styles, - /\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorWrapper_"\]\s*\{[^}]*gap:\s*0;[^}]*margin:\s*0\.25rem\s+0\s+0\.55rem;/s, + /\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorWrapper_"\]\s*\{[^}]*gap:\s*0;[^}]*margin:\s*0\.3rem\s+0\s+0\.65rem;/s, ); assert.match( styles, @@ -577,7 +585,7 @@ test("note code block frame is borderless and language picker is compact", () => ); assert.match( styles, - /\.netcatty-mdx-editor\s+\.cm-gutters\s*\{[^}]*background:\s*transparent\s*!important;[^}]*padding:\s*0\s*!important;/s, + /\.netcatty-mdx-editor\s+\.cm-gutters\s*\{[^}]*padding:\s*0\s*!important;/s, ); assert.match( styles, @@ -585,21 +593,21 @@ test("note code block frame is borderless and language picker is compact", () => ); }); -test("note formulas render without framed surfaces", () => { +test("note formulas render with framed surfaces", () => { const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8"); assert.doesNotMatch(styles, /data-language="math"/); assert.match( styles, - /\.netcatty-math-formula-preview\s*\{[^}]*background:\s*transparent;[^}]*border:\s*0;/s, + /\.netcatty-math-formula-preview\s*\{[^}]*background:\s*hsl\(var\(--card\)\s*\/\s*0\.85\);[^}]*border:\s*1px solid hsl\(var\(--border\)\s*\/\s*0\.6\);/s, ); assert.match( styles, - /\.netcatty-math-reading-mode\s*\{[^}]*background:\s*transparent\s*!important;[^}]*border:\s*none\s*!important;[^}]*padding:\s*0\s*!important;/s, + /\.netcatty-math-reading-mode\s*\{[^}]*background:\s*transparent\s*!important;[^}]*border:\s*none\s*!important;/s, ); assert.match( styles, - /\.netcatty-math-reading-mode\s+\.netcatty-math-formula-preview\s*\{[^}]*background:\s*transparent;/s, + /\.netcatty-math-reading-mode\s+\.netcatty-math-formula-preview\s*\{[^}]*background:\s*hsl\(var\(--secondary\)\s*\/\s*0\.25\);/s, ); assert.match(styles, /\.netcatty-math-formula-preview\s*\{[^}]*justify-content:\s*safe center;[^}]*overflow-x:\s*auto;/s); assert.match( diff --git a/components/notes/InlineMarkdownEditor.tsx b/components/notes/InlineMarkdownEditor.tsx index fe16767a0..8e7497fb0 100644 --- a/components/notes/InlineMarkdownEditor.tsx +++ b/components/notes/InlineMarkdownEditor.tsx @@ -17,6 +17,7 @@ import { thematicBreakPlugin, } from "@mdxeditor/editor"; import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import { tooltips } from "@codemirror/view"; import { tags } from "@lezer/highlight"; import { ExternalLink } from "lucide-react"; import { @@ -294,7 +295,6 @@ const NOTE_CODE_BLOCK_LANGUAGES = { sh: "Shell", shell: "Shell", sql: "SQL", - tex: "TeX", toml: "TOML", ts: "TypeScript", tsx: "TypeScript (React)", @@ -322,7 +322,12 @@ const noteCodeHighlightStyle = HighlightStyle.define([ { tag: tags.invalid, class: "netcatty-code-token-invalid" }, ]); -const NOTE_CODE_MIRROR_EXTENSIONS = [syntaxHighlighting(noteCodeHighlightStyle)]; +const NOTE_CODE_MIRROR_EXTENSIONS = [ + syntaxHighlighting(noteCodeHighlightStyle), + tooltips({ + parent: typeof document !== "undefined" ? document.body : undefined, + }), +]; type RectLike = Pick; @@ -799,14 +804,35 @@ export const annotateNoteCodeBlockDeleteButtons = (container: HTMLElement): void export const isNoteMathLanguageLabel = (value: string): boolean => { const normalized = value.toLowerCase().trim(); if (!normalized) return false; - return normalized === "latex" + return ( + normalized === "latex" || normalized === "tex" - || /(?:^|\s)language-(?:latex|tex)(?:\s|$)/.test(normalized); + || normalized === "math" + || normalized === "katex" + || normalized === "formula" + || normalized === "公式" + || normalized === "math (latex)" + || /(?:^|\s)language-(?:latex|tex|math|katex|formula)(?:\s|$)/.test(normalized) + || /^(?:math|latex|tex|katex|formula|公式)(?:\s|\(|$)/.test(normalized) + ); }; export const shouldRenderNoteMathFormula = ( languageLabel: string, -): boolean => isNoteMathLanguageLabel(languageLabel); + content?: string, +): boolean => { + if (isNoteMathLanguageLabel(languageLabel)) return true; + if (content) { + const trimmed = content.trim(); + if (trimmed.startsWith("$$") && trimmed.endsWith("$$") && trimmed.length >= 4) { + return true; + } + if (trimmed.startsWith("\\[") && trimmed.endsWith("\\]") && trimmed.length >= 4) { + return true; + } + } + return false; +}; export const annotateMathFormulaBlocks = (container: HTMLElement, editorMode: string): void => { container.querySelectorAll('[class*="_codeMirrorWrapper_"], pre').forEach((wrapper) => { @@ -825,7 +851,7 @@ export const annotateMathFormulaBlocks = (container: HTMLElement, editorMode: st const text = getCodeMirrorBlockText(wrapper).trim(); - const isMathBlock = shouldRenderNoteMathFormula(lang); + const isMathBlock = shouldRenderNoteMathFormula(lang, text); if (!isMathBlock) { const existingPreview = wrapper.querySelector(".netcatty-math-formula-preview"); if (existingPreview) existingPreview.remove(); @@ -850,7 +876,11 @@ export const annotateMathFormulaBlocks = (container: HTMLElement, editorMode: st if (preview.dataset.formulaSource !== formulaSource) { preview.dataset.formulaSource = formulaSource; - preview.innerHTML = renderNoteMathFormula(formulaSource); + try { + preview.innerHTML = renderNoteMathFormula(formulaSource); + } catch { + preview.textContent = formulaSource; + } } if (editorMode === "preview") { @@ -860,6 +890,21 @@ export const annotateMathFormulaBlocks = (container: HTMLElement, editorMode: st } }); + if (editorMode === "preview") { + container.querySelectorAll("p").forEach((p) => { + const pText = p.textContent?.trim() || ""; + if (pText.startsWith("$$") && pText.endsWith("$$") && pText.length >= 4) { + if (p.querySelector(".katex, math")) return; + const formula = pText.slice(2, -2).trim(); + try { + p.innerHTML = renderNoteMathFormula(formula); + p.classList.add("netcatty-math-block-p"); + } catch { + // ignore + } + } + }); + } }; const deleteLexicalTextRange = (range: Range, onUpdate: () => void): boolean => { diff --git a/index.css b/index.css index 52d44715a..1d5f9155b 100644 --- a/index.css +++ b/index.css @@ -1356,26 +1356,26 @@ html[data-theme-transition] *::after { .netcatty-mdx-content pre { border: 0; - border-radius: 0; - background: transparent; - padding: 0; + border-radius: 6px; + background: hsl(var(--secondary) / 0.35); + padding: 0.75rem; } .netcatty-mdx-editor [class*="_codeMirrorWrapper_"] { position: relative !important; - min-height: 2rem !important; - margin: 0.35rem 0 0.65rem; + min-height: 2.85rem !important; + margin: 0.5rem 0 0.85rem; border: 0 !important; - border-radius: 0; - background: transparent !important; - padding: 0 !important; + border-radius: 6px; + background: hsl(var(--secondary) / 0.35) !important; + padding: 0.2rem 0 !important; overflow: visible !important; } .netcatty-note-code-copy { position: absolute !important; - top: 0.2rem !important; - right: 0.15rem !important; + top: 0.35rem !important; + right: 0.35rem !important; z-index: 40 !important; display: inline-flex !important; align-items: center !important; @@ -1383,11 +1383,11 @@ html[data-theme-transition] *::after { width: 1.45rem !important; height: 1.45rem !important; padding: 0 !important; - border: 0 !important; + border: 1px solid hsl(var(--border) / 0.7) !important; border-radius: 4px !important; - background: transparent !important; + background: hsl(var(--background) / 0.95) !important; color: hsl(var(--muted-foreground)) !important; - box-shadow: none !important; + box-shadow: 0 1px 3px rgb(0 0 0 / 0.12) !important; opacity: 0; cursor: pointer !important; transition: opacity 0.15s, color 0.15s, background-color 0.15s; @@ -1445,26 +1445,24 @@ html[data-theme-transition] *::after { .netcatty-mdx-editor [class*="_codeMirrorWrapper_"]:has([data-language="latex"]), .netcatty-mdx-editor [class*="_codeMirrorWrapper_"]:has([data-language="tex"]) { - border: 0 !important; - background: transparent !important; - padding: 0 !important; + border-left: 3px solid hsl(var(--primary)) !important; + background: hsl(var(--secondary) / 0.25) !important; } .netcatty-math-formula-preview { display: flex; align-items: center; justify-content: safe center; - padding: 0.55rem 0; - margin: 0.4rem 0; - border-radius: 0; - background: transparent; - border: 0; + padding: 0.85rem 1.25rem; + margin: 0.35rem 0.5rem 0.35rem; + border-radius: 6px; + background: hsl(var(--card) / 0.85); + border: 1px solid hsl(var(--border) / 0.6); font-size: 1.45em; color: hsl(var(--foreground)); overflow-x: auto; - max-width: 100%; user-select: text; - min-height: 2.25rem; + min-height: 2.75rem; } .netcatty-math-formula-preview .katex-display { @@ -1488,27 +1486,39 @@ html[data-theme-transition] *::after { .netcatty-math-reading-mode { background: transparent !important; border: none !important; - padding: 0 !important; - margin: 0.4rem 0 !important; + margin: 0.5rem 0 !important; } .netcatty-math-reading-mode .netcatty-math-formula-preview { margin: 0; - background: transparent; + background: hsl(var(--secondary) / 0.25); } .netcatty-math-reading-mode > .netcatty-note-code-copy { display: none !important; } +p.netcatty-math-block-p { + display: flex; + align-items: center; + justify-content: center; + padding: 0.85rem 1.25rem; + margin: 0.5rem 0; + border-radius: 6px; + background: hsl(var(--secondary) / 0.25); + font-size: 1.45em; + color: hsl(var(--foreground)); + overflow-x: auto; +} + .netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorWrapper_"] { display: flex; flex-direction: column; align-items: stretch; gap: 0; - margin: 0.25rem 0 0.55rem; - border-radius: 0; - background: transparent !important; + margin: 0.3rem 0 0.65rem; + border-radius: 6px; + background: hsl(var(--secondary) / 0.35) !important; overflow: visible !important; } @@ -1517,7 +1527,7 @@ html[data-theme-transition] *::after { } .netcatty-mdx-editor:not(.netcatty-mdx-editor--preview) [class*="_codeMirrorWrapper_"] .cm-editor { - min-height: 2rem !important; + min-height: 2.2rem !important; background: transparent !important; border-radius: 0 !important; } @@ -1742,10 +1752,9 @@ html[data-theme-transition] *::after { --note-code-token-name: color-mix(in oklab, hsl(var(--primary)) 50%, hsl(var(--foreground))); --note-code-token-value: color-mix(in oklab, hsl(var(--primary)) 38%, hsl(var(--foreground))); --note-code-token-string: color-mix(in oklab, hsl(var(--primary)) 24%, hsl(var(--foreground))); - overflow: hidden; border: 0 !important; - border-radius: 0; - background: transparent !important; + border-radius: 6px; + background: hsl(var(--secondary) / 0.35) !important; color: hsl(var(--foreground) / 0.9) !important; font-family: var(--font-mono); font-size: 13px !important; @@ -1761,7 +1770,7 @@ html[data-theme-transition] *::after { background: transparent !important; color: hsl(var(--foreground) / 0.9) !important; font-family: var(--font-mono); - padding: 0 !important; + padding: 0.25rem 0 !important; } .netcatty-mdx-editor .cm-line { @@ -1812,7 +1821,7 @@ html[data-theme-transition] *::after { .netcatty-mdx-editor .cm-gutters { border-right: 1px solid hsl(var(--border) / 0.35) !important; - background: transparent !important; + background: hsl(var(--secondary) / 0.24) !important; color: hsl(var(--muted-foreground) / 0.75) !important; font-family: var(--font-mono); font-size: 13px !important; @@ -1847,6 +1856,91 @@ html[data-theme-transition] *::after { border-left-color: hsl(var(--foreground)) !important; } +.cm-tooltip, +.netcatty-mdx-editor .cm-tooltip { + background: hsl(var(--popover)) !important; + color: hsl(var(--popover-foreground)) !important; + border: 1px solid hsl(var(--border) / 0.8) !important; + border-radius: 6px !important; + box-shadow: 0 6px 20px rgb(0 0 0 / 0.3) !important; + z-index: 9999 !important; +} + +.cm-tooltip.cm-tooltip-autocomplete, +.netcatty-mdx-editor .cm-tooltip-autocomplete { + background: hsl(var(--popover)) !important; + border: 1px solid hsl(var(--border) / 0.8) !important; + border-radius: 6px !important; + overflow: hidden !important; +} + +.cm-tooltip.cm-tooltip-autocomplete > ul, +.netcatty-mdx-editor .cm-tooltip-autocomplete > ul { + background: hsl(var(--popover)) !important; + color: hsl(var(--popover-foreground)) !important; + font-family: var(--font-mono) !important; + font-size: 12px !important; + max-height: 14rem !important; + padding: 4px !important; + margin: 0 !important; + list-style: none !important; +} + +.cm-tooltip.cm-tooltip-autocomplete > ul > li, +.netcatty-mdx-editor .cm-tooltip-autocomplete > ul > li { + padding: 3px 8px !important; + border-radius: 4px !important; + display: flex !important; + align-items: center !important; + gap: 6px !important; + cursor: pointer !important; + color: hsl(var(--popover-foreground)) !important; +} + +.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected="true"], +.cm-tooltip.cm-tooltip-autocomplete > ul > li:hover, +.netcatty-mdx-editor .cm-tooltip-autocomplete > ul > li[aria-selected="true"], +.netcatty-mdx-editor .cm-tooltip-autocomplete > ul > li:hover { + background: hsl(var(--accent)) !important; + color: hsl(var(--accent-foreground)) !important; +} + +.cm-tooltip .cm-completionMatchedText, +.netcatty-mdx-editor .cm-tooltip .cm-completionMatchedText { + color: inherit !important; + font-weight: 700 !important; + text-decoration: underline !important; + text-underline-offset: 2px !important; +} + +.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected="true"] .cm-completionMatchedText, +.cm-tooltip.cm-tooltip-autocomplete > ul > li:hover .cm-completionMatchedText, +.netcatty-mdx-editor .cm-tooltip-autocomplete > ul > li[aria-selected="true"] .cm-completionMatchedText, +.netcatty-mdx-editor .cm-tooltip-autocomplete > ul > li:hover .cm-completionMatchedText { + color: inherit !important; + font-weight: 700 !important; + text-decoration: underline !important; +} + +.cm-tooltip .cm-completionDetail, +.netcatty-mdx-editor .cm-tooltip .cm-completionDetail { + color: hsl(var(--muted-foreground)) !important; + font-size: 0.85em !important; + font-style: italic !important; + margin-left: auto !important; +} + +.cm-tooltip.cm-completionInfo, +.netcatty-mdx-editor .cm-completionInfo { + background: hsl(var(--popover)) !important; + color: hsl(var(--popover-foreground)) !important; + border: 1px solid hsl(var(--border) / 0.8) !important; + border-radius: 6px !important; + padding: 6px 10px !important; + font-size: 12px !important; + box-shadow: 0 6px 20px rgb(0 0 0 / 0.3) !important; +} + .netcatty-mdx-content blockquote { margin: 0.7rem 0; border-left: 2px solid hsl(var(--border));