@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.
Files changed (89) hide show
  1. package/README.md +44 -0
  2. package/dist/editor.d.ts.map +1 -1
  3. package/dist/editor.js +34 -8
  4. package/dist/editor.js.map +1 -1
  5. package/dist/effects.d.ts +5 -3
  6. package/dist/effects.d.ts.map +1 -1
  7. package/dist/effects.js +7 -5
  8. package/dist/effects.js.map +1 -1
  9. package/dist/format.js.map +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/paste.d.ts.map +1 -1
  12. package/dist/paste.js +5 -0
  13. package/dist/paste.js.map +1 -1
  14. package/dist/plugins/_preview.js.map +1 -1
  15. package/dist/plugins/callout.d.ts.map +1 -1
  16. package/dist/plugins/callout.js +37 -45
  17. package/dist/plugins/callout.js.map +1 -1
  18. package/dist/plugins/context-menu.js.map +1 -1
  19. package/dist/plugins/core.js.map +1 -1
  20. package/dist/plugins/emoji.js.map +1 -1
  21. package/dist/plugins/floating-toolbar.js.map +1 -1
  22. package/dist/plugins/hr.d.ts.map +1 -1
  23. package/dist/plugins/hr.js +4 -7
  24. package/dist/plugins/hr.js.map +1 -1
  25. package/dist/plugins/image.d.ts.map +1 -1
  26. package/dist/plugins/image.js +28 -20
  27. package/dist/plugins/image.js.map +1 -1
  28. package/dist/plugins/inline.js.map +1 -1
  29. package/dist/plugins/link.d.ts.map +1 -1
  30. package/dist/plugins/link.js +16 -9
  31. package/dist/plugins/link.js.map +1 -1
  32. package/dist/plugins/math.d.ts.map +1 -1
  33. package/dist/plugins/math.js +25 -34
  34. package/dist/plugins/math.js.map +1 -1
  35. package/dist/plugins/mention.js.map +1 -1
  36. package/dist/plugins/mermaid.d.ts.map +1 -1
  37. package/dist/plugins/mermaid.js +25 -34
  38. package/dist/plugins/mermaid.js.map +1 -1
  39. package/dist/plugins/overlay.js.map +1 -1
  40. package/dist/plugins/single-block.js.map +1 -1
  41. package/dist/plugins/slash.js.map +1 -1
  42. package/dist/plugins/table.d.ts.map +1 -1
  43. package/dist/plugins/table.js +7 -4
  44. package/dist/plugins/table.js.map +1 -1
  45. package/dist/plugins/types.js.map +1 -1
  46. package/dist/plugins/ui.js.map +1 -1
  47. package/dist/security.d.ts +26 -0
  48. package/dist/security.d.ts.map +1 -0
  49. package/dist/security.js +87 -0
  50. package/dist/security.js.map +1 -0
  51. package/dist/state.js.map +1 -1
  52. package/dist/surfaces/link-dialog.js.map +1 -1
  53. package/dist/surfaces/toolbar.js.map +1 -1
  54. package/dist/theme.js.map +1 -1
  55. package/dist/transformers/gfm.js.map +1 -1
  56. package/dist/transformers/registry.js.map +1 -1
  57. package/package.json +23 -17
  58. package/src/editor.ts +339 -0
  59. package/src/effects.ts +53 -0
  60. package/src/format.ts +57 -0
  61. package/src/index.ts +93 -0
  62. package/src/paste.ts +96 -0
  63. package/src/plugins/_preview.ts +51 -0
  64. package/src/plugins/callout.ts +180 -0
  65. package/src/plugins/context-menu.ts +118 -0
  66. package/src/plugins/core.ts +179 -0
  67. package/src/plugins/emoji.ts +59 -0
  68. package/src/plugins/floating-toolbar.ts +180 -0
  69. package/src/plugins/hr.ts +56 -0
  70. package/src/plugins/image.ts +240 -0
  71. package/src/plugins/inline.ts +76 -0
  72. package/src/plugins/link.ts +143 -0
  73. package/src/plugins/math.ts +102 -0
  74. package/src/plugins/mention.ts +224 -0
  75. package/src/plugins/mermaid.ts +115 -0
  76. package/src/plugins/overlay.ts +138 -0
  77. package/src/plugins/single-block.ts +193 -0
  78. package/src/plugins/slash.ts +224 -0
  79. package/src/plugins/table.ts +284 -0
  80. package/src/plugins/types.ts +50 -0
  81. package/src/plugins/ui.ts +79 -0
  82. package/src/security.ts +89 -0
  83. package/src/state.ts +220 -0
  84. package/src/styles/editor.css +554 -0
  85. package/src/surfaces/link-dialog.ts +67 -0
  86. package/src/surfaces/toolbar.ts +246 -0
  87. package/src/theme.ts +42 -0
  88. package/src/transformers/gfm.ts +69 -0
  89. package/src/transformers/registry.ts +47 -0
