@llui/markdown-editor 0.2.11 → 0.2.13

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.
@@ -0,0 +1,180 @@
1
+ // Floating selection toolbar — a bubble of inline-format actions that appears
2
+ // above a non-collapsed text selection. A plugin-UI overlay: `register` watches
3
+ // selection changes and positions/fills the bar; clicking a button runs the
4
+ // command on the still-live selection.
5
+
6
+ import { $getSelection, $isRangeSelection } from 'lexical'
7
+ import { $findMatchingParent, mergeRegister } from '@lexical/utils'
8
+ import { $isLinkNode } from '@lexical/link'
9
+ import { button, each, span, text, unsafeHtml, type Signal } from '@llui/dom'
10
+ import { definePluginUI } from './ui.js'
11
+ import { OVERLAY_Z, hideOverlay, onViewportChange, overlayRoot } from './overlay.js'
12
+ import { DEFAULT_GLYPHS } from '../surfaces/toolbar.js'
13
+ import type { CommandItem, MarkdownPlugin } from './types.js'
14
+
15
+ interface BarItem {
16
+ id: string
17
+ label: string
18
+ glyph: string
19
+ active: boolean
20
+ }
21
+
22
+ interface FloatState {
23
+ open: boolean
24
+ x: number
25
+ y: number
26
+ items: BarItem[]
27
+ }
28
+
29
+ type FloatMsg =
30
+ | { type: 'show'; x: number; y: number; items: BarItem[] }
31
+ | { type: 'hide' }
32
+ | { type: 'run'; index: number }
33
+
34
+ type FloatEffect = { type: 'run'; id: string }
35
+
36
+ interface InlineFormat {
37
+ bold: boolean
38
+ italic: boolean
39
+ strikethrough: boolean
40
+ code: boolean
41
+ link: boolean
42
+ }
43
+
44
+ function readFormat(editor: import('lexical').LexicalEditor): InlineFormat {
45
+ return editor.getEditorState().read(() => {
46
+ const selection = $getSelection()
47
+ if (!$isRangeSelection(selection)) {
48
+ return { bold: false, italic: false, strikethrough: false, code: false, link: false }
49
+ }
50
+ return {
51
+ bold: selection.hasFormat('bold'),
52
+ italic: selection.hasFormat('italic'),
53
+ strikethrough: selection.hasFormat('strikethrough'),
54
+ code: selection.hasFormat('code'),
55
+ link: $findMatchingParent(selection.anchor.getNode(), (n) => $isLinkNode(n)) !== null,
56
+ }
57
+ })
58
+ }
59
+
60
+ function activeFor(id: string, fmt: InlineFormat): boolean {
61
+ switch (id) {
62
+ case 'bold':
63
+ return fmt.bold
64
+ case 'italic':
65
+ return fmt.italic
66
+ case 'strikethrough':
67
+ return fmt.strikethrough
68
+ case 'code':
69
+ return fmt.code
70
+ case 'link':
71
+ return fmt.link
72
+ default:
73
+ return false
74
+ }
75
+ }
76
+
77
+ export function floatingToolbarPlugin(): MarkdownPlugin {
78
+ let floatingItems: CommandItem[] = []
79
+
80
+ return {
81
+ name: 'floatingToolbar',
82
+ onItems: (items) => {
83
+ floatingItems = items.filter((i) =>
84
+ i.surfaces ? i.surfaces.includes('floating') : i.group === 'inline',
85
+ )
86
+ },
87
+ register: (editor, ctx) => {
88
+ const refresh = (): void => {
89
+ const collapsed = editor.getEditorState().read(() => {
90
+ const s = $getSelection()
91
+ return !$isRangeSelection(s) || s.isCollapsed()
92
+ })
93
+ const dom = typeof window !== 'undefined' ? window.getSelection() : null
94
+ if (collapsed || !dom || dom.rangeCount === 0) {
95
+ ctx.emit({ type: 'plugin', name: 'floatingToolbar', msg: { type: 'hide' } })
96
+ return
97
+ }
98
+ const rect = dom.getRangeAt(0).getBoundingClientRect()
99
+ if (rect.width === 0 && rect.height === 0) {
100
+ ctx.emit({ type: 'plugin', name: 'floatingToolbar', msg: { type: 'hide' } })
101
+ return
102
+ }
103
+ const fmt = readFormat(editor)
104
+ const items: BarItem[] = floatingItems.map((i) => ({
105
+ id: i.id,
106
+ label: i.label,
107
+ glyph: DEFAULT_GLYPHS[i.id] ?? i.label,
108
+ active: activeFor(i.id, fmt),
109
+ }))
110
+ ctx.emit({
111
+ type: 'plugin',
112
+ name: 'floatingToolbar',
113
+ msg: { type: 'show', x: rect.left + rect.width / 2, y: rect.top, items },
114
+ })
115
+ }
116
+ return mergeRegister(
117
+ editor.registerUpdateListener(() => refresh()),
118
+ // Keep the bubble glued to the selection while the page scrolls.
119
+ onViewportChange(refresh),
120
+ )
121
+ },
122
+ ui: definePluginUI<FloatState, FloatMsg, FloatEffect>({
123
+ init: () => ({ open: false, x: 0, y: 0, items: [] }),
124
+ update: (state, msg) => {
125
+ switch (msg.type) {
126
+ case 'show':
127
+ return { open: msg.items.length > 0, x: msg.x, y: msg.y, items: msg.items }
128
+ case 'hide':
129
+ return hideOverlay(state)
130
+ case 'run': {
131
+ const item = state.items[msg.index]
132
+ return item ? [state, [{ type: 'run', id: item.id }]] : state
133
+ }
134
+ }
135
+ },
136
+ onEffect: (effect, ctx) => {
137
+ ctx.emit({ type: 'runCommand', id: effect.id })
138
+ },
139
+ // `x` is the selection's horizontal centre; the transform centres the bar on
140
+ // it and lifts it above the selection.
141
+ view: ({ state, send }) =>
142
+ overlayRoot({
143
+ open: state.at('open'),
144
+ x: state.at('x'),
145
+ y: state.at('y'),
146
+ zIndex: OVERLAY_Z.floatingToolbar,
147
+ transform: 'transform:translate(-50%,-115%)',
148
+ attrs: { 'data-scope': 'md-floating', 'data-part': 'bar' },
149
+ children: () => [
150
+ each(state.at('items') as Signal<BarItem[]>, {
151
+ key: (it) => it.id,
152
+ render: (item, index) => [
153
+ button(
154
+ {
155
+ type: 'button',
156
+ 'data-scope': 'md-floating',
157
+ 'data-part': 'item',
158
+ 'data-active': item.map((it) => (it.active ? '' : undefined)),
159
+ 'aria-label': item.map((it) => it.label),
160
+ onMouseDown: (e: MouseEvent) => {
161
+ e.preventDefault()
162
+ send({ type: 'run', index: index.peek() })
163
+ },
164
+ },
165
+ [span({ 'data-part': 'glyph', 'aria-hidden': 'true' }, [renderGlyph(item)])],
166
+ ),
167
+ ],
168
+ }),
169
+ ],
170
+ }),
171
+ }),
172
+ }
173
+ }
174
+
175
+ /** Render an item's glyph (SVG markup → unsafeHtml, otherwise text). */
176
+ function renderGlyph(item: Signal<BarItem>): import('@llui/dom').Mountable {
177
+ // The glyph value is stable per row; reading once is fine.
178
+ const glyph = item.peek().glyph
179
+ return glyph.trimStart().startsWith('<svg') ? unsafeHtml(glyph) : text(item.map((it) => it.glyph))
180
+ }
@@ -0,0 +1,66 @@
1
+ // Horizontal-rule plugin — a thematic break rendered as an `<hr>` via the
2
+ // decorator bridge, round-tripping to `---` markdown.
3
+
4
+ import { type ElementNode, type LexicalNode } from 'lexical'
5
+ import { $insertNodeToNearestRoot } from '@lexical/utils'
6
+ import type { ElementTransformer } from '@lexical/markdown'
7
+ import {
8
+ $createLLuiDecoratorNode,
9
+ $isLLuiDecoratorNode,
10
+ LLuiDecoratorNode,
11
+ decoratorBridge,
12
+ } from '@llui/lexical'
13
+ import { component, hr } from '@llui/dom'
14
+ import type { MarkdownPlugin } from './types.js'
15
+
16
+ const BRIDGE_TYPE = 'hr'
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
+ )
31
+
32
+ const HR_TRANSFORMER: ElementTransformer = {
33
+ dependencies: [LLuiDecoratorNode],
34
+ export: (node: LexicalNode): string | null =>
35
+ $isLLuiDecoratorNode(node) && node.getBridgeType() === BRIDGE_TYPE ? '---' : null,
36
+ regExp: /^(---|\*\*\*|___)\s*$/,
37
+ replace: (parentNode: ElementNode): void => {
38
+ parentNode.replace($createLLuiDecoratorNode(BRIDGE_TYPE, {}))
39
+ },
40
+ type: 'element',
41
+ }
42
+
43
+ /** Insert a horizontal rule at the current selection. */
44
+ export function $insertHorizontalRule(): void {
45
+ $insertNodeToNearestRoot($createLLuiDecoratorNode(BRIDGE_TYPE, {}))
46
+ }
47
+
48
+ export function hrPlugin(): MarkdownPlugin {
49
+ return {
50
+ name: 'hr',
51
+ nodes: [LLuiDecoratorNode],
52
+ decorators: [hrBridge],
53
+ transformers: [HR_TRANSFORMER],
54
+ items: [
55
+ {
56
+ id: 'horizontalRule',
57
+ label: 'Divider',
58
+ icon: 'hr',
59
+ group: 'insert',
60
+ keywords: ['rule', 'divider', 'separator', 'hr'],
61
+ run: (editor) => editor.update(() => $insertHorizontalRule()),
62
+ surfaces: ['toolbar', 'slash', 'context'],
63
+ },
64
+ ],
65
+ }
66
+ }
@@ -0,0 +1,237 @@
1
+ // Image plugin — a block image rendered via the decorator bridge, round-tripping
2
+ // to `![alt](src)` markdown, inserted through a plugin-UI dialog (URL + alt, with
3
+ // optional file upload). Exercises decorator rendering + a transformer + the
4
+ // plugin-UI extension all at once.
5
+
6
+ import {
7
+ $getSelection,
8
+ $setSelection,
9
+ type BaseSelection,
10
+ type ElementNode,
11
+ type LexicalNode,
12
+ } from 'lexical'
13
+ import { $insertNodeToNearestRoot } from '@lexical/utils'
14
+ import type { ElementTransformer } from '@lexical/markdown'
15
+ import {
16
+ $createLLuiDecoratorNode,
17
+ $isLLuiDecoratorNode,
18
+ LLuiDecoratorNode,
19
+ decoratorBridge,
20
+ } from '@llui/lexical'
21
+ import { button, component, div, img, input, text, type Signal } from '@llui/dom'
22
+ import {
23
+ connect as connectDialog,
24
+ overlay as overlayDialog,
25
+ type DialogMsg,
26
+ } from '@llui/components/dialog'
27
+ import { definePluginUI } from './ui.js'
28
+ import type { MarkdownPlugin } from './types.js'
29
+
30
+ const BRIDGE_TYPE = 'image'
31
+
32
+ interface ImageData {
33
+ src: string
34
+ alt: string
35
+ }
36
+
37
+ function isImageData(value: unknown): value is ImageData {
38
+ return (
39
+ typeof value === 'object' &&
40
+ value !== null &&
41
+ typeof (value as ImageData).src === 'string' &&
42
+ typeof (value as ImageData).alt === 'string'
43
+ )
44
+ }
45
+
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
+ )
60
+
61
+ const IMAGE_TRANSFORMER: ElementTransformer = {
62
+ dependencies: [LLuiDecoratorNode],
63
+ export: (node: LexicalNode): string | null => {
64
+ if (!$isLLuiDecoratorNode(node) || node.getBridgeType() !== BRIDGE_TYPE) return null
65
+ const data = node.getData()
66
+ return isImageData(data) ? `![${data.alt}](${data.src})` : null
67
+ },
68
+ regExp: /^!\[([^\]]*)\]\(([^)]+)\)$/,
69
+ replace: (parentNode: ElementNode, _children, match): void => {
70
+ parentNode.replace(
71
+ $createLLuiDecoratorNode(BRIDGE_TYPE, { alt: match[1] ?? '', src: match[2] ?? '' }),
72
+ )
73
+ },
74
+ type: 'element',
75
+ }
76
+
77
+ interface ImageState {
78
+ dialog: { open: boolean }
79
+ src: string
80
+ alt: string
81
+ }
82
+
83
+ type ImageMsg =
84
+ | { type: 'open' }
85
+ | { type: 'setSrc'; src: string }
86
+ | { type: 'setAlt'; alt: string }
87
+ | { type: 'submit' }
88
+ | { type: 'dialog'; msg: DialogMsg }
89
+
90
+ type ImageEffect = { type: 'begin' } | { type: 'insert'; src: string; alt: string }
91
+
92
+ function dialogOpen(msg: DialogMsg, current: boolean): boolean {
93
+ switch (msg.type) {
94
+ case 'open':
95
+ return true
96
+ case 'close':
97
+ return false
98
+ case 'toggle':
99
+ return !current
100
+ case 'setOpen':
101
+ return msg.open
102
+ case 'animationEnd':
103
+ case 'transitionEnd':
104
+ return current
105
+ }
106
+ }
107
+
108
+ export interface ImagePluginOptions {
109
+ /** Upload a chosen file and resolve to its URL. When omitted, the file picker
110
+ * is hidden and only URL entry is offered. */
111
+ upload?: (file: File) => Promise<string>
112
+ }
113
+
114
+ export function imagePlugin(opts: ImagePluginOptions = {}): MarkdownPlugin {
115
+ let savedSelection: BaseSelection | null = null
116
+
117
+ return {
118
+ name: 'image',
119
+ nodes: [LLuiDecoratorNode],
120
+ decorators: [imageBridge],
121
+ transformers: [IMAGE_TRANSFORMER],
122
+ items: [
123
+ {
124
+ id: 'image',
125
+ label: 'Image',
126
+ icon: 'image',
127
+ group: 'insert',
128
+ keywords: ['img', 'picture', 'photo'],
129
+ run: (_editor, ctx) => ctx.send({ type: 'plugin', name: 'image', msg: { type: 'open' } }),
130
+ surfaces: ['toolbar', 'slash', 'context'],
131
+ },
132
+ ],
133
+ ui: definePluginUI<ImageState, ImageMsg, ImageEffect>({
134
+ init: () => ({ dialog: { open: false }, src: '', alt: '' }),
135
+ update: (state, msg) => {
136
+ switch (msg.type) {
137
+ case 'open':
138
+ return [{ dialog: { open: true }, src: '', alt: '' }, [{ type: 'begin' }]]
139
+ case 'setSrc':
140
+ return { ...state, src: msg.src }
141
+ case 'setAlt':
142
+ return { ...state, alt: msg.alt }
143
+ case 'submit':
144
+ return [
145
+ { ...state, dialog: { open: false } },
146
+ [{ type: 'insert', src: state.src, alt: state.alt }],
147
+ ]
148
+ case 'dialog': {
149
+ const open = dialogOpen(msg.msg, state.dialog.open)
150
+ return open === state.dialog.open ? state : { ...state, dialog: { open } }
151
+ }
152
+ }
153
+ },
154
+ view: ({ state, send }) => {
155
+ const dialogSend = (msg: DialogMsg): void => send({ type: 'dialog', msg })
156
+ const parts = connectDialog(state.at('dialog'), dialogSend, {
157
+ id: 'md-image-dialog',
158
+ closeLabel: 'Cancel',
159
+ })
160
+ return [
161
+ overlayDialog({
162
+ state: state.at('dialog'),
163
+ send: dialogSend,
164
+ parts,
165
+ content: () => [
166
+ div({ ...parts.content, 'data-md-link': 'box' }, [
167
+ div({ ...parts.title, 'data-md-link': 'title' }, [text('Insert image')]),
168
+ input({
169
+ 'data-md-link': 'input',
170
+ type: 'url',
171
+ placeholder: 'https://example.com/image.png',
172
+ value: state.at('src') as Signal<string>,
173
+ onInput: (e: Event) =>
174
+ send({ type: 'setSrc', src: (e.target as HTMLInputElement).value }),
175
+ }),
176
+ input({
177
+ 'data-md-link': 'input',
178
+ 'data-md-image': 'alt',
179
+ type: 'text',
180
+ placeholder: 'Alt text (description)',
181
+ value: state.at('alt') as Signal<string>,
182
+ onInput: (e: Event) =>
183
+ send({ type: 'setAlt', alt: (e.target as HTMLInputElement).value }),
184
+ }),
185
+ ...(opts.upload
186
+ ? [
187
+ input({
188
+ 'data-md-image': 'file',
189
+ type: 'file',
190
+ accept: 'image/*',
191
+ onChange: (e: Event) => {
192
+ const file = (e.target as HTMLInputElement).files?.[0]
193
+ if (file && opts.upload) {
194
+ void opts.upload(file).then((src) => send({ type: 'setSrc', src }))
195
+ }
196
+ },
197
+ }),
198
+ ]
199
+ : []),
200
+ div({ 'data-md-link': 'actions' }, [
201
+ button({ ...parts.closeTrigger, 'data-md-link': 'cancel' }, [text('Cancel')]),
202
+ button(
203
+ {
204
+ type: 'button',
205
+ 'data-md-link': 'apply',
206
+ onClick: () => send({ type: 'submit' }),
207
+ },
208
+ [text('Insert')],
209
+ ),
210
+ ]),
211
+ ]),
212
+ ],
213
+ }),
214
+ ]
215
+ },
216
+ onEffect: (effect, ctx) => {
217
+ const editor = ctx.editor()
218
+ if (!editor) return
219
+ if (effect.type === 'begin') {
220
+ savedSelection = editor.getEditorState().read(() => {
221
+ const selection = $getSelection()
222
+ return selection ? selection.clone() : null
223
+ })
224
+ return
225
+ }
226
+ if (effect.src.trim() === '') return
227
+ editor.update(() => {
228
+ if (savedSelection) $setSelection(savedSelection.clone())
229
+ $insertNodeToNearestRoot(
230
+ $createLLuiDecoratorNode(BRIDGE_TYPE, { src: effect.src.trim(), alt: effect.alt }),
231
+ )
232
+ })
233
+ savedSelection = null
234
+ },
235
+ }),
236
+ }
237
+ }
@@ -0,0 +1,76 @@
1
+ // Shared inline text-formatting building blocks: the bold / italic /
2
+ // strikethrough / code command items and the `FORMAT_TEXT_COMMAND` dispatcher.
3
+ // Both `corePlugin` (the full GFM superset) and `singleBlockPlugin` (inline-only)
4
+ // derive their inline surface from here, so the canonical inline-format set and
5
+ // its labels/icons/keywords live in exactly one place.
6
+
7
+ import { FORMAT_TEXT_COMMAND, type LexicalEditor, type TextFormatType } from 'lexical'
8
+ import type { FormatState } from '../state.js'
9
+ import type { CommandItem } from './types.js'
10
+
11
+ /** An inline text-format surfaced as a toolbar command item. */
12
+ export type InlineFormat = 'bold' | 'italic' | 'strikethrough' | 'code'
13
+
14
+ /** The full inline-format set, in toolbar order. */
15
+ export const INLINE_FORMATS: readonly InlineFormat[] = ['bold', 'italic', 'strikethrough', 'code']
16
+
17
+ interface InlineFormatDef {
18
+ label: string
19
+ icon: string
20
+ keywords: readonly string[]
21
+ format: TextFormatType
22
+ isActive: (f: FormatState) => boolean
23
+ }
24
+
25
+ const DEFS: Record<InlineFormat, InlineFormatDef> = {
26
+ bold: {
27
+ label: 'Bold',
28
+ icon: 'bold',
29
+ keywords: ['strong'],
30
+ format: 'bold',
31
+ isActive: (f) => f.bold,
32
+ },
33
+ italic: {
34
+ label: 'Italic',
35
+ icon: 'italic',
36
+ keywords: ['emphasis'],
37
+ format: 'italic',
38
+ isActive: (f) => f.italic,
39
+ },
40
+ strikethrough: {
41
+ label: 'Strikethrough',
42
+ icon: 'strikethrough',
43
+ keywords: ['strike', 'del'],
44
+ format: 'strikethrough',
45
+ isActive: (f) => f.strikethrough,
46
+ },
47
+ code: {
48
+ label: 'Inline code',
49
+ icon: 'code',
50
+ keywords: ['mono'],
51
+ format: 'code',
52
+ isActive: (f) => f.code,
53
+ },
54
+ }
55
+
56
+ /** Dispatch a text-format toggle on the live editor. */
57
+ export function inlineFormatCommand(format: TextFormatType): (editor: LexicalEditor) => void {
58
+ return (editor) => editor.dispatchCommand(FORMAT_TEXT_COMMAND, format)
59
+ }
60
+
61
+ /** Build the inline `CommandItem`s for `formats` (default: all four), grouped
62
+ * under `'inline'`. Surfaces are left unset (item appears in every surface). */
63
+ export function inlineItems(formats: readonly InlineFormat[] = INLINE_FORMATS): CommandItem[] {
64
+ return formats.map((id) => {
65
+ const def = DEFS[id]
66
+ return {
67
+ id,
68
+ label: def.label,
69
+ icon: def.icon,
70
+ group: 'inline',
71
+ keywords: def.keywords,
72
+ run: inlineFormatCommand(def.format),
73
+ isActive: def.isActive,
74
+ }
75
+ })
76
+ }
@@ -0,0 +1,133 @@
1
+ // The link plugin — a stateful, UI-bearing feature built entirely as a plugin
2
+ // (no core editor changes). It owns its dialog state, its view (the modal), and
3
+ // its effects (save/restore selection + toggle the link). This is the proof that
4
+ // the plugin-UI extension makes such features pluggable rather than built-in.
5
+
6
+ import {
7
+ $getSelection,
8
+ $isRangeSelection,
9
+ $setSelection,
10
+ type BaseSelection,
11
+ type LexicalEditor,
12
+ } from 'lexical'
13
+ import { $findMatchingParent } from '@lexical/utils'
14
+ import { $isLinkNode, $toggleLink } from '@lexical/link'
15
+ import type { DialogMsg, DialogState } from '@llui/components/dialog'
16
+ import { linkDialog } from '../surfaces/link-dialog.js'
17
+ import { definePluginUI } from './ui.js'
18
+ import type { CommandItem, MarkdownPlugin } from './types.js'
19
+
20
+ const PLUGIN = 'link'
21
+
22
+ interface LinkState {
23
+ dialog: DialogState
24
+ url: string
25
+ }
26
+
27
+ type LinkMsg =
28
+ | { type: 'open' }
29
+ | { type: 'show'; url: string }
30
+ | { type: 'setUrl'; url: string }
31
+ | { type: 'submit' }
32
+ | { type: 'dialog'; msg: DialogMsg }
33
+
34
+ type LinkEffect = { type: 'begin' } | { type: 'commit'; url: string }
35
+
36
+ /** Read the URL of the link wrapping the current selection (empty if none). */
37
+ function readLinkUrl(editor: LexicalEditor): string {
38
+ return editor.getEditorState().read(() => {
39
+ const selection = $getSelection()
40
+ if (!$isRangeSelection(selection)) return ''
41
+ const link = $findMatchingParent(selection.anchor.getNode(), (node) => $isLinkNode(node))
42
+ return $isLinkNode(link) ? link.getURL() : ''
43
+ })
44
+ }
45
+
46
+ function dialogOpen(msg: DialogMsg, current: boolean): boolean {
47
+ switch (msg.type) {
48
+ case 'open':
49
+ return true
50
+ case 'close':
51
+ return false
52
+ case 'toggle':
53
+ return !current
54
+ case 'setOpen':
55
+ return msg.open
56
+ case 'animationEnd':
57
+ case 'transitionEnd':
58
+ return current
59
+ }
60
+ }
61
+
62
+ export interface LinkPluginOptions {
63
+ /** Default URL pre-filled when there's no existing link (default ''). */
64
+ defaultUrl?: string
65
+ }
66
+
67
+ export function linkPlugin(opts: LinkPluginOptions = {}): MarkdownPlugin {
68
+ // 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
71
+
72
+ const item: CommandItem = {
73
+ id: 'link',
74
+ label: 'Link',
75
+ icon: 'link',
76
+ group: 'inline',
77
+ keywords: ['url', 'href'],
78
+ run: (_editor, ctx) => ctx.send({ type: 'plugin', name: PLUGIN, msg: { type: 'open' } }),
79
+ isActive: (f) => f.link,
80
+ surfaces: ['toolbar', 'floating', 'context'],
81
+ }
82
+
83
+ return {
84
+ name: PLUGIN,
85
+ items: [item],
86
+ ui: definePluginUI<LinkState, LinkMsg, LinkEffect>({
87
+ init: () => ({ dialog: { open: false }, url: opts.defaultUrl ?? '' }),
88
+ update: (state, msg) => {
89
+ switch (msg.type) {
90
+ case 'open':
91
+ return [state, [{ type: 'begin' }]]
92
+ case 'show':
93
+ return { dialog: { open: true }, url: msg.url }
94
+ case 'setUrl':
95
+ return { ...state, url: msg.url }
96
+ case 'submit':
97
+ return [{ ...state, dialog: { open: false } }, [{ type: 'commit', url: state.url }]]
98
+ case 'dialog': {
99
+ const open = dialogOpen(msg.msg, state.dialog.open)
100
+ return open === state.dialog.open ? state : { ...state, dialog: { open } }
101
+ }
102
+ }
103
+ },
104
+ view: ({ state, send }) => [
105
+ linkDialog({
106
+ dialog: state.at('dialog'),
107
+ url: state.at('url'),
108
+ onInput: (url) => send({ type: 'setUrl', url }),
109
+ onSubmit: () => send({ type: 'submit' }),
110
+ onDialog: (msg) => send({ type: 'dialog', msg }),
111
+ }),
112
+ ],
113
+ onEffect: (effect, ctx) => {
114
+ const editor = ctx.editor()
115
+ if (!editor) return
116
+ if (effect.type === 'begin') {
117
+ savedSelection = editor.getEditorState().read(() => {
118
+ const selection = $getSelection()
119
+ return selection ? selection.clone() : null
120
+ })
121
+ ctx.send({ type: 'show', url: readLinkUrl(editor) })
122
+ return
123
+ }
124
+ const url = effect.url.trim()
125
+ editor.update(() => {
126
+ if (savedSelection) $setSelection(savedSelection.clone())
127
+ $toggleLink(url === '' ? null : url)
128
+ })
129
+ savedSelection = null
130
+ },
131
+ }),
132
+ }
133
+ }