@llui/markdown-editor 0.2.14 → 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llui/markdown-editor",
3
- "version": "0.2.14",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -33,10 +33,10 @@
33
33
  "@lexical/selection": "^0.46.0",
34
34
  "@lexical/utils": "^0.46.0",
35
35
  "@lexical/code-core": "^0.46.0",
36
- "@llui/dom": "^0.11.8",
37
- "@llui/lexical": "^0.2.10",
38
- "@llui/markdown": "^0.11.3",
39
- "@llui/components": "^0.12.4"
36
+ "@llui/dom": "^0.12.0",
37
+ "@llui/markdown": "^0.12.0",
38
+ "@llui/lexical": "^0.3.0",
39
+ "@llui/components": "^0.13.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "lexical": "^0.46.0",
@@ -51,10 +51,10 @@
51
51
  "typescript": "^6.0.0",
52
52
  "vitest": "^4.1.2",
53
53
  "@lexical/code-core": "^0.46.0",
54
- "@llui/dom": "0.11.8",
55
- "@llui/markdown": "0.11.3",
56
- "@llui/components": "0.12.4",
57
- "@llui/lexical": "0.2.10"
54
+ "@llui/dom": "0.12.0",
55
+ "@llui/markdown": "0.12.0",
56
+ "@llui/components": "0.13.0",
57
+ "@llui/lexical": "0.3.0"
58
58
  },
