Skip to content

Commit e9384df

Browse files
committed
feat(web): support milkdown editor
1 parent 0049f6f commit e9384df

28 files changed

Lines changed: 5087 additions & 82 deletions

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
"kungalgamer",
9292
"kunloveren",
9393
"lastmod",
94+
"lezer",
9495
"lightbox",
9596
"localforage",
9697
"loli",
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<script setup lang="ts">
2+
import { MilkdownProvider } from '@milkdown/vue'
3+
import { ProsemirrorAdapterProvider } from '@prosemirror-adapter/vue'
4+
import { activeTab } from './atom'
5+
6+
defineProps<{
7+
valueMarkdown: string
8+
language?: Language
9+
}>()
10+
11+
const emits = defineEmits<{
12+
setMarkdown: [value: string]
13+
}>()
14+
15+
const cmAPI = ref({
16+
update: (_: string) => {}
17+
})
18+
19+
const saveMarkdown = (markdown: string) => {
20+
cmAPI.value.update(markdown)
21+
emits('setMarkdown', markdown)
22+
}
23+
24+
const setCmAPI = (api: { update: (markdown: string) => void }) => {
25+
cmAPI.value = api
26+
}
27+
</script>
28+
29+
<template>
30+
<MilkdownProvider>
31+
<ProsemirrorAdapterProvider>
32+
<div class="space-y-3">
33+
<KunMilkdownEditor
34+
:value-markdown="valueMarkdown"
35+
@save-markdown="saveMarkdown"
36+
:language="language ?? 'zh-cn'"
37+
>
38+
<template #footer>
39+
<slot />
40+
</template>
41+
</KunMilkdownEditor>
42+
43+
<template v-if="activeTab === 'code'">
44+
<KunMilkdownCodemirror
45+
:markdown="valueMarkdown"
46+
@set-cm-api="setCmAPI"
47+
@on-change="(value) => emits('setMarkdown', value)"
48+
/>
49+
</template>
50+
</div>
51+
</ProsemirrorAdapterProvider>
52+
</MilkdownProvider>
53+
</template>
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
<script setup lang="ts">
2+
// Milkdown core
3+
import { Editor, rootCtx, defaultValueCtx } from '@milkdown/kit/core'
4+
import { Milkdown, useEditor } from '@milkdown/vue'
5+
import { commonmark } from '@milkdown/kit/preset/commonmark'
6+
import { gfm } from '@milkdown/kit/preset/gfm'
7+
// Milkdown Plugins
8+
import { history } from '@milkdown/kit/plugin/history'
9+
import { listener, listenerCtx } from '@milkdown/kit/plugin/listener'
10+
import { clipboard } from '@milkdown/kit/plugin/clipboard'
11+
import { indent } from '@milkdown/kit/plugin/indent'
12+
import { trailing } from '@milkdown/kit/plugin/trailing'
13+
import { usePluginViewFactory } from '@prosemirror-adapter/vue'
14+
import { upload, uploadConfig } from '@milkdown/kit/plugin/upload'
15+
16+
// Custom plugins
17+
import { activeTab } from './atom'
18+
import { createKunUploader, kunUploadWidgetFactory } from './plugins/upload/uploader'
19+
import { tooltipFactory } from '@milkdown/kit/plugin/tooltip'
20+
import Tooltip from './plugins/tooltip/Tooltip.vue'
21+
import { replaceAll } from '@milkdown/kit/utils'
22+
import {
23+
stopLinkCommand,
24+
linkCustomKeymap
25+
} from './plugins/stop-link/stopLinkPlugin'
26+
import { kunSpoilerPlugin } from './plugins/spoiler/spoilerPlugin'
27+
28+
// Code Block
29+
import { defaultKeymap, indentWithTab } from '@codemirror/commands'
30+
import { keymap, EditorView } from '@codemirror/view'
31+
import {
32+
codeBlockComponent,
33+
codeBlockConfig,
34+
type CodeBlockConfig
35+
} from '@milkdown/kit/component/code-block'
36+
import { basicSetup } from 'codemirror'
37+
import {
38+
chevronDownIcon,
39+
clearIcon,
40+
copyIcon,
41+
editIcon,
42+
searchIcon,
43+
visibilityOffIcon
44+
} from './plugins/code/icons'
45+
import { languages } from '@codemirror/language-data'
46+
import { kunCM } from './codemirror/theme'
47+
48+
// katex
49+
import { blockKatexSchema } from './plugins/katex/blockKatex'
50+
import { mathInlineSchema } from './plugins/katex/inlineKatex'
51+
import { toggleLatexCommand } from './plugins/katex/command'
52+
import {
53+
mathBlockInputRule,
54+
mathInlineInputRule
55+
} from './plugins/katex/inputRule'
56+
import { remarkMathBlockPlugin, remarkMathPlugin } from './plugins/katex/remark'
57+
import katex from 'katex'
58+
import type { KatexOptions } from 'katex'
59+
60+
const props = defineProps<{
61+
valueMarkdown: string
62+
language: Language
63+
}>()
64+
65+
const emits = defineEmits<{
66+
saveMarkdown: [markdown: string]
67+
}>()
68+
69+
const valueMarkdown = computed(() => props.valueMarkdown)
70+
71+
const tooltip = tooltipFactory('Text')
72+
const pluginViewFactory = usePluginViewFactory()
73+
74+
// Captured in setup (Nuxt context) so the runtime API base can be handed to
75+
// the upload plugin, whose uploader runs outside setup (paste/drop handlers).
76+
const runtimeConfig = useRuntimeConfig()
77+
const apiBase =
78+
(runtimeConfig.public.apiBase as string) || 'http://127.0.0.1:5214/api/v1'
79+
const container = ref<HTMLElement | null>(null)
80+
const toolbar = ref<HTMLElement | null>(null)
81+
const editorContent = ref('')
82+
83+
const renderLatex = (content: string, options?: KatexOptions) => {
84+
const html = katex.renderToString(content, {
85+
...options,
86+
throwOnError: false,
87+
displayMode: true
88+
})
89+
return html
90+
}
91+
92+
const editorInfo = useEditor((root) =>
93+
Editor.make()
94+
.config((ctx) => {
95+
ctx.set(rootCtx, root)
96+
ctx.set(defaultValueCtx, valueMarkdown.value)
97+
98+
const listener = ctx.get(listenerCtx)
99+
listener.markdownUpdated((ctx, markdown, prevMarkdown) => {
100+
if (markdown !== prevMarkdown) {
101+
editorContent.value = markdown
102+
emits('saveMarkdown', markdown)
103+
}
104+
})
105+
106+
ctx.update(uploadConfig.key, (prev) => ({
107+
...prev,
108+
uploader: createKunUploader(apiBase),
109+
uploadWidgetFactory: kunUploadWidgetFactory
110+
}))
111+
112+
ctx.set(tooltip.key, {
113+
view: pluginViewFactory({
114+
component: Tooltip
115+
})
116+
})
117+
118+
const extensions = [
119+
kunCM(),
120+
EditorView.lineWrapping,
121+
keymap.of(defaultKeymap.concat(indentWithTab)),
122+
basicSetup
123+
]
124+
// if (theme) {
125+
// extensions.push(theme)
126+
// }
127+
128+
ctx.update(codeBlockConfig.key, (defaultConfig) => ({
129+
extensions,
130+
languages,
131+
expandIcon: chevronDownIcon,
132+
searchIcon: searchIcon,
133+
clearSearchIcon: clearIcon,
134+
searchPlaceholder: '搜索咒文',
135+
copyText: '复制咒文',
136+
copyIcon: copyIcon,
137+
onCopy: () => {},
138+
noResultText: '无结果',
139+
renderLanguage: defaultConfig.renderLanguage,
140+
previewLoading: '加载中...',
141+
renderPreview: defaultConfig.renderPreview,
142+
previewToggleButton: (previewOnlyMode) => {
143+
const icon = previewOnlyMode ? editIcon : visibilityOffIcon
144+
const text = previewOnlyMode ? '编辑' : '隐藏'
145+
return [icon, text].map((v) => v.trim()).join(' ')
146+
},
147+
previewLabel: defaultConfig.previewLabel
148+
// previewLoading: config.previewLoading || defaultConfig.previewLoading,
149+
// previewOnlyByDefault:
150+
// config.previewOnlyByDefault ?? defaultConfig.previewOnlyByDefault
151+
}))
152+
153+
const katexOptions: KatexOptions = {}
154+
155+
ctx.update(codeBlockConfig.key, (prev) => ({
156+
...prev,
157+
renderPreview: (language, content, applyPreview) => {
158+
if (language.toLowerCase() === 'latex' && content.length > 0) {
159+
return renderLatex(content, katexOptions)
160+
}
161+
const renderPreview = prev.renderPreview
162+
return renderPreview(language, content, applyPreview)
163+
}
164+
}))
165+
})
166+
.use(history)
167+
.use(commonmark)
168+
.use(gfm)
169+
.use(listener)
170+
.use(clipboard)
171+
.use(indent)
172+
.use(trailing)
173+
.use(tooltip)
174+
.use(upload)
175+
.use(codeBlockComponent)
176+
.use([kunSpoilerPlugin, stopLinkCommand, linkCustomKeymap].flat())
177+
.use(remarkMathPlugin)
178+
.use(remarkMathBlockPlugin)
179+
.use(mathInlineSchema)
180+
.use(mathInlineInputRule)
181+
.use(mathBlockInputRule)
182+
.use(blockKatexSchema)
183+
.use(toggleLatexCommand)
184+
)
185+
186+
const textCount = computed(() => markdownToText(props.valueMarkdown).length)
187+
188+
watch(
189+
() => [props.language],
190+
() => {
191+
editorInfo.get()?.action(replaceAll(valueMarkdown.value))
192+
}
193+
)
194+
</script>
195+
196+
<template>
197+
<div ref="container" class="space-y-3">
198+
<KunMilkdownPluginsMenu
199+
ref="toolbar"
200+
:editor-info="editorInfo"
201+
:is-show-upload-image="true"
202+
/>
203+
204+
<template v-if="activeTab === 'preview'">
205+
<Milkdown />
206+
207+
<div class="flex items-center justify-between text-sm">
208+
<slot name="footer" />
209+
210+
<div class="flex shrink-0 items-center gap-2">
211+
<KunChip color="success">
212+
<KunIconMarkdown class="text-success-700 dark:text-success" />
213+
Markdown 支持
214+
</KunChip>
215+
<span>
216+
{{ `${textCount} 字` }}
217+
</span>
218+
</div>
219+
</div>
220+
221+
<div class="text-default-500 text-sm">
222+
特殊语法: 您可以使用 ||隐藏文本|| 来隐藏图片或者文字 (目前依然禁止 R18
223+
图片内容)
224+
</div>
225+
</template>
226+
</div>
227+
</template>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export const tabs = [
2+
{ textValue: '回到预览', value: 'preview' },
3+
{ textValue: 'MD代码', value: 'code' }
4+
]
5+
6+
export const activeTab = ref('preview')
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<script setup lang="ts">
2+
import { createCodeMirrorState, createCodeMirrorView } from './setup'
3+
4+
interface Props {
5+
markdown: string
6+
}
7+
8+
const props = defineProps<Props>()
9+
const emit = defineEmits<{
10+
updateCmAPI: [{ update: (markdown: string) => void }]
11+
onChange: [value: string]
12+
}>()
13+
14+
const editorDiv = ref<HTMLDivElement | null>(null)
15+
let editor: ReturnType<typeof createCodeMirrorView> | null = null
16+
17+
onMounted(() => {
18+
if (!editorDiv.value) {
19+
return
20+
}
21+
22+
editor = createCodeMirrorView({
23+
root: editorDiv.value,
24+
onChange: (getString) => emit('onChange', getString()),
25+
content: props.markdown
26+
})
27+
28+
emit('updateCmAPI', {
29+
update: (markdown: string) => {
30+
const state = createCodeMirrorState({
31+
onChange: (getString) => emit('onChange', getString()),
32+
content: markdown
33+
})
34+
editor?.setState(state)
35+
}
36+
})
37+
})
38+
39+
onBeforeUnmount(() => {
40+
editor?.destroy()
41+
})
42+
43+
watch(
44+
() => props.markdown,
45+
(newMarkdown) => {
46+
if (editor && editor.state.doc.toString() !== newMarkdown) {
47+
const state = createCodeMirrorState({
48+
onChange: (getString) => emit('onChange', getString()),
49+
content: newMarkdown
50+
})
51+
editor.setState(state)
52+
}
53+
}
54+
)
55+
</script>
56+
57+
<template>
58+
<div
59+
ref="editorDiv"
60+
class="scrollbar-hide flex-1 overflow-y-scroll overscroll-none max-h-[500px]"
61+
/>
62+
</template>

0 commit comments

Comments
 (0)