@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.
@@ -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
+ }