diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.i18n.yaml index 1fa2ca7ae4..9f1d512c97 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md -2026-09-17-multiline-display-dollar-math.md: b75d1e600b8579e5b60d11f5c1d48a3bde6d6f43 -2026-09-17-multiline-display-dollar-math.zh.md: 06980b242861eb57ee401ab4d0c8d554b00a6bb4 +2026-09-17-multiline-display-dollar-math.md: df2caeeea3a52ba8f04b2d4c598921a79222d0ce +2026-09-17-multiline-display-dollar-math.zh.md: ffe69e3e4857a44780ec0b7b37cb85fb93d59db2 diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md index b75d1e600b..df2caeeea3 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md +++ b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md @@ -21,7 +21,7 @@ In the session that surfaced this, one chat reply lost lines 22–55 of 55 to a [mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) gains multi-line display-dollar blocks. Its dollar construct keeps the opening line's text in `mathFlowValue` instead of fence meta and closes at a trailing `$$` on any later content line, so `$$a+b` continued by `c+d$$` becomes one math node. -It ends the attempt when a `$$` sequence is not followed by the line's end, on the opening line or any later one (`$$x$$ trailing`), because scanning on from there would consume a later block's closing fence. A solitary `$` is content rather than a failed fence, so a formula that uses one — `$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$` — keeps the block open and closes normally wherever this flow owns the block. A trailing backslash escapes the line ending, not the next line's first character, so the escape run ends at the line ending instead of reaching into the next candidate. An unescaped `%` starts a TeX comment that runs to the line ending, so a `$$` inside one closes nothing either; a `\verb…` region is verbatim, so a `%` inside one is content. Both states are tracked for the dollar flow, which is the only flow that reads them. +It ends the attempt when a `$$` sequence is not followed by the line's end, on the opening line or any later one (`$$x$$ trailing`), because scanning on from there would consume a later block's closing fence. A solitary `$` is content rather than a failed fence, so a formula that uses one — `$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$` — keeps the block open and closes normally wherever this flow owns the block. A trailing backslash escapes the line ending, not the next line's first character, so the escape run ends at the line ending instead of reaching into the next candidate. An unescaped `%` starts a TeX comment that runs to the line ending, so a `$$` at the end of one closes nothing when it appears on a later line of the block; a block that opens and closes on one line keeps closing at that line's `$$`, as it did before this flow spanned lines. A `\verb…` region is verbatim, so a `%` inside one is content. Both states are tracked for the dollar flow, which is the only flow that reads them. Its paragraph-interruption check declines instead of consuming the line ending, for the dollar construct only. Consuming it there keeps the paragraph open across the line boundary, so the document layer keeps the container and counts the next line lazy for every construct tried afterwards; that made upstream's `mathFlow`, the construct tried after this one, end empty inside a list item. Declining also leaves the interruption decision to that upstream flow, whose own check short-circuits without consuming and claims only the fences it accepts, so a candidate neither construct accepts (`**before` followed by `$$a\$b`) leaves its paragraph intact. The backslash construct has no competing upstream construct and keeps consuming there. diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md index 06980b2428..ffe69e3e48 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md @@ -21,7 +21,7 @@ $$f(x)=x [mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) 现在支持跨行的显示美元块。dollar 构造把开围栏同一行的文本留在 `mathFlowValue` 里而不是当作围栏 meta,并在后续任意内容行行尾的 `$$` 处闭合,于是 `$$a+b` 接 `c+d$$` 成为一个 math 节点。 -`$$` 序列后面不是该行行尾时(开围栏行或任意后续行,例如 `$$x$$ trailing`)它放弃这次尝试:继续扫描会把更靠后一个块的闭合围栏也吞掉。孤立的一个 `$` 属于公式内容而不是失败的围栏,所以凡是本 flow 拥有该块的场合,用到它的公式——`$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$`——保持块打开并正常闭合。行尾反斜杠转义的是换行本身,不是下一行的第一个字符,因此转义状态在换行处结束,不会延续到下一个候选。未转义的 `%` 会开启延续到行尾的 TeX 注释,注释里的 `$$` 同样不闭合任何东西;`\verb<分隔符>…<分隔符>` 是原样文本区域,其中的 `%` 属于内容。这两种状态都只为 dollar flow 跟踪,也只有它会读取。 +`$$` 序列后面不是该行行尾时(开围栏行或任意后续行,例如 `$$x$$ trailing`)它放弃这次尝试:继续扫描会把更靠后一个块的闭合围栏也吞掉。孤立的一个 `$` 属于公式内容而不是失败的围栏,所以凡是本 flow 拥有该块的场合,用到它的公式——`$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$`——保持块打开并正常闭合。行尾反斜杠转义的是换行本身,不是下一行的第一个字符,因此转义状态在换行处结束,不会延续到下一个候选。未转义的 `%` 会开启延续到行尾的 TeX 注释,因此当这样的 `$$` 出现在块的后续行行尾时不闭合任何东西;而在一行内开闭的块仍在该行的 `$$` 处闭合,与它跨行之前的行为一致。`\verb<分隔符>…<分隔符>` 是原样文本区域,其中的 `%` 属于内容。这两种状态都只为 dollar flow 跟踪,也只有它会读取。 它的段落打断检查改为「不认领」,且只作用于 dollar 构造。在那里消耗换行会让段落跨行边界保持打开,document 层因此保留容器并把下一行对随后尝试的所有构造计为 lazy;这正是上游 `mathFlow`(本构造之后被尝试的构造)在列表项内以空块结束的原因。不认领同时把打断决定交给上游 flow:它自己的检查不消耗换行就短路返回,并且只认领它接受的围栏,因此两个构造都不接受的候选(`**before` 后接 `$$a\$b`)保持原段落。反斜杠构造没有竞争的上游构造,仍然照旧消耗换行。 diff --git a/packages/client/ui-primitives/src/markdown/mathCompatibility.ts b/packages/client/ui-primitives/src/markdown/mathCompatibility.ts index f0d2fce3f7..6a7c50f414 100644 --- a/packages/client/ui-primitives/src/markdown/mathCompatibility.ts +++ b/packages/client/ui-primitives/src/markdown/mathCompatibility.ts @@ -135,6 +135,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number) const self = this let oddBackslashRun = false let inComment = false + let crossedLineEnding = false let sawClosingFenceSequence = false let verbPrefixIndex = 0 let verbatimDelimiter: number | null = null @@ -169,7 +170,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number) function content(code: number | null): State | undefined { if (code === codes.eof) return nok(code) - if (code === marker && (marker !== codes.dollarSign || (!oddBackslashRun && !inComment))) { + if (code === marker && (marker !== codes.dollarSign || (!oddBackslashRun && !(inComment && crossedLineEnding)))) { return effects.attempt( { partial: true, tokenize: tokenizeClosingFence }, closed, @@ -179,9 +180,12 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number) if (markdownLineEnding(code)) { // A trailing backslash escapes the line ending, not the next line's // first character, so the escape run ends here, and a TeX comment ends - // with the line. + // with the line. The comment exception covers only a closing fence on a + // later line, which is where the multi-line block can hide one; the + // same-line form keeps closing as it did before this flow spanned lines. oddBackslashRun = false inComment = false + crossedLineEnding = true // A paragraph-interruption check on the dollar flow must not consume the // line ending: that keeps the paragraph open across the line boundary, // and the document layer then counts the next line lazy for every @@ -219,9 +223,10 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number) function valueStart(code: number | null): State | undefined { effects.enter('mathFlowValue') // An unescaped `%` opens a TeX comment wherever it is the first character - // of a value run, such as a line that starts inside the block. + // of a value run, such as a line that starts inside the block. The + // backslash construct reads neither this state nor the escape run. oddBackslashRun = code === codes.backslash - if (code === codes.percentSign) inComment = true + if (code === codes.percentSign && marker === codes.dollarSign) inComment = true effects.consume(code) if (code !== codes.backslash) return value verbPrefixIndex = 0 @@ -258,9 +263,8 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number) return afterBackslash } // An unescaped `%` starts a TeX comment that runs to the line ending, so - // a `$$` inside it closes nothing. The backslash construct ignores this - // state, as it has no upstream counterpart to keep in step with. - if (!escaped && code === codes.percentSign) inComment = true + // a `$$` inside it closes nothing. + if (!escaped && code === codes.percentSign && marker === codes.dollarSign) inComment = true effects.consume(code) return value } diff --git a/packages/client/ui-primitives/tests/markdown.client.spec.tsx b/packages/client/ui-primitives/tests/markdown.client.spec.tsx index d61e279ef4..ec010f9807 100644 --- a/packages/client/ui-primitives/tests/markdown.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.client.spec.tsx @@ -569,38 +569,32 @@ describe('MarkdownText', () => { expect(container.querySelector('p')?.textContent).toBe('After') }) - it('ignores a closing fence inside a TeX comment wherever the comment starts', () => { - for (const source of [ - '$$\n\\frac{1}{ % keep $$\n2}\n$$\n\nAfter', - '$$\n% keep $$\n\\frac12\n$$\n\nAfter', - '$$% keep $$\n\\frac12\n$$\n\nAfter', - ]) { + it('ignores a closing fence inside a TeX comment on a later line', () => { + const commented = render() + expect(commented.container.querySelectorAll('.katex-display')).toHaveLength(1) + expect(commented.container.querySelector('.katex-display annotation')?.textContent) + .toBe('\\frac{1}{ % keep $$\n2}') + expect(commented.container.querySelector('.katex-error')).toBeNull() + expect(commented.container.querySelector('p')?.textContent).toBe('After') + commented.unmount() + + for (const source of ['$$\n% keep $$\n\\frac12\n$$\n\nAfter']) { const rendered = render() expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1) expect(rendered.container.querySelector('.katex-error')).toBeNull() - expect(rendered.container.querySelectorAll('p')).toHaveLength(1) - expect(rendered.container.querySelector('p')?.textContent).toBe('After') - expect(rendered.container.querySelector('.katex-display annotation')?.textContent) - .toBe('\\frac{1}{ % keep $$\n2}') - rendered.unmount() - break - } - - for (const source of [ - '$$\n% keep $$\n\\frac12\n$$\n\nAfter', - '$$% keep $$\n\\frac12\n$$\n\nAfter', - ]) { - const rendered = render() - - expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1) - expect(rendered.container.querySelector('.katex-error')).toBeNull() - expect(rendered.container.querySelectorAll('p')).toHaveLength(1) expect(rendered.container.querySelector('p')?.textContent).toBe('After') rendered.unmount() } }) + it('closes a same-line block whose percent is formula content', () => { + const { container } = render() + + expect(container.querySelectorAll('.katex-display')).toHaveLength(1) + expect(container.querySelector('.katex-display annotation')?.textContent).toBe('\\text{acc} = 95%') + }) + it('treats a verbatim region as content rather than a comment', () => { const { container } = render()