@llui/markdown-editor 0.2.14 → 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 (70) hide show
  1. package/dist/editor.d.ts +22 -2
  2. package/dist/editor.d.ts.map +1 -1
  3. package/dist/editor.js +45 -6
  4. package/dist/editor.js.map +1 -1
  5. package/dist/effects.d.ts +0 -1
  6. package/dist/effects.d.ts.map +1 -1
  7. package/dist/effects.js +4 -1
  8. package/dist/effects.js.map +1 -1
  9. package/dist/index.d.ts +9 -4
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +18 -4
  12. package/dist/index.js.map +1 -1
  13. package/dist/plugins/block-drag.d.ts +73 -0
  14. package/dist/plugins/block-drag.d.ts.map +1 -0
  15. package/dist/plugins/block-drag.js +760 -0
  16. package/dist/plugins/block-drag.js.map +1 -0
  17. package/dist/plugins/code-language.d.ts +60 -0
  18. package/dist/plugins/code-language.d.ts.map +1 -0
  19. package/dist/plugins/code-language.js +308 -0
  20. package/dist/plugins/code-language.js.map +1 -0
  21. package/dist/plugins/frontmatter.d.ts +45 -0
  22. package/dist/plugins/frontmatter.d.ts.map +1 -0
  23. package/dist/plugins/frontmatter.js +281 -0
  24. package/dist/plugins/frontmatter.js.map +1 -0
  25. package/dist/plugins/overlay.d.ts +1 -0
  26. package/dist/plugins/overlay.d.ts.map +1 -1
  27. package/dist/plugins/overlay.js +7 -0
  28. package/dist/plugins/overlay.js.map +1 -1
  29. package/dist/plugins/table.d.ts.map +1 -1
  30. package/dist/plugins/table.js +77 -15
  31. package/dist/plugins/table.js.map +1 -1
  32. package/dist/plugins/wikilink.d.ts +91 -0
  33. package/dist/plugins/wikilink.d.ts.map +1 -0
  34. package/dist/plugins/wikilink.js +467 -0
  35. package/dist/plugins/wikilink.js.map +1 -0
  36. package/dist/styles/block-drag.css +63 -0
  37. package/dist/styles/editor.css +64 -6
  38. package/dist/theme.d.ts +2 -5
  39. package/dist/theme.d.ts.map +1 -1
  40. package/dist/theme.js +15 -11
  41. package/dist/theme.js.map +1 -1
  42. package/dist/transformers/code.d.ts +24 -0
  43. package/dist/transformers/code.d.ts.map +1 -0
  44. package/dist/transformers/code.js +167 -0
  45. package/dist/transformers/code.js.map +1 -0
  46. package/dist/transformers/gfm.d.ts +9 -1
  47. package/dist/transformers/gfm.d.ts.map +1 -1
  48. package/dist/transformers/gfm.js +16 -4
  49. package/dist/transformers/gfm.js.map +1 -1
  50. package/dist/transformers/registry.d.ts +3 -0
  51. package/dist/transformers/registry.d.ts.map +1 -1
  52. package/dist/transformers/registry.js +26 -0
  53. package/dist/transformers/registry.js.map +1 -1
  54. package/package.json +45 -31
  55. package/src/editor.ts +77 -9
  56. package/src/effects.ts +4 -2
  57. package/src/index.ts +61 -9
  58. package/src/plugins/block-drag.ts +912 -0
  59. package/src/plugins/code-language.ts +378 -0
  60. package/src/plugins/frontmatter.ts +324 -0
  61. package/src/plugins/overlay.ts +7 -0
  62. package/src/plugins/table.ts +87 -13
  63. package/src/plugins/wikilink.ts +553 -0
  64. package/src/styles/block-drag.css +63 -0
  65. package/src/styles/editor.css +64 -6
  66. package/src/theme.ts +15 -11
  67. package/src/transformers/code.ts +174 -0
  68. package/src/transformers/gfm.ts +16 -4
  69. package/src/transformers/registry.ts +27 -0
  70. 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,
@@ -76,10 +83,23 @@ export interface EditorConfig {
76
83
  }
