@byline/richtext-lexical 3.3.1 → 3.4.1

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,56 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import type * as React from 'react'
12
+ import { createContext, useContext, useMemo, useRef, useState } from 'react'
13
+
14
+ interface MarkdownModeContextValue {
15
+ /** React state — drives toolbar button active styling and re-render. */
16
+ isMarkdown: boolean
17
+ setIsMarkdown: (value: boolean) => void
18
+ /**
19
+ * Synchronous mirror of {@link isMarkdown} for non-React readers — the
20
+ * editor's `OnChangePlugin` guard consults this to decide whether to
21
+ * suppress form persistence. React state updates are async, so the
22
+ * toggle handler writes the ref eagerly (before mutating the editor) to
23
+ * guarantee the markdown-source snapshot is never emitted to the form.
24
+ */
25
+ markdownModeRef: React.MutableRefObject<boolean>
26
+ }
27
+
28
+ const MarkdownModeContext = createContext<MarkdownModeContextValue | null>(null)
29
+
30
+ /**
31
+ * Holds the per-editor "view as markdown source" mode. Sits OUTSIDE the
32
+ * Lexical composer (alongside the shared on-change / history contexts) so
33
+ * both the in-composer editor surface and the toolbar button read the same
34
+ * mode — and so node decorators rendered as composer siblings can see it.
35
+ */
36
+ export function MarkdownModeProvider({
37
+ children,
38
+ }: {
39
+ children: React.ReactNode
40
+ }): React.JSX.Element {
41
+ const [isMarkdown, setIsMarkdown] = useState(false)
42
+ const markdownModeRef = useRef(false)
43
+ const value = useMemo<MarkdownModeContextValue>(
44
+ () => ({ isMarkdown, setIsMarkdown, markdownModeRef }),
45
+ [isMarkdown]
46
+ )
47
+ return <MarkdownModeContext.Provider value={value}>{children}</MarkdownModeContext.Provider>
48
+ }
49
+
50
+ export function useMarkdownMode(): MarkdownModeContextValue {
51
+ const ctx = useContext(MarkdownModeContext)
52
+ if (ctx == null) {
53
+ throw new Error('useMarkdownMode must be used within a MarkdownModeProvider')
54
+ }
55
+ return ctx
56
+ }
@@ -2,6 +2,9 @@
2
2
  display: flex;
3
3
  isolation: isolate;
4
4
  width: 100%;
5
+ /* Flex container — keep it from being widened by its content (a wide
6
+ * white-space:pre code block) past its allotted column width. */
7
+ min-width: 0;
5
8
  }
6
9
 
