diff --git a/components/notes/InlineMarkdownEditor.tsx b/components/notes/InlineMarkdownEditor.tsx index b927c97a3e..ebceac05b7 100644 --- a/components/notes/InlineMarkdownEditor.tsx +++ b/components/notes/InlineMarkdownEditor.tsx @@ -2226,7 +2226,13 @@ export const InlineMarkdownEditor = React.memo( editorMode === "preview" && "netcatty-mdx-editor--preview", )} contentEditableClassName="netcatty-mdx-content" - onChange={commitMarkdown} + onChange={(markdown, initialMarkdownNormalize) => { + // Importing Markdown may escape literal comparisons on export. + // Keep the user's source until they actually edit the rich view. + if (!initialMarkdownNormalize && editorMode !== "preview") { + commitMarkdown(markdown); + } + }} onError={handleMdxParseError} /> )} diff --git a/components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx b/components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx index 15b6e32b8e..89b8e6ab2d 100644 --- a/components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx +++ b/components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; import { JSDOM } from "jsdom"; @@ -23,6 +24,9 @@ const NOTE_MARKDOWN_MDX_CANNOT_PARSE = [ const PLAIN_NOTE_MARKDOWN = ["# Steps", "", "Promote the replica, then restart the agent."].join("\n"); +// Public author-supplied note from issue #3205. Commands are inert test content. +const ISSUE_3205_MARKDOWN = readFileSync(new URL("./test-fixtures/issue-3205.md", import.meta.url), "utf8"); + const setupDom = () => { const dom = new JSDOM('
', { pretendToBeVisual: true, @@ -41,17 +45,51 @@ const setupDom = () => { disconnect() {} } + function LoadedImageStub() { + const image = window.document.createElement("img"); + Object.defineProperty(image, "src", { + get: () => image.getAttribute("src") ?? "", + set: (src: string) => { + image.setAttribute("src", src); + queueMicrotask(() => image.dispatchEvent(new window.Event("load"))); + }, + }); + return image; + } + + Object.assign(window.Range.prototype, { + getClientRects: () => [], + getBoundingClientRect: () => new window.DOMRect(), + }); + + // CodeMirror needs these browser APIs when the full note contains code blocks. + window.matchMedia = (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent: () => false, + }); + for (const [key, value] of Object.entries({ window, + Window: window.Window, + Image: LoadedImageStub, document: window.document, navigator: window.navigator, HTMLElement: window.HTMLElement, + HTMLImageElement: window.HTMLImageElement, HTMLInputElement: window.HTMLInputElement, HTMLTextAreaElement: window.HTMLTextAreaElement, HTMLSelectElement: window.HTMLSelectElement, Element: window.Element, SVGElement: window.SVGElement, Node: window.Node, + DocumentFragment: window.DocumentFragment, + Range: window.Range, NodeFilter: window.NodeFilter, MutationObserver: window.MutationObserver, CustomEvent: window.CustomEvent, @@ -92,6 +130,7 @@ type ActiveFormats = { type RenderEditorProps = { value: string; + noteId?: string; editorMode?: "edit" | "preview" | "source"; onActiveFormatsChange?: (formats: ActiveFormats) => void; }; @@ -113,7 +152,7 @@ const renderEditor = async ( root.render( { + const { window, cleanup } = setupDom(); + try { + const value = "Memory<1GB"; + const { rootNode, changes, unmount } = await renderEditor(window, { + value, + editorMode: "preview", + }); + try { + assert.ok(!querySourceFallback(rootNode), "a comparison must render as ordinary text"); + assert.equal(rootNode.querySelector("[contenteditable]")?.textContent, value); + assert.deepEqual(changes, [], "reading the note must preserve its original source"); + } finally { + await unmount(); + } + } finally { + cleanup(); + } +}); + +test("comparison variants render in paragraphs, tables and alongside HTML", async () => { + const { window, cleanup } = setupDom(); + try { + const value = [ + "<1GB", + "", + "Memory<1GB, x<=2, x<-1, x<+2, x<.5, x < 3, x\\<4, x<5", + "", + "| Limit |", + "| --- |", + "| **Memory<1GB** |", + "", + 'limit<1<2', + "", + 'limit<3', + "", + "`literal<1 and `", + "", + "```sh", + "cat <<'EOF'", + " and x<1", + "EOF", + "```", + ].join("\n"); + const { rootNode, changes, unmount } = await renderEditor(window, { value }); + try { + assert.ok(!querySourceFallback(rootNode)); + const editable = rootNode.querySelector("[contenteditable]"); + assert.ok(editable); + assert.match(editable.textContent ?? "", /Memory<1GB, x<=2, x<-1, x<\+2, x<\.5, x < 3, x<4, x<5/); + assert.equal(editable.querySelector("td strong")?.textContent, "Memory<1GB"); + assert.equal(editable.querySelector("a")?.getAttribute("href"), "https://example.com/"); + assert.equal(editable.querySelector("a")?.textContent, "limit<1"); + const image = editable.querySelector("img"); + assert.equal(image?.getAttribute("alt"), "limit"); + assert.equal(image?.getAttribute("width"), "80"); + assert.equal(image?.getAttribute("height"), "40"); + assert.equal(editable.querySelector("code")?.textContent, "literal<1 and "); + const { getCodeMirrorBlockText } = await import("./InlineMarkdownEditor.tsx"); + const codeBlock = editable.querySelector(".cm-editor"); + assert.ok(codeBlock); + assert.equal(getCodeMirrorBlockText(codeBlock), "cat <<'EOF'\n and x<1\nEOF"); + assert.deepEqual(changes, []); + } finally { + await unmount(); + } + } finally { + cleanup(); + } +}); + +test("issue 3205 author note survives source, preview and edit without rewriting", async () => { + const { window, cleanup } = setupDom(); + try { + const value = ISSUE_3205_MARKDOWN; + const { rootNode, changes, rerender, unmount } = await renderEditor(window, { value, editorMode: "source" }); + try { + assert.equal(rootNode.querySelector("textarea")?.value, value); + for (const editorMode of ["preview", "edit", "preview"] as const) { + await rerender({ value, editorMode }); + await runWithAct(async () => { await new Promise((resolve) => setTimeout(resolve, 30)); }); + assert.ok(!querySourceFallback(rootNode), `${editorMode} must render the author's note`); + const editable = rootNode.querySelector("[contenteditable]"); + assert.ok(editable); + assert.equal(editable.getAttribute("contenteditable"), editorMode === "edit" ? "true" : "false"); + assert.equal(editable.querySelector("h1")?.textContent, "设备TCP栈调优操作记录"); + assert.match(editable.querySelector("table")?.textContent ?? "", /内存<1GB设备/); + assert.match(editable.textContent ?? "", /配置回滚/); + assert.equal(editable.querySelectorAll(".cm-editor").length, 10); + } + await rerender({ value, editorMode: "source" }); + assert.equal(rootNode.querySelector("textarea")?.value, value); + assert.deepEqual(changes, [], "view changes must not rewrite the author's source"); + } finally { + await unmount(); + } + } finally { + cleanup(); + } +}); + +test("editing a comparison emits Markdown that can be reopened", async () => { + const { window, cleanup } = setupDom(); + try { + const value = "Memory<1GB"; + const { rootNode, changes, rerender, unmount } = await renderEditor(window, { value }); + try { + assert.deepEqual(changes, []); + const editable = rootNode.querySelector("[contenteditable]"); + assert.ok(editable); + const { getNearestEditorFromDOMNode, $getRoot, $isTextNode } = await import("lexical"); + const editor = getNearestEditorFromDOMNode(editable); + assert.ok(editor); + await runWithAct(async () => { + editor.update(() => { + const text = $getRoot().getFirstDescendant(); + assert.ok($isTextNode(text)); + text.setTextContent("Memory<2GB"); + }, { discrete: true }); + }); + assert.ok(changes.length > 0, "real edits must still be saved"); + const edited = changes.at(-1)!; + await rerender({ value: edited, editorMode: "preview" }); + assert.ok(!querySourceFallback(rootNode)); + assert.equal(rootNode.querySelector("[contenteditable]")?.textContent, "Memory<2GB"); + await rerender({ value: edited, editorMode: "source" }); + assert.equal(rootNode.querySelector("textarea")?.value, edited); + } finally { + await unmount(); + } + } finally { + cleanup(); + } +}); + +test("preview task checkboxes still save explicit changes without rewriting comparisons", async () => { + const { window, cleanup } = setupDom(); + try { + const value = "- [ ] Memory<1GB\n- [x] Ready"; + const { rootNode, changes, unmount } = await renderEditor(window, { value, editorMode: "preview" }); + try { + assert.deepEqual(changes, []); + const task = rootNode.querySelector('li[role="checkbox"]'); + assert.ok(task); + await runWithAct(async () => { + task.dispatchEvent(new window.MouseEvent("click", { bubbles: true, clientX: 0 })); + }); + assert.deepEqual(changes, ["- [x] Memory<1GB\n- [x] Ready"]); + assert.equal(rootNode.querySelector('li[role="checkbox"]')?.getAttribute("aria-checked"), "true"); + } finally { + await unmount(); + } + } finally { + cleanup(); + } +}); + +test("switching between a comparison note and an unsupported note keeps the fallback scoped", async () => { + const { window, cleanup } = setupDom(); + try { + const value = "Memory<1GB"; + const { rootNode, changes, rerender, unmount } = await renderEditor(window, { value }); + try { + for (const props of [ + { noteId: "unsupported", value: NOTE_MARKDOWN_MDX_CANNOT_PARSE }, + { noteId: "comparison", value }, + ]) { + await rerender(props); + await runWithAct(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); + if (props.noteId === "unsupported") { + assert.equal(querySourceFallback(rootNode)?.value, props.value); + } else { + assert.ok(!querySourceFallback(rootNode)); + assert.equal(rootNode.querySelector("[contenteditable]")?.textContent, value); + } + } + assert.deepEqual(changes, []); + } finally { + await unmount(); + } + } finally { + cleanup(); + } +}); + test("unrenderable markdown stays visible via the raw source fallback", async () => { const { window, cleanup } = setupDom(); try { diff --git a/components/notes/test-fixtures/issue-3205.md b/components/notes/test-fixtures/issue-3205.md new file mode 100644 index 0000000000..ede7e98dac --- /dev/null +++ b/components/notes/test-fixtures/issue-3205.md @@ -0,0 +1,121 @@ +# 设备TCP栈调优操作记录 + +> **目标**:根据本地带宽动态调整TCP接收/发送缓冲区,使其能容纳带宽延迟积(BDP)。 + +## 1. 环境信息收集 + +### 1.1 基础硬件与系统信息 +```bash +# 设备型号 +cat /tmp/sysinfo/model + +# 内核版本 +uname -r + +# 总内存 +grep MemTotal /proc/meminfo +``` + +### 1.2 当前TCP参数快照 +```bash +sysctl net.ipv4.tcp_rmem \ + net.ipv4.tcp_wmem \ + net.core.rmem_max \ + net.core.wmem_max \ + net.ipv4.tcp_congestion_control \ + net.core.netdev_max_backlog +``` + +### 1.3 网络链路与带宽测试 +- **查看WAN口速率**(替换``为实际接口名,如`eth0`): + ```bash + ethtool | grep -i speed + ``` +- **实测带宽**(确保无代理干扰): + ```bash + # 检查代理进程 + ps | grep -E "openclash|mihomo" + + # 若存在代理,请先关闭;然后测速 + speedtest --accept-license --format=json + ``` + +### 1.4 检查BBR拥塞控制算法支持 +```bash +cat /proc/sys/net/ipv4/tcp_available_congestion_control +``` +> **注意**:若输出不包含`bbr`,则后续配置保持`cubic`。 + +--- + +## 2. BDP计算与参数确定 + +### 2.1 计算公式 +`最大缓冲区(MB) ≈ 带宽(Mbps) × 0.125 × 最大RTT(秒)` +即:`带宽(Mbps) × 0.125 × (RTT_ms / 1000)` + +### 2.2 参数参考 +| 场景 | 预估RTT | 推荐`max`值 | +| :--- | :--- | :--- | +| 本地网络 | ~30ms | 根据实测带宽计算 | +| 代理/国际链路 | ~150-200ms | 根据实测带宽计算 | +| **内存<1GB设备** | - | **固定16MB** | +| 大内存服务器 | - | 可参考GCP建议最大64MB | + +### 2.3 配置示例(16MB) +若计算后决定使用16MB上限: +```bash +cat > /etc/sysctl.d/90-tcp-tuning.conf <<'EOF' +net.core.rmem_max = 16777216 +net.core.wmem_max = 16777216 +net.ipv4.tcp_rmem = 4096 131072 16777216 +net.ipv4.tcp_wmem = 4096 16384 16777216 +net.core.netdev_max_backlog = 8192 +net.ipv4.tcp_mtu_probing = 1 +net.ipv4.tcp_fastopen = 3 +net.ipv4.tcp_slow_start_after_idle = 0 +EOF +``` + +--- + +## 3. 应用与验证 + +### 3.1 加载配置 +```bash +# 应用配置文件(BusyBox使用-p参数) +sysctl -p /etc/sysctl.d/90-tcp-tuning.conf +``` + +### 3.2 验证生效 +```bash +# 检查关键参数 +sysctl net.ipv4.tcp_rmem net.core.rmem_max +``` + +### 3.3 本地环回吞吐测试 +```bash +# 启动临时服务端(运行1次后退出) +iperf3 -s -1 & + +# 客户端连接本机测试(3秒) +iperf3 -c 127.0.0.1 -t 3 +``` +> **目的**:验证内核TCP栈本身是否正常。 + +--- + +## 4. 重要说明 + +- **开机自动加载**:OpenWrt系统下,`/etc/init.d/sysctl`会自动遍历`/etc/sysctl.d/*.conf`,无需额外设置。 +- **BBR支持**:若内核不支持BBR,不添加`net.ipv4.tcp_congestion_control=bbr`,保留默认cubic。 +- **故障排查**:外网测速慢时,请先排除: + - 代理软件(openclash/mihomo)劫持流量。 + - 上游运营商或国际链路瓶颈。 +- **配置回滚**:如需恢复默认设置,只需: + ```bash + rm /etc/sysctl.d/90-tcp-tuning.conf + sysctl -p /etc/sysctl.d/90-tcp-tuning.conf # 或重启设备 + ``` + +--- \ No newline at end of file diff --git a/patches/micromark-extension-mdx-jsx+3.0.2.patch b/patches/micromark-extension-mdx-jsx+3.0.2.patch new file mode 100644 index 0000000000..8df89d660c --- /dev/null +++ b/patches/micromark-extension-mdx-jsx+3.0.2.patch @@ -0,0 +1,48 @@ +diff --git a/node_modules/micromark-extension-mdx-jsx/dev/lib/factory-tag.js b/node_modules/micromark-extension-mdx-jsx/dev/lib/factory-tag.js +index 36d8f7f..9f9d6eb 100644 +--- a/node_modules/micromark-extension-mdx-jsx/dev/lib/factory-tag.js ++++ b/node_modules/micromark-extension-mdx-jsx/dev/lib/factory-tag.js +@@ -151,6 +151,19 @@ export function factoryTag( + * @type {State} + */ + function nameBefore(code) { ++ // Netcatty notes are Markdown: numeric comparisons cannot start JSX tags. ++ // Let micromark roll back this construct and keep the literal text. ++ if ( ++ code !== codes.eof && ++ ((code >= codes.digit0 && code <= codes.digit9) || ++ code === codes.equalsTo || ++ code === codes.plusSign || ++ code === codes.dash || ++ code === codes.dot) ++ ) { ++ return nok(code) ++ } ++ + // Closing tag. + if (code === codes.slash) { + effects.enter(tagClosingMarkerType) +diff --git a/node_modules/micromark-extension-mdx-jsx/lib/factory-tag.js b/node_modules/micromark-extension-mdx-jsx/lib/factory-tag.js +index 6beab7e..b501f5e 100644 +--- a/node_modules/micromark-extension-mdx-jsx/lib/factory-tag.js ++++ b/node_modules/micromark-extension-mdx-jsx/lib/factory-tag.js +@@ -108,6 +108,19 @@ export function factoryTag(effects, ok, nok, acorn, acornOptions, addResult, all + * @type {State} + */ + function nameBefore(code) { ++ // Netcatty notes are Markdown: numeric comparisons cannot start JSX tags. ++ // Let micromark roll back this construct and keep the literal text. ++ if ( ++ code !== null && ++ ((code >= 48 && code <= 57) || ++ code === 61 || ++ code === 43 || ++ code === 45 || ++ code === 46) ++ ) { ++ return nok(code); ++ } ++ + // Closing tag. + if (code === 47) { + effects.enter(tagClosingMarkerType);