77
84
 
78
85
  /** Disposer-returning binding the collab layer installs on the live editor.
79
- * `@llui/lexical-collab`'s `YjsCollab` satisfies this structurally, so
80
- * `@llui/markdown-editor` needs no Yjs dependency of its own. */
86
+ * `@llui/lexical-collab`'s `YjsCollab` and `@llui/lexical-loro`'s `LoroCollab`
87
+ * both satisfy this structurally, so `@llui/markdown-editor` needs neither a Yjs
88
+ * nor a Loro dependency of its own. */
81
89
  export interface CollabBinding {
82
90
  register: (editor: LexicalEditor) => () => void
91
+ /**
92
+ * A CRDT-aware undo owner, if the binding provides one SEPARATELY from
93
+ * `register`. When present it is handed to `lexicalForeign({ externalUndo })`,
94
+ * which forces the built-in `@lexical/history` stack off so the two can never
95
+ * both be live — this is what gives collab mode real, peer-scoped undo.
96
+ *
97
+ * Optional because not every binding splits undo out this way: `yjsCollab`
98
+ * installs its own undo commands INSIDE `register`, so it leaves this unset and
99
+ * still owns undo. A binding that sets neither would leave the editor with no
100
+ * undo at all — see `@llui/lexical-loro`, which sets this.
101
+ */
102
+ externalUndo?: (editor: LexicalEditor) => () => void
83
103
  }
84
104
 
85
105
  /** Hooks the editor injects into the {@link CollabFactory}: a markdown `seed`
@@ -109,13 +129,31 @@ function defaultPlugins(): MarkdownPlugin[] {
109
129
  return [corePlugin(), linkPlugin()]
110
130
  }
111
131
 
132
+ /** Swallow the underline text-format command. `registerRichText` wires Cmd+U to
133
+ * FORMAT_TEXT 'underline', but the GFM markdown dialect this editor serializes
134
+ * has no underline representation, so an applied underline would be silently
135
+ * stripped on save. Intercepting at CRITICAL priority (ahead of rich-text) keeps
136
+ * the WYSIWYG surface and the serialized dialect in lock-step: underline can be
137
+ * neither applied nor lost. Returns a disposer. */
138
+ export function blockUnderlineFormat(editor: LexicalEditor): () => void {
139
+ return editor.registerCommand(
140
+ FORMAT_TEXT_COMMAND,
141
+ (payload) => payload === 'underline',
142
+ COMMAND_PRIORITY_CRITICAL,
143
+ )
144
+ }
145
+
112
146
  /** The per-mount `send` closure — the WeakMap key that ties a live editor to the
113
147
  * one mount it belongs to. */
114
148
  type EditorSend = (msg: EditorMsg) => void
115
149
 
116
- /** Per-mount live state: the Lexical editor captured by that mount's `onReady`. */
150
+ /** Per-mount live state: the Lexical editor captured by that mount's `onReady`,
151
+ * plus the last markdown delivered to the consumer's `onChange` (dedup key). */
117
152
  interface MountContext {
118
153
  editor: LexicalEditor | null
154
+ /** Last value handed to `config.onChange` for this mount — guards against
155
+ * double / redundant consumer notifications. `undefined` = nothing emitted yet. */
156
+ lastChange: string | undefined
119
157
  }
120
158
 
