@llui/markdown-editor 0.2.11 → 0.2.13
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/package.json +37 -33
- package/src/editor.ts +303 -0
- package/src/effects.ts +51 -0
- package/src/format.ts +57 -0
- package/src/index.ts +93 -0
- package/src/paste.ts +91 -0
- package/src/plugins/_preview.ts +51 -0
- package/src/plugins/callout.ts +195 -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 +66 -0
- package/src/plugins/image.ts +237 -0
- package/src/plugins/inline.ts +76 -0
- package/src/plugins/link.ts +133 -0
- package/src/plugins/math.ts +116 -0
- package/src/plugins/mention.ts +224 -0
- package/src/plugins/mermaid.ts +134 -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 +281 -0
- package/src/plugins/types.ts +50 -0
- package/src/plugins/ui.ts +79 -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
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @llui/markdown-editor
|
|
2
|
+
|
|
3
|
+
WYSIWYG Markdown editor for [LLui](https://github.com/fponticelli/llui) — hides Markdown behind a rich, pluggable editing widget built on Lexical.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm add @llui/markdown-editor @llui/lexical @llui/components lexical
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { mountApp } from '@llui/dom'
|
|
13
|
+
import { markdownEditor } from '@llui/markdown-editor'
|
|
14
|
+
import '@llui/markdown-editor/styles/editor.css'
|
|
15
|
+
|
|
16
|
+
// `markdownEditor()` returns a component definition; mount it (or compose it).
|
|
17
|
+
mountApp(
|
|
18
|
+
document.getElementById('editor')!,
|
|
19
|
+
markdownEditor({
|
|
20
|
+
defaultValue: '# Hello\n\nStart typing…',
|
|
21
|
+
toolbar: true,
|
|
22
|
+
onChange: (markdown) => console.log(markdown),
|
|
23
|
+
}),
|
|
24
|
+
)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## What it provides
|
|
28
|
+
|
|
29
|
+
- **`markdownEditor()`** — the editor component, built on the [`@llui/lexical`](https://www.npmjs.com/package/@llui/lexical) seam.
|
|
30
|
+
- **Transformer registry** — GFM and callout plugins (`./plugins/core`, `./plugins/callout`) that map Markdown constructs to Lexical nodes.
|
|
31
|
+
- **Toolbar surface** — an optional formatting toolbar (`./surfaces/toolbar`).
|
|
32
|
+
- **`collab` seam** — opt-in collaborative editing via [`@llui/lexical-collab`](https://www.npmjs.com/package/@llui/lexical-collab).
|
|
33
|
+
|
|
34
|
+
## Entry points
|
|
35
|
+
|
|
36
|
+
| Import | Purpose |
|
|
37
|
+
| ----------------------------------------- | ------------------------------ |
|
|
38
|
+
| `@llui/markdown-editor` | `markdownEditor()` component |
|
|
39
|
+
| `@llui/markdown-editor/plugins/core` | Core GFM transformers |
|
|
40
|
+
| `@llui/markdown-editor/plugins/callout` | Callout/admonition transformer |
|
|
41
|
+
| `@llui/markdown-editor/surfaces/toolbar` | Toolbar surface |
|
|
42
|
+
| `@llui/markdown-editor/styles/editor.css` | Editor styles |
|
|
43
|
+
|
|
44
|
+
Peers on `@llui/dom`, `@llui/lexical`, `@llui/components`, `lexical`, and the relevant `@lexical/*` packages (`^0.46`).
|
package/package.json
CHANGED
|
@@ -1,58 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llui/markdown-editor",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": {
|
|
9
|
-
"
|
|
10
|
-
"
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
11
|
},
|
|
12
12
|
"./plugins/core": {
|
|
13
|
-
"
|
|
14
|
-
"
|
|
13
|
+
"types": "./dist/plugins/core.d.ts",
|
|
14
|
+
"import": "./dist/plugins/core.js"
|
|
15
15
|
},
|
|
16
16
|
"./plugins/callout": {
|
|
17
|
-
"
|
|
18
|
-
"
|
|
17
|
+
"types": "./dist/plugins/callout.d.ts",
|
|
18
|
+
"import": "./dist/plugins/callout.js"
|
|
19
19
|
},
|
|
20
20
|
"./surfaces/toolbar": {
|
|
21
|
-
"
|
|
22
|
-
"
|
|
21
|
+
"types": "./dist/surfaces/toolbar.d.ts",
|
|
22
|
+
"import": "./dist/surfaces/toolbar.js"
|
|
23
23
|
},
|
|
24
24
|
"./styles/editor.css": "./dist/styles/editor.css"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
27
|
"@llui/dom": "^0.11.5",
|
|
28
|
-
"@llui/lexical": "^0.2.
|
|
28
|
+
"@llui/lexical": "^0.2.8",
|
|
29
29
|
"@llui/components": "^0.12.2",
|
|
30
|
-
"lexical": "^0.
|
|
31
|
-
"@lexical/markdown": "^0.
|
|
32
|
-
"@lexical/rich-text": "^0.
|
|
33
|
-
"@lexical/list": "^0.
|
|
34
|
-
"@lexical/link": "^0.
|
|
35
|
-
"@lexical/table": "^0.
|
|
36
|
-
"@lexical/selection": "^0.
|
|
37
|
-
"@lexical/utils": "^0.
|
|
38
|
-
"@lexical/code-core": "^0.
|
|
30
|
+
"lexical": "^0.46.0",
|
|
31
|
+
"@lexical/markdown": "^0.46.0",
|
|
32
|
+
"@lexical/rich-text": "^0.46.0",
|
|
33
|
+
"@lexical/list": "^0.46.0",
|
|
34
|
+
"@lexical/link": "^0.46.0",
|
|
35
|
+
"@lexical/table": "^0.46.0",
|
|
36
|
+
"@lexical/selection": "^0.46.0",
|
|
37
|
+
"@lexical/utils": "^0.46.0",
|
|
38
|
+
"@lexical/code-core": "^0.46.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"lexical": "^0.
|
|
42
|
-
"@lexical/headless": "^0.
|
|
43
|
-
"@lexical/markdown": "^0.
|
|
44
|
-
"@lexical/rich-text": "^0.
|
|
45
|
-
"@lexical/list": "^0.
|
|
46
|
-
"@lexical/link": "^0.
|
|
47
|
-
"@lexical/table": "^0.
|
|
48
|
-
"@lexical/selection": "^0.
|
|
49
|
-
"@lexical/utils": "^0.
|
|
41
|
+
"lexical": "^0.46.0",
|
|
42
|
+
"@lexical/headless": "^0.46.0",
|
|
43
|
+
"@lexical/markdown": "^0.46.0",
|
|
44
|
+
"@lexical/rich-text": "^0.46.0",
|
|
45
|
+
"@lexical/list": "^0.46.0",
|
|
46
|
+
"@lexical/link": "^0.46.0",
|
|
47
|
+
"@lexical/table": "^0.46.0",
|
|
48
|
+
"@lexical/selection": "^0.46.0",
|
|
49
|
+
"@lexical/utils": "^0.46.0",
|
|
50
50
|
"typescript": "^6.0.0",
|
|
51
51
|
"vitest": "^4.1.2",
|
|
52
|
-
"@lexical/code-core": "^0.
|
|
53
|
-
"@llui/dom": "0.11.
|
|
54
|
-
"@llui/lexical": "0.2.
|
|
55
|
-
"@llui/components": "0.12.
|
|
52
|
+
"@lexical/code-core": "^0.46.0",
|
|
53
|
+
"@llui/dom": "0.11.7",
|
|
54
|
+
"@llui/lexical": "0.2.9",
|
|
55
|
+
"@llui/components": "0.12.3"
|
|
56
56
|
},
|
|
57
57
|
"sideEffects": [
|
|
58
58
|
"./dist/styles/editor.css"
|
|
@@ -78,8 +78,12 @@
|
|
|
78
78
|
},
|
|
79
79
|
"homepage": "https://github.com/fponticelli/llui/tree/main/packages/markdown-editor#readme",
|
|
80
80
|
"files": [
|
|
81
|
-
"dist"
|
|
81
|
+
"dist",
|
|
82
|
+
"src"
|
|
82
83
|
],
|
|
84
|
+
"publishConfig": {
|
|
85
|
+
"access": "public"
|
|
86
|
+
},
|
|
83
87
|
"scripts": {
|
|
84
88
|
"build": "tsc -p tsconfig.build.json && mkdir -p dist/styles && cp src/styles/editor.css dist/styles/",
|
|
85
89
|
"check": "tsc --noEmit -p tsconfig.check.json",
|
package/src/editor.ts
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// `markdownEditor(config)` — the high-level component. Lexical owns the live
|
|
2
|
+
// document; this wires the foreign seam to the markdown transformer converters,
|
|
3
|
+
// surfaces the format state for the chrome, routes command intents back to the
|
|
4
|
+
// live editor through effects, and COMPOSES plugin UI extensions (each plugin's
|
|
5
|
+
// state slice + reducer + view + effects) into the single component.
|
|
6
|
+
|
|
7
|
+
import { $getRoot, $setSelection, type EditorThemeClasses, type LexicalEditor } from 'lexical'
|
|
8
|
+
import {
|
|
9
|
+
$convertFromMarkdownString,
|
|
10
|
+
$convertToMarkdownString,
|
|
11
|
+
registerMarkdownShortcuts,
|
|
12
|
+
} from '@lexical/markdown'
|
|
13
|
+
import { component, div, type Renderable, type Signal, type SignalComponentDef } from '@llui/dom'
|
|
14
|
+
import {
|
|
15
|
+
lexicalForeign,
|
|
16
|
+
registerDecoratorBridges,
|
|
17
|
+
PROGRAMMATIC_TAG,
|
|
18
|
+
type DecoratorBridge,
|
|
19
|
+
} from '@llui/lexical'
|
|
20
|
+
import { corePlugin } from './plugins/core.js'
|
|
21
|
+
import { linkPlugin } from './plugins/link.js'
|
|
22
|
+
import { registerMarkdownPaste } from './paste.js'
|
|
23
|
+
import { toolbar as renderToolbar } from './surfaces/toolbar.js'
|
|
24
|
+
import type { CommandItem, MarkdownPlugin } from './plugins/types.js'
|
|
25
|
+
import type { PluginUI } from './plugins/ui.js'
|
|
26
|
+
import { buildTransformers } from './transformers/registry.js'
|
|
27
|
+
import { mergeTheme } from './theme.js'
|
|
28
|
+
import { computeFormatState } from './format.js'
|
|
29
|
+
import { makeOnEffect } from './effects.js'
|
|
30
|
+
import {
|
|
31
|
+
countWords,
|
|
32
|
+
init,
|
|
33
|
+
update,
|
|
34
|
+
type EditorEffect,
|
|
35
|
+
type EditorMsg,
|
|
36
|
+
type EditorOutMsg,
|
|
37
|
+
type EditorState,
|
|
38
|
+
type FormatState,
|
|
39
|
+
} from './state.js'
|
|
40
|
+
|
|
41
|
+
export interface EditorConfig {
|
|
42
|
+
/** Plugins composing the feature set; order defines transformer precedence.
|
|
43
|
+
* Defaults to `[corePlugin(), linkPlugin()]` so the minimal editor has GFM + links. */
|
|
44
|
+
plugins?: readonly MarkdownPlugin[]
|
|
45
|
+
/** Initial markdown (uncontrolled seed). */
|
|
46
|
+
defaultValue?: string
|
|
47
|
+
/** Controlled: the consumer owns this signal; the editor follows it. */
|
|
48
|
+
value?: Signal<string>
|
|
49
|
+
/** Debounced markdown-emission window (ms). Default 300. */
|
|
50
|
+
changeDebounceMs?: number
|
|
51
|
+
placeholder?: string
|
|
52
|
+
readonly?: boolean
|
|
53
|
+
/** Lexical theme class map. */
|
|
54
|
+
theme?: EditorThemeClasses
|
|
55
|
+
/** Editor namespace (instance isolation). */
|
|
56
|
+
namespace?: string
|
|
57
|
+
/** Outbound markdown (after debounce). */
|
|
58
|
+
onChange?: (markdown: string) => void
|
|
59
|
+
/** Outbound format surface (for chrome built outside this package). */
|
|
60
|
+
onFormatChange?: (format: FormatState) => void
|
|
61
|
+
/** Receives the live Lexical editor at mount (imperative access, collab hooks). */
|
|
62
|
+
onReady?: (editor: LexicalEditor) => void
|
|
63
|
+
/** Render the built-in toolbar above the editor. Default false (minimal). */
|
|
64
|
+
toolbar?: boolean
|
|
65
|
+
/** Convert plain-text Markdown to rich content on paste. Default true.
|
|
66
|
+
* Pastes that carry `text/html` are always left to Lexical's HTML import,
|
|
67
|
+
* regardless of this flag. Set false to paste Markdown as literal text. */
|
|
68
|
+
pasteMarkdown?: boolean
|
|
69
|
+
/** Enable collaborative editing. The editor hands you a markdown `seed` and
|
|
70
|
+
* status sinks; return a binding (build it with `yjsCollab` from
|
|
71
|
+
* `@llui/lexical-collab`, wiring your own provider). Mutually exclusive with
|
|
72
|
+
* `value` — the shared CRDT document, not a markdown signal, owns the content.
|
|
73
|
+
* `defaultValue` becomes the seed the bootstrapping peer writes. */
|
|
74
|
+
collab?: CollabFactory
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Disposer-returning binding the collab layer installs on the live editor.
|
|
78
|
+
* `@llui/lexical-collab`'s `YjsCollab` satisfies this structurally, so
|
|
79
|
+
* `@llui/markdown-editor` needs no Yjs dependency of its own. */
|
|
80
|
+
export interface CollabBinding {
|
|
81
|
+
register: (editor: LexicalEditor) => () => void
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Hooks the editor injects into the {@link CollabFactory}: a markdown `seed`
|
|
85
|
+
* (run once by the bootstrapping peer to fill an empty shared doc from
|
|
86
|
+
* `defaultValue`) plus status sinks the editor mirrors into `state.collab`.
|
|
87
|
+
* Spread straight into `yjsCollab({ id, provider, user, ...hooks })`. */
|
|
88
|
+
export interface CollabHooks {
|
|
89
|
+
seed: (editor: LexicalEditor) => void
|
|
90
|
+
onStatus: (connected: boolean) => void
|
|
91
|
+
onSync: (synced: boolean) => void
|
|
92
|
+
onPeers: (count: number) => void
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Builds the collab binding from the editor-supplied hooks. */
|
|
96
|
+
export type CollabFactory = (hooks: CollabHooks) => CollabBinding
|
|
97
|
+
|
|
98
|
+
/** Hooks the chrome layer (toolbar/menus) uses to compose around the editor. */
|
|
99
|
+
export interface EditorParts {
|
|
100
|
+
/** The merged, surface-filtered command items. */
|
|
101
|
+
items: readonly CommandItem[]
|
|
102
|
+
/** Reactive format signal for `connect`-style toolbars. */
|
|
103
|
+
format: Signal<FormatState>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Default plugin set when the consumer supplies none. */
|
|
107
|
+
function defaultPlugins(): MarkdownPlugin[] {
|
|
108
|
+
return [corePlugin(), linkPlugin()]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build the markdown editor component. Embed it with `mountApp(el, markdownEditor(...))`
|
|
113
|
+
* or compose it inside a larger component.
|
|
114
|
+
*/
|
|
115
|
+
export function markdownEditor(
|
|
116
|
+
config: EditorConfig = {},
|
|
117
|
+
): SignalComponentDef<EditorState, EditorMsg, EditorEffect> {
|
|
118
|
+
const plugins = config.plugins && config.plugins.length > 0 ? config.plugins : defaultPlugins()
|
|
119
|
+
const transformers = buildTransformers(plugins)
|
|
120
|
+
|
|
121
|
+
const items: CommandItem[] = plugins.flatMap((p) => p.items ?? [])
|
|
122
|
+
const itemsById = new Map(items.map((i) => [i.id, i]))
|
|
123
|
+
// Share the merged item list with plugins that want it (e.g. the slash menu).
|
|
124
|
+
for (const plugin of plugins) plugin.onItems?.(items)
|
|
125
|
+
const decorators: DecoratorBridge[] = plugins.flatMap((p) => p.decorators ?? [])
|
|
126
|
+
const pluginUIs: Array<{ name: string; ui: PluginUI }> = plugins
|
|
127
|
+
.filter((p): p is MarkdownPlugin & { ui: PluginUI } => p.ui !== undefined)
|
|
128
|
+
.map((p) => ({ name: p.name, ui: p.ui }))
|
|
129
|
+
const pluginUIByName = new Map(pluginUIs.map((p) => [p.name, p.ui]))
|
|
130
|
+
|
|
131
|
+
// The live editor, captured at mount; effects dispatch through it.
|
|
132
|
+
let editorRef: LexicalEditor | null = null
|
|
133
|
+
const getEditor = (): LexicalEditor | null => editorRef
|
|
134
|
+
|
|
135
|
+
const baseOnEffect = makeOnEffect(getEditor, itemsById, {
|
|
136
|
+
onChange: config.onChange,
|
|
137
|
+
onFormatChange: config.onFormatChange,
|
|
138
|
+
applyValue: (editor, value) =>
|
|
139
|
+
editor.update(
|
|
140
|
+
() => {
|
|
141
|
+
$convertFromMarkdownString(value, transformers)
|
|
142
|
+
// Clear selection so the reconciler doesn't pull DOM focus into the
|
|
143
|
+
// editor on an external push (e.g. typing in a bound source textarea).
|
|
144
|
+
$setSelection(null)
|
|
145
|
+
},
|
|
146
|
+
{ tag: PROGRAMMATIC_TAG },
|
|
147
|
+
),
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
if (config.collab && config.value) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
'markdownEditor: `collab` and `value` are mutually exclusive — in a collaborative ' +
|
|
153
|
+
'session the shared CRDT document owns the content, not a markdown signal. ' +
|
|
154
|
+
'Use `defaultValue` as the bootstrap seed instead.',
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const collabEnabled = !!config.collab
|
|
159
|
+
const seedValue = config.value ? config.value.peek() : (config.defaultValue ?? '')
|
|
160
|
+
|
|
161
|
+
// ── Composed TEA: core + plugin UI slices ──────────────────────────────────
|
|
162
|
+
const composedInit = (): [EditorState, EditorEffect[]] => {
|
|
163
|
+
const [core, effects] = init({
|
|
164
|
+
value: seedValue,
|
|
165
|
+
readonly: config.readonly ?? false,
|
|
166
|
+
collab: collabEnabled,
|
|
167
|
+
})
|
|
168
|
+
const slices: Record<string, unknown> = {}
|
|
169
|
+
for (const { name, ui } of pluginUIs) slices[name] = ui.init()
|
|
170
|
+
return [{ ...core, plugins: slices }, effects]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const composedUpdate = (state: EditorState, msg: EditorMsg): [EditorState, EditorEffect[]] => {
|
|
174
|
+
if (msg.type === 'plugin') {
|
|
175
|
+
const ui = pluginUIByName.get(msg.name)
|
|
176
|
+
if (!ui?.update) return [state, []]
|
|
177
|
+
const result = ui.update(state.plugins[msg.name], msg.msg)
|
|
178
|
+
const [slice, effects] = (Array.isArray(result) ? result : [result, []]) as [
|
|
179
|
+
unknown,
|
|
180
|
+
unknown[],
|
|
181
|
+
]
|
|
182
|
+
return [
|
|
183
|
+
{ ...state, plugins: { ...state.plugins, [msg.name]: slice } },
|
|
184
|
+
effects.map((effect) => ({ type: 'pluginEffect' as const, name: msg.name, effect })),
|
|
185
|
+
]
|
|
186
|
+
}
|
|
187
|
+
return update(state, msg)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const composedOnEffect = (
|
|
191
|
+
effect: EditorEffect,
|
|
192
|
+
api: { send: (msg: EditorMsg) => void; state: Signal<EditorState> },
|
|
193
|
+
): void => {
|
|
194
|
+
if (effect.type === 'pluginEffect') {
|
|
195
|
+
const ui = pluginUIByName.get(effect.name)
|
|
196
|
+
ui?.onEffect?.(effect.effect, {
|
|
197
|
+
editor: getEditor,
|
|
198
|
+
send: (msg) => api.send({ type: 'plugin', name: effect.name, msg }),
|
|
199
|
+
emit: (msg) => api.send(msg as EditorMsg),
|
|
200
|
+
})
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
baseOnEffect(effect, api)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const view = ({
|
|
207
|
+
state,
|
|
208
|
+
send,
|
|
209
|
+
}: {
|
|
210
|
+
state: Signal<EditorState>
|
|
211
|
+
send: (msg: EditorMsg) => void
|
|
212
|
+
}): Renderable => {
|
|
213
|
+
// Build the collab binding (once, at mount) from the consumer's factory,
|
|
214
|
+
// injecting the markdown seed + status sinks that mirror into `state.collab`.
|
|
215
|
+
const collabBinding: CollabBinding | null = config.collab
|
|
216
|
+
? config.collab({
|
|
217
|
+
seed: () => {
|
|
218
|
+
$convertFromMarkdownString(seedValue, transformers)
|
|
219
|
+
$setSelection(null)
|
|
220
|
+
},
|
|
221
|
+
onStatus: (connected) => send({ type: 'collabStatus', connected }),
|
|
222
|
+
onSync: (synced) => send({ type: 'collabSync', synced }),
|
|
223
|
+
onPeers: (peers) => send({ type: 'collabPeers', peers }),
|
|
224
|
+
})
|
|
225
|
+
: null
|
|
226
|
+
|
|
227
|
+
const host = lexicalForeign<EditorOutMsg>({
|
|
228
|
+
namespace: config.namespace ?? 'llui-markdown',
|
|
229
|
+
theme: mergeTheme(config.theme),
|
|
230
|
+
plugins,
|
|
231
|
+
serialize: (editor) =>
|
|
232
|
+
editor.getEditorState().read(() => $convertToMarkdownString(transformers)),
|
|
233
|
+
deserialize: (_editor, value) => {
|
|
234
|
+
$convertFromMarkdownString(value, transformers)
|
|
235
|
+
$setSelection(null)
|
|
236
|
+
},
|
|
237
|
+
// In collab mode the shared CRDT owns the document: the local undo stack
|
|
238
|
+
// and the boot-time seed are disabled — the binding supplies a scoped undo
|
|
239
|
+
// manager and a sync-gated bootstrap instead.
|
|
240
|
+
...(collabBinding ? { history: false, seedMode: 'deferred' as const } : {}),
|
|
241
|
+
defaultValue: collabBinding || config.value ? undefined : (config.defaultValue ?? ''),
|
|
242
|
+
...(config.value && !collabBinding ? { value: config.value } : {}),
|
|
243
|
+
readonly: state.at('readonly'),
|
|
244
|
+
...(config.changeDebounceMs !== undefined
|
|
245
|
+
? { changeDebounceMs: config.changeDebounceMs }
|
|
246
|
+
: {}),
|
|
247
|
+
register: (editor) => {
|
|
248
|
+
const disposers = [registerMarkdownShortcuts(editor, transformers)]
|
|
249
|
+
if (config.pasteMarkdown !== false)
|
|
250
|
+
disposers.push(registerMarkdownPaste(editor, transformers))
|
|
251
|
+
if (decorators.length > 0) disposers.push(registerDecoratorBridges(editor, decorators))
|
|
252
|
+
if (collabBinding) disposers.push(collabBinding.register(editor))
|
|
253
|
+
return () => {
|
|
254
|
+
for (const dispose of disposers) dispose()
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
onReady: (editor) => {
|
|
258
|
+
editorRef = editor
|
|
259
|
+
if (config.placeholder) {
|
|
260
|
+
editor.getRootElement()?.setAttribute('data-placeholder', config.placeholder)
|
|
261
|
+
}
|
|
262
|
+
config.onReady?.(editor)
|
|
263
|
+
},
|
|
264
|
+
onChange: (value) => send({ type: 'markdownChanged', value }),
|
|
265
|
+
onSelectionChange: (ctx) => {
|
|
266
|
+
const format = computeFormatState(ctx.editor, ctx)
|
|
267
|
+
const text = ctx.editor.getEditorState().read(() => $getRoot().getTextContent())
|
|
268
|
+
// Toggle an empty marker so CSS can show the placeholder.
|
|
269
|
+
ctx.editor.getRootElement()?.setAttribute('data-empty', text === '' ? 'true' : 'false')
|
|
270
|
+
send({ type: 'formatChanged', format, wordCount: countWords(text), charCount: text.length })
|
|
271
|
+
},
|
|
272
|
+
emit: (msg) => send(msg),
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
// Plugin view contributions (overlays/panels) — each gets its own slice + send.
|
|
276
|
+
const pluginViews: Renderable = pluginUIs.flatMap(({ name, ui }) => {
|
|
277
|
+
if (!ui.view) return []
|
|
278
|
+
const rendered = ui.view({
|
|
279
|
+
state: state.at(`plugins.${name}`),
|
|
280
|
+
send: (msg) => send({ type: 'plugin', name, msg }),
|
|
281
|
+
editor: getEditor,
|
|
282
|
+
})
|
|
283
|
+
return Array.isArray(rendered) ? rendered : [rendered]
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
if (!config.toolbar) return [host, ...pluginViews]
|
|
287
|
+
return [
|
|
288
|
+
div({ 'data-scope': 'md-editor', 'data-part': 'root' }, [
|
|
289
|
+
renderToolbar({ format: state.at('format'), send, items, collab: state.at('collab') }),
|
|
290
|
+
div({ 'data-scope': 'md-editor', 'data-part': 'surface' }, [host]),
|
|
291
|
+
]),
|
|
292
|
+
...pluginViews,
|
|
293
|
+
]
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return component<EditorState, EditorMsg, EditorEffect>({
|
|
297
|
+
name: 'MarkdownEditor',
|
|
298
|
+
init: composedInit,
|
|
299
|
+
update: composedUpdate,
|
|
300
|
+
view,
|
|
301
|
+
onEffect: composedOnEffect,
|
|
302
|
+
})
|
|
303
|
+
}
|
package/src/effects.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Effect handler: the only place TEA reaches back into the live Lexical editor.
|
|
2
|
+
// `execCommand` looks an id up in the merged command-item map and runs it on the
|
|
3
|
+
// editor captured at mount; emit* forward to the consumer's callbacks.
|
|
4
|
+
|
|
5
|
+
import type { LexicalEditor } from 'lexical'
|
|
6
|
+
import type { CommandItem } from './plugins/types.js'
|
|
7
|
+
import type { EditorEffect, EditorMsg, FormatState } from './state.js'
|
|
8
|
+
|
|
9
|
+
/** The api the component passes to `onEffect` (send + state). */
|
|
10
|
+
export interface EffectApi {
|
|
11
|
+
send: (msg: EditorMsg) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface EffectConfig {
|
|
15
|
+
onChange?: (markdown: string) => void
|
|
16
|
+
onFormatChange?: (format: FormatState) => void
|
|
17
|
+
/** Push markdown into the live editor (deserialize), without echoing onChange. */
|
|
18
|
+
applyValue: (editor: LexicalEditor, value: string) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Build the component's `onEffect`. `getEditor` returns the live editor (set at
|
|
22
|
+
* mount via the foreign `onReady`); `items` is the merged id → command map. */
|
|
23
|
+
export function makeOnEffect(
|
|
24
|
+
getEditor: () => LexicalEditor | null,
|
|
25
|
+
items: ReadonlyMap<string, CommandItem>,
|
|
26
|
+
config: EffectConfig,
|
|
27
|
+
): (effect: EditorEffect, api: EffectApi) => void {
|
|
28
|
+
return (effect, api) => {
|
|
29
|
+
switch (effect.type) {
|
|
30
|
+
case 'execCommand': {
|
|
31
|
+
const editor = getEditor()
|
|
32
|
+
const item = items.get(effect.id)
|
|
33
|
+
if (editor && item) item.run(editor, { send: api.send })
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
case 'applyValue': {
|
|
37
|
+
const editor = getEditor()
|
|
38
|
+
if (editor) config.applyValue(editor, effect.value)
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
case 'emitChange': {
|
|
42
|
+
config.onChange?.(effect.value)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
case 'emitFormat': {
|
|
46
|
+
config.onFormatChange?.(effect.format)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Compute the full toolbar FormatState from a Lexical selection — the base
|
|
2
|
+
// rich-text format (from @llui/lexical) refined with list/code/link detection
|
|
3
|
+
// (which needs the list/code/link packages this layer depends on).
|
|
4
|
+
|
|
5
|
+
import { $getSelection, $isRangeSelection, type LexicalEditor } from 'lexical'
|
|
6
|
+
import { $findMatchingParent, $getNearestNodeOfType } from '@lexical/utils'
|
|
7
|
+
import { ListNode, $isListItemNode } from '@lexical/list'
|
|
8
|
+
import { $isCodeNode } from '@lexical/code-core'
|
|
9
|
+
import { $isLinkNode } from '@lexical/link'
|
|
10
|
+
import { $readBaseFormat } from '@llui/lexical'
|
|
11
|
+
import type { SelectionContext } from '@llui/lexical'
|
|
12
|
+
import { EMPTY_FORMAT, type BlockType, type FormatState } from './state.js'
|
|
13
|
+
|
|
14
|
+
/** Read the full format surface at the current selection (opens a read ctx). */
|
|
15
|
+
export function computeFormatState(
|
|
16
|
+
editor: LexicalEditor,
|
|
17
|
+
history: Pick<SelectionContext, 'canUndo' | 'canRedo'>,
|
|
18
|
+
): FormatState {
|
|
19
|
+
return editor.getEditorState().read(() => {
|
|
20
|
+
const base = $readBaseFormat()
|
|
21
|
+
if (!base.hasSelection) {
|
|
22
|
+
return { ...EMPTY_FORMAT, canUndo: history.canUndo, canRedo: history.canRedo }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let blockType: BlockType = base.blockType
|
|
26
|
+
let link = false
|
|
27
|
+
|
|
28
|
+
const selection = $getSelection()
|
|
29
|
+
if ($isRangeSelection(selection)) {
|
|
30
|
+
const anchorNode = selection.anchor.getNode()
|
|
31
|
+
link = $findMatchingParent(anchorNode, (node) => $isLinkNode(node)) !== null
|
|
32
|
+
|
|
33
|
+
if (base.blockType === 'other') {
|
|
34
|
+
const listItem = $findMatchingParent(anchorNode, (node) => $isListItemNode(node))
|
|
35
|
+
if (listItem) {
|
|
36
|
+
const list = $getNearestNodeOfType(anchorNode, ListNode)
|
|
37
|
+
if (list) blockType = list.getListType()
|
|
38
|
+
} else {
|
|
39
|
+
const top = anchorNode.getKey() === 'root' ? null : anchorNode.getTopLevelElement()
|
|
40
|
+
if (top && $isCodeNode(top)) blockType = 'code'
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
bold: base.bold,
|
|
47
|
+
italic: base.italic,
|
|
48
|
+
strikethrough: base.strikethrough,
|
|
49
|
+
code: base.code,
|
|
50
|
+
link,
|
|
51
|
+
blockType,
|
|
52
|
+
alignment: base.alignment,
|
|
53
|
+
canUndo: history.canUndo,
|
|
54
|
+
canRedo: history.canRedo,
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// `@llui/markdown-editor` — WYSIWYG Markdown editor for LLui, built on Lexical.
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
type EditorConfig,
|
|
5
|
+
type EditorParts,
|
|
6
|
+
type CollabBinding,
|
|
7
|
+
type CollabHooks,
|
|
8
|
+
type CollabFactory,
|
|
9
|
+
markdownEditor,
|
|
10
|
+
} from './editor.js'
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
type BlockType,
|
|
14
|
+
type FormatState,
|
|
15
|
+
type OverlayKind,
|
|
16
|
+
type CollabStatus,
|
|
17
|
+
type EditorState,
|
|
18
|
+
type EditorMsg,
|
|
19
|
+
type EditorOutMsg,
|
|
20
|
+
type EditorEffect,
|
|
21
|
+
type InitOptions,
|
|
22
|
+
EMPTY_FORMAT,
|
|
23
|
+
COLLAB_OFF,
|
|
24
|
+
init,
|
|
25
|
+
update,
|
|
26
|
+
countWords,
|
|
27
|
+
} from './state.js'
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
type ItemSurface,
|
|
31
|
+
type CommandItem,
|
|
32
|
+
type CommandContext,
|
|
33
|
+
type MarkdownPlugin,
|
|
34
|
+
} from './plugins/types.js'
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
type PluginUI,
|
|
38
|
+
type PluginUISpec,
|
|
39
|
+
type PluginViewArgs,
|
|
40
|
+
type PluginEffectContext,
|
|
41
|
+
definePluginUI,
|
|
42
|
+
} from './plugins/ui.js'
|
|
43
|
+
|
|
44
|
+
export { type CorePluginOptions, corePlugin } from './plugins/core.js'
|
|
45
|
+
export {
|
|
46
|
+
type InlineFormat,
|
|
47
|
+
type SingleBlockPluginOptions,
|
|
48
|
+
singleBlockPlugin,
|
|
49
|
+
} from './plugins/single-block.js'
|
|
50
|
+
export { type LinkPluginOptions, linkPlugin } from './plugins/link.js'
|
|
51
|
+
export {
|
|
52
|
+
type CalloutKind,
|
|
53
|
+
type CalloutData,
|
|
54
|
+
type CalloutPluginOptions,
|
|
55
|
+
calloutPlugin,
|
|
56
|
+
$insertCallout,
|
|
57
|
+
} from './plugins/callout.js'
|
|
58
|
+
export { hrPlugin, $insertHorizontalRule } from './plugins/hr.js'
|
|
59
|
+
export { slashPlugin } from './plugins/slash.js'
|
|
60
|
+
export { contextMenuPlugin } from './plugins/context-menu.js'
|
|
61
|
+
export { floatingToolbarPlugin } from './plugins/floating-toolbar.js'
|
|
62
|
+
export { type MathPluginOptions, mathPlugin } from './plugins/math.js'
|
|
63
|
+
export { type MermaidPluginOptions, mermaidPlugin } from './plugins/mermaid.js'
|
|
64
|
+
export { type Mention, type MentionPluginOptions, mentionPlugin } from './plugins/mention.js'
|
|
65
|
+
export { type EmojiPluginOptions, DEFAULT_EMOJI, emojiPlugin } from './plugins/emoji.js'
|
|
66
|
+
export { type ImagePluginOptions, imagePlugin } from './plugins/image.js'
|
|
67
|
+
export { tablePlugin } from './plugins/table.js'
|
|
68
|
+
|
|
69
|
+
export { $insertMarkdownAtSelection, registerMarkdownPaste } from './paste.js'
|
|
70
|
+
|
|
71
|
+
export { GFM_NODES, GFM_TRANSFORMERS } from './transformers/gfm.js'
|
|
72
|
+
export { buildTransformers, orderTransformers } from './transformers/registry.js'
|
|
73
|
+
|
|
74
|
+
export { computeFormatState } from './format.js'
|
|
75
|
+
|
|
76
|
+
export {
|
|
77
|
+
STRIKETHROUGH_CLASS,
|
|
78
|
+
UNDERLINE_CLASS,
|
|
79
|
+
UNDERLINE_STRIKETHROUGH_CLASS,
|
|
80
|
+
defaultTheme,
|
|
81
|
+
mergeTheme,
|
|
82
|
+
} from './theme.js'
|
|
83
|
+
|
|
84
|
+
export {
|
|
85
|
+
type ToolbarItemParts,
|
|
86
|
+
type ToolbarParts,
|
|
87
|
+
type ToolbarOptions,
|
|
88
|
+
DEFAULT_GLYPHS,
|
|
89
|
+
connectToolbar,
|
|
90
|
+
toolbar,
|
|
91
|
+
} from './surfaces/toolbar.js'
|
|
92
|
+
|
|
93
|
+
export { type LinkDialogOptions, linkDialog } from './surfaces/link-dialog.js'
|