@llui/markdown-editor 0.3.0 → 0.4.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 (54) hide show
  1. package/dist/editor.d.ts +15 -2
  2. package/dist/editor.d.ts.map +1 -1
  3. package/dist/editor.js +12 -2
  4. package/dist/editor.js.map +1 -1
  5. package/dist/index.d.ts +6 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +15 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/plugins/block-drag.d.ts +73 -0
  10. package/dist/plugins/block-drag.d.ts.map +1 -0
  11. package/dist/plugins/block-drag.js +760 -0
  12. package/dist/plugins/block-drag.js.map +1 -0
  13. package/dist/plugins/code-language.d.ts +60 -0
  14. package/dist/plugins/code-language.d.ts.map +1 -0
  15. package/dist/plugins/code-language.js +308 -0
  16. package/dist/plugins/code-language.js.map +1 -0
  17. package/dist/plugins/frontmatter.d.ts +45 -0
  18. package/dist/plugins/frontmatter.d.ts.map +1 -0
  19. package/dist/plugins/frontmatter.js +281 -0
  20. package/dist/plugins/frontmatter.js.map +1 -0
  21. package/dist/plugins/overlay.d.ts +1 -0
  22. package/dist/plugins/overlay.d.ts.map +1 -1
  23. package/dist/plugins/overlay.js +7 -0
  24. package/dist/plugins/overlay.js.map +1 -1
  25. package/dist/plugins/wikilink.d.ts +91 -0
  26. package/dist/plugins/wikilink.d.ts.map +1 -0
  27. package/dist/plugins/wikilink.js +467 -0
  28. package/dist/plugins/wikilink.js.map +1 -0
  29. package/dist/styles/block-drag.css +63 -0
  30. package/dist/styles/editor.css +64 -0
  31. package/dist/transformers/code.d.ts +24 -0
  32. package/dist/transformers/code.d.ts.map +1 -0
  33. package/dist/transformers/code.js +167 -0
  34. package/dist/transformers/code.js.map +1 -0
  35. package/dist/transformers/gfm.d.ts.map +1 -1
  36. package/dist/transformers/gfm.js +7 -2
  37. package/dist/transformers/gfm.js.map +1 -1
  38. package/dist/transformers/registry.d.ts +3 -0
  39. package/dist/transformers/registry.d.ts.map +1 -1
  40. package/dist/transformers/registry.js +26 -0
  41. package/dist/transformers/registry.js.map +1 -1
  42. package/package.json +40 -26
  43. package/src/editor.ts +27 -4
  44. package/src/index.ts +58 -1
  45. package/src/plugins/block-drag.ts +912 -0
  46. package/src/plugins/code-language.ts +378 -0
  47. package/src/plugins/frontmatter.ts +324 -0
  48. package/src/plugins/overlay.ts +7 -0
  49. package/src/plugins/wikilink.ts +553 -0
  50. package/src/styles/block-drag.css +63 -0
  51. package/src/styles/editor.css +64 -0
  52. package/src/transformers/code.ts +174 -0
  53. package/src/transformers/gfm.ts +6 -2
  54. package/src/transformers/registry.ts +27 -0
