@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,224 @@
1
+ // Slash command menu — a `/` command palette built as a plugin. It uses both the
2
+ // engine `register` hook (to detect the `/query` trigger and drive keyboard nav
3
+ // from editor events) and the plugin-UI extension (state + the floating menu
4
+ // view + command execution). The flagship demonstration that the plugin-UI seam
5
+ // handles complex, stateful overlays.
6
+
7
+ import {
8
+ $getSelection,
9
+ $isRangeSelection,
10
+ $isTextNode,
11
+ COMMAND_PRIORITY_HIGH,
12
+ KEY_ARROW_DOWN_COMMAND,
13
+ KEY_ARROW_UP_COMMAND,
14
+ KEY_ENTER_COMMAND,
15
+ KEY_ESCAPE_COMMAND,
16
+ } from 'lexical'
17
+ import { mergeRegister } from '@lexical/utils'
18
+ import { div, each, text, type Signal } from '@llui/dom'
19
+ import { definePluginUI } from './ui.js'
20
+ import { OVERLAY_Z, hideOverlay, overlayRoot } from './overlay.js'
21
+ import type { CommandItem, MarkdownPlugin } from './types.js'
22
+
23
+ interface MenuItem {
24
+ id: string
25
+ label: string
26
+ }
27
+
28
+ /** A rendered row — the `active` flag is row-local so `each` highlights reliably. */
29
+ interface MenuRow {
30
+ id: string
31
+ label: string
32
+ active: boolean
33
+ }
34
+
35
+ interface SlashState {
36
+ open: boolean
37
+ query: string
38
+ items: MenuRow[]
39
+ index: number
40
+ x: number
41
+ y: number
42
+ }
43
+
44
+ function withActive(items: readonly MenuItem[], index: number): MenuRow[] {
45
+ return items.map((it, i) => ({ id: it.id, label: it.label, active: i === index }))
46
+ }
47
+
48
+ type SlashMsg =
49
+ | { type: 'show'; query: string; items: MenuItem[]; x: number; y: number }
50
+ | { type: 'hide' }
51
+ | { type: 'move'; delta: number }
52
+ | { type: 'choose' }
53
+ | { type: 'click'; index: number }
54
+
55
+ type SlashEffect = { type: 'run'; id: string; query: string }
56
+
57
+ const TRIGGER = /(?:^|\s)\/([\w-]*)$/
58
+
59
+ /** Read the active slash query before the collapsed caret, or null. */
60
+ function $readSlashQuery(): string | null {
61
+ const selection = $getSelection()
62
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null
63
+ const node = selection.anchor.getNode()
64
+ if (!$isTextNode(node)) return null
65
+ const before = node.getTextContent().slice(0, selection.anchor.offset)
66
+ const match = before.match(TRIGGER)
67
+ return match ? (match[1] ?? '') : null
68
+ }
69
+
70
+ function caretXY(): { x: number; y: number } {
71
+ if (typeof window === 'undefined') return { x: 0, y: 0 }
72
+ const sel = window.getSelection()
73
+ if (sel && sel.rangeCount > 0) {
74
+ const rect = sel.getRangeAt(0).getBoundingClientRect()
75
+ return { x: rect.left, y: rect.bottom + 4 }
76
+ }
77
+ return { x: 0, y: 0 }
78
+ }
79
+
80
+ function matches(item: CommandItem, query: string): boolean {
81
+ if (query === '') return true
82
+ const q = query.toLowerCase()
83
+ if (item.label.toLowerCase().includes(q) || item.id.toLowerCase().includes(q)) return true
84
+ return (item.keywords ?? []).some((k) => k.toLowerCase().includes(q))
85
+ }
86
+
87
+ export function slashPlugin(): MarkdownPlugin {
88
+ let slashItems: CommandItem[] = []
89
+
90
+ const filtered = (query: string): MenuItem[] =>
91
+ slashItems.filter((i) => matches(i, query)).map((i) => ({ id: i.id, label: i.label }))
92
+
93
+ return {
94
+ name: 'slash',
95
+ onItems: (items) => {
96
+ slashItems = items.filter((i) =>
97
+ i.surfaces
98
+ ? i.surfaces.includes('slash')
99
+ : ['block', 'list', 'insert'].includes(i.group ?? ''),
100
+ )
101
+ },
102
+ register: (editor, ctx) => {
103
+ const isActive = (): boolean => editor.getEditorState().read(() => $readSlashQuery() !== null)
104
+
105
+ const refresh = (): void => {
106
+ const query = editor.getEditorState().read(() => $readSlashQuery())
107
+ if (query === null) {
108
+ ctx.emit({ type: 'plugin', name: 'slash', msg: { type: 'hide' } })
109
+ return
110
+ }
111
+ const items = filtered(query)
112
+ const { x, y } = caretXY()
113
+ ctx.emit({ type: 'plugin', name: 'slash', msg: { type: 'show', query, items, x, y } })
114
+ }
115
+
116
+ const nav = (delta: number, e: KeyboardEvent | null): boolean => {
117
+ if (!isActive()) return false
118
+ e?.preventDefault()
119
+ ctx.emit({ type: 'plugin', name: 'slash', msg: { type: 'move', delta } })
120
+ return true
121
+ }
122
+
123
+ return mergeRegister(
124
+ editor.registerUpdateListener(() => refresh()),
125
+ editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (e) => nav(1, e), COMMAND_PRIORITY_HIGH),
126
+ editor.registerCommand(KEY_ARROW_UP_COMMAND, (e) => nav(-1, e), COMMAND_PRIORITY_HIGH),
127
+ editor.registerCommand(
128
+ KEY_ENTER_COMMAND,
129
+ (e) => {
130
+ if (!isActive()) return false
131
+ e?.preventDefault()
132
+ ctx.emit({ type: 'plugin', name: 'slash', msg: { type: 'choose' } })
133
+ return true
134
+ },
135
+ COMMAND_PRIORITY_HIGH,
136
+ ),
137
+ editor.registerCommand(
138
+ KEY_ESCAPE_COMMAND,
139
+ () => {
140
+ if (!isActive()) return false
141
+ ctx.emit({ type: 'plugin', name: 'slash', msg: { type: 'hide' } })
142
+ return true
143
+ },
144
+ COMMAND_PRIORITY_HIGH,
145
+ ),
146
+ )
147
+ },
148
+ ui: definePluginUI<SlashState, SlashMsg, SlashEffect>({
149
+ init: () => ({ open: false, query: '', items: [], index: 0, x: 0, y: 0 }),
150
+ update: (state, msg) => {
151
+ switch (msg.type) {
152
+ case 'show':
153
+ return {
154
+ open: msg.items.length > 0,
155
+ query: msg.query,
156
+ items: withActive(msg.items, 0),
157
+ index: 0,
158
+ x: msg.x,
159
+ y: msg.y,
160
+ }
161
+ case 'hide':
162
+ return hideOverlay(state)
163
+ case 'move': {
164
+ if (!state.open || state.items.length === 0) return state
165
+ const index = (state.index + msg.delta + state.items.length) % state.items.length
166
+ return { ...state, index, items: withActive(state.items, index) }
167
+ }
168
+ case 'choose': {
169
+ const item = state.items[state.index]
170
+ if (!state.open || !item) return hideOverlay(state)
171
+ return [{ ...state, open: false }, [{ type: 'run', id: item.id, query: state.query }]]
172
+ }
173
+ case 'click': {
174
+ const item = state.items[msg.index]
175
+ if (!item) return state
176
+ return [{ ...state, open: false }, [{ type: 'run', id: item.id, query: state.query }]]
177
+ }
178
+ }
179
+ },
180
+ onEffect: (effect, ctx) => {
181
+ const editor = ctx.editor()
182
+ if (!editor) return
183
+ // Remove the typed "/query" before running the command.
184
+ editor.update(() => {
185
+ const selection = $getSelection()
186
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return
187
+ const node = selection.anchor.getNode()
188
+ if (!$isTextNode(node)) return
189
+ const offset = selection.anchor.offset
190
+ const start = offset - effect.query.length - 1
191
+ if (start >= 0) node.spliceText(start, effect.query.length + 1, '', true)
192
+ })
193
+ ctx.emit({ type: 'runCommand', id: effect.id })
194
+ },
195
+ view: ({ state, send }) =>
196
+ overlayRoot({
197
+ open: state.at('open'),
198
+ x: state.at('x'),
199
+ y: state.at('y'),
200
+ zIndex: OVERLAY_Z.typeahead,
201
+ attrs: { 'data-scope': 'md-slash', 'data-part': 'root' },
202
+ children: () => [
203
+ each(state.at('items') as Signal<MenuRow[]>, {
204
+ key: (it) => it.id,
205
+ render: (item, index) => [
206
+ div(
207
+ {
208
+ 'data-scope': 'md-slash',
209
+ 'data-part': 'option',
210
+ 'data-active': item.map((it) => (it.active ? '' : undefined)),
211
+ onMouseDown: (e: MouseEvent) => {
212
+ e.preventDefault()
213
+ send({ type: 'click', index: index.peek() })
214
+ },
215
+ },
216
+ [text(item.map((it) => it.label))],
217
+ ),
218
+ ],
219
+ }),
220
+ ],
221
+ }),
222
+ }),
223
+ }
224
+ }
@@ -0,0 +1,284 @@
1
+ // Table plugin — GFM tables backed by @lexical/table nodes, with a hand-written
2
+ // multiline transformer that imports `| a | b |` syntax to a TableNode and
3
+ // exports it back. Cells are ordinary editable paragraphs.
4
+ //
5
+ // Place `tablePlugin()` before `corePlugin()` so its multiline transformer is
6
+ // tried ahead of the generic code-block one.
7
+
8
+ import {
9
+ $createParagraphNode,
10
+ $createTextNode,
11
+ $getSelection,
12
+ $isRangeSelection,
13
+ type LexicalNode,
14
+ } from 'lexical'
15
+ import { $insertNodeToNearestRoot, mergeRegister } from '@lexical/utils'
16
+ import {
17
+ $createTableCellNode,
18
+ $createTableNode,
19
+ $createTableRowNode,
20
+ $deleteTableColumnAtSelection,
21
+ $deleteTableRowAtSelection,
22
+ $getTableCellNodeFromLexicalNode,
23
+ $getTableNodeFromLexicalNodeOrThrow,
24
+ $insertTableColumnAtSelection,
25
+ $insertTableRowAtSelection,
26
+ $isTableNode,
27
+ $isTableSelection,
28
+ TableCellHeaderStates,
29
+ TableCellNode,
30
+ TableNode,
31
+ TableRowNode,
32
+ } from '@lexical/table'
33
+ import type { MultilineElementTransformer } from '@lexical/markdown'
34
+ import { button, text } from '@llui/dom'
35
+ import { definePluginUI } from './ui.js'
36
+ import { OVERLAY_Z, hideOverlay, onViewportChange, overlayRoot } from './overlay.js'
37
+ import type { MarkdownPlugin } from './types.js'
38
+
39
+ interface TableToolsState {
40
+ open: boolean
41
+ x: number
42
+ y: number
43
+ }
44
+
45
+ type TableToolsMsg =
46
+ | { type: 'show'; x: number; y: number }
47
+ | { type: 'hide' }
48
+ | { type: 'op'; id: string }
49
+ type TableToolsEffect = { type: 'run'; id: string }
50
+
51
+ const TABLE_TOOLS: ReadonlyArray<{ id: string; label: string; title: string }> = [
52
+ { id: 'rowAbove', label: '↑+', title: 'Insert row above' },
53
+ { id: 'rowBelow', label: '↓+', title: 'Insert row below' },
54
+ { id: 'colLeft', label: '←+', title: 'Insert column left' },
55
+ { id: 'colRight', label: '→+', title: 'Insert column right' },
56
+ { id: 'delRow', label: '✕R', title: 'Delete row' },
57
+ { id: 'delCol', label: '✕C', title: 'Delete column' },
58
+ { id: 'delTable', label: '🗑', title: 'Delete table' },
59
+ ]
60
+
61
+ /** Run a table editing operation against the current cell selection. */
62
+ function $runTableOp(id: string): void {
63
+ switch (id) {
64
+ case 'rowAbove':
65
+ $insertTableRowAtSelection(false)
66
+ return
67
+ case 'rowBelow':
68
+ $insertTableRowAtSelection(true)
69
+ return
70
+ case 'colLeft':
71
+ $insertTableColumnAtSelection(false)
72
+ return
73
+ case 'colRight':
74
+ $insertTableColumnAtSelection(true)
75
+ return
76
+ case 'delRow':
77
+ $deleteTableRowAtSelection()
78
+ return
79
+ case 'delCol':
80
+ $deleteTableColumnAtSelection()
81
+ return
82
+ case 'delTable': {
83
+ const selection = $getSelection()
84
+ // A focused cell is a RangeSelection; a multi-cell drag is a TableSelection.
85
+ // Both carry an anchor pointing into a cell — accept either.
86
+ if (!$isRangeSelection(selection) && !$isTableSelection(selection)) return
87
+ const cell = $getTableCellNodeFromLexicalNode(selection.anchor.getNode())
88
+ if (cell) $getTableNodeFromLexicalNodeOrThrow(cell).remove()
89
+ return
90
+ }
91
+ }
92
+ }
93
+
94
+ function splitRow(line: string): string[] {
95
+ // Strip the OUTER table pipes (always unescaped delimiters), then split on
96
+ // unescaped `|` only — an escaped `\|` is literal cell content and must NOT
97
+ // split the cell (export writes `\|` for a pipe in a cell; see TABLE_TRANSFORMER
98
+ // export). Finally unescape `\|` → `|` so the round-trip is lossless.
99
+ const inner = line
100
+ .trim()
101
+ .replace(/^\|/, '')
102
+ .replace(/(?<!\\)\|$/, '')
103
+ return inner.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, '|'))
104
+ }
105
+
106
+ function isSeparator(line: string | undefined): boolean {
107
+ return !!line && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(line) && line.includes('-')
108
+ }
109
+
110
+ function isRow(line: string | undefined): boolean {
111
+ return !!line && /^\s*\|.*\|\s*$/.test(line.trim())
112
+ }
113
+
114
+ function makeCell(textValue: string, header: boolean): TableCellNode {
115
+ const cell = $createTableCellNode(
116
+ header ? TableCellHeaderStates.COLUMN : TableCellHeaderStates.NO_STATUS,
117
+ )
118
+ cell.append($createParagraphNode().append($createTextNode(textValue)))
119
+ return cell
120
+ }
121
+
122
+ function buildTable(header: string[], body: string[][]): TableNode {
123
+ const table = $createTableNode()
124
+ const headerRow = $createTableRowNode()
125
+ for (const h of header) headerRow.append(makeCell(h, true))
126
+ table.append(headerRow)
127
+ for (const row of body) {
128
+ const tr = $createTableRowNode()
129
+ for (let i = 0; i < header.length; i++) tr.append(makeCell(row[i] ?? '', false))
130
+ table.append(tr)
131
+ }
132
+ return table
133
+ }
134
+
135
+ const TABLE_TRANSFORMER: MultilineElementTransformer = {
136
+ dependencies: [TableNode, TableRowNode, TableCellNode],
137
+ export: (node: LexicalNode): string | null => {
138
+ if (!$isTableNode(node)) return null
139
+ const rows = node.getChildren() as TableRowNode[]
140
+ if (rows.length === 0) return null
141
+ const lines: string[] = []
142
+ rows.forEach((row, ri) => {
143
+ const cells = row.getChildren() as TableCellNode[]
144
+ const texts = cells.map((c) => c.getTextContent().replace(/\|/g, '\\|').replace(/\n/g, ' '))
145
+ lines.push('| ' + texts.join(' | ') + ' |')
146
+ if (ri === 0) lines.push('| ' + texts.map(() => '---').join(' | ') + ' |')
147
+ })
148
+ return lines.join('\n')
149
+ },
150
+ regExpStart: /^\s*\|(.+)\|\s*$/,
151
+ handleImportAfterStartMatch: ({ lines, rootNode, startLineIndex }) => {
152
+ if (!isSeparator(lines[startLineIndex + 1])) return null // header must be followed by a separator
153
+ let end = startLineIndex + 2
154
+ const body: string[][] = []
155
+ while (end < lines.length && isRow(lines[end]) && !isSeparator(lines[end])) {
156
+ body.push(splitRow(lines[end]!))
157
+ end++
158
+ }
159
+ rootNode.append(buildTable(splitRow(lines[startLineIndex]!), body))
160
+ return [true, end - 1]
161
+ },
162
+ // Import is handled manually above; a multiline transformer still needs replace.
163
+ replace: () => false,
164
+ type: 'multiline-element',
165
+ }
166
+
167
+ export function tablePlugin(): MarkdownPlugin {
168
+ return {
169
+ name: 'table',
170
+ nodes: [TableNode, TableRowNode, TableCellNode],
171
+ transformers: [TABLE_TRANSFORMER],
172
+ // A contextual toolbar appears above the table whenever the selection is in a
173
+ // cell. It tracks the table's viewport rect, so it repositions on scroll/resize
174
+ // (not just on editor updates) and only re-emits when the rect actually moves.
175
+ register: (editor, ctx) => {
176
+ let lastKey: string | null = null
177
+ let lastX = NaN
178
+ let lastY = NaN
179
+ const refresh = (): void => {
180
+ const tableKey = editor.getEditorState().read(() => {
181
+ const selection = $getSelection()
182
+ // RangeSelection = caret in a cell; TableSelection = multi-cell drag.
183
+ if (!$isRangeSelection(selection) && !$isTableSelection(selection)) return null
184
+ const cell = $getTableCellNodeFromLexicalNode(selection.anchor.getNode())
185
+ return cell ? $getTableNodeFromLexicalNodeOrThrow(cell).getKey() : null
186
+ })
187
+ const el = tableKey ? editor.getElementByKey(tableKey) : null
188
+ if (!el) {
189
+ if (lastKey !== null) {
190
+ lastKey = null
191
+ lastX = NaN
192
+ lastY = NaN
193
+ ctx.emit({ type: 'plugin', name: 'table', msg: { type: 'hide' } })
194
+ }
195
+ return
196
+ }
197
+ const rect = el.getBoundingClientRect()
198
+ // Typing inside a cell never moves the table's top-left; skip the redundant
199
+ // emit so the overlay isn't reconciled on every keystroke.
200
+ if (tableKey === lastKey && rect.left === lastX && rect.top === lastY) return
201
+ lastKey = tableKey
202
+ lastX = rect.left
203
+ lastY = rect.top
204
+ ctx.emit({
205
+ type: 'plugin',
206
+ name: 'table',
207
+ msg: { type: 'show', x: rect.left, y: rect.top },
208
+ })
209
+ }
210
+ return mergeRegister(
211
+ editor.registerUpdateListener(() => refresh()),
212
+ onViewportChange(refresh),
213
+ )
214
+ },
215
+ ui: definePluginUI<TableToolsState, TableToolsMsg, TableToolsEffect>({
216
+ init: () => ({ open: false, x: 0, y: 0 }),
217
+ update: (state, msg) => {
218
+ switch (msg.type) {
219
+ case 'show':
220
+ return state.open && state.x === msg.x && state.y === msg.y
221
+ ? state
222
+ : { open: true, x: msg.x, y: msg.y }
223
+ case 'hide':
224
+ return hideOverlay(state)
225
+ case 'op':
226
+ return [state, [{ type: 'run', id: msg.id }]]
227
+ }
228
+ },
229
+ onEffect: (effect, ctx) => {
230
+ const editor = ctx.editor()
231
+ if (editor) editor.update(() => $runTableOp(effect.id))
232
+ },
233
+ view: ({ state, send }) =>
234
+ overlayRoot({
235
+ open: state.at('open'),
236
+ x: state.at('x'),
237
+ y: state.at('y'),
238
+ zIndex: OVERLAY_Z.tableTools,
239
+ // Lift the bar above the table's top edge.
240
+ transform: 'transform:translateY(-118%)',
241
+ attrs: { 'data-scope': 'md-table-tools', 'data-part': 'bar' },
242
+ children: () =>
243
+ TABLE_TOOLS.map((tool) =>
244
+ button(
245
+ {
246
+ type: 'button',
247
+ 'data-scope': 'md-table-tools',
248
+ 'data-part': 'tool',
249
+ title: tool.title,
250
+ 'aria-label': tool.title,
251
+ onMouseDown: (e: MouseEvent) => {
252
+ e.preventDefault()
253
+ send({ type: 'op', id: tool.id })
254
+ },
255
+ },
256
+ [text(tool.label)],
257
+ ),
258
+ ),
259
+ }),
260
+ }),
261
+ items: [
262
+ {
263
+ id: 'table',
264
+ label: 'Table',
265
+ icon: 'table',
266
+ group: 'insert',
267
+ keywords: ['grid', 'rows', 'columns'],
268
+ run: (editor) =>
269
+ editor.update(() =>
270
+ $insertNodeToNearestRoot(
271
+ buildTable(
272
+ ['Column 1', 'Column 2'],
273
+ [
274
+ ['', ''],
275
+ ['', ''],
276
+ ],
277
+ ),
278
+ ),
279
+ ),
280
+ surfaces: ['toolbar', 'slash', 'context'],
281
+ },
282
+ ],
283
+ }
284
+ }
@@ -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
+ }