@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/src/paste.ts ADDED
@@ -0,0 +1,91 @@
1
+ // Markdown-on-paste: when the user pastes plain text into the editor, parse it
2
+ // as Markdown and insert the resulting rich nodes at the caret instead of the
3
+ // literal source. Rich pastes (clipboards carrying `text/html`) are left to
4
+ // Lexical's own HTML import so copy-from-the-web keeps its formatting. Disabled
5
+ // per-editor via the `pasteMarkdown: false` config option.
6
+
7
+ import {
8
+ $createParagraphNode,
9
+ $getSelection,
10
+ $insertNodes,
11
+ $isRangeSelection,
12
+ $setSelection,
13
+ COMMAND_PRIORITY_HIGH,
14
+ PASTE_COMMAND,
15
+ isDOMNode,
16
+ isSelectionCapturedInDecoratorInput,
17
+ type LexicalEditor,
18
+ } from 'lexical'
19
+ import { $convertFromMarkdownString, type Transformer } from '@lexical/markdown'
20
+
21
+ /**
22
+ * Parse `markdown` with `transformers` and insert the produced nodes at the
23
+ * current range selection. The document is parsed into a detached scratch
24
+ * container (so the live root is never cleared) and the selection captured
25
+ * before the import — which moves the caret into the scratch node — is restored
26
+ * before the nodes are spliced in. Returns `true` only when nodes were actually
27
+ * inserted; `false` (a no-op) when there is no range selection to insert into or
28
+ * the markdown parsed to nothing — letting the caller fall back to the default
29
+ * paste behaviour instead of silently swallowing the event.
30
+ */
31
+ export function $insertMarkdownAtSelection(
32
+ markdown: string,
33
+ transformers: Array<Transformer>,
34
+ ): boolean {
35
+ const selection = $getSelection()
36
+ if (!$isRangeSelection(selection)) return false
37
+ const saved = selection.clone()
38
+
39
+ // Import into a detached element so `$convertFromMarkdownString`'s `root.clear()`
40
+ // touches the scratch node, not the live document.
41
+ const scratch = $createParagraphNode()
42
+ $convertFromMarkdownString(markdown, transformers, scratch)
43
+ const nodes = scratch.getChildren()
44
+ if (nodes.length === 0) return false
45
+
46
+ // The import re-homes the selection to the scratch node's start; restore the
47
+ // real caret, then splice the parsed nodes in (splitting blocks as needed).
48
+ $setSelection(saved)
49
+ $insertNodes(nodes)
50
+ return true
51
+ }
52
+
53
+ /**
54
+ * Register the markdown-on-paste handler on `editor`. Returns a disposer.
55
+ *
56
+ * Plain-text pastes are converted as Markdown. Pastes that also carry
57
+ * `text/html` are ignored so Lexical's richer HTML import handles them.
58
+ */
59
+ export function registerMarkdownPaste(
60
+ editor: LexicalEditor,
61
+ transformers: Array<Transformer>,
62
+ ): () => void {
63
+ return editor.registerCommand(
64
+ PASTE_COMMAND,
65
+ (event: ClipboardEvent) => {
66
+ // Let native inputs inside a DecoratorNode paste themselves (mirrors
67
+ // Lexical's own rich-text paste guard).
68
+ if (isDOMNode(event.target) && isSelectionCapturedInDecoratorInput(event.target)) return false
69
+ const clipboard = event.clipboardData
70
+ if (!clipboard) return false
71
+ // Defer to Lexical's HTML import when the source provides rich content.
72
+ if (clipboard.types.includes('text/html')) return false
73
+ const text = clipboard.getData('text/plain')
74
+ if (!text) return false
75
+
76
+ // Command listeners run inside a read context, so we can inspect the
77
+ // selection synchronously. Only claim the event when there is a range
78
+ // selection to insert into; otherwise fall through to Lexical's default
79
+ // paste handler (e.g. a selected decorator/NodeSelection) rather than
80
+ // swallowing the paste.
81
+ if (!$isRangeSelection($getSelection())) return false
82
+
83
+ event.preventDefault()
84
+ editor.update(() => {
85
+ $insertMarkdownAtSelection(text, transformers)
86
+ })
87
+ return true
88
+ },
89
+ COMMAND_PRIORITY_HIGH,
90
+ )
91
+ }
@@ -0,0 +1,51 @@
1
+ // Shared rendered-preview seam for the math / mermaid decorator plugins.
2
+ //
3
+ // SECURITY: a consumer-supplied `render` turns editor-document content
4
+ // (TeX / mermaid source the user typed) into a preview. To avoid forcing
5
+ // that through an unsanitized HTML sink, `render` may return EITHER:
6
+ //
7
+ // - a DOM `Node` — mounted directly with no HTML round-trip and NO
8
+ // sanitization needed (prefer this: hand back the element KaTeX /
9
+ // mermaid already produced); or
10
+ // - a `string` — injected as **trusted HTML**. The renderer owns
11
+ // sanitization in this branch; document content flows into it, so a
12
+ // vulnerable/misconfigured renderer is an XSS sink. Return sanitized
13
+ // markup (e.g. via DOMPurify) or prefer the Node form.
14
+
15
+ import { foreign, type Mountable, type Signal } from '@llui/dom'
16
+
17
+ /** A preview renderer: source string → safe DOM node, or trusted HTML string. */
18
+ export type PreviewRender = (source: string) => string | Node
19
+
20
+ interface PreviewInstance {
21
+ unbind: () => void
22
+ }
23
+
24
+ /**
25
+ * Build a reactive `data-part="preview"` region that re-renders whenever
26
+ * `source` changes. String results are set as trusted HTML; Node results
27
+ * are mounted directly (no sanitization). See the module header.
28
+ */
29
+ export function renderedPreview(
30
+ source: Signal<string>,
31
+ render: PreviewRender,
32
+ tag = 'div',
33
+ ): Mountable {
34
+ return foreign<PreviewInstance, { source: Signal<string> }>({
35
+ tag,
36
+ state: { source },
37
+ mount: ({ el, state }) => {
38
+ el.setAttribute('data-part', 'preview')
39
+ el.setAttribute('contenteditable', 'false')
40
+ const apply = (value: string): void => {
41
+ const out = render(value)
42
+ if (typeof out === 'string') el.innerHTML = out
43
+ else el.replaceChildren(out)
44
+ }
45
+ // `bind` fires immediately with the current value, then on change.
46
+ const unbind = state.source.bind(apply)
47
+ return { unbind }
48
+ },
49
+ unmount: (instance) => instance.unbind(),
50
+ })
51
+ }
@@ -0,0 +1,195 @@
1
+ // Callout (admonition) plugin — the custom-rendering showcase. A callout is a
2
+ // block decorator: it stores `{ kind, text }`, renders an LLui sub-view (its own
3
+ // TEA loop) with a clickable kind badge, and round-trips to `:::kind text`
4
+ // markdown via a contributed element transformer.
5
+
6
+ import { type ElementNode, type LexicalEditor, type LexicalNode } from 'lexical'
7
+ import { $insertNodeToNearestRoot } from '@lexical/utils'
8
+ import type { ElementTransformer } from '@lexical/markdown'
9
+ import {
10
+ $createLLuiDecoratorNode,
11
+ $isLLuiDecoratorNode,
12
+ LLuiDecoratorNode,
13
+ decoratorBridge,
14
+ } from '@llui/lexical'
15
+ import { button, component, div, span, text, type Signal } from '@llui/dom'
16
+ import type { MarkdownPlugin } from './types.js'
17
+
18
+ export type CalloutKind = 'note' | 'tip' | 'warning' | 'danger'
19
+
20
+ export interface CalloutData {
21
+ kind: CalloutKind
22
+ text: string
23
+ }
24
+
25
+ const KIND_CYCLE: readonly CalloutKind[] = ['note', 'tip', 'warning', 'danger']
26
+ const KIND_LABEL: Readonly<Record<CalloutKind, string>> = {
27
+ note: 'Note',
28
+ tip: 'Tip',
29
+ warning: 'Warning',
30
+ danger: 'Danger',
31
+ }
32
+
33
+ const BRIDGE_TYPE = 'callout'
34
+
35
+ function nextKind(kind: CalloutKind): CalloutKind {
36
+ const idx = KIND_CYCLE.indexOf(kind)
37
+ return KIND_CYCLE[(idx + 1) % KIND_CYCLE.length]!
38
+ }
39
+
40
+ function isCalloutData(value: unknown): value is CalloutData {
41
+ return (
42
+ typeof value === 'object' &&
43
+ value !== null &&
44
+ typeof (value as CalloutData).kind === 'string' &&
45
+ typeof (value as CalloutData).text === 'string'
46
+ )
47
+ }
48
+
49
+ type CalloutMsg = { type: 'cycle' } | { type: 'commitText'; text: string }
50
+
51
+ // Keep keyboard/input/paste events from bubbling to the outer Lexical editor so
52
+ // the nested editable text island edits natively without Lexical intercepting.
53
+ const stop = (e: Event): void => e.stopPropagation()
54
+
55
+ /** The LLui sub-view rendered inside a callout DecoratorNode. The badge cycles
56
+ * the kind; the text is an editable island that persists into the Lexical node
57
+ * on blur (both round-trip to markdown). */
58
+ const calloutBridge = decoratorBridge<CalloutData, CalloutData, CalloutMsg, never>(
59
+ BRIDGE_TYPE,
60
+ (data, api) =>
61
+ component<CalloutData, CalloutMsg, never>({
62
+ name: 'Callout',
63
+ init: () => ({ kind: data.kind, text: data.text }),
64
+ update: (state, msg) => {
65
+ if (msg.type === 'cycle') {
66
+ const kind = nextKind(state.kind)
67
+ api.update({ kind, text: state.text })
68
+ return { ...state, kind }
69
+ }
70
+ if (msg.type === 'commitText') {
71
+ if (msg.text === state.text) return state
72
+ api.update({ kind: state.kind, text: msg.text })
73
+ return { ...state, text: msg.text }
74
+ }
75
+ return state
76
+ },
77
+ view: ({ state, send }) => [
78
+ div(
79
+ {
80
+ 'data-scope': 'md-callout',
81
+ 'data-part': 'root',
82
+ 'data-kind': state.at('kind') as Signal<string>,
83
+ contenteditable: 'false',
84
+ onKeyDown: stop,
85
+ onBeforeInput: stop,
86
+ onPaste: stop,
87
+ },
88
+ [
89
+ button(
90
+ {
91
+ type: 'button',
92
+ 'data-part': 'badge',
93
+ 'aria-label': 'Change callout kind',
94
+ onClick: () => send({ type: 'cycle' }),
95
+ },
96
+ [text(state.at('kind').map((k) => KIND_LABEL[k]))],
97
+ ),
98
+ span(
99
+ {
100
+ 'data-part': 'text',
101
+ contenteditable: 'true',
102
+ role: 'textbox',
103
+ 'aria-label': 'Callout text',
104
+ onBlur: (e: FocusEvent) =>
105
+ send({ type: 'commitText', text: (e.target as HTMLElement).textContent ?? '' }),
106
+ },
107
+ [text(state.at('text') as Signal<string>)],
108
+ ),
109
+ ],
110
+ ),
111
+ ],
112
+ }),
113
+ )
114
+
115
+ /** `:::kind text` element transformer (single-line admonition). */
116
+ const CALLOUT_TRANSFORMER: ElementTransformer = {
117
+ dependencies: [LLuiDecoratorNode],
118
+ export: (node: LexicalNode): string | null => {
119
+ if (!$isLLuiDecoratorNode(node) || node.getBridgeType() !== BRIDGE_TYPE) return null
120
+ const data = node.getData()
121
+ if (!isCalloutData(data)) return null
122
+ return `:::${data.kind} ${data.text}`
123
+ },
124
+ regExp: /^:::(note|tip|warning|danger)[ \t]+(.+)$/,
125
+ replace: (parentNode: ElementNode, _children, match): void => {
126
+ const kind = match[1] as CalloutKind
127
+ const callout = $createLLuiDecoratorNode(BRIDGE_TYPE, { kind, text: match[2] ?? '' })
128
+ parentNode.replace(callout)
129
+ },
130
+ type: 'element',
131
+ }
132
+
133
+ /** Insert a fresh callout at the current selection; returns the created node. */
134
+ export function $insertCallout(
135
+ kind: CalloutKind = 'note',
136
+ textValue = 'New callout',
137
+ ): LLuiDecoratorNode {
138
+ return $insertNodeToNearestRoot($createLLuiDecoratorNode(BRIDGE_TYPE, { kind, text: textValue }))
139
+ }
140
+
141
+ /** Move focus into a callout's editable text island once it has decorated, and
142
+ * select its placeholder text so the next keystroke replaces it. */
143
+ function focusCalloutText(editor: LexicalEditor, key: string, attempt = 0): void {
144
+ if (typeof requestAnimationFrame !== 'function') return
145
+ requestAnimationFrame(() => {
146
+ const span = editor.getElementByKey(key)?.querySelector('[data-part="text"]')
147
+ if (span instanceof HTMLElement) {
148
+ span.focus()
149
+ try {
150
+ const range = document.createRange()
151
+ range.selectNodeContents(span)
152
+ const sel = window.getSelection()
153
+ sel?.removeAllRanges()
154
+ sel?.addRange(range)
155
+ } catch {
156
+ /* selection API unavailable */
157
+ }
158
+ } else if (attempt < 3) {
159
+ focusCalloutText(editor, key, attempt + 1)
160
+ }
161
+ })
162
+ }
163
+
164
+ export interface CalloutPluginOptions {
165
+ /** Default kind for the toolbar/slash insert action. */
166
+ defaultKind?: CalloutKind
167
+ }
168
+
169
+ export function calloutPlugin(opts: CalloutPluginOptions = {}): MarkdownPlugin {
170
+ const defaultKind = opts.defaultKind ?? 'note'
171
+ return {
172
+ name: 'callout',
173
+ nodes: [LLuiDecoratorNode],
174
+ decorators: [calloutBridge],
175
+ transformers: [CALLOUT_TRANSFORMER],
176
+ items: [
177
+ {
178
+ id: 'callout',
179
+ label: 'Callout',
180
+ icon: 'callout',
181
+ group: 'insert',
182
+ keywords: ['note', 'admonition', 'aside', 'tip', 'warning'],
183
+ run: (editor) => {
184
+ let key = ''
185
+ editor.update(() => {
186
+ key = $insertCallout(defaultKind).getKey()
187
+ })
188
+ // Land the caret inside the new callout's text, not after the block.
189
+ focusCalloutText(editor, key)
190
+ },
191
+ surfaces: ['toolbar', 'slash', 'context'],
192
+ },
193
+ ],
194
+ }
195
+ }
@@ -0,0 +1,118 @@
1
+ // Right-click context menu — a plugin-UI overlay. `register` installs a
2
+ // `contextmenu` listener on the editor root and opens the menu at the pointer;
3
+ // the plugin-UI renders the menu and runs the chosen command.
4
+
5
+ import { div, each, text, type Signal } from '@llui/dom'
6
+ import { definePluginUI } from './ui.js'
7
+ import { OVERLAY_Z, hideOverlay, overlayRoot } from './overlay.js'
8
+ import type { CommandItem, MarkdownPlugin } from './types.js'
9
+
10
+ interface MenuItem {
11
+ id: string
12
+ label: string
13
+ }
14
+
15
+ interface ContextState {
16
+ open: boolean
17
+ x: number
18
+ y: number
19
+ items: MenuItem[]
20
+ }
21
+
22
+ type ContextMsg =
23
+ | { type: 'open'; x: number; y: number; items: MenuItem[] }
24
+ | { type: 'close' }
25
+ | { type: 'choose'; index: number }
26
+
27
+ type ContextEffect = { type: 'run'; id: string }
28
+
29
+ export function contextMenuPlugin(): MarkdownPlugin {
30
+ let contextItems: CommandItem[] = []
31
+
32
+ return {
33
+ name: 'contextMenu',
34
+ onItems: (items) => {
35
+ // Curated: only items that explicitly opt into the context menu (insert
36
+ // actions, links, history). Inline formatting lives in the floating bar.
37
+ contextItems = items.filter((i) => i.surfaces?.includes('context') ?? false)
38
+ },
39
+ register: (editor, ctx) => {
40
+ const onContext = (e: Event): void => {
41
+ const me = e as MouseEvent
42
+ e.preventDefault()
43
+ ctx.emit({
44
+ type: 'plugin',
45
+ name: 'contextMenu',
46
+ msg: {
47
+ type: 'open',
48
+ x: me.clientX,
49
+ y: me.clientY,
50
+ items: contextItems.map((i) => ({ id: i.id, label: i.label })),
51
+ },
52
+ })
53
+ }
54
+ const root = editor.getRootElement()
55
+ root?.addEventListener('contextmenu', onContext)
56
+ return () => {
57
+ editor.getRootElement()?.removeEventListener('contextmenu', onContext)
58
+ }
59
+ },
60
+ ui: definePluginUI<ContextState, ContextMsg, ContextEffect>({
61
+ init: () => ({ open: false, x: 0, y: 0, items: [] }),
62
+ update: (state, msg) => {
63
+ switch (msg.type) {
64
+ case 'open':
65
+ return { open: msg.items.length > 0, x: msg.x, y: msg.y, items: msg.items }
66
+ case 'close':
67
+ return hideOverlay(state)
68
+ case 'choose': {
69
+ const item = state.items[msg.index]
70
+ if (!item) return hideOverlay(state)
71
+ return [{ ...state, open: false }, [{ type: 'run', id: item.id }]]
72
+ }
73
+ }
74
+ },
75
+ onEffect: (effect, ctx) => {
76
+ ctx.emit({ type: 'runCommand', id: effect.id })
77
+ },
78
+ view: ({ state, send }) =>
79
+ overlayRoot({
80
+ open: state.at('open'),
81
+ x: state.at('x'),
82
+ y: state.at('y'),
83
+ zIndex: OVERLAY_Z.contextMenu,
84
+ attrs: { 'data-scope': 'md-context', 'data-part': 'root' },
85
+ // Backdrop closes the menu on any outside interaction.
86
+ before: () => [
87
+ div({
88
+ 'data-scope': 'md-context',
89
+ 'data-part': 'backdrop',
90
+ onMouseDown: () => send({ type: 'close' }),
91
+ onContextMenu: (e: Event) => {
92
+ e.preventDefault()
93
+ send({ type: 'close' })
94
+ },
95
+ }),
96
+ ],
97
+ children: () => [
98
+ each(state.at('items') as Signal<MenuItem[]>, {
99
+ key: (it) => it.id,
100
+ render: (item, index) => [
101
+ div(
102
+ {
103
+ 'data-scope': 'md-context',
104
+ 'data-part': 'option',
105
+ onMouseDown: (e: MouseEvent) => {
106
+ e.preventDefault()
107
+ send({ type: 'choose', index: index.peek() })
108
+ },
109
+ },
110
+ [text(item.map((it) => it.label))],
111
+ ),
112
+ ],
113
+ }),
114
+ ],
115
+ }),
116
+ }),
117
+ }
118
+ }
@@ -0,0 +1,179 @@
1
+ // The core plugin: the GFM superset of nodes, transformers, command items, and
2
+ // shortcuts. This is the default plugin when none are supplied, so the minimal
3
+ // `markdownEditor()` one-liner still has full keyboard formatting.
4
+
5
+ import {
6
+ $createParagraphNode,
7
+ $getSelection,
8
+ $isRangeSelection,
9
+ REDO_COMMAND,
10
+ UNDO_COMMAND,
11
+ type ElementNode,
12
+ type LexicalEditor,
13
+ } from 'lexical'
14
+ import { mergeRegister } from '@lexical/utils'
15
+ import { $setBlocksType } from '@lexical/selection'
16
+ import { $createHeadingNode, $createQuoteNode, type HeadingTagType } from '@lexical/rich-text'
17
+ import { $createCodeNode } from '@lexical/code-core'
18
+ import {
19
+ INSERT_CHECK_LIST_COMMAND,
20
+ INSERT_ORDERED_LIST_COMMAND,
21
+ INSERT_UNORDERED_LIST_COMMAND,
22
+ registerCheckList,
23
+ registerList,
24
+ } from '@lexical/list'
25
+ import type { CommandContext, CommandItem, MarkdownPlugin } from './types.js'
26
+ import { inlineItems } from './inline.js'
27
+ import { GFM_NODES, GFM_TRANSFORMERS } from '../transformers/gfm.js'
28
+
29
+ const NOOP_CTX: CommandContext = { send: () => {} }
30
+
31
+ /** Wrap a node-factory into an editor action that converts the selected blocks. */
32
+ function block(create: () => ElementNode): (editor: LexicalEditor) => void {
33
+ return (editor) =>
34
+ editor.update(() => {
35
+ const selection = $getSelection()
36
+ if ($isRangeSelection(selection)) $setBlocksType(selection, create)
37
+ })
38
+ }
39
+
40
+ function heading(tag: HeadingTagType): (editor: LexicalEditor) => void {
41
+ return block(() => $createHeadingNode(tag))
42
+ }
43
+
44
+ export interface CorePluginOptions {
45
+ /** Reserved for future core options. */
46
+ readonly _?: never
47
+ }
48
+
49
+ export function corePlugin(_opts: CorePluginOptions = {}): MarkdownPlugin {
50
+ const items: CommandItem[] = [
51
+ ...inlineItems(),
52
+ {
53
+ id: 'paragraph',
54
+ label: 'Text',
55
+ icon: 'paragraph',
56
+ group: 'block',
57
+ keywords: ['body', 'normal'],
58
+ run: block(() => $createParagraphNode()),
59
+ isActive: (f) => f.blockType === 'paragraph',
60
+ },
61
+ {
62
+ id: 'h1',
63
+ label: 'Heading 1',
64
+ icon: 'h1',
65
+ group: 'block',
66
+ keywords: ['title'],
67
+ run: heading('h1'),
68
+ isActive: (f) => f.blockType === 'h1',
69
+ },
70
+ {
71
+ id: 'h2',
72
+ label: 'Heading 2',
73
+ icon: 'h2',
74
+ group: 'block',
75
+ run: heading('h2'),
76
+ isActive: (f) => f.blockType === 'h2',
77
+ },
78
+ {
79
+ id: 'h3',
80
+ label: 'Heading 3',
81
+ icon: 'h3',
82
+ group: 'block',
83
+ run: heading('h3'),
84
+ isActive: (f) => f.blockType === 'h3',
85
+ },
86
+ {
87
+ id: 'quote',
88
+ label: 'Quote',
89
+ icon: 'quote',
90
+ group: 'block',
91
+ keywords: ['blockquote'],
92
+ run: block(() => $createQuoteNode()),
93
+ isActive: (f) => f.blockType === 'quote',
94
+ },
95
+ {
96
+ id: 'codeBlock',
97
+ label: 'Code block',
98
+ icon: 'code-block',
99
+ group: 'block',
100
+ keywords: ['fence', 'pre'],
101
+ run: block(() => $createCodeNode()),
102
+ isActive: (f) => f.blockType === 'code',
103
+ },
104
+ {
105
+ id: 'bulletList',
106
+ label: 'Bulleted list',
107
+ icon: 'list-bullet',
108
+ group: 'list',
109
+ keywords: ['unordered', 'ul'],
110
+ run: (e) => e.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined),
111
+ isActive: (f) => f.blockType === 'bullet',
112
+ },
113
+ {
114
+ id: 'numberList',
115
+ label: 'Numbered list',
116
+ icon: 'list-number',
117
+ group: 'list',
118
+ keywords: ['ordered', 'ol'],
119
+ run: (e) => e.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined),
120
+ isActive: (f) => f.blockType === 'number',
121
+ },
122
+ {
123
+ id: 'checkList',
124
+ label: 'Task list',
125
+ icon: 'list-check',
126
+ group: 'list',
127
+ keywords: ['todo', 'checkbox'],
128
+ run: (e) => e.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined),
129
+ isActive: (f) => f.blockType === 'check',
130
+ },
131
+ {
132
+ id: 'undo',
133
+ label: 'Undo',
134
+ icon: 'undo',
135
+ group: 'history',
136
+ run: (e) => e.dispatchCommand(UNDO_COMMAND, undefined),
137
+ isDisabled: (f) => !f.canUndo,
138
+ surfaces: ['toolbar', 'context'],
139
+ },
140
+ {
141
+ id: 'redo',
142
+ label: 'Redo',
143
+ icon: 'redo',
144
+ group: 'history',
145
+ run: (e) => e.dispatchCommand(REDO_COMMAND, undefined),
146
+ isDisabled: (f) => !f.canRedo,
147
+ surfaces: ['toolbar', 'context'],
148
+ },
149
+ ]
150
+
151
+ const byId = new Map(items.map((i) => [i.id, i]))
152
+ const shortcut =
153
+ (id: string) =>
154
+ (editor: LexicalEditor): boolean => {
155
+ const item = byId.get(id)
156
+ if (!item) return false
157
+ item.run(editor, NOOP_CTX)
158
+ return true
159
+ }
160
+
161
+ return {
162
+ name: 'core',
163
+ nodes: GFM_NODES,
164
+ transformers: GFM_TRANSFORMERS,
165
+ items,
166
+ // registerList wires list commands/indentation; registerCheckList adds the
167
+ // click-to-toggle on task items. (Linking is handled by the editor's link
168
+ // dialog via $toggleLink — see commitLink in editor.ts.)
169
+ register: (editor) => mergeRegister(registerList(editor), registerCheckList(editor)),
170
+ shortcuts: [
171
+ { combo: 'Mod-Alt-1', run: shortcut('h1') },
172
+ { combo: 'Mod-Alt-2', run: shortcut('h2') },
173
+ { combo: 'Mod-Alt-3', run: shortcut('h3') },
174
+ { combo: 'Mod-Shift-7', run: shortcut('numberList') },
175
+ { combo: 'Mod-Shift-8', run: shortcut('bulletList') },
176
+ { combo: 'Mod-Shift-9', run: shortcut('quote') },
177
+ ],
178
+ }
179
+ }
@@ -0,0 +1,59 @@
1
+ // Emoji plugin — replace `:shortcode:` with the emoji while typing or on import,
2
+ // via a text-match transformer. Unknown shortcodes are left untouched.
3
+
4
+ import { $createTextNode, type TextNode } from 'lexical'
5
+ import type { TextMatchTransformer } from '@lexical/markdown'
6
+ import type { MarkdownPlugin } from './types.js'
7
+
8
+ /** A small default shortcode → emoji map. Extend via `emojiPlugin({ emoji })`. */
9
+ export const DEFAULT_EMOJI: Readonly<Record<string, string>> = {
10
+ smile: '😄',
11
+ grin: '😁',
12
+ joy: '😂',
13
+ wink: '😉',
14
+ heart: '❤️',
15
+ thumbsup: '👍',
16
+ '+1': '👍',
17
+ thumbsdown: '👎',
18
+ fire: '🔥',
19
+ tada: '🎉',
20
+ rocket: '🚀',
21
+ star: '⭐',
22
+ check: '✅',
23
+ x: '❌',
24
+ warning: '⚠️',
25
+ bulb: '💡',
26
+ eyes: '👀',
27
+ sparkles: '✨',
28
+ '100': '💯',
29
+ thinking: '🤔',
30
+ }
31
+
32
+ function transformer(map: Readonly<Record<string, string>>): TextMatchTransformer {
33
+ return {
34
+ dependencies: [],
35
+ // `null` keeps the emoji as a unicode character in the exported markdown.
36
+ export: () => null,
37
+ importRegExp: /:([a-z0-9_+-]+):/,
38
+ regExp: /:([a-z0-9_+-]+):$/,
39
+ trigger: ':',
40
+ replace: (node: TextNode, match: RegExpMatchArray): void => {
41
+ const emoji = map[match[1] ?? '']
42
+ if (emoji) node.replace($createTextNode(emoji))
43
+ },
44
+ type: 'text-match',
45
+ }
46
+ }
47
+
48
+ export interface EmojiPluginOptions {
49
+ /** Extra/override shortcode → emoji entries (merged over the defaults). */
50
+ emoji?: Readonly<Record<string, string>>
51
+ }
52
+
53
+ export function emojiPlugin(opts: EmojiPluginOptions = {}): MarkdownPlugin {
54
+ const map = { ...DEFAULT_EMOJI, ...(opts.emoji ?? {}) }
55
+ return {
56
+ name: 'emoji',
57
+ transformers: [transformer(map)],
58
+ }
59
+ }