@byline/richtext-lexical 3.4.1 → 3.5.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 (32) hide show
  1. package/dist/field/extensions/admonition/admonition-commands.d.ts +25 -0
  2. package/dist/field/extensions/admonition/admonition-commands.js +4 -0
  3. package/dist/field/extensions/admonition/admonition-extension.d.ts +2 -3
  4. package/dist/field/extensions/admonition/admonition-extension.js +138 -24
  5. package/dist/field/extensions/admonition/admonition-node.css +112 -0
  6. package/dist/field/extensions/admonition/admonition-node.d.ts +26 -14
  7. package/dist/field/extensions/admonition/admonition-node.js +121 -72
  8. package/dist/field/extensions/admonition/index.js +1 -0
  9. package/dist/field/extensions/admonition/node-types.d.ts +10 -4
  10. package/dist/field/extensions/floating-text-format/index.d.ts +8 -1
  11. package/dist/field/extensions/floating-text-format/index.js +6 -4
  12. package/dist/field/markdown/transformers.js +11 -14
  13. package/package.json +5 -5
  14. package/src/field/extensions/admonition/admonition-commands.ts +31 -0
  15. package/src/field/extensions/admonition/admonition-extension.tsx +248 -37
  16. package/src/field/extensions/admonition/admonition-node.css +113 -0
  17. package/src/field/extensions/admonition/admonition-node.tsx +172 -93
  18. package/src/field/extensions/admonition/index.ts +4 -8
  19. package/src/field/extensions/admonition/node-types.ts +10 -10
  20. package/src/field/extensions/floating-text-format/index.tsx +19 -3
  21. package/src/field/markdown/admonition-roundtrip.test.node.ts +110 -0
  22. package/src/field/markdown/transformers.ts +45 -24
  23. package/dist/field/extensions/admonition/admonition-node-component.css +0 -119
  24. package/dist/field/extensions/admonition/admonition-node-component.d.ts +0 -17
  25. package/dist/field/extensions/admonition/admonition-node-component.js +0 -196
  26. package/dist/field/extensions/admonition/icons/danger-icon.d.ts +0 -7
  27. package/dist/field/extensions/admonition/icons/index.d.ts +0 -4
  28. package/dist/field/extensions/admonition/icons/note-icon.d.ts +0 -7
  29. package/dist/field/extensions/admonition/icons/tip-icon.d.ts +0 -7
  30. package/dist/field/extensions/admonition/icons/warning-icon.d.ts +0 -7
  31. package/src/field/extensions/admonition/admonition-node-component.css +0 -115
  32. package/src/field/extensions/admonition/admonition-node-component.tsx +0 -257
@@ -5,16 +5,22 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import type { LexicalEditor, NodeKey, SerializedEditor, SerializedLexicalNode, Spread } from 'lexical';
8
+ import type { NodeKey, SerializedElementNode, Spread } from 'lexical';
9
9
  export type AdmonitionType = 'note' | 'tip' | 'warning' | 'danger';
10
10
  export interface AdmonitionAttributes {
11
11
  admonitionType: AdmonitionType;
12
12
  title: string;
13
- content?: LexicalEditor;
14
13
  key?: NodeKey;
15
14
  }
15
+ /**
16
+ * The admonition is an `ElementNode` — its body lives as real children in
17
+ * the main editor tree (paragraphs + inline content), so the serialized
18
+ * shape extends `SerializedElementNode` and carries `children`. `type` and
19
+ * `title` are node-level attributes set from the Insert/Edit modal; they
20
+ * ride the opening Docusaurus fence (`:::type[title]`) on markdown export,
21
+ * not the body.
22
+ */
16
23
  export type SerializedAdmonitionNode = Spread<{
17
24
  admonitionType: AdmonitionType;
18
25
  title: string;
19
- content: SerializedEditor;
20
- }, SerializedLexicalNode>;
26
+ }, SerializedElementNode>;
@@ -7,6 +7,13 @@
7
7
  */
8
8
  import './index.css';
9
9
  import type * as React from 'react';
