@llui/markdown-editor 0.2.12 → 0.2.14

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.
Files changed (89) hide show
  1. package/README.md +44 -0
  2. package/dist/editor.d.ts.map +1 -1
  3. package/dist/editor.js +34 -8
  4. package/dist/editor.js.map +1 -1
  5. package/dist/effects.d.ts +5 -3
  6. package/dist/effects.d.ts.map +1 -1
  7. package/dist/effects.js +7 -5
  8. package/dist/effects.js.map +1 -1
  9. package/dist/format.js.map +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/paste.d.ts.map +1 -1
  12. package/dist/paste.js +5 -0
  13. package/dist/paste.js.map +1 -1
  14. package/dist/plugins/_preview.js.map +1 -1
  15. package/dist/plugins/callout.d.ts.map +1 -1
  16. package/dist/plugins/callout.js +37 -45
  17. package/dist/plugins/callout.js.map +1 -1
  18. package/dist/plugins/context-menu.js.map +1 -1
  19. package/dist/plugins/core.js.map +1 -1
  20. package/dist/plugins/emoji.js.map +1 -1
  21. package/dist/plugins/floating-toolbar.js.map +1 -1
  22. package/dist/plugins/hr.d.ts.map +1 -1
  23. package/dist/plugins/hr.js +4 -7
  24. package/dist/plugins/hr.js.map +1 -1
  25. package/dist/plugins/image.d.ts.map +1 -1
  26. package/dist/plugins/image.js +28 -20
  27. package/dist/plugins/image.js.map +1 -1
  28. package/dist/plugins/inline.js.map +1 -1
  29. package/dist/plugins/link.d.ts.map +1 -1
  30. package/dist/plugins/link.js +16 -9
  31. package/dist/plugins/link.js.map +1 -1
  32. package/dist/plugins/math.d.ts.map +1 -1
  33. package/dist/plugins/math.js +25 -34
  34. package/dist/plugins/math.js.map +1 -1
  35. package/dist/plugins/mention.js.map +1 -1
  36. package/dist/plugins/mermaid.d.ts.map +1 -1
  37. package/dist/plugins/mermaid.js +25 -34
  38. package/dist/plugins/mermaid.js.map +1 -1
  39. package/dist/plugins/overlay.js.map +1 -1
  40. package/dist/plugins/single-block.js.map +1 -1
  41. package/dist/plugins/slash.js.map +1 -1
  42. package/dist/plugins/table.d.ts.map +1 -1
  43. package/dist/plugins/table.js +7 -4
  44. package/dist/plugins/table.js.map +1 -1
  45. package/dist/plugins/types.js.map +1 -1
  46. package/dist/plugins/ui.js.map +1 -1
  47. package/dist/security.d.ts +26 -0
  48. package/dist/security.d.ts.map +1 -0
  49. package/dist/security.js +87 -0
  50. package/dist/security.js.map +1 -0
  51. package/dist/state.js.map +1 -1
  52. package/dist/surfaces/link-dialog.js.map +1 -1
  53. package/dist/surfaces/toolbar.js.map +1 -1
  54. package/dist/theme.js.map +1 -1
  55. package/dist/transformers/gfm.js.map +1 -1
  56. package/dist/transformers/registry.js.map +1 -1
  57. package/package.json +23 -17
  58. package/src/editor.ts +339 -0
  59. package/src/effects.ts +53 -0
  60. package/src/format.ts +57 -0
  61. package/src/index.ts +93 -0
  62. package/src/paste.ts +96 -0
  63. package/src/plugins/_preview.ts +51 -0
  64. package/src/plugins/callout.ts +180 -0
  65. package/src/plugins/context-menu.ts +118 -0
  66. package/src/plugins/core.ts +179 -0
  67. package/src/plugins/emoji.ts +59 -0
  68. package/src/plugins/floating-toolbar.ts +180 -0
  69. package/src/plugins/hr.ts +56 -0
  70. package/src/plugins/image.ts +240 -0
  71. package/src/plugins/inline.ts +76 -0
  72. package/src/plugins/link.ts +143 -0
  73. package/src/plugins/math.ts +102 -0
  74. package/src/plugins/mention.ts +224 -0
  75. package/src/plugins/mermaid.ts +115 -0
  76. package/src/plugins/overlay.ts +138 -0
  77. package/src/plugins/single-block.ts +193 -0
  78. package/src/plugins/slash.ts +224 -0
  79. package/src/plugins/table.ts +284 -0
  80. package/src/plugins/types.ts +50 -0
  81. package/src/plugins/ui.ts +79 -0
  82. package/src/security.ts +89 -0
  83. package/src/state.ts +220 -0
  84. package/src/styles/editor.css +554 -0
  85. package/src/surfaces/link-dialog.ts +67 -0
  86. package/src/surfaces/toolbar.ts +246 -0
  87. package/src/theme.ts +42 -0
  88. package/src/transformers/gfm.ts +69 -0
  89. package/src/transformers/registry.ts +47 -0
