@llui/markdown-editor 0.2.11 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ // The single-block plugin: constrains the editor to exactly ONE paragraph that
2
+ // holds only inline content (bold / italic / strikethrough / code, optionally
3
+ // links). No headings, lists, quotes, code blocks, or multiple paragraphs.
4
+ //
5
+ // It enforces the invariant two ways, so it holds no matter how content arrives
6
+ // (typing, the markdown seed, or a paste):
7
+ // 1. The transformer set contributes ONLY inline text-format transformers, so
8
+ // block markdown (`# `, `- `, `> `, fenced code) is never parsed into block
9
+ // nodes — `registerMarkdownShortcuts` and `$convertFromMarkdownString` both
10
+ // read from this set.
11
+ // 2. A RootNode transform coalesces the document back to a single paragraph of
12
+ // inline leaves whenever anything slips through (a multi-paragraph paste, a
13
+ // multi-line seed). Enter is intercepted so it never splits the paragraph.
14
+ //
15
+ // `singleBlockPlugin()` REPLACES the default plugin set — pass it instead of
16
+ // `corePlugin()`. A dev-only guard warns when block-contributing plugins are
17
+ // composed alongside it (since a plugin is additive and can't un-register them).
18
+
19
+ import {
20
+ $createLineBreakNode,
21
+ $createParagraphNode,
22
+ $createTextNode,
23
+ $getSelection,
24
+ $isElementNode,
25
+ $isLineBreakNode,
26
+ $isParagraphNode,
27
+ $isRangeSelection,
28
+ $isTextNode,
29
+ COMMAND_PRIORITY_HIGH,
30
+ KEY_ENTER_COMMAND,
31
+ RootNode,
32
+ type ElementNode,
33
+ type LexicalNode,
34
+ type ParagraphNode,
35
+ } from 'lexical'
36
+ import { mergeRegister } from '@lexical/utils'
37
+ import { LinkNode } from '@lexical/link'
38
+ import { LINK, type Transformer } from '@lexical/markdown'
39
+ import type { CommandItem, MarkdownPlugin } from './types.js'
40
+ import { inlineItems, type InlineFormat } from './inline.js'
41
+ import { INLINE_TEXT_TRANSFORMERS } from '../transformers/gfm.js'
42
+
43
+ export type { InlineFormat }
44
+
45
+ // `import.meta.env.DEV` is substituted by Vite/Rollup at build time; raw tsc /
46
+ // vitest see it as undefined, so the dev guard stays off there unless the
47
+ // bundler sets it. (Augmented locally so the package carries no build-tool dep.)
48
+ declare global {
49
+ interface ImportMeta {
50
+ env?: { DEV?: boolean; MODE?: string }
51
+ }
52
+ }
53
+
54
+ export interface SingleBlockPluginOptions {
55
+ /** Inline formats surfaced as toolbar items.
56
+ * Default `['bold', 'italic', 'strikethrough', 'code']`. NOTE: this limits the
57
+ * toolbar buttons only — markdown syntax (`*x*`) and Ctrl/⌘ shortcuts still
58
+ * apply every inline format, and all inline markdown round-trips regardless. */
59
+ formats?: readonly InlineFormat[]
60
+ /** Allow soft line breaks within the single paragraph. When `false` (default)
61
+ * Enter is inert and pasted/seeded line breaks collapse to spaces — a strict
62
+ * single-line field. When `true`, Enter inserts a `\n` and merged lines are
63
+ * joined with a line break instead of a space. A new paragraph is never made. */
64
+ allowLineBreaks?: boolean
65
+ /** Register `LinkNode` + the markdown link transformer so inline links
66
+ * round-trip. Default `false`. Compose with `linkPlugin()` for the toolbar
67
+ * button + insert dialog. */
68
+ link?: boolean
69
+ }
70
+
71
+ /** Is this node an inline leaf that belongs directly inside a paragraph? */
72
+ function isInlineLeaf(node: LexicalNode): boolean {
73
+ return $isTextNode(node) || $isLineBreakNode(node) || ($isElementNode(node) && node.isInline())
74
+ }
75
+
76
+ /** Decompose a block element into "lines" — each line is the inline content of
77
+ * one leaf block (paragraph, heading, list item, …). Recurses through nested
78
+ * block containers (a list → its items) so every leaf block becomes its own
79
+ * line; this is what lets adjacent list items be separator-joined rather than
80
+ * mashed together. */
81
+ function blockLines(node: ElementNode): LexicalNode[][] {
82
+ const children = node.getChildren()
83
+ const hasBlockChildren = children.some((c) => $isElementNode(c) && !c.isInline())
84
+ if (!hasBlockChildren) {
85
+ return [children.filter(isInlineLeaf)]
86
+ }
87
+ const lines: LexicalNode[][] = []
88
+ for (const child of children) {
89
+ if ($isElementNode(child) && !child.isInline()) lines.push(...blockLines(child))
90
+ else if (isInlineLeaf(child)) lines.push([child])
91
+ }
92
+ return lines
93
+ }
94
+
95
+ /** Collapse every line break in a paragraph to a single space (single-line
96
+ * mode) so adjacent lines don't mash into one word. */
97
+ function collapseLineBreaks(para: ParagraphNode): void {
98
+ for (const child of para.getChildren()) {
99
+ if ($isLineBreakNode(child)) child.replace($createTextNode(' '))
100
+ }
101
+ }
102
+
103
+ /** Coalesce the document to exactly one paragraph of inline content. Returns the
104
+ * `RootNode` transform the plugin registers. */
105
+ function makeEnforce(allowLineBreaks: boolean): (root: RootNode) => void {
106
+ return (root) => {
107
+ const children = root.getChildren()
108
+
109
+ // Fast path: already a single paragraph — only fix stray line breaks.
110
+ if (children.length === 1 && $isParagraphNode(children[0])) {
111
+ if (!allowLineBreaks) collapseLineBreaks(children[0])
112
+ return
113
+ }
114
+
115
+ // Gather the inline content of every leaf block as a separate line.
116
+ const lines: LexicalNode[][] = []
117
+ for (const child of children) {
118
+ if ($isElementNode(child)) {
119
+ if (child.isInline()) lines.push([child])
120
+ else lines.push(...blockLines(child))
121
+ } else if (isInlineLeaf(child)) {
122
+ lines.push([child])
123
+ }
124
+ }
125
+
126
+ const para = $createParagraphNode()
127
+ let first = true
128
+ for (const line of lines) {
129
+ if (line.length === 0) continue
130
+ if (!first) para.append(allowLineBreaks ? $createLineBreakNode() : $createTextNode(' '))
131
+ for (const node of line) para.append(node)
132
+ first = false
133
+ }
134
+ if (!allowLineBreaks) collapseLineBreaks(para)
135
+ root.clear()
136
+ root.append(para)
137
+ }
138
+ }
139
+
140
+ export function singleBlockPlugin(opts: SingleBlockPluginOptions = {}): MarkdownPlugin {
141
+ const allowLineBreaks = opts.allowLineBreaks ?? false
142
+ const linkEnabled = opts.link ?? false
143
+
144
+ const items: CommandItem[] = inlineItems(opts.formats)
145
+
146
+ const transformers: Transformer[] = [...INLINE_TEXT_TRANSFORMERS]
147
+ if (linkEnabled) transformers.push(LINK)
148
+
149
+ const enforce = makeEnforce(allowLineBreaks)
150
+
151
+ const onEnter = (event: KeyboardEvent | null): boolean => {
152
+ if (!allowLineBreaks) {
153
+ // Inert: never split the paragraph, never insert a break.
154
+ event?.preventDefault()
155
+ return true
156
+ }
157
+ // Convert a plain Enter (which would split the paragraph) into a soft line
158
+ // break; let Shift+Enter fall through to the default soft-break handler.
159
+ if (event && event.shiftKey) return false
160
+ event?.preventDefault()
161
+ const selection = $getSelection()
162
+ if ($isRangeSelection(selection)) selection.insertLineBreak()
163
+ return true
164
+ }
165
+
166
+ return {
167
+ name: 'single-block',
168
+ ...(linkEnabled ? { nodes: [LinkNode] } : {}),
169
+ transformers,
170
+ items,
171
+ // Dev guard: single-block REPLACES the default plugin set, but a plugin is
172
+ // additive and can't un-register a sibling's block contributions. If another
173
+ // plugin's command items introduce block/structural content, warn loudly so
174
+ // the silently-broken `[corePlugin(), singleBlockPlugin()]` combo is caught.
175
+ onItems: (all) => {
176
+ if (import.meta.env?.DEV !== true) return
177
+ const structural = all.filter((i) => i.group && i.group !== 'inline' && i.group !== 'history')
178
+ if (structural.length === 0) return
179
+ const ids = structural.map((i) => i.id).join(', ')
180
+ console.warn(
181
+ `[llui] singleBlockPlugin: other plugins contribute block/structural commands (${ids}). ` +
182
+ `single-block REPLACES the default plugin set — pass it INSTEAD OF corePlugin (and any ` +
183
+ `block/insert plugins). Block content may otherwise reappear and the single-paragraph ` +
184
+ `constraint won't hold.`,
185
+ )
186
+ },
187
+ register: (editor) =>
188
+ mergeRegister(
189
+ editor.registerNodeTransform(RootNode, enforce),
190
+ editor.registerCommand(KEY_ENTER_COMMAND, onEnter, COMMAND_PRIORITY_HIGH),
191
+ ),
192
+ }
193
+ }
@@ -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,281 @@
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
+ return line
96
+ .trim()
97
+ .replace(/^\|/, '')
98
+ .replace(/\|$/, '')
99
+ .split('|')
100
+ .map((c) => c.trim().replace(/\\\|/g, '|'))
101
+ }
102
+
103
+ function isSeparator(line: string | undefined): boolean {
104
+ return !!line && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(line) && line.includes('-')
105
+ }
106
+
107
+ function isRow(line: string | undefined): boolean {
108
+ return !!line && /^\s*\|.*\|\s*$/.test(line.trim())
109
+ }
110
+
111
+ function makeCell(textValue: string, header: boolean): TableCellNode {
112
+ const cell = $createTableCellNode(
113
+ header ? TableCellHeaderStates.COLUMN : TableCellHeaderStates.NO_STATUS,
114
+ )
115
+ cell.append($createParagraphNode().append($createTextNode(textValue)))
116
+ return cell
117
+ }
118
+
119
+ function buildTable(header: string[], body: string[][]): TableNode {
120
+ const table = $createTableNode()
121
+ const headerRow = $createTableRowNode()
122
+ for (const h of header) headerRow.append(makeCell(h, true))
123
+ table.append(headerRow)
124
+ for (const row of body) {
125
+ const tr = $createTableRowNode()
126
+ for (let i = 0; i < header.length; i++) tr.append(makeCell(row[i] ?? '', false))
127
+ table.append(tr)
128
+ }
129
+ return table
130
+ }
131
+
132
+ const TABLE_TRANSFORMER: MultilineElementTransformer = {
133
+ dependencies: [TableNode, TableRowNode, TableCellNode],
134
+ export: (node: LexicalNode): string | null => {
135
+ if (!$isTableNode(node)) return null
136
+ const rows = node.getChildren() as TableRowNode[]
137
+ if (rows.length === 0) return null
138
+ const lines: string[] = []
139
+ rows.forEach((row, ri) => {
140
+ const cells = row.getChildren() as TableCellNode[]
141
+ const texts = cells.map((c) => c.getTextContent().replace(/\|/g, '\\|').replace(/\n/g, ' '))
142
+ lines.push('| ' + texts.join(' | ') + ' |')
143
+ if (ri === 0) lines.push('| ' + texts.map(() => '---').join(' | ') + ' |')
144
+ })
145
+ return lines.join('\n')
146
+ },
147
+ regExpStart: /^\s*\|(.+)\|\s*$/,
148
+ handleImportAfterStartMatch: ({ lines, rootNode, startLineIndex }) => {
149
+ if (!isSeparator(lines[startLineIndex + 1])) return null // header must be followed by a separator
150
+ let end = startLineIndex + 2
151
+ const body: string[][] = []
152
+ while (end < lines.length && isRow(lines[end]) && !isSeparator(lines[end])) {
153
+ body.push(splitRow(lines[end]!))
154
+ end++
155
+ }
156
+ rootNode.append(buildTable(splitRow(lines[startLineIndex]!), body))
157
+ return [true, end - 1]
158
+ },
159
+ // Import is handled manually above; a multiline transformer still needs replace.
160
+ replace: () => false,
161
+ type: 'multiline-element',
162
+ }
163
+
164
+ export function tablePlugin(): MarkdownPlugin {
165
+ return {
166
+ name: 'table',
167
+ nodes: [TableNode, TableRowNode, TableCellNode],
168
+ transformers: [TABLE_TRANSFORMER],
169
+ // A contextual toolbar appears above the table whenever the selection is in a
170
+ // cell. It tracks the table's viewport rect, so it repositions on scroll/resize
171
+ // (not just on editor updates) and only re-emits when the rect actually moves.
172
+ register: (editor, ctx) => {
173
+ let lastKey: string | null = null
174
+ let lastX = NaN
175
+ let lastY = NaN
176
+ const refresh = (): void => {
177
+ const tableKey = editor.getEditorState().read(() => {
178
+ const selection = $getSelection()
179
+ // RangeSelection = caret in a cell; TableSelection = multi-cell drag.
180
+ if (!$isRangeSelection(selection) && !$isTableSelection(selection)) return null
181
+ const cell = $getTableCellNodeFromLexicalNode(selection.anchor.getNode())
182
+ return cell ? $getTableNodeFromLexicalNodeOrThrow(cell).getKey() : null
183
+ })
184
+ const el = tableKey ? editor.getElementByKey(tableKey) : null
185
+ if (!el) {
186
+ if (lastKey !== null) {
187
+ lastKey = null
188
+ lastX = NaN
189
+ lastY = NaN
190
+ ctx.emit({ type: 'plugin', name: 'table', msg: { type: 'hide' } })
191
+ }
192
+ return
193
+ }
194
+ const rect = el.getBoundingClientRect()
195
+ // Typing inside a cell never moves the table's top-left; skip the redundant
196
+ // emit so the overlay isn't reconciled on every keystroke.
197
+ if (tableKey === lastKey && rect.left === lastX && rect.top === lastY) return
198
+ lastKey = tableKey
199
+ lastX = rect.left
200
+ lastY = rect.top
201
+ ctx.emit({
202
+ type: 'plugin',
203
+ name: 'table',
204
+ msg: { type: 'show', x: rect.left, y: rect.top },
205
+ })
206
+ }
207
+ return mergeRegister(
208
+ editor.registerUpdateListener(() => refresh()),
209
+ onViewportChange(refresh),
210
+ )
211
+ },
212
+ ui: definePluginUI<TableToolsState, TableToolsMsg, TableToolsEffect>({
213
+ init: () => ({ open: false, x: 0, y: 0 }),
214
+ update: (state, msg) => {
215
+ switch (msg.type) {
216
+ case 'show':
217
+ return state.open && state.x === msg.x && state.y === msg.y
218
+ ? state
219
+ : { open: true, x: msg.x, y: msg.y }
220
+ case 'hide':
221
+ return hideOverlay(state)
222
+ case 'op':
223
+ return [state, [{ type: 'run', id: msg.id }]]
224
+ }
225
+ },
226
+ onEffect: (effect, ctx) => {
227
+ const editor = ctx.editor()
228
+ if (editor) editor.update(() => $runTableOp(effect.id))
229
+ },
230
+ view: ({ state, send }) =>
231
+ overlayRoot({
232
+ open: state.at('open'),
233
+ x: state.at('x'),
234
+ y: state.at('y'),
235
+ zIndex: OVERLAY_Z.tableTools,
236
+ // Lift the bar above the table's top edge.
237
+ transform: 'transform:translateY(-118%)',
238
+ attrs: { 'data-scope': 'md-table-tools', 'data-part': 'bar' },
239
+ children: () =>
240
+ TABLE_TOOLS.map((tool) =>
241
+ button(
242
+ {
243
+ type: 'button',
244
+ 'data-scope': 'md-table-tools',
245
+ 'data-part': 'tool',
246
+ title: tool.title,
247
+ 'aria-label': tool.title,
248
+ onMouseDown: (e: MouseEvent) => {
249
+ e.preventDefault()
250
+ send({ type: 'op', id: tool.id })
251
+ },
252
+ },
253
+ [text(tool.label)],
254
+ ),
255
+ ),
256
+ }),
257
+ }),
258
+ items: [
259
+ {
260
+ id: 'table',
261
+ label: 'Table',
262
+ icon: 'table',
263
+ group: 'insert',
264
+ keywords: ['grid', 'rows', 'columns'],
265
+ run: (editor) =>
266
+ editor.update(() =>
267
+ $insertNodeToNearestRoot(
268
+ buildTable(
269
+ ['Column 1', 'Column 2'],
270
+ [
271
+ ['', ''],
272
+ ['', ''],
273
+ ],
274
+ ),
275
+ ),
276
+ ),
277
+ surfaces: ['toolbar', 'slash', 'context'],
278
+ },
279
+ ],
280
+ }
281
+ }