59
59
  "sideEffects": [
60
60
  "./dist/styles/editor.css"
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,
@@ -109,13 +116,31 @@ function defaultPlugins(): MarkdownPlugin[] {
109
116
  return [corePlugin(), linkPlugin()]
110
117
  }
111
118
 
119
+ /** Swallow the underline text-format command. `registerRichText` wires Cmd+U to
120
+ * FORMAT_TEXT 'underline', but the GFM markdown dialect this editor serializes
121
+ * has no underline representation, so an applied underline would be silently
122
+ * stripped on save. Intercepting at CRITICAL priority (ahead of rich-text) keeps
123
+ * the WYSIWYG surface and the serialized dialect in lock-step: underline can be
124
+ * neither applied nor lost. Returns a disposer. */
125
+ export function blockUnderlineFormat(editor: LexicalEditor): () => void {
126
+ return editor.registerCommand(
127
+ FORMAT_TEXT_COMMAND,
128
+ (payload) => payload === 'underline',
129
+ COMMAND_PRIORITY_CRITICAL,
130
+ )
131
+ }
132
+
112
133
  /** The per-mount `send` closure — the WeakMap key that ties a live editor to the
113
134
  * one mount it belongs to. */
114
135
  type EditorSend = (msg: EditorMsg) => void
115
136
 
116
- /** Per-mount live state: the Lexical editor captured by that mount's `onReady`. */
137
+ /** Per-mount live state: the Lexical editor captured by that mount's `onReady`,
138
+ * plus the last markdown delivered to the consumer's `onChange` (dedup key). */
117
139
  interface MountContext {
118
140
  editor: LexicalEditor | null
141
+ /** Last value handed to `config.onChange` for this mount — guards against
142
+ * double / redundant consumer notifications. `undefined` = nothing emitted yet. */
143
+ lastChange: string | undefined
119
144
  }
120
145
 
121
146
  /**
@@ -148,14 +173,17 @@ export function markdownEditor(
148
173
  const contextFor = (send: EditorSend): MountContext => {
149
174
  let ctx = mountContexts.get(send)
150
175
  if (!ctx) {
151
- ctx = { editor: null }
176
+ ctx = { editor: null, lastChange: undefined }
152
177
  mountContexts.set(send, ctx)
153
178
  }
154
179
  return ctx
155
180
  }
156
181
 
157
182
  const baseOnEffect = makeOnEffect((api) => contextFor(api.send).editor, itemsById, {
158
- onChange: config.onChange,
183
+ // NB: consumer `onChange` is delivered DIRECTLY from the foreign onChange
184
+ // wrapper (see `view` below), NOT through this effect path — the dispose-time
185
+ // debounce flush runs after the TEA loop is disposed, and a `send` from a
186
+ // disposed loop is dropped, so effect-routed delivery loses the final edit.
159
187
  onFormatChange: config.onFormatChange,
160
188
  applyValue: (editor, value) =>
161
189
  editor.update(
@@ -273,6 +301,10 @@ export function markdownEditor(
273
301
  : {}),
274
302
  register: (editor) => {
275
303
  const disposers = [
304
+ // Keep the WYSIWYG surface and the serialized GFM dialect in lock-step:
305
+ // underline (Cmd+U) has no markdown representation, so it is swallowed
306
+ // rather than applied-then-silently-stripped on save.
307
+ blockUnderlineFormat(editor),
276
308
  registerMarkdownShortcuts(editor, transformers),
277
309
  // Global scheme allowlist for links: the single backstop covering the
278
310
  // link dialog, pasted/imported markdown, and the typed `[x](url)`
@@ -297,7 +329,20 @@ export function markdownEditor(
297
329
  }
298
330
  config.onReady?.(editor)
299
331
  },
300
- onChange: (value) => send({ type: 'markdownChanged', value }),
332
+ onChange: (value) => {
333
+ // Deliver to the consumer DIRECTLY, independent of the TEA loop: the
334
+ // dispose-time debounce flush runs AFTER the component is disposed, and a
335
+ // `send` from a disposed loop is dropped — so routing consumer delivery
336
+ // through `send` would lose the last debounce window of typing on unmount.
337
+ // Deduped per mount so the same value isn't delivered twice.
338
+ if (value !== mount.lastChange) {
339
+ mount.lastChange = value
340
+ config.onChange?.(value)
341
+ }
342
+ // Mirror into TEA state (value/dirty) while the loop is alive; dropped
343
+ // silently after dispose, which is fine — the consumer was already told.
344
+ send({ type: 'markdownChanged', value })
345
+ },
301
346
  onSelectionChange: (ctx) => {
302
347
  const format = computeFormatState(ctx.editor, ctx)
303
348
  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 {
@@ -68,18 +69,12 @@ export { tablePlugin } from './plugins/table.js'
68
69
 
69
70
  export { $insertMarkdownAtSelection, registerMarkdownPaste } from './paste.js'
70
71
 
71
- export { GFM_NODES, GFM_TRANSFORMERS } from './transformers/gfm.js'
72
+ export { GFM_NODES, GFM_TRANSFORMERS, HIGHLIGHT_TRANSFORMER } from './transformers/gfm.js'
72
73
  export { buildTransformers, orderTransformers } from './transformers/registry.js'
73
74
 
74
75
  export { computeFormatState } from './format.js'
75
76
 
76
- export {
77
- STRIKETHROUGH_CLASS,
78
- UNDERLINE_CLASS,
79
- UNDERLINE_STRIKETHROUGH_CLASS,
80
- defaultTheme,
81
- mergeTheme,
82
- } from './theme.js'
77
+ export { STRIKETHROUGH_CLASS, defaultTheme, mergeTheme } from './theme.js'
83
78
 
84
79
  export {
85
80
  type ToolbarItemParts,
@@ -7,9 +7,10 @@
7
7
 
8
8
  import {
9
9
  $createParagraphNode,
10
- $createTextNode,
11
10
  $getSelection,
12
11
  $isRangeSelection,
12
+ type ElementFormatType,
13
+ type ElementNode,
13
14
  type LexicalNode,
14
15
  } from 'lexical'
15
16
  import { $insertNodeToNearestRoot, mergeRegister } from '@lexical/utils'
@@ -30,12 +31,26 @@ import {
30
31
  TableNode,
31
32
  TableRowNode,
32
33
  } from '@lexical/table'
33
- import type { MultilineElementTransformer } from '@lexical/markdown'
34
+ import {
35
+ $convertFromMarkdownString,
36
+ LINK,
37
+ type MultilineElementTransformer,
38
+ type Transformer,
39
+ } from '@lexical/markdown'
34
40
  import { button, text } from '@llui/dom'
35
41
  import { definePluginUI } from './ui.js'
36
42
  import { OVERLAY_Z, hideOverlay, onViewportChange, overlayRoot } from './overlay.js'
43
+ import { INLINE_TEXT_TRANSFORMERS } from '../transformers/gfm.js'
37
44
  import type { MarkdownPlugin } from './types.js'
38
45
 
46
+ /** Transformers used to parse/serialize a single table cell's INLINE content
47
+ * (bold/italic/strikethrough/inline-code and links). No block/element or
48
+ * multiline transformers, so a cell's markdown is always a single inline run —
49
+ * this is what makes bold/link cells survive an export∘import round-trip instead
50
+ * of being flattened to bare text. `LINK` needs `LinkNode`, which the core plugin
51
+ * registers (tables are used alongside it). */
52
+ const CELL_TRANSFORMERS: readonly Transformer[] = [...INLINE_TEXT_TRANSFORMERS, LINK]
53
+
39
54
  interface TableToolsState {
40
55
  open: boolean
41
56
  x: number
@@ -107,26 +122,71 @@ function isSeparator(line: string | undefined): boolean {
107
122
  return !!line && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(line) && line.includes('-')
108
123
  }
109
124
 
125
+ /** Is `line` a GFM table row? GFM makes the leading and trailing pipes OPTIONAL
126
+ * (`a | b` is as valid as `| a | b |`); the only requirement is at least one
127
+ * unescaped cell delimiter. The old `\|.*\|` test rejected valid pipe-less-edge
128
+ * rows, truncating imported tables. */
110
129
  function isRow(line: string | undefined): boolean {
111
- return !!line && /^\s*\|.*\|\s*$/.test(line.trim())
130
+ if (line === undefined) return false
131
+ const trimmed = line.trim()
132
+ return trimmed !== '' && /(?<!\\)\|/.test(trimmed)
112
133
  }
113
134
 
114
- function makeCell(textValue: string, header: boolean): TableCellNode {
135
+ /** Map a GFM separator cell (`:---`, `:---:`, `---:`, `---`) to a cell alignment. */
136
+ function parseAlignment(separatorCell: string): ElementFormatType {
137
+ const t = separatorCell.trim()
138
+ const left = t.startsWith(':')
139
+ const right = t.endsWith(':')
140
+ if (left && right) return 'center'
141
+ if (right) return 'right'
142
+ if (left) return 'left'
143
+ return ''
144
+ }
145
+
146
+ /** Map a cell's alignment back to its GFM separator token. */
147
+ function alignmentSeparator(format: ElementFormatType): string {
148
+ switch (format) {
149
+ case 'center':
150
+ return ':---:'
151
+ case 'right':
152
+ case 'end':
153
+ return '---:'
154
+ case 'left':
155
+ case 'start':
156
+ return ':---'
157
+ default:
158
+ return '---'
159
+ }
160
+ }
161
+
162
+ /** Build a cell from its inline markdown, parsing bold/italic/link/etc. through
163
+ * {@link CELL_TRANSFORMERS} (not a bare text node) and applying its column's
164
+ * alignment. */
165
+ function makeCell(cellMarkdown: string, header: boolean, align: ElementFormatType): TableCellNode {
115
166
  const cell = $createTableCellNode(
116
167
  header ? TableCellHeaderStates.COLUMN : TableCellHeaderStates.NO_STATUS,
117
168
  )
118
- cell.append($createParagraphNode().append($createTextNode(textValue)))
169
+ // Parse the cell's inline markdown into a paragraph of formatted text nodes.
170
+ $convertFromMarkdownString(cellMarkdown, [...CELL_TRANSFORMERS], cell)
171
+ // A blank cell yields no paragraph — keep the cell a well-formed single-paragraph.
172
+ if (cell.getChildrenSize() === 0) cell.append($createParagraphNode())
173
+ if (align !== '') cell.setFormat(align)
119
174
  return cell
120
175
  }
121
176
 
122
- function buildTable(header: string[], body: string[][]): TableNode {
177
+ function buildTable(
178
+ header: string[],
179
+ body: string[][],
180
+ aligns: readonly ElementFormatType[],
181
+ ): TableNode {
123
182
  const table = $createTableNode()
124
183
  const headerRow = $createTableRowNode()
125
- for (const h of header) headerRow.append(makeCell(h, true))
184
+ header.forEach((h, i) => headerRow.append(makeCell(h, true, aligns[i] ?? '')))
126
185
  table.append(headerRow)
127
186
  for (const row of body) {
128
187
  const tr = $createTableRowNode()
129
- for (let i = 0; i < header.length; i++) tr.append(makeCell(row[i] ?? '', false))
188
+ for (let i = 0; i < header.length; i++)
189
+ tr.append(makeCell(row[i] ?? '', false, aligns[i] ?? ''))
130
190
  table.append(tr)
131
191
  }
132
192
  return table
@@ -134,29 +194,42 @@ function buildTable(header: string[], body: string[][]): TableNode {
134
194
 
135
195
  const TABLE_TRANSFORMER: MultilineElementTransformer = {
136
196
  dependencies: [TableNode, TableRowNode, TableCellNode],
137
- export: (node: LexicalNode): string | null => {
197
+ export: (node: LexicalNode, traverseChildren: (node: ElementNode) => string): string | null => {
138
198
  if (!$isTableNode(node)) return null
139
199
  const rows = node.getChildren() as TableRowNode[]
140
200
  if (rows.length === 0) return null
141
201
  const lines: string[] = []
142
202
  rows.forEach((row, ri) => {
143
203
  const cells = row.getChildren() as TableCellNode[]
144
- const texts = cells.map((c) => c.getTextContent().replace(/\|/g, '\\|').replace(/\n/g, ' '))
204
+ // Serialize each cell's INLINE content through the full transformer set
205
+ // (so bold/italic/links survive), then escape `|` and flatten newlines so
206
+ // the cell can't break the row.
207
+ const texts = cells.map((c) =>
208
+ traverseChildren(c).replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim(),
209
+ )
145
210
  lines.push('| ' + texts.join(' | ') + ' |')
146
- if (ri === 0) lines.push('| ' + texts.map(() => '---').join(' | ') + ' |')
211
+ if (ri === 0) {
212
+ // Reconstruct the alignment row from the header cells' alignments.
213
+ const seps = cells.map((c) => alignmentSeparator(c.getFormatType()))
214
+ lines.push('| ' + seps.join(' | ') + ' |')
215
+ }
147
216
  })
148
217
  return lines.join('\n')
149
218
  },
150
- regExpStart: /^\s*\|(.+)\|\s*$/,
219
+ // Leading/trailing pipes are optional in GFM; require only one internal pipe.
220
+ // A false positive is harmless — the header is only accepted when the NEXT line
221
+ // is a delimiter row (checked below).
222
+ regExpStart: /^\s*\|?.*(?<!\\)\|.*\|?\s*$/,
151
223
  handleImportAfterStartMatch: ({ lines, rootNode, startLineIndex }) => {
152
224
  if (!isSeparator(lines[startLineIndex + 1])) return null // header must be followed by a separator
225
+ const aligns = splitRow(lines[startLineIndex + 1]!).map(parseAlignment)
153
226
  let end = startLineIndex + 2
154
227
  const body: string[][] = []
155
228
  while (end < lines.length && isRow(lines[end]) && !isSeparator(lines[end])) {
156
229
  body.push(splitRow(lines[end]!))
157
230
  end++
158
231
  }
159
- rootNode.append(buildTable(splitRow(lines[startLineIndex]!), body))
232
+ rootNode.append(buildTable(splitRow(lines[startLineIndex]!), body, aligns))
160
233
  return [true, end - 1]
161
234
  },
162
235
  // Import is handled manually above; a multiline transformer still needs replace.
@@ -274,6 +347,7 @@ export function tablePlugin(): MarkdownPlugin {
274
347
  ['', ''],
275
348
  ['', ''],
276
349
  ],
350
+ ['', ''],
277
351
  ),
278
352
  ),
279
353
  ),
@@ -83,12 +83,6 @@
83
83
  [data-lexical-editor] .md-strikethrough {
84
84
  text-decoration: line-through;
85
85
  }
86
- [data-lexical-editor] .md-underline {
87
- text-decoration: underline;
88
- }
89
- [data-lexical-editor] .md-underline-strikethrough {
90
- text-decoration: underline line-through;
91
- }
92
86
 
93
87
  /* Task-list items (li[role="checkbox"]) with a clickable checkbox. */
94
88
  [data-lexical-editor] li[role='checkbox'] {
package/src/theme.ts CHANGED
@@ -2,10 +2,19 @@
2
2
  //
3
3
  // Lexical styles most inline formats through semantic tags that carry
4
4
  // browser-default styling — bold→<strong>, italic→<em>, code→<code>. But
5
- // `strikethrough` (and `underline`) render as a bare <span> whose ONLY styling
6
- // hook is a `theme.text.<format>` class. With no theme the format is applied to
7
- // the model but is visually invisible. This default theme supplies those class
8
- // names; the bundled `styles/editor.css` gives them their `text-decoration`.
5
+ // `strikethrough` renders as a bare <span> whose ONLY styling hook is a
6
+ // `theme.text.strikethrough` class. With no theme the format is applied to the
7
+ // model but is visually invisible. This default theme supplies that class name;
8
+ // the bundled `styles/editor.css` gives it its `text-decoration`.
9
+ //
10
+ // `underline` is deliberately NOT themed here: the GFM markdown dialect this
11
+ // editor serializes has no underline representation (Lexical's text-format
12
+ // transformers require a SYMMETRIC delimiter, and there is no standard symmetric
13
+ // underline syntax), so an applied underline would be silently stripped on save.
14
+ // To keep the WYSIWYG surface and the serialized dialect in lock-step, the
15
+ // underline command is also intercepted at the editor seam (see editor.ts) — so
16
+ // underline can be neither applied nor lost. Add it back (theme + transformer +
17
+ // command) only alongside a dialect that can round-trip it.
9
18
  //
10
19
  // Consumers can override any entry via `markdownEditor({ theme })` — the user's
11
20
  // theme is merged over this default (see `mergeTheme`).
@@ -13,22 +22,17 @@
13
22
  import type { EditorThemeClasses } from 'lexical'
14
23
 
15
24
  export const STRIKETHROUGH_CLASS = 'md-strikethrough'
16
- export const UNDERLINE_CLASS = 'md-underline'
17
- export const UNDERLINE_STRIKETHROUGH_CLASS = 'md-underline-strikethrough'
18
25
 
19
26
  /** The class hooks Lexical needs for text-decoration formats it renders as a
20
- * plain <span>. (`underlineStrikethrough` is Lexical's special composite key for
21
- * the case where both apply — both want `text-decoration`.) */
27
+ * plain <span>. */
22
28
  export const defaultTheme: EditorThemeClasses = {
23
29
  text: {
24
30
  strikethrough: STRIKETHROUGH_CLASS,
25
- underline: UNDERLINE_CLASS,
26
- underlineStrikethrough: UNDERLINE_STRIKETHROUGH_CLASS,
27
31
  },
28
32
  }
29
33
 
30
34
  /** Merge a consumer theme over the default. `text` is merged per-key so a
31
- * consumer overriding (say) `strikethrough` keeps the default `underline`.
35
+ * consumer overriding (say) `strikethrough` keeps the other default entries.
32
36
  *
33
37
  * Always returns a FRESH theme (never the shared `defaultTheme` singleton):
34
38
  * Lexical caches resolved class arrays by MUTATING the `text` object it is
@@ -39,9 +39,18 @@ export const GFM_NODES: ReadonlyArray<Klass<LexicalNode>> = [
39
39
  LinkNode,
40
40
  ]
41
41
 
42
+ /** The `==highlight==` transformer. NOT part of the default GFM set: `==..==` is
43
+ * not GFM, so exporting it produces non-standard markdown other renderers won't
44
+ * understand. Offered as an opt-in a consumer can add to a plugin's transformers. */
45
+ export const HIGHLIGHT_TRANSFORMER: Transformer = HIGHLIGHT
46
+
42
47
  /** Inline text-format transformers (no block nodes, no node registration). These
43
48
  * are the only transformers a single-block / inline-only editor needs; `LINK` is
44
- * kept separate since it requires `LinkNode` to be registered. */
49
+ * kept separate since it requires `LinkNode` to be registered.
50
+ *
51
+ * `HIGHLIGHT` is deliberately excluded: it round-trips as the non-GFM `==..==`
52
+ * syntax, so it would silently emit markdown outside the editor's stated dialect.
53
+ * Opt in with {@link HIGHLIGHT_TRANSFORMER}. */
45
54
  export const INLINE_TEXT_TRANSFORMERS: readonly Transformer[] = [
46
55
  BOLD_ITALIC_STAR,
47
56
  BOLD_ITALIC_UNDERSCORE,
@@ -51,7 +60,6 @@ export const INLINE_TEXT_TRANSFORMERS: readonly Transformer[] = [
51
60
  ITALIC_UNDERSCORE,
52
61
  STRIKETHROUGH,
53
62
  INLINE_CODE,
54
- HIGHLIGHT,
55
63
  ]
56
64
 
57
65
  /** Markdown ↔ node transformers for the GFM superset. */