@llui/markdown-editor 0.2.13 → 0.3.0
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 +7 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +66 -11
- package/dist/editor.js.map +1 -1
- package/dist/effects.d.ts +5 -4
- package/dist/effects.d.ts.map +1 -1
- package/dist/effects.js +11 -6
- package/dist/effects.js.map +1 -1
- package/dist/format.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- 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 +84 -19
- 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/styles/editor.css +0 -6
- package/dist/surfaces/link-dialog.js.map +1 -1
- package/dist/surfaces/toolbar.js.map +1 -1
- package/dist/theme.d.ts +2 -5
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +15 -11
- package/dist/theme.js.map +1 -1
- package/dist/transformers/gfm.d.ts +9 -1
- package/dist/transformers/gfm.d.ts.map +1 -1
- package/dist/transformers/gfm.js +9 -2
- 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 +92 -11
- package/src/effects.ts +11 -7
- package/src/index.ts +3 -8
- 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 +94 -17
- package/src/security.ts +89 -0
- package/src/styles/editor.css +0 -6
- package/src/theme.ts +15 -11
- package/src/transformers/gfm.ts +10 -2
- package/dist/__llui_deps.json +0 -274
package/src/editor.ts
CHANGED
|
@@ -4,7 +4,14 @@
|
|
|
4
4
|
// live editor through effects, and COMPOSES plugin UI extensions (each plugin's
|
|
5
5
|
// state slice + reducer + view + effects) into the single component.
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
$getRoot,
|
|
9
|
+
$setSelection,
|
|
10
|
+
COMMAND_PRIORITY_CRITICAL,
|
|
11
|
+
FORMAT_TEXT_COMMAND,
|
|
12
|
+
type EditorThemeClasses,
|
|
13
|
+
type LexicalEditor,
|
|
14
|
+
} from 'lexical'
|
|
8
15
|
import {
|
|
9
16
|
$convertFromMarkdownString,
|
|
10
17
|
$convertToMarkdownString,
|
|
@@ -20,6 +27,7 @@ import {
|
|
|
20
27
|
import { corePlugin } from './plugins/core.js'
|
|
21
28
|
import { linkPlugin } from './plugins/link.js'
|
|
22
29
|
import { registerMarkdownPaste } from './paste.js'
|
|
30
|
+
import { registerLinkSanitizer } from './security.js'
|
|
23
31
|
import { toolbar as renderToolbar } from './surfaces/toolbar.js'
|
|
24
32
|
import type { CommandItem, MarkdownPlugin } from './plugins/types.js'
|
|
25
33
|
import type { PluginUI } from './plugins/ui.js'
|
|
@@ -108,6 +116,33 @@ function defaultPlugins(): MarkdownPlugin[] {
|
|
|
108
116
|
return [corePlugin(), linkPlugin()]
|
|
109
117
|
}
|
|
110
118
|
|
|
119
|
+
/** Swallow the underline text-format command. `registerRichText` wires Cmd+U to
|
|
120
|
+
* FORMAT_TEXT 'underline', but the GFM markdown dialect this editor serializes
|
|
121
|
+
* has no underline representation, so an applied underline would be silently
|
|
122
|
+
* stripped on save. Intercepting at CRITICAL priority (ahead of rich-text) keeps
|
|
123
|
+
* the WYSIWYG surface and the serialized dialect in lock-step: underline can be
|
|
124
|
+
* neither applied nor lost. Returns a disposer. */
|
|
125
|
+
export function blockUnderlineFormat(editor: LexicalEditor): () => void {
|
|
126
|
+
return editor.registerCommand(
|
|
127
|
+
FORMAT_TEXT_COMMAND,
|
|
128
|
+
(payload) => payload === 'underline',
|
|
129
|
+
COMMAND_PRIORITY_CRITICAL,
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The per-mount `send` closure — the WeakMap key that ties a live editor to the
|
|
134
|
+
* one mount it belongs to. */
|
|
135
|
+
type EditorSend = (msg: EditorMsg) => void
|
|
136
|
+
|
|
137
|
+
/** Per-mount live state: the Lexical editor captured by that mount's `onReady`,
|
|
138
|
+
* plus the last markdown delivered to the consumer's `onChange` (dedup key). */
|
|
139
|
+
interface MountContext {
|
|
140
|
+
editor: LexicalEditor | null
|
|
141
|
+
/** Last value handed to `config.onChange` for this mount — guards against
|
|
142
|
+
* double / redundant consumer notifications. `undefined` = nothing emitted yet. */
|
|
143
|
+
lastChange: string | undefined
|
|
144
|
+
}
|
|
145
|
+
|
|
111
146
|
/**
|
|
112
147
|
* Build the markdown editor component. Embed it with `mountApp(el, markdownEditor(...))`
|
|
113
148
|
* or compose it inside a larger component.
|
|
@@ -128,12 +163,27 @@ export function markdownEditor(
|
|
|
128
163
|
.map((p) => ({ name: p.name, ui: p.ui }))
|
|
129
164
|
const pluginUIByName = new Map(pluginUIs.map((p) => [p.name, p.ui]))
|
|
130
165
|
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
166
|
+
// Per-MOUNT live-editor context, keyed by the mount's stable `send` closure —
|
|
167
|
+
// the SAME object the runtime hands to both `view` and `onEffect`. A single
|
|
168
|
+
// definition can be mounted more than once (or re-mounted); each mount gets its
|
|
169
|
+
// own editor reference here, so effects and plugin handlers dispatch to their
|
|
170
|
+
// own editor and unmount clears only that mount's reference (no cross-wiring,
|
|
171
|
+
// no leaked disposed editor). Entries are collected with their `send` key.
|
|
172
|
+
const mountContexts = new WeakMap<EditorSend, MountContext>()
|
|
173
|
+
const contextFor = (send: EditorSend): MountContext => {
|
|
174
|
+
let ctx = mountContexts.get(send)
|
|
175
|
+
if (!ctx) {
|
|
176
|
+
ctx = { editor: null, lastChange: undefined }
|
|
177
|
+
mountContexts.set(send, ctx)
|
|
178
|
+
}
|
|
179
|
+
return ctx
|
|
180
|
+
}
|
|
134
181
|
|
|
135
|
-
const baseOnEffect = makeOnEffect(
|
|
136
|
-
|
|
182
|
+
const baseOnEffect = makeOnEffect((api) => contextFor(api.send).editor, itemsById, {
|
|
183
|
+
// NB: consumer `onChange` is delivered DIRECTLY from the foreign onChange
|
|
184
|
+
// wrapper (see `view` below), NOT through this effect path — the dispose-time
|
|
185
|
+
// debounce flush runs after the TEA loop is disposed, and a `send` from a
|
|
186
|
+
// disposed loop is dropped, so effect-routed delivery loses the final edit.
|
|
137
187
|
onFormatChange: config.onFormatChange,
|
|
138
188
|
applyValue: (editor, value) =>
|
|
139
189
|
editor.update(
|
|
@@ -192,9 +242,10 @@ export function markdownEditor(
|
|
|
192
242
|
api: { send: (msg: EditorMsg) => void; state: Signal<EditorState> },
|
|
193
243
|
): void => {
|
|
194
244
|
if (effect.type === 'pluginEffect') {
|
|
245
|
+
const mount = contextFor(api.send)
|
|
195
246
|
const ui = pluginUIByName.get(effect.name)
|
|
196
247
|
ui?.onEffect?.(effect.effect, {
|
|
197
|
-
editor:
|
|
248
|
+
editor: () => mount.editor,
|
|
198
249
|
send: (msg) => api.send({ type: 'plugin', name: effect.name, msg }),
|
|
199
250
|
emit: (msg) => api.send(msg as EditorMsg),
|
|
200
251
|
})
|
|
@@ -210,6 +261,10 @@ export function markdownEditor(
|
|
|
210
261
|
state: Signal<EditorState>
|
|
211
262
|
send: (msg: EditorMsg) => void
|
|
212
263
|
}): Renderable => {
|
|
264
|
+
// This mount's live-editor slot (keyed by this mount's `send`). `onReady`
|
|
265
|
+
// fills it; effects/plugins read it through `contextFor(api.send)`.
|
|
266
|
+
const mount = contextFor(send)
|
|
267
|
+
|
|
213
268
|
// Build the collab binding (once, at mount) from the consumer's factory,
|
|
214
269
|
// injecting the markdown seed + status sinks that mirror into `state.collab`.
|
|
215
270
|
const collabBinding: CollabBinding | null = config.collab
|
|
@@ -245,23 +300,49 @@ export function markdownEditor(
|
|
|
245
300
|
? { changeDebounceMs: config.changeDebounceMs }
|
|
246
301
|
: {}),
|
|
247
302
|
register: (editor) => {
|
|
248
|
-
const disposers = [
|
|
303
|
+
const disposers = [
|
|
304
|
+
// Keep the WYSIWYG surface and the serialized GFM dialect in lock-step:
|
|
305
|
+
// underline (Cmd+U) has no markdown representation, so it is swallowed
|
|
306
|
+
// rather than applied-then-silently-stripped on save.
|
|
307
|
+
blockUnderlineFormat(editor),
|
|
308
|
+
registerMarkdownShortcuts(editor, transformers),
|
|
309
|
+
// Global scheme allowlist for links: the single backstop covering the
|
|
310
|
+
// link dialog, pasted/imported markdown, and the typed `[x](url)`
|
|
311
|
+
// shortcut. Image src is enforced in the image transformer.
|
|
312
|
+
registerLinkSanitizer(editor),
|
|
313
|
+
]
|
|
249
314
|
if (config.pasteMarkdown !== false)
|
|
250
315
|
disposers.push(registerMarkdownPaste(editor, transformers))
|
|
251
316
|
if (decorators.length > 0) disposers.push(registerDecoratorBridges(editor, decorators))
|
|
252
317
|
if (collabBinding) disposers.push(collabBinding.register(editor))
|
|
253
318
|
return () => {
|
|
254
319
|
for (const dispose of disposers) dispose()
|
|
320
|
+
// Release this mount's editor reference so a disposed editor never
|
|
321
|
+
// leaks and stale effects can't reach a torn-down editor.
|
|
322
|
+
mount.editor = null
|
|
255
323
|
}
|
|
256
324
|
},
|
|
257
325
|
onReady: (editor) => {
|
|
258
|
-
|
|
326
|
+
mount.editor = editor
|
|
259
327
|
if (config.placeholder) {
|
|
260
328
|
editor.getRootElement()?.setAttribute('data-placeholder', config.placeholder)
|
|
261
329
|
}
|
|
262
330
|
config.onReady?.(editor)
|
|
263
331
|
},
|
|
264
|
-
onChange: (value) =>
|
|
332
|
+
onChange: (value) => {
|
|
333
|
+
// Deliver to the consumer DIRECTLY, independent of the TEA loop: the
|
|
334
|
+
// dispose-time debounce flush runs AFTER the component is disposed, and a
|
|
335
|
+
// `send` from a disposed loop is dropped — so routing consumer delivery
|
|
336
|
+
// through `send` would lose the last debounce window of typing on unmount.
|
|
337
|
+
// Deduped per mount so the same value isn't delivered twice.
|
|
338
|
+
if (value !== mount.lastChange) {
|
|
339
|
+
mount.lastChange = value
|
|
340
|
+
config.onChange?.(value)
|
|
341
|
+
}
|
|
342
|
+
// Mirror into TEA state (value/dirty) while the loop is alive; dropped
|
|
343
|
+
// silently after dispose, which is fine — the consumer was already told.
|
|
344
|
+
send({ type: 'markdownChanged', value })
|
|
345
|
+
},
|
|
265
346
|
onSelectionChange: (ctx) => {
|
|
266
347
|
const format = computeFormatState(ctx.editor, ctx)
|
|
267
348
|
const text = ctx.editor.getEditorState().read(() => $getRoot().getTextContent())
|
|
@@ -278,7 +359,7 @@ export function markdownEditor(
|
|
|
278
359
|
const rendered = ui.view({
|
|
279
360
|
state: state.at(`plugins.${name}`),
|
|
280
361
|
send: (msg) => send({ type: 'plugin', name, msg }),
|
|
281
|
-
editor:
|
|
362
|
+
editor: () => mount.editor,
|
|
282
363
|
})
|
|
283
364
|
return Array.isArray(rendered) ? rendered : [rendered]
|
|
284
365
|
})
|
package/src/effects.ts
CHANGED
|
@@ -12,34 +12,38 @@ export interface EffectApi {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export interface EffectConfig {
|
|
15
|
-
onChange?: (markdown: string) => void
|
|
16
15
|
onFormatChange?: (format: FormatState) => void
|
|
17
16
|
/** Push markdown into the live editor (deserialize), without echoing onChange. */
|
|
18
17
|
applyValue: (editor: LexicalEditor, value: string) => void
|
|
19
18
|
}
|
|
20
19
|
|
|
21
|
-
/** Build the component's `onEffect`. `
|
|
22
|
-
*
|
|
20
|
+
/** Build the component's `onEffect`. `resolveEditor` maps the per-mount effect
|
|
21
|
+
* `api` (whose `send` identifies the mount) to that mount's live editor — so two
|
|
22
|
+
* mounts of one definition dispatch to their own editors; `items` is the merged
|
|
23
|
+
* id → command map. */
|
|
23
24
|
export function makeOnEffect(
|
|
24
|
-
|
|
25
|
+
resolveEditor: (api: EffectApi) => LexicalEditor | null,
|
|
25
26
|
items: ReadonlyMap<string, CommandItem>,
|
|
26
27
|
config: EffectConfig,
|
|
27
28
|
): (effect: EditorEffect, api: EffectApi) => void {
|
|
28
29
|
return (effect, api) => {
|
|
29
30
|
switch (effect.type) {
|
|
30
31
|
case 'execCommand': {
|
|
31
|
-
const editor =
|
|
32
|
+
const editor = resolveEditor(api)
|
|
32
33
|
const item = items.get(effect.id)
|
|
33
34
|
if (editor && item) item.run(editor, { send: api.send })
|
|
34
35
|
return
|
|
35
36
|
}
|
|
36
37
|
case 'applyValue': {
|
|
37
|
-
const editor =
|
|
38
|
+
const editor = resolveEditor(api)
|
|
38
39
|
if (editor) config.applyValue(editor, effect.value)
|
|
39
40
|
return
|
|
40
41
|
}
|
|
41
42
|
case 'emitChange': {
|
|
42
|
-
|
|
43
|
+
// Consumer `onChange` delivery moved to the foreign onChange wrapper (see
|
|
44
|
+
// editor.ts) so it survives dispose — the loop is torn down before the
|
|
45
|
+
// dispose-time debounce flush runs, and a `send`-routed effect would be
|
|
46
|
+
// dropped. This effect now only signals that state changed; no side effect.
|
|
43
47
|
return
|
|
44
48
|
}
|
|
45
49
|
case 'emitFormat': {
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export {
|
|
|
7
7
|
type CollabHooks,
|
|
8
8
|
type CollabFactory,
|
|
9
9
|
markdownEditor,
|
|
10
|
+
blockUnderlineFormat,
|
|
10
11
|
} from './editor.js'
|
|
11
12
|
|
|
12
13
|
export {
|
|
@@ -68,18 +69,12 @@ export { tablePlugin } from './plugins/table.js'
|
|
|
68
69
|
|
|
69
70
|
export { $insertMarkdownAtSelection, registerMarkdownPaste } from './paste.js'
|
|
70
71
|
|
|
71
|
-
export { GFM_NODES, GFM_TRANSFORMERS } from './transformers/gfm.js'
|
|
72
|
+
export { GFM_NODES, GFM_TRANSFORMERS, HIGHLIGHT_TRANSFORMER } from './transformers/gfm.js'
|
|
72
73
|
export { buildTransformers, orderTransformers } from './transformers/registry.js'
|
|
73
74
|
|
|
74
75
|
export { computeFormatState } from './format.js'
|
|
75
76
|
|
|
76
|
-
export {
|
|
77
|
-
STRIKETHROUGH_CLASS,
|
|
78
|
-
UNDERLINE_CLASS,
|
|
79
|
-
UNDERLINE_STRIKETHROUGH_CLASS,
|
|
80
|
-
defaultTheme,
|
|
81
|
-
mergeTheme,
|
|
82
|
-
} from './theme.js'
|
|
77
|
+
export { STRIKETHROUGH_CLASS, defaultTheme, mergeTheme } from './theme.js'
|
|
83
78
|
|
|
84
79
|
export {
|
|
85
80
|
type ToolbarItemParts,
|
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
|
}
|