@incremark/vue 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,394 @@
1
+ <script setup lang="ts">
2
+ import type { Code } from 'mdast'
3
+ import { computed, ref, watch, shallowRef, onUnmounted } from 'vue'
4
+
5
+ const props = withDefaults(
6
+ defineProps<{
7
+ node: Code
8
+ /** Shiki 主题,默认 github-dark */
9
+ theme?: string
10
+ /** 是否禁用代码高亮 */
11
+ disableHighlight?: boolean
12
+ /** Mermaid 渲染延迟(毫秒),用于流式输入时防抖 */
13
+ mermaidDelay?: number
14
+ }>(),
15
+ {
16
+ theme: 'github-dark',
17
+ disableHighlight: false,
18
+ mermaidDelay: 500
19
+ }
20
+ )
21
+
22
+ const copied = ref(false)
23
+ const highlightedHtml = ref('')
24
+ const isHighlighting = ref(false)
25
+ const highlightError = ref(false)
26
+
27
+ // Mermaid 支持
28
+ const mermaidSvg = ref('')
29
+ const mermaidError = ref('')
30
+ const mermaidLoading = ref(false)
31
+ const mermaidRef = shallowRef<any>(null)
32
+ let mermaidTimer: ReturnType<typeof setTimeout> | null = null
33
+ // 视图模式:'preview' | 'source'
34
+ const mermaidViewMode = ref<'preview' | 'source'>('preview')
35
+
36
+ function toggleMermaidView() {
37
+ mermaidViewMode.value = mermaidViewMode.value === 'preview' ? 'source' : 'preview'
38
+ }
39
+
40
+ const language = computed(() => props.node.lang || 'text')
41
+ const code = computed(() => props.node.value)
42
+ const isMermaid = computed(() => language.value === 'mermaid')
43
+
44
+ // 缓存 highlighter
45
+ const highlighterRef = shallowRef<any>(null)
46
+ const loadedLanguages = new Set<string>()
47
+ const loadedThemes = new Set<string>()
48
+
49
+ // Mermaid 渲染(带防抖动)
50
+ function scheduleRenderMermaid() {
51
+ if (!isMermaid.value || !code.value) return
52
+
53
+ // 清除之前的定时器
54
+ if (mermaidTimer) {
55
+ clearTimeout(mermaidTimer)
56
+ }
57
+
58
+ // 显示加载状态
59
+ mermaidLoading.value = true
60
+
61
+ // 防抖动延迟渲染
62
+ mermaidTimer = setTimeout(() => {
63
+ doRenderMermaid()
64
+ }, props.mermaidDelay)
65
+ }
66
+
67
+ async function doRenderMermaid() {
68
+ if (!code.value) return
69
+
70
+ mermaidError.value = ''
71
+
72
+ try {
73
+ // 动态导入 mermaid
74
+ if (!mermaidRef.value) {
75
+ // @ts-ignore - mermaid 是可选依赖
76
+ const mermaidModule = await import('mermaid')
77
+ mermaidRef.value = mermaidModule.default
78
+ mermaidRef.value.initialize({
79
+ startOnLoad: false,
80
+ theme: 'dark',
81
+ securityLevel: 'loose'
82
+ })
83
+ }
84
+
85
+ const mermaid = mermaidRef.value
86
+ const id = `mermaid-${Date.now()}-${Math.random().toString(36).slice(2)}`
87
+
88
+ const { svg } = await mermaid.render(id, code.value)
89
+ mermaidSvg.value = svg
90
+ } catch (e: any) {
91
+ // 不显示错误,可能是代码还不完整
92
+ mermaidError.value = ''
93
+ mermaidSvg.value = ''
94
+ } finally {
95
+ mermaidLoading.value = false
96
+ }
97
+ }
98
+
99
+ onUnmounted(() => {
100
+ if (mermaidTimer) {
101
+ clearTimeout(mermaidTimer)
102
+ }
103
+ })
104
+
105
+ // 动态加载 shiki 并高亮
106
+ async function highlight() {
107
+ if (isMermaid.value) {
108
+ scheduleRenderMermaid()
109
+ return
110
+ }
111
+
112
+ if (!code.value || props.disableHighlight) {
113
+ highlightedHtml.value = ''
114
+ return
115
+ }
116
+
117
+ isHighlighting.value = true
118
+ highlightError.value = false
119
+
120
+ try {
121
+ // 动态导入 shiki
122
+ if (!highlighterRef.value) {
123
+ const { createHighlighter } = await import('shiki')
124
+ highlighterRef.value = await createHighlighter({
125
+ themes: [props.theme as any],
126
+ langs: []
127
+ })
128
+ loadedThemes.add(props.theme)
129
+ }
130
+
131
+ const highlighter = highlighterRef.value
132
+ const lang = language.value
133
+
134
+ // 按需加载语言
135
+ if (!loadedLanguages.has(lang) && lang !== 'text') {
136
+ try {
137
+ await highlighter.loadLanguage(lang)
138
+ loadedLanguages.add(lang)
139
+ } catch {
140
+ // 语言不支持,标记但不阻止
141
+ }
142
+ }
143
+
144
+ // 按需加载主题
145
+ if (!loadedThemes.has(props.theme)) {
146
+ try {
147
+ await highlighter.loadTheme(props.theme)
148
+ loadedThemes.add(props.theme)
149
+ } catch {
150
+ // 主题不支持
151
+ }
152
+ }
153
+
154
+ const html = highlighter.codeToHtml(code.value, {
155
+ lang: loadedLanguages.has(lang) ? lang : 'text',
156
+ theme: loadedThemes.has(props.theme) ? props.theme : 'github-dark'
157
+ })
158
+ highlightedHtml.value = html
159
+ } catch (e) {
160
+ // Shiki 不可用或加载失败
161
+ highlightError.value = true
162
+ highlightedHtml.value = ''
163
+ } finally {
164
+ isHighlighting.value = false
165
+ }
166
+ }
167
+
168
+ // 监听代码变化,重新高亮/渲染
169
+ watch([code, () => props.theme, isMermaid], highlight, { immediate: true })
170
+
171
+ async function copyCode() {
172
+ try {
173
+ await navigator.clipboard.writeText(code.value)
174
+ copied.value = true
175
+ setTimeout(() => {
176
+ copied.value = false
177
+ }, 2000)
178
+ } catch {
179
+ // 复制失败静默处理
180
+ }
181
+ }
182
+ </script>
183
+
184
+ <template>
185
+ <!-- Mermaid 图表 -->
186
+ <div v-if="isMermaid" class="incremark-mermaid">
187
+ <div class="mermaid-header">
188
+ <span class="language">MERMAID</span>
189
+ <div class="mermaid-actions">
190
+ <button
191
+ class="view-toggle"
192
+ @click="toggleMermaidView"
193
+ type="button"
194
+ :disabled="!mermaidSvg"
195
+ >
196
+ {{ mermaidViewMode === 'preview' ? '源码' : '预览' }}
197
+ </button>
198
+ <button class="copy-btn" @click="copyCode" type="button">
199
+ {{ copied ? '✓ 已复制' : '复制' }}
200
+ </button>
201
+ </div>
202
+ </div>
203
+ <div class="mermaid-content">
204
+ <!-- 加载中 -->
205
+ <div v-if="mermaidLoading && !mermaidSvg" class="mermaid-loading">
206
+ <pre class="mermaid-source-code">{{ code }}</pre>
207
+ </div>
208
+ <!-- 源码模式 -->
209
+ <pre v-else-if="mermaidViewMode === 'source'" class="mermaid-source-code">{{ code }}</pre>
210
+ <!-- 预览模式 -->
211
+ <div v-else-if="mermaidSvg" v-html="mermaidSvg" class="mermaid-svg" />
212
+ <!-- 无法渲染时显示源码 -->
213
+ <pre v-else class="mermaid-source-code">{{ code }}</pre>
214
+ </div>
215
+ </div>
216
+
217
+ <!-- 普通代码块 -->
218
+ <div v-else class="incremark-code">
219
+ <div class="code-header">
220
+ <span class="language">{{ language }}</span>
221
+ <button class="copy-btn" @click="copyCode" type="button">
222
+ {{ copied ? '✓ 已复制' : '复制' }}
223
+ </button>
224
+ </div>
225
+ <div class="code-content">
226
+ <!-- 正在加载高亮 -->
227
+ <div v-if="isHighlighting && !highlightedHtml" class="code-loading">
228
+ <pre><code>{{ code }}</code></pre>
229
+ </div>
230
+ <!-- 高亮后的代码 -->
231
+ <div v-else-if="highlightedHtml" v-html="highlightedHtml" class="shiki-wrapper" />
232
+ <!-- 回退:无高亮 -->
233
+ <pre v-else class="code-fallback"><code>{{ code }}</code></pre>
234
+ </div>
235
+ </div>
236
+ </template>
237
+
238
+ <style scoped>
239
+ /* Mermaid 样式 */
240
+ .incremark-mermaid {
241
+ margin: 1em 0;
242
+ border-radius: 8px;
243
+ overflow: hidden;
244
+ background: #1a1a2e;
245
+ }
246
+
247
+ .mermaid-header {
248
+ display: flex;
249
+ justify-content: space-between;
250
+ align-items: center;
251
+ padding: 8px 16px;
252
+ background: #16213e;
253
+ border-bottom: 1px solid #0f3460;
254
+ font-size: 12px;
255
+ }
256
+
257
+ .mermaid-actions {
258
+ display: flex;
259
+ gap: 8px;
260
+ }
261
+
262
+ .view-toggle {
263
+ padding: 4px 10px;
264
+ border: 1px solid #0f3460;
265
+ border-radius: 6px;
266
+ background: transparent;
267
+ color: #8b949e;
268
+ font-size: 12px;
269
+ cursor: pointer;
270
+ transition: all 0.2s;
271
+ }
272
+
273
+ .view-toggle:hover:not(:disabled) {
274
+ background: #0f3460;
275
+ color: #e0e0e0;
276
+ }
277
+
278
+ .view-toggle:disabled {
279
+ opacity: 0.5;
280
+ cursor: not-allowed;
281
+ }
282
+
283
+ .mermaid-content {
284
+ padding: 16px;
285
+ min-height: 100px;
286
+ }
287
+
288
+ .mermaid-loading {
289
+ color: #8b949e;
290
+ font-size: 14px;
291
+ }
292
+
293
+ .mermaid-source-code {
294
+ margin: 0;
295
+ padding: 12px;
296
+ background: #0d1117;
297
+ border-radius: 6px;
298
+ color: #c9d1d9;
299
+ font-family: 'Fira Code', 'SF Mono', monospace;
300
+ font-size: 13px;
301
+ line-height: 1.5;
302
+ white-space: pre-wrap;
303
+ overflow-x: auto;
304
+ }
305
+
306
+ .mermaid-svg {
307
+ overflow-x: auto;
308
+ }
309
+
310
+ .mermaid-svg :deep(svg) {
311
+ max-width: 100%;
312
+ height: auto;
313
+ }
314
+
315
+ /* 代码块样式 */
316
+ .incremark-code {
317
+ margin: 1em 0;
318
+ border-radius: 8px;
319
+ overflow: hidden;
320
+ background: #24292e;
321
+ }
322
+
323
+ .code-header {
324
+ display: flex;
325
+ justify-content: space-between;
326
+ align-items: center;
327
+ padding: 8px 16px;
328
+ background: #1f2428;
329
+ border-bottom: 1px solid #30363d;
330
+ font-size: 12px;
331
+ }
332
+
333
+ .language {
334
+ color: #8b949e;
335
+ text-transform: uppercase;
336
+ font-weight: 500;
337
+ letter-spacing: 0.5px;
338
+ }
339
+
340
+ .copy-btn {
341
+ padding: 4px 12px;
342
+ border: 1px solid #30363d;
343
+ border-radius: 6px;
344
+ background: transparent;
345
+ color: #8b949e;
346
+ font-size: 12px;
347
+ cursor: pointer;
348
+ transition: all 0.2s;
349
+ }
350
+
351
+ .copy-btn:hover {
352
+ background: #30363d;
353
+ color: #c9d1d9;
354
+ }
355
+
356
+ .code-content {
357
+ overflow-x: auto;
358
+ }
359
+
360
+ .code-loading {
361
+ opacity: 0.7;
362
+ }
363
+
364
+ /* Shiki 生成的代码样式 */
365
+ .shiki-wrapper :deep(pre) {
366
+ margin: 0;
367
+ padding: 16px;
368
+ background: transparent !important;
369
+ overflow-x: auto;
370
+ }
371
+
372
+ .shiki-wrapper :deep(code) {
373
+ font-family: 'Fira Code', 'SF Mono', 'Monaco', 'Consolas', monospace;
374
+ font-size: 14px;
375
+ line-height: 1.6;
376
+ }
377
+
378
+ /* 回退样式 */
379
+ .code-fallback,
380
+ .code-loading pre {
381
+ margin: 0;
382
+ padding: 16px;
383
+ overflow-x: auto;
384
+ background: transparent;
385
+ }
386
+
387
+ .code-fallback code,
388
+ .code-loading code {
389
+ font-family: 'Fira Code', 'SF Mono', 'Monaco', 'Consolas', monospace;
390
+ font-size: 14px;
391
+ line-height: 1.6;
392
+ color: #c9d1d9;
393
+ }
394
+ </style>
@@ -0,0 +1,42 @@
1
+ <script setup lang="ts">
2
+ import type { RootContent } from 'mdast'
3
+
4
+ defineProps<{
5
+ node: RootContent
6
+ }>()
7
+ </script>
8
+
9
+ <template>
10
+ <div class="incremark-default">
11
+ <span class="type-badge">{{ node.type }}</span>
12
+ <pre>{{ JSON.stringify(node, null, 2) }}</pre>
13
+ </div>
14
+ </template>
15
+
16
+ <style scoped>
17
+ .incremark-default {
18
+ margin: 0.5em 0;
19
+ padding: 10px;
20
+ background: #fff3cd;
21
+ border: 1px solid #ffc107;
22
+ border-radius: 4px;
23
+ font-size: 12px;
24
+ }
25
+
26
+ .type-badge {
27
+ display: inline-block;
28
+ padding: 2px 8px;
29
+ background: #ffc107;
30
+ border-radius: 4px;
31
+ font-weight: 600;
32
+ margin-bottom: 8px;
33
+ }
34
+
35
+ pre {
36
+ margin: 0;
37
+ white-space: pre-wrap;
38
+ word-break: break-all;
39
+ font-size: 11px;
40
+ }
41
+ </style>
42
+
@@ -0,0 +1,33 @@
1
+ <script setup lang="ts">
2
+ import type { Heading } from 'mdast'
3
+ import { computed } from 'vue'
4
+ import IncremarkInline from './IncremarkInline.vue'
5
+
6
+ const props = defineProps<{
7
+ node: Heading
8
+ }>()
9
+
10
+ const tag = computed(() => `h${props.node.depth}`)
11
+ </script>
12
+
13
+ <template>
14
+ <component :is="tag" class="incremark-heading">
15
+ <IncremarkInline :nodes="node.children" />
16
+ </component>
17
+ </template>
18
+
19
+ <style scoped>
20
+ .incremark-heading {
21
+ margin: 0.5em 0;
22
+ font-weight: 600;
23
+ line-height: 1.3;
24
+ }
25
+
26
+ h1 { font-size: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
27
+ h2 { font-size: 1.5em; }
28
+ h3 { font-size: 1.25em; }
29
+ h4 { font-size: 1em; }
30
+ h5 { font-size: 0.875em; }
31
+ h6 { font-size: 0.85em; color: #666; }
32
+ </style>
33
+
@@ -0,0 +1,75 @@
1
+ <script setup lang="ts">
2
+ import type { PhrasingContent } from 'mdast'
3
+ import IncremarkMath from './IncremarkMath.vue'
4
+
5
+ defineProps<{
6
+ nodes: PhrasingContent[]
7
+ }>()
8
+
9
+ function escapeHtml(str: string): string {
10
+ return str
11
+ .replace(/&/g, '&amp;')
12
+ .replace(/</g, '&lt;')
13
+ .replace(/>/g, '&gt;')
14
+ .replace(/"/g, '&quot;')
15
+ }
16
+ </script>
17
+
18
+ <template>
19
+ <template v-for="(node, idx) in nodes" :key="idx">
20
+ <!-- 文本 -->
21
+ <template v-if="node.type === 'text'">{{ node.value }}</template>
22
+
23
+ <!-- 行内公式 -->
24
+ <IncremarkMath v-else-if="(node as any).type === 'inlineMath'" :node="node as any" />
25
+
26
+ <!-- 加粗 -->
27
+ <strong v-else-if="node.type === 'strong'">
28
+ <IncremarkInline :nodes="(node.children as PhrasingContent[])" />
29
+ </strong>
30
+
31
+ <!-- 斜体 -->
32
+ <em v-else-if="node.type === 'emphasis'">
33
+ <IncremarkInline :nodes="(node.children as PhrasingContent[])" />
34
+ </em>
35
+
36
+ <!-- 行内代码 -->
37
+ <code v-else-if="node.type === 'inlineCode'" class="incremark-inline-code">{{ node.value }}</code>
38
+
39
+ <!-- 链接 -->
40
+ <a
41
+ v-else-if="node.type === 'link'"
42
+ :href="node.url"
43
+ target="_blank"
44
+ rel="noopener"
45
+ >
46
+ <IncremarkInline :nodes="(node.children as PhrasingContent[])" />
47
+ </a>
48
+
49
+ <!-- 图片 -->
50
+ <img
51
+ v-else-if="node.type === 'image'"
52
+ :src="node.url"
53
+ :alt="node.alt || ''"
54
+ loading="lazy"
55
+ />
56
+
57
+ <!-- 换行 -->
58
+ <br v-else-if="node.type === 'break'" />
59
+
60
+ <!-- 删除线 -->
61
+ <del v-else-if="node.type === 'delete'">
62
+ <IncremarkInline :nodes="(node.children as PhrasingContent[])" />
63
+ </del>
64
+ </template>
65
+ </template>
66
+
67
+ <style>
68
+ .incremark-inline-code {
69
+ padding: 0.2em 0.4em;
70
+ background: rgba(0, 0, 0, 0.06);
71
+ border-radius: 4px;
72
+ font-family: 'Fira Code', 'SF Mono', Consolas, monospace;
73
+ font-size: 0.9em;
74
+ }
75
+ </style>
@@ -0,0 +1,83 @@
1
+ <script setup lang="ts">
2
+ import type { List, ListItem, PhrasingContent } from 'mdast'
3
+ import { computed } from 'vue'
4
+ import IncremarkInline from './IncremarkInline.vue'
5
+
6
+ const props = defineProps<{
7
+ node: List
8
+ }>()
9
+
10
+ const tag = computed(() => props.node.ordered ? 'ol' : 'ul')
11
+
12
+ function getItemContent(item: ListItem): PhrasingContent[] {
13
+ const firstChild = item.children[0]
14
+ if (firstChild?.type === 'paragraph') {
15
+ return firstChild.children as PhrasingContent[]
16
+ }
17
+ return []
18
+ }
19
+ </script>
20
+
21
+ <template>
22
+ <component :is="tag" class="incremark-list" :class="{ 'task-list': node.children.some(item => item.checked !== null && item.checked !== undefined) }">
23
+ <li
24
+ v-for="(item, index) in node.children"
25
+ :key="index"
26
+ class="incremark-list-item"
27
+ :class="{ 'task-item': item.checked !== null && item.checked !== undefined }"
28
+ >
29
+ <label v-if="item.checked !== null && item.checked !== undefined" class="task-label">
30
+ <input
31
+ type="checkbox"
32
+ :checked="item.checked"
33
+ disabled
34
+ class="checkbox"
35
+ />
36
+ <span class="task-content">
37
+ <IncremarkInline :nodes="getItemContent(item)" />
38
+ </span>
39
+ </label>
40
+ <template v-else>
41
+ <IncremarkInline :nodes="getItemContent(item)" />
42
+ </template>
43
+ </li>
44
+ </component>
45
+ </template>
46
+
47
+ <style scoped>
48
+ .incremark-list {
49
+ margin: 0.75em 0;
50
+ padding-left: 2em;
51
+ }
52
+
53
+ .incremark-list.task-list {
54
+ list-style: none;
55
+ padding-left: 0;
56
+ }
57
+
58
+ .incremark-list-item {
59
+ margin: 0.25em 0;
60
+ line-height: 1.6;
61
+ }
62
+
63
+ .task-item {
64
+ list-style: none;
65
+ }
66
+
67
+ .task-label {
68
+ display: flex;
69
+ align-items: flex-start;
70
+ gap: 0.5em;
71
+ cursor: default;
72
+ }
73
+
74
+ .checkbox {
75
+ margin-top: 0.3em;
76
+ flex-shrink: 0;
77
+ }
78
+
79
+ .task-content {
80
+ flex: 1;
81
+ }
82
+ </style>
83
+