@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.
Files changed (83) hide show
  1. package/dist/editor.d.ts +7 -0
  2. package/dist/editor.d.ts.map +1 -1
  3. package/dist/editor.js +66 -11
  4. package/dist/editor.js.map +1 -1
  5. package/dist/effects.d.ts +5 -4
  6. package/dist/effects.d.ts.map +1 -1
  7. package/dist/effects.js +11 -6
  8. package/dist/effects.js.map +1 -1
  9. package/dist/format.js.map +1 -1
  10. package/dist/index.d.ts +3 -3
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +3 -3
  13. package/dist/index.js.map +1 -1
  14. package/dist/paste.d.ts.map +1 -1
  15. package/dist/paste.js +5 -0
  16. package/dist/paste.js.map +1 -1
  17. package/dist/plugins/_preview.js.map +1 -1
  18. package/dist/plugins/callout.d.ts.map +1 -1
  19. package/dist/plugins/callout.js +37 -45
  20. package/dist/plugins/callout.js.map +1 -1
  21. package/dist/plugins/context-menu.js.map +1 -1
  22. package/dist/plugins/core.js.map +1 -1
  23. package/dist/plugins/emoji.js.map +1 -1
  24. package/dist/plugins/floating-toolbar.js.map +1 -1
  25. package/dist/plugins/hr.d.ts.map +1 -1
  26. package/dist/plugins/hr.js +4 -7
  27. package/dist/plugins/hr.js.map +1 -1
  28. package/dist/plugins/image.d.ts.map +1 -1
  29. package/dist/plugins/image.js +28 -20
  30. package/dist/plugins/image.js.map +1 -1
  31. package/dist/plugins/inline.js.map +1 -1
  32. package/dist/plugins/link.d.ts.map +1 -1
  33. package/dist/plugins/link.js +16 -9
  34. package/dist/plugins/link.js.map +1 -1
  35. package/dist/plugins/math.d.ts.map +1 -1
  36. package/dist/plugins/math.js +25 -34
  37. package/dist/plugins/math.js.map +1 -1
  38. package/dist/plugins/mention.js.map +1 -1
  39. package/dist/plugins/mermaid.d.ts.map +1 -1
  40. package/dist/plugins/mermaid.js +25 -34
  41. package/dist/plugins/mermaid.js.map +1 -1
  42. package/dist/plugins/overlay.js.map +1 -1
  43. package/dist/plugins/single-block.js.map +1 -1
  44. package/dist/plugins/slash.js.map +1 -1
  45. package/dist/plugins/table.d.ts.map +1 -1
  46. package/dist/plugins/table.js +84 -19
  47. package/dist/plugins/table.js.map +1 -1
  48. package/dist/plugins/types.js.map +1 -1
  49. package/dist/plugins/ui.js.map +1 -1
  50. package/dist/security.d.ts +26 -0
  51. package/dist/security.d.ts.map +1 -0
  52. package/dist/security.js +87 -0
  53. package/dist/security.js.map +1 -0
  54. package/dist/state.js.map +1 -1
  55. package/dist/styles/editor.css +0 -6
  56. package/dist/surfaces/link-dialog.js.map +1 -1
  57. package/dist/surfaces/toolbar.js.map +1 -1
  58. package/dist/theme.d.ts +2 -5
  59. package/dist/theme.d.ts.map +1 -1
  60. package/dist/theme.js +15 -11
  61. package/dist/theme.js.map +1 -1
  62. package/dist/transformers/gfm.d.ts +9 -1
  63. package/dist/transformers/gfm.d.ts.map +1 -1
  64. package/dist/transformers/gfm.js +9 -2
  65. package/dist/transformers/gfm.js.map +1 -1
  66. package/dist/transformers/registry.js.map +1 -1
  67. package/package.json +10 -8
  68. package/src/editor.ts +92 -11
  69. package/src/effects.ts +11 -7
  70. package/src/index.ts +3 -8
  71. package/src/paste.ts +5 -0
  72. package/src/plugins/callout.ts +44 -59
  73. package/src/plugins/hr.ts +4 -14
  74. package/src/plugins/image.ts +32 -29
  75. package/src/plugins/link.ts +20 -10
  76. package/src/plugins/math.ts +27 -41
  77. package/src/plugins/mermaid.ts +26 -45
  78. package/src/plugins/table.ts +94 -17
  79. package/src/security.ts +89 -0
  80. package/src/styles/editor.css +0 -6
  81. package/src/theme.ts +15 -11
  82. package/src/transformers/gfm.ts +10 -2
  83. 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 { $getRoot, $setSelection, type EditorThemeClasses, type LexicalEditor } from 'lexical'
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
- // The live editor, captured at mount; effects dispatch through it.
132
- let editorRef: LexicalEditor | null = null
133
- const getEditor = (): LexicalEditor | null => editorRef
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(getEditor, itemsById, {
136
- onChange: config.onChange,
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: getEditor,
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 = [registerMarkdownShortcuts(editor, transformers)]
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
- editorRef = editor
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) => send({ type: 'markdownChanged', 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: getEditor,
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`. `getEditor` returns the live editor (set at
22
- * mount via the foreign `onReady`); `items` is the merged id command map. */
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
- getEditor: () => LexicalEditor | null,
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 = getEditor()
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 = getEditor()
38
+ const editor = resolveEditor(api)
38
39
  if (editor) config.applyValue(editor, effect.value)
39
40
  return
40
41
  }
41
42
  case 'emitChange': {
42
- config.onChange?.(effect.value)
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
 
@@ -12,7 +12,7 @@ import {
12
12
  LLuiDecoratorNode,
13
13
  decoratorBridge,
14
14
  } from '@llui/lexical'
15
- import { button, component, div, span, text, type Signal } from '@llui/dom'
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
- const calloutBridge = decoratorBridge<CalloutData, CalloutData, CalloutMsg, never>(
59
- BRIDGE_TYPE,
60
- (data, api) =>
61
- component<CalloutData, CalloutMsg, never>({
62
- name: 'Callout',
63
- init: () => ({ kind: data.kind, text: data.text }),
64
- update: (state, msg) => {
65
- if (msg.type === 'cycle') {
66
- const kind = nextKind(state.kind)
67
- api.update({ kind, text: state.text })
68
- return { ...state, kind }
69
- }
70
- if (msg.type === 'commitText') {
71
- if (msg.text === state.text) return state
72
- api.update({ kind: state.kind, text: msg.text })
73
- return { ...state, text: msg.text }
74
- }
75
- return state
76
- },
77
- view: ({ state, send }) => [
78
- div(
79
- {
80
- 'data-scope': 'md-callout',
81
- 'data-part': 'root',
82
- 'data-kind': state.at('kind') as Signal<string>,
83
- contenteditable: 'false',
84
- onKeyDown: stop,
85
- onBeforeInput: stop,
86
- onPaste: stop,
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
- button(
90
- {
91
- type: 'button',
92
- 'data-part': 'badge',
93
- 'aria-label': 'Change callout kind',
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 { component, hr } from '@llui/dom'
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
- Record<string, never>,
20
- Record<string, never>,
21
- { type: 'noop' },
22
- never
23
- >(BRIDGE_TYPE, () =>
24
- component<Record<string, never>, { type: 'noop' }, never>({
25
- name: 'HorizontalRule',
26
- init: () => ({}),
27
- update: (state) => state,
28
- view: () => [hr({ 'data-md-hr': '', contenteditable: 'false' })],
29
- }),
30
- )
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],
@@ -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, component, div, img, input, text, type Signal } from '@llui/dom'
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, ImageData, { type: 'noop' }, never>(
47
- BRIDGE_TYPE,
48
- (data) =>
49
- component<ImageData, { type: 'noop' }, never>({
50
- name: 'Image',
51
- init: () => ({ src: data.src, alt: data.alt }),
52
- update: (state) => state,
53
- view: ({ state }) => [
54
- div({ 'data-scope': 'md-image', 'data-part': 'root', contenteditable: 'false' }, [
55
- img({ src: state.at('src') as Signal<string>, alt: state.at('alt') as Signal<string> }),
56
- ]),
57
- ],
58
- }),
59
- )
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
- parentNode.replace(
71
- $createLLuiDecoratorNode(BRIDGE_TYPE, { alt: match[1] ?? '', src: match[2] ?? '' }),
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
- let savedSelection: BaseSelection | null = null
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 = editor.getEditorState().read(() => {
221
- const selection = $getSelection()
222
- return selection ? selection.clone() : null
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
- if (effect.src.trim() === '') return
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 (savedSelection) $setSelection(savedSelection.clone())
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 = null
236
+ savedSelection.delete(editor)
234
237
  },
235
238
  }),
236
239
  }
@@ -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. Encapsulated in the plugin instance not the core editor.
70
- let savedSelection: BaseSelection | null = null
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 = editor.getEditorState().read(() => {
118
- const selection = $getSelection()
119
- return selection ? selection.clone() : null
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
- const url = effect.url.trim()
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 (savedSelection) $setSelection(savedSelection.clone())
127
- $toggleLink(url === '' ? null : url)
136
+ if (saved) $setSelection(saved.clone())
137
+ $toggleLink(safe === null || safe === '' ? null : safe)
128
138
  })
129
- savedSelection = null
139
+ savedSelection.delete(editor)
130
140
  },
131
141
  }),
132
142
  }