feat(richtext-lexical)!: finalize ClientFeature interface (#6191)

**BREAKING:**
If you have own, custom lexical features, there will be a bunch of breaking API changes for you. The saved JSON data is not affected.

- `floatingSelectToolbar` has been changed to `toolbarInline`

- `slashMenu.dynamicOptions `and `slashMenu.options` have been changed to `slashMenu.groups` and `slashMenu.dynamicGroups`

- `toolbarFixed.sections` is now `toolbarFixed.groups`

- Slash menu group `options` and toolbar group `entries` have both been renamed to `items`

- Toolbar group item `onClick` has been renamed to `onSelect` to match slash menu properties

- slashMenu item `onSelect` is no longer auto-wrapped inside an `editor.update`. If you perform editor updates in them, you have to wrap it inside an `editor.update` callback yourself. Within our own features this extra control has removed a good amount of unnecessary, nested `editor.update` calls, which is good

- Slash menu items are no longer initialized using the `new` keyword, as they are now types and no longer classes. You can convert them to an object and add the `key` property as an object property instead of an argument to the previous SlashMenuItem constructor

- CSS classnames for slash menu and toolbars, as well as their items, have changed

- `CheckListFeature` is now exported as and has been renamed to `ChecklistFeature`

For guidance on migration, check out how we migrated our own features in this PR's diff: https://github.com/payloadcms/payload/pull/6191/files
This commit is contained in:
Alessio Gravili
2024-05-02 21:38:15 -04:00
committed by GitHub
parent f2d415663f
commit 7ab156e117
78 changed files with 951 additions and 892 deletions

View File

@@ -2,5 +2,5 @@ export { RichTextCell } from '../cell/index.js'
export { RichTextField } from '../field/index.js'
export { defaultEditorLexicalConfig } from '../field/lexical/config/client/default.js'
export { ToolbarButton } from '../field/lexical/plugins/FloatingSelectToolbar/ToolbarButton/index.js'
export { ToolbarDropdown } from '../field/lexical/plugins/FloatingSelectToolbar/ToolbarDropdown/index.js'
export { ToolbarButton } from '../field/lexical/plugins/toolbars/inline/ToolbarButton/index.js'
export { ToolbarDropdown } from '../field/lexical/plugins/toolbars/inline/ToolbarDropdown/index.js'

View File

@@ -9,58 +9,52 @@ import { AlignJustifyIcon } from '../../lexical/ui/icons/AlignJustify/index.js'
import { AlignLeftIcon } from '../../lexical/ui/icons/AlignLeft/index.js'
import { AlignRightIcon } from '../../lexical/ui/icons/AlignRight/index.js'
import { createClientComponent } from '../createClientComponent.js'
import { AlignDropdownSectionWithEntries } from './floatingSelectToolbarAlignDropdownSection.js'
import { alignGroupWithItems } from './inlineToolbarAlignGroup.js'
const AlignFeatureClient: FeatureProviderProviderClient<undefined> = (props) => {
return {
clientFeatureProps: props,
feature: () => ({
clientFeatureProps: props,
floatingSelectToolbar: {
sections: [
AlignDropdownSectionWithEntries([
toolbarInline: {
groups: [
alignGroupWithItems([
{
ChildComponent: AlignLeftIcon,
isActive: () => false,
key: 'align-left',
key: 'alignLeft',
label: `Align Left`,
onClick: ({ editor }) => {
onSelect: ({ editor }) => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left')
},
order: 1,
},
]),
AlignDropdownSectionWithEntries([
{
ChildComponent: AlignCenterIcon,
isActive: () => false,
key: 'align-center',
key: 'alignCenter',
label: `Align Center`,
onClick: ({ editor }) => {
onSelect: ({ editor }) => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center')
},
order: 2,
},
]),
AlignDropdownSectionWithEntries([
{
ChildComponent: AlignRightIcon,
isActive: () => false,
key: 'align-right',
key: 'alignRight',
label: `Align Right`,
onClick: ({ editor }) => {
onSelect: ({ editor }) => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right')
},
order: 3,
},
]),
AlignDropdownSectionWithEntries([
{
ChildComponent: AlignJustifyIcon,
isActive: () => false,
key: 'align-justify',
key: 'alignJustify',
label: `Align Justify`,
onClick: ({ editor }) => {
onSelect: ({ editor }) => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify')
},
order: 4,

View File

@@ -1,18 +0,0 @@
import type {
FloatingToolbarSection,
FloatingToolbarSectionEntry,
} from '../../lexical/plugins/FloatingSelectToolbar/types.js'
import { AlignLeftIcon } from '../../lexical/ui/icons/AlignLeft/index.js'
export const AlignDropdownSectionWithEntries = (
entries: FloatingToolbarSectionEntry[],
): FloatingToolbarSection => {
return {
type: 'dropdown',
ChildComponent: AlignLeftIcon,
entries,
key: 'dropdown-align',
order: 2,
}
}

View File

@@ -0,0 +1,16 @@
import type {
InlineToolbarGroup,
InlineToolbarGroupItem,
} from '../../lexical/plugins/toolbars/inline/types.js'
import { AlignLeftIcon } from '../../lexical/ui/icons/AlignLeft/index.js'
export const alignGroupWithItems = (items: InlineToolbarGroupItem[]): InlineToolbarGroup => {
return {
type: 'dropdown',
ChildComponent: AlignLeftIcon,
items,
key: 'align',
order: 2,
}
}

View File

@@ -6,10 +6,9 @@ import { $getSelection } from 'lexical'
import type { FeatureProviderProviderClient } from '../types.js'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.js'
import { BlockquoteIcon } from '../../lexical/ui/icons/Blockquote/index.js'
import { TextDropdownSectionWithEntries } from '../common/floatingSelectToolbarTextDropdownSection/index.js'
import { createClientComponent } from '../createClientComponent.js'
import { inlineToolbarTextDropdownGroupWithItems } from '../shared/inlineToolbar/textDropdownGroup.js'
import { MarkdownTransformer } from './markdownTransformer.js'
const BlockQuoteFeatureClient: FeatureProviderProviderClient<undefined> = (props) => {
@@ -17,15 +16,40 @@ const BlockQuoteFeatureClient: FeatureProviderProviderClient<undefined> = (props
clientFeatureProps: props,
feature: () => ({
clientFeatureProps: props,
floatingSelectToolbar: {
sections: [
TextDropdownSectionWithEntries([
markdownTransformers: [MarkdownTransformer],
nodes: [QuoteNode],
slashMenu: {
groups: [
{
displayName: 'Basic',
items: [
{
Icon: BlockquoteIcon,
displayName: 'Blockquote',
key: 'blockquote',
keywords: ['quote', 'blockquote'],
onSelect: ({ editor }) => {
editor.update(() => {
const selection = $getSelection()
$setBlocksType(selection, () => $createQuoteNode())
})
},
},
],
key: 'basic',
},
],
},
toolbarInline: {
groups: [
inlineToolbarTextDropdownGroupWithItems([
{
ChildComponent: BlockquoteIcon,
isActive: () => false,
key: 'blockquote',
label: `Blockquote`,
onClick: ({ editor }) => {
onSelect: ({ editor }) => {
editor.update(() => {
const selection = $getSelection()
$setBlocksType(selection, () => $createQuoteNode())
@@ -36,28 +60,6 @@ const BlockQuoteFeatureClient: FeatureProviderProviderClient<undefined> = (props
]),
],
},
markdownTransformers: [MarkdownTransformer],
nodes: [QuoteNode],
slashMenu: {
options: [
{
displayName: 'Basic',
key: 'basic',
options: [
new SlashMenuOption(`blockquote`, {
Icon: BlockquoteIcon,
displayName: `Blockquote`,
keywords: ['quote', 'blockquote'],
onSelect: () => {
const selection = $getSelection()
$setBlocksType(selection, () => $createQuoteNode())
},
}),
],
},
],
},
}),
}
}

View File

@@ -6,7 +6,6 @@ import { getTranslation } from '@payloadcms/translations'
import type { FeatureProviderProviderClient } from '../types.js'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.js'
import { BlockIcon } from '../../lexical/ui/icons/Block/index.js'
import { createClientComponent } from '../createClientComponent.js'
import { BlockNode } from './nodes/BlocksNode.js'
@@ -30,32 +29,31 @@ const BlocksFeatureClient: FeatureProviderProviderClient<BlocksFeatureClientProp
},
],
slashMenu: {
options: [
groups: [
{
displayName: 'Blocks',
key: 'blocks',
options: [
...props.reducedBlocks.map((block) => {
return new SlashMenuOption('block-' + block.slug, {
Icon: BlockIcon,
displayName: ({ i18n }) => {
if (!block.labels.singular) {
return block.slug
}
items: props.reducedBlocks.map((block) => {
return {
Icon: BlockIcon,
displayName: ({ i18n }) => {
if (!block.labels.singular) {
return block.slug
}
return getTranslation(block.labels.singular, i18n)
},
keywords: ['block', 'blocks', block.slug],
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_BLOCK_COMMAND, {
id: null,
blockName: '',
blockType: block.slug,
})
},
})
}),
],
return getTranslation(block.labels.singular, i18n)
},
key: 'block-' + block.slug,
keywords: ['block', 'blocks', block.slug],
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_BLOCK_COMMAND, {
id: null,
blockName: '',
blockType: block.slug,
})
},
}
}),
key: 'blocks',
},
],
},

View File

@@ -1,15 +0,0 @@
import type {
FloatingToolbarSection,
FloatingToolbarSectionEntry,
} from '../../../lexical/plugins/FloatingSelectToolbar/types.js'
export const FeaturesSectionWithEntries = (
entries: FloatingToolbarSectionEntry[],
): FloatingToolbarSection => {
return {
type: 'buttons',
entries,
key: 'features',
order: 5,
}
}

View File

@@ -1,18 +0,0 @@
import type {
FloatingToolbarSection,
FloatingToolbarSectionEntry,
} from '../../../lexical/plugins/FloatingSelectToolbar/types.js'
import { TextIcon } from '../../../lexical/ui/icons/Text/index.js'
export const TextDropdownSectionWithEntries = (
entries: FloatingToolbarSectionEntry[],
): FloatingToolbarSection => {
return {
type: 'dropdown',
ChildComponent: TextIcon,
entries,
key: 'dropdown-text',
order: 1,
}
}

View File

@@ -10,7 +10,7 @@ export const TestRecorderFeature: FeatureProviderProviderServer<undefined, undef
serverFeatureProps: props,
}
},
key: 'testrecorder',
key: 'testRecorder',
serverFeatureProps: props,
}
}

View File

@@ -10,7 +10,7 @@ export const TreeViewFeature: FeatureProviderProviderServer<undefined, undefined
serverFeatureProps: props,
}
},
key: 'treeview',
key: 'treeView',
serverFeatureProps: props,
}
}

View File

@@ -5,7 +5,7 @@ import type { FeatureProviderProviderClient } from '../../types.js'
import { BoldIcon } from '../../../lexical/ui/icons/Bold/index.js'
import { createClientComponent } from '../../createClientComponent.js'
import { SectionWithEntries } from '../common/floatingSelectToolbarSection.js'
import { inlineToolbarFormatGroupWithItems } from '../shared/inlineToolbarFormatGroup.js'
import {
BOLD_ITALIC_STAR,
BOLD_ITALIC_UNDERSCORE,
@@ -24,9 +24,10 @@ const BoldFeatureClient: FeatureProviderProviderClient<undefined> = (props) => {
return {
clientFeatureProps: props,
floatingSelectToolbar: {
sections: [
SectionWithEntries([
markdownTransformers,
toolbarInline: {
groups: [
inlineToolbarFormatGroupWithItems([
{
ChildComponent: BoldIcon,
isActive: ({ selection }) => {
@@ -36,7 +37,7 @@ const BoldFeatureClient: FeatureProviderProviderClient<undefined> = (props) => {
return false
},
key: 'bold',
onClick: ({ editor }) => {
onSelect: ({ editor }) => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
},
order: 1,
@@ -44,7 +45,6 @@ const BoldFeatureClient: FeatureProviderProviderClient<undefined> = (props) => {
]),
],
},
markdownTransformers,
}
},
}

View File

@@ -1,15 +0,0 @@
import type {
FloatingToolbarSection,
FloatingToolbarSectionEntry,
} from '../../../lexical/plugins/FloatingSelectToolbar/types.js'
export const SectionWithEntries = (
entries: FloatingToolbarSectionEntry[],
): FloatingToolbarSection => {
return {
type: 'buttons',
entries,
key: 'format',
order: 4,
}
}

View File

@@ -6,7 +6,7 @@ import type { FeatureProviderProviderClient } from '../../types.js'
import { CodeIcon } from '../../../lexical/ui/icons/Code/index.js'
import { createClientComponent } from '../../createClientComponent.js'
import { SectionWithEntries } from '../common/floatingSelectToolbarSection.js'
import { inlineToolbarFormatGroupWithItems } from '../shared/inlineToolbarFormatGroup.js'
import { INLINE_CODE } from './markdownTransformers.js'
const InlineCodeFeatureClient: FeatureProviderProviderClient<undefined> = (props) => {
@@ -15,9 +15,11 @@ const InlineCodeFeatureClient: FeatureProviderProviderClient<undefined> = (props
feature: () => {
return {
clientFeatureProps: props,
floatingSelectToolbar: {
sections: [
SectionWithEntries([
markdownTransformers: [INLINE_CODE],
toolbarInline: {
groups: [
inlineToolbarFormatGroupWithItems([
{
ChildComponent: CodeIcon,
isActive: ({ selection }) => {
@@ -26,8 +28,8 @@ const InlineCodeFeatureClient: FeatureProviderProviderClient<undefined> = (props
}
return false
},
key: 'code',
onClick: ({ editor }) => {
key: 'inlineCode',
onSelect: ({ editor }) => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code')
},
order: 7,
@@ -35,8 +37,6 @@ const InlineCodeFeatureClient: FeatureProviderProviderClient<undefined> = (props
]),
],
},
markdownTransformers: [INLINE_CODE],
}
},
}

View File

@@ -12,7 +12,7 @@ export const InlineCodeFeature: FeatureProviderProviderServer<undefined, undefin
serverFeatureProps: props,
}
},
key: 'inlinecode',
key: 'inlineCode',
serverFeatureProps: props,
}
}

View File

@@ -6,7 +6,7 @@ import type { FeatureProviderProviderClient } from '../../types.js'
import { ItalicIcon } from '../../../lexical/ui/icons/Italic/index.js'
import { createClientComponent } from '../../createClientComponent.js'
import { SectionWithEntries } from '../common/floatingSelectToolbarSection.js'
import { inlineToolbarFormatGroupWithItems } from '../shared/inlineToolbarFormatGroup.js'
import { ITALIC_STAR, ITALIC_UNDERSCORE } from './markdownTransformers.js'
const ItalicFeatureClient: FeatureProviderProviderClient<undefined> = (props) => {
@@ -16,9 +16,10 @@ const ItalicFeatureClient: FeatureProviderProviderClient<undefined> = (props) =>
return {
clientFeatureProps: props,
floatingSelectToolbar: {
sections: [
SectionWithEntries([
markdownTransformers: [ITALIC_STAR, ITALIC_UNDERSCORE],
toolbarInline: {
groups: [
inlineToolbarFormatGroupWithItems([
{
ChildComponent: ItalicIcon,
isActive: ({ selection }) => {
@@ -28,7 +29,7 @@ const ItalicFeatureClient: FeatureProviderProviderClient<undefined> = (props) =>
return false
},
key: 'italic',
onClick: ({ editor }) => {
onSelect: ({ editor }) => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
},
order: 2,
@@ -36,7 +37,6 @@ const ItalicFeatureClient: FeatureProviderProviderClient<undefined> = (props) =>
]),
],
},
markdownTransformers: [ITALIC_STAR, ITALIC_UNDERSCORE],
}
},
}

View File

@@ -0,0 +1,15 @@
import type {
InlineToolbarGroup,
InlineToolbarGroupItem,
} from '../../../lexical/plugins/toolbars/inline/types.js'
export const inlineToolbarFormatGroupWithItems = (
items: InlineToolbarGroupItem[],
): InlineToolbarGroup => {
return {
type: 'buttons',
items,
key: 'format',
order: 4,
}
}

View File

@@ -6,7 +6,7 @@ import type { FeatureProviderProviderClient } from '../../types.js'
import { StrikethroughIcon } from '../../../lexical/ui/icons/Strikethrough/index.js'
import { createClientComponent } from '../../createClientComponent.js'
import { SectionWithEntries } from '../common/floatingSelectToolbarSection.js'
import { inlineToolbarFormatGroupWithItems } from '../shared/inlineToolbarFormatGroup.js'
import { STRIKETHROUGH } from './markdownTransformers.js'
const StrikethroughFeatureClient: FeatureProviderProviderClient<undefined> = (props) => {
@@ -16,9 +16,10 @@ const StrikethroughFeatureClient: FeatureProviderProviderClient<undefined> = (pr
return {
clientFeatureProps: props,
floatingSelectToolbar: {
sections: [
SectionWithEntries([
markdownTransformers: [STRIKETHROUGH],
toolbarInline: {
groups: [
inlineToolbarFormatGroupWithItems([
{
ChildComponent: StrikethroughIcon,
isActive: ({ selection }) => {
@@ -28,7 +29,7 @@ const StrikethroughFeatureClient: FeatureProviderProviderClient<undefined> = (pr
return false
},
key: 'strikethrough',
onClick: ({ editor }) => {
onSelect: ({ editor }) => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')
},
order: 4,
@@ -36,7 +37,6 @@ const StrikethroughFeatureClient: FeatureProviderProviderClient<undefined> = (pr
]),
],
},
markdownTransformers: [STRIKETHROUGH],
}
},
}

View File

@@ -6,7 +6,7 @@ import type { FeatureProviderProviderClient } from '../../types.js'
import { SubscriptIcon } from '../../../lexical/ui/icons/Subscript/index.js'
import { createClientComponent } from '../../createClientComponent.js'
import { SectionWithEntries } from '../common/floatingSelectToolbarSection.js'
import { inlineToolbarFormatGroupWithItems } from '../shared/inlineToolbarFormatGroup.js'
const SubscriptFeatureClient: FeatureProviderProviderClient<undefined> = (props) => {
return {
@@ -14,9 +14,9 @@ const SubscriptFeatureClient: FeatureProviderProviderClient<undefined> = (props)
feature: () => {
return {
clientFeatureProps: props,
floatingSelectToolbar: {
sections: [
SectionWithEntries([
toolbarInline: {
groups: [
inlineToolbarFormatGroupWithItems([
{
ChildComponent: SubscriptIcon,
isActive: ({ selection }) => {
@@ -26,7 +26,7 @@ const SubscriptFeatureClient: FeatureProviderProviderClient<undefined> = (props)
return false
},
key: 'subscript',
onClick: ({ editor }) => {
onSelect: ({ editor }) => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript')
},
order: 5,

View File

@@ -6,7 +6,7 @@ import type { FeatureProviderProviderClient } from '../../types.js'
import { SuperscriptIcon } from '../../../lexical/ui/icons/Superscript/index.js'
import { createClientComponent } from '../../createClientComponent.js'
import { SectionWithEntries } from '../common/floatingSelectToolbarSection.js'
import { inlineToolbarFormatGroupWithItems } from '../shared/inlineToolbarFormatGroup.js'
const SuperscriptFeatureClient: FeatureProviderProviderClient<undefined> = (props) => {
return {
@@ -14,9 +14,9 @@ const SuperscriptFeatureClient: FeatureProviderProviderClient<undefined> = (prop
feature: () => {
return {
clientFeatureProps: props,
floatingSelectToolbar: {
sections: [
SectionWithEntries([
toolbarInline: {
groups: [
inlineToolbarFormatGroupWithItems([
{
ChildComponent: SuperscriptIcon,
isActive: ({ selection }) => {
@@ -26,7 +26,7 @@ const SuperscriptFeatureClient: FeatureProviderProviderClient<undefined> = (prop
return false
},
key: 'superscript',
onClick: ({ editor }) => {
onSelect: ({ editor }) => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript')
},
order: 6,

View File

@@ -6,7 +6,7 @@ import type { FeatureProviderProviderClient } from '../../types.js'
import { UnderlineIcon } from '../../../lexical/ui/icons/Underline/index.js'
import { createClientComponent } from '../../createClientComponent.js'
import { SectionWithEntries } from '../common/floatingSelectToolbarSection.js'
import { inlineToolbarFormatGroupWithItems } from '../shared/inlineToolbarFormatGroup.js'
const UnderlineFeatureClient: FeatureProviderProviderClient<undefined> = (props) => {
return {
@@ -14,9 +14,9 @@ const UnderlineFeatureClient: FeatureProviderProviderClient<undefined> = (props)
feature: () => {
return {
clientFeatureProps: props,
floatingSelectToolbar: {
sections: [
SectionWithEntries([
toolbarInline: {
groups: [
inlineToolbarFormatGroupWithItems([
{
ChildComponent: UnderlineIcon,
isActive: ({ selection }) => {
@@ -26,7 +26,7 @@ const UnderlineFeatureClient: FeatureProviderProviderClient<undefined> = (props)
return false
},
key: 'underline',
onClick: ({ editor }) => {
onSelect: ({ editor }) => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline')
},
order: 3,

View File

@@ -9,15 +9,14 @@ import { $getSelection } from 'lexical'
import type { FeatureProviderProviderClient } from '../types.js'
import type { HeadingFeatureProps } from './feature.server.js'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.js'
import { H1Icon } from '../../lexical/ui/icons/H1/index.js'
import { H2Icon } from '../../lexical/ui/icons/H2/index.js'
import { H3Icon } from '../../lexical/ui/icons/H3/index.js'
import { H4Icon } from '../../lexical/ui/icons/H4/index.js'
import { H5Icon } from '../../lexical/ui/icons/H5/index.js'
import { H6Icon } from '../../lexical/ui/icons/H6/index.js'
import { TextDropdownSectionWithEntries } from '../common/floatingSelectToolbarTextDropdownSection/index.js'
import { createClientComponent } from '../createClientComponent.js'
import { inlineToolbarTextDropdownGroupWithItems } from '../shared/inlineToolbar/textDropdownGroup.js'
import { MarkdownTransformer } from './markdownTransformer.js'
const setHeading = (headingSize: HeadingTagType) => {
@@ -42,47 +41,52 @@ const HeadingFeatureClient: FeatureProviderProviderClient<HeadingFeatureProps> =
feature: () => {
return {
clientFeatureProps: props,
floatingSelectToolbar: {
sections: [
...enabledHeadingSizes.map((headingSize, i) =>
TextDropdownSectionWithEntries([
{
ChildComponent: iconImports[headingSize],
isActive: () => false,
key: headingSize,
label: `Heading ${headingSize.charAt(1)}`,
onClick: ({ editor }) => {
editor.update(() => {
setHeading(headingSize)
})
},
order: i + 2,
},
]),
),
],
},
markdownTransformers: [MarkdownTransformer(enabledHeadingSizes)],
nodes: [HeadingNode],
slashMenu: {
options: [
...enabledHeadingSizes.map((headingSize) => {
return {
displayName: 'Basic',
key: 'basic',
options: [
new SlashMenuOption(`heading-${headingSize.charAt(1)}`, {
Icon: iconImports[headingSize],
displayName: `Heading ${headingSize.charAt(1)}`,
keywords: ['heading', headingSize],
onSelect: () => {
setHeading(headingSize)
},
groups: enabledHeadingSizes?.length
? [
{
displayName: 'Basic',
items: enabledHeadingSizes.map((headingSize) => {
return {
Icon: iconImports[headingSize],
displayName: `Heading ${headingSize.charAt(1)}`,
key: `heading-${headingSize.charAt(1)}`,
keywords: ['heading', headingSize],
onSelect: ({ editor }) => {
editor.update(() => {
setHeading(headingSize)
})
},
}
}),
],
}
}),
],
key: 'basic',
},
]
: [],
},
toolbarInline: {
groups: enabledHeadingSizes?.length
? [
inlineToolbarTextDropdownGroupWithItems(
enabledHeadingSizes.map((headingSize, i) => {
return {
ChildComponent: iconImports[headingSize],
isActive: () => false,
key: headingSize,
label: `Heading ${headingSize.charAt(1)}`,
onSelect: ({ editor }) => {
editor.update(() => {
setHeading(headingSize)
})
},
order: i + 2,
}
}),
),
]
: [],
},
}
},

View File

@@ -2,7 +2,6 @@
import type { FeatureProviderProviderClient } from '../types.js'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.js'
import { HorizontalRuleIcon } from '../../lexical/ui/icons/HorizontalRule/index.js'
import { createClientComponent } from '../createClientComponent.js'
import { MarkdownTransformer } from './markdownTransformer.js'
@@ -23,20 +22,21 @@ const HorizontalRuleFeatureClient: FeatureProviderProviderClient<undefined> = (p
},
],
slashMenu: {
options: [
groups: [
{
displayName: 'Basic',
key: 'basic',
options: [
new SlashMenuOption(`horizontalrule`, {
items: [
{
Icon: HorizontalRuleIcon,
displayName: `Horizontal Rule`,
key: 'horizontalRule',
keywords: ['hr', 'horizontal rule', 'line', 'separator'],
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined)
},
}),
},
],
key: 'basic',
},
],
},

View File

@@ -30,7 +30,7 @@ export const HorizontalRuleFeature: FeatureProviderProviderServer<undefined, und
serverFeatureProps: props,
}
},
key: 'horizontalrule',
key: 'horizontalRule',
serverFeatureProps: props,
}
}

View File

@@ -7,16 +7,16 @@ import type { FeatureProviderProviderClient } from '../types.js'
import { IndentDecreaseIcon } from '../../lexical/ui/icons/IndentDecrease/index.js'
import { IndentIncreaseIcon } from '../../lexical/ui/icons/IndentIncrease/index.js'
import { createClientComponent } from '../createClientComponent.js'
import { IndentSectionWithEntries } from './floatingSelectToolbarIndentSection.js'
import { indentGroupWithItems } from './inlineToolbarIndentGroup.js'
const IndentFeatureClient: FeatureProviderProviderClient<undefined> = (props) => {
return {
clientFeatureProps: props,
feature: () => ({
clientFeatureProps: props,
floatingSelectToolbar: {
sections: [
IndentSectionWithEntries([
toolbarInline: {
groups: [
indentGroupWithItems([
{
ChildComponent: IndentDecreaseIcon,
isActive: () => false,
@@ -37,21 +37,19 @@ const IndentFeatureClient: FeatureProviderProviderClient<undefined> = (props) =>
}
return false
},
key: 'indent-decrease',
key: 'indentDecrease',
label: `Decrease Indent`,
onClick: ({ editor }) => {
onSelect: ({ editor }) => {
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)
},
order: 1,
},
]),
IndentSectionWithEntries([
{
ChildComponent: IndentIncreaseIcon,
isActive: () => false,
key: 'indent-increase',
key: 'indentIncrease',
label: `Increase Indent`,
onClick: ({ editor }) => {
onSelect: ({ editor }) => {
editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)
},
order: 2,

View File

@@ -1,15 +0,0 @@
import type {
FloatingToolbarSection,
FloatingToolbarSectionEntry,
} from '../../lexical/plugins/FloatingSelectToolbar/types.js'
export const IndentSectionWithEntries = (
entries: FloatingToolbarSectionEntry[],
): FloatingToolbarSection => {
return {
type: 'buttons',
entries,
key: 'indent',
order: 3,
}
}

View File

@@ -1,4 +0,0 @@
.floating-select-toolbar-popup__section-indent {
display: flex;
gap: 2px;
}

View File

@@ -0,0 +1,13 @@
import type {
InlineToolbarGroup,
InlineToolbarGroupItem,
} from '../../lexical/plugins/toolbars/inline/types.js'
export const indentGroupWithItems = (items: InlineToolbarGroupItem[]): InlineToolbarGroup => {
return {
type: 'buttons',
items,
key: 'indent',
order: 3,
}
}

View File

@@ -9,8 +9,8 @@ import type { LinkFields } from './nodes/types.js'
import { LinkIcon } from '../../lexical/ui/icons/Link/index.js'
import { getSelectedNode } from '../../lexical/utils/getSelectedNode.js'
import { FeaturesSectionWithEntries } from '../common/floatingSelectToolbarFeaturesButtonsSection/index.js'
import { createClientComponent } from '../createClientComponent.js'
import { inlineToolbarFeatureButtonsGroupWithItems } from '../shared/inlineToolbar/featureButtonsGroup.js'
import { AutoLinkNode } from './nodes/AutoLinkNode.js'
import { $isLinkNode, LinkNode, TOGGLE_LINK_COMMAND } from './nodes/LinkNode.js'
import { AutoLinkPlugin } from './plugins/autoLink/index.js'
@@ -26,9 +26,28 @@ const LinkFeatureClient: FeatureProviderProviderClient<ClientProps> = (props) =>
clientFeatureProps: props,
feature: () => ({
clientFeatureProps: props,
floatingSelectToolbar: {
sections: [
FeaturesSectionWithEntries([
nodes: [LinkNode, AutoLinkNode],
plugins: [
{
Component: LinkPlugin,
position: 'normal',
},
{
Component: AutoLinkPlugin,
position: 'normal',
},
{
Component: ClickableLinkPlugin,
position: 'normal',
},
{
Component: FloatingLinkEditorPlugin,
position: 'floatingAnchorElem',
},
],
toolbarInline: {
groups: [
inlineToolbarFeatureButtonsGroupWithItems([
{
ChildComponent: LinkIcon,
isActive: ({ selection }) => {
@@ -41,7 +60,7 @@ const LinkFeatureClient: FeatureProviderProviderClient<ClientProps> = (props) =>
},
key: 'link',
label: `Link`,
onClick: ({ editor, isActive }) => {
onSelect: ({ editor, isActive }) => {
if (!isActive) {
let selectedText = null
editor.getEditorState().read(() => {
@@ -67,25 +86,6 @@ const LinkFeatureClient: FeatureProviderProviderClient<ClientProps> = (props) =>
]),
],
},
nodes: [LinkNode, AutoLinkNode],
plugins: [
{
Component: LinkPlugin,
position: 'normal',
},
{
Component: AutoLinkPlugin,
position: 'normal',
},
{
Component: ClickableLinkPlugin,
position: 'normal',
},
{
Component: FloatingLinkEditorPlugin,
position: 'floatingAnchorElem',
},
],
}),
}
}

View File

@@ -3,15 +3,14 @@ import { INSERT_CHECK_LIST_COMMAND, ListItemNode, ListNode } from '@lexical/list
import type { ClientFeature, FeatureProviderProviderClient } from '../../types.js'
import { SlashMenuOption } from '../../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.js'
import { ChecklistIcon } from '../../../lexical/ui/icons/Checklist/index.js'
import { TextDropdownSectionWithEntries } from '../../common/floatingSelectToolbarTextDropdownSection/index.js'
import { createClientComponent } from '../../createClientComponent.js'
import { inlineToolbarTextDropdownGroupWithItems } from '../../shared/inlineToolbar/textDropdownGroup.js'
import { LexicalListPlugin } from '../plugin/index.js'
import { CHECK_LIST } from './markdownTransformers.js'
import { LexicalCheckListPlugin } from './plugin/index.js'
const CheckListFeatureClient: FeatureProviderProviderClient<undefined> = (props) => {
const ChecklistFeatureClient: FeatureProviderProviderClient<undefined> = (props) => {
return {
clientFeatureProps: props,
feature: ({ featureProviderMap }) => {
@@ -22,7 +21,7 @@ const CheckListFeatureClient: FeatureProviderProviderClient<undefined> = (props)
},
]
if (!featureProviderMap.has('unorderedlist') && !featureProviderMap.has('orderedlist')) {
if (!featureProviderMap.has('unorderedList') && !featureProviderMap.has('orderedList')) {
plugins.push({
Component: LexicalListPlugin,
position: 'normal',
@@ -31,15 +30,40 @@ const CheckListFeatureClient: FeatureProviderProviderClient<undefined> = (props)
return {
clientFeatureProps: props,
floatingSelectToolbar: {
sections: [
TextDropdownSectionWithEntries([
markdownTransformers: [CHECK_LIST],
nodes:
featureProviderMap.has('unorderedList') || featureProviderMap.has('orderedList')
? []
: [ListNode, ListItemNode],
plugins,
slashMenu: {
groups: [
{
displayName: 'Lists',
items: [
{
Icon: ChecklistIcon,
displayName: 'Check List',
key: 'checklist',
keywords: ['check list', 'check', 'checklist', 'cl'],
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined)
},
},
],
key: 'lists',
},
],
},
toolbarInline: {
groups: [
inlineToolbarTextDropdownGroupWithItems([
{
ChildComponent: ChecklistIcon,
isActive: () => false,
key: 'checkList',
key: 'checklist',
label: `Check List`,
onClick: ({ editor }) => {
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined)
},
order: 12,
@@ -47,33 +71,9 @@ const CheckListFeatureClient: FeatureProviderProviderClient<undefined> = (props)
]),
],
},
markdownTransformers: [CHECK_LIST],
nodes:
featureProviderMap.has('unorderedlist') || featureProviderMap.has('orderedlist')
? []
: [ListNode, ListItemNode],
plugins,
slashMenu: {
options: [
{
displayName: 'Lists',
key: 'lists',
options: [
new SlashMenuOption('checklist', {
Icon: ChecklistIcon,
displayName: 'Check List',
keywords: ['check list', 'check', 'checklist', 'cl'],
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined)
},
}),
],
},
],
},
}
},
}
}
export const CheckListFeatureClientComponent = createClientComponent(CheckListFeatureClient)
export const ChecklistFeatureClientComponent = createClientComponent(ChecklistFeatureClient)

View File

@@ -4,17 +4,17 @@ import type { FeatureProviderProviderServer } from '../../types.js'
import { createNode } from '../../typeUtilities.js'
import { ListHTMLConverter, ListItemHTMLConverter } from '../htmlConverter.js'
import { CheckListFeatureClientComponent } from './feature.client.js'
import { ChecklistFeatureClientComponent } from './feature.client.js'
import { CHECK_LIST } from './markdownTransformers.js'
export const CheckListFeature: FeatureProviderProviderServer<undefined, undefined> = (props) => {
export const ChecklistFeature: FeatureProviderProviderServer<undefined, undefined> = (props) => {
return {
feature: ({ featureProviderMap }) => {
return {
ClientComponent: CheckListFeatureClientComponent,
ClientComponent: ChecklistFeatureClientComponent,
markdownTransformers: [CHECK_LIST],
nodes:
featureProviderMap.has('unorderedlist') || featureProviderMap.has('orderedlist')
featureProviderMap.has('unorderedList') || featureProviderMap.has('orderedList')
? []
: [
createNode({

View File

@@ -2,7 +2,7 @@ import type { ElementTransformer } from '@lexical/markdown'
import { $isListNode, ListItemNode, ListNode } from '@lexical/list'
import { listExport, listReplace } from '../common/markdown.js'
import { listExport, listReplace } from '../shared/markdown.js'
export const CHECK_LIST: ElementTransformer = {
type: 'element',

View File

@@ -3,10 +3,9 @@ import { INSERT_ORDERED_LIST_COMMAND, ListItemNode, ListNode } from '@lexical/li
import type { FeatureProviderProviderClient } from '../../types.js'
import { SlashMenuOption } from '../../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.js'
import { OrderedListIcon } from '../../../lexical/ui/icons/OrderedList/index.js'
import { TextDropdownSectionWithEntries } from '../../common/floatingSelectToolbarTextDropdownSection/index.js'
import { createClientComponent } from '../../createClientComponent.js'
import { inlineToolbarTextDropdownGroupWithItems } from '../../shared/inlineToolbar/textDropdownGroup.js'
import { LexicalListPlugin } from '../plugin/index.js'
import { ORDERED_LIST } from './markdownTransformer.js'
@@ -16,25 +15,9 @@ const OrderedListFeatureClient: FeatureProviderProviderClient<undefined> = (prop
feature: ({ featureProviderMap }) => {
return {
clientFeatureProps: props,
floatingSelectToolbar: {
sections: [
TextDropdownSectionWithEntries([
{
ChildComponent: OrderedListIcon,
isActive: () => false,
key: 'orderedList',
label: `Ordered List`,
onClick: ({ editor }) => {
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined)
},
order: 10,
},
]),
],
},
markdownTransformers: [ORDERED_LIST],
nodes: featureProviderMap.has('unorderedlist') ? [] : [ListNode, ListItemNode],
plugins: featureProviderMap.has('unorderedlist')
nodes: featureProviderMap.has('orderedList') ? [] : [ListNode, ListItemNode],
plugins: featureProviderMap.has('orderedList')
? []
: [
{
@@ -43,23 +26,40 @@ const OrderedListFeatureClient: FeatureProviderProviderClient<undefined> = (prop
},
],
slashMenu: {
options: [
groups: [
{
displayName: 'Lists',
key: 'lists',
options: [
new SlashMenuOption('orderedlist', {
items: [
{
Icon: OrderedListIcon,
displayName: 'Ordered List',
key: 'orderedList',
keywords: ['ordered list', 'ol'],
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined)
},
}),
},
],
key: 'lists',
},
],
},
toolbarInline: {
groups: [
inlineToolbarTextDropdownGroupWithItems([
{
ChildComponent: OrderedListIcon,
isActive: () => false,
key: 'orderedList',
label: `Ordered List`,
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined)
},
order: 10,
},
]),
],
},
}
},
}

View File

@@ -32,7 +32,7 @@ export const OrderedListFeature: FeatureProviderProviderServer<undefined, undefi
serverFeatureProps: props,
}
},
key: 'orderedlist',
key: 'orderedList',
serverFeatureProps: props,
}
}

View File

@@ -2,7 +2,7 @@ import type { ElementTransformer } from '@lexical/markdown'
import { $isListNode, ListItemNode, ListNode } from '@lexical/list'
import { listExport, listReplace } from '../common/markdown.js'
import { listExport, listReplace } from '../shared/markdown.js'
export const ORDERED_LIST: ElementTransformer = {
type: 'element',

View File

@@ -4,10 +4,10 @@ import { INSERT_UNORDERED_LIST_COMMAND, ListItemNode, ListNode } from '@lexical/
import type { FeatureProviderProviderClient } from '../../types.js'
import { SlashMenuOption } from '../../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.js'
import { SlashMenuItem } from '../../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.js'
import { UnorderedListIcon } from '../../../lexical/ui/icons/UnorderedList/index.js'
import { TextDropdownSectionWithEntries } from '../../common/floatingSelectToolbarTextDropdownSection/index.js'
import { createClientComponent } from '../../createClientComponent.js'
import { inlineToolbarTextDropdownGroupWithItems } from '../../shared/inlineToolbar/textDropdownGroup.js'
import { LexicalListPlugin } from '../plugin/index.js'
import { UNORDERED_LIST } from './markdownTransformer.js'
@@ -17,22 +17,6 @@ const UnorderedListFeatureClient: FeatureProviderProviderClient<undefined> = (pr
feature: () => {
return {
clientFeatureProps: props,
floatingSelectToolbar: {
sections: [
TextDropdownSectionWithEntries([
{
ChildComponent: UnorderedListIcon,
isActive: () => false,
key: 'unorderedList',
label: `Unordered List`,
onClick: ({ editor }) => {
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)
},
order: 11,
},
]),
],
},
markdownTransformers: [UNORDERED_LIST],
nodes: [ListNode, ListItemNode],
plugins: [
@@ -42,23 +26,40 @@ const UnorderedListFeatureClient: FeatureProviderProviderClient<undefined> = (pr
},
],
slashMenu: {
options: [
groups: [
{
displayName: 'Lists',
key: 'lists',
options: [
new SlashMenuOption('unorderedlist', {
items: [
{
Icon: UnorderedListIcon,
displayName: 'Unordered List',
key: 'unorderedList',
keywords: ['unordered list', 'ul'],
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)
},
}),
},
],
key: 'lists',
},
],
},
toolbarInline: {
groups: [
inlineToolbarTextDropdownGroupWithItems([
{
ChildComponent: UnorderedListIcon,
isActive: () => false,
key: 'unorderedList',
label: `Unordered List`,
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)
},
order: 11,
},
]),
],
},
}
},
}

View File

@@ -32,7 +32,7 @@ export const UnorderedListFeature: FeatureProviderProviderServer<undefined, unde
serverFeatureProps: props,
}
},
key: 'unorderedlist',
key: 'unorderedList',
serverFeatureProps: props,
}
}

View File

@@ -2,7 +2,7 @@ import type { ElementTransformer } from '@lexical/markdown'
import { $isListNode, ListItemNode, ListNode } from '@lexical/list'
import { listExport, listReplace } from '../common/markdown.js'
import { listExport, listReplace } from '../shared/markdown.js'
export const UNORDERED_LIST: ElementTransformer = {
type: 'element',

View File

@@ -5,25 +5,46 @@ import { $createParagraphNode, $getSelection } from 'lexical'
import type { FeatureProviderProviderClient } from '../types.js'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.js'
import { TextIcon } from '../../lexical/ui/icons/Text/index.js'
import { TextDropdownSectionWithEntries } from '../common/floatingSelectToolbarTextDropdownSection/index.js'
import { createClientComponent } from '../createClientComponent.js'
import { inlineToolbarTextDropdownGroupWithItems } from '../shared/inlineToolbar/textDropdownGroup.js'
const ParagraphFeatureClient: FeatureProviderProviderClient<undefined> = (props) => {
return {
clientFeatureProps: props,
feature: () => ({
clientFeatureProps: props,
floatingSelectToolbar: {
sections: [
TextDropdownSectionWithEntries([
slashMenu: {
groups: [
{
displayName: 'Basic',
items: [
{
Icon: TextIcon,
displayName: 'Paragraph',
key: 'paragraph',
keywords: ['normal', 'paragraph', 'p', 'text'],
onSelect: ({ editor }) => {
editor.update(() => {
const selection = $getSelection()
$setBlocksType(selection, () => $createParagraphNode())
})
},
},
],
key: 'basic',
},
],
},
toolbarInline: {
groups: [
inlineToolbarTextDropdownGroupWithItems([
{
ChildComponent: TextIcon,
isActive: () => false,
key: 'normal-text',
key: 'paragraph',
label: 'Normal Text',
onClick: ({ editor }) => {
onSelect: ({ editor }) => {
editor.update(() => {
const selection = $getSelection()
$setBlocksType(selection, () => $createParagraphNode())
@@ -34,27 +55,6 @@ const ParagraphFeatureClient: FeatureProviderProviderClient<undefined> = (props)
]),
],
},
slashMenu: {
options: [
{
displayName: 'Basic',
key: 'basic',
options: [
new SlashMenuOption('paragraph', {
Icon: TextIcon,
displayName: 'Paragraph',
keywords: ['normal', 'paragraph', 'p', 'text'],
onSelect: ({ editor }) => {
editor.update(() => {
const selection = $getSelection()
$setBlocksType(selection, () => $createParagraphNode())
})
},
}),
],
},
],
},
}),
}
}

View File

@@ -5,7 +5,6 @@ import { withMergedProps } from '@payloadcms/ui/elements/withMergedProps'
import type { FeatureProviderProviderClient } from '../types.js'
import type { RelationshipFeatureProps } from './feature.server.js'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.js'
import { RelationshipIcon } from '../../lexical/ui/icons/Relationship/index.js'
import { createClientComponent } from '../createClientComponent.js'
import { INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND } from './drawer/commands.js'
@@ -30,14 +29,14 @@ const RelationshipFeatureClient: FeatureProviderProviderClient<RelationshipFeatu
},
],
slashMenu: {
options: [
groups: [
{
displayName: 'Basic',
key: 'basic',
options: [
new SlashMenuOption('relationship', {
items: [
{
Icon: RelationshipIcon,
displayName: 'Relationship',
key: 'relationship',
keywords: ['relationship', 'relation', 'rel'],
onSelect: ({ editor }) => {
// dispatch INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND
@@ -45,8 +44,9 @@ const RelationshipFeatureClient: FeatureProviderProviderClient<RelationshipFeatu
replace: false,
})
},
}),
},
],
key: 'basic',
},
],
},

View File

@@ -0,0 +1,15 @@
import type {
InlineToolbarGroup,
InlineToolbarGroupItem,
} from '../../../lexical/plugins/toolbars/inline/types.js'
export const inlineToolbarFeatureButtonsGroupWithItems = (
items: InlineToolbarGroupItem[],
): InlineToolbarGroup => {
return {
type: 'buttons',
items,
key: 'features',
order: 5,
}
}

View File

@@ -0,0 +1,18 @@
import type {
InlineToolbarGroup,
InlineToolbarGroupItem,
} from '../../../lexical/plugins/toolbars/inline/types.js'
import { TextIcon } from '../../../lexical/ui/icons/Text/index.js'
export const inlineToolbarTextDropdownGroupWithItems = (
items: InlineToolbarGroupItem[],
): InlineToolbarGroup => {
return {
type: 'dropdown',
ChildComponent: TextIcon,
items,
key: 'text',
order: 1,
}
}

View File

@@ -17,8 +17,9 @@ import type React from 'react'
import type { AdapterProps } from '../../types.js'
import type { ClientEditorConfig, ServerEditorConfig } from '../lexical/config/types.js'
import type { FloatingToolbarSection } from '../lexical/plugins/FloatingSelectToolbar/types.js'
import type { SlashMenuGroup } from '../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.js'
import type { FixedToolbarGroup } from '../lexical/plugins/toolbars/fixed/types.js'
import type { InlineToolbarGroup } from '../lexical/plugins/toolbars/inline/types.js'
import type { HTMLConverter } from './converters/html/converter/types.js'
export type PopulationPromise<T extends SerializedLexicalNode = SerializedLexicalNode> = ({
@@ -131,10 +132,6 @@ export type ClientFeature<ClientFeatureProps> = {
* Return props, to make it easy to retrieve passed in props to this Feature for the client if anyone wants to
*/
clientFeatureProps: ClientComponentProps<ClientFeatureProps>
floatingSelectToolbar?: {
sections: FloatingToolbarSection[]
}
hooks?: {
load?: ({
incomingEditorState,
@@ -149,6 +146,9 @@ export type ClientFeature<ClientFeatureProps> = {
}
markdownTransformers?: Transformer[]
nodes?: Array<Klass<LexicalNode> | LexicalNodeReplacement>
/**
* Plugins are react component which get added to the editor. You can use them to interact with lexical, e.g. to create a command which creates a node, or opens a modal, or some other more "outside" functionality
*/
plugins?: Array<
| {
// plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality
@@ -172,14 +172,39 @@ export type ClientFeature<ClientFeatureProps> = {
}
>
slashMenu?: {
dynamicOptions?: ({
/**
* Dynamic groups allow you to add different groups depending on the query string (so, the text after the slash).
* Thus, to re-calculate the available groups, this function will be called every time you type after the /.
*
* The groups provided by dynamicGroups will be merged with the static groups provided by the groups property.
*/
dynamicGroups?: ({
editor,
queryString,
}: {
editor: LexicalEditor
queryString: string
}) => SlashMenuGroup[]
options?: SlashMenuGroup[]
/**
* Static array of groups together with the items in them. These will always be present.
* While typing after the /, they will be filtered by the query string and the keywords, key and display name of the items.
*/
groups?: SlashMenuGroup[]
}
/**
* An opt-in, classic fixed toolbar which stays at the top of the editor
*/
toolbarFixed?: {
groups: FixedToolbarGroup[]
}
/**
* The default, floating toolbar which appears when you select text.
*/
toolbarInline?: {
/**
* Array of toolbar groups / sections. Each section can contain multiple toolbar items.
*/
groups: InlineToolbarGroup[]
}
}
@@ -302,6 +327,9 @@ export type ResolvedClientFeatureMap = Map<string, ResolvedClientFeature<unknown
export type ServerFeatureProviderMap = Map<string, FeatureProviderServer<unknown, unknown>>
export type ClientFeatureProviderMap = Map<string, FeatureProviderClient<unknown>>
/**
* Plugins are react component which get added to the editor. You can use them to interact with lexical, e.g. to create a command which creates a node, or opens a modal, or some other more "outside" functionality
*/
export type SanitizedPlugin =
| {
// plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality
@@ -381,13 +409,13 @@ export type SanitizedServerFeatures = Required<
}
export type SanitizedClientFeatures = Required<
Pick<ResolvedClientFeature<unknown>, 'markdownTransformers' | 'nodes'>
Pick<
ResolvedClientFeature<unknown>,
'markdownTransformers' | 'nodes' | 'toolbarFixed' | 'toolbarInline'
>
> & {
/** The keys of all enabled features */
enabledFeatures: string[]
floatingSelectToolbar: {
sections: FloatingToolbarSection[]
}
hooks: {
load: Array<
({
@@ -404,11 +432,24 @@ export type SanitizedClientFeatures = Required<
}) => SerializedEditorState
>
}
/**
* Plugins are react component which get added to the editor. You can use them to interact with lexical, e.g. to create a command which creates a node, or opens a modal, or some other more "outside" functionality
*/
plugins?: Array<SanitizedPlugin>
slashMenu: {
dynamicOptions: Array<
/**
* Dynamic groups allow you to add different groups depending on the query string (so, the text after the slash).
* Thus, to re-calculate the available groups, this function will be called every time you type after the /.
*
* The groups provided by dynamicGroups will be merged with the static groups provided by the groups property.
*/
dynamicGroups: Array<
({ editor, queryString }: { editor: LexicalEditor; queryString: string }) => SlashMenuGroup[]
>
groupsWithOptions: SlashMenuGroup[]
/**
* Static array of groups together with the items in them. These will always be present.
* While typing after the /, they will be filtered by the query string and the keywords, key and display name of the items.
*/
groups: SlashMenuGroup[]
}
}

View File

@@ -2,7 +2,6 @@
import type { FeatureProviderProviderClient } from '../types.js'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.js'
import { UploadIcon } from '../../lexical/ui/icons/Upload/index.js'
import { createClientComponent } from '../createClientComponent.js'
import { INSERT_UPLOAD_WITH_DRAWER_COMMAND } from './drawer/commands.js'
@@ -30,22 +29,23 @@ const UploadFeatureClient: FeatureProviderProviderClient<UploadFeaturePropsClien
},
],
slashMenu: {
options: [
groups: [
{
displayName: 'Basic',
key: 'basic',
options: [
new SlashMenuOption('upload', {
items: [
{
Icon: UploadIcon,
displayName: 'Upload',
key: 'upload',
keywords: ['upload', 'image', 'file', 'img', 'picture', 'photo', 'media'],
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_UPLOAD_WITH_DRAWER_COMMAND, {
replace: false,
})
},
}),
},
],
key: 'basic',
},
],
},

View File

@@ -1,3 +1,5 @@
@import '../../scss/styles';
.rich-text-lexical {
.editor {
position: relative;
@@ -23,7 +25,7 @@
.editor-placeholder {
position: absolute;
top: 8px;
left: 0px;
left: 0;
font-size: 15px;
color: var(--theme-elevation-500);
/* Prevent text selection */
@@ -36,21 +38,3 @@
pointer-events: none;
}
}
.floating-select-toolbar-popup__section-dropdown-align {
display: flex;
gap: 2px;
}
.floating-select-toolbar-popup__section-features {
display: flex;
gap: 2px;
}
.floating-select-toolbar-popup__section-dropdown-text {
display: flex;
gap: 2px;
}
.floating-select-toolbar-popup__section-format {
display: flex;
gap: 2px;
}

View File

@@ -12,11 +12,11 @@ import type { LexicalProviderProps } from './LexicalProvider.js'
import { EditorPlugin } from './EditorPlugin.js'
import './LexicalEditor.scss'
import { FloatingSelectToolbarPlugin } from './plugins/FloatingSelectToolbar/index.js'
import { MarkdownShortcutPlugin } from './plugins/MarkdownShortcut/index.js'
import { SlashMenuPlugin } from './plugins/SlashMenu/index.js'
import { AddBlockHandlePlugin } from './plugins/handles/AddBlockHandlePlugin/index.js'
import { DraggableBlockPlugin } from './plugins/handles/DraggableBlockPlugin/index.js'
import { FloatingSelectToolbarPlugin } from './plugins/toolbars/inline/Toolbar/index.js'
import { LexicalContentEditable } from './ui/ContentEditable.js'
export const LexicalEditor: React.FC<Pick<LexicalProviderProps, 'editorConfig' | 'onChange'>> = (

View File

@@ -10,9 +10,6 @@ export const sanitizeClientFeatures = (
): SanitizedClientFeatures => {
const sanitized: SanitizedClientFeatures = {
enabledFeatures: [],
floatingSelectToolbar: {
sections: [],
},
hooks: {
load: [],
save: [],
@@ -21,8 +18,14 @@ export const sanitizeClientFeatures = (
nodes: [],
plugins: [],
slashMenu: {
dynamicOptions: [],
groupsWithOptions: [],
dynamicGroups: [],
groups: [],
},
toolbarFixed: {
groups: [],
},
toolbarInline: {
groups: [],
},
}
@@ -53,60 +56,82 @@ export const sanitizeClientFeatures = (
})
}
if (feature.floatingSelectToolbar?.sections?.length) {
for (const section of feature.floatingSelectToolbar.sections) {
// 1. find the section with the same key or create new one
let foundSection = sanitized.floatingSelectToolbar.sections.find(
(sanitizedSection) => sanitizedSection.key === section.key,
if (feature.toolbarInline?.groups?.length) {
for (const group of feature.toolbarInline.groups) {
// 1. find the group with the same key or create new one
let foundGroup = sanitized.toolbarInline.groups.find(
(sanitizedGroup) => sanitizedGroup.key === group.key,
)
if (!foundSection) {
foundSection = {
...section,
entries: [],
if (!foundGroup) {
foundGroup = {
...group,
items: [],
}
} else {
sanitized.floatingSelectToolbar.sections =
sanitized.floatingSelectToolbar.sections.filter(
(sanitizedSection) => sanitizedSection.key !== section.key,
)
sanitized.toolbarInline.groups = sanitized.toolbarInline.groups.filter(
(sanitizedGroup) => sanitizedGroup.key !== group.key,
)
}
// 2. Add options to group options array and add to sanitized.slashMenu.groupsWithOptions
if (section?.entries?.length) {
foundSection.entries = foundSection.entries.concat(section.entries)
if (group?.items?.length) {
foundGroup.items = foundGroup.items.concat(group.items)
}
sanitized.floatingSelectToolbar?.sections.push(foundSection)
sanitized.toolbarInline?.groups.push(foundGroup)
}
}
if (feature.slashMenu?.options) {
if (feature.slashMenu.dynamicOptions?.length) {
sanitized.slashMenu.dynamicOptions = sanitized.slashMenu.dynamicOptions.concat(
feature.slashMenu.dynamicOptions,
if (feature.toolbarFixed?.groups?.length) {
for (const group of feature.toolbarFixed.groups) {
// 1. find the group with the same key or create new one
let foundGroup = sanitized.toolbarFixed.groups.find(
(sanitizedGroup) => sanitizedGroup.key === group.key,
)
if (!foundGroup) {
foundGroup = {
...group,
items: [],
}
} else {
sanitized.toolbarFixed.groups = sanitized.toolbarFixed.groups.filter(
(sanitizedGroup) => sanitizedGroup.key !== group.key,
)
}
// 2. Add options to group options array and add to sanitized.slashMenu.groupsWithOptions
if (group?.items?.length) {
foundGroup.items = foundGroup.items.concat(group.items)
}
sanitized.toolbarFixed?.groups.push(foundGroup)
}
}
if (feature.slashMenu?.groups) {
if (feature.slashMenu.dynamicGroups?.length) {
sanitized.slashMenu.dynamicGroups = sanitized.slashMenu.dynamicGroups.concat(
feature.slashMenu.dynamicGroups,
)
}
for (const optionGroup of feature.slashMenu.options) {
for (const optionGroup of feature.slashMenu.groups) {
// 1. find the group with the same name or create new one
let group = sanitized.slashMenu.groupsWithOptions.find(
(group) => group.key === optionGroup.key,
)
let group = sanitized.slashMenu.groups.find((group) => group.key === optionGroup.key)
if (!group) {
group = {
...optionGroup,
options: [],
items: [],
}
} else {
sanitized.slashMenu.groupsWithOptions = sanitized.slashMenu.groupsWithOptions.filter(
sanitized.slashMenu.groups = sanitized.slashMenu.groups.filter(
(group) => group.key !== optionGroup.key,
)
}
// 2. Add options to group options array and add to sanitized.slashMenu.groupsWithOptions
if (optionGroup?.options?.length) {
group.options = group.options.concat(optionGroup.options)
if (optionGroup?.items?.length) {
group.items = group.items.concat(optionGroup.items)
}
sanitized.slashMenu.groupsWithOptions.push(group)
sanitized.slashMenu.groups.push(group)
}
}
@@ -118,8 +143,20 @@ export const sanitizeClientFeatures = (
sanitized.enabledFeatures.push(feature.key)
})
// Sort sanitized.floatingSelectToolbar.sections by order property
sanitized.floatingSelectToolbar.sections.sort((a, b) => {
// Sort sanitized.toolbarInline.groups by order property
sanitized.toolbarInline.groups.sort((a, b) => {
if (a.order && b.order) {
return a.order - b.order
} else if (a.order) {
return -1
} else if (b.order) {
return 1
} else {
return 0
}
})
// Sort sanitized.toolbarFixed.groups by order property
sanitized.toolbarFixed.groups.sort((a, b) => {
if (a.order && b.order) {
return a.order - b.order
} else if (a.order) {
@@ -131,9 +168,24 @@ export const sanitizeClientFeatures = (
}
})
// Sort sanitized.floatingSelectToolbar.sections.[section].entries by order property
for (const section of sanitized.floatingSelectToolbar.sections) {
section.entries.sort((a, b) => {
// Sort sanitized.toolbarInline.groups.[group].entries by order property
for (const group of sanitized.toolbarInline.groups) {
group.items.sort((a, b) => {
if (a.order && b.order) {
return a.order - b.order
} else if (a.order) {
return -1
} else if (b.order) {
return 1
} else {
return 0
}
})
}
// Sort sanitized.toolbarFixed.groups.[group].entries by order property
for (const group of sanitized.toolbarFixed.groups) {
group.items.sort((a, b) => {
if (a.order && b.order) {
return a.order - b.order
} else if (a.order) {

View File

@@ -1,24 +1,24 @@
import type { EditorConfig as LexicalEditorConfig } from 'lexical'
import type { FeatureProviderServer } from '../../../features/types.js'
import type { SanitizedServerEditorConfig, ServerEditorConfig } from '../types.js'
import type { ServerEditorConfig } from '../types.js'
import { AlignFeature } from '../../../features/align/feature.server.js'
import { BlockQuoteFeature } from '../../../features/blockquote/feature.server.js'
import { BoldFeature } from '../../../features/format/bold/feature.server.js'
import { InlineCodeFeature } from '../../../features/format/inlinecode/feature.server.js'
import { InlineCodeFeature } from '../../../features/format/inlineCode/feature.server.js'
import { ItalicFeature } from '../../../features/format/italic/feature.server.js'
import { StrikethroughFeature } from '../../../features/format/strikethrough/feature.server.js'
import { SubscriptFeature } from '../../../features/format/subscript/feature.server.js'
import { SuperscriptFeature } from '../../../features/format/superscript/feature.server.js'
import { UnderlineFeature } from '../../../features/format/underline/feature.server.js'
import { HeadingFeature } from '../../../features/heading/feature.server.js'
import { HorizontalRuleFeature } from '../../../features/horizontalrule/feature.server.js'
import { HorizontalRuleFeature } from '../../../features/horizontalRule/feature.server.js'
import { IndentFeature } from '../../../features/indent/feature.server.js'
import { LinkFeature } from '../../../features/link/feature.server.js'
import { CheckListFeature } from '../../../features/lists/checklist/feature.server.js'
import { OrderedListFeature } from '../../../features/lists/orderedlist/feature.server.js'
import { UnorderedListFeature } from '../../../features/lists/unorderedlist/feature.server.js'
import { ChecklistFeature } from '../../../features/lists/checklist/feature.server.js'
import { OrderedListFeature } from '../../../features/lists/orderedList/feature.server.js'
import { UnorderedListFeature } from '../../../features/lists/unorderedList/feature.server.js'
import { ParagraphFeature } from '../../../features/paragraph/feature.server.js'
import { RelationshipFeature } from '../../../features/relationship/feature.server.js'
import { UploadFeature } from '../../../features/upload/feature.server.js'
@@ -43,7 +43,7 @@ export const defaultEditorFeatures: FeatureProviderServer<unknown, unknown>[] =
IndentFeature(),
UnorderedListFeature(),
OrderedListFeature(),
CheckListFeature(),
ChecklistFeature(),
LinkFeature(),
RelationshipFeature(),
BlockQuoteFeature(),

View File

@@ -1,70 +0,0 @@
'use client'
import React from 'react'
const baseClass = 'floating-select-toolbar-popup__dropdown'
import type { LexicalEditor } from 'lexical'
import type { FloatingToolbarSectionEntry } from '../types.js'
import { DropDown, DropDownItem } from './DropDown.js'
import './index.scss'
export const ToolbarEntry = ({
anchorElem,
editor,
entry,
}: {
anchorElem: HTMLElement
editor: LexicalEditor
entry: FloatingToolbarSectionEntry
}) => {
if (entry.Component) {
return (
entry?.Component && (
<entry.Component anchorElem={anchorElem} editor={editor} entry={entry} key={entry.key} />
)
)
}
return (
<DropDownItem entry={entry} key={entry.key}>
{entry?.ChildComponent && <entry.ChildComponent />}
<span className="text">{entry.label}</span>
</DropDownItem>
)
}
export const ToolbarDropdown = ({
Icon,
anchorElem,
classNames,
editor,
entries,
sectionKey,
}: {
Icon?: React.FC
anchorElem: HTMLElement
classNames?: string[]
editor: LexicalEditor
entries: FloatingToolbarSectionEntry[]
sectionKey: string
}) => {
return (
<DropDown
Icon={Icon}
buttonAriaLabel={`${sectionKey} dropdown`}
buttonClassName={[baseClass, `${baseClass}-${sectionKey}`, ...(classNames || [])]
.filter(Boolean)
.join(' ')}
key={sectionKey}
>
{entries.length &&
entries.map((entry) => {
return (
<ToolbarEntry anchorElem={anchorElem} editor={editor} entry={entry} key={entry.key} />
)
})}
</DropDown>
)
}

View File

@@ -18,7 +18,7 @@ import {
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { MenuTextMatch } from '../useMenuTriggerMatch.js'
import type { SlashMenuGroup, SlashMenuOption } from './types.js'
import type { SlashMenuGroupInternal, SlashMenuItem, SlashMenuItemInternal } from './types.js'
export type MenuResolution = {
getRect: () => DOMRect
@@ -30,10 +30,10 @@ const baseClass = 'slash-menu-popup'
export type MenuRenderFn = (
anchorElementRef: MutableRefObject<HTMLElement | null>,
itemProps: {
groupsWithOptions: Array<SlashMenuGroup>
selectOptionAndCleanUp: (selectedOption: SlashMenuOption) => void
selectedOptionKey: null | string
setSelectedOptionKey: (optionKey: string) => void
groups: Array<SlashMenuGroupInternal>
selectItemAndCleanUp: (selectedItem: SlashMenuItem) => void
selectedItemKey: null | string
setSelectedItemKey: (itemKey: string) => void
},
matchingString: null | string,
) => JSX.Element | ReactPortal | null
@@ -63,10 +63,10 @@ const scrollIntoViewIfNeeded = (target: HTMLElement) => {
* Walk backwards along user input and forward through entity title to try
* and replace more of the user's text with entity.
*/
function getFullMatchOffset(documentText: string, entryText: string, offset: number): number {
function getFullMatchOffset(documentText: string, entryText: string, offset: number) {
let triggerOffset = offset
for (let i = triggerOffset; i <= entryText.length; i++) {
if (documentText.substr(-i) === entryText.substr(0, i)) {
if (documentText.substring(documentText.length - i) === entryText.substring(0, i)) {
triggerOffset = i
}
}
@@ -194,27 +194,27 @@ export function useDynamicPositioning(
export const SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND: LexicalCommand<{
index: number
option: SlashMenuOption
item: SlashMenuItemInternal
}> = createCommand('SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND')
export function LexicalMenu({
anchorElementRef,
close,
editor,
// groupsWithOptions filtering is already handled in SlashMenu/index.tsx. Thus, groupsWithOptions always contains the matching options.
groupsWithOptions,
// groups filtering is already handled in SlashMenu/index.tsx. Thus, groups always contains the matching items.
groups,
menuRenderFn,
onSelectOption,
onSelectItem,
resolution,
shouldSplitNodeWithQuery = false,
}: {
anchorElementRef: MutableRefObject<HTMLElement>
close: () => void
editor: LexicalEditor
groupsWithOptions: Array<SlashMenuGroup>
groups: Array<SlashMenuGroupInternal>
menuRenderFn: MenuRenderFn
onSelectOption: (
option: SlashMenuOption,
onSelectItem: (
item: SlashMenuItem,
textNodeContainingQuery: TextNode | null,
closeMenu: () => void,
matchingString: string,
@@ -222,55 +222,55 @@ export function LexicalMenu({
resolution: MenuResolution
shouldSplitNodeWithQuery?: boolean
}): JSX.Element | null {
const [selectedOptionKey, setSelectedOptionKey] = useState<null | string>(null)
const [selectedItemKey, setSelectedItemKey] = useState<null | string>(null)
const matchingString = (resolution.match && resolution.match.matchingString) || ''
const updateSelectedOption = useCallback(
(option: SlashMenuOption) => {
const updateSelectedItem = useCallback(
(item: SlashMenuItem) => {
const rootElem = editor.getRootElement()
if (rootElem !== null) {
rootElem.setAttribute('aria-activedescendant', `${baseClass}__item-${option.key}`)
setSelectedOptionKey(option.key)
rootElem.setAttribute('aria-activedescendant', `${baseClass}__item-${item.key}`)
setSelectedItemKey(item.key)
}
},
[editor],
)
const setSelectedOptionKeyToFirstMatchingOption = useCallback(() => {
// set selected option to the first of the matching ones
if (groupsWithOptions !== null && matchingString != null) {
// groupsWithOptions filtering is already handled in SlashMenu/index.tsx. Thus, groupsWithOptions always contains the matching options.
const allOptions = groupsWithOptions.flatMap((group) => group.options)
const setSelectedItemKeyToFirstMatchingItem = useCallback(() => {
// set selected item to the first of the matching ones
if (groups !== null && matchingString != null) {
// groups filtering is already handled in SlashMenu/index.tsx. Thus, groups always contains the matching items.
const allItems = groups.flatMap((group) => group.items)
if (allOptions.length) {
const firstMatchingOption = allOptions[0]
updateSelectedOption(firstMatchingOption)
if (allItems.length) {
const firstMatchingItem = allItems[0]
updateSelectedItem(firstMatchingItem)
}
}
}, [groupsWithOptions, updateSelectedOption, matchingString])
}, [groups, updateSelectedItem, matchingString])
useEffect(() => {
setSelectedOptionKeyToFirstMatchingOption()
}, [matchingString, setSelectedOptionKeyToFirstMatchingOption])
setSelectedItemKeyToFirstMatchingItem()
}, [matchingString, setSelectedItemKeyToFirstMatchingItem])
const selectOptionAndCleanUp = useCallback(
(selectedOption: SlashMenuOption) => {
const selectItemAndCleanUp = useCallback(
(selectedItem: SlashMenuItem) => {
editor.update(() => {
const textNodeContainingQuery =
resolution.match != null && shouldSplitNodeWithQuery
? $splitNodeContainingQuery(resolution.match)
: null
onSelectOption(
selectedOption,
onSelectItem(
selectedItem,
textNodeContainingQuery,
close,
resolution.match ? resolution.match.matchingString : '',
)
})
},
[editor, shouldSplitNodeWithQuery, resolution.match, onSelectOption, close],
[editor, shouldSplitNodeWithQuery, resolution.match, onSelectItem, close],
)
useEffect(() => {
@@ -283,25 +283,20 @@ export function LexicalMenu({
}, [editor])
useLayoutEffect(() => {
if (groupsWithOptions === null) {
setSelectedOptionKey(null)
} else if (selectedOptionKey === null) {
setSelectedOptionKeyToFirstMatchingOption()
if (groups === null) {
setSelectedItemKey(null)
} else if (selectedItemKey === null) {
setSelectedItemKeyToFirstMatchingItem()
}
}, [
groupsWithOptions,
selectedOptionKey,
updateSelectedOption,
setSelectedOptionKeyToFirstMatchingOption,
])
}, [groups, selectedItemKey, updateSelectedItem, setSelectedItemKeyToFirstMatchingItem])
useEffect(() => {
return mergeRegister(
editor.registerCommand(
SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND,
({ option }) => {
if (option.ref && option.ref.current != null) {
scrollIntoViewIfNeeded(option.ref.current)
({ item }) => {
if (item.ref && item.ref.current != null) {
scrollIntoViewIfNeeded(item.ref.current)
return true
}
@@ -310,7 +305,7 @@ export function LexicalMenu({
COMMAND_PRIORITY_LOW,
),
)
}, [editor, updateSelectedOption])
}, [editor, updateSelectedItem])
useEffect(() => {
return mergeRegister(
@@ -318,23 +313,19 @@ export function LexicalMenu({
KEY_ARROW_DOWN_COMMAND,
(payload) => {
const event = payload
if (
groupsWithOptions !== null &&
groupsWithOptions.length &&
selectedOptionKey !== null
) {
const allOptions = groupsWithOptions.flatMap((group) => group.options)
const selectedIndex = allOptions.findIndex((option) => option.key === selectedOptionKey)
if (groups !== null && groups.length && selectedItemKey !== null) {
const allItems = groups.flatMap((group) => group.items)
const selectedIndex = allItems.findIndex((item) => item.key === selectedItemKey)
const newSelectedIndex = selectedIndex !== allOptions.length - 1 ? selectedIndex + 1 : 0
const newSelectedIndex = selectedIndex !== allItems.length - 1 ? selectedIndex + 1 : 0
const newSelectedOption = allOptions[newSelectedIndex]
const newSelectedItem = allItems[newSelectedIndex]
updateSelectedOption(newSelectedOption)
if (newSelectedOption.ref != null && newSelectedOption.ref.current) {
updateSelectedItem(newSelectedItem)
if (newSelectedItem.ref != null && newSelectedItem.ref.current) {
editor.dispatchCommand(SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, {
index: newSelectedIndex,
option: newSelectedOption,
item: newSelectedItem,
})
}
event.preventDefault()
@@ -348,21 +339,17 @@ export function LexicalMenu({
KEY_ARROW_UP_COMMAND,
(payload) => {
const event = payload
if (
groupsWithOptions !== null &&
groupsWithOptions.length &&
selectedOptionKey !== null
) {
const allOptions = groupsWithOptions.flatMap((group) => group.options)
const selectedIndex = allOptions.findIndex((option) => option.key === selectedOptionKey)
if (groups !== null && groups.length && selectedItemKey !== null) {
const allItems = groups.flatMap((group) => group.items)
const selectedIndex = allItems.findIndex((item) => item.key === selectedItemKey)
const newSelectedIndex = selectedIndex !== 0 ? selectedIndex - 1 : allOptions.length - 1
const newSelectedIndex = selectedIndex !== 0 ? selectedIndex - 1 : allItems.length - 1
const newSelectedOption = allOptions[newSelectedIndex]
const newSelectedItem = allItems[newSelectedIndex]
updateSelectedOption(newSelectedOption)
if (newSelectedOption.ref != null && newSelectedOption.ref.current) {
scrollIntoViewIfNeeded(newSelectedOption.ref.current)
updateSelectedItem(newSelectedItem)
if (newSelectedItem.ref != null && newSelectedItem.ref.current) {
scrollIntoViewIfNeeded(newSelectedItem.ref.current)
}
event.preventDefault()
event.stopImmediatePropagation()
@@ -387,18 +374,18 @@ export function LexicalMenu({
(payload) => {
const event = payload
if (groupsWithOptions === null || selectedOptionKey === null) {
if (groups === null || selectedItemKey === null) {
return false
}
const allOptions = groupsWithOptions.flatMap((group) => group.options)
const selectedOption = allOptions.find((option) => option.key === selectedOptionKey)
if (!selectedOption) {
const allItems = groups.flatMap((group) => group.items)
const selectedItem = allItems.find((item) => item.key === selectedItemKey)
if (!selectedItem) {
return false
}
event.preventDefault()
event.stopImmediatePropagation()
selectOptionAndCleanUp(selectedOption)
selectItemAndCleanUp(selectedItem)
return true
},
COMMAND_PRIORITY_LOW,
@@ -406,12 +393,12 @@ export function LexicalMenu({
editor.registerCommand(
KEY_ENTER_COMMAND,
(event: KeyboardEvent | null) => {
if (groupsWithOptions === null || selectedOptionKey === null) {
if (groups === null || selectedItemKey === null) {
return false
}
const allOptions = groupsWithOptions.flatMap((group) => group.options)
const selectedOption = allOptions.find((option) => option.key === selectedOptionKey)
if (!selectedOption) {
const allItems = groups.flatMap((group) => group.items)
const selectedItem = allItems.find((item) => item.key === selectedItemKey)
if (!selectedItem) {
return false
}
@@ -419,29 +406,22 @@ export function LexicalMenu({
event.preventDefault()
event.stopImmediatePropagation()
}
selectOptionAndCleanUp(selectedOption)
selectItemAndCleanUp(selectedItem)
return true
},
COMMAND_PRIORITY_LOW,
),
)
}, [
selectOptionAndCleanUp,
close,
editor,
groupsWithOptions,
selectedOptionKey,
updateSelectedOption,
])
}, [selectItemAndCleanUp, close, editor, groups, selectedItemKey, updateSelectedItem])
const listItemProps = useMemo(
() => ({
groupsWithOptions,
selectOptionAndCleanUp,
selectedOptionKey,
setSelectedOptionKey,
groups,
selectItemAndCleanUp,
selectedItemKey,
setSelectedItemKey,
}),
[selectOptionAndCleanUp, selectedOptionKey, groupsWithOptions],
[selectItemAndCleanUp, selectedItemKey, groups],
)
return menuRenderFn(

View File

@@ -21,7 +21,7 @@ import * as React from 'react'
import type { MenuTextMatch, TriggerFn } from '../useMenuTriggerMatch.js'
import type { MenuRenderFn, MenuResolution } from './LexicalMenu.js'
import type { SlashMenuGroup, SlashMenuOption } from './types.js'
import type { SlashMenuGroup, SlashMenuGroupInternal, SlashMenuItem } from './types.js'
import { LexicalMenu, useMenuAnchorRef } from './LexicalMenu.js'
@@ -128,13 +128,13 @@ export { useDynamicPositioning } from './LexicalMenu.js'
export type TypeaheadMenuPluginProps = {
anchorClassName?: string
anchorElem: HTMLElement
groupsWithOptions: Array<SlashMenuGroup>
groups: Array<SlashMenuGroupInternal>
menuRenderFn: MenuRenderFn
onClose?: () => void
onOpen?: (resolution: MenuResolution) => void
onQueryChange: (matchingString: null | string) => void
onSelectOption: (
option: SlashMenuOption,
onSelectItem: (
item: SlashMenuItem,
textNodeContainingQuery: TextNode | null,
closeMenu: () => void,
matchingString: string,
@@ -149,12 +149,12 @@ export const ENABLE_SLASH_MENU_COMMAND: LexicalCommand<{
export function LexicalTypeaheadMenuPlugin({
anchorClassName,
anchorElem,
groupsWithOptions,
groups,
menuRenderFn,
onClose,
onOpen,
onQueryChange,
onSelectOption,
onSelectItem,
triggerFn,
}: TypeaheadMenuPluginProps): JSX.Element | null {
const [editor] = useLexicalComposerContext()
@@ -270,9 +270,9 @@ export function LexicalTypeaheadMenuPlugin({
anchorElementRef={anchorElementRef}
close={closeTypeahead}
editor={editor}
groupsWithOptions={groupsWithOptions}
groups={groups}
menuRenderFn={menuRenderFn}
onSelectOption={onSelectOption}
onSelectItem={onSelectItem}
resolution={resolution}
shouldSplitNodeWithQuery
/>

View File

@@ -3,7 +3,7 @@ import type { LexicalEditor } from 'lexical'
import type { MutableRefObject } from 'react'
import type React from 'react'
export class SlashMenuOption {
export type SlashMenuItem = {
// Icon for display
Icon: React.FC
@@ -13,41 +13,22 @@ export class SlashMenuOption {
// TBD
keyboardShortcut?: string
// For extra searching.
keywords: Array<string>
// What happens when you select this option?
keywords?: Array<string>
// What happens when you select this item?
onSelect: ({ editor, queryString }: { editor: LexicalEditor; queryString: string }) => void
ref?: MutableRefObject<HTMLElement | null>
constructor(
key: string,
options: {
Icon: React.FC
displayName?: (({ i18n }: { i18n: I18n }) => string) | string
keyboardShortcut?: string
keywords?: Array<string>
onSelect: ({ editor, queryString }: { editor: LexicalEditor; queryString: string }) => void
},
) {
this.key = key
this.ref = { current: null }
this.setRefElement = this.setRefElement.bind(this)
this.displayName = options.displayName
this.keywords = options.keywords || []
this.Icon = options.Icon
this.keyboardShortcut = options.keyboardShortcut
this.onSelect = options.onSelect.bind(this)
}
setRefElement(element: HTMLElement | null) {
this.ref = { current: element }
}
}
export class SlashMenuGroup {
export type SlashMenuGroup = {
// Used for class names and, if displayName is not provided, for display.
displayName?: (({ i18n }: { i18n: I18n }) => string) | string
items: Array<SlashMenuItem>
key: string
options: Array<SlashMenuOption>
}
export type SlashMenuItemInternal = SlashMenuItem & {
ref: MutableRefObject<HTMLButtonElement | null>
}
export type SlashMenuGroupInternal = SlashMenuGroup & {
items: Array<SlashMenuItemInternal>
}

View File

@@ -7,7 +7,12 @@ import { useCallback, useMemo, useState } from 'react'
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import type { SlashMenuGroup, SlashMenuOption } from './LexicalTypeaheadMenuPlugin/types.js'
import type {
SlashMenuGroup,
SlashMenuGroupInternal,
SlashMenuItemInternal,
SlashMenuItem as SlashMenuItemType,
} from './LexicalTypeaheadMenuPlugin/types.js'
import { useEditorConfigContext } from '../../config/client/EditorConfigProvider.js'
import { LexicalTypeaheadMenuPlugin } from './LexicalTypeaheadMenuPlugin/index.js'
@@ -18,27 +23,26 @@ const baseClass = 'slash-menu-popup'
function SlashMenuItem({
isSelected,
item,
onClick,
onMouseEnter,
option,
}: {
index: number
isSelected: boolean
item: SlashMenuItemInternal
onClick: () => void
onMouseEnter: () => void
option: SlashMenuOption
}) {
const { i18n } = useTranslation()
let className = `${baseClass}__item ${baseClass}__item-${option.key}`
let className = `${baseClass}__item ${baseClass}__item-${item.key}`
if (isSelected) {
className += ` ${baseClass}__item--selected`
}
let title = option.key
if (option.displayName) {
title =
typeof option.displayName === 'function' ? option.displayName({ i18n }) : option.displayName
let title = item.key
if (item.displayName) {
title = typeof item.displayName === 'function' ? item.displayName({ i18n }) : item.displayName
}
// Crop title to max. 50 characters
if (title.length > 25) {
@@ -49,16 +53,16 @@ function SlashMenuItem({
<button
aria-selected={isSelected}
className={className}
id={baseClass + '__item-' + option.key}
key={option.key}
id={baseClass + '__item-' + item.key}
key={item.key}
onClick={onClick}
onMouseEnter={onMouseEnter}
ref={option.setRefElement}
role="option"
ref={item.ref}
role="item"
tabIndex={-1}
type="button"
>
{option?.Icon && <option.Icon />}
{item?.Icon && <item.Icon />}
<span className={`${baseClass}__item-text`}>{title}</span>
</button>
@@ -69,7 +73,7 @@ export function SlashMenuPlugin({
anchorElem = document.body,
}: {
anchorElem?: HTMLElement
}): JSX.Element {
}): React.ReactElement {
const [editor] = useLexicalComposerContext()
const [queryString, setQueryString] = useState<null | string>(null)
const { editorConfig } = useEditorConfigContext()
@@ -79,94 +83,97 @@ export function SlashMenuPlugin({
minLength: 0,
})
const getDynamicOptions = useCallback(() => {
let groupWithOptions: Array<SlashMenuGroup> = []
const getDynamicItems = useCallback(() => {
let groupWithItems: Array<SlashMenuGroup> = []
for (const dynamicOption of editorConfig.features.slashMenu.dynamicOptions) {
const dynamicGroupWithOptions = dynamicOption({
for (const dynamicItem of editorConfig.features.slashMenu.dynamicGroups) {
const dynamicGroupWithItems = dynamicItem({
editor,
queryString,
})
groupWithOptions = groupWithOptions.concat(dynamicGroupWithOptions)
groupWithItems = groupWithItems.concat(dynamicGroupWithItems)
}
return groupWithOptions
return groupWithItems
}, [editor, queryString, editorConfig?.features])
const groups: SlashMenuGroup[] = useMemo(() => {
let groupsWithOptions: SlashMenuGroup[] = []
for (const groupWithOption of editorConfig?.features.slashMenu.groupsWithOptions ?? []) {
groupsWithOptions.push(groupWithOption)
let groupsWithItems: SlashMenuGroup[] = []
for (const groupWithItem of editorConfig?.features.slashMenu.groups ?? []) {
groupsWithItems.push(groupWithItem)
}
if (queryString) {
// Filter current groups first
groupsWithOptions = groupsWithOptions.map((group) => {
const filteredOptions = group.options.filter((option) => {
let optionTitle = option.key
if (option.displayName) {
optionTitle =
typeof option.displayName === 'function'
? option.displayName({ i18n })
: option.displayName
groupsWithItems = groupsWithItems.map((group) => {
const filteredItems = group.items.filter((item) => {
let itemTitle = item.key
if (item.displayName) {
itemTitle =
typeof item.displayName === 'function' ? item.displayName({ i18n }) : item.displayName
}
return new RegExp(queryString, 'gi').exec(optionTitle) || option.keywords != null
? option.keywords.some((keyword) => new RegExp(queryString, 'gi').exec(keyword))
: false
if (new RegExp(queryString, 'gi').exec(itemTitle)) {
return true
}
if (item.keywords != null) {
return item.keywords.some((keyword) => new RegExp(queryString, 'gi').exec(keyword))
}
return false
})
if (filteredOptions.length) {
if (filteredItems.length) {
return {
...group,
options: filteredOptions,
items: filteredItems,
}
}
return null
})
groupsWithOptions = groupsWithOptions.filter((group) => group != null)
groupsWithItems = groupsWithItems.filter((group) => group != null)
// Now add dynamic groups
const dynamicOptionGroups = getDynamicOptions()
const dynamicItemGroups = getDynamicItems()
// merge dynamic options into groups
for (const dynamicGroup of dynamicOptionGroups) {
// merge dynamic items into groups
for (const dynamicGroup of dynamicItemGroups) {
// 1. find the group with the same name or create new one
let group = groupsWithOptions.find((group) => group.key === dynamicGroup.key)
let group = groupsWithItems.find((group) => group.key === dynamicGroup.key)
if (!group) {
group = {
...dynamicGroup,
options: [],
items: [],
}
} else {
groupsWithOptions = groupsWithOptions.filter((group) => group.key !== dynamicGroup.key)
groupsWithItems = groupsWithItems.filter((group) => group.key !== dynamicGroup.key)
}
// 2. Add options to group options array and add to sanitized.slashMenu.groupsWithOptions
if (group?.options?.length) {
group.options = group.options.concat(group.options)
// 2. Add items to group items array and add to sanitized.slashMenu.groupsWithItems
if (group?.items?.length) {
group.items = group.items.concat(group.items)
}
groupsWithOptions.push(group)
groupsWithItems.push(group)
}
}
return groupsWithOptions
}, [getDynamicOptions, queryString, editorConfig?.features, i18n])
return groupsWithItems
}, [getDynamicItems, queryString, editorConfig?.features, i18n])
const onSelectOption = useCallback(
const onSelectItem = useCallback(
(
selectedOption: SlashMenuOption,
selectedItem: SlashMenuItemType,
nodeToRemove: TextNode | null,
closeMenu: () => void,
matchingString: string,
) => {
editor.update(() => {
if (nodeToRemove) {
if (nodeToRemove) {
editor.update(() => {
nodeToRemove.remove()
}
selectedOption.onSelect({ editor, queryString: matchingString })
closeMenu()
})
})
}
selectedItem.onSelect({ editor, queryString: matchingString })
closeMenu()
},
[editor],
)
@@ -175,10 +182,10 @@ export function SlashMenuPlugin({
<React.Fragment>
<LexicalTypeaheadMenuPlugin
anchorElem={anchorElem}
groupsWithOptions={groups}
groups={groups as SlashMenuGroupInternal[]}
menuRenderFn={(
anchorElementRef,
{ selectOptionAndCleanUp, selectedOptionKey, setSelectedOptionKey },
{ selectItemAndCleanUp, selectedItemKey, setSelectedItemKey },
) =>
anchorElementRef.current && groups.length
? ReactDOM.createPortal(
@@ -198,19 +205,19 @@ export function SlashMenuPlugin({
key={group.key}
>
<div className={`${baseClass}__group-title`}>{groupTitle}</div>
{group.options.map((option, oi: number) => (
{group.items.map((item, oi: number) => (
<SlashMenuItem
index={oi}
isSelected={selectedOptionKey === option.key}
key={option.key}
isSelected={selectedItemKey === item.key}
item={item as SlashMenuItemInternal}
key={item.key}
onClick={() => {
setSelectedOptionKey(option.key)
selectOptionAndCleanUp(option)
setSelectedItemKey(item.key)
selectItemAndCleanUp(item)
}}
onMouseEnter={() => {
setSelectedOptionKey(option.key)
setSelectedItemKey(item.key)
}}
option={option}
/>
))}
</div>
@@ -222,7 +229,7 @@ export function SlashMenuPlugin({
: null
}
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
onSelectItem={onSelectItem}
triggerFn={checkForTriggerMatch}
/>
</React.Fragment>

View File

@@ -1,28 +1,28 @@
import type { BaseSelection, LexicalEditor } from 'lexical'
import type React from 'react'
export type FloatingToolbarSection =
export type FixedToolbarGroup =
| {
ChildComponent?: React.FC
entries: Array<FloatingToolbarSectionEntry>
items: Array<FixedToolbarGroupItem>
key: string
order?: number
type: 'dropdown'
}
| {
entries: Array<FloatingToolbarSectionEntry>
items: Array<FixedToolbarGroupItem>
key: string
order?: number
type: 'buttons'
}
export type FloatingToolbarSectionEntry = {
export type FixedToolbarGroupItem = {
ChildComponent?: React.FC
/** Use component to ignore the children and onClick properties. It does not use the default, pre-defined format Button component */
Component?: React.FC<{
anchorElem: HTMLElement
editor: LexicalEditor
entry: FloatingToolbarSectionEntry
item: FixedToolbarGroupItem
}>
isActive?: ({ editor, selection }: { editor: LexicalEditor; selection: BaseSelection }) => boolean
isEnabled?: ({
@@ -33,7 +33,7 @@ export type FloatingToolbarSectionEntry = {
selection: BaseSelection
}) => boolean
key: string
/** The label is displayed as text if the entry is part of a dropdown section */
/** The label is displayed as text if the item is part of a dropdown group */
label?: string
onClick?: ({ editor, isActive }: { editor: LexicalEditor; isActive: boolean }) => void
order?: number

View File

@@ -1,12 +1,12 @@
@import '../../../../scss/styles.scss';
@import '../../../../../../scss/styles';
html[data-theme='light'] {
.floating-select-toolbar-popup {
.inline-toolbar-popup {
@include shadow-m;
}
}
.floating-select-toolbar-popup {
.inline-toolbar-popup {
display: flex;
align-items: center;
background: var(--color-base-0);
@@ -31,9 +31,10 @@ html[data-theme='light'] {
border-top-color: var(--color-base-0);
}
&__section {
&__group {
display: flex;
align-items: center;
gap: 2px;
.icon {
min-width: 20px;

View File

@@ -14,93 +14,88 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import * as React from 'react'
import { createPortal } from 'react-dom'
import type { FloatingToolbarSection, FloatingToolbarSectionEntry } from './types.js'
import type { InlineToolbarGroup, InlineToolbarGroupItem } from '../types.js'
import { useEditorConfigContext } from '../../config/client/EditorConfigProvider.js'
import { getDOMRangeRect } from '../../utils/getDOMRangeRect.js'
import { setFloatingElemPosition } from '../../utils/setFloatingElemPosition.js'
import { ToolbarButton } from './ToolbarButton/index.js'
import { ToolbarDropdown } from './ToolbarDropdown/index.js'
import { useEditorConfigContext } from '../../../../config/client/EditorConfigProvider.js'
import { getDOMRangeRect } from '../../../../utils/getDOMRangeRect.js'
import { setFloatingElemPosition } from '../../../../utils/setFloatingElemPosition.js'
import { ToolbarButton } from '../ToolbarButton/index.js'
import { ToolbarDropdown } from '../ToolbarDropdown/index.js'
import './index.scss'
function ButtonSectionEntry({
function ButtonGroupItem({
anchorElem,
editor,
entry,
item,
}: {
anchorElem: HTMLElement
editor: LexicalEditor
entry: FloatingToolbarSectionEntry
item: InlineToolbarGroupItem
}): React.ReactNode {
if (entry.Component) {
if (item.Component) {
return (
entry?.Component && (
<entry.Component anchorElem={anchorElem} editor={editor} entry={entry} key={entry.key} />
item?.Component && (
<item.Component anchorElem={anchorElem} editor={editor} item={item} key={item.key} />
)
)
}
return (
<ToolbarButton entry={entry} key={entry.key}>
{entry?.ChildComponent && <entry.ChildComponent />}
<ToolbarButton item={item} key={item.key}>
{item?.ChildComponent && <item.ChildComponent />}
</ToolbarButton>
)
}
function ToolbarSection({
function ToolbarGroup({
anchorElem,
editor,
group,
index,
section,
}: {
anchorElem: HTMLElement
editor: LexicalEditor
group: InlineToolbarGroup
index: number
section: FloatingToolbarSection
}): React.ReactNode {
const { editorConfig } = useEditorConfigContext()
const Icon =
section?.type === 'dropdown' && section.entries.length && section.ChildComponent
? section.ChildComponent
group?.type === 'dropdown' && group.items.length && group.ChildComponent
? group.ChildComponent
: null
return (
<div
className={`floating-select-toolbar-popup__section floating-select-toolbar-popup__section-${section.key}`}
key={section.key}
className={`inline-toolbar-popup__group inline-toolbar-popup__group-${group.key}`}
key={group.key}
>
{section.type === 'dropdown' &&
section.entries.length &&
{group.type === 'dropdown' &&
group.items.length &&
(Icon ? (
<ToolbarDropdown
Icon={Icon}
anchorElem={anchorElem}
editor={editor}
entries={section.entries}
sectionKey={section.key}
groupKey={group.key}
items={group.items}
/>
) : (
<ToolbarDropdown
anchorElem={anchorElem}
editor={editor}
entries={section.entries}
sectionKey={section.key}
groupKey={group.key}
items={group.items}
/>
))}
{section.type === 'buttons' &&
section.entries.length &&
section.entries.map((entry) => {
{group.type === 'buttons' &&
group.items.length &&
group.items.map((item) => {
return (
<ButtonSectionEntry
anchorElem={anchorElem}
editor={editor}
entry={entry}
key={entry.key}
/>
<ButtonGroupItem anchorElem={anchorElem} editor={editor} item={item} key={item.key} />
)
})}
{index < editorConfig.features.floatingSelectToolbar?.sections.length - 1 && (
{index < editorConfig.features.toolbarInline?.groups.length - 1 && (
<div className="divider" />
)}
</div>
@@ -262,19 +257,19 @@ function FloatingSelectToolbar({
}, [editor, updateTextFormatFloatingToolbar])
return (
<div className="floating-select-toolbar-popup" ref={floatingToolbarRef}>
<div className="inline-toolbar-popup" ref={floatingToolbarRef}>
<div className="caret" ref={caretRef} />
{editor.isEditable() && (
<React.Fragment>
{editorConfig?.features &&
editorConfig.features?.floatingSelectToolbar?.sections.map((section, i) => {
editorConfig.features?.toolbarInline?.groups.map((group, i) => {
return (
<ToolbarSection
<ToolbarGroup
anchorElem={anchorElem}
editor={editor}
group={group}
index={i}
key={section.key}
section={section}
key={group.key}
/>
)
})}

View File

@@ -1,4 +1,4 @@
.floating-select-toolbar-popup__button {
.inline-toolbar-popup__button {
display: flex;
align-items: center;
vertical-align: middle;

View File

@@ -4,18 +4,18 @@ import { mergeRegister } from '@lexical/utils'
import { $getSelection } from 'lexical'
import React, { useCallback, useEffect, useState } from 'react'
import type { FloatingToolbarSectionEntry } from '../types.js'
import type { InlineToolbarGroupItem } from '../types.js'
import './index.scss'
const baseClass = 'floating-select-toolbar-popup__button'
const baseClass = 'inline-toolbar-popup__button'
export const ToolbarButton = ({
children,
entry,
item,
}: {
children: React.JSX.Element
entry: FloatingToolbarSectionEntry
item: InlineToolbarGroupItem
}) => {
const [editor] = useLexicalComposerContext()
const [enabled, setEnabled] = useState<boolean>(true)
@@ -25,20 +25,20 @@ export const ToolbarButton = ({
const updateStates = useCallback(() => {
editor.getEditorState().read(() => {
const selection = $getSelection()
if (entry.isActive) {
const isActive = entry.isActive({ editor, selection })
if (item.isActive) {
const isActive = item.isActive({ editor, selection })
if (active !== isActive) {
setActive(isActive)
}
}
if (entry.isEnabled) {
const isEnabled = entry.isEnabled({ editor, selection })
if (item.isEnabled) {
const isEnabled = item.isEnabled({ editor, selection })
if (enabled !== isEnabled) {
setEnabled(isEnabled)
}
}
})
}, [active, editor, enabled, entry])
}, [active, editor, enabled, item])
useEffect(() => {
updateStates()
@@ -65,7 +65,7 @@ export const ToolbarButton = ({
baseClass,
enabled === false ? 'disabled' : '',
active ? 'active' : '',
entry?.key ? `${baseClass}-` + entry.key : '',
item?.key ? `${baseClass}-` + item.key : '',
]
.filter(Boolean)
.join(' '),
@@ -77,7 +77,7 @@ export const ToolbarButton = ({
className={className}
onClick={() => {
if (enabled !== false) {
entry.onClick({
item.onSelect({
editor,
isActive: active,
})

View File

@@ -6,9 +6,9 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro
import React from 'react'
import { createPortal } from 'react-dom'
import type { FloatingToolbarSectionEntry } from '../types.js'
import type { InlineToolbarGroupItem } from '../types.js'
const baseClass = 'floating-select-toolbar-popup__dropdown-item'
const baseClass = 'inline-toolbar-popup__dropdown-item'
interface DropDownContextType {
registerItem: (ref: React.RefObject<HTMLButtonElement>) => void
@@ -18,11 +18,11 @@ const DropDownContext = React.createContext<DropDownContextType | null>(null)
export function DropDownItem({
children,
entry,
item,
title,
}: {
children: React.ReactNode
entry: FloatingToolbarSectionEntry
item: InlineToolbarGroupItem
title?: string
}): React.ReactNode {
const [editor] = useLexicalComposerContext()
@@ -33,20 +33,20 @@ export function DropDownItem({
const updateStates = useCallback(() => {
editor.getEditorState().read(() => {
const selection = $getSelection()
if (entry.isActive) {
const isActive = entry.isActive({ editor, selection })
if (item.isActive) {
const isActive = item.isActive({ editor, selection })
if (active !== isActive) {
setActive(isActive)
}
}
if (entry.isEnabled) {
const isEnabled = entry.isEnabled({ editor, selection })
if (item.isEnabled) {
const isEnabled = item.isEnabled({ editor, selection })
if (enabled !== isEnabled) {
setEnabled(isEnabled)
}
}
})
}, [active, editor, enabled, entry])
}, [active, editor, enabled, item])
useEffect(() => {
updateStates()
@@ -73,12 +73,12 @@ export function DropDownItem({
baseClass,
enabled === false ? 'disabled' : '',
active ? 'active' : '',
entry?.key ? `${baseClass}-${entry.key}` : '',
item?.key ? `${baseClass}-${item.key}` : '',
]
.filter(Boolean)
.join(' '),
)
}, [enabled, active, className, entry.key])
}, [enabled, active, className, item.key])
const ref = useRef<HTMLButtonElement>(null)
@@ -101,7 +101,7 @@ export function DropDownItem({
className={className}
onClick={() => {
if (enabled !== false) {
entry.onClick({
item.onSelect({
editor,
isActive: active,
})
@@ -185,7 +185,7 @@ function DropDownItems({
return (
<DropDownContext.Provider value={contextValue}>
<div
className="floating-select-toolbar-popup__dropdown-items"
className="inline-toolbar-popup__dropdown-items"
onKeyDown={handleKeyDown}
ref={dropDownRef}
>
@@ -277,7 +277,7 @@ export function DropDown({
type="button"
>
<Icon />
<i className="floating-select-toolbar-popup__dropdown-caret" />
<i className="inline-toolbar-popup__dropdown-caret" />
</button>
{showDropDown &&

View File

@@ -1,6 +1,6 @@
@import '../../../../../scss/styles.scss';
@import '../../../../../../scss/styles';
.floating-select-toolbar-popup__dropdown {
.inline-toolbar-popup__dropdown {
display: flex;
align-items: center;
vertical-align: middle;
@@ -29,7 +29,7 @@
&.active {
background-color: var(--color-base-100);
.floating-select-toolbar-popup__dropdown-caret {
.inline-toolbar-popup__dropdown-caret {
&:after {
transform: rotate(0deg);
}
@@ -50,7 +50,7 @@
height: 4px;
opacity: 0.3;
background-image: url(../../../ui/icons/Caret/index.svg);
background-image: url(../../../../ui/icons/Caret/index.svg);
background-position-y: 0px;
background-position-x: 0px;
}
@@ -63,7 +63,7 @@
width: 132.5px;
z-index: 100;
.floating-select-toolbar-popup__dropdown-item {
.inline-toolbar-popup__dropdown-item {
all: unset; // reset all default button styles
cursor: pointer;
color: var(--color-base-900);
@@ -91,7 +91,7 @@
}
html[data-theme='light'] {
.floating-select-toolbar-popup__dropdown {
.inline-toolbar-popup__dropdown {
&-items {
position: absolute;
@include shadow-m;

View File

@@ -0,0 +1,68 @@
'use client'
import React from 'react'
const baseClass = 'inline-toolbar-popup__dropdown'
import type { LexicalEditor } from 'lexical'
import type { InlineToolbarGroupItem } from '../types.js'
import { DropDown, DropDownItem } from './DropDown.js'
import './index.scss'
export const ToolbarItem = ({
anchorElem,
editor,
item,
}: {
anchorElem: HTMLElement
editor: LexicalEditor
item: InlineToolbarGroupItem
}) => {
if (item.Component) {
return (
item?.Component && (
<item.Component anchorElem={anchorElem} editor={editor} item={item} key={item.key} />
)
)
}
return (
<DropDownItem item={item} key={item.key}>
{item?.ChildComponent && <item.ChildComponent />}
<span className="text">{item.label}</span>
</DropDownItem>
)
}
export const ToolbarDropdown = ({
Icon,
anchorElem,
classNames,
editor,
groupKey,
items,
}: {
Icon?: React.FC
anchorElem: HTMLElement
classNames?: string[]
editor: LexicalEditor
groupKey: string
items: InlineToolbarGroupItem[]
}) => {
return (
<DropDown
Icon={Icon}
buttonAriaLabel={`${groupKey} dropdown`}
buttonClassName={[baseClass, `${baseClass}-${groupKey}`, ...(classNames || [])]
.filter(Boolean)
.join(' ')}
key={groupKey}
>
{items.length &&
items.map((item) => {
return <ToolbarItem anchorElem={anchorElem} editor={editor} item={item} key={item.key} />
})}
</DropDown>
)
}

View File

@@ -0,0 +1,40 @@
import type { BaseSelection, LexicalEditor } from 'lexical'
import type React from 'react'
export type InlineToolbarGroup =
| {
ChildComponent?: React.FC
items: Array<InlineToolbarGroupItem>
key: string
order?: number
type: 'dropdown'
}
| {
items: Array<InlineToolbarGroupItem>
key: string
order?: number
type: 'buttons'
}
export type InlineToolbarGroupItem = {
ChildComponent?: React.FC
/** Use component to ignore the children and onClick properties. It does not use the default, pre-defined format Button component */
Component?: React.FC<{
anchorElem: HTMLElement
editor: LexicalEditor
item: InlineToolbarGroupItem
}>
isActive?: ({ editor, selection }: { editor: LexicalEditor; selection: BaseSelection }) => boolean
isEnabled?: ({
editor,
selection,
}: {
editor: LexicalEditor
selection: BaseSelection
}) => boolean
key: string
/** The label is displayed as text if the item is part of a dropdown group */
label?: string
onSelect?: ({ editor, isActive }: { editor: LexicalEditor; isActive: boolean }) => void
order?: number
}

View File

@@ -16,9 +16,7 @@
}
&__quote {
margin: 0;
margin-left: 20px;
margin-bottom: 10px;
margin: 0 0 10px 20px;
font-size: 15px;
color: rgb(101, 103, 107);
border-left-color: rgb(206, 208, 212);
@@ -115,9 +113,7 @@
padding: 8px 8px 8px 52px;
line-height: 1.53;
font-size: 13px;
margin: 0;
margin-top: 8px;
margin-bottom: 8px;
margin: 8px 0;
tab-size: 2;
/* white-space: pre; */
overflow-x: auto;

View File

@@ -29,7 +29,7 @@ let defaultSanitizedServerEditorConfig: SanitizedServerEditorConfig = null
export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapterProvider {
return async ({ config }) => {
let resolvedFeatureMap: ResolvedServerFeatureMap = null
let resolvedFeatureMap: ResolvedServerFeatureMap
let finalSanitizedEditorConfig: SanitizedServerEditorConfig // For server only
if (!props || (!props.features && !props.lexical)) {
@@ -315,11 +315,10 @@ export {
type SerializedBlockNode,
} from './field/features/blocks/nodes/BlocksNode.js'
export { TextDropdownSectionWithEntries } from './field/features/common/floatingSelectToolbarTextDropdownSection/index.js'
export { LinebreakHTMLConverter } from './field/features/converters/html/converter/converters/linebreak.js'
export { ParagraphHTMLConverter } from './field/features/converters/html/converter/converters/paragraph.js'
export { TextHTMLConverter } from './field/features/converters/html/converter/converters/text.js'
export { defaultHTMLConverters } from './field/features/converters/html/converter/defaultConverters.js'
export {
convertLexicalNodesToHTML,
@@ -335,13 +334,13 @@ export {
lexicalHTML,
} from './field/features/converters/html/field/index.js'
export { createClientComponent } from './field/features/createClientComponent.js'
export { TestRecorderFeature } from './field/features/debug/testrecorder/feature.server.js'
export { TreeViewFeature } from './field/features/debug/treeview/feature.server.js'
export { TestRecorderFeature } from './field/features/debug/testRecorder/feature.server.js'
export { TreeViewFeature } from './field/features/debug/treeView/feature.server.js'
export { BoldFeature } from './field/features/format/bold/feature.server.js'
export { InlineCodeFeature } from './field/features/format/inlineCode/feature.server.js'
export { SectionWithEntries as FormatSectionWithEntries } from './field/features/format/common/floatingSelectToolbarSection.js'
export { InlineCodeFeature } from './field/features/format/inlinecode/feature.server.js'
export { ItalicFeature } from './field/features/format/italic/feature.server.js'
export { inlineToolbarFormatGroupWithItems } from './field/features/format/shared/inlineToolbarFormatGroup.js'
export { StrikethroughFeature } from './field/features/format/strikethrough/feature.server.js'
export { SubscriptFeature } from './field/features/format/subscript/feature.server.js'
export { SuperscriptFeature } from './field/features/format/superscript/feature.server.js'
@@ -350,11 +349,11 @@ export {
HeadingFeature,
type HeadingFeatureProps,
} from './field/features/heading/feature.server.js'
export { HorizontalRuleFeature } from './field/features/horizontalRule/feature.server.js'
export { HorizontalRuleFeature } from './field/features/horizontalrule/feature.server.js'
export { IndentFeature } from './field/features/indent/feature.server.js'
export { LinkFeature, type LinkFeatureServerProps } from './field/features/link/feature.server.js'
export {
$createAutoLinkNode,
$isAutoLinkNode,
@@ -371,9 +370,9 @@ export type {
SerializedAutoLinkNode,
SerializedLinkNode,
} from './field/features/link/nodes/types.js'
export { CheckListFeature } from './field/features/lists/checklist/feature.server.js'
export { OrderedListFeature } from './field/features/lists/orderedlist/feature.server.js'
export { UnorderedListFeature } from './field/features/lists/unorderedlist/feature.server.js'
export { ChecklistFeature } from './field/features/lists/checklist/feature.server.js'
export { OrderedListFeature } from './field/features/lists/orderedList/feature.server.js'
export { UnorderedListFeature } from './field/features/lists/unorderedList/feature.server.js'
export { LexicalPluginToLexicalFeature } from './field/features/migrations/lexicalPluginToLexical/feature.server.js'
export { SlateBlockquoteConverter } from './field/features/migrations/slateToLexical/converter/converters/blockquote/index.js'
export { SlateHeadingConverter } from './field/features/migrations/slateToLexical/converter/converters/heading/index.js'
@@ -384,18 +383,18 @@ export { SlateOrderedListConverter } from './field/features/migrations/slateToLe
export { SlateRelationshipConverter } from './field/features/migrations/slateToLexical/converter/converters/relationship/index.js'
export { SlateUnknownConverter } from './field/features/migrations/slateToLexical/converter/converters/unknown/index.js'
export { SlateUnorderedListConverter } from './field/features/migrations/slateToLexical/converter/converters/unorderedList/index.js'
export { SlateUploadConverter } from './field/features/migrations/slateToLexical/converter/converters/upload/index.js'
export { defaultSlateConverters } from './field/features/migrations/slateToLexical/converter/defaultConverters.js'
export {
convertSlateNodesToLexical,
convertSlateToLexical,
} from './field/features/migrations/slateToLexical/converter/index.js'
export type {
SlateNode,
SlateNodeConverter,
} from './field/features/migrations/slateToLexical/converter/types.js'
export { SlateToLexicalFeature } from './field/features/migrations/slateToLexical/feature.server.js'
export { ParagraphFeature } from './field/features/paragraph/feature.server.js'
export {
@@ -409,6 +408,8 @@ export {
RelationshipNode,
type SerializedRelationshipNode,
} from './field/features/relationship/nodes/RelationshipNode.js'
export { inlineToolbarFeatureButtonsGroupWithItems } from './field/features/shared/inlineToolbar/featureButtonsGroup.js'
export { inlineToolbarTextDropdownGroupWithItems } from './field/features/shared/inlineToolbar/textDropdownGroup.js'
export { createNode } from './field/features/typeUtilities.js'
export type {
ClientComponentProps,
@@ -474,17 +475,17 @@ export type {
} from './field/lexical/config/types.js'
export { getEnabledNodes } from './field/lexical/nodes/index.js'
export {
type FloatingToolbarSection,
type FloatingToolbarSectionEntry,
} from './field/lexical/plugins/FloatingSelectToolbar/types.js'
export { ENABLE_SLASH_MENU_COMMAND } from './field/lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/index.js'
export type {
SlashMenuGroup,
SlashMenuItem,
} from './field/lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.js'
export type { AdapterProps }
export {
SlashMenuGroup,
SlashMenuOption,
} from './field/lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.js'
export type {
InlineToolbarGroup,
InlineToolbarGroupItem,
} from './field/lexical/plugins/toolbars/inline/types.js'
export { CAN_USE_DOM } from './field/lexical/utils/canUseDOM.js'
export { cloneDeep } from './field/lexical/utils/cloneDeep.js'
export { getDOMRangeRect } from './field/lexical/utils/getDOMRangeRect.js'

View File

@@ -7,7 +7,7 @@ import {
BlockQuoteFeature,
BlocksFeature,
BoldFeature,
CheckListFeature,
ChecklistFeature,
HeadingFeature,
IndentFeature,
InlineCodeFeature,
@@ -90,7 +90,7 @@ export async function buildConfigWithDefaults(
},
],
}),
CheckListFeature(),
ChecklistFeature(),
UnorderedListFeature(),
OrderedListFeature(),
AlignFeature(),

View File

@@ -209,16 +209,14 @@ describe('lexical', () => {
}
// The following text should now be selected: Node
const floatingToolbar_formatSection = page.locator(
'.floating-select-toolbar-popup__section-format',
)
const floatingToolbar_formatSection = page.locator('.inline-toolbar-popup__group-format')
await expect(floatingToolbar_formatSection).toBeVisible()
await expect(page.locator('.floating-select-toolbar-popup__button').first()).toBeVisible()
await expect(page.locator('.inline-toolbar-popup__button').first()).toBeVisible()
const boldButton = floatingToolbar_formatSection
.locator('.floating-select-toolbar-popup__button')
.locator('.inline-toolbar-popup__button')
.first()
await expect(boldButton).toBeVisible()
@@ -441,16 +439,14 @@ describe('lexical', () => {
}
// The following text should now be selectedelationship node 1
const floatingToolbar_formatSection = page.locator(
'.floating-select-toolbar-popup__section-format',
)
const floatingToolbar_formatSection = page.locator('.inline-toolbar-popup__group-format')
await expect(floatingToolbar_formatSection).toBeVisible()
await expect(page.locator('.floating-select-toolbar-popup__button').first()).toBeVisible()
await expect(page.locator('.inline-toolbar-popup__button').first()).toBeVisible()
const boldButton = floatingToolbar_formatSection
.locator('.floating-select-toolbar-popup__button')
.locator('.inline-toolbar-popup__button')
.first()
await expect(boldButton).toBeVisible()
@@ -521,13 +517,11 @@ describe('lexical', () => {
}
// The following text should now be "Node"
const floatingToolbar = page.locator('.floating-select-toolbar-popup')
const floatingToolbar = page.locator('.inline-toolbar-popup')
await expect(floatingToolbar).toBeVisible()
const linkButton = floatingToolbar
.locator('.floating-select-toolbar-popup__button-link')
.first()
const linkButton = floatingToolbar.locator('.inline-toolbar-popup__button-link').first()
await expect(linkButton).toBeVisible()
await linkButton.click()