@llui/markdown-editor 0.2.12 → 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 +17 -13
- 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
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The markdown plugin contract: extends the engine-level LexicalPlugin with
|
|
2
|
+
// markdown transformers and UI command items (toolbar / slash / context).
|
|
3
|
+
|
|
4
|
+
import type { LexicalEditor } from 'lexical'
|
|
5
|
+
import type { Transformer } from '@lexical/markdown'
|
|
6
|
+
import type { LexicalPlugin } from '@llui/lexical'
|
|
7
|
+
import type { EditorMsg, EditorOutMsg, FormatState } from '../state.js'
|
|
8
|
+
import type { PluginUI } from './ui.js'
|
|
9
|
+
|
|
10
|
+
/** Which surfaces a command item appears in (default: all). */
|
|
11
|
+
export type ItemSurface = 'toolbar' | 'floating' | 'slash' | 'context'
|
|
12
|
+
|
|
13
|
+
/** Handed to a command item's `run` so it can talk back to the host (e.g. open
|
|
14
|
+
* the link dialog) instead of only mutating the editor. */
|
|
15
|
+
export interface CommandContext {
|
|
16
|
+
send: (msg: EditorMsg) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A user-invokable editor command surfaced to the chrome. Its reactive
|
|
20
|
+
* active/disabled state is read from {@link FormatState}; `run` mutates the
|
|
21
|
+
* live editor. */
|
|
22
|
+
export interface CommandItem {
|
|
23
|
+
/** Stable id (also the `runCommand` payload). */
|
|
24
|
+
id: string
|
|
25
|
+
label: string
|
|
26
|
+
/** Optional icon hint (class / svg id); rendering is the consumer's CSS. */
|
|
27
|
+
icon?: string
|
|
28
|
+
/** Grouping key for menu sectioning. */
|
|
29
|
+
group?: string
|
|
30
|
+
/** Keyword aliases for slash/command-palette filtering. */
|
|
31
|
+
keywords?: readonly string[]
|
|
32
|
+
isActive?: (format: FormatState) => boolean
|
|
33
|
+
isDisabled?: (format: FormatState) => boolean
|
|
34
|
+
run: (editor: LexicalEditor, ctx: CommandContext) => void
|
|
35
|
+
surfaces?: readonly ItemSurface[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A markdown editor plugin: engine wiring + transformers + UI items + an
|
|
39
|
+
* optional stateful UI extension (its own state slice, reducer, view, effects). */
|
|
40
|
+
export interface MarkdownPlugin extends LexicalPlugin<EditorOutMsg> {
|
|
41
|
+
/** Markdown ↔ node transformers contributed to the registry. */
|
|
42
|
+
transformers?: readonly Transformer[]
|
|
43
|
+
/** Command items surfaced to the toolbar / slash / context menus. */
|
|
44
|
+
items?: readonly CommandItem[]
|
|
45
|
+
/** A stateful UI extension keyed by this plugin's `name` (see {@link definePluginUI}). */
|
|
46
|
+
ui?: PluginUI
|
|
47
|
+
/** Receive the merged command items from all plugins (e.g. a slash menu lists
|
|
48
|
+
* every plugin's items). Called once at editor construction. */
|
|
49
|
+
onItems?: (items: readonly CommandItem[]) => void
|
|
50
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Plugin UI/state extensions — the seam that makes stateful, UI-bearing features
|
|
2
|
+
// (link editor, slash menu, @mentions, …) into plugins instead of core built-ins.
|
|
3
|
+
//
|
|
4
|
+
// A plugin may contribute a small TEA module: a namespaced state slice (stored
|
|
5
|
+
// under `state.plugins[name]`), a reducer, a view (overlays/panels rendered by
|
|
6
|
+
// the host), and effects (handled with live-editor access). Types are erased at
|
|
7
|
+
// the registry boundary via {@link definePluginUI}, which keeps each plugin's
|
|
8
|
+
// `State`/`Msg`/`Effect` fully typed at the definition site.
|
|
9
|
+
|
|
10
|
+
import type { LexicalEditor } from 'lexical'
|
|
11
|
+
import type { Renderable, Signal } from '@llui/dom'
|
|
12
|
+
|
|
13
|
+
/** Context for a plugin's `onEffect` — reach the live editor and dispatch back. */
|
|
14
|
+
export interface PluginEffectContext<M> {
|
|
15
|
+
/** The live Lexical editor (null before mount). */
|
|
16
|
+
editor: () => LexicalEditor | null
|
|
17
|
+
/** Dispatch a message back into this plugin. */
|
|
18
|
+
send: (msg: M) => void
|
|
19
|
+
/** Dispatch a host editor message (e.g. `{type:'runCommand', id}`). */
|
|
20
|
+
emit: (msg: unknown) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Args for a plugin's `view` — its reactive state slice + a scoped dispatcher. */
|
|
24
|
+
export interface PluginViewArgs<S, M> {
|
|
25
|
+
state: Signal<S>
|
|
26
|
+
send: (msg: M) => void
|
|
27
|
+
editor: () => LexicalEditor | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A typed plugin UI module (authored via {@link definePluginUI}). */
|
|
31
|
+
export interface PluginUISpec<S, M, E = never> {
|
|
32
|
+
/** Initial slice state (JSON-serializable). */
|
|
33
|
+
init: () => S
|
|
34
|
+
/** Pure reducer over the slice; may return effects. */
|
|
35
|
+
update?: (state: S, msg: M) => S | [S, E[]]
|
|
36
|
+
/** View contribution (overlays/panels), rendered by the host. */
|
|
37
|
+
view?: (args: PluginViewArgs<S, M>) => Renderable
|
|
38
|
+
/** Effect handler with live-editor access + host dispatch. */
|
|
39
|
+
onEffect?: (effect: E, ctx: PluginEffectContext<M>) => void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The host message type a plugin effect may emit (the editor's full Msg). */
|
|
43
|
+
export type HostEmit = (msg: unknown) => void
|
|
44
|
+
|
|
45
|
+
/** The type-erased form stored on a plugin and consumed by the host. */
|
|
46
|
+
export interface PluginUI {
|
|
47
|
+
init: () => unknown
|
|
48
|
+
update?: (state: unknown, msg: unknown) => unknown | [unknown, unknown[]]
|
|
49
|
+
view?: (args: PluginViewArgs<unknown, unknown>) => Renderable
|
|
50
|
+
onEffect?: (effect: unknown, ctx: PluginEffectContext<unknown>) => void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Author a plugin UI module with full `State`/`Msg`/`Effect` types, erased for
|
|
55
|
+
* storage. The casts are confined to this boundary (the host only knows
|
|
56
|
+
* `unknown`), exactly like the decorator bridge.
|
|
57
|
+
*/
|
|
58
|
+
export function definePluginUI<S, M, E = never>(spec: PluginUISpec<S, M, E>): PluginUI {
|
|
59
|
+
return {
|
|
60
|
+
init: spec.init,
|
|
61
|
+
update: spec.update ? (state, msg) => spec.update!(state as S, msg as M) : undefined,
|
|
62
|
+
view: spec.view
|
|
63
|
+
? (args) =>
|
|
64
|
+
spec.view!({
|
|
65
|
+
state: args.state as Signal<S>,
|
|
66
|
+
send: args.send as (msg: M) => void,
|
|
67
|
+
editor: args.editor,
|
|
68
|
+
})
|
|
69
|
+
: undefined,
|
|
70
|
+
onEffect: spec.onEffect
|
|
71
|
+
? (effect, ctx) =>
|
|
72
|
+
spec.onEffect!(effect as E, {
|
|
73
|
+
editor: ctx.editor,
|
|
74
|
+
send: ctx.send as (msg: M) => void,
|
|
75
|
+
emit: ctx.emit,
|
|
76
|
+
})
|
|
77
|
+
: undefined,
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// The editor's TEA state, messages, effects, and pure reducer. Lexical owns the
|
|
2
|
+
// live document; this state holds JSON-serializable mirrors/derivations only, so
|
|
3
|
+
// `update` stays pure and DOM-free (fully unit-testable).
|
|
4
|
+
|
|
5
|
+
import type { Alignment } from '@llui/lexical'
|
|
6
|
+
|
|
7
|
+
/** The block kind at the selection — base rich-text kinds plus list/code,
|
|
8
|
+
* resolved by the markdown layer. */
|
|
9
|
+
export type BlockType =
|
|
10
|
+
| 'paragraph'
|
|
11
|
+
| 'h1'
|
|
12
|
+
| 'h2'
|
|
13
|
+
| 'h3'
|
|
14
|
+
| 'h4'
|
|
15
|
+
| 'h5'
|
|
16
|
+
| 'h6'
|
|
17
|
+
| 'quote'
|
|
18
|
+
| 'code'
|
|
19
|
+
| 'bullet'
|
|
20
|
+
| 'number'
|
|
21
|
+
| 'check'
|
|
22
|
+
| 'other'
|
|
23
|
+
|
|
24
|
+
/** The toolbar-facing format surface at the current selection (all primitives). */
|
|
25
|
+
export interface FormatState {
|
|
26
|
+
bold: boolean
|
|
27
|
+
italic: boolean
|
|
28
|
+
strikethrough: boolean
|
|
29
|
+
code: boolean
|
|
30
|
+
link: boolean
|
|
31
|
+
blockType: BlockType
|
|
32
|
+
alignment: Alignment
|
|
33
|
+
canUndo: boolean
|
|
34
|
+
canRedo: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const EMPTY_FORMAT: FormatState = {
|
|
38
|
+
bold: false,
|
|
39
|
+
italic: false,
|
|
40
|
+
strikethrough: false,
|
|
41
|
+
code: false,
|
|
42
|
+
link: false,
|
|
43
|
+
blockType: 'paragraph',
|
|
44
|
+
alignment: null,
|
|
45
|
+
canUndo: false,
|
|
46
|
+
canRedo: false,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Which floating surface is currently open. */
|
|
50
|
+
export type OverlayKind = 'none' | 'floating' | 'slash' | 'context' | 'link'
|
|
51
|
+
|
|
52
|
+
/** Live collaborative-session status (mirror of the CRDT provider state).
|
|
53
|
+
* `enabled` is false unless the editor was created with a `collab` factory. */
|
|
54
|
+
export interface CollabStatus {
|
|
55
|
+
enabled: boolean
|
|
56
|
+
connected: boolean
|
|
57
|
+
synced: boolean
|
|
58
|
+
/** Remote peers currently present (excludes this client). */
|
|
59
|
+
peers: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const COLLAB_OFF: CollabStatus = {
|
|
63
|
+
enabled: false,
|
|
64
|
+
connected: false,
|
|
65
|
+
synced: false,
|
|
66
|
+
peers: 0,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface EditorState {
|
|
70
|
+
/** Last serialized markdown (mirror of the live document). */
|
|
71
|
+
value: string
|
|
72
|
+
format: FormatState
|
|
73
|
+
wordCount: number
|
|
74
|
+
charCount: number
|
|
75
|
+
ui: {
|
|
76
|
+
activeOverlay: OverlayKind
|
|
77
|
+
slashQuery: string
|
|
78
|
+
menu: { x: number; y: number }
|
|
79
|
+
}
|
|
80
|
+
/** Per-plugin UI state slices, keyed by plugin name (see {@link PluginUI}). */
|
|
81
|
+
plugins: Record<string, unknown>
|
|
82
|
+
dirty: boolean
|
|
83
|
+
readonly: boolean
|
|
84
|
+
/** Collaborative-session status (always present; inert unless `collab` set). */
|
|
85
|
+
collab: CollabStatus
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type EditorMsg =
|
|
89
|
+
| { type: 'markdownChanged'; value: string }
|
|
90
|
+
| { type: 'formatChanged'; format: FormatState; wordCount: number; charCount: number }
|
|
91
|
+
| { type: 'runCommand'; id: string }
|
|
92
|
+
| { type: 'setValue'; value: string }
|
|
93
|
+
| { type: 'openOverlay'; overlay: OverlayKind; x?: number; y?: number }
|
|
94
|
+
| { type: 'closeOverlay' }
|
|
95
|
+
| { type: 'slashQuery'; query: string }
|
|
96
|
+
| { type: 'setReadOnly'; readonly: boolean }
|
|
97
|
+
| { type: 'collabStatus'; connected: boolean }
|
|
98
|
+
| { type: 'collabSync'; synced: boolean }
|
|
99
|
+
| { type: 'collabPeers'; peers: number }
|
|
100
|
+
/** Route a message to a plugin's UI reducer (see {@link PluginUI}). */
|
|
101
|
+
| { type: 'plugin'; name: string; msg: unknown }
|
|
102
|
+
|
|
103
|
+
/** The subset of messages a plugin may emit through its `PluginContext` (e.g. a
|
|
104
|
+
* `register` listener routing an editor event into its own plugin UI). */
|
|
105
|
+
export type EditorOutMsg = Extract<
|
|
106
|
+
EditorMsg,
|
|
107
|
+
{ type: 'openOverlay' | 'closeOverlay' | 'slashQuery' | 'plugin' }
|
|
108
|
+
>
|
|
109
|
+
|
|
110
|
+
export type EditorEffect =
|
|
111
|
+
| { type: 'execCommand'; id: string }
|
|
112
|
+
| { type: 'applyValue'; value: string }
|
|
113
|
+
| { type: 'emitChange'; value: string }
|
|
114
|
+
| { type: 'emitFormat'; format: FormatState }
|
|
115
|
+
/** An effect produced by a plugin's UI reducer (see {@link PluginUI}). */
|
|
116
|
+
| { type: 'pluginEffect'; name: string; effect: unknown }
|
|
117
|
+
|
|
118
|
+
export interface InitOptions {
|
|
119
|
+
value: string
|
|
120
|
+
readonly: boolean
|
|
121
|
+
/** Whether a collaborative session is wired (drives `collab.enabled`). */
|
|
122
|
+
collab?: boolean
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function init(opts: InitOptions): [EditorState, EditorEffect[]] {
|
|
126
|
+
const wordCount = countWords(opts.value)
|
|
127
|
+
return [
|
|
128
|
+
{
|
|
129
|
+
value: opts.value,
|
|
130
|
+
format: EMPTY_FORMAT,
|
|
131
|
+
wordCount,
|
|
132
|
+
charCount: opts.value.length,
|
|
133
|
+
ui: { activeOverlay: 'none', slashQuery: '', menu: { x: 0, y: 0 } },
|
|
134
|
+
plugins: {},
|
|
135
|
+
dirty: false,
|
|
136
|
+
readonly: opts.readonly,
|
|
137
|
+
collab: { ...COLLAB_OFF, enabled: opts.collab ?? false },
|
|
138
|
+
},
|
|
139
|
+
[],
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function update(state: EditorState, msg: EditorMsg): [EditorState, EditorEffect[]] {
|
|
144
|
+
switch (msg.type) {
|
|
145
|
+
case 'markdownChanged': {
|
|
146
|
+
// Idempotent: re-emitting the current value is a no-op (echo safety).
|
|
147
|
+
if (msg.value === state.value) return [state, []]
|
|
148
|
+
return [
|
|
149
|
+
{ ...state, value: msg.value, dirty: true },
|
|
150
|
+
[{ type: 'emitChange', value: msg.value }],
|
|
151
|
+
]
|
|
152
|
+
}
|
|
153
|
+
case 'formatChanged': {
|
|
154
|
+
return [
|
|
155
|
+
{ ...state, format: msg.format, wordCount: msg.wordCount, charCount: msg.charCount },
|
|
156
|
+
[{ type: 'emitFormat', format: msg.format }],
|
|
157
|
+
]
|
|
158
|
+
}
|
|
159
|
+
case 'runCommand': {
|
|
160
|
+
return [state, [{ type: 'execCommand', id: msg.id }]]
|
|
161
|
+
}
|
|
162
|
+
case 'setValue': {
|
|
163
|
+
// External markdown push (via the component handle). Idempotent; does not
|
|
164
|
+
// re-emit onChange (the consumer already owns this value).
|
|
165
|
+
if (msg.value === state.value) return [state, []]
|
|
166
|
+
return [
|
|
167
|
+
{ ...state, value: msg.value, dirty: true },
|
|
168
|
+
[{ type: 'applyValue', value: msg.value }],
|
|
169
|
+
]
|
|
170
|
+
}
|
|
171
|
+
case 'openOverlay': {
|
|
172
|
+
return [
|
|
173
|
+
{
|
|
174
|
+
...state,
|
|
175
|
+
ui: {
|
|
176
|
+
...state.ui,
|
|
177
|
+
activeOverlay: msg.overlay,
|
|
178
|
+
slashQuery: msg.overlay === 'slash' ? '' : state.ui.slashQuery,
|
|
179
|
+
menu: { x: msg.x ?? state.ui.menu.x, y: msg.y ?? state.ui.menu.y },
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
[],
|
|
183
|
+
]
|
|
184
|
+
}
|
|
185
|
+
case 'closeOverlay': {
|
|
186
|
+
if (state.ui.activeOverlay === 'none') return [state, []]
|
|
187
|
+
return [{ ...state, ui: { ...state.ui, activeOverlay: 'none', slashQuery: '' } }, []]
|
|
188
|
+
}
|
|
189
|
+
case 'slashQuery': {
|
|
190
|
+
return [{ ...state, ui: { ...state.ui, slashQuery: msg.query } }, []]
|
|
191
|
+
}
|
|
192
|
+
case 'setReadOnly': {
|
|
193
|
+
if (state.readonly === msg.readonly) return [state, []]
|
|
194
|
+
return [{ ...state, readonly: msg.readonly }, []]
|
|
195
|
+
}
|
|
196
|
+
case 'collabStatus': {
|
|
197
|
+
if (state.collab.connected === msg.connected) return [state, []]
|
|
198
|
+
return [{ ...state, collab: { ...state.collab, connected: msg.connected } }, []]
|
|
199
|
+
}
|
|
200
|
+
case 'collabSync': {
|
|
201
|
+
if (state.collab.synced === msg.synced) return [state, []]
|
|
202
|
+
return [{ ...state, collab: { ...state.collab, synced: msg.synced } }, []]
|
|
203
|
+
}
|
|
204
|
+
case 'collabPeers': {
|
|
205
|
+
if (state.collab.peers === msg.peers) return [state, []]
|
|
206
|
+
return [{ ...state, collab: { ...state.collab, peers: msg.peers } }, []]
|
|
207
|
+
}
|
|
208
|
+
case 'plugin': {
|
|
209
|
+
// Plugin messages are routed by the host's composed reducer (it holds the
|
|
210
|
+
// plugin registry); the pure core reducer treats them as a no-op.
|
|
211
|
+
return [state, []]
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Count whitespace-delimited words (shared by init and the format handler). */
|
|
217
|
+
export function countWords(text: string): number {
|
|
218
|
+
const trimmed = text.trim()
|
|
219
|
+
return trimmed === '' ? 0 : trimmed.split(/\s+/).length
|
|
220
|
+
}
|