@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.
- package/dist/field/extensions/admonition/admonition-commands.d.ts +25 -0
- package/dist/field/extensions/admonition/admonition-commands.js +4 -0
- package/dist/field/extensions/admonition/admonition-extension.d.ts +2 -3
- package/dist/field/extensions/admonition/admonition-extension.js +138 -24
- package/dist/field/extensions/admonition/admonition-node.css +112 -0
- package/dist/field/extensions/admonition/admonition-node.d.ts +26 -14
- package/dist/field/extensions/admonition/admonition-node.js +121 -72
- package/dist/field/extensions/admonition/index.js +1 -0
- package/dist/field/extensions/admonition/node-types.d.ts +10 -4
- package/dist/field/extensions/floating-text-format/index.d.ts +8 -1
- package/dist/field/extensions/floating-text-format/index.js +6 -4
- package/dist/field/markdown/transformers.js +11 -14
- package/package.json +5 -5
- package/src/field/extensions/admonition/admonition-commands.ts +31 -0
- package/src/field/extensions/admonition/admonition-extension.tsx +248 -37
- package/src/field/extensions/admonition/admonition-node.css +113 -0
- package/src/field/extensions/admonition/admonition-node.tsx +172 -93
- package/src/field/extensions/admonition/index.ts +4 -8
- package/src/field/extensions/admonition/node-types.ts +10 -10
- package/src/field/extensions/floating-text-format/index.tsx +19 -3
- package/src/field/markdown/admonition-roundtrip.test.node.ts +110 -0
- package/src/field/markdown/transformers.ts +45 -24
- package/dist/field/extensions/admonition/admonition-node-component.css +0 -119
- package/dist/field/extensions/admonition/admonition-node-component.d.ts +0 -17
- package/dist/field/extensions/admonition/admonition-node-component.js +0 -196
- package/dist/field/extensions/admonition/icons/danger-icon.d.ts +0 -7
- package/dist/field/extensions/admonition/icons/index.d.ts +0 -4
- package/dist/field/extensions/admonition/icons/note-icon.d.ts +0 -7
- package/dist/field/extensions/admonition/icons/tip-icon.d.ts +0 -7
- package/dist/field/extensions/admonition/icons/warning-icon.d.ts +0 -7
- package/src/field/extensions/admonition/admonition-node-component.css +0 -115
- package/src/field/extensions/admonition/admonition-node-component.tsx +0 -257
|
@@ -8,180 +8,259 @@
|
|
|
8
8
|
* Copyright (c) Infonomic Company Limited
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import * as React from 'react'
|
|
12
|
-
|
|
13
11
|
import type {
|
|
14
12
|
DOMConversionMap,
|
|
15
13
|
DOMConversionOutput,
|
|
16
14
|
DOMExportOutput,
|
|
17
15
|
EditorConfig,
|
|
16
|
+
ElementDOMSlot,
|
|
18
17
|
LexicalEditor,
|
|
19
18
|
LexicalNode,
|
|
19
|
+
LexicalUpdateJSON,
|
|
20
20
|
NodeKey,
|
|
21
21
|
} from 'lexical'
|
|
22
|
-
import { $applyNodeReplacement,
|
|
22
|
+
import { $applyNodeReplacement, ElementNode } from 'lexical'
|
|
23
23
|
|
|
24
|
+
import { OPEN_ADMONITION_MODAL_COMMAND } from './admonition-commands'
|
|
24
25
|
import type { AdmonitionAttributes, AdmonitionType, SerializedAdmonitionNode } from './node-types'
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
import './admonition-node.css'
|
|
28
|
+
|
|
29
|
+
const ADMONITION_TYPES: ReadonlySet<string> = new Set(['note', 'tip', 'warning', 'danger'])
|
|
30
|
+
|
|
31
|
+
function isAdmonitionType(value: unknown): value is AdmonitionType {
|
|
32
|
+
return typeof value === 'string' && ADMONITION_TYPES.has(value)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Inline SVG markup for the header icon, keyed by type. Mirrors the React
|
|
36
|
+
// icon components in `./icons` — the ElementNode chrome is plain DOM, so the
|
|
37
|
+
// markup is inlined rather than rendered through React.
|
|
38
|
+
const ICON_SVG: Record<AdmonitionType, string> = {
|
|
39
|
+
note: '<svg focusable="false" aria-hidden="true" viewBox="0 0 24 24"><path d="M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z"></path></svg>',
|
|
40
|
+
tip: '<svg focusable="false" aria-hidden="true" viewBox="0 0 24 24"><path d="M20,12A8,8 0 0,1 12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4C12.76,4 13.5,4.11 14.2,4.31L15.77,2.74C14.61,2.26 13.34,2 12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12M7.91,10.08L6.5,11.5L11,16L21,6L19.59,4.58L11,13.17L7.91,10.08Z"></path></svg>',
|
|
41
|
+
warning:
|
|
42
|
+
'<svg focusable="false" aria-hidden="true" viewBox="0 0 24 24"><path d="M12 5.99L19.53 19H4.47L12 5.99M12 2L1 21h22L12 2zm1 14h-2v2h2v-2zm0-6h-2v4h2v-4z"></path></svg>',
|
|
43
|
+
danger:
|
|
44
|
+
'<svg focusable="false" aria-hidden="true" viewBox="0 0 24 24"><path d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"></path></svg>',
|
|
45
|
+
}
|
|
27
46
|
|
|
28
47
|
function convertAdmonitionElement(domNode: Node): null | DOMConversionOutput {
|
|
29
|
-
if (domNode instanceof HTMLDivElement) {
|
|
30
|
-
const type = domNode.dataset.type
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
48
|
+
if (domNode instanceof HTMLDivElement && domNode.dataset.type != null) {
|
|
49
|
+
const type = domNode.dataset.type
|
|
50
|
+
if (!isAdmonitionType(type)) {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
const title = domNode.dataset.title ?? ''
|
|
54
|
+
return { node: $createAdmonitionNode({ admonitionType: type, title }) }
|
|
34
55
|
}
|
|
35
56
|
return null
|
|
36
57
|
}
|
|
37
58
|
|
|
38
|
-
|
|
59
|
+
/**
|
|
60
|
+
* The admonition (callout) block. An `ElementNode` whose body is real
|
|
61
|
+
* children in the main editor tree — see the module note in
|
|
62
|
+
* `../../markdown/transformers.ts` for why this beats a nested editor for
|
|
63
|
+
* markdown round-tripping. `__admonitionType` / `__title` are attributes set
|
|
64
|
+
* from the Insert/Edit modal and rendered as non-editable chrome; the body
|
|
65
|
+
* (paragraphs + inline content) renders into the slot returned by
|
|
66
|
+
* `getDOMSlot`.
|
|
67
|
+
*/
|
|
68
|
+
export class AdmonitionNode extends ElementNode {
|
|
39
69
|
__admonitionType: AdmonitionType
|
|
40
70
|
__title: string
|
|
41
|
-
__content: LexicalEditor
|
|
42
71
|
|
|
43
72
|
static getType(): string {
|
|
44
73
|
return 'admonition'
|
|
45
74
|
}
|
|
46
75
|
|
|
47
76
|
static clone(node: AdmonitionNode): AdmonitionNode {
|
|
48
|
-
return new AdmonitionNode(node.__admonitionType, node.__title, node.
|
|
77
|
+
return new AdmonitionNode(node.__admonitionType, node.__title, node.__key)
|
|
49
78
|
}
|
|
50
79
|
|
|
51
80
|
static importJSON(serializedNode: SerializedAdmonitionNode): AdmonitionNode {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
})
|
|
57
|
-
const nestedEditor = node.__content
|
|
58
|
-
const editorState = nestedEditor.parseEditorState(content.editorState)
|
|
59
|
-
if (!editorState.isEmpty()) {
|
|
60
|
-
nestedEditor.setEditorState(editorState)
|
|
61
|
-
}
|
|
62
|
-
return node
|
|
81
|
+
return $createAdmonitionNode({
|
|
82
|
+
admonitionType: serializedNode.admonitionType,
|
|
83
|
+
title: serializedNode.title,
|
|
84
|
+
}).updateFromJSON(serializedNode)
|
|
63
85
|
}
|
|
64
86
|
|
|
65
87
|
static importDOM(): DOMConversionMap | null {
|
|
66
88
|
return {
|
|
67
|
-
div: (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
89
|
+
div: (node: HTMLElement) => {
|
|
90
|
+
if (node.dataset.type == null || !isAdmonitionType(node.dataset.type)) {
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
return { conversion: convertAdmonitionElement, priority: 1 }
|
|
94
|
+
},
|
|
71
95
|
}
|
|
72
96
|
}
|
|
73
97
|
|
|
74
|
-
constructor(
|
|
75
|
-
admonitionType: AdmonitionType,
|
|
76
|
-
title: string,
|
|
77
|
-
content?: LexicalEditor,
|
|
78
|
-
key?: NodeKey
|
|
79
|
-
) {
|
|
98
|
+
constructor(admonitionType: AdmonitionType, title: string, key?: NodeKey) {
|
|
80
99
|
super(key)
|
|
81
100
|
this.__admonitionType = admonitionType
|
|
82
101
|
this.__title = title
|
|
83
|
-
this.__content = content ?? createEditor()
|
|
84
102
|
}
|
|
85
103
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
104
|
+
updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedAdmonitionNode>): this {
|
|
105
|
+
return super
|
|
106
|
+
.updateFromJSON(serializedNode)
|
|
107
|
+
.setAdmonitionType(serializedNode.admonitionType)
|
|
108
|
+
.setTitle(serializedNode.title)
|
|
91
109
|
}
|
|
92
110
|
|
|
93
111
|
exportJSON(): SerializedAdmonitionNode {
|
|
94
112
|
return {
|
|
113
|
+
...super.exportJSON(),
|
|
95
114
|
admonitionType: this.__admonitionType,
|
|
96
115
|
title: this.__title,
|
|
97
|
-
content: this.__content.toJSON(),
|
|
98
|
-
type: 'admonition',
|
|
99
|
-
version: 1,
|
|
100
116
|
}
|
|
101
117
|
}
|
|
102
118
|
|
|
103
|
-
|
|
104
|
-
|
|
119
|
+
exportDOM(): DOMExportOutput {
|
|
120
|
+
const element = document.createElement('div')
|
|
121
|
+
element.setAttribute('data-type', this.__admonitionType)
|
|
122
|
+
element.setAttribute('data-title', this.__title)
|
|
123
|
+
element.className = `admonition admonition-${this.__admonitionType}`
|
|
124
|
+
return { element }
|
|
105
125
|
}
|
|
106
126
|
|
|
107
|
-
|
|
108
|
-
return this.__title
|
|
109
|
-
}
|
|
127
|
+
// Structure / behaviour ----------------------------------------------------
|
|
110
128
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
writable.__title = title
|
|
129
|
+
isShadowRoot(): boolean {
|
|
130
|
+
return true
|
|
114
131
|
}
|
|
115
132
|
|
|
116
|
-
|
|
117
|
-
return
|
|
133
|
+
isInline(): false {
|
|
134
|
+
return false
|
|
118
135
|
}
|
|
119
136
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
137
|
+
canBeEmpty(): boolean {
|
|
138
|
+
// Kept true so an in-flight empty admonition isn't auto-removed mid-edit;
|
|
139
|
+
// the extension's structure transform re-seeds an empty paragraph.
|
|
140
|
+
return true
|
|
123
141
|
}
|
|
124
142
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const { admonitionType, title } = payload
|
|
128
|
-
if (admonitionType != null) {
|
|
129
|
-
writable.__admonitionType = admonitionType
|
|
130
|
-
}
|
|
131
|
-
if (title != null) {
|
|
132
|
-
writable.__title = title
|
|
133
|
-
}
|
|
143
|
+
canIndent(): false {
|
|
144
|
+
return false
|
|
134
145
|
}
|
|
135
146
|
|
|
136
|
-
// View
|
|
147
|
+
// View ---------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement {
|
|
150
|
+
const key = this.getKey()
|
|
151
|
+
const dom = document.createElement('div')
|
|
152
|
+
dom.setAttribute('data-type', this.__admonitionType)
|
|
153
|
+
dom.setAttribute('data-title', this.__title)
|
|
154
|
+
dom.className = `${config.theme.admonition ?? ''} admonition-${this.__admonitionType}`.trim()
|
|
155
|
+
|
|
156
|
+
// Non-editable header chrome: icon + title + edit affordance. Marked
|
|
157
|
+
// contenteditable=false so the caret never lands in it and Lexical's
|
|
158
|
+
// reconciler leaves it alone (managed children go into the slot below).
|
|
159
|
+
const header = document.createElement('div')
|
|
160
|
+
header.className = 'AdmonitionNode__header'
|
|
161
|
+
header.contentEditable = 'false'
|
|
162
|
+
header.setAttribute('data-lexical-admonition-chrome', 'true')
|
|
163
|
+
|
|
164
|
+
const icon = document.createElement('span')
|
|
165
|
+
icon.className = 'AdmonitionNode__icon'
|
|
166
|
+
icon.innerHTML = ICON_SVG[this.__admonitionType]
|
|
167
|
+
|
|
168
|
+
const titleEl = document.createElement('span')
|
|
169
|
+
titleEl.className = 'AdmonitionNode__title'
|
|
170
|
+
titleEl.textContent = this.__title
|
|
171
|
+
|
|
172
|
+
const editButton = document.createElement('button')
|
|
173
|
+
editButton.type = 'button'
|
|
174
|
+
editButton.className = 'AdmonitionNode__edit-button'
|
|
175
|
+
editButton.textContent = 'Edit'
|
|
176
|
+
editButton.addEventListener('click', (event) => {
|
|
177
|
+
event.preventDefault()
|
|
178
|
+
event.stopPropagation()
|
|
179
|
+
editor.dispatchCommand(OPEN_ADMONITION_MODAL_COMMAND, { nodeKey: key })
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
header.appendChild(icon)
|
|
183
|
+
header.appendChild(titleEl)
|
|
184
|
+
header.appendChild(editButton)
|
|
185
|
+
|
|
186
|
+
const content = document.createElement('div')
|
|
187
|
+
content.className = 'AdmonitionNode__content'
|
|
188
|
+
|
|
189
|
+
dom.appendChild(header)
|
|
190
|
+
dom.appendChild(content)
|
|
191
|
+
return dom
|
|
192
|
+
}
|
|
137
193
|
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const className = `${config.theme.admonition} admonition-${this.__admonitionType}`
|
|
143
|
-
if (className !== undefined) {
|
|
144
|
-
div.className = className
|
|
194
|
+
getDOMSlot(element: HTMLElement): ElementDOMSlot<HTMLElement> {
|
|
195
|
+
const content = element.querySelector(':scope > .AdmonitionNode__content')
|
|
196
|
+
if (!(content instanceof HTMLElement)) {
|
|
197
|
+
throw new Error('AdmonitionNode: expected a .AdmonitionNode__content slot element')
|
|
145
198
|
}
|
|
146
|
-
return
|
|
199
|
+
return super.getDOMSlot(element).withElement(content)
|
|
147
200
|
}
|
|
148
201
|
|
|
149
|
-
updateDOM(prevNode: AdmonitionNode, dom: HTMLElement
|
|
150
|
-
const
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
202
|
+
updateDOM(prevNode: AdmonitionNode, dom: HTMLElement): boolean {
|
|
203
|
+
const type = this.__admonitionType
|
|
204
|
+
if (type !== prevNode.__admonitionType) {
|
|
205
|
+
dom.className =
|
|
206
|
+
`${dom.className.replace(/admonition-\w+/, '').trim()} admonition-${type}`.trim()
|
|
207
|
+
dom.setAttribute('data-type', type)
|
|
208
|
+
const icon = dom.querySelector(':scope > .AdmonitionNode__header > .AdmonitionNode__icon')
|
|
209
|
+
if (icon != null) {
|
|
210
|
+
icon.innerHTML = ICON_SVG[type]
|
|
155
211
|
}
|
|
156
|
-
dom.setAttribute('data-type', admonitionType)
|
|
157
|
-
return true
|
|
158
212
|
}
|
|
159
213
|
if (this.__title !== prevNode.__title) {
|
|
160
214
|
dom.setAttribute('data-title', this.__title)
|
|
161
|
-
|
|
215
|
+
const titleEl = dom.querySelector(':scope > .AdmonitionNode__header > .AdmonitionNode__title')
|
|
216
|
+
if (titleEl != null) {
|
|
217
|
+
titleEl.textContent = this.__title
|
|
218
|
+
}
|
|
162
219
|
}
|
|
220
|
+
// Never recreate the DOM — that would drop the header chrome and its
|
|
221
|
+
// click handler. Mutations above are applied in place.
|
|
163
222
|
return false
|
|
164
223
|
}
|
|
165
224
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
225
|
+
// Accessors ----------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
getTitle(): string {
|
|
228
|
+
return this.getLatest().__title
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
setTitle(title: string): this {
|
|
232
|
+
const writable = this.getWritable()
|
|
233
|
+
writable.__title = title
|
|
234
|
+
return writable
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
getAdmonitionType(): AdmonitionType {
|
|
238
|
+
return this.getLatest().__admonitionType
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
setAdmonitionType(admonitionType: AdmonitionType): this {
|
|
242
|
+
const writable = this.getWritable()
|
|
243
|
+
writable.__admonitionType = admonitionType
|
|
244
|
+
return writable
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
update(payload: Partial<Pick<AdmonitionAttributes, 'admonitionType' | 'title'>>): void {
|
|
248
|
+
const writable = this.getWritable()
|
|
249
|
+
if (payload.admonitionType != null) {
|
|
250
|
+
writable.__admonitionType = payload.admonitionType
|
|
251
|
+
}
|
|
252
|
+
if (payload.title != null) {
|
|
253
|
+
writable.__title = payload.title
|
|
254
|
+
}
|
|
175
255
|
}
|
|
176
256
|
}
|
|
177
257
|
|
|
178
258
|
export function $createAdmonitionNode({
|
|
179
259
|
admonitionType,
|
|
180
260
|
title,
|
|
181
|
-
content,
|
|
182
261
|
key,
|
|
183
262
|
}: AdmonitionAttributes): AdmonitionNode {
|
|
184
|
-
return $applyNodeReplacement(new AdmonitionNode(admonitionType, title,
|
|
263
|
+
return $applyNodeReplacement(new AdmonitionNode(admonitionType, title, key))
|
|
185
264
|
}
|
|
186
265
|
|
|
187
266
|
export function $isAdmonitionNode(node: LexicalNode | null | undefined): node is AdmonitionNode {
|
|
@@ -6,13 +6,9 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
export * from './admonition-commands'
|
|
9
10
|
export * from './admonition-node'
|
|
10
|
-
// Intentionally NOT re-exported: `admonition-
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// any consumer of this barrel and defeat the split.
|
|
14
|
-
//
|
|
15
|
-
// Likewise NOT re-exported: `admonition-extension.tsx` and its dependencies
|
|
16
|
-
// (`admonition-modal.tsx`, `fields.ts`, `types.ts`) — those pull React UI
|
|
17
|
-
// and would bloat consumers that only want the node class.
|
|
11
|
+
// Intentionally NOT re-exported: `admonition-extension.tsx` and its
|
|
12
|
+
// dependencies (`admonition-modal.tsx`, `fields.ts`, `types.ts`) — those pull
|
|
13
|
+
// React UI and would bloat consumers that only want the node class.
|
|
18
14
|
export * from './node-types'
|
|
@@ -6,28 +6,28 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type {
|
|
10
|
-
LexicalEditor,
|
|
11
|
-
NodeKey,
|
|
12
|
-
SerializedEditor,
|
|
13
|
-
SerializedLexicalNode,
|
|
14
|
-
Spread,
|
|
15
|
-
} from 'lexical'
|
|
9
|
+
import type { NodeKey, SerializedElementNode, Spread } from 'lexical'
|
|
16
10
|
|
|
17
11
|
export type AdmonitionType = 'note' | 'tip' | 'warning' | 'danger'
|
|
18
12
|
|
|
19
13
|
export interface AdmonitionAttributes {
|
|
20
14
|
admonitionType: AdmonitionType
|
|
21
15
|
title: string
|
|
22
|
-
content?: LexicalEditor
|
|
23
16
|
key?: NodeKey
|
|
24
17
|
}
|
|
25
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The admonition is an `ElementNode` — its body lives as real children in
|
|
21
|
+
* the main editor tree (paragraphs + inline content), so the serialized
|
|
22
|
+
* shape extends `SerializedElementNode` and carries `children`. `type` and
|
|
23
|
+
* `title` are node-level attributes set from the Insert/Edit modal; they
|
|
24
|
+
* ride the opening Docusaurus fence (`:::type[title]`) on markdown export,
|
|
25
|
+
* not the body.
|
|
26
|
+
*/
|
|
26
27
|
export type SerializedAdmonitionNode = Spread<
|
|
27
28
|
{
|
|
28
29
|
admonitionType: AdmonitionType
|
|
29
30
|
title: string
|
|
30
|
-
content: SerializedEditor
|
|
31
31
|
},
|
|
32
|
-
|
|
32
|
+
SerializedElementNode
|
|
33
33
|
>
|
|
@@ -279,7 +279,8 @@ function TextFormatFloatingToolbar({
|
|
|
279
279
|
|
|
280
280
|
function useFloatingTextFormatToolbar(
|
|
281
281
|
editor: LexicalEditor,
|
|
282
|
-
anchorElem: HTMLElement
|
|
282
|
+
anchorElem: HTMLElement,
|
|
283
|
+
shouldShow?: () => boolean
|
|
283
284
|
): React.JSX.Element | null {
|
|
284
285
|
const [isText, setIsText] = useState(false)
|
|
285
286
|
const [isLink, setIsLink] = useState(false)
|
|
@@ -315,6 +316,13 @@ function useFloatingTextFormatToolbar(
|
|
|
315
316
|
return
|
|
316
317
|
}
|
|
317
318
|
|
|
319
|
+
// Caller-supplied gate (e.g. "only inside an admonition body"). Runs in
|
|
320
|
+
// this read context so it can inspect the current selection's ancestry.
|
|
321
|
+
if (shouldShow != null && !shouldShow()) {
|
|
322
|
+
setIsText(false)
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
|
|
318
326
|
const node = getSelectedNode(selection)
|
|
319
327
|
|
|
320
328
|
// Update text format
|
|
@@ -345,7 +353,7 @@ function useFloatingTextFormatToolbar(
|
|
|
345
353
|
setIsText(false)
|
|
346
354
|
}
|
|
347
355
|
})
|
|
348
|
-
}, [editor])
|
|
356
|
+
}, [editor, shouldShow])
|
|
349
357
|
|
|
350
358
|
useEffect(() => {
|
|
351
359
|
document.addEventListener('selectionchange', updatePopup)
|
|
@@ -390,9 +398,17 @@ function useFloatingTextFormatToolbar(
|
|
|
390
398
|
|
|
391
399
|
export function FloatingTextFormatToolbarPlugin({
|
|
392
400
|
anchorElem = document.body,
|
|
401
|
+
shouldShow,
|
|
393
402
|
}: {
|
|
394
403
|
anchorElem?: HTMLElement
|
|
404
|
+
/**
|
|
405
|
+
* Optional gate evaluated inside an editor read context. Return `false`
|
|
406
|
+
* to suppress the popover for the current selection — used to scope the
|
|
407
|
+
* toolbar to admonition bodies on editors where the global
|
|
408
|
+
* `FloatingTextFormatExtension` is removed.
|
|
409
|
+
*/
|
|
410
|
+
shouldShow?: () => boolean
|
|
395
411
|
}): React.JSX.Element | null {
|
|
396
412
|
const [editor] = useLexicalComposerContext()
|
|
397
|
-
return useFloatingTextFormatToolbar(editor, anchorElem)
|
|
413
|
+
return useFloatingTextFormatToolbar(editor, anchorElem, shouldShow)
|
|
398
414
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
* Round-trip coverage for the admonition markdown transformer. The
|
|
11
|
+
* `ElementNode` body (paragraphs + inline content) is converted with the same
|
|
12
|
+
* engine as the rest of the document, so a markdown → Lexical → markdown
|
|
13
|
+
* cycle must be stable. These run headless (no DOM / React).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { CodeNode } from '@lexical/code'
|
|
17
|
+
import { createHeadlessEditor } from '@lexical/headless'
|
|
18
|
+
import { LinkNode } from '@lexical/link'
|
|
19
|
+
import { ListItemNode, ListNode } from '@lexical/list'
|
|
20
|
+
import { $convertFromMarkdownString, $convertToMarkdownString } from '@lexical/markdown'
|
|
21
|
+
import { HeadingNode, QuoteNode } from '@lexical/rich-text'
|
|
22
|
+
import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'
|
|
23
|
+
import { $getRoot, type Klass, type LexicalNode } from 'lexical'
|
|
24
|
+
import { describe, expect, it } from 'vitest'
|
|
25
|
+
|
|
26
|
+
import { $isAdmonitionNode, AdmonitionNode } from '../extensions/admonition/admonition-node'
|
|
27
|
+
import { BYLINE_TRANSFORMERS } from './transformers'
|
|
28
|
+
|
|
29
|
+
// Explicit node list rather than the `../nodes` barrel — that barrel pulls in
|
|
30
|
+
// JSX-bearing decorator nodes (inline-image, embeds) which node-mode Vitest
|
|
31
|
+
// won't parse. The new AdmonitionNode is pure-DOM, so it imports cleanly here.
|
|
32
|
+
const Nodes: Array<Klass<LexicalNode>> = [
|
|
33
|
+
AdmonitionNode,
|
|
34
|
+
LinkNode,
|
|
35
|
+
HeadingNode,
|
|
36
|
+
QuoteNode,
|
|
37
|
+
ListNode,
|
|
38
|
+
ListItemNode,
|
|
39
|
+
CodeNode,
|
|
40
|
+
TableNode,
|
|
41
|
+
TableRowNode,
|
|
42
|
+
TableCellNode,
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
function roundTrip(markdown: string): string {
|
|
46
|
+
const editor = createHeadlessEditor({
|
|
47
|
+
namespace: 'test',
|
|
48
|
+
nodes: Nodes,
|
|
49
|
+
onError: (e) => {
|
|
50
|
+
throw e
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
let output = ''
|
|
54
|
+
editor.update(
|
|
55
|
+
() => {
|
|
56
|
+
$convertFromMarkdownString(markdown, BYLINE_TRANSFORMERS)
|
|
57
|
+
},
|
|
58
|
+
{ discrete: true }
|
|
59
|
+
)
|
|
60
|
+
editor.read(() => {
|
|
61
|
+
output = $convertToMarkdownString(BYLINE_TRANSFORMERS)
|
|
62
|
+
})
|
|
63
|
+
return output
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('admonition markdown round-trip', () => {
|
|
67
|
+
it('round-trips a titled warning with inline formatting + a link', () => {
|
|
68
|
+
const md = [
|
|
69
|
+
':::warning[Heads up]',
|
|
70
|
+
'This is **bold** and a [link](https://example.com).',
|
|
71
|
+
':::',
|
|
72
|
+
].join('\n')
|
|
73
|
+
expect(roundTrip(md)).toBe(md)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('round-trips a multi-paragraph body', () => {
|
|
77
|
+
const md = [':::note[Notes]', 'First paragraph.', '', 'Second paragraph.', ':::'].join('\n')
|
|
78
|
+
expect(roundTrip(md)).toBe(md)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('round-trips an admonition with no title', () => {
|
|
82
|
+
const md = [':::tip', 'A quick tip.', ':::'].join('\n')
|
|
83
|
+
expect(roundTrip(md)).toBe(md)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('builds an AdmonitionNode with the right type + title on import', () => {
|
|
87
|
+
const editor = createHeadlessEditor({
|
|
88
|
+
namespace: 'test',
|
|
89
|
+
nodes: Nodes,
|
|
90
|
+
onError: (e) => {
|
|
91
|
+
throw e
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
editor.update(
|
|
95
|
+
() => {
|
|
96
|
+
$convertFromMarkdownString(':::danger[Stop]\nBody text.\n:::', BYLINE_TRANSFORMERS)
|
|
97
|
+
},
|
|
98
|
+
{ discrete: true }
|
|
99
|
+
)
|
|
100
|
+
editor.read(() => {
|
|
101
|
+
const node = $getRoot().getFirstChild()
|
|
102
|
+
expect($isAdmonitionNode(node)).toBe(true)
|
|
103
|
+
if ($isAdmonitionNode(node)) {
|
|
104
|
+
expect(node.getAdmonitionType()).toBe('danger')
|
|
105
|
+
expect(node.getTitle()).toBe('Stop')
|
|
106
|
+
expect(node.getTextContent()).toContain('Body text.')
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
})
|