Skip to content

Commit d7df271

Browse files
committed
feat: support mermaid node
1 parent 90884af commit d7df271

File tree

17 files changed

+1437
-64
lines changed

17 files changed

+1437
-64
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@
149149
"fuse.js": "^7.0.0",
150150
"html-parse-stringify": "^3.0.1",
151151
"i18next": "^23.10.1",
152+
"js-event-bus": "^1.1.1",
152153
"jsx-dom-cjs": "^8.1.5",
153154
"linkify-it": "^5.0.0",
154155
"lodash": "^4.17.21",
@@ -157,6 +158,7 @@
157158
"mdast-util-from-markdown": "^1.3.1",
158159
"mdast-util-gfm-autolink-literal": "^1.0.3",
159160
"mdast-util-gfm-strikethrough": "^1.0.3",
161+
"mermaid": "^11.4.1",
160162
"micromark-extension-gfm-autolink-literal": "^1.0.5",
161163
"micromark-extension-gfm-strikethrough": "^1.0.7",
162164
"nanoid": "^5.0.8",

src/editor/codemirror/codemirror.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import type { EditorSchema, EditorView, ProsemirrorNode } from '@remirror/pm'
1414
import { exitCode } from '@remirror/pm/commands'
1515
import { Selection, TextSelection } from '@remirror/pm/state'
1616
import { lightTheme } from '../../editor/theme'
17-
import { ensureSyntaxTree, type LanguageDescription, type LanguageSupport } from '@codemirror/language'
17+
import {
18+
ensureSyntaxTree,
19+
type LanguageDescription,
20+
type LanguageSupport,
21+
} from '@codemirror/language'
1822
import { languages } from '@codemirror/language-data'
1923
import { nanoid } from 'nanoid'
2024
import type { LoadLanguage } from '../extensions/CodeMirror/codemirror-node-view'
@@ -36,17 +40,17 @@ export const changeTheme = (theme: CreateThemeOptions): void => {
3640
}
3741

3842
export const extractMatches = (view: CodeMirrorEditorView) => {
39-
const tree: Tree | null = ensureSyntaxTree(view.state, view.state.doc.length);
40-
const matches: any[] = [];
43+
const tree: Tree | null = ensureSyntaxTree(view.state, view.state.doc.length)
44+
const matches: any[] = []
4145
tree?.iterate({
4246
from: 0,
4347
to: view.state.doc.length,
4448
enter: ({ type, from, to }: SyntaxNodeRef) => {
4549
if (type.name.startsWith('ATXHeading')) {
46-
matches.push({ from, to, value: view.state.doc.sliceString(from, to), type: type.name });
50+
matches.push({ from, to, value: view.state.doc.sliceString(from, to), type: type.name })
4751
}
48-
}
49-
});
52+
},
53+
})
5054

5155
return matches
5256
}
@@ -58,6 +62,8 @@ export type CreateCodemirrorOptions = {
5862
useProsemirrorHistoryKey?: boolean
5963

6064
codemirrorEditorViewConfig?: CodeMirrorEditorViewConfig
65+
66+
onValueChange?: (value: string) => void
6167
}
6268

6369
export class MfCodemirrorView {
@@ -241,6 +247,8 @@ export class MfCodemirrorView {
241247
change.text ? this.schema.text(change.text) : [],
242248
)
243249
this.view.dispatch(transaction)
250+
251+
this.options?.onValueChange?.(tr.state.doc.toString())
244252
}
245253
}
246254

src/editor/components/Preview/preview.tsx

Lines changed: 10 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,23 @@
11
import { WysiwygThemeWrapper } from '../../theme'
2-
import { prosemirrorNodeToHtml } from 'remirror'
32
import { DOMSerializer, type Node } from '@remirror/pm/model'
43
import { EditorProps } from '../Editor'
54
import { createWysiwygDelegate } from '../WysiwygEditor'
6-
// @ts-ignore
7-
import HTML from 'html-parse-stringify'
85
import { useEffect, useState } from 'react'
9-
import { nanoid } from 'nanoid'
106
import { Icon } from 'zens'
7+
import { rmeProsemirrorNodeToHtml } from '@/editor/utils/prosemirrorNodeToHtml'
118

129
interface PreviewProps {
1310
doc: Node | string
1411
delegateOptions?: EditorProps['delegateOptions']
12+
onError?: (e: Error) => void
1513
}
1614

