diff --git a/.changeset/ai.md b/.changeset/ai.md
new file mode 100644
index 0000000000..a329f1684b
--- /dev/null
+++ b/.changeset/ai.md
@@ -0,0 +1,10 @@
+---
+'@udecode/plate-ai': minor
+---
+
+- `api.aiChat.replaceSelection()` – new option `format: 'none' | 'single' | 'all'`
+ - `'single'` (default):
+ - Single block: Applies block's formatting to inserted content
+ - Multiple blocks: Preserves source formatting
+ - `'all'`: Forces first block's formatting on all inserted blocks
+ - `'none'`: Preserves source formatting completely
diff --git a/.changeset/friendly-cooks-add.md b/.changeset/friendly-cooks-add.md
new file mode 100644
index 0000000000..4d7b6c72d6
--- /dev/null
+++ b/.changeset/friendly-cooks-add.md
@@ -0,0 +1,5 @@
+---
+'@udecode/slate-utils': patch
+---
+
+Add `getFirstNodeText`
diff --git a/.changeset/happy-buses-glow.md b/.changeset/happy-buses-glow.md
new file mode 100644
index 0000000000..b6386419e4
--- /dev/null
+++ b/.changeset/happy-buses-glow.md
@@ -0,0 +1,5 @@
+---
+'@udecode/slate': patch
+---
+
+export type NodeTextsOptions
diff --git a/.changeset/list.md b/.changeset/list.md
new file mode 100644
index 0000000000..9b43cd4f38
--- /dev/null
+++ b/.changeset/list.md
@@ -0,0 +1,5 @@
+---
+'@udecode/plate-indent-list': patch
+---
+
+Fix todo list: insert break should inherit format
diff --git a/apps/www/content/docs/ai.mdx b/apps/www/content/docs/ai.mdx
index 7d5ae429eb..1ac7861857 100644
--- a/apps/www/content/docs/ai.mdx
+++ b/apps/www/content/docs/ai.mdx
@@ -280,7 +280,7 @@ Template for system messages. Supports same placeholders as `promptTemplate`.
## API
-### editor.aiChat.accept()
+### api.aiChat.accept()
Accepts the current AI suggestion:
@@ -288,7 +288,7 @@ Accepts the current AI suggestion:
- Hides the AI chat interface
- Focuses the editor
-### editor.aiChat.insertBelow()
+### api.aiChat.insertBelow()
Inserts AI content below the current block.
@@ -303,7 +303,7 @@ Handles both block selection and normal selection modes:
- In block selection: Inserts after the last selected block
- In normal selection: Inserts after the current block
-### editor.aiChat.replaceSelection()
+### api.aiChat.replaceSelection()
Replaces the current selection with AI content.
@@ -311,14 +311,22 @@ Replaces the current selection with AI content.
Editor containing the content to replace with.
+
+
+
+ When true, applies the first block's formatting to all inserted blocks. Defaults to false.
+
+
+
-Handles both block selection and normal selection modes:
+Handles different selection modes:
-- In block selection: Replaces all selected blocks
-- In normal selection: Replaces the current selection
+- Single block selection: Replaces the selected block, applying its formatting to all inserted content
+- Multiple block selection: Replaces all selected blocks, preserving the original formatting unless `forceUniformFormatting` is enabled
+- Normal selection: Replaces the current selection while maintaining surrounding context
-### editor.aiChat.reset()
+### api.aiChat.reset()
Resets the chat state:
@@ -326,7 +334,7 @@ Resets the chat state:
- Clears chat messages
- Removes all AI nodes from the editor
-### editor.aiChat.submit()
+### api.aiChat.submit()
Submits a prompt to generate AI content.
@@ -355,7 +363,7 @@ In insert mode, undoes previous AI changes before submitting.
## Transforms
-### editor.ai.insertNodes()
+### tf.ai.insertNodes()
Inserts AI-generated nodes with the AI mark.
@@ -372,7 +380,7 @@ Inserts AI-generated nodes with the AI mark.
-### editor.ai.removeMarks()
+### tf.ai.removeMarks()
Removes AI marks from nodes in the specified location.
@@ -386,7 +394,7 @@ Removes AI marks from nodes in the specified location.
-### editor.ai.removeNodes()
+### tf.ai.removeNodes()
Removes nodes that have the AI mark.
@@ -400,7 +408,7 @@ Removes nodes that have the AI mark.
-### editor.ai.undo()
+### tf.ai.undo()
Special undo operation for AI changes:
diff --git a/apps/www/content/docs/api/slate-utils.mdx b/apps/www/content/docs/api/slate-utils.mdx
index a7126f8fe3..608175c536 100644
--- a/apps/www/content/docs/api/slate-utils.mdx
+++ b/apps/www/content/docs/api/slate-utils.mdx
@@ -130,6 +130,22 @@ Retrieves a consistent property value from a fragment of nodes.
The consistent property value found in the fragment, or undefined if no consistent value is found.
+### getFirstNodeText
+
+Gets the first text node from a node.
+
+
+
+ The root node to search for text nodes.
+
+
+ Options for getting node texts.
+
+
+
+ Returns the first text node entry or `undefined` if no text nodes are found.
+
+
### getLastChild
Returns the last child of a node or `null` if no children.
diff --git a/apps/www/src/registry/default/components/editor/transforms.ts b/apps/www/src/registry/default/components/editor/transforms.ts
index 1efd7d71ee..f1bf98dd57 100644
--- a/apps/www/src/registry/default/components/editor/transforms.ts
+++ b/apps/www/src/registry/default/components/editor/transforms.ts
@@ -25,6 +25,7 @@ import { TocPlugin } from '@udecode/plate-heading/react';
import { INDENT_LIST_KEYS, ListStyleType } from '@udecode/plate-indent-list';
import { IndentListPlugin } from '@udecode/plate-indent-list/react';
import { insertColumnGroup, toggleColumnGroup } from '@udecode/plate-layout';
+import { ColumnItemPlugin, ColumnPlugin } from '@udecode/plate-layout/react';
import { LinkPlugin, triggerFloatingLink } from '@udecode/plate-link/react';
import { insertEquation, insertInlineEquation } from '@udecode/plate-math';
import {
@@ -44,9 +45,22 @@ import {
MediaEmbedPlugin,
VideoPlugin,
} from '@udecode/plate-media/react';
-import { TablePlugin, insertTable } from '@udecode/plate-table/react';
+import {
+ TableCellPlugin,
+ TablePlugin,
+ TableRowPlugin,
+ insertTable,
+} from '@udecode/plate-table/react';
import { Path } from 'slate';
+export const STRUCTURAL_TYPES = [
+ ColumnPlugin.key,
+ ColumnItemPlugin.key,
+ TablePlugin.key,
+ TableRowPlugin.key,
+ TableCellPlugin.key,
+];
+
const ACTION_THREE_COLUMNS = 'action_three_columns';
const insertList = (editor: PlateEditor, type: string) => {
diff --git a/apps/www/src/registry/default/plate-ui/turn-into-dropdown-menu.tsx b/apps/www/src/registry/default/plate-ui/turn-into-dropdown-menu.tsx
index 6bf33c1185..fdb8881937 100644
--- a/apps/www/src/registry/default/plate-ui/turn-into-dropdown-menu.tsx
+++ b/apps/www/src/registry/default/plate-ui/turn-into-dropdown-menu.tsx
@@ -30,6 +30,7 @@ import {
} from 'lucide-react';
import {
+ STRUCTURAL_TYPES,
getBlockType,
setBlockType,
} from '@/registry/default/components/editor/transforms';
@@ -119,6 +120,7 @@ export function TurnIntoDropdownMenu(props: DropdownMenuProps) {
const value = useSelectionFragmentProp({
defaultValue: ParagraphPlugin.key,
getProp: (node) => getBlockType(node as any),
+ structuralTypes: STRUCTURAL_TYPES,
});
const selectedItem = React.useMemo(
() =>
diff --git a/packages/ai/src/react/ai-chat/transforms/replaceSelectionAIChat.ts b/packages/ai/src/react/ai-chat/transforms/replaceSelectionAIChat.ts
index 9a2398425c..eb438a3015 100644
--- a/packages/ai/src/react/ai-chat/transforms/replaceSelectionAIChat.ts
+++ b/packages/ai/src/react/ai-chat/transforms/replaceSelectionAIChat.ts
@@ -1,7 +1,12 @@
-import type { PlateEditor } from '@udecode/plate-common/react';
-
-import { isEditorEmpty, withNewBatch } from '@udecode/plate-common';
-import { focusEditor } from '@udecode/plate-common/react';
+import {
+ type TElement,
+ getFirstNodeText,
+ getNodeProps,
+ isEditorEmpty,
+ isText,
+ withNewBatch,
+} from '@udecode/plate-common';
+import { type PlateEditor, focusEditor } from '@udecode/plate-common/react';
import {
BlockSelectionPlugin,
removeBlockSelectionNodes,
@@ -12,7 +17,8 @@ import type { AIChatPluginConfig } from '../AIChatPlugin';
export const replaceSelectionAIChat = (
editor: PlateEditor,
- sourceEditor: PlateEditor
+ sourceEditor: PlateEditor,
+ { format = 'single' }: { format?: 'all' | 'none' | 'single' } = {}
) => {
if (!sourceEditor || isEditorEmpty(sourceEditor)) return;
@@ -23,11 +29,21 @@ export const replaceSelectionAIChat = (
editor.getApi({ key: 'ai' }).aiChat.hide();
- if (isBlockSelecting) {
- const firstBlockPath = editor
- .getApi(BlockSelectionPlugin)
- .blockSelection.getNodes()[0][1];
+ // If no blocks selected, treat it like a normal selection replacement
+ if (!isBlockSelecting) {
+ editor.insertFragment(sourceEditor.children);
+ focusEditor(editor);
+
+ return;
+ }
+ const blockSelectionApi = editor.getApi(BlockSelectionPlugin).blockSelection;
+ const selectedBlocks = blockSelectionApi.getNodes();
+
+ if (selectedBlocks.length === 0) return;
+ // If format is 'none' or multiple blocks with 'single',
+ // just insert the content as is
+ if (format === 'none' || (format === 'single' && selectedBlocks.length > 1)) {
editor.withoutNormalizing(() => {
removeBlockSelectionNodes(editor);
@@ -37,13 +53,63 @@ export const replaceSelectionAIChat = (
.blockSelection.insertBlocksAndSelect(
cloneDeep(sourceEditor.children),
{
- at: firstBlockPath,
+ at: selectedBlocks[0][1],
}
);
});
});
- } else {
- editor.insertFragment(sourceEditor.children);
+
focusEditor(editor);
+
+ return;
}
+
+ // Apply formatting from first block when:
+ // - formatting is 'all', or
+ // - only one block is selected
+ const [firstBlockNode, firstBlockPath] = selectedBlocks[0];
+ const firstBlockProps = getNodeProps(firstBlockNode);
+
+ // Get formatting from first text node
+ const firstTextEntry = getFirstNodeText(firstBlockNode as TElement);
+
+ if (!firstTextEntry) return;
+
+ const textProps = getNodeProps(firstTextEntry[0]);
+
+ // Apply text props recursively to text nodes
+ const applyTextProps = (node: any): any => {
+ if (isText(node)) {
+ return { ...textProps, ...node };
+ }
+ if (node.children) {
+ return {
+ ...node,
+ children: node.children.map(applyTextProps),
+ };
+ }
+
+ return node;
+ };
+
+ editor.withoutNormalizing(() => {
+ removeBlockSelectionNodes(editor);
+
+ withNewBatch(editor, () => {
+ // Create new blocks with first block's formatting
+ const newBlocks = cloneDeep(sourceEditor.children).map((block) => ({
+ ...block,
+ ...firstBlockProps,
+ children: block.children.map(applyTextProps),
+ }));
+
+ editor
+ .getTransforms(BlockSelectionPlugin)
+ .blockSelection.insertBlocksAndSelect(newBlocks, {
+ at: firstBlockPath,
+ });
+ });
+ });
+
+ focusEditor(editor);
};
diff --git a/packages/indent-list/src/lib/normalizers/withInsertBreakIndentList.spec.tsx b/packages/indent-list/src/lib/normalizers/withInsertBreakIndentList.spec.tsx
new file mode 100644
index 0000000000..1ffdf13027
--- /dev/null
+++ b/packages/indent-list/src/lib/normalizers/withInsertBreakIndentList.spec.tsx
@@ -0,0 +1,119 @@
+/** @jsx jsxt */
+
+import { type SlateEditor, BaseParagraphPlugin } from '@udecode/plate-common';
+import { createPlateEditor } from '@udecode/plate-common/react';
+import { IndentPlugin } from '@udecode/plate-indent/react';
+import { jsxt } from '@udecode/plate-test-utils';
+
+import {
+ BaseIndentListPlugin,
+ INDENT_LIST_KEYS,
+} from '../BaseIndentListPlugin';
+
+jsxt;
+
+describe('withInsertBreakIndentList', () => {
+ it('should insert a new todo list line with the same formatting', () => {
+ const input = (
+
+
+ Todo item
+
+
+
+ ) as any as SlateEditor;
+
+ const output = (
+
+
+ Todo item
+
+
+
+
+
+ ) as any as SlateEditor;
+
+ const editor = createPlateEditor({
+ editor: input,
+ plugins: [BaseParagraphPlugin, IndentPlugin, BaseIndentListPlugin],
+ });
+
+ editor.insertBreak();
+
+ expect(editor.children).toEqual(output.children);
+ });
+
+ it('should behave like a normal break if not a todo line', () => {
+ const input = (
+
+
+ Disc item
+
+
+
+ ) as any as SlateEditor;
+
+ const output = (
+
+
+ Disc item
+
+
+
+
+
+ ) as any as SlateEditor;
+
+ const editor = createPlateEditor({
+ editor: input,
+ plugins: [BaseParagraphPlugin, IndentPlugin, BaseIndentListPlugin],
+ });
+
+ editor.insertBreak();
+
+ expect(editor.children).toEqual(output.children);
+ });
+
+ it('should behave like a normal break if selection is expanded', () => {
+ const input = (
+
+
+ Todo
+ item
+
+
+
+ ) as any as SlateEditor;
+
+ const output = (
+
+
+ Todo
+
+
+
+
+
+ ) as any as SlateEditor;
+
+ const editor = createPlateEditor({
+ editor: input,
+ plugins: [BaseParagraphPlugin, IndentPlugin, BaseIndentListPlugin],
+ });
+
+ editor.insertBreak();
+
+ expect(editor.children).toEqual(output.children);
+ });
+});
diff --git a/packages/indent-list/src/lib/normalizers/withInsertBreakIndentList.ts b/packages/indent-list/src/lib/normalizers/withInsertBreakIndentList.ts
index aeacde9ac7..5697985686 100644
--- a/packages/indent-list/src/lib/normalizers/withInsertBreakIndentList.ts
+++ b/packages/indent-list/src/lib/normalizers/withInsertBreakIndentList.ts
@@ -1,12 +1,12 @@
import {
type ExtendEditor,
type TElement,
- BaseParagraphPlugin,
getAboveNode,
- insertNodes,
isDefined,
isEndPoint,
isExpanded,
+ setNodes,
+ withoutNormalizing,
} from '@udecode/plate-common';
import {
@@ -25,23 +25,32 @@ export const withInsertBreakIndentList: ExtendEditor = ({
if (!nodeEntry) return insertBreak();
- const [node] = nodeEntry;
+ const [node, path] = nodeEntry;
if (
!isDefined(node[BaseIndentListPlugin.key]) ||
node[BaseIndentListPlugin.key] !== INDENT_LIST_KEYS.todo ||
// https://github.com/udecode/plate/issues/3340
isExpanded(editor.selection) ||
- !isEndPoint(editor, editor.selection?.focus, nodeEntry[1])
- )
+ !isEndPoint(editor, editor.selection?.focus, path)
+ ) {
return insertBreak();
-
- insertNodes(editor, {
- [BaseIndentListPlugin.key]: INDENT_LIST_KEYS.todo,
- checked: false,
- children: [{ text: '' }],
- indent: node.indent,
- type: BaseParagraphPlugin.key,
+ }
+
+ withoutNormalizing(editor, () => {
+ insertBreak();
+
+ const newEntry = getAboveNode(editor);
+
+ if (newEntry) {
+ setNodes(
+ editor,
+ {
+ checked: false,
+ },
+ { at: newEntry[1] }
+ );
+ }
});
};
diff --git a/packages/slate-utils/src/queries/getFirstNodeText.ts b/packages/slate-utils/src/queries/getFirstNodeText.ts
new file mode 100644
index 0000000000..b8dc01ad42
--- /dev/null
+++ b/packages/slate-utils/src/queries/getFirstNodeText.ts
@@ -0,0 +1,17 @@
+import {
+ type NodeTextsOptions,
+ type TNode,
+ type TextOf,
+ getNodeTexts,
+} from '@udecode/slate';
+
+/** Get the first text node from a node. */
+export const getFirstNodeText = , R extends TNode = TNode>(
+ root: R,
+ options?: NodeTextsOptions
+) => {
+ const texts = getNodeTexts(root, options);
+ const firstTextEntry = texts.next().value;
+
+ return firstTextEntry ?? undefined;
+};
diff --git a/packages/slate-utils/src/queries/index.ts b/packages/slate-utils/src/queries/index.ts
index 0ba55547b0..7500609a33 100644
--- a/packages/slate-utils/src/queries/index.ts
+++ b/packages/slate-utils/src/queries/index.ts
@@ -8,6 +8,7 @@ export * from './getBlockAbove';
export * from './getBlocks';
export * from './getChildren';
export * from './getEdgeBlocksAbove';
+export * from './getFirstNodeText';
export * from './getFragmentProp';
export * from './getLastChild';
export * from './getLastNodeByLevel';
diff --git a/packages/slate/src/interfaces/node/getNodeTexts.ts b/packages/slate/src/interfaces/node/getNodeTexts.ts
index 655032f2cf..bce519e066 100644
--- a/packages/slate/src/interfaces/node/getNodeTexts.ts
+++ b/packages/slate/src/interfaces/node/getNodeTexts.ts
@@ -1,19 +1,24 @@
import type { Modify } from '@udecode/utils';
-import { type NodeTextsOptions, Node } from 'slate';
+import { type NodeTextsOptions as SlateNodeTextsOptions, Node } from 'slate';
import type { TextOf } from '../text/TText';
import type { NodeOf, TNode } from './TNode';
import type { TNodeEntry } from './TNodeEntry';
+export type NodeTextsOptions<
+ N extends TextOf,
+ R extends TNode = TNode,
+> = Modify<
+ NonNullable,
+ {
+ pass?: (entry: TNodeEntry>) => boolean;
+ }
+>;
+
/** Return a generator of all leaf text nodes in a root node. */
export const getNodeTexts = , R extends TNode = TNode>(
root: R,
- options?: Modify<
- NonNullable,
- {
- pass?: (entry: TNodeEntry>) => boolean;
- }
- >
+ options?: NodeTextsOptions
) =>
Node.texts(root, options as any) as Generator, void, undefined>;