@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,179 @@
|
|
|
1
|
+
// The core plugin: the GFM superset of nodes, transformers, command items, and
|
|
2
|
+
// shortcuts. This is the default plugin when none are supplied, so the minimal
|
|
3
|
+
// `markdownEditor()` one-liner still has full keyboard formatting.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
$createParagraphNode,
|
|
7
|
+
$getSelection,
|
|
8
|
+
$isRangeSelection,
|
|
9
|
+
REDO_COMMAND,
|
|
10
|
+
UNDO_COMMAND,
|
|
11
|
+
type ElementNode,
|
|
12
|
+
type LexicalEditor,
|
|
13
|
+
} from 'lexical'
|
|
14
|
+
import { mergeRegister } from '@lexical/utils'
|
|
15
|
+
import { $setBlocksType } from '@lexical/selection'
|
|
16
|
+
import { $createHeadingNode, $createQuoteNode, type HeadingTagType } from '@lexical/rich-text'
|
|
17
|
+
import { $createCodeNode } from '@lexical/code-core'
|
|
18
|
+
import {
|
|
19
|
+
INSERT_CHECK_LIST_COMMAND,
|
|
20
|
+
INSERT_ORDERED_LIST_COMMAND,
|
|
21
|
+
INSERT_UNORDERED_LIST_COMMAND,
|
|
22
|
+
registerCheckList,
|
|
23
|
+
registerList,
|
|
24
|
+
} from '@lexical/list'
|
|
25
|
+
import type { CommandContext, CommandItem, MarkdownPlugin } from './types.js'
|
|
26
|
+
import { inlineItems } from './inline.js'
|
|
27
|
+
import { GFM_NODES, GFM_TRANSFORMERS } from '../transformers/gfm.js'
|
|
28
|
+
|
|
29
|
+
const NOOP_CTX: CommandContext = { send: () => {} }
|
|
30
|
+
|
|
31
|
+
/** Wrap a node-factory into an editor action that converts the selected blocks. */
|
|
32
|
+
function block(create: () => ElementNode): (editor: LexicalEditor) => void {
|
|
33
|
+
return (editor) =>
|
|
34
|
+
editor.update(() => {
|
|
35
|
+
const selection = $getSelection()
|
|
36
|
+
if ($isRangeSelection(selection)) $setBlocksType(selection, create)
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function heading(tag: HeadingTagType): (editor: LexicalEditor) => void {
|
|
41
|
+
return block(() => $createHeadingNode(tag))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CorePluginOptions {
|
|
45
|
+
/** Reserved for future core options. */
|
|
46
|
+
readonly _?: never
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function corePlugin(_opts: CorePluginOptions = {}): MarkdownPlugin {
|
|
50
|
+
const items: CommandItem[] = [
|
|
51
|
+
...inlineItems(),
|
|
52
|
+
{
|
|
53
|
+
id: 'paragraph',
|
|
54
|
+
label: 'Text',
|
|
55
|
+
icon: 'paragraph',
|
|
56
|
+
group: 'block',
|
|
57
|
+
keywords: ['body', 'normal'],
|
|
58
|
+
run: block(() => $createParagraphNode()),
|
|
59
|
+
isActive: (f) => f.blockType === 'paragraph',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'h1',
|
|
63
|
+
label: 'Heading 1',
|
|
64
|
+
icon: 'h1',
|
|
65
|
+
group: 'block',
|
|
66
|
+
keywords: ['title'],
|
|
67
|
+
run: heading('h1'),
|
|
68
|
+
isActive: (f) => f.blockType === 'h1',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'h2',
|
|
72
|
+
label: 'Heading 2',
|
|
73
|
+
icon: 'h2',
|
|
74
|
+
group: 'block',
|
|
75
|
+
run: heading('h2'),
|
|
76
|
+
isActive: (f) => f.blockType === 'h2',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'h3',
|
|
80
|
+
label: 'Heading 3',
|
|
81
|
+
icon: 'h3',
|
|
82
|
+
group: 'block',
|
|
83
|
+
run: heading('h3'),
|
|
84
|
+
isActive: (f) => f.blockType === 'h3',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'quote',
|
|
88
|
+
label: 'Quote',
|
|
89
|
+
icon: 'quote',
|
|
90
|
+
group: 'block',
|
|
91
|
+
keywords: ['blockquote'],
|
|
92
|
+
run: block(() => $createQuoteNode()),
|
|
93
|
+
isActive: (f) => f.blockType === 'quote',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'codeBlock',
|
|
97
|
+
label: 'Code block',
|
|
98
|
+
icon: 'code-block',
|
|
99
|
+
group: 'block',
|
|
100
|
+
keywords: ['fence', 'pre'],
|
|
101
|
+
run: block(() => $createCodeNode()),
|
|
102
|
+
isActive: (f) => f.blockType === 'code',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: 'bulletList',
|
|
106
|
+
label: 'Bulleted list',
|
|
107
|
+
icon: 'list-bullet',
|
|
108
|
+
group: 'list',
|
|
109
|
+
keywords: ['unordered', 'ul'],
|
|
110
|
+
run: (e) => e.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined),
|
|
111
|
+
isActive: (f) => f.blockType === 'bullet',
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: 'numberList',
|
|
115
|
+
label: 'Numbered list',
|
|
116
|
+
icon: 'list-number',
|
|
117
|
+
group: 'list',
|
|
118
|
+
keywords: ['ordered', 'ol'],
|
|
119
|
+
run: (e) => e.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined),
|
|
120
|
+
isActive: (f) => f.blockType === 'number',
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: 'checkList',
|
|
124
|
+
label: 'Task list',
|
|
125
|
+
icon: 'list-check',
|
|
126
|
+
group: 'list',
|
|
127
|
+
keywords: ['todo', 'checkbox'],
|
|
128
|
+
run: (e) => e.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined),
|
|
129
|
+
isActive: (f) => f.blockType === 'check',
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: 'undo',
|
|
133
|
+
label: 'Undo',
|
|
134
|
+
icon: 'undo',
|
|
135
|
+
group: 'history',
|
|
136
|
+
run: (e) => e.dispatchCommand(UNDO_COMMAND, undefined),
|
|
137
|
+
isDisabled: (f) => !f.canUndo,
|
|
138
|
+
surfaces: ['toolbar', 'context'],
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: 'redo',
|
|
142
|
+
label: 'Redo',
|
|
143
|
+
icon: 'redo',
|
|
144
|
+
group: 'history',
|
|
145
|
+
run: (e) => e.dispatchCommand(REDO_COMMAND, undefined),
|
|
146
|
+
isDisabled: (f) => !f.canRedo,
|
|
147
|
+
surfaces: ['toolbar', 'context'],
|
|
148
|
+
},
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
const byId = new Map(items.map((i) => [i.id, i]))
|
|
152
|
+
const shortcut =
|
|
153
|
+
(id: string) =>
|
|
154
|
+
(editor: LexicalEditor): boolean => {
|
|
155
|
+
const item = byId.get(id)
|
|
156
|
+
if (!item) return false
|
|
157
|
+
item.run(editor, NOOP_CTX)
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
name: 'core',
|
|
163
|
+
nodes: GFM_NODES,
|
|
164
|
+
transformers: GFM_TRANSFORMERS,
|
|
165
|
+
items,
|
|
166
|
+
// registerList wires list commands/indentation; registerCheckList adds the
|
|
167
|
+
// click-to-toggle on task items. (Linking is handled by the editor's link
|
|
168
|
+
// dialog via $toggleLink — see commitLink in editor.ts.)
|
|
169
|
+
register: (editor) => mergeRegister(registerList(editor), registerCheckList(editor)),
|
|
170
|
+
shortcuts: [
|
|
171
|
+
{ combo: 'Mod-Alt-1', run: shortcut('h1') },
|
|
172
|
+
{ combo: 'Mod-Alt-2', run: shortcut('h2') },
|
|
173
|
+
{ combo: 'Mod-Alt-3', run: shortcut('h3') },
|
|
174
|
+
{ combo: 'Mod-Shift-7', run: shortcut('numberList') },
|
|
175
|
+
{ combo: 'Mod-Shift-8', run: shortcut('bulletList') },
|
|
176
|
+
{ combo: 'Mod-Shift-9', run: shortcut('quote') },
|
|
177
|
+
],
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Emoji plugin — replace `:shortcode:` with the emoji while typing or on import,
|
|
2
|
+
// via a text-match transformer. Unknown shortcodes are left untouched.
|
|
3
|
+
|
|
4
|
+
import { $createTextNode, type TextNode } from 'lexical'
|
|
5
|
+
import type { TextMatchTransformer } from '@lexical/markdown'
|
|
6
|
+
import type { MarkdownPlugin } from './types.js'
|
|
7
|
+
|
|
8
|
+
/** A small default shortcode → emoji map. Extend via `emojiPlugin({ emoji })`. */
|
|
9
|
+
export const DEFAULT_EMOJI: Readonly<Record<string, string>> = {
|
|
10
|
+
smile: '😄',
|
|
11
|
+
grin: '😁',
|
|
12
|
+
joy: '😂',
|
|
13
|
+
wink: '😉',
|
|
14
|
+
heart: '❤️',
|
|
15
|
+
thumbsup: '👍',
|
|
16
|
+
'+1': '👍',
|
|
17
|
+
thumbsdown: '👎',
|
|
18
|
+
fire: '🔥',
|
|
19
|
+
tada: '🎉',
|
|
20
|
+
rocket: '🚀',
|
|
21
|
+
star: '⭐',
|
|
22
|
+
check: '✅',
|
|
23
|
+
x: '❌',
|
|
24
|
+
warning: '⚠️',
|
|
25
|
+
bulb: '💡',
|
|
26
|
+
eyes: '👀',
|
|
27
|
+
sparkles: '✨',
|
|
28
|
+
'100': '💯',
|
|
29
|
+
thinking: '🤔',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function transformer(map: Readonly<Record<string, string>>): TextMatchTransformer {
|
|
33
|
+
return {
|
|
34
|
+
dependencies: [],
|
|
35
|
+
// `null` keeps the emoji as a unicode character in the exported markdown.
|
|
36
|
+
export: () => null,
|
|
37
|
+
importRegExp: /:([a-z0-9_+-]+):/,
|
|
38
|
+
regExp: /:([a-z0-9_+-]+):$/,
|
|
39
|
+
trigger: ':',
|
|
40
|
+
replace: (node: TextNode, match: RegExpMatchArray): void => {
|
|
41
|
+
const emoji = map[match[1] ?? '']
|
|
42
|
+
if (emoji) node.replace($createTextNode(emoji))
|
|
43
|
+
},
|
|
44
|
+
type: 'text-match',
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface EmojiPluginOptions {
|
|
49
|
+
/** Extra/override shortcode → emoji entries (merged over the defaults). */
|
|
50
|
+
emoji?: Readonly<Record<string, string>>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function emojiPlugin(opts: EmojiPluginOptions = {}): MarkdownPlugin {
|
|
54
|
+
const map = { ...DEFAULT_EMOJI, ...(opts.emoji ?? {}) }
|
|
55
|
+
return {
|
|
56
|
+
name: 'emoji',
|
|
57
|
+
transformers: [transformer(map)],
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -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,56 @@
|
|
|
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 { hr } from '@llui/dom'
|
|
14
|
+
import type { MarkdownPlugin } from './types.js'
|
|
15
|
+
|
|
16
|
+
const BRIDGE_TYPE = 'hr'
|
|
17
|
+
|
|
18
|
+
const hrBridge = decoratorBridge<Record<string, never>>(BRIDGE_TYPE, () => [
|
|
19
|
+
hr({ 'data-md-hr': '', contenteditable: 'false' }),
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
const HR_TRANSFORMER: ElementTransformer = {
|
|
23
|
+
dependencies: [LLuiDecoratorNode],
|
|
24
|
+
export: (node: LexicalNode): string | null =>
|
|
25
|
+
$isLLuiDecoratorNode(node) && node.getBridgeType() === BRIDGE_TYPE ? '---' : null,
|
|
26
|
+
regExp: /^(---|\*\*\*|___)\s*$/,
|
|
27
|
+
replace: (parentNode: ElementNode): void => {
|
|
28
|
+
parentNode.replace($createLLuiDecoratorNode(BRIDGE_TYPE, {}))
|
|
29
|
+
},
|
|
30
|
+
type: 'element',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Insert a horizontal rule at the current selection. */
|
|
34
|
+
export function $insertHorizontalRule(): void {
|
|
35
|
+
$insertNodeToNearestRoot($createLLuiDecoratorNode(BRIDGE_TYPE, {}))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function hrPlugin(): MarkdownPlugin {
|
|
39
|
+
return {
|
|
40
|
+
name: 'hr',
|
|
41
|
+
nodes: [LLuiDecoratorNode],
|
|
42
|
+
decorators: [hrBridge],
|
|
43
|
+
transformers: [HR_TRANSFORMER],
|
|
44
|
+
items: [
|
|
45
|
+
{
|
|
46
|
+
id: 'horizontalRule',
|
|
47
|
+
label: 'Divider',
|
|
48
|
+
icon: 'hr',
|
|
49
|
+
group: 'insert',
|
|
50
|
+
keywords: ['rule', 'divider', 'separator', 'hr'],
|
|
51
|
+
run: (editor) => editor.update(() => $insertHorizontalRule()),
|
|
52
|
+
surfaces: ['toolbar', 'slash', 'context'],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
}
|
|
56
|
+
}
|