@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.
- package/README.md +44 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +34 -8
- package/dist/editor.js.map +1 -1
- package/dist/effects.d.ts +5 -3
- package/dist/effects.d.ts.map +1 -1
- package/dist/effects.js +7 -5
- package/dist/effects.js.map +1 -1
- package/dist/format.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/paste.d.ts.map +1 -1
- package/dist/paste.js +5 -0
- package/dist/paste.js.map +1 -1
- package/dist/plugins/_preview.js.map +1 -1
- package/dist/plugins/callout.d.ts.map +1 -1
- package/dist/plugins/callout.js +37 -45
- package/dist/plugins/callout.js.map +1 -1
- package/dist/plugins/context-menu.js.map +1 -1
- package/dist/plugins/core.js.map +1 -1
- package/dist/plugins/emoji.js.map +1 -1
- package/dist/plugins/floating-toolbar.js.map +1 -1
- package/dist/plugins/hr.d.ts.map +1 -1
- package/dist/plugins/hr.js +4 -7
- package/dist/plugins/hr.js.map +1 -1
- package/dist/plugins/image.d.ts.map +1 -1
- package/dist/plugins/image.js +28 -20
- package/dist/plugins/image.js.map +1 -1
- package/dist/plugins/inline.js.map +1 -1
- package/dist/plugins/link.d.ts.map +1 -1
- package/dist/plugins/link.js +16 -9
- package/dist/plugins/link.js.map +1 -1
- package/dist/plugins/math.d.ts.map +1 -1
- package/dist/plugins/math.js +25 -34
- package/dist/plugins/math.js.map +1 -1
- package/dist/plugins/mention.js.map +1 -1
- package/dist/plugins/mermaid.d.ts.map +1 -1
- package/dist/plugins/mermaid.js +25 -34
- package/dist/plugins/mermaid.js.map +1 -1
- package/dist/plugins/overlay.js.map +1 -1
- package/dist/plugins/single-block.js.map +1 -1
- package/dist/plugins/slash.js.map +1 -1
- package/dist/plugins/table.d.ts.map +1 -1
- package/dist/plugins/table.js +7 -4
- package/dist/plugins/table.js.map +1 -1
- package/dist/plugins/types.js.map +1 -1
- package/dist/plugins/ui.js.map +1 -1
- package/dist/security.d.ts +26 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +87 -0
- package/dist/security.js.map +1 -0
- package/dist/state.js.map +1 -1
- package/dist/surfaces/link-dialog.js.map +1 -1
- package/dist/surfaces/toolbar.js.map +1 -1
- package/dist/theme.js.map +1 -1
- package/dist/transformers/gfm.js.map +1 -1
- package/dist/transformers/registry.js.map +1 -1
- package/package.json +23 -17
- package/src/editor.ts +339 -0
- package/src/effects.ts +53 -0
- package/src/format.ts +57 -0
- package/src/index.ts +93 -0
- package/src/paste.ts +96 -0
- package/src/plugins/_preview.ts +51 -0
- package/src/plugins/callout.ts +180 -0
- package/src/plugins/context-menu.ts +118 -0
- package/src/plugins/core.ts +179 -0
- package/src/plugins/emoji.ts +59 -0
- package/src/plugins/floating-toolbar.ts +180 -0
- package/src/plugins/hr.ts +56 -0
- package/src/plugins/image.ts +240 -0
- package/src/plugins/inline.ts +76 -0
- package/src/plugins/link.ts +143 -0
- package/src/plugins/math.ts +102 -0
- package/src/plugins/mention.ts +224 -0
- package/src/plugins/mermaid.ts +115 -0
- package/src/plugins/overlay.ts +138 -0
- package/src/plugins/single-block.ts +193 -0
- package/src/plugins/slash.ts +224 -0
- package/src/plugins/table.ts +284 -0
- package/src/plugins/types.ts +50 -0
- package/src/plugins/ui.ts +79 -0
- package/src/security.ts +89 -0
- package/src/state.ts +220 -0
- package/src/styles/editor.css +554 -0
- package/src/surfaces/link-dialog.ts +67 -0
- package/src/surfaces/toolbar.ts +246 -0
- package/src/theme.ts +42 -0
- package/src/transformers/gfm.ts +69 -0
- package/src/transformers/registry.ts +47 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Toolbar surface. `connectToolbar` returns a parts bag (the @llui/components
|
|
2
|
+
// idiom) driven reactively by the format signal; `toolbar` is a ready-made
|
|
3
|
+
// grouped toolbar Mountable for consumers who don't want to hand-render buttons.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
button,
|
|
7
|
+
div,
|
|
8
|
+
option,
|
|
9
|
+
select,
|
|
10
|
+
span,
|
|
11
|
+
text,
|
|
12
|
+
tagSend,
|
|
13
|
+
unsafeHtml,
|
|
14
|
+
type Mountable,
|
|
15
|
+
type Send,
|
|
16
|
+
type Signal,
|
|
17
|
+
} from '@llui/dom'
|
|
18
|
+
import type { CommandItem } from '../plugins/types.js'
|
|
19
|
+
import type { CollabStatus, EditorMsg, FormatState } from '../state.js'
|
|
20
|
+
|
|
21
|
+
export interface ToolbarItemParts {
|
|
22
|
+
type: 'button'
|
|
23
|
+
'data-scope': 'md-toolbar'
|
|
24
|
+
'data-part': 'item'
|
|
25
|
+
'data-id': string
|
|
26
|
+
'aria-label': string
|
|
27
|
+
title: string
|
|
28
|
+
'aria-pressed': Signal<'true' | 'false'>
|
|
29
|
+
'aria-disabled': Signal<'true' | undefined>
|
|
30
|
+
disabled: Signal<boolean>
|
|
31
|
+
'data-active': Signal<'' | undefined>
|
|
32
|
+
onClick: (e: MouseEvent) => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ToolbarParts {
|
|
36
|
+
root: {
|
|
37
|
+
role: 'toolbar'
|
|
38
|
+
'aria-label': string
|
|
39
|
+
'data-scope': 'md-toolbar'
|
|
40
|
+
'data-part': 'root'
|
|
41
|
+
}
|
|
42
|
+
item: (id: string) => ToolbarItemParts
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Build reactive toolbar parts from the format signal. Spread `item(id)` onto a
|
|
46
|
+
* `<button>`; `aria-pressed` / `data-active` / `disabled` track the format. */
|
|
47
|
+
export function connectToolbar(
|
|
48
|
+
format: Signal<FormatState>,
|
|
49
|
+
send: Send<EditorMsg>,
|
|
50
|
+
items: readonly CommandItem[],
|
|
51
|
+
): ToolbarParts {
|
|
52
|
+
const byId = new Map(items.map((i) => [i.id, i]))
|
|
53
|
+
return {
|
|
54
|
+
root: {
|
|
55
|
+
role: 'toolbar',
|
|
56
|
+
'aria-label': 'Formatting',
|
|
57
|
+
'data-scope': 'md-toolbar',
|
|
58
|
+
'data-part': 'root',
|
|
59
|
+
},
|
|
60
|
+
item: (id) => {
|
|
61
|
+
const item = byId.get(id)
|
|
62
|
+
const label = item?.label ?? id
|
|
63
|
+
return {
|
|
64
|
+
type: 'button',
|
|
65
|
+
'data-scope': 'md-toolbar',
|
|
66
|
+
'data-part': 'item',
|
|
67
|
+
'data-id': id,
|
|
68
|
+
'aria-label': label,
|
|
69
|
+
title: label,
|
|
70
|
+
'aria-pressed': format.map((f) => (item?.isActive?.(f) ? 'true' : 'false')),
|
|
71
|
+
'aria-disabled': format.map((f) => (item?.isDisabled?.(f) ? 'true' : undefined)),
|
|
72
|
+
disabled: format.map((f) => item?.isDisabled?.(f) ?? false),
|
|
73
|
+
'data-active': format.map((f) => (item?.isActive?.(f) ? '' : undefined)),
|
|
74
|
+
onClick: tagSend(send, ['runCommand'], () => send({ type: 'runCommand', id })),
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Inline SVG icon (monochrome, inherits `currentColor`) used where a glyph
|
|
81
|
+
* reads poorly. A glyph string starting with `<svg` is rendered as markup. */
|
|
82
|
+
const LINK_ICON =
|
|
83
|
+
'<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.5 13.5a3.5 3.5 0 0 0 5 0l3-3a3.5 3.5 0 1 0-5-5l-1 1"/><path d="M14.5 10.5a3.5 3.5 0 0 0-5 0l-3 3a3.5 3.5 0 1 0 5 5l1-1"/></svg>'
|
|
84
|
+
|
|
85
|
+
const IMAGE_ICON =
|
|
86
|
+
'<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="9.5" r="1.6"/><path d="m4 18 5-5 4 4 3-3 4 4"/></svg>'
|
|
87
|
+
|
|
88
|
+
/** Compact glyphs so the default toolbar reads as a real toolbar without icon
|
|
89
|
+
* assets. SVG strings render as icons; everything else as text. Override via
|
|
90
|
+
* `ToolbarOptions.glyphs`. */
|
|
91
|
+
export const DEFAULT_GLYPHS: Readonly<Record<string, string>> = {
|
|
92
|
+
bold: 'B',
|
|
93
|
+
italic: 'I',
|
|
94
|
+
strikethrough: 'S',
|
|
95
|
+
code: '</>',
|
|
96
|
+
link: LINK_ICON,
|
|
97
|
+
image: IMAGE_ICON,
|
|
98
|
+
hr: '—',
|
|
99
|
+
table: '▦',
|
|
100
|
+
paragraph: '¶',
|
|
101
|
+
h1: 'H1',
|
|
102
|
+
h2: 'H2',
|
|
103
|
+
h3: 'H3',
|
|
104
|
+
quote: '❝',
|
|
105
|
+
codeBlock: '{ }',
|
|
106
|
+
bulletList: '•',
|
|
107
|
+
numberList: '1.',
|
|
108
|
+
checkList: '☑',
|
|
109
|
+
undo: '↺',
|
|
110
|
+
redo: '↻',
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const GROUP_ORDER = ['inline', 'block', 'list', 'insert', 'history']
|
|
114
|
+
|
|
115
|
+
function groupItems(items: readonly CommandItem[]): string[][] {
|
|
116
|
+
const groups = new Map<string, string[]>()
|
|
117
|
+
for (const item of items) {
|
|
118
|
+
const key = item.group ?? 'other'
|
|
119
|
+
const list = groups.get(key) ?? []
|
|
120
|
+
list.push(item.id)
|
|
121
|
+
groups.set(key, list)
|
|
122
|
+
}
|
|
123
|
+
const ordered: string[][] = []
|
|
124
|
+
for (const key of GROUP_ORDER) {
|
|
125
|
+
const list = groups.get(key)
|
|
126
|
+
if (list) ordered.push(list)
|
|
127
|
+
}
|
|
128
|
+
for (const [key, list] of groups) {
|
|
129
|
+
if (!GROUP_ORDER.includes(key)) ordered.push(list)
|
|
130
|
+
}
|
|
131
|
+
return ordered
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface ToolbarOptions {
|
|
135
|
+
format: Signal<FormatState>
|
|
136
|
+
send: Send<EditorMsg>
|
|
137
|
+
items: readonly CommandItem[]
|
|
138
|
+
/** Explicit grouped layout of ids; defaults to grouping by `item.group`. */
|
|
139
|
+
groups?: readonly (readonly string[])[]
|
|
140
|
+
/** Glyph overrides (id → text/emoji). Merged over {@link DEFAULT_GLYPHS}. */
|
|
141
|
+
glyphs?: Readonly<Record<string, string>>
|
|
142
|
+
/** Render the `block` group as a `<select>` dropdown instead of buttons
|
|
143
|
+
* (default true). */
|
|
144
|
+
blockSelect?: boolean
|
|
145
|
+
/** Collaborative-session status. When supplied AND `enabled`, the toolbar
|
|
146
|
+
* appends a presence indicator (connection dot + live peer count). */
|
|
147
|
+
collab?: Signal<CollabStatus>
|
|
148
|
+
'aria-label'?: string
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Connection/presence indicator for a collaborative session. */
|
|
152
|
+
function presenceIndicator(collab: Signal<CollabStatus>): Mountable {
|
|
153
|
+
const statusOf = (c: CollabStatus): string =>
|
|
154
|
+
c.connected ? (c.synced ? 'synced' : 'connecting') : 'offline'
|
|
155
|
+
return div({ 'data-scope': 'md-toolbar', 'data-part': 'presence' }, [
|
|
156
|
+
span({
|
|
157
|
+
'data-part': 'presence-status',
|
|
158
|
+
'data-status': collab.map(statusOf),
|
|
159
|
+
'aria-hidden': 'true',
|
|
160
|
+
title: collab.map(statusOf),
|
|
161
|
+
}),
|
|
162
|
+
span(
|
|
163
|
+
{
|
|
164
|
+
'data-part': 'presence-peers',
|
|
165
|
+
role: 'status',
|
|
166
|
+
'aria-live': 'polite',
|
|
167
|
+
},
|
|
168
|
+
[text(collab.map((c) => (c.peers === 0 ? 'Only you' : `${c.peers + 1} editing`)))],
|
|
169
|
+
),
|
|
170
|
+
])
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Render the `block` group as a native `<select>` of block types. */
|
|
174
|
+
function blockTypeSelect(
|
|
175
|
+
format: Signal<FormatState>,
|
|
176
|
+
send: Send<EditorMsg>,
|
|
177
|
+
blockItems: readonly CommandItem[],
|
|
178
|
+
): Mountable {
|
|
179
|
+
return select(
|
|
180
|
+
{
|
|
181
|
+
'data-scope': 'md-toolbar',
|
|
182
|
+
'data-part': 'block-select',
|
|
183
|
+
'aria-label': 'Block type',
|
|
184
|
+
value: format.map((f) => blockItems.find((i) => i.isActive?.(f))?.id ?? ''),
|
|
185
|
+
onChange: (e: Event) => {
|
|
186
|
+
const id = (e.currentTarget as HTMLSelectElement).value
|
|
187
|
+
if (id) send({ type: 'runCommand', id })
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
[
|
|
191
|
+
option({ value: '', hidden: true }, [text('—')]),
|
|
192
|
+
...blockItems.map((i) => option({ value: i.id }, [text(i.label)])),
|
|
193
|
+
],
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A ready-made grouped toolbar. Items not surfaced to `'toolbar'` are dropped. */
|
|
198
|
+
export function toolbar(opts: ToolbarOptions): Mountable {
|
|
199
|
+
const surfaceItems = opts.items.filter((i) => i.surfaces?.includes('toolbar') ?? true)
|
|
200
|
+
const byId = new Map(surfaceItems.map((i) => [i.id, i]))
|
|
201
|
+
const parts = connectToolbar(opts.format, opts.send, surfaceItems)
|
|
202
|
+
const glyphs = { ...DEFAULT_GLYPHS, ...(opts.glyphs ?? {}) }
|
|
203
|
+
|
|
204
|
+
const useBlockSelect = opts.blockSelect !== false && !opts.groups
|
|
205
|
+
const blockItems = useBlockSelect ? surfaceItems.filter((i) => i.group === 'block') : []
|
|
206
|
+
const buttonItems = useBlockSelect
|
|
207
|
+
? surfaceItems.filter((i) => i.group !== 'block')
|
|
208
|
+
: surfaceItems
|
|
209
|
+
const groups = opts.groups ?? groupItems(buttonItems)
|
|
210
|
+
|
|
211
|
+
const children: Mountable[] = []
|
|
212
|
+
if (blockItems.length > 0) {
|
|
213
|
+
children.push(
|
|
214
|
+
div({ 'data-scope': 'md-toolbar', 'data-part': 'group' }, [
|
|
215
|
+
blockTypeSelect(opts.format, opts.send, blockItems),
|
|
216
|
+
]),
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
for (const ids of groups) {
|
|
220
|
+
const present = ids.filter((id) => byId.has(id))
|
|
221
|
+
if (present.length === 0) continue
|
|
222
|
+
children.push(
|
|
223
|
+
div(
|
|
224
|
+
{ 'data-scope': 'md-toolbar', 'data-part': 'group' },
|
|
225
|
+
present.map((id) => {
|
|
226
|
+
const item = byId.get(id)!
|
|
227
|
+
const glyph = glyphs[id] ?? item.label
|
|
228
|
+
const glyphNode = glyph.trimStart().startsWith('<svg') ? unsafeHtml(glyph) : text(glyph)
|
|
229
|
+
return button({ ...parts.item(id) }, [
|
|
230
|
+
span({ 'data-part': 'glyph', 'aria-hidden': 'true' }, [glyphNode]),
|
|
231
|
+
])
|
|
232
|
+
}),
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Presence indicator (constant `enabled` after init, so a one-shot peek is safe).
|
|
238
|
+
if (opts.collab && opts.collab.peek().enabled) {
|
|
239
|
+
children.push(presenceIndicator(opts.collab))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return div(
|
|
243
|
+
{ ...parts.root, ...(opts['aria-label'] ? { 'aria-label': opts['aria-label'] } : {}) },
|
|
244
|
+
children,
|
|
245
|
+
)
|
|
246
|
+
}
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Default Lexical theme for the markdown editor.
|
|
2
|
+
//
|
|
3
|
+
// Lexical styles most inline formats through semantic tags that carry
|
|
4
|
+
// browser-default styling — bold→<strong>, italic→<em>, code→<code>. But
|
|
5
|
+
// `strikethrough` (and `underline`) render as a bare <span> whose ONLY styling
|
|
6
|
+
// hook is a `theme.text.<format>` class. With no theme the format is applied to
|
|
7
|
+
// the model but is visually invisible. This default theme supplies those class
|
|
8
|
+
// names; the bundled `styles/editor.css` gives them their `text-decoration`.
|
|
9
|
+
//
|
|
10
|
+
// Consumers can override any entry via `markdownEditor({ theme })` — the user's
|
|
11
|
+
// theme is merged over this default (see `mergeTheme`).
|
|
12
|
+
|
|
13
|
+
import type { EditorThemeClasses } from 'lexical'
|
|
14
|
+
|
|
15
|
+
export const STRIKETHROUGH_CLASS = 'md-strikethrough'
|
|
16
|
+
export const UNDERLINE_CLASS = 'md-underline'
|
|
17
|
+
export const UNDERLINE_STRIKETHROUGH_CLASS = 'md-underline-strikethrough'
|
|
18
|
+
|
|
19
|
+
/** The class hooks Lexical needs for text-decoration formats it renders as a
|
|
20
|
+
* plain <span>. (`underlineStrikethrough` is Lexical's special composite key for
|
|
21
|
+
* the case where both apply — both want `text-decoration`.) */
|
|
22
|
+
export const defaultTheme: EditorThemeClasses = {
|
|
23
|
+
text: {
|
|
24
|
+
strikethrough: STRIKETHROUGH_CLASS,
|
|
25
|
+
underline: UNDERLINE_CLASS,
|
|
26
|
+
underlineStrikethrough: UNDERLINE_STRIKETHROUGH_CLASS,
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Merge a consumer theme over the default. `text` is merged per-key so a
|
|
31
|
+
* consumer overriding (say) `strikethrough` keeps the default `underline`.
|
|
32
|
+
*
|
|
33
|
+
* Always returns a FRESH theme (never the shared `defaultTheme` singleton):
|
|
34
|
+
* Lexical caches resolved class arrays by MUTATING the `text` object it is
|
|
35
|
+
* handed (`text.__lexicalClassNameCache`). Handing it a fresh copy keeps the
|
|
36
|
+
* exported singleton clean, and stripping any inherited cache prevents a stale
|
|
37
|
+
* entry from a previously-used theme object shadowing an overridden class. */
|
|
38
|
+
export function mergeTheme(theme?: EditorThemeClasses): EditorThemeClasses {
|
|
39
|
+
const text = { ...defaultTheme.text, ...theme?.text }
|
|
40
|
+
delete (text as { __lexicalClassNameCache?: unknown }).__lexicalClassNameCache
|
|
41
|
+
return { ...theme, text }
|
|
42
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The built-in GFM superset: node classes + the explicit transformer set that
|
|
2
|
+
// maps exactly to those nodes (no surprise nodes — HR/tables are opt-in plugins).
|
|
3
|
+
|
|
4
|
+
import type { Klass, LexicalNode } from 'lexical'
|
|
5
|
+
import { HeadingNode, QuoteNode } from '@lexical/rich-text'
|
|
6
|
+
import { ListNode, ListItemNode } from '@lexical/list'
|
|
7
|
+
import { LinkNode } from '@lexical/link'
|
|
8
|
+
// `@lexical/code-core` (not `@lexical/code`) keeps Prism out of the bundle — we
|
|
9
|
+
// never register syntax highlighting, so plain CodeNode is all we need.
|
|
10
|
+
import { CodeNode, CodeHighlightNode } from '@lexical/code-core'
|
|
11
|
+
import {
|
|
12
|
+
type Transformer,
|
|
13
|
+
HEADING,
|
|
14
|
+
QUOTE,
|
|
15
|
+
UNORDERED_LIST,
|
|
16
|
+
ORDERED_LIST,
|
|
17
|
+
CHECK_LIST,
|
|
18
|
+
CODE,
|
|
19
|
+
BOLD_ITALIC_STAR,
|
|
20
|
+
BOLD_ITALIC_UNDERSCORE,
|
|
21
|
+
BOLD_STAR,
|
|
22
|
+
BOLD_UNDERSCORE,
|
|
23
|
+
ITALIC_STAR,
|
|
24
|
+
ITALIC_UNDERSCORE,
|
|
25
|
+
STRIKETHROUGH,
|
|
26
|
+
INLINE_CODE,
|
|
27
|
+
HIGHLIGHT,
|
|
28
|
+
LINK,
|
|
29
|
+
} from '@lexical/markdown'
|
|
30
|
+
|
|
31
|
+
/** Node classes required to render the GFM superset. */
|
|
32
|
+
export const GFM_NODES: ReadonlyArray<Klass<LexicalNode>> = [
|
|
33
|
+
HeadingNode,
|
|
34
|
+
QuoteNode,
|
|
35
|
+
ListNode,
|
|
36
|
+
ListItemNode,
|
|
37
|
+
CodeNode,
|
|
38
|
+
CodeHighlightNode,
|
|
39
|
+
LinkNode,
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
/** Inline text-format transformers (no block nodes, no node registration). These
|
|
43
|
+
* are the only transformers a single-block / inline-only editor needs; `LINK` is
|
|
44
|
+
* kept separate since it requires `LinkNode` to be registered. */
|
|
45
|
+
export const INLINE_TEXT_TRANSFORMERS: readonly Transformer[] = [
|
|
46
|
+
BOLD_ITALIC_STAR,
|
|
47
|
+
BOLD_ITALIC_UNDERSCORE,
|
|
48
|
+
BOLD_STAR,
|
|
49
|
+
BOLD_UNDERSCORE,
|
|
50
|
+
ITALIC_STAR,
|
|
51
|
+
ITALIC_UNDERSCORE,
|
|
52
|
+
STRIKETHROUGH,
|
|
53
|
+
INLINE_CODE,
|
|
54
|
+
HIGHLIGHT,
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
/** Markdown ↔ node transformers for the GFM superset. */
|
|
58
|
+
export const GFM_TRANSFORMERS: readonly Transformer[] = [
|
|
59
|
+
HEADING,
|
|
60
|
+
QUOTE,
|
|
61
|
+
// CHECK_LIST must precede the plain list transformers: `- [ ]`/`- [x]` also
|
|
62
|
+
// match `- `, so UNORDERED_LIST would otherwise swallow it as bullet text.
|
|
63
|
+
CHECK_LIST,
|
|
64
|
+
UNORDERED_LIST,
|
|
65
|
+
ORDERED_LIST,
|
|
66
|
+
CODE,
|
|
67
|
+
...INLINE_TEXT_TRANSFORMERS,
|
|
68
|
+
LINK,
|
|
69
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The transformer registry: flatten plugin transformer contributions into a
|
|
2
|
+
// single, correctly-ordered Lexical TRANSFORMERS array. Ordering is the single
|
|
3
|
+
// place markdown round-trip fidelity is governed.
|
|
4
|
+
|
|
5
|
+
import type { Transformer } from '@lexical/markdown'
|
|
6
|
+
import type { MarkdownPlugin } from '../plugins/types.js'
|
|
7
|
+
|
|
8
|
+
// Lexical resolves transformers in array order. Element/multiline constructs
|
|
9
|
+
// must be tried before inline text matching, and longer text-format patterns
|
|
10
|
+
// (e.g. `***`) before shorter (`**`, `*`) so they aren't shadowed.
|
|
11
|
+
const TYPE_RANK: Record<string, number> = {
|
|
12
|
+
'multiline-element': 0,
|
|
13
|
+
element: 1,
|
|
14
|
+
'text-format': 2,
|
|
15
|
+
'text-match': 3,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Stable-sort transformers into the order Lexical expects. */
|
|
19
|
+
export function orderTransformers(transformers: readonly Transformer[]): Transformer[] {
|
|
20
|
+
return [...transformers].sort((a, b) => {
|
|
21
|
+
const ra = TYPE_RANK[a.type] ?? 9
|
|
22
|
+
const rb = TYPE_RANK[b.type] ?? 9
|
|
23
|
+
if (ra !== rb) return ra - rb
|
|
24
|
+
if (a.type === 'text-format' && b.type === 'text-format') {
|
|
25
|
+
// Longer trigger (`***`) before shorter (`**`, `*`).
|
|
26
|
+
return b.tag.length - a.tag.length
|
|
27
|
+
}
|
|
28
|
+
return 0
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Collect every plugin's transformers (de-duplicated by reference) and order
|
|
33
|
+
* them. The result is passed to `$convertTo/FromMarkdownString` and
|
|
34
|
+
* `registerMarkdownShortcuts`. */
|
|
35
|
+
export function buildTransformers(plugins: readonly MarkdownPlugin[]): Transformer[] {
|
|
36
|
+
const seen = new Set<Transformer>()
|
|
37
|
+
const collected: Transformer[] = []
|
|
38
|
+
for (const plugin of plugins) {
|
|
39
|
+
for (const transformer of plugin.transformers ?? []) {
|
|
40
|
+
if (!seen.has(transformer)) {
|
|
41
|
+
seen.add(transformer)
|
|
42
|
+
collected.push(transformer)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return orderTransformers(collected)
|
|
47
|
+
}
|