@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
|
+
// Mention plugin — an `@`-triggered typeahead. Same shape as the slash menu, but
|
|
2
|
+
// the candidates come from a configurable `source`, and choosing inserts the
|
|
3
|
+
// mention text (`@label`) rather than running a command. Demonstrates a second
|
|
4
|
+
// typeahead built on the plugin-UI seam with no new machinery.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
$getSelection,
|
|
8
|
+
$isRangeSelection,
|
|
9
|
+
$isTextNode,
|
|
10
|
+
COMMAND_PRIORITY_HIGH,
|
|
11
|
+
KEY_ARROW_DOWN_COMMAND,
|
|
12
|
+
KEY_ARROW_UP_COMMAND,
|
|
13
|
+
KEY_ENTER_COMMAND,
|
|
14
|
+
KEY_ESCAPE_COMMAND,
|
|
15
|
+
} from 'lexical'
|
|
16
|
+
import { mergeRegister } from '@lexical/utils'
|
|
17
|
+
import { div, each, text, type Signal } from '@llui/dom'
|
|
18
|
+
import { definePluginUI } from './ui.js'
|
|
19
|
+
import { OVERLAY_Z, hideOverlay, overlayRoot } from './overlay.js'
|
|
20
|
+
import type { MarkdownPlugin } from './types.js'
|
|
21
|
+
|
|
22
|
+
export interface Mention {
|
|
23
|
+
id: string
|
|
24
|
+
label: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Row {
|
|
28
|
+
id: string
|
|
29
|
+
label: string
|
|
30
|
+
active: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface MentionState {
|
|
34
|
+
open: boolean
|
|
35
|
+
query: string
|
|
36
|
+
items: Row[]
|
|
37
|
+
index: number
|
|
38
|
+
x: number
|
|
39
|
+
y: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type MentionMsg =
|
|
43
|
+
| { type: 'show'; query: string; items: Mention[]; x: number; y: number }
|
|
44
|
+
| { type: 'hide' }
|
|
45
|
+
| { type: 'move'; delta: number }
|
|
46
|
+
| { type: 'choose' }
|
|
47
|
+
| { type: 'click'; index: number }
|
|
48
|
+
|
|
49
|
+
type MentionEffect = { type: 'insert'; label: string; query: string }
|
|
50
|
+
|
|
51
|
+
const TRIGGER = /(?:^|\s)@(\w*)$/
|
|
52
|
+
|
|
53
|
+
const DEFAULT_MENTIONS: readonly Mention[] = [
|
|
54
|
+
{ id: 'franco', label: 'Franco' },
|
|
55
|
+
{ id: 'ada', label: 'Ada' },
|
|
56
|
+
{ id: 'grace', label: 'Grace' },
|
|
57
|
+
{ id: 'linus', label: 'Linus' },
|
|
58
|
+
{ id: 'margaret', label: 'Margaret' },
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
function withActive(items: readonly Mention[], index: number): Row[] {
|
|
62
|
+
return items.map((it, i) => ({ id: it.id, label: it.label, active: i === index }))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function $readQuery(): string | null {
|
|
66
|
+
const selection = $getSelection()
|
|
67
|
+
if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null
|
|
68
|
+
const node = selection.anchor.getNode()
|
|
69
|
+
if (!$isTextNode(node)) return null
|
|
70
|
+
const before = node.getTextContent().slice(0, selection.anchor.offset)
|
|
71
|
+
const match = before.match(TRIGGER)
|
|
72
|
+
return match ? (match[1] ?? '') : null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function caretXY(): { x: number; y: number } {
|
|
76
|
+
if (typeof window === 'undefined') return { x: 0, y: 0 }
|
|
77
|
+
const sel = window.getSelection()
|
|
78
|
+
if (sel && sel.rangeCount > 0) {
|
|
79
|
+
const rect = sel.getRangeAt(0).getBoundingClientRect()
|
|
80
|
+
return { x: rect.left, y: rect.bottom + 4 }
|
|
81
|
+
}
|
|
82
|
+
return { x: 0, y: 0 }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface MentionPluginOptions {
|
|
86
|
+
/** Resolve candidates for a query (default: a small sample list). */
|
|
87
|
+
source?: (query: string) => readonly Mention[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function mentionPlugin(opts: MentionPluginOptions = {}): MarkdownPlugin {
|
|
91
|
+
const source =
|
|
92
|
+
opts.source ??
|
|
93
|
+
((query: string) =>
|
|
94
|
+
DEFAULT_MENTIONS.filter((m) => m.label.toLowerCase().includes(query.toLowerCase())))
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
name: 'mention',
|
|
98
|
+
register: (editor, ctx) => {
|
|
99
|
+
const isActive = (): boolean => editor.getEditorState().read(() => $readQuery() !== null)
|
|
100
|
+
|
|
101
|
+
const refresh = (): void => {
|
|
102
|
+
const query = editor.getEditorState().read(() => $readQuery())
|
|
103
|
+
if (query === null) {
|
|
104
|
+
ctx.emit({ type: 'plugin', name: 'mention', msg: { type: 'hide' } })
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
const items = [...source(query)].slice(0, 8)
|
|
108
|
+
const { x, y } = caretXY()
|
|
109
|
+
ctx.emit({ type: 'plugin', name: 'mention', msg: { type: 'show', query, items, x, y } })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const nav = (delta: number, e: KeyboardEvent | null): boolean => {
|
|
113
|
+
if (!isActive()) return false
|
|
114
|
+
e?.preventDefault()
|
|
115
|
+
ctx.emit({ type: 'plugin', name: 'mention', msg: { type: 'move', delta } })
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return mergeRegister(
|
|
120
|
+
editor.registerUpdateListener(() => refresh()),
|
|
121
|
+
editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (e) => nav(1, e), COMMAND_PRIORITY_HIGH),
|
|
122
|
+
editor.registerCommand(KEY_ARROW_UP_COMMAND, (e) => nav(-1, e), COMMAND_PRIORITY_HIGH),
|
|
123
|
+
editor.registerCommand(
|
|
124
|
+
KEY_ENTER_COMMAND,
|
|
125
|
+
(e) => {
|
|
126
|
+
if (!isActive()) return false
|
|
127
|
+
e?.preventDefault()
|
|
128
|
+
ctx.emit({ type: 'plugin', name: 'mention', msg: { type: 'choose' } })
|
|
129
|
+
return true
|
|
130
|
+
},
|
|
131
|
+
COMMAND_PRIORITY_HIGH,
|
|
132
|
+
),
|
|
133
|
+
editor.registerCommand(
|
|
134
|
+
KEY_ESCAPE_COMMAND,
|
|
135
|
+
() => {
|
|
136
|
+
if (!isActive()) return false
|
|
137
|
+
ctx.emit({ type: 'plugin', name: 'mention', msg: { type: 'hide' } })
|
|
138
|
+
return true
|
|
139
|
+
},
|
|
140
|
+
COMMAND_PRIORITY_HIGH,
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
},
|
|
144
|
+
ui: definePluginUI<MentionState, MentionMsg, MentionEffect>({
|
|
145
|
+
init: () => ({ open: false, query: '', items: [], index: 0, x: 0, y: 0 }),
|
|
146
|
+
update: (state, msg) => {
|
|
147
|
+
switch (msg.type) {
|
|
148
|
+
case 'show':
|
|
149
|
+
return {
|
|
150
|
+
open: msg.items.length > 0,
|
|
151
|
+
query: msg.query,
|
|
152
|
+
items: withActive(msg.items, 0),
|
|
153
|
+
index: 0,
|
|
154
|
+
x: msg.x,
|
|
155
|
+
y: msg.y,
|
|
156
|
+
}
|
|
157
|
+
case 'hide':
|
|
158
|
+
return hideOverlay(state)
|
|
159
|
+
case 'move': {
|
|
160
|
+
if (!state.open || state.items.length === 0) return state
|
|
161
|
+
const index = (state.index + msg.delta + state.items.length) % state.items.length
|
|
162
|
+
return { ...state, index, items: withActive(state.items, index) }
|
|
163
|
+
}
|
|
164
|
+
case 'choose': {
|
|
165
|
+
const item = state.items[state.index]
|
|
166
|
+
if (!state.open || !item) return hideOverlay(state)
|
|
167
|
+
return [
|
|
168
|
+
{ ...state, open: false },
|
|
169
|
+
[{ type: 'insert', label: item.label, query: state.query }],
|
|
170
|
+
]
|
|
171
|
+
}
|
|
172
|
+
case 'click': {
|
|
173
|
+
const item = state.items[msg.index]
|
|
174
|
+
if (!item) return state
|
|
175
|
+
return [
|
|
176
|
+
{ ...state, open: false },
|
|
177
|
+
[{ type: 'insert', label: item.label, query: state.query }],
|
|
178
|
+
]
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
onEffect: (effect, ctx) => {
|
|
183
|
+
const editor = ctx.editor()
|
|
184
|
+
if (!editor) return
|
|
185
|
+
editor.update(() => {
|
|
186
|
+
const selection = $getSelection()
|
|
187
|
+
if (!$isRangeSelection(selection) || !selection.isCollapsed()) return
|
|
188
|
+
const node = selection.anchor.getNode()
|
|
189
|
+
if (!$isTextNode(node)) return
|
|
190
|
+
const offset = selection.anchor.offset
|
|
191
|
+
const start = offset - effect.query.length - 1
|
|
192
|
+
if (start >= 0) node.spliceText(start, effect.query.length + 1, `@${effect.label} `, true)
|
|
193
|
+
})
|
|
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<Row[]>, {
|
|
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,115 @@
|
|
|
1
|
+
// Mermaid plugin — a fenced ```mermaid block rendered via the decorator bridge.
|
|
2
|
+
// The diagram source is editable (persists on blur); an optional `render` callback
|
|
3
|
+
// (e.g. the mermaid library) produces the diagram. Round-trips to a ```mermaid
|
|
4
|
+
// fence.
|
|
5
|
+
//
|
|
6
|
+
// NOTE: place `mermaidPlugin()` BEFORE `corePlugin()` in the plugins array so its
|
|
7
|
+
// multiline transformer matches ```mermaid before the generic code-block one.
|
|
8
|
+
|
|
9
|
+
import { type LexicalNode } from 'lexical'
|
|
10
|
+
import { $insertNodeToNearestRoot } from '@lexical/utils'
|
|
11
|
+
import type { MultilineElementTransformer } from '@lexical/markdown'
|
|
12
|
+
import {
|
|
13
|
+
$createLLuiDecoratorNode,
|
|
14
|
+
$isLLuiDecoratorNode,
|
|
15
|
+
LLuiDecoratorNode,
|
|
16
|
+
decoratorBridge,
|
|
17
|
+
} from '@llui/lexical'
|
|
18
|
+
import { div, text, type Mountable, type Signal } from '@llui/dom'
|
|
19
|
+
import type { MarkdownPlugin } from './types.js'
|
|
20
|
+
import { renderedPreview, type PreviewRender } from './_preview.js'
|
|
21
|
+
|
|
22
|
+
const BRIDGE_TYPE = 'mermaid'
|
|
23
|
+
|
|
24
|
+
interface MermaidData {
|
|
25
|
+
code: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isMermaidData(value: unknown): value is MermaidData {
|
|
29
|
+
return (
|
|
30
|
+
typeof value === 'object' && value !== null && typeof (value as MermaidData).code === 'string'
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const stop = (e: Event): void => e.stopPropagation()
|
|
35
|
+
|
|
36
|
+
export interface MermaidPluginOptions {
|
|
37
|
+
/** Render the diagram source to an HTML string (e.g. mermaid). When omitted,
|
|
38
|
+
* the raw source is shown in a styled box. */
|
|
39
|
+
/** Render the mermaid source to a preview. Return a DOM `Node` (mounted
|
|
40
|
+
* directly, no sanitization) or a **trusted HTML string** (injected as-is
|
|
41
|
+
* — sanitize it yourself, e.g. via DOMPurify, since it carries document
|
|
42
|
+
* content). See `renderedPreview`. */
|
|
43
|
+
render?: PreviewRender
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function mermaidPlugin(opts: MermaidPluginOptions = {}): MarkdownPlugin {
|
|
47
|
+
const bridge = decoratorBridge<MermaidData>(BRIDGE_TYPE, (data, api) => {
|
|
48
|
+
const children: Mountable[] = [
|
|
49
|
+
div(
|
|
50
|
+
{
|
|
51
|
+
'data-part': 'source',
|
|
52
|
+
contenteditable: 'true',
|
|
53
|
+
role: 'textbox',
|
|
54
|
+
'aria-label': 'Mermaid source',
|
|
55
|
+
onKeyDown: stop,
|
|
56
|
+
onBeforeInput: stop,
|
|
57
|
+
onPaste: stop,
|
|
58
|
+
onBlur: (e: FocusEvent) => {
|
|
59
|
+
const code = (e.target as HTMLElement).textContent ?? ''
|
|
60
|
+
if (code !== data.peek().code) api.update({ code })
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
[text(data.at('code') as Signal<string>)],
|
|
64
|
+
),
|
|
65
|
+
]
|
|
66
|
+
if (opts.render) {
|
|
67
|
+
children.push(renderedPreview(data.at('code') as Signal<string>, opts.render))
|
|
68
|
+
}
|
|
69
|
+
return [
|
|
70
|
+
div({ 'data-scope': 'md-mermaid', 'data-part': 'root', contenteditable: 'false' }, children),
|
|
71
|
+
]
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const transformer: MultilineElementTransformer = {
|
|
75
|
+
dependencies: [LLuiDecoratorNode],
|
|
76
|
+
export: (node: LexicalNode): string | null => {
|
|
77
|
+
if (!$isLLuiDecoratorNode(node) || node.getBridgeType() !== BRIDGE_TYPE) return null
|
|
78
|
+
const data = node.getData()
|
|
79
|
+
return isMermaidData(data) ? '```mermaid\n' + data.code + '\n```' : null
|
|
80
|
+
},
|
|
81
|
+
regExpStart: /^```mermaid$/,
|
|
82
|
+
regExpEnd: /^```$/,
|
|
83
|
+
replace: (rootNode, _children, _startMatch, _endMatch, linesInBetween): boolean => {
|
|
84
|
+
const lines = [...(linesInBetween ?? [])]
|
|
85
|
+
while (lines.length > 0 && lines[0] === '') lines.shift()
|
|
86
|
+
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
|
87
|
+
rootNode.append($createLLuiDecoratorNode(BRIDGE_TYPE, { code: lines.join('\n') }))
|
|
88
|
+
return true
|
|
89
|
+
},
|
|
90
|
+
type: 'multiline-element',
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
name: 'mermaid',
|
|
95
|
+
nodes: [LLuiDecoratorNode],
|
|
96
|
+
decorators: [bridge],
|
|
97
|
+
transformers: [transformer],
|
|
98
|
+
items: [
|
|
99
|
+
{
|
|
100
|
+
id: 'mermaid',
|
|
101
|
+
label: 'Diagram',
|
|
102
|
+
icon: 'mermaid',
|
|
103
|
+
group: 'insert',
|
|
104
|
+
keywords: ['mermaid', 'diagram', 'flowchart', 'graph'],
|
|
105
|
+
run: (editor) =>
|
|
106
|
+
editor.update(() =>
|
|
107
|
+
$insertNodeToNearestRoot(
|
|
108
|
+
$createLLuiDecoratorNode(BRIDGE_TYPE, { code: 'graph TD\n A --> B' }),
|
|
109
|
+
),
|
|
110
|
+
),
|
|
111
|
+
surfaces: ['slash', 'context'],
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Shared overlay primitive for plugin-UI surfaces (context menu, slash/mention
|
|
2
|
+
// typeaheads, floating toolbar, table tools). Every one of these is a portaled,
|
|
3
|
+
// viewport-positioned panel whose `{ open, x, y }` lives in pure, JSON state and
|
|
4
|
+
// is rendered with `show → portal → fixed-positioned div`. Factoring that single
|
|
5
|
+
// shape here keeps positioning testable (a plain style string, not imperative
|
|
6
|
+
// floating-ui calls) and removes the copy-paste that otherwise accumulates one
|
|
7
|
+
// near-identical state machine per overlay plugin.
|
|
8
|
+
//
|
|
9
|
+
// Anchored positioning via `@llui/components`' `attachFloating` (floating-ui) was
|
|
10
|
+
// considered: it gives flip/shift/auto-update for free but needs a live element
|
|
11
|
+
// ref and async, jsdom-untestable DOM writes — a poor fit for overlays whose
|
|
12
|
+
// position is derived from a transient caret/pointer/element rect each `register`
|
|
13
|
+
// tick. The pure-state idiom below covers scroll/resize via {@link onViewportChange}.
|
|
14
|
+
//
|
|
15
|
+
// ## Stacking order (z-index)
|
|
16
|
+
// A single documented scale so overlays never tie and obscure each other:
|
|
17
|
+
// 60 — typeaheads (slash, mention) — caret-anchored, lowest
|
|
18
|
+
// 61 — context menu — pointer-anchored
|
|
19
|
+
// 62 — floating selection toolbar — selection-anchored bubble
|
|
20
|
+
// 63 — table tools — element-anchored, sits over a table
|
|
21
|
+
// The values live with each plugin's view; this comment is the source of truth.
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
div,
|
|
25
|
+
derived,
|
|
26
|
+
onMount,
|
|
27
|
+
portal,
|
|
28
|
+
show,
|
|
29
|
+
type Mountable,
|
|
30
|
+
type Renderable,
|
|
31
|
+
type Signal,
|
|
32
|
+
} from '@llui/dom'
|
|
33
|
+
import { registerNestedLayer } from '@llui/components/utils'
|
|
34
|
+
|
|
35
|
+
export const OVERLAY_Z = {
|
|
36
|
+
typeahead: 60,
|
|
37
|
+
contextMenu: 61,
|
|
38
|
+
floatingToolbar: 62,
|
|
39
|
+
tableTools: 63,
|
|
40
|
+
} as const
|
|
41
|
+
|
|
42
|
+
const toMountables = (r: Renderable): Mountable[] => [...r]
|
|
43
|
+
|
|
44
|
+
// Unique marker per overlay instance so its portal root can be located while
|
|
45
|
+
// open. Every overlayRoot is a body-level sibling portal; without declaring it
|
|
46
|
+
// as a nested layer, a host `dialog.overlay()` mis-reads an interaction inside
|
|
47
|
+
// it as "outside" and dismisses (see @llui/components registerNestedLayer).
|
|
48
|
+
let layerSeq = 0
|
|
49
|
+
const NESTED_LAYER_ATTR = 'data-llui-nested-layer'
|
|
50
|
+
|
|
51
|
+
export interface OverlayRootConfig {
|
|
52
|
+
/** Whether the overlay is shown. */
|
|
53
|
+
open: Signal<boolean>
|
|
54
|
+
/** Viewport x of the anchor point (px). */
|
|
55
|
+
x: Signal<number>
|
|
56
|
+
/** Viewport y of the anchor point (px). */
|
|
57
|
+
y: Signal<number>
|
|
58
|
+
/** Stacking level — use {@link OVERLAY_Z}. */
|
|
59
|
+
zIndex: number
|
|
60
|
+
/** Extra CSS appended after positioning (e.g. a centering/lift transform). */
|
|
61
|
+
transform?: string
|
|
62
|
+
/** Attributes for the positioned element (`data-scope`/`data-part`, handlers). */
|
|
63
|
+
attrs: Record<string, unknown>
|
|
64
|
+
/** Optional siblings rendered inside the portal before the root (e.g. a backdrop). */
|
|
65
|
+
before?: () => Renderable
|
|
66
|
+
/** The positioned element's children. */
|
|
67
|
+
children: () => Renderable
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A portaled, `position:fixed` overlay placed at `(x, y)` and shown while `open`.
|
|
72
|
+
* Returns the `Renderable` a plugin's `view` yields directly. The emitted style is
|
|
73
|
+
* a deterministic string — `position:fixed;left:${x}px;top:${y}px;z-index:${z}` plus
|
|
74
|
+
* an optional `transform` — so positioning is unit-testable.
|
|
75
|
+
*/
|
|
76
|
+
export function overlayRoot(cfg: OverlayRootConfig): Renderable {
|
|
77
|
+
const style = derived(
|
|
78
|
+
cfg.x,
|
|
79
|
+
cfg.y,
|
|
80
|
+
(x, y) =>
|
|
81
|
+
`position:fixed;left:${x}px;top:${y}px;z-index:${cfg.zIndex}` +
|
|
82
|
+
(cfg.transform ? `;${cfg.transform}` : ''),
|
|
83
|
+
) as Signal<string>
|
|
84
|
+
const layerId = `mdo-${++layerSeq}`
|
|
85
|
+
return [
|
|
86
|
+
// Register once for the overlay's lifetime; the resolver returns the live
|
|
87
|
+
// portal root only while open (and nothing when closed/unmounted), so the
|
|
88
|
+
// single registration tracks open/closed without per-toggle churn.
|
|
89
|
+
onMount(() =>
|
|
90
|
+
registerNestedLayer(() => {
|
|
91
|
+
if (typeof document === 'undefined') return []
|
|
92
|
+
const el = document.querySelector(`[${NESTED_LAYER_ATTR}="${layerId}"]`)
|
|
93
|
+
return el ? [el] : []
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
show(cfg.open, () => [
|
|
97
|
+
portal(() => [
|
|
98
|
+
...(cfg.before ? toMountables(cfg.before()) : []),
|
|
99
|
+
div({ ...cfg.attrs, [NESTED_LAYER_ATTR]: layerId, style }, toMountables(cfg.children())),
|
|
100
|
+
]),
|
|
101
|
+
]),
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Guarded close: collapse `open` to false, preserving the reference when already
|
|
106
|
+
* closed so the host doesn't reconcile a no-op. */
|
|
107
|
+
export function hideOverlay<S extends { open: boolean }>(state: S): S {
|
|
108
|
+
return state.open ? { ...state, open: false } : state
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Run `run` (debounced to one call per animation frame) whenever the viewport
|
|
113
|
+
* changes — any ancestor scroll or a window resize. Overlays anchored to a
|
|
114
|
+
* persistent element (a table) or a live selection must reposition on scroll;
|
|
115
|
+
* editor update listeners alone never fire for a plain scroll. Returns a cleanup
|
|
116
|
+
* that removes the listeners. No-op (and cleanup is a no-op) outside the browser.
|
|
117
|
+
*/
|
|
118
|
+
export function onViewportChange(run: () => void): () => void {
|
|
119
|
+
if (typeof window === 'undefined' || typeof requestAnimationFrame !== 'function') {
|
|
120
|
+
return () => {}
|
|
121
|
+
}
|
|
122
|
+
let pending = 0
|
|
123
|
+
const handler = (): void => {
|
|
124
|
+
if (pending) return
|
|
125
|
+
pending = requestAnimationFrame(() => {
|
|
126
|
+
pending = 0
|
|
127
|
+
run()
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
// Capture phase so scrolls in any nested scroll container are caught.
|
|
131
|
+
window.addEventListener('scroll', handler, true)
|
|
132
|
+
window.addEventListener('resize', handler)
|
|
133
|
+
return () => {
|
|
134
|
+
window.removeEventListener('scroll', handler, true)
|
|
135
|
+
window.removeEventListener('resize', handler)
|
|
136
|
+
if (pending) cancelAnimationFrame(pending)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -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
|
+
}
|