@@ -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
+ }
@@ -0,0 +1,180 @@
1
+ // Floating selection toolbar — a bubble of inline-format actions that appears
2
+ // above a non-collapsed text selection. A plugin-UI overlay: `register` watches
3
+ // selection changes and positions/fills the bar; clicking a button runs the
4
+ // command on the still-live selection.
5
+
6
+ import { $getSelection, $isRangeSelection } from 'lexical'
7
+ import { $findMatchingParent, mergeRegister } from '@lexical/utils'
8
+ import { $isLinkNode } from '@lexical/link'
9
+ import { button, each, span, text, unsafeHtml, type Signal } from '@llui/dom'
10
+ import { definePluginUI } from './ui.js'
11
+ import { OVERLAY_Z, hideOverlay, onViewportChange, overlayRoot } from './overlay.js'
12
+ import { DEFAULT_GLYPHS } from '../surfaces/toolbar.js'
13
+ import type { CommandItem, MarkdownPlugin } from './types.js'
14
+
15
+ interface BarItem {
16
+ id: string
17
+ label: string
18
+ glyph: string
19
+ active: boolean
20
+ }
21
+
22
+ interface FloatState {
23
+ open: boolean
24
+ x: number
25
+ y: number
26
+ items: BarItem[]
27
+ }
28
+
29
+ type FloatMsg =
30
+ | { type: 'show'; x: number; y: number; items: BarItem[] }
31
+ | { type: 'hide' }
32
+ | { type: 'run'; index: number }
33
+
34
+ type FloatEffect = { type: 'run'; id: string }
35
+
36
+ interface InlineFormat {
37
+ bold: boolean
38
+ italic: boolean
39
+ strikethrough: boolean
40
+ code: boolean
41
+ link: boolean
42
+ }
43
+
44
+ function readFormat(editor: import('lexical').LexicalEditor): InlineFormat {
45
+ return editor.getEditorState().read(() => {
46
+ const selection = $getSelection()
47
+ if (!$isRangeSelection(selection)) {
48
+ return { bold: false, italic: false, strikethrough: false, code: false, link: false }
49
+ }
50
+ return {
51
+ bold: selection.hasFormat('bold'),
52
+ italic: selection.hasFormat('italic'),
53
+ strikethrough: selection.hasFormat('strikethrough'),
54
+ code: selection.hasFormat('code'),
55
+ link: $findMatchingParent(selection.anchor.getNode(), (n) => $isLinkNode(n)) !== null,
56
+ }
57
+ })
58
+ }
59
+
60
+ function activeFor(id: string, fmt: InlineFormat): boolean {
61
+ switch (id) {
62
+ case 'bold':
63
+ return fmt.bold
64
+ case 'italic':
65
+ return fmt.italic
66
+ case 'strikethrough':
67
+ return fmt.strikethrough
68
+ case 'code':
69
+ return fmt.code
70
+ case 'link':
71
+ return fmt.link
72
+ default:
73
+ return false
74
+ }
75
+ }
76
+
77
+ export function floatingToolbarPlugin(): MarkdownPlugin {
78
+ let floatingItems: CommandItem[] = []
79
+
80
+ return {
81
+ name: 'floatingToolbar',
82
+ onItems: (items) => {
83
+ floatingItems = items.filter((i) =>
84
+ i.surfaces ? i.surfaces.includes('floating') : i.group === 'inline',
85
+ )
86
+ },
87
+ register: (editor, ctx) => {
88
+ const refresh = (): void => {
89
+ const collapsed = editor.getEditorState().read(() => {
90
+ const s = $getSelection()
91
+ return !$isRangeSelection(s) || s.isCollapsed()
92
+ })
93
+ const dom = typeof window !== 'undefined' ? window.getSelection() : null
94
+ if (collapsed || !dom || dom.rangeCount === 0) {
95
+ ctx.emit({ type: 'plugin', name: 'floatingToolbar', msg: { type: 'hide' } })
96
+ return
97
+ }
98
+ const rect = dom.getRangeAt(0).getBoundingClientRect()
99
+ if (rect.width === 0 && rect.height === 0) {
100
+ ctx.emit({ type: 'plugin', name: 'floatingToolbar', msg: { type: 'hide' } })
101
+ return
102
+ }
103
+ const fmt = readFormat(editor)
104
+ const items: BarItem[] = floatingItems.map((i) => ({
105
+ id: i.id,
106
+ label: i.label,
107
+ glyph: DEFAULT_GLYPHS[i.id] ?? i.label,
108
+ active: activeFor(i.id, fmt),
109
+ }))
110
+ ctx.emit({
111
+ type: 'plugin',
112
+ name: 'floatingToolbar',
113
+ msg: { type: 'show', x: rect.left + rect.width / 2, y: rect.top, items },
114
+ })
115
+ }
116
+ return mergeRegister(
117
+ editor.registerUpdateListener(() => refresh()),
118
+ // Keep the bubble glued to the selection while the page scrolls.
119
+ onViewportChange(refresh),
120
+ )
121
+ },
122
+ ui: definePluginUI<FloatState, FloatMsg, FloatEffect>({
123
+ init: () => ({ open: false, x: 0, y: 0, items: [] }),
124
+ update: (state, msg) => {
125
+ switch (msg.type) {
126
+ case 'show':
127
+ return { open: msg.items.length > 0, x: msg.x, y: msg.y, items: msg.items }
128
+ case 'hide':
129
+ return hideOverlay(state)
130
+ case 'run': {
131
+ const item = state.items[msg.index]
132
+ return item ? [state, [{ type: 'run', id: item.id }]] : state
133
+ }
134
+ }
135
+ },
136
+ onEffect: (effect, ctx) => {
137
+ ctx.emit({ type: 'runCommand', id: effect.id })
138
+ },
139
+ // `x` is the selection's horizontal centre; the transform centres the bar on
140
+ // it and lifts it above the selection.
141
+ view: ({ state, send }) =>
142
+ overlayRoot({
143
+ open: state.at('open'),
144
+ x: state.at('x'),
145
+ y: state.at('y'),
146
+ zIndex: OVERLAY_Z.floatingToolbar,
147
+ transform: 'transform:translate(-50%,-115%)',
148
+ attrs: { 'data-scope': 'md-floating', 'data-part': 'bar' },
149
+ children: () => [
150
+ each(state.at('items') as Signal<BarItem[]>, {
151
+ key: (it) => it.id,
152
+ render: (item, index) => [
153
+ button(
154
+ {
155
+ type: 'button',
156
+ 'data-scope': 'md-floating',
157
+ 'data-part': 'item',
158
+ 'data-active': item.map((it) => (it.active ? '' : undefined)),
159
+ 'aria-label': item.map((it) => it.label),
160
+ onMouseDown: (e: MouseEvent) => {
161
+ e.preventDefault()
162
+ send({ type: 'run', index: index.peek() })
163
+ },
164
+ },
165
+ [span({ 'data-part': 'glyph', 'aria-hidden': 'true' }, [renderGlyph(item)])],
166
+ ),
167
+ ],
168
+ }),
169
+ ],
170
+ }),
171
+ }),
172
+ }
173
+ }
174
+
175
+ /** Render an item's glyph (SVG markup → unsafeHtml, otherwise text). */
176
+ function renderGlyph(item: Signal<BarItem>): import('@llui/dom').Mountable {
177
+ // The glyph value is stable per row; reading once is fine.
178
+ const glyph = item.peek().glyph
179
+ return glyph.trimStart().startsWith('<svg') ? unsafeHtml(glyph) : text(item.map((it) => it.glyph))
180
+ }
@@ -0,0 +1,56 @@
1
+ // Horizontal-rule plugin — a thematic break rendered as an `<hr>` via the
2
+ // decorator bridge, round-tripping to `---` markdown.
3
+
4
+ import { type ElementNode, type LexicalNode } from 'lexical'
5
+ import { $insertNodeToNearestRoot } from '@lexical/utils'
6
+ import type { ElementTransformer } from '@lexical/markdown'
7
+ import {
8
+ $createLLuiDecoratorNode,
9
+ $isLLuiDecoratorNode,
10
+ LLuiDecoratorNode,
11
+ decoratorBridge,
12
+ } from '@llui/lexical'
13
+ import { hr } from '@llui/dom'
14
+ import type { MarkdownPlugin } from './types.js'
15
+
16
+ const BRIDGE_TYPE = 'hr'
17
+
18
+ const hrBridge = decoratorBridge<Record<string, never>>(BRIDGE_TYPE, () => [
19
+ hr({ 'data-md-hr': '', contenteditable: 'false' }),
20
+ ])
21
+
22
+ const HR_TRANSFORMER: ElementTransformer = {
23
+ dependencies: [LLuiDecoratorNode],
24
+ export: (node: LexicalNode): string | null =>
25
+ $isLLuiDecoratorNode(node) && node.getBridgeType() === BRIDGE_TYPE ? '---' : null,
26
+ regExp: /^(---|\*\*\*|___)\s*$/,
27
+ replace: (parentNode: ElementNode): void => {
28
+ parentNode.replace($createLLuiDecoratorNode(BRIDGE_TYPE, {}))
29
+ },
30
+ type: 'element',
31
+ }
32
+
33
+ /** Insert a horizontal rule at the current selection. */
34
+ export function $insertHorizontalRule(): void {
35
+ $insertNodeToNearestRoot($createLLuiDecoratorNode(BRIDGE_TYPE, {}))
36
+ }
37
+
38
+ export function hrPlugin(): MarkdownPlugin {
39
+ return {
40
+ name: 'hr',
41
+ nodes: [LLuiDecoratorNode],
42
+ decorators: [hrBridge],
43
+ transformers: [HR_TRANSFORMER],
44
+ items: [
45
+ {
46
+ id: 'horizontalRule',
47
+ label: 'Divider',
48
+ icon: 'hr',
49
+ group: 'insert',
50
+ keywords: ['rule', 'divider', 'separator', 'hr'],
51
+ run: (editor) => editor.update(() => $insertHorizontalRule()),
52
+ surfaces: ['toolbar', 'slash', 'context'],
53
+ },
54
+ ],
55
+ }
56
+ }