@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.
Files changed (89) hide show
  1. package/README.md +44 -0
  2. package/dist/editor.d.ts.map +1 -1
  3. package/dist/editor.js +34 -8
  4. package/dist/editor.js.map +1 -1
  5. package/dist/effects.d.ts +5 -3
  6. package/dist/effects.d.ts.map +1 -1
  7. package/dist/effects.js +7 -5
  8. package/dist/effects.js.map +1 -1
  9. package/dist/format.js.map +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/paste.d.ts.map +1 -1
  12. package/dist/paste.js +5 -0
  13. package/dist/paste.js.map +1 -1
  14. package/dist/plugins/_preview.js.map +1 -1
  15. package/dist/plugins/callout.d.ts.map +1 -1
  16. package/dist/plugins/callout.js +37 -45
  17. package/dist/plugins/callout.js.map +1 -1
  18. package/dist/plugins/context-menu.js.map +1 -1
  19. package/dist/plugins/core.js.map +1 -1
  20. package/dist/plugins/emoji.js.map +1 -1
  21. package/dist/plugins/floating-toolbar.js.map +1 -1
  22. package/dist/plugins/hr.d.ts.map +1 -1
  23. package/dist/plugins/hr.js +4 -7
  24. package/dist/plugins/hr.js.map +1 -1
  25. package/dist/plugins/image.d.ts.map +1 -1
  26. package/dist/plugins/image.js +28 -20
  27. package/dist/plugins/image.js.map +1 -1
  28. package/dist/plugins/inline.js.map +1 -1
  29. package/dist/plugins/link.d.ts.map +1 -1
  30. package/dist/plugins/link.js +16 -9
  31. package/dist/plugins/link.js.map +1 -1
  32. package/dist/plugins/math.d.ts.map +1 -1
  33. package/dist/plugins/math.js +25 -34
  34. package/dist/plugins/math.js.map +1 -1
  35. package/dist/plugins/mention.js.map +1 -1
  36. package/dist/plugins/mermaid.d.ts.map +1 -1
  37. package/dist/plugins/mermaid.js +25 -34
  38. package/dist/plugins/mermaid.js.map +1 -1
  39. package/dist/plugins/overlay.js.map +1 -1
  40. package/dist/plugins/single-block.js.map +1 -1
  41. package/dist/plugins/slash.js.map +1 -1
  42. package/dist/plugins/table.d.ts.map +1 -1
  43. package/dist/plugins/table.js +7 -4
  44. package/dist/plugins/table.js.map +1 -1
  45. package/dist/plugins/types.js.map +1 -1
  46. package/dist/plugins/ui.js.map +1 -1
  47. package/dist/security.d.ts +26 -0
  48. package/dist/security.d.ts.map +1 -0
  49. package/dist/security.js +87 -0
  50. package/dist/security.js.map +1 -0
  51. package/dist/state.js.map +1 -1
  52. package/dist/surfaces/link-dialog.js.map +1 -1
  53. package/dist/surfaces/toolbar.js.map +1 -1
  54. package/dist/theme.js.map +1 -1
  55. package/dist/transformers/gfm.js.map +1 -1
  56. package/dist/transformers/registry.js.map +1 -1
  57. package/package.json +23 -17
  58. package/src/editor.ts +339 -0
  59. package/src/effects.ts +53 -0
  60. package/src/format.ts +57 -0
  61. package/src/index.ts +93 -0
  62. package/src/paste.ts +96 -0
  63. package/src/plugins/_preview.ts +51 -0
  64. package/src/plugins/callout.ts +180 -0
  65. package/src/plugins/context-menu.ts +118 -0
  66. package/src/plugins/core.ts +179 -0
  67. package/src/plugins/emoji.ts +59 -0
  68. package/src/plugins/floating-toolbar.ts +180 -0
  69. package/src/plugins/hr.ts +56 -0
  70. package/src/plugins/image.ts +240 -0
  71. package/src/plugins/inline.ts +76 -0
  72. package/src/plugins/link.ts +143 -0
  73. package/src/plugins/math.ts +102 -0
  74. package/src/plugins/mention.ts +224 -0
  75. package/src/plugins/mermaid.ts +115 -0
  76. package/src/plugins/overlay.ts +138 -0
  77. package/src/plugins/single-block.ts +193 -0
  78. package/src/plugins/slash.ts +224 -0
  79. package/src/plugins/table.ts +284 -0
  80. package/src/plugins/types.ts +50 -0
  81. package/src/plugins/ui.ts +79 -0
  82. package/src/security.ts +89 -0
  83. package/src/state.ts +220 -0
  84. package/src/styles/editor.css +554 -0
  85. package/src/surfaces/link-dialog.ts +67 -0
  86. package/src/surfaces/toolbar.ts +246 -0
  87. package/src/theme.ts +42 -0
  88. package/src/transformers/gfm.ts +69 -0
  89. package/src/transformers/registry.ts +47 -0
