Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 39 additions & 23 deletions components/notes/InlineMarkdownEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -490,14 +494,18 @@ test("pasting inside code blocks keeps CodeMirror in control", () => {
);
});

test("note code block editor colors follow the app theme", () => {
test("note code block editor colors provide independent high-contrast syntax highlighting", () => {
const styles = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");

assert.match(source, /Prec\.highest\(\s*tooltips\(/);
assert.match(styles, /\.netcatty-mdx-editor\s+\.cm-editor/);
assert.match(styles, /\.netcatty-mdx-editor\s+\.cm-gutters/);
assert.match(styles, /background:\s*hsl\(var\(--secondary\)/);
assert.match(styles, /color:\s*hsl\(var\(--foreground\)/);
assert.match(styles, /--note-code-token-keyword:\s*color-mix\(in oklab,\s*hsl\(var\(--primary\)\)/);
assert.match(styles, /--note-code-token-keyword:\s*#cf222e;/);
assert.match(styles, /--note-code-token-keyword:\s*#ff7b72;/);
assert.doesNotMatch(styles, /--note-code-token-keyword:\s*color-mix/);
assert.match(styles, /\.netcatty-mdx-editor\s+\.cm-content\s+\.netcatty-code-token-keyword/);
assert.match(styles, /\.netcatty-mdx-editor\s+\.cm-content\s+\.netcatty-code-token-string/);
assert.doesNotMatch(styles, /span\[class\*="ͼ"\]/);
Expand All @@ -515,26 +523,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[^{]*\{[^}]*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[^{]*\{[^}]*color:\s*inherit\s*!important;[^}]*font-weight:\s*700\s*!important;[^}]*text-decoration:\s*underline\s*!important;/s,
);
});

test("note code block frame is framed with borders 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*1px solid hsl\(var\(--border\)\s*\/\s*0\.6\)\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,
Expand Down Expand Up @@ -574,37 +586,41 @@ 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,
/\.netcatty-mdx-editor:not\(\.netcatty-mdx-editor--preview\)\s+\[class\*="_codeMirrorWrapper_"\]\s+\.cm-content\s*\{[^}]*padding:\s*0\s*!important;/s,
);
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,
/\.netcatty-mdx-editor--preview\s+\[class\*="_codeMirrorToolbar_"\]\s*\{[^}]*display:\s*none\s*!important;/s,
);
});

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;/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+\.netcatty-math-formula-preview\s*\{[^}]*border:\s*1px solid hsl\(var\(--border\)\s*\/\s*0\.6\);[^}]*background:\s*hsl\(var\(--secondary\)\s*\/\s*0\.25\);/s,
);
assert.match(
styles,
/\.netcatty-math-reading-mode\s+\.netcatty-math-formula-preview\s*\{[^}]*background:\s*transparent;/s,
/p\.netcatty-math-block-p\s*\{[^}]*border:\s*1px solid hsl\(var\(--border\)\s*\/\s*0\.6\);[^}]*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(
Expand Down
62 changes: 55 additions & 7 deletions components/notes/InlineMarkdownEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
thematicBreakPlugin,
} from "@mdxeditor/editor";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { Prec } from "@codemirror/state";
import { tooltips } from "@codemirror/view";
import { tags } from "@lezer/highlight";
import { AlertTriangle, ExternalLink } from "lucide-react";
import {
Expand Down Expand Up @@ -302,7 +304,6 @@ const NOTE_CODE_BLOCK_LANGUAGES = {
sh: "Shell",
shell: "Shell",
sql: "SQL",
tex: "TeX",
toml: "TOML",
Comment on lines 305 to 307

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the existing TeX language alias registered

Existing or imported notes can contain fenced tex blocks, and the surrounding detection and CSS still explicitly support tex, but removing it from NOTE_CODE_BLOCK_LANGUAGES means codeMirrorPlugin no longer recognizes or offers that language through its supported-language registry. Such blocks can therefore lose their CodeMirror editor/formula-preview path; retain the tex: "TeX" alias alongside latex for backward compatibility.

Useful? React with 👍 / 👎.

ts: "TypeScript",
tsx: "TypeScript (React)",
Expand Down Expand Up @@ -330,7 +331,14 @@ 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),
Prec.highest(
tooltips({
parent: typeof document !== "undefined" ? document.body : undefined,
}),
),
];

type RectLike = Pick<DOMRect, "bottom" | "height" | "left" | "top" | "width">;

Expand Down Expand Up @@ -807,14 +815,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;
}
Comment on lines +836 to +843

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require an explicit math language before replacing code blocks

When a plain-text or unlabeled fenced block literally contains $$...$$ or \[...\]—for example, documentation showing Markdown syntax—this content-only fallback now classifies it as math. In preview, annotateMathFormulaBlocks consequently hides the CodeMirror block and its copy action and substitutes a KaTeX rendering, even though the user did not select latex or tex; keep formula rendering gated by an explicit math language.

Useful? React with 👍 / 👎.

}
return false;
};

export const annotateMathFormulaBlocks = (container: HTMLElement, editorMode: string): void => {
container.querySelectorAll('[class*="_codeMirrorWrapper_"], pre').forEach((wrapper) => {
Expand All @@ -833,7 +862,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();
Expand All @@ -858,7 +887,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") {
Expand All @@ -868,6 +901,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 => {
Expand Down
Loading