10
- export declare function FloatingTextFormatToolbarPlugin({ anchorElem, }: {
10
+ export declare function FloatingTextFormatToolbarPlugin({ anchorElem, shouldShow, }: {
11
11
  anchorElem?: HTMLElement;
12
+ /**
13
+ * Optional gate evaluated inside an editor read context. Return `false`
14
+ * to suppress the popover for the current selection — used to scope the
15
+ * toolbar to admonition bodies on editors where the global
16
+ * `FloatingTextFormatExtension` is removed.
17
+ */
18
+ shouldShow?: () => boolean;
12
19
  }): React.JSX.Element | null;
@@ -207,7 +207,7 @@ function TextFormatFloatingToolbar({ editor, anchorElem, isLink, isBold, isItali
207
207
  })
208
208
  });
209
209
  }
210
- function useFloatingTextFormatToolbar(editor, anchorElem) {
210
+ function useFloatingTextFormatToolbar(editor, anchorElem, shouldShow) {
211
211
  const [isText, setIsText] = useState(false);
212
212
  const [isLink, setIsLink] = useState(false);
213
213
  const [isBold, setIsBold] = useState(false);
@@ -225,6 +225,7 @@ function useFloatingTextFormatToolbar(editor, anchorElem) {
225
225
  const rootElement = editor.getRootElement();
226
226
  if (null !== nativeSelection && (!$isRangeSelection(selection) || null === rootElement || !rootElement.contains(nativeSelection.anchorNode))) return void setIsText(false);
227
227
  if (!$isRangeSelection(selection)) return;
228
+ if (null != shouldShow && !shouldShow()) return void setIsText(false);
228
229
  const node = getSelectedNode(selection);
229
230
  setIsBold(selection.hasFormat('bold'));
230
231
  setIsItalic(selection.hasFormat('italic'));
@@ -240,7 +241,8 @@ function useFloatingTextFormatToolbar(editor, anchorElem) {
240
241
  if (!selection.isCollapsed() && '' === rawTextContent) setIsText(false);
241
242
  });
242
243
  }, [
243
- editor
244
+ editor,
245
+ shouldShow
244
246
  ]);
245
247
  useEffect(()=>{
246
248
  document.addEventListener('selectionchange', updatePopup);
@@ -272,8 +274,8 @@ function useFloatingTextFormatToolbar(editor, anchorElem) {
272
274
  isCode: isCode
273
275
  }), anchorElem);
274
276
  }
