@blue-patterns/richtext 0.0.0-stage → 0.7.18
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/CHANGELOG.md +275 -0
- package/LICENSE +22 -0
- package/README.md +88 -2
- package/astryx.integration.mjs +11 -0
- package/blocks/RichTextEditorShowcase.template.mjs +13 -0
- package/blocks/RichTextEditorShowcase.tsx +19 -0
- package/dist/RichTextEditor.d.ts +282 -0
- package/dist/RichTextEditor.d.ts.map +1 -0
- package/dist/RichTextEditor.js +657 -0
- package/dist/RichTextEditorAutoLinkPlugin.d.ts +83 -0
- package/dist/RichTextEditorAutoLinkPlugin.d.ts.map +1 -0
- package/dist/RichTextEditorAutoLinkPlugin.js +99 -0
- package/dist/RichTextEditorToolbar.d.ts +186 -0
- package/dist/RichTextEditorToolbar.d.ts.map +1 -0
- package/dist/RichTextEditorToolbar.js +688 -0
- package/dist/RichTextView.d.ts +92 -0
- package/dist/RichTextView.d.ts.map +1 -0
- package/dist/RichTextView.js +267 -0
- package/dist/codeLanguages.d.ts +14 -0
- package/dist/codeLanguages.d.ts.map +1 -0
- package/dist/codeLanguages.js +34 -0
- package/dist/codeLineTokens.d.ts +53 -0
- package/dist/codeLineTokens.d.ts.map +1 -0
- package/dist/codeLineTokens.js +188 -0
- package/dist/editorNodes.d.ts +8 -0
- package/dist/editorNodes.d.ts.map +1 -0
- package/dist/editorNodes.js +30 -0
- package/dist/editorTheme.d.ts +7 -0
- package/dist/editorTheme.d.ts.map +1 -0
- package/dist/editorTheme.js +328 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/internal/CodeBackspacePlugin.d.ts +2 -0
- package/dist/internal/CodeBackspacePlugin.d.ts.map +1 -0
- package/dist/internal/CodeBackspacePlugin.js +70 -0
- package/dist/internal/CodeBlockHeaderPlugin.d.ts +11 -0
- package/dist/internal/CodeBlockHeaderPlugin.d.ts.map +1 -0
- package/dist/internal/CodeBlockHeaderPlugin.js +204 -0
- package/dist/internal/CodePastePlugin.d.ts +5 -0
- package/dist/internal/CodePastePlugin.d.ts.map +1 -0
- package/dist/internal/CodePastePlugin.js +104 -0
- package/dist/internal/CodeSyntaxPlugin.d.ts +8 -0
- package/dist/internal/CodeSyntaxPlugin.d.ts.map +1 -0
- package/dist/internal/CodeSyntaxPlugin.js +397 -0
- package/dist/internal/CodeTabPlugin.d.ts +2 -0
- package/dist/internal/CodeTabPlugin.d.ts.map +1 -0
- package/dist/internal/CodeTabPlugin.js +256 -0
- package/dist/internal/DisabledLinkGuardPlugin.d.ts +4 -0
- package/dist/internal/DisabledLinkGuardPlugin.d.ts.map +1 -0
- package/dist/internal/DisabledLinkGuardPlugin.js +93 -0
- package/dist/internal/ImageSourceCaretPlugin.d.ts +3 -0
- package/dist/internal/ImageSourceCaretPlugin.d.ts.map +1 -0
- package/dist/internal/ImageSourceCaretPlugin.js +105 -0
- package/dist/internal/ListItemShortcutsPlugin.d.ts +6 -0
- package/dist/internal/ListItemShortcutsPlugin.d.ts.map +1 -0
- package/dist/internal/ListItemShortcutsPlugin.js +314 -0
- package/dist/internal/MarkdownExtensionsPlugin.d.ts +17 -0
- package/dist/internal/MarkdownExtensionsPlugin.d.ts.map +1 -0
- package/dist/internal/MarkdownExtensionsPlugin.js +185 -0
- package/dist/internal/SelectAllScopePlugin.d.ts +2 -0
- package/dist/internal/SelectAllScopePlugin.d.ts.map +1 -0
- package/dist/internal/SelectAllScopePlugin.js +67 -0
- package/dist/internal/TableColumnFloorPlugin.d.ts +12 -0
- package/dist/internal/TableColumnFloorPlugin.d.ts.map +1 -0
- package/dist/internal/TableColumnFloorPlugin.js +84 -0
- package/dist/internal/TablePastePlugin.d.ts +9 -0
- package/dist/internal/TablePastePlugin.d.ts.map +1 -0
- package/dist/internal/TablePastePlugin.js +183 -0
- package/dist/internal/TableScrollRegionPlugin.d.ts +9 -0
- package/dist/internal/TableScrollRegionPlugin.d.ts.map +1 -0
- package/dist/internal/TableScrollRegionPlugin.js +95 -0
- package/dist/internal/TaskCheckboxPlugin.d.ts +7 -0
- package/dist/internal/TaskCheckboxPlugin.d.ts.map +1 -0
- package/dist/internal/TaskCheckboxPlugin.js +193 -0
- package/dist/internal/ToolbarLinkPopover.d.ts +74 -0
- package/dist/internal/ToolbarLinkPopover.d.ts.map +1 -0
- package/dist/internal/ToolbarLinkPopover.js +292 -0
- package/dist/linkEditing.d.ts +6 -0
- package/dist/linkEditing.d.ts.map +1 -0
- package/dist/linkEditing.js +20 -0
- package/dist/linkForm.d.ts +28 -0
- package/dist/linkForm.d.ts.map +1 -0
- package/dist/linkForm.js +137 -0
- package/dist/linkUtils.d.ts +27 -0
- package/dist/linkUtils.d.ts.map +1 -0
- package/dist/linkUtils.js +101 -0
- package/dist/markdown.d.ts +17 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +17 -0
- package/dist/markdownCharacterReferences.d.ts +135 -0
- package/dist/markdownCharacterReferences.d.ts.map +1 -0
- package/dist/markdownCharacterReferences.js +1127 -0
- package/dist/markdownCodeFence.d.ts +4 -0
- package/dist/markdownCodeFence.d.ts.map +1 -0
- package/dist/markdownCodeFence.js +167 -0
- package/dist/markdownExtensionNode.d.ts +88 -0
- package/dist/markdownExtensionNode.d.ts.map +1 -0
- package/dist/markdownExtensionNode.js +248 -0
- package/dist/markdownExtensions.d.ts +115 -0
- package/dist/markdownExtensions.d.ts.map +1 -0
- package/dist/markdownExtensions.js +392 -0
- package/dist/markdownHardLineBreak.d.ts +25 -0
- package/dist/markdownHardLineBreak.d.ts.map +1 -0
- package/dist/markdownHardLineBreak.js +59 -0
- package/dist/markdownHeading.d.ts +20 -0
- package/dist/markdownHeading.d.ts.map +1 -0
- package/dist/markdownHeading.js +30 -0
- package/dist/markdownLink.d.ts +5 -0
- package/dist/markdownLink.d.ts.map +1 -0
- package/dist/markdownLink.js +80 -0
- package/dist/markdownListExport.d.ts +3 -0
- package/dist/markdownListExport.d.ts.map +1 -0
- package/dist/markdownListExport.js +68 -0
- package/dist/markdownListIndentation.d.ts +8 -0
- package/dist/markdownListIndentation.d.ts.map +1 -0
- package/dist/markdownListIndentation.js +101 -0
- package/dist/markdownOrderedList.d.ts +3 -0
- package/dist/markdownOrderedList.d.ts.map +1 -0
- package/dist/markdownOrderedList.js +51 -0
- package/dist/markdownQuote.d.ts +22 -0
- package/dist/markdownQuote.d.ts.map +1 -0
- package/dist/markdownQuote.js +32 -0
- package/dist/markdownSerializers.d.ts +49 -0
- package/dist/markdownSerializers.d.ts.map +1 -0
- package/dist/markdownSerializers.js +83 -0
- package/dist/markdownSource.d.ts +71 -0
- package/dist/markdownSource.d.ts.map +1 -0
- package/dist/markdownSource.js +804 -0
- package/dist/markdownSpaceRuns.d.ts +24 -0
- package/dist/markdownSpaceRuns.d.ts.map +1 -0
- package/dist/markdownSpaceRuns.js +147 -0
- package/dist/markdownTable.d.ts +35 -0
- package/dist/markdownTable.d.ts.map +1 -0
- package/dist/markdownTable.js +227 -0
- package/dist/markdownTaskList.d.ts +48 -0
- package/dist/markdownTaskList.d.ts.map +1 -0
- package/dist/markdownTaskList.js +166 -0
- package/dist/markdownThematicBreak.d.ts +3 -0
- package/dist/markdownThematicBreak.d.ts.map +1 -0
- package/dist/markdownThematicBreak.js +55 -0
- package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts +12 -0
- package/dist/privateAttributes/layerPortalBoundary.stylex.d.ts.map +1 -0
- package/dist/privateAttributes/layerPortalBoundary.stylex.js +16 -0
- package/dist/richtext.css +213 -0
- package/dist/textSemantics.d.ts +3 -0
- package/dist/textSemantics.d.ts.map +1 -0
- package/dist/textSemantics.js +87 -0
- package/package.json +158 -3
- package/src/RichTextEditor.doc.mjs +308 -0
- package/src/RichTextEditor.tsx +1109 -0
- package/src/RichTextEditorAutoLinkPlugin.doc.mjs +43 -0
- package/src/RichTextEditorAutoLinkPlugin.tsx +126 -0
- package/src/RichTextEditorToolbar.doc.mjs +92 -0
- package/src/RichTextEditorToolbar.tsx +871 -0
- package/src/RichTextView.doc.mjs +101 -0
- package/src/RichTextView.tsx +351 -0
- package/src/codeLanguages.ts +42 -0
- package/src/codeLineTokens.ts +241 -0
- package/src/doc-mjs.d.ts +23 -0
- package/src/editorNodes.ts +44 -0
- package/src/editorTheme.ts +550 -0
- package/src/index.ts +59 -0
- package/src/internal/CodeBackspacePlugin.tsx +96 -0
- package/src/internal/CodeBlockHeaderPlugin.tsx +282 -0
- package/src/internal/CodePastePlugin.tsx +144 -0
- package/src/internal/CodeSyntaxPlugin.tsx +493 -0
- package/src/internal/CodeTabPlugin.tsx +298 -0
- package/src/internal/DisabledLinkGuardPlugin.tsx +93 -0
- package/src/internal/ImageSourceCaretPlugin.tsx +159 -0
- package/src/internal/ListItemShortcutsPlugin.tsx +404 -0
- package/src/internal/MarkdownExtensionsPlugin.tsx +289 -0
- package/src/internal/SelectAllScopePlugin.tsx +86 -0
- package/src/internal/TableColumnFloorPlugin.tsx +99 -0
- package/src/internal/TablePastePlugin.tsx +223 -0
- package/src/internal/TableScrollRegionPlugin.tsx +107 -0
- package/src/internal/TaskCheckboxPlugin.tsx +246 -0
- package/src/internal/ToolbarLinkPopover.tsx +392 -0
- package/src/linkEditing.ts +20 -0
- package/src/linkForm.tsx +156 -0
- package/src/linkUtils.ts +106 -0
- package/src/markdown.ts +25 -0
- package/src/markdownCharacterReferences.ts +1137 -0
- package/src/markdownCodeFence.ts +216 -0
- package/src/markdownExtensionNode.ts +339 -0
- package/src/markdownExtensions.ts +496 -0
- package/src/markdownHardLineBreak.ts +73 -0
- package/src/markdownHeading.ts +33 -0
- package/src/markdownLink.ts +86 -0
- package/src/markdownListExport.ts +85 -0
- package/src/markdownListIndentation.ts +105 -0
- package/src/markdownOrderedList.ts +60 -0
- package/src/markdownQuote.ts +35 -0
- package/src/markdownSerializers.ts +108 -0
- package/src/markdownSource.ts +1045 -0
- package/src/markdownSpaceRuns.ts +164 -0
- package/src/markdownTable.ts +309 -0
- package/src/markdownTaskList.ts +222 -0
- package/src/markdownThematicBreak.ts +66 -0
- package/src/privateAttributes/layerPortalBoundary.stylex.ts +18 -0
- package/src/textSemantics.ts +104 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/** @type {import('@blue-patterns/cli/authoring').ComponentDoc} */
|
|
4
|
+
|
|
5
|
+
export const docs = {
|
|
6
|
+
name: 'RichTextView',
|
|
7
|
+
displayName: 'Rich Text View',
|
|
8
|
+
category: 'Content',
|
|
9
|
+
keywords: [
|
|
10
|
+
'richtext',
|
|
11
|
+
'rich text',
|
|
12
|
+
'viewer',
|
|
13
|
+
'read-only',
|
|
14
|
+
'lexical',
|
|
15
|
+
'render',
|
|
16
|
+
'serialized',
|
|
17
|
+
],
|
|
18
|
+
props: [
|
|
19
|
+
{
|
|
20
|
+
name: 'value',
|
|
21
|
+
type: 'string',
|
|
22
|
+
description:
|
|
23
|
+
'Serialized editor state to render: the JSON string from JSON.stringify(editorState.toJSON()). The view updates when value changes.',
|
|
24
|
+
required: true,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'nodes',
|
|
28
|
+
type: 'ReadonlyArray<Klass<LexicalNode>>',
|
|
29
|
+
description:
|
|
30
|
+
'Lexical nodes to register beyond the default set. Pass the same nodes the content was authored with, so custom node types deserialize.',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'markdownExtensions',
|
|
34
|
+
type: 'ReadonlyArray<RichTextMarkdownExtension>',
|
|
35
|
+
description:
|
|
36
|
+
'Markdown plugins whose nodes this view draws, each adopted with createRichTextExtension. A plugin node renders as core Markdown renders it; a node whose plugin is not passed shows its source. Pass the extensions the content was converted with, created in a client module.',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'codeLanguages',
|
|
40
|
+
type: 'ReadonlyArray<CodeLanguage>',
|
|
41
|
+
description:
|
|
42
|
+
"CodeBlock languages a fence may name, such as graphql from @blue-patterns/core/CodeBlock/languages/graphql. Pass the same list as Markdown's codeLanguages, so reading and editing label and paint the same languages.",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'plugins',
|
|
46
|
+
type: 'ReactNode',
|
|
47
|
+
description:
|
|
48
|
+
'Read-only Lexical plugins to render inside the view, such as hover cards or decorators.',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'namespace',
|
|
52
|
+
type: 'string',
|
|
53
|
+
description: 'The Lexical composer namespace.',
|
|
54
|
+
default: "'astryx-view'",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'onParseError',
|
|
58
|
+
type: '(error: Error) => void',
|
|
59
|
+
description:
|
|
60
|
+
'Called when value cannot be parsed or rendered, for example malformed JSON or a node type that is not registered through nodes. Use it to log or report the failure.',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'errorFallback',
|
|
64
|
+
type: 'ReactNode',
|
|
65
|
+
description:
|
|
66
|
+
'What to render when value cannot be parsed or rendered. The view renders it instead of throwing.',
|
|
67
|
+
default: 'null',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'xstyle',
|
|
71
|
+
type: 'StyleXStyles',
|
|
72
|
+
description: 'StyleX styles for the view root.',
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
usage: {
|
|
76
|
+
description:
|
|
77
|
+
'Renders serialized RichTextEditor content read-only, with the same nodes, tables, code blocks and Markdown plugin nodes the editor draws. Ships from @blue-patterns/richtext, with lexical and @lexical/* as optional peer dependencies.',
|
|
78
|
+
bestPractices: [
|
|
79
|
+
{
|
|
80
|
+
guidance: true,
|
|
81
|
+
description:
|
|
82
|
+
'Store editorState.toJSON() from RichTextEditor onChange, and pass the stringified JSON as value.',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
guidance: true,
|
|
86
|
+
description:
|
|
87
|
+
'Register the same custom nodes and markdownExtensions on the editor and the view, so stored content round-trips.',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
guidance: true,
|
|
91
|
+
description:
|
|
92
|
+
'Pass onParseError and an errorFallback when the stored content can come from an older or a foreign schema. By default a parse failure renders nothing instead of throwing.',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
guidance: false,
|
|
96
|
+
description:
|
|
97
|
+
'Use RichTextView to render Markdown source; use core Markdown for that.',
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
};
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file RichTextView.tsx
|
|
7
|
+
* @input Uses React, Lexical (lexical + @lexical/react, composed through
|
|
8
|
+
* LexicalExtensionComposer), mergeProps from core utils, design tokens,
|
|
9
|
+
* TableScrollRegionPlugin
|
|
10
|
+
* @output Exports RichTextView component and RichTextViewProps
|
|
11
|
+
* @position Read-only renderer for serialized Lexical editor state, exposed to
|
|
12
|
+
* assistive technology as document content rather than a form field;
|
|
13
|
+
* exported from @blue-patterns/richtext
|
|
14
|
+
*
|
|
15
|
+
* SYNC: When modified, update these files to stay in sync:
|
|
16
|
+
* - /packages/richtext/src/RichTextEditor.test.tsx
|
|
17
|
+
* - /packages/richtext/src/index.ts
|
|
18
|
+
* - /apps/storybook/stories/RichTextEditor.stories.tsx
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {useEffect, useRef, useState, type ReactNode} from 'react';
|
|
22
|
+
import * as stylex from '@stylexjs/stylex';
|
|
23
|
+
import {sharedEditorTheme} from './editorTheme';
|
|
24
|
+
import type {BaseProps} from '@blue-patterns/core';
|
|
25
|
+
import {mergeProps} from '@blue-patterns/core/utils';
|
|
26
|
+
import {
|
|
27
|
+
colorVars,
|
|
28
|
+
typeScaleVars,
|
|
29
|
+
typographyVars,
|
|
30
|
+
} from '@blue-patterns/core/theme/tokens.stylex';
|
|
31
|
+
|
|
32
|
+
import {LexicalExtensionComposer} from '@lexical/react/LexicalExtensionComposer';
|
|
33
|
+
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
|
34
|
+
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
|
|
35
|
+
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
|
|
36
|
+
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
|
|
37
|
+
import {TablePlugin} from '@lexical/react/LexicalTablePlugin';
|
|
38
|
+
import {TableScrollRegionPlugin} from './internal/TableScrollRegionPlugin';
|
|
39
|
+
import {TableColumnFloorPlugin} from './internal/TableColumnFloorPlugin';
|
|
40
|
+
import {CodeBlockHeaderPlugin} from './internal/CodeBlockHeaderPlugin';
|
|
41
|
+
import {CodeSyntaxPlugin} from './internal/CodeSyntaxPlugin';
|
|
42
|
+
import {MarkdownExtensionsPlugin} from './internal/MarkdownExtensionsPlugin';
|
|
43
|
+
import type {RichTextMarkdownExtension} from './markdownExtensions';
|
|
44
|
+
import type {CodeLanguage} from '@blue-patterns/core/CodeBlock';
|
|
45
|
+
import {TaskCheckboxPlugin} from './internal/TaskCheckboxPlugin';
|
|
46
|
+
import type {
|
|
47
|
+
AnyLexicalExtension,
|
|
48
|
+
Klass,
|
|
49
|
+
LexicalNode,
|
|
50
|
+
EditorThemeClasses,
|
|
51
|
+
} from 'lexical';
|
|
52
|
+
import {defineExtension} from 'lexical';
|
|
53
|
+
import {TextSemanticsExtension} from './textSemantics';
|
|
54
|
+
// The same node set as the editor, so anything it writes renders here.
|
|
55
|
+
import {DEFAULT_NODES} from './editorNodes';
|
|
56
|
+
|
|
57
|
+
const styles = stylex.create({
|
|
58
|
+
root: {
|
|
59
|
+
width: '100%',
|
|
60
|
+
// Holds the code block headers and task checkboxes drawn over the
|
|
61
|
+
// content.
|
|
62
|
+
position: 'relative',
|
|
63
|
+
// The document's body text, as core Markdown and the editor set it
|
|
64
|
+
// (spec:AST-061 FR2). Without it, blocks the theme leaves unsized —
|
|
65
|
+
// list items, quotes, table cells — take the host page's font.
|
|
66
|
+
fontFamily: typographyVars['--font-family-body'],
|
|
67
|
+
fontSize: typeScaleVars['--text-body-size'],
|
|
68
|
+
lineHeight: typeScaleVars['--text-body-leading'],
|
|
69
|
+
color: colorVars['--color-text-primary'],
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Lexical's `ContentEditable` renders `role="textbox"` and widget-only ARIA
|
|
75
|
+
* (`aria-autocomplete`, `aria-readonly`) even when the editor is not editable.
|
|
76
|
+
* A view renders document content, not a form field, so all three are cleared:
|
|
77
|
+
*
|
|
78
|
+
* - A textbox is a widget, so an unnamed one fails axe
|
|
79
|
+
* `aria-input-field-name` — and a view has no name to give, because it is
|
|
80
|
+
* content rather than a labelled input.
|
|
81
|
+
* - The widget role exposes the rendered document to assistive technology
|
|
82
|
+
* as a form field rather than as the content it is.
|
|
83
|
+
* - Widget-only ARIA is invalid without a widget role, so clearing only the
|
|
84
|
+
* role would trade `aria-input-field-name` for `aria-allowed-attr`.
|
|
85
|
+
*
|
|
86
|
+
* `role` must be `null`, not `undefined`: Lexical substitutes its `textbox`
|
|
87
|
+
* default for `undefined`. The ARIA keys reach the element through Lexical's
|
|
88
|
+
* trailing prop spread, which is why `undefined` clears them. A proposed
|
|
89
|
+
* upstream fix (facebook/lexical#9270) would make these overrides no-ops.
|
|
90
|
+
*
|
|
91
|
+
* A caller who wants read-only form-field semantics instead wants
|
|
92
|
+
* `<RichTextEditor isReadOnly />`, which is a labelled field by construction.
|
|
93
|
+
*/
|
|
94
|
+
const VIEW_CONTENT_EDITABLE_PROPS = {
|
|
95
|
+
'aria-autocomplete': undefined,
|
|
96
|
+
'aria-readonly': undefined,
|
|
97
|
+
role: null as unknown as undefined,
|
|
98
|
+
} as const;
|
|
99
|
+
|
|
100
|
+
/** The view imports and exports no Markdown, so it uses no transformers. */
|
|
101
|
+
const NO_TRANSFORMERS: ReadonlyArray<never> = [];
|
|
102
|
+
|
|
103
|
+
export interface RichTextViewProps extends BaseProps {
|
|
104
|
+
/**
|
|
105
|
+
* Serialized editor state to render (a JSON string produced by
|
|
106
|
+
* `JSON.stringify(editorState.toJSON())`).
|
|
107
|
+
*/
|
|
108
|
+
value: string;
|
|
109
|
+
/**
|
|
110
|
+
* Additional Lexical nodes to register beyond the default OSS set. Must match
|
|
111
|
+
* the nodes used to author `value` so custom node types deserialize.
|
|
112
|
+
*/
|
|
113
|
+
nodes?: ReadonlyArray<Klass<LexicalNode>>;
|
|
114
|
+
/**
|
|
115
|
+
* Markdown plugins whose nodes this surface draws, each adopted with
|
|
116
|
+
* `createRichTextExtension` (spec:AST-064). A plugin node renders exactly as
|
|
117
|
+
* core `Markdown` renders it, and one whose plugin is not given here shows
|
|
118
|
+
* its source. Pass the extensions the content was converted with. Create
|
|
119
|
+
* them in a client module: they hold the plugins' functions, so they are not
|
|
120
|
+
* serializable props.
|
|
121
|
+
*/
|
|
122
|
+
markdownExtensions?: ReadonlyArray<RichTextMarkdownExtension>;
|
|
123
|
+
/**
|
|
124
|
+
* CodeBlock language values a fence may name, each imported on its own,
|
|
125
|
+
* such as `graphql` from `@blue-patterns/core/CodeBlock/languages/graphql`,
|
|
126
|
+
* or made with `createCodeLanguage`. Pass the same list as `Markdown`'s
|
|
127
|
+
* `codeLanguages`, so reading and editing paint and label the same
|
|
128
|
+
* languages (spec:AST-061).
|
|
129
|
+
*/
|
|
130
|
+
codeLanguages?: ReadonlyArray<CodeLanguage>;
|
|
131
|
+
/**
|
|
132
|
+
* Additional read-only plugins to render inside the composer (e.g. hover
|
|
133
|
+
* cards, decorators).
|
|
134
|
+
*/
|
|
135
|
+
plugins?: ReactNode;
|
|
136
|
+
/** The Lexical composer namespace. @default 'astryx-view' */
|
|
137
|
+
namespace?: string;
|
|
138
|
+
/**
|
|
139
|
+
* Called when `value` cannot be parsed/rendered (e.g. malformed JSON, or
|
|
140
|
+
* state authored with node types not registered via `nodes`). A read-only
|
|
141
|
+
* view renders *persisted* content — exactly where stale or foreign-schema
|
|
142
|
+
* state shows up — so by default a parse failure renders `errorFallback`
|
|
143
|
+
* instead of throwing and taking down the host. Provide `onParseError` to log or
|
|
144
|
+
* report it.
|
|
145
|
+
*/
|
|
146
|
+
onParseError?: (error: Error) => void;
|
|
147
|
+
/**
|
|
148
|
+
* What to render when `value` fails to parse/render. Defaults to `null`
|
|
149
|
+
* (renders nothing). Pass a node to show a placeholder/empty state.
|
|
150
|
+
* @default null
|
|
151
|
+
*/
|
|
152
|
+
errorFallback?: ReactNode;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Keeps the rendered content in sync with the `value` prop after mount.
|
|
157
|
+
*
|
|
158
|
+
* The editor's initial state is seeded once, when the composer mounts, so a
|
|
159
|
+
* plain `<RichTextView value={changingValue} />` would freeze at its first
|
|
160
|
+
* value — the content would never update when `value` changed. This plugin runs
|
|
161
|
+
* inside the composer context and re-applies `value` whenever it changes, so the
|
|
162
|
+
* read-only view stays reactive (e.g. previewing content edited elsewhere).
|
|
163
|
+
*
|
|
164
|
+
* The initial `value` is already applied via the extension's
|
|
165
|
+
* `$initialEditorState`, so we skip the first run to avoid a redundant re-parse
|
|
166
|
+
* on mount.
|
|
167
|
+
*
|
|
168
|
+
* This mirrors the canonical Lexical pattern for applying externally-sourced
|
|
169
|
+
* serialized state after mount: the Lexical Playground's ActionsPlugin does the
|
|
170
|
+
* same `editor.setEditorState(editor.parseEditorState(...))` from inside a
|
|
171
|
+
* plugin (see facebook/lexical
|
|
172
|
+
* packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx). It is
|
|
173
|
+
* necessary because the composer builds the editor once and reads the initial
|
|
174
|
+
* state exactly once at that point, so a changed prop cannot re-seed the editor
|
|
175
|
+
* on its own. A read-only view has no history, so we skip the Playground's
|
|
176
|
+
* accompanying CLEAR_HISTORY_COMMAND.
|
|
177
|
+
*
|
|
178
|
+
* `parseEditorState` / `setEditorState` are methods on the editor instance, so
|
|
179
|
+
* this plugin needs no module-level Lexical imports of its own.
|
|
180
|
+
*/
|
|
181
|
+
function SyncValuePlugin({value}: {value: string}): null {
|
|
182
|
+
const [editor] = useLexicalComposerContext();
|
|
183
|
+
const isFirstRun = useRef(true);
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (isFirstRun.current) {
|
|
186
|
+
isFirstRun.current = false;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
editor.setEditorState(editor.parseEditorState(value));
|
|
190
|
+
}, [editor, value]);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* A read-only renderer for serialized Lexical content. Renders the same styled
|
|
196
|
+
* output as {@link RichTextEditor} without any editing affordances.
|
|
197
|
+
*
|
|
198
|
+
* The output is document content, not a form field: headings, lists and links
|
|
199
|
+
* keep their own roles for assistive technology, and the view takes no label.
|
|
200
|
+
* For a labelled, read-only field, use `<RichTextEditor isReadOnly />`. A
|
|
201
|
+
* table wider than the view scrolls inside a region named "Table" that takes a
|
|
202
|
+
* tab stop while it overflows, as core `Table` does.
|
|
203
|
+
*
|
|
204
|
+
* @example
|
|
205
|
+
* ```
|
|
206
|
+
* import {RichTextView} from '@blue-patterns/richtext';
|
|
207
|
+
* <RichTextView value={storedEditorStateJSON} />
|
|
208
|
+
* ```
|
|
209
|
+
*/
|
|
210
|
+
export function RichTextView({
|
|
211
|
+
value,
|
|
212
|
+
nodes,
|
|
213
|
+
markdownExtensions,
|
|
214
|
+
codeLanguages,
|
|
215
|
+
plugins,
|
|
216
|
+
namespace = 'astryx-view',
|
|
217
|
+
onParseError,
|
|
218
|
+
errorFallback = null,
|
|
219
|
+
xstyle,
|
|
220
|
+
className,
|
|
221
|
+
style,
|
|
222
|
+
...rest
|
|
223
|
+
}: RichTextViewProps) {
|
|
224
|
+
const themeRef = useRef<EditorThemeClasses | null>(null);
|
|
225
|
+
if (themeRef.current === null) {
|
|
226
|
+
themeRef.current = sharedEditorTheme();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Built on first render (and again after an error, below) rather than per
|
|
230
|
+
// render: LexicalExtensionComposer re-creates the editor whenever the
|
|
231
|
+
// extension's identity changes, and `value` updates are applied in place by
|
|
232
|
+
// SyncValuePlugin instead.
|
|
233
|
+
const extensionRef = useRef<AnyLexicalExtension | null>(null);
|
|
234
|
+
|
|
235
|
+
const [hasError, setHasError] = useState(false);
|
|
236
|
+
|
|
237
|
+
// Reset the error state when the value changes so a corrected value recovers.
|
|
238
|
+
const lastValueRef = useRef(value);
|
|
239
|
+
if (lastValueRef.current !== value && hasError) {
|
|
240
|
+
lastValueRef.current = value;
|
|
241
|
+
setHasError(false);
|
|
242
|
+
} else {
|
|
243
|
+
lastValueRef.current = value;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// A parse failure is recorded during render (below, or from Lexical's
|
|
247
|
+
// `onError` while the composer builds the editor) but reported to the
|
|
248
|
+
// consumer only after commit. Updating this component's own state during
|
|
249
|
+
// render is allowed; running a consumer's callback is not — a callback that
|
|
250
|
+
// updates its own component would do so during another component's render,
|
|
251
|
+
// and StrictMode or a discarded concurrent render would repeat it or run it
|
|
252
|
+
// for output that never commits. This Effect synchronizes the consumer's
|
|
253
|
+
// `onParseError`, reporting each distinct failure exactly once.
|
|
254
|
+
const pendingErrorRef = useRef<Error | null>(null);
|
|
255
|
+
const reportedErrorRef = useRef<Error | null>(null);
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
const error = pendingErrorRef.current;
|
|
258
|
+
if (error !== null && error !== reportedErrorRef.current) {
|
|
259
|
+
reportedErrorRef.current = error;
|
|
260
|
+
onParseError?.(error);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const handleError = (error: Error) => {
|
|
265
|
+
pendingErrorRef.current = error;
|
|
266
|
+
setHasError(true);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// Validate `value` parses as JSON before handing it to Lexical. Malformed
|
|
270
|
+
// JSON would otherwise throw synchronously while the composer builds the
|
|
271
|
+
// editor and escape any error boundary, taking down the host on the render
|
|
272
|
+
// path.
|
|
273
|
+
if (!hasError) {
|
|
274
|
+
try {
|
|
275
|
+
JSON.parse(value);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
handleError(err instanceof Error ? err : new Error(String(err)));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (hasError) {
|
|
282
|
+
// The composer subtree is unmounted while the fallback renders. Drop the
|
|
283
|
+
// extension so that recovering from the error builds a fresh one, seeded
|
|
284
|
+
// with the corrected `value`.
|
|
285
|
+
extensionRef.current = null;
|
|
286
|
+
return (
|
|
287
|
+
<div
|
|
288
|
+
{...mergeProps(stylex.props(styles.root, xstyle), className, style)}
|
|
289
|
+
{...rest}>
|
|
290
|
+
{errorFallback}
|
|
291
|
+
</div>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (extensionRef.current === null) {
|
|
296
|
+
extensionRef.current = defineExtension({
|
|
297
|
+
name: '@blue-patterns/richtext/RichTextView',
|
|
298
|
+
namespace,
|
|
299
|
+
theme: themeRef.current,
|
|
300
|
+
editable: false,
|
|
301
|
+
nodes: nodes ? [...DEFAULT_NODES, ...nodes] : [...DEFAULT_NODES],
|
|
302
|
+
// Struck text is a deletion.
|
|
303
|
+
dependencies: [TextSemanticsExtension],
|
|
304
|
+
$initialEditorState: value,
|
|
305
|
+
// A read-only view renders persisted content; a bad node/schema should not
|
|
306
|
+
// crash the host. Surface it via onParseError + fallback instead of re-throwing.
|
|
307
|
+
onError: handleError,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
<div
|
|
313
|
+
{...mergeProps(stylex.props(styles.root, xstyle), className, style)}
|
|
314
|
+
{...rest}>
|
|
315
|
+
<LexicalExtensionComposer
|
|
316
|
+
extension={extensionRef.current}
|
|
317
|
+
contentEditable={null}>
|
|
318
|
+
<SyncValuePlugin value={value} />
|
|
319
|
+
{/* Same table configuration as the editor, so a wide table scrolls
|
|
320
|
+
inside its own wrapper instead of widening the page. */}
|
|
321
|
+
<TablePlugin
|
|
322
|
+
hasCellMerge={false}
|
|
323
|
+
hasCellBackgroundColor={false}
|
|
324
|
+
hasTabHandler={false}
|
|
325
|
+
hasHorizontalScroll
|
|
326
|
+
/>
|
|
327
|
+
<TableScrollRegionPlugin />
|
|
328
|
+
<TableColumnFloorPlugin />
|
|
329
|
+
<RichTextPlugin
|
|
330
|
+
contentEditable={<ContentEditable {...VIEW_CONTENT_EDITABLE_PROPS} />}
|
|
331
|
+
placeholder={null}
|
|
332
|
+
ErrorBoundary={LexicalErrorBoundary}
|
|
333
|
+
/>
|
|
334
|
+
{/* After the content, so the copy buttons and checkboxes follow it
|
|
335
|
+
in tab order. */}
|
|
336
|
+
<CodeBlockHeaderPlugin codeLanguages={codeLanguages} />
|
|
337
|
+
<CodeSyntaxPlugin codeLanguages={codeLanguages} />
|
|
338
|
+
{markdownExtensions != null && markdownExtensions.length > 0 ? (
|
|
339
|
+
<MarkdownExtensionsPlugin
|
|
340
|
+
extensions={markdownExtensions}
|
|
341
|
+
transformers={NO_TRANSFORMERS}
|
|
342
|
+
/>
|
|
343
|
+
) : null}
|
|
344
|
+
<TaskCheckboxPlugin isReadOnly />
|
|
345
|
+
{plugins}
|
|
346
|
+
</LexicalExtensionComposer>
|
|
347
|
+
</div>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
RichTextView.displayName = 'RichTextView';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file codeLanguages.ts
|
|
5
|
+
* @input CodeLanguage values from @blue-patterns/core/CodeBlock
|
|
6
|
+
* @output listedLanguage, which finds the listed value a fence word names, and
|
|
7
|
+
* sameLanguages, which compares two lists entry by entry
|
|
8
|
+
* @position Shared by CodeSyntaxPlugin and CodeBlockHeaderPlugin, so RichText
|
|
9
|
+
* resolves a fence as Markdown does (component:Markdown FR30)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {CodeLanguage} from '@blue-patterns/core/CodeBlock';
|
|
13
|
+
|
|
14
|
+
/** The listed value whose identifiers include `word`; a later entry wins. */
|
|
15
|
+
export function listedLanguage(
|
|
16
|
+
languages: ReadonlyArray<CodeLanguage> | undefined,
|
|
17
|
+
word: string,
|
|
18
|
+
): CodeLanguage | undefined {
|
|
19
|
+
if (languages == null) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
for (let i = languages.length - 1; i >= 0; i--) {
|
|
23
|
+
if (languages[i].identifiers.includes(word)) {
|
|
24
|
+
return languages[i];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Whether two lists name the same values in the same order. */
|
|
31
|
+
export function sameLanguages(
|
|
32
|
+
a: ReadonlyArray<CodeLanguage> | undefined,
|
|
33
|
+
b: ReadonlyArray<CodeLanguage> | undefined,
|
|
34
|
+
): boolean {
|
|
35
|
+
if (a === b) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
if (a == null || b == null || a.length !== b.length) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return a.every((language, i) => language === b[i]);
|
|
42
|
+
}
|