package/src/index.ts ADDED
@@ -0,0 +1,93 @@
1
+ // `@llui/markdown-editor` — WYSIWYG Markdown editor for LLui, built on Lexical.
2
+
3
+ export {
4
+ type EditorConfig,
5
+ type EditorParts,
6
+ type CollabBinding,
7
+ type CollabHooks,
8
+ type CollabFactory,
9
+ markdownEditor,
10
+ } from './editor.js'
11
+
12
+ export {
13
+ type BlockType,
14
+ type FormatState,
15
+ type OverlayKind,
16
+ type CollabStatus,
17
+ type EditorState,
18
+ type EditorMsg,
19
+ type EditorOutMsg,
20
+ type EditorEffect,
21
+ type InitOptions,
22
+ EMPTY_FORMAT,
23
+ COLLAB_OFF,
24
+ init,
25
+ update,
26
+ countWords,
27
+ } from './state.js'
28
+
29
+ export {
30
+ type ItemSurface,
31
+ type CommandItem,
32
+ type CommandContext,
33
+ type MarkdownPlugin,
34
+ } from './plugins/types.js'
35
+
36
+ export {
37
+ type PluginUI,
38
+ type PluginUISpec,
39
+ type PluginViewArgs,
40
+ type PluginEffectContext,
41
+ definePluginUI,
42
+ } from './plugins/ui.js'
43
+
44
+ export { type CorePluginOptions, corePlugin } from './plugins/core.js'
45
+ export {
46
+ type InlineFormat,
47
+ type SingleBlockPluginOptions,
48
+ singleBlockPlugin,
49
+ } from './plugins/single-block.js'
50
+ export { type LinkPluginOptions, linkPlugin } from './plugins/link.js'
51
+ export {
52
+ type CalloutKind,
53
+ type CalloutData,
54
+ type CalloutPluginOptions,
55
+ calloutPlugin,
56
+ $insertCallout,
57
+ } from './plugins/callout.js'
58
+ export { hrPlugin, $insertHorizontalRule } from './plugins/hr.js'
59
+ export { slashPlugin } from './plugins/slash.js'
60
+ export { contextMenuPlugin } from './plugins/context-menu.js'
61
+ export { floatingToolbarPlugin } from './plugins/floating-toolbar.js'
62
+ export { type MathPluginOptions, mathPlugin } from './plugins/math.js'
63
+ export { type MermaidPluginOptions, mermaidPlugin } from './plugins/mermaid.js'
64
+ export { type Mention, type MentionPluginOptions, mentionPlugin } from './plugins/mention.js'
65
+ export { type EmojiPluginOptions, DEFAULT_EMOJI, emojiPlugin } from './plugins/emoji.js'
66
+ export { type ImagePluginOptions, imagePlugin } from './plugins/image.js'
67
+ export { tablePlugin } from './plugins/table.js'
68
+
69
+ export { $insertMarkdownAtSelection, registerMarkdownPaste } from './paste.js'
70
+
71
+ export { GFM_NODES, GFM_TRANSFORMERS } from './transformers/gfm.js'
72
+ export { buildTransformers, orderTransformers } from './transformers/registry.js'
73
+
74
+ export { computeFormatState } from './format.js'
75
+
76
+ export {
77
+ STRIKETHROUGH_CLASS,
78
+ UNDERLINE_CLASS,
79
+ UNDERLINE_STRIKETHROUGH_CLASS,
80
+ defaultTheme,
81
+ mergeTheme,
82
+ } from './theme.js'
83
+
84
+ export {
85
+ type ToolbarItemParts,
86
+ type ToolbarParts,
87
+ type ToolbarOptions,
88
+ DEFAULT_GLYPHS,
89
+ connectToolbar,
90
+ toolbar,
91
+ } from './surfaces/toolbar.js'
92
+
93
+ export { type LinkDialogOptions, linkDialog } from './surfaces/link-dialog.js'
package/src/paste.ts ADDED
@@ -0,0 +1,96 @@
1
+ // Markdown-on-paste: when the user pastes plain text into the editor, parse it
2
+ // as Markdown and insert the resulting rich nodes at the caret instead of the
3
+ // literal source. Rich pastes (clipboards carrying `text/html`) are left to
4
+ // Lexical's own HTML import so copy-from-the-web keeps its formatting. Disabled
5
+ // per-editor via the `pasteMarkdown: false` config option.
6
+
7
+ import {
8
+ $createParagraphNode,
9
+ $getSelection,
10
+ $insertNodes,
11
+ $isRangeSelection,
12
+ $setSelection,
13
+ COMMAND_PRIORITY_HIGH,
14
+ PASTE_COMMAND,
15
+ isDOMNode,
16
+ isSelectionCapturedInDecoratorInput,
17
+ type LexicalEditor,
18
+ } from 'lexical'
19
+ import { $convertFromMarkdownString, type Transformer } from '@lexical/markdown'
20
+ import { $sanitizeLinkNodes } from './security.js'
21
+
22
+ /**
23
+ * Parse `markdown` with `transformers` and insert the produced nodes at the
24
+ * current range selection. The document is parsed into a detached scratch
25
+ * container (so the live root is never cleared) and the selection captured
26
+ * before the import — which moves the caret into the scratch node — is restored
27
+ * before the nodes are spliced in. Returns `true` only when nodes were actually
28
+ * inserted; `false` (a no-op) when there is no range selection to insert into or
29
+ * the markdown parsed to nothing — letting the caller fall back to the default
30
+ * paste behaviour instead of silently swallowing the event.
31
+ */
32
+ export function $insertMarkdownAtSelection(
33
+ markdown: string,
34
+ transformers: Array<Transformer>,
35
+ ): boolean {
36
+ const selection = $getSelection()
37
+ if (!$isRangeSelection(selection)) return false
38
+ const saved = selection.clone()
39
+
40
+ // Import into a detached element so `$convertFromMarkdownString`'s `root.clear()`
41
+ // touches the scratch node, not the live document.
42
+ const scratch = $createParagraphNode()
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)
48
+ const nodes = scratch.getChildren()
49
+ if (nodes.length === 0) return false
50
+
51
+ // The import re-homes the selection to the scratch node's start; restore the
52
+ // real caret, then splice the parsed nodes in (splitting blocks as needed).
53
+ $setSelection(saved)
54
+ $insertNodes(nodes)
55
+ return true
56
+ }
57
+
58
+ /**
59
+ * Register the markdown-on-paste handler on `editor`. Returns a disposer.
60
+ *
61
+ * Plain-text pastes are converted as Markdown. Pastes that also carry
62
+ * `text/html` are ignored so Lexical's richer HTML import handles them.
63
+ */
64
+ export function registerMarkdownPaste(
65
+ editor: LexicalEditor,
66
+ transformers: Array<Transformer>,
67
+ ): () => void {
68
+ return editor.registerCommand(
69
+ PASTE_COMMAND,
70
+ (event: ClipboardEvent) => {
71
+ // Let native inputs inside a DecoratorNode paste themselves (mirrors
72
+ // Lexical's own rich-text paste guard).
73
+ if (isDOMNode(event.target) && isSelectionCapturedInDecoratorInput(event.target)) return false
74
+ const clipboard = event.clipboardData
75
+ if (!clipboard) return false
76
+ // Defer to Lexical's HTML import when the source provides rich content.
77
+ if (clipboard.types.includes('text/html')) return false
78
+ const text = clipboard.getData('text/plain')
79
+ if (!text) return false
80
+
81
+ // Command listeners run inside a read context, so we can inspect the
82
+ // selection synchronously. Only claim the event when there is a range
83
+ // selection to insert into; otherwise fall through to Lexical's default
84
+ // paste handler (e.g. a selected decorator/NodeSelection) rather than
85
+ // swallowing the paste.
86
+ if (!$isRangeSelection($getSelection())) return false
87
+
88
+ event.preventDefault()
89
+ editor.update(() => {
90
+ $insertMarkdownAtSelection(text, transformers)
91
+ })
92
+ return true
93
+ },
94
+ COMMAND_PRIORITY_HIGH,
95
+ )
96
+ }
@@ -0,0 +1,51 @@
1
+ // Shared rendered-preview seam for the math / mermaid decorator plugins.
2
+ //
3
+ // SECURITY: a consumer-supplied `render` turns editor-document content
4
+ // (TeX / mermaid source the user typed) into a preview. To avoid forcing
5
+ // that through an unsanitized HTML sink, `render` may return EITHER:
6
+ //
7
+ // - a DOM `Node` — mounted directly with no HTML round-trip and NO
8
+ // sanitization needed (prefer this: hand back the element KaTeX /
9
+ // mermaid already produced); or
10
+ // - a `string` — injected as **trusted HTML**. The renderer owns
11
+ // sanitization in this branch; document content flows into it, so a
12
+ // vulnerable/misconfigured renderer is an XSS sink. Return sanitized
13
+ // markup (e.g. via DOMPurify) or prefer the Node form.
14
+
15
+ import { foreign, type Mountable, type Signal } from '@llui/dom'
16
+
17
+ /** A preview renderer: source string → safe DOM node, or trusted HTML string. */
18
+ export type PreviewRender = (source: string) => string | Node
19
+
20
+ interface PreviewInstance {
21
+ unbind: () => void
22
+ }
23
+
24
+ /**
25
+ * Build a reactive `data-part="preview"` region that re-renders whenever
26
+ * `source` changes. String results are set as trusted HTML; Node results
27
+ * are mounted directly (no sanitization). See the module header.
28
+ */
29
+ export function renderedPreview(
30
+ source: Signal<string>,
31
+ render: PreviewRender,
32
+ tag = 'div',
33
+ ): Mountable {
34
+ return foreign<PreviewInstance, { source: Signal<string> }>({
35
+ tag,
36
+ state: { source },
37
+ mount: ({ el, state }) => {
38
+ el.setAttribute('data-part', 'preview')
39
+ el.setAttribute('contenteditable', 'false')
40
+ const apply = (value: string): void => {
41
+ const out = render(value)
42
+ if (typeof out === 'string') el.innerHTML = out
43
+ else el.replaceChildren(out)
44
+ }
45
+ // `bind` fires immediately with the current value, then on change.
46
+ const unbind = state.source.bind(apply)
47
+ return { unbind }
48
+ },
49
+ unmount: (instance) => instance.unbind(),
50
+ })
51
+ }
@@ -0,0 +1,180 @@
1
+ // Callout (admonition) plugin — the custom-rendering showcase. A callout is a
2
+ // block decorator: it stores `{ kind, text }`, renders an LLui sub-view (its own
3
+ // TEA loop) with a clickable kind badge, and round-trips to `:::kind text`
4
+ // markdown via a contributed element transformer.
5
+
6
+ import { type ElementNode, type LexicalEditor, type LexicalNode } from 'lexical'
7
+ import { $insertNodeToNearestRoot } from '@lexical/utils'
8
+ import type { ElementTransformer } from '@lexical/markdown'
9
+ import {
10
+ $createLLuiDecoratorNode,
11
+ $isLLuiDecoratorNode,
12
+ LLuiDecoratorNode,
13
+ decoratorBridge,
14
+ } from '@llui/lexical'
15
+ import { button, div, span, text, type Signal } from '@llui/dom'
16
+ import type { MarkdownPlugin } from './types.js'
17
+
18
+ export type CalloutKind = 'note' | 'tip' | 'warning' | 'danger'
19
+
20
+ export interface CalloutData {
21
+ kind: CalloutKind
22
+ text: string
23
+ }
24
+
25
+ const KIND_CYCLE: readonly CalloutKind[] = ['note', 'tip', 'warning', 'danger']
26
+ const KIND_LABEL: Readonly<Record<CalloutKind, string>> = {
27
+ note: 'Note',
28
+ tip: 'Tip',
29
+ warning: 'Warning',
30
+ danger: 'Danger',
31
+ }
32
+
33
+ const BRIDGE_TYPE = 'callout'
34
+
35
+ function nextKind(kind: CalloutKind): CalloutKind {
36
+ const idx = KIND_CYCLE.indexOf(kind)
37
+ return KIND_CYCLE[(idx + 1) % KIND_CYCLE.length]!
38
+ }
39
+
40
+ function isCalloutData(value: unknown): value is CalloutData {
41
+ return (
42
+ typeof value === 'object' &&
43
+ value !== null &&
44
+ typeof (value as CalloutData).kind === 'string' &&
45
+ typeof (value as CalloutData).text === 'string'
46
+ )
47
+ }
48
+
49
+ // Keep keyboard/input/paste events from bubbling to the outer Lexical editor so
50
+ // the nested editable text island edits natively without Lexical intercepting.
51
+ const stop = (e: Event): void => e.stopPropagation()
52
+
53
+ /** The LLui sub-view rendered inside a callout DecoratorNode. The badge cycles
54
+ * the kind; the text is an editable island that persists into the Lexical node
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 })
92
+ },
93
+ },
94
+ [text(data.at('text') as Signal<string>)],
95
+ ),
96
+ ],
97
+ ),
98
+ ])
99
+
100
+ /** `:::kind text` element transformer (single-line admonition). */
101
+ const CALLOUT_TRANSFORMER: ElementTransformer = {
102
+ dependencies: [LLuiDecoratorNode],
103
+ export: (node: LexicalNode): string | null => {
104
+ if (!$isLLuiDecoratorNode(node) || node.getBridgeType() !== BRIDGE_TYPE) return null
105
+ const data = node.getData()
106
+ if (!isCalloutData(data)) return null
107
+ return `:::${data.kind} ${data.text}`
108
+ },
109
+ regExp: /^:::(note|tip|warning|danger)[ \t]+(.+)$/,
110
+ replace: (parentNode: ElementNode, _children, match): void => {
111
+ const kind = match[1] as CalloutKind
112
+ const callout = $createLLuiDecoratorNode(BRIDGE_TYPE, { kind, text: match[2] ?? '' })
113
+ parentNode.replace(callout)
114
+ },
115
+ type: 'element',
116
+ }
117
+
118
+ /** Insert a fresh callout at the current selection; returns the created node. */
119
+ export function $insertCallout(
120
+ kind: CalloutKind = 'note',
121
+ textValue = 'New callout',
122
+ ): LLuiDecoratorNode {
123
+ return $insertNodeToNearestRoot($createLLuiDecoratorNode(BRIDGE_TYPE, { kind, text: textValue }))
124
+ }
125
+
126
+ /** Move focus into a callout's editable text island once it has decorated, and
127
+ * select its placeholder text so the next keystroke replaces it. */
128
+ function focusCalloutText(editor: LexicalEditor, key: string, attempt = 0): void {
129
+ if (typeof requestAnimationFrame !== 'function') return
130
+ requestAnimationFrame(() => {
131
+ const span = editor.getElementByKey(key)?.querySelector('[data-part="text"]')
132
+ if (span instanceof HTMLElement) {
133
+ span.focus()
134
+ try {
135
+ const range = document.createRange()
136
+ range.selectNodeContents(span)
137
+ const sel = window.getSelection()
138
+ sel?.removeAllRanges()
139
+ sel?.addRange(range)
140
+ } catch {
141
+ /* selection API unavailable */
142
+ }
143
+ } else if (attempt < 3) {
144
+ focusCalloutText(editor, key, attempt + 1)
145
+ }
146
+ })
147
+ }
148
+
149
+ export interface CalloutPluginOptions {
150
+ /** Default kind for the toolbar/slash insert action. */
151
+ defaultKind?: CalloutKind
152
+ }
153
+
154
+ export function calloutPlugin(opts: CalloutPluginOptions = {}): MarkdownPlugin {
155
+ const defaultKind = opts.defaultKind ?? 'note'
156
+ return {
157
+ name: 'callout',
158
+ nodes: [LLuiDecoratorNode],
159
+ decorators: [calloutBridge],
160
+ transformers: [CALLOUT_TRANSFORMER],
161
+ items: [
162
+ {
163
+ id: 'callout',
164
+ label: 'Callout',
165
+ icon: 'callout',
166
+ group: 'insert',
167
+ keywords: ['note', 'admonition', 'aside', 'tip', 'warning'],
168
+ run: (editor) => {
169
+ let key = ''
170
+ editor.update(() => {
171
+ key = $insertCallout(defaultKind).getKey()
172
+ })
173
+ // Land the caret inside the new callout's text, not after the block.
174
+ focusCalloutText(editor, key)
175
+ },
176
+ surfaces: ['toolbar', 'slash', 'context'],
177
+ },
178
+ ],
179
+ }
180
+ }
@@ -0,0 +1,118 @@
1
+ // Right-click context menu — a plugin-UI overlay. `register` installs a
2
+ // `contextmenu` listener on the editor root and opens the menu at the pointer;
3
+ // the plugin-UI renders the menu and runs the chosen command.
4
+
5
+ import { div, each, text, type Signal } from '@llui/dom'
6
+ import { definePluginUI } from './ui.js'
7
+ import { OVERLAY_Z, hideOverlay, overlayRoot } from './overlay.js'
8
+ import type { CommandItem, MarkdownPlugin } from './types.js'
9
+
10
+ interface MenuItem {
11
+ id: string
12
+ label: string
13
+ }
14
+
15
+ interface ContextState {
16
+ open: boolean
17
+ x: number
18
+ y: number
19
+ items: MenuItem[]
20
+ }
21
+
22
+ type ContextMsg =
23
+ | { type: 'open'; x: number; y: number; items: MenuItem[] }
24
+ | { type: 'close' }
25
+ | { type: 'choose'; index: number }
26
+
27
+ type ContextEffect = { type: 'run'; id: string }
28
+
29
+ export function contextMenuPlugin(): MarkdownPlugin {
30
+ let contextItems: CommandItem[] = []
31
+
32
+ return {
33
+ name: 'contextMenu',
34
+ onItems: (items) => {
35
+ // Curated: only items that explicitly opt into the context menu (insert
36
+ // actions, links, history). Inline formatting lives in the floating bar.
37
+ contextItems = items.filter((i) => i.surfaces?.includes('context') ?? false)
38
+ },
39
+ register: (editor, ctx) => {
40
+ const onContext = (e: Event): void => {
41
+ const me = e as MouseEvent
42
+ e.preventDefault()
43
+ ctx.emit({
44
+ type: 'plugin',
45
+ name: 'contextMenu',
46
+ msg: {
47
+ type: 'open',
48
+ x: me.clientX,
49
+ y: me.clientY,
50
+ items: contextItems.map((i) => ({ id: i.id, label: i.label })),
51
+ },
52
+ })
53
+ }
54
+ const root = editor.getRootElement()
55
+ root?.addEventListener('contextmenu', onContext)
56
+ return () => {
57
+ editor.getRootElement()?.removeEventListener('contextmenu', onContext)
58
+ }
59
+ },
60
+ ui: definePluginUI<ContextState, ContextMsg, ContextEffect>({
61
+ init: () => ({ open: false, x: 0, y: 0, items: [] }),
62
+ update: (state, msg) => {
63
+ switch (msg.type) {
64
+ case 'open':
65
+ return { open: msg.items.length > 0, x: msg.x, y: msg.y, items: msg.items }
66
+ case 'close':
67
+ return hideOverlay(state)
68
+ case 'choose': {
69
+ const item = state.items[msg.index]
70
+ if (!item) return hideOverlay(state)
71
+ return [{ ...state, open: false }, [{ type: 'run', id: item.id }]]
72
+ }
73
+ }
74
+ },
75
+ onEffect: (effect, ctx) => {
76
+ ctx.emit({ type: 'runCommand', id: effect.id })
77
+ },
78
+ view: ({ state, send }) =>
79
+ overlayRoot({
80
+ open: state.at('open'),
81
+ x: state.at('x'),
82
+ y: state.at('y'),
83
+ zIndex: OVERLAY_Z.contextMenu,
84
+ attrs: { 'data-scope': 'md-context', 'data-part': 'root' },
85
+ // Backdrop closes the menu on any outside interaction.
86
+ before: () => [
87
+ div({
88
+ 'data-scope': 'md-context',
89
+ 'data-part': 'backdrop',
90
+ onMouseDown: () => send({ type: 'close' }),
91
+ onContextMenu: (e: Event) => {
92
+ e.preventDefault()
93
+ send({ type: 'close' })
94
+ },
95
+ }),
96
+ ],
97
+ children: () => [
98
+ each(state.at('items') as Signal<MenuItem[]>, {
99
+ key: (it) => it.id,
100
+ render: (item, index) => [
101
+ div(
102
+ {
103
+ 'data-scope': 'md-context',
104
+ 'data-part': 'option',
105
+ onMouseDown: (e: MouseEvent) => {
106
+ e.preventDefault()
107
+ send({ type: 'choose', index: index.peek() })
108
+ },
109
+ },
110
+ [text(item.map((it) => it.label))],
111
+ ),
112
+ ],
113
+ }),
114
+ ],
115
+ }),
116
+ }),
117
+ }
118
+ }