@llui/markdown-editor 0.2.13 → 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/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 +10 -8
- package/src/editor.ts +44 -8
- package/src/effects.ts +7 -5
- package/src/paste.ts +5 -0
- package/src/plugins/callout.ts +44 -59
- package/src/plugins/hr.ts +4 -14
- package/src/plugins/image.ts +32 -29
- package/src/plugins/link.ts +20 -10
- package/src/plugins/math.ts +27 -41
- package/src/plugins/mermaid.ts +26 -45
- package/src/plugins/table.ts +7 -4
- package/src/security.ts +89 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llui/markdown-editor",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.14",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -24,9 +24,6 @@
|
|
|
24
24
|
"./styles/editor.css": "./dist/styles/editor.css"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
|
-
"@llui/dom": "^0.11.5",
|
|
28
|
-
"@llui/lexical": "^0.2.8",
|
|
29
|
-
"@llui/components": "^0.12.2",
|
|
30
27
|
"lexical": "^0.46.0",
|
|
31
28
|
"@lexical/markdown": "^0.46.0",
|
|
32
29
|
"@lexical/rich-text": "^0.46.0",
|
|
@@ -35,7 +32,11 @@
|
|
|
35
32
|
"@lexical/table": "^0.46.0",
|
|
36
33
|
"@lexical/selection": "^0.46.0",
|
|
37
34
|
"@lexical/utils": "^0.46.0",
|
|
38
|
-
"@lexical/code-core": "^0.46.0"
|
|
35
|
+
"@lexical/code-core": "^0.46.0",
|
|
36
|
+
"@llui/dom": "^0.11.8",
|
|
37
|
+
"@llui/lexical": "^0.2.10",
|
|
38
|
+
"@llui/markdown": "^0.11.3",
|
|
39
|
+
"@llui/components": "^0.12.4"
|
|
39
40
|
},
|
|
40
41
|
"devDependencies": {
|
|
41
42
|
"lexical": "^0.46.0",
|
|
@@ -50,9 +51,10 @@
|
|
|
50
51
|
"typescript": "^6.0.0",
|
|
51
52
|
"vitest": "^4.1.2",
|
|
52
53
|
"@lexical/code-core": "^0.46.0",
|
|
53
|
-
"@llui/dom": "0.11.
|
|
54
|
-
"@llui/
|
|
55
|
-
"@llui/components": "0.12.
|
|
54
|
+
"@llui/dom": "0.11.8",
|
|
55
|
+
"@llui/markdown": "0.11.3",
|
|
56
|
+
"@llui/components": "0.12.4",
|
|
57
|
+
"@llui/lexical": "0.2.10"
|
|
56
58
|
},
|
|
57
59
|
"sideEffects": [
|
|
58
60
|
"./dist/styles/editor.css"
|
package/src/editor.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import { corePlugin } from './plugins/core.js'
|
|
21
21
|
import { linkPlugin } from './plugins/link.js'
|
|
22
22
|
import { registerMarkdownPaste } from './paste.js'
|
|
23
|
+
import { registerLinkSanitizer } from './security.js'
|
|
23
24
|
import { toolbar as renderToolbar } from './surfaces/toolbar.js'
|
|
24
25
|
import type { CommandItem, MarkdownPlugin } from './plugins/types.js'
|
|
25
26
|
import type { PluginUI } from './plugins/ui.js'
|
|
@@ -108,6 +109,15 @@ function defaultPlugins(): MarkdownPlugin[] {
|
|
|
108
109
|
return [corePlugin(), linkPlugin()]
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/** The per-mount `send` closure — the WeakMap key that ties a live editor to the
|
|
113
|
+
* one mount it belongs to. */
|
|
114
|
+
type EditorSend = (msg: EditorMsg) => void
|
|
115
|
+
|
|
116
|
+
/** Per-mount live state: the Lexical editor captured by that mount's `onReady`. */
|
|
117
|
+
interface MountContext {
|
|
118
|
+
editor: LexicalEditor | null
|
|
119
|
+
}
|
|
120
|
+
|
|
111
121
|
/**
|
|
112
122
|
* Build the markdown editor component. Embed it with `mountApp(el, markdownEditor(...))`
|
|
113
123
|
* or compose it inside a larger component.
|
|
@@ -128,11 +138,23 @@ export function markdownEditor(
|
|
|
128
138
|
.map((p) => ({ name: p.name, ui: p.ui }))
|
|
129
139
|
const pluginUIByName = new Map(pluginUIs.map((p) => [p.name, p.ui]))
|
|
130
140
|
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
141
|
+
// Per-MOUNT live-editor context, keyed by the mount's stable `send` closure —
|
|
142
|
+
// the SAME object the runtime hands to both `view` and `onEffect`. A single
|
|
143
|
+
// definition can be mounted more than once (or re-mounted); each mount gets its
|
|
144
|
+
// own editor reference here, so effects and plugin handlers dispatch to their
|
|
145
|
+
// own editor and unmount clears only that mount's reference (no cross-wiring,
|
|
146
|
+
// no leaked disposed editor). Entries are collected with their `send` key.
|
|
147
|
+
const mountContexts = new WeakMap<EditorSend, MountContext>()
|
|
148
|
+
const contextFor = (send: EditorSend): MountContext => {
|
|
149
|
+
let ctx = mountContexts.get(send)
|
|
150
|
+
if (!ctx) {
|
|
151
|
+
ctx = { editor: null }
|
|
152
|
+
mountContexts.set(send, ctx)
|
|
153
|
+
}
|
|
154
|
+
return ctx
|
|
155
|
+
}
|
|
134
156
|
|
|
135
|
-
const baseOnEffect = makeOnEffect(
|
|
157
|
+
const baseOnEffect = makeOnEffect((api) => contextFor(api.send).editor, itemsById, {
|
|
136
158
|
onChange: config.onChange,
|
|
137
159
|
onFormatChange: config.onFormatChange,
|
|
138
160
|
applyValue: (editor, value) =>
|
|
@@ -192,9 +214,10 @@ export function markdownEditor(
|
|
|
192
214
|
api: { send: (msg: EditorMsg) => void; state: Signal<EditorState> },
|
|
193
215
|
): void => {
|
|
194
216
|
if (effect.type === 'pluginEffect') {
|
|
217
|
+
const mount = contextFor(api.send)
|
|
195
218
|
const ui = pluginUIByName.get(effect.name)
|
|
196
219
|
ui?.onEffect?.(effect.effect, {
|
|
197
|
-
editor:
|
|
220
|
+
editor: () => mount.editor,
|
|
198
221
|
send: (msg) => api.send({ type: 'plugin', name: effect.name, msg }),
|
|
199
222
|
emit: (msg) => api.send(msg as EditorMsg),
|
|
200
223
|
})
|
|
@@ -210,6 +233,10 @@ export function markdownEditor(
|
|
|
210
233
|
state: Signal<EditorState>
|
|
211
234
|
send: (msg: EditorMsg) => void
|
|
212
235
|
}): Renderable => {
|
|
236
|
+
// This mount's live-editor slot (keyed by this mount's `send`). `onReady`
|
|
237
|
+
// fills it; effects/plugins read it through `contextFor(api.send)`.
|
|
238
|
+
const mount = contextFor(send)
|
|
239
|
+
|
|
213
240
|
// Build the collab binding (once, at mount) from the consumer's factory,
|
|
214
241
|
// injecting the markdown seed + status sinks that mirror into `state.collab`.
|
|
215
242
|
const collabBinding: CollabBinding | null = config.collab
|
|
@@ -245,17 +272,26 @@ export function markdownEditor(
|
|
|
245
272
|
? { changeDebounceMs: config.changeDebounceMs }
|
|
246
273
|
: {}),
|
|
247
274
|
register: (editor) => {
|
|
248
|
-
const disposers = [
|
|
275
|
+
const disposers = [
|
|
276
|
+
registerMarkdownShortcuts(editor, transformers),
|
|
277
|
+
// Global scheme allowlist for links: the single backstop covering the
|
|
278
|
+
// link dialog, pasted/imported markdown, and the typed `[x](url)`
|
|
279
|
+
// shortcut. Image src is enforced in the image transformer.
|
|
280
|
+
registerLinkSanitizer(editor),
|
|
281
|
+
]
|
|
249
282
|
if (config.pasteMarkdown !== false)
|
|
250
283
|
disposers.push(registerMarkdownPaste(editor, transformers))
|
|
251
284
|
if (decorators.length > 0) disposers.push(registerDecoratorBridges(editor, decorators))
|
|
252
285
|
if (collabBinding) disposers.push(collabBinding.register(editor))
|
|
253
286
|
return () => {
|
|
254
287
|
for (const dispose of disposers) dispose()
|
|
288
|
+
// Release this mount's editor reference so a disposed editor never
|
|
289
|
+
// leaks and stale effects can't reach a torn-down editor.
|
|
290
|
+
mount.editor = null
|
|
255
291
|
}
|
|
256
292
|
},
|
|
257
293
|
onReady: (editor) => {
|
|
258
|
-
|
|
294
|
+
mount.editor = editor
|
|
259
295
|
if (config.placeholder) {
|
|
260
296
|
editor.getRootElement()?.setAttribute('data-placeholder', config.placeholder)
|
|
261
297
|
}
|
|
@@ -278,7 +314,7 @@ export function markdownEditor(
|
|
|
278
314
|
const rendered = ui.view({
|
|
279
315
|
state: state.at(`plugins.${name}`),
|
|
280
316
|
send: (msg) => send({ type: 'plugin', name, msg }),
|
|
281
|
-
editor:
|
|
317
|
+
editor: () => mount.editor,
|
|
282
318
|
})
|
|
283
319
|
return Array.isArray(rendered) ? rendered : [rendered]
|
|
284
320
|
})
|
package/src/effects.ts
CHANGED
|
@@ -18,23 +18,25 @@ export interface EffectConfig {
|
|
|
18
18
|
applyValue: (editor: LexicalEditor, value: string) => void
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/** Build the component's `onEffect`. `
|
|
22
|
-
*
|
|
21
|
+
/** Build the component's `onEffect`. `resolveEditor` maps the per-mount effect
|
|
22
|
+
* `api` (whose `send` identifies the mount) to that mount's live editor — so two
|
|
23
|
+
* mounts of one definition dispatch to their own editors; `items` is the merged
|
|
24
|
+
* id → command map. */
|
|
23
25
|
export function makeOnEffect(
|
|
24
|
-
|
|
26
|
+
resolveEditor: (api: EffectApi) => LexicalEditor | null,
|
|
25
27
|
items: ReadonlyMap<string, CommandItem>,
|
|
26
28
|
config: EffectConfig,
|
|
27
29
|
): (effect: EditorEffect, api: EffectApi) => void {
|
|
28
30
|
return (effect, api) => {
|
|
29
31
|
switch (effect.type) {
|
|
30
32
|
case 'execCommand': {
|
|
31
|
-
const editor =
|
|
33
|
+
const editor = resolveEditor(api)
|
|
32
34
|
const item = items.get(effect.id)
|
|
33
35
|
if (editor && item) item.run(editor, { send: api.send })
|
|
34
36
|
return
|
|
35
37
|
}
|
|
36
38
|
case 'applyValue': {
|
|
37
|
-
const editor =
|
|
39
|
+
const editor = resolveEditor(api)
|
|
38
40
|
if (editor) config.applyValue(editor, effect.value)
|
|
39
41
|
return
|
|
40
42
|
}
|
package/src/paste.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type LexicalEditor,
|
|
18
18
|
} from 'lexical'
|
|
19
19
|
import { $convertFromMarkdownString, type Transformer } from '@lexical/markdown'
|
|
20
|
+
import { $sanitizeLinkNodes } from './security.js'
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Parse `markdown` with `transformers` and insert the produced nodes at the
|
|
@@ -40,6 +41,10 @@ export function $insertMarkdownAtSelection(
|
|
|
40
41
|
// touches the scratch node, not the live document.
|
|
41
42
|
const scratch = $createParagraphNode()
|
|
42
43
|
$convertFromMarkdownString(markdown, transformers, scratch)
|
|
44
|
+
// Untrusted source: neutralize any `javascript:`/`data:` link the markdown
|
|
45
|
+
// carried BEFORE the nodes are spliced into the live document. (Image src is
|
|
46
|
+
// enforced in the image transformer; links go through here.)
|
|
47
|
+
$sanitizeLinkNodes(scratch)
|
|
43
48
|
const nodes = scratch.getChildren()
|
|
44
49
|
if (nodes.length === 0) return false
|
|
45
50
|
|
package/src/plugins/callout.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
LLuiDecoratorNode,
|
|
13
13
|
decoratorBridge,
|
|
14
14
|
} from '@llui/lexical'
|
|
15
|
-
import { button,
|
|
15
|
+
import { button, div, span, text, type Signal } from '@llui/dom'
|
|
16
16
|
import type { MarkdownPlugin } from './types.js'
|
|
17
17
|
|
|
18
18
|
export type CalloutKind = 'note' | 'tip' | 'warning' | 'danger'
|
|
@@ -46,71 +46,56 @@ function isCalloutData(value: unknown): value is CalloutData {
|
|
|
46
46
|
)
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
type CalloutMsg = { type: 'cycle' } | { type: 'commitText'; text: string }
|
|
50
|
-
|
|
51
49
|
// Keep keyboard/input/paste events from bubbling to the outer Lexical editor so
|
|
52
50
|
// the nested editable text island edits natively without Lexical intercepting.
|
|
53
51
|
const stop = (e: Event): void => e.stopPropagation()
|
|
54
52
|
|
|
55
53
|
/** The LLui sub-view rendered inside a callout DecoratorNode. The badge cycles
|
|
56
54
|
* the kind; the text is an editable island that persists into the Lexical node
|
|
57
|
-
* on blur (both round-trip to markdown).
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
55
|
+
* on blur (both round-trip to markdown). `data` is a reactive signal fed by the
|
|
56
|
+
* bridge, so a data commit updates the view IN PLACE — the editable island keeps
|
|
57
|
+
* its focus/caret instead of the whole sub-app remounting. */
|
|
58
|
+
const calloutBridge = decoratorBridge<CalloutData>(BRIDGE_TYPE, (data, api) => [
|
|
59
|
+
div(
|
|
60
|
+
{
|
|
61
|
+
'data-scope': 'md-callout',
|
|
62
|
+
'data-part': 'root',
|
|
63
|
+
'data-kind': data.at('kind') as Signal<string>,
|
|
64
|
+
contenteditable: 'false',
|
|
65
|
+
onKeyDown: stop,
|
|
66
|
+
onBeforeInput: stop,
|
|
67
|
+
onPaste: stop,
|
|
68
|
+
},
|
|
69
|
+
[
|
|
70
|
+
button(
|
|
71
|
+
{
|
|
72
|
+
type: 'button',
|
|
73
|
+
'data-part': 'badge',
|
|
74
|
+
'aria-label': 'Change callout kind',
|
|
75
|
+
onClick: () => {
|
|
76
|
+
const cur = data.peek()
|
|
77
|
+
api.update({ kind: nextKind(cur.kind), text: cur.text })
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
[text(data.at('kind').map((k) => KIND_LABEL[k]))],
|
|
81
|
+
),
|
|
82
|
+
span(
|
|
83
|
+
{
|
|
84
|
+
'data-part': 'text',
|
|
85
|
+
contenteditable: 'true',
|
|
86
|
+
role: 'textbox',
|
|
87
|
+
'aria-label': 'Callout text',
|
|
88
|
+
onBlur: (e: FocusEvent) => {
|
|
89
|
+
const cur = data.peek()
|
|
90
|
+
const value = (e.target as HTMLElement).textContent ?? ''
|
|
91
|
+
if (value !== cur.text) api.update({ kind: cur.kind, text: value })
|
|
87
92
|
},
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
onClick: () => send({ type: 'cycle' }),
|
|
95
|
-
},
|
|
96
|
-
[text(state.at('kind').map((k) => KIND_LABEL[k]))],
|
|
97
|
-
),
|
|
98
|
-
span(
|
|
99
|
-
{
|
|
100
|
-
'data-part': 'text',
|
|
101
|
-
contenteditable: 'true',
|
|
102
|
-
role: 'textbox',
|
|
103
|
-
'aria-label': 'Callout text',
|
|
104
|
-
onBlur: (e: FocusEvent) =>
|
|
105
|
-
send({ type: 'commitText', text: (e.target as HTMLElement).textContent ?? '' }),
|
|
106
|
-
},
|
|
107
|
-
[text(state.at('text') as Signal<string>)],
|
|
108
|
-
),
|
|
109
|
-
],
|
|
110
|
-
),
|
|
111
|
-
],
|
|
112
|
-
}),
|
|
113
|
-
)
|
|
93
|
+
},
|
|
94
|
+
[text(data.at('text') as Signal<string>)],
|
|
95
|
+
),
|
|
96
|
+
],
|
|
97
|
+
),
|
|
98
|
+
])
|
|
114
99
|
|
|
115
100
|
/** `:::kind text` element transformer (single-line admonition). */
|
|
116
101
|
const CALLOUT_TRANSFORMER: ElementTransformer = {
|
package/src/plugins/hr.ts
CHANGED
|
@@ -10,24 +10,14 @@ import {
|
|
|
10
10
|
LLuiDecoratorNode,
|
|
11
11
|
decoratorBridge,
|
|
12
12
|
} from '@llui/lexical'
|
|
13
|
-
import {
|
|
13
|
+
import { hr } from '@llui/dom'
|
|
14
14
|
import type { MarkdownPlugin } from './types.js'
|
|
15
15
|
|
|
16
16
|
const BRIDGE_TYPE = 'hr'
|
|
17
17
|
|
|
18
|
-
const hrBridge = decoratorBridge<
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
)
|
|
18
|
+
const hrBridge = decoratorBridge<Record<string, never>>(BRIDGE_TYPE, () => [
|
|
19
|
+
hr({ 'data-md-hr': '', contenteditable: 'false' }),
|
|
20
|
+
])
|
|
31
21
|
|
|
32
22
|
const HR_TRANSFORMER: ElementTransformer = {
|
|
33
23
|
dependencies: [LLuiDecoratorNode],
|
package/src/plugins/image.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
$setSelection,
|
|
9
9
|
type BaseSelection,
|
|
10
10
|
type ElementNode,
|
|
11
|
+
type LexicalEditor,
|
|
11
12
|
type LexicalNode,
|
|
12
13
|
} from 'lexical'
|
|
13
14
|
import { $insertNodeToNearestRoot } from '@lexical/utils'
|
|
@@ -18,12 +19,13 @@ import {
|
|
|
18
19
|
LLuiDecoratorNode,
|
|
19
20
|
decoratorBridge,
|
|
20
21
|
} from '@llui/lexical'
|
|
21
|
-
import { button,
|
|
22
|
+
import { button, div, img, input, text, type Signal } from '@llui/dom'
|
|
22
23
|
import {
|
|
23
24
|
connect as connectDialog,
|
|
24
25
|
overlay as overlayDialog,
|
|
25
26
|
type DialogMsg,
|
|
26
27
|
} from '@llui/components/dialog'
|
|
28
|
+
import { sanitizeImageUrl } from '../security.js'
|
|
27
29
|
import { definePluginUI } from './ui.js'
|
|
28
30
|
import type { MarkdownPlugin } from './types.js'
|
|
29
31
|
|
|
@@ -43,20 +45,11 @@ function isImageData(value: unknown): value is ImageData {
|
|
|
43
45
|
)
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
const imageBridge = decoratorBridge<ImageData,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
)
|
|
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
|
+
])
|
|
60
53
|
|
|
61
54
|
const IMAGE_TRANSFORMER: ElementTransformer = {
|
|
62
55
|
dependencies: [LLuiDecoratorNode],
|
|
@@ -67,9 +60,12 @@ const IMAGE_TRANSFORMER: ElementTransformer = {
|
|
|
67
60
|
},
|
|
68
61
|
regExp: /^!\[([^\]]*)\]\(([^)]+)\)$/,
|
|
69
62
|
replace: (parentNode: ElementNode, _children, match): void => {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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 }))
|
|
73
69
|
},
|
|
74
70
|
type: 'element',
|
|
75
71
|
}
|
|
@@ -112,7 +108,9 @@ export interface ImagePluginOptions {
|
|
|
112
108
|
}
|
|
113
109
|
|
|
114
110
|
export function imagePlugin(opts: ImagePluginOptions = {}): MarkdownPlugin {
|
|
115
|
-
|
|
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>()
|
|
116
114
|
|
|
117
115
|
return {
|
|
118
116
|
name: 'image',
|
|
@@ -217,20 +215,25 @@ export function imagePlugin(opts: ImagePluginOptions = {}): MarkdownPlugin {
|
|
|
217
215
|
const editor = ctx.editor()
|
|
218
216
|
if (!editor) return
|
|
219
217
|
if (effect.type === 'begin') {
|
|
220
|
-
savedSelection
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
218
|
+
savedSelection.set(
|
|
219
|
+
editor,
|
|
220
|
+
editor.getEditorState().read(() => {
|
|
221
|
+
const selection = $getSelection()
|
|
222
|
+
return selection ? selection.clone() : null
|
|
223
|
+
}),
|
|
224
|
+
)
|
|
224
225
|
return
|
|
225
226
|
}
|
|
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
|
|
227
232
|
editor.update(() => {
|
|
228
|
-
if (
|
|
229
|
-
$insertNodeToNearestRoot(
|
|
230
|
-
$createLLuiDecoratorNode(BRIDGE_TYPE, { src: effect.src.trim(), alt: effect.alt }),
|
|
231
|
-
)
|
|
233
|
+
if (saved) $setSelection(saved.clone())
|
|
234
|
+
$insertNodeToNearestRoot($createLLuiDecoratorNode(BRIDGE_TYPE, { src, alt: effect.alt }))
|
|
232
235
|
})
|
|
233
|
-
savedSelection
|
|
236
|
+
savedSelection.delete(editor)
|
|
234
237
|
},
|
|
235
238
|
}),
|
|
236
239
|
}
|
package/src/plugins/link.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { $findMatchingParent } from '@lexical/utils'
|
|
|
14
14
|
import { $isLinkNode, $toggleLink } from '@lexical/link'
|
|
15
15
|
import type { DialogMsg, DialogState } from '@llui/components/dialog'
|
|
16
16
|
import { linkDialog } from '../surfaces/link-dialog.js'
|
|
17
|
+
import { sanitizeLinkUrl } from '../security.js'
|
|
17
18
|
import { definePluginUI } from './ui.js'
|
|
18
19
|
import type { CommandItem, MarkdownPlugin } from './types.js'
|
|
19
20
|
|
|
@@ -66,8 +67,9 @@ export interface LinkPluginOptions {
|
|
|
66
67
|
|
|
67
68
|
export function linkPlugin(opts: LinkPluginOptions = {}): MarkdownPlugin {
|
|
68
69
|
// Selection saved when the dialog opens (the modal steals focus/selection),
|
|
69
|
-
// restored on commit.
|
|
70
|
-
|
|
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>()
|
|
71
73
|
|
|
72
74
|
const item: CommandItem = {
|
|
73
75
|
id: 'link',
|
|
@@ -114,19 +116,27 @@ export function linkPlugin(opts: LinkPluginOptions = {}): MarkdownPlugin {
|
|
|
114
116
|
const editor = ctx.editor()
|
|
115
117
|
if (!editor) return
|
|
116
118
|
if (effect.type === 'begin') {
|
|
117
|
-
savedSelection
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
savedSelection.set(
|
|
120
|
+
editor,
|
|
121
|
+
editor.getEditorState().read(() => {
|
|
122
|
+
const selection = $getSelection()
|
|
123
|
+
return selection ? selection.clone() : null
|
|
124
|
+
}),
|
|
125
|
+
)
|
|
121
126
|
ctx.send({ type: 'show', url: readLinkUrl(editor) })
|
|
122
127
|
return
|
|
123
128
|
}
|
|
124
|
-
|
|
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
|
|
125
135
|
editor.update(() => {
|
|
126
|
-
if (
|
|
127
|
-
$toggleLink(
|
|
136
|
+
if (saved) $setSelection(saved.clone())
|
|
137
|
+
$toggleLink(safe === null || safe === '' ? null : safe)
|
|
128
138
|
})
|
|
129
|
-
savedSelection
|
|
139
|
+
savedSelection.delete(editor)
|
|
130
140
|
},
|
|
131
141
|
}),
|
|
132
142
|
}
|
package/src/plugins/math.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
LLuiDecoratorNode,
|
|
12
12
|
decoratorBridge,
|
|
13
13
|
} from '@llui/lexical'
|
|
14
|
-
import {
|
|
14
|
+
import { div, span, text, type Mountable, type Signal } from '@llui/dom'
|
|
15
15
|
import type { MarkdownPlugin } from './types.js'
|
|
16
16
|
import { renderedPreview, type PreviewRender } from './_preview.js'
|
|
17
17
|
|
|
@@ -25,8 +25,6 @@ function isMathData(value: unknown): value is MathData {
|
|
|
25
25
|
return typeof value === 'object' && value !== null && typeof (value as MathData).tex === 'string'
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
type MathMsg = { type: 'commit'; tex: string }
|
|
29
|
-
|
|
30
28
|
const stop = (e: Event): void => e.stopPropagation()
|
|
31
29
|
|
|
32
30
|
export interface MathPluginOptions {
|
|
@@ -40,44 +38,32 @@ export interface MathPluginOptions {
|
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
export function mathPlugin(opts: MathPluginOptions = {}): MarkdownPlugin {
|
|
43
|
-
const bridge = decoratorBridge<MathData
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
[text(state.at('tex') as Signal<string>)],
|
|
70
|
-
),
|
|
71
|
-
]
|
|
72
|
-
if (opts.render) {
|
|
73
|
-
children.push(renderedPreview(state.at('tex') as Signal<string>, opts.render, 'span'))
|
|
74
|
-
}
|
|
75
|
-
return [
|
|
76
|
-
div({ 'data-scope': 'md-math', 'data-part': 'root', contenteditable: 'false' }, children),
|
|
77
|
-
]
|
|
78
|
-
},
|
|
79
|
-
}),
|
|
80
|
-
)
|
|
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
|
+
})
|
|
81
67
|
|
|
82
68
|
const transformer: ElementTransformer = {
|
|
83
69
|
dependencies: [LLuiDecoratorNode],
|