1715
export type HTMLAstNode = {
1816
attrs: Record<string, any>
1917
name: string
2018
type: string
2119
children?: HTMLAstNode[]
20+
content?: string
2221
}
2322

2423
export const Preview: React.FC<PreviewProps> = (props) => {
@@ -30,46 +29,16 @@ export const Preview: React.FC<PreviewProps> = (props) => {
3029
targetDoc = createWysiwygDelegate(delegateOptions).stringToDoc(targetDoc)
3130
}
3231

33-
const html = prosemirrorNodeToHtml(targetDoc)
34-
3532
useEffect(() => {
36-
const fullAst = HTML.parse(html)
37-
38-
const imageLoadTasks: Promise<void>[] = []
39-
const handleHtmlText = async (ast: HTMLAstNode[]) => {
40-
const handleNode = (node: HTMLAstNode) => {
41-
if (!node) {
42-
return
43-
}
44-
45-
if (node.name === 'img' && node.attrs?.src && delegateOptions?.handleViewImgSrcUrl) {
46-
imageLoadTasks.push(
47-
(async () => {
48-
node.attrs.src = await delegateOptions?.handleViewImgSrcUrl?.(node.attrs.src)
49-
node.attrs.key = nanoid()
50-
})(),
51-
)
52-
}
53-
54-
if (node.children) {
55-
handleHtmlText(node.children)
56-
}
57-
}
58-
59-
for (let i = 0; i < ast.length; i++) {
60-
handleNode(ast[i])
61-
}
62-
}
63-
64-
handleHtmlText(fullAst)
65-
Promise.all(imageLoadTasks)
66-
.then((res) => {
67-
setProcessedHtml(HTML.stringify(fullAst))
68-
})
69-
.catch(() => {
33+
rmeProsemirrorNodeToHtml(targetDoc, delegateOptions)
34+
.then((html) => {
7035
setProcessedHtml(html)
7136
})
72-
}, [html])
37+
.catch((e) => {
38+
props.onError?.(e)
39+
console.error(e)
40+
})
41+
}, [props.onError])
7342

7443
if (!processedHtml) {
7544
return (

src/editor/components/ThemeProvider.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { ThemeProvider as ScThemeProvider } from 'styled-components'
22
import { CreateThemeOptions, changeTheme } from '../codemirror'
3-
import { useEffect } from 'react'
3+
import { memo, useEffect } from 'react'
44
import { darkTheme, lightTheme } from '../theme'
55
import { changeLng, i18nInit } from '../i18n'
6+
import mermaid from 'mermaid'
7+
import { eventBus } from '../utils/eventbus'
68

79
export * from './Editor'
810

@@ -26,14 +28,13 @@ type Props = {
2628
children?: React.ReactNode
2729
}
2830

29-
export const ThemeProvider: React.FC<Props> = ({ theme, i18n, children }: Props) => {
31+
export const ThemeProvider: React.FC<Props> = memo(({ theme, i18n, children }: Props) => {
3032
const mode = theme?.mode || 'light'
3133

3234
const defaultThemeToken = mode === 'dark' ? darkTheme.styledConstants : lightTheme.styledConstants
3335

3436
const themeToken = theme?.token ? { ...defaultThemeToken, ...theme.token } : defaultThemeToken
3537

36-
3738
useEffect(() => {
3839
if (i18n?.locales) {
3940
i18nInit(i18n.locales).then(() => {
@@ -50,7 +51,13 @@ export const ThemeProvider: React.FC<Props> = ({ theme, i18n, children }: Props)
5051
? darkTheme.codemirrorTheme
5152
: lightTheme.codemirrorTheme
5253
changeTheme(codemirrorTheme)
54+
55+
mermaid.initialize({
56+
theme: mode === 'dark' ? 'dark' : 'default',
57+
})
58+
59+
eventBus.emit('change-theme')
5360
}, [mode, theme?.codemirrorTheme, changeTheme])
5461

5562
return <ScThemeProvider theme={themeToken}>{children}</ScThemeProvider>
56-
}
63+
})

src/editor/extensions/CodeMirror/codemirror-extension.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export class LineCodeMirrorExtension extends NodeExtension<CodeMirrorExtensionOp
116116
* when typing triple back tick followed by a space.
117117
*/
118118
createInputRules(): InputRule[] {
119-
const regexp = /^```(\S+) $/
119+
const regexp = /^```(?!mermaid)(\S*) $/
120120

121121
const getAttributes: GetAttributes = (match) => {
122122
const language = match[1] ?? ''

src/editor/extensions/CodeMirror/codemirror-utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ export function arrowHandler(dir: 'left' | 'right' | 'up' | 'down'): CommandFunc
2727
if (
2828
nextPos.$head &&
2929
(nextPos.$head.parent.type.name === 'codeMirror' ||
30-
nextPos.$head.parent.type.name === 'html_block')
30+
nextPos.$head.parent.type.name === 'html_block' ||
31+
nextPos.$head.parent.type.name === 'mermaid_node')
3132
) {
3233
dispatch?.(tr.setSelection(nextPos))
3334
return true

src/editor/extensions/HtmlNode/html-block-view.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ export class HtmlNodeView implements NodeView {
5959

6060
this.dom.appendChild(this._htmlSrcElt)
6161

62-
this.dom.addEventListener('click', () => this.ensureFocus())
6362
label.addEventListener('click', () => this.ensureFocus())
6463
this.dom.addEventListener('mouseenter', this.handleMouseEnter)
6564
this.dom.addEventListener('mouseleave', this.handleMouseLeave)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './mermaid-extension'
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { NodeSerializerOptions } from '../../transform'
2+
import { ParserRuleType } from '../../transform'
3+
import type {
4+
ApplySchemaAttributes,
5+
NodeExtensionSpec,
6+
NodeSpecOverride,
7+
NodeViewMethod,
8+
PrioritizedKeyBindings,
9+
} from '@remirror/core'
10+
import { NodeExtension, isElementDomNode, nodeInputRule } from '@remirror/core'
11+
import type { ProsemirrorNode } from '@remirror/pm'
12+
import { MermaidNodeView } from './mermaid-view'
13+
import type { InputRule } from '@remirror/pm/inputrules'
14+
import { TextSelection } from '@remirror/pm/state'
15+
import { arrowHandler } from '../CodeMirror/codemirror-utils'
16+
17+
export class MermaidBlockExtension extends NodeExtension {
18+
get name() {
19+
return 'mermaid_node' as const
20+
}
21+
22+
createNodeSpec(extra: ApplySchemaAttributes, override: NodeSpecOverride): NodeExtensionSpec {
23+
return {
24+
group: 'block',
25+
content: 'text*',
26+
defining: true,
27+
...override,
28+
code: true,
29+
marks: '',
30+
attrs: {
31+
...extra.defaults(),
32+
},
33+
parseDOM: [
34+
{
35+
tag: 'pre',
36+
getAttrs: (node) => (isElementDomNode(node) ? extra.parse(node) : false),
37+
},
38+
...(override.parseDOM ?? []),
39+
],
40+
toDOM() {
41+
return ['pre', { 'data-type': 'mermaid' }, 0]
42+
},
43+
isolating: true,
44+
}
45+
}
46+
47+
createNodeViews(): NodeViewMethod {
48+
return (node: ProsemirrorNode, view, getPos) => {
49+
return new MermaidNodeView(node, view, getPos as () => number)
50+
}
51+
}
52+
53+
createInputRules(): InputRule[] {
54+
const rules: InputRule[] = [
55+
nodeInputRule({
56+
regexp: /^```mermaid$/,
57+
type: this.type,
58+
beforeDispatch: ({ tr, start, match }) => {
59+
const $pos = tr.doc.resolve(start)
60+
tr.setSelection(TextSelection.near($pos))
61+
},
62+
}),
63+
]
64+
65+
return rules
66+
}
67+
68+
createKeymap(): PrioritizedKeyBindings {
69+
return {
70+
ArrowLeft: arrowHandler('left'),
71+
ArrowRight: arrowHandler('right'),
72+
ArrowUp: arrowHandler('up'),
73+
ArrowDown: arrowHandler('down'),
74+
}
75+
}
76+
77+
public fromMarkdown() {
78+
return [
79+
{
80+
type: ParserRuleType.block,
81+
token: 'mermaid_node',
82+
node: this.name,
83+
hasOpenClose: false,
84+
},
85+
] as const
86+
}
87+
88+
public toMarkdown({ state, node }: NodeSerializerOptions) {
89+
state.write('```mermaid\n')
90+
state.text(node.textContent, false)
91+
state.text('\n')
92+
state.write('```')
93+
state.closeBlock(node)
94+
state.ensureNewLine()
95+
}
96+
}

0 commit comments

Comments
 (0)