Skip to content

Commit 5a955ac

Browse files
committed
fix(math): enhance math recognition for incomplete TeX commands and improve loading state handling
1 parent 9d1e632 commit 5a955ac

File tree

3 files changed

+120
-24
lines changed

3 files changed

+120
-24
lines changed

src/components/MathInlineNode/MathInlineNode.vue

Lines changed: 88 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
<script setup lang="ts">
2-
import katex from 'katex'
3-
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
4-
import { renderKaTeXInWorker, setKaTeXCache } from '../../workers/katexWorkerClient'
2+
import { computed, onBeforeUnmount, onMounted, ref, useAttrs, watch } from 'vue'
3+
import { renderKaTeXInWorker } from '../../workers/katexWorkerClient'
54
65
const props = defineProps<{
76
node: {
@@ -10,13 +9,28 @@ const props = defineProps<{
109
raw: string
1110
loading?: boolean
1211
}
12+
/** link text / underline color (CSS color string) */
13+
color?: string
14+
/** underline height in px */
15+
underlineHeight?: number
16+
/** underline bottom offset (px). Can be negative. */
17+
underlineBottom?: number | string
18+
/** total animation duration in seconds */
19+
animationDuration?: number
20+
/** underline opacity */
21+
animationOpacity?: number
22+
/** animation timing function */
23+
animationTiming?: string
24+
/** animation iteration (e.g. 'infinite' or a number) */
25+
animationIteration?: string | number
1326
}>()
1427
1528
const mathElement = ref<HTMLElement | null>(null)
1629
let hasRenderedOnce = false
1730
let currentRenderId = 0
1831
let isUnmounted = false
1932
let currentAbortController: AbortController | null = null
33+
const renderingLoading = ref(true)
2034
2135
function renderMath() {
2236
if (!props.node.content || !mathElement.value || isUnmounted)
@@ -37,6 +51,7 @@ function renderMath() {
3751
return
3852
if (!mathElement.value)
3953
return
54+
renderingLoading.value = false
4055
mathElement.value.innerHTML = html
4156
hasRenderedOnce = true
4257
})
@@ -45,28 +60,21 @@ function renderMath() {
4560
return
4661
if (!mathElement.value)
4762
return
48-
// Try synchronous render as a fallback
49-
try {
50-
const html = katex.renderToString(props.node.content, {
51-
throwOnError: true,
52-
displayMode: false,
53-
})
54-
mathElement.value.innerHTML = html
55-
hasRenderedOnce = true
56-
try {
57-
setKaTeXCache(props.node.content, false, html)
58-
}
59-
catch {
60-
// ignore cache set errors
61-
}
62-
}
63-
catch {
64-
if (!hasRenderedOnce || !props.node.loading)
65-
mathElement.value.textContent = props.node.raw
63+
if (!hasRenderedOnce || !props.node.loading) {
64+
renderingLoading.value = true
65+
// mathElement.value.textContent = props.node.raw
6666
}
6767
})
6868
}
6969
70+
// watch(
71+
// () => props.node.loading,
72+
// (newVal) => {
73+
// nextTick(() => {
74+
75+
// })
76+
// },
77+
// )
7078
watch(
7179
() => props.node.content,
7280
() => {
@@ -85,15 +93,73 @@ onBeforeUnmount(() => {
8593
currentAbortController = null
8694
}
8795
})
96+
const cssVars = computed(() => {
97+
const bottom = props.underlineBottom !== undefined
98+
? (typeof props.underlineBottom === 'number' ? `${props.underlineBottom}px` : String(props.underlineBottom))
99+
: '-3px'
100+
101+
return {
102+
'--link-color': props.color ?? '#0366d6',
103+
'--underline-height': `${props.underlineHeight ?? 2}px`,
104+
'--underline-bottom': bottom,
105+
'--underline-opacity': String(props.animationOpacity ?? 0.9),
106+
'--underline-duration': `${props.animationDuration ?? 0.8}s`,
107+
'--underline-timing': props.animationTiming ?? 'linear',
108+
'--underline-iteration': typeof props.animationIteration === 'number' ? String(props.animationIteration) : (props.animationIteration ?? 'infinite'),
109+
} as Record<string, string>
110+
})
111+
const attrs = useAttrs()
88112
</script>
89113

90114
<template>
91-
<span ref="mathElement" class="math-inline" />
115+
<!-- <span v-if="renderingLoading"
116+
:style="cssVars"
117+
>Loading...</span> -->
118+
<span v-show="renderingLoading" class="math-loading inline-flex items-baseline gap-1.5" :aria-hidden="!node.loading ? 'true' : 'false'" v-bind="attrs" :style="cssVars">
119+
<span class="math-text-wrapper relative inline-flex">
120+
<span class="leading-[normal] math-text">Loading...</span>
121+
<span class="underline-anim" aria-hidden="true" />
122+
</span>
123+
</span>
124+
<span v-show="!renderingLoading" ref="mathElement" class="math-inline" />
92125
</template>
93126

94127
<style>
95128
.math-inline {
96129
display: inline-block;
97130
vertical-align: middle;
98131
}
132+
.math-loading .math-text-wrapper {
133+
position: relative;
134+
}
135+
136+
.math-loading .math-text {
137+
position: relative;
138+
z-index: 2;
139+
}
140+
141+
.underline-anim {
142+
position: absolute;
143+
left: 0;
144+
right: 0;
145+
height: var(--underline-height, 2px);
146+
bottom: var(--underline-bottom, -3px); /* a little below text */
147+
background: currentColor;
148+
/* grow symmetrically from the center */
149+
transform-origin: center center;
150+
will-change: transform, opacity;
151+
opacity: var(--underline-opacity, 0.9);
152+
transform: scaleX(0);
153+
animation: underlineLoop var(--underline-duration, 0.8s) var(--underline-timing, linear) var(--underline-iteration, infinite);
154+
}
155+
156+
@keyframes underlineLoop {
157+
0% { transform: scaleX(0); opacity: var(--underline-opacity, 0.9); }
158+
/* draw to full width by 75% (0.6s) */
159+
75% { transform: scaleX(1); opacity: var(--underline-opacity, 0.9); }
160+
/* hold at full width until ~99% (~0.2s pause) */
161+
99% { transform: scaleX(1); opacity: var(--underline-opacity, 0.9); }
162+
/* collapse quickly back to center right at the end */
163+
100% { transform: scaleX(0); opacity: 0; }
164+
}
99165
</style>

src/utils/markdown/plugins/math.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,11 @@ const CONTROL_MAP: Record<string, string> = {
120120
const TEX_CMD_RE = /\\[a-z]+/i
121121
const PREFIX_CLASS = '(?:\\\\|\\u0008)'
122122
const TEX_CMD_WITH_BRACES_RE = new RegExp(`${PREFIX_CLASS}(?:${ESCAPED_TEX_BRACE_COMMANDS})\\s*\\{[^}]+\\}`, 'i')
123+
// Detect brace-taking TeX commands even when the leading backslash or the
124+
// closing brace/content is missing (e.g. "operatorname{" or "operatorname{span").
125+
// This helps the heuristic treat incomplete but clearly TeX-like fragments
126+
// as math-like instead of plain text.
127+
const TEX_BRACE_CMD_START_RE = new RegExp(`(?:${PREFIX_CLASS})?(?:${ESCAPED_TEX_BRACE_COMMANDS})\s*\{`, 'i')
123128
const TEX_SPECIFIC_RE = /\\(?:text|frac|left|right|times)/
124129
const SUPER_SUB_RE = /\^|_/
125130
// Match common math operator symbols or named commands.
@@ -158,6 +163,7 @@ export function isMathLike(s: string) {
158163
// TeX commands e.g. \frac, \alpha
159164
const texCmd = TEX_CMD_RE.test(norm)
160165
const texCmdWithBraces = TEX_CMD_WITH_BRACES_RE.test(norm)
166+
const texBraceStart = TEX_BRACE_CMD_START_RE.test(norm)
161167

162168
// Explicit common TeX tokens (keeps compatibility with previous heuristic)
163169
const texSpecific = TEX_SPECIFIC_RE.test(norm)
@@ -170,7 +176,7 @@ export function isMathLike(s: string) {
170176
// common math words
171177
const words = WORDS_RE.test(norm)
172178

173-
return texCmd || texCmdWithBraces || texSpecific || superSub || ops || funcCall || words
179+
return texCmd || texCmdWithBraces || texBraceStart || texSpecific || superSub || ops || funcCall || words
174180
}
175181

176182
export function normalizeStandaloneBackslashT(s: string, opts?: MathOptions) {
@@ -285,9 +291,32 @@ export function applyMath(md: MarkdownIt, mathOpts?: MathOptions) {
285291
// 不能简单地用 indexOf 找到第一个 close — 需要处理嵌套与转义字符
286292
const endIdx = findMatchingClose(src, index + open.length, open, close)
287293
if (endIdx === -1) {
294+
// loading 状态的 math,没有找到匹配的结尾
295+
const preSearchPos = searchPos
288296
// no matching close for this opener; skip forward
289297
searchPos = index + open.length
290-
continue
298+
const content = src.slice(searchPos)
299+
if (isMathLike(content)) {
300+
foundAny = true
301+
if (!silent) {
302+
if (preSearchPos)
303+
pushText(src.slice(preSearchPos, searchPos))
304+
else
305+
pushText(src.slice(0, index))
306+
const token = state.push('math_inline', 'math', 0)
307+
token.content = normalizeStandaloneBackslashT(content, mathOpts)
308+
token.markup = open === '$$' ? '$$' : open === '\\(' ? '\\(\\)' : open === '$' ? '$' : '()'
309+
token.loading = true
310+
// consume the full inline source
311+
state.pos = src.length
312+
}
313+
searchPos = src.length
314+
// break
315+
}
316+
else {
317+
pushText(src.slice(preSearchPos, searchPos))
318+
}
319+
break
291320
}
292321
const content = src.slice(index + open.length, endIdx)
293322
if (!isMathLike(content)) {

test/debug/isMathLike.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ it('recognizes \"\\boldsymbol{...}\" as math-like', () => {
88
expect(isMathLike('\\(f^{(k)}(a)\\)')).toBe(true)
99
expect(isMathLike('\\(W^\perp\\)')).toBe(true)
1010
expect(isMathLike('\\(2025/9/30 21:37:24\\)')).toBe(false)
11+
expect(isMathLike('operatorname{')).toBe(true)
1112
})

0 commit comments

Comments
 (0)