@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.
- package/README.md +44 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +34 -8
- package/dist/editor.js.map +1 -1
- package/dist/effects.d.ts +5 -3
- package/dist/effects.d.ts.map +1 -1
- package/dist/effects.js +7 -5
- package/dist/effects.js.map +1 -1
- package/dist/format.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/paste.d.ts.map +1 -1
- package/dist/paste.js +5 -0
- package/dist/paste.js.map +1 -1
- package/dist/plugins/_preview.js.map +1 -1
- package/dist/plugins/callout.d.ts.map +1 -1
- package/dist/plugins/callout.js +37 -45
- package/dist/plugins/callout.js.map +1 -1
- package/dist/plugins/context-menu.js.map +1 -1
- package/dist/plugins/core.js.map +1 -1
- package/dist/plugins/emoji.js.map +1 -1
- package/dist/plugins/floating-toolbar.js.map +1 -1
- package/dist/plugins/hr.d.ts.map +1 -1
- package/dist/plugins/hr.js +4 -7
- package/dist/plugins/hr.js.map +1 -1
- package/dist/plugins/image.d.ts.map +1 -1
- package/dist/plugins/image.js +28 -20
- package/dist/plugins/image.js.map +1 -1
- package/dist/plugins/inline.js.map +1 -1
- package/dist/plugins/link.d.ts.map +1 -1
- package/dist/plugins/link.js +16 -9
- package/dist/plugins/link.js.map +1 -1
- package/dist/plugins/math.d.ts.map +1 -1
- package/dist/plugins/math.js +25 -34
- package/dist/plugins/math.js.map +1 -1
- package/dist/plugins/mention.js.map +1 -1
- package/dist/plugins/mermaid.d.ts.map +1 -1
- package/dist/plugins/mermaid.js +25 -34
- package/dist/plugins/mermaid.js.map +1 -1
- package/dist/plugins/overlay.js.map +1 -1
- package/dist/plugins/single-block.js.map +1 -1
- package/dist/plugins/slash.js.map +1 -1
- package/dist/plugins/table.d.ts.map +1 -1
- package/dist/plugins/table.js +7 -4
- package/dist/plugins/table.js.map +1 -1
- package/dist/plugins/types.js.map +1 -1
- package/dist/plugins/ui.js.map +1 -1
- package/dist/security.d.ts +26 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +87 -0
- package/dist/security.js.map +1 -0
- package/dist/state.js.map +1 -1
- package/dist/surfaces/link-dialog.js.map +1 -1
- package/dist/surfaces/toolbar.js.map +1 -1
- package/dist/theme.js.map +1 -1
- package/dist/transformers/gfm.js.map +1 -1
- package/dist/transformers/registry.js.map +1 -1
- package/package.json +23 -17
- package/src/editor.ts +339 -0
- package/src/effects.ts +53 -0
- package/src/format.ts +57 -0
- package/src/index.ts +93 -0
- package/src/paste.ts +96 -0
- package/src/plugins/_preview.ts +51 -0
- package/src/plugins/callout.ts +180 -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 +56 -0
- package/src/plugins/image.ts +240 -0
- package/src/plugins/inline.ts +76 -0
- package/src/plugins/link.ts +143 -0
- package/src/plugins/math.ts +102 -0
- package/src/plugins/mention.ts +224 -0
- package/src/plugins/mermaid.ts +115 -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 +284 -0
- package/src/plugins/types.ts +50 -0
- package/src/plugins/ui.ts +79 -0
- package/src/security.ts +89 -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,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
|
+
}
|