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/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/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>;