@@ -0,0 +1,378 @@
1
+ // Code-block language plugin — makes a fenced block's **info string** visible
2
+ // and editable, and makes it round-trip verbatim.
3
+ //
4
+ // ## Why this package replaces `@lexical/markdown`'s CODE transformer
5
+ //
6
+ // Upstream's `CODE` captures the info string with `([\w-]+)?`, i.e. a SINGLE
7
+ // word-ish token, pushing the rest of the fence line into the code CONTENT:
8
+ //
9
+ // ```lance table → language 'lance', body 'table\nsum(x)' (corrupt)
10
+ // ```c++ → language 'c', body '++\nint main()' (corrupt)
11
+ //
12
+ // That is silent data loss for any consumer that keys off the info string
13
+ // (downstream, ```lance / ```lance table marks a formula block). The CommonMark
14
+ // -correct replacement lives in `transformers/code.ts` and is the DEFAULT in
15
+ // `GFM_TRANSFORMERS`, so the corruption is fixed for every consumer whether or
16
+ // not they enable this plugin, and plugin ORDER cannot reintroduce it. This
17
+ // plugin therefore adds only the editing chrome.
18
+ //
19
+ // ## Why no highlighter
20
+ //
21
+ // The package deliberately depends on `@lexical/code-core`, not `@lexical/code`,
22
+ // to keep Prism out of the bundle (see `transformers/gfm.ts`). The language is
23
+ // therefore an OPAQUE label: it is stored, shown, edited, and re-emitted, but
24
+ // nothing interprets it. That is precisely what lets an arbitrary token like
25
+ // `lance table` survive. The optional `languages` option only seeds a
26
+ // `<datalist>` of suggestions — it never constrains what may be typed.
27
+ //
28
+ // ## Chrome
29
+ //
30
+ // The editor uses the shared overlay seam (`overlayRoot` + `definePluginUI`),
31
+ // exactly like the table tools: a small portaled badge anchored to the code
32
+ // block's top-right corner whenever the caret is inside one.
33
+
34
+ import { $getNodeByKey, $getSelection, $isRangeSelection, SKIP_DOM_SELECTION_TAG } from 'lexical'
35
+ import { $findMatchingParent, mergeRegister } from '@lexical/utils'
36
+ import { $isCodeNode, CodeHighlightNode, CodeNode } from '@lexical/code-core'
37
+ import { el, input, text, type Mountable } from '@llui/dom'
38
+ import { definePluginUI } from './ui.js'
39
+ import { OVERLAY_Z, hideOverlay, onViewportChange, overlayRoot } from './overlay.js'
40
+ import type { MarkdownPlugin } from './types.js'
41
+
42
+ /** This plugin's registry name (the `plugin` message envelope's `name`). */
43
+ export const CODE_LANGUAGE_PLUGIN = 'codeLanguage'
44
+
45
+ // The fenced-code transformer now lives in `transformers/code.ts` so that it is
46
+ // the DEFAULT in `GFM_TRANSFORMERS`, not an opt-in a consumer must remember to
47
+ // order ahead of `corePlugin()`. This plugin contributes the SAME object
48
+ // reference, so `buildTransformers`' reference de-duplication collapses the two
49
+ // contributions into one and plugin order cannot change the parse.
50
+ // Re-exported here because it is part of this plugin's documented surface.
51
+ import { CODE_INFO_TRANSFORMER, normalizeCodeInfo } from '../transformers/code.js'
52
+
53
+ export { CODE_INFO_TRANSFORMER, normalizeCodeInfo }
54
+
55
+ // ── Plugin UI state ─────────────────────────────────────────────────────────
56
+
57
+ /** The language badge's state. JSON-serializable, like every LLui state slice. */
58
+ export interface CodeLanguageState {
59
+ /** Whether the badge is shown. */
60
+ open: boolean
61
+ /** Viewport x of the anchor (the code block's right edge). */
62
+ x: number
63
+ /** Viewport y of the anchor (the code block's top edge). */
64
+ y: number
65
+ /** Node key of the anchored code block (`''` when none). */
66
+ key: string
67
+ /** The input's current value (the block's info string, or the in-flight edit). */
68
+ language: string
69
+ /** The info string as last read from the node — the baseline `cancel` restores
70
+ * and `commit` diffs against, so a no-op commit never touches the document. */
71
+ committed: string
72
+ /** Whether the input has focus; a refresh must not overwrite what's being typed. */
73
+ editing: boolean
74
+ /** A `hide` that arrived mid-edit, applied when the edit ends. */
75
+ pendingHide: boolean
76
+ }
77
+
78
+ export type CodeLanguageMsg =
79
+ | { type: 'show'; key: string; x: number; y: number; language: string | null }
80
+ | { type: 'hide' }
81
+ | { type: 'edit' }
82
+ | { type: 'input'; language: string }
83
+ | { type: 'commit' }
84
+ | { type: 'cancel' }
85
+
86
+ /** Write `language` (null clears it) onto the code block with node key `key`. */
87
+ export type CodeLanguageEffect = { type: 'apply'; key: string; language: string | null }
88
+
89
+ const INITIAL: CodeLanguageState = {
90
+ open: false,
91
+ x: 0,
92
+ y: 0,
93
+ key: '',
94
+ language: '',
95
+ committed: '',
96
+ editing: false,
97
+ pendingHide: false,
98
+ }
99
+
100
+ function sameState(a: CodeLanguageState, b: CodeLanguageState): boolean {
101
+ return (
102
+ a.open === b.open &&
103
+ a.x === b.x &&
104
+ a.y === b.y &&
105
+ a.key === b.key &&
106
+ a.language === b.language &&
107
+ a.committed === b.committed &&
108
+ a.editing === b.editing &&
109
+ a.pendingHide === b.pendingHide
110
+ )
111
+ }
112
+
113
+ /** End an edit: drop the editing flag and honour a hide deferred during it. */
114
+ function endEdit(state: CodeLanguageState, language: string): CodeLanguageState {
115
+ return {
116
+ ...state,
117
+ language,
118
+ editing: false,
119
+ pendingHide: false,
120
+ open: state.pendingHide ? false : state.open,
121
+ }
122
+ }
123
+
124
+ function reduce(
125
+ state: CodeLanguageState,
126
+ msg: CodeLanguageMsg,
127
+ ): CodeLanguageState | [CodeLanguageState, CodeLanguageEffect[]] {
128
+ switch (msg.type) {
129
+ case 'show': {
130
+ const language = normalizeCodeInfo(msg.language) ?? ''
131
+ // A refresh for the block being edited only re-anchors it: the typed value
132
+ // and the edit itself are preserved (the register listener re-emits `show`
133
+ // on every editor update, including the one this plugin's own commit made).
134
+ const keepEdit = state.editing && state.key === msg.key
135
+ const next: CodeLanguageState = {
136
+ open: true,
137
+ x: msg.x,
138
+ y: msg.y,
139
+ key: msg.key,
140
+ language: keepEdit ? state.language : language,
141
+ committed: language,
142
+ editing: keepEdit,
143
+ // Preserved across a same-block `show` for the same reason `language`
144
+ // and `editing` are. `onViewportChange(refresh)` re-emits `show` on
145
+ // every scroll/resize, so an unconditional reset let a scroll while the
146
+ // input was focused erase a deferred hide — `endEdit` then saw
147
+ // `pendingHide === false` and left the badge anchored over a block the
148
+ // caret had already left.
149
+ pendingHide: keepEdit ? state.pendingHide : false,
150
+ }
151
+ return sameState(state, next) ? state : next
152
+ }
153
+ case 'hide':
154
+ // Focusing the badge's input can collapse the editor selection, which makes
155
+ // the register listener emit `hide`. Closing then would yank the input out
156
+ // from under the caret, so remember it and apply it when the edit ends.
157
+ if (state.editing) return state.pendingHide ? state : { ...state, pendingHide: true }
158
+ return hideOverlay(state)
159
+ case 'edit':
160
+ return state.editing ? state : { ...state, editing: true }
161
+ case 'input':
162
+ return state.language === msg.language && state.editing
163
+ ? state
164
+ : { ...state, language: msg.language, editing: true }
165
+ case 'commit': {
166
+ const next = normalizeCodeInfo(state.language)
167
+ const previous = normalizeCodeInfo(state.committed)
168
+ const ended = endEdit({ ...state, committed: next ?? '' }, next ?? '')
169
+ // An unchanged (or still-empty) value must NOT touch the document: doing so
170
+ // would dirty the editor and re-emit onChange for a no-op.
171
+ if (next === previous) return [endEdit(state, state.committed), []]
172
+ return [ended, [{ type: 'apply', key: state.key, language: next }]]
173
+ }
174
+ case 'cancel':
175
+ return endEdit(state, state.committed)
176
+ }
177
+ }
178
+
179
+ // ── Plugin ──────────────────────────────────────────────────────────────────
180
+
181
+ export interface CodeLanguagePluginOptions {
182
+ /** Suggestions offered in the language input's `<datalist>`. Purely advisory —
183
+ * ANY info string may be typed, including multi-token ones. */
184
+ languages?: readonly string[]
185
+ /** Placeholder shown when a block has no language (default `'plain text'`). */
186
+ placeholder?: string
187
+ /** Accessible label for the language input (default `'Code block language'`). */
188
+ label?: string
189
+ }
190
+
191
+ /** Unique per plugin instance so two mounted editors never share a datalist id. */
192
+ let datalistSeq = 0
193
+
194
+ export function codeLanguagePlugin(opts: CodeLanguagePluginOptions = {}): MarkdownPlugin {
195
+ const languages = opts.languages ?? []
196
+ const listId = languages.length > 0 ? `md-code-lang-${++datalistSeq}` : null
197
+ // Per-instance id so the command item can focus THIS editor's badge input even
198
+ // with several editors mounted at once (the overlay is portaled to <body>).
199
+ const inputId = `md-code-lang-input-${++datalistSeq}`
200
+
201
+ return {
202
+ name: CODE_LANGUAGE_PLUGIN,
203
+
204
+ // The keyboard route to the badge. The overlay is portaled to a body-level
205
+ // sibling that exists only while the caret is in a code block, and Tab
206
+ // inside a Lexical CodeNode is consumed for indentation — so without a
207
+ // command the input was unreachable without a pointer, making the ONLY way
208
+ // to set or clear a code block's language mouse-only.
209
+ items: [
210
+ {
211
+ id: CODE_LANGUAGE_PLUGIN,
212
+ label: 'Set code block language',
213
+ icon: 'codeLanguage',
214
+ group: 'block',
215
+ keywords: ['code', 'language', 'fence', 'syntax', 'info string'],
216
+ isDisabled: () => false,
217
+ run: (editor) => {
218
+ // Only meaningful with the caret inside a code block; the overlay is
219
+ // open in exactly that case, so focusing its input is the whole action.
220
+ const focus = (): void => {
221
+ const el = editor.getRootElement()?.ownerDocument.getElementById(inputId)
222
+ if (el instanceof HTMLInputElement) {
223
+ el.focus()
224
+ el.select()
225
+ }
226
+ }
227
+ // The overlay may not be open yet if the caret only just moved; let the
228
+ // register listener's refresh land first.
229
+ if (typeof queueMicrotask === 'function') queueMicrotask(focus)
230
+ else focus()
231
+ },
232
+ surfaces: ['slash', 'context'],
233
+ },
234
+ ],
235
+ // Declared so the plugin stands alone (a code-only editor without
236
+ // `corePlugin`); the mount de-duplicates node classes across plugins.
237
+ nodes: [CodeNode, CodeHighlightNode],
238
+ transformers: [CODE_INFO_TRANSFORMER],
239
+ // Track the code block under the caret and its viewport rect. Mirrors
240
+ // `tablePlugin`: re-emit only when the anchor or the language actually
241
+ // changes, so typing inside a code block doesn't reconcile the overlay.
242
+ register: (editor, ctx) => {
243
+ let lastKey: string | null = null
244
+ let lastX = NaN
245
+ let lastY = NaN
246
+ let lastLanguage: string | null = null
247
+ const refresh = (): void => {
248
+ const found = editor.getEditorState().read(() => {
249
+ const selection = $getSelection()
250
+ if (!$isRangeSelection(selection)) return null
251
+ const code = $findMatchingParent(selection.anchor.getNode(), $isCodeNode)
252
+ if (!$isCodeNode(code)) return null
253
+ return { key: code.getKey(), language: normalizeCodeInfo(code.getLanguage()) }
254
+ })
255
+ const element = found ? editor.getElementByKey(found.key) : null
256
+ if (!found || !element) {
257
+ if (lastKey !== null) {
258
+ lastKey = null
259
+ lastX = NaN
260
+ lastY = NaN
261
+ lastLanguage = null
262
+ ctx.emit({
263
+ type: 'plugin',
264
+ name: CODE_LANGUAGE_PLUGIN,
265
+ msg: { type: 'hide' },
266
+ })
267
+ }
268
+ return
269
+ }
270
+ const rect = element.getBoundingClientRect()
271
+ if (
272
+ found.key === lastKey &&
273
+ rect.right === lastX &&
274
+ rect.top === lastY &&
275
+ found.language === lastLanguage
276
+ ) {
277
+ return
278
+ }
279
+ lastKey = found.key
280
+ lastX = rect.right
281
+ lastY = rect.top
282
+ lastLanguage = found.language
283
+ ctx.emit({
284
+ type: 'plugin',
285
+ name: CODE_LANGUAGE_PLUGIN,
286
+ msg: {
287
+ type: 'show',
288
+ key: found.key,
289
+ x: rect.right,
290
+ y: rect.top,
291
+ language: found.language,
292
+ },
293
+ })
294
+ }
295
+ return mergeRegister(
296
+ editor.registerUpdateListener(() => refresh()),
297
+ onViewportChange(refresh),
298
+ )
299
+ },
300
+ ui: definePluginUI<CodeLanguageState, CodeLanguageMsg, CodeLanguageEffect>({
301
+ init: () => INITIAL,
302
+ update: reduce,
303
+ onEffect: (effect, ctx) => {
304
+ const editor = ctx.editor()
305
+ if (!editor) return
306
+ // SKIP_DOM_SELECTION_TAG is load-bearing, not decoration. `setLanguage`
307
+ // dirties the CodeNode, so an untagged update reaches
308
+ // `$updateDOMSelection`. Lexical does not clear its selection on blur
309
+ // ('blur' is a PASS_THROUGH_COMMAND), so while the badge input holds DOM
310
+ // focus the pending selection still points INSIDE the code block; the
311
+ // equality short-circuit therefore fails and the reconciler moves the
312
+ // native selection back into the contenteditable — stealing focus out of
313
+ // the input mid-edit. This package already defends against the same
314
+ // hazard in editor.ts (the explicit `$setSelection(null)`).
315
+ editor.update(
316
+ () => {
317
+ const node = $getNodeByKey(effect.key)
318
+ if ($isCodeNode(node)) node.setLanguage(effect.language)
319
+ },
320
+ { tag: SKIP_DOM_SELECTION_TAG },
321
+ )
322
+ },
323
+ view: ({ state, send }) =>
324
+ overlayRoot({
325
+ open: state.at('open'),
326
+ x: state.at('x'),
327
+ y: state.at('y'),
328
+ zIndex: OVERLAY_Z.codeLanguage,
329
+ // Sit just above the block's top-right corner.
330
+ transform: 'transform:translate(-100%,-100%)',
331
+ attrs: { 'data-scope': 'md-code-language', 'data-part': 'bar' },
332
+ children: (): Mountable[] => {
333
+ const children: Mountable[] = [
334
+ input({
335
+ type: 'text',
336
+ 'data-scope': 'md-code-language',
337
+ 'data-part': 'input',
338
+ id: inputId,
339
+ 'aria-label': opts.label ?? 'Code block language',
340
+ placeholder: opts.placeholder ?? 'plain text',
341
+ spellcheck: 'false',
342
+ autocapitalize: 'off',
343
+ autocomplete: 'off',
344
+ ...(listId ? { list: listId } : {}),
345
+ value: state.at('language'),
346
+ onFocus: () => send({ type: 'edit' }),
347
+ onBlur: () => send({ type: 'commit' }),
348
+ onInput: (e: Event) =>
349
+ send({ type: 'input', language: (e.target as HTMLInputElement).value }),
350
+ // The badge lives in a portal outside the contenteditable, but
351
+ // document-level editor handlers would still see these keys.
352
+ onKeyDown: (e: KeyboardEvent) => {
353
+ e.stopPropagation()
354
+ if (e.key === 'Enter') {
355
+ e.preventDefault()
356
+ send({ type: 'commit' })
357
+ } else if (e.key === 'Escape') {
358
+ e.preventDefault()
359
+ send({ type: 'cancel' })
360
+ }
361
+ },
362
+ }),
363
+ ]
364
+ if (listId) {
365
+ children.push(
366
+ el(
367
+ 'datalist',
368
+ { id: listId },
369
+ languages.map((language) => el('option', { value: language }, [text(language)])),
370
+ ),
371
+ )
372
+ }
373
+ return children
374
+ },
375
+ }),
376
+ }),
377
+ }
378
+ }