@llui/markdown-editor 0.2.12 → 0.2.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +34 -8
- package/dist/editor.js.map +1 -1
- package/dist/effects.d.ts +5 -3
- package/dist/effects.d.ts.map +1 -1
- package/dist/effects.js +7 -5
- package/dist/effects.js.map +1 -1
- package/dist/format.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/paste.d.ts.map +1 -1
- package/dist/paste.js +5 -0
- package/dist/paste.js.map +1 -1
- package/dist/plugins/_preview.js.map +1 -1
- package/dist/plugins/callout.d.ts.map +1 -1
- package/dist/plugins/callout.js +37 -45
- package/dist/plugins/callout.js.map +1 -1
- package/dist/plugins/context-menu.js.map +1 -1
- package/dist/plugins/core.js.map +1 -1
- package/dist/plugins/emoji.js.map +1 -1
- package/dist/plugins/floating-toolbar.js.map +1 -1
- package/dist/plugins/hr.d.ts.map +1 -1
- package/dist/plugins/hr.js +4 -7
- package/dist/plugins/hr.js.map +1 -1
- package/dist/plugins/image.d.ts.map +1 -1
- package/dist/plugins/image.js +28 -20
- package/dist/plugins/image.js.map +1 -1
- package/dist/plugins/inline.js.map +1 -1
- package/dist/plugins/link.d.ts.map +1 -1
- package/dist/plugins/link.js +16 -9
- package/dist/plugins/link.js.map +1 -1
- package/dist/plugins/math.d.ts.map +1 -1
- package/dist/plugins/math.js +25 -34
- package/dist/plugins/math.js.map +1 -1
- package/dist/plugins/mention.js.map +1 -1
- package/dist/plugins/mermaid.d.ts.map +1 -1
- package/dist/plugins/mermaid.js +25 -34
- package/dist/plugins/mermaid.js.map +1 -1
- package/dist/plugins/overlay.js.map +1 -1
- package/dist/plugins/single-block.js.map +1 -1
- package/dist/plugins/slash.js.map +1 -1
- package/dist/plugins/table.d.ts.map +1 -1
- package/dist/plugins/table.js +7 -4
- package/dist/plugins/table.js.map +1 -1
- package/dist/plugins/types.js.map +1 -1
- package/dist/plugins/ui.js.map +1 -1
- package/dist/security.d.ts +26 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +87 -0
- package/dist/security.js.map +1 -0
- package/dist/state.js.map +1 -1
- package/dist/surfaces/link-dialog.js.map +1 -1
- package/dist/surfaces/toolbar.js.map +1 -1
- package/dist/theme.js.map +1 -1
- package/dist/transformers/gfm.js.map +1 -1
- package/dist/transformers/registry.js.map +1 -1
- package/package.json +23 -17
- package/src/editor.ts +339 -0
- package/src/effects.ts +53 -0
- package/src/format.ts +57 -0
- package/src/index.ts +93 -0
- package/src/paste.ts +96 -0
- package/src/plugins/_preview.ts +51 -0
- package/src/plugins/callout.ts +180 -0
- package/src/plugins/context-menu.ts +118 -0
- package/src/plugins/core.ts +179 -0
- package/src/plugins/emoji.ts +59 -0
- package/src/plugins/floating-toolbar.ts +180 -0
- package/src/plugins/hr.ts +56 -0
- package/src/plugins/image.ts +240 -0
- package/src/plugins/inline.ts +76 -0
- package/src/plugins/link.ts +143 -0
- package/src/plugins/math.ts +102 -0
- package/src/plugins/mention.ts +224 -0
- package/src/plugins/mermaid.ts +115 -0
- package/src/plugins/overlay.ts +138 -0
- package/src/plugins/single-block.ts +193 -0
- package/src/plugins/slash.ts +224 -0
- package/src/plugins/table.ts +284 -0
- package/src/plugins/types.ts +50 -0
- package/src/plugins/ui.ts +79 -0
- package/src/security.ts +89 -0
- package/src/state.ts +220 -0
- package/src/styles/editor.css +554 -0
- package/src/surfaces/link-dialog.ts +67 -0
- package/src/surfaces/toolbar.ts +246 -0
- package/src/theme.ts +42 -0
- package/src/transformers/gfm.ts +69 -0
- package/src/transformers/registry.ts +47 -0
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
|
+
}
|