@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,240 @@
|
|
|
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 LexicalEditor,
|
|
12
|
+
type LexicalNode,
|
|
13
|
+
} from 'lexical'
|
|
14
|
+
import { $insertNodeToNearestRoot } from '@lexical/utils'
|
|
15
|
+
import type { ElementTransformer } from '@lexical/markdown'
|
|
16
|
+
import {
|
|
17
|
+
$createLLuiDecoratorNode,
|
|
18
|
+
$isLLuiDecoratorNode,
|
|
19
|
+
LLuiDecoratorNode,
|
|
20
|
+
decoratorBridge,
|
|
21
|
+
} from '@llui/lexical'
|
|
22
|
+
import { button, div, img, input, text, type Signal } from '@llui/dom'
|
|
23
|
+
import {
|
|
24
|
+
connect as connectDialog,
|
|
25
|
+
overlay as overlayDialog,
|
|
26
|
+
type DialogMsg,
|
|
27
|
+
} from '@llui/components/dialog'
|
|
28
|
+
import { sanitizeImageUrl } from '../security.js'
|
|
29
|
+
import { definePluginUI } from './ui.js'
|
|
30
|
+
import type { MarkdownPlugin } from './types.js'
|
|
31
|
+
|
|
32
|
+
const BRIDGE_TYPE = 'image'
|
|
33
|
+
|
|
34
|
+
interface ImageData {
|
|
35
|
+
src: string
|
|
36
|
+
alt: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isImageData(value: unknown): value is ImageData {
|
|
40
|
+
return (
|
|
41
|
+
typeof value === 'object' &&
|
|
42
|
+
value !== null &&
|
|
43
|
+
typeof (value as ImageData).src === 'string' &&
|
|
44
|
+
typeof (value as ImageData).alt === 'string'
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const imageBridge = decoratorBridge<ImageData>(BRIDGE_TYPE, (data) => [
|
|
49
|
+
div({ 'data-scope': 'md-image', 'data-part': 'root', contenteditable: 'false' }, [
|
|
50
|
+
img({ src: data.at('src') as Signal<string>, alt: data.at('alt') as Signal<string> }),
|
|
51
|
+
]),
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
const IMAGE_TRANSFORMER: ElementTransformer = {
|
|
55
|
+
dependencies: [LLuiDecoratorNode],
|
|
56
|
+
export: (node: LexicalNode): string | null => {
|
|
57
|
+
if (!$isLLuiDecoratorNode(node) || node.getBridgeType() !== BRIDGE_TYPE) return null
|
|
58
|
+
const data = node.getData()
|
|
59
|
+
return isImageData(data) ? `` : null
|
|
60
|
+
},
|
|
61
|
+
regExp: /^!\[([^\]]*)\]\(([^)]+)\)$/,
|
|
62
|
+
replace: (parentNode: ElementNode, _children, match): void => {
|
|
63
|
+
// Enforce the image-src allowlist on import/paste: a disallowed scheme
|
|
64
|
+
// (e.g. `javascript:`) sanitizes to null → drop the image entirely rather
|
|
65
|
+
// than materialize a decorator node bound to an unsafe src.
|
|
66
|
+
const src = sanitizeImageUrl(match[2] ?? '')
|
|
67
|
+
if (src === null) return
|
|
68
|
+
parentNode.replace($createLLuiDecoratorNode(BRIDGE_TYPE, { alt: match[1] ?? '', src }))
|
|
69
|
+
},
|
|
70
|
+
type: 'element',
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface ImageState {
|
|
74
|
+
dialog: { open: boolean }
|
|
75
|
+
src: string
|
|
76
|
+
alt: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type ImageMsg =
|
|
80
|
+
| { type: 'open' }
|
|
81
|
+
| { type: 'setSrc'; src: string }
|
|
82
|
+
| { type: 'setAlt'; alt: string }
|
|
83
|
+
| { type: 'submit' }
|
|
84
|
+
| { type: 'dialog'; msg: DialogMsg }
|
|
85
|
+
|
|
86
|
+
type ImageEffect = { type: 'begin' } | { type: 'insert'; src: string; alt: string }
|
|
87
|
+
|
|
88
|
+
function dialogOpen(msg: DialogMsg, current: boolean): boolean {
|
|
89
|
+
switch (msg.type) {
|
|
90
|
+
case 'open':
|
|
91
|
+
return true
|
|
92
|
+
case 'close':
|
|
93
|
+
return false
|
|
94
|
+
case 'toggle':
|
|
95
|
+
return !current
|
|
96
|
+
case 'setOpen':
|
|
97
|
+
return msg.open
|
|
98
|
+
case 'animationEnd':
|
|
99
|
+
case 'transitionEnd':
|
|
100
|
+
return current
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ImagePluginOptions {
|
|
105
|
+
/** Upload a chosen file and resolve to its URL. When omitted, the file picker
|
|
106
|
+
* is hidden and only URL entry is offered. */
|
|
107
|
+
upload?: (file: File) => Promise<string>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function imagePlugin(opts: ImagePluginOptions = {}): MarkdownPlugin {
|
|
111
|
+
// Keyed by the per-mount editor so two mounts never cross-wire the selection
|
|
112
|
+
// saved while the insert dialog is open.
|
|
113
|
+
const savedSelection = new WeakMap<LexicalEditor, BaseSelection | null>()
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
name: 'image',
|
|
117
|
+
nodes: [LLuiDecoratorNode],
|
|
118
|
+
decorators: [imageBridge],
|
|
119
|
+
transformers: [IMAGE_TRANSFORMER],
|
|
120
|
+
items: [
|
|
121
|
+
{
|
|
122
|
+
id: 'image',
|
|
123
|
+
label: 'Image',
|
|
124
|
+
icon: 'image',
|
|
125
|
+
group: 'insert',
|
|
126
|
+
keywords: ['img', 'picture', 'photo'],
|
|
127
|
+
run: (_editor, ctx) => ctx.send({ type: 'plugin', name: 'image', msg: { type: 'open' } }),
|
|
128
|
+
surfaces: ['toolbar', 'slash', 'context'],
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
ui: definePluginUI<ImageState, ImageMsg, ImageEffect>({
|
|
132
|
+
init: () => ({ dialog: { open: false }, src: '', alt: '' }),
|
|
133
|
+
update: (state, msg) => {
|
|
134
|
+
switch (msg.type) {
|
|
135
|
+
case 'open':
|
|
136
|
+
return [{ dialog: { open: true }, src: '', alt: '' }, [{ type: 'begin' }]]
|
|
137
|
+
case 'setSrc':
|
|
138
|
+
return { ...state, src: msg.src }
|
|
139
|
+
case 'setAlt':
|
|
140
|
+
return { ...state, alt: msg.alt }
|
|
141
|
+
case 'submit':
|
|
142
|
+
return [
|
|
143
|
+
{ ...state, dialog: { open: false } },
|
|
144
|
+
[{ type: 'insert', src: state.src, alt: state.alt }],
|
|
145
|
+
]
|
|
146
|
+
case 'dialog': {
|
|
147
|
+
const open = dialogOpen(msg.msg, state.dialog.open)
|
|
148
|
+
return open === state.dialog.open ? state : { ...state, dialog: { open } }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
view: ({ state, send }) => {
|
|
153
|
+
const dialogSend = (msg: DialogMsg): void => send({ type: 'dialog', msg })
|
|
154
|
+
const parts = connectDialog(state.at('dialog'), dialogSend, {
|
|
155
|
+
id: 'md-image-dialog',
|
|
156
|
+
closeLabel: 'Cancel',
|
|
157
|
+
})
|
|
158
|
+
return [
|
|
159
|
+
overlayDialog({
|
|
160
|
+
state: state.at('dialog'),
|
|
161
|
+
send: dialogSend,
|
|
162
|
+
parts,
|
|
163
|
+
content: () => [
|
|
164
|
+
div({ ...parts.content, 'data-md-link': 'box' }, [
|
|
165
|
+
div({ ...parts.title, 'data-md-link': 'title' }, [text('Insert image')]),
|
|
166
|
+
input({
|
|
167
|
+
'data-md-link': 'input',
|
|
168
|
+
type: 'url',
|
|
169
|
+
placeholder: 'https://example.com/image.png',
|
|
170
|
+
value: state.at('src') as Signal<string>,
|
|
171
|
+
onInput: (e: Event) =>
|
|
172
|
+
send({ type: 'setSrc', src: (e.target as HTMLInputElement).value }),
|
|
173
|
+
}),
|
|
174
|
+
input({
|
|
175
|
+
'data-md-link': 'input',
|
|
176
|
+
'data-md-image': 'alt',
|
|
177
|
+
type: 'text',
|
|
178
|
+
placeholder: 'Alt text (description)',
|
|
179
|
+
value: state.at('alt') as Signal<string>,
|
|
180
|
+
onInput: (e: Event) =>
|
|
181
|
+
send({ type: 'setAlt', alt: (e.target as HTMLInputElement).value }),
|
|
182
|
+
}),
|
|
183
|
+
...(opts.upload
|
|
184
|
+
? [
|
|
185
|
+
input({
|
|
186
|
+
'data-md-image': 'file',
|
|
187
|
+
type: 'file',
|
|
188
|
+
accept: 'image/*',
|
|
189
|
+
onChange: (e: Event) => {
|
|
190
|
+
const file = (e.target as HTMLInputElement).files?.[0]
|
|
191
|
+
if (file && opts.upload) {
|
|
192
|
+
void opts.upload(file).then((src) => send({ type: 'setSrc', src }))
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
}),
|
|
196
|
+
]
|
|
197
|
+
: []),
|
|
198
|
+
div({ 'data-md-link': 'actions' }, [
|
|
199
|
+
button({ ...parts.closeTrigger, 'data-md-link': 'cancel' }, [text('Cancel')]),
|
|
200
|
+
button(
|
|
201
|
+
{
|
|
202
|
+
type: 'button',
|
|
203
|
+
'data-md-link': 'apply',
|
|
204
|
+
onClick: () => send({ type: 'submit' }),
|
|
205
|
+
},
|
|
206
|
+
[text('Insert')],
|
|
207
|
+
),
|
|
208
|
+
]),
|
|
209
|
+
]),
|
|
210
|
+
],
|
|
211
|
+
}),
|
|
212
|
+
]
|
|
213
|
+
},
|
|
214
|
+
onEffect: (effect, ctx) => {
|
|
215
|
+
const editor = ctx.editor()
|
|
216
|
+
if (!editor) return
|
|
217
|
+
if (effect.type === 'begin') {
|
|
218
|
+
savedSelection.set(
|
|
219
|
+
editor,
|
|
220
|
+
editor.getEditorState().read(() => {
|
|
221
|
+
const selection = $getSelection()
|
|
222
|
+
return selection ? selection.clone() : null
|
|
223
|
+
}),
|
|
224
|
+
)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
// Enforce the image-src allowlist at insert: a disallowed scheme drops
|
|
228
|
+
// the insertion rather than binding the decorator to an unsafe src.
|
|
229
|
+
const src = sanitizeImageUrl(effect.src.trim())
|
|
230
|
+
if (src === null) return
|
|
231
|
+
const saved = savedSelection.get(editor) ?? null
|
|
232
|
+
editor.update(() => {
|
|
233
|
+
if (saved) $setSelection(saved.clone())
|
|
234
|
+
$insertNodeToNearestRoot($createLLuiDecoratorNode(BRIDGE_TYPE, { src, alt: effect.alt }))
|
|
235
|
+
})
|
|
236
|
+
savedSelection.delete(editor)
|
|
237
|
+
},
|
|
238
|
+
}),
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -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,143 @@
|
|
|
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 { sanitizeLinkUrl } from '../security.js'
|
|
18
|
+
import { definePluginUI } from './ui.js'
|
|
19
|
+
import type { CommandItem, MarkdownPlugin } from './types.js'
|
|
20
|
+
|
|
21
|
+
const PLUGIN = 'link'
|
|
22
|
+
|
|
23
|
+
interface LinkState {
|
|
24
|
+
dialog: DialogState
|
|
25
|
+
url: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type LinkMsg =
|
|
29
|
+
| { type: 'open' }
|
|
30
|
+
| { type: 'show'; url: string }
|
|
31
|
+
| { type: 'setUrl'; url: string }
|
|
32
|
+
| { type: 'submit' }
|
|
33
|
+
| { type: 'dialog'; msg: DialogMsg }
|
|
34
|
+
|
|
35
|
+
type LinkEffect = { type: 'begin' } | { type: 'commit'; url: string }
|
|
36
|
+
|
|
37
|
+
/** Read the URL of the link wrapping the current selection (empty if none). */
|
|
38
|
+
function readLinkUrl(editor: LexicalEditor): string {
|
|
39
|
+
return editor.getEditorState().read(() => {
|
|
40
|
+
const selection = $getSelection()
|
|
41
|
+
if (!$isRangeSelection(selection)) return ''
|
|
42
|
+
const link = $findMatchingParent(selection.anchor.getNode(), (node) => $isLinkNode(node))
|
|
43
|
+
return $isLinkNode(link) ? link.getURL() : ''
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function dialogOpen(msg: DialogMsg, current: boolean): boolean {
|
|
48
|
+
switch (msg.type) {
|
|
49
|
+
case 'open':
|
|
50
|
+
return true
|
|
51
|
+
case 'close':
|
|
52
|
+
return false
|
|
53
|
+
case 'toggle':
|
|
54
|
+
return !current
|
|
55
|
+
case 'setOpen':
|
|
56
|
+
return msg.open
|
|
57
|
+
case 'animationEnd':
|
|
58
|
+
case 'transitionEnd':
|
|
59
|
+
return current
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface LinkPluginOptions {
|
|
64
|
+
/** Default URL pre-filled when there's no existing link (default ''). */
|
|
65
|
+
defaultUrl?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function linkPlugin(opts: LinkPluginOptions = {}): MarkdownPlugin {
|
|
69
|
+
// Selection saved when the dialog opens (the modal steals focus/selection),
|
|
70
|
+
// restored on commit. Keyed by the per-mount editor so two mounts of the same
|
|
71
|
+
// plugin instance never cross-wire their saved selection.
|
|
72
|
+
const savedSelection = new WeakMap<LexicalEditor, BaseSelection | null>()
|
|
73
|
+
|
|
74
|
+
const item: CommandItem = {
|
|
75
|
+
id: 'link',
|
|
76
|
+
label: 'Link',
|
|
77
|
+
icon: 'link',
|
|
78
|
+
group: 'inline',
|
|
79
|
+
keywords: ['url', 'href'],
|
|
80
|
+
run: (_editor, ctx) => ctx.send({ type: 'plugin', name: PLUGIN, msg: { type: 'open' } }),
|
|
81
|
+
isActive: (f) => f.link,
|
|
82
|
+
surfaces: ['toolbar', 'floating', 'context'],
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
name: PLUGIN,
|
|
87
|
+
items: [item],
|
|
88
|
+
ui: definePluginUI<LinkState, LinkMsg, LinkEffect>({
|
|
89
|
+
init: () => ({ dialog: { open: false }, url: opts.defaultUrl ?? '' }),
|
|
90
|
+
update: (state, msg) => {
|
|
91
|
+
switch (msg.type) {
|
|
92
|
+
case 'open':
|
|
93
|
+
return [state, [{ type: 'begin' }]]
|
|
94
|
+
case 'show':
|
|
95
|
+
return { dialog: { open: true }, url: msg.url }
|
|
96
|
+
case 'setUrl':
|
|
97
|
+
return { ...state, url: msg.url }
|
|
98
|
+
case 'submit':
|
|
99
|
+
return [{ ...state, dialog: { open: false } }, [{ type: 'commit', url: state.url }]]
|
|
100
|
+
case 'dialog': {
|
|
101
|
+
const open = dialogOpen(msg.msg, state.dialog.open)
|
|
102
|
+
return open === state.dialog.open ? state : { ...state, dialog: { open } }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
view: ({ state, send }) => [
|
|
107
|
+
linkDialog({
|
|
108
|
+
dialog: state.at('dialog'),
|
|
109
|
+
url: state.at('url'),
|
|
110
|
+
onInput: (url) => send({ type: 'setUrl', url }),
|
|
111
|
+
onSubmit: () => send({ type: 'submit' }),
|
|
112
|
+
onDialog: (msg) => send({ type: 'dialog', msg }),
|
|
113
|
+
}),
|
|
114
|
+
],
|
|
115
|
+
onEffect: (effect, ctx) => {
|
|
116
|
+
const editor = ctx.editor()
|
|
117
|
+
if (!editor) return
|
|
118
|
+
if (effect.type === 'begin') {
|
|
119
|
+
savedSelection.set(
|
|
120
|
+
editor,
|
|
121
|
+
editor.getEditorState().read(() => {
|
|
122
|
+
const selection = $getSelection()
|
|
123
|
+
return selection ? selection.clone() : null
|
|
124
|
+
}),
|
|
125
|
+
)
|
|
126
|
+
ctx.send({ type: 'show', url: readLinkUrl(editor) })
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
// Enforce the URL-scheme allowlist at commit: a `javascript:`/`data:`
|
|
130
|
+
// href sanitizes to null → no link is created (unlink). The global
|
|
131
|
+
// LinkNode transform is the backstop, but blocking here avoids ever
|
|
132
|
+
// materializing the unsafe link.
|
|
133
|
+
const safe = sanitizeLinkUrl(effect.url.trim())
|
|
134
|
+
const saved = savedSelection.get(editor) ?? null
|
|
135
|
+
editor.update(() => {
|
|
136
|
+
if (saved) $setSelection(saved.clone())
|
|
137
|
+
$toggleLink(safe === null || safe === '' ? null : safe)
|
|
138
|
+
})
|
|
139
|
+
savedSelection.delete(editor)
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Math plugin — a block math node ($$…$$) rendered via the decorator bridge. The
|
|
2
|
+
// TeX source is editable inline (persists on blur); an optional `render` callback
|
|
3
|
+
// (e.g. KaTeX) turns it into a typeset preview. Round-trips to `$$tex$$`.
|
|
4
|
+
|
|
5
|
+
import { type ElementNode, type LexicalNode } from 'lexical'
|
|
6
|
+
import { $insertNodeToNearestRoot } from '@lexical/utils'
|
|
7
|
+
import type { ElementTransformer } from '@lexical/markdown'
|
|
8
|
+
import {
|
|
9
|
+
$createLLuiDecoratorNode,
|
|
10
|
+
$isLLuiDecoratorNode,
|
|
11
|
+
LLuiDecoratorNode,
|
|
12
|
+
decoratorBridge,
|
|
13
|
+
} from '@llui/lexical'
|
|
14
|
+
import { div, span, text, type Mountable, type Signal } from '@llui/dom'
|
|
15
|
+
import type { MarkdownPlugin } from './types.js'
|
|
16
|
+
import { renderedPreview, type PreviewRender } from './_preview.js'
|
|
17
|
+
|
|
18
|
+
const BRIDGE_TYPE = 'math'
|
|
19
|
+
|
|
20
|
+
interface MathData {
|
|
21
|
+
tex: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isMathData(value: unknown): value is MathData {
|
|
25
|
+
return typeof value === 'object' && value !== null && typeof (value as MathData).tex === 'string'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const stop = (e: Event): void => e.stopPropagation()
|
|
29
|
+
|
|
30
|
+
export interface MathPluginOptions {
|
|
31
|
+
/** Typeset TeX to an HTML string (e.g. via KaTeX). When omitted, the raw TeX is
|
|
32
|
+
* shown in a styled box. */
|
|
33
|
+
/** Render the TeX source to a preview. Return a DOM `Node` (mounted
|
|
34
|
+
* directly, no sanitization) or a **trusted HTML string** (injected as-is
|
|
35
|
+
* — sanitize it yourself, e.g. via DOMPurify, since it carries document
|
|
36
|
+
* content). See `renderedPreview`. */
|
|
37
|
+
render?: PreviewRender
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function mathPlugin(opts: MathPluginOptions = {}): MarkdownPlugin {
|
|
41
|
+
const bridge = decoratorBridge<MathData>(BRIDGE_TYPE, (data, api) => {
|
|
42
|
+
const children: Mountable[] = [
|
|
43
|
+
span(
|
|
44
|
+
{
|
|
45
|
+
'data-part': 'source',
|
|
46
|
+
contenteditable: 'true',
|
|
47
|
+
role: 'textbox',
|
|
48
|
+
'aria-label': 'TeX source',
|
|
49
|
+
onKeyDown: stop,
|
|
50
|
+
onBeforeInput: stop,
|
|
51
|
+
onPaste: stop,
|
|
52
|
+
onBlur: (e: FocusEvent) => {
|
|
53
|
+
const tex = (e.target as HTMLElement).textContent ?? ''
|
|
54
|
+
if (tex !== data.peek().tex) api.update({ tex })
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
[text(data.at('tex') as Signal<string>)],
|
|
58
|
+
),
|
|
59
|
+
]
|
|
60
|
+
if (opts.render) {
|
|
61
|
+
children.push(renderedPreview(data.at('tex') as Signal<string>, opts.render, 'span'))
|
|
62
|
+
}
|
|
63
|
+
return [
|
|
64
|
+
div({ 'data-scope': 'md-math', 'data-part': 'root', contenteditable: 'false' }, children),
|
|
65
|
+
]
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const transformer: ElementTransformer = {
|
|
69
|
+
dependencies: [LLuiDecoratorNode],
|
|
70
|
+
export: (node: LexicalNode): string | null => {
|
|
71
|
+
if (!$isLLuiDecoratorNode(node) || node.getBridgeType() !== BRIDGE_TYPE) return null
|
|
72
|
+
const data = node.getData()
|
|
73
|
+
return isMathData(data) ? `$$${data.tex}$$` : null
|
|
74
|
+
},
|
|
75
|
+
regExp: /^\$\$(.+)\$\$$/,
|
|
76
|
+
replace: (parentNode: ElementNode, _children, match): void => {
|
|
77
|
+
parentNode.replace($createLLuiDecoratorNode(BRIDGE_TYPE, { tex: match[1] ?? '' }))
|
|
78
|
+
},
|
|
79
|
+
type: 'element',
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
name: 'math',
|
|
84
|
+
nodes: [LLuiDecoratorNode],
|
|
85
|
+
decorators: [bridge],
|
|
86
|
+
transformers: [transformer],
|
|
87
|
+
items: [
|
|
88
|
+
{
|
|
89
|
+
id: 'math',
|
|
90
|
+
label: 'Math block',
|
|
91
|
+
icon: 'math',
|
|
92
|
+
group: 'insert',
|
|
93
|
+
keywords: ['latex', 'tex', 'equation', 'formula'],
|
|
94
|
+
run: (editor) =>
|
|
95
|
+
editor.update(() =>
|
|
96
|
+
$insertNodeToNearestRoot($createLLuiDecoratorNode(BRIDGE_TYPE, { tex: 'e = mc^2' })),
|
|
97
|
+
),
|
|
98
|
+
surfaces: ['slash', 'context'],
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
}
|
|
102
|
+
}
|