diff --git a/packages/core/src/blocks/Quote/block.test.ts b/packages/core/src/blocks/Quote/block.test.ts new file mode 100644 index 0000000000..a25ad37907 --- /dev/null +++ b/packages/core/src/blocks/Quote/block.test.ts @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { insertOrUpdateBlockForSlashMenu } from "../../extensions/SuggestionMenu/getDefaultSlashMenuItems.js"; + +let editor: BlockNoteEditor | undefined; +let container: HTMLDivElement | undefined; + +afterEach(() => { + editor?.unmount(); + container?.remove(); +}); + +describe("pasting into an empty quote (#2236)", () => { + it.each(["text", "html"] as const)( + "preserves the quote when pasting %s", + (format) => { + container = document.createElement("div"); + document.body.append(container); + editor = BlockNoteEditor.create({ + initialContent: [{ id: "source", type: "paragraph" }], + }); + editor.mount(container); + editor.setTextCursorPosition("source", "start"); + insertOrUpdateBlockForSlashMenu(editor, { type: "quote" }); + + if (format === "text") { + editor.pasteText("Pasted paragraph"); + } else { + editor.pasteHTML("

Pasted paragraph

"); + } + + expect(editor.document[0]).toMatchObject({ + type: "quote", + content: [{ type: "text", text: "Pasted paragraph", styles: {} }], + }); + expect( + editor.document.filter( + (block) => Array.isArray(block.content) && block.content.length > 0, + ), + ).toHaveLength(1); + }, + ); +}); diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.test.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.test.ts new file mode 100644 index 0000000000..f52cb1bee3 --- /dev/null +++ b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.test.ts @@ -0,0 +1,136 @@ +// @vitest-environment node +import { TextSelection } from "@tiptap/pm/state"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { insertOrUpdateBlockForSlashMenu } from "./getDefaultSlashMenuItems.js"; + +let editor: BlockNoteEditor | undefined; + +afterEach(() => { + editor?._tiptapEditor.destroy(); +}); + +describe("slash-menu block conversion (#2236)", () => { + it("converts the existing paragraph to a quote at its start", () => { + editor = BlockNoteEditor.create({ + initialContent: [ + { id: "source", type: "paragraph", content: "Existing text" }, + ], + }); + // The suggestion menu removes its slash/query before invoking the item. + editor.setTextCursorPosition("source", "start"); + + insertOrUpdateBlockForSlashMenu(editor, { type: "quote" }); + + expect(editor.document).toHaveLength(1); + expect(editor.document[0]).toMatchObject({ + id: "source", + type: "quote", + content: [{ type: "text", text: "Existing text", styles: {} }], + }); + expect(editor.getTextCursorPosition().block.id).toBe("source"); + expect(editor.prosemirrorState.selection.empty).toBe(true); + expect(editor.prosemirrorState.selection.$from.parentOffset).toBe(0); + }); + + it.each([ + ["middle", 8], + ["end", 13], + ] as const)( + "inserts a new quote below when invoked at the paragraph's %s", + (_position, offset) => { + editor = BlockNoteEditor.create({ + initialContent: [ + { id: "source", type: "paragraph", content: "Existing text" }, + ], + }); + // The suggestion menu removes its slash/query before invoking the item. + editor.setTextCursorPosition("source", "start"); + editor.transact((tr) => + tr.setSelection( + TextSelection.create(tr.doc, tr.selection.from + offset), + ), + ); + + insertOrUpdateBlockForSlashMenu(editor, { type: "quote" }); + + expect(editor.document).toHaveLength(2); + expect(editor.document[0]).toMatchObject({ + id: "source", + type: "paragraph", + content: [{ type: "text", text: "Existing text", styles: {} }], + }); + expect(editor.document[1]).toMatchObject({ type: "quote", content: [] }); + expect(editor.getTextCursorPosition().block.id).toBe( + editor.document[1].id, + ); + expect(editor.prosemirrorState.selection.empty).toBe(true); + expect(editor.prosemirrorState.selection.$from.parentOffset).toBe(0); + }, + ); + + it("still inserts below when the item supplies replacement content", () => { + editor = BlockNoteEditor.create({ + initialContent: [ + { id: "source", type: "paragraph", content: "Existing text" }, + ], + }); + editor.setTextCursorPosition("source", "start"); + + insertOrUpdateBlockForSlashMenu(editor, { + type: "quote", + content: "New text", + }); + + expect(editor.document.map((block) => block.type)).toEqual([ + "paragraph", + "quote", + ]); + expect(editor.document[0].content).toEqual([ + { type: "text", text: "Existing text", styles: {} }, + ]); + expect(editor.document[1].content).toEqual([ + { type: "text", text: "New text", styles: {} }, + ]); + }); + + it("enters a table replacing an empty paragraph before other content", () => { + editor = BlockNoteEditor.create({ + initialContent: [ + { id: "source", type: "paragraph" }, + { id: "following", type: "paragraph", content: "Following text" }, + ], + }); + editor.setTextCursorPosition("source", "start"); + + insertOrUpdateBlockForSlashMenu(editor, { + type: "table", + content: { type: "tableContent", rows: [{ cells: ["Cell"] }] }, + }); + + expect(editor.getTextCursorPosition().block).toMatchObject({ + id: "source", + type: "table", + }); + }); + + it("still inserts non-inline blocks below existing text", () => { + editor = BlockNoteEditor.create({ + initialContent: [ + { id: "source", type: "paragraph", content: "Existing text" }, + ], + }); + editor.setTextCursorPosition("source", "start"); + + insertOrUpdateBlockForSlashMenu(editor, { type: "divider" }); + + expect(editor.document.slice(0, 2).map((block) => block.type)).toEqual([ + "paragraph", + "divider", + ]); + expect(editor.document[0].content).toEqual([ + { type: "text", text: "Existing text", styles: {} }, + ]); + }); +}); diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts index 30497bb8de..e788b4fde3 100644 --- a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts @@ -53,10 +53,10 @@ function setSelectionToNextContentEditableBlock< } } -// Checks if the current block is empty or only contains a slash, and if so, -// updates the current block instead of inserting a new one below. If the new -// block doesn't contain editable content, the cursor is moved to the next block -// that does. +// Updates an empty/slash-only block, or converts an inline-content block when +// choosing another inline type at the start of the block, instead of inserting +// below. If the new block doesn't contain editable content, the cursor is moved +// to the next block that does. export function insertOrUpdateBlockForSlashMenu< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -73,19 +73,39 @@ export function insertOrUpdateBlockForSlashMenu< let newBlock: Block; + const { selection } = editor.prosemirrorState; + // The menu has already removed the slash/query. With a collapsed cursor, + // choosing an inline block type at the start converts the current block. + // At other positions, insert a new block below without changing existing text. + // Only convert between inline-content types when no replacement content was + // supplied, so the existing text is preserved; other items insert below. + const convertsExistingText = + selection.empty && + selection.$from.parentOffset === 0 && + editor.schema.blockSchema[currentBlock.type].content === "inline" && + block.type !== undefined && + editor.schema.blockSchema[block.type].content === "inline" && + block.content === undefined; + if ( - Array.isArray(currentBlock.content) && - ((currentBlock.content.length === 1 && - isStyledTextInlineContent(currentBlock.content[0]) && - currentBlock.content[0].type === "text" && - currentBlock.content[0].text === "/") || - currentBlock.content.length === 0) + convertsExistingText || + (Array.isArray(currentBlock.content) && + ((currentBlock.content.length === 1 && + isStyledTextInlineContent(currentBlock.content[0]) && + currentBlock.content[0].type === "text" && + currentBlock.content[0].text === "/") || + currentBlock.content.length === 0)) ) { newBlock = editor.updateBlock(currentBlock, block); - // We make sure to reset the cursor position to the new block as calling - // `updateBlock` may move it out. This generally happens when the content - // changes, or the update makes the block multi-column. - editor.setTextCursorPosition(newBlock); + // Preserve the mapped caret for same-content-type updates. A structural + // change (e.g. inline text to a table or columns) can map it outside the + // updated block, so explicitly enter the new block in that case. + if ( + editor.schema.blockSchema[currentBlock.type].content !== + editor.schema.blockSchema[newBlock.type].content + ) { + editor.setTextCursorPosition(newBlock); + } } else { newBlock = editor.insertBlocks([block], currentBlock, "after")[0]; editor.setTextCursorPosition(editor.getTextCursorPosition().nextBlock!); diff --git a/packages/xl-multi-column/src/extensions/SuggestionMenu/getMultiColumnSlashMenuItems.test.tsx b/packages/xl-multi-column/src/extensions/SuggestionMenu/getMultiColumnSlashMenuItems.test.tsx index 0ab7e8c047..2a39d8e413 100644 --- a/packages/xl-multi-column/src/extensions/SuggestionMenu/getMultiColumnSlashMenuItems.test.tsx +++ b/packages/xl-multi-column/src/extensions/SuggestionMenu/getMultiColumnSlashMenuItems.test.tsx @@ -55,6 +55,11 @@ describe("Test getMultiColumnSlashMenuItems", () => { // The empty paragraph becomes the column list, and keeps its id. expect(getEditor().getBlock("empty-paragraph")?.type).toBe("columnList"); + const cursorBlock = getEditor().getTextCursorPosition().block; + const column = getEditor().getParentBlock(cursorBlock); + expect(cursorBlock.type).toBe("paragraph"); + expect(column?.type).toBe("column"); + expect(getEditor().getParentBlock(column!)?.id).toBe("empty-paragraph"); expect(getEditor().document).toMatchSnapshot(); }); }); diff --git a/tests/src/end-to-end/y-prosemirror/nesting.test.tsx b/tests/src/end-to-end/y-prosemirror/nesting.test.tsx index 620f926321..76aafb6ac9 100644 --- a/tests/src/end-to-end/y-prosemirror/nesting.test.tsx +++ b/tests/src/end-to-end/y-prosemirror/nesting.test.tsx @@ -97,6 +97,10 @@ test("suggestion mode: change block type of a block with children", async () => // TODO: should this be editor.document[0], or expose .documentWithoutDeletions? await expect.poll(() => editor.document[1]?.type).toBe("heading"); + // The type updates before the asynchronous suggestion decorations settle. + // Wait for the final attributed structure before capturing its styling. + await waitForSuggestion(editor); + await expectScreenshot( screen.getByTestId("editor-root"), "nesting-change-parent-type",