@@ -0,0 +1,240 @@
1
+ // Image plugin — a block image rendered via the decorator bridge, round-tripping
2
+ // to `![alt](src)` markdown, inserted through a plugin-UI dialog (URL + alt, with
3
+ // optional file upload). Exercises decorator rendering + a transformer + the
4
+ // plugin-UI extension all at once.
5
+
6
+ import {
7
+ $getSelection,
8
+ $setSelection,
9
+ type BaseSelection,
10
+ type ElementNode,
11
+ type LexicalEditor,
12
+ type LexicalNode,
13
+ } from 'lexical'
14
+ import { $insertNodeToNearestRoot } from '@lexical/utils'
15
+ import type { ElementTransformer } from '@lexical/markdown'
16
+ import {
17
+ $createLLuiDecoratorNode,
18
+ $isLLuiDecoratorNode,
19
+ LLuiDecoratorNode,
20
+ decoratorBridge,
21
+ } from '@llui/lexical'
22
+ import { button, div, img, input, text, type Signal } from '@llui/dom'
23
+ import {
24
+ connect as connectDialog,
25
+ overlay as overlayDialog,
26
+ type DialogMsg,
27
+ } from '@llui/components/dialog'
28
+ import { sanitizeImageUrl } from '../security.js'
29
+ import { definePluginUI } from './ui.js'
30
+ import type { MarkdownPlugin } from './types.js'
31
+
32
+ const BRIDGE_TYPE = 'image'
33
+
34
+ interface ImageData {
35
+ src: string
36
+ alt: string
37
+ }
38
+
39
+ function isImageData(value: unknown): value is ImageData {
40
+ return (
41
+ typeof value === 'object' &&
42
+ value !== null &&
43
+ typeof (value as ImageData).src === 'string' &&
44
+ typeof (value as ImageData).alt === 'string'
45
+ )
46
+ }
47
+
48
+ const imageBridge = decoratorBridge<ImageData>(BRIDGE_TYPE, (data) => [
49
+ div({ 'data-scope': 'md-image', 'data-part': 'root', contenteditable: 'false' }, [
50
+ img({ src: data.at('src') as Signal<string>, alt: data.at('alt') as Signal<string> }),
51
+ ]),
52
+ ])
53
+
54
+ const IMAGE_TRANSFORMER: ElementTransformer = {
55
+ dependencies: [LLuiDecoratorNode],
56
+ export: (node: LexicalNode): string | null => {
57
+ if (!$isLLuiDecoratorNode(node) || node.getBridgeType() !== BRIDGE_TYPE) return null
58
+ const data = node.getData()
59
+ return isImageData(data) ? `![${data.alt}](${data.src})` : null
60
+ },
61
+ regExp: /^!\[([^\]]*)\]\(([^)]+)\)$/,
62
+ replace: (parentNode: ElementNode, _children, match): void => {
63
+ // Enforce the image-src allowlist on import/paste: a disallowed scheme
64
+ // (e.g. `javascript:`) sanitizes to null → drop the image entirely rather
65
+ // than materialize a decorator node bound to an unsafe src.
66
+ const src = sanitizeImageUrl(match[2] ?? '')
67
+ if (src === null) return
68
+ parentNode.replace($createLLuiDecoratorNode(BRIDGE_TYPE, { alt: match[1] ?? '', src }))
69
+ },
70
+ type: 'element',
71
+ }
72
+
73
+ interface ImageState {
74
+ dialog: { open: boolean }
75
+ src: string
76
+ alt: string
77
+ }
78
+
79
+ type ImageMsg =
80
+ | { type: 'open' }
81
+ | { type: 'setSrc'; src: string }
82
+ | { type: 'setAlt'; alt: string }
83
+ | { type: 'submit' }
84
+ | { type: 'dialog'; msg: DialogMsg }
85
+
86
+ type ImageEffect = { type: 'begin' } | { type: 'insert'; src: string; alt: string }
87
+
88
+ function dialogOpen(msg: DialogMsg, current: boolean): boolean {
89
+ switch (msg.type) {
90
+ case 'open':
91
+ return true
92
+ case 'close':
93
+ return false
94
+ case 'toggle':
95
+ return !current
96
+ case 'setOpen':
97
+ return msg.open
98
+ case 'animationEnd':
99
+ case 'transitionEnd':
100
+ return current
101
+ }
102
+ }
103
+
104
+ export interface ImagePluginOptions {
105
+ /** Upload a chosen file and resolve to its URL. When omitted, the file picker
106
+ * is hidden and only URL entry is offered. */
107
+ upload?: (file: File) => Promise<string>
108
+ }
109
+
110
+ export function imagePlugin(opts: ImagePluginOptions = {}): MarkdownPlugin {
111
+ // Keyed by the per-mount editor so two mounts never cross-wire the selection
112
+ // saved while the insert dialog is open.
113
+ const savedSelection = new WeakMap<LexicalEditor, BaseSelection | null>()
114
+
115
+ return {
116
+ name: 'image',
117
+ nodes: [LLuiDecoratorNode],
118
+ decorators: [imageBridge],
119
+ transformers: [IMAGE_TRANSFORMER],
120
+ items: [
121
+ {
122
+ id: 'image',
123
+ label: 'Image',
124
+ icon: 'image',
125
+ group: 'insert',
126
+ keywords: ['img', 'picture', 'photo'],
127
+ run: (_editor, ctx) => ctx.send({ type: 'plugin', name: 'image', msg: { type: 'open' } }),
128
+ surfaces: ['toolbar', 'slash', 'context'],
129
+ },
130
+ ],
131
+ ui: definePluginUI<ImageState, ImageMsg, ImageEffect>({
132
+ init: () => ({ dialog: { open: false }, src: '', alt: '' }),
133
+ update: (state, msg) => {
134
+ switch (msg.type) {
135
+ case 'open':
136
+ return [{ dialog: { open: true }, src: '', alt: '' }, [{ type: 'begin' }]]
137
+ case 'setSrc':
138
+ return { ...state, src: msg.src }
139
+ case 'setAlt':
140
+ return { ...state, alt: msg.alt }
141
+ case 'submit':
142
+ return [
143
+ { ...state, dialog: { open: false } },
144
+ [{ type: 'insert', src: state.src, alt: state.alt }],
145
+ ]
146
+ case 'dialog': {
147
+ const open = dialogOpen(msg.msg, state.dialog.open)
148
+ return open === state.dialog.open ? state : { ...state, dialog: { open } }
149
+ }
150
+ }
151
+ },
152
+ view: ({ state, send }) => {
153
+ const dialogSend = (msg: DialogMsg): void => send({ type: 'dialog', msg })
154
+ const parts = connectDialog(state.at('dialog'), dialogSend, {
155
+ id: 'md-image-dialog',
156
+ closeLabel: 'Cancel',
157
+ })
158
+ return [
159
+ overlayDialog({
160
+ state: state.at('dialog'),
161
+ send: dialogSend,
162
+ parts,
163
+ content: () => [
164
+ div({ ...parts.content, 'data-md-link': 'box' }, [
165
+ div({ ...parts.title, 'data-md-link': 'title' }, [text('Insert image')]),
166
+ input({
167
+ 'data-md-link': 'input',
168
+ type: 'url',
169
+ placeholder: 'https://example.com/image.png',
170
+ value: state.at('src') as Signal<string>,
171
+ onInput: (e: Event) =>
172
+ send({ type: 'setSrc', src: (e.target as HTMLInputElement).value }),
173
+ }),
174
+ input({
175
+ 'data-md-link': 'input',
176
+ 'data-md-image': 'alt',
177
+ type: 'text',
178
+ placeholder: 'Alt text (description)',
179
+ value: state.at('alt') as Signal<string>,
180
+ onInput: (e: Event) =>
181
+ send({ type: 'setAlt', alt: (e.target as HTMLInputElement).value }),
182
+ }),
183
+ ...(opts.upload
184
+ ? [
185
+ input({
186
+ 'data-md-image': 'file',
187
+ type: 'file',
188
+ accept: 'image/*',
189
+ onChange: (e: Event) => {
190
+ const file = (e.target as HTMLInputElement).files?.[0]
191
+ if (file && opts.upload) {
192
+ void opts.upload(file).then((src) => send({ type: 'setSrc', src }))
193
+ }
194
+ },
195
+ }),
196
+ ]
197
+ : []),
198
+ div({ 'data-md-link': 'actions' }, [
199
+ button({ ...parts.closeTrigger, 'data-md-link': 'cancel' }, [text('Cancel')]),
200
+ button(
201
+ {
202
+ type: 'button',
203
+ 'data-md-link': 'apply',
204
+ onClick: () => send({ type: 'submit' }),
205
+ },
206
+ [text('Insert')],
207
+ ),
208
+ ]),
209
+ ]),
210
+ ],
211
+ }),
212
+ ]
213
+ },
214
+ onEffect: (effect, ctx) => {
215
+ const editor = ctx.editor()
216
+ if (!editor) return
217
+ if (effect.type === 'begin') {
218
+ savedSelection.set(
219
+ editor,
220
+ editor.getEditorState().read(() => {
221
+ const selection = $getSelection()
222
+ return selection ? selection.clone() : null
223
+ }),
224
+ )
225
+ return
226
+ }
227
+ // Enforce the image-src allowlist at insert: a disallowed scheme drops
228
+ // the insertion rather than binding the decorator to an unsafe src.
229
+ const src = sanitizeImageUrl(effect.src.trim())
230
+ if (src === null) return
231
+ const saved = savedSelection.get(editor) ?? null
232
+ editor.update(() => {
233
+ if (saved) $setSelection(saved.clone())
234
+ $insertNodeToNearestRoot($createLLuiDecoratorNode(BRIDGE_TYPE, { src, alt: effect.alt }))
235
+ })
236
+ savedSelection.delete(editor)
237
+ },
238
+ }),
239
+ }
240
+ }
@@ -0,0 +1,76 @@
1
+ // Shared inline text-formatting building blocks: the bold / italic /
2
+ // strikethrough / code command items and the `FORMAT_TEXT_COMMAND` dispatcher.
3
+ // Both `corePlugin` (the full GFM superset) and `singleBlockPlugin` (inline-only)
4
+ // derive their inline surface from here, so the canonical inline-format set and
5
+ // its labels/icons/keywords live in exactly one place.
6
+
7
+ import { FORMAT_TEXT_COMMAND, type LexicalEditor, type TextFormatType } from 'lexical'
8
+ import type { FormatState } from '../state.js'
9
+ import type { CommandItem } from './types.js'
10
+
11
+ /** An inline text-format surfaced as a toolbar command item. */
12
+ export type InlineFormat = 'bold' | 'italic' | 'strikethrough' | 'code'
13
+
14
+ /** The full inline-format set, in toolbar order. */
15
+ export const INLINE_FORMATS: readonly InlineFormat[] = ['bold', 'italic', 'strikethrough', 'code']
16
+
17
+ interface InlineFormatDef {
18
+ label: string
19
+ icon: string
20
+ keywords: readonly string[]
21
+ format: TextFormatType
22
+ isActive: (f: FormatState) => boolean
23
+ }
24
+
25
+ const DEFS: Record<InlineFormat, InlineFormatDef> = {
26
+ bold: {
27
+ label: 'Bold',
28
+ icon: 'bold',
29
+ keywords: ['strong'],
30
+ format: 'bold',
31
+ isActive: (f) => f.bold,
32
+ },
33
+ italic: {
34
+ label: 'Italic',
35
+ icon: 'italic',
36
+ keywords: ['emphasis'],
37
+ format: 'italic',
38
+ isActive: (f) => f.italic,
39
+ },
40
+ strikethrough: {
41
+ label: 'Strikethrough',
42
+ icon: 'strikethrough',
43
+ keywords: ['strike', 'del'],
44
+ format: 'strikethrough',
45
+ isActive: (f) => f.strikethrough,
46
+ },
47
+ code: {
48
+ label: 'Inline code',
49
+ icon: 'code',
50
+ keywords: ['mono'],
51
+ format: 'code',
52
+ isActive: (f) => f.code,
53
+ },
54
+ }
55
+
56
+ /** Dispatch a text-format toggle on the live editor. */
57
+ export function inlineFormatCommand(format: TextFormatType): (editor: LexicalEditor) => void {
58
+ return (editor) => editor.dispatchCommand(FORMAT_TEXT_COMMAND, format)
59
+ }
60
+
61
+ /** Build the inline `CommandItem`s for `formats` (default: all four), grouped
62
+ * under `'inline'`. Surfaces are left unset (item appears in every surface). */
63
+ export function inlineItems(formats: readonly InlineFormat[] = INLINE_FORMATS): CommandItem[] {
64
+ return formats.map((id) => {
65
+ const def = DEFS[id]
66
+ return {
67
+ id,
68
+ label: def.label,
69
+ icon: def.icon,
70
+ group: 'inline',
71
+ keywords: def.keywords,
72
+ run: inlineFormatCommand(def.format),
73
+ isActive: def.isActive,
74
+ }
75
+ })
76
+ }
@@ -0,0 +1,143 @@
1
+ // The link plugin — a stateful, UI-bearing feature built entirely as a plugin
2
+ // (no core editor changes). It owns its dialog state, its view (the modal), and
3
+ // its effects (save/restore selection + toggle the link). This is the proof that
4
+ // the plugin-UI extension makes such features pluggable rather than built-in.
5
+
6
+ import {
7
+ $getSelection,
8
+ $isRangeSelection,
9
+ $setSelection,
10
+ type BaseSelection,
11
+ type LexicalEditor,
12
+ } from 'lexical'
13
+ import { $findMatchingParent } from '@lexical/utils'
14
+ import { $isLinkNode, $toggleLink } from '@lexical/link'
15
+ import type { DialogMsg, DialogState } from '@llui/components/dialog'
16
+ import { linkDialog } from '../surfaces/link-dialog.js'
17
+ import { sanitizeLinkUrl } from '../security.js'
18
+ import { definePluginUI } from './ui.js'
19
+ import type { CommandItem, MarkdownPlugin } from './types.js'
20
+
21
+ const PLUGIN = 'link'
22
+
23
+ interface LinkState {
24
+ dialog: DialogState
25
+ url: string
26
+ }
27
+
28
+ type LinkMsg =
29
+ | { type: 'open' }
30
+ | { type: 'show'; url: string }
31
+ | { type: 'setUrl'; url: string }
32
+ | { type: 'submit' }
33
+ | { type: 'dialog'; msg: DialogMsg }
34
+
35
+ type LinkEffect = { type: 'begin' } | { type: 'commit'; url: string }
36
+
37
+ /** Read the URL of the link wrapping the current selection (empty if none). */
38
+ function readLinkUrl(editor: LexicalEditor): string {
39
+ return editor.getEditorState().read(() => {
40
+ const selection = $getSelection()
41
+ if (!$isRangeSelection(selection)) return ''
42
+ const link = $findMatchingParent(selection.anchor.getNode(), (node) => $isLinkNode(node))
43
+ return $isLinkNode(link) ? link.getURL() : ''
44
+ })
45
+ }
46
+
47
+ function dialogOpen(msg: DialogMsg, current: boolean): boolean {
48
+ switch (msg.type) {
49
+ case 'open':
50
+ return true
51
+ case 'close':
52
+ return false
53
+ case 'toggle':
54
+ return !current
55
+ case 'setOpen':
56
+ return msg.open
57
+ case 'animationEnd':
58
+ case 'transitionEnd':
59
+ return current
60
+ }
61
+ }
62
+
63
+ export interface LinkPluginOptions {
64
+ /** Default URL pre-filled when there's no existing link (default ''). */
65
+ defaultUrl?: string
66
+ }
67
+
68
+ export function linkPlugin(opts: LinkPluginOptions = {}): MarkdownPlugin {
69
+ // Selection saved when the dialog opens (the modal steals focus/selection),
70
+ // restored on commit. Keyed by the per-mount editor so two mounts of the same
71
+ // plugin instance never cross-wire their saved selection.
72
+ const savedSelection = new WeakMap<LexicalEditor, BaseSelection | null>()
73
+
74
+ const item: CommandItem = {
75
+ id: 'link',
76
+ label: 'Link',
77
+ icon: 'link',
78
+ group: 'inline',
79
+ keywords: ['url', 'href'],
80
+ run: (_editor, ctx) => ctx.send({ type: 'plugin', name: PLUGIN, msg: { type: 'open' } }),
81
+ isActive: (f) => f.link,
82
+ surfaces: ['toolbar', 'floating', 'context'],
83
+ }
84
+
85
+ return {
86
+ name: PLUGIN,
87
+ items: [item],
88
+ ui: definePluginUI<LinkState, LinkMsg, LinkEffect>({
89
+ init: () => ({ dialog: { open: false }, url: opts.defaultUrl ?? '' }),
90
+ update: (state, msg) => {
91
+ switch (msg.type) {
92
+ case 'open':
93
+ return [state, [{ type: 'begin' }]]
94
+ case 'show':
95
+ return { dialog: { open: true }, url: msg.url }
96
+ case 'setUrl':
97
+ return { ...state, url: msg.url }
98
+ case 'submit':
99
+ return [{ ...state, dialog: { open: false } }, [{ type: 'commit', url: state.url }]]
100
+ case 'dialog': {
101
+ const open = dialogOpen(msg.msg, state.dialog.open)
102
+ return open === state.dialog.open ? state : { ...state, dialog: { open } }
103
+ }
104
+ }
105
+ },
106
+ view: ({ state, send }) => [
107
+ linkDialog({
108
+ dialog: state.at('dialog'),
109
+ url: state.at('url'),
110
+ onInput: (url) => send({ type: 'setUrl', url }),
111
+ onSubmit: () => send({ type: 'submit' }),
112
+ onDialog: (msg) => send({ type: 'dialog', msg }),
113
+ }),
114
+ ],
115
+ onEffect: (effect, ctx) => {
116
+ const editor = ctx.editor()
117
+ if (!editor) return
118
+ if (effect.type === 'begin') {
119
+ savedSelection.set(
120
+ editor,
121
+ editor.getEditorState().read(() => {
122
+ const selection = $getSelection()
123
+ return selection ? selection.clone() : null
124
+ }),
125
+ )
126
+ ctx.send({ type: 'show', url: readLinkUrl(editor) })
127
+ return
128
+ }
129
+ // Enforce the URL-scheme allowlist at commit: a `javascript:`/`data:`
130
+ // href sanitizes to null → no link is created (unlink). The global
131
+ // LinkNode transform is the backstop, but blocking here avoids ever
132
+ // materializing the unsafe link.
133
+ const safe = sanitizeLinkUrl(effect.url.trim())
134
+ const saved = savedSelection.get(editor) ?? null
135
+ editor.update(() => {
136
+ if (saved) $setSelection(saved.clone())
137
+ $toggleLink(safe === null || safe === '' ? null : safe)
138
+ })
139
+ savedSelection.delete(editor)
140
+ },
141
+ }),
142
+ }
143
+ }
@@ -0,0 +1,102 @@
1
+ // Math plugin — a block math node ($$…$$) rendered via the decorator bridge. The
2
+ // TeX source is editable inline (persists on blur); an optional `render` callback
3
+ // (e.g. KaTeX) turns it into a typeset preview. Round-trips to `$$tex$$`.
4
+
5
+ import { type ElementNode, type LexicalNode } from 'lexical'
6
+ import { $insertNodeToNearestRoot } from '@lexical/utils'
7
+ import type { ElementTransformer } from '@lexical/markdown'
8
+ import {
9
+ $createLLuiDecoratorNode,
10
+ $isLLuiDecoratorNode,
11
+ LLuiDecoratorNode,
12
+ decoratorBridge,
13
+ } from '@llui/lexical'
14
+ import { div, span, text, type Mountable, type Signal } from '@llui/dom'
15
+ import type { MarkdownPlugin } from './types.js'
16
+ import { renderedPreview, type PreviewRender } from './_preview.js'
17
+
18
+ const BRIDGE_TYPE = 'math'
19
+
20
+ interface MathData {
21
+ tex: string
22
+ }
23
+
24
+ function isMathData(value: unknown): value is MathData {
25
+ return typeof value === 'object' && value !== null && typeof (value as MathData).tex === 'string'
26
+ }
27
+
28
+ const stop = (e: Event): void => e.stopPropagation()
29
+
30
+ export interface MathPluginOptions {
31
+ /** Typeset TeX to an HTML string (e.g. via KaTeX). When omitted, the raw TeX is
32
+ * shown in a styled box. */
33
+ /** Render the TeX source to a preview. Return a DOM `Node` (mounted
34
+ * directly, no sanitization) or a **trusted HTML string** (injected as-is
35
+ * — sanitize it yourself, e.g. via DOMPurify, since it carries document
36
+ * content). See `renderedPreview`. */
37
+ render?: PreviewRender
38
+ }
39
+
40
+ export function mathPlugin(opts: MathPluginOptions = {}): MarkdownPlugin {
41
+ const bridge = decoratorBridge<MathData>(BRIDGE_TYPE, (data, api) => {
42
+ const children: Mountable[] = [
43
+ span(
44
+ {
45
+ 'data-part': 'source',
46
+ contenteditable: 'true',
47
+ role: 'textbox',
48
+ 'aria-label': 'TeX source',
49
+ onKeyDown: stop,
50
+ onBeforeInput: stop,
51
+ onPaste: stop,
52
+ onBlur: (e: FocusEvent) => {
53
+ const tex = (e.target as HTMLElement).textContent ?? ''
54
+ if (tex !== data.peek().tex) api.update({ tex })
55
+ },
56
+ },
57
+ [text(data.at('tex') as Signal<string>)],
58
+ ),
59
+ ]
60
+ if (opts.render) {
61
+ children.push(renderedPreview(data.at('tex') as Signal<string>, opts.render, 'span'))
62
+ }
63
+ return [
64
+ div({ 'data-scope': 'md-math', 'data-part': 'root', contenteditable: 'false' }, children),
65
+ ]
66
+ })
67
+
68
+ const transformer: ElementTransformer = {
69
+ dependencies: [LLuiDecoratorNode],
70
+ export: (node: LexicalNode): string | null => {
71
+ if (!$isLLuiDecoratorNode(node) || node.getBridgeType() !== BRIDGE_TYPE) return null
72
+ const data = node.getData()
73
+ return isMathData(data) ? `$$${data.tex}$$` : null
74
+ },
75
+ regExp: /^\$\$(.+)\$\$$/,
76
+ replace: (parentNode: ElementNode, _children, match): void => {
77
+ parentNode.replace($createLLuiDecoratorNode(BRIDGE_TYPE, { tex: match[1] ?? '' }))
78
+ },
79
+ type: 'element',
80
+ }
81
+
82
+ return {
83
+ name: 'math',
84
+ nodes: [LLuiDecoratorNode],
85
+ decorators: [bridge],
86
+ transformers: [transformer],
87
+ items: [
88
+ {
89
+ id: 'math',
90
+ label: 'Math block',
91
+ icon: 'math',
92
+ group: 'insert',
93
+ keywords: ['latex', 'tex', 'equation', 'formula'],
94
+ run: (editor) =>
95
+ editor.update(() =>
96
+ $insertNodeToNearestRoot($createLLuiDecoratorNode(BRIDGE_TYPE, { tex: 'e = mc^2' })),
97
+ ),
98
+ surfaces: ['slash', 'context'],
99
+ },
100
+ ],
101
+ }
102
+ }