275
- function FloatingTextFormatToolbarPlugin({ anchorElem = document.body }) {
277
+ function FloatingTextFormatToolbarPlugin({ anchorElem = document.body, shouldShow }) {
276
278
  const [editor] = useLexicalComposerContext();
277
- return useFloatingTextFormatToolbar(editor, anchorElem);
279
+ return useFloatingTextFormatToolbar(editor, anchorElem, shouldShow);
278
280
  }
279
281
  export { FloatingTextFormatToolbarPlugin };
@@ -1,6 +1,6 @@
1
- import { $convertFromMarkdownString, $convertToMarkdownString, TEXT_FORMAT_TRANSFORMERS, TRANSFORMERS } from "@lexical/markdown";
1
+ import { $convertFromMarkdownString, $convertToMarkdownString, LINK, TEXT_FORMAT_TRANSFORMERS, TRANSFORMERS } from "@lexical/markdown";
2
2
  import { $createTableCellNode, $createTableNode, $createTableRowNode, $isTableCellNode, $isTableNode, $isTableRowNode, TableCellHeaderStates, TableCellNode, TableNode, TableRowNode } from "@lexical/table";
3
- import { $isParagraphNode, $isTextNode } from "lexical";
3
+ import { $createParagraphNode, $isParagraphNode, $isTextNode } from "lexical";
4
4
  import { $createAdmonitionNode, $isAdmonitionNode, AdmonitionNode } from "../extensions/admonition/admonition-node.js";
5
5
  const TABLE_ROW_REG_EXP = /^(?:\|)(.+)(?:\|)\s?$/;
6
6
  const TABLE_ROW_DIVIDER_REG_EXP = /^(\| ?:?-+:? ?)+\|?\s?$/;
@@ -103,7 +103,8 @@ const ADMONITION_TYPES = new Set([
103
103
  const ADMONITION_START_REG_EXP = /^:::(note|tip|warning|danger)(?:\[([^\]]*)\])?\s*$/;
104
104
  const ADMONITION_END_REG_EXP = /^:::\s*$/;
105
105
  const ADMONITION_BODY_TRANSFORMERS = [
106
- ...TEXT_FORMAT_TRANSFORMERS
106
+ ...TEXT_FORMAT_TRANSFORMERS,
107
+ LINK
107
108
  ];
108
109
  const ADMONITION = {
109
110
  dependencies: [
@@ -113,10 +114,7 @@ const ADMONITION = {
113
114
  if (!$isAdmonitionNode(node)) return null;
114
115
  const type = node.getAdmonitionType();
115
116
  const title = node.getTitle();
116
- let body = '';
117
- node.__content.read(()=>{
118
- body = $convertToMarkdownString(ADMONITION_BODY_TRANSFORMERS).trim();
119
- });
117
+ const body = $convertToMarkdownString(ADMONITION_BODY_TRANSFORMERS, node).trim();
120
118
  const heading = title ? `:::${type}[${title}]` : `:::${type}`;
121
119
  return body ? `${heading}\n${body}\n:::` : `${heading}\n:::`;
122
120
  },
@@ -125,7 +123,7 @@ const ADMONITION = {
125
123
  optional: true,
126
124
  regExp: ADMONITION_END_REG_EXP
127
125
  },
128
- replace: (rootNode, children, startMatch, _endMatch, linesInBetween)=>{
126
+ replace: (rootNode, children, startMatch, _endMatch, linesInBetween, isImport)=>{
129
127
  const rawType = startMatch[1];
130
128
  if (!ADMONITION_TYPES.has(rawType)) return false;
131
129
  const admonitionType = rawType;
@@ -134,15 +132,14 @@ const ADMONITION = {
134
132
  admonitionType,
135
133
  title
136
134
  });
137
- if (!children && null != linesInBetween) {
135
+ if (null != children) node.append(...children);
136
+ else if (null != linesInBetween) {
138
137
  const body = linesInBetween.join('\n').trim();
139
- if (body) node.__content.update(()=>{
140
- $convertFromMarkdownString(body, ADMONITION_BODY_TRANSFORMERS);
141
- }, {
142
- discrete: true
143
- });
138
+ if (body) $convertFromMarkdownString(body, ADMONITION_BODY_TRANSFORMERS, node);
144
139
  }
140
+ if (0 === node.getChildrenSize()) node.append($createParagraphNode());
145
141
  rootNode.append(node);
142
+ if (!isImport) node.selectStart();
146
143
  },
147
144
  type: 'multiline-element'
148
145
  };
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "private": false,
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
- "version": "3.4.1",
6
+ "version": "3.5.0",
7
7
  "engines": {
8
8
  "node": ">=20.9.0"
9
9
  },
@@ -77,10 +77,10 @@
77
77
  "npm-run-all": "^4.1.5",
78
78
  "prism-react-renderer": "^2.4.1",
79
79
  "react-error-boundary": "^6.1.1",
80
- "@byline/client": "3.4.1",
81
- "@byline/admin": "3.4.1",
82
- "@byline/ui": "3.4.1",
83
- "@byline/core": "3.4.1"
80
+ "@byline/client": "3.5.0",
81
+ "@byline/ui": "3.5.0",
82
+ "@byline/admin": "3.5.0",
83
+ "@byline/core": "3.5.0"
84
84
  },
85
85
  "peerDependencies": {
86
86
  "react": "^19.0.0",
@@ -0,0 +1,31 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * Leaf module for the admonition Lexical commands. Lives apart from
11
+ * `admonition-extension.tsx` so the node class (`admonition-node.tsx`)
12
+ * can dispatch the "open modal" command from its `createDOM` chrome
13
+ * without importing the extension (which imports the node — a cycle).
14
+ */
15
+
16
+ import { createCommand, type LexicalCommand, type NodeKey } from 'lexical'
17
+
18
+ import type { AdmonitionAttributes } from './node-types'
19
+
20
+ /**
21
+ * Opens the admonition modal. Payload of `null` means "insert a new
22
+ * admonition"; a `{ nodeKey }` payload means "edit the existing node"
23
+ * — dispatched by the per-node Edit button rendered in `createDOM`.
24
+ */
25
+ export const OPEN_ADMONITION_MODAL_COMMAND: LexicalCommand<{ nodeKey: NodeKey } | null> =
26
+ createCommand('OPEN_ADMONITION_MODAL_COMMAND')
27
+
28
+ /** Inserts a new admonition (type + title come from the modal). */
29
+ export const INSERT_ADMONITION_COMMAND: LexicalCommand<AdmonitionAttributes> = createCommand(
30
+ 'INSERT_ADMONITION_COMMAND'
31
+ )
@@ -13,43 +13,192 @@ import { useEffect } from 'react'
13
13
 
14
14
  import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
15
15
  import { ReactExtension } from '@lexical/react/ReactExtension'
16
- import { $insertNodeToNearestRoot, mergeRegister } from '@lexical/utils'
16
+ import { useOptionalExtensionDependency } from '@lexical/react/useExtensionComponent'
17
+ import { $findMatchingParent, $insertNodeToNearestRoot, mergeRegister } from '@lexical/utils'
17
18
  import {
19
+ $createParagraphNode,
20
+ $createTextNode,
21
+ $getNodeByKey,
18
22
  $getSelection,
23
+ $isDecoratorNode,
24
+ $isElementNode,
25
+ $isLineBreakNode,
26
+ $isParagraphNode,
19
27
  $isRangeSelection,
28
+ $isTextNode,
20
29
  COMMAND_PRIORITY_EDITOR,
30
+ COMMAND_PRIORITY_LOW,
21
31
  COMMAND_PRIORITY_NORMAL,
22
32
  configExtension,
23
- createCommand,
24
33
  declarePeerDependency,
25
34
  defineExtension,
26
- type LexicalCommand,
35
+ type ElementNode,
36
+ KEY_ARROW_DOWN_COMMAND,
37
+ KEY_ARROW_LEFT_COMMAND,
38
+ KEY_ARROW_RIGHT_COMMAND,
39
+ KEY_ARROW_UP_COMMAND,
40
+ type NodeKey,
27
41
  } from 'lexical'
28
42
 
29
43
  import { useToolbarActiveEditor } from '../../plugins/toolbar-plugin/toolbar-active-editor'
30
44
  import { DropDownItem } from '../../ui/dropdown'
45
+ import {
46
+ type BylineFloatingUIConfig,
47
+ BylineFloatingUIExtension,
48
+ type BylineFloatingUIProps,
49
+ } from '../byline-floating-ui/byline-floating-ui-extension'
31
50
  import {
32
51
  type BylineToolbarConfig,
33
52
  BylineToolbarExtension,
34
53
  } from '../byline-toolbar/byline-toolbar-extension'
54
+ import { FloatingTextFormatExtension } from '../floating-text-format/floating-text-format-extension'
55
+ import { FloatingTextFormatToolbarPlugin } from '../floating-text-format/index'
56
+ import { INSERT_ADMONITION_COMMAND, OPEN_ADMONITION_MODAL_COMMAND } from './admonition-commands'
35
57
  import { AdmonitionModal } from './admonition-modal'
36
- import { $createAdmonitionNode, AdmonitionNode } from './admonition-node'
37
- import type { AdmonitionAttributes } from './node-types'
58
+ import { $createAdmonitionNode, $isAdmonitionNode, AdmonitionNode } from './admonition-node'
59
+ import type { AdmonitionAttributes, AdmonitionType } from './node-types'
38
60
  import type { AdmonitionData } from './types'
39
61
 
40
62
  export type InsertAdmonitionPayload = Readonly<AdmonitionAttributes>
41
63
 
42
- export const OPEN_ADMONITION_MODAL_COMMAND: LexicalCommand<null> = createCommand(
43
- 'OPEN_ADMONITION_MODAL_COMMAND'
44
- )
64
+ // Re-exported for backwards compatibility — the commands now live in their
65
+ // own leaf module so the node can dispatch the "open modal" command without
66
+ // importing this extension.
67
+ export { INSERT_ADMONITION_COMMAND, OPEN_ADMONITION_MODAL_COMMAND }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Body restriction — structure-enforcing transform.
71
+ //
72
+ // The admonition body is deliberately limited to formatted text + links
73
+ // (paragraphs only): no nested admonitions, no images / embeds, no headings /
74
+ // lists / tables / code blocks. Insert-command guards prevent the common
75
+ // entry points; this transform is the backstop for paste and markdown import.
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /** True when the selection's anchor sits inside an admonition body. */
79
+ function $selectionInsideAdmonition(): boolean {
80
+ const selection = $getSelection()
81
+ if (!$isRangeSelection(selection)) {
82
+ return false
83
+ }
84
+ return $findMatchingParent(selection.anchor.getNode(), $isAdmonitionNode) != null
85
+ }
45
86
 
46
- export const INSERT_ADMONITION_COMMAND: LexicalCommand<AdmonitionAttributes> = createCommand(
47
- 'INSERT_ADMONITION_COMMAND'
48
- )
87
+ /** Flatten a disallowed block child to a paragraph, preserving inline content. */
88
+ function $flattenToParagraph(element: ElementNode): void {
89
+ const paragraph = $createParagraphNode()
90
+ const children = element.getChildren()
91
+ const allInline =
92
+ children.length > 0 &&
93
+ children.every((child) => $isTextNode(child) || $isLineBreakNode(child) || child.isInline())
94
+ if (allInline) {
95
+ paragraph.append(...children)
96
+ } else {
97
+ const text = element.getTextContent()
98
+ if (text.length > 0) {
99
+ paragraph.append($createTextNode(text))
100
+ }
101
+ }
102
+ element.replace(paragraph)
103
+ }
104
+
105
+ function $normalizeAdmonition(node: AdmonitionNode): void {
106
+ // Never allow an admonition to nest inside another — unwrap this one,
107
+ // hoisting its children to sit before it in the outer admonition.
108
+ const parent = node.getParent()
109
+ const ancestorAdmonition =
110
+ parent != null ? $findMatchingParent(parent, (candidate) => $isAdmonitionNode(candidate)) : null
111
+ if (ancestorAdmonition != null) {
112
+ for (const child of node.getChildren()) {
113
+ node.insertBefore(child)
114
+ }
115
+ node.remove()
116
+ return
117
+ }
118
+
119
+ if (node.getChildrenSize() === 0) {
120
+ node.append($createParagraphNode())
121
+ return
122
+ }
123
+
124
+ for (const child of node.getChildren()) {
125
+ if ($isParagraphNode(child)) {
126
+ continue
127
+ }
128
+ // Inline / text / linebreak stragglers → wrap in a paragraph.
129
+ if ($isTextNode(child) || $isLineBreakNode(child) || child.isInline()) {
130
+ const paragraph = $createParagraphNode()
131
+ child.insertBefore(paragraph)
132
+ paragraph.append(child)
133
+ continue
134
+ }
135
+ // Decorators (images, embeds, horizontal rule) aren't allowed in the body.
136
+ if ($isDecoratorNode(child)) {
137
+ child.remove()
138
+ continue
139
+ }
140
+ // Other block elements (headings, lists, quotes, tables, code) collapse
141
+ // to a paragraph.
142
+ if ($isElementNode(child)) {
143
+ $flattenToParagraph(child)
144
+ }
145
+ }
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Shadow-root escape — insert a paragraph above/below when the caret is at the
150
+ // admonition's outer edge, so the block never traps the cursor. Mirrors the
151
+ // Lexical Collapsible plugin.
152
+ // ---------------------------------------------------------------------------
153
+
154
+ function $onEscapeUp(): boolean {
155
+ const selection = $getSelection()
156
+ if ($isRangeSelection(selection) && selection.isCollapsed() && selection.anchor.offset === 0) {
157
+ const admonition = $findMatchingParent(selection.anchor.getNode(), $isAdmonitionNode)
158
+ if ($isAdmonitionNode(admonition)) {
159
+ const parent = admonition.getParent()
160
+ if (
161
+ parent != null &&
162
+ parent.getFirstChild() === admonition &&
163
+ selection.anchor.key === admonition.getFirstDescendant()?.getKey()
164
+ ) {
165
+ admonition.insertBefore($createParagraphNode())
166
+ }
167
+ }
168
+ }
169
+ return false
170
+ }
171
+
172
+ function $onEscapeDown(): boolean {
173
+ const selection = $getSelection()
174
+ if ($isRangeSelection(selection) && selection.isCollapsed()) {
175
+ const admonition = $findMatchingParent(selection.anchor.getNode(), $isAdmonitionNode)
176
+ if ($isAdmonitionNode(admonition)) {
177
+ const parent = admonition.getParent()
178
+ if (parent != null && parent.getLastChild() === admonition) {
179
+ const lastParagraph = admonition.getLastDescendant()
180
+ if (
181
+ lastParagraph != null &&
182
+ selection.anchor.key === lastParagraph.getKey() &&
183
+ selection.anchor.offset === lastParagraph.getTextContentSize()
184
+ ) {
185
+ admonition.insertAfter($createParagraphNode())
186
+ }
187
+ }
188
+ }
189
+ }
190
+ return false
191
+ }
49
192
 
50
193
  export function AdmonitionPlugin(): React.JSX.Element {
51
194
  const [editor] = useLexicalComposerContext()
52
195
  const [open, setOpen] = React.useState(false)
196
+ // Null target = insert a new admonition; a key = edit that existing node.
197
+ const [editNodeKey, setEditNodeKey] = React.useState<NodeKey | null>(null)
198
+ const [modalData, setModalData] = React.useState<{
199
+ title: string
200
+ admonitionType?: AdmonitionType
201
+ }>({ title: '', admonitionType: undefined })
53
202
 
54
203
  useEffect(() => {
55
204
  if (!editor.hasNodes([AdmonitionNode])) {
@@ -57,10 +206,26 @@ export function AdmonitionPlugin(): React.JSX.Element {
57
206
  }
58
207
 
59
208
  return mergeRegister(
60
- // TODO: possibly register this command with insert and edit options?
61
- editor.registerCommand<null>(
209
+ editor.registerCommand<{ nodeKey: NodeKey } | null>(
62
210
  OPEN_ADMONITION_MODAL_COMMAND,
63
- () => {
211
+ (payload) => {
212
+ if (payload == null) {
213
+ setEditNodeKey(null)
214
+ setModalData({ title: '', admonitionType: undefined })
215
+ setOpen(true)
216
+ return true
217
+ }
218
+ const data = editor.getEditorState().read(() => {
219
+ const node = $getNodeByKey(payload.nodeKey)
220
+ return $isAdmonitionNode(node)
221
+ ? { title: node.getTitle(), admonitionType: node.getAdmonitionType() }
222
+ : null
223
+ })
224
+ if (data == null) {
225
+ return false
226
+ }
227
+ setEditNodeKey(payload.nodeKey)
228
+ setModalData(data)
64
229
  setOpen(true)
65
230
  return true
66
231
  },
@@ -69,47 +234,85 @@ export function AdmonitionPlugin(): React.JSX.Element {
69
234
 
70
235
  editor.registerCommand<InsertAdmonitionPayload>(
71
236
  INSERT_ADMONITION_COMMAND,
72
- (payload: AdmonitionAttributes) => {
73
- // return true
237
+ (payload) => {
74
238
  const selection = $getSelection()
75
-
76
239
  if (!$isRangeSelection(selection)) {
77
240
  return false
78
241
  }
79
-
80
- const focusNode = selection.focus.getNode()
81
-
82
- if (focusNode !== null) {
83
- const admonitionNode = $createAdmonitionNode(payload)
84
- $insertNodeToNearestRoot(admonitionNode)
242
+ // No nesting — refuse (silently) when already inside an admonition.
243
+ if ($selectionInsideAdmonition()) {
244
+ return true
85
245
  }
246
+ const admonition = $createAdmonitionNode(payload)
247
+ admonition.append($createParagraphNode())
248
+ $insertNodeToNearestRoot(admonition)
249
+ admonition.selectStart()
86
250
  return true
87
251
  },
88
252
  COMMAND_PRIORITY_EDITOR
89
- )
253
+ ),
254
+
255
+ // Structure / restriction backstop.
256
+ editor.registerNodeTransform(AdmonitionNode, $normalizeAdmonition),
257
+
258
+ // Shadow-root caret escape.
259
+ editor.registerCommand(KEY_ARROW_DOWN_COMMAND, $onEscapeDown, COMMAND_PRIORITY_LOW),
260
+ editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, $onEscapeDown, COMMAND_PRIORITY_LOW),
261
+ editor.registerCommand(KEY_ARROW_UP_COMMAND, $onEscapeUp, COMMAND_PRIORITY_LOW),
262
+ editor.registerCommand(KEY_ARROW_LEFT_COMMAND, $onEscapeUp, COMMAND_PRIORITY_LOW)
90
263
  )
91
264
  }, [editor])
92
265
 
93
- const handleInsertAdmonition = ({ admonitionType, title }: AdmonitionData): void => {
94
- if (title != null && admonitionType != null) {
95
- const admonitionPayload: AdmonitionAttributes = {
96
- admonitionType,
97
- title,
98
- }
99
-
100
- editor.dispatchCommand(INSERT_ADMONITION_COMMAND, admonitionPayload)
266
+ const handleSubmit = ({ admonitionType, title }: AdmonitionData): void => {
267
+ setOpen(false)
268
+ if (admonitionType == null) {
269
+ return
270
+ }
271
+ const key = editNodeKey
272
+ if (key != null) {
273
+ editor.update(() => {
274
+ const node = $getNodeByKey(key)
275
+ if ($isAdmonitionNode(node)) {
276
+ node.update({ admonitionType, title })
277
+ }
278
+ })
101
279
  } else {
102
- console.error('Error: missing title or type for admonition.')
280
+ editor.dispatchCommand(INSERT_ADMONITION_COMMAND, { admonitionType, title })
103
281
  }
104
- setOpen(false)
282
+ setEditNodeKey(null)
105
283
  }
106
284
 
107
285
  return (
108
286
  <AdmonitionModal
109
287
  open={open}
110
- data={{ title: '', admonitionType: undefined }}
111
- onClose={() => setOpen(false)}
112
- onSubmit={handleInsertAdmonition}
288
+ data={modalData}
289
+ onClose={() => {
290
+ setOpen(false)
291
+ setEditNodeKey(null)
292
+ }}
293
+ onSubmit={handleSubmit}
294
+ />
295
+ )
296
+ }
297
+
298
+ /**
299
+ * Floating text-format toolbar scoped to admonition bodies. Only mounts when
300
+ * the global `FloatingTextFormatExtension` is *absent* — when it's present, it
301
+ * already covers admonition bodies (and everywhere else), so this would just
302
+ * double up. This is what gives the body its bold/italic/link popover on
303
+ * editors (e.g. the AI editor) that suppress the global one.
304
+ */
305
+ function AdmonitionFloatingTextFormat({
306
+ anchorElem,
307
+ }: BylineFloatingUIProps): React.JSX.Element | null {
308
+ const hasGlobalToolbar = useOptionalExtensionDependency(FloatingTextFormatExtension) !== undefined
309
+ if (hasGlobalToolbar) {
310
+ return null
311
+ }
312
+ return (
313
+ <FloatingTextFormatToolbarPlugin
314
+ anchorElem={anchorElem}
315
+ shouldShow={$selectionInsideAdmonition}
113
316
  />
114
317
  )
115
318
  }
@@ -144,5 +347,13 @@ export const AdmonitionExtension = defineExtension({
144
347
  },
145
348
  ],
146
349
  } satisfies Partial<BylineToolbarConfig>),
350
+ declarePeerDependency<typeof BylineFloatingUIExtension>(BylineFloatingUIExtension.name, {
351
+ items: [
352
+ {
353
+ id: '@byline/richtext-lexical/Admonition/floating-text-format',
354
+ Component: AdmonitionFloatingTextFormat,
355
+ },
356
+ ],
357
+ } satisfies Partial<BylineFloatingUIConfig>),
147
358
  ],
148
359
  })
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Admonition (callout) block — ElementNode chrome.
3
+ *
4
+ * The container is a real block in the main editor tree. The header
5
+ * (icon + title + Edit button) is non-editable chrome injected in
6
+ * `createDOM`; the body paragraphs render into `.AdmonitionNode__content`
7
+ * (the DOM slot returned by `getDOMSlot`).
8
+ */
9
+
10
+ .LexicalEditor__admonition {
11
+ position: relative;
12
+ margin-top: 0.5rem;
13
+ margin-bottom: 1.5rem;
14
+ padding: 8px 12px 4px;
15
+ border: solid 1px #666666;
16
+ border-left-width: 4px;
17
+ border-radius: 3px;
18
+ }
19
+
20
+ /* Per-type accent — colours the left border + header icon. */
21
+ .LexicalEditor__admonition.admonition-note {
22
+ border-left-color: #4a90d9;
23
+ }
24
+ .LexicalEditor__admonition.admonition-tip {
25
+ border-left-color: #3fa45b;
26
+ }
27
+ .LexicalEditor__admonition.admonition-warning {
28
+ border-left-color: #d9a334;
29
+ }
30
+ .LexicalEditor__admonition.admonition-danger {
31
+ border-left-color: #d94a4a;
32
+ }
33
+
34
+ .LexicalEditor__admonition .AdmonitionNode__header {
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 6px;
38
+ font-weight: bold;
39
+ font-size: 1rem;
40
+ text-transform: capitalize;
41
+ margin-bottom: 0.25rem;
42
+ user-select: none;
43
+ }
44
+
45
+ .LexicalEditor__admonition .AdmonitionNode__icon {
46
+ display: inline-flex;
47
+ align-items: center;
48
+ }
49
+
50
+ .LexicalEditor__admonition .AdmonitionNode__icon svg {
51
+ width: 20px;
52
+ height: 20px;
53
+ fill: currentColor;
54
+ }
55
+
56
+ .LexicalEditor__admonition.admonition-note .AdmonitionNode__icon {
57
+ color: #4a90d9;
58
+ }
59
+ .LexicalEditor__admonition.admonition-tip .AdmonitionNode__icon {
60
+ color: #3fa45b;
61
+ }
62
+ .LexicalEditor__admonition.admonition-warning .AdmonitionNode__icon {
63
+ color: #d9a334;
64
+ }
65
+ .LexicalEditor__admonition.admonition-danger .AdmonitionNode__icon {
66
+ color: #d94a4a;
67
+ }
68
+
69
+ .LexicalEditor__admonition .AdmonitionNode__title {
70
+ flex: 1 1 auto;
71
+ }
72
+
73
+ .LexicalEditor__admonition .AdmonitionNode__edit-button {
74
+ flex: 0 0 auto;
75
+ font-size: 0.8rem;
76
+ padding: 2px 8px;
77
+ border: 1px solid rgba(0, 0, 0, 0.2);
78
+ border-radius: 5px;
79
+ background-color: rgba(0, 0, 0, 0.04);
80
+ color: inherit;
81
+ cursor: pointer;
82
+ user-select: none;
83
+ }
84
+
85
+ .LexicalEditor__admonition .AdmonitionNode__edit-button:hover {
86
+ background-color: rgba(60, 132, 244, 0.18);
87
+ }
88
+
89
+ .LexicalEditor__admonition .AdmonitionNode__content {
90
+ font-size: 0.925em;
91
+ color: #4b5563;
92
+ }
93
+
94
+ .LexicalEditor__admonition .AdmonitionNode__content > * {
95
+ margin-bottom: 0.25em;
96
+ }
97
+
98
+ .LexicalEditor__admonition .AdmonitionNode__content > *:last-child {
99
+ margin-bottom: 0;
100
+ }
101
+
102
+ .dark .LexicalEditor__admonition {
103
+ border-color: #444444;
104
+ }
105
+
106
+ .dark .LexicalEditor__admonition .AdmonitionNode__content {
107
+ color: #9ca3af;
108
+ }
109
+
110
+ .dark .LexicalEditor__admonition .AdmonitionNode__edit-button {
111
+ border-color: rgba(255, 255, 255, 0.2);
112
+ background-color: rgba(255, 255, 255, 0.08);
113
+ }