7
10
  .lexicalRichTextEditor--read-only {
@@ -21,6 +24,9 @@
21
24
  .lexicalRichTextEditor__wrap {
22
25
  width: 100%;
23
26
  position: relative;
27
+ /* Flex item of .lexicalRichTextEditor — default min-width:auto would let
28
+ * the code block's intrinsic width grow this past 100%. */
29
+ min-width: 0;
24
30
  }
25
31
 
26
32
  .lexicalRichTextEditor.error {
@@ -22,6 +22,7 @@ import {
22
22
 
23
23
  import { defaultExtensionsList } from './config/default-extensions'
24
24
  import { EditorConfigContext } from './config/editor-config-context'
25
+ import { MarkdownModeProvider } from './context/markdown-mode-context'
25
26
  import { SharedHistoryContext } from './context/shared-history-context'
26
27
  import { SharedOnChangeContext } from './context/shared-on-change-context'
27
28
  import { Editor } from './editor'
@@ -119,18 +120,20 @@ export function EditorContext(props: {
119
120
  <EditorConfigContext config={editorConfig.settings}>
120
121
  <SharedOnChangeContext onChange={onChange}>
121
122
  <SharedHistoryContext>
122
- <LexicalExtensionComposer
123
- extension={rootExtension}
124
- contentEditable={null}
125
- key={composerKey + editable}
126
- >
127
- <div className="editor-shell">
128
- {beforeEditor}
129
- <Editor minHeight={props.minHeight} maxHeight={props.maxHeight} />
130
- {afterEditor}
131
- {children}
132
- </div>
133
- </LexicalExtensionComposer>
123
+ <MarkdownModeProvider>
124
+ <LexicalExtensionComposer
125
+ extension={rootExtension}
126
+ contentEditable={null}
127
+ key={composerKey + editable}
128
+ >
129
+ <div className="editor-shell">
130
+ {beforeEditor}
131
+ <Editor minHeight={props.minHeight} maxHeight={props.maxHeight} />
132
+ {afterEditor}
133
+ {children}
134
+ </div>
135
+ </LexicalExtensionComposer>
136
+ </MarkdownModeProvider>
134
137
  </SharedHistoryContext>
135
138
  </SharedOnChangeContext>
136
139
  </EditorConfigContext>
@@ -48,6 +48,11 @@
48
48
  flex: auto;
49
49
  position: relative;
50
50
  max-width: 100%;
51
+ /* Flex item default min-width:auto would let a wide (white-space:pre) code
52
+ * block grow this wrapper to its content width and scroll the whole editor.
53
+ * min-width:0 keeps the wrapper at the container width so the code block's
54
+ * own overflow-x scrolls internally and its sticky gutter stays pinned. */
55
+ min-width: 0;
51
56
  resize: vertical;
52
57
  z-index: -1;
53
58
  }
@@ -1255,6 +1260,28 @@ button.toolbar-item.active i {
1255
1260
  opacity: 1;
1256
1261
  }
1257
1262
 
1263
+ button.toolbar-item .markdown-toggle-label {
1264
+ font-family: var(--font-mono, ui-monospace, monospace);
1265
+ font-size: 15px;
1266
+ font-weight: 700;
1267
+ line-height: 18px;
1268
+ width: 18px;
1269
+ text-align: center;
1270
+ /* Explicit color (like `.toolbar-item .text`) rather than inheriting —
1271
+ * in dark mode `.editor-shell` sets the inherited color to #fff, which the
1272
+ * `.toolbar span { filter: invert(1) }` rule would then flip to black.
1273
+ * A mid-gray inverts to a visible mid-gray in both themes. */
1274
+ color: #777;
1275
+ }
1276
+
1277
+ button.toolbar-item.active .markdown-toggle-label {
1278
+ color: #1a1a1a;
1279
+ }
1280
+
1281
+ button.toolbar-item:disabled .markdown-toggle-label {
1282
+ color: #bbb;
1283
+ }
1284
+
1258
1285
  .toolbar-item.font-family .text {
1259
1286
  display: block;
1260
1287
  max-width: 40px;
@@ -11,7 +11,6 @@
11
11
  import type * as React from 'react'
12
12
  import { memo, useCallback, useEffect, useMemo, useState } from 'react'
13
13
 
14
- import { TRANSFORMERS } from '@lexical/markdown'
15
14
  import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
16
15
  import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
17
16
  import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin'
@@ -26,6 +25,7 @@ import type { EditorState, LexicalEditor } from 'lexical'
26
25
 
27
26
  import { useEditorConfig } from './config/editor-config-context'
28
27
  import { ContentEditable } from './content-editable'
28
+ import { useMarkdownMode } from './context/markdown-mode-context'
29
29
  import { useSharedHistoryContext } from './context/shared-history-context'
30
30
  import { useSharedOnChange } from './context/shared-on-change-context'
31
31
  import { Debug } from './debug'
@@ -34,6 +34,7 @@ import {
34
34
  selectFloatingUIItems,
35
35
  } from './extensions/byline-floating-ui/byline-floating-ui-extension'
36
36
  import { TableExtension as BylineTableExtension } from './extensions/table/table-extension'
37
+ import { BYLINE_TRANSFORMERS } from './markdown/transformers'
37
38
  // import { AiPlugin } from './plugins/ai-plugin'
38
39
  // import { DragDropPaste } from './plugins/drag-drop-paste-plugin'
39
40
  import { TablePlugin } from './plugins/table-plugin'
@@ -63,6 +64,7 @@ export const Editor = memo(function Editor({
63
64
  const _debugTagLogCountRef = useState(() => ({ count: 0 }))[0]
64
65
  const { onChange } = useSharedOnChange()
65
66
  const { historyState } = useSharedHistoryContext()
67
+ const { markdownModeRef } = useMarkdownMode()
66
68
  const {
67
69
  config: {
68
70
  options: { debug, richText, showTreeView, markdownShortcutPlugin },
@@ -148,6 +150,9 @@ export const Editor = memo(function Editor({
148
150
  // console.log('[lexical][top] tags', Array.from(tags))
149
151
  // }
150
152
  if (tags.has(APPLY_VALUE_TAG)) return
153
+ // Markdown-source edits are a transient view — never persisted to
154
+ // the form. Only the single convert-back on exit reaches onChange.
155
+ if (markdownModeRef.current) return
151
156
  if (!tags.has('focus') || tags.size > 1) {
152
157
  if (onChange != null) onChange(editorState, editor, tags)
153
158
  }
@@ -161,7 +166,9 @@ export const Editor = memo(function Editor({
161
166
  ErrorBoundary={LexicalErrorBoundary}
162
167
  />
163
168
  <HistoryPlugin externalHistoryState={historyState} />
164
- {markdownShortcutPlugin && <MarkdownShortcutPlugin transformers={TRANSFORMERS} />}
169
+ {markdownShortcutPlugin && (
170
+ <MarkdownShortcutPlugin transformers={BYLINE_TRANSFORMERS} />
171
+ )}
165
172
  {floatingAnchorElem != null &&
166
173
  !isSmallWidthViewport &&
167
174
  floatingUIItems.map(({ id, Component }) => (
@@ -0,0 +1,142 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import { useCallback, useEffect, useRef } from 'react'
12
+
13
+ import { $createCodeNode, $isCodeNode } from '@lexical/code'
14
+ import { $convertFromMarkdownString, $convertToMarkdownString } from '@lexical/markdown'
15
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
16
+ import { $getRoot, type EditorState, RootNode } from 'lexical'
17
+
18
+ import { APPLY_VALUE_TAG } from '../constants'
19
+ import { useMarkdownMode } from '../context/markdown-mode-context'
20
+ import { BYLINE_TRANSFORMERS } from '../markdown/transformers'
21
+
22
+ const MARKDOWN_LANGUAGE = 'markdown'
23
+
24
+ /** Trailing blank lines are semantically meaningless in markdown — ignore
25
+ * them when deciding whether the user actually edited the source. */
26
+ function normalize(markdown: string): string {
27
+ return markdown.replace(/\n+$/, '')
28
+ }
29
+
30
+ /**
31
+ * Document-level "view as markdown source" toggle for a single editor.
32
+ *
33
+ * The editor is bound to a Byline form field that accumulates
34
+ * `DocumentPatch[]`, so this hook is deliberate about persistence:
35
+ *
36
+ * - While in markdown mode the surface is a single `CodeNode` of raw
37
+ * markdown text. `markdownModeRef` suppresses the editor's
38
+ * `OnChangePlugin` so none of those keystrokes reach the form.
39
+ * - A pure round-trip (WYSIWYG → markdown → WYSIWYG with no edits)
40
+ * restores the *exact* captured `EditorState` — identical serialized
41
+ * state, so the form sees no change and records **no patch**.
42
+ * - Edits made in markdown produce a single conversion back to rich
43
+ * nodes on exit, emitting one field value change → **one patch**.
44
+ */
45
+ export function useMarkdownToggle(): {
46
+ isMarkdown: boolean
47
+ toggleMarkdown: () => void
48
+ } {
49
+ const [editor] = useLexicalComposerContext()
50
+ const { isMarkdown, setIsMarkdown, markdownModeRef } = useMarkdownMode()
51
+
52
+ // Exact rich state captured on entry, restored verbatim on a no-edit exit.
53
+ const originalEditorStateRef = useRef<EditorState | null>(null)
54
+ // The markdown we generated on entry — compared against the code node text
55
+ // on exit to detect whether the user touched the source.
56
+ const originalMarkdownRef = useRef<string>('')
57
+ // Unregister fn for the root-shape guard, live only while in markdown mode.
58
+ const unregisterTransformRef = useRef<(() => void) | null>(null)
59
+
60
+ const registerRootGuard = useCallback(() => {
61
+ unregisterTransformRef.current?.()
62
+ // Safety net: keep the root as exactly one markdown CodeNode so the user
63
+ // can't split it into sibling root nodes while editing source.
64
+ unregisterTransformRef.current = editor.registerNodeTransform(RootNode, (rootNode) => {
65
+ let codeNode = rootNode.getChildren().find($isCodeNode)
66
+ if (codeNode == null) {
67
+ codeNode = $createCodeNode(MARKDOWN_LANGUAGE)
68
+ }
69
+ if (rootNode.getChildrenSize() !== 1 || codeNode.getParent() == null) {
70
+ rootNode.splice(0, rootNode.getChildrenSize(), [codeNode])
71
+ codeNode.selectEnd()
72
+ }
73
+ if (codeNode.getLanguage() !== MARKDOWN_LANGUAGE) {
74
+ codeNode.setLanguage(MARKDOWN_LANGUAGE)
75
+ }
76
+ })
77
+ }, [editor])
78
+
79
+ const clearRootGuard = useCallback(() => {
80
+ unregisterTransformRef.current?.()
81
+ unregisterTransformRef.current = null
82
+ }, [])
83
+
84
+ const enterMarkdown = useCallback(() => {
85
+ originalEditorStateRef.current = editor.getEditorState()
86
+ // Suppress persistence *before* mutating so the code-block snapshot is
87
+ // never emitted to the form.
88
+ markdownModeRef.current = true
89
+ editor.update(() => {
90
+ const markdown = $convertToMarkdownString(BYLINE_TRANSFORMERS, undefined, true)
91
+ originalMarkdownRef.current = markdown
92
+ const codeNode = $createCodeNode(MARKDOWN_LANGUAGE)
93
+ $getRoot().clear().append(codeNode)
94
+ codeNode.select().insertRawText(markdown)
95
+ })
96
+ registerRootGuard()
97
+ setIsMarkdown(true)
98
+ }, [editor, markdownModeRef, registerRootGuard, setIsMarkdown])
99
+
100
+ const exitMarkdown = useCallback(() => {
101
+ clearRootGuard()
102
+
103
+ let currentMarkdown = ''
104
+ editor.read(() => {
105
+ const first = $getRoot().getFirstChild()
106
+ currentMarkdown = $isCodeNode(first) ? first.getTextContent() : $getRoot().getTextContent()
107
+ })
108
+
109
+ const edited = normalize(currentMarkdown) !== normalize(originalMarkdownRef.current)
110
+
111
+ if (!edited && originalEditorStateRef.current != null) {
112
+ // Pure round-trip: restore the exact captured state. Tagged so the
113
+ // OnChangePlugin ignores it — guaranteed no patch.
114
+ editor.setEditorState(originalEditorStateRef.current, { tag: APPLY_VALUE_TAG })
115
+ markdownModeRef.current = false
116
+ } else {
117
+ // Edited: drop the guard first so the single rebuilt rich state IS
118
+ // emitted to the form (one field change → one patch).
119
+ markdownModeRef.current = false
120
+ editor.update(() => {
121
+ $convertFromMarkdownString(currentMarkdown, BYLINE_TRANSFORMERS, undefined, true)
122
+ })
123
+ }
124
+
125
+ originalEditorStateRef.current = null
126
+ originalMarkdownRef.current = ''
127
+ setIsMarkdown(false)
128
+ }, [editor, clearRootGuard, markdownModeRef, setIsMarkdown])
129
+
130
+ const toggleMarkdown = useCallback(() => {
131
+ if (markdownModeRef.current) {
132
+ exitMarkdown()
133
+ } else {
134
+ enterMarkdown()
135
+ }
136
+ }, [markdownModeRef, enterMarkdown, exitMarkdown])
137
+
138
+ // If the host unmounts while still in markdown mode, drop the transform.
139
+ useEffect(() => () => clearRootGuard(), [clearRootGuard])
140
+
141
+ return { isMarkdown, toggleMarkdown }
142
+ }
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ */
8
+
9
+ /**
10
+ * Byline markdown transformers.
11
+ *
12
+ * Extends the stock `@lexical/markdown` `TRANSFORMERS` with handlers for
13
+ * Byline's custom nodes, so the document-level markdown toggle (and the
14
+ * inline markdown-shortcut plugin) round-trip them instead of dropping
15
+ * them. The `TABLE` transformer is adapted from the Lexical playground's
16
+ * `MarkdownTransformers` (GFM pipe tables).
17
+ *
18
+ * Used in the browser editor only (the toggle + shortcuts). Server-side
19
+ * markdown export walks the serialized JSON via its own serializer and
20
+ * does not use this module.
21
+ */
22
+
23
+ import {
24
+ $convertFromMarkdownString,
25
+ $convertToMarkdownString,
26
+ type ElementTransformer,
27
+ type MultilineElementTransformer,
28
+ TEXT_FORMAT_TRANSFORMERS,
29
+ TRANSFORMERS,
30
+ type Transformer,
31
+ } from '@lexical/markdown'
32
+ import {
33
+ $createTableCellNode,
34
+ $createTableNode,
35
+ $createTableRowNode,
36
+ $isTableCellNode,
37
+ $isTableNode,
38
+ $isTableRowNode,
39
+ TableCellHeaderStates,
40
+ TableCellNode,
41
+ TableNode,
42
+ TableRowNode,
43
+ } from '@lexical/table'
44
+ import { $isParagraphNode, $isTextNode } from 'lexical'
45
+
46
+ import {
47
+ $createAdmonitionNode,
48
+ $isAdmonitionNode,
49
+ AdmonitionNode,
50
+ } from '../extensions/admonition/admonition-node'
51
+ import type { AdmonitionType } from '../extensions/admonition/node-types'
52
+
53
+ // A markdown table row: | a | b | c |
54
+ const TABLE_ROW_REG_EXP = /^(?:\|)(.+)(?:\|)\s?$/
55
+ // The header separator row: | --- | :--: | ---: | (0.44's @lexical/markdown
56
+ // does not export isTableRowDivider, so inline it).
57
+ const TABLE_ROW_DIVIDER_REG_EXP = /^(\| ?:?-+:? ?)+\|?\s?$/
58
+
59
+ function isTableRowDivider(row: string): boolean {
60
+ return TABLE_ROW_DIVIDER_REG_EXP.test(row)
61
+ }
62
+
63
+ export const TABLE: ElementTransformer = {
64
+ dependencies: [TableNode, TableRowNode, TableCellNode],
65
+ export: (node) => {
66
+ if (!$isTableNode(node)) {
67
+ return null
68
+ }
69
+
70
+ const output: string[] = []
71
+
72
+ for (const row of node.getChildren()) {
73
+ const rowOutput: string[] = []
74
+ if (!$isTableRowNode(row)) {
75
+ continue
76
+ }
77
+
78
+ let isHeaderRow = false
79
+ for (const cell of row.getChildren()) {
80
+ if ($isTableCellNode(cell)) {
81
+ // Cell content is itself markdown; escape literal newlines so the
82
+ // row stays on one line.
83
+ rowOutput.push(
84
+ $convertToMarkdownString(BYLINE_TRANSFORMERS, cell).replace(/\n/g, '\\n').trim()
85
+ )
86
+ if (cell.hasHeaderState(TableCellHeaderStates.ROW)) {
87
+ isHeaderRow = true
88
+ }
89
+ }
90
+ }
91
+
92
+ output.push(`| ${rowOutput.join(' | ')} |`)
93
+ if (isHeaderRow) {
94
+ output.push(`| ${rowOutput.map(() => '---').join(' | ')} |`)
95
+ }
96
+ }
97
+
98
+ return output.join('\n')
99
+ },
100
+ regExp: TABLE_ROW_REG_EXP,
101
+ replace: (parentNode, _children, match) => {
102
+ // Header divider row: promote the previous table's last row to headers.
103
+ if (isTableRowDivider(match[0])) {
104
+ const table = parentNode.getPreviousSibling()
105
+ if (!table || !$isTableNode(table)) {
106
+ return
107
+ }
108
+
109
+ const rows = table.getChildren()
110
+ const lastRow = rows[rows.length - 1]
111
+ if (!lastRow || !$isTableRowNode(lastRow)) {
112
+ return
113
+ }
114
+
115
+ lastRow.getChildren().forEach((cell) => {
116
+ if (!$isTableCellNode(cell)) {
117
+ return
118
+ }
119
+ cell.setHeaderStyles(TableCellHeaderStates.ROW, TableCellHeaderStates.ROW)
120
+ })
121
+
122
+ parentNode.remove()
123
+ return
124
+ }
125
+
126
+ const matchCells = mapToTableCells(match[0])
127
+ if (matchCells == null) {
128
+ return
129
+ }
130
+
131
+ const rows = [matchCells]
132
+ let sibling = parentNode.getPreviousSibling()
133
+ let maxCells = matchCells.length
134
+
135
+ // Walk backwards over preceding single-text paragraphs that are also
136
+ // table rows, accumulating them into this table.
137
+ while (sibling) {
138
+ if (!$isParagraphNode(sibling)) {
139
+ break
140
+ }
141
+ if (sibling.getChildrenSize() !== 1) {
142
+ break
143
+ }
144
+ const firstChild = sibling.getFirstChild()
145
+ if (!$isTextNode(firstChild)) {
146
+ break
147
+ }
148
+ const cells = mapToTableCells(firstChild.getTextContent())
149
+ if (cells == null) {
150
+ break
151
+ }
152
+ maxCells = Math.max(maxCells, cells.length)
153
+ rows.unshift(cells)
154
+ const previousSibling = sibling.getPreviousSibling()
155
+ sibling.remove()
156
+ sibling = previousSibling
157
+ }
158
+
159
+ const table = $createTableNode()
160
+
161
+ for (const cells of rows) {
162
+ const tableRow = $createTableRowNode()
163
+ table.append(tableRow)
164
+ for (let i = 0; i < maxCells; i++) {
165
+ tableRow.append(i < cells.length ? cells[i] : $createTableCell(''))
166
+ }
167
+ }
168
+
169
+ const previousSibling = parentNode.getPreviousSibling()
170
+ if ($isTableNode(previousSibling) && getTableColumnsSize(previousSibling) === maxCells) {
171
+ previousSibling.append(...table.getChildren())
172
+ parentNode.remove()
173
+ } else {
174
+ parentNode.replace(table)
175
+ }
176
+
177
+ table.selectEnd()
178
+ },
179
+ type: 'element',
180
+ }
181
+
182
+ function getTableColumnsSize(table: TableNode): number {
183
+ const row = table.getFirstChild()
184
+ return $isTableRowNode(row) ? row.getChildrenSize() : 0
185
+ }
186
+
187
+ const $createTableCell = (textContent: string): TableCellNode => {
188
+ const content = textContent.replace(/\\n/g, '\n')
189
+ const cell = $createTableCellNode(TableCellHeaderStates.NO_STATUS)
190
+ $convertFromMarkdownString(content, BYLINE_TRANSFORMERS, cell)
191
+ return cell
192
+ }
193
+
194
+ const mapToTableCells = (textContent: string): Array<TableCellNode> | null => {
195
+ const match = textContent.match(TABLE_ROW_REG_EXP)
196
+ if (!match?.[1]) {
197
+ return null
198
+ }
199
+ return match[1].split('|').map((text) => $createTableCell(text))
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Admonitions → Docusaurus directive syntax: :::type[Title] … :::
204
+ //
205
+ // AdmonitionNode is a DecoratorNode whose body is a *nested* LexicalEditor
206
+ // (a bare `createEditor()` — paragraph/text/linebreak only). So the body is
207
+ // converted with an inline-only transformer set that matches what the nested
208
+ // editor can hold; block constructs in the body are intentionally not
209
+ // round-tripped because the node can't represent them anyway.
210
+ // ---------------------------------------------------------------------------
211
+
212
+ const ADMONITION_TYPES: ReadonlySet<string> = new Set(['note', 'tip', 'warning', 'danger'])
213
+ const ADMONITION_START_REG_EXP = /^:::(note|tip|warning|danger)(?:\[([^\]]*)\])?\s*$/
214
+ const ADMONITION_END_REG_EXP = /^:::\s*$/
215
+
216
+ // Inline-only — the nested admonition editor has no block nodes beyond the
217
+ // default paragraph, so importing headings/lists/tables into it would fail.
218
+ const ADMONITION_BODY_TRANSFORMERS: Array<Transformer> = [...TEXT_FORMAT_TRANSFORMERS]
219
+
220
+ export const ADMONITION: MultilineElementTransformer = {
221
+ dependencies: [AdmonitionNode],
222
+ export: (node) => {
223
+ if (!$isAdmonitionNode(node)) {
224
+ return null
225
+ }
226
+ const type = node.getAdmonitionType()
227
+ const title = node.getTitle()
228
+
229
+ // Convert the nested editor's content to markdown in its own read context.
230
+ let body = ''
231
+ node.__content.read(() => {
232
+ body = $convertToMarkdownString(ADMONITION_BODY_TRANSFORMERS).trim()
233
+ })
234
+
235
+ const heading = title ? `:::${type}[${title}]` : `:::${type}`
236
+ return body ? `${heading}\n${body}\n:::` : `${heading}\n:::`
237
+ },
238
+ regExpStart: ADMONITION_START_REG_EXP,
239
+ // `optional` is required for the as-you-type shortcut path to run this
240
+ // transformer at all: runMultilineElementTransformers skips any multiline
241
+ // transformer whose regExpEnd is non-optional (the closing ::: hasn't been
242
+ // typed yet when the start line fires). The toggle/import path still honours
243
+ // the explicit ::: end when present, falling back to EOF only if it's absent.
244
+ regExpEnd: { optional: true, regExp: ADMONITION_END_REG_EXP },
245
+ replace: (rootNode, children, startMatch, _endMatch, linesInBetween) => {
246
+ const rawType = startMatch[1]
247
+ if (!ADMONITION_TYPES.has(rawType)) {
248
+ return false
249
+ }
250
+ const admonitionType = rawType as AdmonitionType
251
+ const title = startMatch[2] ?? ''
252
+
253
+ const node = $createAdmonitionNode({ admonitionType, title })
254
+
255
+ // Import path: body arrives as the lines between the fences. Populate the
256
+ // nested editor synchronously so it's flushed before render/serialize.
257
+ if (!children && linesInBetween != null) {
258
+ const body = linesInBetween.join('\n').trim()
259
+ if (body) {
260
+ node.__content.update(
261
+ () => {
262
+ $convertFromMarkdownString(body, ADMONITION_BODY_TRANSFORMERS)
263
+ },
264
+ { discrete: true }
265
+ )
266
+ }
267
+ }
268
+
269
+ rootNode.append(node)
270
+ },
271
+ type: 'multiline-element',
272
+ }
273
+
274
+ /**
275
+ * Stock transformers plus Byline's custom-node handlers. Self-referenced by
276
+ * the `TABLE` transformer (cells are converted with the same set), so it
277
+ * must be declared after `TABLE` — the references live inside callbacks that
278
+ * run well after module init, so there is no TDZ hazard.
279
+ */
280
+ export const BYLINE_TRANSFORMERS: Array<Transformer> = [TABLE, ADMONITION, ...TRANSFORMERS]
@@ -92,6 +92,7 @@ import {
92
92
  OPEN_LINK_MODAL_COMMAND,
93
93
  TOGGLE_LINK_COMMAND,
94
94
  } from '../../extensions/link'
95
+ import { useMarkdownToggle } from '../../hooks/use-markdown-toggle'
95
96
  import { IS_APPLE } from '../../shared/environment'
96
97
  import { DropDown, DropDownItem } from '../../ui/dropdown'
97
98
  import { getSelectedNode } from '../../utils/getSelectedNode'
@@ -372,9 +373,10 @@ export function ToolbarPlugin(): React.JSX.Element {
372
373
  const {
373
374
  uuid,
374
375
  config: {
375
- options: { textAlignment, undoRedo, textStyle, inlineCode },
376
+ options: { textAlignment, undoRedo, textStyle, inlineCode, markdownToggle },
376
377
  },
377
378
  } = useEditorConfig()
379
+ const { isMarkdown, toggleMarkdown } = useMarkdownToggle()
378
380
  // const { openModal } = usePayloadModal()
379
381
  // const editDepth = useEditDepth()
380
382
 
@@ -869,6 +871,23 @@ export function ToolbarPlugin(): React.JSX.Element {
869
871
  <Fragment key={item.id}>{item.node}</Fragment>
870
872
  ))}
871
873
  </ToolbarActiveEditorProvider>
874
+
875
+ {markdownToggle && (
876
+ <>
877
+ <Divider />
878
+ <button
879
+ type="button"
880
+ disabled={!isEditable}
881
+ onClick={toggleMarkdown}
882
+ className={`toolbar-item spaced markdown-toggle ${isMarkdown ? 'active' : ''}`}
883
+ title={isMarkdown ? 'Convert from Markdown' : 'Convert to Markdown'}
884
+ aria-label={isMarkdown ? 'Convert from markdown' : 'Convert to markdown'}
885
+ aria-pressed={isMarkdown}
886
+ >
887
+ <span className="markdown-toggle-label">M</span>
888
+ </button>
889
+ </>
890
+ )}
872
891
  </div>
873
892
  )
874
893
  }