diff --git a/packages/richtext-lexical/src/exports/components.ts b/packages/richtext-lexical/src/exports/components.ts index 13a033a165..faeec867ea 100644 --- a/packages/richtext-lexical/src/exports/components.ts +++ b/packages/richtext-lexical/src/exports/components.ts @@ -1,6 +1,11 @@ export { RichTextCell } from '../cell/index.js' -export { RichTextField } from '../field/index.js' +export { ToolbarButton } from '../field/features/toolbars/shared/ToolbarButton/index.js' +export { ToolbarDropdown } from '../field/features/toolbars/shared/ToolbarDropdown/index.js' +export { RichTextField } from '../field/index.js' +export { + type EditorFocusContextType, + EditorFocusProvider, + useEditorFocus, +} from '../field/lexical/EditorFocusProvider.js' export { defaultEditorLexicalConfig } from '../field/lexical/config/client/default.js' -export { ToolbarButton } from '../field/lexical/plugins/toolbars/inline/ToolbarButton/index.js' -export { ToolbarDropdown } from '../field/lexical/plugins/toolbars/inline/ToolbarDropdown/index.js' diff --git a/packages/richtext-lexical/src/field/features/align/feature.client.tsx b/packages/richtext-lexical/src/field/features/align/feature.client.tsx index 3acbcc76f0..2623a09ebf 100644 --- a/packages/richtext-lexical/src/field/features/align/feature.client.tsx +++ b/packages/richtext-lexical/src/field/features/align/feature.client.tsx @@ -1,7 +1,8 @@ 'use client' -import { FORMAT_ELEMENT_COMMAND } from 'lexical' +import { $isElementNode, $isRangeSelection, FORMAT_ELEMENT_COMMAND } from 'lexical' +import type { ToolbarGroup } from '../toolbars/types.js' import type { FeatureProviderProviderClient } from '../types.js' import { AlignCenterIcon } from '../../lexical/ui/icons/AlignCenter/index.js' @@ -9,58 +10,147 @@ 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 { alignGroupWithItems } from './inlineToolbarAlignGroup.js' +import { toolbarAlignGroupWithItems } from './toolbarAlignGroup.js' + +const toolbarGroups: ToolbarGroup[] = [ + toolbarAlignGroupWithItems([ + { + ChildComponent: AlignLeftIcon, + isActive: ({ selection }) => { + if (!$isRangeSelection(selection)) { + return false + } + for (const node of selection.getNodes()) { + if ($isElementNode(node)) { + if (node.getFormatType() === 'left') { + continue + } + } + + const parent = node.getParent() + if ($isElementNode(parent)) { + if (parent.getFormatType() === 'left') { + continue + } + } + + return false + } + return true + }, + key: 'alignLeft', + label: `Align Left`, + onSelect: ({ editor }) => { + editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left') + }, + order: 1, + }, + { + ChildComponent: AlignCenterIcon, + isActive: ({ selection }) => { + if (!$isRangeSelection(selection)) { + return false + } + for (const node of selection.getNodes()) { + if ($isElementNode(node)) { + if (node.getFormatType() === 'center') { + continue + } + } + + const parent = node.getParent() + if ($isElementNode(parent)) { + if (parent.getFormatType() === 'center') { + continue + } + } + + return false + } + return true + }, + key: 'alignCenter', + label: `Align Center`, + onSelect: ({ editor }) => { + editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center') + }, + order: 2, + }, + { + ChildComponent: AlignRightIcon, + isActive: ({ selection }) => { + if (!$isRangeSelection(selection)) { + return false + } + for (const node of selection.getNodes()) { + if ($isElementNode(node)) { + if (node.getFormatType() === 'right') { + continue + } + } + + const parent = node.getParent() + if ($isElementNode(parent)) { + if (parent.getFormatType() === 'right') { + continue + } + } + + return false + } + return true + }, + key: 'alignRight', + label: `Align Right`, + onSelect: ({ editor }) => { + editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right') + }, + order: 3, + }, + { + ChildComponent: AlignJustifyIcon, + isActive: ({ selection }) => { + if (!$isRangeSelection(selection)) { + return false + } + for (const node of selection.getNodes()) { + if ($isElementNode(node)) { + if (node.getFormatType() === 'justify') { + continue + } + } + + const parent = node.getParent() + if ($isElementNode(parent)) { + if (parent.getFormatType() === 'justify') { + continue + } + } + + return false + } + return true + }, + key: 'alignJustify', + label: `Align Justify`, + onSelect: ({ editor }) => { + editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify') + }, + order: 4, + }, + ]), +] const AlignFeatureClient: FeatureProviderProviderClient = (props) => { return { clientFeatureProps: props, feature: () => ({ clientFeatureProps: props, + toolbarFixed: { + groups: toolbarGroups, + }, toolbarInline: { - groups: [ - alignGroupWithItems([ - { - ChildComponent: AlignLeftIcon, - isActive: () => false, - key: 'alignLeft', - label: `Align Left`, - onSelect: ({ editor }) => { - editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left') - }, - order: 1, - }, - { - ChildComponent: AlignCenterIcon, - isActive: () => false, - key: 'alignCenter', - label: `Align Center`, - onSelect: ({ editor }) => { - editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center') - }, - order: 2, - }, - { - ChildComponent: AlignRightIcon, - isActive: () => false, - key: 'alignRight', - label: `Align Right`, - onSelect: ({ editor }) => { - editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right') - }, - order: 3, - }, - { - ChildComponent: AlignJustifyIcon, - isActive: () => false, - key: 'alignJustify', - label: `Align Justify`, - onSelect: ({ editor }) => { - editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify') - }, - order: 4, - }, - ]), - ], + groups: toolbarGroups, }, }), } diff --git a/packages/richtext-lexical/src/field/features/align/inlineToolbarAlignGroup.ts b/packages/richtext-lexical/src/field/features/align/inlineToolbarAlignGroup.ts deleted file mode 100644 index e559e82e67..0000000000 --- a/packages/richtext-lexical/src/field/features/align/inlineToolbarAlignGroup.ts +++ /dev/null @@ -1,16 +0,0 @@ -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, - } -} diff --git a/packages/richtext-lexical/src/field/features/align/toolbarAlignGroup.ts b/packages/richtext-lexical/src/field/features/align/toolbarAlignGroup.ts new file mode 100644 index 0000000000..2b6f540ffb --- /dev/null +++ b/packages/richtext-lexical/src/field/features/align/toolbarAlignGroup.ts @@ -0,0 +1,13 @@ +import type { ToolbarGroup, ToolbarGroupItem } from '../toolbars/types.js' + +import { AlignLeftIcon } from '../../lexical/ui/icons/AlignLeft/index.js' + +export const toolbarAlignGroupWithItems = (items: ToolbarGroupItem[]): ToolbarGroup => { + return { + type: 'dropdown', + ChildComponent: AlignLeftIcon, + items, + key: 'align', + order: 30, + } +} diff --git a/packages/richtext-lexical/src/field/features/blockquote/feature.client.tsx b/packages/richtext-lexical/src/field/features/blockquote/feature.client.tsx index ce6ac160e2..d8ca8c9f8f 100644 --- a/packages/richtext-lexical/src/field/features/blockquote/feature.client.tsx +++ b/packages/richtext-lexical/src/field/features/blockquote/feature.client.tsx @@ -1,16 +1,45 @@ 'use client' -import { $createQuoteNode, QuoteNode } from '@lexical/rich-text' +import { $createQuoteNode, $isQuoteNode, QuoteNode } from '@lexical/rich-text' import { $setBlocksType } from '@lexical/selection' -import { $getSelection } from 'lexical' +import { $getSelection, $isRangeSelection } from 'lexical' +import type { ToolbarGroup } from '../toolbars/types.js' import type { FeatureProviderProviderClient } from '../types.js' import { BlockquoteIcon } from '../../lexical/ui/icons/Blockquote/index.js' import { createClientComponent } from '../createClientComponent.js' -import { inlineToolbarTextDropdownGroupWithItems } from '../shared/inlineToolbar/textDropdownGroup.js' +import { toolbarTextDropdownGroupWithItems } from '../shared/toolbar/textDropdownGroup.js' import { MarkdownTransformer } from './markdownTransformer.js' +const toolbarGroups: ToolbarGroup[] = [ + toolbarTextDropdownGroupWithItems([ + { + ChildComponent: BlockquoteIcon, + isActive: ({ selection }) => { + if (!$isRangeSelection(selection)) { + return false + } + for (const node of selection.getNodes()) { + if (!$isQuoteNode(node) && !$isQuoteNode(node.getParent())) { + return false + } + } + return true + }, + key: 'blockquote', + label: `Blockquote`, + onSelect: ({ editor }) => { + editor.update(() => { + const selection = $getSelection() + $setBlocksType(selection, () => $createQuoteNode()) + }) + }, + order: 20, + }, + ]), +] + const BlockQuoteFeatureClient: FeatureProviderProviderClient = (props) => { return { clientFeatureProps: props, @@ -22,13 +51,12 @@ const BlockQuoteFeatureClient: FeatureProviderProviderClient = (props slashMenu: { groups: [ { - displayName: 'Basic', items: [ { Icon: BlockquoteIcon, - displayName: 'Blockquote', key: 'blockquote', keywords: ['quote', 'blockquote'], + label: 'Blockquote', onSelect: ({ editor }) => { editor.update(() => { const selection = $getSelection() @@ -38,27 +66,15 @@ const BlockQuoteFeatureClient: FeatureProviderProviderClient = (props }, ], key: 'basic', + label: 'Basic', }, ], }, + toolbarFixed: { + groups: toolbarGroups, + }, toolbarInline: { - groups: [ - inlineToolbarTextDropdownGroupWithItems([ - { - ChildComponent: BlockquoteIcon, - isActive: () => false, - key: 'blockquote', - label: `Blockquote`, - onSelect: ({ editor }) => { - editor.update(() => { - const selection = $getSelection() - $setBlocksType(selection, () => $createQuoteNode()) - }) - }, - order: 20, - }, - ]), - ], + groups: toolbarGroups, }, }), } diff --git a/packages/richtext-lexical/src/field/features/blocks/component/index.tsx b/packages/richtext-lexical/src/field/features/blocks/component/index.tsx index 46486612a4..74099a9eca 100644 --- a/packages/richtext-lexical/src/field/features/blocks/component/index.tsx +++ b/packages/richtext-lexical/src/field/features/blocks/component/index.tsx @@ -187,6 +187,5 @@ export const BlockComponent: React.FC = (props) => { schemaFieldsPath, path, ]) // Adding formData to the dependencies here might break it - - return
{formContent}
+ return
{formContent}
} diff --git a/packages/richtext-lexical/src/field/features/blocks/feature.client.tsx b/packages/richtext-lexical/src/field/features/blocks/feature.client.tsx index fd8e7a47cb..a540917c45 100644 --- a/packages/richtext-lexical/src/field/features/blocks/feature.client.tsx +++ b/packages/richtext-lexical/src/field/features/blocks/feature.client.tsx @@ -31,19 +31,18 @@ const BlocksFeatureClient: FeatureProviderProviderClient { return { Icon: BlockIcon, - displayName: ({ i18n }) => { + key: 'block-' + block.slug, + keywords: ['block', 'blocks', block.slug], + label: ({ i18n }) => { if (!block.labels.singular) { return 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, @@ -54,6 +53,39 @@ const BlocksFeatureClient: FeatureProviderProviderClient { + return { + ChildComponent: BlockIcon, + isActive: undefined, // At this point, we would be inside a sub-richtext-editor. And at this point this will be run against the focused sub-editor, not the parent editor which has the actual block. Thus, no point in running this + key: 'block-' + block.slug, + label: ({ i18n }) => { + if (!block.labels.singular) { + return block.slug + } + + return getTranslation(block.labels.singular, i18n) + }, + onSelect: ({ editor }) => { + editor.dispatchCommand(INSERT_BLOCK_COMMAND, { + id: null, + blockName: '', + blockType: block.slug, + }) + }, + order: index, + } + }), + key: 'blocks', + order: 20, }, ], }, diff --git a/packages/richtext-lexical/src/field/features/format/bold/feature.client.tsx b/packages/richtext-lexical/src/field/features/format/bold/feature.client.tsx index 3399054576..90f700c570 100644 --- a/packages/richtext-lexical/src/field/features/format/bold/feature.client.tsx +++ b/packages/richtext-lexical/src/field/features/format/bold/feature.client.tsx @@ -1,11 +1,12 @@ 'use client' import { $isRangeSelection, FORMAT_TEXT_COMMAND } from 'lexical' +import type { ToolbarGroup } from '../../toolbars/types.js' import type { FeatureProviderProviderClient } from '../../types.js' import { BoldIcon } from '../../../lexical/ui/icons/Bold/index.js' import { createClientComponent } from '../../createClientComponent.js' -import { inlineToolbarFormatGroupWithItems } from '../shared/inlineToolbarFormatGroup.js' +import { toolbarFormatGroupWithItems } from '../shared/toolbarFormatGroup.js' import { BOLD_ITALIC_STAR, BOLD_ITALIC_UNDERSCORE, @@ -13,6 +14,25 @@ import { BOLD_UNDERSCORE, } from './markdownTransformers.js' +const toolbarGroups: ToolbarGroup[] = [ + toolbarFormatGroupWithItems([ + { + ChildComponent: BoldIcon, + isActive: ({ selection }) => { + if ($isRangeSelection(selection)) { + return selection.hasFormat('bold') + } + return false + }, + key: 'bold', + onSelect: ({ editor }) => { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold') + }, + order: 1, + }, + ]), +] + const BoldFeatureClient: FeatureProviderProviderClient = (props) => { return { clientFeatureProps: props, @@ -25,25 +45,11 @@ const BoldFeatureClient: FeatureProviderProviderClient = (props) => { return { clientFeatureProps: props, markdownTransformers, + toolbarFixed: { + groups: toolbarGroups, + }, toolbarInline: { - groups: [ - inlineToolbarFormatGroupWithItems([ - { - ChildComponent: BoldIcon, - isActive: ({ selection }) => { - if ($isRangeSelection(selection)) { - return selection.hasFormat('bold') - } - return false - }, - key: 'bold', - onSelect: ({ editor }) => { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold') - }, - order: 1, - }, - ]), - ], + groups: toolbarGroups, }, } }, diff --git a/packages/richtext-lexical/src/field/features/format/inlineCode/feature.client.tsx b/packages/richtext-lexical/src/field/features/format/inlineCode/feature.client.tsx index f36201a582..618f3f2822 100644 --- a/packages/richtext-lexical/src/field/features/format/inlineCode/feature.client.tsx +++ b/packages/richtext-lexical/src/field/features/format/inlineCode/feature.client.tsx @@ -2,13 +2,33 @@ import { $isRangeSelection, FORMAT_TEXT_COMMAND } from 'lexical' +import type { ToolbarGroup } from '../../toolbars/types.js' import type { FeatureProviderProviderClient } from '../../types.js' import { CodeIcon } from '../../../lexical/ui/icons/Code/index.js' import { createClientComponent } from '../../createClientComponent.js' -import { inlineToolbarFormatGroupWithItems } from '../shared/inlineToolbarFormatGroup.js' +import { toolbarFormatGroupWithItems } from '../shared/toolbarFormatGroup.js' import { INLINE_CODE } from './markdownTransformers.js' +const toolbarGroups: ToolbarGroup[] = [ + toolbarFormatGroupWithItems([ + { + ChildComponent: CodeIcon, + isActive: ({ selection }) => { + if ($isRangeSelection(selection)) { + return selection.hasFormat('code') + } + return false + }, + key: 'inlineCode', + onSelect: ({ editor }) => { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code') + }, + order: 7, + }, + ]), +] + const InlineCodeFeatureClient: FeatureProviderProviderClient = (props) => { return { clientFeatureProps: props, @@ -17,25 +37,11 @@ const InlineCodeFeatureClient: FeatureProviderProviderClient = (props clientFeatureProps: props, markdownTransformers: [INLINE_CODE], + toolbarFixed: { + groups: toolbarGroups, + }, toolbarInline: { - groups: [ - inlineToolbarFormatGroupWithItems([ - { - ChildComponent: CodeIcon, - isActive: ({ selection }) => { - if ($isRangeSelection(selection)) { - return selection.hasFormat('code') - } - return false - }, - key: 'inlineCode', - onSelect: ({ editor }) => { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code') - }, - order: 7, - }, - ]), - ], + groups: toolbarGroups, }, } }, diff --git a/packages/richtext-lexical/src/field/features/format/italic/feature.client.tsx b/packages/richtext-lexical/src/field/features/format/italic/feature.client.tsx index bf46776d31..bebea20625 100644 --- a/packages/richtext-lexical/src/field/features/format/italic/feature.client.tsx +++ b/packages/richtext-lexical/src/field/features/format/italic/feature.client.tsx @@ -2,13 +2,33 @@ import { $isRangeSelection, FORMAT_TEXT_COMMAND } from 'lexical' +import type { ToolbarGroup } from '../../toolbars/types.js' import type { FeatureProviderProviderClient } from '../../types.js' import { ItalicIcon } from '../../../lexical/ui/icons/Italic/index.js' import { createClientComponent } from '../../createClientComponent.js' -import { inlineToolbarFormatGroupWithItems } from '../shared/inlineToolbarFormatGroup.js' +import { toolbarFormatGroupWithItems } from '../shared/toolbarFormatGroup.js' import { ITALIC_STAR, ITALIC_UNDERSCORE } from './markdownTransformers.js' +const toolbarGroups: ToolbarGroup[] = [ + toolbarFormatGroupWithItems([ + { + ChildComponent: ItalicIcon, + isActive: ({ selection }) => { + if ($isRangeSelection(selection)) { + return selection.hasFormat('italic') + } + return false + }, + key: 'italic', + onSelect: ({ editor }) => { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic') + }, + order: 2, + }, + ]), +] + const ItalicFeatureClient: FeatureProviderProviderClient = (props) => { return { clientFeatureProps: props, @@ -17,25 +37,11 @@ const ItalicFeatureClient: FeatureProviderProviderClient = (props) => clientFeatureProps: props, markdownTransformers: [ITALIC_STAR, ITALIC_UNDERSCORE], + toolbarFixed: { + groups: toolbarGroups, + }, toolbarInline: { - groups: [ - inlineToolbarFormatGroupWithItems([ - { - ChildComponent: ItalicIcon, - isActive: ({ selection }) => { - if ($isRangeSelection(selection)) { - return selection.hasFormat('italic') - } - return false - }, - key: 'italic', - onSelect: ({ editor }) => { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic') - }, - order: 2, - }, - ]), - ], + groups: toolbarGroups, }, } }, diff --git a/packages/richtext-lexical/src/field/features/format/shared/inlineToolbarFormatGroup.ts b/packages/richtext-lexical/src/field/features/format/shared/inlineToolbarFormatGroup.ts deleted file mode 100644 index d26dd61d68..0000000000 --- a/packages/richtext-lexical/src/field/features/format/shared/inlineToolbarFormatGroup.ts +++ /dev/null @@ -1,15 +0,0 @@ -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, - } -} diff --git a/packages/richtext-lexical/src/field/features/format/shared/toolbarFormatGroup.ts b/packages/richtext-lexical/src/field/features/format/shared/toolbarFormatGroup.ts new file mode 100644 index 0000000000..180bf0faa6 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/format/shared/toolbarFormatGroup.ts @@ -0,0 +1,10 @@ +import type { ToolbarGroup, ToolbarGroupItem } from '../../toolbars/types.js' + +export const toolbarFormatGroupWithItems = (items: ToolbarGroupItem[]): ToolbarGroup => { + return { + type: 'buttons', + items, + key: 'format', + order: 40, + } +} diff --git a/packages/richtext-lexical/src/field/features/format/strikethrough/feature.client.tsx b/packages/richtext-lexical/src/field/features/format/strikethrough/feature.client.tsx index 5463f73b99..b6acc50df2 100644 --- a/packages/richtext-lexical/src/field/features/format/strikethrough/feature.client.tsx +++ b/packages/richtext-lexical/src/field/features/format/strikethrough/feature.client.tsx @@ -6,9 +6,28 @@ import type { FeatureProviderProviderClient } from '../../types.js' import { StrikethroughIcon } from '../../../lexical/ui/icons/Strikethrough/index.js' import { createClientComponent } from '../../createClientComponent.js' -import { inlineToolbarFormatGroupWithItems } from '../shared/inlineToolbarFormatGroup.js' +import { toolbarFormatGroupWithItems } from '../shared/toolbarFormatGroup.js' import { STRIKETHROUGH } from './markdownTransformers.js' +const toolbarGroups = [ + toolbarFormatGroupWithItems([ + { + ChildComponent: StrikethroughIcon, + isActive: ({ selection }) => { + if ($isRangeSelection(selection)) { + return selection.hasFormat('strikethrough') + } + return false + }, + key: 'strikethrough', + onSelect: ({ editor }) => { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough') + }, + order: 4, + }, + ]), +] + const StrikethroughFeatureClient: FeatureProviderProviderClient = (props) => { return { clientFeatureProps: props, @@ -17,25 +36,11 @@ const StrikethroughFeatureClient: FeatureProviderProviderClient = (pr clientFeatureProps: props, markdownTransformers: [STRIKETHROUGH], + toolbarFixed: { + groups: toolbarGroups, + }, toolbarInline: { - groups: [ - inlineToolbarFormatGroupWithItems([ - { - ChildComponent: StrikethroughIcon, - isActive: ({ selection }) => { - if ($isRangeSelection(selection)) { - return selection.hasFormat('strikethrough') - } - return false - }, - key: 'strikethrough', - onSelect: ({ editor }) => { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough') - }, - order: 4, - }, - ]), - ], + groups: toolbarGroups, }, } }, diff --git a/packages/richtext-lexical/src/field/features/format/subscript/feature.client.tsx b/packages/richtext-lexical/src/field/features/format/subscript/feature.client.tsx index 02a219871a..891399fde8 100644 --- a/packages/richtext-lexical/src/field/features/format/subscript/feature.client.tsx +++ b/packages/richtext-lexical/src/field/features/format/subscript/feature.client.tsx @@ -2,11 +2,31 @@ import { $isRangeSelection, FORMAT_TEXT_COMMAND } from 'lexical' +import type { ToolbarGroup } from '../../toolbars/types.js' import type { FeatureProviderProviderClient } from '../../types.js' import { SubscriptIcon } from '../../../lexical/ui/icons/Subscript/index.js' import { createClientComponent } from '../../createClientComponent.js' -import { inlineToolbarFormatGroupWithItems } from '../shared/inlineToolbarFormatGroup.js' +import { toolbarFormatGroupWithItems } from '../shared/toolbarFormatGroup.js' + +const toolbarGroups: ToolbarGroup[] = [ + toolbarFormatGroupWithItems([ + { + ChildComponent: SubscriptIcon, + isActive: ({ selection }) => { + if ($isRangeSelection(selection)) { + return selection.hasFormat('subscript') + } + return false + }, + key: 'subscript', + onSelect: ({ editor }) => { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript') + }, + order: 5, + }, + ]), +] const SubscriptFeatureClient: FeatureProviderProviderClient = (props) => { return { @@ -14,25 +34,11 @@ const SubscriptFeatureClient: FeatureProviderProviderClient = (props) feature: () => { return { clientFeatureProps: props, + toolbarFixed: { + groups: toolbarGroups, + }, toolbarInline: { - groups: [ - inlineToolbarFormatGroupWithItems([ - { - ChildComponent: SubscriptIcon, - isActive: ({ selection }) => { - if ($isRangeSelection(selection)) { - return selection.hasFormat('subscript') - } - return false - }, - key: 'subscript', - onSelect: ({ editor }) => { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript') - }, - order: 5, - }, - ]), - ], + groups: toolbarGroups, }, } }, diff --git a/packages/richtext-lexical/src/field/features/format/superscript/feature.client.tsx b/packages/richtext-lexical/src/field/features/format/superscript/feature.client.tsx index a482a05619..e9e56f150c 100644 --- a/packages/richtext-lexical/src/field/features/format/superscript/feature.client.tsx +++ b/packages/richtext-lexical/src/field/features/format/superscript/feature.client.tsx @@ -2,11 +2,31 @@ import { $isRangeSelection, FORMAT_TEXT_COMMAND } from 'lexical' +import type { ToolbarGroup } from '../../toolbars/types.js' import type { FeatureProviderProviderClient } from '../../types.js' import { SuperscriptIcon } from '../../../lexical/ui/icons/Superscript/index.js' import { createClientComponent } from '../../createClientComponent.js' -import { inlineToolbarFormatGroupWithItems } from '../shared/inlineToolbarFormatGroup.js' +import { toolbarFormatGroupWithItems } from '../shared/toolbarFormatGroup.js' + +const toolbarGroups: ToolbarGroup[] = [ + toolbarFormatGroupWithItems([ + { + ChildComponent: SuperscriptIcon, + isActive: ({ selection }) => { + if ($isRangeSelection(selection)) { + return selection.hasFormat('superscript') + } + return false + }, + key: 'superscript', + onSelect: ({ editor }) => { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript') + }, + order: 6, + }, + ]), +] const SuperscriptFeatureClient: FeatureProviderProviderClient = (props) => { return { @@ -14,25 +34,11 @@ const SuperscriptFeatureClient: FeatureProviderProviderClient = (prop feature: () => { return { clientFeatureProps: props, + toolbarFixed: { + groups: toolbarGroups, + }, toolbarInline: { - groups: [ - inlineToolbarFormatGroupWithItems([ - { - ChildComponent: SuperscriptIcon, - isActive: ({ selection }) => { - if ($isRangeSelection(selection)) { - return selection.hasFormat('superscript') - } - return false - }, - key: 'superscript', - onSelect: ({ editor }) => { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript') - }, - order: 6, - }, - ]), - ], + groups: toolbarGroups, }, } }, diff --git a/packages/richtext-lexical/src/field/features/format/underline/feature.client.tsx b/packages/richtext-lexical/src/field/features/format/underline/feature.client.tsx index 2028ebe27e..ae62287a36 100644 --- a/packages/richtext-lexical/src/field/features/format/underline/feature.client.tsx +++ b/packages/richtext-lexical/src/field/features/format/underline/feature.client.tsx @@ -2,11 +2,31 @@ import { $isRangeSelection, FORMAT_TEXT_COMMAND } from 'lexical' +import type { ToolbarGroup } from '../../toolbars/types.js' import type { FeatureProviderProviderClient } from '../../types.js' import { UnderlineIcon } from '../../../lexical/ui/icons/Underline/index.js' import { createClientComponent } from '../../createClientComponent.js' -import { inlineToolbarFormatGroupWithItems } from '../shared/inlineToolbarFormatGroup.js' +import { toolbarFormatGroupWithItems } from '../shared/toolbarFormatGroup.js' + +const toolbarGroups: ToolbarGroup[] = [ + toolbarFormatGroupWithItems([ + { + ChildComponent: UnderlineIcon, + isActive: ({ selection }) => { + if ($isRangeSelection(selection)) { + return selection.hasFormat('underline') + } + return false + }, + key: 'underline', + onSelect: ({ editor }) => { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline') + }, + order: 3, + }, + ]), +] const UnderlineFeatureClient: FeatureProviderProviderClient = (props) => { return { @@ -14,25 +34,11 @@ const UnderlineFeatureClient: FeatureProviderProviderClient = (props) feature: () => { return { clientFeatureProps: props, + toolbarFixed: { + groups: toolbarGroups, + }, toolbarInline: { - groups: [ - inlineToolbarFormatGroupWithItems([ - { - ChildComponent: UnderlineIcon, - isActive: ({ selection }) => { - if ($isRangeSelection(selection)) { - return selection.hasFormat('underline') - } - return false - }, - key: 'underline', - onSelect: ({ editor }) => { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline') - }, - order: 3, - }, - ]), - ], + groups: toolbarGroups, }, } }, diff --git a/packages/richtext-lexical/src/field/features/heading/feature.client.tsx b/packages/richtext-lexical/src/field/features/heading/feature.client.tsx index 43ef5a9bd3..87123f8025 100644 --- a/packages/richtext-lexical/src/field/features/heading/feature.client.tsx +++ b/packages/richtext-lexical/src/field/features/heading/feature.client.tsx @@ -2,10 +2,12 @@ import type { HeadingTagType } from '@lexical/rich-text' +import { $isHeadingNode } from '@lexical/rich-text' import { $createHeadingNode, HeadingNode } from '@lexical/rich-text' import { $setBlocksType } from '@lexical/selection' -import { $getSelection } from 'lexical' +import { $getSelection, $isRangeSelection } from 'lexical' +import type { ToolbarGroup } from '../toolbars/types.js' import type { FeatureProviderProviderClient } from '../types.js' import type { HeadingFeatureProps } from './feature.server.js' @@ -16,7 +18,7 @@ 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 { createClientComponent } from '../createClientComponent.js' -import { inlineToolbarTextDropdownGroupWithItems } from '../shared/inlineToolbar/textDropdownGroup.js' +import { toolbarTextDropdownGroupWithItems } from '../shared/toolbar/textDropdownGroup.js' import { MarkdownTransformer } from './markdownTransformer.js' const setHeading = (headingSize: HeadingTagType) => { @@ -34,11 +36,47 @@ const iconImports = { } const HeadingFeatureClient: FeatureProviderProviderClient = (props) => { - const { enabledHeadingSizes = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] } = props - return { clientFeatureProps: props, feature: () => { + const { enabledHeadingSizes = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] } = props + + const toolbarGroups: ToolbarGroup[] = [ + toolbarTextDropdownGroupWithItems( + enabledHeadingSizes.map((headingSize, i) => { + return { + ChildComponent: iconImports[headingSize], + isActive: ({ selection }) => { + if (!$isRangeSelection(selection)) { + return false + } + for (const node of selection.getNodes()) { + if ($isHeadingNode(node) && node.getTag() === headingSize) { + continue + } + + const parent = node.getParent() + if ($isHeadingNode(parent) && parent.getTag() === headingSize) { + continue + } + + return false + } + return true + }, + key: headingSize, + label: `Heading ${headingSize.charAt(1)}`, + onSelect: ({ editor }) => { + editor.update(() => { + setHeading(headingSize) + }) + }, + order: i + 2, + } + }), + ), + ] + return { clientFeatureProps: props, markdownTransformers: [MarkdownTransformer(enabledHeadingSizes)], @@ -47,13 +85,12 @@ const HeadingFeatureClient: FeatureProviderProviderClient = 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], + label: `Heading ${headingSize.charAt(1)}`, onSelect: ({ editor }) => { editor.update(() => { setHeading(headingSize) @@ -62,31 +99,16 @@ const HeadingFeatureClient: FeatureProviderProviderClient = } }), key: 'basic', + label: 'Basic', }, ] : [], }, + toolbarFixed: { + groups: enabledHeadingSizes?.length ? toolbarGroups : [], + }, 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, - } - }), - ), - ] - : [], + groups: enabledHeadingSizes?.length ? toolbarGroups : [], }, } }, diff --git a/packages/richtext-lexical/src/field/features/horizontalRule/feature.client.tsx b/packages/richtext-lexical/src/field/features/horizontalRule/feature.client.tsx index 11faf6827a..17f9b62c2e 100644 --- a/packages/richtext-lexical/src/field/features/horizontalRule/feature.client.tsx +++ b/packages/richtext-lexical/src/field/features/horizontalRule/feature.client.tsx @@ -1,11 +1,18 @@ 'use client' +import { $isNodeSelection } from 'lexical' + import type { FeatureProviderProviderClient } from '../types.js' import { HorizontalRuleIcon } from '../../lexical/ui/icons/HorizontalRule/index.js' import { createClientComponent } from '../createClientComponent.js' +import { toolbarAddDropdownGroupWithItems } from '../shared/toolbar/addDropdownGroup.js' import { MarkdownTransformer } from './markdownTransformer.js' -import { HorizontalRuleNode, INSERT_HORIZONTAL_RULE_COMMAND } from './nodes/HorizontalRuleNode.js' +import { + $isHorizontalRuleNode, + HorizontalRuleNode, + INSERT_HORIZONTAL_RULE_COMMAND, +} from './nodes/HorizontalRuleNode.js' import { HorizontalRulePlugin } from './plugin/index.js' const HorizontalRuleFeatureClient: FeatureProviderProviderClient = (props) => { @@ -24,22 +31,44 @@ const HorizontalRuleFeatureClient: FeatureProviderProviderClient = (p slashMenu: { groups: [ { - displayName: 'Basic', items: [ { Icon: HorizontalRuleIcon, - displayName: `Horizontal Rule`, key: 'horizontalRule', keywords: ['hr', 'horizontal rule', 'line', 'separator'], + label: `Horizontal Rule`, onSelect: ({ editor }) => { editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined) }, }, ], key: 'basic', + label: 'Basic', }, ], }, + toolbarFixed: { + groups: [ + toolbarAddDropdownGroupWithItems([ + { + ChildComponent: HorizontalRuleIcon, + isActive: ({ selection }) => { + if (!$isNodeSelection(selection) || !selection.getNodes().length) { + return false + } + + const firstNode = selection.getNodes()[0] + return $isHorizontalRuleNode(firstNode) + }, + key: 'horizontalRule', + label: `Horizontal Rule`, + onSelect: ({ editor }) => { + editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined) + }, + }, + ]), + ], + }, }), } } diff --git a/packages/richtext-lexical/src/field/features/indent/feature.client.tsx b/packages/richtext-lexical/src/field/features/indent/feature.client.tsx index 286935877e..78fbc61aca 100644 --- a/packages/richtext-lexical/src/field/features/indent/feature.client.tsx +++ b/packages/richtext-lexical/src/field/features/indent/feature.client.tsx @@ -2,60 +2,64 @@ import { INDENT_CONTENT_COMMAND, OUTDENT_CONTENT_COMMAND } from 'lexical' +import type { ToolbarGroup } from '../toolbars/types.js' 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 { indentGroupWithItems } from './inlineToolbarIndentGroup.js' +import { toolbarIndentGroupWithItems } from './toolbarIndentGroup.js' + +const toolbarGroups: ToolbarGroup[] = [ + toolbarIndentGroupWithItems([ + { + ChildComponent: IndentDecreaseIcon, + isActive: () => false, + isEnabled: ({ selection }) => { + if (!selection || !selection?.getNodes()?.length) { + return false + } + for (const node of selection.getNodes()) { + // If at least one node is indented, this should be active + if ( + ('__indent' in node && (node.__indent as number) > 0) || + (node.getParent() && '__indent' in node.getParent() && node.getParent().__indent > 0) + ) { + return true + } + } + return false + }, + key: 'indentDecrease', + label: `Decrease Indent`, + onSelect: ({ editor }) => { + editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined) + }, + order: 1, + }, + { + ChildComponent: IndentIncreaseIcon, + isActive: () => false, + key: 'indentIncrease', + label: `Increase Indent`, + onSelect: ({ editor }) => { + editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined) + }, + order: 2, + }, + ]), +] const IndentFeatureClient: FeatureProviderProviderClient = (props) => { return { clientFeatureProps: props, feature: () => ({ clientFeatureProps: props, + toolbarFixed: { + groups: toolbarGroups, + }, toolbarInline: { - groups: [ - indentGroupWithItems([ - { - ChildComponent: IndentDecreaseIcon, - isActive: () => false, - isEnabled: ({ selection }) => { - if (!selection || !selection?.getNodes()?.length) { - return false - } - for (const node of selection.getNodes()) { - // If at least one node is indented, this should be active - if ( - ('__indent' in node && (node.__indent as number) > 0) || - (node.getParent() && - '__indent' in node.getParent() && - node.getParent().__indent > 0) - ) { - return true - } - } - return false - }, - key: 'indentDecrease', - label: `Decrease Indent`, - onSelect: ({ editor }) => { - editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined) - }, - order: 1, - }, - { - ChildComponent: IndentIncreaseIcon, - isActive: () => false, - key: 'indentIncrease', - label: `Increase Indent`, - onSelect: ({ editor }) => { - editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined) - }, - order: 2, - }, - ]), - ], + groups: toolbarGroups, }, }), } diff --git a/packages/richtext-lexical/src/field/features/indent/inlineToolbarIndentGroup.ts b/packages/richtext-lexical/src/field/features/indent/inlineToolbarIndentGroup.ts deleted file mode 100644 index 623c58068f..0000000000 --- a/packages/richtext-lexical/src/field/features/indent/inlineToolbarIndentGroup.ts +++ /dev/null @@ -1,13 +0,0 @@ -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, - } -} diff --git a/packages/richtext-lexical/src/field/features/indent/toolbarIndentGroup.ts b/packages/richtext-lexical/src/field/features/indent/toolbarIndentGroup.ts new file mode 100644 index 0000000000..ab454ce0b5 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/indent/toolbarIndentGroup.ts @@ -0,0 +1,10 @@ +import type { ToolbarGroup, ToolbarGroupItem } from '../toolbars/types.js' + +export const toolbarIndentGroupWithItems = (items: ToolbarGroupItem[]): ToolbarGroup => { + return { + type: 'buttons', + items, + key: 'indent', + order: 35, + } +} diff --git a/packages/richtext-lexical/src/field/features/link/feature.client.tsx b/packages/richtext-lexical/src/field/features/link/feature.client.tsx index 9549772c5b..6aa3efe75d 100644 --- a/packages/richtext-lexical/src/field/features/link/feature.client.tsx +++ b/packages/richtext-lexical/src/field/features/link/feature.client.tsx @@ -3,6 +3,7 @@ import { $findMatchingParent } from '@lexical/utils' import { $getSelection, $isRangeSelection } from 'lexical' +import type { ToolbarGroup } from '../toolbars/types.js' import type { FeatureProviderProviderClient } from '../types.js' import type { ExclusiveLinkCollectionsProps } from './feature.server.js' import type { LinkFields } from './nodes/types.js' @@ -10,7 +11,7 @@ import type { LinkFields } from './nodes/types.js' import { LinkIcon } from '../../lexical/ui/icons/Link/index.js' import { getSelectedNode } from '../../lexical/utils/getSelectedNode.js' import { createClientComponent } from '../createClientComponent.js' -import { inlineToolbarFeatureButtonsGroupWithItems } from '../shared/inlineToolbar/featureButtonsGroup.js' +import { toolbarFeatureButtonsGroupWithItems } from '../shared/toolbar/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' @@ -21,6 +22,46 @@ import { LinkPlugin } from './plugins/link/index.js' export type ClientProps = ExclusiveLinkCollectionsProps +const toolbarGroups: ToolbarGroup[] = [ + toolbarFeatureButtonsGroupWithItems([ + { + ChildComponent: LinkIcon, + isActive: ({ selection }) => { + if ($isRangeSelection(selection)) { + const selectedNode = getSelectedNode(selection) + const linkParent = $findMatchingParent(selectedNode, $isLinkNode) + return linkParent != null + } + return false + }, + key: 'link', + label: `Link`, + onSelect: ({ editor, isActive }) => { + if (!isActive) { + let selectedText = null + editor.getEditorState().read(() => { + selectedText = $getSelection().getTextContent() + }) + const linkFields: LinkFields = { + doc: null, + linkType: 'custom', + newTab: false, + url: 'https://', + } + editor.dispatchCommand(TOGGLE_LINK_WITH_MODAL_COMMAND, { + fields: linkFields, + text: selectedText, + }) + } else { + // remove link + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null) + } + }, + order: 1, + }, + ]), +] + const LinkFeatureClient: FeatureProviderProviderClient = (props) => { return { clientFeatureProps: props, @@ -45,46 +86,11 @@ const LinkFeatureClient: FeatureProviderProviderClient = (props) => position: 'floatingAnchorElem', }, ], + toolbarFixed: { + groups: toolbarGroups, + }, toolbarInline: { - groups: [ - inlineToolbarFeatureButtonsGroupWithItems([ - { - ChildComponent: LinkIcon, - isActive: ({ selection }) => { - if ($isRangeSelection(selection)) { - const selectedNode = getSelectedNode(selection) - const linkParent = $findMatchingParent(selectedNode, $isLinkNode) - return linkParent != null - } - return false - }, - key: 'link', - label: `Link`, - onSelect: ({ editor, isActive }) => { - if (!isActive) { - let selectedText = null - editor.getEditorState().read(() => { - selectedText = $getSelection().getTextContent() - }) - const linkFields: LinkFields = { - doc: null, - linkType: 'custom', - newTab: false, - url: 'https://', - } - editor.dispatchCommand(TOGGLE_LINK_WITH_MODAL_COMMAND, { - fields: linkFields, - text: selectedText, - }) - } else { - // remove link - editor.dispatchCommand(TOGGLE_LINK_COMMAND, null) - } - }, - order: 1, - }, - ]), - ], + groups: toolbarGroups, }, }), } diff --git a/packages/richtext-lexical/src/field/features/lists/checklist/feature.client.tsx b/packages/richtext-lexical/src/field/features/lists/checklist/feature.client.tsx index 37b867ce37..57c0b7d4d5 100644 --- a/packages/richtext-lexical/src/field/features/lists/checklist/feature.client.tsx +++ b/packages/richtext-lexical/src/field/features/lists/checklist/feature.client.tsx @@ -1,15 +1,56 @@ 'use client' -import { INSERT_CHECK_LIST_COMMAND, ListItemNode, ListNode } from '@lexical/list' +import { $isListNode, INSERT_CHECK_LIST_COMMAND, ListItemNode, ListNode } from '@lexical/list' +import { $isRangeSelection } from 'lexical' +import type { ToolbarGroup } from '../../toolbars/types.js' import type { ClientFeature, FeatureProviderProviderClient } from '../../types.js' import { ChecklistIcon } from '../../../lexical/ui/icons/Checklist/index.js' import { createClientComponent } from '../../createClientComponent.js' -import { inlineToolbarTextDropdownGroupWithItems } from '../../shared/inlineToolbar/textDropdownGroup.js' +import { toolbarTextDropdownGroupWithItems } from '../../shared/toolbar/textDropdownGroup.js' import { LexicalListPlugin } from '../plugin/index.js' import { CHECK_LIST } from './markdownTransformers.js' import { LexicalCheckListPlugin } from './plugin/index.js' +const toolbarGroups: ToolbarGroup[] = [ + toolbarTextDropdownGroupWithItems([ + { + ChildComponent: ChecklistIcon, + isActive: ({ selection }) => { + if (!$isRangeSelection(selection)) { + return false + } + for (const node of selection.getNodes()) { + if ($isListNode(node) && node.getListType() === 'check') { + continue + } + + const parent = node.getParent() + + if ($isListNode(parent) && parent.getListType() === 'check') { + continue + } + + const parentParent = parent?.getParent() + // Example scenario: Node = textNode, parent = listItemNode, parentParent = listNode + if ($isListNode(parentParent) && parentParent.getListType() === 'check') { + continue + } + + return false + } + return true + }, + key: 'checklist', + label: `Check List`, + onSelect: ({ editor }) => { + editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined) + }, + order: 12, + }, + ]), +] + const ChecklistFeatureClient: FeatureProviderProviderClient = (props) => { return { clientFeatureProps: props, @@ -39,37 +80,27 @@ const ChecklistFeatureClient: FeatureProviderProviderClient = (props) slashMenu: { groups: [ { - displayName: 'Lists', items: [ { Icon: ChecklistIcon, - displayName: 'Check List', key: 'checklist', keywords: ['check list', 'check', 'checklist', 'cl'], + label: 'Check List', onSelect: ({ editor }) => { editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined) }, }, ], key: 'lists', + label: 'Lists', }, ], }, + toolbarFixed: { + groups: toolbarGroups, + }, toolbarInline: { - groups: [ - inlineToolbarTextDropdownGroupWithItems([ - { - ChildComponent: ChecklistIcon, - isActive: () => false, - key: 'checklist', - label: `Check List`, - onSelect: ({ editor }) => { - editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined) - }, - order: 12, - }, - ]), - ], + groups: toolbarGroups, }, } }, diff --git a/packages/richtext-lexical/src/field/features/lists/orderedList/feature.client.tsx b/packages/richtext-lexical/src/field/features/lists/orderedList/feature.client.tsx index 55564a67fd..ede2ce7d72 100644 --- a/packages/richtext-lexical/src/field/features/lists/orderedList/feature.client.tsx +++ b/packages/richtext-lexical/src/field/features/lists/orderedList/feature.client.tsx @@ -1,14 +1,55 @@ 'use client' -import { INSERT_ORDERED_LIST_COMMAND, ListItemNode, ListNode } from '@lexical/list' +import { $isListNode, INSERT_ORDERED_LIST_COMMAND, ListItemNode, ListNode } from '@lexical/list' +import { $isRangeSelection } from 'lexical' +import type { ToolbarGroup } from '../../toolbars/types.js' import type { FeatureProviderProviderClient } from '../../types.js' import { OrderedListIcon } from '../../../lexical/ui/icons/OrderedList/index.js' import { createClientComponent } from '../../createClientComponent.js' -import { inlineToolbarTextDropdownGroupWithItems } from '../../shared/inlineToolbar/textDropdownGroup.js' +import { toolbarTextDropdownGroupWithItems } from '../../shared/toolbar/textDropdownGroup.js' import { LexicalListPlugin } from '../plugin/index.js' import { ORDERED_LIST } from './markdownTransformer.js' +const toolbarGroups: ToolbarGroup[] = [ + toolbarTextDropdownGroupWithItems([ + { + ChildComponent: OrderedListIcon, + isActive: ({ selection }) => { + if (!$isRangeSelection(selection)) { + return false + } + for (const node of selection.getNodes()) { + if ($isListNode(node) && node.getListType() === 'number') { + continue + } + + const parent = node.getParent() + + if ($isListNode(parent) && parent.getListType() === 'number') { + continue + } + + const parentParent = parent?.getParent() + // Example scenario: Node = textNode, parent = listItemNode, parentParent = listNode + if ($isListNode(parentParent) && parentParent.getListType() === 'number') { + continue + } + + return false + } + return true + }, + key: 'orderedList', + label: `Ordered List`, + onSelect: ({ editor }) => { + editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined) + }, + order: 10, + }, + ]), +] + const OrderedListFeatureClient: FeatureProviderProviderClient = (props) => { return { clientFeatureProps: props, @@ -28,37 +69,27 @@ const OrderedListFeatureClient: FeatureProviderProviderClient = (prop slashMenu: { groups: [ { - displayName: 'Lists', items: [ { Icon: OrderedListIcon, - displayName: 'Ordered List', key: 'orderedList', keywords: ['ordered list', 'ol'], + label: 'Ordered List', onSelect: ({ editor }) => { editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined) }, }, ], key: 'lists', + label: 'Lists', }, ], }, + toolbarFixed: { + groups: toolbarGroups, + }, toolbarInline: { - groups: [ - inlineToolbarTextDropdownGroupWithItems([ - { - ChildComponent: OrderedListIcon, - isActive: () => false, - key: 'orderedList', - label: `Ordered List`, - onSelect: ({ editor }) => { - editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined) - }, - order: 10, - }, - ]), - ], + groups: toolbarGroups, }, } }, diff --git a/packages/richtext-lexical/src/field/features/lists/unorderedList/feature.client.tsx b/packages/richtext-lexical/src/field/features/lists/unorderedList/feature.client.tsx index 012ad23f2f..81d0bbac2d 100644 --- a/packages/richtext-lexical/src/field/features/lists/unorderedList/feature.client.tsx +++ b/packages/richtext-lexical/src/field/features/lists/unorderedList/feature.client.tsx @@ -1,16 +1,56 @@ 'use client' -import { INSERT_UNORDERED_LIST_COMMAND, ListItemNode, ListNode } from '@lexical/list' +import { $isListNode, INSERT_UNORDERED_LIST_COMMAND, ListItemNode, ListNode } from '@lexical/list' +import { $isRangeSelection } from 'lexical' +import type { ToolbarGroup } from '../../toolbars/types.js' import type { FeatureProviderProviderClient } from '../../types.js' -import { SlashMenuItem } from '../../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.js' import { UnorderedListIcon } from '../../../lexical/ui/icons/UnorderedList/index.js' import { createClientComponent } from '../../createClientComponent.js' -import { inlineToolbarTextDropdownGroupWithItems } from '../../shared/inlineToolbar/textDropdownGroup.js' +import { toolbarTextDropdownGroupWithItems } from '../../shared/toolbar/textDropdownGroup.js' import { LexicalListPlugin } from '../plugin/index.js' import { UNORDERED_LIST } from './markdownTransformer.js' +const toolbarGroups: ToolbarGroup[] = [ + toolbarTextDropdownGroupWithItems([ + { + ChildComponent: UnorderedListIcon, + isActive: ({ selection }) => { + if (!$isRangeSelection(selection)) { + return false + } + for (const node of selection.getNodes()) { + if ($isListNode(node) && node.getListType() === 'bullet') { + continue + } + + const parent = node.getParent() + + if ($isListNode(parent) && parent.getListType() === 'bullet') { + continue + } + + const parentParent = parent?.getParent() + // Example scenario: Node = textNode, parent = listItemNode, parentParent = listNode + if ($isListNode(parentParent) && parentParent.getListType() === 'bullet') { + continue + } + + return false + } + return true + }, + key: 'unorderedList', + label: `Unordered List`, + onSelect: ({ editor }) => { + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined) + }, + order: 11, + }, + ]), +] + const UnorderedListFeatureClient: FeatureProviderProviderClient = (props) => { return { clientFeatureProps: props, @@ -28,37 +68,27 @@ const UnorderedListFeatureClient: FeatureProviderProviderClient = (pr slashMenu: { groups: [ { - displayName: 'Lists', items: [ { Icon: UnorderedListIcon, - displayName: 'Unordered List', key: 'unorderedList', keywords: ['unordered list', 'ul'], + label: 'Unordered List', onSelect: ({ editor }) => { editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined) }, }, ], key: 'lists', + label: 'Lists', }, ], }, + toolbarFixed: { + groups: toolbarGroups, + }, toolbarInline: { - groups: [ - inlineToolbarTextDropdownGroupWithItems([ - { - ChildComponent: UnorderedListIcon, - isActive: () => false, - key: 'unorderedList', - label: `Unordered List`, - onSelect: ({ editor }) => { - editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined) - }, - order: 11, - }, - ]), - ], + groups: toolbarGroups, }, } }, diff --git a/packages/richtext-lexical/src/field/features/paragraph/feature.client.tsx b/packages/richtext-lexical/src/field/features/paragraph/feature.client.tsx index ee26b4db79..e426d2be81 100644 --- a/packages/richtext-lexical/src/field/features/paragraph/feature.client.tsx +++ b/packages/richtext-lexical/src/field/features/paragraph/feature.client.tsx @@ -1,13 +1,42 @@ 'use client' import { $setBlocksType } from '@lexical/selection' -import { $createParagraphNode, $getSelection } from 'lexical' +import { $createParagraphNode, $getSelection, $isParagraphNode, $isRangeSelection } from 'lexical' +import type { ToolbarGroup } from '../toolbars/types.js' import type { FeatureProviderProviderClient } from '../types.js' import { TextIcon } from '../../lexical/ui/icons/Text/index.js' import { createClientComponent } from '../createClientComponent.js' -import { inlineToolbarTextDropdownGroupWithItems } from '../shared/inlineToolbar/textDropdownGroup.js' +import { toolbarTextDropdownGroupWithItems } from '../shared/toolbar/textDropdownGroup.js' + +const toolbarGroups: ToolbarGroup[] = [ + toolbarTextDropdownGroupWithItems([ + { + ChildComponent: TextIcon, + isActive: ({ selection }) => { + if (!$isRangeSelection(selection)) { + return false + } + for (const node of selection.getNodes()) { + if (!$isParagraphNode(node) && !$isParagraphNode(node.getParent())) { + return false + } + } + return true + }, + key: 'paragraph', + label: 'Normal Text', + onSelect: ({ editor }) => { + editor.update(() => { + const selection = $getSelection() + $setBlocksType(selection, () => $createParagraphNode()) + }) + }, + order: 1, + }, + ]), +] const ParagraphFeatureClient: FeatureProviderProviderClient = (props) => { return { @@ -17,13 +46,12 @@ const ParagraphFeatureClient: FeatureProviderProviderClient = (props) slashMenu: { groups: [ { - displayName: 'Basic', items: [ { Icon: TextIcon, - displayName: 'Paragraph', key: 'paragraph', keywords: ['normal', 'paragraph', 'p', 'text'], + label: 'Paragraph', onSelect: ({ editor }) => { editor.update(() => { const selection = $getSelection() @@ -33,27 +61,15 @@ const ParagraphFeatureClient: FeatureProviderProviderClient = (props) }, ], key: 'basic', + label: 'Basic', }, ], }, + toolbarFixed: { + groups: toolbarGroups, + }, toolbarInline: { - groups: [ - inlineToolbarTextDropdownGroupWithItems([ - { - ChildComponent: TextIcon, - isActive: () => false, - key: 'paragraph', - label: 'Normal Text', - onSelect: ({ editor }) => { - editor.update(() => { - const selection = $getSelection() - $setBlocksType(selection, () => $createParagraphNode()) - }) - }, - order: 1, - }, - ]), - ], + groups: toolbarGroups, }, }), } diff --git a/packages/richtext-lexical/src/field/features/relationship/feature.client.tsx b/packages/richtext-lexical/src/field/features/relationship/feature.client.tsx index 5148250371..0265244697 100644 --- a/packages/richtext-lexical/src/field/features/relationship/feature.client.tsx +++ b/packages/richtext-lexical/src/field/features/relationship/feature.client.tsx @@ -1,14 +1,16 @@ 'use client' import { withMergedProps } from '@payloadcms/ui/elements/withMergedProps' +import { $isNodeSelection } from 'lexical' import type { FeatureProviderProviderClient } from '../types.js' import type { RelationshipFeatureProps } from './feature.server.js' import { RelationshipIcon } from '../../lexical/ui/icons/Relationship/index.js' import { createClientComponent } from '../createClientComponent.js' +import { toolbarAddDropdownGroupWithItems } from '../shared/toolbar/addDropdownGroup.js' import { INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND } from './drawer/commands.js' -import { RelationshipNode } from './nodes/RelationshipNode.js' +import { $isRelationshipNode, RelationshipNode } from './nodes/RelationshipNode.js' import { RelationshipPlugin } from './plugins/index.js' const RelationshipFeatureClient: FeatureProviderProviderClient = ( @@ -31,13 +33,12 @@ const RelationshipFeatureClient: FeatureProviderProviderClient { // dispatch INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND editor.dispatchCommand(INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND, { @@ -47,9 +48,35 @@ const RelationshipFeatureClient: FeatureProviderProviderClient { + if (!$isNodeSelection(selection) || !selection.getNodes().length) { + return false + } + + const firstNode = selection.getNodes()[0] + return $isRelationshipNode(firstNode) + }, + key: 'relationship', + label: 'Relationship', + onSelect: ({ editor }) => { + // dispatch INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND + editor.dispatchCommand(INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND, { + replace: false, + }) + }, + }, + ]), + ], + }, }), } } diff --git a/packages/richtext-lexical/src/field/features/shared/inlineToolbar/featureButtonsGroup.ts b/packages/richtext-lexical/src/field/features/shared/inlineToolbar/featureButtonsGroup.ts deleted file mode 100644 index 001fea1fb1..0000000000 --- a/packages/richtext-lexical/src/field/features/shared/inlineToolbar/featureButtonsGroup.ts +++ /dev/null @@ -1,15 +0,0 @@ -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, - } -} diff --git a/packages/richtext-lexical/src/field/features/shared/inlineToolbar/textDropdownGroup.ts b/packages/richtext-lexical/src/field/features/shared/inlineToolbar/textDropdownGroup.ts deleted file mode 100644 index 02a7f0c5ae..0000000000 --- a/packages/richtext-lexical/src/field/features/shared/inlineToolbar/textDropdownGroup.ts +++ /dev/null @@ -1,18 +0,0 @@ -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, - } -} diff --git a/packages/richtext-lexical/src/field/features/shared/toolbar/addDropdownGroup.ts b/packages/richtext-lexical/src/field/features/shared/toolbar/addDropdownGroup.ts new file mode 100644 index 0000000000..a81e636ef4 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/shared/toolbar/addDropdownGroup.ts @@ -0,0 +1,13 @@ +import type { ToolbarGroup, ToolbarGroupItem } from '../../toolbars/types.js' + +import { AddIcon } from '../../../lexical/ui/icons/Add/index.js' + +export const toolbarAddDropdownGroupWithItems = (items: ToolbarGroupItem[]): ToolbarGroup => { + return { + type: 'dropdown', + ChildComponent: AddIcon, + items, + key: 'add', + order: 10, + } +} diff --git a/packages/richtext-lexical/src/field/features/shared/toolbar/featureButtonsGroup.ts b/packages/richtext-lexical/src/field/features/shared/toolbar/featureButtonsGroup.ts new file mode 100644 index 0000000000..16e62c3da8 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/shared/toolbar/featureButtonsGroup.ts @@ -0,0 +1,10 @@ +import type { ToolbarGroup, ToolbarGroupItem } from '../../toolbars/types.js' + +export const toolbarFeatureButtonsGroupWithItems = (items: ToolbarGroupItem[]): ToolbarGroup => { + return { + type: 'buttons', + items, + key: 'features', + order: 50, + } +} diff --git a/packages/richtext-lexical/src/field/features/shared/toolbar/textDropdownGroup.ts b/packages/richtext-lexical/src/field/features/shared/toolbar/textDropdownGroup.ts new file mode 100644 index 0000000000..0bc92fdec6 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/shared/toolbar/textDropdownGroup.ts @@ -0,0 +1,13 @@ +import type { ToolbarGroup, ToolbarGroupItem } from '../../toolbars/types.js' + +import { TextIcon } from '../../../lexical/ui/icons/Text/index.js' + +export const toolbarTextDropdownGroupWithItems = (items: ToolbarGroupItem[]): ToolbarGroup => { + return { + type: 'dropdown', + ChildComponent: TextIcon, + items, + key: 'text', + order: 25, + } +} diff --git a/packages/richtext-lexical/src/field/features/toolbars/fixed/Toolbar/index.scss b/packages/richtext-lexical/src/field/features/toolbars/fixed/Toolbar/index.scss new file mode 100644 index 0000000000..fc69d306fd --- /dev/null +++ b/packages/richtext-lexical/src/field/features/toolbars/fixed/Toolbar/index.scss @@ -0,0 +1,81 @@ +@import '../../../../../scss/styles'; + + +html[data-theme='dark'] { + .fixed-toolbar { + &__dropdown-items { + background: var(--theme-elevation-0); + + .toolbar-popup__dropdown-item { + color: var(--theme-elevation-900); + + &:hover:not([disabled]), &.active { + background-color: var(--theme-elevation-100); + } + + .icon { + color: var(--theme-elevation-600); + } + } + } + + .toolbar-popup { + &__button { + &.active, &:hover:not([disabled]) { + background-color: var(--theme-elevation-100); + } + } + + &__dropdown { + &:hover:not([disabled]) { + background-color: var(--theme-elevation-100); + } + + &-caret:after { + filter: invert(1); + } + + &-label { + color: var(--theme-elevation-750); + } + } + } + } +} + + +.fixed-toolbar { + @include blur-bg(var(--theme-elevation-0)); + display: flex; + align-items: center; + padding: 0 3.72px 0 6.25px; + vertical-align: middle; + height: 37.5px; + position: sticky; + z-index: 2; + top: var(--doc-controls-height); + margin-bottom: $baseline; + border: $style-stroke-width-s solid var(--theme-elevation-150); + + + &__group { + display: flex; + align-items: center; + gap: 2px; + z-index: 1; + + .icon { + min-width: 20px; + height: 20px; + color: var(--theme-elevation-600); + } + + .divider { + width: 1px; + height: 15px; + background-color: var(--color-base-100); + margin: 0 6.25px; + } + } + +} diff --git a/packages/richtext-lexical/src/field/features/toolbars/fixed/Toolbar/index.tsx b/packages/richtext-lexical/src/field/features/toolbars/fixed/Toolbar/index.tsx new file mode 100644 index 0000000000..91be149bef --- /dev/null +++ b/packages/richtext-lexical/src/field/features/toolbars/fixed/Toolbar/index.tsx @@ -0,0 +1,212 @@ +'use client' +import type { LexicalEditor } from 'lexical' + +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js' +import { useTranslation } from '@payloadcms/ui/providers/Translation' +import * as React from 'react' + +import type { EditorFocusContextType } from '../../../../lexical/EditorFocusProvider.js' +import type { SanitizedClientEditorConfig } from '../../../../lexical/config/types.js' +import type { ToolbarGroup, ToolbarGroupItem } from '../../types.js' + +import { useEditorFocus } from '../../../../lexical/EditorFocusProvider.js' +import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider.js' +import { ToolbarButton } from '../../shared/ToolbarButton/index.js' +import { ToolbarDropdown } from '../../shared/ToolbarDropdown/index.js' +import './index.scss' + +function ButtonGroupItem({ + anchorElem, + editor, + item, +}: { + anchorElem: HTMLElement + editor: LexicalEditor + item: ToolbarGroupItem +}): React.ReactNode { + if (item.Component) { + return ( + item?.Component && ( + + ) + ) + } + + return ( + + {item?.ChildComponent && } + + ) +} + +function ToolbarGroupComponent({ + anchorElem, + editor, + editorConfig, + group, + index, +}: { + anchorElem: HTMLElement + editor: LexicalEditor + editorConfig: SanitizedClientEditorConfig + group: ToolbarGroup + index: number +}): React.ReactNode { + const { i18n } = useTranslation() + + const [dropdownLabel, setDropdownLabel] = React.useState(null) + const [DropdownIcon, setDropdownIcon] = React.useState(null) + + React.useEffect(() => { + if (group?.type === 'dropdown' && group.items.length && group.ChildComponent) { + setDropdownIcon(() => group.ChildComponent) + } else { + setDropdownIcon(null) + } + }, [group]) + + const onActiveChange = ({ activeItems }: { activeItems: ToolbarGroupItem[] }) => { + if (!activeItems.length) { + if (group?.type === 'dropdown' && group.items.length && group.ChildComponent) { + setDropdownIcon(() => group.ChildComponent) + setDropdownLabel(null) + } else { + setDropdownIcon(null) + setDropdownLabel(null) + } + return + } + const item = activeItems[0] + + let label = item.key + if (item.label) { + label = typeof item.label === 'function' ? item.label({ i18n }) : item.label + } + // Crop title to max. 25 characters + if (label.length > 25) { + label = label.substring(0, 25) + '...' + } + setDropdownLabel(label) + setDropdownIcon(() => item.ChildComponent) + } + + return ( +
+ {group.type === 'dropdown' && + group.items.length && + (DropdownIcon ? ( + + ) : ( + + ))} + {group.type === 'buttons' && + group.items.length && + group.items.map((item) => { + return ( + + ) + })} + {index < editorConfig.features.toolbarFixed?.groups.length - 1 &&
} +
+ ) +} + +function FixedToolbar({ + anchorElem, + editor, + editorConfig, +}: { + anchorElem: HTMLElement + editor: LexicalEditor + editorConfig: SanitizedClientEditorConfig +}): React.ReactNode { + return ( +
{ + // Prevent other focus events being triggered. Otherwise, if this was to be clicked while in a child editor, + // the parent editor will be focused, and the child editor will lose focus. + event.stopPropagation() + }} + > + {editor.isEditable() && ( + + {editorConfig?.features && + editorConfig.features?.toolbarFixed?.groups.map((group, i) => { + return ( + + ) + })} + + )} +
+ ) +} + +const checkParentEditor = (editorFocus: EditorFocusContextType): boolean => { + if (editorFocus.parentEditorConfigContext?.editorConfig) { + if ( + editorFocus.parentEditorConfigContext?.editorConfig.resolvedFeatureMap.has('toolbarFixed') + ) { + return true + } else { + if (editorFocus.parentEditorFocus) { + return checkParentEditor(editorFocus.parentEditorFocus) + } + } + } + return false +} + +export function FixedToolbarPlugin({ + anchorElem = document.body, +}: { + anchorElem?: HTMLElement +}): React.ReactElement | null { + const [currentEditor] = useLexicalComposerContext() + const { editorConfig: currentEditorConfig, uuid } = useEditorConfigContext() + + const editorFocus = useEditorFocus() + const editor = editorFocus.focusedEditor || currentEditor + + const editorConfig = editorFocus.focusedEditorConfigContext?.editorConfig || currentEditorConfig + + // Check if there is a parent editor with a fixed toolbar already + const hasParentWithFixedToolbar = checkParentEditor(editorFocus) + + if (hasParentWithFixedToolbar) { + return null + } + + if (!editorConfig?.features?.toolbarFixed?.groups?.length) { + return null + } + + return +} diff --git a/packages/richtext-lexical/src/field/features/toolbars/fixed/feature.client.tsx b/packages/richtext-lexical/src/field/features/toolbars/fixed/feature.client.tsx new file mode 100644 index 0000000000..40592e84a5 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/toolbars/fixed/feature.client.tsx @@ -0,0 +1,23 @@ +'use client' + +import type { FeatureProviderProviderClient } from '../../types.js' + +import { createClientComponent } from '../../createClientComponent.js' +import { FixedToolbarPlugin } from './Toolbar/index.js' + +const FixedToolbarFeatureClient: FeatureProviderProviderClient = (props) => { + return { + clientFeatureProps: props, + feature: () => ({ + clientFeatureProps: props, + plugins: [ + { + Component: FixedToolbarPlugin, + position: 'top', + }, + ], + }), + } +} + +export const FixedToolbarFeatureClientComponent = createClientComponent(FixedToolbarFeatureClient) diff --git a/packages/richtext-lexical/src/field/features/toolbars/fixed/feature.server.ts b/packages/richtext-lexical/src/field/features/toolbars/fixed/feature.server.ts new file mode 100644 index 0000000000..a28cf3b55b --- /dev/null +++ b/packages/richtext-lexical/src/field/features/toolbars/fixed/feature.server.ts @@ -0,0 +1,17 @@ +import type { FeatureProviderProviderServer } from '../../types.js' + +import { FixedToolbarFeatureClientComponent } from './feature.client.js' + +export const FixedToolbarFeature: FeatureProviderProviderServer = (props) => { + return { + feature: () => { + return { + ClientComponent: FixedToolbarFeatureClientComponent, + clientFeatureProps: null, + serverFeatureProps: props, + } + }, + key: 'toolbarFixed', + serverFeatureProps: props, + } +} diff --git a/packages/richtext-lexical/src/field/lexical/plugins/toolbars/inline/Toolbar/index.scss b/packages/richtext-lexical/src/field/features/toolbars/inline/Toolbar/index.scss similarity index 82% rename from packages/richtext-lexical/src/field/lexical/plugins/toolbars/inline/Toolbar/index.scss rename to packages/richtext-lexical/src/field/features/toolbars/inline/Toolbar/index.scss index 353d3792cc..b648779330 100644 --- a/packages/richtext-lexical/src/field/lexical/plugins/toolbars/inline/Toolbar/index.scss +++ b/packages/richtext-lexical/src/field/features/toolbars/inline/Toolbar/index.scss @@ -1,4 +1,4 @@ -@import '../../../../../../scss/styles'; +@import '../../../../../scss/styles'; html[data-theme='light'] { .inline-toolbar-popup { @@ -10,12 +10,12 @@ html[data-theme='light'] { display: flex; align-items: center; background: var(--color-base-0); - padding: 0px 3.72px 0px 6.25px; + padding: 0 3.72px 0 6.25px; vertical-align: middle; position: absolute; top: 0; left: 0; - z-index: 10; + z-index: 1; opacity: 0; border-radius: 6.25px; transition: opacity 0.2s; @@ -50,9 +50,4 @@ html[data-theme='light'] { } } - @media (max-width: 1024px) { - button.insert-comment { - display: none; - } - } } diff --git a/packages/richtext-lexical/src/field/lexical/plugins/toolbars/inline/Toolbar/index.tsx b/packages/richtext-lexical/src/field/features/toolbars/inline/Toolbar/index.tsx similarity index 83% rename from packages/richtext-lexical/src/field/lexical/plugins/toolbars/inline/Toolbar/index.tsx rename to packages/richtext-lexical/src/field/features/toolbars/inline/Toolbar/index.tsx index 8539ce5694..765f68ae9d 100644 --- a/packages/richtext-lexical/src/field/lexical/plugins/toolbars/inline/Toolbar/index.tsx +++ b/packages/richtext-lexical/src/field/features/toolbars/inline/Toolbar/index.tsx @@ -14,13 +14,13 @@ import { useCallback, useEffect, useRef, useState } from 'react' import * as React from 'react' import { createPortal } from 'react-dom' -import type { InlineToolbarGroup, InlineToolbarGroupItem } from '../types.js' +import type { ToolbarGroup, ToolbarGroupItem } 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 '../../../../lexical/config/client/EditorConfigProvider.js' +import { getDOMRangeRect } from '../../../../lexical/utils/getDOMRangeRect.js' +import { setFloatingElemPosition } from '../../../../lexical/utils/setFloatingElemPosition.js' +import { ToolbarButton } from '../../shared/ToolbarButton/index.js' +import { ToolbarDropdown } from '../../shared/ToolbarDropdown/index.js' import './index.scss' function ButtonGroupItem({ @@ -30,7 +30,7 @@ function ButtonGroupItem({ }: { anchorElem: HTMLElement editor: LexicalEditor - item: InlineToolbarGroupItem + item: ToolbarGroupItem }): React.ReactNode { if (item.Component) { return ( @@ -41,13 +41,13 @@ function ButtonGroupItem({ } return ( - + {item?.ChildComponent && } ) } -function ToolbarGroup({ +function ToolbarGroupComponent({ anchorElem, editor, group, @@ -55,15 +55,33 @@ function ToolbarGroup({ }: { anchorElem: HTMLElement editor: LexicalEditor - group: InlineToolbarGroup + group: ToolbarGroup index: number }): React.ReactNode { const { editorConfig } = useEditorConfigContext() - const Icon = - group?.type === 'dropdown' && group.items.length && group.ChildComponent - ? group.ChildComponent - : null + const [DropdownIcon, setDropdownIcon] = React.useState(null) + + React.useEffect(() => { + if (group?.type === 'dropdown' && group.items.length && group.ChildComponent) { + setDropdownIcon(() => group.ChildComponent) + } else { + setDropdownIcon(null) + } + }, [group]) + + const onActiveChange = ({ activeItems }: { activeItems: ToolbarGroupItem[] }) => { + if (!activeItems.length) { + if (group?.type === 'dropdown' && group.items.length && group.ChildComponent) { + setDropdownIcon(() => group.ChildComponent) + } else { + setDropdownIcon(null) + } + return + } + const item = activeItems[0] + setDropdownIcon(() => item.ChildComponent) + } return (
{group.type === 'dropdown' && group.items.length && - (Icon ? ( + (DropdownIcon ? ( ) : ( ))} {group.type === 'buttons' && @@ -102,7 +124,7 @@ function ToolbarGroup({ ) } -function FloatingSelectToolbar({ +function InlineToolbar({ anchorElem, editor, }: { @@ -264,7 +286,7 @@ function FloatingSelectToolbar({ {editorConfig?.features && editorConfig.features?.toolbarInline?.groups.map((group, i) => { return ( - { @@ -360,14 +382,15 @@ function useFloatingTextFormatToolbar( return null } - return createPortal(, anchorElem) + return createPortal(, anchorElem) } -export function FloatingSelectToolbarPlugin({ +export function InlineToolbarPlugin({ anchorElem = document.body, }: { anchorElem?: HTMLElement -}): JSX.Element | null { +}): React.ReactElement | null { const [editor] = useLexicalComposerContext() - return useFloatingTextFormatToolbar(editor, anchorElem) + + return useInlineToolbar(editor, anchorElem) } diff --git a/packages/richtext-lexical/src/field/features/toolbars/inline/feature.client.tsx b/packages/richtext-lexical/src/field/features/toolbars/inline/feature.client.tsx new file mode 100644 index 0000000000..6b7266604b --- /dev/null +++ b/packages/richtext-lexical/src/field/features/toolbars/inline/feature.client.tsx @@ -0,0 +1,23 @@ +'use client' + +import type { FeatureProviderProviderClient } from '../../types.js' + +import { createClientComponent } from '../../createClientComponent.js' +import { InlineToolbarPlugin } from './Toolbar/index.js' + +const InlineToolbarFeatureClient: FeatureProviderProviderClient = (props) => { + return { + clientFeatureProps: props, + feature: () => ({ + clientFeatureProps: props, + plugins: [ + { + Component: InlineToolbarPlugin, + position: 'floatingAnchorElem', + }, + ], + }), + } +} + +export const InlineToolbarFeatureClientComponent = createClientComponent(InlineToolbarFeatureClient) diff --git a/packages/richtext-lexical/src/field/features/toolbars/inline/feature.server.ts b/packages/richtext-lexical/src/field/features/toolbars/inline/feature.server.ts new file mode 100644 index 0000000000..be28a4c43a --- /dev/null +++ b/packages/richtext-lexical/src/field/features/toolbars/inline/feature.server.ts @@ -0,0 +1,19 @@ +import type { FeatureProviderProviderServer } from '../../types.js' + +import { InlineToolbarFeatureClientComponent } from './feature.client.js' + +export const InlineToolbarFeature: FeatureProviderProviderServer = ( + props, +) => { + return { + feature: () => { + return { + ClientComponent: InlineToolbarFeatureClientComponent, + clientFeatureProps: null, + serverFeatureProps: props, + } + }, + key: 'toolbarInline', + serverFeatureProps: props, + } +} diff --git a/packages/richtext-lexical/src/field/lexical/plugins/toolbars/inline/ToolbarButton/index.scss b/packages/richtext-lexical/src/field/features/toolbars/shared/ToolbarButton/index.scss similarity index 88% rename from packages/richtext-lexical/src/field/lexical/plugins/toolbars/inline/ToolbarButton/index.scss rename to packages/richtext-lexical/src/field/features/toolbars/shared/ToolbarButton/index.scss index 1b51de17ab..886917b147 100644 --- a/packages/richtext-lexical/src/field/lexical/plugins/toolbars/inline/ToolbarButton/index.scss +++ b/packages/richtext-lexical/src/field/features/toolbars/shared/ToolbarButton/index.scss @@ -1,4 +1,6 @@ -.inline-toolbar-popup__button { +@import '../../../../../scss/styles'; + +.toolbar-popup__button { display: flex; align-items: center; vertical-align: middle; diff --git a/packages/richtext-lexical/src/field/lexical/plugins/toolbars/inline/ToolbarButton/index.tsx b/packages/richtext-lexical/src/field/features/toolbars/shared/ToolbarButton/index.tsx similarity index 79% rename from packages/richtext-lexical/src/field/lexical/plugins/toolbars/inline/ToolbarButton/index.tsx rename to packages/richtext-lexical/src/field/features/toolbars/shared/ToolbarButton/index.tsx index 36449217d1..61fa65f22f 100644 --- a/packages/richtext-lexical/src/field/lexical/plugins/toolbars/inline/ToolbarButton/index.tsx +++ b/packages/richtext-lexical/src/field/features/toolbars/shared/ToolbarButton/index.tsx @@ -1,44 +1,48 @@ 'use client' -import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js' +import type { LexicalEditor } from 'lexical' + import { mergeRegister } from '@lexical/utils' import { $getSelection } from 'lexical' import React, { useCallback, useEffect, useState } from 'react' -import type { InlineToolbarGroupItem } from '../types.js' +import type { ToolbarGroupItem } from '../../types.js' +import { useEditorFocus } from '../../../../lexical/EditorFocusProvider.js' import './index.scss' -const baseClass = 'inline-toolbar-popup__button' +const baseClass = 'toolbar-popup__button' export const ToolbarButton = ({ children, + editor, item, }: { children: React.JSX.Element - item: InlineToolbarGroupItem + editor: LexicalEditor + item: ToolbarGroupItem }) => { - const [editor] = useLexicalComposerContext() const [enabled, setEnabled] = useState(true) const [active, setActive] = useState(false) const [className, setClassName] = useState(baseClass) + const editorFocusContext = useEditorFocus() const updateStates = useCallback(() => { editor.getEditorState().read(() => { const selection = $getSelection() if (item.isActive) { - const isActive = item.isActive({ editor, selection }) + const isActive = item.isActive({ editor, editorFocusContext, selection }) if (active !== isActive) { setActive(isActive) } } if (item.isEnabled) { - const isEnabled = item.isEnabled({ editor, selection }) + const isEnabled = item.isEnabled({ editor, editorFocusContext, selection }) if (enabled !== isEnabled) { setEnabled(isEnabled) } } }) - }, [active, editor, enabled, item]) + }, [active, editor, editorFocusContext, enabled, item]) useEffect(() => { updateStates() @@ -70,7 +74,7 @@ export const ToolbarButton = ({ .filter(Boolean) .join(' '), ) - }, [enabled, active, className]) + }, [enabled, active, className, item.key]) return ( {showDropDown && createPortal( - + {children} , document.body, diff --git a/packages/richtext-lexical/src/field/lexical/plugins/toolbars/inline/ToolbarDropdown/index.scss b/packages/richtext-lexical/src/field/features/toolbars/shared/ToolbarDropdown/index.scss similarity index 70% rename from packages/richtext-lexical/src/field/lexical/plugins/toolbars/inline/ToolbarDropdown/index.scss rename to packages/richtext-lexical/src/field/features/toolbars/shared/ToolbarDropdown/index.scss index 639cd1d426..1a581cd35b 100644 --- a/packages/richtext-lexical/src/field/lexical/plugins/toolbars/inline/ToolbarDropdown/index.scss +++ b/packages/richtext-lexical/src/field/features/toolbars/shared/ToolbarDropdown/index.scss @@ -1,18 +1,22 @@ -@import '../../../../../../scss/styles'; +@import '../../../../../scss/styles'; -.inline-toolbar-popup__dropdown { +.toolbar-popup__dropdown { display: flex; align-items: center; vertical-align: middle; justify-content: center; height: 30px; - width: 40px; border: 0; background: none; border-radius: 4px; cursor: pointer; position: relative; - padding: 0; + padding: 0 10px; + + &-label { + color: var(--color-base-600); + padding: 0 10px; + } &:disabled { cursor: not-allowed; @@ -26,10 +30,10 @@ background-color: var(--color-base-100); } - &.active { + .active { background-color: var(--color-base-100); - .inline-toolbar-popup__dropdown-caret { + .toolbar-popup__dropdown-caret { &:after { transform: rotate(0deg); } @@ -50,9 +54,9 @@ height: 4px; opacity: 0.3; - background-image: url(../../../../ui/icons/Caret/index.svg); - background-position-y: 0px; - background-position-x: 0px; + background-image: url(../../../../lexical/ui/icons/Caret/index.svg); + background-position-y: 0; + background-position-x: 0; } } @@ -60,17 +64,25 @@ position: absolute; background: var(--color-base-0); border-radius: 4px; - width: 132.5px; + min-width: 132.5px; + max-width: 200px; z-index: 100; - .inline-toolbar-popup__dropdown-item { + .toolbar-popup__dropdown-item { all: unset; // reset all default button styles cursor: pointer; color: var(--color-base-900); - &:hover:not([disabled]) { + .text { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + &:hover:not([disabled]), &.active { background-color: var(--color-base-100); } + padding-left: 6.25px; padding-right: 6.25px; width: 100%; @@ -91,7 +103,7 @@ } html[data-theme='light'] { - .inline-toolbar-popup__dropdown { + .toolbar-popup__dropdown { &-items { position: absolute; @include shadow-m; diff --git a/packages/richtext-lexical/src/field/features/toolbars/shared/ToolbarDropdown/index.tsx b/packages/richtext-lexical/src/field/features/toolbars/shared/ToolbarDropdown/index.tsx new file mode 100644 index 0000000000..70c8891f04 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/toolbars/shared/ToolbarDropdown/index.tsx @@ -0,0 +1,175 @@ +'use client' +import React, { useCallback, useEffect } from 'react' + +const baseClass = 'toolbar-popup__dropdown' + +import type { LexicalEditor } from 'lexical' + +import { mergeRegister } from '@lexical/utils' +import { useTranslation } from '@payloadcms/ui/providers/Translation' +import { $getSelection } from 'lexical' + +import type { ToolbarGroupItem } from '../../types.js' + +import { useEditorFocus } from '../../../../lexical/EditorFocusProvider.js' +import { DropDown, DropDownItem } from './DropDown.js' +import './index.scss' + +const ToolbarItem = ({ + active, + anchorElem, + editor, + enabled, + item, +}: { + active?: boolean + anchorElem: HTMLElement + editor: LexicalEditor + enabled?: boolean + item: ToolbarGroupItem +}) => { + const { i18n } = useTranslation() + + if (item.Component) { + return ( + item?.Component && ( + + ) + ) + } + + let title = item.key + if (item.label) { + title = typeof item.label === 'function' ? item.label({ i18n }) : item.label + } + // Crop title to max. 25 characters + if (title.length > 25) { + title = title.substring(0, 25) + '...' + } + + return ( + + {item?.ChildComponent && } + {title} + + ) +} + +export const ToolbarDropdown = ({ + Icon, + anchorElem, + classNames, + editor, + groupKey, + items, + itemsContainerClassNames, + label, + maxActiveItems, + onActiveChange, +}: { + Icon?: React.FC + anchorElem: HTMLElement + classNames?: string[] + editor: LexicalEditor + groupKey: string + items: ToolbarGroupItem[] + itemsContainerClassNames?: string[] + label?: string + /** + * Maximum number of active items allowed. This is a performance optimization to prevent + * unnecessary item active checks when the maximum number of active items is reached. + */ + maxActiveItems?: number + onActiveChange?: ({ activeItems }: { activeItems: ToolbarGroupItem[] }) => void +}) => { + const [activeItemKeys, setActiveItemKeys] = React.useState([]) + const [enabledItemKeys, setEnabledItemKeys] = React.useState([]) + const editorFocusContext = useEditorFocus() + + const updateStates = useCallback(() => { + editor.getEditorState().read(() => { + const selection = $getSelection() + + const _activeItemKeys: string[] = [] + const _activeItems: ToolbarGroupItem[] = [] + const _enabledItemKeys: string[] = [] + + for (const item of items) { + if (item.isActive && (!maxActiveItems || _activeItemKeys.length < maxActiveItems)) { + const isActive = item.isActive({ editor, editorFocusContext, selection }) + if (isActive) { + _activeItemKeys.push(item.key) + _activeItems.push(item) + } + } + if (item.isEnabled) { + const isEnabled = item.isEnabled({ editor, editorFocusContext, selection }) + if (isEnabled) { + _enabledItemKeys.push(item.key) + } + } else { + _enabledItemKeys.push(item.key) + } + } + setActiveItemKeys(_activeItemKeys) + setEnabledItemKeys(_enabledItemKeys) + + if (onActiveChange) { + onActiveChange({ activeItems: _activeItems }) + } + }) + }, [editor, editorFocusContext, items, maxActiveItems, onActiveChange]) + + useEffect(() => { + updateStates() + }, [updateStates]) + + useEffect(() => { + document.addEventListener('mouseup', updateStates) + return () => { + document.removeEventListener('mouseup', updateStates) + } + }, [updateStates]) + + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(() => { + updateStates() + }), + ) + }, [editor, updateStates]) + + return ( + + {items.length && + items.map((item) => { + return ( + + ) + })} + + ) +} diff --git a/packages/richtext-lexical/src/field/lexical/plugins/toolbars/inline/types.ts b/packages/richtext-lexical/src/field/features/toolbars/types.ts similarity index 56% rename from packages/richtext-lexical/src/field/lexical/plugins/toolbars/inline/types.ts rename to packages/richtext-lexical/src/field/features/toolbars/types.ts index f8cca048e2..3d0bd5137a 100644 --- a/packages/richtext-lexical/src/field/lexical/plugins/toolbars/inline/types.ts +++ b/packages/richtext-lexical/src/field/features/toolbars/types.ts @@ -1,40 +1,55 @@ +import type { I18n } from '@payloadcms/translations' import type { BaseSelection, LexicalEditor } from 'lexical' import type React from 'react' -export type InlineToolbarGroup = +import type { EditorFocusContextType } from '../../lexical/EditorFocusProvider.js' + +export type ToolbarGroup = | { ChildComponent?: React.FC - items: Array + items: Array key: string order?: number type: 'dropdown' } | { - items: Array + items: Array key: string order?: number type: 'buttons' } -export type InlineToolbarGroupItem = { +export type ToolbarGroupItem = { 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<{ + active?: boolean anchorElem: HTMLElement editor: LexicalEditor - item: InlineToolbarGroupItem + enabled?: boolean + item: ToolbarGroupItem }> - isActive?: ({ editor, selection }: { editor: LexicalEditor; selection: BaseSelection }) => boolean - isEnabled?: ({ + isActive?: ({ editor, + editorFocusContext, selection, }: { editor: LexicalEditor + editorFocusContext: EditorFocusContextType + selection: BaseSelection + }) => boolean + isEnabled?: ({ + editor, + editorFocusContext, + selection, + }: { + editor: LexicalEditor + editorFocusContext: EditorFocusContextType selection: BaseSelection }) => boolean key: string /** The label is displayed as text if the item is part of a dropdown group */ - label?: string + label?: (({ i18n }: { i18n: I18n }) => string) | string onSelect?: ({ editor, isActive }: { editor: LexicalEditor; isActive: boolean }) => void order?: number } diff --git a/packages/richtext-lexical/src/field/features/types.ts b/packages/richtext-lexical/src/field/features/types.ts index 0e92cae59c..c56eb9aa9c 100644 --- a/packages/richtext-lexical/src/field/features/types.ts +++ b/packages/richtext-lexical/src/field/features/types.ts @@ -18,9 +18,8 @@ import type React from 'react' import type { AdapterProps } from '../../types.js' import type { ClientEditorConfig, ServerEditorConfig } from '../lexical/config/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' +import type { ToolbarGroup } from './toolbars/types.js' export type PopulationPromise = ({ context, @@ -147,7 +146,7 @@ export type ClientFeature = { markdownTransformers?: Transformer[] nodes?: Array | 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 are react components 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< | { @@ -195,7 +194,7 @@ export type ClientFeature = { * An opt-in, classic fixed toolbar which stays at the top of the editor */ toolbarFixed?: { - groups: FixedToolbarGroup[] + groups: ToolbarGroup[] } /** * The default, floating toolbar which appears when you select text. @@ -204,7 +203,7 @@ export type ClientFeature = { /** * Array of toolbar groups / sections. Each section can contain multiple toolbar items. */ - groups: InlineToolbarGroup[] + groups: ToolbarGroup[] } } @@ -328,7 +327,7 @@ export type ServerFeatureProviderMap = Map> /** - * 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 are react components 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 = | { @@ -433,7 +432,7 @@ export type SanitizedClientFeatures = Required< > } /** - * 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 are react components 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 slashMenu: { diff --git a/packages/richtext-lexical/src/field/features/upload/feature.client.tsx b/packages/richtext-lexical/src/field/features/upload/feature.client.tsx index 57d3869c9b..26506980d1 100644 --- a/packages/richtext-lexical/src/field/features/upload/feature.client.tsx +++ b/packages/richtext-lexical/src/field/features/upload/feature.client.tsx @@ -1,11 +1,14 @@ 'use client' +import { $isNodeSelection } from 'lexical' + import type { FeatureProviderProviderClient } from '../types.js' import { UploadIcon } from '../../lexical/ui/icons/Upload/index.js' import { createClientComponent } from '../createClientComponent.js' +import { toolbarAddDropdownGroupWithItems } from '../shared/toolbar/addDropdownGroup.js' import { INSERT_UPLOAD_WITH_DRAWER_COMMAND } from './drawer/commands.js' -import { UploadNode } from './nodes/UploadNode.js' +import { $isUploadNode, UploadNode } from './nodes/UploadNode.js' import { UploadPlugin } from './plugin/index.js' export type UploadFeaturePropsClient = { @@ -31,13 +34,12 @@ const UploadFeatureClient: FeatureProviderProviderClient { editor.dispatchCommand(INSERT_UPLOAD_WITH_DRAWER_COMMAND, { replace: false, @@ -46,9 +48,34 @@ const UploadFeatureClient: FeatureProviderProviderClient { + if (!$isNodeSelection(selection) || !selection.getNodes().length) { + return false + } + + const firstNode = selection.getNodes()[0] + return $isUploadNode(firstNode) + }, + key: 'upload', + label: 'Upload', + onSelect: ({ editor }) => { + editor.dispatchCommand(INSERT_UPLOAD_WITH_DRAWER_COMMAND, { + replace: false, + }) + }, + }, + ]), + ], + }, }), } } diff --git a/packages/richtext-lexical/src/field/lexical/EditorFocusProvider.tsx b/packages/richtext-lexical/src/field/lexical/EditorFocusProvider.tsx new file mode 100644 index 0000000000..f3c9fd2cb1 --- /dev/null +++ b/packages/richtext-lexical/src/field/lexical/EditorFocusProvider.tsx @@ -0,0 +1,100 @@ +import type { LexicalEditor } from 'lexical' + +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js' +import React, { createContext, useCallback, useContext, useState } from 'react' + +import type { EditorConfigContextType } from './config/client/EditorConfigProvider.js' + +import { useEditorConfigContext } from './config/client/EditorConfigProvider.js' + +export type EditorFocusContextType = { + blurEditor: () => void + editor: LexicalEditor + focusEditor: (_editor?: LexicalEditor, _editorConfigContext?: EditorConfigContextType) => void + focusedEditor: LexicalEditor | null + focusedEditorConfigContext: EditorConfigContextType + isChildEditorFocused: () => boolean + isEditorFocused: () => boolean + isParentEditorFocused: () => boolean + parentEditorConfigContext: EditorConfigContextType + parentEditorFocus: EditorFocusContextType +} + +const EditorFocusContext = createContext({ + blurEditor: null, + editor: null, + focusEditor: null, + focusedEditor: null, + focusedEditorConfigContext: null, + isChildEditorFocused: null, + isEditorFocused: null, + isParentEditorFocused: null, + parentEditorConfigContext: null, + parentEditorFocus: null, +}) + +export const useEditorFocus = (): EditorFocusContextType => { + return useContext(EditorFocusContext) +} + +export const EditorFocusProvider = ({ children }) => { + const parentEditorFocus = useEditorFocus() + const parentEditorConfigContext = useEditorConfigContext() // Is parent, as this EditorFocusProvider sits outside the EditorConfigProvider + const [editor] = useLexicalComposerContext() + + const [focusedEditor, setFocusedEditor] = useState(null) + const [focusedEditorConfigContext, setFocusedEditorConfigContext] = + useState(null) + + const focusEditor = useCallback( + (_editor: LexicalEditor, _editorConfigContext: EditorConfigContextType) => { + setFocusedEditor(_editor !== undefined ? _editor : editor) + setFocusedEditorConfigContext(_editorConfigContext) + if (parentEditorFocus.focusEditor) { + parentEditorFocus.focusEditor( + _editor !== undefined ? _editor : editor, + _editorConfigContext, + ) + } + }, + [editor, parentEditorFocus], + ) + + const blurEditor = useCallback(() => { + if (focusedEditor === editor) { + setFocusedEditor(null) + setFocusedEditorConfigContext(null) + } + }, [editor, focusedEditor]) + + const isEditorFocused = useCallback(() => { + return focusedEditor === editor + }, [editor, focusedEditor]) + + const isParentEditorFocused = useCallback(() => { + return parentEditorFocus?.isEditorFocused ? parentEditorFocus.isEditorFocused() : false + }, [parentEditorFocus]) + + const isChildEditorFocused = useCallback(() => { + return focusedEditor !== editor && !!focusedEditor + }, [editor, focusedEditor]) + + return ( + + {children} + + ) +} diff --git a/packages/richtext-lexical/src/field/lexical/LexicalEditor.tsx b/packages/richtext-lexical/src/field/lexical/LexicalEditor.tsx index ec93bfae6a..aa88a52721 100644 --- a/packages/richtext-lexical/src/field/lexical/LexicalEditor.tsx +++ b/packages/richtext-lexical/src/field/lexical/LexicalEditor.tsx @@ -5,25 +5,30 @@ import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin.js' import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin.js' import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin.js' import { TabIndentationPlugin } from '@lexical/react/LexicalTabIndentationPlugin.js' +import { mergeRegister } from '@lexical/utils' +import { BLUR_COMMAND, COMMAND_PRIORITY_LOW, FOCUS_COMMAND } from 'lexical' import * as React from 'react' import { useEffect, useState } from 'react' import type { LexicalProviderProps } from './LexicalProvider.js' +import { useEditorFocus } from './EditorFocusProvider.js' import { EditorPlugin } from './EditorPlugin.js' import './LexicalEditor.scss' +import { useEditorConfigContext } from './config/client/EditorConfigProvider.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> = ( props, ) => { const { editorConfig, onChange } = props + const editorConfigContext = useEditorConfigContext() const [editor] = useLexicalComposerContext() + const editorFocus = useEditorFocus() const [floatingAnchorElem, setFloatingAnchorElem] = useState(null) const onRef = (_floatingAnchorElem: HTMLDivElement) => { @@ -32,6 +37,29 @@ export const LexicalEditor: React.FC { + return mergeRegister( + editor.registerCommand( + FOCUS_COMMAND, + () => { + editorFocus.focusEditor(editor, editorConfigContext) + return true + }, + + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + BLUR_COMMAND, + () => { + editorFocus.blurEditor() + return true + }, + + COMMAND_PRIORITY_LOW, + ), + ) + }, [editor, editorConfig, editorFocus]) + const [isSmallWidthViewport, setIsSmallWidthViewport] = useState(false) useEffect(() => { @@ -105,7 +133,6 @@ export const LexicalEditor: React.FC - )} diff --git a/packages/richtext-lexical/src/field/lexical/LexicalProvider.tsx b/packages/richtext-lexical/src/field/lexical/LexicalProvider.tsx index 7c98832bf5..d9730198fd 100644 --- a/packages/richtext-lexical/src/field/lexical/LexicalProvider.tsx +++ b/packages/richtext-lexical/src/field/lexical/LexicalProvider.tsx @@ -9,6 +9,7 @@ import * as React from 'react' import type { SanitizedClientEditorConfig } from './config/types.js' +import { EditorFocusProvider } from './EditorFocusProvider.js' import { LexicalEditor as LexicalEditorComponent } from './LexicalEditor.js' import { EditorConfigProvider } from './config/client/EditorConfigProvider.js' import { getEnabledNodes } from './nodes/index.js' @@ -77,11 +78,13 @@ export const LexicalProvider: React.FC = (props) => { return ( - -
- -
-
+ + +
+ +
+
+
) } diff --git a/packages/richtext-lexical/src/field/lexical/config/client/EditorConfigProvider.tsx b/packages/richtext-lexical/src/field/lexical/config/client/EditorConfigProvider.tsx index e0a52275f0..c5ebd5c846 100644 --- a/packages/richtext-lexical/src/field/lexical/config/client/EditorConfigProvider.tsx +++ b/packages/richtext-lexical/src/field/lexical/config/client/EditorConfigProvider.tsx @@ -11,7 +11,7 @@ import type { SanitizedClientEditorConfig } from '../types.js' function generateQuickGuid(): string { return Math.random().toString(36).substring(2, 12) + Math.random().toString(36).substring(2, 12) } -interface ContextType { +export interface EditorConfigContextType { editorConfig: SanitizedClientEditorConfig field: FormFieldBase & { editorConfig: SanitizedClientEditorConfig // With rendered features n stuff @@ -21,7 +21,7 @@ interface ContextType { uuid: string } -const Context: React.Context = createContext({ +const Context: React.Context = createContext({ editorConfig: null, field: null, uuid: generateQuickGuid(), @@ -56,7 +56,7 @@ export const EditorConfigProvider = ({ return {children} } -export const useEditorConfigContext = (): ContextType => { +export const useEditorConfigContext = (): EditorConfigContextType => { const context = useContext(Context) if (context === undefined) { throw new Error('useEditorConfigContext must be used within an EditorConfigProvider') diff --git a/packages/richtext-lexical/src/field/lexical/config/server/default.ts b/packages/richtext-lexical/src/field/lexical/config/server/default.ts index 0c4fb0abe7..15f823dbaa 100644 --- a/packages/richtext-lexical/src/field/lexical/config/server/default.ts +++ b/packages/richtext-lexical/src/field/lexical/config/server/default.ts @@ -21,6 +21,8 @@ import { OrderedListFeature } from '../../../features/lists/orderedList/feature. 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 { FixedToolbarFeature } from '../../../features/toolbars/fixed/feature.server.js' +import { InlineToolbarFeature } from '../../../features/toolbars/inline/feature.server.js' import { UploadFeature } from '../../../features/upload/feature.server.js' import { LexicalEditorTheme } from '../../theme/EditorTheme.js' @@ -49,6 +51,7 @@ export const defaultEditorFeatures: FeatureProviderServer[] = BlockQuoteFeature(), UploadFeature(), HorizontalRuleFeature(), + InlineToolbarFeature(), ] export const defaultEditorConfig: ServerEditorConfig = { diff --git a/packages/richtext-lexical/src/field/lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.ts b/packages/richtext-lexical/src/field/lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.ts index c7378bb6e5..0b2f2dedc8 100644 --- a/packages/richtext-lexical/src/field/lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.ts +++ b/packages/richtext-lexical/src/field/lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.ts @@ -7,22 +7,22 @@ export type SlashMenuItem = { // Icon for display Icon: React.FC - displayName?: (({ i18n }: { i18n: I18n }) => string) | string - // Used for class names and, if displayName is not provided, for display. + // Used for class names and, if label is not provided, for display. key: string // TBD keyboardShortcut?: string // For extra searching. keywords?: Array + label?: (({ i18n }: { i18n: I18n }) => string) | string // What happens when you select this item? onSelect: ({ editor, queryString }: { editor: LexicalEditor; queryString: string }) => void } export type SlashMenuGroup = { - // Used for class names and, if displayName is not provided, for display. - displayName?: (({ i18n }: { i18n: I18n }) => string) | string items: Array key: string + // Used for class names and, if label is not provided, for display. + label?: (({ i18n }: { i18n: I18n }) => string) | string } export type SlashMenuItemInternal = SlashMenuItem & { diff --git a/packages/richtext-lexical/src/field/lexical/plugins/SlashMenu/index.tsx b/packages/richtext-lexical/src/field/lexical/plugins/SlashMenu/index.tsx index 65a969410a..fb78bbec44 100644 --- a/packages/richtext-lexical/src/field/lexical/plugins/SlashMenu/index.tsx +++ b/packages/richtext-lexical/src/field/lexical/plugins/SlashMenu/index.tsx @@ -41,10 +41,10 @@ function SlashMenuItem({ } let title = item.key - if (item.displayName) { - title = typeof item.displayName === 'function' ? item.displayName({ i18n }) : item.displayName + if (item.label) { + title = typeof item.label === 'function' ? item.label({ i18n }) : item.label } - // Crop title to max. 50 characters + // Crop title to max. 25 characters if (title.length > 25) { title = title.substring(0, 25) + '...' } @@ -108,9 +108,8 @@ export function SlashMenuPlugin({ 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 + if (item.label) { + itemTitle = typeof item.label === 'function' ? item.label({ i18n }) : item.label } if (new RegExp(queryString, 'gi').exec(itemTitle)) { @@ -192,11 +191,9 @@ export function SlashMenuPlugin({
{groups.map((group) => { let groupTitle = group.key - if (group.displayName) { + if (group.label) { groupTitle = - typeof group.displayName === 'function' - ? group.displayName({ i18n }) - : group.displayName + typeof group.label === 'function' ? group.label({ i18n }) : group.label } return ( diff --git a/packages/richtext-lexical/src/field/lexical/plugins/toolbars/fixed/types.ts b/packages/richtext-lexical/src/field/lexical/plugins/toolbars/fixed/types.ts deleted file mode 100644 index 77afed47ec..0000000000 --- a/packages/richtext-lexical/src/field/lexical/plugins/toolbars/fixed/types.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { BaseSelection, LexicalEditor } from 'lexical' -import type React from 'react' - -export type FixedToolbarGroup = - | { - ChildComponent?: React.FC - items: Array - key: string - order?: number - type: 'dropdown' - } - | { - items: Array - key: string - order?: number - type: 'buttons' - } - -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 - item: FixedToolbarGroupItem - }> - 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 - onClick?: ({ editor, isActive }: { editor: LexicalEditor; isActive: boolean }) => void - order?: number -} diff --git a/packages/richtext-lexical/src/field/lexical/plugins/toolbars/inline/ToolbarDropdown/index.tsx b/packages/richtext-lexical/src/field/lexical/plugins/toolbars/inline/ToolbarDropdown/index.tsx deleted file mode 100644 index aacc94c5c6..0000000000 --- a/packages/richtext-lexical/src/field/lexical/plugins/toolbars/inline/ToolbarDropdown/index.tsx +++ /dev/null @@ -1,68 +0,0 @@ -'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 && ( - - ) - ) - } - - return ( - - {item?.ChildComponent && } - {item.label} - - ) -} - -export const ToolbarDropdown = ({ - Icon, - anchorElem, - classNames, - editor, - groupKey, - items, -}: { - Icon?: React.FC - anchorElem: HTMLElement - classNames?: string[] - editor: LexicalEditor - groupKey: string - items: InlineToolbarGroupItem[] -}) => { - return ( - - {items.length && - items.map((item) => { - return - })} - - ) -} diff --git a/packages/richtext-lexical/src/field/lexical/ui/icons/Add/index.tsx b/packages/richtext-lexical/src/field/lexical/ui/icons/Add/index.tsx new file mode 100644 index 0000000000..89c4c6a6a2 --- /dev/null +++ b/packages/richtext-lexical/src/field/lexical/ui/icons/Add/index.tsx @@ -0,0 +1,8 @@ +import React from 'react' + +export const AddIcon: React.FC = () => ( + + + + +) diff --git a/packages/richtext-lexical/src/index.ts b/packages/richtext-lexical/src/index.ts index f4022e72b7..bfde95b1a9 100644 --- a/packages/richtext-lexical/src/index.ts +++ b/packages/richtext-lexical/src/index.ts @@ -340,7 +340,7 @@ export { BoldFeature } from './field/features/format/bold/feature.server.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 { toolbarFormatGroupWithItems } from './field/features/format/shared/toolbarFormatGroup.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' @@ -384,8 +384,8 @@ export { SlateRelationshipConverter } from './field/features/migrations/slateToL 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, @@ -394,8 +394,8 @@ 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 { RelationshipFeature, @@ -408,8 +408,14 @@ 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 { toolbarAddDropdownGroupWithItems } from './field/features/shared/toolbar/addDropdownGroup.js' +export { toolbarFeatureButtonsGroupWithItems } from './field/features/shared/toolbar/featureButtonsGroup.js' + +export { toolbarTextDropdownGroupWithItems } from './field/features/shared/toolbar/textDropdownGroup.js' +export { FixedToolbarFeature } from './field/features/toolbars/fixed/feature.server.js' +export { InlineToolbarFeature } from './field/features/toolbars/inline/feature.server.js' + +export type { ToolbarGroup, ToolbarGroupItem } from './field/features/toolbars/types.js' export { createNode } from './field/features/typeUtilities.js' export type { ClientComponentProps, @@ -434,10 +440,10 @@ export type { ServerFeature, ServerFeatureProviderMap, } from './field/features/types.js' + export { UploadFeature } from './field/features/upload/feature.server.js' export type { UploadFeatureProps } from './field/features/upload/feature.server.js' - export { $createUploadNode, $isUploadNode, @@ -445,6 +451,7 @@ export { type UploadData, UploadNode, } from './field/features/upload/nodes/UploadNode.js' + export { EditorConfigProvider, useEditorConfigContext, @@ -462,30 +469,26 @@ export { loadFeatures, sortFeaturesForOptimalLoading, } from './field/lexical/config/server/loader.js' + export { sanitizeServerEditorConfig, sanitizeServerFeatures, } from './field/lexical/config/server/sanitize.js' - export type { ClientEditorConfig, SanitizedClientEditorConfig, SanitizedServerEditorConfig, ServerEditorConfig, } from './field/lexical/config/types.js' -export { getEnabledNodes } from './field/lexical/nodes/index.js' +export { getEnabledNodes } from './field/lexical/nodes/index.js' export { ENABLE_SLASH_MENU_COMMAND } from './field/lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/index.js' +export type { AdapterProps } + export type { SlashMenuGroup, SlashMenuItem, } from './field/lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.js' -export type { AdapterProps } - -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' diff --git a/test/fields/collections/Lexical/e2e.spec.ts b/test/fields/collections/Lexical/e2e.spec.ts index 07e56a3be9..7bf92162d6 100644 --- a/test/fields/collections/Lexical/e2e.spec.ts +++ b/test/fields/collections/Lexical/e2e.spec.ts @@ -213,11 +213,9 @@ describe('lexical', () => { await expect(floatingToolbar_formatSection).toBeVisible() - await expect(page.locator('.inline-toolbar-popup__button').first()).toBeVisible() + await expect(page.locator('.toolbar-popup__button').first()).toBeVisible() - const boldButton = floatingToolbar_formatSection - .locator('.inline-toolbar-popup__button') - .first() + const boldButton = floatingToolbar_formatSection.locator('.toolbar-popup__button').first() await expect(boldButton).toBeVisible() await boldButton.click() @@ -443,11 +441,9 @@ describe('lexical', () => { await expect(floatingToolbar_formatSection).toBeVisible() - await expect(page.locator('.inline-toolbar-popup__button').first()).toBeVisible() + await expect(page.locator('.toolbar-popup__button').first()).toBeVisible() - const boldButton = floatingToolbar_formatSection - .locator('.inline-toolbar-popup__button') - .first() + const boldButton = floatingToolbar_formatSection.locator('.toolbar-popup__button').first() await expect(boldButton).toBeVisible() await boldButton.click() @@ -521,7 +517,7 @@ describe('lexical', () => { await expect(floatingToolbar).toBeVisible() - const linkButton = floatingToolbar.locator('.inline-toolbar-popup__button-link').first() + const linkButton = floatingToolbar.locator('.toolbar-popup__button-link').first() await expect(linkButton).toBeVisible() await linkButton.click() diff --git a/test/fields/collections/Lexical/index.ts b/test/fields/collections/Lexical/index.ts index cc92bfa5d2..bae89a94be 100644 --- a/test/fields/collections/Lexical/index.ts +++ b/test/fields/collections/Lexical/index.ts @@ -2,6 +2,7 @@ import type { CollectionConfig } from 'payload/types' import { BlocksFeature, + FixedToolbarFeature, HeadingFeature, LinkFeature, TreeViewFeature, @@ -71,6 +72,7 @@ export const LexicalFields: CollectionConfig = { //TestRecorderFeature(), TreeViewFeature(), //HTMLConverterFeature(), + FixedToolbarFeature(), LinkFeature({ fields: ({ defaultFields }) => [ ...defaultFields,