121
159
  /**
@@ -148,14 +186,17 @@ export function markdownEditor(
148
186
  const contextFor = (send: EditorSend): MountContext => {
149
187
  let ctx = mountContexts.get(send)
150
188
  if (!ctx) {
151
- ctx = { editor: null }
189
+ ctx = { editor: null, lastChange: undefined }
152
190
  mountContexts.set(send, ctx)
153
191
  }
154
192
  return ctx
155
193
  }
156
194
 
157
195
  const baseOnEffect = makeOnEffect((api) => contextFor(api.send).editor, itemsById, {
158
- onChange: config.onChange,
196
+ // NB: consumer `onChange` is delivered DIRECTLY from the foreign onChange
197
+ // wrapper (see `view` below), NOT through this effect path — the dispose-time
198
+ // debounce flush runs after the TEA loop is disposed, and a `send` from a
199
+ // disposed loop is dropped, so effect-routed delivery loses the final edit.
159
200
  onFormatChange: config.onFormatChange,
160
201
  applyValue: (editor, value) =>
161
202
  editor.update(
@@ -263,8 +304,18 @@ export function markdownEditor(
263
304
  },
264
305
  // In collab mode the shared CRDT owns the document: the local undo stack
265
306
  // and the boot-time seed are disabled — the binding supplies a scoped undo
266
- // manager and a sync-gated bootstrap instead.
267
- ...(collabBinding ? { history: false, seedMode: 'deferred' as const } : {}),
307
+ // manager and a sync-gated bootstrap instead. A binding that owns undo via
308
+ // a SEPARATE `externalUndo` (Loro) has it wired here; one that owns undo
309
+ // inside its own `register` (Yjs) leaves the field unset. Either way the
310
+ // built-in history is off, so a binding that forgot both would show as "no
311
+ // undo" rather than fighting a local stack.
312
+ ...(collabBinding
313
+ ? {
314
+ history: false as const,
315
+ seedMode: 'deferred' as const,
316
+ ...(collabBinding.externalUndo ? { externalUndo: collabBinding.externalUndo } : {}),
317
+ }
318
+ : {}),
268
319
  defaultValue: collabBinding || config.value ? undefined : (config.defaultValue ?? ''),
269
320
  ...(config.value && !collabBinding ? { value: config.value } : {}),
270
321
  readonly: state.at('readonly'),
@@ -273,6 +324,10 @@ export function markdownEditor(
273
324
  : {}),
274
325
  register: (editor) => {
275
326
  const disposers = [
327
+ // Keep the WYSIWYG surface and the serialized GFM dialect in lock-step:
328
+ // underline (Cmd+U) has no markdown representation, so it is swallowed
329
+ // rather than applied-then-silently-stripped on save.
330
+ blockUnderlineFormat(editor),
276
331
  registerMarkdownShortcuts(editor, transformers),
277
332
  // Global scheme allowlist for links: the single backstop covering the
278
333
  // link dialog, pasted/imported markdown, and the typed `[x](url)`
@@ -297,7 +352,20 @@ export function markdownEditor(
297
352
  }
298
353
  config.onReady?.(editor)
299
354
  },
300
- onChange: (value) => send({ type: 'markdownChanged', value }),
355
+ onChange: (value) => {
356
+ // Deliver to the consumer DIRECTLY, independent of the TEA loop: the
357
+ // dispose-time debounce flush runs AFTER the component is disposed, and a
358
+ // `send` from a disposed loop is dropped — so routing consumer delivery
359
+ // through `send` would lose the last debounce window of typing on unmount.
360
+ // Deduped per mount so the same value isn't delivered twice.
361
+ if (value !== mount.lastChange) {
362
+ mount.lastChange = value
363
+ config.onChange?.(value)
364
+ }
365
+ // Mirror into TEA state (value/dirty) while the loop is alive; dropped
366
+ // silently after dispose, which is fine — the consumer was already told.
367
+ send({ type: 'markdownChanged', value })
368
+ },
301
369
  onSelectionChange: (ctx) => {
302
370
  const format = computeFormatState(ctx.editor, ctx)
303
371
  const text = ctx.editor.getEditorState().read(() => $getRoot().getTextContent())
package/src/effects.ts CHANGED
@@ -12,7 +12,6 @@ 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
@@ -41,7 +40,10 @@ export function makeOnEffect(
41
40
  return
42
41
  }
43
42
  case 'emitChange': {
44
- 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.
45
47
  return
46
48
  }
47
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 {
@@ -56,6 +57,17 @@ export {
56
57
  $insertCallout,
57
58
  } from './plugins/callout.js'
58
59
  export { hrPlugin, $insertHorizontalRule } from './plugins/hr.js'
60
+ export {
61
+ type FrontmatterData,
62
+ type FrontmatterPluginOptions,
63
+ FRONTMATTER_BRIDGE_TYPE,
64
+ FRONTMATTER_TRANSFORMER,
65
+ frontmatterPlugin,
66
+ serializeFrontmatter,
67
+ splitFrontmatter,
68
+ $getFrontmatter,
69
+ $setFrontmatter,
70
+ } from './plugins/frontmatter.js'
59
71
  export { slashPlugin } from './plugins/slash.js'
60
72
  export { contextMenuPlugin } from './plugins/context-menu.js'
61
73
  export { floatingToolbarPlugin } from './plugins/floating-toolbar.js'
@@ -65,21 +77,61 @@ export { type Mention, type MentionPluginOptions, mentionPlugin } from './plugin
65
77
  export { type EmojiPluginOptions, DEFAULT_EMOJI, emojiPlugin } from './plugins/emoji.js'
66
78
  export { type ImagePluginOptions, imagePlugin } from './plugins/image.js'
67
79
  export { tablePlugin } from './plugins/table.js'
80
+ export {
81
+ type CodeLanguagePluginOptions,
82
+ type CodeLanguageState,
83
+ type CodeLanguageMsg,
84
+ type CodeLanguageEffect,
85
+ CODE_LANGUAGE_PLUGIN,
86
+ codeLanguagePlugin,
87
+ } from './plugins/code-language.js'
88
+ export {
89
+ type WikiLink,
90
+ type WikiLinkPluginOptions,
91
+ type SerializedWikiLinkNode,
92
+ WikiLinkNode,
93
+ $createWikiLinkNode,
94
+ $isWikiLinkNode,
95
+ parseWikiLinkInner,
96
+ formatWikiLink,
97
+ // The representability guards that make `formatWikiLink` the true inverse of
98
+ // `parseWikiLinkInner`; exported so a host resolving/creating targets can
99
+ // apply the same normalization before it hands one to `$createWikiLinkNode`.
100
+ sanitizeWikiLinkTarget,
101
+ sanitizeWikiLinkAlias,
102
+ wikilinkPlugin,
103
+ } from './plugins/wikilink.js'
104
+ export {
105
+ type BlockDragOptions,
106
+ type BlockRect,
107
+ type DropTarget,
108
+ type IndicatorRect,
109
+ type Place,
110
+ BLOCK_DRAG_Z,
111
+ blockAtPoint,
112
+ findDropTarget,
113
+ indicatorRect,
114
+ blockDragPlugin,
115
+ } from './plugins/block-drag.js'
68
116
 
69
117
  export { $insertMarkdownAtSelection, registerMarkdownPaste } from './paste.js'
70
118
 
71
- export { GFM_NODES, GFM_TRANSFORMERS } from './transformers/gfm.js'
72
- export { buildTransformers, orderTransformers } from './transformers/registry.js'
119
+ export { GFM_NODES, GFM_TRANSFORMERS, HIGHLIGHT_TRANSFORMER } from './transformers/gfm.js'
120
+ // The CommonMark-correct fenced-code transformer. Already part of
121
+ // `GFM_TRANSFORMERS`; exported for consumers assembling a transformer set by hand.
122
+ export { CODE_INFO_TRANSFORMER, normalizeCodeInfo } from './transformers/code.js'
123
+ // `setTransformerPrecedence` breaks ties between SAME-rank transformers, so a
124
+ // collision (e.g. wikilink vs upstream LINK, which both match at the same index)
125
+ // is resolved structurally instead of by the order a consumer lists plugins in.
126
+ export {
127
+ buildTransformers,
128
+ orderTransformers,
129
+ setTransformerPrecedence,
130
+ } from './transformers/registry.js'
73
131
 
74
132
  export { computeFormatState } from './format.js'
75
133
 
76
- export {
77
- STRIKETHROUGH_CLASS,
78
- UNDERLINE_CLASS,
79
- UNDERLINE_STRIKETHROUGH_CLASS,
80
- defaultTheme,
81
- mergeTheme,
82
- } from './theme.js'
134
+ export { STRIKETHROUGH_CLASS, defaultTheme, mergeTheme } from './theme.js'
83
135
 
84
136
  export {
85
137
  type ToolbarItemParts,