@llui/markdown-editor 0.2.12 → 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.
- package/README.md +44 -0
- package/package.json +17 -13
- package/src/editor.ts +303 -0
- package/src/effects.ts +51 -0
- package/src/format.ts +57 -0
- package/src/index.ts +93 -0
- package/src/paste.ts +91 -0
- package/src/plugins/_preview.ts +51 -0
- package/src/plugins/callout.ts +195 -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 +66 -0
- package/src/plugins/image.ts +237 -0
- package/src/plugins/inline.ts +76 -0
- package/src/plugins/link.ts +133 -0
- package/src/plugins/math.ts +116 -0
- package/src/plugins/mention.ts +224 -0
- package/src/plugins/mermaid.ts +134 -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 +281 -0
- package/src/plugins/types.ts +50 -0
- package/src/plugins/ui.ts +79 -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,180 @@
|
|
|
1
|
+
// Floating selection toolbar — a bubble of inline-format actions that appears
|
|
2
|
+
// above a non-collapsed text selection. A plugin-UI overlay: `register` watches
|
|
3
|
+
// selection changes and positions/fills the bar; clicking a button runs the
|
|
4
|
+
// command on the still-live selection.
|
|
5
|
+
|
|
6
|
+
import { $getSelection, $isRangeSelection } from 'lexical'
|
|
7
|
+
import { $findMatchingParent, mergeRegister } from '@lexical/utils'
|
|
8
|
+
import { $isLinkNode } from '@lexical/link'
|
|
9
|
+
import { button, each, span, text, unsafeHtml, type Signal } from '@llui/dom'
|
|
10
|
+
import { definePluginUI } from './ui.js'
|
|
11
|
+
import { OVERLAY_Z, hideOverlay, onViewportChange, overlayRoot } from './overlay.js'
|
|
12
|
+
import { DEFAULT_GLYPHS } from '../surfaces/toolbar.js'
|
|
13
|
+
import type { CommandItem, MarkdownPlugin } from './types.js'
|
|
14
|
+
|
|
15
|
+
interface BarItem {
|
|
16
|
+
id: string
|
|
17
|
+
label: string
|
|
18
|
+
glyph: string
|
|
19
|
+
active: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface FloatState {
|
|
23
|
+
open: boolean
|
|
24
|
+
x: number
|
|
25
|
+
y: number
|
|
26
|
+
items: BarItem[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type FloatMsg =
|
|
30
|
+
| { type: 'show'; x: number; y: number; items: BarItem[] }
|
|
31
|
+
| { type: 'hide' }
|
|
32
|
+
| { type: 'run'; index: number }
|
|
33
|
+
|
|
34
|
+
type FloatEffect = { type: 'run'; id: string }
|
|
35
|
+
|
|
36
|
+
interface InlineFormat {
|
|
37
|
+
bold: boolean
|
|
38
|
+
italic: boolean
|
|
39
|
+
strikethrough: boolean
|
|
40
|
+
code: boolean
|
|
41
|
+
link: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readFormat(editor: import('lexical').LexicalEditor): InlineFormat {
|
|
45
|
+
return editor.getEditorState().read(() => {
|
|
46
|
+
const selection = $getSelection()
|
|
47
|
+
if (!$isRangeSelection(selection)) {
|
|
48
|
+
return { bold: false, italic: false, strikethrough: false, code: false, link: false }
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
bold: selection.hasFormat('bold'),
|
|
52
|
+
italic: selection.hasFormat('italic'),
|
|
53
|
+
strikethrough: selection.hasFormat('strikethrough'),
|
|
54
|
+
code: selection.hasFormat('code'),
|
|
55
|
+
link: $findMatchingParent(selection.anchor.getNode(), (n) => $isLinkNode(n)) !== null,
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function activeFor(id: string, fmt: InlineFormat): boolean {
|
|
61
|
+
switch (id) {
|
|
62
|
+
case 'bold':
|
|
63
|
+
return fmt.bold
|
|
64
|
+
case 'italic':
|
|
65
|
+
return fmt.italic
|
|
66
|
+
case 'strikethrough':
|
|
67
|
+
return fmt.strikethrough
|
|
68
|
+
case 'code':
|
|
69
|
+
return fmt.code
|
|
70
|
+
case 'link':
|
|
71
|
+
return fmt.link
|
|
72
|
+
default:
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function floatingToolbarPlugin(): MarkdownPlugin {
|
|
78
|
+
let floatingItems: CommandItem[] = []
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
name: 'floatingToolbar',
|
|
82
|
+
onItems: (items) => {
|
|
83
|
+
floatingItems = items.filter((i) =>
|
|
84
|
+
i.surfaces ? i.surfaces.includes('floating') : i.group === 'inline',
|
|
85
|
+
)
|
|
86
|
+
},
|
|
87
|
+
register: (editor, ctx) => {
|
|
88
|
+
const refresh = (): void => {
|
|
89
|
+
const collapsed = editor.getEditorState().read(() => {
|
|
90
|
+
const s = $getSelection()
|
|
91
|
+
return !$isRangeSelection(s) || s.isCollapsed()
|
|
92
|
+
})
|
|
93
|
+
const dom = typeof window !== 'undefined' ? window.getSelection() : null
|
|
94
|
+
if (collapsed || !dom || dom.rangeCount === 0) {
|
|
95
|
+
ctx.emit({ type: 'plugin', name: 'floatingToolbar', msg: { type: 'hide' } })
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
const rect = dom.getRangeAt(0).getBoundingClientRect()
|
|
99
|
+
if (rect.width === 0 && rect.height === 0) {
|
|
100
|
+
ctx.emit({ type: 'plugin', name: 'floatingToolbar', msg: { type: 'hide' } })
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
const fmt = readFormat(editor)
|
|
104
|
+
const items: BarItem[] = floatingItems.map((i) => ({
|
|
105
|
+
id: i.id,
|
|
106
|
+
label: i.label,
|
|
107
|
+
glyph: DEFAULT_GLYPHS[i.id] ?? i.label,
|
|
108
|
+
active: activeFor(i.id, fmt),
|
|
109
|
+
}))
|
|
110
|
+
ctx.emit({
|
|
111
|
+
type: 'plugin',
|
|
112
|
+
name: 'floatingToolbar',
|
|
113
|
+
msg: { type: 'show', x: rect.left + rect.width / 2, y: rect.top, items },
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
return mergeRegister(
|
|
117
|
+
editor.registerUpdateListener(() => refresh()),
|
|
118
|
+
// Keep the bubble glued to the selection while the page scrolls.
|
|
119
|
+
onViewportChange(refresh),
|
|
120
|
+
)
|
|
121
|
+
},
|
|
122
|
+
ui: definePluginUI<FloatState, FloatMsg, FloatEffect>({
|
|
123
|
+
init: () => ({ open: false, x: 0, y: 0, items: [] }),
|
|
124
|
+
update: (state, msg) => {
|
|
125
|
+
switch (msg.type) {
|
|
126
|
+
case 'show':
|
|
127
|
+
return { open: msg.items.length > 0, x: msg.x, y: msg.y, items: msg.items }
|
|
128
|
+
case 'hide':
|
|
129
|
+
return hideOverlay(state)
|
|
130
|
+
case 'run': {
|
|
131
|
+
const item = state.items[msg.index]
|
|
132
|
+
return item ? [state, [{ type: 'run', id: item.id }]] : state
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
onEffect: (effect, ctx) => {
|
|
137
|
+
ctx.emit({ type: 'runCommand', id: effect.id })
|
|
138
|
+
},
|
|
139
|
+
// `x` is the selection's horizontal centre; the transform centres the bar on
|
|
140
|
+
// it and lifts it above the selection.
|
|
141
|
+
view: ({ state, send }) =>
|
|
142
|
+
overlayRoot({
|
|
143
|
+
open: state.at('open'),
|
|
144
|
+
x: state.at('x'),
|
|
145
|
+
y: state.at('y'),
|
|
146
|
+
zIndex: OVERLAY_Z.floatingToolbar,
|
|
147
|
+
transform: 'transform:translate(-50%,-115%)',
|
|
148
|
+
attrs: { 'data-scope': 'md-floating', 'data-part': 'bar' },
|
|
149
|
+
children: () => [
|
|
150
|
+
each(state.at('items') as Signal<BarItem[]>, {
|
|
151
|
+
key: (it) => it.id,
|
|
152
|
+
render: (item, index) => [
|
|
153
|
+
button(
|
|
154
|
+
{
|
|
155
|
+
type: 'button',
|
|
156
|
+
'data-scope': 'md-floating',
|
|
157
|
+
'data-part': 'item',
|
|
158
|
+
'data-active': item.map((it) => (it.active ? '' : undefined)),
|
|
159
|
+
'aria-label': item.map((it) => it.label),
|
|
160
|
+
onMouseDown: (e: MouseEvent) => {
|
|
161
|
+
e.preventDefault()
|
|
162
|
+
send({ type: 'run', index: index.peek() })
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
[span({ 'data-part': 'glyph', 'aria-hidden': 'true' }, [renderGlyph(item)])],
|
|
166
|
+
),
|
|
167
|
+
],
|
|
168
|
+
}),
|
|
169
|
+
],
|
|
170
|
+
}),
|
|
171
|
+
}),
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Render an item's glyph (SVG markup → unsafeHtml, otherwise text). */
|
|
176
|
+
function renderGlyph(item: Signal<BarItem>): import('@llui/dom').Mountable {
|
|
177
|
+
// The glyph value is stable per row; reading once is fine.
|
|
178
|
+
const glyph = item.peek().glyph
|
|
179
|
+
return glyph.trimStart().startsWith('<svg') ? unsafeHtml(glyph) : text(item.map((it) => it.glyph))
|
|
180
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Horizontal-rule plugin — a thematic break rendered as an `<hr>` via the
|
|
2
|
+
// decorator bridge, round-tripping to `---` markdown.
|
|
3
|
+
|
|
4
|
+
import { type ElementNode, type LexicalNode } from 'lexical'
|
|
5
|
+
import { $insertNodeToNearestRoot } from '@lexical/utils'
|
|
6
|
+
import type { ElementTransformer } from '@lexical/markdown'
|
|
7
|
+
import {
|
|
8
|
+
$createLLuiDecoratorNode,
|
|
9
|
+
$isLLuiDecoratorNode,
|
|
10
|
+
LLuiDecoratorNode,
|
|
11
|
+
decoratorBridge,
|
|
12
|
+
} from '@llui/lexical'
|
|
13
|
+
import { component, hr } from '@llui/dom'
|
|
14
|
+
import type { MarkdownPlugin } from './types.js'
|
|
15
|
+
|
|
16
|
+
const BRIDGE_TYPE = 'hr'
|
|
17
|
+
|
|
18
|
+
const hrBridge = decoratorBridge<
|
|
19
|
+
Record<string, never>,
|
|
20
|
+
Record<string, never>,
|
|
21
|
+
{ type: 'noop' },
|
|
22
|
+
never
|
|
23
|
+
>(BRIDGE_TYPE, () =>
|
|
24
|
+
component<Record<string, never>, { type: 'noop' }, never>({
|
|
25
|
+
name: 'HorizontalRule',
|
|
26
|
+
init: () => ({}),
|
|
27
|
+
update: (state) => state,
|
|
28
|
+
view: () => [hr({ 'data-md-hr': '', contenteditable: 'false' })],
|
|
29
|
+
}),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
const HR_TRANSFORMER: ElementTransformer = {
|
|
33
|
+
dependencies: [LLuiDecoratorNode],
|
|
34
|
+
export: (node: LexicalNode): string | null =>
|
|
35
|
+
$isLLuiDecoratorNode(node) && node.getBridgeType() === BRIDGE_TYPE ? '---' : null,
|
|
36
|
+
regExp: /^(---|\*\*\*|___)\s*$/,
|
|
37
|
+
replace: (parentNode: ElementNode): void => {
|
|
38
|
+
parentNode.replace($createLLuiDecoratorNode(BRIDGE_TYPE, {}))
|
|
39
|
+
},
|
|
40
|
+
type: 'element',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Insert a horizontal rule at the current selection. */
|
|
44
|
+
export function $insertHorizontalRule(): void {
|
|
45
|
+
$insertNodeToNearestRoot($createLLuiDecoratorNode(BRIDGE_TYPE, {}))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function hrPlugin(): MarkdownPlugin {
|
|
49
|
+
return {
|
|
50
|
+
name: 'hr',
|
|
51
|
+
nodes: [LLuiDecoratorNode],
|
|
52
|
+
decorators: [hrBridge],
|
|
53
|
+
transformers: [HR_TRANSFORMER],
|
|
54
|
+
items: [
|
|
55
|
+
{
|
|
56
|
+
id: 'horizontalRule',
|
|
57
|
+
label: 'Divider',
|
|
58
|
+
icon: 'hr',
|
|
59
|
+
group: 'insert',
|
|
60
|
+
keywords: ['rule', 'divider', 'separator', 'hr'],
|
|
61
|
+
run: (editor) => editor.update(() => $insertHorizontalRule()),
|
|
62
|
+
surfaces: ['toolbar', 'slash', 'context'],
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Image plugin — a block image rendered via the decorator bridge, round-tripping
|
|
2
|
+
// to `` markdown, inserted through a plugin-UI dialog (URL + alt, with
|
|
3
|
+
// optional file upload). Exercises decorator rendering + a transformer + the
|
|
4
|
+
// plugin-UI extension all at once.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
$getSelection,
|
|
8
|
+
$setSelection,
|
|
9
|
+
type BaseSelection,
|
|
10
|
+
type ElementNode,
|
|
11
|
+
type LexicalNode,
|
|
12
|
+
} from 'lexical'
|
|
13
|
+
import { $insertNodeToNearestRoot } from '@lexical/utils'
|
|
14
|
+
import type { ElementTransformer } from '@lexical/markdown'
|
|
15
|
+
import {
|
|
16
|
+
$createLLuiDecoratorNode,
|
|
17
|
+
$isLLuiDecoratorNode,
|
|
18
|
+
LLuiDecoratorNode,
|
|
19
|
+
decoratorBridge,
|
|
20
|
+
} from '@llui/lexical'
|
|
21
|
+
import { button, component, div, img, input, text, type Signal } from '@llui/dom'
|
|
22
|
+
import {
|
|
23
|
+
connect as connectDialog,
|
|
24
|
+
overlay as overlayDialog,
|
|
25
|
+
type DialogMsg,
|
|
26
|
+
} from '@llui/components/dialog'
|
|
27
|
+
import { definePluginUI } from './ui.js'
|
|
28
|
+
import type { MarkdownPlugin } from './types.js'
|
|
29
|
+
|
|
30
|
+
const BRIDGE_TYPE = 'image'
|
|
31
|
+
|
|
32
|
+
interface ImageData {
|
|
33
|
+
src: string
|
|
34
|
+
alt: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isImageData(value: unknown): value is ImageData {
|
|
38
|
+
return (
|
|
39
|
+
typeof value === 'object' &&
|
|
40
|
+
value !== null &&
|
|
41
|
+
typeof (value as ImageData).src === 'string' &&
|
|
42
|
+
typeof (value as ImageData).alt === 'string'
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const imageBridge = decoratorBridge<ImageData, ImageData, { type: 'noop' }, never>(
|
|
47
|
+
BRIDGE_TYPE,
|
|
48
|
+
(data) =>
|
|
49
|
+
component<ImageData, { type: 'noop' }, never>({
|
|
50
|
+
name: 'Image',
|
|
51
|
+
init: () => ({ src: data.src, alt: data.alt }),
|
|
52
|
+
update: (state) => state,
|
|
53
|
+
view: ({ state }) => [
|
|
54
|
+
div({ 'data-scope': 'md-image', 'data-part': 'root', contenteditable: 'false' }, [
|
|
55
|
+
img({ src: state.at('src') as Signal<string>, alt: state.at('alt') as Signal<string> }),
|
|
56
|
+
]),
|
|
57
|
+
],
|
|
58
|
+
}),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const IMAGE_TRANSFORMER: ElementTransformer = {
|
|
62
|
+
dependencies: [LLuiDecoratorNode],
|
|
63
|
+
export: (node: LexicalNode): string | null => {
|
|
64
|
+
if (!$isLLuiDecoratorNode(node) || node.getBridgeType() !== BRIDGE_TYPE) return null
|
|
65
|
+
const data = node.getData()
|
|
66
|
+
return isImageData(data) ? `` : null
|
|
67
|
+
},
|
|
68
|
+
regExp: /^!\[([^\]]*)\]\(([^)]+)\)$/,
|
|
69
|
+
replace: (parentNode: ElementNode, _children, match): void => {
|
|
70
|
+
parentNode.replace(
|
|
71
|
+
$createLLuiDecoratorNode(BRIDGE_TYPE, { alt: match[1] ?? '', src: match[2] ?? '' }),
|
|
72
|
+
)
|
|
73
|
+
},
|
|
74
|
+
type: 'element',
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface ImageState {
|
|
78
|
+
dialog: { open: boolean }
|
|
79
|
+
src: string
|
|
80
|
+
alt: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type ImageMsg =
|
|
84
|
+
| { type: 'open' }
|
|
85
|
+
| { type: 'setSrc'; src: string }
|
|
86
|
+
| { type: 'setAlt'; alt: string }
|
|
87
|
+
| { type: 'submit' }
|
|
88
|
+
| { type: 'dialog'; msg: DialogMsg }
|
|
89
|
+
|
|
90
|
+
type ImageEffect = { type: 'begin' } | { type: 'insert'; src: string; alt: string }
|
|
91
|
+
|
|
92
|
+
function dialogOpen(msg: DialogMsg, current: boolean): boolean {
|
|
93
|
+
switch (msg.type) {
|
|
94
|
+
case 'open':
|
|
95
|
+
return true
|
|
96
|
+
case 'close':
|
|
97
|
+
return false
|
|
98
|
+
case 'toggle':
|
|
99
|
+
return !current
|
|
100
|
+
case 'setOpen':
|
|
101
|
+
return msg.open
|
|
102
|
+
case 'animationEnd':
|
|
103
|
+
case 'transitionEnd':
|
|
104
|
+
return current
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ImagePluginOptions {
|
|
109
|
+
/** Upload a chosen file and resolve to its URL. When omitted, the file picker
|
|
110
|
+
* is hidden and only URL entry is offered. */
|
|
111
|
+
upload?: (file: File) => Promise<string>
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function imagePlugin(opts: ImagePluginOptions = {}): MarkdownPlugin {
|
|
115
|
+
let savedSelection: BaseSelection | null = null
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
name: 'image',
|
|
119
|
+
nodes: [LLuiDecoratorNode],
|
|
120
|
+
decorators: [imageBridge],
|
|
121
|
+
transformers: [IMAGE_TRANSFORMER],
|
|
122
|
+
items: [
|
|
123
|
+
{
|
|
124
|
+
id: 'image',
|
|
125
|
+
label: 'Image',
|
|
126
|
+
icon: 'image',
|
|
127
|
+
group: 'insert',
|
|
128
|
+
keywords: ['img', 'picture', 'photo'],
|
|
129
|
+
run: (_editor, ctx) => ctx.send({ type: 'plugin', name: 'image', msg: { type: 'open' } }),
|
|
130
|
+
surfaces: ['toolbar', 'slash', 'context'],
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
ui: definePluginUI<ImageState, ImageMsg, ImageEffect>({
|
|
134
|
+
init: () => ({ dialog: { open: false }, src: '', alt: '' }),
|
|
135
|
+
update: (state, msg) => {
|
|
136
|
+
switch (msg.type) {
|
|
137
|
+
case 'open':
|
|
138
|
+
return [{ dialog: { open: true }, src: '', alt: '' }, [{ type: 'begin' }]]
|
|
139
|
+
case 'setSrc':
|
|
140
|
+
return { ...state, src: msg.src }
|
|
141
|
+
case 'setAlt':
|
|
142
|
+
return { ...state, alt: msg.alt }
|
|
143
|
+
case 'submit':
|
|
144
|
+
return [
|
|
145
|
+
{ ...state, dialog: { open: false } },
|
|
146
|
+
[{ type: 'insert', src: state.src, alt: state.alt }],
|
|
147
|
+
]
|
|
148
|
+
case 'dialog': {
|
|
149
|
+
const open = dialogOpen(msg.msg, state.dialog.open)
|
|
150
|
+
return open === state.dialog.open ? state : { ...state, dialog: { open } }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
view: ({ state, send }) => {
|
|
155
|
+
const dialogSend = (msg: DialogMsg): void => send({ type: 'dialog', msg })
|
|
156
|
+
const parts = connectDialog(state.at('dialog'), dialogSend, {
|
|
157
|
+
id: 'md-image-dialog',
|
|
158
|
+
closeLabel: 'Cancel',
|
|
159
|
+
})
|
|
160
|
+
return [
|
|
161
|
+
overlayDialog({
|
|
162
|
+
state: state.at('dialog'),
|
|
163
|
+
send: dialogSend,
|
|
164
|
+
parts,
|
|
165
|
+
content: () => [
|
|
166
|
+
div({ ...parts.content, 'data-md-link': 'box' }, [
|
|
167
|
+
div({ ...parts.title, 'data-md-link': 'title' }, [text('Insert image')]),
|
|
168
|
+
input({
|
|
169
|
+
'data-md-link': 'input',
|
|
170
|
+
type: 'url',
|
|
171
|
+
placeholder: 'https://example.com/image.png',
|
|
172
|
+
value: state.at('src') as Signal<string>,
|
|
173
|
+
onInput: (e: Event) =>
|
|
174
|
+
send({ type: 'setSrc', src: (e.target as HTMLInputElement).value }),
|
|
175
|
+
}),
|
|
176
|
+
input({
|
|
177
|
+
'data-md-link': 'input',
|
|
178
|
+
'data-md-image': 'alt',
|
|
179
|
+
type: 'text',
|
|
180
|
+
placeholder: 'Alt text (description)',
|
|
181
|
+
value: state.at('alt') as Signal<string>,
|
|
182
|
+
onInput: (e: Event) =>
|
|
183
|
+
send({ type: 'setAlt', alt: (e.target as HTMLInputElement).value }),
|
|
184
|
+
}),
|
|
185
|
+
...(opts.upload
|
|
186
|
+
? [
|
|
187
|
+
input({
|
|
188
|
+
'data-md-image': 'file',
|
|
189
|
+
type: 'file',
|
|
190
|
+
accept: 'image/*',
|
|
191
|
+
onChange: (e: Event) => {
|
|
192
|
+
const file = (e.target as HTMLInputElement).files?.[0]
|
|
193
|
+
if (file && opts.upload) {
|
|
194
|
+
void opts.upload(file).then((src) => send({ type: 'setSrc', src }))
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
}),
|
|
198
|
+
]
|
|
199
|
+
: []),
|
|
200
|
+
div({ 'data-md-link': 'actions' }, [
|
|
201
|
+
button({ ...parts.closeTrigger, 'data-md-link': 'cancel' }, [text('Cancel')]),
|
|
202
|
+
button(
|
|
203
|
+
{
|
|
204
|
+
type: 'button',
|
|
205
|
+
'data-md-link': 'apply',
|
|
206
|
+
onClick: () => send({ type: 'submit' }),
|
|
207
|
+
},
|
|
208
|
+
[text('Insert')],
|
|
209
|
+
),
|
|
210
|
+
]),
|
|
211
|
+
]),
|
|
212
|
+
],
|
|
213
|
+
}),
|
|
214
|
+
]
|
|
215
|
+
},
|
|
216
|
+
onEffect: (effect, ctx) => {
|
|
217
|
+
const editor = ctx.editor()
|
|
218
|
+
if (!editor) return
|
|
219
|
+
if (effect.type === 'begin') {
|
|
220
|
+
savedSelection = editor.getEditorState().read(() => {
|
|
221
|
+
const selection = $getSelection()
|
|
222
|
+
return selection ? selection.clone() : null
|
|
223
|
+
})
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
if (effect.src.trim() === '') return
|
|
227
|
+
editor.update(() => {
|
|
228
|
+
if (savedSelection) $setSelection(savedSelection.clone())
|
|
229
|
+
$insertNodeToNearestRoot(
|
|
230
|
+
$createLLuiDecoratorNode(BRIDGE_TYPE, { src: effect.src.trim(), alt: effect.alt }),
|
|
231
|
+
)
|
|
232
|
+
})
|
|
233
|
+
savedSelection = null
|
|
234
|
+
},
|
|
235
|
+
}),
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Shared inline text-formatting building blocks: the bold / italic /
|
|
2
|
+
// strikethrough / code command items and the `FORMAT_TEXT_COMMAND` dispatcher.
|
|
3
|
+
// Both `corePlugin` (the full GFM superset) and `singleBlockPlugin` (inline-only)
|
|
4
|
+
// derive their inline surface from here, so the canonical inline-format set and
|
|
5
|
+
// its labels/icons/keywords live in exactly one place.
|
|
6
|
+
|
|
7
|
+
import { FORMAT_TEXT_COMMAND, type LexicalEditor, type TextFormatType } from 'lexical'
|
|
8
|
+
import type { FormatState } from '../state.js'
|
|
9
|
+
import type { CommandItem } from './types.js'
|
|
10
|
+
|
|
11
|
+
/** An inline text-format surfaced as a toolbar command item. */
|
|
12
|
+
export type InlineFormat = 'bold' | 'italic' | 'strikethrough' | 'code'
|
|
13
|
+
|
|
14
|
+
/** The full inline-format set, in toolbar order. */
|
|
15
|
+
export const INLINE_FORMATS: readonly InlineFormat[] = ['bold', 'italic', 'strikethrough', 'code']
|
|
16
|
+
|
|
17
|
+
interface InlineFormatDef {
|
|
18
|
+
label: string
|
|
19
|
+
icon: string
|
|
20
|
+
keywords: readonly string[]
|
|
21
|
+
format: TextFormatType
|
|
22
|
+
isActive: (f: FormatState) => boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFS: Record<InlineFormat, InlineFormatDef> = {
|
|
26
|
+
bold: {
|
|
27
|
+
label: 'Bold',
|
|
28
|
+
icon: 'bold',
|
|
29
|
+
keywords: ['strong'],
|
|
30
|
+
format: 'bold',
|
|
31
|
+
isActive: (f) => f.bold,
|
|
32
|
+
},
|
|
33
|
+
italic: {
|
|
34
|
+
label: 'Italic',
|
|
35
|
+
icon: 'italic',
|
|
36
|
+
keywords: ['emphasis'],
|
|
37
|
+
format: 'italic',
|
|
38
|
+
isActive: (f) => f.italic,
|
|
39
|
+
},
|
|
40
|
+
strikethrough: {
|
|
41
|
+
label: 'Strikethrough',
|
|
42
|
+
icon: 'strikethrough',
|
|
43
|
+
keywords: ['strike', 'del'],
|
|
44
|
+
format: 'strikethrough',
|
|
45
|
+
isActive: (f) => f.strikethrough,
|
|
46
|
+
},
|
|
47
|
+
code: {
|
|
48
|
+
label: 'Inline code',
|
|
49
|
+
icon: 'code',
|
|
50
|
+
keywords: ['mono'],
|
|
51
|
+
format: 'code',
|
|
52
|
+
isActive: (f) => f.code,
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Dispatch a text-format toggle on the live editor. */
|
|
57
|
+
export function inlineFormatCommand(format: TextFormatType): (editor: LexicalEditor) => void {
|
|
58
|
+
return (editor) => editor.dispatchCommand(FORMAT_TEXT_COMMAND, format)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Build the inline `CommandItem`s for `formats` (default: all four), grouped
|
|
62
|
+
* under `'inline'`. Surfaces are left unset (item appears in every surface). */
|
|
63
|
+
export function inlineItems(formats: readonly InlineFormat[] = INLINE_FORMATS): CommandItem[] {
|
|
64
|
+
return formats.map((id) => {
|
|
65
|
+
const def = DEFS[id]
|
|
66
|
+
return {
|
|
67
|
+
id,
|
|
68
|
+
label: def.label,
|
|
69
|
+
icon: def.icon,
|
|
70
|
+
group: 'inline',
|
|
71
|
+
keywords: def.keywords,
|
|
72
|
+
run: inlineFormatCommand(def.format),
|
|
73
|
+
isActive: def.isActive,
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// The link plugin — a stateful, UI-bearing feature built entirely as a plugin
|
|
2
|
+
// (no core editor changes). It owns its dialog state, its view (the modal), and
|
|
3
|
+
// its effects (save/restore selection + toggle the link). This is the proof that
|
|
4
|
+
// the plugin-UI extension makes such features pluggable rather than built-in.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
$getSelection,
|
|
8
|
+
$isRangeSelection,
|
|
9
|
+
$setSelection,
|
|
10
|
+
type BaseSelection,
|
|
11
|
+
type LexicalEditor,
|
|
12
|
+
} from 'lexical'
|
|
13
|
+
import { $findMatchingParent } from '@lexical/utils'
|
|
14
|
+
import { $isLinkNode, $toggleLink } from '@lexical/link'
|
|
15
|
+
import type { DialogMsg, DialogState } from '@llui/components/dialog'
|
|
16
|
+
import { linkDialog } from '../surfaces/link-dialog.js'
|
|
17
|
+
import { definePluginUI } from './ui.js'
|
|
18
|
+
import type { CommandItem, MarkdownPlugin } from './types.js'
|
|
19
|
+
|
|
20
|
+
const PLUGIN = 'link'
|
|
21
|
+
|
|
22
|
+
interface LinkState {
|
|
23
|
+
dialog: DialogState
|
|
24
|
+
url: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type LinkMsg =
|
|
28
|
+
| { type: 'open' }
|
|
29
|
+
| { type: 'show'; url: string }
|
|
30
|
+
| { type: 'setUrl'; url: string }
|
|
31
|
+
| { type: 'submit' }
|
|
32
|
+
| { type: 'dialog'; msg: DialogMsg }
|
|
33
|
+
|
|
34
|
+
type LinkEffect = { type: 'begin' } | { type: 'commit'; url: string }
|
|
35
|
+
|
|
36
|
+
/** Read the URL of the link wrapping the current selection (empty if none). */
|
|
37
|
+
function readLinkUrl(editor: LexicalEditor): string {
|
|
38
|
+
return editor.getEditorState().read(() => {
|
|
39
|
+
const selection = $getSelection()
|
|
40
|
+
if (!$isRangeSelection(selection)) return ''
|
|
41
|
+
const link = $findMatchingParent(selection.anchor.getNode(), (node) => $isLinkNode(node))
|
|
42
|
+
return $isLinkNode(link) ? link.getURL() : ''
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function dialogOpen(msg: DialogMsg, current: boolean): boolean {
|
|
47
|
+
switch (msg.type) {
|
|
48
|
+
case 'open':
|
|
49
|
+
return true
|
|
50
|
+
case 'close':
|
|
51
|
+
return false
|
|
52
|
+
case 'toggle':
|
|
53
|
+
return !current
|
|
54
|
+
case 'setOpen':
|
|
55
|
+
return msg.open
|
|
56
|
+
case 'animationEnd':
|
|
57
|
+
case 'transitionEnd':
|
|
58
|
+
return current
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface LinkPluginOptions {
|
|
63
|
+
/** Default URL pre-filled when there's no existing link (default ''). */
|
|
64
|
+
defaultUrl?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function linkPlugin(opts: LinkPluginOptions = {}): MarkdownPlugin {
|
|
68
|
+
// Selection saved when the dialog opens (the modal steals focus/selection),
|
|
69
|
+
// restored on commit. Encapsulated in the plugin instance — not the core editor.
|
|
70
|
+
let savedSelection: BaseSelection | null = null
|
|
71
|
+
|
|
72
|
+
const item: CommandItem = {
|
|
73
|
+
id: 'link',
|
|
74
|
+
label: 'Link',
|
|
75
|
+
icon: 'link',
|
|
76
|
+
group: 'inline',
|
|
77
|
+
keywords: ['url', 'href'],
|
|
78
|
+
run: (_editor, ctx) => ctx.send({ type: 'plugin', name: PLUGIN, msg: { type: 'open' } }),
|
|
79
|
+
isActive: (f) => f.link,
|
|
80
|
+
surfaces: ['toolbar', 'floating', 'context'],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
name: PLUGIN,
|
|
85
|
+
items: [item],
|
|
86
|
+
ui: definePluginUI<LinkState, LinkMsg, LinkEffect>({
|
|
87
|
+
init: () => ({ dialog: { open: false }, url: opts.defaultUrl ?? '' }),
|
|
88
|
+
update: (state, msg) => {
|
|
89
|
+
switch (msg.type) {
|
|
90
|
+
case 'open':
|
|
91
|
+
return [state, [{ type: 'begin' }]]
|
|
92
|
+
case 'show':
|
|
93
|
+
return { dialog: { open: true }, url: msg.url }
|
|
94
|
+
case 'setUrl':
|
|
95
|
+
return { ...state, url: msg.url }
|
|
96
|
+
case 'submit':
|
|
97
|
+
return [{ ...state, dialog: { open: false } }, [{ type: 'commit', url: state.url }]]
|
|
98
|
+
case 'dialog': {
|
|
99
|
+
const open = dialogOpen(msg.msg, state.dialog.open)
|
|
100
|
+
return open === state.dialog.open ? state : { ...state, dialog: { open } }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
view: ({ state, send }) => [
|
|
105
|
+
linkDialog({
|
|
106
|
+
dialog: state.at('dialog'),
|
|
107
|
+
url: state.at('url'),
|
|
108
|
+
onInput: (url) => send({ type: 'setUrl', url }),
|
|
109
|
+
onSubmit: () => send({ type: 'submit' }),
|
|
110
|
+
onDialog: (msg) => send({ type: 'dialog', msg }),
|
|
111
|
+
}),
|
|
112
|
+
],
|
|
113
|
+
onEffect: (effect, ctx) => {
|
|
114
|
+
const editor = ctx.editor()
|
|
115
|
+
if (!editor) return
|
|
116
|
+
if (effect.type === 'begin') {
|
|
117
|
+
savedSelection = editor.getEditorState().read(() => {
|
|
118
|
+
const selection = $getSelection()
|
|
119
|
+
return selection ? selection.clone() : null
|
|
120
|
+
})
|
|
121
|
+
ctx.send({ type: 'show', url: readLinkUrl(editor) })
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
const url = effect.url.trim()
|
|
125
|
+
editor.update(() => {
|
|
126
|
+
if (savedSelection) $setSelection(savedSelection.clone())
|
|
127
|
+
$toggleLink(url === '' ? null : url)
|
|
128
|
+
})
|
|
129
|
+
savedSelection = null
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
}
|
|
133
|
+
}
|